feat: add simulation settings and location board redesign
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+137
-48
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user