knapsack scheduling algorithm

This commit is contained in:
2026-07-12 14:19:58 -04:00
parent 031da48ddd
commit 9e100d60d0
56 changed files with 1936 additions and 1286 deletions
+283 -607
View File
@@ -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