From 49680af852bcb19b01528969def4b68d4b8e69a5 Mon Sep 17 00:00:00 2001 From: Sergio Souza Costa Date: Sun, 3 May 2026 17:49:54 -0300 Subject: [PATCH] fix(core): prevent finished models from stalling the environment clock A model whose end_time had passed was still included in the pending event queue, causing the clock to stall at its next_time indefinitely. Fix: filter pending events by both env.end_time and model.end_time. Add test suite for Environment/Model scheduler (27 tests covering clock accuracy, model boundaries, heterogeneous steps, lifecycle hooks, re-run semantics, and edge cases) --- dissmodel/core/environment.py | 1 + docs/api/core.md | 28 ++- pyproject.toml | 2 +- tests/test_scheduler.py | 332 ++++++++++++++++++++++++++++++++++ 4 files changed, 347 insertions(+), 16 deletions(-) create mode 100644 tests/test_scheduler.py diff --git a/dissmodel/core/environment.py b/dissmodel/core/environment.py index da60f68..14f7c1c 100644 --- a/dissmodel/core/environment.py +++ b/dissmodel/core/environment.py @@ -147,6 +147,7 @@ def run(self, till: Optional[float] = None) -> None: m._next_time for m in self._models if m._next_time < self.end_time + and m._next_time < m.end_time ] if not pending: break diff --git a/docs/api/core.md b/docs/api/core.md index a9cb7ea..71a83f3 100644 --- a/docs/api/core.md +++ b/docs/api/core.md @@ -113,21 +113,19 @@ Expected output: ``` Running from 2010 to 2016 (duration: 6) -[B] t=2010.0 -[C] t=2010.0 -[B] t=2011.0 -[C] t=2011.0 -[A] t=2012.0 -[B] t=2012.0 -[C] t=2012.0 -[A] t=2013.0 -[C] t=2013.0 -[A] t=2014.0 -[C] t=2014.0 -[A] t=2015.0 -[C] t=2015.0 -[A] t=2016.0 -[C] t=2016.0 +[B] t=2010 +[C] t=2010 +[B] t=2011 +[C] t=2011 +[A] t=2012 +[B] t=2012 +[C] t=2012 +[A] t=2013 +[C] t=2013 +[A] t=2014 +[C] t=2014 +[A] t=2015 +[C] t=2015 ``` !!! note diff --git a/pyproject.toml b/pyproject.toml index a26e01c..c80ca1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dissmodel" -version = "0.5.0" +version = "0.5.1" description = "Discrete Spatial Modeling framework for raster and vector simulations" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.10" diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 0000000..70f1a4b --- /dev/null +++ b/tests/test_scheduler.py @@ -0,0 +1,332 @@ +""" +tests/core/test_scheduler.py +============================= +Test suite for the Environment / Model time-stepped scheduler. + +Covers: +- Clock accuracy and boundaries +- Model start_time / end_time constraints +- Multi-model heterogeneous step sizes +- Lifecycle hooks (setup, pre_execute, execute, post_execute) +- Edge cases: single tick, empty environment, re-run +""" +from __future__ import annotations + +import math +import pytest + +from dissmodel.core import Environment, Model + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +class Recorder(Model): + """Model that records every tick it executes.""" + + def setup(self, **kwargs): + self.ticks: list[float] = [] + self.pre_ticks: list[float] = [] + self.post_ticks: list[float] = [] + + def pre_execute(self): + self.pre_ticks.append(self.env.now()) + + def execute(self): + self.ticks.append(self.env.now()) + + def post_execute(self): + self.post_ticks.append(self.env.now()) + + +# --------------------------------------------------------------------------- +# Clock +# --------------------------------------------------------------------------- + +class TestClock: + def test_now_returns_start_time_before_run(self): + env = Environment(start_time=2010, end_time=2015) + assert env.now() == 2010 + + def test_now_advances_correctly(self): + ticks = [] + + class Capture(Model): + def setup(self, **kwargs): pass + def execute(self): ticks.append(self.env.now()) + + env = Environment(start_time=0, end_time=5) + Capture(step=1) + env.run() + assert ticks == [0, 1, 2, 3, 4] + + def test_end_time_is_exclusive(self): + """Last tick must be end_time - step, not end_time.""" + ticks = [] + + class Capture(Model): + def setup(self, **kwargs): pass + def execute(self): ticks.append(self.env.now()) + + env = Environment(start_time=0, end_time=3) + Capture(step=1) + env.run() + assert max(ticks) == 2 + assert 3 not in ticks + + def test_start_time_offset(self): + """start_time=2010 means first tick is at 2010, not 0.""" + ticks = [] + + class Capture(Model): + def setup(self, **kwargs): pass + def execute(self): ticks.append(self.env.now()) + + env = Environment(start_time=2010, end_time=2013) + Capture(step=1) + env.run() + assert ticks == [2010, 2011, 2012] + + def test_fractional_step(self): + ticks = [] + + class Capture(Model): + def setup(self, **kwargs): pass + def execute(self): ticks.append(self.env.now()) + + env = Environment(start_time=0, end_time=1) + Capture(step=0.25) + env.run() + assert len(ticks) == 4 + assert abs(ticks[-1] - 0.75) < 1e-9 + + def test_till_overrides_end_time(self): + ticks = [] + + class Capture(Model): + def setup(self, **kwargs): pass + def execute(self): ticks.append(self.env.now()) + + env = Environment(start_time=0, end_time=100) + Capture(step=1) + env.run(till=3) + assert max(ticks) == 2 + + def test_run_raises_without_end_time(self): + env = Environment(start_time=0) + + class Noop(Model): + def setup(self, **kwargs): pass + + Noop(step=1) + with pytest.raises(ValueError, match="end_time"): + env.run() + + +# --------------------------------------------------------------------------- +# Model start_time / end_time +# --------------------------------------------------------------------------- + +class TestModelBoundaries: + def test_model_start_time_respected(self): + env = Environment(start_time=2010, end_time=2016) + a = Recorder(start_time=2012) + env.run() + assert all(t >= 2012 for t in a.ticks) + assert 2010 not in a.ticks + assert 2011 not in a.ticks + + def test_model_end_time_respected(self): + env = Environment(start_time=2010, end_time=2016) + b = Recorder(end_time=2013) + env.run() + assert all(t < 2013 for t in b.ticks) + assert 2013 not in b.ticks + + def test_model_end_time_does_not_stall_clock(self): + """ModelB ending early must not stall the clock for ModelC.""" + env = Environment(start_time=2010, end_time=2016) + b = Recorder(end_time=2013) + c = Recorder() + env.run() + # ModelC must run until 2015 (end_time - 1) + assert max(c.ticks) == 2015 + assert len(c.ticks) == 6 + + def test_three_models_mixed_boundaries(self): + """Reproduces the canonical test from the docs.""" + env = Environment(start_time=2010, end_time=2016) + a = Recorder(start_time=2012) + b = Recorder(end_time=2013) + c = Recorder() + env.run() + + assert a.ticks == [2012, 2013, 2014, 2015] + assert b.ticks == [2010, 2011, 2012] + assert c.ticks == [2010, 2011, 2012, 2013, 2014, 2015] + + def test_model_active_single_tick(self): + """Model with start_time == end_time - 1 executes exactly once.""" + env = Environment(start_time=0, end_time=5) + r = Recorder(start_time=3, end_time=4) + env.run() + assert r.ticks == [3] + + def test_model_never_active(self): + """Model whose window is entirely outside env range executes never.""" + env = Environment(start_time=0, end_time=5) + r = Recorder(start_time=10, end_time=20) + env.run() + assert r.ticks == [] + + +# --------------------------------------------------------------------------- +# Heterogeneous step sizes +# --------------------------------------------------------------------------- + +class TestHeterogeneousSteps: + def test_two_models_different_steps(self): + env = Environment(start_time=0, end_time=6) + fast = Recorder(step=1) + slow = Recorder(step=2) + env.run() + + assert fast.ticks == [0, 1, 2, 3, 4, 5] + assert slow.ticks == [0, 2, 4] + + def test_clock_does_not_skip_ticks(self): + """Clock must stop at every tick needed by any model.""" + env = Environment(start_time=0, end_time=6) + Recorder(step=1) + Recorder(step=3) + # clock must visit 0,1,2,3,4,5 — not skip to 0,3 + visited = [] + + class ClockWatcher(Model): + def setup(self, **kwargs): pass + def execute(self): visited.append(self.env.now()) + + ClockWatcher(step=1) + env.run() + assert visited == [0, 1, 2, 3, 4, 5] + + +# --------------------------------------------------------------------------- +# Lifecycle hooks +# --------------------------------------------------------------------------- + +class TestLifecycleHooks: + def test_setup_called_once(self): + setup_calls = [] + + class SetupModel(Model): + def setup(self, tag="x", **kwargs): + setup_calls.append(tag) + + env = Environment(start_time=0, end_time=3) + SetupModel(tag="hello") + env.run() + assert setup_calls == ["hello"] + + def test_setup_receives_kwargs(self): + received = {} + + class KwargsModel(Model): + def setup(self, rate=0.0, name_tag="", **kwargs): + received["rate"] = rate + received["name_tag"] = name_tag + + env = Environment(start_time=0, end_time=1) + KwargsModel(rate=0.5, name_tag="test") + env.run() + assert received == {"rate": 0.5, "name_tag": "test"} + + def test_pre_post_execute_order(self): + order = [] + + class OrderModel(Model): + def setup(self, **kwargs): pass + def pre_execute(self): order.append("pre") + def execute(self): order.append("exec") + def post_execute(self): order.append("post") + + env = Environment(start_time=0, end_time=2) + OrderModel(step=1) + env.run() + assert order == ["pre", "exec", "post", "pre", "exec", "post"] + + def test_pre_post_execute_same_tick(self): + """pre/post must record the same time as execute.""" + env = Environment(start_time=0, end_time=3) + r = Recorder(step=1) + env.run() + assert r.pre_ticks == r.ticks == r.post_ticks + + def test_hooks_not_called_outside_model_window(self): + env = Environment(start_time=0, end_time=5) + r = Recorder(start_time=2, end_time=4) + env.run() + assert r.pre_ticks == [2, 3] + assert r.ticks == [2, 3] + assert r.post_ticks == [2, 3] + + +# --------------------------------------------------------------------------- +# Re-run and reset +# --------------------------------------------------------------------------- + +class TestReRun: + def test_rerun_produces_same_ticks(self): + env = Environment(start_time=0, end_time=3) + r = Recorder(step=1) + env.run() + assert len(r.ticks) == 3 # primeiro run + env.run() + assert len(r.ticks) == 6 # segundo run acumula — comportamento esperado + + def test_reset_clears_plot_metadata(self): + env = Environment(start_time=0, end_time=2) + env._plot_metadata = {"x": {"data": [1, 2, 3]}} + + class Noop(Model): + def setup(self, **kwargs): pass + + Noop(step=1) + env.run() + assert env._plot_metadata["x"]["data"] == [] + + def test_environment_current_updated_on_init(self): + env1 = Environment(start_time=0, end_time=5) + assert Environment._current is env1 + env2 = Environment(start_time=0, end_time=5) + assert Environment._current is env2 + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + +class TestEdgeCases: + def test_empty_environment_runs_without_error(self): + env = Environment(start_time=0, end_time=5) + env.run() # no models registered + + def test_model_without_environment_raises(self): + Environment._current = None + with pytest.raises(RuntimeError, match="No active Environment"): + Recorder(step=1) + + def test_single_tick_simulation(self): + env = Environment(start_time=0, end_time=1) + r = Recorder(step=1) + env.run() + assert r.ticks == [0] + + def test_model_inf_end_time(self): + """Model with default end_time=inf must respect env end_time.""" + env = Environment(start_time=0, end_time=3) + r = Recorder(step=1) + assert r.end_time == math.inf + env.run() + assert r.ticks == [0, 1, 2]