Skip to content

fujilib.devices

The Analyzer facade and open_device (design §7.1, §7.2), the session every call goes through (design §6), discovery (design §7.5) and device profiles; the frozen data models (design §8), the pure decoders from register banks to models, the read procedures that join a read plan to a decoder, safety tiers and capability flags, and the unified-API snapshots; and the write side: encoding a setting, writing it and reading it back, settings documents, and the operation commands (design §6.1-§6.4, Safety); and the front panel's manual calibrations, planned, watched and driven from the host (design §6.5).

Opening and using an analyzer

fujilib.devices.factory

open_device: the entry point (design §7.1, unified API §A).

It opens the serial port (or takes the caller's open transport), binds one Modbus bus to it, attaches a session to the station, and by default identifies the analyzer. If anything fails or is cancelled on the way, what this call opened is closed again; a transport the caller passed in is never closed by it.

A port opened by name can be opened again the same way after a connection failure (Analyzer.reopen()); a transport the caller passed in cannot.

open_device async

open_device(
    port,
    *,
    profile=ZP_PROFILE,
    protocol=None,
    address=1,
    serial_settings=None,
    timeout=DEFAULTS.request_timeout_s,
    identify=True,
    channel_map=None,
    options=Capability.NONE,
    write_warn_per_minute=DEFAULTS.write_warn_per_minute,
    settle_after_reopen_s=DEFAULTS.settle_after_reopen_s,
)

Open the analyzer at station address on port.

Use the result as an async context manager, or call close()::

async with await open_device("COM8", channel_map={"CH3": "o2"}) as anz:
    frame = await anz.poll()

Parameters:

Name Type Description Default
port str | Transport

A serial port name ("COM8", "/dev/ttyUSB0"; any spelling of a port, such as \\.\COM8, names the same port), or an open :class:~fujilib.transport.base.Transport, which stays the caller's to close.

required
profile DeviceProfile

The analyzer family.

ZP_PROFILE
protocol ProtocolKind | str | None

The wire protocol; the profile's (MODBUS RTU) when None.

None
address int

The station number, 1-31, as set on the front panel.

1
serial_settings SerialSettings | None

The serial framing, with port agreeing with port; the profile's (38400 8-N-1) when None. Not accepted with an open transport, whose settings are fixed already.

None
timeout float

Seconds to wait for each reply. A per-call timeout= on the analyzer's methods is a deadline for a whole operation instead.

request_timeout_s
identify bool

Identify the analyzer before returning (six transactions).

True
channel_map Mapping[ChannelId | str, Gas | str] | None

The gas on each channel, e.g. {"CH1": "co2", "CH3": "o2"}. Only an asserted label is fit for calculation (design §2.9).

None
options Capability

Options the analyzer has, whatever its type code says, e.g. Capability.AUTO_CALIBRATION | Capability.AUTO_ZERO for a unit whose calibration gases are plumbed. The type code only suggests them, like gas labels (design §6.1).

NONE
write_warn_per_minute int

Setting writes a minute above which a warning is logged; 0 for never (design §6.3).

write_warn_per_minute
settle_after_reopen_s float

Seconds for which readings are settling instead of ok after a reopen that follows a connection failure; 0 for not at all (design §8).

settle_after_reopen_s

Raises:

Type Description
FujiValidationError

an argument is invalid; nothing was opened.

FujiConnectionError

the port cannot be opened, or the transport is closed.

FujiConfigurationError

the transport already carries an open analyzer.

FujiProtocolUnsupportedError

the station does not identify as a ZP analyzer.

FujiError

identification failed.

Source code in src/fujilib/devices/factory.py
async def open_device(
    port: str | Transport,
    *,
    profile: DeviceProfile = ZP_PROFILE,
    protocol: ProtocolKind | str | None = None,
    address: int = 1,
    serial_settings: SerialSettings | None = None,
    timeout: float = DEFAULTS.request_timeout_s,
    identify: bool = True,
    channel_map: Mapping[ChannelId | str, Gas | str] | None = None,
    options: Capability = Capability.NONE,
    write_warn_per_minute: int = DEFAULTS.write_warn_per_minute,
    settle_after_reopen_s: float = DEFAULTS.settle_after_reopen_s,
) -> Analyzer:
    r"""Open the analyzer at station ``address`` on ``port``.

    Use the result as an async context manager, or call ``close()``::

        async with await open_device("COM8", channel_map={"CH3": "o2"}) as anz:
            frame = await anz.poll()

    Args:
        port: A serial port name (``"COM8"``, ``"/dev/ttyUSB0"``; any spelling
            of a port, such as ``\\.\COM8``, names the same port), or an open
            :class:`~fujilib.transport.base.Transport`, which stays the
            caller's to close.
        profile: The analyzer family.
        protocol: The wire protocol; the profile's (MODBUS RTU) when ``None``.
        address: The station number, 1-31, as set on the front panel.
        serial_settings: The serial framing, with ``port`` agreeing with
            ``port``; the profile's (38400 8-N-1) when ``None``. Not accepted
            with an open transport, whose settings are fixed already.
        timeout: Seconds to wait for each reply. A per-call ``timeout=`` on
            the analyzer's methods is a deadline for a whole operation instead.
        identify: Identify the analyzer before returning (six transactions).
        channel_map: The gas on each channel, e.g. ``{"CH1": "co2", "CH3":
            "o2"}``. Only an asserted label is fit for calculation (design §2.9).
        options: Options the analyzer has, whatever its type code says, e.g.
            ``Capability.AUTO_CALIBRATION | Capability.AUTO_ZERO`` for a unit
            whose calibration gases are plumbed. The type code only suggests
            them, like gas labels (design §6.1).
        write_warn_per_minute: Setting writes a minute above which a warning is
            logged; 0 for never (design §6.3).
        settle_after_reopen_s: Seconds for which readings are ``settling``
            instead of ``ok`` after a reopen that follows a connection failure;
            0 for not at all (design §8).

    Raises:
        FujiValidationError: an argument is invalid; nothing was opened.
        FujiConnectionError: the port cannot be opened, or the transport is closed.
        FujiConfigurationError: the transport already carries an open analyzer.
        FujiProtocolUnsupportedError: the station does not identify as a ZP analyzer.
        FujiError: identification failed.
    """
    _check_protocol(profile, protocol)
    _check_address(address)
    _check_options(options, write_warn_per_minute, settle_after_reopen_s)
    asserted = coerce_channel_map(channel_map) if channel_map is not None else None
    reopener: Reopener | None = None
    if isinstance(port, str):
        settings = _serial_settings(profile, port, serial_settings)
        transport: Transport = await SerialTransport.open(settings)
        owns_transport = True
        reopener = partial(_open_port, settings, timeout)
    else:
        if not _is_transport(port):
            msg = f"port must be a port name or an open Transport, got {type(port).__name__}"
            raise FujiValidationError(msg)
        if serial_settings is not None:
            msg = "serial_settings cannot be given with an open transport"
            raise FujiValidationError(msg, context=ErrorContext(port=port.label))
        if not port.is_open:
            msg = f"the transport of {port.label} is closed"
            raise FujiConnectionError(msg, context=ErrorContext(port=port.label))
        transport, owns_transport = port, False

    modbus: ModbusPort | None = None
    try:
        modbus = ModbusPort(transport, request_timeout=timeout, owns_transport=owns_transport)
        session = Session(
            modbus,
            address=address,
            profile=profile,
            channel_map=asserted,
            reopener=reopener,
            options=options,
            write_warn_per_minute=write_warn_per_minute,
            settle_after_reopen_s=settle_after_reopen_s,
        )
        analyzer = Analyzer(session)
        if identify:
            await analyzer.identify()
    except BaseException:
        with anyio.CancelScope(shield=True):
            if modbus is not None:
                await modbus.aclose()
            elif owns_transport:
                await transport.aclose()
        raise
    return analyzer

fujilib.devices.analyzer

The :class:Analyzer facade: one ZP-series analyzer on one station (design §7.2).

Every method that reads from the analyzer runs as one operation of the :class:~fujilib.devices.session.Session, under the port's operation lock and one operation deadline. Arguments are checked first, so a bad channel or parameter name is refused before anything is sent.

  • Every I/O method takes a keyword-only timeout: a deadline for the whole operation, including the wait for the port and any retries. None (the default) leaves it to the per-transaction timeout and the retries.
  • Channel arguments accept a :class:~fujilib.registry.channels.ChannelId or a string such as "CH3".
  • Everything that changes the analyzer takes confirm, and is refused before anything is sent unless it is True (design §6.2). A setting write is written once and read back (:class:~fujilib.devices.writes.WriteResult); only the reviewed subset of the register map is writable (design §5.4).

Example::

async with await open_device(
    "COM8", channel_map={"CH1": "co2", "CH2": "co", "CH3": "o2"}
) as anz:
    frame = await anz.poll()
    o2 = frame.channel("CH3")  # value, unit, state, label source, ...

Analyzer

Analyzer(session)

A Fuji ZP-series analyzer. Created by :func:~fujilib.devices.factory.open_device.

Wrap an open session; :func:~fujilib.devices.factory.open_device does this.

Source code in src/fujilib/devices/analyzer.py
def __init__(self, session: Session) -> None:
    """Wrap an open ``session``; :func:`~fujilib.devices.factory.open_device` does this."""
    self._session = session

address property

address

The station number, 1-31.

channels property

channels

The established channels, labelled (design §2.9).

info property

info

What identify() established, kept current; None before it.

last_frame property

last_frame

The most recent poll's frame, or None.

options property

options

The options taken as fitted: asserted when opening, or listed by the type code.

port property

port

The canonical port name.

protocol property

protocol

Always :attr:ProtocolKind.MODBUS_RTU.

session property

session

The session: counters, caches and state (unified API §J).

apply_settings async

apply_settings(
    document,
    *,
    confirm=False,
    any_analyzer=False,
    max_tier=SafetyTier.DANGEROUS,
    timeout=None,
)

Write the settings of a document that differ from the analyzer's.

The whole document is compared first (:meth:diff_settings); if any setting is refused, nothing is written. confirm must then be True; the CLI also asks for its destructive flag when a write is DANGEROUS. The writes go one at a time in dependency order, each read back; the first that fails stops the rest, and nothing is rolled back. max_tier refuses a document whose writes go above it, judged on the comparison made here, not on an earlier one: the CLI passes PERSISTENT unless its destructive flag is given. timeout bounds the whole apply.

Raises:

Type Description
FujiValidationError

the document is not a settings document, or is refused; nothing was written.

FujiConfirmationRequiredError

there is something to write and confirm is not True, or a write is above max_tier; nothing was written.

FujiError

the comparison's reads failed. A failed write does not raise: it is in the report.

Source code in src/fujilib/devices/analyzer.py
async def apply_settings(
    self,
    document: SettingsDocument | Mapping[str, object],
    *,
    confirm: bool = False,
    any_analyzer: bool = False,
    max_tier: SafetyTier = SafetyTier.DANGEROUS,
    timeout: float | None = None,
) -> ApplyReport:
    """Write the settings of a document that differ from the analyzer's.

    The whole document is compared first (:meth:`diff_settings`); if any
    setting is refused, nothing is written. ``confirm`` must then be
    ``True``; the CLI also asks for its destructive flag when a write is
    DANGEROUS. The writes go one at a time in dependency order, each read
    back; the first that fails stops the rest, and nothing is rolled back.
    ``max_tier`` refuses a document whose writes go above it, judged on
    the comparison made here, not on an earlier one: the CLI passes
    ``PERSISTENT`` unless its destructive flag is given. ``timeout`` bounds
    the whole apply.

    Raises:
        FujiValidationError: the document is not a settings document, or is
            refused; nothing was written.
        FujiConfirmationRequiredError: there is something to write and
            ``confirm`` is not ``True``, or a write is above ``max_tier``;
            nothing was written.
        FujiError: the comparison's reads failed. A failed *write* does not
            raise: it is in the report.
    """
    deadline = Deadline.after(timeout, operation="apply_settings")
    diff = await self.diff_settings(
        document, any_analyzer=any_analyzer, timeout=_remaining(deadline)
    )
    if not diff.ok:
        raise diff.refusal()
    if not diff.writes:
        return ApplyReport(diff, ())
    if diff.tier > max_tier:
        msg = (
            f"applying the settings would make {diff.tier.name} writes, above "
            f"{max_tier.name}; nothing was written"
        )
        raise FujiConfirmationRequiredError(
            msg, context=ErrorContext(extra={"safety": diff.tier.name.lower()})
        )
    self._session.gate(
        "apply_settings", tier=diff.tier, confirm=confirm, subject="applying the settings"
    )
    registry = self._session.profile.registry
    completed: list[WriteResult] = []
    writes = diff.writes
    for index, change in enumerate(writes):
        spec = registry.resolve(change.name)
        try:
            result = await self._write(
                "apply_settings",
                spec,
                change.desired.value,
                unit=change.desired.unit,
                confirm=confirm,
                timeout=_remaining(deadline),
            )
        except FujiError as exc:
            rest = tuple(c.name for c in writes[index + 1 :])
            return ApplyReport(diff, tuple(completed), change.name, exc, rest)
        completed.append(result)
    return ApplyReport(diff, tuple(completed))

calibration_status async

calibration_status(*, timeout=None)

What is calibrating, held or failed. Two transactions, the poll's status blocks.

Source code in src/fujilib/devices/analyzer.py
async def calibration_status(self, *, timeout: float | None = None) -> CalibrationStatus:
    """What is calibrating, held or failed. Two transactions, the poll's status blocks."""
    status = await self._read_status("calibration_status", timeout)
    return operations.calibration_status(status)

channel_status async

channel_status(channel, *, timeout=None)

One measured channel's status: range, calibration, hold and errors. Two transactions.

Raises:

Type Description
FujiValidationError

channel is not one of channels 1-5; nothing was sent.

Source code in src/fujilib/devices/analyzer.py
async def channel_status(
    self, channel: ChannelId | str, *, timeout: float | None = None
) -> ChannelStatus:
    """One measured channel's status: range, calibration, hold and errors. Two transactions.

    Raises:
        FujiValidationError: ``channel`` is not one of channels 1-5; nothing was sent.
    """
    cid = coerce_channel(channel)
    if not cid.is_measured:
        msg = f"{cid.value} is a derived channel and has no status registers"
        raise FujiValidationError(msg, context=ErrorContext(channel=cid.value))
    status = await self._read_status("channel_status", timeout)
    return status.channels[cid]

close async

close()

Close the analyzer and the port it opened. Idempotent.

Waits for an operation in progress to finish. A transport the caller passed to open_device is left open.

Source code in src/fujilib/devices/analyzer.py
async def close(self) -> None:
    """Close the analyzer and the port it opened. Idempotent.

    Waits for an operation in progress to finish. A transport the caller
    passed to ``open_device`` is left open.
    """
    await self._session.close()

diff_settings async

diff_settings(
    document, *, any_analyzer=False, timeout=None
)

Compare a settings document (fujilib-settings/1) with the analyzer's settings.

Read-only: the range tables and every setting are read (four transactions), and each setting of the document is found unchanged, to be written, or refused, with the reason (:mod:fujilib.devices.settings). A document from another analyzer is refused unless any_analyzer.

Raises:

Type Description
FujiValidationError

the document is not a settings document.

FujiError

a transaction failed.

Source code in src/fujilib/devices/analyzer.py
async def diff_settings(
    self,
    document: SettingsDocument | Mapping[str, object],
    *,
    any_analyzer: bool = False,
    timeout: float | None = None,
) -> SettingsDiff:
    """Compare a settings document (``fujilib-settings/1``) with the analyzer's settings.

    Read-only: the range tables and every setting are read (four
    transactions), and each setting of the document is found unchanged, to
    be written, or refused, with the reason (:mod:`fujilib.devices.settings`).
    A document from another analyzer is refused unless ``any_analyzer``.

    Raises:
        FujiValidationError: the document is not a settings document.
        FujiError: a transaction failed.
    """
    doc = (
        document
        if isinstance(document, SettingsDocument)
        else SettingsDocument.from_json(dict(document))
    )
    session = self._session

    async def body(client: ProtocolClient, deadline: Deadline) -> SettingsDiff:
        info = session.info
        if info is None:
            identity = await session.profile.identify(client, probe=True, deadline=deadline)
            info = session.learn_identity(identity)
        ranges = session.learn_ranges(await reads.read_ranges(client, deadline=deadline))
        current = await reads.read_settings(client, ranges=ranges, deadline=deadline)
        return diff_settings(
            doc,
            current,
            registry=session.profile.registry,
            ranges=ranges,
            serial_number=info.serial_number,
            any_analyzer=any_analyzer,
        )

    return await session.run("diff_settings", body, timeout=timeout)

identify async

identify(*, channel_map=None, timeout=None)

Read the type code, serial number, ranges and readings, and probe the capabilities.

Six transactions (design §4.3). channel_map replaces the asserted gas labels; an asserted label is the only one fit for calculation (design §2.9).

Raises:

Type Description
FujiValidationError

channel_map names an unknown channel or gas.

FujiProtocolUnsupportedError

the type code does not name a ZP model.

FujiError

a transaction failed.

Source code in src/fujilib/devices/analyzer.py
async def identify(
    self,
    *,
    channel_map: Mapping[ChannelId | str, Gas | str] | None = None,
    timeout: float | None = None,
) -> DeviceInfo:
    """Read the type code, serial number, ranges and readings, and probe the capabilities.

    Six transactions (design §4.3). ``channel_map`` replaces the asserted
    gas labels; an asserted label is the only one fit for calculation
    (design §2.9).

    Raises:
        FujiValidationError: ``channel_map`` names an unknown channel or gas.
        FujiProtocolUnsupportedError: the type code does not name a ZP model.
        FujiError: a transaction failed.
    """
    asserted = coerce_channel_map(channel_map) if channel_map is not None else None
    session = self._session

    async def body(client: ProtocolClient, deadline: Deadline) -> DeviceInfo:
        identity = await session.profile.identify(client, probe=True, deadline=deadline)
        return session.learn_identity(identity, channel_map=asserted)

    return await session.run("identify", body, timeout=timeout)

manual_calibration

manual_calibration(
    plan,
    *,
    gas,
    confirm=False,
    rule=None,
    adc=False,
    interval=0.5,
    key_timeout=2.0,
    run_timeout=30.0,
    cleanup_timeout=30.0,
)

A manual zero or span of plan, driven from the host with the panel's keys.

Use it as an async context manager (design §6.5)::

plan = await anz.plan_manual_calibration("CH3", "span")
gas = CalibrationGas(20.95, "vol%", label="20.95 % O2 in N2")
async with anz.manual_calibration(plan, gas=gas, confirm=True) as run:
    await run.wait_steady()  # the operator opens the span-gas valve
    event = await run.calibrate(confirm=True)

Entering it checks, before any key, that the panel is on the measurement screen with no calibration flag set, key lock and output hold are off, the analyzer reports no instrument error, the plan reads the same again and calibrates no channel on both ranges, and gas (one for every established channel of the plan, or one for all) is each channel's calibration-gas setting. It then presses ZERO or SPAN, moves the cursor and selects the channel: the wait step, where the gas settles. These keys are STATEFUL; calibrate(confirm=True) sends the ENT that calibrates, which is DANGEROUS. Leaving the block returns the panel to measurement for the step it is on (ESC on the wait step), shielded from cancellation.

With adc the raw A/D values are read with each read on the wait step and kept in the record. interval is the time between those reads; the port is free between them, so a recording goes on.

Raises:

Type Description
FujiValidationError

a gas is missing or malformed, or a time is not positive; nothing was sent.

Source code in src/fujilib/devices/analyzer.py
def manual_calibration(
    self,
    plan: ManualCalibrationPlan,
    *,
    gas: CalibrationGas | Mapping[ChannelId | str, CalibrationGas],
    confirm: bool = False,
    rule: SteadinessRule | None = None,
    adc: bool = False,
    interval: float = 0.5,
    key_timeout: float = 2.0,
    run_timeout: float = 30.0,
    cleanup_timeout: float = 30.0,
) -> RemoteCalibration:
    """A manual zero or span of ``plan``, driven from the host with the panel's keys.

    Use it as an async context manager (design §6.5)::

        plan = await anz.plan_manual_calibration("CH3", "span")
        gas = CalibrationGas(20.95, "vol%", label="20.95 % O2 in N2")
        async with anz.manual_calibration(plan, gas=gas, confirm=True) as run:
            await run.wait_steady()  # the operator opens the span-gas valve
            event = await run.calibrate(confirm=True)

    Entering it checks, before any key, that the panel is on the
    measurement screen with no calibration flag set, key lock and output
    hold are off, the analyzer reports no instrument error, the plan reads
    the same again and calibrates no channel on both ranges, and ``gas``
    (one for every established channel of the plan, or one for all) is
    each channel's calibration-gas setting. It then presses ZERO or SPAN,
    moves the cursor and selects the channel: the wait step, where the
    gas settles. These keys are ``STATEFUL``; ``calibrate(confirm=True)``
    sends the ENT that calibrates, which is ``DANGEROUS``. Leaving the
    block returns the panel to measurement for the step it is on (ESC on
    the wait step), shielded from cancellation.

    With ``adc`` the raw A/D values are read with each read on the wait
    step and kept in the record. ``interval`` is the time between those
    reads; the port is free between them, so a recording goes on.

    Raises:
        FujiValidationError: a gas is missing or malformed, or a time is
            not positive; nothing was sent.
    """
    return RemoteCalibration(
        self._session,
        plan,
        gas=gas,
        confirm=confirm,
        rule=rule,
        adc=adc,
        interval=interval,
        key_timeout=key_timeout,
        run_timeout=run_timeout,
        cleanup_timeout=cleanup_timeout,
    )

plan_auto_calibration async

plan_auto_calibration(*, timeout=None)

What :meth:start_auto_calibration would calibrate, against which gases, for how long.

Read-only: the channels enabled for it, their ranges (both where the calibration range is "both"), the calibration gases, whether the outputs are held, and an estimated duration inferred from the flow times. Two transactions, plus the range tables if needed.

Source code in src/fujilib/devices/analyzer.py
async def plan_auto_calibration(self, *, timeout: float | None = None) -> CalibrationPlan:
    """What :meth:`start_auto_calibration` would calibrate, against which gases, for how long.

    Read-only: the channels enabled for it, their ranges (both where the
    calibration range is "both"), the calibration gases, whether the
    outputs are held, and an estimated duration inferred from the flow
    times. Two transactions, plus the range tables if needed.
    """
    return await self._plan("plan_auto_calibration", CalibrationRun.AUTO_CALIBRATION, timeout)

plan_auto_zero_calibration async

plan_auto_zero_calibration(*, timeout=None)

What :meth:start_auto_zero_calibration would zero; as :meth:plan_auto_calibration.

Source code in src/fujilib/devices/analyzer.py
async def plan_auto_zero_calibration(self, *, timeout: float | None = None) -> CalibrationPlan:
    """What :meth:`start_auto_zero_calibration` would zero; as :meth:`plan_auto_calibration`."""
    return await self._plan("plan_auto_zero_calibration", CalibrationRun.AUTO_ZERO, timeout)

plan_manual_calibration async

plan_manual_calibration(channel, kind, *, timeout=None)

What a manual zero or span of channel at the front panel would calibrate.

Read-only. A zero of a channel set to "at once" zeroes every channel so set, and a channel set to "both" is calibrated on both its ranges; the plan lists every channel and range with its calibration gas (design §6.5). Two transactions, plus the range tables if needed.

Raises:

Type Description
FujiValidationError

channel is not 1-5, or kind is not "zero" or "span"; nothing was sent.

FujiDecodeError

a range setting does not read as a range.

Source code in src/fujilib/devices/analyzer.py
async def plan_manual_calibration(
    self,
    channel: ChannelId | str,
    kind: ManualCalibrationKind | str,
    *,
    timeout: float | None = None,
) -> ManualCalibrationPlan:
    """What a manual zero or span of ``channel`` at the front panel would calibrate.

    Read-only. A zero of a channel set to "at once" zeroes every channel
    so set, and a channel set to "both" is calibrated on both its ranges;
    the plan lists every channel and range with its calibration gas
    (design §6.5). Two transactions, plus the range tables if needed.

    Raises:
        FujiValidationError: ``channel`` is not 1-5, or ``kind`` is not
            ``"zero"`` or ``"span"``; nothing was sent.
        FujiDecodeError: a range setting does not read as a range.
    """
    cid = _measured(channel)
    what = _manual_kind(kind)
    session = self._session

    async def body(client: ProtocolClient, deadline: Deadline) -> ManualCalibrationPlan:
        ranges = await session.ensure_ranges(client, deadline)
        settings = await reads.read_registers(
            client, panel.MANUAL_PLAN_SETTINGS, ranges=ranges, deadline=deadline
        )
        return panel.plan_manual_calibration(
            settings,
            what,
            cid,
            ranges=ranges,
            established=[c.channel for c in session.channels],
        )

    return await session.run("plan_manual_calibration", body, timeout=timeout)

poll async

poll(*, detail=True, timeout=None)

Read every established channel and the analyzer's status: two transactions.

Without detail only the concentrations are read (one transaction), and every reading's validity is unknown rather than assumed (design §4.3). A channel that reads non-zero for the first time joins the established channels, in this frame and every later one (design §2.9).

Raises:

Type Description
FujiError

a transaction failed. When the status block fails after the concentrations were read, the error's extra["completed"] holds the concentration words.

Source code in src/fujilib/devices/analyzer.py
async def poll(self, *, detail: bool = True, timeout: float | None = None) -> Frame:
    """Read every established channel and the analyzer's status: two transactions.

    Without ``detail`` only the concentrations are read (one transaction),
    and every reading's validity is unknown rather than assumed (design
    §4.3). A channel that reads non-zero for the first time joins the
    established channels, in this frame and every later one (design §2.9).

    Raises:
        FujiError: a transaction failed. When the status block fails after
            the concentrations were read, the error's ``extra["completed"]``
            holds the concentration words.
    """
    session = self._session

    async def body(client: ProtocolClient, deadline: Deadline) -> Frame:
        return session.learn_poll(
            await reads.read_poll(client, detail=detail, deadline=deadline)
        )

    return await session.run("poll", body, timeout=timeout)

read_adc async

read_adc(*, timeout=None)

The 21 raw A/D counts of the service manual's table (undocumented; design §6.6).

A service diagnostic, not a calibrated or higher-resolution gas measurement.

Raises:

Type Description
FujiCapabilityError

the analyzer has no A/D block; nothing was sent once known.

Source code in src/fujilib/devices/analyzer.py
async def read_adc(self, *, timeout: float | None = None) -> AdcValues:
    """The 21 raw A/D counts of the service manual's table (undocumented; design §6.6).

    A service diagnostic, not a calibrated or higher-resolution gas measurement.

    Raises:
        FujiCapabilityError: the analyzer has no A/D block; nothing was sent once known.
    """
    session = self._session

    async def body(client: ProtocolClient, deadline: Deadline) -> AdcValues:
        return await session.read_capability(
            Capability.ADC_VALUES, "read_adc", lambda: reads.read_adc(client, deadline=deadline)
        )

    return await session.run("read_adc", body, timeout=timeout, requires=Capability.ADC_VALUES)

read_calibration_log async

read_calibration_log(channel=None, *, timeout=None)

Calibration-log records, newest first per channel; firmware 2.24 or later.

channel None reads every established measured channel, in order. Each channel is seven transactions.

Raises:

Type Description
FujiValidationError

channel is not one of channels 1-5; nothing was sent.

FujiFirmwareError

the analyzer has no calibration log (older firmware); nothing was sent once known.

Source code in src/fujilib/devices/analyzer.py
async def read_calibration_log(
    self, channel: ChannelId | str | None = None, *, timeout: float | None = None
) -> tuple[CalibrationLogEntry, ...]:
    """Calibration-log records, newest first per channel; firmware 2.24 or later.

    ``channel`` ``None`` reads every established measured channel, in order.
    Each channel is seven transactions.

    Raises:
        FujiValidationError: ``channel`` is not one of channels 1-5; nothing was sent.
        FujiFirmwareError: the analyzer has no calibration log (older
            firmware); nothing was sent once known.
    """
    if channel is not None:
        wanted: tuple[ChannelId, ...] = (coerce_channel(channel),)
    else:
        wanted = tuple(c.channel for c in self._session.channels if c.channel.is_measured)
    for cid in wanted:
        if not cid.is_measured:
            msg = f"{cid.value} is a derived channel and has no calibration log"
            raise FujiValidationError(msg, context=ErrorContext(channel=cid.value))
    session = self._session

    async def body(
        client: ProtocolClient, deadline: Deadline
    ) -> tuple[CalibrationLogEntry, ...]:
        entries: list[CalibrationLogEntry] = []
        for cid in wanted:
            entries += await session.read_capability(
                Capability.CALIBRATION_LOG,
                "read_calibration_log",
                partial(reads.read_calibration_log, client, cid, deadline=deadline),
            )
        return tuple(entries)

    return await session.run(
        "read_calibration_log", body, timeout=timeout, requires=Capability.CALIBRATION_LOG
    )

read_channel async

read_channel(channel, *, timeout=None)

One established channel's reading, from a full poll.

Raises:

Type Description
FujiValidationError

channel is unknown or not established; nothing was sent. Assert it with channel_map to read it.

Source code in src/fujilib/devices/analyzer.py
async def read_channel(
    self, channel: ChannelId | str, *, timeout: float | None = None
) -> Reading:
    """One established channel's reading, from a full poll.

    Raises:
        FujiValidationError: ``channel`` is unknown or not established;
            nothing was sent. Assert it with ``channel_map`` to read it.
    """
    cid = self._established(channel)
    frame = await self.poll(timeout=timeout)
    return frame.channel(cid)

read_clock async

read_clock(*, timeout=None)

The analyzer's real-time clock and when it was read (undocumented; design §6.6).

Naive local time with a two-digit year; it drifts, and it never replaces host timestamps.

Raises:

Type Description
FujiCapabilityError

the analyzer has no clock; nothing was sent once known.

FujiDecodeError

the words are not a date.

Source code in src/fujilib/devices/analyzer.py
async def read_clock(self, *, timeout: float | None = None) -> ClockReading:
    """The analyzer's real-time clock and when it was read (undocumented; design §6.6).

    Naive local time with a two-digit year; it drifts, and it never replaces
    host timestamps.

    Raises:
        FujiCapabilityError: the analyzer has no clock; nothing was sent once known.
        FujiDecodeError: the words are not a date.
    """
    session = self._session

    async def body(client: ProtocolClient, deadline: Deadline) -> ClockReading:
        return await session.read_capability(
            Capability.CLOCK, "read_clock", lambda: reads.read_clock(client, deadline=deadline)
        )

    return await session.run("read_clock", body, timeout=timeout, requires=Capability.CLOCK)

read_error_log async

read_error_log(*, timeout=None)

The error log, newest first; up to 14 entries with day, hour and minute only.

Source code in src/fujilib/devices/analyzer.py
async def read_error_log(self, *, timeout: float | None = None) -> tuple[ErrorLogEntry, ...]:
    """The error log, newest first; up to 14 entries with day, hour and minute only."""

    async def body(client: ProtocolClient, deadline: Deadline) -> tuple[ErrorLogEntry, ...]:
        return await reads.read_error_log(client, deadline=deadline)

    return await self._session.run("read_error_log", body, timeout=timeout)

read_metadata async

read_metadata(*, timeout=None)

The settings snapshot a consumer records with its data (design §2.11, §7.2).

Response times, averaging, calibration gases and scope, hold, the automatic schedules, the current ranges and the analyzer's clock. Reads the identity first if identify() has not run, and the range tables if a poll saw a range change.

Raises:

Type Description
FujiError

a transaction failed.

Source code in src/fujilib/devices/analyzer.py
async def read_metadata(self, *, timeout: float | None = None) -> AnalyzerMetadata:
    """The settings snapshot a consumer records with its data (design §2.11, §7.2).

    Response times, averaging, calibration gases and scope, hold, the
    automatic schedules, the current ranges and the analyzer's clock. Reads
    the identity first if ``identify()`` has not run, and the range tables
    if a poll saw a range change.

    Raises:
        FujiError: a transaction failed.
    """
    session = self._session

    async def body(client: ProtocolClient, deadline: Deadline) -> AnalyzerMetadata:
        info = session.info
        if info is None:
            identity = await session.profile.identify(client, probe=True, deadline=deadline)
            info = session.learn_identity(identity)
        ranges = await session.ensure_ranges(client, deadline)
        clock = await self._clock_available(client, deadline)
        meta = await reads.read_metadata(
            client,
            serial_number=info.serial_number,
            ranges=ranges,
            channels=session.channels,
            clock=clock,
            deadline=deadline,
        )
        session.learn_current_ranges(meta.current_range)
        return meta

    return await session.run("read_metadata", body, timeout=timeout)

read_parameter async

read_parameter(name, *, alarm_targets=None, timeout=None)

One register by its name in the register map (docs/registers.md).

A range-scaled value uses the range tables, read first if needed. An alarm limit needs alarm_targets (alarm number to channel), because the target register's encoding is contested (design §5.2); without it the value is None and only the raw word is given.

Raises:

Type Description
FujiValidationError

name is not in the register map; nothing was sent.

Source code in src/fujilib/devices/analyzer.py
async def read_parameter(
    self,
    name: str,
    *,
    alarm_targets: Mapping[int, ChannelId | str] | None = None,
    timeout: float | None = None,
) -> RegisterValue:
    """One register by its name in the register map (``docs/registers.md``).

    A range-scaled value uses the range tables, read first if needed. An
    alarm limit needs ``alarm_targets`` (alarm number to channel), because
    the target register's encoding is contested (design §5.2); without it
    the value is ``None`` and only the raw word is given.

    Raises:
        FujiValidationError: ``name`` is not in the register map; nothing was sent.
    """
    values = await self._read_named("read_parameter", (name,), alarm_targets, timeout)
    return values[name]

read_parameters async

read_parameters(names, *, alarm_targets=None, timeout=None)

Several registers by name, read in the fewest blocks, in the order given.

Raises:

Type Description
FujiValidationError

a name is not in the register map; nothing was sent.

Source code in src/fujilib/devices/analyzer.py
async def read_parameters(
    self,
    names: Iterable[str],
    *,
    alarm_targets: Mapping[int, ChannelId | str] | None = None,
    timeout: float | None = None,
) -> Mapping[str, RegisterValue]:
    """Several registers by name, read in the fewest blocks, in the order given.

    Raises:
        FujiValidationError: a name is not in the register map; nothing was sent.
    """
    if isinstance(names, str):
        msg = "names must be a sequence of register names, not one string"
        raise FujiValidationError(msg)
    return await self._read_named("read_parameters", tuple(names), alarm_targets, timeout)

read_ranges async

read_ranges(*, timeout=None)

Read the range tables of channels 1-5; the session keeps them. One transaction.

Source code in src/fujilib/devices/analyzer.py
async def read_ranges(self, *, timeout: float | None = None) -> tuple[RangeInfo, ...]:
    """Read the range tables of channels 1-5; the session keeps them. One transaction."""
    session = self._session

    async def body(client: ProtocolClient, deadline: Deadline) -> tuple[RangeInfo, ...]:
        return session.learn_ranges(await reads.read_ranges(client, deadline=deadline))

    return await session.run("read_ranges", body, timeout=timeout)

read_settings async

read_settings(*, alarm_targets=None, timeout=None)

Every holding register, decoded, by name. Three transactions, plus the ranges if needed.

Raises:

Type Description
FujiError

a transaction failed.

Source code in src/fujilib/devices/analyzer.py
async def read_settings(
    self,
    *,
    alarm_targets: Mapping[int, ChannelId | str] | None = None,
    timeout: float | None = None,
) -> Mapping[str, RegisterValue]:
    """Every holding register, decoded, by name. Three transactions, plus the ranges if needed.

    Raises:
        FujiError: a transaction failed.
    """
    targets = _alarm_targets(alarm_targets)
    session = self._session

    async def body(client: ProtocolClient, deadline: Deadline) -> Mapping[str, RegisterValue]:
        ranges = await session.ensure_ranges(client, deadline)
        return await reads.read_settings(
            client, ranges=ranges, alarm_targets=targets, deadline=deadline
        )

    return await session.run("read_settings", body, timeout=timeout)

reopen async

reopen(*, timeout=None)

Open the port again and identify the analyzer, after a connection failure.

A connection failure breaks the session (every later call is refused); this is the way back without losing what the session learned. Only a port that open_device opened by name can be reopened. The station must answer as the same analyzer: the same serial number and type code.

Raises:

Type Description
FujiConfigurationError

the port came from the caller; or another analyzer answers on the station.

FujiConnectionError

the analyzer is closed, or the port cannot be opened.

FujiError

identification failed; the analyzer stays unusable.

Source code in src/fujilib/devices/analyzer.py
async def reopen(self, *, timeout: float | None = None) -> DeviceInfo:
    """Open the port again and identify the analyzer, after a connection failure.

    A connection failure breaks the session (every later call is refused);
    this is the way back without losing what the session learned. Only a
    port that ``open_device`` opened by name can be reopened. The station
    must answer as the same analyzer: the same serial number and type code.

    Raises:
        FujiConfigurationError: the port came from the caller; or another
            analyzer answers on the station.
        FujiConnectionError: the analyzer is closed, or the port cannot be opened.
        FujiError: identification failed; the analyzer stays unusable.
    """
    return await self._session.reopen(timeout=timeout)

reprobe async

reprobe(capability, *, timeout=None)

Probe capability again and return what was found (design §6.6).

Raises:

Type Description
FujiValidationError

capability is not a probed capability; nothing was sent.

Source code in src/fujilib/devices/analyzer.py
async def reprobe(
    self, capability: Capability, *, timeout: float | None = None
) -> Availability:
    """Probe ``capability`` again and return what was found (design §6.6).

    Raises:
        FujiValidationError: ``capability`` is not a probed capability; nothing was sent.
    """
    if capability not in PROBED_CAPABILITIES:
        names = ", ".join(str(c.name) for c in PROBED_CAPABILITIES)
        msg = f"{capability!r} is not a probed capability; probed are {names}"
        raise FujiValidationError(msg)
    session = self._session

    async def body(client: ProtocolClient, deadline: Deadline) -> Availability:
        probe = await reads.probe_capability(client, capability, deadline=deadline)
        session.set_availability(capability, probe.availability)
        return probe.availability

    return await session.run("reprobe", body, timeout=timeout)

return_to_measurement async

return_to_measurement(*, confirm=False, timeout=None)

Put the front panel back on the measurement screen (42002). STATEFUL.

It takes an operator out of whatever menu they are in, and out of a manual calibration's channel selection, so it is not refused there. It does not stop or cancel a calibration, and is refused while one is under way: on a manual calibration's wait step 42002 brings the display back but leaves the calibration's flag set, and nothing at the panel shows it (protocol findings §18.4, design §13.1 #83). ESC on the wait step, at the panel, cancels a manual calibration.

Raises:

Type Description
FujiConfirmationRequiredError

confirm is not True; nothing was sent.

FujiAnalyzerStateError

a calibration, automatic or manual, is under way; nothing was sent.

FujiVerificationError

acknowledged, but the panel does not show the measurement screen; or it shows it with a calibration flag set, which a calibration started at the panel meanwhile would do.

FujiWriteOutcomeUnknownError

its reply was lost and the status cannot be read.

FujiError

a transaction failed.

Source code in src/fujilib/devices/analyzer.py
async def return_to_measurement(
    self, *, confirm: bool = False, timeout: float | None = None
) -> CommandResult:
    """Put the front panel back on the measurement screen (42002). STATEFUL.

    It takes an operator out of whatever menu they are in, and out of a
    manual calibration's channel selection, so it is not refused there. It
    does not stop or cancel a calibration, and is refused while one is under
    way: on a manual calibration's wait step 42002 brings the display back
    but leaves the calibration's flag set, and nothing at the panel shows it
    (protocol findings §18.4, design §13.1 #83). ESC on the wait step, at
    the panel, cancels a manual calibration.

    Raises:
        FujiConfirmationRequiredError: ``confirm`` is not ``True``; nothing was sent.
        FujiAnalyzerStateError: a calibration, automatic or manual, is under
            way; nothing was sent.
        FujiVerificationError: acknowledged, but the panel does not show
            the measurement screen; or it shows it with a calibration flag
            set, which a calibration started at the panel meanwhile would do.
        FujiWriteOutcomeUnknownError: its reply was lost and the status
            cannot be read.
        FujiError: a transaction failed.
    """
    return await self._command("return_to_measurement", None, confirm, timeout)

set_calibration_gas async

set_calibration_gas(
    channel,
    range_number,
    kind,
    value,
    *,
    unit,
    confirm=False,
    timeout=None,
)

Set the zero or span calibration gas of a measured channel's range. DANGEROUS.

It takes effect at the next calibration, manual or automatic, and a wrong value miscalibrates the analyzer then. unit must be the range's own; span gas is limited to 1-105 % and zero gas to 0-100 % of the range's full scale.

Raises:

Type Description
FujiValidationError

channel, range_number or kind ("zero" or "span") is not one there is, or the value does not fit the range.

FujiError

as :meth:write_parameter.

Source code in src/fujilib/devices/analyzer.py
async def set_calibration_gas(
    self,
    channel: ChannelId | str,
    range_number: int,
    kind: str,
    value: float | str,
    *,
    unit: Unit | str,
    confirm: bool = False,
    timeout: float | None = None,
) -> WriteResult:
    """Set the zero or span calibration gas of a measured channel's range. DANGEROUS.

    It takes effect at the next calibration, manual or automatic, and a
    wrong value miscalibrates the analyzer then. ``unit`` must be the
    range's own; span gas is limited to 1-105 % and zero gas to 0-100 % of
    the range's full scale.

    Raises:
        FujiValidationError: ``channel``, ``range_number`` or ``kind``
            (``"zero"`` or ``"span"``) is not one there is, or the value
            does not fit the range.
        FujiError: as :meth:`write_parameter`.
    """
    cid = _measured(channel)
    if (
        range_number not in {1, 2}
        or isinstance(range_number, bool)
        or kind
        not in {
            "zero",
            "span",
        }
    ):
        msg = f"expected range 1 or 2 and kind 'zero' or 'span', got {range_number!r}, {kind!r}"
        raise FujiValidationError(msg, context=ErrorContext(channel=cid.value))
    spec = self._writable(f"calibration_gas.ch{cid.number}.range{range_number}.{kind}")
    return await self._write(
        "set_calibration_gas", spec, value, unit=unit, confirm=confirm, timeout=timeout
    )

set_hold_mode async

set_hold_mode(mode, *, confirm=False, timeout=None)

What the outputs hold during calibration: last_value or setting.

Raises:

Type Description
FujiError

as :meth:write_parameter.

Source code in src/fujilib/devices/analyzer.py
async def set_hold_mode(
    self, mode: HoldMode | str, *, confirm: bool = False, timeout: float | None = None
) -> WriteResult:
    """What the outputs hold during calibration: ``last_value`` or ``setting``.

    Raises:
        FujiError: as :meth:`write_parameter`.
    """
    spec = self._writable("hold.mode")
    return await self._write("set_hold_mode", spec, mode, confirm=confirm, timeout=timeout)

set_hold_value async

set_hold_value(
    channel, percent_fs, *, confirm=False, timeout=None
)

The value a measured channel holds in setting mode, 0-100 % of full scale.

Raises:

Type Description
FujiValidationError

channel is not one of channels 1-5.

FujiError

as :meth:write_parameter.

Source code in src/fujilib/devices/analyzer.py
async def set_hold_value(
    self,
    channel: ChannelId | str,
    percent_fs: int,
    *,
    confirm: bool = False,
    timeout: float | None = None,
) -> WriteResult:
    """The value a measured channel holds in ``setting`` mode, 0-100 % of full scale.

    Raises:
        FujiValidationError: ``channel`` is not one of channels 1-5.
        FujiError: as :meth:`write_parameter`.
    """
    cid = _measured(channel)
    spec = self._writable(f"hold.ch{cid.number}.value")
    return await self._write(
        "set_hold_value", spec, percent_fs, confirm=confirm, timeout=timeout
    )

set_output_hold async

set_output_hold(enabled, *, confirm=False, timeout=None)

Hold the outputs, and the Modbus concentrations, during calibration, or not.

Raises:

Type Description
FujiError

as :meth:write_parameter.

Source code in src/fujilib/devices/analyzer.py
async def set_output_hold(
    self, enabled: bool, *, confirm: bool = False, timeout: float | None = None
) -> WriteResult:
    """Hold the outputs, and the Modbus concentrations, during calibration, or not.

    Raises:
        FujiError: as :meth:`write_parameter`.
    """
    spec = self._writable("output_hold.enabled")
    return await self._write("set_output_hold", spec, enabled, confirm=confirm, timeout=timeout)

set_range async

set_range(
    channel, range_number, *, confirm=False, timeout=None
)

Select range 1 or 2 of a measured channel, whose range method must be manual.

It returns once the channel measures on the range: the analyzer switches some tens of milliseconds after the setting reads back, so the channel's current range is read until it follows, within the read-back budget.

Raises:

Type Description
FujiValidationError

channel is not one of channels 1-5, range_number is not 1 or 2, or the channel's range method is not manual; nothing was written.

FujiVerificationError

the setting reads back otherwise, or it reads back as written but the channel did not switch to it.

FujiError

as :meth:write_parameter.

Source code in src/fujilib/devices/analyzer.py
async def set_range(
    self,
    channel: ChannelId | str,
    range_number: int,
    *,
    confirm: bool = False,
    timeout: float | None = None,
) -> WriteResult:
    """Select range 1 or 2 of a measured channel, whose range method must be manual.

    It returns once the channel measures on the range: the analyzer
    switches some tens of milliseconds after the setting reads back, so
    the channel's current range is read until it follows, within the
    read-back budget.

    Raises:
        FujiValidationError: ``channel`` is not one of channels 1-5,
            ``range_number`` is not 1 or 2, or the channel's range method
            is not manual; nothing was written.
        FujiVerificationError: the setting reads back otherwise, or it
            reads back as written but the channel did not switch to it.
        FujiError: as :meth:`write_parameter`.
    """
    cid = _measured(channel)
    if range_number not in {1, 2} or isinstance(range_number, bool):
        msg = f"range_number must be 1 or 2, got {range_number!r}"
        raise FujiValidationError(msg, context=ErrorContext(channel=cid.value))
    spec = self._writable(f"range.ch{cid.number}.selected")
    index = RangeIndex(range_number - 1)
    return await self._write("set_range", spec, index, confirm=confirm, timeout=timeout)

set_range_method async

set_range_method(
    channel, method, *, confirm=False, timeout=None
)

How a measured channel changes range: manual or auto (remote is refused).

Raises:

Type Description
FujiValidationError

channel is not one of channels 1-5, or the method is not manual or auto.

FujiError

as :meth:write_parameter.

Source code in src/fujilib/devices/analyzer.py
async def set_range_method(
    self,
    channel: ChannelId | str,
    method: RangeMethod | str,
    *,
    confirm: bool = False,
    timeout: float | None = None,
) -> WriteResult:
    """How a measured channel changes range: ``manual`` or ``auto`` (``remote`` is refused).

    Raises:
        FujiValidationError: ``channel`` is not one of channels 1-5, or the
            method is not ``manual`` or ``auto``.
        FujiError: as :meth:`write_parameter`.
    """
    cid = _measured(channel)
    spec = self._writable(f"range.ch{cid.number}.method")
    return await self._write("set_range_method", spec, method, confirm=confirm, timeout=timeout)

set_response_time async

set_response_time(
    target, seconds, *, confirm=False, timeout=None
)

Set a response time, 0-60 s, by channel or by slot ("o2", "ndir1".."ndir4").

There are four NDIR-component slots and one O2 slot, not one per channel (design §2.6). A channel is mapped to its slot only through asserted gases: O2 to the O2 slot, the n-th NDIR channel to ndirn, which needs every channel before it asserted. Otherwise name the slot.

0 switches the analyzer's filter off (protocol findings §20).

Raises:

Type Description
FujiValidationError

the channel cannot be mapped to a slot; as :meth:write_parameter otherwise.

FujiError

as :meth:write_parameter.

Source code in src/fujilib/devices/analyzer.py
async def set_response_time(
    self,
    target: ChannelId | str,
    seconds: int,
    *,
    confirm: bool = False,
    timeout: float | None = None,
) -> WriteResult:
    """Set a response time, 0-60 s, by channel or by slot (``"o2"``, ``"ndir1"``..``"ndir4"``).

    There are four NDIR-component slots and one O2 slot, not one per
    channel (design §2.6). A channel is mapped to its slot only through
    asserted gases: O2 to the O2 slot, the n-th NDIR channel to ``ndirn``,
    which needs every channel before it asserted. Otherwise name the slot.

    0 switches the analyzer's filter off (protocol findings §20).

    Raises:
        FujiValidationError: the channel cannot be mapped to a slot; as
            :meth:`write_parameter` otherwise.
        FujiError: as :meth:`write_parameter`.
    """
    name = self._response_time_name(target)
    return await self._write(
        "set_response_time", self._writable(name), seconds, confirm=confirm, timeout=timeout
    )

snapshot async

snapshot(*, name=None)

Identity and health from cached state, with no I/O (unified API §H).

name defaults to the model, or "analyzer" before identify().

Source code in src/fujilib/devices/analyzer.py
async def snapshot(self, *, name: str | None = None) -> FujiDeviceSnapshot:
    """Identity and health from cached state, with no I/O (unified API §H).

    ``name`` defaults to the model, or ``"analyzer"`` before ``identify()``.
    """
    return self._session.snapshot(name=name)

start_auto_calibration async

start_auto_calibration(*, confirm=False, timeout=None)

Run auto calibration once (42003). DANGEROUS: it overwrites the calibration.

It zeroes and spans every channel enabled for it against the calibration gases the analyzer's own valves let in, so it is right only where those gases are plumbed. It needs the auto-calibration option, which the type code lists or open_device(options=...) asserts. It is refused while a calibration runs, the panel is in a menu, or the analyzer reports an instrument error. The plan is read and returned with the result; no register stops a calibration once started.

Raises:

Type Description
FujiConfirmationRequiredError

confirm is not True; nothing was sent.

FujiCapabilityError

the option is not fitted; nothing was sent.

FujiAnalyzerStateError

the analyzer's state forbids it; nothing was sent.

FujiWriteOutcomeUnknownError

its reply was lost and the status does not show it running.

FujiError

a transaction failed.

Source code in src/fujilib/devices/analyzer.py
async def start_auto_calibration(
    self, *, confirm: bool = False, timeout: float | None = None
) -> CommandResult:
    """Run auto calibration once (42003). DANGEROUS: it overwrites the calibration.

    It zeroes and spans every channel enabled for it against the
    calibration gases the analyzer's own valves let in, so it is right only
    where those gases are plumbed. It needs the auto-calibration option,
    which the type code lists or ``open_device(options=...)`` asserts. It is
    refused while a calibration runs, the panel is in a menu, or the
    analyzer reports an instrument error. The plan is read and returned
    with the result; no register stops a calibration once started.

    Raises:
        FujiConfirmationRequiredError: ``confirm`` is not ``True``; nothing was sent.
        FujiCapabilityError: the option is not fitted; nothing was sent.
        FujiAnalyzerStateError: the analyzer's state forbids it; nothing was sent.
        FujiWriteOutcomeUnknownError: its reply was lost and the status
            does not show it running.
        FujiError: a transaction failed.
    """
    return await self._command(
        "start_auto_calibration", CalibrationRun.AUTO_CALIBRATION, confirm, timeout
    )

start_auto_zero_calibration async

start_auto_zero_calibration(*, confirm=False, timeout=None)

Run auto zero calibration once (42004). DANGEROUS; as :meth:start_auto_calibration.

It zeroes every channel enabled for auto calibration, and needs the auto-zero option.

Source code in src/fujilib/devices/analyzer.py
async def start_auto_zero_calibration(
    self, *, confirm: bool = False, timeout: float | None = None
) -> CommandResult:
    """Run auto zero calibration once (42004). DANGEROUS; as :meth:`start_auto_calibration`.

    It zeroes every channel enabled for auto calibration, and needs the
    auto-zero option.
    """
    return await self._command(
        "start_auto_zero_calibration", CalibrationRun.AUTO_ZERO, confirm, timeout
    )

start_blowback async

start_blowback(*, confirm=False, timeout=None)

Run blowback once (42005). STATEFUL; the blowback option, which no ZPA has.

No register shows blowback running, so the outcome is only sent.

Raises:

Type Description
FujiCapabilityError

the analyzer has no blowback; nothing was sent.

FujiError

as :meth:start_auto_calibration.

Source code in src/fujilib/devices/analyzer.py
async def start_blowback(
    self, *, confirm: bool = False, timeout: float | None = None
) -> CommandResult:
    """Run blowback once (42005). STATEFUL; the blowback option, which no ZPA has.

    No register shows blowback running, so the outcome is only ``sent``.

    Raises:
        FujiCapabilityError: the analyzer has no blowback; nothing was sent.
        FujiError: as :meth:`start_auto_calibration`.
    """
    return await self._command("start_blowback", None, confirm, timeout)

status async

status(*, timeout=None)

The analyzer's status: errors, alarms, auto calibration, display. Two transactions.

Source code in src/fujilib/devices/analyzer.py
async def status(self, *, timeout: float | None = None) -> AnalyzerStatus:
    """The analyzer's status: errors, alarms, auto calibration, display. Two transactions."""
    status = await self._read_status("status", timeout)
    return status.analyzer

wait_for_calibration async

wait_for_calibration(*, timeout, interval=2.0, since=None)

Wait until nothing is calibrating, reading the status every interval seconds.

The port is free between reads, so a recording goes on meanwhile. timeout bounds the whole wait. The result says whether a calibration was seen running at all (if not, it may have ended already), whether any error 4-9 is active at the end, and which appeared since since: pass the before of the command's result, or the wait's own first read is the baseline.

Raises:

Type Description
FujiValidationError

timeout or interval is not a positive number.

FujiTimeoutError

something was still calibrating at timeout; its context says whether a calibration was seen and how many reads were made.

FujiError

a status read failed.

Source code in src/fujilib/devices/analyzer.py
async def wait_for_calibration(
    self,
    *,
    timeout: float,
    interval: float = 2.0,
    since: CalibrationStatus | None = None,
) -> CalibrationWait:
    """Wait until nothing is calibrating, reading the status every ``interval`` seconds.

    The port is free between reads, so a recording goes on meanwhile.
    ``timeout`` bounds the whole wait. The result says whether a
    calibration was seen running at all (if not, it may have ended
    already), whether any error 4-9 is active at the end, and which
    appeared since ``since``: pass the ``before`` of the command's result,
    or the wait's own first read is the baseline.

    Raises:
        FujiValidationError: ``timeout`` or ``interval`` is not a positive number.
        FujiTimeoutError: something was still calibrating at ``timeout``; its
            context says whether a calibration was seen and how many reads
            were made.
        FujiError: a status read failed.
    """
    _check_seconds("timeout", timeout)
    _check_seconds("interval", interval)
    deadline = Deadline.after(timeout, operation="wait_for_calibration")
    saw_running = False
    polls = 0
    try:
        with deadline.enforce():
            first = await self.calibration_status()
            status = first
            while True:
                polls += 1
                if not status.busy:
                    return CalibrationWait(
                        final=status,
                        saw_running=saw_running,
                        polls=polls,
                        elapsed_s=deadline.elapsed(),
                        new_errors=_new_errors((since or first).errors, status.errors),
                    )
                saw_running = True
                await anyio.sleep(interval)
                status = await self.calibration_status()
    except FujiTimeoutError as exc:
        raise exc.with_context(saw_running=saw_running, polls=polls) from exc.__cause__

wait_for_manual_calibration async

wait_for_manual_calibration(
    *, timeout, interval=0.5, adc=False
)

Wait for a manual zero or span made at the front panel to end, and describe it.

Polls every interval seconds (two transactions, and the A/D block with adc) until a pass through the calibration steps ends, one already under way included. The event gives the channels, how it ended and why, the readings before and after, the calibration gases and, with adc, the raw A/D values when it ran (design §6.5). The port is free between polls, so a recording goes on meanwhile.

A calibration runs for a second or two, so a long interval can miss it; the undocumented result register usually settles the outcome then, and otherwise the event says ambiguous.

Raises:

Type Description
FujiValidationError

timeout or interval is not a positive number of seconds; nothing was sent.

FujiCapabilityError

adc is set and the analyzer is known to have no A/D block; nothing was sent.

FujiTimeoutError

no pass ended within timeout; its context says whether one was under way and how many polls were made.

FujiError

a read failed.

Source code in src/fujilib/devices/analyzer.py
async def wait_for_manual_calibration(
    self, *, timeout: float, interval: float = 0.5, adc: bool = False
) -> ManualCalibrationEvent:
    """Wait for a manual zero or span made at the front panel to end, and describe it.

    Polls every ``interval`` seconds (two transactions, and the A/D block
    with ``adc``) until a pass through the calibration steps ends, one
    already under way included. The event gives the channels, how it
    ended and why, the readings before and after, the calibration gases
    and, with ``adc``, the raw A/D values when it ran (design §6.5). The
    port is free between polls, so a recording goes on meanwhile.

    A calibration runs for a second or two, so a long ``interval`` can
    miss it; the undocumented result register usually settles the outcome
    then, and otherwise the event says ``ambiguous``.

    Raises:
        FujiValidationError: ``timeout`` or ``interval`` is not a positive
            number of seconds; nothing was sent.
        FujiCapabilityError: ``adc`` is set and the analyzer is known to
            have no A/D block; nothing was sent.
        FujiTimeoutError: no pass ended within ``timeout``; its context says
            whether one was under way and how many polls were made.
        FujiError: a read failed.
    """
    _check_seconds("timeout", timeout)
    _check_seconds("interval", interval)
    requires = Capability.ADC_VALUES if adc else Capability.NONE
    self._session.gate("wait_for_manual_calibration", requires=requires)
    tracker = ManualCalibrationTracker()
    deadline = Deadline.after(timeout, operation="wait_for_manual_calibration")
    polls = 0
    event: ManualCalibrationEvent | None = None
    try:
        with deadline.enforce():
            while event is None:
                started = anyio.current_time()
                frame = await self.poll()
                values = await self.read_adc() if adc else None
                polls += 1
                observation = PanelObservation.from_frame(frame, adc=values)
                event = tracker.feed(observation) if observation is not None else None
                if event is None:
                    await anyio.sleep(max(0.0, interval - (anyio.current_time() - started)))
    except FujiTimeoutError as exc:
        raise exc.with_context(polls=polls, in_progress=tracker.active) from exc.__cause__
    return await self._with_gases(event)

write_parameter async

write_parameter(
    name, value, *, unit=None, confirm=False, timeout=None
)

Write one setting by its name in the register map, then read it back (design §6.3).

Only the reviewed subset is writable (docs/registers.md, design §5.4). value is True/False for a flag, an enum member or its name for an enumerated setting, a whole number for a time or count, and for a calibration gas a number in unit, which is then required and must be the unit of the gas's (channel, range).

Before the write the analyzer's status is read, and the write refused while a calibration runs or the front panel is in a menu. The write is sent once, never retried, and read back whatever happens to its reply. About six transactions.

Raises:

Type Description
FujiValidationError

an unknown or read-only name, or a value that does not fit; nothing was written.

FujiConfirmationRequiredError

confirm is not True; nothing was sent.

FujiAnalyzerStateError

a calibration is running or the panel is in a menu; nothing was written.

FujiModbusError

the analyzer refused the write; nothing was applied.

FujiVerificationError

the setting reads back as something else.

FujiWriteOutcomeUnknownError

neither the write's reply nor the read-back arrived; the write may or may not have been applied.

Source code in src/fujilib/devices/analyzer.py
async def write_parameter(
    self,
    name: str,
    value: object,
    *,
    unit: Unit | str | None = None,
    confirm: bool = False,
    timeout: float | None = None,
) -> WriteResult:
    """Write one setting by its name in the register map, then read it back (design §6.3).

    Only the reviewed subset is writable (``docs/registers.md``, design
    §5.4). ``value`` is ``True``/``False`` for a flag, an enum member or its
    name for an enumerated setting, a whole number for a time or count, and
    for a calibration gas a number in ``unit``, which is then required and
    must be the unit of the gas's (channel, range).

    Before the write the analyzer's status is read, and the write refused
    while a calibration runs or the front panel is in a menu. The write is
    sent once, never retried, and read back whatever happens to its reply.
    About six transactions.

    Raises:
        FujiValidationError: an unknown or read-only name, or a value that
            does not fit; nothing was written.
        FujiConfirmationRequiredError: ``confirm`` is not ``True``; nothing
            was sent.
        FujiAnalyzerStateError: a calibration is running or the panel is in
            a menu; nothing was written.
        FujiModbusError: the analyzer refused the write; nothing was applied.
        FujiVerificationError: the setting reads back as something else.
        FujiWriteOutcomeUnknownError: neither the write's reply nor the
            read-back arrived; the write may or may not have been applied.
    """
    spec = self._writable(name)
    return await self._write(
        "write_parameter", spec, value, unit=unit, confirm=confirm, timeout=timeout
    )

fujilib.devices.session

The session: the only path from the facade to the wire (design §6).

Every I/O call of an :class:~fujilib.devices.analyzer.Analyzer goes through :meth:Session.run, which walks the gates (:meth:Session.gate) before anything is sent (design §6.1):

  1. State. The session is open, and no connection failure has broken it.
  2. Safety tier. Anything above READ_ONLY needs confirm=True.
  3. Capability. An operation that needs a probed capability is refused while that capability is known to be UNSUPPORTED (design §6.6), and one that needs an option is refused unless the type code lists it or the caller asserted it (:attr:Session.options).

The facade resolves names and checks access before the gates, and checks values after them, still before any I/O. run then starts the operation deadline, which also covers the wait for the port's operation lock (design §6.4), and holds the lock for the whole operation, so its transactions are never interleaved with other traffic on the port. A failure is kept as :attr:Session.last_error. A connection failure, or a write whose outcome is unknown because the port failed, also breaks the session: every later call is refused until the analyzer is opened again.

A setting write (:meth:Session.write_setting) then reads the analyzer's status and refuses to write while a calibration runs or the front panel is in a menu, or while this session drives a manual calibration at the panel (:meth:Session.claim_panel); reads the setting and what it depends on; for a range-scaled value, reads the range it is scaled by; encodes the value; writes it once and reads it back (:mod:fujilib.devices.writes); and, for a channel's selected range, waits until the channel measures on it.

The session keeps what it has learned about the station (design §6.7):

Cache Filled by Changed by
identity (DeviceInfo) identify() identify(); what the rows below learn
established channels the caller's assertion; a non-zero reading only ever grows
ranges identify(), read_ranges() re-read when a poll sees a current range change
availability per capability the probes reprobe(); every read of the capability
last frame poll() the next poll()

The front panel stays live, so none of this stays authoritative for long: an operator can change a range or a setting at any time (design §1).

A session whose port was opened by name can be reopened after a connection failure (:meth:Session.reopen): the port is opened again under the same settings, the analyzer is identified again and must be the same one, and what the session had learned is kept. When the reopen follows a connection failure, readings that would be ok are settling for a while (:attr:Session.settling_until, design §8): a pulled cable and a power cut look alike from here, and an analyzer that was switched off reports nothing of its own while it warms up.

Reopener

Reopener = Callable[[], Awaitable[ModbusPort]]

Opens the session's port again, as it was first opened.

Session

Session(
    port,
    *,
    address,
    profile,
    channel_map=None,
    reopener=None,
    options=Capability.NONE,
    verify_timeout=None,
    write_warn_per_minute=DEFAULTS.write_warn_per_minute,
    settle_after_reopen_s=DEFAULTS.settle_after_reopen_s,
)

One station's session: gates, the operation lock, deadlines and caches.

Created by :func:~fujilib.devices.factory.open_device; reached as :attr:Analyzer.session <fujilib.devices.analyzer.Analyzer.session>.

Bind to station address on port, which the session then owns.

reopener opens the port again for :meth:reopen; without one the session cannot be reopened. options are options the caller asserts are fitted, whatever the type code says. verify_timeout bounds the read after a write or command; by default it is what the port's timing allows two block reads to take, each with its retries and a late-reply window. write_warn_per_minute is where the write-rate warning starts (0 for never). settle_after_reopen_s is how long readings are settling after a reopen that follows a connection failure (0 for not at all).

Raises:

Type Description
FujiValidationError

address is not a station number, 1-31; options holds something that is not an option; settle_after_reopen_s is negative or not finite.

Source code in src/fujilib/devices/session.py
def __init__(
    self,
    port: ModbusPort,
    *,
    address: int,
    profile: DeviceProfile,
    channel_map: Mapping[ChannelId, Gas] | None = None,
    reopener: Reopener | None = None,
    options: Capability = Capability.NONE,
    verify_timeout: float | None = None,
    write_warn_per_minute: int = DEFAULTS.write_warn_per_minute,
    settle_after_reopen_s: float = DEFAULTS.settle_after_reopen_s,
) -> None:
    """Bind to station ``address`` on ``port``, which the session then owns.

    ``reopener`` opens the port again for :meth:`reopen`; without one the
    session cannot be reopened. ``options`` are options the caller asserts
    are fitted, whatever the type code says. ``verify_timeout`` bounds the
    read after a write or command; by default it is what the port's timing
    allows two block reads to take, each with its retries and a late-reply
    window. ``write_warn_per_minute`` is where the write-rate warning
    starts (0 for never). ``settle_after_reopen_s`` is how long readings
    are ``settling`` after a reopen that follows a connection failure (0
    for not at all).

    Raises:
        FujiValidationError: ``address`` is not a station number, 1-31;
            ``options`` holds something that is not an option;
            ``settle_after_reopen_s`` is negative or not finite.
    """
    if options & ~OPTION_CAPABILITIES:
        msg = f"options must be option capabilities, got {options!r}"
        raise FujiValidationError(msg)
    if not math.isfinite(settle_after_reopen_s) or settle_after_reopen_s < 0:
        msg = (
            "settle_after_reopen_s must be finite seconds, 0 or more; "
            f"got {settle_after_reopen_s!r}"
        )
        raise FujiValidationError(msg)
    self._client = port.client(address)
    self._port = port
    self._reopener = reopener
    self._reopening = anyio.Lock()
    self._profile = profile
    self._asserted: Mapping[ChannelId, Gas] = MappingProxyType(dict(channel_map or {}))
    self._state = SessionState.OPEN
    self._info: DeviceInfo | None = None
    self._seen: frozenset[ChannelId] = frozenset()
    self._channels: tuple[ChannelInfo, ...] = ()
    self._ranges: tuple[RangeInfo, ...] | None = None
    self._ranges_stale = False
    self._current_ranges: Mapping[ChannelId, int] | None = None
    self._availability = dict.fromkeys(PROBED_CAPABILITIES, Availability.UNKNOWN)
    self._last_frame: Frame | None = None
    self._last_error: ErrorContext | None = None
    self._warned: set[tuple[ChannelId, Gas, Gas]] = set()
    self._asserted_options = options
    self._verify_timeout = (
        verify_timeout if verify_timeout is not None else _read_budget(port, blocks=2)
    )
    self._write_rate = WriteRateMonitor(warn_per_minute=write_warn_per_minute)
    self._panel_driver: str | None = None
    self._settle_after_reopen_s = float(settle_after_reopen_s)
    # The end of the settling period: on the clock a poll is timed by, and on the wall clock.
    self._settling: tuple[int, datetime] | None = None
    self._relabel()

address property

address

The station number, 1-31.

asserted property

asserted

The caller's channel map.

asserted_options property

asserted_options

The options the caller asserted when opening the analyzer.

availability property

availability

What is known about each probed capability.

channels property

channels

The established channels, labelled (design §2.9).

connected property

connected

Whether the session is open and its transport is too.

counters property

counters

The station's traffic counters: requests, retries and failures by kind.

Live, and counted over the whole session: after :meth:reopen the same object goes on counting.

current_ranges property

current_ranges

The range each of channels 1-5 was last seen measuring on, or None.

info property

info

What identify() established, kept current; None before it.

last_error property

last_error

The context of the most recent failure, or None.

last_frame property

last_frame

The most recent poll's frame, or None.

options property

options

The options taken as fitted: asserted, or listed by the type code.

An option the model's manual does not describe at all is never taken as fitted, even when asserted.

panel_claimed property

panel_claimed

Whether this session is driving a manual calibration at the front panel.

port property

port

The canonical port name.

profile property

profile

The analyzer family.

protocol property

protocol

Always :attr:ProtocolKind.MODBUS_RTU.

ranges property

ranges

The range tables last read, or None.

recoverable_error_count property

recoverable_error_count

Failed read attempts that a retry of the same read recovered (unified API §J).

Counted over the whole session, across :meth:reopen.

reopenable property

reopenable

Whether :meth:reopen can open the port again: it was opened by name.

serial_settings property

serial_settings

The serial settings in use.

settling_until property

settling_until

When readings stop being marked settling (UTC), or None when they are not.

The period starts when :meth:reopen brings back a session that a connection failure had broken, and lasts the settle_after_reopen_s the session was opened with (design §8).

state property

state

Open, broken by a connection failure, or closed.

verify_timeout property

verify_timeout

Seconds the read after a write or command may take, whatever the deadline.

Design §6.4.

write_rate property

write_rate

The setting writes of the last minute (design §6.3).

check_not_calibrating async

check_not_calibrating(client, deadline, operation)

Read the status, and refuse return to measurement while a calibration is under way.

A menu or a manual calibration's channel selection does not refuse it: closing them is what it is for. On a manual calibration's wait step 42002 brings the display back but leaves the channel's flag set (protocol findings §18.4), and during a running calibration what it does is not known, so it is refused whenever a calibration flag is set (design §13.1 #83).

Raises:

Type Description
FujiAnalyzerStateError

a calibration, automatic or manual, is under way, or this session drives one; nothing was written.

Source code in src/fujilib/devices/session.py
async def check_not_calibrating(
    self, client: ProtocolClient, deadline: Deadline, operation: str
) -> StatusRead:
    """Read the status, and refuse return to measurement while a calibration is under way.

    A menu or a manual calibration's channel selection does not refuse it:
    closing them is what it is for. On a manual calibration's wait step
    42002 brings the display back but leaves the channel's flag set
    (protocol findings §18.4), and during a running calibration what it does
    is not known, so it is refused whenever a calibration flag is set
    (design §13.1 #83).

    Raises:
        FujiAnalyzerStateError: a calibration, automatic or manual, is under
            way, or this session drives one; nothing was written.
    """
    self._check_panel_free(operation)
    status = await read_status(client, deadline=deadline)
    self.learn_current_ranges({c: s.range for c, s in status.channels.items()})
    reasons = calibrating_reasons(status)
    if reasons:
        msg = (
            f"{operation} refused, nothing was written: {'; '.join(reasons)}. On a "
            "manual calibration's wait step it would bring the display back but leave "
            "the calibration's flag set; ESC on the wait step cancels it"
        )
        raise FujiAnalyzerStateError(
            msg, context=self._context(operation).merged(reasons=tuple(reasons))
        )
    return status

check_quiet async

check_quiet(client, deadline, operation)

Read the status, and refuse a write or command it forbids (design §6.1).

Raises:

Type Description
FujiAnalyzerStateError

a calibration is running, the front panel is in a menu, or this session drives a manual calibration; nothing was written.

Source code in src/fujilib/devices/session.py
async def check_quiet(
    self, client: ProtocolClient, deadline: Deadline, operation: str
) -> StatusRead:
    """Read the status, and refuse a write or command it forbids (design §6.1).

    Raises:
        FujiAnalyzerStateError: a calibration is running, the front panel
            is in a menu, or this session drives a manual calibration;
            nothing was written.
    """
    self._check_panel_free(operation)
    status = await read_status(client, deadline=deadline)
    self.learn_current_ranges({c: s.range for c, s in status.channels.items()})
    reasons = busy_reasons(status)
    if reasons:
        msg = f"{operation} refused, nothing was written: {'; '.join(reasons)}"
        raise FujiAnalyzerStateError(
            msg, context=self._context(operation).merged(reasons=tuple(reasons))
        )
    return status

claim_panel

claim_panel(operation)

Take the front panel for operation, a remote manual calibration (design §6.5).

One at a time: the keys of two would interleave. While it is held, the session refuses setting writes and commands, which would change what the calibration was planned from or take the panel from it.

Raises:

Type Description
FujiAnalyzerStateError

another remote manual calibration holds it.

Source code in src/fujilib/devices/session.py
def claim_panel(self, operation: str) -> None:
    """Take the front panel for ``operation``, a remote manual calibration (design §6.5).

    One at a time: the keys of two would interleave. While it is held, the
    session refuses setting writes and commands, which would change what
    the calibration was planned from or take the panel from it.

    Raises:
        FujiAnalyzerStateError: another remote manual calibration holds it.
    """
    if self._panel_driver is not None:
        msg = (
            f"{operation} refused, nothing was sent: {self._panel_driver} is already "
            "under way on this analyzer"
        )
        raise FujiAnalyzerStateError(msg, context=self._context(operation))
    self._panel_driver = operation

close async

close()

Close the session and its port. Idempotent.

Waits for an operation in progress to finish, and completes even when the caller is cancelled (the port closes shielded). A transport the caller supplied is left open.

Source code in src/fujilib/devices/session.py
async def close(self) -> None:
    """Close the session and its port. Idempotent.

    Waits for an operation in progress to finish, and completes even when
    the caller is cancelled (the port closes shielded). A transport the
    caller supplied is left open.
    """
    self._state = SessionState.CLOSED
    # Every call waits: the port closes once, after the operation (or the
    # reopen) in progress.
    with anyio.CancelScope(shield=True):
        async with self._reopening:
            await self._port.aclose()

ensure_ranges async

ensure_ranges(client, deadline)

The range tables, read again first when none are cached or they are stale.

Source code in src/fujilib/devices/session.py
async def ensure_ranges(
    self, client: ProtocolClient, deadline: Deadline
) -> tuple[RangeInfo, ...]:
    """The range tables, read again first when none are cached or they are stale."""
    if self._ranges is None or self._ranges_stale:
        return self.learn_ranges(await read_ranges(client, deadline=deadline))
    return self._ranges

gate

gate(
    operation,
    *,
    tier=SafetyTier.READ_ONLY,
    confirm=False,
    requires=Capability.NONE,
    subject=None,
)

Walk the gates of the module docstring; nothing is sent either way.

subject names what the operation acts on, for the refusal's message.

Raises:

Type Description
FujiConnectionError

the session is closed or broken.

FujiConfirmationRequiredError

tier is above READ_ONLY and confirm is not True.

FujiCapabilityError

a capability in requires is known to be unsupported, or an option in it is not taken as fitted.

Source code in src/fujilib/devices/session.py
def gate(
    self,
    operation: str,
    *,
    tier: SafetyTier = SafetyTier.READ_ONLY,
    confirm: bool = False,
    requires: Capability = Capability.NONE,
    subject: str | None = None,
) -> None:
    """Walk the gates of the module docstring; nothing is sent either way.

    ``subject`` names what the operation acts on, for the refusal's message.

    Raises:
        FujiConnectionError: the session is closed or broken.
        FujiConfirmationRequiredError: ``tier`` is above ``READ_ONLY`` and
            ``confirm`` is not ``True``.
        FujiCapabilityError: a capability in ``requires`` is known to be
            unsupported, or an option in it is not taken as fitted.
    """
    self._check_state(operation)
    if tier > SafetyTier.READ_ONLY and confirm is not True:
        what = subject or operation
        msg = f"{what} is {tier.name}; pass confirm=True to go ahead"
        raise FujiConfirmationRequiredError(
            msg, context=self._context(operation).merged(safety=tier.name.lower())
        )
    self._check_capability(requires, operation)
    self._check_options(requires, operation)

learn_current_ranges

learn_current_ranges(current)

Take in the current range of channels 1-5; a change makes the range tables stale.

Source code in src/fujilib/devices/session.py
def learn_current_ranges(self, current: Mapping[ChannelId, int]) -> None:
    """Take in the current range of channels 1-5; a change makes the range tables stale."""
    previous = self._current_ranges
    if previous is not None and dict(previous) != dict(current) and self._ranges is not None:
        _LOG.info(
            "%s station %d: the current range changed; the range tables will be read again",
            self.port,
            self.address,
        )
        self._ranges_stale = True
    self._current_ranges = MappingProxyType(dict(current))

learn_identity

learn_identity(identity, *, channel_map=None)

Take in an identity read; channel_map replaces the asserted map when given.

Raises:

Type Description
FujiProtocolUnsupportedError

the type code does not name a ZP model.

Source code in src/fujilib/devices/session.py
def learn_identity(
    self, identity: Identity, *, channel_map: Mapping[ChannelId, Gas] | None = None
) -> DeviceInfo:
    """Take in an identity read; ``channel_map`` replaces the asserted map when given.

    Raises:
        FujiProtocolUnsupportedError: the type code does not name a ZP model.
    """
    asserted = self._asserted if channel_map is None else MappingProxyType(dict(channel_map))
    # A channel established by the map it replaces stays established (design §6.7).
    established = self._seen | set(self._asserted)
    info = describe_identity(
        identity,
        address=self.address,
        serial_settings=self.serial_settings,
        asserted=asserted,
        established=established,
    )
    self._asserted = asserted
    self._seen = established | identity.nonzero
    self._availability.update(identity.availability)
    self._ranges, self._ranges_stale = identity.ranges, False
    self._current_ranges = identity.current_ranges
    self._info = info
    self._relabel()
    return info

learn_poll

learn_poll(poll)

Decode a poll, first establishing any channel it shows alive (design §2.9).

Source code in src/fujilib/devices/session.py
def learn_poll(self, poll: PollRead) -> Frame:
    """Decode a poll, first establishing any channel it shows alive (design §2.9)."""
    fresh = poll.nonzero - self._seen - set(self._asserted)
    self._seen |= poll.nonzero
    if fresh:
        _LOG.info(
            "%s station %d: %s read non-zero and joined the established channels",
            self.port,
            self.address,
            ", ".join(c.value for c in sorted(fresh, key=lambda c: c.number)),
        )
        self._relabel()
    self.learn_current_ranges(poll.current_ranges)
    frame = self._mark_settling(poll.decode(self._channels))
    self._last_frame = frame
    return frame

learn_ranges

learn_ranges(ranges)

Take in a fresh read of the range tables.

Source code in src/fujilib/devices/session.py
def learn_ranges(self, ranges: tuple[RangeInfo, ...]) -> tuple[RangeInfo, ...]:
    """Take in a fresh read of the range tables."""
    self._ranges, self._ranges_stale = ranges, False
    self._refresh_info()
    return ranges

note_port_failure

note_port_failure(error)

Take in a failure reported in a result rather than raised.

A connection failure breaks the session, as a raised one does.

Source code in src/fujilib/devices/session.py
def note_port_failure(self, error: FujiError) -> None:
    """Take in a failure reported in a result rather than raised.

    A connection failure breaks the session, as a raised one does.
    """
    self._note_failure(error)

read_capability async

read_capability(capability, operation, read)

Run read of a probed capability, keeping its availability current.

Exception 02 to a well-formed read marks the capability UNSUPPORTED and raises :class:FujiCapabilityError; words that do not validate mark it INVALID_DATA; a result marks it SUPPORTED. Anything else, such as a timeout, says nothing about it.

Source code in src/fujilib/devices/session.py
async def read_capability[T](
    self, capability: Capability, operation: str, read: Callable[[], Awaitable[T]]
) -> T:
    """Run ``read`` of a probed capability, keeping its availability current.

    Exception 02 to a well-formed read marks the capability ``UNSUPPORTED``
    and raises :class:`FujiCapabilityError`; words that do not validate mark
    it ``INVALID_DATA``; a result marks it ``SUPPORTED``. Anything else, such
    as a timeout, says nothing about it.
    """
    try:
        result = await read()
    except FujiModbusIllegalDataAddressError as exc:
        self.set_availability(capability, Availability.UNSUPPORTED)
        raise self._unsupported(capability, operation) from exc
    except FujiDecodeError:
        self.set_availability(capability, Availability.INVALID_DATA)
        raise
    self.set_availability(capability, Availability.SUPPORTED)
    return result

release_panel

release_panel()

Give the front panel back. Idempotent.

Source code in src/fujilib/devices/session.py
def release_panel(self) -> None:
    """Give the front panel back. Idempotent."""
    self._panel_driver = None

reopen async

reopen(*, timeout=None)

Open the port again and identify the analyzer, usually after a connection failure.

The old port is closed first (after the operation in progress), then opened again with the settings it was first opened with. The station must identify as the analyzer that was open: the same serial number and type code. The asserted channel map, the established channels and the traffic counters are kept. If a connection failure had broken the session, the settling period starts (:attr:settling_until). Calls made meanwhile are refused. Reopens are taken one at a time, and a call that finds the analyzer reopened by another while it waited returns at once. :meth:close waits for a reopen in progress, so no port is left open once it returns.

Raises:

Type Description
FujiConfigurationError

the analyzer was closed; its port came from the caller, so it cannot be reopened; or another analyzer answers on the station. None of these changes by trying again.

FujiConnectionError

the port cannot be opened.

FujiValidationError

timeout is negative or not finite.

FujiError

identification failed. The session stays broken.

Source code in src/fujilib/devices/session.py
async def reopen(self, *, timeout: float | None = None) -> DeviceInfo:
    """Open the port again and identify the analyzer, usually after a connection failure.

    The old port is closed first (after the operation in progress), then
    opened again with the settings it was first opened with. The station
    must identify as the analyzer that was open: the same serial number and
    type code. The asserted channel map, the established channels and the
    traffic counters are kept. If a connection failure had broken the
    session, the settling period starts (:attr:`settling_until`). Calls
    made meanwhile are refused. Reopens
    are taken one at a time, and a call that finds the analyzer reopened
    by another while it waited returns at once. :meth:`close` waits for a
    reopen in progress, so no port is left open once it returns.

    Raises:
        FujiConfigurationError: the analyzer was closed; its port came from
            the caller, so it cannot be reopened; or another analyzer answers
            on the station. None of these changes by trying again.
        FujiConnectionError: the port cannot be opened.
        FujiValidationError: ``timeout`` is negative or not finite.
        FujiError: identification failed. The session stays broken.
    """
    operation = "reopen"
    reopener = self._reopener
    if reopener is None:
        msg = (
            f"the analyzer on {self.port} was opened on a transport the caller supplied, "
            "so fujilib cannot open it again"
        )
        raise FujiConfigurationError(msg, context=self._context(operation))
    deadline = Deadline.after(timeout, operation=operation)
    found = self._port
    try:
        with deadline.enforce():
            async with self._reopening:
                self._check_not_closed(operation)
                if self._port is not found and self._state is SessionState.OPEN:
                    return self._require_info()  # another call reopened it meanwhile
                was_broken = self._state is SessionState.BROKEN
                self._state = SessionState.BROKEN
                await self._port.aclose()
                port = await reopener()
                try:
                    self._check_not_closed(operation)
                    client, identity = await self._identify_on(port, deadline)
                    self._check_not_closed(operation)
                except BaseException:
                    with anyio.CancelScope(shield=True):
                        await port.aclose()
                    raise
                # The session's counters go on, with the new port's traffic so far.
                client.counters = _added(self._client.counters, client.counters)
                self._port, self._client = port, client
                self._state = SessionState.OPEN
                if was_broken:
                    self._start_settling()
    except FujiError as exc:
        located = self._located(exc, operation)
        self._last_error = located.context
        if located is exc:
            raise
        raise located from exc.__cause__
    _LOG.info("%s station %d: reopened", self.port, self.address)
    return self.learn_identity(identity)

run async

run(
    operation,
    body,
    *,
    timeout=None,
    requires=Capability.NONE,
    tier=SafetyTier.READ_ONLY,
    confirm=False,
)

Run body as one operation, after the gates (see the module docstring).

body receives the station's client and the operation's deadline, which it passes to every read so the budget is shared.

Raises:

Type Description
FujiConnectionError

the session is closed or broken; nothing was sent.

FujiConfirmationRequiredError

tier needs confirm=True; nothing was sent.

FujiCapabilityError

a capability in requires is known to be unsupported, or an option in it is not fitted; nothing was sent.

FujiValidationError

timeout is negative or not finite.

FujiError

whatever body raised, with the port and station in its context.

Source code in src/fujilib/devices/session.py
async def run[T](
    self,
    operation: str,
    body: Callable[[ProtocolClient, Deadline], Awaitable[T]],
    *,
    timeout: float | None = None,
    requires: Capability = Capability.NONE,
    tier: SafetyTier = SafetyTier.READ_ONLY,
    confirm: bool = False,
) -> T:
    """Run ``body`` as one operation, after the gates (see the module docstring).

    ``body`` receives the station's client and the operation's deadline,
    which it passes to every read so the budget is shared.

    Raises:
        FujiConnectionError: the session is closed or broken; nothing was sent.
        FujiConfirmationRequiredError: ``tier`` needs ``confirm=True``;
            nothing was sent.
        FujiCapabilityError: a capability in ``requires`` is known to be
            unsupported, or an option in it is not fitted; nothing was sent.
        FujiValidationError: ``timeout`` is negative or not finite.
        FujiError: whatever ``body`` raised, with the port and station in its context.
    """
    self.gate(operation, tier=tier, confirm=confirm, requires=requires)
    deadline = Deadline.after(timeout, operation=operation)
    refused: FujiConnectionError | None = None
    try:
        with deadline.enforce():
            while True:
                port = self._port
                async with maybe_acquire(port.lock):
                    if port is not self._port:
                        continue  # reopened while this call waited: take the new port's lock
                    # The session may have been closed or broken while this call
                    # waited for the lock. A refusal is not a failure of the
                    # analyzer, so it is raised below, not kept as the last error.
                    refused = self._state_error(operation)
                    if refused is None:
                        return await body(self._client, deadline)
                    break
    except FujiError as exc:
        located = self._located(exc, operation)
        self._note_failure(located)
        if located is exc:
            raise
        raise located from exc.__cause__
    assert refused is not None  # noqa: S101 - the body returned otherwise
    raise refused

set_availability

set_availability(capability, availability)

Record what is now known about capability.

Source code in src/fujilib/devices/session.py
def set_availability(self, capability: Capability, availability: Availability) -> None:
    """Record what is now known about ``capability``."""
    self._availability[capability] = availability
    self._refresh_info()

snapshot

snapshot(*, name=None)

Identity and health from what is cached; no I/O (unified API §H).

name defaults to the model, or "analyzer" before identify().

Source code in src/fujilib/devices/session.py
def snapshot(self, *, name: str | None = None) -> FujiDeviceSnapshot:
    """Identity and health from what is cached; no I/O (unified API §H).

    ``name`` defaults to the model, or ``"analyzer"`` before ``identify()``.
    """
    info = self._info
    model = info.model if info is not None else None
    return FujiDeviceSnapshot(
        name=name if name is not None else (model or "analyzer"),
        model=model,
        firmware=None,
        serial=info.serial_number if info is not None else None,
        connected=self.connected,
        last_error=self._last_error,
        recoverable_error_count=self.recoverable_error_count,
        captured_at=datetime.now(UTC),
        address=self.address,
        protocol=self.protocol,
        type_code=info.type_code.raw if info is not None else None,
        capabilities=_supported(self._availability),
        availability=self.availability,
        channels=tuple(c.channel for c in self._channels),
    )

write_setting async

write_setting(client, deadline, prepared, *, command)

Write one prepared setting and read it back, under the operation lock.

In order: the status (refusing while a calibration runs or the panel is in a menu), the range tables for a range-scaled value, the setting and what it depends on, the encoding against that range, then the write and its read-back (:func:~fujilib.devices.writes.write_setting). A selected range that reads back as written is then waited for until the channel measures on it. The range tables are read again next time they are needed after a range or range-scaled write (design §6.7).

Raises:

Type Description
FujiAnalyzerStateError

the status forbids a write now.

FujiValidationError

the value does not fit the range, or a setting it depends on does not allow it; nothing was written.

FujiVerificationError

a selected range reads back as written, but the channel did not measure on it within the read-back budget.

FujiError

as :func:~fujilib.devices.writes.write_setting.

Source code in src/fujilib/devices/session.py
async def write_setting(
    self,
    client: ProtocolClient,
    deadline: Deadline,
    prepared: PreparedValue,
    *,
    command: str,
) -> WriteResult:
    """Write one prepared setting and read it back, under the operation lock.

    In order: the status (refusing while a calibration runs or the panel is
    in a menu), the range tables for a range-scaled value, the setting and
    what it depends on, the encoding against that range, then the write and
    its read-back (:func:`~fujilib.devices.writes.write_setting`). A
    selected range that reads back as written is then waited for until the
    channel measures on it. The range tables are read again next time they
    are needed after a range or range-scaled write (design §6.7).

    Raises:
        FujiAnalyzerStateError: the status forbids a write now.
        FujiValidationError: the value does not fit the range, or a setting
            it depends on does not allow it; nothing was written.
        FujiVerificationError: a selected range reads back as written, but
            the channel did not measure on it within the read-back budget.
        FujiError: as :func:`~fujilib.devices.writes.write_setting`.
    """
    spec = prepared.spec
    await self.check_quiet(client, deadline, command)
    requirement = _REQUIRES_SETTING.get(spec.name)
    scaled = spec.scaling.kind is ScalingKind.BY_RANGE
    ranges: tuple[RangeInfo, ...] = ()
    range_info = None
    if scaled or requirement is not None:
        ranges = self.learn_ranges(await read_ranges(client, deadline=deadline))
    if scaled:
        range_info = _range_of(ranges, spec, spec.range or 0)
    names = (spec.name,) if requirement is None else (spec.name, requirement[0])
    current = await read_registers(client, names, ranges=ranges, deadline=deadline)
    if requirement is not None:
        _check_requirement(spec, current[requirement[0]], requirement[1])
        assert isinstance(prepared.value, RangeIndex)  # noqa: S101 - its encoding says so
        _range_of(ranges, spec, prepared.value.number)
    raw = encode_prepared(prepared, range_info)
    self._write_rate.record(spec.name, where=f"{self.port} station {self.address}")
    try:
        result = await write_setting(
            client,
            spec,
            raw,
            previous=current[spec.name],
            scaling=(range_info[2], range_info[0]) if range_info is not None else None,
            deadline=deadline,
            verify_timeout=self._verify_timeout,
            command=command,
        )
    finally:
        if spec.name.startswith("range.") or range_info is not None:
            self._ranges_stale = True
    if result.verified and spec.name in _CURRENT_RANGE:
        await self._await_range(client, result, command)
    return result

SessionState

Bases: StrEnum

Whether a session can talk to its analyzer.

BROKEN class-attribute instance-attribute

BROKEN = 'broken'

A connection failure: the analyzer has to be opened again.

describe_identity

describe_identity(
    identity,
    *,
    address,
    serial_settings,
    asserted=None,
    established=(),
    availability=None,
)

The :class:DeviceInfo of an identity read (design §2.9, §6.6).

established adds channels seen earlier; availability overrides what the identity's own probes found.

Raises:

Type Description
FujiProtocolUnsupportedError

the type code does not name a ZP model.

Source code in src/fujilib/devices/session.py
def describe_identity(
    identity: Identity,
    *,
    address: int,
    serial_settings: SerialSettings,
    asserted: Mapping[ChannelId, Gas] | None = None,
    established: Iterable[ChannelId] = (),
    availability: Mapping[Capability, Availability] | None = None,
) -> DeviceInfo:
    """The :class:`DeviceInfo` of an identity read (design §2.9, §6.6).

    ``established`` adds channels seen earlier; ``availability`` overrides
    what the identity's own probes found.

    Raises:
        FujiProtocolUnsupportedError: the type code does not name a ZP model.
    """
    type_code = identity.type_code
    if type_code.model is None:
        msg = f"the analyzer's type code {type_code.raw!r} does not name a ZP-series model"
        raise FujiProtocolUnsupportedError(msg, context=ErrorContext(address=address))
    found = dict(identity.availability)
    found.update(availability or {})
    channels = label_channels(
        set(identity.nonzero) | set(established), asserted=asserted, type_code=type_code
    )
    return DeviceInfo(
        model=type_code.model,
        type_code=type_code,
        serial_number=identity.serial_number,
        channels=channels,
        ranges=identity.ranges,
        capabilities=_supported(found),
        availability=MappingProxyType(found),
        protocol=ProtocolKind.MODBUS_RTU,
        address=address,
        serial_settings=serial_settings,
        health=_health(type_code, found),
    )

fujilib.devices.discovery

Finding analyzers on serial ports (design §7.5, unified API §B).

Discovery is read-only. Each station gets one FC04 read of type-code digits 1-3, which on a ZP analyzer name its model (ZPA, ZPB, ...):

  • a reply naming a model is an analyzer, identified in full unless asked not to;
  • an exception reply, or characters that name no model, is a Modbus device that is not a ZP analyzer;
  • silence is an empty address (or one whose station number differs).

The baud rate is fixed at 38400, so a port is one sweep of station numbers. Ports are scanned concurrently; the stations of one port one at a time, on one bus. An absent station costs the probe timeout plus the 0.1 s quiet window after it, so a full sweep of 31 stations takes about 12 s per port.

Discovery never raises for a failed probe or a port that will not open; each becomes a row with ok=False. It raises only for invalid arguments.

DiscoveryResult dataclass

DiscoveryResult(
    ok,
    port,
    address,
    baudrate,
    protocol,
    device_info,
    error,
    elapsed_s,
    model=None,
)

One probed station (unified API §B).

Attributes:

Name Type Description
ok bool

Whether the station answered as an analyzer of a known family.

port str

The canonical port name.

address int | None

The station number probed.

baudrate int | None

The baud rate used, or None when the port did not open.

protocol ProtocolKind | None

The wire protocol when ok, else None.

device_info DeviceInfo | None

The full identity when ok and identification succeeded.

error FujiError | None

Why the station is not ok; or, when it is, why its identification failed. None otherwise.

elapsed_s float

Seconds spent on the station.

model str | None

The model the probe named ("ZPA"), even without identification.

DiscoverySummary dataclass

DiscoverySummary(
    port, ok, addresses, probed, error, elapsed_s
)

What discovery found on one port.

Attributes:

Name Type Description
port str

The canonical port name.

ok bool

Whether any analyzer answered.

addresses tuple[int, ...]

The stations that answered as analyzers.

probed int

How many stations were probed.

error FujiError | None

When nothing answered: why the port did not open, or else the first probe's error. None when something answered.

elapsed_s float

Seconds spent on the port's stations, summed.

find_devices async

find_devices(
    *,
    ports=None,
    addresses=(1,),
    profiles=DEVICE_PROFILES,
    per_probe_timeout_s=0.3,
    identify=True,
    max_concurrency=8,
)

Probe addresses on each of ports for analyzers; read-only.

Parameters:

Name Type Description Default
ports Sequence[str] | None

Port names; every serial port of the host when None. Two spellings of one port (COM8, com8) are scanned once. Every port listed receives the probe frames, so name only ports whose instruments may receive a Modbus read request.

None
addresses Sequence[int]

Station numbers to probe, 1-31.

(1,)
profiles Sequence[DeviceProfile]

Analyzer families to try, in order.

DEVICE_PROFILES
per_probe_timeout_s float

Seconds to wait for each station's reply. Probes are not retried.

0.3
identify bool

Identify each analyzer found (six more transactions each).

True
max_concurrency int

How many ports are scanned at once.

8

Returns:

Type Description
list[DiscoveryResult]

One result per port and station, in the order given.

Raises:

Type Description
FujiValidationError

an argument is invalid; nothing was sent.

FujiConnectionError

ports is None and the host's ports cannot be listed.

Source code in src/fujilib/devices/discovery.py
async def find_devices(
    *,
    ports: Sequence[str] | None = None,
    addresses: Sequence[int] = (1,),
    profiles: Sequence[DeviceProfile] = DEVICE_PROFILES,
    per_probe_timeout_s: float = 0.3,
    identify: bool = True,
    max_concurrency: int = 8,
) -> list[DiscoveryResult]:
    """Probe ``addresses`` on each of ``ports`` for analyzers; read-only.

    Args:
        ports: Port names; every serial port of the host when ``None``. Two
            spellings of one port (``COM8``, ``com8``) are scanned once. Every
            port listed receives the probe frames, so name only ports whose
            instruments may receive a Modbus read request.
        addresses: Station numbers to probe, 1-31.
        profiles: Analyzer families to try, in order.
        per_probe_timeout_s: Seconds to wait for each station's reply. Probes
            are not retried.
        identify: Identify each analyzer found (six more transactions each).
        max_concurrency: How many ports are scanned at once.

    Returns:
        One result per port and station, in the order given.

    Raises:
        FujiValidationError: an argument is invalid; nothing was sent.
        FujiConnectionError: ``ports`` is ``None`` and the host's ports cannot be listed.
    """
    stations = _check_addresses(addresses)
    if not profiles:
        msg = "profiles must name at least one analyzer family"
        raise FujiValidationError(msg)
    if not (math.isfinite(per_probe_timeout_s) and 0 < per_probe_timeout_s <= _MAX_PROBE_TIMEOUT):
        msg = (
            f"per_probe_timeout_s must be in (0, {_MAX_PROBE_TIMEOUT:g}] seconds, "
            f"got {per_probe_timeout_s!r}"
        )
        raise FujiValidationError(msg)
    if not _is_int(max_concurrency) or max_concurrency < 1:
        msg = f"max_concurrency must be a positive integer, got {max_concurrency!r}"
        raise FujiValidationError(msg)
    names = _check_ports(ports) if ports is not None else await _host_ports()

    results: list[list[DiscoveryResult]] = [[] for _ in names]
    limiter = anyio.CapacityLimiter(max_concurrency)

    async def scan(index: int, name: str) -> None:
        async with limiter:
            results[index] = await _scan_port(
                name, stations, profiles, per_probe_timeout_s, identify=identify
            )

    async with anyio.create_task_group() as tg:
        for index, name in enumerate(names):
            _ = tg.start_soon(scan, index, name)
    return [row for rows in results for row in rows]

summarize_discovery

summarize_discovery(results)

One summary per port, in the order the ports first appear.

Source code in src/fujilib/devices/discovery.py
def summarize_discovery(results: Iterable[DiscoveryResult]) -> list[DiscoverySummary]:
    """One summary per port, in the order the ports first appear."""
    by_port: dict[str, list[DiscoveryResult]] = {}
    for result in results:
        by_port.setdefault(result.port, []).append(result)
    summaries: list[DiscoverySummary] = []
    for port, rows in by_port.items():
        found = tuple(r.address for r in rows if r.ok and r.address is not None)
        error = None if found else next((r.error for r in rows if r.error is not None), None)
        summaries.append(
            DiscoverySummary(
                port=port,
                ok=bool(found),
                addresses=found,
                probed=len(rows),
                error=error,
                elapsed_s=sum(r.elapsed_s for r in rows),
            )
        )
    return summaries

fujilib.devices.profile

Device profiles: what one analyzer family brings (design §5.3).

A :class:DeviceProfile names a family and carries what differs between families: its register map, its protocol and serial framing, how a station is identified, and how discovery recognizes one. Profiles are shared and frozen; what one station turns out to be (its channels, what its probes found) lives in the session, never in the profile.

One profile exists: :data:ZP_PROFILE, for the ZPA and the ZPB, ZPG, ZPAJ and ZPG3E models that share its MODBUS map (TN5A1190).

DeviceProfile dataclass

DeviceProfile(
    name,
    registry,
    default_protocol,
    default_serial,
    identify,
    discovery_probe,
    recognize,
)

One analyzer family.

default_serial instance-attribute

default_serial

The family's serial framing. Its port is a placeholder the caller's port replaces.

discovery_probe instance-attribute

discovery_probe

The one read discovery sends to each station.

name instance-attribute

name

A short name, e.g. "zp".

recognize instance-attribute

recognize

The model the probe's words name, or None when they are not this family's.

registry instance-attribute

registry

The family's register map; parameter names resolve against it.

IdentifyStrategy

Bases: Protocol

Reads what identify() establishes about one station.

__call__ async

__call__(client, *, probe=True, deadline=None)

Read the station's identity, ranges and readings, and probe its capabilities.

Source code in src/fujilib/devices/profile.py
async def __call__(
    self, client: ProtocolClient, *, probe: bool = True, deadline: Deadline | None = None
) -> Identity:
    """Read the station's identity, ranges and readings, and probe its capabilities."""
    ...

recognize_zp

recognize_zp(words)

The model when type-code digits 1-3 read Z, P and a model letter (design §7.5).

Returns "ZPA", "ZPB", "ZPG"… or None for anything else.

Source code in src/fujilib/devices/profile.py
def recognize_zp(words: Sequence[int]) -> str | None:
    """The model when type-code digits 1-3 read ``Z``, ``P`` and a model letter (design §7.5).

    Returns ``"ZPA"``, ``"ZPB"``, ``"ZPG"``… or ``None`` for anything else.
    """
    try:
        text = decode_chars(words, strip=False)
    except FujiDecodeError:
        return None
    if len(text) == DISCOVERY_PROBE.count and text.startswith("ZP") and "A" <= text[-1] <= "Z":
        return text
    return None

Models

fujilib.devices.models

Frozen data models (design §8).

Every model is a @dataclass(frozen=True, slots=True) and constructible by keyword without hardware, so consumers can build them in simulators.

Validity. Each :class:Reading carries a :class:ReadingState, one token from a closed vocabulary that says whether the value is live and, if not, why. Reading.valid derives from it: True for ok, None when the status was not read, False otherwise. Raw values are always kept; validity flags them, it does not hide them.

Row columns. The columns a reading and the analyzer status contribute to a row are defined once, here (:data:READING_COLUMNS, :data:ANALYZER_COLUMNS). :meth:Reading.as_dict, :meth:Frame.as_long_rows and :func:fujilib.sinks.base.sample_to_row all use these definitions, so they cannot disagree. Every column value is a scalar (float, int, str, bool or None); sets and tuples are encoded as strings.

AdcValues dataclass

AdcValues(
    inputs,
    temperatures,
    resistances,
    pressure,
    reference_voltage,
    ground,
    raw,
    received_at,
    t_mono_ns,
)

The undocumented A/D block: raw service counts, not a gas measurement (design §6.6).

The groups follow the service manual's table order; that interpretation is inferred.

ground instance-attribute

ground

No. 16.

inputs instance-attribute

inputs

No. 0-4: the four NDIR inputs and the O2 sensor input.

pressure instance-attribute

pressure

No. 14.

raw instance-attribute

raw

All 21 counts, in table order.

reference_voltage instance-attribute

reference_voltage

No. 15.

resistances instance-attribute

resistances

No. 10-13 and 17-20.

temperatures instance-attribute

temperatures

No. 5-9.

AnalyzerMetadata dataclass

AnalyzerMetadata(
    serial_number,
    ranges,
    current_range,
    response_time_s,
    response_time_ndir_s,
    response_time_o2_s,
    moving_average,
    calibration_gas,
    calibration_scope,
    hold_mode,
    output_hold,
    auto_calibration,
    auto_zero,
    clock,
    clock_read_at,
    captured_at,
)

The read-only settings snapshot a consumer records with its data (design §2.11).

Response times are per NDIR component slot and a separate O2 slot; response_time_s maps them onto channels only where the channel labels say which is which.

calibration_gas instance-attribute

calibration_gas

(channel, range) → (zero, span), scaled by the range; None if unscalable.

clock instance-attribute

clock

The analyzer's clock: naive local time. None if unavailable.

clock_read_at instance-attribute

clock_read_at

Host UTC time the clock was read.

AnalyzerStatus dataclass

AnalyzerStatus(
    instrument_error,
    calibration_error,
    errors,
    alarms,
    peak_count,
    peak_alarm,
    auto_calibration_running,
    display,
)

Analyzer-level status from a full poll.

alarms instance-attribute

alarms

States of alarms 1-6, in order.

errors instance-attribute

errors

Analyzer-level errors currently active: 1, 2, 3, 10.

as_dict

as_dict()

The analyzer columns of a row (:data:ANALYZER_COLUMNS).

Source code in src/fujilib/devices/models.py
def as_dict(self) -> dict[str, Scalar]:
    """The analyzer columns of a row (:data:`ANALYZER_COLUMNS`)."""
    return {c.name: c.extract(self) for c in ANALYZER_COLUMNS}

AutoCalibrationSchedule dataclass

AutoCalibrationSchedule(
    schedule, channels, ranges, flow_times_s
)

Auto calibration: the schedule, which channels and ranges, and the gas flow times.

AutoZeroSchedule dataclass

AutoZeroSchedule(schedule, flow_time_s)

Auto zero calibration: the schedule and the gas flow time.

AveragePeriod dataclass

AveragePeriod(period, unit)

One moving-average period (holding 40085-40092).

CalibrationLogEntry dataclass

CalibrationLogEntry(
    channel,
    range,
    kind,
    detector_count,
    deviation_percent_fs,
    at,
)

One calibration-log record (firmware 2.24 or later).

CalibrationScope dataclass

CalibrationScope(zero_mode, range_mode)

How far a calibration started on one channel reaches (design §2.6).

ChannelInfo dataclass

ChannelInfo(
    channel,
    gas,
    suggested_gas,
    role,
    label_source,
    derived_from=None,
)

What is known about one established channel's label.

derived_from class-attribute instance-attribute

derived_from = None

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

ChannelStatus dataclass

ChannelStatus(
    range,
    zero_calibrating,
    span_calibrating,
    auto_zero_running,
    auto_span_running,
    hold,
    errors,
)

Per-channel status of a measured channel (1-5).

calibrating property

calibrating

Whether any zero or span calibration, manual or automatic, is running.

errors instance-attribute

errors

Errors 4-9 currently active on this channel.

hold instance-attribute

hold

The channel's output is held: the value is frozen, not live.

range instance-attribute

range

The current range, 1 or 2.

ColumnDef dataclass

ColumnDef(name, python_type, extract)

A row column: its name, scalar type and how to extract it from a model.

DeviceHealth

Bases: StrEnum

How completely identify() succeeded.

DeviceInfo dataclass

DeviceInfo(
    model,
    type_code,
    serial_number,
    channels,
    ranges,
    capabilities,
    availability,
    protocol,
    address,
    serial_settings,
    health,
    firmware=None,
)

What identify() established about an analyzer.

firmware class-attribute instance-attribute

firmware = None

Always None: the program version is shown on the display only, not in a register.

serial_number instance-attribute

serial_number

The manual's "board" code; on the bench unit, the serial number.

DisplayState dataclass

DisplayState(
    screen,
    calibration_step,
    top_channel,
    cursor_channel,
    calibration_result=None,
    key=None,
)

What the front panel shows (input 30181-30183, 30189; 30186 and 30190 observed).

calibration_result class-attribute instance-attribute

calibration_result = None

The last manual calibration (30186, observed; protocol findings §14.2).

calibration_step instance-attribute

calibration_step

The manual-calibration step (30182) on the measurement screen; on a menu, the raw page number the word shows there (protocol findings §15).

key class-attribute instance-attribute

key = None

The key being pressed at the panel (30190, observed); KeyCode(0) for none.

ErrorLogEntry dataclass

ErrorLogEntry(code, channel, at)

One error-log entry (newest first in the log).

channel instance-attribute

channel

None for analyzer-level errors.

Frame dataclass

Frame(
    readings,
    analyzer,
    protocol,
    readings_timing,
    status_timing,
    raw,
)

Every established channel and the analyzer status from one poll.

analyzer instance-attribute

analyzer

None when only the readings block was read.

channels property

channels

The channels in this frame, in order.

raw instance-attribute

raw

The words of every block of the poll, big-endian, concatenated.

readings_timing instance-attribute

readings_timing

Timing of the block that holds every concentration.

as_long_rows

as_long_rows(*, device, address)

One row per channel, with the analyzer status repeated on each.

For SQL unions with long-format siblings; the recorder's rows are wide.

Source code in src/fujilib/devices/models.py
def as_long_rows(self, *, device: str, address: int) -> list[dict[str, Scalar]]:
    """One row per channel, with the analyzer status repeated on each.

    For SQL unions with long-format siblings; the recorder's rows are wide.
    """
    analyzer: dict[str, Scalar] = (
        self.analyzer.as_dict()
        if self.analyzer is not None
        else dict.fromkeys(c.name for c in ANALYZER_COLUMNS)
    )
    return [
        {
            "device": device,
            "address": address,
            "protocol": self.protocol.value,
            "channel": r.channel.value,
            **r.as_dict(),
            **analyzer,
        }
        for r in self.readings
    ]

channel

channel(channel)

The reading of channel.

Raises:

Type Description
FujiValidationError

the channel is unknown or not in this frame.

Source code in src/fujilib/devices/models.py
def channel(self, channel: ChannelId | str) -> Reading:
    """The reading of ``channel``.

    Raises:
        FujiValidationError: the channel is unknown or not in this frame.
    """
    cid = coerce_channel(channel)
    for reading in self.readings:
        if reading.channel is cid:
            return reading
    msg = f"{cid.value} is not an established channel of this frame"
    raise FujiValidationError(msg, context=ErrorContext(channel=cid.value))

PartialTimestamp dataclass

PartialTimestamp(month, day, hour, minute)

A log time without a year (and, in the error log, without a month).

resolve

resolve(clock, *, max_age)

The unique matching time at most max_age before clock.

clock is a reference in the same (naive local) time as the log, normally the analyzer's own clock. Returns None when no candidate or more than one candidate falls in the window.

Source code in src/fujilib/devices/models.py
def resolve(self, clock: datetime, *, max_age: timedelta) -> datetime | None:
    """The unique matching time at most ``max_age`` before ``clock``.

    ``clock`` is a reference in the same (naive local) time as the log,
    normally the analyzer's own clock. Returns ``None`` when no candidate
    or more than one candidate falls in the window.
    """
    earliest = clock - max_age
    candidates: list[datetime] = []
    for year in range(earliest.year, clock.year + 1):
        months = [self.month] if self.month is not None else range(1, 13)
        for month in months:
            try:
                at = datetime(year, month, self.day, self.hour, self.minute)
            except ValueError:
                continue
            at = at.replace(tzinfo=clock.tzinfo)
            if earliest <= at <= clock:
                candidates.append(at)
    return candidates[0] if len(candidates) == 1 else None

RangeInfo dataclass

RangeInfo(channel, count, units, full_scale, decimals)

A measured channel's ranges, from the fixed-setting registers.

count instance-attribute

count

How many ranges the channel has, 1 or 2. Unused channels also report ranges.

of

of(rng)

(unit, full_scale, decimals) of range rng (1 or 2).

Raises:

Type Description
FujiValidationError

rng is not a range of the channel's table.

Source code in src/fujilib/devices/models.py
def of(self, rng: int) -> tuple[Unit, float, int]:
    """``(unit, full_scale, decimals)`` of range ``rng`` (1 or 2).

    Raises:
        FujiValidationError: ``rng`` is not a range of the channel's table.
    """
    if not 1 <= rng <= len(self.units):
        msg = f"{self.channel.value} has no range {rng}"
        raise FujiValidationError(msg, context=ErrorContext(channel=self.channel.value))
    i = rng - 1
    return self.units[i], self.full_scale[i], self.decimals[i]

Reading dataclass

Reading(
    channel,
    gas,
    suggested_gas,
    label_source,
    role,
    value,
    unit,
    raw_value,
    decimals,
    status,
    state,
    protocol,
)

One channel's concentration from a poll, with its label, validity and provenance.

decimals instance-attribute

decimals

The decimal-point register, 0-3, so the exact decimal can be rebuilt.

gas instance-attribute

gas

UNKNOWN unless asserted by the caller (or, where so labelled, decoded).

raw_value instance-attribute

raw_value

The signed integer from the register.

status instance-attribute

status

None for derived channels and when the status block was not read.

valid property

valid

Whether the value is live; None when that is unknown (design §8).

value instance-attribute

value

The scaled value, or None when the triple does not decode.

as_decimal

as_decimal()

The exact decimal value, or None when the decimal point does not decode.

Source code in src/fujilib/devices/models.py
def as_decimal(self) -> Decimal | None:
    """The exact decimal value, or ``None`` when the decimal point does not decode."""
    return None if self.value is None else as_decimal(self.raw_value, self.decimals)

as_dict

as_dict()

The per-channel columns of a row, without the chN_ prefix.

Source code in src/fujilib/devices/models.py
def as_dict(self) -> dict[str, Scalar]:
    """The per-channel columns of a row, without the ``chN_`` prefix."""
    return {c.name: c.extract(self) for c in READING_COLUMNS}

ReadingState

Bases: StrEnum

Whether a reading is live and, if not, the most important reason why.

When several reasons apply, the first in this order wins: analyzer error, channel error, calibrating, auto calibration, hold, source invalid, settling.

ANALYZER_ERROR class-attribute instance-attribute

ANALYZER_ERROR = 'analyzer_error'

The analyzer reports error 1, 2, 3 or 10.

AUTO_CALIBRATION class-attribute instance-attribute

AUTO_CALIBRATION = 'auto_calibration'

An auto calibration or auto zero calibration is running.

CALIBRATING class-attribute instance-attribute

CALIBRATING = 'calibrating'

The channel is being zero- or span-calibrated.

CHANNEL_ERROR class-attribute instance-attribute

CHANNEL_ERROR = 'channel_error'

The channel reports an error 4-9.

HOLD class-attribute instance-attribute

HOLD = 'hold'

The channel's output is held; the value is frozen, not live.

OK class-attribute instance-attribute

OK = 'ok'

Live: no hold, calibration or error.

SETTLING class-attribute instance-attribute

SETTLING = 'settling'

Read soon after the connection came back: the analyzer may have been switched off, and reports nothing of its own while it warms up (design §8).

SOURCE_INVALID class-attribute instance-attribute

SOURCE_INVALID = 'source_invalid'

A derived channel whose source channel or O2 channel is not valid.

UNKNOWN class-attribute instance-attribute

UNKNOWN = 'unknown'

The status was not read, or the value did not decode.

valid property

valid

True for OK, None for UNKNOWN, False otherwise.

Schedule dataclass

Schedule(
    enabled,
    start_day,
    start_hour_raw,
    start_minute_raw,
    cycle,
    cycle_unit,
)

An automatic function's schedule.

The start hour and minute are kept raw: the manual says BCD, but the bench unit contradicts it (design §2.6).

TransferTiming dataclass

TransferTiming(
    requested_at,
    received_at,
    t_request_mono_ns,
    t_reply_mono_ns,
)

Host timing of one Modbus transaction.

latency_s property

latency_s

Round-trip time in seconds.

midpoint_mono_ns property

midpoint_mono_ns

Monotonic midpoint of request and reply: the best estimate of the reading's time.

midpoint_utc property

midpoint_utc

Wall-clock midpoint of request and reply.

received_at instance-attribute

received_at

Wall clock (UTC, tz-aware) when the reply had been read.

requested_at instance-attribute

requested_at

Wall clock (UTC, tz-aware) when the request had been sent: after the inter-frame gap, the write and the drain, so no wait for the line is included.

encode_codes

encode_codes(codes)

Encode a set of error codes for a row: sorted and comma-joined, "" if none.

Source code in src/fujilib/devices/models.py
def encode_codes(codes: Iterable[int] | None) -> str | None:
    """Encode a set of error codes for a row: sorted and comma-joined, ``""`` if none."""
    if codes is None:
        return None
    return ",".join(str(int(c)) for c in sorted(codes))

encode_enum

encode_enum(value)

Encode an enum value for a row: its lower-case name, or the raw number.

Source code in src/fujilib/devices/models.py
def encode_enum(value: IntEnum | int | None) -> str | None:
    """Encode an enum value for a row: its lower-case name, or the raw number."""
    if value is None:
        return None
    if isinstance(value, IntEnum):
        return value.name.lower()
    return str(value)

fujilib.devices.capability

Safety tiers, capability flags and probe availability (design §6.2, §6.6).

  • :class:SafetyTier — how dangerous an operation is; anything above READ_ONLY needs confirm=True.
  • :class:Capability — firmware, option and model features.
  • :class:Availability — what a probe found for one capability.

This module is a leaf: it imports nothing from :mod:fujilib, so the registry can use these enums without an import cycle. Note that anymodbus also exports a Capability; never import that one bare beside this one.

Availability

Bases: StrEnum

What a probe found for one capability (design §6.6).

Only UNSUPPORTED short-circuits later calls; UNKNOWN is retried on next use.

INVALID_DATA class-attribute instance-attribute

INVALID_DATA = 'invalid_data'

Readable, but the content does not validate (e.g. a clock that is not a date).

SUPPORTED class-attribute instance-attribute

SUPPORTED = 'supported'

The probe returned data that validates.

UNKNOWN class-attribute instance-attribute

UNKNOWN = 'unknown'

Not probed yet, or the probe timed out or failed framing.

UNSUPPORTED class-attribute instance-attribute

UNSUPPORTED = 'unsupported'

A well-formed probe inside one known block was answered with exception 02.

Capability

Bases: Flag

Features that depend on firmware, options or model.

The first group is probed by identify() and never assumed (design §6.6). The rest are options and model features (:data:OPTION_CAPABILITIES): the type code suggests them and the caller may assert them. They gate the operation commands that need them, never reads, because an option's registers read even when it is not fitted.

ADC_VALUES class-attribute instance-attribute

ADC_VALUES = auto()

Undocumented A/D values at FC04 03EFh (observed on the bench unit).

ALARMS class-attribute instance-attribute

ALARMS = auto()

Concentration alarms 1-6 (option).

AUTO_CALIBRATION class-attribute instance-attribute

AUTO_CALIBRATION = auto()

Auto calibration (option).

AUTO_ZERO class-attribute instance-attribute

AUTO_ZERO = auto()

Auto zero calibration (option).

AVERAGING class-attribute instance-attribute

AVERAGING = auto()

Moving-average outputs (option).

BLOWBACK class-attribute instance-attribute

BLOWBACK = auto()

Blowback (option).

CALIBRATION_LOG class-attribute instance-attribute

CALIBRATION_LOG = auto()

Calibration log at FC04 1000h; firmware 2.24 or later.

CLOCK class-attribute instance-attribute

CLOCK = auto()

Undocumented real-time clock at FC04 03E8h (observed on the bench unit).

MEASUREMENT_POINT class-attribute instance-attribute

MEASUREMENT_POINT = auto()

Measurement-point switching (option).

O2_CORRECTION class-attribute instance-attribute

O2_CORRECTION = auto()

O2-corrected outputs (type-code digit 21).

REFERENCE_GAS class-attribute instance-attribute

REFERENCE_GAS = auto()

Reference-gas switching and averaging: ZPB and ZPG only.

TYPE_CODE_EXT class-attribute instance-attribute

TYPE_CODE_EXT = auto()

Type-code digits 27-29 at FC04 047Ah; firmware 2.24 or later.

SafetyTier

Bases: IntEnum

How dangerous an operation is. The tier follows its effect (design §6.2).

DANGEROUS class-attribute instance-attribute

DANGEROUS = 3

Calibration, calibration schedules, calibration gases and scope.

Requires confirm=True, and the CLI also requires --i-understand-this-is-destructive.

PERSISTENT class-attribute instance-attribute

PERSISTENT = 2

A settings write that changes only configuration. Requires confirm=True.

READ_ONLY class-attribute instance-attribute

READ_ONLY = 0

Every read.

STATEFUL class-attribute instance-attribute

STATEFUL = 1

A transient state change: return to measurement, blowback. Requires confirm=True.

fujilib.devices.snapshot

Identity and health snapshots (unified API §H).

snapshot() builds these from cached state and never performs I/O. The base :class:DeviceSnapshot has the same fields in every sibling library, so a consumer can render every device's snapshot uniformly; :class:FujiDeviceSnapshot adds what is specific to the analyzer.

DeviceSnapshot dataclass

DeviceSnapshot(
    name,
    model,
    firmware,
    serial,
    connected,
    last_error,
    recoverable_error_count,
    captured_at,
)

Cross-library identity and health snapshot.

Attributes:

Name Type Description
name str

The device's name (a manager name, or the model when unmanaged).

model str | None

Cached model, e.g. "ZPA", or None before identify().

firmware str | None

Always None: the analyzer's program version is not readable.

serial str | None

Cached serial number, or None before identify().

connected bool

Whether the session is operational.

last_error ErrorContext | None

The context of the last failure, or None.

recoverable_error_count int

Read retries that later succeeded, since open.

captured_at datetime

When the snapshot was taken (UTC, tz-aware).

FujiDeviceSnapshot dataclass

FujiDeviceSnapshot(
    name,
    model,
    firmware,
    serial,
    connected,
    last_error,
    recoverable_error_count,
    captured_at,
    address,
    protocol,
    type_code,
    capabilities,
    availability,
    channels,
)

Bases: DeviceSnapshot

Analyzer-specific snapshot extras.

Attributes:

Name Type Description
address int

The station number, 1-31.

protocol ProtocolKind

The session's wire protocol.

type_code str | None

The raw type code, or None before identify().

capabilities Capability

Capabilities the session believes the analyzer has.

availability Mapping[Capability, Availability]

What each probe found, per capability.

channels tuple[ChannelId, ...]

The established channels, in order.

Decoding and reading

fujilib.devices.decode

Pure decoders: register banks to models (design §2.5-§2.9, §8).

A bank maps addresses of one register table to the words read there (BlockRead.to_bank builds one from a reply; a register dump is one). Every function here is pure: no I/O, no clock reads, no caches. The session supplies what it has cached (ranges, labels, the channels established so far) and the timing of each transaction.

Decoding is total where the analyzer may legitimately surprise: an undocumented enum value is kept as an int, and a concentration whose decimal point does not decode becomes a reading with value=None and state unknown instead of failing the poll. Only structural problems, such as a missing word, raise :class:~fujilib.errors.FujiDecodeError.

RegisterValue dataclass

RegisterValue(spec, words, raw, value, unit)

One register decoded against its spec, for display and parameter reads.

raw instance-attribute

raw

The value the codec decodes, before scaling or enum lookup.

value instance-attribute

value

Scaled, or an enum member; None when a scaled value cannot be scaled.

decode_adc

decode_adc(words, *, received_at, t_mono_ns)

The 21 A/D counts (42 words, long words low word first), grouped in table order.

Raises:

Type Description
FujiDecodeError

not exactly 42 words.

Source code in src/fujilib/devices/decode.py
def decode_adc(words: Sequence[int], *, received_at: datetime, t_mono_ns: int) -> AdcValues:
    """The 21 A/D counts (42 words, long words low word first), grouped in table order.

    Raises:
        FujiDecodeError: not exactly 42 words.
    """
    if len(words) != 42:  # noqa: PLR2004
        msg = f"the A/D block is 42 words, got {len(words)}"
        raise FujiDecodeError(msg)
    counts = tuple(decode_uint32_lh(words[i : i + 2]) for i in range(0, 42, 2))
    return AdcValues(
        inputs=counts[0:5],
        temperatures=counts[5:10],
        resistances=counts[10:14] + counts[17:21],
        pressure=counts[14],
        reference_voltage=counts[15],
        ground=counts[16],
        raw=counts,
        received_at=received_at,
        t_mono_ns=t_mono_ns,
    )

decode_analyzer_status

decode_analyzer_status(bank)

The analyzer-level status, from both poll blocks.

Raises:

Type Description
FujiDecodeError

a status word was not read.

Source code in src/fujilib/devices/decode.py
def decode_analyzer_status(bank: Bank) -> AnalyzerStatus:
    """The analyzer-level status, from both poll blocks.

    Raises:
        FujiDecodeError: a status word was not read.
    """
    errors = frozenset(ErrorCode(e) for e in (1, 2, 3, 10) if _flag(bank, f"error.e{e}.active"))
    alarms = tuple(_enum(bank, f"alarm{n}.state", AlarmState) for n in range(1, 7))
    screen = _enum(bank, "display.screen", DisplayScreen)
    return AnalyzerStatus(
        instrument_error=_flag(bank, "status.instrument_error"),
        calibration_error=_flag(bank, "status.calibration_error"),
        errors=errors,
        alarms=alarms,
        peak_count=_word(bank, "peak_alarm.count_per_hour"),
        peak_alarm=_flag(bank, "peak_alarm.active"),
        auto_calibration_running=_flag(bank, "status.auto_calibration_running"),
        display=DisplayState(
            screen=screen,
            calibration_step=_calibration_step(bank, screen),
            top_channel=_channel_minus_one(_word(bank, "display.top_channel")),
            cursor_channel=_channel_minus_one(_word(bank, "display.cursor_channel")),
            calibration_result=_enum(bank, "display.calibration_result", ManualCalibrationResult),
            key=_key(_word(bank, "display.key")),
        ),
    )

decode_calibration_log

decode_calibration_log(bank, channel)

One channel's calibration log, newest first, without empty records.

A record is empty when its channel field reads -1 (FFFFh, or 00FFh: the manual writes "FF(-1)").

Raises:

Type Description
FujiDecodeError

channel is not measured, or a log word was not read.

Source code in src/fujilib/devices/decode.py
def decode_calibration_log(bank: Bank, channel: ChannelId) -> tuple[CalibrationLogEntry, ...]:
    """One channel's calibration log, newest first, without empty records.

    A record is empty when its channel field reads -1 (``FFFFh``, or ``00FFh``:
    the manual writes "FF(-1)").

    Raises:
        FujiDecodeError: ``channel`` is not measured, or a log word was not read.
    """
    if not channel.is_measured:
        msg = f"{channel.value} has no calibration log"
        raise FujiDecodeError(msg, context=ErrorContext(channel=channel.value))
    out: list[CalibrationLogEntry] = []
    fields = {f.name: f for f in CALIBRATION_LOG.fields}
    for index in range(CALIBRATION_LOG.records):
        base = CALIBRATION_LOG.record_address(channel.number, index)
        words = words_of(bank, base, CALIBRATION_LOG.record_words)
        if words[0] in {_EMPTY, _EMPTY_BYTE}:
            continue
        kind = decode_enum(CalibrationKind, words[fields["kind"].offset])
        count_at = fields["detector_count"].offset
        deviation = decode_int(words[fields["deviation"].offset], signed=True)
        out.append(
            CalibrationLogEntry(
                channel=channel,
                range=kind.range if isinstance(kind, CalibrationKind) else kind // 2 + 1,
                kind=kind,
                detector_count=decode_uint32_lh(words[count_at : count_at + 2]),
                deviation_percent_fs=scale(deviation, 1),
                at=PartialTimestamp(
                    *(words[fields[f].offset] for f in ("month", "day", "hour", "minute"))
                ),
            )
        )
    return tuple(out)

decode_channel_status

decode_channel_status(bank, channel)

The status of measured channel (1-5), from both poll blocks.

Raises:

Type Description
FujiDecodeError

channel is not measured, or a status word was not read.

Source code in src/fujilib/devices/decode.py
def decode_channel_status(bank: Bank, channel: ChannelId) -> ChannelStatus:
    """The status of measured ``channel`` (1-5), from both poll blocks.

    Raises:
        FujiDecodeError: ``channel`` is not measured, or a status word was not read.
    """
    if not channel.is_measured:
        msg = f"{channel.value} has no status registers"
        raise FujiDecodeError(msg, context=ErrorContext(channel=channel.value))
    c = channel.number
    errors = frozenset(
        ErrorCode(e) for e in range(4, 10) if _flag(bank, f"error.ch{c}.e{e}.active")
    )
    return ChannelStatus(
        range=_range_number(bank, f"range.ch{c}.current"),
        zero_calibrating=_flag(bank, f"status.ch{c}.zero_calibrating"),
        span_calibrating=_flag(bank, f"status.ch{c}.span_calibrating"),
        auto_zero_running=_flag(bank, f"status.ch{c}.auto_zero_running"),
        auto_span_running=_flag(bank, f"status.ch{c}.auto_span_running"),
        hold=_flag(bank, f"status.ch{c}.hold"),
        errors=errors,
    )

decode_clock

decode_clock(words)

The analyzer's clock: seven BCD words, year (two digits) to second.

The result is naive local time. The weekday register must agree with the date (0 = Sunday).

Raises:

Type Description
FujiDecodeError

the words are not seven BCD values forming a valid date.

Source code in src/fujilib/devices/decode.py
def decode_clock(words: Sequence[int]) -> datetime:
    """The analyzer's clock: seven BCD words, year (two digits) to second.

    The result is naive local time. The weekday register must agree with the
    date (0 = Sunday).

    Raises:
        FujiDecodeError: the words are not seven BCD values forming a valid date.
    """
    if len(words) != 7:  # noqa: PLR2004
        msg = f"the clock is 7 words, got {len(words)}"
        raise FujiDecodeError(msg)
    year, month, day, weekday, hour, minute, second = (decode_bcd(w) for w in words)
    try:
        at = datetime(2000 + year, month, day, hour, minute, second)
    except ValueError as exc:
        msg = f"the clock does not read as a date: {exc}"
        raise FujiDecodeError(msg, context=ErrorContext(extra={"words": tuple(words)})) from exc
    if (at.weekday() + 1) % 7 != weekday:
        msg = f"the clock's weekday {weekday} does not match {at.date()}"
        raise FujiDecodeError(msg, context=ErrorContext(extra={"words": tuple(words)}))
    return at

decode_current_ranges

decode_current_ranges(bank)

The range each of channels 1-5 is measuring on, 1 or 2 (input 30038-30042).

Raises:

Type Description
FujiDecodeError

the current-range registers were not read.

Source code in src/fujilib/devices/decode.py
def decode_current_ranges(bank: Bank) -> Mapping[ChannelId, int]:
    """The range each of channels 1-5 is measuring on, 1 or 2 (input 30038-30042).

    Raises:
        FujiDecodeError: the current-range registers were not read.
    """
    return MappingProxyType(
        {c: _range_number(bank, f"range.ch{c.number}.current") for c in MEASURED_CHANNELS}
    )

decode_error_log

decode_error_log(bank)

The error log, newest first, without empty entries.

The log has no year and no month; each time is a :class:PartialTimestamp.

Raises:

Type Description
FujiDecodeError

a log word was not read.

Source code in src/fujilib/devices/decode.py
def decode_error_log(bank: Bank) -> tuple[ErrorLogEntry, ...]:
    """The error log, newest first, without empty entries.

    The log has no year and no month; each time is a :class:`PartialTimestamp`.

    Raises:
        FujiDecodeError: a log word was not read.
    """
    out: list[ErrorLogEntry] = []
    for index in range(ERROR_LOG.records):
        words = words_of(bank, ERROR_LOG.record_address(1, index), ERROR_LOG.record_words)
        number = decode_int(words[0], signed=True)
        if number < 0:
            continue
        code = decode_enum(ErrorCode, number + 1)
        channel = None
        if isinstance(code, ErrorCode) and code.scope is ErrorScope.CHANNEL:
            channel = _channel_minus_one(words[4])
        out.append(
            ErrorLogEntry(code, channel, PartialTimestamp(None, words[1], words[2], words[3]))
        )
    return tuple(out)

decode_frame

decode_frame(
    bank,
    channels,
    *,
    readings_timing,
    status_timing=None,
    raw=b"",
    protocol=ProtocolKind.MODBUS_RTU,
)

Decode a poll of the established channels.

With status_timing the bank must hold both poll blocks and every reading gets a state; without it only the readings block was read, and every state is UNKNOWN (poll(detail=False)).

Raises:

Type Description
FujiDecodeError

a needed word was not read.

Source code in src/fujilib/devices/decode.py
def decode_frame(
    bank: Bank,
    channels: Sequence[ChannelInfo],
    *,
    readings_timing: TransferTiming,
    status_timing: TransferTiming | None = None,
    raw: bytes = b"",
    protocol: ProtocolKind = ProtocolKind.MODBUS_RTU,
) -> Frame:
    """Decode a poll of the established ``channels``.

    With ``status_timing`` the bank must hold both poll blocks and every
    reading gets a state; without it only the readings block was read, and
    every state is ``UNKNOWN`` (``poll(detail=False)``).

    Raises:
        FujiDecodeError: a needed word was not read.
    """
    detail = status_timing is not None
    analyzer = decode_analyzer_status(bank) if detail else None
    statuses = (
        {
            c.channel: decode_channel_status(bank, c.channel)
            for c in channels
            if c.channel.is_measured
        }
        if detail
        else {}
    )
    decoded: dict[ChannelId, tuple[float | None, int, int, Unit]] = {}
    for info in channels:
        value, dp, unit_code = _triple(bank, info.channel)
        scaled = scale(value, dp) if 0 <= dp <= MAX_DECIMALS else None
        decoded[info.channel] = (scaled, value, dp, unit_from_code(unit_code))
    undecodable = [c for c, (v, *_rest) in decoded.items() if v is None]
    states = derive_states(channels, statuses, analyzer, undecodable=undecodable)
    readings = tuple(
        Reading(
            channel=info.channel,
            gas=info.gas,
            suggested_gas=info.suggested_gas,
            label_source=info.label_source,
            role=info.role,
            value=decoded[info.channel][0],
            unit=decoded[info.channel][3],
            raw_value=decoded[info.channel][1],
            decimals=decoded[info.channel][2],
            status=statuses.get(info.channel),
            state=states[info.channel],
            protocol=protocol,
        )
        for info in channels
    )
    return Frame(
        readings=readings,
        analyzer=analyzer,
        protocol=protocol,
        readings_timing=readings_timing,
        status_timing=status_timing,
        raw=raw,
    )

decode_identity

decode_identity(bank)

The type code (with digits 27-29 when they were read) and the serial number.

Raises:

Type Description
FujiDecodeError

the identity registers were not read, or are not characters.

Source code in src/fujilib/devices/decode.py
def decode_identity(bank: Bank) -> tuple[TypeCode, str]:
    """The type code (with digits 27-29 when they were read) and the serial number.

    Raises:
        FujiDecodeError: the identity registers were not read, or are not characters.
    """
    raw = decode_chars(_spec_words(bank, "identity.type_code"), strip=False)
    ext = REGISTRY.resolve("identity.type_code_ext")
    if all(a in bank for a in range(ext.address, ext.last_address + 1)):
        raw += decode_chars(words_of(bank, ext.address, ext.count), strip=False)
    serial = decode_chars(_spec_words(bank, "identity.serial_number"))
    return decode_type_code(raw), serial

decode_metadata

decode_metadata(
    bank,
    *,
    serial_number,
    ranges,
    channels,
    current_range,
    captured_at,
    clock=None,
    clock_read_at=None,
)

The settings snapshot, from the holding bank and what the session has cached.

Response times are mapped onto channels only where the labels say which channel is O2 and which are NDIR components (in channel order).

Raises:

Type Description
FujiDecodeError

a holding word was not read.

Source code in src/fujilib/devices/decode.py
def decode_metadata(
    bank: Bank,
    *,
    serial_number: str,
    ranges: Sequence[RangeInfo],
    channels: Sequence[ChannelInfo],
    current_range: Mapping[ChannelId, int],
    captured_at: datetime,
    clock: datetime | None = None,
    clock_read_at: datetime | None = None,
) -> AnalyzerMetadata:
    """The settings snapshot, from the holding bank and what the session has cached.

    Response times are mapped onto channels only where the labels say which
    channel is O2 and which are NDIR components (in channel order).

    Raises:
        FujiDecodeError: a holding word was not read.
    """
    ndir, o2, by_channel = _response_times(bank, channels)
    gases: dict[tuple[ChannelId, int], tuple[float | None, float | None]] = {}
    for info in ranges:
        for rng in range(1, info.count + 1):
            pair: list[float | None] = []
            for kind in ("zero", "span"):
                spec = REGISTRY.resolve(
                    f"calibration_gas.ch{info.channel.number}.range{rng}.{kind}"
                )
                decoded = decode_register(
                    spec, words_of(bank, spec.address), scaling=range_scaling(spec, ranges)
                )
                pair.append(decoded.value if isinstance(decoded.value, float) else None)
            gases[info.channel, rng] = (pair[0], pair[1])
    return AnalyzerMetadata(
        serial_number=serial_number,
        ranges=tuple(ranges),
        current_range=MappingProxyType(dict(current_range)),
        response_time_s=by_channel,
        response_time_ndir_s=ndir,
        response_time_o2_s=o2,
        moving_average=tuple(
            AveragePeriod(
                period=_word(bank, f"moving_average{k}.period"),
                unit=_enum(bank, f"moving_average{k}.unit", PeriodUnit),
            )
            for k in range(1, 5)
        ),
        calibration_gas=MappingProxyType(gases),
        calibration_scope=MappingProxyType(
            {
                c: CalibrationScope(
                    zero_mode=_enum(
                        bank, f"calibration.ch{c.number}.zero_mode", ZeroCalibrationMode
                    ),
                    range_mode=_enum(
                        bank, f"calibration.ch{c.number}.range_mode", CalibrationRangeMode
                    ),
                )
                for c in MEASURED_CHANNELS
            }
        ),
        hold_mode=_enum(bank, "hold.mode", HoldMode),
        output_hold=_flag(bank, "output_hold.enabled"),
        auto_calibration=AutoCalibrationSchedule(
            schedule=_schedule(bank, "auto_calibration"),
            channels=MappingProxyType(
                {
                    c: _flag(bank, f"auto_calibration.ch{c.number}.included")
                    for c in MEASURED_CHANNELS
                }
            ),
            ranges=MappingProxyType(
                {
                    c: _range_number(bank, f"auto_calibration.ch{c.number}.range")
                    for c in MEASURED_CHANNELS
                }
            ),
            flow_times_s=tuple(_word(bank, f"auto_calibration.flow_time{k}") for k in range(1, 8)),
        ),
        auto_zero=AutoZeroSchedule(
            schedule=_schedule(bank, "auto_zero"),
            flow_time_s=_word(bank, "auto_zero.flow_time"),
        ),
        clock=clock,
        clock_read_at=clock_read_at,
        captured_at=captured_at,
    )

decode_ranges

decode_ranges(bank)

The range tables of channels 1-5.

A range whose decimal point does not decode has a nan full scale.

Raises:

Type Description
FujiDecodeError

the range registers were not read.

Source code in src/fujilib/devices/decode.py
def decode_ranges(bank: Bank) -> tuple[RangeInfo, ...]:
    """The range tables of channels 1-5.

    A range whose decimal point does not decode has a ``nan`` full scale.

    Raises:
        FujiDecodeError: the range registers were not read.
    """
    out: list[RangeInfo] = []
    for channel in MEASURED_CHANNELS:
        c = channel.number
        units: list[Unit] = []
        full_scale: list[float] = []
        decimals: list[int] = []
        for r in (1, 2):
            dp = _word(bank, f"range.ch{c}.range{r}.decimals")
            raw = _word(bank, f"range.ch{c}.range{r}.full_scale")
            units.append(unit_from_code(_word(bank, f"range.ch{c}.range{r}.unit")))
            full_scale.append(scale(raw, dp) if 0 <= dp <= MAX_DECIMALS else float("nan"))
            decimals.append(dp)
        out.append(
            RangeInfo(
                channel=channel,
                count=_word(bank, f"range.ch{c}.count"),
                units=tuple(units),
                full_scale=tuple(full_scale),
                decimals=tuple(decimals),
            )
        )
    return tuple(out)

decode_register

decode_register(spec, words, *, scaling=None)

Decode spec from its words.

scaling gives the decimals and unit of a range-scaled register; an inline-scaled concentration is decoded with :func:decode_frame instead and comes back raw here.

Raises:

Type Description
FujiDecodeError

the words do not decode as the spec's type.

Source code in src/fujilib/devices/decode.py
def decode_register(
    spec: RegisterSpec,
    words: Sequence[int],
    *,
    scaling: tuple[int, Unit] | None = None,
) -> RegisterValue:
    """Decode ``spec`` from its ``words``.

    ``scaling`` gives the decimals and unit of a range-scaled register; an
    inline-scaled concentration is decoded with :func:`decode_frame` instead
    and comes back raw here.

    Raises:
        FujiDecodeError: the words do not decode as the spec's type.
    """
    raw = decode_raw(words, spec.dtype)
    value: float | int | bool | str | None = raw
    unit = spec.unit
    if spec.enum is not None and isinstance(raw, int) and not isinstance(raw, bool):
        value = decode_enum(spec.enum, raw)
    kind = spec.scaling.kind
    if kind in {ScalingKind.BY_RANGE, ScalingKind.BY_ALARM_TARGET}:
        if scaling is None or not 0 <= scaling[0] <= MAX_DECIMALS or not isinstance(raw, int):
            value = None
        else:
            value = scale(raw, scaling[0])
            unit = scaling[1].value
    elif kind is ScalingKind.FIXED and isinstance(raw, int) and spec.scaling.decimals is not None:
        value = scale(raw, spec.scaling.decimals)
    return RegisterValue(spec=spec, words=tuple(words), raw=raw, value=value, unit=unit)

derive_states

derive_states(
    channels, statuses, analyzer, *, undecodable=()
)

Apply the validity rule of design §8 to every established channel.

  • Without the status block every state is UNKNOWN.
  • A channel whose value did not decode is UNKNOWN.
  • A channel is invalid when the analyzer reports an instrument error, the channel reports an error 4-9, it is calibrating, an auto calibration is running, or it is held.
  • A derived channel (O2-corrected, an average, or an unlabelled channel above 5) is also SOURCE_INVALID when its source channel or the O2 channel is invalid. When its source is not known, any invalid measured channel makes it invalid.
Source code in src/fujilib/devices/decode.py
def derive_states(
    channels: Sequence[ChannelInfo],
    statuses: Mapping[ChannelId, ChannelStatus],
    analyzer: AnalyzerStatus | None,
    *,
    undecodable: Iterable[ChannelId] = (),
) -> Mapping[ChannelId, ReadingState]:
    """Apply the validity rule of design §8 to every established channel.

    - Without the status block every state is ``UNKNOWN``.
    - A channel whose value did not decode is ``UNKNOWN``.
    - A channel is invalid when the analyzer reports an instrument error, the
      channel reports an error 4-9, it is calibrating, an auto calibration is
      running, or it is held.
    - A derived channel (O2-corrected, an average, or an unlabelled channel
      above 5) is also ``SOURCE_INVALID`` when its source channel or the O2
      channel is invalid. When its source is not known, any invalid measured
      channel makes it invalid.
    """
    if analyzer is None:
        return MappingProxyType({c.channel: ReadingState.UNKNOWN for c in channels})
    states = {c.channel: _own_state(statuses.get(c.channel), analyzer) for c in channels}
    o2 = next(
        (
            c.channel
            for c in channels
            if Gas.O2 in {c.gas, c.suggested_gas} and c.role is ChannelRole.INSTANTANEOUS
        ),
        None,
    )
    measured_invalid = any(
        states[c.channel] is not ReadingState.OK for c in channels if not _is_derived(c)
    )
    for info in channels:
        if not _is_derived(info) or states[info.channel] is not ReadingState.OK:
            continue
        if info.derived_from is None and info.role is not ChannelRole.O2_AVERAGE:
            invalid = measured_invalid
        else:
            sources = {s for s in (info.derived_from, o2) if s is not None and s in states}
            invalid = any(states[s] is not ReadingState.OK for s in sources)
        if invalid:
            states[info.channel] = ReadingState.SOURCE_INVALID
    for channel in states.keys() & set(undecodable):
        states[channel] = ReadingState.UNKNOWN
    return MappingProxyType(states)

label_channels

label_channels(present, *, asserted=None, type_code=None)

Label the established channels (design §2.9).

The established channels are present plus every asserted one. An asserted label wins and is the only source fit for calculation; otherwise gas is UNKNOWN and the type code (or the layout rule) only suggests one.

Source code in src/fujilib/devices/decode.py
def label_channels(
    present: Iterable[ChannelId],
    *,
    asserted: Mapping[ChannelId, Gas] | None = None,
    type_code: TypeCode | None = None,
) -> tuple[ChannelInfo, ...]:
    """Label the established channels (design §2.9).

    The established channels are ``present`` plus every asserted one. An
    asserted label wins and is the only source fit for calculation; otherwise
    ``gas`` is ``UNKNOWN`` and the type code (or the layout rule) only
    *suggests* one.
    """
    asserted = asserted or {}
    channels = sorted(set(present) | set(asserted), key=lambda c: c.number)
    suggestions = suggest_labels(type_code, channels)
    out: list[ChannelInfo] = []
    for channel in channels:
        suggestion = suggestions.get(channel)
        role = suggestion.role if suggestion is not None else ChannelRole.UNKNOWN
        gas = asserted.get(channel)
        if gas is not None and suggestion is None and channel.is_measured:
            role = ChannelRole.INSTANTANEOUS
        out.append(
            ChannelInfo(
                channel=channel,
                gas=gas if gas is not None else Gas.UNKNOWN,
                suggested_gas=suggestion.gas if suggestion is not None else None,
                role=role,
                label_source=(
                    LabelSource.ASSERTED
                    if gas is not None
                    else (suggestion.source if suggestion is not None else LabelSource.UNKNOWN)
                ),
                derived_from=suggestion.derived_from if suggestion is not None else None,
            )
        )
    return tuple(out)

nonzero_channels

nonzero_channels(bank)

Channels whose reading triple is not all zero in bank.

An all-zero triple is a legal zero reading (0, dp 0, vol%), not proof of absence, so this only ever adds channels (design §2.9, step 2).

Source code in src/fujilib/devices/decode.py
def nonzero_channels(bank: Bank) -> frozenset[ChannelId]:
    """Channels whose reading triple is not all zero in ``bank``.

    An all-zero triple is a legal zero reading (0, dp 0, vol%), not proof of
    absence, so this only ever *adds* channels (design §2.9, step 2).
    """
    return frozenset(c for c in CHANNELS if any(_triple(bank, c)))

range_scaling

range_scaling(spec, ranges, *, alarm_targets=None)

The decimals and unit that scale spec, or None if they cannot be known.

BY_RANGE uses the spec's own channel and range. BY_ALARM_TARGET needs the alarm's target channel, which only alarm_targets can supply because the target register's encoding is contested.

Source code in src/fujilib/devices/decode.py
def range_scaling(
    spec: RegisterSpec,
    ranges: Sequence[RangeInfo],
    *,
    alarm_targets: Mapping[int, ChannelId] | None = None,
) -> tuple[int, Unit] | None:
    """The decimals and unit that scale ``spec``, or ``None`` if they cannot be known.

    ``BY_RANGE`` uses the spec's own channel and range. ``BY_ALARM_TARGET``
    needs the alarm's target channel, which only ``alarm_targets`` can supply
    because the target register's encoding is contested.
    """
    channel: ChannelId | None = spec.channel
    if spec.scaling.kind is ScalingKind.BY_ALARM_TARGET:
        channel = (alarm_targets or {}).get(spec.alarm or 0)
    elif spec.scaling.kind is not ScalingKind.BY_RANGE:
        return None
    if channel is None or spec.range is None:
        return None
    for info in ranges:
        if info.channel is channel and spec.range <= len(info.units):
            unit, _full_scale, decimals = info.of(spec.range)
            return decimals, unit
    return None

words_of

words_of(bank, address, count=1)

The count words from address.

Raises:

Type Description
FujiDecodeError

a word is missing from bank.

Source code in src/fujilib/devices/decode.py
def words_of(bank: Bank, address: int, count: int = 1) -> tuple[int, ...]:
    """The ``count`` words from ``address``.

    Raises:
        FujiDecodeError: a word is missing from ``bank``.
    """
    try:
        return tuple(bank[a] for a in range(address, address + count))
    except KeyError as exc:
        msg = f"register 0x{exc.args[0]:04X} was not read"
        raise FujiDecodeError(msg, context=ErrorContext(register_address=exc.args[0])) from None

fujilib.devices.reads

Read procedures: a precomputed plan, a client and a pure decoder (design §4.3, §6.6).

Each function reads one of the precomputed plans of :mod:fujilib.protocol.modbus.read_plan through a :class:~fujilib.protocol.base.ProtocolClient and decodes the words with :mod:fujilib.devices.decode. They hold no state: whatever a decoder needs beyond the words (the established channels, the ranges, the current range) is passed in by the caller, which caches it (design §6.7). Gates, caches and availability bookkeeping belong to the session; these functions only read.

A read plan runs under the port's operation lock, so the blocks of one procedure are never interleaved with other traffic on the port. They are not simultaneous: the analyzer updates between transactions, and the front panel stays live (design §1).

Transactions per procedure (FC04 unless noted), tested as exact lists:

Procedure Transactions
read_poll, read_frame 0000h+61, 0083h+60; the first only without detail
read_status the same two blocks
read_ranges 0425h+35
read_identity 0425h+35, 0448h+34, 0000h+42, then the probes
probe_capabilities 03E8h+49 (03E8h+7 and 03EFh+42 if it fails), 047Ah+3, 1000h+9
read_metadata FC03 0000h+64, 0040h+64, 0080h+36; 0025h+5; 03E8h+7 with the clock
read_settings FC03 0000h+64, 0040h+64, 0080h+44
read_error_log 003Dh+60, 0079h+10, then 003Dh+5 to check
read_calibration_log five blocks of 63 words and one of 45, then the first record
read_clock 03E8h+7
read_adc 03EFh+42

ClockReading dataclass

ClockReading(clock, timing)

The analyzer's clock and when it was read.

The clock is naive local time with a two-digit year, and it drifts: on the bench it ran minutes behind the host. It never replaces host timestamps (design §6.6).

read_at property

read_at

The host's UTC time of the read (the transaction's midpoint).

Identity dataclass

Identity(
    type_code,
    serial_number,
    ranges,
    nonzero,
    current_ranges,
    probes,
    timings,
)

What identify() reads: identity, ranges, presence and capabilities.

availability property

availability

What each probe found.

current_ranges instance-attribute

current_ranges

The range each of channels 1-5 was measuring on.

nonzero instance-attribute

nonzero

Channels whose reading triple was not all zero (design §2.9 step 2).

type_code instance-attribute

type_code

Digits 1-26, plus 27-29 where the analyzer has them.

PollRead dataclass

PollRead(bank, timings, raw)

The words of one poll, before they are decoded.

bank instance-attribute

bank

The input-register words read, by address.

current_ranges property

current_ranges

The range each of channels 1-5 is measuring on; in the readings block.

detail property

detail

Whether the status block was read as well.

nonzero property

nonzero

Channels whose reading triple was not all zero (design §2.9 step 2).

timings instance-attribute

timings

The timing of each block: the readings block, then the status block.

decode

decode(channels)

The frame of the established channels.

Source code in src/fujilib/devices/reads.py
def decode(self, channels: Sequence[ChannelInfo]) -> Frame:
    """The frame of the established ``channels``."""
    return decode_frame(
        self.bank,
        channels,
        readings_timing=self.timings[0],
        status_timing=self.timings[1] if self.detail else None,
        raw=self.raw,
    )

status

status()

The analyzer's and channels 1-5's status in a poll read with detail.

Source code in src/fujilib/devices/reads.py
def status(self) -> StatusRead:
    """The analyzer's and channels 1-5's status in a poll read with ``detail``."""
    return StatusRead(
        analyzer=decode_analyzer_status(self.bank),
        channels=MappingProxyType(
            {c: decode_channel_status(self.bank, c) for c in MEASURED_CHANNELS}
        ),
        timings=self.timings,
    )

ProbeResult dataclass

ProbeResult(
    capability,
    availability,
    words=(),
    error=None,
    timing=None,
)

What probing one capability found (design §6.6).

error class-attribute instance-attribute

error = None

Why the probe did not find the capability supported, when it failed.

words class-attribute instance-attribute

words = ()

The words read, when the probe read any.

StatusRead dataclass

StatusRead(analyzer, channels, timings)

The analyzer's status and every measured channel's, from one poll's blocks.

probe_capabilities async

probe_capabilities(
    client,
    capabilities=PROBED_CAPABILITIES,
    *,
    deadline=None,
)

Probe each of capabilities (design §6.6).

The clock and the A/D values are adjacent, so when both are asked for they are probed with one read of both, and separately only if that fails.

Raises:

Type Description
FujiValidationError

a capability is not one that is probed.

FujiTimeoutError

deadline expired.

FujiConnectionError

the port failed.

Source code in src/fujilib/devices/reads.py
async def probe_capabilities(
    client: ProtocolClient,
    capabilities: Iterable[Capability] = PROBED_CAPABILITIES,
    *,
    deadline: Deadline | None = None,
) -> Mapping[Capability, ProbeResult]:
    """Probe each of ``capabilities`` (design §6.6).

    The clock and the A/D values are adjacent, so when both are asked for
    they are probed with one read of both, and separately only if that fails.

    Raises:
        FujiValidationError: a capability is not one that is probed.
        FujiTimeoutError: ``deadline`` expired.
        FujiConnectionError: the port failed.
    """
    wanted = tuple(dict.fromkeys(capabilities))
    for capability in wanted:
        if capability not in PROBED_CAPABILITIES:
            msg = f"{capability!r} is not a probed capability"
            raise FujiValidationError(msg)
    results: dict[Capability, ProbeResult] = {}
    if Capability.CLOCK in wanted and Capability.ADC_VALUES in wanted:
        combined = await _probe_read(client, SERVICE_PLAN[0], deadline)
        if isinstance(combined, tuple):
            words, timing = combined
            clock_words, adc_words = words[:_CLOCK_WORDS], words[_CLOCK_WORDS:]
            results[Capability.CLOCK] = _validate(Capability.CLOCK, clock_words, timing)
            results[Capability.ADC_VALUES] = _validate(Capability.ADC_VALUES, adc_words, timing)
    for capability in wanted:
        if capability not in results:
            results[capability] = await probe_capability(client, capability, deadline=deadline)
    return MappingProxyType({c: results[c] for c in wanted})

probe_capability async

probe_capability(client, capability, *, deadline=None)

Probe one capability (design §6.6).

  • SUPPORTED: the probe read data that validates.
  • UNSUPPORTED: exception 02. Every probe is a well-formed read inside one documented or observed block, so 02 means the block is absent.
  • UNKNOWN: no reply, a damaged reply, or another exception.
  • INVALID_DATA: readable, but the words do not validate.

Raises:

Type Description
FujiValidationError

capability is not one that is probed.

FujiTimeoutError

deadline expired.

FujiConnectionError

the port failed.

Source code in src/fujilib/devices/reads.py
async def probe_capability(
    client: ProtocolClient, capability: Capability, *, deadline: Deadline | None = None
) -> ProbeResult:
    """Probe one capability (design §6.6).

    - ``SUPPORTED``: the probe read data that validates.
    - ``UNSUPPORTED``: exception 02. Every probe is a well-formed read inside
      one documented or observed block, so 02 means the block is absent.
    - ``UNKNOWN``: no reply, a damaged reply, or another exception.
    - ``INVALID_DATA``: readable, but the words do not validate.

    Raises:
        FujiValidationError: ``capability`` is not one that is probed.
        FujiTimeoutError: ``deadline`` expired.
        FujiConnectionError: the port failed.
    """
    block = _PROBES.get(capability)
    if block is None:
        msg = f"{capability!r} is not a probed capability"
        raise FujiValidationError(msg)
    outcome = await _probe_read(client, block, deadline)
    if not isinstance(outcome, tuple):
        availability = (
            Availability.UNSUPPORTED
            if isinstance(outcome, FujiModbusIllegalDataAddressError)
            else Availability.UNKNOWN
        )
        return ProbeResult(capability, availability, error=outcome)
    return _validate(capability, *outcome)

read_adc async

read_adc(client, *, deadline=None)

The 21 A/D counts of the service manual's table (undocumented; design §6.6).

A service diagnostic, not a calibrated or higher-resolution gas measurement.

Source code in src/fujilib/devices/reads.py
async def read_adc(client: ProtocolClient, *, deadline: Deadline | None = None) -> AdcValues:
    """The 21 A/D counts of the service manual's table (undocumented; design §6.6).

    A service diagnostic, not a calibrated or higher-resolution gas measurement.
    """
    reply = await client.read(ADC_PLAN[0], deadline=deadline, command="read_adc")
    return decode_adc(
        reply.words,
        received_at=reply.timing.received_at,
        t_mono_ns=reply.timing.midpoint_mono_ns,
    )

read_calibration_log async

read_calibration_log(client, channel, *, deadline=None)

One channel's calibration log, newest first; firmware 2.24 or later (design §4.3).

Raises:

Type Description
FujiValidationError

channel is not one of channels 1-5.

Source code in src/fujilib/devices/reads.py
async def read_calibration_log(
    client: ProtocolClient, channel: ChannelId | str, *, deadline: Deadline | None = None
) -> tuple[CalibrationLogEntry, ...]:
    """One channel's calibration log, newest first; firmware 2.24 or later (design §4.3).

    Raises:
        FujiValidationError: ``channel`` is not one of channels 1-5.
    """
    cid = coerce_channel(channel)
    if not cid.is_measured:
        msg = f"{cid.value} has no calibration log"
        raise FujiValidationError(msg, context=ErrorContext(channel=cid.value))
    plan = calibration_log_plan(cid.number)
    bank = await _read_log(
        client, CALIBRATION_LOG, cid.number, plan, deadline=deadline, command="read_calibration_log"
    )
    return decode_calibration_log(bank, cid)

read_clock async

read_clock(client, *, deadline=None)

The analyzer's clock (undocumented; design §6.6).

Raises:

Type Description
FujiDecodeError

the words are not a date.

Source code in src/fujilib/devices/reads.py
async def read_clock(client: ProtocolClient, *, deadline: Deadline | None = None) -> ClockReading:
    """The analyzer's clock (undocumented; design §6.6).

    Raises:
        FujiDecodeError: the words are not a date.
    """
    reply = await client.read(CLOCK_PLAN[0], deadline=deadline, command="read_clock")
    return ClockReading(decode_clock(reply.words), reply.timing)

read_error_log async

read_error_log(client, *, deadline=None)

The error log, newest first (design §4.3).

A new entry shifts the whole log, so the newest record is read again after the scan; if it changed, the log is read once more.

Source code in src/fujilib/devices/reads.py
async def read_error_log(
    client: ProtocolClient, *, deadline: Deadline | None = None
) -> tuple[ErrorLogEntry, ...]:
    """The error log, newest first (design §4.3).

    A new entry shifts the whole log, so the newest record is read again
    after the scan; if it changed, the log is read once more.
    """
    bank = await _read_log(
        client, ERROR_LOG, 1, ERROR_LOG_PLAN, deadline=deadline, command="read_error_log"
    )
    return decode_error_log(bank)

read_frame async

read_frame(client, channels, *, detail=True, deadline=None)

Poll the established channels: two transactions, or one without detail.

Without detail only the concentrations are read, and every reading's state is unknown rather than a manufactured "ok" (design §4.3).

Raises:

Type Description
FujiError

as :func:read_poll.

Source code in src/fujilib/devices/reads.py
async def read_frame(
    client: ProtocolClient,
    channels: Sequence[ChannelInfo],
    *,
    detail: bool = True,
    deadline: Deadline | None = None,
) -> Frame:
    """Poll the established ``channels``: two transactions, or one without ``detail``.

    Without ``detail`` only the concentrations are read, and every reading's
    state is ``unknown`` rather than a manufactured "ok" (design §4.3).

    Raises:
        FujiError: as :func:`read_poll`.
    """
    poll = await read_poll(client, detail=detail, deadline=deadline)
    return poll.decode(channels)

read_identity async

read_identity(client, *, probe=True, deadline=None)

Read the type code, serial number, ranges and readings, then probe the capabilities.

With probe=False no capability is probed and :attr:Identity.probes is empty.

Raises:

Type Description
FujiError

a transaction of the identity plan failed. A failed probe does not raise; it is reported in :attr:Identity.probes.

FujiDecodeError

the type code or serial number is not characters.

Source code in src/fujilib/devices/reads.py
async def read_identity(
    client: ProtocolClient, *, probe: bool = True, deadline: Deadline | None = None
) -> Identity:
    """Read the type code, serial number, ranges and readings, then probe the capabilities.

    With ``probe=False`` no capability is probed and :attr:`Identity.probes`
    is empty.

    Raises:
        FujiError: a transaction of the identity plan failed. A failed *probe*
            does not raise; it is reported in :attr:`Identity.probes`.
        FujiDecodeError: the type code or serial number is not characters.
    """
    dl = deadline if deadline is not None else Deadline.after(None, operation="identify")
    reply = await client.read_plan(IDENTIFY_PLAN, deadline=dl, command="identify")
    probes: Mapping[Capability, ProbeResult] = MappingProxyType({})
    if probe:
        probes = await probe_capabilities(client, deadline=dl)
    bank = dict(reply.input)
    extension = probes.get(Capability.TYPE_CODE_EXT)
    if extension is not None and extension.availability is Availability.SUPPORTED:
        bank.update(TYPE_CODE_EXT_PLAN[0].to_bank(extension.words))
    type_code, serial = decode_identity(bank)
    timings = reply.timings + tuple(p.timing for p in probes.values() if p.timing is not None)
    return Identity(
        type_code=type_code,
        serial_number=serial,
        ranges=decode_ranges(bank),
        nonzero=nonzero_channels(bank),
        current_ranges=decode_current_ranges(bank),
        probes=probes,
        timings=tuple(dict.fromkeys(timings)),
    )

read_metadata async

read_metadata(
    client,
    *,
    serial_number,
    ranges,
    channels,
    clock,
    deadline=None,
)

The settings snapshot consumers such as capa carry (design §7.2).

The current range of each channel is read with the settings, so the snapshot does not depend on an earlier poll. clock reads the analyzer's clock as well; pass it only when :attr:Capability.CLOCK is supported. A clock that does not decode is reported as None. captured_at is when the last settings block arrived.

Raises:

Type Description
FujiError

a transaction failed.

Source code in src/fujilib/devices/reads.py
async def read_metadata(
    client: ProtocolClient,
    *,
    serial_number: str,
    ranges: Sequence[RangeInfo],
    channels: Sequence[ChannelInfo],
    clock: bool,
    deadline: Deadline | None = None,
) -> AnalyzerMetadata:
    """The settings snapshot consumers such as capa carry (design §7.2).

    The current range of each channel is read with the settings, so the
    snapshot does not depend on an earlier poll. ``clock`` reads the analyzer's
    clock as well; pass it only when :attr:`Capability.CLOCK` is supported. A
    clock that does not decode is reported as ``None``. ``captured_at`` is when
    the last settings block arrived.

    Raises:
        FujiError: a transaction failed.
    """
    plan = METADATA_PLAN + CURRENT_RANGE_PLAN + (CLOCK_PLAN if clock else ())
    reply = await client.read_plan(plan, deadline=deadline, command="read_metadata")
    settings = reply.replies[len(METADATA_PLAN) - 1]
    clock_value: datetime | None = None
    clock_read_at: datetime | None = None
    if clock:
        clock_reply = reply.replies[-1]
        try:
            clock_value = decode_clock(clock_reply.words)
        except FujiDecodeError as exc:
            _LOG.warning("%s: the analyzer clock does not decode: %s", client.label, exc)
        else:
            clock_read_at = clock_reply.timing.midpoint_utc
    return decode_metadata(
        reply.holding,
        serial_number=serial_number,
        ranges=ranges,
        channels=channels,
        current_range=decode_current_ranges(reply.input),
        captured_at=settings.timing.received_at,
        clock=clock_value,
        clock_read_at=clock_read_at,
    )

read_poll async

read_poll(client, *, detail=True, deadline=None)

Read a poll's words: two transactions, or one without detail.

The words are decoded separately (:meth:PollRead.decode), so a caller can first learn from them which channels have come to life (design §2.9).

Raises:

Type Description
FujiError

a transaction failed. When the status block fails after the concentrations were read, the error's extra["completed"] holds the concentration words (design §4.3).

Source code in src/fujilib/devices/reads.py
async def read_poll(
    client: ProtocolClient, *, detail: bool = True, deadline: Deadline | None = None
) -> PollRead:
    """Read a poll's words: two transactions, or one without ``detail``.

    The words are decoded separately (:meth:`PollRead.decode`), so a caller
    can first learn from them which channels have come to life (design §2.9).

    Raises:
        FujiError: a transaction failed. When the status block fails after the
            concentrations were read, the error's ``extra["completed"]`` holds
            the concentration words (design §4.3).
    """
    plan = POLL_PLAN if detail else POLL_PLAN[:1]
    reply = await client.read_plan(plan, deadline=deadline, command="poll")
    return PollRead(bank=reply.input, timings=reply.timings, raw=reply.raw)

read_ranges async

read_ranges(client, *, deadline=None)

The range tables of channels 1-5.

Source code in src/fujilib/devices/reads.py
async def read_ranges(
    client: ProtocolClient, *, deadline: Deadline | None = None
) -> tuple[RangeInfo, ...]:
    """The range tables of channels 1-5."""
    reply = await client.read_plan(RANGES_PLAN, deadline=deadline, command="read_ranges")
    return decode_ranges(reply.input)

read_registers async

read_registers(
    client,
    names,
    *,
    ranges=(),
    alarm_targets=None,
    deadline=None,
)

The registers called names, read in the fewest blocks, decoded, in the order given.

A concentration (inline scaling) comes back raw; :func:read_frame scales it.

Raises:

Type Description
FujiValidationError

a name is not in the registry.

Source code in src/fujilib/devices/reads.py
async def read_registers(
    client: ProtocolClient,
    names: Iterable[str],
    *,
    ranges: Sequence[RangeInfo] = (),
    alarm_targets: Mapping[int, ChannelId] | None = None,
    deadline: Deadline | None = None,
) -> Mapping[str, RegisterValue]:
    """The registers called ``names``, read in the fewest blocks, decoded, in the order given.

    A concentration (inline scaling) comes back raw; :func:`read_frame` scales it.

    Raises:
        FujiValidationError: a name is not in the registry.
    """
    specs = tuple(dict.fromkeys(REGISTRY.resolve(n) for n in names))
    reply = await client.read_plan(plan_reads(specs), deadline=deadline, command="read_registers")
    return _decode_specs(specs, reply, ranges, alarm_targets)

read_settings async

read_settings(
    client, *, ranges, alarm_targets=None, deadline=None
)

Every holding register, decoded, by name.

Range-scaled values use ranges; alarm limits need alarm_targets (alarm number to channel), because the target register's encoding is contested (design §5.2). Without a scale a value is None; its raw word is always kept.

Source code in src/fujilib/devices/reads.py
async def read_settings(
    client: ProtocolClient,
    *,
    ranges: Sequence[RangeInfo],
    alarm_targets: Mapping[int, ChannelId] | None = None,
    deadline: Deadline | None = None,
) -> Mapping[str, RegisterValue]:
    """Every holding register, decoded, by name.

    Range-scaled values use ``ranges``; alarm limits need ``alarm_targets``
    (alarm number to channel), because the target register's encoding is
    contested (design §5.2). Without a scale a value is ``None``; its raw word
    is always kept.
    """
    reply = await client.read_plan(SETTINGS_PLAN, deadline=deadline, command="read_settings")
    specs = REGISTRY.in_table(RegisterTable.HOLDING)
    return _decode_specs(specs, reply, ranges, alarm_targets)

read_status async

read_status(client, *, deadline=None)

The analyzer's status and channels 1-5's, from the two poll blocks.

Source code in src/fujilib/devices/reads.py
async def read_status(client: ProtocolClient, *, deadline: Deadline | None = None) -> StatusRead:
    """The analyzer's status and channels 1-5's, from the two poll blocks."""
    reply = await client.read_plan(POLL_PLAN, deadline=deadline, command="status")
    bank = reply.input
    return StatusRead(
        analyzer=decode_analyzer_status(bank),
        channels=MappingProxyType({c: decode_channel_status(bank, c) for c in MEASURED_CHANNELS}),
        timings=reply.timings,
    )

Writing and commands

fujilib.devices.encode

The word a setting write sends, from the caller's value (design §6.1, §6.3).

The inverse of :func:fujilib.devices.decode.decode_register, in two steps, because a range-scaled value can only be finished once its range has been read, immediately before the write:

  1. :func:prepare_value checks everything that needs no I/O: that the register may be written, the value's type, an enum member, a finite number, and the unit a scaled value is given in. An unscaled value is encoded here and its limits checked.
  2. :func:encode_prepared finishes a range-scaled value against that range: the unit must be the range's, the value must fit the range's decimals exactly, and the raw and percent-of-full-scale limits must hold.

Nothing here does I/O, and every refusal is a :class:~fujilib.errors.FujiValidationError: nothing was sent.

PreparedValue dataclass

PreparedValue(spec, value, raw, unit=None)

A caller's value for one setting, checked as far as it can be without I/O.

raw instance-attribute

raw

The word to write; None for a range-scaled value, which needs its range first.

unit class-attribute instance-attribute

unit = None

The unit a range-scaled value is given in.

value instance-attribute

value

The value, normalized: a flag, an enum member, an integer, or an exact decimal.

encode_prepared

encode_prepared(prepared, range_info=None)

The word to write for prepared.

range_info is the (unit, full scale, decimals) of a range-scaled setting's (channel, range), read just before the write; it is ignored for an unscaled one.

Raises:

Type Description
FujiValidationError

the range is unknown or in another unit, the value has more decimals than the range, or it is outside the limits.

Source code in src/fujilib/devices/encode.py
def encode_prepared(
    prepared: PreparedValue, range_info: tuple[Unit, float, int] | None = None
) -> int:
    """The word to write for ``prepared``.

    ``range_info`` is the ``(unit, full scale, decimals)`` of a range-scaled
    setting's (channel, range), read just before the write; it is ignored for
    an unscaled one.

    Raises:
        FujiValidationError: the range is unknown or in another unit, the
            value has more decimals than the range, or it is outside the
            limits.
    """
    if prepared.raw is not None:
        return prepared.raw
    spec = prepared.spec
    if range_info is None:
        raise _refuse(spec, "the range's decimal point and unit are not known")
    range_unit, full_scale, decimals = range_info
    if range_unit is Unit.UNKNOWN or range_unit is not prepared.unit:
        given = prepared.unit.value if prepared.unit is not None else "?"
        raise _refuse(
            spec,
            f"Ch{spec.channel.number if spec.channel else '?'} range {spec.range} is in "
            f"{range_unit.value}, not {given}",
            range_unit=range_unit.value,
        )
    raw = unscale(prepared.value, decimals)
    _check_limits(spec, raw)
    percent = spec.write_percent_fs
    if percent is not None:
        raw_full_scale = unscale(full_scale, decimals) if math.isfinite(full_scale) else 0
        low, high = percent
        if raw_full_scale <= 0 or not low * raw_full_scale <= raw * 100 <= high * raw_full_scale:
            raise _refuse(
                spec,
                f"{prepared.value} {range_unit.value} is outside {low}-{high} % of the range's "
                f"full scale ({full_scale:g} {range_unit.value})",
                raw=raw,
            )
    return raw

prepare_value

prepare_value(spec, value, *, unit=None)

Check value for spec without I/O, and encode it unless it is range-scaled.

Parameters:

Name Type Description Default
spec RegisterSpec

The register, which must be writable.

required
value object

True/False for a flag; for an enumerated setting a member of its enum or its name (any case), never its number; an integer for a count or a time; for a range-scaled setting (a calibration gas) a number, in unit.

required
unit Unit | str | None

The unit of a range-scaled value; required there, and refused unless it is the range's own. For an unscaled value it may be given, and must then be the register's ("s", "%FS").

None

Raises:

Type Description
FujiValidationError

the register is read-only, or the value or unit does not fit it.

Source code in src/fujilib/devices/encode.py
def prepare_value(
    spec: RegisterSpec, value: object, *, unit: Unit | str | None = None
) -> PreparedValue:
    """Check ``value`` for ``spec`` without I/O, and encode it unless it is range-scaled.

    Args:
        spec: The register, which must be writable.
        value: ``True``/``False`` for a flag; for an enumerated setting a
            member of its enum or its name (any case), never its number; an
            integer for a count or a time; for a range-scaled setting (a
            calibration gas) a number, in ``unit``.
        unit: The unit of a range-scaled value; required there, and refused
            unless it is the range's own. For an unscaled value it may be given,
            and must then be the register's (``"s"``, ``"%FS"``).

    Raises:
        FujiValidationError: the register is read-only, or the value or unit
            does not fit it.
    """
    if not spec.writable or (spec.name, spec.address) not in REVIEWED_SETTINGS:
        raise _refuse(spec, "the register is read-only")
    if spec.scaling.kind is ScalingKind.BY_RANGE:
        return _prepare_scaled(spec, value, unit)
    if unit is not None and (spec.unit is None or str(unit).strip() != spec.unit):
        raise _refuse(spec, f"takes no unit {unit!r}; its unit is {spec.unit or 'none'}")
    if spec.dtype is DataType.BOOL:
        if not isinstance(value, bool):
            raise _refuse(spec, f"expects True or False, got {value!r}")
        return _encoded(spec, value, int(value))
    if spec.enum is not None:
        member = _member(spec, spec.enum, value)
        return _encoded(spec, member, int(member))
    number = _whole(spec, value)
    return _encoded(spec, number, number)

fujilib.devices.writes

Write procedures: one setting, written once and read back (design §6.3, §6.4).

Like :mod:fujilib.devices.reads, these functions hold no state; the gates, the caches and the write-rate warning belong to the session.

One write, never retried. :func:write_setting writes one word with FC06 and reads it back. The read-back runs in a scope shielded from cancellation, with its own deadline (verify_timeout), so it happens even when the write used up the operation's deadline or its reply was lost. A caller that cancels the call itself (rather than a deadline running out) cancels it without a read-back; the port's late-reply window still protects the next request. What the read-back finds decides the outcome, a :class:WriteState:

  • verified: the register reads back as written, whether or not the write's own reply arrived;
  • mismatch: it reads back otherwise (the analyzer refused it silently, the front panel changed it, or a write whose reply was lost never arrived);
  • unknown: the read-back failed too, so nothing is established.

An exception reply to the write is a definite refusal and is raised as it is: nothing was applied. A port that fails while the write waits for its reply is raised as an unknown outcome at once; nothing more can be read.

When not to write. :func:busy_reasons says why the analyzer's status forbids a write or a command now: a calibration running, or the front panel in a menu or in a manual calibration. The operator may be changing the very setting, and a setting changed during a calibration can change its scope mid-run (design §6.1). :func:calibrating_reasons is the calibration part alone, which also stops return to measurement (design §13.1 #83).

WriteResult dataclass

WriteResult(
    name,
    requested,
    previous,
    observed,
    state,
    acknowledged,
    timing,
    read_back_error=None,
)

One setting write and what its read-back found.

acknowledged instance-attribute

acknowledged

Whether the analyzer's reply to the write arrived.

changed property

changed

Whether the value written differs from the one before.

name instance-attribute

name

The register's name in the register map.

observed instance-attribute

observed

The register as read back; None when the read-back failed.

previous instance-attribute

previous

The register as read just before the write.

read_back_error class-attribute instance-attribute

read_back_error = None

Why the read-back failed, when it did.

requested instance-attribute

requested

The word written, decoded as the register.

timing instance-attribute

timing

The write's timing, when its reply arrived.

verified property

verified

Whether the register reads back as written.

WriteState

Bases: StrEnum

What the read-back after a write established.

MISMATCH class-attribute instance-attribute

MISMATCH = 'mismatch'

The register reads back as something else.

UNKNOWN class-attribute instance-attribute

UNKNOWN = 'unknown'

The read-back failed too: the write may or may not have been applied.

VERIFIED class-attribute instance-attribute

VERIFIED = 'verified'

The register reads back as written.

busy_reasons

busy_reasons(status)

Why the analyzer's status forbids a write or a command now; empty when it does not.

Source code in src/fujilib/devices/writes.py
def busy_reasons(status: StatusRead) -> list[str]:
    """Why the analyzer's status forbids a write or a command now; empty when it does not."""
    reasons = calibrating_reasons(status)
    display = status.analyzer.display
    if display is not None:
        if display.screen != DisplayScreen.MEASUREMENT:
            screen = (
                display.screen.name.lower().replace("_", " ")
                if isinstance(display.screen, DisplayScreen)
                else f"screen {display.screen}"
            )
            reasons.append(f"the front panel shows the {screen} screen")
        elif display.calibration_step != ManualCalibrationStep.NONE:
            reasons.append("a manual calibration is in progress at the front panel")
    return reasons

calibrating_reasons

calibrating_reasons(status)

Which calibrations the status shows under way, automatic or manual; empty when none.

A manual calibration's channel flags are set from its wait step to its end (protocol findings §14.3).

Source code in src/fujilib/devices/writes.py
def calibrating_reasons(status: StatusRead) -> list[str]:
    """Which calibrations the status shows under way, automatic or manual; empty when none.

    A manual calibration's channel flags are set from its wait step to its end
    (protocol findings §14.3).
    """
    reasons: list[str] = []
    if status.analyzer.auto_calibration_running:
        reasons.append("an auto calibration or auto zero calibration is running")
    for channel, channel_status in status.channels.items():
        if channel_status.calibrating:
            reasons.append(f"{channel.value} is being calibrated")
    return reasons

describe

describe(value)

A register value for a message: 15 s, 20.95 vol%, manual.

Source code in src/fujilib/devices/writes.py
def describe(value: RegisterValue) -> str:
    """A register value for a message: ``15 s``, ``20.95 vol%``, ``manual``."""
    shown = value.value if value.value is not None else value.raw
    if isinstance(shown, IntEnum):
        shown = shown.name.lower()
    return f"{shown} {value.unit}" if value.unit else str(shown)

outcome_error

outcome_error(result)

The error for a write that is not verified, or None for one that is.

Source code in src/fujilib/devices/writes.py
def outcome_error(result: WriteResult) -> FujiError | None:
    """The error for a write that is not verified, or ``None`` for one that is."""
    context = ErrorContext(
        extra={
            "setting": result.name,
            "write_state": result.state.value,
            "acknowledged": result.acknowledged,
            "transmission_started": True,
            "requested_raw": result.requested.raw,
            "previous_raw": result.previous.raw,
            "observed_raw": result.observed.raw if result.observed is not None else None,
        }
    )
    wanted = describe(result.requested)
    if result.state is WriteState.MISMATCH:
        assert result.observed is not None  # noqa: S101 - a mismatch was read back
        found = describe(result.observed)
        if result.acknowledged:
            msg = f"{result.name}: wrote {wanted}, but it reads back as {found}"
        elif result.observed.words == result.previous.words:
            msg = (
                f"{result.name}: the reply to writing {wanted} was lost, and it reads back "
                f"as {found}, as before: the write was not applied"
            )
        else:
            msg = (
                f"{result.name}: the reply to writing {wanted} was lost, and it reads back "
                f"as {found}, neither what was written nor what it was before"
            )
        return FujiVerificationError(msg, context=context)
    if result.state is WriteState.UNKNOWN:
        if isinstance(result.read_back_error, FujiConnectionError):
            # The port failed: the session breaks, as on any connection failure.
            context = context.merged(failure=FailureKind.CONNECTION.value)
        if result.acknowledged:
            msg = (
                f"{result.name}: the analyzer acknowledged writing {wanted}, but reading it "
                "back failed"
            )
        else:
            msg = (
                f"{result.name}: the reply to writing {wanted} was lost and reading it back "
                "failed; it may or may not have been applied"
            )
        return FujiWriteOutcomeUnknownError(msg, context=context)
    return None

write_setting async

write_setting(
    client,
    spec,
    raw,
    *,
    previous,
    scaling,
    deadline,
    verify_timeout,
    command,
)

Write raw to spec once with FC06, then read it back (see the module docstring).

previous is the register as read just before; scaling decodes a range-scaled value. The result is returned whatever it says; :func:outcome_error turns one that is not verified into its error.

Raises:

Type Description
FujiModbusError

the analyzer refused the write with an exception reply; nothing was applied.

FujiWriteOutcomeUnknownError

the port failed while the write waited for its reply.

FujiError

the write could not be sent (a closed port, an expired deadline): nothing was written.

Source code in src/fujilib/devices/writes.py
async def write_setting(
    client: ProtocolClient,
    spec: RegisterSpec,
    raw: int,
    *,
    previous: RegisterValue,
    scaling: tuple[int, Unit] | None,
    deadline: Deadline,
    verify_timeout: float,
    command: str,
) -> WriteResult:
    """Write ``raw`` to ``spec`` once with FC06, then read it back (see the module docstring).

    ``previous`` is the register as read just before; ``scaling`` decodes a
    range-scaled value. The result is returned whatever it says;
    :func:`outcome_error` turns one that is not verified into its error.

    Raises:
        FujiModbusError: the analyzer refused the write with an exception
            reply; nothing was applied.
        FujiWriteOutcomeUnknownError: the port failed while the write waited
            for its reply.
        FujiError: the write could not be sent (a closed port, an expired
            deadline): nothing was written.
    """
    requested = decode_register(spec, (raw,), scaling=scaling)
    acknowledged = False
    timing: TransferTiming | None = None
    try:
        timing = await client.write_register(spec.address, raw, deadline=deadline, command=command)
        acknowledged = True
    except FujiWriteOutcomeUnknownError as exc:
        if exc.context.extra.get("failure") == FailureKind.CONNECTION.value:
            raise exc.with_context(setting=spec.name) from exc.__cause__
    read_back = await _read_back(client, spec, scaling, verify_timeout, command)
    observed = read_back if not isinstance(read_back, FujiError) else None
    if observed is None:
        state = WriteState.UNKNOWN
    elif observed.words == requested.words:
        state = WriteState.VERIFIED
    else:
        state = WriteState.MISMATCH
    return WriteResult(
        name=spec.name,
        requested=requested,
        previous=previous,
        observed=observed,
        state=state,
        acknowledged=acknowledged,
        timing=timing,
        read_back_error=read_back if isinstance(read_back, FujiError) else None,
    )

fujilib.devices.settings

The analyzer's settings as a document: compared and applied (design §6.3, §7.7).

A settings document is what fuji-configure dump writes (format fujilib-settings/1): the analyzer's identity, then each holding register by name with its value and unit (and raw, access, safety and evidence, which are informative). A document written by hand may give only the settings to change, each as {"value": ..., "unit": ...} or as a bare value.

Comparing (:func:diff_settings) decides, for every setting the document names, one :class:ChangeAction:

  • unchanged: the analyzer has that value already;
  • write: it differs, the register is writable, and the value passes every check that can be made before writing, against the range tables read just before;
  • refused: an unknown name, an operation, an input register, a read-only register whose value differs, a value that does not fit, a unit other than the range's, or a range selection whose method would not be manual.

A document from another analyzer (another serial number) is refused as a whole unless the caller says any analyzer will do.

Applying (:meth:~fujilib.devices.analyzer.Analyzer.apply_settings) preflights the whole document first: if anything is refused, nothing is written. The writes then go in a fixed order, each a setting write of its own (read back, never retried): output hold before the hold settings when it is switched on, after them when it is switched off; a range method before its range; then the response times, the calibration scope and the calibration gases. The first write that fails stops the rest. The report says which writes completed, which failed and which were not attempted. Nothing is rolled back or switched back on.

ApplyReport dataclass

ApplyReport(
    diff,
    completed,
    failed=None,
    error=None,
    not_attempted=(),
)

What applying a settings document did.

completed instance-attribute

completed

The writes that completed, verified, in order.

failed class-attribute instance-attribute

failed = None

The setting whose write failed, if one did.

not_attempted class-attribute instance-attribute

not_attempted = ()

The writes after the failed one.

status property

status

How it ended (see :class:ApplyStatus).

ApplyStatus

Bases: StrEnum

How applying a document ended.

FAILED class-attribute instance-attribute

FAILED = 'failed'

The first write failed, or was refused by the analyzer; nothing was changed.

OK class-attribute instance-attribute

OK = 'ok'

Every write verified (or there was nothing to write).

PARTIAL class-attribute instance-attribute

PARTIAL = 'partial'

A write failed after others had completed; the rest were not attempted.

UNKNOWN class-attribute instance-attribute

UNKNOWN = 'unknown'

A write's outcome could not be established.

VERIFY_FAILED class-attribute instance-attribute

VERIFY_FAILED = 'verify_failed'

A write read back as something else.

ChangeAction

Bases: StrEnum

What applying a document would do with one of its settings.

DesiredSetting dataclass

DesiredSetting(name, value, unit=None, raw=None)

One setting as a document gives it.

raw class-attribute instance-attribute

raw = None

The raw word, compared only when value is None (an alarm limit with no scale).

SettingChange dataclass

SettingChange(
    name, action, desired, current, safety, reason=None
)

One setting of a document, against the analyzer.

current instance-attribute

current

The analyzer's value; None for a name that is not a holding register.

reason class-attribute instance-attribute

reason = None

Why it is refused.

safety instance-attribute

safety

The tier of the write; READ_ONLY for one that will not be written.

SettingsDiff dataclass

SettingsDiff(changes, identity_mismatch=None)

Every setting of a document against the analyzer, writes in the order they would go.

identity_mismatch class-attribute instance-attribute

identity_mismatch = None

Why the document is not this analyzer's, unless any analyzer was allowed.

ok property

ok

Whether the document may be applied: nothing refused, and it is this analyzer's.

refused property

refused

The settings refused.

tier property

tier

The highest tier of the writes; READ_ONLY when there are none.

unchanged property

unchanged

The settings the analyzer has already.

writes property

writes

The settings that would be written, in order.

refusal

refusal()

The error for a document that may not be applied, naming every reason.

Source code in src/fujilib/devices/settings.py
def refusal(self) -> FujiValidationError:
    """The error for a document that may not be applied, naming every reason."""
    reasons = [f"{c.name}: {c.reason}" for c in self.refused]
    if self.identity_mismatch is not None:
        reasons.insert(0, self.identity_mismatch)
    msg = "the settings document is refused, nothing was written: " + "; ".join(reasons)
    return FujiValidationError(msg, context=ErrorContext(extra={"refused": tuple(reasons)}))

SettingsDocument dataclass

SettingsDocument(
    settings, serial_number=None, type_code=None, model=None
)

A settings document: the analyzer it describes, and its settings by name.

from_json classmethod

from_json(data)

Read a document, as parsed from JSON.

Raises:

Type Description
FujiValidationError

it is not a fujilib-settings/1 document.

Source code in src/fujilib/devices/settings.py
@classmethod
def from_json(cls, data: object) -> SettingsDocument:
    """Read a document, as parsed from JSON.

    Raises:
        FujiValidationError: it is not a ``fujilib-settings/1`` document.
    """
    if not isinstance(data, dict):
        msg = "a settings document is a JSON object"
        raise FujiValidationError(msg)
    document = cast("dict[str, object]", data)
    if document.get("format") != SETTINGS_FORMAT:
        msg = f"expected format {SETTINGS_FORMAT!r}, got {document.get('format')!r}"
        raise FujiValidationError(msg)
    entries = document.get("settings")
    if not isinstance(entries, dict):
        msg = "a settings document needs a 'settings' object"
        raise FujiValidationError(msg)
    analyzer = document.get("analyzer", {})
    if not isinstance(analyzer, dict):
        msg = "'analyzer' must be an object"
        raise FujiValidationError(msg)
    identity = cast("dict[str, object]", analyzer)
    for key in ("serial_number", "type_code", "model"):
        if identity.get(key) is not None and not isinstance(identity.get(key), str):
            msg = f"'analyzer.{key}' must be text, got {identity.get(key)!r}"
            raise FujiValidationError(msg)
    settings = {
        str(name): _desired(str(name), entry)
        for name, entry in cast("dict[object, object]", entries).items()
    }
    return cls(
        settings=MappingProxyType(settings),
        serial_number=_text(identity.get("serial_number")),
        type_code=_text(identity.get("type_code")),
        model=_text(identity.get("model")),
    )

diff_settings

diff_settings(
    document,
    current,
    *,
    registry,
    ranges,
    serial_number,
    any_analyzer=False,
)

Compare document with the analyzer's current holding registers.

ranges are the range tables read with them; serial_number is the analyzer's. See the module docstring for what is refused.

Source code in src/fujilib/devices/settings.py
def diff_settings(
    document: SettingsDocument,
    current: Mapping[str, RegisterValue],
    *,
    registry: RegisterRegistry,
    ranges: Sequence[RangeInfo],
    serial_number: str | None,
    any_analyzer: bool = False,
) -> SettingsDiff:
    """Compare ``document`` with the analyzer's ``current`` holding registers.

    ``ranges`` are the range tables read with them; ``serial_number`` is the
    analyzer's. See the module docstring for what is refused.
    """
    against = _Against(current, registry, ranges, document)
    changes = [_change(name, desired, against) for name, desired in document.settings.items()]
    mismatch = None
    if (
        not any_analyzer
        and document.serial_number is not None
        and document.serial_number != serial_number
    ):
        mismatch = (
            f"the document describes the analyzer with serial number "
            f"{document.serial_number!r}, not this one ({serial_number!r})"
        )
    ordered = sorted(changes, key=lambda c: _order(c, registry))
    return SettingsDiff(tuple(ordered), mismatch)

fujilib.devices.operations

Operation commands: auto calibration, auto zero, blowback, return to measurement.

The four documented commands (design §6.2-§6.4) are FC06 writes of 1 to 42002-42005 (:data:~fujilib.registry.write_policy.OPERATIONS). This module plans them, sends them and reports what they did; the gates belong to the session.

What a calibration touches (:func:plan_calibration, ZPA manual p.45-47, p.53-60). Auto calibration and auto zero calibration act on the channels enabled for auto calibration (40021-40025), each on its auto-calibration range (40116-40120), and on both ranges where the calibration range is "both" (40031-40035). The "at once" setting (40026-40030) does not widen them: their zero is always done together. Auto calibration then spans the channels one at a time from Ch1. The plan also gives the calibration gases each range will be calibrated against, the flow times, whether the outputs are held, and a duration that is inferred from the flow times, which the manuals do not state as such.

What a command did (:class:CommandResult). The analyzer's reply means it accepted the command, not that it finished (TN5A1190a p.11, p.17), and a command is never retried. So the status is read after it, shielded from cancellation and within its own deadline:

  • a calibration is started when the status shows it running, and ambiguous when the analyzer acknowledged it but nothing runs: it never started, or already ended (design §6.4);
  • return to measurement is done when the panel shows the measurement screen and no calibration flag is set. It is refused while a flag is set before it (design §13.1 #83): on a manual calibration's wait step 42002 brings the display back but leaves the flag set (protocol findings §18.4);
  • blowback is only sent: no register shows it running.

A command whose reply was lost is established from the status where the status can say (a calibration running, the measurement screen shown), and is otherwise an unknown outcome.

No register stops a running calibration. The front panel can force-stop one, but not while key lock is on (ZPA manual p.55-57, p.62).

CalibrationPlan dataclass

CalibrationPlan(
    run, targets, hold, phases, estimated_duration_s, notes
)

What an automatic calibration will do, read from the settings (see the module docstring).

channels property

channels

The channels calibrated.

estimated_duration_s instance-attribute

estimated_duration_s

The sum of the phases' flow times; an inference, not a documented figure.

hold instance-attribute

hold

Whether output hold is on: the outputs and Modbus concentrations are held throughout.

phases instance-attribute

phases

(phase, flow time in s) in order; which flow time is which is inferred.

CalibrationRun

Bases: StrEnum

Which automatic calibration a command starts.

AUTO_CALIBRATION class-attribute instance-attribute

AUTO_CALIBRATION = 'auto_calibration'

Zero and span (42003).

AUTO_ZERO class-attribute instance-attribute

AUTO_ZERO = 'auto_zero'

Zero only (42004).

CalibrationStatus dataclass

CalibrationStatus(
    running, channels, calibration_error, display, read_at
)

What is calibrating, held or failed, from one read of the status blocks.

busy property

busy

Whether any calibration, automatic or manual, is running.

calibration_error instance-attribute

calibration_error

The calibration-error contact: an error 4-9 is active on some channel.

channels instance-attribute

channels

Channels 1-5: calibration flags, hold, errors 4-9, current range.

errors property

errors

Errors 4-9 active per channel; channels without any are left out.

held property

held

Channels whose outputs are held.

read_at instance-attribute

read_at

Host UTC time the status was read.

running instance-attribute

running

Input 30049: an auto calibration or auto zero calibration is running.

CalibrationTarget dataclass

CalibrationTarget(
    channel,
    ranges,
    span,
    widened,
    established,
    zero_gas,
    span_gas,
    units,
)

One channel an automatic calibration will calibrate.

established instance-attribute

established

Whether the session knows the channel is present.

ranges instance-attribute

ranges

The ranges calibrated, 1 and/or 2.

span instance-attribute

span

Whether it is spanned as well as zeroed (auto calibration).

span_gas instance-attribute

span_gas

The span gas of each range in :attr:ranges, in its range's unit.

units instance-attribute

units

The unit of each range in :attr:ranges.

widened instance-attribute

widened

Whether "both" adds the range beyond its auto-calibration range.

zero_gas instance-attribute

zero_gas

The zero gas of each range in :attr:ranges, in its range's unit.

CalibrationWait dataclass

CalibrationWait(
    final, saw_running, polls, elapsed_s, new_errors
)

How :meth:~fujilib.devices.analyzer.Analyzer.wait_for_calibration ended.

failed property

failed

Whether any calibration error is active at the end.

An error left by an earlier calibration counts too: the analyzer shows no difference. :attr:new_errors says which appeared since the baseline.

final instance-attribute

final

The status that showed nothing calibrating.

new_errors instance-attribute

new_errors

Errors 4-9 active at the end that were not in the baseline.

saw_running instance-attribute

saw_running

Whether any read showed a calibration running; if not, it may have ended already.

CommandOutcome

Bases: StrEnum

What the status read after a command showed.

AMBIGUOUS class-attribute instance-attribute

AMBIGUOUS = 'ambiguous'

Acknowledged, but nothing runs: it never started, or it already ended.

DONE class-attribute instance-attribute

DONE = 'done'

The front panel shows the measurement screen, and no calibration flag is set.

SENT class-attribute instance-attribute

SENT = 'sent'

Acknowledged; nothing shows what it did.

STARTED class-attribute instance-attribute

STARTED = 'started'

The calibration is running.

CommandResult dataclass

CommandResult(
    operation,
    outcome,
    acknowledged,
    status,
    plan,
    timing,
    status_error=None,
    before=None,
)

An operation command sent once, and what the status after it showed.

acknowledged instance-attribute

acknowledged

Whether the analyzer's reply to the command arrived.

before class-attribute instance-attribute

before = None

The status read just before the command.

plan instance-attribute

plan

For a calibration: what it was to do, read just before it was sent.

status instance-attribute

status

The status read after it; None when that read failed.

status_error class-attribute instance-attribute

status_error = None

Why the status read after the command failed, when it did.

timing instance-attribute

timing

The command's timing, when its reply arrived.

calibration_status

calibration_status(status)

The calibration view of a status read.

Source code in src/fujilib/devices/operations.py
def calibration_status(status: StatusRead) -> CalibrationStatus:
    """The calibration view of a status read."""
    return CalibrationStatus(
        running=status.analyzer.auto_calibration_running,
        channels=status.channels,
        calibration_error=status.analyzer.calibration_error,
        display=status.analyzer.display,
        read_at=status.timings[-1].received_at,
    )

check_healthy

check_healthy(status, operation)

Refuse a calibration while the analyzer reports an analyzer-level error.

A calibration now would compute its coefficients from a faulty measurement.

Raises:

Type Description
FujiAnalyzerStateError

an instrument error or error 1, 2, 3 or 10 is active.

Source code in src/fujilib/devices/operations.py
def check_healthy(status: StatusRead, operation: str) -> None:
    """Refuse a calibration while the analyzer reports an analyzer-level error.

    A calibration now would compute its coefficients from a faulty measurement.

    Raises:
        FujiAnalyzerStateError: an instrument error or error 1, 2, 3 or 10 is active.
    """
    analyzer = status.analyzer
    if analyzer.instrument_error or analyzer.errors:
        codes = ", ".join(str(int(e)) for e in sorted(analyzer.errors)) or "unnumbered"
        msg = (
            f"{operation} refused, nothing was sent: the analyzer reports an instrument "
            f"error ({codes}), so a calibration now would be computed from a faulty reading"
        )
        raise FujiAnalyzerStateError(msg, context=ErrorContext(command_name=operation))

plan_calibration

plan_calibration(settings, run, *, ranges, established)

What run will do, from the :data:PLAN_SETTINGS values and the range tables.

Source code in src/fujilib/devices/operations.py
def plan_calibration(
    settings: Mapping[str, RegisterValue],
    run: CalibrationRun,
    *,
    ranges: Sequence[RangeInfo],
    established: Iterable[ChannelId],
) -> CalibrationPlan:
    """What ``run`` will do, from the :data:`PLAN_SETTINGS` values and the range tables."""
    known = set(established)
    counts = {info.channel: info.count for info in ranges}
    targets: list[CalibrationTarget] = []
    notes: list[str] = []
    span = run is CalibrationRun.AUTO_CALIBRATION

    def raw(name: str) -> int:
        value = settings[name].raw
        return int(value)

    for channel in MEASURED_CHANNELS:
        c = channel.number
        if not raw(f"auto_calibration.ch{c}.included"):
            continue
        own = raw(f"auto_calibration.ch{c}.range") + 1
        if own not in {1, 2}:
            msg = (
                f"auto_calibration.ch{c}.range reads {own - 1}, which is not a range; "
                "the plan cannot say what the calibration would touch"
            )
            raise FujiDecodeError(msg, context=ErrorContext(channel=channel.value))
        both = raw(f"calibration.ch{c}.range_mode") == CalibrationRangeMode.BOTH
        existing = counts.get(channel, 2)
        numbers = (1, 2)[:existing] if both else (own,)
        if own > existing:
            notes.append(
                f"{channel.value} is set to auto-calibrate range {own}, "
                f"but it has {existing} range(s)"
            )
        gases = [
            (
                settings[f"calibration_gas.ch{c}.range{r}.zero"],
                settings[f"calibration_gas.ch{c}.range{r}.span"],
            )
            for r in numbers
        ]
        targets.append(
            CalibrationTarget(
                channel=channel,
                ranges=numbers,
                span=span,
                widened=len(numbers) > 1,
                established=channel in known,
                zero_gas=tuple(_scaled(z) for z, _ in gases),
                span_gas=tuple(_scaled(s) for _, s in gases),
                units=tuple(z.unit or "?" for z, _ in gases),
            )
        )
    unknown = [t.channel.value for t in targets if not t.established]
    if unknown:
        notes.append(
            f"{', '.join(unknown)} enabled but not established: what the analyzer does with "
            "an enabled channel that is not fitted is not documented"
        )
    hold = bool(settings["output_hold.enabled"].raw)
    phases: list[tuple[str, int]]
    if span:
        phases = [("zero", raw("auto_calibration.flow_time1"))]
        phases += [
            (f"{t.channel.value} span", raw(f"auto_calibration.flow_time{t.channel.number + 1}"))
            for t in targets
        ]
        if hold:
            phases.append(("hold extension", raw("auto_calibration.flow_time7")))
    else:
        flow = raw("auto_zero.flow_time")
        phases = [("zero", flow)]
        if hold:
            phases.append(("gas replacement", flow))
    notes.append(_FLOW_NOTE)
    return CalibrationPlan(
        run=run,
        targets=tuple(targets),
        hold=hold,
        phases=tuple(phases),
        estimated_duration_s=sum(t for _, t in phases),
        notes=tuple(notes),
    )

read_calibration_plan async

read_calibration_plan(
    client, run, *, ranges, established, deadline=None
)

Read the :data:PLAN_SETTINGS and make the plan of run (two transactions).

Source code in src/fujilib/devices/operations.py
async def read_calibration_plan(
    client: ProtocolClient,
    run: CalibrationRun,
    *,
    ranges: Sequence[RangeInfo],
    established: Iterable[ChannelId],
    deadline: Deadline | None = None,
) -> CalibrationPlan:
    """Read the :data:`PLAN_SETTINGS` and make the plan of ``run`` (two transactions)."""
    settings = await read_registers(client, PLAN_SETTINGS, ranges=ranges, deadline=deadline)
    return plan_calibration(settings, run, ranges=ranges, established=established)

send_command async

send_command(
    client,
    operation,
    *,
    plan,
    deadline,
    verify_timeout,
    before=None,
)

Send operation once with FC06 and read the status after it (see the module docstring).

Raises:

Type Description
FujiModbusError

the analyzer refused the command with an exception reply.

FujiWriteOutcomeUnknownError

its reply was lost and the status cannot say whether it acted, or the port failed.

FujiVerificationError

return to measurement was acknowledged, but the panel does not show the measurement screen; or the panel shows it with a calibration flag set.

FujiError

the command could not be sent; nothing was.

Source code in src/fujilib/devices/operations.py
async def send_command(
    client: ProtocolClient,
    operation: OperationSpec,
    *,
    plan: CalibrationPlan | None,
    deadline: Deadline,
    verify_timeout: float,
    before: CalibrationStatus | None = None,
) -> CommandResult:
    """Send ``operation`` once with FC06 and read the status after it (see the module docstring).

    Raises:
        FujiModbusError: the analyzer refused the command with an exception reply.
        FujiWriteOutcomeUnknownError: its reply was lost and the status cannot
            say whether it acted, or the port failed.
        FujiVerificationError: return to measurement was acknowledged, but the
            panel does not show the measurement screen; or the panel shows it
            with a calibration flag set.
        FujiError: the command could not be sent; nothing was.
    """
    name = operation.name
    acknowledged = False
    timing: TransferTiming | None = None
    try:
        timing = await client.write_register(
            operation.address, operation.value, deadline=deadline, command=name
        )
        acknowledged = True
    except FujiWriteOutcomeUnknownError as exc:
        if exc.context.extra.get("failure") == FailureKind.CONNECTION.value:
            raise
    after = await _read_status_after(client, verify_timeout, name)
    status = calibration_status(after) if isinstance(after, StatusRead) else None
    outcome = _outcome(name, status, acknowledged=acknowledged, read_error=after)
    error = after if isinstance(after, FujiError) else None
    return CommandResult(name, outcome, acknowledged, status, plan, timing, error, before)

The front panel

fujilib.devices.panel

Manual calibration at the front panel: plans, watching and events (design §6.5).

A manual zero or span exists only at the front panel: ZERO or SPAN, the cursor to the channel, ENT to select it (the wait step, while the gas settles), and ENT again to calibrate. This module watches one from the status registers; it sends no key. :mod:fujilib.devices.keys drives one from the host, and records it with the same tracker.

What the registers show (protocol findings §14, observed on the bench analyzer):

  • the step (30182): 4 or 7 while a channel is selected, 5 or 8 while the gas settles, 6 or 9 while it runs, 10 on the error display, then 0. The screen (30181) stays on measurement throughout. On any other screen 30182 numbers the menu's pages instead, 4-10 among them (protocol findings §15), so it is read as a step only on the measurement screen;
  • the per-channel zero and span flags (30050-30059), from the wait step to the end;
  • 30186 (display.calibration_result, undocumented): 0 once a channel is selected, 4 while it runs, 6 when it has finished, kept until the next;
  • 30190 (display.key, undocumented): the key being pressed.

What a zero or span touches (:func:plan_manual_calibration, ZPA manual p.43-45, p.75-77). A zero of a channel set to "at once" zeroes every channel so set; a span, or a zero of a channel set to "each", calibrates that channel only. A channel set to "both" is calibrated on both its ranges; otherwise on the range it measures on or, when its range method is auto, on its auto-calibration range.

How an event is decided (:class:ManualCalibrationTracker). A pass starts when the step leaves 0 and ends when it is 0 again, or when a read shows a screen other than measurement. A poll reads the readings and the zero and span flags before the step, so when the first read back on measurement still shows the pass's flags, the readings after it are taken from the next read.

  • It ran if a read showed it running or on the error display, or if the result register read 0 on the wait step and 6 at the end: a calibration takes a second or two, which can fall between two reads.
  • It failed if the error display was shown, or an error 4-8 appeared on its channels.
  • It was cancelled if it never reached the wait step, or if it did and the result register still reads 0 at the end.
  • Anything else is ambiguous: the reads do not say whether it ran.

The result register is undocumented, so it decides nothing that the step contradicts (design §13.1 #73). Every event carries the evidence for its outcome.

Records. :func:calibration_record writes an event as a fujilib-calibration/1 document, the same for a calibration watched at the panel and one driven from the host (design §13.1 #75, #90).

ManualCalibrationEvent dataclass

ManualCalibrationEvent(
    kind,
    outcome,
    channels,
    ranges,
    started_at,
    selected_at,
    ran_after,
    ran_before,
    ended_at,
    before,
    after,
    adc_before,
    new_errors,
    evidence,
    observations,
    gases=(lambda: MappingProxyType({}))(),
)

A manual zero or span made at the front panel, and how it ended (design §8).

adc_before instance-attribute

adc_before

The raw A/D values read with before: the detectors' counts when it ran.

after instance-attribute

after

The channels' readings at ended_at.

before instance-attribute

before

The channels' readings at ran_after: what the calibration saw.

calibrated_at property

calibrated_at

When it ran, at the latest; None when it did not run or may not have.

channels instance-attribute

channels

The channels calibrated: those whose zero or span flag was set, else the cursor's.

deviations property

deviations

Each channel's reading before it ran, less its calibration gas.

Empty unless it ran; only channels whose reading and gas are both known; in the reading's own unit, which a calibration gas shares (design §13.1 #62).

ended_at instance-attribute

ended_at

The first read that showed no step: back on the measurement screen, or on a menu.

evidence instance-attribute

evidence

Why the outcome is what it is, and anything odd the reads showed.

gases class-attribute instance-attribute

gases = field(default_factory=lambda: MappingProxyType({}))

The calibration gas of each channel's range, in its unit, when it was read.

new_errors instance-attribute

new_errors

Errors 4-9 on its channels at the end that were not active before it.

observations instance-attribute

observations

How many reads the pass spanned.

ran property

ran

Whether it ran: completed or failed.

ran_after instance-attribute

ran_after

It ran after this read (the last on the wait step), when it ran.

ran_before instance-attribute

ran_before

It ran before this read (the first that showed it running or done), when it ran.

ranges instance-attribute

ranges

Each channel's current range when it was calibrated.

selected_at instance-attribute

selected_at

The first read on the wait step: the channel was selected and the gas supplied.

started_at instance-attribute

started_at

The first read that showed the zero or span screen.

ManualCalibrationKind

Bases: StrEnum

A manual zero or a manual span.

ManualCalibrationOutcome

Bases: StrEnum

How a manual calibration ended, as far as the reads can tell.

AMBIGUOUS class-attribute instance-attribute

AMBIGUOUS = 'ambiguous'

The reads do not say whether it ran.

CANCELLED class-attribute instance-attribute

CANCELLED = 'cancelled'

It was left before it ran.

COMPLETED class-attribute instance-attribute

COMPLETED = 'completed'

It ran, and no calibration error followed.

FAILED class-attribute instance-attribute

FAILED = 'failed'

It ran into the error display, or an error 4-8 appeared on its channels.

ManualCalibrationPlan dataclass

ManualCalibrationPlan(kind, channel, targets, notes)

What a manual zero or span of one channel at the panel would calibrate.

channel instance-attribute

channel

The channel the cursor selects.

channels property

channels

The channels calibrated.

targets instance-attribute

targets

Every channel it calibrates, with its ranges and calibration gases.

ManualCalibrationTracker

ManualCalibrationTracker()

Turns successive panel observations into manual calibration events.

Feed it every observation in order, from polls, status reads or recorded frames; the closer together they are, the less is left ambiguous. It does no I/O.

Start with no pass under way.

Source code in src/fujilib/devices/panel.py
def __init__(self) -> None:
    """Start with no pass under way."""
    self._pass: _Pass | None = None
    self._last: PanelObservation | None = None

active property

active

Whether a pass through the calibration steps is under way.

step property

step

The step the last observation showed (NONE on a menu); None before the first.

feed

feed(observation)

Take in observation; return the event of a pass it ends, if it ends one.

Source code in src/fujilib/devices/panel.py
def feed(self, observation: PanelObservation) -> ManualCalibrationEvent | None:
    """Take in ``observation``; return the event of a pass it ends, if it ends one."""
    step = observation.step
    event: ManualCalibrationEvent | None = None
    current = self._pass
    if current is not None:
        kind = _kind_of(step)
        if current.ending is not None:
            event = self._finish(current, observation)
            self._pass = self._start(observation) if _in_pass(step) else None
        elif step == _STEP.NONE:
            if _flagged(observation, current.kind) & current.flagged:
                # A poll reads the readings and flags before the step, so this
                # read's readings may predate the end: take them from the next.
                current.ending = observation
                current.observations += 1
            else:
                event = self._finish(current, observation)
                self._pass = None
        elif kind is not None and current.kind is not None and kind is not current.kind:
            event = self._finish(current, observation)
            note = "the next pass began before a read showed the measurement screen"
            event = replace(event, evidence=(*event.evidence, note))
            self._pass = self._start(observation)
        else:
            self._update(current, observation)
    elif _in_pass(step):
        self._pass = self._start(observation)
    self._last = observation
    return event

PanelObservation dataclass

PanelObservation(
    at,
    display,
    channels,
    calibration_error,
    readings=(lambda: MappingProxyType({}))(),
    adc=None,
)

One read of the panel and calibration status, with the host time it was read.

adc class-attribute instance-attribute

adc = None

The raw A/D values read with it, if any.

at instance-attribute

at

Host UTC time of the status read.

channels instance-attribute

channels

Status of the measured channels read.

readings class-attribute instance-attribute

readings = field(
    default_factory=lambda: MappingProxyType({})
)

The readings of the same poll, when it was a poll.

step property

step

The manual-calibration step shown; NONE on any screen but measurement.

A manual calibration keeps the measurement screen. In the menus 30182 numbers the pages instead, 4-10 among them (protocol findings §15), so it is no step there.

errors

errors()

Errors 4-9 active per channel read.

Source code in src/fujilib/devices/panel.py
def errors(self) -> dict[ChannelId, frozenset[ErrorCode]]:
    """Errors 4-9 active per channel read."""
    return {c: s.errors for c, s in self.channels.items()}

from_frame classmethod

from_frame(frame, *, adc=None)

The observation in a poll's frame; None for a poll without the status block.

Source code in src/fujilib/devices/panel.py
@classmethod
def from_frame(cls, frame: Frame, *, adc: AdcValues | None = None) -> PanelObservation | None:
    """The observation in a poll's frame; ``None`` for a poll without the status block."""
    analyzer = frame.analyzer
    if analyzer is None or analyzer.display is None:
        return None
    timing = frame.status_timing if frame.status_timing is not None else frame.readings_timing
    return cls(
        at=timing.received_at,
        display=analyzer.display,
        channels=MappingProxyType(
            {r.channel: r.status for r in frame.readings if r.status is not None}
        ),
        calibration_error=analyzer.calibration_error,
        readings=MappingProxyType({r.channel: r for r in frame.readings}),
        adc=adc,
    )

from_status classmethod

from_status(status)

The observation in a calibration status; None when it has no display state.

Source code in src/fujilib/devices/panel.py
@classmethod
def from_status(cls, status: CalibrationStatus) -> PanelObservation | None:
    """The observation in a calibration status; ``None`` when it has no display state."""
    if status.display is None:
        return None
    return cls(
        at=status.read_at,
        display=status.display,
        channels=status.channels,
        calibration_error=status.calibration_error,
    )

calibration_record

calibration_record(
    event,
    *,
    info=None,
    port=None,
    address=None,
    source="panel",
)

event as a fujilib-calibration/1 document of JSON values.

source says who pressed the keys: "panel" for a calibration made at the front panel and watched, "remote" for one driven from the host. calibrated_at is when it ran, at the latest, or None when it did not run; it is what a zero or span time is taken from. channel_gases gives each channel's gas label, as asserted.

Source code in src/fujilib/devices/panel.py
def calibration_record(
    event: ManualCalibrationEvent,
    *,
    info: DeviceInfo | None = None,
    port: str | None = None,
    address: int | None = None,
    source: str = "panel",
) -> dict[str, object]:
    """``event`` as a ``fujilib-calibration/1`` document of JSON values.

    ``source`` says who pressed the keys: ``"panel"`` for a calibration made at
    the front panel and watched, ``"remote"`` for one driven from the host.
    ``calibrated_at`` is when it ran, at the latest, or ``None`` when it did
    not run; it is what a zero or span time is taken from. ``channel_gases``
    gives each channel's gas label, as asserted.
    """

    def when(moment: datetime | None) -> str | None:
        return moment.isoformat() if moment is not None else None

    def reading(r: Reading) -> dict[str, object]:
        return {"value": r.value, "unit": r.unit.value, "state": r.state.value}

    adc = event.adc_before
    record = calibration_record_header(info=info, port=port, address=address, source=source)
    record |= {
        "kind": event.kind.value,
        "outcome": event.outcome.value,
        "channels": [c.value for c in event.channels],
        "channel_gases": {c.value: r.gas.value for c, r in {**event.after, **event.before}.items()},
        "ranges": {c.value: r for c, r in event.ranges.items()},
        "calibrated_at": when(event.calibrated_at),
        "started_at": when(event.started_at),
        "selected_at": when(event.selected_at),
        "ran_after": when(event.ran_after),
        "ran_before": when(event.ran_before),
        "ended_at": when(event.ended_at),
        "gas_settings": {c.value: g for c, g in event.gases.items()},
        "before": {c.value: reading(r) for c, r in event.before.items()},
        "after": {c.value: reading(r) for c, r in event.after.items()},
        "deviations": {c.value: d for c, d in event.deviations.items()},
        "new_errors": {
            c.value: sorted(int(e) for e in codes) for c, codes in event.new_errors.items()
        },
        "detector_counts": list(adc.inputs) if adc is not None else None,
        "adc": list(adc.raw) if adc is not None else None,
        "evidence": list(event.evidence),
        "observations": event.observations,
    }
    return record

calibration_record_header

calibration_record_header(
    *, info=None, port=None, address=None, source="panel"
)

The part of a calibration record that names the document and the analyzer.

Source code in src/fujilib/devices/panel.py
def calibration_record_header(
    *,
    info: DeviceInfo | None = None,
    port: str | None = None,
    address: int | None = None,
    source: str = "panel",
) -> dict[str, object]:
    """The part of a calibration record that names the document and the analyzer."""
    return {
        "format": CALIBRATION_FORMAT,
        "fujilib_version": __version__,
        "written_at": datetime.now(UTC).isoformat(),
        "source": source,
        "analyzer": {
            "model": info.model if info is not None else None,
            "serial_number": info.serial_number if info is not None else None,
            "type_code": info.type_code.raw if info is not None else None,
            "port": port,
            "address": address,
        },
    }

plan_manual_calibration

plan_manual_calibration(
    settings, kind, channel, *, ranges, established
)

What a manual kind of channel would calibrate (see the module docstring).

settings holds the :data:MANUAL_PLAN_SETTINGS.

Raises:

Type Description
FujiValidationError

channel is not a measured channel (1-5).

FujiDecodeError

a range setting reads a value that is not a range.

Source code in src/fujilib/devices/panel.py
def plan_manual_calibration(
    settings: Mapping[str, RegisterValue],
    kind: ManualCalibrationKind,
    channel: ChannelId,
    *,
    ranges: Sequence[RangeInfo],
    established: Iterable[ChannelId],
) -> ManualCalibrationPlan:
    """What a manual ``kind`` of ``channel`` would calibrate (see the module docstring).

    ``settings`` holds the :data:`MANUAL_PLAN_SETTINGS`.

    Raises:
        FujiValidationError: ``channel`` is not a measured channel (1-5).
        FujiDecodeError: a range setting reads a value that is not a range.
    """
    if not channel.is_measured:
        msg = f"{channel.value} is not a measured channel; only channels 1-5 are calibrated"
        raise FujiValidationError(msg, context=ErrorContext(channel=channel.value))
    known = set(established)
    counts = {info.channel: info.count for info in ranges}
    notes: list[str] = []

    def raw(name: str) -> int:
        return int(settings[name].raw)

    def at_once(c: ChannelId) -> bool:
        return raw(f"calibration.ch{c.number}.zero_mode") == ZeroCalibrationMode.AT_ONCE

    group: list[ChannelId] = [channel]
    if kind is ManualCalibrationKind.ZERO and at_once(channel):
        group = [c for c in MEASURED_CHANNELS if at_once(c)]
        if len(group) > 1:
            notes.append(
                f"{channel.value} is set to zero 'at once' with "
                f"{', '.join(c.value for c in group if c is not channel)}: they are zeroed together"
            )
    targets: list[CalibrationTarget] = []
    for c in group:
        n = c.number
        existing = counts.get(c, 2)
        if raw(f"calibration.ch{n}.range_mode") == CalibrationRangeMode.BOTH:
            numbers: tuple[int, ...] = (1, 2)[:existing]
        elif raw(f"range.ch{n}.method") == RangeMethod.AUTO:
            numbers = (_range_number(raw(f"auto_calibration.ch{n}.range"), c, "auto_calibration"),)
            notes.append(
                f"{c.value} switches range automatically, so it is calibrated on its "
                f"auto-calibration range, range {numbers[0]} (ZPA manual p.76)"
            )
        else:
            numbers = (_range_number(raw(f"range.ch{n}.current"), c, "range"),)
        gases = [
            (
                settings[f"calibration_gas.ch{n}.range{r}.zero"],
                settings[f"calibration_gas.ch{n}.range{r}.span"],
            )
            for r in numbers
        ]
        targets.append(
            CalibrationTarget(
                channel=c,
                ranges=numbers,
                span=kind is ManualCalibrationKind.SPAN,
                widened=len(numbers) > 1,
                established=c in known,
                zero_gas=tuple(_scaled(z) for z, _ in gases),
                span_gas=tuple(_scaled(s) for _, s in gases),
                units=tuple(z.unit or "?" for z, _ in gases),
            )
        )
    unknown = [t.channel.value for t in targets if not t.established]
    if unknown:
        notes.append(
            f"{', '.join(unknown)} would be calibrated but is not established: whether it is "
            "fitted is not known"
        )
    return ManualCalibrationPlan(kind, channel, tuple(targets), tuple(notes))

fujilib.devices.keys

Front-panel keys over Modbus, and a manual zero or span driven with them (design §6.5).

A manual zero or span exists only as front-panel keys: ZERO or SPAN, the cursor to the channel, ENT to select it (the wait step, while the gas settles) and ENT again to calibrate. 42001 presses a key as the panel would (protocol findings §18). This module is the only one that writes it.

Which keys, and where. Only UP, DOWN, ESC, ENT, ZERO and SPAN, never MODE or SIDE, which open the menus and enter their passwords; the write envelope checks the value written as well as the address (design §5.4). Each key only on the steps where it belongs (:func:key_refusal):

Step Keys
measurement, no calibration flag set ZERO, SPAN
channel selection UP, DOWN, ESC; ENT with the cursor on the planned channel
wait ESC; ENT, which calibrates, only through :meth:RemoteCalibration.calibrate
running none
error display ESC; never ENT, which forces the calibration on errors 5 and 7
any other screen none

One key is one locked operation: read the panel and refuse unless the step allows the key; write it once, never retried; read until the step, the cursor and the flags show it taken. 30190 cannot confirm a key written over Modbus: it shows only keys pressed at the panel (findings §18.1). A key the panel does not take (key lock, the backlight, a key pressed at the panel meanwhile) stops the run.

The cursor wraps round, and channels zeroed "at once" share a position that reads as its first channel reached going down (findings §18.2). So the cursor is moved with DOWN only, one key at a time, each confirmed by the cursor moving, until it reads the planned channel, or the first of the "at once" channels. ZERO may open it anywhere, so it is read, never predicted.

A remote calibration (:class:RemoteCalibration, from :meth:Analyzer.manual_calibration <fujilib.devices.analyzer.Analyzer.manual_calibration>) is an async context manager. Entering it refuses (nothing sent) unless the panel is on the measurement screen with no calibration flag set, key lock and output hold are off (design §13.1 #85, #86), there is no instrument error, the plan reads the same again and calibrates no channel on both ranges (#88), and the operator's gas for every channel is its calibration-gas setting (#89). It then presses ZERO or SPAN, moves the cursor and selects the channel. Inside, :meth:~RemoteCalibration.wait_steady watches the readings until the steadiness rule holds (design §13.1 #78), and :meth:~RemoteCalibration.calibrate checks it all again and sends the ENT that calibrates, which is DANGEROUS. The other keys are STATEFUL.

Cleanup. However the block is left, the panel is returned to measurement for the step it is on, shielded from cancellation and within its own time:

Step Cleanup
channel selection ESC
wait ESC, which clears the flags; never 42002, which leaves them set (findings §18.4)
running wait for the analyzer to finish
error display ESC
any other screen 42002

Each cleanup key is sent once. A flag still set on the measurement screen afterwards raises :class:~fujilib.errors.FujiAnalyzerStateError naming the channel and the recovery: enter its wait step at the panel and press ESC. fujilib does not try that itself (design §13.1 #84).

The run is recorded as the watcher records one made at the panel: the :class:~fujilib.devices.panel.ManualCalibrationTracker sees every read, and :class:RemoteCalibrationResult adds the plan, the gases named, the steadiness, the readings on the wait step, the keys and the cleanup.

CalibrationGas dataclass

CalibrationGas(value, unit=None, label=None)

The gas the operator says is at the inlet, for one channel (design §13.1 #89).

value is in unit, which must be the channel's unit on the range calibrated; for a zero gas of 0 the unit may be left out. A unit given as text is kept as a :class:~fujilib.registry.units.Unit. label is kept in the record, e.g. "N2, cylinder 1234" or "20.95 % O2 in N2".

__post_init__

__post_init__()

Refuse a value that is not a finite number.

Raises:

Type Description
FujiValidationError

the value is not a finite number, or a gas other than 0 has no unit.

Source code in src/fujilib/devices/keys.py
def __post_init__(self) -> None:
    """Refuse a value that is not a finite number.

    Raises:
        FujiValidationError: the value is not a finite number, or a gas
            other than 0 has no unit.
    """
    value = cast("object", self.value)
    if (
        isinstance(value, bool)
        or not isinstance(value, int | float)
        or not math.isfinite(value)
    ):
        msg = f"a calibration gas must be a finite number, got {value!r}"
        raise FujiValidationError(msg)
    if self.unit is None:
        if value != 0:
            msg = f"a calibration gas of {value:g} needs its unit, e.g. 'vol%' or 'ppm'"
            raise FujiValidationError(msg)
        return
    unit = coerce_unit(self.unit)
    if unit is Unit.UNKNOWN:
        msg = f"{self.unit!r} is not a unit of the ZP series: 'vol%', 'ppm', 'mg/m3' or 'g/m3'"
        raise FujiValidationError(msg)
    object.__setattr__(self, "unit", unit)

CleanupReport dataclass

CleanupReport(clean, actions, flags_left=(), error=None)

How the panel was returned to measurement.

clean instance-attribute

clean

The measurement screen, no step and no calibration flag at the end.

error class-attribute instance-attribute

error = None

Why the panel could not be read or keyed, when it could not.

flags_left class-attribute instance-attribute

flags_left = ()

Channels whose calibration flag was still set at the end.

KeyPress dataclass

KeyPress(
    key,
    step,
    cursor,
    sent_at,
    acknowledged,
    taken,
    after_s,
    reads,
    purpose="",
)

One key written to 42001, and what the reads after it showed.

acknowledged instance-attribute

acknowledged

Whether the analyzer's reply came.

after_s instance-attribute

after_s

Seconds from the write to the read that showed it taken.

cursor instance-attribute

cursor

Where the cursor was when it was sent.

name property

name

The key's name.

purpose class-attribute instance-attribute

purpose = ''

Why it was sent: the run, or the cleanup.

reads instance-attribute

reads

Reads made after it.

step instance-attribute

step

The step it was sent on.

taken instance-attribute

taken

Whether the reads showed the panel act on it.

RemoteCalibration

RemoteCalibration(
    session,
    plan,
    *,
    gas,
    confirm,
    rule=None,
    adc=False,
    interval=0.5,
    key_timeout=2.0,
    run_timeout=30.0,
    cleanup_timeout=30.0,
)

A manual zero or span driven from the host (see the module docstring).

Made by :meth:Analyzer.manual_calibration <fujilib.devices.analyzer.Analyzer.manual_calibration>; use it as an async context manager, once.

Prepare the run; nothing is sent until the block is entered.

Raises:

Type Description
FujiValidationError

a gas is missing or malformed, or a time is not a positive number of seconds.

Source code in src/fujilib/devices/keys.py
def __init__(
    self,
    session: Session,
    plan: ManualCalibrationPlan,
    *,
    gas: CalibrationGas | Mapping[ChannelId | str, CalibrationGas],
    confirm: bool,
    rule: SteadinessRule | None = None,
    adc: bool = False,
    interval: float = 0.5,
    key_timeout: float = 2.0,
    run_timeout: float = 30.0,
    cleanup_timeout: float = 30.0,
) -> None:
    """Prepare the run; nothing is sent until the block is entered.

    Raises:
        FujiValidationError: a gas is missing or malformed, or a time is
            not a positive number of seconds.
    """
    for name, value in (
        ("interval", interval),
        ("key_timeout", key_timeout),
        ("run_timeout", run_timeout),
        ("cleanup_timeout", cleanup_timeout),
    ):
        _check_seconds(name, value)
    self._session = session
    self._plan = plan
    self._established = tuple(t.channel for t in plan.targets if t.established)
    if not self._established:
        channels = ", ".join(c.value for c in plan.channels)
        msg = (
            f"none of the channels the plan calibrates ({channels}) is established, so "
            "none can be watched"
        )
        raise FujiValidationError(msg)
    self._gases = _gases(gas, self._established)
    self._confirmed = confirm
    self._rule = rule if rule is not None else SteadinessRule()
    self._adc = adc
    self._timing = _Timing(interval, key_timeout, run_timeout, cleanup_timeout, flag_wait=1.0)
    self._tracker = ManualCalibrationTracker()
    self._state = RunState.NEW
    self._keys: list[KeyPress] = []
    self._samples: list[WaitSample] = []
    self._event: ManualCalibrationEvent | None = None
    self._verdict: SteadinessVerdict | None = None
    self._judge: SteadinessJudge | None = None
    self._last: PanelObservation | None = None
    self._status: StatusRead | None = None
    self._calibrated = False
    self._entered = False
    self._started_at = datetime.now(UTC)
    self._result: RemoteCalibrationResult | None = None
    self._error: str | None = None

event property

event

The pass, once it has ended.

gases property

gases

The gas named for each established channel the plan calibrates.

keys property

keys

The keys written so far.

observation property

observation

The last read of the panel.

plan property

plan

What the run calibrates.

result property

result

The whole run, once the block has been left.

rule property

rule

The steadiness rule applied.

state property

state

Where the run is.

steadiness property

steadiness

The last verdict on the wait step.

__aenter__ async

__aenter__()

Check everything, then press ZERO or SPAN, move the cursor and select the channel.

Raises:

Type Description
FujiConfirmationRequiredError

confirm was not True; nothing was sent.

FujiAnalyzerStateError

the panel, a setting or the plan forbids it, or a key was not taken; the panel was then returned to measurement.

FujiCapabilityError

A/D values were asked for and the analyzer has none.

FujiError

a read or a key failed; the panel was then returned to measurement.

Source code in src/fujilib/devices/keys.py
async def __aenter__(self) -> Self:
    """Check everything, then press ZERO or SPAN, move the cursor and select the channel.

    Raises:
        FujiConfirmationRequiredError: ``confirm`` was not ``True``; nothing was sent.
        FujiAnalyzerStateError: the panel, a setting or the plan forbids it,
            or a key was not taken; the panel was then returned to
            measurement.
        FujiCapabilityError: A/D values were asked for and the analyzer has none.
        FujiError: a read or a key failed; the panel was then returned to measurement.
    """
    if self._entered:
        msg = "a remote calibration is used once; make another with manual_calibration()"
        raise FujiValidationError(msg)
    self._entered = True
    self._started_at = datetime.now(UTC)
    requires = Capability.ADC_VALUES if self._adc else Capability.NONE
    session = self._session
    session.gate(
        _OPERATION,
        tier=SafetyTier.STATEFUL,
        confirm=self._confirmed,
        requires=requires,
        subject="a remote manual calibration",
    )
    session.claim_panel(_OPERATION)
    try:
        checked = await self._preflight()
        self._judge = self._make_judge(checked.settings)
        await self._open()
        await self._navigate()
        await self._select()
    except BaseException as exc:
        self._error = str(exc) or type(exc).__name__
        with anyio.CancelScope(shield=True):
            await self._close(exc)
        raise
    self._state = RunState.WAITING
    return self

__aexit__ async

__aexit__(exc_type, exc, tb)

Return the panel to measurement for the step it is on (see the module docstring).

Raises:

Type Description
FujiAnalyzerStateError

a calibration flag is still set on the measurement screen, or the panel could not be returned to it.

Source code in src/fujilib/devices/keys.py
async def __aexit__(
    self,
    exc_type: type[BaseException] | None,
    exc: BaseException | None,
    tb: TracebackType | None,
) -> None:
    """Return the panel to measurement for the step it is on (see the module docstring).

    Raises:
        FujiAnalyzerStateError: a calibration flag is still set on the
            measurement screen, or the panel could not be returned to it.
    """
    if exc is not None and self._error is None:
        self._error = str(exc) or type(exc).__name__
    with anyio.CancelScope(shield=True):
        await self._close(exc)

calibrate async

calibrate(*, confirm=False)

Send the ENT that calibrates, and follow the calibration to its end. DANGEROUS.

Everything is checked again first, in the same operation as the key: the panel on the wait step with the plan's flags, the gas still steady on every channel with this read, the plan, key lock, output hold and the instrument errors. The calibration is then followed until the panel is back on measurement; on the error display it sends ESC, never ENT.

Raises:

Type Description
FujiConfirmationRequiredError

confirm is not True; nothing was sent.

FujiAnalyzerStateError

not on the wait step, not steady, or a check failed; nothing was sent. Or the key was not taken.

FujiError

a read failed.

Source code in src/fujilib/devices/keys.py
async def calibrate(self, *, confirm: bool = False) -> ManualCalibrationEvent:
    """Send the ENT that calibrates, and follow the calibration to its end. DANGEROUS.

    Everything is checked again first, in the same operation as the key:
    the panel on the wait step with the plan's flags, the gas still steady
    on every channel with this read, the plan, key lock, output hold and
    the instrument errors. The calibration is then followed until the
    panel is back on measurement; on the error display it sends ESC,
    never ENT.

    Raises:
        FujiConfirmationRequiredError: ``confirm`` is not ``True``; nothing was sent.
        FujiAnalyzerStateError: not on the wait step, not steady, or a
            check failed; nothing was sent. Or the key was not taken.
        FujiError: a read failed.
    """
    operation = f"{_OPERATION} calibrate"
    session = self._session
    session.gate(
        operation,
        tier=SafetyTier.DANGEROUS,
        confirm=confirm,
        subject="the key that starts a manual calibration",
    )
    self._require_waiting("calibrate")
    verdict = self._verdict
    if verdict is None or not verdict.steady:
        why = (
            "; ".join(verdict.reasons)
            if verdict is not None
            else "no read on the wait step yet"
        )
        msg = (
            f"calibrate refused, nothing was sent: the gas is not steady ({why}); "
            "wait_steady() first"
        )
        raise FujiAnalyzerStateError(msg, context=ErrorContext(command_name=operation))

    async def check(
        client: ProtocolClient, deadline: Deadline, before: PanelObservation
    ) -> PanelObservation:
        # The settings first, then the panel again, so that the panel is the last
        # thing read before the key.
        self._check_waiting(before)
        settings, ranges = await self._read_settings(client, deadline)
        reasons = self._settings_refusals(settings, ranges)
        latest, status = await self._observe(client, deadline, adc=self._adc)
        self._check_waiting(latest)
        again = self._judge_read(latest)
        if not again.steady:
            reasons.append("the gas is not steady with this read: " + "; ".join(again.reasons))
        reasons += _panel_refusals(latest, status, waiting=True)
        reasons += self._unit_refusals(latest)
        analyzer = status.analyzer
        if analyzer.instrument_error or analyzer.errors:
            codes = ", ".join(str(int(e)) for e in sorted(analyzer.errors)) or "unnumbered"
            reasons.append(f"the analyzer reports an instrument error ({codes})")
        if reasons:
            msg = f"calibrate refused, nothing was sent: {'; '.join(reasons)}"
            raise FujiAnalyzerStateError(
                msg,
                context=ErrorContext(command_name=operation, extra={"reasons": tuple(reasons)}),
            )
        return latest

    def took(_before: PanelObservation, after: PanelObservation) -> bool | str:
        # It ran, failed, or already finished: the tracker says which.
        step = after.step
        if step in _RUNNING_STEPS or step in {_STEP.ERROR_DISPLAY, _STEP.NONE}:
            return True
        if step == _WAIT[self._plan.kind]:
            return False
        return f"it went to {_step_name(step)}"

    await self._key(
        KeyCode.ENT,
        done=took,
        purpose=_CALIBRATE,
        calibrate=True,
        before_key=check,
        budget=self._timing.run_timeout,
        tier=SafetyTier.DANGEROUS,
        confirm=confirm,
        step=_WAIT[self._plan.kind],
    )
    with anyio.CancelScope(shield=True):
        await self._follow()
    event = self._event
    if event is None:
        self._state = RunState.ENDED
        msg = (
            f"the calibration did not end within {self._timing.run_timeout:g} s; the cleanup "
            "will wait for it"
        )
        raise FujiTimeoutError(msg, context=ErrorContext(command_name=operation))
    return event

cancel async

cancel()

Leave the wait step with ESC, which clears the flags; the pass's event.

Raises:

Type Description
FujiAnalyzerStateError

the run is not on the wait step, or ESC was not taken.

Source code in src/fujilib/devices/keys.py
async def cancel(self) -> ManualCalibrationEvent | None:
    """Leave the wait step with ESC, which clears the flags; the pass's event.

    Raises:
        FujiAnalyzerStateError: the run is not on the wait step, or ESC was not taken.
    """
    self._require_waiting("cancel")
    await self._escape("cancel")
    self._state = RunState.ENDED
    return self._event

read async

read(*, timeout=None)

Read the panel once on the wait step, and judge the gas.

Raises:

Type Description
FujiAnalyzerStateError

the run is not on the wait step, or the panel has left it.

FujiError

the read failed.

Source code in src/fujilib/devices/keys.py
async def read(self, *, timeout: float | None = None) -> SteadinessVerdict:
    """Read the panel once on the wait step, and judge the gas.

    Raises:
        FujiAnalyzerStateError: the run is not on the wait step, or the
            panel has left it.
        FujiError: the read failed.
    """
    self._require_waiting("read")
    operation = f"{_OPERATION} (wait)"

    async def body(client: ProtocolClient, deadline: Deadline) -> SteadinessVerdict:
        observation, _ = await self._observe(client, deadline, adc=self._adc)
        self._check_waiting(observation)
        return self._judge_read(observation)

    return await self._session.run(operation, body, timeout=timeout)

wait_steady async

wait_steady(*, timeout=None, progress=None)

Read every interval until the gas is steady on every channel; the verdict.

The port is free between reads. timeout defaults to the rule's timeout_s. progress is called with each verdict, on the event loop.

Raises:

Type Description
FujiTimeoutError

not steady within timeout; its context gives why.

FujiAnalyzerStateError

the run is not on the wait step, or the panel left it.

FujiValidationError

timeout is not a positive number of seconds.

FujiError

a read failed.

Source code in src/fujilib/devices/keys.py
async def wait_steady(
    self,
    *,
    timeout: float | None = None,
    progress: Callable[[SteadinessVerdict], object] | None = None,
) -> SteadinessVerdict:
    """Read every ``interval`` until the gas is steady on every channel; the verdict.

    The port is free between reads. ``timeout`` defaults to the rule's
    ``timeout_s``. ``progress`` is called with each verdict, on the event
    loop.

    Raises:
        FujiTimeoutError: not steady within ``timeout``; its context gives why.
        FujiAnalyzerStateError: the run is not on the wait step, or the panel left it.
        FujiValidationError: ``timeout`` is not a positive number of seconds.
        FujiError: a read failed.
    """
    self._require_waiting("wait_steady")
    limit = _check_seconds("timeout", self._rule.timeout_s if timeout is None else timeout)
    deadline = Deadline.after(limit, operation=f"{_OPERATION} wait_steady")
    while True:
        started = anyio.current_time()
        verdict = await self.read()
        if progress is not None:
            progress(verdict)
        if verdict.steady:
            return verdict
        if deadline.remaining() <= 0:
            msg = f"the gas was not steady within {limit:g} s: " + "; ".join(verdict.reasons)
            raise FujiTimeoutError(
                msg,
                context=ErrorContext(
                    command_name=f"{_OPERATION} wait_steady",
                    elapsed_s=deadline.elapsed(),
                    extra={"reasons": verdict.reasons},
                ),
            )
        pause = self._timing.interval - (anyio.current_time() - started)
        await anyio.sleep(max(0.0, min(pause, deadline.remaining())))

RemoteCalibrationResult dataclass

RemoteCalibrationResult(
    plan,
    gases,
    rule,
    event,
    steadiness,
    calibrating_key_sent,
    keys,
    cleanup,
    samples,
    started_at,
    ended_at,
    error=None,
)

A manual zero or span driven from the host, and how it ended.

calibrating_key_sent instance-attribute

calibrating_key_sent

Whether the ENT that calibrates was sent.

error class-attribute instance-attribute

error = None

What stopped the run, when something did.

event instance-attribute

event

The pass as the tracker saw it; None when no key opened one.

outcome property

outcome

The event's outcome; None when no pass was opened.

steadiness instance-attribute

steadiness

The last verdict on the wait step.

as_record

as_record(
    *,
    info=None,
    port=None,
    address=None,
    operator=None,
    notes=None,
)

The run as a fujilib-calibration/1 document (design §13.1 #75, #90).

Source code in src/fujilib/devices/keys.py
def as_record(
    self,
    *,
    info: DeviceInfo | None = None,
    port: str | None = None,
    address: int | None = None,
    operator: str | None = None,
    notes: str | None = None,
) -> dict[str, object]:
    """The run as a ``fujilib-calibration/1`` document (design §13.1 #75, #90)."""
    if self.event is not None:
        record = panel.calibration_record(
            self.event, info=info, port=port, address=address, source="remote"
        )
    else:
        record = panel.calibration_record_header(
            info=info, port=port, address=address, source="remote"
        )
        record |= {"kind": self.plan.kind.value, "outcome": None}
    record |= {
        "plan": {
            "channel": self.plan.channel.value,
            "targets": [
                {
                    "channel": t.channel.value,
                    "ranges": list(t.ranges),
                    "established": t.established,
                    "zero_gas": list(t.zero_gas),
                    "span_gas": list(t.span_gas),
                    "units": list(t.units),
                }
                for t in self.plan.targets
            ],
            "notes": list(self.plan.notes),
        },
        "named_gas": {
            c.value: {
                "value": g.value,
                "unit": str(g.unit) if g.unit is not None else None,
                "label": g.label,
            }
            for c, g in self.gases.items()
        },
        "steadiness": _verdict_record(self.rule, self.steadiness),
        "calibrating_key_sent": self.calibrating_key_sent,
        "wait_series": [
            {
                "at": s.at.isoformat(),
                "readings": {c.value: v for c, v in s.readings.items()},
                "counts": list(s.counts) if s.counts is not None else None,
            }
            for s in self.samples
        ],
        "keys": [
            {
                "key": k.name,
                "step": int(k.step),
                "cursor": k.cursor.value if k.cursor is not None else None,
                "sent_at": k.sent_at.isoformat(),
                "acknowledged": k.acknowledged,
                "taken": k.taken,
                "after_s": k.after_s,
                "purpose": k.purpose,
            }
            for k in self.keys
        ],
        "cleanup": {
            "clean": self.cleanup.clean,
            "actions": list(self.cleanup.actions),
            "flags_left": [c.value for c in self.cleanup.flags_left],
            "error": self.cleanup.error,
        },
        "run_started_at": self.started_at.isoformat(),
        "run_ended_at": self.ended_at.isoformat(),
        "error": self.error,
        "operator": operator,
        "notes": notes,
    }
    return record

RunState

Bases: StrEnum

Where a remote calibration is.

CLOSED class-attribute instance-attribute

CLOSED = 'closed'

The block was left and the cleanup ran.

ENDED class-attribute instance-attribute

ENDED = 'ended'

The pass ended: calibrated, cancelled, or stopped.

WAITING class-attribute instance-attribute

WAITING = 'waiting'

On the wait step, the channel selected: the gas settles.

WaitSample dataclass

WaitSample(at, readings, counts)

One read on the wait step: what the steadiness rule saw.

counts instance-attribute

counts

The detectors' raw A/D counts (A/D Nos. 0-4), when read.

calibration_filename

calibration_filename(result, serial_number)

A file name for a run's record: serial, channel, kind and start time.

Source code in src/fujilib/devices/keys.py
def calibration_filename(result: RemoteCalibrationResult, serial_number: str | None) -> str:
    """A file name for a run's record: serial, channel, kind and start time."""
    stamp = result.started_at.strftime("%Y%m%dT%H%M%SZ")
    serial = (serial_number or "analyzer").strip() or "analyzer"
    plan = result.plan
    return f"fuji-calibration_{serial}_{plan.channel.value}_{plan.kind.value}_{stamp}.json"

key_refusal

key_refusal(
    key, observation, *, cursor=None, calibrate=False
)

Why key may not be sent on the panel observation shows; None if it may.

cursor is where ENT on channel selection needs the cursor. ENT on a wait step starts the calibration, so it is allowed only with calibrate. ENT on the error display is never allowed.

Source code in src/fujilib/devices/keys.py
def key_refusal(
    key: KeyCode,
    observation: PanelObservation,
    *,
    cursor: ChannelId | None = None,
    calibrate: bool = False,
) -> str | None:
    """Why ``key`` may not be sent on the panel ``observation`` shows; ``None`` if it may.

    ``cursor`` is where ENT on channel selection needs the cursor. ENT on a
    wait step starts the calibration, so it is allowed only with
    ``calibrate``. ENT on the error display is never allowed.
    """
    name = KEY_NAMES.get(key)
    if name is None:
        return f"0x{int(key):02X} is not one of the calibration keys"
    display = observation.display
    if display.screen != DisplayScreen.MEASUREMENT:
        return f"the panel shows {_screen_name(display.screen)}, not the measurement screen"
    step = observation.step
    if key not in _ALLOWED.get(step, frozenset()):
        note = "; ENT there can force the calibration" if step == _STEP.ERROR_DISPLAY else ""
        return f"{name} is not sent on {_step_name(step)}{note}"
    flags = _any_flag(observation)
    if step == _STEP.NONE and flags:
        channels = ", ".join(c.value for c in sorted(flags, key=lambda c: c.number))
        return f"a calibration flag is set on {channels}"
    if key is not KeyCode.ENT:
        return None
    return _ent_refusal(observation, cursor=cursor, calibrate=calibrate)

fujilib.devices.steadiness

Whether a calibration gas has settled at the inlet: the steadiness rule (design §13.1 #78).

A manual zero or span is right only if it is computed from the gas it names, after the reading has stopped moving. The operator switches the valves and names the gas; the analyzer's own guard is coarse (error 8: more than 100 counts of change within 60 s during the calibration, service manual p.33). So before the key that calibrates, fujilib watches each channel's reading on the wait step and calls it steady when, over a window of the last :meth:SteadinessRule.window_for seconds:

  • it has moved by no more than :attr:SteadinessRule.band_percent_fs of its range's full scale (highest less lowest reading), and
  • its mean lies within :attr:SteadinessRule.tolerance_percent_fs of the gas the operator named, which catches the wrong cylinder or a valve left shut;
  • and no two reads in it are more than :attr:SteadinessRule.max_gap_s apart, so that a verdict never rests on a couple of reads either side of a pause.

The window is the longer of :attr:SteadinessRule.window_s and :attr:SteadinessRule.response_factor times the channel's response time. On the bench, O2 settled within 0.01 vol% about 36 s after the gas changed, with the response time at 15 s (protocol findings §14.4).

Every channel a calibration touches must be steady at once. The readings are the Modbus concentrations, which are live only while output hold is off; a remote calibration is refused while it is on (design §13.1 #86).

:class:SteadinessJudge is pure: it takes each read's readings with a monotonic time and gives a :class:SteadinessVerdict. It does no I/O.

ChannelSteadiness dataclass

ChannelSteadiness(
    channel,
    steady,
    window_s,
    covered_s,
    samples,
    gas,
    unit,
    last,
    mean,
    spread_percent_fs,
    offset_percent_fs,
    reason,
)

One channel's part of a verdict.

covered_s instance-attribute

covered_s

Seconds the reads kept cover, up to the window.

last instance-attribute

last

The last reading.

offset_percent_fs instance-attribute

offset_percent_fs

The window's mean less the named gas, in % of full scale.

reason instance-attribute

reason

Why it is, or is not yet, steady.

samples instance-attribute

samples

Reads in the window.

spread_percent_fs instance-attribute

spread_percent_fs

Highest less lowest reading over the window, in % of full scale.

steady instance-attribute

steady

Steady and on the named gas.

SteadinessJudge

SteadinessJudge(rule, targets)

Applies a :class:SteadinessRule to successive reads (see the module docstring).

Watch targets under rule.

Raises:

Type Description
FujiValidationError

no channel to watch, or a full scale that is not positive.

Source code in src/fujilib/devices/steadiness.py
def __init__(self, rule: SteadinessRule, targets: Mapping[ChannelId, SteadinessTarget]) -> None:
    """Watch ``targets`` under ``rule``.

    Raises:
        FujiValidationError: no channel to watch, or a full scale that is not positive.
    """
    if not targets:
        msg = "a steadiness judge needs at least one channel"
        raise FujiValidationError(msg)
    for channel, target in targets.items():
        if not (math.isfinite(target.full_scale) and target.full_scale > 0):
            msg = f"{channel.value}: the full scale must be positive, got {target.full_scale}"
            raise FujiValidationError(msg)
    self._rule = rule
    self._targets = MappingProxyType(dict(targets))
    self._windows = {c: rule.window_for(t.response_time_s) for c, t in targets.items()}
    longest = max(self._windows.values())
    self._keep = longest
    self._samples: deque[tuple[float, Mapping[ChannelId, float | None]]] = deque()
    self._first: float | None = None
    self._reads = 0

rule property

rule

The rule applied.

targets property

targets

The channels watched.

feed

feed(at_s, readings)

Take in one read at monotonic time at_s; the verdict with it.

A channel missing from readings counts as a reading that could not be decoded.

Raises:

Type Description
FujiValidationError

at_s is earlier than the read before it.

Source code in src/fujilib/devices/steadiness.py
def feed(self, at_s: float, readings: Mapping[ChannelId, float | None]) -> SteadinessVerdict:
    """Take in one read at monotonic time ``at_s``; the verdict with it.

    A channel missing from ``readings`` counts as a reading that could not
    be decoded.

    Raises:
        FujiValidationError: ``at_s`` is earlier than the read before it.
    """
    if self._samples and at_s < self._samples[-1][0]:
        msg = f"reads must come in time order: {at_s} is before {self._samples[-1][0]}"
        raise FujiValidationError(msg)
    if self._first is None:
        self._first = at_s
    self._reads += 1
    self._samples.append((at_s, MappingProxyType(dict(readings))))
    # Keep one read at or before the start of the longest window, so it is covered.
    while len(self._samples) > 1 and self._samples[1][0] <= at_s - self._keep:
        self._samples.popleft()
    channels = {c: self._judge(c, at_s) for c in self._targets}
    return SteadinessVerdict(
        steady=all(s.steady for s in channels.values()),
        channels=MappingProxyType(channels),
        elapsed_s=at_s - self._first,
        reads=self._reads,
    )

reset

reset()

Forget every read, as when the gas is changed.

Source code in src/fujilib/devices/steadiness.py
def reset(self) -> None:
    """Forget every read, as when the gas is changed."""
    self._samples.clear()
    self._first = None
    self._reads = 0

window

window(channel)

The window, in seconds, of channel.

Source code in src/fujilib/devices/steadiness.py
def window(self, channel: ChannelId) -> float:
    """The window, in seconds, of ``channel``."""
    return self._windows[channel]

SteadinessRule dataclass

SteadinessRule(
    window_s=30.0,
    response_factor=2.0,
    band_percent_fs=0.5,
    tolerance_percent_fs=10.0,
    timeout_s=600.0,
    max_gap_s=5.0,
)

When a channel's reading counts as steady on the named gas (design §13.1 #78).

The defaults are the design's starting values, to be tuned on the bench.

band_percent_fs class-attribute instance-attribute

band_percent_fs = 0.5

How far the reading may move over the window, in % of full scale.

max_gap_s class-attribute instance-attribute

max_gap_s = 5.0

The longest time between two reads in the window, in seconds.

response_factor class-attribute instance-attribute

response_factor = 2.0

The window is at least this many times the channel's response time.

timeout_s class-attribute instance-attribute

timeout_s = 600.0

How long to wait for the gas to settle before giving up, in seconds.

tolerance_percent_fs class-attribute instance-attribute

tolerance_percent_fs = 10.0

How far the window's mean may lie from the named gas, in % of full scale.

window_s class-attribute instance-attribute

window_s = 30.0

The shortest window, in seconds.

__post_init__

__post_init__()

Refuse a rule that could never, or always, be met.

Raises:

Type Description
FujiValidationError

a value is not a positive finite number, or the response factor is negative.

Source code in src/fujilib/devices/steadiness.py
def __post_init__(self) -> None:
    """Refuse a rule that could never, or always, be met.

    Raises:
        FujiValidationError: a value is not a positive finite number, or the
            response factor is negative.
    """
    for name in (
        "window_s",
        "band_percent_fs",
        "tolerance_percent_fs",
        "timeout_s",
        "max_gap_s",
    ):
        if not _number(getattr(self, name), minimum=0.0, inclusive=False):
            msg = f"{name} must be a positive number, got {getattr(self, name)!r}"
            raise FujiValidationError(msg)
    if not _number(self.response_factor, minimum=0.0, inclusive=True):
        msg = f"response_factor must be a number of 0 or more, got {self.response_factor!r}"
        raise FujiValidationError(msg)

window_for

window_for(response_time_s)

The window for a channel with response_time_s (None: not known).

Source code in src/fujilib/devices/steadiness.py
def window_for(self, response_time_s: float | None) -> float:
    """The window for a channel with ``response_time_s`` (``None``: not known)."""
    if response_time_s is None:
        return self.window_s
    return max(self.window_s, self.response_factor * response_time_s)

SteadinessTarget dataclass

SteadinessTarget(
    gas, full_scale, unit, response_time_s=None
)

One channel to watch: the gas named for it and its range.

full_scale instance-attribute

full_scale

The full scale of the range it is calibrated on, in the same unit.

gas instance-attribute

gas

The named gas, in the channel's unit.

response_time_s class-attribute instance-attribute

response_time_s = None

The channel's response time, if known; it lengthens the window.

SteadinessVerdict dataclass

SteadinessVerdict(steady, channels, elapsed_s, reads)

What the rule says after a read, for every channel watched.

elapsed_s instance-attribute

elapsed_s

Seconds since the first read.

reads instance-attribute

reads

Reads taken in so far.

reasons property

reasons

Each channel's reason, prefixed with the channel.

steady instance-attribute

steady

Every channel is steady on its named gas.