fixed compatibility issue

This commit is contained in:
2026-06-23 12:07:43 -04:00
parent e51a6777fc
commit 21402f7ee3
5 changed files with 80 additions and 64 deletions
+15 -3
View File
@@ -6,6 +6,7 @@ from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Iterator from typing import Any, Iterator
from graph import build_station_testpoint_map
APP_ROOT = Path(__file__).resolve().parent APP_ROOT = Path(__file__).resolve().parent
@@ -36,6 +37,7 @@ class TestRecord:
status: str = "pending" status: str = "pending"
excluded: bool = False excluded: bool = False
raw_payload: dict[str, Any] | None = None raw_payload: dict[str, Any] | None = None
station_testpoint_map: str | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -141,6 +143,7 @@ def init_db(db_path: str | Path = DB_PATH) -> None:
_ensure_column(conn, "tests", "victim_band", "TEXT") _ensure_column(conn, "tests", "victim_band", "TEXT")
_ensure_column(conn, "tests", "excluded", "INTEGER NOT NULL DEFAULT 0") _ensure_column(conn, "tests", "excluded", "INTEGER NOT NULL DEFAULT 0")
_ensure_column(conn, "tests", "throttled", "INTEGER NOT NULL DEFAULT 0") _ensure_column(conn, "tests", "throttled", "INTEGER NOT NULL DEFAULT 0")
_ensure_column(conn, "tests", "station_testpoint_map", "TEXT")
# Seed runtime defaults for schedule estimation. # Seed runtime defaults for schedule estimation.
conn.execute( conn.execute(
@@ -154,6 +157,12 @@ def init_db(db_path: str | Path = DB_PATH) -> None:
) )
def _serialize_station_testpoint_map(config: dict[str, dict[str, str | None]]) -> str:
"""Compute and serialize the station-to-testpoint map from a test config."""
station_map = build_station_testpoint_map(config)
return json.dumps(station_map)
def upsert_tests(records: list[TestRecord], db_path: str | Path = DB_PATH) -> int: def upsert_tests(records: list[TestRecord], db_path: str | Path = DB_PATH) -> int:
if not records: if not records:
return 0 return 0
@@ -163,9 +172,9 @@ def upsert_tests(records: list[TestRecord], db_path: str | Path = DB_PATH) -> in
""" """
INSERT INTO tests( INSERT INTO tests(
test_id, device, test_type, rotation, rx_tx, has_coe_pair, test_id, device, test_type, rotation, rx_tx, has_coe_pair,
coe_pairing_json, config_json, priority, victim_band, throttled, estimated_minutes, status, excluded, raw_payload coe_pairing_json, config_json, priority, victim_band, throttled, estimated_minutes, status, excluded, raw_payload, station_testpoint_map
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(test_id, device) DO UPDATE SET ON CONFLICT(test_id, device) DO UPDATE SET
test_type = excluded.test_type, test_type = excluded.test_type,
device = excluded.device, device = excluded.device,
@@ -181,6 +190,7 @@ def upsert_tests(records: list[TestRecord], db_path: str | Path = DB_PATH) -> in
status = excluded.status, status = excluded.status,
excluded = excluded.excluded, excluded = excluded.excluded,
raw_payload = excluded.raw_payload, raw_payload = excluded.raw_payload,
station_testpoint_map = excluded.station_testpoint_map,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
""", """,
[ [
@@ -200,6 +210,7 @@ def upsert_tests(records: list[TestRecord], db_path: str | Path = DB_PATH) -> in
r.status, r.status,
int(r.excluded), int(r.excluded),
json.dumps(r.raw_payload or {}), json.dumps(r.raw_payload or {}),
_serialize_station_testpoint_map(r.config),
) )
for r in records for r in records
], ],
@@ -403,7 +414,7 @@ def list_tests_for_device(device: str, db_path: str | Path = DB_PATH) -> list[Te
test_id, device, test_type, rotation, rx_tx, has_coe_pair, test_id, device, test_type, rotation, rx_tx, has_coe_pair,
config_json, victim_band, config_json, victim_band,
coe_pairing_json, priority, throttled, estimated_minutes, coe_pairing_json, priority, throttled, estimated_minutes,
excluded, status, raw_payload excluded, status, raw_payload, station_testpoint_map
FROM tests FROM tests
WHERE device = ? WHERE device = ?
""", """,
@@ -429,6 +440,7 @@ def list_tests_for_device(device: str, db_path: str | Path = DB_PATH) -> list[Te
status=row["status"], status=row["status"],
excluded=bool(row["excluded"]), excluded=bool(row["excluded"]),
raw_payload=json.loads(row["raw_payload"] or "{}"), raw_payload=json.loads(row["raw_payload"] or "{}"),
station_testpoint_map=row["station_testpoint_map"],
) )
) )
+42 -57
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json
from typing import Any from typing import Any
from pathlib import Path
# Immutable-in-practice graph state for the loaded DUT test set. # Immutable-in-practice graph state for the loaded DUT test set.
_TESTS_BY_ID: dict[str, Any] = {} _TESTS_BY_ID: dict[str, Any] = {}
@@ -13,6 +15,19 @@ def reset_graph_state() -> None:
_GRAPH = {} _GRAPH = {}
def _get_station_map(test: Any) -> dict[str, str]:
"""Get station testpoint map from test record or compute it from config."""
# Try to get from serialized map first (cached from DB)
if hasattr(test, 'station_testpoint_map') and test.station_testpoint_map:
try:
return json.loads(test.station_testpoint_map)
except (json.JSONDecodeError, TypeError):
pass
# Safeguard: return empty map
return {}
def is_graph_built() -> bool: def is_graph_built() -> bool:
return bool(_GRAPH) return bool(_GRAPH)
@@ -45,56 +60,49 @@ def _build_graph(tests: dict[str, Any]) -> dict[str, set[str]]:
if _compatible(a, b): if _compatible(a, b):
graph[a_id].add(b_id) graph[a_id].add(b_id)
graph[b_id].add(a_id) graph[b_id].add(a_id)
# Print out graph for debugging
out = Path('output1.txt')
with out.open('w', encoding='utf-8') as f:
f.write(json.dumps({k: list(v) for k, v in graph.items()}, indent=2))
return graph return graph
def _compatible(a: Any, b: Any) -> bool: def _compatible(a: Any, b: Any) -> bool:
# Hard conflict: same testpoint but different STA assignment means they cannot share a window. station_map_a = _get_station_map(a)
if _has_testpoint_sta_conflict(a.config, b.config): station_map_b = _get_station_map(b)
return False if _same_station_to_testpoint(station_map_a, station_map_b, a, b) and _same_testpoint_to_station(station_map_a, station_map_b, a, b):
# Compatible when same rotation, or both non-P3P and overlap has identical testpoints.
if a.rotation == b.rotation:
return True
if a.test_type != "P3P" and b.test_type != "P3P" and _same_test_points_for_overlap(a.config, b.config):
return True return True
return False return False
def _same_station_to_testpoint(
def _has_testpoint_sta_conflict( station_map_a: dict[str, str],
config_a: dict[str, dict[str, str | None]], station_map_b: dict[str, str], test_a: Any, test_b: Any
config_b: dict[str, dict[str, str | None]],
) -> bool: ) -> bool:
testpoint_to_sta_a = _build_testpoint_sta_map(config_a) overlap_stations = set(station_map_a.keys()) & set(station_map_b.keys())
testpoint_to_sta_b = _build_testpoint_sta_map(config_b) for station in overlap_stations:
overlap = set(testpoint_to_sta_a.keys()) & set(testpoint_to_sta_b.keys())
for testpoint in overlap:
if testpoint_to_sta_a[testpoint] != testpoint_to_sta_b[testpoint]:
return True
return False
def _same_test_points_for_overlap(
config_a: dict[str, dict[str, str | None]],
config_b: dict[str, dict[str, str | None]],
) -> bool:
station_map_a = _build_station_testpoint_map(config_a)
station_map_b = _build_station_testpoint_map(config_b)
overlap = set(station_map_a.keys()) & set(station_map_b.keys())
for station in overlap:
if station_map_a[station] != station_map_b[station]: if station_map_a[station] != station_map_b[station]:
return False return False
return True return True
def _same_testpoint_to_station(
station_map_a: dict[str, str],
station_map_b: dict[str, str], test_a: Any, test_b: Any
) -> bool:
overlap_testpoints = set(station_map_a.values()) & set(station_map_b.values())
for testpoint in overlap_testpoints:
stations_a = {s for s, t in station_map_a.items() if t == testpoint}
stations_b = {s for s, t in station_map_b.items() if t == testpoint}
if stations_a != stations_b:
print(f"Tests {test_a.test_id} and {test_b.test_id} have conflicting stations for testpoint. {station_map_a} vs {station_map_b}")
return False
return True
def _build_station_testpoint_map(config: dict[str, dict[str, str | None]]) -> dict[str, str]: def build_station_testpoint_map(config: dict[str, dict[str, str | None]]) -> dict[str, str]:
station_to_testpoint: dict[str, str] = {} station_to_testpoint: dict[str, str] = {}
def _add_entry(entry: dict[str, str | None]) -> None: def _add_entry(entry: dict[str, str | None]) -> None:
testpoint = _norm(entry.get("test_point") or entry.get("Testpoint")) testpoint = _norm(entry.get("test_point"))
sta_raw = _norm(entry.get("sta") or entry.get("STA")) sta_raw = _norm(entry.get("sta"))
if not testpoint or not sta_raw: if not testpoint or not sta_raw:
return return
for sta in sta_raw.split(","): for sta in sta_raw.split(","):
@@ -113,29 +121,6 @@ def _build_station_testpoint_map(config: dict[str, dict[str, str | None]]) -> di
return station_to_testpoint return station_to_testpoint
def _build_testpoint_sta_map(config: dict[str, dict[str, str | None]]) -> dict[str, set[str]]:
testpoint_to_sta: dict[str, set[str]] = {}
def _add_entry(entry: dict[str, str | None]) -> None:
testpoint = _norm(entry.get("test_point") or entry.get("Testpoint"))
sta_raw = _norm(entry.get("sta") or entry.get("STA"))
if not testpoint or not sta_raw:
return
for sta in sta_raw.split(","):
sta_clean = _norm(sta)
if sta_clean:
testpoint_to_sta.setdefault(testpoint, set()).add(sta_clean)
for band in ("5G", "6G", "2G"):
entry = config.get(band) or {}
_add_entry(entry)
for station_key in ("Station 1", "Station 2", "Station 3"):
entry = config.get(station_key) or {}
_add_entry(entry)
return testpoint_to_sta
def _norm(value: Any) -> str: def _norm(value: Any) -> str:
if value is None: if value is None:
+1 -1
View File
@@ -314,7 +314,7 @@ def _fit_bundles_to_shifts(
state = shift_state[date_shift] state = shift_state[date_shift]
state["remaining_minutes"] += prev_remaining # Add back any leftover from previous shift state["remaining_minutes"] += prev_remaining # Add back any leftover from previous shift
if state['remaining_minutes'] >= test.estimated_minutes: if state['remaining_minutes'] >= test.estimated_minutes:
print(f"remaining minutes for {date_shift}: {state['remaining_minutes']} - placing {key} ({test.estimated_minutes}m)") #print(f"remaining minutes for {date_shift}: {state['remaining_minutes']} - placing {key} ({test.estimated_minutes}m)")
entries.append( entries.append(
ScheduleEntry( ScheduleEntry(
test_id=test.test_id, test_id=test.test_id,
+1 -1
View File
@@ -64,7 +64,7 @@ export default function RightPanel({
{/* Top priority */} {/* Top priority */}
<div> <div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1.5"> <label className="block text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1.5">
Top Priority Top Priority Tests
</label> </label>
<textarea <textarea
rows={3} rows={3}
+20 -1
View File
@@ -5,6 +5,24 @@ const STATUS_STYLES = {
invalid: 'bg-yellow-800/60 text-yellow-200 border-yellow-600', invalid: 'bg-yellow-800/60 text-yellow-200 border-yellow-600',
} }
function formatConfig(config) {
if (!config || typeof config !== 'object') return '—'
const entries = Object.entries(config)
if (entries.length === 0) return '—'
const formattedEntries = entries
.map(([band, data]) => {
if (!data || typeof data !== 'object') return null
const testpoint = data.test_point || data['Test Point']
const sta = data.sta || data.STA
// Only display if both testpoint and sta are present
if (!testpoint || !sta) return null
return `${band}: ${testpoint}${sta}`
})
.filter(Boolean)
return formattedEntries.length > 0 ? formattedEntries.join('; ') : '—'
}
export default function TestCard({ test }) { export default function TestCard({ test }) {
const style = STATUS_STYLES[test.status] ?? STATUS_STYLES.pending const style = STATUS_STYLES[test.status] ?? STATUS_STYLES.pending
@@ -18,10 +36,11 @@ export default function TestCard({ test }) {
</div> </div>
{/* Hover tooltip */} {/* Hover tooltip */}
<div className="absolute z-50 bottom-full left-0 mb-1 hidden group-hover:block min-w-max"> <div className="absolute z-50 bottom-full left-0 mb-1 hidden group-hover:block min-w-max">
<div className="bg-gray-800 border border-gray-600 rounded-md px-2.5 py-1.5 text-xs text-gray-200 shadow-lg"> <div className="bg-gray-800 border border-gray-600 rounded-md px-2.5 py-1.5 text-xs text-gray-200 shadow-lg max-w-md">
<p><span className="text-gray-400">Type:</span> {test.test_type ?? '—'}</p> <p><span className="text-gray-400">Type:</span> {test.test_type ?? '—'}</p>
<p><span className="text-gray-400">Rotation:</span> {test.rotation ?? '—'}</p> <p><span className="text-gray-400">Rotation:</span> {test.rotation ?? '—'}</p>
<p><span className="text-gray-400">Device:</span> {test.device ?? '—'}</p> <p><span className="text-gray-400">Device:</span> {test.device ?? '—'}</p>
<p className="mt-1 pt-1 border-t border-gray-600"><span className="text-gray-400">Config:</span> {formatConfig(test.config)}</p>
</div> </div>
</div> </div>
</div> </div>