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:
2026-05-02 18:40:33 +02:00
co-authored by Claude Opus 4.6
parent d57f0566d2
commit b6ca785f74
7 changed files with 1354 additions and 2 deletions
+57
View File
@@ -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.
+320 -1
View File
@@ -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...")
+9 -1
View File
@@ -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)
+205
View File
@@ -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()
+219
View File
@@ -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: 412 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 (07), 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()