@@ -2,6 +2,7 @@
|
|||||||
/backend/__pycache__
|
/backend/__pycache__
|
||||||
/backend/_run_sched_test.py
|
/backend/_run_sched_test.py
|
||||||
/backend/output.txt
|
/backend/output.txt
|
||||||
|
/backend/.env
|
||||||
IMPLEMENTATIONPLAN.md
|
IMPLEMENTATIONPLAN.md
|
||||||
scheduler.db
|
scheduler.db
|
||||||
scheduler.db*
|
scheduler.db*
|
||||||
@@ -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
|
- DUT test result directory path, REF test result directory path
|
||||||
- Estimated runtime for each type of test for calculations
|
- Estimated runtime for each type of test for calculations
|
||||||
- Rotation definition
|
- Test config definition
|
||||||
- Test exclusions
|
- Test exclusions
|
||||||
- Top priority tests
|
- Top priority tests
|
||||||
- Allow user to manually override and enter tests that needs to be run tonight
|
- 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
|
## Output
|
||||||
### Backend
|
### 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**
|
- **Rules**
|
||||||
- Can be run in the same night
|
- Test Windows
|
||||||
- Same rotation and same test point P2P/COE tests
|
- Weekday night time test window: Monday - Thursday
|
||||||
- COERXBE002: T1F -> STA63, T2A -> STA56, T3E -> STA4, R3
|
- Starts 5PM of that day, ends 9AM the next day
|
||||||
- COERXBE003: T1F -> STA63, T2A -> STA56, T3E -> STA4, R3
|
- Capcity: 17 hrs (16hrs + 1hr margin)
|
||||||
|
- Ex. Monday 6/29 5PM - Tuesday 6/30 9AM
|
||||||
- Different rotations but all stations are at same testpoints
|
- Weekday day time test window: Monday-Thursday
|
||||||
- R2 P2PRXAC003 5G T1C -> STA4
|
- Starts 9AM - 5PM of that day
|
||||||
- R1 P2PRXAC012 2G T1C -> STA4
|
- Capacity: 8 hrs
|
||||||
|
- Weekend test window: Friday - Sunday
|
||||||
- Same rotation and same test point P3P tests
|
- Starts Friday 5PM, ends Monday 9AM
|
||||||
- Needs to be run back to back
|
- 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)
|
- P2P up link (RX) and down link (TX)
|
||||||
- Ex. P2PRXAX001 and P2PTXAX001
|
- Ex. P2PRXAX001 and P2PTXAX001
|
||||||
- COE P2P bundle
|
- P3P throttled(TH) and unthrottled(UT)
|
||||||
- DUT and REF tests
|
- Same tests, different devices(DUT, REF) needs to be run in back to back test windows
|
||||||
|
- Ex. Monday
|
||||||
- **Priority**
|
- **Priority**
|
||||||
- Default priority: COE P2P pair -> P2P only -> P3P
|
- Default priority: COE P2P pair -> P2P only -> P3P
|
||||||
- 1: highest priority (must run tonight)
|
- 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
|
- 4: P3P
|
||||||
- 5: lowest priority
|
- 5: lowest priority
|
||||||
- Priority 1 and 5 needs to be entered manually
|
- 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**
|
- **Calculation**
|
||||||
- Use the estimate run time for each type of test to fit as many tests as possible in a shift of testing.
|
- 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
|
- 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
|
- Create bundles of tests that needs to be run back to back
|
||||||
- Types of test bundles and their order
|
- Types of test bundles and their order
|
||||||
1. Failed tests rerun if exist
|
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
|
3. P2P tests without COE pairs: P2PRX on DUT -> P2PTX on DUT -> P2PRX on REF -> P2PTX on REF
|
||||||
4. P3P tests (ALL ROT's)
|
4. P3P tests (ALL ROT's)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
DUT="CGW453"
|
|
||||||
REF="CGW452"
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime, timedelta
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
@@ -9,9 +9,15 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
import db
|
import db
|
||||||
import graph
|
|
||||||
from parser import CsvValidationError, parse_target_csv
|
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
|
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()
|
REF = os.getenv("REF", "CGW452").strip()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class LoadTestsRequest(BaseModel):
|
class LoadTestsRequest(BaseModel):
|
||||||
csv_path: str = Field(..., description="Absolute or backend-relative path to target CSV")
|
csv_path: str = Field(..., description="Absolute or backend-relative path to target CSV")
|
||||||
|
|
||||||
@@ -37,7 +44,7 @@ def _smb_credentials_from_settings(settings: dict[str, Any]) -> dict[str, str]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _runtime_overrides_from_settings(settings: dict[str, Any]) -> dict[str, int]:
|
def _runtime_overrides_from_settings(settings: dict[str, Any]) -> dict[str, dict[str, int]]:
|
||||||
def _parse_positive_int(value: Any) -> int | None:
|
def _parse_positive_int(value: Any) -> int | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -51,15 +58,23 @@ def _runtime_overrides_from_settings(settings: dict[str, Any]) -> dict[str, int]
|
|||||||
return None
|
return None
|
||||||
return parsed if parsed > 0 else None
|
return parsed if parsed > 0 else None
|
||||||
|
|
||||||
overrides: dict[str, int] = {}
|
legacy_overrides = {
|
||||||
for test_type, key in (
|
"P2P": _parse_positive_int(settings.get("p2pRuntimeMinutes")),
|
||||||
("P2P", "p2pRuntimeMinutes"),
|
"COE": _parse_positive_int(settings.get("coeRuntimeMinutes")),
|
||||||
("COE", "coeRuntimeMinutes"),
|
"P3P": _parse_positive_int(settings.get("p3pRuntimeMinutes")),
|
||||||
("P3P", "p3pRuntimeMinutes"),
|
}
|
||||||
):
|
|
||||||
minutes = _parse_positive_int(settings.get(key))
|
overrides: dict[str, dict[str, int]] = {}
|
||||||
|
for device_key, field_prefix in ((db.DEVICE_DUT, "dut"), (db.DEVICE_REF, "ref")):
|
||||||
|
device_overrides: dict[str, int] = {}
|
||||||
|
for test_type, suffix in (("P2P", "P2p"), ("COE", "Coe"), ("P3P", "P3p")):
|
||||||
|
minutes = _parse_positive_int(settings.get(f"{field_prefix}{suffix}RuntimeMinutes"))
|
||||||
|
if minutes is None:
|
||||||
|
minutes = legacy_overrides[test_type]
|
||||||
if minutes is not None:
|
if minutes is not None:
|
||||||
overrides[test_type] = minutes
|
device_overrides[test_type] = minutes
|
||||||
|
if device_overrides:
|
||||||
|
overrides[device_key] = device_overrides
|
||||||
|
|
||||||
return overrides
|
return overrides
|
||||||
|
|
||||||
@@ -68,6 +83,8 @@ class CompileScheduleRequest(BaseModel):
|
|||||||
start_date: str | None = Field(default=None, description="YYYY-MM-DD")
|
start_date: str | None = Field(default=None, description="YYYY-MM-DD")
|
||||||
rule: str = ""
|
rule: str = ""
|
||||||
daytime_testing_today: bool = False
|
daytime_testing_today: bool = False
|
||||||
|
dual_device_weekend_start_enabled: bool = False
|
||||||
|
dual_device_weekend_start_dates: list[str] = Field(default_factory=list)
|
||||||
top_priority_tests: list[str] = Field(default_factory=list)
|
top_priority_tests: list[str] = Field(default_factory=list)
|
||||||
lowest_priority_tests: list[str] = Field(default_factory=list)
|
lowest_priority_tests: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
@@ -80,10 +97,179 @@ class SaveHolidaysRequest(BaseModel):
|
|||||||
dates: list[str] = Field(default_factory=list)
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(application: FastAPI):
|
async def lifespan(application: FastAPI):
|
||||||
db.init_db(DB_PATH)
|
db.init_db(DB_PATH)
|
||||||
graph.reset_graph_state()
|
|
||||||
settings = db.read_settings(DB_PATH)
|
settings = db.read_settings(DB_PATH)
|
||||||
configure_result_watcher(settings)
|
configure_result_watcher(settings)
|
||||||
try:
|
try:
|
||||||
@@ -141,21 +327,17 @@ def load_tests(request: LoadTestsRequest) -> dict[str, Any]:
|
|||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
count = db.upsert_tests(parsed.tests, DB_PATH)
|
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 {
|
return {
|
||||||
"loaded_tests": count,
|
"loaded_tests": count,
|
||||||
"warnings": parsed.warnings,
|
"warnings": parsed.warnings,
|
||||||
"dut_graph_nodes": len(graph.get_graph()),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/schedule/active/remove")
|
@app.post("/api/schedule/active/remove")
|
||||||
def remove_active_tests(request: RemoveActiveTestsRequest) -> dict[str, Any]:
|
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)}
|
return {"status": "ok", "removed": len(request.test_ids)}
|
||||||
|
|
||||||
|
|
||||||
@@ -167,7 +349,6 @@ def compile_schedule_endpoint(request: CompileScheduleRequest) -> dict[str, Any]
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail="start_date must be YYYY-MM-DD") from 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)
|
stored_tests = db.list_schedulable_tests(DB_PATH, rule=request.rule)
|
||||||
if not stored_tests:
|
if not stored_tests:
|
||||||
version = db.create_schedule_version([], DB_PATH)
|
version = db.create_schedule_version([], DB_PATH)
|
||||||
@@ -189,29 +370,38 @@ def compile_schedule_endpoint(request: CompileScheduleRequest) -> dict[str, Any]
|
|||||||
config=t.config,
|
config=t.config,
|
||||||
throttled=t.throttled,
|
throttled=t.throttled,
|
||||||
estimated_minutes=t.estimated_minutes,
|
estimated_minutes=t.estimated_minutes,
|
||||||
priority=t.priority,
|
|
||||||
raw_payload=t.raw_payload or {},
|
|
||||||
)
|
)
|
||||||
for t in stored_tests
|
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)
|
holiday_dates = db.list_holidays(DB_PATH)
|
||||||
try:
|
top_priority_pairs: set[tuple[str, str]] = set()
|
||||||
entries, completion_date = compile_schedule(
|
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,
|
tests=scheduler_tests,
|
||||||
|
top_priority_tests=top_priority_pairs,
|
||||||
start_date=request.start_date,
|
start_date=request.start_date,
|
||||||
holiday_dates=holiday_dates,
|
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,
|
daytime_testing_today=request.daytime_testing_today,
|
||||||
|
dual_device_weekend_start_enabled=request.dual_device_weekend_start_enabled,
|
||||||
|
dual_device_window_start_dates=set(item.strip() for item in request.dual_device_weekend_start_dates if item.strip()),
|
||||||
)
|
)
|
||||||
except RuntimeError as exc:
|
|
||||||
|
try:
|
||||||
|
schedule_error = scheduler.compile_schedule()
|
||||||
|
entries = scheduler.get_schedule()
|
||||||
|
except (RuntimeError, ValueError) as exc:
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from 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(
|
version = db.create_schedule_version(
|
||||||
[(e.test_id, e.device, e.scheduled_date, e.shift_index, e.sequence_in_shift) for e in entries],
|
[(e.test_id, e.device, e.scheduled_date, e.shift_index, e.sequence_in_shift) for e in entries],
|
||||||
DB_PATH,
|
DB_PATH,
|
||||||
@@ -252,25 +442,16 @@ def get_schedule_week(start: str | None = None) -> dict[str, Any]:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail="start must be YYYY-MM-DD") from 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)
|
rows = db.get_schedule_week(week_start, DB_PATH)
|
||||||
|
all_rows = db.get_schedule_rows_for_latest_version(DB_PATH)
|
||||||
|
completion_date = max((row.scheduled_date for row in all_rows), default=None)
|
||||||
|
holiday_dates = db.list_holidays(DB_PATH)
|
||||||
return {
|
return {
|
||||||
"start_date": week_start,
|
"start_date": week_start,
|
||||||
"items": [
|
"items": [_serialize_schedule_row(row) for row in rows],
|
||||||
{
|
"completion_date": completion_date,
|
||||||
"test_id": row.test_id,
|
"windows": _build_schedule_windows(week_start_date, all_rows, holiday_dates),
|
||||||
"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
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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
|
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
|
P1,1,No,No,R3,P2PRXBE001,5G,LPI,T1F,36,80,-76,UL,STA63,,,,,,,,,,,,
|
||||||
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,1,No,No,R3,P2PRXBE002,5G,LPI,T1B,36,80,-67,UL,STA63,,,,,,,,,,,,
|
||||||
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,1,No,No,R3,P2PRXBE003,5G,LPI,T1C,36,80,-45,UL,STA63,,,,,,,,,,,,
|
||||||
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,2,No,Yes,R3,P2PRXBE004,5G,LPI,T1F,100,80,-76,UL,STA63,,,,,,,,,,,,
|
||||||
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,2,No,No,R3,P2PRXBE005,5G,LPI,T1B,100,80,-67,UL,STA63,,,,,,,,,,,,
|
||||||
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
|
P1,2,No,No,R3,P2PRXBE006,5G,LPI,T1C,100,80,-45,UL,STA63,,,,,,,,,,,,
|
||||||
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
|
|
||||||
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,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
|
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,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,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,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
|
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,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,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,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,,,,,,
|
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,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,,,,,,
|
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,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,,,,,,
|
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,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,,,,,,
|
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,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,,,,,,
|
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,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,P2PRXBE029,2G,LPI,,,,,,,,,,,,,T1D,1,20,-60,UL,STA63
|
||||||
P1,23,No,No,R2,P2PRXBE030,2G,LPI,,,,,,,,,,,,,T1C,1,20,-45,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,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,P2PRXBE032,2G,LPI,,,,,,,,,,,,,T1D,6,20,-60,UL,STA63
|
||||||
P1,25,No,No,R2,P2PRXBE033,2G,LPI,,,,,,,,,,,,,T1C,6,20,-45,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,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,P2PRXBE035,2G,LPI,,,,,,,,,,,,,T1D,11,20,-60,UL,STA63
|
||||||
P1,27,No,No,R2,P2PRXBE036,2G,LPI,,,,,,,,,,,,,T1C,11,20,-45,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,,,,,,,,,,,,
|
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,R2,P2PTXAC002,5G,LPI,T2B,36,80,-67,DL,STA4,,,,,,,,,,,,
|
P1,1,No,No,R1,P2PRXAX001,5G,LPI,T1F,36,80,-76,UL,STA56,,,,,,,,,,,,
|
||||||
P1,1,No,No,R2,P2PTXAC003,5G,LPI,T1C,36,80,-45,DL,STA4,,,,,,,,,,,,
|
P1,1,No,No,R1,P2PRXAX002,5G,LPI,T1B,36,80,-67,UL,STA56,,,,,,,,,,,,
|
||||||
P1,2,No,Yes,R2,P2PTXAC004,5G,LPI,T1F,100,80,-76,DL,STA4,,,,,,,,,,,,
|
P1,1,No,No,R1,P2PRXAX003,5G,LPI,T1C,36,80,-45,UL,STA56,,,,,,,,,,,,
|
||||||
P1,2,No,No,R2,P2PTXAC005,5G,LPI,T2B,100,80,-67,DL,STA4,,,,,,,,,,,,
|
P1,2,No,Yes,R1,P2PRXAX004,5G,LPI,T1F,100,80,-76,UL,STA56,,,,,,,,,,,,
|
||||||
P1,2,No,No,R2,P2PTXAC006,5G,LPI,T1C,100,80,-45,DL,STA4,,,,,,,,,,,,
|
P1,2,No,No,R1,P2PRXAX005,5G,LPI,T1B,100,80,-67,UL,STA56,,,,,,,,,,,,
|
||||||
P1,5,No,Yes,R2,P2PTXAC007,5G,LPI,T1F,161,80,-76,DL,STA4,,,,,,,,,,,,
|
P1,2,No,No,R1,P2PRXAX006,5G,LPI,T1C,100,80,-45,UL,STA56,,,,,,,,,,,,
|
||||||
P1,5,No,No,R2,P2PTXAC008,5G,LPI,T2B,161,80,-67,DL,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
|
||||||
P1,5,No,No,R2,P2PTXAC009,5G,LPI,T1C,161,80,-45,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,14,No,Yes,R1,P2PTXAC010,2G,LPI,,,,,,,,,,,,,T3E,1,20,-70,DL,STA4
|
P1,5,No,Yes,R1,P2PRXAX007,5G,LPI,T1F,161,80,-76,UL,STA56,,,,,,,,,,,,
|
||||||
P1,14,No,No,R1,P2PTXAC011,2G,LPI,,,,,,,,,,,,,T1D,1,20,-60,DL,STA4
|
P1,5,No,No,R1,P2PRXAX008,5G,LPI,T1B,161,80,-67,UL,STA56,,,,,,,,,,,,
|
||||||
P1,14,No,No,R1,P2PTXAC012,2G,LPI,,,,,,,,,,,,,T1C,1,20,-45,DL,STA4
|
P1,5,No,No,R1,P2PRXAX009,5G,LPI,T1C,161,80,-45,UL,STA56,,,,,,,,,,,,
|
||||||
P1,16,No,Yes,R1,P2PTXAC013,2G,LPI,,,,,,,,,,,,,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
|
||||||
P1,16,No,No,R1,P2PTXAC014,2G,LPI,,,,,,,,,,,,,T1D,6,20,-60,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
|
||||||
P1,16,No,No,R1,P2PTXAC015,2G,LPI,,,,,,,,,,,,,T1C,6,20,-45,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
|
||||||
P1,18,No,Yes,R1,P2PTXAC016,2G,LPI,,,,,,,,,,,,,T3E,11,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,18,No,No,R1,P2PTXAC017,2G,LPI,,,,,,,,,,,,,T1D,11,20,-60,DL,STA4
|
P1,10,No,Yes,R1,P2PRXAX010,5G,LPI,T1F,100,160,-76,UL,STA56,,,,,,,,,,,,
|
||||||
P1,18,No,No,R1,P2PTXAC018,2G,LPI,,,,,,,,,,,,,T1C,11,20,-45,DL,STA4
|
P1,10,No,No,R1,P2PRXAX011,5G,LPI,T1B,100,160,-67,UL,STA56,,,,,,,,,,,,
|
||||||
P1,1,No,No,R1,P2PTXAX001,5G,LPI,T1F,36,80,-76,DL,STA56,,,,,,,,,,,,
|
P1,10,No,No,R1,P2PRXAX012,5G,LPI,T1C,100,160,-45,UL,STA56,,,,,,,,,,,,
|
||||||
P1,1,No,No,R1,P2PTXAX002,5G,LPI,T2B,36,80,-67,DL,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
|
||||||
P1,1,No,No,R1,P2PTXAX003,5G,LPI,T1C,36,80,-45,DL,STA56,,,,,,,,,,,,
|
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,2,No,Yes,R1,P2PTXAX004,5G,LPI,T1F,100,80,-76,DL,STA56,,,,,,,,,,,,
|
P1,13,No,Yes,R2,P2PRXAX013,6G,LPI,,,,,,,T2A,5,160,-79,UL,STA56,,,,,,
|
||||||
P1,2,No,No,R1,P2PTXAX005,5G,LPI,T2B,100,80,-67,DL,STA56,,,,,,,,,,,,
|
P1,13,No,No,R2,P2PRXAX014,6G,LPI,,,,,,,T1B,5,160,-70,UL,STA56,,,,,,
|
||||||
P1,2,No,No,R1,P2PTXAX006,5G,LPI,T1C,100,80,-45,DL,STA56,,,,,,,,,,,,
|
P1,13,No,No,R2,P2PRXAX015,6G,LPI,,,,,,,T1C,5,160,-45,UL,STA56,,,,,,
|
||||||
P1,5,No,Yes,R1,P2PTXAX007,5G,LPI,T1F,161,80,-76,DL,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,5,No,No,R1,P2PTXAX008,5G,LPI,T2B,161,80,-67,DL,STA56,,,,,,,,,,,,
|
P1,15,No,Yes,R2,P2PRXAX016,6G,LPI,,,,,,,T2A,133,160,-79,UL,STA56,,,,,,
|
||||||
P1,5,No,No,R1,P2PTXAX009,5G,LPI,T1C,161,80,-45,DL,STA56,,,,,,,,,,,,
|
P1,15,No,No,R2,P2PRXAX017,6G,LPI,,,,,,,T1B,133,160,-70,UL,STA56,,,,,,
|
||||||
P1,8,No,Yes,R1,P2PTXAX010,5G,LPI,T1F,100,160,-76,DL,STA56,,,,,,,,,,,,
|
P1,15,No,No,R2,P2PRXAX018,6G,LPI,,,,,,,T1C,133,160,-45,UL,STA63,,,,,,
|
||||||
P1,8,No,No,R1,P2PTXAX011,5G,LPI,T2B,100,160,-67,DL,STA56,,,,,,,,,,,,
|
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,8,No,No,R1,P2PTXAX012,5G,LPI,T1C,100,160,-45,DL,STA56,,,,,,,,,,,,
|
P1,17,No,Yes,R2,P2PRXAX019,6G,LPI,,,,,,,T2A,197,160,-79,UL,STA56,,,,,,
|
||||||
P1,11,No,Yes,R2,P2PTXAX013,6G,LPI,,,,,,,T2A,5,160,-79,DL,STA56,,,,,,
|
P1,17,No,No,R2,P2PRXAX020,6G,LPI,,,,,,,T1B,197,160,-70,UL,STA56,,,,,,
|
||||||
P1,11,No,No,R2,P2PTXAX014,6G,LPI,,,,,,,T2B,5,160,-70,DL,STA56,,,,,,
|
P1,17,No,No,R2,P2PRXAX021,6G,LPI,,,,,,,T1C,197,160,-45,UL,STA56,,,,,,
|
||||||
P1,11,No,No,R2,P2PTXAX015,6G,LPI,,,,,,,T1C,5,160,-45,DL,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,13,No,Yes,R2,P2PTXAX016,6G,LPI,,,,,,,T2A,133,160,-79,DL,STA56,,,,,,
|
P1,19,No,Yes,R2,P2PRXAX022,6G,SP,,,,,,,T2A,5,320,-79,UL,STA56,,,,,,
|
||||||
P1,13,No,No,R2,P2PTXAX017,6G,LPI,,,,,,,T2B,133,160,-70,DL,STA56,,,,,,
|
P1,19,No,No,R2,P2PRXAX023,6G,SP,,,,,,,T1B,5,320,-70,UL,STA56,,,,,,
|
||||||
P1,13,No,No,R2,P2PTXAX018,6G,LPI,,,,,,,T1C,133,160,-45,DL,STA56,,,,,,
|
P1,19,No,No,R2,P2PRXAX024,6G,SP,,,,,,,T1C,5,320,-45,UL,STA56,,,,,,
|
||||||
P1,15,No,Yes,R2,P2PTXAX019,6G,LPI,,,,,,,T2A,197,160,-79,DL,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,15,No,No,R2,P2PTXAX020,6G,LPI,,,,,,,T2B,197,160,-70,DL,STA56,,,,,,
|
P1,21,No,Yes,R2,P2PRXAX025,6G,LPI,,,,,,,T2A,133,320,-79,UL,STA56,,,,,,
|
||||||
P1,15,No,No,R2,P2PTXAX021,6G,LPI,,,,,,,T1C,197,160,-45,DL,STA56,,,,,,
|
P1,21,No,No,R2,P2PRXAX026,6G,LPI,,,,,,,T1B,133,320,-70,UL,STA56,,,,,,
|
||||||
P1,17,No,Yes,R2,P2PTXAX022,6G,SP,,,,,,,T2A,5,320,-79,DL,STA56,,,,,,
|
P1,21,No,No,R2,P2PRXAX027,6G,LPI,,,,,,,T1C,133,320,-45,UL,STA56,,,,,,
|
||||||
P1,17,No,No,R2,P2PTXAX023,6G,SP,,,,,,,T2B,5,320,-70,DL,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,17,No,No,R2,P2PTXAX024,6G,SP,,,,,,,T1C,5,320,-45,DL,STA56,,,,,,
|
P1,23,No,Yes,R3,P2PRXAX028,2G,LPI,,,,,,,,,,,,,T3E,1,20,-70,UL,STA56
|
||||||
P1,19,No,Yes,R2,P2PTXAX025,6G,LPI,,,,,,,T2A,133,320,-79,DL,STA56,,,,,,
|
P1,23,No,No,R3,P2PRXAX029,2G,LPI,,,,,,,,,,,,,T1D,1,20,-60,UL,STA56
|
||||||
P1,19,No,No,R2,P2PTXAX026,6G,LPI,,,,,,,T2B,133,320,-70,DL,STA56,,,,,,
|
P1,23,No,No,R3,P2PRXAX030,2G,LPI,,,,,,,,,,,,,T1C,1,20,-45,UL,STA56
|
||||||
P1,19,No,No,R2,P2PTXAX027,6G,LPI,,,,,,,T1C,133,320,-45,DL,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,21,No,Yes,R3,P2PTXAX028,2G,LPI,,,,,,,,,,,,,T3E,1,20,-70,UL,STA56
|
P1,25,No,Yes,R3,P2PRXAX031,2G,LPI,,,,,,,,,,,,,T3E,6,20,-70,UL,STA56
|
||||||
P1,21,No,No,R3,P2PTXAX029,2G,LPI,,,,,,,,,,,,,T1D,1,20,-60,UL,STA56
|
P1,25,No,No,R3,P2PRXAX032,2G,LPI,,,,,,,,,,,,,T1D,6,20,-60,UL,STA56
|
||||||
P1,21,No,No,R3,P2PTXAX030,2G,LPI,,,,,,,,,,,,,T1C,1,20,-45,UL,STA56
|
P1,25,No,No,R3,P2PRXAX033,2G,LPI,,,,,,,,,,,,,T1C,6,20,-45,UL,STA56
|
||||||
P1,23,No,Yes,R3,P2PTXAX031,2G,LPI,,,,,,,,,,,,,T3E,6,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
|
||||||
P1,23,No,No,R3,P2PTXAX032,2G,LPI,,,,,,,,,,,,,T1D,6,20,-60,UL,STA56
|
P1,27,No,Yes,R3,P2PRXAX034,2G,LPI,,,,,,,,,,,,,T3E,11,20,-70,UL,STA56
|
||||||
P1,23,No,No,R3,P2PTXAX033,2G,LPI,,,,,,,,,,,,,T1C,6,20,-45,UL,STA56
|
P1,27,No,No,R3,P2PRXAX035,2G,LPI,,,,,,,,,,,,,T1D,11,20,-60,UL,STA56
|
||||||
P1,25,No,Yes,R3,P2PTXAX034,2G,LPI,,,,,,,,,,,,,T3E,11,20,-70,UL,STA56
|
P1,27,No,No,R3,P2PRXAX036,2G,LPI,,,,,,,,,,,,,T1C,11,20,-45,UL,STA56
|
||||||
P1,25,No,No,R3,P2PTXAX035,2G,LPI,,,,,,,,,,,,,T1D,11,20,-60,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,25,No,No,R3,P2PTXAX036,2G,LPI,,,,,,,,,,,,,T1C,11,20,-45,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,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,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,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,,,,,,,,,,,,
|
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,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,,,,,,,,,,,,
|
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,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,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,,,,,,,,,,,,
|
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
|
||||||
P1,11,No,No,R1,P2PTXBE014,6G,LPI,T2B,5,160,-70,DL,STA63,,,,,,,,,,,,
|
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,,,,,,,,,,,,
|
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,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,,,,,,,,,,,,
|
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,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,,,,,,,,,,,,
|
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,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,,,,,,,,,,,,
|
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,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,,,,,,,,,,,,
|
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,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,P2PTXBE029,2G,LPI,T1D,1,20,-60,UL,STA63,,,,,,,,,,,,
|
||||||
P1,21,No,No,R2,P2PTXBE030,2G,LPI,T1C,1,20,-45,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,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,P2PTXBE032,2G,LPI,T1D,6,20,-60,UL,STA63,,,,,,,,,,,,
|
||||||
P1,23,No,No,R2,P2PTXBE033,2G,LPI,T1C,6,20,-45,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,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,P2PTXBE035,2G,LPI,T1D,11,20,-60,UL,STA63,,,,,,,,,,,,
|
||||||
P1,25,No,No,R2,P2PTXBE036,2G,LPI,T1C,11,20,-45,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
|
||||||
|
@@ -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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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
|
|
||||||
|
@@ -6,7 +6,7 @@ from contextlib import contextmanager
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterator
|
from typing import Any, Iterator
|
||||||
from graph import build_station_testpoint_map
|
from test_config import serialize_station_testpoint_map
|
||||||
|
|
||||||
|
|
||||||
APP_ROOT = Path(__file__).resolve().parent
|
APP_ROOT = Path(__file__).resolve().parent
|
||||||
@@ -123,21 +123,6 @@ def init_db(db_path: str | Path = DB_PATH) -> None:
|
|||||||
minutes INTEGER NOT NULL
|
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,
|
|
||||||
failed_test_ids_json TEXT NOT NULL,
|
|
||||||
estimated_rerun_minutes INTEGER NOT NULL,
|
|
||||||
rerun_during_day INTEGER
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tests_status ON tests(status);
|
CREATE INDEX IF NOT EXISTS idx_tests_status ON tests(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_tests_priority ON tests(priority);
|
CREATE INDEX IF NOT EXISTS idx_tests_priority ON tests(priority);
|
||||||
CREATE INDEX IF NOT EXISTS idx_schedules_date_shift ON schedules(scheduled_date, shift_index);
|
CREATE INDEX IF NOT EXISTS idx_schedules_date_shift ON schedules(scheduled_date, shift_index);
|
||||||
@@ -166,14 +151,38 @@ def init_db(db_path: str | Path = DB_PATH) -> None:
|
|||||||
|
|
||||||
def _serialize_station_testpoint_map(config: dict[str, dict[str, str | None]]) -> str:
|
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."""
|
"""Compute and serialize the station-to-testpoint map from a test config."""
|
||||||
station_map = build_station_testpoint_map(config)
|
return serialize_station_testpoint_map(config)
|
||||||
return json.dumps(station_map)
|
|
||||||
|
|
||||||
|
|
||||||
def upsert_tests(records: list[TestRecord], db_path: str | Path = DB_PATH) -> int:
|
def upsert_tests(records: list[TestRecord], db_path: str | Path = DB_PATH) -> int:
|
||||||
if not records:
|
if not records:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
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:
|
with get_connection(db_path) as conn:
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"""
|
"""
|
||||||
@@ -200,27 +209,7 @@ def upsert_tests(records: list[TestRecord], db_path: str | Path = DB_PATH) -> in
|
|||||||
station_testpoint_map = excluded.station_testpoint_map,
|
station_testpoint_map = excluded.station_testpoint_map,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
""",
|
""",
|
||||||
[
|
values,
|
||||||
(
|
|
||||||
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
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
return len(records)
|
return len(records)
|
||||||
|
|
||||||
@@ -253,52 +242,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}
|
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]:
|
def _parse_rule_tokens(rule: str | None) -> list[str]:
|
||||||
if not rule:
|
if not rule:
|
||||||
return []
|
return []
|
||||||
@@ -345,7 +288,7 @@ def _match_any_band_value(record: TestRecord, key: str, token_suffix: str) -> bo
|
|||||||
|
|
||||||
target_num = _extract_signed_int(token_suffix)
|
target_num = _extract_signed_int(token_suffix)
|
||||||
target_compact = _normalize_compact(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:
|
for band in keys:
|
||||||
entry = _band_entry(record.config, band)
|
entry = _band_entry(record.config, band)
|
||||||
@@ -552,11 +495,82 @@ def get_schedule_week(start_date: str, db_path: str | Path = DB_PATH) -> list[Sc
|
|||||||
FROM schedules s
|
FROM schedules s
|
||||||
JOIN tests t ON t.test_id = s.test_id AND t.device = s.device
|
JOIN tests t ON t.test_id = s.test_id AND t.device = s.device
|
||||||
WHERE s.schedule_version = ?
|
WHERE s.schedule_version = ?
|
||||||
AND s.scheduled_date >= ?
|
AND (
|
||||||
|
(
|
||||||
|
s.scheduled_date >= ?
|
||||||
AND s.scheduled_date < date(?, '+7 day')
|
AND s.scheduled_date < date(?, '+7 day')
|
||||||
|
AND s.shift_index IN (2, 3)
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
s.scheduled_date > ?
|
||||||
|
AND s.scheduled_date < date(?, '+7 day')
|
||||||
|
AND s.shift_index = 1
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
s.scheduled_date = date(?, '+7 day')
|
||||||
|
AND s.shift_index = 1
|
||||||
|
)
|
||||||
|
)
|
||||||
ORDER BY s.scheduled_date, s.shift_index, s.sequence_in_shift
|
ORDER BY s.scheduled_date, s.shift_index, s.sequence_in_shift
|
||||||
""",
|
""",
|
||||||
(latest, start_date, start_date),
|
(
|
||||||
|
latest,
|
||||||
|
start_date,
|
||||||
|
start_date,
|
||||||
|
start_date,
|
||||||
|
start_date,
|
||||||
|
start_date,
|
||||||
|
),
|
||||||
|
).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 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()
|
).fetchall()
|
||||||
|
|
||||||
result: list[ScheduleRow] = []
|
result: list[ScheduleRow] = []
|
||||||
|
|||||||
@@ -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())
|
|
||||||
@@ -21,24 +21,24 @@ P2P_COE_REQUIRED_COLUMNS = [
|
|||||||
"TC ID",
|
"TC ID",
|
||||||
"Victim Band",
|
"Victim Band",
|
||||||
"6GHz Power Mode",
|
"6GHz Power Mode",
|
||||||
"5G Test Point",
|
"5G_Test Point",
|
||||||
"5G Channel",
|
"5G_Channel",
|
||||||
"5G Bandwidth",
|
"5G_Bandwidth",
|
||||||
"5G RSSI",
|
"5G_RSSI",
|
||||||
"5G Direction",
|
"5G_Direction",
|
||||||
"5G STA",
|
"5G_STA",
|
||||||
"6G Test Point",
|
"6G_Test Point",
|
||||||
"6G Channel",
|
"6G_Channel",
|
||||||
"6G Bandwidth",
|
"6G_Bandwidth",
|
||||||
"6G RSSI",
|
"6G_RSSI",
|
||||||
"6G Direction",
|
"6G_Direction",
|
||||||
"6G STA",
|
"6G_STA",
|
||||||
"2G Test Point",
|
"2G_Test Point",
|
||||||
"2G Channel",
|
"2G_Channel",
|
||||||
"2G Bandwidth",
|
"2G_Bandwidth",
|
||||||
"2G RSSI",
|
"2G_RSSI",
|
||||||
"2G Direction",
|
"2G_Direction",
|
||||||
"2G STA",
|
"2G_STA",
|
||||||
]
|
]
|
||||||
|
|
||||||
P3P_REQUIRED_COLUMNS = [
|
P3P_REQUIRED_COLUMNS = [
|
||||||
@@ -49,27 +49,27 @@ P3P_REQUIRED_COLUMNS = [
|
|||||||
"TC ID",
|
"TC ID",
|
||||||
"Band",
|
"Band",
|
||||||
"6GHz Power Mode",
|
"6GHz Power Mode",
|
||||||
"Station 1 Test Point",
|
"STATION 1_Test Point",
|
||||||
"Station 1 Channel",
|
"STATION 1_Channel",
|
||||||
"Station 1 Bandwidth",
|
"STATION 1_Bandwidth",
|
||||||
"Station 1 RSSI",
|
"STATION 1_RSSI",
|
||||||
"Station 1 Direction",
|
"STATION 1_Direction",
|
||||||
"Station 1 Rate",
|
"STATION 1_Rate",
|
||||||
"Station 1 STA",
|
"STATION 1_STA",
|
||||||
"Station 2 Test Point",
|
"STATION 2_Test Point",
|
||||||
"Station 2 Channel",
|
"STATION 2_Channel",
|
||||||
"Station 2 Bandwidth",
|
"STATION 2_Bandwidth",
|
||||||
"Station 2 RSSI",
|
"STATION 2_RSSI",
|
||||||
"Station 2 Direction",
|
"STATION 2_Direction",
|
||||||
"Station 2 Rate",
|
"STATION 2_Rate",
|
||||||
"Station 2 STA",
|
"STATION 2_STA",
|
||||||
"Station 3 Test Point",
|
"STATION 3_Test Point",
|
||||||
"Station 3 Channel",
|
"STATION 3_Channel",
|
||||||
"Station 3 Bandwidth",
|
"STATION 3_Bandwidth",
|
||||||
"Station 3 RSSI",
|
"STATION 3_RSSI",
|
||||||
"Station 3 Direction",
|
"STATION 3_Direction",
|
||||||
"Station 3 Rate",
|
"STATION 3_Rate",
|
||||||
"Station 3 STA",
|
"STATION 3_STA",
|
||||||
]
|
]
|
||||||
|
|
||||||
RUNTIME_DEFAULTS = {
|
RUNTIME_DEFAULTS = {
|
||||||
@@ -230,20 +230,23 @@ def _detect_csv_format(normalized_fieldnames: set[str]) -> str:
|
|||||||
f"{', '.join(p2p_coe_missing)}; missing columns for P3P format: {', '.join(p3p_missing)}"
|
f"{', '.join(p2p_coe_missing)}; missing columns for P3P format: {', '.join(p3p_missing)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_target_csv(
|
def parse_target_csv(
|
||||||
csv_path: str | Path | list[str | Path] | tuple[str | Path, ...],
|
csv_path: str | Path | list[str | Path] | tuple[str | Path, ...],
|
||||||
smb_credentials: dict[str, Any] | None = None,
|
smb_credentials: dict[str, Any] | None = None,
|
||||||
runtime_overrides: dict[str, Any] | None = None,
|
runtime_overrides: dict[str, Any] | None = None,
|
||||||
) -> ParseResult:
|
) -> ParseResult:
|
||||||
paths = _resolve_csv_paths(csv_path, smb_credentials=smb_credentials)
|
paths = _resolve_csv_paths(csv_path, smb_credentials=smb_credentials)
|
||||||
runtime_defaults = _resolve_runtime_defaults(runtime_overrides)
|
runtime_defaults_by_device = _resolve_runtime_defaults(runtime_overrides)
|
||||||
all_tests: list[TestRecord] = []
|
all_tests: list[TestRecord] = []
|
||||||
all_warnings: list[str] = []
|
all_warnings: list[str] = []
|
||||||
seen_test_keys: set[tuple[str, str]] = set()
|
seen_test_keys: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
for path in paths:
|
for path in paths:
|
||||||
parsed = _parse_single_csv(path, smb_credentials=smb_credentials, runtime_defaults=runtime_defaults)
|
parsed = _parse_single_csv(
|
||||||
|
path,
|
||||||
|
smb_credentials=smb_credentials,
|
||||||
|
runtime_defaults_by_device=runtime_defaults_by_device,
|
||||||
|
)
|
||||||
all_warnings.extend(parsed.warnings)
|
all_warnings.extend(parsed.warnings)
|
||||||
for record in parsed.tests:
|
for record in parsed.tests:
|
||||||
key = (record.test_id, record.device)
|
key = (record.test_id, record.device)
|
||||||
@@ -258,13 +261,13 @@ def parse_target_csv(
|
|||||||
return ParseResult(tests=all_tests, warnings=all_warnings)
|
return ParseResult(tests=all_tests, warnings=all_warnings)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_runtime_defaults(runtime_overrides: dict[str, Any] | None) -> dict[str, int]:
|
def _coerce_runtime_defaults(raw_overrides: dict[str, Any] | None) -> dict[str, int]:
|
||||||
defaults = dict(RUNTIME_DEFAULTS)
|
defaults = dict(RUNTIME_DEFAULTS)
|
||||||
if not runtime_overrides:
|
if not raw_overrides:
|
||||||
return defaults
|
return defaults
|
||||||
|
|
||||||
for test_type in ("P2P", "COE", "P3P"):
|
for test_type in ("P2P", "COE", "P3P"):
|
||||||
raw_value = runtime_overrides.get(test_type)
|
raw_value = raw_overrides.get(test_type)
|
||||||
if raw_value is None:
|
if raw_value is None:
|
||||||
continue
|
continue
|
||||||
if isinstance(raw_value, str):
|
if isinstance(raw_value, str):
|
||||||
@@ -281,6 +284,25 @@ def _resolve_runtime_defaults(runtime_overrides: dict[str, Any] | None) -> dict[
|
|||||||
return defaults
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_runtime_defaults(runtime_overrides: dict[str, Any] | None) -> dict[str, dict[str, int]]:
|
||||||
|
legacy_defaults = _coerce_runtime_defaults(runtime_overrides)
|
||||||
|
if not runtime_overrides:
|
||||||
|
return {
|
||||||
|
DEVICE_DUT: dict(legacy_defaults),
|
||||||
|
DEVICE_REF: dict(legacy_defaults),
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved: dict[str, dict[str, int]] = {}
|
||||||
|
for device in (DEVICE_DUT, DEVICE_REF):
|
||||||
|
device_defaults = dict(legacy_defaults)
|
||||||
|
raw_device_overrides = runtime_overrides.get(device)
|
||||||
|
if isinstance(raw_device_overrides, dict):
|
||||||
|
device_defaults.update(_coerce_runtime_defaults(raw_device_overrides))
|
||||||
|
resolved[device] = device_defaults
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_csv_paths(
|
def _resolve_csv_paths(
|
||||||
csv_path: str | Path | list[str | Path] | tuple[str | Path, ...],
|
csv_path: str | Path | list[str | Path] | tuple[str | Path, ...],
|
||||||
@@ -319,7 +341,7 @@ def _open_csv_handle(path: str, smb_credentials: dict[str, Any] | None):
|
|||||||
def _parse_single_csv(
|
def _parse_single_csv(
|
||||||
path: str,
|
path: str,
|
||||||
smb_credentials: dict[str, Any] | None,
|
smb_credentials: dict[str, Any] | None,
|
||||||
runtime_defaults: dict[str, int],
|
runtime_defaults_by_device: dict[str, dict[str, int]],
|
||||||
) -> ParseResult:
|
) -> ParseResult:
|
||||||
with _open_csv_handle(path, smb_credentials=smb_credentials) as handle:
|
with _open_csv_handle(path, smb_credentials=smb_credentials) as handle:
|
||||||
reader = csv.DictReader(handle)
|
reader = csv.DictReader(handle)
|
||||||
@@ -353,7 +375,6 @@ def _parse_single_csv(
|
|||||||
has_coe_pair = _normalize_yes_no(_row_get(row, "COE Pair")) if csv_format == "p2p_coe" else False
|
has_coe_pair = _normalize_yes_no(_row_get(row, "COE Pair")) if csv_format == "p2p_coe" else False
|
||||||
config = _build_config(row, csv_format)
|
config = _build_config(row, csv_format)
|
||||||
signature = _victim_band_signature(row) if csv_format == "p2p_coe" else None
|
signature = _victim_band_signature(row) if csv_format == "p2p_coe" else None
|
||||||
estimated_minutes = runtime_defaults.get(test_type, RUNTIME_DEFAULTS[test_type])
|
|
||||||
victim_band_source = "Victim Band" if csv_format == "p2p_coe" else "Band"
|
victim_band_source = "Victim Band" if csv_format == "p2p_coe" else "Band"
|
||||||
victim_band = _normalize_victim_band(_row_get(row, victim_band_source))
|
victim_band = _normalize_victim_band(_row_get(row, victim_band_source))
|
||||||
|
|
||||||
@@ -377,7 +398,7 @@ def _parse_single_csv(
|
|||||||
victim_band=victim_band,
|
victim_band=victim_band,
|
||||||
config=config,
|
config=config,
|
||||||
throttled=throttled,
|
throttled=throttled,
|
||||||
estimated_minutes=estimated_minutes,
|
estimated_minutes=RUNTIME_DEFAULTS[test_type],
|
||||||
status="pending",
|
status="pending",
|
||||||
excluded=False,
|
excluded=False,
|
||||||
raw_payload=row,
|
raw_payload=row,
|
||||||
@@ -402,6 +423,7 @@ def _parse_single_csv(
|
|||||||
pairs = sorted(coe_by_signature_and_suffix.get(key, [])) if key else []
|
pairs = sorted(coe_by_signature_and_suffix.get(key, [])) if key else []
|
||||||
|
|
||||||
for device in (DEVICE_DUT, DEVICE_REF):
|
for device in (DEVICE_DUT, DEVICE_REF):
|
||||||
|
device_runtime_defaults = runtime_defaults_by_device.get(device, RUNTIME_DEFAULTS)
|
||||||
tests.append(
|
tests.append(
|
||||||
TestRecord(
|
TestRecord(
|
||||||
test_id=record.test_id,
|
test_id=record.test_id,
|
||||||
@@ -415,7 +437,7 @@ def _parse_single_csv(
|
|||||||
victim_band=record.victim_band,
|
victim_band=record.victim_band,
|
||||||
config=record.config,
|
config=record.config,
|
||||||
throttled=record.throttled,
|
throttled=record.throttled,
|
||||||
estimated_minutes=record.estimated_minutes,
|
estimated_minutes=device_runtime_defaults.get(record.test_type, RUNTIME_DEFAULTS[record.test_type]),
|
||||||
status=record.status,
|
status=record.status,
|
||||||
excluded=record.excluded,
|
excluded=record.excluded,
|
||||||
raw_payload=record.raw_payload,
|
raw_payload=record.raw_payload,
|
||||||
@@ -476,11 +498,20 @@ def _victim_band_signature(row: dict[str, str]) -> tuple[str, ...] | None:
|
|||||||
if band is None:
|
if band is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
prefix = f"{band} "
|
# Source CSVs may use either "5G_Test Point" or "5G Test Point" style headers.
|
||||||
test_point = _normalize_value(_row_get(row, f"{prefix}Test Point"))
|
prefixes = (f"{band}_", f"{band} ")
|
||||||
channel = _normalize_value(_row_get(row, f"{prefix}Channel"))
|
|
||||||
rssi = _normalize_value(_row_get(row, f"{prefix}RSSI"))
|
def _pick_value(suffix: str) -> str:
|
||||||
bandwidth = _normalize_value(_row_get(row, f"{prefix}Bandwidth"))
|
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.
|
# Direction is intentionally ignored for COE pairing matching.
|
||||||
if not all([test_point, channel, rssi, bandwidth]):
|
if not all([test_point, channel, rssi, bandwidth]):
|
||||||
@@ -515,61 +546,61 @@ def _normalize_yes_no(value: str | None) -> bool:
|
|||||||
def _build_legacy_config(row: dict[str, str]) -> dict[str, dict[str, str | None]]:
|
def _build_legacy_config(row: dict[str, str]) -> dict[str, dict[str, str | None]]:
|
||||||
return {
|
return {
|
||||||
"5G": {
|
"5G": {
|
||||||
"test_point": _empty_to_none(_row_get(row, "5G Test Point")),
|
"test_point": _empty_to_none(_row_get(row, "5G_Test Point")),
|
||||||
"channel": _empty_to_none(_row_get(row, "5G Channel")),
|
"channel": _empty_to_none(_row_get(row, "5G_Channel")),
|
||||||
"bandwidth": _empty_to_none(_row_get(row, "5G Bandwidth")),
|
"bandwidth": _empty_to_none(_row_get(row, "5G_Bandwidth")),
|
||||||
"rssi": _empty_to_none(_row_get(row, "5G RSSI")),
|
"rssi": _empty_to_none(_row_get(row, "5G_RSSI")),
|
||||||
"direction": _empty_to_none(_row_get(row, "5G Direction")),
|
"direction": _empty_to_none(_row_get(row, "5G_Direction")),
|
||||||
"sta": _empty_to_none(_row_get(row, "5G STA")),
|
"sta": _empty_to_none(_row_get(row, "5G_STA")),
|
||||||
},
|
},
|
||||||
"6G": {
|
"6G": {
|
||||||
"power_mode": _empty_to_none(_row_get(row, "6GHz Power Mode")),
|
"power_mode": _empty_to_none(_row_get(row, "6GHz Power Mode")),
|
||||||
"test_point": _empty_to_none(_row_get(row, "6G Test Point")),
|
"test_point": _empty_to_none(_row_get(row, "6G_Test Point")),
|
||||||
"channel": _empty_to_none(_row_get(row, "6G Channel")),
|
"channel": _empty_to_none(_row_get(row, "6G_Channel")),
|
||||||
"bandwidth": _empty_to_none(_row_get(row, "6G Bandwidth")),
|
"bandwidth": _empty_to_none(_row_get(row, "6G_Bandwidth")),
|
||||||
"rssi": _empty_to_none(_row_get(row, "6G RSSI")),
|
"rssi": _empty_to_none(_row_get(row, "6G_RSSI")),
|
||||||
"direction": _empty_to_none(_row_get(row, "6G Direction")),
|
"direction": _empty_to_none(_row_get(row, "6G_Direction")),
|
||||||
"sta": _empty_to_none(_row_get(row, "6G STA")),
|
"sta": _empty_to_none(_row_get(row, "6G_STA")),
|
||||||
},
|
},
|
||||||
"2G": {
|
"2G": {
|
||||||
"test_point": _empty_to_none(_row_get(row, "2G Test Point")),
|
"test_point": _empty_to_none(_row_get(row, "2G_Test Point")),
|
||||||
"channel": _empty_to_none(_row_get(row, "2G Channel")),
|
"channel": _empty_to_none(_row_get(row, "2G_Channel")),
|
||||||
"bandwidth": _empty_to_none(_row_get(row, "2G Bandwidth")),
|
"bandwidth": _empty_to_none(_row_get(row, "2G_Bandwidth")),
|
||||||
"rssi": _empty_to_none(_row_get(row, "2G RSSI")),
|
"rssi": _empty_to_none(_row_get(row, "2G_RSSI")),
|
||||||
"direction": _empty_to_none(_row_get(row, "2G Direction")),
|
"direction": _empty_to_none(_row_get(row, "2G_Direction")),
|
||||||
"sta": _empty_to_none(_row_get(row, "2G STA")),
|
"sta": _empty_to_none(_row_get(row, "2G_STA")),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_p3p_config(row: dict[str, str]) -> dict[str, dict[str, str | None]]:
|
def _build_p3p_config(row: dict[str, str]) -> dict[str, dict[str, str | None]]:
|
||||||
return {
|
return {
|
||||||
"Station 1": {
|
"STATION1": {
|
||||||
"test_point": _empty_to_none(_row_get(row, "Station 1 Test Point")),
|
"test_point": _empty_to_none(_row_get(row, "STATION 1_Test Point")),
|
||||||
"channel": _empty_to_none(_row_get(row, "Station 1 Channel")),
|
"channel": _empty_to_none(_row_get(row, "STATION 1_Channel")),
|
||||||
"bandwidth": _empty_to_none(_row_get(row, "Station 1 Bandwidth")),
|
"bandwidth": _empty_to_none(_row_get(row, "STATION 1_Bandwidth")),
|
||||||
"rssi": _empty_to_none(_row_get(row, "Station 1 RSSI")),
|
"rssi": _empty_to_none(_row_get(row, "STATION 1_RSSI")),
|
||||||
"direction": _empty_to_none(_row_get(row, "Station 1 Direction")),
|
"direction": _empty_to_none(_row_get(row, "STATION 1_Direction")),
|
||||||
"rate": _empty_to_none(_row_get(row, "Station 1 Rate")),
|
"rate": _empty_to_none(_row_get(row, "STATION 1_Rate")),
|
||||||
"sta": _empty_to_none(_row_get(row, "Station 1 STA")),
|
"sta": _empty_to_none(_row_get(row, "STATION 1_STA")),
|
||||||
},
|
},
|
||||||
"Station 2": {
|
"STATION2": {
|
||||||
"test_point": _empty_to_none(_row_get(row, "Station 2 Test Point")),
|
"test_point": _empty_to_none(_row_get(row, "STATION 2_Test Point")),
|
||||||
"channel": _empty_to_none(_row_get(row, "Station 2 Channel")),
|
"channel": _empty_to_none(_row_get(row, "STATION 2_Channel")),
|
||||||
"bandwidth": _empty_to_none(_row_get(row, "Station 2 Bandwidth")),
|
"bandwidth": _empty_to_none(_row_get(row, "STATION 2_Bandwidth")),
|
||||||
"rssi": _empty_to_none(_row_get(row, "Station 2 RSSI")),
|
"rssi": _empty_to_none(_row_get(row, "STATION 2_RSSI")),
|
||||||
"direction": _empty_to_none(_row_get(row, "Station 2 Direction")),
|
"direction": _empty_to_none(_row_get(row, "STATION 2_Direction")),
|
||||||
"rate": _empty_to_none(_row_get(row, "Station 2 Rate")),
|
"rate": _empty_to_none(_row_get(row, "STATION 2_Rate")),
|
||||||
"sta": _empty_to_none(_row_get(row, "Station 2 STA")),
|
"sta": _empty_to_none(_row_get(row, "STATION 2_STA")),
|
||||||
},
|
},
|
||||||
"Station 3": {
|
"STATION3": {
|
||||||
"test_point": _empty_to_none(_row_get(row, "Station 3 Test Point")),
|
"test_point": _empty_to_none(_row_get(row, "STATION 3_Test Point")),
|
||||||
"channel": _empty_to_none(_row_get(row, "Station 3 Channel")),
|
"channel": _empty_to_none(_row_get(row, "STATION 3_Channel")),
|
||||||
"bandwidth": _empty_to_none(_row_get(row, "Station 3 Bandwidth")),
|
"bandwidth": _empty_to_none(_row_get(row, "STATION 3_Bandwidth")),
|
||||||
"rssi": _empty_to_none(_row_get(row, "Station 3 RSSI")),
|
"rssi": _empty_to_none(_row_get(row, "STATION 3_RSSI")),
|
||||||
"direction": _empty_to_none(_row_get(row, "Station 3 Direction")),
|
"direction": _empty_to_none(_row_get(row, "STATION 3_Direction")),
|
||||||
"rate": _empty_to_none(_row_get(row, "Station 3 Rate")),
|
"rate": _empty_to_none(_row_get(row, "STATION 3_Rate")),
|
||||||
"sta": _empty_to_none(_row_get(row, "Station 3 STA")),
|
"sta": _empty_to_none(_row_get(row, "STATION 3_STA")),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,37 +2,12 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, timedelta
|
||||||
from typing import Any
|
from test_window import get_shift_sequence_with_capacity, get_shift_capacity_for_date, is_off_day, next_window_start_date
|
||||||
|
from test_bundle import Test, TestBundle, build_test_bundles, bundle_pair_lookup
|
||||||
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]
|
|
||||||
|
|
||||||
|
DUT = (os.getenv("DUT") or "DUT").strip()
|
||||||
|
REF = (os.getenv("REF") or "REF").strip()
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ScheduleEntry:
|
class ScheduleEntry:
|
||||||
@@ -43,551 +18,340 @@ class ScheduleEntry:
|
|||||||
sequence_in_shift: int
|
sequence_in_shift: int
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass
|
||||||
class TestBundle:
|
class ScheduleWindow:
|
||||||
"""A bundle of tests that must be run together in sequence."""
|
index: int
|
||||||
test_ids: list[TestKey]
|
shifts: list[tuple[date, int]]
|
||||||
priority_tier: int
|
capacity_minutes: int
|
||||||
total_minutes: int
|
remaining_minutes: int
|
||||||
|
assigned_device: str | None = None
|
||||||
|
assigned_config: tuple[str, ...] | None = None
|
||||||
|
|
||||||
|
|
||||||
# Global scheduler state. Graph is built once per loaded test set.
|
class Scheduler:
|
||||||
_TESTS_BY_ID: dict[TestKey, SchedulerTest] = {}
|
def __init__(
|
||||||
_ACTIVE_DUT: dict[TestKey, int] = {}
|
self,
|
||||||
_ACTIVE_REF: dict[TestKey, int] = {}
|
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,
|
||||||
|
dual_device_weekend_start_enabled: bool = False,
|
||||||
|
dual_device_window_start_dates: set[str] | None = None,
|
||||||
|
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.dual_device_weekend_start_enabled = dual_device_weekend_start_enabled
|
||||||
|
self.dual_device_window_start_dates = dual_device_window_start_dates or set()
|
||||||
|
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."
|
||||||
|
|
||||||
def reset_scheduler_state() -> None:
|
active_dut_bundles = [b for b in bundles if b.device == DUT]
|
||||||
global _TESTS_BY_ID, _ACTIVE_DUT, _ACTIVE_REF
|
active_ref_bundles = [b for b in bundles if b.device == REF]
|
||||||
_TESTS_BY_ID = {}
|
|
||||||
_ACTIVE_DUT = {}
|
|
||||||
_ACTIVE_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])
|
||||||
|
|
||||||
def initialize_scheduler_state(tests: list[SchedulerTest]) -> None:
|
# Create dict: TC -> bundles supporting that TC
|
||||||
"""Initialize active scheduler state once for the current loaded schedulable dataset."""
|
bundles_by_tc: dict = {}
|
||||||
global _TESTS_BY_ID, _ACTIVE_DUT, _ACTIVE_REF
|
# Sort with None last
|
||||||
if _TESTS_BY_ID:
|
sorted_tcs = sorted([tc for tc in all_tcs if tc is not None]) + ([None] if None in all_tcs else [])
|
||||||
return
|
for tc in sorted_tcs:
|
||||||
|
if tc is None:
|
||||||
_TESTS_BY_ID = {(t.test_id, t.device): t for t in tests}
|
bundles_by_tc[tc] = [b for b in bundles if not b.config]
|
||||||
_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
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
placed_entries, placed_test_ids, placed_last_date = _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,
|
|
||||||
)
|
|
||||||
entries.extend(placed_entries)
|
|
||||||
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:
|
else:
|
||||||
current_date = current_date + timedelta(days=1)
|
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))
|
||||||
|
|
||||||
return entries, last_date
|
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]))
|
||||||
|
|
||||||
|
|
||||||
def format_schedule_for_frontend(
|
for tc, tc_bundles in sorted_tc_items:
|
||||||
entries: list[ScheduleEntry],
|
window_device = DUT
|
||||||
) -> dict[str, dict[int, list[SchedulerTest]]]:
|
dut_unscheduled = [b for b in tc_bundles if b.device == DUT]
|
||||||
"""Convert flat ScheduleEntry list to nested format for frontend.
|
ref_unscheduled = [b for b in tc_bundles if b.device == REF]
|
||||||
|
|
||||||
Returns: {date_string: {shift_index: [SchedulerTest, ...]}, ...}
|
while dut_unscheduled or ref_unscheduled:
|
||||||
"""
|
active_unscheduled = dut_unscheduled if window_device == DUT else ref_unscheduled
|
||||||
schedule: dict[str, dict[int, list[SchedulerTest]]] = {}
|
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
|
||||||
|
|
||||||
for entry in entries:
|
shifts, capacity = get_shift_sequence_with_capacity(cursor_date, self.holiday_dates, self.daytime_testing_today)
|
||||||
if entry.scheduled_date not in schedule:
|
cursor_date_key = cursor_date.isoformat()
|
||||||
schedule[entry.scheduled_date] = {1: [], 2: [], 3: []}
|
dual_device_window = cursor_date_key in self.dual_device_window_start_dates
|
||||||
|
if not dual_device_window and self.dual_device_weekend_start_enabled:
|
||||||
|
dual_device_window = self._is_weekend_start_day(cursor_date)
|
||||||
|
|
||||||
test = _TESTS_BY_ID.get((entry.test_id, entry.device))
|
# Evaluate both devices without mutating queue state, then commit once.
|
||||||
if test:
|
mirrored_bundles, knapsack_bundles, remaining_time = self._select_bundles(
|
||||||
schedule[entry.scheduled_date][entry.shift_index].append(test)
|
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
|
||||||
|
active_unscheduled = dut_unscheduled if window_device == DUT else ref_unscheduled
|
||||||
|
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
|
||||||
|
|
||||||
return schedule
|
primary_device = window_device
|
||||||
|
|
||||||
|
if dual_device_window and remaining_time > 0:
|
||||||
|
secondary_device = REF if primary_device == DUT else DUT
|
||||||
|
secondary_unscheduled = dut_unscheduled if secondary_device == DUT else ref_unscheduled
|
||||||
|
secondary_mirrored, secondary_knapsack, remaining_time = self._select_bundles(
|
||||||
|
secondary_device,
|
||||||
|
remaining_time,
|
||||||
|
secondary_unscheduled,
|
||||||
|
tc,
|
||||||
|
mutate=True,
|
||||||
|
)
|
||||||
|
selected_bundles.extend(secondary_mirrored + secondary_knapsack)
|
||||||
|
|
||||||
|
# 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}
|
||||||
|
selected_bundle_keys = {(b.index, b.device) for b in selected_bundles}
|
||||||
|
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 or pair_key in selected_bundle_keys:
|
||||||
|
continue
|
||||||
|
pending_target.append(pair_bundle)
|
||||||
|
existing_pending.add(pair_key)
|
||||||
|
|
||||||
|
|
||||||
def _derive_priority(
|
window = ScheduleWindow(
|
||||||
test: SchedulerTest,
|
index=window_index,
|
||||||
top_priority_tests: set[str],
|
shifts=shifts,
|
||||||
lowest_priority_tests: set[str],
|
capacity_minutes=capacity,
|
||||||
all_tests: list[SchedulerTest],
|
remaining_minutes=remaining_time,
|
||||||
) -> int:
|
assigned_config=tc,
|
||||||
if test.test_id in top_priority_tests:
|
assigned_device=primary_device,
|
||||||
return 1
|
)
|
||||||
if test.test_id in lowest_priority_tests:
|
|
||||||
return 5
|
|
||||||
return test.priority
|
|
||||||
|
|
||||||
|
self._place_bundles_in_window(window, selected_bundles)
|
||||||
|
|
||||||
def _fit_bundles_to_shifts(
|
# 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 _is_weekend_start_day(self, current_date: date) -> bool:
|
||||||
|
if is_off_day(current_date, self.holiday_dates):
|
||||||
|
return False
|
||||||
|
return is_off_day(current_date + timedelta(days=1), self.holiday_dates)
|
||||||
|
|
||||||
|
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],
|
bundles: list[TestBundle],
|
||||||
shift_sequence: list[tuple[date, int]],
|
) -> None:
|
||||||
tests: dict[TestKey, SchedulerTest],
|
# Build per-shift remaining capacity
|
||||||
graph_by_test_id: dict[str, set[str]],
|
shift_remaining = [
|
||||||
shift_capacities: dict[tuple[date, int], int],
|
get_shift_capacity_for_date(day, self.holiday_dates, self.daytime_testing_today).get(shift_idx, 0)
|
||||||
) -> tuple[list[ScheduleEntry], set[TestKey], str | None]:
|
for day, shift_idx in window.shifts
|
||||||
"""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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# 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
|
current_shift_pos = 0
|
||||||
window_placed_test_ids: set[str] = set()
|
sequence_in_shift = [0] * len(window.shifts)
|
||||||
|
|
||||||
for bundle in bundles:
|
for bundle in bundles:
|
||||||
# Skip bundles that are already fully placed.
|
bundle_key = (bundle.index, bundle.device)
|
||||||
if any(test_id in placed_test_ids for test_id in bundle.test_ids):
|
if bundle_key in self.scheduled_bundle_keys:
|
||||||
continue
|
continue
|
||||||
|
self.scheduled_bundle_keys.add(bundle_key)
|
||||||
|
|
||||||
if not bundle.test_ids:
|
for test in bundle.tests:
|
||||||
|
schedule_key = f"{test}:{bundle.device}"
|
||||||
|
if schedule_key in self.scheduled_test_ids:
|
||||||
continue
|
continue
|
||||||
|
self.scheduled_test_ids.add(schedule_key)
|
||||||
|
|
||||||
# Keep all tests in the same window pairwise-compatible.
|
test_minutes = self._get_test_minutes(test, bundle.device)
|
||||||
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
|
|
||||||
|
|
||||||
# Ensure the whole bundle can still fit somewhere in the remaining window.
|
while current_shift_pos < len(window.shifts) and shift_remaining[current_shift_pos] <= 0:
|
||||||
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
|
current_shift_pos += 1
|
||||||
prev_remaining = state['remaining_minutes']
|
|
||||||
if current_shift_pos >= len(shift_sequence):
|
if current_shift_pos >= len(window.shifts):
|
||||||
failed = True
|
|
||||||
break
|
break
|
||||||
|
|
||||||
if failed:
|
if sum(shift_remaining[current_shift_pos:]) < test_minutes:
|
||||||
break
|
break
|
||||||
|
|
||||||
if failed:
|
start_shift_pos = current_shift_pos
|
||||||
# Roll back any partially placed tests from this bundle.
|
shift_day, shift_idx = window.shifts[start_shift_pos]
|
||||||
for key, date_shift in reversed(placed_bundle_tests):
|
sequence_in_shift[start_shift_pos] += 1
|
||||||
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:
|
remaining_test_minutes = test_minutes
|
||||||
window_placed_test_ids.update(test_key[0] for test_key in bundle.test_ids)
|
consume_shift_pos = start_shift_pos
|
||||||
|
while remaining_test_minutes > 0 and consume_shift_pos < len(window.shifts):
|
||||||
|
consumed_minutes = min(shift_remaining[consume_shift_pos], remaining_test_minutes)
|
||||||
|
shift_remaining[consume_shift_pos] -= consumed_minutes
|
||||||
|
remaining_test_minutes -= consumed_minutes
|
||||||
|
consume_shift_pos += 1
|
||||||
|
|
||||||
return entries, placed_test_ids, last_date
|
window.remaining_minutes -= test_minutes
|
||||||
|
|
||||||
def _create_bundles(
|
while current_shift_pos < len(window.shifts) and shift_remaining[current_shift_pos] <= 0:
|
||||||
dut_active_test_ids: set[TestKey],
|
current_shift_pos += 1
|
||||||
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:
|
self.schedule.append(
|
||||||
- P2P with COE pairs: [P2P, COE1, COE2, ...]
|
ScheduleEntry(
|
||||||
- P2P without COE: [P2P_RX, P2P_TX] if both active
|
test_id=test,
|
||||||
- COE without a linked active P2P: [COE] (standalone bundle)
|
device=bundle.device,
|
||||||
- P3P: individual test (not bundled)
|
scheduled_date=str(shift_day),
|
||||||
|
shift_index=shift_idx,
|
||||||
Returned list is sorted by (priority_tier, total_minutes) to place small/high-priority bundles first.
|
sequence_in_shift=sequence_in_shift[start_shift_pos],
|
||||||
"""
|
)
|
||||||
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:
|
|
||||||
bundle_test_ids: list[TestKey] = []
|
|
||||||
for device in (DUT, REF):
|
|
||||||
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:
|
|
||||||
bundle_test_ids: list[TestKey] = []
|
|
||||||
for device in (DUT, REF):
|
|
||||||
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)
|
|
||||||
|
|
||||||
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_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":
|
|
||||||
bundle_test_ids = [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 bundle_test_ids:
|
|
||||||
continue
|
|
||||||
# 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_P3P
|
|
||||||
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)
|
|
||||||
))
|
|
||||||
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:
|
def _get_test_minutes(self, test_id: str, device: str) -> int:
|
||||||
if "RX" in test_id:
|
if device == DUT and test_id in self.active_dut:
|
||||||
candidate = test_id.replace("RX", "TX", 1)
|
return self.active_dut[test_id].estimated_minutes
|
||||||
return candidate if candidate in active else None
|
if device == REF and test_id in self.active_ref:
|
||||||
if "TX" in test_id:
|
return self.active_ref[test_id].estimated_minutes
|
||||||
candidate = test_id.replace("TX", "RX", 1)
|
return 0
|
||||||
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 _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
|
|
||||||
|
|
||||||
# If holiday on a weekday, treat as weekend (all 3 shifts)
|
|
||||||
if is_holiday and weekday < 5:
|
|
||||||
shifts.append((start_date, 1))
|
|
||||||
shifts.append((start_date, 2))
|
|
||||||
shifts.append((start_date, 3))
|
|
||||||
return shifts
|
|
||||||
|
|
||||||
# Standard weekend day (Sat/Sun)
|
|
||||||
if weekday >= 5:
|
|
||||||
shifts.append((start_date, 1))
|
|
||||||
shifts.append((start_date, 2))
|
|
||||||
shifts.append((start_date, 3))
|
|
||||||
return shifts
|
|
||||||
|
|
||||||
# Friday: 4-day window (Fri-Mon)
|
|
||||||
if weekday == 4:
|
|
||||||
shifts.append((start_date, 3)) # Fri shift 3
|
|
||||||
|
|
||||||
sat = start_date + timedelta(days=1)
|
|
||||||
shifts.append((sat, 1)) # Sat shift 1
|
|
||||||
shifts.append((sat, 2)) # Sat shift 2
|
|
||||||
shifts.append((sat, 3)) # Sat shift 3
|
|
||||||
|
|
||||||
sun = sat + timedelta(days=1)
|
|
||||||
shifts.append((sun, 1)) # Sun shift 1
|
|
||||||
shifts.append((sun, 2)) # Sun shift 2
|
|
||||||
shifts.append((sun, 3)) # Sun shift 3
|
|
||||||
|
|
||||||
mon = sun + timedelta(days=1)
|
|
||||||
shifts.append((mon, 1)) # Mon shift 1
|
|
||||||
|
|
||||||
return shifts
|
|
||||||
|
|
||||||
# Monday-Thursday: 2-shift window
|
|
||||||
shifts.append((start_date, 3)) # Today shift 3
|
|
||||||
next_day = start_date + timedelta(days=1)
|
|
||||||
shifts.append((next_day, 1)) # Tomorrow shift 1
|
|
||||||
|
|
||||||
return shifts
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 []
|
||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -22,3 +22,5 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
/data
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import FailedBanner from './components/FailedBanner'
|
|||||||
import Calendar from './components/Calendar'
|
import Calendar from './components/Calendar'
|
||||||
import RightPanel from './components/RightPanel'
|
import RightPanel from './components/RightPanel'
|
||||||
import SettingsModal from './components/SettingsModal'
|
import SettingsModal from './components/SettingsModal'
|
||||||
|
import TestWindowDetailsPanel from './components/TestWindowDetailsPanel'
|
||||||
import { api, groupScheduleItems } from './api'
|
import { api, groupScheduleItems } from './api'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -36,6 +37,33 @@ function addDays(date, n) {
|
|||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseHolidayDates(raw = '') {
|
||||||
|
return new Set(
|
||||||
|
String(raw)
|
||||||
|
.split(',')
|
||||||
|
.map((token) => token.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOffDay(date, holidaySet) {
|
||||||
|
const weekday = date.getDay()
|
||||||
|
if (weekday === 0 || weekday === 6) return true
|
||||||
|
return holidaySet.has(toKey(date))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekendStartDatesForWeek(weekStart, holidaySet) {
|
||||||
|
const dates = []
|
||||||
|
for (let offset = 0; offset < 7; offset += 1) {
|
||||||
|
const current = addDays(weekStart, offset)
|
||||||
|
const nextDay = addDays(current, 1)
|
||||||
|
if (!isOffDay(current, holidaySet) && isOffDay(nextDay, holidaySet)) {
|
||||||
|
dates.push(toKey(current))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dates
|
||||||
|
}
|
||||||
|
|
||||||
function getTonightWindowKeys() {
|
function getTonightWindowKeys() {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const hour = now.getHours()
|
const hour = now.getHours()
|
||||||
@@ -99,17 +127,34 @@ const DEFAULT_SETTINGS = {
|
|||||||
smbUsername: '',
|
smbUsername: '',
|
||||||
smbPassword: '',
|
smbPassword: '',
|
||||||
smbDomain: '',
|
smbDomain: '',
|
||||||
p2pRuntimeMinutes: '',
|
dutP2pRuntimeMinutes: '',
|
||||||
coeRuntimeMinutes: '',
|
dutCoeRuntimeMinutes: '',
|
||||||
p3pRuntimeMinutes: '',
|
dutP3pRuntimeMinutes: '',
|
||||||
|
refP2pRuntimeMinutes: '',
|
||||||
|
refCoeRuntimeMinutes: '',
|
||||||
|
refP3pRuntimeMinutes: '',
|
||||||
testExclusion: '',
|
testExclusion: '',
|
||||||
holidays: '',
|
holidays: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeSettings(saved = {}) {
|
function sanitizeSettings(saved = {}) {
|
||||||
const { startDateOverride: _ignored, ...rest } = saved
|
const {
|
||||||
|
p2pRuntimeMinutes,
|
||||||
|
coeRuntimeMinutes,
|
||||||
|
p3pRuntimeMinutes,
|
||||||
|
...rest
|
||||||
|
} = saved
|
||||||
|
|
||||||
|
delete rest.startDateOverride
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
|
dutP2pRuntimeMinutes: rest.dutP2pRuntimeMinutes ?? p2pRuntimeMinutes ?? '',
|
||||||
|
dutCoeRuntimeMinutes: rest.dutCoeRuntimeMinutes ?? coeRuntimeMinutes ?? '',
|
||||||
|
dutP3pRuntimeMinutes: rest.dutP3pRuntimeMinutes ?? p3pRuntimeMinutes ?? '',
|
||||||
|
refP2pRuntimeMinutes: rest.refP2pRuntimeMinutes ?? p2pRuntimeMinutes ?? '',
|
||||||
|
refCoeRuntimeMinutes: rest.refCoeRuntimeMinutes ?? coeRuntimeMinutes ?? '',
|
||||||
|
refP3pRuntimeMinutes: rest.refP3pRuntimeMinutes ?? p3pRuntimeMinutes ?? '',
|
||||||
...rest,
|
...rest,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,23 +164,49 @@ function sanitizeSettings(saved = {}) {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
const [settings, setSettings] = useState(DEFAULT_SETTINGS)
|
const [settings, setSettings] = useState(DEFAULT_SETTINGS)
|
||||||
const [daytimeEnabled, setDaytimeEnabled] = useState(false)
|
const [dualDeviceWeekendWeekSelections, setDualDeviceWeekendWeekSelections] = useState({})
|
||||||
const [topPriority, setTopPriority] = useState('')
|
const [topPriority, setTopPriority] = useState('')
|
||||||
const [lowestPriority, setLowestPriority] = useState('')
|
const [lowestPriority, setLowestPriority] = useState('')
|
||||||
const [startDateOverride, setStartDateOverride] = useState('')
|
const [startDateOverride, setStartDateOverride] = useState('')
|
||||||
const [failedTests, setFailedTests] = useState([])
|
const [failedTests, setFailedTests] = useState([])
|
||||||
const [scheduleData, setScheduleData] = useState({})
|
const [scheduleData, setScheduleData] = useState({})
|
||||||
|
const [scheduleWindows, setScheduleWindows] = useState([])
|
||||||
|
const [selectedWindowId, setSelectedWindowId] = useState(null)
|
||||||
const [completionDate, setCompletionDate] = useState(null)
|
const [completionDate, setCompletionDate] = useState(null)
|
||||||
const [weekStart, setWeekStart] = useState(() => getMondayOfWeek(new Date()))
|
const [weekStart, setWeekStart] = useState(() => getMondayOfWeek(new Date()))
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const tonightConfigRows = useMemo(() => toTonightConfigRows(scheduleData), [scheduleData])
|
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],
|
||||||
|
)
|
||||||
|
const holidaySet = useMemo(() => parseHolidayDates(settings.holidays), [settings.holidays])
|
||||||
|
const activeWeekKey = useMemo(() => toKey(weekStart), [weekStart])
|
||||||
|
const dualDeviceWeekendWeekEnabled = Boolean(dualDeviceWeekendWeekSelections[activeWeekKey])
|
||||||
|
const dualDeviceWindowStartDates = useMemo(() => {
|
||||||
|
if (!dualDeviceWeekendWeekEnabled) return []
|
||||||
|
return getWeekendStartDatesForWeek(weekStart, holidaySet)
|
||||||
|
}, [dualDeviceWeekendWeekEnabled, weekStart, holidaySet])
|
||||||
|
|
||||||
// Fetch schedule for the given weekStart (Monday)
|
// Fetch schedule for the given weekStart (Monday)
|
||||||
const fetchSchedule = useCallback(async (start) => {
|
const fetchSchedule = useCallback(async (start) => {
|
||||||
try {
|
try {
|
||||||
const data = await api.getScheduleWeek(toKey(start))
|
const data = await api.getScheduleWeek(toKey(start))
|
||||||
setScheduleData(groupScheduleItems(data.items))
|
setScheduleData(groupScheduleItems(data.items))
|
||||||
|
setScheduleWindows(data.windows ?? [])
|
||||||
|
setCompletionDate(data.completion_date ?? null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch schedule:', e)
|
console.error('Failed to fetch schedule:', e)
|
||||||
}
|
}
|
||||||
@@ -226,7 +297,7 @@ export default function App() {
|
|||||||
const effectiveStartDate = startDateOverride.trim()
|
const effectiveStartDate = startDateOverride.trim()
|
||||||
const result = await api.compileSchedule({
|
const result = await api.compileSchedule({
|
||||||
start_date: effectiveStartDate || null,
|
start_date: effectiveStartDate || null,
|
||||||
daytime_testing_today: daytimeEnabled,
|
dual_device_weekend_start_dates: dualDeviceWindowStartDates,
|
||||||
top_priority_tests: topPriority.split(',').map(s => s.trim()).filter(Boolean),
|
top_priority_tests: topPriority.split(',').map(s => s.trim()).filter(Boolean),
|
||||||
lowest_priority_tests: lowestPriority.split(',').map(s => s.trim()).filter(Boolean),
|
lowest_priority_tests: lowestPriority.split(',').map(s => s.trim()).filter(Boolean),
|
||||||
rule: settings.testExclusion ?? '',
|
rule: settings.testExclusion ?? '',
|
||||||
@@ -254,20 +325,30 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRerunDecision(rerunDuringDay) {
|
function handleRerunDecision(_rerunDuringDay) {
|
||||||
const rerunIds = failedTests.map(t => t.test_id).join(', ')
|
|
||||||
setTopPriority(prev => {
|
setTopPriority(prev => {
|
||||||
const existing = prev.split(',').map(s => s.trim()).filter(Boolean)
|
const existing = prev.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
const incoming = failedTests.map(t => t.test_id)
|
const incoming = failedTests.map(t => t.test_id)
|
||||||
const merged = [...new Set([...existing, ...incoming])]
|
const merged = [...new Set([...existing, ...incoming])]
|
||||||
return merged.join(', ')
|
return merged.join(', ')
|
||||||
})
|
})
|
||||||
if (rerunDuringDay) {
|
|
||||||
setDaytimeEnabled(true)
|
|
||||||
}
|
|
||||||
setFailedTests([])
|
setFailedTests([])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedWindowId && !selectedWindow) {
|
||||||
|
setSelectedWindowId(null)
|
||||||
|
}
|
||||||
|
}, [selectedWindow, selectedWindowId])
|
||||||
|
|
||||||
|
function handleWindowSelect(windowDetails) {
|
||||||
|
setSelectedWindowId(windowDetails?.window_id ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloseWindowDetails() {
|
||||||
|
setSelectedWindowId(null)
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -286,13 +367,22 @@ export default function App() {
|
|||||||
onDecision={handleRerunDecision}
|
onDecision={handleRerunDecision}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="flex flex-1 gap-4 p-4 overflow-hidden">
|
<main className="relative flex flex-1 gap-4 p-4 overflow-hidden">
|
||||||
<div className="flex-1 min-w-0 flex flex-col">
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
<Calendar
|
<Calendar
|
||||||
scheduleData={scheduleData}
|
scheduleData={scheduleData}
|
||||||
daytimeDateKey={daytimeEnabled ? toKey(new Date()) : null}
|
daytimeDateKey={null}
|
||||||
weekStart={weekStart}
|
weekStart={weekStart}
|
||||||
onWeekChange={setWeekStart}
|
onWeekChange={setWeekStart}
|
||||||
|
dualDeviceWeekendWeekEnabled={dualDeviceWeekendWeekEnabled}
|
||||||
|
onDualDeviceWeekendWeekEnabledChange={(enabled) => {
|
||||||
|
setDualDeviceWeekendWeekSelections((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[activeWeekKey]: enabled,
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
windowLookup={windowLookup}
|
||||||
|
onWindowSelect={handleWindowSelect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -300,8 +390,6 @@ export default function App() {
|
|||||||
completionDate={completionDate}
|
completionDate={completionDate}
|
||||||
startDateOverride={startDateOverride}
|
startDateOverride={startDateOverride}
|
||||||
onStartDateOverrideChange={setStartDateOverride}
|
onStartDateOverrideChange={setStartDateOverride}
|
||||||
daytimeEnabled={daytimeEnabled}
|
|
||||||
onDaytimeEnabledChange={setDaytimeEnabled}
|
|
||||||
topPriority={topPriority}
|
topPriority={topPriority}
|
||||||
onTopPriorityChange={setTopPriority}
|
onTopPriorityChange={setTopPriority}
|
||||||
lowestPriority={lowestPriority}
|
lowestPriority={lowestPriority}
|
||||||
@@ -310,6 +398,12 @@ export default function App() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
tonightConfigRows={tonightConfigRows}
|
tonightConfigRows={tonightConfigRows}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TestWindowDetailsPanel
|
||||||
|
isOpen={Boolean(selectedWindow)}
|
||||||
|
windowDetails={selectedWindow}
|
||||||
|
onClose={handleCloseWindowDetails}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<SettingsModal
|
<SettingsModal
|
||||||
|
|||||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 403 KiB |
|
After Width: | Height: | Size: 423 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 423 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 419 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 13 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,4 +1,6 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
import DayColumn from './DayColumn'
|
import DayColumn from './DayColumn'
|
||||||
|
import { getDeviceAccentClass } from './TestCard'
|
||||||
|
|
||||||
function addDays(date, n) {
|
function addDays(date, n) {
|
||||||
const d = new Date(date)
|
const d = new Date(date)
|
||||||
@@ -47,7 +49,22 @@ function getNextTestWindow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// scheduleData: { "YYYY-MM-DD": { shift1: [...], shift2: [...], shift3: [...] }, ... }
|
// scheduleData: { "YYYY-MM-DD": { shift1: [...], shift2: [...], shift3: [...] }, ... }
|
||||||
export default function Calendar({ scheduleData = {}, daytimeDateKey = null, weekStart, onWeekChange }) {
|
export default function Calendar({
|
||||||
|
scheduleData = {},
|
||||||
|
daytimeDateKey = null,
|
||||||
|
weekStart,
|
||||||
|
onWeekChange,
|
||||||
|
dualDeviceWeekendWeekEnabled = false,
|
||||||
|
onDualDeviceWeekendWeekEnabledChange,
|
||||||
|
windowLookup = new Map(),
|
||||||
|
onWindowSelect,
|
||||||
|
}) {
|
||||||
|
const DEFAULT_SHIFT_MIN_ROWS = 3
|
||||||
|
const SLOT_CHROME_PX = 10
|
||||||
|
const TEST_CARD_ROW_HEIGHT_PX = 22
|
||||||
|
const TEST_CARD_ROW_GAP_PX = 2
|
||||||
|
const DAY_HEADER_HEIGHT_PX = 48
|
||||||
|
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
today.setHours(0, 0, 0, 0)
|
today.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
@@ -58,6 +75,52 @@ export default function Calendar({ scheduleData = {}, daytimeDateKey = null, wee
|
|||||||
// Reorder columns: Sun Mon Tue Wed Thu Fri Sat
|
// Reorder columns: Sun Mon Tue Wed Thu Fri Sat
|
||||||
// weekStart is Monday, so days[0]=Mon ... days[6]=Sun → put Sunday first
|
// weekStart is Monday, so days[0]=Mon ... days[6]=Sun → put Sunday first
|
||||||
const orderedDays = [ ...days.slice(0, 7)]
|
const orderedDays = [ ...days.slice(0, 7)]
|
||||||
|
const shiftMinRows = useMemo(() => {
|
||||||
|
const minima = {
|
||||||
|
shift2: DEFAULT_SHIFT_MIN_ROWS,
|
||||||
|
shift3: DEFAULT_SHIFT_MIN_ROWS,
|
||||||
|
nextDayShift1: DEFAULT_SHIFT_MIN_ROWS,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const date of orderedDays) {
|
||||||
|
const key = toDateKey(date)
|
||||||
|
const shifts = scheduleData[key] ?? { shift1: [], shift2: [], shift3: [] }
|
||||||
|
const nextKey = toDateKey(addDays(date, 1))
|
||||||
|
const nextDayShift1 = scheduleData[nextKey]?.shift1 ?? []
|
||||||
|
|
||||||
|
minima.shift2 = Math.max(minima.shift2, shifts.shift2.length || DEFAULT_SHIFT_MIN_ROWS)
|
||||||
|
minima.shift3 = Math.max(minima.shift3, shifts.shift3.length || DEFAULT_SHIFT_MIN_ROWS)
|
||||||
|
minima.nextDayShift1 = Math.max(minima.nextDayShift1, nextDayShift1.length || DEFAULT_SHIFT_MIN_ROWS)
|
||||||
|
}
|
||||||
|
|
||||||
|
return minima
|
||||||
|
}, [orderedDays, scheduleData])
|
||||||
|
const slotHeights = useMemo(() => {
|
||||||
|
const calcSlotHeight = (rows) => {
|
||||||
|
const safeRows = Math.max(DEFAULT_SHIFT_MIN_ROWS, rows || DEFAULT_SHIFT_MIN_ROWS)
|
||||||
|
const contentHeight = (safeRows * TEST_CARD_ROW_HEIGHT_PX) + ((safeRows - 1) * TEST_CARD_ROW_GAP_PX)
|
||||||
|
return contentHeight + SLOT_CHROME_PX
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shift2: calcSlotHeight(shiftMinRows.shift2),
|
||||||
|
shift3: calcSlotHeight(shiftMinRows.shift3),
|
||||||
|
nextDayShift1: calcSlotHeight(shiftMinRows.nextDayShift1),
|
||||||
|
}
|
||||||
|
}, [shiftMinRows])
|
||||||
|
const totalShiftHeightPx = slotHeights.shift2 + slotHeights.shift3 + slotHeights.nextDayShift1
|
||||||
|
const deviceLegendItems = Array.from(
|
||||||
|
new Set(
|
||||||
|
Object.values(scheduleData)
|
||||||
|
.flatMap((day) => [
|
||||||
|
...(day?.shift1 ?? []),
|
||||||
|
...(day?.shift2 ?? []),
|
||||||
|
...(day?.shift3 ?? []),
|
||||||
|
])
|
||||||
|
.map((test) => test?.device)
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
).sort((a, b) => String(a).localeCompare(String(b)))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 h-full">
|
<div className="flex flex-col gap-3 h-full">
|
||||||
@@ -91,19 +154,65 @@ export default function Calendar({ scheduleData = {}, daytimeDateKey = null, wee
|
|||||||
>
|
>
|
||||||
Today
|
Today
|
||||||
</button>
|
</button>
|
||||||
|
<div className="ml-auto flex items-center gap-2 text-xs text-gray-300 select-none">
|
||||||
|
<span>Run both devices on weekend this week</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={dualDeviceWeekendWeekEnabled}
|
||||||
|
onClick={() => onDualDeviceWeekendWeekEnabledChange?.(!dualDeviceWeekendWeekEnabled)}
|
||||||
|
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/70 ${
|
||||||
|
dualDeviceWeekendWeekEnabled ? 'bg-blue-600' : 'bg-gray-600'
|
||||||
|
}`}
|
||||||
|
title="Toggle dual-device weekend-start scheduling for this week"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform ${
|
||||||
|
dualDeviceWeekendWeekEnabled ? 'translate-x-4' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 7-day grid */}
|
{/* 7-day grid */}
|
||||||
|
<div className="flex gap-2 flex-1 min-h-0 overflow-hidden">
|
||||||
|
<div className="w-10 shrink-0 text-[10px] font-semibold uppercase tracking-wider text-gray-500">
|
||||||
|
<div style={{ height: `${DAY_HEADER_HEIGHT_PX}px` }} />
|
||||||
|
<div className="relative" style={{ height: `${totalShiftHeightPx}px` }}>
|
||||||
|
<span className="absolute left-0 top-0">9AM</span>
|
||||||
|
<span className="absolute left-0" style={{ top: `${slotHeights.shift2}px` }}>5PM</span>
|
||||||
|
<span className="absolute left-0" style={{ top: `${slotHeights.shift2 + slotHeights.shift3}px` }}>12AM</span>
|
||||||
|
<span
|
||||||
|
className="absolute right-2 text-[10px] font-bold uppercase tracking-[0.16em] text-gray-600"
|
||||||
|
style={{
|
||||||
|
top: `${slotHeights.shift2 + slotHeights.shift3 + (slotHeights.nextDayShift1 / 2)}px`,
|
||||||
|
transform: 'translateY(-50%) rotate(180deg)',
|
||||||
|
writingMode: 'vertical-rl',
|
||||||
|
textOrientation: 'mixed',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next Day
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1.5 flex-1 overflow-x-auto">
|
<div className="flex gap-1.5 flex-1 overflow-x-auto">
|
||||||
{orderedDays.map((date) => {
|
{orderedDays.map((date) => {
|
||||||
const key = toDateKey(date)
|
const key = toDateKey(date)
|
||||||
const shifts = scheduleData[key] ?? { shift1: [], shift2: [], shift3: [] }
|
const shifts = scheduleData[key] ?? { shift1: [], shift2: [], shift3: [] }
|
||||||
const isToday = date.getTime() === today.getTime()
|
const isToday = date.getTime() === today.getTime()
|
||||||
const isWeekend = date.getDay() === 0 || date.getDay() === 6
|
const isWeekend = date.getDay() === 0 || date.getDay() === 6
|
||||||
|
// Get next day's shift1 for display
|
||||||
|
const nextDate = addDays(date, 1)
|
||||||
|
const nextKey = toDateKey(nextDate)
|
||||||
|
const nextDayShift1 = scheduleData[nextKey]?.shift1 ?? []
|
||||||
// Which shifts on this date are part of the active test window?
|
// Which shifts on this date are part of the active test window?
|
||||||
const activeShifts = new Set()
|
const activeShifts = new Set()
|
||||||
if (key === activeWindow.shift3Date) activeShifts.add(3)
|
if (key === activeWindow.shift3Date) activeShifts.add(3)
|
||||||
if (key === activeWindow.shift1Date) activeShifts.add(1)
|
if (key === activeWindow.shift1Date) activeShifts.add(1)
|
||||||
|
// Check if next day's shift1 is active
|
||||||
|
const nextDayShift1Active = nextKey === activeWindow.shift1Date
|
||||||
return (
|
return (
|
||||||
<DayColumn
|
<DayColumn
|
||||||
key={key}
|
key={key}
|
||||||
@@ -111,17 +220,30 @@ export default function Calendar({ scheduleData = {}, daytimeDateKey = null, wee
|
|||||||
isToday={isToday}
|
isToday={isToday}
|
||||||
activeShifts={activeShifts}
|
activeShifts={activeShifts}
|
||||||
shifts={shifts}
|
shifts={shifts}
|
||||||
|
nextDayShift1={nextDayShift1}
|
||||||
|
nextDayShift1Active={nextDayShift1Active}
|
||||||
|
shiftMinRows={shiftMinRows}
|
||||||
|
slotHeights={slotHeights}
|
||||||
showShift2={isWeekend || (daytimeDateKey !== null && key === daytimeDateKey)}
|
showShift2={isWeekend || (daytimeDateKey !== null && key === daytimeDateKey)}
|
||||||
|
windowLookup={windowLookup}
|
||||||
|
onWindowSelect={onWindowSelect}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="flex gap-4 text-xs text-gray-400">
|
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs text-gray-400">
|
||||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-gray-500 inline-block" />Pending</span>
|
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-gray-500 inline-block" />Pending</span>
|
||||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-green-700 inline-block" />Completed</span>
|
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-green-700 inline-block" />Completed</span>
|
||||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-red-700 inline-block" />Rerun Required</span>
|
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-red-700 inline-block" />Rerun Required</span>
|
||||||
|
{deviceLegendItems.map((device) => (
|
||||||
|
<span key={device} className="flex items-center gap-1.5">
|
||||||
|
<span className={`w-2.5 h-2.5 rounded-sm inline-block ${getDeviceAccentClass(device)}`} />
|
||||||
|
{device}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,20 +2,39 @@ import ShiftSlot from './ShiftSlot'
|
|||||||
|
|
||||||
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||||
|
|
||||||
export default function DayColumn({ date, isToday, activeShifts = new Set(), shifts = {}, showShift2 = false }) {
|
export default function DayColumn({
|
||||||
|
date,
|
||||||
|
isToday,
|
||||||
|
activeShifts = new Set(),
|
||||||
|
shifts = {},
|
||||||
|
nextDayShift1 = [],
|
||||||
|
nextDayShift1Active = false,
|
||||||
|
showShift2 = false,
|
||||||
|
shiftMinRows = { shift2: 1, shift3: 1, nextDayShift1: 1 },
|
||||||
|
slotHeights = { shift2: 0, shift3: 0, nextDayShift1: 0 },
|
||||||
|
windowLookup = new Map(),
|
||||||
|
onWindowSelect,
|
||||||
|
}) {
|
||||||
const dayName = DAY_NAMES[date.getDay()]
|
const dayName = DAY_NAMES[date.getDay()]
|
||||||
const dayNum = date.getDate()
|
const dayNum = date.getDate()
|
||||||
const isWeekend = date.getDay() === 0 || date.getDay() === 6
|
const dateKey = date.toISOString().slice(0, 10)
|
||||||
const hasActive = activeShifts.size > 0
|
const nextDateKey = new Date(date.getTime() + (24 * 60 * 60 * 1000)).toISOString().slice(0, 10)
|
||||||
|
|
||||||
// Shift 2 is visible if: weekend, holiday (showShift2), or daytime testing on
|
function getWindowForShift(shiftIndex) {
|
||||||
const shift2Visible = isWeekend || showShift2
|
return windowLookup.get(`${dateKey}::shift${shiftIndex}`) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextDayShift1Window() {
|
||||||
|
return windowLookup.get(`${nextDateKey}::shift1`) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const shift2Visible = showShift2 || shifts.shift2.length > 0 || Boolean(getWindowForShift(2))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-w-0 flex-1 rounded-lg border border-gray-700 bg-gray-800/40">
|
<div className="flex flex-col min-w-0 flex-1 rounded-lg border border-gray-700 bg-gray-800/40">
|
||||||
{/* Day header — subtle today ring, no full-column highlight */}
|
{/* Day header — subtle today ring, no full-column highlight */}
|
||||||
<div
|
<div
|
||||||
className={`text-center py-1.5 rounded-t-lg ${
|
className={`h-12 flex flex-col justify-center text-center py-1.5 rounded-t-lg ${
|
||||||
isToday ? 'bg-gray-600 text-white' : 'bg-gray-700/60 text-gray-300'
|
isToday ? 'bg-gray-600 text-white' : 'bg-gray-700/60 text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -28,10 +47,34 @@ export default function DayColumn({ date, isToday, activeShifts = new Set(), shi
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shifts */}
|
{/* Shifts */}
|
||||||
<div className="flex flex-col flex-1 px-0.5 py-1">
|
<div className="flex flex-col flex-1 px-0.5">
|
||||||
<ShiftSlot label="12AM–9AM" tests={shifts.shift1} visible={true} active={activeShifts.has(1)} />
|
<ShiftSlot
|
||||||
<ShiftSlot label="9AM–5PM" tests={shifts.shift2} visible={true} active={activeShifts.has(2)} />
|
tests={shifts.shift2}
|
||||||
<ShiftSlot label="5PM–12AM" tests={shifts.shift3} visible={true} active={activeShifts.has(3)} />
|
visible={true}
|
||||||
|
active={activeShifts.has(2)}
|
||||||
|
windowDetails={getWindowForShift(2)}
|
||||||
|
minContentRows={shiftMinRows.shift2}
|
||||||
|
slotHeightPx={slotHeights.shift2}
|
||||||
|
onWindowSelect={onWindowSelect}
|
||||||
|
/>
|
||||||
|
<ShiftSlot
|
||||||
|
tests={shifts.shift3}
|
||||||
|
visible={true}
|
||||||
|
active={activeShifts.has(3)}
|
||||||
|
windowDetails={getWindowForShift(3)}
|
||||||
|
minContentRows={shiftMinRows.shift3}
|
||||||
|
slotHeightPx={slotHeights.shift3}
|
||||||
|
onWindowSelect={onWindowSelect}
|
||||||
|
/>
|
||||||
|
<ShiftSlot
|
||||||
|
tests={nextDayShift1}
|
||||||
|
visible={true}
|
||||||
|
active={nextDayShift1Active}
|
||||||
|
windowDetails={getNextDayShift1Window()}
|
||||||
|
minContentRows={shiftMinRows.nextDayShift1}
|
||||||
|
slotHeightPx={slotHeights.nextDayShift1}
|
||||||
|
onWindowSelect={onWindowSelect}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export default function Header({ onOpenSettings }) {
|
|||||||
return (
|
return (
|
||||||
<header className="flex items-center justify-between px-6 py-3 bg-gray-900 border-b border-gray-700 shrink-0">
|
<header className="flex items-center justify-between px-6 py-3 bg-gray-900 border-b border-gray-700 shrink-0">
|
||||||
<h1 className="text-white font-semibold text-lg tracking-wide">
|
<h1 className="text-white font-semibold text-lg tracking-wide">
|
||||||
CGW453 Scheduler
|
NJTH Scheduler
|
||||||
</h1>
|
</h1>
|
||||||
<button
|
<button
|
||||||
onClick={onOpenSettings}
|
onClick={onOpenSettings}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import cgw453Image from '../assets/CGW453.PNG'
|
||||||
|
|
||||||
function formatCompletionDate(value) {
|
function formatCompletionDate(value) {
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
|
|
||||||
@@ -16,8 +18,6 @@ export default function RightPanel({
|
|||||||
completionDate,
|
completionDate,
|
||||||
startDateOverride,
|
startDateOverride,
|
||||||
onStartDateOverrideChange,
|
onStartDateOverrideChange,
|
||||||
daytimeEnabled,
|
|
||||||
onDaytimeEnabledChange,
|
|
||||||
topPriority,
|
topPriority,
|
||||||
onTopPriorityChange,
|
onTopPriorityChange,
|
||||||
lowestPriority,
|
lowestPriority,
|
||||||
@@ -30,6 +30,11 @@ export default function RightPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex flex-col gap-4 w-60 shrink-0 pt-8">
|
<aside className="flex flex-col gap-4 w-60 shrink-0 pt-8">
|
||||||
|
{/* Device Image */}
|
||||||
|
<div className="flex justify-center mb-2">
|
||||||
|
<img src={cgw453Image} alt="CGW453 Device" className="h-32 object-contain" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Estimated Completion */}
|
{/* Estimated Completion */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1">
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1">
|
||||||
@@ -40,29 +45,6 @@ export default function RightPanel({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Daytime testing toggle */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-500">
|
|
||||||
Day Time Testing (Today)
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
role="switch"
|
|
||||||
aria-checked={daytimeEnabled}
|
|
||||||
onClick={() => onDaytimeEnabledChange(!daytimeEnabled)}
|
|
||||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
|
|
||||||
daytimeEnabled ? 'bg-blue-600' : 'bg-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform ${
|
|
||||||
daytimeEnabled ? 'translate-x-4' : 'translate-x-0'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Top priority */}
|
{/* Top priority */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1.5">
|
<label className="block text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1.5">
|
||||||
@@ -97,35 +79,6 @@ export default function RightPanel({
|
|||||||
>
|
>
|
||||||
{loading ? 'Working…' : 'Remake Schedule'}
|
{loading ? 'Working…' : 'Remake Schedule'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Config for tonight */}
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1.5">
|
|
||||||
Config for Today
|
|
||||||
</p>
|
|
||||||
{tonightConfigRows.length === 0 ? (
|
|
||||||
<p className="text-xs text-gray-500">No STA placement mapping available for tonight.</p>
|
|
||||||
) : (
|
|
||||||
<div className="max-h-52 overflow-y-auto rounded border border-gray-700">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead className="bg-gray-900 sticky top-0">
|
|
||||||
<tr>
|
|
||||||
<th className="text-left text-gray-400 font-semibold px-2 py-1.5 border-b border-gray-700">STA</th>
|
|
||||||
<th className="text-left text-gray-400 font-semibold px-2 py-1.5 border-b border-gray-700">Testpoint</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{tonightConfigRows.map((row) => (
|
|
||||||
<tr key={row.sta} className="odd:bg-gray-950 even:bg-gray-900/70">
|
|
||||||
<td className="text-gray-200 font-semibold px-2 py-1.5 border-b border-gray-800">{row.sta}</td>
|
|
||||||
<td className="text-gray-300 px-2 py-1.5 border-b border-gray-800">{row.testPoints.join(', ')}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,9 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
|
|||||||
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
|
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
|
||||||
Runtime Overrides
|
Runtime Overrides
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-300 mb-2">DUT</p>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
<Field label="P2P Minutes">
|
<Field label="P2P Minutes">
|
||||||
<input
|
<input
|
||||||
@@ -160,8 +163,8 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
|
|||||||
step="1"
|
step="1"
|
||||||
className={INPUT_CLS}
|
className={INPUT_CLS}
|
||||||
placeholder="80"
|
placeholder="80"
|
||||||
value={form.p2pRuntimeMinutes ?? ''}
|
value={form.dutP2pRuntimeMinutes ?? ''}
|
||||||
onChange={(e) => set('p2pRuntimeMinutes', e.target.value)}
|
onChange={(e) => set('dutP2pRuntimeMinutes', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="COE Minutes">
|
<Field label="COE Minutes">
|
||||||
@@ -171,8 +174,8 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
|
|||||||
step="1"
|
step="1"
|
||||||
className={INPUT_CLS}
|
className={INPUT_CLS}
|
||||||
placeholder="115"
|
placeholder="115"
|
||||||
value={form.coeRuntimeMinutes ?? ''}
|
value={form.dutCoeRuntimeMinutes ?? ''}
|
||||||
onChange={(e) => set('coeRuntimeMinutes', e.target.value)}
|
onChange={(e) => set('dutCoeRuntimeMinutes', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="P3P Minutes">
|
<Field label="P3P Minutes">
|
||||||
@@ -182,13 +185,54 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
|
|||||||
step="1"
|
step="1"
|
||||||
className={INPUT_CLS}
|
className={INPUT_CLS}
|
||||||
placeholder="105"
|
placeholder="105"
|
||||||
value={form.p3pRuntimeMinutes ?? ''}
|
value={form.dutP3pRuntimeMinutes ?? ''}
|
||||||
onChange={(e) => set('p3pRuntimeMinutes', e.target.value)}
|
onChange={(e) => set('dutP3pRuntimeMinutes', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-300 mb-2">REF</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<Field label="P2P Minutes">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
className={INPUT_CLS}
|
||||||
|
placeholder="80"
|
||||||
|
value={form.refP2pRuntimeMinutes ?? ''}
|
||||||
|
onChange={(e) => set('refP2pRuntimeMinutes', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="COE Minutes">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
className={INPUT_CLS}
|
||||||
|
placeholder="115"
|
||||||
|
value={form.refCoeRuntimeMinutes ?? ''}
|
||||||
|
onChange={(e) => set('refCoeRuntimeMinutes', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="P3P Minutes">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
className={INPUT_CLS}
|
||||||
|
placeholder="105"
|
||||||
|
value={form.refP3pRuntimeMinutes ?? ''}
|
||||||
|
onChange={(e) => set('refP3pRuntimeMinutes', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr className="border-gray-700" />
|
<hr className="border-gray-700" />
|
||||||
|
|
||||||
{/* Section: Test Exclusion */}
|
{/* Section: Test Exclusion */}
|
||||||
@@ -196,12 +240,12 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
|
|||||||
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
|
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
|
||||||
Test Exclusion
|
Test Exclusion
|
||||||
</p>
|
</p>
|
||||||
<Field label="Excluded Test IDs" hint="(comma-separated)">
|
<Field label="Exclusions" hint="(comma-separated)">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className={INPUT_CLS}
|
className={INPUT_CLS}
|
||||||
placeholder="P2PRXAX001, COERXBE002…"
|
placeholder="SP, BW320,..."
|
||||||
value={form.testExclusion ?? ''}
|
value={form.testExclusion?? ''}
|
||||||
onChange={(e) => set('testExclusion', e.target.value)}
|
onChange={(e) => set('testExclusion', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -215,9 +259,9 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
|
|||||||
Holidays
|
Holidays
|
||||||
</p>
|
</p>
|
||||||
<Field label="Holiday Dates" hint="(comma-separated, YYYY-MM-DD)">
|
<Field label="Holiday Dates" hint="(comma-separated, YYYY-MM-DD)">
|
||||||
<input
|
<textarea
|
||||||
type="text"
|
|
||||||
className={INPUT_CLS}
|
className={INPUT_CLS}
|
||||||
|
rows={4}
|
||||||
placeholder="2026-07-04, 2026-12-25…"
|
placeholder="2026-07-04, 2026-12-25…"
|
||||||
value={form.holidays ?? ''}
|
value={form.holidays ?? ''}
|
||||||
onChange={(e) => set('holidays', e.target.value)}
|
onChange={(e) => set('holidays', e.target.value)}
|
||||||
|
|||||||
@@ -1,22 +1,53 @@
|
|||||||
import TestCard from './TestCard'
|
import TestCard from './TestCard'
|
||||||
|
|
||||||
export default function ShiftSlot({ label, tests = [], visible = true, active = false }) {
|
const TEST_CARD_ROW_HEIGHT_PX = 22
|
||||||
|
const TEST_CARD_ROW_GAP_PX = 2
|
||||||
|
|
||||||
|
export default function ShiftSlot({
|
||||||
|
tests = [],
|
||||||
|
visible = true,
|
||||||
|
active = false,
|
||||||
|
windowDetails = null,
|
||||||
|
minContentRows = 3,
|
||||||
|
slotHeightPx = null,
|
||||||
|
onWindowSelect,
|
||||||
|
}) {
|
||||||
if (!visible) return null
|
if (!visible) return null
|
||||||
|
|
||||||
|
const clickable = Boolean(windowDetails && onWindowSelect)
|
||||||
|
const targetRows = Math.max(1, minContentRows)
|
||||||
|
const minContentHeightPx = (targetRows * TEST_CARD_ROW_HEIGHT_PX) + ((targetRows - 1) * TEST_CARD_ROW_GAP_PX)
|
||||||
|
const outerMinHeightPx = Number.isFinite(slotHeightPx) && slotHeightPx > 0
|
||||||
|
? slotHeightPx
|
||||||
|
: minContentHeightPx + 10
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`border-t pt-1 pb-1.5 ${
|
<div
|
||||||
|
role={clickable ? 'button' : undefined}
|
||||||
|
tabIndex={clickable ? 0 : undefined}
|
||||||
|
onClick={clickable ? () => onWindowSelect(windowDetails) : undefined}
|
||||||
|
onKeyDown={clickable ? (event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
onWindowSelect(windowDetails)
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
|
style={{ minHeight: `${outerMinHeightPx}px` }}
|
||||||
|
className={`border-t pt-1 pb-1.5 transition-colors ${
|
||||||
active ? 'border-blue-500/50 bg-blue-950/20 rounded' : 'border-gray-700/60'
|
active ? 'border-blue-500/50 bg-blue-950/20 rounded' : 'border-gray-700/60'
|
||||||
}`}>
|
} ${clickable ? 'cursor-pointer hover:bg-gray-700/25 focus:outline-none focus:ring-1 focus:ring-cyan-400/70' : ''}`}
|
||||||
<p className={`text-[10px] font-semibold uppercase tracking-wider mb-1 px-1 ${
|
>
|
||||||
active ? 'text-blue-400' : 'text-gray-500'
|
<div
|
||||||
}`}>
|
className="flex flex-col gap-0.5 px-1 min-h-4"
|
||||||
{label}
|
style={{ minHeight: `${minContentHeightPx}px` }}
|
||||||
</p>
|
>
|
||||||
<div className="flex flex-col gap-0.5 px-1 min-h-4">
|
{tests.length === 0 ? null : (
|
||||||
{tests.length === 0 ? (
|
tests.map((test, index) => (
|
||||||
<span className="text-[10px] text-gray-600 italic">—</span>
|
<TestCard
|
||||||
) : (
|
key={`${test.test_id}-${test.device}-${test.scheduled_date ?? 'na'}-${test.shift_index ?? 'na'}-${test.sequence_in_shift ?? index}`}
|
||||||
tests.map((test) => <TestCard key={`${test.test_id}-${test.device}`} test={test} />)
|
test={test}
|
||||||
|
/>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,33 @@
|
|||||||
|
import { useRef, useEffect, useState } from 'react'
|
||||||
|
|
||||||
const STATUS_STYLES = {
|
const STATUS_STYLES = {
|
||||||
pending: 'bg-gray-600/60 text-gray-200 border-gray-500',
|
pending: 'bg-gray-600/60 text-gray-200 border-gray-500',
|
||||||
completed: 'bg-green-800/60 text-green-200 border-green-600',
|
completed: 'bg-green-800/60 text-green-200 border-green-600',
|
||||||
rerun: 'bg-red-800/60 text-red-200 border-red-600',
|
rerun: 'bg-red-800/60 text-red-200 border-red-600',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DEVICE_ACCENT_CLASSES = [
|
||||||
|
'bg-sky-400',
|
||||||
|
'bg-emerald-400',
|
||||||
|
'bg-amber-400',
|
||||||
|
'bg-rose-400',
|
||||||
|
'bg-cyan-400',
|
||||||
|
'bg-lime-400',
|
||||||
|
'bg-orange-400',
|
||||||
|
'bg-fuchsia-400',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getDeviceAccentClass(device) {
|
||||||
|
if (!device) return 'bg-gray-500'
|
||||||
|
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < device.length; i += 1) {
|
||||||
|
hash = (hash * 31 + device.charCodeAt(i)) >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEVICE_ACCENT_CLASSES[hash % DEVICE_ACCENT_CLASSES.length]
|
||||||
|
}
|
||||||
|
|
||||||
function formatConfig(config) {
|
function formatConfig(config) {
|
||||||
if (!config || typeof config !== 'object') return '—'
|
if (!config || typeof config !== 'object') return '—'
|
||||||
const entries = Object.entries(config)
|
const entries = Object.entries(config)
|
||||||
@@ -24,24 +48,72 @@ function formatConfig(config) {
|
|||||||
|
|
||||||
export default function TestCard({ test }) {
|
export default function TestCard({ test }) {
|
||||||
const style = STATUS_STYLES[test.status] ?? STATUS_STYLES.pending
|
const style = STATUS_STYLES[test.status] ?? STATUS_STYLES.pending
|
||||||
|
const deviceAccentClass = getDeviceAccentClass(test.device)
|
||||||
|
const cardRef = useRef(null)
|
||||||
|
const [tooltipPos, setTooltipPos] = useState({ top: '0px', left: '0px' })
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cardElement = cardRef.current
|
||||||
|
if (!cardElement) return
|
||||||
|
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
// Position tooltip near the cursor, with 10px offset
|
||||||
|
const top = e.clientY + 10
|
||||||
|
const left = e.clientX + 10
|
||||||
|
|
||||||
|
setTooltipPos({
|
||||||
|
top: `${top}px`,
|
||||||
|
left: `${left}px`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
setIsHovered(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setIsHovered(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
cardElement.addEventListener('mouseenter', handleMouseEnter)
|
||||||
|
cardElement.addEventListener('mouseleave', handleMouseLeave)
|
||||||
|
cardElement.addEventListener('mousemove', handleMouseMove)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cardElement.removeEventListener('mouseenter', handleMouseEnter)
|
||||||
|
cardElement.removeEventListener('mouseleave', handleMouseLeave)
|
||||||
|
cardElement.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative group">
|
<div ref={cardRef} className="relative">
|
||||||
<div
|
<div
|
||||||
className={`px-1.5 py-0.5 rounded border text-xs font-mono truncate cursor-default select-none ${style}`}
|
className={`relative px-1.5 py-0.5 pl-3 rounded border text-xs font-mono truncate cursor-default select-none ${style}`}
|
||||||
style={{ maxWidth: '100%' }}
|
style={{ maxWidth: '100%' }}
|
||||||
>
|
>
|
||||||
|
<span className={`absolute inset-y-0 left-0 w-1 rounded-l ${deviceAccentClass}`} aria-hidden="true" />
|
||||||
{test.test_id}
|
{test.test_id}
|
||||||
</div>
|
</div>
|
||||||
{/* Hover tooltip */}
|
{/* Hover tooltip — follows cursor */}
|
||||||
<div className="absolute z-50 bottom-full left-0 mb-1 hidden group-hover:block min-w-max">
|
{isHovered && (
|
||||||
<div className="bg-gray-800 border border-gray-600 rounded-md px-2.5 py-1.5 text-xs text-gray-200 shadow-lg max-w-md">
|
<div className="fixed z-50 bg-gray-800 border border-gray-600 rounded-md px-2.5 py-1.5 text-xs text-gray-200 shadow-lg max-w-md pointer-events-none"
|
||||||
|
style={{
|
||||||
|
top: tooltipPos.top,
|
||||||
|
left: tooltipPos.left,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<p><span className="text-gray-400">Type:</span> {test.test_type ?? '—'}</p>
|
<p><span className="text-gray-400">Type:</span> {test.test_type ?? '—'}</p>
|
||||||
<p><span className="text-gray-400">Rotation:</span> {test.rotation ?? '—'}</p>
|
<p><span className="text-gray-400">Rotation:</span> {test.rotation ?? '—'}</p>
|
||||||
<p><span className="text-gray-400">Device:</span> {test.device ?? '—'}</p>
|
<p className="flex items-center gap-1.5">
|
||||||
|
<span className="text-gray-400">Device:</span>
|
||||||
|
<span className={`inline-block w-2 h-2 rounded-full ${deviceAccentClass}`} aria-hidden="true" />
|
||||||
|
<span>{test.device ?? '—'}</span>
|
||||||
|
</p>
|
||||||
<p className="mt-1 pt-1 border-t border-gray-600"><span className="text-gray-400">Config:</span> {formatConfig(test.config)}</p>
|
<p className="mt-1 pt-1 border-t border-gray-600"><span className="text-gray-400">Config:</span> {formatConfig(test.config)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { getDeviceAccentClass } from './TestCard'
|
||||||
|
|
||||||
|
const CONFIG_MAP_IMAGE_MODULES = import.meta.glob('../assets/TC*_map/*.png', {
|
||||||
|
eager: true,
|
||||||
|
import: 'default',
|
||||||
|
})
|
||||||
|
|
||||||
|
const STATUS_STYLES = {
|
||||||
|
pending: 'bg-gray-700 text-gray-200 border-gray-600',
|
||||||
|
completed: 'bg-green-900/60 text-green-200 border-green-700',
|
||||||
|
rerun: 'bg-red-900/60 text-red-200 border-red-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWindowHeader(windowDetails) {
|
||||||
|
if (!windowDetails?.start_at || !windowDetails?.end_at) return 'Window details'
|
||||||
|
|
||||||
|
const start = new Date(windowDetails.start_at)
|
||||||
|
const end = new Date(windowDetails.end_at)
|
||||||
|
|
||||||
|
const startLabel = start.toLocaleString('en-US', {
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
}).replace(':00', '').replace(' AM', 'am').replace(' PM', 'pm')
|
||||||
|
|
||||||
|
const endLabel = end.toLocaleString('en-US', {
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
}).replace(':00', '').replace(' AM', 'am').replace(' PM', 'pm')
|
||||||
|
|
||||||
|
return `${startLabel} - ${endLabel}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHours(minutes) {
|
||||||
|
if (!Number.isFinite(minutes) || minutes <= 0) return '0 hrs'
|
||||||
|
const hours = minutes / 60
|
||||||
|
const rounded = Number.isInteger(hours) ? String(hours) : hours.toFixed(1)
|
||||||
|
return `${rounded} hrs`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShiftLabel(test) {
|
||||||
|
const shiftLabels = {
|
||||||
|
1: '12AM–9AM',
|
||||||
|
2: '9AM–5PM',
|
||||||
|
3: '5PM–12AM',
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = test?.scheduled_date
|
||||||
|
const label = shiftLabels[test?.shift_index] ?? 'Unknown shift'
|
||||||
|
return date ? `${date} ${label}` : label
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWindowTestConfig(windowDetails) {
|
||||||
|
if (Array.isArray(windowDetails?.bundle_test_configs)) {
|
||||||
|
return windowDetails.bundle_test_configs
|
||||||
|
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tests = windowDetails?.tests ?? []
|
||||||
|
const allKeys = new Set()
|
||||||
|
for (const test of tests) {
|
||||||
|
if (Array.isArray(test?.bundle_test_configs)) {
|
||||||
|
for (const key of test.bundle_test_configs) {
|
||||||
|
if (typeof key === 'string' && key.trim()) {
|
||||||
|
allKeys.add(key.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof test?.test_config === 'string' && test.test_config.trim()) {
|
||||||
|
allKeys.add(test.test_config.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(allKeys).sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConfigEntries(config) {
|
||||||
|
if (!config || typeof config !== 'object') return []
|
||||||
|
|
||||||
|
return Object.entries(config)
|
||||||
|
.map(([band, data]) => {
|
||||||
|
if (!data || typeof data !== 'object') return null
|
||||||
|
const testPoint = data.test_point || data['Test Point'] || '—'
|
||||||
|
const sta = data.sta || data.STA || '—'
|
||||||
|
return { band, testPoint, sta }
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfigMapImages(configKey) {
|
||||||
|
if (!configKey) return []
|
||||||
|
|
||||||
|
const folderToken = `/${configKey.toUpperCase()}_map/`
|
||||||
|
return Object.entries(CONFIG_MAP_IMAGE_MODULES)
|
||||||
|
.filter(([path]) => path.includes(folderToken))
|
||||||
|
.sort(([pathA], [pathB]) => pathA.localeCompare(pathB, undefined, { numeric: true }))
|
||||||
|
.map(([, assetUrl]) => assetUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestRow({ test }) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const statusStyle = STATUS_STYLES[test.status] ?? STATUS_STYLES.pending
|
||||||
|
const configEntries = useMemo(() => formatConfigEntries(test.config), [test.config])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-700 bg-gray-900/70 overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((value) => !value)}
|
||||||
|
className="w-full px-4 py-3 text-left hover:bg-gray-800/80 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${getDeviceAccentClass(test.device)}`} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-white">{test.test_id}</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-400">{test.device ?? '—'} · {formatShiftLabel(test)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className={`px-2 py-1 rounded-full border text-[11px] font-semibold uppercase tracking-wide ${statusStyle}`}>
|
||||||
|
{test.status ?? 'pending'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">{Number.isFinite(test.estimated_minutes) && test.estimated_minutes > 0 ? `${test.estimated_minutes} min` : '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-4 pb-4 pt-1 border-t border-gray-800 bg-gray-950/60">
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-xs text-gray-300">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 uppercase tracking-wide text-[11px] mb-1">Type</p>
|
||||||
|
<p>{test.test_type ?? '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 uppercase tracking-wide text-[11px] mb-1">Rotation</p>
|
||||||
|
<p>{test.rotation ?? '—'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-gray-500 uppercase tracking-wide text-[11px] mb-2">Config</p>
|
||||||
|
{configEntries.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-500">No config details available.</p>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-gray-800 overflow-hidden">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-gray-900/90">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-3 py-2 font-semibold text-gray-400 border-b border-gray-800">Band</th>
|
||||||
|
<th className="text-left px-3 py-2 font-semibold text-gray-400 border-b border-gray-800">Testpoint</th>
|
||||||
|
<th className="text-left px-3 py-2 font-semibold text-gray-400 border-b border-gray-800">STA</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{configEntries.map((entry) => (
|
||||||
|
<tr key={`${test.test_id}-${entry.band}`} className="odd:bg-gray-950 even:bg-gray-900/60">
|
||||||
|
<td className="px-3 py-2 text-gray-200 border-b border-gray-800">{entry.band}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-300 border-b border-gray-800">{entry.testPoint}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-300 border-b border-gray-800">{entry.sta}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TestWindowDetailsPanel({ isOpen, windowDetails, onClose }) {
|
||||||
|
const [showConfigMap, setShowConfigMap] = useState(false)
|
||||||
|
const windowTests = useMemo(
|
||||||
|
() => (Array.isArray(windowDetails?.tests) ? windowDetails.tests : []),
|
||||||
|
[windowDetails],
|
||||||
|
)
|
||||||
|
const windowTestConfig = useMemo(() => getWindowTestConfig(windowDetails), [windowDetails])
|
||||||
|
const windowConfigMapKey = useMemo(
|
||||||
|
() => (windowTestConfig.length > 0 ? String(windowTestConfig[0]).toUpperCase() : null),
|
||||||
|
[windowTestConfig],
|
||||||
|
)
|
||||||
|
const configMapImages = useMemo(() => getConfigMapImages(windowConfigMapKey), [windowConfigMapKey])
|
||||||
|
const hasConfigMapImages = configMapImages.length > 0
|
||||||
|
const showMapPane = showConfigMap && hasConfigMapImages
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return undefined
|
||||||
|
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [isOpen, onClose])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 z-40 transition-opacity duration-300 ${
|
||||||
|
isOpen ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'
|
||||||
|
}`}
|
||||||
|
aria-hidden={!isOpen}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute inset-0 bg-gray-950/45 backdrop-blur-[1px]"
|
||||||
|
aria-label="Close test window details"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
className={`absolute inset-y-0 right-0 border-l border-gray-700 bg-gray-950/96 shadow-2xl transition-[width,transform] duration-300 ease-in-out ${
|
||||||
|
showMapPane ? 'w-full' : 'w-[30rem]'
|
||||||
|
} ${
|
||||||
|
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex h-full min-w-0 flex-col">
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b border-gray-800 px-5 py-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-cyan-400">Test Window</p>
|
||||||
|
<h2 className="mt-2 text-xl font-semibold text-white">
|
||||||
|
{windowDetails ? formatWindowHeader(windowDetails) : 'Window details'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-full border border-gray-700 p-2 text-gray-400 hover:border-gray-500 hover:text-white transition-colors"
|
||||||
|
aria-label="Close panel"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{windowDetails && (
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full min-w-0">
|
||||||
|
<div className={`${showMapPane ? 'w-[30rem] min-w-[30rem] border-r border-gray-800' : 'w-full'} px-5 py-5`}>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-2xl px-4 py-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">Available Run Time</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-white">{formatHours(windowDetails.available_runtime_minutes)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl px-4 py-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">Estimated Run Time</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-white">{formatHours(windowDetails.estimated_runtime_minutes)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 rounded-2xl border border-gray-800 bg-gray-900/80 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-semibold text-white">Config</p>
|
||||||
|
<span className="rounded-full border border-cyan-700/60 bg-cyan-900/30 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-cyan-200">
|
||||||
|
{windowTestConfig.length > 0 ? windowTestConfig.join(', ') : 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{windowDetails.configRows?.length ? (
|
||||||
|
<div className="mt-3 overflow-hidden rounded-xl border border-gray-800">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-gray-950/90">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-semibold text-gray-400 border-b border-gray-800">STA</th>
|
||||||
|
<th className="px-3 py-2 text-left font-semibold text-gray-400 border-b border-gray-800">Testpoint</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{windowDetails.configRows.map((row, index) => (
|
||||||
|
<tr key={`${row.sta}-${index}`} className="odd:bg-gray-950 even:bg-gray-900/60">
|
||||||
|
<td className="px-3 py-2 border-b border-gray-800 font-semibold text-gray-200">{row.sta}</td>
|
||||||
|
<td className="px-3 py-2 border-b border-gray-800 text-gray-300">{row.testPoints.join(', ')}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-xs text-gray-500">No STA placement mapping available for this window.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfigMap((value) => !value)}
|
||||||
|
disabled={!hasConfigMapImages}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border border-gray-700 bg-gray-900 px-3 py-1.5 text-xs font-semibold text-gray-200 hover:bg-gray-800 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span>{showMapPane ? 'Hide' : 'Show'} Config Map</span>
|
||||||
|
<span className="text-gray-400">{windowConfigMapKey ?? 'N/A'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!hasConfigMapImages && (
|
||||||
|
<p className="mt-2 text-xs text-gray-500">No config map images found for this window config.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="flex items-center justify-between gap-3 mb-3">
|
||||||
|
<p className="text-sm font-semibold text-white">Tests Scheduled In This Window</p>
|
||||||
|
<span className="text-xs text-gray-500">{windowTests.length} tests</span>
|
||||||
|
</div>
|
||||||
|
{windowTests.length ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{windowTests.map((test, index) => (
|
||||||
|
<TestRow
|
||||||
|
key={`${test.test_id}-${test.device}-${test.scheduled_date ?? 'na'}-${test.shift_index ?? 'na'}-${test.sequence_in_shift ?? index}`}
|
||||||
|
test={test}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-dashed border-gray-800 bg-gray-900/50 px-4 py-6 text-sm text-gray-500">
|
||||||
|
No tests are currently scheduled in this window.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showMapPane && (
|
||||||
|
<div className="min-w-0 flex-1 px-6 py-5">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-semibold text-white">Config Map</p>
|
||||||
|
<span className="rounded-full border border-cyan-700/60 bg-cyan-900/30 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-cyan-200">
|
||||||
|
{windowConfigMapKey}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{configMapImages.map((imageUrl, index) => (
|
||||||
|
<div key={`${windowConfigMapKey}-map-${index + 1}`} className="rounded-xl p-2">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={`${windowConfigMapKey} config map ${index + 1}`}
|
||||||
|
className="w-full max-h-[78vh] rounded-lg object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||