knapsack scheduling algorithm
This commit is contained in:
+210
-43
@@ -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: "12AM–9AM",
|
||||
2: "9AM–5PM",
|
||||
3: "5PM–12AM",
|
||||
}
|
||||
|
||||
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__":
|
||||
|
||||
Reference in New Issue
Block a user