Files
BobBot c8c4199c5c
ci / test (push) Canceled after 0s
ci / build-arm64 (push) Canceled after 0s
feat(ecs): slice 2 — MattonellaState register image + Mattonella model
- 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
2026-11-12 23:36:32 +00:00

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)

  1. 64-bit arithmetic where C overflows 32-bit. In decoding.c, tot = raw_tot * dco0_periods_fs[address] is uint32_t and the asic_timestamp correction is computed in int/int32 — with cnt=4 and 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_sub for asic_timestamp instead of uint64 wrap). Golden values are unchanged; corner cases no longer silently wrap.
  2. Strict socket reads. protocol.py loops on recv() until the read timeout and can silently return truncated responses. The Rust client reads exactly header+payload or returns ProtocolError::UnexpectedEof.
  3. Malformed words: skip and count, never crash. The C aborts the packet on a bad header; Python returned None. Here: Decoder::decode skips the word and bumps stats.n_bad_headers; a non-13-multiple packet length is a hard DecodeError (misframed stream is a real bug, not garbage).
  4. Calibration state is per-Decoder, not process-global. The C keeps dco0/dco1_periods_fs in thread-local static state; a Rust struct field makes multi-stream decoding explicit and thread-safe.
  5. 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).
  6. position_to_address built like _bindings.py. The lazy inverse table is constructed by enumerating decode_position over all 4096 addresses (which is exactly where the Python ix/iy shadowing bug lived — not reproduced here; see _bindings.py history in the original repo).

Faithful-port findings (bugs/quirks in the original, mirrored exactly)

  1. i2c_enum codes do not always round-trip through the getter. The Python setter writes enum_code & mask, but several enums define codes wider than their mask (fastin_src LVDS=0xFF with mask 0x01, external_dac OFF=0xFF/0x02, sel_data_en_src J14=0xFF/0x04, si_clk_oe ON=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.
  2. config_crystal hi/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.
  3. MattonellaState round-trips its own serialization exactly — verified property: from_bytes(to_bytes(state)) == state for arbitrary states (9 slice-2 tests), incl. the pairwise finetune-DAC nibble packing (pixel 2i = low nibble, 2i+1 = high nibble of register 0x4C + 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.c defines).
  • Frame types (high nibble byte[0]): 0b1000 data/calibration, 0b1001 timeframe.
  • Integration times: code 0..3 → 400/800/1600/3200 ns.
  • bxid tick: 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).