feat(map): add edge-center delta stats, thickness overlay, and layout fix

- Add edge-center sensor pair tables and delta calculations per wafer family.
- Parse and interpolate wafer thickness CSV data for offscreen visualization.
- Refactor ReadoutPanel layout using Item + anchors to fix right-margin clipping.
- Compact E-C Delta text format to fit standard sidebar width.
- Add pytest suite covering delta calculations, layout pairs, and thickness CSV.
This commit is contained in:
jack
2026-07-10 17:32:12 -07:00
parent 25fa7507ce
commit 94f917b116
12 changed files with 518 additions and 63 deletions
+46 -1
View File
@@ -2,7 +2,12 @@ import math
import pytest
from pygui.backend.models.frame_stats import Stats, compute_stats
from pygui.backend.models.frame_stats import (
EdgeCenterStats,
Stats,
compute_edge_center,
compute_stats,
)
def test_basic_stat():
@@ -28,3 +33,43 @@ def test_ignores_nan():
assert s.avg == pytest.approx(150.0)
assert s.sigma == pytest.approx(1.0)
assert s.three_sigma == pytest.approx(3.0)
# ---- edge-center delta (ADR-0002) ----
PAIRS = ((0, 4), (1, 4), (2, 5), (3, 5))
def test_edge_center_min_max_pairs():
# e0 e1 e2 e3 c4 c5
values = [150.0, 148.0, 151.0, 149.4, 149.0, 149.5]
ec = compute_edge_center(values, PAIRS, excluded=set())
# deltas: |150-149|=1.0, |148-149|=1.0, |151-149.5|=1.5, |149.4-149.5|=0.1
assert ec == EdgeCenterStats(
min_edge_index=3, min_center_index=5, min_edge=149.4, min_center=149.5,
min_delta=pytest.approx(0.1),
max_edge_index=2, max_center_index=5, max_edge=151.0, max_center=149.5,
max_delta=pytest.approx(1.5),
)
def test_edge_center_skips_excluded_sensors():
values = [150.0, 148.0, 151.0, 149.4, 149.0, 149.5]
ec = compute_edge_center(values, PAIRS, excluded={2, 3}) # both c5 pairs out
assert ec is not None
assert ec.max_delta == pytest.approx(1.0)
# excluding a center sensor kills all its pairs
assert compute_edge_center(values, PAIRS, excluded={4, 5}) is None
def test_edge_center_skips_nan_and_out_of_range():
values = [150.0, math.nan, 149.0] # only pair (0, 2) is computable
ec = compute_edge_center(values, ((0, 2), (1, 2), (0, 9)), excluded=set())
assert ec is not None
assert ec.min_delta == ec.max_delta == pytest.approx(1.0)
assert (ec.min_edge_index, ec.min_center_index) == (0, 2)
def test_edge_center_none_when_no_pairs():
assert compute_edge_center([1.0, 2.0], (), excluded=set()) is None
assert compute_edge_center([], PAIRS, excluded=set()) is None
+44
View File
@@ -248,3 +248,47 @@ def test_on_live_frame_emits_trend_delta_with_elapsed_seconds(controller, monkey
elapsed, avg = payload[0]
assert elapsed == pytest.approx(2.5)
assert avg == pytest.approx(20.0)
# ---- edge-center stats exposure (ADR-0002) ----
def _write_f_family_csv(path):
"""22-sensor F-family stream: pair (0,18) has delta 1.0, all others 0."""
n = 22
values = [149.0] * n
values[0] = 150.0
lines = [
"Wafer ID=F12",
"Acquisition Date=03/26/2025",
"Label," + ",".join(str(i + 1) for i in range(n)),
"X (mm)," + ",".join(str(i) for i in range(n)),
"Y (mm)," + ",".join(str(i) for i in range(n)),
"data",
"0.0," + ",".join(f"{v:.1f}" for v in values),
]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def test_stats_include_edge_center_for_known_family(controller, tmp_path):
csv = tmp_path / "f_run.csv"
_write_f_family_csv(csv)
controller.loadFile(str(csv))
s = controller.stats
assert s["ecMaxDelta"] == pytest.approx(1.0)
assert s["ecMaxEdgeIndex"] == 1 and s["ecMaxCenterIndex"] == 19 # 1-origin
assert s["ecMaxEdgeValue"] == pytest.approx(150.0)
assert s["ecMaxCenterValue"] == pytest.approx(149.0)
assert s["ecMinDelta"] == pytest.approx(0.0)
def test_stats_edge_center_skips_replaced_sensor(controller, tmp_path):
csv = tmp_path / "f_run.csv"
_write_f_family_csv(csv)
controller.loadFile(str(csv))
controller.replaceSensor(0, 149.0) # replaced: pair (0,18) must be skipped
assert controller.stats["ecMaxDelta"] == pytest.approx(0.0)
def test_stats_edge_center_absent_for_unknown_or_short_family(controller):
controller.loadFile("tests/fixtures/sample_stream.csv") # A12, 3 sensors
assert "ecMaxDelta" not in controller.stats
+29 -1
View File
@@ -1,7 +1,11 @@
"""Tests for src/pygui/backend/wafer_layouts.py."""
import pytest
from pygui.backend.wafer.wafer_layouts import available_families, load_layout
from pygui.backend.wafer.wafer_layouts import (
available_families,
ec_pairs_for_wafer_id,
load_layout,
)
from pygui.backend.wafer.zwafer_models import Sensor
@@ -20,3 +24,27 @@ def test_loads_sensors_in_mm():
def test_unknown_family_raises():
with pytest.raises(KeyError):
load_layout("nope")
# ---- edge-center pair tables (ADR-0002) ----
def test_ec_pairs_aep_match_csharp_table():
pairs = ec_pairs_for_wafer_id("A00123")
assert pairs[0] == (0, 40)
assert pairs[-1] == (15, 47)
assert len(pairs) == 16
assert ec_pairs_for_wafer_id("E1") == pairs
assert ec_pairs_for_wafer_id("P1") == pairs
def test_ec_pairs_per_family_classes():
assert len(ec_pairs_for_wafer_id("B00108")) == 12
assert ec_pairs_for_wafer_id("C1") == ec_pairs_for_wafer_id("D1")
assert ec_pairs_for_wafer_id("F1")[0] == (0, 18)
assert ec_pairs_for_wafer_id("X1")[0] == (5, 76)
assert ec_pairs_for_wafer_id("Z1")[0] == (49, 0)
def test_ec_pairs_unknown_family_empty():
assert ec_pairs_for_wafer_id("Q999") == ()
assert ec_pairs_for_wafer_id("") == ()
+76 -3
View File
@@ -47,16 +47,89 @@ def test_thickness_properties():
def test_thickness_setter_updates_data():
"""Setting thicknessData updates _thickness_data."""
"""Setting thicknessData stores [x, y, t2] triples."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
item._rebuild_thickness = MagicMock()
item.thicknessData = [1.0, 2.0, 3.0]
item.thicknessData = [[0.0, 10.0, 1.5], [5.0, -5.0, 2.0]]
assert item._thickness_data == [1.0, 2.0, 3.0]
assert item._thickness_data == [[0.0, 10.0, 1.5], [5.0, -5.0, 2.0]]
assert item._rebuild_thickness.called
assert item.hasThickness is True
THICKNESS_HEADER = "Lot Start Time,RC,Site#,Slot#,T2,NGOF,Wafer X,Wafer Y\n"
def _write_csv(tmp_path, body, header=THICKNESS_HEADER):
p = tmp_path / "thickness.csv"
p.write_text(header + body)
return str(p)
def test_parse_thickness_csv_valid(tmp_path):
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
path = _write_csv(tmp_path,
"t,1,1,7,1.5,0,10.0,-20.0\n"
"t,1,2,7,2.5,0,-30.0,40.0\n")
points, err = parse_thickness_csv(path)
assert err == ""
assert points == [[10.0, -20.0, 1.5], [-30.0, 40.0, 2.5]]
def test_parse_thickness_csv_first_slot_only(tmp_path):
"""Rows after the Slot# changes belong to another wafer and are skipped."""
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
path = _write_csv(tmp_path,
"t,1,1,7,1.5,0,10.0,-20.0\n"
"t,1,1,8,9.9,0,0.0,0.0\n")
points, err = parse_thickness_csv(path)
assert err == ""
assert points == [[10.0, -20.0, 1.5]]
def test_parse_thickness_csv_bad_header(tmp_path):
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
path = _write_csv(tmp_path, "t,1,1,7,1.5,0,10.0,-20.0\n", header="A,B,C\n")
points, err = parse_thickness_csv(path)
assert points == []
assert "header" in err
def test_parse_thickness_csv_malformed_value(tmp_path):
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
path = _write_csv(tmp_path, "t,1,1,7,oops,0,10.0,-20.0\n")
points, err = parse_thickness_csv(path)
assert points == []
assert "Malformed" in err
def test_parse_thickness_csv_missing_file():
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
points, err = parse_thickness_csv("/no/such/file.csv")
assert points == []
assert "Unable to read" in err
def test_load_thickness_slot(tmp_path):
"""loadThickness feeds parsed points into thicknessData."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
path = _write_csv(tmp_path, "t,1,1,7,1.5,0,10.0,-20.0\n")
assert item.loadThickness(path) == ""
assert item.hasThickness is True
assert item.loadThickness("/no/such/file.csv") != ""
# Failed load keeps the previously loaded data.
assert item.hasThickness is True
def test_show_thickness_setter():