diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..e4b4c3e
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+version: 2
+updates:
+ - package-ecosystem: "pip"
+ directory: "/"
+ schedule:
+ interval: "monthly"
+ # group the dependencies
+ groups:
+ python-dependencies:
+ patterns:
+ - "*"
diff --git a/.github/workflows/pip-audit.yml b/.github/workflows/pip-audit.yml
new file mode 100644
index 0000000..11863ac
--- /dev/null
+++ b/.github/workflows/pip-audit.yml
@@ -0,0 +1,28 @@
+name: Pip-Audit Security Check
+
+on:
+ push:
+ branches: ["dev"]
+ pull_request:
+ branches: ["dev"]
+
+jobs:
+ scan:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install pip-audit
+ pip install -r requirements.txt
+
+ - name: Run pip-audit manually
+ run: pip-audit -r requirements.txt
diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml
new file mode 100644
index 0000000..3da2497
--- /dev/null
+++ b/.github/workflows/ruff.yml
@@ -0,0 +1,44 @@
+name: Ruff Linting
+
+on:
+ push:
+ branches: ["dev"]
+ pull_request:
+ branches: ["dev"]
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+
+ - name: Install Ruff
+ run: |
+ pip install ruff
+
+ - name: Run Ruff Code quality check
+ run: ruff check .
+
+ format:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+
+ - name: Install Ruff
+ run: |
+ pip install ruff
+
+ - name: Run Ruff format check
+ run: ruff format --check .
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d984d86
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,41 @@
+# OS
+.DS_Store
+Thumbs.db
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+
+# Logs
+logs/
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Dependencies
+node_modules/
+
+# Build outputs
+dist/
+build/
+*.egg-info/
+
+# Environment variables
+.env
+.env.local
+.env.*.local
+.venv
+__pycache__/
+
+# Temporary files
+tmp/
+temp/
+*.tmp
+
+# cache files
+.pytest_cache/
+.ruff_cache/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..aac93d4
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,23 @@
+repos:
+ # Ruff für Linting und Formatting
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.8.5
+ hooks:
+ # Ruff Linting
+ - id: ruff
+ args: [--fix]
+ # Ruff Formatting
+ - id: ruff-format
+
+ # essential Pre-commit Checks
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v5.0.0
+ hooks:
+ - id: trailing-whitespace
+ - id: end-of-file-fixer
+ - id: check-yaml
+ - id: check-json
+ - id: check-toml
+ - id: check-added-large-files
+ args: ["--maxkb=500"]
+ - id: check-merge-conflict
diff --git a/README.md b/README.md
index 6bc20a8..db940e7 100644
--- a/README.md
+++ b/README.md
@@ -1,37 +1,81 @@
-
+
+
# ENGINEER
+
Welcome to the ENGINEER project (**En**twicklun**g** standardisierter **In**formationsmodelle für die Planung am B**e**ispiel von Labyrinth-W**e**h**r**anlagen)!
The ENGINEER project is developing an automated digital design for labyrinth weir structures. The project was funded by [mFUND](https://bmdv.bund.de/DE/Themen/Digitales/mFund/Projekte/mfund-projekte.html). Participants include the [BAW](https://www.baw.de), [WNA Magdeburg](https://www.wna-magdeburg.wsv.de/Webs/WNA/WNA-Magdeburg/DE/Startseite/startseite_node.html), [Arcadis](https://www.arcadis.com/de-de) and [Cadcom](https://cadcom.de/). The project is funded until end of February 2025. After that, further development will be limited to minor bug fixes as needed and subject to availability of resources.
-This repository contains the Python scripts for the hydraulic design of labyrinth weir structures developed by BAW. This is only a small part of the developements from the ENGINEER project but it might be useful for you.
+This repository contains the Python scripts for the hydraulic design of labyrinth weir structures developed by BAW. This is only a small part of the developements from the ENGINEER project but it might be useful for you. The core functionality can be used as a standalone Python library or deployed as a REST API (see the [API Guide](server/API_GUIDE.md) below).
->[!WARNING]
->The developed source code is the result of a research project. BAW does not accept any responsibility for the correctness of the code or the results achieved with it. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-[GNU General Public License](LICENSE) for more details.
+> [!WARNING]
+> The developed source code is the result of a research project. BAW does not accept any responsibility for the correctness of the code or the results achieved with it. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+> [GNU General Public License](LICENSE) for more details.
Currently the code is written in "Denglish" which is a mixture of German and English language. Sorry for that. We are working on it. Volunteers are welcome to help with the translation.
# Usage
+
## General considerations
+
This code can be used to design labyrinth weir structures consisting of a labyrinth weir and a parrallel flap gate. Furthermore, it is possible to estimate the hydraulic effect of the system over a given discharge range. You can find more information about labyrinth weirs in the BAWMitteilungen Nr. 105[^fn1]. The hydraulic calculation is based on the formulas published by Crookston & Tullis (2013)[^fn2] and Tullis et al. (2007)[^fn3].
-This repository consists mainly of two Python files:
-*
engineer.py
This ist the brain. You should not modify this file unless you find an bug or want to develop the project further.
-*
example.py
This is an application example. Feel free to adapt this file according to your wishes and your project. You will find all the code snippets from this README.md in example.py.
+
+This repository consists mainly of four Python files:
+
+-
engineer.py
This ist the brain. You should not modify this file unless you find an bug or want to develop the project further.
+-
examples/example.py
This is an application example. Feel free to adapt this file according to your wishes and your project. You will find all the code snippets from this README.md in examples/example.py.
+-
STL_function.py
Single entry point `generate_labyrinth_geometry(...)` builds a trapezoidal labyrinth weir and writes a watertight binary STL mesh that you can download or hand to a slicer.
+-
examples/example_stl_generation.py
Demonstrates how to call `generate_labyrinth_geometry` with typical dimensions and write the resulting STL to disk.
+
+**REST API:** All functionality is also available via a REST API. See the [API Guide](server/API_GUIDE.md) for detailed documentation.
## Prerequisites
-You need to have the following python libraries installed on your system:
-
numpy matplotlib os shutil pandas math re sys scipy
-We recommend installing these either with [pip](https://packaging.python.org/en/latest/tutorials/installing-packages/) or [conda](https://docs.conda.io/projects/conda/en/23.3.x/user-guide/getting-started.html).
-Example of installation with pip:
-
pip install numpy matplotlib os shutil pandas math re sys scipy
-Example of installation with conda:
-
conda install numpy matplotlib os shutil pandas math re sys scipy
+
+### Python Libraries
+
+Install the required Python libraries using the provided `requirements.txt` file:
+
+```bash
+pip install -r requirements.txt
+```
+
+Alternatively, you can install the libraries manually:
+
+- `numpy`, `scipy`, `pandas`, `matplotlib` (for calculations and plotting)
+- `fastapi`, `uvicorn`, `pydantic` (for the REST API server)
+- `openpyxl` (for Excel export functionality)
+
+**Note:** The libraries `os`, `shutil`, `math`, `re`, `sys` are part of Python's standard library and don't need to be installed separately.
+
+### Pre-commit Hooks
+
+This project uses pre-commit hooks to ensure code quality and consistency. The hooks run automatically before each commit and include:
+
+- **Ruff** for linting and code formatting
+- Essential checks (trailing whitespace, end-of-file fixes, YAML/JSON/TOML validation, etc.)
+
+To set up pre-commit hooks:
+
+```bash
+# Install pre-commit (included in requirements.txt)
+pip install -r requirements.txt
+
+# Install the git hooks
+pre-commit install
+
+# Optional: Run hooks on all files to fix existing issues
+pre-commit run --all-files
+```
+
+After installation, the hooks will run automatically on every `git commit`. If any hook fails, the commit will be blocked until the issues are resolved.
## Hydraulic Design
-
+
+
+
### Case 1: You already know the geometry of your labyrinth weir
-
+
+
If you already know the geometry of your labyrinth weir you can plot it and calculate the upstream water level depending on the geometry, the discharge and the downstream water level. You can initialise an object `lab` from the class `labyrinth` and calculate the upstream water level as shown below.
@@ -46,10 +90,11 @@ If you already know the geometry of your labyrinth weir you can plot it and calc
D=0.5) # front wall width [m]
```
-The object `labyrinth_weir` includes the attributes overflow height `labyrinth_weir.hu` and the absolute upstream water level `labyrinth_weir.yu`. In case you change any attribute, e.g. the labyrinth weir
+The object `labyrinth_weir` includes the attributes overflow height `labyrinth_weir.hu` and the absolute upstream water level `labyrinth_weir.yu`. In case you change any attribute, e.g. the labyrinth weir
height `labyrinth_weir.P`, you have to rerun the hydraulic calculation with `labyrinth_weir.update()`.
Furthermore, you can output a summary of the input and output parameters of the calculation:
+
```python
labyrinth_weir.verbose = 1
labyrinth_weir.print_results()
@@ -58,24 +103,25 @@ Furthermore, you can output a summary of the input and output parameters of the
The output looks similar to:
### Case 2: You know how large your construction site is, how high the weir should be and the design discharge. Let ENGINEER design the labyrinth itself.
-
+
+
In this case, ENGINEER will design the labyrinth weir to fit your construction site and to ensure the lowest possible upstream water level at the specified design discharge.
First, define your bounday conditions:
@@ -85,30 +131,35 @@ labyrinth_width = 10 # available width for the labyrinth weir [m]
labyrinth_length = 8 # available length in flow direction for the labyrinth weir [m]
design_discharge = 20 # design discharge [m3/s]
design_downstream_water_level = 1.8 # downstream water level at design discharge [m]
-labyrinth_crest_height = 2.2 # crest height of labyrinth weir [m]
+labyrinth_crest_height = 2.2 # crest height of labyrinth weir [m]
```
Then start the optimization:
+
```python
optimized_labyrinth = optimize_labyrinth_geometry(labyrinth, bottom_level, design_downstream_water_level, design_discharge,
labyrinth_width, labyrinth_crest_height - bottom_level + 0, labyrinth_length,
path='', show_plot=False)
```
-This code gives you the object ```optimized_labyrinth```, which is an instance of the class ```labyrinth```. Now you can continue to work with it, as in Case 1.
-Again, you can postprocess your ```optimized_labyrinth```:
+This code gives you the object `optimized_labyrinth`, which is an instance of the class `labyrinth`. Now you can continue to work with it, as in Case 1.
+
+Again, you can postprocess your `optimized_labyrinth`:
+
```python
optimized_labyrinth.plot_geometry() #plot the optimized geometry (see plot below)
optimized_labyrinth.verbose = 1 #print output
optimized_labyrinth.print_results() #print result parameters
```
-
+
### Flap Gate
-
-The objects of the class ```flap_gate``` work similar to the class ```labyrinth```. You have to define the maximum height of the flap gate, the angle to the horizontal, the discharge and the downstream water level. The object will calculate the upstream water level:
+
+
+The objects of the class `flap_gate` work similar to the class `labyrinth`. You have to define the maximum height of the flap gate, the angle to the horizontal, the discharge and the downstream water level. The object will calculate the upstream water level:
+
```python
flap_gate = FlapGate(bottom_level=0.1, # bottom height [m]
downstream_water_level=1.09, # downstream water level [m]
@@ -117,17 +168,22 @@ flap_gate = FlapGate(bottom_level=0.1, # bottom height [m]
flap_gate_height=2.35, # flap height [m]
flap_gate_angle=74) # flap angle [degree]
```
+
The upstream water level is calculated according to Bollrich (2019)[^fn4].
To print to overflow height, do:
+
```python
print(flap_gate.hu)
2.7284763202006874
```
## Operational Model
-
-The labyrinth weir and the flap gate are coupled via the common upstream water level. The discharge is distributed depending on the capacity of the two parts. This coupling is automatically done in the code with the function `coupling`. As the total discharge increases, the valve is opened further and further to ensure that the legally required design water level is maintained. As soon as the flap is fully lowered, the water begins to flow over the labyrinth weir. This is implemented by the `operational_model` function.
+
+
+The labyrinth weir and the flap gate are coupled via the common upstream water level. The discharge is distributed depending on the capacity of the two parts. This coupling is automatically done in the code with the function `coupling`. As the total discharge increases, the valve is opened further and further to ensure that the legally required design water level is maintained. As soon as the flap is fully lowered, the water begins to flow over the labyrinth weir. This is implemented by the `operational_model` function.
+
To use the `operational_model` the following steps are required:
+
1. The discharge and the downstream rating curve must be defined. Both must be defined as a numpy array.
```python
discharge = np.array([
@@ -143,7 +199,6 @@ To use the `operational_model` the following steps are required:
22.90,
24.50])
```
-
```python
downstream_water_level = np.array([
1.07,
@@ -158,16 +213,14 @@ To use the `operational_model` the following steps are required:
2.67,
2.67])
```
- The model will calculate a continuous discharge curve in steps of 0.1 l/s and interpolate discharges and tailwater levels for this purpose. To do this, you have to choose an interpolation method. To try out the available interpolation methods, the `interpolate_downstream_curve` function can be used.
+ The model will calculate a continuous discharge curve in steps of 0.1 l/s and interpolate discharges and tailwater levels for this purpose. To do this, you have to choose an interpolation method. To try out the available interpolation methods, the `interpolate_downstream_curve` function can be used.
```python
interpolate_downstream_curve(discharge,downstream_water_level,interpolation='all',show_plot=True, save_plot=False)
```
-
+
Please interpret the plot with engineering expertise and decide on the interpolation method that best matches the given tailwater levels.
-
+2. We assume that the planning will replace an existing control structure and that the future water level must be compared with the current water level in order to prove that the discharge capacity remains unchanged. Therefore, the current water level must be specified for the discharge points given from step 1:
-
-3. We assume that the planning will replace an existing control structure and that the future water level must be compared with the current water level in order to prove that the discharge capacity remains unchanged. Therefore, the current water level must be specified for the discharge points given from step 1:
```python
downstream_water_level = np.array([
2.03,
@@ -181,40 +234,65 @@ To use the `operational_model` the following steps are required:
2.47,
2.47,
2.47])
- ```
+ ```
+
+3. An instance of the class `flap_gate` and the class `labyrinth` must be initialized as explained above. For doing this, either a self-designed labyrinth weir or the optimized one from case 2 can be used.
+4. In order to operate the valve, the following data is required: the design water level and the maximum angle of the flap to the horizontal. Now the operational model can be initialized.
-4. An instance of the class `flap_gate` and the class `labyrinth` must be initialized as explained above. For doing this, either a self-designed labyrinth weir or the optimized one from case 2 can be used.
-
-6. In order to operate the valve, the following data is required: the design water level and the maximum angle of the flap to the horizontal. Now the operational model can be initialized.
```python
results, results_events = operational_model(labyrinth_object=optimized_labyrinth,
flap_gate_opject=flap_gate,
discharge_vector=discharge,
downstream_water_level_vector=downstream_water_level,
- upstream_water_level_vector=upstream_water_level_today,
design_upstream_water_level=design_upstream_water_level,
max_flap_gate_angle=max_flap_gate_angle,
fish_body_height=fish_body_height,
interpolation_method='exponential',
show_plot=False,
save_plot=False)
- ```
+ ```
-8. The return value is two variables of the type [pandas.DataFrame](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.html): results and results_evens:
+5. The return value is two variables of the type [pandas.DataFrame](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.html): results and results_evens:
- `results` contains discharge, downstream water level, upstream water level, discharge over the labyrinth weir, discharge over the flap gate and flap angle for the following range: `np.arange(min(discharge), max(discharge), 0.1)`.
- `results_events` contains the same parameters as `results` but for the grid point given in `discharge`.
- In addition, a figure is displayed that contains the following representations (from top to bottom): downstream water level grid points and interpolation curve, fractions of discharge over labyrinth weir and flap gate, upstream water level in the design and actual state, flap angle. The x-axis of all plots indicates the total discharge through the system.
-
-
+
+
+## STL Export
+
+If you need a geometry file for CAD/3D printing or downstream workflows, `STL_function.generate_labyrinth_geometry(...)` builds a trapezoidal labyrinth weir from the same dimensional parameters used in the hydraulic calculations and writes it as a binary STL mesh. The function returns the written `filename` and an `info` dictionary with derived geometry metrics (cycle widths, crest length, number of teeth, triangle count, etc.).
+
+The helper script `examples/example_stl_generation.py` shows a minimal invocation:
+
+```python
+from STL_function import generate_labyrinth_geometry
+
+D = 0.5 # front/back wall width [m]
+W = 4 # total channel width [m]
+alpha = 8 # key angle [°]
+available_length = 10 # available length in flow direction [m]
+t = 0.7 # wall thickness [m]
+B = available_length - t
+P = 6 # crest height [m]
+
+filename, info = generate_labyrinth_geometry(D, W, alpha, B, t=t, P=P, filename="labyrinth_stl.stl")
+print(f"STL written to {filename} ({info['n_triangles']} triangles)")
+```
+
+Adjust the parameters to match your site and download the generated `labyrinth_stl.stl` for visualization or fabrication.
+
+## REST API
+
+ENGINEER provides a REST API for programmatic access to all hydraulic calculations. For complete API documentation, including endpoints, request/response schemas, examples, and error handling, see the [API Guide](server/API_GUIDE.md).
# Literature
+
[^fn1]: Bundesanstalt für Wasserbau (Hg.) (2020): Feste Wehre an Bundeswasserstraßen: Untersuchungen zur Machbarkeit sowie Empfehlungen zur Umsetzung. Karlsruhe: Bundesanstalt für Wasserbau (BAWMitteilungen, 105). [https://hdl.handle.net/20.500.11970/107132](https://hdl.handle.net/20.500.11970/107132)
-[^fn2]: Crookston, B. M.; Tullis, B. P. (2013): Hydraulic Design and Analysis of Labyrinth Weirs. I: Discharge Relationships. In: Journal of Irrigation and Drainage Engineering
-139 (5), S. 363–370. [https://doi.org/10.1061/(ASCE)IR.1943-4774.0000558](https://doi.org/10.1061/(ASCE)IR.1943-4774.0000558)
+[^fn2]:
+ Crookston, B. M.; Tullis, B. P. (2013): Hydraulic Design and Analysis of Labyrinth Weirs. I: Discharge Relationships. In: Journal of Irrigation and Drainage Engineering
+ 139 (5), S. 363–370. [https://doi.org/10.1061/(ASCE)IR.1943-4774.0000558]()
-[^fn3]: Tullis, B. P.; Young, J. C.; Chandler, M. A. (2007): Head-Discharge Relationships for Submerged Labyrinth Weirs. In: J. Hydraul. Eng. 133 (3), S. 248–254. [https://doi.org/10.1061/(ASCE)0733-9429(2007)133:3(248)](https://doi.org/10.1061/(ASCE)0733-9429(2007)133:3(248))
+[^fn3]: Tullis, B. P.; Young, J. C.; Chandler, M. A. (2007): Head-Discharge Relationships for Submerged Labyrinth Weirs. In: J. Hydraul. Eng. 133 (3), S. 248–254. [https://doi.org/10.1061/(ASCE)0733-9429(2007)133:3(248)]()
[^fn4]: Bollrich, Gerhard (2019): Technische Hydromechanik 1. Grundlagen. Berlin: Beuth Verlag GmbH.
-
-
diff --git a/STL_function.py b/STL_function.py
new file mode 100644
index 0000000..7295ea0
--- /dev/null
+++ b/STL_function.py
@@ -0,0 +1,349 @@
+"""
+Created on Fri Apr 10 12:14:14 2026
+
+@author: morenos
+
+Labyrinth weir STL generator.
+
+Single entry point: `generate_labyrinth_geometry(D, W, alpha, B, t, P, filename)`
+Builds the geometry of a trapezoidal labyrinth weir from its dimensional
+parameters and writes a watertight binary STL mesh ready for download.
+"""
+
+import struct
+
+import numpy as np
+
+# ---------------------------------------------------------------------------
+# Helper functions
+# ---------------------------------------------------------------------------
+
+
+def _offset_polyline(x, y, d):
+ """
+ Compute the parallel offset of a polyline at distance `d`.
+ d > 0 -> left side (CCW normal of each segment)
+ d < 0 -> right side
+ Uses miter joins at interior vertices.
+ """
+ pts = np.column_stack([x, y]).astype(float)
+ n = len(pts)
+ out = np.zeros_like(pts)
+
+ edges = pts[1:] - pts[:-1]
+ lens = np.linalg.norm(edges, axis=1)
+ e_u = edges / lens[:, None]
+ # "Left" normal: rotate 90° CCW (x, y) -> (-y, x)
+ normals = np.column_stack([-e_u[:, 1], e_u[:, 0]])
+
+ # End points: offset perpendicular to the adjacent segment
+ out[0] = pts[0] + d * normals[0]
+ out[-1] = pts[-1] + d * normals[-1]
+
+ # Interior vertices: miter join
+ for i in range(1, n - 1):
+ n1, n2 = normals[i - 1], normals[i]
+ bis = n1 + n2
+ denom = 1.0 + float(n1 @ n2)
+ if abs(denom) < 1e-12:
+ out[i] = pts[i] + d * n1
+ else:
+ out[i] = pts[i] + d * bis / denom
+ return out[:, 0], out[:, 1]
+
+
+def _build_polyline(D, W, alpha, B):
+ """
+ Build the crest polyline of the trapezoidal labyrinth weir in plan view.
+
+ The labyrinth is built as `n` symmetric teeth with (n-1) front walls
+ between them (no front wall at the very start or end). Each tooth =
+ upstream slope + back wall + downstream slope. Labyrinth width:
+ W_lab = n * w_cycle - D.
+
+ If `W` does not allow an integer number of teeth, the cycles are centered
+ and the leftover length is distributed as two straight extensions
+ (at y = 0) on each side.
+ """
+ alpha_rad = np.radians(alpha)
+ proj = B * np.tan(alpha_rad)
+ side_len = B / np.cos(alpha_rad)
+
+ w_cycle = 2 * D + 2 * proj
+ L_cycle = 2 * D + 2 * side_len
+
+ n_exact = W / w_cycle
+ n_teeth = int(np.floor(n_exact))
+ if n_teeth < 1:
+ raise ValueError(f"W={W} is too small for a single tooth (w_cycle={w_cycle:.4f}, n_exact={n_exact:.3f}). Reduce D, B or alpha, or increase W.")
+
+ W_lab = n_teeth * w_cycle - D # width occupied by the symmetric teeth
+ pad = (W - W_lab) / 2.0 # straight extension on each side
+
+ xs, ys = [0.0], [0.0]
+ x = 0.0
+
+ # Left straight extension
+ if pad > 0:
+ x += pad
+ xs.append(x)
+ ys.append(0.0)
+
+ # First tooth: upstream slope + back wall + downstream slope (no leading front wall)
+ x += proj
+ xs.append(x)
+ ys.append(B)
+ x += D
+ xs.append(x)
+ ys.append(B)
+ x += proj
+ xs.append(x)
+ ys.append(0.0)
+
+ # Remaining teeth: front wall + upstream slope + back wall + downstream slope
+ for _ in range(n_teeth - 1):
+ x += D
+ xs.append(x)
+ ys.append(0.0)
+ x += proj
+ xs.append(x)
+ ys.append(B)
+ x += D
+ xs.append(x)
+ ys.append(B)
+ x += proj
+ xs.append(x)
+ ys.append(0.0)
+
+ # Right straight extension
+ if pad > 0:
+ x += pad
+ xs.append(x)
+ ys.append(0.0)
+
+ L_total = n_teeth * L_cycle - D + 2 * pad
+
+ info = {
+ "n_exact": n_exact,
+ "n_cycles": n_teeth,
+ "n_teeth": n_teeth,
+ "w_cycle": w_cycle,
+ "L_cycle": L_cycle,
+ "L_total": L_total,
+ "W_used": W,
+ "W_lab": W_lab,
+ "pad": pad,
+ "L_over_W": L_total / W,
+ "proj": proj,
+ "side_len": side_len,
+ }
+ return np.array(xs), np.array(ys), info
+
+
+def _cap_rings(x, y, xL, yL, P, r, n_theta=14):
+ """
+ Generate rings of a half-cylinder of radius `r` swept along the centerline,
+ whose base sits at z = P - r and top at z = P. Each ring corresponds to
+ an angle theta in [-pi/2, +pi/2] around the cylinder axis.
+
+ Convention:
+ theta = -pi/2 -> coincides with the right offset (xR, z = P-r)
+ theta = 0 -> top of the dome (centerline, z = P)
+ theta = +pi/2 -> coincides with the left offset (xL, z = P-r)
+
+ Returns: list of (cx, cy, cz) arrays of length n.
+ """
+ dxL = xL - x
+ dyL = yL - y
+ thetas = np.linspace(-np.pi / 2, np.pi / 2, n_theta + 1)
+ rings = []
+ for th in thetas:
+ s = np.sin(th)
+ c = np.cos(th)
+ cx = x + s * dxL
+ cy = y + s * dyL
+ cz = np.full_like(x, (P - r) + r * c)
+ rings.append((cx, cy, cz))
+ return rings
+
+
+# ---------------------------------------------------------------------------
+# Main entry point
+# ---------------------------------------------------------------------------
+
+
+def generate_labyrinth_geometry(D, W, alpha, B, t=0.01, P=0.253, filename="labyrinth_weir.stl", n_theta=14):
+ """
+ Generate a trapezoidal labyrinth weir as a watertight binary STL mesh.
+
+ Parameters
+ ----------
+ D : float
+ Front/back wall width [m].
+ W : float
+ Total channel width [m].
+ alpha : float
+ Sidewall angle [degrees].
+ B : float
+ Depth in flow direction [m].
+ t : float, optional
+ Wall thickness [m]. Default 0.01 (Ts = 0.01 m).
+ P : float, optional
+ Weir height [m]. Default 0.253.
+ filename : str, optional
+ Output STL file path. Default "labyrinth_weir.stl".
+ n_theta : int, optional
+ Number of angular subdivisions of the rounded crest dome. Default 14.
+
+ Returns
+ -------
+ filename : str
+ Path of the written STL file.
+ info : dict
+ Dictionary with derived geometric quantities (number of cycles,
+ cycle width, total crest length, L/W ratio, etc.).
+ """
+ # 1) Build centerline polyline and left/right offsets for wall thickness
+ x, y, info = _build_polyline(D, W, alpha, B)
+ xL, yL = _offset_polyline(x, y, +t / 2)
+ xR, yR = _offset_polyline(x, y, -t / 2)
+
+ n = len(xL)
+ r = t / 2
+ z_body = P - r
+
+ # 2) Triangulate: each entry is (v0, v1, v2) in CCW order viewed from outside
+ triangles = []
+
+ def quad_to_tris(a, b, c, d):
+ triangles.append((a, b, c))
+ triangles.append((a, c, d))
+
+ # --- Vertical body and bottom cap ---
+ for i in range(n - 1):
+ # Outer left face (normal pointing outward on the left side)
+ quad_to_tris(
+ (xL[i], yL[i], 0.0),
+ (xL[i], yL[i], z_body),
+ (xL[i + 1], yL[i + 1], z_body),
+ (xL[i + 1], yL[i + 1], 0.0),
+ )
+ # Outer right face
+ quad_to_tris(
+ (xR[i], yR[i], 0.0),
+ (xR[i + 1], yR[i + 1], 0.0),
+ (xR[i + 1], yR[i + 1], z_body),
+ (xR[i], yR[i], z_body),
+ )
+ # Bottom cap (z = 0, normal pointing downward)
+ quad_to_tris(
+ (xL[i], yL[i], 0.0),
+ (xL[i + 1], yL[i + 1], 0.0),
+ (xR[i + 1], yR[i + 1], 0.0),
+ (xR[i], yR[i], 0.0),
+ )
+
+ # --- Dome: strip of quads between consecutive rings ---
+ rings = _cap_rings(x, y, xL, yL, P, r, n_theta=n_theta)
+ for j in range(len(rings) - 1):
+ cxA, cyA, czA = rings[j]
+ cxB, cyB, czB = rings[j + 1]
+ for i in range(n - 1):
+ # Winding so the normal points outward from the dome
+ quad_to_tris(
+ (cxA[i], cyA[i], czA[i]),
+ (cxA[i + 1], cyA[i + 1], czA[i + 1]),
+ (cxB[i + 1], cyB[i + 1], czB[i + 1]),
+ (cxB[i], cyB[i], czB[i]),
+ )
+
+ # --- End caps: rectangle (0..z_body) + half-disk (fan) ---
+ for i_end, sign in ((0, -1), (-1, +1)):
+ # Rectangle at the end. `sign` is the outward normal direction
+ # (-x at the start, +x at the end).
+ if sign < 0:
+ quad_to_tris(
+ (xL[i_end], yL[i_end], 0.0),
+ (xL[i_end], yL[i_end], z_body),
+ (xR[i_end], yR[i_end], z_body),
+ (xR[i_end], yR[i_end], 0.0),
+ )
+ else:
+ quad_to_tris(
+ (xR[i_end], yR[i_end], 0.0),
+ (xR[i_end], yR[i_end], z_body),
+ (xL[i_end], yL[i_end], z_body),
+ (xL[i_end], yL[i_end], 0.0),
+ )
+ # Half-disk fan around the end center (x[i_end], y[i_end], z_body)
+ cx0, cy0 = float(x[i_end]), float(y[i_end])
+ center = (cx0, cy0, z_body)
+ for j in range(len(rings) - 1):
+ ax_, ay_, az_ = (float(rings[j][0][i_end]), float(rings[j][1][i_end]), float(rings[j][2][i_end]))
+ bx_, by_, bz_ = (float(rings[j + 1][0][i_end]), float(rings[j + 1][1][i_end]), float(rings[j + 1][2][i_end]))
+ if sign < 0:
+ triangles.append((center, (ax_, ay_, az_), (bx_, by_, bz_)))
+ else:
+ triangles.append((center, (bx_, by_, bz_), (ax_, ay_, az_)))
+
+ # 3) Compute normals and write binary STL
+ def normal(a, b, c):
+ ax, ay, az = a
+ bx, by, bz = b
+ cx, cy, cz = c
+ ux, uy, uz = bx - ax, by - ay, bz - az
+ vx, vy, vz = cx - ax, cy - ay, cz - az
+ nx, ny, nz = uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx
+ L = (nx * nx + ny * ny + nz * nz) ** 0.5
+ if L == 0:
+ return 0.0, 0.0, 0.0
+ return nx / L, ny / L, nz / L
+
+ with open(filename, "wb") as f:
+ f.write(b"\0" * 80) # 80-byte header
+ f.write(struct.pack("
+
+
diff --git a/pictures/best_lab_plot.png b/assets/pictures/best_lab_plot.png
similarity index 100%
rename from pictures/best_lab_plot.png
rename to assets/pictures/best_lab_plot.png
diff --git a/pictures/dimensions.svg b/assets/pictures/dimensions.svg
similarity index 100%
rename from pictures/dimensions.svg
rename to assets/pictures/dimensions.svg
diff --git a/assets/pictures/empty.txt b/assets/pictures/empty.txt
new file mode 100644
index 0000000..e69de29
diff --git a/assets/pictures/flap_gate.webp b/assets/pictures/flap_gate.webp
new file mode 100644
index 0000000..4aff8b7
Binary files /dev/null and b/assets/pictures/flap_gate.webp differ
diff --git a/assets/pictures/operational_model.webp b/assets/pictures/operational_model.webp
new file mode 100644
index 0000000..20d6651
Binary files /dev/null and b/assets/pictures/operational_model.webp differ
diff --git a/pictures/results_plot.png b/assets/pictures/results_plot.png
similarity index 100%
rename from pictures/results_plot.png
rename to assets/pictures/results_plot.png
diff --git a/engineer.py b/engineer.py
index 2fd2a29..b136ef3 100644
--- a/engineer.py
+++ b/engineer.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""
Created on Wed Aug 3 14:05:07 2022
@@ -22,47 +21,79 @@
along with this program. If not, see .
"""
+import io
import math
+import os
import re
-import sys
+import matplotlib
+
+# Automatically use non-GUI backend in server mode
+if os.environ.get("SERVER_MODE") == "1":
+ matplotlib.use("Agg") # use non-GUI backend for server / headless environments
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib.patches import Arc
from scipy.interpolate import interp1d
-from scipy.optimize import fsolve, curve_fit, minimize, minimize_scalar
+from scipy.optimize import curve_fit, fsolve, minimize, minimize_scalar
+
+
+class EngineerInputError(Exception):
+ """Grouping of all input errors."""
+
+ def __init__(self, messages):
+ if isinstance(messages, str):
+ self.messages = [messages]
+ else:
+ self.messages = list(messages)
+ message = "; ".join(self.messages) if self.messages else "Invalid input parameters."
+ super().__init__(message)
+
-'''plots format style'''''''''''''''''''''
+"""plots format style""" """""" """""" """"""
-plt.rcParams['text.usetex'] = False
-plt.rcParams['font.family'] = 'serif'
+plt.rcParams["text.usetex"] = False
+plt.rcParams["font.family"] = "serif"
# plt.rcParams['font.serif'] = ['Cambria']
-plt.rcParams['font.size'] = 11
-plt.rcParams['axes.grid'] = True
-plt.rcParams['axes.grid.axis'] = 'both'
-plt.rcParams['axes.grid.which'] = 'both'
-plt.rcParams['grid.linestyle'] = 'dashed'
-plt.rcParams['grid.linewidth'] = 0.5
-plt.rcParams['grid.color'] = 'grey'
-plt.rcParams['grid.alpha'] = 0.8
-plt.rcParams['figure.figsize'] = (6.5, 7.5) # size in inches
-plt.rcParams['lines.linewidth'] = 1
-plt.rcParams['lines.markersize'] = 4
-
-plt.rcParams['figure.subplot.left'] = 0.125
-plt.rcParams['figure.subplot.right'] = 0.9
-plt.rcParams['figure.subplot.bottom'] = 0.09
-plt.rcParams['figure.subplot.top'] = 0.975
-
-''''''''''''''''''''''''''''''''''''''
-
-
-class Labyrinth(): # this is only one geometry
-
- def __init__(self, bottom_level=None, downstream_water_level=None, discharge=None, labyrinth_width=None,
- labyrinth_height=None, labyrinth_length=None, labyrinth_key_angle=None, path='', show_errors=True,
- show_geometry=False, show_results=False, D=0.3, t=0.3, skip_zero_check=False): # instance attribute
+plt.rcParams["font.size"] = 11
+plt.rcParams["axes.grid"] = True
+plt.rcParams["axes.grid.axis"] = "both"
+plt.rcParams["axes.grid.which"] = "both"
+plt.rcParams["grid.linestyle"] = "dashed"
+plt.rcParams["grid.linewidth"] = 0.5
+plt.rcParams["grid.color"] = "grey"
+plt.rcParams["grid.alpha"] = 0.8
+plt.rcParams["figure.figsize"] = (6.5, 7.5) # size in inches
+plt.rcParams["lines.linewidth"] = 1
+plt.rcParams["lines.markersize"] = 4
+
+plt.rcParams["figure.subplot.left"] = 0.125
+plt.rcParams["figure.subplot.right"] = 0.9
+plt.rcParams["figure.subplot.bottom"] = 0.09
+plt.rcParams["figure.subplot.top"] = 0.975
+
+"""""" """""" """""" """""" """""" """""" ""
+
+
+class Labyrinth: # this is only one geometry
+ def __init__(
+ self,
+ bottom_level=None,
+ downstream_water_level=None,
+ discharge=None,
+ labyrinth_width=None,
+ labyrinth_height=None,
+ labyrinth_length=None,
+ labyrinth_key_angle=None,
+ D=None,
+ path="",
+ show_errors=True,
+ show_geometry=False,
+ show_results=False,
+ t=0.3,
+ skip_zero_check=False,
+ ): # instance attribute
self.Sh = bottom_level # Sohlhöhe [m ü. NHN]
self.UW = downstream_water_level # Unterwasserstand [m ü. NHN]
self.Q = discharge # Abfluss [m3/sec]
@@ -106,8 +137,8 @@ def input_plausibilty(eingabe_name, eingabe_wert, max_value=None, min_value=None
fehler = [] # Store error messages
if not self.skip_zero_check and eingabe_wert <= 0:
- if re.search(r'(Unterwasser|SohleHoehe)', eingabe_name):
- fehler.append(f"Achtung: {eingabe_name} Wert ist negative.")
+ if re.search(r"(Unterwasser|SohleHoehe)", eingabe_name):
+ fehler.append(f"Achtung: {eingabe_name} Wert ist negativ.")
else:
fehler.append(f"{eingabe_name} Wert ist nicht plausibel (sollte größer als 0 sein).")
@@ -132,17 +163,16 @@ def input_plausibilty(eingabe_name, eingabe_wert, max_value=None, min_value=None
fehler += input_plausibilty("t", self.t)
if all(fehler_message.startswith("Achtung:") for fehler_message in fehler):
+ # Only warnings -> optionally print to console, but allow computation to continue
for i, fehler_message in enumerate(fehler, start=1):
- print(f"{i}. {fehler_message}")
+ print(f"[Labyrinth] {i}. {fehler_message}")
fehler = None
else:
- for i, fehler_message in enumerate(fehler, start=1):
- print(f"{i}. {fehler_message}")
- sys.exit()
+ # Hard input errors -> raise exception without additional print (avoid duplicate output)
+ raise EngineerInputError(fehler)
# Berechnung der Wehrgeometrie
def geometrie(self):
-
self.w = 2 * (self.D + self.B * (math.tan(math.radians(self.alpha)))) # w = Breite der einzelnen Keys
self.l = self.B / math.cos(math.radians(self.alpha)) # Länge der einzelnen schrägen Seitenwände
self.N = math.floor(self.W / self.w) # Anazahl der Keys
@@ -153,14 +183,17 @@ def geometrie(self):
# Abrufen von Konstanten aus Alpha_result, Private method
def __angle_result(self):
-
- Angle_kons = np.array([[6, 0.009447, -4.039, 0.3955, 0.187],
- [8, 0.017090, -3.497, 0.4048, 0.2286],
- [10, 0.029900, -2.978, 0.4107, 0.2520],
- [12, 0.030390, -3.102, 0.4393, 0.2912],
- [15, 0.031600, -3.270, 0.4849, 0.3349],
- [20, 0.033610, -3.500, 0.5536, 0.3923],
- [35, 0.018550, -4.904, 0.6697, 0.5062]])
+ Angle_kons = np.array(
+ [
+ [6, 0.009447, -4.039, 0.3955, 0.187],
+ [8, 0.017090, -3.497, 0.4048, 0.2286],
+ [10, 0.029900, -2.978, 0.4107, 0.2520],
+ [12, 0.030390, -3.102, 0.4393, 0.2912],
+ [15, 0.031600, -3.270, 0.4849, 0.3349],
+ [20, 0.033610, -3.500, 0.5536, 0.3923],
+ [35, 0.018550, -4.904, 0.6697, 0.5062],
+ ]
+ )
# Berechnung der Winkelkonstanten
@@ -175,7 +208,6 @@ def __angle_result(self):
# Berechnung von Abfluss
def cal_Q(self):
-
self.__angle_result()
Cd_alt = 0.1
@@ -184,7 +216,6 @@ def cal_Q(self):
n = 0
while n >= 0:
-
n = n + 1
Hu_alt = pow((1.5 * (self.Q / (Cd_alt * self.L * pow((2 * self.gravity), 0.5)))), (2 / 3))
@@ -198,7 +229,7 @@ def cal_Q(self):
# print('Number of iterations for Cd: ' + str(n))
# break
- if abs(Q_neu - self.Q) < 0.01:
+ if abs(Q_neu - self.Q) < 0.01: # !!! hier prüfen
break
Cd_alt = Cd_neu
@@ -210,9 +241,8 @@ def cal_Q(self):
# Rückstaueinfluss
def cal_hd(self):
-
self.hd = (self.UW - self.Sh) - self.P
- self.vd = self.Q / (self.W * (self.hd + self.P))
+ self.vd = self.Q / (self.W * (self.hd + self.P)) if self.hd > 0 else 0.0
self.Hd = self.hd + ((self.vd * self.vd) / (2 * self.gravity))
self.rs = "Kein Rückstaueinfluss!"
@@ -227,7 +257,6 @@ def cal_hd(self):
# Berechnung des Oberwasserspiegels bei Rückstaueinflusses
def cal_ruckstauH(self):
-
if self.Hu != 0:
R = self.Hd / self.Hu
else:
@@ -246,7 +275,6 @@ def cal_ruckstauH(self):
# Berechnung der Geschwindigkeit
def cal_v(self):
-
v_alt = 0.1
m = 0
@@ -264,11 +292,9 @@ def cal_v(self):
return self.v
def cal_hu(self):
-
self.hu = self.Hu - pow(self.v, 2) / (2 * self.gravity)
def cal_yu(self):
-
self.yu = self.Sh + self.P + self.hu
# Druckergebnisse
@@ -276,24 +302,30 @@ def print_results(self):
if self.verbose:
self.show_errors = True
self.check_for_error()
- print('Key Laenge = %2.2f [m]' % self.B, '\n'
- 'Key Frontwand =', self.D, '[m]\n'
- 'Key Winkel =', self.alpha, '[°]\n'
- 'Key Wandstaerke =',
- self.t, '[m]\n'
- 'Key Hoehe = %2.2f' % self.P, '[m]\n'
- 'Key Anzahl =', self.N, '\n'
- 'Key Weite = %2.2f [m]' % self.w, '\n'
- 'Keys Weite = %2.2f [m]' % (
- self.N * self.w), '\n'
- 'Seite Weite[S] = %2.2f [m]' % self.S, '\n'
- 'Wehr Weite = %2.2f [m]' % self.W,
- '\n'
- 'L/W = %2.2f [m]' % (self.L / self.W), '\n'
- 'Hu = %2.2f [m]' % self.Hu, '\n',
- 'hu = %2.2f [m]' % self.hu, '\n',
- self.rs, '\n',
- self.ce if self.ce != 0 else '')
+ print(
+ "Key Laenge = %2.2f [m]" % self.B,
+ "\nKey Frontwand =",
+ self.D,
+ "[m]\nKey Winkel =",
+ self.alpha,
+ "[°]\nKey Wandstaerke =",
+ self.t,
+ "[m]\nKey Hoehe = %2.2f" % self.P,
+ "[m]\nKey Anzahl =",
+ self.N,
+ "\nKey Weite = %2.2f [m]" % self.w,
+ "\nKeys Weite = %2.2f [m]" % (self.N * self.w),
+ "\nSeite Weite[S] = %2.2f [m]" % self.S,
+ "\nWehr Weite = %2.2f [m]" % self.W,
+ "\nL/W = %2.2f [m]" % (self.L / self.W),
+ "\nHu = %2.2f [m]" % self.Hu,
+ "\n",
+ "hu = %2.2f [m]" % self.hu,
+ "\n",
+ self.rs,
+ "\n",
+ self.ce if self.ce != 0 else "",
+ )
# Grenzen der Variablen
def check_for_error(self):
@@ -308,14 +340,13 @@ def check_for_error(self):
# Plotten Labyrinth-Wehr
def plot_geometry(self):
-
# plt.close()
+ self._last_geometry_svg_bytes = None
fig = plt.figure()
ax = fig.add_subplot(1, 1, 1)
if self.N > 0:
-
a = self.D / 2 # Konstanten
b = self.B * (math.tan(math.radians(self.alpha))) # Konstanten
@@ -340,9 +371,12 @@ def plot_geometry(self):
Labyrinth_wehr = np.insert(Labyrinth_wehr, 0, np.array((0, 0)), 0) # Hinzufügung von Origin koordinatoren
- Labyrinth_wehr = np.insert(Labyrinth_wehr, len(Labyrinth_wehr),
- np.array((Labyrinth_wehr[-1, 0] + self.S / 2, 0)),
- 0) # Hinzufügen der letzten Koordinate
+ Labyrinth_wehr = np.insert(
+ Labyrinth_wehr,
+ len(Labyrinth_wehr),
+ np.array((Labyrinth_wehr[-1, 0] + self.S / 2, 0)),
+ 0,
+ ) # Hinzufügen der letzten Koordinate
# Plotting
@@ -352,40 +386,82 @@ def plot_geometry(self):
# Plot Angle
# a = Arc((Labyrinth_wehr[1,0],Labyrinth_wehr[1,1]),1,1,0,0,45,color='red',lw=1)
- a = Arc((Labyrinth_wehr[1, 0], Labyrinth_wehr[1, 1]), 1, 1, angle=0, theta1=0, theta2=45, color='red', lw=1)
+ a = Arc(
+ (Labyrinth_wehr[1, 0], Labyrinth_wehr[1, 1]),
+ 1,
+ 1,
+ angle=0,
+ theta1=0,
+ theta2=45,
+ color="red",
+ lw=1,
+ )
ax.add_patch(a)
# Annotation text
- ax.text(0.5 * (Labyrinth_wehr[3, 0] + Labyrinth_wehr[4, 0]), self.B + 0.2, 'D', size=14)
- ax.text(0.5 * Labyrinth_wehr[-1, 0], -0.1, 'W', size=14)
- ax.text(0, 0.5 * self.B, 'B', size=14)
+ ax.text(0.5 * (Labyrinth_wehr[3, 0] + Labyrinth_wehr[4, 0]), self.B + 0.2, "D", size=14)
+ ax.text(0.5 * Labyrinth_wehr[-1, 0], -0.1, "W", size=14)
+ ax.text(0, 0.5 * self.B, "B", size=14)
ax.text(Labyrinth_wehr[1, 0] + 0.5, Labyrinth_wehr[1, 1] + 0.2, r"$\alpha$", size=14)
# Annotation arrows
- ax.annotate('', (Labyrinth_wehr[4, 0] + 0.1, self.B + 0.1), (Labyrinth_wehr[3, 0] - 0.1, self.B + 0.1),
- arrowprops=dict(arrowstyle='<->', color='black'))
- ax.annotate('', (Labyrinth_wehr[-1, 0], -0.25), (Labyrinth_wehr[0, 0], -0.25),
- arrowprops=dict(arrowstyle='<->', color='black'))
- ax.annotate('', (0, self.B), (Labyrinth_wehr[0, 0], 0), arrowprops=dict(arrowstyle='<->', color='black'))
-
- plt.show()
+ ax.annotate(
+ "",
+ (Labyrinth_wehr[4, 0] + 0.1, self.B + 0.1),
+ (Labyrinth_wehr[3, 0] - 0.1, self.B + 0.1),
+ arrowprops=dict(arrowstyle="<->", color="black"),
+ )
+ ax.annotate(
+ "",
+ (Labyrinth_wehr[-1, 0], -0.25),
+ (Labyrinth_wehr[0, 0], -0.25),
+ arrowprops=dict(arrowstyle="<->", color="black"),
+ )
+ ax.annotate(
+ "",
+ (0, self.B),
+ (Labyrinth_wehr[0, 0], 0),
+ arrowprops=dict(arrowstyle="<->", color="black"),
+ )
+
+ if os.environ.get("SERVER_MODE") != "1":
+ plt.show()
# plt.savefig('result-'+str(self.B)+' '+str(self.alpha)+'.jpg')
# fig,ax = plt.subplots(ncols=2)
else:
- print('Plotten des Labyrinths ist mit', self.N, 'keys nicht möglich')
-
- if self.path:
- plt.savefig(self.path + '\\Labyrinth-Wehr_plot.svg')
- plt.savefig(self.path + '\\Labyrinth-Wehr_plot.pdf')
- else:
- plt.savefig('Labyrinth-Wehr_plot.svg')
- plt.savefig('Labyrinth-Wehr_plot.pdf')
+ print("Plotten des Labyrinths ist mit", self.N, "keys nicht möglich")
+
+ try:
+ svg_buf = io.BytesIO()
+ plt.savefig(svg_buf, format="svg")
+ self._last_geometry_svg_bytes = svg_buf.getvalue()
+ except Exception:
+ self._last_geometry_svg_bytes = None
+
+ if os.environ.get("SERVER_MODE") != "1":
+ if self.path:
+ plt.savefig(self.path + "\\Labyrinth-Wehr_plot.svg")
+ plt.savefig(self.path + "\\Labyrinth-Wehr_plot.pdf")
+ else:
+ plt.savefig("Labyrinth-Wehr_plot.svg")
+ plt.savefig("Labyrinth-Wehr_plot.pdf")
# Berechnung einer hydraulisch optimalen Geometrie aus den baulichen Randbedingungen
-def optimize_labyrinth_geometry(labyrinth, sohleHoehe, UW, Q, labyrinthBreite, labyrinthHoehe, labyrinthLaengeMax, path,
- show_results=False, show_plot=False):
+def optimize_labyrinth_geometry( # TODO
+ labyrinth,
+ sohleHoehe,
+ UW,
+ Q,
+ labyrinthBreite,
+ labyrinthHoehe,
+ labyrinthLaengeMax,
+ D,
+ path,
+ show_results=False,
+ show_plot=False,
+):
B_vector = np.arange(1, labyrinthLaengeMax + 0.1, 0.1)
Angle_vector = np.arange(6, 36, 1)
@@ -402,10 +478,8 @@ def optimize_labyrinth_geometry(labyrinth, sohleHoehe, UW, Q, labyrinthBreite, l
v_result = np.empty([np.size(B_vector), np.size(Angle_vector)])
for i, B in enumerate(B_vector):
-
for j, alpha in enumerate(Angle_vector): # float werte --- Table
-
- Lab = labyrinth(sohleHoehe, UW, Q, labyrinthBreite, labyrinthHoehe, B, alpha)
+ Lab = labyrinth(sohleHoehe, UW, Q, labyrinthBreite, labyrinthHoehe, B, alpha, D)
Lab.update()
# Lab.plot_geometry()
w_result[i, j] = Lab.w
@@ -425,59 +499,84 @@ def optimize_labyrinth_geometry(labyrinth, sohleHoehe, UW, Q, labyrinthBreite, l
i = i[0]
j = j[0]
- Cd_best = Cd_result[i, j]
Hu_best = Hu_result[i, j]
- hd_best = hd_result[i, j]
- v_best = v_result[i, j]
Angle_best = Angle_vector[j]
B_best = B_vector[i]
w_best = w_result[i, j]
- l_best = l_result[i, j]
N_best = N_result[i, j]
S_best = S_result[i, j]
L_best = L_result[i, j]
- bestLab = labyrinth(sohleHoehe, UW, Q, labyrinthBreite, labyrinthHoehe, B_best, Angle_best, path)
+ bestLab = labyrinth(sohleHoehe, UW, Q, labyrinthBreite, labyrinthHoehe, B_best, Angle_best, D, path)
if show_results:
- print('Optimale Geometrie des Wehre ist:', '\n',
- 'Labyrinth Laenge = %2.2f [m]' % B_best, '\n',
- 'Key Frontwand =', Lab.D, '[m]', '\n',
- 'Key Winkel =', Angle_best, '[°]', '\n',
- 'Key Wandstaerke =', Lab.t, ' [m]', '\n',
- 'Labyrinth Hoehe = %2.2f [m]' % Lab.P, '\n',
- 'Keys Anzahl = %2.0f [m]' % N_best, '\n',
- 'Key Breite = %2.2f [m]' % w_best, '\n',
- 'Keys Breite = %2.2f [m]' % (N_best * w_best), '\n',
- 'Seite Breite[S] = %2.2f [m]' % S_best, '\n',
- 'Labyrinth Breite = %2.2f [m]' % labyrinthBreite, '\n',
- 'L/W = %2.2f [m]' % (L_best / labyrinthBreite), '\n',
- 'Hu_min = %2.2f [m]' % Hu_best, '\n',
- )
+ print(
+ "Optimale Geometrie des Wehre ist:",
+ "\n",
+ "Labyrinth Laenge = %2.2f [m]" % B_best,
+ "\n",
+ "Key Frontwand =",
+ Lab.D,
+ "[m]",
+ "\n",
+ "Key Winkel =",
+ Angle_best,
+ "[°]",
+ "\n",
+ "Key Wandstaerke =",
+ Lab.t,
+ " [m]",
+ "\n",
+ "Labyrinth Hoehe = %2.2f [m]" % Lab.P,
+ "\n",
+ "Keys Anzahl = %2.0f [m]" % N_best,
+ "\n",
+ "Key Breite = %2.2f [m]" % w_best,
+ "\n",
+ "Keys Breite = %2.2f [m]" % (N_best * w_best),
+ "\n",
+ "Seite Breite[S] = %2.2f [m]" % S_best,
+ "\n",
+ "Labyrinth Breite = %2.2f [m]" % labyrinthBreite,
+ "\n",
+ "L/W = %2.2f [m]" % (L_best / labyrinthBreite),
+ "\n",
+ "Hu_min = %2.2f [m]" % Hu_best,
+ "\n",
+ )
if show_plot:
plt.figure()
alphai, Bi = np.meshgrid(Angle_vector, B_vector)
- plt.pcolormesh(alphai, Bi, Hu_result, cmap='rainbow') # imshow,pcolor options
- plt.xlabel('alpha [°]')
- plt.ylabel('B [m]')
+ plt.pcolormesh(alphai, Bi, Hu_result, cmap="rainbow") # imshow,pcolor options
+ plt.xlabel("alpha [°]")
+ plt.ylabel("B [m]")
plt.grid()
plt.colorbar()
plt.show()
- plt.title('Original')
+ plt.title("Original")
return bestLab
-class FlapGate():
-
- def __init__(self, bottom_level=None, downstream_water_level=None, discharge=None, flap_gate_width=None, flap_gate_height=None, flap_gate_angle=None,
- show_errors=0, skip_zero_check=False): # instance attribute
+class FlapGate:
+ def __init__(
+ self,
+ bottom_level=None,
+ downstream_water_level=None,
+ discharge=None,
+ flap_gate_width=None,
+ flap_gate_height=None,
+ flap_gate_angle=None,
+ show_errors=0,
+ skip_zero_check=False,
+ ): # instance attribute
self.Sh = bottom_level # Sohlhöhe [m ü. NHN]
self.UW = downstream_water_level # Unterwasserstand [m ü. NHN]
self.Q = discharge # Abfluss [m3/sec]
self.KW = flap_gate_width # Breite des Klappe [m]
self.KP = flap_gate_height
+ self._Kalpha_intern = None
self.Kalpha = flap_gate_angle
self.g = 9.81 # g = Erdbeschleunigung [m2/sec]
self.show_errors = show_errors
@@ -511,13 +610,27 @@ def update(self):
self.FAA_FAbA()
# self.print_results()
+ @property
+ def Kalpha(self):
+ """Klappenneigung zur Horizontalen [°]: 0 = gelegt, 90 = aufgestellt."""
+ if self._Kalpha_intern is None:
+ return None
+ return 90 - self._Kalpha_intern
+
+ @Kalpha.setter
+ def Kalpha(self, value):
+ if value is None:
+ self._Kalpha_intern = None
+ else:
+ self._Kalpha_intern = 90 - value
+
def check_and_exit_on_input_errors(self):
def input_plausibilty(eingabe_name, eingabe_wert, max_value=None, min_value=None):
fehler = [] # Store error messages
if not self.skip_zero_check and eingabe_wert <= 0:
- if re.search(r'(Unterwasser|SohleHoehe)', eingabe_name):
- fehler.append(f"Achtung: {eingabe_name} Wert ist negative.")
+ if re.search(r"(Unterwasser|SohleHoehe)", eingabe_name):
+ fehler.append(f"Achtung: {eingabe_name} Wert ist negativ.")
else:
fehler.append(f"{eingabe_name} Wert ist nicht plausibel (sollte größer als 0 sein).")
@@ -536,98 +649,102 @@ def input_plausibilty(eingabe_name, eingabe_wert, max_value=None, min_value=None
fehler += input_plausibilty("Abfluss", self.Q)
fehler += input_plausibilty("Klappe Breite", self.KW)
fehler += input_plausibilty("Klappe Hoehe", self.KP)
- fehler += input_plausibilty("Klappe Winkel", self.Kalpha)
+
+ if self.Kalpha is None or not (0 <= self.Kalpha <= 90):
+ fehler.append("Klappenwinkel β muss zwischen 0° und 90° liegen.")
if all(message.startswith("Achtung:") for message in fehler):
+ # Only warnings -> optionally print to console, but allow computation to continue
for i, fehler_message in enumerate(fehler, start=1):
- print(f"{i}. {fehler_message}")
+ print(f"[FlapGate] {i}. {fehler_message}")
fehler = None
else:
- for i, fehler_message in enumerate(fehler, start=1):
- print(f"{i}. {fehler_message}")
- sys.exit()
+ # Hard input errors -> raise exception without additional print
+ raise EngineerInputError(fehler)
def abflussbeiwert(self):
-
- self.mu_verhältnis = np.array([[-42.74, 0.9143],
- [-39.05, 0.92],
- [-35.07, 0.9271],
- [-31.1, 0.9343],
- [-27.21, 0.9434],
- [-23.15, 0.9496],
- [-19.17, 0.9571],
- [-15.2, 0.9659],
- [-11.22, 0.9743],
- [-7.24, 0.9831],
- [-3.274, 0.9926],
- [0.6412, 1.002],
- [2.418, 1.009],
- [5.761, 1.015],
- [9.555, 1.025],
- [13.5, 1.034],
- [16.96, 1.046],
- [20.76, 1.055],
- [24.37, 1.065],
- [27.98, 1.074],
- [31.78, 1.084],
- [35.75, 1.094],
- [39.73, 1.104],
- [43.82, 1.11],
- [47.68, 1.118],
- [51.65, 1.124],
- [55.63, 1.128],
- [59.6, 1.131],
- [63.58, 1.13],
- [67.55, 1.127],
- [71.35, 1.119],
- [74.42, 1.109],
- [76.72, 1.098],
- [80.02, 1.079],
- [81.29, 1.069],
- [82.37, 1.058],
- [83.27, 1.051],
- [84.18, 1.038],
- [84.9, 1.03],
- [85.8, 1.015]])
-
- self.mu_ratio = np.interp(self.Kalpha, self.mu_verhältnis[:, 0], self.mu_verhältnis[:, 1])
+ self.mu_verhältnis = np.array(
+ [
+ [-42.74, 0.9143],
+ [-39.05, 0.92],
+ [-35.07, 0.9271],
+ [-31.1, 0.9343],
+ [-27.21, 0.9434],
+ [-23.15, 0.9496],
+ [-19.17, 0.9571],
+ [-15.2, 0.9659],
+ [-11.22, 0.9743],
+ [-7.24, 0.9831],
+ [-3.274, 0.9926],
+ [0.6412, 1.002],
+ [2.418, 1.009],
+ [5.761, 1.015],
+ [9.555, 1.025],
+ [13.5, 1.034],
+ [16.96, 1.046],
+ [20.76, 1.055],
+ [24.37, 1.065],
+ [27.98, 1.074],
+ [31.78, 1.084],
+ [35.75, 1.094],
+ [39.73, 1.104],
+ [43.82, 1.11],
+ [47.68, 1.118],
+ [51.65, 1.124],
+ [55.63, 1.128],
+ [59.6, 1.131],
+ [63.58, 1.13],
+ [67.55, 1.127],
+ [71.35, 1.119],
+ [74.42, 1.109],
+ [76.72, 1.098],
+ [80.02, 1.079],
+ [81.29, 1.069],
+ [82.37, 1.058],
+ [83.27, 1.051],
+ [84.18, 1.038],
+ [84.9, 1.03],
+ [85.8, 1.015],
+ ]
+ )
+
+ self.mu_ratio = np.interp(self._Kalpha_intern, self.mu_verhältnis[:, 0], self.mu_verhältnis[:, 1])
def abminderung_faktor(self):
-
- self.Abminderung_fak = np.array([[0.0112, 0.9916],
- [0.0718, 0.9764],
- [0.1610, 0.9473],
- [0.2191, 0.9268],
- [0.2801, 0.9032],
- [0.3396, 0.8775],
- [0.3992, 0.8500],
- [0.4588, 0.8201],
- [0.5184, 0.7877],
- [0.5780, 0.7525],
- [0.6377, 0.7127],
- [0.6976, 0.6707],
- [0.7572, 0.6168],
- [0.8107, 0.5622],
- [0.9055, 0.4440],
- [0.9382, 0.4055],
- [1, 0.3]])
+ self.Abminderung_fak = np.array(
+ [
+ [0.0112, 0.9916],
+ [0.0718, 0.9764],
+ [0.1610, 0.9473],
+ [0.2191, 0.9268],
+ [0.2801, 0.9032],
+ [0.3396, 0.8775],
+ [0.3992, 0.8500],
+ [0.4588, 0.8201],
+ [0.5184, 0.7877],
+ [0.5780, 0.7525],
+ [0.6377, 0.7127],
+ [0.6976, 0.6707],
+ [0.7572, 0.6168],
+ [0.8107, 0.5622],
+ [0.9055, 0.4440],
+ [0.9382, 0.4055],
+ [1, 0.3],
+ ]
+ )
def cal_P_neu(self):
- self.P_neu = self.KP * (math.cos(math.radians(abs(self.Kalpha))))
+ self.P_neu = self.KP * (math.cos(math.radians(abs(self._Kalpha_intern))))
def cal_Q(self):
-
mu_alt = 0.1
n = 0
while n >= 0:
+ hu_alt = pow(self.Q / (2.953 * mu_alt * self.KW), 2 / 3) # Tech. Hydro mechanik 1 - Gleichung 9.2 - Zeite 403
- hu_alt = pow(self.Q / (2.953 * mu_alt * self.KW),
- 2 / 3) # Tech. Hydro mechanik 1 - Gleichung 9.2 - Zeite 403
-
- mu90_neu = 0.615 * (1 + (1 / (1000 * hu_alt + 1.6))) * (1 + (0.5 * pow(hu_alt / (hu_alt + self.P_neu),
- 2))) # Tech. Hydro mechanik 1 - Gleichung 9.16 - Zeite 415
+ mu90_neu = 0.615 * (1 + (1 / (1000 * hu_alt + 1.6))) * (1 + (0.5 * pow(hu_alt / (hu_alt + self.P_neu), 2))) # Tech. Hydro mechanik 1 - Gleichung 9.16 - Zeite 415
# =============================================================================
# Diese Formel gilt für
@@ -665,10 +782,9 @@ def f(h):
# print (self.hd,h[0],self.hd/h[0])
if h[0] < self.hd:
h[0] = self.hd + 0.01
- return (pow((1 - pow((self.hd / h), 1.15)), 0.37) * 2.953
- * 0.615 * (1 + (1 / (1000 * h + 1.6))) * (1 + (0.5 * pow(h / (h + self.P_neu), 2)))
- * self.mu_ratio
- * self.KW * pow(h, 1.5) - self.Q)
+ return (
+ pow((1 - pow((self.hd / h), 1.15)), 0.37) * 2.953 * 0.615 * (1 + (1 / (1000 * h + 1.6))) * (1 + (0.5 * pow(h / (h + self.P_neu), 2))) * self.mu_ratio * self.KW * pow(h, 1.5) - self.Q
+ )
# def f(h):
# return ((np.interp(self.hd/h0,self.Abminderung_fak[:,0],self.Abminderung_fak[:,1]))*2.953
@@ -678,11 +794,9 @@ def f(h):
h0 = self.hd + 0.1
- self.hu = fsolve(f, h0)[
- 0] # Der Rückgabetyp der Funktion fsolve ist array, hier wird array in float umgewandelt
+ self.hu = fsolve(f, h0)[0] # Der Rückgabetyp der Funktion fsolve ist array, hier wird array in float umgewandelt
def cal_v(self):
-
if self.hu == 0:
self.v = np.nan
@@ -690,18 +804,15 @@ def cal_v(self):
self.v = self.Q / (self.KW * self.hu)
def cal_yu(self):
-
self.yu = self.Sh + self.P_neu + self.hu
def cal_vd(self):
-
- self.vd = self.Q / (self.KW * (self.UW - self.Sh))
+ self.vd = self.Q / (self.KW * (self.UW - self.Sh)) if self.hd > 0 else 0.0
def FAA_FAbA(self):
-
self.h_gr = pow((pow(self.Q / self.KW, 2)) / self.g, 0.33)
self.v_gr = pow((self.g * self.h_gr), 0.5)
- self.beschleunigung = (self.v_gr - self.v) / (self.KP * (math.sin(math.radians(abs(self.Kalpha)))))
+ self.beschleunigung = (self.v_gr - self.v) / (self.KP * (math.sin(math.radians(abs(self._Kalpha_intern)))))
# Grenzen der Variablen
def check_for_error(self):
@@ -717,26 +828,38 @@ def check_for_error(self):
def print_results(self):
self.show_errors = True
self.check_for_error()
- print('Hoehe =', self.KP, '[m]\n',
- 'Breite =', self.KW, '[m]\n',
- 'Winkel zur Vertikalen = %2.2f [°]' % self.Kalpha, '\n',
- 'mu_ratio = %2.2f' % self.mu_ratio, '\n',
- 'mu = %2.2f' % self.mu, '\n',
- 'Oberwasserstand über OK = %2.2f [m]' % self.hu, '\n',
- 'Oberwasserstand = %2.2f [m ü. NHN]' % self.yu, '\n',
- 'Unterwasserstand über OK = %2.2f [m]' % self.hd, '\n',
- 'Unterwasserstand =%2.2f [m ü. NHN]' % self.UW, '\n')
+ print(
+ "Hoehe =",
+ self.KP,
+ "[m]\n",
+ "Breite =",
+ self.KW,
+ "[m]\n",
+ "Klappenneigung zur Sohle = %2.2f [°]" % self.Kalpha,
+ "\n",
+ "mu_ratio = %2.2f" % self.mu_ratio,
+ "\n",
+ "mu = %2.2f" % self.mu,
+ "\n",
+ "Oberwasserstand über OK = %2.2f [m]" % self.hu,
+ "\n",
+ "Oberwasserstand = %2.2f [m ü. NHN]" % self.yu,
+ "\n",
+ "Unterwasserstand über OK = %2.2f [m]" % self.hd,
+ "\n",
+ "Unterwasserstand =%2.2f [m ü. NHN]" % self.UW,
+ "\n",
+ )
def kopplung(Q, UW, Lab, Kla): # Funktion zur Optimierung der Entladung zwischen Labyrinth und Klappe
-
def check_and_exit_on_input_errors():
def input_plausibilty(eingabe_name, eingabe_wert, max_value=None, min_value=None):
fehler = [] # Store error messages
if eingabe_wert <= 0:
- if re.search(r'(Unterwasser|SohleHoehe)', eingabe_name):
- fehler.append(f"Achtung: {eingabe_name} Wert ist negative.")
+ if re.search(r"(Unterwasser|SohleHoehe)", eingabe_name):
+ fehler.append(f"Achtung: {eingabe_name} Wert ist negativ.")
else:
fehler.append(f"{eingabe_name} Wert ist nicht plausibel (sollte größer als 0 sein).")
@@ -755,7 +878,7 @@ def input_plausibilty(eingabe_name, eingabe_wert, max_value=None, min_value=None
fehler = check_and_exit_on_input_errors()
if fehler:
- print(fehler)
+ print(f"[kopplung] Eingabefehler: {fehler}")
return fehler
# check_and_exit_on_input_errors()
@@ -764,7 +887,6 @@ def input_plausibilty(eingabe_name, eingabe_wert, max_value=None, min_value=None
Kla.UW = UW
def teilung(Q, Lab, Kla):
-
def Objective_fn(i):
a = i[0]
Lab.Q = a * Q
@@ -854,14 +976,22 @@ def Objective_fn(i):
return Lab.Q, Kla.Q, Lab.yu, Kla.yu
-def UW_interpolation(Abfluss, Unterwasser, Q_con, interpolation, path='', show_plot=False, save_plot=False):
+def UW_interpolation(
+ Abfluss,
+ Unterwasser,
+ interpolation,
+ Q_con=None,
+ path="",
+ show_plot=False,
+ save_plot=False,
+):
def check_and_exit_on_input_errors():
def input_plausibilty(eingabe_name, eingabe_wert, max_value=None, min_value=None):
fehler = [] # Store error messages
if eingabe_wert <= 0:
- if re.search(r'(Unterwasser)', eingabe_name):
- fehler.append(f"Achtung: {eingabe_name} Wert ist negative.")
+ if re.search(r"(Unterwasser)", eingabe_name):
+ fehler.append(f"Achtung: {eingabe_name} Wert ist negativ.")
else:
fehler.append(f"{eingabe_name} Wert ist nicht plausibel (sollte größer als 0 sein).")
@@ -892,46 +1022,51 @@ def input_plausibilty(eingabe_name, eingabe_wert, max_value=None, min_value=None
fehler = check_and_exit_on_input_errors()
if all(message.startswith("Achtung:") for message in fehler):
+ # Only warnings -> optionally print to console
for i, fehler_message in enumerate(fehler, start=1):
- print(f"{i}. {fehler_message}")
+ print(f"[UW_interpolation] {i}. {fehler_message}")
fehler = None
else:
- for i, fehler_message in enumerate(fehler, start=1):
- print(f"{i}. {fehler_message}")
- return fehler
+ # Harte Eingabefehler -> Exception, kein zusätzlicher Print
+ raise EngineerInputError(fehler)
def perform_interpolation(interpolation, Abfluss, Unterwasser, Q_con):
- if interpolation == 'exponential':
+ if interpolation == "exponential":
+
def model_f(x, a, b, c):
return a * (np.exp(b * x)) + c
- popt, pcov = curve_fit(model_f, Abfluss, Unterwasser, p0=[0., 0.1, 0.1], maxfev=2000)
+ try:
+ popt, _ = curve_fit(model_f, Abfluss, Unterwasser, p0=[0.0, 0.1, 0.1], maxfev=2000)
+ except RuntimeError as exc:
+ raise EngineerInputError(
+ [
+ "Exponentielle Interpolation konnte nicht bestimmt werden.",
+ "Bitte andere Stützstellen prüfen oder eine andere Interpolationsmethode wählen.",
+ ]
+ ) from exc
a_opt, b_opt, c_opt = popt
UW1 = a_opt * (np.exp(b_opt * Abfluss)) + c_opt
UW2 = a_opt * (np.exp(b_opt * Q_con)) + c_opt
- # Calculate Mean Squared Error
- MSE = np.mean((Unterwasser - UW1) ** 2)
# Calculate R-squared
SSR = np.sum((Unterwasser - UW1) ** 2)
SST = np.sum((Unterwasser - np.mean(UW1)) ** 2)
R_squared = 1 - (SSR / SST)
- elif interpolation == 'linear':
+ elif interpolation == "linear":
coeffs = np.polyfit(Abfluss, Unterwasser, 1)
p = np.poly1d(coeffs)
UW1 = p(Abfluss)
UW2 = p(Q_con)
- # Calculate Mean Squared Error
- MSE = np.mean((Unterwasser - UW1) ** 2)
# Calculate R-squared
SSR = np.sum((Unterwasser - UW1) ** 2)
SST = np.sum((Unterwasser - np.mean(UW1)) ** 2)
R_squared = 1 - (SSR / SST)
- elif interpolation == 'quadratic':
+ elif interpolation == "quadratic":
coeffs = np.polyfit(Abfluss, Unterwasser, 2)
p = np.poly1d(coeffs)
@@ -945,14 +1080,12 @@ def model_f(x, a, b, c):
SST = np.sum((Unterwasser - np.mean(UW1)) ** 2)
R_squared = 1 - (SSR / SST)
- elif interpolation == 'cubic':
+ elif interpolation == "cubic":
coeffs = np.polyfit(Abfluss, Unterwasser, 3)
p = np.poly1d(coeffs)
UW1 = p(Abfluss)
UW2 = p(Q_con)
- # Calculate Mean Squared Error
- MSE = np.mean((Unterwasser - UW1) ** 2)
# Calculate R-squared
SSR = np.sum((Unterwasser - UW1) ** 2)
SST = np.sum((Unterwasser - np.mean(UW1)) ** 2)
@@ -964,42 +1097,52 @@ def plot_interpolation(interpolation, Q_con, UW, Abfluss, Unterwasser, R_squared
# plt.close()
plt.figure()
plt.plot(Q_con, UW)
- plt.scatter(Abfluss, Unterwasser, color='red')
- plt.xlabel('Abfluss [m³/s]')
- plt.ylabel('UW [m ü. NHN]')
- plt.text(0.5, 0.9, f'R² ({interpolation}) = {R_squared:.3f}', transform=plt.gca().transAxes)
+ plt.scatter(Abfluss, Unterwasser, color="red")
+ plt.xlabel("Abfluss [m³/s]")
+ plt.ylabel("UW [m ü. NHN]")
+ plt.text(0.5, 0.9, f"R² ({interpolation}) = {R_squared:.3f}", transform=plt.gca().transAxes)
if save_plot:
- plt.savefig(path + 'Q_UW_interpolation_{}.svg'.format(interpolation))
- plt.savefig(path + 'Q_UW_interpolation_{}.pdf'.format(interpolation))
+ plt.savefig(path + f"Q_UW_interpolation_{interpolation}.svg")
+ plt.savefig(path + f"Q_UW_interpolation_{interpolation}.pdf")
if show_plot:
plt.show()
- # Q_con = np.arange(0.1, np.max(Abfluss) + 0.5, 0.5)
- interpolation_types = ['exponential', 'linear', 'quadratic', 'cubic']
- plot_colors = ['black', 'green', 'brown', 'blue']
+ if Q_con is None:
+ Q_con = np.arange(0.1, np.max(Abfluss) + 0.5, 0.5)
+ interpolation_types = ["exponential", "linear", "quadratic", "cubic"]
+ plot_colors = ["black", "green", "brown", "blue"]
plt.ioff()
- if interpolation == 'all':
+ if interpolation == "all":
for i, interp_type in enumerate(interpolation_types):
UW, R_squared = perform_interpolation(interp_type, Abfluss, Unterwasser, Q_con)
plot_color = plot_colors[i % len(plot_colors)]
plt.plot(Q_con, UW, color=plot_color)
- plt.scatter(Abfluss, Unterwasser, color='red')
- plt.xlabel('Abfluss [m³/s]')
- plt.ylabel('UW [m ü. NHN]')
- plt.text(0.5, 0.95 - i * 0.05, f'R² = {R_squared:.3f}', color=plot_color, transform=plt.gca().transAxes)
- plt.text(0.1, 0.95 - i * 0.05, f'Interpolation: {interp_type}', color=plot_color,
- transform=plt.gca().transAxes)
+ plt.scatter(Abfluss, Unterwasser, color="red")
+ plt.xlabel("Abfluss [m³/s]")
+ plt.ylabel("UW [m ü. NHN]")
+ plt.text(
+ 0.5,
+ 0.95 - i * 0.05,
+ f"R² = {R_squared:.3f}",
+ color=plot_color,
+ transform=plt.gca().transAxes,
+ )
+ plt.text(
+ 0.1,
+ 0.95 - i * 0.05,
+ f"Interpolation: {interp_type}",
+ color=plot_color,
+ transform=plt.gca().transAxes,
+ )
if save_plot:
- plt.savefig('Q_UW_interpolation_all.svg')
+ plt.savefig("Q_UW_interpolation_all.svg")
if show_plot:
plt.show()
-
-
else:
UW, R_squared = perform_interpolation(interpolation, Abfluss, Unterwasser, Q_con)
plot_interpolation(interpolation, Q_con, UW, Abfluss, Unterwasser, R_squared)
@@ -1007,15 +1150,28 @@ def plot_interpolation(interpolation, Q_con, UW, Abfluss, Unterwasser, R_squared
return UW
-def operational_model(labyrinth_object, discharge_vector, downstream_water_level_vector, upstream_water_level_vector, interpolation_method, interpolation_stepsize=1, flap_gate_opject=None, design_upstream_water_level=None, max_flap_gate_angle=None,
- fish_body_height=None, show_plot=False, save_plot=False, path=""):
+def operational_model(
+ labyrinth_object,
+ discharge_vector,
+ downstream_water_level_vector,
+ interpolation_method,
+ interpolation_stepsize=1,
+ flap_gate_opject=None,
+ design_upstream_water_level=None,
+ max_flap_gate_angle=None,
+ fish_body_height=None,
+ 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):
fehler = [] # Store error messages
if eingabe_wert is not None and eingabe_wert <= 0:
- if re.search(r'(Unterwasser|Oberwasser)', eingabe_name):
- fehler.append(f"Achtung: {eingabe_name} Wert ist negative.")
+ if re.search(r"(Unterwasser|Oberwasser)", eingabe_name):
+ fehler.append(f"Achtung: {eingabe_name} Wert ist negativ.")
else:
fehler.append(f"{eingabe_name} Wert ist nicht plausibel (sollte größer als 0 sein).")
@@ -1029,6 +1185,12 @@ def input_plausibilty(eingabe_name, eingabe_wert, max_value=None, min_value=None
fehler = [] # Initialize the fehler list
+ # Ensure vectors are not empty
+ if len(discharge_vector) == 0:
+ fehler.append("discharge_vector must not be empty.")
+ if len(downstream_water_level_vector) == 0:
+ fehler.append("downstream_water_level_vector must not be empty.")
+
# Check Abfluss values
for i, abfluss_wert in enumerate(discharge_vector):
fehler_abfluss = input_plausibilty("Abfluss " + str(discharge_vector[i]), abfluss_wert)
@@ -1038,13 +1200,19 @@ def input_plausibilty(eingabe_name, eingabe_wert, max_value=None, min_value=None
fehler_unterwasser = input_plausibilty("Unterwasser " + str(downstream_water_level_vector[i]), unterwasser_wert)
fehler.extend(fehler_unterwasser)
- for i, oberwasser_wert in enumerate(upstream_water_level_vector):
- fehler_oberwasser = input_plausibilty("Oberwasser " + str(upstream_water_level_vector[i]), oberwasser_wert)
- fehler.extend(fehler_oberwasser)
+ # for i, oberwasser_wert in enumerate(upstream_water_level_vector):
+ # fehler_oberwasser = input_plausibilty("Oberwasser " + str(upstream_water_level_vector[i]), oberwasser_wert)
+ # fehler.extend(fehler_oberwasser)
+
+ # Ensure that all vectors have the same length (required for element-wise operations)
+ if not (len(discharge_vector) == len(downstream_water_level_vector)):
+ fehler.append("discharge_vector and downstream_water_level_vector must have the same length.")
# Check Stauziel, SohleHoehe, LabyrinthMaxBreite, LabyrinthMaxLaenge, and LabyrinthHoehe
fehler += input_plausibilty("Stauziel", design_upstream_water_level)
- fehler += input_plausibilty("Klappe Winkel max", max_flap_gate_angle)
+ 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"]
@@ -1056,26 +1224,32 @@ def input_plausibilty(eingabe_name, eingabe_wert, max_value=None, min_value=None
fehler = check_and_exit_on_input_errors()
if all(message.startswith("Achtung:") for message in fehler):
+ # Only warnings -> optionally print to console
for i, fehler_message in enumerate(fehler, start=1):
- print(f"{i}. {fehler_message}")
+ print(f"[operational_model] {i}. {fehler_message}")
fehler = None
else:
- for i, fehler_message in enumerate(fehler, start=1):
- print(f"{i}. {fehler_message}")
- return fehler
+ # Hard input errors -> raise exception without additional print
+ raise EngineerInputError(fehler)
def operational_model_without_flap():
-
- # Q_con = np.arange(0.1, np.max(discharge_vector) + 0.5, 0.5)
- Q_con = np.arange(50, np.max(discharge_vector) + interpolation_stepsize, interpolation_stepsize)
- UW_con = UW_interpolation(discharge_vector, downstream_water_level_vector, Q_con, interpolation_method, path=path, save_plot=True)
+ Q_con = np.arange(0.1, np.max(discharge_vector) + interpolation_stepsize, interpolation_stepsize)
+ # In server context we never want to open GUI windows; only save plots if explicitly requested.
+ UW_con = UW_interpolation(
+ discharge_vector,
+ downstream_water_level_vector,
+ interpolation_method,
+ Q_con=Q_con,
+ path=path,
+ show_plot=show_plot,
+ save_plot=save_plot,
+ )
Q_UW = np.stack((Q_con, UW_con), axis=1)
Lab_upstream = np.zeros(np.size(Q_con))
Lab_hu = np.zeros(np.size(Q_con))
for i, (Q, UW) in enumerate(zip(Q_UW[:, 0], Q_UW[:, 1])):
- # print(Q)
labyrinth_object.Q = Q
labyrinth_object.UW = UW
labyrinth_object.update()
@@ -1085,48 +1259,53 @@ def operational_model_without_flap():
def print_results():
fig, ax = plt.subplots(3, sharex=True)
- ax[0].plot(Q_UW[:, 0], Q_UW[:, 1], color='r')
- ax[0].set_ylabel('UW [m ü. NHN]')
+ ax[0].plot(Q_UW[:, 0], Q_UW[:, 1], color="r")
+ ax[0].set_ylabel("UW [m ü. NHN]")
ax[0].scatter(discharge_vector, downstream_water_level_vector)
- ax[1].plot(Q_UW[:, 0], Lab_upstream, label='Mit labyrinth')
- ax[1].scatter(discharge_vector, upstream_water_level_vector, label='Ohne Labyrinth')
- ax[1].set_ylabel('OW [m ü. NHN]')
+ ax[1].plot(Q_UW[:, 0], Lab_upstream, label="Mit labyrinth")
+ # ax[1].scatter(discharge_vector, upstream_water_level_vector, label="Ohne Labyrinth")
+ ax[1].set_ylabel("OW [m ü. NHN]")
ax[1].legend()
ax[2].plot(Q_UW[:, 0], Lab_hu)
- ax[2].set_ylabel('Oberfallhöhe [m]')
- ax[2].set_xlabel('Abfluss [m³/s]')
+ ax[2].set_ylabel("Oberfallhöhe [m]")
+ ax[2].set_xlabel("Abfluss [m³/s]")
if show_plot:
fig.show()
if save_plot:
if path:
- fig.savefig(path + '\\result.svg')
- fig.savefig(path + '\\result.pdf')
- fig.savefig(path + '\\result.png')
+ fig.savefig(path + "\\result.svg")
+ fig.savefig(path + "\\result.pdf")
+ fig.savefig(path + "\\result.png")
else:
- fig.savefig('result.svg')
- fig.savefig('result.pdf')
- fig.savefig('result.png')
+ fig.savefig("result.svg")
+ fig.savefig("result.pdf")
+ fig.savefig("result.png")
def save_results():
# save results of all discharge values
results_arr = np.stack((Q_con, UW_con, Lab_upstream, Lab_hu), axis=1)
results_df = pd.DataFrame(results_arr, index=range(1, len(results_arr) + 1))
- results_col = ['Abfluss', 'UW', 'OW', 'Oberfallhöhe']
+ results_col = ["Abfluss", "UW", "OW", "Oberfallhöhe"]
results_df.columns = results_col
results_df = results_df.round(2)
- if path:
- results_df.to_csv(path + '\\results.csv', sep=';', float_format='%.2f', header=results_col)
- else:
- results_df.to_csv('results.csv', sep=';', float_format='%.2f', header=results_col)
+ # Skip CSV export in server mode
+ if os.environ.get("SERVER_MODE") != "1":
+ if path:
+ results_df.to_csv(path + "\\results.csv", sep=";", float_format="%.2f", header=results_col)
+ else:
+ results_df.to_csv("results.csv", sep=";", float_format="%.2f", header=results_col)
+
+ results_df["hu_labyrinth"] = results_df["Oberfallhöhe"]
+ results_df["hu_klappe"] = None
- '''save the results for specific discahrge events'''
+ """save the results for specific discahrge events"""
# Get the first column (Abfluss values)
abfluss_values = results_arr[:, 0]
@@ -1138,16 +1317,29 @@ def save_results():
results_events[:, i] = f(discharge_vector)
results_events_df = pd.DataFrame(results_events, index=range(1, len(results_events) + 1))
- results_events_col = ['Abfluss', 'UW', 'OW', 'Oberfallhöhe']
+ results_events_col = ["Abfluss", "UW", "OW", "Oberfallhöhe"]
results_events_df.columns = results_events_col
results_events_df = results_events_df.round(2)
- if path:
- results_events_df.to_csv(path + '\\results_events.csv', sep=';', float_format='%.2f',
- header=results_events_col)
- else:
- results_events_df.to_csv('results_events.csv', sep=';', float_format='%.2f',
- header=results_events_col)
+ # Skip CSV export in server mode
+ if os.environ.get("SERVER_MODE") != "1":
+ if path:
+ results_events_df.to_csv(
+ path + "\\results_events.csv",
+ sep=";",
+ float_format="%.2f",
+ header=results_events_col,
+ )
+ else:
+ results_events_df.to_csv(
+ "results_events.csv",
+ sep=";",
+ float_format="%.2f",
+ header=results_events_col,
+ )
+
+ results_events_df["hu_labyrinth"] = results_events_df["Oberfallhöhe"]
+ results_events_df["hu_klappe"] = None
return results_df, results_events_df
@@ -1157,10 +1349,9 @@ def save_results():
return results, results_events
def operational_model_with_flap():
- # Q_con = np.arange(0.1, np.max(discharge_vector) + 0.5, 0.5)
- Q_con = np.arange(50, np.max(discharge_vector) + interpolation_stepsize, interpolation_stepsize)
+ Q_con = np.arange(0.1, np.max(discharge_vector) + interpolation_stepsize, interpolation_stepsize)
SZ = design_upstream_water_level
- Klawinkel_Max = max_flap_gate_angle
+ # Klawinkel_Max = max_flap_gate_angle # Legacy, ersetzt durch Kalpha_max
Klappe_al = np.zeros(np.size(Q_con))
Abfluss_R = np.zeros(np.size(Q_con))
@@ -1170,17 +1361,25 @@ def operational_model_with_flap():
Kla_upstream = np.zeros(np.size(Q_con))
Kla_hu = np.zeros(np.size(Q_con))
+ Lab_hu = np.zeros(np.size(Q_con))
Kla_vd = np.zeros(np.size(Q_con))
P_new = np.zeros(np.size(Q_con))
- flap_gate_opject.Kalpha = 0
-
- UW_con = UW_interpolation(discharge_vector, downstream_water_level_vector, Q_con, interpolation_method, path=path, save_plot=True)
+ flap_gate_opject.Kalpha = max_flap_gate_angle
+
+ UW_con = UW_interpolation(
+ discharge_vector,
+ downstream_water_level_vector,
+ interpolation_method,
+ Q_con=Q_con,
+ path=path,
+ show_plot=show_plot,
+ save_plot=save_plot,
+ )
Q_UW = np.stack((Q_con, UW_con), axis=1)
for i, (Q, UW) in enumerate(zip(Q_UW[:, 0], Q_UW[:, 1])):
- # print(Q)
# =============================================================================
# alpha = np.arange(Kla.Kalpha, KlappeWinkel_max+0.2,0.2)
#
@@ -1204,11 +1403,12 @@ def Objective_fn(Kalpha):
return abs(flap_gate_opject.yu - SZ)
# initial values
- Kalpha0 = Klappe_al[i - 1] if i > 0 else [10]
- Kalpha_min = Klappe_al[i - 1] if i > 0 else 0
+ Kalpha0 = Klappe_al[i - 1] if i > 0 else max_flap_gate_angle
+ Kalpha_max = Klappe_al[i - 1] if i > 0 else max_flap_gate_angle
+ Kalpha_min = 0
# minmize function
- result = minimize_scalar(Objective_fn, Kalpha0, bounds=(Kalpha_min, Klawinkel_Max), method='bounded')
+ result = minimize_scalar(Objective_fn, Kalpha0, bounds=(Kalpha_min, Kalpha_max), method="bounded")
Klappe_al[i] = result.x
# print(result.x)
@@ -1219,6 +1419,7 @@ def Objective_fn(Kalpha):
Kla_vd[i] = flap_gate_opject.vd
Lab_upstream[i] = labyrinth_object.yu
+ Lab_hu[i] = labyrinth_object.hu
Abfluss_R[i] = labyrinth_object.Q / flap_gate_opject.Q
Lab_Q[i] = labyrinth_object.Q
Kla_Q[i] = flap_gate_opject.Q
@@ -1227,27 +1428,25 @@ def Objective_fn(Kalpha):
# return Q_con, Lab_Q, Kla_Q, Klappe_al, y_upstream
def print_results():
-
fig, ax = plt.subplots(4, sharex=True)
- ax[0].plot(Q_UW[:, 0], Q_UW[:, 1], color='r')
- ax[0].set_ylabel('UW [m ü. NHN]')
+ ax[0].plot(Q_UW[:, 0], Q_UW[:, 1], color="r")
+ ax[0].set_ylabel("UW [m ü. NHN]")
ax[0].scatter(discharge_vector, downstream_water_level_vector)
- ax[1].plot(Q_UW[:, 0], Lab_Q, label='Labyrinth')
- ax[1].set_ylabel('Q Labyrinth [m³/s]')
- ax[1].plot(Q_UW[:, 0], Kla_Q, label='Klappe')
+ ax[1].plot(Q_UW[:, 0], Lab_Q, label="Labyrinth")
+ ax[1].set_ylabel("Q Labyrinth [m³/s]")
+ ax[1].plot(Q_UW[:, 0], Kla_Q, label="Klappe")
ax[1].legend()
- ax[1].set_ylabel('Q [m³/s]')
+ ax[1].set_ylabel("Q [m³/s]")
- ax[2].plot(Q_UW[:, 0], Lab_upstream, marker='+', label='Labyrinth')
- ax[2].plot(Q_UW[:, 0], Kla_upstream, label='Klappe', color='c')
- ax[2].scatter(discharge_vector, upstream_water_level_vector, label='Ist')
+ ax[2].plot(Q_UW[:, 0], Lab_upstream, marker="+", label="Labyrinth")
+ ax[2].plot(Q_UW[:, 0], Kla_upstream, label="Klappe", color="c")
ax[2].legend()
- ax[2].set_ylabel('OW [m ü. NHN]')
+ ax[2].set_ylabel("OW [m ü. NHN]")
- ax[3].plot(Q_UW[:, 0], Klappe_al, color='b')
- ax[3].set_ylabel(r'$\alpha$ [°]')
+ ax[3].plot(Q_UW[:, 0], Klappe_al, color="b")
+ ax[3].set_ylabel(r"$\beta$ [°]")
# ax[4].plot(Q_UW[:,0],Kla_hu)
# ax[4].set_ylabel('$h_{u,klappe}$[{\small m}]')
@@ -1255,7 +1454,7 @@ def print_results():
# ax[5].plot(Q_UW[:,0],Kla_vd)
# ax[5].set_ylabel('$v_{d,klappe}$[{\small m²/s}]')
- # ax[5].set_xlabel('Abfluss [m³/s]')
+ # ax[5].set_xlabel('Abfluss [m³/s]')
# 3 * H fish text
# ax[4].text(np.max(Abfluss)/2, 3*H_fische, '3 $\cdot$ $H_{Fisch}$',color='red')
@@ -1264,8 +1463,7 @@ def print_results():
kla_upstream = np.round(Kla_upstream, 3)
constant_range = np.nonzero(kla_upstream <= design_upstream_water_level)
const_range = constant_range[0]
- const_range_start = const_range[0]
- const_range_end = const_range[-1]
+ const_range_end = const_range[-1] if const_range.size > 0 else None
# ax[2].annotate('', xy=(Q_UW[const_range_start, 0],Kla_upstream[0]+0.05), xytext=(Q_UW[const_range_end, 0],Kla_upstream[0]+0.05),
# xycoords='data', textcoords='data',arrowprops={'arrowstyle': '|-|'})
@@ -1273,38 +1471,45 @@ def print_results():
# ax[2].annotate('Stauziel', xy=((Q_UW[const_range_start, 0] + Q_UW[const_range_end, 0])/2,Kla_upstream[0]+0.1), ha='center', va='center')
# Schwarz line
- ax[1].axvline(x=Q_UW[const_range_end, 0], color='k', linestyle='--')
+ if const_range_end is not None:
+ ax[1].axvline(x=Q_UW[const_range_end, 0], color="k", linestyle="--")
+ else:
+ print("[Operational] Achtung: Stauziel im berechneten Bereich nicht erreicht.")
if show_plot:
fig.show()
if save_plot:
if path:
- fig.savefig(path + '\\result.svg')
- fig.savefig(path + '\\result.pdf')
- fig.savefig(path + '\\result.png')
+ fig.savefig(path + "\\result.svg")
+ fig.savefig(path + "\\result.pdf")
+ fig.savefig(path + "\\result.png")
else:
- fig.savefig('result.svg')
- fig.savefig('result.pdf')
- fig.savefig('result.png')
+ fig.savefig("result.svg")
+ fig.savefig("result.pdf")
+ fig.savefig("result.png")
def save_results():
-
# save results of all discharge values
results_arr = np.stack((Q_con, UW_con, Kla_upstream, Lab_Q, Kla_Q, Klappe_al), axis=1)
results_df = pd.DataFrame(results_arr, index=range(1, len(results_arr) + 1))
- results_col = ['Abfluss', 'UW', 'OW', 'Labyrinth Q', 'Klappe Q', 'Klappe winkel']
+ results_col = ["Abfluss", "UW", "OW", "Labyrinth Q", "Klappe Q", "Klappe winkel"]
results_df.columns = results_col
results_df = results_df.round(2)
- if path:
- results_df.to_csv(path + '\\results.csv', sep=';', float_format='%.2f', header=results_col)
- else:
- results_df.to_csv('results.csv', sep=';', float_format='%.2f', header=results_col)
+ # Skip CSV export in server mode
+ if os.environ.get("SERVER_MODE") != "1":
+ if path:
+ results_df.to_csv(path + "\\results.csv", sep=";", float_format="%.2f", header=results_col)
+ else:
+ results_df.to_csv("results.csv", sep=";", float_format="%.2f", header=results_col)
+
+ results_df["hu_labyrinth"] = Lab_hu
+ results_df["hu_klappe"] = Kla_hu
- '''save the results for specific discahrge events'''
+ """save the results for specific discahrge events"""
# Get the first column (Abfluss values)
abfluss_values = results_arr[:, 0]
@@ -1316,16 +1521,31 @@ def save_results():
results_events[:, i] = f(discharge_vector)
results_events_df = pd.DataFrame(results_events, index=range(1, len(results_events) + 1))
- results_events_col = ['Abfluss', 'UW', 'OW', 'Labyrinth Q', 'Klappe Q', 'Klappe winkel']
+ results_events_col = ["Abfluss", "UW", "OW", "Labyrinth Q", "Klappe Q", "Klappe winkel"]
results_events_df.columns = results_events_col
results_events_df = results_events_df.round(2)
+ results_events_lab_hu = interp1d(abfluss_values, Lab_hu)(discharge_vector)
+ results_events_kla_hu = interp1d(abfluss_values, Kla_hu)(discharge_vector)
- if path:
- results_events_df.to_csv(path + '\\results_events.csv', sep=';', float_format='%.2f',
- header=results_events_col)
- else:
- results_events_df.to_csv('results_events.csv', sep=';', float_format='%.2f',
- header=results_events_col)
+ # Skip CSV export in server mode
+ if os.environ.get("SERVER_MODE") != "1":
+ if path:
+ results_events_df.to_csv(
+ path + "\\results_events.csv",
+ sep=";",
+ float_format="%.2f",
+ header=results_events_col,
+ )
+ else:
+ results_events_df.to_csv(
+ "results_events.csv",
+ sep=";",
+ float_format="%.2f",
+ header=results_events_col,
+ )
+
+ results_events_df["hu_labyrinth"] = results_events_lab_hu
+ results_events_df["hu_klappe"] = results_events_kla_hu
return results_df, results_events_df
@@ -1342,7 +1562,16 @@ def save_results():
return results, results_events
-def tosbecken(Lab, Abfluss, Unterwasser, sicherheitsfaktor=25, Lab_Q=None, Kla=None, Kla_Q=None, Klappe_al=None):
+def tosbecken(
+ Lab,
+ Abfluss,
+ Unterwasser,
+ sicherheitsfaktor=25,
+ Lab_Q=None,
+ Kla=None,
+ Kla_Q=None,
+ Klappe_al=None,
+):
# plt.close()
Q_UW = np.stack((Abfluss, Unterwasser), axis=1)
@@ -1368,8 +1597,7 @@ def tosbecken(Lab, Abfluss, Unterwasser, sicherheitsfaktor=25, Lab_Q=None, Kla=N
j = 0 # Use 0 for Lab and 1 for Kla
for model in [Lab, Kla]:
-
- if model == None:
+ if model is None:
break
if model == Lab:
@@ -1384,7 +1612,6 @@ def tosbecken(Lab, Abfluss, Unterwasser, sicherheitsfaktor=25, Lab_Q=None, Kla=N
lange_tosbecken_all_model = np.full(np.size(Abfluss), np.nan)
for i, (Q, UW) in enumerate(zip(Q_UW[:, 0], Q_UW[:, 1])):
-
if Q == 0: # Skip the loop iteration if Q is zero
continue
model.Q = Q
@@ -1423,8 +1650,7 @@ def f(y):
sicherheit_initial = 1 + sicherheitfaktor_initial / 100
- delta = (sicherheit_initial * y2) - (model.UW - model.Sh) + (pow(Q / model.W, 2) / (model.g * 2)) * (
- (1 / pow(y2, 2)) - (1 / pow(model.UW - model.Sh, 2)))
+ delta = (sicherheit_initial * y2) - (model.UW - model.Sh) + (pow(Q / model.W, 2) / (model.g * 2)) * ((1 / pow(y2, 2)) - (1 / pow(model.UW - model.Sh, 2)))
lange_tosbecken = 7 * (y2 - y1) # Smetana
@@ -1451,9 +1677,11 @@ def f(y):
delta_design = np.nanmax(delta_model)
lange_tosbecken_design = lange_tosbecken_model[delta_model_max_index]
else:
- delta_design = (sicherheit * y2_model[delta_model_max_index]) - (hd_model[delta_model_max_index]) + \
- ymax_3_model[delta_model_max_index] * ((1 / pow(y2_model[delta_model_max_index], 2)) - (
- 1 / pow(hd_model[delta_model_max_index], 2)))
+ delta_design = (
+ (sicherheit * y2_model[delta_model_max_index])
+ - (hd_model[delta_model_max_index])
+ + ymax_3_model[delta_model_max_index] * ((1 / pow(y2_model[delta_model_max_index], 2)) - (1 / pow(hd_model[delta_model_max_index], 2)))
+ )
lange_tosbecken_design = lange_tosbecken_model[delta_model_max_index]
return delta_design, lange_tosbecken_design
@@ -1469,15 +1697,13 @@ def check_fish_availability(fish_name):
fisch_lange, fisch_hohe, fisch_dicke = fish_arten_DWA[fish_name]
min_bypass_breite = fish_arten_Ebel[fish_name]
print(f"Die gewählte Fischart '{fish_name}' ist in der DWA und Ebel(2016) Quelle verfügbar.")
- print(
- f"Information von DWA: Fisch Länge = {fisch_lange}, Fisch Höhe = {fisch_hohe}, Fisch Dicke = {fisch_dicke}")
+ print(f"Information von DWA: Fisch Länge = {fisch_lange}, Fisch Höhe = {fisch_hohe}, Fisch Dicke = {fisch_dicke}")
print(f"Information von Ebel: Minimal Bypass Breite = {min_bypass_breite}")
elif fish_name in fish_arten_DWA:
fisch_lange, fisch_hohe, fisch_dicke = fish_arten_DWA[fish_name]
print(f"Die gewählte Fischart '{fish_name}' ist in der DWA Quelle verfügbar aber nicht in Ebel(2016).")
- print(
- f"Information von DWA: Fisch Länge = {fisch_lange}, Fisch Höhe = {fisch_hohe}, Fisch Dicke = {fisch_dicke}")
+ print(f"Information von DWA: Fisch Länge = {fisch_lange}, Fisch Höhe = {fisch_hohe}, Fisch Dicke = {fisch_dicke}")
elif fish_name in fish_arten_Ebel:
min_bypass_breite = fish_arten_Ebel[fish_name]
@@ -1515,7 +1741,7 @@ def check_fish_availability(fish_name):
"Schleie": (0.6, 0.16, 0.09),
"Stör": (3.0, 0.51, 0.36),
"Finte": (0.5, 0.10, 0.05),
- "Schnäpel": (0.4, 0.08, 0.04)
+ "Schnäpel": (0.4, 0.08, 0.04),
}
fish_arten_Ebel = {
@@ -1543,7 +1769,7 @@ def check_fish_availability(fish_name):
"Ukelei": 0.21,
"Wels": 0.58,
"Zährte": 0.31,
- "Zander": 0.39
+ "Zander": 0.39,
}
# Check if fish_name is in either fish_arten_DWA or fish_arten_Ebel
@@ -1587,7 +1813,7 @@ def check_fish_availability(fish_name):
3 * fisch_hohe if fisch_hohe is not None else None,
8,
delta_h_wasserpolster,
- Klappe_P
+ Klappe_P,
]
ergebniss = [
@@ -1600,26 +1826,26 @@ def check_fish_availability(fish_name):
results.iloc[:, 1] - Kla.Sh + 0.5,
results.iloc[:, 1] - Kla.Sh,
v_FAA,
- v_FAbA
+ v_FAbA,
]
# Labels for subplots
y_labels = [
- 'Beschleunigung [m/s pro m]',
- 'Breite der Klappe [m]',
- '$h_{u,klappe}$[{\small m}]',
- 'Eintauchgeschwindigkeit [m/s]',
- '$h_{uw}$[{\small m}]',
- 'KlappenOberkante [m]'
+ "Beschleunigung [m/s pro m]",
+ "Breite der Klappe [m]",
+ r"$h_{u,klappe}$[{\small m}]",
+ "Eintauchgeschwindigkeit [m/s]",
+ r"$h_{uw}$[{\small m}]",
+ "KlappenOberkante [m]",
]
subplot_titles = [
- 'Geschwindigkeitsänderung über die Klappenlänge',
- 'Mindestbreite der Klappe',
- 'Überfallhöhe an der Klappe',
- 'Eintauchgeschwindigkeit',
- 'Anforderungen an das Wasserpolster im UW',
- 'Einleitung des Wassers am Ende des Kanals '
+ "Geschwindigkeitsänderung über die Klappenlänge",
+ "Mindestbreite der Klappe",
+ "Überfallhöhe an der Klappe",
+ "Eintauchgeschwindigkeit",
+ "Anforderungen an das Wasserpolster im UW",
+ "Einleitung des Wassers am Ende des Kanals ",
]
# Event_lables = ["Q5", "Q30", "MQ", "Q330", "MHQ", "Q360", "HQ5", "HQ10", "HQ20", "HQ50", "HQ100"]
Event_lables = ["Q5", "Q30", "MQ", "Q330"]
@@ -1629,83 +1855,101 @@ def check_fish_availability(fish_name):
# Plot for the second subplot (results.iloc[:, 0] vs ergebniss[0])
ax[0].plot(results.iloc[:, 0], ergebniss[0])
- ax[0].axhline(y=anforderungen[0], color='r', linestyle='--')
+ ax[0].axhline(y=anforderungen[0], color="r", linestyle="--")
ax[0].set_ylabel(y_labels[0])
ax[0].set_title(subplot_titles[0])
# Plot for the first subplot (ergebniss[1] vs anforderungen[1])
if anforderungen[1] is not None:
- ax[1].axhline(y=anforderungen[1], color='r', linestyle='--')
+ ax[1].axhline(y=anforderungen[1], color="r", linestyle="--")
# Ebel (2016)
- ax[1].text(results.iloc[:, 0].max() / 2, anforderungen[1], 'Ebel (2016)', color='red')
+ ax[1].text(results.iloc[:, 0].max() / 2, anforderungen[1], "Ebel (2016)", color="red")
else:
- ax[1].text(results.iloc[:, 0].max() / 2, anforderungen[2] / 2 + 1,
- 'Die gewählte Fischart ist nicht verfügbar im Ebel (2016)', color='red')
+ ax[1].text(
+ results.iloc[:, 0].max() / 2,
+ anforderungen[2] / 2 + 1,
+ "Die gewählte Fischart ist nicht verfügbar im Ebel (2016)",
+ color="red",
+ )
if anforderungen[2] is not None:
- ax[1].axhline(y=anforderungen[2], color='r', linestyle='--')
+ ax[1].axhline(y=anforderungen[2], color="r", linestyle="--")
# 9 * H fish text
- ax[1].text(results.iloc[:, 0].max() / 2, anforderungen[2], '9 $\cdot$ $D_{Fisch}$', color='red')
+ ax[1].text(results.iloc[:, 0].max() / 2, anforderungen[2], r"9 $\cdot$ $D_{Fisch}$", color="red")
else:
- ax[1].text(results.iloc[:, 0].max() / 2, ergebniss[2] / 2,
- 'Die gewählte Fischart ist nicht verfügbar im DWA (2014)', color='red')
-
- ax[1].axhline(y=ergebniss[2], xmin=0, xmax=results.iloc[:, 0].max(), linestyle='-')
+ ax[1].text(
+ results.iloc[:, 0].max() / 2,
+ ergebniss[2] / 2,
+ "Die gewählte Fischart ist nicht verfügbar im DWA (2014)",
+ color="red",
+ )
+
+ ax[1].axhline(y=ergebniss[2], xmin=0, xmax=results.iloc[:, 0].max(), linestyle="-")
ax[1].set_ylabel(y_labels[1])
ax[1].set_xlim([0, results.iloc[:, 0].max()])
- ax[1].set_title('Klappenbreite')
+ ax[1].set_title("Klappenbreite")
ax[2].plot(results.iloc[:, 0], ergebniss[3])
if anforderungen[2] is not None:
- ax[2].axhline(y=anforderungen[3], color='red', linestyle='--')
+ ax[2].axhline(y=anforderungen[3], color="red", linestyle="--")
# 3 * H fish text
- ax[2].text(results.iloc[:, 0].max() / 2, anforderungen[3], '3 $\cdot$ $H_{Fisch}$', color='red')
+ ax[2].text(results.iloc[:, 0].max() / 2, anforderungen[3], r"3 $\cdot$ $H_{Fisch}$", color="red")
else:
- ax[2].text(results.iloc[:, 0].max() / 2, ax[2].get_ylim()[1] / 2,
- 'Die gewählte Fischart ist nicht verfügbar im DWA (2014)', color='red')
+ ax[2].text(
+ results.iloc[:, 0].max() / 2,
+ ax[2].get_ylim()[1] / 2,
+ "Die gewählte Fischart ist nicht verfügbar im DWA (2014)",
+ color="red",
+ )
ax[2].set_ylabel(y_labels[2])
ax[2].set_title(subplot_titles[2])
ax[3].plot(results.iloc[:, 0], ergebniss[4])
- ax[3].axhline(y=anforderungen[4], color='red', linestyle='--')
+ ax[3].axhline(y=anforderungen[4], color="red", linestyle="--")
ax[3].set_ylabel(y_labels[3])
ax[3].set_title(subplot_titles[3])
- ax[4].plot(results.iloc[:, 0], ergebniss[5], label='$h_{uw}$')
- ax[4].plot(results.iloc[:, 0], anforderungen[5], label='max(0.25 $\cdot$ Fallhöhe, 1.2)', color='r', linestyle='--')
+ ax[4].plot(results.iloc[:, 0], ergebniss[5], label="$h_{uw}$")
+ ax[4].plot(
+ results.iloc[:, 0],
+ anforderungen[5],
+ label=r"max(0.25 $\cdot$ Fallhöhe, 1.2)",
+ color="r",
+ linestyle="--",
+ )
ax[4].set_ylabel(y_labels[4])
ax[4].set_title(subplot_titles[4])
- ax[4].legend(loc='upper right')
+ ax[4].legend(loc="upper right")
- ax[5].plot(results.iloc[:, 0], ergebniss[6], label='$h_{uw}$+ 0.5[m]')
- ax[5].plot(results.iloc[:, 0], ergebniss[5], label='$h_{uw}$')
- ax[5].plot(results.iloc[:, 0], anforderungen[6], label='Oberkante der Klappe über der Sohle')
+ ax[5].plot(results.iloc[:, 0], ergebniss[6], label="$h_{uw}$+ 0.5[m]")
+ ax[5].plot(results.iloc[:, 0], ergebniss[5], label="$h_{uw}$")
+ ax[5].plot(results.iloc[:, 0], anforderungen[6], label="Oberkante der Klappe über der Sohle")
ax[5].set_ylabel(y_labels[5])
ax[5].set_title(subplot_titles[5])
- ax[5].legend(loc='upper right')
+ ax[5].legend(loc="upper right")
- ax[6].plot(results.iloc[:, 0], ergebniss[8], label='$v_{FAA}$')
- ax[6].plot(results.iloc[:, 0], ergebniss[9], label='$v_{FAbA}$')
- ax[6].set_xlabel('Abfluss [m³/s]')
- ax[6].set_ylabel('Geschwindigkeit [m/s]')
- ax[6].legend(loc='upper right')
- ax[6].set_title('Fließgeschwindigkeiten am Einsteig der FAA bzw. im Unterwasser der FAbA')
+ ax[6].plot(results.iloc[:, 0], ergebniss[8], label="$v_{FAA}$")
+ ax[6].plot(results.iloc[:, 0], ergebniss[9], label="$v_{FAbA}$")
+ ax[6].set_xlabel("Abfluss [m³/s]")
+ ax[6].set_ylabel("Geschwindigkeit [m/s]")
+ ax[6].legend(loc="upper right")
+ ax[6].set_title("Fließgeschwindigkeiten am Einsteig der FAA bzw. im Unterwasser der FAbA")
# Add a secondary x-axis on the top
- secax = ax[0].secondary_xaxis('top')
+ secax = ax[0].secondary_xaxis("top")
secax.set_xticks(results_events.iloc[:4, 0])
- secax.set_xticklabels(Event_lables, rotation=90, color='red')
- secax.tick_params(axis='x', labelsize=8)
+ secax.set_xticklabels(Event_lables, rotation=90, color="red")
+ secax.tick_params(axis="x", labelsize=8)
# Adjust the position of the secondary axis
- secax.spines['bottom'].set_position(('outward', 10))
+ secax.spines["bottom"].set_position(("outward", 10))
- fig.suptitle('Anforderungen an den Bypass aus Sicht des Abstiegs und des Aufstiegs', fontsize=16)
+ fig.suptitle("Anforderungen an den Bypass aus Sicht des Abstiegs und des Aufstiegs", fontsize=16)
for event in results_events.iloc[:4, 0]:
for axs in ax:
- axs.axvline(x=event, color='b', linestyle='--', alpha=0.3)
+ axs.axvline(x=event, color="b", linestyle="--", alpha=0.3)
for axs in ax:
axs.set_xlim(left=0)
@@ -1715,8 +1959,9 @@ def check_fish_availability(fish_name):
plt.subplots_adjust(hspace=0.35)
plt.subplots_adjust(top=0.93)
plt.grid(True) # Add grid if needed
- plt.show()
- plt.savefig('check_FAbA_FAA.png')
+ if os.environ.get("SERVER_MODE") != "1":
+ plt.show()
+ plt.savefig("check_FAbA_FAA.png")
# Export Geometry Parameters according to Pralong et al. 2011 or Tullis 20XX
@@ -1733,7 +1978,7 @@ def write_lab_excel(lab):
"N": lab.N, # number of keys
"D": lab.D, # front wall length
"w": lab.w, # key width
- "S": lab.S # overall additional wall width
+ "S": lab.S, # overall additional wall width
}
columns = list(data.keys())
@@ -1752,11 +1997,7 @@ def write_lab_excel(lab):
def write_flap_excel(flap):
- data = {
- 'width': flap.KW,
- 'height': flap.KP,
- 'angle': flap.Kalpha
- }
+ data = {"width": flap.KW, "height": flap.KP, "angle": flap.Kalpha}
columns = list(data.keys())
values = list(data.values())
diff --git a/examples/api_example.py b/examples/api_example.py
new file mode 100644
index 0000000..5697d38
--- /dev/null
+++ b/examples/api_example.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+"""
+Created on Tue Dec 9 14:36:15 2025
+
+@author: belzner
+"""
+
+import requests
+
+## Geometrie-Optimierung
+url = "http://127.0.0.1:8000/labyrinth/optimize"
+
+data = {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.09,
+ "discharge": 10,
+ "labyrinth_width": 15,
+ "labyrinth_height": 2.2,
+ "labyrinth_length_max": 8,
+ "D": 0.5,
+ "t": 0.3,
+}
+
+response = requests.post(url, json=data)
+result = response.json()
+
+print("Status:", response.status_code)
+print("Antwort:", response.text)
+
+
+## Labyrinth-Hydraulik (nutzt die Ergegnisse der Optimierung)
+url = "http://127.0.0.1:8000/labyrinth/compute"
+
+data = {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.09,
+ "discharge": 10,
+ "labyrinth_width": 15,
+ "labyrinth_height": 2.2,
+ "labyrinth_length": result["l_best"],
+ "labyrinth_key_angle": result["Angle_best"],
+ "D": 0.5,
+ "t": 0.3,
+}
+
+response = requests.post(url, json=data)
+result = response.json()
+
+print("Status:", response.status_code)
+print("Antwort:", response.text)
+
+
+## Klappe
+url = "http://127.0.0.1:8000/flap/compute"
+
+data = {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.09,
+ "discharge": 10,
+ "flap_gate_width": 1.4,
+ "flap_gate_height": 2.35,
+ "flap_gate_angle": 74,
+}
+
+response = requests.post(url, json=data)
+result = response.json()
+
+print("Status:", response.status_code)
+print("Antwort:", response.text)
+
+
+## Operational Model (inkl. Kopplung)
+url = "http://127.0.0.1:8000/operational"
+
+data = {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.09,
+ "discharge": 10,
+ "labyrinth_width": 15,
+ "labyrinth_height": 2.2,
+ "labyrinth_length": 8,
+ "labyrinth_key_angle": 8,
+ "discharge_vector": [2.09, 2.79, 6.01, 11.9, 13.9, 16.3, 16.5, 18.6, 20.5, 22.9, 24.5],
+ "downstream_water_level_vector": [1.07, 1.15, 1.19, 1.25, 1.38, 1.39, 1.74, 1.74, 1.94, 2.67, 2.67],
+ "interpolation_method": "exponential",
+ "interpolation_stepsize": 1,
+ "flap_gate_bottom_level": 0.1,
+ "flap_gate_downstream_water_level": 1.09,
+ "flap_gate_discharge": 10,
+ "flap_gate_width": 1.4,
+ "flap_gate_height": 2.35,
+ "flap_gate_angle": 74,
+ "design_upstream_water_level": 2.2,
+ "max_flap_gate_angle": 90,
+ "fish_body_height": 0.4,
+ "include_flap_gate": True,
+}
+
+response = requests.post(url, json=data)
+result = response.json()
+
+print("Status:", response.status_code)
+print("Antwort:", response.text)
diff --git a/example.py b/examples/example.py
similarity index 51%
rename from example.py
rename to examples/example.py
index 61d7d1d..7683dd6 100644
--- a/example.py
+++ b/examples/example.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""
Created on Thu Sep 15 13:32:37 2022
@@ -24,25 +23,33 @@
# %% Module laden
+import os
+import sys
+
+# Add parent directory to path to allow importing engineer
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
import matplotlib.pyplot as plt
import numpy as np
-from engineer import Labyrinth, operational_model, FlapGate, optimize_labyrinth_geometry, write_flap_excel, write_lab_excel
+from engineer import FlapGate, Labyrinth, operational_model, optimize_labyrinth_geometry, write_flap_excel, write_lab_excel
-plt.close('all')
+plt.close("all")
# %% Labyrinth-Wehr
# Initialisierung
# Initialise the labyrinth-weir object
-labyrinth_weir = Labyrinth(bottom_level=0.1, # bottom height [m]
- downstream_water_level=1.09, # downstream Water level [m]
- discharge=10, # discharge [m3/s]
- labyrinth_width=15, # labyrinth weir width [m]
- labyrinth_height=2.2, # labyrinth weir height [m]
- labyrinth_length=8, # labyrinth weir length in flow direction [m]
- labyrinth_key_angle=8, # key angle [degree]
- D=0.5) # front wall width [m]
+labyrinth_weir = Labyrinth(
+ bottom_level=0.1, # bottom height [m]
+ downstream_water_level=1.09, # downstream Water level [m]
+ discharge=10, # discharge [m3/s]
+ labyrinth_width=15, # labyrinth weir width [m]
+ labyrinth_height=2.2, # labyrinth weir height [m]
+ labyrinth_length=8, # labyrinth weir length in flow direction [m]
+ labyrinth_key_angle=8, # key angle [degree]
+ D=1,
+) # front wall width [m]
# Adjust parameters
# After adjustment of parameters, the hydraulics and geometry mus be recalculated
@@ -56,15 +63,17 @@
# %% flap gate
# (Fischbauch)klappe
# Usage like the labyrinth
-flap_gate = FlapGate(bottom_level=0.1, # bottom height [m]
- downstream_water_level=1.09, # downstream water level [m]
- discharge=10, # discharge [m3/s]
- flap_gate_width=1.4, # flap width [m]
- flap_gate_height=2.35, # flap height [m]
- flap_gate_angle=74) # flap angle [degree]
+flap_gate = FlapGate(
+ bottom_level=0.1, # bottom height [m]
+ downstream_water_level=1.09, # downstream water level [m]
+ discharge=10, # discharge [m3/s]
+ flap_gate_width=1.4, # flap width [m]
+ flap_gate_height=2.35, # flap height [m]
+ flap_gate_angle=74,
+) # flap angle [degree]
# %% Optimize the geometry of the labyrinth weir to the maximum hydraulic capacity
-# Boundary conditions:
+# Boundary conditions:
# - the available space for the labyrinth weir
# - the design discharge
# - the crest height of the labyrinth (= minimum upstream water level)
@@ -77,9 +86,9 @@
labyrinth_crest_height = 2.2 # crest height of labyrinth weir [m]
# Optimization: the return value is a labyrinth-object
-optimized_labyrinth = optimize_labyrinth_geometry(Labyrinth, bottom_level, design_downstream_water_level, design_discharge,
- labyrinth_width, labyrinth_crest_height - bottom_level + 0, labyrinth_length,
- path='', show_plot=False)
+optimized_labyrinth = optimize_labyrinth_geometry(
+ Labyrinth, bottom_level, design_downstream_water_level, design_discharge, labyrinth_width, labyrinth_crest_height - bottom_level + 0, labyrinth_length, D=1, path="", show_plot=False
+)
# Postprozess
optimized_labyrinth.plot_geometry() # plot the optimized geometry
@@ -95,59 +104,28 @@
# Hydrology
# Is required to calculate the hydraulic effect along the entire discharge curve
-discharge = np.array([
- 2.09,
- 2.79,
- 6.01,
- 11.90,
- 13.90,
- 16.30,
- 16.50,
- 18.60,
- 20.50,
- 22.90,
- 24.50])
-
-downstream_water_level = np.array([
- 1.07,
- 1.15,
- 1.19,
- 1.25,
- 1.38,
- 1.39,
- 1.74,
- 1.74,
- 1.94,
- 2.67,
- 2.67])
+discharge = np.array([2.09, 2.79, 6.01, 11.90, 13.90, 16.30, 16.50, 18.60, 20.50, 22.90, 24.50])
+
+downstream_water_level = np.array([1.07, 1.15, 1.19, 1.25, 1.38, 1.39, 1.74, 1.74, 1.94, 2.67, 2.67])
# This is the current water level. This is used to compare the hydraulic effect of the labyrinth weir system with the current situation.
-upstream_water_level_today = np.array([
- 2.03,
- 2.15,
- 2.16,
- 2.19,
- 2.22,
- 2.21,
- 2.33,
- 2.33,
- 2.47,
- 2.47,
- 2.47])
+# upstream_water_level_today = np.array([2.03, 2.15, 2.16, 2.19, 2.22, 2.21, 2.33, 2.33, 2.47, 2.47, 2.47])
# UW_interpolation(Abfluss,Unterwasser,interpolation='all',show_plot=True, save_plot=True)
-results, results_events = operational_model(labyrinth_object=optimized_labyrinth,
- flap_gate_opject=flap_gate,
- discharge_vector=discharge,
- downstream_water_level_vector=downstream_water_level,
- upstream_water_level_vector=upstream_water_level_today,
- design_upstream_water_level=design_upstream_water_level,
- max_flap_gate_angle=max_flap_gate_angle,
- fish_body_height=fish_body_height,
- interpolation_method='exponential',
- show_plot=False,
- save_plot=False)
+results, results_events = operational_model(
+ labyrinth_object=optimized_labyrinth,
+ flap_gate_opject=flap_gate,
+ discharge_vector=discharge,
+ downstream_water_level_vector=downstream_water_level,
+ design_upstream_water_level=design_upstream_water_level,
+ max_flap_gate_angle=max_flap_gate_angle,
+ fish_body_height=fish_body_height,
+ interpolation_method="exponential",
+ show_plot=True,
+ save_plot=True,
+)
write_lab_excel(labyrinth_weir)
write_flap_excel(flap_gate)
+print("DONE")
diff --git a/examples/example_stl_generation.py b/examples/example_stl_generation.py
new file mode 100644
index 0000000..220d455
--- /dev/null
+++ b/examples/example_stl_generation.py
@@ -0,0 +1,25 @@
+"""
+Created on Mon Apr 13 08:49:15 2026
+
+@author: belzner
+"""
+
+import os
+import sys
+
+# Add parent directory to path to allow importing STL_function
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import STL_function
+
+D = 0.5
+W = 4
+alpha = 8
+t = 0.7
+B = 10
+B -= t
+P = 6
+
+filename = "labyrinth_stl.stl"
+
+STL_function.generate_labyrinth_geometry(D, W, alpha, B, t, P, filename)
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..1c65d4c
--- /dev/null
+++ b/main.py
@@ -0,0 +1,4 @@
+from server.app.main import app
+
+# Connection to the api/server folder
+__all__ = ["app"]
diff --git a/pictures/Q_interpolate_downstream_curve_all.svg b/pictures/Q_interpolate_downstream_curve_all.svg
deleted file mode 100644
index 2388010..0000000
--- a/pictures/Q_interpolate_downstream_curve_all.svg
+++ /dev/null
@@ -1,2260 +0,0 @@
-
-
-
diff --git a/pictures/empty.txt b/pictures/empty.txt
deleted file mode 100644
index 8b13789..0000000
--- a/pictures/empty.txt
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..f936355
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,45 @@
+[tool.ruff]
+line-length = 200
+target-version = "py313"
+
+[tool.ruff.format]
+# Very minimal formatting settings - minimize line breaking
+quote-style = "double"
+indent-style = "space"
+skip-magic-trailing-comma = false
+line-ending = "auto"
+# Disable aggressive formatting
+docstring-code-format = false
+docstring-code-line-length = "dynamic"
+
+[tool.ruff.lint]
+select = [
+ "E", # pycodestyle errors
+ "W", # pycodestyle warnings
+ "F", # pyflakes
+ "I", # isort
+ "B", # flake8-bugbear
+ "C4", # flake8-comprehensions
+ "UP", # pyupgrade
+]
+ignore = [
+ "E501", # line too long, handled by formatter
+ "B008", # do not perform function calls in argument defaults
+ "C901", # too complex
+ "UP031", # percent format
+ "B023", # function uses loop variable - sometimes necessary
+ "B905", # zip() without an explicit strict= parameter - Python < 3.10 compatibility
+ "C408", # dict() calls - sometimes clearer
+ "E741", # ambiguous variable name - allow single letters in math code
+]
+
+[tool.ruff.lint.isort]
+known-first-party = ["engineer"]
+split-on-trailing-comma = false
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+python_files = "test_*.py"
+python_classes = "Test*"
+python_functions = "test_*"
+addopts = "-v --tb=short"
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..b64f0f9
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,14 @@
+fastapi>=0.136.3
+fastapi-cli>=0.0.16
+uvicorn[standard]>=0.30.6
+
+numpy>=2.3.4
+scipy>=1.16.3
+pandas>=2.3.3
+matplotlib>=3.10.7
+openpyxl>=3.1.5
+
+pydantic>=2.12.5
+requests>=2.31.0
+
+pre-commit>=4.0.1
diff --git a/server/API_GUIDE.md b/server/API_GUIDE.md
new file mode 100644
index 0000000..da551d3
--- /dev/null
+++ b/server/API_GUIDE.md
@@ -0,0 +1,462 @@
+# LABYRINTH API - User Guide
+
+## Local Development / Server Startup
+
+### Prerequisites
+
+- Python 3.9+ installed
+- Repository cloned, working directory: project root (`ENGINEER/`)
+
+### Install Dependencies
+
+Navigate to the project root `ENGINEER` folder and set up a virtual environment + dependencies:
+
+```bash
+cd ENGINEER
+python3 -m venv .venv
+source .venv/bin/activate # on Windows: .venv\Scripts\activate
+pip install --upgrade pip
+pip install -r requirements.txt
+```
+
+### Start FastAPI Server Locally
+
+Start (always from the `ENGINEER/` folder):
+
+- **With FastAPI-CLI** (if installed):
+
+```bash
+fastapi dev main.py
+```
+
+After startup:
+
+- **Base URL:** `http://localhost:8000`
+- **Interactive API Documentation:** `http://localhost:8000/docs`
+
+---
+
+## Overview
+
+The LABYRINTH API is a REST API for hydraulic calculations of labyrinth weirs and flap gates. It enables the calculation of hydraulic parameters, optimization of weir geometries, and simulation of operational behavior.
+
+**Base URL:** `http://localhost:8000` (locally) or your server URL
+
+**API Documentation:** Available at `/docs` after starting the server (e.g., `http://localhost:8000/docs`)
+
+---
+
+## Endpoints
+
+### 0. Health Check
+
+**GET** `/health`
+
+Returns the system status. Useful for automated monitoring and uptime checks.
+
+#### Response Schema (Output)
+
+| Field | Type | Description |
+| -------- | ------ | ---------------------- |
+| `status` | string | Returns `"ok"` |
+
+#### Example Request
+
+```json
+{
+ "status": "ok"
+}
+```
+
+---
+
+### 1. Calculate Labyrinth Weir
+
+**POST** `/labyrinth/compute`
+
+Calculates the hydraulic parameters for a labyrinth weir with given geometry parameters.
+
+#### Request Schema (Input)
+
+| Field | Type | Description | Unit | Required |
+| ------------------------ | ---------------- | --------------------------------- | -------- | -------------- |
+| `bottom_level` | float | Bottom height | m a.s.l. | ✓ |
+| `downstream_water_level` | float | Downstream water level | m a.s.l. | ✓ |
+| `discharge` | float (>0) | Discharge | m³/s | ✓ |
+| `labyrinth_width` | float (>0) | Total width of labyrinth weir | m | ✓ |
+| `labyrinth_height` | float (>0) | Height of labyrinth weir | m | ✓ |
+| `labyrinth_length` | float (>0) | Length of a key in flow direction | m | ✓ |
+| `labyrinth_key_angle` | float (>0) | Angle of inclined side walls | ° | ✓ |
+| `D` | float (optional) | Front wall thickness | m | (Default: 0.3) |
+| `t` | float (optional) | Wall thickness of keys | m | (Default: 0.3) |
+
+#### Response Schema (Output)
+
+| Field | Type | Description | Unit |
+| -------------- | ------------ | ------------------------------- | -------- |
+| **Geometry** |
+| `N` | int | Number of keys | - |
+| `L` | float | Total developed weir length | m |
+| `w` | float | Width of a single key | m |
+| `l` | float | Length of inclined side wall | m |
+| `S` | float | Remaining straight weir length | m |
+| **Hydraulics** |
+| `Hu` | float | Upstream energy head | m |
+| `hu` | float | Water level above crest | m |
+| `yu` | float | Upstream water level (absolute) | m a.s.l. |
+| `Cd` | float | Discharge coefficient | - |
+| `v` | float | Velocity | m/s |
+| `hd` | float | Tailwater above crest | m |
+| `Hd` | float | Specific energy in tailwater | m |
+| `rs` | string | Backwater influence status | - |
+| `warnings` | List[string] | Warnings (if any) | - |
+
+#### Example Request
+
+```json
+{
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.09,
+ "discharge": 10,
+ "labyrinth_width": 15,
+ "labyrinth_height": 2.2,
+ "labyrinth_length": 8,
+ "labyrinth_key_angle": 8,
+ "D": 0.5,
+ "t": 0.3
+}
+```
+
+---
+
+### 2. Optimize Labyrinth Weir
+
+**POST** `/labyrinth/optimize`
+
+Finds the optimal geometry of a labyrinth weir for maximum hydraulic capacity under given boundary conditions.
+
+#### Request Schema (Input)
+
+| Field | Type | Description | Unit | Required |
+| ------------------------ | ---------- | ------------------------------------- | -------- | -------- |
+| `bottom_level` | float | Bottom height | m a.s.l. | ✓ |
+| `downstream_water_level` | float | Downstream water level | m a.s.l. | ✓ |
+| `discharge` | float (>0) | Design discharge | m³/s | ✓ |
+| `labyrinth_width` | float (>0) | Available width | m | ✓ |
+| `labyrinth_height` | float (>0) | Available height | m | ✓ |
+| `labyrinth_length_max` | float (>0) | Maximum available length | m | ✓ |
+| `D` | float | Front wall width used in optimization | m | ✗ (default 0.5) |
+| `t` | float | Wall thickness for STL generation | m | ✗ (default 0.3) |
+
+#### Response Schema (Output)
+
+| Field | Type | Description | Unit |
+| ---------------------- | ----- | ----------------------------- | ---- |
+| **Optimal Geometry** |
+| `B_best` | float | Optimal key length | m |
+| `Angle_best` | float | Optimal key angle | ° |
+| `N_best` | int | Optimal number of keys | - |
+| `w_best` | float | Optimal key width | m |
+| `l_best` | float | Optimal side wall length | m |
+| `S_best` | float | Optimal straight weir length | m |
+| `L_best` | float | Optimal total length | m |
+| **Optimal Hydraulics** |
+| `Hu_best` | float | Minimum upstream energy head | m |
+| `Cd_best` | float | Optimal discharge coefficient | - |
+| `v_best` | float | Optimal velocity | m/s |
+
+#### Example Request
+
+```json
+{
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.8,
+ "discharge": 20,
+ "labyrinth_width": 10,
+ "labyrinth_height": 2.2,
+ "labyrinth_length_max": 8,
+ "D": 0.5,
+ "t": 0.3
+}
+```
+
+---
+
+### 3. Download Optimized Labyrinth STL
+
+**POST** `/labyrinth/optimize/stl`
+
+Optimizes the labyrinth geometry for the given boundary conditions and streams back an STL generated from the optimized geometry.
+
+#### Request Schema (Input)
+
+Same as `/labyrinth/optimize`, plus the STL-specific wall thickness parameter `t`.
+
+#### Response Schema (Output)
+
+| Field | Type | Description |
+| ----------- | ------ | --------------------------------------------------- |
+| `file` | binary | Binary STL payload (`Content-Disposition: attachment`) |
+
+The endpoint returns `200 OK` with a binary stream. Invalid geometries produce `422 Unprocessable Entity` with the domain validation message.
+
+#### Example Request
+
+```json
+{
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.8,
+ "discharge": 20,
+ "labyrinth_width": 10,
+ "labyrinth_height": 2.2,
+ "labyrinth_length_max": 8,
+ "D": 0.5,
+ "t": 0.3
+}
+```
+
+---
+
+### 4. Download Labyrinth STL
+
+**POST** `/labyrinth/stl`
+
+Generates a watertight STL mesh of the labyrinth geometry based on the provided dimensions and streams it back as a file download.
+
+#### Request Schema (Input)
+
+Same as `/labyrinth/compute`:
+
+| Field | Type | Description | Unit | Required |
+| ------------------------ | ---------------- | --------------------------------- | -------- | -------------- |
+| `bottom_level` | float | Bottom height | m a.s.l. | ✓ |
+| `downstream_water_level` | float | Downstream water level | m a.s.l. | ✓ |
+| `discharge` | float (>0) | Discharge | m³/s | ✓ |
+| `labyrinth_width` | float (>0) | Total width of labyrinth weir | m | ✓ |
+| `labyrinth_height` | float (>0) | Height of labyrinth weir | m | ✓ |
+| `labyrinth_length` | float (>0) | Length of a key in flow direction | m | ✓ |
+| `labyrinth_key_angle` | float (>0) | Angle of inclined side walls | ° | ✓ |
+| `D` | float (optional) | Front wall thickness | m | (Default: 0.5) |
+| `t` | float (optional) | Wall thickness of keys | m | (Default: 0.3) |
+
+#### Response Schema (Output)
+
+| Field | Type | Description |
+| ----------- | ------ | --------------------------------------------------- |
+| `file` | binary | Binary STL payload (`Content-Disposition: attachment`) |
+
+The endpoint returns `200 OK` with a binary stream. Invalid geometries produce `422 Unprocessable Entity` with the domain validation message.
+
+#### Example Request
+
+```json
+{
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.09,
+ "discharge": 10,
+ "labyrinth_width": 15,
+ "labyrinth_height": 2.2,
+ "labyrinth_length": 8,
+ "labyrinth_key_angle": 8,
+ "D": 0.5,
+ "t": 0.3
+}
+```
+
+---
+
+### 4. Calculate Flap Gate
+
+**POST** `/flap/compute`
+
+Calculates the hydraulic parameters for a flap gate (fish-belly flap).
+
+#### Request Schema (Input)
+
+| Field | Type | Description | Unit | Required |
+| ------------------------ | ---------- | ---------------------- | -------- | -------- |
+| `bottom_level` | float | Bottom height | m a.s.l. | ✓ |
+| `downstream_water_level` | float | Downstream water level | m a.s.l. | ✓ |
+| `discharge` | float (>0) | Discharge | m³/s | ✓ |
+| `flap_gate_width` | float (>0) | Flap width | m | ✓ |
+| `flap_gate_height` | float (>0) | Flap height | m | ✓ |
+| `flap_gate_angle` | float | Angle to vertical | ° | ✓ |
+
+#### Response Schema (Output)
+
+| Field | Type | Description | Unit |
+| ---------------- | ------------ | ---------------------------------------------- | -------- |
+| **Geometry** |
+| `P_neu` | float | Effective weir height (after angle correction) | m |
+| **Hydraulics** |
+| `mu` | float | Discharge coefficient | - |
+| `mu_ratio` | float | Ratio of discharge coefficients | - |
+| `hu` | float | Upstream water level above crest | m |
+| `yu` | float | Upstream water level (absolute) | m a.s.l. |
+| `hd` | float | Tailwater level above crest | m |
+| `v` | float | Velocity | m/s |
+| `vd` | float | Tailwater velocity | m/s |
+| `beschleunigung` | float | Acceleration along the gate | m/(s·m) |
+| `h_gr` | float | Critical water depth | m |
+| `v_gr` | float | Critical velocity | m/s |
+| `warnings` | List[string] | Warnings (if any) | - |
+
+#### Example Request
+
+```json
+{
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.09,
+ "discharge": 10,
+ "flap_gate_width": 1.4,
+ "flap_gate_height": 2.35,
+ "flap_gate_angle": 74
+}
+```
+
+---
+
+### 5. Simulate Operational Model
+
+**POST** `/operational`
+
+Simulates the operational behavior of a labyrinth weir (optionally including a flap gate) over a discharge curve and returns both the interpolated curve and the event-based points that were provided.
+
+#### Request Schema (Input)
+
+| Field | Type | Description | Unit | Required |
+| --------------------------------- | ------------------- | --------------------------------------------------------------------------- | -------- | -------- |
+| `bottom_level` | float | Bottom height | m a.s.l. | ✓ |
+| `downstream_water_level` | float | Downstream water level | m a.s.l. | ✓ |
+| `discharge` | float (>0) | Reference discharge | m³/s | ✓ |
+| `labyrinth_width` | float (>0) | Total width of the labyrinth weir | m | ✓ |
+| `labyrinth_height` | float (>0) | Height of the labyrinth weir | m | ✓ |
+| `labyrinth_length` | float (>0) | Length of the labyrinth key in flow direction | m | ✓ |
+| `labyrinth_key_angle` | float (>0) | Key angle of the labyrinth weir | ° | ✓ |
+| `D` | float | Front wall thickness (same meaning as other labyrinth endpoints) | m | ✗ (default: 0.5) |
+| `discharge_vector` | List[float (>0)] (max 500) | Discharge curve that will be interpolated | m³/s | ✓ |
+| `downstream_water_level_vector` | List[float] (max 500) | Water level history that matches the discharge vector | m a.s.l. | ✓ |
+| `interpolation_method` | string | Interpolation method for the hydrograph (`exponential`, `linear`, `quadratic`, `cubic`) | - | ✗ (default: `"exponential"`) |
+| `interpolation_stepsize` | float (>0, ≤100) | Discharge stepsize used when filling the computed curve | m³/s | ✗ (default: 1) |
+| `include_flap_gate` | bool | Whether to include the flap gate hydraulics in the simulation | - | ✓ |
+| `flap_gate_bottom_level` | float | Flap gate sill bottom level (required if `include_flap_gate` is `true`) | m | conditional |
+| `flap_gate_downstream_water_level` | float | Flap gate downstream water level (required if `include_flap_gate` is `true`) | m | conditional |
+| `flap_gate_discharge` | float (>0) | Discharge through the flap gate (required if `include_flap_gate` is `true`) | m³/s | conditional |
+| `flap_gate_width` | float (>0) | Flap gate width (required if `include_flap_gate` is `true`) | m | conditional |
+| `flap_gate_height` | float (>0) | Flap gate height (required if `include_flap_gate` is `true`) | m | conditional |
+| `flap_gate_angle` | float | Flap gate angle (required if `include_flap_gate` is `true`) | ° | conditional |
+| `design_upstream_water_level` | float | Design upstream water level for flap gate control | m a.s.l. | ✓ |
+| `max_flap_gate_angle` | float | Maximum allowed flap gate angle | ° | ✓ |
+| `fish_body_height` | float | Height of the fish body used for bypass flow design | m | ✓ |
+
+> **Note:** When `include_flap_gate` is `false`, the flap gate-specific fields may be omitted; when it is `true`, they are required and the `max_flap_gate_angle` / `design_upstream_water_level` / `fish_body_height` fields must also be supplied.
+
+#### Response Schema (Output)
+
+| Field | Type | Description |
+| -------------- | ------------------------ | ------------------------------------------------------------------ |
+| `results` | List[`OperationalPoint`] | Full interpolated discharge curve over the range defined by `discharge_vector` |
+| `results_events` | List[`OperationalPoint`] | Interpolated points that correspond to the original discharge events |
+| `warnings` | List[string] \| `null` | Optional validation or domain warnings |
+
+**OperationalPoint**
+
+| Field | Type | Description |
+| --------------------------- | -------- | ------------------------------------------------------------ |
+| `discharge` | float | Discharge at this point |
+| `downstream_water_level` | float | Downstream water level at the same point |
+| `upstream_water_level` | float | Upstream water level computed for this discharge |
+| `labyrinth_head_over_crest` | float \| `null` | Computed head above crest for the labyrinth curve |
+| `flap_gate_head_over_crest` | float \| `null` | Computed head above crest for the flap gate (if present) |
+| `labyrinth_discharge` | float | Portion of discharge through the labyrinth (plain result for `results_events`) |
+| `flap_gate_discharge` | float \| `null` | Portion of discharge passing through the flap gate |
+| `flap_gate_angle` | float \| `null` | Flap gate angle at this discharge (if flap gate is included) |
+
+#### Example Request
+
+```json
+{
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.09,
+ "discharge": 10,
+ "labyrinth_width": 15,
+ "labyrinth_height": 2.2,
+ "labyrinth_length": 8,
+ "labyrinth_key_angle": 8,
+ "D": 0.5,
+ "discharge_vector": [2.09, 2.79, 6.01, 11.9, 13.9, 16.3, 16.5, 18.6, 20.5, 22.9, 24.5],
+ "downstream_water_level_vector": [1.07, 1.15, 1.19, 1.25, 1.38, 1.39, 1.74, 1.74, 1.94, 2.67, 2.67],
+ "interpolation_method": "exponential",
+ "interpolation_stepsize": 1,
+ "include_flap_gate": true,
+ "flap_gate_bottom_level": 0.1,
+ "flap_gate_downstream_water_level": 1.09,
+ "flap_gate_discharge": 10,
+ "flap_gate_width": 1.4,
+ "flap_gate_height": 2.35,
+ "flap_gate_angle": 74,
+ "design_upstream_water_level": 2.2,
+ "max_flap_gate_angle": 90,
+ "fish_body_height": 0.4
+}
+```
+
+---
+
+### 6. Retrieve Geometry Plot
+
+**GET** `/plots/{plot_id}`
+
+Returns the cached SVG plot for the requested `plot_id` (e.g., `labyrinth` or `optimize-abc123`). Successful responses include caching headers and inline filename hints.
+
+#### Path Parameters
+
+| Field | Type | Description |
+| --------- | ------ | ------------------------------------------- |
+| `plot_id` | string | Plot identifier (must match a stored plot) |
+
+#### Responses
+
+| Status | Description | Content Type |
+| ------ | -------------------------------------------- | ---------------- |
+| 200 | SVG plot image | `image/svg+xml` |
+| 404 | Plot is missing or expired | `application/json` |
+| 500 | Server error during plot generation | `application/json` |
+
+Example:
+
+```
+GET /plots/labyrinth
+```
+
+---
+
+## Important Notes
+
+- **Units:** All lengths in meters [m], discharges in m³/s, angles in degrees [°]
+- **Water Levels:** Absolute values in m a.s.l. (meters above sea level)
+- **Optional Parameters:** If not specified, default values are used
+- **Validation:** The API automatically validates input values (e.g., discharge > 0)
+- **Warnings:** Invalid range limits return warnings in `warnings`
+
+---
+
+## Error Handling
+
+The API returns standard HTTP status codes:
+
+- **`200 OK`**: Successful calculation.
+- **`422 Unprocessable Entity`**: Input error.
+ - Either Pydantic validation (e.g., missing required fields, wrong types), then `detail` contains the usual FastAPI/Pydantic field details.
+ - Or domain validation from the ENGINEER core (`EngineerInputError`), e.g.:
+ - negative or implausible hydraulic quantities (discharge, heights, angles),
+ - tailwater ≤ bottom (UW - bottom ≤ 0),
+ - for `/operational`: empty vectors or vectors with different lengths.
+ - In these cases, the endpoints return a `detail` object of the form:
+ - Labyrinth / Flap: `{"message": "...", "errors": ["Error 1", "Error 2", ...]}`
+ - Operational: `{"message": "Operational model input is invalid.", "errors": [...]}`.
+- **`500 Internal Server Error`**: Unexpected server error.
+ - For `/operational` a structured JSON is returned:
+ `{"message": "Operational model failed due to an internal error.", "error_type": "...", "error": "..."}`.
+
+For validation errors, the response contains details about the invalid fields or a list of domain error messages.
diff --git a/server/app/main.py b/server/app/main.py
new file mode 100644
index 0000000..cd47643
--- /dev/null
+++ b/server/app/main.py
@@ -0,0 +1,47 @@
+import os
+
+# Set server mode before importing engineer to disable GUI backend and CSV exports
+os.environ["SERVER_MODE"] = "1"
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from server.app.routes.flap_gate import router as flap_gate_router
+from server.app.routes.labyrinth import router as labyrinth_router
+from server.app.routes.operational import router as operational_router
+from server.app.routes.plots import router as plots_router
+
+
+def create_app() -> FastAPI:
+ app = FastAPI(
+ title="ENGINEER Labyrinth API",
+ description="REST API exposing hydraulic calculations from the ENGINEER package.",
+ version="0.1.0",
+ )
+
+ # CORS configuration from environment variable
+ # In production/staging, set CORS_ORIGINS to specific domains
+ # Example: CORS_ORIGINS=https://engineer-frontend.dokku.example.com,https://example.com
+ cors_origins = os.getenv("CORS_ORIGINS", "*")
+ origins_list = [origin.strip() for origin in cors_origins.split(",")] if cors_origins != "*" else ["*"]
+
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=origins_list,
+ allow_credentials=False,
+ allow_methods=["GET", "POST", "OPTIONS"],
+ allow_headers=["*"],
+ )
+
+ app.include_router(labyrinth_router, prefix="/labyrinth", tags=["labyrinth"])
+ app.include_router(flap_gate_router, prefix="/flap", tags=["flap"])
+ app.include_router(operational_router, prefix="/operational", tags=["operational"])
+ app.include_router(plots_router, tags=["plots"])
+
+ @app.get("/health", tags=["system"])
+ def health_check():
+ return {"status": "ok"}
+
+ return app
+
+
+app = create_app()
diff --git a/server/app/routes/flap_gate.py b/server/app/routes/flap_gate.py
new file mode 100644
index 0000000..2bd3a1f
--- /dev/null
+++ b/server/app/routes/flap_gate.py
@@ -0,0 +1,66 @@
+import contextlib
+import io
+
+from fastapi import APIRouter, HTTPException
+
+from engineer import EngineerInputError, FlapGate
+
+from ..schemas import FlapGateRequest, FlapGateResult
+
+router = APIRouter()
+
+
+@router.post("/compute", response_model=FlapGateResult)
+def compute_flap_gate(req: FlapGateRequest) -> FlapGateResult:
+ """
+ Calculate hydraulic indicators for a flap gate using ENGINEER helpers.
+ """
+ # Capture warnings printed during flap gate creation
+ captured_output = io.StringIO()
+
+ try:
+ with contextlib.redirect_stdout(captured_output):
+ flap_gate = FlapGate(
+ bottom_level=req.bottom_level,
+ downstream_water_level=req.downstream_water_level,
+ discharge=req.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
+ )
+ except EngineerInputError as exc:
+ # Input validation error coming from the core ENGINEER library
+ raise HTTPException(
+ status_code=422,
+ detail={"message": "Invalid flap gate input parameters.", "errors": exc.messages},
+ ) from exc
+
+ # Parse captured warnings (only unique warnings)
+ warnings = []
+ for line in captured_output.getvalue().strip().split("\n"):
+ if line.startswith("[FlapGate]"):
+ warnings.append(line.split(": ", 1)[1])
+
+ # Remove duplicates and add ce_value if present
+ warnings = list(set(warnings))
+ ce_value = getattr(flap_gate, "ce", None)
+ if ce_value and ce_value != "No errors" and ce_value not in warnings:
+ warnings.append(ce_value)
+
+ warnings = warnings or None
+
+ return FlapGateResult(
+ P_neu=flap_gate.P_neu,
+ mu=flap_gate.mu,
+ mu_ratio=flap_gate.mu_ratio,
+ hu=flap_gate.hu,
+ yu=flap_gate.yu,
+ hd=flap_gate.hd,
+ v=flap_gate.v,
+ vd=flap_gate.vd,
+ beschleunigung=flap_gate.beschleunigung,
+ h_gr=flap_gate.h_gr,
+ v_gr=flap_gate.v_gr,
+ warnings=warnings,
+ )
diff --git a/server/app/routes/labyrinth.py b/server/app/routes/labyrinth.py
new file mode 100644
index 0000000..389e1ba
--- /dev/null
+++ b/server/app/routes/labyrinth.py
@@ -0,0 +1,250 @@
+import contextlib
+import io
+import logging
+import os
+import tempfile
+
+from fastapi import APIRouter, BackgroundTasks, HTTPException
+from starlette.responses import FileResponse
+
+import STL_function
+from engineer import EngineerInputError, Labyrinth, optimize_labyrinth_geometry
+
+try:
+ # Works when imported as server.app.routes.*
+ from server.app.routes.utils.plot_helpers import store_plot_source
+except ModuleNotFoundError:
+ # Fallback for flatter package layouts.
+ from .utils.plot_helpers import store_plot_source
+from ..schemas import LabyrinthOptimizeRequest, LabyrinthOptimizeResult, LabyrinthRequest, LabyrinthResult
+
+
+def _remove_file(path: str) -> None:
+ try:
+ os.remove(path)
+ except OSError:
+ pass
+
+
+router = APIRouter()
+
+
+@router.post("/compute", response_model=LabyrinthResult)
+def compute_labyrinth(req: LabyrinthRequest) -> LabyrinthResult:
+ """
+ Calculate hydraulic indicators for a labyrinth weir using ENGINEER helpers.
+ """
+ # Capture warnings printed during labyrinth creation
+ captured_output = io.StringIO()
+ warnings = []
+
+ try:
+ with contextlib.redirect_stdout(captured_output):
+ labyrinth = Labyrinth(
+ bottom_level=req.bottom_level,
+ downstream_water_level=req.downstream_water_level,
+ discharge=req.discharge,
+ labyrinth_width=req.labyrinth_width,
+ labyrinth_height=req.labyrinth_height,
+ labyrinth_length=req.labyrinth_length,
+ labyrinth_key_angle=req.labyrinth_key_angle,
+ D=req.D,
+ t=req.t,
+ show_errors=True, # Enable warnings to be captured
+ show_geometry=False,
+ show_results=False,
+ )
+ except EngineerInputError as exc:
+ # Input validation error coming from the core ENGINEER library
+ raise HTTPException(
+ status_code=422,
+ detail={"message": "Invalid labyrinth input parameters.", "errors": exc.messages},
+ ) from exc
+
+ # Parse captured warnings (only unique warnings)
+ warnings = []
+ for line in captured_output.getvalue().strip().split("\n"):
+ if line.startswith("[Labyrinth]"):
+ warnings.append(line.split(": ", 1)[1])
+
+ # Remove duplicates and add ce_value if present
+ warnings = list(set(warnings))
+ ce_value = getattr(labyrinth, "ce", None)
+ if ce_value and ce_value not in warnings:
+ warnings.append(ce_value)
+
+ warnings = warnings or None
+
+ # Keep latest computed labyrinth object for /plots/labyrinth rendering.
+ store_plot_source("labyrinth", labyrinth)
+
+ return LabyrinthResult(
+ N=labyrinth.N,
+ L=labyrinth.L,
+ w=labyrinth.w,
+ l=labyrinth.l,
+ S=labyrinth.S,
+ Hu=labyrinth.Hu,
+ hu=labyrinth.hu,
+ yu=labyrinth.yu,
+ Cd=labyrinth.Cd,
+ v=labyrinth.v,
+ hd=labyrinth.hd,
+ Hd=labyrinth.Hd,
+ rs=labyrinth.rs,
+ warnings=warnings,
+ )
+
+
+@router.post("/stl")
+def download_labyrinth_stl(req: LabyrinthRequest, background_tasks: BackgroundTasks) -> FileResponse:
+ """
+ Generate the labyrinth STL with the provided parameters and stream it back as a download.
+ """
+ temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".stl")
+ temp_file.close()
+
+ try:
+ STL_function.generate_labyrinth_geometry(
+ D=req.D,
+ W=req.labyrinth_width,
+ alpha=req.labyrinth_key_angle,
+ B=req.labyrinth_length,
+ t=req.t,
+ P=req.labyrinth_height,
+ filename=temp_file.name,
+ )
+ except Exception as exc:
+ _remove_file(temp_file.name)
+ logging.getLogger(__name__).error(f"STL generation failed: {exc}", exc_info=True)
+ raise HTTPException(status_code=422, detail="STL generation failed with the provided labyrinth parameters.") from exc
+
+ background_tasks.add_task(_remove_file, temp_file.name)
+ return FileResponse(
+ path=temp_file.name,
+ filename="labyrinth_weir.stl",
+ media_type="application/octet-stream",
+ )
+
+
+@router.post("/optimize/stl")
+def download_optimized_labyrinth_stl(req: LabyrinthOptimizeRequest, background_tasks: BackgroundTasks) -> FileResponse:
+ """
+ Optimize the labyrinth geometry and stream the resulting STL back as a download.
+ """
+ captured_output = io.StringIO()
+ temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".stl")
+ temp_file.close()
+
+ try:
+ with contextlib.redirect_stdout(captured_output):
+ best_labyrinth = optimize_labyrinth_geometry(
+ labyrinth=Labyrinth,
+ sohleHoehe=req.bottom_level,
+ UW=req.downstream_water_level,
+ Q=req.discharge,
+ labyrinthBreite=req.labyrinth_width,
+ labyrinthHoehe=req.labyrinth_height,
+ labyrinthLaengeMax=req.labyrinth_length_max,
+ D=req.D,
+ path="",
+ show_results=False,
+ show_plot=False,
+ )
+
+ # Generate STL with optimized geometry values
+ STL_function.generate_labyrinth_geometry(
+ D=best_labyrinth.D,
+ W=best_labyrinth.W,
+ alpha=best_labyrinth.alpha,
+ B=best_labyrinth.B,
+ t=req.t,
+ P=best_labyrinth.P,
+ filename=temp_file.name,
+ )
+ except EngineerInputError as exc:
+ _remove_file(temp_file.name)
+ raise HTTPException(
+ status_code=422,
+ detail={
+ "message": "Optimization failed due to invalid inputs.",
+ "errors": exc.messages,
+ },
+ ) from exc
+ except Exception as exc:
+ _remove_file(temp_file.name)
+ logging.getLogger(__name__).error(f"Optimized STL generation failed: {exc}", exc_info=True)
+ raise HTTPException(status_code=422, detail="STL generation failed for the optimized labyrinth geometry.") from exc
+
+ background_tasks.add_task(_remove_file, temp_file.name)
+ return FileResponse(
+ path=temp_file.name,
+ filename="labyrinth_weir_optimized.stl",
+ media_type="application/octet-stream",
+ )
+
+
+@router.post("/optimize", response_model=LabyrinthOptimizeResult)
+def optimize_labyrinth(req: LabyrinthOptimizeRequest) -> LabyrinthOptimizeResult:
+ """
+ Optimize labyrinth geometry for maximum hydraulic capacity.
+ """
+ # Capture warnings printed during optimization
+ captured_output = io.StringIO()
+
+ try:
+ with contextlib.redirect_stdout(captured_output):
+ best_labyrinth = optimize_labyrinth_geometry(
+ labyrinth=Labyrinth,
+ sohleHoehe=req.bottom_level,
+ UW=req.downstream_water_level,
+ Q=req.discharge,
+ labyrinthBreite=req.labyrinth_width,
+ labyrinthHoehe=req.labyrinth_height,
+ labyrinthLaengeMax=req.labyrinth_length_max,
+ D=req.D,
+ path="",
+ show_results=False,
+ show_plot=False,
+ )
+ except EngineerInputError as exc:
+ # Input validation error during optimization
+ raise HTTPException(
+ status_code=422,
+ detail={
+ "message": "Optimization failed due to invalid inputs.",
+ "errors": exc.messages,
+ },
+ ) from exc
+
+ # Parse captured warnings from optimization (only unique warnings)
+ warnings = []
+ for line in captured_output.getvalue().strip().split("\n"):
+ if line.startswith("[Labyrinth]"):
+ warnings.append(line.split(": ", 1)[1])
+
+ # Remove duplicates and add ce_value if present
+ warnings = list(set(warnings))
+ ce_value = getattr(best_labyrinth, "ce", None)
+ if ce_value and ce_value not in warnings:
+ warnings.append(ce_value)
+
+ warnings = warnings or None
+
+ # Keep latest optimized labyrinth object for /plots/optimize rendering.
+ store_plot_source("optimize", best_labyrinth)
+
+ return LabyrinthOptimizeResult(
+ B_best=best_labyrinth.B,
+ D_best=best_labyrinth.D,
+ Angle_best=best_labyrinth.alpha,
+ N_best=best_labyrinth.N,
+ w_best=best_labyrinth.w,
+ l_best=best_labyrinth.l,
+ S_best=best_labyrinth.S,
+ L_best=best_labyrinth.L,
+ Hu_best=best_labyrinth.Hu,
+ Cd_best=best_labyrinth.Cd,
+ v_best=best_labyrinth.v,
+ warnings=warnings,
+ )
diff --git a/server/app/routes/operational.py b/server/app/routes/operational.py
new file mode 100644
index 0000000..83d896a
--- /dev/null
+++ b/server/app/routes/operational.py
@@ -0,0 +1,119 @@
+import contextlib
+import io
+
+import numpy as np
+from fastapi import APIRouter, HTTPException
+from matplotlib import pyplot as plt
+
+from engineer import EngineerInputError, FlapGate, Labyrinth, operational_model
+
+try:
+ # Works when imported as server.app.routes.*
+ from server.app.routes.utils.plot_helpers import capture_current_figure_svg_bytes, store_plot_svg_bytes
+except ModuleNotFoundError:
+ # Fallback for flatter package layouts.
+ from .utils.plot_helpers import capture_current_figure_svg_bytes, store_plot_svg_bytes
+from ..schemas import OperationalModelRequest, OperationalModelResult, OperationalPoint
+
+router = APIRouter()
+
+
+@router.post("", response_model=OperationalModelResult)
+def compute_operational_model(req: OperationalModelRequest) -> OperationalModelResult:
+ # Capture warnings printed during computation
+ captured_output = io.StringIO()
+
+ try:
+ with contextlib.redirect_stdout(captured_output):
+ # Create base labyrinth object
+ labyrinth = Labyrinth(
+ bottom_level=req.bottom_level,
+ downstream_water_level=req.downstream_water_level,
+ discharge=req.discharge,
+ labyrinth_width=req.labyrinth_width,
+ labyrinth_height=req.labyrinth_height,
+ labyrinth_length=req.labyrinth_length,
+ labyrinth_key_angle=req.labyrinth_key_angle,
+ D=req.D,
+ show_errors=True, # Enable warnings to be captured
+ show_geometry=False,
+ show_results=False,
+ path="",
+ )
+
+ # 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)
+ downstream_water_level_vector = np.array(req.downstream_water_level_vector, dtype=float)
+
+ # Run the operational model with labyrinth and flap gate objects + input parameters (vector, interpolation method, etc.)
+ results_df, results_events_df = operational_model(
+ labyrinth_object=labyrinth,
+ discharge_vector=discharge_vector,
+ downstream_water_level_vector=downstream_water_level_vector,
+ interpolation_method=req.interpolation_method,
+ interpolation_stepsize=req.interpolation_stepsize,
+ flap_gate_opject=flap_gate,
+ design_upstream_water_level=req.design_upstream_water_level,
+ max_flap_gate_angle=req.max_flap_gate_angle,
+ fish_body_height=req.fish_body_height,
+ 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.
+ store_plot_svg_bytes("operational", capture_current_figure_svg_bytes())
+ except EngineerInputError as exc:
+ # Explicitly surface detailed validation messages coming from the ENGINEER core
+ raise HTTPException(
+ status_code=422,
+ detail={"message": "Operational model input is invalid.", "errors": exc.messages},
+ ) from exc
+ finally:
+ plt.close("all")
+
+ # Parse captured warnings (only unique warnings)
+ warnings = []
+ for line in captured_output.getvalue().strip().split("\n"):
+ if line.startswith("[Labyrinth]") or line.startswith("[FlapGate]") or line.startswith("[UW_interpolation]") or line.startswith("[Operational]"):
+ warning_message = line.split(": ", 1)[1] if ": " in line else line
+ warnings.append(warning_message)
+
+ # Remove duplicates
+ warnings = list(set(warnings))
+ warnings = warnings or None
+
+ def df_to_points(df):
+ points = []
+ for row in df.to_dict(orient="records"):
+ labyrinth_head = row.get("hu_labyrinth") if row.get("hu_labyrinth") is not None else row.get("Oberfallhöhe")
+ flap_gate_head = row.get("hu_klappe")
+
+ point = OperationalPoint(
+ discharge=row.get("Abfluss"),
+ downstream_water_level=row.get("UW"),
+ 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") 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"),
+ )
+ points.append(point)
+ return points
+
+ return OperationalModelResult(results=df_to_points(results_df), results_events=df_to_points(results_events_df), warnings=warnings)
diff --git a/server/app/routes/plots.py b/server/app/routes/plots.py
new file mode 100644
index 0000000..657b260
--- /dev/null
+++ b/server/app/routes/plots.py
@@ -0,0 +1,56 @@
+import logging
+from typing import Annotated
+
+from fastapi import APIRouter, HTTPException, Path
+from fastapi.responses import Response
+
+try:
+ # Works when imported as server.app.routes.*
+ from server.app.routes.utils.plot_helpers import generate_geometry_plot_svg
+except ModuleNotFoundError:
+ # Fallback for flatter package layouts.
+ from .utils.plot_helpers import generate_geometry_plot_svg
+
+router = APIRouter(prefix="/plots")
+
+
+@router.get(
+ "/{plot_id}",
+ response_class=Response,
+ summary="Get geometry plot as SVG",
+ description="Returns the labyrinth geometry plot as SVG with strong caching headers.",
+ responses={
+ 200: {"description": "SVG plot image", "content": {"image/svg+xml": {}}},
+ 404: {"description": "Plot could not be generated"},
+ 500: {"description": "Internal server error during plot generation"},
+ },
+)
+def get_plot(
+ plot_id: Annotated[str, Path(description="Plot identifier (e.g. 'labyrinth' or 'optimize-abc123')")],
+) -> Response:
+ # endpoint for getting the geometry plot as SVG
+ try:
+ svg_bytes = generate_geometry_plot_svg(plot_id)
+ if not svg_bytes:
+ raise HTTPException(
+ status_code=404,
+ detail="Plot could not be generated. Check server logs.",
+ )
+
+ return Response(
+ content=svg_bytes,
+ media_type="image/svg+xml",
+ headers={
+ "Cache-Control": "public, max-age=3600, immutable",
+ "Content-Disposition": f'inline; filename="labyrinth-geometry-{plot_id}.svg"',
+ "X-Content-Type-Options": "nosniff",
+ },
+ )
+ except HTTPException:
+ raise
+ except Exception as exc:
+ logging.getLogger(__name__).error(f"Failed to generate plot: {exc}", exc_info=True)
+ raise HTTPException(
+ status_code=500,
+ detail="An unexpected error occurred while generating the plot.",
+ ) from exc
diff --git a/server/app/routes/utils/plot_helpers.py b/server/app/routes/utils/plot_helpers.py
new file mode 100644
index 0000000..9b6cd01
--- /dev/null
+++ b/server/app/routes/utils/plot_helpers.py
@@ -0,0 +1,84 @@
+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.
+ _PLOT_OBJECTS[plot_id] = obj
+
+
+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:
+ # Captures the currently active matplotlib figure as SVG bytes.
+ fig = plt.gcf()
+ if fig is None:
+ return None
+ if not fig.axes:
+ return None
+ svg_buf = io.BytesIO()
+ fig.savefig(svg_buf, format="svg", bbox_inches="tight")
+ svg_buf.seek(0)
+ return svg_buf.getvalue()
+
+
+def generate_geometry_plot_svg(plot_id: str = "default") -> bytes | None:
+ # Generates SVG bytes for the plot ID, or None if no source is available.
+ try:
+ cached_svg = _PLOT_SVG_BYTES.get(plot_id)
+ if cached_svg:
+ return cached_svg
+
+ # Prefer rendering from the latest cached backend object.
+ cached_obj = _PLOT_OBJECTS.get(plot_id)
+ if cached_obj is not None and hasattr(cached_obj, "plot_geometry"):
+ with contextlib.redirect_stdout(io.StringIO()):
+ cached_obj.plot_geometry()
+ svg_bytes = getattr(cached_obj, "_last_geometry_svg_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
+ finally:
+ plt.close("all")
diff --git a/server/app/schemas.py b/server/app/schemas.py
new file mode 100644
index 0000000..6237aad
--- /dev/null
+++ b/server/app/schemas.py
@@ -0,0 +1,257 @@
+from typing import Annotated, Literal
+
+from fastapi import Path
+from pydantic import BaseModel, ConfigDict, Field, confloat, field_validator, model_validator
+
+PlotId = Annotated[str, Path(description="Plot identifier (e.g. 'labyrinth' or 'optimize-abc123')")]
+
+
+class LabyrinthRequest(BaseModel):
+ bottom_level: float = Field(..., description="Bottom height [m] (bed level can be negative)")
+ downstream_water_level: float = Field(..., description="Downstream water level [m]")
+ discharge: confloat(gt=0) = Field(..., description="Discharge [m³/s]")
+ labyrinth_width: confloat(gt=0) = Field(..., description="Labyrinth weir width [m]")
+ labyrinth_height: confloat(gt=0) = Field(..., description="Labyrinth weir height [m]")
+ labyrinth_length: confloat(gt=0) = Field(..., description="Labyrinth weir length in flow direction [m]")
+ labyrinth_key_angle: confloat(gt=0) = Field(..., description="Key angle [degree]")
+ D: float = Field(0.5, description="Front wall width [m]")
+ t: float = Field(0.3, description="Key wall thickness [m]")
+
+ model_config = ConfigDict(
+ json_schema_extra={
+ "example": {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.09,
+ "discharge": 10.0,
+ "labyrinth_width": 15.0,
+ "labyrinth_height": 2.2,
+ "labyrinth_length": 8.0,
+ "labyrinth_key_angle": 8.0,
+ "D": 0.5,
+ "t": 0.3,
+ }
+ }
+ )
+
+
+class LabyrinthOptimizeRequest(BaseModel):
+ bottom_level: float = Field(..., description="Bottom height [m]")
+ downstream_water_level: float = Field(..., description="Downstream water level at design discharge [m]")
+ discharge: confloat(gt=0) = Field(..., description="Design discharge [m³/s]")
+ labyrinth_width: confloat(gt=0) = Field(..., description="Available width for the labyrinth weir [m]")
+ labyrinth_height: confloat(gt=0) = Field(..., description="Available crest height (design upstream level) [m]")
+ labyrinth_length_max: confloat(gt=0) = Field(..., description="Available length in flow direction [m]")
+ D: float = Field(0.5, description="Front wall width used for optimization [m]")
+ t: float = Field(
+ 0.3,
+ description=("Optional: T is not a parameter for the optimization itself but is required for generating an output geometry (STL). "),
+ )
+
+ model_config = ConfigDict(
+ json_schema_extra={
+ "example": {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.8,
+ "discharge": 20.0,
+ "labyrinth_width": 10.0,
+ "labyrinth_height": 2.2,
+ "labyrinth_length_max": 8.0,
+ "D": 0.5,
+ "t": 0.3,
+ }
+ }
+ )
+
+
+class FlapGateRequest(BaseModel):
+ bottom_level: float = Field(..., description="Bottom height at flap gate [m]")
+ downstream_water_level: float = Field(..., description="Downstream water level [m]")
+ discharge: confloat(gt=0) = Field(..., description="Discharge through flap gate [m³/s]")
+ flap_gate_width: confloat(gt=0) = Field(..., description="Flap width [m]")
+ flap_gate_height: confloat(gt=0) = Field(..., description="Flap height [m]")
+ flap_gate_angle: float = Field(..., description="Flap angle [degree]")
+
+ model_config = ConfigDict(
+ json_schema_extra={
+ "example": {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.09,
+ "discharge": 10.0,
+ "flap_gate_width": 1.4,
+ "flap_gate_height": 2.35,
+ "flap_gate_angle": 74.0,
+ }
+ }
+ )
+
+ @field_validator("flap_gate_angle")
+ def flap_gate_angle_range(cls, value):
+ if not 0 <= value <= 90:
+ raise ValueError("Klappenwinkel β muss im Bereich 0° ≤ β ≤ 90° liegen.")
+ return value
+
+
+class OperationalModelRequest(BaseModel):
+ bottom_level: float = Field(..., description="Bottom height [m]")
+ downstream_water_level: float = Field(..., description="Downstream water level [m]")
+ discharge: confloat(gt=0) = Field(..., description="Reference discharge [m³/s]")
+ labyrinth_width: confloat(gt=0) = Field(..., description="Labyrinth weir width [m]")
+ labyrinth_height: confloat(gt=0) = Field(..., description="Labyrinth weir height [m]")
+ labyrinth_length: confloat(gt=0) = Field(..., description="Labyrinth weir length in flow direction [m]")
+ labyrinth_key_angle: confloat(gt=0) = Field(..., description="Key angle [degree]")
+ D: float = Field(0.5, description="Front wall width [m]")
+ discharge_vector: list[confloat(gt=0)] = Field(..., max_length=500, description="Discharge vector [m³/s]")
+ downstream_water_level_vector: list[float] = Field(..., max_length=500, description="Downstream water level vector [m]")
+ interpolation_method: Literal["exponential", "linear", "quadratic", "cubic"] = Field(
+ "exponential",
+ description="Interpolation method for hydrograph data ('exponential', 'linear', 'quadratic', 'cubic')",
+ )
+ interpolation_stepsize: float = Field(
+ 1,
+ gt=0,
+ le=100,
+ description="Step size for interpolating discharge range [m³/s].",
+ )
+ 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 | 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.",
+ )
+
+ model_config = ConfigDict(
+ json_schema_extra={
+ "example": {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.8,
+ "discharge": 20.0,
+ "labyrinth_width": 10.0,
+ "labyrinth_height": 2.1,
+ "labyrinth_length": 7.7,
+ "labyrinth_key_angle": 7.0,
+ "D": 0.5,
+ "discharge_vector": [2.09, 2.79, 6.01, 11.9, 13.9, 16.3, 16.5, 18.6, 20.5, 22.9, 24.5],
+ "downstream_water_level_vector": [1.07, 1.15, 1.19, 1.25, 1.38, 1.39, 1.74, 1.74, 1.94, 2.67, 2.67],
+ "interpolation_method": "exponential",
+ "flap_gate_bottom_level": 0.1,
+ "flap_gate_downstream_water_level": 1.09,
+ "flap_gate_discharge": 10.0,
+ "flap_gate_width": 1.4,
+ "flap_gate_height": 2.35,
+ "flap_gate_angle": 74.0,
+ "include_flap_gate": True,
+ "interpolation_stepsize": 1,
+ "design_upstream_water_level": 2.2,
+ "max_flap_gate_angle": 90.0,
+ "fish_body_height": 0.4,
+ }
+ }
+ )
+
+ @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(self):
+ if not self.include_flap_gate:
+ return self
+ 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(self, name) is None]
+ if missing:
+ raise ValueError(f"Flap gate fields required when include_flap_gate is true: {missing}")
+ return self
+
+
+class LabyrinthResult(BaseModel):
+ N: int = Field(..., description="Number of keys")
+ L: float = Field(..., description="Total developed crest length L [m]")
+ w: float = Field(..., description="Width of a single key [m]")
+ l: float = Field(..., description="Length of the inclined side wall [m]")
+ S: float = Field(..., description="Remaining straight crest length S [m]")
+ Hu: float = Field(..., description="Upstream head Hu [m]")
+ hu: float = Field(..., description="Water level above crest hu [m]")
+ yu: float = Field(..., description="Upstream water level yu [m a.s.l.]")
+ Cd: float = Field(..., description="Discharge coefficient Cd [-]")
+ v: float = Field(..., description="Velocity v [m/s]")
+ hd: float = Field(..., description="Tailwater above crest hd [m]")
+ Hd: float = Field(..., description="Specific energy in tailwater Hd [m]")
+ rs: str = Field(..., description="Backwater status message")
+ warnings: list[str] | None = Field(None, description="Warnings, if any")
+
+
+class LabyrinthOptimizeResult(BaseModel):
+ B_best: float = Field(..., description="Optimal key length B [m]")
+ D_best: float = Field(..., description="Optimal front wall width D [m]")
+ Angle_best: float = Field(..., description="Optimal key angle alpha [°]")
+ N_best: int = Field(..., description="Optimal number of keys")
+ w_best: float = Field(..., description="Optimal key width w [m]")
+ l_best: float = Field(..., description="Optimal side wall length l [m]")
+ S_best: float = Field(..., description="Optimal straight crest portion S [m]")
+ L_best: float = Field(..., description="Optimal total crest length L [m]")
+ Hu_best: float = Field(..., description="Minimum upstream head Hu_min [m]")
+ Cd_best: float = Field(..., description="Optimal discharge coefficient Cd")
+ v_best: float = Field(..., description="Optimal velocity v [m/s]")
+ warnings: list[str] | None = Field(None, description="List of warnings generated during optimization")
+
+
+class FlapGateResult(BaseModel):
+ P_neu: float = Field(..., description="Effective sill height P_neu [m]")
+ mu: float = Field(..., description="Discharge coefficient mu [-]")
+ mu_ratio: float = Field(..., description="Ratio of discharge coefficients mu_ratio [-]")
+ hu: float = Field(..., description="Upstream water level above crest hu [m]")
+ yu: float = Field(..., description="Upstream water level yu [m a.s.l.]")
+ hd: float = Field(..., description="Tailwater level above crest hd [m]")
+ v: float = Field(..., description="Velocity v [m/s]")
+ vd: float = Field(..., description="Tailwater velocity vd [m/s]")
+ beschleunigung: float = Field(..., description="Acceleration along the gate [m/(s·m)]")
+ h_gr: float = Field(..., description="Critical depth h_gr [m]")
+ v_gr: float = Field(..., description="Critical velocity v_gr [m/s]")
+ warnings: list[str] | None = Field(None, description="Warnings, if any")
+
+
+class OperationalModelResult(BaseModel):
+ results: list["OperationalPoint"] = Field(..., description="Computed series over full discharge range.")
+ results_events: list["OperationalPoint"] = Field(..., description="Interpolated results for the input discharge events.")
+ warnings: list[str] | None = Field(None, description="Warnings generated during computation")
+
+
+class OperationalPoint(BaseModel):
+ discharge: float = Field(..., description="Discharge Q [m³/s]")
+ downstream_water_level: float = Field(..., description="Downstream water level UW [m]")
+ upstream_water_level: float = Field(..., description="Upstream water level OW [m]")
+ 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 | 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.",
+ )
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..2f4ec2c
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+# Test package for ENGINEER API
diff --git a/tests/test_flapgate.py b/tests/test_flapgate.py
new file mode 100644
index 0000000..c2ec475
--- /dev/null
+++ b/tests/test_flapgate.py
@@ -0,0 +1,176 @@
+import os
+
+import pytest
+from fastapi.testclient import TestClient
+
+os.environ["SERVER_MODE"] = "1"
+
+from engineer import FlapGate
+from server.app.main import app
+
+
+class TestFlapGateClass:
+ def test_flapgate_initialization_and_computation(self):
+ flapgate = FlapGate(
+ bottom_level=0.1,
+ downstream_water_level=1.09,
+ discharge=10.0,
+ flap_gate_width=1.4,
+ flap_gate_height=2.35,
+ flap_gate_angle=74.0,
+ )
+
+ # Check that input parameters are set
+ assert flapgate.Sh == 0.1
+ assert flapgate.UW == 1.09
+ assert flapgate.Q == 10.0
+ assert flapgate.KW == 1.4
+ assert flapgate.KP == 2.35
+ assert flapgate.Kalpha == 74.0
+
+ # Check that computed values are set
+ assert hasattr(flapgate, "P_neu")
+ assert hasattr(flapgate, "mu") # Discharge coefficient for flap gate
+ assert hasattr(flapgate, "mu_ratio")
+ assert hasattr(flapgate, "hu")
+ assert hasattr(flapgate, "yu")
+ assert hasattr(flapgate, "hd")
+ assert hasattr(flapgate, "v")
+ assert hasattr(flapgate, "vd")
+ assert hasattr(flapgate, "beschleunigung")
+ assert hasattr(flapgate, "h_gr")
+ assert hasattr(flapgate, "v_gr")
+
+ # Check that computed values are reasonable
+ assert flapgate.P_neu > 0
+ assert flapgate.mu > 0
+ assert flapgate.hu > 0
+ assert flapgate.v > 0
+
+ def test_flapgate_with_missing_parameters(self):
+ flapgate = FlapGate(
+ bottom_level=0.1,
+ discharge=10.0,
+ # downstream_water_level is missing
+ )
+
+ # Check that some attributes are set and some are None
+ assert flapgate.Sh == 0.1
+ assert flapgate.UW is None
+ assert flapgate.Q == 10.0
+
+ # Other attributes should not be computed yet
+ assert not hasattr(flapgate, "mu")
+
+ # Now update with remaining parameters
+ flapgate.UW = 1.09
+ flapgate.KW = 1.4
+ flapgate.KP = 2.35
+ flapgate.Kalpha = 74.0
+ flapgate.update()
+
+ # Now computed values should exist
+ assert hasattr(flapgate, "mu")
+
+ def test_flapgate_input_validation_errors(self):
+ # Test with invalid height (KP < 0.3)
+ flapgate_small_height = FlapGate(
+ bottom_level=0.1,
+ downstream_water_level=1.09,
+ discharge=10.0,
+ flap_gate_width=1.4,
+ flap_gate_height=0.2, # Too small
+ flap_gate_angle=74.0,
+ show_errors=True,
+ )
+ flapgate_small_height.check_for_error()
+ assert "außerhalb" in flapgate_small_height.ce
+
+ # Test with extremely small discharge
+ flapgate_small_discharge = FlapGate(
+ bottom_level=0.1,
+ downstream_water_level=1.09,
+ discharge=0.001, # Very small
+ flap_gate_width=1.4,
+ flap_gate_height=2.35,
+ flap_gate_angle=74.0,
+ show_errors=True,
+ )
+ flapgate_small_discharge.check_for_error()
+ # Should trigger error due to hu being out of range
+
+
+class TestFlapGateAPI:
+ @pytest.fixture
+ def client(self):
+ return TestClient(app)
+
+ def test_compute_flapgate_endpoint(self, client):
+ request_data = {"bottom_level": 0.1, "downstream_water_level": 1.09, "discharge": 10.0, "flap_gate_width": 1.4, "flap_gate_height": 2.35, "flap_gate_angle": 74.0}
+
+ response = client.post("/flap/compute", json=request_data)
+ assert response.status_code == 200
+
+ data = response.json()
+ expected_fields = ["P_neu", "mu", "mu_ratio", "hu", "yu", "hd", "v", "vd", "beschleunigung", "h_gr", "v_gr", "warnings"]
+
+ for field in expected_fields:
+ assert field in data
+
+ assert data["P_neu"] > 0
+ assert data["mu"] > 0
+ assert data["hu"] > 0
+ assert data["v"] > 0
+
+ def test_compute_flapgate_endpoint_with_warnings(self, client):
+ request_data = {
+ "bottom_level": -10.0, # Invalid: negative (generates warning)
+ "downstream_water_level": 1.09,
+ "discharge": 10.0,
+ "flap_gate_width": 1.4,
+ "flap_gate_height": 2.35,
+ "flap_gate_angle": 74.0,
+ }
+
+ response = client.post("/flap/compute", json=request_data)
+ assert response.status_code == 200 # Should succeed but with warnings
+
+ data = response.json()
+ assert "warnings" in data
+ assert data["warnings"] is not None
+ assert len(data["warnings"]) > 0
+ assert any("SohleHoehe Wert ist negativ." in warning for warning in data["warnings"])
+
+ def test_compute_flapgate_endpoint_invalid_input(self, client):
+ request_data = {
+ "bottom_level": 2.0,
+ "downstream_water_level": 1.0, # Invalid: below bottom level (UW - Sh <= 0)
+ "discharge": 10.0,
+ "flap_gate_width": 1.4,
+ "flap_gate_height": 2.35,
+ "flap_gate_angle": 74.0,
+ }
+
+ response = client.post("/flap/compute", json=request_data)
+ assert response.status_code == 200
+
+ data = response.json()
+ assert "hd" in data
+ assert data["hd"] <= 0
+
+ def test_compute_flapgate_endpoint_missing_required_param(self, client):
+ request_data = {
+ # "bottom_level" is missing
+ "downstream_water_level": 1.09,
+ "discharge": 10.0,
+ "flap_gate_width": 1.4,
+ "flap_gate_height": 2.35,
+ "flap_gate_angle": 74.0,
+ }
+
+ response = client.post("/flap/compute", json=request_data)
+ assert response.status_code == 422
+
+ data = response.json()
+ assert "detail" in data
+ assert any("bottom_level" in str(error) and "missing" in str(error).lower() for error in data["detail"])
diff --git a/tests/test_labyrinth.py b/tests/test_labyrinth.py
new file mode 100644
index 0000000..4f2e4c4
--- /dev/null
+++ b/tests/test_labyrinth.py
@@ -0,0 +1,338 @@
+import os
+
+import pytest
+from fastapi.testclient import TestClient
+
+os.environ["SERVER_MODE"] = "1"
+
+from engineer import Labyrinth, optimize_labyrinth_geometry
+from server.app.main import app
+
+
+class TestLabyrinthClass:
+ def test_labyrinth_initialization_and_computation(self):
+ labyrinth = Labyrinth(
+ bottom_level=0.1,
+ downstream_water_level=1.09,
+ discharge=10.0,
+ labyrinth_width=15.0,
+ labyrinth_height=2.2,
+ labyrinth_length=8.0,
+ labyrinth_key_angle=8.0,
+ D=0.5,
+ t=0.3,
+ show_errors=False,
+ show_geometry=False,
+ show_results=False,
+ )
+
+ # Check that input parameters are set
+ assert labyrinth.Sh == 0.1
+ assert labyrinth.UW == 1.09
+ assert labyrinth.Q == 10.0
+ assert labyrinth.W == 15.0
+ assert labyrinth.B == 8.0
+ assert labyrinth.P == 2.2
+ assert labyrinth.alpha == 8.0
+
+ # Check that computed values are set
+ assert hasattr(labyrinth, "N") # Number of cycles
+ assert hasattr(labyrinth, "Cd") # Discharge coefficient
+ assert hasattr(labyrinth, "v") # Velocity
+ assert hasattr(labyrinth, "hd") # Hydraulic head
+ assert hasattr(labyrinth, "Hu") # Upstream head
+ assert hasattr(labyrinth, "hu") # Upstream water depth
+ assert hasattr(labyrinth, "yu") # Upstream specific energy
+
+ assert labyrinth.Cd > 0 # Discharge coefficient should be positive
+ assert labyrinth.v > 0 # Velocity should be positive
+
+ def test_labyrinth_with_missing_parameters(self):
+ labyrinth = Labyrinth(
+ bottom_level=0.1,
+ discharge=10.0,
+ labyrinth_width=15.0,
+ labyrinth_height=2.2,
+ labyrinth_length=8.0,
+ labyrinth_key_angle=8.0,
+ # downstream_water_level is missing
+ )
+
+ assert labyrinth.Sh == 0.1
+ assert labyrinth.UW is None
+ assert labyrinth.Q == 10.0
+
+
+class TestLabyrinthOptimization:
+ def test_optimize_labyrinth_geometry(self):
+ best_labyrinth = optimize_labyrinth_geometry(
+ labyrinth=Labyrinth,
+ sohleHoehe=0.1,
+ UW=1.8,
+ Q=20.0,
+ labyrinthBreite=10.0,
+ labyrinthHoehe=2.2,
+ labyrinthLaengeMax=8.0,
+ D=0.5,
+ path="",
+ show_results=False,
+ show_plot=False,
+ )
+
+ assert hasattr(best_labyrinth, "B") # Optimized length
+ assert hasattr(best_labyrinth, "alpha") # Optimized angle
+ assert hasattr(best_labyrinth, "N") # Number of cycles
+ assert hasattr(best_labyrinth, "Cd") # Discharge coefficient
+ assert hasattr(best_labyrinth, "v") # Velocity
+
+ # Basic sanity checks
+ assert best_labyrinth.B > 0 # Length should be positive
+ assert 6 <= best_labyrinth.alpha <= 36 # Angle should be in expected range
+ assert best_labyrinth.Cd > 0 # Discharge coefficient should be positive
+
+
+class TestLabyrinthAPI:
+ @pytest.fixture
+ def client(self):
+ return TestClient(app)
+
+ def test_compute_labyrinth_endpoint(self, client):
+ request_data = {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.09,
+ "discharge": 10.0,
+ "labyrinth_width": 15.0,
+ "labyrinth_height": 2.2,
+ "labyrinth_length": 8.0,
+ "labyrinth_key_angle": 8.0,
+ "D": 0.5,
+ "t": 0.3,
+ }
+
+ response = client.post("/labyrinth/compute", json=request_data)
+ assert response.status_code == 200
+
+ data = response.json()
+ expected_fields = ["N", "L", "w", "l", "S", "Hu", "hu", "yu", "Cd", "v", "hd", "Hd", "rs"]
+
+ for field in expected_fields:
+ assert field in data
+
+ assert data["Cd"] > 0 # Discharge coefficient should be positive
+ assert data["v"] > 0 # Velocity should be positive
+
+ def test_compute_labyrinth_endpoint_with_warnings(self, client):
+ request_data = {
+ "bottom_level": -10.0, # Invalid: negative (generates warning)
+ "downstream_water_level": 1.09,
+ "discharge": 10.0,
+ "labyrinth_width": 15.0,
+ "labyrinth_height": 2.2,
+ "labyrinth_length": 8.0,
+ "labyrinth_key_angle": 8.0,
+ }
+
+ response = client.post("/labyrinth/compute", json=request_data)
+ assert response.status_code == 200 # Should succeed but with warnings
+
+ data = response.json()
+ assert "warnings" in data
+ assert data["warnings"] is not None
+ assert len(data["warnings"]) > 0
+ assert any("SohleHoehe Wert ist negativ." in warning for warning in data["warnings"])
+
+ def test_compute_labyrinth_endpoint_invalid_input(self, client):
+ # Provoke ENGINEER input validation error via invalid geometry parameter
+ request_data = {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.09,
+ "discharge": 10.0,
+ "labyrinth_width": 15.0,
+ "labyrinth_height": 2.2,
+ "labyrinth_length": 8.0,
+ "labyrinth_key_angle": 8.0,
+ "D": 0.0, # invalid: front wall width must be > 0
+ "t": 0.3,
+ }
+
+ response = client.post("/labyrinth/compute", json=request_data)
+ assert response.status_code == 422 # Unprocessable Entity
+
+ data = response.json()
+ assert "detail" in data
+ assert "message" in data["detail"]
+ assert "errors" in data["detail"]
+ # Ensure the ENGINEER error mentions the offending parameter
+ assert any("D Wert" in err for err in data["detail"]["errors"])
+
+ def test_compute_labyrinth_endpoint_missing_required_param(self, client):
+ request_data = {
+ # "bottom_level" is missing
+ "downstream_water_level": 1.09,
+ "discharge": 10.0,
+ "labyrinth_width": 15.0,
+ "labyrinth_height": 2.2,
+ "labyrinth_length": 8.0,
+ "labyrinth_key_angle": 8.0,
+ }
+
+ response = client.post("/labyrinth/compute", json=request_data)
+ assert response.status_code == 422
+
+ data = response.json()
+ assert "detail" in data
+ assert any("bottom_level" in str(error) and "missing" in str(error).lower() for error in data["detail"])
+
+ def test_download_labyrinth_stl_endpoint(self, client):
+ request_data = {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.09,
+ "discharge": 10.0,
+ "labyrinth_width": 15.0,
+ "labyrinth_height": 2.2,
+ "labyrinth_length": 8.0,
+ "labyrinth_key_angle": 8.0,
+ "D": 0.5,
+ "t": 0.3,
+ }
+
+ response = client.post("/labyrinth/stl", json=request_data)
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/octet-stream")
+ assert "attachment;" in response.headers["content-disposition"]
+ assert len(response.content) > 0
+
+ def test_download_labyrinth_stl_endpoint_invalid_input(self, client):
+ request_data = {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.09,
+ "discharge": 10.0,
+ "labyrinth_width": 2.0,
+ "labyrinth_height": 2.2,
+ "labyrinth_length": 8.0,
+ "labyrinth_key_angle": 8.0,
+ "D": 0.5,
+ "t": 0.3,
+ }
+
+ response = client.post("/labyrinth/stl", json=request_data)
+ assert response.status_code == 422
+
+ data = response.json()
+ assert "detail" in data
+ assert "stl generation failed" in data["detail"].lower()
+
+ def test_optimize_labyrinth_endpoint(self, client):
+ request_data = {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.8,
+ "discharge": 20.0,
+ "labyrinth_width": 10.0,
+ "labyrinth_height": 2.2,
+ "labyrinth_length_max": 8.0,
+ }
+
+ response = client.post("/labyrinth/optimize", json=request_data)
+ assert response.status_code == 200
+
+ data = response.json()
+ expected_fields = ["B_best", "D_best", "Angle_best", "N_best", "w_best", "l_best", "S_best", "L_best", "Hu_best", "Cd_best", "v_best"]
+
+ for field in expected_fields:
+ assert field in data
+
+ assert data["B_best"] > 0 # Length should be positive
+ assert 6 <= data["Angle_best"] <= 36 # Angle should be in expected range
+ assert data["Cd_best"] > 0 # Discharge coefficient should be positive
+ assert "warnings" in data
+
+ def test_optimize_labyrinth_endpoint_with_warnings(self, client):
+ request_data = {
+ "bottom_level": -5.0, # Invalid: negative (generates warning)
+ "downstream_water_level": 1.8,
+ "discharge": 20.0,
+ "labyrinth_width": 10.0,
+ "labyrinth_height": 2.2,
+ "labyrinth_length_max": 8.0,
+ }
+
+ response = client.post("/labyrinth/optimize", json=request_data)
+ assert response.status_code == 200 # Should succeed but with warnings
+
+ data = response.json()
+ assert "warnings" in data
+ assert data["warnings"] is not None
+ assert len(data["warnings"]) > 0
+ assert any("SohleHoehe Wert ist negativ." in warning for warning in data["warnings"])
+
+ def test_optimize_labyrinth_endpoint_invalid_input(self, client):
+ # Provoke ENGINEER input validation error during optimization via D=0
+ request_data = {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.8,
+ "discharge": 20.0,
+ "labyrinth_width": 10.0,
+ "labyrinth_height": 2.2,
+ "labyrinth_length_max": 8.0,
+ "D": 0.0,
+ }
+
+ response = client.post("/labyrinth/optimize", json=request_data)
+ assert response.status_code == 422 # Unprocessable Entity
+
+ data = response.json()
+ assert "detail" in data
+ assert "message" in data["detail"]
+ assert "errors" in data["detail"]
+ assert any("D Wert" in err for err in data["detail"]["errors"])
+
+ def test_optimize_labyrinth_endpoint_missing_required_param(self, client):
+ request_data = {
+ # "bottom_level" is missing
+ "downstream_water_level": 1.8,
+ "discharge": 20.0,
+ "labyrinth_width": 10.0,
+ "labyrinth_height": 2.2,
+ "labyrinth_length_max": 8.0,
+ }
+
+ response = client.post("/labyrinth/optimize", json=request_data)
+ assert response.status_code == 422
+
+ data = response.json()
+ assert "detail" in data
+ assert any("bottom_level" in str(error) and "missing" in str(error).lower() for error in data["detail"])
+
+ def test_download_optimized_labyrinth_stl_endpoint(self, client):
+ request_data = {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.8,
+ "discharge": 20.0,
+ "labyrinth_width": 10.0,
+ "labyrinth_height": 2.2,
+ "labyrinth_length_max": 8.0,
+ "D": 0.5,
+ "t": 0.3,
+ }
+
+ response = client.post("/labyrinth/optimize/stl", json=request_data)
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/octet-stream")
+ assert "attachment;" in response.headers["content-disposition"]
+ assert len(response.content) > 0
+
+ def test_download_optimized_labyrinth_stl_endpoint_uses_default_t(self, client):
+ request_data = {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.8,
+ "discharge": 20.0,
+ "labyrinth_width": 10.0,
+ "labyrinth_height": 2.2,
+ "labyrinth_length_max": 8.0,
+ "D": 0.5,
+ }
+
+ response = client.post("/labyrinth/optimize/stl", json=request_data)
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/octet-stream")
+ assert len(response.content) > 0
diff --git a/tests/test_operational.py b/tests/test_operational.py
new file mode 100644
index 0000000..d1e1f4b
--- /dev/null
+++ b/tests/test_operational.py
@@ -0,0 +1,360 @@
+import os
+
+import pytest
+from fastapi.testclient import TestClient
+
+os.environ["SERVER_MODE"] = "1"
+
+from server.app.main import app
+
+
+class TestOperationalAPI:
+ @pytest.fixture
+ def client(self):
+ return TestClient(app)
+
+ def test_compute_operational_endpoint(self, client):
+ request_data = {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.8,
+ "discharge": 20.0,
+ "labyrinth_width": 10.0,
+ "labyrinth_height": 2.1,
+ "labyrinth_length": 7.7,
+ "labyrinth_key_angle": 7.0,
+ "D": 0.5,
+ "discharge_vector": [2.09, 2.79, 6.01, 11.9, 13.9, 16.3, 16.5, 18.6, 20.5, 22.9, 24.5],
+ "downstream_water_level_vector": [1.07, 1.15, 1.19, 1.25, 1.38, 1.39, 1.74, 1.74, 1.94, 2.67, 2.67],
+ "interpolation_method": "exponential",
+ "flap_gate_bottom_level": 0.1,
+ "flap_gate_downstream_water_level": 1.09,
+ "flap_gate_discharge": 10.0,
+ "flap_gate_width": 1.4,
+ "flap_gate_height": 2.35,
+ "flap_gate_angle": 74.0,
+ "design_upstream_water_level": 2.2,
+ "max_flap_gate_angle": 90.0,
+ "fish_body_height": 0.4,
+ "include_flap_gate": True,
+ }
+
+ response = client.post("/operational", json=request_data)
+ assert response.status_code == 200
+
+ data = response.json()
+ # Check that response contains expected fields
+ assert "results" in data
+ assert "results_events" in data
+ assert "warnings" in data
+
+ # Check that results contain expected fields
+ assert len(data["results"]) > 0
+ assert len(data["results_events"]) > 0
+
+ # Check structure of a result point
+ first_result = data["results"][0]
+ expected_fields = [
+ "discharge",
+ "downstream_water_level",
+ "upstream_water_level",
+ "labyrinth_discharge",
+ "flap_gate_discharge",
+ "flap_gate_angle",
+ "labyrinth_head_over_crest",
+ "flap_gate_head_over_crest",
+ ]
+
+ for field in expected_fields:
+ assert field in first_result
+
+ assert first_result["labyrinth_head_over_crest"] is not None
+
+ # Basic sanity checks
+ assert first_result["discharge"] > 0
+ assert first_result["labyrinth_discharge"] >= 0
+ assert first_result["flap_gate_discharge"] >= 0
+
+ def test_compute_operational_endpoint_with_warnings(self, client):
+ request_data = {
+ "bottom_level": -10.0, # Invalid: negative (generates warning)
+ "downstream_water_level": 1.8,
+ "discharge": 20.0,
+ "labyrinth_width": 10.0,
+ "labyrinth_height": 2.1,
+ "labyrinth_length": 7.7,
+ "labyrinth_key_angle": 7.0,
+ "D": 0.5,
+ "discharge_vector": [2.09, 2.79, 6.01],
+ "downstream_water_level_vector": [1.07, 1.15, 1.19],
+ "interpolation_method": "exponential",
+ "flap_gate_bottom_level": 0.1,
+ "flap_gate_downstream_water_level": 1.09,
+ "flap_gate_discharge": 10.0,
+ "flap_gate_width": 1.4,
+ "flap_gate_height": 2.35,
+ "flap_gate_angle": 74.0,
+ "design_upstream_water_level": 2.2,
+ "max_flap_gate_angle": 90.0,
+ "fish_body_height": 0.4,
+ "include_flap_gate": True,
+ }
+
+ response = client.post("/operational", json=request_data)
+ assert response.status_code == 200 # Should succeed but with warnings
+
+ data = response.json()
+ assert "warnings" in data
+ assert data["warnings"] is not None
+ assert len(data["warnings"]) > 0
+ assert any("SohleHoehe Wert ist negativ." in warning for warning in data["warnings"])
+
+ def test_compute_operational_endpoint_invalid_input(self, client):
+ # Provoke ENGINEER input validation error via invalid geometry parameter (D=0)
+ request_data = {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.8,
+ "discharge": 20.0,
+ "labyrinth_width": 10.0,
+ "labyrinth_height": 2.1,
+ "labyrinth_length": 7.7,
+ "labyrinth_key_angle": 7.0,
+ "D": 0.0,
+ "discharge_vector": [2.09, 2.79, 6.01],
+ "downstream_water_level_vector": [1.07, 1.15, 1.19],
+ "interpolation_method": "exponential",
+ "flap_gate_bottom_level": 0.1,
+ "flap_gate_downstream_water_level": 1.09,
+ "flap_gate_discharge": 10.0,
+ "flap_gate_width": 1.4,
+ "flap_gate_height": 2.35,
+ "flap_gate_angle": 74.0,
+ "design_upstream_water_level": 2.2,
+ "max_flap_gate_angle": 90.0,
+ "fish_body_height": 0.4,
+ "include_flap_gate": True,
+ }
+
+ response = client.post("/operational", json=request_data)
+ assert response.status_code == 422 # Unprocessable Entity
+
+ data = response.json()
+ assert "detail" in data
+ if isinstance(data["detail"], dict):
+ assert "message" in data["detail"]
+ assert "errors" in data["detail"]
+
+ def test_compute_operational_endpoint_missing_required_param(self, client):
+ # Test that missing required parameters return 422 with validation error
+ request_data = {
+ # "bottom_level" is missing
+ "downstream_water_level": 1.8,
+ "discharge": 20.0,
+ "labyrinth_width": 10.0,
+ "labyrinth_height": 2.1,
+ "labyrinth_length": 7.7,
+ "labyrinth_key_angle": 7.0,
+ "D": 0.5,
+ "discharge_vector": [2.09, 2.79, 6.01],
+ "downstream_water_level_vector": [1.07, 1.15, 1.19],
+ "interpolation_method": "exponential",
+ "flap_gate_bottom_level": 0.1,
+ "flap_gate_downstream_water_level": 1.09,
+ "flap_gate_discharge": 10.0,
+ "flap_gate_width": 1.4,
+ "flap_gate_height": 2.35,
+ "flap_gate_angle": 74.0,
+ "design_upstream_water_level": 2.2,
+ "max_flap_gate_angle": 90.0,
+ "fish_body_height": 0.4,
+ "include_flap_gate": True,
+ }
+
+ response = client.post("/operational", json=request_data)
+ assert response.status_code == 422
+
+ data = response.json()
+ assert "detail" in data
+ assert any("bottom_level" in str(error) and "missing" in str(error).lower() for error in data["detail"])
+
+ def test_compute_operational_endpoint_flap_angle_range(self, client):
+ request_data = {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.8,
+ "discharge": 20.0,
+ "labyrinth_width": 10.0,
+ "labyrinth_height": 2.1,
+ "labyrinth_length": 7.7,
+ "labyrinth_key_angle": 7.0,
+ "D": 0.5,
+ "discharge_vector": [2.09, 2.79, 6.01],
+ "downstream_water_level_vector": [1.07, 1.15, 1.19],
+ "interpolation_method": "exponential",
+ "flap_gate_bottom_level": 0.1,
+ "flap_gate_downstream_water_level": 1.09,
+ "flap_gate_discharge": 10.0,
+ "flap_gate_width": 1.4,
+ "flap_gate_height": 2.35,
+ "flap_gate_angle": -5.0,
+ "design_upstream_water_level": 2.2,
+ "max_flap_gate_angle": 90.0,
+ "fish_body_height": 0.4,
+ }
+
+ response = client.post("/operational", json=request_data)
+ assert response.status_code == 422
+
+ data = response.json()
+ # support both pydantic error list and ENGINEER dict
+ if isinstance(data["detail"], dict):
+ errors = data["detail"]["errors"]
+ assert any("Klappenwinkel β" in err for err in errors)
+ else:
+ errors = data["detail"]
+ assert any("Klappenwinkel β" in err.get("msg", "") for err in errors)
+
+ def test_compute_operational_endpoint_max_flap_angle_range(self, client):
+ request_data = {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.8,
+ "discharge": 20.0,
+ "labyrinth_width": 10.0,
+ "labyrinth_height": 2.1,
+ "labyrinth_length": 7.7,
+ "labyrinth_key_angle": 7.0,
+ "D": 0.5,
+ "discharge_vector": [2.09, 2.79, 6.01],
+ "downstream_water_level_vector": [1.07, 1.15, 1.19],
+ "interpolation_method": "exponential",
+ "flap_gate_bottom_level": 0.1,
+ "flap_gate_downstream_water_level": 1.09,
+ "flap_gate_discharge": 10.0,
+ "flap_gate_width": 1.4,
+ "flap_gate_height": 2.35,
+ "flap_gate_angle": 74.0,
+ "design_upstream_water_level": 2.2,
+ "max_flap_gate_angle": 120.0,
+ "fish_body_height": 0.4,
+ "include_flap_gate": True,
+ }
+
+ response = client.post("/operational", json=request_data)
+ assert response.status_code == 422
+
+ data = response.json()
+ if isinstance(data["detail"], dict):
+ errors = data["detail"]["errors"]
+ assert any("Maximaler Klappenwinkel" in err for err in errors)
+ else:
+ errors = data["detail"]
+ assert any("Maximaler Klappenwinkel" in err.get("msg", "") for err in errors)
+
+ def test_compute_operational_endpoint_empty_vectors(self, client):
+ request_data = {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.8,
+ "discharge": 20.0,
+ "labyrinth_width": 10.0,
+ "labyrinth_height": 2.1,
+ "labyrinth_length": 7.7,
+ "labyrinth_key_angle": 7.0,
+ "D": 0.5,
+ "discharge_vector": [], # Empty vector
+ "downstream_water_level_vector": [1.07, 1.15, 1.19],
+ "interpolation_method": "exponential",
+ "flap_gate_bottom_level": 0.1,
+ "flap_gate_downstream_water_level": 1.09,
+ "flap_gate_discharge": 10.0,
+ "flap_gate_width": 1.4,
+ "flap_gate_height": 2.35,
+ "flap_gate_angle": 74.0,
+ "design_upstream_water_level": 2.2,
+ "max_flap_gate_angle": 90.0,
+ "fish_body_height": 0.4,
+ "include_flap_gate": True,
+ }
+
+ response = client.post("/operational", json=request_data)
+ assert response.status_code == 422 # Unprocessable Entity
+
+ data = response.json()
+ assert "detail" in data
+ if isinstance(data["detail"], dict):
+ assert "message" in data["detail"]
+ assert "errors" in data["detail"]
+ assert any("discharge_vector" in str(err).lower() or "empty" in str(err).lower() for err in data["detail"]["errors"])
+ else:
+ assert any("discharge_vector" in str(err).lower() or "empty" in str(err).lower() for err in data["detail"])
+
+ def test_compute_operational_endpoint_different_vector_lengths(self, client):
+ request_data = {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.8,
+ "discharge": 20.0,
+ "labyrinth_width": 10.0,
+ "labyrinth_height": 2.1,
+ "labyrinth_length": 7.7,
+ "labyrinth_key_angle": 7.0,
+ "D": 0.5,
+ "discharge_vector": [2.09, 2.79, 6.01, 11.9, 13.9], # 5 elements
+ "downstream_water_level_vector": [1.07, 1.15, 1.19], # 3 elements
+ "interpolation_method": "exponential",
+ "flap_gate_bottom_level": 0.1,
+ "flap_gate_downstream_water_level": 1.09,
+ "flap_gate_discharge": 10.0,
+ "flap_gate_width": 1.4,
+ "flap_gate_height": 2.35,
+ "flap_gate_angle": 74.0,
+ "design_upstream_water_level": 2.2,
+ "max_flap_gate_angle": 90.0,
+ "fish_body_height": 0.4,
+ "include_flap_gate": True,
+ }
+
+ response = client.post("/operational", json=request_data)
+ assert response.status_code == 422
+
+ data = response.json()
+ assert "detail" in data
+ if isinstance(data["detail"], dict):
+ assert "message" in data["detail"]
+ assert "errors" in data["detail"]
+ assert any("same length" in str(err).lower() or "length" in str(err).lower() for err in data["detail"]["errors"])
+ else:
+ assert any("same length" in str(err).lower() or "length" in str(err).lower() for err in data["detail"])
+
+ def test_compute_operational_endpoint_different_interpolation_methods(self, client):
+ """Test different interpolation methods"""
+ base_request = {
+ "bottom_level": 0.1,
+ "downstream_water_level": 1.8,
+ "discharge": 20.0,
+ "labyrinth_width": 10.0,
+ "labyrinth_height": 2.1,
+ "labyrinth_length": 7.7,
+ "labyrinth_key_angle": 7.0,
+ "D": 0.5,
+ "discharge_vector": [2.09, 2.79, 6.01, 11.9, 13.9],
+ "downstream_water_level_vector": [1.07, 1.15, 1.19, 1.25, 1.38],
+ "flap_gate_bottom_level": 0.1,
+ "flap_gate_downstream_water_level": 1.09,
+ "flap_gate_discharge": 10.0,
+ "flap_gate_width": 1.4,
+ "flap_gate_height": 2.35,
+ "flap_gate_angle": 74.0,
+ "design_upstream_water_level": 2.2,
+ "max_flap_gate_angle": 90.0,
+ "fish_body_height": 0.4,
+ "include_flap_gate": True,
+ }
+
+ # Test exponential interpolation (default)
+ request_data = {**base_request, "interpolation_method": "exponential"}
+ response = client.post("/operational", json=request_data)
+ assert response.status_code == 200
+ assert len(response.json()["results"]) > 0
+
+ # Test linear interpolation
+ request_data = {**base_request, "interpolation_method": "linear"}
+ response = client.post("/operational", json=request_data)
+ assert response.status_code == 200
+ assert len(response.json()["results"]) > 0