aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/tests/test_export.py
blob: f5e3cb657c7cf5d97091ad4f680556997f0ec4cd (plain)
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
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
import json
import math
import tempfile
import unittest
from pathlib import Path

import numpy as np
from PIL import Image

import export as EX
import serve
import tiles as T
from mapgen import config as C
from tests.test_serve import built_world


def _spot():
    """A river mouth on the test world: land, sea and a river in one small square."""
    a = np.load(built_world() / "out" / "r2" / "cells.npz")
    mouths = np.where(a["river"].astype(bool) & a["ocean"][a["recv"]].astype(bool))[0]
    i = int(mouths[np.argmax(a["discharge_km3_yr"][mouths])])
    t = np.linspace(0, 1, 101)[:, None]                          # from the mouth toward its sea cell …
    p = a["g_xyz"][i] * (1 - t) + a["g_xyz"][a["recv"][i]] * t
    p /= np.linalg.norm(p, axis=1, keepdims=True)
    lat, lon = np.degrees(np.arcsin(p[:, 2])), np.degrees(np.arctan2(p[:, 1], p[:, 0]))
    src = T.TileSource(serve.World(built_world(), 2), seed=int(C.load(built_world())[0]["build"]["seed"]),
                       cache_dir=Path(tempfile.mkdtemp()), regions_dir=Path(tempfile.mkdtemp()))
    wet = src.surface_at(lat, lon, 1.0)[1]
    k = int(np.argmax(wet)) if wet.any() else 50               # … to where the map shows the sea: the coast
    return float(lat[k]), float(lon[k])


class ExportTest(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.root = built_world()
        cls.lat, cls.lon = _spot()
        cls.pins = Path(tempfile.mkdtemp()) / "pins.json"
        cls.pins.write_text(json.dumps({"pins": [{"id": "pin-1", "name": "Here", "lat": cls.lat, "lon": cls.lon,
                                                  "lore": "somewhere", "note": "", "epoch": None},
                                                 {"id": "pin-2", "name": "Far", "lat": -cls.lat, "lon": cls.lon + 90}]}))
        cls.out = Path(tempfile.mkdtemp())
        cls.d = EX.export(cls.root, 2, cls.lat, cls.lon, size_km=200, res_m=2000, out_dir=cls.out, name="t",
                          pins_path=cls.pins, regions_dir=Path(tempfile.mkdtemp()))
        cls.meta = json.loads((cls.d / "meta.json").read_text())
        cls.n = cls.meta["samples"]
        cls.h = np.fromfile(cls.d / "height.f32", dtype="<f4").reshape(cls.n, cls.n)

    def test_files_and_grid(self):
        self.assertEqual(self.d, self.out / "t")
        for f in ("height.f32", "height.png", "height.r16", "water.png", "biome.png", "ground.png", "landform.png",
                  "preview.png", "rivers.json", "pins.json", "legend.json", "meta.json", "README.txt"):
            self.assertTrue((self.d / f).exists(), f)
        self.assertEqual(self.n, 129, "2^n + 1 samples, the smallest covering 200 km at 2 km")
        self.assertEqual(self.meta["res_m"], 2000)
        self.assertEqual(self.meta["size_m"], 128 * 2000)
        for f in ("water.png", "biome.png", "preview.png"):
            self.assertEqual(Image.open(self.d / f).size, (self.n, self.n), f)
        self.assertEqual(self.meta["sea_level_m"], 0)
        self.assertIn("world_build", self.meta["sources"])

    def test_samples_are_res_apart_on_the_ground(self):
        lat, lon = EX.grid_latlon(self.lat, self.lon, self.n, 2000.0, 12742.0)
        def gc(i1, j1, i2, j2):
            p1, p2 = np.radians([lat[i1, j1], lon[i1, j1]]), np.radians([lat[i2, j2], lon[i2, j2]])
            return 12742e3 * 2 * math.asin(math.sqrt(math.sin((p2[0] - p1[0]) / 2) ** 2 + math.cos(p1[0]) * math.cos(p2[0])
                                                      * math.sin((p2[1] - p1[1]) / 2) ** 2))
        c = self.n // 2
        for a, b in (((c, c), (c, c + 1)), ((0, 0), (0, 1)), ((0, 0), (1, 0)), ((self.n - 1, 5), (self.n - 2, 5))):
            self.assertAlmostEqual(gc(*a, *b), 2000.0, delta=4.0)
        self.assertAlmostEqual(lat[c, c], self.lat, places=9)
        self.assertGreater(lat[0, c], lat[-1, c], "row 0 is the north edge")

    def test_heights_are_the_viewers_terrain(self):
        src = T.TileSource(serve.World(self.root, 2), seed=int(C.load(self.root)[0]["build"]["seed"]),
                           cache_dir=Path(tempfile.mkdtemp()), regions_dir=Path(tempfile.mkdtemp()))
        lat, lon = EX.grid_latlon(self.lat, self.lon, self.n, 2000.0, 12742.0)
        k = np.s_[::8, ::8]
        ref = src.z_at(lat[k].ravel(), lon[k].ravel(), self.meta["tile_px_m"] / 1000.0).reshape(lat[k].shape)
        self.assertGreater(np.corrcoef(ref.ravel(), self.h[k].ravel())[0, 1], 0.99)
        self.assertLess(np.median(np.abs(ref - self.h[k])), 0.1 * ref.std() + 1.0)

    def test_16_bit_heights_decode_to_the_float_heights(self):
        lo, hi = self.meta["height"]["min"], self.meta["height"]["max"]
        v = np.asarray(Image.open(self.d / "height.png"), dtype=np.float64)
        raw = np.fromfile(self.d / "height.r16", dtype="<u2").reshape(self.n, self.n)
        np.testing.assert_array_equal(v, raw)
        np.testing.assert_allclose(lo + v / 65535 * (hi - lo), self.h, atol=(hi - lo) / 65535 + 1e-3)

    def test_water_classes(self):
        w = np.asarray(Image.open(self.d / "water.png"))
        self.assertTrue(set(np.unique(w)) <= {0, 1, 2, 3})
        self.assertTrue((w == 1).any() and (w == 0).any(), "the square holds sea and land")
        self.assertLess(np.median(self.h[w == 1]), 0.0, "the sea lies below sea level")
        self.assertEqual(json.loads((self.d / "legend.json").read_text())["water"], ["land", "sea", "lake", "river"])

    def test_rivers_and_pins_in_local_metres(self):
        rivers = json.loads((self.d / "rivers.json").read_text())
        self.assertTrue(rivers, "the river mouth's river")
        half = self.meta["size_m"] / 2
        for r in rivers:
            self.assertGreater(r["width_m"], 0)
            p = np.asarray(r["points"])
            self.assertTrue(np.all(np.abs(p) <= half + 1e-6))
        pins = json.loads((self.d / "pins.json").read_text())
        self.assertEqual([p["name"] for p in pins], ["Here"])
        self.assertLess(abs(pins[0]["x"]) + abs(pins[0]["y"]), 1.0)
        self.assertEqual(pins[0]["lore"], "somewhere")

    def test_preview_shades_the_relief_so_gentle_land_still_shows(self):
        pv = self.meta["preview"]
        self.assertGreaterEqual(pv["exaggeration"], 1.0)
        rgb = np.asarray(Image.open(self.d / "preview.png"), dtype=np.float64)
        w = np.asarray(Image.open(self.d / "water.png"))
        lum = rgb.mean(axis=2)[w == 0]
        self.assertGreater(lum.std(), 2.0, "not a flat colour field")

    def test_deterministic_in_parallel_too(self):
        d2 = EX.export(self.root, 2, self.lat, self.lon, size_km=200, res_m=2000, out_dir=self.out, name="t2",
                       pins_path=self.pins, regions_dir=Path(tempfile.mkdtemp()), workers=2)
        for f in ("height.f32", "water.png", "biome.png", "preview.png", "rivers.json", "pins.json"):
            self.assertEqual((self.d / f).read_bytes(), (d2 / f).read_bytes(), f)

    def test_exports_into_a_new_folder_leave_no_work_files(self):
        out = Path(tempfile.mkdtemp()) / "new" / "exports"
        d = EX.export(self.root, 2, self.lat, self.lon, size_km=50, res_m=2000, out_dir=out, name="n",
                      regions_dir=Path(tempfile.mkdtemp()))
        self.assertEqual([p.name for p in out.iterdir()], ["n"])
        self.assertTrue((d / "meta.json").exists())

    def test_a_region_change_during_an_export_stops_it_cleanly(self):
        import refine as RF
        import shutil
        from tests.test_refine import square
        regions = Path(tempfile.mkdtemp())
        reg = regions / "r.json"
        reg.write_text(json.dumps({"regions": [square(self.lat, self.lon, 3)]}))
        built = Path(tempfile.mkdtemp())
        RF.build_areas(self.root, 2, reg, log=lambda s: None, regions_root=built)
        src = T.TileSource(serve.World(self.root, 2), seed=int(C.load(self.root)[0]["build"]["seed"]),
                           cache_dir=Path(tempfile.mkdtemp()), regions_dir=regions / "live")
        real, calls = src.export_tile, []

        def tile(*a):
            calls.append(a)
            if len(calls) == 2:                                  # a region build finishes mid-export
                shutil.copytree(built, regions / "live")
                src.reload_regions()
            return real(*a)
        src.export_tile = tile
        out = Path(tempfile.mkdtemp())
        with self.assertRaises(RuntimeError):
            EX.export(self.root, 2, self.lat, self.lon, size_km=200, res_m=2000, out_dir=out, name="mixed", src=src)
        self.assertEqual(list(out.iterdir()), [], "nothing half-mixed written")

    def test_an_existing_export_is_not_overwritten(self):
        with self.assertRaises(FileExistsError):
            EX.export(self.root, 2, self.lat, self.lon, size_km=200, res_m=2000, out_dir=self.out, name="t",
                      pins_path=self.pins, regions_dir=Path(tempfile.mkdtemp()))


class CliTest(unittest.TestCase):
    def test_export_command(self):
        import importlib.util
        spec = importlib.util.spec_from_file_location("mapview_cli", Path(EX.__file__).parent / "mapview.py")
        cli = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(cli)
        lat, lon = _spot()
        out = Path(tempfile.mkdtemp())
        code = cli.main(["export", "--res", "2", "--lat", str(lat), "--lon", str(lon), "--size-km", "100",
                            "--res-m", "2000", "--name", "cli", "--out", str(out), "--workers", "0"], root=built_world())
        self.assertEqual(code, 0)
        self.assertEqual(json.loads((out / "cli" / "meta.json").read_text())["samples"], 65)

    def test_exact_grid_512_m_at_1_m(self):
        import importlib.util
        spec = importlib.util.spec_from_file_location("mapview_cli", Path(EX.__file__).parent / "mapview.py")
        cli = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(cli)
        lat, lon = _spot()
        out = Path(tempfile.mkdtemp())
        self.assertEqual(cli.main(["export", "--res", "2", "--lat", str(lat), "--lon", str(lon), "--size-km", "0.512", "--res-m", "1", "--pixels", "513",
                                      "--name", "c", "--out", str(out), "--workers", "0"], root=built_world()), 0)
        meta = json.loads((out / "c" / "meta.json").read_text())
        self.assertEqual((meta["samples"], meta["res_m"], meta["size_m"]), (513, 1, 512))
        h = np.fromfile(out / "c" / "height.f32", dtype="<f4").reshape(513, 513)
        self.assertAlmostEqual(meta["center_height_m"], float(h[256, 256]), places=3)

class ExportForkTest(unittest.TestCase):
    def test_region_trees_are_built_before_export_workers_fork(self):
        import refine
        from unittest import mock
        order = []

        class FakePool:                                      # stands in for the forked pool: records when it forks
            def __init__(self, n):
                order.append("fork")

            def __enter__(self):
                return self

            def __exit__(self, *a):
                return False

            def imap(self, f, jobs, chunksize=1):
                return map(f, jobs)
        warm = refine.RegionSet.warm
        a = np.load(built_world() / "out" / "r2" / "cells.npz")
        i = int(np.argmax(a["z_surface_m"]))
        with mock.patch.object(refine.RegionSet, "warm", autospec=True,
                               side_effect=lambda rs: (order.append("warm"), warm(rs))[1]), \
             mock.patch.object(EX.multiprocessing, "get_context", return_value=mock.Mock(Pool=FakePool)):
            EX.export(built_world(), 2, float(a["g_lat"][i]), float(a["g_lon"][i]), size_km=50, res_m=2000,
                      out_dir=Path(tempfile.mkdtemp()), name="w", workers=2)
        self.assertIn("warm", order)
        self.assertLess(order.index("warm"), order.index("fork"))


if __name__ == "__main__":
    unittest.main()


class EraExportTest(unittest.TestCase):
    def test_export_of_an_era_with_sea_and_zone_metadata(self):
        import json
        import export
        from tests.test_refine import era_world
        root, _, cut, _ = era_world()
        out = Path(tempfile.mkdtemp())
        a = export.export(root, 2, cut[0], cut[1], size_km=20.0, res_m=500.0, out_dir=out, name="after-x")
        b = export.export(root, 2, cut[0], cut[1], size_km=20.0, res_m=500.0, out_dir=out, name="before-x",
                          era="before")
        ma, mb = (json.loads((d / "meta.json").read_text()) for d in (a, b))
        self.assertEqual((ma["era"]["name"], mb["era"]["name"]), ("after", "before"))
        self.assertLess(ma["center_height_m"], mb["center_height_m"], "the cut lowered the ground")
        for m in (ma, mb):
            self.assertEqual(set(m["water"]), {"sea_fraction", "max_depth_m", "center_depth_m"})
            self.assertEqual(set(m["zones"]), {"gravity_g", "o2_fraction", "po2_bar", "pressure_bar", "fire_reactivity"})
            self.assertIsInstance(m["vents"], list)
            self.assertEqual(m["format"], "worldmap-terrain-export")
        self.assertAlmostEqual(ma["zones"]["fire_reactivity"], 0.4, places=3)
        with self.assertRaises(SystemExit):
            export.export(root, 2, cut[0], cut[1], size_km=20.0, res_m=500.0, out_dir=out, name="n", era="nope")
        from unittest import mock
        import serve
        real, made = serve.World, []
        with mock.patch.object(serve, "World", side_effect=lambda *a, **k: made.append(k.get("era")) or real(*a, **k)):
            export.export(root, 2, cut[0], cut[1], size_km=20.0, res_m=500.0, out_dir=out, name="one", era="before")
        self.assertEqual([e["name"] for e in made], ["before"], "only the chosen era's world is loaded")