Merge remote-tracking branch 'origin/main' into verification-harness
This commit is contained in:
@@ -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
|
||||
@@ -41,18 +67,20 @@ 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
|
||||
[hydrosphere_maritime]
|
||||
# Maritime moderation (D-240): scales the equator→pole temperature gradient by
|
||||
# hydrosphere. 1.0 = full gradient (dry world swings the whole class band);
|
||||
# < 1.0 compresses toward the band midpoint (water-rich worlds are milder at
|
||||
# both poles and equator). Keys = actual bodies.hydrosphere vocabulary in
|
||||
# systems.db. "minimal"/"trace"/"none"/absent default to 1.0 in code.
|
||||
liquid_water = 0.6 # large surface liquid — strongly moderated
|
||||
ocean = 0.6
|
||||
"ocean-coastal" = 0.6
|
||||
extensive = 0.6
|
||||
rivers = 0.8 # partial surface water — mild moderation
|
||||
"rivers-lakes" = 0.8
|
||||
moderate = 0.8
|
||||
ice = 0.85 # frozen surface — slight moderation
|
||||
subsurface_liquid = 0.9
|
||||
subsurface = 0.95 # buried water — minimal surface effect
|
||||
subsurface_ice = 0.95
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+378
-212
@@ -124,30 +124,29 @@ 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
|
||||
/// `bodies.hydrosphere` — the real vocabulary in systems.db: "ice",
|
||||
/// "liquid_water", "none", "rivers", "moderate", "ocean", "minimal", "trace",
|
||||
/// "subsurface_liquid", "rivers-lakes", "extensive", "subsurface_ice",
|
||||
/// "subsurface", "ocean-coastal" | NULL. The `[hydrosphere_maritime]` table
|
||||
/// keys on these for temperature moderation (D-240).
|
||||
pub hydrosphere: Option<String>,
|
||||
/// `bodies.atmosphere` — "breathable" | "thin" | "toxic" | "none" | "dense" | NULL
|
||||
pub atmosphere: Option<String>,
|
||||
/// `bodies.planet_class` — "temperate" | "arid" | "frozen" | "oceanic" | "volcanic" | … | NULL
|
||||
pub planet_class: Option<String>,
|
||||
/// `bodies.orbital_period_days` — orbital period in Earth days.
|
||||
pub orbital_period_days: Option<f64>,
|
||||
/// `bodies.tectonic_activity` — optional authored tectonic override.
|
||||
/// "stable" | "active" | "volcanic" | "tidally_forced". If absent, derived
|
||||
/// from `planet_class`.
|
||||
pub tectonic_activity: Option<String>,
|
||||
/// `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<f64>,
|
||||
/// `star_systems.spectral_class` — stellar spectral class ("G", "K", "M", …).
|
||||
/// Used to derive stellar luminosity for temperature calculation (T-1024).
|
||||
pub spectral_class: Option<String>,
|
||||
/// `star_systems.star_type` — fallback stellar type if spectral_class is absent.
|
||||
pub star_type: Option<String>,
|
||||
/// 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 +623,53 @@ 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<String, (f32, f32)>,
|
||||
/// 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<String, f32>,
|
||||
/// Day/night swing amplitude per atmosphere class (°C).
|
||||
pub diurnal_amplitude_c: std::collections::BTreeMap<String, f32>,
|
||||
/// Stellar luminosity relative to Sol. Keys: spectral class letter.
|
||||
pub star_luminosity: std::collections::BTreeMap<String, f64>,
|
||||
/// Maritime-moderation factor per `hydrosphere` (D-240): scales the
|
||||
/// equator→pole gradient. `1.0` = full gradient (dry world swings the whole
|
||||
/// band); `< 1.0` compresses toward the band midpoint (water-rich worlds are
|
||||
/// milder at both ends). Absent/unknown = `1.0`.
|
||||
pub hydrosphere_maritime: std::collections::BTreeMap<String, f32>,
|
||||
}
|
||||
|
||||
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 +678,63 @@ 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);
|
||||
// Maritime moderation: water-rich worlds compress the equator–pole gradient.
|
||||
// Keys are the actual `bodies.hydrosphere` vocabulary in systems.db.
|
||||
let mut hm = std::collections::BTreeMap::new();
|
||||
// Large surface liquid — strong moderation.
|
||||
hm.insert("liquid_water".into(), 0.6f32);
|
||||
hm.insert("ocean".into(), 0.6f32);
|
||||
hm.insert("ocean-coastal".into(), 0.6f32);
|
||||
hm.insert("extensive".into(), 0.6f32);
|
||||
// Partial surface water — mild moderation.
|
||||
hm.insert("rivers".into(), 0.8f32);
|
||||
hm.insert("rivers-lakes".into(), 0.8f32);
|
||||
hm.insert("moderate".into(), 0.8f32);
|
||||
// Frozen / subsurface — slight moderation.
|
||||
hm.insert("ice".into(), 0.85f32);
|
||||
hm.insert("subsurface_liquid".into(), 0.9f32);
|
||||
hm.insert("subsurface".into(), 0.95f32);
|
||||
hm.insert("subsurface_ice".into(), 0.95f32);
|
||||
// "minimal" / "trace" / "none" / NULL → 1.0 (full gradient) via fallback.
|
||||
|
||||
ClimateConstants {
|
||||
planet_class_temperature: pct,
|
||||
greenhouse_offset_c: gh,
|
||||
diurnal_amplitude_c: da,
|
||||
star_luminosity: sl,
|
||||
hydrosphere_maritime: hm,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -681,6 +742,16 @@ impl ClimateConstants {
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Maritime-moderation factor for a given `hydrosphere` (D-240): `1.0` = full
|
||||
/// equator→pole gradient (dry/unknown); `< 1.0` compresses toward the band
|
||||
/// midpoint (ocean ≈ 0.6 → milder at both ends).
|
||||
pub fn maritime_factor(&self, hydrosphere: &str) -> f32 {
|
||||
self.hydrosphere_maritime
|
||||
.get(hydrosphere)
|
||||
.copied()
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
/// Diurnal amplitude for a given atmosphere class (°C), defaulting to 30.
|
||||
pub fn diurnal_amplitude(&self, atmosphere: &str) -> f32 {
|
||||
self.diurnal_amplitude_c
|
||||
@@ -688,46 +759,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<f32> {
|
||||
/// 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<f32> {
|
||||
let atmosphere = params.atmosphere.as_deref().unwrap_or("none");
|
||||
|
||||
// No atmosphere → airless body; temperature is None (D-227).
|
||||
@@ -735,48 +797,50 @@ 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 across the maritime-moderated band (D-240). Water-rich
|
||||
// worlds compress the equator→pole gradient toward the band midpoint — an ocean
|
||||
// world is milder at both ends; a dry world swings the full band.
|
||||
let hydrosphere = params.hydrosphere.as_deref().unwrap_or("none");
|
||||
let maritime = constants.maritime_factor(hydrosphere);
|
||||
let mid = (cold + warm) * 0.5;
|
||||
let half = band_width * 0.5 * maritime;
|
||||
let lat_frac = (params.region_latitude_deg.abs() as f32 / 90.0).clamp(0.0, 1.0);
|
||||
// equator (frac 0) → mid + half; pole (frac 1) → mid − half.
|
||||
let t_lat = (mid + half) - (2.0 * half) * 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 +883,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 +946,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 +1088,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 +1214,22 @@ mod tests {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// T-1024 climate derivation tests
|
||||
// T-1024 / D-240 climate derivation tests — planet_class envelope model
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Convenience: derive temperature with a fixed seed (0) — deterministic, but
|
||||
/// note seed 0 still applies its (constant, non-zero) nudge like any other seed.
|
||||
fn temp(planet_class: &str, atmosphere: &str, lat: f64, elev_km: f64) -> Option<f32> {
|
||||
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 +1238,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 +1257,200 @@ 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<f64> = (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 {
|
||||
// Sweep sea level AND high altitude so the lapse term + clamp
|
||||
// are exercised jointly against the cold end, not just lat alone.
|
||||
for elev in [0.0_f64, 6.0] {
|
||||
// Sweep hydrosphere too, so the maritime-compressed gradient
|
||||
// path is covered by the band invariant (not just None/1.0).
|
||||
for hydro in ["none", "ocean", "liquid_water", "rivers", "ice"] {
|
||||
for seed in seeds {
|
||||
let params = BodyParams {
|
||||
planet_class: Some((*class).into()),
|
||||
atmosphere: Some((*atmo).into()),
|
||||
hydrosphere: Some(hydro.into()),
|
||||
region_latitude_deg: lat,
|
||||
elevation_km: elev,
|
||||
..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} hydro={hydro} lat={lat} \
|
||||
elev={elev} seed={seed}: temperature {t}°C outside \
|
||||
band [{cold}, {warm}]"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maritime_hydrosphere_compresses_the_gradient() {
|
||||
// D-240: a water-rich world has a SMALLER equator→pole temperature delta
|
||||
// than a dry world of the same class (maritime moderation).
|
||||
let climate = ClimateConstants::default();
|
||||
let mk = |hydro: &str, lat: f64| BodyParams {
|
||||
planet_class: Some("temperate".into()),
|
||||
atmosphere: Some("standard".into()),
|
||||
hydrosphere: Some(hydro.into()),
|
||||
region_latitude_deg: lat,
|
||||
elevation_km: 0.0,
|
||||
..Default::default()
|
||||
};
|
||||
let m_params = BodyParams {
|
||||
spectral_class: Some("M".into()),
|
||||
..base_params
|
||||
// Same seed → only hydrosphere differs.
|
||||
let delta = |hydro: &str| {
|
||||
(derive_temperature_c(&mk(hydro, 0.0), &climate, 7).unwrap()
|
||||
- derive_temperature_c(&mk(hydro, 90.0), &climate, 7).unwrap())
|
||||
.abs()
|
||||
};
|
||||
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 ocean_delta = delta("ocean");
|
||||
let dry_delta = delta("none"); // hydrosphere none ≠ airless (atmosphere is "standard")
|
||||
assert!(
|
||||
t_m < t_g,
|
||||
"M-star temperature {t_m}°C must be less than G-star {t_g}°C at same orbital period"
|
||||
ocean_delta < dry_delta,
|
||||
"ocean gradient {ocean_delta}°C must be milder than dry {dry_delta}°C"
|
||||
);
|
||||
}
|
||||
|
||||
#[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 t = derive_temperature_c(¶ms, &climate, 0)
|
||||
.expect("breathable body must have temperature");
|
||||
assert!(
|
||||
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 +1506,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"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
+3
-3
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user