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
+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,
}