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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) |
Expand Down
24 changes: 24 additions & 0 deletions src/ropeway/alignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))

Expand Down
48 changes: 48 additions & 0 deletions src/ropeway/obstacles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
2 changes: 2 additions & 0 deletions src/ropeway/optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,)
Expand Down
176 changes: 176 additions & 0 deletions tests/test_forced_flyover.py
Original file line number Diff line number Diff line change
@@ -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)
Loading