aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/tests/test_migration.py
blob: 160f75ad8f48da6bada151a832ff9fc16766d256 (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
import unittest

import numpy as np

from tests.helpers import line_world, make_history, make_race
from worldhistory.migration import drift_and_bud, long_jumps, travel_cost
from worldhistory.config import CONDITIONS
from worldhistory.state import new_state

D_DEPTH = CONDITIONS.index("depth")

MP = make_history().migration


def setup(n, pop, race, K=1e6):
    st = new_state(1, n, np.zeros((1, 6)), seed=0)
    st.P[0, 0] = pop
    return st, np.full((1, n), K), np.ones((1, n), bool)


class MigrationTest(unittest.TestCase):
    def test_travel_cost(self):
        w = line_world(4, ocean=[False, False, False, True], landform=[1, 3, 1, 0], river=[True, False, False, False],
                       holdridge=[20, 20, 23, 0])
        r = make_race(travel={"mountain": 2.0, "desert": 1.0})
        np.testing.assert_allclose(travel_cost(w, r, np.zeros(4)), [-1, 2, 0, 0])   # cell 2: desert +1, coast -1
        np.testing.assert_allclose(travel_cost(w, r, np.full(4, 0.5)), [-1.5, 1.5, -0.5, -0.5])

    def test_budding_needs_founder_group(self):
        w = line_world(3)
        r = make_race(demography={"founder": 50.0, "mobility": 0.1})
        st, K, OK = setup(3, 60.0, r, K=100.0)          # crowded, but below 2 founders
        drift_and_bud(st, w, [r], K, OK, np.zeros((1, 3)), np.zeros((1, 3)), np.ones((1, 3)),
                      np.random.default_rng(0), MP)
        self.assertEqual(float(st.P[0, 1]), 0.0)
        st, K, OK = setup(3, 120.0, r, K=100.0)
        drift_and_bud(st, w, [r], K, OK, np.zeros((1, 3)), np.zeros((1, 3)), np.ones((1, 3)),
                      np.random.default_rng(0), MP)
        self.assertIn(float(st.P[0, 1]), (0.0, 20.0))     # a founder group (capped at P/6 = 20) or nothing
        self.assertAlmostEqual(float(st.P[0].sum()), 120.0)

    def test_river_colonised_before_mountain(self):
        r = make_race(demography={"founder": 5.0, "mobility": 0.1}, travel={"mountain": 2.0})
        hits = {0: 0, 2: 0}
        for seed in range(60):
            st = new_state(1, 3, np.zeros((1, 6)), seed=0)
            st.P[0, 1] = 100.0
            w2 = line_world(3, landform=[1, 1, 3], river=[True, True, False])   # cell 0 river plain, cell 2 mountain
            tc = travel_cost(w2, r, np.zeros(3))[None]
            drift_and_bud(st, w2, [r], np.full((1, 3), 100.0), np.ones((1, 3), bool), tc, np.zeros((1, 3)),
                          np.ones((1, 3)), np.random.default_rng(seed), MP)
            hits[0] += st.P[0, 0] > 0
            hits[2] += st.P[0, 2] > 0
        self.assertGreater(hits[0], hits[2])

    def test_drift_toward_headroom(self):
        w = line_world(3)
        r = make_race(demography={"mobility": 0.2})
        st = new_state(1, 3, np.zeros((1, 6)), seed=0)
        st.P[0] = [100.0, 100.0, 100.0]
        K = np.array([[1000.0, 1000.0, 5000.0]])
        drift_and_bud(st, w, [r], K, np.ones((1, 3), bool), np.zeros((1, 3)), np.zeros((1, 3)),
                      np.ones((1, 3)), np.random.default_rng(0), MP)
        self.assertGreater(float(st.P[0, 2]), float(st.P[0, 0]))  # cell 1 sends more toward the roomier side
        self.assertAlmostEqual(float(st.P[0].sum()), 300.0)

    def test_never_into_uninhabitable(self):
        w = line_world(3)
        r = make_race(demography={"mobility": 0.2, "founder": 1.0})
        st = new_state(1, 3, np.zeros((1, 6)), seed=0)
        st.P[0] = [0.0, 100.0, 100.0]
        drift_and_bud(st, w, [r], np.full((1, 3), 100.0), np.array([[False, True, True]]), np.zeros((1, 3)),
                      np.zeros((1, 3)), np.ones((1, 3)), np.random.default_rng(0), MP)
        self.assertEqual(float(st.P[0, 0]), 0.0)
        self.assertAlmostEqual(float(st.P[0].sum()), 200.0)

    def test_drift_keeps_viable_source_viable(self):
        # uncrowded viable group (22, Allee 20) beside a small one: drift may not take it below the Allee threshold
        w = line_world(2)
        r = make_race(demography={"mobility": 0.5, "allee": 20.0, "founder": 20.0})
        st = new_state(1, 2, np.zeros((1, 6)), seed=0)
        st.P[0] = [22.0, 5.0]
        drift_and_bud(st, w, [r], np.full((1, 2), 1e5), np.ones((1, 2), bool), np.zeros((1, 2)),
                      np.zeros((1, 2)), np.ones((1, 2)), np.random.default_rng(0), MP)
        self.assertGreaterEqual(float(st.P[0, 0]), 20.0)
        self.assertAlmostEqual(float(st.P[0].sum()), 27.0)

    def test_small_group_joins_viable_neighbour(self):
        # below the Allee threshold, a group gathers into its viable neighbour rather than dwindling alone
        w = line_world(3)
        r = make_race(demography={"mobility": 0.1, "allee": 20.0, "founder": 20.0})
        st = new_state(1, 3, np.zeros((1, 6)), seed=0)
        st.P[0] = [8.0, 30.0, 0.0]
        drift_and_bud(st, w, [r], np.full((1, 3), 1e5), np.ones((1, 3), bool), np.zeros((1, 3)),
                      np.zeros((1, 3)), np.ones((1, 3)), np.random.default_rng(0), MP)
        np.testing.assert_allclose(st.P[0], [0.0, 38.0, 0.0])

    def test_push_flees_into_empty_land(self):
        # people driven out (push) settle an empty livable neighbour even when nobody is crowded
        w = line_world(2)
        r = make_race(demography={"mobility": 0.0, "allee": 20.0, "founder": 20.0})
        st = new_state(1, 2, np.zeros((1, 6)), seed=0)
        st.P[0] = [120.0, 0.0]
        st.push[0, 0] = 30.0
        drift_and_bud(st, w, [r], np.full((1, 2), 1e5), np.ones((1, 2), bool), np.zeros((1, 2)),
                      np.zeros((1, 2)), np.ones((1, 2)), np.random.default_rng(0), MP)
        np.testing.assert_allclose(st.P[0], [100.0, 20.0])                  # capped at P/6 per neighbour

    def test_roaming_groups_travel_together(self):
        # roam = chance per step that a whole group (a caravan) moves on, together, to one neighbouring cell
        w = line_world(3)
        r = make_race(demography={"mobility": 0.0, "allee": 2.0, "founder": 2.0, "roam": 1.0})
        ends = set()
        for seed in range(20):
            st = new_state(1, 3, np.zeros((1, 6)), seed=0)
            st.P[0] = [0.0, 120.0, 0.0]
            drift_and_bud(st, w, [r], np.full((1, 3), 1e5), np.ones((1, 3), bool), np.zeros((1, 3)),
                          np.zeros((1, 3)), np.ones((1, 3)), np.random.default_rng(seed), MP)
            self.assertEqual(float(st.P[0, 1]), 0.0)
            self.assertIn(float(st.P[0].max()), (120.0,))
            ends.add(int(st.P[0].argmax()))
        self.assertEqual(ends, {0, 2})                                     # either way, never split

    def test_long_jump_crosses_chasm_on_migrants_adaptation(self):
        # deep-adapted swimmers skip an abyss they cannot live in and land on ground that suits *them*, even though
        # the landing cells' stored optimum (native, 0 m) would call them unfit
        from worldhistory.habitat import environment, suitability
        depth = np.r_[np.full(5, 2000.0), np.full(35, 6000.0), np.full(40, 2000.0)]
        w = line_world(80, spacing_km=10.0, ocean=True, elevation_m=-depth)
        tol = {"depth": {"optimum": 0, "width": 60, "lo": 0, "hi": 7000, "rate": 2.0}}
        r = make_race(demography={"founder": 2.0, "allee": 1.0, "jump": 1.0}, tolerance=tol,
                      habitat={"realm": "sea", "terms": [{"p": "sea", "w": 1.0}]})
        env, suit = environment(w), suitability(w, r)[None]
        landed = set()
        for seed in range(200):
            st = new_state(1, w.n, np.zeros((1, 6)), seed=0)
            st.P[0, 0] = 1e6
            st.O[0, D_DEPTH, :5] = 2000.0
            long_jumps(st, w, [r], np.full((1, w.n), 1e6), np.ones((1, w.n), bool), np.random.default_rng(seed),
                       {**MP, "jump_rate": 1.0}, np.ones((1, w.n)), env=env, suit=suit, f_min=0.1)
            landed |= set(np.flatnonzero(st.P[0, 1:] > 0) + 1)
        self.assertTrue(any(c >= 40 for c in landed))                    # across the chasm
        self.assertFalse(any(5 <= c < 40 for c in landed))               # never into the abyss

    def test_sea_path_jumps_never_cross_land(self):
        # swimmers (jump_path = sea) cannot hop over a strip of land; without it they can
        ocean = np.r_[np.ones(5, bool), np.zeros(5, bool), np.ones(70, bool)]
        w = line_world(80, spacing_km=10.0, ocean=ocean, elevation_m=np.where(ocean, -100.0, 10.0))
        from worldhistory.habitat import environment, suitability
        for path, beyond in (("any", True), ("sea", False)):
            r = make_race(demography={"founder": 2.0, "allee": 1.0, "jump_path": path},
                          habitat={"realm": "sea", "terms": [{"p": "sea", "w": 1.0}]})
            env, suit = environment(w), suitability(w, r)[None]
            landed = set()
            for seed in range(200):
                st = new_state(1, w.n, np.zeros((1, 6)), seed=0)
                st.P[0, 0] = 1e6
                long_jumps(st, w, [r], np.full((1, w.n), 1e6), np.ones((1, w.n), bool), np.random.default_rng(seed),
                           {**MP, "jump_rate": 1.0}, np.ones((1, w.n)), env=env, suit=suit, f_min=0.1)
                landed |= set(np.flatnonzero(st.P[0, 1:] > 0) + 1)
            self.assertEqual(any(c >= 10 for c in landed), beyond, path)

    def test_large_group_splits(self):
        # clans above group_max break up: half leaves as a new group into empty land
        w = line_world(3)
        r = make_race(demography={"mobility": 0.0, "allee": 20.0, "founder": 20.0, "group_max": 500.0})
        st = new_state(1, 3, np.zeros((1, 6)), seed=0)
        st.P[0] = [0.0, 1200.0, 0.0]
        drift_and_bud(st, w, [r], np.full((1, 3), 1e5), np.ones((1, 3), bool), np.zeros((1, 3)),
                      np.zeros((1, 3)), np.ones((1, 3)), np.random.default_rng(0), MP)
        np.testing.assert_allclose(st.P[0], [200.0, 800.0, 200.0])          # 600 leave, capped P/6 per side

    def test_push_leaves(self):
        w = line_world(2)
        r = make_race(demography={"mobility": 0.0})
        st = new_state(1, 2, np.zeros((1, 6)), seed=0)
        st.P[0] = [100.0, 100.0]
        st.push[0, 0] = 10.0
        drift_and_bud(st, w, [r], np.full((1, 2), 1000.0), np.ones((1, 2), bool), np.zeros((1, 2)),
                      np.zeros((1, 2)), np.ones((1, 2)), np.random.default_rng(0), MP)
        np.testing.assert_allclose(st.P[0], [90.0, 110.0])
        self.assertEqual(float(st.push.sum()), 0.0)

    def test_long_jump_distances(self):
        w = line_world(4000, spacing_km=10.0)
        r = make_race(demography={"founder": 2.0, "allee": 1.0})
        st = new_state(1, w.n, np.zeros((1, 6)), seed=0)
        st.P[0, 0] = 1e6
        d = []
        for seed in range(300):
            st.P[0] = 0
            st.P[0, 0] = 1e6
            long_jumps(st, w, [r], np.full((1, w.n), 1e6), np.ones((1, w.n), bool), np.random.default_rng(seed),
                       {**MP, "jump_rate": 1.0}, np.ones((1, w.n)))
            hit = np.flatnonzero(st.P[0, 1:]) + 1
            d += list(np.minimum(hit, w.n - hit) * 10.0)          # the line circles the globe: wrap
        d = np.array(d)
        self.assertGreater(len(d), 100)          # a line world: only jumps along the line land near it
        self.assertTrue((d <= MP["jump_cap_km"] + 10).all())
        self.assertGreater(float(np.median(d)), MP["jump_xm_km"] * 0.8)