Files
settled-reach/server/src/atlas/body_params_reader.rs
T
jpmschweitzerandClaude Opus 4.8 b6be9f5758 feat(simulation): planet_class temperature envelope, drop orbit/star inputs (T-1033, D-240)
Replace the sun-driven Stefan-Boltzmann temperature with the D-240 class-envelope
model. Temperature derives only from authored fields — no orbit/star physics.

- [planet_class_temperature] envelope table in climate_constants.toml (approved
  bands: frozen [-90,-25] .. hot_arid [20,58] .. volcanic [30,90]); cold_/hot_/
  warm_ prefix parsing; temperate fallback for unknowns.
- derive_temperature_c(params, constants, body_seed): band = envelope(planet_class);
  latitude lerps across it (equator=warm, pole=cold); atmosphere greenhouse +
  elevation lapse modulate within; small seed nudge for per-body variety; CLAMP to
  band — a body can never escape its class. Airless -> None.
- Delete the dead astrophysics: derive_distance_au, ClimateConstants::luminosity(),
  the [star_luminosity] table. Strip orbital_period_days/spectral_class/star_type/
  axial_tilt_deg from BodyParams + BodyParamsReader (read 3 cols from bodies, no
  star_systems join). DB columns left in place (no regen).
- New every_planet_class_derives_within_its_band test (13 classes x 5 atmo x 19 lat
  x 4 seeds, all in band) — the D-240 consistency guard.

Verified on 25 diverse real bodies: the worlds that derived to +356C now sit inside
their class bands (temperate capped at 28C). cargo test passes, clippy -D warnings
clean, fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:28:24 +02:00

267 lines
11 KiB
Rust

//! Pre-dispatch reader for body physical parameters (T-1032, D-239 §1).
//!
//! Reads the columns needed to construct a [`BodyParams`] for the RegionProfile
//! carrier layer from `systems.db` at `AnalyzeBody` enqueue time. This keeps
//! the Rayon work item DB-free while supplying the climate/tectonic inputs
//! required by `derive_all_regions`.
//!
//! **D-240:** orbit/star fields (`orbital_period_days`, `axial_tilt_deg`,
//! `spectral_class`, `star_type`) are non-canonical placeholder data — they are
//! NOT read into `BodyParams`. Temperature derives from `planet_class` envelope
//! only. The DB columns are left in place (no schema change) but are no longer
//! selected here.
//!
//! **Columns queried** (all are nullable in the schema — `BodyParams` fields are
//! `Option`):
//!
//! | Field | Source |
//! |-------|--------|
//! | `hydrosphere` | `bodies.hydrosphere` |
//! | `atmosphere` | `bodies.atmosphere` |
//! | `planet_class` | `bodies.planet_class` |
//!
//! `tectonic_activity` is **not** in the current schema; `BodyParams.tectonic_activity`
//! is left `None` so the derivation falls back to `planet_class` as documented
//! on the struct. The per-region fields `region_latitude_deg` and `elevation_km`
//! are set by `derive_all_regions` / `derive_region_profile`, not here; they
//! remain at their struct defaults (0.0) from this reader.
//!
//! Read-only `systems.db` access follows the same pattern as
//! [`crate::atlas::source_resolver::BodySourceResolver`] and
//! [`crate::atlas::city_context_reader::CityContextReader`].
use std::path::Path;
use std::sync::{Arc, Mutex};
use rusqlite::{Connection, OpenFlags};
use thiserror::Error;
use crate::atlas::region_profile::BodyParams;
// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------
/// Errors from reading body physical parameters.
#[derive(Debug, Error)]
pub enum BodyParamsReadError {
#[error("systems.db error: {0}")]
Db(String),
#[error("body {0} not found in bodies table")]
UnknownBody(String),
}
// ---------------------------------------------------------------------------
// Reader
// ---------------------------------------------------------------------------
/// Reads body physical parameters from `systems.db` at `AnalyzeBody` dispatch
/// time. Holds a read-only SQLite connection.
///
/// Analogous to [`crate::atlas::city_context_reader::CityContextReader`] —
/// pre-resolves inputs the Rayon work item needs before it is submitted,
/// keeping the cascade DB-free (D-225 pattern).
pub struct BodyParamsReader {
conn: Arc<Mutex<Connection>>,
}
impl BodyParamsReader {
/// Open a read-only connection to `systems_db`.
pub fn open(systems_db: &Path) -> Result<Self, BodyParamsReadError> {
let conn = Connection::open_with_flags(systems_db, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| BodyParamsReadError::Db(e.to_string()))?;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
})
}
/// Read the physical parameters for `body_id`.
///
/// D-240: only `hydrosphere`, `atmosphere`, and `planet_class` are selected.
/// The orbit/star columns (`orbital_period_days`, `axial_tilt_deg`,
/// `spectral_class`, `star_type`) remain in the DB schema but are not
/// consumed — they are non-canonical placeholder data per D-240.
///
/// Returns `BodyParamsReadError::UnknownBody` if the body is not in the DB.
/// A body that exists but has all-NULL columns still returns `Ok(BodyParams::default())` —
/// every derivation function handles missing fields gracefully.
pub fn read_body_params(&self, body_id: &str) -> Result<BodyParams, BodyParamsReadError> {
let conn = self
.conn
.lock()
.map_err(|e| BodyParamsReadError::Db(format!("mutex poisoned: {e}")))?;
// All three columns are nullable; row absence is what signals UnknownBody.
// `tectonic_activity` is absent from the current schema — leave that
// BodyParams field None (derives from planet_class at derivation time).
let result: rusqlite::Result<(
Option<String>, // b.hydrosphere
Option<String>, // b.atmosphere
Option<String>, // b.planet_class
)> = conn.query_row(
"SELECT
b.hydrosphere,
b.atmosphere,
b.planet_class
FROM bodies AS b
WHERE b.body_id = ?1",
[body_id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
);
match result {
Ok((hydrosphere, atmosphere, planet_class)) => Ok(BodyParams {
hydrosphere,
atmosphere,
planet_class,
// tectonic_activity not in schema — leave None.
tectonic_activity: None,
// Per-region fields are set by derive_all_regions / derive_region_profile,
// not at the body level. Leave at struct defaults (0.0).
region_latitude_deg: 0.0,
elevation_km: 0.0,
}),
Err(rusqlite::Error::QueryReturnedNoRows) => {
Err(BodyParamsReadError::UnknownBody(body_id.to_string()))
}
Err(e) => Err(BodyParamsReadError::Db(e.to_string())),
}
}
}
// ---------------------------------------------------------------------------
// Bevy resource wrapper
// ---------------------------------------------------------------------------
/// Bevy `Resource` wrapper — `Res<BodyParamsReaderResource>` in systems.
/// Mirrors [`crate::atlas::city_context_reader::CityContextReaderResource`];
/// the atlas proxy uses it to read a body's physical params on a cache miss.
#[derive(bevy_ecs::prelude::Resource)]
pub struct BodyParamsReaderResource(pub BodyParamsReader);
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
static SEQ: AtomicU32 = AtomicU32::new(0);
/// Create a minimal test DB with the three canonical columns only.
/// The orbit/star columns (orbital_period_days, axial_tilt_deg,
/// spectral_class, star_type) are intentionally absent — the reader
/// must not select them (D-240).
fn make_test_db(
body_id: &str,
hydrosphere: Option<&str>,
atmosphere: Option<&str>,
planet_class: Option<&str>,
) -> PathBuf {
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("sr_bpr_{}_{n}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let conn = Connection::open(&path).expect("create db");
conn.execute_batch(
"CREATE TABLE bodies (
body_id TEXT PRIMARY KEY,
hydrosphere TEXT,
atmosphere TEXT,
planet_class TEXT
);",
)
.expect("create tables");
conn.execute(
"INSERT INTO bodies (body_id, hydrosphere, atmosphere, planet_class)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![body_id, hydrosphere, atmosphere, planet_class],
)
.expect("insert body");
drop(conn);
path
}
#[test]
fn reads_all_columns_present() {
let db = make_test_db("GJ1c", Some("ocean"), Some("breathable"), Some("temperate"));
let reader = BodyParamsReader::open(&db).expect("open");
let params = reader.read_body_params("GJ1c").expect("read");
assert_eq!(params.hydrosphere.as_deref(), Some("ocean"));
assert_eq!(params.atmosphere.as_deref(), Some("breathable"));
assert_eq!(params.planet_class.as_deref(), Some("temperate"));
// Per-region fields always start at 0.0 from the reader.
assert_eq!(params.region_latitude_deg, 0.0);
assert_eq!(params.elevation_km, 0.0);
// tectonic_activity not in schema → None.
assert!(params.tectonic_activity.is_none());
}
#[test]
fn handles_all_null_columns() {
let db = make_test_db("GJ2b", None, None, None);
let reader = BodyParamsReader::open(&db).expect("open");
let params = reader.read_body_params("GJ2b").expect("read");
assert!(params.hydrosphere.is_none());
assert!(params.atmosphere.is_none());
assert!(params.planet_class.is_none());
}
#[test]
fn unknown_body_returns_error() {
let db = make_test_db("GJ3c", None, None, None);
let reader = BodyParamsReader::open(&db).expect("open");
let err = reader.read_body_params("ghost").expect_err("should fail");
assert!(matches!(err, BodyParamsReadError::UnknownBody(_)));
}
#[test]
fn read_is_deterministic() {
let db = make_test_db("GJ4d", Some("ice"), Some("thin"), Some("frozen"));
let reader = BodyParamsReader::open(&db).expect("open");
let p1 = reader.read_body_params("GJ4d").expect("first read");
let p2 = reader.read_body_params("GJ4d").expect("second read");
assert_eq!(p1.hydrosphere, p2.hydrosphere);
assert_eq!(p1.atmosphere, p2.atmosphere);
assert_eq!(p1.planet_class, p2.planet_class);
}
#[test]
fn reader_no_longer_queries_orbit_star_columns() {
// Verify the reader works against a DB that has NO orbit/star columns at all
// (the columns are left in production schema but the SELECT must not touch them).
// This test intentionally omits those columns from the schema to prove
// the query doesn't reference them.
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("sr_bpr_nostar_{}_{n}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let conn = Connection::open(&path).expect("create");
conn.execute_batch(
"CREATE TABLE bodies (
body_id TEXT PRIMARY KEY,
hydrosphere TEXT,
atmosphere TEXT,
planet_class TEXT
-- orbital_period_days, axial_tilt_deg, spectral_class, star_type
-- deliberately absent to prove SELECT doesn't reference them
);
INSERT INTO bodies VALUES ('X', 'ocean', 'breathable', 'temperate');",
)
.expect("setup");
drop(conn);
let reader = BodyParamsReader::open(&path).expect("open");
let params = reader.read_body_params("X").expect("read");
assert_eq!(params.planet_class.as_deref(), Some("temperate"));
}
}