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)