diff --git a/DESIGNPLAN.md b/DESIGNPLAN.md
index b69627e..3067ab5 100644
--- a/DESIGNPLAN.md
+++ b/DESIGNPLAN.md
@@ -7,7 +7,7 @@ A test scheduler for the NJTH which compiles a best schedule according to the ru
- DUT test result directory path, REF test result directory path
- Estimated runtime for each type of test for calculations
-- Rotation definition
+- Test config definition
- Test exclusions
- Top priority tests
- Allow user to manually override and enter tests that needs to be run tonight
@@ -15,23 +15,30 @@ A test scheduler for the NJTH which compiles a best schedule according to the ru
## Output
### Backend
-- Compile a schedule that would require the lowest amount of time in total according to the following rules and priority:
+- Compile a schedule that would require the lowest amount of time and least amount of wasted time in total according to the following rules and priority:
- **Rules**
- - Can be run in the same night
- - Same rotation and same test point P2P/COE tests
- - COERXBE002: T1F -> STA63, T2A -> STA56, T3E -> STA4, R3
- - COERXBE003: T1F -> STA63, T2A -> STA56, T3E -> STA4, R3
-
- - Different rotations but all stations are at same testpoints
- - R2 P2PRXAC003 5G T1C -> STA4
- - R1 P2PRXAC012 2G T1C -> STA4
-
- - Same rotation and same test point P3P tests
- - Needs to be run back to back
+ - Test Windows
+ - Weekday night time test window: Monday - Thursday
+ - Starts 5PM of that day, ends 9AM the next day
+ - Capcity: 17 hrs (16hrs + 1hr margin)
+ - Ex. Monday 6/29 5PM - Tuesday 6/30 9AM
+ - Weekday day time test window: Monday-Thursday
+ - Starts 9AM - 5PM of that day
+ - Capacity: 8 hrs
+ - Weekend test window: Friday - Sunday
+ - Starts Friday 5PM, ends Monday 9AM
+ - Capcity: 65 hrs (64hrs + 1hr margin)
+ - Holiday test window
+ - All tests in the same test window needs to have the same test config
+ - Tests are performed on one device in each test window, unless overrides by user
+ - Some tests needs to be run back to back
+ - P2P COE pairings
+ - Run P2P first then its COE pairings
- P2P up link (RX) and down link (TX)
- Ex. P2PRXAX001 and P2PTXAX001
- - COE P2P bundle
- - DUT and REF tests
+ - P3P throttled(TH) and unthrottled(UT)
+ - Same tests, different devices(DUT, REF) needs to be run in back to back test windows
+ - Ex. Monday
- **Priority**
- Default priority: COE P2P pair -> P2P only -> P3P
- 1: highest priority (must run tonight)
@@ -40,8 +47,6 @@ A test scheduler for the NJTH which compiles a best schedule according to the ru
- 4: P3P
- 5: lowest priority
- Priority 1 and 5 needs to be entered manually
- - Same test points and same rotation will be run first
- - Same test points but different will run if there is still remaining testing time for the day
- **Calculation**
- Use the estimate run time for each type of test to fit as many tests as possible in a shift of testing.
- Weekdays are 16hrs, weekends are 24 hours with 1hr margin
@@ -137,7 +142,7 @@ build_test_bundles():
- Create bundles of tests that needs to be run back to back
- Types of test bundles and their order
1. Failed tests rerun if exist
- 2. The base is P2P and has COE pairs: P2P on DUT -> COE pairings on DUT -> P2P on REF -> COE pairings on REF
+ 2. The P2P and COE pairs: P2P -> COE pairings
3. P2P tests without COE pairs: P2PRX on DUT -> P2PTX on DUT -> P2PRX on REF -> P2PTX on REF
4. P3P tests (ALL ROT's)
```
diff --git a/backend/app.py b/backend/app.py
index 5e36462..0710031 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -1,7 +1,7 @@
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
-from datetime import date, datetime
+from datetime import date, datetime, timedelta
import os
from fastapi import FastAPI, HTTPException
@@ -9,9 +9,15 @@ from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
import db
-import graph
from parser import CsvValidationError, parse_target_csv
-from scheduler import SchedulerTest, compile_schedule, remove_from_active, reset_scheduler_state
+
+# Ensure new_scheduler resolves the same DUT/REF labels as db records.
+os.environ.setdefault("DUT", db.DEVICE_DUT)
+os.environ.setdefault("REF", db.DEVICE_REF)
+
+from scheduler import Scheduler, Test as SchedulerTest
+from test_config import build_bundle_test_configs, build_config_rows
+from test_window import get_shift_capacity_for_date, get_shift_sequence, is_off_day
from watcher import configure_result_watcher, stop_result_watcher
@@ -21,6 +27,7 @@ DUT = os.getenv("DUT", "CGW453").strip()
REF = os.getenv("REF", "CGW452").strip()
+
class LoadTestsRequest(BaseModel):
csv_path: str = Field(..., description="Absolute or backend-relative path to target CSV")
@@ -80,10 +87,179 @@ class SaveHolidaysRequest(BaseModel):
dates: list[str] = Field(default_factory=list)
+SHIFT_LABELS = {
+ 1: "12AM–9AM",
+ 2: "9AM–5PM",
+ 3: "5PM–12AM",
+}
+
+SHIFT_BOUNDARIES = {
+ 1: ((0, 0), (9, 0)),
+ 2: ((9, 0), (17, 0)),
+ 3: ((17, 0), (24, 0)),
+}
+
+
+def _serialize_schedule_row(row: db.ScheduleRow) -> dict[str, Any]:
+ return {
+ "test_id": row.test_id,
+ "device": row.device,
+ "scheduled_date": row.scheduled_date,
+ "shift_index": row.shift_index,
+ "sequence_in_shift": row.sequence_in_shift,
+ "test_type": row.test_type,
+ "rotation": row.rotation,
+ "config": row.config,
+ "status": row.status,
+ "priority": row.priority,
+ "estimated_minutes": row.estimated_minutes,
+ }
+
+
+def _window_segment_key(day: date, shift_index: int) -> str:
+ return f"{day.isoformat()}::shift{shift_index}"
+
+
+def _combine_date_time(day: date, hour: int, minute: int) -> str:
+ if hour == 24:
+ return datetime.combine(day + timedelta(days=1), datetime.min.time()).isoformat(timespec="minutes")
+ return datetime.combine(day, datetime.min.time()).replace(hour=hour, minute=minute).isoformat(timespec="minutes")
+
+
+def _window_intersects_week(segments: list[tuple[date, int]], week_start: date, week_end: date) -> bool:
+ for day, _shift_index in segments:
+ if week_start <= day < week_end:
+ return True
+ return False
+
+
+def _build_schedule_windows(week_start: date, all_rows: list[db.ScheduleRow], holiday_dates: set[str]) -> list[dict[str, Any]]:
+ if not all_rows:
+ return []
+
+ all_dates = [datetime.strptime(row.scheduled_date, "%Y-%m-%d").date() for row in all_rows]
+ first_scheduled_date = min(all_dates)
+ last_scheduled_date = max(all_dates)
+ week_end = week_start + timedelta(days=7)
+
+ rows_by_segment: dict[tuple[str, int], list[dict[str, Any]]] = {}
+ weekday_shift2_dates: set[str] = set()
+ for row in all_rows:
+ serialized = _serialize_schedule_row(row)
+ rows_by_segment.setdefault((row.scheduled_date, row.shift_index), []).append(serialized)
+ row_date = datetime.strptime(row.scheduled_date, "%Y-%m-%d").date()
+ if row.shift_index == 2 and not is_off_day(row_date, holiday_dates):
+ weekday_shift2_dates.add(row.scheduled_date)
+
+ simulation_start = min(first_scheduled_date, week_start) - timedelta(days=7)
+ while is_off_day(simulation_start, holiday_dates):
+ simulation_start -= timedelta(days=1)
+
+ simulation_end = max(last_scheduled_date, week_end) + timedelta(days=7)
+ current_date = simulation_start
+ windows: list[dict[str, Any]] = []
+ seen_window_ids: set[str] = set()
+
+ while current_date <= simulation_end:
+ current_iso = current_date.isoformat()
+
+ if current_iso in weekday_shift2_dates:
+ segments = get_shift_sequence(current_date, holiday_dates, daytime_shift2_only=True)
+ window_id = f"{current_iso}-shift2"
+ if window_id not in seen_window_ids and _window_intersects_week(segments, week_start, week_end):
+ seen_window_ids.add(window_id)
+ shift_tests = [
+ test
+ for day, shift_index in segments
+ for test in rows_by_segment.get((day.isoformat(), shift_index), [])
+ ]
+ available_runtime_minutes = sum(
+ get_shift_capacity_for_date(day, holiday_dates, daytime_shift2_only=True).get(shift_index, 0)
+ for day, shift_index in segments
+ )
+ start_hour, start_minute = SHIFT_BOUNDARIES[segments[0][1]][0]
+ end_hour, end_minute = SHIFT_BOUNDARIES[segments[-1][1]][1]
+ windows.append({
+ "window_id": window_id,
+ "window_type": "daytime",
+ "start_date": segments[0][0].isoformat(),
+ "start_shift_index": segments[0][1],
+ "end_date": segments[-1][0].isoformat(),
+ "end_shift_index": segments[-1][1],
+ "start_at": _combine_date_time(segments[0][0], start_hour, start_minute),
+ "end_at": _combine_date_time(segments[-1][0], end_hour, end_minute),
+ "available_runtime_minutes": available_runtime_minutes,
+ "estimated_runtime_minutes": sum(test["estimated_minutes"] for test in shift_tests),
+ "bundle_test_configs": build_bundle_test_configs(shift_tests),
+ "segments": [
+ {
+ "date": day.isoformat(),
+ "shift_index": shift_index,
+ "label": SHIFT_LABELS[shift_index],
+ "segment_key": _window_segment_key(day, shift_index),
+ }
+ for day, shift_index in segments
+ ],
+ "configRows": build_config_rows(shift_tests),
+ "tests": shift_tests,
+ })
+
+ segments = get_shift_sequence(current_date, holiday_dates, daytime_shift2_only=False)
+ last_sequence_date = segments[-1][0]
+ last_shift_index = segments[-1][1]
+ window_kind = "offday" if is_off_day(current_date, holiday_dates) else "overnight"
+ window_id = f"{current_iso}-shift{segments[0][1]}"
+
+ if window_id not in seen_window_ids and _window_intersects_week(segments, week_start, week_end):
+ seen_window_ids.add(window_id)
+ window_tests = [
+ test
+ for day, shift_index in segments
+ for test in rows_by_segment.get((day.isoformat(), shift_index), [])
+ ]
+ available_runtime_minutes = sum(
+ get_shift_capacity_for_date(day, holiday_dates, daytime_shift2_only=False).get(shift_index, 0)
+ for day, shift_index in segments
+ )
+ start_hour, start_minute = SHIFT_BOUNDARIES[segments[0][1]][0]
+ end_hour, end_minute = SHIFT_BOUNDARIES[segments[-1][1]][1]
+ windows.append({
+ "window_id": window_id,
+ "window_type": window_kind,
+ "start_date": segments[0][0].isoformat(),
+ "start_shift_index": segments[0][1],
+ "end_date": segments[-1][0].isoformat(),
+ "end_shift_index": segments[-1][1],
+ "start_at": _combine_date_time(segments[0][0], start_hour, start_minute),
+ "end_at": _combine_date_time(segments[-1][0], end_hour, end_minute),
+ "available_runtime_minutes": available_runtime_minutes,
+ "estimated_runtime_minutes": sum(test["estimated_minutes"] for test in window_tests),
+ "bundle_test_configs": build_bundle_test_configs(window_tests),
+ "segments": [
+ {
+ "date": day.isoformat(),
+ "shift_index": shift_index,
+ "label": SHIFT_LABELS[shift_index],
+ "segment_key": _window_segment_key(day, shift_index),
+ }
+ for day, shift_index in segments
+ ],
+ "configRows": build_config_rows(window_tests),
+ "tests": window_tests,
+ })
+
+ if last_shift_index in {1, 2}:
+ current_date = last_sequence_date
+ else:
+ current_date = last_sequence_date + timedelta(days=1)
+
+ windows.sort(key=lambda window: (window["start_at"], window["window_id"]))
+ return windows
+
+
@asynccontextmanager
async def lifespan(application: FastAPI):
db.init_db(DB_PATH)
- graph.reset_graph_state()
settings = db.read_settings(DB_PATH)
configure_result_watcher(settings)
try:
@@ -141,21 +317,17 @@ def load_tests(request: LoadTestsRequest) -> dict[str, Any]:
raise HTTPException(status_code=400, detail=str(exc)) from exc
count = db.upsert_tests(parsed.tests, DB_PATH)
- reset_scheduler_state()
- graph.reset_graph_state()
- all_dut_tests = db.list_tests_for_device(DUT, DB_PATH)
- graph.build_and_persist_graph(all_dut_tests, DB_PATH)
+ print(f"Loaded {count} tests from {csv_path}, with {len(parsed.warnings)} warnings.")
return {
"loaded_tests": count,
"warnings": parsed.warnings,
- "dut_graph_nodes": len(graph.get_graph()),
}
-
@app.post("/api/schedule/active/remove")
def remove_active_tests(request: RemoveActiveTestsRequest) -> dict[str, Any]:
- remove_from_active({item.strip() for item in request.test_ids if item.strip()})
+ # new_scheduler does not keep global mutable active state in app lifecycle.
+ # Keep endpoint for compatibility with frontend calls.
return {"status": "ok", "removed": len(request.test_ids)}
@@ -167,7 +339,6 @@ def compile_schedule_endpoint(request: CompileScheduleRequest) -> dict[str, Any]
except ValueError as exc:
raise HTTPException(status_code=400, detail="start_date must be YYYY-MM-DD") from exc
- reset_scheduler_state()
stored_tests = db.list_schedulable_tests(DB_PATH, rule=request.rule)
if not stored_tests:
version = db.create_schedule_version([], DB_PATH)
@@ -189,29 +360,36 @@ def compile_schedule_endpoint(request: CompileScheduleRequest) -> dict[str, Any]
config=t.config,
throttled=t.throttled,
estimated_minutes=t.estimated_minutes,
- priority=t.priority,
- raw_payload=t.raw_payload or {},
)
for t in stored_tests
]
- # DB-backed graph retrieval ensures compile works after restart without manual save/load.
- all_dut_tests = db.list_tests_for_device(DUT, DB_PATH)
- graph.get_graph(DB_PATH, all_dut_tests)
-
holiday_dates = db.list_holidays(DB_PATH)
+ top_priority_pairs: set[tuple[str, str]] = set()
+ for test_id in {item.strip() for item in request.top_priority_tests if item.strip()}:
+ if any(t.test_id == test_id and t.device == DUT for t in stored_tests):
+ top_priority_pairs.add((test_id, DUT))
+ if any(t.test_id == test_id and t.device == REF for t in stored_tests):
+ top_priority_pairs.add((test_id, REF))
+
+ scheduler = Scheduler(
+ tests=scheduler_tests,
+ top_priority_tests=top_priority_pairs,
+ start_date=request.start_date,
+ holiday_dates=holiday_dates,
+ daytime_testing_today=request.daytime_testing_today,
+ )
+
try:
- entries, completion_date = compile_schedule(
- tests=scheduler_tests,
- start_date=request.start_date,
- holiday_dates=holiday_dates,
- top_priority_tests={item.strip() for item in request.top_priority_tests if item.strip()},
- lowest_priority_tests={item.strip() for item in request.lowest_priority_tests if item.strip()},
- daytime_testing_today=request.daytime_testing_today,
- )
- except RuntimeError as exc:
+ schedule_error = scheduler.compile_schedule()
+ entries = scheduler.get_schedule()
+ except (RuntimeError, ValueError) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
+ completion_date = max((e.scheduled_date for e in entries), default=None)
+ if schedule_error:
+ raise HTTPException(status_code=409, detail=schedule_error)
+
version = db.create_schedule_version(
[(e.test_id, e.device, e.scheduled_date, e.shift_index, e.sequence_in_shift) for e in entries],
DB_PATH,
@@ -252,25 +430,14 @@ def get_schedule_week(start: str | None = None) -> dict[str, Any]:
except ValueError as exc:
raise HTTPException(status_code=400, detail="start must be YYYY-MM-DD") from exc
+ week_start_date = datetime.strptime(week_start, "%Y-%m-%d").date()
rows = db.get_schedule_week(week_start, DB_PATH)
+ all_rows = db.get_schedule_rows_for_latest_version(DB_PATH)
+ holiday_dates = db.list_holidays(DB_PATH)
return {
"start_date": week_start,
- "items": [
- {
- "test_id": row.test_id,
- "device": row.device,
- "scheduled_date": row.scheduled_date,
- "shift_index": row.shift_index,
- "sequence_in_shift": row.sequence_in_shift,
- "test_type": row.test_type,
- "rotation": row.rotation,
- "config": row.config,
- "status": row.status,
- "priority": row.priority,
- "estimated_minutes": row.estimated_minutes,
- }
- for row in rows
- ],
+ "items": [_serialize_schedule_row(row) for row in rows],
+ "windows": _build_schedule_windows(week_start_date, all_rows, holiday_dates),
}
if __name__ == "__main__":
diff --git a/backend/data/p2p_coe_tests.csv b/backend/data/CGW453_P2P_COE_Tests.csv
similarity index 83%
rename from backend/data/p2p_coe_tests.csv
rename to backend/data/CGW453_P2P_COE_Tests.csv
index 392590b..908f389 100644
--- a/backend/data/p2p_coe_tests.csv
+++ b/backend/data/CGW453_P2P_COE_Tests.csv
@@ -1,251 +1,251 @@
-Priority,Index,Interferer,COE Pair ,Rotation,TC ID,Victim Band,6GHz Power Mode,5G Test Point,5G Channel,5G Bandwidth,5G RSSI,5G Direction,5G STA,6G Test Point,6G Channel,6G Bandwidth,6G RSSI,6G Direction,6G STA,2G Test Point,2G Channel,2G Bandwidth,2G RSSI,2G Direction,2G STA
-P2,3,Yes,Yes,R2,COERXAC001,5G,LPI,T1F,100,80,-76,UL,STA4,T2A,5,160,OFF,DL,STA63,T3E,6,20,-70,DL,STA56
-P2,6,Yes,Yes,R2,COERXAC002,5G,LPI,T1F,161,80,-76,UL,STA4,T2A,133,160,OFF,DL,STA63,T3E,6,20,-70,DL,STA56
-P2,15,Yes,Yes,R1,COERXAC003,2G,LPI,T1F,36,80,-76,DL,STA56,T2A,5,160,OFF,DL,STA63,T3E,1,20,-70,UL,STA4
-P2,17,Yes,Yes,R1,COERXAC004,2G,LPI,T1F,36,80,-76,DL,STA56,T2A,133,160,OFF,DL,STA63,T3E,6,20,-70,UL,STA4
-P2,19,Yes,Yes,R1,COERXAC005,2G,LPI,T1F,36,80,-76,DL,STA56,T2A,197,160,OFF,DL,STA63,T3E,11,20,-70,UL,STA4
-P2,3,Yes,Yes,R1,COERXAX001,5G,LPI,T1F,100,80,-76,UL,STA56,T2A,5,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
-P2,4,Yes,Yes,R1,COERXAX002,5G,LPI,T1F,100,80,-76,UL,STA56,T2A,133,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
-P2,6,Yes,Yes,R1,COERXAX003,5G,LPI,T1F,161,80,-76,UL,STA56,T2A,5,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
-P2,7,Yes,Yes,R1,COERXAX004,5G,LPI,T1F,161,80,-76,UL,STA56,T2A,133,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
-P2,8,Yes,Yes,R1,COERXAX005,5G,SP,T1F,161,80,-76,UL,STA56,T2A,5,320,-79,DL,STA63,T3E,6,20,-70,DL,STA4
-P2,9,Yes,Yes,R1,COERXAX006,5G,LPI,T1F,161,80,-76,UL,STA56,T2A,133,320,-79,DL,STA63,T3E,6,20,-70,DL,STA4
-P2,11,Yes,Yes,R1,COERXAX007,5G,SP,T1F,100,160,-76,UL,STA56,T2A,5,320,-79,DL,STA63,T3E,6,20,-70,DL,STA4
-P2,12,Yes,Yes,R1,COERXAX008,5G,LPI,T1F,100,160,-76,UL,STA56,T2A,133,320,-79,DL,STA63,T3E,6,20,-70,DL,STA4
-P2,14,Yes,Yes,R2,COERXAX009,6G,LPI,T1F,161,80,-76,DL,STA63,T2A,5,160,-79,UL,STA56,T3E,6,20,-70,DL,STA4
-P2,16,Yes,Yes,R2,COERXAX010,6G,LPI,T1F,161,80,-76,DL,STA63,T2A,133,160,-79,UL,STA56,T3E,6,20,-70,DL,STA4
-P2,18,Yes,Yes,R2,COERXAX011,6G,LPI,T1F,161,80,-76,DL,STA63,T2A,197,160,-79,UL,STA56,T3E,6,20,-70,DL,STA4
-P2,20,Yes,Yes,R2,COERXAX012,6G,SP,T1F,161,80,-76,DL,STA63,T2A,5,320,-79,UL,STA56,T3E,6,20,-70,DL,STA4
-P2,22,Yes,Yes,R2,COERXAX013,6G,LPI,T1F,161,80,-76,DL,STA63,T2A,133,320,-79,UL,STA56,T3E,6,20,-70,DL,STA4
-P2,24,Yes,Yes,R3,COERXAX014,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,5,160,-79,DL,STA63,T3E,1,20,-70,UL,STA56
-P2,26,Yes,Yes,R3,COERXAX015,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,133,160,-79,DL,STA63,T3E,6,20,-70,UL,STA56
-P2,28,Yes,Yes,R3,COERXAX016,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,197,160,-79,DL,STA63,T3E,11,20,-70,UL,STA56
+Priority,Index,Interferer,COE Pair ,Rotation,TC ID,Victim Band,6GHz Power Mode,5G_Test Point,5G_Channel,5G_Bandwidth,5G_RSSI,5G_Direction,5G_STA,6G_Test Point,6G_Channel,6G_Bandwidth,6G_RSSI,6G_Direction,6G_STA,2G_Test Point,2G_Channel,2G_Bandwidth,2G_RSSI,2G_Direction,2G_STA
+P1,1,No,No,R3,P2PRXBE001,5G,LPI,T1F,36,80,-76,UL,STA63,,,,,,,,,,,,
+P1,1,No,No,R3,P2PRXBE002,5G,LPI,T1B,36,80,-67,UL,STA63,,,,,,,,,,,,
+P1,1,No,No,R3,P2PRXBE003,5G,LPI,T1C,36,80,-45,UL,STA63,,,,,,,,,,,,
+P1,2,No,Yes,R3,P2PRXBE004,5G,LPI,T1F,100,80,-76,UL,STA63,,,,,,,,,,,,
+P1,2,No,No,R3,P2PRXBE005,5G,LPI,T1B,100,80,-67,UL,STA63,,,,,,,,,,,,
+P1,2,No,No,R3,P2PRXBE006,5G,LPI,T1C,100,80,-45,UL,STA63,,,,,,,,,,,,
P2,3,Yes,Yes,R3,COERXBE001,5G,LPI,T1F,100,80,-76,UL,STA63,T2A,5,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4
P2,4,Yes,Yes,R3,COERXBE002,5G,LPI,T1F,100,80,-76,UL,STA63,T2A,133,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4
+P1,5,No,Yes,R3,P2PRXBE007,5G,LPI,T1F,161,80,-76,UL,STA63,,,,,,,,,,,,
+P1,5,No,No,R3,P2PRXBE008,5G,LPI,T1B,161,80,-67,UL,STA63,,,,,,,,,,,,
+P1,5,No,No,R3,P2PRXBE009,5G,LPI,T1C,161,80,-45,UL,STA63,,,,,,,,,,,,
P2,6,Yes,Yes,R3,COERXBE003,5G,LPI,T1F,161,80,-76,UL,STA63,T2A,5,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4
P2,7,Yes,Yes,R3,COERXBE004,5G,LPI,T1F,161,80,-76,UL,STA63,T2A,133,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4
P2,8,Yes,Yes,R3,COERXBE005,5G,SP,T1F,161,80,-76,UL,STA63,T2A,5,320,-79,DL,STA64,T3E,6,20,-70,DL,STA4
P2,9,Yes,Yes,R3,COERXBE006,5G,LPI,T1F,161,80,-76,UL,STA63,T2A,133,320,-79,DL,STA64,T3E,6,20,-70,DL,STA4
+P1,10,No,Yes,R3,P2PRXBE010,5G,LPI,T1F,100,160,-76,UL,STA63,,,,,,,,,,,,
+P1,10,No,No,R3,P2PRXBE011,5G,LPI,T1B,100,160,-67,UL,STA63,,,,,,,,,,,,
+P1,10,No,No,R3,P2PRXBE012,5G,LPI,T1C,100,160,-45,UL,STA63,,,,,,,,,,,,
P2,11,Yes,Yes,R3,COERXBE007,5G,SP,T1F,100,160,-76,UL,STA63,T2A,5,320,-79,DL,STA64,T3E,6,20,-70,DL,STA4
P2,12,Yes,Yes,R3,COERXBE008,5G,LPI,T1F,100,160,-76,UL,STA63,T2A,133,320,-79,DL,STA64,T3E,6,20,-70,DL,STA4
-P2,14,Yes,Yes,R1,COERXBE009,6G,LPI,T1F,161,80,-76,DL,STA56,T2A,5,160,-79,UL,STA63,T3E,6,20,-70,DL,STA4
-P2,16,Yes,Yes,R1,COERXBE010,6G,LPI,T1F,161,80,-76,DL,STA56,T2A,133,160,-79,UL,STA63,T3E,6,20,-70,DL,STA4
-P2,18,Yes,Yes,R1,COERXBE011,6G,LPI,T1F,161,80,-76,DL,STA56,T2A,197,160,-79,UL,STA63,T3E,6,20,-70,DL,STA4
-P2,20,Yes,Yes,R1,COERXBE012,6G,SP,T1F,161,80,-76,DL,STA56,T2A,5,320,-79,UL,STA63,T3E,6,20,-70,DL,STA4
-P2,22,Yes,Yes,R1,COERXBE013,6G,LPI,T1F,161,80,-76,DL,STA56,T2A,133,320,-79,UL,STA63,T3E,6,20,-70,DL,STA4
-P2,24,Yes,Yes,R2,COERXBE014,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,5,160,-79,DL,STA56,T3E,1,20,-70,UL,STA63
-P2,26,Yes,Yes,R2,COERXBE015,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,133,160,-79,DL,STA56,T3E,6,20,-70,UL,STA63
-P2,28,Yes,Yes,R2,COERXBE016,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,197,160,-79,DL,STA56,T3E,11,20,-70,UL,STA63
-P2,3,Yes,Yes,R2,COETXAC001,5G,LPI,T1F,100,80,-76,DL,STA4,T1K2A,5,160,OFF,DL,STA63,T3E,6,20,-70,DL,STA56
-P2,6,Yes,Yes,R2,COETXAC002,5G,LPI,T1F,161,80,-76,DL,STA4,T1K2A,133,160,OFF,DL,STA63,T3E,6,20,-70,DL,STA56
-P2,15,Yes,Yes,R1,COETXAC003,2G,LPI,T1F,36,80,-76,DL,STA56,T1K2A,5,160,OFF,DL,STA63,T3E,1,20,-70,DL,STA4
-P2,17,Yes,Yes,R1,COETXAC004,2G,LPI,T1F,36,80,-76,DL,STA56,T1K2A,133,160,OFF,DL,STA63,T3E,6,20,-70,DL,STA4
-P2,19,Yes,Yes,R1,COETXAC005,2G,LPI,T1F,36,80,-76,DL,STA56,T1K2A,5,320,OFF,DL,STA63,T3E,11,20,-70,DL,STA4
-P2,3,Yes,Yes,R1,COETXAX001,5G,LPI,T1F,100,80,-76,DL,STA56,T2A,5,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
-P2,4,Yes,Yes,R1,COETXAX002,5G,LPI,T1F,100,80,-76,DL,STA56,T2A,133,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
-P2,6,Yes,Yes,R1,COETXAX003,5G,LPI,T1F,161,80,-76,DL,STA56,T2A,5,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
-P2,7,Yes,Yes,R1,COETXAX004,5G,LPI,T1F,161,80,-76,DL,STA56,T2A,133,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
-P2,9,Yes,Yes,R1,COETXAX005,5G,SP,T1F,100,160,-76,DL,STA56,T2A,5,320,-79,DL,STA63,T3E,6,20,-70,DL,STA4
-P2,10,Yes,Yes,R1,COETXAX006,5G,LPI,T1F,100,160,-76,DL,STA56,T2A,133,320,-79,DL,STA63,T3E,6,20,-70,DL,STA4
-P2,12,Yes,Yes,R2,COETXAX007,6G,LPI,T1F,161,80,-76,DL,STA63,T2A,5,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4
-P2,14,Yes,Yes,R2,COETXAX008,6G,LPI,T1F,161,80,-76,DL,STA63,T2A,133,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4
-P2,16,Yes,Yes,R2,COETXAX009,6G,LPI,T1F,161,80,-76,DL,STA63,T2A,197,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4
-P2,18,Yes,Yes,R2,COETXAX010,6G,SP,T1F,161,80,-76,DL,STA63,T2A,5,320,-79,DL,STA56,T3E,6,20,-70,DL,STA4
-P2,20,Yes,Yes,R2,COETXAX011,6G,LPI,T1F,161,80,-76,DL,STA63,T2A,133,320,-79,DL,STA56,T3E,6,20,-70,DL,STA4
-P2,22,Yes,Yes,R3,COETXAX012,2G,LPI,T1F,36,80,-76,DL,STA56,T2A,5,160,-79,DL,STA63,T3E,1,20,-70,DL,STA56
-P2,24,Yes,Yes,R3,COETXAX013,2G,LPI,T1F,36,80,-76,DL,STA56,T2A,133,160,-79,DL,STA63,T3E,6,20,-70,DL,STA56
-P2,26,Yes,Yes,R3,COETXAX014,2G,LPI,T1F,36,80,-76,DL,STA56,T2A,197,160,-79,DL,STA63,T3E,11,20,-70,DL,STA56
-P2,3,Yes,Yes,R3,COETXBE001,5G,LPI,T1F,100,80,-76,DL,STA63,T2A,5,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4
-P2,4,Yes,Yes,R3,COETXBE002,5G,LPI,T1F,100,80,-76,DL,STA63,T2A,133,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4
-P2,6,Yes,Yes,R3,COETXBE003,5G,LPI,T1F,161,80,-76,DL,STA63,T2A,5,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4
-P2,7,Yes,Yes,R3,COETXBE004,5G,LPI,T1F,161,80,-76,DL,STA63,T2A,133,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4
-P2,9,Yes,Yes,R3,COETXBE005,5G,SP,T1F,100,160,-76,DL,STA63,T2A,5,320,-79,DL,STA64,T3E,6,20,-70,DL,STA4
-P2,10,Yes,Yes,R3,COETXBE006,5G,LPI,T1F,100,160,-76,DL,STA63,T2A,133,320,-79,DL,STA64,T3E,6,20,-70,DL,STA4
-P2,12,Yes,Yes,R1,COETXBE007,6G,LPI,T1F,161,80,-76,DL,STA56,T2A,5,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
-P2,14,Yes,Yes,R1,COETXBE008,6G,LPI,T1F,161,80,-76,DL,STA56,T2A,133,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
-P2,16,Yes,Yes,R1,COETXBE009,6G,LPI,T1F,161,80,-76,DL,STA56,T2A,197,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
-P2,18,Yes,Yes,R1,COETXBE010,6G,SP,T1F,161,80,-76,DL,STA56,T2A,5,320,-79,DL,STA63,T3E,6,20,-70,DL,STA4
-P2,20,Yes,Yes,R1,COETXBE011,6G,LPI,T1F,161,80,-76,DL,STA56,T2A,133,320,-79,DL,STA63,T3E,6,20,-70,DL,STA4
-P2,22,Yes,Yes,R2,COETXBE012,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,5,160,-79,DL,STA56,T3E,1,20,-70,DL,STA63
-P2,24,Yes,Yes,R2,COETXBE013,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,133,160,-79,DL,STA56,T3E,6,20,-70,DL,STA63
-P2,26,Yes,Yes,R2,COETXBE014,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,197,160,-79,DL,STA56,T3E,11,20,-70,DL,STA63
-P1,1,No,No,R2,P2PRXAC001,5G,LPI,T1F,36,80,-76,UL,STA4,,,,,,,,,,,,
-P1,1,No,No,R2,P2PRXAC002,5G,LPI,T2B,36,80,-67,UL,STA4,,,,,,,,,,,,
-P1,1,No,No,R2,P2PRXAC003,5G,LPI,T1C,36,80,-45,UL,STA4,,,,,,,,,,,,
-P1,2,No,Yes,R2,P2PRXAC004,5G,LPI,T1F,100,80,-76,UL,STA4,,,,,,,,,,,,
-P1,2,No,No,R2,P2PRXAC005,5G,LPI,T2B,100,80,-67,UL,STA4,,,,,,,,,,,,
-P1,2,No,No,R2,P2PRXAC006,5G,LPI,T1C,100,80,-45,UL,STA4,,,,,,,,,,,,
-P1,5,No,Yes,R2,P2PRXAC007,5G,LPI,T1F,161,80,-76,UL,STA4,,,,,,,,,,,,
-P1,5,No,No,R2,P2PRXAC008,5G,LPI,T2B,161,80,-67,UL,STA4,,,,,,,,,,,,
-P1,5,No,No,R2,P2PRXAC009,5G,LPI,T1C,161,80,-45,UL,STA4,,,,,,,,,,,,
-P1,14,No,Yes,R1,P2PRXAC010,2G,LPI,,,,,,,,,,,,,T3E,1,20,-70,UL,STA4
-P1,14,No,No,R1,P2PRXAC011,2G,LPI,,,,,,,,,,,,,T1D,1,20,-60,UL,STA4
-P1,14,No,No,R1,P2PRXAC012,2G,LPI,,,,,,,,,,,,,T1C,1,20,-45,UL,STA4
-P1,16,No,Yes,R1,P2PRXAC013,2G,LPI,,,,,,,,,,,,,T3E,6,20,-70,UL,STA4
-P1,16,No,No,R1,P2PRXAC014,2G,LPI,,,,,,,,,,,,,T1D,6,20,-60,UL,STA4
-P1,16,No,No,R1,P2PRXAC015,2G,LPI,,,,,,,,,,,,,T1C,6,20,-45,UL,STA4
-P1,18,No,Yes,R1,P2PRXAC016,2G,LPI,,,,,,,,,,,,,T3E,11,20,-70,UL,STA4
-P1,18,No,No,R1,P2PRXAC017,2G,LPI,,,,,,,,,,,,,T1D,11,20,-60,UL,STA4
-P1,18,No,No,R1,P2PRXAC018,2G,LPI,,,,,,,,,,,,,T1C,11,20,-45,UL,STA4
-P1,1,No,No,R1,P2PRXAX001,5G,LPI,T1F,36,80,-76,UL,STA56,,,,,,,,,,,,
-P1,1,No,No,R1,P2PRXAX002,5G,LPI,T2B,36,80,-67,UL,STA56,,,,,,,,,,,,
-P1,1,No,No,R1,P2PRXAX003,5G,LPI,T1C,36,80,-45,UL,STA56,,,,,,,,,,,,
-P1,2,No,Yes,R1,P2PRXAX004,5G,LPI,T1F,100,80,-76,UL,STA56,,,,,,,,,,,,
-P1,2,No,No,R1,P2PRXAX005,5G,LPI,T2B,100,80,-67,UL,STA56,,,,,,,,,,,,
-P1,2,No,No,R1,P2PRXAX006,5G,LPI,T1C,100,80,-45,UL,STA56,,,,,,,,,,,,
-P1,5,No,Yes,R1,P2PRXAX007,5G,LPI,T1F,161,80,-76,UL,STA56,,,,,,,,,,,,
-P1,5,No,No,R1,P2PRXAX008,5G,LPI,T2B,161,80,-67,UL,STA56,,,,,,,,,,,,
-P1,5,No,No,R1,P2PRXAX009,5G,LPI,T1C,161,80,-45,UL,STA56,,,,,,,,,,,,
-P1,10,No,Yes,R1,P2PRXAX010,5G,LPI,T1F,100,160,-76,UL,STA56,,,,,,,,,,,,
-P1,10,No,No,R1,P2PRXAX011,5G,LPI,T2B,100,160,-67,UL,STA56,,,,,,,,,,,,
-P1,10,No,No,R1,P2PRXAX012,5G,LPI,T1C,100,160,-45,UL,STA56,,,,,,,,,,,,
-P1,13,No,Yes,R2,P2PRXAX013,6G,LPI,,,,,,,T2A,5,160,-79,UL,STA56,,,,,,
-P1,13,No,No,R2,P2PRXAX014,6G,LPI,,,,,,,T2B,5,160,-70,UL,STA56,,,,,,
-P1,13,No,No,R2,P2PRXAX015,6G,LPI,,,,,,,T1C,5,160,-45,UL,STA56,,,,,,
-P1,15,No,Yes,R2,P2PRXAX016,6G,LPI,,,,,,,T2A,133,160,-79,UL,STA56,,,,,,
-P1,15,No,No,R2,P2PRXAX017,6G,LPI,,,,,,,T2B,133,160,-70,UL,STA56,,,,,,
-P1,15,No,No,R2,P2PRXAX018,6G,LPI,,,,,,,T1C,133,160,-45,UL,STA63,,,,,,
-P1,17,No,Yes,R2,P2PRXAX019,6G,LPI,,,,,,,T2A,197,160,-79,UL,STA56,,,,,,
-P1,17,No,No,R2,P2PRXAX020,6G,LPI,,,,,,,T2B,197,160,-70,UL,STA56,,,,,,
-P1,17,No,No,R2,P2PRXAX021,6G,LPI,,,,,,,T1C,197,160,-45,UL,STA56,,,,,,
-P1,19,No,Yes,R2,P2PRXAX022,6G,SP,,,,,,,T2A,5,320,-79,UL,STA56,,,,,,
-P1,19,No,No,R2,P2PRXAX023,6G,SP,,,,,,,T2B,5,320,-70,UL,STA56,,,,,,
-P1,19,No,No,R2,P2PRXAX024,6G,SP,,,,,,,T1C,5,320,-45,UL,STA56,,,,,,
-P1,21,No,Yes,R2,P2PRXAX025,6G,LPI,,,,,,,T2A,133,320,-79,UL,STA56,,,,,,
-P1,21,No,No,R2,P2PRXAX026,6G,LPI,,,,,,,T2B,133,320,-70,UL,STA56,,,,,,
-P1,21,No,No,R2,P2PRXAX027,6G,LPI,,,,,,,T1C,133,320,-45,UL,STA56,,,,,,
-P1,23,No,Yes,R3,P2PRXAX028,2G,LPI,,,,,,,,,,,,,T3E,1,20,-70,UL,STA56
-P1,23,No,No,R3,P2PRXAX029,2G,LPI,,,,,,,,,,,,,T1D,1,20,-60,UL,STA56
-P1,23,No,No,R3,P2PRXAX030,2G,LPI,,,,,,,,,,,,,T1C,1,20,-45,UL,STA56
-P1,25,No,Yes,R3,P2PRXAX031,2G,LPI,,,,,,,,,,,,,T3E,6,20,-70,UL,STA56
-P1,25,No,No,R3,P2PRXAX032,2G,LPI,,,,,,,,,,,,,T1D,6,20,-60,UL,STA56
-P1,25,No,No,R3,P2PRXAX033,2G,LPI,,,,,,,,,,,,,T1C,6,20,-45,UL,STA56
-P1,27,No,Yes,R3,P2PRXAX034,2G,LPI,,,,,,,,,,,,,T3E,11,20,-70,UL,STA56
-P1,27,No,No,R3,P2PRXAX035,2G,LPI,,,,,,,,,,,,,T1D,11,20,-60,UL,STA56
-P1,27,No,No,R3,P2PRXAX036,2G,LPI,,,,,,,,,,,,,T1C,11,20,-45,UL,STA56
-P1,1,No,No,R3,P2PRXBE001,5G,LPI,T1F,36,80,-76,UL,STA63,,,,,,,,,,,,
-P1,1,No,No,R3,P2PRXBE002,5G,LPI,T2B,36,80,-67,UL,STA63,,,,,,,,,,,,
-P1,1,No,No,R3,P2PRXBE003,5G,LPI,T1C,36,80,-45,UL,STA63,,,,,,,,,,,,
-P1,2,No,Yes,R3,P2PRXBE004,5G,LPI,T1F,100,80,-76,UL,STA63,,,,,,,,,,,,
-P1,2,No,No,R3,P2PRXBE005,5G,LPI,T2B,100,80,-67,UL,STA63,,,,,,,,,,,,
-P1,2,No,No,R3,P2PRXBE006,5G,LPI,T1C,100,80,-45,UL,STA63,,,,,,,,,,,,
-P1,5,No,Yes,R3,P2PRXBE007,5G,LPI,T1F,161,80,-76,UL,STA63,,,,,,,,,,,,
-P1,5,No,No,R3,P2PRXBE008,5G,LPI,T2B,161,80,-67,UL,STA63,,,,,,,,,,,,
-P1,5,No,No,R3,P2PRXBE009,5G,LPI,T1C,161,80,-45,UL,STA63,,,,,,,,,,,,
-P1,10,No,Yes,R3,P2PRXBE010,5G,LPI,T1F,100,160,-76,UL,STA63,,,,,,,,,,,,
-P1,10,No,No,R3,P2PRXBE011,5G,LPI,T2B,100,160,-67,UL,STA63,,,,,,,,,,,,
-P1,10,No,No,R3,P2PRXBE012,5G,LPI,T1C,100,160,-45,UL,STA63,,,,,,,,,,,,
P1,13,No,Yes,R1,P2PRXBE013,6G,LPI,,,,,,,T2A,5,160,-79,UL,STA63,,,,,,
-P1,13,No,No,R1,P2PRXBE014,6G,LPI,,,,,,,T2B,5,160,-70,UL,STA63,,,,,,
+P1,13,No,No,R1,P2PRXBE014,6G,LPI,,,,,,,T1B,5,160,-70,UL,STA63,,,,,,
P1,13,No,No,R1,P2PRXBE015,6G,LPI,,,,,,,T1C,5,160,-45,UL,STA63,,,,,,
+P2,14,Yes,Yes,R1,COERXBE009,6G,LPI,T1F,161,80,-76,DL,STA56,T2A,5,160,-79,UL,STA63,T3E,6,20,-70,DL,STA4
P1,15,No,Yes,R1,P2PRXBE016,6G,LPI,,,,,,,T2A,133,160,-79,UL,STA63,,,,,,
-P1,15,No,No,R1,P2PRXBE017,6G,LPI,,,,,,,T2B,133,160,-70,UL,STA63,,,,,,
+P1,15,No,No,R1,P2PRXBE017,6G,LPI,,,,,,,T1B,133,160,-70,UL,STA63,,,,,,
P1,15,No,No,R1,P2PRXBE018,6G,LPI,,,,,,,T1C,133,160,-45,UL,STA63,,,,,,
+P2,16,Yes,Yes,R1,COERXBE010,6G,LPI,T1F,161,80,-76,DL,STA56,T2A,133,160,-79,UL,STA63,T3E,6,20,-70,DL,STA4
P1,17,No,Yes,R1,P2PRXBE019,6G,LPI,,,,,,,T2A,197,160,-79,UL,STA63,,,,,,
-P1,17,No,No,R1,P2PRXBE020,6G,LPI,,,,,,,T2B,197,160,-70,UL,STA63,,,,,,
+P1,17,No,No,R1,P2PRXBE020,6G,LPI,,,,,,,T1B,197,160,-70,UL,STA63,,,,,,
P1,17,No,No,R1,P2PRXBE021,6G,LPI,,,,,,,T1C,197,160,-45,UL,STA63,,,,,,
+P2,18,Yes,Yes,R1,COERXBE011,6G,LPI,T1F,161,80,-76,DL,STA56,T2A,197,160,-79,UL,STA63,T3E,6,20,-70,DL,STA4
P1,19,No,Yes,R1,P2PRXBE022,6G,SP,,,,,,,T2A,5,320,-79,UL,STA63,,,,,,
-P1,19,No,No,R1,P2PRXBE023,6G,SP,,,,,,,T2B,5,320,-70,UL,STA63,,,,,,
+P1,19,No,No,R1,P2PRXBE023,6G,SP,,,,,,,T1B,5,320,-70,UL,STA63,,,,,,
P1,19,No,No,R1,P2PRXBE024,6G,SP,,,,,,,T1C,5,320,-45,UL,STA63,,,,,,
+P2,20,Yes,Yes,R1,COERXBE012,6G,SP,T1F,161,80,-76,DL,STA56,T2A,5,320,-79,UL,STA63,T3E,6,20,-70,DL,STA4
P1,21,No,Yes,R1,P2PRXBE025,6G,LPI,,,,,,,T2A,133,320,-79,UL,STA63,,,,,,
-P1,21,No,No,R1,P2PRXBE026,6G,LPI,,,,,,,T2B,133,320,-70,UL,STA63,,,,,,
+P1,21,No,No,R1,P2PRXBE026,6G,LPI,,,,,,,T1B,133,320,-70,UL,STA63,,,,,,
P1,21,No,No,R1,P2PRXBE027,6G,LPI,,,,,,,T1C,133,320,-45,UL,STA63,,,,,,
+P2,22,Yes,Yes,R1,COERXBE013,6G,LPI,T1F,161,80,-76,DL,STA56,T2A,133,320,-79,UL,STA63,T3E,6,20,-70,DL,STA4
P1,23,No,Yes,R2,P2PRXBE028,2G,LPI,,,,,,,,,,,,,T3E,1,20,-70,UL,STA63
P1,23,No,No,R2,P2PRXBE029,2G,LPI,,,,,,,,,,,,,T1D,1,20,-60,UL,STA63
P1,23,No,No,R2,P2PRXBE030,2G,LPI,,,,,,,,,,,,,T1C,1,20,-45,UL,STA63
+P2,24,Yes,Yes,R2,COERXBE014,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,5,160,-79,DL,STA56,T3E,1,20,-70,UL,STA63
P1,25,No,Yes,R2,P2PRXBE031,2G,LPI,,,,,,,,,,,,,T3E,6,20,-70,UL,STA63
P1,25,No,No,R2,P2PRXBE032,2G,LPI,,,,,,,,,,,,,T1D,6,20,-60,UL,STA63
P1,25,No,No,R2,P2PRXBE033,2G,LPI,,,,,,,,,,,,,T1C,6,20,-45,UL,STA63
+P2,26,Yes,Yes,R2,COERXBE015,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,133,160,-79,DL,STA56,T3E,6,20,-70,UL,STA63
P1,27,No,Yes,R2,P2PRXBE034,2G,LPI,,,,,,,,,,,,,T3E,11,20,-70,UL,STA63
P1,27,No,No,R2,P2PRXBE035,2G,LPI,,,,,,,,,,,,,T1D,11,20,-60,UL,STA63
P1,27,No,No,R2,P2PRXBE036,2G,LPI,,,,,,,,,,,,,T1C,11,20,-45,UL,STA63
-P1,1,No,No,R2,P2PTXAC001,5G,LPI,T1F,36,80,-76,DL,STA4,,,,,,,,,,,,
-P1,1,No,No,R2,P2PTXAC002,5G,LPI,T2B,36,80,-67,DL,STA4,,,,,,,,,,,,
-P1,1,No,No,R2,P2PTXAC003,5G,LPI,T1C,36,80,-45,DL,STA4,,,,,,,,,,,,
-P1,2,No,Yes,R2,P2PTXAC004,5G,LPI,T1F,100,80,-76,DL,STA4,,,,,,,,,,,,
-P1,2,No,No,R2,P2PTXAC005,5G,LPI,T2B,100,80,-67,DL,STA4,,,,,,,,,,,,
-P1,2,No,No,R2,P2PTXAC006,5G,LPI,T1C,100,80,-45,DL,STA4,,,,,,,,,,,,
-P1,5,No,Yes,R2,P2PTXAC007,5G,LPI,T1F,161,80,-76,DL,STA4,,,,,,,,,,,,
-P1,5,No,No,R2,P2PTXAC008,5G,LPI,T2B,161,80,-67,DL,STA4,,,,,,,,,,,,
-P1,5,No,No,R2,P2PTXAC009,5G,LPI,T1C,161,80,-45,DL,STA4,,,,,,,,,,,,
-P1,14,No,Yes,R1,P2PTXAC010,2G,LPI,,,,,,,,,,,,,T3E,1,20,-70,DL,STA4
-P1,14,No,No,R1,P2PTXAC011,2G,LPI,,,,,,,,,,,,,T1D,1,20,-60,DL,STA4
-P1,14,No,No,R1,P2PTXAC012,2G,LPI,,,,,,,,,,,,,T1C,1,20,-45,DL,STA4
-P1,16,No,Yes,R1,P2PTXAC013,2G,LPI,,,,,,,,,,,,,T3E,6,20,-70,DL,STA4
-P1,16,No,No,R1,P2PTXAC014,2G,LPI,,,,,,,,,,,,,T1D,6,20,-60,DL,STA4
-P1,16,No,No,R1,P2PTXAC015,2G,LPI,,,,,,,,,,,,,T1C,6,20,-45,DL,STA4
-P1,18,No,Yes,R1,P2PTXAC016,2G,LPI,,,,,,,,,,,,,T3E,11,20,-70,DL,STA4
-P1,18,No,No,R1,P2PTXAC017,2G,LPI,,,,,,,,,,,,,T1D,11,20,-60,DL,STA4
-P1,18,No,No,R1,P2PTXAC018,2G,LPI,,,,,,,,,,,,,T1C,11,20,-45,DL,STA4
-P1,1,No,No,R1,P2PTXAX001,5G,LPI,T1F,36,80,-76,DL,STA56,,,,,,,,,,,,
-P1,1,No,No,R1,P2PTXAX002,5G,LPI,T2B,36,80,-67,DL,STA56,,,,,,,,,,,,
-P1,1,No,No,R1,P2PTXAX003,5G,LPI,T1C,36,80,-45,DL,STA56,,,,,,,,,,,,
-P1,2,No,Yes,R1,P2PTXAX004,5G,LPI,T1F,100,80,-76,DL,STA56,,,,,,,,,,,,
-P1,2,No,No,R1,P2PTXAX005,5G,LPI,T2B,100,80,-67,DL,STA56,,,,,,,,,,,,
-P1,2,No,No,R1,P2PTXAX006,5G,LPI,T1C,100,80,-45,DL,STA56,,,,,,,,,,,,
-P1,5,No,Yes,R1,P2PTXAX007,5G,LPI,T1F,161,80,-76,DL,STA56,,,,,,,,,,,,
-P1,5,No,No,R1,P2PTXAX008,5G,LPI,T2B,161,80,-67,DL,STA56,,,,,,,,,,,,
-P1,5,No,No,R1,P2PTXAX009,5G,LPI,T1C,161,80,-45,DL,STA56,,,,,,,,,,,,
-P1,8,No,Yes,R1,P2PTXAX010,5G,LPI,T1F,100,160,-76,DL,STA56,,,,,,,,,,,,
-P1,8,No,No,R1,P2PTXAX011,5G,LPI,T2B,100,160,-67,DL,STA56,,,,,,,,,,,,
-P1,8,No,No,R1,P2PTXAX012,5G,LPI,T1C,100,160,-45,DL,STA56,,,,,,,,,,,,
-P1,11,No,Yes,R2,P2PTXAX013,6G,LPI,,,,,,,T2A,5,160,-79,DL,STA56,,,,,,
-P1,11,No,No,R2,P2PTXAX014,6G,LPI,,,,,,,T2B,5,160,-70,DL,STA56,,,,,,
-P1,11,No,No,R2,P2PTXAX015,6G,LPI,,,,,,,T1C,5,160,-45,DL,STA56,,,,,,
-P1,13,No,Yes,R2,P2PTXAX016,6G,LPI,,,,,,,T2A,133,160,-79,DL,STA56,,,,,,
-P1,13,No,No,R2,P2PTXAX017,6G,LPI,,,,,,,T2B,133,160,-70,DL,STA56,,,,,,
-P1,13,No,No,R2,P2PTXAX018,6G,LPI,,,,,,,T1C,133,160,-45,DL,STA56,,,,,,
-P1,15,No,Yes,R2,P2PTXAX019,6G,LPI,,,,,,,T2A,197,160,-79,DL,STA56,,,,,,
-P1,15,No,No,R2,P2PTXAX020,6G,LPI,,,,,,,T2B,197,160,-70,DL,STA56,,,,,,
-P1,15,No,No,R2,P2PTXAX021,6G,LPI,,,,,,,T1C,197,160,-45,DL,STA56,,,,,,
-P1,17,No,Yes,R2,P2PTXAX022,6G,SP,,,,,,,T2A,5,320,-79,DL,STA56,,,,,,
-P1,17,No,No,R2,P2PTXAX023,6G,SP,,,,,,,T2B,5,320,-70,DL,STA56,,,,,,
-P1,17,No,No,R2,P2PTXAX024,6G,SP,,,,,,,T1C,5,320,-45,DL,STA56,,,,,,
-P1,19,No,Yes,R2,P2PTXAX025,6G,LPI,,,,,,,T2A,133,320,-79,DL,STA56,,,,,,
-P1,19,No,No,R2,P2PTXAX026,6G,LPI,,,,,,,T2B,133,320,-70,DL,STA56,,,,,,
-P1,19,No,No,R2,P2PTXAX027,6G,LPI,,,,,,,T1C,133,320,-45,DL,STA56,,,,,,
-P1,21,No,Yes,R3,P2PTXAX028,2G,LPI,,,,,,,,,,,,,T3E,1,20,-70,UL,STA56
-P1,21,No,No,R3,P2PTXAX029,2G,LPI,,,,,,,,,,,,,T1D,1,20,-60,UL,STA56
-P1,21,No,No,R3,P2PTXAX030,2G,LPI,,,,,,,,,,,,,T1C,1,20,-45,UL,STA56
-P1,23,No,Yes,R3,P2PTXAX031,2G,LPI,,,,,,,,,,,,,T3E,6,20,-70,UL,STA56
-P1,23,No,No,R3,P2PTXAX032,2G,LPI,,,,,,,,,,,,,T1D,6,20,-60,UL,STA56
-P1,23,No,No,R3,P2PTXAX033,2G,LPI,,,,,,,,,,,,,T1C,6,20,-45,UL,STA56
-P1,25,No,Yes,R3,P2PTXAX034,2G,LPI,,,,,,,,,,,,,T3E,11,20,-70,UL,STA56
-P1,25,No,No,R3,P2PTXAX035,2G,LPI,,,,,,,,,,,,,T1D,11,20,-60,UL,STA56
-P1,25,No,No,R3,P2PTXAX036,2G,LPI,,,,,,,,,,,,,T1C,11,20,-45,UL,STA56
+P2,28,Yes,Yes,R2,COERXBE016,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,197,160,-79,DL,STA56,T3E,11,20,-70,UL,STA63
+P1,1,No,No,R1,P2PRXAX001,5G,LPI,T1F,36,80,-76,UL,STA56,,,,,,,,,,,,
+P1,1,No,No,R1,P2PRXAX002,5G,LPI,T1B,36,80,-67,UL,STA56,,,,,,,,,,,,
+P1,1,No,No,R1,P2PRXAX003,5G,LPI,T1C,36,80,-45,UL,STA56,,,,,,,,,,,,
+P1,2,No,Yes,R1,P2PRXAX004,5G,LPI,T1F,100,80,-76,UL,STA56,,,,,,,,,,,,
+P1,2,No,No,R1,P2PRXAX005,5G,LPI,T1B,100,80,-67,UL,STA56,,,,,,,,,,,,
+P1,2,No,No,R1,P2PRXAX006,5G,LPI,T1C,100,80,-45,UL,STA56,,,,,,,,,,,,
+P2,3,Yes,Yes,R1,COERXAX001,5G,LPI,T1F,100,80,-76,UL,STA56,T2A,5,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
+P2,4,Yes,Yes,R1,COERXAX002,5G,LPI,T1F,100,80,-76,UL,STA56,T2A,133,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
+P1,5,No,Yes,R1,P2PRXAX007,5G,LPI,T1F,161,80,-76,UL,STA56,,,,,,,,,,,,
+P1,5,No,No,R1,P2PRXAX008,5G,LPI,T1B,161,80,-67,UL,STA56,,,,,,,,,,,,
+P1,5,No,No,R1,P2PRXAX009,5G,LPI,T1C,161,80,-45,UL,STA56,,,,,,,,,,,,
+P2,6,Yes,Yes,R1,COERXAX003,5G,LPI,T1F,161,80,-76,UL,STA56,T2A,5,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
+P2,7,Yes,Yes,R1,COERXAX004,5G,LPI,T1F,161,80,-76,UL,STA56,T2A,133,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
+P2,8,Yes,Yes,R1,COERXAX005,5G,SP,T1F,161,80,-76,UL,STA56,T2A,5,320,-79,DL,STA63,T3E,6,20,-70,DL,STA4
+P2,9,Yes,Yes,R1,COERXAX006,5G,LPI,T1F,161,80,-76,UL,STA56,T2A,133,320,-79,DL,STA63,T3E,6,20,-70,DL,STA4
+P1,10,No,Yes,R1,P2PRXAX010,5G,LPI,T1F,100,160,-76,UL,STA56,,,,,,,,,,,,
+P1,10,No,No,R1,P2PRXAX011,5G,LPI,T1B,100,160,-67,UL,STA56,,,,,,,,,,,,
+P1,10,No,No,R1,P2PRXAX012,5G,LPI,T1C,100,160,-45,UL,STA56,,,,,,,,,,,,
+P2,11,Yes,Yes,R1,COERXAX007,5G,SP,T1F,100,160,-76,UL,STA56,T2A,5,320,-79,DL,STA63,T3E,6,20,-70,DL,STA4
+P2,12,Yes,Yes,R1,COERXAX008,5G,LPI,T1F,100,160,-76,UL,STA56,T2A,133,320,-79,DL,STA63,T3E,6,20,-70,DL,STA4
+P1,13,No,Yes,R2,P2PRXAX013,6G,LPI,,,,,,,T2A,5,160,-79,UL,STA56,,,,,,
+P1,13,No,No,R2,P2PRXAX014,6G,LPI,,,,,,,T1B,5,160,-70,UL,STA56,,,,,,
+P1,13,No,No,R2,P2PRXAX015,6G,LPI,,,,,,,T1C,5,160,-45,UL,STA56,,,,,,
+P2,14,Yes,Yes,R2,COERXAX009,6G,LPI,T1F,161,80,-76,DL,STA63,T2A,5,160,-79,UL,STA56,T3E,6,20,-70,DL,STA4
+P1,15,No,Yes,R2,P2PRXAX016,6G,LPI,,,,,,,T2A,133,160,-79,UL,STA56,,,,,,
+P1,15,No,No,R2,P2PRXAX017,6G,LPI,,,,,,,T1B,133,160,-70,UL,STA56,,,,,,
+P1,15,No,No,R2,P2PRXAX018,6G,LPI,,,,,,,T1C,133,160,-45,UL,STA63,,,,,,
+P2,16,Yes,Yes,R2,COERXAX010,6G,LPI,T1F,161,80,-76,DL,STA63,T2A,133,160,-79,UL,STA56,T3E,6,20,-70,DL,STA4
+P1,17,No,Yes,R2,P2PRXAX019,6G,LPI,,,,,,,T2A,197,160,-79,UL,STA56,,,,,,
+P1,17,No,No,R2,P2PRXAX020,6G,LPI,,,,,,,T1B,197,160,-70,UL,STA56,,,,,,
+P1,17,No,No,R2,P2PRXAX021,6G,LPI,,,,,,,T1C,197,160,-45,UL,STA56,,,,,,
+P2,18,Yes,Yes,R2,COERXAX011,6G,LPI,T1F,161,80,-76,DL,STA63,T2A,197,160,-79,UL,STA56,T3E,6,20,-70,DL,STA4
+P1,19,No,Yes,R2,P2PRXAX022,6G,SP,,,,,,,T2A,5,320,-79,UL,STA56,,,,,,
+P1,19,No,No,R2,P2PRXAX023,6G,SP,,,,,,,T1B,5,320,-70,UL,STA56,,,,,,
+P1,19,No,No,R2,P2PRXAX024,6G,SP,,,,,,,T1C,5,320,-45,UL,STA56,,,,,,
+P2,20,Yes,Yes,R2,COERXAX012,6G,SP,T1F,161,80,-76,DL,STA63,T2A,5,320,-79,UL,STA56,T3E,6,20,-70,DL,STA4
+P1,21,No,Yes,R2,P2PRXAX025,6G,LPI,,,,,,,T2A,133,320,-79,UL,STA56,,,,,,
+P1,21,No,No,R2,P2PRXAX026,6G,LPI,,,,,,,T1B,133,320,-70,UL,STA56,,,,,,
+P1,21,No,No,R2,P2PRXAX027,6G,LPI,,,,,,,T1C,133,320,-45,UL,STA56,,,,,,
+P2,22,Yes,Yes,R2,COERXAX013,6G,LPI,T1F,161,80,-76,DL,STA63,T2A,133,320,-79,UL,STA56,T3E,6,20,-70,DL,STA4
+P1,23,No,Yes,R3,P2PRXAX028,2G,LPI,,,,,,,,,,,,,T3E,1,20,-70,UL,STA56
+P1,23,No,No,R3,P2PRXAX029,2G,LPI,,,,,,,,,,,,,T1D,1,20,-60,UL,STA56
+P1,23,No,No,R3,P2PRXAX030,2G,LPI,,,,,,,,,,,,,T1C,1,20,-45,UL,STA56
+P2,24,Yes,Yes,R3,COERXAX014,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,5,160,-79,DL,STA63,T3E,1,20,-70,UL,STA56
+P1,25,No,Yes,R3,P2PRXAX031,2G,LPI,,,,,,,,,,,,,T3E,6,20,-70,UL,STA56
+P1,25,No,No,R3,P2PRXAX032,2G,LPI,,,,,,,,,,,,,T1D,6,20,-60,UL,STA56
+P1,25,No,No,R3,P2PRXAX033,2G,LPI,,,,,,,,,,,,,T1C,6,20,-45,UL,STA56
+P2,26,Yes,Yes,R3,COERXAX015,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,133,160,-79,DL,STA63,T3E,6,20,-70,UL,STA56
+P1,27,No,Yes,R3,P2PRXAX034,2G,LPI,,,,,,,,,,,,,T3E,11,20,-70,UL,STA56
+P1,27,No,No,R3,P2PRXAX035,2G,LPI,,,,,,,,,,,,,T1D,11,20,-60,UL,STA56
+P1,27,No,No,R3,P2PRXAX036,2G,LPI,,,,,,,,,,,,,T1C,11,20,-45,UL,STA56
+P2,28,Yes,Yes,R3,COERXAX016,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,197,160,-79,DL,STA63,T3E,11,20,-70,UL,STA56
+P1,1,No,No,R2,P2PRXAC001,5G,LPI,T1F,36,80,-76,UL,STA4,,,,,,,,,,,,
+P1,1,No,No,R2,P2PRXAC002,5G,LPI,T1B,36,80,-67,UL,STA4,,,,,,,,,,,,
+P1,1,No,No,R2,P2PRXAC003,5G,LPI,T1C,36,80,-45,UL,STA4,,,,,,,,,,,,
+P1,2,No,Yes,R2,P2PRXAC004,5G,LPI,T1F,100,80,-76,UL,STA4,,,,,,,,,,,,
+P1,2,No,No,R2,P2PRXAC005,5G,LPI,T1B,100,80,-67,UL,STA4,,,,,,,,,,,,
+P1,2,No,No,R2,P2PRXAC006,5G,LPI,T1C,100,80,-45,UL,STA4,,,,,,,,,,,,
+P2,3,Yes,Yes,R2,COERXAC001,5G,LPI,T1F,100,80,-76,UL,STA4,T2A,5,160,OFF,DL,STA63,T3E,6,20,-70,DL,STA56
+P1,5,No,Yes,R2,P2PRXAC007,5G,LPI,T1F,161,80,-76,UL,STA4,,,,,,,,,,,,
+P1,5,No,No,R2,P2PRXAC008,5G,LPI,T1B,161,80,-67,UL,STA4,,,,,,,,,,,,
+P1,5,No,No,R2,P2PRXAC009,5G,LPI,T1C,161,80,-45,UL,STA4,,,,,,,,,,,,
+P2,6,Yes,Yes,R2,COERXAC002,5G,LPI,T1F,161,80,-76,UL,STA4,T2A,133,160,OFF,DL,STA63,T3E,6,20,-70,DL,STA56
+P1,14,No,Yes,R1,P2PRXAC010,2G,LPI,,,,,,,,,,,,,T3E,1,20,-70,UL,STA4
+P1,14,No,No,R1,P2PRXAC011,2G,LPI,,,,,,,,,,,,,T1D,1,20,-60,UL,STA4
+P1,14,No,No,R1,P2PRXAC012,2G,LPI,,,,,,,,,,,,,T1C,1,20,-45,UL,STA4
+P2,15,Yes,Yes,R1,COERXAC003,2G,LPI,T1F,36,80,-76,DL,STA56,T2A,5,160,OFF,DL,STA63,T3E,1,20,-70,UL,STA4
+P1,16,No,Yes,R1,P2PRXAC013,2G,LPI,,,,,,,,,,,,,T3E,6,20,-70,UL,STA4
+P1,16,No,No,R1,P2PRXAC014,2G,LPI,,,,,,,,,,,,,T1D,6,20,-60,UL,STA4
+P1,16,No,No,R1,P2PRXAC015,2G,LPI,,,,,,,,,,,,,T1C,6,20,-45,UL,STA4
+P2,17,Yes,Yes,R1,COERXAC004,2G,LPI,T1F,36,80,-76,DL,STA56,T2A,133,160,OFF,DL,STA63,T3E,6,20,-70,UL,STA4
+P1,18,No,Yes,R1,P2PRXAC016,2G,LPI,,,,,,,,,,,,,T3E,11,20,-70,UL,STA4
+P1,18,No,No,R1,P2PRXAC017,2G,LPI,,,,,,,,,,,,,T1D,11,20,-60,UL,STA4
+P1,18,No,No,R1,P2PRXAC018,2G,LPI,,,,,,,,,,,,,T1C,11,20,-45,UL,STA4
+P2,19,Yes,Yes,R1,COERXAC005,2G,LPI,T1F,36,80,-76,DL,STA56,T2A,197,160,OFF,DL,STA63,T3E,11,20,-70,UL,STA4
P1,1,No,No,R3,P2PTXBE001,5G,LPI,T1F,36,80,-76,DL,STA63,,,,,,,,,,,,
-P1,1,No,No,R3,P2PTXBE002,5G,LPI,T2B,36,80,-67,DL,STA63,,,,,,,,,,,,
+P1,1,No,No,R3,P2PTXBE002,5G,LPI,T1B,36,80,-67,DL,STA63,,,,,,,,,,,,
P1,1,No,No,R3,P2PTXBE003,5G,LPI,T1C,36,80,-45,DL,STA63,,,,,,,,,,,,
P1,2,No,Yes,R3,P2PTXBE004,5G,LPI,T1F,100,80,-76,DL,STA63,,,,,,,,,,,,
-P1,2,No,No,R3,P2PTXBE005,5G,LPI,T2B,100,80,-67,DL,STA63,,,,,,,,,,,,
+P1,2,No,No,R3,P2PTXBE005,5G,LPI,T1B,100,80,-67,DL,STA63,,,,,,,,,,,,
P1,2,No,No,R3,P2PTXBE006,5G,LPI,T1C,100,80,-45,DL,STA63,,,,,,,,,,,,
+P2,3,Yes,Yes,R3,COETXBE001,5G,LPI,T1F,100,80,-76,DL,STA63,T2A,5,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4
+P2,4,Yes,Yes,R3,COETXBE002,5G,LPI,T1F,100,80,-76,DL,STA63,T2A,133,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4
P1,5,No,Yes,R3,P2PTXBE007,5G,LPI,T1F,161,80,-76,DL,STA63,,,,,,,,,,,,
-P1,5,No,No,R3,P2PTXBE008,5G,LPI,T2B,161,80,-67,DL,STA63,,,,,,,,,,,,
+P1,5,No,No,R3,P2PTXBE008,5G,LPI,T1B,161,80,-67,DL,STA63,,,,,,,,,,,,
P1,5,No,No,R3,P2PTXBE009,5G,LPI,T1C,161,80,-45,DL,STA63,,,,,,,,,,,,
+P2,6,Yes,Yes,R3,COETXBE003,5G,LPI,T1F,161,80,-76,DL,STA63,T2A,5,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4
+P2,7,Yes,Yes,R3,COETXBE004,5G,LPI,T1F,161,80,-76,DL,STA63,T2A,133,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4
P1,8,No,Yes,R3,P2PTXBE010,5G,LPI,T1F,100,160,-76,DL,STA63,,,,,,,,,,,,
-P1,8,No,No,R3,P2PTXBE011,5G,LPI,T2B,100,160,-67,DL,STA63,,,,,,,,,,,,
+P1,8,No,No,R3,P2PTXBE011,5G,LPI,T1B,100,160,-67,DL,STA63,,,,,,,,,,,,
P1,8,No,No,R3,P2PTXBE012,5G,LPI,T1C,100,160,-45,DL,STA63,,,,,,,,,,,,
-P1,11,No,Yes,R3,P2PTXBE013,6G,LPI,T1F,5,160,-79,DL,STA63,,,,,,,,,,,,
-P1,11,No,No,R1,P2PTXBE014,6G,LPI,T2B,5,160,-70,DL,STA63,,,,,,,,,,,,
+P2,9,Yes,Yes,R3,COETXBE005,5G,SP,T1F,100,160,-76,DL,STA63,T2A,5,320,-79,DL,STA64,T3E,6,20,-70,DL,STA4
+P2,10,Yes,Yes,R3,COETXBE006,5G,LPI,T1F,100,160,-76,DL,STA63,T2A,133,320,-79,DL,STA64,T3E,6,20,-70,DL,STA4
+P1,11,No,Yes,R1,P2PTXBE013,6G,LPI,T1F,5,160,-79,DL,STA63,,,,,,,,,,,,
+P1,11,No,No,R1,P2PTXBE014,6G,LPI,T1B,5,160,-70,DL,STA63,,,,,,,,,,,,
P1,11,No,No,R1,P2PTXBE015,6G,LPI,T1C,5,160,-45,DL,STA63,,,,,,,,,,,,
+P2,12,Yes,Yes,R1,COETXBE007,6G,LPI,T1F,161,80,-76,DL,STA56,T2A,5,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
P1,13,No,Yes,R1,P2PTXBE016,6G,LPI,T1F,133,160,-79,DL,STA63,,,,,,,,,,,,
-P1,13,No,No,R1,P2PTXBE017,6G,LPI,T2B,133,160,-70,DL,STA63,,,,,,,,,,,,
+P1,13,No,No,R1,P2PTXBE017,6G,LPI,T1B,133,160,-70,DL,STA63,,,,,,,,,,,,
P1,13,No,No,R1,P2PTXBE018,6G,LPI,T1C,133,160,-45,DL,STA63,,,,,,,,,,,,
+P2,14,Yes,Yes,R1,COETXBE008,6G,LPI,T1F,161,80,-76,DL,STA56,T2A,133,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
P1,15,No,Yes,R1,P2PTXBE019,6G,LPI,T1F,197,160,-79,DL,STA63,,,,,,,,,,,,
-P1,15,No,No,R1,P2PTXBE020,6G,LPI,T2B,197,160,-70,DL,STA63,,,,,,,,,,,,
+P1,15,No,No,R1,P2PTXBE020,6G,LPI,T1B,197,160,-70,DL,STA63,,,,,,,,,,,,
P1,15,No,No,R1,P2PTXBE021,6G,LPI,T1C,197,160,-45,DL,STA63,,,,,,,,,,,,
+P2,16,Yes,Yes,R1,COETXBE009,6G,LPI,T1F,161,80,-76,DL,STA56,T2A,197,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
P1,17,No,Yes,R1,P2PTXBE022,6G,SP,T1F,5,320,-79,DL,STA63,,,,,,,,,,,,
-P1,17,No,No,R1,P2PTXBE023,6G,SP,T2B,5,320,-70,DL,STA63,,,,,,,,,,,,
+P1,17,No,No,R1,P2PTXBE023,6G,SP,T1B,5,320,-70,DL,STA63,,,,,,,,,,,,
P1,17,No,No,R1,P2PTXBE024,6G,SP,T1C,5,320,-45,DL,STA63,,,,,,,,,,,,
+P2,18,Yes,Yes,R1,COETXBE010,6G,SP,T1F,161,80,-76,DL,STA56,T2A,5,320,-79,DL,STA63,T3E,6,20,-70,DL,STA4
P1,19,No,Yes,R1,P2PTXBE025,6G,LPI,T1F,133,320,-79,DL,STA63,,,,,,,,,,,,
-P1,19,No,No,R1,P2PTXBE026,6G,LPI,T2B,133,320,-70,DL,STA63,,,,,,,,,,,,
+P1,19,No,No,R1,P2PTXBE026,6G,LPI,T1B,133,320,-70,DL,STA63,,,,,,,,,,,,
P1,19,No,No,R1,P2PTXBE027,6G,LPI,T1C,133,320,-45,DL,STA63,,,,,,,,,,,,
+P2,20,Yes,Yes,R1,COETXBE011,6G,LPI,T1F,161,80,-76,DL,STA56,T2A,133,320,-79,DL,STA63,T3E,6,20,-70,DL,STA4
P1,21,No,Yes,R2,P2PTXBE028,2G,LPI,T3E,1,20,-70,UL,STA63,,,,,,,,,,,,
P1,21,No,No,R2,P2PTXBE029,2G,LPI,T1D,1,20,-60,UL,STA63,,,,,,,,,,,,
P1,21,No,No,R2,P2PTXBE030,2G,LPI,T1C,1,20,-45,UL,STA63,,,,,,,,,,,,
+P2,22,Yes,Yes,R2,COETXBE012,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,5,160,-79,DL,STA56,T3E,1,20,-70,DL,STA63
P1,23,No,Yes,R2,P2PTXBE031,2G,LPI,T3E,6,20,-70,UL,STA63,,,,,,,,,,,,
P1,23,No,No,R2,P2PTXBE032,2G,LPI,T1D,6,20,-60,UL,STA63,,,,,,,,,,,,
P1,23,No,No,R2,P2PTXBE033,2G,LPI,T1C,6,20,-45,UL,STA63,,,,,,,,,,,,
+P2,24,Yes,Yes,R2,COETXBE013,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,133,160,-79,DL,STA56,T3E,6,20,-70,DL,STA63
P1,25,No,Yes,R2,P2PTXBE034,2G,LPI,T3E,11,20,-70,UL,STA63,,,,,,,,,,,,
P1,25,No,No,R2,P2PTXBE035,2G,LPI,T1D,11,20,-60,UL,STA63,,,,,,,,,,,,
P1,25,No,No,R2,P2PTXBE036,2G,LPI,T1C,11,20,-45,UL,STA63,,,,,,,,,,,,
+P2,26,Yes,Yes,R2,COETXBE014,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,197,160,-79,DL,STA56,T3E,11,20,-70,DL,STA63
+P1,1,No,No,R1,P2PTXAX001,5G,LPI,T1F,36,80,-76,DL,STA56,,,,,,,,,,,,
+P1,1,No,No,R1,P2PTXAX002,5G,LPI,T1B,36,80,-67,DL,STA56,,,,,,,,,,,,
+P1,1,No,No,R1,P2PTXAX003,5G,LPI,T1C,36,80,-45,DL,STA56,,,,,,,,,,,,
+P1,2,No,Yes,R1,P2PTXAX004,5G,LPI,T1F,100,80,-76,DL,STA56,,,,,,,,,,,,
+P1,2,No,No,R1,P2PTXAX005,5G,LPI,T1B,100,80,-67,DL,STA56,,,,,,,,,,,,
+P1,2,No,No,R1,P2PTXAX006,5G,LPI,T1C,100,80,-45,DL,STA56,,,,,,,,,,,,
+P2,3,Yes,Yes,R1,COETXAX001,5G,LPI,T1F,100,80,-76,DL,STA56,T2A,5,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
+P2,4,Yes,Yes,R1,COETXAX002,5G,LPI,T1F,100,80,-76,DL,STA56,T2A,133,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
+P1,5,No,Yes,R1,P2PTXAX007,5G,LPI,T1F,161,80,-76,DL,STA56,,,,,,,,,,,,
+P1,5,No,No,R1,P2PTXAX008,5G,LPI,T1B,161,80,-67,DL,STA56,,,,,,,,,,,,
+P1,5,No,No,R1,P2PTXAX009,5G,LPI,T1C,161,80,-45,DL,STA56,,,,,,,,,,,,
+P2,6,Yes,Yes,R1,COETXAX003,5G,LPI,T1F,161,80,-76,DL,STA56,T2A,5,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
+P2,7,Yes,Yes,R1,COETXAX004,5G,LPI,T1F,161,80,-76,DL,STA56,T2A,133,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4
+P1,8,No,Yes,R1,P2PTXAX010,5G,LPI,T1F,100,160,-76,DL,STA56,,,,,,,,,,,,
+P1,8,No,No,R1,P2PTXAX011,5G,LPI,T1B,100,160,-67,DL,STA56,,,,,,,,,,,,
+P1,8,No,No,R1,P2PTXAX012,5G,LPI,T1C,100,160,-45,DL,STA56,,,,,,,,,,,,
+P2,9,Yes,Yes,R1,COETXAX005,5G,SP,T1F,100,160,-76,DL,STA56,T2A,5,320,-79,DL,STA63,T3E,6,20,-70,DL,STA4
+P2,10,Yes,Yes,R1,COETXAX006,5G,LPI,T1F,100,160,-76,DL,STA56,T2A,133,320,-79,DL,STA63,T3E,6,20,-70,DL,STA4
+P1,11,No,Yes,R2,P2PTXAX013,6G,LPI,,,,,,,T2A,5,160,-79,DL,STA56,,,,,,
+P1,11,No,No,R2,P2PTXAX014,6G,LPI,,,,,,,T1B,5,160,-70,DL,STA56,,,,,,
+P1,11,No,No,R2,P2PTXAX015,6G,LPI,,,,,,,T1C,5,160,-45,DL,STA56,,,,,,
+P2,12,Yes,Yes,R2,COETXAX007,6G,LPI,T1F,161,80,-76,DL,STA63,T2A,5,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4
+P1,13,No,Yes,R2,P2PTXAX016,6G,LPI,,,,,,,T2A,133,160,-79,DL,STA56,,,,,,
+P1,13,No,No,R2,P2PTXAX017,6G,LPI,,,,,,,T1B,133,160,-70,DL,STA56,,,,,,
+P1,13,No,No,R2,P2PTXAX018,6G,LPI,,,,,,,T1C,133,160,-45,DL,STA56,,,,,,
+P2,14,Yes,Yes,R2,COETXAX008,6G,LPI,T1F,161,80,-76,DL,STA63,T2A,133,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4
+P1,15,No,Yes,R2,P2PTXAX019,6G,LPI,,,,,,,T2A,197,160,-79,DL,STA56,,,,,,
+P1,15,No,No,R2,P2PTXAX020,6G,LPI,,,,,,,T1B,197,160,-70,DL,STA56,,,,,,
+P1,15,No,No,R2,P2PTXAX021,6G,LPI,,,,,,,T1C,197,160,-45,DL,STA56,,,,,,
+P2,16,Yes,Yes,R2,COETXAX009,6G,LPI,T1F,161,80,-76,DL,STA63,T2A,197,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4
+P1,17,No,Yes,R2,P2PTXAX022,6G,SP,,,,,,,T2A,5,320,-79,DL,STA56,,,,,,
+P1,17,No,No,R2,P2PTXAX023,6G,SP,,,,,,,T1B,5,320,-70,DL,STA56,,,,,,
+P1,17,No,No,R2,P2PTXAX024,6G,SP,,,,,,,T1C,5,320,-45,DL,STA56,,,,,,
+P2,18,Yes,Yes,R2,COETXAX010,6G,SP,T1F,161,80,-76,DL,STA63,T2A,5,320,-79,DL,STA56,T3E,6,20,-70,DL,STA4
+P1,19,No,Yes,R2,P2PTXAX025,6G,LPI,,,,,,,T2A,133,320,-79,DL,STA56,,,,,,
+P1,19,No,No,R2,P2PTXAX026,6G,LPI,,,,,,,T1B,133,320,-70,DL,STA56,,,,,,
+P1,19,No,No,R2,P2PTXAX027,6G,LPI,,,,,,,T1C,133,320,-45,DL,STA56,,,,,,
+P2,20,Yes,Yes,R2,COETXAX011,6G,LPI,T1F,161,80,-76,DL,STA63,T2A,133,320,-79,DL,STA56,T3E,6,20,-70,DL,STA4
+P1,21,No,Yes,R3,P2PTXAX028,2G,LPI,,,,,,,,,,,,,T3E,1,20,-70,UL,STA56
+P1,21,No,No,R3,P2PTXAX029,2G,LPI,,,,,,,,,,,,,T1D,1,20,-60,UL,STA56
+P1,21,No,No,R3,P2PTXAX030,2G,LPI,,,,,,,,,,,,,T1C,1,20,-45,UL,STA56
+P2,22,Yes,Yes,R3,COETXAX012,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,5,160,-79,DL,STA63,T3E,1,20,-70,DL,STA56
+P1,23,No,Yes,R3,P2PTXAX031,2G,LPI,,,,,,,,,,,,,T3E,6,20,-70,UL,STA56
+P1,23,No,No,R3,P2PTXAX032,2G,LPI,,,,,,,,,,,,,T1D,6,20,-60,UL,STA56
+P1,23,No,No,R3,P2PTXAX033,2G,LPI,,,,,,,,,,,,,T1C,6,20,-45,UL,STA56
+P2,24,Yes,Yes,R3,COETXAX013,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,133,160,-79,DL,STA63,T3E,6,20,-70,DL,STA56
+P1,25,No,Yes,R3,P2PTXAX034,2G,LPI,,,,,,,,,,,,,T3E,11,20,-70,UL,STA56
+P1,25,No,No,R3,P2PTXAX035,2G,LPI,,,,,,,,,,,,,T1D,11,20,-60,UL,STA56
+P1,25,No,No,R3,P2PTXAX036,2G,LPI,,,,,,,,,,,,,T1C,11,20,-45,UL,STA56
+P2,26,Yes,Yes,R3,COETXAX014,2G,LPI,T1F,36,80,-76,DL,STA4,T2A,197,160,-79,DL,STA63,T3E,11,20,-70,DL,STA56
+P1,1,No,No,R2,P2PTXAC001,5G,LPI,T1F,36,80,-76,DL,STA4,,,,,,,,,,,,
+P1,1,No,No,R2,P2PTXAC002,5G,LPI,T1B,36,80,-67,DL,STA4,,,,,,,,,,,,
+P1,1,No,No,R2,P2PTXAC003,5G,LPI,T1C,36,80,-45,DL,STA4,,,,,,,,,,,,
+P1,2,No,Yes,R2,P2PTXAC004,5G,LPI,T1F,100,80,-76,DL,STA4,,,,,,,,,,,,
+P1,2,No,No,R2,P2PTXAC005,5G,LPI,T1B,100,80,-67,DL,STA4,,,,,,,,,,,,
+P1,2,No,No,R2,P2PTXAC006,5G,LPI,T1C,100,80,-45,DL,STA4,,,,,,,,,,,,
+P2,3,Yes,Yes,R2,COETXAC001,5G,LPI,T1F,100,80,-76,DL,STA4,T1K2A,5,160,OFF,DL,STA63,T3E,6,20,-70,DL,STA56
+P1,5,No,Yes,R2,P2PTXAC007,5G,LPI,T1F,161,80,-76,DL,STA4,,,,,,,,,,,,
+P1,5,No,No,R2,P2PTXAC008,5G,LPI,T1B,161,80,-67,DL,STA4,,,,,,,,,,,,
+P1,5,No,No,R2,P2PTXAC009,5G,LPI,T1C,161,80,-45,DL,STA4,,,,,,,,,,,,
+P2,6,Yes,Yes,R2,COETXAC002,5G,LPI,T1F,161,80,-76,DL,STA4,T1K2A,133,160,OFF,DL,STA63,T3E,6,20,-70,DL,STA56
+P1,14,No,Yes,R1,P2PTXAC010,2G,LPI,,,,,,,,,,,,,T3E,1,20,-70,DL,STA4
+P1,14,No,No,R1,P2PTXAC011,2G,LPI,,,,,,,,,,,,,T1D,1,20,-60,DL,STA4
+P1,14,No,No,R1,P2PTXAC012,2G,LPI,,,,,,,,,,,,,T1C,1,20,-45,DL,STA4
+P2,15,Yes,Yes,R1,COETXAC003,2G,LPI,T1F,36,80,-76,DL,STA56,T1K2A,5,160,OFF,DL,STA63,T3E,1,20,-70,DL,STA4
+P1,16,No,Yes,R1,P2PTXAC013,2G,LPI,,,,,,,,,,,,,T3E,6,20,-70,DL,STA4
+P1,16,No,No,R1,P2PTXAC014,2G,LPI,,,,,,,,,,,,,T1D,6,20,-60,DL,STA4
+P1,16,No,No,R1,P2PTXAC015,2G,LPI,,,,,,,,,,,,,T1C,6,20,-45,DL,STA4
+P2,17,Yes,Yes,R1,COETXAC004,2G,LPI,T1F,36,80,-76,DL,STA56,T1K2A,133,160,OFF,DL,STA63,T3E,6,20,-70,DL,STA4
+P1,18,No,Yes,R1,P2PTXAC016,2G,LPI,,,,,,,,,,,,,T3E,11,20,-70,DL,STA4
+P1,18,No,No,R1,P2PTXAC017,2G,LPI,,,,,,,,,,,,,T1D,11,20,-60,DL,STA4
+P1,18,No,No,R1,P2PTXAC018,2G,LPI,,,,,,,,,,,,,T1C,11,20,-45,DL,STA4
+P2,19,Yes,Yes,R1,COETXAC005,2G,LPI,T1F,36,80,-76,DL,STA56,T1K2A,5,320,OFF,DL,STA63,T3E,11,20,-70,DL,STA4
diff --git a/backend/data/p3p_tests.csv b/backend/data/CGW453_P3P_Tests.csv
similarity index 86%
rename from backend/data/p3p_tests.csv
rename to backend/data/CGW453_P3P_Tests.csv
index 614790a..8232be8 100644
--- a/backend/data/p3p_tests.csv
+++ b/backend/data/CGW453_P3P_Tests.csv
@@ -1,81 +1,89 @@
-Priority,Index,Throttled,Rotation,TC ID,Band,6GHz Power Mode,Station 1 Test Point,Station 1 Channel,Station 1 Bandwidth,Station 1 RSSI,Station 1 Direction,Station 1 Rate,Station 1 STA,Station 2 Test Point,Station 2 Channel,Station 2 Bandwidth,Station 2 RSSI,Station 2 Direction,Station 2 Rate,Station 2 STA,Station 3 Test Point,Station 3 Channel,Station 3 Bandwidth,Station 3 RSSI,Station 3 Direction,Station 3 Rate,Station 3 STA
+Priority,Index,Throttled,Rotation,TC ID,Band,6GHz Power Mode,STATION 1_Test Point,STATION 1_Channel,STATION 1_Bandwidth,STATION 1_RSSI,STATION 1_Direction,STATION 1_Rate,STATION 1_STA,STATION 2_Test Point,STATION 2_Channel,STATION 2_Bandwidth,STATION 2_RSSI,STATION 2_Direction,STATION 2_Rate,STATION 2_STA,STATION 3_Test Point,STATION 3_Channel,STATION 3_Bandwidth,STATION 3_RSSI,STATION 3_Direction,STATION 3_Rate,STATION 3_STA
+P2,1,Yes,R3,P3PRXBETH001,5G,LPI,T1F,36,80,-76,UL,22Mbps,STA63,T1I,36,80,-76,UL,22Mbps,STA64,T2J,36,80,-76,UL,11Mbps,STA65
+P2,2,Yes,R3,P3PRXBETH002,5G,LPI,T1F,100,80,-76,UL,22Mbps,STA63,T1I,100,80,-76,UL,22Mbps,STA64,T2J,100,80,-76,UL,11Mbps,STA65
+P2,3,Yes,R3,P3PRXBETH003,5G,LPI,T1F,161,80,-76,UL,22Mbps,STA63,T1I,161,80,-76,UL,22Mbps,STA64,T2J,161,80,-76,UL,11Mbps,STA65
P2,4,Yes,R1,P3PRXBETH004,6G,LPI,T2A,5,160,-79,UL,22Mbps,STA63,T1L,5,160,-79,UL,22Mbps,STA64,T2O,5,160,-79,UL,11Mbps,STA65
P2,5,Yes,R1,P3PRXBETH005,6G,LPI,T2A,133,160,-79,UL,22Mbps,STA63,T1L,133,160,-79,UL,22Mbps,STA64,T2O,133,160,-79,UL,11Mbps,STA65
P2,6,Yes,R1,P3PRXBETH006,6G,LPI,T2A,197,160,-79,UL,22Mbps,STA63,T1L,197,160,-79,UL,22Mbps,STA64,T2O,197,160,-79,UL,11Mbps,STA65
+P2,7,Yes,R2,P3PRXBETH007,2G,LPI,T3E,6,20,-70,UL,22Mbps,STA63,T1P,6,20,-70,UL,22Mbps,STA64,T2Q,6,20,-70,UL,11Mbps,STA65
+P2,8,Yes,R1,P3PRXBETH008,6G,SP,T2A,5,320,-79,UL,22Mbps,STA63,T1L,5,320,-79,UL,22Mbps,STA64,T2O,5,320,-79,UL,11Mbps,STA65
P2,9,Yes,R1,P3PRXBETH009,6G,LPI,T2A,133,320,-79,UL,22Mbps,STA63,T1L,133,320,-79,UL,22Mbps,STA64,T2O,133,320,-79,UL,11Mbps,STA65
+P2,1,No,R3,P3PRXBEUT001,5G,LPI,T1F,36,80,-76,UL,Unlimited,STA63,T1I,36,80,-76,UL,Unlimited,STA64,T2J,36,80,-76,UL,Unlimited,STA65
+P2,2,No,R3,P3PRXBEUT002,5G,LPI,T1F,100,80,-76,UL,Unlimited,STA63,T1I,100,80,-76,UL,Unlimited,STA64,T2J,100,80,-76,UL,Unlimited,STA65
+P2,3,No,R3,P3PRXBEUT003,5G,LPI,T1F,161,80,-76,UL,Unlimited,STA63,T1I,161,80,-76,UL,Unlimited,STA64,T2J,161,80,-76,UL,Unlimited,STA65
P2,4,No,R1,P3PRXBEUT004,6G,LPI,T2A,5,160,-79,UL,Unlimited,STA63,T1L,5,160,-79,UL,Unlimited,STA64,T2O,5,160,-79,UL,Unlimited,STA65
P2,5,No,R1,P3PRXBEUT005,6G,LPI,T2A,133,160,-79,UL,Unlimited,STA63,T1L,133,160,-79,UL,Unlimited,STA64,T2O,133,160,-79,UL,Unlimited,STA65
P2,6,No,R1,P3PRXBEUT006,6G,LPI,T2A,197,160,-79,UL,Unlimited,STA63,T1L,197,160,-79,UL,Unlimited,STA64,T2O,197,160,-79,UL,Unlimited,STA65
+P2,7,No,R2,P3PRXBEUT007,2G,LPI,T3E,6,20,-70,UL,Unlimited,STA63,T1P,6,20,-70,UL,Unlimited,STA64,T2Q,6,20,-70,UL,Unlimited,STA65
+P2,8,No,R1,P3PRXBEUT008,6G,SP,T2A,5,320,-79,UL,Unlimited,STA63,T1L,5,320,-79,UL,Unlimited,STA64,T2O,5,320,-79,UL,Unlimited,STA65
P2,9,No,R1,P3PRXBEUT009,6G,LPI,T2A,133,320,-79,UL,Unlimited,STA63,T1L,133,320,-79,UL,Unlimited,STA64,T2O,133,320,-79,UL,Unlimited,STA65
+P2,1,Yes,R3,P3PTXBETH001,5G,LPI,T1F,36,80,-76,DL,22Mbps,STA63,T1I,36,80,-76,DL,22Mbps,STA64,T2J,36,80,-76,DL,11Mbps,STA65
+P2,2,Yes,R3,P3PTXBETH002,5G,LPI,T1F,100,80,-76,DL,22Mbps,STA63,T1I,100,80,-76,DL,22Mbps,STA64,T2J,100,80,-76,DL,11Mbps,STA65
+P2,3,Yes,R3,P3PTXBETH003,5G,LPI,T1F,161,80,-76,DL,22Mbps,STA63,T1I,161,80,-76,DL,22Mbps,STA64,T2J,161,80,-76,DL,11Mbps,STA65
P2,4,Yes,R1,P3PTXBETH004,6G,LPI,T2A,5,160,-79,DL,22Mbps,STA63,T1L,5,160,-79,DL,22Mbps,STA64,T2O,5,160,-79,DL,11Mbps,STA65
P2,5,Yes,R1,P3PTXBETH005,6G,LPI,T2A,133,160,-79,DL,22Mbps,STA63,T1L,133,160,-79,DL,22Mbps,STA64,T2O,133,160,-79,DL,11Mbps,STA65
P2,6,Yes,R1,P3PTXBETH006,6G,LPI,T2A,197,160,-79,DL,22Mbps,STA63,T1L,197,160,-79,DL,22Mbps,STA64,T2O,197,160,-79,DL,11Mbps,STA65
+P2,7,Yes,R2,P3PTXBETH007,2G,LPI,T3E,6,20,-70,DL,22Mbps,STA63,T1P,6,20,-70,DL,22Mbps,STA64,T2Q,6,20,-70,DL,11Mbps,STA65
+P2,8,Yes,R1,P3PTXBETH008,6G,SP,T2A,5,320,-79,DL,22Mbps,STA63,T1L,5,320,-79,DL,22Mbps,STA64,T2O,5,320,-79,DL,11Mbps,STA65
P2,9,Yes,R1,P3PTXBETH009,6G,LPI,T2A,133,320,-79,DL,22Mbps,STA63,T1L,133,320,-79,DL,22Mbps,STA64,T2O,133,320,-79,DL,11Mbps,STA65
+P2,1,No,R3,P3PTXBEUT001,5G,LPI,T1F,36,80,-76,DL,Unlimited,STA63,T1I,36,80,-76,DL,Unlimited,STA64,T2J,36,80,-76,DL,Unlimited,STA65
+P2,2,No,R3,P3PTXBEUT002,5G,LPI,T1F,100,80,-76,DL,Unlimited,STA63,T1I,100,80,-76,DL,Unlimited,STA64,T2J,100,80,-76,DL,Unlimited,STA65
+P2,3,No,R3,P3PTXBEUT003,5G,LPI,T1F,161,80,-76,DL,Unlimited,STA63,T1I,161,80,-76,DL,Unlimited,STA64,T2J,161,80,-76,DL,Unlimited,STA65
P2,4,No,R1,P3PTXBEUT004,6G,LPI,T2A,5,160,-79,DL,Unlimited,STA63,T1L,5,160,-79,DL,Unlimited,STA64,T2O,5,160,-79,DL,Unlimited,STA65
P2,5,No,R1,P3PTXBEUT005,6G,LPI,T2A,133,160,-79,DL,Unlimited,STA63,T1L,133,160,-79,DL,Unlimited,STA64,T2O,133,160,-79,DL,Unlimited,STA65
P2,6,No,R1,P3PTXBEUT006,6G,LPI,T2A,197,160,-79,DL,Unlimited,STA63,T1L,197,160,-79,DL,Unlimited,STA64,T2O,197,160,-79,DL,Unlimited,STA65
+P2,7,No,R2,P3PTXBEUT007,2G,LPI,T3E,6,20,-70,DL,Unlimited,STA63,T1P,6,20,-70,DL,Unlimited,STA64,T2Q,6,20,-70,DL,Unlimited,STA65
+P2,8,No,R1,P3PTXBEUT008,6G,SP,T2A,5,320,-79,DL,Unlimited,STA63,T1L,5,320,-79,DL,Unlimited,STA64,T2O,5,320,-79,DL,Unlimited,STA65
P2,9,No,R1,P3PTXBEUT009,6G,LPI,T2A,133,320,-79,DL,Unlimited,STA63,T1L,133,320,-79,DL,Unlimited,STA64,T2O,133,320,-79,DL,Unlimited,STA65
P2,1,Yes,R1,P3PRXAXTH001,5G,LPI,T1F,36,80,-76,UL,22Mbps,STA56,T1I,36,80,-76,UL,22Mbps,STA58,T2J,36,80,-76,UL,11Mbps,STA59
P2,2,Yes,R1,P3PRXAXTH002,5G,LPI,T1F,100,80,-76,UL,22Mbps,STA56,T1I,100,80,-76,UL,22Mbps,STA58,T2J,100,80,-76,UL,11Mbps,STA59
P2,3,Yes,R1,P3PRXAXTH003,5G,LPI,T1F,161,80,-76,UL,22Mbps,STA56,T1I,161,80,-76,UL,22Mbps,STA58,T2J,161,80,-76,UL,11Mbps,STA59
-P2,1,No,R1,P3PRXAXUT001,5G,LPI,T1F,36,80,-76,UL,Unlimited,STA56,T1I,36,80,-76,UL,Unlimited,STA58,T2J,36,80,-76,UL,Unlimited,STA59
-P2,2,No,R1,P3PRXAXUT002,5G,LPI,T1F,100,80,-76,UL,Unlimited,STA56,T1I,100,80,-76,UL,Unlimited,STA58,T2J,100,80,-76,UL,Unlimited,STA59
-P2,3,No,R1,P3PRXAXUT003,5G,LPI,T1F,161,80,-76,UL,Unlimited,STA56,T1I,161,80,-76,UL,Unlimited,STA58,T2J,161,80,-76,UL,Unlimited,STA59
-P2,1,Yes,R1,P3PTXAXTH001,5G,LPI,T1F,36,80,-76,DL,22Mbps,STA56,T1I,36,80,-76,DL,22Mbps,STA58,T2J,36,80,-76,DL,11Mbps,STA59
-P2,2,Yes,R1,P3PTXAXTH002,5G,LPI,T1F,100,80,-76,DL,22Mbps,STA56,T1I,100,80,-76,DL,22Mbps,STA58,T2J,100,80,-76,DL,11Mbps,STA59
-P2,3,Yes,R1,P3PTXAXTH003,5G,LPI,T1F,161,80,-76,DL,22Mbps,STA56,T1I,161,80,-76,DL,22Mbps,STA58,T2J,161,80,-76,DL,11Mbps,STA59
-P2,1,No,R1,P3PTXAXUT001,5G,LPI,T1F,36,80,-76,DL,Unlimited,STA56,T1I,36,80,-76,DL,Unlimited,STA58,T2J,36,80,-76,DL,Unlimited,STA59
-P2,2,No,R1,P3PTXAXUT002,5G,LPI,T1F,100,80,-76,DL,Unlimited,STA56,T1I,100,80,-76,DL,Unlimited,STA58,T2J,100,80,-76,DL,Unlimited,STA59
-P2,3,No,R1,P3PTXAXUT003,5G,LPI,T1F,161,80,-76,DL,Unlimited,STA56,T1I,161,80,-76,DL,Unlimited,STA58,T2J,161,80,-76,DL,Unlimited,STA59
-P2,4,Yes,R1,P3PRXACTH004,2G,LPI,T3E,6,20,-70,UL,22Mbps,STA4,T1P,6,20,-70,UL,22Mbps,STA5,T2Q,6,20,-70,UL,11Mbps,STA6
-P2,4,No,R1,P3PRXACUT004,2G,LPI,T3E,6,20,-70,UL,Unlimited,STA4,T1P,6,20,-70,UL,Unlimited,STA5,T2Q,6,20,-70,UL,Unlimited,STA6
-P2,4,Yes,R1,P3PTXACTH004,2G,LPI,T3E,6,20,-70,DL,22Mbps,STA4,T1P,6,20,-70,DL,22Mbps,STA5,T2Q,6,20,-70,DL,11Mbps,STA6
-P2,4,No,R1,P3PTXACUT004,2G,LPI,T3E,6,20,-70,DL,Unlimited,STA4,T1P,6,20,-70,DL,Unlimited,STA5,T2Q,6,20,-70,DL,Unlimited,STA6
-P2,7,Yes,R2,P3PRXBETH007,2G,LPI,T3E,6,20,-70,UL,22Mbps,STA63,T1P,6,20,-70,UL,22Mbps,STA64,T2Q,6,20,-70,UL,11Mbps,STA65
-P2,7,No,R2,P3PRXBEUT007,2G,LPI,T3E,6,20,-70,UL,Unlimited,STA63,T1P,6,20,-70,UL,Unlimited,STA64,T2Q,6,20,-70,UL,Unlimited,STA65
-P2,7,Yes,R2,P3PTXBETH007,2G,LPI,T3E,6,20,-70,DL,22Mbps,STA63,T1P,6,20,-70,DL,22Mbps,STA64,T2Q,6,20,-70,DL,11Mbps,STA65
-P2,7,No,R2,P3PTXBEUT007,2G,LPI,T3E,6,20,-70,DL,Unlimited,STA63,T1P,6,20,-70,DL,Unlimited,STA64,T2Q,6,20,-70,DL,Unlimited,STA65
P2,4,Yes,R2,P3PRXAXTH004,6G,LPI,T2A,5,160,-79,UL,22Mbps,STA56,T1L,5,160,-79,UL,22Mbps,STA58,T2O,5,160,-79,UL,11Mbps,STA59
P2,5,Yes,R2,P3PRXAXTH005,6G,LPI,T2A,133,160,-79,UL,22Mbps,STA56,T1L,133,160,-79,UL,22Mbps,STA58,T2O,133,160,-79,UL,11Mbps,STA59
P2,6,Yes,R2,P3PRXAXTH006,6G,LPI,T2A,197,160,-79,UL,22Mbps,STA56,T1L,197,160,-79,UL,22Mbps,STA58,T2O,197,160,-79,UL,11Mbps,STA59
+P2,7,Yes,R3,P3PRXAXTH007,2G,LPI,T3E,6,20,-70,UL,22Mbps,STA56,T1P,6,20,-70,UL,22Mbps,STA58,T2Q,6,20,-70,UL,11Mbps,STA59
+P2,8,Yes,R2,P3PRXAXTH008,6G,SP,T2A,5,320,-79,UL,22Mbps,STA56,T1L,5,320,-79,UL,22Mbps,STA58,T2O,5,320,-79,UL,11Mbps,STA59
P2,9,Yes,R2,P3PRXAXTH009,6G,LPI,T2A,133,320,-79,UL,22Mbps,STA56,T1L,133,320,-79,UL,22Mbps,STA58,T2O,133,320,-79,UL,11Mbps,STA59
+P2,1,No,R1,P3PRXAXUT001,5G,LPI,T1F,36,80,-76,UL,Unlimited,STA56,T1I,36,80,-76,UL,Unlimited,STA58,T2J,36,80,-76,UL,Unlimited,STA59
+P2,2,No,R1,P3PRXAXUT002,5G,LPI,T1F,100,80,-76,UL,Unlimited,STA56,T1I,100,80,-76,UL,Unlimited,STA58,T2J,100,80,-76,UL,Unlimited,STA59
+P2,3,No,R1,P3PRXAXUT003,5G,LPI,T1F,161,80,-76,UL,Unlimited,STA56,T1I,161,80,-76,UL,Unlimited,STA58,T2J,161,80,-76,UL,Unlimited,STA59
P2,4,No,R2,P3PRXAXUT004,6G,LPI,T2A,5,160,-79,UL,Unlimited,STA56,T1L,5,160,-79,UL,Unlimited,STA58,T2O,5,160,-79,UL,Unlimited,STA59
P2,5,No,R2,P3PRXAXUT005,6G,LPI,T2A,133,160,-79,UL,Unlimited,STA56,T1L,133,160,-79,UL,Unlimited,STA58,T2O,133,160,-79,UL,Unlimited,STA59
P2,6,No,R2,P3PRXAXUT006,6G,LPI,T2A,197,160,-79,UL,Unlimited,STA56,T1L,197,160,-79,UL,Unlimited,STA58,T2O,197,160,-79,UL,Unlimited,STA59
+P2,7,No,R3,P3PRXAXUT007,2G,LPI,T3E,6,20,-70,UL,Unlimited,STA56,T1P,6,20,-70,UL,Unlimited,STA58,T2Q,6,20,-70,UL,Unlimited,STA59
+P2,8,No,R2,P3PRXAXUT008,6G,SP,T2A,5,320,-79,UL,Unlimited,STA56,T1L,5,320,-79,UL,Unlimited,STA58,T2O,5,320,-79,UL,Unlimited,STA59
P2,9,No,R2,P3PRXAXUT009,6G,LPI,T2A,133,320,-79,UL,Unlimited,STA56,T1L,133,320,-79,UL,Unlimited,STA58,T2O,133,320,-79,UL,Unlimited,STA59
+P2,1,Yes,R1,P3PTXAXTH001,5G,LPI,T1F,36,80,-76,DL,22Mbps,STA56,T1I,36,80,-76,DL,22Mbps,STA58,T2J,36,80,-76,DL,11Mbps,STA59
+P2,2,Yes,R1,P3PTXAXTH002,5G,LPI,T1F,100,80,-76,DL,22Mbps,STA56,T1I,100,80,-76,DL,22Mbps,STA58,T2J,100,80,-76,DL,11Mbps,STA59
+P2,3,Yes,R1,P3PTXAXTH003,5G,LPI,T1F,161,80,-76,DL,22Mbps,STA56,T1I,161,80,-76,DL,22Mbps,STA58,T2J,161,80,-76,DL,11Mbps,STA59
P2,4,Yes,R2,P3PTXAXTH004,6G,LPI,T2A,5,160,-79,DL,22Mbps,STA56,T1L,5,160,-79,DL,22Mbps,STA58,T2O,5,160,-79,DL,11Mbps,STA59
P2,5,Yes,R2,P3PTXAXTH005,6G,LPI,T2A,133,160,-79,DL,22Mbps,STA56,T1L,133,160,-79,DL,22Mbps,STA58,T2O,133,160,-79,DL,11Mbps,STA59
P2,6,Yes,R2,P3PTXAXTH006,6G,LPI,T2A,197,160,-79,DL,22Mbps,STA56,T1L,197,160,-79,DL,22Mbps,STA58,T2O,197,160,-79,DL,11Mbps,STA59
+P2,7,Yes,R3,P3PTXAXTH007,2G,LPI,T3E,6,20,-70,DL,22Mbps,STA56,T1P,6,20,-70,DL,22Mbps,STA58,T2Q,6,20,-70,DL,11Mbps,STA59
+P2,8,Yes,R2,P3PTXAXTH008,6G,SP,T2A,5,320,-79,DL,22Mbps,STA56,T1L,5,320,-79,DL,22Mbps,STA58,T2O,5,320,-79,DL,11Mbps,STA59
P2,9,Yes,R2,P3PTXAXTH009,6G,LPI,T2A,133,320,-79,DL,22Mbps,STA56,T1L,133,320,-79,DL,22Mbps,STA58,T2O,133,320,-79,DL,11Mbps,STA59
+P2,1,No,R1,P3PTXAXUT001,5G,LPI,T1F,36,80,-76,DL,Unlimited,STA56,T1I,36,80,-76,DL,Unlimited,STA58,T2J,36,80,-76,DL,Unlimited,STA59
+P2,2,No,R1,P3PTXAXUT002,5G,LPI,T1F,100,80,-76,DL,Unlimited,STA56,T1I,100,80,-76,DL,Unlimited,STA58,T2J,100,80,-76,DL,Unlimited,STA59
+P2,3,No,R1,P3PTXAXUT003,5G,LPI,T1F,161,80,-76,DL,Unlimited,STA56,T1I,161,80,-76,DL,Unlimited,STA58,T2J,161,80,-76,DL,Unlimited,STA59
P2,4,No,R2,P3PTXAXUT004,6G,LPI,T2A,5,160,-79,DL,Unlimited,STA56,T1L,5,160,-79,DL,Unlimited,STA58,T2O,5,160,-79,DL,Unlimited,STA59
P2,5,No,R2,P3PTXAXUT005,6G,LPI,T2A,133,160,-79,DL,Unlimited,STA56,T1L,133,160,-79,DL,Unlimited,STA58,T2O,133,160,-79,DL,Unlimited,STA59
P2,6,No,R2,P3PTXAXUT006,6G,LPI,T2A,197,160,-79,DL,Unlimited,STA56,T1L,197,160,-79,DL,Unlimited,STA58,T2O,197,160,-79,DL,Unlimited,STA59
+P2,7,No,R3,P3PTXAXUT007,2G,LPI,T3E,6,20,-70,DL,Unlimited,STA56,T1P,6,20,-70,DL,Unlimited,STA58,T2Q,6,20,-70,DL,Unlimited,STA59
+P2,8,No,R2,P3PTXAXUT008,6G,SP,T2A,5,320,-79,DL,Unlimited,STA56,T1L,5,320,-79,DL,Unlimited,STA58,T2O,5,320,-79,DL,Unlimited,STA59
P2,9,No,R2,P3PTXAXUT009,6G,LPI,T2A,133,320,-79,DL,Unlimited,STA56,T1L,133,320,-79,DL,Unlimited,STA58,T2O,133,320,-79,DL,Unlimited,STA59
P2,1,Yes,R2,P3PRXACTH001,5G,LPI,T1F,36,80,-76,UL,22Mbps,STA4,T1I,36,80,-76,UL,22Mbps,STA5,T2J,36,80,-76,UL,11Mbps,STA6
P2,2,Yes,R2,P3PRXACTH002,5G,LPI,T1F,100,80,-76,UL,22Mbps,STA4,T1I,100,80,-76,UL,22Mbps,STA5,T2J,100,80,-76,UL,11Mbps,STA6
P2,3,Yes,R2,P3PRXACTH003,5G,LPI,T1F,161,80,-76,UL,22Mbps,STA4,T1I,161,80,-76,UL,22Mbps,STA5,T2J,161,80,-76,UL,11Mbps,STA6
+P2,4,Yes,R1,P3PRXACTH004,2G,LPI,T3E,6,20,-70,UL,22Mbps,STA4,T1P,6,20,-70,UL,22Mbps,STA5,T2Q,6,20,-70,UL,11Mbps,STA6
P2,1,No,R2,P3PRXACUT001,5G,LPI,T1F,36,80,-76,UL,Unlimited,STA4,T1I,36,80,-76,UL,Unlimited,STA5,T2J,36,80,-76,UL,Unlimited,STA6
P2,2,No,R2,P3PRXACUT002,5G,LPI,T1F,100,80,-76,UL,Unlimited,STA4,T1I,100,80,-76,UL,Unlimited,STA5,T2J,100,80,-76,UL,Unlimited,STA6
P2,3,No,R2,P3PRXACUT003,5G,LPI,T1F,161,80,-76,UL,Unlimited,STA4,T1I,161,80,-76,UL,Unlimited,STA5,T2J,161,80,-76,UL,Unlimited,STA6
+P2,4,No,R1,P3PRXACUT004,2G,LPI,T3E,6,20,-70,UL,Unlimited,STA4,T1P,6,20,-70,UL,Unlimited,STA5,T2Q,6,20,-70,UL,Unlimited,STA6
P2,1,Yes,R2,P3PTXACTH001,5G,LPI,T1F,36,80,-76,DL,22Mbps,STA4,T1I,36,80,-76,DL,22Mbps,STA5,T2J,36,80,-76,DL,11Mbps,STA6
P2,2,Yes,R2,P3PTXACTH002,5G,LPI,T1F,100,80,-76,DL,22Mbps,STA4,T1I,100,80,-76,DL,22Mbps,STA5,T2J,100,80,-76,DL,11Mbps,STA6
P2,3,Yes,R2,P3PTXACTH003,5G,LPI,T1F,161,80,-76,DL,22Mbps,STA4,T1I,161,80,-76,DL,22Mbps,STA5,T2J,161,80,-76,DL,11Mbps,STA6
+P2,4,Yes,R1,P3PTXACTH004,2G,LPI,T3E,6,20,-70,DL,22Mbps,STA4,T1P,6,20,-70,DL,22Mbps,STA5,T2Q,6,20,-70,DL,11Mbps,STA6
P2,1,No,R2,P3PTXACUT001,5G,LPI,T1F,36,80,-76,DL,Unlimited,STA4,T1I,36,80,-76,DL,Unlimited,STA5,T2J,36,80,-76,DL,Unlimited,STA6
P2,2,No,R2,P3PTXACUT002,5G,LPI,T1F,100,80,-76,DL,Unlimited,STA4,T1I,100,80,-76,DL,Unlimited,STA5,T2J,100,80,-76,DL,Unlimited,STA6
P2,3,No,R2,P3PTXACUT003,5G,LPI,T1F,161,80,-76,DL,Unlimited,STA4,T1I,161,80,-76,DL,Unlimited,STA5,T2J,161,80,-76,DL,Unlimited,STA6
-P2,1,Yes,R3,P3PRXBETH001,5G,LPI,T1F,36,80,-76,UL,22Mbps,STA63,T1I,36,80,-76,UL,22Mbps,STA64,T2J,36,80,-76,UL,11Mbps,STA65
-P2,2,Yes,R3,P3PRXBETH002,5G,LPI,T1F,100,80,-76,UL,22Mbps,STA63,T1I,100,80,-76,UL,22Mbps,STA64,T2J,100,80,-76,UL,11Mbps,STA65
-P2,3,Yes,R3,P3PRXBETH003,5G,LPI,T1F,161,80,-76,UL,22Mbps,STA63,T1I,161,80,-76,UL,22Mbps,STA64,T2J,161,80,-76,UL,11Mbps,STA65
-P2,1,No,R3,P3PRXBEUT001,5G,LPI,T1F,36,80,-76,UL,Unlimited,STA63,T1I,36,80,-76,UL,Unlimited,STA64,T2J,36,80,-76,UL,Unlimited,STA65
-P2,2,No,R3,P3PRXBEUT002,5G,LPI,T1F,100,80,-76,UL,Unlimited,STA63,T1I,100,80,-76,UL,Unlimited,STA64,T2J,100,80,-76,UL,Unlimited,STA65
-P2,3,No,R3,P3PRXBEUT003,5G,LPI,T1F,161,80,-76,UL,Unlimited,STA63,T1I,161,80,-76,UL,Unlimited,STA64,T2J,161,80,-76,UL,Unlimited,STA65
-P2,1,Yes,R3,P3PTXBETH001,5G,LPI,T1F,36,80,-76,DL,22Mbps,STA63,T1I,36,80,-76,DL,22Mbps,STA64,T2J,36,80,-76,DL,11Mbps,STA65
-P2,2,Yes,R3,P3PTXBETH002,5G,LPI,T1F,100,80,-76,DL,22Mbps,STA63,T1I,100,80,-76,DL,22Mbps,STA64,T2J,100,80,-76,DL,11Mbps,STA65
-P2,3,Yes,R3,P3PTXBETH003,5G,LPI,T1F,161,80,-76,DL,22Mbps,STA63,T1I,161,80,-76,DL,22Mbps,STA64,T2J,161,80,-76,DL,11Mbps,STA65
-P2,1,No,R3,P3PTXBEUT001,5G,LPI,T1F,36,80,-76,DL,Unlimited,STA63,T1I,36,80,-76,DL,Unlimited,STA64,T2J,36,80,-76,DL,Unlimited,STA65
-P2,2,No,R3,P3PTXBEUT002,5G,LPI,T1F,100,80,-76,DL,Unlimited,STA63,T1I,100,80,-76,DL,Unlimited,STA64,T2J,100,80,-76,DL,Unlimited,STA65
-P2,3,No,R3,P3PTXBEUT003,5G,LPI,T1F,161,80,-76,DL,Unlimited,STA63,T1I,161,80,-76,DL,Unlimited,STA64,T2J,161,80,-76,DL,Unlimited,STA65
-P2,7,Yes,R3,P3PRXAXTH007,2G,LPI,T1D,6,20,-70,UL,22Mbps,STA56,T1P,6,20,-70,UL,22Mbps,STA58,T2Q,6,20,-70,UL,11Mbps,STA59
-P2,7,No,R3,P3PRXAXUT007,2G,LPI,T1D,6,20,-70,UL,Unlimited,STA56,T1P,6,20,-70,UL,Unlimited,STA58,T2Q,6,20,-70,UL,Unlimited,STA59
-P2,7,Yes,R3,P3PTXAXTH007,2G,LPI,T1D,6,20,-70,DL,22Mbps,STA56,T1P,6,20,-70,DL,22Mbps,STA58,T2Q,6,20,-70,DL,11Mbps,STA59
-P2,7,No,R3,P3PTXAXUT007,2G,LPI,T1D,6,20,-70,DL,Unlimited,STA56,T1P,6,20,-70,DL,Unlimited,STA58,T2Q,6,20,-70,DL,Unlimited,STA59
+P2,4,No,R1,P3PTXACUT004,2G,LPI,T3E,6,20,-70,DL,Unlimited,STA4,T1P,6,20,-70,DL,Unlimited,STA5,T2Q,6,20,-70,DL,Unlimited,STA6
diff --git a/backend/db.py b/backend/db.py
index 9b86daf..3927705 100644
--- a/backend/db.py
+++ b/backend/db.py
@@ -6,7 +6,7 @@ from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterator
-from graph import build_station_testpoint_map
+from test_config import serialize_station_testpoint_map
APP_ROOT = Path(__file__).resolve().parent
@@ -123,13 +123,6 @@ def init_db(db_path: str | Path = DB_PATH) -> None:
minutes INTEGER NOT NULL
);
- CREATE TABLE IF NOT EXISTS graph_cache (
- name TEXT PRIMARY KEY,
- payload_json TEXT NOT NULL,
- test_count INTEGER NOT NULL DEFAULT 0,
- updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
- );
-
CREATE TABLE IF NOT EXISTS rerun_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
detected_date TEXT NOT NULL,
@@ -166,14 +159,38 @@ 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)
+ return serialize_station_testpoint_map(config)
def upsert_tests(records: list[TestRecord], db_path: str | Path = DB_PATH) -> int:
if not records:
return 0
+ values: list[tuple[Any, ...]] = []
+ for r in records:
+ station_testpoint_map = _serialize_station_testpoint_map(r.config)
+
+ values.append(
+ (
+ r.test_id,
+ r.device,
+ r.test_type,
+ r.rotation,
+ r.rx_tx,
+ int(r.has_coe_pair),
+ json.dumps(r.coe_pairing or []),
+ json.dumps(r.config),
+ r.priority,
+ r.victim_band,
+ int(r.throttled),
+ r.estimated_minutes,
+ r.status,
+ int(r.excluded),
+ json.dumps(r.raw_payload or {}),
+ station_testpoint_map,
+ )
+ )
+
with get_connection(db_path) as conn:
conn.executemany(
"""
@@ -200,27 +217,7 @@ def upsert_tests(records: list[TestRecord], db_path: str | Path = DB_PATH) -> in
station_testpoint_map = excluded.station_testpoint_map,
updated_at = CURRENT_TIMESTAMP
""",
- [
- (
- r.test_id,
- r.device,
- r.test_type,
- r.rotation,
- r.rx_tx,
- int(r.has_coe_pair),
- json.dumps(r.coe_pairing or []),
- json.dumps(r.config),
- r.priority,
- r.victim_band,
- int(r.throttled),
- r.estimated_minutes,
- r.status,
- int(r.excluded),
- json.dumps(r.raw_payload or {}),
- _serialize_station_testpoint_map(r.config),
- )
- for r in records
- ],
+ values,
)
return len(records)
@@ -253,52 +250,6 @@ def read_settings(db_path: str | Path = DB_PATH) -> dict[str, Any]:
return {row["key"]: json.loads(row["value_json"]) for row in rows}
-def save_graph_cache(
- name: str,
- payload: dict[str, list[str]],
- test_count: int,
- db_path: str | Path = DB_PATH,
-) -> None:
- with get_connection(db_path) as conn:
- conn.execute(
- """
- INSERT INTO graph_cache(name, payload_json, test_count, updated_at)
- VALUES (?, ?, ?, CURRENT_TIMESTAMP)
- ON CONFLICT(name) DO UPDATE SET
- payload_json = excluded.payload_json,
- test_count = excluded.test_count,
- updated_at = CURRENT_TIMESTAMP
- """,
- (name, json.dumps(payload), int(test_count)),
- )
-
-
-def load_graph_cache(name: str, db_path: str | Path = DB_PATH) -> dict[str, Any] | None:
- with get_connection(db_path) as conn:
- row = conn.execute(
- """
- SELECT payload_json, test_count, updated_at
- FROM graph_cache
- WHERE name = ?
- """,
- (name,),
- ).fetchone()
-
- if row is None:
- return None
-
- return {
- "payload": json.loads(row["payload_json"] or "{}"),
- "test_count": int(row["test_count"]),
- "updated_at": row["updated_at"],
- }
-
-
-def delete_graph_cache(name: str, db_path: str | Path = DB_PATH) -> None:
- with get_connection(db_path) as conn:
- conn.execute("DELETE FROM graph_cache WHERE name = ?", (name,))
-
-
def _parse_rule_tokens(rule: str | None) -> list[str]:
if not rule:
return []
@@ -345,7 +296,7 @@ def _match_any_band_value(record: TestRecord, key: str, token_suffix: str) -> bo
target_num = _extract_signed_int(token_suffix)
target_compact = _normalize_compact(token_suffix)
- keys = ("Station 1", "Station 2", "Station 3") if record.test_type == "P3P" else ("5G", "6G", "2G")
+ keys = ("STATION 1", "STATION 2", "STATION 3") if record.test_type == "P3P" else ("5G", "6G", "2G")
for band in keys:
entry = _band_entry(record.config, band)
@@ -579,6 +530,56 @@ def get_schedule_week(start_date: str, db_path: str | Path = DB_PATH) -> list[Sc
return result
+
+def get_schedule_rows_for_latest_version(db_path: str | Path = DB_PATH) -> list[ScheduleRow]:
+ with get_connection(db_path) as conn:
+ version_row = conn.execute("SELECT MAX(schedule_version) AS latest FROM schedules").fetchone()
+ latest = version_row["latest"]
+ if latest is None:
+ return []
+
+ rows = conn.execute(
+ """
+ SELECT
+ s.test_id,
+ s.device,
+ s.scheduled_date,
+ s.shift_index,
+ s.sequence_in_shift,
+ t.test_type,
+ t.rotation,
+ t.config_json,
+ t.status,
+ t.priority,
+ t.estimated_minutes
+ FROM schedules s
+ JOIN tests t ON t.test_id = s.test_id AND t.device = s.device
+ WHERE s.schedule_version = ?
+ ORDER BY s.scheduled_date, s.shift_index, s.sequence_in_shift
+ """,
+ (latest,),
+ ).fetchall()
+
+ result: list[ScheduleRow] = []
+ for row in rows:
+ result.append(
+ ScheduleRow(
+ test_id=row["test_id"],
+ device=row["device"],
+ scheduled_date=row["scheduled_date"],
+ shift_index=int(row["shift_index"]),
+ sequence_in_shift=int(row["sequence_in_shift"]),
+ test_type=row["test_type"],
+ rotation=row["rotation"],
+ config=json.loads(row["config_json"] or "{}"),
+ status=row["status"],
+ priority=int(row["priority"]),
+ estimated_minutes=int(row["estimated_minutes"]),
+ )
+ )
+
+ return result
+
def mark_tests_completed(test_ids_with_device: list[tuple[str, str]], db_path: str | Path = DB_PATH) -> None:
if not test_ids_with_device:
return
diff --git a/backend/graph.py b/backend/graph.py
deleted file mode 100644
index 364429a..0000000
--- a/backend/graph.py
+++ /dev/null
@@ -1,179 +0,0 @@
-from __future__ import annotations
-
-import json
-import os
-from pathlib import Path
-from typing import Any
-
-# Immutable-in-practice graph state for the loaded DUT test set.
-_TESTS_BY_ID: dict[str, Any] = {}
-_GRAPH: dict[str, set[str]] = {}
-DUT = os.getenv("DUT", "CGW453").strip()
-APP_ROOT = Path(__file__).resolve().parent
-DB_PATH = APP_ROOT / "scheduler.db"
-GRAPH_CACHE_NAME = "dut_compatibility"
-
-
-def reset_graph_state() -> None:
- global _TESTS_BY_ID, _GRAPH
- _TESTS_BY_ID = {}
- _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:
- return bool(_GRAPH)
-
-
-def build_graph_once(tests: list[Any]) -> dict[str, set[str]]:
- """Build DUT-only compatibility adjacency graph once per load lifecycle."""
- global _TESTS_BY_ID, _GRAPH
- if _GRAPH:
- return _GRAPH
-
- # Keep only one representative per DUT test_id.
- _TESTS_BY_ID = {str(t.test_id): t for t in tests if getattr(t, "test_id", None)}
- _GRAPH = _build_graph(_TESTS_BY_ID)
- return _GRAPH
-
-
-def serialize_graph(graph: dict[str, set[str]]) -> dict[str, list[str]]:
- return {str(test_id): sorted(str(neighbor) for neighbor in neighbors) for test_id, neighbors in graph.items()}
-
-
-def deserialize_graph(data: dict[str, list[str]]) -> dict[str, set[str]]:
- deserialized: dict[str, set[str]] = {}
- for test_id, neighbors in (data or {}).items():
- deserialized[str(test_id)] = {str(neighbor) for neighbor in (neighbors or [])}
- return deserialized
-
-
-def _persist_graph(db_path: str | Path = DB_PATH) -> None:
- import db as db_module
- db_module.save_graph_cache(
- name=GRAPH_CACHE_NAME,
- payload=serialize_graph(_GRAPH),
- test_count=len(_GRAPH),
- db_path=db_path,
- )
-
-
-def _load_graph_from_db(db_path: str | Path = DB_PATH) -> bool:
- global _GRAPH
- import db as db_module
- cached = db_module.load_graph_cache(GRAPH_CACHE_NAME, db_path)
- if cached is None:
- return False
- _GRAPH = deserialize_graph(cached.get("payload") or {})
- return True
-
-
-def build_and_persist_graph(tests: list[Any], db_path: str | Path = DB_PATH) -> dict[str, set[str]]:
- graph = build_graph_once(tests)
- _persist_graph(db_path)
- return graph
-
-
-def get_graph(
- db_path: str | Path = DB_PATH,
- dut_tests: list[Any] | None = None,
-) -> dict[str, set[str]]:
- if _GRAPH:
- return _GRAPH
-
- if _load_graph_from_db(db_path):
- return _GRAPH
-
- if dut_tests is None:
- import db as db_module
- dut_tests = db_module.list_tests_for_device(DUT, db_path)
-
- build_and_persist_graph(dut_tests or [], db_path)
- return _GRAPH
-
-
-def _build_graph(tests: dict[str, Any]) -> dict[str, set[str]]:
- test_ids = list(tests.keys())
- graph = {test_id: set() for test_id in test_ids}
- for i in range(len(test_ids)):
- for j in range(i + 1, len(test_ids)):
- a_id = test_ids[i]
- b_id = test_ids[j]
- a = tests[a_id]
- b = tests[b_id]
- if _compatible(a, b):
- graph[a_id].add(b_id)
- graph[b_id].add(a_id)
- return graph
-
-
-def _compatible(a: Any, b: Any) -> bool:
- station_map_a = _get_station_map(a)
- station_map_b = _get_station_map(b)
- 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):
- return True
- return False
-
-def _same_station_to_testpoint(
- station_map_a: dict[str, str],
- station_map_b: dict[str, str], test_a: Any, test_b: Any
-) -> bool:
- overlap_stations = set(station_map_a.keys()) & set(station_map_b.keys())
- for station in overlap_stations:
- if station_map_a[station] != station_map_b[station]:
- return False
- 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:
- return False
- return True
-
-def build_station_testpoint_map(config: dict[str, dict[str, str | None]]) -> dict[str, str]:
- station_to_testpoint: dict[str, str] = {}
-
- def _add_entry(entry: dict[str, str | None]) -> None:
- testpoint = _norm(entry.get("test_point"))
- sta_raw = _norm(entry.get("sta"))
- if not testpoint or not sta_raw:
- return
- for sta in sta_raw.split(","):
- sta_clean = _norm(sta)
- if sta_clean:
- station_to_testpoint[sta_clean] = testpoint
-
- 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 station_to_testpoint
-
-
-
-def _norm(value: Any) -> str:
- if value is None:
- return ""
- return " ".join(str(value).strip().upper().split())
diff --git a/backend/parser.py b/backend/parser.py
index 26e75a6..910fa7e 100644
--- a/backend/parser.py
+++ b/backend/parser.py
@@ -21,24 +21,24 @@ P2P_COE_REQUIRED_COLUMNS = [
"TC ID",
"Victim Band",
"6GHz Power Mode",
- "5G Test Point",
- "5G Channel",
- "5G Bandwidth",
- "5G RSSI",
- "5G Direction",
- "5G STA",
- "6G Test Point",
- "6G Channel",
- "6G Bandwidth",
- "6G RSSI",
- "6G Direction",
- "6G STA",
- "2G Test Point",
- "2G Channel",
- "2G Bandwidth",
- "2G RSSI",
- "2G Direction",
- "2G STA",
+ "5G_Test Point",
+ "5G_Channel",
+ "5G_Bandwidth",
+ "5G_RSSI",
+ "5G_Direction",
+ "5G_STA",
+ "6G_Test Point",
+ "6G_Channel",
+ "6G_Bandwidth",
+ "6G_RSSI",
+ "6G_Direction",
+ "6G_STA",
+ "2G_Test Point",
+ "2G_Channel",
+ "2G_Bandwidth",
+ "2G_RSSI",
+ "2G_Direction",
+ "2G_STA",
]
P3P_REQUIRED_COLUMNS = [
@@ -49,27 +49,27 @@ P3P_REQUIRED_COLUMNS = [
"TC ID",
"Band",
"6GHz Power Mode",
- "Station 1 Test Point",
- "Station 1 Channel",
- "Station 1 Bandwidth",
- "Station 1 RSSI",
- "Station 1 Direction",
- "Station 1 Rate",
- "Station 1 STA",
- "Station 2 Test Point",
- "Station 2 Channel",
- "Station 2 Bandwidth",
- "Station 2 RSSI",
- "Station 2 Direction",
- "Station 2 Rate",
- "Station 2 STA",
- "Station 3 Test Point",
- "Station 3 Channel",
- "Station 3 Bandwidth",
- "Station 3 RSSI",
- "Station 3 Direction",
- "Station 3 Rate",
- "Station 3 STA",
+ "STATION 1_Test Point",
+ "STATION 1_Channel",
+ "STATION 1_Bandwidth",
+ "STATION 1_RSSI",
+ "STATION 1_Direction",
+ "STATION 1_Rate",
+ "STATION 1_STA",
+ "STATION 2_Test Point",
+ "STATION 2_Channel",
+ "STATION 2_Bandwidth",
+ "STATION 2_RSSI",
+ "STATION 2_Direction",
+ "STATION 2_Rate",
+ "STATION 2_STA",
+ "STATION 3_Test Point",
+ "STATION 3_Channel",
+ "STATION 3_Bandwidth",
+ "STATION 3_RSSI",
+ "STATION 3_Direction",
+ "STATION 3_Rate",
+ "STATION 3_STA",
]
RUNTIME_DEFAULTS = {
@@ -230,7 +230,6 @@ def _detect_csv_format(normalized_fieldnames: set[str]) -> str:
f"{', '.join(p2p_coe_missing)}; missing columns for P3P format: {', '.join(p3p_missing)}"
)
-
def parse_target_csv(
csv_path: str | Path | list[str | Path] | tuple[str | Path, ...],
smb_credentials: dict[str, Any] | None = None,
@@ -476,11 +475,20 @@ def _victim_band_signature(row: dict[str, str]) -> tuple[str, ...] | None:
if band is None:
return None
- prefix = f"{band} "
- test_point = _normalize_value(_row_get(row, f"{prefix}Test Point"))
- channel = _normalize_value(_row_get(row, f"{prefix}Channel"))
- rssi = _normalize_value(_row_get(row, f"{prefix}RSSI"))
- bandwidth = _normalize_value(_row_get(row, f"{prefix}Bandwidth"))
+ # Source CSVs may use either "5G_Test Point" or "5G Test Point" style headers.
+ prefixes = (f"{band}_", f"{band} ")
+
+ def _pick_value(suffix: str) -> str:
+ for prefix in prefixes:
+ value = _row_get(row, f"{prefix}{suffix}")
+ if _normalize_value(value):
+ return _normalize_value(value)
+ return ""
+
+ test_point = _pick_value("Test Point")
+ channel = _pick_value("Channel")
+ rssi = _pick_value("RSSI")
+ bandwidth = _pick_value("Bandwidth")
# Direction is intentionally ignored for COE pairing matching.
if not all([test_point, channel, rssi, bandwidth]):
@@ -515,61 +523,61 @@ def _normalize_yes_no(value: str | None) -> bool:
def _build_legacy_config(row: dict[str, str]) -> dict[str, dict[str, str | None]]:
return {
"5G": {
- "test_point": _empty_to_none(_row_get(row, "5G Test Point")),
- "channel": _empty_to_none(_row_get(row, "5G Channel")),
- "bandwidth": _empty_to_none(_row_get(row, "5G Bandwidth")),
- "rssi": _empty_to_none(_row_get(row, "5G RSSI")),
- "direction": _empty_to_none(_row_get(row, "5G Direction")),
- "sta": _empty_to_none(_row_get(row, "5G STA")),
+ "test_point": _empty_to_none(_row_get(row, "5G_Test Point")),
+ "channel": _empty_to_none(_row_get(row, "5G_Channel")),
+ "bandwidth": _empty_to_none(_row_get(row, "5G_Bandwidth")),
+ "rssi": _empty_to_none(_row_get(row, "5G_RSSI")),
+ "direction": _empty_to_none(_row_get(row, "5G_Direction")),
+ "sta": _empty_to_none(_row_get(row, "5G_STA")),
},
"6G": {
"power_mode": _empty_to_none(_row_get(row, "6GHz Power Mode")),
- "test_point": _empty_to_none(_row_get(row, "6G Test Point")),
- "channel": _empty_to_none(_row_get(row, "6G Channel")),
- "bandwidth": _empty_to_none(_row_get(row, "6G Bandwidth")),
- "rssi": _empty_to_none(_row_get(row, "6G RSSI")),
- "direction": _empty_to_none(_row_get(row, "6G Direction")),
- "sta": _empty_to_none(_row_get(row, "6G STA")),
+ "test_point": _empty_to_none(_row_get(row, "6G_Test Point")),
+ "channel": _empty_to_none(_row_get(row, "6G_Channel")),
+ "bandwidth": _empty_to_none(_row_get(row, "6G_Bandwidth")),
+ "rssi": _empty_to_none(_row_get(row, "6G_RSSI")),
+ "direction": _empty_to_none(_row_get(row, "6G_Direction")),
+ "sta": _empty_to_none(_row_get(row, "6G_STA")),
},
"2G": {
- "test_point": _empty_to_none(_row_get(row, "2G Test Point")),
- "channel": _empty_to_none(_row_get(row, "2G Channel")),
- "bandwidth": _empty_to_none(_row_get(row, "2G Bandwidth")),
- "rssi": _empty_to_none(_row_get(row, "2G RSSI")),
- "direction": _empty_to_none(_row_get(row, "2G Direction")),
- "sta": _empty_to_none(_row_get(row, "2G STA")),
+ "test_point": _empty_to_none(_row_get(row, "2G_Test Point")),
+ "channel": _empty_to_none(_row_get(row, "2G_Channel")),
+ "bandwidth": _empty_to_none(_row_get(row, "2G_Bandwidth")),
+ "rssi": _empty_to_none(_row_get(row, "2G_RSSI")),
+ "direction": _empty_to_none(_row_get(row, "2G_Direction")),
+ "sta": _empty_to_none(_row_get(row, "2G_STA")),
},
}
def _build_p3p_config(row: dict[str, str]) -> dict[str, dict[str, str | None]]:
return {
- "Station 1": {
- "test_point": _empty_to_none(_row_get(row, "Station 1 Test Point")),
- "channel": _empty_to_none(_row_get(row, "Station 1 Channel")),
- "bandwidth": _empty_to_none(_row_get(row, "Station 1 Bandwidth")),
- "rssi": _empty_to_none(_row_get(row, "Station 1 RSSI")),
- "direction": _empty_to_none(_row_get(row, "Station 1 Direction")),
- "rate": _empty_to_none(_row_get(row, "Station 1 Rate")),
- "sta": _empty_to_none(_row_get(row, "Station 1 STA")),
+ "STATION1": {
+ "test_point": _empty_to_none(_row_get(row, "STATION 1_Test Point")),
+ "channel": _empty_to_none(_row_get(row, "STATION 1_Channel")),
+ "bandwidth": _empty_to_none(_row_get(row, "STATION 1_Bandwidth")),
+ "rssi": _empty_to_none(_row_get(row, "STATION 1_RSSI")),
+ "direction": _empty_to_none(_row_get(row, "STATION 1_Direction")),
+ "rate": _empty_to_none(_row_get(row, "STATION 1_Rate")),
+ "sta": _empty_to_none(_row_get(row, "STATION 1_STA")),
},
- "Station 2": {
- "test_point": _empty_to_none(_row_get(row, "Station 2 Test Point")),
- "channel": _empty_to_none(_row_get(row, "Station 2 Channel")),
- "bandwidth": _empty_to_none(_row_get(row, "Station 2 Bandwidth")),
- "rssi": _empty_to_none(_row_get(row, "Station 2 RSSI")),
- "direction": _empty_to_none(_row_get(row, "Station 2 Direction")),
- "rate": _empty_to_none(_row_get(row, "Station 2 Rate")),
- "sta": _empty_to_none(_row_get(row, "Station 2 STA")),
+ "STATION2": {
+ "test_point": _empty_to_none(_row_get(row, "STATION 2_Test Point")),
+ "channel": _empty_to_none(_row_get(row, "STATION 2_Channel")),
+ "bandwidth": _empty_to_none(_row_get(row, "STATION 2_Bandwidth")),
+ "rssi": _empty_to_none(_row_get(row, "STATION 2_RSSI")),
+ "direction": _empty_to_none(_row_get(row, "STATION 2_Direction")),
+ "rate": _empty_to_none(_row_get(row, "STATION 2_Rate")),
+ "sta": _empty_to_none(_row_get(row, "STATION 2_STA")),
},
- "Station 3": {
- "test_point": _empty_to_none(_row_get(row, "Station 3 Test Point")),
- "channel": _empty_to_none(_row_get(row, "Station 3 Channel")),
- "bandwidth": _empty_to_none(_row_get(row, "Station 3 Bandwidth")),
- "rssi": _empty_to_none(_row_get(row, "Station 3 RSSI")),
- "direction": _empty_to_none(_row_get(row, "Station 3 Direction")),
- "rate": _empty_to_none(_row_get(row, "Station 3 Rate")),
- "sta": _empty_to_none(_row_get(row, "Station 3 STA")),
+ "STATION3": {
+ "test_point": _empty_to_none(_row_get(row, "STATION 3_Test Point")),
+ "channel": _empty_to_none(_row_get(row, "STATION 3_Channel")),
+ "bandwidth": _empty_to_none(_row_get(row, "STATION 3_Bandwidth")),
+ "rssi": _empty_to_none(_row_get(row, "STATION 3_RSSI")),
+ "direction": _empty_to_none(_row_get(row, "STATION 3_Direction")),
+ "rate": _empty_to_none(_row_get(row, "STATION 3_Rate")),
+ "sta": _empty_to_none(_row_get(row, "STATION 3_STA")),
},
}
diff --git a/backend/scheduler.py b/backend/scheduler.py
index 165dd26..d564646 100644
--- a/backend/scheduler.py
+++ b/backend/scheduler.py
@@ -2,616 +2,292 @@ from __future__ import annotations
import os
from dataclasses import dataclass
-from datetime import date, datetime, timedelta
-from typing import Any
-
-import graph
-
-# Bundle priority tiers for scheduling order (lower number = higher priority)
-BUNDLE_PRIORITY_FAILED = 0 # Failed tests requiring rerun
-BUNDLE_PRIORITY_P2P_WITH_COE = 1 # P2P tests with COE pairs
-BUNDLE_PRIORITY_P2P_ONLY = 2 # P2P tests without COE pairs (RX/TX bundled)
-BUNDLE_PRIORITY_COE_ONLY = 3 # COE tests without P2P pairing (should be rare/unschedulable)
-BUNDLE_PRIORITY_P3P = 4 # P3P tests
-
-DUT = os.getenv("DUT", "CGW453").strip()
-REF = os.getenv("REF", "CGW452").strip()
-TestKey = tuple[str, str]
-
-@dataclass(frozen=True)
-class SchedulerTest:
- test_id: str
- device: str
- test_type: str
- rotation: str | None
- rx_tx: str | None
- has_coe_pair: bool
- coe_pairing: list[str]
- config: dict[str, dict[str, str | None]]
- throttled: bool
- estimated_minutes: int
- priority: int
- raw_payload: dict[str, Any]
+from datetime import date
+from test_window import get_shift_sequence_with_capacity, next_window_start_date
+from test_bundle import Test, TestBundle, build_test_bundles, bundle_pair_lookup
+DUT = (os.getenv("DUT") or "DUT").strip()
+REF = (os.getenv("REF") or "REF").strip()
@dataclass(frozen=True)
class ScheduleEntry:
- test_id: str
- device: str
- scheduled_date: str
- shift_index: int
- sequence_in_shift: int
-
-
-@dataclass(frozen=True)
-class TestBundle:
- """A bundle of tests that must be run together in sequence."""
- test_ids: list[TestKey]
- priority_tier: int
- total_minutes: int
-
-
-# Global scheduler state. Graph is built once per loaded test set.
-_TESTS_BY_ID: dict[TestKey, SchedulerTest] = {}
-_ACTIVE_DUT: dict[TestKey, int] = {}
-_ACTIVE_REF: dict[TestKey, int] = {}
-
-
-def reset_scheduler_state() -> None:
- global _TESTS_BY_ID, _ACTIVE_DUT, _ACTIVE_REF
- _TESTS_BY_ID = {}
- _ACTIVE_DUT = {}
- _ACTIVE_REF = {}
-
-
-def initialize_scheduler_state(tests: list[SchedulerTest]) -> None:
- """Initialize active scheduler state once for the current loaded schedulable dataset."""
- global _TESTS_BY_ID, _ACTIVE_DUT, _ACTIVE_REF
- if _TESTS_BY_ID:
- return
-
- _TESTS_BY_ID = {(t.test_id, t.device): t for t in tests}
- _ACTIVE_DUT = {
- test_id: _derive_priority(test, set(), set(), tests)
- for test_id, test in _TESTS_BY_ID.items() if test.device == DUT
- }
- _ACTIVE_REF = {
- test_id: _derive_priority(test, set(), set(), tests)
- for test_id, test in _TESTS_BY_ID.items() if test.device == REF
- }
-
-
-def set_user_priorities(top_priority_tests: set[str], lowest_priority_tests: set[str]) -> None:
- """Update active priority map in-place using user overrides."""
- if not _TESTS_BY_ID:
- return
-
- all_tests = list(_TESTS_BY_ID.values())
- for key in list(_ACTIVE_DUT.keys()):
- test = _TESTS_BY_ID.get(key)
- if test is None:
- continue
- _ACTIVE_DUT[key] = _derive_priority(test, top_priority_tests, lowest_priority_tests, all_tests)
-
- for key in list(_ACTIVE_REF.keys()):
- test = _TESTS_BY_ID.get(key)
- if test is None:
- continue
- _ACTIVE_REF[key] = _derive_priority(test, top_priority_tests, lowest_priority_tests, all_tests)
-
-
-def remove_from_active(completed_test_ids: set[str]) -> None:
- """Remove completed/invalid tests from active list without rebuilding graph."""
- for test_id in completed_test_ids:
- _ACTIVE_DUT.pop((test_id, DUT), None)
- _ACTIVE_REF.pop((test_id, REF), None)
-
-
-
-def compile_schedule(
- tests: list[SchedulerTest],
- start_date: str | None,
- holiday_dates: set[str],
- top_priority_tests: set[str],
- lowest_priority_tests: set[str],
- daytime_testing_today: bool,
-) -> tuple[list[ScheduleEntry], str | None]:
- """Compile an optimized schedule for the given tests.
-
- Uses the greedy algorithm respecting shift sequences per design:
- - Mon-Thu: shift 3 (5pm-1am) then shift 1 next day (1am-10am)
- - Friday: shift 3 through Monday shift 1 (4-day window)
- Returns a list of ScheduleEntry objects and the completion date.
- """
- if not tests:
- return [], None
-
- initialize_scheduler_state(tests)
- set_user_priorities(top_priority_tests, lowest_priority_tests)
-
- # Use a local working copy for this compile run. Global active remains until result processing removes IDs.
- dut_active_priority: dict[TestKey, int] = dict(_ACTIVE_DUT)
- ref_active_priority: dict[TestKey, int] = dict(_ACTIVE_REF)
- entries: list[ScheduleEntry] = []
-
- window_start_date = _parse_date(start_date)
- current_date = window_start_date
- last_date: str | None = None
- daytime_shift2_window_pending = daytime_testing_today
- date_device_lock: dict[str, str] = {}
-
- while dut_active_priority or ref_active_priority:
- is_special_daytime_shift2_window = (
- daytime_shift2_window_pending
- and current_date == window_start_date
- and current_date.weekday() < 5
- and current_date.isoformat() not in holiday_dates
- )
-
- # Get the shift sequence for current date (respects Mon/Fri/weekend rules)
- shift_sequence = _get_shift_sequence(
- current_date,
- holiday_dates,
- daytime_shift2_only=is_special_daytime_shift2_window,
- )
-
- if not shift_sequence:
- break # No valid shift sequence
-
- dut_active_test_ids: set[TestKey] = set(dut_active_priority.keys())
- ref_active_test_ids: set[TestKey] = set(ref_active_priority.keys())
- bundles = _create_bundles(dut_active_test_ids, ref_active_test_ids, _TESTS_BY_ID, top_priority_tests)
- shift_capacities = {
- (date_obj, shift_idx): _shift_capacity_for_date(
- current_date=date_obj,
- holiday_dates=holiday_dates,
- daytime_shift2_only=is_special_daytime_shift2_window and date_obj == current_date,
- ).get(shift_idx, 0)
- for date_obj, shift_idx in shift_sequence
- }
-
- forced_device: str | None = None
- for date_obj, _shift_idx in shift_sequence:
- locked_device = date_device_lock.get(date_obj.isoformat())
- if locked_device is None:
- continue
- if forced_device is None:
- forced_device = locked_device
- elif forced_device != locked_device:
- raise ValueError(f"Conflicting device locks in shift sequence for {date_obj.isoformat()}")
-
- placed_entries, placed_test_ids, placed_last_date, window_device = _fit_bundles_to_shifts(
- bundles=bundles,
- shift_sequence=shift_sequence,
- tests=_TESTS_BY_ID,
- graph_by_test_id=graph.get_graph(),
- shift_capacities=shift_capacities,
- forced_device=forced_device,
- )
- entries.extend(placed_entries)
- if window_device is not None:
- for date_obj, _shift_idx in shift_sequence:
- date_device_lock.setdefault(date_obj.isoformat(), window_device)
- for key in placed_test_ids:
- dut_active_priority.pop(key, None)
- ref_active_priority.pop(key, None)
- if placed_last_date is not None:
- last_date = placed_last_date
-
- # Daytime testing special handling applies only to the first scheduling window.
- daytime_shift2_window_pending = False
-
- # Move to next scheduling window start date
- # After shift 1 (1am-9am), there's shift 2 if daytime testing, then shift 3 (5pm)
- # After shift 3 (5pm), shift 1 is next day (1am)
- if shift_sequence:
- last_sequence_date = shift_sequence[-1][0]
- last_shift_index = shift_sequence[-1][1]
-
- if last_shift_index == 1:
- # Last shift was 1 (1am-9am); next shift 3 is same day (5pm)
- current_date = last_sequence_date
- elif last_shift_index == 2:
- # Last shift was 2 (9am-5pm); next shift 3 is same day (5pm)
- current_date = last_sequence_date
- else: # last_shift_index == 3
- # Last shift was 3 (5pm-1am); next shift 1 is next day (1am)
- current_date = last_sequence_date + timedelta(days=1)
- else:
- current_date = current_date + timedelta(days=1)
-
- return entries, last_date
-
-
-def format_schedule_for_frontend(
- entries: list[ScheduleEntry],
-) -> dict[str, dict[int, list[SchedulerTest]]]:
- """Convert flat ScheduleEntry list to nested format for frontend.
-
- Returns: {date_string: {shift_index: [SchedulerTest, ...]}, ...}
- """
- schedule: dict[str, dict[int, list[SchedulerTest]]] = {}
-
- for entry in entries:
- if entry.scheduled_date not in schedule:
- schedule[entry.scheduled_date] = {1: [], 2: [], 3: []}
-
- test = _TESTS_BY_ID.get((entry.test_id, entry.device))
- if test:
- schedule[entry.scheduled_date][entry.shift_index].append(test)
-
- return schedule
-
-
-def _derive_priority(
- test: SchedulerTest,
- top_priority_tests: set[str],
- lowest_priority_tests: set[str],
- all_tests: list[SchedulerTest],
-) -> int:
- if test.test_id in top_priority_tests:
- return 1
- if test.test_id in lowest_priority_tests:
- return 5
- return test.priority
-
-
-def _fit_bundles_to_shifts(
- bundles: list[TestBundle],
- shift_sequence: list[tuple[date, int]],
- tests: dict[TestKey, SchedulerTest],
- graph_by_test_id: dict[str, set[str]],
- shift_capacities: dict[tuple[date, int], int],
- forced_device: str | None = None,
-) -> tuple[list[ScheduleEntry], set[TestKey], str | None, str | None]:
- """Fit bundles into a shift sequence window (e.g., one day or one weekend).
-
- Returns:
- - List of ScheduleEntry for placed tests
- - Set of test_ids that were placed
- - Last scheduled date
- - Device used for this window, if any bundle was placed
-
- Bundles are placed in sequence order and may span multiple shifts inside the same window.
- """
- entries: list[ScheduleEntry] = []
- placed_test_ids: set[TestKey] = set()
- last_date: str | None = None
- window_device: str | None = None
-
- # Track state per shift
- shift_state: dict[tuple[date, int], dict] = {}
- for date_shift in shift_sequence:
- shift_state[date_shift] = {
- 'remaining_minutes': shift_capacities.get(date_shift, 0),
- 'sequence_counter': 1,
- }
-
- shift_positions = {date_shift: idx for idx, date_shift in enumerate(shift_sequence)}
- current_shift_pos = 0
- window_placed_test_ids: set[str] = set()
- window_placed_devices: set[str] = set()
-
- for bundle in bundles:
- # Skip bundles that are already fully placed.
- if any(test_id in placed_test_ids for test_id in bundle.test_ids):
- continue
-
- if not bundle.test_ids:
- continue
-
- bundle_devices = {test_key[1] for test_key in bundle.test_ids}
- if len(bundle_devices) > 1:
- # A bundle cannot span devices because each window is single-device only.
- continue
- if forced_device is not None and forced_device not in bundle_devices:
- continue
-
- # Keep all tests in the same window pairwise-compatible.
- if window_placed_test_ids:
- is_compatible_with_window = True
- bundle_test_ids_only = {test_key[0] for test_key in bundle.test_ids}
- for placed_test_id in window_placed_test_ids:
- placed_neighbors = graph_by_test_id.get(placed_test_id, set())
- if not all(current_test_id in placed_neighbors for current_test_id in bundle_test_ids_only):
- is_compatible_with_window = False
- break
- if not is_compatible_with_window:
- continue
-
- if window_placed_devices and not bundle_devices.issubset(window_placed_devices):
- continue
-
- # Ensure the whole bundle can still fit somewhere in the remaining window.
- remaining_window = sum(
- shift_state[date_shift]['remaining_minutes'] for date_shift in shift_sequence[current_shift_pos:]
- )
- if remaining_window < bundle.total_minutes:
- continue
-
- bundle_start_shift_pos = current_shift_pos
- placed_bundle_tests: list[tuple[TestKey, tuple[date, int]]] = []
- failed = False
-
- for key in bundle.test_ids:
- test = tests[key]
- prev_remaining = 0
- while current_shift_pos < len(shift_sequence):
- date_shift = shift_sequence[current_shift_pos]
- state = shift_state[date_shift]
- state["remaining_minutes"] += prev_remaining # Add back any leftover from previous shift
- if state['remaining_minutes'] >= test.estimated_minutes:
- #print(f"remaining minutes for {date_shift}: {state['remaining_minutes']} - placing {key} ({test.estimated_minutes}m)")
- entries.append(
- ScheduleEntry(
- test_id=test.test_id,
- device=test.device,
- scheduled_date=date_shift[0].isoformat(),
- shift_index=date_shift[1],
- sequence_in_shift=state['sequence_counter'],
- )
- )
- state['sequence_counter'] += 1
- state['remaining_minutes'] -= test.estimated_minutes
- placed_test_ids.add(key)
- placed_bundle_tests.append((key, date_shift))
- last_date = date_shift[0].isoformat()
- break
-
- # Move to the next shift in the sequence and keep the bundle contiguous.
- current_shift_pos += 1
- prev_remaining = state['remaining_minutes']
- if current_shift_pos >= len(shift_sequence):
- failed = True
- break
-
- if failed:
- break
-
- if failed:
- # Roll back any partially placed tests from this bundle.
- for key, date_shift in reversed(placed_bundle_tests):
- state = shift_state[date_shift]
- state['remaining_minutes'] += tests[key].estimated_minutes
- state['sequence_counter'] -= 1
- entries.pop()
- placed_test_ids.discard(key)
- last_date = None
- current_shift_pos = bundle_start_shift_pos
- continue
-
- if placed_bundle_tests:
- window_placed_test_ids.update(test_key[0] for test_key in bundle.test_ids)
- window_placed_devices.update(bundle_devices)
- if window_device is None:
- window_device = next(iter(bundle_devices))
-
- return entries, placed_test_ids, last_date, window_device
-
-def _create_bundles(
- dut_active_test_ids: set[TestKey],
- ref_active_test_ids: set[TestKey],
- tests: dict[TestKey, SchedulerTest],
- top_priority_tests: set[str] | None = None,
-) -> list[TestBundle]:
- """Create bundles of tests that must run together, sorted by priority tier and efficiency.
-
- Bundling rules:
- - P2P with COE pairs: [P2P, COE1, COE2, ...]
- - P2P without COE: [P2P_RX, P2P_TX] if both active
- - COE without a linked active P2P: [COE] (standalone bundle)
- - P3P: individual test (not bundled)
-
- Returned list is sorted by (priority_tier, total_minutes) to place small/high-priority bundles first.
- """
- if top_priority_tests is None:
- top_priority_tests = set()
-
- bundles: list[TestBundle] = []
- processed: set[TestKey] = set()
-
- dut_by_id: dict[str, TestKey] = {test_id: key for test_id, _device in dut_active_test_ids for key in [(test_id, DUT)] if key in dut_active_test_ids}
- ref_by_id: dict[str, TestKey] = {test_id: key for test_id, _device in ref_active_test_ids for key in [(test_id, REF)] if key in ref_active_test_ids}
- all_ids = sorted(set(dut_by_id.keys()) | set(ref_by_id.keys()))
-
- def _active_key(test_id: str, device: str) -> TestKey | None:
- key = (test_id, device)
- if key in dut_active_test_ids or key in ref_active_test_ids:
- return key
- return None
-
- for test_id in all_ids:
- seed_keys = [key for key in (_active_key(test_id, DUT), _active_key(test_id, REF)) if key is not None and key not in processed]
- if not seed_keys:
- continue
-
- representative_key = seed_keys[0]
- test = tests[representative_key]
- if test.test_type == "P2P":
- if test.has_coe_pair:
- for device in (DUT, REF):
- bundle_test_ids: list[TestKey] = []
- base_key = _active_key(test_id, device)
- if base_key is not None and base_key not in processed:
- bundle_test_ids.append(base_key)
- for coe_id in sorted(test.coe_pairing):
- coe_key = _active_key(coe_id, device)
- if coe_key is not None and coe_key not in processed:
- bundle_test_ids.append(coe_key)
-
- if not bundle_test_ids:
- continue
- processed.update(bundle_test_ids)
- # Check if any test in bundle is top priority
- bundle_test_ids_only = {key[0] for key in bundle_test_ids}
- if bundle_test_ids_only & top_priority_tests:
- priority_tier = BUNDLE_PRIORITY_FAILED # Highest priority (0)
- else:
- priority_tier = BUNDLE_PRIORITY_P2P_WITH_COE
- bundles.append(TestBundle(
- test_ids=bundle_test_ids,
- priority_tier=priority_tier,
- total_minutes=sum(tests[key].estimated_minutes for key in bundle_test_ids)
- ))
- else:
- for device in (DUT, REF):
- bundle_test_ids: list[TestKey] = []
- base_key = _active_key(test_id, device)
- if base_key is None or base_key in processed:
- continue
- bundle_test_ids.append(base_key)
- pair_id = _rx_tx_pair_id(test_id, set(k[0] for k in (dut_active_test_ids if device == DUT else ref_active_test_ids)))
- if pair_id:
- pair_key = _active_key(pair_id, device)
- if pair_key is not None and pair_key not in processed and pair_key not in bundle_test_ids:
- bundle_test_ids.append(pair_key)
-
- processed.update(bundle_test_ids)
- # Check if any test in bundle is top priority
- bundle_test_ids_only = {key[0] for key in bundle_test_ids}
- if bundle_test_ids_only & top_priority_tests:
- priority_tier = BUNDLE_PRIORITY_FAILED # Highest priority (0)
- else:
- priority_tier = BUNDLE_PRIORITY_P2P_ONLY
- bundles.append(TestBundle(
- test_ids=bundle_test_ids,
- priority_tier=priority_tier,
- total_minutes=sum(tests[key].estimated_minutes for key in bundle_test_ids)
- ))
- elif test.test_type == "P3P":
- for device in (DUT, REF):
- key = _active_key(test_id, device)
- if key is None or key in processed:
- continue
- bundle_test_ids = [key]
- # Check if any test in bundle is top priority
- if key[0] in top_priority_tests:
- priority_tier = BUNDLE_PRIORITY_FAILED # Highest priority (0)
- else:
- priority_tier = BUNDLE_PRIORITY_P3P
- bundles.append(TestBundle(
- test_ids=bundle_test_ids,
- priority_tier=priority_tier,
- total_minutes=sum(tests[item].estimated_minutes for item in bundle_test_ids)
- ))
- processed.update(bundle_test_ids)
-
- # Second pass to catch any active tests left uncovered by first-pass grouping.
- all_active_keys = sorted(dut_active_test_ids | ref_active_test_ids)
- for key in all_active_keys:
- if key in processed:
- continue
- test = tests[key]
- bundle_test_ids = [key]
- # Check if this test is top priority
- if key[0] in top_priority_tests:
- priority_tier = BUNDLE_PRIORITY_FAILED # Highest priority (0)
- else:
- priority_tier = BUNDLE_PRIORITY_COE_ONLY
- bundles.append(TestBundle(
- test_ids=bundle_test_ids,
- priority_tier=priority_tier,
- total_minutes=sum(tests[item].estimated_minutes for item in bundle_test_ids)
- ))
- processed.update(bundle_test_ids)
-
- # Sort by priority tier (lower first), then by total minutes (smaller first for efficiency)
- bundles.sort(key=lambda b: (b.priority_tier, b.total_minutes))
- return bundles
-
-
-def _rx_tx_pair_id(test_id: str, active: set[str]) -> str | None:
- if "RX" in test_id:
- candidate = test_id.replace("RX", "TX", 1)
- return candidate if candidate in active else None
- if "TX" in test_id:
- candidate = test_id.replace("TX", "RX", 1)
- return candidate if candidate in active else None
- return None
-
-
-def _shift_capacity_for_date(
- current_date: date,
- holiday_dates: set[str],
- daytime_shift2_only: bool,
-) -> dict[int, int]:
- iso = current_date.isoformat()
- if iso in holiday_dates:
- return {1: 480, 2: 480, 3: 480}
-
- is_weekend = current_date.weekday() >= 5
- if is_weekend:
- return {1: 480, 2: 480, 3: 480}
-
- if daytime_shift2_only:
- return {1: 0, 2: 480, 3: 0}
-
- return {1: 600, 2: 0, 3: 420}
-
-
-def _parse_date(value: str | None) -> date:
- if not value:
- return date.today()
- return datetime.strptime(value, "%Y-%m-%d").date()
-
-
-def _is_off_day(day: date, holiday_dates: set[str]) -> bool:
- """Return True for weekend days and configured holidays."""
- return day.weekday() >= 5 or day.isoformat() in holiday_dates
-
-
-def _get_shift_sequence(
- start_date: date,
- holiday_dates: set[str],
- daytime_shift2_only: bool = False,
-) -> list[tuple[date, int]]:
- """Generate the sequence of (date, shift_index) tuples for a scheduling window.
-
- Rules per design:
- - Daytime testing first window (weekday only): [2] (shift 2 today only)
- - Monday-Thursday: [3, 1] (shift 3 today, shift 1 next day) = 16 hours
- - Friday: [3, 1, 2, 3, 1, 2, 3, 1] (Fri-Mon) = 24 hours continuous
- - Saturday/Sunday/Holiday weekday: all 3 shifts
-
- Weekday default starts on shift 3 unless daytime testing shift-2-only is enabled.
- Returns ordered list of (date, shift_index) pairs.
- """
- iso = start_date.isoformat()
- is_holiday = iso in holiday_dates
- weekday = start_date.weekday() # 0=Mon, 4=Fri, 5=Sat, 6=Sun
-
- shifts: list[tuple[date, int]] = []
-
- if daytime_shift2_only and weekday < 5 and not is_holiday:
- shifts.append((start_date, 2))
- return shifts
-
- # Any off-day (weekend/holiday) uses full daytime+night shifts for that date.
- if _is_off_day(start_date, holiday_dates):
- shifts.append((start_date, 1))
- shifts.append((start_date, 2))
- shifts.append((start_date, 3))
- return shifts
-
- # Working-day windows always start at shift 3.
- shifts.append((start_date, 3))
- next_day = start_date + timedelta(days=1)
-
- # If tomorrow starts an off-day chain (holiday/weekend), extend the window
- # through all off-days and end at shift 1 of the next working day.
- if _is_off_day(next_day, holiday_dates):
- cursor = next_day
- while _is_off_day(cursor, holiday_dates):
- shifts.append((cursor, 1))
- shifts.append((cursor, 2))
- shifts.append((cursor, 3))
- cursor += timedelta(days=1)
- shifts.append((cursor, 1))
- return shifts
-
- # Default working-day pair: tonight shift 3 + next day shift 1.
- shifts.append((next_day, 1))
-
- return shifts
+ test_id: str
+ device: str
+ scheduled_date: str
+ shift_index: int
+ sequence_in_shift: int
+
+
+@dataclass
+class ScheduleWindow:
+ index: int
+ shifts: list[tuple[date, int]]
+ capacity_minutes: int
+ remaining_minutes: int
+ assigned_device: str | None = None
+ assigned_config: tuple[str, ...] | None = None
+
+
+class Scheduler:
+ def __init__(
+ self,
+ tests: list[Test],
+ top_priority_tests: set[tuple[str, str]],
+ start_date: str | None = None,
+ holiday_dates: set[str] = set(),
+ daytime_testing_today: bool = False,
+ priority_weight: int = 100,
+ ):
+ self.top_priority_tests = top_priority_tests
+ self.tests = tests
+ self.active_dut: dict[str, Test] = {test.test_id: test for test in tests if test.device == DUT}
+ self.active_ref: dict[str, Test] = {test.test_id: test for test in tests if test.device == REF}
+ self.start_date = date.fromisoformat(start_date) if start_date else date.today()
+ self.holiday_dates = holiday_dates
+ self.daytime_testing_today = daytime_testing_today
+ self.priority_weight = priority_weight
+ self.pending_dut_mirror: list[TestBundle] = []
+ self.pending_ref_mirror: list[TestBundle] = []
+ self.schedule: list[ScheduleEntry] = []
+ self.scheduled_bundle_keys: set[tuple[int, str]] = set()
+ self.scheduled_test_ids: set[str] = set()
+
+ def compile_schedule(self) -> str | None:
+ bundles = build_test_bundles(self.active_dut, self.active_ref, self.top_priority_tests)
+ if not bundles:
+ return f"Error: No test bundles could be created from the provided tests."
+
+ active_dut_bundles = [b for b in bundles if b.device == DUT]
+ active_ref_bundles = [b for b in bundles if b.device == REF]
+
+ # Group by individual TC values (from config tuples)
+ all_tcs = set()
+ for bundle in bundles:
+ all_tcs.update(bundle.config if bundle.config else [None])
+
+ # Create dict: TC -> bundles supporting that TC
+ bundles_by_tc: dict = {}
+ # Sort with None last
+ sorted_tcs = sorted([tc for tc in all_tcs if tc is not None]) + ([None] if None in all_tcs else [])
+ for tc in sorted_tcs:
+ if tc is None:
+ bundles_by_tc[tc] = [b for b in bundles if not b.config]
+ else:
+ bundles_by_tc[tc] = [b for b in bundles if tc in b.config]
+ # Sort by priority, then index
+ bundles_by_tc[tc].sort(key=lambda b: (b.priority, b.index))
+
+ window_index = 0
+ cursor_date = self.start_date
+
+ # Sort TC keys by bundle count (largest first); tie-break by TC name.
+ sorted_tc_items = sorted(
+ [(tc, bundles_by_tc[tc]) for tc in bundles_by_tc if tc is not None],
+ key=lambda x: (-len(x[1]), x[0]),
+ )
+ if None in bundles_by_tc:
+ sorted_tc_items.append((None, bundles_by_tc[None]))
+
+
+ for tc, tc_bundles in sorted_tc_items:
+ window_device = DUT
+ dut_unscheduled = [b for b in tc_bundles if b.device == DUT]
+ ref_unscheduled = [b for b in tc_bundles if b.device == REF]
+
+ while dut_unscheduled or ref_unscheduled:
+ active_unscheduled = dut_unscheduled if window_device == DUT else ref_unscheduled
+ if len(active_unscheduled) == 0:
+ # If no unscheduled bundles for the current device, switch to the other device
+ window_device = REF if window_device == DUT else DUT
+ active_unscheduled = dut_unscheduled if window_device == DUT else ref_unscheduled
+
+ shifts, capacity = get_shift_sequence_with_capacity(cursor_date, self.holiday_dates, self.daytime_testing_today)
+
+ # Evaluate both devices without mutating queue state, then commit once.
+ mirrored_bundles, knapsack_bundles, remaining_time = self._select_bundles(
+ window_device,
+ capacity,
+ active_unscheduled,
+ tc,
+ mutate=True,
+ )
+ selected_bundles = mirrored_bundles + knapsack_bundles
+ if len(selected_bundles) == 0:
+ window_device = REF if window_device == DUT else DUT
+ mirrored_bundles, knapsack_bundles, remaining_time = self._select_bundles(
+ window_device,
+ capacity,
+ active_unscheduled,
+ tc,
+ mutate=True,
+ )
+ selected_bundles = mirrored_bundles + knapsack_bundles
+ if len(selected_bundles) == 0:
+ break
+
+ # Create a mirror of the other device for the next window
+ bundle_pairs = bundle_pair_lookup(knapsack_bundles, dut_unscheduled, ref_unscheduled)
+
+ if window_device == DUT:
+ pending_target = self.pending_ref_mirror
+ else:
+ pending_target = self.pending_dut_mirror
+
+ existing_pending = {(b.index, b.device) for b in pending_target}
+ for pair_bundle in bundle_pairs:
+ pair_key = (pair_bundle.index, pair_bundle.device)
+ if pair_key in self.scheduled_bundle_keys or pair_key in existing_pending:
+ continue
+ pending_target.append(pair_bundle)
+ existing_pending.add(pair_key)
+
+
+ window = ScheduleWindow(
+ index=window_index,
+ shifts=shifts,
+ capacity_minutes=capacity,
+ remaining_minutes=remaining_time,
+ assigned_config=tc,
+ assigned_device=window_device,
+ )
+
+ self._place_bundles_in_window(window, selected_bundles)
+
+ # Mark bundles as scheduled and remove from ALL TC buckets
+ selected_keys = {(b.index, b.device) for b in selected_bundles}
+
+ # Remove scheduled bundles from ALL TC buckets globally
+ for all_tc in bundles_by_tc:
+ bundles_by_tc[all_tc] = [b for b in bundles_by_tc[all_tc] if (b.index, b.device) not in selected_keys]
+
+ # Rebuild current TC unscheduled lists
+ dut_unscheduled = [b for b in bundles_by_tc[tc] if b.device == DUT]
+ ref_unscheduled = [b for b in bundles_by_tc[tc] if b.device == REF]
+
+ window_index += 1
+ window_device = REF if window_device == DUT else DUT
+ cursor_date = next_window_start_date(shifts)
+
+ return None
+
+ def _select_bundles(
+ self,
+ device: str,
+ capacity: int,
+ unscheduled: list[TestBundle],
+ tc,
+ mutate: bool,
+ ) -> tuple[list[TestBundle], list[TestBundle], int]:
+ # Consume pending mirrored bundles only while there is room.
+ pending = self.pending_dut_mirror if device == DUT else self.pending_ref_mirror
+ mirrored_bundles: list[TestBundle] = []
+ still_pending: list[TestBundle] = [] # Bundles that couldn't fit in the remaining capacity
+ remaining_capacity = capacity
+
+ for bundle in pending:
+ bundle_key = (bundle.index, bundle.device)
+ if bundle_key in self.scheduled_bundle_keys:
+ continue
+ if bundle.device != device or (tc is not None and tc not in bundle.config) or (tc is None and bundle.config):
+ still_pending.append(bundle)
+ continue
+ if bundle.total_minutes <= remaining_capacity:
+ mirrored_bundles.append(bundle)
+ remaining_capacity -= bundle.total_minutes
+ else:
+ still_pending.append(bundle)
+
+ if mutate:
+ pending[:] = still_pending
+
+ # Run knapsack selection for remaining capacity
+ candidates = [
+ b
+ for b in unscheduled
+ if b not in mirrored_bundles
+ and b.device == device
+ and (b.index, b.device) not in self.scheduled_bundle_keys
+ ]
+ knapsack_bundles = self._knapsack_select(remaining_capacity, candidates)
+ remaining_capacity -= sum(bundle.total_minutes for bundle in knapsack_bundles)
+
+ print(f"Selected {len(mirrored_bundles)} mirrored bundles and {len(knapsack_bundles)} knapsack bundles for device {device} with remaining capacity {remaining_capacity} minutes.")
+ return mirrored_bundles, knapsack_bundles, remaining_capacity
+
+ def get_schedule(self) -> list[ScheduleEntry]:
+ return self.schedule
+
+ def _knapsack_select(
+ self,
+ capacity: int,
+ candidates: list[TestBundle],
+ ) -> list[TestBundle]:
+ if capacity <= 0 or not candidates:
+ return []
+
+ # Weights are total minutes of each bundle
+ weights = [bundle.total_minutes for bundle in candidates]
+ # Values are based on priority, lower priority number means higher value
+ values = [self.priority_weight - bundle.priority for bundle in candidates]
+
+ # Implement dynamic programming knapsack algorithm to select bundles
+ n = len(candidates)
+ dp = [[0] * (capacity + 1) for _ in range(n + 1)]
+ for i in range(1, n + 1):
+ for w in range(capacity + 1):
+ if weights[i - 1] <= w:
+ dp[i][w] = max(dp[i - 1][w], dp[i - 1][w - weights[i - 1]] + values[i - 1])
+ else:
+ dp[i][w] = dp[i - 1][w]
+
+ # Traceback to find selected bundles
+ w = capacity
+ selected_indices = []
+ for i in range(n, 0, -1):
+ if w <= 0:
+ break
+ if dp[i][w] != dp[i - 1][w]:
+ selected_indices.append(i - 1)
+ w -= weights[i - 1]
+
+ # Return the selected bundles in the order they were added
+ return [candidates[i] for i in reversed(selected_indices)]
+
+ def _place_bundles_in_window(
+ self,
+ window: ScheduleWindow,
+ bundles: list[TestBundle],
+ ) -> None:
+ for bundle in bundles:
+ bundle_key = (bundle.index, bundle.device)
+ if bundle_key in self.scheduled_bundle_keys:
+ continue
+ self.scheduled_bundle_keys.add(bundle_key)
+
+ for idx, test in enumerate(bundle.tests):
+ schedule_key = f"{test}:{bundle.device}"
+ if schedule_key in self.scheduled_test_ids:
+ continue
+ self.scheduled_test_ids.add(schedule_key)
+
+ self.schedule.append(
+ ScheduleEntry(
+ test_id=test,
+ device=bundle.device,
+ scheduled_date=str(window.shifts[0][0]),
+ shift_index=window.shifts[0][1],
+ sequence_in_shift=idx + 1,
+ )
+ )
+ window.remaining_minutes -= self._get_test_minutes(test, bundle.device)
+
+
+ def _get_test_minutes(self, test_id: str, device: str) -> int:
+ if device == DUT and test_id in self.active_dut:
+ return self.active_dut[test_id].estimated_minutes
+ if device == REF and test_id in self.active_ref:
+ return self.active_ref[test_id].estimated_minutes
+ return 0
+
diff --git a/backend/test_bundle.py b/backend/test_bundle.py
new file mode 100644
index 0000000..1b887f7
--- /dev/null
+++ b/backend/test_bundle.py
@@ -0,0 +1,270 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+import os
+from test_config import build_bundle_test_configs
+
+
+@dataclass(frozen=True)
+class Test:
+ test_id: str
+ device: str
+ test_type: str
+ rotation: str | None
+ rx_tx: str | None
+ has_coe_pair: bool
+ coe_pairing: list[str]
+ config: dict[str, dict[str, str | None]]
+ throttled: bool
+ estimated_minutes: int
+
+# Bundle priority tiers for scheduling order (lower number = higher priority)
+BUNDLE_PRIORITY_TOP = 0 # Failed tests requiring rerun
+BUNDLE_PRIORITY_P2P_WITH_COE = 1 # P2P tests with COE pairs
+BUNDLE_PRIORITY_P2P_ONLY = 2 # P2P tests without COE pairs (RX/TX bundled)
+BUNDLE_PRIORITY_COE_ONLY = 3 # COE tests without P2P pairing (should be rare/unschedulable)
+BUNDLE_PRIORITY_P3P = 4 # P3P tests
+
+DUT = (os.getenv("DUT") or "DUT").strip()
+REF = (os.getenv("REF") or "REF").strip()
+
+@dataclass(frozen=True)
+class TestBundle:
+ index: int
+ tests: list[str]
+ total_minutes: int
+ device: str
+ config: tuple[str, ...]
+ priority: int
+
+def build_test_bundles(active_dut: dict[str, Test], active_ref: dict[str, Test], top_priority_tests: set[tuple[str, str]]) -> list[TestBundle]:
+ """Build deterministic bundles for DP scheduling."""
+
+ processed_dut: set[str] = set()
+ processed_ref: set[str] = set()
+ test_bundles: list[TestBundle] = []
+ bundle_index = 0
+
+ def dedupe_preserve_order(test_ids: list[str]) -> list[str]:
+ seen: set[str] = set()
+ deduped: list[str] = []
+ for test_id in test_ids:
+ if test_id in seen:
+ continue
+ seen.add(test_id)
+ deduped.append(test_id)
+ return deduped
+
+ dut_top_priority: list[str] = []
+ ref_top_priority: list[str] = []
+
+ for test_id, device in top_priority_tests:
+ if device == DUT and test_id in active_dut:
+ dut_top_priority.append(test_id)
+ elif device == REF and test_id in active_ref:
+ ref_top_priority.append(test_id)
+
+ if dut_top_priority:
+ test_bundles.append(create_bundle(dut_top_priority, DUT, BUNDLE_PRIORITY_TOP, bundle_index, active_dut, active_ref))
+ processed_dut.update(dut_top_priority)
+ bundle_index += 1
+
+ if ref_top_priority:
+ test_bundles.append(create_bundle(ref_top_priority, REF, BUNDLE_PRIORITY_TOP, bundle_index, active_dut, active_ref))
+ processed_ref.update(ref_top_priority)
+ bundle_index += 1
+
+ # Phase 1: DUT P2P bundles
+ for dut_test_id, dut_test in active_dut.items():
+ if dut_test_id in processed_dut or dut_test.test_type != "P2P":
+ continue
+
+ dut_bundled_tests = [dut_test_id]
+ processed_dut.add(dut_test_id)
+
+ active_coe_pairings = [
+ test_id
+ for test_id in dut_test.coe_pairing
+ if test_id in active_dut and test_id not in processed_dut
+ ]
+
+ if active_coe_pairings:
+ dut_bundled_tests.extend(active_coe_pairings)
+ processed_dut.update(active_coe_pairings)
+ priority = BUNDLE_PRIORITY_P2P_WITH_COE
+ else:
+ rx_tx_pair_id = get_rx_tx_pair_id(dut_test, DUT, active_dut, active_ref)
+ if rx_tx_pair_id and rx_tx_pair_id not in processed_dut:
+ dut_bundled_tests.append(rx_tx_pair_id)
+ processed_dut.add(rx_tx_pair_id)
+ priority = BUNDLE_PRIORITY_P2P_ONLY
+
+ dut_bundled_tests = dedupe_preserve_order(dut_bundled_tests)
+ test_bundles.append(
+ create_bundle(dut_bundled_tests, DUT, priority, bundle_index, active_dut, active_ref)
+ )
+
+ ref_bundled_tests = [
+ test_id
+ for test_id in dut_bundled_tests
+ if test_id in active_ref and test_id not in processed_ref
+ ]
+ if ref_bundled_tests:
+ ref_bundled_tests = dedupe_preserve_order(ref_bundled_tests)
+ processed_ref.update(ref_bundled_tests)
+ test_bundles.append(
+ create_bundle(ref_bundled_tests, REF, priority, bundle_index, active_dut, active_ref)
+ )
+
+ bundle_index += 1
+
+ # Phase 2: DUT P3P bundles
+ for dut_test_id, dut_test in active_dut.items():
+ if dut_test_id in processed_dut or dut_test.test_type != "P3P":
+ continue
+
+ dut_bundled_tests = [dut_test_id]
+ processed_dut.add(dut_test_id)
+
+ th_ut_pair_id = get_th_ut_pair_id(dut_test, DUT, active_dut, active_ref)
+ if th_ut_pair_id and th_ut_pair_id not in processed_dut:
+ dut_bundled_tests.append(th_ut_pair_id)
+ processed_dut.add(th_ut_pair_id)
+
+ dut_bundled_tests = dedupe_preserve_order(dut_bundled_tests)
+ test_bundles.append(
+ create_bundle(dut_bundled_tests, DUT, BUNDLE_PRIORITY_P3P, bundle_index, active_dut, active_ref)
+ )
+
+ ref_bundled_tests = [
+ test_id
+ for test_id in dut_bundled_tests
+ if test_id in active_ref and test_id not in processed_ref
+ ]
+ if ref_bundled_tests:
+ ref_bundled_tests = dedupe_preserve_order(ref_bundled_tests)
+ processed_ref.update(ref_bundled_tests)
+ test_bundles.append(
+ create_bundle(ref_bundled_tests, REF, BUNDLE_PRIORITY_P3P, bundle_index, active_dut, active_ref)
+ )
+
+ bundle_index += 1
+
+ # Phase 3: DUT leftovers (including COE-only)
+ for dut_test_id in active_dut:
+ if dut_test_id in processed_dut:
+ continue
+ test_bundles.append(
+ create_bundle([dut_test_id], DUT, BUNDLE_PRIORITY_COE_ONLY, bundle_index, active_dut, active_ref)
+ )
+ bundle_index += 1
+ processed_dut.add(dut_test_id)
+
+ # Phase 4: unmatched REF P2P bundles
+ for ref_test_id, ref_test in active_ref.items():
+ if ref_test_id in processed_ref or ref_test.test_type != "P2P":
+ continue
+
+ ref_bundled_tests = [ref_test_id]
+ processed_ref.add(ref_test_id)
+
+ active_coe_pairings = [
+ test_id
+ for test_id in ref_test.coe_pairing
+ if test_id in active_ref and test_id not in processed_ref
+ ]
+
+ if active_coe_pairings:
+ ref_bundled_tests.extend(active_coe_pairings)
+ processed_ref.update(active_coe_pairings)
+ priority = BUNDLE_PRIORITY_P2P_WITH_COE
+ else:
+ rx_tx_pair_id = get_rx_tx_pair_id(ref_test, REF, active_dut, active_ref)
+ if rx_tx_pair_id and rx_tx_pair_id not in processed_ref:
+ ref_bundled_tests.append(rx_tx_pair_id)
+ processed_ref.add(rx_tx_pair_id)
+ priority = BUNDLE_PRIORITY_P2P_ONLY
+
+ ref_bundled_tests = dedupe_preserve_order(ref_bundled_tests)
+ test_bundles.append(
+ create_bundle(ref_bundled_tests, REF, priority, bundle_index, active_dut, active_ref)
+ )
+ bundle_index += 1
+
+ # Phase 5: unmatched REF P3P bundles
+ for ref_test_id, ref_test in active_ref.items():
+ if ref_test_id in processed_ref or ref_test.test_type != "P3P":
+ continue
+
+ ref_bundled_tests = [ref_test_id]
+ processed_ref.add(ref_test_id)
+
+ th_ut_pair_id = get_th_ut_pair_id(ref_test, REF, active_dut, active_ref)
+ if th_ut_pair_id and th_ut_pair_id not in processed_ref:
+ ref_bundled_tests.append(th_ut_pair_id)
+ processed_ref.add(th_ut_pair_id)
+
+ ref_bundled_tests = dedupe_preserve_order(ref_bundled_tests)
+ test_bundles.append(
+ create_bundle(ref_bundled_tests, REF, BUNDLE_PRIORITY_P3P, bundle_index, active_dut, active_ref)
+ )
+ bundle_index += 1
+
+ # Phase 6: REF leftovers (including COE-only)
+ for ref_test_id in active_ref:
+ if ref_test_id in processed_ref:
+ continue
+ test_bundles.append(
+ create_bundle([ref_test_id], REF, BUNDLE_PRIORITY_COE_ONLY, bundle_index, active_dut, active_ref)
+ )
+ bundle_index += 1
+ processed_ref.add(ref_test_id)
+
+ test_bundles.sort(key=lambda b: (b.priority, b.index, b.device, b.config))
+ return test_bundles
+
+def get_rx_tx_pair_id(test: Test, device: str, active_dut: dict[str, Test], active_ref: dict[str, Test]) -> str | None:
+ active_tests = active_dut if device == DUT else active_ref
+ test_id = test.test_id
+ pair_id = test_id.replace("RX", "TX", 1) if "RX" in test_id else test_id.replace("TX", "RX", 1)
+ return pair_id if pair_id in active_tests else None
+
+def get_th_ut_pair_id(test: Test, device: str, active_dut: dict[str, Test], active_ref: dict[str, Test]) -> str | None:
+ active_tests = active_dut if device == DUT else active_ref
+ test_id = test.test_id
+ pair_id = test_id.replace("TH", "UT", 1) if "TH" in test_id else test_id.replace("UT", "TH", 1)
+ return pair_id if pair_id in active_tests else None
+
+def create_bundle(tests: list[str], device: str, priority: int, index: int, active_dut: dict[str, Test], active_ref: dict[str, Test]) -> TestBundle:
+ if not tests:
+ raise ValueError("Cannot create bundle with no tests")
+
+ if device == DUT:
+ active_tests = active_dut
+ else:
+ active_tests = active_ref
+
+ total_minutes = sum(active_tests[test_id].estimated_minutes for test_id in tests)
+
+ bundle_tests = [active_tests[test_id] for test_id in tests]
+ config = tuple(build_bundle_test_configs(bundle_tests))
+
+ return TestBundle(
+ index=index,
+ tests=tests,
+ total_minutes=total_minutes,
+ device=device,
+ config=config,
+ priority=priority,
+ )
+
+def bundle_pair_lookup(bundles: list[TestBundle], dut_bundles: list[TestBundle], ref_bundles: list[TestBundle]) -> list[TestBundle]:
+ device = bundles[0].device if bundles else None
+ active_bundle_ids = {b.index for b in dut_bundles} if device == REF else {b.index for b in ref_bundles}
+
+ bundle_indexes = [b.index for b in bundles if b.index in active_bundle_ids]
+ if device == DUT:
+ return [b for b in ref_bundles if b.index in bundle_indexes]
+ elif device == REF:
+ return [b for b in dut_bundles if b.index in bundle_indexes]
+ return []
\ No newline at end of file
diff --git a/backend/test_config.py b/backend/test_config.py
new file mode 100644
index 0000000..178bb65
--- /dev/null
+++ b/backend/test_config.py
@@ -0,0 +1,159 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+TEST_CONFIG_DEFINITION = {
+ "TC1": {"T1D": "STA5", "T1F": "STA56", "T1I": "STA58", "T1L": "STA64", "T2A":"STA63", "T2O":"STA65", "T2J": "STA59", "T2E": "STA6", "T3E": "STA4"},
+ "TC2": {"T1D": "STA64", "T1F": "STA4", "T1I": "STA5", "T1L": "STA58", "T2A":"STA56", "T2O":"STA59", "T2J": "STA6", "T2E": "STA65", "T3E": "STA63"},
+ "TC3": {"T1D": "STA58", "T1F": "STA63", "T1I": "STA64", "T2J": "STA65", "T2E": "STA59", "T3E": "STA56"},
+ "TC4": {"T1B": "STA56", "T1C": "STA4", "T1D": "STA63"},
+ "TC5": {"T1B": "STA4", "T1C": "STA63", "T1D": "STA56"},
+ "TC6": {"T1B": "STA63", "T1C": "STA56", "T1D": "STA4"},
+ "TC7": {"T1F": "STA4", "T2A": "STA63", "T3E": "STA56"},
+ "TC8": {"T1F": "STA63", "T2A": "STA56", "T3E": "STA4"},
+ "TC9": {"T1F": "STA63", "T2A": "STA64", "T3E": "STA4"},
+ "TC10": {"T1F": "STA4", "T3E": "STA56", "T1K2A": "STA63"},
+ "TC11": {"T1F": "STA56", "T3E": "STA63", "T1K2A": "STA4"},
+ "TC12": {"T1F": "STA56", "T3E": "STA4", "T1K2A": "STA63"},
+}
+
+
+
+def _norm(value: Any) -> str:
+ if value is None:
+ return ""
+ return " ".join(str(value).strip().upper().split())
+
+
+def _get_test_config(test: Any) -> dict[str, dict[str, str | None]]:
+ if isinstance(test, dict):
+ config = test.get("config") or {}
+ else:
+ config = getattr(test, "config", None) or {}
+ return config if isinstance(config, dict) else {}
+
+
+def _testpoint_to_station_sets(config: dict[str, dict[str, str | None]]) -> dict[str, set[str]]:
+ result: dict[str, set[str]] = {}
+
+ def _add_entry(entry: dict[str, str | None]) -> None:
+ testpoint = _norm(entry.get("test_point") or entry.get("Test Point"))
+ sta_raw = _norm(entry.get("sta") or entry.get("STA"))
+ if not testpoint or not sta_raw:
+ return
+
+ stations = {_norm(sta) for sta in sta_raw.split(",") if _norm(sta)}
+ if not stations:
+ return
+
+ result.setdefault(testpoint, set()).update(stations)
+
+ for entry in config.values():
+ if isinstance(entry, dict):
+ _add_entry(entry)
+
+ return result
+
+
+def build_station_testpoint_map(config: dict[str, dict[str, str | None]]) -> dict[str, str]:
+ """Build station->testpoint map used by legacy graph serialization."""
+ station_to_testpoint: dict[str, str] = {}
+ for testpoint, stations in _testpoint_to_station_sets(config).items():
+ for station in stations:
+ station_to_testpoint[station] = testpoint
+ return station_to_testpoint
+
+
+def resolve_test_config_keys(config: dict[str, dict[str, str | None]]) -> list[str]:
+ """Return all TC keys whose definitions contain all testpoint->station mappings in config."""
+ testpoint_stations = _testpoint_to_station_sets(config)
+ if not testpoint_stations:
+ return []
+
+ matches: list[str] = []
+ for tc_key, tc_mapping in TEST_CONFIG_DEFINITION.items():
+ is_match = True
+ for testpoint, stations in testpoint_stations.items():
+ # TC definitions map one testpoint to exactly one station.
+ if len(stations) != 1:
+ is_match = False
+ break
+ expected_station = tc_mapping.get(testpoint)
+ if expected_station is None or expected_station not in stations:
+ is_match = False
+ break
+ if is_match:
+ matches.append(tc_key)
+
+ return matches
+
+
+def build_config_rows(tests: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ by_sta: dict[str, set[str]] = {}
+
+ for test in tests:
+ config = test.get("config") or {}
+ if not isinstance(config, dict):
+ continue
+ for entry in config.values():
+ if not isinstance(entry, dict):
+ continue
+ sta_raw = entry.get("sta") or entry.get("STA")
+ test_point = entry.get("test_point") or entry.get("Test Point")
+ if not sta_raw or not test_point:
+ continue
+
+ for sta_part in str(sta_raw).split(","):
+ sta = sta_part.strip().upper()
+ if not sta:
+ continue
+ by_sta.setdefault(sta, set()).add(str(test_point))
+
+ return [
+ {
+ "sta": sta,
+ "testPoints": sorted(points),
+ }
+ for sta, points in sorted(by_sta.items())
+ ]
+
+
+def build_bundle_test_configs(tests: list[Any]) -> list[str]:
+ merged_by_testpoint: dict[str, set[str]] = {}
+ for test in tests:
+ config = _get_test_config(test)
+ for entry in config.values():
+ if not isinstance(entry, dict):
+ continue
+ testpoint_raw = entry.get("test_point") or entry.get("Test Point")
+ sta_raw = entry.get("sta") or entry.get("STA")
+ if not testpoint_raw or not sta_raw:
+ continue
+
+ testpoint = " ".join(str(testpoint_raw).strip().upper().split())
+ if not testpoint:
+ continue
+
+ stations = {
+ " ".join(str(sta).strip().upper().split())
+ for sta in str(sta_raw).split(",")
+ if " ".join(str(sta).strip().upper().split())
+ }
+ if not stations:
+ continue
+
+ merged_by_testpoint.setdefault(testpoint, set()).update(stations)
+
+ merged_config: dict[str, dict[str, str | None]] = {}
+ for idx, (testpoint, stations) in enumerate(sorted(merged_by_testpoint.items()), start=1):
+ merged_config[f"Station {idx}"] = {
+ "test_point": testpoint,
+ "sta": ",".join(sorted(stations)),
+ }
+
+ return resolve_test_config_keys(merged_config)
+
+
+def serialize_station_testpoint_map(config: dict[str, dict[str, str | None]]) -> str:
+ return json.dumps(build_station_testpoint_map(config))
diff --git a/backend/test_window.py b/backend/test_window.py
new file mode 100644
index 0000000..e21a890
--- /dev/null
+++ b/backend/test_window.py
@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+from datetime import date, timedelta
+
+
+def is_off_day(current_date: date, holiday_dates: set[str]) -> bool:
+ return current_date.weekday() >= 5 or current_date.isoformat() in holiday_dates
+
+def get_shift_sequence(
+ current_date: date,
+ holiday_dates: set[str],
+ daytime_shift2_only: bool = False,
+) -> list[tuple[date, int]]:
+ is_holiday = current_date.isoformat() in holiday_dates
+ weekday = current_date.weekday()
+ shifts: list[tuple[date, int]] = []
+
+ if daytime_shift2_only and weekday < 5 and not is_holiday:
+ return [(current_date, 2)]
+
+ if is_off_day(current_date, holiday_dates):
+ return [(current_date, 1), (current_date, 2), (current_date, 3)]
+
+ shifts.append((current_date, 3))
+ next_day = current_date + timedelta(days=1)
+ if is_off_day(next_day, holiday_dates):
+ cursor = next_day
+ while is_off_day(cursor, holiday_dates):
+ shifts.extend([(cursor, 1), (cursor, 2), (cursor, 3)])
+ cursor += timedelta(days=1)
+ shifts.append((cursor, 1))
+ return shifts
+
+ shifts.append((next_day, 1))
+ return shifts
+
+
+def get_shift_capacity_for_date(
+ current_date: date,
+ holiday_dates: set[str],
+ daytime_shift2_only: bool = False,
+) -> dict[int, int]:
+ if daytime_shift2_only:
+ return {1: 0, 2: 480, 3: 0}
+ if is_off_day(current_date, holiday_dates):
+ return {1: 480, 2: 480, 3: 480}
+ return {1: 480, 2: 0, 3: 480}
+
+
+def get_shift_sequence_with_capacity(
+ current_date: date,
+ holiday_dates: set[str],
+ daytime_shift2_only: bool = False,
+) -> tuple[list[tuple[date, int]], int]:
+ shifts = get_shift_sequence(current_date, holiday_dates, daytime_shift2_only)
+ capacity = sum(
+ get_shift_capacity_for_date(day, holiday_dates, daytime_shift2_only).get(shift_index, 0)
+ for day, shift_index in shifts
+ )
+ return shifts, capacity
+
+
+def next_window_start_date(shifts: list[tuple[date, int]]) -> date:
+ last_date, last_shift = shifts[-1]
+ if last_shift in (1, 2):
+ return last_date
+ return last_date + timedelta(days=1)
diff --git a/frontend/.gitignore b/frontend/.gitignore
index a547bf3..6cc8aa7 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
+
+/data
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 46a3a61..84d8451 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,9 +1,10 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
-import Header from './components/Header'
-import FailedBanner from './components/FailedBanner'
-import Calendar from './components/Calendar'
-import RightPanel from './components/RightPanel'
-import SettingsModal from './components/SettingsModal'
+import Header from './components/Header'
+import FailedBanner from './components/FailedBanner'
+import Calendar from './components/Calendar'
+import RightPanel from './components/RightPanel'
+import SettingsModal from './components/SettingsModal'
+import TestWindowDetailsPanel from './components/TestWindowDetailsPanel'
import { api, groupScheduleItems } from './api'
// ---------------------------------------------------------------------------
@@ -123,19 +124,37 @@ export default function App() {
const [topPriority, setTopPriority] = useState('')
const [lowestPriority, setLowestPriority] = useState('')
const [startDateOverride, setStartDateOverride] = useState('')
- const [failedTests, setFailedTests] = useState([])
- const [scheduleData, setScheduleData] = useState({})
- const [completionDate, setCompletionDate] = useState(null)
- const [weekStart, setWeekStart] = useState(() => getMondayOfWeek(new Date()))
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState(null)
+ const [failedTests, setFailedTests] = useState([])
+ const [scheduleData, setScheduleData] = useState({})
+ const [scheduleWindows, setScheduleWindows] = useState([])
+ const [selectedWindowId, setSelectedWindowId] = useState(null)
+ const [completionDate, setCompletionDate] = useState(null)
+ const [weekStart, setWeekStart] = useState(() => getMondayOfWeek(new Date()))
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
const tonightConfigRows = useMemo(() => toTonightConfigRows(scheduleData), [scheduleData])
+ const windowLookup = useMemo(() => {
+ const lookup = new Map()
+
+ for (const windowDetails of scheduleWindows) {
+ for (const segment of windowDetails.segments ?? []) {
+ lookup.set(`${segment.date}::shift${segment.shift_index}`, windowDetails)
+ }
+ }
+
+ return lookup
+ }, [scheduleWindows])
+ const selectedWindow = useMemo(
+ () => scheduleWindows.find((windowDetails) => windowDetails.window_id === selectedWindowId) ?? null,
+ [scheduleWindows, selectedWindowId],
+ )
// Fetch schedule for the given weekStart (Monday)
const fetchSchedule = useCallback(async (start) => {
try {
const data = await api.getScheduleWeek(toKey(start))
setScheduleData(groupScheduleItems(data.items))
+ setScheduleWindows(data.windows ?? [])
} catch (e) {
console.error('Failed to fetch schedule:', e)
}
@@ -268,6 +287,20 @@ export default function App() {
setFailedTests([])
}
+ useEffect(() => {
+ if (selectedWindowId && !selectedWindow) {
+ setSelectedWindowId(null)
+ }
+ }, [selectedWindow, selectedWindowId])
+
+ function handleWindowSelect(windowDetails) {
+ setSelectedWindowId(windowDetails?.window_id ?? null)
+ }
+
+ function handleCloseWindowDetails() {
+ setSelectedWindowId(null)
+ }
+
// -------------------------------------------------------------------------
return (
@@ -286,13 +319,15 @@ export default function App() {
onDecision={handleRerunDecision}
/>
-
- CGW453 Scheduler
+ NJTH Scheduler
- {label} -
++ {label} +
+ {clickable && ( + + + + )} +Type: {test.test_type ?? '—'}
Rotation: {test.rotation ?? '—'}
@@ -69,7 +113,7 @@ export default function TestCard({ test }) {
Config: {formatConfig(test.config)}
Type
+{test.test_type ?? '—'}
+Rotation
+{test.rotation ?? '—'}
+Priority
+{test.priority ?? '—'}
+Sequence
+{test.sequence_in_shift ?? '—'}
+Config
+ {configEntries.length === 0 ? ( +No config details available.
+ ) : ( +| Band | +Testpoint | +STA | +
|---|---|---|
| {entry.band} | +{entry.testPoint} | +{entry.sta} | +