pyNGB Architecture
Overview
pyNGB parses proprietary NETZSCH STA (Simultaneous Thermal Analysis) NGB binary files into structured, analyzable data. Since 0.4.0 the backbone is a strict record-grammar tokenizer: each stream is parsed once, in full, into a queryable document, and every extraction rule is a lookup over that document.
Three invariants shape the design:
- One grammar, one tokenizer — total. Every byte of a stream section is
either a decoded record or an explicit, classified
UnknownSpan; nothing is silently skipped. - Severity policy lives in consumers, not the tokenizer. Data streams hard-error on malformed spans; metadata streams warn and proceed (every metadata field is optional by contract).
- Declarative format knowledge. All byte constants live in one module
(
grammar), all field mappings in another (maps); adding a metadata field means adding a map entry or one plain function, never touching the tokenizer.
System Architecture
Layer Diagram
graph TB
subgraph "Public API Layer"
A[read_ngb / read_ngb_metadata]
B[CLI: convert · inspect · validate]
C[Batch Processing API]
L[load_document]
end
subgraph "Extraction Layer"
E[extract.py — build_metadata]
F[channels.py — build_dataframe]
G[census.py — document_census]
end
subgraph "Document Layer"
D[document.py — NGBDocument / Table / Field]
M[maps.py — declarative format knowledge]
end
subgraph "Tokenizer Layer"
T[grammar.py — tokenize, decode]
end
subgraph "Container Layer"
K[container.py — open_ngb, section directory]
end
A --> E
A --> F
B --> A
B --> G
C --> A
L --> D
A --> L
E --> D
F --> D
G --> D
E --> M
F --> M
D --> T
T --> K
The pyngb.format package is strictly layered: each module imports only
from layers below it. ParsingConfig (resource limits) threads through every
layer as the optional limits= argument.
The pyngb.format package
| Module | Responsibility |
|---|---|
container.py |
ZIP opening with error translation, pre-decompression max_stream_size_mb check, magic validation, section-directory parsing with hard integrity checks (contiguity, EOF, main section present). Returns StreamData per stream. |
grammar.py |
Single source of byte-level truth: record header/END_FIELD constants, DType/Mode enums, ITEM_SIZE, string decoders, scalar/array decoding, and tokenize() — the strict linear walk emitting FieldToken | UnknownSpan. Never raises on corruption (only NGBResourceLimitError on oversized declared arrays). |
document.py |
Assembles tokens into Table objects (category, type_ref, unique-keyed fields) and the queryable NGBDocument (find/first/by_category/unknown_fields/defects). load_document() is the public entry point. |
maps.py |
ALL declarative format knowledge: FIELD_MAP (metadata key ↔ category/field), CHANNEL_MAP, type_ref constants, named field-id groups (PID, stages, calibration, MFC, …). Frozen module-level tables — source edits are the extension point. |
extract.py |
build_metadata(doc) -> FileMetadata: applies FIELD_MAP, then eight plain extractor functions in a tuple, each wrapped in a warn-and-continue net. Adding one function to the tuple = adding an extraction domain. |
channels.py |
build_dataframe(doc) -> pl.DataFrame: a type_ref state machine over streams 2/3 — channel header tables open channels, segment-value tables append data arrays. Gates hard on any malformed/truncated span in a data stream. |
census.py |
document_census(doc): per-stream record/span/coverage accounting and the unknown-field census; powers pyngb inspect and the structural test goldens. |
See Binary Format for the grammar itself, the table
object model, and the field catalog that maps.py mirrors.
Data Flow
sequenceDiagram
participant User
participant read_ngb
participant container as open_ngb
participant tokenizer as tokenize
participant document as NGBDocument
participant extract as build_metadata
participant channels as build_dataframe
User->>read_ngb: read_ngb("file.ngb-ss3")
read_ngb->>container: streams 1,2,3 (retry 1,2 if 3 absent)
container-->>read_ngb: StreamData (validated sections)
read_ngb->>tokenizer: each main section
tokenizer-->>document: FieldTokens + UnknownSpans
document-->>read_ngb: NGBDocument
read_ngb->>extract: build_metadata(doc)
extract-->>read_ngb: FileMetadata
read_ngb->>channels: build_dataframe(doc)
channels-->>read_ngb: Polars DataFrame
read_ngb-->>User: PyArrow Table (+ metadata)
read_ngbloads streams 1–3 (stream 3 optional), attaches column metadata and the BLAKE2bfile_hash, and optionally performs baseline subtraction.read_ngb_metadatais the fast path: stream 1 only, samebuild_metadata, no data assembly. A parity test guarantees the two paths never drift.load_documentexposes the document layer itself — any stream, including the unextracted 4–6 — for programmatic exploration.
Key Design Decisions
Why a tokenizer instead of pattern matching?
Earlier versions hunted per-field byte patterns over concatenated stream
bytes. That carried a structural false-match risk (a pattern can match inside
an unrelated array payload) and could not enumerate what it didn't know.
The tokenizer inverts this: parse everything once, strictly, then extract by
keyed lookup. Unknown fields become a census (NGBDocument.unknown_fields(),
pyngb inspect --unknown) instead of a search problem, and format drift
fails loudly in the structural test suite.
Why PyArrow + Polars?
PyArrow: zero-copy interop with Arrow-based tools, efficient Parquet I/O, schema-level and column-level metadata embedding.
Polars: fast Rust-based DataFrame assembly during parsing, native Arrow interchange.
Why frozen dataclasses and NamedTuples for the model?
StreamData, Table, NGBDocument are frozen dataclasses; FieldToken,
UnknownSpan, Field, MetaField are NamedTuples. The document is an
immutable snapshot of the file — extraction cannot accidentally mutate it,
and instances are safe to share across the metadata and data paths.
@dataclass(frozen=True, slots=True)
class ParsingConfig:
max_stream_size_mb: int = 1000
max_tables_per_stream: int = 10000
max_array_size_mb: int = 500
Why validator composition?
QualityChecker composes independent validators (StructureValidator,
TemperatureValidator, MassValidator, DSCValidator) into one
ValidationResult — each independently testable, easy to add or remove, and
a validator crash becomes a finding rather than aborting the run.
Performance Considerations
Parsing Speed
- Tokenizer: memoryview-based linear walk, precompiled
struct.Structunpackers bound to locals,bytes.findresync, END_FIELD verified at the count-computed position (no scanning). - Arrays decode via
np.frombufferand are lazy:Field.array()decodes on demand, uncached — channel assembly consumes each array exactly once. - The metadata-only path never tokenizes data streams;
read_ngbnever loads streams 4–6.
Benchmarks
Typical performance on modern hardware (398 KB fixture, medians):
read_ngb: ~30 ms;read_ngb_metadata: ~18 ms- Batch processing: dozens of files/second, scaling with worker count
- DTG calculation: ~50 ms (10,000 points); full validation: ~100 ms
Run uv run python scripts/benchmarks.py for current numbers.
Memory Efficiency
- Declared ZIP-member sizes checked before decompression (decompression-bomb guard); declared array sizes checked before allocation.
- Zero-copy memoryview slices from stream blob to field payload.
Error Handling Strategy
Exception Hierarchy
NGBParseError (base)
├── NGBCorruptedFileError # container integrity, data-stream grammar
│ # violations, channel-assembly mismatches
├── NGBStreamNotFoundError # required stream missing from the archive
├── NGBResourceLimitError # ParsingConfig limit exceeded (pre-allocation)
└── NGBDataTypeError # unknown/invalid data type
NGBCorruptedFileError carries structured attributes (stream, offset,
table_index, declared, available); NGBResourceLimitError carries
(stream, offset, declared, limit). Tests and callers assert on
types and attributes, not message prose.
Severity policy
- Streams 2/3 (data): malformed or truncated spans are fatal
(
NGBCorruptedFileError) — silently wrong columns are worse than no columns. - Stream 1 (metadata): grammar violations warn and extraction continues; each extractor is individually wrapped so one failure cannot take down the rest.
- Batch processing isolates per-file errors; validation findings never stop processing.
Testing Strategy
- Parity goldens: full metadata + per-column hashes for all six real fixtures, pinned with zero tolerances, asserted through both parse paths.
- Structural tests: byte-coverage accounting (every gap byte classified), dtype/census goldens, unknown-field census as a format-drift tripwire.
- Builder-based unit tests:
tests/support/ngb_builder.pyconstructs valid NGB bytes; a duality property (tokenize(build(x)) == x) keeps the builder honest against the real grammar. - Corruption matrix: truncations, count overruns, directory corruption, oversized declarations — each asserting exception type + attributes.
- Property-based tests: round-trips (bitwise float equality) and
random/mutation fuzzing (any outcome other than a result or
NGBParseErroris a bug). - Performance gates: parse-time ceilings in
test_performance.py.
Coverage gate: ≥86% (currently ~92%). See tests/README.md.
Future Considerations
The document layer is the extension mechanism. Planned directions
(tracked in FORMAT_FINDINGS.md):
- Extraction from streams 4–6 (end-of-run snapshot, furnace telemetry,
embedded EMF plot previews) — each is one function added to
extract.py's tuple or one API function over the document. - DSC sensitivity fixpoints, consumables catalog, session identity.
- Baseline auto-discovery via the extracted
correction_file_path.