knapsack scheduling algorithm

This commit is contained in:
2026-07-12 14:19:58 -04:00
parent 031da48ddd
commit 9e100d60d0
56 changed files with 1936 additions and 1286 deletions
+210 -43
View File
@@ -1,7 +1,7 @@
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from datetime import date, datetime
from datetime import date, datetime, timedelta
import os
from fastapi import FastAPI, HTTPException
@@ -9,9 +9,15 @@ from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
import db
import graph
from parser import CsvValidationError, parse_target_csv
from scheduler import SchedulerTest, compile_schedule, remove_from_active, reset_scheduler_state
# Ensure new_scheduler resolves the same DUT/REF labels as db records.
os.environ.setdefault("DUT", db.DEVICE_DUT)
os.environ.setdefault("REF", db.DEVICE_REF)
from scheduler import Scheduler, Test as SchedulerTest
from test_config import build_bundle_test_configs, build_config_rows
from test_window import get_shift_capacity_for_date, get_shift_sequence, is_off_day
from watcher import configure_result_watcher, stop_result_watcher
@@ -21,6 +27,7 @@ DUT = os.getenv("DUT", "CGW453").strip()
REF = os.getenv("REF", "CGW452").strip()
class LoadTestsRequest(BaseModel):
csv_path: str = Field(..., description="Absolute or backend-relative path to target CSV")
@@ -80,10 +87,179 @@ class SaveHolidaysRequest(BaseModel):
dates: list[str] = Field(default_factory=list)
SHIFT_LABELS = {
1: "12AM9AM",
2: "9AM5PM",
3: "5PM12AM",
}
SHIFT_BOUNDARIES = {
1: ((0, 0), (9, 0)),
2: ((9, 0), (17, 0)),
3: ((17, 0), (24, 0)),
}
def _serialize_schedule_row(row: db.ScheduleRow) -> dict[str, Any]:
return {
"test_id": row.test_id,
"device": row.device,
"scheduled_date": row.scheduled_date,
"shift_index": row.shift_index,
"sequence_in_shift": row.sequence_in_shift,
"test_type": row.test_type,
"rotation": row.rotation,
"config": row.config,
"status": row.status,
"priority": row.priority,
"estimated_minutes": row.estimated_minutes,
}
def _window_segment_key(day: date, shift_index: int) -> str:
return f"{day.isoformat()}::shift{shift_index}"
def _combine_date_time(day: date, hour: int, minute: int) -> str:
if hour == 24:
return datetime.combine(day + timedelta(days=1), datetime.min.time()).isoformat(timespec="minutes")
return datetime.combine(day, datetime.min.time()).replace(hour=hour, minute=minute).isoformat(timespec="minutes")
def _window_intersects_week(segments: list[tuple[date, int]], week_start: date, week_end: date) -> bool:
for day, _shift_index in segments:
if week_start <= day < week_end:
return True
return False
def _build_schedule_windows(week_start: date, all_rows: list[db.ScheduleRow], holiday_dates: set[str]) -> list[dict[str, Any]]:
if not all_rows:
return []
all_dates = [datetime.strptime(row.scheduled_date, "%Y-%m-%d").date() for row in all_rows]
first_scheduled_date = min(all_dates)
last_scheduled_date = max(all_dates)
week_end = week_start + timedelta(days=7)
rows_by_segment: dict[tuple[str, int], list[dict[str, Any]]] = {}
weekday_shift2_dates: set[str] = set()
for row in all_rows:
serialized = _serialize_schedule_row(row)
rows_by_segment.setdefault((row.scheduled_date, row.shift_index), []).append(serialized)
row_date = datetime.strptime(row.scheduled_date, "%Y-%m-%d").date()
if row.shift_index == 2 and not is_off_day(row_date, holiday_dates):
weekday_shift2_dates.add(row.scheduled_date)
simulation_start = min(first_scheduled_date, week_start) - timedelta(days=7)
while is_off_day(simulation_start, holiday_dates):
simulation_start -= timedelta(days=1)
simulation_end = max(last_scheduled_date, week_end) + timedelta(days=7)
current_date = simulation_start
windows: list[dict[str, Any]] = []
seen_window_ids: set[str] = set()
while current_date <= simulation_end:
current_iso = current_date.isoformat()
if current_iso in weekday_shift2_dates:
segments = get_shift_sequence(current_date, holiday_dates, daytime_shift2_only=True)
window_id = f"{current_iso}-shift2"
if window_id not in seen_window_ids and _window_intersects_week(segments, week_start, week_end):
seen_window_ids.add(window_id)
shift_tests = [
test
for day, shift_index in segments
for test in rows_by_segment.get((day.isoformat(), shift_index), [])
]
available_runtime_minutes = sum(
get_shift_capacity_for_date(day, holiday_dates, daytime_shift2_only=True).get(shift_index, 0)
for day, shift_index in segments
)
start_hour, start_minute = SHIFT_BOUNDARIES[segments[0][1]][0]
end_hour, end_minute = SHIFT_BOUNDARIES[segments[-1][1]][1]
windows.append({
"window_id": window_id,
"window_type": "daytime",
"start_date": segments[0][0].isoformat(),
"start_shift_index": segments[0][1],
"end_date": segments[-1][0].isoformat(),
"end_shift_index": segments[-1][1],
"start_at": _combine_date_time(segments[0][0], start_hour, start_minute),
"end_at": _combine_date_time(segments[-1][0], end_hour, end_minute),
"available_runtime_minutes": available_runtime_minutes,
"estimated_runtime_minutes": sum(test["estimated_minutes"] for test in shift_tests),
"bundle_test_configs": build_bundle_test_configs(shift_tests),
"segments": [
{
"date": day.isoformat(),
"shift_index": shift_index,
"label": SHIFT_LABELS[shift_index],
"segment_key": _window_segment_key(day, shift_index),
}
for day, shift_index in segments
],
"configRows": build_config_rows(shift_tests),
"tests": shift_tests,
})
segments = get_shift_sequence(current_date, holiday_dates, daytime_shift2_only=False)
last_sequence_date = segments[-1][0]
last_shift_index = segments[-1][1]
window_kind = "offday" if is_off_day(current_date, holiday_dates) else "overnight"
window_id = f"{current_iso}-shift{segments[0][1]}"
if window_id not in seen_window_ids and _window_intersects_week(segments, week_start, week_end):
seen_window_ids.add(window_id)
window_tests = [
test
for day, shift_index in segments
for test in rows_by_segment.get((day.isoformat(), shift_index), [])
]
available_runtime_minutes = sum(
get_shift_capacity_for_date(day, holiday_dates, daytime_shift2_only=False).get(shift_index, 0)
for day, shift_index in segments
)
start_hour, start_minute = SHIFT_BOUNDARIES[segments[0][1]][0]
end_hour, end_minute = SHIFT_BOUNDARIES[segments[-1][1]][1]
windows.append({
"window_id": window_id,
"window_type": window_kind,
"start_date": segments[0][0].isoformat(),
"start_shift_index": segments[0][1],
"end_date": segments[-1][0].isoformat(),
"end_shift_index": segments[-1][1],
"start_at": _combine_date_time(segments[0][0], start_hour, start_minute),
"end_at": _combine_date_time(segments[-1][0], end_hour, end_minute),
"available_runtime_minutes": available_runtime_minutes,
"estimated_runtime_minutes": sum(test["estimated_minutes"] for test in window_tests),
"bundle_test_configs": build_bundle_test_configs(window_tests),
"segments": [
{
"date": day.isoformat(),
"shift_index": shift_index,
"label": SHIFT_LABELS[shift_index],
"segment_key": _window_segment_key(day, shift_index),
}
for day, shift_index in segments
],
"configRows": build_config_rows(window_tests),
"tests": window_tests,
})
if last_shift_index in {1, 2}:
current_date = last_sequence_date
else:
current_date = last_sequence_date + timedelta(days=1)
windows.sort(key=lambda window: (window["start_at"], window["window_id"]))
return windows
@asynccontextmanager
async def lifespan(application: FastAPI):
db.init_db(DB_PATH)
graph.reset_graph_state()
settings = db.read_settings(DB_PATH)
configure_result_watcher(settings)
try:
@@ -141,21 +317,17 @@ def load_tests(request: LoadTestsRequest) -> dict[str, Any]:
raise HTTPException(status_code=400, detail=str(exc)) from exc
count = db.upsert_tests(parsed.tests, DB_PATH)
reset_scheduler_state()
graph.reset_graph_state()
all_dut_tests = db.list_tests_for_device(DUT, DB_PATH)
graph.build_and_persist_graph(all_dut_tests, DB_PATH)
print(f"Loaded {count} tests from {csv_path}, with {len(parsed.warnings)} warnings.")
return {
"loaded_tests": count,
"warnings": parsed.warnings,
"dut_graph_nodes": len(graph.get_graph()),
}
@app.post("/api/schedule/active/remove")
def remove_active_tests(request: RemoveActiveTestsRequest) -> dict[str, Any]:
remove_from_active({item.strip() for item in request.test_ids if item.strip()})
# new_scheduler does not keep global mutable active state in app lifecycle.
# Keep endpoint for compatibility with frontend calls.
return {"status": "ok", "removed": len(request.test_ids)}
@@ -167,7 +339,6 @@ def compile_schedule_endpoint(request: CompileScheduleRequest) -> dict[str, Any]
except ValueError as exc:
raise HTTPException(status_code=400, detail="start_date must be YYYY-MM-DD") from exc
reset_scheduler_state()
stored_tests = db.list_schedulable_tests(DB_PATH, rule=request.rule)
if not stored_tests:
version = db.create_schedule_version([], DB_PATH)
@@ -189,29 +360,36 @@ def compile_schedule_endpoint(request: CompileScheduleRequest) -> dict[str, Any]
config=t.config,
throttled=t.throttled,
estimated_minutes=t.estimated_minutes,
priority=t.priority,
raw_payload=t.raw_payload or {},
)
for t in stored_tests
]
# DB-backed graph retrieval ensures compile works after restart without manual save/load.
all_dut_tests = db.list_tests_for_device(DUT, DB_PATH)
graph.get_graph(DB_PATH, all_dut_tests)
holiday_dates = db.list_holidays(DB_PATH)
top_priority_pairs: set[tuple[str, str]] = set()
for test_id in {item.strip() for item in request.top_priority_tests if item.strip()}:
if any(t.test_id == test_id and t.device == DUT for t in stored_tests):
top_priority_pairs.add((test_id, DUT))
if any(t.test_id == test_id and t.device == REF for t in stored_tests):
top_priority_pairs.add((test_id, REF))
scheduler = Scheduler(
tests=scheduler_tests,
top_priority_tests=top_priority_pairs,
start_date=request.start_date,
holiday_dates=holiday_dates,
daytime_testing_today=request.daytime_testing_today,
)
try:
entries, completion_date = compile_schedule(
tests=scheduler_tests,
start_date=request.start_date,
holiday_dates=holiday_dates,
top_priority_tests={item.strip() for item in request.top_priority_tests if item.strip()},
lowest_priority_tests={item.strip() for item in request.lowest_priority_tests if item.strip()},
daytime_testing_today=request.daytime_testing_today,
)
except RuntimeError as exc:
schedule_error = scheduler.compile_schedule()
entries = scheduler.get_schedule()
except (RuntimeError, ValueError) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
completion_date = max((e.scheduled_date for e in entries), default=None)
if schedule_error:
raise HTTPException(status_code=409, detail=schedule_error)
version = db.create_schedule_version(
[(e.test_id, e.device, e.scheduled_date, e.shift_index, e.sequence_in_shift) for e in entries],
DB_PATH,
@@ -252,25 +430,14 @@ def get_schedule_week(start: str | None = None) -> dict[str, Any]:
except ValueError as exc:
raise HTTPException(status_code=400, detail="start must be YYYY-MM-DD") from exc
week_start_date = datetime.strptime(week_start, "%Y-%m-%d").date()
rows = db.get_schedule_week(week_start, DB_PATH)
all_rows = db.get_schedule_rows_for_latest_version(DB_PATH)
holiday_dates = db.list_holidays(DB_PATH)
return {
"start_date": week_start,
"items": [
{
"test_id": row.test_id,
"device": row.device,
"scheduled_date": row.scheduled_date,
"shift_index": row.shift_index,
"sequence_in_shift": row.sequence_in_shift,
"test_type": row.test_type,
"rotation": row.rotation,
"config": row.config,
"status": row.status,
"priority": row.priority,
"estimated_minutes": row.estimated_minutes,
}
for row in rows
],
"items": [_serialize_schedule_row(row) for row in rows],
"windows": _build_schedule_windows(week_start_date, all_rows, holiday_dates),
}
if __name__ == "__main__":