only one device per window, differenciate devices by color
This commit is contained in:
+66
-42
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user