#!/usr/bin/env python3
"""
HOW BIG DOES A TEST WORLD HAVE TO BE TO CONTAIN N BIOMES?
A biome here is not a thing the model knows about. Every point carries four
climate numbers (temp, temp seasonality, precip, precip variability) plus
elevation, and a "biome" is a RULE over those. So the count is ours to choose;
what is NOT ours to choose is how far you must travel before those numbers have
visited enough of their range to satisfy the rules.
That is what this measures, on the COARSE stage — 1 px = 7.68 km and ~700x
cheaper per km2 than the detail stage, so a continent-sized survey is seconds.
THE RULE SET IS DELIBERATELY PLAIN: a 3x3 Whittaker grid on temperature x
precipitation, plus two elevation overrides (alpine, ocean). Eleven classes.
Nothing here argues that these are the right biomes — they exist to make the
question "how much world per biome" answerable with a number.
⚠ IT REPORTS COVERAGE, NOT JUST COUNT. A biome occupying 0.1% of the map is
present and useless to test with. The table gives the count at a >=1% threshold
alongside the raw count, because "11 biomes" where nine are slivers is not a
test world.
python survey_biomes.py --url http://127.0.0.1:8830 --n 128
"""
import argparse, urllib.request
import numpy as np
from server import coarse_to_metres
COARSE_KM = 7.68
def fetch_coarse(url, i1, j1, n):
q = f"{url}/coarse?i1={int(i1)}&j1={int(j1)}&i2={int(i1)+int(n)}&j2={int(j1)+int(n)}"
with urllib.request.urlopen(q, timeout=1800) as r:
h = int(r.headers["X-Height"]); w = int(r.headers["X-Width"])
c = int(r.headers["X-Channels"]); b = r.read()
return np.frombuffer(b, dtype="<f4").reshape(h, w, c).astype(np.float64)
def classify(elev_m, temp, precip):
"""3x3 Whittaker grid + alpine + ocean. Thresholds are QUANTILES of this
survey, so the classes exist by construction and the question stays 'how
much world', not 'did I guess the thresholds'."""
t1, t2 = np.percentile(temp, [33, 67])
p1, p2 = np.percentile(precip, [33, 67])
out = np.zeros(elev_m.shape, dtype=np.int32)
ti = np.digitize(temp, [t1, t2])
pi = np.digitize(precip, [p1, p2])
out = 1 + ti * 3 + pi # 1..9
out[elev_m > 2200] = 10 # alpine, above the trees
out[elev_m <= 0] = 0 # ocean
return out
NAMES = {0: "ocean", 10: "alpine",
1: "cold-dry", 2: "cold-mid", 3: "cold-wet",
4: "temp-dry", 5: "temp-mid", 6: "temp-wet",
7: "warm-dry", 8: "warm-mid", 9: "warm-wet"}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--url", default="http://127.0.0.1:8830")
ap.add_argument("--n", type=int, default=128, help="coarse px per side")
ap.add_argument("--i1", type=int, default=-64)
ap.add_argument("--j1", type=int, default=-64)
a = ap.parse_args()
arr = fetch_coarse(a.url, a.i1, a.j1, a.n)
side_km = a.n * COARSE_KM
print(f"survey {a.n}x{a.n} coarse px = {side_km:.0f} x {side_km:.0f} km "
f"= {side_km*side_km/1e6:.2f} M km2, {arr.shape[2]} channels\n")
# channel 0 is signed-square elevation (#50: R2 0.9952 vs 0.9126 linear)
# server.coarse_to_metres, NOT a reinvention of it. My first pass guessed
# sign(e)*e^2*1000 and produced elevations of +/-6 million metres, which
# then classified 38.5% of the world as alpine. The decoder already existed
# eight lines from the fetch helper this file imports.
elev_m = coarse_to_metres(arr[..., 0])
# CHANNELS, from world_pipeline.py:1064 — the synthetic map's five
# conditioning channels are normalised with MODEL_MEANS[[0,2,3,4,5]],
# SKIPPING index 1. So the six coarse channels are
# 0 elevation 1 ch0-raw = local relief 2 temperature
# 3 temp std 4 precipitation 5 precip variability
# My first pass took ch1 as temperature and ch3 as precipitation, which is
# relief and temperature-seasonality — both wrong, and ch1 correlates 0.98
# with elevation, which is what made biomes look altitude-driven.
temp, precip = arr[..., 2], arr[..., 4]
for i, nm in enumerate(["elev", "relief", "temp", "temp_std", "precip", "precip_var"][:arr.shape[2]]):
c = arr[..., i]
print(f" ch{i} {nm:<10} {c.min():10.3f} .. {c.max():10.3f}")
cls = classify(elev_m, temp, precip)
print(f"\n elevation {elev_m.min():.0f} .. {elev_m.max():.0f} m")
print(f"\n{'window':>10} {'area':>12} {'biomes':>8} {'>=1% of area':>14}")
for win_px in [2, 4, 8, 16, 32, 64, a.n]:
if win_px > a.n: continue
# take the CENTRE window, the honest sample for "a world this size"
c0 = (a.n - win_px) // 2
sub = cls[c0:c0+win_px, c0:c0+win_px]
vals, counts = np.unique(sub, return_counts=True)
frac = counts / sub.size
km = win_px * COARSE_KM
print(f"{win_px:>6} px {km:>6.0f} km sq {len(vals):>8} {int((frac >= 0.01).sum()):>14}")
vals, counts = np.unique(cls, return_counts=True)
print(f"\n full survey composition:")
for v, c in sorted(zip(vals, counts), key=lambda x: -x[1]):
print(f" {NAMES.get(int(v), v):<10} {100.0*c/cls.size:5.1f} %")
if __name__ == "__main__":
main()