This commit is contained in:
Nicolai 2026-03-17 14:41:51 +01:00
parent d41760faa6
commit a803687ed6
14 changed files with 5906 additions and 7 deletions

23
.dockerignore Normal file
View File

@ -0,0 +1,23 @@
.git
.gitignore
.DS_Store
.venv
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
.mypy_cache
webapp/frontend/node_modules
webapp/frontend/dist
notebooks
latex
models
poc1.egg-info
results
var/jobs
*.log

41
Dockerfile Normal file
View File

@ -0,0 +1,41 @@
# syntax=docker/dockerfile:1
FROM node:20-alpine AS frontend-build
WORKDIR /app/webapp/frontend
COPY webapp/frontend/package.json webapp/frontend/package-lock.json ./
RUN npm ci
COPY webapp/frontend/ ./
RUN npm run build
FROM python:3.13-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
APP_HOST=0.0.0.0 \
APP_PORT=8000 \
LOG_LEVEL=info
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends libgomp1 \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml setup.py README.md ./
COPY src ./src
COPY webapp/backend ./webapp/backend
COPY data ./data
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir ".[web]"
COPY --from=frontend-build /app/webapp/frontend/dist ./webapp/frontend/dist
RUN mkdir -p /app/var/jobs
VOLUME ["/app/var"]
EXPOSE 8000
CMD ["sh", "-c", "uvicorn webapp.backend.main:app --host ${APP_HOST} --port ${APP_PORT} --log-level ${LOG_LEVEL}"]

View File

@ -64,13 +64,41 @@ cd webapp/frontend
npm install npm install
npm run dev npm run dev
``` ```
cd webapp/frontend
npm run dev
Optional: eigene Input/Output-Pfade beim Preprocessing via Umgebungsvariablen: Optional: eigene Input/Output-Pfade beim Preprocessing via Umgebungsvariablen:
`POC1_INPUT_XLSX`, `POC1_OUTPUT_DIR`. `POC1_INPUT_XLSX`, `POC1_OUTPUT_DIR`.
## Docker (All-in-One)
Das Docker-Image enthält:
- FastAPI Backend
- React-Frontend als statischer Build (ausgeliefert über FastAPI)
- Python Preprocessing + Optimierung
Standard-Start über Docker Compose:
```bash
docker compose up --build
```
Danach:
- UI: `http://localhost:8000/`
- Health: `http://localhost:8000/api/health`
Persistenz:
- Job-Artefakte und Logs bleiben unter `./var` erhalten (Volume-Mount `./var:/app/var`).
Alternativ ohne Compose:
```bash
docker build -t leag-coallog:latest .
docker run --rm -p 8000:8000 -v "$(pwd)/var:/app/var" leag-coallog:latest
```
Optionale Runtime-Parameter:
- `APP_HOST` (Default: `0.0.0.0`)
- `APP_PORT` (Default: `8000`)
- `LOG_LEVEL` (Default: `info`)
## Abhängigkeiten ## Abhängigkeiten
@ -78,4 +106,3 @@ Optional: eigene Input/Output-Pfade beim Preprocessing via Umgebungsvariablen:
- Pandas (Datenverarbeitung) - Pandas (Datenverarbeitung)
- NumPy (Numerische Berechnungen) - NumPy (Numerische Berechnungen)
- Matplotlib/Plotly (Visualisierung) - Matplotlib/Plotly (Visualisierung)

14
docker-compose.yml Normal file
View File

@ -0,0 +1,14 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
image: leag-coallog:latest
ports:
- "8000:8000"
environment:
APP_HOST: 0.0.0.0
APP_PORT: "8000"
LOG_LEVEL: info
volumes:
- ./var:/app/var

View File

@ -43,6 +43,17 @@ def export_results(model: pyo.ConcreteModel, output_path: Path) -> None:
val = pyo.value(var, exception=False) val = pyo.value(var, exception=False)
return float(val) if val is not None else 0.0 return float(val) if val is not None else 0.0
def excel_col_letter(index_zero_based: int) -> str:
# Convert 0-based index to Excel column letters (A, ..., Z, AA, AB, ...).
if index_zero_based < 0:
raise ValueError("index_zero_based must be non-negative")
n = index_zero_based + 1
letters: list[str] = []
while n > 0:
n, rem = divmod(n - 1, 26)
letters.append(chr(65 + rem))
return "".join(reversed(letters))
def autosize_worksheet(ws, df, index_cols=None, max_width=25): def autosize_worksheet(ws, df, index_cols=None, max_width=25):
if index_cols is None: if index_cols is None:
index_cols = list(df.index.names) index_cols = list(df.index.names)
@ -421,7 +432,7 @@ def export_results(model: pyo.ConcreteModel, output_path: Path) -> None:
index_scale=1.2, index_scale=1.2,
) )
for i, w in enumerate(widths): for i, w in enumerate(widths):
ws1.column_dimensions[chr(65 + i)].width = w ws1.column_dimensions[excel_col_letter(i)].width = w
if bunker_sheet is not None: if bunker_sheet is not None:
bunker_sheet.to_excel(writer, sheet_name="mit_Bunkerbestand") bunker_sheet.to_excel(writer, sheet_name="mit_Bunkerbestand")
@ -494,7 +505,7 @@ def export_results(model: pyo.ConcreteModel, output_path: Path) -> None:
index_scale=1.2, index_scale=1.2,
) )
for i, w in enumerate(widths): for i, w in enumerate(widths):
worksheet.column_dimensions[chr(65 + i)].width = w worksheet.column_dimensions[excel_col_letter(i)].width = w
header_fill = PatternFill("solid", fgColor="E6E6E6") header_fill = PatternFill("solid", fgColor="E6E6E6")
block_colors = ["DCEFFE", "FDEBD0", "E8F8F5", "FADBD8", "E8DAEF", "FEF9E7"] block_colors = ["DCEFFE", "FDEBD0", "E8F8F5", "FADBD8", "E8DAEF", "FEF9E7"]
block_fills = [PatternFill("solid", fgColor=c) for c in block_colors] block_fills = [PatternFill("solid", fgColor=c) for c in block_colors]
@ -666,7 +677,7 @@ def export_results(model: pyo.ConcreteModel, output_path: Path) -> None:
# No group fill for bunker_empirisch (column H). # No group fill for bunker_empirisch (column H).
widths = autosize_worksheet(ws_mix, mix_df, index_cols=[]) widths = autosize_worksheet(ws_mix, mix_df, index_cols=[])
for i, w in enumerate(widths[1:]): for i, w in enumerate(widths[1:]):
ws_mix.column_dimensions[chr(65 + i)].width = w ws_mix.column_dimensions[excel_col_letter(i)].width = w
def main() -> None: def main() -> None:

View File

@ -13,8 +13,10 @@ from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, PlainTextResponse, StreamingResponse from fastapi.responses import FileResponse, PlainTextResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
PROJECT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(__file__).resolve().parents[2]
FRONTEND_DIST = PROJECT_ROOT / "webapp" / "frontend" / "dist"
VAR_DIR = PROJECT_ROOT / "var" VAR_DIR = PROJECT_ROOT / "var"
JOBS_DIR = VAR_DIR / "jobs" JOBS_DIR = VAR_DIR / "jobs"
JOBS_DIR.mkdir(parents=True, exist_ok=True) JOBS_DIR.mkdir(parents=True, exist_ok=True)
@ -973,3 +975,22 @@ def job_log_stream(job_id: str, log_name: str) -> StreamingResponse:
time.sleep(0.5) time.sleep(0.5)
return StreamingResponse(event_stream(), media_type="text/event-stream") return StreamingResponse(event_stream(), media_type="text/event-stream")
if FRONTEND_DIST.exists():
assets_dir = FRONTEND_DIST / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=assets_dir), name="frontend-assets")
@app.get("/", include_in_schema=False)
def frontend_index() -> FileResponse:
return FileResponse(FRONTEND_DIST / "index.html")
@app.get("/{full_path:path}", include_in_schema=False)
def frontend_spa(full_path: str) -> FileResponse:
if full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="Not found")
target = FRONTEND_DIST / full_path
if target.exists() and target.is_file():
return FileResponse(target)
return FileResponse(FRONTEND_DIST / "index.html")

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>POC1 Optimierung</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

4265
webapp/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,21 @@
{
"name": "poc1-webapp",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"plotly.js-dist-min": "^2.35.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-plotly.js": "^2.6.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.0.0"
}
}

394
webapp/frontend/src/App.css Normal file
View File

@ -0,0 +1,394 @@
:root {
--ink: #1a1a1a;
--slate: #4a4a4a;
--sand: #f4f0e6;
--clay: #d0b58a;
--ember: #b34a2a;
--paper: #ffffff;
--shadow: 0 20px 50px rgba(0, 0, 0, 0.12);
--panel: #fffdf8;
--panel-soft: #fffaf1;
--line: #eadfcb;
--line-soft: #eee3cf;
--log-bg: #f6efe4;
--log-ink: #2f2a25;
}
:root[data-theme="dark"] {
--ink: #e8edf2;
--slate: #b7c0c9;
--clay: #9a845f;
--ember: #d97b59;
--paper: #161b20;
--shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
--panel: #1b2127;
--panel-soft: #222a31;
--line: #36404a;
--line-soft: #414b56;
--log-bg: #222930;
--log-ink: #d8e0e8;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif;
color: var(--ink);
background: radial-gradient(circle at top, #f8f5ed 0%, #efe6d5 40%, #e0d0b2 100%);
min-height: 100vh;
}
:root[data-theme="dark"] body {
background: radial-gradient(circle at top, #20272e 0%, #171c22 40%, #11161b 100%);
}
.page {
display: flex;
flex-direction: column;
gap: 32px;
padding: 40px 24px 72px;
}
.hero {
position: relative;
padding: 32px 24px;
border-radius: 24px;
background: linear-gradient(120deg, #ffffff 0%, #f1e4cc 100%);
box-shadow: var(--shadow);
overflow: hidden;
animation: reveal 600ms ease-out;
}
:root[data-theme="dark"] .hero {
background: linear-gradient(120deg, #20262d 0%, #182027 100%);
}
.hero::after {
content: "";
position: absolute;
inset: -40% -20% auto auto;
width: 200px;
height: 200px;
background: radial-gradient(circle, rgba(179, 74, 42, 0.35), transparent 70%);
}
.hero-content {
position: relative;
z-index: 1;
}
.hero-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.2em;
font-size: 0.7rem;
color: var(--ember);
margin: 0 0 8px;
}
h1 {
margin: 0 0 12px;
font-size: clamp(2rem, 3vw, 3rem);
}
.subtitle {
margin: 0;
color: var(--slate);
max-width: 520px;
}
.card {
background: var(--paper);
border-radius: 20px;
padding: 32px;
box-shadow: var(--shadow);
animation: reveal 700ms ease-out;
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 720px;
}
.file-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 2px dashed var(--clay);
border-radius: 16px;
background: var(--panel-soft);
cursor: pointer;
}
.file-card input {
display: none;
}
.file-card.disabled {
opacity: 0.55;
cursor: not-allowed;
}
.form-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
width: fit-content;
}
.theme-toggle {
background: transparent;
color: var(--ink);
border: 1px solid var(--line);
box-shadow: none;
white-space: nowrap;
}
.theme-toggle:hover {
transform: none;
box-shadow: none;
background: var(--panel-soft);
}
button {
border: none;
border-radius: 12px;
padding: 12px 16px;
font-size: 1rem;
background: var(--ember);
color: white;
cursor: pointer;
transition: transform 150ms ease, box-shadow 150ms ease;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 12px 24px rgba(179, 74, 42, 0.25);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
button.secondary {
background: var(--panel-soft);
color: var(--ink);
}
.status {
margin-top: 24px;
display: flex;
flex-direction: column;
gap: 8px;
}
.progress-panel {
margin-top: 4px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel-soft);
display: flex;
flex-direction: column;
gap: 8px;
}
.progress-panel-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--slate);
font-size: 0.9rem;
}
.progress-panel-row strong {
color: var(--ink);
}
.progress-metrics {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
font-size: 0.85rem;
color: var(--ink);
}
.progress-metrics.terminal-like {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
background: var(--log-bg);
border-radius: 8px;
padding: 8px 10px;
border: 1px solid var(--line);
}
.muted {
color: var(--slate);
font-size: 0.9rem;
}
.download {
display: inline-block;
padding: 10px 14px;
border-radius: 12px;
background: var(--clay);
color: var(--ink);
text-decoration: none;
width: fit-content;
}
.log {
margin-top: 16px;
padding: 12px;
background: var(--log-bg);
border-radius: 12px;
max-height: 240px;
overflow: auto;
font-size: 0.85rem;
color: var(--log-ink);
}
.chart-panel {
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: 16px;
}
.charts-stack {
display: flex;
flex-direction: column;
gap: 20px;
}
.chart-card {
padding: 14px;
border: 1px solid var(--line-soft);
border-radius: 16px;
background: var(--panel);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.05);
}
.chart-card-title {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px 16px;
align-items: baseline;
margin-bottom: 10px;
}
.chart-card-title strong {
font-size: 1rem;
}
.chart-card-title span {
color: var(--slate);
font-size: 0.9rem;
}
.chart-panel h2 {
margin: 0 0 6px;
font-size: 1.25rem;
}
.chart-panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
}
.target-select {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 220px;
max-width: 280px;
font-size: 0.9rem;
color: var(--slate);
}
.target-select select {
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px 12px;
background: var(--panel-soft);
color: var(--ink);
font: inherit;
}
.target-select input {
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px 12px;
background: var(--panel-soft);
color: var(--ink);
font: inherit;
}
.target-select.compact {
min-width: auto;
margin-bottom: 10px;
}
.plot {
border-radius: 14px;
overflow: hidden;
background: var(--panel-soft);
border: 1px solid var(--line-soft);
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 720px) {
.card {
padding: 24px;
}
.hero {
padding: 24px 20px;
}
.target-select {
min-width: 100%;
}
.chart-card-title {
flex-direction: column;
align-items: flex-start;
}
.hero-top {
flex-direction: column;
align-items: stretch;
}
}

1048
webapp/frontend/src/App.jsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,10 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./App.css";
createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": "http://localhost:8000",
},
},
});