only one device per window, differenciate devices by color

This commit is contained in:
2026-06-26 12:02:15 -04:00
parent be13849a4f
commit 031da48ddd
2 changed files with 127 additions and 75 deletions
+66 -42
View File
@@ -138,6 +138,7 @@ def compile_schedule(
current_date = window_start_date current_date = window_start_date
last_date: str | None = None last_date: str | None = None
daytime_shift2_window_pending = daytime_testing_today daytime_shift2_window_pending = daytime_testing_today
date_device_lock: dict[str, str] = {}
while dut_active_priority or ref_active_priority: while dut_active_priority or ref_active_priority:
is_special_daytime_shift2_window = ( is_special_daytime_shift2_window = (
@@ -169,14 +170,28 @@ def compile_schedule(
for date_obj, shift_idx in shift_sequence 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, bundles=bundles,
shift_sequence=shift_sequence, shift_sequence=shift_sequence,
tests=_TESTS_BY_ID, tests=_TESTS_BY_ID,
graph_by_test_id=graph.get_graph(), graph_by_test_id=graph.get_graph(),
shift_capacities=shift_capacities, shift_capacities=shift_capacities,
forced_device=forced_device,
) )
entries.extend(placed_entries) 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: for key in placed_test_ids:
dut_active_priority.pop(key, None) dut_active_priority.pop(key, None)
ref_active_priority.pop(key, None) ref_active_priority.pop(key, None)
@@ -247,19 +262,22 @@ def _fit_bundles_to_shifts(
tests: dict[TestKey, SchedulerTest], tests: dict[TestKey, SchedulerTest],
graph_by_test_id: dict[str, set[str]], graph_by_test_id: dict[str, set[str]],
shift_capacities: dict[tuple[date, int], int], 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). """Fit bundles into a shift sequence window (e.g., one day or one weekend).
Returns: Returns:
- List of ScheduleEntry for placed tests - List of ScheduleEntry for placed tests
- Set of test_ids that were placed - Set of test_ids that were placed
- Last scheduled date - 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. Bundles are placed in sequence order and may span multiple shifts inside the same window.
""" """
entries: list[ScheduleEntry] = [] entries: list[ScheduleEntry] = []
placed_test_ids: set[TestKey] = set() placed_test_ids: set[TestKey] = set()
last_date: str | None = None last_date: str | None = None
window_device: str | None = None
# Track state per shift # Track state per shift
shift_state: dict[tuple[date, int], dict] = {} 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)} shift_positions = {date_shift: idx for idx, date_shift in enumerate(shift_sequence)}
current_shift_pos = 0 current_shift_pos = 0
window_placed_test_ids: set[str] = set() window_placed_test_ids: set[str] = set()
window_placed_devices: set[str] = set()
for bundle in bundles: for bundle in bundles:
# Skip bundles that are already fully placed. # Skip bundles that are already fully placed.
@@ -281,6 +300,13 @@ def _fit_bundles_to_shifts(
if not bundle.test_ids: if not bundle.test_ids:
continue 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. # Keep all tests in the same window pairwise-compatible.
if window_placed_test_ids: if window_placed_test_ids:
is_compatible_with_window = True is_compatible_with_window = True
@@ -293,6 +319,9 @@ def _fit_bundles_to_shifts(
if not is_compatible_with_window: if not is_compatible_with_window:
continue 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. # Ensure the whole bundle can still fit somewhere in the remaining window.
remaining_window = sum( remaining_window = sum(
shift_state[date_shift]['remaining_minutes'] for date_shift in shift_sequence[current_shift_pos:] 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: if placed_bundle_tests:
window_placed_test_ids.update(test_key[0] for test_key in bundle.test_ids) 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( def _create_bundles(
dut_active_test_ids: set[TestKey], dut_active_test_ids: set[TestKey],
@@ -397,8 +429,8 @@ def _create_bundles(
test = tests[representative_key] test = tests[representative_key]
if test.test_type == "P2P": if test.test_type == "P2P":
if test.has_coe_pair: if test.has_coe_pair:
bundle_test_ids: list[TestKey] = []
for device in (DUT, REF): for device in (DUT, REF):
bundle_test_ids: list[TestKey] = []
base_key = _active_key(test_id, device) base_key = _active_key(test_id, device)
if base_key is not None and base_key not in processed: if base_key is not None and base_key not in processed:
bundle_test_ids.append(base_key) bundle_test_ids.append(base_key)
@@ -422,8 +454,8 @@ def _create_bundles(
total_minutes=sum(tests[key].estimated_minutes for key in bundle_test_ids) total_minutes=sum(tests[key].estimated_minutes for key in bundle_test_ids)
)) ))
else: else:
bundle_test_ids: list[TestKey] = []
for device in (DUT, REF): for device in (DUT, REF):
bundle_test_ids: list[TestKey] = []
base_key = _active_key(test_id, device) base_key = _active_key(test_id, device)
if base_key is None or base_key in processed: if base_key is None or base_key in processed:
continue continue
@@ -434,8 +466,6 @@ def _create_bundles(
if pair_key is not None and pair_key not in processed and pair_key not in bundle_test_ids: 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) bundle_test_ids.append(pair_key)
if not bundle_test_ids:
continue
processed.update(bundle_test_ids) processed.update(bundle_test_ids)
# Check if any test in bundle is top priority # Check if any test in bundle is top priority
bundle_test_ids_only = {key[0] for key in bundle_test_ids} bundle_test_ids_only = {key[0] for key in bundle_test_ids}
@@ -449,19 +479,20 @@ def _create_bundles(
total_minutes=sum(tests[key].estimated_minutes for key in bundle_test_ids) total_minutes=sum(tests[key].estimated_minutes for key in bundle_test_ids)
)) ))
elif test.test_type == "P3P": 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] for device in (DUT, REF):
if not bundle_test_ids: key = _active_key(test_id, device)
if key is None or key in processed:
continue continue
bundle_test_ids = [key]
# Check if any test in bundle is top priority # Check if any test in bundle is top priority
bundle_test_ids_only = {key[0] for key in bundle_test_ids} if key[0] in top_priority_tests:
if bundle_test_ids_only & top_priority_tests:
priority_tier = BUNDLE_PRIORITY_FAILED # Highest priority (0) priority_tier = BUNDLE_PRIORITY_FAILED # Highest priority (0)
else: else:
priority_tier = BUNDLE_PRIORITY_P3P priority_tier = BUNDLE_PRIORITY_P3P
bundles.append(TestBundle( bundles.append(TestBundle(
test_ids=bundle_test_ids, test_ids=bundle_test_ids,
priority_tier=priority_tier, 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)
)) ))
processed.update(bundle_test_ids) processed.update(bundle_test_ids)
@@ -524,6 +555,11 @@ def _parse_date(value: str | None) -> date:
return datetime.strptime(value, "%Y-%m-%d").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( def _get_shift_sequence(
start_date: date, start_date: date,
holiday_dates: set[str], holiday_dates: set[str],
@@ -550,43 +586,31 @@ def _get_shift_sequence(
shifts.append((start_date, 2)) shifts.append((start_date, 2))
return shifts return shifts
# If holiday on a weekday, treat as weekend (all 3 shifts) # Any off-day (weekend/holiday) uses full daytime+night shifts for that date.
if is_holiday and weekday < 5: if _is_off_day(start_date, holiday_dates):
shifts.append((start_date, 1)) shifts.append((start_date, 1))
shifts.append((start_date, 2)) shifts.append((start_date, 2))
shifts.append((start_date, 3)) shifts.append((start_date, 3))
return shifts return shifts
# Standard weekend day (Sat/Sun) # Working-day windows always start at shift 3.
if weekday >= 5:
shifts.append((start_date, 1))
shifts.append((start_date, 2))
shifts.append((start_date, 3)) 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) 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 return shifts
+30 -2
View File
@@ -4,6 +4,28 @@ const STATUS_STYLES = {
rerun: 'bg-red-800/60 text-red-200 border-red-600', 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) { function formatConfig(config) {
if (!config || typeof config !== 'object') return '—' if (!config || typeof config !== 'object') return '—'
const entries = Object.entries(config) const entries = Object.entries(config)
@@ -24,13 +46,15 @@ function formatConfig(config) {
export default function TestCard({ test }) { export default function TestCard({ test }) {
const style = STATUS_STYLES[test.status] ?? STATUS_STYLES.pending const style = STATUS_STYLES[test.status] ?? STATUS_STYLES.pending
const deviceAccentClass = getDeviceAccentClass(test.device)
return ( return (
<div className="relative group"> <div className="relative group">
<div <div
className={`px-1.5 py-0.5 rounded border text-xs font-mono truncate cursor-default select-none ${style}`} className={`relative px-1.5 py-0.5 pl-3 rounded border text-xs font-mono truncate cursor-default select-none ${style}`}
style={{ maxWidth: '100%' }} style={{ maxWidth: '100%' }}
> >
<span className={`absolute inset-y-0 left-0 w-1 rounded-l ${deviceAccentClass}`} aria-hidden="true" />
{test.test_id} {test.test_id}
</div> </div>
{/* Hover tooltip */} {/* Hover tooltip */}
@@ -38,7 +62,11 @@ export default function TestCard({ test }) {
<div className="bg-gray-800 border border-gray-600 rounded-md px-2.5 py-1.5 text-xs text-gray-200 shadow-lg max-w-md"> <div className="bg-gray-800 border border-gray-600 rounded-md px-2.5 py-1.5 text-xs text-gray-200 shadow-lg max-w-md">
<p><span className="text-gray-400">Type:</span> {test.test_type ?? '—'}</p> <p><span className="text-gray-400">Type:</span> {test.test_type ?? '—'}</p>
<p><span className="text-gray-400">Rotation:</span> {test.rotation ?? '—'}</p> <p><span className="text-gray-400">Rotation:</span> {test.rotation ?? '—'}</p>
<p><span className="text-gray-400">Device:</span> {test.device ?? '—'}</p> <p className="flex items-center gap-1.5">
<span className="text-gray-400">Device:</span>
<span className={`inline-block w-2 h-2 rounded-full ${deviceAccentClass}`} aria-hidden="true" />
<span>{test.device ?? '—'}</span>
</p>
<p className="mt-1 pt-1 border-t border-gray-600"><span className="text-gray-400">Config:</span> {formatConfig(test.config)}</p> <p className="mt-1 pt-1 border-t border-gray-600"><span className="text-gray-400">Config:</span> {formatConfig(test.config)}</p>
</div> </div>
</div> </div>