feat(case-study): engineer trial corridor (India, 805 m)#71
Open
harsh-pandhe wants to merge 20 commits into
Open
feat(case-study): engineer trial corridor (India, 805 m)#71harsh-pandhe wants to merge 20 commits into
harsh-pandhe wants to merge 20 commits into
Conversation
mkdocs use_directory_urls renders the page at /case_studies/<slug>/ but the twin.html lives at /case_studies/<slug>_outputs/twin.html — one level up. The iframe src was <slug>_outputs/twin.html, which resolved to /case_studies/<slug>/<slug>_outputs/twin.html and 404'd. Prefix with ../ so the path resolves correctly on all 12 case-study pages.
Lon/lat (73.5123812, 19.0691450) → (73.5189329, 19.0728925). Western Ghats region. 805 m corridor, 108 m elevation gain. Optimizer result (MGD, GA default config): feasible, 2 intermediate towers, 815 m cable, 3.27 m min clearance, 260.8 kN max tension, EUR 4.95 M EU_ALPINE capex band. - examples/case_engineer_trial_india.py (scaffolded via P49 tool) - docs/case_studies/engineer_trial_india.md (with embedded 3-D twin) - 8 artifacts in docs/case_studies/engineer_trial_india_outputs/ - mkdocs nav entry - Streamlit Demo tab preset (top of the dropdown for the trial) - 13th card on the landing-page case-study grid
…, 742 m rise) Engineer-supplied corridor was actually Bhimashankar Jyotirlinga (one of 12 Hindu pilgrimage sites in Maharashtra). Corridor is much steeper and longer than the initial scaffold: - Lower: 73.5123812, 19.0691450 (Shidighat trail-head) - Upper: 73.5330820, 19.0700494 (Bhimashankar plateau) - 2 181 m horizontal, 742 m terminal-to-terminal rise (34% slope) Tried MGD / 3S / jig-back at default GA budget. All three feasible. Picked jig-back for cleanest visualisation (single intermediate tower) and because pilgrim flow is peaky, not continuous — 3S overkill, MGD needs 6 foundations on protected forest land. Optimizer result (jig-back, 100 pop x 120 gen): feasible, 1 intermediate tower at d=1820 m (height 38.7 m), upper-station tower 61.9 m, 2 345 m cable, 2.92 m min clearance, 1 395 kN max tension, USD 3.9 M Emerging-band capex. Updated: - examples/case_engineer_trial_india.py — Bhimashankar coords + jigback - docs/case_studies/engineer_trial_india.md — full narrative with tower schedule, system-choice rationale, embedded 3-D twin - docs/case_studies/engineer_trial_india_outputs/ — fresh artifacts - app/streamlit_app.py — Bhimashankar preset (jigback default) - docs/index.md — landing-page card updated
… unambiguous The Demo tab and the sidebar 'Run optimization' button both write to the same st.session_state slots. If a user runs Bhimashankar from the Demo tab and then clicks the sidebar Run, the synthetic default overwrites the Demo result without warning — exactly the bug surfaced during pre-demo smoke testing (Optimize + 3-D Twin tabs were showing synthetic terrain after a Demo-tab Bhimashankar run was clobbered). - Both run paths now set session_state['loaded_label'] to a clear name. - Optimize and 3-D Digital Twin tabs show 'Currently loaded: <name>' at the top so it's never ambiguous which corridor is being viewed. - Demo-tab caption now explicitly warns the user not to click the sidebar Run button after a Demo run (would overwrite).
alignment_to_kml writes a KML 2.2 file with:
- per-tower Point placemarks at absolute anchor elevation
(ground + tower height), so the markers float at correct world
height in Google Earth Pro
- cable centerline as a LineString in absolute coords — renders as
the actual suspended cable in GE's 3-D view
- ground centerline as a clampToGround LineString for footprint
reference
- separate icon styles for stations (green star) and intermediate
towers (blue circle)
Wired into the Bhimashankar case study; alignment.kml now ships next
to the existing .geojson / .landxml / .dxf.
docs/VISUALIZE_GE_QGIS.md — engineer-facing walkthrough:
- GE Pro: double-click the .kml; what the icons mean; tilt for 3-D
- GE Web: same KML, with the LineString caveat
- QGIS: drag-and-drop the .geojson, add OSM/ESRI basemap via
QuickMapServices, optionally drop the DEM raster underneath for
3-D map view
6 new tests cover KML XML validity, placemark counts,
absolute-altitude on tower Points and cable LineString,
clampToGround on the ground reference, zero-length rejection.
Full suite: 506 passing.
P72 — KML cable rides Google Earth's terrain. Switch altitudeMode from 'absolute' to 'relativeToGround' for tower Points and cable LineString. Densify the cable polyline with N catenary samples per span (default 24) when segments= is passed. Each sample emits its height-above-local-ground so GE renders the cable above whatever terrain it has — closes the bug where our 30 m Copernicus DEM disagreed with GE's finer terrain and dropped the cable underground. P73 — Station vs Tower vocabulary everywhere a user sees. index 0 → 'Lower station', index N-1 → 'Upper station', other is_station=True → 'Station <i>', non-station intermediates → 'Tower <i>'. Applied in KML names + GeoJSON properties (new 'label' key). _support_label() helper to keep one source of truth. P74 — One-button permit-pack ZIP in Streamlit Demo tab. After a Demo run, a single 'Download permit pack (ZIP)' button bundles KML/DXF/LandXML/GeoJSON/BoM/cost/towers.csv + the alignment & convergence PNGs into one zip the engineer can drop into a permitting office or stakeholder deck. No CLI, no per-file download dance. Bhimashankar test: cable now renders 3.22-152 m above ground along the corridor (min/max), never below 0. Vocabulary now 'Lower station'/'Tower 1'/'Upper station'. docs/POST_TRIAL_PLAN.md — three-horizon plan (H1 recover, H2 web product, H3 commercial) capturing the pivot to web-only UX, Cesium 3-D in browser, AI input, embedded Google Earth walkthrough. Tests: 4 KML tests updated for relativeToGround behaviour, 3 new tests added (densification, no-underground guarantee, station vocabulary). Full suite: 509 passing.
POST /api/v1/corridor takes {start, end, system, name, GA knobs},
auto-fetches the matching Copernicus DEM tile via Phase 47's cache,
runs the optimizer in the existing BackgroundTasks queue, writes every
artifact to a job-scoped tmpdir, and pre-bakes a ZIP.
Endpoints:
POST /api/v1/corridor -> 202 + job_id (+ status_url, artifacts_url)
GET /api/v1/corridor/{job_id} -> status + structured result payload
GET /api/v1/corridor/{job_id}/artifacts -> map of {kind: url}
GET /api/v1/corridor/{job_id}/artifacts/{kind} -> FileResponse
Artifact kinds (stable names for the SPA): kml, dxf, landxml, geojson,
towers_csv, bom_csv, cost_csv, alignment_png, convergence_png, zip.
Validation:
- lon/lat range check (-180..180, -90..90)
- system must be one of the six RopewaySystemType values
- start != end (400)
- unknown kind -> 400, unknown job_id -> 404, not-yet-done -> 409
11 new tests cover POST validation, status polling, artifact listing,
KML mime + P72/P73 content checks, ZIP bundle contents, all
error-path codes. Full suite: 520 passing.
This is the backbone for H2 (Next.js SPA in POST_TRIAL_PLAN.md).
The SPA will POST here and poll the status URL; no CLI or Streamlit
on the engineer's path.
web/ — Next.js 16 (App Router, TypeScript, Tailwind v4) client for
the FastAPI corridor API (P76). End-to-end engineer flow:
1. Pick a preset (Bhimashankar / Aiguille du Midi / Cablebús L2),
or paste two (lon, lat) pairs and a system type.
2. Click 'Build my alignment'.
3. Progress indicator polls /api/v1/corridor/{job_id} every 1.5 s.
4. Result panel renders headline metrics + alignment plot + a
one-click download for every artifact (KML, DXF, LandXML,
GeoJSON, BoM, capex, towers.csv, plots, full ZIP).
No CLI, no Python, no Streamlit on the engineer's path.
Includes:
- web/lib/api.ts — typed fetch wrapper (CorridorRequest /
CorridorJob / artifact URLs / pollUntilDone helper)
- web/app/page.tsx — single-page form + result panel,
a11y-compliant inputs (aria-labels + htmlFor on every control)
- web/README.md — local dev + Vercel + Cloudflare Tunnel deploy
- web/.env.local.example — NEXT_PUBLIC_API_URL hint
Lint clean (0 errors, 1 warning re: next/image vs <img> for the
cross-origin alignment.png — acceptable).
Node 18 will run dev; Next 16 + Tailwind 4 want Node ≥ 20 for a
clean prod build — documented in web/README.md.
Backbone for H2 (POST_TRIAL_PLAN.md): the SPA replaces Streamlit
as the user-facing surface; the engine stays in Python on the box.
web/app/CorridorMap.tsx — Leaflet/OpenStreetMap widget in a
client-only dynamic import (Leaflet touches window on import):
- First click sets the lower terminal (green pin).
- Second click sets the upper terminal (red pin).
- Third click restarts and reseeds the lower terminal.
- Both pins are draggable for fine-tuning.
- Indigo polyline shows the corridor between the two terminals.
- Preset switch / number-input edits fly the map to the new
midpoint.
SVG-encoded markers (no asset 404s from Leaflet's default icons being
tree-shaken oddly inside Next.js).
Engineer no longer needs to type coordinates — but the lon/lat
inputs stay as a fallback for paste-from-Google-Earth flows.
Deps added: leaflet, react-leaflet, @types/leaflet.
Lint clean (0 errors).
web/app/CesiumViewer.tsx — loads the per-job KML artifact into a
Cesium globe and auto-flies the camera to the corridor.
- Loaded via CDN in app/layout.tsx (CesiumJS 1.122) — avoids the
bundler tangle Cesium's asset paths create with Next.js.
- With NEXT_PUBLIC_CESIUM_TOKEN set: Cesium World Terrain + Bing
satellite imagery (full Google-Earth-grade scene).
- Without a token: OSM imagery + flat ellipsoid (still works, less
pretty — hint shown in the bottom-right).
- On error: shows a friendly fallback message + a direct link to
download the KML for Google Earth Pro.
Result panel now leads with the 3-D scene; the 2-D alignment plot
becomes a collapsed details element below it. Cesium consumes the
P72-fixed KML directly (relativeToGround + densified catenary
samples), so the cable rides terrain in the embedded viewer too.
Dep added: cesium (for the TypeScript types only — runtime loads
from CDN).
Lint: 0 errors.
Backend (src/ropeway/server/corridor.py):
- Worker now also writes plot_data.json with sampled terrain +
cable curves (400 samples), per-tower marks (P73-labelled),
convergence history, and per-span max tension in kN.
- New artifact kind 'plot_data' served at
/api/v1/corridor/{id}/artifacts/plot_data with
application/json.
Frontend (web/app/CorridorCharts.tsx):
- Three Plotly charts replace the static alignment.png:
1. Elevation profile + cable + supports (fill+line+marker,
hover shows distance / elevation, supports labelled).
2. GA convergence (best + population avg, log y-axis).
3. Per-span max tension (bar).
- Loaded via dynamic import (react-plotly.js touches window).
- The legacy alignment.png becomes a collapsed details element
so the engineer can still see the Matplotlib version if they
want.
13 corridor-API tests now (added plot_data schema check).
Full backend suite: 521 passing. SPA lint clean (0 errors).
Backend (src/ropeway/server/ai.py):
- POST /api/v1/ask {text} -> {parsed: {start, end, system, name, notes}}
- Calls a local Ollama instance over plain HTTP (no SDK). Default
OLLAMA_HOST=http://localhost:11434, OLLAMA_MODEL=gemma4:e4b
(small + fast); override via env. Smoke-tested on Bhimashankar
request: ~19 s on CPU with gemma4:e4b.
- format=json + low-temp system prompt enforces the schema; tolerant
parser strips stray ``` fences some models emit despite format=json.
- System aliases normalised (gondola→mgd, tram→jigback, etc.).
- 503 with operator hint when Ollama unreachable; 502 on transport
error; 422 on unparseable / out-of-range output.
- Deliberately does NOT auto-submit to /api/v1/corridor — returns the
parsed payload for engineer confirmation. A chatty model on a junk
request shouldn't burn a real GA run without "yes".
Frontend (web/app/page.tsx + web/lib/api.ts):
- New 'Ask AI to fill the form' textarea above the map. Hitting
'Ask AI' fills lon/lat/system/name; engineer reviews on the map +
clicks 'Build my alignment' to actually run.
- Note: model coords for famous places are approximate — drag the
pins to fine-tune. Real geocoder pairing is a P81 follow-on.
Tests (tests/test_ai_endpoint.py — 10 new, all offline):
clean-JSON happy path, alias normalisation, code-fence strip,
unknown-system reject, lon/lat range reject, model error payload,
Ollama unreachable -> 503, request body min_length, 502 on HTTP
error, 422 on non-JSON output.
Anthropic SDK removed from deps — Ollama is local only. Full suite:
531 passing.
Backend:
- CorridorRequest grows optional w_n / w_h / w_L / seed_towers
fields so a request can override CostWeights (P30) and warm-start
from a previous best alignment (P30).
- Corridor worker reads those, builds CostWeights + Alignment for
seed_alignment when set, threads both into optimize().
- New endpoint POST /api/v1/refine {previous_job_id, instruction}:
* Looks up the previous job's request + result.
* Asks Ollama (same model as /ask) to translate the instruction
into {w_n_scale, w_h_scale, w_L_scale, system?, rationale}.
* Builds a new CorridorRequest with the scaled weights +
optional system change + warm-start from prior towers.csv.
* Runs the new job inline and returns its job_id + applied
weights + rationale.
- All scales clamped to [0.1, 50.0]; unknown system → fall back to
previous; ``` fences tolerated; Ollama unreachable → 503.
Frontend:
- Result panel grows an indigo 'Refine with AI' input. Hit Enter
or click Refine; the SPA POSTs to /api/v1/refine, polls the new
job_id, then swaps the result in place. Applied weights +
rationale shown below the input.
Tests (tests/test_ai_endpoint.py):
- refine 404 on unknown previous job
- refine end-to-end: seed → fewer-towers stub → asserts applied
weights + new job done
- refine system-switch stub → applied system updates
Full suite: 534 passing.
CesiumViewer now exposes a 'Record flyover' button (top-left of the
3-D scene). Clicking it:
1. Captures the Cesium canvas via canvas.captureStream(30 fps).
2. Spins up a MediaRecorder (vp9 → vp8 → webm fallback).
3. Runs a scripted 4-stop camera flight over the corridor:
• 3 s wide top-down overview at radius * 4.5
• 3 s oblique sweep heading 75° / pitch -25° at radius * 2.8
• 3 s opposite-side oblique heading 255°
• 3 s pull-back hero shot at radius * 3.2
(~12 s total, derived from the union of KML entity bounding spheres)
4. Stops the recorder, hands back a Blob URL.
5. Reveals a green 'Download flyover' button — engineer gets a
ropeway-flyover.webm file to email to a stakeholder.
Required Cesium tweak: contextOptions.webgl.preserveDrawingBuffer = true
so MediaRecorder sees non-blank frames (WebGL default is to clear the
buffer every frame after composite, which yields a black video).
No server-side rendering. No ffmpeg. Pure browser.
.webm plays in every modern browser + VLC; converter to MP4 is a
P85+ refinement if a stakeholder needs strict MP4.
- web/.nvmrc → 20 (Next 16 + Tailwind v4 hard-fail on Node 18). - web/package.json engines.node ">=20.9.0" so npm warns up front. - web/README.md gets a 'Recovery' section: `rm -rf node_modules package-lock.json .next && nvm use 20 && npm install`. This is the known npm bug (npm/cli#4828) where the @tailwindcss/oxide native binary (oxide-linux-x64-gnu) gets skipped on some installs. - Also documents Turbopack's stale-cache failure mode: pkill -f "next dev" && rm -rf .next && npm run dev. Smoke: with Node 20 active, fresh install, `npm run dev` ready in 195 ms, GET / returns 200 + 31 KB rendered HTML with the SPA shell (Ask AI + map + form + Build button).
… buffer
Engineer's GE Pro screenshot from the 2026-05-25 trial showed the cable
clipping into the cliff face. Root cause: with altitudeMode=relativeToGround
we computed height-above-local-DEM-ground, then GE rendered that with its
own (sometimes finer / sometimes more-stylised) terrain — when the two
disagreed the cable could appear underground.
Fix:
- alignment_to_kml gains altitude_mode={'absolute' | 'relativeToGround'}
+ safety_buffer_m (default 5 m).
- Default switched to absolute: emit the catenary's true WGS-84 elevation
+ 5 m safety buffer at every densified sample and at every tower point.
This is what Civil 3D / OpenRoads import expects anyway.
- relativeToGround stays available as an opt-in for callers that
explicitly want GE to align the cable to its own terrain.
- tests/test_io_kml.py — rewritten assertions for the new default
(absolute + buffer); added test for altitude_mode rejection and for
relative-mode-no-buffer regression.
- Bhimashankar artifact regenerated under the new default: cable rides
smoothly from 221 m at Shidighat to 1019 m at Bhimashankar plateau.
Also lands docs/POST_TRIAL_PLAN_V2.md — feedback-loop-1 plan covering
P93 inline document previews, P94 UI overhaul, P95 (this PR), P96
advanced editor, P97 manual tower drag, P98 refine v2 with before/after
diff + history, P99 saved projects + share links.
Full suite: 537 passing.
Adds a 'Web product (P77+)' section between the headline tagline result and the legacy quickstart. Documents the engineer-facing stack (Next.js + FastAPI + Ollama + Cesium + Leaflet + Plotly) and the role each shipped phase plays. Adds a 'Phase plans' section linking the three planning docs: PHASE_PLAN.md (engine waves), POST_TRIAL_PLAN.md (H1/H2/H3 web pivot), POST_TRIAL_PLAN_V2.md (feedback-loop-1 — Waves A-D). Web-side recovery snippet for the recurring Tailwind oxide native binding npm bug.
… scales
Trial 1 surfaced a credibility gap: engineer asked AI for 'more towers'
twice and felt nothing changed. Two fixes:
1. **Recalibrated LLM scales.** Old prompt mapped 'more towers' →
w_n_scale=0.5 (half). The GA barely notices a 2× weight shift on a
jig-back where physics already caps tower count. New scales:
'fewer towers' -> w_n_scale = 20.0
'more towers' -> w_n_scale = 0.05
'shorter towers' -> w_h_scale = 10.0
'shorter cable' -> w_L_scale = 10.0
'cheaper' -> w_n_scale = 4.0, w_L_scale = 4.0
Clamp band widened from [0.1, 50.0] → [0.01, 100.0] to fit.
2. **Diff payload + history.**
Backend:
- /api/v1/refine response now carries `diff` (4-col
metric/before/after/delta/delta_pct) computed from prev.result
vs new_result for 6 headline metrics. Also `previous_job_id`,
`instruction`, `raw_model_output` for client-side history +
debugging.
Frontend:
- Result panel now renders a real 5-col table with colour-coded
deltas (green = good change; clearance-up and cost-down both
count as good, others lower-better).
- History strip below: each refine becomes a chip showing
'#N <instruction>'. Click to revert to the corridor state
*before* that refine (fetches the previous job_id, restores
it in place, trims history).
Tests: 2 new (diff payload shape, recalibrated 0.05 scale clamp).
Full backend suite: 538 passing.
Engineer feedback (trial 1, verbatim): "Before giving download
features, all documents should be shown here in pages."
Backend:
- Corridor worker now also renders validation_report.pdf via
render_pdf_report (the multi-page Eurocode-check PDF the case
studies have used since 2025-Q4).
- New artifact kind 'pdf' on /api/v1/corridor/{id}/artifacts/pdf,
mime application/pdf. Permit-grade PDF travels with the same job
bundle as every other artefact (also lands in the ZIP).
Frontend:
- New web/app/DocumentPreviews.tsx component. For each artefact
the engineer typically needs to inspect, renders the content
inline in a collapsible <details>:
PDF → iframe at 720 px
CSV → real <table> with sticky header, tabular nums,
hover, max-h-96 scroll (towers / bom / cost)
KML → pretty-printed XML, monospace, scrollable
GeoJSON → JSON.parse → JSON.stringify(2) pretty
LandXML → pretty-printed XML
DXF → first 80 lines (header sanity check; full DXF viewer
is P93b backlog)
Each <summary> shows a discreet 'download' link on the right so
the original download path stays one click away.
- Result panel now has a 'Documents' section between the static
Matplotlib plot and the Downloads grid.
- lib/api.ts: added 'pdf' to ArtifactKind + ARTIFACT_ORDER + labels.
Tests: corridor-API listing now expects 'pdf' in the kinds set.
Full backend suite: 538 passing.
Engineer feedback (trial 1): 'Overall UI needs a major update.'
Visual layer rebuild:
- web/app/globals.css — proper token system (surface / surface-2 /
foreground / muted / border / accent / accent-2 / good / bad /
warn), light + dark variants driven by [data-theme] attribute or
prefers-color-scheme. .card / .btn-primary / .btn-ghost / .hero
utility classes replace the ad-hoc rounded-md border-zinc-200
sprinkled through the page. Tabular numerals globally.
- web/app/ThemeToggle.tsx — light / dark / auto cycle button in
the header. Persists to localStorage. Auto removes the override
and lets prefers-color-scheme decide.
- web/app/page.tsx — proper hero (max-w-6xl, gradient background,
headline + lede + two CTAs, reference-run card with
Bhimashankar metrics). Header now compact + branded. 'Documents'
heading is scroll-anchorable for the hero CTA. Metric cards use
the shared .card class with tabular numerals.
Type cleanups along the way:
- CesiumViewer: imageryProvider constructor option dropped in
Cesium ≥1.119 → use baseLayer via fromProviderAsync.
- CorridorCharts: Plotly's stricter mode literal — 'markers+text'
→ 'text+markers' (functionally identical).
ESLint: 0 errors, 1 unrelated <img> warning. Project tsc: clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
New case study for tomorrow's engineer demo.
What's in this PR
examples/case_engineer_trial_india.py(scaffolded via the P49 tool)docs/case_studies/engineer_trial_india.mdwith embedded 3-D twinWhy
So the engineer can pick his own corridor from the preset dropdown during the live demo — proves the tool isn't just curated showpieces, it works on the corridor he actually cares about.
Test plan
python examples/case_engineer_trial_india.pyruns to completion, reports feasiblepython tools/build_case_study_twins.py --only engineer_trial_indiabuilds the twin