Update scheduling algorithm
This commit is contained in:
+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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user