chore: add reference docs, scripts, tests, demo, prototypes

- Reference docs: known issues, user journey, location redesign, mission merge plan
- Scripts: fill_location_state, merge_missions, migrate_location, migrate_state
- Tests: e2e test suite, asset entries unit tests
- Demo: location board, hifi prototypes, test screenshots
- Data backup: asset log entries and state nodes
- Update .gitignore

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 15:29:47 +08:00
co-authored by Claude Opus 4.7
parent f4909765af
commit e1d601a774
190 changed files with 11464 additions and 0 deletions
+202
View File
@@ -0,0 +1,202 @@
"""
Correctly fill location and state_label in state nodes based on semantic context.
"""
import os, psycopg, re
from datetime import datetime, timezone
from dotenv import load_dotenv
from pathlib import Path
load_dotenv(Path(__file__).resolve().parents[1] / ".env")
conn = psycopg.connect(
host=os.getenv("PGHOST"), port=os.getenv("PGPORT"),
dbname=os.getenv("PGDATABASE"), user=os.getenv("PGUSER"),
password=os.getenv("PGPASSWORD"))
cur = conn.cursor()
NOW = datetime.now(timezone.utc)
# ── Location inference ──
# Map body name → orbit location, surface location (if applicable)
BODY_LOC = {
"mercury": ("Mercury Orbit", "Mercury Surface"),
"venus": ("Venus Orbit", None), # no crewed surface ops
"earth": ("LEO", "Earth Surface"),
"moon": ("Lunar Orbit", "Lunar Surface"),
"lunar": ("Lunar Orbit", "Lunar Surface"),
"mars": ("Mars Orbit", "Mars Surface"),
"phobos": ("Phobos Orbit", None),
"deimos": ("Deimos Orbit", None),
"ceres": ("Ceres Orbit", "Ceres Surface"),
"vesta": ("Vesta Orbit", "Vesta Surface"),
"jupiter": ("Jupiter System", None),
"io": ("Io Orbit", "Io Surface"),
"europa": ("Europa Orbit", "Europa Surface"),
"ganymede": ("Ganymede Orbit", "Ganymede Surface"),
"callisto": ("Callisto Orbit", "Callisto Surface"),
"saturn": ("Saturn System", None),
"titan": ("Titan Orbit", "Titan Surface"),
"iapetus": ("Iapetus Orbit", "Iapetus Surface"),
"enceladus": ("Enceladus Orbit", None),
"dione": ("Dione Orbit", None),
"rhea": ("Rhea Orbit", None),
"tethys": ("Tethys Orbit", None),
"mimas": ("Mimas Orbit", None),
"uranus": ("Uranus System", None),
"miranda": ("Miranda Orbit", None),
"ariel": ("Ariel Orbit", None),
"umbriel": ("Umbriel Orbit", None),
"titania": ("Titania Orbit", None),
"oberon": ("Oberon Orbit", None),
"neptune": ("Neptune System", None),
"triton": ("Triton Orbit", "Triton Surface"),
"pluto": ("Pluto Orbit", "Pluto Surface"),
"charon": ("Charon Orbit", None),
"sun": ("Solar Orbit", None),
"solar": ("Solar Orbit", None),
"star port": ("LEO", None),
"star ring": ("LEO", None),
"leo": ("LEO", None),
"geo": ("GEO", None),
}
# Keywords that indicate surface ops vs orbital
SURFACE_WORDS = {"surface", "landing", "descend", "edl", "eva", "base", "outpost",
"crewed landing", "ground", "walk", "construction"}
ORBIT_WORDS = {"orbit", "orbital", "arrival", "departure", "soi", "flyby",
"survey", "parking", "dock", "rendezvous", "approach",
"braking burn", "course correction", "insertion"}
TRANSFER_WORDS = {"transfer", "transit", "outbound", "inbound", "return",
"departure", "en route", "heading", "burn",
"leave", "left", "sail"}
# State label inference
STATE_KEYWORDS = [
(["construction", "assembly", "assembl", "fitting", "install",
"module delivery", "first module", "construction complete",
"assembly halt", "assembly complete", "resume construction"], "Construction"),
(["trial run", "testing", "test mission", "commissioning", "shakedown",
"deep space test"], "Testing"),
(["maintenance", "upgrade", "refit", "repair", "servicing",
"parked to", "upgrade to"], "Maintenance"),
(["departure", "depart", "leave", "outbound", "earth departure",
"mars departure", "europa departure", "left", "en route to",
"heading to"], "start-transit"),
(["arrival", "arrive", "arrived", "return", "inbound",
"braking burn", "course correction", "soi",
"crew arrival", "crew return", "earth arrival",
"mars arrival", "arriving"], "transit-complete"),
(["exploration", "expedition", "surface operation", "surface expedition",
"descend", "ascend", "eva operation", "orbital survey",
"probe deployment", "atmosphere survey", "crewed landing",
"lander", "surface ops", "rover", "observation"], "Exploration"),
(["docked", "docking", "dock", "berthing", "mooring"], "Docked"),
(["autonomous standby", "standby"], "Autonomous Standby"),
(["crew handoff", "handoff", "handover"], "Crew Handoff"),
]
def find_body(text):
"""Find the primary celestial body mentioned in text (whole-word match only)."""
if not text:
return None
text_lower = text.lower()
# Sort by key length (longest first) to match "star port" before "star"
for body in sorted(BODY_LOC.keys(), key=len, reverse=True):
pattern = r'\b' + re.escape(body) + r'\b'
if re.search(pattern, text_lower):
return body
return None
def infer_location(node_title, node_detail, entry_title, mission_label, asset_name, asset_type, home_region):
"""Infer the correct location for a state node."""
node_text = f"{node_title or ''} {node_detail or ''}".lower()
full_text = f"{entry_title or ''} {mission_label or ''}".lower()
# Priority 1: body mentioned in node title/detail
body = find_body(node_text)
if body and body in BODY_LOC:
orbit_loc, surf_loc = BODY_LOC[body]
has_surface = any(w in node_text for w in SURFACE_WORDS)
has_orbit = any(w in node_text for w in ORBIT_WORDS)
if surf_loc and has_surface and not has_orbit:
return surf_loc
return orbit_loc
# Priority 2: body in entry title / mission label
body = find_body(full_text)
if body and body in BODY_LOC:
orbit_loc, surf_loc = BODY_LOC[body]
has_surface = any(w in node_text for w in SURFACE_WORDS)
has_orbit = any(w in node_text for w in ORBIT_WORDS)
if surf_loc and has_surface:
return surf_loc
return orbit_loc
# Priority 3: transfer keywords
all_text = f"{node_text} {full_text}"
if any(w in all_text for w in TRANSFER_WORDS):
if body:
return BODY_LOC.get(body, (None, None))[0] or "Transfer"
return "Transfer"
# Priority 4: home region or asset type default
if home_region:
hr = str(home_region).lower()
for b in BODY_LOC:
if b in hr:
return BODY_LOC[b][0]
return "LEO"
def infer_state(node_title, node_detail, entry_title, mission_label):
"""Infer the correct state_label for a state node."""
context = f"{node_title or ''} {node_detail or ''} {entry_title or ''} {mission_label or ''}".lower()
for keywords, label in STATE_KEYWORDS:
for kw in keywords:
if kw in context:
return label
return None
# ── Main ──
cur.execute("""
SELECT sn.id::text, sn.title, sn.detail, sn.at_time,
le.title as entry_title, le.mission_label,
a.name as asset_name, a.asset_type, a.home_region
FROM asset_state_nodes sn
JOIN asset_log_entries le ON sn.entry_id = le.id
JOIN assets a ON le.asset_id = a.id
ORDER BY a.name, sn.at_time
""")
rows = cur.fetchall()
updated = 0
for r in rows:
nid, ntitle, ndetail, atime, entry_title, mission, asset_name, asset_type, home_region = r
new_loc = infer_location(ntitle, ndetail, entry_title, mission, asset_name, asset_type, home_region)
new_st = infer_state(ntitle, ndetail, entry_title, mission)
# Only update if different from current
cur.execute("SELECT location, state_label FROM asset_state_nodes WHERE id = %s", (nid,))
cur_loc, cur_st = cur.fetchone()
if (new_loc and new_loc != cur_loc) or (new_st and new_st != cur_st):
cur.execute("""
UPDATE asset_state_nodes
SET location = COALESCE(%s, location),
state_label = COALESCE(%s, state_label),
updated_at = %s
WHERE id = %s
""", (new_loc, new_st, NOW, nid))
updated += 1
print(f" {asset_name[:20]:20s} | {str(ntitle)[:45]:45s} | {cur_loc or 'NULL':20s}{new_loc or 'NULL':20s} | {cur_st or 'NULL':15s}{new_st or 'NULL':15s}")
conn.commit()
cur.execute("""
SELECT COUNT(*),
COUNT(CASE WHEN location IS NOT NULL AND location!='' THEN 1 END),
COUNT(CASE WHEN state_label IS NOT NULL AND state_label!='' THEN 1 END)
FROM asset_state_nodes
""")
r = cur.fetchone()
print(f"\nFinal: {r[0]} total, {r[1]} with location, {r[2]} with state_label")
print(f"Updated: {updated} nodes")
conn.close()
+374
View File
@@ -0,0 +1,374 @@
"""
Merge Outbound/Operations/Inbound entries into single mission entries.
Stage 1: Add missing Inbound state nodes
Stage 2: Merge entries
BACKUP already created in data_backup/ before running this script.
"""
import os, psycopg, uuid
from datetime import datetime, timezone
from dotenv import load_dotenv
from pathlib import Path
load_dotenv(Path(__file__).resolve().parents[1] / ".env")
conn = psycopg.connect(
host=os.getenv("PGHOST"), port=os.getenv("PGPORT"),
dbname=os.getenv("PGDATABASE"), user=os.getenv("PGUSER"),
password=os.getenv("PGPASSWORD"))
cur = conn.cursor()
tz = timezone.utc
NOW = datetime.now(tz)
def ts(y,m,d,h=0,mi=0,s=0):
return datetime(y,m,d,h,mi,s,tzinfo=tz)
def add_node(entry_id, title, detail, at_time, seq=None):
"""Add a state node to an entry."""
cur.execute("""
INSERT INTO asset_state_nodes (id, entry_id, title, detail, at_time, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (str(uuid.uuid4()), entry_id, title, detail, at_time, NOW, NOW))
def move_nodes(from_entry_id, to_entry_id):
"""Move all state nodes from one entry to another."""
cur.execute("""
UPDATE asset_state_nodes SET entry_id = %s, updated_at = %s
WHERE entry_id = %s
""", (to_entry_id, NOW, from_entry_id))
return cur.rowcount
def merge_group(ops_id, outbound_ids, inbound_ids, new_start, new_end, new_title, new_state_label, new_location):
"""
Merge outbound + ops + inbound entries.
- Extend ops entry start/end
- Move all state nodes from outbound & inbound to ops
- Delete outbound & inbound entries
"""
# Extend ops
cur.execute("""
UPDATE asset_log_entries SET start_at = %s, end_at = %s,
title = %s, state_label = %s, location = %s, updated_at = %s
WHERE id = %s
""", (new_start, new_end, new_title, new_state_label, new_location, NOW, ops_id))
deleted = []
total_moved = 0
for eid in outbound_ids + inbound_ids:
n = move_nodes(eid, ops_id)
total_moved += n
cur.execute("DELETE FROM asset_log_entries WHERE id = %s", (eid,))
deleted.append(eid[:8])
print(f" Merged: ops={ops_id[:8]} start={new_start} end={new_end} moved={total_moved} nodes, deleted={deleted}")
return total_moved
print("=" * 60)
print("STAGE 1: Adding missing Inbound state nodes")
print("=" * 60)
# --- EE-1 (XH-01) Inbound: 92315017 ---
# User provided: 2059-07-10 Europa Departure, 2059-11-15 Earth Arrival, 2059-11-16 crew return
add_node('92315017-7011-4930-aabb-d00a6f3f4994',
'Europa Departure', 'Uses standard trajectory with 112km/s of departure burn',
ts(2059,7,10,8,0))
add_node('92315017-7011-4930-aabb-d00a6f3f4994',
'Earth Arrival', 'Parking to Star Port for the first time',
ts(2059,11,15,8,0))
add_node('92315017-7011-4930-aabb-d00a6f3f4994',
'Europa Expedition 1 crew return',
'Crew disembark at Star Port after 15-month Jupiter system expedition',
ts(2059,11,16,8,0))
print("EE-1 (XH-01): 3 state nodes added")
# --- EOCM-2 (ST-02) Inbound: efdb28dd ---
add_node('efdb28dd-3e02-4b16-80e8-7a2d4df76825',
'Europa Departure',
'Departure from Europa after completing outpost construction Phase 2',
ts(2058,9,6,8,0))
add_node('efdb28dd-3e02-4b16-80e8-7a2d4df76825',
'Earth Arrival',
'Parking to Star Port',
ts(2058,11,8,8,0))
add_node('efdb28dd-3e02-4b16-80e8-7a2d4df76825',
'Europa Outpost Construction Mission 2 crew return',
'Crew disembark at Star Port',
ts(2058,11,9,8,0))
print("EOCM-2 (ST-02): 3 state nodes added")
# --- AEM (XH-02) Inbound: d74dfb61 ---
add_node('d74dfb61-fd3a-492c-b019-253ec9cd826d',
'Vesta Departure',
'Departure from Vesta after completing asteroid survey operations',
ts(2058,1,4,8,0))
add_node('d74dfb61-fd3a-492c-b019-253ec9cd826d',
'Earth Arrival',
'Parking to Star Port',
ts(2058,4,17,8,0))
add_node('d74dfb61-fd3a-492c-b019-253ec9cd826d',
'Asteroid Exploration Mission crew return',
'Crew disembark at Star Port after Vesta exploration',
ts(2058,4,18,8,0))
print("AEM (XH-02): 3 state nodes added")
# --- ME-3 crewed Inbound (XH-02): 33a45210 ---
add_node('33a45210-3497-4826-ba6e-291b384ebe7e',
'Mars Departure',
'Departure from Mars with ME-3 crew returning to Earth',
ts(2059,11,4,8,0))
add_node('33a45210-3497-4826-ba6e-291b384ebe7e',
'Earth Arrival',
'Parking to Star Port',
ts(2060,1,6,8,0))
add_node('33a45210-3497-4826-ba6e-291b384ebe7e',
'Mars Expedition 3 crew return',
'Crew disembark at Star Port after extended Mars surface expedition',
ts(2060,1,7,8,0))
print("ME-3 crewed (XH-02): 3 state nodes added")
# --- ME-3 uncrewed Inbound (XH-02): f43aa249 ---
add_node('f43aa249-80d1-426c-9aca-a341411be98d',
'Mars Departure (uncrewed)',
'XH-02 returns to Earth uncrewed; ME-3 crew remains at Mars for ME-4 handoff',
ts(2059,5,4,8,0))
add_node('f43aa249-80d1-426c-9aca-a341411be98d',
'Earth Arrival (uncrewed)',
'Uncrewed parking to Star Port for maintenance rotation',
ts(2059,7,6,8,0))
print("ME-3 uncrewed (XH-02): 2 state nodes added")
# Commit stage 1
conn.commit()
print("\nStage 1 complete. Starting Stage 2...\n")
print("=" * 60)
print("STAGE 2: Merging mission entries")
print("=" * 60)
total_deleted = 0
total_moved = 0
# === XH-01 ===
print("\n--- XH-01 羲和号 ---")
# MMD
n = merge_group(
'23b4304f-8fca-4d78-8a93-c9f78fab51e0', # Ops
['e54817e0-6809-404d-be62-c6017d143362'], # Outbound
['ded06cd6-f9a4-4646-9be4-2f01cf067334'], # Inbound
ts(2054,7,10,8,0), ts(2055,2,28,8,0),
'Mars Mission Demo', 'Transfer; Planetary Operations; Transfer', 'Mars')
total_moved += n
# MOCM-1
n = merge_group(
'ec34d68b-4728-42eb-af79-b3e8805aa253',
['aab3125c-c99e-4af5-ab64-ffe7fda48ad7'],
['bdf99335-d329-4bd2-8e17-27ac1f097ca9'],
ts(2055,6,15,8,0), ts(2056,3,18,8,0),
'Mars One Base Construction Mission 1', 'Transfer; Base Construction; Transfer', 'Mars')
total_moved += n
# MOCM-3
n = merge_group(
'fd22c4b2-4938-4c01-ae92-e84f14d8b8dd',
['ea2024c5-95e6-4acc-a88d-998183a35a5a'],
['e5f549b9-0446-48be-a606-d9c1ada664b7'],
ts(2056,7,30,8,0), ts(2057,3,25,8,0),
'Mars One Base Construction Mission 3', 'Transfer; Base Construction; Transfer', 'Mars')
total_moved += n
# ME-2
n = merge_group(
'7f5da9b3-94fa-4bd5-a6bd-94f6841c71a2',
['30c84287-e5d1-473c-93fb-9d36d426516e'],
['058bbf6d-5c51-49c4-8516-8b6931db53c1'],
ts(2057,5,5,8,0), ts(2058,5,20,8,0),
'Mars Expedition 2', 'Transfer; Planetary Operations; Transfer', 'Mars')
total_moved += n
# EE-1
n = merge_group(
'f01bfb99-f981-45f6-808b-7c5222503715',
['4ff368c4-503b-42ce-9e82-b4da291d3203'],
['92315017-7011-4930-aabb-d00a6f3f4994'],
ts(2058,7,10,8,0), ts(2059,11,15,8,0),
'Europa Expedition 1', 'Transfer; Jupiter Exploration; Transfer', 'Jupiter System')
total_moved += n
# EE-3 (ongoing, no inbound)
cur.execute("""
UPDATE asset_log_entries SET start_at = %s, title = %s,
state_label = %s, updated_at = %s
WHERE id = %s
""", (ts(2060,2,20,8,0), 'Europa Expedition 3',
'Transfer; Jupiter Exploration', NOW,
'91d4948a-ae04-43a8-ac3b-def9caecc8e5'))
n = move_nodes('ee52cc53-7d9c-4134-ba1b-4f7cfb8fdce9', '91d4948a-ae04-43a8-ac3b-def9caecc8e5')
cur.execute("DELETE FROM asset_log_entries WHERE id = 'ee52cc53-7d9c-4134-ba1b-4f7cfb8fdce9'")
print(f" Merged (ongoing): ops=91d4948a start=2060-02-20 end=ongoing moved={n} nodes")
# === ST-01 ===
print("\n--- ST-01 万星源号 ---")
# ISSEM-1 (5 entries → 1)
# Keep Venus Ops as main (longest ops span), merge Mercury Transfer + Mercury Ops + Venus Transfer + Inbound
n1 = move_nodes('4bfddc84-dfec-43e3-b534-4fb798bcc73f', 'f28469ee-1dbc-4232-98ff-91f7abcb9d59') # Mercury Transfer → Venus Ops
n2 = move_nodes('42698c8c-a1cc-4742-9885-7b9e650f9867', 'f28469ee-1dbc-4232-98ff-91f7abcb9d59') # Mercury Ops → Venus Ops
n3 = move_nodes('2adfa016-b23a-4459-af6e-a84da2aa2469', 'f28469ee-1dbc-4232-98ff-91f7abcb9d59') # Venus Transfer → Venus Ops
n4 = move_nodes('1f6e85aa-121a-4f47-95b6-35f040da055c', 'f28469ee-1dbc-4232-98ff-91f7abcb9d59') # Inbound → Venus Ops
cur.execute("""
UPDATE asset_log_entries SET start_at = %s, end_at = %s, title = %s,
state_label = %s, location = %s, updated_at = %s
WHERE id = %s
""", (ts(2055,3,20,8,0), ts(2056,4,12,8,0),
'Inner Solar System Exploration Mission 1',
'Transfer; Planetary Operations; Transfer', 'Mercury; Venus',
NOW, 'f28469ee-1dbc-4232-98ff-91f7abcb9d59'))
for eid in ['4bfddc84-dfec-43e3-b534-4fb798bcc73f','42698c8c-a1cc-4742-9885-7b9e650f9867',
'2adfa016-b23a-4459-af6e-a84da2aa2469','1f6e85aa-121a-4f47-95b6-35f040da055c']:
cur.execute("DELETE FROM asset_log_entries WHERE id = %s", (eid,))
print(f" Merged ISSEM-1 (5→1): moved={n1+n2+n3+n4} nodes, deleted 4 entries")
# MOCM-2
n = merge_group(
'12059276-d10a-4a37-9b8c-6a6183768504',
['ffd625b0-0de4-4f47-8c8d-36669a1675f6'],
['cf351aa5-4149-4938-939b-edcb846f4b14'],
ts(2056,6,2,8,0), ts(2056,11,3,8,0),
'Mars One Construction Mission 2', 'Transfer; Base Construction; Transfer', 'Mars')
total_moved += n
# EOCM-1
n = merge_group(
'545f687e-bd2c-409a-8fb8-186a6dead67e',
['7747339c-f602-4ddf-8511-6862006f789b'],
['43f2ece9-283b-4484-b838-d684b9ada4d6'],
ts(2056,12,15,8,0), ts(2057,12,20,8,0),
'Europa Outpost Construction Mission 1', 'Transfer; Outpost Construction; Transfer', 'Europa')
total_moved += n
# SEM-1
n = merge_group(
'022b0296-5c1a-41f8-8634-97dc663b05d9',
['cad8537e-1966-4b3c-abed-35c511950ac8'],
['f536e950-a024-45e3-95ff-da5f77772119'],
ts(2058,6,1,8,0), ts(2059,11,24,8,0),
'Saturn Exploration Mission 1', 'Transfer; Planetary Operations; Transfer', 'Saturn System')
total_moved += n
# OSSEM-1 (ongoing)
cur.execute("""
UPDATE asset_log_entries SET start_at = %s, title = %s,
state_label = %s, updated_at = %s
WHERE id = %s
""", (ts(2060,2,11,8,0), 'Outer Solar System Exploration Mission 1',
'Transfer; Exploration', NOW,
'18b3429e-28d3-489c-b65c-35e0e9775a0d'))
n = move_nodes('8a9de074-6348-4190-bd27-e3bdeddb70f7', '18b3429e-28d3-489c-b65c-35e0e9775a0d')
cur.execute("DELETE FROM asset_log_entries WHERE id = '8a9de074-6348-4190-bd27-e3bdeddb70f7'")
print(f" Merged OSSEM-1 (ongoing): moved={n} nodes")
# === ST-02 ===
print("\n--- ST-02 克里斯滕号 ---")
# EOCM-2
n = merge_group(
'fb17cab1-dcbc-4814-83e9-5fb81a73f869',
['a61ee6fc-5495-4b5b-a505-968669b81b18'],
['efdb28dd-3e02-4b16-80e8-7a2d4df76825'],
ts(2057,10,6,8,0), ts(2058,11,8,8,0),
'Europa Outpost Construction Mission 2', 'Transfer; Outpost Construction; Transfer', 'Europa')
total_moved += n
# EE-2
n = merge_group(
'65910288-0c2a-4800-bfce-39495efafce2',
['fe21560f-60f4-48bb-bbdb-e2c1ab6fe3af'],
['e77cd6b5-a2e2-479b-817b-67c1c59bfdee'],
ts(2059,4,6,8,0), ts(2060,8,30,6,11),
'Europa Expedition 2', 'Transfer; Planetary Operations; Transfer', 'Europa')
total_moved += n
# === XH-02 ===
print("\n--- XH-02 太白号 ---")
# AEM
n = merge_group(
'99eb75ba-e02b-4cdc-a5f1-3ea547cc7d4a',
['7c166ff7-df3b-495d-a7df-9b99949e23f0'],
['d74dfb61-fd3a-492c-b019-253ec9cd826d'],
ts(2057,5,4,8,0), ts(2058,4,17,8,0),
'Asteroid Exploration Mission', 'Transfer; Asteroid Operations; Transfer', 'Vesta')
total_moved += n
# ME-3 (merge Ops + Uncrewed Inbound + Crewed Inbound)
n1 = move_nodes('f43aa249-80d1-426c-9aca-a341411be98d', '956cc319-668e-45d1-ab7c-9f0aa408c5f7')
n2 = move_nodes('33a45210-3497-4826-ba6e-291b384ebe7e', '956cc319-668e-45d1-ab7c-9f0aa408c5f7')
cur.execute("""
UPDATE asset_log_entries SET start_at = %s, end_at = %s, title = %s,
state_label = %s, location = %s, updated_at = %s
WHERE id = %s
""", (ts(2058,4,17,8,0), ts(2060,1,6,8,0),
'Mars Expedition 3', 'Transfer; Planetary Operations; Transfer', 'Mars',
NOW, '956cc319-668e-45d1-ab7c-9f0aa408c5f7'))
for eid in ['f43aa249-80d1-426c-9aca-a341411be98d','33a45210-3497-4826-ba6e-291b384ebe7e']:
cur.execute("DELETE FROM asset_log_entries WHERE id = %s", (eid,))
print(f" Merged ME-3 (3→1): moved={n1+n2} nodes, deleted 2 entries")
total_moved += n1 + n2
# ME-4 — SKIP (单独处理)
print(" ME-4: SKIPPED (单独处理)")
# === JW-01 ===
print("\n--- JW-01 金乌号 ---")
n = merge_group(
'30930d1b-d32b-4ef6-97ae-6d11c777ad53',
['b99062ea-4547-4cdc-8bf1-3b1f0c28a4be'],
['00c8b5c6-e363-4aee-a768-2fc3fa7a0138'],
ts(2059,8,6,8,0), ts(2060,10,26,10,3),
'Mars One Expansion', 'Transfer; Cargo Delivery; Transfer', 'Mars')
total_moved += n
# === Solar Shuttle 2 ===
print("\n--- Solar Shuttle 2 ---")
n = merge_group(
'c0271f19-e604-4c15-9c00-7118fec1928e',
['5a2092a8-ddad-41b3-9819-fa52557332ed'],
['687615b6-144f-4fd5-b3ee-709e4eaea6aa'],
ts(2059,12,9,8,0), ts(2060,6,26,8,0),
'Europa Outpost Expansion Mission', 'Transfer; Outpost Expansion; Transfer', 'Europa')
total_moved += n
# === Commit ===
conn.commit()
print(f"\n{'='*60}")
print(f"COMPLETE. Total state nodes moved: {total_moved}")
print(f"{'='*60}")
# Verify
cur.execute("""
SELECT a.name, COUNT(*) as entries, COUNT(le.mission_label) as with_mission
FROM asset_log_entries le JOIN assets a ON le.asset_id = a.id
WHERE a.name ILIKE '%XH-01%' OR a.name ILIKE '%XH-02%' OR a.name ILIKE '%ST-01%'
OR a.name ILIKE '%ST-02%' OR a.name ILIKE '%JW-01%' OR a.name ILIKE '%Solar Shuttle 2%'
GROUP BY a.name ORDER BY a.name
""")
print("\nPost-merge entry counts:")
for r in cur.fetchall():
print(f" {r[0]:20s}: {r[1]} entries")
# Count state nodes per asset
cur.execute("""
SELECT a.name, COUNT(sn.id) as nodes
FROM asset_state_nodes sn
JOIN asset_log_entries le ON sn.entry_id = le.id
JOIN assets a ON le.asset_id = a.id
WHERE a.name ILIKE '%XH-01%' OR a.name ILIKE '%XH-02%' OR a.name ILIKE '%ST-01%'
OR a.name ILIKE '%ST-02%' OR a.name ILIKE '%JW-01%' OR a.name ILIKE '%Solar Shuttle 2%'
GROUP BY a.name ORDER BY a.name
""")
print("\nPost-merge state node counts (should match pre-merge total):")
for r in cur.fetchall():
print(f" {r[0]:20s}: {r[1]} nodes")
conn.close()
print("\nDone.")
@@ -0,0 +1,89 @@
"""
Migrate location and state_label from asset_log_entries to asset_state_nodes.
1. Add columns to asset_state_nodes
2. For each interval: migrate location/state_label to first state node (or create one)
3. Drop columns from asset_log_entries
"""
import os, psycopg, uuid
from datetime import datetime, timezone
from dotenv import load_dotenv
from pathlib import Path
load_dotenv(Path(__file__).resolve().parents[1] / ".env")
conn = psycopg.connect(
host=os.getenv("PGHOST"), port=os.getenv("PGPORT"),
dbname=os.getenv("PGDATABASE"), user=os.getenv("PGUSER"),
password=os.getenv("PGPASSWORD"))
cur = conn.cursor()
tz = timezone.utc
NOW = datetime.now(tz)
# Step 1: Add columns
print("Step 1: Adding location and state_label to asset_state_nodes...")
try:
cur.execute("ALTER TABLE asset_state_nodes ADD COLUMN location VARCHAR(255)")
print(" Added location column")
except Exception as e:
if 'already exists' in str(e):
print(" location column already exists")
else:
raise
try:
cur.execute("ALTER TABLE asset_state_nodes ADD COLUMN state_label VARCHAR(120)")
print(" Added state_label column")
except Exception as e:
if 'already exists' in str(e):
print(" state_label column already exists")
else:
raise
# Step 2: Migrate data
print("\nStep 2: Migrating location and state_label to state nodes...")
cur.execute("""
SELECT le.id, le.location, le.state_label, le.title, le.start_at,
(SELECT COUNT(*) FROM asset_state_nodes sn WHERE sn.entry_id = le.id) as nc
FROM asset_log_entries le
WHERE le.location IS NOT NULL OR le.state_label IS NOT NULL
""")
rows = cur.fetchall()
print(f" Found {len(rows)} entries with location/state_label")
created = 0
updated = 0
for row in rows:
entry_id, loc, st_lbl, title, start_at, nc = row
if nc > 0:
# Update the first (earliest) state node of this interval
cur.execute("""
UPDATE asset_state_nodes
SET location = COALESCE(%s, location),
state_label = COALESCE(%s, state_label),
updated_at = %s
WHERE id = (
SELECT id FROM asset_state_nodes
WHERE entry_id = %s
ORDER BY at_time ASC LIMIT 1
)
""", (loc, st_lbl, NOW, entry_id))
updated += 1
else:
# No state nodes — create one at the interval's start_at
cur.execute("""
INSERT INTO asset_state_nodes (id, entry_id, title, detail, at_time, location, state_label, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (str(uuid.uuid4()), entry_id, title or '(Start)',
f'Auto-created during location migration. Original interval: {title}',
start_at, loc, st_lbl, NOW, NOW))
created += 1
print(f" Updated {updated} existing state nodes, created {created} new ones")
# Step 3: Drop columns
print("\nStep 3: Dropping location and state_label from asset_log_entries...")
cur.execute("ALTER TABLE asset_log_entries DROP COLUMN location")
print(" Dropped location")
cur.execute("ALTER TABLE asset_log_entries DROP COLUMN state_label")
print(" Dropped state_label")
conn.commit()
print("\nMigration complete.")
conn.close()
+164
View File
@@ -0,0 +1,164 @@
"""
Migrate state labels: Transfer/In Transit -> start-transit / transit-complete.
Set previous_location and transit_location on start-transit nodes.
"""
import os, psycopg, re
from datetime import datetime, timezone
from dotenv import load_dotenv
from pathlib import Path
load_dotenv(Path(__file__).resolve().parents[1] / ".env")
conn = psycopg.connect(
host=os.getenv("PGHOST"), port=os.getenv("PGPORT"),
dbname=os.getenv("PGDATABASE"), user=os.getenv("PGUSER"),
password=os.getenv("PGPASSWORD"))
cur = conn.cursor()
NOW = datetime.now(timezone.utc)
# 1. Fetch all state nodes with their parent entry info, ordered by time
cur.execute("""
SELECT sn.id::text, sn.title, sn.detail, sn.at_time, sn.location, sn.state_label,
le.title as entry_title, le.mission_label, le.start_at, le.end_at,
a.name as asset_name
FROM asset_state_nodes sn
JOIN asset_log_entries le ON sn.entry_id = le.id
JOIN assets a ON le.asset_id = a.id
ORDER BY a.name, le.start_at, sn.at_time
""")
rows = cur.fetchall()
print(f"Processing {len(rows)} state nodes")
# Group state nodes by entry
from collections import defaultdict
entries = defaultdict(list)
for r in rows:
eid = (r[6], r[7], r[8]) # entry_title, mission_label, start_at
entries[eid].append(r)
updated = 0
for ekey, nodes in entries.items():
entry_title, mission_label, entry_start = ekey
# Find pairs: departure -> arrival within this entry
# A departure has keywords: depart, departure, leave, outbound
# An arrival has keywords: arriv, arrival, inbound, return
for i, node in enumerate(nodes):
nid, ntitle, ndetail, ntime, nloc, nst, *_ = node
title_lower = (ntitle or "").lower()
detail_lower = (ndetail or "").lower()
ctx = f"{title_lower} {detail_lower}"
new_st = None
new_prev_loc = None
new_transit_loc = None
is_departure = any(w in ctx for w in
["departure", "depart", "leave", "outbound", "departed"])
is_arrival = any(w in ctx for w in
["arrival", "arriv", "arrive", "inbound", "return", "soi"])
if is_departure and is_arrival:
# Ambiguous - use position context. If it mentions Earth or a body + departure, it's departure
if "departure" in ctx or "depart" in ctx or "leave" in ctx:
is_arrival = False
else:
is_departure = False
if is_departure:
new_st = "start-transit"
new_transit_loc = "Solar Orbit"
elif is_arrival:
new_st = "transit-complete"
# Also check existing state_label
if nst and nst.lower() in ("transfer", "in transit"):
if not new_st:
# Fallback: try to infer from title
if any(w in ctx for w in ["departure", "depart", "leave"]):
new_st = "start-transit"
new_transit_loc = "Solar Orbit"
elif any(w in ctx for w in ["arrival", "arriv", "arrive"]):
new_st = "transit-complete"
else:
# Keep as Transfer if we can't determine
new_st = "start-transit"
new_transit_loc = "Solar Orbit"
if new_st:
# Determine previous_location for start-transit
if new_st == "start-transit":
# Get previous location from:
# a) Entry-level context (where is the asset before this mission?)
# b) Explicit departure body
body_match = re.search(
r'\b(earth|mars|europa|jupiter|saturn|venus|mercury|neptune|uranus|vesta|ceres|titan|io|ganymede|callisto|triton|pluto|leo|geo)\b',
ctx
)
if body_match:
body = body_match.group(1)
body_map = {
"earth": "LEO", "leo": "LEO", "geo": "GEO",
"mars": "Mars Orbit", "venus": "Venus Orbit",
"mercury": "Mercury Orbit", "jupiter": "Jupiter System",
"europa": "Europa Orbit", "io": "Io Orbit",
"ganymede": "Ganymede Orbit", "callisto": "Callisto Orbit",
"saturn": "Saturn System", "titan": "Titan Orbit",
"uranus": "Uranus System", "neptune": "Neptune System",
"triton": "Triton Orbit", "vesta": "Vesta Orbit",
"ceres": "Ceres Orbit", "pluto": "Pluto Orbit",
}
new_prev_loc = body_map.get(body, "LEO")
else:
new_prev_loc = nloc or "LEO"
# transit_location
if not new_transit_loc:
new_transit_loc = "Solar Orbit"
# For transit-complete, the location IS the arrival location
if new_st == "transit-complete" and not nloc:
# Try to infer arrival location from title
body_match = re.search(
r'\b(earth|mars|europa|jupiter|saturn|venus|mercury|neptune|uranus|vesta|titan|io|ganymede|callisto|triton)\b',
ctx
)
if body_match:
body = body_match.group(1)
body_map = {
"earth": "LEO", "mars": "Mars Orbit", "venus": "Venus Orbit",
"mercury": "Mercury Orbit", "jupiter": "Jupiter System",
"europa": "Europa Orbit", "io": "Io Orbit",
"ganymede": "Ganymede Orbit", "callisto": "Callisto Orbit",
"saturn": "Saturn System", "titan": "Titan Orbit",
"uranus": "Uranus System", "neptune": "Neptune System",
"triton": "Triton Orbit", "vesta": "Vesta Orbit",
}
nloc = body_map.get(body, nloc)
cur.execute("""
UPDATE asset_state_nodes SET
state_label = %s,
previous_location = COALESCE(%s, previous_location),
transit_location = COALESCE(%s, transit_location),
location = COALESCE(%s, location),
updated_at = %s
WHERE id = %s
""", (new_st, new_prev_loc, new_transit_loc, nloc, NOW, nid))
updated += 1
conn.commit()
print(f"Updated {updated} state nodes with new state labels")
# Stats
cur.execute("SELECT state_label, COUNT(*) FROM asset_state_nodes GROUP BY state_label ORDER BY COUNT(*) DESC")
print("\nState label distribution:")
for r in cur.fetchall():
print(f" {r[0] or 'NULL':25s}: {r[1]}")
cur.execute("SELECT COUNT(*), COUNT(CASE WHEN previous_location IS NOT NULL THEN 1 END), COUNT(CASE WHEN transit_location IS NOT NULL THEN 1 END) FROM asset_state_nodes")
r = cur.fetchone()
print(f"\nTotal: {r[0]}, with prev_loc: {r[1]}, with transit_loc: {r[2]}")
conn.close()