Tooling
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
// Heuristic precipitation model: smooth zonal patterns blended with the
|
||||
// complex advection model to reduce splotchiness and strengthen deserts.
|
||||
// Computes precipitation from four multiplicative factors: zonal base curve
|
||||
// (distance from ITCZ), seasonal modifier, continental dryness, and
|
||||
// orographic rain shadow.
|
||||
|
||||
import { smoothstep } from './wind.js';
|
||||
import { elevToHeightKm } from './color-map.js';
|
||||
import { smoothField, makeItczLookup } from './climate-util.js';
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
// ── Zonal base curve ────────────────────────────────────────────────────────
|
||||
// Returns a value in [0.03, 1.0] based on distance from the ITCZ in degrees.
|
||||
|
||||
function zonalBase(distDeg) {
|
||||
if (distDeg < 5) {
|
||||
// ITCZ core: 1.0
|
||||
return 1.0;
|
||||
} else if (distDeg < 10) {
|
||||
// Outer ITCZ / trades: 1.0 → 0.35 (faster falloff)
|
||||
return 1.0 - 0.65 * smoothstep(5, 10, distDeg);
|
||||
} else if (distDeg < 33) {
|
||||
// Subtropical highs (desert factory): 0.35 → 0.02
|
||||
// Very aggressive minimum — core of the desert belt.
|
||||
return 0.35 - 0.33 * smoothstep(10, 28, distDeg);
|
||||
} else if (distDeg < 55) {
|
||||
// Mid-lat westerlies recovery: 0.02 → 0.5
|
||||
return 0.02 + 0.48 * smoothstep(33, 55, distDeg);
|
||||
} else if (distDeg < 70) {
|
||||
// Subpolar: 0.5 → 0.3
|
||||
return 0.5 - 0.2 * smoothstep(55, 70, distDeg);
|
||||
} else {
|
||||
// Polar: 0.3 → 0.1
|
||||
return 0.3 - 0.2 * smoothstep(70, 90, distDeg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Heuristic zonal wind ────────────────────────────────────────────────────
|
||||
// Idealized wind direction based on latitude relative to the ITCZ.
|
||||
// Returns local east/north components (positive east = blowing eastward,
|
||||
// positive north = blowing poleward in NH).
|
||||
//
|
||||
// Zonal wind belts (Earth-like):
|
||||
// ITCZ (0-5°): light/convergent
|
||||
// Trades (5-30°): strong easterlies, deflected equatorward by Coriolis
|
||||
// Subtropical (25-35°): weak/variable (transition)
|
||||
// Westerlies (35-60°): west→east, deflected poleward
|
||||
// Polar easterlies (60-90°): east→west, deflected equatorward
|
||||
|
||||
function heuristicWind(distFromItczDeg, isNorthOfItcz) {
|
||||
// Sign for hemisphere: +1 if north of ITCZ, -1 if south
|
||||
const hemiSign = isNorthOfItcz ? 1 : -1;
|
||||
let we, wn;
|
||||
|
||||
if (distFromItczDeg < 5) {
|
||||
// ITCZ: light convergent winds — slight equatorward component
|
||||
we = 0;
|
||||
wn = -hemiSign * 0.1;
|
||||
} else if (distFromItczDeg < 30) {
|
||||
// Trade winds: easterlies (blowing westward) with equatorward component
|
||||
// Strength ramps up from ITCZ edge, peaks ~15-20°, fades toward subtropics
|
||||
const tradeStrength = smoothstep(5, 15, distFromItczDeg)
|
||||
* (1 - smoothstep(25, 32, distFromItczDeg));
|
||||
we = -tradeStrength * 0.8; // strong westward
|
||||
wn = -hemiSign * tradeStrength * 0.3; // equatorward (toward ITCZ)
|
||||
} else if (distFromItczDeg < 60) {
|
||||
// Westerlies: blowing eastward with poleward component
|
||||
const westStrength = smoothstep(30, 40, distFromItczDeg)
|
||||
* (1 - smoothstep(55, 65, distFromItczDeg));
|
||||
we = westStrength * 0.9; // strong eastward
|
||||
wn = hemiSign * westStrength * 0.25; // poleward
|
||||
} else {
|
||||
// Polar easterlies: blowing westward with equatorward component
|
||||
const polarStrength = smoothstep(60, 70, distFromItczDeg);
|
||||
we = -polarStrength * 0.4; // moderate westward
|
||||
wn = -hemiSign * polarStrength * 0.15; // equatorward
|
||||
}
|
||||
|
||||
return { we, wn };
|
||||
}
|
||||
|
||||
// ── Heuristic wind field for a full season ──────────────────────────────────
|
||||
// Computes idealized zonal wind E/N arrays for all regions.
|
||||
|
||||
export function computeHeuristicWindField(numRegions, r_lat, r_lon, itczLookup) {
|
||||
const hWindE = new Float32Array(numRegions);
|
||||
const hWindN = new Float32Array(numRegions);
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const lat = r_lat[r];
|
||||
const itczLat = itczLookup(r_lon[r]) * 0.3; // dampened ITCZ, same as precip
|
||||
const signedDist = lat - itczLat;
|
||||
const distDeg = Math.abs(signedDist) / DEG;
|
||||
const northOfItcz = signedDist > 0;
|
||||
const { we, wn } = heuristicWind(distDeg, northOfItcz);
|
||||
hWindE[r] = we;
|
||||
hWindN[r] = wn;
|
||||
}
|
||||
|
||||
return { hWindE, hWindN };
|
||||
}
|
||||
|
||||
// ── Main entry point ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute heuristic precipitation for both seasons.
|
||||
* Returns raw (un-normalized) Float32Arrays.
|
||||
*
|
||||
* @param {SphereMesh} mesh
|
||||
* @param {Float32Array} r_xyz
|
||||
* @param {Float32Array} r_elevation
|
||||
* @param {object} windResult - output from computeWind()
|
||||
* @param {Float32Array} r_elevGradE - pre-computed east elevation gradient
|
||||
* @param {Float32Array} r_elevGradN - pre-computed north elevation gradient
|
||||
* @param {Int32Array} r_coastDistLand - BFS hop distance from coast through land
|
||||
* @returns {{ r_precip_summer, r_precip_winter }}
|
||||
*/
|
||||
export function computeHeuristicPrecipitation(mesh, r_xyz, r_elevation, windResult, r_elevGradE, r_elevGradN, r_coastDistLand) {
|
||||
const numRegions = mesh.numRegions;
|
||||
const { r_lat, r_lon, r_isLand, r_continentality } = windResult;
|
||||
|
||||
const avgEdgeKm = (Math.PI * 6371) / Math.sqrt(numRegions);
|
||||
|
||||
// Precompute west-coast proximity: positive = west coast, negative = east coast.
|
||||
// Coastal land cells check which side ocean is on relative to the local east
|
||||
// direction, then the signal is smoothed ~300 km inland through land only.
|
||||
const { r_eastX, r_eastY, r_eastZ } = windResult;
|
||||
const { adjOffset, adjList } = mesh;
|
||||
const r_westCoast = new Float32Array(numRegions);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (!r_isLand[r] || r_coastDistLand[r] !== 0) continue;
|
||||
let oceanDotEast = 0;
|
||||
let count = 0;
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
if (!r_isLand[nb]) {
|
||||
const dx = r_xyz[3 * nb] - r_xyz[3 * r];
|
||||
const dy = r_xyz[3 * nb + 1] - r_xyz[3 * r + 1];
|
||||
const dz = r_xyz[3 * nb + 2] - r_xyz[3 * r + 2];
|
||||
oceanDotEast += dx * r_eastX[r] + dy * r_eastY[r] + dz * r_eastZ[r];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count > 0) {
|
||||
// Negative dot = ocean is to the west = west coast
|
||||
r_westCoast[r] = oceanDotEast < 0 ? 1 : -1;
|
||||
}
|
||||
}
|
||||
// Smooth through land only (~300 km) so the signal bleeds inland
|
||||
const wcPasses = Math.max(2, Math.round(300 / avgEdgeKm));
|
||||
const wcTmp = new Float32Array(numRegions);
|
||||
for (let pass = 0; pass < wcPasses; pass++) {
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (!r_isLand[r]) { wcTmp[r] = 0; continue; }
|
||||
let sum = r_westCoast[r], count = 1;
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
if (r_isLand[nb]) { sum += r_westCoast[nb]; count++; }
|
||||
}
|
||||
wcTmp[r] = sum / count;
|
||||
}
|
||||
r_westCoast.set(wcTmp);
|
||||
}
|
||||
|
||||
const result = {};
|
||||
|
||||
const seasons = [
|
||||
{ name: 'summer', shift: 5 },
|
||||
{ name: 'winter', shift: -5 }
|
||||
];
|
||||
|
||||
for (const { name } of seasons) {
|
||||
const isSummer = name === 'summer';
|
||||
|
||||
const itczLookup = makeItczLookup(windResult.itczLons,
|
||||
isSummer ? windResult.itczLatsSummer : windResult.itczLatsWinter);
|
||||
|
||||
const precip = new Float32Array(numRegions);
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const lat = r_lat[r];
|
||||
const lon = r_lon[r];
|
||||
|
||||
// ── A. Zonal base curve (distance from ITCZ) ──
|
||||
// Dampen ITCZ shift: use only 30% of the complex model's ITCZ
|
||||
// displacement so the zonal bands stay close to the geographic
|
||||
// equator. The full ITCZ swing (up to 15-20°) would drag the
|
||||
// subtropical desert belt too far, drying the true equator and
|
||||
// wetting the mid-latitudes in the shifted season.
|
||||
const itczLat = itczLookup(lon) * 0.3;
|
||||
const signedDist = lat - itczLat;
|
||||
const distFromItczDeg = Math.abs(signedDist) / DEG;
|
||||
const isNorthOfItcz = signedDist > 0;
|
||||
const zonal = zonalBase(distFromItczDeg);
|
||||
|
||||
// ── B. Seasonal modifier + Mediterranean subtropical suppression ──
|
||||
const absLatDeg = Math.abs(lat) / DEG;
|
||||
const inSummerHemi = isSummer ? (lat >= 0) : (lat < 0);
|
||||
let seasonMod = inSummerHemi ? 1.1 : 0.9;
|
||||
|
||||
// Mediterranean suppression: subtropical highs expand poleward in
|
||||
// local summer, strongly suppressing rainfall at 25-42° latitude.
|
||||
// In local winter the highs retreat equatorward and westerlies
|
||||
// bring rain to these latitudes. This seasonal contrast is the
|
||||
// primary driver of Mediterranean (Cs) climates.
|
||||
// Stronger on west coasts (subtropical highs sit over eastern ocean
|
||||
// basins, drying the adjacent western continental margins) and
|
||||
// weaker on east coasts (onshore tropical moisture counters drying).
|
||||
if (inSummerHemi && absLatDeg > 22 && absLatDeg < 45) {
|
||||
const medSuppress = smoothstep(22, 30, absLatDeg)
|
||||
* (1 - smoothstep(38, 45, absLatDeg));
|
||||
const wc = r_westCoast[r]; // +1 west coast, -1 east coast, 0 inland
|
||||
const strength = 0.15 + wc * 0.20; // 0.35 west coast, 0.15 inland, ~0 east coast
|
||||
seasonMod *= (1 - medSuppress * Math.max(0, strength));
|
||||
}
|
||||
|
||||
// ── C. Continental dryness ──
|
||||
let contMod = 1.0;
|
||||
const cont = (r_isLand[r] && r_continentality) ? r_continentality[r] : 0;
|
||||
if (cont > 0) {
|
||||
contMod = 1.0 - cont * cont * 0.65;
|
||||
}
|
||||
|
||||
// ── D. Orographic rain shadow (using heuristic zonal wind) ──
|
||||
let oroMod = 1.0;
|
||||
if (r_isLand[r] && r_elevation[r] > 0) {
|
||||
const { we, wn } = heuristicWind(distFromItczDeg, isNorthOfItcz);
|
||||
// Wind dot elevation gradient: positive = windward, negative = leeward
|
||||
const windDotGrad = we * r_elevGradE[r] + wn * r_elevGradN[r];
|
||||
|
||||
if (windDotGrad > 0) {
|
||||
// Windward: up to +60% boost
|
||||
const uplift = Math.min(1, windDotGrad * 15);
|
||||
oroMod = 1.0 + uplift * 0.6;
|
||||
} else {
|
||||
// Leeward: up to -70% suppression, scaled by mountain height
|
||||
const heightKm = elevToHeightKm(Math.max(0, r_elevation[r]));
|
||||
const heightScale = Math.min(1, heightKm / 3); // 3km+ = full shadow
|
||||
const shadow = Math.min(1, -windDotGrad * 18);
|
||||
oroMod = Math.max(0.3, 1.0 - shadow * 0.7 * heightScale);
|
||||
}
|
||||
}
|
||||
|
||||
// ── E. Hard distance-from-coast cutoff ──
|
||||
// Fixed 2000-3000km cutoff regardless of latitude.
|
||||
let distMod = 1.0;
|
||||
if (r_isLand[r] && r_coastDistLand[r] > 0) {
|
||||
const distKm = r_coastDistLand[r] * avgEdgeKm;
|
||||
if (distKm > 2000) {
|
||||
distMod = Math.max(0.03, 1 - smoothstep(2000, 3000, distKm));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Final ──
|
||||
precip[r] = Math.max(0.05, zonal * seasonMod * contMod * oroMod * distMod);
|
||||
}
|
||||
|
||||
// Light smoothing ~100km
|
||||
const smoothPasses = Math.max(1, Math.round(100 / avgEdgeKm));
|
||||
smoothField(mesh, precip, smoothPasses);
|
||||
|
||||
result[`r_precip_${name}`] = precip;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user