worldgen

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

master

raw · 11691 bytes

import unittest

import numpy as np

from mapgen import ocean as OC
from mapgen.sphere import east_north
from tests.helpers import small_grid

DAY = 31.149
KM_PER_DEG = 12742.0 * np.pi / 180.0


def basin(g):
    """Ocean box 15–45° N between two meridional coasts at ±60° lon (everything else land)."""
    return (g.lat > 15) & (g.lat < 45) & (np.abs(g.lon) < 60)


def gyre_wind(g):
    """Trades (from the east) at 15° |lat|, westerlies at 45° |lat|: −8 … +8 m/s east."""
    e, _ = east_north(g.xyz)
    U = -8.0 * np.cos(np.radians((np.abs(g.lat) - 15.0) / 30.0 * 180.0))
    return U[:, None] * e


def params(**over):
    P = dict(OC.DEFAULTS)
    P["friction_days"] = 2.0          # r/β ≈ 750 km: resolved on the res-3 test grid (≈240 km spacing)
    P.update(over)
    return P


def northward(g, u):
    _, n = east_north(g.xyz)
    return np.sum(u * n, axis=1)


class GyreTest(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.g = small_grid(3)
        cls.ocean = basin(cls.g)
        cls.u = OC.currents(cls.g, cls.ocean, gyre_wind(cls.g), params(), DAY)

    def test_clockwise_with_western_intensification(self):
        g, v = self.g, northward(self.g, self.u)
        band = self.ocean & (g.lat > 25) & (g.lat < 35)
        west_km = (g.lon + 60.0) * KM_PER_DEG * np.cos(np.radians(30.0))
        east_km = (60.0 - g.lon) * KM_PER_DEG * np.cos(np.radians(30.0))
        west, east = band & (west_km < 1500), band & (east_km < 1500)
        self.assertGreater(v[west].max(), 0.0)                          # northward along the west coast
        self.assertLess(v[east].min(), 0.0)                             # southward in the east
        self.assertGreater(v[west].max(), 3.0 * -v[east].min())         # the western boundary current is the fast one

    def test_land_is_still(self):
        g = self.g
        psi = OC.streamfunction(g, self.ocean, gyre_wind(g), params(), DAY)
        sp = np.linalg.norm(OC.velocity(g, psi), axis=1)
        inland = ~self.ocean
        for _ in range(2):                                              # two cells away from any sea
            inland = inland & ~np.bincount(g.src, weights=(~inland[g.dst]).astype(float), minlength=g.n).astype(bool)
        self.assertLess(sp[inland].max(), 0.01 * sp[self.ocean].max())

    def test_zero_on_land_in_output(self):
        self.assertTrue(np.all(self.u[~self.ocean] == 0.0))

    def test_southern_hemisphere_is_anticlockwise(self):
        g = self.g
        ocean = (g.lat < -15) & (g.lat > -45) & (np.abs(g.lon) < 60)
        v = northward(g, OC.currents(g, ocean, gyre_wind(g), params(), DAY))
        band = ocean & (g.lat < -25) & (g.lat > -35)
        west = band & (g.lon < -45)
        self.assertLess(v[west].min(), 0.0)                             # southward along the west coast
        self.assertGreater(-v[west].min(), 3.0 * max(v[band & (g.lon > 45)].max(), 1e-9))


class ChannelTest(unittest.TestCase):
    def test_westerlies_drive_eastward_flow_downwind_without_rotation(self):
        g = small_grid(3)
        ocean = (g.lat > 30) & (g.lat < 60)
        e, _ = east_north(g.xyz)
        wind = 8.0 * e
        mid = (g.lat > 40) & (g.lat < 50)
        u = OC.currents(g, ocean, wind, params(), 1e7)                  # ~no rotation: flow is downwind
        ue, un = np.sum(u * e, axis=1), northward(g, u)
        self.assertGreater(ue[mid].mean(), 0.0)
        self.assertLess(np.abs(un[mid]).mean(), 0.05 * ue[mid].mean())
        u = OC.currents(g, ocean, wind, params(), DAY)
        self.assertGreater(np.sum(u * e, axis=1)[mid].mean(), 0.0)


class DayLengthTest(unittest.TestCase):
    def test_slower_rotation_widens_boundary_current(self):
        g = small_grid(3)
        ocean = basin(g)
        band = ocean & (g.lat > 27) & (g.lat < 33)
        widths = []
        for day in (DAY, 4 * DAY):
            v = northward(g, OC.currents(g, ocean, gyre_wind(g), params(friction_days=4.0), day))
            half = band & (v > 0.5 * v[band].max())
            widths.append(g.lon[half].max() - g.lon[band].min())
        self.assertGreater(widths[1], widths[0])


class EdgeCaseTest(unittest.TestCase):
    def test_no_ocean_gives_zeros(self):
        g = small_grid(2)
        u = OC.currents(g, np.zeros(g.n, bool), gyre_wind(g), params(), DAY)
        self.assertTrue(np.all(u == 0.0))

    def test_all_ocean_globe_is_finite_at_the_poles(self):
        g = small_grid(3)
        e, _ = east_north(g.xyz)
        wind = (-6.0 * np.cos(np.radians(3 * g.lat)))[:, None] * e
        u = OC.currents(g, np.ones(g.n, bool), wind, params(), DAY)
        self.assertTrue(np.all(np.isfinite(u)))
        self.assertLess(np.linalg.norm(u, axis=1).max(), 20.0)


class CoarseTest(unittest.TestCase):
    def test_coarse_fallback_keeps_the_gyre(self):
        g = small_grid(3)
        ocean = basin(g)
        v = northward(g, OC.currents(g, ocean, gyre_wind(g), params(friction_days=1.0, direct_max_cells=1000), DAY))
        band = ocean & (g.lat > 25) & (g.lat < 35)
        self.assertTrue(np.all(np.isfinite(v)))
        self.assertGreater(v[band & (g.lon < -40)].mean(), 0.0)
        self.assertLess(v[band & (g.lon > -20)].mean(), 0.0)


class SSTTest(unittest.TestCase):
    def test_still_water_keeps_equilibrium(self):
        g = small_grid(3)
        ocean = basin(g)
        T_eq = 30.0 - 0.5 * np.abs(g.lat)
        T = OC.sst(g, ocean, np.zeros((g.n, 3)), T_eq, params(kappa_m2s=0.0))
        np.testing.assert_allclose(T, T_eq, atol=1e-6)

    def test_boundary_current_warms_the_west_side(self):
        g = small_grid(3)
        ocean = basin(g)
        u = OC.currents(g, ocean, gyre_wind(g), params(), DAY)
        T_eq = 30.0 - 0.5 * np.abs(g.lat)
        a = OC.sst(g, ocean, u, T_eq, params()) - T_eq
        band = ocean & (g.lat > 32) & (g.lat < 40)
        self.assertGreater(a[band & (g.lon < -45)].mean(), a[band & (g.lon > 45)].mean() + 0.2)
        self.assertGreater(a[band & (g.lon < -45)].mean(), 0.0)


class UpwellingTest(unittest.TestCase):
    def setUp(self):
        self.g = small_grid(3)
        _, self.n = east_north(self.g.xyz)
        self.P = params(upwell_coast_km=0.0)

    def coast(self, ocean, lo, hi):
        g = self.g
        touches_land = np.bincount(g.src, weights=(~ocean[g.dst]).astype(float), minlength=g.n) > 0
        return ocean & touches_land & (g.lat > lo) & (g.lat < hi)

    def test_west_coast_upwells(self):
        g = self.g
        ocean = ~((g.lon > 0) & (g.lon < 90) & (g.lat > 10) & (g.lat < 50))   # continent east of the sea
        w = OC.upwelling(g, ocean, -6.0 * self.n, self.P, DAY)                 # equatorward wind (north)
        self.assertGreater(w[self.coast(ocean, 20, 40) & (g.lon < 0)].mean(), 0.0)

    def test_east_coast_downwells(self):
        g = self.g
        ocean = ~((g.lon > -90) & (g.lon < 0) & (g.lat > 10) & (g.lat < 50))  # continent west of the sea
        w = OC.upwelling(g, ocean, -6.0 * self.n, self.P, DAY)
        self.assertLess(w[self.coast(ocean, 20, 40) & (g.lon > 0)].mean(), 0.0)

    def test_southern_west_coast_upwells_under_equatorward_wind(self):
        g = self.g
        ocean = ~((g.lon > 0) & (g.lon < 90) & (g.lat < -10) & (g.lat > -50))
        w = OC.upwelling(g, ocean, 6.0 * self.n, self.P, DAY)                  # equatorward in the south = north
        self.assertGreater(w[self.coast(ocean, -40, -20) & (g.lon < 0)].mean(), 0.0)

    def test_trades_upwell_at_the_equator(self):
        g = self.g
        e, _ = east_north(g.xyz)
        w = OC.upwelling(g, np.ones(g.n, bool), -6.0 * e, self.P, DAY)
        self.assertGreater(w[np.abs(g.lat) < 3].mean(), 0.0)
        self.assertLess(w[(np.abs(g.lat) > 8) & (np.abs(g.lat) < 20)].mean(), w[np.abs(g.lat) < 3].mean())

    def test_zero_on_land(self):
        g = self.g
        ocean = g.lat < 0
        self.assertTrue(np.all(OC.upwelling(g, ocean, -6.0 * self.n, params(), DAY)[~ocean] == 0.0))


class ProductivityTest(unittest.TestCase):
    def test_upwelling_coast_beats_gyre_centre(self):
        g = small_grid(3)
        ocean = ~((g.lon > 0) & (g.lon < 90) & (g.lat > 10) & (g.lat < 50))
        _, n = east_north(g.xyz)
        w = OC.upwelling(g, ocean, -6.0 * n, params(), DAY)
        p = OC.productivity(g, ocean, w, np.full(g.n, -4000.0), np.zeros(g.n), np.full(g.n, 20.0), params())
        touches_land = np.bincount(g.src, weights=(~ocean[g.dst]).astype(float), minlength=g.n) > 0
        coast = ocean & touches_land & (g.lat > 25) & (g.lat < 35) & (g.lon < 0)
        centre = g.cell_index(30.0, -60.0)
        self.assertGreater(p[coast].max(), 0.1)
        self.assertGreater(p[coast].max(), 10.0 * p[centre])

    def test_shelf_and_light(self):
        g = small_grid(3)
        ocean = g.lat < 0
        zero = np.zeros(g.n)
        shelf = OC.productivity(g, ocean, zero, np.full(g.n, -100.0), zero, np.full(g.n, 25.0), params())
        deep = OC.productivity(g, ocean, zero, np.full(g.n, -3000.0), zero, np.full(g.n, 25.0), params())
        i = g.cell_index(-10.0, 0.0)
        self.assertAlmostEqual(shelf[i], 0.4 * (0.3 + 0.7 * np.cos(np.radians(g.lat[i]))), places=6)  # a_s·light
        self.assertEqual(deep[i], 0.0)
        self.assertGreater(shelf[i], shelf[g.cell_index(-80.0, 0.0)])

    def test_sharp_sst_front_is_productive(self):
        g = small_grid(3)
        ocean = g.lat < 0
        zero, deep = np.zeros(g.n), np.full(g.n, -4000.0)
        front = 15.0 + 8.0 * np.tanh((g.lat + 35.0) / 2.0)             # 16 °C across ≈ 4° (≈ 900 km): ≈ 2 °C/100 km
        flat = 25.0 + 0.1 * g.lat                                       # background pole-ward cooling, ≈ 0.05 °C/100 km
        pf = OC.productivity(g, ocean, zero, deep, zero, front, params())
        pb = OC.productivity(g, ocean, zero, deep, zero, flat, params())
        at, far = g.cell_index(-35.0, 0.0), g.cell_index(-60.0, 0.0)
        self.assertGreater(pf[at], 0.15)
        self.assertLess(pf[far], 0.02)
        self.assertLess(pb.max(), 1e-9)                                 # gentle background gradients add nothing

    def test_land_sea_contrast_is_not_a_front(self):
        g = small_grid(3)
        ocean = g.lat < 0
        zero, deep = np.zeros(g.n), np.full(g.n, -4000.0)
        sst_c = np.where(ocean, 20.0, -10.0)                            # land temperatures differ wildly
        self.assertLess(OC.productivity(g, ocean, zero, deep, zero, sst_c, params()).max(), 1e-9)

    def test_range_and_land(self):
        g = small_grid(3)
        ocean = g.lat < 0
        p = OC.productivity(g, ocean, np.full(g.n, 1e4), np.zeros(g.n), np.full(g.n, 40.0), np.zeros(g.n), params())
        self.assertTrue(np.all((p >= 0) & (p <= 1)))
        self.assertTrue(np.all(p[~ocean] == 0))


class GaugeTest(unittest.TestCase):
    def test_no_spurious_vortex_at_the_gauge(self):
        # cell 0 (79° N, 38° E on H3 grids) at sea in a world with a continent: pinning ψ there must not leave a point
        # vortex (the solve's compatibility residual) — the speed around cell 0 stays within the ocean's p99
        g = small_grid(3)
        land = (np.abs(g.lat) < 40) & (np.abs(g.lon) < 50)
        ocean = ~land
        self.assertTrue(ocean[0])
        e, _ = east_north(g.xyz)
        wind = (-8.0 * np.cos(np.radians(3.0 * g.lat)))[:, None] * e
        sp = np.linalg.norm(OC.currents(g, ocean, wind, params(), DAY), axis=1)
        near = np.zeros(g.n, bool)
        near[0] = True
        for _ in range(2):
            near = near | (np.bincount(g.src, weights=near[g.dst].astype(float), minlength=g.n) > 0)
        self.assertLessEqual(sp[near].max(), np.percentile(sp[ocean], 99))