Replay Tabs:
- Add stability detection and threshold classification features with corresponding tests
This commit is contained in:
@@ -6,6 +6,6 @@ from dataclasses import dataclass
|
|||||||
class Frame:
|
class Frame:
|
||||||
"""One sample across all sensors ata a point in time"""
|
"""One sample across all sensors ata a point in time"""
|
||||||
|
|
||||||
seq: int
|
seq: int # monotonically increasing
|
||||||
t: float
|
t: float # seconds (relative or epoch)
|
||||||
values: list[float]
|
values: list[float] # one per sensor, in sensor-layout order
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Per-frame descriptive statistics"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Stats:
|
||||||
|
min: float; min_index: int
|
||||||
|
max: float; max_index: int
|
||||||
|
diff: float; avg: float
|
||||||
|
sigma: float; three_sigma: float
|
||||||
|
|
||||||
|
|
||||||
|
def compute_stats(values: list[float]) -> Stats:
|
||||||
|
clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)]
|
||||||
|
if not clean:
|
||||||
|
return Stats(0.0, -1, 0.0, -1, 0.0, 0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
min_index, min_v = min(clean, key=lambda iv: iv[1])
|
||||||
|
max_index, max_v = max(clean, key=lambda iv: iv[1])
|
||||||
|
nums = [v for _, v in clean]
|
||||||
|
avg = sum(nums) / len(nums)
|
||||||
|
variance = sum((v - avg) ** 2 for v in nums) / len(nums)
|
||||||
|
sigma = math.sqrt(variance)
|
||||||
|
|
||||||
|
return Stats(
|
||||||
|
min=min_v,
|
||||||
|
min_index=min_index,
|
||||||
|
max=max_v,
|
||||||
|
max_index=max_index,
|
||||||
|
diff=max_v - min_v,
|
||||||
|
avg=avg,
|
||||||
|
sigma=sigma,
|
||||||
|
three_sigma=3 * sigma,
|
||||||
|
)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Detect process state (Idle, Ramp, Set) from the running average temp"""
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
STATE_IDLE = "idle"
|
||||||
|
STATE_RAMP = "ramp"
|
||||||
|
STATE_SET = "set"
|
||||||
|
|
||||||
|
|
||||||
|
class StabilityDetector:
|
||||||
|
def __init__(self, idle_below: float = 50.0, tolerance: float = 1.0,
|
||||||
|
settle_seconds: float = 10.0) -> None:
|
||||||
|
self._idle_below = idle_below
|
||||||
|
self._tolerance = tolerance
|
||||||
|
self._settle_seconds = settle_seconds
|
||||||
|
self._near_since: Optional[float] = None # When avg entered the +- tolerance band
|
||||||
|
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._near_since = None
|
||||||
|
|
||||||
|
def update(self, avg: float, time: float, set_point: float) ->str:
|
||||||
|
if avg < self._idle_below:
|
||||||
|
self._near_since = None
|
||||||
|
return STATE_IDLE
|
||||||
|
if abs(avg - set_point) <= self._tolerance:
|
||||||
|
if self._near_since is None:
|
||||||
|
self._near_since = time
|
||||||
|
if time - self._near_since >= self._settle_seconds:
|
||||||
|
return STATE_SET
|
||||||
|
return STATE_RAMP
|
||||||
|
self._near_since = None
|
||||||
|
return STATE_RAMP
|
||||||
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Classify sensor values into three bands around (target, margin)
|
||||||
|
|
||||||
|
Auto mode derives target=mean, margin=1
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
BAND_IN = "in_range"
|
||||||
|
BAND_HIGH = "high"
|
||||||
|
BAND_LOW = "low"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThresholdConfig:
|
||||||
|
set_point: float = 149.0 # process target: used as band TARGET when auto=False
|
||||||
|
margin: float = 1.0 # used as band MARGIN when auto=False
|
||||||
|
auto: bool = True # auto=True: target=frame mean, margin=frame 1σ
|
||||||
|
|
||||||
|
def resolve_bounds(values: list[float], cfg: ThresholdConfig) -> tuple[float, float]:
|
||||||
|
if not cfg.auto:
|
||||||
|
return cfg.set_point, cfg.margin
|
||||||
|
clean = [v for v in values if not math.isnan(v)]
|
||||||
|
if not clean:
|
||||||
|
return cfg.set_point, cfg.margin
|
||||||
|
mean = sum(clean) / len(clean)
|
||||||
|
variance = sum((v - mean) ** 2 for v in clean) / len(clean)
|
||||||
|
return mean, math.sqrt(variance)
|
||||||
|
|
||||||
|
def classify(value: float, target: float, margin: float) -> str:
|
||||||
|
if math.isnan(value):
|
||||||
|
return BAND_IN
|
||||||
|
if value > target + margin:
|
||||||
|
return BAND_HIGH
|
||||||
|
if value < target - margin:
|
||||||
|
return BAND_LOW
|
||||||
|
return BAND_IN
|
||||||
|
|
||||||
|
def classify_all(values: list[float], cfg: ThresholdConfig) -> list[str]:
|
||||||
|
target, margin = resolve_bounds(values, cfg)
|
||||||
|
return [classify(v, target, margin)for v in values ]
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Tests for serialcomm/data_parser.py binary parsing pipeline."""
|
"""Tests for serialcomm/data_parser.py binary parsing pipeline."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from serialcomm.data_parser import (
|
from pygui.serialcomm.data_parser import (
|
||||||
csv_column_count,
|
csv_column_count,
|
||||||
parse_binary_data,
|
parse_binary_data,
|
||||||
convert_to_temperatures,
|
convert_to_temperatures,
|
||||||
@@ -12,7 +12,6 @@ from serialcomm.data_parser import (
|
|||||||
MAXDUT_X,
|
MAXDUT_X,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── csv_column_count ──────────────────────────────────────────────────────────
|
# ── csv_column_count ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -203,6 +202,7 @@ class TestConvertToTemperatures:
|
|||||||
def test_p_family_single_block(self):
|
def test_p_family_single_block(self):
|
||||||
data = _make_p_block(1, value=0x0100)
|
data = _make_p_block(1, value=0x0100)
|
||||||
hex_data = parse_binary_data(data, "P")
|
hex_data = parse_binary_data(data, "P")
|
||||||
|
assert hex_data is not None
|
||||||
result = convert_to_temperatures(hex_data, "P")
|
result = convert_to_temperatures(hex_data, "P")
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert all(isinstance(v, str) for v in result[0])
|
assert all(isinstance(v, str) for v in result[0])
|
||||||
@@ -270,9 +270,9 @@ class TestSaveToCsv:
|
|||||||
result = save_to_csv(data, family, f"{family}00001", str(tmp_path))
|
result = save_to_csv(data, family, f"{family}00001", str(tmp_path))
|
||||||
assert result is not None, f"save_to_csv returned None for {family}"
|
assert result is not None, f"save_to_csv returned None for {family}"
|
||||||
headers = open(result).readline().strip().split(",")
|
headers = open(result).readline().strip().split(",")
|
||||||
assert len(headers) == expected_cols, (
|
assert (
|
||||||
f"{family}: expected {expected_cols} headers, got {len(headers)}"
|
len(headers) == expected_cols
|
||||||
)
|
), f"{family}: expected {expected_cols} headers, got {len(headers)}"
|
||||||
assert headers[0] == "Sensor1"
|
assert headers[0] == "Sensor1"
|
||||||
assert headers[-1] == f"Sensor{expected_cols}"
|
assert headers[-1] == f"Sensor{expected_cols}"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import math
|
||||||
|
import pytest
|
||||||
|
from pygui.backend.frame_stats import compute_stats, Stats
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_stat():
|
||||||
|
s = compute_stats([148.0, 150.0, 149.0])
|
||||||
|
assert s.min == 148.0 and s.min_index == 0
|
||||||
|
assert s.max == 150.0 and s.max_index == 1
|
||||||
|
assert s.diff == pytest.approx(2.0)
|
||||||
|
assert s.avg == pytest.approx(149.0)
|
||||||
|
assert s.sigma == pytest.approx(math.sqrt(2 / 3))
|
||||||
|
assert s.three_sigma == pytest.approx(3 * math.sqrt(2 / 3))
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_values_returns_zeros():
|
||||||
|
s = compute_stats([])
|
||||||
|
assert s == Stats(0.0, -1, 0.0, -1, 0.0, 0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ignores_nan():
|
||||||
|
s = compute_stats([149.0, float("nan"), 151.0])
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from pygui.backend.stability_detector import (
|
||||||
|
StabilityDetector, STATE_IDLE, STATE_RAMP, STATE_SET,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SET_POINT = 149.0
|
||||||
|
|
||||||
|
def make():
|
||||||
|
return StabilityDetector(idle_below = 50.0, tolerance=1.0, settle_seconds=2.0)
|
||||||
|
|
||||||
|
def test_idle_when_cold():
|
||||||
|
d = make()
|
||||||
|
assert d.update(avg=25.0, time=0.0, set_point=SET_POINT) == STATE_IDLE
|
||||||
|
|
||||||
|
def test_ramp_while_far_from_setpoint():
|
||||||
|
d = make()
|
||||||
|
d.update(avg=100.0, time=0.0, set_point=SET_POINT)
|
||||||
|
|
||||||
|
def test_ramp_until_settle_time_elapses():
|
||||||
|
d = make()
|
||||||
|
assert d.update(avg=149.2, time=0.0, set_point=SET_POINT) == STATE_RAMP
|
||||||
|
assert d.update(avg=148.9, time=0.0, set_point=SET_POINT) == STATE_RAMP
|
||||||
|
|
||||||
|
def test_set_after_holding_near_setpoint():
|
||||||
|
d = make()
|
||||||
|
d.update(avg=149.2, time=0.0, set_point=SET_POINT)
|
||||||
|
d.update(avg=148.9, time=1.0, set_point=SET_POINT)
|
||||||
|
assert d.update(avg=149.0, time=2.5, set_point=SET_POINT) == STATE_SET
|
||||||
|
|
||||||
|
|
||||||
|
def test_back_to_ramp_on_disturbance():
|
||||||
|
d = make()
|
||||||
|
for t in (0.0, 1.0, 2.5):
|
||||||
|
d.update(149.0, t, set_point=SET_POINT)
|
||||||
|
assert d.update(avg=160.0, time=3.0, set_point=SET_POINT) == STATE_RAMP
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import math
|
||||||
|
|
||||||
|
from pygui.backend.threshold_classifier import (
|
||||||
|
ThresholdConfig, classify, classify_all, resolve_bounds,
|
||||||
|
BAND_IN, BAND_HIGH, BAND_LOW
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_about_target_margin():
|
||||||
|
assert classify(149.0, target=149.0, margin=1.0) ==BAND_IN # exactly
|
||||||
|
assert classify(149.9, target=149.0, margin=1.0) ==BAND_IN # within
|
||||||
|
assert classify(150.5, target=149.0, margin=1.0) ==BAND_HIGH # 1.5 above
|
||||||
|
assert classify(147.5, target=149.0, margin=1.0) ==BAND_LOW # 1.5 below
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_bounds_use_set_point_and_margin():
|
||||||
|
cfg = ThresholdConfig(set_point=149.0, margin=1.0, auto=False)
|
||||||
|
assert resolve_bounds([200.0, 0.0], cfg) == (149.0, 1.0)
|
||||||
|
|
||||||
|
def test_auto_bounds_use_mean_and_sigma():
|
||||||
|
cfg = ThresholdConfig(auto=True)
|
||||||
|
target, margin = resolve_bounds([148.0, 150.0, 149.0], cfg)
|
||||||
|
assert target == 149.0
|
||||||
|
assert margin == math.sqrt(2 / 3)
|
||||||
|
|
||||||
|
def test_classify_all_manual():
|
||||||
|
cfg = ThresholdConfig(set_point=149.0, margin=1.0, auto=False)
|
||||||
|
assert classify_all([149.0, 151.0, 147.0], cfg) == [BAND_IN, BAND_HIGH, BAND_LOW]
|
||||||
|
|
||||||
Reference in New Issue
Block a user