knapsack scheduling algorithm
This commit is contained in:
+283
-607
@@ -2,616 +2,292 @@ from __future__ import annotations
|
||||
import os
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import graph
|
||||
|
||||
# Bundle priority tiers for scheduling order (lower number = higher priority)
|
||||
BUNDLE_PRIORITY_FAILED = 0 # Failed tests requiring rerun
|
||||
BUNDLE_PRIORITY_P2P_WITH_COE = 1 # P2P tests with COE pairs
|
||||
BUNDLE_PRIORITY_P2P_ONLY = 2 # P2P tests without COE pairs (RX/TX bundled)
|
||||
BUNDLE_PRIORITY_COE_ONLY = 3 # COE tests without P2P pairing (should be rare/unschedulable)
|
||||
BUNDLE_PRIORITY_P3P = 4 # P3P tests
|
||||
|
||||
DUT = os.getenv("DUT", "CGW453").strip()
|
||||
REF = os.getenv("REF", "CGW452").strip()
|
||||
TestKey = tuple[str, str]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SchedulerTest:
|
||||
test_id: str
|
||||
device: str
|
||||
test_type: str
|
||||
rotation: str | None
|
||||
rx_tx: str | None
|
||||
has_coe_pair: bool
|
||||
coe_pairing: list[str]
|
||||
config: dict[str, dict[str, str | None]]
|
||||
throttled: bool
|
||||
estimated_minutes: int
|
||||
priority: int
|
||||
raw_payload: dict[str, Any]
|
||||
from datetime import date
|
||||
from test_window import get_shift_sequence_with_capacity, next_window_start_date
|
||||
from test_bundle import Test, TestBundle, build_test_bundles, bundle_pair_lookup
|
||||
|
||||
DUT = (os.getenv("DUT") or "DUT").strip()
|
||||
REF = (os.getenv("REF") or "REF").strip()
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScheduleEntry:
|
||||
test_id: str
|
||||
device: str
|
||||
scheduled_date: str
|
||||
shift_index: int
|
||||
sequence_in_shift: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestBundle:
|
||||
"""A bundle of tests that must be run together in sequence."""
|
||||
test_ids: list[TestKey]
|
||||
priority_tier: int
|
||||
total_minutes: int
|
||||
|
||||
|
||||
# Global scheduler state. Graph is built once per loaded test set.
|
||||
_TESTS_BY_ID: dict[TestKey, SchedulerTest] = {}
|
||||
_ACTIVE_DUT: dict[TestKey, int] = {}
|
||||
_ACTIVE_REF: dict[TestKey, int] = {}
|
||||
|
||||
|
||||
def reset_scheduler_state() -> None:
|
||||
global _TESTS_BY_ID, _ACTIVE_DUT, _ACTIVE_REF
|
||||
_TESTS_BY_ID = {}
|
||||
_ACTIVE_DUT = {}
|
||||
_ACTIVE_REF = {}
|
||||
|
||||
|
||||
def initialize_scheduler_state(tests: list[SchedulerTest]) -> None:
|
||||
"""Initialize active scheduler state once for the current loaded schedulable dataset."""
|
||||
global _TESTS_BY_ID, _ACTIVE_DUT, _ACTIVE_REF
|
||||
if _TESTS_BY_ID:
|
||||
return
|
||||
|
||||
_TESTS_BY_ID = {(t.test_id, t.device): t for t in tests}
|
||||
_ACTIVE_DUT = {
|
||||
test_id: _derive_priority(test, set(), set(), tests)
|
||||
for test_id, test in _TESTS_BY_ID.items() if test.device == DUT
|
||||
}
|
||||
_ACTIVE_REF = {
|
||||
test_id: _derive_priority(test, set(), set(), tests)
|
||||
for test_id, test in _TESTS_BY_ID.items() if test.device == REF
|
||||
}
|
||||
|
||||
|
||||
def set_user_priorities(top_priority_tests: set[str], lowest_priority_tests: set[str]) -> None:
|
||||
"""Update active priority map in-place using user overrides."""
|
||||
if not _TESTS_BY_ID:
|
||||
return
|
||||
|
||||
all_tests = list(_TESTS_BY_ID.values())
|
||||
for key in list(_ACTIVE_DUT.keys()):
|
||||
test = _TESTS_BY_ID.get(key)
|
||||
if test is None:
|
||||
continue
|
||||
_ACTIVE_DUT[key] = _derive_priority(test, top_priority_tests, lowest_priority_tests, all_tests)
|
||||
|
||||
for key in list(_ACTIVE_REF.keys()):
|
||||
test = _TESTS_BY_ID.get(key)
|
||||
if test is None:
|
||||
continue
|
||||
_ACTIVE_REF[key] = _derive_priority(test, top_priority_tests, lowest_priority_tests, all_tests)
|
||||
|
||||
|
||||
def remove_from_active(completed_test_ids: set[str]) -> None:
|
||||
"""Remove completed/invalid tests from active list without rebuilding graph."""
|
||||
for test_id in completed_test_ids:
|
||||
_ACTIVE_DUT.pop((test_id, DUT), None)
|
||||
_ACTIVE_REF.pop((test_id, REF), None)
|
||||
|
||||
|
||||
|
||||
def compile_schedule(
|
||||
tests: list[SchedulerTest],
|
||||
start_date: str | None,
|
||||
holiday_dates: set[str],
|
||||
top_priority_tests: set[str],
|
||||
lowest_priority_tests: set[str],
|
||||
daytime_testing_today: bool,
|
||||
) -> tuple[list[ScheduleEntry], str | None]:
|
||||
"""Compile an optimized schedule for the given tests.
|
||||
|
||||
Uses the greedy algorithm respecting shift sequences per design:
|
||||
- Mon-Thu: shift 3 (5pm-1am) then shift 1 next day (1am-10am)
|
||||
- Friday: shift 3 through Monday shift 1 (4-day window)
|
||||
Returns a list of ScheduleEntry objects and the completion date.
|
||||
"""
|
||||
if not tests:
|
||||
return [], None
|
||||
|
||||
initialize_scheduler_state(tests)
|
||||
set_user_priorities(top_priority_tests, lowest_priority_tests)
|
||||
|
||||
# Use a local working copy for this compile run. Global active remains until result processing removes IDs.
|
||||
dut_active_priority: dict[TestKey, int] = dict(_ACTIVE_DUT)
|
||||
ref_active_priority: dict[TestKey, int] = dict(_ACTIVE_REF)
|
||||
entries: list[ScheduleEntry] = []
|
||||
|
||||
window_start_date = _parse_date(start_date)
|
||||
current_date = window_start_date
|
||||
last_date: str | None = None
|
||||
daytime_shift2_window_pending = daytime_testing_today
|
||||
date_device_lock: dict[str, str] = {}
|
||||
|
||||
while dut_active_priority or ref_active_priority:
|
||||
is_special_daytime_shift2_window = (
|
||||
daytime_shift2_window_pending
|
||||
and current_date == window_start_date
|
||||
and current_date.weekday() < 5
|
||||
and current_date.isoformat() not in holiday_dates
|
||||
)
|
||||
|
||||
# Get the shift sequence for current date (respects Mon/Fri/weekend rules)
|
||||
shift_sequence = _get_shift_sequence(
|
||||
current_date,
|
||||
holiday_dates,
|
||||
daytime_shift2_only=is_special_daytime_shift2_window,
|
||||
)
|
||||
|
||||
if not shift_sequence:
|
||||
break # No valid shift sequence
|
||||
|
||||
dut_active_test_ids: set[TestKey] = set(dut_active_priority.keys())
|
||||
ref_active_test_ids: set[TestKey] = set(ref_active_priority.keys())
|
||||
bundles = _create_bundles(dut_active_test_ids, ref_active_test_ids, _TESTS_BY_ID, top_priority_tests)
|
||||
shift_capacities = {
|
||||
(date_obj, shift_idx): _shift_capacity_for_date(
|
||||
current_date=date_obj,
|
||||
holiday_dates=holiday_dates,
|
||||
daytime_shift2_only=is_special_daytime_shift2_window and date_obj == current_date,
|
||||
).get(shift_idx, 0)
|
||||
for date_obj, shift_idx in shift_sequence
|
||||
}
|
||||
|
||||
forced_device: str | None = None
|
||||
for date_obj, _shift_idx in shift_sequence:
|
||||
locked_device = date_device_lock.get(date_obj.isoformat())
|
||||
if locked_device is None:
|
||||
continue
|
||||
if forced_device is None:
|
||||
forced_device = locked_device
|
||||
elif forced_device != locked_device:
|
||||
raise ValueError(f"Conflicting device locks in shift sequence for {date_obj.isoformat()}")
|
||||
|
||||
placed_entries, placed_test_ids, placed_last_date, window_device = _fit_bundles_to_shifts(
|
||||
bundles=bundles,
|
||||
shift_sequence=shift_sequence,
|
||||
tests=_TESTS_BY_ID,
|
||||
graph_by_test_id=graph.get_graph(),
|
||||
shift_capacities=shift_capacities,
|
||||
forced_device=forced_device,
|
||||
)
|
||||
entries.extend(placed_entries)
|
||||
if window_device is not None:
|
||||
for date_obj, _shift_idx in shift_sequence:
|
||||
date_device_lock.setdefault(date_obj.isoformat(), window_device)
|
||||
for key in placed_test_ids:
|
||||
dut_active_priority.pop(key, None)
|
||||
ref_active_priority.pop(key, None)
|
||||
if placed_last_date is not None:
|
||||
last_date = placed_last_date
|
||||
|
||||
# Daytime testing special handling applies only to the first scheduling window.
|
||||
daytime_shift2_window_pending = False
|
||||
|
||||
# Move to next scheduling window start date
|
||||
# After shift 1 (1am-9am), there's shift 2 if daytime testing, then shift 3 (5pm)
|
||||
# After shift 3 (5pm), shift 1 is next day (1am)
|
||||
if shift_sequence:
|
||||
last_sequence_date = shift_sequence[-1][0]
|
||||
last_shift_index = shift_sequence[-1][1]
|
||||
|
||||
if last_shift_index == 1:
|
||||
# Last shift was 1 (1am-9am); next shift 3 is same day (5pm)
|
||||
current_date = last_sequence_date
|
||||
elif last_shift_index == 2:
|
||||
# Last shift was 2 (9am-5pm); next shift 3 is same day (5pm)
|
||||
current_date = last_sequence_date
|
||||
else: # last_shift_index == 3
|
||||
# Last shift was 3 (5pm-1am); next shift 1 is next day (1am)
|
||||
current_date = last_sequence_date + timedelta(days=1)
|
||||
else:
|
||||
current_date = current_date + timedelta(days=1)
|
||||
|
||||
return entries, last_date
|
||||
|
||||
|
||||
def format_schedule_for_frontend(
|
||||
entries: list[ScheduleEntry],
|
||||
) -> dict[str, dict[int, list[SchedulerTest]]]:
|
||||
"""Convert flat ScheduleEntry list to nested format for frontend.
|
||||
|
||||
Returns: {date_string: {shift_index: [SchedulerTest, ...]}, ...}
|
||||
"""
|
||||
schedule: dict[str, dict[int, list[SchedulerTest]]] = {}
|
||||
|
||||
for entry in entries:
|
||||
if entry.scheduled_date not in schedule:
|
||||
schedule[entry.scheduled_date] = {1: [], 2: [], 3: []}
|
||||
|
||||
test = _TESTS_BY_ID.get((entry.test_id, entry.device))
|
||||
if test:
|
||||
schedule[entry.scheduled_date][entry.shift_index].append(test)
|
||||
|
||||
return schedule
|
||||
|
||||
|
||||
def _derive_priority(
|
||||
test: SchedulerTest,
|
||||
top_priority_tests: set[str],
|
||||
lowest_priority_tests: set[str],
|
||||
all_tests: list[SchedulerTest],
|
||||
) -> int:
|
||||
if test.test_id in top_priority_tests:
|
||||
return 1
|
||||
if test.test_id in lowest_priority_tests:
|
||||
return 5
|
||||
return test.priority
|
||||
|
||||
|
||||
def _fit_bundles_to_shifts(
|
||||
bundles: list[TestBundle],
|
||||
shift_sequence: list[tuple[date, int]],
|
||||
tests: dict[TestKey, SchedulerTest],
|
||||
graph_by_test_id: dict[str, set[str]],
|
||||
shift_capacities: dict[tuple[date, int], int],
|
||||
forced_device: str | None = None,
|
||||
) -> tuple[list[ScheduleEntry], set[TestKey], str | None, str | None]:
|
||||
"""Fit bundles into a shift sequence window (e.g., one day or one weekend).
|
||||
|
||||
Returns:
|
||||
- List of ScheduleEntry for placed tests
|
||||
- Set of test_ids that were placed
|
||||
- Last scheduled date
|
||||
- Device used for this window, if any bundle was placed
|
||||
|
||||
Bundles are placed in sequence order and may span multiple shifts inside the same window.
|
||||
"""
|
||||
entries: list[ScheduleEntry] = []
|
||||
placed_test_ids: set[TestKey] = set()
|
||||
last_date: str | None = None
|
||||
window_device: str | None = None
|
||||
|
||||
# Track state per shift
|
||||
shift_state: dict[tuple[date, int], dict] = {}
|
||||
for date_shift in shift_sequence:
|
||||
shift_state[date_shift] = {
|
||||
'remaining_minutes': shift_capacities.get(date_shift, 0),
|
||||
'sequence_counter': 1,
|
||||
}
|
||||
|
||||
shift_positions = {date_shift: idx for idx, date_shift in enumerate(shift_sequence)}
|
||||
current_shift_pos = 0
|
||||
window_placed_test_ids: set[str] = set()
|
||||
window_placed_devices: set[str] = set()
|
||||
|
||||
for bundle in bundles:
|
||||
# Skip bundles that are already fully placed.
|
||||
if any(test_id in placed_test_ids for test_id in bundle.test_ids):
|
||||
continue
|
||||
|
||||
if not bundle.test_ids:
|
||||
continue
|
||||
|
||||
bundle_devices = {test_key[1] for test_key in bundle.test_ids}
|
||||
if len(bundle_devices) > 1:
|
||||
# A bundle cannot span devices because each window is single-device only.
|
||||
continue
|
||||
if forced_device is not None and forced_device not in bundle_devices:
|
||||
continue
|
||||
|
||||
# Keep all tests in the same window pairwise-compatible.
|
||||
if window_placed_test_ids:
|
||||
is_compatible_with_window = True
|
||||
bundle_test_ids_only = {test_key[0] for test_key in bundle.test_ids}
|
||||
for placed_test_id in window_placed_test_ids:
|
||||
placed_neighbors = graph_by_test_id.get(placed_test_id, set())
|
||||
if not all(current_test_id in placed_neighbors for current_test_id in bundle_test_ids_only):
|
||||
is_compatible_with_window = False
|
||||
break
|
||||
if not is_compatible_with_window:
|
||||
continue
|
||||
|
||||
if window_placed_devices and not bundle_devices.issubset(window_placed_devices):
|
||||
continue
|
||||
|
||||
# Ensure the whole bundle can still fit somewhere in the remaining window.
|
||||
remaining_window = sum(
|
||||
shift_state[date_shift]['remaining_minutes'] for date_shift in shift_sequence[current_shift_pos:]
|
||||
)
|
||||
if remaining_window < bundle.total_minutes:
|
||||
continue
|
||||
|
||||
bundle_start_shift_pos = current_shift_pos
|
||||
placed_bundle_tests: list[tuple[TestKey, tuple[date, int]]] = []
|
||||
failed = False
|
||||
|
||||
for key in bundle.test_ids:
|
||||
test = tests[key]
|
||||
prev_remaining = 0
|
||||
while current_shift_pos < len(shift_sequence):
|
||||
date_shift = shift_sequence[current_shift_pos]
|
||||
state = shift_state[date_shift]
|
||||
state["remaining_minutes"] += prev_remaining # Add back any leftover from previous shift
|
||||
if state['remaining_minutes'] >= test.estimated_minutes:
|
||||
#print(f"remaining minutes for {date_shift}: {state['remaining_minutes']} - placing {key} ({test.estimated_minutes}m)")
|
||||
entries.append(
|
||||
ScheduleEntry(
|
||||
test_id=test.test_id,
|
||||
device=test.device,
|
||||
scheduled_date=date_shift[0].isoformat(),
|
||||
shift_index=date_shift[1],
|
||||
sequence_in_shift=state['sequence_counter'],
|
||||
)
|
||||
)
|
||||
state['sequence_counter'] += 1
|
||||
state['remaining_minutes'] -= test.estimated_minutes
|
||||
placed_test_ids.add(key)
|
||||
placed_bundle_tests.append((key, date_shift))
|
||||
last_date = date_shift[0].isoformat()
|
||||
break
|
||||
|
||||
# Move to the next shift in the sequence and keep the bundle contiguous.
|
||||
current_shift_pos += 1
|
||||
prev_remaining = state['remaining_minutes']
|
||||
if current_shift_pos >= len(shift_sequence):
|
||||
failed = True
|
||||
break
|
||||
|
||||
if failed:
|
||||
break
|
||||
|
||||
if failed:
|
||||
# Roll back any partially placed tests from this bundle.
|
||||
for key, date_shift in reversed(placed_bundle_tests):
|
||||
state = shift_state[date_shift]
|
||||
state['remaining_minutes'] += tests[key].estimated_minutes
|
||||
state['sequence_counter'] -= 1
|
||||
entries.pop()
|
||||
placed_test_ids.discard(key)
|
||||
last_date = None
|
||||
current_shift_pos = bundle_start_shift_pos
|
||||
continue
|
||||
|
||||
if placed_bundle_tests:
|
||||
window_placed_test_ids.update(test_key[0] for test_key in bundle.test_ids)
|
||||
window_placed_devices.update(bundle_devices)
|
||||
if window_device is None:
|
||||
window_device = next(iter(bundle_devices))
|
||||
|
||||
return entries, placed_test_ids, last_date, window_device
|
||||
|
||||
def _create_bundles(
|
||||
dut_active_test_ids: set[TestKey],
|
||||
ref_active_test_ids: set[TestKey],
|
||||
tests: dict[TestKey, SchedulerTest],
|
||||
top_priority_tests: set[str] | None = None,
|
||||
) -> list[TestBundle]:
|
||||
"""Create bundles of tests that must run together, sorted by priority tier and efficiency.
|
||||
|
||||
Bundling rules:
|
||||
- P2P with COE pairs: [P2P, COE1, COE2, ...]
|
||||
- P2P without COE: [P2P_RX, P2P_TX] if both active
|
||||
- COE without a linked active P2P: [COE] (standalone bundle)
|
||||
- P3P: individual test (not bundled)
|
||||
|
||||
Returned list is sorted by (priority_tier, total_minutes) to place small/high-priority bundles first.
|
||||
"""
|
||||
if top_priority_tests is None:
|
||||
top_priority_tests = set()
|
||||
|
||||
bundles: list[TestBundle] = []
|
||||
processed: set[TestKey] = set()
|
||||
|
||||
dut_by_id: dict[str, TestKey] = {test_id: key for test_id, _device in dut_active_test_ids for key in [(test_id, DUT)] if key in dut_active_test_ids}
|
||||
ref_by_id: dict[str, TestKey] = {test_id: key for test_id, _device in ref_active_test_ids for key in [(test_id, REF)] if key in ref_active_test_ids}
|
||||
all_ids = sorted(set(dut_by_id.keys()) | set(ref_by_id.keys()))
|
||||
|
||||
def _active_key(test_id: str, device: str) -> TestKey | None:
|
||||
key = (test_id, device)
|
||||
if key in dut_active_test_ids or key in ref_active_test_ids:
|
||||
return key
|
||||
return None
|
||||
|
||||
for test_id in all_ids:
|
||||
seed_keys = [key for key in (_active_key(test_id, DUT), _active_key(test_id, REF)) if key is not None and key not in processed]
|
||||
if not seed_keys:
|
||||
continue
|
||||
|
||||
representative_key = seed_keys[0]
|
||||
test = tests[representative_key]
|
||||
if test.test_type == "P2P":
|
||||
if test.has_coe_pair:
|
||||
for device in (DUT, REF):
|
||||
bundle_test_ids: list[TestKey] = []
|
||||
base_key = _active_key(test_id, device)
|
||||
if base_key is not None and base_key not in processed:
|
||||
bundle_test_ids.append(base_key)
|
||||
for coe_id in sorted(test.coe_pairing):
|
||||
coe_key = _active_key(coe_id, device)
|
||||
if coe_key is not None and coe_key not in processed:
|
||||
bundle_test_ids.append(coe_key)
|
||||
|
||||
if not bundle_test_ids:
|
||||
continue
|
||||
processed.update(bundle_test_ids)
|
||||
# Check if any test in bundle is top priority
|
||||
bundle_test_ids_only = {key[0] for key in bundle_test_ids}
|
||||
if bundle_test_ids_only & top_priority_tests:
|
||||
priority_tier = BUNDLE_PRIORITY_FAILED # Highest priority (0)
|
||||
else:
|
||||
priority_tier = BUNDLE_PRIORITY_P2P_WITH_COE
|
||||
bundles.append(TestBundle(
|
||||
test_ids=bundle_test_ids,
|
||||
priority_tier=priority_tier,
|
||||
total_minutes=sum(tests[key].estimated_minutes for key in bundle_test_ids)
|
||||
))
|
||||
else:
|
||||
for device in (DUT, REF):
|
||||
bundle_test_ids: list[TestKey] = []
|
||||
base_key = _active_key(test_id, device)
|
||||
if base_key is None or base_key in processed:
|
||||
continue
|
||||
bundle_test_ids.append(base_key)
|
||||
pair_id = _rx_tx_pair_id(test_id, set(k[0] for k in (dut_active_test_ids if device == DUT else ref_active_test_ids)))
|
||||
if pair_id:
|
||||
pair_key = _active_key(pair_id, device)
|
||||
if pair_key is not None and pair_key not in processed and pair_key not in bundle_test_ids:
|
||||
bundle_test_ids.append(pair_key)
|
||||
|
||||
processed.update(bundle_test_ids)
|
||||
# Check if any test in bundle is top priority
|
||||
bundle_test_ids_only = {key[0] for key in bundle_test_ids}
|
||||
if bundle_test_ids_only & top_priority_tests:
|
||||
priority_tier = BUNDLE_PRIORITY_FAILED # Highest priority (0)
|
||||
else:
|
||||
priority_tier = BUNDLE_PRIORITY_P2P_ONLY
|
||||
bundles.append(TestBundle(
|
||||
test_ids=bundle_test_ids,
|
||||
priority_tier=priority_tier,
|
||||
total_minutes=sum(tests[key].estimated_minutes for key in bundle_test_ids)
|
||||
))
|
||||
elif test.test_type == "P3P":
|
||||
for device in (DUT, REF):
|
||||
key = _active_key(test_id, device)
|
||||
if key is None or key in processed:
|
||||
continue
|
||||
bundle_test_ids = [key]
|
||||
# Check if any test in bundle is top priority
|
||||
if key[0] in top_priority_tests:
|
||||
priority_tier = BUNDLE_PRIORITY_FAILED # Highest priority (0)
|
||||
else:
|
||||
priority_tier = BUNDLE_PRIORITY_P3P
|
||||
bundles.append(TestBundle(
|
||||
test_ids=bundle_test_ids,
|
||||
priority_tier=priority_tier,
|
||||
total_minutes=sum(tests[item].estimated_minutes for item in bundle_test_ids)
|
||||
))
|
||||
processed.update(bundle_test_ids)
|
||||
|
||||
# Second pass to catch any active tests left uncovered by first-pass grouping.
|
||||
all_active_keys = sorted(dut_active_test_ids | ref_active_test_ids)
|
||||
for key in all_active_keys:
|
||||
if key in processed:
|
||||
continue
|
||||
test = tests[key]
|
||||
bundle_test_ids = [key]
|
||||
# Check if this test is top priority
|
||||
if key[0] in top_priority_tests:
|
||||
priority_tier = BUNDLE_PRIORITY_FAILED # Highest priority (0)
|
||||
else:
|
||||
priority_tier = BUNDLE_PRIORITY_COE_ONLY
|
||||
bundles.append(TestBundle(
|
||||
test_ids=bundle_test_ids,
|
||||
priority_tier=priority_tier,
|
||||
total_minutes=sum(tests[item].estimated_minutes for item in bundle_test_ids)
|
||||
))
|
||||
processed.update(bundle_test_ids)
|
||||
|
||||
# Sort by priority tier (lower first), then by total minutes (smaller first for efficiency)
|
||||
bundles.sort(key=lambda b: (b.priority_tier, b.total_minutes))
|
||||
return bundles
|
||||
|
||||
|
||||
def _rx_tx_pair_id(test_id: str, active: set[str]) -> str | None:
|
||||
if "RX" in test_id:
|
||||
candidate = test_id.replace("RX", "TX", 1)
|
||||
return candidate if candidate in active else None
|
||||
if "TX" in test_id:
|
||||
candidate = test_id.replace("TX", "RX", 1)
|
||||
return candidate if candidate in active else None
|
||||
return None
|
||||
|
||||
|
||||
def _shift_capacity_for_date(
|
||||
current_date: date,
|
||||
holiday_dates: set[str],
|
||||
daytime_shift2_only: bool,
|
||||
) -> dict[int, int]:
|
||||
iso = current_date.isoformat()
|
||||
if iso in holiday_dates:
|
||||
return {1: 480, 2: 480, 3: 480}
|
||||
|
||||
is_weekend = current_date.weekday() >= 5
|
||||
if is_weekend:
|
||||
return {1: 480, 2: 480, 3: 480}
|
||||
|
||||
if daytime_shift2_only:
|
||||
return {1: 0, 2: 480, 3: 0}
|
||||
|
||||
return {1: 600, 2: 0, 3: 420}
|
||||
|
||||
|
||||
def _parse_date(value: str | None) -> date:
|
||||
if not value:
|
||||
return date.today()
|
||||
return datetime.strptime(value, "%Y-%m-%d").date()
|
||||
|
||||
|
||||
def _is_off_day(day: date, holiday_dates: set[str]) -> bool:
|
||||
"""Return True for weekend days and configured holidays."""
|
||||
return day.weekday() >= 5 or day.isoformat() in holiday_dates
|
||||
|
||||
|
||||
def _get_shift_sequence(
|
||||
start_date: date,
|
||||
holiday_dates: set[str],
|
||||
daytime_shift2_only: bool = False,
|
||||
) -> list[tuple[date, int]]:
|
||||
"""Generate the sequence of (date, shift_index) tuples for a scheduling window.
|
||||
|
||||
Rules per design:
|
||||
- Daytime testing first window (weekday only): [2] (shift 2 today only)
|
||||
- Monday-Thursday: [3, 1] (shift 3 today, shift 1 next day) = 16 hours
|
||||
- Friday: [3, 1, 2, 3, 1, 2, 3, 1] (Fri-Mon) = 24 hours continuous
|
||||
- Saturday/Sunday/Holiday weekday: all 3 shifts
|
||||
|
||||
Weekday default starts on shift 3 unless daytime testing shift-2-only is enabled.
|
||||
Returns ordered list of (date, shift_index) pairs.
|
||||
"""
|
||||
iso = start_date.isoformat()
|
||||
is_holiday = iso in holiday_dates
|
||||
weekday = start_date.weekday() # 0=Mon, 4=Fri, 5=Sat, 6=Sun
|
||||
|
||||
shifts: list[tuple[date, int]] = []
|
||||
|
||||
if daytime_shift2_only and weekday < 5 and not is_holiday:
|
||||
shifts.append((start_date, 2))
|
||||
return shifts
|
||||
|
||||
# Any off-day (weekend/holiday) uses full daytime+night shifts for that date.
|
||||
if _is_off_day(start_date, holiday_dates):
|
||||
shifts.append((start_date, 1))
|
||||
shifts.append((start_date, 2))
|
||||
shifts.append((start_date, 3))
|
||||
return shifts
|
||||
|
||||
# Working-day windows always start at shift 3.
|
||||
shifts.append((start_date, 3))
|
||||
next_day = start_date + timedelta(days=1)
|
||||
|
||||
# If tomorrow starts an off-day chain (holiday/weekend), extend the window
|
||||
# through all off-days and end at shift 1 of the next working day.
|
||||
if _is_off_day(next_day, holiday_dates):
|
||||
cursor = next_day
|
||||
while _is_off_day(cursor, holiday_dates):
|
||||
shifts.append((cursor, 1))
|
||||
shifts.append((cursor, 2))
|
||||
shifts.append((cursor, 3))
|
||||
cursor += timedelta(days=1)
|
||||
shifts.append((cursor, 1))
|
||||
return shifts
|
||||
|
||||
# Default working-day pair: tonight shift 3 + next day shift 1.
|
||||
shifts.append((next_day, 1))
|
||||
|
||||
return shifts
|
||||
test_id: str
|
||||
device: str
|
||||
scheduled_date: str
|
||||
shift_index: int
|
||||
sequence_in_shift: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduleWindow:
|
||||
index: int
|
||||
shifts: list[tuple[date, int]]
|
||||
capacity_minutes: int
|
||||
remaining_minutes: int
|
||||
assigned_device: str | None = None
|
||||
assigned_config: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
class Scheduler:
|
||||
def __init__(
|
||||
self,
|
||||
tests: list[Test],
|
||||
top_priority_tests: set[tuple[str, str]],
|
||||
start_date: str | None = None,
|
||||
holiday_dates: set[str] = set(),
|
||||
daytime_testing_today: bool = False,
|
||||
priority_weight: int = 100,
|
||||
):
|
||||
self.top_priority_tests = top_priority_tests
|
||||
self.tests = tests
|
||||
self.active_dut: dict[str, Test] = {test.test_id: test for test in tests if test.device == DUT}
|
||||
self.active_ref: dict[str, Test] = {test.test_id: test for test in tests if test.device == REF}
|
||||
self.start_date = date.fromisoformat(start_date) if start_date else date.today()
|
||||
self.holiday_dates = holiday_dates
|
||||
self.daytime_testing_today = daytime_testing_today
|
||||
self.priority_weight = priority_weight
|
||||
self.pending_dut_mirror: list[TestBundle] = []
|
||||
self.pending_ref_mirror: list[TestBundle] = []
|
||||
self.schedule: list[ScheduleEntry] = []
|
||||
self.scheduled_bundle_keys: set[tuple[int, str]] = set()
|
||||
self.scheduled_test_ids: set[str] = set()
|
||||
|
||||
def compile_schedule(self) -> str | None:
|
||||
bundles = build_test_bundles(self.active_dut, self.active_ref, self.top_priority_tests)
|
||||
if not bundles:
|
||||
return f"Error: No test bundles could be created from the provided tests."
|
||||
|
||||
active_dut_bundles = [b for b in bundles if b.device == DUT]
|
||||
active_ref_bundles = [b for b in bundles if b.device == REF]
|
||||
|
||||
# Group by individual TC values (from config tuples)
|
||||
all_tcs = set()
|
||||
for bundle in bundles:
|
||||
all_tcs.update(bundle.config if bundle.config else [None])
|
||||
|
||||
# Create dict: TC -> bundles supporting that TC
|
||||
bundles_by_tc: dict = {}
|
||||
# Sort with None last
|
||||
sorted_tcs = sorted([tc for tc in all_tcs if tc is not None]) + ([None] if None in all_tcs else [])
|
||||
for tc in sorted_tcs:
|
||||
if tc is None:
|
||||
bundles_by_tc[tc] = [b for b in bundles if not b.config]
|
||||
else:
|
||||
bundles_by_tc[tc] = [b for b in bundles if tc in b.config]
|
||||
# Sort by priority, then index
|
||||
bundles_by_tc[tc].sort(key=lambda b: (b.priority, b.index))
|
||||
|
||||
window_index = 0
|
||||
cursor_date = self.start_date
|
||||
|
||||
# Sort TC keys by bundle count (largest first); tie-break by TC name.
|
||||
sorted_tc_items = sorted(
|
||||
[(tc, bundles_by_tc[tc]) for tc in bundles_by_tc if tc is not None],
|
||||
key=lambda x: (-len(x[1]), x[0]),
|
||||
)
|
||||
if None in bundles_by_tc:
|
||||
sorted_tc_items.append((None, bundles_by_tc[None]))
|
||||
|
||||
|
||||
for tc, tc_bundles in sorted_tc_items:
|
||||
window_device = DUT
|
||||
dut_unscheduled = [b for b in tc_bundles if b.device == DUT]
|
||||
ref_unscheduled = [b for b in tc_bundles if b.device == REF]
|
||||
|
||||
while dut_unscheduled or ref_unscheduled:
|
||||
active_unscheduled = dut_unscheduled if window_device == DUT else ref_unscheduled
|
||||
if len(active_unscheduled) == 0:
|
||||
# If no unscheduled bundles for the current device, switch to the other device
|
||||
window_device = REF if window_device == DUT else DUT
|
||||
active_unscheduled = dut_unscheduled if window_device == DUT else ref_unscheduled
|
||||
|
||||
shifts, capacity = get_shift_sequence_with_capacity(cursor_date, self.holiday_dates, self.daytime_testing_today)
|
||||
|
||||
# Evaluate both devices without mutating queue state, then commit once.
|
||||
mirrored_bundles, knapsack_bundles, remaining_time = self._select_bundles(
|
||||
window_device,
|
||||
capacity,
|
||||
active_unscheduled,
|
||||
tc,
|
||||
mutate=True,
|
||||
)
|
||||
selected_bundles = mirrored_bundles + knapsack_bundles
|
||||
if len(selected_bundles) == 0:
|
||||
window_device = REF if window_device == DUT else DUT
|
||||
mirrored_bundles, knapsack_bundles, remaining_time = self._select_bundles(
|
||||
window_device,
|
||||
capacity,
|
||||
active_unscheduled,
|
||||
tc,
|
||||
mutate=True,
|
||||
)
|
||||
selected_bundles = mirrored_bundles + knapsack_bundles
|
||||
if len(selected_bundles) == 0:
|
||||
break
|
||||
|
||||
# Create a mirror of the other device for the next window
|
||||
bundle_pairs = bundle_pair_lookup(knapsack_bundles, dut_unscheduled, ref_unscheduled)
|
||||
|
||||
if window_device == DUT:
|
||||
pending_target = self.pending_ref_mirror
|
||||
else:
|
||||
pending_target = self.pending_dut_mirror
|
||||
|
||||
existing_pending = {(b.index, b.device) for b in pending_target}
|
||||
for pair_bundle in bundle_pairs:
|
||||
pair_key = (pair_bundle.index, pair_bundle.device)
|
||||
if pair_key in self.scheduled_bundle_keys or pair_key in existing_pending:
|
||||
continue
|
||||
pending_target.append(pair_bundle)
|
||||
existing_pending.add(pair_key)
|
||||
|
||||
|
||||
window = ScheduleWindow(
|
||||
index=window_index,
|
||||
shifts=shifts,
|
||||
capacity_minutes=capacity,
|
||||
remaining_minutes=remaining_time,
|
||||
assigned_config=tc,
|
||||
assigned_device=window_device,
|
||||
)
|
||||
|
||||
self._place_bundles_in_window(window, selected_bundles)
|
||||
|
||||
# Mark bundles as scheduled and remove from ALL TC buckets
|
||||
selected_keys = {(b.index, b.device) for b in selected_bundles}
|
||||
|
||||
# Remove scheduled bundles from ALL TC buckets globally
|
||||
for all_tc in bundles_by_tc:
|
||||
bundles_by_tc[all_tc] = [b for b in bundles_by_tc[all_tc] if (b.index, b.device) not in selected_keys]
|
||||
|
||||
# Rebuild current TC unscheduled lists
|
||||
dut_unscheduled = [b for b in bundles_by_tc[tc] if b.device == DUT]
|
||||
ref_unscheduled = [b for b in bundles_by_tc[tc] if b.device == REF]
|
||||
|
||||
window_index += 1
|
||||
window_device = REF if window_device == DUT else DUT
|
||||
cursor_date = next_window_start_date(shifts)
|
||||
|
||||
return None
|
||||
|
||||
def _select_bundles(
|
||||
self,
|
||||
device: str,
|
||||
capacity: int,
|
||||
unscheduled: list[TestBundle],
|
||||
tc,
|
||||
mutate: bool,
|
||||
) -> tuple[list[TestBundle], list[TestBundle], int]:
|
||||
# Consume pending mirrored bundles only while there is room.
|
||||
pending = self.pending_dut_mirror if device == DUT else self.pending_ref_mirror
|
||||
mirrored_bundles: list[TestBundle] = []
|
||||
still_pending: list[TestBundle] = [] # Bundles that couldn't fit in the remaining capacity
|
||||
remaining_capacity = capacity
|
||||
|
||||
for bundle in pending:
|
||||
bundle_key = (bundle.index, bundle.device)
|
||||
if bundle_key in self.scheduled_bundle_keys:
|
||||
continue
|
||||
if bundle.device != device or (tc is not None and tc not in bundle.config) or (tc is None and bundle.config):
|
||||
still_pending.append(bundle)
|
||||
continue
|
||||
if bundle.total_minutes <= remaining_capacity:
|
||||
mirrored_bundles.append(bundle)
|
||||
remaining_capacity -= bundle.total_minutes
|
||||
else:
|
||||
still_pending.append(bundle)
|
||||
|
||||
if mutate:
|
||||
pending[:] = still_pending
|
||||
|
||||
# Run knapsack selection for remaining capacity
|
||||
candidates = [
|
||||
b
|
||||
for b in unscheduled
|
||||
if b not in mirrored_bundles
|
||||
and b.device == device
|
||||
and (b.index, b.device) not in self.scheduled_bundle_keys
|
||||
]
|
||||
knapsack_bundles = self._knapsack_select(remaining_capacity, candidates)
|
||||
remaining_capacity -= sum(bundle.total_minutes for bundle in knapsack_bundles)
|
||||
|
||||
print(f"Selected {len(mirrored_bundles)} mirrored bundles and {len(knapsack_bundles)} knapsack bundles for device {device} with remaining capacity {remaining_capacity} minutes.")
|
||||
return mirrored_bundles, knapsack_bundles, remaining_capacity
|
||||
|
||||
def get_schedule(self) -> list[ScheduleEntry]:
|
||||
return self.schedule
|
||||
|
||||
def _knapsack_select(
|
||||
self,
|
||||
capacity: int,
|
||||
candidates: list[TestBundle],
|
||||
) -> list[TestBundle]:
|
||||
if capacity <= 0 or not candidates:
|
||||
return []
|
||||
|
||||
# Weights are total minutes of each bundle
|
||||
weights = [bundle.total_minutes for bundle in candidates]
|
||||
# Values are based on priority, lower priority number means higher value
|
||||
values = [self.priority_weight - bundle.priority for bundle in candidates]
|
||||
|
||||
# Implement dynamic programming knapsack algorithm to select bundles
|
||||
n = len(candidates)
|
||||
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
|
||||
for i in range(1, n + 1):
|
||||
for w in range(capacity + 1):
|
||||
if weights[i - 1] <= w:
|
||||
dp[i][w] = max(dp[i - 1][w], dp[i - 1][w - weights[i - 1]] + values[i - 1])
|
||||
else:
|
||||
dp[i][w] = dp[i - 1][w]
|
||||
|
||||
# Traceback to find selected bundles
|
||||
w = capacity
|
||||
selected_indices = []
|
||||
for i in range(n, 0, -1):
|
||||
if w <= 0:
|
||||
break
|
||||
if dp[i][w] != dp[i - 1][w]:
|
||||
selected_indices.append(i - 1)
|
||||
w -= weights[i - 1]
|
||||
|
||||
# Return the selected bundles in the order they were added
|
||||
return [candidates[i] for i in reversed(selected_indices)]
|
||||
|
||||
def _place_bundles_in_window(
|
||||
self,
|
||||
window: ScheduleWindow,
|
||||
bundles: list[TestBundle],
|
||||
) -> None:
|
||||
for bundle in bundles:
|
||||
bundle_key = (bundle.index, bundle.device)
|
||||
if bundle_key in self.scheduled_bundle_keys:
|
||||
continue
|
||||
self.scheduled_bundle_keys.add(bundle_key)
|
||||
|
||||
for idx, test in enumerate(bundle.tests):
|
||||
schedule_key = f"{test}:{bundle.device}"
|
||||
if schedule_key in self.scheduled_test_ids:
|
||||
continue
|
||||
self.scheduled_test_ids.add(schedule_key)
|
||||
|
||||
self.schedule.append(
|
||||
ScheduleEntry(
|
||||
test_id=test,
|
||||
device=bundle.device,
|
||||
scheduled_date=str(window.shifts[0][0]),
|
||||
shift_index=window.shifts[0][1],
|
||||
sequence_in_shift=idx + 1,
|
||||
)
|
||||
)
|
||||
window.remaining_minutes -= self._get_test_minutes(test, bundle.device)
|
||||
|
||||
|
||||
def _get_test_minutes(self, test_id: str, device: str) -> int:
|
||||
if device == DUT and test_id in self.active_dut:
|
||||
return self.active_dut[test_id].estimated_minutes
|
||||
if device == REF and test_id in self.active_ref:
|
||||
return self.active_ref[test_id].estimated_minutes
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user