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>
This commit is contained in:
@@ -5,18 +5,20 @@
|
||||
//! 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` |
|
||||
//! | `orbital_period_days` | `bodies.orbital_period_days` |
|
||||
//! | `axial_tilt_deg` | `bodies.axial_tilt_deg` |
|
||||
//! | `spectral_class` | `star_systems.spectral_class` (via `bodies.system_id`) |
|
||||
//! | `star_type` | `star_systems.star_type` (via `bodies.system_id`) |
|
||||
//! | `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
|
||||
@@ -75,8 +77,10 @@ impl BodyParamsReader {
|
||||
|
||||
/// Read the physical parameters for `body_id`.
|
||||
///
|
||||
/// Joins `bodies` → `star_systems` (LEFT JOIN, so a body with no system row
|
||||
/// still returns valid params with `spectral_class` and `star_type` = `None`).
|
||||
/// 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())` —
|
||||
@@ -87,69 +91,36 @@ impl BodyParamsReader {
|
||||
.lock()
|
||||
.map_err(|e| BodyParamsReadError::Db(format!("mutex poisoned: {e}")))?;
|
||||
|
||||
// All columns are nullable; query row presence is what signals UnknownBody.
|
||||
// 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 query time).
|
||||
// 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
|
||||
Option<f64>, // b.orbital_period_days
|
||||
Option<f64>, // b.axial_tilt_deg
|
||||
Option<String>, // s.spectral_class
|
||||
Option<String>, // s.star_type
|
||||
)> = conn.query_row(
|
||||
"SELECT
|
||||
b.hydrosphere,
|
||||
b.atmosphere,
|
||||
b.planet_class,
|
||||
b.orbital_period_days,
|
||||
b.axial_tilt_deg,
|
||||
s.spectral_class,
|
||||
s.star_type
|
||||
b.planet_class
|
||||
FROM bodies AS b
|
||||
LEFT JOIN star_systems AS s ON s.system_id = b.system_id
|
||||
WHERE b.body_id = ?1",
|
||||
[body_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
row.get(5)?,
|
||||
row.get(6)?,
|
||||
))
|
||||
},
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok((
|
||||
Ok((hydrosphere, atmosphere, planet_class)) => Ok(BodyParams {
|
||||
hydrosphere,
|
||||
atmosphere,
|
||||
planet_class,
|
||||
orbital_period_days,
|
||||
axial_tilt_deg,
|
||||
spectral_class,
|
||||
star_type,
|
||||
)) => {
|
||||
Ok(BodyParams {
|
||||
hydrosphere,
|
||||
atmosphere,
|
||||
planet_class,
|
||||
orbital_period_days,
|
||||
axial_tilt_deg,
|
||||
spectral_class,
|
||||
star_type,
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
// 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()))
|
||||
}
|
||||
@@ -181,49 +152,34 @@ mod tests {
|
||||
|
||||
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,
|
||||
system_id: &str,
|
||||
hydrosphere: Option<&str>,
|
||||
atmosphere: Option<&str>,
|
||||
planet_class: Option<&str>,
|
||||
orbital_period_days: Option<f64>,
|
||||
axial_tilt_deg: Option<f64>,
|
||||
spectral_class: Option<&str>,
|
||||
star_type: 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 star_systems (
|
||||
system_id TEXT PRIMARY KEY,
|
||||
spectral_class TEXT,
|
||||
star_type TEXT
|
||||
);
|
||||
CREATE TABLE bodies (
|
||||
"CREATE TABLE bodies (
|
||||
body_id TEXT PRIMARY KEY,
|
||||
system_id TEXT,
|
||||
hydrosphere TEXT,
|
||||
atmosphere TEXT,
|
||||
planet_class TEXT,
|
||||
orbital_period_days REAL,
|
||||
axial_tilt_deg REAL
|
||||
planet_class TEXT
|
||||
);",
|
||||
)
|
||||
.expect("create tables");
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO star_systems (system_id, spectral_class, star_type) VALUES (?1, ?2, ?3)",
|
||||
rusqlite::params![system_id, spectral_class, star_type],
|
||||
)
|
||||
.expect("insert system");
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO bodies (body_id, system_id, hydrosphere, atmosphere, planet_class, orbital_period_days, axial_tilt_deg)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
rusqlite::params![body_id, system_id, hydrosphere, atmosphere, planet_class, orbital_period_days, axial_tilt_deg],
|
||||
"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");
|
||||
|
||||
@@ -233,27 +189,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn reads_all_columns_present() {
|
||||
let db = make_test_db(
|
||||
"GJ1c",
|
||||
"GJ-1",
|
||||
Some("ocean"),
|
||||
Some("breathable"),
|
||||
Some("temperate"),
|
||||
Some(365.25),
|
||||
Some(23.5),
|
||||
Some("G"),
|
||||
Some("main_sequence"),
|
||||
);
|
||||
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"));
|
||||
assert!((params.orbital_period_days.unwrap() - 365.25).abs() < 1e-9);
|
||||
assert!((params.axial_tilt_deg.unwrap() - 23.5).abs() < 1e-9);
|
||||
assert_eq!(params.spectral_class.as_deref(), Some("G"));
|
||||
assert_eq!(params.star_type.as_deref(), Some("main_sequence"));
|
||||
// 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);
|
||||
@@ -263,86 +205,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn handles_all_null_columns() {
|
||||
let db = make_test_db("GJ2b", "GJ-2", None, None, None, None, None, None, None);
|
||||
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");
|
||||
|
||||
// All nullable columns → all None; struct defaults for per-region fields.
|
||||
assert!(params.hydrosphere.is_none());
|
||||
assert!(params.atmosphere.is_none());
|
||||
assert!(params.planet_class.is_none());
|
||||
assert!(params.orbital_period_days.is_none());
|
||||
assert!(params.axial_tilt_deg.is_none());
|
||||
assert!(params.spectral_class.is_none());
|
||||
assert!(params.star_type.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_body_returns_error() {
|
||||
let db = make_test_db("GJ3c", "GJ-3", None, None, None, None, None, None, None);
|
||||
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 body_with_orphan_system_id_returns_null_stellar_fields() {
|
||||
// The LEFT JOIN guards against a body whose system_id has NO matching
|
||||
// star_systems row (an incomplete DB — e.g. a body loaded before its
|
||||
// system). It is NOT about a NULL system_id: production schema declares
|
||||
// `bodies.system_id TEXT NOT NULL REFERENCES star_systems(system_id)`,
|
||||
// so a NULL system_id can't exist. Mirror that NOT NULL here and test the
|
||||
// real case — a non-NULL system_id pointing at an absent system row.
|
||||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!("sr_bpr_ns_{}_{n}.db", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let conn = Connection::open(&path).expect("create db");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE star_systems (
|
||||
system_id TEXT PRIMARY KEY,
|
||||
spectral_class TEXT,
|
||||
star_type TEXT
|
||||
);
|
||||
CREATE TABLE bodies (
|
||||
body_id TEXT PRIMARY KEY,
|
||||
system_id TEXT NOT NULL,
|
||||
hydrosphere TEXT,
|
||||
atmosphere TEXT,
|
||||
planet_class TEXT,
|
||||
orbital_period_days REAL,
|
||||
axial_tilt_deg REAL
|
||||
);",
|
||||
)
|
||||
.expect("create tables");
|
||||
// Body points at a system that doesn't exist in star_systems (orphan FK).
|
||||
conn.execute(
|
||||
"INSERT INTO bodies (body_id, system_id, atmosphere) VALUES ('lonely', 'GJ-ORPHAN', 'thin')",
|
||||
[],
|
||||
)
|
||||
.expect("insert");
|
||||
drop(conn);
|
||||
|
||||
let reader = BodyParamsReader::open(&path).expect("open");
|
||||
let params = reader.read_body_params("lonely").expect("read");
|
||||
assert_eq!(params.atmosphere.as_deref(), Some("thin"));
|
||||
// No matching star_systems row → stellar fields NULL from the LEFT JOIN.
|
||||
assert!(params.spectral_class.is_none());
|
||||
assert!(params.star_type.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_is_deterministic() {
|
||||
let db = make_test_db(
|
||||
"GJ4d",
|
||||
"GJ-4",
|
||||
Some("ice"),
|
||||
Some("thin"),
|
||||
Some("frozen"),
|
||||
Some(200.0),
|
||||
Some(15.0),
|
||||
Some("K"),
|
||||
Some("main_sequence"),
|
||||
);
|
||||
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");
|
||||
@@ -350,9 +232,35 @@ mod tests {
|
||||
assert_eq!(p1.hydrosphere, p2.hydrosphere);
|
||||
assert_eq!(p1.atmosphere, p2.atmosphere);
|
||||
assert_eq!(p1.planet_class, p2.planet_class);
|
||||
assert_eq!(p1.orbital_period_days, p2.orbital_period_days);
|
||||
assert_eq!(p1.axial_tilt_deg, p2.axial_tilt_deg);
|
||||
assert_eq!(p1.spectral_class, p2.spectral_class);
|
||||
assert_eq!(p1.star_type, p2.star_type);
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user