raw · 12160 bytes
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 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 |