frontend edit
This commit is contained in:
parent
b219df2b93
commit
ab514d5ee8
1
.gitignore
vendored
1
.gitignore
vendored
@ -90,3 +90,4 @@ latex/*.synctex.gz
|
|||||||
# logs
|
# logs
|
||||||
# ======================
|
# ======================
|
||||||
var/
|
var/
|
||||||
|
licenses/gurobi.lic
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -1436,6 +1436,7 @@ def solve_model(
|
|||||||
time_limit: int = 600,
|
time_limit: int = 600,
|
||||||
mip_gap: float = 0.05,
|
mip_gap: float = 0.05,
|
||||||
iis_path: Path = Path("results/iis.ilp"),
|
iis_path: Path = Path("results/iis.ilp"),
|
||||||
|
use_warmstart: bool = False,
|
||||||
) -> pyo.SolverResults:
|
) -> pyo.SolverResults:
|
||||||
if solver_name == "highs":
|
if solver_name == "highs":
|
||||||
solver = pyo.SolverFactory("highs")
|
solver = pyo.SolverFactory("highs")
|
||||||
@ -1447,7 +1448,21 @@ def solve_model(
|
|||||||
"output_flag": True,
|
"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)
|
opt = pyo.SolverFactory(solver_name)
|
||||||
if solver_name == "gurobi":
|
if solver_name == "gurobi":
|
||||||
@ -1458,7 +1473,14 @@ def solve_model(
|
|||||||
# SCIP uses hierarchical parameter names.
|
# SCIP uses hierarchical parameter names.
|
||||||
opt.options["limits/time"] = time_limit
|
opt.options["limits/time"] = time_limit
|
||||||
opt.options["limits/gap"] = mip_gap
|
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 {
|
if solver_name == "gurobi" and results.solver.termination_condition in {
|
||||||
TerminationCondition.infeasible,
|
TerminationCondition.infeasible,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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
|
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:
|
def report_results(model: pyo.ConcreteModel, max_rows: int) -> None:
|
||||||
@ -714,8 +818,8 @@ def main() -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--mip-gap",
|
"--mip-gap",
|
||||||
type=float,
|
type=float,
|
||||||
default=0.03,
|
default=0.30,
|
||||||
help="MIP gap tolerance (default: 0.03).",
|
help="MIP gap tolerance (default: 0.30).",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--step-size-tonnes",
|
"--step-size-tonnes",
|
||||||
@ -724,14 +828,36 @@ def main() -> None:
|
|||||||
choices=[960, 1000],
|
choices=[960, 1000],
|
||||||
help="Discrete train step size in tonnes (default: 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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
tables = load_tables(args.data_dir)
|
tables = load_tables(args.data_dir)
|
||||||
model = build_model(tables, step_size_tonnes=args.step_size_tonnes)
|
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)
|
# report_results(model, args.max_rows)
|
||||||
export_results(model, args.output_xlsx)
|
export_results(model, args.output_xlsx)
|
||||||
|
if args.warmstart_out is not None:
|
||||||
|
export_warmstart(model, args.warmstart_out)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -105,12 +105,21 @@ def _run_step(
|
|||||||
_clear_active_process(job_id)
|
_clear_active_process(job_id)
|
||||||
|
|
||||||
if result_code != 0:
|
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(
|
def _run_job(
|
||||||
job_dir: Path,
|
job_dir: Path,
|
||||||
input_path: Path,
|
input_path: Path,
|
||||||
|
warmstart_input_path: Path | None,
|
||||||
processed_dir: Path,
|
processed_dir: Path,
|
||||||
output_dir: Path,
|
output_dir: Path,
|
||||||
solver_name: str,
|
solver_name: str,
|
||||||
@ -141,8 +150,8 @@ def _run_job(
|
|||||||
cancel_event=cancel_event,
|
cancel_event=cancel_event,
|
||||||
)
|
)
|
||||||
output_path = output_dir / "output.xlsx"
|
output_path = output_dir / "output.xlsx"
|
||||||
_run_step(
|
warmstart_output_path = output_dir / "warmstart.json"
|
||||||
[
|
optimization_cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"src/optimization/run_optimization.py",
|
"src/optimization/run_optimization.py",
|
||||||
"--data-dir",
|
"--data-dir",
|
||||||
@ -157,13 +166,22 @@ def _run_job(
|
|||||||
str(time_limit_seconds),
|
str(time_limit_seconds),
|
||||||
"--output-xlsx",
|
"--output-xlsx",
|
||||||
str(output_path),
|
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(
|
||||||
|
optimization_cmd,
|
||||||
logs_dir / "optimization.log",
|
logs_dir / "optimization.log",
|
||||||
env=env,
|
env=env,
|
||||||
job_id=job_dir.name,
|
job_id=job_dir.name,
|
||||||
cancel_event=cancel_event,
|
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:
|
except JobCancelledError:
|
||||||
_write_status(job_dir, "cancelled")
|
_write_status(job_dir, "cancelled")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@ -342,6 +360,7 @@ def _read_capacity_timeseries(job_dir: Path) -> dict[str, object]:
|
|||||||
granularity: str,
|
granularity: str,
|
||||||
limit_value: float | None = None,
|
limit_value: float | None = None,
|
||||||
limit_map: dict[tuple, float] | None = None,
|
limit_map: dict[tuple, float] | None = None,
|
||||||
|
marker_points: list[dict[str, object]] | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
points: list[dict[str, object]] = []
|
points: list[dict[str, object]] = []
|
||||||
for row in df_usage.itertuples(index=False):
|
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,
|
"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(
|
def _aggregate_usage(
|
||||||
*,
|
*,
|
||||||
@ -562,6 +587,14 @@ def _read_capacity_timeseries(job_dir: Path) -> dict[str, object]:
|
|||||||
if avail_file.exists():
|
if avail_file.exists():
|
||||||
avail = pd.read_parquet(avail_file)
|
avail = pd.read_parquet(avail_file)
|
||||||
shift_map = {"S1": "F", "S2": "S", "S3": "N"}
|
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 [
|
for scope, label, cols, sources in [
|
||||||
(
|
(
|
||||||
@ -593,18 +626,26 @@ def _read_capacity_timeseries(job_dir: Path) -> dict[str, object]:
|
|||||||
if not limit_map:
|
if not limit_map:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
usage_df = _aggregate_usage(sources=sources, granularity="shift")
|
static_cap = static_shift_caps.get(scope)
|
||||||
if usage_df.empty:
|
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
|
continue
|
||||||
verladung_series.append(
|
marker_points.append(
|
||||||
_series_from_dataframe(
|
{
|
||||||
series_id=f"availability_{scope}_shift",
|
"x": _point_x(date, shift=shift, granularity="shift"),
|
||||||
label=label,
|
"label": _point_label(date, shift=shift, granularity="shift"),
|
||||||
df_usage=usage_df,
|
"limit_tonnes": float(dynamic_cap),
|
||||||
granularity="shift",
|
"delta_tonnes": float(static_cap - dynamic_cap),
|
||||||
limit_map=limit_map,
|
}
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
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:
|
if verladung_series:
|
||||||
groups.append({"key": "verladung", "label": "Verladungskapazitäten", "series": verladung_series})
|
groups.append({"key": "verladung", "label": "Verladungskapazitäten", "series": verladung_series})
|
||||||
@ -782,18 +823,28 @@ def _read_capacity_timeseries(job_dir: Path) -> dict[str, object]:
|
|||||||
continue
|
continue
|
||||||
limit_map[(date, shift)] = float(val)
|
limit_map[(date, shift)] = float(val)
|
||||||
|
|
||||||
usage_df = _aggregate_usage(targets={"J"}, granularity="shift")
|
static_cap_j = _zug_cap("KUP + KLP", "KW Jänschwalde")
|
||||||
if limit_map and not usage_df.empty:
|
marker_points: list[dict[str, object]] = []
|
||||||
zug_series.append(
|
if limit_map and static_cap_j is not None:
|
||||||
_series_from_dataframe(
|
for (date, shift), dynamic_cap in sorted(limit_map.items()):
|
||||||
series_id="zug_kvb_nord_dynamic_j",
|
if dynamic_cap >= static_cap_j:
|
||||||
label="Zugdurchlass KVB Nord (dynamisch) -> KW Jänschwalde",
|
continue
|
||||||
df_usage=usage_df,
|
marker_points.append(
|
||||||
granularity="shift",
|
{
|
||||||
limit_map=limit_map,
|
"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:
|
if zug_series:
|
||||||
groups.append({"key": "zugdurchlass", "label": "Zugdurchlasskapazitäten", "series": 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")
|
@app.post("/api/run")
|
||||||
async def run(
|
async def run(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
warmstart_file: UploadFile | None = File(None),
|
||||||
solver: str = Form("highs"),
|
solver: str = Form("highs"),
|
||||||
step_size_tonnes: int = Form(1000),
|
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),
|
max_runtime_minutes: float = Form(10.0),
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
solver = solver.lower().strip()
|
solver = solver.lower().strip()
|
||||||
if solver not in {"highs", "gurobi", "scip"}:
|
if solver != "highs":
|
||||||
raise HTTPException(status_code=400, detail="Unsupported solver")
|
raise HTTPException(status_code=400, detail="Only HiGHS is enabled at the moment")
|
||||||
availability = _get_solver_availability()
|
availability = _get_solver_availability()
|
||||||
if not availability.get(solver, False):
|
if not availability.get(solver, False):
|
||||||
raise HTTPException(status_code=400, detail=f"Solver not available: {solver}")
|
raise HTTPException(status_code=400, detail=f"Solver not available: {solver}")
|
||||||
@ -832,14 +884,21 @@ async def run(
|
|||||||
input_dir = job_dir / "input"
|
input_dir = job_dir / "input"
|
||||||
processed_dir = job_dir / "processed"
|
processed_dir = job_dir / "processed"
|
||||||
output_dir = job_dir / "output"
|
output_dir = job_dir / "output"
|
||||||
|
warmstart_dir = job_dir / "warmstart"
|
||||||
|
|
||||||
input_dir.mkdir(parents=True, exist_ok=True)
|
input_dir.mkdir(parents=True, exist_ok=True)
|
||||||
processed_dir.mkdir(parents=True, exist_ok=True)
|
processed_dir.mkdir(parents=True, exist_ok=True)
|
||||||
output_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"
|
input_path = input_dir / "PoC1_Rohkohleverteilung_Input_Parameter.xlsx"
|
||||||
with input_path.open("wb") as buffer:
|
with input_path.open("wb") as buffer:
|
||||||
shutil.copyfileobj(file.file, 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()
|
cancel_event = threading.Event()
|
||||||
with ACTIVE_JOBS_LOCK:
|
with ACTIVE_JOBS_LOCK:
|
||||||
@ -853,6 +912,7 @@ async def run(
|
|||||||
"step_size_tonnes": str(step_size_tonnes),
|
"step_size_tonnes": str(step_size_tonnes),
|
||||||
"mip_gap_pct": str(float(mip_gap_pct)),
|
"mip_gap_pct": str(float(mip_gap_pct)),
|
||||||
"max_runtime_minutes": str(float(max_runtime_minutes)),
|
"max_runtime_minutes": str(float(max_runtime_minutes)),
|
||||||
|
"warmstart_used": "true" if warmstart_input_path is not None else "false",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
@ -860,6 +920,7 @@ async def run(
|
|||||||
args=(
|
args=(
|
||||||
job_dir,
|
job_dir,
|
||||||
input_path,
|
input_path,
|
||||||
|
warmstart_input_path,
|
||||||
processed_dir,
|
processed_dir,
|
||||||
output_dir,
|
output_dir,
|
||||||
solver,
|
solver,
|
||||||
@ -875,10 +936,12 @@ async def run(
|
|||||||
return {
|
return {
|
||||||
"job_id": job_id,
|
"job_id": job_id,
|
||||||
"download_url": f"/api/jobs/{job_id}/output",
|
"download_url": f"/api/jobs/{job_id}/output",
|
||||||
|
"warmstart_download_url": f"/api/jobs/{job_id}/warmstart",
|
||||||
"solver": solver,
|
"solver": solver,
|
||||||
"step_size_tonnes": str(step_size_tonnes),
|
"step_size_tonnes": str(step_size_tonnes),
|
||||||
"mip_gap_pct": str(float(mip_gap_pct)),
|
"mip_gap_pct": str(float(mip_gap_pct)),
|
||||||
"max_runtime_minutes": str(float(max_runtime_minutes)),
|
"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")
|
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")
|
@app.get("/api/jobs/{job_id}/monthly-flows")
|
||||||
def job_monthly_flows(job_id: str) -> dict[str, object]:
|
def job_monthly_flows(job_id: str) -> dict[str, object]:
|
||||||
output_path = JOBS_DIR / job_id / "output" / "output.xlsx"
|
output_path = JOBS_DIR / job_id / "output" / "output.xlsx"
|
||||||
|
|||||||
@ -160,8 +160,10 @@ function parseSolverProgress(logText, solverName) {
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [file, setFile] = useState(null);
|
const [file, setFile] = useState(null);
|
||||||
|
const [warmstartFile, setWarmstartFile] = useState(null);
|
||||||
const [status, setStatus] = useState("Bereit für Upload");
|
const [status, setStatus] = useState("Bereit für Upload");
|
||||||
const [downloadUrl, setDownloadUrl] = useState("");
|
const [downloadUrl, setDownloadUrl] = useState("");
|
||||||
|
const [warmstartDownloadUrl, setWarmstartDownloadUrl] = useState("");
|
||||||
const [jobId, setJobId] = useState("");
|
const [jobId, setJobId] = useState("");
|
||||||
const [logText, setLogText] = useState("");
|
const [logText, setLogText] = useState("");
|
||||||
const [jobState, setJobState] = useState("");
|
const [jobState, setJobState] = useState("");
|
||||||
@ -173,18 +175,18 @@ export default function App() {
|
|||||||
const [selectedCapacitySeries, setSelectedCapacitySeries] = useState({});
|
const [selectedCapacitySeries, setSelectedCapacitySeries] = useState({});
|
||||||
const [solver, setSolver] = useState("highs");
|
const [solver, setSolver] = useState("highs");
|
||||||
const [stepSizeTonnes, setStepSizeTonnes] = useState("1000");
|
const [stepSizeTonnes, setStepSizeTonnes] = useState("1000");
|
||||||
const [mipGapPct, setMipGapPct] = useState("5");
|
const [mipGapPct, setMipGapPct] = useState("30");
|
||||||
const [maxRuntimeMinutes, setMaxRuntimeMinutes] = useState("10.0");
|
const [maxRuntimeMinutes, setMaxRuntimeMinutes] = useState("10.0");
|
||||||
const [availableSolvers, setAvailableSolvers] = useState({
|
const [availableSolvers, setAvailableSolvers] = useState({
|
||||||
highs: true,
|
highs: true,
|
||||||
gurobi: false,
|
gurobi: false,
|
||||||
scip: false,
|
|
||||||
});
|
});
|
||||||
const [theme, setTheme] = useState("dark");
|
const [theme, setTheme] = useState("dark");
|
||||||
const [jobSolver, setJobSolver] = useState("");
|
const [jobSolver, setJobSolver] = useState("");
|
||||||
const [jobStepSizeTonnes, setJobStepSizeTonnes] = useState("");
|
const [jobStepSizeTonnes, setJobStepSizeTonnes] = useState("");
|
||||||
const [jobMipGapPct, setJobMipGapPct] = useState("");
|
const [jobMipGapPct, setJobMipGapPct] = useState("");
|
||||||
const [jobMaxRuntimeMinutes, setJobMaxRuntimeMinutes] = useState("");
|
const [jobMaxRuntimeMinutes, setJobMaxRuntimeMinutes] = useState("");
|
||||||
|
const [jobWarmstartUsed, setJobWarmstartUsed] = useState("");
|
||||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||||
const [timerRunning, setTimerRunning] = useState(false);
|
const [timerRunning, setTimerRunning] = useState(false);
|
||||||
const [cancelPending, setCancelPending] = useState(false);
|
const [cancelPending, setCancelPending] = useState(false);
|
||||||
@ -205,6 +207,7 @@ export default function App() {
|
|||||||
|
|
||||||
setStatus("Upload läuft…");
|
setStatus("Upload läuft…");
|
||||||
setDownloadUrl("");
|
setDownloadUrl("");
|
||||||
|
setWarmstartDownloadUrl("");
|
||||||
setJobId("");
|
setJobId("");
|
||||||
setLogText("");
|
setLogText("");
|
||||||
setJobState("");
|
setJobState("");
|
||||||
@ -218,12 +221,16 @@ export default function App() {
|
|||||||
setJobStepSizeTonnes("");
|
setJobStepSizeTonnes("");
|
||||||
setJobMipGapPct("");
|
setJobMipGapPct("");
|
||||||
setJobMaxRuntimeMinutes("");
|
setJobMaxRuntimeMinutes("");
|
||||||
|
setJobWarmstartUsed("");
|
||||||
setElapsedSeconds(0);
|
setElapsedSeconds(0);
|
||||||
setTimerRunning(true);
|
setTimerRunning(true);
|
||||||
setCancelPending(false);
|
setCancelPending(false);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
if (warmstartFile) {
|
||||||
|
formData.append("warmstart_file", warmstartFile);
|
||||||
|
}
|
||||||
formData.append("solver", solver);
|
formData.append("solver", solver);
|
||||||
formData.append("step_size_tonnes", stepSizeTonnes);
|
formData.append("step_size_tonnes", stepSizeTonnes);
|
||||||
formData.append("mip_gap_pct", mipGapPct);
|
formData.append("mip_gap_pct", mipGapPct);
|
||||||
@ -245,6 +252,7 @@ export default function App() {
|
|||||||
setJobStepSizeTonnes(String(data.step_size_tonnes || stepSizeTonnes));
|
setJobStepSizeTonnes(String(data.step_size_tonnes || stepSizeTonnes));
|
||||||
setJobMipGapPct(String(data.mip_gap_pct || mipGapPct));
|
setJobMipGapPct(String(data.mip_gap_pct || mipGapPct));
|
||||||
setJobMaxRuntimeMinutes(String(data.max_runtime_minutes || maxRuntimeMinutes));
|
setJobMaxRuntimeMinutes(String(data.max_runtime_minutes || maxRuntimeMinutes));
|
||||||
|
setJobWarmstartUsed(String(data.warmstart_used || ""));
|
||||||
setStatus("Job gestartet. Warte auf Ergebnis…");
|
setStatus("Job gestartet. Warte auf Ergebnis…");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus("Fehler: Job konnte nicht abgeschlossen werden.");
|
setStatus("Fehler: Job konnte nicht abgeschlossen werden.");
|
||||||
@ -294,8 +302,12 @@ export default function App() {
|
|||||||
if (data.max_runtime_minutes) {
|
if (data.max_runtime_minutes) {
|
||||||
setJobMaxRuntimeMinutes(String(data.max_runtime_minutes));
|
setJobMaxRuntimeMinutes(String(data.max_runtime_minutes));
|
||||||
}
|
}
|
||||||
|
if (data.warmstart_used) {
|
||||||
|
setJobWarmstartUsed(String(data.warmstart_used));
|
||||||
|
}
|
||||||
if (data.status === "completed") {
|
if (data.status === "completed") {
|
||||||
setDownloadUrl(`${API_BASE}/api/jobs/${id}/output`);
|
setDownloadUrl(`${API_BASE}/api/jobs/${id}/output`);
|
||||||
|
setWarmstartDownloadUrl(`${API_BASE}/api/jobs/${id}/warmstart`);
|
||||||
setStatus("Fertig. Ergebnis steht zum Download bereit.");
|
setStatus("Fertig. Ergebnis steht zum Download bereit.");
|
||||||
setTimerRunning(false);
|
setTimerRunning(false);
|
||||||
setCancelPending(false);
|
setCancelPending(false);
|
||||||
@ -364,14 +376,10 @@ export default function App() {
|
|||||||
const reported = data?.solvers || {};
|
const reported = data?.solvers || {};
|
||||||
const solvers = {
|
const solvers = {
|
||||||
highs: reported.highs !== false,
|
highs: reported.highs !== false,
|
||||||
gurobi: reported.gurobi === true,
|
gurobi: false,
|
||||||
scip: reported.scip === true,
|
|
||||||
};
|
};
|
||||||
setAvailableSolvers(solvers);
|
setAvailableSolvers(solvers);
|
||||||
if (
|
if (solver === "gurobi" && !solvers.gurobi) {
|
||||||
(solver === "gurobi" && !solvers.gurobi) ||
|
|
||||||
(solver === "scip" && !solvers.scip)
|
|
||||||
) {
|
|
||||||
setSolver("highs");
|
setSolver("highs");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -672,10 +680,29 @@ export default function App() {
|
|||||||
line: { color: plotTheme.limit, width: 2, dash: "dash" },
|
line: { color: plotTheme.limit, width: 2, dash: "dash" },
|
||||||
hovertemplate: "%{x}<br>Grenze: %{y:.1f} kt<extra></extra>",
|
hovertemplate: "%{x}<br>Grenze: %{y:.1f} kt<extra></extra>",
|
||||||
};
|
};
|
||||||
|
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]}<br>Reduzierte Grenze: %{y:.1f} kt<br>Ausfall: %{customdata[1]:.1f} kt<extra></extra>",
|
||||||
|
}
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
group,
|
group,
|
||||||
series,
|
series,
|
||||||
data: [usageTrace, limitTrace],
|
data: [usageTrace, limitTrace, nonavailabilityTrace].filter(Boolean),
|
||||||
layout: {
|
layout: {
|
||||||
paper_bgcolor: plotTheme.paper,
|
paper_bgcolor: plotTheme.paper,
|
||||||
plot_bgcolor: plotTheme.plot,
|
plot_bgcolor: plotTheme.plot,
|
||||||
@ -736,10 +763,20 @@ export default function App() {
|
|||||||
onChange={(event) => setFile(event.target.files?.[0] || null)}
|
onChange={(event) => setFile(event.target.files?.[0] || null)}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
{file ? file.name : "PoC1_Rohkohleverteilung_Input_Parameter.xlsx auswählen"}
|
{file ? file.name : "Input"}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className={`file-card${jobInProgress ? " disabled" : ""}`}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
disabled={jobInProgress}
|
||||||
|
onChange={(event) => setWarmstartFile(event.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
<span>{warmstartFile ? warmstartFile.name : "Optional: Warmstart JSON auswählen"}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className="target-select">
|
<label className="target-select">
|
||||||
<span>Solver</span>
|
<span>Solver</span>
|
||||||
<select
|
<select
|
||||||
@ -748,8 +785,9 @@ export default function App() {
|
|||||||
onChange={(event) => setSolver(event.target.value)}
|
onChange={(event) => setSolver(event.target.value)}
|
||||||
>
|
>
|
||||||
<option value="highs">HiGHS</option>
|
<option value="highs">HiGHS</option>
|
||||||
{availableSolvers.gurobi && <option value="gurobi">Gurobi</option>}
|
<option value="gurobi" disabled>
|
||||||
{availableSolvers.scip && <option value="scip">SCIP</option>}
|
Gurobi
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@ -825,6 +863,9 @@ export default function App() {
|
|||||||
<p className="muted">
|
<p className="muted">
|
||||||
Max Laufzeit: {formatDecimalWithDot(jobMaxRuntimeMinutes || maxRuntimeMinutes)} min
|
Max Laufzeit: {formatDecimalWithDot(jobMaxRuntimeMinutes || maxRuntimeMinutes)} min
|
||||||
</p>
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
Warmstart: {jobWarmstartUsed === "true" ? "verwendet" : "nicht verwendet"}
|
||||||
|
</p>
|
||||||
{(jobId || logText) && (
|
{(jobId || logText) && (
|
||||||
<div className="progress-panel">
|
<div className="progress-panel">
|
||||||
<div className="progress-panel-row">
|
<div className="progress-panel-row">
|
||||||
@ -855,9 +896,14 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{downloadUrl && (
|
{downloadUrl && (
|
||||||
|
<>
|
||||||
<a className="download" href={downloadUrl}>
|
<a className="download" href={downloadUrl}>
|
||||||
Ergebnis herunterladen
|
Ergebnis herunterladen
|
||||||
</a>
|
</a>
|
||||||
|
<a className="download" href={warmstartDownloadUrl}>
|
||||||
|
Warmstart herunterladen
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{logText && (
|
{logText && (
|
||||||
<pre className="log">
|
<pre className="log">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user