only one device per window, differenciate devices by color
This commit is contained in:
+96
-72
@@ -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
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="relative group">
|
||||
<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%' }}
|
||||
>
|
||||
<span className={`absolute inset-y-0 left-0 w-1 rounded-l ${deviceAccentClass}`} aria-hidden="true" />
|
||||
{test.test_id}
|
||||
</div>
|
||||
{/* 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">
|
||||
<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">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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user