fujilib.testing¶
Everything needed to develop and test without hardware (design §10): the family's arrow-format frame fixtures, builders for synthetic readings and frames, a simulated ZP analyzer on a real serial port pair, and the sanitized bench register bank.
The builders are for code that consumes fujilib's models rather than the line: an application's adapter, a sink, a simulator of its own. A built frame becomes the sample and the row a recording produces:
from fujilib import ChannelId, Gas, ReadingState, Sample, sample_to_row
from fujilib.testing import frame, reading, status
held_o2 = reading(
ChannelId.CH3, Gas.O2, 2095, 2, channel_status=status(hold=True), state=ReadingState.HOLD
)
sample = Sample.from_frame(frame((held_o2,)), device="zpa", address=1)
row = sample_to_row(sample) # row["ch3_value"] == 20.95, row["ch3_state"] == "hold"
fujilib.testing.arrow ¶
Test helpers: the family's arrow-format frame fixtures (design §2.10, §10).
An arrow fixture is plain text, one frame per line, as hex bytes::
> 01 03 00 04 00 02 85 CA # a request the host sends
< 01 03 04 00 00 03 E8 FA 8D # the analyzer's reply
>starts an exchange with a request;<adds reply bytes to it (several<lines are concatenated). A request with no<has no reply.#starts a comment anywhere on a line; blank lines are ignored.- Hex may be separated by spaces, colons or commas.
Exchange
dataclass
¶
hex_to_bytes ¶
Parse hex bytes separated by spaces, colons or commas.
Raises:
| Type | Description |
|---|---|
FujiValidationError
|
a token is not one or two hex digits, or |
Source code in src/fujilib/testing/arrow.py
parse_arrow_fixture ¶
Parse an arrow fixture from a path or from its text.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str | Path
|
A :class: |
required |
name
|
str | None
|
The label for error messages; defaults to the path, or |
None
|
Raises:
| Type | Description |
|---|---|
FujiValidationError
|
a malformed line; the message starts with |
Source code in src/fujilib/testing/arrow.py
replay_script ¶
Map each request to its reply, for replaying a fixture to a client.
Requests without a reply are left out.
Raises:
| Type | Description |
|---|---|
FujiValidationError
|
the same request appears with two different replies. |
Source code in src/fujilib/testing/arrow.py
fujilib.testing.frames ¶
Builders for synthetic readings, statuses and frames (design §10).
For code that consumes fujilib's models without an analyzer or a simulated
line: a downstream adapter's tests, a sink's tests, or an application's own
simulator. Each builder returns the real frozen model, so
:meth:Sample.from_frame() <fujilib.streaming.sample.Sample.from_frame> and
:func:~fujilib.sinks.base.sample_to_row turn a built :class:Frame into
exactly the sample and row a recording of an analyzer produces.
- :func:
readingand :func:statusbuild one channel; :func:analyzerthe analyzer-level status; :func:framea whole poll. - :func:
timingbuilds a transaction's timing from an offset. Its defaults, :data:T0and :data:MONO0, are fixed, so a test's timestamps are too; a simulator passes its own clock readings asoriginandmono_origin_ns. - :func:
bench_readingsis CO2, CO and O2 as the bench ZPA reported them.
Nothing here checks that the parts agree: a state is taken as given, not
derived from the status. The decoders do that (:mod:fujilib.devices.decode);
use :class:~fujilib.testing.mock.MockAnalyzer when the decoding itself is
under test.
analyzer ¶
analyzer(
*,
instrument_error=False,
errors=(),
alarms=(AlarmState.NONE,) * 6,
auto_calibration=False,
)
The analyzer-level status, on the measurement screen with nothing active.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
instrument_error
|
bool
|
The instrument-error flag. |
False
|
errors
|
tuple[ErrorCode, ...]
|
The analyzer's active errors: 1, 2, 3 or 10. |
()
|
alarms
|
tuple[AlarmState | int, ...]
|
The states of alarms 1-6, in order. |
(NONE,) * 6
|
auto_calibration
|
bool
|
An auto calibration is running. |
False
|
Source code in src/fujilib/testing/frames.py
bench_readings ¶
CO2, CO and O2 as the bench ZPA reported them at capture.
Source code in src/fujilib/testing/frames.py
frame ¶
frame(
readings=None,
status_block=None,
*,
detail=True,
readings_timing=None,
status_timing=None,
)
A poll's frame.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
readings
|
tuple[Reading, ...] | None
|
The channels; :func: |
None
|
status_block
|
AnalyzerStatus | None
|
The analyzer status; :func: |
None
|
detail
|
bool
|
|
True
|
readings_timing
|
TransferTiming | None
|
The concentration block's timing, which times the
sample; |
None
|
status_timing
|
TransferTiming | None
|
The status block's timing; |
None
|
Source code in src/fujilib/testing/frames.py
reading ¶
reading(
channel,
gas,
raw,
decimals,
*,
unit=Unit.VOL_PERCENT,
state=ReadingState.OK,
channel_status=None,
label_source=LabelSource.ASSERTED,
role=ChannelRole.INSTANTANEOUS,
)
One channel's reading, its value raw / 10**decimals.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
channel
|
ChannelId
|
The channel. |
required |
gas
|
Gas
|
The gas it carries, also given as the suggested gas. |
required |
raw
|
int
|
The signed integer of the concentration register. |
required |
decimals
|
int
|
The decimal-point register, 0-3. |
required |
unit
|
Unit
|
The unit of the channel's current range. |
VOL_PERCENT
|
state
|
ReadingState
|
The validity state; it is not derived from |
OK
|
channel_status
|
ChannelStatus | None
|
The channel's status; :func: |
None
|
label_source
|
LabelSource
|
Where the gas label comes from. |
ASSERTED
|
role
|
ChannelRole
|
The channel's role. |
INSTANTANEOUS
|
Source code in src/fujilib/testing/frames.py
status ¶
A measured channel's status: live on range 1 unless told otherwise.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rng
|
int
|
The current range, 1 or 2. |
1
|
hold
|
bool
|
The channel's output is held. |
False
|
zero
|
bool
|
A manual zero calibration of the channel is under way. |
False
|
span
|
bool
|
A manual span calibration of the channel is under way. |
False
|
errors
|
tuple[ErrorCode, ...]
|
The channel's active errors, 4-9. |
()
|
Source code in src/fujilib/testing/frames.py
timing ¶
The timing of a transaction sent offset_ms after the origin.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
offset_ms
|
float
|
When the request was sent, in milliseconds after the origin. |
0.0
|
latency_ms
|
float
|
The round trip, request to reply. |
20.0
|
origin
|
datetime
|
The wall-clock origin, tz-aware. |
T0
|
mono_origin_ns
|
int
|
The monotonic origin, the same instant as |
MONO0
|
Source code in src/fujilib/testing/frames.py
fujilib.testing.mock ¶
A simulated ZP-series analyzer and the RS-485 line it sits on (design §10).
Two pieces, kept apart so several stations can share one line:
- :class:
MockAnalyzeris one station: its register banks, the region map it answers, the exception replies of one :class:ExceptionProfile, and the fault plan for its replies. It does no I/O. - :class:
MockLineis the line:anymodbus'sMockServeron the analyzer end of a serial port pair. The server reads each request frame once and drops one with a bad CRC or for an absent station (the analyzer stays silent then). The line records the request and hands it to its station, whose reply goes out with the station's faults applied.
Exception profiles. Both follow the manual (TN5A1190a p.13): a read or write that starts at an address the function cannot use answers 02, and one whose count runs past the registers that exist, or asks for more than 64 words, answers 03. They differ where the bench unit (firmware 1.02) differs from the manual: FC01 and FC02 answer 02 on the bench, 01 in the manual (protocol findings §5). Function codes never sent to the bench answer as the manual says in both.
Writes. The simulator accepts only the writes the manual documents minus
the ones fujilib must never make, written out here independently of
:data:fujilib.registry.write_policy.WRITE_ENVELOPE. At the key-simulation
register 07D0h that is the six calibration keys, never MODE, SIDE or two keys
at once. A write outside that list raises :class:MockWriteViolation, which
fails the test. A write is stored as sent,
as the bench analyzer stores a value outside a setting's range, neither
refusing nor clamping it (protocol findings §13.6). A test that wants a
refusal injects an exception reply, and one that wants a write acknowledged
but not stored injects :attr:FaultKind.IGNORE.
Ranges. A channel's selected range (40106-40110), written while its range
method is manual, becomes its current range (30038-30042)
:attr:MockAnalyzerConfig.range_lag_s later, since the bench analyzer
switches some tens of milliseconds after the setting reads back (protocol
findings §13.2). Under the auto or remote method the current range stays.
Operation commands are recorded and act as the manuals describe (ZPA
manual p.31, p.46-47, p.53-60), on the AnyIO clock, with every flow time
shortened by :attr:MockAnalyzerConfig.time_scale:
- return to measurement shows the measurement screen. On a manual calibration's wait step it leaves the channels' flags set, as the bench analyzer did (protocol findings §18.4);
- auto calibration zeroes the channels enabled for it (40021-40025) together for flow time 1, then spans them one at a time from Ch1 for flow times 2-6, then holds for flow time 7 if output hold is on;
- auto zero calibration zeroes the same channels for its flow time, and holds as long again if output hold is on.
While either runs, input 30049 and the channels' auto-zero or auto-span and
hold flags are set, and each enabled channel measures on its auto-calibration
range, back to its own range at the end. Errors set in
:attr:MockAnalyzer.calibration_errors appear at the end, as a failed
calibration's would. A command that arrives while one runs changes nothing,
and so does blowback, which has no status register. None of this is verified
on hardware: the bench analyzer has no calibration valves to drive.
The front panel. Keys reach it two ways: :meth:MockAnalyzer.press, an
operator pressing keys at the panel, and a key code written to 42001. Its
manual calibration follows what the bench analyzer showed (protocol findings
§14, §18):
- ZERO or SPAN opens channel selection (step 4 or 7) with the cursor where it
was, or on the first position after a return to measurement (42002) or,
with :attr:
MockAnalyzerConfig.cursor_reset_idle_s, after a long pause. - UP and DOWN move the cursor, wrapping round at both ends. For a zero, the channels set to "at once" share one position, which reads as its first channel when reached going down and its last going up.
- ENT selects the channel: the wait step (5 or 8), the channels' zero or span flags, and 30186 at 0.
- ENT again runs it (6 or 9, 30186 at 4) for
:attr:
MockAnalyzerConfig.manual_calibration_s, the last :attr:MockAnalyzerConfig.storing_silence_sof it without answering. It then sets each channel's reading to its calibration gas on its current range, and ends on the measurement step with 30186 at 6. - ESC from selection or wait returns to measurement; from the wait step the
flags clear :attr:
MockAnalyzerConfig.flag_lag_slater. Return to measurement (42002) on the wait step leaves them set. - 30190 shows each key pressed at the panel for
:attr:
MockAnalyzerConfig.key_hold_s; a key written to 42001 never shows there. - With key lock on, a key written to 42001 is acknowledged and does nothing,
and the analyzer then answers nothing for
:attr:
MockAnalyzerConfig.key_lock_silence_s.
:meth:MockAnalyzer.flow changes the gas at a channel's inlet: the reading
approaches the new value with a time constant, on the AnyIO clock.
What the bench has not shown is written from the manuals (ZPA manual p.64-67, p.75-77, p.89) and is unverified:
- A channel in :attr:
MockAnalyzer.calibration_errorsends on the error display (step 10), with its error set and 30186 left at 4. ESC clears the display. ENT forces the calibration on error 5 or 7, and clears the display otherwise. - Output hold sets the channels' hold flags from the wait step to the end; their readings are not held.
- MODE opens the menu screen and ESC closes it; no menu is modelled beyond that.
- Key lock does not stop keys pressed at the panel here; the bench has not shown what it does to them.
The simulator validates library integration. It does not validate USB timing, UART behaviour on real hardware, or analyzer semantics it was programmed to assume.
ExceptionProfile ¶
Bases: StrEnum
Which exception replies the simulator gives.
Fault
dataclass
¶
FaultKind ¶
Bases: StrEnum
What goes wrong with a reply.
CORRUPT_CRC
class-attribute
instance-attribute
¶
The reply's CRC is wrong.
DELAY
class-attribute
instance-attribute
¶
The reply is sent :attr:Fault.delay_s late (a late reply when longer than the timeout).
The line is held up meanwhile, as a slow analyzer on a half-duplex line holds it up: the next request is read only after the delayed reply has gone out.
EXCEPTION
class-attribute
instance-attribute
¶
An exception reply with :attr:Fault.exception_code.
GARBAGE
class-attribute
instance-attribute
¶
Bytes that are not a reply: the station, then function code 0.
IGNORE
class-attribute
instance-attribute
¶
A normal reply to a write or command that changes nothing, as a write the analyzer acknowledges and then does not store.
WRONG_COUNT
class-attribute
instance-attribute
¶
A well-formed read reply with one word too few (one too many for a one-word read).
WRONG_FUNCTION
class-attribute
instance-attribute
¶
A reply that echoes another function code.
By default a well-formed reply of the same length: 03 and 04 swap, as do 06
and 10. :attr:Fault.function_code sets the code instead, e.g. 07, which
anymodbus cannot frame at all, as a damaged byte on the line.
MockAnalyzer ¶
One simulated station: registers, regions, exception profile and faults.
Start from config, or an empty analyzer at station 1.
Source code in src/fujilib/testing/mock.py
calibration
instance-attribute
¶
The auto calibration or auto zero calibration running, if any.
calibration_errors
instance-attribute
¶
Errors 4-8 by channel, to raise when the next calibration ends.
commands
instance-attribute
¶
Operation commands carried out, as (address, value); refused or ignored ones
are not.
holding
instance-attribute
¶
Holding-register words; change them freely.
input
instance-attribute
¶
Input-register words; change them freely.
on_request
instance-attribute
¶
Called with each request before it is answered, to change state mid-sequence.
range_changes
instance-attribute
¶
Range switches still to come, by channel: the current-range word and when.
remote_keys
instance-attribute
¶
Keys written to 42001 that acted, as (key code, AnyIO time).
swallowed_keys
instance-attribute
¶
Keys written to 42001 that key lock swallowed, as (key code, AnyIO time).
answer ¶
Take in exchange's request; return the response PDU and the fault for its reply.
Called by :class:MockLine. A request the fault refuses with an
exception reply changes nothing; a reply lost or damaged on its way
back does not undo what the analyzer already did.
Source code in src/fujilib/testing/mock.py
clear ¶
finish_calibration ¶
flow ¶
Put a gas of value at channel's inlet, now or at AnyIO time at.
The reading approaches value exponentially with time constant
tau_s, in the channel's own unit and decimals; with tau_s 0 it
takes the value at once.
Source code in src/fujilib/testing/mock.py
handle ¶
The response PDU to request (an exception PDU included).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
MockRequest
|
The request. |
required |
apply
|
bool
|
Carry out a write or command. |
True
|
Raises:
| Type | Description |
|---|---|
MockWriteViolation
|
|
Source code in src/fujilib/testing/mock.py
inject ¶
Add a fault for the replies to the next matching requests.
Source code in src/fujilib/testing/mock.py
press ¶
Press key (a 42001 key code) at the front panel, now or at AnyIO time at.
This is the operator: 30190 shows the key, and key lock does not stop it.
Source code in src/fujilib/testing/mock.py
register ¶
The words of the register called name.
Source code in src/fujilib/testing/mock.py
reply_frame ¶
The reply frame for response pdu, with fault applied.
Source code in src/fujilib/testing/mock.py
send_reply
async
¶
Send the reply to exchange with fault applied. Called by :class:MockLine.
Source code in src/fujilib/testing/mock.py
set_reading ¶
Set a channel's concentration, decimal point and unit.
Source code in src/fujilib/testing/mock.py
set_register ¶
Set the register called name in the registry; a negative value is two's complement.
Raises:
| Type | Description |
|---|---|
FujiConfigurationError
|
no register has that name. |
ValueError
|
the number of words does not match the register. |
Source code in src/fujilib/testing/mock.py
set_words ¶
Set consecutive words of table from address.
Raises:
| Type | Description |
|---|---|
ValueError
|
a word is outside 0-0xFFFF. |
Source code in src/fujilib/testing/mock.py
silent ¶
Whether the analyzer answers nothing at AnyIO time now.
take_fault ¶
The first pending fault that matches request, used up by one.
Source code in src/fujilib/testing/mock.py
transactions ¶
MockAnalyzerConfig
dataclass
¶
MockAnalyzerConfig(
station=1,
profile=ExceptionProfile.BENCH_1_02,
input=(lambda: MappingProxyType({}))(),
holding=(lambda: MappingProxyType({}))(),
regions=zp_readable_regions(),
description="",
time_scale=0.001,
range_lag_s=0.0,
manual_calibration_s=0.0,
key_hold_s=0.3,
panel_channels=(1, 2, 3, 4, 5),
key_lock_silence_s=1.6,
storing_silence_s=0.0,
flag_lag_s=0.0,
cursor_reset_idle_s=None,
)
What a :class:MockAnalyzer starts from.
cursor_reset_idle_s
class-attribute
instance-attribute
¶
Seconds without a key after which ZERO or SPAN opens on the first position; None
for never.
flag_lag_s
class-attribute
instance-attribute
¶
Seconds a channel's flag stays set after ESC leaves the wait step (a read, on the bench).
holding
class-attribute
instance-attribute
¶
Holding-register words by address; unset addresses in a region read 0.
input
class-attribute
instance-attribute
¶
Input-register words by address; unset addresses in a region read 0.
key_hold_s
class-attribute
instance-attribute
¶
Seconds 30190 shows a key pressed at the panel.
key_lock_silence_s
class-attribute
instance-attribute
¶
Seconds the analyzer answers nothing after key lock swallows a key written to 42001.
manual_calibration_s
class-attribute
instance-attribute
¶
Seconds a manual calibration runs after the ENT that starts it (1.6-2.4 s on the bench).
panel_channels
class-attribute
instance-attribute
¶
The measured channels the panel's calibration cursor offers.
range_lag_s
class-attribute
instance-attribute
¶
Seconds after a range write before the channel measures on the range selected.
storing_silence_s
class-attribute
instance-attribute
¶
Seconds at the end of a manual calibration's run without an answer (about 1 on the bench, for a zero).
time_scale
class-attribute
instance-attribute
¶
Simulated seconds per second of a calibration's flow times.
MockCalibration
dataclass
¶
MockExchange
dataclass
¶
MockLine ¶
A simulated RS-485 line: anymodbus's MockServer, requests routed by station.
Put analyzers on the line.
Raises:
| Type | Description |
|---|---|
ValueError
|
two analyzers share a station number. |
Source code in src/fujilib/testing/mock.py
exchanges
instance-attribute
¶
Every request received with a valid CRC, for any station, with its reply.
add ¶
Put another analyzer on the line.
Raises:
| Type | Description |
|---|---|
ValueError
|
its station number is taken. |
Source code in src/fujilib/testing/mock.py
serve
async
¶
Answer requests on stream until it closes or the task is cancelled.
The line ends quietly when either end closes.
Raises:
| Type | Description |
|---|---|
MockWriteViolation
|
a client wrote where fujilib must never write. |
Source code in src/fujilib/testing/mock.py
MockRegion
dataclass
¶
Addresses first..last that function code function can read.
MockRequest
dataclass
¶
One request as the line received it; a frame with a bad CRC never gets here.
count
instance-attribute
¶
Words read or written, or None for a function code without a count.
MockWriteViolation ¶
Bases: AssertionError
A client sent a write fujilib must never send. Fails the test.
zp_readable_regions ¶
The readable map of a ZP analyzer.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
observed
|
bool
|
Use the bench unit's wider FC04 block 03E8h-0479h (clock, A/D values and the fixed settings) instead of the documented 0425h-0469h (protocol findings §4). |
True
|
firmware_2_24
|
bool
|
Add type-code digits 27-29 (047Ah-047Ch) and the calibration log (1000h-1707h). |
False
|
Source code in src/fujilib/testing/mock.py
fujilib.testing.pair ¶
Wiring a client to the simulator, and loading register banks (design §10).
- :func:
mock_transportputs :class:~fujilib.testing.mock.MockAnalyzerstations on one end ofanyserial.testing.serial_port_pair()and returns a :class:~fujilib.transport.serial.SerialTransportover the other end. Both ends are realSerialPortobjects, soanymodbusdrains and clears input exactly as it does on hardware. - :func:
mock_portadds the :class:~fujilib.protocol.modbus.port.ModbusPort; :func:mock_analyzer_pairgoes one step further, to one station's client. - :func:
fake_transportreplays an arrow fixture byte for byte. - :data:
DEFAULT_ZPA_BANKis the sanitized bench bank: the documented register blocks of the bench ZPA plus its observed clock and A/D block, with the factory calibration blocks left out (design §13.1 #11).
The pairs use the library's timing defaults, except that the one-shot startup settle is 0: it exists for RS-485 adapters, and there is none here.
fake_transport ¶
A :class:FakeTransport that replays the arrow fixture source (a path or its text).
Raises:
| Type | Description |
|---|---|
FujiValidationError
|
the fixture is malformed, or gives one request two replies. |
Source code in src/fujilib/testing/pair.py
load_bank ¶
load_bank(
source=BENCH_BANK_PATH,
*,
station=None,
profile=ExceptionProfile.BENCH_1_02,
regions=None,
)
A simulator configuration from a register dump.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
Path | Mapping[str, object]
|
A dump file, or its parsed JSON: |
BENCH_BANK_PATH
|
station
|
int | None
|
The station number; defaults to the dump's |
None
|
profile
|
ExceptionProfile
|
The exception replies to give. |
BENCH_1_02
|
regions
|
tuple[MockRegion, ...] | None
|
The readable map; defaults to :func: |
None
|
Raises:
| Type | Description |
|---|---|
TypeError
|
a table is not a mapping. |
Source code in src/fujilib/testing/pair.py
mock_analyzer_pair
async
¶
mock_analyzer_pair(
config=None,
*,
request_timeout=DEFAULTS.request_timeout_s,
inter_frame_idle=DEFAULTS.inter_frame_idle_s,
startup_settle=0.0,
read_retries=DEFAULTS.read_retries,
resync_window=DEFAULTS.resync_window_s,
)
A client talking to one simulated analyzer, :data:DEFAULT_ZPA_BANK by default.
Source code in src/fujilib/testing/pair.py
mock_port
async
¶
mock_port(
*analyzers,
request_timeout=DEFAULTS.request_timeout_s,
inter_frame_idle=DEFAULTS.inter_frame_idle_s,
startup_settle=0.0,
read_retries=DEFAULTS.read_retries,
resync_window=DEFAULTS.resync_window_s,
)
A :class:ModbusPort on a line carrying analyzers.
Source code in src/fujilib/testing/pair.py
mock_transport
async
¶
A transport whose line carries analyzers, answered by a :class:MockLine.
Both ends are closed on exit. A :class:~fujilib.testing.mock.MockWriteViolation
raised while serving cancels the async with block and is raised from it.
The line is served by a task group, which would wrap any failure in an
exception group. A single failure, the block's own or the line's, is raised
as itself, so pytest.raises works around the whole block.