From 2df286f3eb91b6d10d1e34f775a79356502986f0 Mon Sep 17 00:00:00 2001 From: "felix.stegemerten" Date: Thu, 8 Jan 2026 13:50:20 +0100 Subject: [PATCH 1/5] Adjust result handling --- agentlib/modules/simulation/simulator.py | 122 ++++++++++++------ .../t_sample_simulation_demonstration.py | 5 +- 2 files changed, 90 insertions(+), 37 deletions(-) diff --git a/agentlib/modules/simulation/simulator.py b/agentlib/modules/simulation/simulator.py index 2bbb5a4c..81749987 100644 --- a/agentlib/modules/simulation/simulator.py +++ b/agentlib/modules/simulation/simulator.py @@ -83,8 +83,10 @@ def write_results(self, file: str): header = not Path(file).exists() self.df().to_csv(file, mode="a", header=header) # keep the last row of the results - self.index = [self.index[-1]] - self.data = [self.data[-1]] + # self.index = [self.index[-1]] + # self.data = [self.data[-1]] + self.index = [] + self.data = [] def read_simulator_results(file: str): @@ -365,6 +367,7 @@ def __init__(self, *, config: dict, agent: Agent): ) self._save_count: int = 1 # tracks, how often results have been saved self._inputs_changed_since_last_results_saving = False + self.temp_store_value = False self._register_input_callbacks() self.logger.info("%s initialized!", self.__class__.__name__) @@ -497,17 +500,39 @@ def process(self): for _idx, _t_sample in enumerate(t_samples[:-1]): _t_start = self.env.now + self.config.t_start dt_sim = t_samples[_idx + 1] - _t_sample + # Three cases to cover for input change: + # 1. No change at all + # 2. Change before do_step + # 3. change after do_step + + # Check if inputs changed BEFORE do_step (they're already active at env.time) + inputs_changed_before_dostep = self._inputs_changed_since_last_results_saving + self.model.do_step(t_start=_t_start, t_sample=dt_sim) - if _idx == len(t_samples) - 2 or self._inputs_changed_since_last_results_saving: - if not self._inputs_changed_since_last_results_saving: - # Did not change during simulation step - timestamp_inputs = _t_start_simulation_loop + + # Check if inputs changed AFTER do_step (they'll be active at next env.time) + # TODO: theoretically could change on both occasions + inputs_changed_after_dostep = self._inputs_changed_since_last_results_saving + if _idx == len(t_samples) - 2 or inputs_changed_before_dostep or inputs_changed_after_dostep: + # Determine when inputs became active + if inputs_changed_before_dostep or inputs_changed_after_dostep: + if inputs_changed_before_dostep: + # Inputs changed before do_step, so they were used in this simulation + # They're active from the start of this simulation loop + timestamp_inputs = self.env.time + else: + # Inputs changed after do_step, so they weren't used yet + # They're active from the next time step (env.time + dt_sim) + timestamp_inputs = self.env.time + dt_sim else: - # The inputs are only applied at self.env.time, not when they are received by the communicator - timestamp_inputs = self.env.time + # No change during step, inputs were active from the start + timestamp_inputs = _t_start_simulation_loop + + # Outputs are always active at the end of the simulation step + timestamp_outputs = self.env.time + dt_sim # Update the results self._update_results( - timestamp_outputs=self.env.time + dt_sim, + timestamp_outputs=timestamp_outputs, timestamp_inputs=timestamp_inputs ) yield self.env.timeout(dt_sim) @@ -568,51 +593,76 @@ def _update_results(self, timestamp_outputs, timestamp_inputs): """ Adds model variables to the SimulationResult object at the given timestamp. + + timestamp_outputs describes the timestamp for which the outputs are active + timestamp_inputs describes the timestamp for which the inputs are active """ + if not self.config.save_results: return inp_values = [var.value for var in self._get_result_input_variables()] - self._inputs_changed_since_last_results_saving = False - out_values = [var.value for var in self._get_result_output_variables()] _len_outputs = len(out_values) + _len_inputs = len(inp_values) + + # Four cases to handle: + # 1. Both timestamps are the same - store both in one row + # 2. Inputs changed before do_step (timestamp_inputs == timestamp_outputs) - same as case 1 + # 3. Inputs changed after do_step (timestamp_inputs == timestamp_outputs but both equal to end time) + # 4. Invalid case (timestamp_inputs > timestamp_outputs) - # Two cases: - # - Either both timestamps are the same and new, add them both - # - or the inputs are for an earlier timestamp -> first add the inputs and then append the new outputs index. if timestamp_inputs == timestamp_outputs: - # self.logger.debug("Storing data at the same time stamp %s s", timestamp_outputs) - self._result.index.append(timestamp_outputs) - self._result.data.append(out_values + inp_values) + # Inputs and outputs are active at the same timestamp + # Check if this timestamp already exists (e.g., from storing outputs earlier) + if timestamp_outputs in self._result.index: + # Update the existing row with input values + idx = self._result.index.index(timestamp_outputs) + # Keep existing outputs, update inputs + self._result.data[idx] = self._result.data[idx][ + :_len_outputs] + inp_values + else: + # New timestamp, store both + self._result.index.append(timestamp_outputs) + self._result.data.append(out_values + inp_values) + + self._inputs_changed_since_last_results_saving = False + elif timestamp_inputs < timestamp_outputs: - # add inputs in the time stamp before adding outputs, as they are active from - # the start of this interval + # Inputs became active at an earlier time than outputs + # This happens when inputs changed during a simulation step + + # First, store the inputs at their activation timestamp if timestamp_inputs in self._result.index: - if timestamp_inputs == self._result.index[-1]: - # self.logger.debug("Adding inputs to last time stamp %s s", timestamp_inputs) - self._result.data[-1] = self._result.data[-1][:_len_outputs] + inp_values - # else: pass, as inputs are outdated (have been changed during simulation step) + # Timestamp already exists, update input values + idx = self._result.index.index(timestamp_inputs) + self._result.data[idx] = self._result.data[idx][ + :_len_outputs] + inp_values else: - # This case may occur if inputs changed during simulation. - # In this case, the inputs hold for current time - t_sample_simulation, but the outputs - # hold for the current time. In this case, just add Nones as outputs. + # New timestamp for inputs only self._result.index.append(timestamp_inputs) self._result.data.append([None] * _len_outputs + inp_values) - # self.logger.debug( - # "Storing inputs only due to changes during simulation at time stamp %s s", - # timestamp_inputs - # ) - - # self.logger.debug("Storing outputs at time stamp %s s", timestamp_outputs) - self._result.index.append(timestamp_outputs) - self._result.data.append(out_values + [None] * len(inp_values)) + + # Now store the outputs at their timestamp + if timestamp_outputs in self._result.index: + # Timestamp already exists (shouldn't normally happen, but handle it) + idx = self._result.index.index(timestamp_outputs) + self._result.data[idx][:_len_outputs] = out_values + else: + self._result.index.append(timestamp_outputs) + self._result.data.append(out_values + [None] * _len_inputs) + + self._inputs_changed_since_last_results_saving = False + else: - raise ValueError("Storing inputs ahead of outputs is not supported.") + raise ValueError(f"Storing inputs ahead of outputs is not supported. " + f"timestamp_inputs={timestamp_inputs}, timestamp_outputs={timestamp_outputs}") + # Write results to file periodically if ( self.config.result_filename is not None - and timestamp_outputs // (self.config.write_results_delay * self._save_count) > 0 + and timestamp_outputs // ( + self.config.write_results_delay * self._save_count) > 0 ): self._save_count += 1 self._result.write_results(self.config.result_filename) diff --git a/examples/multi-agent-systems/room_mas/t_sample_simulation_demonstration.py b/examples/multi-agent-systems/room_mas/t_sample_simulation_demonstration.py index a5723c6e..7d37dd2d 100644 --- a/examples/multi-agent-systems/room_mas/t_sample_simulation_demonstration.py +++ b/examples/multi-agent-systems/room_mas/t_sample_simulation_demonstration.py @@ -37,6 +37,8 @@ "t_sample_communication": 50, "t_sample_simulation": 15, "save_results": True, + "result_filename": "results.csv", + "overwrite_result_file": True, "log_level": "DEBUG", "inputs": [ { @@ -96,6 +98,7 @@ def run_example(until, with_plots=True, log_level=logging.INFO): room_cfg["id"] = f"{t_sample_com}_{t_sample_sim}" room_cfg["modules"]["room"]["t_sample_communication"] = t_sample_com room_cfg["modules"]["room"]["t_sample_simulation"] = t_sample_sim + room_cfg["modules"]["room"]["result_filename"] = f"results_{t_sample_com}_{t_sample_sim}.csv" agent_configs.append(room_cfg) mas = LocalMASAgency( @@ -107,7 +110,7 @@ def run_example(until, with_plots=True, log_level=logging.INFO): # Simulate mas.run(until=until) # Load results: - results = mas.get_results(cleanup=True) + results = mas.get_results(cleanup=False) if not with_plots: return results From a0f23ae40d3c0e84d83f557af0c288c78c029214 Mon Sep 17 00:00:00 2001 From: "felix.stegemerten" Date: Thu, 8 Jan 2026 15:11:46 +0100 Subject: [PATCH 2/5] Reduce indexes --- agentlib/modules/simulation/simulator.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/agentlib/modules/simulation/simulator.py b/agentlib/modules/simulation/simulator.py index 81749987..22aace0c 100644 --- a/agentlib/modules/simulation/simulator.py +++ b/agentlib/modules/simulation/simulator.py @@ -495,9 +495,11 @@ def process(self): t_end=self.config.t_sample_communication, dt=self.config.t_sample_simulation ) - _t_start_simulation_loop = self.env.time + self.logger.debug("Doing simulation steps %s", t_samples) for _idx, _t_sample in enumerate(t_samples[:-1]): + _t_start_substep = self.env.time + _t_start = self.env.now + self.config.t_start dt_sim = t_samples[_idx + 1] - _t_sample # Three cases to cover for input change: @@ -519,14 +521,14 @@ def process(self): if inputs_changed_before_dostep: # Inputs changed before do_step, so they were used in this simulation # They're active from the start of this simulation loop - timestamp_inputs = self.env.time + timestamp_inputs = _t_start_substep else: # Inputs changed after do_step, so they weren't used yet # They're active from the next time step (env.time + dt_sim) timestamp_inputs = self.env.time + dt_sim else: # No change during step, inputs were active from the start - timestamp_inputs = _t_start_simulation_loop + timestamp_inputs = _t_start_substep # Outputs are always active at the end of the simulation step timestamp_outputs = self.env.time + dt_sim From 0037aa66577756ca983786bc7956b0fa33cab2b8 Mon Sep 17 00:00:00 2001 From: "felix.stegemerten" Date: Thu, 8 Jan 2026 23:28:24 +0100 Subject: [PATCH 3/5] First working version --- agentlib/modules/simulation/simulator.py | 266 +++++++++--------- .../t_sample_simulation_demonstration.py | 12 +- 2 files changed, 137 insertions(+), 141 deletions(-) diff --git a/agentlib/modules/simulation/simulator.py b/agentlib/modules/simulation/simulator.py index 22aace0c..d1be0a45 100644 --- a/agentlib/modules/simulation/simulator.py +++ b/agentlib/modules/simulation/simulator.py @@ -82,11 +82,9 @@ def write_results(self, file: str): """ header = not Path(file).exists() self.df().to_csv(file, mode="a", header=header) - # keep the last row of the results - # self.index = [self.index[-1]] - # self.data = [self.data[-1]] - self.index = [] - self.data = [] + # keep the last row of the results, as it is not finished (inputs missing) + self.index = [self.index[-1]] + self.data = [self.data[-1]] def read_simulator_results(file: str): @@ -104,6 +102,7 @@ class SimulatorConfig(BaseModuleConfig): outputs: AgentVariables = [] states: AgentVariables = [] shared_variable_fields: List[str] = ["outputs"] + t_start: Union[float, int] = Field( title="t_start", default=0.0, ge=0, description="Simulation start time" ) @@ -111,10 +110,7 @@ class SimulatorConfig(BaseModuleConfig): title="t_stop", default=inf, ge=0, description="Simulation stop time" ) t_sample: Union[float, int] = Field( - title="t_sample", - default=1, - ge=0, - description="Deprecated option." + title="t_sample", default=1, ge=0, description="Deprecated option." ) t_sample_communication: Union[float, int] = Field( title="t_sample", @@ -135,7 +131,6 @@ class SimulatorConfig(BaseModuleConfig): "as long as the model supports this. Used to override dt of the model." ) model: Dict - # Model results save_results: bool = Field( title="save_results", @@ -165,6 +160,12 @@ class SimulatorConfig(BaseModuleConfig): description="List of causalities to store. Default stores " "only inputs and outputs", ) + fine_results: bool = Field( + title="fine_results", + default=True, + description="If True, results are stored more finely, " + "including inputs during asimulation step.", + ) write_results_delay: Optional[float] = Field( title="Write Results Delay", default=None, @@ -172,6 +173,13 @@ class SimulatorConfig(BaseModuleConfig): validate_default=True, gt=0, ) + update_inputs_on_callback: bool = Field( + title="update_inputs_on_callback", + default=True, + description="Deprecated! Will be removed in future versions." + "If True, model inputs are updated if they are updated in data_broker." + "Else, the model inputs are updated before each simulation.", + ) measurement_uncertainty: Union[Dict[str, float], float] = Field( title="measurement_uncertainty", default=0, @@ -188,13 +196,6 @@ class SimulatorConfig(BaseModuleConfig): "is False by default, as we expect to receive a lot of measurements" " and want to be efficient.", ) - update_inputs_on_callback: bool = Field( - title="update_inputs_on_callback", - default=True, - description="Deprecated! Will be removed in future versions." - "If True, model inputs are updated if they are updated in data_broker." - "Else, the model inputs are updated before each simulation.", - ) @field_validator("result_filename") @classmethod @@ -256,7 +257,8 @@ def check_t_sample(cls, t_sample, info: FieldValidationInfo): @field_validator("t_sample_communication") @classmethod - def check_t_comm_against_sim(cls, t_sample_communication, info: FieldValidationInfo): + def check_t_comm_against_sim(cls, t_sample_communication, + info: FieldValidationInfo): """Check if t_sample is smaller than stop-start time""" t_sample_simulation = info.data.get("t_sample_simulation") if t_sample_simulation is not None: @@ -269,7 +271,8 @@ def check_t_comm_against_sim(cls, t_sample_communication, info: FieldValidationI @field_validator("update_inputs_on_callback") @classmethod - def deprecate_update_inputs_on_callback(cls, update_inputs_on_callback, info: FieldValidationInfo): + def deprecate_update_inputs_on_callback(cls, update_inputs_on_callback, + info: FieldValidationInfo): """Check if t_sample is smaller than stop-start time""" warnings.warn( "update_inputs_on_callback is deprecated, remove it from your config. " @@ -293,15 +296,23 @@ def deprecate_t_sample(cls, t_sample, info: FieldValidationInfo): @field_validator("write_results_delay") @classmethod def set_default_t_sample(cls, write_results_delay, info: FieldValidationInfo): - t_sample = info.data["t_sample"] - if write_results_delay is None: + if info.data["fine_results"]: + t_sample = info.data["t_sample_communication"] + factor = 1 + error = ("With fine_results set to True, write_results_delay needs to be above " + "t_sample_communication.") + else: + t_sample = info.data["t_sample_simulation"] + factor = 5 + error = "Saving results more frequently than you simulate makes no sense." # 5 is an arbitrary default which should balance writing new results as # soon as possible to disk with saving file I/O overhead - return 5 * t_sample + if write_results_delay is None: + return factor * t_sample if write_results_delay < t_sample: raise ValueError( - "Saving results more frequently than you simulate makes no sense. " - "Increase write_results_delay above t_sample." + f"{error} " + "Increase write_results_delay above the sample time." ) return write_results_delay @@ -367,7 +378,6 @@ def __init__(self, *, config: dict, agent: Agent): ) self._save_count: int = 1 # tracks, how often results have been saved self._inputs_changed_since_last_results_saving = False - self.temp_store_value = False self._register_input_callbacks() self.logger.info("%s initialized!", self.__class__.__name__) @@ -473,74 +483,63 @@ def _callback_update_model_parameter(self, par: AgentVariable, name: str): def process(self): """ - This function creates a endless loop for the single simulation step event, - updating inputs, simulating, model results and then outputs. - - In a simulation step following happens: - 1. Specify the end time of the simulation from the agents perspective. - **Important note**: The agents use unix-time as a timestamp and start - the simulation with the current datetime (represented by self.env.time), - the model starts at 0 seconds (represented by self.env.now). - 2. Directly after the simulation we store the results with - the output time and then call the timeout in the environment, - hence actually increase the environment time. - 3. Once the environment time reached the simulation time, - we send the updated output values to other modules and agents by setting - them the data_broker. + This function creates a endless loop for the single simulation step event. + The do_step() function needs to return a generator. """ - self._update_results(timestamp_inputs=self.env.time, timestamp_outputs=self.env.time) + no_sub_sim = self.config.t_sample_communication % self.config.t_sample_simulation == 0 + if no_sub_sim: + self._update_result_inputs(self.env.time, init=True) while True: # Simulate t_samples = create_time_samples( t_end=self.config.t_sample_communication, dt=self.config.t_sample_simulation ) - self.logger.debug("Doing simulation steps %s", t_samples) - for _idx, _t_sample in enumerate(t_samples[:-1]): - _t_start_substep = self.env.time + _t_start_simulation_loop = self.env.time + for _idx, _t_sample in enumerate(t_samples[:-1]): _t_start = self.env.now + self.config.t_start dt_sim = t_samples[_idx + 1] - _t_sample - # Three cases to cover for input change: - # 1. No change at all - # 2. Change before do_step - # 3. change after do_step - - # Check if inputs changed BEFORE do_step (they're already active at env.time) - inputs_changed_before_dostep = self._inputs_changed_since_last_results_saving - self.model.do_step(t_start=_t_start, t_sample=dt_sim) - - # Check if inputs changed AFTER do_step (they'll be active at next env.time) - # TODO: theoretically could change on both occasions - inputs_changed_after_dostep = self._inputs_changed_since_last_results_saving - if _idx == len(t_samples) - 2 or inputs_changed_before_dostep or inputs_changed_after_dostep: - # Determine when inputs became active - if inputs_changed_before_dostep or inputs_changed_after_dostep: - if inputs_changed_before_dostep: - # Inputs changed before do_step, so they were used in this simulation - # They're active from the start of this simulation loop - timestamp_inputs = _t_start_substep - else: - # Inputs changed after do_step, so they weren't used yet - # They're active from the next time step (env.time + dt_sim) - timestamp_inputs = self.env.time + dt_sim + if not self.config.fine_results: + self._inputs_changed_since_last_results_saving = False + # At t_sample_communication store the inputs used for the simulation step + # Outputs are written at t_sample_communication - t_sample_simulation for + # t_sample_communication. If an input is set at t_sample_communication, + # these need to be merged + if self.env.time % self.config.t_sample_communication == 0: + if no_sub_sim: + self._update_result_inputs(self.env.time) + self._update_results(False, _t_start_simulation_loop, no_sub_sim=no_sub_sim) else: - # No change during step, inputs were active from the start - timestamp_inputs = _t_start_substep - - # Outputs are always active at the end of the simulation step - timestamp_outputs = self.env.time + dt_sim + if self.env.time == self.env.offset: + self._update_result_inputs(self.env.time, init=True) + else: + self._update_result_inputs(self.env.time) + elif _idx == len(t_samples) - 2 or self._inputs_changed_since_last_results_saving: # Update the results - self._update_results( - timestamp_outputs=timestamp_outputs, - timestamp_inputs=timestamp_inputs - ) + if _idx == len(t_samples) - 2 and self._inputs_changed_since_last_results_saving: + self._update_results(False, _t_start_simulation_loop, both=True) + else: + self._update_results(self._inputs_changed_since_last_results_saving, + _t_start_simulation_loop) yield self.env.timeout(dt_sim) - # Communicate self.update_module_vars() + def update_model_inputs(self): + """ + Internal method to write current data_broker to simulation model. + Only update values, not other module_types. + """ + model_input_names = ( + self.model.get_input_names() + self.model.get_parameter_names() + ) + for inp in self.variables: + if inp.name in model_input_names: + self.logger.debug("Updating model variable %s=%s", inp.name, inp.value) + self.model.set(name=inp.name, value=inp.value) + def update_module_vars(self): """ Method to write current model output and states @@ -591,83 +590,74 @@ def cleanup_results(self): return os.remove(self.config.result_filename) - def _update_results(self, timestamp_outputs, timestamp_inputs): + def _update_results(self, input_callback, t_start_simulation_loop, both=False, no_sub_sim=False): """ Adds model variables to the SimulationResult object at the given timestamp. - - timestamp_outputs describes the timestamp for which the outputs are active - timestamp_inputs describes the timestamp for which the inputs are active """ - if not self.config.save_results: return + if input_callback: + timestamp = self.env.time + else: + timestamp = t_start_simulation_loop + self.config.t_sample_communication + if both: + time_stamp_both = self.env.time + + self._inputs_changed_since_last_results_saving = False + inp_values = [var.value for var in self._get_result_input_variables()] out_values = [var.value for var in self._get_result_output_variables()] - _len_outputs = len(out_values) - _len_inputs = len(inp_values) - - # Four cases to handle: - # 1. Both timestamps are the same - store both in one row - # 2. Inputs changed before do_step (timestamp_inputs == timestamp_outputs) - same as case 1 - # 3. Inputs changed after do_step (timestamp_inputs == timestamp_outputs but both equal to end time) - # 4. Invalid case (timestamp_inputs > timestamp_outputs) - - if timestamp_inputs == timestamp_outputs: - # Inputs and outputs are active at the same timestamp - # Check if this timestamp already exists (e.g., from storing outputs earlier) - if timestamp_outputs in self._result.index: - # Update the existing row with input values - idx = self._result.index.index(timestamp_outputs) - # Keep existing outputs, update inputs - self._result.data[idx] = self._result.data[idx][ - :_len_outputs] + inp_values - else: - # New timestamp, store both - self._result.index.append(timestamp_outputs) - self._result.data.append(out_values + inp_values) - - self._inputs_changed_since_last_results_saving = False - - elif timestamp_inputs < timestamp_outputs: - # Inputs became active at an earlier time than outputs - # This happens when inputs changed during a simulation step - - # First, store the inputs at their activation timestamp - if timestamp_inputs in self._result.index: - # Timestamp already exists, update input values - idx = self._result.index.index(timestamp_inputs) - self._result.data[idx] = self._result.data[idx][ - :_len_outputs] + inp_values - else: - # New timestamp for inputs only - self._result.index.append(timestamp_inputs) - self._result.data.append([None] * _len_outputs + inp_values) - - # Now store the outputs at their timestamp - if timestamp_outputs in self._result.index: - # Timestamp already exists (shouldn't normally happen, but handle it) - idx = self._result.index.index(timestamp_outputs) - self._result.data[idx][:_len_outputs] = out_values - else: - self._result.index.append(timestamp_outputs) - self._result.data.append(out_values + [None] * _len_inputs) - - self._inputs_changed_since_last_results_saving = False + if input_callback: + # create new timestamp entry in results for upcoming timestamp + self._result.index.append(timestamp) + # append inputs, outputs stay None + self._result.data.append([None]*len(out_values) + inp_values) else: - raise ValueError(f"Storing inputs ahead of outputs is not supported. " - f"timestamp_inputs={timestamp_inputs}, timestamp_outputs={timestamp_outputs}") + # If input comes in at the same time add these first for the current time step + if both: + # create new timestamp entry in results for upcoming timestamp + self._result.index.append(time_stamp_both) + # append inputs, outputs stay None + self._result.data.append([None] * len(out_values) + inp_values) + # Add outputs for the time the simulation ended (inputs are already stored). + self._result.index.append(timestamp) + out_values = [var.value for var in self._get_result_output_variables()] + self._result.data.append(out_values + self._result.data[-1][len(out_values):]) - # Write results to file periodically if ( - self.config.result_filename is not None - and timestamp_outputs // ( - self.config.write_results_delay * self._save_count) > 0 + self.config.result_filename is not None + and self.env.time // (self.config.write_results_delay * self._save_count) > 0 ): self._save_count += 1 + if no_sub_sim: + index_store = self._result.index[-1] + data_store = self._result.data[-1] + self._result.index = self._result.index[:-1] + self._result.data = self._result.data[:-1] self._result.write_results(self.config.result_filename) + if no_sub_sim: + self._result.index = [index_store] + self._result.data = [data_store] + + def _update_result_outputs(self, timestamp: float): + """Updates results with current values for states and outputs.""" + self._result.index.append(timestamp) + out_values = [var.value for var in self._get_result_output_variables()] + self._result.data.append(out_values) + + def _update_result_inputs(self, timestamp: float, init: bool = False): + """Updates results with current values for states and outputs.""" + in_values = [var.value for var in self._get_result_input_variables()] + if init: + self._result.index.append(timestamp) + out_values = [var.value for var in self._get_result_output_variables()] + self._result.data.append([None] * len(out_values) + in_values) + else: + # At full time steps, overwrite the last results row with output and new input + self._result.data[-1] = self._result.data[-1][:-len(in_values)] + in_values def _get_result_model_variables(self) -> AgentVariables: """ @@ -713,4 +703,4 @@ def convert_agent_vars_to_list_of_dicts(var: AgentVariables) -> List[Dict]: agent_var.dict(exclude={"source", "alias", "shared", "rdf_class"}) for agent_var in var ] - return var_dict_list + return var_dict_list \ No newline at end of file diff --git a/examples/multi-agent-systems/room_mas/t_sample_simulation_demonstration.py b/examples/multi-agent-systems/room_mas/t_sample_simulation_demonstration.py index 7d37dd2d..accb61b4 100644 --- a/examples/multi-agent-systems/room_mas/t_sample_simulation_demonstration.py +++ b/examples/multi-agent-systems/room_mas/t_sample_simulation_demonstration.py @@ -14,7 +14,7 @@ "modules": { "sensor": { "type": "TRYSensor", - "t_sample": 223, # Some random value to show differences in communication time-points + "t_sample": 60, # Some random value to show differences in communication time-points "filename": "data/TRY2015_Aachen_Jahr.dat", "log_level": "DEBUG" }, @@ -38,6 +38,7 @@ "t_sample_simulation": 15, "save_results": True, "result_filename": "results.csv", + "fine_results": True, "overwrite_result_file": True, "log_level": "DEBUG", "inputs": [ @@ -89,9 +90,11 @@ def run_example(until, with_plots=True, log_level=logging.INFO): # Change the working directly so that relative paths work os.chdir(os.path.abspath(os.path.dirname(__file__))) # create multiple agents with different configurations - agent_configs = [TRY_CONFIG] + agent_configs = [] + # TODO: why has changing order no effect? + # agent_configs = [TRY_CONFIG] - combinations = [[900], [60, 900]] + combinations = [[900], [900]] for t_sample_com, t_sample_sim in itertools.product(*combinations): room_cfg = deepcopy(ROOM_CONFIG) @@ -100,6 +103,7 @@ def run_example(until, with_plots=True, log_level=logging.INFO): room_cfg["modules"]["room"]["t_sample_simulation"] = t_sample_sim room_cfg["modules"]["room"]["result_filename"] = f"results_{t_sample_com}_{t_sample_sim}.csv" agent_configs.append(room_cfg) + agent_configs.append(TRY_CONFIG) mas = LocalMASAgency( env=env_config, @@ -126,6 +130,8 @@ def run_example(until, with_plots=True, log_level=logging.INFO): idx = 0 for t_sample_com, t_sample_sim in itertools.product(*combinations): df_ro = results[f"{t_sample_com}_{t_sample_sim}"]["room"] + # mask = df_ro.index % t_sample_com == 0 + # df_ro = df_ro[mask] times_input_changed = df_ro.index[~np.isnan(df_ro["T_oda"])] for _i, _time in enumerate(times_input_changed): From 0e6c903d2b96ec3787355a322f316316b9bcfd73 Mon Sep 17 00:00:00 2001 From: "felix.stegemerten" Date: Fri, 9 Jan 2026 15:18:34 +0100 Subject: [PATCH 4/5] Fix simulator results --- agentlib/modules/simulation/simulator.py | 392 ++++++++++-------- agentlib/utils/__init__.py | 2 +- .../t_sample_simulation_demonstration.py | 13 +- 3 files changed, 217 insertions(+), 190 deletions(-) diff --git a/agentlib/modules/simulation/simulator.py b/agentlib/modules/simulation/simulator.py index d1be0a45..f29fb302 100644 --- a/agentlib/modules/simulation/simulator.py +++ b/agentlib/modules/simulation/simulator.py @@ -4,7 +4,7 @@ import os import warnings -from dataclasses import dataclass +from dataclasses import dataclass, field from math import inf from pathlib import Path from typing import Union, Dict, List, Optional @@ -33,11 +33,22 @@ class SimulatorResults: """Class to organize in-memory simulator results.""" - index: List[float] - columns: pd.MultiIndex - data: List[List[float]] + # Configuration + filename: Optional[str] = None + header_written: bool = False - def __init__(self, variables: List[ModelVariable]): + # Data Buffers + index: List[float] = field(default_factory=list) + data: List[List[float]] = field(default_factory=list) + + # State tracking + _current_inputs: List[float] = field(default_factory=list) + _current_outputs: List[float] = field(default_factory=list) + _columns: pd.MultiIndex = None + _input_count: int = 0 + _output_count: int = 0 + + def setup(self, input_vars: List[ModelVariable], output_vars: List[ModelVariable]): """ Initializes results object input variables "u", outputs "x" and internal state variables "x". @@ -50,8 +61,16 @@ def __init__(self, variables: List[ModelVariable]): | 2 | ... | ... | ... | ... | ... | ... | |...| ... | ... | ... | ... | ... | ... | |... + Also initializes the internal buffers. """ - self.columns = pd.MultiIndex.from_arrays( + variables = output_vars + input_vars + self._input_count = len(input_vars) + self._output_count = len(output_vars) + + # Initialize current inputs with current values + self._current_inputs = [var.value for var in input_vars] + + self._columns = pd.MultiIndex.from_arrays( arrays=np.array( [ [_var.causality.name for _var in variables], @@ -62,29 +81,83 @@ def __init__(self, variables: List[ModelVariable]): sortorder=0, names=["causality", "name", "type"], ) - self.index = [] - self.data = [] - def initialize( - self, - time: float, - ): - """Adds the first row to the data""" + def update_inputs(self, values: List[float], time: float, capture_all_inputs: bool): + """ + Updates the internal input buffer. + If capture_all_inputs is True, creates a row with NaN outputs. + """ + self._current_inputs = values + # Results can already hold the input (at t_sample_communication created by + # the output writing) or the input time is new (created by an input callback) + if time not in self.index: + # For capture_all_inputs, append the inputs created by an input callback + if capture_all_inputs: + self.index.append(time) + # index is not in data, if results have been written to disk + # Create row: [NaN, NaN, ..., In1, In2, ...] + row = [None] * self._output_count + self._current_inputs + # If timestamp is new, this needs to be appended + self.data.append(row)# + else: + # Create row: [Out1, Out2, ..., In1, In2, ...] + row = self.data[-1][:self._output_count] + self._current_inputs + # Update timestamp with new inputs + self.data[-1] = row - def df(self) -> pd.DataFrame: - """Returns the current results as a dataframe.""" - return pd.DataFrame(self.data, index=self.index, columns=self.columns) + def update_outputs(self, values: List[float], time: float): + """ + Stores a full result row at the end of a simulation step. + Combines provided output values with the last known input values. + """ + # Create row: [Out1, Out2, ..., None, None, ...] + row = values + [None] * self._input_count + self.index.append(time) + self.data.append(row) + + def update_current_outputs(self, values: List[float]): + """ + Stores the current output values of intermediate simulation steps. + """ + self._current_outputs = values + + def initialize_outputs(self, time): + """ + Initializes output data with Nones. + """ + self.index.append(time) + # Create row: [None, None, ..., In1, In2, ...] + self.data.append([None] * self._output_count + self._current_inputs) + + def initialize_inputs(self, values: List[float]): + """ + Initializes input data with Nones. + """ + self._current_inputs = values - def write_results(self, file: str): + def write_results(self): """ - Dumps results which are currently in memory to a file. - On creation of the file, the header columns are dumped, as well. + Dumps results which are currently in memory to the file. + Clears memory after writing to keep footprint low. """ - header = not Path(file).exists() - self.df().to_csv(file, mode="a", header=header) - # keep the last row of the results, as it is not finished (inputs missing) - self.index = [self.index[-1]] - self.data = [self.data[-1]] + if not self.filename or not self.data: + return + + df = pd.DataFrame(self.data, index=self.index, columns=self._columns) + + # Write header only once + header = not self.header_written and not Path(self.filename).exists() + df.to_csv(self.filename, mode="a", header=header) + + self.header_written = True + + # Clear buffers + self.index.clear() + self.data.clear() + + def df(self) -> pd.DataFrame: + """Returns the current results as a dataframe.""" + return pd.DataFrame(self.data, index=self.index, columns=self._columns) def read_simulator_results(file: str): @@ -160,11 +233,11 @@ class SimulatorConfig(BaseModuleConfig): description="List of causalities to store. Default stores " "only inputs and outputs", ) - fine_results: bool = Field( - title="fine_results", + capture_all_inputs: bool = Field( + title="capture_all_inputs", default=True, - description="If True, results are stored more finely, " - "including inputs during asimulation step.", + description="If True, results are stored immediately when " + "inputs change, even during simulation steps.", ) write_results_delay: Optional[float] = Field( title="Write Results Delay", @@ -245,7 +318,9 @@ def check_t_sample(cls, t_sample, info: FieldValidationInfo): t_start = info.data.get("t_start") t_stop = info.data.get("t_stop") t_sample_old = info.data.get("t_sample") - if t_sample_old != 1: # A change in the default shows t_sample is still in the config of the user + + # Handle legacy t_sample logic + if t_sample_old != 1: if info.field_name == "t_sample_simulation": t_sample = 1 else: @@ -284,36 +359,27 @@ def deprecate_update_inputs_on_callback(cls, update_inputs_on_callback, @field_validator("t_sample") @classmethod def deprecate_t_sample(cls, t_sample, info: FieldValidationInfo): - """Deprecates the t_sample field in favor of t_sample_communication and t_sample_simulation.""" + """Deprecates the t_sample field in favor of t_sample_communication + and t_sample_simulation.""" warnings.warn( - "t_sample is deprecated, use t_sample_communication, " - "t_sample_simulation for a concise separation of the two. " - "Will use the given t_sample for t_sample_communication and t_sample_simulation=1 s, " - "the `model.dt` default.", + "t_sample is deprecated, use t_sample_communication for storing outputs " + "and t_sample_simulation for the actual simulation step. " + "Will use the given t_sample for t_sample_communication and " + "t_sample_simulation=1 s, the `model.dt` default.", ) return t_sample @field_validator("write_results_delay") @classmethod def set_default_t_sample(cls, write_results_delay, info: FieldValidationInfo): - if info.data["fine_results"]: - t_sample = info.data["t_sample_communication"] - factor = 1 - error = ("With fine_results set to True, write_results_delay needs to be above " - "t_sample_communication.") - else: - t_sample = info.data["t_sample_simulation"] - factor = 5 - error = "Saving results more frequently than you simulate makes no sense." - # 5 is an arbitrary default which should balance writing new results as - # soon as possible to disk with saving file I/O overhead + t_comm = info.data.get("t_sample_communication", 1) + if write_results_delay is None: - return factor * t_sample - if write_results_delay < t_sample: - raise ValueError( - f"{error} " - "Increase write_results_delay above the sample time." - ) + # Default to writing every 5 communication steps to balance I/O + return t_comm * 5 + + if write_results_delay < t_comm: + raise ValueError("write_results_delay should be >= t_sample_communication") return write_results_delay @field_validator("model") @@ -370,14 +436,23 @@ class Simulator(BaseModule): def __init__(self, *, config: dict, agent: Agent): super().__init__(config=config, agent=agent) - # Initialize instance attributes + self._model = None self.model = self.config.model - self._result: SimulatorResults = SimulatorResults( - variables=self._get_result_model_variables() - ) - self._save_count: int = 1 # tracks, how often results have been saved self._inputs_changed_since_last_results_saving = False + + # Caching variables for performance (avoid list comprehensions in loop) + self._input_vars = self._get_result_input_variables() + self._output_vars = self._get_result_output_variables() + + # Initialize Result Handler + self._result = SimulatorResults(filename=self.config.result_filename) + if self.config.save_results: + self._result.setup(input_vars=self._input_vars, + output_vars=self._output_vars) + + self._last_write_time = 0.0 + self._register_input_callbacks() self.logger.info("%s initialized!", self.__class__.__name__) @@ -483,62 +558,94 @@ def _callback_update_model_parameter(self, par: AgentVariable, name: str): def process(self): """ - This function creates a endless loop for the single simulation step event. - The do_step() function needs to return a generator. + Main simulation loop. + Handles simulation stepping, result logging, and synchronization. """ - no_sub_sim = self.config.t_sample_communication % self.config.t_sample_simulation == 0 - if no_sub_sim: - self._update_result_inputs(self.env.time, init=True) + # 1. Log Initial State (t=0) + if self.config.save_results: + # Ensure the result buffer has the correct initial inputs + in_values = [var.value for var in self._input_vars] + self._result.initialize_inputs(in_values) + self._result.initialize_outputs(self.env.time) + # Prevent false positive "input change" log at t=0 due to initialization callbacks + self._inputs_changed_since_last_results_saving = False while True: - # Simulate + # Determine the time points for the next communication step t_samples = create_time_samples( t_end=self.config.t_sample_communication, dt=self.config.t_sample_simulation ) - self.logger.debug("Doing simulation steps %s", t_samples) - - _t_start_simulation_loop = self.env.time - for _idx, _t_sample in enumerate(t_samples[:-1]): - _t_start = self.env.now + self.config.t_start - dt_sim = t_samples[_idx + 1] - _t_sample - self.model.do_step(t_start=_t_start, t_sample=dt_sim) - if not self.config.fine_results: + + # Iterate through simulation sub-steps + for i in range(len(t_samples) - 1): + dt_sim = float(t_samples[i + 1] - t_samples[i]) + + # 2. Check for Input Changes (Pre-Step) + # If inputs changed since the last step (or during the yield), we log them now. + # This ensures the new inputs are recorded at the current timestamp, + # separate from the outputs of the *previous* step (which were logged at + # the end of the last loop). + if self._inputs_changed_since_last_results_saving: + if self.config.save_results: + # Create row: [t=Current, Out=NaN, In=New] + self._log_inputs(self.env.time, + capture_all_inputs=self.config.capture_all_inputs) self._inputs_changed_since_last_results_saving = False - # At t_sample_communication store the inputs used for the simulation step - # Outputs are written at t_sample_communication - t_sample_simulation for - # t_sample_communication. If an input is set at t_sample_communication, - # these need to be merged - if self.env.time % self.config.t_sample_communication == 0: - if no_sub_sim: - self._update_result_inputs(self.env.time) - self._update_results(False, _t_start_simulation_loop, no_sub_sim=no_sub_sim) - else: - if self.env.time == self.env.offset: - self._update_result_inputs(self.env.time, init=True) - else: - self._update_result_inputs(self.env.time) - elif _idx == len(t_samples) - 2 or self._inputs_changed_since_last_results_saving: - # Update the results - if _idx == len(t_samples) - 2 and self._inputs_changed_since_last_results_saving: - self._update_results(False, _t_start_simulation_loop, both=True) - else: - self._update_results(self._inputs_changed_since_last_results_saving, - _t_start_simulation_loop) + + # 3. Perform Simulation Step + self.model.do_step( + t_start=self.config.t_start + self.env.now, + t_sample=dt_sim + ) + + # 4. Store intermediate outputs + out_values = [var.value for var in self._output_vars] + self._result.update_current_outputs(out_values) + + # 5. Write results + if self.config.save_results: + if (self.env.time + self.config.t_sample_simulation) % self.config.t_sample_communication == 0: + # Check if we need to write to disk, do this before storing outputs, + # to initialize the new row after dumping the results + self._check_and_write_to_disk(self.env.time + self.config.t_sample_simulation) + + # Log the outputs resulting from the step we just finished. + # These will be paired with the inputs active for the next simulation step. + self._log_outputs(self.env.time + self.config.t_sample_simulation) + + # 6. Wait for the environment yield self.env.timeout(dt_sim) + + # 7. End of Communication Step (Post-Step) + # Communicate self.update_module_vars() - def update_model_inputs(self): + def _log_inputs(self, time: float, capture_all_inputs: bool): """ - Internal method to write current data_broker to simulation model. - Only update values, not other module_types. + Update the result object with current inputs. + If capture_all_inputs is True, a row is added immediately. """ - model_input_names = ( - self.model.get_input_names() + self.model.get_parameter_names() - ) - for inp in self.variables: - if inp.name in model_input_names: - self.logger.debug("Updating model variable %s=%s", inp.name, inp.value) - self.model.set(name=inp.name, value=inp.value) + values = [var.value for var in self._input_vars] + self._result.update_inputs(values, time, capture_all_inputs=capture_all_inputs) + + def _log_outputs(self, time: float): + """ + Add a full result row (Outputs + Last Inputs). + """ + values = [var.value for var in self._output_vars] + self._result.update_outputs(values, time) + + def _check_and_write_to_disk(self, time): + """Check if write delay has passed and dump to disk.""" + if not self.config.result_filename: + return + + # Inputs are written in the next time step, therefore results + # are behind actual env time + current_result_time = time - self.config.t_sample_communication + if (current_result_time - self._last_write_time) >= self.config.write_results_delay: + self._result.write_results() + self._last_write_time = time def update_module_vars(self): """ @@ -578,7 +685,7 @@ def get_results(self) -> Optional[pd.DataFrame]: return file = self.config.result_filename if file: - self._result.write_results(self.config.result_filename) + self._result.write_results() df = read_simulator_results(file) else: df = self._result.df() @@ -590,86 +697,7 @@ def cleanup_results(self): return os.remove(self.config.result_filename) - def _update_results(self, input_callback, t_start_simulation_loop, both=False, no_sub_sim=False): - """ - Adds model variables to the SimulationResult object - at the given timestamp. - """ - if not self.config.save_results: - return - - if input_callback: - timestamp = self.env.time - else: - timestamp = t_start_simulation_loop + self.config.t_sample_communication - if both: - time_stamp_both = self.env.time - - self._inputs_changed_since_last_results_saving = False - - inp_values = [var.value for var in self._get_result_input_variables()] - out_values = [var.value for var in self._get_result_output_variables()] - - if input_callback: - # create new timestamp entry in results for upcoming timestamp - self._result.index.append(timestamp) - # append inputs, outputs stay None - self._result.data.append([None]*len(out_values) + inp_values) - else: - # If input comes in at the same time add these first for the current time step - if both: - # create new timestamp entry in results for upcoming timestamp - self._result.index.append(time_stamp_both) - # append inputs, outputs stay None - self._result.data.append([None] * len(out_values) + inp_values) - # Add outputs for the time the simulation ended (inputs are already stored). - self._result.index.append(timestamp) - out_values = [var.value for var in self._get_result_output_variables()] - self._result.data.append(out_values + self._result.data[-1][len(out_values):]) - - if ( - self.config.result_filename is not None - and self.env.time // (self.config.write_results_delay * self._save_count) > 0 - ): - self._save_count += 1 - if no_sub_sim: - index_store = self._result.index[-1] - data_store = self._result.data[-1] - self._result.index = self._result.index[:-1] - self._result.data = self._result.data[:-1] - self._result.write_results(self.config.result_filename) - if no_sub_sim: - self._result.index = [index_store] - self._result.data = [data_store] - - def _update_result_outputs(self, timestamp: float): - """Updates results with current values for states and outputs.""" - self._result.index.append(timestamp) - out_values = [var.value for var in self._get_result_output_variables()] - self._result.data.append(out_values) - - def _update_result_inputs(self, timestamp: float, init: bool = False): - """Updates results with current values for states and outputs.""" - in_values = [var.value for var in self._get_result_input_variables()] - if init: - self._result.index.append(timestamp) - out_values = [var.value for var in self._get_result_output_variables()] - self._result.data.append([None] * len(out_values) + in_values) - else: - # At full time steps, overwrite the last results row with output and new input - self._result.data[-1] = self._result.data[-1][:-len(in_values)] + in_values - - def _get_result_model_variables(self) -> AgentVariables: - """ - Gets all variables to be saved in the result based - on self.result_causalities. - """ - - # THE ORDER OF THIS CONCAT IS IMPORTANT. The _update_results function will - # extend the outputs with the inputs - return self._get_result_output_variables() + self._get_result_input_variables() - - def _get_result_input_variables(self) -> AgentVariables: + def _get_result_input_variables(self) -> List[ModelVariable]: """Gets all input variables to be saved in the results based on self.result_causalities. Input variables are added to the results at the time index before an interval, i.e. parameters and inputs.""" @@ -681,7 +709,7 @@ def _get_result_input_variables(self) -> AgentVariables: _variables.extend(self.model.parameters) return _variables - def _get_result_output_variables(self) -> AgentVariables: + def _get_result_output_variables(self) -> List[ModelVariable]: """Gets all output variables to be saved in the results based on self.result_causalities. Input variables are added to the results at the time index after an interval, i.e. locals and outputs.""" diff --git a/agentlib/utils/__init__.py b/agentlib/utils/__init__.py index 2886cb2f..595112f4 100644 --- a/agentlib/utils/__init__.py +++ b/agentlib/utils/__init__.py @@ -90,7 +90,7 @@ class (object): The class object specified by class_name ) -def create_time_samples(dt, t_end): +def create_time_samples(dt: float, t_end: float) -> np.ndarray: """ Function to generate an array of time steps using the dt object. diff --git a/examples/multi-agent-systems/room_mas/t_sample_simulation_demonstration.py b/examples/multi-agent-systems/room_mas/t_sample_simulation_demonstration.py index accb61b4..b7ae6b8c 100644 --- a/examples/multi-agent-systems/room_mas/t_sample_simulation_demonstration.py +++ b/examples/multi-agent-systems/room_mas/t_sample_simulation_demonstration.py @@ -14,7 +14,7 @@ "modules": { "sensor": { "type": "TRYSensor", - "t_sample": 60, # Some random value to show differences in communication time-points + "t_sample": 223, # Some random value to show differences in communication time-points "filename": "data/TRY2015_Aachen_Jahr.dat", "log_level": "DEBUG" }, @@ -38,7 +38,7 @@ "t_sample_simulation": 15, "save_results": True, "result_filename": "results.csv", - "fine_results": True, + "capture_all_inputs": True, "overwrite_result_file": True, "log_level": "DEBUG", "inputs": [ @@ -72,6 +72,7 @@ def run_example(until, with_plots=True, log_level=logging.INFO): This example can be used to understand how the simulator receives and stores data. Change the `combinations` in the code if you want to test out different settings. + With the `capture_all_inputs` option, the simulator also stores all inputs during a simulation step. Parameters ---------- @@ -90,11 +91,9 @@ def run_example(until, with_plots=True, log_level=logging.INFO): # Change the working directly so that relative paths work os.chdir(os.path.abspath(os.path.dirname(__file__))) # create multiple agents with different configurations - agent_configs = [] - # TODO: why has changing order no effect? - # agent_configs = [TRY_CONFIG] + agent_configs = [TRY_CONFIG] - combinations = [[900], [900]] + combinations = [[900], [60, 900]] for t_sample_com, t_sample_sim in itertools.product(*combinations): room_cfg = deepcopy(ROOM_CONFIG) @@ -102,8 +101,8 @@ def run_example(until, with_plots=True, log_level=logging.INFO): room_cfg["modules"]["room"]["t_sample_communication"] = t_sample_com room_cfg["modules"]["room"]["t_sample_simulation"] = t_sample_sim room_cfg["modules"]["room"]["result_filename"] = f"results_{t_sample_com}_{t_sample_sim}.csv" + room_cfg["modules"]["room"]["capture_all_inputs"] = False agent_configs.append(room_cfg) - agent_configs.append(TRY_CONFIG) mas = LocalMASAgency( env=env_config, From cf737138466b6d5e0a72e3c46b5100383fbd52c4 Mon Sep 17 00:00:00 2001 From: "felix.stegemerten" Date: Mon, 19 Jan 2026 11:47:40 +0100 Subject: [PATCH 5/5] Add unittests for new simulator --- agentlib/modules/simulation/simulator.py | 39 ++++--- .../t_sample_simulation_demonstration.py | 2 - tests/test_simulator.py | 102 ++++++++++++++++++ 3 files changed, 128 insertions(+), 15 deletions(-) diff --git a/agentlib/modules/simulation/simulator.py b/agentlib/modules/simulation/simulator.py index f29fb302..164657e9 100644 --- a/agentlib/modules/simulation/simulator.py +++ b/agentlib/modules/simulation/simulator.py @@ -84,13 +84,13 @@ def setup(self, input_vars: List[ModelVariable], output_vars: List[ModelVariable def update_inputs(self, values: List[float], time: float, capture_all_inputs: bool): """ - Updates the internal input buffer. + Updates the result with the inputs creating a full result row (output + input). If capture_all_inputs is True, creates a row with NaN outputs. """ self._current_inputs = values # Results can already hold the input (at t_sample_communication created by # the output writing) or the input time is new (created by an input callback) - if time not in self.index: + if not self.index or time != self.index[-1]: # For capture_all_inputs, append the inputs created by an input callback if capture_all_inputs: self.index.append(time) @@ -98,7 +98,7 @@ def update_inputs(self, values: List[float], time: float, capture_all_inputs: bo # Create row: [NaN, NaN, ..., In1, In2, ...] row = [None] * self._output_count + self._current_inputs # If timestamp is new, this needs to be appended - self.data.append(row)# + self.data.append(row) else: # Create row: [Out1, Out2, ..., In1, In2, ...] row = self.data[-1][:self._output_count] + self._current_inputs @@ -107,8 +107,9 @@ def update_inputs(self, values: List[float], time: float, capture_all_inputs: bo def update_outputs(self, values: List[float], time: float): """ - Stores a full result row at the end of a simulation step. - Combines provided output values with the last known input values. + Stores a result row at the end of a simulation step. + Combines provided output values with None for input values, as these are + updated in the next time step. """ # Create row: [Out1, Out2, ..., None, None, ...] row = values + [None] * self._input_count @@ -235,7 +236,7 @@ class SimulatorConfig(BaseModuleConfig): ) capture_all_inputs: bool = Field( title="capture_all_inputs", - default=True, + default=False, description="If True, results are stored immediately when " "inputs change, even during simulation steps.", ) @@ -451,7 +452,9 @@ def __init__(self, *, config: dict, agent: Agent): self._result.setup(input_vars=self._input_vars, output_vars=self._output_vars) + # Initialize local time trackers self._last_write_time = 0.0 + self._last_communication_time = self.env.time self._register_input_callbacks() self.logger.info("%s initialized!", self.__class__.__name__) @@ -599,19 +602,29 @@ def process(self): ) # 4. Store intermediate outputs - out_values = [var.value for var in self._output_vars] - self._result.update_current_outputs(out_values) + if self.config.save_results: + out_values = [var.value for var in self._output_vars] + self._result.update_current_outputs(out_values) # 5. Write results if self.config.save_results: - if (self.env.time + self.config.t_sample_simulation) % self.config.t_sample_communication == 0: - # Check if we need to write to disk, do this before storing outputs, - # to initialize the new row after dumping the results - self._check_and_write_to_disk(self.env.time + self.config.t_sample_simulation) + # Since simulation has been performed, the model and its results are + # already a time step ahead + current_time = self.env.time + self.config.t_sample_simulation + if ((current_time - self._last_communication_time) >= + self.config.t_sample_communication): + # Update time tracker for communication + self._last_communication_time = ((current_time // + self.config.t_sample_communication) * + self.config.t_sample_communication) + # Check if we need to write to disk, do this before storing + # outputs, to initialize the new row after dumping the results + self._check_and_write_to_disk(self.env.time + + self.config.t_sample_simulation) # Log the outputs resulting from the step we just finished. # These will be paired with the inputs active for the next simulation step. - self._log_outputs(self.env.time + self.config.t_sample_simulation) + self._log_outputs(self._last_communication_time) # 6. Wait for the environment yield self.env.timeout(dt_sim) diff --git a/examples/multi-agent-systems/room_mas/t_sample_simulation_demonstration.py b/examples/multi-agent-systems/room_mas/t_sample_simulation_demonstration.py index b7ae6b8c..91aa6553 100644 --- a/examples/multi-agent-systems/room_mas/t_sample_simulation_demonstration.py +++ b/examples/multi-agent-systems/room_mas/t_sample_simulation_demonstration.py @@ -129,8 +129,6 @@ def run_example(until, with_plots=True, log_level=logging.INFO): idx = 0 for t_sample_com, t_sample_sim in itertools.product(*combinations): df_ro = results[f"{t_sample_com}_{t_sample_sim}"]["room"] - # mask = df_ro.index % t_sample_com == 0 - # df_ro = df_ro[mask] times_input_changed = df_ro.index[~np.isnan(df_ro["T_oda"])] for _i, _time in enumerate(times_input_changed): diff --git a/tests/test_simulator.py b/tests/test_simulator.py index fdcfd904..74914c4b 100644 --- a/tests/test_simulator.py +++ b/tests/test_simulator.py @@ -84,6 +84,8 @@ def _get_module_cfg(self, **kwargs): "measurement_uncertainty": 0.01, "inputs": [{"name": "input", "value": 10}], "outputs": [{"name": "output"}, {"name": "output_2"}], + "capture_all_inputs": kwargs.get("capture_all_inputs", False), + "write_results_delay": None } def test_simulator_parameters(self): @@ -141,6 +143,106 @@ def test_save_results(self): next(step) self.assertIsInstance(simulator.get_results(), pd.DataFrame) + def test_capture_all_inputs_true(self): + """Test that inputs are captured immediately when capture_all_inputs=True.""" + agent = self.get_agent() + simulator = Simulator( + config=self._get_module_cfg( + capture_all_inputs=True, + result_filename=None, + write_results_delay=self.t_sample_communication + ), + agent=agent, + ) + agent.env.process(simulator.process()) + + # Simulate an input change mid-simulation + simulator.set("input", value=50) + + # Simulate long enough to trigger results writing + agent.env.run(until=self.t_sample_communication + self.t_sample_simulation) + + res = simulator.get_results() + + # With capture_all_inputs=True, we should see rows where outputs are NaN + # but inputs are captured (these are the immediate input captures) + input_only_rows = res[res["output"].isna() & res["input"].notna()] + self.assertGreater(len(input_only_rows), 0, + "Expected rows with captured inputs and NaN " + "outputs when capture_all_inputs=True") + + # Verify the new input value was captured + self.assertIn(50, res["input"].dropna().values) + + def test_capture_all_inputs_false(self): + """Test that inputs are only captured at communication steps when + capture_all_inputs=False.""" + agent = self.get_agent() + simulator = Simulator( + config=self._get_module_cfg( + capture_all_inputs=False, + result_filename=None, + ), + agent=agent, + ) + agent.env.process(simulator.process()) + + # Simulate an input change mid-simulation + simulator.set("input", value=50) + + # Simulate long enough to trigger results writing + agent.env.run(until=self.t_sample_communication + self.t_sample_simulation) + + res = simulator.get_results() + + # Check that we don't have rows at non-communication times with only inputs + for idx in res.index: + if idx > 0 and idx % self.t_sample_communication != 0: + self.fail( + f"Found input-only row at non-communication time {idx} " + f"when capture_all_inputs=False" + ) + + def test_capture_all_inputs_dataframe_structure(self): + """Test that the resulting dataframe structure is correct in + both capture modes.""" + for capture_mode in [True, False]: + with self.subTest(capture_all_inputs=capture_mode): + agent = self.get_agent() + simulator = Simulator( + config=self._get_module_cfg( + capture_all_inputs=capture_mode, + result_filename=None, + ), + agent=agent, + ) + + # Run the full simulation + simulator.run() + res = simulator.get_results() + + # Verify basic dataframe structure + self.assertIsInstance(res, pd.DataFrame) + self.assertIn("input", res.columns) + self.assertIn("output", res.columns) + self.assertIn("output_2", res.columns) + + # Verify index is numeric (time-based) + self.assertTrue(all(isinstance(idx, (int, float)) for idx in res.index)) + + # Verify we have data + self.assertGreater(len(res), 0) + + # Verify outputs are present at communication times + t_comm = 2 + comm_times = [t for t in res.index if t > 0 and t % t_comm == 0] + for t in comm_times: + if t in res.index: + self.assertTrue( + pd.notna(res.loc[t, "output"]), + f"Expected output at communication time {t}" + ) + def test_wrong_causality(self): with self.assertRaises(ValidationError): Simulator(