- state.rs: ports of mattonella_state.py (569 L), _dac_setting.py and the RegisterField byte plumbing — full 0x6C register image (64 pixel bytes, globals 0x40-0x4B, pairwise finetune-DAC nibbles 0x4C-0x6B) with exact range checks and enum parsing (ChannelMode, CalibrationTime) - mattonella.rs: port of mattonella.py (426 L) — DACInterface (enable bit 7 + 7-bit value RMW), 20 class-level descriptors, pixel batch ops (power/frontend/adj/ctrl/mask/ftdac), calibrate, full-state read/write - 9 integration tests; verified from_bytes(to_bytes(s)) == s round-trip - fake data_server moved to tests/common and fixed to advance the subaddress on multi-byte reads/writes (consecutive registers) - divergences D1-D5 documented in docs/porting-notes.md - workspace: 30 tests green
4.9 KiB
Porting notes: innesco (Python/C) → innesco-rs
What is ported (v0.1)
| Python/C source | Rust crate | Notes |
|---|---|---|
innesco/ecs/protocol.py |
innesco-protocol |
exact wire format: >BI request header (opcode u8, len u32 BE), 5-byte response header (status u8, len u32 BE); opcodes 0x10/0x11/0x12/0x13 (write/read ± subaddress), 0xC1 ping |
innesco/decoding/csrc/decoding.c |
innesco-decoding |
word layout, asic bit map, decode_position, calibration (10 * (100000 * integr_ns / cnt)), asic_timestamp |
tests/mocks/frames.py + tests/test_decode_golden.py |
golden tests in innesco-decoding |
same constants: T=1<<32, F=0x01020304, addresses 197/1221, PERIOD_FS=1e6 fs |
Deliberate divergences (and why)
- 64-bit arithmetic where C overflows 32-bit. In
decoding.c,tot = raw_tot * dco0_periods_fs[address]isuint32_tand theasic_timestampcorrection is computed inint/int32— withcnt=4and integr code 3 the period alone is 800,000,000 fs and a raw ToT of 255 wraps. The Rust port computes both in u64/i64 (saturating_subforasic_timestampinstead of uint64 wrap). Golden values are unchanged; corner cases no longer silently wrap. - Strict socket reads.
protocol.pyloops onrecv()until the read timeout and can silently return truncated responses. The Rust client reads exactly header+payload or returnsProtocolError::UnexpectedEof. - Malformed words: skip and count, never crash. The C aborts the packet
on a bad header; Python returned
None. Here:Decoder::decodeskips the word and bumpsstats.n_bad_headers; a non-13-multiple packet length is a hardDecodeError(misframed stream is a real bug, not garbage). - Calibration state is per-
Decoder, not process-global. The C keepsdco0/dco1_periods_fsin thread-local static state; a Rust struct field makes multi-stream decoding explicit and thread-safe. - AoS instead of SoA. The C fills a SoA DataFrame for polars; v0.1
returns
Vec<Hit>(struct of fields). Revisit when the arrow/parquet storage layer lands (Open Point 1). position_to_addressbuilt like_bindings.py. The lazy inverse table is constructed by enumeratingdecode_positionover all 4096 addresses (which is exactly where the Pythonix/iyshadowing bug lived — not reproduced here; see_bindings.pyhistory in the original repo).
Faithful-port findings (bugs/quirks in the original, mirrored exactly)
i2c_enumcodes do not always round-trip through the getter. The Python setter writesenum_code & mask, but several enums define codes wider than their mask (fastin_srcLVDS=0xFF with mask 0x01,external_dacOFF=0xFF/0x02,sel_data_en_srcJ14=0xFF/0x04,si_clk_oeON=0xFF/0x08). After such a write, the masked read yields a code that is not an enum key, so the getter returns the hex fallback (e.g."01") instead of the name. The Rust port reproduces this exactly; a future cleanup (agreed to be deferred) would store masked codes.config_crystalhi/lo aliasing: the high byte of each register address is written to subaddress 0x01 of the controller (0x75), so a row whose low byte IS 0x01 overwrites the next row's hi byte. Mirrored as-is.MattonellaStateround-trips its own serialization exactly — verified property:from_bytes(to_bytes(state)) == statefor arbitrary states (9 slice-2 tests), incl. the pairwise finetune-DAC nibble packing (pixel2i= low nibble,2i+1= high nibble of register0x4C + i).
Deliberate divergences from the original
D1. The module-global "current connection" is replaced by explicit
&mut Connection arguments on every call.
D2. ValueError / InvalidRange / IOError become FieldError /
StateError return values (no exceptions).
D3. The batch pixel operations (set_pixel_power, set_pixel_frontend,
set_pixel_adj, set_pixel_ctrl) do NOT check the I2C status of their
read step in Python; the Rust port fails explicitly with
FieldError::Server (writing back garbage would be worse).
D4. RegisterField's data-driven resolution (int / str / callable
subaddress, n_bits, bit_shift) is unrolled into explicit per-field
apply / extract calls in state.rs — same byte semantics, no
dynamic dispatch.
D5. _repr_html_ (Jupyter table) has no Rust counterpart; copy() maps to
#[derive(Clone)].
Domain constants (kept identical)
- Quadrants: NE=0, SE=1, SW=2, NW=3 (
decoding.cdefines). - Frame types (high nibble byte[0]): 0b1000 data/calibration, 0b1001 timeframe.
- Integration times: code 0..3 → 400/800/1600/3200 ns.
bxidtick: 25,000,000 (25 ms);asic_timestamp = bxid*25e6 + 25e6 - ((cnt0-1)*p0 - (cnt1-1)*p1).- DCO period:
10 * (100_000 * integr_time_ns / cnt)fs, per address per DCO. - Mattonelle 4-7 are broadcast-only (I2C 0x7F) — relevant for the future Board/quadrant port (Open Point 3).