diff --git a/app/models.py b/app/models.py index f1d5721..82c4fba 100644 --- a/app/models.py +++ b/app/models.py @@ -3,7 +3,7 @@ from __future__ import annotations import uuid from datetime import datetime, timezone -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Numeric, String, Text, Uuid +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, Uuid from sqlalchemy.orm import Mapped, mapped_column, relationship from app.extensions import db @@ -22,6 +22,13 @@ class TimestampMixin: ) +class SimulationSetting(TimestampMixin, db.Model): + __tablename__ = "simulation_settings" + + key: Mapped[str] = mapped_column(String(255), primary_key=True) + value: Mapped[str] = mapped_column(Text, nullable=False) + + class EngineFamily(TimestampMixin, db.Model): __tablename__ = "engine_families" @@ -130,9 +137,10 @@ class VehicleCost(TimestampMixin, db.Model): class Asset(TimestampMixin, db.Model): __tablename__ = "assets" + __table_args__ = (Index("ix_assets_name", "name"),) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) - name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) asset_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True) is_retired: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) program: Mapped[str | None] = mapped_column(String(255)) diff --git a/app/routes/web.py b/app/routes/web.py index 3591460..15ca2de 100644 --- a/app/routes/web.py +++ b/app/routes/web.py @@ -10,13 +10,13 @@ import re from typing import Any import uuid -from flask import Blueprint, abort, flash, has_request_context, redirect, render_template, request, session, url_for -from sqlalchemy import select, text, distinct -from sqlalchemy.exc import IntegrityError +from flask import Blueprint, abort, current_app, flash, has_request_context, redirect, render_template, request, session, url_for +from sqlalchemy import select, distinct +from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.orm import selectinload from app.extensions import db -from app.models import Asset, AssetLogEntry, AssetStateNode, CommunicationPart, DockingEvent, EngineFamily, EngineVariant, TankSpec, VehicleCost +from app.models import Asset, AssetLogEntry, AssetStateNode, CommunicationPart, DockingEvent, EngineFamily, EngineVariant, SimulationSetting, TankSpec, VehicleCost from app.services.fuel_conversion import list_fuel_factors from app.services.log_book_importer import import_log_book_data @@ -669,30 +669,29 @@ HARDCODED_SIMULATION_TIME = datetime(2060, 3, 12, 9, 0, tzinfo=timezone.utc) def _db_read_simulation_time() -> datetime | None: try: - row = db.session.execute( - text("SELECT value FROM simulation_settings WHERE key = 'current_time'") - ).fetchone() - if row is None: + setting = db.session.get(SimulationSetting, "current_time") + if setting is None: return None - return _parse_datetime(row[0], "DB Simulation Time") - except Exception: + return _parse_datetime(setting.value, "DB Simulation Time") + except (SQLAlchemyError, ValueError) as exc: + db.session.rollback() + current_app.logger.warning("Unable to read simulation time: %s", exc) return None def _db_write_simulation_time(value: datetime) -> None: formatted = _format_datetime_input(value) try: - db.session.execute( - text( - "INSERT INTO simulation_settings (key, value, updated_at) " - "VALUES ('current_time', :val, NOW()) " - "ON CONFLICT (key) DO UPDATE SET value = :val2, updated_at = NOW()" - ), - {"val": formatted, "val2": formatted}, - ) + setting = db.session.get(SimulationSetting, "current_time") + if setting is None: + setting = SimulationSetting(key="current_time", value=formatted) + db.session.add(setting) + else: + setting.value = formatted db.session.commit() - except Exception: + except SQLAlchemyError: db.session.rollback() + raise def _default_simulation_time() -> datetime: @@ -3113,6 +3112,7 @@ def dashboard() -> str: "location": snapshot.get("current_location", "—"), "mission": snapshot.get("current_mission", "—"), "state": snapshot.get("current_state", "—"), + "future_events": snapshot.get("future_events", []), }) # Metrics @@ -3139,22 +3139,22 @@ def dashboard() -> str: # Upcoming events (30 days) now = simulation_time + upcoming_cutoff = now + timedelta(days=30) upcoming_events = [] for a in active: - snapshot = _build_asset_snapshot(a if hasattr(a, "log_entries") else assets[0], simulation_time) - for ev in snapshot.get("future_events", []): - ev_date = ev.get("date", "") - if ev_date and ev_date >= now.strftime("%Y-%m-%d"): + for ev in a["future_events"]: + event_at = _as_utc(ev.get("sort_at")) + if event_at is not None and now < event_at <= upcoming_cutoff: upcoming_events.append({ - "date": ev_date, + "date": event_at.strftime("%Y-%m-%d"), "title": f"{a['name']}: {ev.get('title', '')}", "within_30d": True, }) upcoming_events.sort(key=lambda e: e["date"]) + upcoming_count = len(upcoming_events) upcoming_events = upcoming_events[:5] active_missions = len([a for a in active if a["state"] in ("Transfer", "Exploration")]) - upcoming_count = len(upcoming_events) upcoming_summary = f"{upcoming_count} events" if upcoming_count else "None imminent" return render_template( @@ -4677,43 +4677,132 @@ def mission_status_board_preview() -> str: def mission_location_board_preview() -> str: simulation_time = _resolve_simulation_time(request.args.get("sim_time")) simulation_time_input = _format_datetime_input(simulation_time) - - # Load all assets with logs assets = _load_assets_with_logs() - # Build asset position data for Canvas - # Map location strings to approximate orbital bodies and distances - LOCATION_BODY_MAP = { - "LEO": "earth", "GEO": "earth", "MEO": "earth", "Earth System": "earth", "Earth Surface": "earth", - "Lunar Orbit": "moon", "Lunar Surface": "moon", - "Mars Orbit": "mars", "Mars Surface": "mars", "Phobos Orbit": "mars", "Deimos Orbit": "mars", - "Jupiter Orbit": "jupiter", "Europa Orbit": "jupiter", "Europa Surface": "jupiter", - "Ganymede Orbit": "jupiter", "Callisto Orbit": "jupiter", - "Saturn Orbit": "saturn", "Titan Orbit": "saturn", "Titan Surface": "saturn", - "Uranus Orbit": "uranus", "Neptune Orbit": "neptune", "Triton Orbit": "neptune", - "Mercury Orbit": "mercury", "Mercury Surface": "mercury", - "Venus Orbit": "venus", "Venus Surface": "venus", - "Solar Orbit": "sun", "Transfer": "transfer", - "Ceres Orbit": "ceres", "Ceres Surface": "ceres", - "Vesta Orbit": "vesta", "Vesta Surface": "vesta", - "Pluto Orbit": "pluto", "Pluto Surface": "pluto", + body_aliases = ( + ("mercury", ("mercury",)), + ("venus", ("venus",)), + ("moon", ("lunar", "moon")), + ("earth", ("earth", "leo", "meo", "geo")), + ("phobos", ("phobos",)), + ("deimos", ("deimos",)), + ("mars", ("mars",)), + ("ceres", ("ceres",)), + ("vesta", ("vesta",)), + ("io", ("io orbit", "io surface", "io system")), + ("europa", ("europa",)), + ("ganymede", ("ganymede",)), + ("callisto", ("callisto",)), + ("jupiter", ("jupiter",)), + ("mimas", ("mimas",)), + ("enceladus", ("enceladus",)), + ("tethys", ("tethys",)), + ("dione", ("dione",)), + ("rhea", ("rhea",)), + ("titan", ("titan",)), + ("iapetus", ("iapetus",)), + ("saturn", ("saturn",)), + ("miranda", ("miranda",)), + ("ariel", ("ariel",)), + ("umbriel", ("umbriel",)), + ("titania", ("titania",)), + ("oberon", ("oberon",)), + ("uranus", ("uranus",)), + ("triton", ("triton",)), + ("neptune", ("neptune",)), + ("charon", ("charon",)), + ("pluto", ("pluto",)), + ("sun", ("solar", "heliocentric", "sun")), + ) + moon_systems = { + "moon": "earth", "phobos": "mars", "deimos": "mars", + "io": "jupiter", "europa": "jupiter", "ganymede": "jupiter", "callisto": "jupiter", + "mimas": "saturn", "enceladus": "saturn", "tethys": "saturn", "dione": "saturn", + "rhea": "saturn", "titan": "saturn", "iapetus": "saturn", + "miranda": "uranus", "ariel": "uranus", "umbriel": "uranus", "titania": "uranus", "oberon": "uranus", + "triton": "neptune", "charon": "pluto", } + planetary_systems = { + "mercury", "venus", "earth", "mars", "ceres", "vesta", "jupiter", + "saturn", "uranus", "neptune", "pluto", + } + + def location_body(value: object) -> str: + normalized = str(value or "").strip().casefold() + for body_key, aliases in body_aliases: + if any(alias in normalized for alias in aliases): + return body_key + return "unknown" + + def body_system(body_key: str) -> str: + if body_key in moon_systems: + return moon_systems[body_key] + if body_key in planetary_systems or body_key == "sun": + return body_key + return "unknown" + + def active_transfer(snapshot: dict[str, object]) -> dict[str, object] | None: + active_state = snapshot.get("active_state") + if active_state is None or not active_state.state_nodes: + return None + nodes = sorted(active_state.state_nodes, key=lambda item: _as_utc(item.at_time) or simulation_time) + current_index = -1 + for index, node in enumerate(nodes): + node_at = _as_utc(node.at_time) + if node_at is not None and node_at <= simulation_time: + current_index = index + if current_index < 0: + return None + node = nodes[current_index] + if not node.transit_location or not node.previous_location or not node.target_location: + return None + from_body = location_body(node.previous_location) + to_body = location_body(node.target_location) + if "unknown" in (from_body, to_body) or from_body == to_body: + return None + start_at = _as_utc(node.at_time) or simulation_time + end_at = None + if current_index + 1 < len(nodes): + end_at = _as_utc(nodes[current_index + 1].at_time) + if end_at is None: + end_at = _as_utc(active_state.end_at) + progress = 50 + if end_at is not None and end_at > start_at: + progress = round(100 * (simulation_time - start_at).total_seconds() / (end_at - start_at).total_seconds()) + progress = max(2, min(98, progress)) + from_system = body_system(from_body) + to_system = body_system(to_body) + scope = from_system if from_system == to_system and from_system not in {"unknown", "sun"} else "sun" + return { + "from": from_body, + "to": to_body, + "progress": progress, + "scope": scope, + "body": f"{from_body}-{to_body}-transfer", + "location": str(node.transit_location), + } asset_positions = [] for asset in assets: if asset.is_retired: continue snapshot = _build_asset_snapshot(asset, simulation_time) - location = snapshot.get("current_location", "Transfer") - body = LOCATION_BODY_MAP.get(location, "transfer") + location = str(snapshot.get("current_location") or "-") + transfer = active_transfer(snapshot) + body = str(transfer["body"]) if transfer else location_body(location) + scope = str(transfer["scope"]) if transfer else body_system(body) asset_positions.append({ "id": str(asset.id), "name": asset.name, "asset_type": asset.asset_type, - "location": location, + "location": str(transfer["location"]) if transfer else location, "body": body, + "scope": scope, "mission": snapshot.get("current_mission", ""), "state": snapshot.get("current_state", ""), + "next_event": snapshot.get("next_event", ""), + "detail_url": url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input), + "transfer": transfer, }) # Count ongoing missions and upcoming events @@ -4726,7 +4815,7 @@ def mission_location_board_preview() -> str: active_page="location", simulation_time_display=_format_datetime_display(simulation_time), simulation_time_input=simulation_time_input, - asset_positions_json=__import__("json").dumps(asset_positions), + asset_positions=asset_positions, asset_count=len(asset_positions), ongoing_count=len(active_assets), upcoming_events=[], @@ -4885,7 +4974,7 @@ def mission_timeline_preview() -> str: preview_tab="timeline", timeline_scale=timeline_scale, timeline_rows=timeline_rows, - timeline_json=timeline_json, + timeline_data=timeline_json_data, timeline_scale_mode=timeline_scale_mode, timeline_scale_options=TIMELINE_SCALE_OPTIONS, timeline_column_width=timeline_column_width, diff --git a/app/services/log_book_importer.py b/app/services/log_book_importer.py index 68fcb00..c934b57 100644 --- a/app/services/log_book_importer.py +++ b/app/services/log_book_importer.py @@ -9,7 +9,7 @@ from openpyxl import load_workbook from sqlalchemy import delete, select from app.extensions import db -from app.models import Asset, AssetLogEntry +from app.models import Asset, AssetLogEntry, AssetStateNode EVENT_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2})\s+(.+)$") @@ -99,6 +99,8 @@ def import_log_book_data(workbook_path: str | Path) -> LogBookImportSummary: except Exception: db.session.rollback() raise + finally: + workbook.close() return summary @@ -156,22 +158,39 @@ def _import_sheet(worksheet: object, sheet_name: str, asset_spec: dict[str, str] if log_name is None or start_at is None: continue - db.session.add( - AssetLogEntry( - asset_id=asset.id, - entry_kind="state", + entry = AssetLogEntry( + asset_id=asset.id, + entry_kind="state", + title=log_name, + mission_label=log_name, + start_at=start_at, + end_at=end_at, + summary=mission_detail, + note=sub_log, + ) + entry.state_nodes.append( + AssetStateNode( title=log_name, + detail=mission_detail, + at_time=start_at, + target_location=inferred_location, state_label=log_name, - mission_label=log_name, - location=inferred_location, - start_at=start_at, - end_at=end_at, - summary=mission_detail, - note=sub_log, ) ) + db.session.add(entry) summary.state_entries += 1 + events = _parse_sub_log_events( + asset.id, + log_name, + start_at, + end_at, + inferred_location, + sub_log, + ) + db.session.add_all(events) + summary.event_entries += len(events) + return summary @@ -260,19 +279,23 @@ def _parse_sub_log_events( continue description = match.group(2).strip() - events.append( - AssetLogEntry( - asset_id=asset_id, - entry_kind="event", + event = AssetLogEntry( + asset_id=asset_id, + entry_kind="event", + title=description, + mission_label=parent_title, + start_at=event_at, + end_at=None, + summary=parent_title, + note=line, + ) + event.state_nodes.append( + AssetStateNode( title=description, - state_label=None, - mission_label=parent_title, - location=_infer_event_location(description, default_location), - start_at=event_at, - end_at=None, - summary=parent_title, - note=line, + at_time=event_at, + target_location=_infer_event_location(description, default_location), ) ) + events.append(event) - return events \ No newline at end of file + return events diff --git a/app/services/workbook_importer.py b/app/services/workbook_importer.py index e8f4d9c..5f5ced4 100644 --- a/app/services/workbook_importer.py +++ b/app/services/workbook_importer.py @@ -54,6 +54,8 @@ def import_workbook_data( except Exception: db.session.rollback() raise + finally: + workbook.close() summary.imported_rows = { "engine_families": family_count, diff --git a/app/templates/mission_location_board_preview.html b/app/templates/mission_location_board_preview.html index af5e54c..582aa45 100644 --- a/app/templates/mission_location_board_preview.html +++ b/app/templates/mission_location_board_preview.html @@ -1,79 +1,286 @@ {% extends "base.html" %} {% block content %} -
+