Conversation
|
Performance benchmarks:
|
|
On open issue number 2, I am exploring an idea and would appreciate some feedback. I am thinking of adding an @observable("agents", ModelSignals.AGENT_ADDED, when="after")
def register_agent(self, agent):
...In this case, an AGENT_ADDED signal will be emitted after register_agent completes. This signal is part of the What is nice about this approach is that signals are no longer limited to attributes and attribute changes, but can be used more broadly. However, there are also various drawbacks and unresolved issues. First, Second, the observable computed stuff is tricky as it is. I don't yet know how these kinds of additional signals would be implemented in it. The |
|
Thanks for this effort. I will try to dive in later this week(end). |
|
I’ve been exploring the decorator approach and I think we are moving to the right direction but some minor suggestions from my side
We don't need manual registration. We can extend If we have the decorator tag the method with an attribute (e.g., def descriptor_generator(obj):
seen_observables = {}
for base in type(obj).__mro__:
for name, entry in base_dict.items()
# existing checks
# Check for @emits decorated methods
elif callable(entry) and hasattr(entry, "_emits_signal"):
obs_name, signal_type = entry._emits_signal
if obs_name not in seen_observables:
seen_observables[obs_name] = set()
# Accumulate signals
seen_observables[obs_name].add(signal_type)
for observable_name, signal_types in seen_observables.items():
yield observable_name, signal_typesThis naturally handles the "multiple signals per observable" case (like
I agree with separating the naming.
I would advise keeping
Trying to make a computed property depend on an event signal (like Example of why separation matters: class Model(HasObservables):
time = Observable()
# This makes sense - computed depends on Observable
@computed
def agent_count(self):
return len(self._agents)
# This does NOT make sense - computed depending on events
@computed
def something(self):
# What would it mean to depend on "agent added" event?
# Events are point-in-time, not continuous state
passIf you need to react to events, use observation: def __init__(self):
super().__init__()
self.agent_count = 0
# React to events to update state
self.observe("agents", ModelSignals.AGENT_ADDED,
lambda s: setattr(self, 'agent_count', self.agent_count + 1))
|
|
Thanks a lot for the write-up. It cleanly and clearly summarizes what I have been figuring out this afternoon. I'll update this PR hopefully later today along these lines. Also, @codebreaker32, I was thinking of changing |
|
This is somewhat the rough sketch I was working on, Might help. class emits:
def __init__(
self,
observable_name: str,
signal_type: SignalType,
when: Literal["before", "after"] = "after",
include_result: bool = False,
):
self.observable_name = observable_name
self.signal_type = signal_type
self.when = when
self.include_result = include_result
def __call__(self, func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(instance: HasObservables, *args, **kwargs):
# If no one is listening, skip ALL signal logic.
if not instance._has_subscribers(self.observable_name, self.signal_type):
return func(instance, *args, **kwargs)
# ASSUMPTION: First arg is the payload (e.g. agent) It works for now, might optimise later
# We capture this for signal.new convenience.
primary_arg = args[0] if args else None
# Emit "before" if requested
if self.when in ("before"):
instance.notify(
self.observable_name,
old_value=None,
new_value=primary_arg,
signal_type=self.signal_type,
)
# Execute actual method
result = func(instance, *args, **kwargs)
# Emit "after" if requested
if self.when in ("after"):
signal_kwargs = {}
if self.include_result:
signal_kwargs["result"] = result
instance.notify(
self.observable_name,
old_value=None,
new_value=primary_arg,
signal_type=self.signal_type,
**signal_kwargs,
)
return result
# Tag for auto-discovery by descriptor_generator
wrapper._emits_signal = (self.observable_name, self.signal_type)
return wrapper
You mean |
|
I initially added a "both" literal also in the |
Glad it helped :) |
|
Ok, I pushed an update that builds on all the input. Again, thanks a lot.
There is still some work left to be done:
|
Good question, I don't have a strong opinion about this. Note also that Observables emit CHANGED. We could change the list signals to be past tense for consistency.
First, emit allows controllign whether to emit before or after
At the moment everything is tied to AGENT_ADDED/AGENT_REMOVED, so you would have to filter in the handler, which is relatively cheap to do. In #3227, I list ideas for future extensions to mesa_signals that might allow more fine grained approaches at the Agent class level.
Mesa_signals is experimental and was not used in any of our own examples. I doubt it is a feature users are using extensively in real models at the moment, so I don't see the need for this. |
Sounds good.
Fair, if it turns out to be a real need we can always deal with it later.
Cool, let's discuss after this PR.
Aside from the backwards compatibility, |
The problem is that old and new are only meaningful for The other possible design is to build an entire hierarchy of messages with their own unique fields. But this becomes very unwieldy because now in notify you have to figure out which message type to send and you need a complex registration system for user defined signals and message types. |
|
That gives some context, thanks. I'm good with it. |
|
Small update in light of the feedback
|
|
I think these aren't yet addressed, right?
|
The api changes are already noted, so not sure what more would be needed. I want to open a separate issue on adding an overview of signals and message payloads to the docs somewhere. What do you mean with an example? How to use the new decorator? |
|
With experimental features we often don't have tutorials or example models, so the PR and its description is the only part where you can see more than the source code. We want power users to use and test our experimental features, and we often link to the PR in the release notes/highlights. Therefore I always like our PR description to be up to spec, like following the PR feature template when applicable. It helps users to get started and us to remind why and how we did things. The way the PR templates are currently structured, they have dual-use for developers and early adopters (as a quick-start guide). Practically, LLMs are great in helping with this. I often write the motivation myself and then throw in the diff (just add If you don't agree on the usefulness of PR descriptions (for experimental features) or the PR templates itself, let's discuss that separately. |
EwoutH
left a comment
There was a problem hiding this comment.
Approving, most changes are in the experimental space and changes to the stable model.py are minimal, easily revertible and non-breaking.
|
I agree on the PR stuff, but this is part of a series of PRs that, once they come together, must be illustrated by examples and in the PR descriptions. |
|
That's a fair point. I would approach it this way: Write up the PR description of what this PR does now, and if it (significantly) changes in the feature, just add a line on top: This PR is outdated, see #XXXX for the current approach |
|
So we can now subscribe to
|
|
You have to handle that inside the handler. As it should be in my view. |
|
Could you give a minimal example? |
|
You can look at th #3145, where in the handler it is checked. but a minimum example would be: def my_handler(message:Message):
delta = message.kwargs["new"] - message.kwargs["old"]
if delta < 1:
return
# my handler code
# if you want memory for your last action
class MyHandler:
def __init__(self):
self.last_action = 0
def __call__(message:Message):
new = message.kwargs["new"]
delta = new - self._last_action
if delta < 1:
return
self.last_action = new
# my action |
|
Looks complex. But maybe I'm overthinking this. If you want to do sometime every 1 timestep anyways, just schedule an recurring event. |
It really depends on what you want to achieve. I would, in general, use event scheduling for simulation stuff, while using signals for looser coupling to e.g., a UI or a data recorder. |
|
I think time is important enough to warrant some default handlers. Especially if they can take a Schedule. |
It might be quite easy to have a base handler class that takes a schedule, or even instantiate a class with a schedule and a callable. |
Thinking about this a bit more. This conversation focussed mostly on time being observable. In that context, there might be a use case for a default handler. However, I don't think that this generalizes to observables/emitters as such. |
This PR makes
model.timeobservable, and a signal is emitted when an agent is registered or deregistered.This draft pr does the following
ModelextendsHasObservablesModel.timeis declared as beingObservable@emitdecorator that can be used onHasObservablemethods@emittomodel.register_agentsandmodel.deregister_agents, which emits a signal from the newModelSignalsenum@computedto@computed_propertyObservableSignals,ListSignals, andModelSignals. Under conditions, subclassing of enums is allowed, see the python docs.HasObservables.notifyto fully remove any redundancy if there are no subscribers to a signal. The only overhead in that case is a single method call and an if check.Messagedataclass so it can be used generically instead of being tied toObservableSignals.CHANGE.With this pr, the following things have become possible
The handler will be called with a
Messageinstance. This is a dataclass withname,owner,signal_type, andadditional_kwargsas fields.nameis the name of the observable (e.g., time, agents, name_of_observable).owneris theHasObservableinstance emitting the signal.signal_typeis the type of signal (e.g.,ObservableSignals.CHANGED).additional_kwargscontains the payload and this will differ across signal types.The table below lists the fields for all signal types. Note here that anything sent by
@emitwill just include all arguments (args) and all keyword arguments of the method call in the payload.old,newargsargsold,newindex,newindex,newindex,oldindex,old,newcloses #3211