NGB Binary Format Reference
Overview
NETZSCH STA NGB files are proprietary binary files containing thermal analysis data. This document describes the reverse-engineered format as pyngb parses it: a ZIP container of streams, each stream a sectioned blob of serialized objects, every object a table of fields following one uniform record grammar.
Everything here was verified empirically against six real fixtures (two
Proteus vintages, 2022 and 2025, including two baseline files) during the
2026-07 format investigation — 25,075 grammar records across all streams with
zero counter-examples. Numbers quoted below (record censuses, span sizes) come
from those fixtures via scripts/make_goldens.py census; regenerate them with
pyngb inspect if the fixture set changes.
⚠️ Disclaimer: This format documentation is based on reverse engineering and may not be complete or accurate for all NGB file versions. This is an unofficial implementation not affiliated with NETZSCH-Gerätebau GmbH.
Byte-level constants live in pyngb.format.grammar and
pyngb.format.container; all declarative field knowledge lives in
pyngb.format.maps. If this document and the code disagree, the code
(pinned by golden tests) wins.
Container
NGB files are ZIP archives containing binary streams:
file.ngb-ss3
├── Streams/
│ ├── stream_1.table # metadata: sample, program, calibrations, MFC, …
│ ├── stream_2.table # primary measurement channels
│ ├── stream_3.table # additional measurement channels (optional)
│ ├── stream_4.table # end-of-run snapshot (not parsed)
│ ├── stream_5.table # furnace usage histograms (not parsed)
│ └── stream_6.table # embedded plot previews (not parsed)
├── Props.xml # stream/section manifest
└── [Content_Types].xml
.ngb-ss3 is a sample run; .ngb-bs3 is a baseline (correction) run with the
same structure. read_ngb requires streams 1 and 2 and uses stream 3 when
present; read_ngb_metadata reads stream 1 only; load_document models any
requested stream, including 4–6.
.ngb-ds3 ("Sample + Correction") uses the same container but packs two
complete measurements per stream, back-to-back: the raw sample run first,
then a verbatim copy of the correction run it was measured against (byte-
identical across every file sharing that correction). In streams 2/3 each
run carries the full channel-header + segment-value sequence; the run
boundary is a channel header repeating a channel already seen, and
count_runs/build_dataframe(doc, run=...) split there. Stream 1 likewise
appends the correction's calibration-context block after a second section
prologue; metadata extraction is bounded to the sample's blocks. Neither run
is subtracted from the other in the stored data — Proteus applies the
correction at display time.
Stream header and section directory
Every stream_N.table starts with two magic strings and a section directory:
| Offset | Content |
|---|---|
| 0x02 | "Netzsch TA file" |
| 0x1C | "_db_format_1" |
| 0x50 | section directory: consecutive 14-byte entries |
Each directory entry:
2022-vintage files terminate the directory with an all-zero entry
(ff ff 00 00 00 00 00 00 00 00 00 00 00 00); 2025-vintage directories simply
stop (the next bytes are not ff ff-prefixed).
Invariants — checked by parse_container, violations raise
NGBCorruptedFileError:
- every entry is prefixed
ff ff; - offsets are strictly increasing and sections are contiguous (each section starts where the previous one ends);
- the last section ends exactly at end-of-file;
- a main section whose id equals the stream number exists.
Each stream also carries a small section 1 (~480–580 bytes): a table-of-contents of class records for the stream (stream 1 folds it into its main section). The measurement content is entirely in the main section.
Record grammar
The streams are MFC CArchive-style object serialization, not loose byte
soup. Every field of every table in every stream follows one grammar:
18 fc ff ff 03 80 01 RECORD_HEADER
<field_id u16 LE>
00 00 01 00 00 00 FIELD_BRIDGE
0c 00 FIELD_KIND (u16, always 0x000C)
17 fc ff ff TYPE_PREFIX
<dtype u8> one of the nine data types below
80 01 <scalar payload> SCALAR mode …
— or —
a0 01 <count u32 LE> <payload> ARRAY mode
<terminator>
The canonical terminator — on every record of the 2022/2025 fixtures — is
the 9-byte END_FIELD:
with the following 00 03 00, where present, covered as a table_trailer
span. The underlying serialization actually ends records with a run of
6-byte trailer units 01 00 00 00 <K u16> — the canonical 12-byte
ending is unit(K=2) + unit(K=3), chunked as 9+3 for historical parity —
and STA-449F3A .ngb-ds3 files write variant runs directly: unit(3)
alone, or unit(4) + unit(3), notably throughout their timezone blocks.
Those three forms are the only ones observed across all fixtures, and the
tokenizer matches exactly them — END_FIELD first, preserving the
canonical segmentation byte-for-byte. Anything else, including a truncated
END_FIELD or an unobserved unit combination, surfaces as a malformed
span, as a drift tripwire.
Two rules with teeth:
- Array counts are element counts, not byte counts: the payload occupies
count × ITEM_SIZE[dtype]bytes. - A scalar's extent is fully determined by its dtype (fixed
ITEM_SIZE, or the string/REF headers below), so the terminator is verified at the computed position, never searched for. Record anchors occurring inside array payloads are therefore harmless — a linear walk never sees them.
String encoding
String payloads (dtype 1f) come in two forms:
or a length-prefixed form:
Decoding is strict in both encodings; an undecodable payload yields None
rather than mangled text.
Data types
Ten dtypes are observed across all fixtures and streams (DType enum;
counts are strict-grammar records summed over the original six fixtures,
all streams):
| dtype | Type | Item size | Records | Notes |
|---|---|---|---|---|
0x00 |
null (empty scalar) | 0 | — | ds3 timezone blocks; decodes to None |
0x02 |
u16 | 2 | 4,525 | |
0x03 |
i32 | 4 | 6,668 | plus the 66 END_FIELD-less "bare records" below |
0x04 |
f32 | 4 | 2,694 | |
0x05 |
f64 | 8 | 828 | |
0x10 |
u8 / byte array | 1 | 1,749 | arrays decode as raw bytes |
0x14 |
packed 8-byte record | 8 | 492 | opaque; e.g. eb03eb03… tokens matching calibration record filenames |
0x1a |
object reference (REF) | variable | 4,518 | table-open records; exactly one per table |
0x1f |
string | variable | 3,589 | |
0x48 |
16-byte hash/GUID | 16 | 12 | stream 6 only |
Numeric arrays decode via np.frombuffer and widen to f64
(.astype('<f8')); scalars decode with precompiled struct.Structs.
Non-record spans
The tokenizer (pyngb.format.grammar.tokenize) is a strict linear walk that
is total: every byte of a section is either part of a decoded
FieldToken or covered by an explicit UnknownSpan — nothing is silently
skipped. Grammar records cover 95–97% of section bytes on every fixture
stream; the residue is exactly these enumerable forms (SpanKind):
| Kind | Size | Count (all fixtures) | Meaning |
|---|---|---|---|
prologue |
64 B | 66 (one per section) | section preamble, starts 02 00 00 80; 6 observed variants differing in 2 bytes |
preamble |
47 B | 4,518 (one per table) | record variant with mode bytes 00 01; not yet semantically decoded |
table_trailer |
3 B | 20,557 | 00 03 00 sequence closing each table |
bare_record |
28 B | 66 (11 per fixture, both vintages) | i32 scalar record with no END_FIELD, directly after a table trailer; field ids 0x0FDE/0x1165 |
malformed |
variable | 0 in healthy files | grammar violation; tokenizer resyncs to the next record anchor |
truncated |
variable | 0 in healthy files | array whose declared extent overruns the section; the walk stops (a broken length forfeits resync trust) |
The tokenizer itself never raises on corruption — severity policy lives in
the consumers (see Corruption semantics).
Its only exception is NGBResourceLimitError for an array whose declared
payload exceeds max_array_size_mb, checked before any allocation.
Table object model
Fields are grouped into tables. A table opens with a REF record (dtype
0x1a) and closes with END_FIELD + the 00 03 00 trailer:
- The open record's field_id is the table's category (u16).
- Its payload starts with either a class back-reference (
01 80= class index 1, etc.) or — first table of a stream — an inline class definitionff ff <schema u16> <name_len u16> <name>(observed name:CDbTable). - The payload ends
02 00 00 80 <type_ref u16> 00 00; the type_ref identifies the table's kind. Class-definition records for non-table classes have no type_ref. - After the open record comes one
preamblespan, then the field records.
Two identity rules matter for extraction:
- Field ids never repeat within a table (
Table.fieldsis a dict keyed by field id). - Categories DO repeat across tables:
0x7530alone is used by the sample table, temperature/sensitivity fixpoint tables, MFC device-parameter tables, and accessory records. Tables are identified by field membership and type_ref, never by category alone.
Known type_refs (the full per-fixture set is pinned by the census goldens):
| type_ref | Table kind |
|---|---|
0x2B22 |
channel header (streams 2/3) |
0x2B23 |
channel segment values (streams 2/3) |
0x0Bxx |
stream-1 metadata table families |
| others | structural/device tables, ignored by extraction |
Stream 1: metadata catalog
Extraction resolves each metadata key against the document with one rule:
the first stream-1 table of the category that carries the field wins
(stream order is semantic). The declarative map (FIELD_MAP in
pyngb.format.maps):
| Metadata key | Category | Field | Conversion |
|---|---|---|---|
instrument |
0x1775 |
0x1059 |
string |
project |
0x1772 |
0x083C |
string |
date_performed |
0x1772 |
0x083E |
Unix → ISO 8601 UTC |
lab |
0x1772 |
0x0834 |
string |
operator |
0x1772 |
0x0835 |
string |
comment |
0x1772 |
0x083D |
string |
crucible_type |
0x177E |
0x0840 |
string |
furnace_type |
0x177A |
0x0840 |
string |
carrier_type |
0x1779 |
0x0840 |
string |
sample_id |
0x7530 |
0x0898 |
string |
sample_name |
0x7530 |
0x0840 |
string |
material |
0x7530 |
0x0962 |
string |
sample_mass |
0x7530 |
0x0C9E |
positive float |
The remaining structures are procedural (each one function in
pyngb.format.extract, all warn-and-continue):
Temperature program
Stage tables (type ref 0x2B0C) are the stream-1 tables carrying all
five stage fields. The table's category encodes the program ordinal:
stage N has category 0x7530 + N, exposed as stage_N. Stream order is
edit order, not program order — programs edited in Proteus serialize out
of order (two fixtures store 0, 2, 3, 4, 1; verified against the recorded
temperature data, which executes in category order):
| Field | Key |
|---|---|
0x083F |
stage_type (i32) — 0 = initial, 1 = ramp/isothermal (heating_rate > 0 vs = 0), 2 = final entry carrying the emergency-reset temperature (a limit, never executed) |
0x0E17 |
temperature (°C) |
0x0E13 |
heating_rate (°C/min) |
0x0E14 |
acquisition_rate |
0x0E15 |
time — stored in minutes, exposed in seconds (×60) |
Each stage table is followed by per-device state snapshots (see the MFC
device tree below) whose flow setpoints are merged into the stage dict as
purge_1_mfc_flow / purge_2_mfc_flow / protective_mfc_flow (ml/min).
PID settings
Tables carrying all three of 0x0FE7 (xp), 0x0FE8 (tn), 0x0FE9 (tv):
the first occurrence in stream order is the furnace controller
(furnace_xp/tn/tv), the second is the sample controller
(sample_xp/tn/tv).
Crucible masses
Two category-0x177E tables each carry field 0x0C9E (f64). The
preceding table discriminates them: a trailing 0x0C83 (f32) field marks
the sample crucible; a trailing 0x10C4 (u16) field marks the reference
crucible. In all observed files the reference crucible comes first in stream
order. reference_mass is recovered as the last numeric scalar of the table
preceding the reference-crucible table.
DSC calibration constants
From the first category-0x01F5 table yielding them: p0=0x044F,
p1=0x0450, p2=0x0451, p3=0x0452, p4=0x0453, p5=0x04C3.
These feed apply_dsc_calibration: sensitivity
y = (P2 + P3·z + P4·z² + P5·z³)·exp(−z²) with z = (T − P0)/P1, converting
the DSC signal µV → mW.
Temperature calibration
Surfaced as the temperature_calibration metadata block plus a sibling
sensitivity_calibration block. Captured for traceability/QA only: the
sample_temperature channel is already temperature-corrected by Proteus, so
re-applying these coefficients would double-correct the data.
Coefficients — three f32 values stored as a dtype-0x10 byte array on
field 0x04BE of the category-0x01F7 table (12 bytes = 3 × f32 LE),
giving [B0, B1, B2].
Fixpoints — the phase-transition standards used for the calibration, one
table per standard, categories 0x7530–0x753F (ascending temperature; real
files carry 6–9 standards — Biphenyl, Benzoeacid, KClO4, Ag2SO4, CsCl,
K2CrO4, BaCO3, …). Because those categories are shared with sample/MFC/DSC
tables, a temperature fixpoint table is confirmed by carrying both
0x0444 and 0x0447 (DSC sensitivity fixpoint tables carry
0x0454/0x0455/0x0456 instead of 0x0444):
| Field | Key | Description |
|---|---|---|
0x0443 |
name |
standard name |
0x0444 |
actual_c |
literature transition temperature (°C) |
0x0445 |
measured_c |
raw measured transition temperature (°C) |
0x0446 |
weight |
regression weight (1.0 in observed files) |
0x0447 |
corrected_c |
measured value after the calibration polynomial |
Relationship (verified by round-trip on all fixtures, residual < 1e-3):
The residual actual_c − corrected_c is the calibration fit error.
DSC sensitivity fixpoints — the enthalpy standards behind the p0–p5
calibration constants, surfaced as sensitivity_calibration.fixpoints. A
second table family in the same 0x7530–0x753F categories (ascending
temperature; 6–8 standards in real files), reusing field ids
0x0443/0x0445–0x0447 with different meanings — the family is
discriminated by carrying 0x0454 and no 0x0444:
| Field | Key | Description |
|---|---|---|
0x0443 |
name |
standard name |
0x0454 |
temperature_c |
transition temperature (°C) |
0x0455 |
enthalpy |
literature transition enthalpy (J/g, endothermic negative) |
0x0456 |
peak_area |
measured DSC peak area (µV·s/mg, sign matches enthalpy) |
0x0445 |
measured_sensitivity |
measured sensitivity point (µV/mW) |
0x0446 |
weight |
regression weight (1.0 in observed files) |
0x0447 |
fitted_sensitivity |
calibration curve at temperature_c (µV/mW) |
Relationships (both exact on every fixpoint of every fixture, residual < 4e-7 — f32 precision):
measured_sensitivity = peak_area / enthalpy
fitted_sensitivity = (P2 + P3·z + P4·z² + P5·z³)·exp(−z²) # z = (temperature_c − P0)/P1
i.e. these standards are the regression behind the calibration_constants
curve used by apply_dsc_calibration, and fitted_sensitivity is that
curve evaluated at each standard's transition temperature. Values were stored
negative (endothermic-negative convention) and are reported as stored.
Record paths and provenance — each external calibration record has one
category-0x01F5 source table, located by the suffix of its path string
field: .ngb-ts3 → temperature_calibration.record_path, .ngb-es3 →
sensitivity_calibration.record_path. The same table carries the conditions
of the calibration run, extracted into both blocks:
| Field | Key | Description |
|---|---|---|
0x083E |
date_measured |
Unix timestamp of the calibration run (→ ISO 8601 UTC) |
0x0431 |
gas |
purge gas used |
0x044C / 0x0433 |
crucible_type |
crucible used (0x044C in the es3 table, 0x0433 in the ts3 table) |
0x0435 |
heating_rate |
heating rate in K/min |
0x083D |
comment |
operator comment on the calibration run |
Run environment
Timezone — the category-0x1859 table is a Windows
TIME_ZONE_INFORMATION-style snapshot: 0x1135 name (string), 0x1134 bias
(i32 minutes, UTC = local + bias), 0x1137 DST bias (i32), 0x1138 state
(i32; 1 = standard, 2 = daylight). Exposed as timezone and
utc_offset_minutes (−(bias + dst_bias) when daylight time is active, else
−bias). date_performed is UTC; this recovers the local wall-clock time of
the run. Files carry many snapshots — every referenced calibration or
correction embeds one from when that file was measured (NETZSCH factory
calibrations appear as German-timezone entries worldwide), and the
calibration-context block precedes the main metadata document in stream 1.
The run's own snapshot is the first one inside the main document (at or
after the first section-prologue table); a calibration measured under a
different DST state than the run makes the first-in-stream table wrong.
Correction file link — the category-0x1770 measurement-definition table
stores, in field 0x0843, the path of the correction file selected for the
run (→ correction_file_path). For sample (.ngb-ss3) runs this identifies
the matching baseline (.ngb-bs3) file; for correction runs it may reference
the related sample or a prior correction run.
MFC gas metadata: the device tree
Every file carries one self-describing device block in stream 1 (identical type refs in the Proteus 7.5 and 8.0 fixtures, sample and baseline files alike). All MFC metadata comes from it — no string matching, no ordinal pairing:
- Device definitions (type ref
0x2B07, categories0x1BAC+), one per device: device id (0x083F, i32), configured gas name (0x0840), gas GUID (0x0C8F), device kind (0x104B, 2 = MFC; kinds 10/8 are non-MFC devices), hardware type (0x1075, 104 ↔ MFC400). The device id → role map is fixed — 30 = purge 1, 31 = purge 2, 32 = protective — confirmed three independent ways on the 2022 fixture (device-parameter names, recorded flow channels, category order). An MFC definition with any other id (a real fourth controller) is logged as a warning, never silently dropped. The gas name →purge_1_mfc_gas, …. - Range table (type ref
0x2B0A, category0x1780), immediately after each MFC definition: full scale0x1048→purge_1_mfc_range, … plus the overrange limit0x104D(full scale × 1.02) and gas correction factor0x104C(not extracted). - Gas record (type ref
0x2B81, category0x1BE4), after the range table: GUID0x17FC(must match the definition's0x0C8F), gas name0x0840, short formula0x0C88→purge_1_mfc_gas_formula, …, and density0x1040(g/l, not extracted). Gas records of the same shape also occur inside calibration-context blocks elsewhere in the stream (plus empty ones at category0x1B58); anchoring on the definitions and GUID-matching keeps those out. - Per-stage states (type ref
0x2B11, same categories as the definitions): after every temperature-program stage table, one state table per device, its following range table carrying that stage's flow setpoint in0x1047(ml/min). These merge into the stage dicts, andpurge_1_mfc_flow/ … is emitted when the flow is uniform across the program's body stages (stage_type1) — the setpoint the run actually used. A 0.0 means the MFC was configured but not flowing (e.g. the O2 controller during an N2-only run). Programs that vary a flow per stage get no scalar key for that MFC.
The 0x7530-category device-parameter tables (type ref 0x2B65;
UTF-16LE name in 0x1062, f32 value in 0x1061, e.g.
Purge 1 MFC_MFC400_LastUsedFlow) are documented but deliberately not
extracted: LastUsedFlow is persisted instrument config — the last flow
ever used on the channel, stale for MFCs the run did not use — and Proteus
8.0.3 writes parameter blocks even for hardware that does not exist
(Purge 3 on a three-MFC instrument).
Application and license
Strings of the category-0x0300 table: the first matching
Version N.N.N → application_version; the longest multiline string →
licensed_to.
Streams 2 and 3: measurement channels
Data streams hold one channel header table per channel followed by that channel's segment value tables — a type_ref state machine, not byte positions, drives assembly:
- Header table — type_ref
0x2B22. The category's low byte is the channel id (header categories are<ch> 17in stream 2 and<ch> 75in stream 3 — the latter collide with segment categories and are disambiguated purely by type_ref). - Segment value tables — type_ref
0x2B23, categories0x7530,0x7531, … (segment ordinals). Each carries exactly one data array: field0x0F40(f64) for f64 channels (time8c, mass90) or field0x0F3D(f32) for f32 channels (temperatures, DSC, flows). Segment arrays concatenate in stream order to form the channel. - The
timechannel is stored in minutes and exposed in seconds (×60). - Channel
0x87is a data-less trailer header (intentionally unmapped); other unmapped channel ids pass through as hex column names. - Structural ~90-byte tables with other type_refs are ignored.
Channel id → column name (CHANNEL_MAP):
| id | Column | id | Column |
|---|---|---|---|
0x8C |
time |
0x30 |
furnace_temperature |
0x8D |
sample_temperature |
0x32 |
furnace_power |
0x8E |
dsc_signal |
0x33 |
h_foil_temperature |
0x90 |
mass |
0x34 |
uc_module |
0x9C |
purge_flow_1 |
0x35 |
environmental_pressure |
0x9D |
purge_flow_2 |
0x36–0x38 |
environmental_acceleration_x/y/z |
0x9E |
protective_flow |
Channel presence varies by configuration: the 2022 fixture has all three MFC
flow channels; the 2025 fixtures lack 0x9D (purge_flow_2) — its flow
setpoint metadata is then the only record of that flow.
Streams 4, 5, 6: modeled but not extracted
load_document tokenizes these fully (dtypes 0x14/0x48 surface as raw
bytes); no metadata or data is extracted from them yet. What they contain
(see FORMAT_FINDINGS.md for the extraction backlog):
- stream_4 (~34 KB): end-of-run snapshot of the same table families as stream 1 — measurement-end timestamp, run counter, acquisition PC FQDN.
- stream_5 (~12 KB): temperature-band residence histograms — furnace usage/wear telemetry.
- stream_6 (~215–283 KB): two embedded Windows EMF vector images (the
Proteus plot previews) inside
0x10E4byte arrays, plus 16-byte dtype-0x48hash records with counts.
Corruption semantics and coverage
Severity policy lives in the consumers, not the tokenizer:
- Data streams (2/3): any
malformedortruncatedspan fails hard —build_dataframeraisesNGBCorruptedFileErrorbefore assembly, as do data-before-header and segment-length mismatches. - Metadata streams (1, 4–6): grammar violations log a warning and become
spans; extraction proceeds (every
FileMetadatafield is optional by contract). - Container integrity failures (bad magic, broken directory,
non-contiguous sections, missing main section) raise
NGBCorruptedFileErrorregardless of stream. - Resource limits (
ParsingConfig:max_stream_size_mbchecked against the ZIP member's declared size before decompression,max_array_size_mbbefore array allocation,max_tables_per_stream) raiseNGBResourceLimitError.
Corruption exceptions carry structured attributes (stream, offset,
table_index, declared, available / limit) rather than encoding
details in prose.
To explore a file's structure, coverage, and unknown fields:
pyngb inspect file.ngb-ss3 # per-table listing
pyngb inspect file.ngb-ss3 --coverage # byte accounting: records vs spans
pyngb inspect file.ngb-ss3 --unknown # unmapped (category, field) census
pyngb inspect a.ngb-ss3 b.ngb-ss3 # cross-file field comparison
NGBDocument.unknown_fields() gives the same unmapped-field census
programmatically — it is the systematic to-do list for future extraction.
Column metadata
Each data column in the output table carries metadata:
| Field | Type | Description |
|---|---|---|
units |
string | measurement units |
processing_history |
list[string] | processing steps applied |
source |
string | data source identifier |
baseline_subtracted |
bool | mass/DSC only |
calibration_applied |
bool | DSC only |
Discovery methodology
This format was reverse-engineered by hex-dump analysis, cross-file comparison, and validation against instrument software output. The single most useful step was recognizing the uniform record grammar: once every byte is either a record or a classified span, unknown fields become an enumerable census instead of a search problem.
To validate parsing correctness: cross-check against NETZSCH software
exports, check physical validity (temperature ranges, mass values), and
compare across files (pyngb inspect multi-file mode diffs scalar fields
across runs).
Contributing format knowledge
If you discover new patterns or corrections:
- Locate the field with
pyngb inspect --unknown/--valuesand document the(category, field_id, dtype)triple and observed values. - Verify against multiple files (both vintages if possible).
- Provide test files if possible, and add the mapping to
pyngb.format.mapswith a golden-test pin.
References
- ZIP format specification (RFC 1951, RFC 1952)
- IEEE 754 floating-point standard
- UTF-8 and UTF-16LE encoding standards
- MFC
CArchiveserialization format (the record grammar's ancestry)