Skip to content

Protocol findings — bench ZPA, 2026-09-28

Measured on the bench analyzer with the read-only probes in scripts/ (probe_connect.py, probe_map.py, probe_scan.py, probe_link.py, and, for fujilib's own client, probe_client.py, §10). Through §12 only Modbus read function codes were sent; no register was written and no command was issued. §13 records the first writes and commands, made in a session the owner authorized and attended. §14 records a calibration the owner made at the front panel, watched read-only, and §15 a read-only session on what the registers still left unexplained. §16 is fujilib's own watch of a panel calibration, and §17 what the factory-mode screens showed. §18 records the first front-panel keys written over Modbus, in a session the owner authorized and attended, and §19 the first calibrations driven from the host with them, the owner at the gas valves.

This document records what was observed. The manual is INZ-TN5A1190a-E unless noted. Addresses are relative (on-the-wire) hexadecimal. Raw results are in probe_out/ (git-ignored); the full register capture is tests/fixtures/captures/zpa_bench_20260928.json, which is also kept local for now because it carries the analyzer's serial number and factory calibration tables (design §13.1 #11).

1. Test setup

Item Value
Analyzer Fuji ZPA, nameplate serial N8A0259, manufactured 2018-02
Program version 1.02, as shown on the display at power-on (reported by the owner)
Components CO2, CO (NDIR) and O2 (Hummingbird Premus paramagnetic cell, per the owner; probably an upgrade)
Interface RS-485, two-wire
Adapter DTECH USB–RS-485, FTDI FT232R, enumerated as COM8
Framing 38400 8-N-1
Station No. 1
Host stack anyserial 0.1.2 + anymodbus 0.2.0, Python 3.14, Windows 11

The machine also has a B&B / Advantech 485USBTB-2W on COM6; nothing is connected to it that answers Modbus at any framing tried.

2. Identity

Register Contents
0448h–0461h, type code digits 1–26 ZPACBJY1MPFYYYYYY2DEYAYAY0
0462h–0469h, "board" digits 1–8 N8A0259T
047Ah–047Ch, type code digits 27–29 exception 02
  • Each register holds one ASCII character in its low byte, high byte zero.
  • The "board" code is the serial number. Its first seven characters match the nameplate serial. The manual does not say so.
  • Digits 27–29 and the calibration log (1000h–1707h) are absent, as the manual says they are before version 2.24. This unit is on 1.02.
  • The program version is not in any register found. It is shown on the display at power-on only.

Everything in this document was measured on this one unit and this one firmware version. It should not be assumed of other versions or of ZPB / ZPG.

The type code does not match the current manual's table

Decoded against the code table in the instruction manual (4th edition, which prints revision code 2 at digit 8; this unit has 1):

Digit Value Current table says Agrees with the analyzer?
4 C only A and D are listed not in table
5 B 19-inch rack yes
6 J CO2 + CO yes — Ch1 and Ch2
7 Y no O2 sensor no — Ch3 measures O2
8 1 revision code —
9–11 M P F 0–10 vol%, 0–50 vol%, 0–1000 ppm only digit 9 matches (CO2 0–10 vol%)
18 2 NPT 1/4 plausible
19 D 4–20 mA + communication yes
20 E English, 125 V cord plausible
21 Y no O2 correction yes — no corrected channels
24 A ppm / vol% yes
25, 26 Y, 0 not listed not in table

Consequence: the type code cannot be trusted to give the channel layout. Digit 6 was right; digit 7 was wrong. Range, unit and population must come from the range registers and the live readings, not from the code.

3. Channels and ranges

Ch Gas Ranges Range 1 Range 2 Decimals Reading at capture
1 CO2 1 10.00 vol% — 2 −0.11 vol%
2 CO 1 1.000 vol% — 3 −0.009 vol%
3 O2 2 21.00 vol% 25.00 vol% 2 20.29 vol%
4–12 unused concentration 0, decimals 0, unit 0
  • Negative concentrations are two's complement (FFF5h = −11).
  • O2 is transmitted with two decimals, so the Modbus value is quantized to 0.01 vol% (100 ppm). CO is quantized to 0.001 vol% (10 ppm), CO2 to 0.01 vol%.
  • Unused channels return an all-zero triple. Their range registers are not empty (Ch4 and Ch5 report two ranges with plausible values), so the range-count register is not a test for whether a channel exists.
  • The configured O2 range (0–21 / 0–25 vol%) differs from the nameplate (0–10 %).

4. The readable map

Found by reading every address 0000h–1FFFh one word at a time with each function code, then sampling 2000h–FFFFh every 256 addresses and every multiple of 1000.

FC Readable Manual says
04 0000h–00C1h same
04 03E8h–0479h 0425h–0469h
03 0000h–00ABh same
03 03E8h–069Bh not mentioned
03 0BB8h–0C66h not mentioned

Blocks start at decimal 0, 1000 and 3000; the write-only commands are at 2000.

The one-word scans also recorded 43 addresses (23 FC04, 20 FC03) whose reply was malformed rather than an exception: "function code 0", "exception echoes fc 0x20", wrong function codes. A follow-up read-only session re-read exactly those addresses, three times each, with a 5 ms busy-wait gap measured from each reply. All 129 reads returned exception 02. The malformed replies were link corruption from the timing defect in §6. The readable map above stands. FC03 and FC04 address separate tables, including at 03E8h and above.

4.1 Undocumented input registers (FC04)

Address Contents Evidence
03E8h–03EEh real-time clock, BCD: year, month, day, day of week, hour, minute, second read 26 09 28 01 11 51 39 at 11:58:07 PC time on Monday 2026-09-28; advanced correctly between reads; about 6.5 minutes behind the PC
03EFh–0418h 21 A/D conversion values, long words, low word first values match the service manual's A/D table: the reference voltage (No. 15) read 38929, inside its stated 35,000–80,000 window; IR inputs No. 0 and No. 1 read about 65,700 and 69,700
0419h–0424h zero
046Ah–0471h four long words close to the IR input counts the unsmoothed CO2 and CO detector counts, each twice (§15.4)
0472h–0479h zero, but 0472h–0478h read 1 now and then purpose unknown (§15.4)

The A/D values were live: two reads minutes apart differed by a few counts.

4.2 Undocumented holding registers (FC03)

Address Contents
009Eh–00A3h reference-gas and averaging settings (documented for ZPB / ZPG)
00A4h–00ABh four long words, each 1,000,000 — the interference compensation coefficients, of which the manual lists only the first
03E8h–069Bh factory data: tables of breakpoints (300, 600, 1000 … 20000), long-word coefficients near 100,000 and word coefficients near 10,000
0BB8h–0C66h factory configuration, including a copy of the ranges and of the type code and serial, and the factory menu's "other parameters" at 0C2Dh–0C34h (§15.3)

Neither block holds the zero and span calibration coefficients (§15.3).

These two factory blocks must never be written. They hold the linearization and calibration data. Whether the analyzer would accept a write there was not tested and will not be.

4.3 A coherent block capture (2026-09-28)

The register capture was read one word at a time over several minutes, so its live words do not come from one moment. A second read-only capture read the same addresses in block reads:

  • probe_map.py with anymodbus 0.2.1, anyserial 0.1.2, anyio 4.15.1, Python 3.13.13 on Windows 11; timeout 0.5 s, inter-frame idle 5 ms, 2 retries, blocks of up to 64 words.
  • Every documented region and the clock and A/D block (03E8h–0418h): 312 input and 172 holding words, the same addresses as the committed bank. Ten block reads, all successful, plus the read of type-code digits 27–29, which answered exception 02 as before; 0.63 s in all, from 18:52:38 UTC.
  • Saved as tests/fixtures/captures/zpa_bench_block_20260928.json (kept local, like the register capture), SHA-256 7c97590745e658ee2bdfd8a271605325fe5a8ae894dc86b5601e48b795bbeddd; the same file as probe_out/probe_map_20260928T185238Z.json. Its input and holding tables have the capture's shape, so fuji-decode --dump reads it.

Compared with the register capture, three hours earlier:

Words Result
FC03 0000h–00ABh, all 172 identical
FC04 0425h–0469h: ranges, type code, serial identical
FC04 0000h–00C1h except the six words below; error log included identical
Readings 0000h, 0003h, 0006h CO2 −0.11 → −0.10 vol%, CO −0.009 → −0.007 vol%, O2 20.30 → 20.18 vol%
Display-state words 00B9h, 00BCh, 00BDh 6 → 0, 2 → 0, 0 → 16
Clock 03ECh–03EEh read 14:46:12 at 14:52:39 PC time: still about 6.5 minutes behind
A/D 03EFh–0418h 19 of the 21 low words moved, by 1 to 211 counts; every high word identical; reference voltage (No. 15) 38928

Everything that is not a live value matched, so the register capture's settings, ranges and identity stand. The block capture is the coherent one: its readings, status, clock and A/D values come from the same 0.63 s.

5. Replies to requests the analyzer does not support

Request Reply Manual
FC01, FC02 exception 02 implies 01 (illegal function)
address outside the map exception 02 same
65 words exception 03 same
block crossing the end of a region exception 03 —
FC03 read of a command register (07D0h) exception 02 —
FC07, FC08/0000h, FC0B, FC0C, FC11, FC14, FC18, FC2B/0Eh exception 01 (§15.1) —

A CRC-valid exception of any kind therefore means "a station is present".

6.1 Corrected measurement (follow-up session, 2026-09-28)

anymodbus inter-frame idle was set to 0 and retries to 0. Each gap was enforced by a perf_counter busy-wait from the moment the previous reply was returned, and the achieved minimum gap matched the target to within 0.01 ms. There were 500 requests per row, FC04, one word each. 0000h is a valid register; 00C2h is one past the map and draws exception 02.

Sequence Gap after the reply Failed
normal → normal 0 ms 7 (3 silent, 4 frame errors)
normal → normal 1, 2, 3, 5 ms 0 each
exception → exception 0 ms 5 silent
exception → exception 1, 2, 3, 5 ms 0 each
exception → normal, alternating 0 ms 3 silent, all on the normal read
exception → normal, alternating 2, 5 ms 0 each
  • The analyzer needs at most 1 ms after any reply, and an exception reply needs no longer than a normal one. Only a zero gap fails, about 1 % of the time.
  • These are host-side gaps. The FTDI latency timer delays the host's view of each reply, so the analyzer saw at least these gaps. The data cannot resolve requirements below about 1 ms.
  • Limits of this run: gap order was fixed, not randomized; normal → exception was not tested; only counts were kept. The randomized re-run in §6.3 addresses all three.
  • Round trip: 13–27 ms for one word, 50–63 ms for 64 words. A two-block poll therefore takes about 120 ms; the practical ceiling is 7–8 polls per second.
  • The adapter is an FTDI chip at its default 16 ms latency timer, which sets the floor on these figures.

6.2 The first measurement, and why its conclusion was wrong

The first run, below, concluded that the analyzer needs 20–30 ms after an exception reply. The method produced that result by itself:

  1. anymodbus counts the gap from the wrong moment after an exception. probe_link.py relied on anymodbus's inter_frame_idle, which is counted from _last_io_monotonic. That value is set when a request is sent (bus.py:439) and again only after a successful reply (bus.py:475). An exception reply raises before line 475, so after an exception the "gap" was counted from the request. With a 7–13 ms round trip, a configured 5 ms meant no gap at all. That explains the 1 % failures, the same rate as the normal-read 0 ms row. It also explains latency_ms.min = 0.0 at 20, 50 and 100 ms in the raw file: calls finished before the idle they supposedly waited.
  2. Short sleeps on Windows are rounded up. On this machine (Python 3.14.4) any anyio.sleep below about 16 ms takes about 16 ms, and 20 or 30 ms takes about 33 ms. The configured gaps below are not the gaps on the wire; "1.75 ms" was really about 16 ms.

The raw counts are kept for the record.

Tight loops with no retries and nothing executed between requests.

Valid reads, 300 requests per row (configured anymodbus idle):

Gap before request 1 word: failed 64 words: failed
0 ms 4 (1.3 %) 6 (2.0 %)
1.75 ms 0 0
2.5 ms 0 0
5 ms 0 0
10, 20, 50 ms 0 0

Requests answered by an exception, 500 per row (configured idle, counted from the request; see above):

Gap before request Failed
5 ms 5 (1.0 %)
20 ms 0
50 ms 0
100 ms 0

These rows are superseded by §6.1.

6.3 Randomized re-run, and anymodbus 0.2.1 on the analyzer (2026-09-28)

probe_link.py --mode pairs. Each trial is two one-word FC04 reads, each either a normal read (0000h) or a read that draws exception 02 (00C2h), so all four pairings are covered. Trials ran in randomized order, with 10 ms of idle before each trial. The gap is measured from the moment the first read returned to the moment anymodbus logged the second frame for sending. No retries; request timeout 0.3 s. Every trial is kept in the raw file.

What the analyzer needs. anymodbus idle 0, gap busy-waited. 250 trials per cell; failures are all silent (no reply within the timeout):

Gap after the reply normal → normal normal → exception exception → normal exception → exception
0 ms 0 5 2 1
1 ms 0 0 0 0
2 ms 0 0 0 1
5 ms 0 0 0 0
  • This confirms §6.1, now including normal → exception. Only a zero gap fails consistently: 8 of 1,000 (0.8 %), in three of the four pairings. A gap of 1 ms had no failures in 1,000 trials, whichever kind of reply came first.
  • The one failure at 2 ms looks like background loss, not a gap requirement. Its measured gap was 2.08 ms, the neighbouring trials succeeded, and 1 ms and 5 ms had no failures in 2,000 trials. It suggests a loss rate of about 1 in 3,000 requests, which read retries absorb. This run cannot rule out a very small rate specific to 2 ms.
  • Measured gaps were accurate: the medians were 0.08 ms above target at every gap.
  • Round trip, from the frame being sent to the reply being returned, one word: 11.8–26.3 ms, median 13.9 ms.

What anymodbus delivers. The probe added no wait, leaving the gap to anymodbus's own inter_frame_idle of 5 ms (fujilib's default). 250 trials per pairing:

anymodbus Gap after a normal reply Gap after an exception reply Failed
0.2.0 5.09–22.4 ms 0.05–0.22 ms 2 of 500 after an exception
0.2.1 5.49–20.2 ms 5.22–27.5 ms 0 of 1,000
  • 0.2.0 reproduces the defect of §6.2 on the analyzer. After an exception reply the configured 5 ms collapsed to about 0.1 ms, and failures appeared at the zero-gap rate.
  • 0.2.1 holds the gap after every reply. The medians of about 12 ms come from the Windows timer (§6.2), not from the setting.
  • fujilib's default of 5 ms leaves a wide margin over the 1 ms the analyzer needs.

Raw files, in probe_out/ (git-ignored), each recording the probe's arguments, the package versions and the SHA-256 of the probe scripts:

File Run Seed
probe_link_pairs_busywait_20260928T174956Z.json busy-wait, anymodbus 0.2.1 3120660651
probe_link_pairs_anymodbus_20260928T175306Z.json anymodbus 0.2.1 idle 3025024686
probe_link_pairs_anymodbus_20260928T175411Z.json anymodbus 0.2.0 idle 2979417567

7. Status at capture

  • No instrument error, no calibration error, no alarms, no calibration running, no hold.
  • Key lock off, output hold off, auto calibration off, auto zero off.
  • All range-switch methods manual; every channel on range 1.
  • Response time is 15 s on all channels.
  • The error log is full (14 of 14 entries), all calibration errors: No. 5 (zero calibration amount over 50 %FS), No. 6 (span outside the allowable range) and No. 7 (span calibration amount over 50 %FS), on channels 1, 2 and 3. The newest is error 6 on channel 1. The log stores day, hour and minute only.

8. What this changes

Topic Change
Region map per-profile and as measured, with the manual's narrower map as the documented subset
Writes allowed only to a whitelist of documented user settings and commands; the factory blocks are unreachable by construction
Timing inter-frame gap 5 ms (the manual's recommendation), measured from the end of every reply; no special recovery after an exception (§6.1, §6.3); anymodbus ≥ 0.2.1 records the end of every transaction, verified on the analyzer (§6.3)
Channel layout from live data and range registers; the type code is a hint, labelled as such
Serial number read from the "board" code
Clock exposed; used to complete the partial timestamps in the error log
Firmware the calibration log and type digits 27–29 are probed, and absent here
Discovery any CRC-valid reply, including an exception, identifies a station

9. Not tested

  • Any other analyzer or firmware version.
  • Any write: settings, commands, key simulation. (Setting writes and return to measurement later; see §13. Key simulation, calibration and blowback are never sent; a calibration made at the panel was watched later, see §14.)
  • Whether key lock affects Modbus writes. (Later; see §13.3.)
  • Persistence of settings across a power cycle. (Later; see §13.5.)
  • Addresses 2000h–FFFFh at full resolution (sampled only).
  • Function codes other than 01–04, 06 and 10h. (Later: eight read-only diagnostic and identification codes answer exception 01; see §15.1.)
  • Multi-drop with more than one station.
  • A comparison of the Modbus O2 value against the analog output.
  • Linux timing. (Normal → exception gap pairs and randomized gap order were measured later; see §6.3.)

10. fujilib's own client on the bench (2026-09-28, evening)

fujilib's transport, Modbus port, client and read procedures (commit 8c04481), run against the same analyzer on COM8, station 1. They used the library defaults: 0.5 s request timeout, 5 ms inter-frame idle, 50 ms startup settle, 2 read retries and a 0.1 s quiet window. anymodbus 0.2.1, anyserial 0.1.2, anyio 4.15.1, Python 3.13, Windows 11.

  • The probe: scripts/probe_client.py, read-only by construction (the read procedures get a client whose write methods raise).
  • The tests: tests/hardware/test_hardware_client.py, run with FUJILIB_ENABLE_HARDWARE_TESTS=1 FUJILIB_HARDWARE_PORT=COM8 uv run pytest -m hardware tests/hardware.

10.1 Every read procedure

--mode smoke ran every read procedure once.

  • Identify. Type code and serial as in §2; channels 1–3 present.
  • Probes. The clock and the A/D values are supported; type-code digits 27–29 and the calibration log are unsupported (exception 02, as in §5).
  • Poll. Two blocks, 48 ms each. Every reading was in state "ok": CO2 −0.10, CO −0.006, O2 20.17 vol%. No hold and no errors.
  • Metadata. Response times 15 s on every channel.
  • Error log. 14 entries, as in §7.
  • Clock. Still about 6.5 minutes behind the host.
  • A/D. Reference voltage 38927.
  • Counters. 27 requests, no retries; the only failed attempts were the 3 expected exception replies.

The eight hardware tests passed under asyncio.

10.2 Sustained polls

--mode polls --count 300: 300 full polls back to back.

Quantity min median p95 max
block 1 (0000h+61) round trip, ms 46.5 48.9 51.1 54.3
block 2 (0083h+60) round trip, ms 46.0 48.3 50.7 53.9
block 1 reply → block 2 request, ms 5.1 16.9 20.3 22.8
whole poll, ms 110.9 130.9 136.8 140.9
  • No failures. 603 requests, no retries, no failed attempt.
  • 7.78 polls per second, within the 7–8 Hz ceiling estimated in design §2.4.
  • The idle holds. The 5 ms idle was never shortened. Its median of 17 ms is the Windows timer.
  • Timing is honest. Each block's round trip excludes that wait: the client waits out the gap before it timestamps the request (design §4.2).

10.3 A reply still on the wire after its read is cancelled

--mode resync, 30 trials per condition. Each trial:

  1. reads block B (0083h+36, the error and hold flags) as a reference;
  2. idles 40 ms;
  3. reads block A (0000h+36, the readings; about 33 ms round trip) and cancels it after 15 or 30 ms, so A has been sent and its reply is on its way;
  4. reads B again at once and compares.

In all 120 trials A reached the wire before it was cancelled.

Cancel A after Quiet window B right first time B lost (timed out, the retry recovered it) Wrong data accepted Cancel → B done, median
15 ms 0 7 23 0 571 ms (lost) / 55 ms (first time)
15 ms 0.1 s 30 0 0 136 ms
30 ms 0 30 0 0 53 ms
30 ms 0.1 s 30 0 0 138 ms
  • The hazard is real on this line, but it takes a different form from the one the simulator reproduces. Cancelled 15 ms in, A's reply is still arriving when B's request goes out on the half-duplex line. In 23 of 30 trials the analyzer never answered B: the request collided with A's reply, and B cost a 0.5 s timeout and a retry.
  • Stale data was never accepted. In no trial was A's late reply accepted as B's. The hazard the design's §4.2 describes (a same-length reply to another request) remains possible in principle, but was not observed with this adapter.
  • When the reply has landed, the input reset clears it. Cancelled 30 ms in, A's reply has almost entirely arrived by the time B is sent, so the reset clears it and B succeeds even with no window.
  • With the default 0.1 s window every trial succeeded at once. A cancellation or timeout then costs about 80 ms instead of a possible 0.5 s timeout.
  • An invalid first run. Its cancellations landed while the client was still waiting out the inter-frame gap, before anything was sent, so it tested nothing. It is kept (probe_client_resync_20260928T203149Z.json) but not counted. The probe now idles before A.

10.4 Trio could not read a real COM port on Windows (fixed in anyserial 0.2.0)

With anyserial 0.1.2, every hardware test under trio failed within about 4 ms of its first read, with anyserial.SerialError: [WinError 1460] This operation returned because the timeout period expired. A plain idle receive() on COM8, with nothing sent, reproduced it: under asyncio it waited and was cancelled cleanly; under trio it raised.

  • Cause. anyserial reads with the "wait-for-any" COMMTIMEOUTS policy, under which an overlapped read with no data completes after about 1 ms with STATUS_TIMEOUT. That is a success status: asyncio's Proactor returns it as 0 bytes, and anyserial reissues the read. Trio raised it as an error, and anyserial treated it as a failed port.
  • Why CI did not catch it. The simulator's port pair does not take this path, which is why the unit tests pass on trio.
  • Until anyserial 0.2.0, fujilib on Windows with a real port had to run on asyncio, and the hardware tests marked trio on Windows as a strict expected failure (design §4.7 item 14).
  • The fix. anyserial 0.2.0's trio read path treats that STATUS_TIMEOUT as the empty completion asyncio reports, and reissues the read. With the expected-failure mark removed, the eight hardware tests pass on COM8 under both asyncio and trio (16 of 16), run twice the same evening: once with anymodbus 0.2.1 and once with 0.3.0 (§10.5). anyserial 0.2.0, anyio 4.15.1, trio 0.34.0, Python 3.13, Windows 11.

Raw files, in probe_out/ (git-ignored):

File Run probe_client.py SHA-256
probe_client_smoke_20260928T203048Z.json smoke 2475306b…
probe_client_polls_20260928T203100Z.json 300 polls 2475306b…
probe_client_resync_20260928T203149Z.json resync, invalid (cancelled before sending) 2475306b…
probe_client_resync_20260928T203243Z.json resync, cancel after 15 ms 694951e7…
probe_client_resync_20260928T203354Z.json resync, cancel after 30 ms 694951e7…

10.5 The same checks on anymodbus 0.3.0

After fujilib moved to anymodbus 0.3.0 (design §4.7), the checks of §10.1–§10.3 were run again, read-only, the same evening. anymodbus now waits the gap, retries, checks each reply and keeps the late-reply window, and fujilib takes the timing and counters from its per-attempt reports.

  • Every read procedure. The results are identical to §10.1, and so are the counters: 27 requests, and 3 exception replies, all expected. The eight hardware tests pass under asyncio.
  • Sustained polls. 300 polls, no failures, 7.86 polls per second.
  • Block round trips: medians 48.1 and 47.5 ms, maxima 51.8 and 52.2 ms.
  • Reply → next request: at least 5.5 ms, median 16.8 ms.
  • Each block's round trip still excludes the wait: its request time is anymodbus's report of when the request had been sent.
  • Late replies, cancelled after 15 ms, 30 trials per window:
Quiet window B right first time B lost, the retry recovered it Wrong data accepted
0 5 25 (24 timeouts, 1 reply that did not answer the request) 0
0.1 s (late_reply_window) 30 0 0

The one mismatched reply is new. Since 0.3.0 anymodbus checks every reply against its request, so it was rejected and retried rather than returned.

Raw files: probe_client_smoke_20260928T215219Z.json, probe_client_polls_20260928T215234Z.json and probe_client_resync_20260928T215313Z.json, all with probe_client.py 694951e7….

11. The analyzer facade on the bench (2026-09-28, night)

The Analyzer facade, open_device, discovery, the blocking facade and the fuji-* commands, read-only, on COM8, station 1, with the library defaults. anymodbus 0.3.0, anyserial 0.2.0, anyio 4.15.1, trio 0.34.0, Python 3.13, Windows 11.

  • The hardware tests. test_hardware_client.py, test_hardware_reads.py and test_hardware_sync.py: 45 of 45 pass, under asyncio and trio (hardware_sync runs on its own portal's asyncio loop).
  • Opening. open_device with identification takes 0.30-0.32 s (13 requests including the probes, no retries); read_metadata() 0.24-0.25 s; a poll 0.12-0.13 s. Opening, closing and opening the port again at once worked every time.
  • Readings. CO2 −0.10, CO −0.006, O2 20.20 vol%, all in state "ok", with the asserted labels.
  • The clock still ran about 6.5 minutes behind the host.
  • 50 facade polls took about 6.5 s, the same 7.7 polls per second as §10.2.
  • The calibration log was refused before any request, once the probe had found it absent.
  • An empty station (2) timed out after the retries, with the station and port in the error, and the port it had opened was closed, so the analyzer opened again at once.
  • Discovery on COM8, stations 1 and 2: the analyzer at 1, identified; a timeout at 2.
  • The cancelled-read test of §10.3 (test_a_cancelled_read_does_not_disturb_the_next) failed once in the first full run, under trio: the read after the cancelled one needed one retry, and returned the right words. Run on its own 14 more times (140 trials, asyncio and trio) it never recurred. One retry in about 150 trials is read as the background loss of §6.3; stale data was never accepted.

12. Recording on the bench (2026-09-28, night)

The recorder, the sinks and the recording commands, read-only, on COM8, station 1, with the library defaults. anymodbus 0.3.0, anyserial 0.2.0, anyio 4.15.1, pyarrow 25.0.1, Python 3.13, Windows 11.

  • The hardware tests. With test_hardware_recording.py added (a 5 Hz recording, pipe() to CSV and Parquet, fuji-stream, fuji-capture and fuji-diag timing), 52 of 52 pass, under asyncio and trio.
  • A 60-second capture at 1 Hz (fuji-capture ... --reconnect under scripts/soak_monitor.py): 60 polls, none late, dropped or failed, 131 requests with no retries. The two failed attempts are the exception replies of identification's probes of the capabilities firmware 1.02 lacks. Intervals between samples had a median of 1.003 s and a maximum of 1.015 s, and the worst start of a poll was 16 ms late: the Windows timer. Every row carried CH1-3 with the asserted gases, label_source "asserted" and state "ok". scripts/check_soak.py passed every check. The capture's process tree used 62-63 MB.
  • The analyzer's clock read 21:05:12 when the host's local time was 21:11:40: still about 6.5 minutes behind.
  • fuji-diag timing, 800 trials (50 per pairing and gap, seed 20260928), took 40 s and agrees with §6.3. The one failure was a timeout at a 0 ms gap (exception, then exception); every gap of 1 ms or more succeeded. A second read's round trip was 2.6-19.9 ms (median 13.4). In 15 trials the busy-wait overran its gap by more than 5 ms, up to 29 ms, when the thread was descheduled; each trial records the gap it actually had. Raw file probe_out/diag_timing_20260929.json (git-ignored).
  • The 24-hour recording (design §12) started at 02:06 UTC on 2026-09-29: fuji-capture at 1 Hz to Parquet with --reconnect, under scripts/soak_monitor.py (probe_out/soak_20260929.*, git-ignored). Its results are in §12.1.

12.1 The first 24-hour attempt (2026-09-29): stopped by a kill after 10 h 17 min

How it ended. The recording's window was opened by a non-interactive process with cmd /c start, and every process in a window opened that way inherits that process's "ignore Ctrl-C" flag. Ctrl-C in the window therefore did nothing, and at about 12:30 UTC the window was closed, which killed the recording. A test window opened the same way reproduced it with the simulated analyzer: Ctrl-C was ignored, and Ctrl-Break killed every process in the chain, leaving a 4-byte Parquet file. A Parquet file is readable only once its footer is written, so this one was not, and its .meta.json still said recording, with no counters.

What was recovered. The 37 row groups written before the kill were intact. scripts/recover_parquet.py rebuilt the footer: 37,000 rows, 02:06:24 to 12:23:03 UTC, ending exactly at the last byte written. The rows after 12:23:03, which were waiting for their row group, and the final counters are lost.

The recovered rows (scripts/check_soak.py; tick counts and error accounting cannot be judged without counters):

Check Result
Readable output 54 columns, 37,000 rows
Failed polls none; no disconnect
Gaps none: 37,000 polls in 36,999 s, every interval 0.92–1.08 s (median 1.0026 s)
Start of a poll after its slot 0–16 ms (the 15.6 ms Windows timer), not accumulating; 16 polls over 20 ms, 4 over 50 ms, worst 87 ms
Round trip of the concentration block median 48.5 ms, 99.9th percentile 53 ms, worst 0.19 s
Status and provenance every row CO2 / CO / O2, asserted, state ok, valid, no hold, alarm or analyzer error
Process tree 0.5 % of one core; 310–312 handles throughout
Resident memory a 25 MB sawtooth, one tooth per row group written; the fitted trend after the first hour is +0.92 MB/h (+8.6 MB over 9.3 h), within the 20 MB bound
Shutdown failed: killed

The readings barely moved (CO2 −0.12 to −0.10 vol%, CO −0.010 to −0.004 vol%, O2 20.22–20.28 vol%), as expected of an idle analyzer in the calibration state of design §13.3. The wall clock gained 0.2 s on the monotonic clock over the run.

Whether the memory trend is a leak is open. The monitor logged only resident memory, which on Windows is the working set that the system trims and refills. It now logs private memory too, and check_soak.py judges that by the fitted trend over the whole run. The rerun decides it: see §12.3.

What changed because of it (design §12, §13.1 #57): soak_monitor.py turns Ctrl-C back on for the command it starts; the recording commands stop cleanly on Ctrl-Break too; fuji-capture rewrites its .meta.json every minute with the counters so far, and writes its progress line from a worker thread, so a console that stops taking output cannot hold up the recording; recover_parquet.py joins the soak tools. The same test window then stopped cleanly on Ctrl-C (in 0.1 s) and on Ctrl-Break, with the Parquet file readable and the exit logged. Ctrl-Break still ends uv run itself at once (exit code 0xC000013A), but not the recording under it. On the bench, a rerun started by double-clicking its .cmd and stopped with Ctrl-C after 26 s ended as stopped, with 27 rows in a readable Parquet file and the monitor's exit logged.

12.2 The unplug test (2026-09-29)

The procedure of docs/hardware-test-day.md, read-only, on COM8, with the owner pulling the adapter's USB plug.

With --reconnect (13:07:52–13:17:51 UTC, 600 s at 1 Hz, probe_out/unplug_20260929.csv): the plug was pulled twice, for about 30 s each time as the procedure asks (not timed). The capture finished (exit 0, state finished) with 600 polls, none late or dropped, 108 failed, 2 disconnects and 2 reconnects, and every tick kept its slot: intervals 0.945–1.033 s, worst start 22 ms late. The CSV has 600 rows, and its 108 error rows are the two outages:

Outage Polls failed First failure Then Polled again
1 53 (13:09:56.8–13:10:48.7) FujiConnectionError: bus stream was closed while reading the reply 52 refusals: the connection to COM8 failed 13:10:49.7, 53 s after the first failure
2 55 (13:11:43.7–13:12:37.7) FujiConnectionError: [WinError 5] Access is denied while resetting the input buffer 54 refusals 13:12:38.7, 55 s after the first failure

The first failure of a pull depends on where the poll was when the adapter went: reading a reply, or starting a request. The port came back as COM8, and the reopened analyzer passed the same-analyzer check. Each of the 492 successful rows has the asserted labels and state ok. The outages outlasted the pulls by roughly 20–25 s, which fits Windows bringing the adapter back plus the wait for the next attempt of the back-off (0.5, 1, 2, 5, 10, then every 30 s); the attempts themselves are not logged, so this is not measured.

Without --reconnect (13:18:21–13:19:55 UTC, probe_out/unplug_20260929_noreconnect.csv): at the pull the capture ended with state failed and the error FujiConnectionError: poll: [Errno 13] stream failed while resetting the input buffer: [WinError 5] Access is denied.. The CSV and its .meta.json are complete up to the failure: 95 rows (94 polls, then the failed one), polls 95 and failed_polls 1. Its disconnects was 0: the summary counted only the outages a ReconnectPolicy rides out. It now counts the failure that ends a recording too (design §13.1 #48).

Both runs' .meta.json record fujilib as 0.1.0.dev33+g7a99f2771.d20260929: the editable install's version was built before the day's commits. The code was that of 47e9dfa.

12.3 The 12-hour recording (2026-09-29/30)

The long recording of design §12 Phase 5 (#58), read-only, on COM8: fuji-capture COM8 --gas CH1=co2 --gas CH2=co --gas CH3=o2 --rate 1 --duration 43200 --reconnect to Parquet, under scripts/soak_monitor.py --every 600, started by double-clicking its .cmd. It ran from 18:56:48 UTC on 2026-09-29 to 06:56:48 UTC on 2026-09-30 and ended on its duration, exit 0. The code was a16ea77 (0.1.0.dev47+ga16ea770f), with anymodbus 0.3.0, anyserial 0.2.0, anyio 4.15.1, pyarrow 25.0.1, Python 3.13.13 and Windows 11; the files are probe_out/soak_20260929c.* (git-ignored). scripts/check_soak.py passed every check:

Check Result
Readable output 54 columns; 43,200 rows in 44 row groups (43 of 1,000, and the last 200 written at close); 1.9 MB
Clean shutdown finished, no error, the final counters in the .meta.json
Tick and row counts 43,200 polls of 43,200 ticks; none late, dropped or failed; no disconnect or reconnect
Error accounting no error rows. 86,411 requests (two a poll and 11 at the start), no retries; the two failed attempts are identification's probes (§12)
Timing no gaps: every interval 0.939–1.059 s (median 1.0013 s); no poll started more than 22.5 ms after its slot
Round trip of the concentration block median 48.8 ms, 99.9th percentile 53.9 ms, worst 62 ms; the median of each hour 48.8–48.9 ms
Status and provenance every row CO2 / CO / O2, asserted, state ok, valid, with no hold, calibration, alarm or analyzer error
Process 226 s of CPU in 11.8 h (0.5 % of one core); 309–314 handles
Memory private memory: a sawtooth of about 25 MB, one tooth per row group; the fitted trend after the first hour +1.47 MB/h, +15.9 MB over 10.8 h, within the 20 MB bound

Nine rows have a round trip shorter than a reply takes (0.1–31 ms). anymodbus stamps a request as sent when its send and drain return, and in those nine polls the host held the task there for 20–75 ms: part or all of the reply had arrived by the time the request was stamped. Their requested_at is late, their latency_s short, and their t_utc, the midpoint, up to about 55 ms late; every row's t_utc still lies between the request going out and the reply ending. §12.1 showed the same stalls from the other side: five round trips over 100 ms (the worst 0.19 s) and none short, where the host held the task while the reply was read. The recorder's drift, taken as a poll starts, is not affected.

The readings barely moved: CO2 −0.10 and −0.09 vol%, CO −0.009 to −0.005 vol%, and O2 20.80 vol% at the start, then 20.67–20.70 from the first hour on, above §12.1's 20.22–20.28 since the O2 zeros and spans of §14 and §16. The wall clock gained 0.24 s on the monotonic clock, steadily, as in §12.1. The analyzer's clock was 6 min 37 s behind the host's; it has read 6 min 27 s to 6 min 37 s behind since 2026-09-28.

The memory trend was the Parquet sink. The low point of each 2 hours rose steadily, 44, 48, 52, 55, 56 and 58 MB, so the growth was real, not the sawtooth: about 400 B per poll, more than §12.1's +0.92 MB/h of resident memory. fuji-capture, run in-process on the bundled register bank at about 30 polls a second for 25 minutes, found where. Private memory, fitted after the first 5,000 polls of each run:

Run Private memory
A write per poll, as pipe() makes them at 1 Hz, with the sink as recorded +1,459 B a poll
The same with Arrow's system allocator instead of mimalloc (28,000 polls) +1,510 B a poll
Writes of about 30 rows, with tracemalloc +521 B a poll, of which Python objects +5 B
A write per poll, the waiting rows kept as rows and made into one Arrow table per row group +46 B a poll

The simulated analyzer keeps every exchange it answers, about 0.75 KB a poll, which hid everything else at first; it was capped at 64 exchanges for these runs. Python objects did not grow, and Arrow's own pool never held more than 5 MB: what grew was native memory, never given back, from making each write's row or two into its own Arrow table, with either allocator. The sink now keeps the rows waiting for their group as rows and makes one Arrow table per row group, and closing writes the footer even when the last rows cannot be written (design §13.1 #87). The fixed sink itself, a write per poll for 15 minutes, grew +26 B a poll over 28,000 polls. The owner accepted this recording as the hardware exit with the fix shown offline, not rerun on the bench.

Phase 5's hardware exit is met.

13. Writes on the bench (2026-09-29)

The stateful session of docs/hardware-test-day.md, authorized by the owner, who was at the front panel, 15:40–16:03 UTC. It ran on COM8, station 1, with the library defaults. anymodbus 0.3.0, anyserial 0.2.0, anyio 4.15.1, Python 3.13.13, Windows 11. The code was c4bf0e7 with the settings-and-commands work uncommitted on top; the editable install reports 0.1.0.dev40+gc4bf0e720.

Nothing started a calibration, a blowback or a key simulation. The session made 37 setting writes and 3 return-to-measurement commands, and restored every setting it changed. The 162 settings saved first (fuji-configure dump, probe_out/settings_before_20260929.json) all matched at the end: fuji-configure diff gave write: none, refused: none, unchanged: 162. The raw files named below are in probe_out/ (git-ignored).

13.1 The hardware tests

  • Read-only. 52 of 52 pass, in 91 s. The 23 skips are the uvloop variants, which cannot run on Windows.
  • Stateful (-m hardware_stateful, asyncio): 9 of 10 pass, in 14.5 s (stateful_tests_20260929.log):
  • FC06 and FC10 wrote hold.ch4.value the same way;
  • the hold value, response time and span gas of channels and components the unit lacks (Ch5, NDIR 4) were written, verified and restored;
  • the O2 response time went 15 → 16 → 15 s, and output hold and hold mode were switched and restored;
  • a settings document and its baseline applied ok, and the diff after them was empty;
  • the refusals sent no request;
  • return to measurement came back done.

No test's fixture found a setting left changed. The range test failed (§13.2).

13.2 The current range follows a range write a little later

test_selecting_the_other_range wrote range 2 to Ch3 (40108). The write read back as written, so the result was verified. But the Ch3 current range (30040), read next, still said range 1, and the assertion failed. The test's finally put range 1 back, and the settings then matched the saved ones.

With the owner's approval, two follow-up probes repeated the change, with the owner watching the panel. Each wrote through set_range and restored range 1:

  • A timing check (probe_range_timing_20260929T154510Z.json). The owner saw the O2 range switch to 0–25 vol% and back. The first read after each set_range returned showed the new range. Each call took 0.28–0.30 s: the status, range and setting reads, the write and its read-back.
  • Three round trips with back-to-back reads (probe_range_lag_20260929T154606Z.json). After the first switch to range 2, two reads still showed range 1, and the third showed range 2, 70 ms after the call returned. In the other five legs, the first read (about 30 ms after) already showed the new range.

So the analyzer applies a range change, but its current-range register can lag the verified setting by some tens of milliseconds. A single read straight after the write can see the old range. Both lags were on the first switch to range 2 of a run, but nine legs are too few to say whether that matters.

What changed because of it (design §13.1 #70): a range write now returns only once the channel's current range shows the range written, read within the read-back budget, and raises FujiVerificationError if it never does. The simulator switches the current range after a configurable lag.

13.3 Key lock does not stop Modbus writes (design §13.2 #12)

The owner switched key lock on at the panel, and 40074 read 1. Then scripts/probe_write.py key-lock (probe_write_key-lock_20260929T155103Z.json):

  • write_parameter("hold.ch5.value", 37) was acknowledged and verified: 37 read back.
  • Return to measurement was acknowledged, with outcome done. The panel was already on the measurement screen, so this shows the command is not refused under key lock, but not that it would close a menu.
  • The register was restored to 0.

Key lock guards the panel against the operator, not the settings against a program: fujilib's writes go through while it is on.

13.4 A menu at the panel

The owner switched key lock off (40074 read 0) and opened a menu. The status showed the parameter-setting screen (7).

  • fuji-configure apply of a one-setting document (hold.ch5.value 37) was refused before anything was sent (menu_apply_20260929.log). It exited 1 with status: failed, written: none and the error "apply_settings refused, nothing was written: the front panel shows the parameter setting screen". hold.ch5.value stayed 0.
  • return_to_measurement(confirm=True), with the menu still open, was acknowledged in 13 ms. The status after it showed the measurement screen (done), and the owner saw the menu close (probe_menu_return_20260929T155307Z.json).

13.5 Settings survive a power cycle (design §13.2 #14)

persist-write wrote 37 to hold.ch5.value (it was 0), verified, at 15:53:25 UTC. The owner switched the analyzer off, waited 10 s, switched it on and waited for the measurement screen. persist-check at 15:55:27 read 37: kept. It then restored 0. There was no save step, so a setting written over Modbus goes to non-volatile memory. No manual gives that memory's write endurance (probe_write_persist-write_20260929T155325Z.json, probe_write_persist-check_20260929T155527Z.json).

13.6 Values out of range are stored (design §13.2 #32)

out-of-range wrote to response_time.ndir4 (15 s) through the client, past the library's 1–60 s limit (probe_write_out-of-range_20260929T155844Z.json):

Written Reply Read back
61 acknowledged, no exception 61
0 acknowledged, no exception 0

Each was restored to 15. The analyzer neither refuses nor clamps: it stores the value. fujilib's own limits are the only guard, and the simulator, which stores a write as sent, already behaves this way. Only this one register was tried, on a component the unit does not have.

13.7 The schedule start time is not on this unit's panel (design §13.2 #26)

The owner could not find the auto-calibration settings. The user-mode menu lists only Switch Ranges, Calibration Parameters and Parameter Setting. The ZPA manual's menu tree (p.21) marks alarm setting and auto and auto zero calibration as optional, and the peak-alarm menu is missing too. This agrees with the type code, which lists none of these options (§2). The panel therefore cannot show the start time, and the encoding stays unconfirmed. The three schedules still read day 0, hour 0x000C and minute 0.

13.8 The stateful tests again, with range writes followed (2026-09-29)

The owner authorized a rerun once a range write waited for the channel (§13.2), and it ran at 16:52 UTC with the same code otherwise. A fresh dump (settings_before_20260929b.json) came first. 10 of 10 stateful tests pass, in 14.8 s (stateful_tests_20260929b.log). The range test took 1.28 s, against 0.88 s before. The diff against the settings saved at the start of the session showed nothing to write, with 162 unchanged.

14. A manual calibration at the panel (2026-09-29)

The owner calibrated the O2 channel at the front panel, 17:32–17:38 UTC, while scripts/probe_calibration.py watch read the analyzer. That probe sends only read function codes, so fujilib wrote nothing and pressed no key. The questions were:

  • whether the analyzer keeps any trace of a calibration in the registers it answers but the manual does not document, which could stand in for the calibration log that firmware 1.02 lacks (design §2.11, §6.5);
  • what the panel and status registers do during a manual calibration.

Setup: COM8, station 1, anymodbus 0.3.0, anyserial 0.2.0, anyio 4.15.1, Python 3.13.13, Windows 11. The probe took a full snapshot of every readable region (1,379 words: FC04 0000h–00C1h and 03E8h–0479h, FC03 0000h–00ABh, 03E8h–069Bh and 0BB8h–0C66h) twice before the first calibration and 20 s after each one. In between, it read FC04 0000h+61, 0083h+60 and 03E8h+49 every 0.5 s: 661 reads, none lost after retries. The raw files are in probe_out/calwatch_20260929T173222Z/ (git-ignored).

Before it, Ch3 was set to zero mode "each" and calibration range "current", so a zero or span of Ch3 touches Ch3's range 1 only. Its range 1 calibration gases are 0.00 (zero) and 20.95 vol% (span). Ch1 and Ch2 are set to "at once". Output hold and key lock were off.

The owner made three passes. At the panel, each is ZERO or SPAN, the cursor to the channel, ENT to select it, and ENT again to start the calibration:

UTC Keys Steps (30182) O2 before → after
17:32:49–17:32:54 ZERO, DOWN, ENT, ENT 4 → 5 → 6 → 0 −0.12 → 0.00 vol% on zero gas
17:34:38–17:34:43 SPAN, ENT, ENT 7 → 8 → 9 → 0 20.69 → 20.95 vol% on span gas
17:37:39–17:37:44 ZERO, ENT, ESC 4 → 5 → 0 20.95 → 20.95: cancelled, nothing calibrated

No calibration error followed any of them, and Ch1 and Ch2 read −0.09 and −0.006 vol% throughout.

14.1 Nothing in the holding registers changed

Across all three passes no FC03 word changed: not the 172 user settings, and not one of the 867 words of the two undocumented factory blocks. The zero and span are kept somewhere the Modbus map does not reach. The words that did change, outside the live readings, clock and A/D values, are two undocumented input words (§14.2) and the manual-calibration cursor.

14.2 Two undocumented display words

The manual marks 30184–30188 "do not use" and lists nothing at 30190. Two of those words follow the panel:

  • 00BDh (30190) is the key being pressed, in the codes of the key register 42001. It read 64 as ZERO was pressed, 8 for DOWN, 32 for each ENT, 128 for SPAN and 16 for ESC, each for one read only, then 0. The 16 in the capture of 2026-09-28 (§4.3) was therefore an ESC at the panel.
  • 00B9h (30186) follows the last calibration. It went to 0 when ENT selected the channel (the wait step), to 4 when the calibration started, and to 6 when it finished, and it stayed 6 until the next channel was selected. The cancelled pass left it at 0. The 6 in the register capture of 2026-09-28 (§4.3), with the cursor on Ch3, fits an O2 calibration made at the panel before it. Its value after a calibration error is not known.

14.3 The steps and flags

  • The screen register (30181) stayed 0, measurement, throughout, as the manual says for a manual calibration (TN5A1190a p.46). Only 30182 shows one.
  • The per-channel zero and span flags cover manual calibration. The Ch3 zero flag (30052) came on with the wait step, or one read later, and stayed on until the calibration ended. The span flag (30057) did the same. The cancel cleared the zero flag in the same read as the step. The manual does not say whether these flags include manual calibration.
  • ESC from the wait step goes straight back to measurement, not to channel selection.
  • The cursor (30189) kept its channel. ZERO opened with the cursor on Ch1, one DOWN put it on Ch3, and the next ZERO and SPAN opened on Ch3. The "at once" pair Ch1 and Ch2 took a single cursor position.
  • A calibration takes one to three seconds from the second ENT: 1.6 s to the measurement screen for the zero, 2.4 s for the span. The span's new value (20.94) showed while the step still read "running".
  • The analyzer did not answer for about a second while the zero ran. Two attempts at one read went unanswered (0.5 s timeouts each); the third was answered, with the step back at 0. Nothing went unanswered during the span.

14.4 The O2 detector's raw count

A/D value No. 4, adc.input5 (03F7h), is the O2 detector. It read 636–637 on zero gas and 3351–3353 on span gas, and a calibration did not change it: the zero moved the reading from −0.12 to 0.00 at 636 counts, and the span from 20.69 to 20.95 at 3352. So the count is the detector's raw signal, before the calibration is applied.

  • That is about 130 counts per vol%, one count about 0.008 vol%, against the Modbus reading's step of 0.01 vol%. It is not a finer O2 measurement.
  • While a gas was steady, it varied by one count, and the reading by one step at most.
  • Recorded at each calibration, with the reading before it, it gives what firmware 2.24's calibration log keeps per record (design §2.6): a detector count and the deviation.

When the gas changed from zero to span gas, the O2 reading rose from 1.34 to 20.07 vol% in 18 s and reached 20.68 about 36 s after the change. It then stayed within 0.01 vol% until the calibration, with the response time at 15 s.

14.5 Not answered here

  • What the flags, 00B9h and the step do after a calibration error (step 10), and whether ENT there forces the calibration as the manual says.
  • A zero of the "at once" pair: whether both channels' flags are set.
  • A channel set to "both", which should calibrate both ranges.
  • Output hold on: whether the Modbus readings and the A/D values freeze during a manual calibration (ZPA p.64 says the readings do).
  • Whether a key written to 42001 acts, and shows in 00BDh, as a key at the panel.

§18 answers the last item, and the "at once" pair's flags.

15. What the registers left unexplained (2026-09-29)

A read-only session at 18:04–18:16 UTC set out to explain the words the analyzer answers but no manual describes (§4.1, §4.2), and to try the function codes it had never been sent. The owner walked the panel's menus and noted what the maintenance and factory screens showed, then switched the analyzer off and on.

The probe was scripts/probe_unknowns.py, which sends only read requests and the fixed diagnostic frames of §15.1. Its watch read the status, the display block (00B4h–00C1h), the clock and A/D block with the zero words after it (03E8h–0424h) and 046Ah–0479h every 0.3 s: 2,231 answered reads. It took a full snapshot of every readable region (1,379 words) twice before the walk, 15 s after the power cycle and at the end.

Setup: COM8, station 1, anymodbus 0.3.0, anyserial 0.2.0, anyio 4.15.1, Python 3.13.13, Windows 11. Raw files are in probe_out/, git-ignored: probe_functions_20260929T180428Z.json and unknowns_20260929T180435Z/.

15.1 Function codes the analyzer had never been sent

Request Reply
FC07 read exception status exception 01
FC08 sub-function 0000h, return query data exception 01
FC0B get comm event counter exception 01
FC0C get comm event log exception 01
FC11 (17) report server ID exception 01
FC14 (20) read file record, file 1, record 0 exception 01
FC18 (24) read FIFO queue at 0000h exception 01
FC2B/0E (43) read device identification, basic exception 01
  • Each reply took 12–14 ms (66 ms for the first). A normal read straight after them was answered.
  • FC08 was sent with sub-function 0000h only, since its other sub-functions restart or silence the link.
  • FC01 and FC02 answer 02 (§5), so the analyzer knows those two function codes but none of these.
  • The program version cannot be read over Modbus. The display at power-on is the only place it appears.

15.2 30182 numbers the menu pages

The screen register (30181) followed every menu the owner opened. The step register (30182), which the manual defines only for a manual calibration on the measurement screen (TN5A1190a p.46), numbered the pages of the menus:

Screen (30181) 30182
0 measurement, 1 menu, 2 range change, 3 calibration setting 0
7 parameter setting 0; 1 for one read as the maintenance password was confirmed
8 maintenance 0 on the item list; 1 sensor input; 2 error log; 22 and 23 calibration log; 5 while the factory password was entered
9 factory 0 on the item list; 26 A/D data; 38 coefficients; 50 other parameters; also 1, 2, 4, 8, 11, 12, 13, 18, 34, 35, 40, 44, 56, 57, 60, 61, 63, 65 and 78 (more of them identified in §17.1)
  • Several page numbers are calibration step values. Examples are 5 ("zero: wait") during the password entry, and 4 and 8 in factory mode. Only the screen register tells them apart: during a manual calibration it reads 0 (§14.3).
  • fujilib's calibration tracker read the step whatever the screen showed. Replayed through it, this session's log gave five calibration events: four cancelled and one ambiguous. Nothing was calibrated. It now reads 30182 as a step only on the measurement screen, and so does the decoder (design §13.1 #80). The same replay now gives no event, and §14's log still gives its zero, span and cancel.
  • The "do not use" words 00B7h, 00B8h, 00BAh, 00BBh and 00BFh–00C1h read 0 on every screen, as did 0419h–0424h.
  • 00BDh showed every key read, the password keys included. A program polling fast enough could follow a password entered at the panel.
  • The last-calibration word (00B9h) kept 6 through the menus, and the cursor (00BCh) kept Ch3.

15.3 The factory blocks: no calibration coefficients, and the "other parameters"

The two undocumented holding blocks (§4.2) did not change at any point:

  • through the menu walk;
  • through the power cycle;
  • through a second visit to factory mode after it.

All 1,039 holding words were the same at the end as at the start. The blocks are therefore not a copy that is refreshed at power-on.

The calibration coefficients are not in them. The factory "Coefficient" screen showed Ch1's zero and span coefficients (every channel's are in §17.2):

Range Zero Span
1 1.529192 0.661410
2 1.773113 0.305870

None of the four values appears in any readable region, in any of these encodings:

  • one word, or a long word with either word first, scaled by 10^3 to 10^6;
  • the reciprocal of the value, scaled the same way;
  • 32- and 64-bit floating point in each word order;
  • fixed point with 8 to 30 fraction bits.

With §14.1, the conclusion is that the analyzer keeps its zero and span where the Modbus map does not reach.

0C2Dh–0C34h are the factory menu's "other parameters", in the order the panel lists them:

Address Panel Word
0C2Dh zero limit 0 (off)
0C2Eh range limit 0 (off)
0C2Fh AO No. 4
0C30h language 1 (Eng)
0C31h zero gas 0 (Cylinder)
0C32h protocol 0 (MO)
0C33h varied range 1 (on)
0C34h DIO No. 0

The service manual (TN5A1191b p.29-30) describes the two limits:

  • Zero limit off: the display hides values below zero. The Modbus readings are negative all the same (−0.09 vol% CO2 throughout).
  • Range limit off: readings are not held at 110 %FS. Its default is on, and this unit has it off, so a reading far above full scale is reported as it is (§15.5).

The rest of the block holds these known parts:

  • a copy of every channel's ranges at 0BD9h–0BE2h (1000, 1000, 1000, 1000, 2100, 2500, 2000, 2000, 1000, 2500);
  • the type code and serial number as ASCII from 0C35h.

In the larger block, 0428h, 0448h, 0468h and 0488h each start a 16-point breakpoint table (0, 300, 600 … 20000). 03E8h and 0408h start two measured curves on the same 0–20000 scale.

The register capture of 2026-09-28 holds one wrong word. It gives 0440h as 13824. Every snapshot since reads 14000, which is the twelfth point of one of the four identical breakpoint tables; the other three read 14000 there. It was a bad read in the one-word scan, whose link timing was faulty (§6.2). Every other factory word of that capture matches.

15.4 The raw detector counts at 046Ah–0471h

The four long words are two values, each twice: the CO2 detector's count (at 046Ah and 046Ch) and the CO detector's (at 046Eh and 0470h). Each pair was equal in every read. O2 is not among them.

They are the unsmoothed counts that the maintenance "Sensor Input" screen shows. The A/D values at 03EFh on are a smoothed copy:

  • The owner read 69467 for Input 2 on that screen. On that screen the A/D value No. 1 read 69459–69463, while 046Eh read 69449–69473 and was 69467 in one of the reads.
  • At rest, over 1,573 reads, 046Ah changed in 75 % of them (standard deviation 2.3 counts). A/D No. 0 changed in 4 % (1.2 counts). For CO the figures were 90 % (4.8 counts) and 9 % (1.7 counts).
  • After the power cycle (§15.5), 046Ah led and A/D No. 0 followed about 10 s later: 54309 against 43959 at 18:13:05, and 63987 against 59906 at 18:13:18.
  • The factory "A/D data" screen showed 65695, 69461 and 3337 for Nos. 0, 1 and 4. That fits either set.

0472h–0478h are usually 0. For single reads, 0472h and 0474h read 1 together (47 of the 1,573), or 0476h and 0478h did (31), and once all four. They follow the CO2 and CO pairs above, but not the count's value. What they mean is not known.

Why each count appears twice is not known either. One per range is a guess: both channels have one range.

15.5 A power cycle

The owner switched the analyzer off for about 10 s.

  • It answered 11.8 s after its last reply before the switch-off, so within about 2 s of power-on (the moment was not timed). The first reads were answered with every reading 0.
  • 00B9h and the cursor (00BCh) came back as 0, from 6 and Ch3. They are not kept over a power cycle. Settings are (§13.5).
  • The readings were wrong for about a minute, and nothing said so. Neither the status, hold and error flags nor either error register was set:
UTC CO2 vol% CO vol% O2 vol%
18:12:52.9 0.00 0.000 0.00
18:12:55.9 21.95 1.046 20.58
18:13:05.1 11.3 0.451 20.63
18:13:18.8 1.91 0.090 20.65
18:13:31.1 0.22 0.010 20.65
18:13:43.1 −0.01 −0.003 20.66
18:13:59.5 −0.07, within 0.02 of where it settled

The first non-zero CO2 reading was 220 % of the 10 vol% range, and CO was over its range too; with range limit off (§15.3), neither was clamped. A recording would keep these rows with state ok (design §13.1 #81). The O2 cell read 20.58–20.66 vol% from its first non-zero reading. - The clock ran on: it read 14:06:16 local time at 18:12:52.9 UTC, 6 min 37 s behind, as before.

15.6 Firmware 1.02 has a calibration log at the panel

Maintenance mode has a calibration log on this firmware, although Modbus has none (1000h–1707h answer exception 02; §2).

  • The newest Ch3 entry read "S1 21.02 9 29 13 32": a span of range 1 at 13:32 on the analyzer's clock. 21.02 is a concentration, presumably the one shown before the span.
  • That is about 17:38:40 UTC, half a minute after §14's watch stopped. It explains why 00B9h read 6 when this session began.
  • The detector count of that entry was not noted.

15.7 Still not explained

  • 00B7h, 00B8h, 00BAh, 00BBh and 00BFh–00C1h, and 0419h–0424h: always 0 so far.
  • 0472h–0478h, and why each count at 046Ah–0471h appears twice.
  • Most of the two factory blocks, word by word.
  • Where the zero and span coefficients are kept. Not in the Modbus map, as far as any encoding tried shows. (What they are, and how O2's span follows from the A/D counts, is in §17.)

16. fujilib watching a calibration at the panel (2026-09-29)

The bench check of design §12 Phase 7A, 18:28–18:31 UTC. The owner calibrated O2 at the front panel, with zero gas and then span gas at the inlet, while examples/watch_manual_calibration.py ran fujilib's wait_for_manual_calibration on COM8. It polled every 0.5 s with the A/D block and sent reads only. The code was ab4073b with the Phase 7A work uncommitted on top, including the tracker fix of §15.2. The events are probe_out/watch_manual_20260929T182828Z.jsonl (git-ignored).

UTC At the panel Event O2 before → after Gas Deviation O2 count
18:29:44 ZERO, Ch3, ENT, ENT zero, completed, Ch3 range 1 −0.01 → 0.00 vol% 0.00 −0.01 634
18:30:47 SPAN, Ch3, ENT, ENT span, completed, Ch3 range 1 20.86 → 20.95 vol% 20.95 −0.09 3349
18:31:06 ZERO, Ch3, ENT, ESC zero, cancelled 20.95 → 20.95 vol% — — 3349
  • Each event came within about a second of the panel's return to measurement, and each rested on a read that showed the step running ("a read showed it running") or, for the cancel, on 00B9h still at 0 after the wait step.
  • The time of each calibration is bracketed by the last read on the wait step and the first that showed it running, half a second apart.
  • The O2 counts agree with §14.4 (636–637 on zero gas, 3352 on span gas).
  • One read went unanswered once during the zero and once during the span, as in §14.3. The retry answered each, and no poll failed.
  • The cancel's event first reported a deviation of 20.95, the reading on span gas against the zero gas, though nothing was calibrated. An event now reports deviations only when the calibration ran.

Phase 7A's hardware exit is met.

17. The factory-mode screens (2026-09-29)

At 18:41–18:44 UTC the owner opened seven factory-mode items in a set order and noted what each showed, changing nothing. scripts/probe_unknowns.py watch read the panel state throughout (496 reads) and took full snapshots before and after. No holding word changed. The raw files are in probe_out/unknowns_20260929T184115Z/, and the owner's record is probe_out/factory_screens_20260929.md (both git-ignored).

17.1 Which page number each item shows

In factory mode, 30182 (§15.2) gave each item its own number:

Item 30182
3. Ch Data 4
4. Option 65
5. Pressure 60
6. Linearization 8
7. Temperature 11
11. A/D Data 26
12. Others 50
13. Interference 34
14. Coefficient 38

Coefficient showed 38 here as in §15, and every item was opened in the order asked, so the list above is taken as right. The other numbers seen in §15 (1, 2, 12, 13, 18, 35, 40, 44, 56, 57, 61, 63 and 78) belong to items and sub-pages not identified. "10. Memory Access" did not open: ENT on it left the list shown.

17.2 What the items showed

  • 4. Option: alarm 0, autocal off, zero check off. These are the three user menus this unit lacks (§13.7): alarms, auto calibration and auto zero. The menus are off here, and the unit has no DIO board to drive valves or alarm contacts (DIO No. 0, §15.3).
  • 5. Pressure: a list of two tables, "pressure table" and "compensation table", with no live value. The type code orders no pressure compensation (digit 23 Y), and the pressure A/D input (No. 14, 7653–7657) reads like the inputs with nothing connected (7642–7658; the ground input reads 7641–7644), so no sensor is taken to be fitted.
  • 13. Interference: a list of three entries, Interference-1 to -3, not opened. It does not settle whether 00A4h–00ABh, four long words of 1,000,000, are the interference coefficients (§4.2).
  • 14. Coefficient, every channel and range. Ch1's was read at 18:09, before the O2 calibrations of §16; the rest at 18:42, after them:
Channel Range Zero Span
Ch1 CO2 1 1.529192 0.661410
Ch1 CO2 2 1.773113 0.305870
Ch2 CO 1 1.449359 0.390960
Ch2 CO 2 1.000000 1.000000
Ch3 O2 1 −099366 06.17320
Ch3 O2 2 +000000 10.00000

The O2 values are shown as printed, with no decimal point in the zero.

17.3 The coefficients

  • CO2 and CO are within the service manual's limits (TN5A1191b p.33): zero 0.5–5 and span 0.1–10 for an infrared component.
  • CO's range 2 reads 1.000000 for both, which looks like a range never calibrated. CO has one range. CO2's range 2 has values of its own although CO2 also has one range.
  • O2's range 2 (0–25 vol%) looks never calibrated: zero 0, span 10.00000. Its readings would then be wrong until it is zeroed and spanned. Range 1 is the one in use (§3).
  • The O2 span coefficient is 800 × span gas / (span count − zero count), in the A/D counts of No. 4 (§14.4):
  • §16's zero read 634 counts on 0.00 vol% and its span 3349 counts on 20.95 vol%. That gives 800 × 20.95 / (3349 − 634) = 6.1731, against the 6.17320 shown.
  • The reading follows as (count − zero count) × span / 800. At 18:41, 3339 counts gives (3339 − 634) × 6.1732 / 800 = 20.87 vol%, the O2 reading at that moment.
  • The factor 800 comes from the fit, not from a manual.
  • So the counts fujilib records with each calibration (design §13.1 #75) reproduce the analyzer's own O2 span coefficient.
  • The O2 zero coefficient, −99366, is outside the manual's −2,000 to 12,000 for O2, yet the zero raised no error. The manual's O2 figures evidently do not apply to this cell. It also expects 18,000–22,000 counts on zero gas, where this cell reads about
  • How −99366 relates to the zero count is not known. One value cannot say; the coefficient read again after another O2 zero, with that zero's count, would.

18. Front-panel keys written over Modbus (2026-09-30)

The key prototype of design §12 Phase 7B, 12:56–13:12 UTC. The owner authorized the session and stood at the front panel throughout. scripts/probe_panel.py wrote key codes to 42001 and 1 to 42002, and after each write read the panel until it settled, about 12 times a second.

  • What it sent: ZERO, SPAN, UP, DOWN, ESC, and the ENT that selects a channel. It never sent the ENT that starts a calibration, MODE or SIDE, and each key went only after a read of the step showed it allowed.
  • Nothing was calibrated. 00B9h stayed 0 and no step read "running".
  • Nothing else changed. All 162 settings read the same at the end as in the dump taken before the first key. No holding word changed while §18.4's flag was cleared.
  • The gases: zero gas at the inlet for the zero experiments and air for the span one, at the owner's choice. A calibration started by mistake would then have used the right gas.
  • Setup: COM8, station 1, anymodbus 0.3.0, anyserial 0.2.0, anyio 4.15.1, Python 3.13.13, Windows 11.
  • Raw files (git-ignored):
  • probe_out/probe_panel_<experiment>_20260930T*.json, one per run;
  • probe_out/calwatch_20260930T130351Z/, the read-only watch of §18.4;
  • probe_out/settings_before_keys_20260930.json, and probe_out/settings_diff_close_keys_20260930.txt for the closing diff.
UTC Experiment Written Steps (30182)
12:56:45 select-esc ZERO, ESC, SPAN, ESC 0 → 4 → 0, then 0 → 7 → 0
12:57:30 cursor ZERO, DOWN ×3, UP ×3, ESC; SPAN, DOWN ×3, UP ×3, ESC 4 and 7 throughout; the cursor in §18.2
13:01:14 zero-cancel ZERO, DOWN, ENT, ESC 0 → 4 → 5 → 0; Ch3's zero flag on at 5, off with the step
13:01:29 return-select ZERO, 42002 0 → 4 → 0
13:01:44 return-wait ZERO, DOWN, ENT, 42002 0 → 4 → 5 → 0; Ch3's zero flag stayed on (§18.4)
13:09:05 at-once ZERO, UP, UP, DOWN, ENT, ESC 0 → 4 → 5 → 0; the zero flags of Ch1 and Ch2 (§18.5)
13:10:37 span-cancel SPAN, DOWN, DOWN, ENT, ESC 0 → 7 → 8 → 0; Ch3's span flag on at 8, off one read after the step
13:11:25 key-lock ZERO, with key lock on none: key lock swallowed it (§18.6)

The experiments ran in the planned order except span-cancel. It was moved after the zero experiments so the gas changed once. The backlight and hold experiments were prepared and not run, at the owner's choice.

18.1 A key written to 42001 acts as the same key at the panel

  • Every write was acknowledged with the normal echo in 24–41 ms: 40 keys and two
  • There was no exception reply and no lost reply.
  • The analyzer did what the key does at the panel. ZERO and SPAN opened channel selection, UP and DOWN moved the cursor, ENT selected the channel and opened the wait step, and ESC went back.
  • The change was there by the first read after the reply, in every case but one. That read ended 105–132 ms after the write was sent, and reads came about every 82 ms. So a key takes effect well within a tenth of a second.
  • A flag can lag the step by one read. On the span's ESC the step read 0 at 112 ms, and the span flag cleared at 199 ms. Every other flag changed in the same read as the step.
  • 00BDh never showed a key written over Modbus. It read 0 in all 500 reads after the writes. The owner's presses at the panel in the same session showed in it as on 2026-09-29 (§14.2): 64, 16, 8 and 32, each for one read at 0.5 s. So 00BDh is the key pressed at the panel only, and a program cannot confirm its own key from it.

18.2 The cursor wraps round

On channel selection the cursor (30189) wraps at both ends:

  • For a span it offers Ch1, Ch2 and Ch3. DOWN from Ch3 goes to Ch1, and UP from Ch1 to Ch3.
  • For a zero it offers two positions: Ch1 and Ch2 together, which are zeroed "at once", and Ch3. The absent Ch4 and Ch5, also set to "at once", are not offered.
  • The pair's position reads Ch1 when reached going down (DOWN from Ch3, which wraps round) and Ch2 when reached going up (UP from Ch3). The owner saw Ch1 and Ch2 highlighted together both ways.
  • UP from the pair wraps round to Ch3.

Where ZERO opens. It usually opened with the cursor where it was left, as on 2026-09-29. Three times it opened on Ch1 instead:

  • the first ZERO of the session, after 18 hours without a key;
  • the probe's ZERO at 13:01:44, the first after a 42002;
  • the owner's ZERO at the panel at 13:04:49, the first after another 42002.

ESC never had that effect. Why it happens is not known. SPAN was never opened after a 42002 or a long pause. A program has to read the cursor after ZERO or SPAN; it cannot predict it.

18.3 ESC and 42002 from channel selection and the wait step

From ESC 42002
channel selection (step 4 or 7) the measurement screen the measurement screen
the wait step (5 or 8) the measurement screen; the channel's flag clears the measurement screen; the channel's flag stays set

Each went straight to the measurement screen, not to the step before.

18.4 42002 on the wait step leaves the calibration flag set

The 42002 at 13:01:48 returned the display to measurement, and 30182 read 0. The owner saw a normal measurement screen. But Ch3's zero flag (30052) stayed set:

  • It did not time out. It was still set at 13:07:45, six minutes later, beyond the gas flow time of 300 s.
  • A cancel from channel selection did not clear it. The owner pressed ZERO, which opened channel selection with the cursor on Ch1, and ESC (13:04:49–13:04:53).
  • A cancel from the wait step did. The owner pressed ZERO, moved to O2, pressed ENT once (the wait step) and then ESC (13:08:22–13:08:28). The flag cleared with the step. 00B9h stayed 0 throughout: nothing ran.

So 42002 closes the display, but not the analyzer's manual calibration. While the flag is set, fujilib reads the channel as calibrating, and refuses setting writes (design §13.1 #63). An operator would see nothing wrong at the panel. On an analyzer with calibration valves, the calibration gas might go on flowing; this unit has none, so that is untested. The way to cancel a manual calibration is ESC on its wait step, not 42002.

18.5 The "at once" pair

ENT on the pair's position set the zero flags of Ch1 and Ch2 (30050 and 30051). It did not set those of the absent Ch4 and Ch5, although they are set to "at once" too. ESC cleared both in the same read as the step. This answers the flags half of design §13.2 #37. Which ranges a completed "at once" zero changes still needs a real calibration.

18.6 Key lock swallows keys written over Modbus

With key lock switched on at the panel, a ZERO written to 42001 was acknowledged normally (25 ms), and nothing changed: the step stayed 0. Key lock does not stop setting writes (§13.3), but it does stop keys.

The analyzer then answered no read for 1.6 s:

  • the next read timed out on all three attempts;
  • the one after it timed out once;
  • then a late reply to the earlier request arrived, which anymodbus rejected and retried.

Reads were normal again 2.3 s after the key. What the display showed meanwhile was not seen.

18.7 Not answered here

  • Whether a key is swallowed while the backlight is off. The session's first ZERO acted after 18 hours without a key, but whether the backlight was off then was not seen.
  • Output hold during a manual calibration: whether the readings and the A/D values freeze (design §13.2 #38). This is prepared as probe_panel.py hold.
  • The error display (§13.2 #36) and a channel set to "both" (#37). Both need a real calibration.
  • Why ZERO sometimes opens on Ch1 (§18.2).

In passing: O2 read 21.08–21.09 vol% on air, at 3,367–3,369 counts. The span of 2026-09-29 at 18:30 (§16) set 20.95 at 3,349 counts, so the detector count had risen by about 19 counts (0.14 vol%) in 19 hours. Whether that was pressure or drift is not known.

19. A zero and a span driven from the host (2026-09-30)

The bench session of design §12 Phase 7C, 15:32–15:36 UTC. The owner authorized it, switched the gas valves and watched the panel; fuji-calibrate pressed the keys (design §6.5). The code was phase-7c after the independent review's fixes, with 2,985 unit tests passing. The answer at each Calibrate CH3 now? was passed on from the owner: no for the cancel, yes for the zero and the span, once the owner had said the gas was flowing. The records are probe_out/remote_zero_1.json, remote_zero_2.json and remote_span_1.json, and the settings before the session probe_out/settings_before_remote_cal.json (all git-ignored).

Before the first key, and read-only: nothing else had COM8 open; Ch3 was set to zero "each" and calibration range "current", with range-1 calibration gases 0.00 and 20.95 vol%; key lock and output hold were off; the plan of a zero of CH3 listed CH3 range 1 alone; O2 read 20.62 vol% on air, with no error.

UTC Run Gas Keys O2 before → after Deviation O2 count Status
15:32:14 zero, answered no N2 ZERO, DOWN, ENT, ESC 0.04 → 0.04 vol% — 640 cancelled
15:33:07 zero N2 ZERO, ENT, ENT 0.05 → 0.00 vol% +0.05 641 completed
15:35:30 span air, 20.95 vol% SPAN, ENT, ENT 20.89 → 20.95 vol% −0.06 3,369 completed
  • Every key was taken as it was on the 30th's prototype (§18.1): the panel showed it by the first read, 0.15–0.33 s after the write was sent, and each was acknowledged. The two calibrating ENTs showed running 0.16 s after they were sent.
  • The cursor. The first ZERO opened on the "at once" pair (Ch1), and one DOWN took it to Ch3. The next ZERO, and the SPAN, opened on Ch3, where the run before had left it, so no DOWN was sent.
  • The gas was steady from the first read of each wait step, since the owner had switched it before the run: O2 moved 0.00–0.05 %FS over the 30-second window, 0.2–0.3 %FS from the gas named. The rule called it steady 30.4–30.9 s after the wait step opened, its shortest. How long a gas takes to settle after the valves change is therefore not in these records; §14.4 has one such curve (O2 within 0.01 vol% about 36 s after the change, response time 15 s).
  • The calibrations. Each ran between two reads 0.2 s apart (the zero at 15:33:39.66–.87, the span at 15:36:02.03–.24) and was back on measurement 1.1 and 2.4 s later. As on 2026-09-29 (§14.3), the analyzer did not answer one read while it stored each, the zero and this time the span too; the retry answered. No calibration error followed.
  • The cancel reached the wait step, then ESC returned to measurement with the zero flag clear, and 00B9h stayed 0: nothing ran. The cleanup of each run found the panel clean and did nothing.
  • At the close every one of the 162 settings read as before the session, and the panel was on measurement with no calibration or hold flag set and 00B9h at 6.

The O2 counts fit §17.3's span coefficient, 800 × gas / (span count − zero count): 800 × 20.95 / (3,369 − 641) = 6.1437, where the calibrations of 2026-09-29 gave 6.1731. The factory Coefficient screen was not read this time, so whether the analyzer now shows 6.1437 is not known.

Design §12 Phase 7C's hardware exit is met.

20. The response time, and a response time of 0 (2026-10-01)

The owner asked what a response time of 0 does, and whether it differs from 1 s. scripts/probe_response.py ran at 12:23–12:33 UTC on COM8, with the owner's authorization for the session. It set the response times of the three live components (40076, 40078 and 40084) to 0, 1, 0 and 15 s in turn, and put back what it had found. Each stage, and one before and one after at the setting found, lasted 90 s. Through every stage it read the Ch1–Ch3 readings, A/D values No. 0–4 and the counts at 046Ah–0471h about ten times a second: 5,550 reads of each, none failed. The gas at the inlet was at rest, with O2 at 20.59 vol%. The first 20 s of each stage are left out of the figures. Raw files: probe_out/probe_response_20261001T122358Z.jsonl and .json.

The three response times read 1 s when the session began. They read 15 s on 2026-09-29 (§13) and had been changed since, not by a probe. They were left at 1 s.

20.1 The analyzer takes 0 on a live component

Each write of 0 was acknowledged and read back as 0, as on the absent component of §13.6. No reading jumped when a setting changed, in either direction, and no read went unanswered. Those writes went through the client, past the library's limit of the time. Once fujilib took 0 (§20.8), the runs of 12:50 and 13:00 UTC wrote it with write_parameter, which read each one back.

20.2 A response time of 0 switches the filter off

The scatter of each value (standard deviation, in counts), and how often the A/D value equalled the count read about 30 ms after it:

Stage Setting A/D No. 0 (CO2) 046Ah (CO2) A/D No. 1 (CO) 046Eh (CO) No. 0 = 046Ah No. 1 = 046Eh
before 1 s 0.59 0.90 2.46 3.70 44 % 11 %
1 0 s 0.98 0.98 4.17 4.15 77 % 69 %
2 1 s 0.60 0.92 2.81 4.30 41 % 10 %
3 0 s 0.89 0.88 4.12 4.16 84 % 77 %
4 15 s 0.32 1.19 0.51 4.38 37 % 9 %
after 1 s 0.75 1.02 2.93 4.20 37 % 9 %
  • At 0 s the A/D value is the count. The two scatter alike, change in the same share of reads (51 % and 50 % for CO2, 69 % and 68 % for CO in stage 1) and are equal in 69–84 % of reads. The rest fits the count moving between the two reads.
  • 1 s is still a filter. It takes CO's scatter from about 4.2 counts to 2.5–2.9, about half the variance. What kind of filter is in §20.7.
  • At 15 s CO's A/D value scatters 0.51 counts against the count's 4.38.
  • The counts at 046Ah–0471h do not follow the setting: 0.88–1.19 and 3.70–4.38 counts at every setting. So the smoothing of §15.4 is the response-time filter, and the A/D values come after it.

20.3 O2 has no count before the filter

A/D No. 4 follows the setting like No. 0 and No. 1, and nothing at 046Ah–0471h is O2 (§15.4).

  • At 1 s and 15 s it read 3323 in every read of stages 2 and 4 and of the last. In the first stage, at 1 s, it changed 8 times in 70 s.
  • At 0 s it moved between 3323 and 3324, changing in 31 % and 11 % of reads.
  • The O2 reading moved with it, between 20.59 and 20.60 vol%, in the same reads: one step of the reading for one count.

So an unfiltered O2 value exists only with the response time at 0, and the reading is then as fine as the count (§14.4).

20.4 The CO2 and CO readings did not move

Ch1 read −0.08 vol% and Ch2 −0.005 vol% in every read at every setting. At rest their noise is below one step of the reading, so this run shows nothing of the filter in them.

20.5 How often the values are renewed

The counts, and at 0 s the A/D values, changed in half to two thirds of consecutive reads 100 ms apart, and most changes were one read apart. So the analyzer renews them at least about as often as they were read. Faster reads would be needed to time it.

20.6 A change of gas at each setting

A second run, 13:00–13:12 UTC, with the owner at the gases (probe_out/probe_response_20261001T130002Z.jsonl and .json). Six stages of 120 s: 1 s as found, then 0, 1, 0 and 15 s, then 1 s again. In each the probe told the owner to change air for nitrogen 15 s in and back to air 65 s in. 7,298 reads, none failed. Only O2 has gas connected, so the figures are A/D No. 4's. Air read 20.91 vol% at 3,364–3,365 counts and nitrogen 0.00 vol% at 640–642.

Stage Setting Fall, 10–90 % Fall, steepest Rise, 10–90 % Rise, steepest 50 % after the cue: fall, rise
before 1 s 5.97 s 594 counts/s 6.51 s 707 counts/s 12.9 s, 11.0 s
1 0 s 5.87 s 677 6.35 s 728 11.6 s, 11.0 s
2 1 s 5.90 s 616 6.50 s 730 12.0 s, 11.0 s
3 0 s 5.75 s 695 6.48 s 752 11.4 s, 10.6 s
4 15 s 13.18 s 210 13.34 s 207 19.4 s, 18.8 s
after 1 s 5.92 s 625 6.45 s 717 11.8 s, 10.8 s

The steepest slope is over half a second. The times after the cue include the owner's hand on the valve, so they compare only roughly between stages; the 10–90 % times and the slopes do not depend on it.

  • The gas path is most of the response. With the filter off the count first moved 8.4–9.0 s after the cue and took another 5.8–6.5 s from 10 to 90 %. It was within 5 % of the new gas 17–18 s after the cue and within 2 % at 19.5–21.5 s.
  • 1 s against 0 s is about a tenth of a second on the 10–90 % time, 5.90–5.97 against 5.75–5.87 s falling and 6.45–6.51 against 6.35–6.48 s rising, and about a tenth less on the steepest fall. That is the size of one read.
  • 15 s more than doubles it: 13.2–13.3 s from 10 to 90 %, a third of the slope, and the 50 % point 6–8 s later.
  • The reading follows the count. Ch3's curve crossed every level within 0.15 s of A/D No. 4's, at every setting. Nothing else filters between them.

20.7 The filter is a moving average as long as the setting

Each 0 s step of §20.6, averaged over a window and shifted in time for the best fit, against the 15 s step (rms difference in counts, of a step of 2,724):

Filter of the 0 s step Fall Rise
none 293–301 268–285
moving average, 14 s 23–24 21–22
moving average, 15 s 3.4–4.9 4.1–4.7
moving average, 16 s 19–20 19–20
one pole, best time constant (5.5 s) 94 90

The same on §20.2's noise, with no shift: the CO count at 046Eh averaged over a window, against A/D No. 1 (rms difference in counts).

Setting Count as read Averaged over the setting Next best window Best one pole
1 s (three stages) 3.3–4.1 0.58–0.68 over 1.0 s 0.71–0.80 over 1.2 s 1.00–1.28 at 0.7 s
15 s 4.2 0.52 over 15 s 0.59 over 18 s 0.67 at 10 s
  • The value is the mean of the last N seconds, N being the response time. It is not an exponential filter: one fits about twenty times worse on the step.
  • So a step is 90 % through 0.9 N s after it reaches the detector, which is the manual's "response time (for 90 % FS response)" (ZPA p.94), and the value lags a steady change by N/2 s.
  • With N = 1 the mean is over 1.0 s; 0.8 and 1.2 s fit worse.

20.8 What this changes, and what it leaves

fujilib now writes a response time of 0–60 s (design §13.1 #107); it kept to 1–60 s, the instruction manual's limit, before.

Not answered here:

  • A change of gas on CO2 or CO, which have no gas connected. Their A/D values follow the setting as O2's does (§20.2).
  • What the panel's Response Time screen shows for 0, and whether it can set it.
  • A power cycle with a response time of 0. Settings are kept (§13.5); this one was not tried.
  • Whether the analog outputs follow the setting in the same way.