diff --git a/CHANGELOG.md b/CHANGELOG.md index 1824b07..4192d68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,29 @@ a documentation / housekeeping commit on `main`. ## [Unreleased] +### Added + +- **Streamlit UI polish.** The optimizer UI gains the operator-facing + pieces the Phase 2 MVP lacked: + - **Joint H+V control** — a "Corridor half-width" sidebar slider wires + Phase 12c into the UI. Above 0 (GA mode) the run passes a + `surface_fn` built from the DEM swath, so the GA may shift towers + laterally; the run console reports how many towers ended up offset. + - **PPHPD capacity metric** — a line-speed slider plus a "Capacity" + headline metric (persons per hour per direction) from cabin spacing + and speed. + - **Bottom run console** — a persistent expander logging each run + (terrain extent, optimizer config, feasibility, violations). + - **GeoJSON import** — upload a previously-exported `alignment.geojson` + in the Projects tab; the tower schedule is parsed, shown, and + re-evaluated against the current terrain run. + +### Changed + +- CI: `actions/checkout` v4→v5 and `actions/setup-python` v5→v6 to clear + the Node.js 20 deprecation warning ahead of the 2026-06-02 runner + cutover. + ## [0.7.0] — 2026-05-19 — Phase 12c: joint horizontal + vertical alignment ### Added diff --git a/app/streamlit_app.py b/app/streamlit_app.py index 1084080..e2fff89 100644 --- a/app/streamlit_app.py +++ b/app/streamlit_app.py @@ -80,6 +80,15 @@ seat_spacing = st.slider("Seat / cabin spacing [m]", 10, 200, 50, step=5) pax_per_seat = st.slider("Passengers per seat", 1, 12, 8) dyn_factor = st.slider("Dynamic load factor", 1.0, 1.5, 1.1, step=0.05) + line_speed = st.slider("Line speed [m/s]", 1.0, 12.0, 6.0, step=0.5) + + st.header("Joint H+V optimization (Phase 12c)") + corridor_half_width = st.slider( + "Corridor half-width [m]", 0, 300, 0, step=10, + help="0 disables horizontal optimization (pure-vertical GA). " + "Above 0, the GA may shift towers laterally within the DEM " + "swath to route around high terrain. GA mode only.", + ) run_btn = st.button("Run optimization", type="primary", width="stretch") @@ -94,9 +103,27 @@ def _build_cfg() -> ConstraintConfig: seat_spacing_m=float(seat_spacing), passengers_per_seat=int(pax_per_seat), dynamic_load_factor=float(dyn_factor), + corridor_half_width_m=float(corridor_half_width), ) +def _pphpd(speed_m_s: float, seat_spacing_m: float, pax: int) -> float: + """Persons per hour per direction: the headline transit-capacity metric. + + A carrier passes a fixed point every ``seat_spacing / speed`` seconds; + PPHPD = (3600 / headway) * pax_per_carrier. + """ + if seat_spacing_m <= 0 or speed_m_s <= 0: + return 0.0 + headway_s = seat_spacing_m / speed_m_s + return 3600.0 / headway_s * pax + + +def _log(msg: str) -> None: + """Append a line to the bottom run console.""" + st.session_state.setdefault("console_log", []).append(msg) + + def _load_terrain(): """Return (profile, patch). Patch may be None if DEM swath unavailable.""" if mode == "Synthetic": @@ -123,29 +150,47 @@ def _load_terrain(): # Run optimization -> store everything in session_state # -------------------------------------------------------------------------- if run_btn: + st.session_state["console_log"] = [] with st.spinner("Loading terrain..."): profile, patch = _load_terrain() cfg = _build_cfg() + _log(f"Terrain loaded — corridor {profile.total_length:.0f} m, " + f"elevation {profile.elevation.min():.0f}→{profile.elevation.max():.0f} m.") + + joint_hv = corridor_half_width > 0 and patch is not None + surface_fn = None if optimizer_mode == "Pareto (NSGA-II)": + if joint_hv: + st.info("Joint H+V optimization runs in GA mode only — " + "NSGA-II uses the straight corridor.") + _log("NSGA-II: horizontal optimization skipped (GA mode only).") nsga = NSGAConfig( max_intermediate_towers=int(max_towers), population_size=int(population), generations=int(generations), seed=int(seed_ga), ) + _log(f"Running NSGA-II — pop {population}, gen {generations}, seed {seed_ga}.") with st.spinner("Running NSGA-II..."): front = optimize_pareto(profile.as_function(), profile.total_length, cfg=cfg, nsga=nsga) if not front.solutions: st.error("No feasible Pareto solutions.") + _log("NSGA-II: no feasible Pareto solutions.") st.stop() st.session_state["pareto"] = front chosen = front.best_by(0) st.session_state["alignment"] = chosen.alignment st.session_state["eval"] = chosen.result st.session_state["history"] = (None, None) + _log(f"NSGA-II done — {len(front.solutions)} Pareto solutions.") else: + if joint_hv: + surface_fn = patch.surface_function() + _log(f"Joint H+V enabled — corridor half-width " + f"{corridor_half_width:.0f} m (2-D DEM swath).") + _log(f"Running GA — pop {population}, gen {generations}, seed {seed_ga}.") with st.spinner("Running GA..."): result = optimize(profile.as_function(), profile.total_length, cfg=cfg, ga=GAConfig( @@ -153,16 +198,22 @@ def _load_terrain(): population_size=int(population), generations=int(generations), seed=int(seed_ga), - ), verbose=False) + ), surface_fn=surface_fn, verbose=False) if not result.best_result.feasible: st.error("No feasible alignment within current constraints.") + _log(f"GA: infeasible — {len(result.best_result.report.violations)} " + "violation(s).") for v in result.best_result.report.violations[:10]: st.write(f"- {v}") + _log(f" violation: {v}") st.stop() st.session_state.pop("pareto", None) st.session_state["alignment"] = result.best_alignment st.session_state["eval"] = result.best_result st.session_state["history"] = (result.history_best, result.history_avg) + n_off = sum(1 for t in result.best_alignment.towers if abs(t.offset) > 1.0) + _log(f"GA done — feasible, {len(result.best_alignment.towers)} towers" + + (f", {n_off} laterally offset." if joint_hv else ".")) st.session_state["profile"] = profile st.session_state["patch"] = patch @@ -192,12 +243,17 @@ def _load_terrain(): with tab_opt: rep = eval_res.report n_int = max(0, len(alignment.towers) - 2) - cols = st.columns(5) + pphpd = _pphpd(float(line_speed), float(cfg.seat_spacing_m), + int(cfg.passengers_per_seat)) + cols = st.columns(6) cols[0].metric("Intermediate towers", n_int) cols[1].metric("Cable length", f"{rep.total_cable_length_m:.0f} m") cols[2].metric("Min clearance", f"{rep.min_clearance_m:.2f} m") cols[3].metric("Max tension", f"{rep.max_tension_n/1e3:.1f} kN") cols[4].metric("Max break-over", f"{rep.max_break_over_deg:.1f}°") + cols[5].metric("Capacity", f"{pphpd:,.0f} pphpd", + help="Persons per hour per direction at the configured " + "line speed and cabin spacing.") fig_align, _ = plot_alignment(profile, alignment, segments=eval_res.segments) st.pyplot(fig_align) @@ -358,3 +414,67 @@ def _badge(ok: bool, label: str, detail: str): "end_lonlat": rec.end_lonlat, "towers": [{"distance": t.distance, "height": t.height} for t in rec.towers], }) + + st.divider() + st.subheader("Import alignment from GeoJSON") + st.caption("Load a previously-exported `alignment.geojson` and re-evaluate " + "its tower schedule against the current terrain run.") + gj_file = st.file_uploader("alignment.geojson", type=["geojson", "json"], + key="gj_import") + if gj_file is not None: + import json as _json + try: + fc = _json.loads(gj_file.getvalue()) + feats = [f for f in fc.get("features", []) + if f.get("properties", {}).get("kind") == "tower"] + except Exception as exc: # noqa: BLE001 - surface parse errors to the user + feats = [] + st.error(f"Could not parse GeoJSON: {exc}") + if feats: + feats.sort(key=lambda f: f["properties"].get("distance_m", 0.0)) + imported = [ + Tower( + distance=float(f["properties"]["distance_m"]), + height=float(f["properties"]["height_m"]), + is_station=bool(f["properties"].get("is_station", False)), + offset=float(f["properties"].get("offset_m", 0.0)), + ) + for f in feats + ] + st.dataframe( + [{"idx": i, "distance_m": round(t.distance, 1), + "height_m": round(t.height, 2), + "offset_m": round(t.offset, 2), "station": t.is_station} + for i, t in enumerate(imported)], + width="stretch", + ) + st.success(f"Parsed {len(imported)} towers from GeoJSON.") + imported_align = Alignment( + towers=imported, profile_fn=profile.as_function(), cfg=cfg, + ) + imported_eval = evaluate_alignment(imported_align) + irep = imported_eval.report + if imported_eval.feasible: + st.info( + f"Re-evaluated against the current terrain: **feasible** — " + f"min clearance {irep.min_clearance_m:.2f} m, " + f"max tension {irep.max_tension_n/1e3:.1f} kN." + ) + else: + st.warning( + f"Re-evaluated against the current terrain: " + f"**{len(irep.violations)} violation(s)** — the imported " + "layout does not satisfy the current corridor/constraints." + ) + + +# -------------------------------------------------------------------------- +# Bottom run console +# -------------------------------------------------------------------------- +st.divider() +with st.expander("Run console", expanded=False): + log = st.session_state.get("console_log", []) + if not log: + st.caption("No run yet — the console logs each optimization run.") + else: + st.code("\n".join(log), language="text")