Quickstart: async¶
fujilib is async-first and runs on AnyIO, so it works under asyncio and trio alike. Everything here is read-only: nothing is written to the analyzer.
import anyio
from fujilib import open_device
async def main() -> None:
async with await open_device(
"COM8", # or "/dev/ttyUSB0"; the station is 1 unless set otherwise on the panel
channel_map={"CH1": "co2", "CH2": "co", "CH3": "o2"},
) as anz:
print(anz.info) # model, serial number, channels, ranges, capabilities
frame = await anz.poll() # every channel and the analyzer's status
o2 = frame.channel("CH3")
print(o2.value, o2.unit, o2.state, o2.label_source)
meta = await anz.read_metadata() # response times, calibration gases, clock...
print(meta.response_time_o2_s, meta.calibration_gas)
anyio.run(main)
What open_device does¶
open_device opens the serial port (38400 8-N-1, the analyzer's only setting),
identifies the analyzer and returns an Analyzer. Identification reads the
type code, serial number, ranges and readings, and probes the features that
depend on firmware. It takes six Modbus transactions, about 0.3 s. The
async with block closes the port again, including when something fails.
address=is the station number, 1-31, as set on the front panel.timeout=is how long to wait for each reply (0.5 s by default).- A
Transportcan be passed instead of a port name; it stays yours to close.
Gas labels¶
Which gas each channel carries comes from channel_map. The analyzer's type
code only suggests labels, and on the development unit it is out of date. A
channel whose label feeds a calculation must be asserted. Every reading says
where its label came from (label_source), and a label you did not assert
has gas == Gas.UNKNOWN, with the suggestion in suggested_gas.
A channel is established when it is asserted, or when its reading has been non-zero at least once since the analyzer was opened. Only established channels appear in a frame.
Validity¶
Every reading carries a state: ok, or the most important reason it is not
live: an analyzer or channel error, a calibration, an auto calibration, or
output hold, during which the value is frozen. reading.valid is True only
for ok. It is None, not True, when the status was not read
(poll(detail=False)). Raw values are always kept.
Oxygen
Over Modbus, O2 arrives with the display's resolution: 0.01 vol% per step on the development unit. Modbus O2 is not validated for oxygen-consumption calorimetry (design ยง2.11).
Deadlines¶
Every method that talks to the analyzer takes a keyword-only timeout=, a
deadline for the whole call: waiting for the port, every transaction and any
retry. When it expires the call raises FujiTimeoutError.
Errors¶
Every exception is a FujiError with an ErrorContext (port, station,
operation, register). A read that fails in transit is retried twice before it
raises. A connection failure closes the session for good; open the analyzer
again to continue.
More reads¶
| Method | Reads |
|---|---|
status(), channel_status(ch) |
the analyzer's and one channel's status |
read_ranges() |
each channel's ranges |
read_settings(), read_parameter(name) |
holding registers by their names in the register map |
read_error_log() |
the last 14 errors (day, hour and minute only) |
read_calibration_log(ch) |
calibration records; firmware 2.24 or later |
read_clock(), read_adc() |
the analyzer's clock and raw A/D counts (undocumented registers) |
snapshot() |
identity and health, from what is cached, with no I/O |
Changing a setting¶
A reviewed subset of settings can be written: response times, output hold,
hold, a channel's range, and the calibration gases and scope. Every write
needs confirm=True, is refused while the analyzer is calibrating or its
front panel is in a menu, and is read back. See Safety.
result = await anz.write_parameter("response_time.o2", 20, confirm=True)
print(result.state, result.previous.value, result.observed.value) # verified 15 20
await anz.set_range("CH3", 2, confirm=True)
await anz.set_calibration_gas("CH3", 1, "span", 20.9, unit="vol%", confirm=True)
Calibrations made at the front panel¶
The analyzer keeps no calibration log on older firmware, so fujilib records a manual zero or span as it happens. It reads the panel; it presses no key.
plan = await anz.plan_manual_calibration("CH3", "zero") # what a zero of CH3 touches
event = await anz.wait_for_manual_calibration(timeout=600, adc=True)
print(event.outcome, event.channels, event.calibrated_at, event.deviations)
The event says whether it completed, failed or was cancelled, and why. See Safety.
A zero or span can also be driven from the host, with the operator at the gas valves. fujilib presses the calibration keys, waits for the reading to settle on the gas named, and returns the panel to measurement however the block ends:
from fujilib.devices.keys import CalibrationGas
plan = await anz.plan_manual_calibration("CH3", "zero")
async with anz.manual_calibration(plan, gas=CalibrationGas(0, label="N2"), confirm=True) as run:
await run.wait_steady() # switch the inlet to zero gas now
event = await run.calibrate(confirm=True) # changes the analyzer's calibration
See Safety and fuji-calibrate.
Finding analyzers¶
from fujilib import find_devices
results = await find_devices(ports=["COM8"], addresses=range(1, 32))
for result in results:
if result.device_info is not None:
print(result.port, result.address, result.model, result.device_info.serial_number)
Discovery only reads, but every port it scans receives its probe frames, so name the ports rather than scanning them all.
Without hardware¶
fujilib.testing has a simulated analyzer that answers from the development
unit's registers:
from fujilib import open_device
from fujilib.testing import DEFAULT_ZPA_BANK, MockAnalyzer, mock_transport
async with mock_transport(MockAnalyzer(DEFAULT_ZPA_BANK)) as (transport, _line):
async with await open_device(transport) as anz:
print(await anz.poll())
From the command line¶
fuji-read COM8 --gas CH1=co2 --gas CH2=co --gas CH3=o2 # identity and one poll
fuji-read COM8 --all --format json # everything, as JSON
fuji-discover COM8 --addresses 1-31 # find stations on a port
fuji-configure dump COM8 --out settings.json # every setting, by name
fuji-configure diff COM8 --file settings.json # what applying it would change
fuji-read --fixture bench --all # the simulated analyzer
Recording¶
record() polls at a fixed rate; pipe() writes the polls to a sink. See
Recording.