mesa icon indicating copy to clipboard operation
mesa copied to clipboard

Add ContinuousStates and -Observables

Open EwoutH opened this issue 2 months ago β€’ 12 comments

Summary

Adds ContinuousObservable to mesa_signals, enabling agent states that change continuously over time with automatic threshold detection and signal emission. Demonstrates the feature with an enhanced Wolf-Sheep predator-prey model where energy depletes continuously rather than at discrete time steps.

Motive

Agent-based models often need to represent continuously changing states like energy depletion, resource growth, or temperature changes. Currently, modelers must manually calculate state changes at each step or schedule discrete update events, leading to:

  • Balancing trade-offs between update frequency and performance
  • Difficulty detecting exact threshold crossings
  • No standardized patterns for time-varying states
  • Complex coordination of multiple changing states

This addresses discussion #2529 (Continuous States) and provides a foundational building block toward the behavioral framework outlined in discussion #2538, enabling more realistic agent behaviors driven by internal state dynamics.

Implementation

Core Components:

  1. ContinuousObservable descriptor: Extends Observable to track values that change over time according to a rate function. Uses lazy evaluation - values are only recalculated when accessed, making it efficient even with many agents.

  2. ContinuousState helper class: Internal state tracker storing the current value, last update time, rate function, and threshold set. Handles time-based calculation and threshold crossing detection.

  3. Threshold management: HasObservables.add_threshold() provides a clean API for registering callbacks when values cross specific thresholds (upward or downward). Uses the existing signal/observer pattern - thresholds emit "threshold_crossed" signals.

  4. Time integration: Automatically detects time source from model.simulator.time, model.time, or falls back to model.steps. Works with both DEVSimulator (float time) and ABMSimulator (integer steps).

Key design decisions:

  • Lazy evaluation prevents unnecessary calculations when states aren't accessed
  • Threshold storage uses a set (values only), callbacks managed through signal subscriptions to avoid duplicate invocations
  • Linear integration for simplicity; extensible to more complex methods
  • Fully integrated with existing Computed properties and dependency tracking

API usage examples

# Continuous value with rate per time unit
energy = ContinuousObservable(initial_value: float, rate_func: (value, elapsed, agent) -> float)

# Set/adjust value (emits "change" + checks thresholds)
self.energy = 120.0
self.energy += 5.0

# Thresholds (fires "threshold_crossed" with signal.threshold, signal.direction)
self.add_threshold("energy", 0.0, callback)

# Subscribe to signals
self.observe("energy", "change", on_change)
self.observe("energy", "threshold_crossed", on_threshold)

# Computed properties that depend on observables
is_hungry = Computable()
self.is_hungry = Computed(lambda: self.energy < 50.0)

Usage Examples

Basic continuous energy depletion:

class Wolf(Agent, HasObservables):
    energy = ContinuousObservable(
        initial_value=100.0,
        rate_func=lambda value, elapsed, agent: -agent.metabolic_rate
    )
    
    def __init__(self, model):
        super().__init__(model)
        self.metabolic_rate = 0.5
        self.energy = 100.0
        
        # Die when energy reaches zero
        self.add_threshold("energy", 0.0, self._on_death)
    
    def _on_death(self, signal):
        if signal.direction == "down":
            self.remove()

Reactive behaviors with computed properties:

class Animal(Agent, HasObservables):
    energy = ContinuousObservable(
        initial_value=100.0,
        rate_func=lambda value, elapsed, agent: -agent.metabolic_rate
    )
    is_hungry = Computable()
    
    def __init__(self, model):
        super().__init__(model)
        self.energy = 100.0
        # Computed property automatically updates when energy changes
        self.is_hungry = Computed(lambda: self.energy < 50.0)
    
    def move(self):
        if self.is_hungry:
            # Hunt actively when hungry
            self.seek_food()
        else:
            # Conserve energy when not hungry
            self.wander()

Enhanced Wolf-Sheep model: The updated example demonstrates continuous energy dynamics, threshold-triggered death/starvation modes, and behavior switching based on computed hunger states. Energy depletes continuously between steps rather than in discrete chunks, creating more realistic predator-prey dynamics.

Additional Notes

  • Tests: Comprehensive test suite (19 tests) covering basic functionality, signals, edge cases (numpy floats, zero elapsed time, exact thresholds), and integration scenarios
  • Backward compatible: Existing mesa_signals code unchanged; ContinuousObservable is an additive feature
  • Performance: Lazy evaluation ensures minimal overhead; states only recalculate when accessed
  • Future extensions: Foundation for more complex behavioral patterns (needs-based architectures, homeostatic agents) discussed in #2538
  • Time management: Uses TODO comment to reference #2228 regarding universal time handling in Mesa
  • Documentation: Wolf-Sheep example serves as practical demonstration; could expand docs with more use cases

This provides the first of two foundational building blocks identified for Mesa's behavioral framework: reactive state management with continuous dynamics. The second block (action/task scheduling) can build naturally on this foundation.

EwoutH avatar Oct 17 '25 11:10 EwoutH

I'm a bit mentally exhausted, was a big push to get this finished. I will write up some review points and context later this weekend.

The individual commit messages hopefully contain useful details.

For now:

  1. Maybe the new ContinuousObservable and ContinuousState classes should go into a separate file instead of mesa_signals.py.
  2. Yes, I know using ContiniousStates with an ABMSimulator (in the wolf_sheep example) doesn't make that much sense in the current implementation. Future work.
  3. We really should re-address https://github.com/projectmesa/mesa/discussions/2228 sooner than later. model.time always being accessible would make stuff much easier.

Curious on initial thoughts!

(CI test failures are unrelated, see https://github.com/widgetti/solara/issues/1110)

EwoutH avatar Oct 17 '25 11:10 EwoutH

I think this idea is very cool. I hope to find time next week to take a closer look.

One quick question: how is threshold management squared with the lazy evaluation of ContinousStates/ContinuousObservables?

quaquel avatar Oct 17 '25 11:10 quaquel

Great question:

how is threshold management squared with the lazy evaluation of ContinousStates/ContinuousObservables?

Lazy evaluation and threshold detection work together because thresholds are checked during value recalculation, not continuously. When someone accesses a ContinuousObservable (e.g., agent.energy), the system calculates how much time has elapsed since the last access, computes the new value, and then checks if any thresholds were crossed during that transition.

The key is that we check the range between old and new values, not discrete sample points. With linear integration (value + rate * elapsed_time), we can mathematically determine if a threshold was crossed even if we never computed the value at that exact moment. For example, if energy goes from 100 β†’ 40 over 60 time units and there's a threshold at 50, we detect that crossing because old_value (100) > threshold (50) >= new_value (40).

Important to note: Thresholds fire when values are accessed, not at the exact simulation time they're crossed. If an agent's energy crosses zero at t=50 but nothing accesses it until t=100, the death threshold fires at t=100. This is a fundamental trade-off with lazy evaluation: efficiency in exchange for non-deterministic firing times.

In practice, this isn't an issue for most models because agents access their states during decision-making (e.g., checking is_hungry during movement) and data collectors sample states regularly. For the Wolf-Sheep example, agents effectively check their energy every step through computed properties and movement decisions.

An "eager mode" could be added in the future where registering a threshold automatically schedules a discrete event at the calculated crossing time, providing deterministic timing at the cost of additional event management overhead.

What we do need to enable is non-linear rate functions working with thresholds.

EwoutH avatar Oct 17 '25 12:10 EwoutH

@EwoutH This is very cool! Thanks for this PR

tpike3 avatar Oct 19 '25 11:10 tpike3

Thanks for your early feedback! I will be in a night train Tuesday evening to Wednesday morning, I probably have some time to further look into it there.

EwoutH avatar Oct 19 '25 16:10 EwoutH

Threshold detection for non-linear rate functions is absolutely not trivial. I explored some options here:

  • https://github.com/projectmesa/mesa/discussions/2854

I'm not sure which direction I'm leaning personally yet.

EwoutH avatar Oct 21 '25 19:10 EwoutH

@quaquel review with academic literature in mind. Also pay attention to APi and naming.

time will be property, keyword for simulator on instancing.

EwoutH avatar Nov 11 '25 12:11 EwoutH

@coderabbitai full review

tpike3 avatar Nov 16 '25 13:11 tpike3

βœ… Actions performed

Full review triggered.

coderabbitai[bot] avatar Nov 16 '25 13:11 coderabbitai[bot]

Walkthrough

Introduces a reactive, continuous energy model to the wolf-sheep ecosystem simulation. ContinuousObservable enables time-based energy depletion tied to metabolic rates. Animals now use computed reactive properties responding to energy thresholds, with lifecycle callbacks triggering at critical levels. Model integrates a time-based simulator and tracks average energy per species.

Changes

Cohort / File(s) Summary
Mesa Signals Infrastructure
mesa/experimental/mesa_signals/mesa_signal.py, mesa/experimental/mesa_signals/__init__.py
Added public ContinuousObservable class supporting time-based value evolution with threshold crossing detection and callbacks. Implemented add_threshold method on HasObservables for registering thresholds and callbacks. Extended all exports to include ContinuousObservable.
Wolf-Sheep Agent Behavior
mesa/examples/advanced/wolf_sheep/agents.py
Refactored Animal to inherit from HasObservables with energy as ContinuousObservable decaying via metabolic_rate. Added reactive computed properties is_hungry and can_reproduce. Implemented lifecycle callbacks _on_energy_depleted and _on_critical_hunger for threshold-based state changes. Updated Sheep and Wolf movement/feeding logic to prioritize based on computed hunger state. Converted GrassPatch to HasObservables with observable fully_grown and regrowth scheduling.
Wolf-Sheep Model Integration
mesa/examples/advanced/wolf_sheep/model.py
Integrated simulator for time-based energy dynamics. Extended data collection with "Avg Wolf Energy" and "Avg Sheep Energy" reporters. Updated agent initialization to accept explicit energy values derived from RNG.
Continuous Observable Tests
tests/test_continuous_observables.py
Comprehensive test suite covering ContinuousObservable behavior, variable rate handling, threshold mechanics, computed properties, multi-agent scenarios, predator-prey interactions, and batch agent creation with per-agent thresholds.

Sequence Diagram(s)

sequenceDiagram
    participant Simulator
    participant Animal
    participant EnergyObs as ContinuousObservable<br/>(energy)
    participant Computed as Computed<br/>(is_hungry)
    participant Callback as Lifecycle<br/>Callback

    Simulator->>Animal: step()
    Note over Simulator: Time advances
    
    Animal->>EnergyObs: get energy<br/>(queries elapsed time)
    activate EnergyObs
    EnergyObs->>EnergyObs: calculate new value<br/>using rate_func
    EnergyObs->>EnergyObs: check_thresholds()
    alt Threshold Crossed
        EnergyObs->>Callback: emit threshold_crossed signal
        Callback->>Callback: execute callback<br/>(e.g., _on_energy_depleted)
    end
    EnergyObs-->>Animal: return updated energy
    deactivate EnergyObs
    
    Animal->>Computed: read is_hungry
    activate Computed
    Computed->>EnergyObs: evaluate lambda<br/>(energy < threshold)
    EnergyObs-->>Computed: return energy
    Computed-->>Animal: return is_hungry result
    deactivate Computed
    
    alt is_hungry == true
        Animal->>Animal: prioritize food-seeking<br/>movement
    else
        Animal->>Animal: default wandering
    end

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Careful attention needed: ContinuousObservable threshold detection logic and time-based value calculation in mesa_signal.py β€” verify threshold crossing boundary conditions, time fallback logic, and signal emission correctness
  • Cross-system interaction: Verify simulator integration in agents.py and model.py properly initializes and accesses timing information for continuous energy depletion
  • Behavioral logic: Confirm Sheep and Wolf movement/feeding decisions correctly prioritize computed properties and handle reactive state changes without unintended side effects
  • Test coverage: Review test suite scope for edge cases in threshold detection, multi-agent independence, and numpy compatibility

Poem

🐰 Observe the energy flowing soft and slow,
Through thresholds crossed where hungry bunnies go,
With signals firing as the time moves near,
The reactive creatures nibble without fear,
Continuous depletion, renewal tooβ€”
Mesa's pulse now beats so steady and true! 🌿

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.17% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check ❓ Inconclusive The title 'Add ContinuousStates and -Observables' is vague and uses non-standard phrasing with 'and -Observables' instead of 'and Observables', making it unclear and potentially misleading about the actual changes. Use a clearer, more standard title such as 'Add ContinuousObservable for continuous agent state dynamics' to accurately reflect the main feature being introduced.
βœ… Passed checks (1 passed)
Check name Status Explanation
Description check βœ… Passed The pull request provides a comprehensive description covering summary, motive, implementation details, API usage examples, and additional notes.
✨ Finishing touches
  • [ ] πŸ“ Generate docstrings
πŸ§ͺ Generate unit tests (beta)
  • [ ] Create PR with unit tests
  • [ ] Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❀️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

coderabbitai[bot] avatar Nov 16 '25 13:11 coderabbitai[bot]

I'm fine with reviewers using AI tools to help them review a PR, but I'm not triaging AI comments. So @tpike3 please triage the comments and resolve the ones that are not highly relevant in this stage in het PR review process.

EwoutH avatar Nov 16 '25 13:11 EwoutH

I'm fine with reviewers using AI tools to help them review a PR, but I'm not triaging AI comments. So @tpike3 please triage the comments and resolve the ones that are not highly relevant in this stage in het PR review process.

@tpike3 I will hide the AI-review for now. Feel free to unhide it if you find anything relevant.

EwoutH avatar Nov 29 '25 13:11 EwoutH

I updated this PR based on the new unified model.time added in https://github.com/mesa/mesa/pull/2903.

If everyone agrees this is the direction to go, I will fix the last bugs in the example models. Please review critically.

EwoutH avatar Dec 17 '25 10:12 EwoutH