Skip to content

feat(case-study): engineer trial corridor (India, 805 m)#71

Open
harsh-pandhe wants to merge 20 commits into
mainfrom
feat/engineer-trial-india-case-study
Open

feat(case-study): engineer trial corridor (India, 805 m)#71
harsh-pandhe wants to merge 20 commits into
mainfrom
feat/engineer-trial-india-case-study

Conversation

@harsh-pandhe
Copy link
Copy Markdown
Owner

Summary

New case study for tomorrow's engineer demo.

  • Corridor: lon/lat (73.5123812, 19.0691450) → (73.5189329, 19.0728925). Western Ghats region. 805 m, 108 m elevation gain (~14 % mean slope).
  • System: MGD (urban gondola).
  • Optimizer result: 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.

What's in this PR

  • examples/case_engineer_trial_india.py (scaffolded via the P49 tool)
  • docs/case_studies/engineer_trial_india.md with embedded 3-D twin
  • 8 artifacts: alignment.png, .geojson, .landxml, towers.csv, bom.csv, cost_estimate.csv, convergence.png, twin.html
  • mkdocs nav entry (13th case study)
  • Streamlit Demo tab preset (top of the dropdown for the trial)
  • 13th card on the landing-page case-study grid

Why

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.py runs to completion, reports feasible
  • python tools/build_case_study_twins.py --only engineer_trial_india builds the twin
  • Streamlit Demo tab loads the preset on map (verified by syntax + import)
  • mkdocs nav valid

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant