From 031da48dddab22167678e5ad06513dfb58aa5ac3 Mon Sep 17 00:00:00 2001 From: Mia Wu Date: Fri, 26 Jun 2026 12:02:15 -0400 Subject: [PATCH] only one device per window, differenciate devices by color --- backend/scheduler.py | 170 +++++++++++++++------------ frontend/src/components/TestCard.jsx | 32 ++++- 2 files changed, 127 insertions(+), 75 deletions(-) diff --git a/backend/scheduler.py b/backend/scheduler.py index 46603d6..165dd26 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -138,6 +138,7 @@ def compile_schedule( 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 = ( @@ -169,14 +170,28 @@ def compile_schedule( for date_obj, shift_idx in shift_sequence } - placed_entries, placed_test_ids, placed_last_date = _fit_bundles_to_shifts( + 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) @@ -247,19 +262,22 @@ def _fit_bundles_to_shifts( 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]: + 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] = {} @@ -272,6 +290,7 @@ def _fit_bundles_to_shifts( 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. @@ -281,6 +300,13 @@ def _fit_bundles_to_shifts( 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 @@ -293,6 +319,9 @@ def _fit_bundles_to_shifts( 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:] @@ -353,8 +382,11 @@ def _fit_bundles_to_shifts( 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 + return entries, placed_test_ids, last_date, window_device def _create_bundles( dut_active_test_ids: set[TestKey], @@ -397,8 +429,8 @@ def _create_bundles( test = tests[representative_key] if test.test_type == "P2P": if test.has_coe_pair: - bundle_test_ids: list[TestKey] = [] 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) @@ -407,23 +439,23 @@ def _create_bundles( 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) - )) + 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): + bundle_test_ids: list[TestKey] = [] base_key = _active_key(test_id, device) if base_key is None or base_key in processed: continue @@ -434,36 +466,35 @@ def _create_bundles( 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: + 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 - processed.update(bundle_test_ids) + bundle_test_ids = [key] # 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: + if key[0] in top_priority_tests: priority_tier = BUNDLE_PRIORITY_FAILED # Highest priority (0) else: - priority_tier = BUNDLE_PRIORITY_P2P_ONLY + 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) + total_minutes=sum(tests[item].estimated_minutes for item 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) + 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) @@ -524,6 +555,11 @@ def _parse_date(value: str | None) -> date: 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], @@ -550,43 +586,31 @@ def _get_shift_sequence( shifts.append((start_date, 2)) return shifts - # If holiday on a weekday, treat as weekend (all 3 shifts) - if is_holiday and weekday < 5: + # 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 - - # 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 + + # Working-day windows always start at shift 3. + shifts.append((start_date, 3)) next_day = start_date + timedelta(days=1) - shifts.append((next_day, 1)) # Tomorrow shift 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 diff --git a/frontend/src/components/TestCard.jsx b/frontend/src/components/TestCard.jsx index c6abfe7..dc0188c 100644 --- a/frontend/src/components/TestCard.jsx +++ b/frontend/src/components/TestCard.jsx @@ -4,6 +4,28 @@ const STATUS_STYLES = { rerun: 'bg-red-800/60 text-red-200 border-red-600', } +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', +] + +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) { if (!config || typeof config !== 'object') return '—' const entries = Object.entries(config) @@ -24,13 +46,15 @@ function formatConfig(config) { export default function TestCard({ test }) { const style = STATUS_STYLES[test.status] ?? STATUS_STYLES.pending + const deviceAccentClass = getDeviceAccentClass(test.device) return (
+
{/* Hover tooltip */} @@ -38,7 +62,11 @@ export default function TestCard({ test }) {

Type: {test.test_type ?? '—'}

Rotation: {test.rotation ?? '—'}

-

Device: {test.device ?? '—'}

+

+ Device: +

Config: {formatConfig(test.config)}