worldgen

git clone https://git.godosa.eu/worldgen

master

raw · 11744 bytes

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)