This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+301
View File
@@ -0,0 +1,301 @@
// Köppen climate classification using the "worldbuilding pasta" band-based
// methodology. Two-season (summer/winter) data is used as a proxy for
// warmest/coldest month values.
//
// Approach:
// Step 1 – Temperature bands (tropical → temperate → continental → tundra → ice cap)
// Step 2 – Arid zones (B) dry in both seasons → desert core + steppe fringe
// Step 3 – Precipitation subtypes within each band (A / C / D details)
//
// IMPORTANT: The simulation labels "summer" and "winter" are NH-centric
// (NH summer = June-Aug, NH winter = Dec-Feb). For each cell we determine
// the LOCAL warm/cold season from temperature and use that to assign the
// correct precipitation pattern (s/w/f). Without this, Mediterranean (Cs)
// and monsoon (Cw/Dw) climates are hemisphere-flipped.
import { smoothstep } from './wind.js';
/**
* Köppen class definitions: ID → { code, name, color [r,g,b] 0-1 }.
*/
export const KOPPEN_CLASSES = [
{ code: 'Ocean', name: 'Ocean', color: [0.29, 0.44, 0.65] }, // #4a6fa5
{ code: 'Af', name: 'Tropical rainforest', color: [0.00, 0.00, 1.00] }, // #0000FF
{ code: 'Am', name: 'Tropical monsoon', color: [0.00, 0.47, 1.00] }, // #0077FF
{ code: 'Aw', name: 'Tropical savanna', color: [0.27, 0.67, 0.98] }, // #46AAFA
{ code: 'BWh', name: 'Hot desert', color: [1.00, 0.00, 0.00] }, // #FF0000
{ code: 'BWk', name: 'Cold desert', color: [1.00, 0.59, 0.59] }, // #FF9696
{ code: 'BSh', name: 'Hot steppe', color: [0.96, 0.65, 0.00] }, // #F5A500
{ code: 'BSk', name: 'Cold steppe', color: [1.00, 0.86, 0.39] }, // #FFDB63
{ code: 'Cfa', name: 'Humid subtropical', color: [0.78, 1.00, 0.31] }, // #C8FF50
{ code: 'Cfb', name: 'Oceanic', color: [0.39, 1.00, 0.31] }, // #64FF50
{ code: 'Cfc', name: 'Subpolar oceanic', color: [0.20, 0.78, 0.00] }, // #32C800
{ code: 'Csa', name: 'Hot-summer Mediterranean', color: [1.00, 1.00, 0.00] }, // #FFFF00
{ code: 'Csb', name: 'Warm-summer Mediterranean', color: [0.78, 0.78, 0.00] }, // #C8C800
{ code: 'Csc', name: 'Cold-summer Mediterranean', color: [0.59, 0.59, 0.00] }, // #969600
{ code: 'Cwa', name: 'Humid subtropical (monsoon)', color: [0.59, 1.00, 0.59] }, // #96FF96
{ code: 'Cwb', name: 'Subtropical highland', color: [0.39, 0.78, 0.39] }, // #63C764
{ code: 'Cwc', name: 'Cold subtropical highland', color: [0.20, 0.59, 0.20] }, // #329633
{ code: 'Dfa', name: 'Hot-summer continental', color: [0.00, 1.00, 1.00] }, // #00FFFF
{ code: 'Dfb', name: 'Warm-summer continental', color: [0.22, 0.78, 1.00] }, // #37C8FF
{ code: 'Dfc', name: 'Subarctic', color: [0.00, 0.49, 0.49] }, // #007D7D
{ code: 'Dfd', name: 'Extremely cold subarctic', color: [0.00, 0.27, 0.37] }, // #00465F
{ code: 'Dsa', name: 'Hot-summer continental (dry summer)', color: [0.90, 0.50, 1.00] }, // #E680FF
{ code: 'Dsb', name: 'Warm-summer continental (dry summer)', color: [0.70, 0.35, 0.85] }, // #B359D9
{ code: 'Dsc', name: 'Subarctic (dry summer)', color: [0.50, 0.20, 0.65] }, // #8033A6
{ code: 'Dsd', name: 'Extremely cold subarctic (dry summer)', color: [0.35, 0.10, 0.45] }, // #591A73
{ code: 'Dwa', name: 'Hot-summer continental (monsoon)', color: [0.67, 0.69, 1.00] }, // #ABB1FF
{ code: 'Dwb', name: 'Warm-summer continental (monsoon)', color: [0.43, 0.47, 0.78] }, // #6E77C8
{ code: 'Dwc', name: 'Subarctic (monsoon)', color: [0.29, 0.31, 0.78] }, // #4A50C8
{ code: 'Dwd', name: 'Extremely cold subarctic (monsoon)', color: [0.20, 0.00, 0.53] }, // #320087
{ code: 'ET', name: 'Tundra', color: [0.70, 0.70, 0.70] }, // #B2B2B2
{ code: 'EF', name: 'Ice cap', color: [0.41, 0.41, 0.41] }, // #686868
];
// Lookup table: KOPPEN_CLASSES code → ID (built once at import time)
const CODE_TO_ID = {};
KOPPEN_CLASSES.forEach((c, i) => { CODE_TO_ID[c.code] = i; });
/**
* Classify each region into a Köppen climate type using the worldbuilding-
* pasta band-based methodology.
*
* @param {object} mesh - SphereMesh
* @param {Float32Array} r_elevation - per-region elevation (<=0 = ocean)
* @param {object} tempResult - { r_temperature_summer, r_temperature_winter } (0-1 → -45..+45 C)
* @param {object} precipResult - { r_precip_summer, r_precip_winter } (0-1 p95-normalized)
* @returns {Uint8Array} r_koppen - per-region class ID (index into KOPPEN_CLASSES)
*/
export function classifyKoppen(mesh, r_elevation, tempResult, precipResult) {
const n = mesh.numRegions;
const r_koppen = new Uint8Array(n);
const tSummer = tempResult.r_temperature_summer;
const tWinter = tempResult.r_temperature_winter;
const pSummer = precipResult.r_precip_summer;
const pWinter = precipResult.r_precip_winter;
for (let r = 0; r < n; r++) {
// ── Ocean ──
if (r_elevation[r] <= 0) {
r_koppen[r] = 0;
continue;
}
// ── Convert normalised values to physical units ──
// Ts/Tw are NH summer/winter proxies — NOT necessarily local warm/cold
const Ts = -45 + Math.max(0, Math.min(1, tSummer[r])) * 90;
const Tw = -45 + Math.max(0, Math.min(1, tWinter[r])) * 90;
const Thot = Math.max(Ts, Tw); // warmest month proxy (°C)
const Tcold = Math.min(Ts, Tw); // coldest month proxy (°C)
const Tann = (Ts + Tw) / 2;
// "Shoulder-month" temperature: approximate the temp 2 months before
// peak summer. With only 2 seasons we interpolate 2/6 of the way from
// peak toward cold. Used for the humid-continental / subarctic split
// and for the tempLetter 'b' criterion (4+ months >= 10°C).
const Tshoulder = Thot - (Thot - Tcold) * (2 / 6);
// ── Hemisphere-aware local seasons ──
// Determine which simulation season is this cell's LOCAL warm season.
// NH cells: sim summer = local summer. SH cells: sim winter = local summer.
const localSummerIsSim = Ts >= Tw;
// Precipitation: each season value ∈ [0,1] represents ~6 months.
// Scale to approximate mm for that half-year.
const Ps = Math.max(0, pSummer[r]) * 1000; // NH summer half-year mm
const Pw = Math.max(0, pWinter[r]) * 1000; // NH winter half-year mm
const Pann = Ps + Pw; // annual mm
// Local summer/winter precipitation (hemisphere-corrected)
const PsummerLocal = localSummerIsSim ? Ps : Pw;
const PwinterLocal = localSummerIsSim ? Pw : Ps;
const PsMonthLocal = PsummerLocal / 6; // avg monthly precip in local summer
const PwMonthLocal = PwinterLocal / 6; // avg monthly precip in local winter
// Estimate driest individual month from the 6-month average.
// A 6-month dry-season average of 40mm might contain months ranging from
// 10mm to 70mm. The stronger the seasonal contrast (wet vs dry half-year),
// the more peaked the distribution within each half-year, so the driest
// month is further below the half-year average.
// Factor: at equal seasons (ratio=1) → driest ≈ 0.7× average
// at strong monsoon (ratio=5+) → driest ≈ 0.35× average
const seasonRatio = Math.max(PsMonthLocal, PwMonthLocal) / (Math.min(PsMonthLocal, PwMonthLocal) || 1);
const driestFraction = 0.60 - 0.35 * smoothstep(1, 4, seasonRatio);
const Pdry = Math.min(PsMonthLocal, PwMonthLocal) * driestFraction;
// ================================================================
// STEP 1 – TEMPERATURE BANDS
// ================================================================
// Band codes: 'A' tropical, 'C' temperate, 'D' continental,
// 'ET' tundra, 'EF' ice cap
// Sub-bands for temperate: 'hotSummer' (>=22°C) vs 'coolSummer'
// Sub-bands for continental: 'humidCont' (Tshoulder>=10) vs 'subarctic'
let band;
let tempSubBand = ''; // 'hotSummer'|'coolSummer' for C; 'humidCont'|'subarctic' for D
if (Thot < 0) {
// Ice cap: warmest month < 0°C
band = 'EF';
} else if (Thot < 10) {
// Tundra: warmest month 0-10°C
band = 'ET';
} else if (Tcold >= 18) {
// Tropical: coldest month >= 18°C
band = 'A';
} else if (Tcold >= 0) {
// Temperate: coldest month 0-18°C AND warmest >= 10°C
band = 'C';
tempSubBand = Thot >= 22 ? 'hotSummer' : 'coolSummer';
} else {
// Continental: coldest month < 0°C AND warmest >= 10°C
band = 'D';
tempSubBand = Tshoulder >= 10 ? 'humidCont' : 'subarctic';
}
// ── Short-circuit polar types ──
if (band === 'EF') { r_koppen[r] = CODE_TO_ID['EF']; continue; }
if (band === 'ET') { r_koppen[r] = CODE_TO_ID['ET']; continue; }
// ================================================================
// STEP 2 – ARID ZONES (B)
// ================================================================
// The blog approach: areas "dry in both seasons" become desert by
// default, with steppe as a transition on the edges.
//
// We use the standard Köppen aridity threshold (which encodes the
// idea of evapotranspiration exceeding precipitation) to decide B,
// then split desert vs steppe.
//
// h/k is determined by mean annual temperature (standard Köppen):
// Tann >= 18°C → hot (h)
// Tann < 18°C → cold (k)
//
// summerFrac uses LOCAL warm-season precipitation (hemisphere-corrected)
// because the threshold encodes evapotranspiration which peaks in
// the warm season regardless of hemisphere.
let Pthresh;
const summerFrac = Pann > 0 ? PsummerLocal / Pann : 0.5;
if (summerFrac >= 0.7) {
Pthresh = 20 * Tann + 280;
} else if (summerFrac <= 0.3) {
Pthresh = 20 * Tann;
} else {
Pthresh = 20 * Tann + 140;
}
Pthresh = Math.max(0, Pthresh);
if (Pann < Pthresh) {
const isHot = Tann >= 18; // standard Köppen: h if mean annual temp >= 18°C
if (Pann < Pthresh * 0.5) {
// Desert
r_koppen[r] = isHot ? CODE_TO_ID['BWh'] : CODE_TO_ID['BWk'];
} else {
// Steppe (transition fringe)
r_koppen[r] = isHot ? CODE_TO_ID['BSh'] : CODE_TO_ID['BSk'];
}
continue;
}
// ================================================================
// STEP 3 – PRECIPITATION SUBTYPES WITHIN EACH BAND
// ================================================================
// ── Determine s / w / f precipitation pattern ──
// All comparisons use LOCAL summer/winter so the pattern is correct
// in both hemispheres.
// Our "monthly" values are 6-month averages, not individual months —
// this smooths the driest/wettest month contrast, so thresholds are
// relaxed vs. standard Köppen (which uses actual monthly extremes).
// s = dry local summer: summer month < 50mm AND < 1/2 winter month
// w = dry local winter: winter month < 1/4 summer month
// (relaxed from standard 1/10 because 6-month averages compress contrast)
// f = no dry season
let precipPattern;
const localSummerDrier = PsummerLocal < PwinterLocal;
if (localSummerDrier && PsMonthLocal < 50 && PsMonthLocal < PwMonthLocal / 2) {
precipPattern = 's';
} else if (!localSummerDrier && PwMonthLocal < PsMonthLocal / 3) {
precipPattern = 'w';
} else {
precipPattern = 'f';
}
// ── Determine temperature sub-letter (a / b / c / d) ──
// a: warmest month >= 22°C
// b: warmest < 22°C but 4+ months >= 10°C (proxy: Tshoulder >= 10°C)
// c: fewer than 4 months >= 10°C, coldest >= −38°C
// d: coldest < −38°C (extreme continental, only for D)
let tempLetter;
if (Thot >= 22) {
tempLetter = 'a';
} else if (Tshoulder >= 10) {
tempLetter = 'b';
} else if (Tcold >= -38) {
tempLetter = 'c';
} else {
tempLetter = 'd';
}
// ── Band A: Tropical ──
if (band === 'A') {
// Blog approach:
// very wet both seasons → Af (tropical rainforest)
// wet both seasons → Am (tropical monsoon)
// wet one season, dry other → Aw (tropical savanna)
//
// Translated with thresholds:
// Af: driest month >= 60 mm
// Am: Pann >= 25*(100 - Pdry) (i.e. enough total rain to sustain forest
// despite a short dry spell)
// Aw: everything else
if (Pdry >= 60) {
r_koppen[r] = CODE_TO_ID['Af'];
} else if (Pann >= 25 * (100 - Pdry)) {
r_koppen[r] = CODE_TO_ID['Am'];
} else {
r_koppen[r] = CODE_TO_ID['Aw'];
}
continue;
}
// ── Band C: Temperate ──
if (band === 'C') {
// Blog approach:
// dry local summer → Mediterranean (Cs)
// remaining hot-summer → humid subtropical (Cfa / Cwa)
// remaining cool-summer → oceanic (Cfb / Cwb / Cfc / Cwc)
const code = 'C' + precipPattern + tempLetter;
const id = CODE_TO_ID[code];
if (id !== undefined) {
r_koppen[r] = id;
} else {
r_koppen[r] = CODE_TO_ID['Cfb'];
}
continue;
}
// ── Band D: Continental ──
if (band === 'D') {
// Blog approach:
// humid continental (Tshoulder >= 10°C) = Dfa/Dfb/Dsa/Dsb/Dwa/Dwb
// subarctic (Tshoulder < 10°C) = Dfc/Dfd/Dsc/Dsd/Dwc/Dwd
//
// Ds zones appear near Mediterranean regions; Dw zones appear
// near regions with strong monsoon effect (far ITCZ excursion).
const code = 'D' + precipPattern + tempLetter;
const id = CODE_TO_ID[code];
if (id !== undefined) {
r_koppen[r] = id;
} else {
const fallback = 'Df' + tempLetter;
r_koppen[r] = CODE_TO_ID[fallback] || CODE_TO_ID['Dfc'];
}
continue;
}
}
return r_koppen;
}