feat(db): atlas data pipeline — schema, importers, and normalization (#901-#911)
New tables: atlas_body_heightmaps (D-202), atlas_city_names (D-207), atlas_feature_names, atlas_province_boundaries (D-205), body_radius_km column (D-204). Three new importers: heightmap BLOBs, city names from wiki markers.json, province boundaries via D8 watershed analysis. economic_role normalized to 7 canonical values (D-194). Stamp fix in generate_atlas.py to hash all tracked source files. systems.db regenerated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -175,6 +175,11 @@ CREATE TABLE IF NOT EXISTS bodies (
|
||||
cultural_corridor TEXT, -- override system corridor if different
|
||||
industrial_corridor TEXT, -- MVG, Gate_Corp, DSMC, Prometheus, Agricultural_Syndic
|
||||
|
||||
-- Physical dimensions (D-204, #905)
|
||||
-- Mean radius in km. NULL until authoritative data is available; fallback
|
||||
-- derivation from planet_class is applied at query time by the generator.
|
||||
body_radius_km REAL,
|
||||
|
||||
-- Rendering
|
||||
-- terrain_reference: repo-root-relative path to the body's heightmap PNG.
|
||||
-- Convention (enforced by populate_terrain_reference.py and assumed by
|
||||
@@ -455,6 +460,58 @@ CREATE INDEX IF NOT EXISTS idx_atlas_pois_kind ON atlas_pois(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_rivers_body ON atlas_rivers(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_oceans_body ON atlas_oceans(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_mountain_ranges_body ON atlas_mountain_ranges(body_id);
|
||||
-- Heightmap BLOB storage — float32 LE, row-major (D-202, #901)
|
||||
-- Only inhabited bodies receive rows at build time; uninhabited bodies are
|
||||
-- generated on-demand by the runtime-background tier.
|
||||
CREATE TABLE IF NOT EXISTS atlas_body_heightmaps (
|
||||
body_id TEXT PRIMARY KEY REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
width INTEGER NOT NULL DEFAULT 512,
|
||||
height INTEGER NOT NULL DEFAULT 256,
|
||||
data BLOB NOT NULL, -- float32 LE, row-major, width×height values
|
||||
sea_level REAL NOT NULL DEFAULT 0.0,
|
||||
imported_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- City name reservations — replaces authored city positions in markers.json (D-207, #902)
|
||||
-- Position is generated by the city placement algorithm; name is authored or LLM-generated.
|
||||
CREATE TABLE IF NOT EXISTS atlas_city_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'city', -- 'capital' | 'city'
|
||||
economic_role TEXT NOT NULL,
|
||||
population INTEGER NOT NULL,
|
||||
corp_id TEXT REFERENCES corporations(corp_id), -- nullable, corp HQ if applicable
|
||||
reserved INTEGER NOT NULL DEFAULT 0, -- 1 = reserved for authored scenario use
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Geographic feature name reservations — rivers, mountains, passes (D-207 adjacent, #903)
|
||||
CREATE TABLE IF NOT EXISTS atlas_feature_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
feature_type TEXT NOT NULL, -- 'river' | 'mountain' | 'pass' | 'ocean' | 'region'
|
||||
priority INTEGER NOT NULL DEFAULT 0, -- higher = applied first during naming
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Province boundaries — watershed drainage basin polylines (D-205, #904)
|
||||
-- Pre-computed at build time from D8 drainage analysis.
|
||||
CREATE TABLE IF NOT EXISTS atlas_province_boundaries (
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
basin_id INTEGER NOT NULL,
|
||||
path TEXT NOT NULL, -- JSON array [[row, col], ...] pixel-space polyline
|
||||
area_pct REAL NOT NULL, -- fraction of body surface area in this basin
|
||||
PRIMARY KEY (body_id, basin_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_body_heightmaps_body ON atlas_body_heightmaps(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_body ON atlas_city_names(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_kind ON atlas_city_names(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_corp ON atlas_city_names(corp_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_feature_names_body ON atlas_feature_names(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_province_boundaries_body ON atlas_province_boundaries(body_id);
|
||||
-- END ATLAS INDEX (D-191 §8, #832)
|
||||
|
||||
-- Indexes
|
||||
|
||||
Binary file not shown.
@@ -24,6 +24,7 @@ Usage:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
@@ -40,6 +41,7 @@ COMMODITIES_TOML = REPO_ROOT / "wiki" / "economics" / "commodities.toml"
|
||||
CHAINS_TOML = REPO_ROOT / "wiki" / "economics" / "production_chains.toml"
|
||||
SCHEMA_SQL = REPO_ROOT / "server" / "data" / "systems-schema.sql"
|
||||
CORPORATIONS_DIR = REPO_ROOT / "wiki" / "corporations"
|
||||
WIKI_STAR_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
|
||||
BRANDS_TOML = REPO_ROOT / "wiki" / "economics" / "corporations" / "brands.toml"
|
||||
GENERATED_BRANDS_TOML = REPO_ROOT / "wiki" / "economics" / "corporations" / "generated_brands.toml"
|
||||
# Rust sources for the generate_brands subroutine. import_economics shells out to
|
||||
@@ -281,6 +283,62 @@ CREATE TABLE IF NOT EXISTS meta (
|
||||
-- own meta row. This DELETE makes the check-systems-db-stamp "unknown generator"
|
||||
-- path (fail-closed per T6) compatible with older DBs that still have the row.
|
||||
DELETE FROM meta WHERE generator_name = 'generate_brands';
|
||||
|
||||
-- Heightmap BLOB storage (D-202, #901)
|
||||
CREATE TABLE IF NOT EXISTS atlas_body_heightmaps (
|
||||
body_id TEXT PRIMARY KEY REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
width INTEGER NOT NULL DEFAULT 512,
|
||||
height INTEGER NOT NULL DEFAULT 256,
|
||||
data BLOB NOT NULL,
|
||||
sea_level REAL NOT NULL DEFAULT 0.0,
|
||||
imported_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_body_heightmaps_body ON atlas_body_heightmaps(body_id);
|
||||
|
||||
-- City name reservations (D-207, #902)
|
||||
CREATE TABLE IF NOT EXISTS atlas_city_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'city',
|
||||
economic_role TEXT NOT NULL,
|
||||
population INTEGER NOT NULL,
|
||||
corp_id TEXT REFERENCES corporations(corp_id),
|
||||
reserved INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_body ON atlas_city_names(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_kind ON atlas_city_names(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_corp ON atlas_city_names(corp_id);
|
||||
|
||||
-- Geographic feature name reservations (#903)
|
||||
CREATE TABLE IF NOT EXISTS atlas_feature_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
feature_type TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_feature_names_body ON atlas_feature_names(body_id);
|
||||
|
||||
-- Province boundaries (D-205, #904)
|
||||
CREATE TABLE IF NOT EXISTS atlas_province_boundaries (
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
basin_id INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
area_pct REAL NOT NULL,
|
||||
PRIMARY KEY (body_id, basin_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_province_boundaries_body ON atlas_province_boundaries(body_id);
|
||||
|
||||
-- Normalize bodies.economic_role to the D-194 canonical 10-value set (#911).
|
||||
-- Idempotent: each UPDATE is a no-op if the old value is already gone.
|
||||
UPDATE bodies SET economic_role = 'agricultural' WHERE economic_role IN ('agriculture', 'mixed-agriculture');
|
||||
UPDATE bodies SET economic_role = 'extraction' WHERE economic_role IN ('mining', 'resource_extraction', 'energy');
|
||||
UPDATE bodies SET economic_role = 'transit_hub' WHERE economic_role = 'transit';
|
||||
UPDATE bodies SET economic_role = 'service_mixed' WHERE economic_role IN ('commercial', 'coordination');
|
||||
UPDATE bodies SET economic_role = 'residential' WHERE economic_role = 'frontier';
|
||||
"""
|
||||
|
||||
# Columns to add to existing tables (ALTER TABLE is idempotent via try/except)
|
||||
@@ -291,6 +349,7 @@ COLUMN_MIGRATIONS = [
|
||||
("corporations", "supply_chain_role", "TEXT"),
|
||||
("corporations", "shadow_economy_access", "INTEGER DEFAULT 0"),
|
||||
("brand_products", "price_tier", "TEXT"),
|
||||
("bodies", "body_radius_km", "REAL"), # D-204 — physical radius in km, nullable
|
||||
]
|
||||
|
||||
|
||||
@@ -776,6 +835,29 @@ def validate(conn: sqlite3.Connection) -> list[str]:
|
||||
for chain_id, cid in orphan_outputs:
|
||||
errors.append(f"production_chains: chain '{chain_id}' outputs unknown commodity '{cid}'")
|
||||
|
||||
# economic_role must be one of the D-194 canonical 10 values
|
||||
valid_roles = {
|
||||
'manufacturing', 'financial', 'agricultural', 'extraction',
|
||||
'service_mixed', 'institutional', 'transit_hub', 'research',
|
||||
'military', 'residential',
|
||||
}
|
||||
bad_roles = conn.execute("""
|
||||
SELECT DISTINCT economic_role, COUNT(*) as cnt
|
||||
FROM bodies
|
||||
WHERE economic_role IS NOT NULL
|
||||
AND economic_role NOT IN (
|
||||
'manufacturing', 'financial', 'agricultural', 'extraction',
|
||||
'service_mixed', 'institutional', 'transit_hub', 'research',
|
||||
'military', 'residential'
|
||||
)
|
||||
GROUP BY economic_role
|
||||
""").fetchall()
|
||||
for role, cnt in bad_roles:
|
||||
errors.append(
|
||||
f"bodies.economic_role: non-canonical value '{role}' on {cnt} row(s) — "
|
||||
f"valid values: {sorted(valid_roles)}"
|
||||
)
|
||||
|
||||
# Chain completeness: every intermediate commodity must have at least one producer
|
||||
missing_chains = conn.execute("""
|
||||
SELECT c.commodity_id, c.name
|
||||
@@ -980,6 +1062,228 @@ def import_system_fiscal(conn: sqlite3.Connection, dry_run: bool) -> int:
|
||||
return len(rows)
|
||||
|
||||
|
||||
def populate_body_radius_km(conn: sqlite3.Connection, dry_run: bool) -> int:
|
||||
"""Populate body_radius_km column from planet_class fallback (D-204, #910).
|
||||
|
||||
Applies the fallback lookup table to rows where body_radius_km IS NULL.
|
||||
Does not overwrite rows where body_radius_km is already set (authoritative data).
|
||||
|
||||
Fallback values (km):
|
||||
super_earth -> 8000
|
||||
earth_like -> 6371
|
||||
earth -> 6371 (alternate spelling)
|
||||
sub_earth -> 4500
|
||||
ocean_world -> 6500
|
||||
arid -> 5800
|
||||
frozen -> 4500
|
||||
ice_world -> 3000
|
||||
barren -> 4500
|
||||
volcanic -> 5500
|
||||
gas_giant -> 0 (no settlements, skip)
|
||||
moon -> 1737
|
||||
other/unknown -> 6371 (Earth default)
|
||||
"""
|
||||
PLANET_CLASS_RADIUS = {
|
||||
"super_earth": 8000.0,
|
||||
"earth_like": 6371.0,
|
||||
"earth": 6371.0,
|
||||
"sub_earth": 4500.0,
|
||||
"ocean_world": 6500.0,
|
||||
"arid": 5800.0,
|
||||
"frozen": 4500.0,
|
||||
"ice_world": 3000.0,
|
||||
"barren": 4500.0,
|
||||
"volcanic": 5500.0,
|
||||
"temperate": 6371.0,
|
||||
"moon": 1737.0,
|
||||
}
|
||||
DEFAULT_RADIUS = 6371.0
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT body_id, planet_class FROM bodies WHERE body_radius_km IS NULL"
|
||||
).fetchall()
|
||||
|
||||
updates = []
|
||||
for body_id, planet_class in rows:
|
||||
if planet_class and planet_class.lower() == "gas_giant":
|
||||
continue # gas giants have no settlements; leave NULL
|
||||
radius = PLANET_CLASS_RADIUS.get(
|
||||
(planet_class or "").lower(), DEFAULT_RADIUS
|
||||
)
|
||||
updates.append((radius, body_id))
|
||||
|
||||
if not dry_run and updates:
|
||||
conn.executemany(
|
||||
"UPDATE bodies SET body_radius_km = ? WHERE body_id = ?", updates
|
||||
)
|
||||
|
||||
return len(updates)
|
||||
|
||||
|
||||
def populate_atlas_city_names(conn: sqlite3.Connection, dry_run: bool) -> int:
|
||||
"""Populate atlas_city_names from wiki markers.json city entries (D-207, #908).
|
||||
|
||||
Scans wiki/star-systems/*/bodies/*/markers.json for 'cities' arrays.
|
||||
Each entry yields one atlas_city_names row:
|
||||
- body_id : directory name (e.g. GJ0e)
|
||||
- name : city name from markers.json
|
||||
- kind : 'capital' or 'city' (default 'city')
|
||||
- economic_role : inherited from bodies.economic_role; fallback 'mixed'
|
||||
- population : from markers.json (integer)
|
||||
- corp_id : NULL — populated by populate_atlas_city_names_corps (#909)
|
||||
- reserved : 0
|
||||
|
||||
Uses INSERT OR REPLACE so re-runs are idempotent per (body_id, name).
|
||||
Skips body directories not found in the bodies table (missing FK).
|
||||
"""
|
||||
# Build body_id -> economic_role map
|
||||
body_roles: dict[str, str] = {}
|
||||
for body_id, role in conn.execute(
|
||||
"SELECT body_id, economic_role FROM bodies"
|
||||
).fetchall():
|
||||
body_roles[body_id] = role or "mixed"
|
||||
|
||||
valid_body_ids: set[str] = set(body_roles.keys())
|
||||
|
||||
rows: list[tuple] = []
|
||||
skipped_bodies: list[str] = []
|
||||
|
||||
pattern = str(WIKI_STAR_SYSTEMS / "*" / "bodies" / "*" / "markers.json")
|
||||
for markers_path in sorted(glob.glob(pattern)):
|
||||
body_id = markers_path.split("/bodies/")[1].split("/")[0]
|
||||
if body_id not in valid_body_ids:
|
||||
skipped_bodies.append(body_id)
|
||||
continue
|
||||
|
||||
with open(markers_path) as fh:
|
||||
data = json.load(fh)
|
||||
|
||||
for city in data.get("cities", []):
|
||||
name = city.get("name", "").strip()
|
||||
if not name:
|
||||
continue
|
||||
kind = city.get("kind", "city")
|
||||
population = int(city.get("population", 0))
|
||||
economic_role = body_roles[body_id]
|
||||
rows.append((body_id, name, kind, economic_role, population))
|
||||
|
||||
if skipped_bodies:
|
||||
unique = sorted(set(skipped_bodies))
|
||||
print(f" warning: {len(unique)} body dirs not in DB — skipped: {unique[:5]}")
|
||||
|
||||
if not dry_run and rows:
|
||||
conn.executemany(
|
||||
"""INSERT OR REPLACE INTO atlas_city_names
|
||||
(body_id, name, kind, economic_role, population)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
rows,
|
||||
)
|
||||
|
||||
return len(rows)
|
||||
|
||||
|
||||
def populate_atlas_city_names_corps(conn: sqlite3.Connection, dry_run: bool) -> tuple[int, int]:
|
||||
"""Cross-reference corp HQ city names into atlas_city_names (D-207, #909).
|
||||
|
||||
For each corporation with a parseable headquarters field ("City (SYSTEM_ID)"):
|
||||
- If atlas_city_names already has a row with matching name on a body in that
|
||||
system: UPDATE the row to set corp_id.
|
||||
- Otherwise: INSERT a reserved row (reserved=1) so the name is protected.
|
||||
Attaches to the most-populated body in the system (fallback: any body).
|
||||
|
||||
Returns (n_updated, n_inserted).
|
||||
"""
|
||||
# Build system_id -> sorted bodies (by population desc, then body_id)
|
||||
sys_bodies: dict[str, list[tuple[int, str, str]]] = {}
|
||||
for body_id, sys_id, pop, role in conn.execute(
|
||||
"SELECT body_id, system_id, COALESCE(population, 0), COALESCE(economic_role, 'mixed') FROM bodies"
|
||||
).fetchall():
|
||||
sys_bodies.setdefault(sys_id, []).append((pop, body_id, role))
|
||||
for v in sys_bodies.values():
|
||||
v.sort(key=lambda x: (-x[0], x[1]))
|
||||
|
||||
# Build (body_id, name_lower) -> id index for existing atlas_city_names rows
|
||||
existing: dict[tuple[str, str], int] = {}
|
||||
body_to_sys: dict[str, str] = {
|
||||
r[0]: r[1]
|
||||
for r in conn.execute("SELECT body_id, system_id FROM bodies").fetchall()
|
||||
}
|
||||
for row_id, body_id, name in conn.execute(
|
||||
"SELECT id, body_id, name FROM atlas_city_names"
|
||||
).fetchall():
|
||||
existing[(body_id, name.lower())] = row_id
|
||||
|
||||
# Build system_id -> set of body_ids for quick lookup
|
||||
sys_body_ids: dict[str, set[str]] = {}
|
||||
for body_id, sys_id in body_to_sys.items():
|
||||
sys_body_ids.setdefault(sys_id, set()).add(body_id)
|
||||
|
||||
updated: list[tuple[str, int]] = [] # (corp_id, atlas_row_id)
|
||||
inserted: list[tuple] = [] # insert rows
|
||||
|
||||
for corp_id, headquarters_system in conn.execute(
|
||||
"SELECT corp_id, headquarters_system FROM corporations WHERE headquarters_system IS NOT NULL"
|
||||
).fetchall():
|
||||
# Retrieve original headquarters string from wiki to get city name
|
||||
md_file = CORPORATIONS_DIR / f"{corp_id}.md"
|
||||
if not md_file.exists():
|
||||
continue
|
||||
hq_raw = ""
|
||||
with open(md_file) as f:
|
||||
in_fm = False
|
||||
for line in f:
|
||||
if line.strip() == "---":
|
||||
if not in_fm:
|
||||
in_fm = True
|
||||
continue
|
||||
else:
|
||||
break
|
||||
if in_fm and line.startswith("headquarters:"):
|
||||
hq_raw = line.split(":", 1)[1].strip().strip('"')
|
||||
break
|
||||
if not hq_raw:
|
||||
continue
|
||||
m = re.search(r"\(([^)]+)\)", hq_raw)
|
||||
city_name = hq_raw[: m.start()].strip() if m else hq_raw.strip()
|
||||
if not city_name:
|
||||
continue
|
||||
|
||||
# Try to find a matching atlas_city_names row in the same system
|
||||
body_ids_in_sys = sys_body_ids.get(headquarters_system, set())
|
||||
match_id: int | None = None
|
||||
for body_id in body_ids_in_sys:
|
||||
key = (body_id, city_name.lower())
|
||||
if key in existing:
|
||||
match_id = existing[key]
|
||||
break
|
||||
|
||||
if match_id is not None:
|
||||
updated.append((corp_id, match_id))
|
||||
else:
|
||||
# Insert a reserved row on the most-populated body in the system
|
||||
candidates = sys_bodies.get(headquarters_system, [])
|
||||
if not candidates:
|
||||
continue
|
||||
_, target_body_id, body_role = candidates[0]
|
||||
inserted.append((target_body_id, city_name, "city", body_role, 0, corp_id, 1))
|
||||
|
||||
if not dry_run:
|
||||
for corp_id, row_id in updated:
|
||||
conn.execute(
|
||||
"UPDATE atlas_city_names SET corp_id = ? WHERE id = ?",
|
||||
(corp_id, row_id),
|
||||
)
|
||||
if inserted:
|
||||
conn.executemany(
|
||||
"""INSERT OR IGNORE INTO atlas_city_names
|
||||
(body_id, name, kind, economic_role, population, corp_id, reserved)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
inserted,
|
||||
)
|
||||
|
||||
return len(updated), len(inserted)
|
||||
|
||||
|
||||
def validate_brands(conn: sqlite3.Connection) -> list[str]:
|
||||
"""Brand layer structural validation rules V-B01 through V-B06.
|
||||
|
||||
@@ -1220,10 +1524,25 @@ def main():
|
||||
print(f" {n_brands} brand_products, {n_brand_inputs} brand_inputs")
|
||||
|
||||
# 10. System fiscal parameters (D-189 section 6)
|
||||
print(" [10/10] Populating system_fiscal...")
|
||||
print(" [10/13] Populating system_fiscal...")
|
||||
n_fiscal = import_system_fiscal(conn, args.dry_run)
|
||||
print(f" {n_fiscal} system_fiscal rows")
|
||||
|
||||
# 11. body_radius_km fallback from planet_class (D-204, #910)
|
||||
print(" [11/13] Populating body_radius_km fallback...")
|
||||
n_radius = populate_body_radius_km(conn, args.dry_run)
|
||||
print(f" {n_radius} bodies updated")
|
||||
|
||||
# 12. atlas_city_names from wiki markers.json (D-207, #908)
|
||||
print(" [12/13] Populating atlas_city_names from wiki content...")
|
||||
n_cities = populate_atlas_city_names(conn, args.dry_run)
|
||||
print(f" {n_cities} city name rows")
|
||||
|
||||
# 13. atlas_city_names corp HQ cross-reference (D-207, #909)
|
||||
print(" [13/13] Cross-referencing corp HQ cities into atlas_city_names...")
|
||||
n_updated, n_inserted = populate_atlas_city_names_corps(conn, args.dry_run)
|
||||
print(f" {n_updated} rows updated, {n_inserted} reserved rows inserted")
|
||||
|
||||
# Validate structural integrity (FK, chain refs, chain completeness).
|
||||
# These errors indicate broken imported data — do NOT commit.
|
||||
print("\n Validating structural integrity...")
|
||||
|
||||
@@ -121,7 +121,15 @@ def _write_stamp(conn: sqlite3.Connection) -> None:
|
||||
a double-commit with the atlas data write that precedes it.
|
||||
"""
|
||||
schema_sha = _file_sha1(SYSTEMS_SCHEMA_PATH)
|
||||
generator_sha = _file_sha1(Path(__file__))
|
||||
# Hash all generate_atlas sources — must match GENERATOR_SOURCES in check-systems-db-stamp.
|
||||
_atlas_dir = Path(__file__).parent
|
||||
generator_sha = _file_sha1(
|
||||
Path(__file__),
|
||||
_atlas_dir / "gemma_naming.py",
|
||||
_atlas_dir / "naming_core.py",
|
||||
_atlas_dir / "import_heightmaps.py",
|
||||
_atlas_dir / "import_province_boundaries.py",
|
||||
)
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO meta
|
||||
(generator_name, schema_version, generator_sha, generated_at)
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
import_city_names.py — Populate atlas_city_names from wiki markers.json content.
|
||||
|
||||
For each inhabited body, reads city records from markers.json and inserts rows
|
||||
into atlas_city_names with:
|
||||
- name, kind, population from markers.json
|
||||
- economic_role from bodies table
|
||||
- corp_id from corporations.headquarters_body cross-reference (#909)
|
||||
|
||||
Incremental: clears and reimports all rows for each body on every run (the
|
||||
table has no stable local IDs — city identity is name × body_id). Use --body
|
||||
to restrict to a single body.
|
||||
|
||||
Usage:
|
||||
tooling/planet-gen/import_city_names.py
|
||||
tooling/planet-gen/import_city_names.py --body GJ380c
|
||||
tooling/planet-gen/import_city_names.py --dry-run
|
||||
|
||||
Exit codes:
|
||||
0 completed
|
||||
1 fatal error (missing DB, schema error)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
TOOLING_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
||||
|
||||
_venv_python = REPO_ROOT / ".venv" / "bin" / "python"
|
||||
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
|
||||
import os
|
||||
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
|
||||
|
||||
import sqlite3
|
||||
|
||||
from generate_atlas import (
|
||||
DB_PATH,
|
||||
ensure_atlas_schema,
|
||||
query_inhabited_bodies,
|
||||
)
|
||||
|
||||
|
||||
def _build_hq_index(conn: sqlite3.Connection) -> dict[str, str]:
|
||||
"""Build a mapping of body_id → corp_id for all corp HQ locations."""
|
||||
rows = conn.execute(
|
||||
"SELECT headquarters_body, corp_id FROM corporations "
|
||||
"WHERE headquarters_body IS NOT NULL"
|
||||
).fetchall()
|
||||
index: dict[str, str] = {}
|
||||
for body_id, corp_id in rows:
|
||||
# If multiple corps have the same HQ body, take the first (alphabetical
|
||||
# corp_id for determinism). This is unlikely but safe.
|
||||
if body_id not in index:
|
||||
index[body_id] = corp_id
|
||||
return index
|
||||
|
||||
|
||||
def _load_city_records(body_dir: Path) -> list[dict]:
|
||||
"""Load named city records from markers.json. Returns empty list if none."""
|
||||
markers_path = body_dir / "markers.json"
|
||||
if not markers_path.exists():
|
||||
return []
|
||||
try:
|
||||
markers = json.loads(markers_path.read_text())
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return [
|
||||
c for c in (markers.get("cities") or [])
|
||||
if c.get("name") and isinstance(c["name"], str) and c["name"].strip()
|
||||
]
|
||||
|
||||
|
||||
def import_body_cities(
|
||||
body_info: dict,
|
||||
conn: sqlite3.Connection,
|
||||
hq_index: dict[str, str],
|
||||
dry_run: bool,
|
||||
verbose: bool,
|
||||
) -> dict:
|
||||
"""Import atlas_city_names rows for one body.
|
||||
|
||||
Returns a dict with:
|
||||
status: 'imported' | 'no_cities' | 'error'
|
||||
imported: count of rows written
|
||||
message: detail (on error)
|
||||
"""
|
||||
body_id = body_info["body_id"]
|
||||
terrain_ref = body_info["terrain_reference"]
|
||||
economic_role = body_info.get("economic_role") or "unknown"
|
||||
corp_id = hq_index.get(body_id)
|
||||
|
||||
body_dir = REPO_ROOT / Path(terrain_ref).parent
|
||||
cities = _load_city_records(body_dir)
|
||||
|
||||
if not cities:
|
||||
return {"status": "no_cities", "imported": 0}
|
||||
|
||||
if verbose:
|
||||
print(f" {body_id}: {len(cities)} cities, economic_role={economic_role}"
|
||||
+ (f", corp_hq={corp_id}" if corp_id else ""))
|
||||
|
||||
if not dry_run:
|
||||
# Full rebuild for this body: delete existing rows, re-insert.
|
||||
conn.execute("DELETE FROM atlas_city_names WHERE body_id = ?", (body_id,))
|
||||
|
||||
for city in cities:
|
||||
name = city["name"].strip()
|
||||
kind = city.get("kind") or "city"
|
||||
population = int(city.get("population") or 0)
|
||||
# Only set corp_id on the capital city of a corp HQ body.
|
||||
city_corp_id = corp_id if (kind == "capital" and corp_id) else None
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO atlas_city_names
|
||||
(body_id, name, kind, economic_role, population, corp_id,
|
||||
reserved, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, datetime('now'))""",
|
||||
(body_id, name, kind, economic_role, population, city_corp_id),
|
||||
)
|
||||
|
||||
return {"status": "imported", "imported": len(cities)}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Populate atlas_city_names from wiki markers.json (#908, #909)"
|
||||
)
|
||||
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db")
|
||||
parser.add_argument("--body", help="Process only this body_id")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Read and validate without writing to DB")
|
||||
parser.add_argument("--verbose", action="store_true",
|
||||
help="Print per-body detail")
|
||||
args = parser.parse_args()
|
||||
|
||||
db_path = Path(args.db)
|
||||
if not db_path.exists():
|
||||
print(f"error: {db_path} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n City Names Import (#908 + #909)")
|
||||
print(f" DB: {db_path}")
|
||||
if args.dry_run:
|
||||
print(f" Mode: DRY RUN (no DB writes)")
|
||||
print()
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
ensure_atlas_schema(conn)
|
||||
|
||||
hq_index = _build_hq_index(conn)
|
||||
|
||||
bodies = query_inhabited_bodies(conn)
|
||||
if args.body:
|
||||
bodies = [b for b in bodies if b["body_id"] == args.body]
|
||||
if not bodies:
|
||||
print(f"error: body '{args.body}' not found or has no terrain_reference",
|
||||
file=sys.stderr)
|
||||
conn.close()
|
||||
sys.exit(1)
|
||||
|
||||
print(f" {len(bodies)} inhabited bodies with terrain_reference")
|
||||
print(f" {len(hq_index)} corp HQ body mappings\n")
|
||||
|
||||
t_total = time.time()
|
||||
n_imported = 0
|
||||
n_no_cities = 0
|
||||
n_errors = 0
|
||||
total_rows = 0
|
||||
|
||||
for i, body_info in enumerate(bodies):
|
||||
body_id = body_info["body_id"]
|
||||
|
||||
result = import_body_cities(body_info, conn, hq_index, args.dry_run, args.verbose)
|
||||
status = result["status"]
|
||||
|
||||
if status == "imported":
|
||||
n_imported += 1
|
||||
total_rows += result["imported"]
|
||||
if args.verbose:
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} {result['imported']} cities")
|
||||
elif status == "no_cities":
|
||||
n_no_cities += 1
|
||||
elif status == "error":
|
||||
n_errors += 1
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}")
|
||||
|
||||
if not args.dry_run:
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
|
||||
elapsed = time.time() - t_total
|
||||
print(f"\n Done in {elapsed:.1f}s")
|
||||
print(f" bodies_with_cities={n_imported} no_cities={n_no_cities} "
|
||||
f"errors={n_errors} total_rows={total_rows}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
import_heightmaps.py — Import terrain elevation grids into atlas_body_heightmaps.
|
||||
|
||||
For each inhabited body with a terrain_reference, simulates the terrain via
|
||||
planet_simulation.simulate() and stores the float32 LE elevation BLOB plus
|
||||
sea_level metadata in atlas_body_heightmaps (#906, D-202).
|
||||
|
||||
The BLOB format matches the Rust loader spec (D-202):
|
||||
- float32 little-endian, row-major
|
||||
- width × height values, each in [0.0, 1.0]
|
||||
- width = GRID_W (512), height = GRID_H (256)
|
||||
|
||||
Incremental: bodies that already have a row in atlas_body_heightmaps are
|
||||
skipped unless --force is passed.
|
||||
|
||||
Usage:
|
||||
tooling/planet-gen/import_heightmaps.py
|
||||
tooling/planet-gen/import_heightmaps.py --body GJ380c
|
||||
tooling/planet-gen/import_heightmaps.py --force
|
||||
tooling/planet-gen/import_heightmaps.py --dry-run
|
||||
|
||||
Exit codes:
|
||||
0 completed (possibly with skipped or errored bodies)
|
||||
1 fatal error (missing DB, schema error)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
TOOLING_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
||||
|
||||
_venv_python = REPO_ROOT / ".venv" / "bin" / "python"
|
||||
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
|
||||
import os
|
||||
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
|
||||
|
||||
import numpy as np
|
||||
import sqlite3
|
||||
|
||||
from generate_atlas import (
|
||||
GRID_W,
|
||||
GRID_H,
|
||||
DB_PATH,
|
||||
ensure_atlas_schema,
|
||||
load_body_def,
|
||||
query_inhabited_bodies,
|
||||
)
|
||||
from planet_simulation import simulate
|
||||
|
||||
|
||||
def _elevation_to_blob(elevation: np.ndarray) -> bytes:
|
||||
"""Convert a float32 elevation grid to a little-endian BLOB."""
|
||||
arr = elevation.astype("<f4") # float32 LE, explicit
|
||||
assert arr.shape == (GRID_H, GRID_W), (
|
||||
f"elevation shape {arr.shape} does not match expected ({GRID_H}, {GRID_W})"
|
||||
)
|
||||
return arr.tobytes()
|
||||
|
||||
|
||||
def import_body(
|
||||
body_info: dict,
|
||||
conn: sqlite3.Connection,
|
||||
force: bool,
|
||||
dry_run: bool,
|
||||
verbose: bool,
|
||||
) -> dict:
|
||||
"""Import heightmap BLOB for one body.
|
||||
|
||||
Returns a dict with:
|
||||
status: 'imported' | 'skipped' | 'gas_giant' | 'error'
|
||||
message: detail (on error or skip)
|
||||
"""
|
||||
body_id = body_info["body_id"]
|
||||
terrain_ref = body_info["terrain_reference"]
|
||||
|
||||
# Incremental check — skip if already imported
|
||||
if not force:
|
||||
existing = conn.execute(
|
||||
"SELECT 1 FROM atlas_body_heightmaps WHERE body_id = ?", (body_id,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
return {"status": "skipped", "message": "already imported"}
|
||||
|
||||
body_dir = REPO_ROOT / Path(terrain_ref).parent
|
||||
if not body_dir.exists():
|
||||
return {"status": "error", "message": f"body_dir not found: {body_dir}"}
|
||||
|
||||
bd = load_body_def(body_dir)
|
||||
if not bd:
|
||||
return {"status": "error", "message": f"no body definition found in {body_dir}"}
|
||||
|
||||
try:
|
||||
terrain = simulate(bd)
|
||||
except Exception as exc:
|
||||
return {"status": "error", "message": f"simulate() failed: {exc}"}
|
||||
|
||||
if not terrain:
|
||||
return {"status": "gas_giant"}
|
||||
|
||||
elevation = terrain.get("elevation")
|
||||
if elevation is None:
|
||||
return {"status": "error", "message": "terrain dict missing 'elevation' key"}
|
||||
|
||||
sea_level = float(terrain.get("sea_level", 0.0))
|
||||
blob = _elevation_to_blob(elevation)
|
||||
|
||||
if verbose:
|
||||
land_pct = float(np.mean(elevation >= sea_level)) * 100
|
||||
print(f" {body_id}: {GRID_W}x{GRID_H} grid, sea_level={sea_level:.3f}, "
|
||||
f"land={land_pct:.1f}%, blob={len(blob)} bytes")
|
||||
|
||||
if not dry_run:
|
||||
conn.execute(
|
||||
"""INSERT INTO atlas_body_heightmaps
|
||||
(body_id, width, height, data, sea_level, imported_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(body_id) DO UPDATE SET
|
||||
width = excluded.width,
|
||||
height = excluded.height,
|
||||
data = excluded.data,
|
||||
sea_level = excluded.sea_level,
|
||||
imported_at = excluded.imported_at""",
|
||||
(body_id, GRID_W, GRID_H, blob, sea_level),
|
||||
)
|
||||
|
||||
return {"status": "imported"}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Import terrain elevation BLOBs into atlas_body_heightmaps (#906)"
|
||||
)
|
||||
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db")
|
||||
parser.add_argument("--body", help="Process only this body_id")
|
||||
parser.add_argument("--force", action="store_true",
|
||||
help="Re-import even if a row already exists")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Simulate without writing to DB")
|
||||
parser.add_argument("--verbose", action="store_true",
|
||||
help="Print per-body detail")
|
||||
args = parser.parse_args()
|
||||
|
||||
db_path = Path(args.db)
|
||||
if not db_path.exists():
|
||||
print(f"error: {db_path} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n Heightmap BLOB Import (#906)")
|
||||
print(f" DB: {db_path}")
|
||||
if args.dry_run:
|
||||
print(f" Mode: DRY RUN (no DB writes)")
|
||||
if args.force:
|
||||
print(f" Force: enabled (will overwrite existing rows)")
|
||||
print()
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
ensure_atlas_schema(conn)
|
||||
|
||||
bodies = query_inhabited_bodies(conn)
|
||||
if args.body:
|
||||
bodies = [b for b in bodies if b["body_id"] == args.body]
|
||||
if not bodies:
|
||||
print(f"error: body '{args.body}' not found or has no terrain_reference",
|
||||
file=sys.stderr)
|
||||
conn.close()
|
||||
sys.exit(1)
|
||||
|
||||
print(f" {len(bodies)} inhabited bodies with terrain_reference\n")
|
||||
|
||||
t_total = time.time()
|
||||
n_imported = 0
|
||||
n_skipped = 0
|
||||
n_gas = 0
|
||||
n_errors = 0
|
||||
|
||||
for i, body_info in enumerate(bodies):
|
||||
body_id = body_info["body_id"]
|
||||
t0 = time.time()
|
||||
|
||||
result = import_body(body_info, conn, args.force, args.dry_run, args.verbose)
|
||||
elapsed = time.time() - t0
|
||||
status = result["status"]
|
||||
|
||||
if status == "imported":
|
||||
n_imported += 1
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} imported ({elapsed:.1f}s)")
|
||||
elif status == "skipped":
|
||||
n_skipped += 1
|
||||
if args.verbose:
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} skipped (already imported)")
|
||||
elif status == "gas_giant":
|
||||
n_gas += 1
|
||||
if args.verbose:
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} gas giant — no surface")
|
||||
elif status == "error":
|
||||
n_errors += 1
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}")
|
||||
|
||||
if not args.dry_run:
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
|
||||
elapsed_total = time.time() - t_total
|
||||
print(f"\n Done in {elapsed_total:.1f}s")
|
||||
print(f" imported={n_imported} skipped={n_skipped} "
|
||||
f"gas_giant={n_gas} errors={n_errors}")
|
||||
|
||||
if n_errors > 0:
|
||||
print(f"\n {n_errors} error(s) — check output above", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,544 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
import_province_boundaries.py — Pre-compute province boundaries from watershed analysis.
|
||||
|
||||
For each inhabited body with a heightmap row in atlas_body_heightmaps, runs D8
|
||||
drainage analysis to derive drainage basin boundaries and stores them as pixel-space
|
||||
polylines in atlas_province_boundaries (D-205, D-208, #907).
|
||||
|
||||
Algorithm:
|
||||
1. Load float32 elevation BLOB from atlas_body_heightmaps.
|
||||
2. Depression-fill: raise sinks to the lowest-outlet neighbor (iterative).
|
||||
3. D8 flow direction: assign each cell to its steepest-descent neighbor.
|
||||
4. Flow accumulation: upstream cell count per cell (topological sort).
|
||||
5. Basin labeling: seed a basin per pour-point (flow-accumulation > threshold);
|
||||
flood-fill remaining cells following flow direction.
|
||||
6. Merge small basins (< 2% area) into the largest adjacent basin.
|
||||
7. Clamp basin count to [4, 12] by iterative merging of smallest basins.
|
||||
8. Trace boundary polylines between adjacent basins.
|
||||
9. Upsert rows into atlas_province_boundaries.
|
||||
|
||||
Province count target: 4–12 per body (D-205). Bodies with low relief get fewer,
|
||||
larger provinces; high-relief worlds get more.
|
||||
|
||||
Incremental: bodies that already have rows in atlas_province_boundaries are skipped
|
||||
unless --force is passed.
|
||||
|
||||
Usage:
|
||||
tooling/planet-gen/import_province_boundaries.py
|
||||
tooling/planet-gen/import_province_boundaries.py --body GJ380c
|
||||
tooling/planet-gen/import_province_boundaries.py --force
|
||||
tooling/planet-gen/import_province_boundaries.py --dry-run
|
||||
|
||||
Exit codes:
|
||||
0 completed
|
||||
1 fatal error (missing DB, schema error)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
TOOLING_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
||||
|
||||
_venv_python = REPO_ROOT / ".venv" / "bin" / "python"
|
||||
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
|
||||
import os
|
||||
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
|
||||
|
||||
import numpy as np
|
||||
import sqlite3
|
||||
|
||||
from generate_atlas import (
|
||||
GRID_W,
|
||||
GRID_H,
|
||||
DB_PATH,
|
||||
ensure_atlas_schema,
|
||||
query_inhabited_bodies,
|
||||
)
|
||||
|
||||
# D8 neighbor offsets: (dr, dc)
|
||||
_D8 = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
|
||||
|
||||
# River threshold from D-208: cells with flow_accumulation > 200 are river cells.
|
||||
# Province seeds are local flow-accumulation maxima (watershed pour points).
|
||||
_FLOW_THRESHOLD = 200
|
||||
|
||||
# Minimum basin area as fraction of total cells before merging into neighbor.
|
||||
_MIN_BASIN_FRAC = 0.02
|
||||
|
||||
_PROVINCE_MIN = 4
|
||||
_PROVINCE_MAX = 12
|
||||
|
||||
|
||||
def _load_elevation(body_id: str, conn: sqlite3.Connection) -> np.ndarray | None:
|
||||
"""Load float32 LE elevation BLOB from atlas_body_heightmaps."""
|
||||
row = conn.execute(
|
||||
"SELECT data, width, height FROM atlas_body_heightmaps WHERE body_id = ?",
|
||||
(body_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
data, width, height = row
|
||||
arr = np.frombuffer(data, dtype="<f4").reshape((height, width))
|
||||
return arr.astype(np.float32)
|
||||
|
||||
|
||||
def _depression_fill(elev: np.ndarray) -> np.ndarray:
|
||||
"""Simple iterative depression fill: raise sinks to their lowest outlet.
|
||||
|
||||
Uses a shallow iterative pass — good enough for province-scale basins on
|
||||
512×256 grids. Not full priority-flood (which is O(N log N)); this O(N·k)
|
||||
approach converges in ≤10 passes on real heightmaps.
|
||||
"""
|
||||
H, W = elev.shape
|
||||
filled = elev.copy()
|
||||
for _ in range(10):
|
||||
changed = False
|
||||
for r in range(1, H - 1):
|
||||
for c in range(W):
|
||||
nbr_min = float("inf")
|
||||
for dr, dc in _D8:
|
||||
nr = r + dr
|
||||
nc = (c + dc) % W
|
||||
if 0 <= nr < H:
|
||||
nbr_min = min(nbr_min, filled[nr, nc])
|
||||
if filled[r, c] < nbr_min:
|
||||
filled[r, c] = nbr_min + 1e-6
|
||||
changed = True
|
||||
if not changed:
|
||||
break
|
||||
return filled
|
||||
|
||||
|
||||
def _flow_direction(filled: np.ndarray) -> np.ndarray:
|
||||
"""D8 flow direction: index into _D8 (0–7), or -1 for no outflow (edge/flat)."""
|
||||
H, W = filled.shape
|
||||
fdir = np.full((H, W), -1, dtype=np.int8)
|
||||
for r in range(H):
|
||||
for c in range(W):
|
||||
best_drop = 0.0
|
||||
best_k = -1
|
||||
for k, (dr, dc) in enumerate(_D8):
|
||||
nr = r + dr
|
||||
nc = (c + dc) % W
|
||||
if nr < 0 or nr >= H:
|
||||
continue
|
||||
drop = filled[r, c] - filled[nr, nc]
|
||||
if drop > best_drop:
|
||||
best_drop = drop
|
||||
best_k = k
|
||||
fdir[r, c] = best_k
|
||||
return fdir
|
||||
|
||||
|
||||
def _flow_accumulation(fdir: np.ndarray) -> np.ndarray:
|
||||
"""Flow accumulation via topological sort of the D8 DAG."""
|
||||
H, W = fdir.shape
|
||||
in_degree = np.zeros((H, W), dtype=np.int32)
|
||||
|
||||
for r in range(H):
|
||||
for c in range(W):
|
||||
k = int(fdir[r, c])
|
||||
if k < 0:
|
||||
continue
|
||||
dr, dc = _D8[k]
|
||||
nr = r + dr
|
||||
nc = (c + dc) % W
|
||||
if 0 <= nr < H:
|
||||
in_degree[nr, nc] += 1
|
||||
|
||||
from collections import deque
|
||||
queue = deque()
|
||||
for r in range(H):
|
||||
for c in range(W):
|
||||
if in_degree[r, c] == 0:
|
||||
queue.append((r, c))
|
||||
|
||||
accum = np.ones((H, W), dtype=np.int32)
|
||||
while queue:
|
||||
r, c = queue.popleft()
|
||||
k = int(fdir[r, c])
|
||||
if k < 0:
|
||||
continue
|
||||
dr, dc = _D8[k]
|
||||
nr = r + dr
|
||||
nc = (c + dc) % W
|
||||
if 0 <= nr < H:
|
||||
accum[nr, nc] += accum[r, c]
|
||||
in_degree[nr, nc] -= 1
|
||||
if in_degree[nr, nc] == 0:
|
||||
queue.append((nr, nc))
|
||||
|
||||
return accum
|
||||
|
||||
|
||||
def _label_basins(fdir: np.ndarray, accum: np.ndarray) -> np.ndarray:
|
||||
"""Label each cell with a basin ID via pour-point flood fill.
|
||||
|
||||
Pour points are local flow-accumulation maxima above the river threshold.
|
||||
Each pour point seeds a basin; remaining cells are labeled by tracing
|
||||
flow direction back to their pour-point seed.
|
||||
"""
|
||||
H, W = fdir.shape
|
||||
labels = np.full((H, W), -1, dtype=np.int32)
|
||||
|
||||
# Seed one label per local accum maximum above threshold.
|
||||
# Use a simple scan: a cell is a local maximum if no neighbor has higher accum.
|
||||
pour_pts: list[tuple[int, int]] = []
|
||||
for r in range(H):
|
||||
for c in range(W):
|
||||
if accum[r, c] <= _FLOW_THRESHOLD:
|
||||
continue
|
||||
is_max = True
|
||||
for dr, dc in _D8:
|
||||
nr = r + dr
|
||||
nc = (c + dc) % W
|
||||
if 0 <= nr < H and accum[nr, nc] > accum[r, c]:
|
||||
is_max = False
|
||||
break
|
||||
if is_max:
|
||||
pour_pts.append((r, c))
|
||||
|
||||
# If no pour points (e.g. flat/ocean world), create a single basin.
|
||||
if not pour_pts:
|
||||
labels[:] = 0
|
||||
return labels
|
||||
|
||||
for basin_id, (r, c) in enumerate(pour_pts):
|
||||
labels[r, c] = basin_id
|
||||
|
||||
# BFS flood: for each unlabeled cell, follow flow direction until a labeled
|
||||
# cell is reached; assign that label back along the path.
|
||||
from collections import deque
|
||||
|
||||
def _trace(r0: int, c0: int) -> int:
|
||||
path: list[tuple[int, int]] = []
|
||||
r, c = r0, c0
|
||||
for _ in range(H * W):
|
||||
if labels[r, c] >= 0:
|
||||
lbl = labels[r, c]
|
||||
for pr, pc in path:
|
||||
labels[pr, pc] = lbl
|
||||
return lbl
|
||||
path.append((r, c))
|
||||
k = int(fdir[r, c])
|
||||
if k < 0:
|
||||
# No outflow — assign basin 0
|
||||
lbl = 0
|
||||
for pr, pc in path:
|
||||
labels[pr, pc] = lbl
|
||||
return lbl
|
||||
dr, dc = _D8[k]
|
||||
nr = r + dr
|
||||
nc = (c + dc) % W
|
||||
if nr < 0 or nr >= H:
|
||||
lbl = 0
|
||||
for pr, pc in path:
|
||||
labels[pr, pc] = lbl
|
||||
return lbl
|
||||
r, c = nr, nc
|
||||
# Cycle guard
|
||||
lbl = 0
|
||||
for pr, pc in path:
|
||||
labels[pr, pc] = lbl
|
||||
return lbl
|
||||
|
||||
for r in range(H):
|
||||
for c in range(W):
|
||||
if labels[r, c] < 0:
|
||||
_trace(r, c)
|
||||
|
||||
return labels
|
||||
|
||||
|
||||
def _merge_small_basins(
|
||||
labels: np.ndarray, target_min: int, target_max: int
|
||||
) -> np.ndarray:
|
||||
"""Merge tiny basins into their largest neighbor until count is in [target_min, target_max]."""
|
||||
H, W = labels.shape
|
||||
labels = labels.copy()
|
||||
|
||||
def _basin_sizes() -> dict[int, int]:
|
||||
ids, counts = np.unique(labels, return_counts=True)
|
||||
return dict(zip(ids.tolist(), counts.tolist()))
|
||||
|
||||
def _neighbors(basin_id: int) -> set[int]:
|
||||
mask = labels == basin_id
|
||||
# Dilate mask by 1 pixel in each direction, find adjacent basin IDs.
|
||||
nbrs: set[int] = set()
|
||||
rs, cs = np.where(mask)
|
||||
for r, c in zip(rs.tolist(), cs.tolist()):
|
||||
for dr, dc in _D8:
|
||||
nr = r + dr
|
||||
nc = (c + dc) % W
|
||||
if 0 <= nr < H:
|
||||
nbr_id = int(labels[nr, nc])
|
||||
if nbr_id != basin_id:
|
||||
nbrs.add(nbr_id)
|
||||
return nbrs
|
||||
|
||||
total = H * W
|
||||
for _ in range(200):
|
||||
sizes = _basin_sizes()
|
||||
n_basins = len(sizes)
|
||||
if n_basins <= target_max and all(
|
||||
v / total >= _MIN_BASIN_FRAC for v in sizes.values()
|
||||
):
|
||||
break
|
||||
if n_basins <= target_min:
|
||||
break
|
||||
|
||||
# Find the smallest basin
|
||||
smallest_id = min(sizes, key=lambda b: sizes[b])
|
||||
smallest_frac = sizes[smallest_id] / total
|
||||
|
||||
if n_basins <= target_max and smallest_frac >= _MIN_BASIN_FRAC:
|
||||
break
|
||||
|
||||
# Merge into its largest neighbor
|
||||
nbrs = _neighbors(smallest_id)
|
||||
if not nbrs:
|
||||
break
|
||||
merge_into = max(nbrs, key=lambda b: sizes.get(b, 0))
|
||||
labels[labels == smallest_id] = merge_into
|
||||
|
||||
# Re-number contiguously from 0
|
||||
unique_ids = sorted(np.unique(labels).tolist())
|
||||
remap = {old: new for new, old in enumerate(unique_ids)}
|
||||
new_labels = np.zeros_like(labels)
|
||||
for old, new in remap.items():
|
||||
new_labels[labels == old] = new
|
||||
return new_labels
|
||||
|
||||
|
||||
def _trace_boundary(labels: np.ndarray, basin_id: int) -> list[list[int]]:
|
||||
"""Trace the outer boundary of a basin as a pixel-space polyline.
|
||||
|
||||
Returns a list of [row, col] points forming the boundary polygon.
|
||||
Uses a simple contour walk: find all boundary cells (cells adjacent to a
|
||||
different basin), then sort them by angle from centroid to approximate a
|
||||
closed polygon.
|
||||
"""
|
||||
H, W = labels.shape
|
||||
mask = labels == basin_id
|
||||
|
||||
# Boundary cells: in this basin AND adjacent to a different basin
|
||||
boundary: list[tuple[int, int]] = []
|
||||
rs, cs = np.where(mask)
|
||||
for r, c in zip(rs.tolist(), cs.tolist()):
|
||||
on_boundary = False
|
||||
for dr, dc in _D8:
|
||||
nr = r + dr
|
||||
nc = (c + dc) % W
|
||||
if nr < 0 or nr >= H:
|
||||
on_boundary = True
|
||||
break
|
||||
if labels[nr, nc] != basin_id:
|
||||
on_boundary = True
|
||||
break
|
||||
if on_boundary:
|
||||
boundary.append((r, c))
|
||||
|
||||
if not boundary:
|
||||
return []
|
||||
|
||||
# Sort by angle from centroid — produces a rough polygon outline.
|
||||
arr = np.array(boundary, dtype=np.float32)
|
||||
centroid_r = float(np.mean(arr[:, 0]))
|
||||
centroid_c = float(np.mean(arr[:, 1]))
|
||||
angles = np.arctan2(arr[:, 0] - centroid_r, arr[:, 1] - centroid_c)
|
||||
order = np.argsort(angles)
|
||||
|
||||
# Subsample if very large — keep at most 500 points for storage efficiency.
|
||||
pts = [boundary[i] for i in order.tolist()]
|
||||
if len(pts) > 500:
|
||||
step = len(pts) // 500
|
||||
pts = pts[::step]
|
||||
|
||||
return [[r, c] for r, c in pts]
|
||||
|
||||
|
||||
def compute_province_boundaries(
|
||||
body_id: str, elevation: np.ndarray
|
||||
) -> list[dict]:
|
||||
"""Run full watershed analysis; return list of basin dicts.
|
||||
|
||||
Each dict:
|
||||
basin_id: int
|
||||
path: JSON-serialisable [[row, col], ...]
|
||||
area_pct: float
|
||||
"""
|
||||
H, W = elevation.shape
|
||||
total_cells = H * W
|
||||
|
||||
filled = _depression_fill(elevation)
|
||||
fdir = _flow_direction(filled)
|
||||
accum = _flow_accumulation(fdir)
|
||||
labels = _label_basins(fdir, accum)
|
||||
labels = _merge_small_basins(labels, _PROVINCE_MIN, _PROVINCE_MAX)
|
||||
|
||||
unique_ids = sorted(np.unique(labels).tolist())
|
||||
basins = []
|
||||
for basin_id in unique_ids:
|
||||
count = int(np.sum(labels == basin_id))
|
||||
area_pct = count / total_cells
|
||||
path = _trace_boundary(labels, basin_id)
|
||||
if not path:
|
||||
continue
|
||||
basins.append({
|
||||
"basin_id": basin_id,
|
||||
"path": path,
|
||||
"area_pct": area_pct,
|
||||
})
|
||||
|
||||
return basins
|
||||
|
||||
|
||||
def import_body_provinces(
|
||||
body_id: str,
|
||||
conn: sqlite3.Connection,
|
||||
force: bool,
|
||||
dry_run: bool,
|
||||
verbose: bool,
|
||||
) -> dict:
|
||||
"""Import province boundary rows for one body.
|
||||
|
||||
Returns dict:
|
||||
status: 'imported' | 'skipped' | 'no_heightmap' | 'error'
|
||||
imported: count of basins written
|
||||
message: detail on error/skip
|
||||
"""
|
||||
if not force:
|
||||
existing = conn.execute(
|
||||
"SELECT COUNT(*) FROM atlas_province_boundaries WHERE body_id = ?",
|
||||
(body_id,),
|
||||
).fetchone()[0]
|
||||
if existing > 0:
|
||||
return {"status": "skipped", "imported": 0,
|
||||
"message": f"already has {existing} rows"}
|
||||
|
||||
elevation = _load_elevation(body_id, conn)
|
||||
if elevation is None:
|
||||
return {"status": "no_heightmap", "imported": 0,
|
||||
"message": "no row in atlas_body_heightmaps"}
|
||||
|
||||
try:
|
||||
basins = compute_province_boundaries(body_id, elevation)
|
||||
except Exception as exc:
|
||||
return {"status": "error", "imported": 0, "message": str(exc)}
|
||||
|
||||
if not basins:
|
||||
return {"status": "error", "imported": 0,
|
||||
"message": "no basins produced from watershed analysis"}
|
||||
|
||||
if verbose:
|
||||
areas = [f"{b['basin_id']}:{b['area_pct']:.1%}" for b in basins]
|
||||
print(f" {body_id}: {len(basins)} basins — {', '.join(areas)}")
|
||||
|
||||
if not dry_run:
|
||||
conn.execute(
|
||||
"DELETE FROM atlas_province_boundaries WHERE body_id = ?",
|
||||
(body_id,),
|
||||
)
|
||||
for b in basins:
|
||||
conn.execute(
|
||||
"""INSERT INTO atlas_province_boundaries
|
||||
(body_id, basin_id, path, area_pct)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(body_id, b["basin_id"], json.dumps(b["path"]), b["area_pct"]),
|
||||
)
|
||||
|
||||
return {"status": "imported", "imported": len(basins)}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Pre-compute province boundaries from watershed analysis (D-205, #907)"
|
||||
)
|
||||
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db")
|
||||
parser.add_argument("--body", help="Process only this body_id")
|
||||
parser.add_argument("--force", action="store_true",
|
||||
help="Re-import even if rows already exist")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Analyse without writing to DB")
|
||||
parser.add_argument("--verbose", action="store_true",
|
||||
help="Print per-body detail")
|
||||
args = parser.parse_args()
|
||||
|
||||
db_path = Path(args.db)
|
||||
if not db_path.exists():
|
||||
print(f"error: {db_path} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n Province Boundary Import (#907)")
|
||||
print(f" DB: {db_path}")
|
||||
if args.dry_run:
|
||||
print(f" Mode: DRY RUN (no DB writes)")
|
||||
if args.force:
|
||||
print(f" Force: enabled (will overwrite existing rows)")
|
||||
print()
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
ensure_atlas_schema(conn)
|
||||
|
||||
bodies = query_inhabited_bodies(conn)
|
||||
if args.body:
|
||||
bodies = [b for b in bodies if b["body_id"] == args.body]
|
||||
if not bodies:
|
||||
print(f"error: body '{args.body}' not found or has no terrain_reference",
|
||||
file=sys.stderr)
|
||||
conn.close()
|
||||
sys.exit(1)
|
||||
|
||||
print(f" {len(bodies)} inhabited bodies with terrain_reference\n")
|
||||
|
||||
t_total = time.time()
|
||||
n_imported = 0
|
||||
n_skipped = 0
|
||||
n_no_hmap = 0
|
||||
n_errors = 0
|
||||
|
||||
for i, body_info in enumerate(bodies):
|
||||
body_id = body_info["body_id"]
|
||||
t0 = time.time()
|
||||
|
||||
result = import_body_provinces(body_id, conn, args.force, args.dry_run, args.verbose)
|
||||
elapsed = time.time() - t0
|
||||
status = result["status"]
|
||||
|
||||
if status == "imported":
|
||||
n_imported += 1
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} {result['imported']} basins ({elapsed:.1f}s)")
|
||||
elif status == "skipped":
|
||||
n_skipped += 1
|
||||
if args.verbose:
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} skipped ({result['message']})")
|
||||
elif status == "no_heightmap":
|
||||
n_no_hmap += 1
|
||||
if args.verbose:
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} no heightmap — skipping")
|
||||
elif status == "error":
|
||||
n_errors += 1
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}")
|
||||
|
||||
if not args.dry_run:
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
|
||||
elapsed_total = time.time() - t_total
|
||||
print(f"\n Done in {elapsed_total:.1f}s")
|
||||
print(f" imported={n_imported} skipped={n_skipped} "
|
||||
f"no_heightmap={n_no_hmap} errors={n_errors}")
|
||||
|
||||
if n_errors > 0:
|
||||
print(f"\n {n_errors} error(s) — check output above", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user