diff options
| author | godosa <godosa@godosa.eu> | 2026-10-06 23:52:03 +0200 |
|---|---|---|
| committer | godosa <godosa@godosa.eu> | 2026-10-06 23:52:03 +0200 |
| commit | 346b1c5195bffc71ceaa9262453e3c189656400b (patch) | |
| tree | 01ac0d31e2724cd6abcc689a5a228e2cbea2f6cf | |
| download | worldgen-346b1c5195bffc71ceaa9262453e3c189656400b.tar.gz worldgen-346b1c5195bffc71ceaa9262453e3c189656400b.zip | |
worldgen: initial public history
73 files changed, 9283 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a257d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +__pycache__/ +*.pyc +.venv +node_modules/ +.worktrees/ +out/ + +# private state (lives in the private project repo) +TASKS.md +tasks/ +workflow.toml +docs/plans/ +docs/specs/ +docs/AREA-NOTES.md +USER-NEXT.md +CATALOG.md +publish-denylist.local +archive/ +.wf/ +.superpowers/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3813efa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,26 @@ +# CLAUDE.md — worldgen + +Procedural world generator (H3 grid, tectonics → erosion → climate → hydrology → render). Terse everywhere. +Private notes (tasks, specs, area maps) are kept outside this repo; never add them here (see `.gitignore`). + +## Layout +- `mapgen.py` — CLI (new-world, build, check, era, compare; `--res`, `--final`, `--low-memory`, `--from/--stop <stage>`) +- `mapgen/` — pipeline stages, order = `STAGES` in pipeline.py: grid sketch plates crust elevation erosion climate + hydrology seabed environment ice fields render; `mapgen/testing.py` + `mapgen/testdata/world.toml` = test fixtures + (also used by worldmap-viewer's tests: keep `small_world` / `built_world` stable) +- `example/` — starter world; `tests/` — unittest. worldhistory reads `cells.npz`. + +## Build / test +- Setup: `python3 -m venv .venv && .venv/bin/pip install -r requirements.txt`; in a git worktree link the main + tree's `.venv` (`ln -sfn <main>/.venv .venv`; `.venv` is ignored without trailing slash: it may be a link). +- Verify: `.venv/bin/python -m unittest discover -s tests -t .` (270 tests, ~2 min, multi-core). + +## Gotchas +- System python lacks scipy/h3: always `.venv/bin/python`. +- numba optional (not in requirements): JIT fast paths in graph.py / noise.py / render.py; `WORLDGEN_NO_JIT=1` + forces numpy paths; `WORLDGEN_THREADS` caps workers; test_sphere_noise skips without numba. +- Outputs must stay byte-identical across `--low-memory` (test_low_memory) — memory work never changes results. +- Real builds at r5 / `--final` are big (README Troubleshooting). + +## Git +- Work in a git worktree on a branch (`.worktrees/<topic>`), not in the main tree; ff-merge when green. diff --git a/CREDITS.md b/CREDITS.md new file mode 100644 index 0000000..f91ccaf --- /dev/null +++ b/CREDITS.md @@ -0,0 +1,30 @@ +# Credits + +| Kind | Who | Note | +|---|---|---| +| Author | godosa | | +| Libraries | see [licenses.md](licenses.md) | | +| Tooling | AI assistance (Claude, Anthropic) | engineering tool; statement in README | + +## Models and sources + +Methods taken from papers, textbooks or other projects. Add a row in the same commit as the code. + +| Source | Used for | Module | +|---|---|---| +| H3 hexagonal grid (Uber, h3geo.org) | world cells and neighbours | `grid.py` | +| Euler-pole plate kinematics (rigid plates rotating about a pole; standard plate tectonics) | plate motions, boundary types | `plates.py`, `sphere.py` | +| Parsons & Sclater 1977, JGR 82(5) (ocean depth ≈ 2500 + 350·√age m) | sea-floor depth from crust age | `elevation.py` | +| Ebert, Musgrave, Peachey, Perlin & Worley 1994, *Texturing and Modeling: A Procedural Approach* (value noise, fractal sums; ridged noise 1 − \|fBm\| after Musgrave's ridged multifractal) | procedural detail, old orogens, fractal coasts | `noise.py`, `crust.py`, `elevation.py`, `zones.py` | +| Daily-mean top-of-atmosphere insolation (standard astronomical formula); three-cell circulation (Hadley, Ferrel, polar); 6.5 °C/km lapse rate (ICAO standard atmosphere) | temperature, winds | `climate.py`, `check.py` | +| Stommel 1948, Trans. AGU 29(2) (westward intensification); Ekman 1905 (wind-driven transport) | surface currents, upwelling | `ocean.py` | +| Angot, Bruneau & Fabrie 1999, Numer. Math. 81 (Brinkman penalisation) | land as high-friction fluid in the current solver | `ocean.py` | +| Holdridge 1947, Science 105; Holdridge 1967, *Life Zone Ecology* (life zones, biotemperature, PET = 58.93 × biotemperature) | biomes, potential evapotranspiration | `climate.py`, `environment.py`, `render.py` | +| Pike 1964, J. Hydrology 2 (actual evapotranspiration from rain and PET) | runoff | `hydrology.py` | +| Barnes, Lehman & Mulla 2014, Computers & Geosciences 62 (priority-flood + ε) | depression filling, drainage | `graph.py`, `hydrology.py`, `erosion.py` | +| O'Callaghan & Mark 1984 (steepest-descent flow routing, D8), here on hexagons | receivers, flow accumulation | `graph.py` | +| Strahler 1957, Trans. AGU 38(6) (stream order) | river order | `hydrology.py`, `geo.py` | +| Braun & Willett 2013, Geomorphology 180 (implicit stream-power incision) | erosion | `erosion.py` | +| Marching squares (contouring; cf. Lorensen & Cline 1987) | coastline and lake outlines | `geo.py` | +| Equal Earth projection (Šavrič, Patterson & Jenny 2018); Mollweide 1805 | map projections | `projections.py` | +| Kerguelen Plateau (real sunken continental plateau) | inspiration for sunken plateaus | `plateaus.py` | @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 godosa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cea060e --- /dev/null +++ b/README.md @@ -0,0 +1,327 @@ +# worldgen + +Generates a whole planet — plate tectonics, mountains, erosion, climate, rivers, lakes, ice, biomes, ores, sea +floor — on a hexagonal grid ([H3](https://h3geo.org)). You steer it with a rough sketch of where land goes plus a few +config files; physics fills in the rest. Look at the result with +[worldmap-viewer](../worldmap-viewer) (a 3D globe in the browser you can fly down to ground level) or the preview +PNGs it writes. + +Everything runs locally. + +## Requirements + +- Python ≥ 3.11, `pip install -r requirements.txt` (numpy, scipy, pillow, h3) +- Linux or macOS (Windows: use WSL). Multi-core CPU helps a lot. +- RAM: ~2 GB for quick builds (res 3–4); res 5 on a big planet wants 16–30 GB. + +## Quick start + +```bash +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt +cp -r example ../myworld && cd ../myworld # a world is just a folder +../worldgen/.venv/bin/python ../worldgen/mapgen.py new-world --seed 42 # random continents + plates + an example era +../worldgen/.venv/bin/python ../worldgen/mapgen.py build # quick build (res 3, ~1–3 min) +``` + +`mapgen.py` works on the current folder, or pass `--world DIR`. Don't like the world? +`new-world --seed 43 --force` and build again. Like the shape but want to change it? Edit the files below and +rebuild. + +## How it works + +``` +sketch/*.png ─┐ +masks/*.png ─┼─► grid → sketch → plates → crust → elevation → erosion → climate → hydrology +config/*.toml ┘ → seabed → environment → ice → fields → render ──► out/r<res>/ ──► worldmap-viewer + └─► eras (config/eras.toml) → out/r<res>/eras/ +``` + +1. **Sketch** — where land roughly is. Warped a bit so it doesn't look hand-drawn. +2. **Plates** grow from seed points; their motions decide where they collide (mountains, trenches, volcanic arcs), + pull apart (rifts, mid-ocean ridges) or slide past. +3. **Crust/elevation** — continental vs oceanic crust, orogens, shelves, ridges, hotspot chains; sea level is solved + so `land_fraction` of the planet is dry. +4. **Erosion** carves valleys and fills basins. +5. **Climate** — insolation from tilt, wind belts, rain shadows, monsoons → temperature and rainfall per season. + **Ocean** (inside climate): the annual-mean wind drives surface currents (gyres, fast western boundary currents, + circumpolar currents where a passage is open); the currents carry heat, which sets the sea-surface temperature and + warms or cools the coasts downstream; Ekman upwelling and a sea productivity index come from the same winds. +6. **Hydrology** — rivers, lakes, salt flats. **Ice** — ice sheets and sea ice. **Environment** — biomes + (Holdridge), landforms, ground, lithology, ores. **Seabed** — sediments, vents, trenches. +7. **Render** — preview PNGs in `previews/r<res>/` and viewer textures in `out/`. + +Same seed + same inputs = same world. Builds are cached by stage; changing a file reruns only what depends on it. + +## Commands + +| Command | What | +|---|---| +| `new-world --seed N [--continents N] [--land F] [--toward LAT LON] [--mountains T] [--force]` | random `sketch/`, `config/tectonics.toml`, `config/eras.toml` | +| `build [--final] [--res N] [--from STAGE] [--stop STAGE]` | build the world (res_dev, or res_final with `--final`), then every era | +| `era <name> [--final]` | rebuild just one era | +| `check [--final]` | sanity report (land share, rain belts, rain shadows, …) | +| `compare --old <cells.npz>` | how a build differs from an earlier one | + +Regional refinement, sea-floor previews and terrain export for game engines live in worldmap-viewer (`mapview.py`). + +### Resolution and time + +H3 resolution sets detail. Cell counts are the same on any planet; a bigger radius means bigger cells. + +| res | cells | cell size (Earth radius) | build time* | +|---|---|---|---| +| 3 | 41 k | ~110 km | ~2–3 min | +| 4 | 288 k | ~42 km | ~5–10 min | +| 5 | 2.0 M | ~16 km | ~30 min+, lots of RAM | + +\*16 cores. Iterate at res 3, build `--final` once you like it. The viewer adds procedural detail below build +resolution (clearly marked "procedural detail — not data"), and **regions** add real ≈ 5 km detail where you want it. + +**Low memory.** `build --low-memory` / `era --low-memory` (or `WORLDGEN_LOW_MEMORY=1`) renders the big rasters a +band of rows at a time: slower, a lower memory peak, and byte-identical output. The cache does not depend on the mode. +Every build also writes each viewer layer as soon as it is coloured, and samples projections straight from the +8-bit image, rather than holding ~30 full-size images and float copies in memory. The ocean-current solve (a direct +sparse solve, ≈ 1.3 GB at res 4; res 5 solves on the res-4 grid) is unchanged in both modes. + +## Configuring for the world you want + +All in `config/`. Change, rebuild, look. Unknown keys are errors (typo guard). + +### `world.toml` — the planet + +`[planet]` (required) + +| Key | Effect | +|---|---| +| `radius_km` | size. Earth 6371. Changes cell size, distances, horizon | +| `gravity_g` | surface gravity; also a viewer field | +| `day_hours`, `year_days` | sky panel: day length, seasons | +| `tilt_deg` | axial tilt → seasons, tropics, polar circles. 0 = no seasons; 45+ = wild seasons | +| `sea_level_pressure_bar`, `scale_height_km`, `o2_fraction` | air: pressure falls with height; O₂ partial pressure | + +`[build]` (required): `seed`, `res_dev`, `res_final`, `raster_width`, `dev_raster_width`, `preview_width`, +`land_fraction` (0.29 Earth; 0.5 = land world; 0.1 = ocean world with islands). + +`[render]` (optional): `name` (shown by the viewer), `style` (preview palette: default, `tidal-lock`, `salt-mirror`). +`[units]` (optional): in-world units `span_m`, `league_km`, `moment_s` (metadata for the viewer). + +Optional tuning sections (`[climate]`, `[elevation]`, `[erosion]`, `[hydrology]`, …) — see **Tuning** below. + +### `sketch/` — where the land is + +Five equirectangular PNGs, 2:1 (e.g. 2000 × 1000), white = yes, black = no. Left edge = 180° W, top = 90° N. + +| File | Meaning | +|---|---| +| `land.png` | continents. The main control of the map's look | +| `mountains.png` | where you want mountain ranges (added on top of tectonic ones) | +| `desert.png`, `rainforest.png`, `trench.png` | reserved hints; may be all black | + +Draw them in any paint program (GIMP, Krita, Photoshop), or start from `new-world` and edit. `[sketch]` in +world.toml: `warp_km` (how loosely the drawing is followed; 0 = exactly, default 1000), `warp_freq`, +`detail_warp_km`, `detail_warp_freq`, and `moves` to rotate a whole drawn landmass elsewhere without redrawing: + +```toml +[sketch] +moves = [ { at = [10.0, 20.0], to = [25.0, 20.0] } ] # the landmass under 10N 20E moves to 25N 20E +``` + +If you move continents, move their plate seeds too. + +### `masks/` — painted overrides (optional) + +Grayscale PNGs, any 2:1 size, mid-grey = no change. See `masks/README.md`: `land_hint`, `mountain_hint`, `o2_zones`, +`gravity_zones`, `lock` (coast follows land_hint exactly). + +### `tectonics.toml` — plates and features + +This is where mountains come from. Each plate: + +```toml +[[plate]] +id = "big-east" +seed = [20.0, 60.0] # [lat, lon] it grows from +kind = "continental" # or "oceanic" +motion = [270.0, 4.0] # [azimuth° clockwise from north, speed cm/yr (0–20)] +``` + +- Put a **continental** plate under each continent (one seed near its middle). A big continent split into two + continental plates moving **toward** each other gets a Himalaya-style belt along the seam. +- **Oceanic** plates fill the oceans (10–15 total plates looks Earth-like). +- An oceanic plate moving **into** a continent → coastal mountains + deep trench offshore (Andes). +- Plates moving **apart** → rift valleys on land, mid-ocean ridges at sea. +- Faster plates = sharper, higher boundaries. 2–5 continental, 3–8 oceanic is Earth-like. +- `[plates] land_cost` (0.25): lower lets continents split across plates more easily. + +Optional features (any number of each): + +```toml +[[volcano]] # a single big volcano +name = "big-shield" +center = [5.0, -120.0] +radius_km = 250.0 +height_m = 6000.0 +scar_azimuths = [200.0] # optional: flank collapses + +[[hotspot]] # an island chain (Hawaii) +name = "chain" +center = [-10.0, 150.0] +length_km = 2000.0 + +[[lip]] # a flood-basalt province (old lava plateau) +name = "traps" +center = [40.0, 30.0] +radius_km = 900.0 + +[[microcontinent]] # a sliver of continental crust at sea +name = "sliver" +center = [-30.0, 70.0] +radius_km = 300.0 + +[[plateau]] # sunken plateau (Kerguelen-type); ≥ 2500 km apart +name = "deep-plateau" +center = [-40.0, -20.0] +area_km2 = 2.0e6 +elongation = 1.5 # optional, 1–10 +azimuth_deg = 30.0 # optional +top_m = [1500.0, 2500.0] # depth of its top below sea level [shallowest, deepest] +islands = true # optional: a few volcanic peaks break the surface + +[[land_patch]] # force land in a disc (strength 0–2, edge_noise 0–1) +name = "extra-land" +center = [0.0, 0.0] +radius_km = 800.0 +edge_noise = 0.4 + +[[zone]] # gentle regional O₂ / gravity change in the base world +name = "thin-air" +field = "o2" # or "gravity" +center = [30.0, 90.0] +radius_km = 2000.0 +v = -0.5 # −1..1: O₂ × (1 + 0.5 v), gravity × (1 + 0.7 v) +``` + +Low-gravity zones grow taller, spikier relief ("spires"). + +### `eras.toml` — the same world at different times (optional) + +Eras are versions of the world changed by local **events**. Only the areas around events are recomputed (climate, +rivers, ice, … follow). The viewer's era menu switches between them. + +```toml +[[event]] +name = "impact" +kind = "disintegrate" # a crater/basin: land sinks +center = [20.0, 40.0] +radius_km = 600.0 +depth_m = 3000.0 + +[[event]] +name = "new-volcano" +kind = "volcano" +center = [0.0, 10.0] +radius_km = 80.0 +peak_m = 4000.0 +shape = "cone" # cone | shield | caldera +peak_mode = "above" # height above the ground, or "absolute" + +[[event]] +name = "strange-air" +kind = "zone" # change fields in an area +shape = "circle" # or "landmass" with seed = [lat, lon], reach_km = [on land, out to sea] +center = [20.0, 40.0] +radius_km = 1500.0 +profile = "smooth" # or edge_km = 5.0 for a sharp border +fields = { gravity_g = 0.5, pressure_bar = 1.5, o2_fraction = 0.3, fire_reactivity = 0.6 } + +[eras] +order = ["ancient", "modern"] # oldest first; each era includes the events of the eras before it +default = "modern" # what the viewer opens + +[eras.ancient] +label = "Ancient times" +events = [] # no events = the base world + +[eras.modern] +label = "Modern times" +events = ["impact", "new-volcano", "strange-air"] +years = 5000 # time since the era before (erosion/weathering) +``` + +`[eras]` in world.toml: `mask_km` (how far around an event things are recomputed, 1500), `climate_blend_km`, +`erosion_steps`. Editing eras.toml never rebuilds the base world. + +### Tuning + +Any key below goes in `world.toml` under its section; omit it to keep the default. + +| Section | Useful keys (default) | +|---|---| +| `[climate]` | `t_a` (−55.2): **global temperature offset in °C** — +5 = hot world, −8 = ice age. `hadley_edge_deg` (20) / `ferrel_edge_deg` (55): desert belt and westerlies latitudes. `global_mean_mm` (1000): overall rainfall. `oro_rate` (20): rain shadow strength. `monsoon_k` (1). `current_c` (4): old rule-of-thumb current warming/cooling °C (used only with `[ocean] enabled = false`). `lapse_c_per_km` (6.5): cooling with height. `land_seasonal` (0.45), `ocean_seasonal` (0.15): season swing | +| `[elevation]` | `continental_base_m` (400), `continental_noise_m` (350): lowland height and roughness. `coast_noise_m` (900): coastline ruggedness. `trench_depth_m` (4000). `abyss_m` (6500): ocean depth. `hint_m` (2500): height from `mountains.png`. `spire_m` (2500): low-gravity spires | +| `[crust]` | `shelf_km` (450): continental shelf width. `orogen_width_km` (700): mountain belt width. `rift_width_km` (200). `edge_noise` (0.5): coast fractalness | +| `[erosion]` | `steps` (12), `k` (0.02): more = deeper valleys, lower mountains. `hillslope_km` (25). `sediment_fill` (0.15) | +| `[hydrology]` | `river_min_km3_yr` (2): lower = more rivers drawn. `lake_depth_k` (12). `salt_flat_max_p_mm` (300) | +| `[ice]` | `melt_summer_c` (0): higher = more ice. `sea_ice_t_c` (−1.8). `sheet_max_m` (3000) | +| `[plates]` | `land_cost` (0.25), `noise` (0.35): plate boundary wiggle | +| `[seabed]` | `vent_field` (0.8), `sulfides` (0.7), `carbonate_depth_m` (4500) | +| `[ocean]` | `enabled` (true): currents, SST, upwelling, productivity (false = the old rule-of-thumb climate, all ocean fields zero). `friction_days` (5): 1/friction — longer = narrower, faster western boundary currents (width ≈ friction/β, never under one cell). `stress_k` (1): wind-stress multiplier — scales all current speeds. `layer_m` (150): depth of the wind-driven layer. `land_friction` (1000): how still land is. `direct_max_cells` (500000): bigger grids solve currents one resolution coarser. `relax_days` (300): how long sea water keeps its heat — longer = stronger warm/cold current anomalies. `kappa_m2s` (1000): heat mixing. `ekman_min_lat` (3), `upwell_coast_km` (100): upwelling near the equator / spread off coasts. `prod_upwell` (0.6), `prod_shelf` (0.4), `prod_mix` (0.3), `upwell_ref_m_yr` (100): productivity weights. `prod_front` (0.4), `front_min` (0.5), `front_ref` (1.0): SST fronts (where warm and cold currents meet) are productive — gradients over `front_min` °C/100 km count, saturating at `front_ref` above it. `rho_air` (1.2), `drag` (1.3e-3): bulk stress formula | +| `[masks]` | `o2_range` (0.5), `gravity_range` (0.7): how strongly the painted masks act | + +### Recipes + +| Want | Do | +|---|---| +| More / less land | `[build] land_fraction` | +| Pangaea | one huge blob in `land.png`, 2–3 continental plates under it colliding | +| Big mountain range at a spot | two continental plates converging there, or paint `mountains.png`, or `masks/mountain_hint.png` | +| Andes coast | oceanic plate moving into the continent's coast | +| Island arcs | two oceanic plates converging | +| Island chain | `[[hotspot]]` | +| Hotter / colder world | `[climate] t_a` ± a few °C | +| Deserts wider / narrower | `[climate] hadley_edge_deg`, `global_mean_mm` | +| Wetter world | `[climate] global_mean_mm` up | +| No seasons / extreme seasons | `[planet] tilt_deg` 0 / 40+ | +| Rugged vs smooth coasts | `[crust] edge_noise`, `[elevation] coast_noise_m`, `[sketch] warp_km` | +| Exact coastline from your drawing | `[sketch] warp_km = 0`, `detail_warp_km = 0`, or paint `masks/lock.png` | +| Crater, sunken land, magic zone later in history | `eras.toml` events | + +## Files + +``` +mapgen.py CLI mapgen/ pipeline stages (mapgen/testing.py: test fixtures) +example/ a starter world: config/world.toml, masks/README.md (new-world adds the rest) +tests/ python -m unittest discover -s tests -t . +``` + +A world folder: `config/` (world.toml tectonics.toml eras.toml), `sketch/`, optional `masks/`; generated: +`out/ previews/` (safe to delete; rebuild). + +Ocean fields in `out/r<res>/cells.npz` (per cell; zero on land): + +| Field | Unit | Meaning | +|---|---|---| +| `current` | m/s, (n,3) | surface current vector, tangent to the sphere | +| `current_speed` | m/s | its length | +| `sst` | °C | annual-mean sea-surface temperature (≥ −1.8; T_mean on land) | +| `upwelling` | m/yr | Ekman vertical velocity, + = up (nutrients), − = down | +| `productivity` | 0–1 | sea life index: upwelling, shallow shelf, winter mixing, dimmed toward the poles | + +Viewer layers: *Ocean currents* (speed + arrows), *Sea-surface temperature*, *Sea productivity*. + +## Troubleshooting + +- `error: sketch files missing` → run `new-world` or draw `sketch/*.png`. +- `two plate seeds fall in the same cell` → move one seed apart. +- `plateaus … are N km apart` → plateaus need ≥ 2500 km between centres. +- Out of memory → lower `res_final`, or cap it via the shared ledger: `wf res run --mem 12G --for 1h --title "mapgen final" -- python mapgen.py build --final` (busy → exit 3, retry later). + +## Licence and credits + +MIT, see [LICENSE](LICENSE). Third-party notices: [licenses.md](licenses.md). Credits: [CREDITS.md](CREDITS.md). + +## From the author + +These projects are things that have been tumbling about in my head for a long time, and I now feel like trying to do something with. The bulk of my focus here is on some of the games that I love, but every time I boot them up, I just fiddle about in the menu and set up mods and fixes for a couple hours, with my drive to play them fizzling out. This is my hope to solve that, and while I am a professional software engineer this would not have happened were it not for the rise of (relatively) cheap AI that could do the bulk of the work with me. If you don't approve, that's fine. I did these things for me, sharing them is something I do in the hopes to help others in similar situations, that just want to play the games they love. Thank you to all that made these playable in the first place, with some luck this finds you, and can bring some joy. + +Written with AI assistance (Claude, by Anthropic), used as an engineering tool. diff --git a/example/config/world.toml b/example/config/world.toml new file mode 100644 index 0000000..667c224 --- /dev/null +++ b/example/config/world.toml @@ -0,0 +1,28 @@ +# World builder settings. Every [section] below except [planet] and [build] is optional: +# leave a key out to keep its default (README.md → "Tuning" lists them all). + +[planet] +radius_km = 6371.0 # Earth 6371. Bigger planet = more cells per continent, slower builds +gravity_g = 1.0 # surface gravity (Earth = 1) +day_hours = 24.0 # length of a day (sky panel) +year_days = 365.0 # days per year (sky panel, seasons) +tilt_deg = 23.44 # axial tilt: seasons, tropics, polar circles +sea_level_pressure_bar = 1.0 +scale_height_km = 8.0 # how fast air thins with height +o2_fraction = 0.21 + +[build] +seed = 1 # noise seed: same seed + same config = same world +res_dev = 3 # H3 resolution for quick builds (3 ≈ 2–3 min, 4 ≈ 5–10 min) +res_final = 4 # `--final` builds (5 ≈ 30+ min and 16–30 GB RAM) +raster_width = 8192 # equirectangular output width at res_final +dev_raster_width = 2048 +preview_width = 2048 +land_fraction = 0.29 # share of the surface above sea level (Earth ≈ 0.29) + +[climate] +hadley_edge_deg = 30.0 # dry subtropical belt (Earth ≈ 30°) +ferrel_edge_deg = 60.0 # wet westerlies → polar front + +[sketch] +warp_km = 800.0 # how loosely the drawing is followed (0 = exact) diff --git a/example/masks/README.md b/example/masks/README.md new file mode 100644 index 0000000..eea3162 --- /dev/null +++ b/example/masks/README.md @@ -0,0 +1,14 @@ +# Override masks (optional) + +Paint in GIMP/Krita as **equirectangular** grayscale PNGs, any size (2:1), 8- or 16-bit. +Mid-gray (32768 in 16-bit, 128 in 8-bit) = neutral / no change. Transparent or missing = neutral. + +| File | Effect (white = +1, black = −1) | +|---|---| +| `land_hint.png` | bias toward land (+) or sea (−) on top of the sketch | +| `mountain_hint.png` | extra uplift (+) | +| `o2_zones.png` | O₂ modifier: 1 + 0.5·v (world.toml `[masks] o2_range`) | +| `gravity_zones.png` | gravity modifier: 1 + 0.7·v; v < 0 = low-g zone (relief ×, spires) | +| `lock.png` | where brighter than 75% grey (> +0.5), coastline follows land_hint strictly | + +Rebuild: `.venv/bin/python mapgen.py build` (masks are part of the cache key). diff --git a/licenses.md b/licenses.md new file mode 100644 index 0000000..23260c9 --- /dev/null +++ b/licenses.md @@ -0,0 +1,13 @@ +# Third-party licences + +Python packages are installed by the user from `requirements.txt` (not shipped in this repo). +Licences as published by each project, read 2026-09-29; update with any dependency change. + +| Package | Requirement | Licence | +|---|---|---| +| numpy | >=2.3 | BSD-3-Clause | +| scipy | >=1.16 | BSD-3-Clause | +| pillow | >=11 | MIT-CMU (HPND) | +| h3 (h3-py) | >=4.5,<5 | Apache-2.0 | +| numba (optional: compiled fast paths) | any | BSD-2-Clause | +| llvmlite (with numba) | any | BSD-2-Clause; bundles LLVM: Apache-2.0 with LLVM exception | diff --git a/mapgen.py b/mapgen.py new file mode 100755 index 0000000..b371454 --- /dev/null +++ b/mapgen.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""World generator CLI (see README.md). A world is a folder with config/, sketch/ (and optional masks/); builds go to +its out/.""" +import argparse +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from mapgen import config as C # noqa: E402 +from mapgen import pipeline as P # noqa: E402 + + +def main(argv=None, root: Path | None = None) -> int: + ap = argparse.ArgumentParser(prog="mapgen.py") + ap.add_argument("--world", help="the world's folder (its config/, sketch/, out/); default: the current folder") + sub = ap.add_subparsers(dest="cmd", required=True) + nw = sub.add_parser("new-world", help="random sketch/ + config/tectonics.toml + config/eras.toml") + nw.add_argument("--seed", type=int, required=True) + nw.add_argument("--continents", type=int, default=6) + nw.add_argument("--land", type=float, default=0.27, help="land share of the sketch") + nw.add_argument("--toward", type=float, nargs=2, metavar=("LAT", "LON"), help="gather the land toward this point") + nw.add_argument("--mountains", type=float, default=0.86, help="ridge threshold (lower: more ranges)") + nw.add_argument("--force", action="store_true") + for name in ("build", "check", "era", "compare"): + s = sub.add_parser(name) + s.add_argument("--res", type=int) + s.add_argument("--final", action="store_true") + s.add_argument("--low-memory", action="store_true", + help="slower, lower memory peak; same results (or set WORLDGEN_LOW_MEMORY=1)") + if name == "compare": + s.add_argument("--old", required=True, help="an earlier cells.npz of the same resolution") + if name == "era": + s.add_argument("name", help="an era from config/eras.toml") + if name == "build": + s.add_argument("--from", dest="start", choices=P.STAGES) + s.add_argument("--stop", choices=P.STAGES) + a = ap.parse_args(argv) + if a.world: + root = Path(a.world).resolve() + elif root is None: + root = Path.cwd() + try: + if a.cmd == "new-world": + from mapgen import newworld + newworld.make(root, a.seed, a.continents, a.land, a.toward, a.mountains, a.force) + return 0 + cfg, _ = C.load(root) + low = bool(getattr(a, "low_memory", False)) or os.environ.get("WORLDGEN_LOW_MEMORY", "") not in ("", "0") + res = a.res if a.res is not None else int(cfg["build"]["res_final" if a.final else "res_dev"]) + if a.cmd == "build": + ctx = P.build(root, res, a.start, a.stop, low_memory=low) + if a.stop is None: # every era with events, from the fresh base + from mapgen import eras + eras.build_all(root, res, base=ctx, low_memory=low) + return 0 + if a.cmd == "era": + from mapgen import eras + eras.build_era(root, res, a.name, low_memory=low) + return 0 + if a.cmd == "compare": # base vs an earlier build, outside the edited areas + from mapgen import check + return check.compare_report(root, res, Path(a.old)) + from mapgen import check + ctx = P.build(root, res, stop="fields", log=lambda m: None) + return check.report(ctx) + except (C.ConfigError, P.StageError) as e: + print(f"error: {e}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mapgen/__init__.py b/mapgen/__init__.py new file mode 100644 index 0000000..63fd969 --- /dev/null +++ b/mapgen/__init__.py @@ -0,0 +1,7 @@ +"""World generator.""" +import os + +# OpenBLAS threads spin ~0.1 s after each call before sleeping; with solves on several threads (and other jobs on the +# machine) that spinning steals the cores doing the work. A short timeout changes nothing in how work is split, so +# results stay the same bits (the thread *count* would not: see pipeline/graph notes). Only before numpy loads. +os.environ.setdefault("OPENBLAS_THREAD_TIMEOUT", "4") diff --git a/mapgen/check.py b/mapgen/check.py new file mode 100644 index 0000000..f3f4639 --- /dev/null +++ b/mapgen/check.py @@ -0,0 +1,272 @@ +"""`mapgen.py check`: physical sanity checks (fail) + environment summary (info).""" +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np + +from . import plateaus as PL, zones as ZN +from .climate import DEFAULTS as CLIMATE_DEFAULTS +from .config import params +from .environment import GROUND_NAMES, LANDFORM_NAMES, REGIONS +from .fields import gravity_mod +from .graph import gradient +from .ice import ICE_NAMES +from .sphere import gc_dist_km, latlon_to_xyz + + +def check_land_fraction(z, area, target, tol=0.02): + f = area[z > 0].sum() / area.sum() + return None if abs(f - target) <= tol else f"land fraction {f:.3f} not within {tol} of target {target}" + + +def check_hypsometry(z, area): + edges = np.arange(-11000, 12500, 500) + h, _ = np.histogram(z, bins=edges, weights=area) + c = (edges[:-1] + edges[1:]) / 2 + ocean = (c >= -7000) & (c <= -1000) + landb = (c >= -500) & (c <= 3000) + io, il = np.flatnonzero(ocean)[np.argmax(h[ocean])], np.flatnonzero(landb)[np.argmax(h[landb])] + valley = h[io:il + 1].min() + if h[io] > 1.5 * valley and h[il] > 1.5 * valley: + return None + return "hypsometry not bimodal (expected separate ocean-floor and continent peaks)" + + +def check_max_elevation(z, gmod, cap=12000.0): + lim = cap * np.clip(1.0 / gmod, 1.0, 3.0) + bad = z > lim + 1.0 + return None if not bad.any() else f"{int(bad.sum())} cells above {cap:.0f} m (outside low-g zones)" + + +def check_drainage(recv, z, endorheic, terminal_ok=None): + roots = recv == np.arange(len(recv)) + bad = roots & (z > 0) & ~endorheic + if bad.any(): + return f"{int(bad.sum())} land cells are sinks outside endorheic basins" + if terminal_ok is not None: + dry = roots & endorheic & ~terminal_ok + if dry.any(): + return f"{int(dry.sum())} endorheic terminals are neither lake nor salt flat" + return None + + +def check_no_uphill(zf, recv, z, endorheic, lake=None): + ar = np.arange(len(recv)) + sel = (z > 0) & ~endorheic & (recv != ar) + if lake is not None: + sel &= ~lake # flat lake surfaces: routing direction is a convention + bad = sel & (zf[recv] >= zf) + return None if not bad.any() else f"{int(bad.sum())} cells route uphill on the filled surface" + + +def _band_means(g, f, edges): + a = np.abs(g.lat) + return [np.sum(f[(a >= lo) & (a < hi)] * g.area_km2[(a >= lo) & (a < hi)]) / + max(g.area_km2[(a >= lo) & (a < hi)].sum(), 1e-9) for lo, hi in zip(edges, edges[1:])] + + +def check_temperature(g, t_mean): + m = _band_means(g, t_mean, list(range(0, 91, 10))) + ok = all(b <= a + 0.5 for a, b in zip(m, m[1:])) and m[0] > m[-1] + 10 + return None if ok else f"zonal-mean temperature does not fall poleward: {np.round(m, 1).tolist()}" + + +def check_rain_bands(g, p_ann, hadley_edge): + """Dry belt ≈ Hadley edge −2..+8°, storm track ≈ edge +12..+25° (scaled from Earth's 30° edge).""" + h = hadley_edge + eq = _band_means(g, p_ann, [0, 10])[0] + sub = _band_means(g, p_ann, [h - 2, h + 8])[0] + mid = _band_means(g, p_ann, [h + 12, h + 25])[0] + if eq > sub and mid > sub: + return None + return (f"no subtropical dry belt: P(0-10)={eq:.0f}, P({h - 2:.0f}-{h + 8:.0f})={sub:.0f}, " + f"P({h + 12:.0f}-{h + 25:.0f})={mid:.0f} mm/yr") + + +def check_rain_shadow(g, data): + z = np.maximum(np.asarray(data["elevation_eroded_m"], dtype=np.float64), 0.0) / 1000.0 + grad = gradient(g, z) + land = z > 0 + ww, lw = [], [] + for s in ("jun", "dec"): + up = np.sum(np.asarray(data[f"wind_{s}"], dtype=np.float64) * grad, axis=1) + p = np.asarray(data[f"P_{s}"]) + ww.append(p[land & (up > 0.02)]) + lw.append(p[land & (up < -0.02)]) + w, l = np.concatenate(ww), np.concatenate(lw) + if len(w) == 0 or len(l) == 0: + return None + return None if w.mean() > l.mean() else f"windward slopes ({w.mean():.0f}) not wetter than lee ({l.mean():.0f})" + + +def _shares(values, area, names, mask): + tot = area[mask].sum() + out = [] + for i, nm in enumerate(names): + s = area[mask & (values == i)].sum() / max(tot, 1e-9) + if s >= 0.005: + out.append(f"{nm} {100 * s:.1f}%") + return ", ".join(out) + + + +def check_plateaus(g, data, plateaus, seed) -> list: + """Spec §8: every plateau's top within top_m (± relief), hidden ones ≤ −500 m, island ones with land.""" + if not plateaus: + return [] + z = np.asarray(data["elevation_eroded_m"], dtype=np.float64) + ocean, ids = np.asarray(data["ocean"]), np.asarray(data["plateau_id"]) + out = [] + for k, p in enumerate(plateaus): + idx = np.flatnonzero(ids == k) + if len(idx) == 0: + out.append(f"plateau {p['name']}: no cells at this resolution") + continue + _, b = PL.semi_axes(p) + core = idx[(1.0 - PL.rho(g.xyz[idx], p, seed, g.radius_km)) * b > PL.MARGIN_KM] + lo, hi = p["top_m"] + if len(core): + med = float(np.median(-z[core])) + if not lo - PL.RELIEF_M <= med <= hi + PL.RELIEF_M: + out.append(f"plateau {p['name']}: median top depth {med:.0f} m outside {lo:.0f}–{hi:.0f} m") + if p.get("islands", False): + isl = [g.cell_index(c["lat"], c["lon"]) for c in PL.features(p, seed, g.radius_km)["cones"] if c["island"]] + if ocean[idx].all() and all(ocean[i] for i in isl): + out.append(f"plateau {p['name']}: no island breaks the surface") + elif z[idx].max() > -500.0: + out.append(f"plateau {p['name']}: a hidden plateau reaches {z[idx].max():.0f} m (limit −500 m)") + return out + + +def check_zones(g, data, max_o2_points=0.23, max_g=0.014, per_km=30.0) -> list: + """Spec §5, the gradual rule: steepest change per day's walk of the O₂ fraction (points) and of gravity.""" + out = [] + for key, scale, lim, unit in (("o2_fraction", 100.0, max_o2_points, "O₂ points"), ("gravity_g", 1.0, max_g, "g")): + if key not in data: + continue + f = np.asarray(data[key], dtype=np.float64) * scale + steep = float(np.max(np.abs(f[g.dst] - f[g.src]) / g.edge_km)) * per_km + if steep > lim + 1e-9: + out.append(f"zones: {key} changes {steep:.3f} {unit} per {per_km:.0f} km (limit {lim})") + return out + + +def edited_areas(xyz, tect, radius_km, plateau_km=300.0, patch_km=1500.0): + """Cells the revision changes by design: plateaus + 300 km, land patches + 1,500 km, low-gravity zones + (their relief).""" + xyz = np.asarray(xyz, dtype=np.float64) + near = lambda c, r: gc_dist_km(xyz, latlon_to_xyz(*c), radius_km) < r + m = np.zeros(len(xyz), bool) + for p in tect.get("plateau", []): + m |= near(p["center"], PL.semi_axes(p)[0] * (1 + PL.EDGE_WARP) / (1 - PL.EDGE_WARP) + plateau_km) + for p in tect.get("land_patch", []): + m |= near(p["center"], p["radius_km"] * 1.3 + patch_km) + for z in tect.get("zone", []): + if z["field"] == "gravity": + m |= near(z["center"], z["radius_km"] / (1 - ZN.WARP)) + return m + + +O2_FIELDS = ("m_o2_zones", "o2_fraction", "po2_bar") # what the base's O₂ zones change (no relief) + + +def o2_areas(xyz, tect, radius_km): + """Cells the base's O₂ zones reach: compare skips only the O₂ fields there.""" + xyz = np.asarray(xyz, dtype=np.float64) + m = np.zeros(len(xyz), bool) + for z in tect.get("zone", []): + if z["field"] == "o2": + m |= gc_dist_km(xyz, latlon_to_xyz(*z["center"]), radius_km) < z["radius_km"] / (1 - ZN.WARP) + return m + + +def compare(old: dict, new: dict, exclude, extra=None) -> list: + """Per field, over the cells outside `exclude` (and outside extra[field] for that field): share changed, largest + change, 99th percentile.""" + keep0 = ~np.asarray(exclude, bool) + lines = [f"cells compared: {int(keep0.sum())} of {len(keep0)} (outside the edited areas)"] + for k in sorted(set(old) & set(new)): + a, b = np.asarray(old[k]), np.asarray(new[k]) + if a.shape != b.shape or a.shape[:1] != keep0.shape or a.dtype.kind not in "biuf" or b.dtype.kind not in "biuf": + continue + keep = keep0 & ~np.asarray(extra[k], bool) if extra and k in extra else keep0 + a, b = a.astype(np.float64)[keep], b.astype(np.float64)[keep] + with np.errstate(invalid="ignore"): + diff = np.where((a == b) | (np.isnan(a) & np.isnan(b)), 0.0, np.abs(b - a)) + if diff.ndim > 1: + diff = diff.reshape(len(diff), -1).max(axis=1) + if not len(diff): + lines.append(f"{k}: no cells to compare") + continue + lines.append(f"{k}: {np.mean(diff > 0):.1%} changed, max {diff.max():.4g}, p99 {np.percentile(diff, 99):.4g}") + return lines + + +def compare_report(root: Path, res: int, old_path: Path) -> int: + from . import config as C + _, tect = C.load(root) + out = root / "out" / f"r{res}" + with np.load(old_path) as z: + old = {k: z[k] for k in z.files} + with np.load(out / "cells.npz") as z: + new = {k: z[k] for k in z.files} + for name, arrays in ((old_path, old), (out / "cells.npz", new)): + if "g_ids" not in arrays: + print(f"error: {name} has no g_ids: not the cells.npz of a world build") + return 2 + if not np.array_equal(old["g_ids"], new["g_ids"]): + print("error: the two worlds have different cells (another resolution?)") + return 2 + radius = float(json.loads((out / "cells_meta.json").read_text())["radius_km"]) + o2 = o2_areas(new["g_xyz"], tect, radius) + for line in compare(old, new, edited_areas(new["g_xyz"], tect, radius), {k: o2 for k in O2_FIELDS}): + print(line) + return 0 + +def info_lines(ctx): + g, d = ctx.grid, ctx.data + z = np.asarray(d["elevation_eroded_m"]) + land = ~np.asarray(d["ocean"]) if "ocean" in d else z > 0 + a = g.area_km2 + sk = np.asarray(d["sk_land"]) > 0.5 + iou = (land & sk).sum() / max((land | sk).sum(), 1) + sites = PL.site_report(g, d, ctx.tect["plateau"]) if ctx.tect.get("plateau") and "plateau_id" in d else [] + return [f"cells {g.n} (H3 res {ctx.res}); land {100 * a[land].sum() / a.sum():.1f}% " + f"({a[land].sum() / 1e6:.0f}M km², Earth: 29%, 149M km²)", + f"sketch land overlap (IoU): {iou:.2f}", + "regions: " + _shares(np.asarray(d["hold_region"]), a, REGIONS, land), + "landforms: " + _shares(np.asarray(d["landform"]), a, LANDFORM_NAMES, land), + "ground: " + _shares(np.asarray(d["ground"]), a, GROUND_NAMES, land), + "ice: " + _shares(np.asarray(d["ice"]), a, ICE_NAMES, np.ones(g.n, bool))] + [f"site: {s}" for s in sites] + + +def run_checks(ctx): + g, d, cfg = ctx.grid, ctx.data, ctx.cfg + z = np.asarray(d["elevation_eroded_m"], dtype=np.float64) + if "ocean" in d: # interior lows below sea level are land: sign-correct z for the land/sea checks + z = np.where(np.asarray(d["ocean"]), np.minimum(z, -0.001), np.maximum(z, 0.001)) + endo = np.asarray(d["endorheic"]) + results = [ + check_land_fraction(z, g.area_km2, cfg["build"]["land_fraction"]), + check_hypsometry(z, g.area_km2), + check_max_elevation(z, gravity_mod(cfg, d["m_gravity_zones"])), + check_drainage(np.asarray(d["recv"]), z, endo, np.asarray(d["lake"]) | np.asarray(d["salt_flat"])), + check_no_uphill(np.asarray(d["z_filled_m"]), np.asarray(d["recv"]), z, endo, np.asarray(d["lake"])), + check_temperature(g, np.asarray(d["T_mean"])), + check_rain_bands(g, np.asarray(d["P_ann"]), params(cfg, "climate", CLIMATE_DEFAULTS)["hadley_edge_deg"]), + check_rain_shadow(g, d), + ] + results += check_plateaus(g, d, ctx.tect.get("plateau", []), ctx.seed) + check_zones(g, d) + return [r for r in results if r], info_lines(ctx) + + +def report(ctx) -> int: + fails, infos = run_checks(ctx) + for line in infos: + print("info:", line) + for f in fails: + print("FAIL:", f) + print("check:", "OK" if not fails else f"{len(fails)} failure(s)") + return 1 if fails else 0 diff --git a/mapgen/climate.py b/mapgen/climate.py new file mode 100644 index 0000000..491675c --- /dev/null +++ b/mapgen/climate.py @@ -0,0 +1,249 @@ +"""Stage `climate`: seasonal insolation → temperature, 3-cell winds + monsoons, moisture transport → rain.""" +from __future__ import annotations + +import numpy as np +from scipy import sparse + +from . import ocean as OC +from .config import params +from .graph import bicgstab_jacobi, distance_to, gradient, nearest_source, pmap, smooth_km +from .grid import rowdot_at +from .sphere import east_north, latlon_to_xyz, rotate_about, tangent_dir + +S0 = 1361.0 +SEA_FREEZE_C = -1.8 # the exported SST never goes below sea water's freezing point (ice-covered sea) +SEASONS = ("jun", "dec", "eq") +DEFAULTS = { + "t_a": -55.2, "t_b": 0.3206, "t_c": -2.974e-4, "land_seasonal": 0.45, "land_summer": 0.9, "continentality_summer_max": 1.5, "ocean_seasonal": 0.15, + "continentality_km": 1500.0, "continentality_max": 1.8, "lapse_c_per_km": 6.5, + "current_c": 4.0, "current_reach_km": 600.0, "current_leak_km": 150.0, "heat_transport_km": 300.0, + "hadley_edge_deg": 20.0, "ferrel_edge_deg": 55.0, "itcz_shift_deg": 8.0, + "trade_u": -6.0, "trade_v": 2.0, "westerly_u": 8.0, "westerly_v": 1.0, "polar_u": -4.0, "polar_v": 1.0, + "monsoon_k": 1.0, "monsoon_length_km": 1500.0, "monsoon_speed_scale": 6000.0, + "coriolis_min_deg": 20.0, "coriolis_span_deg": 50.0, + "base_rate": 0.25, "conv_rate": 2.0, "front_rate": 0.8, "front_lat_deg": 40.0, "front_width_deg": 10.0, "itcz_width_deg": 8.0, "oro_rate": 20.0, "subsidence": 0.2, + "min_rate": 0.05, "cc_per_c": 0.07, "recycle": 0.83, "eddy_k_m2s": 2.2e6, + "eddy_wind_ms": 8.0, + "global_mean_mm": 1000.0, + "lock": False, "lock_at": [0.0, 0.0], "lock_day_c": 120.0, "lock_night_c": -200.0, "lock_wind_ms": 10.0, + "lock_melt_c": 0.0, "lock_melt_width_c": 25.0, +} + + +def t_of_q(q, P): + """Radiative-equilibrium-like surface temperature (°C) from insolation (W/m²); quadratic fit to Earth-like + zonal means for a 20° tilt: equator 27, 45° ≈ 15, 60° ≈ 3, pole ≈ −22 (concave: damps polar-day summers).""" + return P["t_a"] + P["t_b"] * q + P["t_c"] * q * q + + +def declinations(tilt): + return {"jun": tilt, "dec": -tilt, "eq": 0.0} + + +def insolation(lat, decl): + """Daily-mean top-of-atmosphere insolation (W/m²).""" + phi = np.radians(np.clip(lat, -89.9999, 89.9999)) + d = np.radians(decl) + h0 = np.arccos(np.clip(-np.tan(phi) * np.tan(d), -1.0, 1.0)) + return S0 / np.pi * (h0 * np.sin(phi) * np.sin(d) + np.cos(phi) * np.cos(d) * np.sin(h0)) + + +def zonal_mean(g, f, bin_deg=2.0): + b = np.floor((g.lat + 90.0) / bin_deg).astype(np.int64) + s = np.bincount(b, weights=f * g.area_km2) + w = np.bincount(b, weights=g.area_km2) + return (s / np.maximum(w, 1e-12))[b] + + +def current_anomaly(g, land, P): + """Subtropical gyres: cold water off west coasts, warm off east coasts; leaks onto coastal land.""" + if not land.any(): + return np.zeros(g.n) + d, src = nearest_source(g, np.flatnonzero(land)) + e, _ = east_north(g.xyz) + east_comp = np.sum(tangent_dir(g.xyz, g.xyz[np.maximum(src, 0)]) * e, axis=1) + band = np.sin(np.radians((np.clip(np.abs(g.lat), 10.0, 50.0) - 10.0) * 4.5)) + a = -P["current_c"] * np.sign(east_comp) * band * np.exp(-d / P["current_reach_km"]) + a[land] = 0.0 + return smooth_km(g, a, P["current_leak_km"]) + + +def temperatures(g, z, land, P, tilt, cur=None): + decl = declinations(tilt) + Q = {s: insolation(g.lat, d) for s, d in decl.items()} + q_ann = (Q["jun"] + Q["dec"] + 2 * Q["eq"]) / 4 + t_ann = t_of_q(q_ann, P) + dist_ocean = distance_to(g, ~land) if (~land).any() else np.full(g.n, 1e4) + cont = np.clip(1.0 + dist_ocean / P["continentality_km"], 1.0, P["continentality_max"]) + cur = current_anomaly(g, land, P) if cur is None else cur + lapse = -P["lapse_c_per_km"] * np.maximum(z, 0.0) / 1000.0 + def season(s): + raw = t_of_q(Q[s], P) + land_resp = np.where(raw > t_ann, P["land_summer"] * np.minimum(cont, P["continentality_summer_max"]), + P["land_seasonal"] * cont) # land heats faster in summer (dry, low heat capacity) + resp = np.where(land, land_resp, P["ocean_seasonal"]) + return smooth_km(g, t_ann + resp * (raw - t_ann) + cur, P["heat_transport_km"]) + lapse + return dict(zip(SEASONS, pmap(season, SEASONS))), dist_ocean + + +def biotemperature(tmean, trange, n=12): + ph = np.linspace(0.0, 2 * np.pi, n, endpoint=False) + t = np.asarray(tmean)[:, None] + (np.asarray(trange)[:, None] / 2) * np.sin(ph)[None, :] + return np.clip(t, 0.0, 30.0).mean(axis=1) + + +def band_winds(g, itcz_lat, P): + """3-cell surface winds (m/s) relative to the thermal equator.""" + phi = g.lat - itcz_lat + a = np.abs(phi) + sgn = np.where(phi >= 0, 1.0, -1.0) + s1 = 0.5 * (1 + np.tanh((a - P["hadley_edge_deg"]) / 3.0)) + s2 = 0.5 * (1 + np.tanh((a - P["ferrel_edge_deg"]) / 4.0)) + u = (1 - s1) * P["trade_u"] + (s1 - s2) * P["westerly_u"] + s2 * P["polar_u"] + v = sgn * (-(1 - s1) * P["trade_v"] + (s1 - s2) * P["westerly_v"] - s2 * P["polar_v"]) + e, n = east_north(g.xyz) + return u[:, None] * e + v[:, None] * n + + +def monsoon_winds(g, T_s, land, P): + """Thermal lows over hot land / highs over cold land, flow deflected by Coriolis.""" + anom = np.where(land, T_s - zonal_mean(g, T_s), 0.0) + press = -P["monsoon_k"] * smooth_km(g, anom, P["monsoon_length_km"]) + flow = -gradient(g, press) + theta = np.radians(P["coriolis_min_deg"] + P["coriolis_span_deg"] * np.abs(np.sin(np.radians(g.lat)))) + return rotate_about(g.xyz, flow, -np.sign(g.lat) * theta) * P["monsoon_speed_scale"] + + +def _rate(per_1000km, spacing_km): + return 1.0 - np.exp(-np.maximum(per_1000km, 0.0) * spacing_km / 1000.0) + + +def eddy_mixing(g, P): + """kappa · (nbr − I): the per-step eddy mixing with the neighbours — the same for every season (build once).""" + kappa = P["eddy_k_m2s"] / (P["eddy_wind_ms"] * g.spacing_km * 1000.0) + nbr = sparse.csr_matrix((1.0 / g.counts[g.src], (g.src, g.dst)), shape=(g.n, g.n)) + return kappa * (nbr - sparse.identity(g.n, format="csr")) + + +def precipitation(g, wind, T, z, land, itcz_lat, P, mixing=None): + """Steady-state moisture transport along the wind on the cell graph; returns rain (relative units). + mixing: eddy_mixing(g, P), when several seasons share it.""" + t = g.edge_tangents + out = np.maximum(rowdot_at(wind, g.src, t), 0.0) + tot = np.bincount(g.src, weights=out, minlength=g.n) + frac = np.where(tot[g.src] > 0, out / np.maximum(tot[g.src], 1e-12), 0.0) + Tm = sparse.csr_matrix((frac, (g.dst, g.src)), shape=(g.n, g.n)) + stay = (tot <= 0).astype(np.float64) + upslope = np.maximum(np.sum(wind * gradient(g, np.maximum(z, 0.0) / 1000.0), axis=1), 0.0) + conv = P["conv_rate"] * np.exp(-((g.lat - itcz_lat) / P["itcz_width_deg"]) ** 2) * np.clip((T - 10.0) / 20.0, 0, 1) + subs = P["subsidence"] * np.exp(-((np.abs(g.lat - itcz_lat) - P["hadley_edge_deg"]) / 6.0) ** 2) + front = P["front_rate"] * np.exp(-((np.abs(g.lat - itcz_lat) - P["front_lat_deg"]) / P["front_width_deg"]) ** 2) + per = np.maximum(P["base_rate"] + conv + front + P["oro_rate"] * upslope - subs, P["min_rate"]) + r = _rate(per, g.spacing_km) + evap = np.where(land, 0.0, np.exp(P["cc_per_c"] * (np.clip(T, -2.0, 35.0) - 25.0))) + # per advection step (one cell, time h/U): rain out, move downwind, eddy-mix with neighbours + mix = eddy_mixing(g, P) if mixing is None else mixing + eye = sparse.identity(g.n, format="csr") + keep = sparse.diags(1.0 - r) + recyc = sparse.diags(np.where(land, P["recycle"] * r, 0.0)) # land evapotranspiration returns rain + system = (eye - (Tm @ keep + sparse.diags(stay) @ keep) - mix - recyc).tocsr() + W, info = bicgstab_jacobi(system, evap, evap / np.maximum(r, 1e-6), 1.0 / system.diagonal(), 1e-7, 5000) + if info != 0: + raise ValueError(f"precipitation: moisture solve did not converge (info={info})") + return r * np.maximum(W, 0.0) + + +def run_locked(ctx, g, z, land, P) -> dict: + """One face always to the sun: temperature by sun angle, no seasons; surface wind from night to the sun point.""" + sub = latlon_to_xyz(*P["lock_at"]) + mu = np.maximum(g.xyz @ sub, 0.0) + t = P["lock_night_c"] + (P["lock_day_c"] - P["lock_night_c"]) * mu ** 0.25 + lapse = -P["lapse_c_per_km"] * np.maximum(z, 0.0) / 1000.0 + T = smooth_km(g, t, P["heat_transport_km"]) + lapse + dist_ocean = distance_to(g, ~land) if (~land).any() else np.full(g.n, 1e4) + wind = P["lock_wind_ms"] * tangent_dir(g.xyz, np.broadcast_to(sub, g.xyz.shape)) + rain = np.exp(-((T - P["lock_melt_c"]) / P["lock_melt_width_c"]) ** 2) # meltwater and frost in the twilight ring + k = P["global_mean_mm"] / max(np.sum(rain * g.area_km2) / g.area_km2.sum(), 1e-12) + out = {} + for s in SEASONS: + out[f"wind_{s}"] = wind.astype(np.float32) + out[f"P_{s}"] = rain * k + out[f"T_{s}"] = T + out["P_ann"] = rain * k + out["T_mean"] = T + out["T_range"] = np.zeros(g.n) + out["T_min"] = T + out["biotemp"] = biotemperature(T, out["T_range"]) + out["PET"] = 58.93 * out["biotemp"] + out["dist_ocean_km"] = dist_ocean + return out + + +def _still_ocean(g, t_mean): + """No circulation (ocean disabled, locked world): zero currents/upwelling/productivity, SST = T_mean.""" + z = np.zeros(g.n, np.float32) + return {"current": np.zeros((g.n, 3), np.float32), "current_speed": z, "sst": np.asarray(t_mean, np.float32), + "upwelling": z.copy(), "productivity": z.copy()} + + +def run(ctx) -> dict: + g = ctx.grid + P = params(ctx.cfg, "climate", DEFAULTS) + tilt = float(ctx.cfg["planet"]["tilt_deg"]) + (z,) = ctx.need("elevation_eroded_m") + z = z.astype(np.float64) + water = ctx.data.get("open_water", ctx.data.get("ocean")) # big inland basins are water to the air + land = ~np.asarray(water) if water is not None else z > 0 + O = params(ctx.cfg, "ocean", OC.DEFAULTS) + if P["lock"]: + out = run_locked(ctx, g, z, land, P) + out.update(_still_ocean(g, out["T_mean"])) + return out + sea = ~land + coupled = bool(O["enabled"]) and bool(sea.any()) + T, dist_ocean = temperatures(g, z, land, P, tilt, cur=np.zeros(g.n) if coupled else None) + decl = declinations(tilt) + def season_wind(s): + itcz = P["itcz_shift_deg"] * decl[s] / max(tilt, 1e-9) + wind = band_winds(g, itcz, P) + return wind + monsoon_winds(g, T[s], land, P) if s != "eq" else wind + winds = dict(zip(SEASONS, pmap(season_wind, SEASONS))) + if coupled: # one pass: winds from current-free temperatures, then currents carry heat + day = float(ctx.cfg["planet"]["day_hours"]) + w_ann = (winds["jun"] + winds["dec"] + 2 * winds["eq"]) / 4 + u = OC.currents(g, sea, w_ann, O, day) + T_eq = (T["jun"] + T["dec"] + 2 * T["eq"]) / 4 + T_s = OC.sst(g, sea, u, T_eq, O) + cur = smooth_km(g, np.where(sea, T_s - T_eq, 0.0), P["current_leak_km"]) + T, _ = temperatures(g, z, land, P, tilt, cur=cur) + out = {} + mixing = eddy_mixing(g, P) + rain = dict(zip(SEASONS, pmap(lambda s: precipitation(g, winds[s], T[s], z, land, + P["itcz_shift_deg"] * decl[s] / max(tilt, 1e-9), P, mixing), + SEASONS))) + del mixing + for s in SEASONS: + out[f"wind_{s}"] = winds[s].astype(np.float32) + ann = (rain["jun"] + rain["dec"] + 2 * rain["eq"]) / 4 + k = P["global_mean_mm"] / max(np.sum(ann * g.area_km2) / g.area_km2.sum(), 1e-12) + for s in SEASONS: + out[f"P_{s}"] = rain[s] * k + out[f"T_{s}"] = T[s] + out["P_ann"] = ann * k + out["T_mean"] = (T["jun"] + T["dec"] + 2 * T["eq"]) / 4 + out["T_range"] = np.abs(T["jun"] - T["dec"]) + out["T_min"] = np.minimum(T["jun"], T["dec"]) + out["biotemp"] = biotemperature(out["T_mean"], out["T_range"]) + out["PET"] = 58.93 * out["biotemp"] + out["dist_ocean_km"] = dist_ocean + if coupled: + sst_c = np.where(sea, np.maximum(T_s, SEA_FREEZE_C), out["T_mean"]) + w_up = OC.upwelling(g, sea, w_ann, O, day) + out.update({"current": u.astype(np.float32), + "current_speed": np.linalg.norm(u, axis=1).astype(np.float32), + "sst": sst_c.astype(np.float32), + "upwelling": w_up.astype(np.float32), + "productivity": OC.productivity(g, sea, w_up, z, out["T_range"], sst_c, O).astype(np.float32)}) + else: + out.update(_still_ocean(g, out["T_mean"])) + return out diff --git a/mapgen/config.py b/mapgen/config.py new file mode 100644 index 0000000..a52986a --- /dev/null +++ b/mapgen/config.py @@ -0,0 +1,269 @@ +"""Load and validate map/config/*.toml.""" +from __future__ import annotations + +import tomllib +from pathlib import Path + + +class ConfigError(ValueError): + pass + + +MASK_DEFAULTS = {"o2_range": 0.5, "gravity_range": 0.7} + +REQUIRED = { + "planet": { + "radius_km": (1000.0, 100000.0), + "gravity_g": (0.1, 5.0), + "day_hours": (1.0, 10000.0), + "year_days": (1.0, 100000.0), + "tilt_deg": (0.0, 90.0), + "sea_level_pressure_bar": (0.01, 100.0), + "scale_height_km": (1.0, 100.0), + "o2_fraction": (0.0, 1.0), + }, + "build": { + "seed": (0, 2**31 - 1), + "res_dev": (0, 8), + "res_final": (0, 8), + "raster_width": (64, 32768), + "preview_width": (64, 8192), + "land_fraction": (0.01, 0.99), + }, +} + + +def _check_ranges(cfg: dict, schema: dict, src: str) -> None: + for sec, keys in schema.items(): + if sec not in cfg: + raise ConfigError(f"{src}: missing section [{sec}]") + for k, (lo, hi) in keys.items(): + if k not in cfg[sec]: + raise ConfigError(f"{src}: missing [{sec}].{k}") + v = cfg[sec][k] + if isinstance(v, bool) or not isinstance(v, (int, float)): + raise ConfigError(f"{src}: [{sec}].{k} must be a number, got {v!r}") + if not lo <= v <= hi: + raise ConfigError(f"{src}: [{sec}].{k}={v} outside [{lo}, {hi}]") + + +def _latlon(v, what: str, src: str = "tectonics.toml") -> None: + ok = isinstance(v, list) and len(v) == 2 and all(isinstance(x, (int, float)) for x in v) + if not ok or not (-90 <= v[0] <= 90 and -180 <= v[1] <= 360): + raise ConfigError(f"{src}: {what} must be [lat, lon], got {v!r}") + + +def validate_tectonics(t: dict) -> None: + plates = t.get("plate", []) + if len(plates) < 2: + raise ConfigError("tectonics.toml: need at least 2 [[plate]] tables") + seen = set() + for p in plates: + pid = p.get("id", "?") + for k in ("id", "seed", "kind", "motion"): + if k not in p: + raise ConfigError(f"tectonics.toml: plate {pid} missing {k}") + if pid in seen: + raise ConfigError(f"tectonics.toml: duplicate plate id {pid}") + seen.add(pid) + if p["kind"] not in ("continental", "oceanic"): + raise ConfigError(f"tectonics.toml: plate {pid} kind must be continental|oceanic") + _latlon(p["seed"], f"plate {pid}.seed") + m = p["motion"] + if not (isinstance(m, list) and len(m) == 2 and 0 <= m[1] <= 20): + raise ConfigError(f"tectonics.toml: plate {pid}.motion must be [azimuth_deg, speed 0..20 cm/yr]") + for kind in ("lip", "volcano", "hotspot", "microcontinent"): + for x in t.get(kind, []): + for k in ("name", "center"): + if k not in x: + raise ConfigError(f"tectonics.toml: [[{kind}]] missing {k}") + _latlon(x["center"], f"{kind} {x['name']}.center") + if kind in ("lip", "volcano", "microcontinent") and not x.get("radius_km", 0) > 0: + raise ConfigError(f"tectonics.toml: {kind} {x['name']} needs radius_km > 0") + if kind == "hotspot" and not x.get("length_km", 0) > 0: + raise ConfigError(f"tectonics.toml: hotspot {x['name']} needs length_km > 0") + + +ERAS_FILE = "eras.toml" # events and eras: not part of the base world's inputs key (pipeline.inputs_key) +ZONE_FIELDS = {"gravity_g": (0.05, 5.0), "o2_fraction": (0.0, 1.0), "pressure_bar": (0.01, 10.0), + "fire_reactivity": (0.0, 5.0)} # render.CONTINUOUS holds every value in range +PLATEAU_SPACING_KM = 2500.0 +RESERVED_ERA_KEYS = {"order", "default"} + + +def _num(v, what: str, lo: float, hi: float, src: str = "tectonics.toml") -> None: + if isinstance(v, bool) or not isinstance(v, (int, float)) or not lo <= v <= hi: + raise ConfigError(f"{src}: {what} must be a number in [{lo}, {hi}], got {v!r}") + + +def _pair(v, what: str, lo: float, hi: float, src: str = "tectonics.toml") -> None: + ok = isinstance(v, list) and len(v) == 2 and all(isinstance(x, (int, float)) and not isinstance(x, bool) for x in v) + if not ok or not lo <= v[0] <= v[1] <= hi: + raise ConfigError(f"{src}: {what} must be [low, high] within [{lo}, {hi}], got {v!r}") + + +def _named(items: list, kind: str, src: str = "tectonics.toml") -> set: + seen = set() + for x in items: + if not isinstance(x.get("name"), str): + raise ConfigError(f"{src}: [[{kind}]] needs a name") + if x["name"] in seen: + raise ConfigError(f"{src}: duplicate {kind} name {x['name']!r}") + seen.add(x["name"]) + return seen + + +def _event(e: dict) -> None: + w, src, kind = f"event {e['name']}", ERAS_FILE, e.get("kind") + if kind == "disintegrate": + _latlon(e.get("center"), f"{w}.center", src) + _num(e.get("radius_km"), f"{w}.radius_km", 1.0, 20000.0, src) + _num(e.get("depth_m"), f"{w}.depth_m", 1.0, 20000.0, src) + elif kind == "volcano": + _latlon(e.get("center"), f"{w}.center", src) + _num(e.get("radius_km"), f"{w}.radius_km", 1.0, 5000.0, src) + _num(e.get("peak_m"), f"{w}.peak_m", -11000.0, 12000.0, src) + if e.get("shape", "cone") not in ("cone", "shield", "caldera"): + raise ConfigError(f"{src}: {w}.shape must be cone|shield|caldera, got {e.get('shape')!r}") + if e.get("peak_mode", "above") not in ("above", "absolute"): + raise ConfigError(f"{src}: {w}.peak_mode must be above|absolute, got {e.get('peak_mode')!r}") + elif kind == "zone": + f = e.get("fields") + if not isinstance(f, dict) or not f: + raise ConfigError(f"{src}: {w}.fields must set at least one of {sorted(ZONE_FIELDS)}") + for k, v in f.items(): + if k not in ZONE_FIELDS: + raise ConfigError(f"{src}: {w}.fields: unknown field {k!r} (known: {sorted(ZONE_FIELDS)})") + _num(v, f"{w}.fields.{k}", *ZONE_FIELDS[k], src) + shape = e.get("shape") + if shape == "circle": + _latlon(e.get("center"), f"{w}.center", src) + _num(e.get("radius_km"), f"{w}.radius_km", 1.0, 20000.0, src) + elif shape == "landmass": + _latlon(e.get("seed"), f"{w}.seed", src) + _pair(e.get("reach_km"), f"{w}.reach_km", 0.0, 20000.0, src) + else: + raise ConfigError(f"{src}: {w}.shape must be circle|landmass, got {shape!r}") + if "edge_km" in e: + _num(e["edge_km"], f"{w}.edge_km", 0.001, 5000.0, src) + elif e.get("profile") != "smooth": + raise ConfigError(f'{src}: {w} needs edge_km (a sharp edge) or profile = "smooth"') + else: + raise ConfigError(f"{src}: {w}.kind must be disintegrate|zone|volcano, got {kind!r}") + + +def validate_revision(t: dict, radius_km: float) -> None: + """Plateaus, land patches, zones, events and eras.""" + from .sphere import gc_dist_km, latlon_to_xyz + plateaus = t.get("plateau", []) + _named(plateaus, "plateau") + for p in plateaus: + w = f"plateau {p['name']}" + _latlon(p.get("center"), f"{w}.center") + _num(p.get("area_km2"), f"{w}.area_km2", 1.0e4, 5.0e7) + _num(p.get("elongation", 1.0), f"{w}.elongation", 1.0, 10.0) + _num(p.get("azimuth_deg", 0.0), f"{w}.azimuth_deg", -360.0, 360.0) + _pair(p.get("top_m"), f"{w}.top_m (metres below sea level, shallowest first)", 1.0, 11000.0) + if not isinstance(p.get("islands", False), bool): + raise ConfigError(f"tectonics.toml: {w}.islands must be true or false") + _num(p.get("vent", 1.0), f"{w}.vent", 0.0, 1.0) + for i, a in enumerate(plateaus): + for b in plateaus[i + 1:]: + d = float(gc_dist_km(latlon_to_xyz(*a["center"]), latlon_to_xyz(*b["center"]), radius_km)) + if d < PLATEAU_SPACING_KM: + raise ConfigError(f"tectonics.toml: plateaus {a['name']} and {b['name']} are {d:.0f} km apart " + f"(need ≥ {PLATEAU_SPACING_KM:.0f} km)") + _named(t.get("land_patch", []), "land_patch") + for p in t.get("land_patch", []): + w = f"land_patch {p['name']}" + _latlon(p.get("center"), f"{w}.center") + _num(p.get("radius_km"), f"{w}.radius_km", 1.0, 20000.0) + _num(p.get("strength", 1.0), f"{w}.strength", 0.0, 2.0) + _num(p.get("edge_noise", 0.0), f"{w}.edge_noise", 0.0, 1.0) + _named(t.get("zone", []), "zone") + for z in t.get("zone", []): + w = f"zone {z['name']}" + if z.get("field") not in ("o2", "gravity"): + raise ConfigError(f"tectonics.toml: {w}.field must be o2|gravity, got {z.get('field')!r}") + _latlon(z.get("center"), f"{w}.center") + _num(z.get("radius_km"), f"{w}.radius_km", 1.0, 20000.0) + _num(z.get("v"), f"{w}.v", -1.0, 1.0) + if z.get("profile", "smooth") != "smooth": + raise ConfigError(f"tectonics.toml: {w}.profile must be smooth") + names = _named(t.get("event", []), "event", ERAS_FILE) + for e in t.get("event", []): + _event(e) + eras = t.get("eras") + if eras is None: + return + order = eras.get("order") + if not (isinstance(order, list) and order and all(isinstance(n, str) for n in order) + and len(set(order)) == len(order)) or RESERVED_ERA_KEYS & set(order): + raise ConfigError(f"{ERAS_FILE}: [eras].order must list distinct era names (not order/default), got {order!r}") + if eras.get("default") not in order: + raise ConfigError(f"{ERAS_FILE}: [eras].default {eras.get('default')!r} is not in order {order}") + extra = set(eras) - set(order) - RESERVED_ERA_KEYS + if extra: + raise ConfigError(f"{ERAS_FILE}: [eras] tables {sorted(extra)} are not in order {order}") + for n in order: + e = eras.get(n) + if not isinstance(e, dict) or not isinstance(e.get("label", n), str): + raise ConfigError(f"{ERAS_FILE}: missing [eras.{n}] (label, events)") + evs = e.get("events", []) + if not isinstance(evs, list) or not all(isinstance(x, str) for x in evs): + raise ConfigError(f"{ERAS_FILE}: [eras.{n}].events must be a list of event names") + if "years" in e: + _num(e["years"], f"[eras.{n}].years (since the era before it)", 0.0, 1.0e9, ERAS_FILE) + unknown = [x for x in evs if x not in names] + if unknown: + raise ConfigError(f"{ERAS_FILE}: era {n} names unknown events {unknown}") + + +def era_events(tect: dict, name: str) -> list: + """The events of era `name`: those of every era before it in [eras].order, then its own (cumulative).""" + eras = tect.get("eras") or {} + order = eras.get("order", []) + if name not in order: + raise ConfigError(f"{ERAS_FILE}: unknown era {name!r} (eras: {order})") + by_name = {e["name"]: e for e in tect.get("event", [])} + out = [] + for n in order[: order.index(name) + 1]: + out += [by_name[e] for e in eras.get(n, {}).get("events", [])] + return out + + +def load(root: Path) -> tuple[dict, dict]: + wpath = root / "config" / "world.toml" + tpath = root / "config" / "tectonics.toml" + for p in (wpath, tpath): + if not p.exists(): + raise ConfigError(f"missing {p}") + with open(wpath, "rb") as f: + cfg = tomllib.load(f) + with open(tpath, "rb") as f: + tect = tomllib.load(f) + for k in ("event", "eras"): + if k in tect: + raise ConfigError(f"tectonics.toml: [{k}] belongs in config/{ERAS_FILE} (editing an era must not rebuild " + f"the base world)") + epath = root / "config" / ERAS_FILE + if epath.exists(): + with open(epath, "rb") as f: + e = tomllib.load(f) + unknown = set(e) - {"event", "eras"} + if unknown: + raise ConfigError(f"{ERAS_FILE}: unknown tables {sorted(unknown)} (only [[event]] and [eras])") + tect = {**tect, **e} + _check_ranges(cfg, REQUIRED, "world.toml") + validate_tectonics(tect) + validate_revision(tect, float(cfg["planet"]["radius_km"])) + return cfg, tect + + +def params(cfg: dict, section: str, defaults: dict) -> dict: + """Module defaults overridden by world.toml [section]; unknown keys are errors (typo guard).""" + over = cfg.get(section, {}) + unknown = set(over) - set(defaults) + if unknown: + raise ConfigError(f"world.toml [{section}]: unknown keys {sorted(unknown)}") + return {**defaults, **over} diff --git a/mapgen/crust.py b/mapgen/crust.py new file mode 100644 index 0000000..345a1aa --- /dev/null +++ b/mapgen/crust.py @@ -0,0 +1,97 @@ +"""Stage `crust`: continental vs oceanic crust, age classes, oceanic age.""" +from __future__ import annotations + +import numpy as np + +from .config import params +from . import plateaus as PL +from .graph import distance_to, smooth_km +from .noise import fbm, name_seed, ridged +from .plates import CONV, DIV +from .sphere import azimuth_deg, gc_dist_km, latlon_to_xyz + +OCEANIC, CRATON, PRE_OROGEN, POST_OROGEN, RIFT, LIP, SCAR, VOLCANO = range(8) +AGE_NAMES = ["oceanic", "craton (3g era)", "pre-Lightening orogen", "post-Lightening orogen", + "rift", "Lightening basalt province", "collapse scar", "overshoot volcano"] +DEFAULTS = {"continental_threshold": 0.25, "shelf_km": 450.0, "edge_noise": 0.5, "edge_noise_freq": 4.0, "edge_noise_gain": 0.65, "orogen_width_km": 700.0, "rift_width_km": 200.0, + "pre_orogen_threshold": 0.72, "half_spreading_km_myr": 30.0, "max_ocean_age_myr": 200.0, + "scar_inner": 0.4, "scar_outer": 1.6, "scar_halfwidth_deg": 25.0} + + +def center_dist(g, center): + return gc_dist_km(g.xyz, latlon_to_xyz(*center), g.radius_km) + + +def lip_profile(g, lip, seed): + r = lip["radius_km"] * (1.0 + 0.25 * fbm(g.xyz, name_seed(seed, lip["name"]), 3, 6.0)) + x = center_dist(g, lip["center"]) / r + return np.clip((1.0 - x) / 0.25, 0.0, 1.0) + + +def _scar_azimuths(v, seed): + if "scar_azimuths" in v: + return list(v["scar_azimuths"]) + rng = np.random.default_rng(name_seed(seed, v["name"])) + return list(rng.uniform(0, 360, size=2)) + + +def patch_field(g, patch: dict, seed: int): + """[[land_patch]]: land hint (strength) over a disc whose radius varies ± 25 % · edge_noise (fractal edge).""" + d = center_dist(g, patch["center"]) + r = float(patch["radius_km"]) + out = np.zeros(g.n) + near = d < 1.3 * r + if near.any(): + w = np.clip(2.0 * fbm(g.xyz[near], name_seed(seed, patch["name"]), 6, 12.0), -1.0, 1.0) + out[near] = patch.get("strength", 1.0) * (d[near] < r * (1.0 + 0.25 * patch.get("edge_noise", 0.0) * w)) + return out + + +def run(ctx) -> dict: + g = ctx.grid + P = params(ctx.cfg, "crust", DEFAULTS) + land, land_hint, btype = ctx.need("sk_land", "m_land_hint", "bnd_type") + rough = (fbm(g.xyz, ctx.seed + 23, 7, P["edge_noise_freq"], gain=P["edge_noise_gain"]) + if P["edge_noise"] > 0 else None) + + def continental_of(raw): + field = smooth_km(g, raw, P["shelf_km"]) + if rough is not None: # fractal margins: bays, peninsulas, offshore continental fragments + f = np.clip(field, 0.0, 1.0) + near = 4.0 * f * (1.0 - f) # strongest at the margin; none deep inland / far offshore + field = field + P["edge_noise"] * near * rough / max(float(rough.std()), 1e-12) + else: + field = np.maximum(field, (raw > 0.5).astype(float)) + cont = field > P["continental_threshold"] + for m in ctx.tect.get("microcontinent", []): + cont |= center_dist(g, m["center"]) < m["radius_km"] + return cont + + raw = np.clip(land + 0.5 * land_hint, 0.0, 1.0) + continental_base = continental_of(raw) # without land patches: its sea level is the world's + patches = sum((patch_field(g, p, ctx.seed) for p in ctx.tect.get("land_patch", [])), np.zeros(g.n)) + continental = continental_of(np.clip(raw + patches, 0.0, 1.0)) if patches.any() else continental_base.copy() + plateau_id = PL.cell_ids(g.xyz, ctx.tect.get("plateau", []), ctx.seed, g.radius_km) + continental |= plateau_id >= 0 # sunken plateaus: continental crust that never rose + d_conv = distance_to(g, btype == CONV) + d_div = distance_to(g, btype == DIV) + age = np.full(g.n, CRATON, np.int8) + age[continental & (ridged(g.xyz, ctx.seed + 21, 4, 4.0) > P["pre_orogen_threshold"])] = PRE_OROGEN + age[continental & (d_div < P["rift_width_km"])] = RIFT + age[continental & (d_conv < P["orogen_width_km"])] = POST_OROGEN + age[~continental] = OCEANIC + for lip in ctx.tect.get("lip", []): + age[lip_profile(g, lip, ctx.seed) > 0] = LIP + for v in ctx.tect.get("volcano", []): + d = center_dist(g, v["center"]) + r = v["radius_km"] + age[d < r] = VOLCANO + az = azimuth_deg(latlon_to_xyz(*v["center"]), g.xyz) + for a in _scar_azimuths(v, ctx.seed): + diff = np.abs((az - a + 180.0) % 360.0 - 180.0) + age[(diff < P["scar_halfwidth_deg"]) & (d > P["scar_inner"] * r) & (d < P["scar_outer"] * r)] = SCAR + oceanic_age = np.minimum(d_div / P["half_spreading_km_myr"], P["max_ocean_age_myr"]) + ocean_age = np.where(continental & (plateau_id < 0), 0.0, oceanic_age) # plateaus: the floor around them + return {"continental": continental, "age_class": age, "ocean_age_myr": ocean_age.astype(np.float32), + "d_conv_km": d_conv, "d_div_km": d_div, "continental_base": continental_base, "plateau_id": plateau_id, + "land_patch": patches.astype(np.float32)} diff --git a/mapgen/elevation.py b/mapgen/elevation.py new file mode 100644 index 0000000..47f0060 --- /dev/null +++ b/mapgen/elevation.py @@ -0,0 +1,197 @@ +"""Stage `elevation`: tectonic relief + Lightening provinces + hotspots + hints; sea level solved.""" +from __future__ import annotations + +import numpy as np + +from .config import params +from .crust import PRE_OROGEN, SCAR, center_dist, lip_profile, name_seed +from .fields import gravity_mod +from .graph import OCEAN_MIN_KM2, distance_to, nearest_source, ocean_mask, smooth_km +from .noise import fbm, ridged +from . import plateaus as PL +from .pipeline import StageError +from .plates import edge_convergence +from .sphere import east_north, gc_dist_km, great_circle_point, latlon_to_xyz + +OVER, SUB, COLLISION = 1, 2, 3 +DEFAULTS = { + "continental_base_m": 400.0, "continental_noise_m": 350.0, + "margin_km": 500.0, "shelf_m": -200.0, "slope_km": 250.0, + "coast_noise_m": 900.0, "coast_band_km": 600.0, "coast_noise_freq": 6.0, + "ridge_depth_m": 2500.0, "age_depth_coeff": 350.0, "abyss_m": 6500.0, + "rate_full_m_yr": 0.05, "min_rate_m_yr": 0.005, + "trench_depth_m": 4000.0, "trench_width_km": 70.0, + "arc_cont_m": 5500.0, "arc_ocean_m": 3500.0, "arc_offset_km": 200.0, "arc_width_km": 110.0, + "collision_peak_m": 9500.0, "collision_width_km": 260.0, "plateau_m": 4500.0, "plateau_km": 800.0, + "rift_depth_m": 1200.0, "rift_shoulder_m": 800.0, + "pre_orogen_m": 1200.0, "lip_m": 1500.0, "lip_step_m": 300.0, + "apron_m": 500.0, "scar_drop_m": 1500.0, + "hotspot_m": 5500.0, "hotspot_spacing_km": 150.0, "hotspot_radius_km": 70.0, + "hint_m": 2500.0, "hint_km": 80.0, "land_hint_m": 800.0, "spire_m": 2500.0, "detail_m": 250.0, + "max_land_m": 12000.0, "min_ocean_m": -11000.0, +} + + +def solve_sea_level(z, area, land_fraction): + order = np.argsort(-z) + cum = np.cumsum(area[order]) / area.sum() + k = min(int(np.searchsorted(cum, land_fraction)), len(z) - 1) + return z - z[order[k]] + + +def solve_sea_level_connected(g, z, land_fraction, min_sea_km2=OCEAN_MIN_KM2, iters=30): + """Shift z so land = everything outside the connected ocean covers land_fraction (interior pits stay land).""" + area, tot = g.area_km2, g.area_km2.sum() + if abs(area[~ocean_mask(g, z, min_sea_km2)].sum() / tot - land_fraction) <= 0.5 * area.min() / tot: + return z # already there: a flat sea floor would pull the bisection onto it + s0 = float(np.asarray(z)[np.argsort(-z)][min(int(np.searchsorted(np.cumsum(area[np.argsort(-z)]) / tot, + land_fraction)), len(z) - 1)]) + lo, hi = s0 - 3000.0, s0 + 3000.0 + for _ in range(iters): + mid = 0.5 * (lo + hi) + if area[~ocean_mask(g, z - mid, min_sea_km2)].sum() / tot > land_fraction: + lo = mid + else: + hi = mid + return z - 0.5 * (lo + hi) + + +def _smoothstep(a, b, x): + t = np.clip((x - a) / (b - a), 0.0, 1.0) + return t * t * (3 - 2 * t) + + +def roles(g, plate, vel, continental, ocean_age, plate_continental, min_rate): + conv, _ = edge_convergence(g, vel) + s, d = g.src, g.dst + e = (plate[s] != plate[d]) & (conv > min_rate) + cs, cd = continental[s[e]], continental[d[e]] + ks, kd = plate_continental[plate[s[e]]], plate_continental[plate[d[e]]] + over_s = np.where(cs & ~cd, True, np.where(~cs & cd, False, + np.where(ks & ~kd, True, np.where(~ks & kd, False, ocean_age[s[e]] < ocean_age[d[e]])))) + role_e = np.where(cs & cd, COLLISION, np.where(over_s, OVER, SUB)).astype(np.int8) + order = np.argsort(conv[e]) + role = np.zeros(g.n, np.int8) + role[s[e][order]] = role_e[order] + return role + + +def _on_plate(g, plate, role_mask, other=None): + d, src = nearest_source(g, np.flatnonzero(role_mask)) + ok = src >= 0 + same = ok & (plate == plate[np.maximum(src, 0)]) + if other is not None: + same |= ok & (plate == other[np.maximum(src, 0)]) + return np.where(same, d, np.inf), np.maximum(src, 0) + + +def hotspot_track(g, h: dict, vel, spacing_km: float): + """(unit vector, km from the active end) along a hotspot chain; the chain follows the plate's motion.""" + c = latlon_to_xyz(*h["center"]) + v = vel[g.cell_index(*h["center"])] + sp = np.linalg.norm(v) + t = v / sp if sp > 0 else east_north(c[None])[0][0] + return [(great_circle_point(c, t, k * spacing_km, g.radius_km), k * spacing_km) + for k in range(int(h["length_km"] // spacing_km) + 1)] + + +def _relief(ctx, g, P, cont): + """Heights before the sea-level solve for the continental mask `cont` → (z, role, d_over, d_sub, d_coll).""" + R, seed = g.radius_km, ctx.seed + (plate, vel, pk, brate, bother, age, ocean_age, d_div, land_hint, sk_mtn, mtn_hint) = ctx.need( + "plate", "vel", "plate_continental", "bnd_rate", "bnd_other", "age_class", "ocean_age_myr", "d_div_km", + "m_land_hint", "sk_mountains", "m_mountain_hint") + f = lambda r: np.clip(r / P["rate_full_m_yr"], 0.3, 1.0) + + # passive-margin profile: interior plateau → coastal ramp → shelf → slope → abyssal floor + d_in = distance_to(g, ~cont) if (~cont).any() else np.full(g.n, np.inf) + d_out = distance_to(g, cont) if cont.any() else np.full(g.n, np.inf) + ramp = _smoothstep(0.0, P["margin_km"], d_in) + z_cont = P["shelf_m"] + (P["continental_base_m"] - P["shelf_m"]) * ramp + z_cont = z_cont + P["continental_noise_m"] * fbm(g.xyz, seed + 31, 5, 3.0) + z_ocean = -np.minimum(P["ridge_depth_m"] + P["age_depth_coeff"] * np.sqrt(ocean_age), P["abyss_m"]) + z_ocean = P["shelf_m"] + (z_ocean - P["shelf_m"]) * _smoothstep(0.0, P["slope_km"], d_out) + z = np.where(cont, z_cont, z_ocean) + # multi-scale coastal noise: sea level cuts it → bays, headlands, drowned valleys, offshore islands + d_edge = np.where(cont, d_in, d_out) + z += P["coast_noise_m"] * np.exp(-d_edge / P["coast_band_km"]) * fbm(g.xyz, seed + 91, 7, P["coast_noise_freq"]) + + role = roles(g, plate, vel, cont, ocean_age, pk, P["min_rate_m_yr"]) + d_over, s_over = _on_plate(g, plate, role == OVER) + d_sub, s_sub = _on_plate(g, plate, role == SUB) + d_coll, s_coll = _on_plate(g, plate, role == COLLISION, other=bother) + + z += -P["trench_depth_m"] * f(brate[s_sub]) * np.exp(-(d_sub / P["trench_width_km"]) ** 2) + arc_h = np.where(cont, P["arc_cont_m"], P["arc_ocean_m"]) + z += arc_h * f(brate[s_over]) * np.exp(-((d_over - P["arc_offset_km"]) / P["arc_width_km"]) ** 2) + w = P["collision_width_km"] + z += P["collision_peak_m"] * f(brate[s_coll]) * np.exp(-(d_coll / w) ** 2) + plateau = _smoothstep(0.5 * w, 1.5 * w, d_coll) * (1 - _smoothstep(0.7 * P["plateau_km"], P["plateau_km"], d_coll)) + z += np.where(cont, P["plateau_m"] * f(brate[s_coll]) * plateau, 0.0) + rift = -P["rift_depth_m"] * np.exp(-(d_div / 60.0) ** 2) + P["rift_shoulder_m"] * np.exp(-((d_div - 120.0) / 60.0) ** 2) + z += np.where(cont, rift, 0.0) + + z += np.where(age == PRE_OROGEN, P["pre_orogen_m"] * ridged(g.xyz, seed + 21, 4, 4.0), 0.0) + for lip in ctx.tect.get("lip", []): + prof = lip_profile(g, lip, seed) + z += np.floor(P["lip_m"] * prof / P["lip_step_m"]) * P["lip_step_m"] + for v in ctx.tect.get("volcano", []): + d = center_dist(g, v["center"]) + r, H = v["radius_km"], v.get("height_m", 7000.0) + z += H * np.clip(1 - d / r, 0, 1) ** 1.5 - 0.35 * H * np.exp(-(d / (0.12 * r)) ** 2) + z += P["apron_m"] * np.exp(-((d - r) / (0.3 * r)) ** 2) * (0.5 + 0.5 * fbm(g.xyz, name_seed(seed, v["name"]), 3, 20.0)) + z -= np.where(age == SCAR, P["scar_drop_m"], 0.0) + + for h in ctx.tect.get("hotspot", []): + peaks = np.zeros(g.n) + for p, s in hotspot_track(g, h, vel, P["hotspot_spacing_km"]): + dk = gc_dist_km(g.xyz, p, R) + hk = P["hotspot_m"] * (1 - s / h["length_km"]) + peaks = np.maximum(peaks, hk * np.clip(1 - dk / P["hotspot_radius_km"], 0, 1) ** 1.2) + z += peaks + + hint = smooth_km(g, np.clip(sk_mtn + mtn_hint, 0, 1), P["hint_km"]) + z += np.where(cont, P["hint_m"] * hint, 0.0) + z += P["land_hint_m"] * land_hint + z += P["detail_m"] * fbm(g.xyz, seed + 41, 6, 12.0) + return z, role, d_over, d_sub, d_coll + + +def run(ctx) -> dict: + g = ctx.grid + P = params(ctx.cfg, "elevation", DEFAULTS) + continental, sk_land, land_hint, m_grav, m_lock = ctx.need("continental", "sk_land", "m_land_hint", + "m_gravity_zones", "m_lock") + plateau_id = np.asarray(ctx.data.get("plateau_id", np.full(g.n, -1))) + cont = np.asarray(continental) & (plateau_id < 0) # plateaus: ocean until their surfaces are set (§3) + base = np.asarray(ctx.data.get("continental_base", continental)) & (plateau_id < 0) + z, role, d_over, d_sub, d_coll = _relief(ctx, g, P, cont) + + target = ctx.cfg["build"]["land_fraction"] + cont_area = g.area_km2[base].sum() / g.area_km2.sum() + if target > cont_area + 0.005: + raise StageError(f"land_fraction {target} exceeds continental crust area {cont_area:.3f}: sea level would " + f"lift ocean ridges into land; lower [build] land_fraction or raise [crust] shelf_km") + min_sea = ctx.cfg.get("erosion", {}).get("min_sea_km2", OCEAN_MIN_KM2) + sea_ref = np.zeros(g.n, bool) # sea in the world without land patches + if np.array_equal(base, cont): + z = solve_sea_level_connected(g, z, target, min_sea) + else: # land patches (the eastern continent made whole) must not move every other coast: the sea level of the + z_raw = _relief(ctx, g, P, base)[0] # world without them, applied to the world with them + z_ref = solve_sea_level_connected(g, z_raw, target, min_sea) + z = z - float(np.mean(z_raw - z_ref)) + sea_ref = ocean_mask(g, z_ref, min_sea) + patch = smooth_km(g, np.asarray(ctx.data.get("land_patch", np.zeros(g.n)), dtype=np.float64), P["hint_km"]) + z += P["land_hint_m"] * patch # a patch is a land hint in height too (bare crust + # sits below the world's sea level: land) + mult = np.clip(1.0 / gravity_mod(ctx.cfg, m_grav), 1.0, 3.0) + spires = P["spire_m"] * (mult - 1) * ridged(g.xyz, ctx.seed + 71, 4, 40.0) + z = np.where(z > 0, z * mult + spires, z) + target_land = (sk_land + 0.5 * land_hint) > 0.5 + lock = m_lock > 0.5 + z = np.where(lock & target_land, np.maximum(z, 50.0), np.where(lock & ~target_land, np.minimum(z, -50.0), z)) + z = PL.apply(g, z, ctx.tect.get("plateau", []), plateau_id, ctx.seed) # sunken plateaus (after the solve) + z = np.clip(z, P["min_ocean_m"], P["max_land_m"] * mult) + added = ~ocean_mask(g, z, min_sea) & (sea_ref | (plateau_id >= 0)) # land the sea-level solve never saw + return {"elevation_m": z.astype(np.float32), "role": role, "d_over_km": d_over, "d_sub_km": d_sub, + "d_coll_km": d_coll, "land_added": added} diff --git a/mapgen/environment.py b/mapgen/environment.py new file mode 100644 index 0000000..fbd3c8e --- /dev/null +++ b/mapgen/environment.py @@ -0,0 +1,158 @@ +"""Stage `environment`: Holdridge life zone + seasonality tag + lithology + landform + ground.""" +from __future__ import annotations + +import numpy as np + +from .config import params +from .crust import CRATON, LIP, POST_OROGEN, PRE_OROGEN, RIFT, SCAR, VOLCANO +from .graph import nbr_max, nbr_min, steepest_receivers +from . import minerals as MN +from .noise import fbm + +REGIONS = ["polar", "subpolar", "boreal", "cool temperate", "warm temperate", "subtropical", "tropical"] +ZONES = { + "polar": ["desert"], + "subpolar": ["dry tundra", "moist tundra", "wet tundra", "rain tundra"], + "boreal": ["desert", "dry scrub", "moist forest", "wet forest", "rain forest"], + "cool temperate": ["desert", "desert scrub", "steppe", "moist forest", "wet forest", "rain forest"], + "warm temperate": ["desert", "desert scrub", "thorn steppe", "dry forest", "moist forest", "wet forest", + "rain forest"], + "subtropical": ["desert", "desert scrub", "thorn woodland", "dry forest", "moist forest", "wet forest", + "rain forest"], + "tropical": ["desert", "desert scrub", "thorn woodland", "very dry forest", "dry forest", "moist forest", + "wet forest", "rain forest"], +} +THRESH = { # annual precipitation (mm) upper bounds between successive zones + "polar": [], "subpolar": [125, 250, 500], "boreal": [125, 250, 500, 1000], + "cool temperate": [125, 250, 500, 1000, 2000], "warm temperate": [125, 250, 500, 1000, 2000, 4000], + "subtropical": [125, 250, 500, 1000, 2000, 4000], "tropical": [125, 250, 500, 1000, 2000, 4000, 8000], +} +HOLDRIDGE_NAMES = [f"{r} {z}" for r in REGIONS for z in ZONES[r]] + +SEAS_F, SEAS_S, SEAS_W, SEAS_M = 0, 1, 2, 3 +SEASONALITY_NAMES = ["even rainfall", "dry summer", "dry winter / summer monsoon", "tropical monsoon"] + +LI_OCEAN, LI_GRANITE, LI_LIMESTONE, LI_BASALT, LI_ANDESITE, LI_SANDSTONE, LI_METAMORPHIC = range(7) +LITHOLOGY_NAMES = ["oceanic basalt", "granite/gneiss", "limestone", "basalt", "andesite", "sandstone/shale", + "metamorphic"] + +(LF_OCEAN, LF_PLAIN, LF_HILLS, LF_MOUNTAINS, LF_PLATEAU, LF_RIFT, LF_ESCARPMENT, LF_ARC, LF_MASSIF, + LF_BASALT_PLATEAU, LF_DUNES, LF_BADLANDS) = range(12) +LANDFORM_NAMES = ["ocean", "plain", "hills", "mountains", "plateau", "rift valley", "escarpment", + "volcanic arc", "volcanic massif", "basalt plateau", "dunes", "badlands"] + +(GR_NONE, GR_WETLAND, GR_BOG, GR_FLOODPLAIN, GR_DELTA, GR_MANGROVE, GR_SALT_FLAT, GR_PERMAFROST, + GR_KARST) = range(9) +GROUND_NAMES = ["—", "wetland", "bog", "floodplain", "delta", "mangrove", "salt flat", "permafrost", "karst"] + +LEGENDS = {"deposits": MN.LEGEND, "holdridge": HOLDRIDGE_NAMES, "seasonality": SEASONALITY_NAMES, "landform": LANDFORM_NAMES, + "lithology": LITHOLOGY_NAMES, "ground": GROUND_NAMES} + +DEFAULTS = {"relief_km": 100.0, "coastal_km": 60.0, "dry_ratio_s": 3.0, "dry_ratio_w": 4.0, "monsoon_min_mm": 1500.0, "flat_m_per_km": 1.0, + "wet_min_mm": 700.0, "karst_min_mm": 800.0, "small_river_km3_yr": 5.0, "life": True} + + +def holdridge(bio, p, tmin): + region = np.select([bio < 1.5, bio < 3, bio < 6, bio < 12, (bio < 24) & (tmin < 0), bio < 24], + [0, 1, 2, 3, 4, 5], default=6).astype(np.int8) + zone = np.zeros(len(bio), np.int16) + offset = 0 + for ri, r in enumerate(REGIONS): + sel = region == ri + zone[sel] = offset + np.searchsorted(THRESH[r], p[sel], side="right") + offset += len(ZONES[r]) + return zone, region + + +def seasonality(p_jun, p_dec, lat, tmin, p_ann, P): + summer = np.where(lat >= 0, p_jun, p_dec) + winter = np.where(lat >= 0, p_dec, p_jun) + tag = np.full(len(lat), SEAS_F, np.int8) + tag[summer < winter / P["dry_ratio_s"]] = SEAS_S + w = winter < summer / P["dry_ratio_w"] + tag[w] = SEAS_W + tag[w & (tmin >= 18.0) & (p_ann >= P["monsoon_min_mm"])] = SEAS_M + return tag + + +def lithology(g, z, continental, age, d_over, seed): + nz = fbm(g.xyz, seed + 51, 4, 5.0) + lit = np.full(g.n, LI_GRANITE, np.int8) + lit[continental & (z < 600) & (nz > 0.1)] = LI_SANDSTONE + lit[continental & (z < 300) & (nz < -0.1)] = LI_LIMESTONE + lit[np.isin(age, [POST_OROGEN, PRE_OROGEN]) & (z > 2000)] = LI_METAMORPHIC + lit[~continental] = LI_OCEAN + lit[(d_over > 100) & (d_over < 320)] = LI_ANDESITE + lit[np.isin(age, [LIP, VOLCANO, SCAR])] = LI_BASALT + return lit + + +def landform(g, z, age, lit, d_over, p_ann, ocean=None, relief_km=100.0): + hi, lo = np.asarray(z, dtype=np.float64), np.asarray(z, dtype=np.float64) + for _ in range(max(1, int(round(relief_km / g.spacing_km)))): # window ≈ relief_km at any resolution + hi, lo = nbr_max(g, hi), nbr_min(g, lo) + relief = hi - lo + lf = np.full(g.n, LF_PLAIN, np.int8) + lf[relief >= 300] = LF_HILLS + lf[(z > 1000) & (relief < 500)] = LF_PLATEAU + lf[(relief >= 1500) | (z > 2500)] = LF_MOUNTAINS + lf[(p_ann < 250) & (relief < 300) & (lit == LI_SANDSTONE)] = LF_DUNES + lf[(p_ann >= 250) & (p_ann < 500) & (relief >= 200) & (relief < 800) & (lit == LI_SANDSTONE)] = LF_BADLANDS + lf[age == RIFT] = LF_RIFT + lf[age == LIP] = LF_BASALT_PLATEAU + ocean = z <= 0 if ocean is None else ocean + lf[(d_over > 100) & (d_over < 320) & ~ocean] = LF_ARC + lf[age == VOLCANO] = LF_MASSIF + lf[age == SCAR] = LF_ESCARPMENT + lf[ocean] = LF_OCEAN + return lf, relief + + +def ground(g, z, lit, t_mean, t_min, p_ann, dist_ocean, river, strahler, discharge, lake, salt_flat, P, ocean=None): + land = ~(z <= 0 if ocean is None else ocean) + _, slope, _ = steepest_receivers(g, z) + flat = slope < P["flat_m_per_km"] + coastal = land & (dist_ocean < P["coastal_km"]) + wet = land & flat & ~lake & (p_ann > P["wet_min_mm"]) & (discharge > P["small_river_km3_yr"]) + gr = np.zeros(g.n, np.int8) + gr[land & (t_mean < -2.0)] = GR_PERMAFROST + gr[land & (lit == LI_LIMESTONE) & (p_ann > P["karst_min_mm"])] = GR_KARST + gr[wet & (t_mean < 5.0)] = GR_BOG + gr[wet & (t_mean >= 5.0)] = GR_WETLAND + gr[river & (strahler >= 3) & flat] = GR_FLOODPLAIN + gr[coastal & flat & (t_min >= 20.0) & (p_ann > 1000.0)] = GR_MANGROVE + gr[river & (strahler >= 4) & coastal] = GR_DELTA + gr[salt_flat] = GR_SALT_FLAT + if not P["life"]: + gr[np.isin(gr, [GR_WETLAND, GR_BOG, GR_MANGROVE])] = GR_NONE + return gr + + +def run(ctx) -> dict: + g = ctx.grid + P = params(ctx.cfg, "environment", DEFAULTS) + (z, cont, age, d_over, t_mean, t_min, p_ann, p_jun, p_dec, bio, dist_ocean, river, strahler, q, lake, + salt) = ctx.need("elevation_eroded_m", "continental", "age_class", "d_over_km", "T_mean", "T_min", "P_ann", + "P_jun", "P_dec", "biotemp", "dist_ocean_km", "river", "strahler", "discharge_km3_yr", + "lake", "salt_flat") + z = z.astype(np.float64) + bed = z + if "lake_level_m" in ctx.data: # the ground's shape: lakes at their water, not their beds + lev = np.asarray(ctx.data["lake_level_m"], dtype=np.float64) + z = np.where(np.isfinite(lev), np.maximum(z, lev), z) + ocean = np.asarray(ctx.data["ocean"]) if "ocean" in ctx.data else z <= 0 + zone, region = holdridge(bio, p_ann, t_min) + lit = lithology(g, z, cont, age, d_over, ctx.seed) + lf, relief = landform(g, z, age, lit, d_over, p_ann, ocean, P["relief_km"]) + gr = ground(g, z, lit, t_mean, t_min, p_ann, dist_ocean, river, strahler, q, lake, salt, P, ocean) + coal = (lit == LI_SANDSTONE) & (p_ann > 600) & ~ocean & bool(P["life"]) + get = lambda k, v: np.asarray(ctx.data[k]) if k in ctx.data else np.full(g.n, v) + deposits, main = MN.place(g, {"land": ~ocean & ~lake, "z": bed, "lit": lit, "age": age, "lf": lf, "relief": relief, + "t_mean": t_mean, "p_ann": p_ann, "d_coll": get("d_coll_km", np.inf), + "salt": salt, "endo": get("endorheic", False), "dist_ocean": dist_ocean, + "coal": coal}, ctx.seed) + iron = np.uint32(MN.BIT["bog_iron"] | MN.BIT["ironstone"] | MN.BIT["iron_high"]) + return {"deposits": deposits, "deposit_main": main,"holdridge": zone, "hold_region": region, + "seasonality": seasonality(p_jun, p_dec, g.lat, t_min, p_ann, P), + "landform": lf, "relief_m": relief, "lithology": lit, "ground": gr, + "coal_potential": coal, "iron_potential": (deposits & iron) > 0} diff --git a/mapgen/eras.py b/mapgen/eras.py new file mode 100644 index 0000000..9b0dbc4 --- /dev/null +++ b/mapgen/eras.py @@ -0,0 +1,219 @@ +"""Eras: the base world plus local events. build_era applies an era's events +to the built base, re-runs the stages they affect inside an influence mask (changed cells + mask_km) and keeps every +cell outside it exactly as the base. Results: out/r<res>/eras/<era>/ (as out/r<res>/), previews in +previews/r<res>/eras/<era>/. An era's cache key is the base world's inputs key plus its events (config/eras.toml). Each era's events happen at the end of the era before it; its `years` erode what the events so far +changed; zone events follow the land at the start of their own era.""" +from __future__ import annotations + +import hashlib +import importlib +import json +import re +import shutil +from pathlib import Path + +import numpy as np + +from . import config as C, environment as EN, events as EV, fields as FL, pipeline as P +from .config import params +from .erosion import DEFAULTS as EROSION_DEFAULTS, K_MULT, erode +from .graph import distance_to, ocean_mask + +DEFAULTS = {"mask_km": 1500.0, "climate_blend_km": 300.0, "erosion_steps": 1, "years": 10000.0} +RERUN = ("climate", "hydrology", "seabed", "environment", "ice", "fields") + + +def era_dir(root: Path, res: int, name: str) -> Path: + return Path(root) / "out" / f"r{res}" / "eras" / name + + +def steps(tect: dict, name: str) -> list: + """[(era, its own events, years)] for every era up to and including `name` ([eras] order): an era's events happen + at the end of the era before it; `years` is the time from them to the era's map (None: the default).""" + C.era_events(tect, name) # an unknown era: ConfigError + eras = tect.get("eras") or {} + order = eras["order"] + by_name = {e["name"]: e for e in tect.get("event", [])} + return [(n, [by_name[e] for e in eras.get(n, {}).get("events", [])], eras.get(n, {}).get("years")) + for n in order[: order.index(name) + 1]] + + +def fingerprint(base_key: str, steps_: list) -> str: + content = [[own, yrs] for _, own, yrs in steps_] + return hashlib.sha256((base_key + json.dumps(content, sort_keys=True)).encode()).hexdigest()[:16] + + +def _same(a, b) -> bool: + """Equal arrays; NaN equals NaN in float arrays (a field may hold NaN where it has no value).""" + a, b = np.asarray(a), np.asarray(b) + return np.array_equal(a, b, equal_nan=a.dtype.kind in "fc" and b.dtype.kind in "fc") + + +def merge_lake_ids(base, new, mask): + """Lake ids of an era: the base's outside the mask; inside it a lake the era left as it was keeps its base id and + every other lake gets a fresh id after the base's, so one id never names two lakes.""" + base, new, mask = np.asarray(base), np.asarray(new), np.asarray(mask, bool) + out = base.copy() + nxt = max(int(base.max()) + 1, 0) if len(base) else 0 + counts = np.bincount(base[base >= 0]) if (base >= 0).any() else np.zeros(0, np.int64) + ks = np.unique(new[mask & (new >= 0)]) + idx = np.flatnonzero(np.isin(new, ks)) + idx = idx[np.argsort(new[idx], kind="stable")] + starts = np.searchsorted(new[idx], ks) + ends = np.append(starts[1:], len(idx)) + for s, e in zip(starts.tolist(), ends.tolist()): + cells = idx[s:e] + b = base[cells] + kept = b[0] >= 0 and bool(np.all(b == b[0])) and counts[b[0]] == len(cells) + into = cells[mask[cells]] + if kept: + out[into] = b[0] + else: + out[into] = nxt + nxt += 1 + out[mask & (new < 0)] = -1 + return out.astype(base.dtype) + + +def zone_key(name: str) -> str: + return "zone_" + re.sub(r"[^A-Za-z0-9]", "_", name) + + +def _heights(g, z, events, min_sea, log, name, uncut=lambda z: z): + for ev in events: # heights first, in era order + if ev["kind"] == "disintegrate": + z, z_ref = EV.disintegrate(g.xyz, z, ~ocean_mask(g, uncut(z), min_sea), ev, g.radius_km) + if z_ref is None: + log(f"era {name}: warning: {ev['name']} has no land in its rim ring (0.9–1.0 × radius): " + f"its bowl hangs from 0 m") + else: + log(f"era {name}: {ev['name']} (ground at its rim {z_ref:.0f} m)") + elif ev["kind"] == "volcano": + z = EV.volcano(g.xyz, z, ev, g.radius_km) + return z + + +def build_era(root: Path, res: int, name: str, log=print, base=None, low_memory: bool = False) -> Path: + root = Path(root) + cfg, tect = C.load(root) + events = C.era_events(tect, name) + if not events: + log(f"era {name}: the base world (out/r{res})") + return root / "out" / f"r{res}" + out = era_dir(root, res, name) + base_key = P.inputs_key(root, res) + plan = steps(tect, name) + key = fingerprint(base_key, plan) + label = tect["eras"][name].get("label", name) + meta_path = out / "cells_meta.json" + if meta_path.exists() and (out / "cells.npz").exists(): + meta = json.loads(meta_path.read_text()) + era = meta.get("era", {}) + if era.get("fingerprint") == key: + if (era.get("label"), era.get("name")) != (label, name): # a new label or name needs no rebuild + era["label"], era["name"] = label, name + meta_path.write_text(json.dumps(meta, indent=1)) + log(f"era {name}: cached") + return out + ctx = base if base is not None else P.build(root, res, stop="fields", log=lambda m: None, low_memory=low_memory) + low_memory = low_memory or ctx.low_memory + g, d = ctx.grid, ctx.data + E = params(ctx.cfg, "eras", DEFAULTS) + EP = {**params(ctx.cfg, "erosion", EROSION_DEFAULTS), "steps": E["erosion_steps"]} + kmult = np.vectorize(K_MULT.get)(np.asarray(d["age_class"])).astype(np.float64) + z0 = np.asarray(d["elevation_eroded_m"], dtype=np.float64) + z, ocean = z0.copy(), np.asarray(d["ocean"]) + water = np.asarray(d.get("open_water", ocean)) + cut = np.asarray(d.get("lake_cut_m", np.zeros(g.n)), dtype=np.float64) + uncut = lambda zz: np.where(zz == z0, zz + cut, zz) # sea masks: the ground before lake beds were carved + zones, lands = [], [] + for _, own, yrs in plan: # era by era: its events at the end of the era before + for ev in own: # zones follow the land before their own era's events + if ev["kind"] == "zone": + zones.append(ev) + lands.append(EV.landmass(g, ~water, ev["seed"], ev["name"]) if ev["shape"] == "landmass" else None) + z = _heights(g, z, own, EP["min_sea_km2"], log, name, uncut) + hit = z != z0 + if hit.any(): # the changed ground weathers for the era's years + years = float(E["years"] if yrs is None else yrs) + ze = erode(g, z, kmult, {**EP, "dt_myr": years / 1.0e6 / E["erosion_steps"]}) + z = np.where(hit, np.maximum(np.minimum(z, ze), -11000.0), z) + ocean, water = ocean_mask(g, uncut(z), np.inf), ocean_mask(g, uncut(z), EP["min_sea_km2"]) + hit = z != z0 + weights = [EV.zone_weight(g.xyz, ev, g.radius_km, ctx.seed, None if land is None else g.xyz[land]) + for ev, land in zip(zones, lands)] + changed = hit | (ocean != np.asarray(d["ocean"])) | (water != np.asarray(d.get("open_water", d["ocean"]))) + for w in weights: + changed |= w > 0 + mask = distance_to(g, changed) <= E["mask_km"] if changed.any() else np.zeros(g.n, bool) + + ec = P.Ctx(ctx.root, ctx.cfg, ctx.tect, ctx.res, dict(d), g, low_memory=low_memory) + ec.data.update({"elevation_eroded_m": z.astype(np.asarray(d["elevation_eroded_m"]).dtype), "ocean": ocean, + "open_water": water, "lake_carve": hit}) # lake beds: geology, carved where the ground moved + new_keys = {} + for s in RERUN: + o = importlib.import_module(f"mapgen.{s}").run(ec) + ec.data.update(o) + new_keys[s] = list(o) + blend = np.clip(distance_to(g, ~mask) / E["climate_blend_km"], 0.0, 1.0) if (~mask).any() else np.ones(g.n) + shape = lambda a, v: v.reshape(-1, *([1] * (a.ndim - 1))) + merged = dict(d) + for s, keys in new_keys.items(): + for k in keys: + nv, bv = np.asarray(ec.data[k]), np.asarray(d[k]) + if s == "climate" and nv.dtype.kind == "f": # solved on the whole world, blended in over 300 km + nv = (bv + shape(nv, blend) * (nv - bv)).astype(nv.dtype) + merged[k] = np.where(shape(nv, mask), nv, bv) + for k in ("deposits", "deposit_main", "iron_potential"): # ore is the base's geology: events move ground, the + if k in d: # rank-by-share placement would shift it world-wide + merged[k] = d[k] + if "lake_id" in merged: # one id never names two lakes + merged["lake_id"] = merge_lake_ids(d["lake_id"], ec.data["lake_id"], mask) + merged["elevation_eroded_m"] = np.where(mask, ec.data["elevation_eroded_m"], d["elevation_eroded_m"]) # events and + merged["ocean"] = ocean # lake beds: inside only + if "open_water" in d: + merged["open_water"] = water + H = float(ctx.cfg["planet"]["scale_height_km"]) * 1000.0 + for ev, w, land in zip(zones, weights, lands): # zone events write their fields last + merged.update(EV.apply_zone(merged, w, ev, merged["z_surface_m"], H)) + merged[zone_key(ev["name"])] = w.astype(np.float32) + if land is not None: + merged[zone_key(ev["name"]) + "_land"] = land + if zones: # plants settle into the zone's air and gravity + pl = ctx.cfg["planet"] + merged["plant_height_x"] = FL.plant_height(pl["gravity_g"], merged["gravity_g"]).astype( + np.asarray(d["plant_height_x"]).dtype) + sea_p = np.asarray(merged["pressure_bar"], dtype=np.float64) / FL.pressure(1.0, merged["z_surface_m"], H) + wet = np.sqrt(sea_p / pl["sea_level_pressure_bar"]) # more CO₂ per breath: less water lost per growth + hz, hr = EN.holdridge(merged["biotemp"], np.asarray(merged["P_ann"], dtype=np.float64) * wet, merged["T_min"]) + inside = np.logical_or.reduce([w > 0 for w in weights]) + merged["holdridge"] = np.where(inside, hz, merged["holdridge"]).astype(np.asarray(d["holdridge"]).dtype) + merged["hold_region"] = np.where(inside, hr, merged["hold_region"]).astype(np.asarray(d["hold_region"]).dtype) + merged["era_mask"] = mask + for k, a in d.items(): # guard: nothing outside the mask may differ + a, b = np.asarray(a), np.asarray(merged[k]) + if a.shape[:1] == (g.n,) and not _same(a[~mask], b[~mask]): + raise RuntimeError(f"era {name}: {k} changed outside the influence mask") + + rc = P.Ctx(ctx.root, ctx.cfg, ctx.tect, ctx.res, merged, g, out_dir=out, + preview_dir=root / "previews" / f"r{res}" / "eras" / name, low_memory=low_memory) + importlib.import_module("mapgen.render").run(rc) + meta = json.loads(meta_path.read_text()) + meta["era"] = {"name": name, "label": label, "events": events, "fingerprint": key, "base_key": base_key, + "steps": [[n, [e["name"] for e in own], yrs] for n, own, yrs in plan], + "mask_km": E["mask_km"], "mask_cells": int(mask.sum())} + meta_path.write_text(json.dumps(meta, indent=1)) + log(f"era {name}: {int(mask.sum())} of {g.n} cells inside the influence mask") + return out + + +def build_all(root: Path, res: int, log=print, base=None, low_memory: bool = False) -> list: + """Every configured era with events (the base era is the base build); era folders no longer configured go.""" + _, tect = C.load(Path(root)) + names = [n for n in (tect.get("eras") or {}).get("order", []) if C.era_events(tect, n)] + built = [build_era(root, res, n, log, base, low_memory) for n in names] + top = Path(root) / "out" / f"r{res}" / "eras" + for p in (top.iterdir() if top.is_dir() else []): + if p.is_dir() and p.name not in names: + shutil.rmtree(p, ignore_errors=True) + return built diff --git a/mapgen/erosion.py b/mapgen/erosion.py new file mode 100644 index 0000000..5384988 --- /dev/null +++ b/mapgen/erosion.py @@ -0,0 +1,54 @@ +"""Stage `erosion`: implicit stream-power incision (Braun & Willett 2013) + km-scale hillslope smoothing.""" +from __future__ import annotations + +import numpy as np + +from .config import params +from .crust import CRATON, LIP, OCEANIC, POST_OROGEN, PRE_OROGEN, RIFT, SCAR, VOLCANO +from .elevation import solve_sea_level_connected +from .graph import (accumulate, drop_smooth_cache, ocean_mask, priority_flood, receiver_levels, smooth_km, + steepest_receivers) + +DEFAULTS = {"min_sea_km2": 5.0e6, "steps": 12, "dt_myr": 5.0, "k": 0.02, "m": 0.5, "hillslope_km": 25.0, "sediment_fill": 0.15} +K_MULT = {OCEANIC: 0.0, CRATON: 1.5, PRE_OROGEN: 1.2, POST_OROGEN: 1.0, RIFT: 1.0, LIP: 0.6, SCAR: 0.9, VOLCANO: 0.8} + + +def erode(g, z, kmult, P): + z = np.asarray(z, dtype=np.float64).copy() + ar = np.arange(g.n) + for _ in range(int(P["steps"])): + ocean = ocean_mask(g, z, P["min_sea_km2"]) + zb = np.where(ocean, 0.0, z) # base level = sea level, not the seafloor + zf = priority_flood(g, zb, ocean) + zb = np.where(ocean, zb, zb + P["sediment_fill"] * (zf - zb)) # sediment infills closed basins + recv, _, dist = steepest_receivers(g, zf) + recv = np.where(ocean, ar, recv) + levels = receiver_levels(recv) + area = accumulate(recv, levels, g.area_km2) + F = P["k"] * kmult * P["dt_myr"] * area ** P["m"] / np.where(np.isfinite(dist), dist, 1.0) + F[ocean] = 0.0 + znew = zb.copy() + for lv in levels[1:]: + r = recv[lv] + znew[lv] = np.minimum(zb[lv], (zb[lv] + F[lv] * znew[r]) / (1.0 + F[lv])) + znew = smooth_km(g, znew, P["hillslope_km"], keep=True) # hillslope/sub-grid smoothing, fixed length in km + z = np.where(ocean, z, znew) + drop_smooth_cache(g) + return z + + +def run(ctx) -> dict: + g = ctx.grid + P = params(ctx.cfg, "erosion", DEFAULTS) + z0, age = ctx.need("elevation_m", "age_class") + kmult = np.vectorize(K_MULT.get)(age).astype(np.float64) + z0 = np.asarray(z0, dtype=np.float64) + added = np.asarray(ctx.data.get("land_added", np.zeros(g.n, bool)), bool) # land the sea-level solve didn't + target = ctx.cfg["build"]["land_fraction"] + g.area_km2[added & (z0 > 0)].sum() / g.area_km2.sum() # see + z = erode(g, z0, kmult, P) + z = solve_sea_level_connected(g, z, target, P["min_sea_km2"]) + z = np.maximum(z, -11000.0) + # the sea is the connected world ocean; a separate basin ≥ min_sea_km2 shaped the coasts above as sea and stays + # open water for the climate (open_water), but its water is a lake (hydrology), not sea + return {"elevation_eroded_m": z.astype(np.float32), "ocean": ocean_mask(g, z, np.inf), + "open_water": ocean_mask(g, z, P["min_sea_km2"])} diff --git a/mapgen/events.py b/mapgen/events.py new file mode 100644 index 0000000..cba874e --- /dev/null +++ b/mapgen/events.py @@ -0,0 +1,87 @@ +"""Era events: pure functions of positions and the event's config, so the era +builder (world cells) and the refinement (fine cells) apply the same event.""" +from __future__ import annotations + +import numpy as np +from scipy.spatial import cKDTree + +from .fields import pressure +from .graph import components +from .noise import fbm, name_seed +from .pipeline import StageError +from .sphere import gc_dist_km, latlon_to_xyz +from .zones import smootherstep + + +def disintegrate(xyz, z, land, ev: dict, radius_km: float): + """(new heights, z_ref): inside radius_km of the centre min(old, z_ref − depth · sqrt(1 − (d/r)²)); z_ref = mean + ground height of the land in the ring 0.9–1.0 r before the event, None when no land lies in that ring (the bowl + then hangs from 0 m). Material vanishes: no ejecta, no rim.""" + z = np.asarray(z, dtype=np.float64) + d = gc_dist_km(np.asarray(xyz, dtype=np.float64), latlon_to_xyz(*ev["center"]), radius_km) + r = float(ev["radius_km"]) + ring = np.asarray(land, bool) & (d >= 0.9 * r) & (d <= r) + z_ref = float(np.mean(z[ring])) if ring.any() else None # None: no land on its rim (the bowl hangs from 0 m) + bowl = (0.0 if z_ref is None else z_ref) - ev["depth_m"] * np.sqrt(np.clip(1.0 - (d / r) ** 2, 0.0, 1.0)) + return np.where(d < r, np.minimum(z, bowl), z), z_ref + + +def volcano(xyz, z, ev: dict, radius_km: float): + """Heights after a volcano (only raises): cone, shield or caldera; peak_m above the old ground (peak_mode "above") + or as an absolute height ("absolute").""" + z = np.asarray(z, dtype=np.float64) + d = gc_dist_km(np.asarray(xyz, dtype=np.float64), latlon_to_xyz(*ev["center"]), radius_km) + x = np.clip(d / ev["radius_km"], 0.0, 1.0) + prof = {"cone": (1.0 - x) ** 1.5, "shield": (1.0 - x * x) ** 2, + "caldera": np.maximum((1.0 - x) ** 1.5 - 0.6 * np.exp(-(x / 0.15) ** 2), 0.0)}[ev.get("shape", "cone")] + peak = float(ev["peak_m"]) + new = z + peak * prof if ev.get("peak_mode", "above") == "above" else z + np.maximum(peak - z, 0.0) * prof + return np.maximum(z, new) + + +def landmass(g, land, seed_latlon, event_name: str): + """The connected land (grid cells) containing the seed point.""" + lab = components(g, land) + i = g.cell_index(*seed_latlon) + if lab[i] < 0: + land = np.flatnonzero(np.asarray(land, bool)) + hint = "" + if len(land): + j = land[np.argmax(g.xyz[land] @ g.xyz[i])] + hint = f"; the nearest land is at [{g.lat[j]:.1f}, {g.lon[j]:.1f}]" + raise StageError(f"event {event_name}: its seed {list(seed_latlon)} lies in the sea in this era{hint} " + f"(move the seed in config/eras.toml)") + return lab == lab[i] + + +def zone_weight(xyz, ev: dict, radius_km: float, seed: int, land_xyz=None): + """0–1 per point: 1 inside, 0 outside; a linear ramp edge_km wide at the edge (sharp), or the smooth profile. + circle: centre + radius_km. landmass: within reach of the landmass cells land_xyz (unit vectors), the reach + varying between reach_km by low-frequency noise.""" + xyz = np.asarray(xyz, dtype=np.float64) + if ev["shape"] == "circle": + d = gc_dist_km(xyz, latlon_to_xyz(*ev["center"]), radius_km) + reach = np.full(len(xyz), float(ev["radius_km"])) + else: + chord, _ = cKDTree(np.asarray(land_xyz, dtype=np.float64)).query(xyz) + d = 2.0 * np.arcsin(np.clip(chord / 2.0, 0.0, 1.0)) * radius_km + lo, hi = ev["reach_km"] + reach = lo + (hi - lo) * np.clip(0.5 + 1.5 * fbm(xyz, name_seed(seed, ev["name"]), 3, 1.5), 0.0, 1.0) + if "edge_km" in ev: + return np.clip((reach - d) / ev["edge_km"], 0.0, 1.0) + return 1.0 - smootherstep(d / reach) + + +def apply_zone(fields: dict, w, ev: dict, z_surface_m, scale_height_m: float) -> dict: + """Fields inside a zone event: absolute values blended by w (gravity_g, o2_fraction, fire_reactivity; pressure_bar + is the sea-level value, falling with height as elsewhere); po2_bar follows. Never height. Cells with w = 0 keep + their values exactly.""" + w = np.asarray(w, dtype=np.float64) + decay = pressure(1.0, z_surface_m, scale_height_m) + new = {} + for k, v in ev["fields"].items(): + base = np.asarray(fields[k], dtype=np.float64) + new[k] = (base / decay * (1.0 - w) + v * w) * decay if k == "pressure_bar" else base * (1.0 - w) + v * w + o2 = new.get("o2_fraction", np.asarray(fields["o2_fraction"], dtype=np.float64)) + new["po2_bar"] = o2 * new.get("pressure_bar", np.asarray(fields["pressure_bar"], dtype=np.float64)) + return {k: np.where(w > 0, v, fields[k]).astype(np.asarray(fields[k]).dtype) for k, v in new.items()} diff --git a/mapgen/fields.py b/mapgen/fields.py new file mode 100644 index 0000000..bf83d3f --- /dev/null +++ b/mapgen/fields.py @@ -0,0 +1,36 @@ +"""Stage `fields` and shared zone modifiers.""" +from __future__ import annotations + +import numpy as np + +from .config import MASK_DEFAULTS, params + + +def gravity_mod(cfg, m_gravity): + M = params(cfg, "masks", MASK_DEFAULTS) + return np.clip(1.0 + M["gravity_range"] * np.asarray(m_gravity, dtype=np.float64), 0.05, None) + + +def o2_mod(cfg, m_o2): + M = params(cfg, "masks", MASK_DEFAULTS) + return np.clip(1.0 + M["o2_range"] * np.asarray(m_o2, dtype=np.float64), 0.0, None) + + +def pressure(p0_bar, z_m, scale_height_m): + """Air pressure at ground height z (bar): sea-level pressure p0 falling with height (below 0 m: p0).""" + return p0_bar * np.exp(-np.maximum(np.asarray(z_m, dtype=np.float64), 0.0) / scale_height_m) + + +def plant_height(g_planet, gravity_g): + """How much taller plants grow than under the planet's normal gravity (height limit ∝ 1 / g).""" + return g_planet / np.asarray(gravity_g, dtype=np.float64) + + +def run(ctx) -> dict: + pl = ctx.cfg["planet"] + zs, m_o2, m_g = ctx.need("z_surface_m", "m_o2_zones", "m_gravity_zones") + p = pressure(pl["sea_level_pressure_bar"], zs, pl["scale_height_km"] * 1000.0) + o2 = pl["o2_fraction"] * o2_mod(ctx.cfg, m_o2) + return {"po2_bar": o2 * p, "gravity_g": pl["gravity_g"] * gravity_mod(ctx.cfg, m_g), "pressure_bar": p, + "o2_fraction": o2, "fire_reactivity": np.ones(len(p)), # zone events (eras) set other values + "plant_height_x": plant_height(pl["gravity_g"], pl["gravity_g"] * gravity_mod(ctx.cfg, m_g))} diff --git a/mapgen/geo.py b/mapgen/geo.py new file mode 100644 index 0000000..152ae66 --- /dev/null +++ b/mapgen/geo.py @@ -0,0 +1,200 @@ +"""GeoJSON exports: rivers, coastlines/lakes (marching squares), plate boundaries.""" +from __future__ import annotations + +import json +from pathlib import Path + +import h3.api.basic_int as h3 +import numpy as np + +BOUNDARY_NAMES = {1: "convergent", 2: "divergent", 3: "transform"} +# marching-squares cases; corner bits TL=8 TR=4 BR=2 BL=1; edges T R B L +_CASES = {1: [("L", "B")], 2: [("B", "R")], 3: [("L", "R")], 4: [("T", "R")], 5: [("T", "R"), ("L", "B")], + 6: [("T", "B")], 7: [("L", "T")], 8: [("L", "T")], 9: [("T", "B")], 10: [("L", "T"), ("B", "R")], + 11: [("T", "R")], 12: [("L", "R")], 13: [("B", "R")], 14: [("L", "B")]} +_EDGE = {"T": (1, 0), "R": (2, 1), "B": (1, 2), "L": (0, 1)} # doubled (dx, dy) inside a 2x2 block + + +def split_antimeridian(coords): + parts, cur = [], [list(coords[0])] + for a, b in zip(coords, coords[1:]): + if abs(b[0] - a[0]) > 180.0: + parts.append(cur) + cur = [] + cur.append(list(b)) + parts.append(cur) + return [p for p in parts if len(p) >= 2] + + +def _feature(geom_type, coords, props): + return {"type": "Feature", "geometry": {"type": geom_type, "coordinates": coords}, "properties": props} + + +def river_lines(g, recv, river, strahler, discharge): + """One LineString per run of equal Strahler order (runs share their junction vertex).""" + n = g.n + ar = np.arange(n) + has_donor = np.zeros(n, bool) + nr = river & (recv != ar) + has_donor[recv[nr]] = True + visited = np.zeros(n, bool) + feats = [] + for s in np.flatnonzero(river & ~has_donor): + cells, c = [int(s)], int(s) + while True: + visited[c] = True + r = int(recv[c]) + if r == c: + break + cells.append(r) + if not river[r] or visited[r]: + break + c = r + body = cells[:-1] if len(cells) > 1 else cells + start = 0 + for i in range(1, len(body) + 1): + if i == len(body) or strahler[body[i]] != strahler[body[i - 1]]: + run = body[start:i] + [cells[i] if i < len(cells) else body[-1]] + coords = [[round(float(g.lon[k]), 4), round(float(g.lat[k]), 4)] for k in run] + props = {"order": int(strahler[body[start]]), "discharge_km3_yr": round(float(discharge[body[i - 1]]), 3)} + feats += [_feature("LineString", p, props) for p in split_antimeridian(coords)] + start = i + return feats + + +def boundary_features(g, plate, bnd_type): + s, d = g.src, g.dst + e = np.flatnonzero((plate[s] != plate[d]) & (s < d)) + lines = {} + for k in e: + a, b = int(g.ids[s[k]]), int(g.ids[d[k]]) + seg = [[round(lng, 4), round(lat, 4)] for lat, lng in h3.directed_edge_to_boundary(h3.cells_to_directed_edge(a, b))] + if abs(seg[0][0] - seg[-1][0]) > 180: + continue + lines.setdefault(BOUNDARY_NAMES.get(int(bnd_type[s[k]]), "transform"), []).append(seg) + return [_feature("MultiLineString", v, {"type": t}) for t, v in sorted(lines.items())] + + +def contours(mask): + m = np.pad(np.asarray(mask, dtype=np.uint8), 1) + code = m[:-1, :-1] * 8 + m[:-1, 1:] * 4 + m[1:, 1:] * 2 + m[1:, :-1] + adj = {} + for case, segs in _CASES.items(): + ys, xs = np.nonzero(code == case) + for (e1, e2) in segs: + for y, x in zip(ys.tolist(), xs.tolist()): + p = (2 * x + _EDGE[e1][0], 2 * y + _EDGE[e1][1]) + q = (2 * x + _EDGE[e2][0], 2 * y + _EDGE[e2][1]) + adj.setdefault(p, []).append(q) + adj.setdefault(q, []).append(p) + seen, rings = set(), [] + for start in adj: + if start in seen: + continue + ring, prev, cur = [start], None, start + seen.add(start) + while True: + nxt = [q for q in adj[cur] if q != prev] + if not nxt: + break + prev, cur = cur, nxt[0] + ring.append(cur) + if cur == start: + break + seen.add(cur) + rings.append([(px / 2.0 - 1.0, py / 2.0 - 1.0) for px, py in ring]) + return rings + + +def _signed_area(ring): + a = np.asarray(ring, dtype=np.float64) + return 0.5 * float(np.sum(a[:-1, 0] * a[1:, 1] - a[1:, 0] * a[:-1, 1])) + + +def _point_in_ring(pt, ring): + a = np.asarray(ring, dtype=np.float64) + x1, y1, x2, y2 = a[:-1, 0], a[:-1, 1], a[1:, 0], a[1:, 1] + cross = (y1 > pt[1]) != (y2 > pt[1]) + xi = x1 + (pt[1] - y1) * (x2 - x1) / np.where(y2 != y1, y2 - y1, 1e-300) + return bool(np.count_nonzero(cross & (pt[0] < xi)) % 2) + + +def _orient(ring, ccw): + return ring if (_signed_area(ring) > 0) == ccw else ring[::-1] + + +def _clip(ring, left, x0=180.0): + """Sutherland–Hodgman clip of a closed ring to x ≤ x0 (left) or x ≥ x0.""" + inside = (lambda p: p[0] <= x0) if left else (lambda p: p[0] >= x0) + cut = lambda p, q: [x0, p[1] + (x0 - p[0]) / (q[0] - p[0]) * (q[1] - p[1])] + pts, out = ring[:-1], [] + for i in range(len(pts)): + cur, prev = pts[i], pts[i - 1] + if inside(cur): + if not inside(prev): + out.append(cut(prev, cur)) + out.append(cur) + elif inside(prev): + out.append(cut(prev, cur)) + return out + [out[0]] if len(out) >= 3 else [] + + +def _split_seam(rings): + """rings[0] exterior + holes in continuous longitude; split at +180 into ≤2 polygons in [−180, 180].""" + if max(p[0] for p in rings[0]) <= 180.0: + return [rings] + polys = [] + for left in (True, False): + ext = _clip(rings[0], left) + if len(ext) < 4: + continue + holes = [h for h in (_clip(r, left) for r in rings[1:]) if len(h) >= 4] + part = [ext] + holes + if not left: + part = [[[x - 360.0, y] for x, y in r] for r in part] + polys.append(part) + return polys + + +def _round(poly): + return [[[round(x, 4), round(y, 4)] for x, y in r] for r in poly] + + +def contour_features(mask, kind): + """Land/lake outlines as RFC 7946 (Multi)Polygons: CCW exteriors, CW holes, split at the antimeridian.""" + H, W = mask.shape + col = int(np.argmin(mask.sum(axis=0))) + rings = [] + for ring in contours(np.roll(mask, -col, axis=1)): + pts = [[(x + col + 0.5) / W * 360.0 - 180.0, max(-90.0, min(90.0, 90.0 - (y + 0.5) / H * 180.0))] + for x, y in ring] + if len(pts) >= 4 and pts[0] == pts[-1]: + rings.append(pts) + depth = [sum(_point_in_ring(r[0], o) for j, o in enumerate(rings) if j != i) for i, r in enumerate(rings)] + feats = [] + for i, r in enumerate(rings): + if depth[i] % 2: + continue + holes = [_orient(rings[j], False) for j in range(len(rings)) + if depth[j] == depth[i] + 1 and _point_in_ring(rings[j][0], r)] + polys = [_round(p) for p in _split_seam([_orient(r, True)] + holes)] + if len(polys) == 1: + feats.append(_feature("Polygon", polys[0], {"kind": kind})) + elif polys: + feats.append(_feature("MultiPolygon", polys, {"kind": kind})) + return feats + + +def _write(path: Path, feats) -> None: + path.write_text(json.dumps({"type": "FeatureCollection", "features": feats}, separators=(",", ":"))) + + +def write_all(ctx, out_dir: Path, land_raster, lake_raster) -> None: + g, d = ctx.grid, ctx.data + gdir = out_dir / "geo" + gdir.mkdir(parents=True, exist_ok=True) + _write(gdir / "rivers.geojson", river_lines(g, d["recv"], d["river"], d["strahler"], d["discharge_km3_yr"])) + _write(gdir / "plate_boundaries.geojson", boundary_features(g, d["plate"], d["bnd_type"])) + step = max(1, land_raster.shape[1] // 2048) + _write(gdir / "coast.geojson", contour_features(land_raster[::step, ::step], "land")) + _write(gdir / "lakes.geojson", contour_features(lake_raster[::step, ::step] & land_raster[::step, ::step], "lake")) diff --git a/mapgen/graph.py b/mapgen/graph.py new file mode 100644 index 0000000..126c71f --- /dev/null +++ b/mapgen/graph.py @@ -0,0 +1,477 @@ +"""Operations on the H3 cell graph (CSR neighbours).""" +from __future__ import annotations + +import heapq + +import numpy as np +from scipy import sparse +from scipy.sparse import csgraph +from scipy.sparse import linalg as splinalg + + +def nbr_mean(g, f): + return np.bincount(g.src, weights=f[g.dst], minlength=g.n) / g.counts + + +def nbr_max(g, f): + out = np.asarray(f, dtype=np.float64).copy() + np.maximum.at(out, g.src, f[g.dst]) + return out + + +def nbr_min(g, f): + out = np.asarray(f, dtype=np.float64).copy() + np.minimum.at(out, g.src, f[g.dst]) + return out + + +def diffuse(g, f, iters: int, alpha: float = 0.5, mask=None): + f = np.asarray(f, dtype=np.float64) + for _ in range(int(iters)): + new = (1.0 - alpha) * f + alpha * nbr_mean(g, f) + f = new if mask is None else np.where(mask, new, f) + return f + + +def laplacian_matrix(g): + """Graph Laplacian (per km²): Δf_i ≈ (4/k_i) Σ_j (f_j − f_i)/d_ij² (exact for a regular hex lattice).""" + w = 4.0 / (g.counts[g.src] * g.edge_km**2) + lap = sparse.csr_matrix((w, (g.src, g.dst)), shape=(g.n, g.n)) + return lap - sparse.diags(np.asarray(lap.sum(axis=1)).ravel()) + + +def workers() -> int: + """Threads for independent jobs (seasons, components): WORLDGEN_THREADS, default 3. Each job computes exactly + what it would alone, so results never depend on it; numpy and scipy's sparse kernels release the GIL.""" + import os + try: + return max(1, int(os.environ.get("WORLDGEN_THREADS", "3"))) + except ValueError: + return 3 + + +def pmap(fn, items) -> list: + """[fn(x) for x in items] on up to workers() threads, in order.""" + items = list(items) + if workers() <= 1 or len(items) <= 1: + return [fn(x) for x in items] + from concurrent.futures import ThreadPoolExecutor + with ThreadPoolExecutor(min(workers(), len(items))) as ex: + return list(ex.map(fn, items)) + + +def smooth_km(g, f, length_km: float, rtol: float = 1e-6, keep: bool = False): + """Resolution-independent smoothing: solve (I − L²Δ) s = f (screened Poisson, decay length ≈ L km). + keep: keep the matrix on g for the next call (loops over one grid; drop_smooth_cache(g) frees it).""" + f = np.asarray(f, dtype=np.float64) + scale = float(np.max(np.abs(f))) if f.size else 0.0 + if length_km <= 0 or scale == 0.0: + return f.copy() + f = f / scale # linear system: solve at unit scale (avoids breakdown) + cache = g.__dict__.get("_screened", {}) + if length_km in cache: + A, inv_diag = cache[length_km] + else: + A = (sparse.identity(g.n, format="csr") - length_km**2 * laplacian_matrix(g)).tocsr() + inv_diag = 1.0 / A.diagonal() + if keep: + g.__dict__.setdefault("_screened", {})[length_km] = (A, inv_diag) + s, info = bicgstab_jacobi(A, f, f.copy(), inv_diag, rtol, 5000) + if info != 0: + raise ValueError(f"smooth_km: solver did not converge (info={info})") + return s * scale + + +def _make_bicg_jit(): + """bicgstab's vector updates fused into single passes (numba optional; WORLDGEN_NO_JIT=1 turns it off). Each + element gets the same operations in the same order as scipy's numpy statements; dot products, norms and sparse + products stay the very calls scipy makes, so the iterates — and the answer — are the same floats. (Threads were + tried and dropped: on a busy machine they wait more than they work.)""" + import os + if os.environ.get("WORLDGEN_NO_JIT"): + return None + try: + import numba + except ImportError: + return None + + @numba.njit(cache=True, nogil=True) + def p_update(p, v, r, omega, beta): # p -= omega*v; p *= beta; p += r + for i in range(len(p)): + p[i] = (p[i] - omega * v[i]) * beta + r[i] + + @numba.njit(cache=True, nogil=True) + def scale(out, d, x): # out = d * x (the Jacobi preconditioner) + for i in range(len(x)): + out[i] = d[i] * x[i] + + @numba.njit(cache=True, nogil=True) + def axpy_neg(r, a, v): # r -= a*v + for i in range(len(r)): + r[i] = r[i] - a * v[i] + + @numba.njit(cache=True, nogil=True) + def x_update(x, alpha, phat, omega, shat): # x += alpha*phat; x += omega*shat + for i in range(len(x)): + x[i] = (x[i] + alpha * phat[i]) + omega * shat[i] + + @numba.njit(cache=True, nogil=True) + def axpy(x, a, v): # x += a*v + for i in range(len(x)): + x[i] = x[i] + a * v[i] + + return p_update, scale, axpy_neg, x_update, axpy + + +_bicg_jit = _make_bicg_jit() + + +def _csr_matvec_into(A): + """mv(x, y): y = A @ x, by scipy's own kernel into a reused buffer (A @ x zero-fills a fresh array and calls the + same csr_matvec; a fresh 16 MB array costs more in page faults than the product).""" + try: + from scipy.sparse import _sparsetools + fn = _sparsetools.csr_matvec + except (ImportError, AttributeError): + fn = None + M, N = A.shape + + def mv(x, y): + if fn is None: + y[:] = A @ x + return + y.fill(0.0) + fn(M, N, A.indptr, A.indices, A.data, x, y) + return mv + + +JIT_MIN_N = 50_000 # smaller systems: scipy (thread start-up outweighs the gain; same answer either way) + + +def bicgstab_jacobi(A, b, x0, inv_diag, rtol, maxiter): + """scipy.sparse.linalg.bicgstab(A, b, x0=x0, rtol=rtol, maxiter=maxiter, M=diag(inv_diag)) for a CSR matrix and + float64 vectors: the same iterates, statement by statement (scipy 1.12+'s pure-Python loop), with the vector + updates fused and everything written into preallocated buffers (memory-bound; fresh 16 MB temporaries cost + more in page faults than in arithmetic). Falls back to scipy without numba.""" + b = np.asarray(b, dtype=np.float64).ravel() + if (_bicg_jit is None or A.shape[0] < JIT_MIN_N or not sparse.isspmatrix_csr(A) + and not isinstance(A, sparse.csr_array) or A.dtype != np.float64): + M = splinalg.LinearOperator(A.shape, matvec=lambda x: inv_diag * x) + return splinalg.bicgstab(A, b, x0=x0, rtol=rtol, maxiter=maxiter, M=M) + p_update, scale, axpy_neg, x_update, axpy = _bicg_jit + mv = _csr_matvec_into(A) + inv_diag = np.ascontiguousarray(inv_diag, dtype=np.float64) + x = np.array(x0, dtype=np.float64) + bnrm2 = np.linalg.norm(b) + atol = max(0.0, float(rtol) * float(bnrm2)) + if bnrm2 == 0: + return b, 0 + rhotol = np.finfo(x.dtype.char).eps ** 2 + omegatol = rhotol + rho_prev, omega, alpha, p, v = None, None, None, None, None + r = b - A @ x if x.any() else b.copy() + rtilde = r.copy() + phat, shat, v, t = np.empty_like(r), np.empty_like(r), np.empty_like(r), np.empty_like(r) + for iteration in range(maxiter): + if np.linalg.norm(r) < atol: + return x, 0 + rho = np.dot(rtilde, r) + if np.abs(rho) < rhotol: + return x, -10 + if iteration > 0: + if np.abs(omega) < omegatol: + return x, -11 + beta = (rho / rho_prev) * (alpha / omega) + p_update(p, v, r, omega, beta) + else: + p = r.copy() + scale(phat, inv_diag, p) + mv(phat, v) + rv = np.dot(rtilde, v) + if rv == 0: + return x, -11 + alpha = rho / rv + axpy_neg(r, alpha, v) + s = r # scipy copies r into s here and reads both unchanged until r -= omega*t + if np.linalg.norm(s) < atol: + axpy(x, alpha, phat) + return x, 0 + scale(shat, inv_diag, s) + mv(shat, t) + omega = np.dot(t, s) / np.dot(t, t) + x_update(x, alpha, phat, omega, shat) + axpy_neg(r, omega, t) + rho_prev = rho + return x, maxiter + + +def drop_smooth_cache(g) -> None: + """Free the matrices smooth_km keeps on g.""" + g.__dict__.pop("_screened", None) + + +def gradient(g, f): + """Tangent-plane gradient (f per km): (2/k) Σ_j (f_j − f_i)/d_ij · t_ij.""" + w = (f[g.dst] - f[g.src]) / g.edge_km + s = np.stack([np.bincount(g.src, weights=w * g.edge_tangents[:, c], minlength=g.n) for c in range(3)], axis=1) + return s * (2.0 / g.counts)[:, None] + + +def _csr(g, weights): + return sparse.csr_matrix((weights, (g.src, g.dst)), shape=(g.n, g.n)) + + +def nearest_source(g, sources, weights=None): + sources = np.asarray(sources, dtype=np.int64) + if len(sources) == 0: + return np.full(g.n, np.inf), np.full(g.n, -9999, dtype=np.int64) + w = g.edge_km if weights is None else weights + dist, _, src = csgraph.dijkstra(_csr(g, w), directed=True, indices=sources, + min_only=True, return_predecessors=True) + return dist, src.astype(np.int64) + + +def distance_to(g, mask): + return nearest_source(g, np.flatnonzero(mask))[0] + + +def priority_flood(g, z, sink_mask, eps: float = 0.01): + """Barnes (2014) priority-flood + ε: every non-sink cell gets a strictly descending path to a sink.""" + sink_mask = np.asarray(sink_mask, dtype=bool) + if not sink_mask.any(): + raise ValueError("priority_flood: no sink cells") + has_open = np.bincount(g.src, weights=(~sink_mask)[g.dst].astype(np.float64), minlength=g.n) > 0 + seeds = np.flatnonzero(sink_mask & has_open) + z = np.asarray(z, dtype=np.float64) + if _flood_jit is not None: + return _flood_jit(z.copy(), sink_mask.copy(), np.asarray(g.nbr_ptr, np.int64), np.asarray(g.nbr_idx, np.int64), + seeds.astype(np.int64), float(eps)) + return _flood_py(z, sink_mask, g.nbr_ptr, g.nbr_idx, seeds, eps) + + +def _flood_py(z, sink_mask, nbr_ptr, nbr_idx, seeds, eps): + zf = np.asarray(z, dtype=np.float64).tolist() + done = np.asarray(sink_mask).tolist() + ptr = np.asarray(nbr_ptr).tolist() + idx = np.asarray(nbr_idx).tolist() + heap = [(zf[i], i) for i in np.asarray(seeds).tolist()] + heapq.heapify(heap) + while heap: + zc, c = heapq.heappop(heap) + for k in range(ptr[c], ptr[c + 1]): + n = idx[k] + if not done[n]: + done[n] = True + if zf[n] < zc + eps: + zf[n] = zc + eps + heapq.heappush(heap, (zf[n], n)) + return np.array(zf) + + +def _make_flood_jit(): + """_flood_py compiled with numba when it is installed (optional: same heap order, same float steps, same result; + WORLDGEN_NO_JIT=1 turns it off).""" + import os + if os.environ.get("WORLDGEN_NO_JIT"): + return None + try: + import numba + except ImportError: + return None + + @numba.njit(cache=True) + def flood(zf, done, ptr, idx, seeds, eps): + heap = [(zf[i], i) for i in seeds] + heapq.heapify(heap) + while len(heap): + zc, c = heapq.heappop(heap) + for k in range(ptr[c], ptr[c + 1]): + n = idx[k] + if not done[n]: + done[n] = True + if zf[n] < zc + eps: + zf[n] = zc + eps + heapq.heappush(heap, (zf[n], n)) + return zf + return flood + + +_flood_jit = _make_flood_jit() + + +def _make_steep_jit(): + """steepest_receivers' per-row maximum, compiled (numba optional, as the flood): the first edge in row order + with the largest slope, slopes computed as numpy does. ok=False (empty row, NaN): use the numpy path.""" + import os + if os.environ.get("WORLDGEN_NO_JIT"): + return None + try: + import numba + except ImportError: + return None + + @numba.njit(cache=True) + def steep(z, ptr, dst, edge): + n = len(ptr) - 1 + first = np.empty(n, np.int64) + best = np.empty(n) + for i in range(n): + a, b = ptr[i], ptr[i + 1] + if a == b: + return first, best, False + bk = a + bs = (z[i] - z[dst[a]]) / edge[a] + if bs != bs: + return first, best, False + for k in range(a + 1, b): + sk = (z[i] - z[dst[k]]) / edge[k] + if sk != sk: + return first, best, False + if sk > bs: + bs, bk = sk, k + first[i], best[i] = bk, bs + return first, best, True + return steep + + +_steep_jit = _make_steep_jit() + + +def steepest_receivers(g, z): + """Per cell: the neighbour of steepest descent (first in neighbour order on ties), the slope and the edge length; + no way down → itself, 0, inf.""" + ptr = np.asarray(g.nbr_ptr, np.int64) + if _steep_jit is not None and g.n: + first, s, ok = _steep_jit(np.asarray(z), ptr, np.asarray(g.nbr_idx, np.int64), g.edge_km) + if ok: + down = s > 0 + recv = np.where(down, g.dst[first], np.arange(g.n)) + return recv, np.where(down, s, 0.0), np.where(down, g.edge_km[first], np.inf) + slope = (z[g.src] - z[g.dst]) / g.edge_km + if g.n == 0 or not (np.diff(ptr) > 0).all() or np.isnan(slope).any(): + order = np.lexsort((-slope, g.src)) # general case (empty rows, NaN) + first = order[ptr[:-1]] + else: # same edge as the stable lexsort, without sorting + smax = np.maximum.reduceat(slope, ptr[:-1]) + cand = np.flatnonzero(slope == smax[g.src]) + rows = g.src[cand] + first = cand[np.concatenate([[True], rows[1:] != rows[:-1]])] + s = slope[first] + down = s > 0 + ar = np.arange(g.n) + recv = np.where(down, g.dst[first], ar) + return recv, np.where(down, s, 0.0), np.where(down, g.edge_km[first], np.inf) + + +def _gather(ptr, arr, sel): + counts = ptr[sel + 1] - ptr[sel] + tot = int(counts.sum()) + if tot == 0: + return arr[:0] + starts = np.repeat(ptr[sel] - np.concatenate([[0], np.cumsum(counts)[:-1]]), counts) + return arr[starts + np.arange(tot)] + + +def receiver_levels(recv): + recv = np.asarray(recv, dtype=np.int64) + n = len(recv) + ar = np.arange(n) + root = recv == ar + donors = ar[~root] + donors = donors[np.argsort(recv[donors], kind="stable")] + dptr = np.concatenate([[0], np.cumsum(np.bincount(recv[donors], minlength=n))]) + levels, frontier, seen = [], ar[root], 0 + while len(frontier): + levels.append(frontier) + seen += len(frontier) + frontier = _gather(dptr, donors, frontier) + if seen != n: + raise ValueError("receiver_levels: cycle in receivers") + return levels + + +def accumulate(recv, levels, w): + """Sum w down the receiver tree. Per level only the receivers are touched (a full bincount per level costs + levels × cells); the sums are added in donor order from 0, as bincount does: the same floats.""" + acc = np.asarray(w, dtype=np.float64).copy() + buf = np.zeros(len(acc)) + if len(levels) > 1: + acc += 0.0 # as the first full-length add did: −0 becomes +0 + for lv in reversed(levels[1:]): + r = recv[lv] + buf[r] = 0.0 + np.add.at(buf, r, acc[lv]) + acc[r] = acc[r] + buf[r] + return acc + + +def _make_components_jit(): + """Connected-component labels as scipy's connected_components numbers them — each component (every node not in + the mask is one by itself) by the order of its lowest node — by union-find over the edges, with no sparse matrix + (numba optional; WORLDGEN_NO_JIT=1 turns it off).""" + import os + if os.environ.get("WORLDGEN_NO_JIT"): + return None + try: + import numba + except ImportError: + return None + + @numba.njit(cache=True, nogil=True) + def labels(n, src, dst, mask): + parent = np.arange(n) + for e in range(src.shape[0]): + a, b = src[e], dst[e] + if mask[a] and mask[b]: + while parent[a] != a: + parent[a] = parent[parent[a]] + a = parent[a] + while parent[b] != b: + parent[b] = parent[parent[b]] + b = parent[b] + if a != b: + if a < b: + parent[b] = a + else: + parent[a] = b + root_lab = np.full(n, -1, np.int32) + out = np.empty(n, np.int32) + count = 0 + for v in range(n): + r = v + while parent[r] != r: + r = parent[r] + if root_lab[r] < 0: + root_lab[r] = count + count += 1 + out[v] = root_lab[r] if mask[v] else -1 + return out + return labels + + +_components_jit = _make_components_jit() + + +def components(g, mask): + mask = np.asarray(mask, dtype=bool) + if _components_jit is not None: + return _components_jit(g.n, g.src, g.dst, mask) + e = mask[g.src] & mask[g.dst] + m = sparse.csr_matrix((np.ones(int(e.sum())), (g.src[e], g.dst[e])), shape=(g.n, g.n)) + _, lab = csgraph.connected_components(m, directed=False) + return np.where(mask, lab, -1) + + +OCEAN_MIN_KM2 = 5.0e6 + + +def ocean_mask(g, z, min_area_km2: float = OCEAN_MIN_KM2): + """The connected world ocean plus any separate basin ≥ min_area_km2; smaller interior lows count as land.""" + wet = np.asarray(z) <= 0 + if not wet.any(): + return wet + lab = components(g, wet) + area = np.bincount(lab[wet], weights=g.area_km2[wet]) + keep = area >= min_area_km2 + keep[np.argmax(area)] = True + return wet & keep[np.maximum(lab, 0)] diff --git a/mapgen/grid.py b/mapgen/grid.py new file mode 100644 index 0000000..8138b5f --- /dev/null +++ b/mapgen/grid.py @@ -0,0 +1,121 @@ +"""H3 hexagonal grid as a CSR cell graph. Stage `grid`.""" +from __future__ import annotations + +from dataclasses import dataclass +from functools import cached_property + +import h3.api.basic_int as h3 +import numpy as np + +from .sphere import latlon_to_xyz, tangent_dir + + +EDGE_BLOCK = 1 << 20 + + +def edge_blocks(n: int, block: int | None = None): + """Slices over n edges, EDGE_BLOCK at a time: per-edge (row-wise) formulas give the same values block by block, + with (block, 3) float64 temporaries instead of (edges, 3) ones (290 MB each at r5, 2 GB at r6).""" + block = block or EDGE_BLOCK + return [slice(a, min(a + block, n)) for a in range(0, n, block)] + + +def rowdot_at(A, idx, t) -> np.ndarray: + """np.sum(A[idx] * t, axis=1) — per edge, A's row at an end dotted with the edge's vector — in edge blocks.""" + out = np.empty(len(idx)) + for s in edge_blocks(len(idx)): + out[s] = np.sum(A[idx[s]] * t[s], axis=1) + return out + + +def cell_parents(cells, res: int) -> np.ndarray: + """h3.cell_to_parent for an array of cells (all of resolution ≥ res), by the index bits: the resolution field + set to res, the digits below it set to 7 (unused). Same ids as h3, without a Python call per cell.""" + cells = np.asarray(cells, dtype=np.uint64) + if len(cells) and int(((cells >> np.uint64(52)) & np.uint64(15)).min()) < res: + raise ValueError(f"cell_parents: a cell is coarser than resolution {res}") + unused = 0 + for r in range(res + 1, 16): + unused |= 7 << ((15 - r) * 3) + out = (cells & np.uint64(~(15 << 52) & (2**64 - 1))) | np.uint64(res << 52) + return out | np.uint64(unused) + + +@dataclass +class Grid: + res: int + radius_km: float + ids: np.ndarray + lat: np.ndarray + lon: np.ndarray + xyz: np.ndarray + area_km2: np.ndarray + nbr_ptr: np.ndarray + nbr_idx: np.ndarray + + @property + def n(self) -> int: + return len(self.ids) + + @cached_property + def counts(self): + return np.diff(self.nbr_ptr) + + @cached_property + def src(self): + return np.repeat(np.arange(self.n), self.counts) + + @property + def dst(self): + return self.nbr_idx + + @cached_property + def edge_km(self): + out = np.empty(len(self.dst)) + for s in edge_blocks(len(self.dst)): + d = np.sum(self.xyz[self.src[s]] * self.xyz[self.dst[s]], axis=1) + out[s] = self.radius_km * np.arccos(np.clip(d, -1.0, 1.0)) + return out + + @cached_property + def edge_tangents(self): + out = np.empty((len(self.dst), 3)) + for s in edge_blocks(len(self.dst)): + out[s] = tangent_dir(self.xyz[self.src[s]], self.xyz[self.dst[s]]) + return out + + @cached_property + def spacing_km(self) -> float: + return float(self.edge_km.mean()) + + def cell_index(self, lat: float, lon: float) -> int: + c = np.uint64(h3.latlng_to_cell(float(lat), float(lon), self.res)) + return int(np.searchsorted(self.ids, c)) + + def to_arrays(self) -> dict: + return {"g_ids": self.ids, "g_lat": self.lat, "g_lon": self.lon, "g_xyz": self.xyz, + "g_area_km2": self.area_km2, "g_nbr_ptr": self.nbr_ptr, "g_nbr_idx": self.nbr_idx} + + @classmethod + def from_arrays(cls, d: dict, res: int, radius_km: float) -> "Grid": + return cls(res, radius_km, d["g_ids"], d["g_lat"], d["g_lon"], d["g_xyz"], d["g_area_km2"], + d["g_nbr_ptr"], d["g_nbr_idx"]) + + +def build_grid(res: int, radius_km: float) -> Grid: + cells: list[int] = [] + for r0 in h3.get_res0_cells(): + cells.extend(h3.cell_to_children(r0, res)) + ids = np.array(sorted(cells), dtype=np.uint64) + ll = np.array([h3.cell_to_latlng(int(c)) for c in ids], dtype=np.float64) + area = np.array([h3.cell_area(int(c), unit="rads^2") for c in ids]) * radius_km**2 + rings = [h3.grid_ring(int(c), 1) for c in ids] + counts = np.array([len(r) for r in rings], dtype=np.int64) + ptr = np.concatenate([[0], np.cumsum(counts)]).astype(np.int64) + flat = np.array([c for r in rings for c in r], dtype=np.uint64) + idx = np.searchsorted(ids, flat).astype(np.int64) + return Grid(res, radius_km, ids, ll[:, 0], ll[:, 1], latlon_to_xyz(ll[:, 0], ll[:, 1]), area, ptr, idx) + + +def run(ctx) -> dict: + return build_grid(ctx.res, float(ctx.cfg["planet"]["radius_km"])).to_arrays() diff --git a/mapgen/hydrology.py b/mapgen/hydrology.py new file mode 100644 index 0000000..d7adde1 --- /dev/null +++ b/mapgen/hydrology.py @@ -0,0 +1,231 @@ +"""Stage `hydrology`: depression filling, lakes vs endorheic basins, discharge, rivers, Strahler order.""" +from __future__ import annotations + +import numpy as np +from scipy import sparse +from scipy.sparse import csgraph + +from .config import params +from .crust import RIFT +from .graph import accumulate, components, distance_to, priority_flood, receiver_levels, steepest_receivers +from .noise import fbm +from .pipeline import StageError + +DEFAULTS = {"fill_eps_m": 0.01, "min_depth_m": 1.0, "river_min_km3_yr": 2.0, + "salt_flat_max_p_mm": 300.0, "salt_flat_fraction": 0.2, "dry_lake_fraction": 0.05, + # lake beds: deepest point = k × area^exp m (Earth-like: ≈95 m at + # 1,000 km², ≈230 m at 20,000 km², ≈610 m at 500,000 km²) × 0.5–2 (noise at the lake), × rift_x in + # rifts, × arid_x for dry terminal lakes; never shallower than lake_min_m (refinement keeps ≥ 15 m) + "lake_depth_k": 12.0, "lake_depth_exp": 0.3, "lake_min_m": 25.0, "lake_max_m": 1800.0, + "lake_rift_x": 2.5, "lake_arid_x": 0.4, "lake_arid_p_mm": 400.0, "lake_shore": 0.25} + + +def strahler(recv, levels, river): + n = len(recv) + order = np.zeros(n, np.int8) + mx = np.zeros(n, np.int8) + cnt = np.zeros(n, np.int16) + for lv in reversed(levels): + cells = lv[river[lv]] + if len(cells) == 0: + continue + order[cells] = np.where(cnt[cells] >= 2, mx[cells] + 1, np.maximum(mx[cells], 1)) + nonroot = cells[recv[cells] != cells] + r, o = recv[nonroot], order[nonroot] + np.maximum.at(mx, r, o) + np.add.at(cnt, r, (o == mx[r]).astype(np.int16)) + return order + + +def _groups(lab): + idx = np.argsort(lab, kind="stable") + ls = lab[idx] + start = np.searchsorted(ls, 0) + idx, ls = idx[start:], ls[start:] + cuts = np.flatnonzero(np.diff(ls)) + 1 + return np.split(idx, cuts) if len(idx) else [] + + +def _leaves(recv, lab, x, limit=100000): + """Does the flow from depression cell x leave its depression for good (not back in over shallow ground)?""" + own, y = lab[x], recv[x] + for _ in range(limit): + if lab[y] == own: + return False + if lab[y] >= 0 or recv[y] == y: + return True + y = recv[y] + return True + + +def _leaves_all(recv, lab, levels, limit=100000): + """_leaves for every cell at once: walking down from recv[x], the first cell that is in a depression or a root + (stop) and how many steps away it is; x leaves unless that cell is in x's own depression (or the walk is longer + than limit steps).""" + n = len(recv) + stop, d = np.arange(n), np.zeros(n, np.int64) + for lv in levels[1:]: # roots first: a cell's receiver is done before it + free = lv[lab[lv] < 0] + stop[free] = stop[recv[free]] + d[free] = d[recv[free]] + 1 + y = recv + return (d[y] >= limit) | (lab[stop[y]] != lab) + + +def _route_inside(g, dep, lab, recv, targets): + """Within each depression, point every cell along the shortest intra-depression path to its target cell.""" + e = dep[g.src] & dep[g.dst] & (lab[g.src] == lab[g.dst]) + m = sparse.csr_matrix((g.edge_km[e], (g.src[e], g.dst[e])), shape=(g.n, g.n)) + _, pred, _ = csgraph.dijkstra(m, directed=False, indices=targets, min_only=True, return_predecessors=True) + out = recv.copy() + inside = dep & (pred >= 0) + out[inside] = pred[inside] + return out + + +def _sweep(recv, levels, water, outlets, cap): + """Accumulate flow upstream→downstream; at each spilling-lake outlet remove up to `cap` (lake evaporation).""" + acc = np.asarray(water, dtype=np.float64).copy() + loss = np.zeros(len(acc)) + is_out = np.zeros(len(acc), bool) + is_out[outlets] = True + cap_cell = np.zeros(len(acc)) + cap_cell[outlets] = cap + buf = np.zeros(len(acc)) # per level only the receivers change (graph.accumulate): same floats + if len(levels) > 1: + acc += 0.0 + for lv in reversed(levels[1:]): + push = acc[lv].copy() + o = is_out[lv] + if o.any(): + cells = lv[o] + lost = np.minimum(acc[cells], cap_cell[cells]) + loss[cells] = lost + push[o] = acc[cells] - lost + r = recv[lv] + buf[r] = 0.0 + np.add.at(buf, r, push) + acc[r] = acc[r] + buf[r] + return acc, loss + + +def lake_levels(z, zf, lab, lake, lake_id, endo): + """Each lake cell's water level (NaN elsewhere): a spilling lake stands at its spill height; a terminal lake at the + lowest ground of its basin it does not cover (the next cell to flood). Carving the beds leaves both unchanged.""" + level = np.full(len(z), np.nan) + m = int(lab.max()) + 1 if len(lab) and lab.max() >= 0 else 0 + dry = np.full(m, np.inf) # per depression: its lowest uncovered ground + sel = (lab >= 0) & ~lake + np.minimum.at(dry, lab[sel], z[sel]) + for c in _groups(np.where(lake, lake_id, -1)): + k = lab[c[0]] + level[c] = dry[k] if endo[c[0]] and k >= 0 and np.isfinite(dry[k]) else zf[c].min() + return level + + +def lake_beds(g, z, level, lake, lake_id, endo, rift, p_ann, seed, P, only=None): + """Lake beds carved below their level (heights unchanged elsewhere): each lake's deepest point from its area + (P lake_*), noise keyed by where the lake lies (not its id), the depth rising from lake_shore × that at the shore + to all of it at the cell farthest from shore; never above the ground (min), never shallower than lake_min_m. + only: carve just the lakes touching these cells (eras: where events changed the ground).""" + z = np.asarray(z, dtype=np.float64).copy() + if not lake.any(): + return z + shore = distance_to(g, ~lake) if (~lake).any() else np.full(g.n, g.spacing_km) + for c in _groups(np.where(lake, lake_id, -1)): + if only is not None and not only[c].any(): + continue + area = g.area_km2[c].sum() + ctr = g.xyz[c].mean(0) + ctr = ctr / max(np.linalg.norm(ctr), 1e-12) + d = P["lake_depth_k"] * area ** P["lake_depth_exp"] * 2.0 ** float(fbm(ctr[None], seed + 4421, 3, 6.0)[0] * 1.4) + if rift[c].mean() > 0.3: + d *= P["lake_rift_x"] + if endo[c[0]] and p_ann[c].mean() < P["lake_arid_p_mm"]: + d *= P["lake_arid_x"] + d = float(np.clip(d, P["lake_min_m"], P["lake_max_m"])) + t = shore[c] / max(shore[c].max(), 1e-9) + prof = np.maximum(d * (P["lake_shore"] + (1 - P["lake_shore"]) * t ** 0.6), P["lake_min_m"]) + z[c] = np.minimum(z[c], level[c] - prof) + return z + + +def run(ctx) -> dict: + g = ctx.grid + P = params(ctx.cfg, "hydrology", DEFAULTS) + z, p_ann, pet = ctx.need("elevation_eroded_m", "P_ann", "PET") + z = z.astype(np.float64) + ar = np.arange(g.n) + ocean = np.asarray(ctx.data["ocean"]) if "ocean" in ctx.data else z <= 0 + aet = p_ann / np.sqrt(1.0 + (p_ann / np.maximum(pet, 1e-6)) ** 2) # Pike (1964) + runoff = np.where(ocean, 0.0, np.maximum(p_ann - aet, 0.0)) + water = runoff * g.area_km2 * 1e-6 # km³/yr per cell + zf = priority_flood(g, z, ocean, P["fill_eps_m"]) + depth = zf - z + dep = ~ocean & (depth > P["min_depth_m"]) + lab = components(g, dep) + recv1, _, _ = steepest_receivers(g, zf) + recv1 = np.where(ocean, ar, recv1) + levels1 = receiver_levels(recv1) + q1 = accumulate(recv1, levels1, water) + evap_net = np.maximum(pet - p_ann, 0.0) * g.area_km2 * 1e-6 # full-lake evaporation, km³/yr + + groups = _groups(lab) + leaves = _leaves_all(recv1, lab, levels1) if len(groups) else None + outlets = np.zeros(len(groups), np.int64) + terminals = np.zeros(len(groups), np.int64) + cap = np.zeros(len(groups)) + for k, c in enumerate(groups): + ext = c[lab[recv1[c]] != lab[c[0]]] + ext = ext[leaves[ext]] + outlets[k] = ext[np.argmax(q1[ext])] if len(ext) else c[np.argmax(q1[c])] + terminals[k] = c[np.argmin(z[c])] + cap[k] = evap_net[c].sum() + + # 1) every depression drains to its spill outlet; one upstream-first sweep decides spill vs endorheic + recv_a = _route_inside(g, dep, lab, recv1, outlets) if len(groups) else recv1 + acc_a, _ = _sweep(recv_a, receiver_levels(recv_a), water, outlets, cap) + inflow = acc_a[outlets] + spill = inflow >= cap + # 2) endorheic depressions drain to their single lowest cell instead + targets = np.where(spill, outlets, terminals) + recv = _route_inside(g, dep, lab, recv1, targets) if len(groups) else recv1 + recv[terminals[~spill]] = terminals[~spill] + try: + levels = receiver_levels(recv) + except ValueError as e: + raise StageError(f"hydrology: {e}") from e + q, loss = _sweep(recv, levels, water, outlets[spill], cap[spill]) + + lake = np.zeros(g.n, bool) + endo = np.zeros(g.n, bool) + salt = np.zeros(g.n, bool) + lake_id = np.full(g.n, -1, np.int32) + for k, c in enumerate(groups): + if spill[k]: + lake[c] = True + lake_id[c] = k + continue + endo[c] = True + cs = c[np.argsort(z[c])] + nl = int(np.searchsorted(np.cumsum(evap_net[cs]), inflow[k])) + if inflow[k] > 0: + nl = max(nl, 1) # the terminal always holds some water + lake[cs[:nl]] = True + lake_id[cs[:nl]] = k + if nl == 0 or (nl < P["dry_lake_fraction"] * len(cs) and p_ann[c].mean() < P["salt_flat_max_p_mm"]): + a = np.cumsum(g.area_km2[cs]) + ns = max(nl + 1, int(np.searchsorted(a, P["salt_flat_fraction"] * a[-1]))) + salt[cs[nl:ns]] = True + river = ~ocean & ~lake & (q >= P["river_min_km3_yr"]) + level = lake_levels(z, zf, lab, lake, lake_id, endo) + rift = np.asarray(ctx.data["age_class"]) == RIFT if "age_class" in ctx.data else np.zeros(g.n, bool) + zb = lake_beds(g, z, level, lake, lake_id, endo, rift, p_ann, ctx.seed, P, ctx.data.get("lake_carve")) + cut = np.asarray(ctx.data.get("lake_cut_m", 0.0), dtype=np.float64) + (z - zb) # sea masks see the uncut ground + z = zb + depth = zf - z + dtype = np.asarray(ctx.data["elevation_eroded_m"]).dtype + return {"elevation_eroded_m": z.astype(dtype), "lake_level_m": level.astype(np.float32), + "lake_cut_m": np.broadcast_to(cut, z.shape).astype(np.float32), "z_filled_m": zf, "recv": recv, "discharge_km3_yr": q, "runoff_mm": runoff, "aet_mm": aet, + "lake": lake, "lake_id": lake_id, "endorheic": endo, "salt_flat": salt, "river": river, + "strahler": strahler(recv, levels, river), "depression_depth_m": depth, "lake_loss_km3_yr": loss} diff --git a/mapgen/ice.py b/mapgen/ice.py new file mode 100644 index 0000000..2aef33f --- /dev/null +++ b/mapgen/ice.py @@ -0,0 +1,37 @@ +"""Stage `ice`: ice sheets, mountain glaciers, seasonal/perennial sea ice.""" +from __future__ import annotations + +import numpy as np + +from .config import params +from .graph import components, distance_to + +ICE_NONE, ICE_SHEET, ICE_GLACIER, ICE_SEA_SEASONAL, ICE_SEA_PERENNIAL = range(5) +ICE_NAMES = ["none", "ice sheet", "glacier", "seasonal sea ice", "perennial sea ice"] +DEFAULTS = {"melt_summer_c": 0.0, "sheet_min_km2": 250000.0, "sheet_min_p_mm": 100.0, "sea_ice_t_c": -1.8, "sheet_max_m": 3000.0, + "sheet_edge_m": 200.0, "sheet_growth_m_per_km": 3.0} + + +def run(ctx) -> dict: + g = ctx.grid + P = params(ctx.cfg, "ice", DEFAULTS) + z, t_mean, t_jun, t_dec, p_ann = ctx.need("elevation_eroded_m", "T_mean", "T_jun", "T_dec", "P_ann") + z = z.astype(np.float64) + land = ~np.asarray(ctx.data["ocean"]) if "ocean" in ctx.data else z > 0 + summer = np.where(g.lat >= 0, t_jun, t_dec) + winter = np.where(g.lat >= 0, t_dec, t_jun) + cold = land & (summer < P["melt_summer_c"]) # snow survives the summer → permanent ice + lab = components(g, cold) + area = np.bincount(lab[cold], weights=g.area_km2[cold]) if cold.any() else np.zeros(1) + big = cold & (area[np.maximum(lab, 0)] >= P["sheet_min_km2"]) + sheet = big & (p_ann > P["sheet_min_p_mm"]) + thick = np.zeros(g.n) + if sheet.any(): + d_edge = distance_to(g, ~sheet) + thick = np.where(sheet, np.minimum(P["sheet_max_m"], P["sheet_edge_m"] + P["sheet_growth_m_per_km"] * d_edge), 0.0) + ice = np.zeros(g.n, np.int8) + ice[cold & ~sheet] = ICE_GLACIER + ice[sheet] = ICE_SHEET + ice[~land & (winter < P["sea_ice_t_c"])] = ICE_SEA_SEASONAL + ice[~land & (summer < P["sea_ice_t_c"])] = ICE_SEA_PERENNIAL + return {"ice": ice, "ice_thickness_m": thick, "z_surface_m": z + thick} diff --git a/mapgen/minerals.py b/mapgen/minerals.py new file mode 100644 index 0000000..ab173c9 --- /dev/null +++ b/mapgen/minerals.py @@ -0,0 +1,172 @@ +"""Mineral deposits: where each ore, fuel and industrial mineral can be mined, by +geological rules, placed at an Earth-like share of the land. Everything a society needs to reach steam, rifled guns +and aluminium airships is somewhere; the good stuff (high-grade iron ore and the alloy metals) lies mostly deep in +mountains. Each deposit is a bit of `deposits` (uint32); `deposit_main` names the rarest one in a cell (a map layer). + +A deposit's cells: the land cells (not sea, not under lakes) where its rule allows it, scored by the rule × a district +noise of its own (deposits cluster into mining districts). Half the `share` is taken province by province (≈ 1500 km +cells, each its share of its own land, best-scored first): every region has its local bog iron, coal pits and small +mines where its rocks allow. The rest goes to the best-scored land world-wide (the great districts).""" +from __future__ import annotations + +import numpy as np + +from scipy.spatial import cKDTree + +from .noise import fbm, name_seed + +# name, label, share of land (fraction). Shares are rough Earth-like extents of workable districts, not grades. +DEPOSITS = [ + ("bog_iron", "bog / laterite iron (low grade)", 0.030), + ("ironstone", "ironstone (sedimentary iron)", 0.025), + ("iron_high", "high-grade iron (magnetite, banded)", 0.006), + ("coal", "coal (incl. coking)", 0.050), + ("copper", "copper", 0.012), + ("molybdenum", "molybdenum", 0.002), + ("tin", "tin", 0.003), + ("tungsten", "tungsten", 0.002), + ("lead_zinc", "lead & zinc", 0.010), + ("silver", "silver", 0.005), + ("gold", "gold", 0.008), + ("manganese", "manganese", 0.004), + ("chromium", "chromium", 0.0015), + ("nickel", "nickel", 0.003), + ("cobalt", "cobalt", 0.001), + ("vanadium", "vanadium & titanium", 0.002), + ("platinum", "platinum metals", 0.0005), + ("mercury", "mercury (cinnabar)", 0.0012), + ("sulfur", "sulfur", 0.004), + ("saltpetre", "saltpetre (nitrates)", 0.004), + ("bauxite", "bauxite (aluminium)", 0.010), + ("fluorite", "fluorite / cryolite (flux)", 0.0025), + ("rock_salt", "rock salt & evaporites", 0.018), + ("potash", "potash", 0.004), + ("phosphate", "phosphate", 0.005), + ("oil_gas", "oil & gas", 0.025), + ("helium", "helium (with gas)", 0.0025), + ("diamond", "diamonds (kimberlite)", 0.0004), + ("magnesite", "magnesite / dolomite (magnesium)", 0.003), + ("kaolin", "kaolin / fire clay", 0.010), + ("glass_sand", "glass sand (quartz)", 0.015), + ("graphite", "graphite", 0.002), +] +NAMES = [d[0] for d in DEPOSITS] +BIT = {n: 1 << i for i, n in enumerate(NAMES)} +LEGEND = ["—"] + [d[1] for d in DEPOSITS] +# the good stuff: mostly in deep mountains +DEEP = ("iron_high", "tungsten", "molybdenum", "chromium", "cobalt", "vanadium", "platinum", "manganese", "tin") + + +PROVINCE_KM = 1500.0 # spacing of the provinces that each take their local share +LOCAL = 0.5 # part of a deposit's share taken province by province +LOCAL_MIN = 0.25 # ... only on ground at least this good relative to the world's marginal district + + +def provinces(g, seed): + """Province index of each cell: the nearest of random centres ≈ PROVINCE_KM apart.""" + n = max(1, int(round(4 * np.pi * g.radius_km ** 2 / PROVINCE_KM ** 2))) + c = np.random.default_rng(name_seed(seed, "deposit-provinces")).normal(size=(n, 3)) + c /= np.linalg.norm(c, axis=1)[:, None] + return cKDTree(c).query(g.xyz / np.linalg.norm(g.xyz, axis=1)[:, None])[1] + + +def _rank_select(score, share, land, prov): + """`share` × land cells where score > 0: LOCAL of it the best of each province (its share of its own land, on + ground ≥ LOCAL_MIN × the world's k-th best score), the rest the best world-wide.""" + ok = np.flatnonzero((score > 0) & land) + k = min(len(ok), int(round(share * land.sum()))) + out = np.zeros(len(score), bool) + if k == 0: + return out + cut = LOCAL_MIN * np.partition(score[ok], len(ok) - k)[len(ok) - k] # the k-th best world-wide + ok_l = ok[score[ok] >= cut] + quota = np.floor(LOCAL * share * np.bincount(prov[land], minlength=prov.max() + 1) + 0.5).astype(np.int64) + o = ok_l[np.lexsort((-score[ok_l], prov[ok_l]))] # by province, best first + p = prov[o] + first = np.r_[0, np.flatnonzero(p[1:] != p[:-1]) + 1] + rank = np.arange(len(o)) - np.repeat(first, np.diff(np.r_[first, len(o)])) + local = o[rank < quota[p]] + out[local[np.argsort(-score[local], kind="stable")[:k]]] = True + rest = ok[~out[ok]] + m = k - int(out.sum()) + if m > 0: + out[rest[np.argpartition(-score[rest], m - 1)[:m]]] = True + return out + + +def rules(f): + """Each deposit's suitability (≥ 0, 0 = impossible) from the geology/climate fields in f.""" + from .crust import CRATON, LIP, POST_OROGEN, PRE_OROGEN, RIFT, VOLCANO + from .environment import (LF_ARC, LF_MASSIF, LF_MOUNTAINS, LI_ANDESITE, LI_BASALT, LI_GRANITE, LI_LIMESTONE, + LI_METAMORPHIC, LI_SANDSTONE, LF_DUNES, LF_PLAIN) + z, lit, age, lf, rel = f["z"], f["lit"], f["age"], f["lf"], f["relief"] + t, p = f["t_mean"], f["p_ann"] + b = lambda m: np.asarray(m, dtype=np.float64) + deep = np.clip((z - 800.0) / 2000.0, 0, 1) * np.clip(rel / 1500.0, 0.3, 1.0) # deep-mountain mining ground + deep = np.maximum(deep, 0.6 * b(np.isin(lf, [LF_MOUNTAINS, LF_MASSIF])) * np.clip(z / 2500.0, 0, 1)) + oro = b(np.isin(age, [PRE_OROGEN, POST_OROGEN])) + craton = b(age == CRATON) + rift = b(age == RIFT) + arc = b((lit == LI_ANDESITE) | (lf == LF_ARC)) + volc = b(age == VOLCANO) + arc + gran = b(lit == LI_GRANITE) + meta = b(lit == LI_METAMORPHIC) + lime = b(lit == LI_LIMESTONE) + sand = b(lit == LI_SANDSTONE) + basalt = b((lit == LI_BASALT) | (age == LIP)) + sed = lime + sand + low = b(z < 800) + hot, wet = b(t > 20), b(p > 1200) + arid = b(p < 250) * b(t > 10) + suture = b(f["d_coll"] < 400) * (oro + meta) + return { + "bog_iron": b(p > 700) * low * (b(lf == LF_PLAIN) + 0.5) + hot * wet * low, + "ironstone": sed * b(z < 1200), + "iron_high": deep * (1.0 + craton + meta + oro), + "coal": b(f["coal"]), + "copper": arc * (1 + deep) + 0.4 * sand * rift + 0.3 * basalt, + "molybdenum": arc * deep, + "tin": (gran + meta) * oro * (0.3 + deep), + "tungsten": (gran + meta) * (oro + 0.3) * deep, + "lead_zinc": lime + 0.3 * sand * (1 + rift), + "silver": arc + 0.3 * lime + 0.3 * deep * oro, + "gold": (oro + craton * 0.7 + meta) * (0.3 + deep) + 0.3 * arc, + "manganese": (sed + craton) * (0.2 + deep), + "chromium": suture * deep + 0.3 * craton * basalt * deep, + "nickel": basalt * hot * wet + (craton + suture) * deep, + "cobalt": (basalt * hot * wet + craton * deep + arc * 0.3) * deep, + "vanadium": (basalt + craton) * deep, + "platinum": craton * (basalt + meta + 0.3) * deep, + "mercury": volc * (0.5 + 0.5 * b(f["t_mean"] > -30)), + "sulfur": volc + 0.5 * b(f["salt"]) + 0.3 * sed * arid, + "saltpetre": arid * b(lf != LF_MOUNTAINS) + 0.3 * lime * b(p > 800), + "bauxite": hot * wet * (gran + basalt + 0.3) * b(rel < 800) + 0.4 * lime * b(t > 14) * b(p > 600), + "fluorite": (gran + lime * 0.4) * (rift + oro + 0.2), + "rock_salt": b(f["salt"]) + 0.6 * b(f["endo"]) + 0.3 * sed * b(p < 500), + "potash": b(f["salt"]) + 0.3 * b(f["endo"]) + 0.2 * sed * b(p < 400), + "phosphate": sed * low * b(f["dist_ocean"] < 400) + 0.3 * lime, + "oil_gas": sed * low * (1 + b(f["d_coll"] < 1200) + rift), + "helium": sed * low * (craton + gran), + "diamond": craton * (1 + deep), + "magnesite": (lime + meta * 0.5) * (0.3 + deep) + 0.3 * b(f["salt"]), + "kaolin": (gran + sand * 0.4) * b(p > 900) * b(t > 8), + "glass_sand": sand * (1 + b(lf == LF_DUNES) + b(f["dist_ocean"] < 150)), + "graphite": meta * (0.3 + deep), + } + + +def place(g, f, seed): + """(deposits uint32 bitmask, deposit_main uint8 legend index) over the grid.""" + land = np.asarray(f["land"], bool) + R = rules(f) + prov = provinces(g, seed) + bits = np.zeros(g.n, np.uint32) + main = np.zeros(g.n, np.uint8) + best = np.full(g.n, np.inf) + for i, (name, _, share) in enumerate(DEPOSITS): + nz = 0.5 + 0.5 * fbm(g.xyz, name_seed(seed, "deposit:" + name), 3, 6.0) # mining districts + sel = _rank_select(R[name] * (0.25 + nz) ** 2, share, land, prov) + bits[sel] |= np.uint32(1 << i) + rarer = sel & (share < best) + main[rarer], best[rarer] = i + 1, share + return bits, main diff --git a/mapgen/newworld.py b/mapgen/newworld.py new file mode 100644 index 0000000..80109b0 --- /dev/null +++ b/mapgen/newworld.py @@ -0,0 +1,117 @@ +"""`mapgen.py --world DIR new-world`: a random starting world (sketch/*.png continents, config/tectonics.toml plates), +so a build runs without drawing anything. Used for the moons; edit or redraw afterwards.""" +from __future__ import annotations + +from pathlib import Path + +import numpy as np +from PIL import Image +from scipy import ndimage + +from .noise import fbm, ridged +from .sphere import latlon_to_xyz + +W, H = 2000, 1000 + + +def _spread(rng, n, min_deg, avoid=(), max_lat=70.0, tries=4000): + pts = list(avoid) + out = [] + for _ in range(tries): + if len(out) == n: + break + lat = np.degrees(np.arcsin(rng.uniform(np.sin(np.radians(-max_lat)), np.sin(np.radians(max_lat))))) + lon = rng.uniform(-180, 180) + p = latlon_to_xyz(lat, lon) + if all(np.degrees(np.arccos(np.clip(p @ latlon_to_xyz(*q), -1, 1))) >= min_deg for q in pts): + pts.append((lat, lon)) + out.append((float(lat), float(lon))) + return out + + +def _centroid(mask, LAT, LON): + w = np.cos(np.radians(LAT)) * mask + p = (latlon_to_xyz(LAT, LON) * w[..., None]).sum(axis=(0, 1)) + p /= np.linalg.norm(p) + return float(np.degrees(np.arcsin(p[2]))), float(np.degrees(np.arctan2(p[1], p[0]))) + + +def make(root: Path, seed: int, continents: int = 6, land_share: float = 0.27, toward=None, ridge: float = 0.86, + force: bool = False) -> None: + root = Path(root) + sk, cfg = root / "sketch", root / "config" + targets = [sk / "land.png", cfg / "tectonics.toml"] + if not force and any(p.exists() for p in targets): + raise SystemExit("new-world: sketch/ or config/tectonics.toml already exist (use --force to overwrite)") + rng = np.random.default_rng(seed) + w, h = W // 2, H // 2 + LAT, LON = np.meshgrid(90.0 - (np.arange(h) + 0.5) * 180.0 / h, -180.0 + (np.arange(w) + 0.5) * 360.0 / w, + indexing="ij") + xyz = latlon_to_xyz(LAT, LON).reshape(-1, 3) + + centres = _spread(rng, continents, 35.0, max_lat=55.0) + field = np.zeros(len(xyz)) + for lat, lon in centres: + r = np.radians(rng.uniform(18.0, 34.0)) + d = np.arccos(np.clip(xyz @ latlon_to_xyz(lat, lon), -1, 1)) + field = np.maximum(field, np.clip(1.0 - d / r, 0.0, None) * rng.uniform(0.8, 1.2)) + field += 0.55 * fbm(xyz, seed + 7, 6, 2.2) + if toward is not None: + field += 1.2 * (xyz @ latlon_to_xyz(*toward)) + area = np.cos(np.radians(LAT)).ravel() + order = np.argsort(-field) + cut = field[order][np.searchsorted(np.cumsum(area[order]) / area.sum(), land_share)] + land = (field > cut).reshape(h, w) + + mount = (ridged(xyz, seed + 11, 5, 3.0) > ridge).reshape(h, w) & ndimage.binary_erosion(land, iterations=6) + + def save(name, m): + Image.fromarray((m * 255).astype(np.uint8), "L").resize((W, H), Image.BILINEAR).point( + lambda v: 255 if v > 127 else 0).save(sk / f"{name}.png") + + sk.mkdir(parents=True, exist_ok=True) + cfg.mkdir(parents=True, exist_ok=True) + save("land", land) + save("mountains", mount) + for name in ("desert", "rainforest", "trench"): + save(name, np.zeros_like(land)) + + lab, n = ndimage.label(land) + for a, b in zip(lab[:, 0], lab[:, -1]): + if a and b and a != b: + lab[lab == b] = a + sizes = sorted(((int((lab == i).sum()), i) for i in np.unique(lab) if i), reverse=True) + plates, seeds = [], [] + for k, (px, i) in enumerate(sizes[:continents]): + m = lab == i + if px > 0.035 * m.size: # a big landmass: two plates, a collision belt between + lat, lon = _centroid(m, LAT, LON) + ang = rng.uniform(0, np.pi) + side = (np.sin(ang) * (LAT - lat) + np.cos(ang) * ((LON - lon + 180) % 360 - 180) * np.cos(np.radians(lat))) > 0 + parts = [m & side, m & ~side] + else: + parts = [m] + for j, part in enumerate(parts): + if part.sum() < 50: + continue + lat, lon = _centroid(part, LAT, LON) + seeds.append((lat, lon)) + plates.append((f"continent-{k + 1}{'ab'[j] if len(parts) > 1 else ''}", lat, lon, "continental", + rng.uniform(0, 360), rng.uniform(2.0, 5.0))) + for k, (lat, lon) in enumerate(_spread(rng, 14 - len(plates) // 2, 28.0, seeds, max_lat=85.0)): + plates.append((f"ocean-{k + 1}", lat, lon, "oceanic", rng.uniform(0, 360), rng.uniform(3.0, 8.0))) + + ocean = np.argwhere(~ndimage.binary_dilation(land, iterations=10)) + def sea_point(): + y, x = ocean[rng.integers(len(ocean))] + return float(LAT[y, x]), float(LON[y, x]) + f = lambda v: f"[{v[0]:.1f}, {v[1]:.1f}]" + t = [f"# Generated by `mapgen.py new-world --seed {seed}`. Edit freely (see README.md → Tectonics).", + "# seed = [lat, lon] where the plate grows from; motion = [azimuth° clockwise from north, speed cm/yr].", ""] + for pid, lat, lon, kind, az, sp in plates: + t += ["[[plate]]", f'id = "{pid}"', f"seed = {f((lat, lon))}", f'kind = "{kind}"', f"motion = [{az:.0f}.0, {sp:.1f}]", ""] + if len(ocean): + t += ["[[hotspot]]", 'name = "island-chain"', f"center = {f(sea_point())}", "length_km = 600.0", ""] + (cfg / "tectonics.toml").write_text("\n".join(t)) + + print(f"new-world: {len(plates)} plates, sketch/ and config/tectonics.toml written (seed {seed})") diff --git a/mapgen/noise.py b/mapgen/noise.py new file mode 100644 index 0000000..e52b7f8 --- /dev/null +++ b/mapgen/noise.py @@ -0,0 +1,111 @@ +"""Seeded, vectorized 3D value noise and fractal sums on points (N, 3).""" +from __future__ import annotations + +import zlib + +import numpy as np + +_MASK = (1 << 64) - 1 +_K1 = np.uint64(0x9E3779B185EBCA87) +_K2 = np.uint64(0xC2B2AE3D27D4EB4F) +_K3 = np.uint64(0x165667B19E3779F9) +_M1 = np.uint64(0x94D049BB133111EB) + + +def _hash(ix, iy, iz, seed: int): + s = np.uint64((seed * 0x27D4EB2F165667C5 + 0x632BE59BD9B4E019) & _MASK) + h = ix.astype(np.uint64) * _K1 ^ iy.astype(np.uint64) * _K2 ^ iz.astype(np.uint64) * _K3 ^ s + h ^= h >> np.uint64(31) + h *= _M1 + h ^= h >> np.uint64(29) + return (h >> np.uint64(11)).astype(np.float64) / float(1 << 53) * 2.0 - 1.0 + + +def value_noise(p, seed: int): + if _noise_jit is not None: + p = np.asarray(p) + if p.dtype == np.float64 and p.ndim == 2 and p.shape[1] == 3: + s = np.uint64((seed * 0x27D4EB2F165667C5 + 0x632BE59BD9B4E019) & _MASK) + return _noise_jit(np.ascontiguousarray(p), s) + return _value_noise(p, seed) + + +def _value_noise(p, seed: int): + f = np.floor(p) + i = f.astype(np.int64) + t = p - f + t = t * t * (3.0 - 2.0 * t) + out = np.zeros(len(p)) + for dx in (0, 1): + wx = t[:, 0] if dx else 1.0 - t[:, 0] + for dy in (0, 1): + wy = t[:, 1] if dy else 1.0 - t[:, 1] + for dz in (0, 1): + wz = t[:, 2] if dz else 1.0 - t[:, 2] + out += wx * wy * wz * _hash(i[:, 0] + dx, i[:, 1] + dy, i[:, 2] + dz, seed) + return out + + +def _make_noise_jit(): + """value_noise compiled (numba optional): per point the same integer hash and the same float steps in the same + order, so the same values; one call costs microseconds instead of a dozen numpy passes (river meanders call it + on a few points at a time). WORLDGEN_NO_JIT=1 turns it off.""" + import os + if os.environ.get("WORLDGEN_NO_JIT"): + return None + try: + import numba + except ImportError: + return None + K1, K2, K3, M1 = _K1, _K2, _K3, _M1 + + @numba.njit(cache=True) + def noise(p, s): + n = p.shape[0] + out = np.empty(n) + for k in range(n): + fx, fy, fz = np.floor(p[k, 0]), np.floor(p[k, 1]), np.floor(p[k, 2]) + ix, iy, iz = np.int64(fx), np.int64(fy), np.int64(fz) + tx, ty, tz = p[k, 0] - fx, p[k, 1] - fy, p[k, 2] - fz + tx = tx * tx * (3.0 - 2.0 * tx) + ty = ty * ty * (3.0 - 2.0 * ty) + tz = tz * tz * (3.0 - 2.0 * tz) + acc = 0.0 + for dx in range(2): + wx = tx if dx else 1.0 - tx + for dy in range(2): + wy = ty if dy else 1.0 - ty + for dz in range(2): + wz = tz if dz else 1.0 - tz + h = (np.uint64(ix + dx) * K1) ^ (np.uint64(iy + dy) * K2) ^ (np.uint64(iz + dz) * K3) ^ s + h ^= h >> np.uint64(31) + h *= M1 + h ^= h >> np.uint64(29) + v = np.float64(h >> np.uint64(11)) / 9007199254740992.0 * 2.0 - 1.0 + acc += wx * wy * wz * v + out[k] = acc + return out + return noise + + +_noise_jit = _make_noise_jit() + + +def fbm(xyz, seed: int, octaves: int = 5, freq: float = 2.0, lacunarity: float = 2.0, gain: float = 0.5): + total = np.zeros(len(xyz)) + amp, norm, f = 1.0, 0.0, freq + for o in range(octaves): + total += amp * value_noise(xyz * f, seed + 1013 * o) + norm += amp + amp *= gain + f *= lacunarity + return total / norm + + +def ridged(xyz, seed: int, octaves: int = 5, freq: float = 2.0): + return 1.0 - np.abs(fbm(xyz, seed, octaves, freq)) + + +def name_seed(seed: int, name: str) -> int: + """A per-feature noise seed: the build seed plus a hash of the feature's name (stable across runs).""" + return seed + zlib.crc32(name.encode()) % 100000 diff --git a/mapgen/ocean.py b/mapgen/ocean.py new file mode 100644 index 0000000..67dfaf7 --- /dev/null +++ b/mapgen/ocean.py @@ -0,0 +1,166 @@ +"""Ocean circulation for stage `climate`: wind-driven surface currents, sea-surface temperature, Ekman upwelling +and a productivity index. + +Currents: the Stommel model on the sphere, r∇²ψ + βψ_x = k·curl(τ/ρH), solved for the stream function ψ over +every cell; land is the same fluid with `land_friction`× the friction (Brinkman penalisation), so coasts block the +flow and islands need no special treatment. u = k × ∇ψ. Western boundary currents come out ≈ r/β wide. +""" +from __future__ import annotations + +import numpy as np +from scipy import sparse +from scipy.sparse import linalg as splinalg + +from .graph import bicgstab_jacobi, distance_to, gradient, pmap, smooth_km +from .grid import rowdot_at +from .sphere import east_north + +DEFAULTS = { + "enabled": True, "friction_days": 5.0, "layer_m": 150.0, "stress_k": 1.0, "land_friction": 1000.0, + "rho_air": 1.2, "drag": 1.3e-3, "direct_max_cells": 500000, + "relax_days": 300.0, "kappa_m2s": 1000.0, + "ekman_min_lat": 3.0, "upwell_coast_km": 100.0, + "prod_upwell": 0.6, "prod_shelf": 0.4, "prod_mix": 0.3, "upwell_ref_m_yr": 100.0, + "prod_front": 0.4, "front_min": 0.5, "front_ref": 1.0, +} +RHO_W = 1025.0 +YEAR_S = 3.15576e7 + + +def omega(day_hours): + return 2.0 * np.pi / (float(day_hours) * 3600.0) + + +def divergence(g, V): + """Graph divergence of a tangent field (V per km): (2/k_i) Σ_j V_j·t_ij / d_ij (exact for linear V on a hex + lattice; the V_i terms cancel).""" + w = rowdot_at(V, g.dst, g.edge_tangents) / g.edge_km + return np.bincount(g.src, weights=w, minlength=g.n) * (2.0 / g.counts) + + +def _edge_operator(g, w): + """Sparse operator f ↦ Σ_j w_ij (f_j − f_i).""" + A = sparse.csr_matrix((w, (g.src, g.dst)), shape=(g.n, g.n)) + return A - sparse.diags(np.asarray(A.sum(axis=1)).ravel()) + + +def wind_stress(wind, P): + """Bulk formula τ = k·ρ_air·C_d·|w|·w (N/m²).""" + wind = np.asarray(wind, dtype=np.float64) + return P["stress_k"] * P["rho_air"] * P["drag"] * np.linalg.norm(wind, axis=1, keepdims=True) * wind + + +def streamfunction(g, ocean, wind, P, day_hours): + """ψ (m²/s) of the wind-driven surface flow; one direct sparse solve over every cell.""" + Om = omega(day_hours) + R = g.radius_km * 1e3 + beta = 2.0 * Om * np.cos(np.radians(g.lat)) / R # 1/(m·s) + beta30 = 2.0 * Om * np.cos(np.radians(30.0)) / R + r = max(1.0 / (P["friction_days"] * 86400.0), beta30 * g.spacing_km * 1e3) # boundary layer ≥ one cell + F = np.where(ocean[:, None], wind_stress(wind, P), 0.0) / (RHO_W * P["layer_m"]) + curl = divergence(g, np.cross(F, g.xyz)) * 1e-3 # k·curl F = ∇·(F×k), 1/s² + fr = np.where(ocean, 1.0, P["land_friction"]) + fe = 2.0 / (1.0 / fr[g.src] + 1.0 / fr[g.dst]) # harmonic mean: coasts count as land + L = _edge_operator(g, 4.0 / (g.counts[g.src] * g.edge_km ** 2) * fe) # ∇·(fr∇), per km² + e, _ = east_north(g.xyz) + Dx = _edge_operator(g, (2.0 / g.counts[g.src]) * rowdot_at(e, g.src, g.edge_tangents) / g.edge_km) + A = (L + sparse.diags(beta / r * 1e3) @ Dx).tolil() # β/r·1e3: per km + b = 1e6 * curl / r + # gauge: ψ is defined up to a constant; pin it deep inland, where land friction soaks up the solve's + # compatibility residual (pinned at sea it would leave a point vortex there) + k = int(np.argmax(distance_to(g, ocean))) if (~ocean).any() else 0 + A[k, :] = 0.0 + A[k, k] = 1.0 + b[k] = 0.0 + return splinalg.spsolve(A.tocsc(), b) + + +def velocity(g, psi): + """u = k × ∇ψ (m/s), tangent 3-vectors.""" + return np.cross(g.xyz, gradient(g, psi) * 1e-3) + + +def _coarse_currents(g, ocean, wind, P, day_hours): + """Grids too big for a direct solve: solve on the parent H3 resolution, carry u down, smooth.""" + from .grid import build_grid, cell_parents + cg = build_grid(g.res - 1, g.radius_km) + parent = np.searchsorted(cg.ids, cell_parents(g.ids, g.res - 1)) + cnt = np.maximum(np.bincount(parent, minlength=cg.n), 1) + c_ocean = np.bincount(parent, weights=ocean.astype(float), minlength=cg.n) / cnt > 0.5 + c_wind = np.stack([np.bincount(parent, weights=wind[:, k], minlength=cg.n) / cnt for k in range(3)], axis=1) + cu = currents(cg, c_ocean, c_wind, P, day_hours) + u = np.stack(pmap(lambda k: smooth_km(g, cu[parent, k], cg.spacing_km), range(3)), axis=1) + return u - np.sum(u * g.xyz, axis=1, keepdims=True) * g.xyz # back into the tangent plane + + +def currents(g, ocean, wind, P, day_hours): + """Surface current (n,3) m/s; 0 on land.""" + ocean = np.asarray(ocean, bool) + wind = np.asarray(wind, dtype=np.float64) + if not ocean.any(): + return np.zeros((g.n, 3)) + if g.n > P["direct_max_cells"]: + u = _coarse_currents(g, ocean, wind, P, day_hours) + else: + u = velocity(g, streamfunction(g, ocean, wind, P, day_hours)) + return np.where(ocean[:, None], u, 0.0) + + +def _masked_edges(g, mask, w): + return _edge_operator(g, np.where(mask[g.src] & mask[g.dst], w, 0.0)) + + +def sst(g, ocean, u, T_eq, P): + """Annual-mean sea-surface temperature (°C): steady u·∇T − κ∇²T = λ(T_eq − T) over the ocean (upwind + advection, no flux into land); land keeps T_eq.""" + ocean = np.asarray(ocean, bool) + T_eq = np.asarray(T_eq, dtype=np.float64) + lam = 1.0 / (P["relax_days"] * 86400.0) + up = (4.0 / g.counts[g.src]) * np.maximum(-rowdot_at(u, g.src, g.edge_tangents), 0.0) / (g.edge_km * 1e3) + adv = -_masked_edges(g, ocean, up) # Σ_upstream c_ij (T_i − T_j), 1/s + dif = _masked_edges(g, ocean, 4.0 / (g.counts[g.src] * g.edge_km ** 2)) * (P["kappa_m2s"] * 1e-6) + A = (adv - dif) / lam + sparse.identity(g.n) + A = (sparse.diags(ocean.astype(float)) @ A + sparse.diags((~ocean).astype(float))).tocsr() + T, info = bicgstab_jacobi(A, T_eq, T_eq.copy(), 1.0 / A.diagonal(), 1e-9, 5000) + if info != 0: + raise ValueError(f"sst: solver did not converge (info={info})") + return T + + +def upwelling(g, ocean, wind, P, day_hours): + """Ekman pumping (m/yr, + up): w = ∇·M, M = τ×k/(ρf), |f| floored at `ekman_min_lat`. Transport pointing off a + coast leaves the coast cell (land carries none), so coastal upwelling needs no separate rule. Smoothed over + `upwell_coast_km`; 0 on land.""" + ocean = np.asarray(ocean, bool) + Om = omega(day_hours) + fmin = 2.0 * Om * np.sin(np.radians(P["ekman_min_lat"])) + f = np.where(g.lat >= 0, 1.0, -1.0) * np.maximum(np.abs(2.0 * Om * np.sin(np.radians(g.lat))), fmin) + tau = np.where(ocean[:, None], wind_stress(wind, P), 0.0) + M = np.cross(tau, g.xyz) / (RHO_W * f[:, None]) # m²/s + w = np.where(ocean, divergence(g, M) * 1e-3 * YEAR_S, 0.0) + return np.where(ocean, smooth_km(g, w, P["upwell_coast_km"]), 0.0) + + +def sst_front(g, ocean, sst_c): + """|∇SST| over the sea (°C per 100 km); edges to land count as flat, so coasts are no front.""" + sst_c = np.asarray(sst_c, dtype=np.float64) + both = ocean[g.src] & ocean[g.dst] + w = np.where(both, sst_c[g.dst] - sst_c[g.src], 0.0) / g.edge_km + grad = np.stack([np.bincount(g.src, weights=w * g.edge_tangents[:, c], minlength=g.n) for c in range(3)], axis=1) + return np.where(ocean, np.linalg.norm(grad, axis=1) * (2.0 / g.counts) * 100.0, 0.0) + + +def productivity(g, ocean, w, z, t_range, sst_c, P): + """0–1 sea productivity: upwelling (saturating), shallow shelf, winter mixing and SST fronts (where warm and cold + currents meet, e.g. a Brazil–Malvinas confluence; gradients under `front_min` °C/100 km add nothing), dimmed + toward the poles.""" + ocean = np.asarray(ocean, bool) + depth = np.maximum(-np.asarray(z, dtype=np.float64), 0.0) + x = np.maximum(w, 0.0) / P["upwell_ref_m_yr"] + shelf = np.clip((1000.0 - depth) / 800.0, 0.0, 1.0) + mix = np.clip(np.asarray(t_range) / 20.0, 0.0, 1.0) * np.clip((20.0 - np.asarray(sst_c)) / 20.0, 0.0, 1.0) + light = 0.3 + 0.7 * np.cos(np.radians(g.lat)) + xf = np.maximum(sst_front(g, ocean, sst_c) - P["front_min"], 0.0) / P["front_ref"] + p = (P["prod_upwell"] * x / (1.0 + x) + P["prod_shelf"] * shelf + P["prod_mix"] * mix + + P["prod_front"] * xf / (1.0 + xf)) * light + return np.where(ocean, np.clip(p, 0.0, 1.0), 0.0) diff --git a/mapgen/pipeline.py b/mapgen/pipeline.py new file mode 100644 index 0000000..fca2fa1 --- /dev/null +++ b/mapgen/pipeline.py @@ -0,0 +1,180 @@ +"""Stage runner with an input-hash cache.""" +from __future__ import annotations + +import hashlib +import importlib +import os +import time +from dataclasses import dataclass, field +from pathlib import Path + +import numpy as np + +from . import config as C +from .grid import Grid + +STAGES = ["grid", "sketch", "plates", "crust", "elevation", "erosion", "climate", + "hydrology", "seabed", "environment", "ice", "fields", "render"] + + +class StageError(RuntimeError): + pass + + +@dataclass +class Ctx: + root: Path + cfg: dict + tect: dict + res: int + data: dict = field(default_factory=dict) + grid: Grid | None = None + out_dir: Path | None = None # render: out/r<res> unless set (an era writes out/r<res>/eras/<era>) + preview_dir: Path | None = None + low_memory: bool = False # trade speed for a lower memory peak; never changes the results + + @property + def seed(self) -> int: + return int(self.cfg["build"]["seed"]) + + def need(self, *keys): + missing = [k for k in keys if k not in self.data] + if missing: + raise StageError(f"missing inputs {missing}: run the stage that produces them first") + return [self.data[k] for k in keys] + + +def inputs_key(root: Path, res: int) -> str: + h = hashlib.sha256(str(res).encode()) + files = [] + for sub, pat in (("config", "*.toml"), ("masks", "*.png"), ("sketch", "*.png")): + files += sorted(p for p in (root / sub).glob(pat) if p.name != C.ERAS_FILE) # eras: their own key + files += sorted(Path(__file__).resolve().parent.glob("*.py")) + for p in files: + h.update(p.name.encode()) + h.update(p.read_bytes()) + return h.hexdigest()[:16] + + +ALIGN = 64 + + +def save_npz_aligned(path, /, **arrays) -> None: + """np.savez(path, **arrays), but each array's data starts at a multiple of ALIGN bytes in the file (the local zip + header gets a padding extra field, as zipalign does): an ordinary .npz for np.load, whose arrays npz_maps can + map in place. Alignment matters for more than speed: numpy sums 8-byte-misaligned data in buffered chunks, + i.e. in another order — mapped misaligned inputs would change the last bits of results.""" + import io + import struct + import zipfile + from numpy.lib import format as F + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as zf: + for name, v in arrays.items(): + v = np.asarray(v) + if v.dtype.hasobject: + raise ValueError(f"save_npz_aligned: {name} has object dtype") + head = io.BytesIO() + d = F.header_data_from_array_1_0(v) + try: + F.write_array_header_1_0(head, d) + except ValueError: + head = io.BytesIO() + F.write_array_header_2_0(head, d) + info = zipfile.ZipInfo(f"{name}.npy", date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_STORED + start = zf.fp.tell() + 30 + len(info.filename.encode()) + 20 + len(head.getvalue()) # 20: zip64 field + pad = -start % ALIGN + if 0 < pad < 4: # an extra field is at least its 4-byte header + pad += ALIGN + if pad: + info.extra = struct.pack("<HH", 0xA1A1, pad - 4) + bytes(pad - 4) + with zf.open(info, "w", force_zip64=True) as m: + m.write(head.getvalue()) + _write_data(m, v) + + +def _write_data(m, v) -> None: + """The array's bytes (C order, or Fortran order for an F-contiguous array, as np.save) in 16 MB pieces.""" + flat = v.T.reshape(-1) if (v.flags.f_contiguous and not v.flags.c_contiguous) else np.ascontiguousarray(v).reshape(-1) + step = max(1, (16 << 20) // max(v.itemsize, 1)) + for i in range(0, flat.size, step): + m.write(flat[i:i + step].tobytes()) + + +def npz_maps(f: Path) -> dict: + """The arrays of an uncompressed .npz (np.savez) mapped from the file, copy-on-write: plain writable arrays with + the same values, whose pages the OS reads on use and can drop again (writes stay private, the file never + changes). Members that can't be mapped (compressed, object dtype) are read into memory as np.load would.""" + import mmap + import struct + import zipfile + out = {} + with open(f, "rb") as fh, zipfile.ZipFile(fh) as zf: + mm = mmap.mmap(fh.fileno(), 0, access=mmap.ACCESS_COPY) + for info in zf.infolist(): + name = info.filename[:-4] if info.filename.endswith(".npy") else info.filename + ok = info.compress_type == zipfile.ZIP_STORED + if ok: + fh.seek(info.header_offset) + local = fh.read(30) + n_name, n_extra = struct.unpack("<HH", local[26:30]) + fh.seek(info.header_offset + 30 + n_name + n_extra) + version = np.lib.format.read_magic(fh) + read_header = {(1, 0): np.lib.format.read_array_header_1_0, + (2, 0): np.lib.format.read_array_header_2_0}.get(version) + ok = read_header is not None + if ok: + shape, fortran, dtype = read_header(fh, max_header_size=1 << 20) + ok = not dtype.hasobject + ok = fh.tell() % ALIGN == 0 # misaligned: read (see save_npz_aligned) + if ok: + count = int(np.prod(shape, dtype=np.int64)) + a = np.frombuffer(mm, dtype=dtype, count=count, offset=fh.tell()) if count else np.empty(0, dtype) + out[name] = a.reshape(shape, order="F" if fortran else "C") + else: + with zf.open(info) as m: + out[name] = np.lib.format.read_array(m, allow_pickle=False) + return out + + +def _after(ctx: Ctx, name: str) -> None: + if name == "grid": + ctx.grid = Grid.from_arrays(ctx.data, ctx.res, float(ctx.cfg["planet"]["radius_km"])) + + +def build(root: Path, res: int, start: str | None = None, stop: str | None = None, log=print, + low_memory: bool = False) -> Ctx: + cfg, tect = C.load(root) + ctx = Ctx(root, cfg, tect, res, low_memory=low_memory) + cache = root / "out" / "cache" / f"r{res}" + cache.mkdir(parents=True, exist_ok=True) + key = inputs_key(root, res) + stages = STAGES[: STAGES.index(stop) + 1] if stop else STAGES + forced = False + for name in stages: + forced = forced or name == start + f = cache / f"{name}.npz" + t0 = time.time() + if not forced and f.exists(): + with np.load(f, allow_pickle=False) as z: + hit = str(z["_key"]) == key + if hit and not ctx.low_memory: + ctx.data.update({k: z[k] for k in z.files if k != "_key"}) + if hit: + if ctx.low_memory: + ctx.data.update({k: v for k, v in npz_maps(f).items() if k != "_key"}) + _after(ctx, name) + log(f"{name}: cached") + continue + forced = True + out = importlib.import_module(f"mapgen.{name}").run(ctx) + tmp = f.with_name(f"{f.stem}.tmp-{os.getpid()}.npz") + save_npz_aligned(tmp, _key=np.array(key), **out) + os.replace(tmp, f) # never truncated in place: maps of the old file stay valid + if ctx.low_memory: # fields kept on disk from here on (the cache file just written) + maps = npz_maps(f) + out = {k: maps.get(k, v) if isinstance(v, np.ndarray) else v for k, v in out.items()} + ctx.data.update(out) + _after(ctx, name) + log(f"{name}: {time.time() - t0:.1f}s") + return ctx diff --git a/mapgen/plateaus.py b/mapgen/plateaus.py new file mode 100644 index 0000000..9647d04 --- /dev/null +++ b/mapgen/plateaus.py @@ -0,0 +1,179 @@ +"""Sunken plateaus: Kerguelen-type continental crust that never rose. Outline, +surface and volcanic field are functions of position and the plateau's config (deterministic per name), so the world +build, the seabed pass and the viewer agree at any resolution.""" +from __future__ import annotations + +import numpy as np + +from .graph import distance_to +from .noise import fbm, name_seed +from .sphere import east_north, gc_dist_km, great_circle_point, latlon_to_xyz, tangent_dir, xyz_to_latlon +from .zones import smootherstep + +EDGE_WARP = 0.15 # outline radius ± 15 % (fractal noise): bays and lobes, like a small continent +MARGIN_KM = 150.0 # the surface blends down to the surrounding sea floor over this distance inside the outline +RELIEF_M = 400.0 # internal relief (ridges, basins) +HIDDEN_MAX_M = -550.0 # hidden plateaus: every cell at least this deep (checked at −500 m after erosion's re-solve) +SITE_RULES = (("land", 400.0), ("a plate boundary", 150.0), ("the trench", 200.0)) # km from the outline + + +def semi_axes(p: dict): + """(long, short) semi-axes (km) of the plateau's ellipse: π·a·b = area_km2, a / b = elongation.""" + e = float(p.get("elongation", 1.0)) + b = float(np.sqrt(p["area_km2"] / (np.pi * e))) + return e * b, b + + +def _near(xyz, p, radius_km, pad_km=0.0): + a, _ = semi_axes(p) + lim = min(np.pi, (a * (1.0 + EDGE_WARP) / (1.0 - EDGE_WARP) + pad_km) / radius_km) + return xyz @ latlon_to_xyz(*p["center"]) > np.cos(lim) + + +def rho(xyz, p: dict, seed: int, radius_km: float): + """Normalised radius of points: 0 at the centre, < 1 inside the noise-warped elliptical outline.""" + xyz = np.asarray(xyz, dtype=np.float64) + c = latlon_to_xyz(*p["center"]) + e, n = east_north(c[None]) + ang = np.arccos(np.clip(xyz @ c, -1.0, 1.0)) * radius_km # km from the centre along the surface + t = tangent_dir(np.broadcast_to(c, xyz.shape), xyz) + x, y = ang * (t @ e[0]), ang * (t @ n[0]) # east, north (azimuthal equidistant) + az = np.radians(p.get("azimuth_deg", 0.0)) + along, across = x * np.sin(az) + y * np.cos(az), x * np.cos(az) - y * np.sin(az) + a, b = semi_axes(p) + r = np.sqrt((along / a) ** 2 + (across / b) ** 2) + w = np.clip(2.0 * fbm(xyz, name_seed(seed, p["name"]), 4, 25.0), -1.0, 1.0) + return r / (1.0 + EDGE_WARP * w) + + +def cell_ids(xyz, plateaus: list, seed: int, radius_km: float): + """Plateau index per point (−1 outside every plateau).""" + xyz = np.asarray(xyz, dtype=np.float64) + ids = np.full(len(xyz), -1, np.int16) + for k, p in enumerate(plateaus): + idx = np.flatnonzero(_near(xyz, p, radius_km)) + if len(idx): + ids[idx[rho(xyz[idx], p, seed, radius_km) < 1.0]] = k + return ids + + +def _place(rng, p, seed, radius_km, origin, max_km, inside=0.85, tries=30): + """A random point within max_km of origin inside the outline (rho < inside); None if none is found.""" + e, n = east_north(origin[None]) + for _ in range(tries): + az, dist = rng.uniform(0.0, 2.0 * np.pi), rng.uniform(0.0, max_km) + q = great_circle_point(origin, np.sin(az) * e[0] + np.cos(az) * n[0], dist, radius_km) + if rho(q[None], p, seed, radius_km)[0] < inside: + return q + return None + + +def _ll(q): + lat, lon = xyz_to_latlon(np.asarray(q, dtype=np.float64)) + return round(float(lat), 4), round(float(lon), 4) + + +def features(p: dict, seed: int, radius_km: float) -> dict: + """The plateau's volcanic field at world scale (deterministic per name): its own hotspot point, 6–20 cones and + 1–3 calderas (none when vent = 0); island plateaus lift 3–6 of the cones to +0.6…+1.5 km.""" + rng = np.random.default_rng(name_seed(seed, p["name"]) + 7) + c = latlon_to_xyz(*p["center"]) + _, b = semi_axes(p) + vent = float(p.get("vent", 1.0)) + hot = _place(rng, p, seed, radius_km, c, 0.3 * b) + hot = c if hot is None else hot + cones, calderas = [], [] + if vent > 0: + for _ in range(int(rng.integers(6, 21))): + q = _place(rng, p, seed, radius_km, hot, 0.6 * b) + r_km, h = float(rng.uniform(15.0, 40.0)), float(rng.uniform(500.0, 2000.0)) * vent + if q is not None: + lat, lon = _ll(q) + cones.append({"lat": lat, "lon": lon, "radius_km": r_km, "height_m": h, "island": False}) + for _ in range(int(rng.integers(1, 4))): + q = _place(rng, p, seed, radius_km, hot, 0.5 * b) + r_km = float(rng.uniform(20.0, 50.0)) + if q is not None: + lat, lon = _ll(q) + calderas.append({"lat": lat, "lon": lon, "radius_km": r_km, "rim_m": 300.0 * vent, + "floor_m": -400.0 * vent}) + if p.get("islands", False) and cones: + k = min(len(cones), int(rng.integers(3, 7))) + for i in rng.choice(len(cones), size=k, replace=False): + cones[int(i)].update(island=True, peak_m=float(rng.uniform(600.0, 1500.0)), + radius_km=float(rng.uniform(40.0, 70.0))) + return {"name": p["name"], "center": [float(v) for v in p["center"]], "hotspot": list(_ll(hot)), + "cones": cones, "calderas": calderas} + + +def surface(xyz, p: dict, feat: dict, seed: int, radius_km: float, z_floor): + """Heights of points inside the outline: the plateau top (depth across top_m by low-frequency noise, ± RELIEF_M) + plus its cones and calderas, blended down to z_floor over MARGIN_KM inside the edge.""" + xyz = np.asarray(xyz, dtype=np.float64) + s = name_seed(seed, p["name"]) + t = np.clip(0.5 + 1.5 * fbm(xyz, s + 1, 3, 8.0), 0.0, 1.0) + top0, top1 = p["top_m"] + z = -(top0 + (top1 - top0) * t) + RELIEF_M * np.clip(2.5 * fbm(xyz, s + 2, 5, 40.0), -1.0, 1.0) + for c in feat["cones"]: + if not c["island"]: + d = gc_dist_km(xyz, latlon_to_xyz(c["lat"], c["lon"]), radius_km) + z = z + c["height_m"] * np.clip(1.0 - d / c["radius_km"], 0.0, 1.0) ** 1.5 + for c in feat["calderas"]: + d = gc_dist_km(xyz, latlon_to_xyz(c["lat"], c["lon"]), radius_km) + r = c["radius_km"] + z = z + c["rim_m"] * np.exp(-((d - r) / (0.25 * r)) ** 2) + c["floor_m"] * (1.0 - smootherstep(d / r)) + _, b = semi_axes(p) + w = smootherstep((1.0 - rho(xyz, p, seed, radius_km)) * b / MARGIN_KM) + return np.asarray(z_floor, dtype=np.float64) + (z - z_floor) * w + + +def islands(xyz, feat: dict, radius_km: float, z): + """Island cones lift the ground to their peak_m (small volcanic islands); only raises.""" + z = np.asarray(z, dtype=np.float64) + for c in feat["cones"]: + if c["island"]: + d = gc_dist_km(np.asarray(xyz, dtype=np.float64), latlon_to_xyz(c["lat"], c["lon"]), radius_km) + f = np.clip(d / c["radius_km"], 0.0, 1.0) + z = np.where(d < c["radius_km"], np.maximum(z, c["peak_m"] - (c["peak_m"] - z) * f ** 1.2), z) + return z + + +def apply(g, z, plateaus: list, plateau_id, seed: int): + """Grid heights with the plateaus set (after the world's sea-level solve): surfaces and volcanic fields; hidden + plateaus clamped to HIDDEN_MAX_M; islands, each island's nearest cell raised to its peak (so every resolution + keeps it).""" + z = np.asarray(z, dtype=np.float64).copy() + R = g.radius_km + for k, p in enumerate(plateaus): + idx = np.flatnonzero(np.asarray(plateau_id) == k) + if len(idx) == 0: + continue + feat = features(p, seed, R) + zk = surface(g.xyz[idx], p, feat, seed, R, z[idx]) + if p.get("islands", False): + z[idx] = islands(g.xyz[idx], feat, R, zk) + for c in feat["cones"]: + if c["island"]: + i = g.cell_index(c["lat"], c["lon"]) + z[i] = max(z[i], c["peak_m"]) + else: + z[idx] = np.minimum(zk, HIDDEN_MAX_M) + return z + + +def site_report(g, data: dict, plateaus: list) -> list: + """Plateaus that no longer fit their site on this world, as warnings: outline ≥ 400 km from land, + ≥ 150 km from plate boundaries, ≥ 200 km from the sketch's trench. Reported, never moved.""" + ids = np.asarray(data["plateau_id"]) + masks = {"land": ~np.asarray(data["ocean"]) & (ids < 0), "a plate boundary": np.asarray(data["bnd_type"]) > 0, + "the trench": np.asarray(data.get("sk_trench", np.zeros(g.n))) > 0.5} + out = [] + for what, lim in SITE_RULES: + if not masks[what].any(): + continue + d = distance_to(g, masks[what]) + for k, p in enumerate(plateaus): + m = ids == k + if m.any() and float(d[m].min()) < lim: + out.append(f"plateau {p['name']}: {float(d[m].min()):.0f} km from {what} (rule ≥ {lim:.0f} km)") + return out diff --git a/mapgen/plates.py b/mapgen/plates.py new file mode 100644 index 0000000..af798bf --- /dev/null +++ b/mapgen/plates.py @@ -0,0 +1,86 @@ +"""Stage `plates`: grow plates from seeds, assign Euler motions, classify boundaries.""" +from __future__ import annotations + +import numpy as np +from scipy.spatial import cKDTree + +from .config import params +from .graph import nearest_source +from .grid import edge_blocks +from .noise import fbm +from .pipeline import StageError +from .sphere import motion_to_omega, velocity + +NONE, CONV, DIV, TRANS = 0, 1, 2, 3 +DEFAULTS = {"land_cost": 0.25, "noise": 0.35, "noise_freq": 3.0, "warp_km": 1000.0, "warp_freq": 1.5, + "min_rate_m_yr": 0.005} + + +def edge_convergence(g, vel): + """Per directed edge s→d: closing speed (m/yr, >0 converging) and tangential slip speed.""" + t, src, dst = g.edge_tangents, g.src, g.dst + along, tang = np.empty(len(dst)), np.empty(len(dst)) + for s in edge_blocks(len(dst)): # per edge: the same values, small temporaries + rel = vel[dst[s]] - vel[src[s]] + along[s] = np.sum(rel * t[s], axis=1) + tang[s] = np.linalg.norm(rel - along[s][:, None] * t[s], axis=1) + return -along, tang + + +def classify_boundaries(g, plate, vel, min_rate): + conv, tang = edge_convergence(g, vel) + s, d = g.src, g.dst + b = plate[s] != plate[d] + cnt = np.bincount(s[b], minlength=g.n) + c_mean = np.bincount(s[b], weights=conv[b], minlength=g.n) / np.maximum(cnt, 1) + t_mean = np.bincount(s[b], weights=tang[b], minlength=g.n) / np.maximum(cnt, 1) + other = np.full(g.n, -1, np.int16) + other[s[b]] = plate[d[b]] + typ = np.zeros(g.n, np.int8) + isb = cnt > 0 + typ[isb] = TRANS + typ[isb & (c_mean > min_rate)] = CONV + typ[isb & (c_mean < -min_rate)] = DIV + rate = np.where(typ == CONV, c_mean, np.where(typ == DIV, -c_mean, np.where(isb, t_mean, 0.0))) + return typ, rate.astype(np.float32), other + + +def _warp_labels(g, plate, seeds, P, seed, continental_kind): + """Domain warp: each cell takes the label found at a noise-displaced point → meandering boundaries. + Only same-kind swaps (cont↔cont, ocean↔ocean): ocean–continent margins stay where growth put them.""" + if P["warp_km"] <= 0: + return plate + amp = P["warp_km"] / g.radius_km + disp = np.stack([fbm(g.xyz, seed + 101 + k, 3, P["warp_freq"]) for k in range(3)], axis=1) + disp /= max(float(disp.std()), 1e-12) # warp_km = RMS displacement per component + q = g.xyz + amp * disp + q /= np.linalg.norm(q, axis=1, keepdims=True) + _, j = cKDTree(g.xyz).query(q) + out = plate[j].astype(np.int16) + keep = continental_kind[out] != continental_kind[plate] + out[keep] = plate[keep] + out[seeds] = np.arange(len(seeds), dtype=np.int16) + return out + + +def run(ctx) -> dict: + g = ctx.grid + P = params(ctx.cfg, "plates", DEFAULTS) + land, land_hint = ctx.need("sk_land", "m_land_hint") + plates = ctx.tect["plate"] + seeds = np.array([g.cell_index(*p["seed"]) for p in plates], dtype=np.int64) + if len(np.unique(seeds)) < len(seeds): + raise StageError("two plate seeds fall in the same cell; move one in tectonics.toml") + hint = np.clip(land + 0.5 * land_hint, 0.0, 1.0) + nz = fbm(g.xyz, ctx.seed + 11, octaves=4, freq=P["noise_freq"]) + mult = np.where(hint[g.dst] > 0.5, P["land_cost"], 1.0) * (1.0 + P["noise"] * nz[g.dst]) + _, src = nearest_source(g, seeds, g.edge_km * np.maximum(mult, 0.05)) + order = np.argsort(seeds) + plate = order[np.searchsorted(seeds[order], src)].astype(np.int16) + kinds = np.array([p["kind"] == "continental" for p in plates]) + plate = _warp_labels(g, plate, seeds, P, ctx.seed, kinds) + omegas = np.array([motion_to_omega(*p["seed"], *p["motion"], g.radius_km) for p in plates]) + vel = velocity(g.xyz, omegas[plate], g.radius_km) + btype, brate, bother = classify_boundaries(g, plate, vel, P["min_rate_m_yr"]) + return {"plate": plate, "vel": vel, "plate_continental": kinds, + "bnd_type": btype, "bnd_rate": brate, "bnd_other": bother} diff --git a/mapgen/projections.py b/mapgen/projections.py new file mode 100644 index 0000000..67efb19 --- /dev/null +++ b/mapgen/projections.py @@ -0,0 +1,150 @@ +"""Map projections of the equirectangular relief: Equal Earth, Mollweide (equal-area) and orthographic globes.""" +from __future__ import annotations + +import numpy as np +from PIL import Image, ImageDraw + +from .sketch import sample_equirect +from .sphere import east_north, latlon_to_xyz, xyz_to_latlon + +BACKGROUND = (16, 18, 24) +_A1, _A2, _A3, _A4 = 1.340264, -0.081106, 0.000893, 0.003796 +_M = np.sqrt(3.0) / 2.0 +EE_XMAX = 2.0 * np.sqrt(3.0) * np.pi / (3.0 * _A1) +EE_YMAX = _A1 * np.pi / 3 + _A2 * (np.pi / 3) ** 3 + _A3 * (np.pi / 3) ** 7 + _A4 * (np.pi / 3) ** 9 +MW_XMAX, MW_YMAX = 2.0 * np.sqrt(2.0), np.sqrt(2.0) + + +def equal_earth_forward(lat, lon): + t = np.arcsin(_M * np.sin(np.radians(lat))) + d = _A1 + 3 * _A2 * t**2 + 7 * _A3 * t**6 + 9 * _A4 * t**8 + x = 2 * np.sqrt(3.0) * np.radians(lon) * np.cos(t) / (3 * d) + y = _A1 * t + _A2 * t**3 + _A3 * t**7 + _A4 * t**9 + return x, y + + +def equal_earth_inverse(x, y): + t = np.asarray(y, dtype=np.float64) / _A1 + for _ in range(12): + f = _A1 * t + _A2 * t**3 + _A3 * t**7 + _A4 * t**9 - y + t = t - f / (_A1 + 3 * _A2 * t**2 + 7 * _A3 * t**6 + 9 * _A4 * t**8) + d = _A1 + 3 * _A2 * t**2 + 7 * _A3 * t**6 + 9 * _A4 * t**8 + lat = np.degrees(np.arcsin(np.clip(np.sin(t) / _M, -1, 1))) + lon = np.degrees(3 * np.asarray(x) * d / (2 * np.sqrt(3.0) * np.cos(t))) + return lat, lon + + +def mollweide_forward(lat, lon): + phi = np.radians(lat) + t = phi.copy() if isinstance(phi, np.ndarray) else np.array(phi, dtype=np.float64) + for _ in range(30): + f = 2 * t + np.sin(2 * t) - np.pi * np.sin(phi) + t = t - f / np.maximum(2 + 2 * np.cos(2 * t), 1e-12) + return MW_XMAX / np.pi * np.radians(lon) * np.cos(t), MW_YMAX * np.sin(t) + + +def mollweide_inverse(x, y): + t = np.arcsin(np.clip(np.asarray(y) / MW_YMAX, -1, 1)) + lat = np.degrees(np.arcsin(np.clip((2 * t + np.sin(2 * t)) / np.pi, -1, 1))) + lon = np.degrees(np.pi * np.asarray(x) / (MW_XMAX * np.maximum(np.cos(t), 1e-12))) + return lat, lon + + +def orthographic_inverse(x, y, lat0, lon0): + """Unit-disc view coords → lat/lon on the visible hemisphere centred on (lat0, lon0); ok = inside the disc.""" + x, y = np.asarray(x, dtype=np.float64), np.asarray(y, dtype=np.float64) + rho2 = x**2 + y**2 + ok = rho2 <= 1.0 + z = np.sqrt(np.clip(1.0 - rho2, 0.0, 1.0)) + c = latlon_to_xyz(np.array([lat0]), np.array([lon0])) + e, n = east_north(c) + p = x[..., None] * e[0] + y[..., None] * n[0] + z[..., None] * c[0] + lat, lon = xyz_to_latlon(p) + return lat, lon, ok + + +def _sample_rgb(img, lat, lon): + """Bilinear RGB samples, read straight from the uint8 channels (each sampled value promotes to float64 exactly, + so no full-size float copy of a big image is needed).""" + return np.stack([sample_equirect(img[..., k], lat, lon) for k in range(3)], axis=-1) + + +def reproject(img, proj, width): + """Equirectangular RGB → equal-area world map ('equal_earth' | 'mollweide').""" + xmax, ymax, inv = {"equal_earth": (EE_XMAX, EE_YMAX, equal_earth_inverse), + "mollweide": (MW_XMAX, MW_YMAX, mollweide_inverse)}[proj] + height = int(round(width * ymax / xmax)) + xs = ((np.arange(width) + 0.5) / width * 2 - 1) * xmax + ys = (1 - (np.arange(height) + 0.5) / height * 2) * ymax + X, Y = np.meshgrid(xs, ys) + lat, lon = inv(X, Y) + ok = np.isfinite(lat) & np.isfinite(lon) & (np.abs(lon) <= 180.0) + rgb = _sample_rgb(img, np.where(ok, lat, 0.0), np.where(ok, lon, 0.0)) + out = np.where(ok[..., None], rgb, np.array(BACKGROUND, dtype=np.float64)) + return np.clip(np.round(out), 0, 255).astype(np.uint8) + + +def globe(img, lat0, lon0, size): + """Orthographic view centred on (lat0, lon0), gentle limb darkening.""" + v = (np.arange(size) + 0.5) / size * 2 - 1 + X, Y = np.meshgrid(v, -v) + lat, lon, ok = orthographic_inverse(X, Y, lat0, lon0) + rgb = _sample_rgb(img, lat, lon) * (0.72 + 0.28 * np.sqrt(np.clip(1 - X**2 - Y**2, 0, 1)))[..., None] + out = np.where(ok[..., None], rgb, np.array(BACKGROUND, dtype=np.float64)) + return np.clip(np.round(out), 0, 255).astype(np.uint8) + + +def graticule(arr, proj, step=30): + """Draw a light lat/lon grid (every `step`°) onto an equal-area world map produced by `reproject`.""" + fwd, xmax, ymax = {"equal_earth": (equal_earth_forward, EE_XMAX, EE_YMAX), + "mollweide": (mollweide_forward, MW_XMAX, MW_YMAX)}[proj] + h, w = arr.shape[:2] + im = Image.fromarray(arr) + draw = ImageDraw.Draw(im) + to_px = lambda x, y: list(zip(((x / xmax + 1) / 2 * w).tolist(), ((1 - y / ymax) / 2 * h).tolist())) + t = np.linspace(-90, 90, 181) + for lon in range(-180, 181, step): + draw.line(to_px(*fwd(t, np.full_like(t, float(lon)))), fill=(200, 210, 225), width=1) + s = np.linspace(-180, 180, 361) + for lat in range(-90 + step, 90, step): + draw.line(to_px(*fwd(np.full_like(s, float(lat)), s)), fill=(200, 210, 225), width=1) + return np.array(im) + + +def continent_centres(g, ocean, min_share=0.03): + """Centroids (lat, lon) of land bodies holding ≥ min_share of all land, largest first.""" + from .graph import components + land = ~np.asarray(ocean) + lab = components(g, land) + area = np.bincount(lab[land], weights=g.area_km2[land]) + out = [] + for k in np.argsort(-area): + if area[k] < min_share * area.sum(): + break + m = lab == k + c = (g.xyz[m] * g.area_km2[m, None]).sum(axis=0) + la, lo = xyz_to_latlon(c[None]) + out.append((float(la[0]), float(lo[0]))) + return out + + +def write_all(relief, pdir, g, ocean, width, globe_size=1024): + """Write proj_equal_earth.png, proj_mollweide.png, globe_*.png and globes_sheet.png into pdir.""" + for proj in ("equal_earth", "mollweide"): + Image.fromarray(graticule(reproject(relief, proj, width), proj)).save(pdir / f"proj_{proj}.png") + views = [("north_pole", 90.0, 0.0), ("south_pole", -90.0, 0.0)] + views = [(f"continent_{i + 1}", la, lo) for i, (la, lo) in enumerate(continent_centres(g, ocean))] + views + tiles = [] + for name, la, lo in views: + im = Image.fromarray(globe(relief, la, lo, globe_size)) + im.save(pdir / f"globe_{name}.png") + tiles.append((f"{name} ({la:.0f}°, {lo:.0f}°)", im)) + cols, t = 4, globe_size // 2 + rows = (len(tiles) + cols - 1) // cols + sheet = Image.new("RGB", (cols * t, rows * (t + 16)), BACKGROUND) + draw = ImageDraw.Draw(sheet) + for k, (label, im) in enumerate(tiles): + x, y = (k % cols) * t, (k // cols) * (t + 16) + sheet.paste(im.resize((t, t)), (x, y + 16)) + draw.text((x + 4, y + 2), label, fill=(230, 230, 230)) + sheet.save(pdir / "globes_sheet.png") diff --git a/mapgen/render.py b/mapgen/render.py new file mode 100644 index 0000000..2a408e6 --- /dev/null +++ b/mapgen/render.py @@ -0,0 +1,526 @@ +"""Stage `render`: equirectangular rasters, cells.npz, metadata, previews, contact sheet.""" +from __future__ import annotations + +import colorsys +import json + +import numpy as np +from PIL import Image, ImageDraw +from scipy.spatial import cKDTree + +from . import geo, projections, viewer_export +from . import plateaus as PL +from .crust import AGE_NAMES +from .environment import LEGENDS, REGIONS, ZONES +from .ice import ICE_NAMES +from .noise import fbm +from .seabed import MINERAL_NAMES, SEABED_NAMES +from .sphere import east_north, latlon_to_xyz + +CONTINUOUS = { + "elevation": ("z_surface_m", 0.5, -12000.0, "m"), + "T_mean": ("T_mean", 0.01, -100.0, "degC"), + "T_range": ("T_range", 0.01, 0.0, "degC"), + "P_ann": ("P_ann", 0.5, 0.0, "mm/yr"), + "P_jun": ("P_jun", 0.5, 0.0, "mm/yr"), + "P_dec": ("P_dec", 0.5, 0.0, "mm/yr"), + "po2": ("po2_bar", 2e-4, 0.0, "bar"), + "gravity": ("gravity_g", 1e-4, 0.0, "g"), + "pressure": ("pressure_bar", 2e-4, 0.0, "bar"), + "o2_fraction": ("o2_fraction", 2e-5, 0.0, "fraction"), + "fire": ("fire_reactivity", 1e-4, 0.0, "x"), + "vent_potential": ("vent_potential", 1e-4, 0.0, "0-1"), + "bottom_temp": ("bottom_temp_c", 0.01, -10.0, "degC"), + "sediment": ("sediment_m", 0.5, 0.0, "m"), + "plant_height": ("plant_height_x", 2e-3, 0.0, "x"), + "sst": ("sst", 0.01, -100.0, "degC"), + "productivity": ("productivity", 1e-4, 0.0, "0-1"), + "current_speed": ("current_speed", 1e-4, 0.0, "m/s"), + "upwelling": ("upwelling", 0.05, -1600.0, "m/yr"), +} +CATEGORICAL = {"plates": "plate", "age_class": "age_class", "holdridge": "holdridge", + "seasonality": "seasonality", "landform": "landform", "lithology": "lithology", + "ground": "ground", "ice": "ice", + "seabed_type": "seabed_type", "seabed_mineral": "seabed_mineral", "deposits": "deposit_main"} +DETAIL_M = np.array([60, 40, 150, 400, 80, 150, 300, 300, 300, 60, 30, 120], dtype=np.float64) # by landform +CHUNK = 128 +COAST_DETAIL_M = 250.0 + + +def _by_rows(fn, v, dtype, tail=()): + """fn applied to CHUNK-row slices of v (fn elementwise): the same values as fn(v), without fn's full-size + float64 scratch (a raster ramp builds five H×W×3 float64 temporaries: ~4 GB at 8192 px).""" + v = np.asarray(v) + if v.ndim < 2 or v.shape[0] <= CHUNK: + return fn(v) + out = np.empty(v.shape + tuple(tail), dtype) + for y0 in range(0, v.shape[0], CHUNK): + out[y0:y0 + CHUNK] = fn(v[y0:y0 + CHUNK]) + return out + + +def encode(v, scale, offset): + return _by_rows(lambda x: np.clip(np.round((np.asarray(x, dtype=np.float64) - offset) / scale), 0, 65535) + .astype(np.uint16), v, np.uint16) + + +def decode(raw, scale, offset): + return np.asarray(raw, dtype=np.float64) * scale + offset + + +def _rows_xyz(rows, W, H): + lat = 90.0 - (rows + 0.5) / H * 180.0 + lon = (np.arange(W) + 0.5) / W * 360.0 - 180.0 + LA, LO = np.meshgrid(lat, lon, indexing="ij") + return latlon_to_xyz(LA.ravel(), LO.ravel()) + + +def _make_render_jit(): + """Compiled ramp and sampling loops (numba optional; WORLDGEN_NO_JIT=1 turns it off): per pixel the same float + steps in the same order as the numpy statements they replace, so the same bytes, without the temporaries.""" + import os + if os.environ.get("WORLDGEN_NO_JIT"): + return None + try: + import numba + except ImportError: + return None + + @numba.njit(cache=True, nogil=True) + def ramp(x, lo, span, stops, out): # x: float64 (n,), out: uint8 (n, c) + m = stops.shape[0] + for j in range(x.shape[0]): + t = (x[j] - lo) / span + t = 0.0 if t < 0.0 else (1.0 if t > 1.0 else t) + t = t * (m - 1) + i = min(np.int64(t), m - 2) + f = t - i + for c in range(stops.shape[1]): + out[j, c] = np.uint8(np.int64(stops[i, c] * (1 - f) + stops[i + 1, c] * f)) + + @numba.njit(cache=True, nogil=True) + def sample(v, idx, wts, out): # out[p] = Σ_k v[idx[p,k]] * w[p,k], k left to right (as np.sum, k < 8) + for p in range(idx.shape[0]): + s = v[idx[p, 0]] * np.float64(wts[p, 0]) + for k in range(1, idx.shape[1]): + s += v[idx[p, k]] * np.float64(wts[p, k]) + out[p] = s + return ramp, sample + + +_render_jit = _make_render_jit() + + +def pixel_neighbours(g, W, H, k=3): + from .graph import workers + tree = cKDTree(g.xyz) + idx = np.empty((H, W, k), np.int32) + wts = np.empty((H, W, k), np.float32) + for y0 in range(0, H, CHUNK): + rows = np.arange(y0, min(H, y0 + CHUNK)) + d, i = tree.query(_rows_xyz(rows, W, H), k=k, workers=workers()) # per point: the same answer + w = 1.0 / np.maximum(d, 1e-9) ** 2 + w /= w.sum(axis=1, keepdims=True) + idx[rows] = i.reshape(len(rows), W, k) + wts[rows] = w.reshape(len(rows), W, k) + return idx, wts + + +def sample_cont(v, idx, wts): + out = np.empty(idx.shape[:2]) + if (_render_jit is not None and v.dtype == np.float64 and v.ndim == 1 and wts.dtype == np.float32 + and 1 <= idx.shape[-1] < 8 and idx.shape == wts.shape): + k = idx.shape[-1] + _render_jit[1](np.ascontiguousarray(v), np.ascontiguousarray(idx).reshape(-1, k), + np.ascontiguousarray(wts).reshape(-1, k), out.reshape(-1)) + return out + for y0 in range(0, idx.shape[0], CHUNK): + s = slice(y0, y0 + CHUNK) + out[s] = np.sum(v[idx[s]] * wts[s], axis=-1) + return out + + +def sample_cat(v, idx): + return v[idx[..., 0]] + + +def pixel_land(ocean_k, z): + """Pixel land mask: unanimous neighbour cells decide; at the coast (mixed) the sub-cell height does.""" + all_sea = ocean_k.all(axis=-1) + all_land = ~ocean_k.any(axis=-1) + return all_land | (~all_sea & ~all_land & (z > 0)) + + +def hillshade(z, radius_km, az=315.0, alt=45.0, exag=4.0, low_memory=False): + """low_memory: the same values, CHUNK rows at a time (one halo row each side keeps the central differences).""" + if low_memory: + H = z.shape[0] + out = np.empty(z.shape) + for y0 in range(0, H, CHUNK): + a, b = max(y0 - 1, 0), min(y0 + CHUNK + 1, H) + part = _hillshade_rows(z[a:b], a, H, radius_km, az, alt, exag) + out[y0:min(y0 + CHUNK, H)] = part[y0 - a: y0 - a + min(CHUNK, H - y0)] + return out + return _hillshade_rows(z, 0, z.shape[0], radius_km, az, alt, exag) + + +def _hillshade_rows(z, row0, H, radius_km, az, alt, exag): + """Hillshade of rows row0.. of an H-row raster (z holds those rows; edges of z use one-sided differences).""" + W = z.shape[1] + lat = 90.0 - (np.arange(row0, row0 + z.shape[0]) + 0.5) / H * 180.0 + dy = np.pi * radius_km * 1000.0 / H + dx = 2 * np.pi * radius_km * 1000.0 * np.maximum(np.cos(np.radians(lat)), 0.01) / W + gy, gx = np.gradient(z) + dzdx = gx / dx[:, None] * exag + dzdn = -gy / dy * exag + norm = np.sqrt(dzdx**2 + dzdn**2 + 1.0) + a, b = np.radians(az), np.radians(alt) + L = (np.sin(a) * np.cos(b), np.cos(a) * np.cos(b), np.sin(b)) + return np.clip((-dzdx * L[0] - dzdn * L[1] + L[2]) / norm, 0.0, 1.0) + + +def _ramp(v, lo, hi, stops): + stops = np.asarray(stops, dtype=np.float64) + + def part(x): + if (_render_jit is not None and isinstance(x, np.ndarray) and x.dtype == np.float64 and stops.ndim == 2 + and len(stops) >= 2 and not np.isnan(x).any()): + out = np.empty(x.shape + (stops.shape[1],), np.uint8) + _render_jit[0](np.ascontiguousarray(x).reshape(-1), float(lo), float(hi - lo), stops, + out.reshape(-1, stops.shape[1])) + return out + t = np.clip((x - lo) / (hi - lo), 0, 1) * (len(stops) - 1) + i = np.minimum(t.astype(np.int64), len(stops) - 2) + f = (t - i)[..., None] + return (stops[i] * (1 - f) + stops[i + 1] * f).astype(np.uint8) + return _by_rows(part, v, np.uint8, (stops.shape[-1],)) + + +def holdridge_palette(): + ramp = np.array([[216, 200, 160], [208, 196, 140], [200, 200, 120], [152, 168, 96], [106, 150, 80], + [70, 125, 68], [50, 105, 62], [37, 90, 56]], dtype=np.float64) + tint = {"polar": ((232, 236, 239), 0.9), "subpolar": ((170, 176, 160), 0.55), "boreal": ((70, 100, 80), 0.35), + "tropical": ((20, 90, 40), 0.15)} + cols = [] + for r in REGIONS: + k = len(ZONES[r]) + for j in range(k): + c = ramp[int(round((j / max(k - 1, 1)) * (len(ramp) - 1)))] + if r in tint: + c = c * (1 - tint[r][1]) + np.array(tint[r][0]) * tint[r][1] + cols.append(c) + return np.array(cols, dtype=np.uint8) + + +def category_palette(n): + return np.array([[int(255 * c) for c in colorsys.hsv_to_rgb((i * 0.618034) % 1.0, 0.55, 0.9)] + for i in range(max(n, 1))], dtype=np.uint8) + + +def _region_palette(cols): + """38 zone colours from per-region (dry, wet) colour pairs.""" + out = [] + for r in REGIONS: + dry, wet = (np.array(c, dtype=np.float64) for c in cols[r]) + k = len(ZONES[r]) + out += [dry + (wet - dry) * (j / max(k - 1, 1)) for j in range(k)] + return np.array(out, dtype=np.uint8) + + +STYLES = { # alien palettes (config [render] style); None = Earth-like + "tidal-lock": { + "zones": {"polar": ((34, 38, 46), (34, 38, 46)), "subpolar": ((70, 72, 78), (96, 104, 112)), + "boreal": ((120, 96, 64), (150, 110, 60)), "cool temperate": ((170, 120, 60), (190, 140, 70)), + "warm temperate": ((110, 70, 44), (130, 86, 50)), "subtropical": ((46, 36, 34), (60, 44, 38)), + "tropical": ((22, 20, 22), (30, 26, 28))}, + "ocean": [[4, 12, 16], [10, 34, 40], [40, 80, 84]], "lake": (60, 96, 104), "river": (70, 110, 118), + "ground": {6: (92, 86, 80)}, "sheet": (200, 222, 240), "sea_ice": (170, 200, 226), "sea_ice_seasonal": (120, 150, 170)}, + "salt-mirror": { + "zones": {"polar": ((236, 240, 244), (236, 240, 244)), "subpolar": ((228, 228, 234), (214, 214, 228)), + "boreal": ((238, 236, 228), (208, 208, 224)), "cool temperate": ((242, 238, 226), (200, 204, 222)), + "warm temperate": ((240, 230, 204), (196, 204, 220)), "subtropical": ((238, 224, 186), (192, 206, 218)), + "tropical": ((234, 214, 166), (186, 206, 216))}, + "ocean": [[70, 120, 130], [150, 195, 200], [215, 235, 235]], "lake": (150, 196, 204), "river": (150, 196, 204), + "ground": {6: (252, 250, 244)}, "sheet": (248, 250, 252), "sea_ice": (236, 244, 246), "sea_ice_seasonal": (220, 236, 238)}, +} + + +def style_palette(style): + return None if style is None else STYLES[style] + + +def relief_rgb(z, hs, zone, ground, ice, lake, land=None, vary=None, style=None, low_memory=False): + """vary: optional (brightness factor, tint) per pixel for open land (not lakes or ice): tint > 0 drier/yellower, + < 0 lusher (deeper green). style: a STYLES key (alien palette) or None. low_memory: the same pixels, made + CHUNK rows at a time (no full-size float64 scratch).""" + if low_memory: + out = np.empty(np.shape(z) + (3,), np.uint8) + for y0 in range(0, np.shape(z)[0], CHUNK): + s = slice(y0, y0 + CHUNK) + out[s] = relief_rgb(z[s], hs[s], zone[s], ground[s], ice[s], lake[s], None if land is None else land[s], + None if vary is None else tuple(np.asarray(v)[s] for v in vary), style) + return out + land = z > 0 if land is None else land + st = style_palette(style) + pal = holdridge_palette() if st is None else _region_palette(st["zones"]) + rgb = pal[np.clip(zone, 0, 37)].astype(np.float64) + ocean = _ramp(z, -6500.0 if st is None else -1500.0, 0.0, + [[11, 43, 90], [30, 90, 150], [143, 198, 224]] if st is None else st["ocean"]).astype(np.float64) + rgb = np.where(land[..., None], rgb, ocean) + grounds = ((1, (111, 143, 106)), (2, (120, 128, 100)), (5, (63, 111, 74)), (6, (239, 233, 220))) if st is None \ + else tuple(st["ground"].items()) + for code, col in grounds: + rgb = np.where((land & (ground == code))[..., None], col, rgb) + if vary is not None: + bright, tint = (np.asarray(v, dtype=np.float64)[..., None] for v in vary) + shift = np.where(tint > 0, tint * np.array([1.0, 0.6, -0.8]), -tint * np.array([-0.9, -0.2, -0.6])) + if st is not None: + shift = np.abs(tint) * np.array([0.4, 0.4, 0.4]) * np.sign(tint) + rgb = np.where(land[..., None], rgb * bright + shift, rgb) + rgb = np.where((lake & land)[..., None], (79, 143, 192) if st is None else st["lake"], rgb) + rgb = np.where(np.isin(ice, [1, 2])[..., None], (244, 248, 251) if st is None else st["sheet"], rgb) + rgb = np.where((ice == 4)[..., None], (225, 235, 242) if st is None else st["sea_ice"], rgb) + rgb = np.where((ice == 3)[..., None], 0.5 * rgb + 0.5 * np.array([220, 232, 240] if st is None else st["sea_ice_seasonal"]), rgb) + shade = np.where(land, 0.55 + 0.45 * hs, 0.85 + 0.15 * hs) + return np.clip(rgb * shade[..., None], 0, 255).astype(np.uint8) + + +RIVER_RGB = (58, 112, 176) + + +def draw_rivers(rgb, g, recv, river, strahler, min_order=2, colour=RIVER_RGB): + """Draw river segments (cell centre → receiver) of order ≥ min_order; width grows with order.""" + H, W = rgb.shape[:2] + im = Image.fromarray(rgb) + draw = ImageDraw.Draw(im) + x = (np.asarray(g.lon) + 180.0) / 360.0 * W - 0.5 + y = (90.0 - np.asarray(g.lat)) / 180.0 * H - 0.5 + cells = np.flatnonzero(river & (recv != np.arange(g.n)) & (strahler >= min_order)) + for i in cells[np.argsort(strahler[cells])]: + j = recv[i] + if abs(x[i] - x[j]) > W / 2: + continue + width = max(1, int(round(int(strahler[i]) * W / 8192))) + draw.line([(x[i], y[i]), (x[j], y[j])], fill=tuple(colour), width=width) + return np.array(im) + + +def draw_currents(rgb, g, current, ocean, per_row=60, colour=(255, 255, 255)): + """Arrows along the surface current on a lattice ≈ `per_row` across the map; length ∝ speed (1 m/s ≈ one + lattice step), sea only; currents under 2 cm/s get none.""" + H, W = rgb.shape[:2] + step = W / per_row + current = np.asarray(current, dtype=np.float64) + ocean = np.asarray(ocean, bool) + e, n = east_north(g.xyz) + tree = cKDTree(g.xyz) + ys, xs = np.meshgrid(np.arange(step / 2, H, step), np.arange(step / 2, W, step), indexing="ij") + lat, lon = 90.0 - (ys.ravel() + 0.5) / H * 180.0, (xs.ravel() + 0.5) / W * 360.0 - 180.0 + _, cell = tree.query(latlon_to_xyz(lat, lon)) + ue, un = np.sum(current[cell] * e[cell], axis=1), np.sum(current[cell] * n[cell], axis=1) + sp = np.hypot(ue, un) + im = Image.fromarray(rgb) + draw = ImageDraw.Draw(im) + for x, y, a, b, s, ok in zip(xs.ravel(), ys.ravel(), ue, un, sp, ocean[cell]): + if not ok or s < 0.02: + continue + L = min(s, 1.5) * 0.9 * step + dx, dy = a / s * L, -b / s * L + x0, y0, x1, y1 = x - dx / 2, y - dy / 2, x + dx / 2, y + dy / 2 + draw.line([(x0, y0), (x1, y1)], fill=tuple(colour), width=1) + for ang in (2.6, -2.6): # arrowhead: two barbs ±150° + c, sn = np.cos(ang), np.sin(ang) + draw.line([(x1, y1), (x1 + 0.35 * (c * dx - sn * dy), y1 + 0.35 * (sn * dx + c * dy))], + fill=tuple(colour), width=1) + return np.array(im) + + +RAMPS = { # key: (unit, lo, hi, colour stops) — shared by previews, viewer textures and legends + "elevation": ("m", -6000.0, 6000.0, [[8, 30, 70], [30, 90, 150], [140, 200, 225], [60, 120, 60], + [150, 160, 90], [140, 110, 80], [235, 235, 235]]), + "T_mean": ("degC", -40.0, 40.0, [[40, 60, 160], [240, 240, 240], [180, 30, 30]]), + "T_range": ("degC", 0.0, 60.0, [[68, 1, 84], [33, 145, 140], [253, 231, 37]]), + "P": ("mm/yr", 0.0, 4000.0, [[150, 110, 60], [230, 220, 150], [60, 150, 70], [30, 70, 170]]), + "po2": ("bar", 0.08, 0.32, [[60, 40, 90], [240, 240, 240], [200, 90, 20]]), + "gravity": ("g", 0.3, 1.8, [[20, 120, 180], [240, 240, 240], [120, 40, 40]]), + "pressure": ("bar", 0.3, 2.5, [[40, 60, 120], [240, 240, 240], [150, 60, 30]]), + "o2_fraction": ("fraction", 0.10, 0.40, [[60, 40, 90], [240, 240, 240], [200, 90, 20]]), + "fire": ("x", 0.0, 1.5, [[40, 80, 160], [240, 240, 240], [220, 120, 20]]), + "vent_potential": ("0-1", 0.0, 1.0, [[10, 20, 50], [60, 90, 160], [250, 190, 60], [255, 250, 220]]), + "bottom_temp": ("degC", -2.0, 30.0, [[30, 40, 120], [80, 160, 200], [240, 200, 120]]), + "sediment": ("m", 0.0, 3000.0, [[40, 30, 60], [140, 110, 80], [240, 220, 170]]), + "plant_height": ("x", 0.5, 3.5, [[150, 120, 60], [240, 240, 240], [30, 110, 50]]), + "sst": ("degC", -2.0, 32.0, [[30, 40, 120], [60, 150, 200], [240, 240, 200], [220, 90, 40]]), + "productivity": ("0-1", 0.0, 1.0, [[10, 20, 60], [20, 110, 120], [120, 200, 90], [240, 240, 120]]), + "current_speed": ("m/s", 0.0, 1.0, [[10, 20, 50], [40, 90, 170], [120, 200, 230], [255, 255, 255]]), + "upwelling": ("m/yr", -200.0, 200.0, [[40, 60, 160], [240, 240, 240], [30, 140, 80]]), +} +VIEWER_LAYERS = [ # id, name, source (continuous raster name | categorical name | "relief") + ("relief", "Relief", "relief"), ("biomes", "Biomes (Holdridge)", "holdridge"), + ("elevation", "Elevation", "elevation"), ("temperature", "Mean temperature", "T_mean"), + ("rainfall", "Rainfall", "P_ann"), ("seasonality", "Seasonality", "seasonality"), + ("landform", "Landform", "landform"), ("ground", "Ground", "ground"), ("ice", "Ice", "ice"), ("deposits", "Mineral deposits", "deposits"), + ("plates", "Plates", "plates"), ("o2", "O₂ partial pressure", "po2"), ("gravity", "Gravity", "gravity"), + ("pressure", "Air pressure", "pressure"), ("fire", "Fire reactivity", "fire"), + ("seabed", "Sea-floor type", "seabed_type"), ("minerals", "Sea-floor minerals", "seabed_mineral"), + ("bottom_temp", "Bottom temperature", "bottom_temp"), ("sediment", "Sediment", "sediment"), + ("vent_potential", "Vent potential", "vent_potential"), + ("currents", "Ocean currents", "current_speed"), ("sst", "Sea-surface temperature", "sst"), + ("productivity", "Sea productivity", "productivity"), +] + + +def _ramp_key(name): + return "P" if name.startswith("P_") else name + + +def _colorize(name, v): + _, lo, hi, stops = RAMPS[_ramp_key(name)] + return _ramp(v, lo, hi, stops) + + +def _preview(img: Image.Image, width: int, nearest: bool) -> Image.Image: + return img.resize((width, width // 2), Image.NEAREST if nearest else Image.LANCZOS) + + +def contact_sheet(tiles, tile_w): + cols = 3 + th = tile_w // 2 + 14 + rows = (len(tiles) + cols - 1) // cols + sheet = Image.new("RGB", (cols * tile_w, rows * th), (20, 20, 24)) + draw = ImageDraw.Draw(sheet) + for k, (name, im) in enumerate(tiles): + x, y = (k % cols) * tile_w, (k // cols) * th + sheet.paste(im.resize((tile_w, tile_w // 2)), (x, y + 14)) + draw.text((x + 4, y + 1), name, fill=(235, 235, 235)) + return sheet + + +class _Writer: + """Saves files on a background thread (PNG/zlib encoding releases the GIL) while the next layer is computed; + at most `depth` waiting, so memory stays bounded. The files are the same bytes as saved in line.""" + def __init__(self, threads: int = 2, depth: int = 4): + from concurrent.futures import ThreadPoolExecutor + self.ex, self.pending, self.depth = ThreadPoolExecutor(threads), [], depth + + def __call__(self, fn, *args, **kw): + while len(self.pending) >= self.depth: + self.pending.pop(0).result() + self.pending.append(self.ex.submit(fn, *args, **kw)) + + def close(self): + try: + for f in self.pending: + f.result() + finally: + self.ex.shutdown(wait=True, cancel_futures=True) + + +def run(ctx) -> dict: + writer = _Writer() + try: + return _run(ctx, writer) + finally: + writer.close() + + +def _run(ctx, save) -> dict: + g, cfg, d = ctx.grid, ctx.cfg, ctx.data + W = int(cfg["build"]["raster_width"]) + if ctx.res < int(cfg["build"]["res_final"]): + W = int(cfg["build"].get("dev_raster_width", W)) + H = W // 2 + pw = int(cfg["build"]["preview_width"]) + out = ctx.out_dir or ctx.root / "out" / f"r{ctx.res}" + rdir = out / "raster" + pdir = ctx.preview_dir or ctx.root / "previews" / f"r{ctx.res}" + rdir.mkdir(parents=True, exist_ok=True) + pdir.mkdir(parents=True, exist_ok=True) + idx, wts = pixel_neighbours(g, W, H) + legends = {**LEGENDS, "age_class": AGE_NAMES, "ice": ICE_NAMES, "plates": [p["id"] for p in ctx.tect["plate"]], + "seabed_type": SEABED_NAMES, "seabed_mineral": MINERAL_NAMES} + meta = {"width": W, "height": H, "projection": "equirectangular", "radius_km": g.radius_km, + "units": cfg.get("units", {}), "continuous": {}, "categorical": {}} + tiles = [] + cats = {name: sample_cat(np.asarray(d[key]), idx) for name, key in CATEGORICAL.items()} + + z = sample_cont(np.asarray(d["z_surface_m"], dtype=np.float64), idx, wts) + amp = DETAIL_M[np.clip(cats["landform"], 0, len(DETAIL_M) - 1)] + for y0 in range(0, H, CHUNK): + rows = np.arange(y0, min(H, y0 + CHUNK)) + z[rows] += amp[rows] * fbm(_rows_xyz(rows, W, H), ctx.seed + 61, 5, 64.0).reshape(len(rows), W) + lake = sample_cat(np.asarray(d["lake"]), idx) + ocean_cells = np.asarray(d["ocean"]) if "ocean" in d else np.asarray(d["z_surface_m"]) <= 0 + ocean_k = ocean_cells[idx] + coast = ocean_k.any(axis=-1) & ~ocean_k.all(axis=-1) + for y0 in range(0, H, CHUNK): # extra fine detail where the coastline runs + rows = np.arange(y0, min(H, y0 + CHUNK)) + cz = COAST_DETAIL_M * fbm(_rows_xyz(rows, W, H), ctx.seed + 67, 5, 256.0).reshape(len(rows), W) + z[rows] += np.where(coast[rows], cz, 0.0) + land = pixel_land(ocean_k, z) + if "lake_level_m" in d: # the water surface for drawing: lake cells at their level (beds stay in `elevation`) + lev = np.asarray(d["lake_level_m"], dtype=np.float64) + dz = sample_cont(np.where(np.isfinite(lev), lev - np.asarray(d["z_surface_m"], dtype=np.float64), 0.0), idx, wts) + save(Image.fromarray(encode(z + dz, *CONTINUOUS["elevation"][1:3])).save, rdir / "surface.png") + meta["continuous"]["surface"] = {"file": "surface.png", "scale": CONTINUOUS["elevation"][1], + "offset": CONTINUOUS["elevation"][2], "unit": "m"} + hs = hillshade(z, g.radius_km, low_memory=ctx.low_memory) + style = cfg.get("render", {}).get("style") + rgb = relief_rgb(z, hs, cats["holdridge"], cats["ground"], cats["ice"], lake, land, style=style, + low_memory=ctx.low_memory) + rgb = draw_rivers(rgb, g, np.asarray(d["recv"]), np.asarray(d["river"]), np.asarray(d["strahler"]), + colour=RIVER_RGB if style is None else STYLES[style]["river"]) + relief = Image.fromarray(rgb) + projections.write_all(rgb, pdir, g, ocean_cells, pw, globe_size=max(pw // 2, 64)) + save(relief.copy().save, rdir / "relief.png") + tiles.append(("relief", _preview(relief, pw, False))) + + vdir = out / "viewer" + by_src = {} # viewer layers are written as soon as their colours exist + for vid, vname, src in VIEWER_LAYERS: + by_src.setdefault(src, []).append((vid, vname)) + ventries = {} + + def emit(src, rgb_, legend): + for vid, vname in by_src.get(src, []): + ventries[vid] = viewer_export.write_layer(vdir, {"id": vid, "name": vname, "rgb": rgb_, "legend": legend}) + + emit("relief", rgb, None) + for name, (key, scale, offset, unit) in CONTINUOUS.items(): + v = z if name == "elevation" else sample_cont(np.asarray(d[key], dtype=np.float64), idx, wts) + save(Image.fromarray(encode(v, scale, offset)).save, rdir / f"{name}.png") + meta["continuous"][name] = {"file": f"{name}.png", "scale": scale, "offset": offset, "unit": unit} + crgb = _colorize(name, v) + if name == "current_speed" and "current" in d: + crgb = draw_currents(crgb, g, d["current"], ocean_cells) + if name != "elevation": + tiles.append((name, _preview(Image.fromarray(crgb), pw, False))) + if name in by_src: + unit_, lo, hi, stops = RAMPS[_ramp_key(name)] + emit(name, crgb, viewer_export.continuous_legend(unit_, lo, hi, stops)) + del crgb + for name, v in cats.items(): + save(Image.fromarray(v.astype(np.uint8), "L").save, rdir / f"{name}.png") + meta["categorical"][name] = {"file": f"{name}.png", "legend": legends[name]} + pal = holdridge_palette() if name == "holdridge" else category_palette(len(legends[name])) + crgb = pal[np.clip(v, 0, len(pal) - 1)] + tiles.append((name, _preview(Image.fromarray(crgb), pw, True))) + emit(name, crgb, viewer_export.categorical_legend(legends[name], pal)) + del crgb + (out / "fields.json").write_text(json.dumps(meta, indent=1)) + + per_cell = {k: v for k, v in d.items() if isinstance(v, np.ndarray) and v.shape[:1] == (g.n,)} + save(lambda: np.savez_compressed(out / "cells.npz", **per_cell)) + (out / "cells_meta.json").write_text(json.dumps( + {"res": ctx.res, "radius_km": g.radius_km, "n_cells": g.n, "units": cfg.get("units", {}), + "planet": {**cfg["planet"], **({"sun_lock": cfg["climate"].get("lock_at", [0.0, 0.0])} + if cfg.get("climate", {}).get("lock") else {})}, "name": cfg.get("render", {}).get("name", "World"), "style": style, + "legends": legends, "fields": sorted(per_cell), + "plateaus": [PL.features(p, ctx.seed, g.radius_km) for p in ctx.tect.get("plateau", [])]}, indent=1)) + + for name, im in tiles: + im.save(pdir / f"{name}.png") + contact_sheet(tiles, max(pw // 3, 64)).save(pdir / "contact_sheet.png") + viewer_export.write_index(vdir, [ventries[vid] for vid, _, _ in VIEWER_LAYERS]) + geo.write_all(ctx, out, land, lake) + return {} diff --git a/mapgen/seabed.py b/mapgen/seabed.py new file mode 100644 index 0000000..7715c44 --- /dev/null +++ b/mapgen/seabed.py @@ -0,0 +1,95 @@ +"""Stage `seabed`: sea-floor data for every ocean cell — vent potential, +sea-floor type, minerals, bottom temperature, sediment. Land cells get 0 / "—" (the fields describe the sea floor).""" +from __future__ import annotations + +import numpy as np +from scipy.spatial import cKDTree + +from . import plateaus as PL +from .config import params +from .elevation import hotspot_track +from .graph import distance_to +from .noise import fbm +from .sphere import latlon_to_xyz + +(SB_NONE, SB_TERRIGENOUS, SB_CARBONATE, SB_CLAY, SB_BASALT, SB_VOLCANIC, SB_CONTINENTAL, SB_VENTS, + SB_TRENCH) = range(9) +SEABED_NAMES = ["—", "terrigenous sediment", "carbonate ooze", "pelagic clay", "young basalt", "volcanic", + "continental / plateau", "vent field", "trench"] +MI_NONE, MI_SULFIDES, MI_NODULES, MI_COBALT, MI_PHOSPHORITE = range(5) +MINERAL_NAMES = ["none", "polymetallic sulfides", "manganese nodules", "cobalt crusts", "phosphorite"] +DEFAULTS = {"ridge_km": 100.0, "back_arc_km": 300.0, "back_arc_width_km": 150.0, "back_arc": 0.6, + "hotspot_km": 150.0, "hotspot_spacing_km": 150.0, "terrigenous_km": 300.0, "carbonate_depth_m": 4500.0, + "carbonate_t_c": 10.0, "young_myr": 10.0, "trench_km": 80.0, "trench_depth_m": 5000.0, + "vent_field": 0.8, "sulfides": 0.7, "volcanic": 0.3, "thermocline_m": 800.0} + + +def _cells_near(tree, radius_km, p, reach_km): + return np.asarray(tree.query_ball_point(p, 2.0 * np.sin(min(np.pi, reach_km / radius_km) / 2.0)), dtype=np.int64) + + +def _bump(v, g, tree, p, r_km, amp): + i = _cells_near(tree, g.radius_km, p, 3.0 * r_km) + if len(i): + d = g.radius_km * np.arccos(np.clip(g.xyz[i] @ p, -1.0, 1.0)) + v[i] = np.maximum(v[i], amp * np.exp(-(d / r_km) ** 2)) + + +def volcanic_potential(g, tect: dict, vel, seed: int, P: dict): + """0–1 per cell from hotspot chains (strongest at the active end) and plateau volcanic fields.""" + tree, v = cKDTree(g.xyz), np.zeros(g.n) + for h in tect.get("hotspot", []): + for p, s in hotspot_track(g, h, vel, P["hotspot_spacing_km"]): + _bump(v, g, tree, p, P["hotspot_km"], 1.0 - s / h["length_km"]) + for pl in tect.get("plateau", []): + f, vent = PL.features(pl, seed, g.radius_km), float(pl.get("vent", 1.0)) + _bump(v, g, tree, latlon_to_xyz(*f["hotspot"]), 200.0, vent) + for c in f["cones"]: + _bump(v, g, tree, latlon_to_xyz(c["lat"], c["lon"]), c["radius_km"] + 30.0, 0.8 * vent) + for c in f["calderas"]: + _bump(v, g, tree, latlon_to_xyz(c["lat"], c["lon"]), c["radius_km"] + 30.0, vent) + return v + + +def run(ctx) -> dict: + g = ctx.grid + P = params(ctx.cfg, "seabed", DEFAULTS) + z, ocean, cont, age, d_div, d_over, d_sub, t_mean, vel = ctx.need( + "elevation_eroded_m", "ocean", "continental", "ocean_age_myr", "d_div_km", "d_over_km", "d_sub_km", "T_mean", + "vel") + z, ocean, cont = np.asarray(z, dtype=np.float64), np.asarray(ocean, bool), np.asarray(cont, bool) + age, t_mean = np.asarray(age, dtype=np.float64), np.asarray(t_mean, dtype=np.float64) + plateau_id = np.asarray(ctx.data.get("plateau_id", np.full(g.n, -1))) + depth = np.maximum(-z, 0.0) + volc = volcanic_potential(g, ctx.tect, np.asarray(vel), ctx.seed, P) + ridge = np.exp(-(np.asarray(d_div) / P["ridge_km"]) ** 2) + back_arc = P["back_arc"] * np.exp(-((np.asarray(d_over) - P["back_arc_km"]) / P["back_arc_width_km"]) ** 2) + cluster = np.clip(0.5 + 1.5 * fbm(g.xyz, ctx.seed + 301, 4, 30.0), 0.0, 1.0) # vents come in fields + vent = np.where(ocean, np.maximum.reduce([ridge, back_arc, volc]) * (0.5 + 0.5 * cluster), 0.0) + d_land = distance_to(g, ~ocean) if (~ocean).any() else np.full(g.n, np.inf) + + t = np.full(g.n, SB_CLAY, np.int8) + t[(depth < P["carbonate_depth_m"]) & (t_mean > P["carbonate_t_c"])] = SB_CARBONATE + t[cont] = SB_CONTINENTAL + t[d_land < P["terrigenous_km"]] = SB_TERRIGENOUS + t[~cont & (age < P["young_myr"])] = SB_BASALT + t[volc > P["volcanic"]] = SB_VOLCANIC + t[(np.asarray(d_sub) < P["trench_km"]) & (depth > P["trench_depth_m"])] = SB_TRENCH + t[vent > P["vent_field"]] = SB_VENTS + t[~ocean] = SB_NONE + + m = np.zeros(g.n, np.int8) + m[(t == SB_CLAY) & (age > 30.0) & (d_land > 1000.0) & (depth > 4000.0)] = MI_NODULES + m[((t == SB_VOLCANIC) | (plateau_id >= 0)) & (depth >= 800.0) & (depth <= 2500.0)] = MI_COBALT + m[cont & (depth < 500.0)] = MI_PHOSPHORITE + m[vent > P["sulfides"]] = MI_SULFIDES + m[~ocean] = MI_NONE + + deep = 1.0 + 3.0 * np.cos(np.radians(g.lat)) ** 2 # ≈ 1 °C polar … 4 °C tropical + w = np.clip((P["thermocline_m"] - depth) / P["thermocline_m"], 0.0, 1.0) # shallow floors: towards the surface + bottom = np.where(ocean, deep + w * (np.maximum(t_mean, -1.8) - deep), 0.0) + terr = 2500.0 * np.exp(-d_land / 250.0) # aprons off the continents + sed = np.where(cont, 300.0 + terr, np.minimum(5.0 * age, 800.0) + terr) # pelagic rain ≈ 5 m per Myr + sed = np.where(ocean, sed, 0.0) + return {"vent_potential": vent.astype(np.float32), "seabed_type": t, "seabed_mineral": m, + "bottom_temp_c": bottom.astype(np.float32), "sediment_m": sed.astype(np.float32)} diff --git a/mapgen/sketch.py b/mapgen/sketch.py new file mode 100644 index 0000000..a0249bf --- /dev/null +++ b/mapgen/sketch.py @@ -0,0 +1,121 @@ +"""Stage `sketch`: sample the sketch + user masks onto cells.""" +from __future__ import annotations + +from pathlib import Path + +import numpy as np +from PIL import Image + +from . import zones as ZN +from .config import params +from .noise import fbm +from .pipeline import StageError +from .sphere import latlon_to_xyz, xyz_to_latlon + +W, H = 2000, 1000 +SKETCH = ("land", "mountains", "desert", "rainforest", "trench") +MASKS = ("land_hint", "mountain_hint", "o2_zones", "gravity_zones", "lock") +DEFAULTS = {"warp_km": 1000.0, "warp_freq": 1.2, "detail_warp_km": 200.0, "detail_warp_freq": 5.0, "moves": []} + + +def load_png01(path: Path) -> np.ndarray: + return np.array(Image.open(path).convert("L"), dtype=np.float64) / 255.0 + + +def load_mask(path: Path) -> np.ndarray: + """Grayscale override mask → [−1, 1]; mid-grey (128 / 32768), transparency and missing detail = neutral 0.""" + try: + im = Image.open(path) + im.load() + except (OSError, ValueError) as e: + raise StageError(f"cannot read mask {path.name}: {e}") from e + if im.mode in ("I", "I;16", "I;16B", "I;16L"): + v = (np.array(im).astype(np.float64) - 32768.0) / 32768.0 + dead = 1.0 / 32768.0 + else: + la = np.array(im.convert("LA")).astype(np.float64) + v = np.where(la[..., 1] > 0, (la[..., 0] - 127.5) / 127.5, 0.0) + dead = 1.0 / 255.0 + return np.clip(np.where(np.abs(v) <= dead, 0.0, v), -1.0, 1.0) + + +def sample_equirect(img: np.ndarray, lat, lon) -> np.ndarray: + h, w = img.shape + x = (np.asarray(lon, dtype=np.float64) + 180.0) / 360.0 * w - 0.5 + y = (90.0 - np.asarray(lat, dtype=np.float64)) / 180.0 * h - 0.5 + x0 = np.floor(x).astype(np.int64) + y0 = np.floor(y).astype(np.int64) + fx, fy = x - x0, y - y0 + xa, xb = x0 % w, (x0 + 1) % w + ya, yb = np.clip(y0, 0, h - 1), np.clip(y0 + 1, 0, h - 1) + top = img[ya, xa] * (1 - fx) + img[ya, xb] * fx + bot = img[yb, xa] * (1 - fx) + img[yb, xb] * fx + return top * (1 - fy) + bot * fy + + +def rotation(a, b): + """3×3 rotation carrying [lat, lon] a onto b along the great circle (Rodrigues).""" + pa, pb = latlon_to_xyz(*np.array(a, dtype=np.float64)), latlon_to_xyz(*np.array(b, dtype=np.float64)) + k = np.cross(pa, pb) + s, c = np.linalg.norm(k), float(np.dot(pa, pb)) + if s < 1e-12: + return np.eye(3) + k /= s + K = np.array([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]]) + return np.eye(3) + s * K + (1 - c) * K @ K + + +def continent_mask(land, lat, lon): + """Pixels of the sketch landmass containing (lat, lon), 2 px coastal fringe included; lon wraps.""" + from scipy import ndimage + lab, _ = ndimage.label(land) + for a, b in zip(lab[:, 0], lab[:, -1]): + if a and b and a != b: + lab[lab == b] = a + h, w = land.shape + y, x = min(max(int((90.0 - lat) / 180.0 * h), 0), h - 1), int((lon + 180.0) / 360.0 * w) % w + if not lab[y, x]: + raise StageError(f"sketch move: no sketch land at [{lat}, {lon}]") + return ndimage.binary_dilation(lab == lab[y, x], iterations=2) + + +def warped_latlon(g, P, seed): + """Sample positions displaced by a two-scale domain warp (RMS ≈ warp_km, detail_warp_km).""" + p = g.xyz.copy() + for amp, freq, s in ((P["warp_km"], P["warp_freq"], 201), (P["detail_warp_km"], P["detail_warp_freq"], 211)): + if amp <= 0: + continue + disp = np.stack([fbm(g.xyz, seed + s + k, 4, freq) for k in range(3)], axis=1) + p = p + (amp / g.radius_km) * disp / max(float(disp.std()), 1e-12) + return xyz_to_latlon(p) + + +def run(ctx) -> dict: + g = ctx.grid + P = params(ctx.cfg, "sketch", DEFAULTS) + sk = ctx.root / "sketch" + missing = [n for n in SKETCH if not (sk / f"{n}.png").exists()] + if missing: + raise StageError(f"sketch files missing {missing}: draw them or run `mapgen.py new-world` first") + lat, lon = warped_latlon(g, P, ctx.seed) # the drawing is a loose guide: warp it (user masks are not) + imgs = {n: load_png01(sk / f"{n}.png") for n in SKETCH} + moved = [] # [[sketch.moves]]: rotate whole landmasses across the sphere + for mv in P["moves"]: + comp = continent_mask(imgs["land"] > 0.5, *mv["at"]) + moved.append((rotation(mv["at"], mv["to"]), {n: np.where(comp, im, 0.0) for n, im in imgs.items()})) + imgs = {n: np.where(comp, 0.0, im) for n, im in imgs.items()} + p = latlon_to_xyz(lat, lon) + out = {} + for n in SKETCH: + v = sample_equirect(imgs[n], lat, lon) + for rot, parts in moved: + qlat, qlon = xyz_to_latlon(p @ rot) # rot.T applied to row vectors + v = np.maximum(v, sample_equirect(parts[n], qlat, qlon)) + out[f"sk_{n}"] = v + for m in MASKS: + p = ctx.root / "masks" / f"{m}.png" + out[f"m_{m}"] = sample_equirect(load_mask(p), g.lat, g.lon) if p.exists() else np.zeros(g.n) + zo2, zg = ZN.masks(g.xyz, ctx.tect.get("zone", []), ctx.seed, g.radius_km) # config zones on top of the masks + out["m_o2_zones"] = np.clip(out["m_o2_zones"] + zo2, -1.0, 1.0) + out["m_gravity_zones"] = np.clip(out["m_gravity_zones"] + zg, -1.0, 1.0) + return out diff --git a/mapgen/sphere.py b/mapgen/sphere.py new file mode 100644 index 0000000..394b9ef --- /dev/null +++ b/mapgen/sphere.py @@ -0,0 +1,68 @@ +"""Unit-sphere geometry and plate kinematics. Vectors are (..., 3); lat/lon in degrees.""" +from __future__ import annotations + +import numpy as np + + +def latlon_to_xyz(lat, lon): + la, lo = np.radians(lat), np.radians(lon) + return np.stack([np.cos(la) * np.cos(lo), np.cos(la) * np.sin(lo), np.sin(la)], axis=-1) + + +def xyz_to_latlon(p): + p = p / np.linalg.norm(p, axis=-1, keepdims=True) + return np.degrees(np.arcsin(np.clip(p[..., 2], -1, 1))), np.degrees(np.arctan2(p[..., 1], p[..., 0])) + + +def east_north(p): + """Local unit east/north vectors. At the poles east is taken as +y (finite, arbitrary).""" + p = np.asarray(p, dtype=np.float64) + e = np.stack([-p[..., 1], p[..., 0], np.zeros_like(p[..., 0])], axis=-1) # z × p + ne = np.linalg.norm(e, axis=-1, keepdims=True) + polar = ne[..., 0] < 1e-12 + e = np.where(polar[..., None], np.array([0.0, 1.0, 0.0]), e / np.maximum(ne, 1e-300)) + n = np.cross(p, e) + return e, n + + +def tangent_dir(p, q): + """Unit tangent at p pointing along the great circle toward q.""" + t = q - np.sum(p * q, axis=-1, keepdims=True) * p + return t / np.maximum(np.linalg.norm(t, axis=-1, keepdims=True), 1e-15) + + +def gc_dist_km(p, q, radius_km): + return radius_km * np.arccos(np.clip(np.sum(p * q, axis=-1), -1.0, 1.0)) + + +def motion_to_omega(lat, lon, azimuth_deg, speed_cm_yr, radius_km): + """Angular velocity (rad/yr) that moves the point (lat, lon) along azimuth at speed.""" + p = latlon_to_xyz(np.array([lat]), np.array([lon])) + e, n = east_north(p) + az = np.radians(azimuth_deg) + v = (np.sin(az) * e[0] + np.cos(az) * n[0]) * (speed_cm_yr / 100.0) # m/yr + return np.cross(p[0], v) / (radius_km * 1000.0) + + +def velocity(p, omega, radius_km): + """Surface velocity (m/yr) of points p on plates with angular velocity omega (broadcast).""" + return np.cross(omega, p) * (radius_km * 1000.0) + + +def rotate_about(p, v, ang): + """Rotate tangent vectors v about normals p by ang radians (counter-clockwise seen from outside).""" + ang = np.asarray(ang, dtype=np.float64)[..., None] + return v * np.cos(ang) + np.cross(p, v) * np.sin(ang) + + +def azimuth_deg(center, p): + """Bearing (deg clockwise from north, [0, 360)) from center (3,) to points p (..., 3).""" + e, n = east_north(center[None]) + t = tangent_dir(np.broadcast_to(center, p.shape), p) + return np.degrees(np.arctan2(t @ e[0], t @ n[0])) % 360.0 + + +def great_circle_point(p0, t0, dist_km, radius_km): + """Point reached from p0 moving dist_km along unit tangent t0.""" + a = dist_km / radius_km + return np.cos(a) * p0 + np.sin(a) * t0 diff --git a/mapgen/testdata/world.toml b/mapgen/testdata/world.toml new file mode 100644 index 0000000..e650e39 --- /dev/null +++ b/mapgen/testdata/world.toml @@ -0,0 +1,31 @@ +# Test fixture world (the unit tests' planet); not an example — see example/config/world.toml. +[planet] +radius_km = 12742.0 +gravity_g = 1.05 +day_hours = 31.149 +year_days = 216 +tilt_deg = 20.0 +sea_level_pressure_bar = 1.0 +scale_height_km = 8.0 +o2_fraction = 0.21 + +[units] +span_m = 0.331013 +moment_s = 2.403473 +league_km = 15.4437 + +[build] +seed = 1296 +res_dev = 4 +res_final = 5 +raster_width = 8192 +dev_raster_width = 2048 +preview_width = 2048 +land_fraction = 0.19 + +[climate] +hadley_edge_deg = 20.0 +ferrel_edge_deg = 55.0 + +[sketch] +moves = [] diff --git a/mapgen/testing.py b/mapgen/testing.py new file mode 100644 index 0000000..b70e5fe --- /dev/null +++ b/mapgen/testing.py @@ -0,0 +1,59 @@ +"""Test fixtures shared with tools built on mapgen (e.g. a viewer's tests): a tiny two-continent world at H3 res 2.""" +from __future__ import annotations + +import atexit +import functools +import re +import shutil +import tempfile +from pathlib import Path + +import numpy as np +from PIL import Image + +FIXTURE_TOML = Path(__file__).resolve().parent / "testdata" / "world.toml" + +TECT = """ +[[plate]] +id = "a" +seed = [10.0, -30.0] +kind = "continental" +motion = [90.0, 4.0] +[[plate]] +id = "b" +seed = [10.0, 30.0] +kind = "continental" +motion = [270.0, 4.0] +[[plate]] +id = "c" +seed = [0.0, 150.0] +kind = "oceanic" +motion = [0.0, 3.0] +""" + + +def small_world(tmp: Path) -> None: + (tmp / "config").mkdir() + w = FIXTURE_TOML.read_text() + w = w.replace("raster_width = 8192", "raster_width = 256").replace("preview_width = 2048", "preview_width = 128") + w = w.replace("dev_raster_width = 2048", "dev_raster_width = 256") + w = re.sub(r"moves = \[.*?\n\]", "moves = []", w, flags=re.S) + (tmp / "config" / "world.toml").write_text(w) + (tmp / "config" / "tectonics.toml").write_text(TECT) + (tmp / "sketch").mkdir() + lat = 90 - (np.arange(100) + 0.5) * 1.8 + lon = (np.arange(200) + 0.5) * 1.8 - 180 + LA, LO = np.meshgrid(lat, lon, indexing="ij") + land = ((np.abs(LA - 10) < 35) & (np.abs(LO) < 70)).astype(np.uint8) * 255 + for n in ("land", "mountains", "desert", "rainforest", "trench"): + Image.fromarray(land if n == "land" else np.zeros_like(land), "L").save(tmp / "sketch" / f"{n}.png") + + +@functools.lru_cache(maxsize=1) +def built_world() -> Path: + from mapgen import pipeline as P + tmp = Path(tempfile.mkdtemp(prefix="worldgen-fixture-")) + atexit.register(shutil.rmtree, tmp, True) # 5 MB per build: never leave them in /tmp + small_world(tmp) + P.build(tmp, 2, log=lambda m: None) + return tmp diff --git a/mapgen/viewer_export.py b/mapgen/viewer_export.py new file mode 100644 index 0000000..e6f51b4 --- /dev/null +++ b/mapgen/viewer_export.py @@ -0,0 +1,39 @@ +"""Viewer textures: colourised equirectangular JPEG layers + layers.json.""" +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np +from PIL import Image + + +def hex_color(c) -> str: + return "#%02x%02x%02x" % tuple(int(v) for v in list(c)[:3]) + + +def categorical_legend(names, palette) -> dict: + return {"type": "categorical", + "items": [{"name": n, "color": hex_color(palette[i % len(palette)])} for i, n in enumerate(names)]} + + +def continuous_legend(unit, lo, hi, stops) -> dict: + vals = np.linspace(lo, hi, len(stops)) + return {"type": "continuous", "unit": unit, + "stops": [[round(float(v), 4), hex_color(s)] for v, s in zip(vals, stops)]} + + +def write_layer(vdir: Path, L: dict) -> dict: + """Write one layer's JPEG; returns its layers.json entry.""" + vdir.mkdir(parents=True, exist_ok=True) + Image.fromarray(np.ascontiguousarray(L["rgb"], dtype=np.uint8)).save(vdir / f"{L['id']}.jpg", quality=90) + return {"id": L["id"], "name": L["name"], "file": f"{L['id']}.jpg", "legend": L["legend"]} + + +def write_index(vdir: Path, entries: list[dict]) -> None: + vdir.mkdir(parents=True, exist_ok=True) + (vdir / "layers.json").write_text(json.dumps(entries, indent=1)) + + +def write_all(vdir: Path, layers: list[dict]) -> None: + write_index(vdir, [write_layer(vdir, L) for L in layers]) diff --git a/mapgen/zones.py b/mapgen/zones.py new file mode 100644 index 0000000..46386f7 --- /dev/null +++ b/mapgen/zones.py @@ -0,0 +1,38 @@ +"""Config O₂ / low-gravity zones: smooth, noise-warped discs in mask units +(O₂ ×(1 + 0.5 v), gravity ×(1 + 0.7 v)), added to the painted masks in the `sketch` stage. Very gradual: at most +≈ 1.9 · |effect| / radius per km (≤ 0.23 O₂ points and ≤ 0.014 g per 30 km for the configured zones).""" +from __future__ import annotations + +import numpy as np + +from .noise import fbm, name_seed +from .sphere import gc_dist_km, latlon_to_xyz + +WARP = 0.2 # outline wobble: distances stretched or shrunk by up to 20 % (low-frequency noise) +WARP_FREQ = 1.0 + + +def smootherstep(t): + t = np.clip(t, 0.0, 1.0) + return t * t * t * (t * (6.0 * t - 15.0) + 10.0) + + +def contribution(xyz, zone: dict, seed: int, radius_km: float): + """v · (1 − smootherstep(d′ / r)), d′ = d · (1 + WARP · warp): v at the centre, 0 beyond r / (1 − WARP).""" + xyz = np.asarray(xyz, dtype=np.float64) + d = gc_dist_km(xyz, latlon_to_xyz(*zone["center"]), radius_km) + r = float(zone["radius_km"]) + out = np.zeros(len(xyz)) + near = d < r / (1.0 - WARP) + if near.any(): + w = np.clip(2.0 * fbm(xyz[near], name_seed(seed, zone["name"]), 3, WARP_FREQ), -1.0, 1.0) + out[near] = zone["v"] * (1.0 - smootherstep(d[near] * (1.0 + WARP * w) / r)) + return out + + +def masks(xyz, zones: list, seed: int, radius_km: float): + """(m_o2, m_gravity): the config zones summed (the caller adds the painted masks and clips to [−1, 1]).""" + m = {"o2": np.zeros(len(xyz)), "gravity": np.zeros(len(xyz))} + for z in zones: + m[z["field"]] += contribution(xyz, z, seed, radius_km) + return m["o2"], m["gravity"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3c874e5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +numpy>=2.3 +pillow>=11 +scipy>=1.16 +h3>=4.5,<5 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..ccdaff7 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,10 @@ +import atexit +import os +import shutil +import tempfile + +if "WORLDGEN_TEST_TMP" not in os.environ: + _root = tempfile.mkdtemp(prefix="worldgen-tests-") + os.environ["WORLDGEN_TEST_TMP"] = os.environ["TMPDIR"] = tempfile.tempdir = _root + _pid = os.getpid() + atexit.register(lambda: os.getpid() == _pid and shutil.rmtree(_root, True)) diff --git a/tests/data/climate_golden_r2.npz b/tests/data/climate_golden_r2.npz Binary files differnew file mode 100644 index 0000000..1346781 --- /dev/null +++ b/tests/data/climate_golden_r2.npz diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..1bb865b --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,39 @@ +import copy +import functools +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +BASE_CFG = { + "planet": {"radius_km": 12742.0, "gravity_g": 1.05, "day_hours": 31.149, "year_days": 216, + "tilt_deg": 20.0, "sea_level_pressure_bar": 1.0, "scale_height_km": 8.0, "o2_fraction": 0.21}, + "build": {"seed": 7, "res_dev": 2, "res_final": 2, "raster_width": 256, "preview_width": 128, + "land_fraction": 0.22}, +} + + +@functools.lru_cache(maxsize=4) +def small_grid(res: int = 2): + from mapgen.grid import build_grid + return build_grid(res, 12742.0) + + +def make_ctx(res=2, data=None, cfg=None, tect=None, root=None): + from mapgen.pipeline import Ctx + c = copy.deepcopy(BASE_CFG) + for sec, vals in (cfg or {}).items(): + c.setdefault(sec, {}).update(vals) + ctx = Ctx(root or ROOT, c, tect or {"plate": []}, res) + ctx.grid = small_grid(res) + ctx.data = dict(data or {}) + return ctx + + +def big_river(a) -> int: + """The cell of the biggest river that flows on over land (not into a lake at its own level, which cuts no + valley, nor off a coast, like a basin lake's spillway): the fixtures' river.""" + import numpy as np + river, lake = np.asarray(a["river"]).astype(bool), np.asarray(a["lake"]).astype(bool) + recv = np.asarray(a["recv"]) + wet = lake | np.asarray(a["ocean"]).astype(bool) + return int(np.argmax(np.asarray(a["discharge_km3_yr"]) * (river & ~wet & ~wet[recv]))) diff --git a/tests/test_check.py b/tests/test_check.py new file mode 100644 index 0000000..a4ba179 --- /dev/null +++ b/tests/test_check.py @@ -0,0 +1,165 @@ +import unittest + +import numpy as np + +from mapgen import check as CK +from tests.helpers import small_grid + + +class CheckTest(unittest.TestCase): + def setUp(self): + self.g = small_grid(2) + + def test_land_fraction(self): + a = np.ones(100) + z = np.where(np.arange(100) < 22, 100.0, -100.0) + self.assertIsNone(CK.check_land_fraction(z, a, 0.22)) + self.assertIn("land fraction", CK.check_land_fraction(z, a, 0.40)) + + def test_hypsometry(self): + rng = np.random.default_rng(0) + z = np.concatenate([rng.normal(-4000, 600, 7000), rng.normal(500, 400, 3000)]) + self.assertIsNone(CK.check_hypsometry(z, np.ones(len(z)))) + self.assertIn("bimodal", CK.check_hypsometry(rng.normal(0, 3000, 10000), np.ones(10000))) + + def test_max_elevation(self): + self.assertIsNone(CK.check_max_elevation(np.array([11000.0]), np.array([1.0]))) + self.assertIsNone(CK.check_max_elevation(np.array([20000.0]), np.array([0.5]))) + self.assertIn("12000", CK.check_max_elevation(np.array([13000.0]), np.array([1.0]))) + + def test_drainage_and_uphill(self): + recv = np.array([0, 0, 1, 3]) + z = np.array([-10.0, 5.0, 8.0, 20.0]) + endo = np.array([False, False, False, False]) + self.assertIn("sink", CK.check_drainage(recv, z, endo)) + endo[3] = True + self.assertIsNone(CK.check_drainage(recv, z, endo)) + self.assertIsNone(CK.check_no_uphill(z, recv, z, endo)) + self.assertIn("uphill", CK.check_no_uphill(np.array([-10.0, 9.0, 8.0, 20.0]), recv, z, endo)) + + def test_temperature_and_rain_bands(self): + g = self.g + self.assertIsNone(CK.check_temperature(g, 30 - 0.6 * np.abs(g.lat))) + self.assertIn("poleward", CK.check_temperature(g, 0.3 * np.abs(g.lat))) + wet = 2000 * np.exp(-(g.lat / 8) ** 2) + 900 * np.exp(-((np.abs(g.lat) - 40) / 8) ** 2) + 100 + self.assertIsNone(CK.check_rain_bands(g, wet, 20.0)) + self.assertIn("subtropic", CK.check_rain_bands(g, np.full(g.n, 800.0), 20.0)) + + def test_drainage_terminals_must_hold_water_or_salt(self): + recv = np.array([0, 0, 1, 3]) + z = np.array([-10.0, 5.0, 8.0, 20.0]) + endo = np.array([False, False, False, True]) + dry = np.zeros(4, bool) + self.assertIn("lake", CK.check_drainage(recv, z, endo, terminal_ok=dry)) + wet = dry.copy() + wet[3] = True + self.assertIsNone(CK.check_drainage(recv, z, endo, terminal_ok=wet)) + + def test_lake_surface_routing_is_exempt(self): + recv = np.array([0, 0, 1, 2]) + zf = np.array([-10.0, 5.0, 5.02, 5.01]) # cell 3 → 2 is ε-uphill across a flat lake + z = np.array([-10.0, 5.0, 4.0, 4.5]) + endo = np.zeros(4, bool) + lake = np.array([False, False, True, True]) + self.assertIn("uphill", CK.check_no_uphill(zf, recv, z, endo)) + self.assertIsNone(CK.check_no_uphill(zf, recv, z, endo, lake=lake)) + + +class RevisionCheckTest(unittest.TestCase): + P1 = {"name": "east-flank", "center": [-39.9, -18.0], "area_km2": 3.0e6, "elongation": 1.9, "azimuth_deg": 30.0, + "top_m": [1500.0, 3000.0]} + P2 = {"name": "plateau-02", "center": [-0.4, 176.6], "area_km2": 1.0e6, "top_m": [1000.0, 1500.0], + "islands": True} + + def world(self): + from mapgen import plateaus as PL + g = small_grid(4) + ids = PL.cell_ids(g.xyz, [self.P1, self.P2], 7, g.radius_km) + z = PL.apply(g, np.full(g.n, -6000.0), [self.P1, self.P2], ids, 7) + return g, {"elevation_eroded_m": z, "ocean": z <= 0, "plateau_id": ids} + + def test_plateaus_pass_and_fail(self): + g, d = self.world() + self.assertEqual(CK.check_plateaus(g, d, [self.P1, self.P2], 7), []) + z = d["elevation_eroded_m"].copy() + z[np.flatnonzero(d["plateau_id"] == 0)[0]] = -100.0 + z[z > 0] = -700.0 + bad = CK.check_plateaus(g, {**d, "elevation_eroded_m": z, "ocean": z <= 0}, [self.P1, self.P2], 7) + self.assertTrue(any("east-flank" in m and "−500" in m for m in bad), bad) + self.assertTrue(any("plateau-02" in m and "island" in m for m in bad), bad) + + def test_zones_gradual_rule(self): + from mapgen import zones as ZN + g = small_grid(4) + m = ZN.contribution(g.xyz, {"name": "R1", "field": "o2", "center": [0.0, 0.0], "radius_km": 3200.0, + "v": 1.0}, 7, g.radius_km) + smooth = {"o2_fraction": 0.21 * (1 + 0.5 * m), "gravity_g": np.full(g.n, 1.05)} + self.assertEqual(CK.check_zones(g, smooth), []) + step = {"o2_fraction": np.where(g.lon > 0, 0.35, 0.21), "gravity_g": np.full(g.n, 1.05)} + self.assertTrue(any("o2_fraction" in m for m in CK.check_zones(g, step))) + + def test_compare_skips_o2_fields_inside_o2_zones(self): + g = small_grid(3) + tect = {"plateau": [], "land_patch": [], + "zone": [{"name": "r", "field": "o2", "center": [0.0, 0.0], "radius_km": 2000.0, "v": 1.0}]} + o2 = CK.o2_areas(g.xyz, tect, g.radius_km) + self.assertTrue(o2[g.cell_index(0.0, 0.0)]) + self.assertFalse(o2[g.cell_index(0.0, 90.0)]) + ex = CK.edited_areas(g.xyz, tect, g.radius_km) + self.assertFalse(ex.any(), "O₂ zones don't change relief") + old = {"po2_bar": np.zeros(g.n), "T_mean": np.zeros(g.n)} + new = {"po2_bar": np.where(o2, 0.1, 0.0), "T_mean": np.where(o2, 1.0, 0.0)} + lines = CK.compare(old, new, ex, {k: o2 for k in CK.O2_FIELDS}) + self.assertIn("po2_bar: 0.0% changed, max 0, p99 0", lines) + self.assertTrue(any(s.startswith("T_mean:") and not s.startswith("T_mean: 0.0%") for s in lines), lines) + + def test_compare_command_needs_a_world_cells_file(self): + import importlib.util + import tempfile + from pathlib import Path + from mapgen.testing import built_world + spec = importlib.util.spec_from_file_location("mapgen_cli", Path(CK.__file__).parents[1] / "mapgen.py") + cli = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cli) + old = Path(tempfile.mkdtemp()) / "other.npz" + np.savez(old, a=np.zeros(3)) + self.assertEqual(cli.main(["compare", "--res", "2", "--old", str(old)], root=built_world()), 2) + + def test_compare_ignores_edited_areas(self): + g = small_grid(3) + tect = {"plateau": [self.P1], "land_patch": [], "zone": []} + ex = CK.edited_areas(g.xyz, tect, g.radius_km) + self.assertTrue(ex[g.cell_index(-39.9, -18.0)]) + self.assertFalse(ex[g.cell_index(40.0, 100.0)]) + old = {"a": np.zeros(g.n), "b": np.zeros(g.n), "g_ids": g.ids} + new = {"a": np.where(ex, 5.0, 0.0), "b": np.where(ex, 0.0, 1.0), "g_ids": g.ids} + lines = CK.compare(old, new, ex) + self.assertIn("a: 0.0% changed, max 0, p99 0", lines) + self.assertTrue(any(line.startswith("b: 100.0% changed, max 1") for line in lines), lines) + + def test_site_report_warns_about_land_nearby(self): + from mapgen import plateaus as PL + g, d = self.world() + land = np.abs(g.lat - (-39.9)) < 2.0 + near = land & (np.abs(g.lon - (-18.0)) < 30.0) & (d["plateau_id"] < 0) + data = {**d, "ocean": ~near, "bnd_type": np.zeros(g.n, np.int8)} + msgs = PL.site_report(g, data, [self.P1, self.P2]) + self.assertTrue(any("east-flank" in m and "from land" in m for m in msgs), msgs) + + def test_compare_command(self): + import importlib.util + import shutil + import tempfile + from pathlib import Path + from mapgen.testing import built_world + spec = importlib.util.spec_from_file_location("mapgen_cli", Path(CK.__file__).parents[1] / "mapgen.py") + cli = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cli) + old = Path(tempfile.mkdtemp()) / "cells.npz" + shutil.copy(built_world() / "out" / "r2" / "cells.npz", old) + self.assertEqual(cli.main(["compare", "--res", "2", "--old", str(old)], root=built_world()), 0) + + def test_compare_equal_infinities_are_unchanged(self): + ex = np.zeros(3, bool) + lines = CK.compare({"d": np.array([np.inf, 1.0, 2.0])}, {"d": np.array([np.inf, 1.0, 3.0])}, ex) + self.assertIn("d: 33.3% changed, max 1, p99 0.98", lines) diff --git a/tests/test_climate_ocean.py b/tests/test_climate_ocean.py new file mode 100644 index 0000000..8132c6e --- /dev/null +++ b/tests/test_climate_ocean.py @@ -0,0 +1,81 @@ +import unittest +from pathlib import Path + +import numpy as np + +from mapgen import climate as CL +from tests.helpers import make_ctx + +GOLDEN = Path(__file__).parent / "data" / "climate_golden_r2.npz" +NEW = ("current", "current_speed", "sst", "upwelling", "productivity") + + +def golden_ctx(cfg=None): + ctx = make_ctx(2, cfg=cfg) + g = ctx.grid + land = (np.abs(g.lat) < 40) & (np.abs(g.lon) < 50) + ctx.data["elevation_eroded_m"] = np.where(land, np.where(np.abs(g.lon) < 10, 2500.0, 300.0), + -4000.0).astype(np.float32) + return ctx, land + + +class OceanDisabledTest(unittest.TestCase): + def test_disabled_reproduces_pre_ocean_climate(self): + ctx, land = golden_ctx({"ocean": {"enabled": False}}) + out = CL.run(ctx) + ref = np.load(GOLDEN) + for k in ref.files: + np.testing.assert_array_equal(out[k], ref[k], err_msg=k) + self.assertTrue(np.all(out["current"] == 0) and np.all(out["productivity"] == 0)) + np.testing.assert_array_equal(out["sst"], out["T_mean"].astype(np.float32)) + + +class OceanCoupledTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.ctx, cls.land = golden_ctx() + cls.out = CL.run(cls.ctx) + + def test_new_fields(self): + g, out = self.ctx.grid, self.out + for k in NEW: + self.assertIn(k, out) + self.assertEqual(out[k].dtype, np.float32, k) + self.assertTrue(np.all(np.isfinite(out[k])), k) + self.assertEqual(out["current"].shape, (g.n, 3)) + self.assertTrue(np.all(out["current"][self.land] == 0)) + np.testing.assert_allclose(out["current_speed"], np.linalg.norm(out["current"], axis=1), rtol=1e-5) + sea = ~self.land + self.assertTrue(np.all((out["sst"][sea] >= -1.8) & (out["sst"][sea] < 40))) # sea water freezes at −1.8 °C + + def test_currents_change_the_climate(self): + ref = np.load(GOLDEN) + self.assertGreater(np.abs(self.out["T_mean"] - ref["T_mean"]).max(), 0.5) # the old gyre rule is gone + + def test_winds_come_from_current_free_temperatures(self): + ctx, _ = golden_ctx({"ocean": {"relax_days": 30.0}}) # other SST, same winds + np.testing.assert_array_equal(CL.run(ctx)["wind_jun"], self.out["wind_jun"]) + + def test_unknown_ocean_key_is_an_error(self): + from mapgen.config import ConfigError + ctx, _ = golden_ctx({"ocean": {"frictoin_days": 3.0}}) + with self.assertRaises(ConfigError): + CL.run(ctx) + + def test_all_land_world(self): + ctx = make_ctx(2) + ctx.data["elevation_eroded_m"] = np.full(ctx.grid.n, 500.0, np.float32) + out = CL.run(ctx) + self.assertTrue(np.all(out["current"] == 0)) + + def test_locked_world_has_still_ocean(self): + ctx, land = golden_ctx({"climate": {"lock": True}}) + out = CL.run(ctx) + self.assertTrue(np.all(out["current"] == 0) and np.all(out["upwelling"] == 0)) + np.testing.assert_array_equal(out["sst"], out["T_mean"].astype(np.float32)) + + def test_upwelling_and_productivity_filled(self): + sea = ~self.land + self.assertGreater(np.abs(self.out["upwelling"][sea]).max(), 1.0) + self.assertGreater(self.out["productivity"][sea].max(), 0.05) + self.assertTrue(np.all(self.out["productivity"][self.land] == 0)) diff --git a/tests/test_climate_rain.py b/tests/test_climate_rain.py new file mode 100644 index 0000000..38afed5 --- /dev/null +++ b/tests/test_climate_rain.py @@ -0,0 +1,117 @@ +import unittest + +import numpy as np + +from mapgen import climate as CL +from mapgen.sphere import east_north +from tests.helpers import make_ctx, small_grid + + +class WindTest(unittest.TestCase): + def test_bands(self): + g = small_grid(3) + w = CL.band_winds(g, 0.0, CL.DEFAULTS) + e, n = east_north(g.xyz) + u = np.sum(w * e, axis=1) + v = np.sum(w * n, axis=1) + self.assertLess(u[np.abs(g.lat - 10) < 2].mean(), -3) # trades from the east + self.assertGreater(u[np.abs(g.lat - 40) < 2].mean(), 3) # westerlies + self.assertLess(v[np.abs(g.lat - 10) < 2].mean(), 0) # NH trades flow equatorward + self.assertGreater(v[np.abs(g.lat + 10) < 2].mean(), 0) # SH trades flow equatorward + + def test_monsoon_reverses_onshore_flow(self): + g = small_grid(3) + land = (g.lat > 10) & (g.lat < 40) & (np.abs(g.lon) < 50) + z = np.where(land, 200.0, -4000.0) + T, _ = CL.temperatures(g, z, land, CL.DEFAULTS, 20.0) + _, n = east_north(g.xyz) + south = (g.lat > 0) & (g.lat < 8) & (np.abs(g.lon) < 40) + vj = np.sum(CL.monsoon_winds(g, T["jun"], land, CL.DEFAULTS) * n, axis=1)[south].mean() + vd = np.sum(CL.monsoon_winds(g, T["dec"], land, CL.DEFAULTS) * n, axis=1)[south].mean() + self.assertGreater(vj, 0.5) + self.assertLess(vd, -0.5) + + +class RainTest(unittest.TestCase): + def test_itcz_wetter_than_subtropics_on_aquaplanet(self): + g = small_grid(3) + z = np.full(g.n, -4000.0) + T, _ = CL.temperatures(g, z, z > 0, CL.DEFAULTS, 20.0) + w = CL.band_winds(g, 0.0, CL.DEFAULTS) + p = CL.precipitation(g, w, T["eq"], z, z > 0, 0.0, CL.DEFAULTS) + self.assertGreater(p[np.abs(g.lat) < 5].mean(), 1.5 * p[np.abs(np.abs(g.lat) - 21) < 3].mean()) + + def test_windward_wetter_than_lee(self): + g = small_grid(3) + land = (g.lat > 30) & (g.lat < 50) & (np.abs(g.lon) < 60) + z = np.where(land, 200.0, -4000.0) + z = np.where(land & (np.abs(g.lon) < 5), 3000.0, z) + T, _ = CL.temperatures(g, z, land, CL.DEFAULTS, 20.0) + w = CL.band_winds(g, 0.0, CL.DEFAULTS) + p = CL.precipitation(g, w, T["eq"], z, land, 0.0, CL.DEFAULTS) + band = (g.lat > 35) & (g.lat < 45) + west = p[band & (g.lon > -12) & (g.lon < -3)].mean() + east = p[band & (g.lon > 3) & (g.lon < 12)].mean() + self.assertGreater(west, 1.3 * east) + + def test_stage_outputs(self): + ctx = make_ctx(2) + g = ctx.grid + land = (np.abs(g.lat) < 40) & (np.abs(g.lon) < 50) + ctx.data["elevation_eroded_m"] = np.where(land, 300.0, -4000.0).astype(np.float32) + out = CL.run(ctx) + for k in ("T_jun", "T_dec", "T_eq", "T_mean", "T_range", "T_min", "P_jun", "P_dec", "P_eq", "P_ann", + "wind_jun", "biotemp", "PET", "dist_ocean_km"): + self.assertIn(k, out) + self.assertTrue(np.all(np.isfinite(out[k])), k) + mean = np.sum(out["P_ann"] * g.area_km2) / g.area_km2.sum() + self.assertAlmostEqual(mean, 1000.0, delta=1.0) + self.assertTrue(np.all(out["P_ann"] >= 0)) + + +class StormTrackTest(unittest.TestCase): + def test_midlatitudes_wetter_than_subtropics(self): + g = small_grid(3) + z = np.full(g.n, -4000.0) + T, _ = CL.temperatures(g, z, z > 0, CL.DEFAULTS, 20.0) + p = CL.precipitation(g, CL.band_winds(g, 0.0, CL.DEFAULTS), T["eq"], z, z > 0, 0.0, CL.DEFAULTS) + a = np.abs(g.lat) + h = CL.DEFAULTS["hadley_edge_deg"] # storm track ≈ h+12..h+25, dry belt ≈ h−2..h+8 + self.assertGreater(p[(a > h + 12) & (a < h + 25)].mean(), 1.2 * p[(a > h - 2) & (a < h + 8)].mean()) + + +class LandMoistureTest(unittest.TestCase): + def test_equatorial_land_stays_wet(self): + g = small_grid(3) + land = (np.abs(g.lat) < 12) & (np.abs(g.lon) < 40) + z = np.where(land, 300.0, -4000.0) + T, _ = CL.temperatures(g, z, land, CL.DEFAULTS, 20.0) + p = CL.precipitation(g, CL.band_winds(g, 0.0, CL.DEFAULTS), T["eq"], z, land, 0.0, CL.DEFAULTS) + eq = np.abs(g.lat) < 8 + self.assertGreater(p[land & eq].mean(), 0.5 * p[~land & eq].mean()) + + +class OceanMaskUseTest(unittest.TestCase): + def test_inland_basin_is_land_for_climate(self): + ctx = make_ctx(3) + g = ctx.grid + land = (np.abs(g.lat) < 40) & (np.abs(g.lon) < 60) + z = np.where(land, 300.0, -4000.0) + basin = (np.abs(g.lat) < 8) & (np.abs(g.lon) < 8) + z[basin] = -30.0 + ctx.data.update({"elevation_eroded_m": z.astype(np.float32), "ocean": ~land}) + out = CL.run(ctx) + self.assertTrue(np.all(out["dist_ocean_km"][basin] > 1000)) + + +class InteriorRainTest(unittest.TestCase): + def test_continental_interior_keeps_a_third_of_coastal_rain(self): + from mapgen.graph import distance_to + g = small_grid(3) + land = (g.lat > 10) & (g.lat < 50) & (np.abs(g.lon) < 70) + z = np.where(land, 300.0, -4000.0) + T, _ = CL.temperatures(g, z, land, CL.DEFAULTS, 20.0) + p = CL.precipitation(g, CL.band_winds(g, 0.0, CL.DEFAULTS), T["eq"], z, land, 0.0, CL.DEFAULTS) + d = distance_to(g, ~land) + coast, interior = p[land & (d < 500)].mean(), p[land & (d > 1500)].mean() + self.assertGreater(interior, 0.33 * coast) # was ≈0.25 before the desert retune diff --git a/tests/test_climate_temp.py b/tests/test_climate_temp.py new file mode 100644 index 0000000..2a61fc3 --- /dev/null +++ b/tests/test_climate_temp.py @@ -0,0 +1,93 @@ +import unittest + +import numpy as np + +from mapgen import climate as CL +from tests.helpers import small_grid + + +class InsolationTest(unittest.TestCase): + def test_insolation_finite_at_poles(self): + q = CL.insolation(np.array([90.0, -90.0, 89.999]), 20.0) + self.assertTrue(np.all(np.isfinite(q))) + self.assertAlmostEqual(q[1], 0.0, places=6) # polar night + self.assertGreater(q[0], 400) # polar day + + def test_symmetry(self): + lat = np.linspace(-80, 80, 17) + np.testing.assert_allclose(CL.insolation(lat, 20.0), CL.insolation(-lat, -20.0), rtol=1e-12) + + +class TemperatureTest(unittest.TestCase): + def setUp(self): + self.g = small_grid(3) + self.P = dict(CL.DEFAULTS) + + def test_aquaplanet_zonal_means_fall_poleward(self): + g = self.g + z = np.full(g.n, -4000.0) + T, _ = CL.temperatures(g, z, z > 0, self.P, 20.0) + tm = (T["jun"] + T["dec"] + 2 * T["eq"]) / 4 + bands = [tm[(np.abs(g.lat) >= a) & (np.abs(g.lat) < a + 10)].mean() for a in range(0, 90, 10)] + self.assertTrue(all(b1 > b2 for b1, b2 in zip(bands, bands[1:])), bands) + self.assertTrue(20 < bands[0] < 32 and bands[-1] < -5, bands) + + def test_lapse_rate(self): + g = self.g + land = (np.abs(g.lat) < 30) & (np.abs(g.lon) < 60) + flat = np.where(land, 10.0, -4000.0) + high = np.where(land & (np.abs(g.lon) < 20), 3000.0, flat) + T0, _ = CL.temperatures(g, flat, land, self.P, 20.0) + T1, _ = CL.temperatures(g, high, land, self.P, 20.0) + i = g.cell_index(0.0, 0.0) + self.assertAlmostEqual(T0["eq"][i] - T1["eq"][i], 6.5 * 2.99, delta=0.5) + + def test_cold_west_coast_warm_east_coast(self): + g = self.g + land = (g.lat > 15) & (g.lat < 45) & (np.abs(g.lon) < 30) + a = CL.current_anomaly(g, land, self.P) + self.assertLess(a[g.cell_index(30.0, -33.0)], -0.5) + self.assertGreater(a[g.cell_index(30.0, 33.0)], 0.5) + + def test_continental_interior_has_bigger_seasons(self): + g = self.g + land = (g.lat > 20) & (g.lat < 70) & (np.abs(g.lon) < 90) + z = np.where(land, 200.0, -4000.0) + T, _ = CL.temperatures(g, z, land, self.P, 20.0) + rng = np.abs(T["jun"] - T["dec"]) + self.assertGreater(rng[g.cell_index(50.0, 0.0)], rng[g.cell_index(50.0, 150.0)] + 10) + + def test_biotemperature(self): + bio = CL.biotemperature(np.array([10.0, -5.0, 35.0, 0.0]), np.array([0.0, 0.0, 0.0, 20.0])) + np.testing.assert_allclose(bio[:3], [10.0, 0.0, 30.0]) + self.assertTrue(2.0 < bio[3] < 4.0) + + +class CalibrationTest(unittest.TestCase): + def test_earthlike_midlatitudes_and_sea_ice_edge(self): + g = small_grid(3) + z = np.full(g.n, -4000.0) + T, _ = CL.temperatures(g, z, z > 0, CL.DEFAULTS, 20.0) + tm = (T["jun"] + T["dec"] + 2 * T["eq"]) / 4 + band = lambda a: np.abs(np.abs(g.lat) - a) < 2 + self.assertTrue(10 < tm[band(45)].mean() < 18, tm[band(45)].mean()) + self.assertTrue(-1 < tm[band(60)].mean() < 8, tm[band(60)].mean()) + winter = np.where(g.lat >= 0, T["dec"], T["jun"]) + self.assertGreater(winter[band(48)].mean(), -1.8) # no sea ice at 48° + self.assertLess(winter[band(66)].mean(), -1.8) # seasonal sea ice by 66° + + def test_subtropical_land_heats_in_summer(self): + g = small_grid(3) + land = (g.lat > 10) & (g.lat < 40) & (np.abs(g.lon) < 50) + z = np.where(land, 200.0, -4000.0) + T, _ = CL.temperatures(g, z, land, CL.DEFAULTS, 20.0) + anom = T["jun"] - CL.zonal_mean(g, T["jun"]) + self.assertGreater(anom[land].mean(), 2.5) + self.assertLess(T["jun"][land].max(), 42.0) + + def test_ocean_seasonal_range_earthlike(self): + g = small_grid(3) + z = np.full(g.n, -4000.0) + T, _ = CL.temperatures(g, z, z > 0, CL.DEFAULTS, 20.0) + rng = np.abs(T["jun"] - T["dec"])[np.abs(np.abs(g.lat) - 50) < 2].mean() + self.assertTrue(4 < rng < 10, rng) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..5aafbef --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,182 @@ +import shutil +import tempfile +import unittest +from pathlib import Path + +from mapgen import config as C + +from mapgen.testing import FIXTURE_TOML + +TECT = """ +[[plate]] +id = "a" +seed = [0.0, 0.0] +kind = "continental" +motion = [90.0, 3.0] +[[plate]] +id = "b" +seed = [0.0, 90.0] +kind = "oceanic" +motion = [270.0, 3.0] +""" + + +class ConfigTest(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + (self.tmp / "config").mkdir() + shutil.copy(FIXTURE_TOML, self.tmp / "config" / "world.toml") + (self.tmp / "config" / "tectonics.toml").write_text(TECT) + + def tearDown(self): + shutil.rmtree(self.tmp) + + def test_loads_repo_world(self): + cfg, tect = C.load(self.tmp) + self.assertEqual(cfg["planet"]["radius_km"], 12742.0) + self.assertEqual(len(tect["plate"]), 2) + + def test_out_of_range_is_error(self): + p = self.tmp / "config" / "world.toml" + p.write_text(p.read_text().replace("tilt_deg = 20.0", "tilt_deg = 120.0")) + with self.assertRaisesRegex(C.ConfigError, "tilt_deg"): + C.load(self.tmp) + + def test_missing_key_is_error(self): + p = self.tmp / "config" / "world.toml" + p.write_text(p.read_text().replace("seed = 1296\n", "")) + with self.assertRaisesRegex(C.ConfigError, "seed"): + C.load(self.tmp) + + def test_params_merge_and_typo_guard(self): + cfg = {"erosion": {"k": 0.5}} + self.assertEqual(C.params(cfg, "erosion", {"k": 0.1, "m": 0.5}), {"k": 0.5, "m": 0.5}) + with self.assertRaisesRegex(C.ConfigError, "kk"): + C.params({"erosion": {"kk": 1}}, "erosion", {"k": 0.1}) + + def test_tectonics_validation(self): + (self.tmp / "config" / "tectonics.toml").write_text(TECT.replace('kind = "oceanic"', 'kind = "lava"')) + with self.assertRaisesRegex(C.ConfigError, "kind"): + C.load(self.tmp) + (self.tmp / "config" / "tectonics.toml").write_text(TECT.replace('id = "b"', 'id = "a"')) + with self.assertRaisesRegex(C.ConfigError, "duplicate"): + C.load(self.tmp) + + +REV = """ +[[plateau]] +name = "p1" +center = [-40.0, -18.0] +area_km2 = 3.0e6 +elongation = 1.9 +azimuth_deg = 30.0 +top_m = [1500.0, 3000.0] +[[plateau]] +name = "p2" +center = [0.0, 176.0] +area_km2 = 1.0e6 +top_m = [1000.0, 1500.0] +islands = true +[[land_patch]] +name = "fill" +center = [-10.0, 102.0] +radius_km = 2850.0 +edge_noise = 0.4 +[[zone]] +name = "R1" +field = "o2" +center = [-5.6, -118.8] +radius_km = 3200.0 +v = 1.0 +""" +ERAS = """ +[[event]] +name = "cut" +kind = "disintegrate" +center = [-10.0, 102.0] +radius_km = 3000.0 +depth_m = 3000.0 +[[event]] +name = "aura" +kind = "zone" +shape = "landmass" +seed = [-12.7, 121.5] +reach_km = [500.0, 1000.0] +edge_km = 5.0 +fields = { gravity_g = 0.35, pressure_bar = 2.0, o2_fraction = 0.35, fire_reactivity = 0.5 } +[eras] +order = ["before", "after"] +default = "after" +[eras.before] +label = "Before" +events = [] +[eras.after] +label = "After" +events = ["cut", "aura"] +""" + + +class RevisionConfigTest(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + (self.tmp / "config").mkdir() + shutil.copy(FIXTURE_TOML, self.tmp / "config" / "world.toml") + self._set() + + def tearDown(self): + shutil.rmtree(self.tmp) + + def _set(self, rev=REV, eras=ERAS): + (self.tmp / "config" / "tectonics.toml").write_text(TECT + rev) + (self.tmp / "config" / "eras.toml").write_text(eras) + + def test_valid_revision_config_loads(self): + _, t = C.load(self.tmp) + self.assertEqual([p["name"] for p in t["plateau"]], ["p1", "p2"]) + self.assertEqual([e["name"] for e in C.era_events(t, "after")], ["cut", "aura"]) + self.assertEqual(C.era_events(t, "before"), []) + + def test_era_events_are_cumulative(self): + eras = ERAS.replace('order = ["before", "after"]', 'order = ["before", "mid", "after"]').replace( + '[eras.after]\nlabel = "After"\nevents = ["cut", "aura"]', + '[eras.mid]\nlabel = "Mid"\nevents = ["aura"]\n[eras.after]\nlabel = "After"\nevents = ["cut"]') + self._set(REV, eras) + _, t = C.load(self.tmp) + self.assertEqual([e["name"] for e in C.era_events(t, "mid")], ["aura"]) + self.assertEqual([e["name"] for e in C.era_events(t, "after")], ["aura", "cut"]) + with self.assertRaises(C.ConfigError): + C.era_events(t, "never") + + def test_errors_name_the_problem(self): + dup = '[[plateau]]\nname = "p1"\ncenter = [60.0, 60.0]\narea_km2 = 1e6\ntop_m = [1000.0, 2000.0]\n' + cases = [ + (REV.replace("top_m = [1500.0, 3000.0]", "top_m = [3000.0, 1500.0]"), ERAS, "top_m"), + (REV.replace("center = [0.0, 176.0]", "center = [-41.0, -10.0]"), ERAS, "apart"), + (REV + dup, ERAS, "duplicate"), + (REV.replace('field = "o2"', 'field = "heat"'), ERAS, "field"), + (REV, ERAS.replace("fire_reactivity = 0.5", "sparkle = 1.0"), "sparkle"), + (REV, ERAS.replace('default = "after"', 'default = "later"'), "default"), + (REV, ERAS.replace('events = ["cut", "aura"]', 'events = ["cut", "nope"]'), "nope"), + (REV, ERAS.replace('kind = "disintegrate"', 'kind = "flood"'), "kind"), + (REV, ERAS.replace("edge_km = 5.0\n", ""), "edge_km"), + ] + for rev, eras, needle in cases: + with self.subTest(needle=needle): + self._set(rev, eras) + with self.assertRaises(C.ConfigError) as cm: + C.load(self.tmp) + self.assertIn(needle, str(cm.exception)) + + def test_events_belong_in_eras_toml(self): + (self.tmp / "config" / "tectonics.toml").write_text(TECT + REV + ERAS) + with self.assertRaises(C.ConfigError) as cm: + C.load(self.tmp) + self.assertIn("eras.toml", str(cm.exception)) + + def test_eras_toml_is_not_part_of_the_base_inputs_key(self): + from mapgen import pipeline as P + k = P.inputs_key(self.tmp, 1) + self._set(REV, ERAS.replace("fire_reactivity = 0.5", "fire_reactivity = 0.6")) + self.assertEqual(P.inputs_key(self.tmp, 1), k, "tuning an event must not rebuild the base world") + self._set(REV.replace("v = 1.0", "v = 0.9"), ERAS) + self.assertNotEqual(P.inputs_key(self.tmp, 1), k) diff --git a/tests/test_crust.py b/tests/test_crust.py new file mode 100644 index 0000000..1bfca60 --- /dev/null +++ b/tests/test_crust.py @@ -0,0 +1,114 @@ +import unittest + +import numpy as np + +from mapgen import crust as CR +from mapgen.plates import CONV, DIV +from tests.helpers import make_ctx + +TECT = {"plate": [], + "lip": [{"name": "l", "center": [10.0, -30.0], "radius_km": 1500.0}], + "volcano": [{"name": "v", "center": [-10.0, 20.0], "radius_km": 700.0, "height_m": 7000.0, + "scar_azimuths": [90.0]}]} + + +def ctx3(): + ctx = make_ctx(3, tect=TECT, cfg={"crust": {"edge_noise": 0.0}}) + g = ctx.grid + land = ((np.abs(g.lat) < 35) & (np.abs(g.lon) < 60)).astype(float) + bt = np.zeros(g.n, np.int8) + bt[np.abs(g.lon - 120) < 1.0] = DIV # a mid-ocean ridge along lon 120 + bt[np.abs(g.lon - 0) < 1.0] = CONV + ctx.data.update({"sk_land": land, "m_land_hint": np.zeros(g.n), "bnd_type": bt}) + return ctx + + +class CrustTest(unittest.TestCase): + def test_classes(self): + ctx = ctx3() + out = CR.run(ctx) + g = ctx.grid + age = out["age_class"] + self.assertTrue(out["continental"][g.cell_index(20.0, -50.0)]) + self.assertFalse(out["continental"][g.cell_index(0.0, 150.0)]) + self.assertEqual(age[g.cell_index(0.0, 150.0)], CR.OCEANIC) + self.assertEqual(age[g.cell_index(10.0, -30.0)], CR.LIP) + self.assertEqual(age[g.cell_index(-10.0, 20.0)], CR.VOLCANO) + self.assertEqual(age[g.cell_index(-10.0, 24.0)], CR.SCAR) # east sector, ~900 km out + self.assertEqual(age[g.cell_index(20.0, 3.0)], CR.POST_OROGEN) + + def test_ocean_age_grows_from_ridge(self): + ctx = ctx3() + out = CR.run(ctx) + g = ctx.grid + ocean = ~out["continental"] & (np.abs(g.lat) < 20) & (np.abs(g.lon - 120) < 20) + r = np.corrcoef(np.abs(g.lon[ocean] - 120), out["ocean_age_myr"][ocean])[0, 1] + self.assertGreater(r, 0.95) + self.assertTrue(np.all(out["ocean_age_myr"][out["continental"]] == 0)) + + +class ShelfTest(unittest.TestCase): + def test_continental_shelf_is_resolution_independent(self): + from mapgen.graph import distance_to + for res in (3, 4): + ctx = make_ctx(res, tect={"plate": []}, cfg={"crust": {"edge_noise": 0.0}}) + g = ctx.grid + land = ((np.abs(g.lat) < 30) & (np.abs(g.lon) < 40)).astype(float) + ctx.data.update({"sk_land": land, "m_land_hint": np.zeros(g.n), "bnd_type": np.zeros(g.n, np.int8)}) + cont = CR.run(ctx)["continental"] + d = distance_to(g, land > 0.5) + self.assertTrue(np.all(cont[land > 0.5]), res) + self.assertGreater(cont[(d > 0) & (d < 300)].mean(), 0.8, res) # shelf + self.assertEqual(cont[d > 700].sum(), 0, res) # but not far out + + +class FractalMarginTest(unittest.TestCase): + @staticmethod + def _edge(noise): + from mapgen.graph import components + ctx = make_ctx(4, tect={"plate": []}, cfg={"crust": {"edge_noise": noise}}) + g = ctx.grid + land = ((np.abs(g.lat) < 30) & (np.abs(g.lon) < 60)).astype(float) + ctx.data.update({"sk_land": land, "m_land_hint": np.zeros(g.n), "bnd_type": np.zeros(g.n, np.int8)}) + cont = CR.run(ctx)["continental"] + lab = components(g, cont) + sizes = np.bincount(lab[cont]) + return int(np.sum(cont[g.src] != cont[g.dst])), int(np.sum((sizes > 0) & (sizes < 0.05 * sizes.max()))) + + def test_margins_are_fractal_with_fragments(self): + smooth, _ = self._edge(0.0) + edges, fragments = self._edge(CR.DEFAULTS["edge_noise"]) + self.assertGreater(edges, 1.4 * smooth) + self.assertGreaterEqual(fragments, 3) + + +class RevisionCrustTest(unittest.TestCase): + def test_no_patch_no_plateau_keeps_crust(self): + out = CR.run(ctx3()) + np.testing.assert_array_equal(out["continental"], out["continental_base"]) + self.assertTrue(np.all(out["plateau_id"] == -1)) + + def test_land_patch_adds_continental_crust_only_around_its_disc(self): + ctx = ctx3() + ctx.tect = {**TECT, "land_patch": [{"name": "fill", "center": [0.0, 150.0], "radius_km": 2000.0, + "edge_noise": 0.4}]} + out = CR.run(ctx) + g = ctx.grid + i = g.cell_index(0.0, 150.0) + self.assertTrue(out["continental"][i]) + self.assertFalse(out["continental_base"][i]) + far = CR.center_dist(g, [0.0, 150.0]) > 4500.0 + np.testing.assert_array_equal(out["continental"][far], out["continental_base"][far]) + self.assertEqual(out["age_class"][i] == CR.OCEANIC, False) + + def test_plateau_cells_become_continental_crust_with_ids(self): + ctx = ctx3() + ctx.tect = {**TECT, "plateau": [{"name": "p", "center": [0.0, 150.0], "area_km2": 3.0e6, + "top_m": [1500.0, 3000.0]}]} + out = CR.run(ctx) + i = ctx.grid.cell_index(0.0, 150.0) + self.assertEqual(out["plateau_id"][i], 0) + self.assertTrue(out["continental"][i]) + self.assertFalse(out["continental_base"][i]) + self.assertNotEqual(out["age_class"][i], CR.OCEANIC) + self.assertGreater(out["ocean_age_myr"][i], 0.0, "the plateau keeps the age of the sea floor around it") diff --git a/tests/test_elevation.py b/tests/test_elevation.py new file mode 100644 index 0000000..3a96460 --- /dev/null +++ b/tests/test_elevation.py @@ -0,0 +1,207 @@ +import unittest + +import numpy as np + +from mapgen import crust, elevation as EL, plates +from tests.helpers import make_ctx + + +def scenario(tect, land_fn, land_fraction, res=3, gravity=None, lock=None): + ctx = make_ctx(res, tect=tect, cfg={"build": {"land_fraction": land_fraction}}) + g = ctx.grid + z0 = np.zeros(g.n) + ctx.data.update({"sk_land": land_fn(g).astype(float), "m_land_hint": z0, "sk_mountains": z0, + "m_mountain_hint": z0, "m_gravity_zones": z0 if gravity is None else gravity(g), + "m_lock": z0 if lock is None else lock(g)}) + ctx.data.update(plates.run(ctx)) + ctx.data.update(crust.run(ctx)) + return ctx + + +SUBDUCT = {"plate": [ + {"id": "c", "seed": [0.0, -30.0], "kind": "continental", "motion": [90.0, 3.0]}, + {"id": "o", "seed": [0.0, 10.0], "kind": "oceanic", "motion": [270.0, 5.0]}]} +COLLIDE = {"plate": [ + {"id": "a", "seed": [0.0, -30.0], "kind": "continental", "motion": [90.0, 4.0]}, + {"id": "b", "seed": [0.0, 30.0], "kind": "continental", "motion": [270.0, 4.0]}]} + + +class ElevationTest(unittest.TestCase): + def test_sea_level_hits_target(self): + rng = np.random.default_rng(1) + z = rng.normal(size=5000) * 1000 + area = rng.uniform(1, 2, size=5000) + zs = EL.solve_sea_level(z, area, 0.3) + self.assertAlmostEqual(area[zs > 0].sum() / area.sum(), 0.3, delta=0.005) + + def test_subduction_trench_and_coastal_range(self): + ctx = scenario(SUBDUCT, lambda g: (np.abs(g.lat) < 40) & (g.lon > -70) & (g.lon < 0), 0.12) + out = EL.run(ctx) + z, g = out["elevation_m"], ctx.grid + band = np.abs(g.lat) < 30 + ocean_side = band & (ctx.data["plate"] == 1) & (out["d_sub_km"] < 300) + cont_side = band & (ctx.data["plate"] == 0) & (out["d_over_km"] < 600) + self.assertLess(z[ocean_side].min(), -7000) + self.assertGreater(z[cont_side].max(), 2500) + + def test_collision_builds_high_range(self): + ctx = scenario(COLLIDE, lambda g: (np.abs(g.lat) < 30) & (np.abs(g.lon) < 60), 0.15) + out = EL.run(ctx) + near = out["d_coll_km"] < 300 + self.assertGreater(out["elevation_m"][near].max(), 6000) + self.assertLessEqual(out["elevation_m"].max(), 12000) + + def test_low_gravity_zone_raises_relief(self): + land = lambda g: (np.abs(g.lat) < 30) & (np.abs(g.lon) < 60) + zone = lambda g: np.where((np.abs(g.lat) < 20) & (g.lon > 10) & (g.lon < 50), -1.0, 0.0) + base = EL.run(scenario(COLLIDE, land, 0.15))["elevation_m"] + ctx = scenario(COLLIDE, land, 0.15, gravity=zone) + low = EL.run(ctx)["elevation_m"] + inside = zone(ctx.grid) < 0 + self.assertGreater(low[inside & (base > 0)].mean(), base[inside & (base > 0)].mean() * 1.5) + + def test_lock_forces_coast(self): + land = lambda g: (np.abs(g.lat) < 30) & (np.abs(g.lon) < 60) + lock = lambda g: np.ones(g.n) + ctx = scenario(COLLIDE, land, 0.15, lock=lock) + z = EL.run(ctx)["elevation_m"] + self.assertTrue(np.all((z > 0) == (ctx.data["sk_land"] > 0.5))) + + +class SeaLevelRegressionTest(unittest.TestCase): + LAND = staticmethod(lambda g: (g.lat > -25) & (g.lat < 45) & (np.abs(g.lon) < 62)) # ≈ 0.195 of the sphere + + def test_continents_not_lifted(self): + ctx = scenario(COLLIDE, self.LAND, 0.19) + out = EL.run(ctx) + z, g = out["elevation_m"], ctx.grid + self.assertLess(np.median(z[self.LAND(g) & (out["d_coll_km"] > 1500)]), 1500) + self.assertLess(np.median(z[~ctx.data["continental"]]), -3000) + + def test_target_above_continental_area_errors(self): + from mapgen.pipeline import StageError + with self.assertRaisesRegex(StageError, "land_fraction"): + EL.run(scenario(COLLIDE, self.LAND, 0.30)) + + +class LockThresholdTest(unittest.TestCase): + def test_near_neutral_lock_does_nothing(self): + land = lambda g: (np.abs(g.lat) < 30) & (np.abs(g.lon) < 60) + base = EL.run(scenario(COLLIDE, land, 0.15))["elevation_m"] + tiny = EL.run(scenario(COLLIDE, land, 0.15, lock=lambda g: np.full(g.n, 1 / 255)))["elevation_m"] + np.testing.assert_array_equal(base, tiny) + + +class MarginProfileTest(unittest.TestCase): + @staticmethod + def _profile(extra): + from mapgen.graph import distance_to, ocean_mask + land = lambda g: (np.abs(g.lat) < 30) & (np.abs(g.lon) < 60) + g = make_ctx(4).grid + frac = g.area_km2[land(g)].sum() / g.area_km2.sum() + ctx = make_ctx(4, tect=COLLIDE, cfg={"build": {"land_fraction": round(frac - 0.003, 4)}, + "crust": {"edge_noise": 0.0}, "elevation": extra}) + z0 = np.zeros(g.n) + ctx.data.update({"sk_land": land(g).astype(float), "m_land_hint": z0, "sk_mountains": z0, + "m_mountain_hint": z0, "m_gravity_zones": z0, "m_lock": z0}) + ctx.data.update(plates.run(ctx)) + ctx.data.update(crust.run(ctx)) + z = EL.run(ctx)["elevation_m"].astype(float) + sea = ocean_mask(g, z, 5.0e6) + d_coast_land = distance_to(g, sea) + d_coast_sea = distance_to(g, ~sea) + far = (~sea) & (d_coast_land > 1200) & (np.abs(g.lon) > 20) # interior, away from the collision belt + coastal = (~sea) & (d_coast_land < 250) & (np.abs(g.lon) > 20) + shelf = sea & (d_coast_sea < 200) + return np.median(z[coastal]), np.median(z[far]), np.median(z[shelf]) + + def test_coastal_lowlands_and_shelves(self): + step = self._profile({"coast_noise_m": 0.0, "margin_km": 1.0, "slope_km": 1.0}) + coastal, interior, shelf = self._profile({}) + self.assertLess(coastal, interior - 300) # coastal plains sit well below the interior + self.assertGreater(shelf, -800) # a shallow shelf fringes the coast + self.assertGreaterEqual(step[0], step[1] - 300) # the old step margin had no coastal lowlands + + +class ConnectedSeaLevelTest(unittest.TestCase): + def test_interior_pits_do_not_count_as_sea(self): + from mapgen.graph import ocean_mask + from tests.helpers import small_grid + g = small_grid(3) + land = (np.abs(g.lat) < 40) & (np.abs(g.lon) < 70) + z = np.where(land, 300.0 + 40.0 * (70.0 - np.abs(g.lon)), -4000.0) # rises inland (continuous) + pits = land & ((g.lat % 10) < 3) & ((g.lon % 10) < 3) & (np.abs(g.lon) < 55) + z[pits] = -500.0 # many deep interior pits + target = 0.8 * g.area_km2[land].sum() / g.area_km2.sum() + zs = EL.solve_sea_level_connected(g, z, target, 5.0e6) + sea = ocean_mask(g, zs, 5.0e6) + self.assertAlmostEqual(g.area_km2[~sea].sum() / g.area_km2.sum(), target, delta=0.01) + + +class RevisionElevationTest(unittest.TestCase): + LAND = staticmethod(lambda g: np.abs(g.lon + 30) < 25) + + def test_without_patches_the_relief_is_solved_once(self): + from unittest import mock + ctx = scenario(SUBDUCT, self.LAND, 0.1) + with mock.patch.object(EL, "_relief", wraps=EL._relief) as rel: + EL.run(ctx) + self.assertEqual(rel.call_count, 1) + + def test_a_land_patch_keeps_the_sea_level_of_the_world_without_it(self): + from mapgen.crust import center_dist + za = EL.run(scenario(SUBDUCT, self.LAND, 0.1))["elevation_m"] + tect = {**SUBDUCT, "land_patch": [{"name": "fill", "center": [0.0, 120.0], "radius_km": 1500.0}]} + b = scenario(tect, self.LAND, 0.1) + zb = EL.run(b)["elevation_m"] + g = b.grid + far = (za > 0) & (center_dist(g, [0.0, 120.0]) > 5000.0) + self.assertGreater(far.sum(), 20) + np.testing.assert_allclose(zb[far], za[far], atol=1e-3) # the other coasts don't move + i = g.cell_index(0.0, 120.0) + self.assertGreater(zb[i] - za[i], 1000.0, "the patch is continental ground now") + + def test_a_land_patch_is_land_though_the_sea_level_drowns_bare_crust(self): + from mapgen.crust import center_dist + tect = {**SUBDUCT, "land_patch": [{"name": "fill", "center": [0.0, 120.0], "radius_km": 1500.0}]} + b = scenario(tect, self.LAND, 0.05) + z = EL.run(b)["elevation_m"] + inner = center_dist(b.grid, [0.0, 120.0]) < 1000.0 + self.assertGreater(float(np.mean(z[inner] > 0)), 0.9) + + def test_plateaus_get_their_surface_after_the_sea_level_solve(self): + from mapgen import plateaus as PL + from mapgen.crust import center_dist + plats = [{"name": "p", "center": [0.0, 120.0], "area_km2": 3.0e6, "top_m": [1500.0, 3000.0]}, + {"name": "q", "center": [40.0, 150.0], "area_km2": 1.0e6, "top_m": [1000.0, 1500.0], + "islands": True}] + za = EL.run(scenario(SUBDUCT, self.LAND, 0.1))["elevation_m"] + b = scenario({**SUBDUCT, "plateau": plats}, self.LAND, 0.1) + zb = EL.run(b)["elevation_m"] + g, ids = b.grid, b.data["plateau_id"] + away = (center_dist(g, [0.0, 120.0]) > 2500.0) & (center_dist(g, [40.0, 150.0]) > 1500.0) + np.testing.assert_allclose(zb[away], za[away], atol=1e-3) + _, short = PL.semi_axes(plats[0]) + core = np.flatnonzero(ids == 0) + core = core[(1.0 - PL.rho(g.xyz[core], plats[0], b.seed, g.radius_km)) * short > PL.MARGIN_KM] + self.assertGreater(len(core), 5) + self.assertTrue(1500.0 - PL.RELIEF_M <= float(np.median(-zb[core])) <= 3000.0 + PL.RELIEF_M) + self.assertLessEqual(zb[ids == 0].max(), PL.HIDDEN_MAX_M) + near_q = center_dist(g, [40.0, 150.0]) < 1500.0 + self.assertGreater(zb[near_q].max(), 0.0, "the island plateau breaks the surface") + + def test_land_added_marks_what_the_patch_makes_land(self): + from mapgen.crust import center_dist + from mapgen.graph import ocean_mask + a = EL.run(scenario(SUBDUCT, self.LAND, 0.05)) + tect = {**SUBDUCT, "land_patch": [{"name": "fill", "center": [0.0, 120.0], "radius_km": 1500.0}]} + b = scenario(tect, self.LAND, 0.05) + ob = EL.run(b) + g = b.grid + land_a = ~ocean_mask(g, a["elevation_m"]) + land_b = ~ocean_mask(g, ob["elevation_m"]) + added = ob["land_added"] + self.assertGreater(float(added[center_dist(g, [0.0, 120.0]) < 1000.0].mean()), 0.9) + self.assertFalse((added & land_a).any(), "land without the patch is not added land") + self.assertFalse((added & ~land_b).any(), "added land is land") + self.assertFalse(a["land_added"].any(), "no patch, no plateau: nothing added") diff --git a/tests/test_environment.py b/tests/test_environment.py new file mode 100644 index 0000000..0f4c5c6 --- /dev/null +++ b/tests/test_environment.py @@ -0,0 +1,173 @@ +import unittest + +import numpy as np + +from mapgen import environment as EN +from tests.helpers import make_ctx + + +def name(bio, p, tmin): + z, _ = EN.holdridge(np.array([bio]), np.array([p]), np.array([tmin])) + return EN.HOLDRIDGE_NAMES[z[0]] + + +class HoldridgeTest(unittest.TestCase): + def test_count(self): + self.assertEqual(len(EN.HOLDRIDGE_NAMES), 38) + + def test_table(self): + self.assertEqual(name(26, 3000, 20), "tropical moist forest") + self.assertEqual(name(26, 9000, 20), "tropical rain forest") + self.assertEqual(name(26, 100, 20), "tropical desert") + self.assertEqual(name(0.5, 100, -30), "polar desert") + self.assertEqual(name(8, 300, -10), "cool temperate steppe") + self.assertEqual(name(15, 1500, -5), "warm temperate moist forest") + self.assertEqual(name(15, 1500, 5), "subtropical moist forest") + self.assertEqual(name(4, 700, -20), "boreal wet forest") + self.assertEqual(name(2, 200, -25), "subpolar moist tundra") + + def test_polar_desert_with_summer_monsoon(self): + tag = EN.seasonality(np.array([180.0, 20.0]), np.array([20.0, 180.0]), np.array([80.0, 80.0]), + np.array([-30.0, -30.0]), np.array([100.0, 100.0]), EN.DEFAULTS) + self.assertEqual(tag[0], EN.SEAS_W) # NH: wet June (summer), dry December + self.assertEqual(tag[1], EN.SEAS_S) # NH: dry summer + sh = EN.seasonality(np.array([20.0]), np.array([180.0]), np.array([-80.0]), np.array([-30.0]), + np.array([100.0]), EN.DEFAULTS) + self.assertEqual(sh[0], EN.SEAS_W) # SH summer is December + + def test_tropical_monsoon(self): + tag = EN.seasonality(np.array([3000.0]), np.array([200.0]), np.array([15.0]), np.array([20.0]), + np.array([1800.0]), EN.DEFAULTS) + self.assertEqual(tag[0], EN.SEAS_M) + + +class StageTest(unittest.TestCase): + def test_run(self): + ctx = make_ctx(3) + g = ctx.grid + n = g.n + land = (np.abs(g.lat) < 40) & (np.abs(g.lon) < 60) + z = np.where(land, 300.0, -4000.0) + z = np.where(land & (np.abs(g.lon) < 4), 4000.0, z) + zeros = np.zeros(n) + ctx.data.update({ + "elevation_eroded_m": z, "continental": land, "age_class": np.where(land, 1, 0).astype(np.int8), + "d_over_km": np.full(n, np.inf), "T_mean": np.where(np.abs(g.lat) > 30, -5.0, 20.0), + "T_min": np.full(n, -10.0), "T_range": np.full(n, 20.0), "P_ann": np.full(n, 800.0), + "P_jun": np.full(n, 800.0), "P_dec": np.full(n, 800.0), "biotemp": np.full(n, 10.0), + "dist_ocean_km": np.where(land, 1000.0, 0.0), "river": np.zeros(n, bool), + "strahler": np.zeros(n, np.int8), "discharge_km3_yr": zeros, "lake": np.zeros(n, bool), + "salt_flat": np.zeros(n, bool)}) + out = EN.run(ctx) + self.assertEqual(out["landform"][g.cell_index(0.0, 0.0)], EN.LF_MOUNTAINS) + self.assertEqual(out["landform"][g.cell_index(0.0, 150.0)], EN.LF_OCEAN) + self.assertEqual(out["ground"][g.cell_index(35.0, 40.0)], EN.GR_PERMAFROST) + for k in ("holdridge", "hold_region", "seasonality", "landform", "relief_m", "lithology", "ground", + "coal_potential", "iron_potential"): + self.assertEqual(len(out[k]), n) + + +class OceanMaskEnvTest(unittest.TestCase): + def test_inland_basin_not_ocean_landform(self): + ctx = make_ctx(3) + g = ctx.grid + n = g.n + land = (np.abs(g.lat) < 40) & (np.abs(g.lon) < 60) + z = np.where(land, 300.0, -4000.0) + basin = (np.abs(g.lat) < 6) & (np.abs(g.lon) < 6) + z[basin] = -30.0 + zeros = np.zeros(n) + ctx.data.update({ + "elevation_eroded_m": z, "ocean": ~land, "continental": land, "age_class": np.where(land, 1, 0).astype(np.int8), + "d_over_km": np.full(n, np.inf), "T_mean": np.full(n, 20.0), "T_min": np.full(n, 10.0), + "T_range": np.full(n, 10.0), "P_ann": np.full(n, 800.0), "P_jun": np.full(n, 800.0), + "P_dec": np.full(n, 800.0), "biotemp": np.full(n, 20.0), "dist_ocean_km": np.where(land, 1000.0, 0.0), + "river": np.zeros(n, bool), "strahler": np.zeros(n, np.int8), "discharge_km3_yr": zeros, + "lake": np.zeros(n, bool), "salt_flat": np.zeros(n, bool)}) + out = EN.run(ctx) + self.assertFalse(np.any(out["landform"][basin] == EN.LF_OCEAN)) + + +class ReliefResolutionTest(unittest.TestCase): + def test_relief_window_is_in_km(self): + from tests.helpers import small_grid + med = [] + for res in (2, 3): + g = small_grid(res) + z = 1000.0 * np.sin(10.0 * g.xyz[:, 0]) + 800.0 * np.cos(9.0 * g.xyz[:, 1]) + _, relief = EN.landform(g, z, np.ones(g.n, np.int8), np.ones(g.n, np.int8), np.full(g.n, np.inf), + np.full(g.n, 800.0), np.zeros(g.n, bool), relief_km=700.0) + med.append(np.median(relief)) + self.assertAlmostEqual(med[0] / med[1], 1.0, delta=0.3) + + +class WetlandRuleTest(unittest.TestCase): + def test_wetlands_need_real_rivers_on_real_flats(self): + from types import SimpleNamespace + from mapgen import graph as G + g = small_grid_env() + n = g.n + z = np.where(g.lat > -30, 100.0 + 0.0001 * g.lat, -3000.0) # a very flat humid plain + common = dict(lit=np.full(n, EN.LI_GRANITE, np.int8), t_mean=np.full(n, 15.0), t_min=np.full(n, 5.0), + p_ann=np.full(n, 1200.0), dist_ocean=np.full(n, 2000.0), river=np.zeros(n, bool), + strahler=np.zeros(n, np.int8), lake=np.zeros(n, bool), salt_flat=np.zeros(n, bool)) + def wet(q): + gr = EN.ground(g, z, common["lit"], common["t_mean"], common["t_min"], common["p_ann"], common["dist_ocean"], + common["river"], common["strahler"], np.full(n, q), common["lake"], common["salt_flat"], + EN.DEFAULTS, g.lat <= -30) + return np.mean(gr[g.lat > 0] == EN.GR_WETLAND) + self.assertLess(wet(3.0), 0.05) # small streams don't make a wetland + self.assertGreater(wet(8.0), 0.9) # a big river across a flat does + + +def small_grid_env(): + from tests.helpers import small_grid + return small_grid(3) + + +class DepositsTest(unittest.TestCase): + def test_shares_rules_and_main(self): + from mapgen import minerals as MN + from mapgen import environment as EN + from mapgen.crust import CRATON, POST_OROGEN + g = make_ctx(4).grid + n = g.n + land = np.abs(g.lat) < 60 + mountain = land & (g.lon > 0) & (g.lon < 60) + f = {"land": land, "z": np.where(mountain, 3500.0, 200.0), "lit": np.where(mountain, EN.LI_METAMORPHIC, EN.LI_SANDSTONE).astype(np.int8), + "age": np.where(mountain, POST_OROGEN, CRATON), "lf": np.where(mountain, EN.LF_MOUNTAINS, EN.LF_PLAIN), + "relief": np.where(mountain, 2500.0, 50.0), "t_mean": np.full(n, 15.0), "p_ann": np.full(n, 900.0), + "d_coll": np.full(n, np.inf), "salt": np.zeros(n, bool), "endo": np.zeros(n, bool), + "dist_ocean": np.full(n, 500.0), "coal": land & ~mountain} + bits, main = MN.place(g, f, 7) + self.assertEqual(len(MN.DEPOSITS), 32) + self.assertFalse(bits[~land].any()) + for name in ("ironstone", "oil_gas", "iron_high", "tungsten"): + has = (bits & np.uint32(MN.BIT[name])) > 0 + share = dict((d[0], d[2]) for d in MN.DEPOSITS)[name] + self.assertLessEqual(has.sum(), round(share * land.sum()) + 1, name) + self.assertTrue(has.any(), name) + for name in MN.DEEP[:2]: # the good stuff: in the mountains only here + has = (bits & np.uint32(MN.BIT[name])) > 0 + self.assertTrue(np.all(mountain[has]), name) + some = bits > 0 + self.assertTrue(np.all(main[some] > 0) and np.all(main[~some] == 0)) + k = np.flatnonzero(some)[0] + self.assertTrue(bits[k] & np.uint32(1 << (int(main[k]) - 1))) + + def test_deposits_are_sprinkled_not_one_hotspot(self): + from mapgen import minerals as MN + g = make_ctx(4).grid + land = np.abs(g.lat) < 60 + hot = land & (g.lat > 0) & (g.lat < 30) & (g.lon > 0) & (g.lon < 40) # a world-class district + score = np.where(hot, 3.0, np.where(land, 1.0, 0.0)) + prov = MN.provinces(g, 7) + sel = MN._rank_select(score, 0.02, land, prov) + self.assertEqual(sel.sum(), round(0.02 * land.sum())) + self.assertFalse(sel[~land].any()) + self.assertGreater((sel & hot).sum(), 0.3 * sel.sum()) # the district keeps the most + self.assertGreater((sel & ~hot).sum(), 0.3 * sel.sum()) # the rest is sprinkled + self.assertGreater(len(np.unique(prov[sel & ~hot])), 0.3 * len(np.unique(prov[land & ~hot]))) + self.assertFalse(MN._rank_select(np.where(hot, 1.0, 0.0), 0.02, land, prov)[~hot].any()) # rules still rule + poor = MN._rank_select(np.where(hot, 10.0, np.where(land, 1.0, 0.0)), 0.02, land, prov) + self.assertFalse(poor[~hot].any()) # far poorer ground: no local mines diff --git a/tests/test_eras.py b/tests/test_eras.py new file mode 100644 index 0000000..96b7c82 --- /dev/null +++ b/tests/test_eras.py @@ -0,0 +1,303 @@ +import shutil +import importlib.util +import tempfile +import unittest +from pathlib import Path + +import numpy as np + +from mapgen import eras as ER, events as EV, pipeline as P +from mapgen.graph import components, distance_to +from mapgen.sphere import gc_dist_km, latlon_to_xyz, xyz_to_latlon +from tests.test_render import small_world + +R = 12742.0 +ERAS = """ +[[event]] +name = "cut" +kind = "disintegrate" +center = {cut} +radius_km = 3000.0 +depth_m = 3000.0 +[[event]] +name = "aura" +kind = "zone" +shape = "landmass" +seed = {seed} +reach_km = [500.0, 1000.0] +edge_km = 5.0 +fields = {{ gravity_g = 0.35, pressure_bar = 2.0, o2_fraction = 0.35, fire_reactivity = {fire} }} +[[event]] +name = "rise" +kind = "volcano" +center = {vol} +radius_km = 900.0 +peak_m = 1500.0 +peak_mode = "absolute" +[[event]] +name = "ghost" +kind = "zone" +shape = "landmass" +seed = {seed} +reach_km = [100.0, 200.0] +edge_km = 5.0 +fields = {{ fire_reactivity = 0.2 }} +[eras] +order = ["before", "glow", "after", "risen", "haunt"] +default = "after" +[eras.before] +label = "Before" +events = [] +[eras.glow] +label = "Glow" +events = ["aura"] +[eras.after] +label = "After" +events = ["cut"] +[eras.risen] +label = "Risen" +events = ["rise"] +years = 8000 +[eras.haunt] +label = "Haunt" +events = ["ghost"] +""" + + +def cli(): + spec = importlib.util.spec_from_file_location("mapgen_cli", Path(ER.__file__).parents[1] / "mapgen.py") + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) # the script (mapgen/ is the package) + return m + + +def latlon(p): + lat, lon = xyz_to_latlon(np.asarray(p, dtype=np.float64)) + return [round(float(lat), 3), round(float(lon), 3)] + + +class ErasTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.root = Path(tempfile.mkdtemp()) + small_world(cls.root) + cls.base = P.build(cls.root, 2, log=lambda m: None) + g, d = cls.base.grid, cls.base.data + land = ~np.asarray(d["ocean"]) + lab = components(g, land) + big = lab == np.bincount(lab[land]).argmax() + mid = g.xyz[big].mean(axis=0) + cls.cut = latlon(g.xyz[big][np.argmax(g.xyz[big] @ (mid / np.linalg.norm(mid)))]) + far = gc_dist_km(g.xyz, latlon_to_xyz(*cls.cut), R) + cls.seed = latlon(g.xyz[np.argmax(np.where(big, far, -1.0))]) # stays land after the cut + cls.vol_i = int(np.argmax(distance_to(g, land))) # the loneliest sea + cls.vol = latlon(g.xyz[cls.vol_i]) + cls.g0 = float(cls.base.cfg["planet"]["gravity_g"]) + cls.write_eras() + for n in ("glow", "after", "risen", "haunt"): + ER.build_era(cls.root, 2, n, log=lambda m: None, base=cls.base) + + def test_low_memory_era_writes_identical_files(self): + # oracle: the normal-mode era built in setUpClass + tmp = Path(tempfile.mkdtemp()) + try: + shutil.copytree(self.root, tmp, dirs_exist_ok=True) + shutil.rmtree(ER.era_dir(tmp, 2, "glow")) + ER.build_era(tmp, 2, "glow", log=lambda m: None, low_memory=True) + a, b = ER.era_dir(self.root, 2, "glow"), ER.era_dir(tmp, 2, "glow") + fa = sorted(p.relative_to(a) for p in a.rglob("*") if p.is_file()) + self.assertEqual(fa, sorted(p.relative_to(b) for p in b.rglob("*") if p.is_file())) + for f in fa: + self.assertEqual((a / f).read_bytes(), (b / f).read_bytes(), str(f)) + finally: + shutil.rmtree(tmp) + + @classmethod + def write_eras(cls, fire=0.5): + (cls.root / "config" / "eras.toml").write_text(ERAS.format(cut=cls.cut, seed=cls.seed, vol=cls.vol, fire=fire)) + + def load(self, name=None): + d = self.root / "out" / "r2" if name is None else ER.era_dir(self.root, 2, name) + with np.load(d / "cells.npz") as z: + return {k: z[k] for k in z.files} + + def test_outside_the_mask_is_the_base(self): + b = self.load() + for n in ("glow", "after", "risen"): + e = self.load(n) + m = e["era_mask"] + self.assertTrue(m.any() and not m.all(), n) + for k, v in b.items(): + if v.shape[:1] == m.shape: + np.testing.assert_array_equal(e[k][~m], v[~m], err_msg=f"{n}: {k}") + + def test_deposits_are_the_base_geology(self): + b = self.load() # an event moves ground; it neither makes nor unmakes ore + for n in ("glow", "after", "risen"): + e = self.load(n) + for k in ("deposits", "deposit_main", "iron_potential"): + np.testing.assert_array_equal(e[k], b[k], err_msg=f"{n}: {k}") + + def test_disintegration_only_lowers_and_reaches_depth(self): + b, e = self.load(), self.load("after") + zb, ze = b["elevation_eroded_m"].astype(np.float64), e["elevation_eroded_m"].astype(np.float64) + self.assertTrue(np.all(ze <= zb + 1e-3)) + d = gc_dist_km(b["g_xyz"], latlon_to_xyz(*self.cut), R) + _, z_ref = EV.disintegrate(b["g_xyz"], zb, ~b.get("open_water", b["ocean"]), {"center": self.cut, "radius_km": 3000.0, + "depth_m": 3000.0}, R) + inner = d < 1500.0 + self.assertTrue(inner.any()) + self.assertTrue(np.all(ze[inner] <= z_ref - 3000.0 * np.sqrt(0.75) + 1e-3)) + self.assertTrue((e["ocean"] | e["lake"])[inner].all(), "the bowl fills with water") + + def test_zone_fields_hold_inside_the_landmass(self): + b, e = self.load(), self.load("glow") + land, w = e["zone_aura_land"], e["zone_aura"] + self.assertTrue(land.any()) + self.assertTrue(np.all(w[land] == 1.0)) + np.testing.assert_allclose(e["gravity_g"][land], 0.35, rtol=1e-6) + np.testing.assert_allclose(e["fire_reactivity"][land], 0.5) + np.testing.assert_allclose(e["po2_bar"], e["o2_fraction"] * e["pressure_bar"], rtol=1e-5) + np.testing.assert_array_equal(e["gravity_g"][w == 0], b["gravity_g"][w == 0]) + + def test_zone_follows_the_landmass_before_the_era(self): + b, e = self.load(), self.load("after") + d = gc_dist_km(b["g_xyz"], latlon_to_xyz(*self.cut), R) + lost = ~b["open_water"] & e["open_water"] & (d < 1500.0) + self.assertTrue(lost.any()) + self.assertTrue(e["zone_aura_land"][lost].all(), "the old coast, not the bitten one") + np.testing.assert_allclose(e["gravity_g"][lost], 0.35, rtol=1e-6) + + def test_plants_in_a_zone(self): + from mapgen import environment as EN + e = self.load("glow") + land = e["zone_aura_land"] & ~e["ocean"] + zone, _ = EN.holdridge(e["biotemp"], e["P_ann"] * np.sqrt(2.0), e["T_min"]) + np.testing.assert_array_equal(e["holdridge"][land], zone[land]) + np.testing.assert_allclose(e["plant_height_x"], self.g0 / e["gravity_g"], rtol=1e-5) + + def test_era_years_set_its_erosion(self): + e = self.load("risen") + self.assertGreater(float(e["elevation_eroded_m"][self.vol_i]), 1400.0, "8,000 years barely wear a cone") + self.assertNotEqual(ER.fingerprint("k", [("a", [], 8000)]), ER.fingerprint("k", [("a", [], 9000)])) + + def test_steps_list_each_eras_own_events_and_years(self): + from mapgen import config as C + st = ER.steps(C.load(self.root)[1], "risen") + self.assertEqual([(n, [e["name"] for e in ev], y) for n, ev, y in st], + [("before", [], None), ("glow", ["aura"], None), ("after", ["cut"], None), + ("risen", ["rise"], 8000)]) + + def test_a_later_zone_follows_the_land_of_its_own_era(self): + b, e = self.load(), self.load("haunt") + d = gc_dist_km(b["g_xyz"], latlon_to_xyz(*self.cut), R) + lost = ~b["open_water"] & self.load("after")["open_water"] & (d < 1500.0) + self.assertTrue(lost.any()) + self.assertFalse(e["zone_ghost_land"][lost].any(), "ghost came after the cut: the bitten coast") + self.assertTrue(e["zone_aura_land"][lost].all(), "aura still follows the land before the cut") + + def test_a_new_label_needs_no_rebuild(self): + import json + f = ER.era_dir(self.root, 2, "glow") / "cells.npz" + before = f.stat().st_mtime_ns + p = self.root / "config" / "eras.toml" + p.write_text(p.read_text().replace('label = "Glow"', 'label = "The Glow"')) + try: + logs = [] + ER.build_era(self.root, 2, "glow", log=logs.append) + self.assertIn("era glow: cached", logs) + meta = json.loads((ER.era_dir(self.root, 2, "glow") / "cells_meta.json").read_text()) + self.assertEqual(meta["era"]["label"], "The Glow") + self.assertEqual(f.stat().st_mtime_ns, before) + finally: + self.write_eras() + ER.build_era(self.root, 2, "glow", log=lambda m: None) + + def test_a_renamed_era_whose_folder_moved_needs_no_rebuild(self): + import json + old, new = ER.era_dir(self.root, 2, "glow"), ER.era_dir(self.root, 2, "shine") + before = (old / "cells.npz").stat().st_mtime_ns + p = self.root / "config" / "eras.toml" + p.write_text(p.read_text().replace('"glow"', '"shine"').replace("[eras.glow]", "[eras.shine]")) + old.rename(new) + try: + logs = [] + ER.build_era(self.root, 2, "shine", log=logs.append) + self.assertIn("era shine: cached", logs) + self.assertEqual(json.loads((new / "cells_meta.json").read_text())["era"]["name"], "shine") + self.assertEqual((new / "cells.npz").stat().st_mtime_ns, before) + finally: + new.rename(old) + self.write_eras() + ER.build_era(self.root, 2, "glow", log=lambda m: None) + + def test_one_lake_id_never_names_two_lakes(self): + b, e = self.load(), self.load("after") + m, ids = e["era_mask"], e["lake_id"] + for k in np.unique(ids[m & (ids >= 0)]).tolist(): + if (ids[~m] == k).any(): + np.testing.assert_array_equal(ids == k, b["lake_id"] == k, err_msg=f"lake {k}") + + def test_zone_events_never_change_height(self): + b, e = self.load(), self.load("glow") + for k in ("elevation_m", "elevation_eroded_m", "z_surface_m"): + np.testing.assert_array_equal(e[k], b[k], err_msg=k) + + def test_a_volcano_raises_new_land(self): + e = self.load("risen") + self.assertFalse(e["ocean"][self.vol_i]) + self.assertGreater(float(e["elevation_eroded_m"][self.vol_i]), 0.0) + + def test_era_is_cached_until_its_events_change(self): + logs = [] + ER.build_era(self.root, 2, "glow", log=logs.append) + self.assertIn("era glow: cached", logs) + first = self.load("glow") + self.write_eras(fire=0.6) + try: + logs, base_logs = [], [] + ER.build_era(self.root, 2, "glow", log=logs.append) + self.assertNotIn("era glow: cached", logs) + P.build(self.root, 2, log=base_logs.append) + self.assertTrue(base_logs and all(line.endswith("cached") for line in base_logs), + "tuning an event never rebuilds the base") + finally: + self.write_eras() + ER.build_era(self.root, 2, "glow", log=lambda m: None) + again = self.load("glow") # repeatable: the same inputs, the same arrays + for k in first: + np.testing.assert_array_equal(again[k], first[k], err_msg=k) + + def test_build_command_builds_eras_and_removes_stale_ones(self): + stale = ER.era_dir(self.root, 2, "gone") + stale.mkdir(parents=True, exist_ok=True) + self.assertEqual(cli().main(["build", "--res", "2"], root=self.root), 0) + self.assertFalse(stale.exists()) + for n in ("glow", "after", "risen"): + self.assertTrue((ER.era_dir(self.root, 2, n) / "cells.npz").exists(), n) + self.assertFalse(ER.era_dir(self.root, 2, "before").exists(), "the base era is the base build") + self.assertEqual(cli().main(["era", "glow", "--res", "2"], root=self.root), 0) + self.assertEqual(cli().main(["era", "nowhen", "--res", "2"], root=self.root), 2) + + +class MergeLakeIdsTest(unittest.TestCase): + def test_kept_lakes_keep_ids_changed_and_new_lakes_get_fresh_ones(self): + base = np.array([0, 0, 1, 1, -1, 2, -1], np.int32) + new = np.array([5, 5, 0, 0, 0, -1, 1], np.int32) # 5 = base 0 as it was; 0 = base 1 grown; 1 new; 2 gone + out = ER.merge_lake_ids(base, new, np.ones(7, bool)) + np.testing.assert_array_equal(out, [0, 0, 3, 3, 3, -1, 4]) + self.assertEqual(out.dtype, np.int32) + + def test_outside_the_mask_stays_the_base(self): + base = np.array([0, 0, -1, 1]) + new = np.array([3, 3, 7, 7]) + out = ER.merge_lake_ids(base, new, np.array([False, False, True, True])) + np.testing.assert_array_equal(out, [0, 0, 2, 2]) + + +class SameTest(unittest.TestCase): + def test_nan_equals_nan_in_float_arrays_only(self): + self.assertTrue(ER._same(np.array([np.nan, 1.0]), np.array([np.nan, 1.0]))) + self.assertFalse(ER._same(np.array([np.nan, 1.0]), np.array([np.nan, 2.0]))) + self.assertTrue(ER._same(np.array([1, 2]), np.array([1, 2]))) + self.assertTrue(ER._same(np.array([True]), np.array([True]))) diff --git a/tests/test_erosion.py b/tests/test_erosion.py new file mode 100644 index 0000000..2adefc0 --- /dev/null +++ b/tests/test_erosion.py @@ -0,0 +1,111 @@ +import unittest + +import numpy as np + +from mapgen import erosion as ER +from mapgen.crust import CRATON +from tests.helpers import make_ctx + + +def mountain_ctx(k=None): + cfg = {"build": {"land_fraction": 0.05}} + if k is not None: + cfg["erosion"] = {"k": k} + ctx = make_ctx(3, cfg=cfg) + g = ctx.grid + d = g.radius_km * np.arccos(np.clip(g.xyz @ g.xyz[g.cell_index(0.0, 0.0)], -1, 1)) + z = np.where(d < 6000, 6000 * np.clip(1 - d / 6000, 0, None) ** 1.2 + 50, -4000.0) + ctx.data.update({"elevation_m": z.astype(np.float32), "age_class": np.full(g.n, CRATON, np.int8)}) + return ctx, z + + +class ErosionTest(unittest.TestCase): + def test_erode_lowers_land(self): + ctx, z0 = mountain_ctx() + kmult = np.full(ctx.grid.n, 1.5) + z1 = ER.erode(ctx.grid, z0, kmult, ER.DEFAULTS) + land = z0 > 0 + self.assertLess(z1[land].mean(), z0[land].mean()) + self.assertLessEqual(z1.max(), z0.max() + 1e-6) + + def test_stage_keeps_land_fraction(self): + ctx, _ = mountain_ctx() + z = ER.run(ctx)["elevation_eroded_m"] + g = ctx.grid + self.assertTrue(np.all(np.isfinite(z))) + self.assertAlmostEqual(g.area_km2[z > 0].sum() / g.area_km2.sum(), 0.05, delta=0.01) + + def test_stable_with_huge_k(self): + ctx, _ = mountain_ctx(k=1e3) + z = ER.run(ctx)["elevation_eroded_m"] + self.assertTrue(np.all(np.isfinite(z))) + self.assertGreaterEqual(z.min(), -11000) + + def test_stage_outputs_connected_ocean(self): + ctx, z0 = mountain_ctx() + g = ctx.grid + c = g.xyz[g.cell_index(0.0, 0.0)] + d = g.radius_km * np.arccos(np.clip(g.xyz @ c, -1, 1)) + z0 = np.where(d < 700, -500.0, z0) # deep interior pit in the mountain block + ctx.data["elevation_m"] = z0.astype(np.float32) + out = ER.run(ctx) + self.assertIn("ocean", out) + self.assertFalse(out["ocean"][d < 300].any()) + self.assertTrue(out["ocean"][d > 7000].all()) + + def test_stage_adds_the_elevation_stages_added_land(self): + ctx, z0 = mountain_ctx() + ctx.cfg["build"]["land_fraction"] = 0.02 + g = ctx.grid + mtn = z0 > 0 + ctx.data.update({"continental": mtn, "continental_base": mtn}) # lifted shelf: no new crust at all + added = mtn & (g.lon >= 0) + ctx.data["land_added"] = added + extra = g.area_km2[added].sum() / g.area_km2.sum() + z = ER.run(ctx)["elevation_eroded_m"] + self.assertAlmostEqual(g.area_km2[z > 0].sum() / g.area_km2.sum(), 0.02 + extra, delta=0.005) + +class BaseLevelTest(unittest.TestCase): + def test_erosion_keeps_coastline(self): + ctx = make_ctx(3) + g = ctx.grid + block = (np.abs(g.lat) < 30) & (np.abs(g.lon) < 50) + z0 = np.where(block, 800.0 + 2500.0 * np.exp(-((g.lon + 45) / 6.0) ** 2), -6000.0) # coastal range by a deep sea + z1 = ER.erode(g, z0, np.full(g.n, 1.5), ER.DEFAULTS) + np.testing.assert_array_equal(z1 > 0, z0 > 0) + self.assertTrue(np.all(z1[~block] == z0[~block])) + + +class HillslopeResolutionTest(unittest.TestCase): + def test_hillslope_smoothing_is_resolution_independent(self): + from tests.helpers import small_grid + drops = [] + for res in (3, 4): + g = small_grid(res) + c = g.xyz[g.cell_index(20.0, 0.0)] + d = g.radius_km * np.arccos(np.clip(g.xyz @ c, -1, 1)) + z0 = np.where(g.lat > -40, 500.0 + 3000.0 * np.exp(-(d / 600.0) ** 2), -4000.0) + P = dict(ER.DEFAULTS, k=0.0) # isolate hillslope smoothing + z1 = ER.erode(g, z0, np.ones(g.n), P) + drops.append(z0.max() - z1[d < 150].max()) + self.assertAlmostEqual(drops[0] / max(drops[1], 1e-9), 1.0, delta=0.35) + + +class SedimentFillTest(unittest.TestCase): + def test_shallow_basins_fill_deep_ones_remain(self): + g = make_ctx(3).grid + z0 = np.where(g.lat > -20, 200.0 + 2.0 * (g.lat + 20), -3000.0) + depth = {} + for name, (lat, dz) in {"shallow": (20.0, 60.0), "deep": (50.0, 2500.0)}.items(): + c = g.xyz[g.cell_index(lat, 0.0)] + d = g.radius_km * np.arccos(np.clip(g.xyz @ c, -1, 1)) + z0 = np.where(d < 700, z0 - dz * (1 - d / 700), z0) + depth[name] = d < 250 + from mapgen.graph import priority_flood + def fill_after(frac): + z1 = ER.erode(g, z0, np.full(g.n, 1.0), dict(ER.DEFAULTS, k=0.0, sediment_fill=frac)) + return priority_flood(g, z1, z1 <= -1000) - z1 + self.assertGreater(fill_after(0.0)[depth["shallow"]].max(), 20.0) # without sediment it stays a basin + fill = fill_after(ER.DEFAULTS["sediment_fill"]) + self.assertLess(fill[depth["shallow"]].max(), 0.2 * 60.0) # noise-scale basins mostly filled + self.assertGreater(fill[depth["deep"]].max(), 20.0) diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..1b3d51a --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,121 @@ +import unittest + +import numpy as np + +from mapgen import events as EV +from mapgen.pipeline import StageError +from mapgen.sphere import east_north, gc_dist_km, great_circle_point, latlon_to_xyz +from tests.helpers import small_grid + +R = 12742.0 +CUT = {"name": "cut", "kind": "disintegrate", "center": [0.0, 0.0], "radius_km": 3000.0, "depth_m": 3000.0} + + +class DisintegrateTest(unittest.TestCase): + def test_bowl_only_lowers_and_reaches_its_depth(self): + g = small_grid(3) + d = gc_dist_km(g.xyz, latlon_to_xyz(0.0, 0.0), R) + z = np.where(d < 4000, 800.0, -4000.0) + z2, z_ref = EV.disintegrate(g.xyz, z, z > 0, CUT, R) + self.assertAlmostEqual(z_ref, 800.0) + self.assertTrue(np.all(z2 <= z)) + np.testing.assert_array_equal(z2[d >= 3000], z[d >= 3000]) + c = int(np.argmin(d)) + self.assertAlmostEqual(z2[c], 800.0 - 3000.0 * np.sqrt(1 - (d[c] / 3000.0) ** 2), places=6) + + + def test_disintegration_over_open_sea_has_no_rim_height(self): + g = small_grid(3) + z2, z_ref = EV.disintegrate(g.xyz, np.full(g.n, -1000.0), np.zeros(g.n, bool), CUT, R) + self.assertIsNone(z_ref) + self.assertLess(float(z2[g.cell_index(0.0, 0.0)]), -2990.0, "the bowl hangs from 0 m") + + def test_heights_warn_when_a_cut_has_no_rim_land(self): + from mapgen import eras as ER + g = small_grid(3) + logs = [] + ER._heights(g, np.full(g.n, -1000.0), [dict(CUT)], 5.0e6, logs.append, "x") + self.assertTrue(any("no land in its rim" in m for m in logs), logs) + + +class VolcanoTest(unittest.TestCase): + def test_shapes_and_modes(self): + g = small_grid(3) + d = gc_dist_km(g.xyz, latlon_to_xyz(0.0, 90.0), R) + c = int(np.argmin(d)) + z = np.full(g.n, -3000.0) + above = EV.volcano(g.xyz, z, {"center": [0.0, 90.0], "radius_km": 900.0, "peak_m": 2000.0}, R) + self.assertTrue(np.all(above >= z)) + self.assertAlmostEqual(above[c], -3000.0 + 2000.0 * (1 - d[c] / 900.0) ** 1.5) + absolute = EV.volcano(g.xyz, z, {"center": [0.0, 90.0], "radius_km": 900.0, "peak_m": 1500.0, + "peak_mode": "absolute"}, R) + self.assertGreater(absolute[c], 0.0, "a volcano above 0 m makes land") + np.testing.assert_array_equal(absolute[d >= 900.0], z[d >= 900.0]) + cal = EV.volcano(g.xyz, np.zeros(g.n), {"center": [0.0, 90.0], "radius_km": 2000.0, "peak_m": 2000.0, + "shape": "caldera"}, R) + ring = (d > 300.0) & (d < 700.0) + self.assertLess(cal[c], cal[ring].max(), "a caldera's middle sits below its rim") + + +class ZoneWeightTest(unittest.TestCase): + def test_sharp_circle_edge_ramps_over_edge_km(self): + ev = {"name": "z", "shape": "circle", "center": [10.0, 20.0], "radius_km": 800.0, "edge_km": 5.0} + p0 = latlon_to_xyz(10.0, 20.0) + e, n = east_north(p0[None]) + s = np.arange(780.0, 810.0, 0.5) + pts = np.array([great_circle_point(p0, e[0], si, R) for si in s]) + w = EV.zone_weight(pts, ev, R, 1296) + self.assertTrue(np.all(w[s <= 794.5] == 1.0)) + self.assertTrue(np.all(w[s >= 800.5] == 0.0)) + ramp = s[(w > 0) & (w < 1)] + self.assertLessEqual(ramp.max() - ramp.min(), 5.0) + + def test_landmass_reach_varies_between_its_bounds(self): + g = small_grid(3) + land = (np.abs(g.lat) < 20) & (np.abs(g.lon) < 30) + ev = {"name": "aura", "shape": "landmass", "seed": [0.0, 0.0], "reach_km": [500.0, 1000.0], "edge_km": 5.0} + lm = EV.landmass(g, land, ev["seed"], ev["name"]) + np.testing.assert_array_equal(lm, land) + w = EV.zone_weight(g.xyz, ev, R, 1296, g.xyz[lm]) + self.assertTrue(np.all(w[land] == 1.0)) + from scipy.spatial import cKDTree + chord, _ = cKDTree(g.xyz[lm]).query(g.xyz) + d = 2 * np.arcsin(chord / 2) * R + self.assertTrue(np.all(w[d > 1000.0] == 0.0)) + self.assertTrue(np.all(w[(d > 0) & (d < 495.0)] == 1.0)) + + def test_landmass_seed_in_the_sea_names_the_event(self): + g = small_grid(3) + land = (np.abs(g.lat) < 20) & (np.abs(g.lon) < 30) + with self.assertRaises(StageError) as cm: + EV.landmass(g, land, [0.0, 150.0], "impact-aftermath") + self.assertIn("impact-aftermath", str(cm.exception)) + self.assertIn("nearest land", str(cm.exception)) + + +class ApplyZoneTest(unittest.TestCase): + EV = {"name": "aura", "fields": {"gravity_g": 0.35, "pressure_bar": 2.0, "o2_fraction": 0.35, + "fire_reactivity": 0.5}} + + def base(self, n=4): + return {"gravity_g": np.full(n, 1.05, np.float32), "o2_fraction": np.array([0.21, 0.3, 0.1, 0.21]), + "pressure_bar": np.array([1.0, 1.0, np.exp(-1), 1.0]), "fire_reactivity": np.ones(n), + "po2_bar": np.array([0.21, 0.3, 0.1 * np.exp(-1), 0.21])} + + def test_zone_values_are_absolute_over_any_base(self): + f = self.base() + w = np.array([1.0, 1.0, 1.0, 0.0]) + z = np.array([0.0, -50.0, 8000.0, 0.0]) + out = EV.apply_zone(f, w, self.EV, z, 8000.0) + np.testing.assert_allclose(out["o2_fraction"][:3], 0.35) + np.testing.assert_allclose(out["gravity_g"][:3], 0.35, rtol=1e-6) + np.testing.assert_allclose(out["pressure_bar"][:3], [2.0, 2.0, 2.0 * np.exp(-1)]) + np.testing.assert_allclose(out["po2_bar"], out["o2_fraction"] * out["pressure_bar"]) + self.assertEqual(out["gravity_g"].dtype, np.float32) + for k in out: + self.assertEqual(out[k][3], f[k][3], f"{k}: untouched outside the zone") + + def test_half_weight_blends(self): + out = EV.apply_zone(self.base(), np.full(4, 0.5), self.EV, np.zeros(4), 8000.0) + self.assertAlmostEqual(float(out["fire_reactivity"][0]), 0.75) + self.assertAlmostEqual(float(out["pressure_bar"][0]), 1.5) diff --git a/tests/test_geo.py b/tests/test_geo.py new file mode 100644 index 0000000..d438299 --- /dev/null +++ b/tests/test_geo.py @@ -0,0 +1,84 @@ +import unittest +from types import SimpleNamespace + +import numpy as np + +from mapgen import geo as GE +from mapgen.plates import CONV +from tests.helpers import small_grid + + +def _area(ring): + a = np.asarray(ring, dtype=float) + return 0.5 * float(np.sum(a[:-1, 0] * a[1:, 1] - a[1:, 0] * a[:-1, 1])) + + +class GeoTest(unittest.TestCase): + def test_split_antimeridian(self): + parts = GE.split_antimeridian([[178.0, 0.0], [179.5, 1.0], [-179.5, 1.0], [-178.0, 0.0]]) + self.assertEqual(len(parts), 2) + self.assertEqual(parts[0][-1], [179.5, 1.0]) + + def test_river_split_at_antimeridian(self): + g = SimpleNamespace(n=4, lat=np.zeros(4), lon=np.array([170.0, 178.0, -178.0, -170.0])) + recv = np.array([1, 2, 3, 3]) + feats = GE.river_lines(g, recv, np.array([True, True, True, False]), np.array([1, 1, 2, 0], np.int8), + np.array([1.0, 2.0, 3.0, 0.0])) + self.assertEqual(len(feats), 2) + for f in feats: + xs = [c[0] for c in f["geometry"]["coordinates"]] + self.assertLess(max(xs) - min(xs), 180) + + def test_contour_square(self): + m = np.zeros((10, 20), bool) + m[3:7, 5:9] = True + rings = GE.contours(m) + self.assertEqual(len(rings), 1) + r = np.array(rings[0]) + self.assertEqual(tuple(r[0]), tuple(r[-1])) + self.assertTrue(4 <= r[:, 0].min() and r[:, 0].max() <= 9 and 2 <= r[:, 1].min() and r[:, 1].max() <= 7) + + def test_contour_ring_split(self): + m = np.zeros((10, 20), bool) + m[3:7, :3] = True + m[3:7, 17:] = True # one island wrapping the antimeridian + feats = GE.contour_features(m, "land") + self.assertEqual(len(feats), 1) + geom = feats[0]["geometry"] + self.assertEqual(geom["type"], "MultiPolygon") # fillable, split at ±180 + for poly in geom["coordinates"]: + ring = poly[0] + self.assertEqual(ring[0], ring[-1]) + xs = [c[0] for c in ring] + self.assertTrue(-180 <= min(xs) and max(xs) <= 180 and max(xs) - min(xs) < 180) + self.assertGreater(_area(ring), 0) # counter-clockwise exterior + + def test_polygon_orientation_and_holes(self): + m = np.zeros((20, 40), bool) + m[4:16, 8:24] = True + m[8:12, 13:19] = False # a lake-shaped hole + feats = GE.contour_features(m, "land") + self.assertEqual(len(feats), 1) + geom = feats[0]["geometry"] + self.assertEqual(geom["type"], "Polygon") + self.assertEqual(len(geom["coordinates"]), 2) + self.assertGreater(_area(geom["coordinates"][0]), 0) # exterior CCW + self.assertLess(_area(geom["coordinates"][1]), 0) # hole CW + + def test_river_features_have_single_order(self): + g = SimpleNamespace(n=6, lat=np.arange(6.0), lon=np.zeros(6)) + recv = np.array([1, 2, 3, 4, 5, 5]) + order = np.array([1, 1, 2, 2, 3, 0], np.int8) + feats = GE.river_lines(g, recv, np.array([True] * 5 + [False]), order, np.arange(6.0)) + self.assertEqual([f["properties"]["order"] for f in feats], [1, 2, 3]) + for f in feats: + self.assertGreaterEqual(len(f["geometry"]["coordinates"]), 2) + + def test_plate_boundaries(self): + g = small_grid(1) + plate = (g.lon > 0).astype(np.int16) + bt = np.full(g.n, CONV, np.int8) + feats = GE.boundary_features(g, plate, bt) + self.assertEqual(len(feats), 1) + self.assertEqual(feats[0]["properties"]["type"], "convergent") + self.assertGreater(len(feats[0]["geometry"]["coordinates"]), 5) diff --git a/tests/test_graph.py b/tests/test_graph.py new file mode 100644 index 0000000..ca324cc --- /dev/null +++ b/tests/test_graph.py @@ -0,0 +1,362 @@ +import unittest + +import numpy as np + +from mapgen import graph as G +from mapgen.sphere import latlon_to_xyz, east_north +from tests.helpers import small_grid + + +class GraphTest(unittest.TestCase): + def setUp(self): + self.g = small_grid(2) + + def test_mean_max_min_diffuse(self): + g = self.g + f = np.zeros(g.n) + f[0] = 6.0 + m = G.nbr_mean(g, f) + self.assertAlmostEqual(m[g.nbr_idx[g.nbr_ptr[0]]], 6.0 / g.counts[g.nbr_idx[g.nbr_ptr[0]]]) + self.assertEqual(G.nbr_max(g, f)[g.nbr_idx[g.nbr_ptr[0]]], 6.0) + d = G.diffuse(g, f, 10) + self.assertLess(d.max(), 6.0) + self.assertGreater(np.count_nonzero(d > 1e-6), 20) + + def test_gradient_of_linear_field(self): + g = self.g + f = g.xyz[:, 2] * g.radius_km # height ∝ z → gradient points north near the equator + gr = G.gradient(g, f) + eq = np.abs(g.lat) < 20 + _, n = east_north(g.xyz[eq]) + cos_to_north = np.sum(gr[eq] * n, axis=1) / np.linalg.norm(gr[eq], axis=1) + self.assertGreater(np.median(cos_to_north), 0.99) + self.assertAlmostEqual(float(np.median(np.linalg.norm(gr[eq], axis=1))), 1.0, delta=0.1) + + def test_distance_matches_great_circle(self): + g = self.g + i = g.cell_index(0.0, 0.0) + d = G.distance_to(g, np.arange(g.n) == i) + gc = g.radius_km * np.arccos(np.clip(g.xyz @ g.xyz[i], -1, 1)) + far = gc > 3000 + ratio = d[far] / gc[far] + self.assertTrue(np.all(ratio >= 0.999) and np.median(ratio) < 1.15) + self.assertTrue(np.all(np.isinf(G.distance_to(g, np.zeros(g.n, bool))))) + + def test_nearest_source_labels(self): + g = self.g + a, b = g.cell_index(0.0, -90.0), g.cell_index(0.0, 90.0) + _, src = G.nearest_source(g, [a, b]) + self.assertEqual(src[g.cell_index(0.0, -60.0)], a) + self.assertEqual(src[g.cell_index(0.0, 60.0)], b) + + def test_priority_flood_fills_basin_and_drains(self): + g = self.g + ocean = g.lat < -30 + z = np.where(ocean, -1000.0, 500.0 + 10 * g.lat) + pit = g.cell_index(40.0, 0.0) + z[pit] = -50.0 # land pit below sea level, not connected to ocean + zf = G.priority_flood(g, z, ocean) + self.assertGreater(zf[pit], z[pit]) + recv, slope, dist = G.steepest_receivers(g, zf) + recv[ocean] = np.flatnonzero(ocean) + levels = G.receiver_levels(recv) + self.assertEqual(sum(len(l) for l in levels), g.n) + self.assertTrue(np.all(ocean[levels[0]])) + + def test_priority_flood_needs_sink(self): + with self.assertRaisesRegex(ValueError, "no sink"): + G.priority_flood(self.g, np.ones(self.g.n), np.zeros(self.g.n, bool)) + + def test_accumulate_conserves(self): + g = self.g + ocean = g.lat < -30 + z = np.where(ocean, -1000.0, 1000.0 + 20 * g.lat) + zf = G.priority_flood(g, z, ocean) + recv, _, _ = G.steepest_receivers(g, zf) + recv[ocean] = np.flatnonzero(ocean) + lv = G.receiver_levels(recv) + w = np.where(ocean, 0.0, 1.0) + acc = G.accumulate(recv, lv, w) + self.assertAlmostEqual(acc[lv[0]].sum(), w.sum()) + + def test_cycle_detected(self): + with self.assertRaisesRegex(ValueError, "cycle"): + G.receiver_levels(np.array([1, 0, 2])) + + def test_components(self): + g = self.g + m = (np.abs(g.lat) < 10) & (np.abs(g.lon) < 20) | (np.abs(g.lat - 50) < 8) & (np.abs(g.lon) < 20) + lab = G.components(g, m) + self.assertEqual(len(np.unique(lab[m])), 2) + self.assertTrue(np.all(lab[~m] == -1)) + + +class SmoothKmTest(unittest.TestCase): + def test_constant_preserved_and_spike_decays(self): + g = small_grid(3) + np.testing.assert_allclose(G.smooth_km(g, np.full(g.n, 5.0), 1000.0), 5.0, rtol=1e-4) + i = g.cell_index(0.0, 0.0) + s = G.smooth_km(g, (np.arange(g.n) == i).astype(float), 1000.0) + d = g.radius_km * np.arccos(np.clip(g.xyz @ g.xyz[i], -1, 1)) + near, far = s[(d > 800) & (d < 1200)].mean(), s[(d > 2800) & (d < 3200)].mean() + self.assertTrue(near > far > 0) + + def test_resolution_independent(self): + vals = [] + for res in (2, 3): + g = small_grid(res) + d = g.radius_km * np.arccos(np.clip(g.xyz @ g.xyz[g.cell_index(0.0, 0.0)], -1, 1)) + s = G.smooth_km(g, (d < 2000).astype(float), 1500.0) + vals.append(s[g.cell_index(0.0, 30.0)]) # ~6700 km away + self.assertAlmostEqual(vals[0], vals[1], delta=0.25 * max(vals)) + self.assertGreater(min(vals), 0.005) + + +class OceanMaskTest(unittest.TestCase): + def test_inland_depression_is_not_ocean(self): + g = small_grid(3) + z = np.where(g.lat < 0, -3000.0, 500.0) + c = g.xyz[g.cell_index(40.0, 0.0)] + d = g.radius_km * np.arccos(np.clip(g.xyz @ c, -1, 1)) + z[d < 600] = -50.0 # interior basin below sea level + ocean = G.ocean_mask(g, z, 1.0e6) + self.assertTrue(ocean[g.lat < -5].all()) + self.assertFalse(ocean[d < 600].any()) + + +class SmoothKmRobustTest(unittest.TestCase): + def test_zero_and_tiny_fields(self): + g = small_grid(3) + np.testing.assert_array_equal(G.smooth_km(g, np.zeros(g.n), 25.0), 0.0) + f = np.where(g.lat > 0, 1e-9, 0.0) + s = G.smooth_km(g, f, 25.0) + self.assertTrue(np.all(np.isfinite(s)) and s.max() <= 1e-9 * (1 + 1e-6)) + + def test_femto_scale_field(self): + g = small_grid(3) + rng = np.random.default_rng(0) + f = np.where(rng.random(g.n) < 0.06, rng.random(g.n) * 8e-14, 0.0) + s = G.smooth_km(g, f, 25.0) + self.assertTrue(np.all(np.isfinite(s))) + self.assertAlmostEqual(float(s.sum() / f.sum()), 1.0, delta=0.05) + + +def _flood_ref(g, z, sink_mask, eps=0.01): + """The original pure-Python priority flood (oracle for the compiled one).""" + import heapq + has_open = np.bincount(g.src, weights=(~sink_mask)[g.dst].astype(np.float64), minlength=g.n) > 0 + zf, done = np.asarray(z, dtype=np.float64).tolist(), sink_mask.tolist() + ptr, idx = g.nbr_ptr.tolist(), g.nbr_idx.tolist() + heap = [(zf[i], i) for i in np.flatnonzero(sink_mask & has_open).tolist()] + heapq.heapify(heap) + while heap: + zc, c = heapq.heappop(heap) + for k in range(ptr[c], ptr[c + 1]): + n = idx[k] + if not done[n]: + done[n] = True + zf[n] = max(zf[n], zc + eps) + heapq.heappush(heap, (zf[n], n)) + return np.array(zf) + + +def _steepest_ref(g, z): + """The original lexsort version (oracle).""" + slope = (z[g.src] - z[g.dst]) / g.edge_km + first = np.lexsort((-slope, g.src))[g.nbr_ptr[:-1]] + s = slope[first] + down = s > 0 + return (np.where(down, g.dst[first], np.arange(g.n)), np.where(down, s, 0.0), + np.where(down, g.edge_km[first], np.inf)) + + +def _accumulate_ref(recv, levels, w): + acc = np.asarray(w, dtype=np.float64).copy() + for lv in reversed(levels[1:]): + acc += np.bincount(recv[lv], weights=acc[lv], minlength=len(acc)) + return acc + + +class FastPathsTest(unittest.TestCase): + """The speed-ups (numba flood, sort-free receivers, per-level accumulate) give bit-identical results.""" + + def fields(self, g): + rng = np.random.default_rng(7) + rough = rng.normal(0, 300, g.n) + flat = np.round(rng.normal(0, 2, g.n)) # many exact ties: tie order matters + pits = np.where(rng.random(g.n) < 0.2, -50.0, rough) + return {"rough": rough, "flat": flat, "pits": pits} + + def test_flood_matches_reference(self): + g = small_grid(3) + for name, z in self.fields(g).items(): + sink = z < np.quantile(z, 0.1) + for eps in (0.01, 0.0): + with self.subTest(name=name, eps=eps): + want = _flood_ref(g, z, sink, eps) + got = G.priority_flood(g, z, sink, eps) + self.assertTrue(np.array_equal(got, want)) + py = G._flood_py(z, sink, g.nbr_ptr, g.nbr_idx, + np.flatnonzero(sink & (np.bincount(g.src, weights=(~sink)[g.dst].astype(float), + minlength=g.n) > 0)), eps) + self.assertTrue(np.array_equal(py, want)) + + def test_steepest_receivers_match_reference(self): + g = small_grid(3) + for name, z in self.fields(g).items(): + with self.subTest(name=name): + for got, want in zip(G.steepest_receivers(g, z), _steepest_ref(g, z)): + self.assertTrue(np.array_equal(got, want)) + + def test_accumulate_matches_reference(self): + g = small_grid(3) + rng = np.random.default_rng(3) + for name, z in self.fields(g).items(): + with self.subTest(name=name): + zf = G.priority_flood(g, z, z < np.quantile(z, 0.1)) + recv, _, _ = G.steepest_receivers(g, zf) + lv = G.receiver_levels(recv) + w = rng.random(g.n) * 1e3 * np.where(rng.random(g.n) < 0.1, -0.0, 1.0) # with negative zeros + got, want = G.accumulate(recv, lv, w), _accumulate_ref(recv, lv, w) + self.assertTrue(np.array_equal(got, want)) + self.assertTrue(np.array_equal(np.signbit(got), np.signbit(want))) + + +class SweepTest(unittest.TestCase): + def test_sweep_matches_full_bincount(self): + from mapgen import hydrology as HY + + def sweep_ref(recv, levels, water, outlets, cap): + acc = np.asarray(water, dtype=np.float64).copy() + loss = np.zeros(len(acc)) + is_out = np.zeros(len(acc), bool) + is_out[outlets] = True + cap_cell = np.zeros(len(acc)) + cap_cell[outlets] = cap + for lv in reversed(levels[1:]): + push = acc[lv].copy() + o = is_out[lv] + if o.any(): + cells = lv[o] + lost = np.minimum(acc[cells], cap_cell[cells]) + loss[cells] = lost + push[o] = acc[cells] - lost + acc += np.bincount(recv[lv], weights=push, minlength=len(acc)) + return acc, loss + + g = small_grid(3) + rng = np.random.default_rng(11) + z = rng.normal(0, 300, g.n) + zf = G.priority_flood(g, z, z < np.quantile(z, 0.1)) + recv, _, _ = G.steepest_receivers(g, zf) + lv = G.receiver_levels(recv) + water = rng.random(g.n) * np.where(rng.random(g.n) < 0.1, -0.0, 1.0) # with negative zeros + outlets = rng.choice(g.n, 200, replace=False) + cap = rng.random(200) * 2 + for got, want in zip(HY._sweep(recv, lv, water, outlets, cap), sweep_ref(recv, lv, water, outlets, cap)): + self.assertTrue(np.array_equal(got, want)) + self.assertTrue(np.array_equal(np.signbit(got), np.signbit(want))) + + +class LeavesTest(unittest.TestCase): + def test_leaves_all_matches_the_walk(self): + from mapgen import hydrology as HY + g = small_grid(3) + rng = np.random.default_rng(4) + for seed in range(3): + z = rng.normal(0, 300, g.n) + ocean = z < np.quantile(z, 0.2) + zf = G.priority_flood(g, z, ocean) + lab = G.components(g, ~ocean & (zf - z > 1.0)) + recv, _, _ = G.steepest_receivers(g, zf) + recv = np.where(ocean, np.arange(g.n), recv) + lv = G.receiver_levels(recv) + xs = np.flatnonzero(lab >= 0) + for limit in (100000, 3): + want = np.array([HY._leaves(recv, lab, x, limit) for x in xs]) + got = HY._leaves_all(recv, lab, lv, limit)[xs] + self.assertGreater(want.sum(), 0) + self.assertTrue(np.array_equal(got, want), (seed, limit)) + + +class BicgstabJacobiTest(unittest.TestCase): + """The fused solver walks scipy's iterates exactly: same answers bit for bit, same exit codes.""" + def systems(self): + from scipy import sparse + rng = np.random.default_rng(0) + for n in (2000, 9000): + i = np.repeat(np.arange(n), 6) + j = (i + rng.integers(-50, 50, len(i))) % n + L = sparse.csr_matrix((rng.random(len(i)), (i, j)), shape=(n, n)) + S = L + L.T + yield (sparse.diags(np.asarray(S.sum(1)).ravel()) - S).tocsr() * 40 + sparse.identity(n, format="csr") + yield (sparse.identity(n, format="csr") * (1 + np.asarray(L.sum(1)).ravel().max() * 0.6) - L).tocsr() + + def setUp(self): + self.min_n = G.JIT_MIN_N + G.JIT_MIN_N = 0 # the compiled path even on small test systems + + def tearDown(self): + G.JIT_MIN_N = self.min_n + + def test_matches_scipy_bicgstab(self): + from scipy.sparse import linalg as splinalg + rng = np.random.default_rng(1) + for k, A in enumerate(self.systems()): + for rtol, maxiter in ((1e-6, 5000), (1e-9, 5000), (1e-12, 7)): + b = rng.normal(size=A.shape[0]) + inv = 1.0 / A.diagonal() + M = splinalg.LinearOperator(A.shape, matvec=lambda x: inv * x) + want = splinalg.bicgstab(A, b, x0=b * 0.5, rtol=rtol, maxiter=maxiter, M=M) + got = G.bicgstab_jacobi(A, b, b * 0.5, inv, rtol, maxiter) + with self.subTest(k=k, rtol=rtol, maxiter=maxiter): + self.assertEqual(got[1], want[1]) + self.assertTrue(np.array_equal(got[0], want[0])) + + def test_zero_right_hand_side_and_zero_start(self): + A = next(self.systems()) + inv = 1.0 / A.diagonal() + x, info = G.bicgstab_jacobi(A, np.zeros(A.shape[0]), np.zeros(A.shape[0]), inv, 1e-6, 100) + self.assertEqual(info, 0) + self.assertFalse(x.any()) + + +class PmapTest(unittest.TestCase): + def test_order_and_same_floats_as_serial(self): + import os + from unittest import mock + g = small_grid(2) + fields = [np.random.default_rng(i).normal(size=g.n) for i in range(4)] + serial = [G.smooth_km(g, f, 900.0) for f in fields] + with mock.patch.dict(os.environ, {"WORLDGEN_THREADS": "4"}): + self.assertEqual(G.workers(), 4) + got = G.pmap(lambda f: G.smooth_km(g, f, 900.0), fields) + for a, b in zip(serial, got): + self.assertTrue(np.array_equal(a, b)) + with mock.patch.dict(os.environ, {"WORLDGEN_THREADS": "3"}): + self.assertEqual(G.pmap(lambda k: k * k, range(9)), [k * k for k in range(9)]) + with mock.patch.dict(os.environ, {"WORLDGEN_THREADS": "x"}): + self.assertEqual(G.workers(), 3) + + +class ComponentsTest(unittest.TestCase): + def test_same_labels_as_scipy(self): + from scipy import sparse + from scipy.sparse import csgraph + g = small_grid(3) + rng = np.random.default_rng(11) + + def scipy_labels(mask): # the previous implementation, as the oracle + e = mask[g.src] & mask[g.dst] + m = sparse.csr_matrix((np.ones(int(e.sum())), (g.src[e], g.dst[e])), shape=(g.n, g.n)) + _, lab = csgraph.connected_components(m, directed=False) + return np.where(mask, lab, -1) + for p in (0.0, 0.2, 0.45, 0.6, 0.9, 1.0): + for _ in range(3): + mask = rng.random(g.n) < p + want, got = scipy_labels(mask), G.components(g, mask) + self.assertEqual(got.dtype, want.dtype) + self.assertTrue(np.array_equal(got, want), p) + smooth = G.smooth_km(g, rng.normal(size=g.n), 2000.0) > 0 # big blobs, many cells each + self.assertTrue(np.array_equal(G.components(g, smooth), scipy_labels(smooth))) + self.assertGreater(len(np.unique(G.components(g, smooth))), 2) diff --git a/tests/test_grid.py b/tests/test_grid.py new file mode 100644 index 0000000..a6b0848 --- /dev/null +++ b/tests/test_grid.py @@ -0,0 +1,83 @@ +import unittest + +import numpy as np + +from mapgen.grid import Grid +from tests.helpers import small_grid + + +class GridTest(unittest.TestCase): + def test_counts_and_pentagons(self): + g = small_grid(2) + self.assertEqual(g.n, 5882) + self.assertTrue(np.all(np.diff(g.ids.astype(np.int64)) > 0)) + self.assertEqual(int(np.sum(g.counts == 5)), 12) + self.assertEqual(int(np.sum(g.counts == 6)), g.n - 12) + + def test_neighbours_symmetric(self): + g = small_grid(2) + pairs = set(zip(g.src.tolist(), g.dst.tolist())) + self.assertTrue(all((b, a) in pairs for a, b in pairs)) + + def test_area_sums_to_sphere(self): + g = small_grid(2) + self.assertAlmostEqual(g.area_km2.sum() / (4 * np.pi * 12742.0**2), 1.0, places=6) + + def test_spacing_and_tangents(self): + g = small_grid(2) + self.assertTrue(500 < g.spacing_km < 800) # res 2 ≈ 632 km + np.testing.assert_allclose(np.linalg.norm(g.edge_tangents, axis=1), 1.0) + np.testing.assert_allclose(np.sum(g.edge_tangents * g.xyz[g.src], axis=1), 0.0, atol=1e-12) + + def test_roundtrip_arrays_and_lookup(self): + g = small_grid(2) + h = Grid.from_arrays(g.to_arrays(), g.res, g.radius_km) + np.testing.assert_array_equal(h.nbr_idx, g.nbr_idx) + i = g.cell_index(10.0, 20.0) + self.assertLess(abs(g.lat[i] - 10.0), 5.0) + + +class CellParentsTest(unittest.TestCase): + def test_same_as_h3_cell_to_parent(self): + import h3.api.basic_int as h3 + import numpy as np + from mapgen.grid import cell_parents + rng = np.random.default_rng(5) + for res in (1, 3, 6, 9): + cells = [h3.latlng_to_cell(float(la), float(lo), res + int(k)) + for la, lo, k in zip(rng.uniform(-90, 90, 300), rng.uniform(-180, 180, 300), rng.integers(0, 4, 300))] + pent = h3.get_pentagons(max(res - 2, 0))[:2] + [h3.latlng_to_cell(10.0, 20.0, max(res - 2, 0))] + cells += [c for p in pent for c in h3.cell_to_children(p, res)] # incl. pentagons + for pr in range(0, res + 1): + want = np.array([h3.cell_to_parent(c, pr) for c in cells], np.uint64) + self.assertTrue(np.array_equal(cell_parents(cells, pr), want), (res, pr)) + with self.assertRaises(ValueError): + cell_parents([h3.latlng_to_cell(0.0, 0.0, 2)], 3) + + +class EdgeBlocksTest(unittest.TestCase): + def test_blocked_edge_fields_same_as_whole(self): + from unittest import mock + from mapgen import grid as GR + from mapgen import plates as PL + from mapgen.sphere import tangent_dir + g = small_grid(3) + xyz, src, dst = g.xyz, g.src, g.dst + want_t = tangent_dir(xyz[src], xyz[dst]) # the whole-array formulas, written out + want_km = g.radius_km * np.arccos(np.clip(np.sum(xyz[src] * xyz[dst], axis=1), -1.0, 1.0)) + vel = np.random.default_rng(2).normal(size=(g.n, 3)) + rel = vel[dst] - vel[src] + along = np.sum(rel * want_t, axis=1) + want_tang = np.linalg.norm(rel - along[:, None] * want_t, axis=1) + with mock.patch.object(GR, "EDGE_BLOCK", 997): # many uneven blocks + g2 = small_grid(3) + self.assertTrue(np.array_equal(g2.edge_tangents, want_t)) + self.assertTrue(np.array_equal(g2.edge_km, want_km)) + conv, tang = PL.edge_convergence(g2, vel) + self.assertTrue(np.array_equal(conv, -along)) + self.assertTrue(np.array_equal(tang, want_tang)) + self.assertGreater(len(GR.edge_blocks(len(dst), 997)), 3) + with mock.patch.object(GR, "EDGE_BLOCK", 997): + for idx in (src, dst): + self.assertTrue(np.array_equal(GR.rowdot_at(vel, idx, want_t), np.sum(vel[idx] * want_t, axis=1))) + self.assertTrue(np.array_equal(GR.rowdot_at(vel, idx, want_t), np.sum(want_t * vel[idx], axis=1))) diff --git a/tests/test_hydrology.py b/tests/test_hydrology.py new file mode 100644 index 0000000..3e9314a --- /dev/null +++ b/tests/test_hydrology.py @@ -0,0 +1,133 @@ +import unittest + +import numpy as np + +from mapgen import graph as G +from mapgen import hydrology as HY +from tests.helpers import make_ctx + + +def basin_ctx(p_basin, pet_basin): + ctx = make_ctx(3) + g = ctx.grid + ocean = g.lat < -20 + z = np.where(ocean, -3000.0, 200.0 + 30.0 * (g.lat + 20)) # land rises northward + c = g.xyz[g.cell_index(40.0, 0.0)] + d = g.radius_km * np.arccos(np.clip(g.xyz @ c, -1, 1)) + z = np.where(d < 800, z - 1500 * (1 - d / 800), z) # closed basin + ctx.data.update({"elevation_eroded_m": z.astype(np.float32), "P_ann": np.full(g.n, p_basin), + "PET": np.full(g.n, pet_basin)}) + return ctx, d + + +class HydrologyTest(unittest.TestCase): + def test_strahler(self): + recv = np.array([2, 2, 6, 5, 5, 6, 6]) + levels = G.receiver_levels(recv) + o = HY.strahler(recv, levels, np.ones(7, bool)) + np.testing.assert_array_equal(o, [1, 1, 2, 1, 1, 2, 3]) + + def test_arid_basin_is_endorheic_with_salt(self): + ctx, d = basin_ctx(100.0, 1500.0) + out = HY.run(ctx) + inside = d < 300 + self.assertTrue(out["endorheic"][inside].all()) + self.assertTrue(out["salt_flat"][inside].any() or out["lake"][inside].any()) + + def test_humid_basin_overflows(self): + ctx, d = basin_ctx(2500.0, 400.0) + out = HY.run(ctx) + inside = d < 300 + self.assertTrue(out["lake"][inside].all()) + self.assertFalse(out["endorheic"][inside].any()) + + def test_mass_balance_and_rivers(self): + ctx, _ = basin_ctx(2500.0, 400.0) + out = HY.run(ctx) + g = ctx.grid + roots = out["recv"] == np.arange(g.n) + total = np.sum(out["runoff_mm"] * g.area_km2) * 1e-6 + self.assertAlmostEqual(out["discharge_km3_yr"][roots].sum(), total, delta=total * 1e-6) + self.assertTrue(out["river"].any()) + self.assertTrue(np.all(out["strahler"][out["river"]] >= 1)) + land = ctx.data["elevation_eroded_m"] > 0 + nonendo = land & ~out["endorheic"] & ~roots + self.assertTrue(np.all(out["z_filled_m"][out["recv"][nonendo]] < out["z_filled_m"][nonendo])) + + +class InlandDepressionTest(unittest.TestCase): + def test_humid_depression_below_sea_level_becomes_lake(self): + ctx, d = basin_ctx(2500.0, 400.0) + g = ctx.grid + z = ctx.data["elevation_eroded_m"].astype(np.float64) + z[d < 300] = -40.0 + ctx.data["elevation_eroded_m"] = z.astype(np.float32) + ctx.data["ocean"] = G.ocean_mask(g, z, 1.0e6) + out = HY.run(ctx) + self.assertTrue(out["lake"][d < 300].all()) + + +def two_pit_ctx(p, pet): + ctx = make_ctx(3) + g = ctx.grid + ocean = g.lat < -20 + z = np.where(ocean, -3000.0, 200.0 + 30.0 * (g.lat + 20)) + for lon in (-3.5, 3.5): + c = g.xyz[g.cell_index(40.0, lon)] + d = g.radius_km * np.arccos(np.clip(g.xyz @ c, -1, 1)) + z = np.where(d < 900, z - (1500 + 200 * (lon > 0)) * (1 - d / 900), z) # two pits, one depression + ctx.data.update({"elevation_eroded_m": z.astype(np.float32), "P_ann": np.full(g.n, p), "PET": np.full(g.n, pet)}) + return ctx, z + + +class TerminalTest(unittest.TestCase): + def test_every_land_root_is_lake_or_salt(self): + ctx, z = two_pit_ctx(700.0, 1400.0) + out = HY.run(ctx) + roots = (out["recv"] == np.arange(len(z))) & (z > 0) | (out["recv"] == np.arange(len(z))) & out["endorheic"] + self.assertTrue(out["endorheic"].any()) + self.assertTrue(np.all(out["lake"][roots] | out["salt_flat"][roots])) + self.assertEqual(int((roots & out["endorheic"]).sum()), 1) # one terminal for the depression + + def test_mass_balance_with_lake_evaporation(self): + ctx, z = two_pit_ctx(1000.0, 1200.0) + out = HY.run(ctx) + g = ctx.grid + roots = out["recv"] == np.arange(g.n) + total = np.sum(out["runoff_mm"] * g.area_km2) * 1e-6 + self.assertGreater(out["lake_loss_km3_yr"].sum(), 0) + self.assertAlmostEqual(out["discharge_km3_yr"][roots].sum() + out["lake_loss_km3_yr"].sum(), total, + delta=total * 1e-6) + + +class LakeBedTest(unittest.TestCase): + def test_lakes_carved_below_level_and_rerun_is_stable(self): + ctx, d = basin_ctx(2500.0, 400.0) + z0 = np.asarray(ctx.data["elevation_eroded_m"], dtype=np.float64) + out = HY.run(ctx) + lake, lev, z = out["lake"], out["lake_level_m"], out["elevation_eroded_m"].astype(np.float64) + self.assertTrue(lake.any()) + self.assertTrue(np.all(np.isnan(lev[~lake]))) + self.assertTrue(np.all(z[lake] <= lev[lake] - HY.DEFAULTS["lake_min_m"] + 1e-3)) + np.testing.assert_array_equal(z[~lake], z0[~lake].astype(np.float32)) # only lake beds change + ctx.data.update({"elevation_eroded_m": out["elevation_eroded_m"]}) # again (eras rerun hydrology) + again = HY.run(ctx) + np.testing.assert_array_equal(again["lake"], lake) + np.testing.assert_allclose(again["lake_level_m"][lake], lev[lake]) + np.testing.assert_array_equal(again["elevation_eroded_m"], out["elevation_eroded_m"]) + + def test_bigger_lakes_deeper(self): + from tests.helpers import make_ctx as mk + g = mk(3).grid + P = {**HY.DEFAULTS, "lake_depth_exp": 0.3} + z = np.zeros(g.n) + lake = np.zeros(g.n, bool) + small, big = g.cell_index(10.0, 0.0), np.flatnonzero(g.xyz @ g.xyz[g.cell_index(-30.0, 90.0)] > 0.97) + lake[small] = lake[big] = True + lid = np.where(lake, 0, -1) + lid[big] = 1 + bed = HY.lake_beds(g, z, np.where(lake, 0.0, np.nan), lake, lid, np.zeros(g.n, bool), np.zeros(g.n, bool), + np.full(g.n, 1000.0), 1, P) + self.assertLess(bed[big].min(), -P["lake_min_m"]) + self.assertLessEqual(bed[small], -P["lake_min_m"]) + self.assertTrue(np.all(bed[~lake] == 0)) diff --git a/tests/test_ice_fields.py b/tests/test_ice_fields.py new file mode 100644 index 0000000..08fda18 --- /dev/null +++ b/tests/test_ice_fields.py @@ -0,0 +1,98 @@ +import unittest + +import numpy as np + +from mapgen import fields as FI +from mapgen import ice as IC +from tests.helpers import make_ctx + + +class IceTest(unittest.TestCase): + def test_sheet_glacier_sea_ice(self): + ctx = make_ctx(3) + g = ctx.grid + polar_land = g.lat > 70 + z = np.where(polar_land, 500.0, -3000.0) + peak = g.cell_index(0.0, 0.0) + z[peak] = 6000.0 + t_mean = np.where(g.lat > 60, -25.0, 15.0) + t_mean[peak] = -9.0 # too warm for a sheet, summer below zero → glacier + t_sum = t_mean + 8 + t_win = t_mean - 8 + ctx.data.update({"elevation_eroded_m": z, "T_mean": t_mean, "T_jun": t_sum, "T_dec": t_win, + "P_ann": np.full(g.n, 300.0)}) + out = IC.run(ctx) + self.assertEqual(out["ice"][g.cell_index(85.0, 0.0)], IC.ICE_SHEET) + self.assertEqual(out["ice"][peak], IC.ICE_GLACIER) + self.assertEqual(out["ice"][g.cell_index(65.0, 0.0)], IC.ICE_SEA_PERENNIAL) + self.assertEqual(out["ice"][g.cell_index(0.0, 150.0)], IC.ICE_NONE) + i = g.cell_index(85.0, 0.0) + self.assertGreater(out["z_surface_m"][i], z[i] + 200) + + +class FieldsTest(unittest.TestCase): + def test_o2_and_gravity(self): + ctx = make_ctx(1) + n = ctx.grid.n + zs = np.zeros(n) + zs[1] = 8000.0 + m_o2 = np.zeros(n) + m_o2[2] = 1.0 + m_g = np.zeros(n) + m_g[3] = -1.0 + ctx.data.update({"z_surface_m": zs, "m_o2_zones": m_o2, "m_gravity_zones": m_g}) + out = FI.run(ctx) + self.assertAlmostEqual(out["po2_bar"][0], 0.21) + self.assertAlmostEqual(out["po2_bar"][1], 0.21 * np.exp(-1), places=6) + self.assertAlmostEqual(out["po2_bar"][2], 0.21 * 1.5) + self.assertAlmostEqual(out["gravity_g"][0], 1.05) + self.assertAlmostEqual(out["gravity_g"][3], 1.05 * 0.3) + + def test_pressure_o2_fraction_and_fire(self): + ctx = make_ctx(1) + n = ctx.grid.n + zs = np.zeros(n) + zs[1] = 8000.0 + m_o2 = np.zeros(n) + m_o2[2] = 1.0 + ctx.data.update({"z_surface_m": zs, "m_o2_zones": m_o2, "m_gravity_zones": np.zeros(n)}) + out = FI.run(ctx) + self.assertAlmostEqual(out["pressure_bar"][0], 1.0) + self.assertAlmostEqual(out["pressure_bar"][1], np.exp(-1)) + self.assertAlmostEqual(out["o2_fraction"][2], 0.21 * 1.5) + np.testing.assert_allclose(out["po2_bar"], out["o2_fraction"] * out["pressure_bar"]) + self.assertTrue(np.all(out["fire_reactivity"] == 1.0)) + np.testing.assert_allclose(out["plant_height_x"], ctx.cfg["planet"]["gravity_g"] / out["gravity_g"]) + np.testing.assert_allclose(FI.pressure(2.0, np.array([-100.0, 8000.0]), 8000.0), [2.0, 2.0 * np.exp(-1)]) + +class IceOceanMaskTest(unittest.TestCase): + def test_cold_inland_basin_is_not_sea_ice(self): + ctx = make_ctx(3) + g = ctx.grid + land = g.lat > 50 + z = np.where(land, 300.0, -3000.0) + basin = (g.lat > 60) & (g.lat < 68) & (np.abs(g.lon) < 20) + z[basin] = -20.0 + t = np.where(g.lat > 50, -5.0, 10.0) + ctx.data.update({"elevation_eroded_m": z, "ocean": ~land, "T_mean": t, "T_jun": t + 8, "T_dec": t - 8, + "P_ann": np.full(g.n, 300.0)}) + out = IC.run(ctx) + self.assertFalse(np.isin(out["ice"][basin], [IC.ICE_SEA_SEASONAL, IC.ICE_SEA_PERENNIAL]).any()) + + +class SummerMeltRuleTest(unittest.TestCase): + def test_ice_sheets_need_summers_below_freezing(self): + ctx = make_ctx(3) + g = ctx.grid + land = g.lat > 55 + z = np.where(land, 300.0, -3000.0) + t_mean = np.where(g.lat > 75, -25.0, -12.0) # 55–75°: cold on average, but summers melt + t_sum = np.where(g.lat > 75, -8.0, 4.0) + peak = g.cell_index(0.0, 0.0) + z[peak], t_mean[peak], t_sum[peak] = 6000.0, -9.0, -1.0 + ctx.data.update({"elevation_eroded_m": z, "ocean": ~(land | (np.arange(g.n) == peak)), "T_mean": t_mean, + "T_jun": t_sum, "T_dec": t_mean - 10, "P_ann": np.full(g.n, 300.0)}) + out = IC.run(ctx) + self.assertEqual(out["ice"][g.cell_index(65.0, 0.0)], IC.ICE_NONE) # tundra, not ice + self.assertEqual(out["ice"][g.cell_index(85.0, 0.0)], IC.ICE_SHEET) + self.assertEqual(out["ice"][peak], IC.ICE_GLACIER) # small cold spot = glacier diff --git a/tests/test_low_memory.py b/tests/test_low_memory.py new file mode 100644 index 0000000..c5ed333 --- /dev/null +++ b/tests/test_low_memory.py @@ -0,0 +1,83 @@ +import shutil +import tempfile +import tracemalloc +import unittest +from pathlib import Path + +import numpy as np + +from mapgen import pipeline as P +from mapgen import render as RN +from mapgen.testing import small_world + + +def _files(root: Path) -> dict: + return {p.relative_to(root).as_posix(): p.read_bytes() + for p in sorted(root.rglob("*")) if p.is_file() and "cache" not in p.parts} + + +class LowMemoryTest(unittest.TestCase): + def test_low_memory_build_writes_identical_files(self): + # oracle: the normal-mode build of the same world; every output byte must match + outs = [] + for low in (False, True): + tmp = Path(tempfile.mkdtemp()) + try: + small_world(tmp) + P.build(tmp, 2, log=lambda m: None, low_memory=low) + outs.append({**_files(tmp / "out"), **_files(tmp / "previews")}) + finally: + shutil.rmtree(tmp) + self.assertEqual(sorted(outs[0]), sorted(outs[1])) + for k in outs[0]: + self.assertEqual(outs[0][k], outs[1][k], k) + + def test_chunked_relief_matches_and_uses_less_memory(self): + rng = np.random.default_rng(0) + H, W = 512, 1024 + z = rng.normal(0, 2000, (H, W)) + hs = rng.random((H, W)) + zone = rng.integers(0, 38, (H, W)).astype(np.int16) + ground = rng.integers(0, 9, (H, W)).astype(np.int8) + ice = rng.integers(0, 5, (H, W)).astype(np.int8) + lake = rng.random((H, W)) < 0.05 + peaks = [] + outs = [] + for low in (False, True): + tracemalloc.start() + outs.append(RN.relief_rgb(z, hs, zone, ground, ice, lake, low_memory=low)) + peaks.append(tracemalloc.get_traced_memory()[1]) + tracemalloc.stop() + np.testing.assert_array_equal(outs[0], outs[1]) + self.assertLess(peaks[1], 0.4 * peaks[0]) # measured ≈ 0.28 + + +class ProjectionMemoryTest(unittest.TestCase): + def test_sampling_converts_one_channel_at_a_time(self): + from mapgen import projections as PJ + rng = np.random.default_rng(1) + img = rng.integers(0, 256, (512, 1024, 3), dtype=np.uint8) + lat = rng.uniform(-90, 90, (64, 64)) + lon = rng.uniform(-180, 180, (64, 64)) + whole = img.astype(np.float64) # oracle: the whole-image float conversion + want = np.stack([PJ.sample_equirect(whole[..., k], lat, lon) for k in range(3)], axis=-1) + del whole + tracemalloc.start() + got = PJ._sample_rgb(img, lat, lon) + peak = tracemalloc.get_traced_memory()[1] + tracemalloc.stop() + np.testing.assert_array_equal(got, want) + self.assertLess(peak, 0.5 * img.size * 8) # no full-size float64 copy + + +class HillshadeTest(unittest.TestCase): + def test_chunked_hillshade_matches_and_uses_less_memory(self): + z = np.random.default_rng(2).normal(0, 1500, (700, 1400)) # 700 rows: chunk edges fall mid-raster + peaks, outs = [], [] + for low in (False, True): + tracemalloc.start() + outs.append(RN.hillshade(z, 6371.0, low_memory=low)) + peaks.append(tracemalloc.get_traced_memory()[1]) + tracemalloc.stop() + np.testing.assert_array_equal(outs[0], outs[1]) + self.assertLess(peaks[1], 0.5 * peaks[0]) diff --git a/tests/test_ocean.py b/tests/test_ocean.py new file mode 100644 index 0000000..81ad8e1 --- /dev/null +++ b/tests/test_ocean.py @@ -0,0 +1,258 @@ +import unittest + +import numpy as np + +from mapgen import ocean as OC +from mapgen.sphere import east_north +from tests.helpers import small_grid + +DAY = 31.149 +KM_PER_DEG = 12742.0 * np.pi / 180.0 + + +def basin(g): + """Ocean box 15–45° N between two meridional coasts at ±60° lon (everything else land).""" + return (g.lat > 15) & (g.lat < 45) & (np.abs(g.lon) < 60) + + +def gyre_wind(g): + """Trades (from the east) at 15° |lat|, westerlies at 45° |lat|: −8 … +8 m/s east.""" + e, _ = east_north(g.xyz) + U = -8.0 * np.cos(np.radians((np.abs(g.lat) - 15.0) / 30.0 * 180.0)) + return U[:, None] * e + + +def params(**over): + P = dict(OC.DEFAULTS) + P["friction_days"] = 2.0 # r/β ≈ 750 km: resolved on the res-3 test grid (≈240 km spacing) + P.update(over) + return P + + +def northward(g, u): + _, n = east_north(g.xyz) + return np.sum(u * n, axis=1) + + +class GyreTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.g = small_grid(3) + cls.ocean = basin(cls.g) + cls.u = OC.currents(cls.g, cls.ocean, gyre_wind(cls.g), params(), DAY) + + def test_clockwise_with_western_intensification(self): + g, v = self.g, northward(self.g, self.u) + band = self.ocean & (g.lat > 25) & (g.lat < 35) + west_km = (g.lon + 60.0) * KM_PER_DEG * np.cos(np.radians(30.0)) + east_km = (60.0 - g.lon) * KM_PER_DEG * np.cos(np.radians(30.0)) + west, east = band & (west_km < 1500), band & (east_km < 1500) + self.assertGreater(v[west].max(), 0.0) # northward along the west coast + self.assertLess(v[east].min(), 0.0) # southward in the east + self.assertGreater(v[west].max(), 3.0 * -v[east].min()) # the western boundary current is the fast one + + def test_land_is_still(self): + g = self.g + psi = OC.streamfunction(g, self.ocean, gyre_wind(g), params(), DAY) + sp = np.linalg.norm(OC.velocity(g, psi), axis=1) + inland = ~self.ocean + for _ in range(2): # two cells away from any sea + inland = inland & ~np.bincount(g.src, weights=(~inland[g.dst]).astype(float), minlength=g.n).astype(bool) + self.assertLess(sp[inland].max(), 0.01 * sp[self.ocean].max()) + + def test_zero_on_land_in_output(self): + self.assertTrue(np.all(self.u[~self.ocean] == 0.0)) + + def test_southern_hemisphere_is_anticlockwise(self): + g = self.g + ocean = (g.lat < -15) & (g.lat > -45) & (np.abs(g.lon) < 60) + v = northward(g, OC.currents(g, ocean, gyre_wind(g), params(), DAY)) + band = ocean & (g.lat < -25) & (g.lat > -35) + west = band & (g.lon < -45) + self.assertLess(v[west].min(), 0.0) # southward along the west coast + self.assertGreater(-v[west].min(), 3.0 * max(v[band & (g.lon > 45)].max(), 1e-9)) + + +class ChannelTest(unittest.TestCase): + def test_westerlies_drive_eastward_flow_downwind_without_rotation(self): + g = small_grid(3) + ocean = (g.lat > 30) & (g.lat < 60) + e, _ = east_north(g.xyz) + wind = 8.0 * e + mid = (g.lat > 40) & (g.lat < 50) + u = OC.currents(g, ocean, wind, params(), 1e7) # ~no rotation: flow is downwind + ue, un = np.sum(u * e, axis=1), northward(g, u) + self.assertGreater(ue[mid].mean(), 0.0) + self.assertLess(np.abs(un[mid]).mean(), 0.05 * ue[mid].mean()) + u = OC.currents(g, ocean, wind, params(), DAY) + self.assertGreater(np.sum(u * e, axis=1)[mid].mean(), 0.0) + + +class DayLengthTest(unittest.TestCase): + def test_slower_rotation_widens_boundary_current(self): + g = small_grid(3) + ocean = basin(g) + band = ocean & (g.lat > 27) & (g.lat < 33) + widths = [] + for day in (DAY, 4 * DAY): + v = northward(g, OC.currents(g, ocean, gyre_wind(g), params(friction_days=4.0), day)) + half = band & (v > 0.5 * v[band].max()) + widths.append(g.lon[half].max() - g.lon[band].min()) + self.assertGreater(widths[1], widths[0]) + + +class EdgeCaseTest(unittest.TestCase): + def test_no_ocean_gives_zeros(self): + g = small_grid(2) + u = OC.currents(g, np.zeros(g.n, bool), gyre_wind(g), params(), DAY) + self.assertTrue(np.all(u == 0.0)) + + def test_all_ocean_globe_is_finite_at_the_poles(self): + g = small_grid(3) + e, _ = east_north(g.xyz) + wind = (-6.0 * np.cos(np.radians(3 * g.lat)))[:, None] * e + u = OC.currents(g, np.ones(g.n, bool), wind, params(), DAY) + self.assertTrue(np.all(np.isfinite(u))) + self.assertLess(np.linalg.norm(u, axis=1).max(), 20.0) + + +class CoarseTest(unittest.TestCase): + def test_coarse_fallback_keeps_the_gyre(self): + g = small_grid(3) + ocean = basin(g) + v = northward(g, OC.currents(g, ocean, gyre_wind(g), params(friction_days=1.0, direct_max_cells=1000), DAY)) + band = ocean & (g.lat > 25) & (g.lat < 35) + self.assertTrue(np.all(np.isfinite(v))) + self.assertGreater(v[band & (g.lon < -40)].mean(), 0.0) + self.assertLess(v[band & (g.lon > -20)].mean(), 0.0) + + +class SSTTest(unittest.TestCase): + def test_still_water_keeps_equilibrium(self): + g = small_grid(3) + ocean = basin(g) + T_eq = 30.0 - 0.5 * np.abs(g.lat) + T = OC.sst(g, ocean, np.zeros((g.n, 3)), T_eq, params(kappa_m2s=0.0)) + np.testing.assert_allclose(T, T_eq, atol=1e-6) + + def test_boundary_current_warms_the_west_side(self): + g = small_grid(3) + ocean = basin(g) + u = OC.currents(g, ocean, gyre_wind(g), params(), DAY) + T_eq = 30.0 - 0.5 * np.abs(g.lat) + a = OC.sst(g, ocean, u, T_eq, params()) - T_eq + band = ocean & (g.lat > 32) & (g.lat < 40) + self.assertGreater(a[band & (g.lon < -45)].mean(), a[band & (g.lon > 45)].mean() + 0.2) + self.assertGreater(a[band & (g.lon < -45)].mean(), 0.0) + + +class UpwellingTest(unittest.TestCase): + def setUp(self): + self.g = small_grid(3) + _, self.n = east_north(self.g.xyz) + self.P = params(upwell_coast_km=0.0) + + def coast(self, ocean, lo, hi): + g = self.g + touches_land = np.bincount(g.src, weights=(~ocean[g.dst]).astype(float), minlength=g.n) > 0 + return ocean & touches_land & (g.lat > lo) & (g.lat < hi) + + def test_west_coast_upwells(self): + g = self.g + ocean = ~((g.lon > 0) & (g.lon < 90) & (g.lat > 10) & (g.lat < 50)) # continent east of the sea + w = OC.upwelling(g, ocean, -6.0 * self.n, self.P, DAY) # equatorward wind (north) + self.assertGreater(w[self.coast(ocean, 20, 40) & (g.lon < 0)].mean(), 0.0) + + def test_east_coast_downwells(self): + g = self.g + ocean = ~((g.lon > -90) & (g.lon < 0) & (g.lat > 10) & (g.lat < 50)) # continent west of the sea + w = OC.upwelling(g, ocean, -6.0 * self.n, self.P, DAY) + self.assertLess(w[self.coast(ocean, 20, 40) & (g.lon > 0)].mean(), 0.0) + + def test_southern_west_coast_upwells_under_equatorward_wind(self): + g = self.g + ocean = ~((g.lon > 0) & (g.lon < 90) & (g.lat < -10) & (g.lat > -50)) + w = OC.upwelling(g, ocean, 6.0 * self.n, self.P, DAY) # equatorward in the south = north + self.assertGreater(w[self.coast(ocean, -40, -20) & (g.lon < 0)].mean(), 0.0) + + def test_trades_upwell_at_the_equator(self): + g = self.g + e, _ = east_north(g.xyz) + w = OC.upwelling(g, np.ones(g.n, bool), -6.0 * e, self.P, DAY) + self.assertGreater(w[np.abs(g.lat) < 3].mean(), 0.0) + self.assertLess(w[(np.abs(g.lat) > 8) & (np.abs(g.lat) < 20)].mean(), w[np.abs(g.lat) < 3].mean()) + + def test_zero_on_land(self): + g = self.g + ocean = g.lat < 0 + self.assertTrue(np.all(OC.upwelling(g, ocean, -6.0 * self.n, params(), DAY)[~ocean] == 0.0)) + + +class ProductivityTest(unittest.TestCase): + def test_upwelling_coast_beats_gyre_centre(self): + g = small_grid(3) + ocean = ~((g.lon > 0) & (g.lon < 90) & (g.lat > 10) & (g.lat < 50)) + _, n = east_north(g.xyz) + w = OC.upwelling(g, ocean, -6.0 * n, params(), DAY) + p = OC.productivity(g, ocean, w, np.full(g.n, -4000.0), np.zeros(g.n), np.full(g.n, 20.0), params()) + touches_land = np.bincount(g.src, weights=(~ocean[g.dst]).astype(float), minlength=g.n) > 0 + coast = ocean & touches_land & (g.lat > 25) & (g.lat < 35) & (g.lon < 0) + centre = g.cell_index(30.0, -60.0) + self.assertGreater(p[coast].max(), 0.1) + self.assertGreater(p[coast].max(), 10.0 * p[centre]) + + def test_shelf_and_light(self): + g = small_grid(3) + ocean = g.lat < 0 + zero = np.zeros(g.n) + shelf = OC.productivity(g, ocean, zero, np.full(g.n, -100.0), zero, np.full(g.n, 25.0), params()) + deep = OC.productivity(g, ocean, zero, np.full(g.n, -3000.0), zero, np.full(g.n, 25.0), params()) + i = g.cell_index(-10.0, 0.0) + self.assertAlmostEqual(shelf[i], 0.4 * (0.3 + 0.7 * np.cos(np.radians(g.lat[i]))), places=6) # a_s·light + self.assertEqual(deep[i], 0.0) + self.assertGreater(shelf[i], shelf[g.cell_index(-80.0, 0.0)]) + + def test_sharp_sst_front_is_productive(self): + g = small_grid(3) + ocean = g.lat < 0 + zero, deep = np.zeros(g.n), np.full(g.n, -4000.0) + front = 15.0 + 8.0 * np.tanh((g.lat + 35.0) / 2.0) # 16 °C across ≈ 4° (≈ 900 km): ≈ 2 °C/100 km + flat = 25.0 + 0.1 * g.lat # background pole-ward cooling, ≈ 0.05 °C/100 km + pf = OC.productivity(g, ocean, zero, deep, zero, front, params()) + pb = OC.productivity(g, ocean, zero, deep, zero, flat, params()) + at, far = g.cell_index(-35.0, 0.0), g.cell_index(-60.0, 0.0) + self.assertGreater(pf[at], 0.15) + self.assertLess(pf[far], 0.02) + self.assertLess(pb.max(), 1e-9) # gentle background gradients add nothing + + def test_land_sea_contrast_is_not_a_front(self): + g = small_grid(3) + ocean = g.lat < 0 + zero, deep = np.zeros(g.n), np.full(g.n, -4000.0) + sst_c = np.where(ocean, 20.0, -10.0) # land temperatures differ wildly + self.assertLess(OC.productivity(g, ocean, zero, deep, zero, sst_c, params()).max(), 1e-9) + + def test_range_and_land(self): + g = small_grid(3) + ocean = g.lat < 0 + p = OC.productivity(g, ocean, np.full(g.n, 1e4), np.zeros(g.n), np.full(g.n, 40.0), np.zeros(g.n), params()) + self.assertTrue(np.all((p >= 0) & (p <= 1))) + self.assertTrue(np.all(p[~ocean] == 0)) + + +class GaugeTest(unittest.TestCase): + def test_no_spurious_vortex_at_the_gauge(self): + # cell 0 (79° N, 38° E on H3 grids) at sea in a world with a continent: pinning ψ there must not leave a point + # vortex (the solve's compatibility residual) — the speed around cell 0 stays within the ocean's p99 + g = small_grid(3) + land = (np.abs(g.lat) < 40) & (np.abs(g.lon) < 50) + ocean = ~land + self.assertTrue(ocean[0]) + e, _ = east_north(g.xyz) + wind = (-8.0 * np.cos(np.radians(3.0 * g.lat)))[:, None] * e + sp = np.linalg.norm(OC.currents(g, ocean, wind, params(), DAY), axis=1) + near = np.zeros(g.n, bool) + near[0] = True + for _ in range(2): + near = near | (np.bincount(g.src, weights=near[g.dst].astype(float), minlength=g.n) > 0) + self.assertLessEqual(sp[near].max(), np.percentile(sp[ocean], 99)) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 0000000..adbe3c4 --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,185 @@ +import shutil +import tempfile +import unittest +from pathlib import Path + +import numpy as np + +from mapgen import pipeline as P +from mapgen.testing import FIXTURE_TOML +from tests.helpers import ROOT, make_ctx + +TECT = """ +[[plate]] +id = "a" +seed = [0.0, 0.0] +kind = "continental" +motion = [90.0, 3.0] +[[plate]] +id = "b" +seed = [0.0, 90.0] +kind = "oceanic" +motion = [270.0, 3.0] +""" + + +class PipelineTest(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + (self.tmp / "config").mkdir() + shutil.copy(FIXTURE_TOML, self.tmp / "config" / "world.toml") + (self.tmp / "config" / "tectonics.toml").write_text(TECT) + self.log = [] + + def tearDown(self): + shutil.rmtree(self.tmp) + + def _build(self, **kw): + self.log = [] + return P.build(self.tmp, 1, stop="grid", log=self.log.append, **kw) + + def test_cache_hit(self): + ctx = self._build() + self.assertEqual(ctx.grid.n, 842) + self.assertFalse(any("cached" in m for m in self.log)) + ctx = self._build() + self.assertTrue(any("grid: cached" in m for m in self.log)) + self.assertEqual(ctx.grid.n, 842) + + def test_config_change_invalidates(self): + self._build() + p = self.tmp / "config" / "world.toml" + p.write_text(p.read_text().replace("seed = 1296", "seed = 1297")) + self._build() + self.assertFalse(any("cached" in m for m in self.log)) + + def test_from_forces_rerun(self): + self._build() + self._build(start="grid") + self.assertFalse(any("cached" in m for m in self.log)) + + def test_need_reports_missing(self): + ctx = make_ctx(1) + with self.assertRaisesRegex(P.StageError, "sk_land"): + ctx.need("sk_land") + + +class OceanExportTest(unittest.TestCase): + def test_cells_and_fields_carry_the_ocean(self): + import json + from mapgen.testing import built_world + root = built_world() + outs = sorted((root / "out").glob("r*/cells.npz")) + self.assertTrue(outs) + for cells in outs + sorted((root / "out").glob("r*/eras/*/cells.npz")): # every era re-runs climate + z = np.load(cells) + for k in ("current", "current_speed", "sst", "upwelling", "productivity"): + self.assertIn(k, z.files, f"{cells}: {k}") + meta = json.loads((outs[0].parent / "fields.json").read_text()) + for k in ("sst", "productivity", "current_speed", "upwelling"): + self.assertIn(k, meta["continuous"]) + layers = [L["id"] for L in json.loads((outs[0].parent / "viewer" / "layers.json").read_text())] + self.assertIn("currents", layers) + + +class NpzMapsTest(unittest.TestCase): + def test_same_arrays_as_np_load_writable_and_file_untouched(self): + import hashlib + import tempfile + import numpy as np + from pathlib import Path + from mapgen import pipeline as P + rng = np.random.default_rng(0) + arrays = {"f": rng.normal(size=1000), "i": rng.integers(0, 9, (40, 3)).astype(np.int16), + "b": rng.random(77) < 0.5, "F": np.asfortranarray(rng.random((30, 4))), "s": np.array(2.5), + "e": np.zeros((0, 3), np.float32), "_key": np.array("abc"), "u": np.arange(5, dtype=np.uint64), + "big": rng.normal(size=(3_000_000,)), "nc": rng.random((50, 8))[:, ::3], "x" * 200: np.arange(7)} + with tempfile.TemporaryDirectory() as t: + for save in (np.savez, np.savez_compressed, P.save_npz_aligned): + f = Path(t) / f"{save.__name__}.npz" + save(f, **arrays) + before = hashlib.sha256(f.read_bytes()).hexdigest() + got = P.npz_maps(f) + if save is P.save_npz_aligned: # every member mapped in place, at aligned addresses + for k, v in got.items(): + if v.size: + self.assertEqual(v.ctypes.data % P.ALIGN, 0, k) + b = v + while isinstance(b, (np.ndarray, memoryview)): + b = b.base if isinstance(b, np.ndarray) else b.obj + self.assertIsInstance(b, __import__("mmap").mmap, k) + with np.load(f) as z: + self.assertEqual(sorted(got), sorted(z.files)) + for k in z.files: + self.assertEqual(got[k].dtype, z[k].dtype, k) + self.assertEqual(got[k].shape, z[k].shape, k) + self.assertTrue(np.array_equal(got[k], z[k]), k) + self.assertTrue(np.array_equal(got[k], arrays[k]), k) + for k, v in got.items(): # whatever is mapped is aligned (else: read) + b = v + while isinstance(b, (np.ndarray, memoryview)): + b = b.base if isinstance(b, np.ndarray) else b.obj + if isinstance(b, __import__("mmap").mmap): + self.assertEqual(v.ctypes.data % P.ALIGN, 0, (save.__name__, k)) + got["f"][:] = -1.0 # private copy-on-write: allowed, file unchanged + got["F"][0, 0] = 9.0 + self.assertEqual(float(got["F"][0, 0]), 9.0) + self.assertEqual(hashlib.sha256(f.read_bytes()).hexdigest(), before, save.__name__) + self.assertTrue(np.array_equal(np.load(f)["f"], arrays["f"])) + + def test_low_memory_rebuild_from_cache_identical(self): + import shutil + import tempfile + from pathlib import Path + from mapgen import pipeline as P + from mapgen.testing import small_world + outs = [] + tmp = Path(tempfile.mkdtemp()) + try: + small_world(tmp) + for low in (False, True, True): # normal; low (fresh cache hit); low again + ctx = P.build(tmp, 2, log=lambda m: None, low_memory=low) + outs.append({k: v for k, v in ctx.data.items()}) + for k, v in outs[0].items(): + for o in outs[1:]: + self.assertTrue(np.array_equal(np.asarray(o[k]), np.asarray(v), equal_nan=np.asarray(v).dtype.kind == "f"), k) + self.assertFalse(list((tmp / "out" / "cache" / "r2").glob("*.tmp-*")), "no temporary files left") + import mmap + ctx = P.build(tmp, 2, start="climate", log=lambda m: None, low_memory=True) # freshly computed + for k in ("T_jun", "P_ann", "z_surface_m", "plate"): + b = ctx.data[k] + while isinstance(b, (np.ndarray, memoryview)): + b = b.base if isinstance(b, np.ndarray) else b.obj + self.assertIsInstance(b, mmap.mmap, f"{k}: kept on disk, not in RAM") + self.assertTrue(np.array_equal(ctx.data[k], outs[0][k], equal_nan=True), k) + finally: + shutil.rmtree(tmp) + + +class AlignedSumTest(unittest.TestCase): + def test_misaligned_data_sums_differently(self): + """Why the cache is aligned: numpy sums misaligned float64 data in another order (else drop the alignment).""" + import numpy as np + x = np.random.default_rng(0).normal(size=2_000_000) + buf = bytearray(8 * len(x) + 8) + a = np.frombuffer(memoryview(buf)[4:4 + 8 * len(x)], np.float64) + np.copyto(a, x) + self.assertFalse(a.flags.aligned) + self.assertTrue(np.array_equal(a, x)) + if a.sum() == x.sum(): + self.skipTest("this numpy sums misaligned data the same way") + self.assertNotEqual(a.sum(), x.sum()) + + +class BlasSpinTest(unittest.TestCase): + def test_import_sets_a_short_blas_spin_unless_given(self): + import os + import subprocess + import sys + code = "import os, mapgen, numpy; print(os.environ['OPENBLAS_THREAD_TIMEOUT'])" + env = {k: v for k, v in os.environ.items() if k != "OPENBLAS_THREAD_TIMEOUT"} + out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, env=env, cwd=os.getcwd()) + self.assertEqual(out.stdout.strip(), "4") + out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, + env={**env, "OPENBLAS_THREAD_TIMEOUT": "9"}, cwd=os.getcwd()) + self.assertEqual(out.stdout.strip(), "9") diff --git a/tests/test_plateaus.py b/tests/test_plateaus.py new file mode 100644 index 0000000..00da0ed --- /dev/null +++ b/tests/test_plateaus.py @@ -0,0 +1,78 @@ +# map/tests/test_plateaus.py +import json +import unittest + +import numpy as np + +from mapgen import plateaus as PL +from mapgen.sphere import east_north, great_circle_point, latlon_to_xyz +from tests.helpers import small_grid + +R = 12742.0 +P1 = {"name": "east-flank", "center": [-39.9, -18.0], "area_km2": 3.0e6, "elongation": 1.9, "azimuth_deg": 30.0, + "top_m": [1500.0, 3000.0]} +P2 = {"name": "plateau-02", "center": [-0.4, 176.6], "area_km2": 1.0e6, "top_m": [1000.0, 1500.0], "islands": True} + + +def at(lat, lon): + return latlon_to_xyz(lat, lon)[None] + + +class PlateauGeometryTest(unittest.TestCase): + def test_centre_inside_and_area_close_to_config(self): + g = small_grid(4) + for p in (P1, P2): + self.assertLess(PL.rho(at(*p["center"]), p, 1296, R)[0], 1e-6) + area = g.area_km2[PL.rho(g.xyz, p, 1296, R) < 1.0].sum() + self.assertAlmostEqual(area / p["area_km2"], 1.0, delta=0.2, msg=p["name"]) + + def test_long_axis_follows_the_azimuth(self): + a, b = PL.semi_axes(P1) + self.assertAlmostEqual(a / b, 1.9) + self.assertAlmostEqual(np.pi * a * b, P1["area_km2"], delta=1.0) + c = latlon_to_xyz(*P1["center"]) + e, n = east_north(c[None]) + az = np.radians(30.0) + along = np.sin(az) * e[0] + np.cos(az) * n[0] + across = np.cos(az) * e[0] - np.sin(az) * n[0] + self.assertLess(PL.rho(great_circle_point(c, along, 0.8 * a, R)[None], P1, 1296, R)[0], 1.0) + self.assertGreater(PL.rho(great_circle_point(c, across, 0.8 * a, R)[None], P1, 1296, R)[0], 1.0) + + def test_features_are_deterministic_inside_and_json_ready(self): + f = PL.features(P2, 1296, R) + self.assertEqual(f, PL.features(P2, 1296, R)) + json.dumps(f) + self.assertTrue(6 <= len(f["cones"]) <= 20) + self.assertTrue(1 <= len(f["calderas"]) <= 3) + self.assertTrue(3 <= sum(c["island"] for c in f["cones"]) <= 6) + for c in f["cones"] + f["calderas"]: + self.assertLess(PL.rho(at(c["lat"], c["lon"]), P2, 1296, R)[0], 0.86) + self.assertFalse(any(c["island"] for c in PL.features(P1, 1296, R)["cones"])) + self.assertEqual(PL.features({**P1, "vent": 0.0}, 1296, R)["cones"], []) + + def test_surface_depth_follows_top_m(self): + g = small_grid(4) + inner = g.xyz[PL.rho(g.xyz, P1, 1296, R) < 0.5] + z = PL.surface(inner, P1, {"cones": [], "calderas": []}, 1296, R, np.full(len(inner), -5900.0)) + self.assertTrue(np.all(z <= -P1["top_m"][0] + PL.RELIEF_M + 1e-6)) + self.assertTrue(np.all(z >= -P1["top_m"][1] - PL.RELIEF_M - 1e-6)) + + def test_apply_hidden_clamp_islands_and_nothing_else(self): + g = small_grid(4) + plats = [P1, P2] + ids = PL.cell_ids(g.xyz, plats, 1296, R) + self.assertGreater((ids == 0).sum(), 100) + z = PL.apply(g, np.full(g.n, -6000.0), plats, ids, 1296) + self.assertLessEqual(z[ids == 0].max(), PL.HIDDEN_MAX_M) + self.assertGreater(z.max(), 0.0, "island plateau: small volcanic islands") + changed_outside = (ids < 0) & (z != -6000.0) + self.assertLessEqual(changed_outside.sum(), 6, "only islands' nearest cells may lie outside the outline") + + def test_outline_across_the_antimeridian_and_near_the_pole(self): + p = {"name": "plateau-11", "center": [77.0, 179.0], "area_km2": 0.9e6, "top_m": [1000.0, 1500.0]} + self.assertLess(PL.rho(at(77.0, 179.8), p, 1296, R)[0], 0.3) + self.assertLess(PL.rho(at(77.0, -179.8), p, 1296, R)[0], 0.3) + q = {"name": "polar", "center": [88.0, 0.0], "area_km2": 1.0e6, "top_m": [1000.0, 1500.0]} + self.assertLess(PL.rho(at(89.9, 90.0), q, 1296, R)[0], 1.0) + self.assertLess(PL.rho(at(89.9, -90.0), q, 1296, R)[0], 1.0) + self.assertTrue(np.all(np.isfinite(PL.rho(small_grid(3).xyz, q, 1296, R)))) diff --git a/tests/test_plates.py b/tests/test_plates.py new file mode 100644 index 0000000..9b71bb9 --- /dev/null +++ b/tests/test_plates.py @@ -0,0 +1,73 @@ +import unittest + +import numpy as np + +from mapgen import plates as PL +from mapgen.pipeline import StageError +from tests.helpers import make_ctx + + +def two_plates(az_a, az_b, speed=5.0, kinds=("oceanic", "oceanic")): + return {"plate": [ + {"id": "a", "seed": [0.0, -40.0], "kind": kinds[0], "motion": [az_a, speed]}, + {"id": "b", "seed": [0.0, 40.0], "kind": kinds[1], "motion": [az_b, speed]}, + ]} + + +def ctx_for(tect, land=None, res=2): + ctx = make_ctx(res, tect=tect) + n = ctx.grid.n + ctx.data.update({"sk_land": np.zeros(n) if land is None else land, "m_land_hint": np.zeros(n)}) + return ctx + + +class PlatesTest(unittest.TestCase): + def test_convergent_then_divergent(self): + for az_a, az_b, want in ((90.0, 270.0, PL.CONV), (270.0, 90.0, PL.DIV)): + ctx = ctx_for(two_plates(az_a, az_b)) + out = PL.run(ctx) + g = ctx.grid + near = (np.abs(g.lon) < 12) & (np.abs(g.lat) < 30) & (out["bnd_type"] > 0) + self.assertGreater(near.sum(), 3) + self.assertTrue(np.mean(out["bnd_type"][near] == want) > 0.8, (az_a, want)) + + def test_every_cell_assigned_and_seeds_own_cells(self): + ctx = ctx_for(two_plates(90.0, 270.0)) + out = PL.run(ctx) + g = ctx.grid + self.assertEqual(set(np.unique(out["plate"])), {0, 1}) + self.assertEqual(out["plate"][g.cell_index(0.0, -40.0)], 0) + self.assertEqual(out["plate"][g.cell_index(0.0, 40.0)], 1) + + def test_continent_stays_on_its_plate(self): + tect = {"plate": [ + {"id": "c", "seed": [0.0, 0.0], "kind": "continental", "motion": [0.0, 1.0]}, + {"id": "o", "seed": [0.0, 60.0], "kind": "oceanic", "motion": [0.0, 1.0]}, + ]} + ctx = make_ctx(2, tect=tect) + g = ctx.grid + land = ((np.abs(g.lat) < 20) & (np.abs(g.lon) < 40)).astype(float) + ctx.data.update({"sk_land": land, "m_land_hint": np.zeros(g.n)}) + out = PL.run(ctx) + self.assertTrue(np.all(out["plate"][land > 0.5] == 0)) + + def test_duplicate_seed_cell_errors(self): + tect = two_plates(90.0, 270.0) + tect["plate"][1]["seed"] = [0.01, -40.01] + with self.assertRaisesRegex(StageError, "same cell"): + PL.run(ctx_for(tect)) + + def test_zero_speed_plate(self): + out = PL.run(ctx_for(two_plates(90.0, 270.0, speed=0.0))) + self.assertTrue(np.all(np.isfinite(out["vel"]))) + self.assertTrue(np.allclose(out["vel"], 0.0)) + + +class MeanderTest(unittest.TestCase): + def test_boundaries_meander(self): + ctx = ctx_for(two_plates(90.0, 270.0, kinds=("continental", "continental")), res=4) + g = ctx.grid + ctx.data["sk_land"] = ((np.abs(g.lat) < 40) & (np.abs(g.lon) < 70)).astype(float) + out = PL.run(ctx) + b = (out["bnd_type"] > 0) & (np.abs(g.lat) < 35) & (np.abs(g.lon) < 40) + self.assertGreater(g.lon[b].std(), 3.0) diff --git a/tests/test_projections.py b/tests/test_projections.py new file mode 100644 index 0000000..f1a5f90 --- /dev/null +++ b/tests/test_projections.py @@ -0,0 +1,55 @@ +import unittest + +import numpy as np + +from mapgen import projections as PJ + + +class ProjectionTest(unittest.TestCase): + def test_equal_earth_roundtrip(self): + lat = np.array([0.0, 30.0, -60.0, 89.0, 10.0]) + lon = np.array([0.0, 120.0, -170.0, 45.0, -179.0]) + x, y = PJ.equal_earth_forward(lat, lon) + la, lo = PJ.equal_earth_inverse(x, y) + np.testing.assert_allclose(la, lat, atol=1e-6) + np.testing.assert_allclose(lo, lon, atol=1e-6) + + def test_mollweide_roundtrip(self): + lat = np.array([0.0, 45.0, -80.0, 89.9]) + lon = np.array([0.0, -100.0, 170.0, 20.0]) + x, y = PJ.mollweide_forward(lat, lon) + la, lo = PJ.mollweide_inverse(x, y) + np.testing.assert_allclose(la, lat, atol=1e-6) + np.testing.assert_allclose(lo, lon, atol=1e-6) + + def test_equal_area(self): + # equal lat/lon boxes at the equator and at 60° should differ in area by cos(lat) — as on the sphere + def box_area(f, lat0): + la = np.array([lat0, lat0, lat0 + 1, lat0 + 1]); lo = np.array([0.0, 1.0, 1.0, 0.0]) + x, y = f(la, lo) + return 0.5 * abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))) + for f in (PJ.equal_earth_forward, PJ.mollweide_forward): + ratio = box_area(f, 60.0) / box_area(f, 0.0) + self.assertAlmostEqual(ratio, (np.sin(np.radians(61)) - np.sin(np.radians(60))) / np.sin(np.radians(1)), + delta=0.01) + + def test_orthographic_centre_and_limb(self): + lat, lon, ok = PJ.orthographic_inverse(np.array([0.0, 0.99, 1.5]), np.array([0.0, 0.0, 0.0]), 40.0, 10.0) + self.assertAlmostEqual(lat[0], 40.0, places=9) + self.assertAlmostEqual(lon[0], 10.0, places=9) + self.assertTrue(ok[0] and ok[1] and not ok[2]) + + def test_reproject_image(self): + img = np.zeros((90, 180, 3), np.uint8) + img[:, :90] = (255, 0, 0) # western hemisphere red + img[:, 90:] = (0, 0, 255) # eastern blue + out = PJ.reproject(img, "equal_earth", 400) + h, w = out.shape[:2] + self.assertTrue(1.9 < w / h < 2.2) + self.assertEqual(tuple(out[h // 2, w // 4]), (255, 0, 0)) + self.assertEqual(tuple(out[h // 2, 3 * w // 4]), (0, 0, 255)) + self.assertEqual(tuple(out[2, 2]), PJ.BACKGROUND) # outside the map outline + globe = PJ.globe(img, 0.0, -90.0, 200) + self.assertEqual(globe.shape[:2], (200, 200)) + self.assertGreater(int(globe[100, 100, 0]), 150) # centred on the red hemisphere + self.assertEqual(tuple(globe[2, 2]), PJ.BACKGROUND) diff --git a/tests/test_render.py b/tests/test_render.py new file mode 100644 index 0000000..4c25cb0 --- /dev/null +++ b/tests/test_render.py @@ -0,0 +1,239 @@ +import json +import re +import shutil +import tempfile +import unittest +from pathlib import Path + +import numpy as np +from PIL import Image + +from mapgen import pipeline as P +from mapgen import render as RN +from tests.helpers import small_grid +from mapgen.sphere import east_north +from mapgen.testing import FIXTURE_TOML + +from mapgen.testing import TECT, small_world # noqa: F401 (other tests import them from here) + + +class RenderTest(unittest.TestCase): + def test_encode_roundtrip(self): + v = np.array([-11000.0, 0.0, 8848.3]) + np.testing.assert_allclose(RN.decode(RN.encode(v, 0.5, -12000.0), 0.5, -12000.0), v, atol=0.25) + + def test_full_pipeline_small(self): + tmp = Path(tempfile.mkdtemp()) + try: + small_world(tmp) + ctx = P.build(tmp, 2, log=lambda m: None) + out = tmp / "out" / "r2" + meta = json.loads((out / "fields.json").read_text()) + im = Image.open(out / "raster" / meta["continuous"]["elevation"]["file"]) + self.assertEqual(im.size, (256, 128)) + raw = np.array(im) + e = meta["continuous"]["elevation"] + z = RN.decode(raw, e["scale"], e["offset"]) + self.assertTrue(-11500 < z.min() < 0 < z.max() < 13000) + cells = np.load(out / "cells.npz") + self.assertEqual(len(cells["g_ids"]), ctx.grid.n) + self.assertIn("holdridge", cells.files) + self.assertTrue((tmp / "previews" / "r2" / "contact_sheet.png").exists()) + self.assertTrue((out / "geo" / "coast.geojson").exists()) + for f in ("proj_equal_earth.png", "proj_mollweide.png", "globes_sheet.png", "globe_north_pole.png"): + self.assertTrue((tmp / "previews" / "r2" / f).exists(), f) + vidx = json.loads((out / "viewer" / "layers.json").read_text()) + self.assertEqual([L["id"] for L in vidx], ["relief", "biomes", "elevation", "temperature", "rainfall", + "seasonality", "landform", "ground", "ice", "deposits", "plates", "o2", + "gravity", "pressure", "fire", "seabed", "minerals", + "bottom_temp", "sediment", "vent_potential", "currents", "sst", "productivity"]) + self.assertEqual(Image.open(out / "viewer" / "biomes.jpg").size, (256, 128)) + self.assertEqual(len(vidx[1]["legend"]["items"]), 38) + cm = json.loads((out / "cells_meta.json").read_text()) + self.assertEqual(cm["res"], 2) + self.assertEqual(len(cm["legends"]["holdridge"]), 38) + finally: + shutil.rmtree(tmp) + + def test_deterministic(self): + outs = [] + for _ in range(2): + tmp = Path(tempfile.mkdtemp()) + try: + small_world(tmp) + outs.append(P.build(tmp, 2, stop="erosion", log=lambda m: None).data["elevation_eroded_m"]) + finally: + shutil.rmtree(tmp) + np.testing.assert_array_equal(outs[0], outs[1]) + + def test_viewer_has_the_sea_floor_and_zone_layers(self): + from mapgen.testing import built_world + root = built_world() + ids = [L["id"] for L in json.loads((root / "out" / "r2" / "viewer" / "layers.json").read_text())] + for k in ("seabed", "minerals", "bottom_temp", "sediment", "vent_potential", "pressure", "fire"): + self.assertIn(k, ids) + + +class DevWidthTest(unittest.TestCase): + def test_dev_builds_use_dev_raster_width(self): + tmp = Path(tempfile.mkdtemp()) + try: + small_world(tmp) + w = tmp / "config" / "world.toml" + w.write_text(w.read_text().replace("dev_raster_width = 256", "dev_raster_width = 128")) + P.build(tmp, 2, log=lambda m: None) # res 2 != res_final → dev width + meta = json.loads((tmp / "out" / "r2" / "fields.json").read_text()) + self.assertEqual(meta["width"], 128) + finally: + shutil.rmtree(tmp) + + +class RiverDrawTest(unittest.TestCase): + def test_rivers_are_drawn_and_seam_skipped(self): + from types import SimpleNamespace + g = SimpleNamespace(n=4, lat=np.array([0.0, 0.0, 0.0, 0.0]), lon=np.array([-90.0, 0.0, 90.0, 179.0])) + recv = np.array([1, 2, 2, 0]) + img = np.zeros((20, 40, 3), np.uint8) + out = RN.draw_rivers(img, g, recv, np.array([True, True, False, True]), np.array([2, 2, 0, 1], np.int8)) + row = out[9:11].max(axis=0) + self.assertTrue(np.all(row[11:29, 2] > 100)) # blue along lon −90..90 + self.assertEqual(int(out[:, 32:, 2].sum()), 0) # the 179 → −90 segment wraps the seam: not drawn + + +class RegionalWidthTest(unittest.TestCase): + def test_finer_than_final_uses_full_width(self): + tmp = Path(tempfile.mkdtemp()) + try: + small_world(tmp) + w = tmp / "config" / "world.toml" + w.write_text(w.read_text().replace("res_final = 5", "res_final = 1").replace("res_dev = 4", "res_dev = 1") + .replace("dev_raster_width = 256", "dev_raster_width = 128")) + P.build(tmp, 2, log=lambda m: None) # res 2 > res_final: regional/finer build + meta = json.loads((tmp / "out" / "r2" / "fields.json").read_text()) + self.assertEqual(meta["width"], 256) + finally: + shutil.rmtree(tmp) + + +class PixelCoastTest(unittest.TestCase): + def test_pixel_land_rule(self): + ocean_k = np.array([[[True, True, True], [False, False, False], [True, False, False], [True, False, False]]]) + z = np.array([[50.0, -30.0, 10.0, -10.0]]) + np.testing.assert_array_equal(RN.pixel_land(ocean_k, z), [[False, True, True, False]]) + + +class ReliefRiverOrderTest(unittest.TestCase): + def test_first_order_streams_hidden(self): + from types import SimpleNamespace + g = SimpleNamespace(n=3, lat=np.zeros(3), lon=np.array([-90.0, 0.0, 90.0])) + img = np.zeros((20, 40, 3), np.uint8) + out = RN.draw_rivers(img, g, np.array([1, 2, 2]), np.array([True, True, False]), np.array([1, 1, 0], np.int8)) + self.assertEqual(int(out.sum()), 0) + + +class RasterRangeTest(unittest.TestCase): + def test_continuous_rasters_hold_every_configurable_value(self): + from mapgen import render as RD + from mapgen.config import ZONE_FIELDS + top = lambda name: RD.CONTINUOUS[name][1] * 65535 + RD.CONTINUOUS[name][2] + g_lo = ZONE_FIELDS["gravity_g"][0] + need = {"pressure": ZONE_FIELDS["pressure_bar"][1], "o2_fraction": ZONE_FIELDS["o2_fraction"][1], + "fire": ZONE_FIELDS["fire_reactivity"][1], "gravity": ZONE_FIELDS["gravity_g"][1], + "po2": ZONE_FIELDS["o2_fraction"][1] * ZONE_FIELDS["pressure_bar"][1], "plant_height": 5.0 / g_lo} + for name, v in need.items(): + self.assertGreaterEqual(top(name), v, name) + + +class CurrentArrowsTest(unittest.TestCase): + def test_eastward_current_draws_horizontal_arrows_at_sea_only(self): + g = small_grid(2) + e, _ = east_north(g.xyz) + ocean = g.lat < 0 + base = np.zeros((128, 256, 3), np.uint8) + out = RN.draw_currents(base.copy(), g, 0.5 * e, ocean) + ys, xs = np.nonzero(out.any(axis=2)) + self.assertGreater(len(ys), 50) + self.assertTrue(np.all(ys >= 60)) # north half (land) untouched (row 64 = equator) + still = RN.draw_currents(base.copy(), g, np.zeros((g.n, 3)), ocean) + self.assertFalse(still.any()) # no current, no arrows + + def test_new_layers_registered(self): + for name in ("sst", "productivity", "current_speed", "upwelling"): + self.assertIn(name, RN.CONTINUOUS) + self.assertIn(name, RN.RAMPS) + ids = [v[0] for v in RN.VIEWER_LAYERS] + for vid in ("currents", "sst", "productivity"): + self.assertIn(vid, ids) + + +class ByRowsTest(unittest.TestCase): + """Rasters are coloured and encoded CHUNK rows at a time: the same bytes as in one piece.""" + def test_ramp_and_encode_same_as_whole(self): + from unittest import mock + import numpy as np + from mapgen import render as R + v = np.random.default_rng(3).normal(0, 2000, size=(300, 37)) + v[5, 5], v[7, 7] = -np.inf, np.inf + stops = [[0, 0, 0], [10, 200, 30], [255, 255, 255]] + with mock.patch.object(R, "CHUNK", 10**6): + want_r, want_e = R._ramp(v, -3000.0, 4000.0, stops), R.encode(v, 0.5, -12000.0) + with mock.patch.object(R, "CHUNK", 64): + got_r, got_e = R._ramp(v, -3000.0, 4000.0, stops), R.encode(v, 0.5, -12000.0) + self.assertEqual(got_r.shape, (300, 37, 3)) + self.assertEqual(got_r.dtype, np.uint8) + self.assertEqual(got_e.dtype, np.uint16) + self.assertTrue(np.array_equal(got_r, want_r)) + self.assertTrue(np.array_equal(got_e, want_e)) + # hand-derived: midpoint of a 0..1 ramp between 0 and 200 → 100 + self.assertEqual(R._ramp(np.full((200, 2), 0.5), 0.0, 1.0, [[0, 0, 0], [200, 200, 200]])[150, 1, 0], 100) + self.assertEqual(int(R.encode(np.full((200, 2), 3.0), 0.5, -12000.0)[199, 0]), 24006) + + +class CompiledRenderTest(unittest.TestCase): + def test_ramp_same_bytes_as_numpy_formula(self): + import numpy as np + from mapgen import render as R + rng = np.random.default_rng(7) + v = np.concatenate([rng.normal(0, 3000, 199_997), [np.inf, -np.inf, 0.0, -0.0, 4000.0, -3000.0, 500.0]]) + v = v.reshape(-1, 7) + for lo, hi, stops in ((-3000, 4000, [[0, 0, 0], [10, 200, 30], [255, 255, 255]]), + (-6500.0, 0.0, [[11, 43, 90], [30, 90, 150], [143, 198, 224]]), + (0.1, 0.35, [[1, 2, 3], [250, 9, 77], [3, 255, 100], [255, 255, 255]]), + (0, 1, [[0, 0, 0], [255, 255, 255]])): + st = np.asarray(stops, dtype=np.float64) + t = np.clip((v - lo) / (hi - lo), 0, 1) * (len(st) - 1) # the numpy statements, written out + i = np.minimum(t.astype(np.int64), len(st) - 2) + f = (t - i)[..., None] + want = (st[i] * (1 - f) + st[i + 1] * f).astype(np.uint8) + got = R._ramp(v, lo, hi, stops) + self.assertEqual(got.dtype, np.uint8) + self.assertTrue(np.array_equal(got, want), (lo, hi)) + + def test_sample_cont_same_as_numpy_sum(self): + import numpy as np + from mapgen import render as R + rng = np.random.default_rng(8) + v = rng.normal(size=5000) * 10.0 ** rng.integers(-6, 6, 5000) + for k in (1, 3, 5): + idx = rng.integers(0, 5000, (300, 170, k)).astype(np.int32) + w = rng.random((300, 170, k)).astype(np.float32) + want = np.sum(v[idx] * w, axis=-1) + self.assertTrue(np.array_equal(R.sample_cont(v, idx, w), want), k) + + def test_writer_finishes_everything_and_raises_errors(self): + import threading + from mapgen import render as R + done = [] + w = R._Writer(threads=2, depth=2) + for i in range(9): + w(lambda i=i: done.append(i)) + w.close() + self.assertEqual(sorted(done), list(range(9))) + w = R._Writer() + w(lambda: (_ for _ in ()).throw(OSError("disk full"))) + with self.assertRaises(OSError): + w.close() + with self.assertRaises(OSError): # surfaced at the latest by the next call over depth + w = R._Writer(threads=1, depth=1) + w(lambda: (_ for _ in ()).throw(OSError("disk full"))) + w(lambda: None) diff --git a/tests/test_seabed.py b/tests/test_seabed.py new file mode 100644 index 0000000..607aeec --- /dev/null +++ b/tests/test_seabed.py @@ -0,0 +1,94 @@ +import json +import unittest + +import numpy as np + +from mapgen import seabed as SB +from mapgen.graph import distance_to +from tests.helpers import make_ctx + + +def sea_ctx(tect=None): + ctx = make_ctx(3, tect=tect or {"plate": []}) + g = ctx.grid + land = (np.abs(g.lat) < 30) & (np.abs(g.lon) < 40) + ridge = np.abs(g.lon - 120) < 1.0 + d_div = distance_to(g, ridge) + age = np.where(land, 0.0, np.minimum(d_div / 30.0, 200.0)) + ctx.data.update({"elevation_eroded_m": np.where(land, 500.0, -4500.0).astype(np.float32), "ocean": ~land, + "continental": land, "ocean_age_myr": age.astype(np.float32), "d_div_km": d_div, + "d_over_km": np.full(g.n, np.inf), "d_sub_km": np.full(g.n, np.inf), + "T_mean": 25.0 - 0.5 * np.abs(g.lat), "vel": np.zeros((g.n, 3))}) + return ctx + + +class SeabedTest(unittest.TestCase): + def test_land_has_no_sea_floor(self): + ctx = sea_ctx() + out = SB.run(ctx) + land = ~ctx.data["ocean"] + for k in ("vent_potential", "seabed_type", "seabed_mineral", "bottom_temp_c", "sediment_m"): + self.assertTrue(np.all(np.asarray(out[k])[land] == 0), k) + + def test_vents_on_the_ridge_not_far_from_it(self): + ctx = sea_ctx() + out = SB.run(ctx) + d = ctx.data["d_div_km"] + self.assertGreaterEqual(out["vent_potential"][d == 0].min(), 0.5) + self.assertLess(out["vent_potential"][ctx.data["ocean"] & (d > 3000)].max(), 0.05) + + def test_sediment_thickens_with_age_away_from_land(self): + ctx = sea_ctx() + out = SB.run(ctx) + g = ctx.grid + young = ctx.data["ocean"] & (ctx.data["d_div_km"] < 300) & (np.abs(g.lon) > 90) + old = ctx.data["ocean"] & (ctx.data["ocean_age_myr"] > 100) & (np.abs(g.lon) > 90) + self.assertLess(out["sediment_m"][young].mean(), out["sediment_m"][old].mean()) + + def test_bottom_temperature(self): + ctx = sea_ctx() + g = ctx.grid + eq, polar = g.cell_index(0.0, 170.0), g.cell_index(-80.0, 170.0) + z = np.asarray(ctx.data["elevation_eroded_m"]).copy() + shallow = g.cell_index(-20.0, 170.0) + z[shallow] = -100.0 + ctx.data["elevation_eroded_m"] = z + out = SB.run(ctx) + self.assertAlmostEqual(out["bottom_temp_c"][eq], 1.0 + 3.0 * np.cos(np.radians(g.lat[eq])) ** 2, places=4) + self.assertLess(out["bottom_temp_c"][polar], 1.3) + deep = 1.0 + 3.0 * np.cos(np.radians(g.lat[shallow])) ** 2 + t = 25.0 - 0.5 * abs(g.lat[shallow]) + self.assertAlmostEqual(out["bottom_temp_c"][shallow], deep + 0.875 * (t - deep), places=3) + + def test_plateau_volcanic_field_makes_volcanic_sea_floor(self): + plat = {"name": "p", "center": [0.0, -150.0], "area_km2": 3.0e6, "top_m": [1500.0, 3000.0]} + ctx = sea_ctx({"plate": [], "plateau": [plat]}) + out = SB.run(ctx) + from mapgen.crust import center_dist + near = center_dist(ctx.grid, [0.0, -150.0]) < 800.0 + self.assertTrue(np.isin(out["seabed_type"][near], [SB.SB_VOLCANIC, SB.SB_VENTS]).any()) + self.assertGreater(out["vent_potential"][near].max(), 0.3) + + def test_deterministic(self): + a, b = SB.run(sea_ctx()), SB.run(sea_ctx()) + for k in a: + np.testing.assert_array_equal(a[k], b[k]) + + +class SeabedRenderTest(unittest.TestCase): + def test_rasters_legends_and_plateaus_in_the_build(self): + from mapgen.testing import built_world + out = built_world() / "out" / "r2" + fields = json.loads((out / "fields.json").read_text()) + for name in ("pressure", "o2_fraction", "fire", "vent_potential", "bottom_temp", "sediment", + "plant_height"): + self.assertIn(name, fields["continuous"]) + self.assertTrue((out / "raster" / f"{name}.png").exists(), name) + for name in ("seabed_type", "seabed_mineral"): + self.assertIn(name, fields["categorical"]) + meta = json.loads((out / "cells_meta.json").read_text()) + self.assertEqual(meta["legends"]["seabed_type"], SB.SEABED_NAMES) + self.assertEqual(meta["plateaus"], []) + with np.load(out / "cells.npz") as z: + for k in ("vent_potential", "seabed_type", "pressure_bar", "fire_reactivity", "plateau_id"): + self.assertIn(k, z.files) diff --git a/tests/test_sketch.py b/tests/test_sketch.py new file mode 100644 index 0000000..f827943 --- /dev/null +++ b/tests/test_sketch.py @@ -0,0 +1,151 @@ +import shutil +import tempfile +import unittest +from pathlib import Path + +import numpy as np +from PIL import Image + +from mapgen import sketch as SK +from mapgen.pipeline import StageError +from tests.helpers import make_ctx + + +class SampleTest(unittest.TestCase): + def test_bilinear_centres_and_wrap(self): + img = np.arange(32, dtype=np.float64).reshape(4, 8) + # pixel (1, 2) centre: lon = 2.5/8*360-180 = -67.5, lat = 90-1.5/4*180 = 22.5 + self.assertAlmostEqual(SK.sample_equirect(img, np.array([22.5]), np.array([-67.5]))[0], img[1, 2]) + a = SK.sample_equirect(img, np.array([22.5]), np.array([179.999]))[0] + b = SK.sample_equirect(img, np.array([22.5]), np.array([-179.999]))[0] + self.assertAlmostEqual(a, b, places=2) # wraps across the antimeridian + self.assertAlmostEqual(SK.sample_equirect(img, np.array([90.0]), np.array([-157.5]))[0], img[0, 0]) + + def test_mask_8bit_and_any_size(self): + tmp = Path(tempfile.mkdtemp()) + try: + Image.fromarray(np.full((19, 37), 255, np.uint8), "L").save(tmp / "a.png") + Image.fromarray(np.full((10, 20), 32768, np.uint16)).save(tmp / "b.png") + self.assertGreater(SK.load_mask(tmp / "a.png").min(), 0.99) + self.assertAlmostEqual(float(np.abs(SK.load_mask(tmp / "b.png")).max()), 0.0) + finally: + shutil.rmtree(tmp) + + +class StageTest(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + + def tearDown(self): + shutil.rmtree(self.tmp) + + def test_requires_import(self): + ctx = make_ctx(1, root=self.tmp) + with self.assertRaisesRegex(StageError, "new-world"): + SK.run(ctx) + + def test_outputs(self): + (self.tmp / "sketch").mkdir() + land = np.zeros((100, 200), np.uint8) + land[:, :100] = 255 # western hemisphere is land + for n in SK.SKETCH: + Image.fromarray(land if n == "land" else np.zeros_like(land), "L").save(self.tmp / "sketch" / f"{n}.png") + ctx = make_ctx(2, root=self.tmp) + out = SK.run(ctx) + g = ctx.grid + self.assertGreater(out["sk_land"][g.lon < -10].mean(), 0.95) + self.assertLess(out["sk_land"][g.lon > 10].mean(), 0.05) + for m in SK.MASKS: + self.assertTrue(np.all(out[f"m_{m}"] == 0)) + + +class MaskFormatsTest(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + + def tearDown(self): + shutil.rmtree(self.tmp) + + def test_gimp_formats(self): + Image.fromarray(np.full((8, 16), 128, np.uint8), "L").save(self.tmp / "grey.png") + pal = Image.new("P", (16, 8), 0) + pal.putpalette([128, 128, 128, 255, 255, 255] + [0] * 762) + pal.save(self.tmp / "pal_grey.png") + pal.paste(1, (0, 0, 16, 8)) + pal.save(self.tmp / "pal_white.png") + Image.new("1", (16, 8), 1).save(self.tmp / "bit_white.png") + Image.new("RGBA", (16, 8), (0, 0, 0, 0)).save(self.tmp / "clear.png") + self.assertEqual(float(np.abs(SK.load_mask(self.tmp / "grey.png")).max()), 0.0) + self.assertEqual(float(np.abs(SK.load_mask(self.tmp / "pal_grey.png")).max()), 0.0) + self.assertAlmostEqual(float(SK.load_mask(self.tmp / "pal_white.png").min()), 1.0) + self.assertAlmostEqual(float(SK.load_mask(self.tmp / "bit_white.png").min()), 1.0) + self.assertEqual(float(np.abs(SK.load_mask(self.tmp / "clear.png")).max()), 0.0) + + def test_corrupt_mask_is_stage_error(self): + (self.tmp / "bad.png").write_bytes(b"not a png") + with self.assertRaisesRegex(StageError, "bad.png"): + SK.load_mask(self.tmp / "bad.png") + + +class SketchWarpTest(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + (self.tmp / "sketch").mkdir() + lat = 90 - (np.arange(100) + 0.5) * 1.8 + lon = (np.arange(200) + 0.5) * 1.8 - 180 + LA, LO = np.meshgrid(lat, lon, indexing="ij") + land = ((np.abs(LA) < 35) & (np.abs(LO) < 60)).astype(np.uint8) * 255 + for n in SK.SKETCH: + Image.fromarray(land if n == "land" else np.zeros_like(land), "L").save(self.tmp / "sketch" / f"{n}.png") + + def tearDown(self): + shutil.rmtree(self.tmp) + + def _land(self, **over): + ctx = make_ctx(3, root=self.tmp, cfg={"sketch": over} if over else None) + return SK.run(ctx)["sk_land"] > 0.5, ctx.grid + + def test_warp_off_matches_drawing(self): + warped, g = self._land(warp_km=0.0, detail_warp_km=0.0) + direct = SK.sample_equirect(SK.load_png01(self.tmp / "sketch" / "land.png"), g.lat, g.lon) > 0.5 + np.testing.assert_array_equal(warped, direct) + + def test_default_warp_deviates_but_keeps_continent(self): + straight, g = self._land(warp_km=0.0, detail_warp_km=0.0) + warped, _ = self._land() + iou = (warped & straight).sum() / (warped | straight).sum() + self.assertTrue(0.35 < iou < 0.85, iou) + again, _ = self._land() + np.testing.assert_array_equal(warped, again) + + +class SketchMoveTest(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + (self.tmp / "sketch").mkdir() + lat = 90 - (np.arange(200) + 0.5) * 0.9 + lon = (np.arange(400) + 0.5) * 0.9 - 180 + LA, LO = np.meshgrid(lat, lon, indexing="ij") + land = (((LA - 0) ** 2 + (LO - 0) ** 2 < 15 ** 2) | ((LA + 40) ** 2 + (LO - 100) ** 2 < 10 ** 2)) + for n in SK.SKETCH: + Image.fromarray((land * 255).astype(np.uint8) if n == "land" else np.zeros(land.shape, np.uint8), "L") \ + .save(self.tmp / "sketch" / f"{n}.png") + + def tearDown(self): + shutil.rmtree(self.tmp) + + def _land(self, moves): + ctx = make_ctx(4, root=self.tmp, cfg={"sketch": {"warp_km": 0.0, "detail_warp_km": 0.0, "moves": moves}}) + return SK.run(ctx)["sk_land"] > 0.5, ctx.grid + + def test_move_rotates_one_continent_and_keeps_its_area(self): + before, g = self._land([]) + after, _ = self._land([{"at": [0.0, 0.0], "to": [30.0, 0.0]}]) + near = lambda la, lo, r: g.radius_km * np.arccos(np.clip(g.xyz @ g.xyz[g.cell_index(la, lo)], -1, 1)) < r + self.assertTrue(before[near(0.0, 0.0, 800)].all() and not after[near(0.0, 0.0, 800)].any()) + self.assertTrue(after[near(30.0, 0.0, 800)].all()) + other = near(-40.0, 100.0, 600) + np.testing.assert_array_equal(before[other], after[other]) # the other island stays + a = g.area_km2 + moved_area = a[after & ~other].sum() / a[before & ~other].sum() + self.assertAlmostEqual(moved_area, 1.0, delta=0.05) # rotation preserves area diff --git a/tests/test_sphere_noise.py b/tests/test_sphere_noise.py new file mode 100644 index 0000000..a3c325e --- /dev/null +++ b/tests/test_sphere_noise.py @@ -0,0 +1,86 @@ +import unittest + +import numpy as np + +from mapgen import noise as N +from mapgen import sphere as S + + +class SphereTest(unittest.TestCase): + def test_roundtrip(self): + lat = np.array([0.0, 45.0, -60.0, 89.0]) + lon = np.array([0.0, 120.0, -170.0, 10.0]) + la, lo = S.xyz_to_latlon(S.latlon_to_xyz(lat, lon)) + np.testing.assert_allclose(la, lat, atol=1e-9) + np.testing.assert_allclose(lo, lon, atol=1e-9) + + def test_east_north_at_pole(self): + p = S.latlon_to_xyz(np.array([90.0, -90.0, 0.0]), np.array([0.0, 0.0, 0.0])) + e, n = S.east_north(p) + self.assertTrue(np.all(np.isfinite(e)) and np.all(np.isfinite(n))) + np.testing.assert_allclose(np.linalg.norm(e, axis=1), 1.0) + np.testing.assert_allclose(np.sum(e * p, axis=1), 0.0, atol=1e-12) + # at the equator/prime meridian east = +y, north = +z + np.testing.assert_allclose(e[2], [0, 1, 0], atol=1e-12) + np.testing.assert_allclose(n[2], [0, 0, 1], atol=1e-12) + + def test_motion_velocity_matches_request(self): + R = 12742.0 + om = S.motion_to_omega(10.0, 20.0, 90.0, 5.0, R) # due east, 5 cm/yr + p = S.latlon_to_xyz(np.array([10.0]), np.array([20.0])) + v = S.velocity(p, om, R)[0] + e, n = S.east_north(p) + self.assertAlmostEqual(float(np.dot(v, e[0])), 0.05, places=6) + self.assertAlmostEqual(float(np.dot(v, n[0])), 0.0, places=6) + + def test_zero_speed_is_zero_velocity(self): + om = S.motion_to_omega(0.0, 0.0, 45.0, 0.0, 12742.0) + self.assertTrue(np.allclose(om, 0.0)) + + def test_rotate_and_azimuth(self): + p = S.latlon_to_xyz(np.array([0.0]), np.array([0.0])) + e, n = S.east_north(p) + r = S.rotate_about(p, e, np.pi / 2) # CCW seen from outside: east -> north + np.testing.assert_allclose(r, n, atol=1e-12) + q = S.latlon_to_xyz(np.array([0.0, 10.0]), np.array([10.0, 0.0])) + np.testing.assert_allclose(S.azimuth_deg(p[0], q), [90.0, 0.0], atol=1e-9) + + def test_great_circle_point(self): + p0 = S.latlon_to_xyz(np.array([0.0]), np.array([0.0]))[0] + e, _ = S.east_north(p0[None]) + R = 12742.0 + q = S.great_circle_point(p0, e[0], np.pi * R / 2, R) # quarter turn east + np.testing.assert_allclose(q, [0, 1, 0], atol=1e-12) + + +class NoiseTest(unittest.TestCase): + def test_deterministic_and_bounded(self): + rng = np.random.default_rng(0) + p = rng.normal(size=(5000, 3)) + p /= np.linalg.norm(p, axis=1, keepdims=True) + a = N.fbm(p, 42) + b = N.fbm(p, 42) + c = N.fbm(p, 43) + np.testing.assert_array_equal(a, b) + self.assertFalse(np.allclose(a, c)) + self.assertTrue(np.all(np.abs(a) <= 1.0)) + self.assertGreater(a.std(), 0.05) + r = N.ridged(p, 1) + self.assertTrue(np.all((r >= 0) & (r <= 1))) + + def test_continuity(self): + p = np.array([[0.3, 0.4, 0.5]]) + d = N.fbm(p + 1e-6, 5) - N.fbm(p, 5) + self.assertLess(abs(d[0]), 1e-3) + + +class CompiledNoiseTest(unittest.TestCase): + def test_compiled_value_noise_matches_numpy(self): + from mapgen import noise as N + if N._noise_jit is None: + self.skipTest("numba not installed") + rng = np.random.default_rng(0) + for scale in (1.0, 37.0, 1e4, 1e7): + for seed in (0, 4242, 123456789, -5, 2 ** 40): + p = rng.normal(0, scale, (2000, 3)) + self.assertTrue(np.array_equal(N.value_noise(p, seed), N._value_noise(p, seed)), (scale, seed)) diff --git a/tests/test_viewer_export.py b/tests/test_viewer_export.py new file mode 100644 index 0000000..b34f2f9 --- /dev/null +++ b/tests/test_viewer_export.py @@ -0,0 +1,33 @@ +import json +import shutil +import tempfile +import unittest +from pathlib import Path + +import numpy as np +from PIL import Image + +from mapgen import viewer_export as VE + + +class ViewerExportTest(unittest.TestCase): + def test_write_all(self): + tmp = Path(tempfile.mkdtemp()) + try: + rgb = np.zeros((20, 40, 3), np.uint8) + rgb[:, :20] = (200, 30, 30) + VE.write_all(tmp / "viewer", [ + {"id": "a", "name": "A", "rgb": rgb, "legend": None}, + {"id": "b", "name": "B", "rgb": rgb, + "legend": VE.categorical_legend(["x", "y"], np.array([[255, 0, 0], [0, 0, 255]]))}, + {"id": "c", "name": "C", "rgb": rgb, + "legend": VE.continuous_legend("m", -10.0, 10.0, [[0, 0, 0], [128, 128, 128], [255, 255, 255]])}, + ]) + idx = json.loads((tmp / "viewer" / "layers.json").read_text()) + self.assertEqual([L["id"] for L in idx], ["a", "b", "c"]) + self.assertEqual(idx[1]["legend"]["items"][1], {"name": "y", "color": "#0000ff"}) + self.assertEqual(idx[2]["legend"]["stops"], [[-10.0, "#000000"], [0.0, "#808080"], [10.0, "#ffffff"]]) + im = Image.open(tmp / "viewer" / "a.jpg") + self.assertEqual((im.size, im.format), ((40, 20), "JPEG")) + finally: + shutil.rmtree(tmp) diff --git a/tests/test_zones.py b/tests/test_zones.py new file mode 100644 index 0000000..f5b73c1 --- /dev/null +++ b/tests/test_zones.py @@ -0,0 +1,66 @@ +# map/tests/test_zones.py +import tempfile +import unittest +from pathlib import Path + +import numpy as np +from PIL import Image + +from mapgen import sketch as SK, zones as ZN +from mapgen.sphere import east_north, great_circle_point, latlon_to_xyz +from tests.helpers import make_ctx + +R = 12742.0 +R1 = {"name": "R1", "field": "o2", "center": [-5.6, -118.8], "radius_km": 3200.0, "v": 1.0} +L1 = {"name": "L1", "field": "gravity", "center": [-26.3, -133.7], "radius_km": 2200.0, "v": -0.6} + + +def ray(zone, az_deg, step_km=5.0): + p0 = latlon_to_xyz(*zone["center"]) + e, n = east_north(p0[None]) + a = np.radians(az_deg) + t = np.sin(a) * e[0] + np.cos(a) * n[0] + s = np.arange(0.0, 1.4 * zone["radius_km"], step_km) + return s, np.array([great_circle_point(p0, t, si, R) for si in s]) + + +class ZoneProfileTest(unittest.TestCase): + def test_peak_at_the_centre_and_nothing_beyond_the_warped_radius(self): + for z in (R1, L1): + s, pts = ray(z, 40.0) + f = ZN.contribution(pts, z, 1296, R) + self.assertAlmostEqual(f[0], z["v"]) + self.assertTrue(np.all(f[s > z["radius_km"] / (1 - ZN.WARP) + 1] == 0.0)) + + def test_steepest_change_is_hard_to_notice_over_a_days_walk(self): + """≤ 0.23 O₂ points and ≤ 0.014 g per 30 km, for the strongest O₂ and low-gravity zones.""" + for z, per_unit, limit in ((R1, 0.21 * 0.5 * 100.0, 0.23), (L1, 1.05 * 0.7, 0.014)): + worst = 0.0 + for az in range(0, 360, 5): + _, pts = ray(z, az) + f = ZN.contribution(pts, z, 1296, R) * per_unit + worst = max(worst, float(np.max(np.abs(np.diff(f)))) / 5.0 * 30.0) + self.assertLessEqual(worst, limit, z["name"]) + + def test_outline_is_not_a_circle(self): + at = [ZN.contribution(ray(R1, az)[1][[400]], R1, 1296, R)[0] for az in range(0, 360, 30)] # 2,000 km out + self.assertGreater(max(at) - min(at), 0.02) + + +class SketchZonesTest(unittest.TestCase): + def test_config_zones_add_to_painted_masks_and_clip(self): + root = Path(tempfile.mkdtemp()) + (root / "sketch").mkdir() + (root / "masks").mkdir() + for n in SK.SKETCH: + Image.fromarray(np.zeros((10, 20), np.uint8), "L").save(root / "sketch" / f"{n}.png") + Image.fromarray(np.full((10, 20), 191, np.uint8), "L").save(root / "masks" / "o2_zones.png") # ≈ +0.5 + zones = [{**R1, "center": [0.0, 0.0]}, {**L1, "center": [0.0, 0.0]}] + ctx = make_ctx(2, tect={"plate": [], "zone": zones}, root=root) + out = SK.run(ctx) + g = ctx.grid + c, far = g.cell_index(0.0, 0.0), g.cell_index(0.0, 180.0) + self.assertEqual(out["m_o2_zones"][c], 1.0, "mask + zone, clipped") + self.assertAlmostEqual(out["m_o2_zones"][far], 0.498, places=2) + self.assertAlmostEqual(out["m_gravity_zones"][c], -0.6, delta=0.05) + self.assertEqual(out["m_gravity_zones"][far], 0.0) |
