| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304 |
- """
- Unit tests for seq_interp.src.gui.adapters — pure data-conversion helpers,
- no Qt or hardware dependencies required.
- """
- import math
- import pytest
- import numpy as np
- from seq_interp.src.gui.adapters import (
- gate_to_step,
- build_block_rows,
- find_block_at_time,
- validate_timing,
- seq_metadata,
- block_cumtimes,
- )
- # ─── Fixtures / helpers ────────────────────────────────────────────────────────
- class _HW:
- """Minimal stand-in for HardwareConstraints."""
- rf_raster_time = 1e-6
- grad_raster_time = 10e-6
- adc_raster_time = 100e-9
- block_duration_raster = 10e-6
- RF_DELAY = 800e-9
- TR_DELAY = 800e-9
- START_DELAY = 1600e-9
- MIN_BLOCK_DURATION = 20e-9
- gamma = 42.576e6
- GRAD_MAX = 9e-3 * 42.576e6
- def _make_seq_data(blocks: list, rf=None, t_rf=None,
- gx=None, t_gx=None, gy=None, t_gy=None, gz=None, t_gz=None):
- return {
- "blocks": blocks,
- "params": {"scale_rf": 1.0},
- "rf": rf or np.array([]),
- "t_rf": t_rf or np.array([]),
- "gx": gx or np.array([]),
- "t_gx": t_gx or np.array([]),
- "gy": gy or np.array([]),
- "t_gy": t_gy or np.array([]),
- "gz": gz or np.array([]),
- "t_gz": t_gz or np.array([]),
- }
- def _make_sync_data(gate_rf, gate_adc, gate_tr, durs, n_blocks=None):
- return {
- "gate_rf": list(gate_rf),
- "gate_adc": list(gate_adc),
- "gate_tr_switch": list(gate_tr),
- "blocks_duration": list(durs),
- "number_of_blocks": n_blocks if n_blocks is not None else len(durs) - 1,
- "synchro_block_timer": 20e-9,
- "min_block_time": 800e-9,
- }
- # ─── gate_to_step ──────────────────────────────────────────────────────────────
- class TestGateToStep:
- def test_output_length(self):
- gate = [0, 1, 0]
- durs = [1e-3, 2e-3, 1e-3]
- t, v = gate_to_step(gate, durs)
- assert len(t) == 6
- assert len(v) == 6
- def test_values_match_gate(self):
- gate = [0, 1, 0]
- durs = [1e-3, 2e-3, 1e-3]
- t, v = gate_to_step(gate, durs)
- assert v[0] == 0
- assert v[2] == 1
- assert v[4] == 0
- def test_time_monotonic(self):
- gate = [1, 0, 1, 0]
- durs = [0.5e-3] * 4
- t, _ = gate_to_step(gate, durs)
- assert all(t[i] <= t[i + 1] for i in range(len(t) - 1))
- def test_empty_gate(self):
- t, v = gate_to_step([], [])
- assert len(t) == 0 and len(v) == 0
- def test_step_coverage(self):
- """Each gate value spans exactly its block duration."""
- gate = [1]
- durs = [2e-3]
- t, v = gate_to_step(gate, durs)
- assert math.isclose(t[1] - t[0], 2e-3)
- # ─── block_cumtimes ────────────────────────────────────────────────────────────
- class TestBlockCumtimes:
- def test_starts_at_zero(self):
- ct = block_cumtimes([1e-3, 2e-3, 3e-3])
- assert ct[0] == pytest.approx(0.0)
- def test_length(self):
- ct = block_cumtimes([1e-3, 2e-3])
- assert len(ct) == 3
- def test_values(self):
- ct = block_cumtimes([1e-3, 2e-3, 3e-3])
- assert ct[-1] == pytest.approx(6e-3)
- # ─── build_block_rows ─────────────────────────────────────────────────────────
- class TestBuildBlockRows:
- def _simple_sync(self, blocks):
- """
- Produce a minimal consistent sync_data that matches what Synchronizer would
- generate for the given block list. Keeps bookkeeping manual but deterministic.
- """
- start = 1.6e-6
- rf_delay = 0.8e-6
- tr_delay = 0.8e-6
- block_dur = 5e-3
- g_rf, g_adc, g_tr, durs = [0], [0], [1], [start]
- for blk in blocks:
- has_adc = blk.get("has_adc", False)
- has_rf = "RF" in blk.get("type", [])
- if has_adc:
- g_adc.append(0); g_rf.append(g_rf[-1])
- durs[-1] -= tr_delay; durs.append(tr_delay)
- g_tr.append(0)
- g_adc.append(1); g_tr.append(0)
- else:
- g_tr.append(1); g_adc.append(0)
- if has_rf and not has_adc:
- g_rf.append(1); g_adc.append(g_adc[-1])
- durs[-1] -= rf_delay; durs.append(rf_delay)
- g_tr.append(g_tr[-1])
- g_rf.append(1)
- else:
- g_rf.append(0)
- durs.append(block_dur)
- return _make_sync_data(g_rf, g_adc, g_tr, durs)
- def test_start_delay_is_first_row(self):
- blocks = [{"type": [], "duration": 5e-3, "has_adc": False}]
- sync = self._simple_sync(blocks)
- seq = _make_seq_data(blocks)
- rows = build_block_rows(seq, sync)
- assert rows[0].is_delay
- assert rows[0].delay_type == "START"
- assert rows[0].sync_index == 0
- def test_plain_block_no_delay_inserted(self):
- blocks = [{"type": [], "duration": 5e-3, "has_adc": False}]
- sync = self._simple_sync(blocks)
- seq = _make_seq_data(blocks)
- rows = build_block_rows(seq, sync)
- # start_delay + 1 actual block = 2 rows
- assert len(rows) == 2
- assert not rows[1].is_delay
- def test_rf_block_inserts_rf_delay(self):
- blocks = [{"type": ["RF"], "duration": 5e-3, "has_adc": False}]
- sync = self._simple_sync(blocks)
- seq = _make_seq_data(blocks)
- rows = build_block_rows(seq, sync)
- # start_delay + RF_DELAY + RF_block = 3 rows
- assert len(rows) == 3
- assert rows[1].delay_type == "RF"
- assert rows[2].has_rf
- def test_adc_block_inserts_tr_delay(self):
- blocks = [{"type": ["ADC"], "duration": 5e-3, "has_adc": True}]
- sync = self._simple_sync(blocks)
- seq = _make_seq_data(blocks)
- rows = build_block_rows(seq, sync)
- # start_delay + TR_DELAY + ADC_block = 3 rows
- assert len(rows) == 3
- assert rows[1].delay_type == "TR"
- assert rows[2].has_adc
- def test_t_start_t_end_coverage(self):
- blocks = [
- {"type": [], "duration": 5e-3, "has_adc": False},
- {"type": [], "duration": 5e-3, "has_adc": False},
- ]
- sync = self._simple_sync(blocks)
- seq = _make_seq_data(blocks)
- rows = build_block_rows(seq, sync)
- for r in rows:
- assert r.t_end > r.t_start
- def test_empty_blocks(self):
- sync = _make_sync_data([0], [0], [1], [1.6e-6])
- seq = _make_seq_data([])
- rows = build_block_rows(seq, sync)
- assert len(rows) == 1
- assert rows[0].delay_type == "START"
- # ─── find_block_at_time ───────────────────────────────────────────────────────
- class TestFindBlockAtTime:
- def _rows(self):
- blocks = [{"type": [], "duration": 5e-3, "has_adc": False}] * 3
- sync = {
- "gate_rf": [0, 0, 0, 0], "gate_adc": [0, 0, 0, 0],
- "gate_tr_switch": [1, 1, 1, 1],
- "blocks_duration": [1e-3, 5e-3, 5e-3, 5e-3],
- "number_of_blocks": 3, "synchro_block_timer": 20e-9,
- "min_block_time": 800e-9,
- }
- return build_block_rows(_make_seq_data(blocks), sync)
- def test_finds_correct_row(self):
- rows = self._rows()
- # First block: [0, 1e-3), second: [1e-3, 6e-3) …
- found = find_block_at_time(0.5e-3, rows)
- assert found is not None
- assert found.t_start == pytest.approx(0.0)
- def test_returns_none_past_end(self):
- rows = self._rows()
- found = find_block_at_time(1e3, rows)
- assert found is None
- def test_boundary_belongs_to_next(self):
- rows = self._rows()
- # Exactly at t=1e-3 should be the SECOND block (t_start=1e-3)
- found = find_block_at_time(1e-3, rows)
- assert found is not None
- assert found.t_start == pytest.approx(1e-3)
- # ─── validate_timing ──────────────────────────────────────────────────────────
- class TestValidateTiming:
- def test_no_warnings_for_valid_data(self):
- hw = _HW()
- seq = _make_seq_data([])
- sync = _make_sync_data([0], [0], [1], [20e-9])
- warns = validate_timing(hw, seq, sync)
- assert warns == []
- def test_rf_delay_below_min(self):
- hw = _HW()
- hw.RF_DELAY = 1e-9 # below MIN_BLOCK_DURATION = 20 ns
- seq = _make_seq_data([])
- sync = _make_sync_data([0], [0], [1], [20e-9])
- warns = validate_timing(hw, seq, sync)
- assert any("RF_DELAY" in w for w in warns)
- def test_cl_rounds_to_zero(self):
- hw = _HW()
- # synchro_block_timer = 20e-9; duration = 10e-9 → CL = 0
- seq = _make_seq_data([])
- sync = _make_sync_data([0], [0], [1], [10e-9])
- sync["synchro_block_timer"] = 20e-9
- warns = validate_timing(hw, seq, sync)
- assert any("CL rounds to zero" in w for w in warns)
- def test_waveform_missing(self):
- hw = _HW()
- seq = {"blocks": [], "params": {}} # no rf/t_rf
- sync = _make_sync_data([0], [0], [1], [20e-9])
- warns = validate_timing(hw, seq, sync)
- assert any("'rf'" in w or "'t_rf'" in w for w in warns)
- def test_length_mismatch(self):
- hw = _HW()
- seq = _make_seq_data([], rf=np.array([1.0, 2.0]), t_rf=np.array([0.0]))
- sync = _make_sync_data([0], [0], [1], [20e-9])
- warns = validate_timing(hw, seq, sync)
- assert any("mismatch" in w.lower() for w in warns)
- # ─── seq_metadata ─────────────────────────────────────────────────────────────
- class TestSeqMetadata:
- def test_counts(self):
- hw = _HW()
- blocks = [
- {"type": ["RF"], "duration": 5e-3, "has_adc": False},
- {"type": ["ADC"], "duration": 5e-3, "has_adc": True},
- {"type": ["GRAD"], "duration": 5e-3, "has_adc": False},
- {"type": [], "duration": 5e-3, "has_adc": False},
- ]
- meta = seq_metadata(_make_seq_data(blocks), hw)
- assert meta["Total blocks (orig)"] == 4
- assert meta["RF blocks"] == 1
- assert meta["ADC blocks"] == 1
- assert meta["Grad blocks"] == 1
- def test_raster_times_in_human_units(self):
- hw = _HW()
- meta = seq_metadata(_make_seq_data([]), hw)
- assert float(meta["RF raster (µs)"]) == pytest.approx(1.0)
- assert float(meta["Grad raster (µs)"]) == pytest.approx(10.0)
- assert float(meta["ADC raster (ns)"]) == pytest.approx(100.0)
|