Coverage for agentlib_flexquant/modules/flexibility_indicator.py: 93%
228 statements
« prev ^ index » next coverage.py v7.4.4, created at 2026-03-26 09:43 +0000
« prev ^ index » next coverage.py v7.4.4, created at 2026-03-26 09:43 +0000
1"""
2Flexibility indicator module for calculating and distributing energy flexibility offers.
4This module processes power and energy profiles from baseline and shadow MPCs to
5calculate flexibility KPIs, validate profile consistency, and generate flexibility
6offers for energy markets. It handles both positive and negative flexibility with
7optional cost calculations and energy storage corrections.
8"""
9import logging
10import os
11from pathlib import Path
12from typing import Optional
14import agentlib
15import numpy as np
16import pandas as pd
17from pydantic import BaseModel, ConfigDict, Field, model_validator
18from agentlib_flexquant.utils.data_handling import fill_nans, MEAN
20import agentlib_flexquant.data_structures.globals as glbs
21from agentlib_flexquant.data_structures.flex_kpis import (
22 FlexibilityData,
23 FlexibilityKPIs,
24)
25from agentlib_flexquant.data_structures.flex_offer import FlexOffer
28class InputsForCorrectFlexCosts(BaseModel):
29 """Configuration for flexibility cost correction.
31 """
33 enable_energy_costs_correction: bool = Field(
34 name="enable_energy_costs_correction",
35 description=(
36 "Variable determining whether to correct the costs of the "
37 "flexible energy. Define the variable for stored electrical "
38 "energy in the base MPC model and config as output if the "
39 "correction of costs is enabled"
40 ),
41 default=False,
42 )
44 absolute_power_deviation_tolerance: float = Field(
45 name="absolute_power_deviation_tolerance",
46 default=0.1,
47 description="Absolute tolerance in kW within which no warning is thrown",
48 )
50 stored_energy_variable: Optional[str] = Field(
51 name="stored_energy_variable",
52 default=None,
53 description="Name of the variable representing the stored electrical energy "
54 "in the baseline config"
55 )
58class InputsForCalculateFlexCosts(BaseModel):
59 """Configuration for flexibility cost calculation with optional constant
60 pricing.
62 """
64 use_constant_electricity_price: bool = Field(
65 default=False, description="Use constant electricity price"
66 )
67 use_constant_feed_in_price: bool = Field(
68 default=False, description="Use constant feed-in price"
69 )
70 calculate_flex_costs: bool = Field(
71 default=True, description="Calculate the flexibility cost"
72 )
73 const_electricity_price: float = Field(
74 default=np.nan, description="constant electricity price in ct/kWh"
75 )
76 const_feed_in_price: float = Field(
77 default=np.nan, description="constant feed-in price in ct/kWh"
78 )
80 @model_validator(mode="after")
81 def validate_constant_prices(self):
82 """Validate that valid constant prices are provided when enabled."""
84 price_settings = [
85 (
86 "use_constant_electricity_price",
87 "const_electricity_price",
88 "electricity",
89 ),
90 (
91 "use_constant_feed_in_price",
92 "const_feed_in_price",
93 "feed-in",
94 ),
95 ]
97 for use_flag, price_field, label in price_settings:
98 if getattr(self, use_flag) and np.isnan(getattr(self, price_field)):
99 raise ValueError(
100 (
101 f'Constant {label} price must be a valid float if it is used '
102 f'for calculation. '
103 f'Received "{use_flag}": true, '
104 f'"{price_field}": {getattr(self, price_field)}. '
105 'Please specify them correctly in the "calculate_costs" '
106 "field in the flex config."
107 )
108 )
110 return self
113# Pos and neg kpis to get the right names for plotting
114kpis_pos = FlexibilityKPIs(direction="positive")
115kpis_neg = FlexibilityKPIs(direction="negative")
118class FlexibilityIndicatorModuleConfig(agentlib.BaseModuleConfig):
119 """Configuration for flexibility indicator module with power/energy inputs,
120 KPI outputs, and cost calculation settings.
122 """
124 model_config = ConfigDict(extra="forbid")
126 inputs: list[agentlib.AgentVariable] = [
127 agentlib.AgentVariable(
128 name=glbs.POWER_ALIAS_BASE,
129 unit="W",
130 type="pd.Series",
131 description="The power input to the system",
132 ),
133 agentlib.AgentVariable(
134 name=glbs.POWER_ALIAS_NEG,
135 unit="W",
136 type="pd.Series",
137 description="The power input to the system",
138 ),
139 agentlib.AgentVariable(
140 name=glbs.POWER_ALIAS_POS,
141 unit="W",
142 type="pd.Series",
143 description="The power input to the system",
144 ),
145 agentlib.AgentVariable(
146 name=glbs.STORED_ENERGY_ALIAS_BASE,
147 unit="kWh",
148 type="pd.Series",
149 description="Energy stored in the system w.r.t. 0K",
150 ),
151 agentlib.AgentVariable(
152 name=glbs.STORED_ENERGY_ALIAS_NEG,
153 unit="kWh",
154 type="pd.Series",
155 description="Energy stored in the system w.r.t. 0K",
156 ),
157 agentlib.AgentVariable(
158 name=glbs.STORED_ENERGY_ALIAS_POS,
159 unit="kWh",
160 type="pd.Series",
161 description="Energy stored in the system w.r.t. 0K",
162 ),
163 ]
165 outputs: list[agentlib.AgentVariable] = [
166 # Flexibility offer
167 agentlib.AgentVariable(name=glbs.FLEXIBILITY_OFFER, type="FlexOffer"),
168 # Power KPIs
169 agentlib.AgentVariable(
170 name=kpis_neg.power_flex_full.get_kpi_identifier(),
171 unit="W",
172 type="pd.Series",
173 description="Negative power flexibility",
174 ),
175 agentlib.AgentVariable(
176 name=kpis_pos.power_flex_full.get_kpi_identifier(),
177 unit="W",
178 type="pd.Series",
179 description="Positive power flexibility",
180 ),
181 agentlib.AgentVariable(
182 name=kpis_neg.power_flex_offer.get_kpi_identifier(),
183 unit="W",
184 type="pd.Series",
185 description="Negative power flexibility",
186 ),
187 agentlib.AgentVariable(
188 name=kpis_pos.power_flex_offer.get_kpi_identifier(),
189 unit="W",
190 type="pd.Series",
191 description="Positive power flexibility",
192 ),
193 agentlib.AgentVariable(
194 name=kpis_neg.power_flex_offer_min.get_kpi_identifier(),
195 unit="W",
196 type="float",
197 description="Minimum of negative power flexibility",
198 ),
199 agentlib.AgentVariable(
200 name=kpis_pos.power_flex_offer_min.get_kpi_identifier(),
201 unit="W",
202 type="float",
203 description="Minimum of positive power flexibility",
204 ),
205 agentlib.AgentVariable(
206 name=kpis_neg.power_flex_offer_max.get_kpi_identifier(),
207 unit="W",
208 type="float",
209 description="Maximum of negative power flexibility",
210 ),
211 agentlib.AgentVariable(
212 name=kpis_pos.power_flex_offer_max.get_kpi_identifier(),
213 unit="W",
214 type="float",
215 description="Maximum of positive power flexibility",
216 ),
217 agentlib.AgentVariable(
218 name=kpis_neg.power_flex_offer_avg.get_kpi_identifier(),
219 unit="W",
220 type="float",
221 description="Average of negative power flexibility",
222 ),
223 agentlib.AgentVariable(
224 name=kpis_pos.power_flex_offer_avg.get_kpi_identifier(),
225 unit="W",
226 type="float",
227 description="Average of positive power flexibility",
228 ),
229 agentlib.AgentVariable(
230 name=kpis_neg.power_flex_within_boundary.get_kpi_identifier(),
231 unit="-",
232 type="bool",
233 description=(
234 "Variable indicating whether the baseline power and flex power "
235 "align at the horizon end"
236 ),
237 ),
238 agentlib.AgentVariable(
239 name=kpis_pos.power_flex_within_boundary.get_kpi_identifier(),
240 unit="-",
241 type="bool",
242 description=(
243 "Variable indicating whether the baseline power and flex power "
244 "align at the horizon end"
245 ),
246 ),
247 # Energy KPIs
248 agentlib.AgentVariable(
249 name=kpis_neg.energy_flex.get_kpi_identifier(),
250 unit="kWh",
251 type="float",
252 description="Negative energy flexibility",
253 ),
254 agentlib.AgentVariable(
255 name=kpis_pos.energy_flex.get_kpi_identifier(),
256 unit="kWh",
257 type="float",
258 description="Positive energy flexibility",
259 ),
260 # Costs KPIs
261 agentlib.AgentVariable(
262 name=kpis_neg.costs.get_kpi_identifier(),
263 unit="ct",
264 type="float",
265 description="Saved costs due to baseline",
266 ),
267 agentlib.AgentVariable(
268 name=kpis_pos.costs.get_kpi_identifier(),
269 unit="ct",
270 type="float",
271 description="Saved costs due to baseline",
272 ),
273 agentlib.AgentVariable(
274 name=kpis_neg.corrected_costs.get_kpi_identifier(),
275 unit="ct",
276 type="float",
277 description="Corrected saved costs due to baseline",
278 ),
279 agentlib.AgentVariable(
280 name=kpis_pos.corrected_costs.get_kpi_identifier(),
281 unit="ct",
282 type="float",
283 description="Corrected saved costs due to baseline",
284 ),
285 agentlib.AgentVariable(
286 name=kpis_neg.costs_rel.get_kpi_identifier(),
287 unit="ct/kWh",
288 type="float",
289 description="Saved costs due to baseline",
290 ),
291 agentlib.AgentVariable(
292 name=kpis_pos.costs_rel.get_kpi_identifier(),
293 unit="ct/kWh",
294 type="float",
295 description="Saved costs due to baseline",
296 ),
297 agentlib.AgentVariable(
298 name=kpis_neg.corrected_costs_rel.get_kpi_identifier(),
299 unit="ct/kWh",
300 type="float",
301 description="Corrected saved costs per energy due to baseline",
302 ),
303 agentlib.AgentVariable(
304 name=kpis_pos.corrected_costs_rel.get_kpi_identifier(),
305 unit="ct/kWh",
306 type="float",
307 description="Corrected saved costs per energy due to baseline",
308 ),
309 ]
311 parameters: list[agentlib.AgentVariable] = [
312 agentlib.AgentVariable(name=glbs.PREP_TIME, unit="s",
313 description="Preparation time"),
314 agentlib.AgentVariable(name=glbs.MARKET_TIME, unit="s",
315 description="Market time"),
316 agentlib.AgentVariable(name=glbs.FLEX_EVENT_DURATION, unit="s",
317 description="time to switch objective"),
318 agentlib.AgentVariable(name=glbs.TIME_STEP, unit="s",
319 description="timestep of the mpc solution"),
320 agentlib.AgentVariable(name=glbs.PREDICTION_HORIZON, unit="-",
321 description="prediction horizon of the mpc solution"),
322 agentlib.AgentVariable(name=glbs.COLLOCATION_TIME_GRID,
323 alias=glbs.COLLOCATION_TIME_GRID,
324 description="Time grid of the mpc model output")
325 ]
327 results_file: Optional[Path] = Field(
328 default=Path("flexibility_indicator.csv"),
329 description="User specified results file name",
330 )
331 save_results: Optional[bool] = Field(
332 validate_default=True,
333 default=True
334 )
335 price_variable: str = Field(
336 default="c_pel", description="Name of the price variable sent by a predictor",
337 )
338 price_variable_feed_in: str = Field(
339 default="c_pel_feed_in",
340 description="Name of the feed-in price variable sent by a predictor",
341 )
342 power_unit: str = Field(
343 default="kW",
344 description="Unit of the power variable"
345 )
346 integration_method: glbs.INTEGRATION_METHOD = Field(
347 default=glbs.LINEAR,
348 description="Method set to integrate series variable"
349 )
350 shared_variable_fields: list[str] = ["outputs"]
352 correct_costs: InputsForCorrectFlexCosts = InputsForCorrectFlexCosts()
353 calculate_costs: InputsForCalculateFlexCosts = InputsForCalculateFlexCosts()
355 @model_validator(mode="after")
356 def check_results_file_extension(self):
357 """Validate that results_file has a .csv extension."""
358 if self.results_file and self.results_file.suffix != ".csv":
359 raise ValueError(
360 f"Invalid file extension for 'results_file': '{self.results_file}'. "
361 f"Expected a '.csv' file."
362 )
363 return self
365class CallBackHandler:
366 """Helper class to manage callback handling for flexibility indicator module.
368 Adapter, der self.data schreibt
370 """
371 necessary_callback_variables: dict[str,dict[str, bool]]
373 def __init__(self,config: FlexibilityIndicatorModuleConfig):
374 """Load general settings"""
375 # set collocation time grid
376 def get_param(cfg, name: str):
377 return next(v for v in cfg.parameters if v.name == name)
378 self.collocation_time_grid = get_param(config, glbs.COLLOCATION_TIME_GRID).value
379 self.necessary_callback_variables = {
380 glbs.POWER_ALIAS_BASE: {"name":"power_profile_base", "is_mpc":True},
381 glbs.POWER_ALIAS_NEG: {"name":"power_profile_flex_neg", "is_mpc":True},
382 glbs.POWER_ALIAS_POS: {"name":"power_profile_flex_pos", "is_mpc":True},
383 }
385 def update_price_variables(self, config: FlexibilityIndicatorModuleConfig, data: FlexibilityData):
386 if config.calculate_costs.calculate_flex_costs:
387 if config.calculate_costs.use_constant_electricity_price:
388 electricity_price_series = pd.Series(
389 data=config.calculate_costs.const_electricity_price,
390 index=data.mpc_time_grid,
391 )
392 data.update_profile("electricity_price_series", electricity_price_series, mpc=False)
393 else:
394 self.necessary_callback_variables.update({config.price_variable: {"name":"electricity_price_series", "is_mpc":False}})
396 if config.calculate_costs.use_constant_feed_in_price:
397 feed_in_price_series = pd.Series(
398 data=config.calculate_costs.const_feed_in_price,
399 index=data.mpc_time_grid,
400 )
401 data.update_profile("feed_in_price_series", feed_in_price_series, mpc=False)
402 else:
403 self.necessary_callback_variables.update({config.price_variable_feed_in: {"name":"feed_in_price_series", "is_mpc":False}})
404 return data
406 def initialize_callback_variables(self, data: FlexibilityData, config: FlexibilityIndicatorModuleConfig) -> FlexibilityData:
407 data = self.update_price_variables(config=config, data=data)
408 if config.correct_costs.enable_energy_costs_correction:
409 self.necessary_callback_variables.update({
410 glbs.STORED_ENERGY_ALIAS_BASE: {"name":"stored_energy_profile_base", "is_mpc":True},
411 glbs.STORED_ENERGY_ALIAS_NEG: {"name":"stored_energy_profile_flex_neg", "is_mpc":True},
412 glbs.STORED_ENERGY_ALIAS_POS: {"name":"stored_energy_profile_flex_pos", "is_mpc":True},
413 })
415 return data
417 def set_all_callback_variables_to_none(self, data: FlexibilityData) -> FlexibilityData:
418 """Clear the values of the callback variables after processing."""
419 for alias, var in self.necessary_callback_variables.items():
420 data.update_profile(var["name"], None, mpc=var["is_mpc"])
421 return data
423 def update_input(self, data: FlexibilityData, name: str, value: pd.Series) -> FlexibilityData:
424 """Update the incoming value"""
425 var_tuple = self.necessary_callback_variables.get(name, None)
426 if var_tuple is not None:
427 variable_name, mpc = var_tuple["name"], var_tuple["is_mpc"]
428 data.update_profile(variable_name, value, mpc=mpc)
429 return data
431 def is_ready_for_calculation(self, data: FlexibilityData) -> bool:
432 """Check if all necessary profiles and parameters are set for KPI calculation."""
433 required_profiles = [getattr(data, var["name"]) for key, var in self.necessary_callback_variables.items()]
434 return all(profile is not None for profile in required_profiles)
436class FlexibilityIndicatorModule(agentlib.BaseModule):
437 """Module for calculating flexibility KPIs and generating flexibility offers
438 from MPC power/energy profiles."""
440 config: FlexibilityIndicatorModuleConfig
441 data: FlexibilityData
442 callback_handler: CallBackHandler
444 def __init__(self, *args, **kwargs):
445 super().__init__(*args, **kwargs)
446 self.var_list = []
447 for variable in self.variables:
448 if variable.name in [glbs.FLEXIBILITY_OFFER]:
449 continue
450 self.var_list.append(variable.name)
451 self.time = []
452 self.in_provision = False
453 self.offer_count = 0
454 self.df = pd.DataFrame(columns=pd.Series(self.var_list))
455 self.data = FlexibilityData(
456 prep_time=self.get(glbs.PREP_TIME).value,
457 market_time=self.get(glbs.MARKET_TIME).value,
458 flex_event_duration=self.get(glbs.FLEX_EVENT_DURATION).value,
459 time_step=self.get(glbs.TIME_STEP).value,
460 prediction_horizon=self.get(glbs.PREDICTION_HORIZON).value,
461 )
462 self.callback_handler = CallBackHandler(config=self.config)
463 self.data = self.callback_handler.initialize_callback_variables(data=self.data, config=self.config)
465 def register_callbacks(self):
466 inputs = self.config.inputs
467 for var in inputs:
468 self.agent.data_broker.register_callback(
469 name=var.name, alias=var.name, callback=self.callback
470 )
471 self.agent.data_broker.register_callback(
472 name=glbs.PROVISION_VAR_NAME, alias=glbs.PROVISION_VAR_NAME,
473 callback=self.callback
474 )
476 def process(self):
477 """Yield control to the simulation environment and wait for events."""
478 yield self.env.event()
480 def callback(self, inp, name):
481 """Handle incoming data by storing power/energy/price profiles and triggering
482 flexibility calculations when all required inputs are available.
483 """
485 if name == glbs.PROVISION_VAR_NAME:
486 self.in_provision = inp.value
488 if self.in_provision:
489 self.data = self.callback_handler.set_all_callback_variables_to_none(data=self.data)
490 else:
491 self.data = self.callback_handler.update_input(data=self.data, name=name, value=inp.value)
493 if self.callback_handler.is_ready_for_calculation(data=self.data):
494 # check the power profile end deviation
495 if not self.config.correct_costs.enable_energy_costs_correction:
496 self.check_power_end_deviation(
497 tol=self.config.correct_costs.absolute_power_deviation_tolerance
498 )
499 # calculate and send the offer and reset the callback variables
500 self.calc_and_send_offer()
501 self.data = self.callback_handler.set_all_callback_variables_to_none(data=self.data)
503 def get_results(self) -> Optional[pd.DataFrame]:
504 """Open results file of flexibility_indicator.py."""
505 results_file = self.config.results_file
506 try:
507 results = pd.read_csv(results_file, header=[0], index_col=[0, 1])
508 return results
509 except FileNotFoundError:
510 self.logger.error("Results file %s was not found.", results_file)
511 return None
513 def write_results(self, df: pd.DataFrame, ts: float, n: int) -> pd.DataFrame:
514 """Write every data of variables in self.var_list in an DataFrame.
516 DataFrame will be updated every time step
518 Args:
519 df: DataFrame which is initialised as an empty DataFrame with columns
520 according to self.var_list
521 ts: time step
522 n: number of time steps during prediction horizon
524 Returns:
525 DataFrame with results of every variable in self.var_list
527 """
528 results = []
529 now = self.env.now
531 # First, collect all series and their indices
532 all_series = []
533 for name in self.var_list:
534 # Get the appropriate values based on name
535 if name == glbs.POWER_ALIAS_BASE:
536 values = self.data.power_profile_base
537 elif name == glbs.POWER_ALIAS_NEG:
538 values = self.data.power_profile_flex_neg
539 elif name == glbs.POWER_ALIAS_POS:
540 values = self.data.power_profile_flex_pos
541 elif name == glbs.STORED_ENERGY_ALIAS_BASE:
542 values = self.data.stored_energy_profile_base
543 elif name == glbs.STORED_ENERGY_ALIAS_NEG:
544 values = self.data.stored_energy_profile_flex_neg
545 elif name == glbs.STORED_ENERGY_ALIAS_POS:
546 values = self.data.stored_energy_profile_flex_pos
547 elif name == self.config.price_variable:
548 values = self.data.electricity_price_series
549 elif name == self.config.price_variable_feed_in:
550 values = self.data.feed_in_price_series
551 elif name == glbs.COLLOCATION_TIME_GRID:
552 value = self.get(name).value
553 values = pd.Series(index=value, data=value)
554 else:
555 values = self.get(name).value
557 # Convert to Series if not already
558 if not isinstance(values, pd.Series):
559 values = pd.Series(values)
561 all_series.append((name, values))
563 # Create the standard grid for reference
564 standard_grid = np.arange(0, n * ts, ts)
566 # Find the union of all indices to create a comprehensive grid
567 all_indices = set(standard_grid)
568 for _, series in all_series:
569 all_indices.update(series.index)
570 combined_index = sorted(all_indices)
572 # Reindex all series to the combined grid
573 for i, (name, series) in enumerate(all_series):
574 # Reindex to the comprehensive grid
575 reindexed = series.reindex(combined_index)
576 results.append(reindexed)
578 if not now % ts:
579 self.time.append(now)
580 new_df = pd.DataFrame(results).T
581 new_df.columns = self.var_list
582 # Rename time_step variable column
583 new_df.rename(
584 columns={glbs.TIME_STEP: f"{glbs.TIME_STEP}_mpc"}, inplace=True
585 )
586 new_df.index.direction = "time"
587 new_df[glbs.TIME_STEP] = now
588 new_df.set_index([glbs.TIME_STEP, new_df.index], inplace=True)
589 df = pd.concat([df, new_df])
590 # set the indices once again as concat cant handle indices properly
591 indices = pd.MultiIndex.from_tuples(
592 df.index, names=[glbs.TIME_STEP, "time"]
593 )
594 df.set_index(indices, inplace=True)
595 # Drop column time_step and keep it as an index only
596 if glbs.TIME_STEP in df.columns:
597 df.drop(columns=[glbs.TIME_STEP], inplace=True)
599 return df
601 def cleanup_results(self):
602 """Remove the existing result files."""
603 results_file = self.config.results_file
604 if not results_file:
605 return
606 os.remove(results_file)
608 def calc_and_send_offer(self):
609 """Calculate the flexibility KPIs for current predictions, send the flex offer
610 and set the outputs, write and save the results."""
611 # Calculate the flexibility KPIs for current predictions
612 collocation_time_grid = self.get(glbs.COLLOCATION_TIME_GRID).value
613 self.data.calculate(
614 enable_energy_costs_correction=
615 self.config.correct_costs.enable_energy_costs_correction,
616 calculate_flex_cost=self.config.calculate_costs.calculate_flex_costs,
617 integration_method=self.config.integration_method,
618 collocation_time_grid=collocation_time_grid)
620 # get the full index during flex event including mpc_time_grid index and the
621 # collocation index
622 full_index = np.sort(np.concatenate([collocation_time_grid,
623 self.data.mpc_time_grid]))
624 flex_begin = self.get(glbs.MARKET_TIME).value + self.get(glbs.PREP_TIME).value
625 flex_end = flex_begin + self.get(glbs.FLEX_EVENT_DURATION).value
626 full_flex_offer_index = full_index[(full_index >= flex_begin) &
627 (full_index <= flex_end)]
629 # reindex the power profiles to not send the simulation points to the market,
630 # but only the values on the collocation points and the forward mean of them
631 base_power_profile = self.data.power_profile_base.reindex(
632 collocation_time_grid).reindex(full_flex_offer_index)
633 pos_diff_profile = self.data.kpis_pos.power_flex_offer.value.reindex(
634 collocation_time_grid).reindex(full_flex_offer_index)
635 neg_diff_profile = self.data.kpis_neg.power_flex_offer.value.reindex(
636 collocation_time_grid).reindex(full_flex_offer_index)
638 # fill the mpc_time_grid with forward mean
639 base_power_profile = fill_nans(base_power_profile, method=MEAN)
640 pos_diff_profile = fill_nans(pos_diff_profile, method=MEAN)
641 neg_diff_profile = fill_nans(neg_diff_profile, method=MEAN)
643 # Send flex offer
644 self.send_flex_offer(
645 name=glbs.FLEXIBILITY_OFFER,
646 base_power_profile=base_power_profile,
647 pos_diff_profile=pos_diff_profile,
648 pos_price=self.data.kpis_pos.costs.value,
649 neg_diff_profile=neg_diff_profile,
650 neg_price=self.data.kpis_neg.costs.value,
651 )
653 # set outputs
654 for kpi in self.data.get_kpis().values():
655 if kpi.get_kpi_identifier() not in [
656 kpis_pos.power_flex_within_boundary.get_kpi_identifier(),
657 kpis_neg.power_flex_within_boundary.get_kpi_identifier(),
658 ]:
659 for output in self.config.outputs:
660 if output.name == kpi.get_kpi_identifier():
661 self.set(output.name, kpi.value)
663 # write results
664 self.df = self.write_results(
665 df=self.df,
666 ts=self.get(glbs.TIME_STEP).value,
667 n=self.get(glbs.PREDICTION_HORIZON).value,
668 )
670 # save results
671 if self.config.save_results:
672 self.df.to_csv(self.config.results_file)
674 def send_flex_offer(
675 self,
676 name: str,
677 base_power_profile: pd.Series,
678 pos_diff_profile: pd.Series,
679 pos_price: float,
680 neg_diff_profile: pd.Series,
681 neg_price: float,
682 timestamp: float = None,
683 ):
684 """Send a flex offer as an agent Variable.
686 The first offer is dismissed, since the different MPCs need one time step
687 to fully initialize.
689 Args:
690 name: name of the agent variable
691 base_power_profile: time series of power from baseline mpc
692 pos_diff_profile: power profile for the positive difference (base-pos)
693 in flexibility event time grid
694 pos_price: price for positive flexibility
695 neg_diff_profile: power profile for the negative difference (neg-base)
696 in flexibility event time grid
697 neg_price: price for negative flexibility
698 timestamp: the time offer was generated
700 """
701 if self.offer_count > 0:
702 var = self._variables_dict[name]
703 var.value = FlexOffer(
704 base_power_profile=base_power_profile,
705 pos_diff_profile=pos_diff_profile,
706 pos_price=pos_price,
707 neg_diff_profile=neg_diff_profile,
708 neg_price=neg_price,
709 )
710 if timestamp is None:
711 timestamp = self.env.time
712 var.timestamp = timestamp
713 self.agent.data_broker.send_variable(
714 variable=var.copy(update={"source": self.source}), copy=False,
715 )
716 self.offer_count += 1
718 def check_power_end_deviation(self, tol: float):
719 """Calculate the deviation of the final value of the power profiles
720 and warn the user if it exceeds the tolerance."""
721 logger = logging.getLogger(__name__)
722 dev_pos = np.mean(
723 self.data.power_profile_flex_pos.values[-4:]
724 - self.data.power_profile_base.values[-4:]
725 )
726 dev_neg = np.mean(
727 self.data.power_profile_flex_neg.values[-4:]
728 - self.data.power_profile_base.values[-4:]
729 )
730 if abs(dev_pos) > tol:
731 logger.warning(
732 "There is an average deviation of %.6f kW between the final values of "
733 "power profiles of positive shadow MPC and the baseline. "
734 "Correction of energy costs might be necessary.",
735 dev_pos,
736 )
737 self.set(kpis_pos.power_flex_within_boundary.get_kpi_identifier(), False)
738 else:
739 self.set(kpis_pos.power_flex_within_boundary.get_kpi_identifier(), True)
740 if abs(dev_neg) > tol:
741 logger.warning(
742 "There is an average deviation of %.6f kW between the final values of "
743 "power profiles of negative shadow MPC and the baseline. "
744 "Correction of energy costs might be necessary.",
745 dev_neg,
746 )
747 self.set(kpis_neg.power_flex_within_boundary.get_kpi_identifier(), False)
748 else:
749 self.set(kpis_neg.power_flex_within_boundary.get_kpi_identifier(), True)