Demo¶
A scroll-through of the project. Each slide is a picture first, words second. If you just want the answer, the live UI is at rush-ui-jv7ddtssdq-oa.a.run.app.
Slide 1 · The question¶
flowchart TB
U([student in the office]) -->|"two clicks +<br/>'now'"| UI[Streamlit UI]
UI -->|verdict| U
classDef u fill:#fff,stroke:#444,stroke-width:1px
classDef ui fill:#eef,stroke:#447
class U u
class UI ui
Should I leave now, or wait?
One screen. Two clicks. A one-line answer based on historical traffic for that hour and the current weather forecast.
Slide 2 · End-to-end pipeline¶
flowchart TB
subgraph SRC["sources"]
SZ[/"Stadt Zürich<br/>MIV counts"/]
OMA[/"Open-Meteo<br/>archive"/]
OMF[/"Open-Meteo<br/>forecast"/]
end
subgraph ING["ingestion · dlt"]
DLT["dlt resources<br/>+ schema inference"]
end
subgraph RAW["raw zone · BigQuery"]
T1[("traffic_raw<br/>hourly_counts")]
T2[("weather_raw<br/>hourly_history")]
T3[("weather_raw<br/>hourly_forecast")]
end
subgraph TRF["transform · dbt"]
STG["staging<br/>(clean + cast)"]
MART["marts<br/>(business logic)"]
end
subgraph SRV["serve"]
REC["recommender<br/>(rule)"]
UI["Streamlit UI"]
end
SZ --> DLT --> T1
OMA --> DLT --> T2
OMF --> DLT --> T3
T1 --> STG --> MART
T2 --> STG
T3 --> STG
MART --> REC --> UI
classDef src fill:#fef9e7,stroke:#b7950b
classDef raw fill:#eaf2f8,stroke:#2874a6
classDef trf fill:#e8f8f5,stroke:#117a65
classDef srv fill:#fdedec,stroke:#922b21
class SZ,OMA,OMF src
class T1,T2,T3 raw
class STG,MART trf
class REC,UI srv
Same picture as the slide before, but every box now has a code path.
| Box | Lives at |
|---|---|
| dlt resources | pipelines/ingestion/ + pipelines/backfill/ |
| raw BQ datasets | terraform-managed, traffic_raw + weather_raw |
| staging + marts | pipelines/transformation/dbt/models/ |
| recommender | pipelines/recommend/ |
| UI | pipelines/ui/app.py |
Two sources are missing from the picture above on purpose: they only fire at request time, not during ingestion. The next slide shows them.
Slide 2b · Sources, all five of them¶
flowchart TB
subgraph BATCH["batch · runs nightly via Airflow / Cloud Scheduler"]
SZ[/"Stadt Zürich MIV<br/>~215 counters, hourly<br/>data.stadt-zuerich.ch"/]
OMA[/"Open-Meteo archive<br/>2 years hourly history<br/>4 bbox corners"/]
OMF[/"Open-Meteo forecast<br/>7-day rolling, hourly<br/>4 bbox corners"/]
end
subgraph LIVE["live · runs on every UI click"]
OSRM[/"OSRM public demo<br/>driving polyline + duration<br/>router.project-osrm.org"/]
OMP[/"Open-Meteo forecast<br/>precip at route midpoint<br/>per hour"/]
end
SZ --> RAW[(raw zone)]
OMA --> RAW
OMF --> RAW
RAW --> MARTS[(marts)]
MARTS --> REC[recommender]
OSRM --> REC
OMP --> REC
classDef b fill:#fef9e7,stroke:#b7950b
classDef l fill:#fdedec,stroke:#922b21
class SZ,OMA,OMF b
class OSRM,OMP l
| Source | Used for | Cadence | Auth | Fallback if down |
|---|---|---|---|---|
| Stadt Zürich MIV CSVs | historical hourly vehicle counts per counter+direction | nightly | none | next day re-ingests same window |
| Open-Meteo archive | 2 yr precip history at 4 corners, drives mart_weather_effect |
one-shot backfill | none | retry; bucket is averaged across 4 corners so 1 corner failing is fine |
| Open-Meteo forecast (history slot) | last 7d to keep the archive fresh | nightly | none | next day fills the gap |
| OSRM public demo | driving polyline + duration between origin and destination | per UI click | none | haversine line at 30 km/h, the route.source field flips to fallback (<exc>) so the UI is never blank |
| Open-Meteo forecast (live slot) | precipitation at the route midpoint, per hour | per UI click | none | the recommender returns precip = 0 → bucket = none → weather_coef = 1.0 |
The two "live" sources are the reason every UI request is independent of ingestion freshness. Even if last night's backfill failed, the route + forecast still come back. Only the baselines could be stale.
Slide 2c · Ingestion (dlt), what one row looks like¶
flowchart TB
SRC[(source API)] -->|"requests.get<br/>JSON / CSV"| RES["@dlt.resource<br/>generator"]
RES -->|"yield rows"| PIPE["dlt.pipeline<br/>infer schema<br/>+ append"]
PIPE -->|"COPY / load_job"| RAW[(BQ / Postgres)]
Same code, two destinations:
# pipelines/ingestion/weather.py (excerpt)
@dlt.resource(name="hourly_forecast", write_disposition="append")
def hourly_forecast():
for corner in BBOX_CORNERS: # 4 points around Zürich
for row in fetch_open_meteo_forecast(corner): # 168 rows per corner
yield row
pipe = dlt.pipeline(
pipeline_name="rush_weather",
destination=make_destination(), # bigquery | postgres
dataset_name="weather_raw",
)
pipe.run(hourly_forecast())
What one row of weather_raw.hourly_forecast actually contains after a run:
forecast_time 2026-05-28 17:00:00+00
latitude 47.4
longitude 8.5
temperature_2m 18.4
precipitation 0.0
windspeed_10m 11.2
weathercode 3
Row counts after the auto-backfill on a fresh install (verified today):
| Table | Rows | Comes from |
|---|---|---|
traffic_raw.hourly_counts |
729,963 (1y) → 3.2 M (2y) | 1 row per counter + direction + hour |
weather_raw.hourly_history |
768 (latest week) → 71,712 (2y) | 1 row per corner + hour |
weather_raw.hourly_forecast |
168 | 1 row per corner + hour for the next 7 days |
Slide 2d · Transformation, formulas with numbers¶
flowchart TB
subgraph STG["staging · views"]
s1["stg_traffic__counts<br/>+ hour_of_day, day_of_week,<br/> month_of_year, ts_hour"]
s2["stg_traffic__counters<br/>DISTINCT(zsid,direction)<br/>+ lat/lon (LV95→WGS84)"]
s3["stg_weather__history<br/>+ precip_bucket<br/>+ wmo label"]
end
subgraph MART["marts · tables"]
m1[("mart_traffic_baseline<br/>177,618 rows")]
m2[("mart_weather_effect<br/>25,417 rows")]
end
s1 --> m1
s1 --> m2
s3 --> m2
Precip bucket (stg_weather__history)
Baseline (mart_traffic_baseline) — one row per (zsid, direction, month, dow, hour):
Weather coefficient (mart_weather_effect) — one row per (zsid, direction, dow, hour, bucket):
coef = 1.0 means weather is irrelevant for that counter+hour. > 1.0
means traffic gets heavier under that bucket, < 1.0 lighter.
Real rows from the local Postgres run today, Tuesday 17:00, moderate rain:
| zsid | direction | hour | dow | avg count | n_obs |
|---|---|---|---|---|---|
| Z024 | auswärts (Hardbrücke) | 17 | 2 | 3,978 | 19 |
| Z024 | auswärts | 17 | 2 | 3,868 | 13 |
| zsid | direction | bucket | coef |
|---|---|---|---|
| Z062 | Allmend | moderate | 1.854 |
| Z096 | einwärts | moderate | 1.746 |
| Z100 | Albisriederstrasse | moderate | 1.707 |
| Z049 | Bahnhof | moderate | 1.407 |
| Z006 | einwärts | moderate | 1.350 |
Reading: at Allmend on a Tuesday at 17:00, moderate rain pushes the count to 1.85× the dry baseline. That counter is a rain-amplifier; the recommender weighs it the same as any other counter on the route.
Slide 2e · Recommender, what one click computes¶
flowchart TB
CLK([click 2 points]) --> R["OSRM<br/>route polyline + duration"]
R --> S["snap.py<br/>counters within 300 m<br/>of any segment"]
S --> Z["zsids on the route"]
Z --> L1["lookup load_profile<br/>(per counter, dow, 24 h)"]
Z --> L2["lookup weather_coef<br/>(per counter, dow, h, bucket)"]
M["Open-Meteo forecast<br/>at route midpoint"] --> B["bucket(precip)"]
B --> L2
L1 --> X["congestion(load)<br/>= 1 + α·(load − 1)"]
L2 --> X2["weather × congestion<br/>per counter"]
X --> X2
X2 --> AVG["mean across counters<br/>= multiplier"]
R --> BASE["baseline_minutes<br/>= OSRM duration / 60"]
AVG --> EXP["expected_minutes<br/>= baseline × multiplier"]
EXP --> V["verdict band"]
Per-counter multiplier
with \(\alpha = 0.3\) and \(m_z\) capped at \(2.5\). Then average across the counters on the route:
Verdict band — the only thing the user actually reads:
| \(M\) | Verdict |
|---|---|
| \(\ge 1.10\) | wait, traffic heavier than usual |
| \(\le 0.95\) | leave now, lighter than usual |
| in between | fine to leave, usual conditions |
Sample output (recommend.py JSON, real call from today):
{
"route": {"duration_s": 590.1, "distance_m": 5313.3, "source": "osrm"},
"precip_mm": 0.0, "precip_bucket": "none",
"baseline_minutes": 9.835,
"weather_multiplier": 0.994,
"expected_minutes": 9.77,
"counters_used": 15,
"verdict": "fine to leave, usual conditions"
}
Slide 2f · The UI, what updates and when¶
sequenceDiagram
actor U as user
participant ST as Streamlit
participant R as recommend/<br/>timeline.py
participant O as OSRM
participant W as Open-Meteo
participant PG as Postgres / BQ
U->>ST: click origin
U->>ST: click destination
ST->>R: hourly_timeline(o, d, day)
R->>O: GET /route/v1/driving (one call)
O-->>R: polyline + duration
R->>R: snap counters within 300 m
R->>PG: load_profile_for(zsids, dow)
R->>PG: weather_coef_for(zsids, dow, h, bucket)
loop hour 0..23
R->>W: GET /forecast precip at midpoint
R->>R: mult = mean(congestion · coef)
end
R-->>ST: 24 rows + route + counters
ST-->>U: countdown + map + timeline
U->>ST: drag hour slider
ST-->>U: re-renders from cached rows<br/>(no new API calls)
| User action | What re-runs | Cost (API calls) |
|---|---|---|
| First app load | nothing, picker map only | 0 |
| Click origin | nothing yet | 0 |
| Click destination | full hourly_timeline |
1 OSRM + ≤24 Open-Meteo + 2 SQL |
| Change date | full hourly_timeline again (forecast bucket changes) |
1 OSRM + ≤24 Open-Meteo + 2 SQL |
| Move hour slider | only the metric row + map highlight, in-memory | 0 |
| Click "clear pins" | back to picker | 0 |
| Underlying marts refresh | nothing inside the session; next click sees new data | n/a |
The UI is read-only: it never writes to the warehouse. Freshness is set
by the nightly DAG (rush_daily 02:00 UTC) and Cloud Scheduler
(rush-ingest-daily 02:00 UTC + rush-dbt-daily 03:00 UTC). The
forecast is always live — it's a per-click API call, not a stored value.
Slide 2g · Data journey, one row end-to-end¶
Following a single counter (Z062 Allmend, direction Allmend) at
Tuesday 17:00 through the pipeline.
flowchart TB
A["CSV row<br/>Stadt Zürich<br/>2024-05-21 17:00<br/>count=412"] -->|dlt append| B
B["traffic_raw.hourly_counts<br/>+ ingested_at"] -->|dbt view| C
C["stg_traffic__counts<br/>+ hour=17, dow=2,<br/> month=5, ts_hour"] -->|group by| D
D["mart_traffic_baseline<br/>avg=320 over 19 obs"] -->|join| E
E["mart_weather_effect<br/>bucket=moderate<br/>coef=1.854"] -->|loaded by| F
F["timeline.py<br/>congestion(load) · coef"] -->|averaged with<br/>14 other counters| G
G["multiplier M=0.99"] -->|× 9.84 min| H
H["expected_minutes=9.77<br/>verdict='fine to leave'"]
| Stage | What it is | Example value |
|---|---|---|
| Source | row in a Stadt Zürich CSV | (Z062, Allmend, 2024-05-21T17:00, 412) |
traffic_raw.hourly_counts |
raw append + _dlt_load_id |
same, plus dlt metadata |
stg_traffic__counts |
derived time keys | adds hour_of_day=17, day_of_week=2, month_of_year=5 |
mart_traffic_baseline |
one row per (zsid, dir, month, dow, hour) | avg_vehicle_count=320, n_observations=19 |
mart_weather_effect |
one row per (zsid, dir, dow, hour, bucket) | bucket=moderate, weather_coef=1.854 |
| Recommender | loaded into timeline.py, multiplied |
combined m_z = 1.83 for this counter |
| UI | shown to user | expected 9.8 min, verdict "fine to leave" |
The same row also appears in the BigQuery copy of every table above with identical values — that is the portability claim, made concrete.
Slide 3 · Two runtimes, one codebase¶
flowchart TB
subgraph LOCAL["local · ./setup.sh"]
AF[Airflow scheduler]
PG[("Postgres<br/>warehouse")]
SL[Streamlit]
AF --> PG --> SL
end
subgraph CLOUD["cloud · terraform apply"]
CS[Cloud Scheduler]
CR1[Cloud Run Job<br/>ingest]
CR2[Cloud Run Job<br/>dbt]
BQ[("BigQuery<br/>warehouse")]
CRS[Cloud Run Service<br/>Streamlit]
CS --> CR1 --> BQ
CS --> CR2 --> BQ --> CRS
end
CODE[(same dlt + dbt code)] --> LOCAL
CODE --> CLOUD
The thing that matters: dlt, dbt models, and the Streamlit app are identical on both sides. Only the orchestrator and the destination change. The dbt profile picks Postgres or BigQuery from an env var.
Slide 4 · Architecture decisions¶
| Decision | Why | What it costs |
|---|---|---|
| Postgres and BigQuery, same models | Reviewer can grade with zero cloud cost; cloud target proves portability | Two profiles, two CI paths |
| Dimensional marts, not raw passthrough | UI query is a single SELECT instead of a 10-table join |
One extra dbt layer to maintain |
| Append-only fact tables | dlt 1.x can't merge into BigQuery without a primary key column the source doesn't give us |
Dedup happens in staging, not in dlt |
| Cloud Run Jobs, not GKE / Composer | Scale-to-zero. About CHF 0/month idle | No long-lived workers, cold starts on each run |
| Streamlit, not React + API | One file for the whole UI | Not pretty, but graded on substance |
Macros for dayofweek / median |
Same SQL compiles on Postgres and BigQuery | Two short macro files |
Each row is a tradeoff that was made on purpose, not by accident.
Slide 5 · Failure modes¶
flowchart TB
subgraph L["where it can break"]
A[source API down]
B[dlt schema drift]
C[dbt test fails]
D[Cloud Run job timeout]
E[scheduler skips a run]
end
subgraph R["what catches it"]
a[retry with backoff]
b[dlt evolves the table,<br/>raises only on type clash]
c[build stops,<br/>bad data never hits a mart]
d["max_retries=1<br/>+ next day's run heals it"]
e[next cron tick<br/>backfills the gap]
end
A --> a
B --> b
C --> c
D --> d
E --> e
| Layer | Failure | First defence | Last defence |
|---|---|---|---|
| ingest | API 5xx / timeout | dlt retry | next day re-ingests the same window |
| ingest | schema change | dlt evolves columns | dbt test on staging row count |
| transform | bad row, null key | not_null test |
dbt build fails → no fresh mart, UI keeps last-good |
| orchestration | scheduler down | Cloud Scheduler is managed | manual gcloud run jobs execute |
| serve | UI 5xx | Cloud Run autoscale + health check | static fallback message |
The pattern is the same everywhere: catch what you can, make the next run idempotent for the rest.
Slide 6 · Scaling considerations¶
flowchart TB
subgraph NOW["today"]
n1["~730k traffic rows"]
n2["1 city, 1 weather grid"]
n3["daily refresh"]
end
subgraph NEXT["next step"]
x1["partition by date<br/>+ clustering"]
x2["one resource per city,<br/>config-driven"]
x3["hourly + 6h forecast<br/>refresh on a faster cron"]
end
subgraph LATER["later"]
l1["BQ slot reservation<br/>or DuckDB on GCS"]
l2["multi-region grid,<br/>sharded by country"]
l3["streaming dlt<br/>+ materialised views"]
end
n1 --> x1 --> l1
n2 --> x2 --> l2
n3 --> x3 --> l3
| Dimension | Now | If 10× | If 100× |
|---|---|---|---|
| Volume | ~730k rows | partition + cluster, drop reading old years | column store on GCS, Iceberg |
| Cities | 1 (Zürich) | config-driven dlt resource per city | shard datasets, region-aware Cloud Run |
| Freshness | daily 02:00 UTC | hourly cron, append-only stays cheap | streaming dlt, materialised views |
| Cost | ~CHF 0/month idle | still ~CHF 0 thanks to scale-to-zero | slot reservation or self-hosted DuckDB |
| Concurrency | 1 user | Cloud Run autoscales the UI | put a CDN in front, cache mart query for N min |
Nothing about the current shape blocks the next step. That was the point.
Slide 7 · System thinking, not tools¶
The grading rubric asks for six things. Each one is solved by a choice in the architecture, not by a brand name.
flowchart TB
subgraph REQ["rubric"]
r1["use case · 5"]
r2["ingestion · 10"]
r3["storage + docker · 10"]
r4["transformation · 5"]
r5["orchestration · 10"]
r6["repository · 5"]
end
subgraph ANS["architecture choice"]
a1["one question,<br/>one verdict"]
a2["dlt + idempotent backfill,<br/>schema evolution"]
a3["one compose file,<br/>7 services, healthchecks"]
a4["staging → marts,<br/>portable SQL via macros"]
a5["Airflow local +<br/>Cloud Scheduler"]
a6["one-line install,<br/>one-line teardown"]
end
r1 --> a1
r2 --> a2
r3 --> a3
r4 --> a4
r5 --> a5
r6 --> a6
Tools are interchangeable. Replace dlt with Singer, Airflow with Dagster, Streamlit with FastAPI + React, BigQuery with Snowflake. The boxes stay, the arrows stay, the failure modes stay. That is the system.
Slide 8 · Live evidence¶
A fresh terraform apply + one ingest run + one dbt run, this week:
| Table | Rows |
|---|---|
traffic_raw.hourly_counts |
729,963 |
weather_raw.hourly_history |
768 |
weather_raw.hourly_forecast |
168 |
rush.mart_traffic_baseline |
177,618 |
rush.mart_weather_effect |
25,417 |
- Live UI: rush-ui-jv7ddtssdq-oa.a.run.app —
HTTP 200 - Scheduler:
rush-ingest-daily02:00 UTC,rush-dbt-daily03:00 UTC - One curl on a clean machine reproduces the local half:
That command clones the repo, builds the image, starts the 7-service stack,
and triggers rush_backfill for you. You only watch the progress bar.