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