From ebd0132b4f2a650c1135578cc3c26d651302042e Mon Sep 17 00:00:00 2001 From: Harsh Pandhe Date: Wed, 20 May 2026 12:26:44 +0530 Subject: [PATCH] =?UTF-8?q?feat(phase-12d):=20ForcedFlyOverZone=20?= =?UTF-8?q?=E2=80=94=20bridge/freeway/water=20corridor=20intervals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dual of NoTowerZone. Three case studies in a row surfaced the same gap: Roosevelt Island Queensboro Bridge, London Thames shipping channel, Portland OHSU I-5 deck — all man-made obstacles the DEM does not contain, so the optimizer was producing physically valid but practically wrong single-span layouts. ForcedFlyOverZone declares an along-corridor interval where the cable must clear a minimum absolute elevation. The evaluator penalises any cable sample inside the zone that sits below min_cable_elev_m, so the GA gets a smooth gradient pushing tower heights up / inserting intermediates to lift the cable over the obstacle. API additions: - src/ropeway/obstacles.py — ForcedFlyOverZone dataclass with normalised interval, contains/overlaps_span helpers, deficit_at (worst below-minimum inside the zone, element-wise on arrays). - Alignment.forced_flyover_zones (list, defaults []). - optimize(forced_flyover_zones=[...]) threads the list through to every alignment built per individual. Penalty: 5e3 * deficit_m per offending span — same order as the no-tower-zone weight, so the two zones compete cleanly and the GA honours both. Tests: tests/test_forced_flyover.py — 10 new (dataclass invariants, deficit math, evaluator penalty firing / silent / outside corridor, GA raises towers to clear, NoTowerZone + ForcedFlyOverZone coexist on the same corridor). Suite 184 -> 199, zero regressions. --- CHANGELOG.md | 17 ++++ README.md | 7 +- src/ropeway/alignment.py | 24 +++++ src/ropeway/obstacles.py | 48 ++++++++++ src/ropeway/optimizer.py | 2 + tests/test_forced_flyover.py | 176 +++++++++++++++++++++++++++++++++++ 6 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 tests/test_forced_flyover.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e25a7f0..886fc79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,23 @@ a documentation / housekeeping commit on `main`. ### Added +- **Phase 12d — `ForcedFlyOverZone`** (`src/ropeway/obstacles.py`). The + dual of `NoTowerZone`: corridor intervals where the cable must clear + a minimum *absolute* elevation. Closes the unifying gap surfaced by + three case studies in a row — Roosevelt Island Queensboro Bridge + (~40 m), London Thames shipping channel (~50 m), Portland OHSU I-5 + deck (~36 m). The DEM does not contain these obstacles, so the + optimizer was producing physically valid but practically wrong + single-span layouts; with a `ForcedFlyOverZone` declared the GA + raises tower heights / inserts intermediates to lift the cable over + the obstacle. `Alignment.forced_flyover_zones` and + `optimize(forced_flyover_zones=[...])` accept a list; the evaluator + penalises any cable sample inside the zone that sits below + `min_cable_elev_m`. `tests/test_forced_flyover.py` — 10 new tests + (dataclass invariants, deficit math, evaluator penalty firing / + silent / outside-corridor, GA-routes-up integration, coexistence + with `NoTowerZone`). Suite 184 → 199. + - **Phase 15j/k/l — three more case studies, twelve installations, five continents.** Reinforces the urban-transit story (MGD and jig-back) and surfaces a sharper specification for the next diff --git a/README.md b/README.md index 6ec1742..466f795 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI](https://github.com/harsh-pandhe/Autonomous-Ropeway-Alignment/actions/workflows/ci.yml/badge.svg)](https://github.com/harsh-pandhe/Autonomous-Ropeway-Alignment/actions/workflows/ci.yml) [![Python](https://img.shields.io/badge/python-3.12-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![Tests](https://img.shields.io/badge/tests-157%20passing-brightgreen.svg)](#testing) +[![Tests](https://img.shields.io/badge/tests-199%20passing-brightgreen.svg)](#testing) [![Validation](https://img.shields.io/badge/textbook%20validation-0.00%25%20error-brightgreen.svg)](src/ropeway/validation.py) Open-source toolchain that ingests a free DEM and two station coordinates, then @@ -101,8 +101,9 @@ ropeway run \ | 7+ | ✅ | No-tower exclusion zones + pinned intermediate stations wired into GA + NSGA-II | | 12b | ✅ | Coupled elastic-cable solver (per-span elongation + thermal — hot day lowers clearance, cold day raises tension) | | 12c | ✅ | Joint horizontal + vertical alignment optimization (per-tower lateral offset within a DEM swath) | -| 13 | ⬜ | AI assistant (LLM natural-language → optimization) | -| 14 | ⬜ | htmx web client (shareable links) | +| 13 | ✅ | AI assistant (LLM natural-language → optimization) — `ropeway ask "..."` | +| 14 | ✅ | htmx web client (shareable links) — `/` + `/htmx/run/{rid}` | +| 12d | ✅ | `ForcedFlyOverZone` — bridge / freeway / shipping-channel corridor intervals where the cable must clear a minimum absolute elevation (dual of `NoTowerZone`) | | 15b-redux / 15c-redux | ✅ | Re-ran Línea Roja with Cementerio pinned (3-station topology now matches) + Whistler with Fitzsimmons no-tower valley (2 806 m single span, 7 % vs as-built 3 024 m, down from 47 %) | | 15d | ✅ | Case study: Roosevelt Island Tramway (NYC urban jig-back) | | 15e | ✅ | Case study: Medellín Metrocable Línea K (MGD, 4-station, 2 pinned waypoints) | diff --git a/src/ropeway/alignment.py b/src/ropeway/alignment.py index 8970141..397ea8f 100644 --- a/src/ropeway/alignment.py +++ b/src/ropeway/alignment.py @@ -67,10 +67,17 @@ class Alignment: # When None, the alignment is purely vertical and every tower stays on # the corridor centreline — bit-identical to the pre-12c behaviour. surface_fn: Callable[[np.ndarray, np.ndarray], np.ndarray] | None = None + # Phase 12d: optional list of `obstacles.ForcedFlyOverZone` — corridor + # intervals where the cable must clear a minimum absolute elevation + # (bridge deck, freeway, shipping channel — obstacles the DEM does + # not contain). Empty by default; no behaviour change when absent. + forced_flyover_zones: list = None def __post_init__(self) -> None: if self.no_tower_zones is None: self.no_tower_zones = [] + if self.forced_flyover_zones is None: + self.forced_flyover_zones = [] # The two terminals are always stations. if self.towers: self.towers[0].is_station = True @@ -244,6 +251,23 @@ def evaluate_alignment( f"span starting at {seg.xA:.0f} m" ) + # Phase 12d: forced-fly-over zones — bridge/freeway/shipping + # channel intervals where the cable must clear an absolute + # minimum elevation the DEM does not contain. Penalty scales + # with the worst deficit so the GA gets a clean lift gradient. + for fz in alignment.forced_flyover_zones: + if not fz.overlaps_span(float(sample_d[0]), float(sample_d[-1])): + continue + deficit = fz.deficit_at(cable_z, sample_d) + if deficit > 0.0: + penalty += 5e3 * deficit + rep.add_violation( + f"cable {deficit:.2f} m below required fly-over " + f"{fz.min_cable_elev_m:.1f} m in zone " + f"'{fz.name or 'flyover'}' " + f"[{fz.distance_start_m:.0f}, {fz.distance_end_m:.0f}] m" + ) + total_len += seg.length() max_tension = max(max_tension, seg.max_tension(w_loaded)) diff --git a/src/ropeway/obstacles.py b/src/ropeway/obstacles.py index b02a209..40dfa8f 100644 --- a/src/ropeway/obstacles.py +++ b/src/ropeway/obstacles.py @@ -89,6 +89,54 @@ def overlaps_span(self, span_start_m: float, span_end_m: float) -> bool: return not (hi < self.distance_start_m or lo > self.distance_end_m) +@dataclass +class ForcedFlyOverZone: + """An along-corridor interval where the cable must clear a *minimum + elevation* — the dual of :class:`NoTowerZone`. + + Use it for man-made obstacles the DEM does not contain: a bridge + deck, a freeway overpass, a navigable-water shipping channel. The + optimizer checks that the cable elevation stays at or above + ``min_cable_elev_m`` everywhere inside the interval; any sample + below is penalised proportional to the deficit, so the GA gets a + smooth gradient pushing the cable up over the obstacle. + + Three case studies surfaced this gap: + * Roosevelt Island — Queensboro Bridge deck (~ 40 m above MSL) + * London IFS Cloud — Thames shipping channel (~ 50 m mast clearance) + * Portland OHSU — Interstate 5 freeway deck (~ 36 m) + """ + + distance_start_m: float + distance_end_m: float + min_cable_elev_m: float + name: str = "" + + def __post_init__(self) -> None: + if self.distance_end_m < self.distance_start_m: + self.distance_start_m, self.distance_end_m = ( + self.distance_end_m, self.distance_start_m + ) + + def contains(self, distance_m: float) -> bool: + """True if a point at ``distance_m`` falls inside this zone.""" + return self.distance_start_m <= distance_m <= self.distance_end_m + + def overlaps_span(self, span_start_m: float, span_end_m: float) -> bool: + """True if the span [start, end] intersects this zone.""" + lo, hi = sorted((span_start_m, span_end_m)) + return not (hi < self.distance_start_m or lo > self.distance_end_m) + + def deficit_at(self, cable_elev_m, distance_m) -> float: + """How far below ``min_cable_elev_m`` the cable sits at one sample + (0.0 when above or outside the zone). Element-wise on arrays.""" + d = np.asarray(distance_m, dtype=float) + z = np.asarray(cable_elev_m, dtype=float) + inside = (d >= self.distance_start_m) & (d <= self.distance_end_m) + below = self.min_cable_elev_m - z + return float(np.maximum(0.0, np.where(inside, below, 0.0)).max()) + + def project_polygon_to_corridor( zone: "ExclusionZone", corridor_start_xy: tuple[float, float], diff --git a/src/ropeway/optimizer.py b/src/ropeway/optimizer.py index 83f1159..65f65b7 100644 --- a/src/ropeway/optimizer.py +++ b/src/ropeway/optimizer.py @@ -289,6 +289,7 @@ def optimize( no_tower_zones: list | None = None, intermediate_stations: Sequence[float] | None = None, surface_fn: Callable[[np.ndarray, np.ndarray], np.ndarray] | None = None, + forced_flyover_zones: list | None = None, verbose: bool = False, ) -> GAResult: """Run the GA and return the best feasible alignment found. @@ -332,6 +333,7 @@ def _fitness(individual: list[float]) -> tuple[float]: clearance_profile=clearance_profile, no_tower_zones=no_tower_zones, surface_fn=surface_fn, + forced_flyover_zones=forced_flyover_zones, ) result = evaluate_alignment(alignment) return (result.cost,) diff --git a/tests/test_forced_flyover.py b/tests/test_forced_flyover.py new file mode 100644 index 0000000..9b25141 --- /dev/null +++ b/tests/test_forced_flyover.py @@ -0,0 +1,176 @@ +"""Phase 12d — ForcedFlyOverZone tests.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from ropeway.alignment import Alignment, Tower, evaluate_alignment +from ropeway.obstacles import ForcedFlyOverZone, NoTowerZone, ZoneKind +from ropeway.optimizer import GAConfig, optimize +from ropeway.safety import ConstraintConfig + + +def _flat_profile(level: float = 50.0): + def fn(x): + x = np.asarray(x, dtype=float) + return np.full_like(x, level) + return fn + + +# --------------------------------------------------------------------------- +# Dataclass invariants +# --------------------------------------------------------------------------- + + +def test_forced_flyover_zone_normalises_reversed_interval(): + fz = ForcedFlyOverZone(distance_end_m=200.0, distance_start_m=500.0, + min_cable_elev_m=80.0, name="Queensboro Bridge") + assert fz.distance_start_m == 200.0 + assert fz.distance_end_m == 500.0 + + +def test_contains_and_overlaps_span(): + fz = ForcedFlyOverZone(300.0, 600.0, min_cable_elev_m=80.0) + assert fz.contains(450.0) + assert not fz.contains(100.0) + assert fz.overlaps_span(100.0, 400.0) + assert fz.overlaps_span(500.0, 800.0) + assert not fz.overlaps_span(700.0, 900.0) + + +def test_deficit_returns_worst_below_minimum_in_zone_only(): + fz = ForcedFlyOverZone(100.0, 500.0, min_cable_elev_m=80.0) + d = np.array([50.0, 150.0, 250.0, 350.0, 600.0]) + z = np.array([90.0, 70.0, 60.0, 75.0, 10.0]) # 10 outside, ignored + assert fz.deficit_at(z, d) == pytest.approx(20.0) + + +def test_deficit_zero_when_cable_above_min_everywhere(): + fz = ForcedFlyOverZone(0.0, 1000.0, min_cable_elev_m=50.0) + d = np.linspace(0.0, 1000.0, 20) + z = np.full_like(d, 70.0) + assert fz.deficit_at(z, d) == 0.0 + + +# --------------------------------------------------------------------------- +# evaluate_alignment integration +# --------------------------------------------------------------------------- + + +def test_alignment_default_has_empty_flyover_list(): + align = Alignment( + towers=[Tower(0.0, 10.0), Tower(400.0, 10.0)], + profile_fn=_flat_profile(), + cfg=ConstraintConfig(max_span_m=600.0), + ) + assert align.forced_flyover_zones == [] + + +def test_cable_below_flyover_minimum_flags_violation(): + """Low towers + a high required flyover -> the cable can't clear it.""" + cfg = ConstraintConfig( + max_span_m=600.0, min_tower_height_m=5.0, max_tower_height_m=80.0, + ) + align = Alignment( + towers=[Tower(0.0, 10.0), Tower(500.0, 10.0)], + profile_fn=_flat_profile(level=50.0), + cfg=cfg, + forced_flyover_zones=[ + ForcedFlyOverZone(150.0, 350.0, min_cable_elev_m=200.0, + name="Bridge"), + ], + ) + res = evaluate_alignment(align) + msgs = " ".join(res.report.violations) + assert "fly-over" in msgs + assert "Bridge" in msgs + + +def test_tall_towers_clear_flyover_no_violation(): + """Towers taller than the flyover minimum produce no violation.""" + cfg = ConstraintConfig( + max_span_m=600.0, min_tower_height_m=5.0, max_tower_height_m=300.0, + ) + align = Alignment( + towers=[Tower(0.0, 180.0), Tower(500.0, 180.0)], + profile_fn=_flat_profile(level=50.0), + cfg=cfg, + forced_flyover_zones=[ + ForcedFlyOverZone(150.0, 350.0, min_cable_elev_m=200.0), + ], + ) + res = evaluate_alignment(align) + assert not any("fly-over" in v for v in res.report.violations) + + +def test_flyover_outside_corridor_does_not_fire(): + """A zone past the corridor end is irrelevant.""" + cfg = ConstraintConfig(max_span_m=600.0) + align = Alignment( + towers=[Tower(0.0, 10.0), Tower(400.0, 10.0)], + profile_fn=_flat_profile(level=50.0), + cfg=cfg, + forced_flyover_zones=[ + ForcedFlyOverZone(1000.0, 1200.0, min_cable_elev_m=999.0), + ], + ) + res = evaluate_alignment(align) + assert not any("fly-over" in v for v in res.report.violations) + + +# --------------------------------------------------------------------------- +# Optimizer routing — the GA must raise the cable to clear the obstacle +# --------------------------------------------------------------------------- + + +def test_optimizer_lifts_cable_to_clear_forced_flyover(): + """GA passed a tall flyover requirement chooses tall enough towers.""" + cfg = ConstraintConfig( + horizontal_tension_n=400_000.0, cable_weight_n_per_m=50.0, + seat_spacing_m=50.0, passengers_per_seat=8, + max_span_m=500.0, min_tower_height_m=5.0, max_tower_height_m=100.0, + ) + ga = GAConfig(max_intermediate_towers=4, population_size=60, + generations=60, seed=42) + flyover = ForcedFlyOverZone(200.0, 400.0, min_cable_elev_m=130.0, + name="freeway deck") + result = optimize( + _flat_profile(level=50.0), 600.0, cfg=cfg, ga=ga, + forced_flyover_zones=[flyover], + verbose=False, + ) + assert result.best_result.feasible + # No fly-over deficit in the violations list. + assert not any("fly-over" in v for v in result.best_result.report.violations) + # The cable must sit at >= 130 m anywhere inside the zone; with + # ground at 50 m that needs anchors well above 130 m -> at least + # one tower taller than the naive ~10 m default. + assert max(t.height for t in result.best_alignment.towers) >= 70.0 + + +def test_forced_flyover_and_no_tower_zone_coexist(): + """A zone forbidding towers AND a zone requiring high cable can apply + to the same corridor without colliding.""" + cfg = ConstraintConfig( + horizontal_tension_n=400_000.0, cable_weight_n_per_m=50.0, + seat_spacing_m=50.0, passengers_per_seat=8, + max_span_m=900.0, min_tower_height_m=5.0, max_tower_height_m=120.0, + ) + ga = GAConfig(max_intermediate_towers=4, population_size=60, + generations=80, seed=7) + no_tower = NoTowerZone(250.0, 450.0, kind=ZoneKind.WATER, name="river") + flyover = ForcedFlyOverZone(250.0, 450.0, min_cable_elev_m=120.0, + name="shipping channel") + result = optimize( + _flat_profile(level=50.0), 800.0, cfg=cfg, ga=ga, + no_tower_zones=[no_tower], + forced_flyover_zones=[flyover], + verbose=False, + ) + assert result.best_result.feasible + # No tower lands inside the river. + for t in result.best_alignment.towers: + if t.is_station: + continue + assert not no_tower.contains(t.distance)