File size: 13,896 Bytes
e7a9f02
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
"""Crowd state, bottleneck detection, prediction, routing and the strategy engine."""

from __future__ import annotations

import numpy as np
import pytest

from flowtwin.config import SETTINGS
from flowtwin.crowd.density import classify, density, time_to_threshold
from flowtwin.crowd.flow import build_alerts, detect_bottlenecks, primary_bottleneck
from flowtwin.prediction.features import N_FEATURES, build_feature_matrix
from flowtwin.prediction.inference import DensityPredictor
from flowtwin.routing.graph import RoutingTables
from flowtwin.simulation.agents import POLICY_ADAPTIVE, POLICY_SHORTEST, POLICY_STATIC
from flowtwin.simulation.engine import RunOverrides, Simulator
from flowtwin.strategy.engine import StrategyEngine
from flowtwin.strategy.interventions import generate_candidates
from flowtwin.venue import compile_venue, load_scenario


@pytest.fixture(scope="module")
def venue():
    return compile_venue("circuit_alpha")


@pytest.fixture(scope="module")
def scenario():
    return load_scenario("circuit_alpha_post_race")


@pytest.fixture(scope="module")
def congested(venue, scenario):
    """A simulation held at the point where Exit B is genuinely failing."""
    sim = Simulator(venue, scenario, SETTINGS, seed=42193,
                    overrides=RunOverrides(crowd_size=40000))
    sim.run_for(1000)
    return sim


# ── crowd state ──────────────────────────────────────────────────────

def test_density_is_people_over_area():
    assert density(np.array([100.0]), np.array([50.0]))[0] == pytest.approx(2.0)
    assert density(np.array([100.0]), np.array([0.0]))[0] == 0.0


def test_density_levels_are_ordered():
    levels = classify(np.array([0.1, 1.2, 2.2, 3.5]), warning=2.0, critical=3.0)
    assert levels.tolist() == [0, 1, 2, 3]


def test_time_to_threshold_interpolates():
    t = time_to_threshold(1.0, [(30.0, 1.5), (60.0, 2.5)], threshold=2.0)
    assert t == pytest.approx(45.0, abs=1.0)
    assert time_to_threshold(3.0, [(30.0, 3.5)], 2.0) == 0.0
    assert time_to_threshold(1.0, [(30.0, 1.1)], 2.0) is None


def test_state_engine_tracks_flow_and_growth(congested):
    st = congested.state
    assert st.edge_density.max() > 0
    assert st.edge_inflow_ppm.max() > 0
    assert st.edge_velocity.max() <= SETTINGS.movement.free_speed_mps + 1e-9
    assert np.all(st.edge_risk >= 0) and np.all(st.edge_risk <= 1)
    assert st.samples > 100


def test_risk_contributions_sum_to_the_risk_score(congested):
    idx = int(np.argmax(congested.state.edge_risk))
    parts = congested.state.risk_contributions(
        idx, congested.venue.venue.warning_density, congested.venue.venue.critical_density)
    assert sum(parts.values()) == pytest.approx(congested.state.edge_risk[idx], abs=0.02)


# ── bottleneck detection ─────────────────────────────────────────────

def test_detects_the_degraded_exit_as_the_primary_bottleneck(congested):
    primary = primary_bottleneck(congested)
    assert primary is not None
    assert primary.base_id == "X_E_EXITB", f"expected Exit B approach, got {primary.base_id}"
    assert primary.risk > 0.5
    assert primary.causes, "a bottleneck must explain itself"


def test_bottlenecks_are_reported_once_per_physical_corridor(congested):
    found = detect_bottlenecks(congested, limit=8)
    ids = [b.base_id for b in found]
    assert len(ids) == len(set(ids))


def test_alerts_carry_severity_cause_and_lead_time(congested):
    predictor = DensityPredictor(SETTINGS)
    preds = predictor.predict(congested)
    alerts = build_alerts(congested, detect_bottlenecks(congested), preds)
    assert alerts, "no alert raised for a failing exit"
    top = alerts[0]
    assert top["severity"] in {"critical", "warning", "watch"}
    assert top["causes"]
    assert "projection" in top


def test_a_quiet_network_raises_no_critical_alert(venue, scenario):
    sim = Simulator(venue, scenario, SETTINGS, seed=5,
                    overrides=RunOverrides(crowd_size=2000))
    sim.run_for(200)
    predictor = DensityPredictor(SETTINGS)
    alerts = build_alerts(sim, detect_bottlenecks(sim), predictor.predict(sim))
    assert not any(a["severity"] == "critical" for a in alerts)


# ── prediction ───────────────────────────────────────────────────────

def test_feature_matrix_shape_and_sanity(congested):
    X = build_feature_matrix(congested)
    assert X.shape == (congested.venue.n_edges, N_FEATURES)
    assert np.isfinite(X).all()


def test_prediction_produces_horizons_and_lead_time(congested):
    predictor = DensityPredictor(SETTINGS)
    preds = predictor.predict(congested)
    idx = primary_bottleneck(congested).index
    row = preds[idx]
    assert set(row["horizons"]) == {str(h) for h in SETTINGS.prediction.horizons_s}
    assert all(v >= 0 for v in row["horizons"].values())
    assert row["source"] in {"trained_model", "analytic_baseline"}


def test_prediction_responds_to_a_change_in_state(venue, scenario):
    """The projection must track the state, not just the recent trend.

    Two branches leave the same instant: one keeps the degraded exit, the other
    loses more capacity. The physics must respond (measured throughput falls)
    and the projection must respond with it.
    """
    sim = Simulator(venue, scenario, SETTINGS, seed=42193,
                    overrides=RunOverrides(crowd_size=40000))
    sim.run_for(900)
    idx = primary_bottleneck(sim).index
    gate = venue.node_index["EXIT_B"]

    unchanged = sim.branch()
    worse = sim.branch()
    worse.node_budget.multiplier[gate] *= 0.4

    unchanged.run_for(240)
    worse.run_for(240)

    assert worse.state.node_throughput_ppm[gate] < unchanged.state.node_throughput_ppm[gate], \
        "cutting the gate did not reduce measured throughput"
    assert worse.state.node_queue[gate] > unchanged.state.node_queue[gate]

    base = DensityPredictor(SETTINGS).predict(unchanged, [idx])[idx]["peak_projected"]
    degraded = DensityPredictor(SETTINGS).predict(worse, [idx])[idx]["peak_projected"]
    assert degraded > base, (
        f"projection did not rise after the exit was cut further ({degraded} vs {base})")


def test_restoring_capacity_raises_measured_throughput(venue, scenario):
    """Opening capacity is a real change to the network, not a label."""
    sim = Simulator(venue, scenario, SETTINGS, seed=42193,
                    overrides=RunOverrides(crowd_size=40000))
    sim.run_for(900)
    gate = venue.node_index["EXIT_B"]

    degraded = sim.branch()
    restored = sim.branch()
    assert restored.open_alternate("EXIT_B", 4.0)

    degraded.run_for(180)
    restored.run_for(180)
    assert restored.state.node_throughput_ppm[gate] > degraded.state.node_throughput_ppm[gate]


def test_both_directions_of_a_corridor_share_one_projection(congested):
    predictor = DensityPredictor(SETTINGS)
    proj = predictor.project(congested)
    v = congested.venue
    for e in range(v.n_edges):
        p = int(v.pair_of[e])
        if p >= 0:
            assert np.allclose(proj[:, e], proj[:, p])


# ── routing ──────────────────────────────────────────────────────────

def test_every_node_can_reach_every_destination(venue, scenario):
    sim = Simulator(venue, scenario, SETTINGS, overrides=RunOverrides(crowd_size=500))
    for slot in range(len(sim.dest_indices)):
        for node in range(venue.n_nodes):
            nodes, _ = sim.tables.path_nodes(POLICY_SHORTEST, slot, node)
            assert nodes[-1] == sim.dest_indices[slot], \
                f"{venue.node_ids[node]} cannot reach {sim.dest_ids[slot]}"


def test_routing_tables_stay_acyclic_under_hysteresis(congested):
    for policy in (POLICY_SHORTEST, POLICY_STATIC, POLICY_ADAPTIVE):
        for slot, dest in enumerate(congested.dest_indices):
            for node in range(congested.venue.n_nodes):
                nodes, _ = congested.tables.path_nodes(policy, slot, node)
                assert len(nodes) == len(set(nodes)), \
                    f"cycle in policy {policy} from {congested.venue.node_ids[node]}"


def test_adaptive_routing_avoids_the_congested_asset(congested):
    """The dynamic plan must not still prefer the failing exit."""
    branch = congested.branch()
    edge = branch.venue.edge_index["X_E_EXITB"]
    node = branch.venue.node_index["EXIT_B"]
    branch.divert_flow(0.5, {edge, int(branch.venue.pair_of[edge])}, {node})
    slot = branch.dest_indices.index(branch.venue.node_index["TRANSPORT_BUS"])
    _, edges = branch.tables.path_nodes(POLICY_ADAPTIVE, slot,
                                        branch.venue.node_index["CON_EAST"])
    assert edge not in edges, "adaptive plan still routes through the degraded exit"


def test_hysteresis_limits_route_churn(congested):
    """Repeated refreshes on an unchanged state must not keep flipping routes."""
    branch = congested.branch()
    branch.refresh_routing()
    first = branch.tables.next_hop[POLICY_ADAPTIVE].copy()
    for _ in range(6):
        branch.refresh_routing()
    changed = int(np.sum(branch.tables.next_hop[POLICY_ADAPTIVE] != first))
    assert changed == 0, f"{changed} next-hops flapped without any state change"


# ── strategy engine ──────────────────────────────────────────────────

def test_candidates_are_generated_from_topology(congested):
    bn = primary_bottleneck(congested)
    cands = generate_candidates(congested, bn)
    ids = [c.id for c in cands]
    assert "no_action" in ids
    assert sum(1 for i in ids if i.startswith("reroute_")) >= 3
    assert len(ids) >= 5
    assert len(ids) == len(set(ids))
    for c in cands:
        assert c.description and c.instruction


def test_counterfactuals_all_start_from_the_same_state(congested):
    """Two evaluations of the same strategy from the same state must agree."""
    predictor = DensityPredictor(SETTINGS)
    engine = StrategyEngine(SETTINGS, predictor)
    a = engine.evaluate(congested, horizon_s=120, strategy_ids=["reroute_30"])
    b = engine.evaluate(congested, horizon_s=120, strategy_ids=["reroute_30"])
    ma = {s["id"]: s["metrics"] for s in a["strategies"]}
    mb = {s["id"]: s["metrics"] for s in b["strategies"]}
    assert ma == mb


def test_evaluation_does_not_advance_the_live_simulation(congested):
    predictor = DensityPredictor(SETTINGS)
    engine = StrategyEngine(SETTINGS, predictor)
    t_before = congested.time
    pos_before = congested.pop.pos_m.copy()
    engine.evaluate(congested, horizon_s=120, strategy_ids=["reroute_20"])
    assert congested.time == t_before
    assert np.array_equal(congested.pop.pos_m, pos_before)


def test_recommendation_beats_no_action_on_the_score(congested):
    engine = StrategyEngine(SETTINGS, DensityPredictor(SETTINGS))
    result = engine.evaluate(congested, horizon_s=240)
    assert result["available"]
    by_id = {s["id"]: s for s in result["strategies"]}
    winner = result["recommendation"]["strategy_id"]
    assert by_id[winner]["score"] <= by_id["no_action"]["score"]
    assert by_id[winner]["recommended"] is True
    assert by_id[winner]["metrics"]["peak_density"] <= by_id["no_action"]["metrics"]["peak_density"]


def test_explanation_uses_measured_values(congested):
    engine = StrategyEngine(SETTINGS, DensityPredictor(SETTINGS))
    result = engine.evaluate(congested, horizon_s=240)
    rec = result["recommendation"]
    by_id = {s["id"]: s for s in result["strategies"]}
    winner, baseline = by_id[rec["strategy_id"]], by_id["no_action"]
    for reason in rec["reasons"]:
        key = reason["metric"]
        attr = {"peak_density": "peak_density",
                "critical_duration": "critical_duration_s",
                "avg_travel_time": "avg_travel_time_s",
                "aggregate_risk": "aggregate_risk",
                "max_queue": "max_queue",
                "throughput": "throughput"}[key]
        assert reason["value"] == pytest.approx(winner["metrics"][attr], abs=0.02)
        assert reason["baseline"] == pytest.approx(baseline["metrics"][attr], abs=0.02)


def test_applying_a_strategy_changes_the_live_simulation(congested):
    branch = congested.branch()
    engine = StrategyEngine(SETTINGS, DensityPredictor(SETTINGS))
    result = engine.apply(branch, "reroute_30")
    assert result["applied"]
    assert result["agents_affected"] > 0
    assert branch.applied_interventions
    assert np.sum(branch.pop.policy == POLICY_ADAPTIVE) > 0


def test_unknown_strategy_is_refused(congested):
    engine = StrategyEngine(SETTINGS, DensityPredictor(SETTINGS))
    result = engine.apply(congested.branch(), "teleport_everyone")
    assert result["applied"] is False


def test_recommendation_changes_with_the_scenario(venue):
    """A different failure must not produce the same canned answer."""
    engine = StrategyEngine(SETTINGS, DensityPredictor(SETTINGS))

    egress = Simulator(venue, load_scenario("circuit_alpha_post_race"), SETTINGS,
                       seed=42193, overrides=RunOverrides(crowd_size=40000))
    egress.run_for(1000)
    a = engine.evaluate(egress, horizon_s=180)

    arrival = Simulator(venue, load_scenario("circuit_alpha_arrival"), SETTINGS,
                        seed=7717, overrides=RunOverrides(crowd_size=26000))
    arrival.run_for(900)
    b = engine.evaluate(arrival, horizon_s=180)

    assert a["bottleneck"]["base_id"] != b["bottleneck"]["base_id"], \
        "the two scenarios were expected to fail in different places"