https://github.com/goldbarth/port-tidewatch
Water-level ingestion and storm-surge alerting service, modelled on Hamburg's WADI warning system. .NET ingestion pipeline with RabbitMQ, staged threshold evaluation, and a read-only Angular dashboard.
https://github.com/goldbarth/port-tidewatch
angular argocd aspnet-core azure-container-apps clean-architecture csharp dotnet event-driven kubernetes microservices observability opentelemetry rabbitmq testcontainers
Last synced: 6 days ago
JSON representation
Water-level ingestion and storm-surge alerting service, modelled on Hamburg's WADI warning system. .NET ingestion pipeline with RabbitMQ, staged threshold evaluation, and a read-only Angular dashboard.
- Host: GitHub
- URL: https://github.com/goldbarth/port-tidewatch
- Owner: goldbarth
- License: mit
- Created: 2026-06-07T13:12:29.000Z (17 days ago)
- Default Branch: main
- Last Pushed: 2026-06-15T11:34:48.000Z (9 days ago)
- Last Synced: 2026-06-15T11:49:20.638Z (9 days ago)
- Topics: angular, argocd, aspnet-core, azure-container-apps, clean-architecture, csharp, dotnet, event-driven, kubernetes, microservices, observability, opentelemetry, rabbitmq, testcontainers
- Language: C#
- Homepage: https://zealous-field-0d1829603.7.azurestaticapps.net/
- Size: 7 MB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 3
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
Tidewatch
Water-Level Ingestion & Storm-Surge Alerting
port-tidewatch
A small, focused ingestion service for port water-level telemetry, with
threshold-based storm-surge alerting and a read-only monitoring dashboard.

> π **Written up on my site:** [the project](https://www.goldbarth.dev/projects/port-tidewatch),
> and β in German, leicht verdaulich β [the surge-evaluator decision (ADR-004)](https://www.goldbarth.dev/decisions/surge-evaluator-decisions).
Live dashboard on real WSV/PEGELONLINE data β the public Hamburg Elbe gauges (St. Pauli, Zollenspieker, Over, Bunthaus), polled live.
---
The domain is modelled on the Hamburg storm-surge warning service (WADI):
a warning is raised when an expected surge peak can exceed **4.50 m above
sea level (NHN)** / 2.40 m above mean high water (MThw). tidewatch ingests
water-level readings β from the **live WSV/PEGELONLINE Elbe feed** (the German
Waterways and Shipping Administration's open gauge data) or a scripted simulator
β evaluates them against that threshold, and surfaces the result.
> **Scope is intentionally narrow:** one domain, one ingestion path, no write
> operations from the UI. The goal is a reliable, observable ingestion
> pipeline end to end.
---
## Why this project
The Hamburg port runs on reliable, observable, security-relevant
infrastructure. tidewatch works the ingestion-and-alerting pattern in a
domain I care about, and takes the next steps in my stack β Angular and
Kubernetes/GitOps β on real ground rather than in the abstract.
---
## What it does
- A reading-source host emits water-level readings for a set of gauges β
either a scripted simulator or the live PEGELONLINE Elbe feed, chosen by
config (same build, no recompile).
- An ingestion service consumes readings via RabbitMQ (with a dead-letter
path for poison messages), evaluates each reading against the WADI
threshold, and emits an alert state.
- A read-only Angular dashboard shows current levels, per-gauge alert status
(normal / warning / severe), and a short recent-history trend.
---
## Demo
### Scripted storm surge

Calm β every gauge normal, overall status normal.
Surge rising β CUX in warning (5.37 m), overall status warning.
Surge peak β CUX crosses severe (5.83 m), overall status severe.
βΆ Watch the ~60-second surge demo β the full normal β warning β severe β recede cascade.
**What you're seeing.** Each card is a gauge, plotted on a fixed **0β6 m NHN**
scale. The shaded bands *are* the WADI thresholds: **warning at 4.50 m** and
**severe at 5.50 m** (2.40 m over mean high water). One gauge β `CUX` β runs a
scripted storm surge and cascades `normal β warning β severe β recede`; the
others hold `normal` for contrast. The header summarises gauges per stage, the
highest current level, and overall status, while the live / last-updated
indicator makes stale data obvious β turning the threshold story into a single
glance.
---
## Architecture
```
βββββββββββββββββββββββββββββββ readings ββββββββββββββββββββ alerts / state βββββββββββββββ
β reading-source host (.NET) β βββββββββββββββΆ β ingestion serviceβ ββββββββββββββββββΆ β dashboard β
β one source, set by config: β RabbitMQ β (.NET) β REST (polling) β (Angular) β
β β’ simulator (scripted) β ββββββββββββββββββββ βββββββββββββββ
β β’ PEGELONLINE Elbe feed β β
βββββββββββββββββββββββββββββββ β poison messages
ReadingSource switch βΌ
ββββββββββββββββββββ
β dead-letter queueβ
ββββββββββββββββββββ
```
### Alert-state lifecycle
A gauge moves between stages as its evaluated level crosses the configured
thresholds (m above NHN). Stages are strictly ordered; the evaluator derives
them from the reading window, not from a single spike.
```
normal ββ(β₯ 4.50 m)βββΆ warning ββ(β₯ 5.50 m)βββΆ severe
β² β β
βββββββββββββββββββββββββ΄ββββββββββββββββββββββββ
(level falls back below stage)
```
Architecture decisions are recorded as ADRs under `docs/adrs/`. Open questions
are tracked there too, so the reasoning is visible even where the
implementation is not finished.
| # | Concern | Decision |
|-----|-------------------------------|---------------------------------------------------------------------------------------------------|
| 001 | Where threshold logic lives | [Threshold evaluation lives in the ingestion service](docs/adrs/001-threshold-logic-in-service.md) |
| 002 | Dashboard client state | [Angular state structure](docs/adrs/002-angular-state-structure.md) |
| 003 | Deploy target | [Azure Container Apps vs. Kubernetes + Argo CD](docs/adrs/003-container-apps-vs-kubernetes.md) |
| 004 | Stage-determination algorithm | [Surge evaluator algorithm](docs/adrs/004-surge-evaluator-algorithm.md) Β· [Blog (DE)](https://www.goldbarth.dev/decisions/surge-evaluator-decisions) |
---
## API
The ingestion service exposes a deliberately thin, read-only HTTP surface for
the dashboard. The reading consumer runs as a hosted service alongside it; all
state is live and in-memory.
| Method | Path | Description |
|--------|---------------|-----------------------------------------------------------------------------|
| `GET` | `/healthz` | Liveness probe β returns `ok`. |
| `GET` | `/api/gauges` | Snapshot of every gauge: current level, alert stage, and downsampled trend. |
`/api/gauges` returns one object per gauge:
```jsonc
{
"gaugeId": "st-pauli",
"level": 4.62, // metres above NHN, latest reading (null if none yet)
"stage": "warning", // normal | warning | severe
"changedAt": "2026-06-12T08:41:00Z",
"trend": [ // recent window, downsampled to β€ 24 points
{ "t": "2026-06-12T08:30:00Z", "v": 4.41 },
{ "t": "2026-06-12T08:35:00Z", "v": 4.58 }
],
"rateMetersPerMin": 0.04, // least-squares rate-of-change over the window (null if < 2 points)
"timeInStageSeconds": 180, // how long the gauge has held its current stage (null if none yet)
"windowMin": 4.38, // window extent β lowest reading (null if empty)
"windowMax": 4.71 // window extent β highest reading (null if empty)
}
```
> The derived signals (`rateMetersPerMin`, `timeInStageSeconds`, `windowMin` /
> `windowMax`) are computed in the API mapper, not held in state β the state
> holder stays raw (ADR-002).
---
## Tech Stack
| Concern | Technology |
|-------------------|-------------------------------------------|
| Service & API | .NET 10 / ASP.NET Core |
| Messaging | RabbitMQ |
| Observability | OpenTelemetry |
| Testing | xUnit Β· Testcontainers |
| Dashboard | Angular |
| Containerisation | Docker |
| Baseline deploy | Azure Container Apps |
| GitOps deploy | Kubernetes + Argo CD (final phase) |
---
## Out of scope (deliberately)
The narrow scope is a design choice, not a backlog. Kept out so the pipeline
stays the thing that gets done well:
- **No writes from the UI** β the dashboard is read-only by design (ADR-002).
- **No persistence layer** β gauge state is live, in-memory only; there is no
historical store. The point is the ingestion path, not a time-series database.
- **No auth on the API** β single-tenant showcase; the surface is two read-only
endpoints.
- **One domain, one ingestion path** β no multi-network federation, no
multi-tenancy. A real public gauge feed (PEGELONLINE) ships in v1.2 (M7),
selectable against the simulator via the `ReadingSource` switch; one source is
active per run (they are not run side by side).
- **Notification delivery** β `ApplyStageChange` now publishes alert events at
its single chokepoint (v1.1 / M6), but *acting* on them (email / push, a
notification consumer) stays out of scope; the showcase ends at the event.
---
## Status
Milestones M1βM7 are complete β **v1.0 is presentable end to end, v1.1 (demo
& polish) and v1.2 (real PEGELONLINE data) have landed.** M8 (v1.3) is the
remaining planned work. Every intermediate state is built to stay coherent β
see the roadmap.
## Roadmap
Built in milestones, each an intermediate state that stays coherent. The full
issue-by-issue breakdown lives in **[`docs/ISSUES.md`](docs/ISSUES.md)**.
| Milestone | Focus | Status |
|--------------------------------------------|-------|-------------|
| **M1 Β· Foundation** | Repo scaffold, contracts, threshold configuration | Done |
| **M2 Β· Ingestion** | RabbitMQ transport, consumer + dead-letter, per-gauge state, surge evaluator | Done |
| **M3 Β· Observability & Tests** | OpenTelemetry tracing, Testcontainers integration tests | Done |
| **M4 Β· Dashboard** | Angular read-only view β levels, status, trend | Done |
| **M5 Β· Deploy** | Kubernetes + Argo CD (GitOps, primary) and Azure Container Apps (IaC + CI) | Done |
| **M6 Β· Demo & polish (v1.1)** | Storm-surge scenario, dashboard polish, richer signals, demo assets, alert events | Done |
| **M7 Β· Real data (v1.2)** | Real PEGELONLINE Elbe feed alongside the simulator, source selection | Done |
| **M8 Β· Observability made visible (v1.3)** | Surface the OpenTelemetry path β latency pulse, Jaeger deep-link, optional trace waterfall | Planned |
> **M5 ordering:** Kubernetes + Argo CD is the primary deployment, run on a local
> cluster to stay at β¬0; Azure Container Apps ships as IaC + CI, deployed on demand.
> The reasoning is in [ADR-003](docs/adrs/003-container-apps-vs-kubernetes.md).
---
## Running it
| Stack | Runbook |
|-------|---------|
| Local dev (broker + ingestion + simulator + `ng serve`) | [runbook-local-dashboard.md](docs/runbook-local-dashboard.md) |
| Kubernetes + Argo CD (local kind, GitOps) | [runbook-k8s-argocd.md](docs/runbook-k8s-argocd.md) |
| Azure Container Apps (azd) | [runbook-container-apps.md](docs/runbook-container-apps.md) |
### Local surfaces
Once the local stack is up, these are the addresses you'll use:
| Surface | URL | Notes |
|---------|-----|-------|
| Dashboard (`ng serve`) | | Proxies `/api` to the service. |
| Ingestion API | | Read-only; also `/healthz`. |
| RabbitMQ management UI | | **Dev-only** β default `guest` / `guest`. |
### .NET solution commands
The solution is a `.slnx` β needs the **.NET 10 SDK**.
```bash
# Build / restore
dotnet build port-tidewatch.slnx
dotnet restore port-tidewatch.slnx
# Run the ingestion service (needs a reachable RabbitMQ β see appsettings RabbitMq section)
dotnet run --project src/Tidewatch.Ingestion
# Run the reading-source host (publishes Reading messages; RABBITMQ_HOST env var overrides host).
# Defaults to the scripted simulator; switch to the live Elbe feed with ReadingSource=Pegelonline.
dotnet run --project src/Tidewatch.Source
ReadingSource=Pegelonline dotnet run --project src/Tidewatch.Source
```
### Reading source: simulator vs. live feed
`Tidewatch.Source` runs exactly one reading source, chosen at startup by the
`ReadingSource` config switch (appsettings key or env var) β the same build serves
the scripted demo or the real feed without recompiling:
| `ReadingSource` | Source | Notes |
|-----------------|--------|-------|
| `Simulator` (default) | Scripted surge | One gauge runs warning β severe β recede; the rest stay normal. |
| `Pegelonline` | Live WSV/PEGELONLINE Elbe feed | Polls the configured Hamburg Elbe gauges; cm β m and PNP β NHN applied in an explicit mapping layer. |
A missing or unrecognised `ReadingSource` fails at startup (same fail-fast posture
as the threshold config). Both sources emit the identical `Reading` shape, so the
ingestion path cannot tell them apart.
The live feed covers four Hamburg Elbe gauges, configured by UUID under the
`Pegelonline` section: **St. Pauli**, **Bunthaus**, **Over**, **Zollenspieker**.
PEGELONLINE data is Datenlizenz Deutschland Zero 2.0 (free, no auth). HPA tidal
gauges expose no `gaugeZero`, so their PNP is set explicitly (Hamburg PNP =
NHN β5.00 m).
---
## Testing
Two .NET test projects plus the Angular suite. Integration tests stand up a
real broker via Testcontainers, so the message path is exercised end to end β
no mocked transport.
| Layer | Project / command | Scope |
|-------|-------------------|-------|
| Unit | `Tidewatch.Ingestion.UnitTests` | Surge-evaluator stage logic β thresholds, trend, outlier handling. |
| Integration | `Tidewatch.Ingestion.IntegrationTests` | Full consume path against a Testcontainers RabbitMQ, incl. the dead-letter route for poison messages. |
| Frontend | `npm test` (in `frontend/`) | Angular component / state tests. |
```bash
# All .NET tests (Docker daemon must be running β Testcontainers starts a RabbitMQ container)
dotnet test
# A single test or class
dotnet test --filter "FullyQualifiedName~SurgeEvaluatorTests"
```
---
## Effort accounting
This is an AI-assisted build, and I'd rather be transparent about it than coy.
v1.0 β the full pipeline (RabbitMQ transport with a dead-letter path, per-gauge
state, the surge evaluator, OpenTelemetry tracing, Testcontainers tests, the
Angular dashboard, and *both* a Kubernetes/Argo CD and an Azure Container Apps
deploy) β came together over roughly **five focused days**.
What got compressed was keystrokes: boilerplate, DTOs, wiring, test scaffolds.
What did **not** get compressed was the thinking. The architectural calls β where
threshold logic lives, the queue topology, the evaluator algorithm, the deploy
ordering β are mine, made deliberately and written down as ADRs before the code
followed. The AI is a fast pair of hands; the design ownership stayed with me.
That's the honest accounting, and it's why the ADRs exist.
---
## License
[MIT](LICENSE) Β© Felix Wahl