Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [0.5.0] — 2026-05-02

### Breaking Changes
- Removed `salabim` and `greenlet` dependencies entirely
- `Model.process()` no longer exists — replace with `pre_execute()` / `post_execute()` hooks

### Added
- `Environment`: lightweight pure-Python time-stepped scheduler
- `Model.setup(**kwargs)`: called automatically at instantiation, mirrors salabim `Component.setup()` contract
- `Model.pre_execute()`: hook called before each `execute()`
- `Model.post_execute()`: hook called after each `execute()`

### Changed
- `SyncRasterModel`: replaced `process()` with `pre_execute()` / `post_execute()`
- `SyncSpatialModel`: replaced `process()` with `pre_execute()` / `post_execute()`
- `Environment.run()`: loop now calls `pre_execute → execute → post_execute` per model per tick

### Removed
- `salabim>=25.0.0` from dependencies
- `greenlet>=3.0.0` from dependencies
- `salabim.*` from mypy overrides

### Internal
- Public API unchanged — existing models implementing only `execute()` require no modification
---

## [0.3.0] - 2026-04

### Added
Expand Down
34 changes: 14 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ DisSModel is the synthesis: a Python-native, FAIR-aligned, cloud-ready simulatio
## 🌟 Key Features

- **Dual substrate** — same model logic runs on vector (`GeoDataFrame`) and raster (`RasterBackend`/NumPy).
- **Discrete Event Simulation** — built on [Salabim](https://salabim.org/); time advances to the next relevant event, not millisecond by millisecond.
- **Lightweight scheduler** — pure-Python time-stepped engine; models auto-register at instantiation and receive clock ticks via `setup / pre_execute / execute / post_execute` lifecycle hooks.
- **Executor pattern** — strict separation between science (models) and infrastructure (I/O, CLI, reproducible execution).
- **Experiment tracking** — every run generates an immutable `ExperimentRecord` with SHA-256 checksums, TOML snapshot, and full provenance.
- **Storage-agnostic I/O** — `dissmodel.io` handles local paths and `s3://` URIs transparently.
Expand All @@ -63,7 +63,7 @@ DisSModel is the synthesis: a Python-native, FAIR-aligned, cloud-ready simulatio

```
┌──────────────────────────────────────────────────────────┐
│ Science Layer (Model / Salabim)
│ Science Layer (Model)
│ FloodModel, AllocationClueLike, MangroveModel, ... │
│ → only knows math, geometry and time │
├──────────────────────────────────────────────────────────┤
Expand All @@ -72,7 +72,7 @@ DisSModel is the synthesis: a Python-native, FAIR-aligned, cloud-ready simulatio
│ → only knows URIs, local/S3, column_map, parameters │
├──────────────────────────────────────────────────────────┤
│ Core modules │
│ dissmodel.core — Environment, SpatialModel
│ dissmodel.core — Environment, Model, SpatialModel
│ dissmodel.geo — RasterBackend, neighborhoods │
│ dissmodel.executor — ModelExecutor ABC, ExperimentRecord│
│ dissmodel.io — load_dataset / save_dataset │
Expand Down Expand Up @@ -104,10 +104,9 @@ class ForestFireModel(SpatialModel):
self.prob_spread = prob_spread

def execute(self):
# Called every step by Salabim — only math here, no I/O
# Called every step — only math here, no I/O
burning = self.gdf["state"] == "burning"
# ... apply spread logic ...
return self.gdf

env = Environment(end_time=50)
ForestFireModel(gdf=gdf, prob_spread=0.4)
Expand All @@ -130,13 +129,12 @@ class ForestFireExecutor(ModelExecutor):
record.source.checksum = checksum
return gdf

def run(self, record: ExperimentRecord):
def run(self, data, record: ExperimentRecord):
from dissmodel.core import Environment
gdf = self.load(record)
env = Environment(end_time=record.parameters.get("end_time", 50))
ForestFireModel(gdf=gdf, **record.parameters)
ForestFireModel(gdf=data, **record.parameters)
env.run()
return gdf
return data

def save(self, result, record: ExperimentRecord) -> ExperimentRecord:
uri = record.output_path or "output.gpkg"
Expand Down Expand Up @@ -177,7 +175,7 @@ Every run produces an immutable provenance record:
{
"experiment_id": "abc123",
"model_commit": "a3f9c12",
"code_version": "0.4.0",
"code_version": "0.5.0",
"resolved_spec": { "...TOML snapshot..." },
"source": { "uri": "s3://...", "checksum": "e3b0c44..." },
"artifacts": { "output": "sha256...", "profiling": "sha256..." },
Expand Down Expand Up @@ -212,7 +210,7 @@ Every run via the executor lifecycle generates a `profiling_{id}.md` alongside t

## 🧩 Ecosystem: Models & Examples

DisSModel is a core framework. To maintain a clean and specialized environment, all simulation models and implementation examples are hosted in separate repositories within the LambdaGeo ecosystem.
DisSModel is a core framework. To maintain a clean and specialized environment, all simulation models and implementation examples are hosted in separate repositories within the DisSModel ecosystem.

### 🔬 Specialized Model Libraries

Expand All @@ -234,9 +232,9 @@ Each repository demonstrates how to:

## 📚 Documentation

- 📘 **User Guide**: [https://lambdageo.github.io/dissmodel/](https://lambdageo.github.io/dissmodel/)
- 🧪 **API Reference**: [https://lambdageo.github.io/dissmodel/api/](https://lambdageo.github.io/dissmodel/api/)
- 🎓 **Tutorials**: [https://lambdageo.github.io/dissmodel/tutorials/](https://lambdageo.github.io/dissmodel/tutorials/)
- 📘 **User Guide**: [https://dissmodel.github.io/dissmodel/](https://dissmodel.github.io/dissmodel/)
- 🧪 **API Reference**: [https://dissmodel.github.io/dissmodel/api/](https://dissmodel.github.io/dissmodel/api/)
- 🎓 **Tutorials**: [https://dissmodel.github.io/dissmodel/tutorials/](https://dissmodel.github.io/dissmodel/tutorials/)

---

Expand All @@ -259,17 +257,13 @@ Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTIN
year = {2026},
publisher = {LambdaGeo, Federal University of Maranhão (UFMA)},
url = {https://github.com/DisSModel/dissmodel},
version = {0.4.0}
version = {0.5.0}
}
```

---

## ⚖️ License

MIT © [LambdaGeo — UFMA](https://github.com/DisSModel)
MIT © [DisSModel — UFMA](https://github.com/DisSModel)
See [LICENSE](LICENSE) for details.


---

145 changes: 101 additions & 44 deletions dissmodel/core/environment.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,21 @@
from __future__ import annotations

from typing import Any, Optional
from typing import Any, ClassVar, Optional

import salabim as sim


class Environment(sim.Environment):
class Environment:
"""
Simulation environment with support for a custom time window.

Extends :class:`salabim.Environment` with ``start_time`` and ``end_time``
to define the simulation boundaries explicitly.
Manages the simulation clock and coordinates the execution of all
registered :class:`~dissmodel.core.Model` instances.

Parameters
----------
start_time : float, optional
Simulation start time, by default 0.
end_time : float, optional
Simulation end time. Can also be set via ``till`` in :meth:`run`.
*args :
Extra positional arguments forwarded to :class:`salabim.Environment`.
**kwargs :
Extra keyword arguments forwarded to :class:`salabim.Environment`.

Examples
--------
Expand All @@ -32,23 +26,71 @@ class Environment(sim.Environment):
10
"""

_current: ClassVar[Optional[Environment]] = None

def __init__(
self,
start_time: float = 0,
end_time: Optional[float] = None,
*args: Any,
**kwargs: Any,
) -> None:
kwargs.pop("animation", None)
kwargs.pop("trace", False)
super().__init__(*args, trace=False, **kwargs)
self.start_time = start_time
self.end_time = end_time
self._now: float = start_time
self._models: list[Any] = []
self._plot_metadata: dict[str, Any] = {}
Environment._current = self

# ------------------------------------------------------------------
# Clock
# ------------------------------------------------------------------

def now(self) -> float:
"""
Return the current simulation time.

Returns
-------
float
Current simulation time.

Examples
--------
>>> env = Environment(start_time=5)
>>> env.now()
5
"""
return self._now

# ------------------------------------------------------------------
# Model registration
# ------------------------------------------------------------------

def _register(self, model: Any) -> None:
"""
Register a model to be executed during the simulation.

Called automatically by :class:`~dissmodel.core.Model.__init__`.

Parameters
----------
model : Model
The model instance to register.
"""
self._models.append(model)

# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------

def run(self, till: Optional[float] = None) -> None:
"""
Run the simulation over the configured time window.

Executes all registered models in time-step order. On each tick,
every model whose next scheduled time is less than or equal to the
current simulation time has its :meth:`~dissmodel.core.Model.execute`
method called. The clock then advances to the nearest pending event.

Parameters
----------
till : float, optional
Expand All @@ -71,45 +113,60 @@ def run(self, till: Optional[float] = None) -> None:
if till is not None:
self.end_time = self.start_time + till
elif self.end_time is not None:
till = self.end_time - self.start_time
pass
else:
raise ValueError("Provide 'till' or set 'end_time' before calling run().")

print(f"Running from {self.start_time} to {self.end_time} (duration: {till})")
super().run(till=till)
raise ValueError(
"Provide 'till' or set 'end_time' before calling run()."
)

duration = self.end_time - self.start_time
print(
f"Running from {self.start_time} to {self.end_time} "
f"(duration: {duration})"
)

# Initialise next-execution time for every registered model
for model in self._models:
model._next_time = model.start_time

self._now = self.start_time

while self._now < self.end_time:
for model in self._models:
if (
model._next_time <= self._now
and self._now < model.end_time
):
model.pre_execute()
model.execute()
model.post_execute()
model._next_time = self._now + model._step

# Advance clock to the nearest pending event
pending = [
m._next_time
for m in self._models
if m._next_time < self.end_time
]
if not pending:
break
self._now = min(pending)

def reset(self) -> None:
"""
Clear accumulated plot data.
Reset the clock and clear accumulated plot data.

This method is called automatically at the start of :meth:`run` to
ensure charts start fresh on each simulation run.
Called automatically at the start of :meth:`run` to ensure the
environment starts fresh on each simulation run.

Examples
--------
>>> env = Environment()
>>> env = Environment(start_time=0, end_time=10)
>>> env._plot_metadata = {"x": {"data": [1, 2, 3]}}
>>> env.reset()
>>> env._plot_metadata["x"]["data"]
[]
"""
if hasattr(self, "_plot_metadata"):
for item in self._plot_metadata.values():
item["data"].clear()

def now(self) -> float:
"""
Return the current simulation time adjusted by ``start_time``.

Returns
-------
float
Current time as ``salabim.now() + start_time``.

Examples
--------
>>> env = Environment(start_time=5)
>>> env.now()
5.0
"""
return super().now() + self.start_time
self._now = self.start_time
for item in self._plot_metadata.values():
item["data"].clear()
Loading
Loading