diff --git a/ONE_DAY_SCHEDULER.md b/ONE_DAY_SCHEDULER.md new file mode 100644 index 0000000..c1cf6c1 --- /dev/null +++ b/ONE_DAY_SCHEDULER.md @@ -0,0 +1,40 @@ +# Redesign Scheduler: +Instead of creating a whole schedule at once, schedule tests for today first, then fill in the rest with the original scheduler design. Add a button above remake schedule that opens up a modal and show the following. User has to confirm this new modal before able to click remake schedule. + +## Backend: +- Device: keeps track of what device should tests be performed on today. +- DUT priority queue keeps track of: + - rerun required DUT tests + +- REF priority queue keeps track of: + - DUT completed tests mirrored (priority 1) + - rerun required REF tests (prirority 2) + - rest of the test bundles in TC + - P2P COE test bundles priority 3 + - P2P only test bundles priority 4 + - P3P test bundles priority 5 + +Workflow: +1. fill pending queues: + - fill pending queues with all tests bundles in the current TC + - when there are DUT tests completed last test window, add to REF pending queue with priority 1 + - when there are DUT tests rerun required for last test window, add to DUT pending queue with priority 2 + - when there are REF tests rerun required for last test window, add to REF pending queue with priority 2 +2. Create schedule for today + - Day time testing 9AM - 5PM + - take in user inputs for device and hours(capcity) + - fill in as many test from the chosen device pending queue as possible. If the chosen device pending queue is empty, use the original scheduler knapsack select from active list for that device + - Night time test window + - calculate capacity + - fill in as many tests from the today's device + - if still time available, fill with knapsack select + - if queue empty, fill with knapsack select + +## Frontend +- Display DUT pending queue and REF pending queue + - DUT on the left, REF on the right + - list tests +- Display device: if last testing window was DUT then default device should be REF for tonight, vice versa. user has the choice to override this +- Display current Test Config (TC) +- Display day time available hours (default 8 hours) +- Diaplay night time test window available hours (16 hours for workday, 64 for weekends) \ No newline at end of file diff --git a/backend/_tmp_validate_pairing.py b/backend/_tmp_validate_pairing.py new file mode 100644 index 0000000..fcdb1c0 --- /dev/null +++ b/backend/_tmp_validate_pairing.py @@ -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) diff --git a/backend/app.py b/backend/app.py index 426554b..ed5d5c5 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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 ] diff --git a/backend/db.py b/backend/db.py index 8bc7f77..97960f0 100644 --- a/backend/db.py +++ b/backend/db.py @@ -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, + } + diff --git a/backend/parser.py b/backend/parser.py index 56e277c..b6d1877 100644 --- a/backend/parser.py +++ b/backend/parser.py @@ -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, ...]]: diff --git a/backend/scheduler.py b/backend/scheduler.py index 37fd098..f3df0ae 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -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 diff --git a/backend/test_bundle.py b/backend/test_bundle.py index 1b887f7..0309883 100644 --- a/backend/test_bundle.py +++ b/backend/test_bundle.py @@ -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 [] \ No newline at end of file + 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 \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1715fff..a27596d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -402,6 +402,35 @@ export default function App() { } } + async function handleRestartAndClearData() { + setLoading(true) + setError(null) + try { + await api.restartAndClearData() + + setSettings(DEFAULT_SETTINGS) + setTopPriorityDut('') + setTopPriorityRef('') + setLowestPriority('') + setStartDateOverride('') + setFailedTests([]) + setScheduleData({}) + setPreviousScheduleData({}) + setScheduleStartShift(null) + setScheduleWindows([]) + setSelectedWindowId(null) + setCompletionDate(null) + + await fetchSchedule(weekStart) + await fetchRerunTests() + } catch (e) { + setError(e.message) + throw e + } finally { + setLoading(false) + } + } + async function handleRemakeSchedule() { setLoading(true) setError(null) @@ -541,6 +570,7 @@ export default function App() { onClose={() => setSettingsOpen(false)} settings={settings} onSave={handleSaveSettings} + onRestart={handleRestartAndClearData} /> ) diff --git a/frontend/src/api.js b/frontend/src/api.js index 80099b5..b25bd54 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -20,6 +20,7 @@ export const api = { // Settings getSettings: () => request('GET', '/settings'), saveSettings: (settings) => request('POST', '/settings', { settings }), + restartAndClearData: () => request('POST', '/settings/restart'), // Holidays getHolidays: () => request('GET', '/holidays'), diff --git a/frontend/src/components/FailedBanner.jsx b/frontend/src/components/FailedBanner.jsx index 9ea1b8a..698cafb 100644 --- a/frontend/src/components/FailedBanner.jsx +++ b/frontend/src/components/FailedBanner.jsx @@ -5,7 +5,17 @@ export default function FailedBanner({ rerunTests, onDecision }) { const hours = Math.floor(totalMinutes / 60) const mins = totalMinutes % 60 const timeStr = hours > 0 ? `${hours}h ${mins}m` : `${mins}m` - const testIds = rerunTests.map(t => t.test_id).join(', ') + + // Group test IDs by device + const byDevice = rerunTests.reduce((acc, t) => { + const device = t.device ?? 'Unknown' + if (!acc[device]) acc[device] = [] + acc[device].push(t.test_id) + return acc + }, {}) + const deviceSummaries = Object.entries(byDevice) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([device, ids]) => `${device}: ${ids.join(', ')}`) return (
@@ -26,9 +36,13 @@ export default function FailedBanner({ rerunTests, onDecision }) {

{rerunTests.length} test{rerunTests.length !== 1 ? 's' : ''} - {' '}require a rerun — not completed in last overnight window:{' '} - {testIds} + {' '}require a rerun — not completed in last overnight window:

+ {deviceSummaries.map((summary) => ( +

+ {summary} +

+ ))}

Estimated rerun time: {timeStr} {' '}— Schedule rerun during daytime today? diff --git a/frontend/src/components/OneDayModal.jsx b/frontend/src/components/OneDayModal.jsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/SettingsModal.jsx b/frontend/src/components/SettingsModal.jsx index a4b3dae..9e283d8 100644 --- a/frontend/src/components/SettingsModal.jsx +++ b/frontend/src/components/SettingsModal.jsx @@ -16,12 +16,22 @@ function Field({ label, hint, required = false, children }) { const INPUT_CLS = 'w-full bg-gray-900 border border-gray-600 rounded-md px-3 py-1.5 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono' -export default function SettingsModal({ isOpen, onClose, settings, onSave }) { +export default function SettingsModal({ isOpen, onClose, settings, onSave, onRestart }) { const [form, setForm] = useState({ ...settings }) + const [confirmOpen, setConfirmOpen] = useState(false) + const [confirmText, setConfirmText] = useState('') + const [restartBusy, setRestartBusy] = useState(false) + const [restartError, setRestartError] = useState(null) // Sync if parent settings change while modal is closed useEffect(() => { - if (!isOpen) setForm({ ...settings }) + if (!isOpen) { + setForm({ ...settings }) + setConfirmOpen(false) + setConfirmText('') + setRestartBusy(false) + setRestartError(null) + } }, [isOpen, settings]) if (!isOpen) return null @@ -35,6 +45,30 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave }) { onClose() } + async function handleRestartClick() { + if (!confirmOpen) { + setConfirmOpen(true) + setRestartError(null) + return + } + + if (confirmText.trim() !== 'RESET') { + setRestartError('Type RESET to confirm database clear.') + return + } + + setRestartBusy(true) + setRestartError(null) + try { + await onRestart() + onClose() + } catch (err) { + setRestartError(err?.message || 'Failed to restart and clear data.') + } finally { + setRestartBusy(false) + } + } + return (

+ +
+ + {/* Section: Danger Zone */} +
+

+ Danger Zone +

+
+

Reset And Clear Database

+

+ This permanently deletes tests, schedules, settings, and holidays from the database. +

+ + {confirmOpen && ( +
+

+ Type RESET below, then click the button again to confirm. +

+ setConfirmText(e.target.value)} + /> +
+ )} + + {restartError && ( +

{restartError}

+ )} + +
+ + {confirmOpen && ( + + )} +
+
+
{/* Footer */}