diff --git a/deploy/nas-topology-manual/.env.example b/deploy/nas-topology-manual/.env.example new file mode 100644 index 0000000..08b82aa --- /dev/null +++ b/deploy/nas-topology-manual/.env.example @@ -0,0 +1,8 @@ +PGHOST=database-host +PGPORT=5432 +PGUSER=topology_reader_writer +PGPASSWORD=replace-me +TOPO_DATABASE=ksp_wiki_topology + +# NAS host port mapped to container port 8080. +TOPOLOGY_API_PORT=38080 diff --git a/deploy/nas-topology-manual/DEPLOY.md b/deploy/nas-topology-manual/DEPLOY.md new file mode 100644 index 0000000..52e06f9 --- /dev/null +++ b/deploy/nas-topology-manual/DEPLOY.md @@ -0,0 +1,80 @@ +# 广寒拓扑 NAS 容器手动部署 + +此部署包只用于更新 NAS 上现有的 `topology-api` 容器,不修改公网服务器的 Nginx 路由。 + +## 部署前 + +1. 备份 NAS 上当前拓扑项目目录和 `.env`。 +2. 确认当前容器对外端口仍为 `38080`,容器内端口为 `8080`。 +3. 将本压缩包解压到一个新目录。 +4. 把现有生产 `.env` 复制到解压后的目录。部署包不包含任何凭据。 + +## 构建新镜像 + +在解压目录执行: + +```bash +docker build --no-cache -t topology-api:20260721-1145 . +``` + +本次数据库布局数据已经写入现有拓扑数据库,正常情况下不需要再次运行 seed 脚本。 + +## 替换容器 + +以下命令假定: + +- 当前容器名称为 `topology-api-2`; +- NAS 对外端口为 `38080`; +- 生产 `.env` 已复制到当前解压目录。 + +先停止并保留旧容器: + +```bash +docker stop topology-api-2 +docker rename topology-api-2 topology-api-2-backup-20260721 +``` + +再启动新容器: + +```bash +docker run -d \ + --name topology-api-2 \ + --restart unless-stopped \ + --env-file .env \ + -p 38080:8080 \ + topology-api:20260721-1145 +``` + +## 验证 + +```bash +curl -fsS http://127.0.0.1:38080/health +curl -fsS 'http://127.0.0.1:38080/api/v1/bases/guanghan/snapshot?mission=GHC-05' +curl -fsS 'http://127.0.0.1:38080/api/v1/bases/guanghan/snapshot?mission=GHC-06' +``` + +预期结果: + +- GHC-05:`engineering_overview_v1`,画布宽度 `1900`。 +- GHC-06:`engineering_overview_ghc06_v1`,画布宽度 `2900`。 +- GHC-05 的吴刚三号位于居住舱二号北部。 +- GHC-06 的吴刚二号位于实验舱东部。 + +公网验证地址: + +```text +http://118.89.192.134:59100/topology/guanghan_topology_demo.html?mission=GHC-06 +``` + +## 回滚 + +如果健康检查或页面验证失败,停止并移除新容器,然后恢复旧容器: + +```bash +docker stop topology-api-2 +docker rm topology-api-2 +docker rename topology-api-2-backup-20260721 topology-api-2 +docker start topology-api-2 +``` + +确认新版本稳定运行后,旧容器可以继续保留一段时间。不要在首次验证前删除旧容器或旧镜像。 diff --git a/docker_image/docker-compose.topology-api.yml b/docker_image/docker-compose.topology-api.yml new file mode 100644 index 0000000..23fe05b --- /dev/null +++ b/docker_image/docker-compose.topology-api.yml @@ -0,0 +1,21 @@ +services: + topology-api: + image: topology-api:20260721-ghc06-layout + container_name: topology-api-2 + restart: unless-stopped + env_file: + - .env + ports: + - "38080:8080" + healthcheck: + test: + - CMD + - python + - -c + - >- + import urllib.request; + urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3) + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/docker_image/topology-api-20260721-ghc06-layout-DEPLOY.md b/docker_image/topology-api-20260721-ghc06-layout-DEPLOY.md new file mode 100644 index 0000000..68714b3 --- /dev/null +++ b/docker_image/topology-api-20260721-ghc06-layout-DEPLOY.md @@ -0,0 +1,46 @@ +# topology-api NAS 部署 + +镜像文件:`topology-api-20260721-ghc06-layout.tar` + +## 1. 导入镜像 + +```bash +docker load -i topology-api-20260721-ghc06-layout.tar +``` + +预期镜像标签:`topology-api:20260721-ghc06-layout` + +## 2. 保留旧容器并启动新容器 + +在包含现有生产 `.env` 的目录执行: + +```bash +docker stop topology-api-2 +docker rename topology-api-2 topology-api-2-backup-20260721 + +docker run -d \ + --name topology-api-2 \ + --restart unless-stopped \ + --env-file .env \ + -p 38080:8080 \ + topology-api:20260721-ghc06-layout +``` + +## 3. 验证 + +```bash +curl -fsS http://127.0.0.1:38080/health +docker ps --filter name=topology-api-2 +docker logs --tail 50 topology-api-2 +``` + +## 4. 回滚 + +```bash +docker stop topology-api-2 +docker rm topology-api-2 +docker rename topology-api-2-backup-20260721 topology-api-2 +docker start topology-api-2 +``` + +确认新版本稳定前,不要删除备份容器或旧镜像。不要重新运行 seed 脚本。 diff --git a/scripts/nas_interactive_login.cmd b/scripts/nas_interactive_login.cmd new file mode 100644 index 0000000..36b7dc6 --- /dev/null +++ b/scripts/nas_interactive_login.cmd @@ -0,0 +1,8 @@ +@echo off +title NAS Login for Codex +echo Connecting to UGNAS. Complete host verification and password entry here. +echo. +ssh -M -S C:\Users\Armor\.ssh\codex-ugnas-20260721.sock -o ControlPersist=900 UGNAS +echo. +echo SSH session ended. You may close this window. +pause diff --git a/topology_api/queries/snapshot_queries.py b/topology_api/queries/snapshot_queries.py index f8c2207..e3b3af2 100644 --- a/topology_api/queries/snapshot_queries.py +++ b/topology_api/queries/snapshot_queries.py @@ -245,8 +245,8 @@ def fetch_snapshot(snapshot_at): return result -def fetch_layout(): - """Fetch the default diagram layout with node positions.""" +def fetch_layout(layout_key=None): + """Fetch a named diagram layout, falling back to the default layout.""" with read_cursor() as cur: cur.execute(""" SELECT @@ -284,9 +284,12 @@ def fetch_layout(): ON default_theme.base_id = dl.base_id AND default_theme.is_default = true WHERE dl.base_id = (SELECT id FROM bases WHERE code = 'guanghan') - AND dl.is_default = true + AND ( + (%(layout_key)s::text IS NOT NULL AND dl.layout_key = %(layout_key)s) + OR (%(layout_key)s::text IS NULL AND dl.is_default = true) + ) ORDER BY dn.z_index, dn.node_key - """) + """, {"layout_key": layout_key}) nodes = [] layout_info = None theme_info = None diff --git a/topology_api/routes/snapshot.py b/topology_api/routes/snapshot.py index 79e542b..700a0f4 100644 --- a/topology_api/routes/snapshot.py +++ b/topology_api/routes/snapshot.py @@ -7,6 +7,33 @@ from topology_api.queries.snapshot_queries import ( ) snapshot_bp = Blueprint("snapshot", __name__) +GHC06_LAYOUT_START = datetime.fromisoformat("2025-02-15T00:00:00+00:00") + + +def _layout_key_for_snapshot(requested_mission, snapshot_at): + """Keep historical mission views pinned to their contemporary layout. + + GHC-05 overlaps GHC-06 chronologically, so timestamp-only selection would + incorrectly redraw the GHC-05 terminal snapshot with the later expansion + layout. Explicit mission views therefore take precedence over time. + """ + if requested_mission: + try: + mission_number = int(requested_mission.rsplit("-", 1)[-1]) + except (TypeError, ValueError): + mission_number = None + return ( + "engineering_overview_ghc06_v1" + if mission_number is not None and mission_number >= 6 + else None + ) + + snapshot_dt = datetime.fromisoformat(snapshot_at.replace("Z", "+00:00")) + return ( + "engineering_overview_ghc06_v1" + if snapshot_dt >= GHC06_LAYOUT_START + else None + ) def _parse_iso_timestamp(value): @@ -82,7 +109,8 @@ def get_snapshot(base_code): }) data = fetch_snapshot(snapshot_at) - layout = fetch_layout() + layout_key = _layout_key_for_snapshot(requested_mission, snapshot_at) + layout = fetch_layout(layout_key=layout_key) return jsonify({ "base": data["base"], diff --git a/topology_api/seed/seed_ghc06.py b/topology_api/seed/seed_ghc06.py new file mode 100644 index 0000000..f8d8d5f --- /dev/null +++ b/topology_api/seed/seed_ghc06.py @@ -0,0 +1,391 @@ +"""Incrementally seed GHC-06 topology data without rebuilding earlier missions. + +Usage: + python topology_api/seed/seed_ghc06.py +""" +import json +from datetime import datetime, timezone + +from topology_api.db import write_cursor + + +UTC = timezone.utc + + +def ts(value): + return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC) + + +def seed(): + with write_cursor() as cur: + cur.execute("SELECT id FROM bases WHERE code = 'guanghan'") + base = cur.fetchone() + if not base: + raise RuntimeError("Base 'guanghan' is missing; seed GHC-01 through GHC-05 first") + base_id = base["id"] + + cur.execute(""" + INSERT INTO missions (id, base_id, code, title, occurrence_status, + description, source_ref) + VALUES (gen_random_uuid(), %(base)s, 'GHC-06', + '广寒基地建设任务-06', 'occurred', + '第二次乘组轮换与广寒 04 北部扩展区建设', + '/home/mission/Guanghan_Program/GHC-06') + ON CONFLICT (base_id, code) DO UPDATE SET + title = EXCLUDED.title, + occurrence_status = EXCLUDED.occurrence_status, + description = EXCLUDED.description, + source_ref = EXCLUDED.source_ref, + updated_at = now() + RETURNING id + """, {"base": base_id}) + mission_id = cur.fetchone()["id"] + + event_specs = [ + ("landing", "2025-01-11T10:49:00Z", 0, "component_introduced", + "吴刚三号着陆广寒基地"), + ("lander_connected", "2025-01-12T00:00:00Z", 0, "connection_established", + "吴刚三号通过柔性加压管道接入基地"), + ("hub_installed", "2025-02-15T00:00:00Z", 0, "component_installed", + "十字安装模块安装至北部扩展位置"), + ("greenhouse_installed", "2025-02-17T00:00:00Z", 0, "component_installed", + "温室二号与堆肥模块安装完成"), + ("habitat_storage_installed", "2025-02-19T00:00:00Z", 0, "component_installed", + "存储模块与居住舱三号安装完成"), + ("passage_installed", "2025-02-22T00:00:00Z", 0, "connection_established", + "温室模块与温室二号加压走道安装完成"), + ("topology_reconfigured", "2025-02-22T00:00:00Z", 1, "connection_changed", + "吴刚二号改接实验舱远端接口"), + ("reactor_35", "2025-02-27T00:00:00Z", 0, "state_changed", + "核反应堆功率提升至 35%"), + ("lander_departed", "2025-03-11T00:00:00Z", 0, "component_retired", + "GHC-06 乘组搭乘吴刚三号撤离"), + ("terminal", "2025-03-11T00:00:00Z", 1, "mission_checkpoint", + "GHC-06 任务结束状态"), + ] + events = {} + event_times = {} + for key, effective_at, order, event_type, title in event_specs: + event_times[key] = ts(effective_at) + cur.execute(""" + INSERT INTO mission_events + (id, mission_id, effective_at, event_order, event_type, + title, source_ref) + VALUES (gen_random_uuid(), %(mission)s, %(at)s, %(ord)s, + %(type)s, %(title)s, %(source)s) + ON CONFLICT (mission_id, effective_at, event_order) DO UPDATE SET + event_type = EXCLUDED.event_type, + title = EXCLUDED.title, + source_ref = EXCLUDED.source_ref + RETURNING id + """, { + "mission": mission_id, "at": event_times[key], "ord": order, + "type": event_type, "title": title, + "source": "/home/mission/Guanghan_Program/GHC-06", + }) + events[key] = cur.fetchone()["id"] + + component_specs = [ + ("wugang_3", "vehicle", "landing", "吴刚三号", + "Block 2 载人月面着陆器", {"block": 2, "role": "crewed_lander"}), + ("expansion_hub", "node", "hub_installed", "十字安装模块", + "广寒 04 北部扩展区四向安装节点", {"dockingNodes": 4}), + ("greenhouse_2", "module", "greenhouse_installed", "温室二号", + "第二座折叠式月面温室", {"foldingArms": 4}), + ("compost_module", "equipment", "greenhouse_installed", "堆肥模块", + "温室二号有机废弃物与养分循环设备", {}), + ("storage_module", "module", "habitat_storage_installed", "存储模块", + "北部扩展区温湿度受控存储设施", {}), + ("habitat_3", "module", "habitat_storage_installed", "居住舱段三号", + "四铺位居住舱段,使基地容量提升至 14 人", {"capacity": 4}), + ] + components = {} + for key, component_type, event_key, display_name, summary, metadata in component_specs: + cur.execute(""" + INSERT INTO components + (id, base_id, component_key, component_type, + introduced_by_event_id, metadata) + VALUES (gen_random_uuid(), %(base)s, %(key)s, %(type)s, + %(event)s, %(metadata)s) + ON CONFLICT (base_id, component_key) DO UPDATE SET + component_type = EXCLUDED.component_type, + introduced_by_event_id = EXCLUDED.introduced_by_event_id, + metadata = EXCLUDED.metadata, + updated_at = now() + RETURNING id + """, { + "base": base_id, "key": key, "type": component_type, + "event": events[event_key], "metadata": json.dumps(metadata), + }) + component_id = cur.fetchone()["id"] + components[key] = component_id + + cur.execute(""" + SELECT id FROM component_versions + WHERE component_id = %(component)s + AND started_by_event_id = %(event)s + """, {"component": component_id, "event": events[event_key]}) + version = cur.fetchone() + values = { + "component": component_id, "name": display_name, + "summary": summary, "properties": json.dumps(metadata), + "event": events[event_key], + } + if version: + cur.execute(""" + UPDATE component_versions SET + display_name = %(name)s, summary = %(summary)s, + properties = %(properties)s, updated_at = now() + WHERE id = %(id)s + """, {**values, "id": version["id"]}) + else: + cur.execute(""" + INSERT INTO component_versions + (id, component_id, display_name, summary, properties, + valid_from, started_by_event_id) + VALUES (gen_random_uuid(), %(component)s, %(name)s, + %(summary)s, %(properties)s, + (SELECT effective_at FROM mission_events WHERE id = %(event)s), + %(event)s) + """, values) + + cur.execute(""" + UPDATE components SET retired_by_event_id = %(event)s, updated_at = now() + WHERE id = %(component)s + """, {"event": events["lander_departed"], "component": components["wugang_3"]}) + cur.execute(""" + UPDATE component_versions SET + valid_to = (SELECT effective_at FROM mission_events WHERE id = %(event)s), + ended_by_event_id = %(event)s, + updated_at = now() + WHERE component_id = %(component)s AND valid_to IS NULL + """, {"event": events["lander_departed"], "component": components["wugang_3"]}) + + # Include existing components used as attachment roots. + cur.execute(""" + SELECT component_key, id FROM components + WHERE base_id = %(base)s + AND component_key IN ( + 'greenhouse', 'lab_module', 'wugang_2', 'nuclear_reactor' + ) + """, {"base": base_id}) + components.update({row["component_key"]: row["id"] for row in cur.fetchall()}) + for required in ("greenhouse", "lab_module", "wugang_2", "nuclear_reactor"): + if required not in components: + raise RuntimeError(f"Required component '{required}' is missing") + + port_specs = [ + ("wugang_3", "dock", "加压对接口", "south", "docking"), + ("expansion_hub", "north", "北部安装口", "north", "structural"), + ("expansion_hub", "east", "东部安装口", "east", "structural"), + ("expansion_hub", "south", "南部加压接口", "south", "pressurized"), + ("expansion_hub", "west", "西部安装口", "west", "structural"), + ("greenhouse_2", "core", "十字模块接口", "west", "structural"), + ("greenhouse_2", "passage", "温室模块加压接口", "east", "pressurized"), + ("greenhouse_2", "mount", "顶部设备接口", "north", "structural"), + ("compost_module", "mount", "温室安装接口", "south", "structural"), + ("storage_module", "core", "十字模块接口", "south", "structural"), + ("habitat_3", "core", "十字模块接口", "east", "structural"), + ("greenhouse", "distal", "远端对接口", "west", "pressurized"), + ("lab_module", "distal", "远端加压接口", "east", "pressurized"), + ("wugang_2", "dock", "加压对接口", "west", "docking"), + ] + ports = {} + for component_key, port_key, name, direction, interface_type in port_specs: + cur.execute(""" + INSERT INTO ports + (id, component_id, port_key, display_name, direction, interface_type) + VALUES (gen_random_uuid(), %(component)s, %(key)s, %(name)s, + %(direction)s, %(interface)s) + ON CONFLICT (component_id, port_key) DO UPDATE SET + display_name = EXCLUDED.display_name, + direction = EXCLUDED.direction, + interface_type = EXCLUDED.interface_type + RETURNING id + """, { + "component": components[component_key], "key": port_key, + "name": name, "direction": direction, "interface": interface_type, + }) + ports[(component_key, port_key)] = cur.fetchone()["id"] + + connection_specs = [ + ("expansion_hub", "north", "storage_module", "core", "structural_mount", + "habitat_storage_installed", None), + ("expansion_hub", "east", "greenhouse_2", "core", "structural_mount", + "greenhouse_installed", None), + ("expansion_hub", "west", "habitat_3", "core", "structural_mount", + "habitat_storage_installed", None), + ("greenhouse_2", "mount", "compost_module", "mount", "structural_mount", + "greenhouse_installed", None), + ("greenhouse", "distal", "greenhouse_2", "passage", "pressurized_passage", + "passage_installed", None), + ("lab_module", "distal", "wugang_2", "dock", "pressurized_passage", + "topology_reconfigured", None), + ] + + # Remove the earlier inferred south connection; the GHC-06 terminal + # topology keeps the expansion hub's south port intentionally vacant. + cur.execute(""" + DELETE FROM topology_connections tc + USING ports pa, ports pb + WHERE tc.port_a_id = pa.id AND tc.port_b_id = pb.id + AND tc.established_by_event_id = %(event)s + AND ( + pa.component_id = %(hub)s OR pb.component_id = %(hub)s + ) + AND ( + pa.component_id NOT IN (%(storage)s, %(greenhouse)s, %(habitat)s, %(compost)s) + OR pb.component_id NOT IN (%(storage)s, %(greenhouse)s, %(habitat)s, %(compost)s) + ) + """, { + "event": events["passage_installed"], + "hub": components["expansion_hub"], + "storage": components["storage_module"], + "greenhouse": components["greenhouse_2"], + "habitat": components["habitat_3"], + "compost": components["compost_module"], + }) + + # Close the old greenhouse-to-Wugang-2 passage when the lander moves + # to the laboratory cabin's distal interface. + cur.execute(""" + UPDATE topology_connections tc SET + valid_to = %(at)s, + ended_by_event_id = %(event)s, + updated_at = now() + FROM ports pa, ports pb + WHERE tc.port_a_id = pa.id AND tc.port_b_id = pb.id + AND tc.valid_to IS NULL + AND ((pa.component_id = %(greenhouse)s AND pb.component_id = %(wugang)s) + OR (pa.component_id = %(wugang)s AND pb.component_id = %(greenhouse)s)) + """, { + "at": event_times["topology_reconfigured"], + "event": events["topology_reconfigured"], + "greenhouse": components["greenhouse"], + "wugang": components["wugang_2"], + }) + for comp_a, port_a, comp_b, port_b, kind, start_key, end_key in connection_specs: + port_a_id = ports[(comp_a, port_a)] + port_b_id = ports[(comp_b, port_b)] + cur.execute(""" + SELECT id FROM topology_connections + WHERE base_id = %(base)s + AND port_a_id = %(port_a)s AND port_b_id = %(port_b)s + AND established_by_event_id = %(event)s + """, { + "base": base_id, "port_a": port_a_id, "port_b": port_b_id, + "event": events[start_key], + }) + connection = cur.fetchone() + params = { + "base": base_id, "port_a": port_a_id, "port_b": port_b_id, + "kind": kind, "event": events[start_key], + "end_event": events[end_key] if end_key else None, + "end_at": event_times[end_key] if end_key else None, + } + if connection: + cur.execute(""" + UPDATE topology_connections SET + connection_type = %(kind)s, + valid_to = %(end_at)s, + ended_by_event_id = %(end_event)s, + updated_at = now() + WHERE id = %(id)s + """, {**params, "id": connection["id"]}) + else: + cur.execute(""" + INSERT INTO topology_connections + (id, base_id, port_a_id, port_b_id, connection_type, + valid_from, valid_to, established_by_event_id, ended_by_event_id) + VALUES ( + gen_random_uuid(), %(base)s, %(port_a)s, %(port_b)s, %(kind)s, + (SELECT effective_at FROM mission_events WHERE id = %(event)s), + %(end_at)s, + %(event)s, %(end_event)s) + """, params) + + # Lifecycle states for newly introduced components. + for key, _, event_key, _, _, _ in component_specs: + cur.execute(""" + SELECT id FROM component_states + WHERE component_id = %(component)s AND state_kind = 'lifecycle' + AND started_by_event_id = %(event)s + """, {"component": components[key], "event": events[event_key]}) + if not cur.fetchone(): + cur.execute(""" + INSERT INTO component_states + (id, component_id, state_kind, state_value, valid_from, + valid_to, started_by_event_id, ended_by_event_id) + VALUES ( + gen_random_uuid(), %(component)s, 'lifecycle', 'installed', + (SELECT effective_at FROM mission_events WHERE id = %(event)s), + %(end_at)s, + %(event)s, %(end_event)s) + """, { + "component": components[key], "event": events[event_key], + "end_event": events["lander_departed"] if key == "wugang_3" else None, + "end_at": event_times["lander_departed"] if key == "wugang_3" else None, + }) + + # Change the reactor state from 30% to 35% without creating overlaps. + cur.execute(""" + UPDATE component_states SET + valid_to = (SELECT effective_at FROM mission_events WHERE id = %(event)s), + ended_by_event_id = %(event)s, + updated_at = now() + WHERE component_id = %(component)s AND state_kind = 'operation' + AND valid_to IS NULL AND details @> '{"powerPercent": 30}' + """, {"component": components["nuclear_reactor"], "event": events["reactor_35"]}) + cur.execute(""" + SELECT id FROM component_states + WHERE component_id = %(component)s AND state_kind = 'operation' + AND started_by_event_id = %(event)s + """, {"component": components["nuclear_reactor"], "event": events["reactor_35"]}) + if not cur.fetchone(): + cur.execute(""" + INSERT INTO component_states + (id, component_id, state_kind, state_value, details, + valid_from, started_by_event_id) + VALUES ( + gen_random_uuid(), %(component)s, 'operation', 'operational', + '{"powerPercent": 35}', + (SELECT effective_at FROM mission_events WHERE id = %(event)s), + %(event)s) + """, {"component": components["nuclear_reactor"], "event": events["reactor_35"]}) + + # The version carries the headline engineering property used by the + # card; create a new temporal version so older snapshots still show 30%. + cur.execute(""" + UPDATE component_versions SET + valid_to = (SELECT effective_at FROM mission_events WHERE id = %(event)s), + ended_by_event_id = %(event)s, + updated_at = now() + WHERE component_id = %(component)s AND valid_to IS NULL + AND started_by_event_id <> %(event)s + """, {"component": components["nuclear_reactor"], "event": events["reactor_35"]}) + cur.execute(""" + SELECT id FROM component_versions + WHERE component_id = %(component)s AND started_by_event_id = %(event)s + """, {"component": components["nuclear_reactor"], "event": events["reactor_35"]}) + if not cur.fetchone(): + cur.execute(""" + INSERT INTO component_versions + (id, component_id, display_name, summary, properties, + valid_from, started_by_event_id) + VALUES ( + gen_random_uuid(), %(component)s, '核反应堆', + '广寒基地主电力来源,GHC-06 后以百分之三十五功率运行', + '{"powerPercent": 35}', + (SELECT effective_at FROM mission_events WHERE id = %(event)s), + %(event)s) + """, {"component": components["nuclear_reactor"], "event": events["reactor_35"]}) + + print("GHC-06 topology seed complete") + print(f" mission: GHC-06") + print(f" events: {len(events)}") + print(f" new components: {len(component_specs)}") + print(f" connections: {len(connection_specs)}") + + +if __name__ == "__main__": + seed() diff --git a/topology_api/seed/seed_layout.py b/topology_api/seed/seed_layout.py index 7e9f6e1..1b94071 100644 --- a/topology_api/seed/seed_layout.py +++ b/topology_api/seed/seed_layout.py @@ -4,9 +4,17 @@ import json from topology_api.db import write_cursor THEME_KEY = "guanghan_light_engineering_v1" -CANVAS_WIDTH = 1900 -CANVAS_HEIGHT = 1900 -CONTENT_X_OFFSET = 200 +LEGACY_CANVAS_WIDTH = 1900 +LEGACY_CANVAS_HEIGHT = 1900 +LEGACY_X_OFFSET = 200 +LEGACY_Y_OFFSET = 0 + +# GHC-06 and later use a separate future-ready canvas. Historical missions +# keep the legacy coordinates, especially Wugang 2's GHC-05 greenhouse berth. +FUTURE_CANVAS_WIDTH = 2900 +FUTURE_CANVAS_HEIGHT = 2350 +FUTURE_X_OFFSET = 500 +FUTURE_Y_OFFSET = 280 THEME_TOKENS = { "textColor": "#152033", @@ -99,7 +107,11 @@ def seed(): theme_id = cur.fetchone()["id"] print(f"Theme: {theme_id}") - # Upsert layout + # Keep the pre-GHC-06 layout as the default/fallback layout. + cur.execute( + "UPDATE diagram_layouts SET is_default = false WHERE base_id = %(base)s", + {"base": base_id}, + ) cur.execute(""" INSERT INTO diagram_layouts (id, base_id, layout_key, name, canvas_width, canvas_height, is_default, theme_id) @@ -114,8 +126,8 @@ def seed(): RETURNING id """, { "base": base_id, - "canvas_width": CANVAS_WIDTH, - "canvas_height": CANVAS_HEIGHT, + "canvas_width": LEGACY_CANVAS_WIDTH, + "canvas_height": LEGACY_CANVAS_HEIGHT, "theme": theme_id, }) layout_id = cur.fetchone()["id"] @@ -239,6 +251,21 @@ def seed(): ("wugang_2", 40, 495, 210, 150, "vehicle-card", None, 3, "Block 2 载人着陆器", "温室远端接口", {}, ["wugang_2"]), + # GHC-06 northern expansion. The engineering overview keeps the + # pre-GHC-06 core cross stable and places the new branch to its + # upper-right so historical mission snapshots do not jump. + ("expansion_hub", 40, 490, 210, 170, "core-card", "十字扩展节点", 9, + "扩展区中枢", "南部接口预留", {}, ["expansion_hub"]), + ("storage_module", 35, 220, 220, 140, "node-card", "存储模块", 5, + "十字扩展节点北口", "温湿度受控存储", {}, ["storage_module"]), + ("greenhouse_2", 280, 490, 220, 170, "node-card", "温室二号", 5, + "东接温室模块 · 西接十字节点", "堆肥循环系统", {}, + ["greenhouse_2", "compost_module"]), + ("habitat_3", -190, 505, 200, 140, "node-card", "居住舱段三号", 5, + "十字扩展节点西口", "4 铺位 · 总容量 14 人", {}, ["habitat_3"]), + ("wugang_3", 645, 40, 210, 150, "vehicle-card", "吴刚三号", 3, + "Block 2 载人着陆器", "居住舱二号北部停泊位", {}, ["wugang_3"]), + # Power cross ("power_module_hub", 650, 1260, 200, 180, "power-node-card", "电力模块", 8, "四向安装节点", "核电与资源中枢", {}, @@ -247,7 +274,7 @@ def seed(): "北部模块", 5, "电力模块存储支架", "加压走道端", {"radiator": True}, ["power_storage_rack"]), ("nuclear_reactor", 620, 1540, 260, 150, "power-card", - "南部模块", 5, "核反应堆", "30%功率", + "南部模块", 5, "核反应堆", "35%功率", {"radiator": True}, ["nuclear_reactor"]), # Power west/east groups (multiple DB components per SVG node) @@ -269,48 +296,96 @@ def seed(): "电力模块东侧", "Block 2", {}, ["binglun_3"]), ] - # Delete existing nodes and re-insert - cur.execute("DELETE FROM diagram_nodes WHERE layout_id = %(lid)s", - {"lid": layout_id}) - - for ( - node_key, x, y, w, h, style, label, z, display_meta, - display_hint, decorations, member_keys, - ) in nodes: - primary_component_id = ( - comp_map[member_keys[0]] if len(member_keys) == 1 else None - ) - cur.execute(""" - INSERT INTO diagram_nodes - (id, layout_id, component_id, node_key, x, y, - width, height, style_key, label_override, z_index, - display_meta, display_hint, decorations) - VALUES (gen_random_uuid(), %(lid)s, %(cid)s, %(nkey)s, - %(x)s, %(y)s, %(w)s, %(h)s, - %(style)s, %(label)s, %(z)s, - %(meta)s, %(hint)s, %(decorations)s) - RETURNING id - """, { - "lid": layout_id, "cid": primary_component_id, - "nkey": node_key, - "x": x + CONTENT_X_OFFSET, "y": y, "w": w, "h": h, - "style": style, "label": label, "z": z, - "meta": display_meta, "hint": display_hint, - "decorations": json.dumps(decorations), - }) - node_id = cur.fetchone()["id"] - for display_order, member_key in enumerate(member_keys): + def insert_nodes(target_layout_id, target_nodes, x_offset, y_offset): + cur.execute("DELETE FROM diagram_nodes WHERE layout_id = %(lid)s", + {"lid": target_layout_id}) + for ( + node_key, x, y, w, h, style, label, z, display_meta, + display_hint, decorations, member_keys, + ) in target_nodes: + primary_component_id = ( + comp_map[member_keys[0]] if len(member_keys) == 1 else None + ) cur.execute(""" - INSERT INTO diagram_node_members - (diagram_node_id, component_id, display_order) - VALUES (%(node)s, %(component)s, %(display_order)s) + INSERT INTO diagram_nodes + (id, layout_id, component_id, node_key, x, y, + width, height, style_key, label_override, z_index, + display_meta, display_hint, decorations) + VALUES (gen_random_uuid(), %(lid)s, %(cid)s, %(nkey)s, + %(x)s, %(y)s, %(w)s, %(h)s, + %(style)s, %(label)s, %(z)s, + %(meta)s, %(hint)s, %(decorations)s) + RETURNING id """, { - "node": node_id, - "component": comp_map[member_key], - "display_order": display_order, + "lid": target_layout_id, "cid": primary_component_id, + "nkey": node_key, "x": x + x_offset, "y": y + y_offset, + "w": w, "h": h, "style": style, "label": label, "z": z, + "meta": display_meta, "hint": display_hint, + "decorations": json.dumps(decorations), }) + node_id = cur.fetchone()["id"] + for display_order, member_key in enumerate(member_keys): + cur.execute(""" + INSERT INTO diagram_node_members + (diagram_node_id, component_id, display_order) + VALUES (%(node)s, %(component)s, %(display_order)s) + """, { + "node": node_id, + "component": comp_map[member_key], + "display_order": display_order, + }) - print(f"Nodes inserted: {len(nodes)}") + insert_nodes(layout_id, nodes, LEGACY_X_OFFSET, LEGACY_Y_OFFSET) + print(f"Legacy nodes inserted: {len(nodes)}") + + # Create the GHC-06+ layout without changing historical coordinates. + cur.execute(""" + INSERT INTO diagram_layouts (id, base_id, layout_key, name, + canvas_width, canvas_height, is_default, theme_id) + VALUES (gen_random_uuid(), %(base)s, 'engineering_overview_ghc06_v1', + '工程总图 GHC-06+', %(width)s, %(height)s, false, %(theme)s) + ON CONFLICT (base_id, layout_key) DO UPDATE SET + name = EXCLUDED.name, + canvas_width = EXCLUDED.canvas_width, + canvas_height = EXCLUDED.canvas_height, + is_default = false, + theme_id = EXCLUDED.theme_id + RETURNING id + """, { + "base": base_id, "width": FUTURE_CANVAS_WIDTH, + "height": FUTURE_CANVAS_HEIGHT, "theme": theme_id, + }) + future_layout_id = cur.fetchone()["id"] + + future_positions = { + "core_cabin": (970, 470), "habitat_2": (970, 220), + "habitat_1": (970, 760), "greenhouse": (700, 490), + "lab_module": (1260, 490), "wugang_2": (1570, 495), + "expansion_hub": (40, 490), "storage_module": (35, 220), + "greenhouse_2": (280, 490), "habitat_3": (-190, 505), + "wugang_3": (985, 40), "power_module_hub": (990, 1260), + "power_storage_rack": (940, 990), "nuclear_reactor": (960, 1540), + "power_west_group": (620, 1250), "power_east_group": (1300, 1250), + "binglun_1": (370, 1280), "binglun_2": (985, 1740), + "binglun_3": (1600, 1280), + } + future_copy = { + "greenhouse": ("核心舱西部安装口", "加压走道接温室二号"), + "lab_module": ("核心舱东部安装口", "东向扩建轴 · 远端接吴刚二号"), + "wugang_2": ("Block 2 载人着陆器", "实验舱东部停泊位"), + } + future_nodes = [] + for node in nodes: + updated = list(node) + updated[1], updated[2] = future_positions[updated[0]] + if updated[0] in future_copy: + updated[8], updated[9] = future_copy[updated[0]] + future_nodes.append(tuple(updated)) + + insert_nodes( + future_layout_id, future_nodes, FUTURE_X_OFFSET, FUTURE_Y_OFFSET + ) + print(f"GHC-06+ nodes inserted: {len(future_nodes)}") if __name__ == "__main__":