Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ coverage.xml
.ipynb_checkpoints/
*.ipynb~


/docs/reverse_eng/infivision_rec_format.ipynb

# OS
.DS_Store
Thumbs.db
Expand Down
10 changes: 6 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ ruff check src/ tests/
ruff format src/ tests/
```

Both checks run automatically on every push via GitHub Actions.
GitHub Actions runs `ruff check` and `ruff format --check` on every pull request
to `dev` or `main`, and on every push to those branches.

## Running tests

Expand All @@ -43,7 +44,8 @@ pytest tests/
```

Tests that require real patient files are skipped automatically if the files
are not present (they are gitignored).
are not present (they are gitignored). Use `pytest -rs` to see which tests were
skipped and why.

## Adding a new parser

Expand All @@ -53,11 +55,11 @@ the testing checklist.

## Pull request process

Please ensure to discuss about your idea with an Github issue before opening a PR.
Please open a GitHub issue to discuss your idea before opening a PR.

1. Fork the repository and create a branch from `dev`
2. Make your changes with tests
3. Ensure `ruff check` and `pytest` pass locally
3. Ensure `ruff check`, `ruff format --check` and `pytest` pass locally
4. Open a PR targeting `dev` (not `main`)
5. Describe what changed and why

Expand Down
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ PulmoVista 500. Provides structured data containers for research in mechanical v
## Installation

**Users:**

```bash
pip install git+https://github.com/Kernel236/fastEIT.git
```

**Developers:**

```bash
git clone https://github.com/Kernel236/fastEIT.git
cd fastEIT
Expand All @@ -40,7 +42,7 @@ print(data.global_signal[:10]) # first 10 frames global EIT signal
data = load_data("patient01.eit")
print(data.n_frames, data.fs) # e.g. 11500, 50.0
print(data.measurements.shape) # (11500, 208) — 208 = 16 inj × 13 meas
print(list(data.aux_signals.keys())) # timestamp, I_real, V_diff, medibus, ...
print(list(data.aux_signals.keys())) # timestamp, trans_A, trans_B, I_real, V_diff, medibus, ...

# Dräger .asc — continuous frame-by-frame signal export
data = load_data("patient01.asc")
Expand All @@ -61,7 +63,7 @@ Pre-alpha. Data model and parsing layer implemented.

| Class | Content | Produced by |
|-------|---------|-------------|
| `ReconstructedFrameData` | 32×32 pixel matrices + synchronized signals | `.bin`, `.txt` |
| `ReconstructedFrameData` | 32×32 pixel matrices + synchronized signals | Dräger `.bin`, Timpel `.csv`/`.txt`/`.asc` |
| `ContinuousSignalData` | Signal table, one row per frame | `.asc` |
| `RawImpedanceData` | Calibrated transimpedances + aux signals for pyEIT | `.eit` |

Expand All @@ -72,14 +74,17 @@ Pre-alpha. Data model and parsing layer implemented.
| `DragerBinParser` | Implemented | `.bin` — base frame (4358 b) and PressurePod frame (4382 b); registry-driven, new frame sizes require only a dtype + one entry |
| `DragerAscParser` | Implemented | `.asc`, `.txt`, `.csv` — continuous waveform export |
| `DragerEitParser` | Implemented | `.eit` — ASCII header + binary frames (5495 b/frame); 208 calibrated transimpedances + Medibus aux signals |
| `TimpelTabularParser` | Implemented | `.csv`, `.txt` — reconstructed frame export |
| `TimpelTabularParser` | Implemented | `.csv`, `.txt`, `.asc` — reconstructed frame export |

All parsers are accessible via the single entry point `load_data(path)`, which
auto-detects vendor and format.

`RawImpedanceData` from `.eit` files can be reconstructed to 32×32 pixel images via
which is an example of pyEIT wrapping `reconstruct_greit()` (optional dependency: `pip install fasteit[pyeit]`; implements
GREIT — Adler et al., *Physiol. Meas.* 2009, DOI: 10.1088/0967-3334/30/6/S03):
`RawImpedanceData` from `.eit` files can be passed to pyEIT through
`reconstruct_greit()` (optional dependency: `pip install fasteit[pyeit]`; implements
GREIT — Adler et al., *Physiol. Meas.* 2009, DOI: 10.1088/0967-3334/30/6/S03).
This function is far from reliable: it was written only to show one possible
application of the `.eit` parser. It uses a generic circular mesh, so the images do
not match the Dräger reconstruction stored in `.bin` files.

```python
from fasteit.parsers.draeger.eit.eit_pyeit_bridge import reconstruct_greit
Expand Down
11 changes: 6 additions & 5 deletions docs/data_model.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,17 @@ duration float n_frames / fs (0.0 if fs is None)

```
frames np.ndarray Structured array shape (N,). Named fields:
ts float64 timestamp (seconds)
ts float64 timestamp (unit depends on vendor, see below)
pixels float32 32×32 image per frame
Dräger .bin only:
min_max_flag int32 +1=insp peak, -1=exp trough, 0=none
event_marker int32 event counter
event_text S30 null-padded ASCII event string
event_text S30 ASCII event string, space- or null-padded
timing_error int32 0 = ok

aux_signals dict|None Named arrays, each shape (N,), frame-aligned with frames.
Dräger .bin: 52 or 58 Medibus channels.
Dräger .bin: 52 or 58 Medibus channels, sentinel values
left unchanged (replaced in preprocessing).
Timpel: 6 device channels (airway_pressure, flow,
volume, min_flag, max_flag, qrs_flag — cols 1024-1029).
None if the source file carries no auxiliary signals.
Expand All @@ -64,7 +65,7 @@ is applied in the preprocessing layer.

| Vendor | `ts` field | Unit |
|--------|-----------|------|
| Dräger `.bin` | wall-clock fraction-of-day from device | seconds (converted from float64 day fraction) |
| Dräger `.bin` | wall-clock time from device, stored as-is | fraction of day (× 86400 for seconds) |
| Timpel `.csv` | synthetic: `(frame_index + first_frame) / 50.0` | seconds from start |

---
Expand All @@ -89,7 +90,7 @@ table pd.DataFrame One row per EIT frame. Snake_case column names.
## 4. `RawImpedanceData`

**File:** `models/raw_impedance_data.py`
**Produced by:** `DragerEitParser` (`.eit`); `TimpelRawParser` (`.x`) — scaffold
**Produced by:** `DragerEitParser` (`.eit`)

```
measurements np.ndarray shape (N_frames, 208)
Expand Down
64 changes: 41 additions & 23 deletions docs/parsers.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ For **architecture and extension recipes** see [`parsing_layer.md`](parsing_laye
| `.asc` / `.txt` / `.csv` | Dräger | `DragerAscParser` | `ContinuousSignalData` |
| `.eit` | Dräger | `DragerEitParser` | `RawImpedanceData` |
| `.csv` / `.txt` / `.asc` | Timpel | `TimpelTabularParser` | `ReconstructedFrameData` |
| `.x` | Timpel | `TimpelRawParser` *(scaffold)* | `RawImpedanceData` |

All parsers inherit from `BaseParser` and are accessible via `load_data(path)`.

Expand All @@ -34,35 +33,46 @@ Two frame formats are registered; detection is size-based (no header).
| Name | Frame size | Medibus channels | Notes |
|------|-----------|-----------------|-------|
| `Draeger_base_4358` | 4358 bytes | 52 | Standard export |
| `Draeger_ext_4382` | 4382 bytes | 58 | PressurePod: adds esophageal + transpulmonary pressure |
| `Draeger_ext_4382` | 4382 bytes | 58 | PressurePod variant: BiLevel parameters + 4 Pod pressure waveforms |

### Binary frame layout (base format)

| Field | Type | Size | Content |
|-------|------|------|---------|
| `ts` | float64 | 8 B | Wall-clock timestamp (fraction of day × 86400 → seconds) |
| `ts` | float64 | 8 B | Wall-clock timestamp as fraction of day (kept as-is; × 86400 gives seconds) |
| `dummy` | float32 | 4 B | Unknown — ignored |
| `pixels` | float32 × 1024 | 4096 B | Reconstructed 32×32 image (C-order) |
| `min_max_flag` | int32 | 4 B | +1 = inspiratory peak, −1 = expiratory trough, 0 = none |
| `event_marker` | int32 | 4 B | Event counter |
| `event_text` | S30 | 30 B | ASCII event label, space-padded |
| `event_text` | S30 | 30 B | ASCII event label, padded with spaces (or nulls on frames without an event) |
| `timing_error` | int32 | 4 B | 0 = frame timing OK |
| `medibus_data` | float32 × 52 | 208 B | Ventilator Medibus channels; sentinel −3.4×10³⁸ = disconnected |

PressurePod format (`Draeger_ext_4382`) adds 6 extra Medibus channels (indices 52–57):
esophageal pressure (Pes), airway pressure (Paw), transpulmonary pressure (Ptp),
and three auxiliary pressure waveforms from the Pod hardware.
The two formats share Medibus indices 0–50 and differ from index 51 onward:

### Sentinel handling
| Index | Base (52 channels) | PressurePod (58 channels) |
|-------|--------------------|---------------------------|
| 51 | `time_at_low_pressure` | `high_pressure` (BiLevel PHigh) |
| 52 | — | `low_pressure` (BiLevel Plow) |
| 53 | — | `time_at_low_pressure` (BiLevel Tlow) |
| 54–57 | — | Pod waveforms: `airway_pressure_pod`, `esophageal_pressure_pod`, `transpulmonary_pressure_pod`, `gastric_pressure_pod` |

Medibus channels disconnected from the ventilator carry the sentinel value
`−3.4×10³⁸` (IEEE 754 bit pattern `0xFF7FC99E`). The parser replaces these
with `NaN` so downstream code can use `np.isnan()` to detect disconnected channels.
Full field list with units: `parsers/draeger/bin/draeger_dtypes.py`.

### Sentinel values

Disconnected Medibus channels carry the sentinel `−3.4×10³⁸` (IEEE 754 bit
pattern `0xFF7FC99E`); BiLevel fields (51–53 in the PressurePod format) read
`−1000.0` when the ventilator is not in BiLevel mode. The parser returns these
values unchanged. Replacing them with `NaN` is a preprocessing step;
`bin_utils.replace_no_data_sentinels()` is available for it.

### Sampling frequency

Not stored in the binary. Estimated from consecutive timestamp differences:
`fs = 1 / median(diff(ts))`, rounded to the nearest integer Hz.
Not stored in the binary. Estimated from the timestamps:
`fs = (N − 1) / (t_last − t_first)`, with timestamps converted to seconds and
unwrapped across midnight, rounded to the nearest integer Hz. Falls back to
50 Hz (recorded in `metadata["warnings"]`) when the estimate fails.

---

Expand All @@ -71,7 +81,7 @@ Not stored in the binary. Estimated from consecutive timestamp differences:
**File:** `parsers/draeger/eit/eit_parser.py`

Reads raw transimpedance measurements from the PulmoVista 500 proprietary
binary format. This is the **only open-source Python parser for this format**.
binary format. We are not aware of another open-source Python parser for it.

### File structure

Expand Down Expand Up @@ -155,8 +165,9 @@ measurement (step = 1 electrode).
Reads the PulmoVista 500 continuous waveform export (tab-separated text).
Returns `ContinuousSignalData` with one row per EIT frame.

Detection: looks for `"Dräger"` or `"Draeger"` keyword in the first 40 lines.
Sampling frequency estimated from the `time` column if present.
Detection: looks for the `DraegerEIT Software` header string in the first 40 lines
(read as latin-1). Sampling frequency is estimated from the `time` column
(fraction of day) when present, otherwise `fs` is `None`.

Typical columns: `image`, `time`, `global`, `local_1_x_16_y_29` … `local_4_x_16_y_05`,
`minmax`, `event`, `eventtext`, `timing_error`, plus Medibus channel columns when
Expand All @@ -172,16 +183,19 @@ Reads Timpel EIT device tabular exports. Each row is one EIT frame;
columns 0–1023 are the 32×32 pixel values (C-order); columns 1024–1029
are auxiliary signals (airway_pressure, flow, volume, min_flag, max_flag, qrs_flag).

Returns `ReconstructedFrameData`. Sampling frequency defaults to 50 Hz if a
`first_frame` column is present but timing cannot be derived otherwise.
Returns `ReconstructedFrameData`. The export has no timestamp column, so `fs`
is fixed at 50 Hz and `ts` is generated as `(frame_index + first_frame) / 50`.

Detection: looks for `"Timpel"` keyword in the first 40 lines.
Detection: the keyword `timpel` in the first 40 lines, or a first data row with
at least 1029 separators (comma, tab or semicolon), i.e. the 1030-column layout.

---

## 6. GREIT reconstruction bridge

This function is far to be reliable. It's just an example how to bridge on recustrustion library such as pyEIT stasting from fastEIT parsing layer output.
This function is far from reliable. It was written only to show one possible
application of the `.eit` parser output: passing it to a reconstruction library
such as pyEIT.

**File:** `parsers/draeger/eit/eit_pyeit_bridge.py`
**Optional dependency:** `pip install fasteit[pyeit]`
Expand Down Expand Up @@ -232,7 +246,8 @@ sourced from PulmoVista 500 IFU SW 1.3n:
proprietary reconstruction. Both algorithms produce correct relative distributions,
but the vendor `.bin` uses a device-specific mesh and matrix. A data-driven
reconstruction that learns the Dräger mapping from paired `.eit`/`.bin` recordings
is provided in `fasteit.reconstruction` (see `feat/ml-reconstruction`).
is being developed on the `feat/ml-reconstruction` branch and is not part of this
release.

---

Expand All @@ -243,7 +258,10 @@ is provided in `fasteit.reconstruction` (see `feat/ml-reconstruction`).
### `load_data(path) → BaseData`

Single entry point. Auto-detects vendor and format, selects the right parser,
returns the appropriate container. Raises `ValueError` on unknown format.
returns the appropriate container. Raises `ValueError` for an unsupported
extension or unrecognised content, `NotImplementedError` when no parser is
registered for the detected vendor/extension, and a `ParserError` subclass for
`.bin` files with an unknown or ambiguous frame size.

```python
from fasteit.parsers.loader import load_data
Expand All @@ -266,7 +284,7 @@ recordings = load_many(["patient01.bin", "patient02.bin", "patient01.asc"])
### `load_folder(folder, pattern="*") → list[BaseData]`

Scans a directory, parses every file with a registered parser.
Files that raise any error are silently skipped (warning to stderr).
Files that raise an error are skipped and a one-line message is printed to stderr.

```python
from fasteit.parsers.loader import load_folder
Expand Down
31 changes: 16 additions & 15 deletions docs/parsing_layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This document explains the architecture of the parsing layer and provides
step-by-step recipes for the most common extension tasks:

1. [How the parsing layer works](#1-how-the-parsing-layer-works)
2. [Add a new Dräger `.bin` frame size](#2-add-a-new-drger-bin-frame-size)
2. [Add a new Dräger `.bin` frame size](#2-add-a-new-dräger-bin-frame-size)
3. [Add a new parser](#3-add-a-new-parser)
4. [Testing checklist](#4-testing-checklist)
5. [File layout](#5-file-layout)
Expand Down Expand Up @@ -43,7 +43,7 @@ The strategy depends on the extension:
|-----------|----------|--------------|
| `.bin` | size-based | `file_size % frame_size == 0` for each registered `FormatSpec` |
| `.eit` | header-based | reads first bytes, looks for ASCII magic string for each registered `HeaderFormatSpec` |
| `.asc` `.txt` `.csv` | content-based | reads first 40 lines, looks for vendor keyword |
| `.asc` `.txt` `.csv` | content-based | reads first 40 lines, looks for a vendor keyword; Timpel is also recognised by its 1030-column row |

`.bin` is special: there is no header. The only way to identify the format is
that the file size must be an exact multiple of the frame size in bytes. The
Expand Down Expand Up @@ -130,9 +130,9 @@ recordings = load_many([
```

**`load_folder(folder, pattern="*")`** — scans a directory (alphabetically),
parses every file that has a registered parser, and **silently skips** files
that raise any error (no registered parser, malformed file, wrong extension).
Skipped files print a one-line warning to `stderr` but never abort the scan.
parses every file that has a registered parser, and **skips** files that raise
an error (no registered parser, malformed file, wrong extension). Each skipped
file prints a one-line message to `stderr`; the scan is never aborted.

```python
from fasteit.parsers.loader import load_folder
Expand Down Expand Up @@ -242,7 +242,8 @@ differs by format type.
| Format | Return type | Example |
|--------|-------------|---------|
| Binary frame sequence | `ReconstructedFrameData` | Dräger `.bin`, AcmEIT `.acm` |
| Tabular (CSV / TXT) | `ContinuousSignalData` | Dräger `.asc`, Hamilton export, SERVO-U export, capnograph |
| Tabular (CSV / TXT), signals only | `ContinuousSignalData` | Dräger `.asc`, Hamilton export, SERVO-U export, capnograph |
| Tabular with pixel columns | `ReconstructedFrameData` | Timpel `.csv` |

Tabular parsers are common for any device that exports to a plain-text table —
including standalone ventilators (Hamilton, Maquet SERVO, Dräger Evita) that
Expand Down Expand Up @@ -316,7 +317,7 @@ Then add the `HeaderFormatSpec` to `HEADER_FORMAT_SPECS` as above.

**Binary without a header (size-based, `.bin`)**

Add a `FormatSpec` to `BIN_FORMAT_SPECS` (see [section 2](#2-add-a-new-drger-bin-frame-size)).
Add a `FormatSpec` to `BIN_FORMAT_SPECS` (see [section 2](#2-add-a-new-dräger-bin-frame-size)).
No changes to `detection.py` needed.

---
Expand Down Expand Up @@ -394,7 +395,8 @@ class AcmEitParser(BaseParser):
return True

def parse(self, path: Path) -> ReconstructedFrameData:
# np.memmap with ACMEIT_FRAME_DTYPE, sentinel → NaN, fs estimation ...
# np.memmap with ACMEIT_FRAME_DTYPE, fs estimation ...
# (keep sentinel values as-is: NaN replacement belongs to preprocessing)
return ReconstructedFrameData(
frames=frames,
aux_signals=aux_signals,
Expand Down Expand Up @@ -534,25 +536,25 @@ src/fasteit/
├── base.py BaseParser (validate / parse / parse_safe)
├── bin_formats.py BIN_FORMAT_SPECS — all known .bin frame sizes
├── detection.py detect_vendor_and_format() — routes any file
├── errors.py AmbiguousFormatError, UnknownFormatError
├── header_formats.py EIT_HEADER_FORMATS — known .eit ASCII headers
├── errors.py ParserError and subclasses (UnsupportedFrameSize, AmbiguousFormat, InvalidSlice)
├── header_formats.py HEADER_FORMAT_SPECS — known .eit ASCII headers
├── loader.py load_data / load_many / load_folder
│
├── draeger/
│ ├── asc/
│ │ └── asc_parser.py DragerAscParser — .asc continuous export
│ ├── bin/
│ │ ├── bin_parser.py DragerBinParser — .bin reconstructed frames
│ │ ├── bin_utils.py Sentinel → NaN, fs estimation helpers
│ │ ├── bin_utils.py Frame slicing, fs estimation, sentinel helpers
│ │ └── draeger_dtypes.py FRAME_BASE_DTYPE, FRAME_EXT_DTYPE, MEDIBUS_* fields
│ └── eit/
│ ├── eit_parser.py DragerEitParser — .eit raw transimpedances
│ ├── eit_dtypes.py EIT_FRAME_DTYPE (5495-byte frame layout)
│ ├── eit_utils.py Calibration constants FT_A/FT_B/FC_CURRENT/FV_VOLTAGE
│ ├── eit_dtypes.py FRAME_EIT_DTYPE (5495-byte frame layout)
│ ├── eit_utils.py Header parsing, calibration constants FT_A/FT_B/FC_CURRENT/FV_VOLTAGE
│ └── eit_pyeit_bridge.py build_greit() / reconstruct_greit() — pyEIT bridge
│
└── timpel/
├── timpel_parser.py TimpelTabularParser — .csv/.txt reconstructed frames
├── timpel_parser.py TimpelTabularParser — .csv/.txt/.asc reconstructed frames
└── timpel_dtypes.py TIMPEL_AUX_FIELDS — auxiliary signal columns
```

Expand All @@ -561,4 +563,3 @@ one `<vendor>_parser.py`, one `<vendor>_dtypes.py`, one `__init__.py`.
Then follow steps 1–5 in [section 3](#3-add-a-new-parser).

---

Loading
Loading