Skip to content

Conversation

@EwoutH
Copy link
Member

@EwoutH EwoutH commented Oct 17, 2025

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 Behavioral Framework #2538
  • Time management: Uses TODO comment to reference The time variable in mesa.Model #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.

Add ContinuousObservable class that extends mesa_signals to support agent properties that change continuously over time. This enables modeling realistic phenomena like energy depletion, resource growth, and gradual state changes without manual step-by-step updates.

Key features:
- Lazy evaluation: values only recalculated when accessed, improving performance
- Threshold detection: automatic callbacks when values cross specified thresholds in either direction
- Configurable rate functions: support linear, exponential, or custom change patterns
- Full integration with existing mesa_signals reactive system: works seamlessly with Observable, Computable, and dependency tracking
- Time-aware: integrates with Mesa's time system for accurate elapsed time calculations

This provides a foundational building block for modeling needs-based architectures, continuous resource systems, and time-dependent agent behaviors while maintaining the composability and reactivity of mesa_signals.

fix imports
Update the wolf-sheep predation example to demonstrate mesa_signals and ContinuousObservable capabilities. Energy now depletes continuously over time rather than in discrete steps, and agent behaviors react automatically to energy state changes.

Changes:
- Convert Animal base class to use ContinuousObservable for energy tracking with configurable metabolic rates
- Add computed properties (is_hungry, can_reproduce) that automatically update based on energy levels
- Implement threshold-triggered events for death (energy reaches zero) and survival mode (critical hunger)
- Enhance decision logic: sheep prioritize grass when hungry, wolves hunt actively when hungry
- Add activity-based metabolic rates (movement costs more energy than resting)
- Improve GrassPatch to use Observable and react to state changes with signal handlers
- Expand data collection to track average energy levels across populations

The example now demonstrates key reactive programming patterns: observable state management, computed properties for decision making, threshold-based event triggering, and automatic dependency tracking. This makes the model more realistic (continuous dynamics) while reducing boilerplate code (declarative state management).

fix agent imports
Implement flexible time retrieval in ContinuousObservable to work with different Mesa time management approaches. The _get_time() method tries multiple sources in priority order:

1. model.simulator.time (DEVS/continuous models)
2. model.time (if explicitly set)
3. model.steps (fallback for discrete models)

This workaround enables ContinuousObservable to function correctly regardless of whether a model uses discrete event simulation, custom time tracking, or standard step-based progression.

Obviously this should be fixed structurally. See projectmesa#2228
The ContinuousObservable descriptor was inheriting Observable's __set__ method, which stored raw numeric values instead of ContinuousState objects. This caused AttributeError when __get__ tried to access state.last_update on a float/numpy.float64.

The new __set__ method creates a ContinuousState wrapper on first assignment and updates the existing state on subsequent assignments. This ensures the private attribute always contains a properly structured state object with value, last_update, rate_func, and thresholds attributes.

Fixes initialization of continuous observables in agent __init__ methods where energy and other time-varying properties are set.
Changes the ContinuousObservable threshold system from storing callbacks
directly to using a signal-based subscription pattern:

- Change `_thresholds` from dict to set (stores threshold values only)
- Update `add_threshold()` to check for existing subscriptions before
  subscribing to prevent duplicate callback invocations
- Separate concerns: thresholds define WHICH values to watch, observers
  define WHO to notify when crossed
- Add ValueError when attempting to add threshold to non-ContinuousObservable

This fixes an issue where registering multiple thresholds with the same
callback would cause it to be called multiple times per threshold crossing.
Adds test_continuous_observables.py with 19 tests covering the full  functionality of continuous state management in mesa_signals:

Basic functionality:
- Constant and variable rate depletion over time
- Manual value setting (e.g., energy boost from eating)
- Time-based lazy evaluation

Signal emissions:
- Change notifications when values update
- Threshold crossing detection (upward and downward)
- Multiple threshold management
- No signals when values don't change

Edge cases:
- Zero elapsed time (no spurious updates)
- Multiple accesses at same time point (single calculation)
- Exact threshold values (inclusive boundary handling)
- Negative values (energy going below zero)
- NumPy float compatibility (fixes AttributeError with random values)

Integration:
- Computed properties based on continuous observables
- Batch agent creation with numpy arrays
- Both DEVSimulator (float time) and ABMSimulator (integer steps)
- Wolf-sheep predator-prey scenario with death thresholds

All tests use proper discrete event scheduling through Mesa's simulator rather than manual time manipulation, ensuring realistic simulation behavior.
@EwoutH EwoutH requested a review from quaquel October 17, 2025 11:35
@EwoutH EwoutH added the feature Release notes label label Oct 17, 2025
@EwoutH
Copy link
Member Author

EwoutH commented Oct 17, 2025

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 The time variable in mesa.Model #2228 sooner than later. model.time always being accessible would make stuff much easier.

Curious on initial thoughts!

(CI test failures are unrelated, see widgetti/solara#1110)

@quaquel
Copy link
Member

quaquel commented Oct 17, 2025

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?

BREAKING: Thresholds are now stored per-instance instead of per-class.

Previously, ContinuousObservable stored thresholds at the class level in a shared `_thresholds` set. This caused all instances to check the same threshold values, preventing agents from having individual thresholds based on their specific parameters (e.g., a wolf with 100 starting energy couldn't have a different critical threshold than one with 50 starting energy).

Changes:
- Move threshold storage from descriptor to ContinuousState instances
- Change threshold structure from set to dict: {threshold_value: set(callbacks)}
- Update add_threshold() to access and modify instance-level state
- Ensure threshold initialization happens before adding thresholds
- Add direction parameter to all threshold_crossed signal emissions

This allows each agent to maintain its own set of thresholds, enabling instance-specific reactive behaviors like `self.add_threshold("energy", self.starting_energy * 0.25, callback)`.
@EwoutH
Copy link
Member Author

EwoutH commented Oct 17, 2025

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.

self.subscribers[observable][signal_type] = active_observers

def add_threshold(self, observable_name: str, threshold: float, callback: Callable):
"""Convenience method for adding thresholds."""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just nitpicking, but this docstring can use some further elaboration beyond the one line summary. For example, it might be good to give a short explanation of what a treshold is and how it only applies to continuous observables.


return state.value

# TODO: A universal truth for time should be implemented structurally in Mesa. See https://github.com/projectmesa/mesa/discussions/2228
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes this really drives home the need for a proper solution.

Also, time should probably be observable, or easy to be made observable, in which case your ContinuousObservable becomes a subclass of Computable.

Returns:
List of (threshold_value, direction) tuples for crossed thresholds
"""
crossed = []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you indicate whether you are only interested in moving above or moving below, or do you get any crossing of the threshold?

Copy link
Member

@quaquel quaquel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see anything obviously wrong here. However, it's been a while since I looked at this code, so I might take another look at this PR annd the rest of signals next week. In the meantime, three clarifying questions.

@tpike3
Copy link
Member

tpike3 commented Oct 19, 2025

@EwoutH This is very cool! Thanks for this PR

@EwoutH
Copy link
Member Author

EwoutH commented Oct 19, 2025

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
Copy link
Member Author

EwoutH commented Oct 21, 2025

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

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

@EwoutH EwoutH requested a review from tpike3 October 28, 2025 16:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Release notes label

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants