Skip to content
Open
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
151 changes: 137 additions & 14 deletions src/ropeway/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from pathlib import Path

from .alignment import Alignment, Tower
from .obstacles import ForcedFlyOverZone, NoTowerZone, ZoneKind
from .safety import ConstraintConfig

DEFAULT_DB_PATH = Path("data/projects.db")
Expand All @@ -46,11 +47,62 @@
corridor_len REAL,
utm_epsg INTEGER,
config_json TEXT NOT NULL,
towers_json TEXT NOT NULL
towers_json TEXT NOT NULL,
extras_json TEXT NOT NULL DEFAULT '{}'
);
"""


def _zones_to_json(zones: list) -> list[dict]:
"""Serialise a list of NoTowerZone instances (Phase 7+)."""
out = []
for z in zones or []:
out.append({
"distance_start_m": z.distance_start_m,
"distance_end_m": z.distance_end_m,
"kind": z.kind.value if hasattr(z.kind, "value") else str(z.kind),
"name": z.name,
})
return out


def _zones_from_json(rows: list[dict]) -> list[NoTowerZone]:
return [
NoTowerZone(
distance_start_m=float(r["distance_start_m"]),
distance_end_m=float(r["distance_end_m"]),
kind=ZoneKind(r["kind"]) if r.get("kind") else ZoneKind.PROTECTED,
name=str(r.get("name", "")),
)
for r in (rows or [])
]


def _flyovers_to_json(zones: list) -> list[dict]:
"""Serialise a list of ForcedFlyOverZone instances (Phase 12d)."""
out = []
for z in zones or []:
out.append({
"distance_start_m": z.distance_start_m,
"distance_end_m": z.distance_end_m,
"min_cable_elev_m": z.min_cable_elev_m,
"name": z.name,
})
return out


def _flyovers_from_json(rows: list[dict]) -> list[ForcedFlyOverZone]:
return [
ForcedFlyOverZone(
distance_start_m=float(r["distance_start_m"]),
distance_end_m=float(r["distance_end_m"]),
min_cable_elev_m=float(r["min_cable_elev_m"]),
name=str(r.get("name", "")),
)
for r in (rows or [])
]


@dataclass
class ProjectRecord:
id: int
Expand All @@ -62,14 +114,39 @@ class ProjectRecord:
utm_epsg: int
config: ConstraintConfig
towers: list[Tower]
# Phase 4+ — Phase 7+ / 12d state persisted alongside the towers.
intermediate_stations: list[float] = None
no_tower_zones: list = None
forced_flyover_zones: list = None

def to_alignment(self, profile_fn, clearance_profile=None) -> Alignment:
"""Rebuild a runnable Alignment given a freshly-sampled profile_fn."""
def __post_init__(self) -> None:
if self.intermediate_stations is None:
self.intermediate_stations = []
if self.no_tower_zones is None:
self.no_tower_zones = []
if self.forced_flyover_zones is None:
self.forced_flyover_zones = []

def to_alignment(self, profile_fn, clearance_profile=None,
surface_fn=None) -> Alignment:
"""Rebuild a runnable Alignment given a freshly-sampled profile_fn.

Phase 4+: ``Tower.offset`` and ``is_station`` are now preserved,
and the Phase 7+ / 12d corridor constraints carry through so the
round-tripped alignment evaluates identically to the saved one.
"""
return Alignment(
towers=[Tower(t.distance, t.height) for t in self.towers],
towers=[
Tower(t.distance, t.height,
is_station=bool(t.is_station), offset=float(t.offset))
for t in self.towers
],
profile_fn=profile_fn,
cfg=self.config,
clearance_profile=clearance_profile,
no_tower_zones=list(self.no_tower_zones),
surface_fn=surface_fn,
forced_flyover_zones=list(self.forced_flyover_zones),
)


Expand All @@ -78,6 +155,14 @@ def _connect(db_path: str | Path) -> sqlite3.Connection:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
conn.execute(_SCHEMA)
# Phase 4+ schema migration: older DBs predate the extras_json column;
# add it idempotently so an upgraded process can still read old files.
try:
conn.execute("ALTER TABLE projects ADD COLUMN extras_json TEXT NOT NULL DEFAULT '{}'")
conn.commit()
except sqlite3.OperationalError:
# Column already exists — expected on every run after the first.
pass
return conn


Expand All @@ -90,28 +175,47 @@ def save_project(
corridor_len: float,
utm_epsg: int,
db_path: str | Path = DEFAULT_DB_PATH,
intermediate_stations: list[float] | None = None,
) -> int:
"""Persist a project and return its new row id."""
"""Persist a project and return its new row id.

Phase 4+: Tower offset + is_station are now stored, and Phase 7+ /
12d corridor constraints (no_tower_zones, intermediate_stations,
forced_flyover_zones) are serialised alongside as ``extras_json``.
"""
conn = _connect(db_path)
try:
config_json = json.dumps(asdict(alignment.cfg))
towers_json = json.dumps(
[{"distance": t.distance, "height": t.height} for t in alignment.towers]
)
towers_json = json.dumps([
{
"distance": t.distance,
"height": t.height,
"is_station": bool(t.is_station),
"offset": float(t.offset),
}
for t in alignment.towers
])
extras_json = json.dumps({
"intermediate_stations": list(intermediate_stations or []),
"no_tower_zones": _zones_to_json(alignment.no_tower_zones),
"forced_flyover_zones": _flyovers_to_json(
alignment.forced_flyover_zones
),
})
cur = conn.execute(
"""
INSERT INTO projects
(name, created_at, start_lon, start_lat, end_lon, end_lat,
corridor_len, utm_epsg, config_json, towers_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
corridor_len, utm_epsg, config_json, towers_json, extras_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
name,
datetime.now(timezone.utc).isoformat(timespec="seconds"),
start_lonlat[0], start_lonlat[1],
end_lonlat[0], end_lonlat[1],
corridor_len, utm_epsg,
config_json, towers_json,
config_json, towers_json, extras_json,
),
)
conn.commit()
Expand All @@ -138,13 +242,19 @@ def list_projects(db_path: str | Path = DEFAULT_DB_PATH) -> list[dict]:


def load_project(project_id: int, db_path: str | Path = DEFAULT_DB_PATH) -> ProjectRecord:
"""Load a project by id. Raises KeyError if not found."""
"""Load a project by id. Raises KeyError if not found.

Phase 4+: rehydrates Tower offset + is_station and the corridor-
constraint extras saved alongside the towers; pre-4+ records (no
extras column populated) deserialise to empty zone/station lists
so legacy callers see no behavioural change.
"""
conn = _connect(db_path)
try:
row = conn.execute(
"""
SELECT id, name, created_at, start_lon, start_lat, end_lon, end_lat,
corridor_len, utm_epsg, config_json, towers_json
corridor_len, utm_epsg, config_json, towers_json, extras_json
FROM projects WHERE id = ?
""",
(project_id,),
Expand All @@ -153,7 +263,15 @@ def load_project(project_id: int, db_path: str | Path = DEFAULT_DB_PATH) -> Proj
raise KeyError(f"project id {project_id} not found in {db_path}")
cfg_dict = json.loads(row[9])
config = ConstraintConfig(**cfg_dict)
towers = [Tower(t["distance"], t["height"]) for t in json.loads(row[10])]
towers = [
Tower(
t["distance"], t["height"],
is_station=bool(t.get("is_station", False)),
offset=float(t.get("offset", 0.0)),
)
for t in json.loads(row[10])
]
extras = json.loads(row[11] or "{}")
return ProjectRecord(
id=row[0],
name=row[1],
Expand All @@ -164,6 +282,11 @@ def load_project(project_id: int, db_path: str | Path = DEFAULT_DB_PATH) -> Proj
utm_epsg=row[8],
config=config,
towers=towers,
intermediate_stations=list(extras.get("intermediate_stations", [])),
no_tower_zones=_zones_from_json(extras.get("no_tower_zones", [])),
forced_flyover_zones=_flyovers_from_json(
extras.get("forced_flyover_zones", [])
),
)
finally:
conn.close()
Expand Down
Loading
Loading