Files
settled-reach/tooling/assign-astro-ids.py
T
jpmschweitzerandClaude Fable 5 346d87df7a chore(meta): docs/build sweep + tooling test gate (T-1069, T-1066)
- make test-tooling: planet-gen determinism guard + import_economics
  --dry-run, wired into pre-push on TOOLING_CHANGED; ruff widened to
  E4/E7/E9/F/W (90 safe auto-fixes applied; E402/E702/F841 ignored with
  documented counts)
- one-generator reality fixed in DEVOPS.md, asset-pipeline rule, CLAUDE.md
  (import_economics sole generator since #951/D-223); dead check-protocol
  target deleted; DEVOPS hook/config sections rewritten from the actual
  hook sources; team-patterns gate description updated (client+tooling)
- project.yaml: 0.2.0 → 0.4.0 per the 0.{phase}.{n} scheme, description
  refreshed from the v0.1 Sova narration to cascade reality
- stale comment sweep: voxel.rs stub claims (all 8 families implemented),
  cascade.rs TODO recited to T-1044, main.rs D-192 handshake claim,
  relationships.rs/chunk_streaming.rs version targets → phase language
- gitignore: client/settings.db* e2e-run artifacts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:22:55 +02:00

276 lines
9.6 KiB
Python

#!/usr/bin/env python3
"""
assign-astro-ids.py
Assigns real astronomical_id values to all 300 systems in star-map.json.
All 300 systems get REAL Gliese-Jahreiss designations from the nearest
300 GJ-cataloged stars (sourced from HYG Database v4.1).
Assignment logic:
- Gateway (S-001) pinned to GJ 71 (Tau Ceti)
- Systems sorted by hop distance from Gateway + topology rank
- Real stars sorted by distance from Sol
- Closest stars → most central systems
- Binary/companion entries (A/B suffixes) handled: one system per star system
Also writes spectral class from the real catalog into each node.
"""
import json
from pathlib import Path
STAR_MAP_PATH = Path(__file__).parent.parent / "docs/design/star-map.json"
CATALOG_PATH = Path(__file__).parent / "wiki/gj-catalog-real.json"
# Pinned assignments: system_id -> gj_id
PINNED = {
"S-001": "GJ 71", # Tau Ceti — Gateway
}
# Stars to skip (companions that share a system with their primary)
SKIP_COMPANIONS = {"GJ 244B", "GJ 65B"} # Sirius B, Luyten 726-8 B
def topology_rank(node: dict) -> int:
scores = {"hub": 5, "junction": 4, "loop_member": 3, "through_route": 2, "spur_end": 1, "dead_end": 0}
return scores.get(node.get("gate_topology", ""), 0)
def simplify_spectral(spect: str) -> str:
"""Convert detailed spectral class to simplified star_type for the game.
Returns: G, K, M, F, A, binary, unusual
"""
if not spect:
return "M" # default for unknowns (most nearby stars are M-dwarfs)
s = spect.strip()
# Check first character for main spectral class
first = s[0].upper() if s else "?"
if first == "D":
# White dwarf — unusual
return "unusual"
if first == "S" and s.startswith("sd"):
# Subdwarf — map to the next character
first = s[2].upper() if len(s) > 2 else "?"
if first in ("M", "m"):
return "M"
if first == "K":
return "K"
if first == "G":
return "G"
if first == "F":
return "F"
if first == "A":
return "F" # A-types are rare; group with F for game purposes
if first in ("O", "B"):
return "unusual"
return "M" # default for unrecognized
def main():
# Load star map
with open(STAR_MAP_PATH, "r") as f:
data = json.load(f)
nodes = data["nodes"]
# Load real GJ catalog
with open(CATALOG_PATH, "r") as f:
catalog = json.load(f)
real_stars = catalog["stars"]
# Filter out companions and build available pool
pool = []
seen_gj = set()
for star in real_stars:
gj = star["gj_id"]
if gj in SKIP_COMPANIONS:
continue
if gj in seen_gj:
continue
seen_gj.add(gj)
pool.append(star)
print(f"Real GJ catalog: {len(real_stars)} entries, {len(pool)} unique after dedup")
print(f"Systems to assign: {len(nodes)}")
if len(pool) < len(nodes):
print(f"WARNING: Not enough real stars ({len(pool)}) for {len(nodes)} systems!")
print(f"Will need {len(nodes) - len(pool)} fabricated entries.")
# Sort pool by distance (already sorted, but be explicit)
pool.sort(key=lambda s: s["dist_pc"])
# Sort systems: hop distance ascending, topology rank descending
sorted_nodes = sorted(nodes, key=lambda n: (
n.get("hop_distance_from_gateway", 999),
-topology_rank(n),
n["system_id"]
))
# Build assignments
assignments: dict[str, dict] = {}
# Step 1: Pin fixed assignments
for sid, gj_id in PINNED.items():
star = next((s for s in pool if s["gj_id"] == gj_id), None)
if star:
assignments[sid] = star
else:
print(f"WARNING: Pinned star {gj_id} not found in catalog!")
# Step 2: Find a binary-type system for Alpha Centauri (GJ 559A)
alpha_a = next((s for s in pool if s["gj_id"] == "GJ 559A"), None)
if alpha_a:
binary_systems = [n for n in sorted_nodes
if n.get("star_type") == "binary"
and n["system_id"] not in assignments]
if binary_systems:
bs = binary_systems[0]
assignments[bs["system_id"]] = alpha_a
print(f" Binary: {bs['system_id']} -> GJ 559A (Alpha Centauri)")
# Step 3: Distance-first with habitability preference
#
# The 300 nearest GJ stars are our pool. We want distance to drive
# centrality (closest stars = core systems), but within each distance
# band, prefer G/K/F stars over M-dwarfs. This means the habitable
# stars get picked first at each distance layer, pushing the M-dwarfs
# to fill later (more peripheral) slots. The net effect: the web of
# systems with usable planets spreads wider through the network because
# we're cherry-picking the good ones from each distance shell.
#
# Implementation: sort the full pool by distance, but with a small
# penalty for M-dwarfs that pushes them down within their distance
# neighborhood without disrupting the overall distance ordering.
used_gj = {a["gj_id"] for a in assignments.values()}
remaining_pool = [s for s in pool if s["gj_id"] not in used_gj]
def habitability_penalty(star: dict) -> float:
"""Small distance penalty for less habitable star types.
G/K: no penalty (0 pc). These get picked first at their distance.
F: tiny penalty (0.5 pc). Still good, slight deprioritization.
M: moderate penalty (3 pc). Pushed down ~3 pc worth of slots.
unusual/WD: larger penalty (5 pc). Pushed further down.
At the scale of our pool (4-33 ly / 1.3-10 pc), a 3 pc penalty
means an M-dwarf at 5 pc sorts like it's at 8 pc — it gets
overtaken by G/K stars up to ~8 pc but stays ahead of G/K stars
at 10+ pc. This is a gentle preference, not a hard filter.
"""
sp = simplify_spectral(star.get("spect", ""))
if sp in ("G", "K"):
return 0.0
if sp == "F":
return 0.5
if sp == "M":
return 3.0
return 5.0 # unusual, white dwarfs
remaining_pool.sort(key=lambda s: s["dist_pc"] + habitability_penalty(s))
# Assign unassigned systems (sorted by importance) from the reordered pool
pool_idx = 0
for node in sorted_nodes:
sid = node["system_id"]
if sid in assignments:
continue
if pool_idx >= len(remaining_pool):
break
assignments[sid] = remaining_pool[pool_idx]
pool_idx += 1
# Step 4: If we ran out of real stars, fabricate the remainder
fabricated = 0
if len(assignments) < len(nodes):
# Generate fabricated GJ numbers in the 5000+ range
import random
rng = random.Random(20260313)
used_nums = set()
for a in assignments.values():
gj = a["gj_id"]
try:
num = int(gj.replace("GJ ", "").rstrip("ABC"))
used_nums.add(num)
except ValueError:
pass
for node in sorted_nodes:
sid = node["system_id"]
if sid in assignments:
continue
# Fabricate in 5000-8000 range
while True:
n = rng.randint(5001, 8000)
if n not in used_nums:
used_nums.add(n)
break
assignments[sid] = {
"gj_id": f"GJ {n}",
"proper_name": None,
"dist_pc": 0,
"dist_ly": 0,
"spect": "",
"fabricated": True,
}
fabricated += 1
# Step 5: Apply to nodes
for node in nodes:
sid = node["system_id"]
star = assignments[sid]
node["astronomical_id"] = star["gj_id"]
node["proper_name"] = star.get("proper_name")
node["dist_ly"] = round(star.get("dist_ly", 0), 1)
node["spectral_class"] = star.get("spect", "")
# Step 6: Write back
with open(STAR_MAP_PATH, "w") as f:
json.dump(data, f, indent=2)
f.write("\n")
# Step 7: Summary
real_count = len(assignments) - fabricated
print(f"\n{'='*60}")
print("ASTRONOMICAL ID ASSIGNMENT COMPLETE")
print(f"{'='*60}")
print(f"Total systems: {len(nodes)}")
print(f"Real GJ assigned: {real_count}")
print(f"Fabricated: {fabricated}")
print(f"Distance range: {pool[0]['dist_ly']:.1f}{pool[min(len(nodes)-1, len(pool)-1)]['dist_ly']:.1f} ly")
# Named stars
named = [(sid, a) for sid, a in assignments.items() if a.get("proper_name")]
print(f"\nNamed stars ({len(named)}):")
node_by_id = {n["system_id"]: n for n in nodes}
for sid, star in sorted(named, key=lambda x: x[1].get("dist_ly", 0)):
n = node_by_id[sid]
print(f" {star['gj_id']:12s} {star['proper_name']:25s} {star['dist_ly']:5.1f} ly {n['gate_topology']:14s} {sid}")
# Spectral type distribution
type_counts: dict[str, int] = {}
for node in nodes:
sp = simplify_spectral(node.get("spectral_class", ""))
type_counts[sp] = type_counts.get(sp, 0) + 1
print("\nSimplified spectral distribution:")
for t in ["G", "K", "M", "F", "unusual"]:
print(f" {t:8s} {type_counts.get(t, 0):3d} ({type_counts.get(t, 0)/len(nodes)*100:.0f}%)")
# Collision check
all_ids = [n["astronomical_id"] for n in nodes]
if len(set(all_ids)) != len(all_ids):
dupes = [x for x in all_ids if all_ids.count(x) > 1]
print(f"\nWARNING: Duplicates found: {set(dupes)}")
else:
print(f"\nCollision check: PASSED — all {len(set(all_ids))} unique.")
print(f"\nWritten to: {STAR_MAP_PATH}")
if __name__ == "__main__":
main()