Skip to content

fujilib.registry

The register map and everything it is described in terms of (design §5). The generated register map lists every register.

fujilib.registry.registers

The ZP-series register map: :class:RegisterSpec and the registry (design §5.1).

The map is written in Python, generated from the manual's stride patterns (for c in 1..5, for r in 1..2), so it reads like the manual's tables and mypy checks it. It is the single source of truth for addresses, allowed function codes, value domains, scaling, safety tiers and evidence; docs/registers.md is generated from it.

Addresses are 0-based relative addresses, exactly as on the wire. Page references are PDF page numbers of the manuals, which match the page markers of the text extracts (the printed page numbers differ).

What is writable. A holding register is writable only when its declaration gives it a write tier. That is the reviewed subset of design §5.4: documented, not contradicted by the bench unit, not an option, and testable on the bench analyzer. Every other register, holding or input, is read-only. For a writable register minimum and maximum are the limits of a write, the narrower of the two manuals' where they disagree (the MODBUS manual defers setting ranges to the instruction manual, TN5A1190a p.28).

The whole map is validated at import (:func:validate_map) and a violation fails loudly as :class:~fujilib.errors.FujiConfigurationError:

  • names are unique;
  • no two entries overlap within a table;
  • every entry lies inside a region for each function code it lists;
  • every writable entry lies inside the frozen write envelope, is one word, can be written with FC06, and is one of the reviewed settings of :data:~fujilib.registry.write_policy.REVIEWED_SETTINGS;
  • only documented registers are writable, and only above READ_ONLY;
  • enum, limit and scaling metadata are consistent.

Access

Bases: StrEnum

Whether fujilib may write a register. Operation commands are not registers.

LogField dataclass

LogField(
    name,
    offset,
    dtype,
    doc,
    count=1,
    enum=None,
    scaling=_NONE,
)

One field of a log record, at offset words from the record start.

LogSpec dataclass

LogSpec(
    name,
    group,
    table,
    base,
    record_words,
    records,
    fields,
    evidence,
    manual_ref,
    doc,
    channels=1,
    channel_stride=0,
    requires=Capability.NONE,
    notes="",
)

A log of fixed-width records, newest first (design §2.6).

channels regions of records records each start channel_stride words apart; record i (0 = newest) of channel c starts at base + channel_stride * (c - 1) + record_words * i.

last_address property

last_address

The last address of the whole log.

words_per_channel property

words_per_channel

Words in one channel's region.

record_address

record_address(channel, index)

First address of record index (0 = newest) of channel (1-based).

Raises:

Type Description
FujiValidationError

channel or index is out of range.

Source code in src/fujilib/registry/registers.py
def record_address(self, channel: int, index: int) -> int:
    """First address of record ``index`` (0 = newest) of ``channel`` (1-based).

    Raises:
        FujiValidationError: ``channel`` or ``index`` is out of range.
    """
    if not 1 <= channel <= self.channels or not 0 <= index < self.records:
        msg = f"{self.name}: no record {index} for channel {channel}"
        raise FujiValidationError(msg)
    return self.base + self.channel_stride * (channel - 1) + self.record_words * index

RegisterRegistry dataclass

RegisterRegistry(specs, logs, regions)

The validated register map, indexed by name and by address.

Construction validates the map (:func:validate_map). The indexes are read-only.

at

at(table, address)

The register occupying address of table, if any.

Source code in src/fujilib/registry/registers.py
def at(self, table: RegisterTable, address: int) -> RegisterSpec | None:
    """The register occupying ``address`` of ``table``, if any."""
    return self._by_address.get((table, address))

groups

groups()

Register group names, in map order.

Source code in src/fujilib/registry/registers.py
def groups(self) -> tuple[str, ...]:
    """Register group names, in map order."""
    return tuple(dict.fromkeys(s.group for s in self.specs))

has

has(name)

Whether name is a register of the map. Never raises.

Source code in src/fujilib/registry/registers.py
def has(self, name: str) -> bool:
    """Whether ``name`` is a register of the map. Never raises."""
    return name in self._by_name

in_table

in_table(table)

Every register of table, in address order.

Source code in src/fujilib/registry/registers.py
def in_table(self, table: RegisterTable) -> tuple[RegisterSpec, ...]:
    """Every register of ``table``, in address order."""
    return tuple(s for s in self.specs if s.table is table)

log

log(name)

Return the log named name.

Raises:

Type Description
FujiValidationError

no log has that name.

Source code in src/fujilib/registry/registers.py
def log(self, name: str) -> LogSpec:
    """Return the log named ``name``.

    Raises:
        FujiValidationError: no log has that name.
    """
    try:
        return self._logs_by_name[name]
    except KeyError:
        msg = f"unknown log {name!r}"
        raise FujiValidationError(msg, context=ErrorContext(extra={"name": name})) from None

resolve

resolve(name)

Return the canonical spec named name.

Raises:

Type Description
FujiValidationError

no register has that name.

Source code in src/fujilib/registry/registers.py
def resolve(self, name: str) -> RegisterSpec:
    """Return the canonical spec named ``name``.

    Raises:
        FujiValidationError: no register has that name.
    """
    try:
        return self._by_name[name]
    except KeyError:
        close = difflib.get_close_matches(name, self._by_name, n=3)
        hint = f"; did you mean {', '.join(close)}?" if close else ""
        msg = f"unknown register {name!r}{hint}"
        raise FujiValidationError(msg, context=ErrorContext(extra={"name": name})) from None

select

select(prefix)

Every register whose name is prefix or starts with prefix., in map order.

Source code in src/fujilib/registry/registers.py
def select(self, prefix: str) -> tuple[RegisterSpec, ...]:
    """Every register whose name is ``prefix`` or starts with ``prefix.``, in map order."""
    return tuple(s for s in self.specs if s.name == prefix or s.name.startswith(f"{prefix}."))

RegisterSpec dataclass

RegisterSpec(
    name,
    group,
    table,
    address,
    dtype,
    access,
    read_functions,
    write_functions,
    safety,
    evidence,
    manual_ref,
    doc,
    count=1,
    scaling=_NONE,
    unit=None,
    minimum=None,
    maximum=None,
    write_values=None,
    write_percent_fs=None,
    enum=None,
    channel=None,
    range=None,
    alarm=None,
    requires=Capability.NONE,
    notes="",
)

One register (or a multi-word value) of the map.

minimum and maximum are raw limits, before scaling; for a writable register they are the limits of a write. unit is a fixed display unit ("s", "%FS"); a scaled concentration takes its unit from the same source as its decimals. notes records where the manuals disagree or the bench unit contradicts them.

last_address property

last_address

The last address the value occupies.

register_number property

register_number

The 40001- or 30001-based register number, for humans and docs.

writable property

writable

Whether fujilib may ever write this register.

write_percent_fs class-attribute instance-attribute

write_percent_fs = None

For a range-scaled setting: the limits of a write, in percent of the range's full scale.

write_values class-attribute instance-attribute

write_values = None

The raw values a write may use, where fewer than the limits allow.

Scaling dataclass

Scaling(kind, decimals=None)

A scaling rule. The concentration unit comes from the same place as the decimals.

ScalingKind

Bases: StrEnum

Where a register's decimal-point position (and concentration unit) comes from.

BY_ALARM_TARGET class-attribute instance-attribute

BY_ALARM_TARGET = 'by_alarm_target'

The range registers of the alarm's target channel, whose encoding is contested.

BY_RANGE class-attribute instance-attribute

BY_RANGE = 'by_range'

The (channel, range) decimal-point and unit registers, 31087-31096 and 31067-31076.

FIXED class-attribute instance-attribute

FIXED = 'fixed'

A constant number of decimals (the calibration deviation is %FS x 10).

INLINE class-attribute instance-attribute

INLINE = 'inline'

The two registers that follow it, read in the same block (concentrations).

NONE class-attribute instance-attribute

NONE = 'none'

An unscaled count, switch or time.

validate_map

validate_map(specs, logs, regions)

Validate a register map; see the module docstring for the rules.

Takes the map as arguments so tests can feed it deliberately broken tables.

Raises:

Type Description
FujiConfigurationError

the first rule the map breaks.

Source code in src/fujilib/registry/registers.py
def validate_map(
    specs: Sequence[RegisterSpec],
    logs: Sequence[LogSpec],
    regions: RegionMap,
) -> None:
    """Validate a register map; see the module docstring for the rules.

    Takes the map as arguments so tests can feed it deliberately broken tables.

    Raises:
        FujiConfigurationError: the first rule the map breaks.
    """
    names: set[str] = set()
    spans: list[tuple[RegisterTable, int, int, str]] = []
    for spec in specs:
        if spec.name in names:
            _fail(f"duplicate register name {spec.name!r}", spec.table, spec.address)
        names.add(spec.name)
        _check_spec(spec, regions)
        spans.append((spec.table, spec.address, spec.last_address, spec.name))
    for log in logs:
        if log.name in names:
            _fail(f"duplicate name {log.name!r}", log.table, log.base)
        names.add(log.name)
        size = log.last_address - log.base + 1
        if regions.region_for(log.table.read_function, log.base, size) is None:
            _fail(f"{log.name}: not inside one region", log.table, log.base)
        for fld in log.fields:
            if fld.offset + fld.count > log.record_words:
                _fail(f"{log.name}.{fld.name}: outside the record", log.table, log.base)
        spans.append((log.table, log.base, log.last_address, log.name))
    spans.sort()
    for before, after in pairwise(spans):
        if before[0] is after[0] and after[1] <= before[2]:
            _fail(f"{before[3]} and {after[3]} overlap", after[0], after[1])

fujilib.registry.regions

Valid address regions per function code (design §2.3).

A block read may bridge unused addresses inside a region but must never cross a region boundary: the bench unit answers a read that crosses a region's end with exception 03, and one that starts outside the map with exception 02 (design §2.2). The planner therefore closes a block at every region change.

Regions are listed per function code, because FC03 and FC04 address separate tables and FC06 covers a smaller holding range than FC10. Two kinds are kept apart:

  • documented regions come from the MODBUS manual (TN5A1190a);
  • observed regions were found on the bench unit and are only used for registers behind a probed :class:~fujilib.devices.capability.Capability.

A :class:RegionMap lists the regions fujilib reads from, not the whole readable map; the analyzer also answers inside blocks fujilib never touches (protocol findings §4). Being in a region never makes a register writable: the write envelope is a separate frozen constant (:mod:fujilib.registry.write_policy).

Evidence

Bases: StrEnum

How much a fact about a register or region is worth (design §5.1).

CONTESTED class-attribute instance-attribute

CONTESTED = 'contested'

Documented, but the bench unit contradicts the manual. Kept raw; never writable.

DOCUMENTED class-attribute instance-attribute

DOCUMENTED = 'documented'

Stated by the manuals.

INFERRED class-attribute instance-attribute

INFERRED = 'inferred'

An interpretation of what was observed or documented. Never writable.

OBSERVED class-attribute instance-attribute

OBSERVED = 'observed'

Seen on the bench unit (firmware 1.02) but not documented.

Region dataclass

Region(
    name,
    function,
    first,
    last,
    evidence,
    doc,
    requires=Capability.NONE,
)

A contiguous run of addresses one function code may access.

count property

count

Number of addresses in the region.

contains

contains(address, count=1)

Whether count addresses from address all lie in this region.

Source code in src/fujilib/registry/regions.py
def contains(self, address: int, count: int = 1) -> bool:
    """Whether ``count`` addresses from ``address`` all lie in this region."""
    return count >= 1 and self.first <= address and address + count - 1 <= self.last

RegionMap dataclass

RegionMap(regions)

The regions of one analyzer or profile, per function code.

Regions of the same function code must not overlap.

for_function

for_function(function)

The regions of function, in address order.

Source code in src/fujilib/registry/regions.py
def for_function(self, function: int) -> tuple[Region, ...]:
    """The regions of ``function``, in address order."""
    return tuple(
        sorted((r for r in self.regions if r.function == function), key=lambda r: r.first)
    )

region_for

region_for(function, address, count=1)

The region that holds all count addresses from address, if one does.

Source code in src/fujilib/registry/regions.py
def region_for(self, function: int, address: int, count: int = 1) -> Region | None:
    """The region that holds all ``count`` addresses from ``address``, if one does."""
    for region in self.regions:
        if region.function == function and region.contains(address, count):
            return region
    return None

with_regions

with_regions(*extra)

Return a new map with extra regions added (for per-station observations).

Source code in src/fujilib/registry/regions.py
def with_regions(self, *extra: Region) -> RegionMap:
    """Return a new map with ``extra`` regions added (for per-station observations)."""
    return RegionMap((*self.regions, *extra))

RegisterTable

Bases: StrEnum

The two register tables: holding (4xxxx) and input (3xxxx).

number_base property

number_base

The register-number base: 40001 for holding, 30001 for input.

read_function property

read_function

The function code that reads this table.

fujilib.registry.write_policy

The only definition of what fujilib may ever write (design §5.4).

:data:WRITE_ENVELOPE is frozen and independent of the registry and of probing: nothing is derived from the register map, and no observation can widen it. The Modbus client re-checks every write request against it as the last step before anymodbus, so a forged spec, a custom registry or an address in a settings file cannot reach the wire outside it.

Deliberately excluded:

  • 00A4h–00ABh, whose meaning (interference compensation coefficients) is inferred, not documented (design §2.6);
  • every key code at 07D0h (key simulation) but the six calibration keys of :data:CALIBRATION_KEYS. MODE and SIDE, which open the menus and enter their passwords, reach maintenance and factory mode (design §6.5), so the envelope checks the value written there as well as the address.

Inside the envelope, only :data:REVIEWED_SETTINGS are ever written: the reviewed subset of design §5.4, by name and address, also written out here independently of the registry. The registry refuses to mark anything else writable, and a setting write is refused for anything not in it, so neither a custom registry nor a forged spec can widen it.

The four operation commands are :class:OperationSpec entries, not registers: each has its own facade method, safety tier and post-conditions, and none is reachable by writing a parameter.

OperationSpec dataclass

OperationSpec(
    name,
    address,
    value,
    safety,
    requires,
    effect,
    manual_ref,
)

One operation command: write value to address with FC06 (design §2.7).

register_number property

register_number

The 40001-based register number, for humans and docs.

WriteRange dataclass

WriteRange(fc, first, last, values=None)

Addresses first..last (inclusive) that function code fc may write.

values class-attribute instance-attribute

values = None

The only words it may write, or None for any word.

allows

allows(fc, address, count=1, values=None)

Whether a write of count words at address with fc, of values, is allowed.

Where the range limits its values, the write is allowed only with values given, count of them, each one of its values.

Source code in src/fujilib/registry/write_policy.py
def allows(
    self, fc: int, address: int, count: int = 1, values: Sequence[int] | None = None
) -> bool:
    """Whether a write of ``count`` words at ``address`` with ``fc``, of ``values``, is allowed.

    Where the range limits its values, the write is allowed only with
    ``values`` given, ``count`` of them, each one of its values.
    """
    if not self.contains(fc, address, count):
        return False
    if self.values is None:
        return True
    return values is not None and len(values) == count and all(v in self.values for v in values)

contains

contains(fc, address, count=1)

Whether a write of count words at address with fc lies inside.

Source code in src/fujilib/registry/write_policy.py
def contains(self, fc: int, address: int, count: int = 1) -> bool:
    """Whether a write of ``count`` words at ``address`` with ``fc`` lies inside."""
    return (
        fc == self.fc
        and count >= 1
        and self.first <= address <= address + count - 1 <= self.last
    )

check_envelope

check_envelope(fc, address, count=1, *, values=None)

Refuse a write outside :data:WRITE_ENVELOPE.

Raises:

Type Description
FujiValidationError

the write is outside the envelope.

Source code in src/fujilib/registry/write_policy.py
def check_envelope(
    fc: int, address: int, count: int = 1, *, values: Sequence[int] | None = None
) -> None:
    """Refuse a write outside :data:`WRITE_ENVELOPE`.

    Raises:
        FujiValidationError: the write is outside the envelope.
    """
    if not envelope_allows(fc, address, count, values=values):
        shown = "" if values is None else f" of {', '.join(f'0x{v:04X}' for v in values)}"
        msg = (
            f"FC{fc:02X} write of {count} word(s){shown} at 0x{address:04X} is outside the "
            "write envelope"
        )
        extra: dict[str, object] = {"count": count}
        if values is not None:
            extra["values"] = tuple(values)
        raise FujiValidationError(
            msg,
            context=ErrorContext(function_code=fc, register_address=address, extra=extra),
        )

envelope_allows

envelope_allows(fc, address, count=1, *, values=None)

Whether a count-word write at address with fc lies inside one envelope range.

Where a range limits the words written (key simulation), values must be given and each be one of them: an address alone is not enough there.

Source code in src/fujilib/registry/write_policy.py
def envelope_allows(
    fc: int, address: int, count: int = 1, *, values: Sequence[int] | None = None
) -> bool:
    """Whether a ``count``-word write at ``address`` with ``fc`` lies inside one envelope range.

    Where a range limits the words written (key simulation), ``values`` must
    be given and each be one of them: an address alone is not enough there.
    """
    return any(r.allows(fc, address, count, values) for r in WRITE_ENVELOPE)

fujilib.registry.typecode

The type code and the channel layout it implies (design §2.9).

The type code (FC04 0448h, one character per register) names the ordered configuration: which NDIR components, which O2 source, which O2-corrected outputs. The ZPA manual (§5.3(3), p.37) derives the channel layout from three of its digits:

  1. the NDIR components of digit 6, in order (NO is shown as NOx when digit 21 is A or C);
  2. then O2, when digit 7 names an O2 source;
  3. then the O2-corrected NO/SO2/CO values, when digit 21 is A or C;
  4. then their averages, when digit 21 is C.

The type code is a hint, not an authority. The bench unit's digit 7 says "no O2" although channel 3 measures O2 with a cell fitted later, and three of its digits are not in the current table. Labels that feed a calculation are asserted by the caller; everything here is a suggestion with a :class:~fujilib.registry.channels.LabelSource.

Options. Digits 21 (O2-corrected outputs) and 22 (the DIO contacts, which carry the auto-calibration valve drive and the alarm outputs) say which options were ordered. :attr:TypeCode.options is that suggestion; like a label, it is a hint the caller can override by asserting the options. :data:MODEL_OPTIONS is what each model's manual describes at all.

Only the ZPA code table ships. Other models' codes are kept raw.

ChannelLayout dataclass

ChannelLayout(channels)

The channels a type code implies, in channel order.

o2_channel property

o2_channel

The channel carrying instantaneous O2, if any.

get

get(channel)

The suggestion for channel, if the layout has one.

Source code in src/fujilib/registry/typecode.py
def get(self, channel: ChannelId) -> ChannelSuggestion | None:
    """The suggestion for ``channel``, if the layout has one."""
    for suggestion in self.channels:
        if suggestion.channel is channel:
            return suggestion
    return None

ChannelSuggestion dataclass

ChannelSuggestion(
    channel, gas, role, source, derived_from=None
)

What the type code suggests a channel carries.

derived_from class-attribute instance-attribute

derived_from = None

For an O2-corrected value or average, the channel of the corrected component.

DigitMeaning dataclass

DigitMeaning(digit, code, item, meaning, inferred=False)

One digit of a type code and what the table says it means.

inferred class-attribute instance-attribute

inferred = False

The meaning was reconstructed, not read from the table.

meaning instance-attribute

meaning

None when the table does not list code for this digit.

O2Correction

Bases: StrEnum

Which O2-corrected outputs are fitted (type-code digit 21).

has_average property

has_average

Whether corrected averages are output.

has_corrected property

has_corrected

Whether corrected instantaneous values are output.

O2Source

Bases: StrEnum

Where the O2 value comes from (type-code digit 7).

EXTERNAL_ANALYZER class-attribute instance-attribute

EXTERNAL_ANALYZER = 'external_analyzer'

An external O2 analyzer through the A/I connector, as a 0-1 V DC signal.

EXTERNAL_ZIRCONIA class-attribute instance-attribute

EXTERNAL_ZIRCONIA = 'external_zirconia'

An external zirconia analyzer (ZFK7) through the same connector.

GALVANIC class-attribute instance-attribute

GALVANIC = 'galvanic'

A built-in galvanic fuel cell.

PARAMAGNETIC_ENVIRONMENTAL class-attribute instance-attribute

PARAMAGNETIC_ENVIRONMENTAL = 'paramagnetic_environmental'

A built-in paramagnetic cell, environmental-measurement variant.

PARAMAGNETIC_HEAT_TREATMENT class-attribute instance-attribute

PARAMAGNETIC_HEAT_TREATMENT = 'paramagnetic_heat_treatment'

A built-in paramagnetic cell, heat-treatment variant.

TypeCode dataclass

TypeCode(
    raw,
    model=None,
    decoder=None,
    digits=(),
    ndir_components=None,
    o2_source=None,
    o2_correction=None,
    layout=None,
    options=None,
)

A type code: the raw string plus whatever of it decodes.

decoded property

decoded

Whether a code table was applied at all.

options class-attribute instance-attribute

options = None

The options the code lists, or None when its option digits do not decode.

unknown_digits property

unknown_digits

Positions whose code the table does not list.

TypeCodeDecoder

Bases: Protocol

Decodes one model's type codes.

name property

name

The model this decoder handles, e.g. "ZPA".

decode

decode(raw)

Decode raw. Never raises; undecodable digits have no meaning.

Source code in src/fujilib/registry/typecode.py
def decode(self, raw: str) -> TypeCode:
    """Decode ``raw``. Never raises; undecodable digits have no meaning."""
    ...

channel_layout

channel_layout(ndir, *, o2, correction, source)

Apply the ZPA manual's layout rule (§5.3(3)); see the module docstring.

Correction needs an O2 value, so without o2 no corrected channels are laid out.

Source code in src/fujilib/registry/typecode.py
def channel_layout(
    ndir: Sequence[Gas],
    *,
    o2: bool,
    correction: O2Correction,
    source: LabelSource,
) -> ChannelLayout:
    """Apply the ZPA manual's layout rule (§5.3(3)); see the module docstring.

    Correction needs an O2 value, so without ``o2`` no corrected channels are
    laid out.
    """
    corrected = correction.has_corrected or correction.has_average
    shown = tuple(Gas.NOX if g is Gas.NO and corrected and o2 else g for g in ndir)
    out: list[ChannelSuggestion] = []

    def add(gas: Gas, role: ChannelRole, derived_from: ChannelId | None = None) -> ChannelId:
        channel = ChannelId.from_number(len(out) + 1)
        out.append(ChannelSuggestion(channel, gas, role, source, derived_from))
        return channel

    component_channel = {gas: add(gas, ChannelRole.INSTANTANEOUS) for gas in shown}
    if o2:
        add(Gas.O2, ChannelRole.INSTANTANEOUS)
        targets = [g for g, orig in zip(shown, ndir, strict=True) if orig in _CORRECTABLE]
        if correction.has_corrected:
            for gas in targets:
                add(gas, ChannelRole.O2_CORRECTED, component_channel[gas])
        if correction.has_average:
            for gas in targets:
                add(gas, ChannelRole.O2_CORRECTED_AVERAGE, component_channel[gas])
    return ChannelLayout(tuple(out))

decode_type_code

decode_type_code(raw, decoders=TYPECODE_DECODERS)

Decode raw with the table for its model, or keep it raw.

Never raises. A code whose first three characters name no known model comes back with only raw (and model when it starts with ZP).

Source code in src/fujilib/registry/typecode.py
def decode_type_code(
    raw: str,
    decoders: Mapping[str, TypeCodeDecoder] = TYPECODE_DECODERS,
) -> TypeCode:
    """Decode ``raw`` with the table for its model, or keep it raw.

    Never raises. A code whose first three characters name no known model
    comes back with only ``raw`` (and ``model`` when it starts with ``ZP``).
    """
    text = raw.rstrip()
    model = text[:3]
    decoder = decoders.get(model)
    if decoder is None:
        return TypeCode(raw=text, model=model if model.startswith("ZP") else None)
    return decoder.decode(text)

suggest_labels

suggest_labels(type_code, present)

Suggest a label for each present channel (design §2.9, step 4).

A channel the type-code layout covers gets its suggestion. A present channel just after the layout's NDIR components, when the layout has no O2, is suggested as O2 by the layout rule, labelled INFERRED: this is the bench unit, whose code says "no O2" while channel 3 measures O2. Any other present channel gets no suggestion. A suggestion never selects a scientific channel by itself; only an asserted label does.

Source code in src/fujilib/registry/typecode.py
def suggest_labels(
    type_code: TypeCode | None,
    present: Iterable[ChannelId],
) -> Mapping[ChannelId, ChannelSuggestion]:
    """Suggest a label for each present channel (design §2.9, step 4).

    A channel the type-code layout covers gets its suggestion. A present
    channel just after the layout's NDIR components, when the layout has no
    O2, is suggested as O2 by the layout rule, labelled ``INFERRED``: this is
    the bench unit, whose code says "no O2" while channel 3 measures O2. Any
    other present channel gets no suggestion. A suggestion never selects a
    scientific channel by itself; only an asserted label does.
    """
    layout = type_code.layout if type_code is not None else None
    suggestions: dict[ChannelId, ChannelSuggestion] = {}
    if layout is None:
        return MappingProxyType(suggestions)
    after_ndir = ChannelId.from_number(len(layout.channels) + 1) if layout.channels else None
    for channel in present:
        suggestion = layout.get(channel)
        if suggestion is not None:
            suggestions[channel] = suggestion
        elif layout.o2_channel is None and channel is after_ndir and not _has_derived(layout):
            suggestions[channel] = ChannelSuggestion(
                channel, Gas.O2, ChannelRole.INSTANTANEOUS, LabelSource.INFERRED
            )
    return MappingProxyType(suggestions)

fujilib.registry.channels

Channel identifiers, roles, gases and label provenance (design §2.9, §8).

The ZP-series presents up to twelve display channels: the measured components first (NDIR, then O2), then O2-corrected values, corrected averages and an O2 average. Only channels 1–5 carry per-channel status registers (current range, calibration flags, errors, hold), so only they can be measured channels; 6–12 are always derived.

Which gas a channel carries is asserted by the caller. The type code and live data only suggest it, and every label records its :class:LabelSource so a consumer can tell the two apart.

ChannelId

Bases: StrEnum

A display channel, CH1 … CH12.

is_measured property

is_measured

Whether the channel has per-channel status registers (channels 1–5).

number property

number

The 1-based channel number, as the manual counts.

from_number classmethod

from_number(number)

Return the channel with 1-based number.

Raises:

Type Description
FujiValidationError

number is not 1–12.

Source code in src/fujilib/registry/channels.py
@classmethod
def from_number(cls, number: int) -> ChannelId:
    """Return the channel with 1-based ``number``.

    Raises:
        FujiValidationError: ``number`` is not 1–12.
    """
    if not 1 <= number <= len(_ALL):
        msg = f"channel number must be 1-{len(_ALL)}, got {number}"
        raise FujiValidationError(msg, context=ErrorContext(channel=str(number)))
    return _ALL[number - 1]

ChannelRole

Bases: StrEnum

What a channel's value is (ZPA manual §5.3(3)).

INSTANTANEOUS class-attribute instance-attribute

INSTANTANEOUS = 'instantaneous'

A measured component: an NDIR gas or O2.

O2_AVERAGE class-attribute instance-attribute

O2_AVERAGE = 'o2_average'

The moving average of O2. The manual shows it but not its channel index.

O2_CORRECTED class-attribute instance-attribute

O2_CORRECTED = 'o2_corrected'

An NDIR value corrected to the O2 reference (type-code digit 21 = A or C).

O2_CORRECTED_AVERAGE class-attribute instance-attribute

O2_CORRECTED_AVERAGE = 'o2_corrected_average'

The moving average of an O2-corrected value (digit 21 = C).

UNKNOWN class-attribute instance-attribute

UNKNOWN = 'unknown'

Not established: no assertion and nothing the type code decodes.

Gas

Bases: StrEnum

A measured gas.

Values are lower-case formulas, which is the vocabulary capa's cone profile uses for its analyzers ("o2", "co", "co2").

LabelSource

Bases: StrEnum

Where a channel's gas label came from (design §2.9).

ASSERTED class-attribute instance-attribute

ASSERTED = 'asserted'

Given by the caller in a channel_map. The only source fit for calculation.

INFERRED class-attribute instance-attribute

INFERRED = 'inferred'

From the layout rule (NDIR components first, then O2). A weaker hint.

TYPE_CODE class-attribute instance-attribute

TYPE_CODE = 'type_code'

Decoded from the analyzer's type code. A hint; the bench unit's code is stale.

UNKNOWN class-attribute instance-attribute

UNKNOWN = 'unknown'

No label.

coerce_channel

coerce_channel(channel)

Resolve channel to a :class:ChannelId.

Accepts a :class:ChannelId or a string such as "CH3", "ch3" or "3".

Raises:

Type Description
FujiValidationError

channel names no channel.

Source code in src/fujilib/registry/channels.py
def coerce_channel(channel: ChannelId | str) -> ChannelId:
    """Resolve ``channel`` to a :class:`ChannelId`.

    Accepts a :class:`ChannelId` or a string such as ``"CH3"``, ``"ch3"`` or
    ``"3"``.

    Raises:
        FujiValidationError: ``channel`` names no channel.
    """
    if isinstance(channel, ChannelId):
        return channel
    text = channel.strip().upper()
    if text.isdigit():
        return ChannelId.from_number(int(text))
    try:
        return ChannelId(text)
    except ValueError:
        msg = f"unknown channel {channel!r}; expected CH1-CH12"
        raise FujiValidationError(msg, context=ErrorContext(channel=channel)) from None

coerce_channel_map

coerce_channel_map(channel_map)

Resolve a caller's channel map, as open_device and identify() take it.

Raises:

Type Description
FujiValidationError

a channel or gas is unknown, a channel is asserted as unknown, or one channel is named twice with different gases.

Source code in src/fujilib/registry/channels.py
def coerce_channel_map(
    channel_map: Mapping[ChannelId | str, Gas | str],
) -> Mapping[ChannelId, Gas]:
    """Resolve a caller's channel map, as ``open_device`` and ``identify()`` take it.

    Raises:
        FujiValidationError: a channel or gas is unknown, a channel is asserted
            as ``unknown``, or one channel is named twice with different gases.
    """
    out: dict[ChannelId, Gas] = {}
    for key, value in channel_map.items():
        channel, gas = coerce_channel(key), coerce_gas(value)
        if gas is Gas.UNKNOWN:
            msg = f"{channel.value} cannot be asserted as unknown; leave it out instead"
            raise FujiValidationError(msg, context=ErrorContext(channel=channel.value))
        if out.setdefault(channel, gas) is not gas:
            msg = f"{channel.value} is asserted as both {out[channel].value} and {gas.value}"
            raise FujiValidationError(msg, context=ErrorContext(channel=channel.value))
    return MappingProxyType(dict(sorted(out.items(), key=lambda item: item[0].number)))

coerce_gas

coerce_gas(gas)

Resolve gas to a :class:Gas, from a member or its formula in any case ("O2").

Raises:

Type Description
FujiValidationError

gas names no gas.

Source code in src/fujilib/registry/channels.py
def coerce_gas(gas: Gas | str) -> Gas:
    """Resolve ``gas`` to a :class:`Gas`, from a member or its formula in any case (``"O2"``).

    Raises:
        FujiValidationError: ``gas`` names no gas.
    """
    if isinstance(gas, Gas):
        return gas
    try:
        return Gas(gas.strip().lower())
    except ValueError:
        known = ", ".join(g.value for g in Gas if g is not Gas.UNKNOWN)
        msg = f"unknown gas {gas!r}; expected one of {known}"
        raise FujiValidationError(msg) from None

fujilib.registry.units

Measurement units and the unit-code registers (design §2.5).

A concentration's unit is not fixed: it lives in a register beside the value (input 30001–30036) or in the range tables (31067–31076), as a code 0–3. The unit of a range cannot be changed (TN5A1190a p.26).

:func:unit_from_code and :func:coerce_unit never raise: an unrecognised code or text becomes :attr:Unit.UNKNOWN, so one odd register never sinks a whole frame. The pint mapping lives in :mod:fujilib.units.

Unit

Bases: StrEnum

A concentration unit. The value is the canonical display string.

coerce_unit

coerce_unit(unit)

Map a :class:Unit or a unit string (case-insensitive) to a :class:Unit.

Unrecognised text becomes :attr:Unit.UNKNOWN.

Source code in src/fujilib/registry/units.py
def coerce_unit(unit: Unit | str) -> Unit:
    """Map a :class:`Unit` or a unit string (case-insensitive) to a :class:`Unit`.

    Unrecognised text becomes :attr:`Unit.UNKNOWN`.
    """
    if isinstance(unit, Unit):
        return unit
    return _BY_TEXT.get(unit.strip().casefold(), Unit.UNKNOWN)

unit_code

unit_code(unit)

Return the register code for unit.

Raises:

Type Description
FujiValidationError

unit is :attr:Unit.UNKNOWN, which has no code.

Source code in src/fujilib/registry/units.py
def unit_code(unit: Unit) -> int:
    """Return the register code for ``unit``.

    Raises:
        FujiValidationError: ``unit`` is :attr:`Unit.UNKNOWN`, which has no code.
    """
    try:
        return _CODE_OF[unit]
    except KeyError:
        msg = f"unit {unit.value!r} has no register code"
        raise FujiValidationError(msg) from None

unit_from_code

unit_from_code(code)

Map a unit-code register value (0 vol%, 1 ppm, 2 mg/m³, 3 g/m³) to a :class:Unit.

Any other value becomes :attr:Unit.UNKNOWN.

Source code in src/fujilib/registry/units.py
def unit_from_code(code: int) -> Unit:
    """Map a unit-code register value (0 vol%, 1 ppm, 2 mg/m³, 3 g/m³) to a :class:`Unit`.

    Any other value becomes :attr:`Unit.UNKNOWN`.
    """
    return _BY_CODE.get(code, Unit.UNKNOWN)

fujilib.registry.enums

Enumerated register values, exactly as the manuals define them (design §2.6–§2.8).

Every member's value is the raw register value. Two traps are kept visible rather than merged:

  • Alarm mode and alarm state are different enums. Mode 2 is "high or low"; state 2 is "low limit alarm", and state 0 is "no alarm".
  • The two "cycle unit" registers use different codes. The auto-calibration, auto-zero and blowback cycles use 0 = hours, 1 = days (:class:ScheduleCycleUnit); the moving-average and measurement-point periods use 0 = hours, 1 = minutes (:class:PeriodUnit).

Real analyzers do not always stay inside the documented domains (the bench unit's alarm-6 target channel reads 12, outside the documented 0–6), so decoders use :func:fujilib.protocol.modbus.codec.decode_enum, which can keep an undefined value as a plain int instead of failing a whole read.

AlarmMode

Bases: IntEnum

How an alarm is configured to trip (holding 40056–40060, 40131).

AlarmState

Bases: IntEnum

Whether and how an alarm is currently tripped (input 30043–30047, 30191).

CalibrationKind

Bases: IntEnum

Range and kind of a calibration-log record (0 Z1, 1 S1, 2 Z2, 3 S2).

is_span property

is_span

Whether this was a span (rather than zero) calibration.

range property

range

The calibrated range, 1 or 2.

CalibrationRangeMode

Bases: IntEnum

Which ranges a calibration adjusts (holding 40031–40035).

BOTH calibrates both ranges together, manual or automatic, widening a calibration beyond the range it was started on (ZPA manual p.45; design §2.6).

DayOfWeek

Bases: IntEnum

Day of week in schedule start times (0 = Sunday … 6 = Saturday).

DisplayScreen

Bases: IntEnum

The front panel's current screen (input 30181; TN5A1190a p.46).

MAINTENANCE and FACTORY mean an operator is in the service menus.

ErrorCode

Bases: IntEnum

Analyzer error numbers (ZPA manual §8, service manual §4; design §2.8).

Errors 1, 2, 3 and 10 close the instrument-error (FAULT) contact; errors 4–9 close the calibration-error contact and are reported per channel.

AD_CONVERSION class-attribute instance-attribute

AD_CONVERSION = 3

A/D conversion fault.

AUTO_CALIBRATION class-attribute instance-attribute

AUTO_CALIBRATION = 9

One of errors 4-8 occurred during auto calibration.

DETECTOR class-attribute instance-attribute

DETECTOR = 2

Detector failure.

LIGHT_SOURCE class-attribute instance-attribute

LIGHT_SOURCE = 1

Light source or sector motor fault.

OUTPUT_CIRCUIT class-attribute instance-attribute

OUTPUT_CIRCUIT = 10

Output cable or DIO circuit fault.

SPAN_AMOUNT_OVER_50 class-attribute instance-attribute

SPAN_AMOUNT_OVER_50 = 7

Span calibration amount over 50 %FS. The panel offers a forced calibration.

SPAN_OUT_OF_RANGE class-attribute instance-attribute

SPAN_OUT_OF_RANGE = 6

Span calibration outside the allowable range.

UNSTABLE class-attribute instance-attribute

UNSTABLE = 8

Reading unstable during calibration.

ZERO_AMOUNT_OVER_50 class-attribute instance-attribute

ZERO_AMOUNT_OVER_50 = 5

Zero calibration amount over 50 %FS. The panel offers a forced calibration.

ZERO_OUT_OF_RANGE class-attribute instance-attribute

ZERO_OUT_OF_RANGE = 4

Zero calibration outside the allowable range.

scope property

scope

Whether the error concerns the analyzer or a single channel.

ErrorScope

Bases: StrEnum

Whether an error concerns the whole analyzer or one channel.

HoldMode

Bases: IntEnum

What the outputs hold during calibration (holding 40140).

KeyCode

Bases: IntFlag

Front-panel key codes of the key-simulation register 42001.

The undocumented input 30190 (00BDh) shows the key being pressed at the panel in the same codes, and 0 otherwise (protocol findings §14.2).

fujilib writes only UP, DOWN, ESC, ENT, ZERO and SPAN, and only from the front-panel driver of a manual zero or span (:mod:fujilib.devices.keys, design §6.5). MODE and SIDE reach the menus and their passwords; the write envelope refuses them.

ManualCalibrationResult

Bases: IntEnum

The last manual calibration, as the undocumented input 30186 (00B9h) shows it.

Observed on the bench analyzer, not documented (protocol findings §14.2): 0 once a channel is selected, 4 while the calibration runs, 6 when it has finished, and kept until the next channel is selected. A calibration cancelled before it ran leaves 0. The value after a calibration error is not known.

NONE class-attribute instance-attribute

NONE = 0

None has finished since a channel was last selected (or it was cancelled).

ManualCalibrationStep

Bases: IntEnum

The manual-calibration step shown on the panel (input 30182; TN5A1190a p.46).

Values 1–3 are not defined by the manual. On the bench analyzer the screen register (30181) stays MEASUREMENT while one of these is shown (protocol findings §14.3). On any other screen the word numbers the menu's pages, these values among them, and is not a step (protocol findings §15).

MeasurementPoint

Bases: IntEnum

Measurement-point setting (holding 40157; option).

PeriodUnit

Bases: IntEnum

Unit of the moving-average and measurement-point periods (0 h, 1 min).

RangeIndex

Bases: IntEnum

A range as the registers encode it: 0 is range 1, 1 is range 2.

number property

number

The 1-based range number.

RangeMethod

Bases: IntEnum

How a channel changes range (holding 40111–40115).

AUTO switches up at 90 % of range 1 and back below 80 % (ZPA manual §6.1).

ScheduleCycleUnit

Bases: IntEnum

Unit of the auto-calibration, auto-zero and blowback cycles (0 h, 1 days).

ZeroCalibrationMode

Bases: IntEnum

Manual zero calibration scope (holding 40026–40030).

AT_ONCE zeroes every channel so set when any one is zeroed at the panel, which widens a manual zero beyond the channel it was started on. Auto calibration and auto zero calibration ignore it: they zero every enabled channel together (ZPA manual p.47; design §2.6).