feat(topology): add GHC06 layout and deploy docs
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
|
||||
确认新版本稳定运行后,旧容器可以继续保留一段时间。不要在首次验证前删除旧容器或旧镜像。
|
||||
@@ -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
|
||||
@@ -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 脚本。
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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()
|
||||
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user