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
|
"""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}
|