Files
2026-09-25 17:02:24 +03:00

302 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}