diff --git a/engineer.py b/engineer.py index c8cfa42..701b430 100644 --- a/engineer.py +++ b/engineer.py @@ -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): @@ -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"] diff --git a/server/app/routes/operational.py b/server/app/routes/operational.py index 552b7ac..7a8dd65 100644 --- a/server/app/routes/operational.py +++ b/server/app/routes/operational.py @@ -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) @@ -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. @@ -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"), ) diff --git a/server/app/routes/utils/plot_helpers.py b/server/app/routes/utils/plot_helpers.py index 7d77b11..9b6cd01 100644 --- a/server/app/routes/utils/plot_helpers.py +++ b/server/app/routes/utils/plot_helpers.py @@ -1,5 +1,7 @@ import contextlib import io +import os +from pathlib import Path from typing import Any import matplotlib.pyplot as plt @@ -7,6 +9,16 @@ _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. @@ -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: @@ -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 diff --git a/server/app/schemas.py b/server/app/schemas.py index 5f05c5f..32d4989 100644 --- a/server/app/schemas.py +++ b/server/app/schemas.py @@ -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')")] @@ -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 @@ -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, @@ -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") @@ -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.", + )