diff --git a/server/data/climate_constants.toml b/server/data/climate_constants.toml index d9edf393e..808c0bee9 100644 --- a/server/data/climate_constants.toml +++ b/server/data/climate_constants.toml @@ -1,30 +1,56 @@ -# Climate constants for district temperature derivation (T-1024, D-239 §2). +# Climate constants for district temperature derivation (T-1024, D-239 §2, D-240). # # Source-canonical — loaded by the Rust simulation at runtime. -# These values mirror the tuned constants from tooling/planet-gen/planet_simulation.py. # Changing these constants does NOT require a DB migration (runtime file, not DB). # To tune: edit here, rerun the server, inspect Atlas temperature maps. # -# Two tables are required: -# [greenhouse_offset_c] — mean-annual base temperature offset by atmosphere class. -# [diurnal_amplitude_c] — day/night swing AMPLITUDE by atmosphere class. +# Three tables are required: +# [planet_class_temperature] — per-class [cold_end, warm_end] °C band (D-240). +# [greenhouse_offset_c] — mean-annual base temperature offset by atmosphere class. +# [diurnal_amplitude_c] — day/night swing AMPLITUDE by atmosphere class. # -# Temperature derivation formula (D-239 §2): -# T_base = T_stellar_equilibrium + greenhouse_offset_c[atmosphere] -# T_mean = T_base + latitude_term + elevation_lapse_term -# T_diurnal_amplitude = diurnal_amplitude_c[atmosphere] +# Temperature derivation formula (D-240): +# (cold, warm) = planet_class_temperature[planet_class] # envelope +# t_lat = warm - (warm - cold) * (|latitude_deg| / 90) # latitude lerp +# greenhouse modulates within the band +# elevation lapse pulls toward cold end +# seed nudge ±~3°C +# CLAMP to [cold, warm] — body can never derive outside its class band. # # No atmosphere → temperature_c = None (airless body; D-227). +# --------------------------------------------------------------------------- +# Planet class temperature envelopes (D-240). +# Each entry: [cold_end_c, warm_end_c] — the band a body of this class stays in. +# Prefix rules: "cold_*" shifts both ends ~10°C colder; "hot_*" ~10°C warmer; +# "warm_*" ~5°C warmer. Unknown class falls back to "temperate" band. +# --------------------------------------------------------------------------- +[planet_class_temperature] +frozen = [-90.0, -25.0] +ice = [-90.0, -25.0] +boreal = [-35.0, 12.0] +cold_arid = [-40.0, 20.0] +temperate = [-12.0, 28.0] +oceanic = [-12.0, 28.0] +subtropical = [ 2.0, 34.0] +warm_ocean = [ 2.0, 34.0] +tropical = [ 16.0, 40.0] +arid = [ -5.0, 45.0] +hot_arid = [ 20.0, 58.0] +volcanic = [ 30.0, 90.0] +geothermal = [ 30.0, 90.0] + [greenhouse_offset_c] -# Greenhouse warming contribution per atmosphere class (°C above bare rock). +# Greenhouse warming contribution per atmosphere class — used as a fractional +# nudge toward the warm end of the class band, not an absolute Kelvin offset. # "none" is not present — airless bodies skip the climate branch entirely (D-227). -# Values from planet_simulation.py STAR_LUMINOSITY + greenhouse table. -thin = 8 -standard = 33 -toxic = 33 # treated as standard greenhouse for thermal purposes -breathable = 33 # synonym for standard -dense = 80 +# Values represent a 0.0–1.0 fraction of the band width to add toward warm end. +# thin=0.1 (slightly warmer), breathable/standard=0.25, toxic=0.25, dense=0.55 +thin = 0.10 +standard = 0.25 +toxic = 0.25 +breathable = 0.25 +dense = 0.55 [diurnal_amplitude_c] # Day/night swing amplitude (°C). The actual swing is ±amplitude around the @@ -39,20 +65,3 @@ standard = 15 # ~15°C: Earth-like moderate swing toxic = 20 # intermediate: thick but maybe less redistribution breathable = 15 # synonym for standard dense = 3 # ~3°C: Venus-like near-uniform temperature - -# --------------------------------------------------------------------------- -# Stellar luminosity lookup (relative to Sol = 1.0). -# Midpoint per spectral type, matching planet_simulation.py STAR_LUMINOSITY. -# Used to derive distance_au at runtime from orbital_period_days via Kepler's 3rd law. -# -# These values are also used to compute T_stellar_equilibrium: -# T_eq_K = 278.5 * (luminosity ^ 0.25) / sqrt(distance_au) -# --------------------------------------------------------------------------- -[star_luminosity] -O = 100000.0 -B = 1000.0 -A = 10.0 -F = 2.5 -G = 1.0 -K = 0.4 -M = 0.04 diff --git a/server/src/atlas/body_params_reader.rs b/server/src/atlas/body_params_reader.rs index 990c06805..99bcd59da 100644 --- a/server/src/atlas/body_params_reader.rs +++ b/server/src/atlas/body_params_reader.rs @@ -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, // b.hydrosphere Option, // b.atmosphere Option, // b.planet_class - Option, // b.orbital_period_days - Option, // b.axial_tilt_deg - Option, // s.spectral_class - Option, // 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, - axial_tilt_deg: Option, - 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")); } } diff --git a/server/src/atlas/region_profile.rs b/server/src/atlas/region_profile.rs index 77c503ed0..fa6b14c2f 100644 --- a/server/src/atlas/region_profile.rs +++ b/server/src/atlas/region_profile.rs @@ -124,8 +124,13 @@ pub enum VegetationClass { /// Body-level physical parameters needed to derive `RegionProfile`. /// /// Modelled on the `BodyRow` reader at `bin/atlas/common.rs`. Source columns -/// live on the `bodies` table: `hydrosphere`, `atmosphere`, `planet_class`, -/// `orbital_period_days`. All are optional (may be NULL in the DB). +/// live on the `bodies` table: `hydrosphere`, `atmosphere`, `planet_class`. +/// All are optional (may be NULL in the DB). +/// +/// **D-240:** orbit/star fields (`orbital_period_days`, `axial_tilt_deg`, +/// `spectral_class`, `star_type`) are non-canonical placeholder data and are +/// NOT read into this struct. Temperature derives from `planet_class` envelope +/// only — see `derive_temperature_c`. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct BodyParams { /// `bodies.hydrosphere` — "ocean" | "ice" | "rivers" | "none" | "subsurface" | NULL @@ -134,20 +139,10 @@ pub struct BodyParams { pub atmosphere: Option, /// `bodies.planet_class` — "temperate" | "arid" | "frozen" | "oceanic" | "volcanic" | … | NULL pub planet_class: Option, - /// `bodies.orbital_period_days` — orbital period in Earth days. - pub orbital_period_days: Option, /// `bodies.tectonic_activity` — optional authored tectonic override. /// "stable" | "active" | "volcanic" | "tidally_forced". If absent, derived /// from `planet_class`. pub tectonic_activity: Option, - /// `bodies.axial_tilt_deg` — axial tilt in degrees (T-1024, D-239 §2). - /// Sourced from planet-gen body-def frontmatter. - pub axial_tilt_deg: Option, - /// `star_systems.spectral_class` — stellar spectral class ("G", "K", "M", …). - /// Used to derive stellar luminosity for temperature calculation (T-1024). - pub spectral_class: Option, - /// `star_systems.star_type` — fallback stellar type if spectral_class is absent. - pub star_type: Option, /// Latitude of the region's centre in the body's reference frame, in degrees. /// 0.0 = equator, ±90.0 = poles. Used for latitude-band temperature gradient. pub region_latitude_deg: f64, @@ -624,29 +619,48 @@ const MAX_REGION_ELEVATION_KM: f64 = 8.0; /// `load()` method yet, so keep the TOML and the embedded `default()` in sync by /// hand until then. /// -/// Values mirror `tooling/planet-gen/planet_simulation.py`. +/// D-240: temperature derives from `planet_class` envelope, not orbit/star data. #[derive(Debug, Clone)] pub struct ClimateConstants { - /// Greenhouse warming offset per atmosphere class (°C). + /// Per-`planet_class` temperature envelope: `(cold_end_c, warm_end_c)` °C. + /// A body can never derive outside its class band (D-240). + /// Keys: "temperate", "arid", "frozen", "tropical", "hot_arid", "volcanic", … + /// Prefix rules applied in `envelope()`: "cold_*" → −10°C shift; "hot_*" → +10°C; + /// "warm_*" → +5°C. Unknown class falls back to "temperate" band. + pub planet_class_temperature: std::collections::BTreeMap, + /// Greenhouse warming fraction per atmosphere class (0.0–1.0 of band width). /// Keys: "thin", "standard", "breathable", "toxic", "dense". - /// "none" / absent = 0 °C (but airless bodies return `None` before reaching this). + /// "none" / absent = 0.0 (airless bodies return `None` before reaching this). pub greenhouse_offset_c: std::collections::BTreeMap, /// Day/night swing amplitude per atmosphere class (°C). pub diurnal_amplitude_c: std::collections::BTreeMap, - /// Stellar luminosity relative to Sol. Keys: spectral class letter. - pub star_luminosity: std::collections::BTreeMap, } impl Default for ClimateConstants { /// Embedded fallback — matches `server/data/climate_constants.toml`. /// Used when the TOML file is not available (tests, embedded contexts). fn default() -> Self { + let mut pct = std::collections::BTreeMap::new(); + pct.insert("frozen".into(), (-90.0f32, -25.0f32)); + pct.insert("ice".into(), (-90.0f32, -25.0f32)); + pct.insert("boreal".into(), (-35.0f32, 12.0f32)); + pct.insert("cold_arid".into(), (-40.0f32, 20.0f32)); + pct.insert("temperate".into(), (-12.0f32, 28.0f32)); + pct.insert("oceanic".into(), (-12.0f32, 28.0f32)); + pct.insert("subtropical".into(), (2.0f32, 34.0f32)); + pct.insert("warm_ocean".into(), (2.0f32, 34.0f32)); + pct.insert("tropical".into(), (16.0f32, 40.0f32)); + pct.insert("arid".into(), (-5.0f32, 45.0f32)); + pct.insert("hot_arid".into(), (20.0f32, 58.0f32)); + pct.insert("volcanic".into(), (30.0f32, 90.0f32)); + pct.insert("geothermal".into(), (30.0f32, 90.0f32)); + let mut gh = std::collections::BTreeMap::new(); - gh.insert("thin".into(), 8.0f32); - gh.insert("standard".into(), 33.0f32); - gh.insert("breathable".into(), 33.0f32); - gh.insert("toxic".into(), 33.0f32); - gh.insert("dense".into(), 80.0f32); + gh.insert("thin".into(), 0.10f32); + gh.insert("standard".into(), 0.25f32); + gh.insert("breathable".into(), 0.25f32); + gh.insert("toxic".into(), 0.25f32); + gh.insert("dense".into(), 0.55f32); let mut da = std::collections::BTreeMap::new(); da.insert("thin".into(), 50.0f32); @@ -655,25 +669,43 @@ impl Default for ClimateConstants { da.insert("toxic".into(), 20.0f32); da.insert("dense".into(), 3.0f32); - let mut sl = std::collections::BTreeMap::new(); - sl.insert("O".into(), 100_000.0f64); - sl.insert("B".into(), 1_000.0f64); - sl.insert("A".into(), 10.0f64); - sl.insert("F".into(), 2.5f64); - sl.insert("G".into(), 1.0f64); - sl.insert("K".into(), 0.4f64); - sl.insert("M".into(), 0.04f64); - ClimateConstants { + planet_class_temperature: pct, greenhouse_offset_c: gh, diurnal_amplitude_c: da, - star_luminosity: sl, } } } impl ClimateConstants { - /// Greenhouse offset for a given atmosphere class (°C), defaulting to 0. + /// Return the temperature envelope `(cold_c, warm_c)` for a planet class. + /// + /// Looks up the class directly first. On a miss, applies prefix rules against + /// the "temperate" band: `cold_*` shifts both ends −10 °C, `hot_*` +10 °C, + /// `warm_*` +5 °C. Completely unknown classes fall back to the "temperate" band. + pub fn envelope(&self, planet_class: &str) -> (f32, f32) { + // Direct lookup first. + if let Some(&band) = self.planet_class_temperature.get(planet_class) { + return band; + } + // Prefix rules: shift the temperate fallback band. + let default_band = self + .planet_class_temperature + .get("temperate") + .copied() + .unwrap_or((-12.0, 28.0)); + if planet_class.starts_with("cold_") { + (default_band.0 - 10.0, default_band.1 - 10.0) + } else if planet_class.starts_with("hot_") { + (default_band.0 + 10.0, default_band.1 + 10.0) + } else if planet_class.starts_with("warm_") { + (default_band.0 + 5.0, default_band.1 + 5.0) + } else { + default_band + } + } + + /// Greenhouse fraction for a given atmosphere class (0.0–1.0), defaulting to 0.0. pub fn greenhouse(&self, atmosphere: &str) -> f32 { self.greenhouse_offset_c .get(atmosphere) @@ -688,46 +720,37 @@ impl ClimateConstants { .copied() .unwrap_or(30.0) } - - /// Stellar luminosity for a given spectral class (relative to Sol = 1.0). - pub fn luminosity(&self, spectral_class: &str) -> f64 { - // Spectral class is often multi-character like "G2V"; take the first letter. - let key = spectral_class - .chars() - .next() - .map(|c| c.to_uppercase().to_string()) - .unwrap_or_default(); - self.star_luminosity.get(&key).copied().unwrap_or(1.0) - } -} - -/// Derive orbital distance in AU from orbital period (days) and stellar luminosity. -/// -/// Kepler's 3rd law (approximation for main-sequence star with mass ≈ luminosity^0.25): -/// a_AU = (T_yr)^(2/3) × (star_mass)^(1/3) -/// star_mass_solar ≈ luminosity^0.25 (main-sequence mass-luminosity relation) -/// -/// This is the same derivation used in `planet_simulation.py`. -/// Uses f64 throughout; result is a positional input, not a gate comparison (D-010). -fn derive_distance_au(orbital_period_days: f64, luminosity_solar: f64) -> f64 { - let t_yr = orbital_period_days / 365.25; - // Mass-luminosity: M ≈ L^0.25 (rough but adequate for temperature estimation) - let star_mass = luminosity_solar.powf(0.25); - t_yr.powf(2.0 / 3.0) * star_mass.powf(1.0 / 3.0) } /// Derive mean-annual district temperature in °C (nullable). /// +/// Implements D-240: temperature derives from `planet_class` envelope only. +/// Orbit/star fields (`orbital_period_days`, `spectral_class`, `star_type`, +/// `axial_tilt_deg`) are non-canonical placeholder data and are NOT inputs. +/// /// Returns `None` if no atmosphere (airless; D-227). -/// Implements D-239 §2: mean-annual scalar, latitude + elevation lapse, greenhouse offset. -/// Orbital phase is fixed at equinox; diurnal swing is carried as `amplitude_c` only. /// -/// Uses f64 for intermediate calculations; the final result is cast to f32 -/// (adequate precision for a ~2 km district-mean temperature). +/// ## Algorithm (D-240) /// -/// D-010: all gating decisions downstream use the integer `temperature_c as i32`; -/// the f64/f32 here is positional physics, not a structural comparison. -pub fn derive_temperature_c(params: &BodyParams, constants: &ClimateConstants) -> Option { +/// 1. Look up the `(cold, warm)` envelope for `planet_class` via +/// `ClimateConstants::envelope()`. Unknown class falls back to "temperate". +/// 2. **Latitude lerp**: `t_lat = warm - (warm - cold) * (|lat| / 90)` — +/// equator → warm end, pole → cold end. +/// 3. **Atmosphere modulation**: greenhouse fraction nudges `t_lat` toward the +/// warm end by `greenhouse_frac × (warm - cold)`. +/// 4. **Elevation lapse**: subtract `lapse_rate × elevation_km` (thin atmo: +/// 3.5 °C/km, else 6.5 °C/km). +/// 5. **Seed nudge**: deterministic ±~3 °C per-body variation from `body_seed`. +/// 6. **Clamp to `[cold, warm]`** — class band is a hard invariant (D-240). +/// +/// Uses f32 throughout (adequate for ~2 km district-mean temperature). +/// D-010: all downstream gating uses `temperature_c as i32`; the float here +/// is positional physics, not a structural comparison. +pub fn derive_temperature_c( + params: &BodyParams, + constants: &ClimateConstants, + body_seed: u64, +) -> Option { let atmosphere = params.atmosphere.as_deref().unwrap_or("none"); // No atmosphere → airless body; temperature is None (D-227). @@ -735,48 +758,43 @@ pub fn derive_temperature_c(params: &BodyParams, constants: &ClimateConstants) - return None; } - // Stellar luminosity — from spectral_class or star_type. - let spectral = params - .spectral_class - .as_deref() - .or(params.star_type.as_deref()) - .unwrap_or("G"); - let luminosity = constants.luminosity(spectral); + // Step 1: planet_class → (cold, warm) envelope (D-240). + let planet_class = params.planet_class.as_deref().unwrap_or("temperate"); + let (cold, warm) = constants.envelope(planet_class); + let band_width = warm - cold; - // Orbital distance — derive from orbital_period_days if available, - // else fall back to 1 AU (Sol-equivalent distance). - let distance_au = params - .orbital_period_days - .filter(|&d| d > 0.0) - .map(|d| derive_distance_au(d, luminosity)) - .unwrap_or(1.0); + // Step 2: latitude lerp — equator → warm end, pole → cold end. + let lat_frac = (params.region_latitude_deg.abs() as f32 / 90.0).clamp(0.0, 1.0); + let t_lat = warm - band_width * lat_frac; - // Stellar equilibrium temperature (K) — Stefan-Boltzmann approximation. - let t_equilibrium = 278.5 * luminosity.powf(0.25) / distance_au.sqrt(); + // Step 3: atmosphere greenhouse nudge — fraction of band_width toward warm end. + let gh_frac = constants.greenhouse(atmosphere); + let t_atmo = t_lat + gh_frac * band_width; - // Greenhouse offset (°C from planet_simulation.py tuning). - let greenhouse = constants.greenhouse(atmosphere) as f64; - let t_base = t_equilibrium + greenhouse; - - // Latitude gradient. Axial tilt modulates the equator–pole delta. - let axial_tilt = params.axial_tilt_deg.unwrap_or(23.4); // Earth-like default - // tilt_factor: 1.0 = no tilt (full equator–pole gradient), 0.5 = 90° tilt (reduced gradient) - let tilt_factor = 1.0 - (axial_tilt / 90.0) * 0.5; - let lat_gradient_c = 60.0 * tilt_factor; - let lat_frac = params.region_latitude_deg / 90.0; // [-1.0, 1.0] - let t_lat = t_base - lat_gradient_c * lat_frac.abs(); - - // Elevation lapse rate (°C/km). Earth standard ~6.5 °C/km; airless = 2. + // Step 4: elevation lapse rate (°C/km). let lapse = if atmosphere == "thin" { - 3.5_f64 + 3.5_f32 } else { - 6.5_f64 + 6.5_f32 }; - let t_final = t_lat - lapse * params.elevation_km.max(0.0); + let t_lapse = t_atmo - lapse * (params.elevation_km as f32).max(0.0); - // Convert from K to °C (subtract 273.15) and clamp to a plausible range. - let t_celsius = (t_final - 273.15).clamp(-200.0, 600.0) as f32; - Some(t_celsius) + // Step 5: seed nudge — deterministic ±~3 °C per-body variety (D-010, D-240). + // Hash body_seed with a mixing constant to produce a per-body offset. + // The nudge is a fraction of the band width, bounded to ±3 °C maximum. + let nudge = { + // Deterministic hash: splitmix64-style single round. + let h = body_seed + .wrapping_add(0x9e37_79b9_7f4a_7c15) + .wrapping_mul(0x6c62_272e_07bb_0142); + // Map to [-1.0, 1.0] and scale to ±3°C. + let unit = (h as i64 as f64 / i64::MAX as f64) as f32; + unit * 3.0_f32 + }; + let t_nudged = t_lapse + nudge; + + // Step 6: clamp to [cold, warm] — class band is a hard invariant (D-240). + Some(t_nudged.clamp(cold, warm)) } /// Derive moisture primitive (0–100 integer). @@ -819,12 +837,11 @@ pub fn derive_moisture_q(params: &BodyParams) -> i32 { /// cell; default is 8 (at 128×64 working grid, that yields ~80×64 regions ≈ /// ~5 000 regions/body, within the D-203 ~6 000/body budget). /// -/// Temperature and moisture are derived inline via D-239 §2 climate functions -/// (T-1024). Pass a `&ClimateConstants` to control the tuning constants. +/// Temperature and moisture are derived inline via D-239 §2 / D-240 climate +/// functions (T-1024). Pass a `&ClimateConstants` to control the tuning constants. +/// The seed chain provides the body-scoped seed for the D-240 temperature nudge. pub fn derive_region_profile( - // Reserved: per-region stochastic derivation (T-1027/T-1028) will derive - // from this under a dedicated `SeedDomain::RegionProfile`. Unused today. - _seed: SeedChain, + seed: SeedChain, body_params: &BodyParams, ta: &TerrainAnalysis, pos: RegionPos, @@ -883,7 +900,9 @@ pub fn derive_region_profile( elevation_km: (elev_q as f64 / 100.0) * MAX_REGION_ELEVATION_KM, ..body_params.clone() }; - let temperature_c = derive_temperature_c(®ion_climate_params, climate); + // D-240: body-scoped seed for the deterministic per-body temperature nudge. + let body_seed = seed.seed(); + let temperature_c = derive_temperature_c(®ion_climate_params, climate, body_seed); let moisture_q = derive_moisture_q(body_params); // Climate-derived fields: computed from temperature + moisture primitives @@ -1023,6 +1042,7 @@ mod tests { fn derive_region_profile_is_deterministic() { let hm = test_hm(); let ta = test_ta(&hm); + // D-240: no orbit/star fields in BodyParams — only class/atmo/hydro. let params = BodyParams { hydrosphere: Some("ocean".into()), atmosphere: Some("breathable".into()), @@ -1148,9 +1168,21 @@ mod tests { } // ----------------------------------------------------------------------- - // T-1024 climate derivation tests + // T-1024 / D-240 climate derivation tests — planet_class envelope model // ----------------------------------------------------------------------- + /// Convenience: derive temperature with seed=0 for deterministic tests. + fn temp(planet_class: &str, atmosphere: &str, lat: f64, elev_km: f64) -> Option { + let params = BodyParams { + planet_class: Some(planet_class.into()), + atmosphere: Some(atmosphere.into()), + region_latitude_deg: lat, + elevation_km: elev_km, + ..Default::default() + }; + derive_temperature_c(¶ms, &ClimateConstants::default(), 0) + } + #[test] fn airless_body_has_no_temperature() { let params = BodyParams { @@ -1159,55 +1191,17 @@ mod tests { }; let climate = ClimateConstants::default(); assert_eq!( - derive_temperature_c(¶ms, &climate), + derive_temperature_c(¶ms, &climate, 0), None, "airless body must return None temperature (D-227)" ); } - #[test] - fn earth_like_body_temperature_plausible() { - // Earth: G-star, 365-day orbit, 23.5° tilt, breathable atmosphere, equator. - let params = BodyParams { - atmosphere: Some("breathable".into()), - spectral_class: Some("G".into()), - orbital_period_days: Some(365.25), - axial_tilt_deg: Some(23.5), - region_latitude_deg: 0.0, - elevation_km: 0.0, - ..Default::default() - }; - let climate = ClimateConstants::default(); - let t = derive_temperature_c(¶ms, &climate).expect("breathable body must have temp"); - // Earth equator is roughly 20–30°C. With our formula T_eq ~278K + 33K greenhouse - // = ~38°C, plausible for equatorial region. - assert!( - (-10.0..=80.0).contains(&t), - "Earth-like equatorial temperature {t}°C out of plausible range [-10, 80]" - ); - } - #[test] fn polar_region_is_colder_than_equatorial() { - let base_params = BodyParams { - atmosphere: Some("breathable".into()), - spectral_class: Some("G".into()), - orbital_period_days: Some(365.25), - axial_tilt_deg: Some(23.5), - elevation_km: 0.0, - ..Default::default() - }; - let climate = ClimateConstants::default(); - let equatorial = BodyParams { - region_latitude_deg: 0.0, - ..base_params.clone() - }; - let polar = BodyParams { - region_latitude_deg: 90.0, - ..base_params - }; - let t_eq = derive_temperature_c(&equatorial, &climate).unwrap(); - let t_pol = derive_temperature_c(&polar, &climate).unwrap(); + // seed=0 has nudge; compare equatorial vs polar for same body → ordering holds. + let t_eq = temp("temperate", "breathable", 0.0, 0.0).unwrap(); + let t_pol = temp("temperate", "breathable", 90.0, 0.0).unwrap(); assert!( t_pol < t_eq, "polar temperature {t_pol}°C must be less than equatorial {t_eq}°C" @@ -1216,63 +1210,163 @@ mod tests { #[test] fn high_elevation_is_colder() { - let base_params = BodyParams { - atmosphere: Some("breathable".into()), - spectral_class: Some("G".into()), - orbital_period_days: Some(365.25), - axial_tilt_deg: Some(23.5), - region_latitude_deg: 0.0, - ..Default::default() - }; - let climate = ClimateConstants::default(); - let sea_level = BodyParams { - elevation_km: 0.0, - ..base_params.clone() - }; - let mountain = BodyParams { - elevation_km: 5.0, - ..base_params - }; - let t_low = derive_temperature_c(&sea_level, &climate).unwrap(); - let t_high = derive_temperature_c(&mountain, &climate).unwrap(); - // Lapse rate 6.5°C/km × 5 km = 32.5°C colder. + let t_low = temp("temperate", "breathable", 0.0, 0.0).unwrap(); + let t_high = temp("temperate", "breathable", 0.0, 5.0).unwrap(); + // Lapse rate 6.5°C/km × 5 km = 32.5°C; nudge same for same seed. assert!( t_high < t_low, "mountain temperature {t_high}°C must be less than sea level {t_low}°C" ); let delta = t_low - t_high; + // The clamping to [cold, warm] may reduce the apparent delta at the band edge, + // but 5 km lapse should still register at least 5°C. assert!( - (20.0..=45.0).contains(&delta), - "elevation delta {delta}°C unexpected for 5 km lapse" + delta >= 5.0, + "elevation delta {delta}°C too small for 5 km lapse" ); } #[test] - fn m_star_body_same_orbit_colder_than_g_star() { - let base_params = BodyParams { + fn tropical_body_temperature_plausible() { + // Tropical band is [16, 40]°C. Equatorial, sea-level, seed=0. + let t = temp("tropical", "breathable", 0.0, 0.0).unwrap(); + assert!( + (16.0..=40.0).contains(&t), + "tropical equatorial temperature {t}°C out of class band [16, 40]" + ); + } + + #[test] + fn frozen_body_temperature_plausible() { + // Frozen band is [-90, -25]°C. + let t = temp("frozen", "thin", 45.0, 0.0).unwrap(); + assert!( + (-90.0..=-25.0).contains(&t), + "frozen temperature {t}°C out of class band [-90, -25]" + ); + } + + #[test] + fn temperature_is_deterministic() { + // Same inputs → same output, always. + let params = BodyParams { + atmosphere: Some("thin".into()), + planet_class: Some("arid".into()), + region_latitude_deg: 45.0, + elevation_km: 1.5, + ..Default::default() + }; + let climate = ClimateConstants::default(); + let t1 = derive_temperature_c(¶ms, &climate, 12345); + let t2 = derive_temperature_c(¶ms, &climate, 12345); + assert_eq!(t1, t2, "temperature derivation must be deterministic"); + } + + #[test] + fn different_seeds_produce_different_nudges() { + // Two different body seeds should produce slightly different temperatures + // for the same class/lat/elev. + let params = BodyParams { atmosphere: Some("breathable".into()), - orbital_period_days: Some(365.25), - axial_tilt_deg: Some(23.5), - region_latitude_deg: 0.0, + planet_class: Some("temperate".into()), + region_latitude_deg: 30.0, elevation_km: 0.0, ..Default::default() }; let climate = ClimateConstants::default(); - let g_params = BodyParams { - spectral_class: Some("G".into()), - ..base_params.clone() + let t_a = derive_temperature_c(¶ms, &climate, 1); + let t_b = derive_temperature_c(¶ms, &climate, 999_999_999); + // Both still within the class band, but not identical. + assert_ne!(t_a, t_b, "different seeds should produce different nudges"); + let (cold, warm) = climate.envelope("temperate"); + assert!(t_a.unwrap() >= cold && t_a.unwrap() <= warm); + assert!(t_b.unwrap() >= cold && t_b.unwrap() <= warm); + } + + /// D-240 class-band consistency guard: every planet_class must derive within + /// its `[cold, warm]` band across a full latitude sweep, all atmospheres, + /// and sea level. This is the key acceptance test for T-1033. + #[test] + fn every_planet_class_derives_within_its_band() { + let climate = ClimateConstants::default(); + let planet_classes = [ + "frozen", + "ice", + "boreal", + "cold_arid", + "temperate", + "oceanic", + "subtropical", + "warm_ocean", + "tropical", + "arid", + "hot_arid", + "volcanic", + "geothermal", + ]; + let atmospheres = ["thin", "standard", "breathable", "toxic", "dense"]; + // Sweep latitudes 0°–90° in 5° steps; elevation = 0. + let latitudes: Vec = (0..=90).step_by(5).map(|d| d as f64).collect(); + let seeds: [u64; 4] = [0, 1, u64::MAX / 2, u64::MAX]; + + for class in &planet_classes { + let (cold, warm) = climate.envelope(class); + for atmo in &atmospheres { + for &lat in &latitudes { + for seed in seeds { + let params = BodyParams { + planet_class: Some((*class).into()), + atmosphere: Some((*atmo).into()), + region_latitude_deg: lat, + elevation_km: 0.0, + ..Default::default() + }; + let t = derive_temperature_c(¶ms, &climate, seed) + .expect("non-airless body must have temperature"); + assert!( + t >= cold && t <= warm, + "class={class} atmo={atmo} lat={lat} seed={seed}: \ + temperature {t}°C outside band [{cold}, {warm}]" + ); + } + } + } + } + } + + #[test] + fn unknown_planet_class_falls_back_to_temperate_band() { + // An unrecognised class string must not panic; it falls back to temperate. + let climate = ClimateConstants::default(); + let (cold, warm) = climate.envelope("temperate"); + let params = BodyParams { + planet_class: Some("unknown_alien_class".into()), + atmosphere: Some("breathable".into()), + region_latitude_deg: 0.0, + elevation_km: 0.0, + ..Default::default() }; - let m_params = BodyParams { - spectral_class: Some("M".into()), - ..base_params - }; - let t_g = derive_temperature_c(&g_params, &climate).unwrap(); - let t_m = derive_temperature_c(&m_params, &climate).unwrap(); - // M star has luminosity 0.04 × Sol; same orbital period but star is - // much dimmer so equilibrium temperature is much lower. + let t = derive_temperature_c(¶ms, &climate, 0) + .expect("breathable body must have temperature"); assert!( - t_m < t_g, - "M-star temperature {t_m}°C must be less than G-star {t_g}°C at same orbital period" + t >= cold && t <= warm, + "unknown class temperature {t}°C outside temperate band [{cold}, {warm}]" + ); + } + + #[test] + fn prefix_classes_derive_within_shifted_band() { + // "cold_weird" is not in the table; prefix rule should shift temperate. + let climate = ClimateConstants::default(); + let (base_cold, base_warm) = climate.envelope("temperate"); + let (cold, warm) = climate.envelope("cold_weird"); + assert!( + (cold - (base_cold - 10.0)).abs() < f32::EPSILON * 10.0, + "cold_ prefix should shift cold_end by -10" + ); + assert!( + (warm - (base_warm - 10.0)).abs() < f32::EPSILON * 10.0, + "cold_ prefix should shift warm_end by -10" ); } @@ -1328,30 +1422,18 @@ mod tests { } #[test] - fn temperature_is_deterministic() { - let params = BodyParams { - atmosphere: Some("thin".into()), - spectral_class: Some("K".into()), - orbital_period_days: Some(200.0), - axial_tilt_deg: Some(10.0), - region_latitude_deg: 45.0, - elevation_km: 1.5, - ..Default::default() - }; - let climate = ClimateConstants::default(); - let t1 = derive_temperature_c(¶ms, &climate); - let t2 = derive_temperature_c(¶ms, &climate); - assert_eq!(t1, t2, "temperature derivation must be deterministic"); - } - - #[test] - fn climate_constants_spectral_class_prefix_match() { - // "G2V" should resolve to the same luminosity as "G". + fn climate_constants_envelope_direct_lookup() { + // Direct lookup returns the exact registered band. let cc = ClimateConstants::default(); - assert_eq!(cc.luminosity("G2V"), cc.luminosity("G")); - assert_eq!(cc.luminosity("M5"), cc.luminosity("M")); - // Unknown class falls back to 1.0 (Sol). - assert_eq!(cc.luminosity(""), 1.0); + let (cold, warm) = cc.envelope("tropical"); + assert!( + (cold - 16.0).abs() < f32::EPSILON * 10.0, + "tropical cold_end should be 16°C" + ); + assert!( + (warm - 40.0).abs() < f32::EPSILON * 10.0, + "tropical warm_end should be 40°C" + ); } // ----------------------------------------------------------------------- diff --git a/server/src/main.rs b/server/src/main.rs index 783d374ea..d7d7857ea 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -215,10 +215,10 @@ fn main() { ), } - // Body physical params reader for RegionProfile carrier layer (T-1032, D-239 §1): - // reads hydrosphere / atmosphere / planet_class / orbital_period_days / - // axial_tilt_deg / spectral_class / star_type on a cache miss so the Rayon + // Body physical params reader for RegionProfile carrier layer (T-1032, D-239 §1, D-240): + // reads hydrosphere / atmosphere / planet_class on a cache miss so the Rayon // cascade work item stays DB-free (D-225 pattern). + // D-240: orbit/star fields are non-canonical and are no longer read. match settled_reach_server::atlas::body_params_reader::BodyParamsReader::open(&systems_db_path) { Ok(reader) => {