Skip to content

Commit

Permalink
feat: time profiled KPIs (#92)
Browse files Browse the repository at this point in the history
Added an option for generating KPIs on time profiles instead of evaluating accumulated values over the whole simulation.
This option can be enabled using the new simulation parameter "kpi_time_profile". This parameter can either be a boolean (default is False) or a list defining time intervals (example: [0, 25200, 28800, ..]).
Time profiling will apply to all KPIs that are compatible (see PROFILE_COMPATIBILITY class attribute).
Simulation will crash if KPIs with mixed compatibilities are mixed in a same KpiOutput.
KPIs evaluation methods have been adapted to provide correct evaluations on the time intervals.
  • Loading branch information
leo-desbureaux-tellae authored May 27, 2024
1 parent 702878c commit 7ef4b24
Show file tree
Hide file tree
Showing 10 changed files with 757 additions and 496 deletions.
8 changes: 4 additions & 4 deletions starling_sim/basemodel/input/dynamic_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,10 @@ def new_agent_input(self, feature):
duplicated_feature = copy.deepcopy(feature)
del duplicated_feature["properties"]["duplicates"]
# create a new id using the original agent id and the duplicate index
duplicated_feature["properties"][
"agent_id"
] = self.DUPLICATE_AGENT_ID_FORMAT.format(
original_id=input_dict["agent_id"], index=i
duplicated_feature["properties"]["agent_id"] = (
self.DUPLICATE_AGENT_ID_FORMAT.format(
original_id=input_dict["agent_id"], index=i
)
)

new_agents.append(self.new_agent_input(duplicated_feature))
Expand Down
140 changes: 80 additions & 60 deletions starling_sim/basemodel/output/kpi_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

import logging
import pandas as pd
import os
import math
from datetime import datetime, time, timedelta


KEY_TIME_RANGE = "timeRange"


class KpiOutput:
Expand All @@ -24,6 +30,13 @@ def __init__(self, population_names, kpi_list, kpi_name=None):

# list of kpi to evaluate the given agents
self.kpi_list = kpi_list
self.columns = None

# dict containing kpi values
self.kpi_rows = None

# indicates if KpiOutput is time profiled
self.time_profiling = None

# output file
self.filename = None
Expand All @@ -45,8 +58,26 @@ def setup(self, filename, folder, simulation_model):
self.filename = filename
self.folder = folder

# setup kpis and get columns
columns = [KPI.KEY_ID]
time_profiling = None
for kpi in self.kpi_list:
kpi.setup(simulation_model)
# setup kpi
kpi.setup(self, simulation_model)
if time_profiling is None:
time_profiling = kpi.profile is not None
else:
assert time_profiling == (
kpi.profile is not None
), f"KPIs profiling types cannot be mixed ({self.name})"

# add kpi columns
columns += kpi.export_keys

self.time_profiling = time_profiling
if self.time_profiling:
columns.insert(1, KEY_TIME_RANGE)
self.columns = columns

if isinstance(self.population_names, list):
self.populations = [
Expand All @@ -56,64 +87,29 @@ def setup(self, filename, folder, simulation_model):
else:
self.populations = [simulation_model.agentPopulation[self.population_names]]

def agent_kpi_dict(self, agent):
"""
Computes the KPIs for the given agent
by calling their update method for all its trace
:param agent:
:return:
"""

indicators_dict = dict()

# get agent trace
events = agent.trace.eventList

# evaluate all indicators in a single pass
for event in events:
for kpi in self.kpi_list:
kpi.update(event, agent)

# merge all completed indicators
for kpi in self.kpi_list:
indicators_dict.update(kpi.indicator_dict)

# raising a warning with sphinx
# indicators_dict = {**indicators_dict, **kpi.indicator_dict}

# reset kpi values
kpi.new_indicator_dict()

# return complete indicator dict
return indicators_dict

def write_kpi_table(self):
"""
Write the KPI of the population in the csv file
obtained from out file attributes
The KPIs evaluated are defined by the kpi_list attribute
"""

# first row is always agent's id, then we add the kpi_list keys
header_list = [KPI.KEY_ID]
for kpi in self.kpi_list:
header_list += kpi.keys

path = self.folder + self.filename

kpi_table = pd.DataFrame()
# build the KPI table for all agents of each population
kpi_table = self.build_kpi_table()

# compute the kpi table for each population dict
for population in self.populations:
kpi_table = pd.concat([kpi_table, self.compute_population_kpi_table(population)])
if self.time_profiling:
kpi_table[KEY_TIME_RANGE] = kpi_table[KEY_TIME_RANGE].apply(
lambda x: (datetime.min + timedelta(seconds=x)).strftime("%H:%M:%S")
)

# do not generate a kpi output if the kpi table is empty
if kpi_table.empty:
return

path = str(os.path.join(self.folder, self.filename))
try:
# write the dataframe into a csv file
kpi_table.to_csv(path, sep=";", index=False, columns=header_list)
kpi_table.to_csv(path, sep=";", index=False)

# signal new file to output factory
mimetype = "text/csv"
Expand All @@ -132,27 +128,51 @@ def write_kpi_table(self):
"Could not generate kpi output {}, " "error occurred : {}".format(path, e)
)

def compute_population_kpi_table(self, population):
def build_kpi_table(self) -> pd.DataFrame:
"""
Compute a kpi table for the given population dict.
Build the output KPI table.
:param population: population dict {id: agent}
:return: DataFrame containing the KPI values
KPIs from self.kpis are evaluated on each agent of each population from self.populations.
:return: KPI DataFrame
"""

df_output = pd.DataFrame()
kpi_tables = []
# compute kpi tables for each population dict
for population in self.populations:
# compute a kpi table for each agent of the population
for agent in population.values():
kpi_tables.append(self.compute_agent_kpis(agent))

for agent in population.values():
# create kpi dict for the agent
agent_indicators = self.agent_kpi_dict(agent)
result = pd.concat(kpi_tables)
return result

# build a dataframe from the dict
if isinstance(agent_indicators[KPI.KEY_ID], list):
df = pd.DataFrame(agent_indicators)
else:
df = pd.DataFrame(agent_indicators, index=[0])
def compute_agent_kpis(self, agent):
"""
Build a DataFrame containing indicator evaluated on the given agent.
The DataFrame columns are defined by the KPIs `keys` attributes,
with and additional column for the agent id, and an optional column
for time profiling.
The DataFrame can contain several rows, for instance when KPIs
are profiled by time.
:param agent: Agent on which KPIs are evaluated
:return: DataFrame containing indicators evaluated on the given agent
"""

self.kpi_rows = {key: [] for key in self.columns}

# evaluate indicators on agent
for kpi in self.kpi_list:
kpi.evaluate_for_agent(agent)

self.kpi_rows[KPI.KEY_ID] = agent.id
if self.time_profiling:
self.kpi_rows[KEY_TIME_RANGE] = self.kpi_list[0].profile

# append the dataframe to the total output
df_output = pd.concat([df_output, df])
res = pd.DataFrame(self.kpi_rows, columns=self.columns)

return df_output
return res
Loading

0 comments on commit 7ef4b24

Please sign in to comment.