Update scheduling algorithm
This commit is contained in:
@@ -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)
|
||||
@@ -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
@@ -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 (1am–10am) 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
@@ -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
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user