Update scheduling algorithm

This commit is contained in:
2026-07-23 14:02:14 -04:00
parent 5eebee70d8
commit 23ae3a2f3a
12 changed files with 555 additions and 231 deletions
+10
View File
@@ -0,0 +1,10 @@
from parser import parse_target_csv
from db import DEVICE_DUT, DEVICE_REF
result = parse_target_csv(['data/CGW453_P2P_COE_Tests.csv'])
by_id = {(t.test_id, t.device): t for t in result.tests}
dut = by_id.get(('P2PTXBE018', DEVICE_DUT))
ref = by_id.get(('COETXBE012', DEVICE_REF))
print('DUT', dut is not None, dut.coe_pairing if dut else None)
print('REF', ref is not None, ref.coe_pairing if ref else None)
+11
View File
@@ -335,6 +335,16 @@ def get_settings() -> dict[str, Any]:
return db.read_settings(DB_PATH)
@app.post("/api/settings/restart")
def restart_and_clear_data() -> dict[str, Any]:
deleted_counts = db.reset_all_data(DB_PATH)
configure_result_watcher({})
return {
"status": "cleared",
**deleted_counts,
}
@app.post("/api/tests/load")
def load_tests(request: LoadTestsRequest) -> dict[str, Any]:
csv_path = _resolve_requested_csv_path(request.csv_path)
@@ -398,6 +408,7 @@ def compile_schedule_endpoint(request: CompileScheduleRequest) -> dict[str, Any]
config=t.config,
throttled=t.throttled,
estimated_minutes=t.estimated_minutes,
status=t.status,
)
for t in stored_tests
]
+183 -32
View File
@@ -6,7 +6,7 @@ from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterator
from test_config import serialize_station_testpoint_map
from test_config import serialize_station_testpoint_map, resolve_test_config_keys
APP_ROOT = Path(__file__).resolve().parent
@@ -166,7 +166,6 @@ def init_db(db_path: str | Path = DB_PATH) -> None:
"""
)
def _serialize_station_testpoint_map(config: dict[str, dict[str, str | None]]) -> str:
"""Compute and serialize the station-to-testpoint map from a test config."""
return serialize_station_testpoint_map(config)
@@ -377,6 +376,74 @@ def _should_exclude_by_rule(record: TestRecord, tokens: list[str]) -> bool:
return any(_matches_exclusion_token(record, token) for token in tokens)
def get_completed_test_tcs(db_path: str | Path = DB_PATH) -> set[str]:
"""Return the set of TC names that have at least one completed test."""
with get_connection(db_path) as conn:
rows = conn.execute(
"SELECT config_json FROM tests WHERE status = 'completed' AND excluded = 0"
).fetchall()
tcs: set[str] = set()
for row in rows:
config = json.loads(row["config_json"] or "{}")
tcs.update(resolve_test_config_keys(config))
return tcs
def get_completed_dut_test_ids(db_path: str | Path = DB_PATH) -> set[str]:
"""Return test IDs that have been completed on DUT."""
with get_connection(db_path) as conn:
rows = conn.execute(
"SELECT test_id FROM tests WHERE status = 'completed' AND excluded = 0 AND device = ?",
(DUT,)
).fetchall()
return {row["test_id"] for row in rows}
# Get tests for device that are not excluded regardless of completion status.
def get_not_excluded_tests(device: str, db_path: str | Path = DB_PATH, rules: dict[str, Any] | None = None) -> list[TestRecord]:
tokens = _parse_rule_tokens(rules.get("rule") if rules else "") if rules else []
with get_connection(db_path) as conn:
rows = conn.execute(
"""
SELECT
test_id, device, test_type, rotation, rx_tx, has_coe_pair,
power_mode, config_json, victim_band,
coe_pairing_json, priority, throttled, estimated_minutes,
excluded, status, raw_payload
FROM tests
WHERE excluded = 0
AND device = ?
""",
(device,)
).fetchall()
results: list[TestRecord] = []
for row in rows:
results.append(
TestRecord(
test_id=row["test_id"],
device=row["device"],
test_type=row["test_type"],
rotation=row["rotation"],
rx_tx=row["rx_tx"],
power_mode=row["power_mode"],
has_coe_pair=bool(row["has_coe_pair"]),
coe_pairing=json.loads(row["coe_pairing_json"] or "[]"),
priority=int(row["priority"]),
victim_band=row["victim_band"],
config=json.loads(row["config_json"] or "{}"),
throttled=bool(row["throttled"]),
estimated_minutes=int(row["estimated_minutes"]),
status=row["status"],
excluded=bool(row["excluded"]),
raw_payload=json.loads(row["raw_payload"] or "{}"),
)
)
if not tokens:
return results
return [record for record in results if not _should_exclude_by_rule(record, tokens)]
def list_schedulable_tests(db_path: str | Path = DB_PATH, rule: str = "") -> list[TestRecord]:
tokens = _parse_rule_tokens(rule)
with get_connection(db_path) as conn:
@@ -420,6 +487,7 @@ def list_schedulable_tests(db_path: str | Path = DB_PATH, rule: str = "") -> lis
return results
return [record for record in results if not _should_exclude_by_rule(record, tokens)]
# Get all tests for a specific device, regardless of exclusion or completion status. Used for schedule display and management.
def list_tests_for_device(device: str, db_path: str | Path = DB_PATH) -> list[TestRecord]:
with get_connection(db_path) as conn:
@@ -619,7 +687,24 @@ def get_schedule_week(
return []
use_live_status = latest_version is not None and selected_version == latest_version
status_sql = "COALESCE(t.status, s.status_snapshot, 'pending')" if use_live_status else "COALESCE(s.status_snapshot, 'pending')"
if use_live_status:
# Only surface live 'rerun' for slots that are actually overdue (past).
# Future slots fall back to status_snapshot so they don't show red.
status_sql = """
CASE
WHEN t.status = 'completed' THEN 'completed'
WHEN t.status = 'rerun'
AND (
s.scheduled_date < date('now')
OR (s.scheduled_date = date('now') AND s.shift_index = 1)
)
THEN 'rerun'
WHEN t.status = 'rerun' THEN COALESCE(s.status_snapshot, 'pending')
ELSE COALESCE(t.status, s.status_snapshot, 'pending')
END
"""
else:
status_sql = "COALESCE(s.status_snapshot, 'pending')"
rows = conn.execute(
f"""
@@ -679,7 +764,22 @@ def get_schedule_rows(
return []
use_live_status = latest_version is not None and selected_version == latest_version
status_sql = "COALESCE(t.status, s.status_snapshot, 'pending')" if use_live_status else "COALESCE(s.status_snapshot, 'pending')"
if use_live_status:
status_sql = """
CASE
WHEN t.status = 'completed' THEN 'completed'
WHEN t.status = 'rerun'
AND (
s.scheduled_date < date('now')
OR (s.scheduled_date = date('now') AND s.shift_index = 1)
)
THEN 'rerun'
WHEN t.status = 'rerun' THEN COALESCE(s.status_snapshot, 'pending')
ELSE COALESCE(t.status, s.status_snapshot, 'pending')
END
"""
else:
status_sql = "COALESCE(s.status_snapshot, 'pending')"
rows = conn.execute(
f"""
@@ -746,21 +846,36 @@ def mark_tests_completed(test_ids_with_device: list[tuple[str, str]], db_path: s
def mark_overdue_as_rerun(db_path: str | Path = DB_PATH) -> int:
"""Mark tests from the last overnight window that are still pending as rerun.
'Last overnight window' = all pending tests scheduled before today (any shift)
plus today's shift 1 (1am10am) if it has already ended (current hour >= 10).
Returns the number of tests newly marked as rerun.
"""
from datetime import date as _date, datetime as _datetime
now = _datetime.now()
today = now.date().isoformat()
shift1_ended = now.hour >= 10 # shift 1 ends ~10am
with get_connection(db_path) as conn:
version_row = conn.execute("SELECT MAX(schedule_version) AS latest FROM schedules").fetchone()
latest = version_row["latest"]
# Use the most recent schedule version that has any overdue (past-due) entries.
# If a fresh schedule was just compiled, its entries are all in the future and
# MAX(schedule_version) would miss the overdue context from the prior version.
if shift1_ended:
overdue_date_clause = "s.scheduled_date < :today OR (s.scheduled_date = :today AND s.shift_index = 1)"
else:
overdue_date_clause = "s.scheduled_date < :today"
version_row = conn.execute(
f"""
SELECT MAX(s.schedule_version) AS v
FROM schedules s
JOIN tests t ON t.test_id = s.test_id AND t.device = s.device
WHERE t.status = 'pending'
AND ({overdue_date_clause})
""",
{"today": today},
).fetchone()
latest = version_row["v"] if version_row else None
if latest is None:
# No version has overdue pending tests — fall back to MAX to still run the
# update (it will simply mark zero rows).
fb = conn.execute("SELECT MAX(schedule_version) AS v FROM schedules").fetchone()
latest = fb["v"] if fb else None
if latest is None:
return 0
@@ -807,36 +922,48 @@ def mark_overdue_as_rerun(db_path: str | Path = DB_PATH) -> int:
def get_rerun_tests(db_path: str | Path = DB_PATH) -> list[dict]:
"""Return tests scheduled before shift 2 today that are not yet completed.
This checks the latest schedule version and returns tests from:
- Yesterday's shift 3 (5pm-1am)
- Today's shift 1 (1am-10am)
Once tests are rescheduled to shift 2 or later today, they no longer appear.
"""Return all overdue tests that have not been completed.
Checks the most recent schedule version that contains overdue pending entries
so that recompiling a fresh schedule does not erase the overdue context.
Overdue = scheduled before today (any shift) or today shift 1 if it has ended.
"""
from datetime import date as _date
today = _date.today().isoformat()
from datetime import date as _date, datetime as _datetime
now = _datetime.now()
today = now.date().isoformat()
shift1_ended = now.hour >= 10
with get_connection(db_path) as conn:
version_row = conn.execute("SELECT MAX(schedule_version) AS latest FROM schedules").fetchone()
latest = version_row["latest"]
if shift1_ended:
overdue_date_clause = "s.scheduled_date < :today OR (s.scheduled_date = :today AND s.shift_index = 1)"
else:
overdue_date_clause = "s.scheduled_date < :today"
version_row = conn.execute(
f"""
SELECT MAX(s.schedule_version) AS v
FROM schedules s
JOIN tests t ON t.test_id = s.test_id AND t.device = s.device
WHERE t.status != 'completed'
AND ({overdue_date_clause})
""",
{"today": today},
).fetchone()
latest = version_row["v"] if version_row else None
if latest is None:
return []
rows = conn.execute(
"""
f"""
SELECT DISTINCT t.test_id, t.device, t.test_type, t.estimated_minutes
FROM tests t
JOIN schedules s ON s.test_id = t.test_id AND s.device = t.device
WHERE s.schedule_version = ?
WHERE s.schedule_version = :v
AND t.status != 'completed'
AND (
(s.scheduled_date = date(?, '-1 day') AND s.shift_index = 3)
OR (s.scheduled_date = ? AND s.shift_index = 1)
)
AND ({overdue_date_clause})
ORDER BY t.test_id, t.device
""",
(latest, today, today),
{"v": latest, "today": today},
).fetchall()
return [
{
@@ -865,3 +992,27 @@ def reset_tests(db_path: str | Path = DB_PATH) -> int:
cursor = conn.execute("DELETE FROM tests")
return cursor.rowcount
def reset_all_data(db_path: str | Path = DB_PATH) -> dict[str, int]:
"""Clear all scheduler data from mutable tables.
Keeps schema and runtime default rows intact.
"""
with get_connection(db_path) as conn:
schedules_deleted = conn.execute("DELETE FROM schedules").rowcount
tests_deleted = conn.execute("DELETE FROM tests").rowcount
settings_deleted = conn.execute("DELETE FROM settings").rowcount
holidays_deleted = conn.execute("DELETE FROM holidays").rowcount
# Reset autoincrement counters for cleared tables.
conn.execute(
"DELETE FROM sqlite_sequence WHERE name IN ('tests', 'schedules')"
)
return {
"tests_deleted": tests_deleted,
"schedules_deleted": schedules_deleted,
"settings_deleted": settings_deleted,
"holidays_deleted": holidays_deleted,
}
+3 -3
View File
@@ -536,13 +536,13 @@ def _band_signature(row: dict[str, str], band: str) -> tuple[str, ...] | None:
channel = _pick_value("Channel")
rssi = _pick_value("RSSI")
bandwidth = _pick_value("Bandwidth")
direction = _pick_value("Direction")
# Pairing should ignore direction so reverse-signed COE and P2P rows still match.
sta = _pick_value("STA")
if not all([test_point, channel, rssi, bandwidth, direction, sta]):
if not all([test_point, channel, rssi, bandwidth, sta]):
return None
return (test_point, channel, bandwidth, rssi, direction, sta)
return (test_point, channel, bandwidth, rssi, sta)
def _all_band_signatures(row: dict[str, str]) -> list[tuple[str, ...]]:
+72 -121
View File
@@ -4,7 +4,7 @@ import os
from dataclasses import dataclass
from datetime import date, timedelta
from test_window import get_shift_sequence_with_capacity, get_shift_capacity_for_date, is_off_day, next_window_start_date
from test_bundle import BUNDLE_PRIORITY_TOP, Test, TestBundle, build_test_bundles, bundle_pair_lookup
from test_bundle import Test, TestBundle, build_test_bundles, update_mirrored_bundle_priorities
DUT = (os.getenv("DUT") or "DUT").strip()
REF = (os.getenv("REF") or "REF").strip()
@@ -38,7 +38,7 @@ class Scheduler:
daytime_testing_today: bool = False,
dual_device_weekend_start_enabled: bool = False,
dual_device_window_start_dates: set[str] | None = None,
priority_weight: int = 100,
priority_weight: int = 1000,
):
self.top_priority_tests = top_priority_tests
self.tests = tests
@@ -50,11 +50,10 @@ class Scheduler:
self.dual_device_weekend_start_enabled = dual_device_weekend_start_enabled
self.dual_device_window_start_dates = dual_device_window_start_dates or set()
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_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)
@@ -65,80 +64,42 @@ class Scheduler:
print("Compiled Test Bundles:")
for bundle in bundles:
print(f"Bundle Index: {bundle.index}, Device: {bundle.device}, Priority: {bundle.priority}, Config: {bundle.config}, Total Minutes: {bundle.total_minutes}, Tests: {bundle.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])
all_bundles_by_tc = self.create_tc_dict(bundles, all_tcs)
# 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
# Process TCs that include top-priority bundles first.
# REF top-priority TCs come before DUT top-priority TCs so that
# entering tests in the REF top-priority field always claims tonight's
# first window for REF.
def tc_sort_key(item: tuple[str | None, list[TestBundle]]) -> tuple[int, int, int, str]:
tc, tc_bundles = item
has_ref_top = any(bundle.priority == BUNDLE_PRIORITY_TOP and bundle.device == REF for bundle in tc_bundles)
has_dut_top = any(bundle.priority == BUNDLE_PRIORITY_TOP and bundle.device == DUT for bundle in tc_bundles)
min_priority = min((bundle.priority for bundle in tc_bundles), default=BUNDLE_PRIORITY_TOP + 99)
tc_name = tc if tc is not None else "~"
if has_dut_top:
tier = 0 # DUT top-priority TCs first (wins when both exist)
elif has_ref_top:
tier = 1 # REF-only top-priority TCs second
else:
tier = 2 # All other TCs
return (tier, min_priority, -len(tc_bundles), tc_name)
tc_order = self.get_tc_order(self.tests)
sorted_tc_items = sorted(
[(tc, bundles_by_tc[tc]) for tc in bundles_by_tc],
key=tc_sort_key,
)
for tc in tc_order:
tc_bundles = all_bundles_by_tc[tc]
if tc_bundles is None or len(tc_bundles) == 0:
continue
window_device = DUT
pending_dut: list[TestBundle] = []
pending_ref: list[TestBundle] = []
for bundle in tc_bundles:
if bundle.device == DUT:
pending_dut.append(bundle)
for tc, tc_bundles in sorted_tc_items:
tc_has_ref_top_priority = any(
bundle.device == REF and bundle.priority == BUNDLE_PRIORITY_TOP
for bundle in tc_bundles
)
tc_has_dut_top_priority = any(
bundle.device == DUT and bundle.priority == BUNDLE_PRIORITY_TOP
for bundle in tc_bundles
)
elif bundle.device == REF:
pending_ref.append(bundle)
if tc_has_ref_top_priority:
window_device = REF
elif tc_has_dut_top_priority:
window_device = DUT
else:
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:
while pending_dut or pending_ref:
active_pending = pending_dut if window_device == DUT else pending_ref
if len(active_pending) == 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
active_pending = pending_dut if window_device == DUT else pending_ref
shifts, capacity = get_shift_sequence_with_capacity(cursor_date, self.holiday_dates, self.daytime_testing_today)
cursor_date_key = cursor_date.isoformat()
@@ -146,60 +107,20 @@ class Scheduler:
if not dual_device_window and self.dual_device_weekend_start_enabled:
dual_device_window = self._is_weekend_start_day(cursor_date)
# 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
active_unscheduled = dut_unscheduled if window_device == DUT else ref_unscheduled
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
selected_bundles = []
selected_bundles = self._knapsack_select(capacity, active_pending)
remaining_time = capacity - sum(bundle.total_minutes for bundle in selected_bundles)
primary_device = window_device
if dual_device_window and remaining_time > 0:
secondary_device = REF if primary_device == DUT else DUT
secondary_unscheduled = dut_unscheduled if secondary_device == DUT else ref_unscheduled
secondary_mirrored, secondary_knapsack, remaining_time = self._select_bundles(
secondary_device,
remaining_time,
secondary_unscheduled,
tc,
mutate=True,
)
selected_bundles.extend(secondary_mirrored + secondary_knapsack)
# 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}
selected_bundle_keys = {(b.index, b.device) for b in selected_bundles}
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 or pair_key in selected_bundle_keys:
continue
pending_target.append(pair_bundle)
existing_pending.add(pair_key)
secondary_pending = pending_dut if secondary_device == DUT else pending_ref
secondary_selected_bundles = self._knapsack_select(remaining_time, secondary_pending)
selected_bundles.extend(secondary_selected_bundles)
# Create a mirror of the other device for the next window by updating the priorities
pending_dut, pending_ref = update_mirrored_bundle_priorities(selected_bundles, pending_dut, pending_ref)
window = ScheduleWindow(
index=window_index,
@@ -216,12 +137,12 @@ class Scheduler:
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]
for all_tc in all_bundles_by_tc:
all_bundles_by_tc[all_tc] = [b for b in all_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]
# Remove from pending queues
pending_dut = [b for b in pending_dut if (b.index, b.device) not in selected_keys]
pending_ref = [b for b in pending_ref if (b.index, b.device) not in selected_keys]
window_index += 1
window_device = REF if window_device == DUT else DUT
@@ -243,7 +164,7 @@ class Scheduler:
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
pending = self.pending_dut if device == DUT else self.pending_ref
mirrored_bundles: list[TestBundle] = []
still_pending: list[TestBundle] = [] # Bundles that couldn't fit in the remaining capacity
remaining_capacity = capacity
@@ -288,11 +209,13 @@ class Scheduler:
) -> 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]
# Values strongly favor higher priority tiers without hard-forcing them.
max_priority = max(bundle.priority for bundle in candidates)
priority_bias = 2.5
values = [int(self.priority_weight * (priority_bias ** (max_priority - bundle.priority))) for bundle in candidates]
# Implement dynamic programming knapsack algorithm to select bundles
n = len(candidates)
@@ -387,6 +310,34 @@ class Scheduler:
if device == REF and test_id in self.active_ref:
return self.active_ref[test_id].estimated_minutes
return 0
def create_tc_dict(self, bundles: list[TestBundle], all_tcs: set[str | None]) -> dict[str | None, list[TestBundle]]:
# Create dict: TC -> bundles supporting that TC
all_bundles_by_tc: dict[str | None, list[TestBundle]] = {}
# 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:
all_bundles_by_tc[tc] = [b for b in bundles if not b.config]
else:
all_bundles_by_tc[tc] = [b for b in bundles if tc in b.config]
# Sort by priority, then index
all_bundles_by_tc[tc].sort(key=lambda b: (b.priority, b.index))
return all_bundles_by_tc
def get_tc_order(self, all_tests: list[Test]) -> list[str | None]:
# Implement the logic for getting the test case order
all_dut = {test.test_id: test for test in all_tests if test.device == DUT}
all_ref = {test.test_id: test for test in all_tests if test.device == REF}
bundles = build_test_bundles(all_dut, all_ref, [])
all_tcs = set()
for bundle in bundles:
all_tcs.update(bundle.config if bundle.config else [None])
all_bundles_by_tc = self.create_tc_dict(bundles, all_tcs)
# Sort TCs by the number of bundles available for each TC (most to least)
sorted_tcs = sorted(all_bundles_by_tc.keys(), key=lambda tc: len(all_bundles_by_tc[tc]), reverse=True)
return sorted_tcs
+95 -70
View File
@@ -4,8 +4,7 @@ from dataclasses import dataclass
import os
from test_config import build_bundle_test_configs
@dataclass(frozen=True)
@dataclass(frozen=False)
class Test:
test_id: str
device: str
@@ -17,18 +16,22 @@ class Test:
config: dict[str, dict[str, str | None]]
throttled: bool
estimated_minutes: int
status: str
# Bundle priority tiers for scheduling order (lower number = higher priority)
BUNDLE_PRIORITY_TOP = 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
BUNDLE_PRIORITY_DUT_COMPLETED_MIRROR = 0 # REF-side tests whose DUT counterpart is already completed
BUNDLE_PRIORITY_USER_TOP = 1 # User-specified top priority tests
BUNDLE_PRIORITY_RERUN = 2 # Rerun tests (DUT or REF)
BUNDLE_PRIORITY_MIRROR = 3
BUNDLE_PRIORITY_P2P_WITH_COE = 4 # P2P tests with COE pairs
BUNDLE_PRIORITY_P2P_ONLY = 5 # P2P tests without COE pairs (RX/TX bundled)
BUNDLE_PRIORITY_COE_ONLY = 6 # COE tests without P2P pairing (should be rare/unschedulable)
BUNDLE_PRIORITY_P3P = 6 # P3P tests
DUT = (os.getenv("DUT") or "DUT").strip()
REF = (os.getenv("REF") or "REF").strip()
@dataclass(frozen=True)
@dataclass(frozen=False)
class TestBundle:
index: int
tests: list[str]
@@ -36,7 +39,8 @@ class TestBundle:
device: str
config: tuple[str, ...]
priority: int
completed: int
def build_test_bundles(active_dut: dict[str, Test], active_ref: dict[str, Test], top_priority_tests: set[tuple[str, str]]) -> list[TestBundle]:
"""Build deterministic bundles for DP scheduling."""
@@ -55,25 +59,6 @@ def build_test_bundles(active_dut: dict[str, Test], active_ref: dict[str, Test],
deduped.append(test_id)
return deduped
dut_top_priority: list[str] = []
ref_top_priority: list[str] = []
for test_id, device in top_priority_tests:
if device == DUT and test_id in active_dut:
dut_top_priority.append(test_id)
elif device == REF and test_id in active_ref:
ref_top_priority.append(test_id)
if dut_top_priority:
test_bundles.append(create_bundle(dut_top_priority, DUT, BUNDLE_PRIORITY_TOP, bundle_index, active_dut, active_ref))
processed_dut.update(dut_top_priority)
bundle_index += 1
if ref_top_priority:
test_bundles.append(create_bundle(ref_top_priority, REF, BUNDLE_PRIORITY_TOP, bundle_index, active_dut, active_ref))
processed_ref.update(ref_top_priority)
bundle_index += 1
# Phase 1: DUT P2P bundles
for dut_test_id, dut_test in active_dut.items():
if dut_test_id in processed_dut or dut_test.test_type != "P2P":
@@ -100,6 +85,15 @@ def build_test_bundles(active_dut: dict[str, Test], active_ref: dict[str, Test],
priority = BUNDLE_PRIORITY_P2P_ONLY
dut_bundled_tests = dedupe_preserve_order(dut_bundled_tests)
# Check if any of the DUT tests in this bundle are in the top priority list
if any((DUT, dut_test_id) in top_priority_tests for dut_test_id in dut_bundled_tests):
priority = BUNDLE_PRIORITY_USER_TOP
# Check if any of the DUT tests in this bundle are reruns
if any((dut_test.status == "rerun" for dut_test_id in dut_bundled_tests if (dut_test := active_dut.get(dut_test_id)))):
priority = BUNDLE_PRIORITY_RERUN
test_bundles.append(
create_bundle(dut_bundled_tests, DUT, priority, bundle_index, active_dut, active_ref)
)
@@ -112,6 +106,14 @@ def build_test_bundles(active_dut: dict[str, Test], active_ref: dict[str, Test],
if ref_bundled_tests:
ref_bundled_tests = dedupe_preserve_order(ref_bundled_tests)
processed_ref.update(ref_bundled_tests)
# Check if any of the REF tests in this bundle are in the top priority list
if any((REF, ref_test_id) in top_priority_tests for ref_test_id in ref_bundled_tests):
priority = BUNDLE_PRIORITY_USER_TOP
# Check if any of the REF tests in this bundle are reruns
if any((ref_test.status == "rerun" for ref_test_id in ref_bundled_tests if (ref_test := active_ref.get(ref_test_id)))):
priority = BUNDLE_PRIORITY_RERUN
test_bundles.append(
create_bundle(ref_bundled_tests, REF, priority, bundle_index, active_dut, active_ref)
)
@@ -132,8 +134,18 @@ def build_test_bundles(active_dut: dict[str, Test], active_ref: dict[str, Test],
processed_dut.add(th_ut_pair_id)
dut_bundled_tests = dedupe_preserve_order(dut_bundled_tests)
priority = BUNDLE_PRIORITY_P3P
# Check if any of the DUT tests in this bundle are in the top priority list
if any((DUT, dut_test_id) in top_priority_tests for dut_test_id in dut_bundled_tests):
priority = BUNDLE_PRIORITY_USER_TOP
# Check if any of the DUT tests in this bundle are reruns
if any((dut_test.status == "rerun" for dut_test_id in dut_bundled_tests if (dut_test := active_dut.get(dut_test_id)))):
priority = BUNDLE_PRIORITY_RERUN
test_bundles.append(
create_bundle(dut_bundled_tests, DUT, BUNDLE_PRIORITY_P3P, bundle_index, active_dut, active_ref)
create_bundle(dut_bundled_tests, DUT, priority, bundle_index, active_dut, active_ref)
)
ref_bundled_tests = [
@@ -141,49 +153,66 @@ def build_test_bundles(active_dut: dict[str, Test], active_ref: dict[str, Test],
for test_id in dut_bundled_tests
if test_id in active_ref and test_id not in processed_ref
]
# Check if any of the REF tests in this bundle are in the top priority list
if any((REF, ref_test_id) in top_priority_tests for ref_test_id in ref_bundled_tests):
priority = BUNDLE_PRIORITY_USER_TOP
# Check if any of the REF tests in this bundle are reruns
if any((ref_test.status == "rerun" for ref_test_id in ref_bundled_tests if (ref_test := active_ref.get(ref_test_id)))):
priority = BUNDLE_PRIORITY_RERUN
if ref_bundled_tests:
ref_bundled_tests = dedupe_preserve_order(ref_bundled_tests)
processed_ref.update(ref_bundled_tests)
test_bundles.append(
create_bundle(ref_bundled_tests, REF, BUNDLE_PRIORITY_P3P, bundle_index, active_dut, active_ref)
create_bundle(ref_bundled_tests, REF, priority, bundle_index, active_dut, active_ref)
)
bundle_index += 1
# Phase 3: DUT leftovers (including COE-only)
# Phase 3: DUT leftovers (COE-only)
for dut_test_id in active_dut:
if dut_test_id in processed_dut:
continue
print(f"Orphan COE-only DUT test found: {dut_test_id}")
test_bundles.append(
create_bundle([dut_test_id], DUT, BUNDLE_PRIORITY_COE_ONLY, bundle_index, active_dut, active_ref)
)
bundle_index += 1
processed_dut.add(dut_test_id)
# Phase 4: unmatched REF P2P bundles
# Phase 4: unmatched REF bundles, meaning DUT is completed
for ref_test_id, ref_test in active_ref.items():
if ref_test_id in processed_ref or ref_test.test_type != "P2P":
if ref_test_id in processed_ref:
continue
ref_bundled_tests = [ref_test_id]
processed_ref.add(ref_test_id)
active_coe_pairings = [
test_id
for test_id in ref_test.coe_pairing
if test_id in active_ref and test_id not in processed_ref
]
if ref_test.test_type == "P2P":
active_coe_pairings = [
test_id
for test_id in ref_test.coe_pairing
if test_id in active_ref and test_id not in processed_ref
]
if active_coe_pairings:
ref_bundled_tests.extend(active_coe_pairings)
processed_ref.update(active_coe_pairings)
priority = BUNDLE_PRIORITY_P2P_WITH_COE
else:
rx_tx_pair_id = get_rx_tx_pair_id(ref_test, REF, active_dut, active_ref)
if rx_tx_pair_id and rx_tx_pair_id not in processed_ref:
ref_bundled_tests.append(rx_tx_pair_id)
processed_ref.add(rx_tx_pair_id)
priority = BUNDLE_PRIORITY_P2P_ONLY
if active_coe_pairings:
ref_bundled_tests.extend(active_coe_pairings)
processed_ref.update(active_coe_pairings)
priority = BUNDLE_PRIORITY_DUT_COMPLETED_MIRROR
else:
rx_tx_pair_id = get_rx_tx_pair_id(ref_test, REF, active_dut, active_ref)
if rx_tx_pair_id and rx_tx_pair_id not in processed_ref:
ref_bundled_tests.append(rx_tx_pair_id)
processed_ref.add(rx_tx_pair_id)
priority = BUNDLE_PRIORITY_DUT_COMPLETED_MIRROR
if ref_test.test_type == "P3P":
th_ut_pair_id = get_th_ut_pair_id(ref_test, REF, active_dut, active_ref)
if th_ut_pair_id and th_ut_pair_id not in processed_ref:
ref_bundled_tests.append(th_ut_pair_id)
processed_ref.add(th_ut_pair_id)
priority = BUNDLE_PRIORITY_DUT_COMPLETED_MIRROR
ref_bundled_tests = dedupe_preserve_order(ref_bundled_tests)
test_bundles.append(
@@ -191,31 +220,13 @@ def build_test_bundles(active_dut: dict[str, Test], active_ref: dict[str, Test],
)
bundle_index += 1
# Phase 5: unmatched REF P3P bundles
for ref_test_id, ref_test in active_ref.items():
if ref_test_id in processed_ref or ref_test.test_type != "P3P":
continue
ref_bundled_tests = [ref_test_id]
processed_ref.add(ref_test_id)
th_ut_pair_id = get_th_ut_pair_id(ref_test, REF, active_dut, active_ref)
if th_ut_pair_id and th_ut_pair_id not in processed_ref:
ref_bundled_tests.append(th_ut_pair_id)
processed_ref.add(th_ut_pair_id)
ref_bundled_tests = dedupe_preserve_order(ref_bundled_tests)
test_bundles.append(
create_bundle(ref_bundled_tests, REF, BUNDLE_PRIORITY_P3P, bundle_index, active_dut, active_ref)
)
bundle_index += 1
# Phase 6: REF leftovers (including COE-only)
# Phase 5: REF leftovers ( COE-only)
for ref_test_id in active_ref:
if ref_test_id in processed_ref:
continue
print(f"Orphan COE-only REF test found: {ref_test_id}")
test_bundles.append(
create_bundle([ref_test_id], REF, BUNDLE_PRIORITY_COE_ONLY, bundle_index, active_dut, active_ref)
create_bundle([ref_test_id], REF, BUNDLE_PRIORITY_DUT_COMPLETED_MIRROR, bundle_index, active_dut, active_ref)
)
bundle_index += 1
processed_ref.add(ref_test_id)
@@ -256,6 +267,7 @@ def create_bundle(tests: list[str], device: str, priority: int, index: int, acti
device=device,
config=config,
priority=priority,
completed=0
)
def bundle_pair_lookup(bundles: list[TestBundle], dut_bundles: list[TestBundle], ref_bundles: list[TestBundle]) -> list[TestBundle]:
@@ -267,4 +279,17 @@ def bundle_pair_lookup(bundles: list[TestBundle], dut_bundles: list[TestBundle],
return [b for b in ref_bundles if b.index in bundle_indexes]
elif device == REF:
return [b for b in dut_bundles if b.index in bundle_indexes]
return []
return []
def update_mirrored_bundle_priorities(knapsack_bundles: list[TestBundle], dut_pending: list[TestBundle], ref_pending: list[TestBundle]) -> tuple[list[TestBundle], list[TestBundle]]:
bundle_pairs = bundle_pair_lookup(knapsack_bundles, dut_pending, ref_pending)
for bundle in dut_pending:
if bundle.index in {b.index for b in bundle_pairs}:
bundle.priority = BUNDLE_PRIORITY_MIRROR
for bundle in ref_pending:
if bundle.index in {b.index for b in bundle_pairs}:
bundle.priority = BUNDLE_PRIORITY_MIRROR
return dut_pending, ref_pending