2026-06-16 15:07:59 -04:00
|
|
|
from __future__ import annotations
|
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from datetime import date, datetime, timedelta
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
import graph
|
|
|
|
|
|
|
|
|
|
# Bundle priority tiers for scheduling order (lower number = higher priority)
|
|
|
|
|
BUNDLE_PRIORITY_FAILED = 0 # Failed tests requiring rerun
|
|
|
|
|
BUNDLE_PRIORITY_P2P_WITH_COE = 1 # P2P tests with COE pairs
|
|
|
|
|
BUNDLE_PRIORITY_P2P_ONLY = 2 # P2P tests without COE pairs (RX/TX bundled)
|
|
|
|
|
BUNDLE_PRIORITY_COE_ONLY = 3 # COE tests without P2P pairing (should be rare/unschedulable)
|
|
|
|
|
BUNDLE_PRIORITY_P3P = 4 # P3P tests
|
|
|
|
|
|
|
|
|
|
DUT = os.getenv("DUT", "CGW453").strip()
|
|
|
|
|
REF = os.getenv("REF", "CGW452").strip()
|
|
|
|
|
TestKey = tuple[str, str]
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class SchedulerTest:
|
|
|
|
|
test_id: str
|
|
|
|
|
device: str
|
|
|
|
|
test_type: str
|
|
|
|
|
rotation: str | None
|
|
|
|
|
rx_tx: str | None
|
|
|
|
|
has_coe_pair: bool
|
|
|
|
|
coe_pairing: list[str]
|
|
|
|
|
config: dict[str, dict[str, str | None]]
|
2026-06-16 16:09:54 -04:00
|
|
|
throttled: bool
|
2026-06-16 15:07:59 -04:00
|
|
|
estimated_minutes: int
|
|
|
|
|
priority: int
|
|
|
|
|
raw_payload: dict[str, Any]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class ScheduleEntry:
|
|
|
|
|
test_id: str
|
|
|
|
|
device: str
|
|
|
|
|
scheduled_date: str
|
|
|
|
|
shift_index: int
|
|
|
|
|
sequence_in_shift: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class TestBundle:
|
|
|
|
|
"""A bundle of tests that must be run together in sequence."""
|
|
|
|
|
test_ids: list[TestKey]
|
|
|
|
|
priority_tier: int
|
|
|
|
|
total_minutes: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Global scheduler state. Graph is built once per loaded test set.
|
|
|
|
|
_TESTS_BY_ID: dict[TestKey, SchedulerTest] = {}
|
|
|
|
|
_ACTIVE_DUT: dict[TestKey, int] = {}
|
|
|
|
|
_ACTIVE_REF: dict[TestKey, int] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def reset_scheduler_state() -> None:
|
|
|
|
|
global _TESTS_BY_ID, _ACTIVE_DUT, _ACTIVE_REF
|
|
|
|
|
_TESTS_BY_ID = {}
|
|
|
|
|
_ACTIVE_DUT = {}
|
|
|
|
|
_ACTIVE_REF = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def initialize_scheduler_state(tests: list[SchedulerTest]) -> None:
|
|
|
|
|
"""Initialize active scheduler state once for the current loaded schedulable dataset."""
|
|
|
|
|
global _TESTS_BY_ID, _ACTIVE_DUT, _ACTIVE_REF
|
|
|
|
|
if _TESTS_BY_ID:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
_TESTS_BY_ID = {(t.test_id, t.device): t for t in tests}
|
|
|
|
|
_ACTIVE_DUT = {
|
|
|
|
|
test_id: _derive_priority(test, set(), set(), tests)
|
|
|
|
|
for test_id, test in _TESTS_BY_ID.items() if test.device == DUT
|
|
|
|
|
}
|
|
|
|
|
_ACTIVE_REF = {
|
|
|
|
|
test_id: _derive_priority(test, set(), set(), tests)
|
|
|
|
|
for test_id, test in _TESTS_BY_ID.items() if test.device == REF
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_user_priorities(top_priority_tests: set[str], lowest_priority_tests: set[str]) -> None:
|
|
|
|
|
"""Update active priority map in-place using user overrides."""
|
|
|
|
|
if not _TESTS_BY_ID:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
all_tests = list(_TESTS_BY_ID.values())
|
|
|
|
|
for key in list(_ACTIVE_DUT.keys()):
|
|
|
|
|
test = _TESTS_BY_ID.get(key)
|
|
|
|
|
if test is None:
|
|
|
|
|
continue
|
|
|
|
|
_ACTIVE_DUT[key] = _derive_priority(test, top_priority_tests, lowest_priority_tests, all_tests)
|
|
|
|
|
|
|
|
|
|
for key in list(_ACTIVE_REF.keys()):
|
|
|
|
|
test = _TESTS_BY_ID.get(key)
|
|
|
|
|
if test is None:
|
|
|
|
|
continue
|
|
|
|
|
_ACTIVE_REF[key] = _derive_priority(test, top_priority_tests, lowest_priority_tests, all_tests)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def remove_from_active(completed_test_ids: set[str]) -> None:
|
|
|
|
|
"""Remove completed/invalid tests from active list without rebuilding graph."""
|
|
|
|
|
for test_id in completed_test_ids:
|
|
|
|
|
_ACTIVE_DUT.pop((test_id, DUT), None)
|
|
|
|
|
_ACTIVE_REF.pop((test_id, REF), None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def compile_schedule(
|
|
|
|
|
tests: list[SchedulerTest],
|
|
|
|
|
start_date: str | None,
|
|
|
|
|
holiday_dates: set[str],
|
|
|
|
|
top_priority_tests: set[str],
|
|
|
|
|
lowest_priority_tests: set[str],
|
|
|
|
|
daytime_testing_today: bool,
|
|
|
|
|
) -> tuple[list[ScheduleEntry], str | None]:
|
|
|
|
|
"""Compile an optimized schedule for the given tests.
|
|
|
|
|
|
|
|
|
|
Uses the greedy algorithm respecting shift sequences per design:
|
|
|
|
|
- Mon-Thu: shift 3 (5pm-1am) then shift 1 next day (1am-10am)
|
|
|
|
|
- Friday: shift 3 through Monday shift 1 (4-day window)
|
|
|
|
|
Returns a list of ScheduleEntry objects and the completion date.
|
|
|
|
|
"""
|
|
|
|
|
if not tests:
|
|
|
|
|
return [], None
|
|
|
|
|
if not graph.is_graph_built():
|
|
|
|
|
raise RuntimeError("Graph not initialized. Load tests first to build DUT compatibility graph.")
|
|
|
|
|
|
|
|
|
|
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] = []
|
|
|
|
|
|
2026-06-17 16:02:04 -04:00
|
|
|
window_start_date = _parse_date(start_date)
|
|
|
|
|
current_date = window_start_date
|
2026-06-16 15:07:59 -04:00
|
|
|
last_date: str | None = None
|
2026-06-17 16:02:04 -04:00
|
|
|
daytime_shift2_window_pending = daytime_testing_today
|
2026-06-16 15:07:59 -04:00
|
|
|
|
|
|
|
|
while dut_active_priority or ref_active_priority:
|
2026-06-17 16:02:04 -04:00
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-16 15:07:59 -04:00
|
|
|
# Get the shift sequence for current date (respects Mon/Fri/weekend rules)
|
2026-06-17 16:02:04 -04:00
|
|
|
shift_sequence = _get_shift_sequence(
|
|
|
|
|
current_date,
|
|
|
|
|
holiday_dates,
|
|
|
|
|
daytime_shift2_only=is_special_daytime_shift2_window,
|
|
|
|
|
)
|
2026-06-16 15:07:59 -04:00
|
|
|
|
|
|
|
|
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())
|
2026-06-17 16:02:04 -04:00
|
|
|
bundles = _create_bundles(dut_active_test_ids, ref_active_test_ids, _TESTS_BY_ID, top_priority_tests)
|
2026-06-16 15:07:59 -04:00
|
|
|
shift_capacities = {
|
|
|
|
|
(date_obj, shift_idx): _shift_capacity_for_date(
|
|
|
|
|
current_date=date_obj,
|
|
|
|
|
holiday_dates=holiday_dates,
|
2026-06-17 16:02:04 -04:00
|
|
|
daytime_shift2_only=is_special_daytime_shift2_window and date_obj == current_date,
|
2026-06-16 15:07:59 -04:00
|
|
|
).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
|
|
|
|
|
|
2026-06-17 16:02:04 -04:00
|
|
|
# Daytime testing special handling applies only to the first scheduling window.
|
|
|
|
|
daytime_shift2_window_pending = False
|
|
|
|
|
|
2026-06-16 15:07:59 -04:00
|
|
|
# Move to next scheduling window start date
|
|
|
|
|
# After shift 1 (1am-9am), there's shift 2 if daytime testing, then shift 3 (5pm)
|
|
|
|
|
# After shift 3 (5pm), shift 1 is next day (1am)
|
|
|
|
|
if shift_sequence:
|
|
|
|
|
last_sequence_date = shift_sequence[-1][0]
|
|
|
|
|
last_shift_index = shift_sequence[-1][1]
|
|
|
|
|
|
|
|
|
|
if last_shift_index == 1:
|
|
|
|
|
# Last shift was 1 (1am-9am); next shift 3 is same day (5pm)
|
|
|
|
|
current_date = last_sequence_date
|
|
|
|
|
elif last_shift_index == 2:
|
|
|
|
|
# Last shift was 2 (9am-5pm); next shift 3 is same day (5pm)
|
|
|
|
|
current_date = last_sequence_date
|
|
|
|
|
else: # last_shift_index == 3
|
|
|
|
|
# Last shift was 3 (5pm-1am); next shift 1 is next day (1am)
|
|
|
|
|
current_date = last_sequence_date + timedelta(days=1)
|
|
|
|
|
else:
|
|
|
|
|
current_date = current_date + timedelta(days=1)
|
|
|
|
|
|
|
|
|
|
return entries, last_date
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def format_schedule_for_frontend(
|
|
|
|
|
entries: list[ScheduleEntry],
|
|
|
|
|
) -> dict[str, dict[int, list[SchedulerTest]]]:
|
|
|
|
|
"""Convert flat ScheduleEntry list to nested format for frontend.
|
|
|
|
|
|
|
|
|
|
Returns: {date_string: {shift_index: [SchedulerTest, ...]}, ...}
|
|
|
|
|
"""
|
|
|
|
|
schedule: dict[str, dict[int, list[SchedulerTest]]] = {}
|
|
|
|
|
|
|
|
|
|
for entry in entries:
|
|
|
|
|
if entry.scheduled_date not in schedule:
|
|
|
|
|
schedule[entry.scheduled_date] = {1: [], 2: [], 3: []}
|
|
|
|
|
|
|
|
|
|
test = _TESTS_BY_ID.get((entry.test_id, entry.device))
|
|
|
|
|
if test:
|
|
|
|
|
schedule[entry.scheduled_date][entry.shift_index].append(test)
|
|
|
|
|
|
|
|
|
|
return schedule
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _derive_priority(
|
|
|
|
|
test: SchedulerTest,
|
|
|
|
|
top_priority_tests: set[str],
|
|
|
|
|
lowest_priority_tests: set[str],
|
|
|
|
|
all_tests: list[SchedulerTest],
|
|
|
|
|
) -> int:
|
|
|
|
|
if test.test_id in top_priority_tests:
|
|
|
|
|
return 1
|
|
|
|
|
if test.test_id in lowest_priority_tests:
|
|
|
|
|
return 5
|
|
|
|
|
return test.priority
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fit_bundles_to_shifts(
|
|
|
|
|
bundles: list[TestBundle],
|
|
|
|
|
shift_sequence: list[tuple[date, int]],
|
|
|
|
|
tests: dict[TestKey, SchedulerTest],
|
|
|
|
|
graph_by_test_id: dict[str, set[str]],
|
|
|
|
|
shift_capacities: dict[tuple[date, int], int],
|
|
|
|
|
) -> tuple[list[ScheduleEntry], set[TestKey], str | None]:
|
|
|
|
|
"""Fit bundles into a shift sequence window (e.g., one day or one weekend).
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
- List of ScheduleEntry for placed tests
|
|
|
|
|
- Set of test_ids that were placed
|
|
|
|
|
- Last scheduled date
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
window_placed_test_ids: set[str] = set()
|
|
|
|
|
|
|
|
|
|
for bundle in bundles:
|
|
|
|
|
# Skip bundles that are already fully placed.
|
|
|
|
|
if any(test_id in placed_test_ids for test_id in bundle.test_ids):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if not bundle.test_ids:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Keep all tests in the same window pairwise-compatible.
|
|
|
|
|
if window_placed_test_ids:
|
|
|
|
|
is_compatible_with_window = True
|
|
|
|
|
bundle_test_ids_only = {test_key[0] for test_key in bundle.test_ids}
|
|
|
|
|
for placed_test_id in window_placed_test_ids:
|
|
|
|
|
placed_neighbors = graph_by_test_id.get(placed_test_id, set())
|
|
|
|
|
if not all(current_test_id in placed_neighbors for current_test_id in bundle_test_ids_only):
|
|
|
|
|
is_compatible_with_window = False
|
|
|
|
|
break
|
|
|
|
|
if not is_compatible_with_window:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Ensure the whole bundle can still fit somewhere in the remaining window.
|
|
|
|
|
remaining_window = sum(
|
|
|
|
|
shift_state[date_shift]['remaining_minutes'] for date_shift in shift_sequence[current_shift_pos:]
|
|
|
|
|
)
|
|
|
|
|
if remaining_window < bundle.total_minutes:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
bundle_start_shift_pos = current_shift_pos
|
|
|
|
|
placed_bundle_tests: list[tuple[TestKey, tuple[date, int]]] = []
|
|
|
|
|
failed = False
|
|
|
|
|
|
|
|
|
|
for key in bundle.test_ids:
|
|
|
|
|
test = tests[key]
|
|
|
|
|
prev_remaining = 0
|
|
|
|
|
while current_shift_pos < len(shift_sequence):
|
|
|
|
|
date_shift = shift_sequence[current_shift_pos]
|
|
|
|
|
state = shift_state[date_shift]
|
|
|
|
|
state["remaining_minutes"] += prev_remaining # Add back any leftover from previous shift
|
|
|
|
|
if state['remaining_minutes'] >= test.estimated_minutes:
|
2026-06-23 12:07:43 -04:00
|
|
|
#print(f"remaining minutes for {date_shift}: {state['remaining_minutes']} - placing {key} ({test.estimated_minutes}m)")
|
2026-06-16 15:07:59 -04:00
|
|
|
entries.append(
|
|
|
|
|
ScheduleEntry(
|
|
|
|
|
test_id=test.test_id,
|
|
|
|
|
device=test.device,
|
|
|
|
|
scheduled_date=date_shift[0].isoformat(),
|
|
|
|
|
shift_index=date_shift[1],
|
|
|
|
|
sequence_in_shift=state['sequence_counter'],
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
state['sequence_counter'] += 1
|
|
|
|
|
state['remaining_minutes'] -= test.estimated_minutes
|
|
|
|
|
placed_test_ids.add(key)
|
|
|
|
|
placed_bundle_tests.append((key, date_shift))
|
|
|
|
|
last_date = date_shift[0].isoformat()
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
# Move to the next shift in the sequence and keep the bundle contiguous.
|
|
|
|
|
current_shift_pos += 1
|
|
|
|
|
prev_remaining = state['remaining_minutes']
|
|
|
|
|
if current_shift_pos >= len(shift_sequence):
|
|
|
|
|
failed = True
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if failed:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if failed:
|
|
|
|
|
# Roll back any partially placed tests from this bundle.
|
|
|
|
|
for key, date_shift in reversed(placed_bundle_tests):
|
|
|
|
|
state = shift_state[date_shift]
|
|
|
|
|
state['remaining_minutes'] += tests[key].estimated_minutes
|
|
|
|
|
state['sequence_counter'] -= 1
|
|
|
|
|
entries.pop()
|
|
|
|
|
placed_test_ids.discard(key)
|
|
|
|
|
last_date = None
|
|
|
|
|
current_shift_pos = bundle_start_shift_pos
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if placed_bundle_tests:
|
|
|
|
|
window_placed_test_ids.update(test_key[0] for test_key in bundle.test_ids)
|
|
|
|
|
|
|
|
|
|
return entries, placed_test_ids, last_date
|
|
|
|
|
|
|
|
|
|
def _create_bundles(
|
|
|
|
|
dut_active_test_ids: set[TestKey],
|
|
|
|
|
ref_active_test_ids: set[TestKey],
|
|
|
|
|
tests: dict[TestKey, SchedulerTest],
|
2026-06-17 16:02:04 -04:00
|
|
|
top_priority_tests: set[str] | None = None,
|
2026-06-16 15:07:59 -04:00
|
|
|
) -> list[TestBundle]:
|
|
|
|
|
"""Create bundles of tests that must run together, sorted by priority tier and efficiency.
|
|
|
|
|
|
|
|
|
|
Bundling rules:
|
|
|
|
|
- P2P with COE pairs: [P2P, COE1, COE2, ...]
|
|
|
|
|
- P2P without COE: [P2P_RX, P2P_TX] if both active
|
|
|
|
|
- COE without a linked active P2P: [COE] (standalone bundle)
|
|
|
|
|
- P3P: individual test (not bundled)
|
|
|
|
|
|
|
|
|
|
Returned list is sorted by (priority_tier, total_minutes) to place small/high-priority bundles first.
|
|
|
|
|
"""
|
2026-06-17 16:02:04 -04:00
|
|
|
if top_priority_tests is None:
|
|
|
|
|
top_priority_tests = set()
|
|
|
|
|
|
2026-06-16 15:07:59 -04:00
|
|
|
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)
|
2026-06-17 16:02:04 -04:00
|
|
|
# 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
|
2026-06-16 15:07:59 -04:00
|
|
|
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)
|
2026-06-17 16:02:04 -04:00
|
|
|
# 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
|
2026-06-16 15:07:59 -04:00
|
|
|
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
|
2026-06-17 16:02:04 -04:00
|
|
|
# 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
|
2026-06-16 15:07:59 -04:00
|
|
|
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]
|
2026-06-17 16:02:04 -04:00
|
|
|
# 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
|
2026-06-16 15:07:59 -04:00
|
|
|
bundles.append(TestBundle(
|
|
|
|
|
test_ids=bundle_test_ids,
|
|
|
|
|
priority_tier=priority_tier,
|
|
|
|
|
total_minutes=sum(tests[item].estimated_minutes for item in bundle_test_ids)
|
|
|
|
|
))
|
|
|
|
|
processed.update(bundle_test_ids)
|
|
|
|
|
|
|
|
|
|
# Sort by priority tier (lower first), then by total minutes (smaller first for efficiency)
|
|
|
|
|
bundles.sort(key=lambda b: (b.priority_tier, b.total_minutes))
|
|
|
|
|
return bundles
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _rx_tx_pair_id(test_id: str, active: set[str]) -> str | None:
|
|
|
|
|
if "RX" in test_id:
|
|
|
|
|
candidate = test_id.replace("RX", "TX", 1)
|
|
|
|
|
return candidate if candidate in active else None
|
|
|
|
|
if "TX" in test_id:
|
|
|
|
|
candidate = test_id.replace("TX", "RX", 1)
|
|
|
|
|
return candidate if candidate in active else None
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _shift_capacity_for_date(
|
|
|
|
|
current_date: date,
|
|
|
|
|
holiday_dates: set[str],
|
2026-06-17 16:02:04 -04:00
|
|
|
daytime_shift2_only: bool,
|
2026-06-16 15:07:59 -04:00
|
|
|
) -> 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}
|
|
|
|
|
|
2026-06-17 16:02:04 -04:00
|
|
|
if daytime_shift2_only:
|
|
|
|
|
return {1: 0, 2: 480, 3: 0}
|
2026-06-16 15:07:59 -04:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
2026-06-17 16:02:04 -04:00
|
|
|
def _get_shift_sequence(
|
|
|
|
|
start_date: date,
|
|
|
|
|
holiday_dates: set[str],
|
|
|
|
|
daytime_shift2_only: bool = False,
|
|
|
|
|
) -> list[tuple[date, int]]:
|
2026-06-16 15:07:59 -04:00
|
|
|
"""Generate the sequence of (date, shift_index) tuples for a scheduling window.
|
|
|
|
|
|
|
|
|
|
Rules per design:
|
2026-06-17 16:02:04 -04:00
|
|
|
- Daytime testing first window (weekday only): [2] (shift 2 today only)
|
2026-06-16 15:07:59 -04:00
|
|
|
- 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
|
|
|
|
|
|
2026-06-17 16:02:04 -04:00
|
|
|
Weekday default starts on shift 3 unless daytime testing shift-2-only is enabled.
|
2026-06-16 15:07:59 -04:00
|
|
|
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]] = []
|
2026-06-17 16:02:04 -04:00
|
|
|
|
|
|
|
|
if daytime_shift2_only and weekday < 5 and not is_holiday:
|
|
|
|
|
shifts.append((start_date, 2))
|
|
|
|
|
return shifts
|
2026-06-16 15:07:59 -04:00
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|