feat: add simulation settings and location board redesign

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 15:02:34 +08:00
co-authored by Claude
parent 65e77cc227
commit cf1d26dba9
13 changed files with 8289 additions and 141 deletions
+137 -48
View File
@@ -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,