From ab514d5ee800a5366d63dd6adfcd78cc3d1024c6 Mon Sep 17 00:00:00 2001 From: Nicolai Date: Wed, 25 Mar 2026 19:25:27 +0100 Subject: [PATCH] frontend edit --- .gitignore | 1 + .../__pycache__/model_builder.cpython-313.pyc | Bin 108785 -> 109947 bytes .../run_optimization.cpython-313.pyc | Bin 35064 -> 41610 bytes src/optimization/model_builder.py | 26 ++- src/optimization/run_optimization.py | 132 ++++++++++++++- webapp/backend/main.py | 159 +++++++++++++----- webapp/frontend/src/App.jsx | 76 +++++++-- 7 files changed, 330 insertions(+), 64 deletions(-) diff --git a/.gitignore b/.gitignore index 685e910..b979619 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,4 @@ latex/*.synctex.gz # logs # ====================== var/ +licenses/gurobi.lic diff --git a/src/optimization/__pycache__/model_builder.cpython-313.pyc b/src/optimization/__pycache__/model_builder.cpython-313.pyc index 4a9a06448e92590b313f56a379d7b6ed6e61e8ce..b1842a43f5271081c98fa6e0fc37d8eb13c3dcf0 100644 GIT binary patch delta 2176 zcmaJ?T~HH89N*35^K$vT1d>1?M7{-#1F;H~f+Nr|h*Ng1&_SD?7-(!voV%cXX?4bj z(!L1I*rOd8KAr*^h|@>9oFd_aIPpbd%Zr?eG3y z``f*}hqC^$qPH$tEHuDvpZ!|A=buTd2hx)@Z=;L?f?fHQ!D2(}Ia&;xlINDt4#pmg_hNK<-FpV@lL^~o2ozIw=1 zaOU(#7uKxKYS&0U2mm7HSMR&5iS<+k>eU3vs5WFkR0t*woNnE>0e~l>8=r|@0kjs2yWs}w8Uoq;S4VAKULS4o7d zkmgz-ZBNozVk?oD$BP}T$EvywHd6IqE5Rl^!N|eUWaf5~*}fq5VO5{fbsyJ;PnTQu zp%8_NnlvvElt#}Jlzy?Gv?^%eX+hVQa$j9~fGiybGs$W{tR{WNQp8%fYE=EMs2iuv zggh=ye`BW8;L_rHi={u$!YZ4mAbirynfte7Bezb zvmW_&KrFPmKcrUBjL(LjTAQ5+V+EgT8wY{Y7BUw`b;HP}feJ9>EI1kfuLU3g%0Z8& z&ocscQsrO-KF0#~`8O7L`*RePNfu^m`aSL#RR`v=V%PqofZ^bB6U0_2tTW~YMNubAWC1Khac$Qz+|08>bU^x`xMI< z&bTgsaE~}JYQ*R}m>v}s!)jhpxJjN-xEh5%8WY60n2sgW3WduREjW1~5Rg8*0R6>F z*zQw&$%ykc^tSZN1sFcDYzGWGv2AL5)?6u@D|4Ge1q7tgQ@)vZ}?o9u1F#hDyAn=@;xl5JI4Ta9e1$u)!~n5iMzUWbdbIZwwwicjfe zYtu|A?xgWqn62d?aQT-2w73-W@^4!7uhb&UW5lL z9^p+Ans6xf;0S-=trAu+S7#K+1t~m|YA!*)MWtw=o=_{&YnR|bxGa5p30AjSg;Rw5 zo5XoQI&uJoIf4cV`i-D2f_^6m-v~hXgP@>9Uxv4;$tn2?2;|lf?6^~mFCR%A8cW35 z1&EEw$3@gZ>EFxnyMdgqJa5?zA)7Oo<|zX1Ib3-i0a#QF@ka|oBe1f0lL~2IWAkPe TGQj0o_0C&?zIeq(ST*l|&(;2x delta 1145 zcmZ`&TWAwO6rG#RzVg^SHfglBrU|ueN~wxzwStO5TTnsl5RFvST2hHfS~gMqD5&_M zerZ``6ns=|txx<>1o5MwC<;PFK@BQ`Rl%=SQ4m4Bvo=kCoQ1jP-Z|&ay|XjFiqe;- zlJ?E(brBrTf1l3o`#z-20_RZumS~8|m1L*W@Y66=Y!sjsRJBo%MutXW^JY;GcXxsj zqO+;jUJ7saC1EZpeYA4u^IS*LY5F1Jn?v;}eq@IuQFX8tZnpyTovQB@c>z;HMk^2k z-)`c3oTPxl(ljAG9mMQ$$7BjEfDH4xGKZ;WpfQ>fyVR1lY*T~^RBUt9qA1RuECSV^ zlQ5TL=V~G(UB?yFpgESs zH-|%tR1UE_XR(s*82fo1s#9_;woyxBq&r^PQzkuFMo#UXSBg+QrD|l6qf}}Qi4uy8 zl`7Z%Lw(RkmI+bP2Z<27aRFAy2`CDMOonY7fvs?#-5!As@e$sK4yKGk9q4R68cEFe z@7j}L&qv`36Mx0g!6Snw z+J_5=2Pb?@6G{{NGX`4&THt8*NcMy{oE&!^&Q56U?9wH80WurA3=LKM_uQ9PB|4Ji zLDJP35gshpI)lQ)px7B&YUk)JM^3iyDm;ewZ1pv$bbrAR ze{gw=u>NasYnA102K}YG$`a7N3xq5YttAmeGDiy0bW7#PMSQx=wmd|N{P1GT2x6+g D>#z19 diff --git a/src/optimization/__pycache__/run_optimization.cpython-313.pyc b/src/optimization/__pycache__/run_optimization.cpython-313.pyc index 84d084edc9510d3dbe39293eaa0a018bb45f8ea7..1cc4466929e8996dc7c7dd6274021812fc630d79 100644 GIT binary patch delta 9484 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 HA@%+ju_V@2 diff --git a/src/optimization/model_builder.py b/src/optimization/model_builder.py index 4d3434b..305b19f 100644 --- a/src/optimization/model_builder.py +++ b/src/optimization/model_builder.py @@ -1436,6 +1436,7 @@ def solve_model( time_limit: int = 600, mip_gap: float = 0.05, iis_path: Path = Path("results/iis.ilp"), + use_warmstart: bool = False, ) -> pyo.SolverResults: if solver_name == "highs": solver = pyo.SolverFactory("highs") @@ -1447,7 +1448,21 @@ def solve_model( "output_flag": True, } ) - return solver.solve(model, tee=True) + if hasattr(solver, "config"): + solver.config.load_solutions = False + solver.config.raise_exception_on_nonoptimal_result = False + solve_kwargs = {"tee": True} + if use_warmstart and hasattr(solver, "warm_start_capable"): + try: + if solver.warm_start_capable(): + solve_kwargs["warmstart"] = True + except Exception: + pass + results = solver.solve(model, **solve_kwargs) + solution_status = str(getattr(results, "solution_status", "")).lower() + if solution_status in {"feasible", "optimal"} and hasattr(results, "solution_loader"): + results.solution_loader.load_vars() + return results opt = pyo.SolverFactory(solver_name) if solver_name == "gurobi": @@ -1458,7 +1473,14 @@ def solve_model( # SCIP uses hierarchical parameter names. opt.options["limits/time"] = time_limit opt.options["limits/gap"] = mip_gap - results = opt.solve(model, tee=True, symbolic_solver_labels=True) + solve_kwargs = {"tee": True, "symbolic_solver_labels": True} + if use_warmstart and hasattr(opt, "warm_start_capable"): + try: + if opt.warm_start_capable(): + solve_kwargs["warmstart"] = True + except Exception: + pass + results = opt.solve(model, **solve_kwargs) if solver_name == "gurobi" and results.solver.termination_condition in { TerminationCondition.infeasible, diff --git a/src/optimization/run_optimization.py b/src/optimization/run_optimization.py index d38c601..297e985 100644 --- a/src/optimization/run_optimization.py +++ b/src/optimization/run_optimization.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import json import sys from pathlib import Path @@ -15,6 +16,109 @@ if str(SRC_ROOT) not in sys.path: from optimization.model_builder import build_model, load_tables, solve_model +def _safe_value(component) -> float: + val = pyo.value(component, exception=False) + return float(val) if val is not None else 0.0 + + +def export_warmstart(model: pyo.ConcreteModel, output_path: Path) -> None: + output_path.parent.mkdir(parents=True, exist_ok=True) + + payload: dict[str, object] = { + "schema_version": 1, + "step_size_tonnes": _safe_value(model.step_size_tonnes), + "variables": {}, + } + + variables: dict[str, list[dict[str, object]]] = {} + + k_rows = [] + for i in model.I: + for j in model.J: + for w in model.W: + for d in model.D: + for s in model.S: + value = _safe_value(model.k[i, j, w, d, s]) + if abs(value) <= 1e-9: + continue + k_rows.append({"i": i, "j": j, "w": int(w), "d": d, "s": s, "value": round(value, 8)}) + variables["k"] = k_rows + + if hasattr(model, "bunker"): + bunker_rows = [] + for i in model.I: + for j in getattr(model, "J_BUNKER", []): + for w in model.W: + for d in model.D: + value = _safe_value(model.bunker[i, j, w, d]) + if abs(value) <= 1e-9: + continue + bunker_rows.append({"i": i, "j": j, "w": int(w), "d": d, "value": round(value, 8)}) + variables["bunker"] = bunker_rows + + if hasattr(model, "bunker_out"): + bunker_out_rows = [] + for i in model.I: + for j in getattr(model, "J_BUNKER", []): + for w in model.W: + for d in model.D: + for s in model.S: + value = _safe_value(model.bunker_out[i, j, w, d, s]) + if abs(value) <= 1e-9: + continue + bunker_out_rows.append( + {"i": i, "j": j, "w": int(w), "d": d, "s": s, "value": round(value, 8)} + ) + variables["bunker_out"] = bunker_out_rows + + payload["variables"] = variables + output_path.write_text(json.dumps(payload, indent=2, ensure_ascii=True), encoding="utf-8") + + +def load_warmstart(model: pyo.ConcreteModel, input_path: Path) -> None: + payload = json.loads(input_path.read_text(encoding="utf-8")) + variables = payload.get("variables", {}) + if not isinstance(variables, dict): + raise ValueError("Warmstart-Datei ist ungueltig: 'variables' fehlt oder hat falsches Format") + + for idx in model.k: + model.k[idx].set_value(0) + if hasattr(model, "bunker"): + for idx in model.bunker: + model.bunker[idx].set_value(0) + if hasattr(model, "bunker_out"): + for idx in model.bunker_out: + model.bunker_out[idx].set_value(0) + + def set_rows(component_name: str, component, keys: tuple[str, ...]) -> None: + rows = variables.get(component_name, []) + if rows is None: + return + if not isinstance(rows, list): + raise ValueError(f"Warmstart-Datei ist ungueltig: '{component_name}' muss eine Liste sein") + for row in rows: + if not isinstance(row, dict): + raise ValueError(f"Warmstart-Datei ist ungueltig: Eintrag in '{component_name}' ist kein Objekt") + try: + index = tuple(row[key] for key in keys) + except KeyError as exc: + raise ValueError( + f"Warmstart-Datei ist ungueltig: Schluessel {exc.args[0]!r} fehlt in '{component_name}'" + ) from exc + if len(index) >= 3: + index = tuple(int(part) if keys[pos] == "w" else part for pos, part in enumerate(index)) + if index not in component: + continue + value = float(row.get("value", 0.0)) + component[index].set_value(value) + + set_rows("k", model.k, ("i", "j", "w", "d", "s")) + if hasattr(model, "bunker"): + set_rows("bunker", model.bunker, ("i", "j", "w", "d")) + if hasattr(model, "bunker_out"): + set_rows("bunker_out", model.bunker_out, ("i", "j", "w", "d", "s")) + + def report_results(model: pyo.ConcreteModel, max_rows: int) -> None: @@ -714,8 +818,8 @@ def main() -> None: parser.add_argument( "--mip-gap", type=float, - default=0.03, - help="MIP gap tolerance (default: 0.03).", + default=0.30, + help="MIP gap tolerance (default: 0.30).", ) parser.add_argument( "--step-size-tonnes", @@ -724,14 +828,36 @@ def main() -> None: choices=[960, 1000], help="Discrete train step size in tonnes (default: 1000).", ) + parser.add_argument( + "--warmstart-in", + type=Path, + default=None, + help="Optional JSON solution snapshot to use as warmstart.", + ) + parser.add_argument( + "--warmstart-out", + type=Path, + default=None, + help="Optional JSON path for exporting a reusable warmstart snapshot.", + ) args = parser.parse_args() tables = load_tables(args.data_dir) model = build_model(tables, step_size_tonnes=args.step_size_tonnes) + if args.warmstart_in is not None: + load_warmstart(model, args.warmstart_in) - solve_model(model, args.solver, args.time_limit, args.mip_gap) + solve_model( + model, + args.solver, + args.time_limit, + args.mip_gap, + use_warmstart=args.warmstart_in is not None, + ) # report_results(model, args.max_rows) export_results(model, args.output_xlsx) + if args.warmstart_out is not None: + export_warmstart(model, args.warmstart_out) if __name__ == "__main__": diff --git a/webapp/backend/main.py b/webapp/backend/main.py index 9231367..4b039a7 100644 --- a/webapp/backend/main.py +++ b/webapp/backend/main.py @@ -105,12 +105,21 @@ def _run_step( _clear_active_process(job_id) if result_code != 0: - raise RuntimeError(f"Command failed: {' '.join(cmd)}") + tail = "" + try: + lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines() + tail_lines = lines[-40:] + if tail_lines: + tail = "\n" + "\n".join(tail_lines) + except Exception: + pass + raise RuntimeError(f"Command failed: {' '.join(cmd)}{tail}") def _run_job( job_dir: Path, input_path: Path, + warmstart_input_path: Path | None, processed_dir: Path, output_dir: Path, solver_name: str, @@ -141,29 +150,38 @@ def _run_job( cancel_event=cancel_event, ) output_path = output_dir / "output.xlsx" + warmstart_output_path = output_dir / "warmstart.json" + optimization_cmd = [ + sys.executable, + "src/optimization/run_optimization.py", + "--data-dir", + str(processed_dir), + "--solver", + solver_name, + "--step-size-tonnes", + str(step_size_tonnes), + "--mip-gap", + str(mip_gap), + "--time-limit", + str(time_limit_seconds), + "--output-xlsx", + str(output_path), + "--warmstart-out", + str(warmstart_output_path), + ] + if warmstart_input_path is not None and warmstart_input_path.exists(): + optimization_cmd.extend(["--warmstart-in", str(warmstart_input_path)]) _run_step( - [ - sys.executable, - "src/optimization/run_optimization.py", - "--data-dir", - str(processed_dir), - "--solver", - solver_name, - "--step-size-tonnes", - str(step_size_tonnes), - "--mip-gap", - str(mip_gap), - "--time-limit", - str(time_limit_seconds), - "--output-xlsx", - str(output_path), - ], + optimization_cmd, logs_dir / "optimization.log", env=env, job_id=job_dir.name, cancel_event=cancel_event, ) - _write_status(job_dir, "completed", {"output": str(output_path)}) + extra = {"output": str(output_path)} + if warmstart_output_path.exists(): + extra["warmstart"] = str(warmstart_output_path) + _write_status(job_dir, "completed", extra) except JobCancelledError: _write_status(job_dir, "cancelled") except Exception as exc: @@ -342,6 +360,7 @@ def _read_capacity_timeseries(job_dir: Path) -> dict[str, object]: granularity: str, limit_value: float | None = None, limit_map: dict[tuple, float] | None = None, + marker_points: list[dict[str, object]] | None = None, ) -> dict[str, object]: points: list[dict[str, object]] = [] for row in df_usage.itertuples(index=False): @@ -379,7 +398,13 @@ def _read_capacity_timeseries(job_dir: Path) -> dict[str, object]: "utilization_pct": float(util) if util is not None else None, } ) - return {"id": series_id, "label": label, "granularity": granularity, "points": points} + return { + "id": series_id, + "label": label, + "granularity": granularity, + "points": points, + "marker_points": marker_points or [], + } def _aggregate_usage( *, @@ -562,6 +587,14 @@ def _read_capacity_timeseries(job_dir: Path) -> dict[str, object]: if avail_file.exists(): avail = pd.read_parquet(avail_file) shift_map = {"S1": "F", "S2": "S", "S3": "N"} + static_shift_caps = { + "welzow": _cap_lookup("Welzow-Süd", "pro Schicht"), + "rw_no": _cap_lookup("Boxberg (RW+NO)", "pro Schicht"), + } + marker_target_series = { + "welzow": "verladung_welzow_shift", + "rw_no": "verladung_boxberg_shift", + } for scope, label, cols, sources in [ ( @@ -593,18 +626,26 @@ def _read_capacity_timeseries(job_dir: Path) -> dict[str, object]: if not limit_map: continue - usage_df = _aggregate_usage(sources=sources, granularity="shift") - if usage_df.empty: - continue - verladung_series.append( - _series_from_dataframe( - series_id=f"availability_{scope}_shift", - label=label, - df_usage=usage_df, - granularity="shift", - limit_map=limit_map, - ) - ) + static_cap = static_shift_caps.get(scope) + marker_points: list[dict[str, object]] = [] + if static_cap is not None: + for (date, shift), dynamic_cap in sorted(limit_map.items()): + if dynamic_cap >= static_cap: + continue + marker_points.append( + { + "x": _point_x(date, shift=shift, granularity="shift"), + "label": _point_label(date, shift=shift, granularity="shift"), + "limit_tonnes": float(dynamic_cap), + "delta_tonnes": float(static_cap - dynamic_cap), + } + ) + target_series_id = marker_target_series.get(scope) + if target_series_id and marker_points: + for series in verladung_series: + if series.get("id") == target_series_id: + series["marker_points"] = marker_points + break if verladung_series: groups.append({"key": "verladung", "label": "Verladungskapazitäten", "series": verladung_series}) @@ -782,17 +823,27 @@ def _read_capacity_timeseries(job_dir: Path) -> dict[str, object]: continue limit_map[(date, shift)] = float(val) - usage_df = _aggregate_usage(targets={"J"}, granularity="shift") - if limit_map and not usage_df.empty: - zug_series.append( - _series_from_dataframe( - series_id="zug_kvb_nord_dynamic_j", - label="Zugdurchlass KVB Nord (dynamisch) -> KW Jänschwalde", - df_usage=usage_df, - granularity="shift", - limit_map=limit_map, + static_cap_j = _zug_cap("KUP + KLP", "KW Jänschwalde") + marker_points: list[dict[str, object]] = [] + if limit_map and static_cap_j is not None: + for (date, shift), dynamic_cap in sorted(limit_map.items()): + if dynamic_cap >= static_cap_j: + continue + marker_points.append( + { + "x": _point_x(date, shift=shift, granularity="shift"), + "label": _point_label(date, shift=shift, granularity="shift"), + "limit_tonnes": float(dynamic_cap), + "delta_tonnes": float(static_cap_j - dynamic_cap), + } ) - ) + + if marker_points: + for series in zug_series: + if series.get("id") == "zug_all_to_j": + existing = list(series.get("marker_points", [])) + series["marker_points"] = existing + marker_points + break if zug_series: groups.append({"key": "zugdurchlass", "label": "Zugdurchlasskapazitäten", "series": zug_series}) @@ -808,14 +859,15 @@ def health() -> dict[str, object]: @app.post("/api/run") async def run( file: UploadFile = File(...), + warmstart_file: UploadFile | None = File(None), solver: str = Form("highs"), step_size_tonnes: int = Form(1000), - mip_gap_pct: float = Form(5.0), + mip_gap_pct: float = Form(30.0), max_runtime_minutes: float = Form(10.0), ) -> dict[str, str]: solver = solver.lower().strip() - if solver not in {"highs", "gurobi", "scip"}: - raise HTTPException(status_code=400, detail="Unsupported solver") + if solver != "highs": + raise HTTPException(status_code=400, detail="Only HiGHS is enabled at the moment") availability = _get_solver_availability() if not availability.get(solver, False): raise HTTPException(status_code=400, detail=f"Solver not available: {solver}") @@ -832,14 +884,21 @@ async def run( input_dir = job_dir / "input" processed_dir = job_dir / "processed" output_dir = job_dir / "output" + warmstart_dir = job_dir / "warmstart" input_dir.mkdir(parents=True, exist_ok=True) processed_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True) + warmstart_dir.mkdir(parents=True, exist_ok=True) input_path = input_dir / "PoC1_Rohkohleverteilung_Input_Parameter.xlsx" with input_path.open("wb") as buffer: shutil.copyfileobj(file.file, buffer) + warmstart_input_path = None + if warmstart_file is not None and warmstart_file.filename: + warmstart_input_path = warmstart_dir / "warmstart.json" + with warmstart_input_path.open("wb") as buffer: + shutil.copyfileobj(warmstart_file.file, buffer) cancel_event = threading.Event() with ACTIVE_JOBS_LOCK: @@ -853,6 +912,7 @@ async def run( "step_size_tonnes": str(step_size_tonnes), "mip_gap_pct": str(float(mip_gap_pct)), "max_runtime_minutes": str(float(max_runtime_minutes)), + "warmstart_used": "true" if warmstart_input_path is not None else "false", }, ) thread = threading.Thread( @@ -860,6 +920,7 @@ async def run( args=( job_dir, input_path, + warmstart_input_path, processed_dir, output_dir, solver, @@ -875,10 +936,12 @@ async def run( return { "job_id": job_id, "download_url": f"/api/jobs/{job_id}/output", + "warmstart_download_url": f"/api/jobs/{job_id}/warmstart", "solver": solver, "step_size_tonnes": str(step_size_tonnes), "mip_gap_pct": str(float(mip_gap_pct)), "max_runtime_minutes": str(float(max_runtime_minutes)), + "warmstart_used": "true" if warmstart_input_path is not None else "false", } @@ -929,6 +992,14 @@ def job_output(job_id: str) -> FileResponse: return FileResponse(output_path, filename="output.xlsx") +@app.get("/api/jobs/{job_id}/warmstart") +def job_warmstart(job_id: str) -> FileResponse: + warmstart_path = JOBS_DIR / job_id / "output" / "warmstart.json" + if not warmstart_path.exists(): + raise HTTPException(status_code=404, detail="Warmstart output not found") + return FileResponse(warmstart_path, filename="warmstart.json") + + @app.get("/api/jobs/{job_id}/monthly-flows") def job_monthly_flows(job_id: str) -> dict[str, object]: output_path = JOBS_DIR / job_id / "output" / "output.xlsx" diff --git a/webapp/frontend/src/App.jsx b/webapp/frontend/src/App.jsx index f7c1405..e07e6db 100644 --- a/webapp/frontend/src/App.jsx +++ b/webapp/frontend/src/App.jsx @@ -160,8 +160,10 @@ function parseSolverProgress(logText, solverName) { export default function App() { const [file, setFile] = useState(null); + const [warmstartFile, setWarmstartFile] = useState(null); const [status, setStatus] = useState("Bereit für Upload"); const [downloadUrl, setDownloadUrl] = useState(""); + const [warmstartDownloadUrl, setWarmstartDownloadUrl] = useState(""); const [jobId, setJobId] = useState(""); const [logText, setLogText] = useState(""); const [jobState, setJobState] = useState(""); @@ -173,18 +175,18 @@ export default function App() { const [selectedCapacitySeries, setSelectedCapacitySeries] = useState({}); const [solver, setSolver] = useState("highs"); const [stepSizeTonnes, setStepSizeTonnes] = useState("1000"); - const [mipGapPct, setMipGapPct] = useState("5"); + const [mipGapPct, setMipGapPct] = useState("30"); const [maxRuntimeMinutes, setMaxRuntimeMinutes] = useState("10.0"); const [availableSolvers, setAvailableSolvers] = useState({ highs: true, gurobi: false, - scip: false, }); const [theme, setTheme] = useState("dark"); const [jobSolver, setJobSolver] = useState(""); const [jobStepSizeTonnes, setJobStepSizeTonnes] = useState(""); const [jobMipGapPct, setJobMipGapPct] = useState(""); const [jobMaxRuntimeMinutes, setJobMaxRuntimeMinutes] = useState(""); + const [jobWarmstartUsed, setJobWarmstartUsed] = useState(""); const [elapsedSeconds, setElapsedSeconds] = useState(0); const [timerRunning, setTimerRunning] = useState(false); const [cancelPending, setCancelPending] = useState(false); @@ -205,6 +207,7 @@ export default function App() { setStatus("Upload läuft…"); setDownloadUrl(""); + setWarmstartDownloadUrl(""); setJobId(""); setLogText(""); setJobState(""); @@ -218,12 +221,16 @@ export default function App() { setJobStepSizeTonnes(""); setJobMipGapPct(""); setJobMaxRuntimeMinutes(""); + setJobWarmstartUsed(""); setElapsedSeconds(0); setTimerRunning(true); setCancelPending(false); const formData = new FormData(); formData.append("file", file); + if (warmstartFile) { + formData.append("warmstart_file", warmstartFile); + } formData.append("solver", solver); formData.append("step_size_tonnes", stepSizeTonnes); formData.append("mip_gap_pct", mipGapPct); @@ -245,6 +252,7 @@ export default function App() { setJobStepSizeTonnes(String(data.step_size_tonnes || stepSizeTonnes)); setJobMipGapPct(String(data.mip_gap_pct || mipGapPct)); setJobMaxRuntimeMinutes(String(data.max_runtime_minutes || maxRuntimeMinutes)); + setJobWarmstartUsed(String(data.warmstart_used || "")); setStatus("Job gestartet. Warte auf Ergebnis…"); } catch (error) { setStatus("Fehler: Job konnte nicht abgeschlossen werden."); @@ -294,8 +302,12 @@ export default function App() { if (data.max_runtime_minutes) { setJobMaxRuntimeMinutes(String(data.max_runtime_minutes)); } + if (data.warmstart_used) { + setJobWarmstartUsed(String(data.warmstart_used)); + } if (data.status === "completed") { setDownloadUrl(`${API_BASE}/api/jobs/${id}/output`); + setWarmstartDownloadUrl(`${API_BASE}/api/jobs/${id}/warmstart`); setStatus("Fertig. Ergebnis steht zum Download bereit."); setTimerRunning(false); setCancelPending(false); @@ -364,14 +376,10 @@ export default function App() { const reported = data?.solvers || {}; const solvers = { highs: reported.highs !== false, - gurobi: reported.gurobi === true, - scip: reported.scip === true, + gurobi: false, }; setAvailableSolvers(solvers); - if ( - (solver === "gurobi" && !solvers.gurobi) || - (solver === "scip" && !solvers.scip) - ) { + if (solver === "gurobi" && !solvers.gurobi) { setSolver("highs"); } } catch { @@ -672,10 +680,29 @@ export default function App() { line: { color: plotTheme.limit, width: 2, dash: "dash" }, hovertemplate: "%{x}
Grenze: %{y:.1f} kt", }; + const markerPoints = series.marker_points || []; + const nonavailabilityTrace = markerPoints.length + ? { + type: "scatter", + mode: "markers", + name: "Kurzfristige Nichtverfügbarkeit", + x: markerPoints.map((p) => p.x), + y: markerPoints.map((p) => tonnesToKt(p.limit_tonnes)), + marker: { + size: 10, + color: plotTheme.limit, + symbol: "circle", + line: { width: 1, color: plotTheme.paper }, + }, + customdata: markerPoints.map((p) => [p.label, tonnesToKt(p.delta_tonnes)]), + hovertemplate: + "%{customdata[0]}
Reduzierte Grenze: %{y:.1f} kt
Ausfall: %{customdata[1]:.1f} kt", + } + : null; return { group, series, - data: [usageTrace, limitTrace], + data: [usageTrace, limitTrace, nonavailabilityTrace].filter(Boolean), layout: { paper_bgcolor: plotTheme.paper, plot_bgcolor: plotTheme.plot, @@ -736,10 +763,20 @@ export default function App() { onChange={(event) => setFile(event.target.files?.[0] || null)} /> - {file ? file.name : "PoC1_Rohkohleverteilung_Input_Parameter.xlsx auswählen"} + {file ? file.name : "Input"} + + @@ -825,6 +863,9 @@ export default function App() {

Max Laufzeit: {formatDecimalWithDot(jobMaxRuntimeMinutes || maxRuntimeMinutes)} min

+

+ Warmstart: {jobWarmstartUsed === "true" ? "verwendet" : "nicht verwendet"} +

{(jobId || logText) && (
@@ -855,9 +896,14 @@ export default function App() {
)} {downloadUrl && ( - - Ergebnis herunterladen - + <> + + Ergebnis herunterladen + + + Warmstart herunterladen + + )} {logText && (