Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
124 changes: 122 additions & 2 deletions app/streamlit_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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":
Expand All @@ -123,46 +150,70 @@ 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(
max_intermediate_towers=int(max_towers),
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Loading