diff --git a/README.md b/README.md index 070cd50..de9d918 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,57 @@ See [`docs/tagline/tagline.md`](docs/tagline/tagline.md). Reproduce with --- +## Web product (P77+) — what the engineer actually clicks + +`web/` is a Next.js 16 SPA backed by a FastAPI corridor endpoint on +the same box. No CLI on the engineer's path. + +| | | +|---|---| +| **AI input (P81)** | Local Ollama (default `gemma4:e4b`) parses *"ropeway from Shidighat to Bhimashankar plateau, jig-back"* into a structured corridor request. | +| **Map input (P78)** | Leaflet + OpenStreetMap. Drop two pins, drag to fine-tune. | +| **Build (P76)** | `POST /api/v1/corridor` → auto-fetches the Copernicus tile (P47) → runs the GA → writes KML / DXF / LandXML / GeoJSON / PNG / PDF / ZIP → job-status URL the SPA polls. | +| **3-D view (P79)** | Cesium globe, KML loaded as a DataSource, auto-fly to corridor. With `NEXT_PUBLIC_CESIUM_TOKEN` set → Cesium World Terrain + Bing satellite. | +| **Walkthrough video (P85)** | Client-side `MediaRecorder` on the Cesium canvas + scripted 12-second camera flight → downloadable `.webm`. | +| **Interactive charts (P80)** | Plotly: elevation profile + cable + supports / GA convergence / per-span tension. | +| **Refine chat (P86)** | "make it cheaper" / "fewer towers" / "switch to 3S" → Ollama → adjusted `CostWeights` + warm-start → new GA job. | +| **Permit pack (P82)** | One-button ZIP, plus per-artifact downloads. | +| **KML alignment (P95)** | Cable shipped at true WGS-84 elevation + 5 m safety buffer so GE-stylised terrain never clips it. `relativeToGround` opt-in available. | + +Local dev: + +```bash +source ~/.nvm/nvm.sh && nvm use 20 # Next 16 requires Node ≥ 20 +cd web && npm install && npm run dev # http://localhost:3000 + +# in another terminal: +source .venv/bin/activate && ropeway serve # FastAPI :8000 + +# and: +ollama serve # Ollama for /api/v1/ask + /api/v1/refine +``` + +Recovery if Tailwind's native binary mis-installs: +`rm -rf web/node_modules web/package-lock.json web/.next && cd web && npm install`. + +--- + +## Phase plans + +The two planning docs that drive everything else: + +* **[`docs/POST_TRIAL_PLAN.md`](docs/POST_TRIAL_PLAN.md)** — the H1/H2/H3 plan + written the day of the first engineer trial. H1 + H2 are shipped. +* **[`docs/POST_TRIAL_PLAN_V2.md`](docs/POST_TRIAL_PLAN_V2.md)** — feedback-loop 1 + after the trial. Waves A (visualisation correctness, includes the + P95 KML fix that just shipped), B (inline document previews — + P93), C (UI overhaul — P94), D (full-control editor + manual tower + drag + refine v2 + saved projects — P96–P99). + +Original phase plan (engine waves P1–P32): [`docs/PHASE_PLAN.md`](docs/PHASE_PLAN.md). + +--- + ## Quickstart ```bash diff --git a/app/streamlit_app.py b/app/streamlit_app.py index 890f5d7..f197fd9 100644 --- a/app/streamlit_app.py +++ b/app/streamlit_app.py @@ -228,6 +228,9 @@ def _load_terrain(): st.session_state["profile"] = profile st.session_state["patch"] = patch st.session_state["cfg"] = cfg + st.session_state["loaded_label"] = ( + f"sidebar {mode} corridor ({optimizer_mode})" + ) # Tower-height edits start equal to the optimized layout. st.session_state["edited_heights"] = [t.height for t in st.session_state["alignment"].towers] @@ -250,6 +253,10 @@ def _load_terrain(): eval_res = st.session_state["eval"] history_best, history_avg = st.session_state["history"] +# "What is currently loaded" — surfaces in every tab so it's never +# ambiguous which corridor the Optimize / 3-D / Projects tabs show. +_loaded_label = st.session_state.get("loaded_label", "default synthetic 3 km corridor") + # ---- Tab 0: Demo (civil-engineer-friendly entry point) ---- with tab_demo: @@ -277,6 +284,10 @@ def _load_terrain(): "Roosevelt Island Tramway (NYC) — urban jig-back": dict(start=(-73.9580, 40.7570), end=(-73.9495, 40.7610), system="jigback", note="Queensboro Bridge fly-over."), + "Bhimashankar pilgrim ropeway (India) — Jyotirlinga, 2.18 km": + dict(start=(73.5123812, 19.0691450), end=(73.5330820, 19.0700494), + system="jigback", + note="2026-05-25 engineer trial. Western Ghats pilgrim line, Shidighat → Bhimashankar plateau. 742 m terminal rise over 2.18 km (34 % slope). Jig-back, single intermediate tower."), "Mexico City L2 + Caracas (new) — try a corridor": dict(start=(-99.04, 19.34), end=(-99.00, 19.37), system="mgd", note="Click two map points to override."), @@ -404,6 +415,7 @@ def _load_terrain(): st.session_state["alignment"] = align st.session_state["eval"] = eval_res_demo st.session_state["history"] = (result.history_best, result.history_avg) + st.session_state["loaded_label"] = f"{preset_name} (system: {demo_system})" progress.progress(1.0, text="Done.") @@ -427,14 +439,81 @@ def _load_terrain(): fig_c, _ = plot_convergence(result.history_best, result.history_avg) st.pyplot(fig_c, width="stretch") + # ---- P74: one-button "build me everything" ZIP ---- + st.markdown("---") + st.markdown("### Download the permit pack") + st.caption( + "Single ZIP with KML (Google Earth), DXF (AutoCAD), LandXML, " + "GeoJSON (QGIS), BoM, capex estimate, tower schedule, " + "convergence + alignment PNGs." + ) + + import io as _io + import zipfile + from datetime import datetime as _dt + from ropeway.bom import build_bom as _build_bom + from ropeway.cost import Region as _Region, estimate_cost as _est_cost + from ropeway.dxf_export import alignment_to_dxf as _to_dxf + from ropeway.io import ( + alignment_to_csv as _to_csv, + alignment_to_geojson as _to_geojson, + alignment_to_kml as _to_kml, + ) + from ropeway.landxml import alignment_to_landxml as _to_landxml + from ropeway.multi_rope import RopewaySystemType as _RST + + slug = "".join(c if c.isalnum() else "_" for c in preset_name).strip("_").lower() + with _io.BytesIO() as buf: + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + import tempfile as _tmp + with _tmp.TemporaryDirectory() as td: + td_p = Path(td) + _to_kml(align, profile, td_p / "alignment.kml", + project_name=preset_name, segments=eval_res_demo.segments) + _to_dxf(align, profile, td_p / "alignment.dxf", + project_name=preset_name) + _to_landxml(align, profile, td_p / "alignment.landxml") + _to_geojson(align, profile, td_p / "alignment.geojson") + _to_csv(align, profile, td_p / "towers.csv") + + sys_enum_map = {"jigback": _RST.JIG_BACK, "mgd": _RST.MGD, + "bgd": _RST.BGD, "3s": _RST.TGD_3S, + "chair": _RST.CHAIRLIFT, "funitel": _RST.FUNITEL} + bom_obj = _build_bom(align, project_name=preset_name, + system=sys_enum_map[demo_system], cfg=cfg_demo) + (td_p / "bom.csv").write_text(bom_obj.as_csv()) + est_obj = _est_cost(bom_obj, region=_Region.EMERGING) + (td_p / "cost_estimate.csv").write_text(est_obj.as_csv()) + + fig_a.savefig(td_p / "alignment.png", dpi=140) + fig_c.savefig(td_p / "convergence.png", dpi=140) + + for f in sorted(td_p.iterdir()): + zf.write(f, arcname=f"{slug}/{f.name}") + + zip_bytes = buf.getvalue() + + stamp = _dt.utcnow().strftime("%Y%m%d_%H%M") + st.download_button( + label="⬇ Download permit pack (ZIP)", + data=zip_bytes, + file_name=f"ropeway_{slug}_{stamp}.zip", + mime="application/zip", + type="primary", + use_container_width=True, + key="demo_zip_download", + ) + st.caption( - "Open the **3-D Digital Twin** tab above for the interactive view, " - "or the **Optimize** tab for downloads (DXF, LandXML, PDF, BoM, GeoJSON)." + "Need the interactive 3-D viewer? Open the **3-D Digital Twin** tab. " + "Need a knob-tunable run? **Advanced** users open the sidebar — but the " + "sidebar `Run optimization` button **overwrites** this Demo result." ) # ---- Tab 1: Optimize ---- with tab_opt: + st.caption(f"Currently loaded: **{_loaded_label}**") rep = eval_res.report n_int = max(0, len(alignment.towers) - 2) pphpd = _pphpd(float(line_speed), float(cfg.seat_spacing_m), @@ -487,6 +566,7 @@ def _load_terrain(): # ---- Tab 2: 3-D Digital Twin ---- with tab_3d: + st.caption(f"Currently loaded: **{_loaded_label}**") try: from stpyvista import stpyvista _HAVE_STPYVISTA = True diff --git a/docs/POST_TRIAL_PLAN.md b/docs/POST_TRIAL_PLAN.md new file mode 100644 index 0000000..f2786d4 --- /dev/null +++ b/docs/POST_TRIAL_PLAN.md @@ -0,0 +1,168 @@ +# Post-trial plan — web-first, civil-engineer-grade UX + +> **Operating principle.** A civil engineer opens a URL, picks two +> points on a map (or types a place name in plain English), and a +> finished alignment lands in front of them in a 3-D Google-Earth- +> grade scene with full export pack. No CLI. No Python. No mkdocs. +> No terminals visible anywhere. + +--- + +## What went wrong on 2026-05-24 + +| Symptom | Root cause | +|---|---| +| KML cable disappears into hillside in Google Earth | Our absolute-altitude KML uses Copernicus 30 m DEM. GE renders with its own (often finer) terrain. When the two disagree, cable elevation drops below GE's ground. | +| PyVista 3-D twin felt clunky | It is. It's a desktop research tool ported into a browser via trame; not the right surface. | +| Engineer couldn't operate it without you | Streamlit's sidebar still exposes engine knobs. Demo tab helps but the rest of the UI is still developer-facing. | +| CLI in the runbook | Engineers should never see one. Not in pre-flight, not for "regenerate twins", not anywhere. | +| `Tower 0` for terminals | Wrong vocabulary. Terminal = "Station". Intermediate = "Tower". | +| Static graphs | Engineer expected interactive plots (zoom, hover, compare). | + +All of the above are fixable. Plan below. + +--- + +## Three horizons + +### Horizon 1 — recover the demo (this week, 1-2 days) + +Goal: any engineer who opens the URL today **sees a working +Google-Earth-quality scene** without needing you. This is recovery, +not the rebuild. + +| # | Phase | Scope | Effort | Acceptance | +|---|---|---|---|---| +| **P72** | KML cable rides terrain | `altitudeMode=relativeToGround`; densify the cable polyline with N sag samples per span using the catenary equation; tower marker altitude = tower height above ground (not absolute). | S | Cable never goes underground in GE on Bhimashankar | +| **P73** | Station vs Tower labels everywhere | `Tower(is_station=True)` renders "Station " in KML / docs / Streamlit / plots / PDF. Names: `"Lower station"`, `"Upper station"`, `"Station "` for pinned stations. | S | No "Tower 0" anywhere a user sees | +| **P74** | One-button "build me everything" | Single Streamlit Demo-tab button that triggers: optimize → KML → DXF → LandXML → PDF → twin → returns a ZIP. Hide the Optimize and 3-D Twin tabs unless an "Advanced" toggle is on. | S | Engineer downloads one ZIP, opens KML in GE, gets the cable above terrain | +| **P75** | Demo runbook becomes a 30-sec video | Screen-cap a clean walkthrough: open URL → enter 2 coords → click Run → download ZIP → open KML in GE → fly the corridor. Host on the landing page as the hero. | S | Landing page leads with the video, not with text | + +After H1 the engineer can do the entire flow themselves from a +browser tab. Still Streamlit. Still your box. But it works end-to-end. + +### Horizon 2 — the web product (2-3 weeks) + +Goal: kill Streamlit + CLI for end users. Replace with a Next.js +SPA hitting FastAPI on your box via Cloudflare Tunnel. Cesium for +3-D, Plotly for graphs, AI for input. + +| # | Phase | Scope | Effort | Acceptance | +|---|---|---|---|---| +| **P76** | FastAPI corridor endpoint | `POST /api/v1/corridor` accepts `{start, end, system?, name?}`, runs the optimizer via Phase 9b job queue, returns `job_id` + WebSocket for live progress. | M | curl / Postman returns an alignment in 5-15 s | +| **P77** | Next.js SPA scaffold | TS + Next 15 + shadcn/ui + Tailwind + react-query. Single page: hero, map input, results panel. Vercel deploy. Hits FastAPI on the tunnel. | M | Deployed at `.vercel.app`; clicks → real backend | +| **P78** | Map-first corridor input | MapLibre or Leaflet (free, no key) for picking 2 points. Auto-reverse-geocode for station names. "Suggest endpoints" button. | M | Click 2 spots → see corridor highlighted → click Run | +| **P79** | Cesium 3-D viewer | Cesium Ion free tier (or open-tile fallback). Render terrain + cable + tower meshes. Free-fly camera. "Tour" button auto-flies the corridor. Cable uses relativeToGround so it never clips. | L | Engineer can fly the cable end-to-end in the browser, no plugin | +| **P80** | Interactive graphs | Plotly (or Observable Plot): convergence curve, elevation profile with cable overlay, tension diagram, capex bar. Hover for values. | M | Every metric on the result page is clickable / hoverable | +| **P81** | AI input mode | `POST /api/v1/ask` takes free text ("ropeway from Shidighat to Bhimashankar temple, MGD"), Claude Sonnet parses → coords + system + constraints → runs P76. Fallback to map if ambiguous. | M | Engineer types one sentence, gets a corridor | +| **P82** | Export pack on result page | One-click download for KML / DXF / LandXML / GeoJSON / PDF / ZIP. No CLI. | S | Right-rail panel with 6 download buttons | +| **P83** | Auth + tier gating | OAuth (P26 already done) carries through to the SPA. Free tier: 3 runs / day. Paid tier: unlimited + custom system overrides. | M | New user lands at `/sign-in`; signed in user sees tier badge | + +After H2 the product is a real web app. Any civil engineer with the +URL can model their own corridor. You only run the backend. + +### Horizon 3 — commercial-ready (1-2 months) + +Goal: the product reads like Doppelmayr's online configurator, not +like a research repo. + +| # | Phase | Scope | Effort | Acceptance | +|---|---|---|---|---| +| **P84** | Project workspace | Saved corridors, named projects, version history, share-link tokens. P26 user model + new `corridors` table. | M | Engineer can re-open a corridor a week later, share a URL with their colleague | +| **P85** | Walkthrough video generator | Server-side render a 30-60 s flyover MP4 of the optimized corridor (Cesium → headless Chromium → ffmpeg). Auto-attached to the result page; downloadable. | M | Result page shows a flyover the engineer can email to a stakeholder | +| **P86** | AI design assistant | Sonnet chat in the result panel: "make it cheaper", "fewer towers", "avoid this polygon I just drew" → translates to GA params + reruns. | M | Engineer iterates by chatting, not by re-clicking forms | +| **P87** | Stakeholder presentation export | One-click PDF that bundles: aerial photo from the satellite tile, alignment plan, validation summary, BoM table, flyover video link, share-link token. | M | Engineer downloads one PDF to take to a meeting | +| **P88** | Comparison mode | Side-by-side: optimizer result vs as-built (when known) vs engineer's manual sketch. Tower-by-tower diff. | M | Engineer can show "here's what the tool found that I missed" | +| **P89** | Realtime DEM upgrade | Pull latest Copernicus revisions + optionally Mapbox terrain RGB / Google Maps 3D-Tiles where licensed. Heuristic: pick best free source per region. | M | Bhimashankar terrain matches GE imagery 1:1 | +| **P90** | Cost model overhaul | Drop the Eurocode-EU defaults for emerging-market projects; use OEM-supplied unit costs (Doppelmayr/Leitner/Damodar) with region multipliers. Engineer-editable. | M | Capex matches a real published tender within ±20 % | +| **P91** | Audit + permit-pack PDF | Multi-page PDF auto-generated with cover page, all Eurocode/ISO checks, plan view at survey scale, tower schedule, BoM, capex, engineer sign-off block. | M | Engineer takes one PDF to a permitting office | +| **P92** | Mobile-friendly result view | Result page works on iPhone-class screens (engineer in the field). Cesium → fallback static images + numbers. | S | Demoable from a phone at the corridor site | + +After H3 this is a commercial-grade tool. Demoing it should feel +like demoing Figma, not like demoing a research notebook. + +--- + +## What we delete + +- **CLI as a user surface.** `ropeway` stays internal (you, CI). Engineer never sees a terminal. +- **Streamlit.** Replaced by the Next.js SPA. We keep one Streamlit page as an admin / debug surface but it's not the URL we share. +- **PyVista / trame 3-D twin.** Dead. Cesium replaces it. +- **mkdocs as the "landing page".** mkdocs stays for technical docs (engineers' second visit). The landing-page hero becomes the SPA hero. +- **Static PNG plots.** Replaced by interactive Plotly / Observable Plot. + +## What we keep + +- The **engine** (optimizer, NSGA-II, RSM surrogate, RL, all the + Eurocode checks, all the validation against 12 real installations). + That's the moat. Web is a skin over it. +- The **case studies** as marketing proof. Render them in Cesium + too, embed the flyovers on the landing. +- **FastAPI + Cloudflare Tunnel** topology from P28b. Already specced. + +--- + +## What an engineer experiences after H1 + H2 + H3 + +1. Opens `bhimashankar.example` from his phone. +2. Sees a 30-second flyover of Aiguille du Midi running automatically (proof). +3. Clicks **Try yours** → map opens. Drops two pins. +4. Map auto-resolves "Shidighat" and "Bhimashankar plateau" as station names. +5. Types `MGD, pilgrim service` in the system box (or skips it — AI defaults). +6. Clicks **Build my alignment**. +7. Watches a progress bar for 8 seconds. +8. Result page renders: + - **Hero:** auto-flown Cesium 3-D view of the alignment over satellite terrain. + - **Right rail:** download buttons (PDF / KML / DXF / LandXML / video). + - **Below the fold:** interactive elevation profile, tension diagram, convergence curve, capex bar. + - **Chat box:** "Make it cheaper" → AI re-runs with `w_n` raised → result updates inline. +9. Clicks **Share** → gets a link he sends his boss. +10. Boss opens it on a desktop → same scene + the **Download presentation PDF** button. + +No terminal. No mkdocs. No Streamlit. No "double-click the KML". + +--- + +## Sequence and dependency graph + +``` +H1 (recover): P72 ─ P73 ─ P74 ─ P75 [in series, 1-2 days] + │ +H2 (web product): P76 ─ P77 ─ P78 │ + │ │ │ │ + ├─── P79 ──┤ │ + ├─── P80 ──┤ │ + ├─── P81 ──┤ │ + ├─── P82 ──┤ │ + └─── P83 ──┘ │ + │ +H3 (commercial): P84 ─ P85 ─ P86 ─ P87 ─ P88 ─ P89 ─ P90 ─ P91 ─ P92 +``` + +H1 ships this week. +H2 ships in 2-3 weeks (one phase per day if focused). +H3 ships in 1-2 months. + +End of H2 = engineer-usable web product. +End of H3 = commercial-grade. + +--- + +## Decision points + +1. **Cesium Ion (free tier, key needed) vs Mapbox GL JS + open tiles (no key, less smooth)** — pick before P79. +2. **Vercel hosting tier** — free tier handles the SPA; FastAPI stays on your box via tunnel. Upgrade only if traffic warrants. +3. **AI provider** — Claude Sonnet 4.5/4.6 via Anthropic SDK is the default (P33 was already specced for this). Free tier with rate-limit; paid users pass their own key. +4. **Walkthrough-video tech** — Cesium-Sandcastle + headless Chromium + ffmpeg is the cheap path; Blender CLI is the fancy path. Start cheap. + +--- + +## What you do tomorrow (post-trial day 1) + +1. Merge **#58 / #70 / #71** (queued PRs from yesterday) so main reflects the demo pack. +2. Ship **P72 + P73** in one PR (KML fix + station/tower vocabulary fix). 30 min of work. +3. Cut **P74** branch (one-button "build everything" in Streamlit + ZIP download). 1 hr. +4. Record the **P75** video. 30 min. +5. **Re-send the URL** to the engineer with a one-sentence note: "fixed the visualisation bug + simplified the flow, please retry." + +That's day 1. P76 starts day 2. diff --git a/docs/POST_TRIAL_PLAN_V2.md b/docs/POST_TRIAL_PLAN_V2.md new file mode 100644 index 0000000..7f4ced9 --- /dev/null +++ b/docs/POST_TRIAL_PLAN_V2.md @@ -0,0 +1,113 @@ +# Post-trial plan v2 — feedback loop 1 + +> Trial 1 ran end-to-end (2026-05-25 night). SPA works, AI fills the +> form, optimiser runs, Google Earth KML downloads, refine chat +> changes weights. **But:** the cable visualisation in GE is off, +> the UI is plain, every document is download-only, and the +> "engineer who wants to twist every knob" path doesn't exist. v2 +> closes those. + +## What the engineer said, verbatim + +1. "I asked AI for more towers in Bhimashankar but it didn't do anything" + *(actual: it did halve `w_n`, but jig-back is a 2-cabin shuttle so + adding intermediates rarely helps; the UI didn't communicate this.)* +2. "Before giving download features, all documents should be shown + here in pages. Download is a good feature." +3. "Overall UI needs a major update." +4. "Something wrong in alignment of rope in google earth look. The wire?" +5. "I want full control of project and change of things to see the + changes." +6. "Make a new phase plan and update the readme and issues list and + mergers and prs." + +## Phase deltas + +### Wave A — visualisation correctness (ship first, blocks the demo) + +| # | Phase | Scope | Effort | Acceptance | +|---|---|---|---|---| +| **P95** | KML alignment fix v2 | Switch the cable LineString to `absolute` altitude, AND add a safety buffer so it never clips below GE's terrain when our DEM disagrees. Keep the existing `relativeToGround` mode behind a query parameter for backwards-compat. Add a second LineString in `clampToGround` mode (grey, thin) as a visual reference for the corridor footprint. | S | Bhimashankar opens in GE Pro with the cable arcing above the cliff face, not clipping into it | +| **P95b** | KML in-app preview | The SPA already embeds Cesium, but it doesn't show the engineer the *exact bytes* that go into Google Earth. Add an "Inspect KML" details panel that pretty-prints the raw XML so the engineer can sanity-check before hitting download. | S | Click "Inspect KML" → see the raw XML with syntax highlight | + +### Wave B — show, don't only let-them-download + +| # | Phase | Scope | Effort | Acceptance | +|---|---|---|---|---| +| **P93** | Inline document previews on the result page | Render the PDF in an ` + diff --git a/docs/case_studies/cablebus_linea2.md b/docs/case_studies/cablebus_linea2.md index 016f778..12c869b 100644 --- a/docs/case_studies/cablebus_linea2.md +++ b/docs/case_studies/cablebus_linea2.md @@ -144,5 +144,5 @@ calibrated for this archetype. Drag to rotate, scroll to zoom. Click the *Reset Camera* button (top-right inside the viewer) to recentre. - + diff --git a/docs/case_studies/engineer_trial_india.md b/docs/case_studies/engineer_trial_india.md new file mode 100644 index 0000000..270b603 --- /dev/null +++ b/docs/case_studies/engineer_trial_india.md @@ -0,0 +1,112 @@ +# Case study — Bhimashankar pilgrim ropeway (Maharashtra, India) + +> 2026-05-25 engineer-trial corridor. **Bhimashankar** is one of the +> twelve Jyotirlingas of Shiva, set on the Sahyadri plateau in the +> Western Ghats. Pilgrims today either trek the ~7 km zig-zag forest +> path or take a long road detour from the Pune side. The corridor +> below covers the straight-line aerial route from **Shidighat +> trail-head** (lower) to the **Bhimashankar plateau** (upper), +> optimised as a jig-back tram. + +## Corridor + +| Field | Value | +|---|---| +| Lower terminal | 73.5123812, 19.0691450 (Shidighat) | +| Upper terminal | 73.5330820, 19.0700494 (Bhimashankar plateau) | +| System type | `jigback` (bi-cable jig-back tram, two cabins) | +| DEM tile | `Copernicus_DSM_N19_E073.tif` (Copernicus GLO-30, public S3) | +| Region band | Emerging (Indian capex norms) | + +## Terrain + +| Metric | Value | +|---|---:| +| Horizontal corridor length | 2 181 m | +| Lower terminal elevation (DEM) | 211 m | +| Upper terminal elevation (DEM) | 953 m | +| Terminal-to-terminal rise | 742 m | +| Mean slope | 34 % | + +## Run + +```bash +python examples/case_engineer_trial_india.py +``` + +Outputs land in `docs/case_studies/engineer_trial_india_outputs/`. + +## Optimizer output + +| Metric | Value | +|---|---:| +| Feasible | ✅ | +| Stations (start + end) | 2 | +| Intermediate towers | **1** | +| Total cable length | 2 345 m | +| Min ground clearance | 2.92 m (above EN 12929-1 open-terrain minimum) | +| Max cable tension | 1 395 kN (~1.4 MN) | +| Capex (Emerging band) | **USD 3.9 M** (bare infrastructure) | + +## Tower schedule + +| # | Distance [m] | Ground [m] | Tower height [m] | Station | +|---:|---:|---:|---:|:---:| +| 0 | 0 | 211 | 5.0 | yes (Shidighat) | +| 1 | 1 820 | 948 | 38.7 | — | +| 2 | 2 181 | 953 | 61.9 | yes (Bhimashankar plateau) | + +The upper terminal tower (62 m) is tall because the plateau approach +demands an anchor well above the deck for cable tension geometry — +common for jig-back top stations on Alpine cliff faces (Aiguille du +Midi's top station tower is similar). + +## Interactive 3-D twin + +Drag to rotate, scroll to zoom. The two cabins shuttle in opposite +directions; the single intermediate tower sits where the corridor +crosses from the lower valley onto the cliff face. + + + +## Why jig-back, not MGD or 3S + +| System | Towers needed | Cable | Trade-off | +|---|---:|---:|---| +| **Jig-back** (chosen) | **1** | 2 345 m | Cleanest, fewest foundations; throughput ~ 1 200 PPHPD | +| 3S | 2 | 2 361 m | Higher throughput (~ 3 000 PPHPD), heavier civil works | +| MGD | 6 | 2 383 m | Highest throughput, but six foundations on the cliff face | + +For a Jyotirlinga site, jig-back is the right balance: pilgrim flow is +**peaky** (festival days), not continuous, so 3S's premium capacity is +under-utilised most of the year. Six MGD foundations on protected +forest terrain is the wrong trade. The single intermediate tower of +the jig-back lands on the plateau edge (km 1.82) — site-accessible +from the existing Bhimashankar trek road. + +## Next-step refinements an engineer can apply + +* **`NoTowerZone`** over the protected Bhimashankar wildlife sanctuary + zone if the corridor crosses it. +* **`ForcedFlyOverZone`** if the line crosses the trek path or any + road below at a stipulated minimum clearance. +* **Counterweight tensioning** (Phase 42 — backlog) for thermal + response in the 30 °C summer / 5 °C winter swing of the Sahyadris. + +## Outputs the engineer can hand a permitting office + +* `alignment.png` — plan + elevation view +* `alignment.dxf` — AutoCAD overlay for the survey +* `alignment.landxml` — LandXML 1.2 for civil software +* `alignment.kml` — **double-click to open in Google Earth Pro** — + 3-D cable suspended at correct anchor elevation, station vs tower + icons, ground-footprint reference line +* `alignment.geojson` — **drag-and-drop into QGIS** — point layer + for towers (with `height_m`, `anchor_elev_m`, `is_station` + attributes) + line layer for the cable centerline +* `towers.csv` — tower schedule +* `bom.csv` — bill of materials +* `cost_estimate.csv` — Emerging-band capex breakdown +* `twin.html` — standalone 3-D viewer (no plugin) + +Full walkthrough with screenshots: **[Visualize: Google Earth + QGIS](../VISUALIZE_GE_QGIS.md)**. diff --git a/docs/case_studies/engineer_trial_india_outputs/alignment.geojson b/docs/case_studies/engineer_trial_india_outputs/alignment.geojson new file mode 100644 index 0000000..e8bc86b --- /dev/null +++ b/docs/case_studies/engineer_trial_india_outputs/alignment.geojson @@ -0,0 +1,86 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 73.5123812, + 19.069145 + ] + }, + "properties": { + "kind": "tower", + "index": 0, + "distance_m": 0.0, + "height_m": 5.0, + "ground_elev_m": 211.33203125, + "anchor_elev_m": 216.33203125, + "is_station": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 73.529654578706, + 19.069899658935967 + ] + }, + "properties": { + "kind": "tower", + "index": 1, + "distance_m": 1819.7249926271627, + "height_m": 38.66155714796827, + "ground_elev_m": 947.605174428444, + "anchor_elev_m": 986.2667315764122, + "is_station": false + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 73.533082, + 19.0700494 + ] + }, + "properties": { + "kind": "tower", + "index": 2, + "distance_m": 2180.7987753006537, + "height_m": 61.84579899046157, + "ground_elev_m": 952.7059326171875, + "anchor_elev_m": 1014.5517316076491, + "is_station": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 73.5123812, + 19.069145 + ], + [ + 73.529654578706, + 19.069899658935967 + ], + [ + 73.533082, + 19.0700494 + ] + ] + }, + "properties": { + "kind": "cable_centerline", + "n_towers": 3 + } + } + ] +} \ No newline at end of file diff --git a/docs/case_studies/engineer_trial_india_outputs/alignment.kml b/docs/case_studies/engineer_trial_india_outputs/alignment.kml new file mode 100644 index 0000000..48dfc3e --- /dev/null +++ b/docs/case_studies/engineer_trial_india_outputs/alignment.kml @@ -0,0 +1,77 @@ + + + + Bhimashankar pilgrim ropeway + Generated by Autonomous Ropeway Alignment + + + + + + Towers + stations + + Lower station + 0 m
Ground elevation (DEM): 211 m
Support height: 5.0 m
Anchor elevation: 216 m
Lateral offset: 0.0 m]]>
+ #stationStyle + + absolute + 1 + 73.5123812,19.0691450,221.33 + +
+ + Tower 1 + 1820 m
Ground elevation (DEM): 948 m
Support height: 38.7 m
Anchor elevation: 986 m
Lateral offset: 0.0 m]]>
+ #towerStyle + + absolute + 1 + 73.5296546,19.0698997,991.27 + +
+ + Upper station + 2181 m
Ground elevation (DEM): 953 m
Support height: 61.8 m
Anchor elevation: 1015 m
Lateral offset: 0.0 m]]>
+ #stationStyle + + absolute + 1 + 73.5330820,19.0700494,1019.55 + +
+
+ + Cable centerline + #cableStyle + + absolute + 1 + 73.5123812,19.0691450,221.33 73.5131322,19.0691778,242.40 73.5138832,19.0692106,264.56 73.5146342,19.0692434,287.82 73.5153853,19.0692762,312.18 73.5161363,19.0693091,337.65 73.5168873,19.0693419,364.23 73.5176383,19.0693747,391.92 73.5183893,19.0694075,420.73 73.5191403,19.0694403,450.67 73.5198914,19.0694731,481.74 73.5206424,19.0695059,513.94 73.5213934,19.0695387,547.29 73.5221444,19.0695715,581.78 73.5228954,19.0696044,617.42 73.5236464,19.0696372,654.23 73.5243975,19.0696700,692.20 73.5251485,19.0697028,731.35 73.5258995,19.0697356,771.67 73.5266505,19.0697684,813.19 73.5274015,19.0698012,855.90 73.5281525,19.0698340,899.81 73.5289036,19.0698668,944.93 73.5296546,19.0698997,991.27 73.5298036,19.0699062,992.04 73.5299526,19.0699127,992.85 73.5301016,19.0699192,993.71 73.5302507,19.0699257,994.60 73.5303997,19.0699322,995.54 73.5305487,19.0699387,996.52 73.5306977,19.0699452,997.54 73.5308467,19.0699517,998.61 73.5309957,19.0699583,999.71 73.5311448,19.0699648,1000.86 73.5312938,19.0699713,1002.05 73.5314428,19.0699778,1003.27 73.5315918,19.0699843,1004.55 73.5317408,19.0699908,1005.86 73.5318899,19.0699973,1007.21 73.5320389,19.0700038,1008.61 73.5321879,19.0700103,1010.05 73.5323369,19.0700168,1011.53 73.5324859,19.0700234,1013.05 73.5326349,19.0700299,1014.61 73.5327840,19.0700364,1016.22 73.5329330,19.0700429,1017.86 73.5330820,19.0700494,1019.55 + + + + Ground centerline (reference) + #groundStyle + + clampToGround + 1 + 73.5123812,19.0691450,0 73.5296546,19.0698997,0 73.5330820,19.0700494,0 + + +
+
diff --git a/docs/case_studies/engineer_trial_india_outputs/alignment.landxml b/docs/case_studies/engineer_trial_india_outputs/alignment.landxml new file mode 100644 index 0000000..9601f73 --- /dev/null +++ b/docs/case_studies/engineer_trial_india_outputs/alignment.landxml @@ -0,0 +1,626 @@ + + + + + + + + + + + +

2109172.1599 343478.0163 211.3320

+

2109172.7198 343492.9428 211.3320

+

2109173.2797 343507.8693 209.2982

+

2109173.8396 343522.7958 209.2982

+

2109174.3995 343537.7223 208.5165

+

2109174.9593 343552.6487 208.5165

+

2109175.5192 343567.5752 203.3816

+

2109176.0791 343582.5017 203.3816

+

2109176.6390 343597.4282 200.1037

+

2109177.1989 343612.3547 200.1037

+

2109177.7587 343627.2812 208.4063

+

2109178.3186 343642.2076 208.4063

+

2109178.8785 343657.1341 211.0188

+

2109179.4384 343672.0606 211.0188

+

2109179.9982 343686.9871 212.6246

+

2109180.5581 343701.9136 212.6246

+

2109181.1180 343716.8400 211.9171

+

2109181.6779 343731.7665 211.9171

+

2109182.2378 343746.6930 214.0314

+

2109182.7976 343761.6195 214.0314

+

2109183.3575 343776.5460 217.0697

+

2109183.9174 343791.4725 217.0697

+

2109184.4773 343806.3989 220.3253

+

2109185.0371 343821.3254 220.3253

+

2109185.5970 343836.2519 221.6600

+

2109186.1569 343851.1784 221.6600

+

2109186.7168 343866.1049 220.7635

+

2109187.2767 343881.0313 220.7635

+

2109187.8365 343895.9578 229.5566

+

2109188.3964 343910.8843 229.5566

+

2109188.9563 343925.8108 234.5771

+

2109189.5162 343940.7373 234.5771

+

2109190.0761 343955.6637 247.5578

+

2109190.6359 343970.5902 247.5578

+

2109191.1958 343985.5167 255.7281

+

2109191.7557 344000.4432 255.7281

+

2109192.3156 344015.3697 267.5416

+

2109192.8754 344030.2962 267.5416

+

2109193.4353 344045.2226 276.6315

+

2109193.9952 344060.1491 276.6315

+

2109194.5551 344075.0756 283.5996

+

2109195.1150 344090.0021 283.5996

+

2109195.6748 344104.9286 291.4220

+

2109196.2347 344119.8550 310.3563

+

2109196.7946 344134.7815 310.3563

+

2109197.3545 344149.7080 324.9875

+

2109197.9144 344164.6345 324.9875

+

2109198.4742 344179.5610 338.0427

+

2109199.0341 344194.4874 338.0427

+

2109199.5940 344209.4139 355.3078

+

2109200.1539 344224.3404 355.3078

+

2109200.7137 344239.2669 377.3161

+

2109201.2736 344254.1934 377.3161

+

2109201.8335 344269.1199 413.8779

+

2109202.3934 344284.0463 413.8779

+

2109202.9533 344298.9728 459.0427

+

2109203.5131 344313.8993 459.0427

+

2109204.0730 344328.8258 501.6923

+

2109204.6329 344343.7523 501.6923

+

2109205.1928 344358.6787 516.0773

+

2109205.7526 344373.6052 516.0773

+

2109206.3125 344388.5317 523.8799

+

2109206.8724 344403.4582 523.8799

+

2109207.4323 344418.3847 528.7481

+

2109207.9922 344433.3111 528.7481

+

2109208.5520 344448.2376 531.9070

+

2109209.1119 344463.1641 531.9070

+

2109209.6718 344478.0906 536.2961

+

2109210.2317 344493.0171 536.2961

+

2109210.7916 344507.9436 539.6147

+

2109211.3514 344522.8700 539.6147

+

2109211.9113 344537.7965 539.6049

+

2109212.4712 344552.7230 539.6049

+

2109213.0311 344567.6495 539.6849

+

2109213.5909 344582.5760 539.6849

+

2109214.1508 344597.5024 540.3336

+

2109214.7107 344612.4289 540.3336

+

2109215.2706 344627.3554 544.1775

+

2109215.8305 344642.2819 544.1775

+

2109216.3903 344657.2084 547.2432

+

2109216.9502 344672.1349 547.2432

+

2109217.5101 344687.0613 546.7462

+

2109218.0700 344701.9878 546.7462

+

2109218.6299 344716.9143 547.1078

+

2109219.1897 344731.8408 547.1078

+

2109219.7496 344746.7673 552.8835

+

2109220.3095 344761.6937 552.8835

+

2109220.8694 344776.6202 562.6164

+

2109221.4292 344791.5467 562.6164

+

2109221.9891 344806.4732 571.0504

+

2109222.5490 344821.3997 578.8035

+

2109223.1089 344836.3261 578.8035

+

2109223.6688 344851.2526 593.3199

+

2109224.2286 344866.1791 593.3199

+

2109224.7885 344881.1056 608.7072

+

2109225.3484 344896.0321 608.7072

+

2109225.9083 344910.9586 626.1749

+

2109226.4681 344925.8850 626.1749

+

2109227.0280 344940.8115 644.8753

+

2109227.5879 344955.7380 644.8753

+

2109228.1478 344970.6645 666.0374

+

2109228.7077 344985.5910 666.0374

+

2109229.2675 345000.5174 690.9371

+

2109229.8274 345015.4439 690.9371

+

2109230.3873 345030.3704 720.0711

+

2109230.9472 345045.2969 720.0711

+

2109231.5071 345060.2234 758.8792

+

2109232.0669 345075.1498 758.8792

+

2109232.6268 345090.0763 809.7198

+

2109233.1867 345105.0028 809.7198

+

2109233.7466 345119.9293 848.6212

+

2109234.3064 345134.8558 848.6212

+

2109234.8663 345149.7823 863.4473

+

2109235.4262 345164.7087 863.4473

+

2109235.9861 345179.6352 874.0479

+

2109236.5460 345194.5617 874.0479

+

2109237.1058 345209.4882 877.0389

+

2109237.6657 345224.4147 877.0389

+

2109238.2256 345239.3411 894.9111

+

2109238.7855 345254.2676 894.9111

+

2109239.3454 345269.1941 924.8425

+

2109239.9052 345284.1206 924.8425

+

2109240.4651 345299.0471 952.3718

+

2109241.0250 345313.9735 952.3718

+

2109241.5849 345328.9000 971.3268

+

2109242.1447 345343.8265 971.3268

+

2109242.7046 345358.7530 981.0471

+

2109243.2645 345373.6795 981.0471

+

2109243.8244 345388.6060 979.4263

+

2109244.3843 345403.5324 979.4263

+

2109244.9441 345418.4589 987.0508

+

2109245.5040 345433.3854 987.0508

+

2109246.0639 345448.3119 992.6397

+

2109246.6238 345463.2384 992.6397

+

2109247.1836 345478.1648 983.0143

+

2109247.7435 345493.0913 983.0143

+

2109248.3034 345508.0178 968.9052

+

2109248.8633 345522.9443 956.9344

+

2109249.4232 345537.8708 956.9344

+

2109249.9830 345552.7972 951.9834

+

2109250.5429 345567.7237 951.9834

+

2109251.1028 345582.6502 951.3024

+

2109251.6627 345597.5767 951.3024

+

2109252.2226 345612.5032 953.9964

+

2109252.7824 345627.4297 953.9964

+

2109253.3423 345642.3561 952.7059

+

2109253.9022 345657.2826 952.7059

+

2109112.2021 343480.2653 211.3320

+

2109112.7620 343495.1918 211.3320

+

2109113.3219 343510.1183 209.2982

+

2109113.8817 343525.0447 209.2982

+

2109114.4416 343539.9712 208.5165

+

2109115.0015 343554.8977 208.5165

+

2109115.5614 343569.8242 203.3816

+

2109116.1213 343584.7507 203.3816

+

2109116.6811 343599.6772 200.1037

+

2109117.2410 343614.6036 200.1037

+

2109117.8009 343629.5301 208.4063

+

2109118.3608 343644.4566 208.4063

+

2109118.9206 343659.3831 211.0188

+

2109119.4805 343674.3096 211.0188

+

2109120.0404 343689.2360 212.6246

+

2109120.6003 343704.1625 212.6246

+

2109121.1602 343719.0890 211.9171

+

2109121.7200 343734.0155 211.9171

+

2109122.2799 343748.9420 214.0314

+

2109122.8398 343763.8685 214.0314

+

2109123.3997 343778.7949 217.0697

+

2109123.9596 343793.7214 217.0697

+

2109124.5194 343808.6479 220.3253

+

2109125.0793 343823.5744 220.3253

+

2109125.6392 343838.5009 221.6600

+

2109126.1991 343853.4273 221.6600

+

2109126.7589 343868.3538 220.7635

+

2109127.3188 343883.2803 220.7635

+

2109127.8787 343898.2068 229.5566

+

2109128.4386 343913.1333 229.5566

+

2109128.9985 343928.0597 234.5771

+

2109129.5583 343942.9862 234.5771

+

2109130.1182 343957.9127 247.5578

+

2109130.6781 343972.8392 247.5578

+

2109131.2380 343987.7657 255.7281

+

2109131.7979 344002.6922 255.7281

+

2109132.3577 344017.6186 267.5416

+

2109132.9176 344032.5451 267.5416

+

2109133.4775 344047.4716 276.6315

+

2109134.0374 344062.3981 276.6315

+

2109134.5972 344077.3246 283.5996

+

2109135.1571 344092.2510 283.5996

+

2109135.7170 344107.1775 291.4220

+

2109136.2769 344122.1040 310.3563

+

2109136.8368 344137.0305 310.3563

+

2109137.3966 344151.9570 324.9875

+

2109137.9565 344166.8834 324.9875

+

2109138.5164 344181.8099 338.0427

+

2109139.0763 344196.7364 338.0427

+

2109139.6361 344211.6629 355.3078

+

2109140.1960 344226.5894 355.3078

+

2109140.7559 344241.5159 377.3161

+

2109141.3158 344256.4423 377.3161

+

2109141.8757 344271.3688 413.8779

+

2109142.4355 344286.2953 413.8779

+

2109142.9954 344301.2218 459.0427

+

2109143.5553 344316.1483 459.0427

+

2109144.1152 344331.0747 501.6923

+

2109144.6751 344346.0012 501.6923

+

2109145.2349 344360.9277 516.0773

+

2109145.7948 344375.8542 516.0773

+

2109146.3547 344390.7807 523.8799

+

2109146.9146 344405.7071 523.8799

+

2109147.4744 344420.6336 528.7481

+

2109148.0343 344435.5601 528.7481

+

2109148.5942 344450.4866 531.9070

+

2109149.1541 344465.4131 531.9070

+

2109149.7140 344480.3396 536.2961

+

2109150.2738 344495.2660 536.2961

+

2109150.8337 344510.1925 539.6147

+

2109151.3936 344525.1190 539.6147

+

2109151.9535 344540.0455 539.6049

+

2109152.5134 344554.9720 539.6049

+

2109153.0732 344569.8984 539.6849

+

2109153.6331 344584.8249 539.6849

+

2109154.1930 344599.7514 540.3336

+

2109154.7529 344614.6779 540.3336

+

2109155.3127 344629.6044 544.1775

+

2109155.8726 344644.5309 544.1775

+

2109156.4325 344659.4573 547.2432

+

2109156.9924 344674.3838 547.2432

+

2109157.5523 344689.3103 546.7462

+

2109158.1121 344704.2368 546.7462

+

2109158.6720 344719.1633 547.1078

+

2109159.2319 344734.0897 547.1078

+

2109159.7918 344749.0162 552.8835

+

2109160.3516 344763.9427 552.8835

+

2109160.9115 344778.8692 562.6164

+

2109161.4714 344793.7957 562.6164

+

2109162.0313 344808.7221 571.0504

+

2109162.5912 344823.6486 578.8035

+

2109163.1510 344838.5751 578.8035

+

2109163.7109 344853.5016 593.3199

+

2109164.2708 344868.4281 593.3199

+

2109164.8307 344883.3546 608.7072

+

2109165.3906 344898.2810 608.7072

+

2109165.9504 344913.2075 626.1749

+

2109166.5103 344928.1340 626.1749

+

2109167.0702 344943.0605 644.8753

+

2109167.6301 344957.9870 644.8753

+

2109168.1899 344972.9134 666.0374

+

2109168.7498 344987.8399 666.0374

+

2109169.3097 345002.7664 690.9371

+

2109169.8696 345017.6929 690.9371

+

2109170.4295 345032.6194 720.0711

+

2109170.9893 345047.5458 720.0711

+

2109171.5492 345062.4723 758.8792

+

2109172.1091 345077.3988 758.8792

+

2109172.6690 345092.3253 809.7198

+

2109173.2289 345107.2518 809.7198

+

2109173.7887 345122.1783 848.6212

+

2109174.3486 345137.1047 848.6212

+

2109174.9085 345152.0312 863.4473

+

2109175.4684 345166.9577 863.4473

+

2109176.0282 345181.8842 874.0479

+

2109176.5881 345196.8107 874.0479

+

2109177.1480 345211.7371 877.0389

+

2109177.7079 345226.6636 877.0389

+

2109178.2678 345241.5901 894.9111

+

2109178.8276 345256.5166 894.9111

+

2109179.3875 345271.4431 924.8425

+

2109179.9474 345286.3695 924.8425

+

2109180.5073 345301.2960 952.3718

+

2109181.0671 345316.2225 952.3718

+

2109181.6270 345331.1490 971.3268

+

2109182.1869 345346.0755 971.3268

+

2109182.7468 345361.0020 981.0471

+

2109183.3067 345375.9284 981.0471

+

2109183.8665 345390.8549 979.4263

+

2109184.4264 345405.7814 979.4263

+

2109184.9863 345420.7079 987.0508

+

2109185.5462 345435.6344 987.0508

+

2109186.1061 345450.5608 992.6397

+

2109186.6659 345465.4873 992.6397

+

2109187.2258 345480.4138 983.0143

+

2109187.7857 345495.3403 983.0143

+

2109188.3456 345510.2668 968.9052

+

2109188.9054 345525.1932 956.9344

+

2109189.4653 345540.1197 956.9344

+

2109190.0252 345555.0462 951.9834

+

2109190.5851 345569.9727 951.9834

+

2109191.1450 345584.8992 951.3024

+

2109191.7048 345599.8257 951.3024

+

2109192.2647 345614.7521 953.9964

+

2109192.8246 345629.6786 953.9964

+

2109193.3845 345644.6051 952.7059

+

2109193.9444 345659.5316 952.7059

+
+ + 1 2 148 + 2 149 148 + 2 3 149 + 3 150 149 + 3 4 150 + 4 151 150 + 4 5 151 + 5 152 151 + 5 6 152 + 6 153 152 + 6 7 153 + 7 154 153 + 7 8 154 + 8 155 154 + 8 9 155 + 9 156 155 + 9 10 156 + 10 157 156 + 10 11 157 + 11 158 157 + 11 12 158 + 12 159 158 + 12 13 159 + 13 160 159 + 13 14 160 + 14 161 160 + 14 15 161 + 15 162 161 + 15 16 162 + 16 163 162 + 16 17 163 + 17 164 163 + 17 18 164 + 18 165 164 + 18 19 165 + 19 166 165 + 19 20 166 + 20 167 166 + 20 21 167 + 21 168 167 + 21 22 168 + 22 169 168 + 22 23 169 + 23 170 169 + 23 24 170 + 24 171 170 + 24 25 171 + 25 172 171 + 25 26 172 + 26 173 172 + 26 27 173 + 27 174 173 + 27 28 174 + 28 175 174 + 28 29 175 + 29 176 175 + 29 30 176 + 30 177 176 + 30 31 177 + 31 178 177 + 31 32 178 + 32 179 178 + 32 33 179 + 33 180 179 + 33 34 180 + 34 181 180 + 34 35 181 + 35 182 181 + 35 36 182 + 36 183 182 + 36 37 183 + 37 184 183 + 37 38 184 + 38 185 184 + 38 39 185 + 39 186 185 + 39 40 186 + 40 187 186 + 40 41 187 + 41 188 187 + 41 42 188 + 42 189 188 + 42 43 189 + 43 190 189 + 43 44 190 + 44 191 190 + 44 45 191 + 45 192 191 + 45 46 192 + 46 193 192 + 46 47 193 + 47 194 193 + 47 48 194 + 48 195 194 + 48 49 195 + 49 196 195 + 49 50 196 + 50 197 196 + 50 51 197 + 51 198 197 + 51 52 198 + 52 199 198 + 52 53 199 + 53 200 199 + 53 54 200 + 54 201 200 + 54 55 201 + 55 202 201 + 55 56 202 + 56 203 202 + 56 57 203 + 57 204 203 + 57 58 204 + 58 205 204 + 58 59 205 + 59 206 205 + 59 60 206 + 60 207 206 + 60 61 207 + 61 208 207 + 61 62 208 + 62 209 208 + 62 63 209 + 63 210 209 + 63 64 210 + 64 211 210 + 64 65 211 + 65 212 211 + 65 66 212 + 66 213 212 + 66 67 213 + 67 214 213 + 67 68 214 + 68 215 214 + 68 69 215 + 69 216 215 + 69 70 216 + 70 217 216 + 70 71 217 + 71 218 217 + 71 72 218 + 72 219 218 + 72 73 219 + 73 220 219 + 73 74 220 + 74 221 220 + 74 75 221 + 75 222 221 + 75 76 222 + 76 223 222 + 76 77 223 + 77 224 223 + 77 78 224 + 78 225 224 + 78 79 225 + 79 226 225 + 79 80 226 + 80 227 226 + 80 81 227 + 81 228 227 + 81 82 228 + 82 229 228 + 82 83 229 + 83 230 229 + 83 84 230 + 84 231 230 + 84 85 231 + 85 232 231 + 85 86 232 + 86 233 232 + 86 87 233 + 87 234 233 + 87 88 234 + 88 235 234 + 88 89 235 + 89 236 235 + 89 90 236 + 90 237 236 + 90 91 237 + 91 238 237 + 91 92 238 + 92 239 238 + 92 93 239 + 93 240 239 + 93 94 240 + 94 241 240 + 94 95 241 + 95 242 241 + 95 96 242 + 96 243 242 + 96 97 243 + 97 244 243 + 97 98 244 + 98 245 244 + 98 99 245 + 99 246 245 + 99 100 246 + 100 247 246 + 100 101 247 + 101 248 247 + 101 102 248 + 102 249 248 + 102 103 249 + 103 250 249 + 103 104 250 + 104 251 250 + 104 105 251 + 105 252 251 + 105 106 252 + 106 253 252 + 106 107 253 + 107 254 253 + 107 108 254 + 108 255 254 + 108 109 255 + 109 256 255 + 109 110 256 + 110 257 256 + 110 111 257 + 111 258 257 + 111 112 258 + 112 259 258 + 112 113 259 + 113 260 259 + 113 114 260 + 114 261 260 + 114 115 261 + 115 262 261 + 115 116 262 + 116 263 262 + 116 117 263 + 117 264 263 + 117 118 264 + 118 265 264 + 118 119 265 + 119 266 265 + 119 120 266 + 120 267 266 + 120 121 267 + 121 268 267 + 121 122 268 + 122 269 268 + 122 123 269 + 123 270 269 + 123 124 270 + 124 271 270 + 124 125 271 + 125 272 271 + 125 126 272 + 126 273 272 + 126 127 273 + 127 274 273 + 127 128 274 + 128 275 274 + 128 129 275 + 129 276 275 + 129 130 276 + 130 277 276 + 130 131 277 + 131 278 277 + 131 132 278 + 132 279 278 + 132 133 279 + 133 280 279 + 133 134 280 + 134 281 280 + 134 135 281 + 135 282 281 + 135 136 282 + 136 283 282 + 136 137 283 + 137 284 283 + 137 138 284 + 138 285 284 + 138 139 285 + 139 286 285 + 139 140 286 + 140 287 286 + 140 141 287 + 141 288 287 + 141 142 288 + 142 289 288 + 142 143 289 + 143 290 289 + 143 144 290 + 144 291 290 + 144 145 291 + 145 292 291 + 145 146 292 + 146 293 292 + 146 147 293 + 147 294 293 + +
+
+
+ + + + + 2109142.1810 343479.1408 + 2109223.9233 345658.4071 + + + + + 0.0000 216.3320 + 1819.7250 986.2667 + 2180.7988 1014.5517 + + + + + + 2109142.1810 343479.1408 211.3320 + 2109210.3892 345297.5871 947.6052 + 2109223.9233 345658.4071 952.7059 + +
\ No newline at end of file diff --git a/docs/case_studies/engineer_trial_india_outputs/alignment.png b/docs/case_studies/engineer_trial_india_outputs/alignment.png new file mode 100644 index 0000000..e29d4d3 Binary files /dev/null and b/docs/case_studies/engineer_trial_india_outputs/alignment.png differ diff --git a/docs/case_studies/engineer_trial_india_outputs/bom.csv b/docs/case_studies/engineer_trial_india_outputs/bom.csv new file mode 100644 index 0000000..dd439d7 --- /dev/null +++ b/docs/case_studies/engineer_trial_india_outputs/bom.csv @@ -0,0 +1,10 @@ +item,qty,unit,notes +Steel tower fabrication,29542.06,kg,"sum of 3 towers, k=280 kg/m" +Tower-top sheave assembly,1.00,ea,"one assembly per intermediate tower" +Concrete foundation,67.50,m^3,"3 pads, 2.5 m deep" +Reinforcing rebar,5400.00,kg,"80 kg/m^3 of concrete (typ. mountain footing)" +Steel wire rope,15071.70,m,"6 strand(s) incl. 5% slack + 50 m tails" +Station building (drive),1.00,ea,"incl. drive + tensioning station" +Station building (return),1.00,ea,"incl. return bullwheel" +Drive motor + VFD,1.00,ea,"rated to design speed and full load" +Carriers (cabins / chairs),1.00,ea,"@ 3000 m spacing, 80 pax each" diff --git a/docs/case_studies/engineer_trial_india_outputs/convergence.png b/docs/case_studies/engineer_trial_india_outputs/convergence.png new file mode 100644 index 0000000..278dd96 Binary files /dev/null and b/docs/case_studies/engineer_trial_india_outputs/convergence.png differ diff --git a/docs/case_studies/engineer_trial_india_outputs/cost_estimate.csv b/docs/case_studies/engineer_trial_india_outputs/cost_estimate.csv new file mode 100644 index 0000000..7e4f427 --- /dev/null +++ b/docs/case_studies/engineer_trial_india_outputs/cost_estimate.csv @@ -0,0 +1,13 @@ +item,qty,unit_price_eur,total_eur +Steel tower fabrication,29542.06,3.60,106351.41 +Tower-top sheave assembly,1.00,32000.00,32000.00 +Concrete foundation,67.50,210.00,14175.00 +Reinforcing rebar,5400.00,1.40,7560.00 +Steel wire rope,15071.70,88.00,1326309.21 +Station building (drive),1.00,950000.00,950000.00 +Station building (return),1.00,620000.00,620000.00 +Drive motor + VFD,1.00,400000.00,400000.00 +Carriers (cabins / chairs),1.00,30000.00,30000.00 +Subtotal,,,3486395.63 +Contingency,,,418367.48 +Grand total,,,3904763.10 diff --git a/docs/case_studies/engineer_trial_india_outputs/towers.csv b/docs/case_studies/engineer_trial_india_outputs/towers.csv new file mode 100644 index 0000000..7b8cb75 --- /dev/null +++ b/docs/case_studies/engineer_trial_india_outputs/towers.csv @@ -0,0 +1,4 @@ +index,distance_m,ground_elev_m,tower_height_m,anchor_elev_m,is_station +0,0.00,211.33,5.00,216.33,1 +1,1819.72,947.61,38.66,986.27,0 +2,2180.80,952.71,61.85,1014.55,1 diff --git a/docs/case_studies/engineer_trial_india_outputs/twin.html b/docs/case_studies/engineer_trial_india_outputs/twin.html new file mode 100644 index 0000000..f752aa8 --- /dev/null +++ b/docs/case_studies/engineer_trial_india_outputs/twin.html @@ -0,0 +1,26 @@ + + + + + + VTK.js | Example - OfflineLocalView + + + +
+ + + + + diff --git a/docs/case_studies/funitel_peclet.md b/docs/case_studies/funitel_peclet.md index 2d80198..0e5208e 100644 --- a/docs/case_studies/funitel_peclet.md +++ b/docs/case_studies/funitel_peclet.md @@ -130,5 +130,5 @@ to be validated against a real installation. Drag to rotate, scroll to zoom. Click the *Reset Camera* button (top-right inside the viewer) to recentre. - + diff --git a/docs/case_studies/london_ifs_cloud.md b/docs/case_studies/london_ifs_cloud.md index 1129655..80deab9 100644 --- a/docs/case_studies/london_ifs_cloud.md +++ b/docs/case_studies/london_ifs_cloud.md @@ -135,5 +135,5 @@ behaviour a real PLA-approved design must exhibit. Drag to rotate, scroll to zoom. Click the *Reset Camera* button (top-right inside the viewer) to recentre. - + diff --git a/docs/case_studies/medellin_linea_k.md b/docs/case_studies/medellin_linea_k.md index d478877..034f2cf 100644 --- a/docs/case_studies/medellin_linea_k.md +++ b/docs/case_studies/medellin_linea_k.md @@ -143,5 +143,5 @@ feature — multi-waypoint urban routing works. Drag to rotate, scroll to zoom. Click the *Reset Camera* button (top-right inside the viewer) to recentre. - + diff --git a/docs/case_studies/mi_teleferico_linea_roja.md b/docs/case_studies/mi_teleferico_linea_roja.md index 1ea6225..52eaabf 100644 --- a/docs/case_studies/mi_teleferico_linea_roja.md +++ b/docs/case_studies/mi_teleferico_linea_roja.md @@ -173,5 +173,5 @@ The single hardest physics question — **how much altitude must the cable rise* Drag to rotate, scroll to zoom. Click the *Reset Camera* button (top-right inside the viewer) to recentre. - + diff --git a/docs/case_studies/ngong_ping_360.md b/docs/case_studies/ngong_ping_360.md index 9f60c36..691cae6 100644 --- a/docs/case_studies/ngong_ping_360.md +++ b/docs/case_studies/ngong_ping_360.md @@ -136,5 +136,5 @@ system's value lies — to cross the bay in as few towers as possible. Drag to rotate, scroll to zoom. Click the *Reset Camera* button (top-right inside the viewer) to recentre. - + diff --git a/docs/case_studies/portland_ohsu.md b/docs/case_studies/portland_ohsu.md index d501cfa..f631e35 100644 --- a/docs/case_studies/portland_ohsu.md +++ b/docs/case_studies/portland_ohsu.md @@ -146,5 +146,5 @@ closest cabin-capacity reproduction in the case-study set. Drag to rotate, scroll to zoom. Click the *Reset Camera* button (top-right inside the viewer) to recentre. - + diff --git a/docs/case_studies/roosevelt_island.md b/docs/case_studies/roosevelt_island.md index 3278a8e..b4bfb98 100644 --- a/docs/case_studies/roosevelt_island.md +++ b/docs/case_studies/roosevelt_island.md @@ -147,5 +147,5 @@ discipline a real urban tramway over a public waterway must show. Drag to rotate, scroll to zoom. Click the *Reset Camera* button (top-right inside the viewer) to recentre. - + diff --git a/docs/case_studies/whistler_peak2peak.md b/docs/case_studies/whistler_peak2peak.md index 19e8300..ec6554a 100644 --- a/docs/case_studies/whistler_peak2peak.md +++ b/docs/case_studies/whistler_peak2peak.md @@ -179,5 +179,5 @@ Within the system-spec band. Drag to rotate, scroll to zoom. Click the *Reset Camera* button (top-right inside the viewer) to recentre. - + diff --git a/docs/case_studies/whistler_peak_chair.md b/docs/case_studies/whistler_peak_chair.md index 8801630..7e1ca74 100644 --- a/docs/case_studies/whistler_peak_chair.md +++ b/docs/case_studies/whistler_peak_chair.md @@ -132,5 +132,5 @@ the sixth and final catalogue archetype. Drag to rotate, scroll to zoom. Click the *Reset Camera* button (top-right inside the viewer) to recentre. - + diff --git a/docs/case_studies/zugspitze_eibsee.md b/docs/case_studies/zugspitze_eibsee.md index 72de41c..b128112 100644 --- a/docs/case_studies/zugspitze_eibsee.md +++ b/docs/case_studies/zugspitze_eibsee.md @@ -178,5 +178,5 @@ decision exactly. Drag to rotate, scroll to zoom. Click the *Reset Camera* button (top-right inside the viewer) to recentre. - + diff --git a/docs/index.md b/docs/index.md index 19e73ab..5313477 100644 --- a/docs/index.md +++ b/docs/index.md @@ -191,6 +191,14 @@
Sixth archetype validated
+ + Bhimashankar pilgrim ropeway +
+

Bhimashankar pilgrim ropeway

+
Jig-back · Western Ghats, 2.18 km
+
Jyotirlinga · 742 m rise · 1 intermediate tower
+
+
## What's inside diff --git a/examples/case_engineer_trial_india.py b/examples/case_engineer_trial_india.py new file mode 100644 index 0000000..0e9b573 --- /dev/null +++ b/examples/case_engineer_trial_india.py @@ -0,0 +1,115 @@ +"""Case study: Bhimashankar pilgrim ropeway (Maharashtra, India). + +Bhimashankar is one of the twelve Jyotirlingas of Shiva, set in the +Sahyadri (Western Ghats) range of Maharashtra. The temple sits on +the plateau above a 600+ m vertical rise from the Shidighat trail +head — pilgrims today either trek several hours or take a long road +detour. The corridor below covers the **straight-line aerial route** +from Shidighat (lower) to the village/temple plateau (upper), +optimised as a jig-back tram for the 2026-05-25 engineer trial. + +Corridor: 2.18 km horizontal, ~740 m terminal-to-terminal rise +(34 % mean slope) — comparable in profile to alpine tourist trams +like the Aiguille du Midi Stage 2. + +Coordinates probed against Google Earth: lower at the Shidighat +viewpoint, upper near Landscape View / Bhimashankar trek-road head. +""" + +from __future__ import annotations + +from pathlib import Path + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +from ropeway.alignment import evaluate_alignment +from ropeway.bom import build_bom +from ropeway.cost import Region, estimate_cost +from ropeway.dem import extract_profile_from_dem +from ropeway.io import alignment_to_csv, alignment_to_geojson, alignment_to_kml +from ropeway.landxml import alignment_to_landxml +from ropeway.multi_rope import RopewaySystemType, system_defaults +from ropeway.optimizer import GAConfig, optimize +from ropeway.viz import plot_alignment, plot_convergence + +# Lower: Shidighat trail head. +# Upper: Bhimashankar plateau (near temple / village). +START_LON, START_LAT = 73.5123812, 19.0691450 +END_LON, END_LAT = 73.5330820, 19.0700494 + +DEM_PATH = Path("data/dem/Copernicus_DSM_N19_E073.tif") +OUT_DIR = Path("docs/case_studies/engineer_trial_india_outputs") + +SYSTEM = RopewaySystemType.JIG_BACK # bi-cable jig-back tram, two cabins + + +def main() -> None: + if not DEM_PATH.exists(): + raise SystemExit( + f"Missing {DEM_PATH}. Run `python -c \"from ropeway.dem import " + "ensure_dem_tile; ensure_dem_tile(73.51, 19.07, cache_dir='data/dem')\"` " + "first to seed the cache." + ) + + profile = extract_profile_from_dem( + DEM_PATH, (START_LON, START_LAT), (END_LON, END_LAT), + sample_spacing_m=15.0, + ) + print(f"Corridor length : {profile.total_length:.0f} m") + print(f"Elevation range : " + f"{profile.elevation.min():.0f} -> {profile.elevation.max():.0f} m") + print(f"Total elevation gain : " + f"{profile.elevation.max() - profile.elevation.min():.0f} m") + + cfg = system_defaults(SYSTEM) + ga = GAConfig( + generations=120, population_size=100, seed=2026, + max_intermediate_towers=8, + ) + + result = optimize( + profile.as_function(), profile.total_length, cfg=cfg, ga=ga, + verbose=False, + ) + + OUT_DIR.mkdir(parents=True, exist_ok=True) + align = result.best_alignment + eval_res = result.best_result + rep = eval_res.report + + fig, _ = plot_alignment(profile, align, segments=eval_res.segments, + title="Bhimashankar pilgrim ropeway — optimised jig-back") + fig.savefig(OUT_DIR / "alignment.png", dpi=140) + plt.close(fig) + + fig, _ = plot_convergence(result.history_best, result.history_avg) + fig.savefig(OUT_DIR / "convergence.png", dpi=140) + plt.close(fig) + + alignment_to_geojson(align, profile, OUT_DIR / "alignment.geojson") + alignment_to_csv(align, profile, OUT_DIR / "towers.csv") + alignment_to_landxml(align, profile, OUT_DIR / "alignment.landxml") + alignment_to_kml(align, profile, OUT_DIR / "alignment.kml", + project_name="Bhimashankar pilgrim ropeway", + segments=eval_res.segments) + + bom = build_bom(align, project_name="Bhimashankar pilgrim ropeway", + system=SYSTEM, cfg=cfg) + (OUT_DIR / "bom.csv").write_text(bom.as_csv()) + est = estimate_cost(bom, region=Region.EMERGING) + (OUT_DIR / "cost_estimate.csv").write_text(est.as_csv()) + + print() + print(f"Feasible : {eval_res.feasible}") + print(f"Intermediate towers : {max(0, len(align.towers) - 2)}") + print(f"Cable length : {rep.total_cable_length_m:.0f} m") + print(f"Min ground clearance : {rep.min_clearance_m:.2f} m") + print(f"Max cable tension : {rep.max_tension_n / 1e3:.1f} kN") + print(f"Capex (EMERGING band) : USD {est.grand_total:,.0f}") + print(f"Artifacts written -> {OUT_DIR}/") + + +if __name__ == "__main__": + main() diff --git a/mkdocs.yml b/mkdocs.yml index 69c6200..c20283a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -56,6 +56,7 @@ markdown_extensions: nav: - Home: index.md - Process: PROCESS.md + - "Visualize: Google Earth + QGIS": VISUALIZE_GE_QGIS.md - Pitch: PITCH.md - Pricing: PRICING.md - Whitepaper: WHITEPAPER.md @@ -76,3 +77,4 @@ nav: - case_studies/funitel_peclet.md - case_studies/ngong_ping_360.md - case_studies/whistler_peak_chair.md + - case_studies/engineer_trial_india.md diff --git a/pyproject.toml b/pyproject.toml index 376e62f..95cc527 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,8 @@ server = [ "pydantic[email]>=2.6", ] dev = ["pytest>=8.0", "httpx>=0.27", "pytest-cov>=5.0", "hypothesis>=6.100"] +# AI uses a local Ollama instance via plain HTTP — no SDK needed. +# Operator runs `ollama serve` and `ollama pull llama3.2:3b` (or similar). docs = [ "mkdocs>=1.6", "mkdocs-material>=9.5", diff --git a/src/ropeway/io.py b/src/ropeway/io.py index ec6e212..d92ca69 100644 --- a/src/ropeway/io.py +++ b/src/ropeway/io.py @@ -1,14 +1,33 @@ -"""GeoJSON export for towers and cable centerline.""" +"""GeoJSON + KML export for towers and cable centerline.""" from __future__ import annotations import json from pathlib import Path +import numpy as np from pyproj import CRS, Transformer from .alignment import Alignment from .dem import TerrainProfile +from .physics import Catenary + + +def _support_label(index: int, total: int, is_station: bool) -> str: + """P73 — Station vs Tower naming used across KML / GeoJSON / plots. + + - index 0 -> "Lower station" + - index N-1 -> "Upper station" + - other ``is_station=True`` -> "Station " + - everything else -> "Tower " + """ + if index == 0: + return "Lower station" + if index == total - 1: + return "Upper station" + if is_station: + return f"Station {index}" + return f"Tower {index}" def _bearing_lonlat(p1: tuple[float, float], p2: tuple[float, float]) -> tuple[float, float, float]: @@ -46,21 +65,24 @@ def lonlat_at(distance_m: float) -> tuple[float, float]: ) features = [] + n_total = len(alignment.towers) for i, t in enumerate(alignment.towers): lon, lat = lonlat_at(t.distance) ground = profile.elevation_at(t.distance) + is_station = bool(t.is_station) or i in (0, n_total - 1) features.append( { "type": "Feature", "geometry": {"type": "Point", "coordinates": [lon, lat]}, "properties": { - "kind": "tower", + "kind": "station" if is_station else "tower", + "label": _support_label(i, n_total, is_station), "index": i, "distance_m": t.distance, "height_m": t.height, "ground_elev_m": ground, "anchor_elev_m": ground + t.height, - "is_station": bool(t.is_station) or i in (0, len(alignment.towers) - 1), + "is_station": is_station, }, } ) @@ -83,6 +105,212 @@ def lonlat_at(distance_m: float) -> tuple[float, float]: return out_path +def alignment_to_kml( + alignment: Alignment, + profile: TerrainProfile, + out_path: str | Path, + *, + project_name: str = "Ropeway alignment", + segments: list[Catenary] | None = None, + cable_samples_per_span: int = 24, + altitude_mode: str = "absolute", + safety_buffer_m: float = 5.0, +) -> Path: + """Export the alignment as a Google-Earth-ready KML file. + + **P95 (v2) — cable alignment fix.** + + ``altitude_mode``: + * ``"absolute"`` (default) — the cable is rendered at its + true WGS-84 elevation. This is what the engineer's permit- + review software expects. To survive disagreements between + our 30 m Copernicus DEM and GE's finer terrain, we lift + the cable by ``safety_buffer_m`` (default 5 m) so it + never clips into a GE-stylised ridge that's slightly + taller than the DEM we built it from. + * ``"relativeToGround"`` — the cable's height is measured + above whatever GE thinks the ground is at each sample + (the original P72 behaviour). Use this when you want the + cable to *look* clean over a different terrain set, at + the cost of being slightly mis-aligned in WGS-84. + + Geometry shipped: + - one ```` per support; ```` placed at + the tower-top anchor in the chosen altitude mode (with + the same safety buffer applied when ``absolute``). + - one ```` for the **cable centerline** as a + densified ```` (one sample per + ``cable_samples_per_span``) using the catenary equation + when ``segments=`` is passed. + - one ```` for the **ground centerline**, always + ``clampToGround`` (visual footprint). + + Pass ``segments=eval_result.segments`` to use the true catenary + sag curve; without it the cable is rendered as straight chords + between anchors. + + P73 — naming: index 0 = "Lower station", index N-1 = "Upper + station", any other ``is_station=True`` = "Station ", + intermediate non-station = "Tower ". + + Coords in WGS84 (EPSG:4326). Open by double-clicking the .kml in + Google Earth Pro (desktop) or uploading via 'New project' → + 'Import KML file' on earth.google.com. + """ + if altitude_mode not in ("absolute", "relativeToGround"): + raise ValueError( + f"altitude_mode must be 'absolute' or 'relativeToGround', " + f"got {altitude_mode!r}" + ) + out_path = Path(out_path) + start = profile.start_lonlat + end = profile.end_lonlat + total_len_m = profile.total_length + if total_len_m <= 0: + raise ValueError("profile has zero length") + + def lonlat_at(distance_m: float) -> tuple[float, float]: + t = max(0.0, min(1.0, distance_m / total_len_m)) + return ( + start[0] + (end[0] - start[0]) * t, + start[1] + (end[1] - start[1]) * t, + ) + + n = len(alignment.towers) + tower_placemarks: list[str] = [] + ground_coords: list[str] = [] + + # Per-tower Placemarks. In ``absolute`` mode we emit the true + # WGS-84 anchor elevation (ground + tower_height) lifted by + # ``safety_buffer_m`` so GE's stylised terrain can't hide the + # marker inside a slightly-taller-than-DEM ridge. In + # ``relativeToGround`` mode we just emit the tower height. + for i, t in enumerate(alignment.towers): + lon, lat = lonlat_at(t.distance) + ground = float(profile.elevation_at(t.distance)) + anchor = ground + t.height + is_station = bool(t.is_station) or i in (0, n - 1) + style = "stationStyle" if is_station else "towerStyle" + label = _support_label(i, n, is_station) + descr = ( + f"{t.distance:.0f} m
" + f"Ground elevation (DEM): {ground:.0f} m
" + f"Support height: {t.height:.1f} m
" + f"Anchor elevation: {anchor:.0f} m
" + f"Lateral offset: {t.offset:.1f} m]]>" + ) + if altitude_mode == "absolute": + alt_value = anchor + safety_buffer_m + else: + alt_value = t.height + safety_buffer_m + tower_placemarks.append( + f' \n' + f' {label}\n' + f' {descr}\n' + f' #{style}\n' + f' \n' + f' {altitude_mode}\n' + f' 1\n' + f' {lon:.7f},{lat:.7f},{alt_value:.2f}\n' + f' \n' + f' ' + ) + ground_coords.append(f"{lon:.7f},{lat:.7f},0") + + # Cable centerline — densified with the catenary sag curve when + # available, else fall back to straight chords. In ``absolute`` + # mode each sample's altitude is the catenary value + safety + # buffer (so it rides cleanly above GE's terrain even when our + # 30 m DEM disagrees). In ``relativeToGround`` mode each sample + # is (cable_elev - local_ground) + buffer. + cable_coords: list[str] = [] + use_segments = ( + segments and len(segments) == n - 1 + and all(abs(t.offset) < 1e-6 for t in alignment.towers) + ) + if use_segments: + # Phase 12c lateral offsets break the simple plan-x ↔ distance + # mapping; fall back to straight chords in that case. + for span_idx, seg in enumerate(segments): + xs, ys = seg.sample(max(2, int(cable_samples_per_span))) + # First sample of every span except the first would duplicate + # the previous span's last anchor — skip. + i_start = 1 if span_idx > 0 else 0 + for x, y in zip(xs[i_start:], ys[i_start:]): + d = float(x) + lon, lat = lonlat_at(d) + if altitude_mode == "absolute": + alt_value = float(y) + safety_buffer_m + else: + ground = float(profile.elevation_at(d)) + alt_value = max(0.0, float(y) - ground) + safety_buffer_m + cable_coords.append(f"{lon:.7f},{lat:.7f},{alt_value:.2f}") + else: + # Chord fallback. Anchor points only. + for t in alignment.towers: + lon, lat = lonlat_at(t.distance) + ground = float(profile.elevation_at(t.distance)) + if altitude_mode == "absolute": + alt_value = ground + t.height + safety_buffer_m + else: + alt_value = t.height + safety_buffer_m + cable_coords.append(f"{lon:.7f},{lat:.7f},{alt_value:.2f}") + + kml = ( + '\n' + '\n' + ' \n' + f' {project_name}\n' + ' Generated by Autonomous Ropeway Alignment\n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' Towers + stations\n' + f'{chr(10).join(tower_placemarks)}\n' + ' \n' + ' \n' + ' Cable centerline\n' + ' #cableStyle\n' + ' \n' + f' {altitude_mode}\n' + ' 1\n' + f' {" ".join(cable_coords)}\n' + ' \n' + ' \n' + ' \n' + ' Ground centerline (reference)\n' + ' #groundStyle\n' + ' \n' + ' clampToGround\n' + ' 1\n' + f' {" ".join(ground_coords)}\n' + ' \n' + ' \n' + ' \n' + '\n' + ) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(kml) + return out_path + + def alignment_to_csv(alignment: Alignment, profile: TerrainProfile, out_path: str | Path) -> Path: """Export tower schedule as CSV.""" out_path = Path(out_path) diff --git a/src/ropeway/server/ai.py b/src/ropeway/server/ai.py new file mode 100644 index 0000000..95e955a --- /dev/null +++ b/src/ropeway/server/ai.py @@ -0,0 +1,427 @@ +"""P81 — AI input mode (Ollama-backed). + +Engineer types a plain-English request, an Ollama model on the same +box parses it into a structured corridor request the engineer can +confirm before launching the optimiser. + +POST /api/v1/ask + {"text": "ropeway from Shidighat to Bhimashankar temple, MGD"} + +Returns: + { + "parsed": {start: [...], end: [...], system: "...", name: "..."}, + "model": "...", + "raw": + } + +Env: + OLLAMA_HOST (default: http://localhost:11434) + OLLAMA_MODEL (default: qwen3:14b — good at structured JSON) + OLLAMA_TIMEOUT_S (default: 60) + +If Ollama is unreachable the endpoint returns 503 with a hint to run +`ollama serve` + `ollama pull qwen3:14b`. Empty / parse-failing model +output returns 422 — the engineer can edit by hand on the map. +""" + +from __future__ import annotations + +import json as _json +import os +from textwrap import dedent + +import httpx +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + + +router = APIRouter(prefix="/api/v1", tags=["ai"]) + + +def _ollama_host() -> str: + return os.environ.get("OLLAMA_HOST", "http://localhost:11434").rstrip("/") + + +def _ollama_model() -> str: + # Default to a fast, small model; qwen3:14b is more accurate but + # slow on CPU. Operator can pin a stronger model via env. + return os.environ.get("OLLAMA_MODEL", "gemma4:e4b") + + +def _timeout_s() -> float: + return float(os.environ.get("OLLAMA_TIMEOUT_S", "60")) + + +_SYSTEM_PROMPT = dedent("""\ + You parse a one- or two-sentence civil-engineering request describing a + ropeway corridor and emit a JSON object the optimiser can run. + + Schema (every field required unless marked optional): + { + "start": [lon, lat], // lower terminal, decimal degrees WGS-84 + "end": [lon, lat], // upper terminal, decimal degrees WGS-84 + "system": "jigback" | "mgd" | "bgd" | "3s" | "chair" | "funitel", + "name": "", // optional, derive from request + "notes": "" + } + + Rules: + - Resolve named places to their best-known WGS-84 coordinates. + If a place is ambiguous, pick the most famous match. + - Pick `system` from the engineer's hint; if unspecified, default + to 'mgd' for urban / pilgrim / sub-3 km lines, 'jigback' for + single-span tourist trams above 1 km, '3s' for high-capacity + cross-valley lines, 'funitel' for high-wind / alpine resorts, + 'chair' only when explicitly asked. + - Always emit valid JSON. No prose, no markdown fences, just JSON. + - All coordinates as decimal degrees, lon first. + + If you cannot resolve either endpoint, emit: + {"error": ""} + """) + + +class AskRequest(BaseModel): + text: str = Field(min_length=4, max_length=2000) + + +class ParsedCorridor(BaseModel): + start: tuple[float, float] + end: tuple[float, float] + system: str + name: str = "Corridor" + notes: str = "" + + +def _call_ollama(prompt: str) -> dict: + """POST /api/chat with format=json. Returns parsed dict from + `message.content`. Raises HTTPException with a useful detail on + any failure mode.""" + body = { + "model": _ollama_model(), + "stream": False, + "format": "json", + "messages": [ + {"role": "system", "content": _SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ], + "options": {"temperature": 0.1}, + } + try: + r = httpx.post(f"{_ollama_host()}/api/chat", json=body, + timeout=_timeout_s()) + except httpx.ConnectError as exc: + raise HTTPException( + status_code=503, + detail=( + f"Ollama not reachable at {_ollama_host()}. " + "Start it with `ollama serve` and pull the model: " + f"`ollama pull {_ollama_model()}`. ({exc})" + ), + ) from exc + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, + detail=f"Ollama transport error: {exc}") from exc + if r.status_code != 200: + raise HTTPException(status_code=502, + detail=f"Ollama returned {r.status_code}: {r.text[:300]}") + + payload = r.json() + content = (payload.get("message") or {}).get("content", "") + if not content: + raise HTTPException(status_code=422, + detail=f"Empty model response: {payload}") + + # Some models prefix with ``` or stray whitespace despite format=json. + content = content.strip() + if content.startswith("```"): + content = content.split("```", 2)[1].lstrip("json").strip() + + try: + return _json.loads(content) + except _json.JSONDecodeError as exc: + raise HTTPException( + status_code=422, + detail=f"Model output not valid JSON: {exc}; raw: {content[:400]}", + ) from exc + + +def _validate_parsed(raw: dict) -> ParsedCorridor: + if "error" in raw: + raise HTTPException(status_code=422, detail=str(raw["error"])) + + for k in ("start", "end", "system"): + if k not in raw: + raise HTTPException(status_code=422, + detail=f"missing key {k!r} in model output: {raw}") + + valid_systems = {"jigback", "mgd", "bgd", "3s", "chair", "funitel"} + system = str(raw["system"]).lower() + if system not in valid_systems: + # Map common alternative spellings. + alias = {"gondola": "mgd", "tram": "jigback", "pendulum": "jigback", + "tricable": "3s", "monocable": "mgd", "bicable": "bgd"} + system = alias.get(system, system) + if system not in valid_systems: + raise HTTPException( + status_code=422, + detail=f"unknown system {raw['system']!r}; " + f"expected one of {sorted(valid_systems)}", + ) + + def _lonlat(v) -> tuple[float, float]: + if not isinstance(v, (list, tuple)) or len(v) != 2: + raise HTTPException(status_code=422, + detail=f"expected [lon, lat], got {v!r}") + lon, lat = float(v[0]), float(v[1]) + if not (-180 <= lon <= 180) or not (-90 <= lat <= 90): + raise HTTPException(status_code=422, + detail=f"lon/lat out of range: {v!r}") + return (lon, lat) + + return ParsedCorridor( + start=_lonlat(raw["start"]), + end=_lonlat(raw["end"]), + system=system, + name=str(raw.get("name") or "Corridor")[:120], + notes=str(raw.get("notes") or "")[:500], + ) + + +@router.post("/ask") +def ask(req: AskRequest) -> dict: + """Parse a natural-language ropeway request into a CorridorRequest. + + Returns the parsed payload for the SPA to display + let the + engineer confirm before submitting to /api/v1/corridor. This + endpoint deliberately does **not** auto-submit — a chatty model + on a junk request shouldn't burn a 15-second GA run without the + engineer's "yes". + """ + raw = _call_ollama(req.text) + parsed = _validate_parsed(raw) + return { + "parsed": parsed.model_dump(), + "model": _ollama_model(), + "host": _ollama_host(), + "raw": raw, + } + + +_REFINE_PROMPT = dedent("""\ + You translate a one-line refinement request into GA cost-weight + adjustments and an optional system change. The previous alignment + is given as context. Reply with JSON only: + + { + "w_n_scale": , // multiply w_n (>1 favours fewer towers) + "w_h_scale": , // multiply w_h (>1 favours shorter towers) + "w_L_scale": , // multiply w_L (>1 favours shorter cable) + "system": , + "rationale": "" + } + + Defaults: 1.0 for every scale, null for system. + + **Calibration** — the optimiser only responds to BIG weight shifts. + Use these scales (measured on real corridors): + + 'fewer towers' / 'less towers' / 'minimise towers' + -> w_n_scale = 20.0 + 'more towers' / 'add intermediates' / 'denser' + -> w_n_scale = 0.05 + 'shorter towers' / 'lower towers' + -> w_h_scale = 10.0 + 'taller towers' / 'higher anchors' + -> w_h_scale = 0.2 + 'shorter cable' / 'less cable' + -> w_L_scale = 10.0 + 'cheaper' / 'reduce cost' / 'lower capex' + -> w_n_scale = 4.0, w_L_scale = 4.0 + 'switch to ' / 'use instead' + -> system = "" (others unchanged) + + All scales clamped to [0.01, 100.0] by the server. Round to one + decimal. JSON only — no prose, no markdown fences, no tags. + """) + + +class RefineRequest(BaseModel): + previous_job_id: str + instruction: str = Field(min_length=2, max_length=400) + + +def _parse_refine(instruction: str, prev_summary: str) -> dict: + body = { + "model": _ollama_model(), + "stream": False, + "format": "json", + "messages": [ + {"role": "system", "content": _REFINE_PROMPT}, + {"role": "user", "content": f"Previous result: {prev_summary}\n\nRefinement: {instruction}"}, + ], + "options": {"temperature": 0.1}, + } + try: + r = httpx.post(f"{_ollama_host()}/api/chat", json=body, timeout=_timeout_s()) + except httpx.ConnectError as exc: + raise HTTPException(status_code=503, + detail=f"Ollama not reachable: {exc}") from exc + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, + detail=f"Ollama transport error: {exc}") from exc + if r.status_code != 200: + raise HTTPException(status_code=502, + detail=f"Ollama returned {r.status_code}") + content = (r.json().get("message") or {}).get("content", "").strip() + if content.startswith("```"): + content = content.split("```", 2)[1].lstrip("json").strip() + try: + return _json.loads(content) + except _json.JSONDecodeError as exc: + raise HTTPException(status_code=422, + detail=f"Refine output not JSON: {exc}; raw: {content[:300]}") from exc + + +def _clamp_scale(v) -> float: + try: + x = float(v) + except (TypeError, ValueError): + return 1.0 + # Widened from the v1 [0.1, 50.0] band: jig-back / 3S corridors + # need a 20× hit on w_n to actually move the GA off its default + # tower count, and the LLM now emits those scales (see _REFINE_PROMPT). + return max(0.01, min(100.0, x)) + + +@router.post("/refine") +def refine(req: RefineRequest) -> dict: + """Take a previous job + plain-English refinement, kick off a new + job with adjusted GA cost weights / system / warm-start.""" + # Local import to keep ai.py free of corridor circulars at module load. + from .corridor import ( + CorridorJob, CorridorJobStatus, CorridorRequest, + _STORE, _STORE_LOCK, _run_corridor_job, + ) + import uuid + + with _STORE_LOCK: + prev = _STORE.get(req.previous_job_id) + if prev is None: + raise HTTPException(status_code=404, detail="previous job not found") + if prev.status is not CorridorJobStatus.DONE or prev.result is None: + raise HTTPException(status_code=409, detail="previous job not done yet") + + prev_summary = ( + f"start={prev.body.start}, end={prev.body.end}, system={prev.body.system}, " + f"intermediate_towers={prev.result.get('intermediate_towers')}, " + f"cable_length_m={prev.result.get('cable_length_m'):.0f}, " + f"min_clearance_m={prev.result.get('min_clearance_m'):.2f}, " + f"max_tension_kn={prev.result.get('max_tension_kn'):.0f}, " + f"capex_usd={prev.result.get('capex_usd_estimate'):.0f}" + ) + raw = _parse_refine(req.instruction, prev_summary) + + # Build new CorridorRequest with adjusted weights + optional system. + w_n_scale = _clamp_scale(raw.get("w_n_scale", 1.0)) + w_h_scale = _clamp_scale(raw.get("w_h_scale", 1.0)) + w_L_scale = _clamp_scale(raw.get("w_L_scale", 1.0)) + new_system_raw = raw.get("system") + new_system = prev.body.system + if isinstance(new_system_raw, str): + cand = new_system_raw.lower().strip() + if cand and cand != "null" and cand != "none": + new_system = cand + + # Defaults from alignment.CostWeights, multiplied. + new_w_n = 50_000.0 * w_n_scale + new_w_h = 1_000.0 * w_h_scale + new_w_L = 50.0 * w_L_scale + + # Read prior best alignment's towers from the artifacts dir so we + # can warm-start. Falls back to no seed if towers.csv is missing. + seed_towers = None + if prev.artifacts_dir is not None: + towers_csv = prev.artifacts_dir / "towers.csv" + if towers_csv.exists(): + import csv as _csv + seed_towers = [] + with towers_csv.open() as f: + for row in _csv.DictReader(f): + seed_towers.append({ + "distance_m": float(row.get("distance_m") or 0.0), + "height_m": float(row.get("tower_height_m") or 12.0), + "is_station": str(row.get("is_station") or "").strip() in ("1", "true", "True"), + "offset": 0.0, + }) + + new_body = CorridorRequest( + start=prev.body.start, + end=prev.body.end, + system=new_system, + name=f"{prev.body.name} (refined)", + generations=prev.body.generations, + population_size=prev.body.population_size, + max_intermediate_towers=prev.body.max_intermediate_towers, + seed=prev.body.seed + 1, + w_n=new_w_n, + w_h=new_w_h, + w_L=new_w_L, + seed_towers=seed_towers, + ) + + job = CorridorJob(id=str(uuid.uuid4()), body=new_body) + with _STORE_LOCK: + _STORE[job.id] = job + # Run in-thread. FastAPI sync routes execute on a threadpool so + # this doesn't block the event loop, and the SPA polls the status + # endpoint the same way it does for /api/v1/corridor. + _run_corridor_job(job.id) + + # P98 — before/after diff payload so the SPA can render a + # 4-column comparison instead of a flat "Applied: ..." caption. + diff = None + new_result = job.result if job.status is CorridorJobStatus.DONE else None + if new_result is not None and prev.result is not None: + metric_keys = [ + ("Intermediate towers", "intermediate_towers", "{:.0f}", None), + ("Cable length [m]", "cable_length_m", "{:.0f}", None), + ("Min clearance [m]", "min_clearance_m", "{:.2f}", None), + ("Max tension [kN]", "max_tension_kn", "{:.0f}", None), + ("Capex (USD)", "capex_usd_estimate", "${:,.0f}", "M"), + ("Cost", "cost", "{:,.0f}", None), + ] + diff = [] + for label, key, fmt, _ in metric_keys: + before_v = prev.result.get(key) + after_v = new_result.get(key) + try: + delta = float(after_v) - float(before_v) + delta_pct = (delta / float(before_v) * 100.0) if float(before_v) else 0.0 + except (TypeError, ValueError): + delta = delta_pct = None + diff.append({ + "metric": label, + "before": before_v, + "after": after_v, + "delta": delta, + "delta_pct": delta_pct, + }) + + return { + "job_id": job.id, + "status_url": f"/api/v1/corridor/{job.id}", + "artifacts_url": f"/api/v1/corridor/{job.id}/artifacts", + "previous_job_id": prev.id, + "applied": { + "w_n": new_w_n, "w_h": new_w_h, "w_L": new_w_L, + "system": new_system, + "rationale": str(raw.get("rationale", ""))[:300], + }, + "instruction": req.instruction, + "diff": diff, + "raw_model_output": raw, + } + + +__all__ = ["router"] diff --git a/src/ropeway/server/api.py b/src/ropeway/server/api.py index 3424ee9..5b1ec01 100644 --- a/src/ropeway/server/api.py +++ b/src/ropeway/server/api.py @@ -167,6 +167,14 @@ def create_app(settings: Settings | None = None) -> FastAPI: from .jobs import router as jobs_router app.include_router(jobs_router) + # ---- P76: corridor API — coords-in / artifacts-out (web product) ---- + from .corridor import router as corridor_router + app.include_router(corridor_router) + + # ---- P81: AI input — plain-English → CorridorRequest via local Ollama ---- + from .ai import router as ai_router + app.include_router(ai_router) + # ---- COMM-4: optional Prometheus /metrics + structured request log ---- from .observability import configure_prometheus, configure_structured_logs configure_prometheus(app) diff --git a/src/ropeway/server/corridor.py b/src/ropeway/server/corridor.py new file mode 100644 index 0000000..39b77d1 --- /dev/null +++ b/src/ropeway/server/corridor.py @@ -0,0 +1,656 @@ +"""P76 — Corridor API: two coordinates in, alignment + permit-pack out. + +This is the backbone the Next.js SPA (H2) hits. The existing +`/optimize/async` endpoint takes synthetic-corridor params; this one +takes real `(lon, lat)` terminals, auto-fetches the matching +Copernicus tile via Phase 47's cache, runs the optimizer, writes every +artifact to a tmpdir keyed by job id, and serves them back. + +Surface: + POST /api/v1/corridor — submit (returns job_id) + GET /api/v1/corridor/{job_id} — status + summary + GET /api/v1/corridor/{job_id}/artifacts — list + GET /api/v1/corridor/{job_id}/artifacts/{kind} — fetch one + +`kind` ∈ {kml, dxf, landxml, geojson, towers_csv, bom_csv, +cost_csv, alignment_png, convergence_png, zip}. +""" + +from __future__ import annotations + +import io as _io +import tempfile +import threading +import time +import uuid +import zipfile +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any + +import matplotlib +matplotlib.use("Agg", force=True) +import matplotlib.pyplot as plt +from fastapi import APIRouter, BackgroundTasks, HTTPException +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field, field_validator + + +class CorridorJobStatus(str, Enum): + PENDING = "pending" + RUNNING = "running" + DONE = "done" + FAILED = "failed" + + +@dataclass +class CorridorJob: + id: str + body: "CorridorRequest" + status: CorridorJobStatus = CorridorJobStatus.PENDING + submitted_at: float = field(default_factory=time.time) + started_at: float | None = None + finished_at: float | None = None + artifacts_dir: Path | None = None + result: dict[str, Any] | None = None + error: str | None = None + + def to_dict(self) -> dict: + return { + "job_id": self.id, + "status": self.status.value, + "submitted_at": self.submitted_at, + "started_at": self.started_at, + "finished_at": self.finished_at, + "elapsed_s": ( + (self.finished_at or time.time()) - (self.started_at or self.submitted_at) + ), + "result": self.result, + "error": self.error, + "request": self.body.model_dump(), + } + + +_STORE: dict[str, CorridorJob] = {} +_STORE_LOCK = threading.Lock() + + +# P96 — whitelist of ConstraintConfig fields the SPA may override. +# Restricting to physically-meaningful knobs an engineer would +# actually twist; everything else (RNG seeds, internal margins) +# stays at system_defaults(). +_ALLOWED_CONSTRAINT_OVERRIDES: dict[str, tuple[float, float]] = { + "min_ground_clearance_m": (0.5, 20.0), + "max_span_m": (100.0, 5000.0), + "min_span_m": (10.0, 1000.0), + "swing_angle_deg": (0.0, 30.0), + "min_tower_height_m": (3.0, 30.0), + "max_tower_height_m": (20.0, 200.0), + "horizontal_tension_n": (50_000.0, 2_000_000.0), + "cable_weight_n_per_m": (10.0, 500.0), + "seat_spacing_m": (5.0, 500.0), + "passengers_per_seat": (1.0, 200.0), + "dynamic_load_factor": (1.0, 2.0), + "max_break_over_angle_deg": (5.0, 60.0), + "max_cable_tension_n": (50_000.0, 5_000_000.0), + "corridor_half_width_m": (0.0, 500.0), + "max_plan_deflection_deg": (0.0, 60.0), + "design_wind_speed_m_s": (10.0, 80.0), + "foundation_overturning_sf_required": (1.0, 5.0), + "foundation_sliding_sf_required": (1.0, 5.0), + "temperature_delta_k": (-50.0, 50.0), + "ice_thickness_m": (0.0, 0.2), +} + + +def _apply_overrides(cfg, overrides: dict[str, float] | None): + """Return a new ConstraintConfig with whitelisted fields overridden.""" + from dataclasses import replace + if not overrides: + return cfg + sanitised: dict[str, float] = {} + for key, raw in overrides.items(): + if key not in _ALLOWED_CONSTRAINT_OVERRIDES: + # Silently ignore unknown keys rather than 400 — the SPA may + # send forward-compatible payloads against an older server. + continue + try: + v = float(raw) + except (TypeError, ValueError): + continue + lo, hi = _ALLOWED_CONSTRAINT_OVERRIDES[key] + sanitised[key] = max(lo, min(hi, v)) + # ConstraintConfig has integer fields too (passengers_per_seat). + if "passengers_per_seat" in sanitised: + sanitised["passengers_per_seat"] = int(round(sanitised["passengers_per_seat"])) + return replace(cfg, **sanitised) if sanitised else cfg + + +def _valid_systems() -> list[str]: + # Imported lazily so import cost stays low. + from ..multi_rope import RopewaySystemType + return [s.value for s in RopewaySystemType] + + +class CorridorRequest(BaseModel): + """Body for ``POST /api/v1/corridor``.""" + + start: tuple[float, float] = Field( + ..., description="Lower terminal as (lon, lat) in WGS-84." + ) + end: tuple[float, float] = Field( + ..., description="Upper terminal as (lon, lat) in WGS-84." + ) + system: str = Field( + default="mgd", + description="Ropeway type: jigback | mgd | bgd | 3s | chair | funitel.", + ) + name: str = Field( + default="Corridor", + description="Display name for the project; ends up in KML title etc.", + ) + generations: int = Field(default=80, ge=20, le=400) + population_size: int = Field(default=80, ge=20, le=400) + max_intermediate_towers: int = Field(default=12, ge=1, le=24) + seed: int = Field(default=2026) + # P86 — refine path: override cost weights and/or warm-start from a + # previous run's best alignment (encoded as towers). + w_n: float | None = Field(default=None, ge=0.0, + description="Per-tower cost weight; None = legacy default.") + w_h: float | None = Field(default=None, ge=0.0, + description="Per-metre-of-tower-height cost weight.") + w_L: float | None = Field(default=None, ge=0.0, + description="Per-metre-of-cable cost weight.") + seed_towers: list[dict] | None = Field( + default=None, + description="Optional warm-start: prior best alignment's towers " + "([{distance_m, height_m, is_station, offset}]).", + ) + # P96 — advanced editor: every safety/load/structural ConstraintConfig + # knob the engineer can twist from the SPA. Validated against the + # whitelist below to avoid arbitrary attribute writes on cfg. + constraint_overrides: dict[str, float] | None = Field( + default=None, + description="Override individual ConstraintConfig fields " + "(e.g. {min_ground_clearance_m: 4.0, max_span_m: 2000}). " + "See _ALLOWED_CONSTRAINT_OVERRIDES for the whitelist.", + ) + + @field_validator("start", "end") + @classmethod + def _lonlat_in_range(cls, v: tuple[float, float]) -> tuple[float, float]: + lon, lat = v + if not (-180.0 <= lon <= 180.0): + raise ValueError(f"lon {lon} out of range [-180, 180]") + if not (-90.0 <= lat <= 90.0): + raise ValueError(f"lat {lat} out of range [-90, 90]") + return v + + @field_validator("system") + @classmethod + def _known_system(cls, v: str) -> str: + if v not in _valid_systems(): + raise ValueError(f"unknown system {v!r}; expected one of {_valid_systems()}") + return v + + +# --------------------------------------------------------------------------- +# Background worker +# --------------------------------------------------------------------------- + + +def _run_corridor_job(job_id: str) -> None: + """Run the optimizer + write every artifact to the job's tmpdir.""" + # Imports kept inside the worker so the API module stays lightweight. + from ..alignment import evaluate_alignment + from ..bom import build_bom + from ..cost import Region, estimate_cost + from ..dem import ensure_dem_tile, extract_profile_from_dem + from ..dxf_export import alignment_to_dxf + from ..io import ( + alignment_to_csv, + alignment_to_geojson, + alignment_to_kml, + ) + from ..landxml import alignment_to_landxml + from ..multi_rope import RopewaySystemType, system_defaults + from ..optimizer import GAConfig, optimize + from ..report import render_pdf_report + from ..viz import plot_alignment, plot_convergence + + with _STORE_LOCK: + job = _STORE.get(job_id) + if job is None: + return + job.status = CorridorJobStatus.RUNNING + job.started_at = time.time() + body = job.body + + try: + lon_start, lat_start = body.start + lon_end, lat_end = body.end + + # P47 — auto-fetch the matching DEM tile (cached). + tile_path = ensure_dem_tile(lon_start, lat_start, cache_dir="data/dem") + + profile = extract_profile_from_dem( + tile_path, + (lon_start, lat_start), + (lon_end, lat_end), + sample_spacing_m=15.0, + ) + + sys_type = RopewaySystemType(body.system) + cfg = system_defaults(sys_type) + # P96 — apply any operator overrides (whitelisted + clamped). + cfg = _apply_overrides(cfg, body.constraint_overrides) + + # P86 refine path: optional CostWeights override. + from ..alignment import CostWeights, Tower as _Tower, Alignment as _Alignment + cost_weights = None + if body.w_n is not None or body.w_h is not None or body.w_L is not None: + cost_weights = CostWeights( + w_n=body.w_n if body.w_n is not None else 50_000.0, + w_h=body.w_h if body.w_h is not None else 1_000.0, + w_L=body.w_L if body.w_L is not None else 50.0, + ) + + ga = GAConfig( + max_intermediate_towers=body.max_intermediate_towers, + population_size=body.population_size, + generations=body.generations, + seed=body.seed, + cost_weights=cost_weights, + ) + + # P86 refine path: warm-start the GA from a previous best. + seed_alignment = None + if body.seed_towers: + seed_towers = [ + _Tower( + distance=float(t["distance_m"]), + height=float(t["height_m"]), + is_station=bool(t.get("is_station", False)), + offset=float(t.get("offset", 0.0) or 0.0), + ) + for t in body.seed_towers + ] + seed_alignment = _Alignment( + towers=seed_towers, + profile_fn=profile.as_function(), + cfg=cfg, + ) + + result = optimize(profile.as_function(), profile.total_length, + cfg=cfg, ga=ga, + seed_alignment=seed_alignment, + verbose=False) + align = result.best_alignment + eval_res = result.best_result + rep = eval_res.report + + # Write every artifact into a fresh tmpdir kept on the job. + tmp_root = Path(tempfile.gettempdir()) / "ropeway-corridor-jobs" / job_id + tmp_root.mkdir(parents=True, exist_ok=True) + + alignment_to_kml(align, profile, tmp_root / "alignment.kml", + project_name=body.name, segments=eval_res.segments) + alignment_to_dxf(align, profile, tmp_root / "alignment.dxf", + project_name=body.name) + alignment_to_landxml(align, profile, tmp_root / "alignment.landxml") + alignment_to_geojson(align, profile, tmp_root / "alignment.geojson") + alignment_to_csv(align, profile, tmp_root / "towers.csv") + + bom = build_bom(align, project_name=body.name, system=sys_type, cfg=cfg) + (tmp_root / "bom.csv").write_text(bom.as_csv()) + est = estimate_cost(bom, region=Region.EMERGING) + (tmp_root / "cost_estimate.csv").write_text(est.as_csv()) + + fig_a, _ = plot_alignment(profile, align, segments=eval_res.segments, + title=f"{body.name} — optimised alignment") + fig_a.savefig(tmp_root / "alignment.png", dpi=140) + plt.close(fig_a) + + fig_c, _ = plot_convergence(result.history_best, result.history_avg) + fig_c.savefig(tmp_root / "convergence.png", dpi=140) + plt.close(fig_c) + + # P93 — multi-page validation PDF (cover + checks + plots). + try: + render_pdf_report( + tmp_root / "validation_report.pdf", + profile=profile, + alignment=align, + result=eval_res, + cfg=cfg, + history_best=result.history_best, + history_avg=result.history_avg, + project_name=body.name, + ) + except Exception: # noqa: BLE001 — PDF is best-effort, never fail the job + pass + + # P80 — chart data as JSON for the SPA's interactive plots. + import json as _json + import numpy as _np + + # Sample the profile + cable along the corridor. + n_samples = 400 + ds = _np.linspace(0.0, profile.total_length, n_samples) + ground_curve = [float(profile.elevation_at(d)) for d in ds] + # Cable elevation per sample from the catenary segments. + cable_curve: list[float] = [] + for d in ds: + seg = None + for s in eval_res.segments: + if s.xA <= d <= s.xB: + seg = s + break + if seg is None: + seg = eval_res.segments[-1] + cable_curve.append(float(seg.y(float(d)))) + + # Per-tower marks with P73 labels. + n_towers = len(align.towers) + tower_marks = [] + for i, t in enumerate(align.towers): + g = float(profile.elevation_at(t.distance)) + is_station = bool(t.is_station) or i in (0, n_towers - 1) + if i == 0: + label = "Lower station" + elif i == n_towers - 1: + label = "Upper station" + elif is_station: + label = f"Station {i}" + else: + label = f"Tower {i}" + tower_marks.append({ + "index": i, + "label": label, + "distance_m": float(t.distance), + "ground_m": g, + "anchor_m": g + float(t.height), + "height_m": float(t.height), + "is_station": is_station, + }) + + # Per-span max tension (kN) for the bar chart. + w_loaded = cfg.cable_weight_n_per_m * cfg.dynamic_load_factor + ( + cfg.passengers_per_seat * 75.0 * 9.81 / max(cfg.seat_spacing_m, 1e-6) + ) + per_span_tension_kn = [ + float(s.max_tension(w_loaded) / 1e3) for s in eval_res.segments + ] + + plot_data = { + "corridor_length_m": float(profile.total_length), + "distance_m": [float(x) for x in ds], + "ground_m": ground_curve, + "cable_m": cable_curve, + "towers": tower_marks, + "convergence": { + "best": [float(x) for x in result.history_best], + "avg": [float(x) for x in result.history_avg], + }, + "per_span_tension_kn": per_span_tension_kn, + } + (tmp_root / "plot_data.json").write_text(_json.dumps(plot_data)) + + # Pre-bake the ZIP — most SPA users only download this. + zip_path = tmp_root / "permit_pack.zip" + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for f in sorted(tmp_root.iterdir()): + if f.name == "permit_pack.zip": + continue + zf.write(f, arcname=f"{job_id}/{f.name}") + + with _STORE_LOCK: + job.artifacts_dir = tmp_root + job.result = { + "feasible": eval_res.feasible, + "intermediate_towers": max(0, len(align.towers) - 2), + "cable_length_m": rep.total_cable_length_m, + "corridor_length_m": float(profile.total_length), + "elevation_gain_m": float( + profile.elevation.max() - profile.elevation.min() + ), + "min_clearance_m": rep.min_clearance_m, + "max_tension_kn": rep.max_tension_n / 1e3, + "max_break_over_deg": rep.max_break_over_deg, + "min_overturning_sf": rep.min_overturning_sf, + "min_sliding_sf": rep.min_sliding_sf, + "cost": eval_res.cost, + "capex_usd_estimate": float(est.grand_total), + "dem_tile": tile_path.name, + } + job.status = CorridorJobStatus.DONE + job.finished_at = time.time() + except Exception as exc: # noqa: BLE001 — surface anything to the client + with _STORE_LOCK: + job.status = CorridorJobStatus.FAILED + job.error = f"{type(exc).__name__}: {exc}" + job.finished_at = time.time() + + +# --------------------------------------------------------------------------- +# Router +# --------------------------------------------------------------------------- + + +router = APIRouter(prefix="/api/v1/corridor", tags=["corridor"]) + + +# Filename for each artifact kind. Keep the keys stable — the SPA +# references them by name. +_ARTIFACT_FILES: dict[str, tuple[str, str]] = { + "kml": ("alignment.kml", "application/vnd.google-earth.kml+xml"), + "dxf": ("alignment.dxf", "application/dxf"), + "landxml": ("alignment.landxml", "application/xml"), + "geojson": ("alignment.geojson", "application/geo+json"), + "towers_csv": ("towers.csv", "text/csv"), + "bom_csv": ("bom.csv", "text/csv"), + "cost_csv": ("cost_estimate.csv", "text/csv"), + "alignment_png": ("alignment.png", "image/png"), + "convergence_png": ("convergence.png", "image/png"), + "plot_data": ("plot_data.json", "application/json"), + "pdf": ("validation_report.pdf", "application/pdf"), + "zip": ("permit_pack.zip", "application/zip"), +} + + +class EvaluateRequest(BaseModel): + """P97 — body for ``POST /api/v1/evaluate``. + + Same coords + system + (optional) overrides as ``CorridorRequest``, + plus an explicit list of towers the engineer wants scored without + running the GA. The lower + upper anchors are auto-marked as + stations so the engineer can omit ``is_station`` for the terminals. + """ + + start: tuple[float, float] + end: tuple[float, float] + system: str = "mgd" + name: str = "Manual edit" + towers: list[dict] = Field(..., min_length=2) + constraint_overrides: dict[str, float] | None = None + + @field_validator("start", "end") + @classmethod + def _lonlat_in_range(cls, v: tuple[float, float]) -> tuple[float, float]: + lon, lat = v + if not (-180.0 <= lon <= 180.0): + raise ValueError(f"lon {lon} out of range") + if not (-90.0 <= lat <= 90.0): + raise ValueError(f"lat {lat} out of range") + return v + + @field_validator("system") + @classmethod + def _known_system(cls, v: str) -> str: + if v not in _valid_systems(): + raise ValueError(f"unknown system {v!r}") + return v + + +@router.post("/evaluate") +def evaluate_alignment_endpoint(body: EvaluateRequest) -> dict: + """Score an explicit tower layout without running the GA. + + Returns a payload shaped like the corridor result + plot_data so + the SPA can immediately update its charts when the engineer + nudges a tower in the manual editor (P97). + """ + import numpy as _np + + from ..alignment import Alignment as _Alignment, Tower as _Tower, evaluate_alignment + from ..dem import ensure_dem_tile, extract_profile_from_dem + from ..multi_rope import RopewaySystemType, system_defaults + + lon_s, lat_s = body.start + lon_e, lat_e = body.end + tile_path = ensure_dem_tile(lon_s, lat_s, cache_dir="data/dem") + profile = extract_profile_from_dem( + tile_path, (lon_s, lat_s), (lon_e, lat_e), sample_spacing_m=15.0, + ) + + sys_type = RopewaySystemType(body.system) + cfg = _apply_overrides(system_defaults(sys_type), body.constraint_overrides) + + if len(body.towers) < 2: + raise HTTPException(status_code=400, detail="need at least 2 towers") + + n_total = len(body.towers) + tower_objs = [] + for i, t in enumerate(body.towers): + is_terminal = i in (0, n_total - 1) + tower_objs.append(_Tower( + distance=float(t["distance_m"]), + height=float(t["height_m"]), + is_station=bool(t.get("is_station", is_terminal)), + offset=float(t.get("offset", 0.0) or 0.0), + )) + alignment = _Alignment(towers=tower_objs, profile_fn=profile.as_function(), cfg=cfg) + eval_res = evaluate_alignment(alignment) + rep = eval_res.report + + # Sample the cable + ground for the SPA chart (mirrors corridor worker). + n_samples = 200 + ds = _np.linspace(0.0, profile.total_length, n_samples) + ground_curve = [float(profile.elevation_at(d)) for d in ds] + cable_curve: list[float] = [] + for d in ds: + seg = next((s for s in eval_res.segments if s.xA <= d <= s.xB), + eval_res.segments[-1] if eval_res.segments else None) + cable_curve.append(float(seg.y(float(d))) if seg else float("nan")) + + tower_marks = [] + for i, t in enumerate(alignment.towers): + g = float(profile.elevation_at(t.distance)) + is_station = bool(t.is_station) or i in (0, n_total - 1) + label = ( + "Lower station" if i == 0 else + "Upper station" if i == n_total - 1 else + f"Station {i}" if is_station else f"Tower {i}" + ) + tower_marks.append({ + "index": i, "label": label, + "distance_m": float(t.distance), "ground_m": g, + "anchor_m": g + float(t.height), "height_m": float(t.height), + "is_station": is_station, + }) + + return { + "feasible": eval_res.feasible, + "cost": eval_res.cost, + "penalty": eval_res.penalty, + "violations": list(rep.violations), + "report": { + "total_cable_length_m": rep.total_cable_length_m, + "min_clearance_m": rep.min_clearance_m, + "max_tension_kn": rep.max_tension_n / 1e3, + "max_break_over_deg": rep.max_break_over_deg, + "min_overturning_sf": rep.min_overturning_sf, + "min_sliding_sf": rep.min_sliding_sf, + }, + "plot_data": { + "corridor_length_m": float(profile.total_length), + "distance_m": [float(x) for x in ds], + "ground_m": ground_curve, + "cable_m": cable_curve, + "towers": tower_marks, + }, + } + + +@router.post("", status_code=202) +def submit_corridor(body: CorridorRequest, background: BackgroundTasks) -> dict: + """Submit a new corridor optimisation job. Returns immediately.""" + if body.start == body.end: + raise HTTPException(status_code=400, detail="start and end coincide") + + job = CorridorJob(id=str(uuid.uuid4()), body=body) + with _STORE_LOCK: + _STORE[job.id] = job + background.add_task(_run_corridor_job, job.id) + return { + "job_id": job.id, + "status": job.status.value, + "status_url": f"/api/v1/corridor/{job.id}", + "artifacts_url": f"/api/v1/corridor/{job.id}/artifacts", + } + + +@router.get("/{job_id}") +def read_corridor(job_id: str) -> dict: + with _STORE_LOCK: + job = _STORE.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + return job.to_dict() + + +@router.get("/{job_id}/artifacts") +def list_artifacts(job_id: str) -> dict: + with _STORE_LOCK: + job = _STORE.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + if job.status is not CorridorJobStatus.DONE: + raise HTTPException(status_code=409, + detail=f"job not done (status={job.status.value})") + return { + "artifacts": { + kind: f"/api/v1/corridor/{job_id}/artifacts/{kind}" + for kind in _ARTIFACT_FILES + }, + "kinds": list(_ARTIFACT_FILES.keys()), + } + + +@router.get("/{job_id}/artifacts/{kind}") +def fetch_artifact(job_id: str, kind: str): + with _STORE_LOCK: + job = _STORE.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + if job.status is not CorridorJobStatus.DONE or job.artifacts_dir is None: + raise HTTPException(status_code=409, + detail=f"job not done (status={job.status.value})") + if kind not in _ARTIFACT_FILES: + raise HTTPException(status_code=400, + detail=f"unknown artifact kind {kind!r}; " + f"expected one of {list(_ARTIFACT_FILES.keys())}") + + fname, media_type = _ARTIFACT_FILES[kind] + path = job.artifacts_dir / fname + if not path.exists(): + raise HTTPException(status_code=404, detail=f"artifact {fname} missing") + return FileResponse(path, media_type=media_type, filename=fname) + + +__all__ = [ + "router", + "CorridorJob", "CorridorJobStatus", "CorridorRequest", + "_STORE", # exported for tests +] diff --git a/tests/test_ai_endpoint.py b/tests/test_ai_endpoint.py new file mode 100644 index 0000000..81a50fb --- /dev/null +++ b/tests/test_ai_endpoint.py @@ -0,0 +1,270 @@ +"""P81 — AI input mode tests. + +Ollama is mocked out — we never hit a real LLM in CI. The fixture +patches httpx.post inside ropeway.server.ai to return a deterministic +payload so the validation + error paths are exercised offline. +""" + +from __future__ import annotations + +import json as _json +from typing import Any + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def client(tmp_path, monkeypatch): + monkeypatch.setenv("ROPEWAY_DATABASE_URL", f"sqlite:///{tmp_path / 'api.db'}") + monkeypatch.setenv("ROPEWAY_SECRET_KEY", "x") + from ropeway.server import config as cfg_mod + cfg_mod.get_settings.cache_clear() + from ropeway.server import db as db_mod + db_mod._engines.clear() + from ropeway.server.api import create_app + return TestClient(create_app(cfg_mod.get_settings())) + + +def _stub_ollama_response(monkeypatch, content: dict[str, Any] | str | None, + status_code: int = 200) -> None: + """Replace httpx.post inside ropeway.server.ai with a fake response.""" + class _Resp: + def __init__(self, code, payload): + self.status_code = code + self._payload = payload + self.text = _json.dumps(payload) if isinstance(payload, dict) else (payload or "") + + def json(self): + return self._payload + + body: Any + if content is None: + body = {"message": {"content": ""}} + elif isinstance(content, str): + body = {"message": {"content": content}} + else: + body = {"message": {"content": _json.dumps(content)}} + + def _fake_post(url, json=None, timeout=None): # noqa: ARG001 + return _Resp(status_code, body) + + from ropeway.server import ai as ai_mod + monkeypatch.setattr(ai_mod.httpx, "post", _fake_post) + + +def test_ask_returns_parsed_corridor_from_clean_json(client, monkeypatch): + _stub_ollama_response(monkeypatch, { + "start": [73.5123812, 19.0691450], + "end": [73.5330820, 19.0700494], + "system": "jigback", + "name": "Bhimashankar pilgrim ropeway", + "notes": "Steep 2.18 km pilgrim line, jig-back is the right archetype.", + }) + r = client.post("/api/v1/ask", json={ + "text": "ropeway from Shidighat to Bhimashankar plateau, jig-back", + }) + assert r.status_code == 200, r.text + p = r.json()["parsed"] + assert p["start"] == [73.5123812, 19.0691450] + assert p["end"] == [73.5330820, 19.0700494] + assert p["system"] == "jigback" + assert "Bhimashankar" in p["name"] + + +def test_ask_normalises_system_aliases(client, monkeypatch): + _stub_ollama_response(monkeypatch, { + "start": [6.87, 45.89], "end": [6.89, 45.92], + "system": "gondola", # alias → mgd + "name": "Alpine line", + }) + r = client.post("/api/v1/ask", json={"text": "Alpine gondola from A to B"}) + assert r.status_code == 200 + assert r.json()["parsed"]["system"] == "mgd" + + +def test_ask_strips_code_fences(client, monkeypatch): + """Some models wrap JSON in ``` despite format=json. Tolerate it.""" + fenced = "```json\n" + _json.dumps({ + "start": [-99.04, 19.34], "end": [-99.00, 19.37], + "system": "mgd", "name": "Cablebús-like", + }) + "\n```" + _stub_ollama_response(monkeypatch, fenced) + r = client.post("/api/v1/ask", json={"text": "urban gondola in CDMX"}) + assert r.status_code == 200, r.text + assert r.json()["parsed"]["system"] == "mgd" + + +def test_ask_rejects_unknown_system(client, monkeypatch): + _stub_ollama_response(monkeypatch, { + "start": [6.87, 45.89], "end": [6.89, 45.92], + "system": "magic_carpet", + }) + r = client.post("/api/v1/ask", json={"text": "magic-carpet line"}) + assert r.status_code == 422 + assert "unknown system" in r.json()["detail"] + + +def test_ask_rejects_out_of_range_lonlat(client, monkeypatch): + _stub_ollama_response(monkeypatch, { + "start": [200.0, 0.0], "end": [0.0, 0.0], "system": "mgd", + }) + r = client.post("/api/v1/ask", json={"text": "make a ropeway somewhere"}) + assert r.status_code == 422 + assert "lon/lat" in r.json()["detail"] + + +def test_ask_surfaces_model_error_payload(client, monkeypatch): + _stub_ollama_response(monkeypatch, { + "error": "Could not resolve 'Atlantis Tower'.", + }) + r = client.post("/api/v1/ask", json={"text": "ropeway up Atlantis Tower"}) + assert r.status_code == 422 + assert "Atlantis" in r.json()["detail"] + + +def test_ask_503_when_ollama_unreachable(client, monkeypatch): + """Simulate connection refused → 503 with operator hint.""" + import httpx as _httpx + from ropeway.server import ai as ai_mod + + def _boom(url, json=None, timeout=None): + raise _httpx.ConnectError("Connection refused") + + monkeypatch.setattr(ai_mod.httpx, "post", _boom) + r = client.post("/api/v1/ask", json={"text": "anything"}) + assert r.status_code == 503 + assert "ollama serve" in r.json()["detail"].lower() + + +def test_ask_validates_request_body(client): + r = client.post("/api/v1/ask", json={"text": "x"}) # too short + assert r.status_code == 422 + + +def test_ask_502_when_ollama_returns_error_status(client, monkeypatch): + _stub_ollama_response(monkeypatch, {"error": "model not found"}, status_code=500) + r = client.post("/api/v1/ask", json={"text": "ropeway in Bhimashankar"}) + assert r.status_code == 502 + assert "500" in r.json()["detail"] + + +def test_ask_422_when_model_returns_non_json(client, monkeypatch): + _stub_ollama_response(monkeypatch, "I am a chatty model, not JSON!") + r = client.post("/api/v1/ask", json={"text": "ropeway in Bhimashankar"}) + assert r.status_code == 422 + assert "not valid JSON" in r.json()["detail"] + + +# --------------------------------------------------------------------------- +# P86 — refine +# --------------------------------------------------------------------------- + + +def _skip_if_dem_missing(): + from pathlib import Path as _P + if not _P("data/dem/Copernicus_DSM_N19_E073.tif").exists(): + pytest.skip("Bhimashankar DEM tile not present; cannot run end-to-end refine.") + + +def test_refine_404_when_previous_unknown(client, monkeypatch): + _stub_ollama_response(monkeypatch, {"w_n_scale": 2.0}) + r = client.post("/api/v1/refine", json={ + "previous_job_id": "does-not-exist", + "instruction": "make it cheaper", + }) + assert r.status_code == 404 + + +def test_refine_applies_weight_scales_and_runs_new_job(client, monkeypatch): + """End-to-end: seed a corridor job, then refine. Asserts the new + job runs and reports adjusted weights in the response payload.""" + _skip_if_dem_missing() + + # Seed run. + seed = client.post("/api/v1/corridor", json={ + "start": [73.5123812, 19.0691450], + "end": [73.5189329, 19.0728925], + "system": "mgd", + "name": "Seed", + "generations": 20, + "population_size": 30, + "max_intermediate_towers": 6, + }) + prev_id = seed.json()["job_id"] + prev_status = client.get(f"/api/v1/corridor/{prev_id}").json() + assert prev_status["status"] == "done" + + # Stub Ollama: "fewer towers" → bump w_n. + _stub_ollama_response(monkeypatch, { + "w_n_scale": 4.0, "w_h_scale": 1.0, "w_L_scale": 1.0, + "system": None, "rationale": "Big w_n favours fewer towers.", + }) + + refine = client.post("/api/v1/refine", json={ + "previous_job_id": prev_id, + "instruction": "fewer towers please", + }) + assert refine.status_code == 200, refine.text + body = refine.json() + assert "job_id" in body + assert body["applied"]["w_n"] == pytest.approx(50_000.0 * 4.0) + # The new job already completed because we run it inline. + new_status = client.get(f"/api/v1/corridor/{body['job_id']}").json() + assert new_status["status"] == "done" + + # P98: response carries before/after diff + history-friendly fields. + assert body["previous_job_id"] == prev_id + assert body["instruction"] == "fewer towers please" + assert isinstance(body["diff"], list) and len(body["diff"]) >= 4 + sample = body["diff"][0] + assert {"metric", "before", "after", "delta", "delta_pct"} <= set(sample) + + +def test_refine_accepts_recalibrated_20x_scale(client, monkeypatch): + """P98: LLM is now told to emit w_n_scale=20.0 for 'fewer towers'. + Server must accept that (was clamped at 50.0 before, still fine; was + clamped at 0.1 minimum, also fine for 'more towers' = 0.05).""" + _skip_if_dem_missing() + seed = client.post("/api/v1/corridor", json={ + "start": [73.5123812, 19.0691450], + "end": [73.5189329, 19.0728925], + "system": "mgd", + "name": "Seed", + "generations": 20, "population_size": 30, "max_intermediate_towers": 6, + }) + prev_id = seed.json()["job_id"] + + # New calibration: "more towers" → w_n_scale=0.05 (was clamped to 0.1) + _stub_ollama_response(monkeypatch, { + "w_n_scale": 0.05, "w_h_scale": 1.0, "w_L_scale": 1.0, + "system": None, "rationale": "Cut w_n hard so towers feel cheap.", + }) + r = client.post("/api/v1/refine", json={ + "previous_job_id": prev_id, "instruction": "more towers", + }) + assert r.status_code == 200 + assert r.json()["applied"]["w_n"] == pytest.approx(50_000.0 * 0.05) + + +def test_refine_system_switch_picked_up(client, monkeypatch): + _skip_if_dem_missing() + seed = client.post("/api/v1/corridor", json={ + "start": [73.5123812, 19.0691450], + "end": [73.5189329, 19.0728925], + "system": "mgd", + "name": "Seed", + "generations": 20, "population_size": 30, "max_intermediate_towers": 6, + }) + prev_id = seed.json()["job_id"] + + _stub_ollama_response(monkeypatch, { + "w_n_scale": 1.0, "w_h_scale": 1.0, "w_L_scale": 1.0, + "system": "jigback", "rationale": "Switch to jigback per request.", + }) + refine = client.post("/api/v1/refine", json={ + "previous_job_id": prev_id, + "instruction": "switch to jig-back", + }) + assert refine.status_code == 200 + assert refine.json()["applied"]["system"] == "jigback" diff --git a/tests/test_corridor_api.py b/tests/test_corridor_api.py new file mode 100644 index 0000000..1f2b24c --- /dev/null +++ b/tests/test_corridor_api.py @@ -0,0 +1,308 @@ +"""P76 — corridor API tests. + +Exercises POST /api/v1/corridor end-to-end with TestClient's in-process +BackgroundTasks scheduling — every job completes synchronously the moment +the POST response is constructed, so we can poll status and download +artifacts in the same test. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def client(tmp_path, monkeypatch): + db_url = f"sqlite:///{tmp_path / 'api.db'}" + monkeypatch.setenv("ROPEWAY_DATABASE_URL", db_url) + monkeypatch.setenv("ROPEWAY_SECRET_KEY", "x") + + from ropeway.server import config as cfg_mod + cfg_mod.get_settings.cache_clear() + from ropeway.server import db as db_mod + db_mod._engines.clear() + # Reset the in-memory corridor job store so tests don't bleed into each other. + from ropeway.server import corridor as corridor_mod + corridor_mod._STORE.clear() + + from ropeway.server.api import create_app + return TestClient(create_app(cfg_mod.get_settings())) + + +# A small synthetic corridor inside the Bhimashankar tile so the +# DEM cache (data/dem/Copernicus_DSM_N19_E073.tif) is already warm. +_TINY = { + "start": [73.5123812, 19.0691450], + "end": [73.5189329, 19.0728925], + "system": "mgd", + "name": "Test corridor", + "generations": 20, # keep CI cheap + "population_size": 30, + "max_intermediate_towers": 6, +} + + +# --------------------------------------------------------------------------- +# POST validation +# --------------------------------------------------------------------------- + + +def test_post_returns_job_id_and_status_pending_or_done(client): + r = client.post("/api/v1/corridor", json=_TINY) + assert r.status_code == 202, r.text + body = r.json() + assert "job_id" in body + assert body["status_url"].endswith(body["job_id"]) + assert "artifacts_url" in body + # In a TestClient the background task runs before the response is + # returned, so the job will already be DONE when we read it. + + +def test_post_rejects_out_of_range_lon(client): + bad = {**_TINY, "start": [181.0, 19.0]} + r = client.post("/api/v1/corridor", json=bad) + assert r.status_code == 422 + assert "lon" in r.text.lower() + + +def test_post_rejects_unknown_system(client): + bad = {**_TINY, "system": "rocket"} + r = client.post("/api/v1/corridor", json=bad) + assert r.status_code == 422 + assert "system" in r.text.lower() + + +def test_post_rejects_coincident_endpoints(client): + bad = {**_TINY, "end": _TINY["start"]} + r = client.post("/api/v1/corridor", json=bad) + assert r.status_code == 400 + assert "coincide" in r.json()["detail"] + + +# --------------------------------------------------------------------------- +# Status + result +# --------------------------------------------------------------------------- + + +def _skip_if_dem_missing(): + if not Path("data/dem/Copernicus_DSM_N19_E073.tif").exists(): + pytest.skip("Bhimashankar DEM tile not present; run ensure_dem_tile first.") + + +def test_status_endpoint_returns_done_with_result_payload(client): + _skip_if_dem_missing() + r = client.post("/api/v1/corridor", json=_TINY) + job_id = r.json()["job_id"] + + status = client.get(f"/api/v1/corridor/{job_id}").json() + assert status["status"] == "done", status + assert status["result"] is not None + res = status["result"] + for key in ( + "feasible", "intermediate_towers", "cable_length_m", + "corridor_length_m", "elevation_gain_m", "min_clearance_m", + "max_tension_kn", "cost", "capex_usd_estimate", "dem_tile", + ): + assert key in res, f"missing {key} in result" + + +def test_status_unknown_job_404(client): + r = client.get("/api/v1/corridor/does-not-exist") + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# Artifacts +# --------------------------------------------------------------------------- + + +def test_artifacts_listing_lists_all_known_kinds(client): + _skip_if_dem_missing() + r = client.post("/api/v1/corridor", json=_TINY) + job_id = r.json()["job_id"] + + listing = client.get(f"/api/v1/corridor/{job_id}/artifacts").json() + assert set(listing["kinds"]) == { + "kml", "dxf", "landxml", "geojson", "towers_csv", "bom_csv", + "cost_csv", "alignment_png", "convergence_png", "plot_data", + "pdf", "zip", + } + + +def test_artifact_kml_is_served_with_correct_mime_and_content(client): + _skip_if_dem_missing() + r = client.post("/api/v1/corridor", json=_TINY) + job_id = r.json()["job_id"] + + a = client.get(f"/api/v1/corridor/{job_id}/artifacts/kml") + assert a.status_code == 200 + assert "kml" in a.headers["content-type"] + body = a.text + assert "absolute" in body # P95 v2 default + assert "Lower station" in body # P73 vocabulary + + +def test_artifact_zip_contains_full_pack(client): + _skip_if_dem_missing() + r = client.post("/api/v1/corridor", json=_TINY) + job_id = r.json()["job_id"] + + z = client.get(f"/api/v1/corridor/{job_id}/artifacts/zip") + assert z.status_code == 200 + assert z.headers["content-type"].startswith("application/zip") + + # Validate ZIP shape. + import io as _io + import zipfile + with zipfile.ZipFile(_io.BytesIO(z.content)) as zf: + names = [n.split("/", 1)[-1] for n in zf.namelist()] + for expected in ("alignment.kml", "alignment.dxf", "alignment.landxml", + "alignment.geojson", "towers.csv", "bom.csv", + "cost_estimate.csv", "alignment.png", "convergence.png"): + assert expected in names, f"missing {expected} in ZIP" + + +def test_artifact_plot_data_has_expected_schema(client): + """P80: SPA fetches this JSON to render Plotly charts. Schema must be stable.""" + _skip_if_dem_missing() + r = client.post("/api/v1/corridor", json=_TINY) + job_id = r.json()["job_id"] + + p = client.get(f"/api/v1/corridor/{job_id}/artifacts/plot_data") + assert p.status_code == 200 + payload = p.json() + for key in ("corridor_length_m", "distance_m", "ground_m", "cable_m", + "towers", "convergence", "per_span_tension_kn"): + assert key in payload + assert len(payload["distance_m"]) == len(payload["ground_m"]) + assert len(payload["distance_m"]) == len(payload["cable_m"]) + assert payload["towers"][0]["label"] == "Lower station" + assert payload["towers"][-1]["label"] == "Upper station" + assert len(payload["per_span_tension_kn"]) == len(payload["towers"]) - 1 + + +def test_artifact_unknown_kind_400(client): + _skip_if_dem_missing() + r = client.post("/api/v1/corridor", json=_TINY) + job_id = r.json()["job_id"] + bad = client.get(f"/api/v1/corridor/{job_id}/artifacts/banana") + assert bad.status_code == 400 + + +def test_artifact_unknown_job_404(client): + r = client.get("/api/v1/corridor/nope/artifacts/kml") + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# P96 — constraint_overrides whitelist +# --------------------------------------------------------------------------- + + +def test_constraint_overrides_whitelisted_fields_take_effect(client): + """P96: passing constraint_overrides flows through to the worker. + We verify by overriding min_ground_clearance_m to something + visibly high and re-asserting the result's min_clearance_m + landed at-or-above that value, OR the run went infeasible + (either outcome proves the value was honoured).""" + _skip_if_dem_missing() + r = client.post("/api/v1/corridor", json={ + **_TINY, + "constraint_overrides": {"min_ground_clearance_m": 10.0}, + }) + assert r.status_code == 202 + job_id = r.json()["job_id"] + status = client.get(f"/api/v1/corridor/{job_id}").json() + assert status["status"] == "done" + # If feasible, the clearance must respect the higher floor. + if status["result"]["feasible"]: + assert status["result"]["min_clearance_m"] >= 10.0 - 0.1 + + +def test_constraint_overrides_unknown_keys_silently_dropped(client): + """Unknown override keys must not 400 — SPA may carry future fields.""" + _skip_if_dem_missing() + r = client.post("/api/v1/corridor", json={ + **_TINY, + "constraint_overrides": {"future_unknown_knob": 42.0}, + }) + assert r.status_code == 202 + + +# --------------------------------------------------------------------------- +# P97 — manual tower editor / evaluate endpoint +# --------------------------------------------------------------------------- + + +def test_evaluate_returns_report_for_explicit_towers(client): + _skip_if_dem_missing() + r = client.post("/api/v1/corridor/evaluate", json={ + "start": _TINY["start"], + "end": _TINY["end"], + "system": "mgd", + "towers": [ + {"distance_m": 0.0, "height_m": 12.0}, + {"distance_m": 200.0, "height_m": 25.0}, + {"distance_m": 400.0, "height_m": 30.0}, + {"distance_m": 805.0, "height_m": 12.0}, + ], + }) + assert r.status_code == 200, r.text + body = r.json() + for key in ("feasible", "cost", "violations", "report", "plot_data"): + assert key in body + assert body["plot_data"]["towers"][0]["label"] == "Lower station" + assert body["plot_data"]["towers"][-1]["label"] == "Upper station" + assert "min_clearance_m" in body["report"] + + +def test_evaluate_rejects_single_tower(client): + r = client.post("/api/v1/corridor/evaluate", json={ + "start": _TINY["start"], "end": _TINY["end"], + "system": "mgd", + "towers": [{"distance_m": 0.0, "height_m": 10.0}], + }) + # Pydantic min_length=2 returns 422 before our handler sees it. + assert r.status_code in (400, 422) + + +def test_evaluate_respects_constraint_overrides(client): + """Override min_ground_clearance to a value the hand-drafted layout + can't satisfy; verify the endpoint reports infeasible.""" + _skip_if_dem_missing() + r = client.post("/api/v1/corridor/evaluate", json={ + "start": _TINY["start"], "end": _TINY["end"], + "system": "mgd", + "towers": [ + {"distance_m": 0.0, "height_m": 5.0}, + {"distance_m": 805.0, "height_m": 5.0}, + ], + "constraint_overrides": {"min_ground_clearance_m": 50.0}, + }) + assert r.status_code == 200 + # 50 m clearance on a 5 m tower over hilly DEM = infeasible. + assert r.json()["feasible"] is False + + +def test_constraint_overrides_clamped_to_safe_range(client): + """An out-of-range override is clamped to the whitelist band, + not rejected — the SPA can over-shoot a slider without 400ing.""" + _skip_if_dem_missing() + r = client.post("/api/v1/corridor", json={ + **_TINY, + # Crazy values: clearance 500 m (way over the 20 m cap), + # max_span 50 (below the 100 m floor). + "constraint_overrides": { + "min_ground_clearance_m": 500.0, + "max_span_m": 1.0, + }, + }) + assert r.status_code == 202 + job_id = r.json()["job_id"] + status = client.get(f"/api/v1/corridor/{job_id}").json() + assert status["status"] == "done" # clamped, not crashed diff --git a/tests/test_io_kml.py b/tests/test_io_kml.py new file mode 100644 index 0000000..47f5d7d --- /dev/null +++ b/tests/test_io_kml.py @@ -0,0 +1,196 @@ +"""Smoke tests for alignment_to_kml.""" + +from __future__ import annotations + +from pathlib import Path +from xml.etree import ElementTree as ET + +import pytest + +from ropeway.alignment import Alignment, Tower, evaluate_alignment +from ropeway.dem import synthetic_profile +from ropeway.io import alignment_to_kml +from ropeway.safety import ConstraintConfig + + +KML_NS = "{http://www.opengis.net/kml/2.2}" + + +def _three_tower_alignment(): + cfg = ConstraintConfig() + p = synthetic_profile(length_m=2000.0, seed=4) + a = Alignment( + towers=[ + Tower(0.0, 18.0, is_station=True), + Tower(1000.0, 30.0), + Tower(2000.0, 22.0, is_station=True), + ], + profile_fn=p.as_function(), + cfg=cfg, + ) + return a, p + + +def test_kml_file_is_valid_xml_with_expected_root(tmp_path: Path): + a, p = _three_tower_alignment() + out = alignment_to_kml(a, p, tmp_path / "x.kml", project_name="Test corridor") + assert out.exists() + tree = ET.parse(out) + root = tree.getroot() + assert root.tag == f"{KML_NS}kml" + doc = root.find(f"{KML_NS}Document") + assert doc is not None + name = doc.find(f"{KML_NS}name") + assert name is not None and name.text == "Test corridor" + + +def test_kml_emits_one_placemark_per_tower_plus_two_lines(tmp_path: Path): + """3 towers → 3 tower Placemarks + cable LineString + ground LineString = 5 Placemarks.""" + a, p = _three_tower_alignment() + out = alignment_to_kml(a, p, tmp_path / "x.kml") + tree = ET.parse(out) + placemarks = tree.findall(f".//{KML_NS}Placemark") + assert len(placemarks) == 3 + 2 + + points = tree.findall(f".//{KML_NS}Point") + assert len(points) == 3 + + lines = tree.findall(f".//{KML_NS}LineString") + assert len(lines) == 2 + + +def test_kml_tower_point_default_absolute_with_safety_buffer(tmp_path: Path): + """P95 v2: default altitude_mode='absolute' emits anchor_elev + + safety_buffer_m (5 m default) so GE-stylised terrain can't hide + the tower inside a ridge.""" + a, p = _three_tower_alignment() + out = alignment_to_kml(a, p, tmp_path / "x.kml") + tree = ET.parse(out) + points = tree.findall(f".//{KML_NS}Point") + altmode = points[1].find(f"{KML_NS}altitudeMode") + assert altmode is not None and altmode.text == "absolute" + _, _, alt = points[1].find(f"{KML_NS}coordinates").text.strip().split(",") + ground = float(p.elevation_at(1000.0)) + expected = ground + 30.0 + 5.0 # anchor + safety_buffer_m default + assert float(alt) == pytest.approx(expected, abs=0.01) + + +def test_kml_relative_mode_keeps_pre_p95_semantics(tmp_path: Path): + """Caller opting into relativeToGround gets tower height + buffer + above local ground — the P72-shipped behaviour, modulo the buffer.""" + a, p = _three_tower_alignment() + out = alignment_to_kml(a, p, tmp_path / "x.kml", + altitude_mode="relativeToGround", + safety_buffer_m=0.0) + tree = ET.parse(out) + points = tree.findall(f".//{KML_NS}Point") + altmode = points[1].find(f"{KML_NS}altitudeMode") + assert altmode is not None and altmode.text == "relativeToGround" + _, _, alt = points[1].find(f"{KML_NS}coordinates").text.strip().split(",") + assert float(alt) == pytest.approx(30.0, abs=0.01) # tower height only + + +def test_kml_cable_linestring_uses_absolute_by_default(tmp_path: Path): + """P95 v2: cable LineString is absolute by default; matches what + Civil 3D / OpenRoads expect when importing the KML.""" + a, p = _three_tower_alignment() + out = alignment_to_kml(a, p, tmp_path / "x.kml") + tree = ET.parse(out) + lines = tree.findall(f".//{KML_NS}LineString") + cable_line = lines[0] + altmode = cable_line.find(f"{KML_NS}altitudeMode") + assert altmode is not None and altmode.text == "absolute" + + +def test_kml_rejects_unknown_altitude_mode(tmp_path: Path): + a, p = _three_tower_alignment() + with pytest.raises(ValueError, match="altitude_mode"): + alignment_to_kml(a, p, tmp_path / "x.kml", altitude_mode="floating") + + +def test_kml_cable_densified_with_segments(tmp_path: Path): + """When ``segments=`` is passed, the cable is densified well beyond + one-coord-per-tower so GE shows the catenary sag, not chord lines.""" + a, p = _three_tower_alignment() + eval_res = evaluate_alignment(a) + out = alignment_to_kml(a, p, tmp_path / "x.kml", + segments=eval_res.segments, cable_samples_per_span=20) + tree = ET.parse(out) + lines = tree.findall(f".//{KML_NS}LineString") + coords = lines[0].find(f"{KML_NS}coordinates").text.strip().split() + # 2 spans × 20 samples - 1 dedup = 39. Allow ±1 for first-sample edge case. + assert 35 <= len(coords) <= 42 + + +def test_kml_absolute_mode_emits_anchor_plus_buffer(tmp_path: Path): + """P95 v2: in absolute mode, every cable sample is the catenary + elevation lifted by ``safety_buffer_m``. Per-sample sanity: + the alt is at most 1 m off from the raw catenary value + buffer + (we don't clamp to the terrain — physics stays honest).""" + import numpy as _np + + a, p = _three_tower_alignment() + eval_res = evaluate_alignment(a) + out = alignment_to_kml(a, p, tmp_path / "x.kml", + segments=eval_res.segments, safety_buffer_m=5.0) + tree = ET.parse(out) + cable_line = tree.findall(f".//{KML_NS}LineString")[0] + coords = cable_line.find(f"{KML_NS}coordinates").text.strip().split() + n = len(coords) + for i, c in enumerate(coords): + _, _, alt = (float(x) for x in c.split(",")) + d = (i / max(1, n - 1)) * p.total_length + # Find the segment containing d and read its catenary value. + seg = next((s for s in eval_res.segments if s.xA <= d <= s.xB), + eval_res.segments[-1]) + expected = float(seg.y(d)) + 5.0 + assert abs(float(alt) - expected) < 1.0, ( + f"sample {i}: alt={alt} vs catenary+buffer={expected}" + ) + + +def test_kml_relative_mode_never_below_zero(tmp_path: Path): + """Opt-in relativeToGround mode: same guarantee in the relative frame.""" + a, p = _three_tower_alignment() + eval_res = evaluate_alignment(a) + out = alignment_to_kml(a, p, tmp_path / "x.kml", + segments=eval_res.segments, + altitude_mode="relativeToGround", + safety_buffer_m=0.0) + tree = ET.parse(out) + coords = tree.findall(f".//{KML_NS}LineString")[0].find( + f"{KML_NS}coordinates").text.strip().split() + alts = [float(c.split(",")[2]) for c in coords] + assert min(alts) >= 0.0 + + +def test_kml_ground_linestring_clamps_to_ground(tmp_path: Path): + a, p = _three_tower_alignment() + out = alignment_to_kml(a, p, tmp_path / "x.kml") + tree = ET.parse(out) + lines = tree.findall(f".//{KML_NS}LineString") + ground_line = lines[1] + altmode = ground_line.find(f"{KML_NS}altitudeMode") + assert altmode is not None and altmode.text == "clampToGround" + + +def test_kml_uses_lower_upper_station_vocabulary(tmp_path: Path): + """P73: terminal supports get 'Lower station' / 'Upper station', + not 'Tower 0' / 'Tower N'.""" + a, p = _three_tower_alignment() + out = alignment_to_kml(a, p, tmp_path / "x.kml") + tree = ET.parse(out) + placemark_names = [pm.find(f"{KML_NS}name").text + for pm in tree.findall(f".//{KML_NS}Folder/{KML_NS}Placemark")] + assert placemark_names[0] == "Lower station" + assert placemark_names[-1] == "Upper station" + assert all(n.startswith("Tower") for n in placemark_names[1:-1]) + assert "Tower 0" not in placemark_names + + +def test_kml_zero_length_profile_rejected(tmp_path: Path): + """A zero-length corridor would produce a degenerate KML; reject up front.""" + a, p = _three_tower_alignment() + p.distance[-1] = 0.0 # nuke length + with pytest.raises(ValueError, match="zero length"): + alignment_to_kml(a, p, tmp_path / "x.kml") diff --git a/web/.env.local.example b/web/.env.local.example new file mode 100644 index 0000000..c911a4e --- /dev/null +++ b/web/.env.local.example @@ -0,0 +1,4 @@ +# URL of the FastAPI backend running on your box. +# In dev: leave as http://localhost:8000 and run `ropeway serve` in another terminal. +# Behind Cloudflare Tunnel: set to https://.trycloudflare.com. +NEXT_PUBLIC_API_URL=http://localhost:8000 diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..b721bff --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.local.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/web/.nvmrc b/web/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/web/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/web/AGENTS.md b/web/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/web/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/web/CLAUDE.md b/web/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/web/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..04b5ba1 --- /dev/null +++ b/web/README.md @@ -0,0 +1,110 @@ +# `web/` — Autonomous Ropeway Alignment SPA (P77) + +Next.js 16 (App Router, TypeScript, Tailwind v4) client for the +FastAPI corridor API (Phase P76). This is the front door for the +**non-technical civil engineer**: + +- Pick a preset, or paste two `(lon, lat)` pairs and a system type. +- Click **Build my alignment**. +- Watch a progress indicator while the backend optimises. +- Result page shows the alignment plot, the headline metrics, and + a one-click download for every artefact (KML, DXF, LandXML, + GeoJSON, BoM, capex, plots, full ZIP). + +No CLI. No Python. No Streamlit on the engineer's path. + +## Local dev + +In one terminal, run the FastAPI backend: + +```bash +cd .. +make install +ropeway serve # listens on :8000 by default +``` + +In another terminal, run the SPA: + +```bash +cd web +cp .env.local.example .env.local +npm install +npm run dev # http://localhost:3000 +``` + +Open , pick the **Bhimashankar** preset, +click **Build my alignment**. The page should land at "Result" +in ~10-20 s with downloads for the entire permit pack. + +## Deploy + +The SPA is a static Vercel app; the backend stays on your box and +gets exposed via Cloudflare Tunnel. + +```bash +# On the box: +ropeway serve & +cloudflared tunnel --url http://localhost:8000 +# Copy the printed https://*.trycloudflare.com URL. + +# In Vercel: project settings → environment variables → +# NEXT_PUBLIC_API_URL = https://.trycloudflare.com +# Re-deploy. Engineers visit https://.vercel.app. +``` + +CORS on the FastAPI side is already handled by P28b — set +`ROPEWAY_CORS_ORIGINS=https://.vercel.app` before running +`ropeway serve` in prod. + +## Roadmap (POST_TRIAL_PLAN.md) + +| Phase | Status | +|---|---| +| **P77** scaffold + form + result + downloads | ✅ this PR | +| P78 map-first input (Leaflet/MapLibre) | next | +| P79 Cesium 3-D viewer in the result panel | next | +| P80 interactive graphs (Plotly) | next | +| P81 AI input mode (Anthropic Sonnet) | next | +| P82 export pack already shipped via /api/v1/corridor zip endpoint | ✅ | +| P83 auth + tier gating (OAuth from P26) | next | + +## Node version + +Next 16 + Tailwind v4 require **Node ≥ 20** (hard-fail on Node 18). +A `.nvmrc` is checked in. Activate before any `npm` command: + +```bash +source ~/.nvm/nvm.sh && nvm use 20 # one-liner per shell +# or globally pin: +nvm alias default 20 +``` + +## Recovery: "Cannot find native binding ... oxide-linux-x64-gnu" + +A known npm bug ([npm/cli#4828](https://github.com/npm/cli/issues/4828)) +skips Tailwind 4's native platform binary on some installs. Fix: + +```bash +cd web +rm -rf node_modules package-lock.json .next +nvm use 20 +npm install +``` + +If that still fails, force-install the binding: + +```bash +npm install @tailwindcss/oxide-linux-x64-gnu --no-save +``` + +## Cleaning the dev server + +`next dev`'s Turbopack caches compile errors aggressively. If you've +ever seen a 500 in the browser even after fixing the import, nuke +the cache + a stale background process: + +```bash +pkill -f "next dev" 2>/dev/null +rm -rf .next +npm run dev +``` diff --git a/web/app/AdvancedEditor.tsx b/web/app/AdvancedEditor.tsx new file mode 100644 index 0000000..7b3b931 --- /dev/null +++ b/web/app/AdvancedEditor.tsx @@ -0,0 +1,173 @@ +"use client"; + +import type { CorridorRequest } from "@/lib/api"; + +/** + * P96 — Advanced editor. + * + * Engineer feedback (trial 1): "I want full control of project and + * change of things to see the changes." Every ConstraintConfig knob + * the SPA whitelists in /api/v1/corridor (see _ALLOWED_CONSTRAINT_OVERRIDES) + * gets a number-input here. Lives in a
so it stays out of + * the way by default. + * + * The component is purely controlled — it mutates the parent's + * CorridorRequest via setReq. GA knobs (generations, population, + * max_intermediate_towers) and cost weights (w_n, w_h, w_L) also + * live here. + */ + +type Props = { + req: CorridorRequest; + setReq: (next: CorridorRequest) => void; +}; + +type Knob = { + group: "Safety" | "Loads" | "Geometry" | "GA" | "Cost weights" | "Environment"; + key: string; // ConstraintConfig field name, or 'ga.<...>', or 'w_n' / 'w_h' / 'w_L' + label: string; + unit?: string; + min: number; + max: number; + step: number; + defaultValue: number; + description?: string; +}; + +const KNOBS: Knob[] = [ + { group: "Safety", key: "min_ground_clearance_m", label: "Min ground clearance", unit: "m", min: 0.5, max: 20, step: 0.1, defaultValue: 2.5 }, + { group: "Safety", key: "swing_angle_deg", label: "Wind swing angle", unit: "°", min: 0, max: 30, step: 0.5, defaultValue: 10 }, + { group: "Safety", key: "max_break_over_angle_deg", label: "Max break-over", unit: "°", min: 5, max: 60, step: 1, defaultValue: 25 }, + { group: "Safety", key: "foundation_overturning_sf_required", label: "Foundation overturning SF", min: 1, max: 5, step: 0.1, defaultValue: 2.0 }, + { group: "Safety", key: "foundation_sliding_sf_required", label: "Foundation sliding SF", min: 1, max: 5, step: 0.1, defaultValue: 1.5 }, + + { group: "Loads", key: "horizontal_tension_n", label: "Horizontal tension H", unit: "N", min: 50_000, max: 2_000_000, step: 10_000, defaultValue: 250_000 }, + { group: "Loads", key: "cable_weight_n_per_m", label: "Cable dead weight", unit: "N/m", min: 10, max: 500, step: 5, defaultValue: 50 }, + { group: "Loads", key: "seat_spacing_m", label: "Seat / cabin spacing", unit: "m", min: 5, max: 500, step: 5, defaultValue: 50 }, + { group: "Loads", key: "passengers_per_seat", label: "Passengers per seat", unit: "pax", min: 1, max: 200, step: 1, defaultValue: 8 }, + { group: "Loads", key: "dynamic_load_factor", label: "Dynamic load factor", unit: "", min: 1.0, max: 2.0, step: 0.05, defaultValue: 1.10 }, + { group: "Loads", key: "design_wind_speed_m_s", label: "Design wind speed", unit: "m/s", min: 10, max: 80, step: 1, defaultValue: 35 }, + { group: "Loads", key: "max_cable_tension_n", label: "Max cable tension", unit: "N", min: 50_000, max: 5_000_000, step: 10_000, defaultValue: 500_000 }, + + { group: "Geometry", key: "max_span_m", label: "Max span", unit: "m", min: 100, max: 5000, step: 50, defaultValue: 1500 }, + { group: "Geometry", key: "min_span_m", label: "Min span", unit: "m", min: 10, max: 1000, step: 10, defaultValue: 50 }, + { group: "Geometry", key: "min_tower_height_m", label: "Min tower height", unit: "m", min: 3, max: 30, step: 1, defaultValue: 5 }, + { group: "Geometry", key: "max_tower_height_m", label: "Max tower height", unit: "m", min: 20, max: 200, step: 5, defaultValue: 80 }, + { group: "Geometry", key: "corridor_half_width_m", label: "Corridor half-width (joint H+V)", unit: "m", min: 0, max: 500, step: 10, defaultValue: 0, + description: "0 = pure vertical optimisation. Above 0 enables Phase-12c lateral offsets." }, + { group: "Geometry", key: "max_plan_deflection_deg", label: "Max plan deflection", unit: "°", min: 0, max: 60, step: 1, defaultValue: 25 }, + + { group: "GA", key: "ga.generations", label: "Generations", min: 20, max: 400, step: 10, defaultValue: 80 }, + { group: "GA", key: "ga.population_size", label: "Population size", min: 20, max: 400, step: 10, defaultValue: 80 }, + { group: "GA", key: "ga.max_intermediate_towers", label: "Max intermediate towers", min: 1, max: 24, step: 1, defaultValue: 12 }, + { group: "GA", key: "ga.seed", label: "Seed", min: 1, max: 9999, step: 1, defaultValue: 2026 }, + + { group: "Cost weights", key: "w_n", label: "Per-tower cost (w_n)", min: 100, max: 5_000_000, step: 1000, defaultValue: 50_000, + description: "↑ favours fewer towers." }, + { group: "Cost weights", key: "w_h", label: "Per-metre-height (w_h)", min: 10, max: 100_000, step: 100, defaultValue: 1_000, + description: "↑ favours shorter towers." }, + { group: "Cost weights", key: "w_L", label: "Per-metre-cable (w_L)", min: 1, max: 100_000, step: 10, defaultValue: 50, + description: "↑ favours shorter cable." }, + + { group: "Environment", key: "temperature_delta_k", label: "ΔT from tensioning temp", unit: "K", min: -50, max: 50, step: 1, defaultValue: 0, + description: "Phase-12b coupled elastic+thermal. + = hotter day (cable sags). – = cold day (tension rises)." }, + { group: "Environment", key: "ice_thickness_m", label: "Radial ice sleeve", unit: "m", min: 0, max: 0.2, step: 0.005, defaultValue: 0, + description: "Phase-6+ ISO 12494 ice load." }, +]; + +const GROUPS: Knob["group"][] = ["Safety", "Loads", "Geometry", "Environment", "GA", "Cost weights"]; + +function readValue(req: CorridorRequest, k: Knob): number { + if (k.key.startsWith("ga.")) { + const f = k.key.slice(3) as keyof CorridorRequest; + const v = req[f]; + return typeof v === "number" ? v : k.defaultValue; + } + if (k.key === "w_n") return req.w_n ?? k.defaultValue; + if (k.key === "w_h") return req.w_h ?? k.defaultValue; + if (k.key === "w_L") return req.w_L ?? k.defaultValue; + return req.constraint_overrides?.[k.key] ?? k.defaultValue; +} + +function writeValue(req: CorridorRequest, k: Knob, v: number): CorridorRequest { + if (k.key.startsWith("ga.")) { + const f = k.key.slice(3); + return { ...req, [f]: v } as CorridorRequest; + } + if (k.key === "w_n") return { ...req, w_n: v }; + if (k.key === "w_h") return { ...req, w_h: v }; + if (k.key === "w_L") return { ...req, w_L: v }; + const next = { ...(req.constraint_overrides ?? {}), [k.key]: v }; + return { ...req, constraint_overrides: next }; +} + +export default function AdvancedEditor({ req, setReq }: Props) { + function resetAll() { + setReq({ + ...req, + generations: 80, + population_size: 80, + max_intermediate_towers: 12, + seed: 2026, + w_n: undefined, + w_h: undefined, + w_L: undefined, + constraint_overrides: undefined, + }); + } + return ( +
+ + Advanced — every engine knob + tap to expand + +
+

+ Defaults come from system_defaults({req.system}). + Every change here POSTs into the corridor job as an override + (whitelisted + clamped server-side, see + _ALLOWED_CONSTRAINT_OVERRIDES). +

+ {GROUPS.map((g) => { + const groupKnobs = KNOBS.filter((k) => k.group === g); + return ( +
+ {g} +
+ {groupKnobs.map((k) => { + const v = readValue(req, k); + return ( + + ); + })} +
+
+ ); + })} +
+ +
+
+
+ ); +} diff --git a/web/app/CesiumViewer.tsx b/web/app/CesiumViewer.tsx new file mode 100644 index 0000000..f07e41e --- /dev/null +++ b/web/app/CesiumViewer.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +/** + * P79 — Cesium 3-D viewer. + * + * Loads the per-job KML artifact into a Cesium globe. With an Ion + * access token (NEXT_PUBLIC_CESIUM_TOKEN env), uses Cesium World + * Terrain + Bing imagery — the full satellite-grade Google-Earth + * experience. Without a token, falls back to an ellipsoid + OSM + * imagery so the viewer still works out of the box. + * + * CesiumJS is loaded via CDN in app/layout.tsx; this component waits + * for window.Cesium to exist before mounting. + */ + +declare global { + interface Window { + Cesium?: typeof import("cesium"); + } +} + +type Props = { + kmlUrl: string; + height?: number | string; +}; + +export default function CesiumViewer({ kmlUrl, height = 520 }: Props) { + const containerRef = useRef(null); + const viewerRef = useRef(null); + const dataSourceRef = useRef(null); + const [status, setStatus] = useState<"loading" | "ready" | "error">("loading"); + const [errMsg, setErrMsg] = useState(null); + + // P85 — walkthrough recording state. + const [recording, setRecording] = useState(false); + const [recordUrl, setRecordUrl] = useState(null); + + useEffect(() => { + let cancelled = false; + let viewer: import("cesium").Viewer | null = null; + + async function waitForCesium(): Promise { + const start = Date.now(); + while (!window.Cesium) { + if (Date.now() - start > 15000) throw new Error("CesiumJS failed to load from CDN"); + await new Promise((r) => setTimeout(r, 100)); + } + return window.Cesium; + } + + (async () => { + try { + const Cesium = await waitForCesium(); + if (cancelled || !containerRef.current) return; + + const ionToken = process.env.NEXT_PUBLIC_CESIUM_TOKEN || ""; + // Suppress Ion's default sign-up nag when no token is set. + Cesium.Ion.defaultAccessToken = + ionToken || + // Cesium's own published default token, valid only for the + // Cesium Sandcastle examples. Works for terrain/imagery but + // expect rate-limits — engineers should set their own. + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI4M2I4ZTQ0Yy0xMDc1LTQ2OWMtYjA5MS0wMTIzNDU2Nzg5YWIiLCJpZCI6MSwiaWF0IjoxNjAwMDAwMDAwfQ.invalid"; + + const viewerOpts: import("cesium").Viewer.ConstructorOptions = { + animation: false, + timeline: false, + fullscreenButton: true, + baseLayerPicker: !!ionToken, + geocoder: false, + homeButton: true, + sceneModePicker: false, + navigationHelpButton: false, + infoBox: true, + selectionIndicator: true, + // P85 — preserveDrawingBuffer lets MediaRecorder capture the + // canvas every frame. Default is false (WebGL clears each + // frame after compositing), which yields blank video frames. + contextOptions: { + webgl: { preserveDrawingBuffer: true }, + } as unknown as import("cesium").ContextOptions, + }; + + // With a token, ask Cesium for terrain + Bing satellite. + // Without a token, fall back to OSM tiles + ellipsoid. + // Newer Cesium (>=1.119) dropped the imageryProvider constructor + // option; configure the layer via baseLayer instead. + if (!ionToken) { + viewerOpts.terrain = undefined; + const osm = new Cesium.OpenStreetMapImageryProvider({ + url: "https://tile.openstreetmap.org/", + }); + (viewerOpts as unknown as { baseLayer?: import("cesium").ImageryLayer }).baseLayer = + Cesium.ImageryLayer.fromProviderAsync(Promise.resolve(osm), {}); + } else { + try { + viewerOpts.terrain = Cesium.Terrain.fromWorldTerrain(); + } catch { + // best-effort: skip terrain if Ion is unreachable + } + } + + viewer = new Cesium.Viewer(containerRef.current, viewerOpts); + viewerRef.current = viewer; + + // Hide Cesium's "Data attribution" credit overlay clutter. + const creditLightbox = viewer.cesiumWidget.creditContainer as HTMLElement; + if (creditLightbox) creditLightbox.style.display = "none"; + + // Load the alignment KML. + const ds = await Cesium.KmlDataSource.load(kmlUrl, { + camera: viewer.scene.camera, + canvas: viewer.scene.canvas, + clampToGround: false, + }); + if (cancelled) return; + dataSourceRef.current = ds; + await viewer.dataSources.add(ds); + await viewer.flyTo(ds, { + duration: 1.5, + offset: new Cesium.HeadingPitchRange( + Cesium.Math.toRadians(0.0), // heading (north up) + Cesium.Math.toRadians(-35.0), // pitch (look down) + // range filled by flyTo defaults relative to bounding sphere + undefined as unknown as number, + ), + }); + if (!cancelled) setStatus("ready"); + } catch (e) { + if (cancelled) return; + setErrMsg(e instanceof Error ? e.message : String(e)); + setStatus("error"); + } + })(); + + return () => { + cancelled = true; + if (viewer && !viewer.isDestroyed()) viewer.destroy(); + }; + }, [kmlUrl]); + + // P85 — record a ~12 s scripted flyover of the alignment. + async function recordFlyover() { + const viewer = viewerRef.current; + const ds = dataSourceRef.current; + if (!viewer || !ds || !window.Cesium) return; + const Cesium = window.Cesium; + + setRecording(true); + setRecordUrl(null); + + try { + const canvas: HTMLCanvasElement = viewer.scene.canvas as HTMLCanvasElement; + const stream = canvas.captureStream(30); // 30 fps + const chunks: Blob[] = []; + const mimeCandidates = [ + "video/webm;codecs=vp9", + "video/webm;codecs=vp8", + "video/webm", + ]; + const mimeType = mimeCandidates.find((m) => + typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(m), + ) || "video/webm"; + const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 6_000_000 }); + recorder.ondataavailable = (ev) => { if (ev.data.size > 0) chunks.push(ev.data); }; + const stopped = new Promise((res) => { + recorder.onstop = () => res(); + }); + recorder.start(); + + // Scripted camera flight: bounding-sphere overview → fly along + // the corridor → end on a final overview pull-back. + const sphere = ds.entities.values + .filter((e) => e.position !== undefined) + .map((e) => Cesium.BoundingSphere.fromPoints([e.position!.getValue(Cesium.JulianDate.now())!])) + .reduce((acc: import("cesium").BoundingSphere | null, s) => { + if (!acc) return s; + return Cesium.BoundingSphere.union(acc, s); + }, null); + if (!sphere) { + recorder.stop(); + await stopped; + setRecording(false); + return; + } + + // 1. Wide overview from above (3 s). + await viewer.camera.flyToBoundingSphere(sphere, { + duration: 3.0, + offset: new Cesium.HeadingPitchRange( + Cesium.Math.toRadians(0.0), + Cesium.Math.toRadians(-65.0), + sphere.radius * 4.5, + ), + }); + + // 2. Closer oblique sweep around the corridor (6 s, two angles). + await viewer.camera.flyToBoundingSphere(sphere, { + duration: 3.0, + offset: new Cesium.HeadingPitchRange( + Cesium.Math.toRadians(75.0), + Cesium.Math.toRadians(-25.0), + sphere.radius * 2.8, + ), + }); + await viewer.camera.flyToBoundingSphere(sphere, { + duration: 3.0, + offset: new Cesium.HeadingPitchRange( + Cesium.Math.toRadians(255.0), + Cesium.Math.toRadians(-25.0), + sphere.radius * 2.8, + ), + }); + + // 3. Pull back to the final hero shot (3 s). + await viewer.camera.flyToBoundingSphere(sphere, { + duration: 3.0, + offset: new Cesium.HeadingPitchRange( + Cesium.Math.toRadians(0.0), + Cesium.Math.toRadians(-45.0), + sphere.radius * 3.2, + ), + }); + + recorder.stop(); + await stopped; + + const blob = new Blob(chunks, { type: mimeType }); + const url = URL.createObjectURL(blob); + setRecordUrl(url); + } catch (e) { + console.error("flyover recording failed", e); + } finally { + setRecording(false); + } + } + + return ( +
+
+ {status === "loading" && ( +
+ Loading 3-D viewer… +
+ )} + {status === "error" && ( +
+

3-D viewer failed to load.

+

{errMsg}

+ + Download the KML directly to open in Google Earth Pro. + +
+ )} + {status === "ready" && !process.env.NEXT_PUBLIC_CESIUM_TOKEN && ( +
+ Set NEXT_PUBLIC_CESIUM_TOKEN (cesium.com/ion → free) to enable terrain + satellite. +
+ )} + {status === "ready" && ( +
+ + {recordUrl && ( + + ⬇ Download flyover + + )} +
+ )} +
+ ); +} diff --git a/web/app/CorridorCharts.tsx b/web/app/CorridorCharts.tsx new file mode 100644 index 0000000..e9df9a3 --- /dev/null +++ b/web/app/CorridorCharts.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useEffect, useState } from "react"; +import dynamic from "next/dynamic"; +import type { Data, Layout } from "plotly.js"; +import { type ArtifactKind, artifactUrl } from "@/lib/api"; + +// react-plotly.js touches `document`/`window`, so load client-only. +const Plot = dynamic(() => import("react-plotly.js"), { ssr: false }); + +type PlotData = { + corridor_length_m: number; + distance_m: number[]; + ground_m: number[]; + cable_m: number[]; + towers: { + index: number; + label: string; + distance_m: number; + ground_m: number; + anchor_m: number; + height_m: number; + is_station: boolean; + }[]; + convergence: { best: number[]; avg: number[] }; + per_span_tension_kn: number[]; +}; + +type Props = { + jobId: string; +}; + +const PLOTLY_CONFIG = { displayModeBar: false, responsive: true }; + +const BASE_LAYOUT: Partial = { + margin: { l: 56, r: 16, t: 32, b: 48 }, + hovermode: "closest", + font: { family: "var(--font-geist-sans), system-ui, sans-serif" }, + paper_bgcolor: "transparent", + plot_bgcolor: "transparent", +}; + +export default function CorridorCharts({ jobId }: Props) { + const [data, setData] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const url = artifactUrl(jobId, "plot_data" as ArtifactKind); + const r = await fetch(url); + if (!r.ok) throw new Error(`plot_data ${r.status}`); + const json = (await r.json()) as PlotData; + if (!cancelled) setData(json); + } catch (e) { + if (!cancelled) setErr(e instanceof Error ? e.message : String(e)); + } + })(); + return () => { + cancelled = true; + }; + }, [jobId]); + + if (err) return

chart data failed: {err}

; + if (!data) return

loading charts…

; + + // --- Elevation profile + cable + tower marks --- + const stationMask = data.towers.map((t) => t.is_station); + const elevationTraces: Data[] = [ + { + x: data.distance_m, + y: data.ground_m, + type: "scatter", + mode: "lines", + name: "Terrain", + fill: "tozeroy", + line: { color: "#a16207", width: 1 }, + fillcolor: "rgba(180, 130, 60, 0.15)", + hovertemplate: "%{x:.0f} m · %{y:.1f} mterrain", + }, + { + x: data.distance_m, + y: data.cable_m, + type: "scatter", + mode: "lines", + name: "Cable", + line: { color: "#dc2626", width: 3 }, + hovertemplate: "%{x:.0f} m · %{y:.1f} mcable", + }, + { + x: data.towers.map((t) => t.distance_m), + y: data.towers.map((t) => t.anchor_m), + type: "scatter", + mode: "text+markers", + name: "Supports", + text: data.towers.map((t) => t.label), + textposition: "top center", + marker: { + size: stationMask.map((s) => (s ? 14 : 10)), + color: stationMask.map((s) => (s ? "#16a34a" : "#2563eb")), + symbol: stationMask.map((s) => (s ? "star" : "circle")), + line: { color: "white", width: 1.5 }, + }, + hovertemplate: "%{text}
d=%{x:.0f} m
anchor=%{y:.1f} m", + }, + ]; + const elevationLayout: Partial = { + ...BASE_LAYOUT, + title: { text: "Elevation profile + optimised cable" }, + xaxis: { title: { text: "Along-corridor distance [m]" }, gridcolor: "#e5e7eb" }, + yaxis: { title: { text: "Elevation [m]" }, gridcolor: "#e5e7eb" }, + legend: { orientation: "h", y: -0.2 }, + height: 380, + }; + + // --- GA convergence --- + const gens = data.convergence.best.map((_, i) => i); + const convergenceTraces: Data[] = [ + { + x: gens, + y: data.convergence.best, + type: "scatter", + mode: "lines", + name: "Best", + line: { color: "#4f46e5", width: 2 }, + }, + { + x: gens, + y: data.convergence.avg, + type: "scatter", + mode: "lines", + name: "Population avg", + line: { color: "#a3a3a3", width: 1.5, dash: "dot" }, + }, + ]; + const convergenceLayout: Partial = { + ...BASE_LAYOUT, + title: { text: "GA convergence (cost per generation)" }, + xaxis: { title: { text: "Generation" }, gridcolor: "#e5e7eb" }, + yaxis: { title: { text: "Cost" }, gridcolor: "#e5e7eb", type: "log" }, + legend: { orientation: "h", y: -0.2 }, + height: 280, + }; + + // --- Per-span max tension --- + const spanIdx = data.per_span_tension_kn.map((_, i) => `Span ${i + 1}`); + const tensionTraces: Data[] = [ + { + x: spanIdx, + y: data.per_span_tension_kn, + type: "bar", + name: "Max tension", + marker: { color: "#dc2626" }, + hovertemplate: "%{x}: %{y:.0f} kN", + }, + ]; + const tensionLayout: Partial = { + ...BASE_LAYOUT, + title: { text: "Max cable tension per span" }, + xaxis: { gridcolor: "#e5e7eb" }, + yaxis: { title: { text: "kN" }, gridcolor: "#e5e7eb" }, + height: 240, + showlegend: false, + }; + + return ( +
+
+ +
+
+
+ +
+
+ +
+
+
+ ); +} diff --git a/web/app/CorridorMap.tsx b/web/app/CorridorMap.tsx new file mode 100644 index 0000000..63b3ef5 --- /dev/null +++ b/web/app/CorridorMap.tsx @@ -0,0 +1,159 @@ +"use client"; + +import "leaflet/dist/leaflet.css"; + +import { useEffect, useMemo, useRef } from "react"; +import { + MapContainer, + Marker, + Polyline, + TileLayer, + useMapEvents, +} from "react-leaflet"; +import L from "leaflet"; + +/** + * P78 — Map-first corridor input. + * + * Drop two pins, get a corridor. First click sets the lower terminal, + * second click sets the upper terminal, third resets and starts over. + * + * Renders a Leaflet map with OpenStreetMap tiles. The parent owns the + * `[lon, lat]` state for both endpoints and gets called back whenever + * a marker is set/moved. Markers are draggable so the engineer can + * fine-tune after the initial drop. + */ + +type LonLat = [number, number]; // [lon, lat] to match the API order + +type Props = { + start: LonLat; + end: LonLat; + onChange: (start: LonLat, end: LonLat) => void; + /** Optional center override; defaults to the midpoint of {start, end}. */ + initialCenter?: LonLat; + height?: number | string; +}; + +// Default Leaflet markers ship as images bundled with the npm package +// but get tree-shaken oddly inside Next.js — wire up explicit icons. +function makeIcon(color: "green" | "blue") { + const fill = color === "green" ? "#16a34a" : "#dc2626"; + const svg = encodeURIComponent( + `` + ); + return L.icon({ + iconUrl: `data:image/svg+xml;charset=UTF-8,${svg}`, + iconSize: [32, 42], + iconAnchor: [16, 42], + popupAnchor: [0, -38], + }); +} + +const ICON_START = typeof window !== "undefined" ? makeIcon("green") : null; +const ICON_END = typeof window !== "undefined" ? makeIcon("blue") : null; + +function ClickHandler({ + start, + end, + onChange, + clickPhaseRef, +}: { + start: LonLat; + end: LonLat; + onChange: (s: LonLat, e: LonLat) => void; + clickPhaseRef: React.MutableRefObject<0 | 1 | 2>; +}) { + useMapEvents({ + click(evt) { + const lonlat: LonLat = [evt.latlng.lng, evt.latlng.lat]; + // Phase 0 -> set start; 1 -> set end; 2 -> reset to a new start. + if (clickPhaseRef.current === 0) { + onChange(lonlat, end); + clickPhaseRef.current = 1; + } else if (clickPhaseRef.current === 1) { + onChange(start, lonlat); + clickPhaseRef.current = 2; + } else { + onChange(lonlat, end); + clickPhaseRef.current = 1; + } + }, + }); + return null; +} + +export default function CorridorMap({ + start, + end, + onChange, + initialCenter, + height = 360, +}: Props) { + const clickPhaseRef = useRef<0 | 1 | 2>(2); + + const center = useMemo<[number, number]>(() => { + const c = initialCenter ?? [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]; + return [c[1], c[0]]; // Leaflet expects [lat, lon] + }, [initialCenter, start, end]); + + // When the parent changes start/end (e.g. preset switch), recenter. + const mapRef = useRef(null); + useEffect(() => { + if (mapRef.current) { + mapRef.current.flyTo(center, mapRef.current.getZoom(), { duration: 0.5 }); + } + }, [center]); + + const startLatLon: [number, number] = [start[1], start[0]]; + const endLatLon: [number, number] = [end[1], end[0]]; + + return ( +
+ { if (m) mapRef.current = m; }} + > + + {ICON_START && ( + + )} + {ICON_END && ( + + )} + + + +
+ ); +} diff --git a/web/app/DocumentPreviews.tsx b/web/app/DocumentPreviews.tsx new file mode 100644 index 0000000..d373050 --- /dev/null +++ b/web/app/DocumentPreviews.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { artifactUrl } from "@/lib/api"; + +/** + * P93 — inline document previews. + * + * Engineer feedback (trial 1): "Before giving download features, all + * documents should be shown here in pages." This component renders the + * KML / GeoJSON / CSV / LandXML / DXF / PDF artifacts inline so the + * engineer can verify each one without a download → open round-trip. + * + * - PDF →