Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions engineer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,7 @@ def operational_model(
show_plot=False,
save_plot=False,
path="",
include_flap_gate=True,
):
def check_and_exit_on_input_errors():
def input_plausibilty(eingabe_name, eingabe_wert, max_value=None, min_value=None):
Expand Down Expand Up @@ -1193,8 +1194,9 @@ def input_plausibilty(eingabe_name, eingabe_wert, max_value=None, min_value=None

# Check Stauziel, SohleHoehe, LabyrinthMaxBreite, LabyrinthMaxLaenge, and LabyrinthHoehe
fehler += input_plausibilty("Stauziel", design_upstream_water_level)
if max_flap_gate_angle is None or not (0 <= max_flap_gate_angle <= 90):
fehler.append("Maximaler Klappenwinkel muss zwischen 0° und 90° liegen.")
if include_flap_gate:
if max_flap_gate_angle is None or not (0 <= max_flap_gate_angle <= 90):
fehler.append("Maximaler Klappenwinkel muss zwischen 0° und 90° liegen.")
fehler += input_plausibilty("Fishe Hoehe", fish_body_height)

valid_interpolations = ["exponential", "linear", "quadratic", "cubic"]
Expand Down
25 changes: 14 additions & 11 deletions server/app/routes/operational.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,18 @@ def compute_operational_model(req: OperationalModelRequest) -> OperationalModelR
path="",
)

# Flap gate object
flap_gate = FlapGate(
bottom_level=req.flap_gate_bottom_level,
downstream_water_level=req.flap_gate_downstream_water_level,
discharge=req.flap_gate_discharge,
flap_gate_width=req.flap_gate_width,
flap_gate_height=req.flap_gate_height,
flap_gate_angle=req.flap_gate_angle,
show_errors=True, # Enable warnings to be captured
)
# Flap gate object (optional)
flap_gate = None
if req.include_flap_gate:
flap_gate = FlapGate(
bottom_level=req.flap_gate_bottom_level,
downstream_water_level=req.flap_gate_downstream_water_level,
discharge=req.flap_gate_discharge,
flap_gate_width=req.flap_gate_width,
flap_gate_height=req.flap_gate_height,
flap_gate_angle=req.flap_gate_angle,
show_errors=True, # Enable warnings to be captured
)

# Convert vectors to NumPy arrays to match the expectations of the core ENGINEER library
discharge_vector = np.array(req.discharge_vector, dtype=float)
Expand All @@ -69,6 +71,7 @@ def compute_operational_model(req: OperationalModelRequest) -> OperationalModelR
show_plot=False,
save_plot=False,
path="",
include_flap_gate=req.include_flap_gate,
)

# Keep latest operational plot so frontend can request it via /api/plots/operational.
Expand Down Expand Up @@ -105,7 +108,7 @@ def df_to_points(df):
upstream_water_level=row.get("OW"),
labyrinth_head_over_crest=labyrinth_head,
flap_gate_head_over_crest=flap_gate_head,
labyrinth_discharge=row.get("Labyrinth Q"),
labyrinth_discharge=row.get("Labyrinth Q") if row.get("Labyrinth Q") is not None else row.get("Abfluss"),
flap_gate_discharge=row.get("Klappe Q"),
flap_gate_angle=row.get("Klappe winkel"),
)
Expand Down
27 changes: 27 additions & 0 deletions server/app/routes/utils/plot_helpers.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import contextlib
import io
import os
from pathlib import Path
from typing import Any

import matplotlib.pyplot as plt

_PLOT_OBJECTS: dict[str, Any] = {}
_PLOT_SVG_BYTES: dict[str, bytes] = {}

_SHARED_PLOT_DIR = Path(os.environ.get("PLOT_CACHE_DIR", "/tmp/labyrinth-plots")).expanduser()
try:
_SHARED_PLOT_DIR.mkdir(parents=True, exist_ok=True)
except OSError:
pass


def _shared_plot_path(plot_id: str) -> Path:
return _SHARED_PLOT_DIR / f"{plot_id}.svg"


def store_plot_source(plot_id: str, obj: Any) -> None:
# Stores the latest computed object for a plot ID.
Expand All @@ -17,8 +29,16 @@ def store_plot_svg_bytes(plot_id: str, svg_bytes: bytes | None) -> None:
# Stores rendered SVG bytes for plot IDs that are not object-based.
if svg_bytes:
_PLOT_SVG_BYTES[plot_id] = svg_bytes
try:
_shared_plot_path(plot_id).write_bytes(svg_bytes)
except OSError:
pass
else:
_PLOT_SVG_BYTES.pop(plot_id, None)
try:
_shared_plot_path(plot_id).unlink()
except OSError:
pass


def capture_current_figure_svg_bytes() -> bytes | None:
Expand Down Expand Up @@ -50,6 +70,13 @@ def generate_geometry_plot_svg(plot_id: str = "default") -> bytes | None:
if svg_bytes:
return svg_bytes

file_path = _shared_plot_path(plot_id)
if file_path.is_file():
try:
return file_path.read_bytes()
except OSError:
pass

return None
except Exception:
return None
Expand Down
55 changes: 45 additions & 10 deletions server/app/schemas.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import Annotated

from fastapi import Path
from pydantic import BaseModel, Field, confloat, field_validator
from pydantic import BaseModel, Field, confloat, field_validator, model_validator

PlotId = Annotated[str, Path(description="Plot identifier (e.g. 'labyrinth' or 'optimize-abc123')")]

Expand Down Expand Up @@ -101,15 +101,25 @@ class OperationalModelRequest(BaseModel):
"exponential",
description="Interpolation method for hydrograph data ('exponential', 'linear', 'quadratic', 'cubic')",
)
flap_gate_bottom_level: float = Field(..., description="Bottom height at flap gate [m]")
flap_gate_downstream_water_level: float = Field(..., description="Downstream water level at flap gate [m]")
flap_gate_discharge: confloat(gt=0) = Field(..., description="Discharge through flap gate [m³/s]")
flap_gate_width: confloat(gt=0) = Field(..., description="Flap gate width [m]")
flap_gate_height: confloat(gt=0) = Field(..., description="Flap gate height [m]")
flap_gate_angle: float = Field(..., description="Flap gate angle [degree]")
flap_gate_bottom_level: float | None = Field(None, description="Bottom height at flap gate [m]")
flap_gate_downstream_water_level: float | None = Field(
None,
description="Downstream water level at flap gate [m]",
)
flap_gate_discharge: confloat(gt=0) | None = Field(None, description="Discharge through flap gate [m³/s]")
flap_gate_width: confloat(gt=0) | None = Field(None, description="Flap gate width [m]")
flap_gate_height: confloat(gt=0) | None = Field(None, description="Flap gate height [m]")
flap_gate_angle: float | None = Field(None, description="Flap gate angle [degree]")
design_upstream_water_level: float = Field(..., description="Design upstream water level [m]")
max_flap_gate_angle: float = Field(..., description="Maximum flap gate angle [degree]")
max_flap_gate_angle: float | None = Field(
None,
description="Maximum flap gate angle [degree]",
)
fish_body_height: float = Field(..., description="Fish body height for bypass design [m]")
include_flap_gate: bool = Field(
...,
description="Whether the operational model should account for the flap gate.",
)

class Config:
# prefill the example with the default values
Expand All @@ -132,6 +142,7 @@ class Config:
"flap_gate_width": 1.4,
"flap_gate_height": 2.35,
"flap_gate_angle": 74.0,
"include_flap_gate": True,
"design_upstream_water_level": 2.2,
"max_flap_gate_angle": 90.0,
"fish_body_height": 0.4,
Expand All @@ -140,12 +151,33 @@ class Config:

@field_validator("flap_gate_angle", "max_flap_gate_angle")
def flap_gate_angles_range(cls, value, info):
if value is None:
return value
if not 0 <= value <= 90:
if info.field_name == "flap_gate_angle":
raise ValueError("Klappenwinkel β muss im Bereich 0° ≤ β ≤ 90° liegen.")
raise ValueError("Maximaler Klappenwinkel (β) muss im Bereich 0° ≤ β ≤ 90° liegen.")
return value

@model_validator(mode="after")
def flap_gate_values_presence(cls, values):
include_flap_gate = getattr(values, "include_flap_gate", True)
if not include_flap_gate:
return values
mandatory_fields = [
"flap_gate_bottom_level",
"flap_gate_downstream_water_level",
"flap_gate_discharge",
"flap_gate_width",
"flap_gate_height",
"flap_gate_angle",
"max_flap_gate_angle",
]
missing = [name for name in mandatory_fields if getattr(values, name) is None]
if missing:
raise ValueError(f"Flap gate fields required when include_flap_gate is true: {missing}")
return values


class LabyrinthResult(BaseModel):
N: int = Field(..., description="Number of keys")
Expand Down Expand Up @@ -207,5 +239,8 @@ class OperationalPoint(BaseModel):
labyrinth_head_over_crest: float | None = Field(None, description="Labyrinth-specific head over crest (if available)")
flap_gate_head_over_crest: float | None = Field(None, description="Flap gate-specific head over crest (if available)")
labyrinth_discharge: float = Field(..., description="Labyrinth discharge share [m³/s].")
flap_gate_discharge: float = Field(..., description="Flap gate discharge share [m³/s].")
flap_gate_angle: float = Field(..., description="Flap gate angle alpha [degree] for this discharge.")
flap_gate_discharge: float | None = Field(None, description="Flap gate discharge share [m³/s].")
flap_gate_angle: float | None = Field(
None,
description="Flap gate angle alpha [degree] for this discharge.",
)
Loading