Coverage for agentlib_flexquant/modules/flexibility_indicator.py: 94%
241 statements
« prev ^ index » next coverage.py v7.4.4, created at 2026-06-17 09:09 +0000
« prev ^ index » next coverage.py v7.4.4, created at 2026-06-17 09:09 +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.in_provision = False
482 self.offer_count = 0
483 self.df = pd.DataFrame(columns=pd.Series(self.var_list))
484 self.data = FlexibilityData(
485 prep_time=self.get(glbs.PREP_TIME).value,
486 market_time=self.get(glbs.MARKET_TIME).value,
487 flex_event_duration=self.get(glbs.FLEX_EVENT_DURATION).value,
488 time_step=self.get(glbs.TIME_STEP).value,
489 prediction_horizon=self.get(glbs.PREDICTION_HORIZON).value,
490 )
491 self.callback_handler = CallBackHandler(config=self.config)
492 self.data = self.callback_handler.initialize_callback_variables(data=self.data, config=self.config)
494 def register_callbacks(self):
495 inputs = self.config.inputs
496 for var in inputs:
497 self.agent.data_broker.register_callback(
498 name=var.name, alias=var.name, callback=self.callback
499 )
500 self.agent.data_broker.register_callback(
501 name=glbs.PROVISION_VAR_NAME, alias=glbs.PROVISION_VAR_NAME,
502 callback=self.callback
503 )
505 def process(self):
506 """Yield control to the simulation environment and wait for events."""
507 yield self.env.event()
509 def callback(self, inp, name):
510 """Handle incoming data by storing power/energy/price profiles and triggering
511 flexibility calculations when all required inputs are available.
512 """
514 if name == glbs.PROVISION_VAR_NAME:
515 self.in_provision = inp.value
517 if self.in_provision:
518 self.data = self.callback_handler.set_all_callback_variables_to_none(data=self.data)
519 else:
520 self.data = self.callback_handler.update_input(data=self.data, name=name, value=inp.value)
522 if self.callback_handler.is_ready_for_calculation(data=self.data):
523 # check the power profile end deviation
524 if not self.config.correct_costs.enable_energy_costs_correction:
525 self.check_power_end_deviation(
526 tol=self.config.correct_costs.absolute_power_deviation_tolerance
527 )
528 # calculate and send the offer and reset the callback variables
529 self.calc_and_send_offer()
530 self.data = self.callback_handler.set_all_callback_variables_to_none(data=self.data)
532 def get_results(self) -> Optional[pd.DataFrame]:
533 """Open results file of flexibility_indicator.py."""
534 results_file = self.config.results_file
535 try:
536 results = pd.read_csv(results_file, header=[0], index_col=[0, 1])
537 return results
538 except FileNotFoundError:
539 self.logger.error("Results file %s was not found.", results_file)
540 return None
542 def write_results(self, df: pd.DataFrame, ts: float, n: int) -> pd.DataFrame:
543 """Write every data of variables in self.var_list in an DataFrame.
545 DataFrame will be updated every time step
547 Args:
548 df: DataFrame which is initialised as an empty DataFrame with columns
549 according to self.var_list
550 ts: time step
551 n: number of time steps during prediction horizon
553 Returns:
554 DataFrame with results of every variable in self.var_list
556 """
557 results = []
558 now = self.env.now
560 # First, collect all series and their indices
561 all_series = []
562 for name in self.var_list:
563 # Get the appropriate values based on name
564 if name == glbs.POWER_ALIAS_BASE:
565 values = self.data.power_profile_base
566 elif name == glbs.POWER_ALIAS_NEG:
567 values = self.data.power_profile_flex_neg
568 elif name == glbs.POWER_ALIAS_POS:
569 values = self.data.power_profile_flex_pos
570 elif name == glbs.STORED_ENERGY_ALIAS_BASE:
571 values = self.data.stored_energy_profile_base
572 elif name == glbs.STORED_ENERGY_ALIAS_NEG:
573 values = self.data.stored_energy_profile_flex_neg
574 elif name == glbs.STORED_ENERGY_ALIAS_POS:
575 values = self.data.stored_energy_profile_flex_pos
576 elif self.config.correct_costs.eta_thermal_base and name == self.config.correct_costs.eta_thermal_base:
577 values = self.data.eta_thermal_base
578 elif name == self.config.price_variable:
579 values = self.data.electricity_price_series
580 elif name == self.config.price_variable_feed_in:
581 values = self.data.feed_in_price_series
582 elif name == glbs.COLLOCATION_TIME_GRID:
583 value = self.get(name).value
584 values = pd.Series(index=value, data=value)
585 else:
586 values = self.get(name).value
588 # Convert to Series if not already
589 if not isinstance(values, pd.Series):
590 values = pd.Series(values)
592 all_series.append((name, values))
594 # Create the standard grid for reference
595 standard_grid = np.arange(0, n * ts, ts)
597 # Find the union of all indices to create a comprehensive grid
598 all_indices = set(standard_grid)
599 for _, series in all_series:
600 all_indices.update(series.index)
601 combined_index = sorted(all_indices)
603 # Reindex all series to the combined grid
604 for i, (name, series) in enumerate(all_series):
605 # Reindex to the comprehensive grid
606 reindexed = series.reindex(combined_index)
607 results.append(reindexed)
609 if not now % ts:
610 self.time.append(now)
611 new_df = pd.DataFrame(results).T
612 new_df.columns = self.var_list
613 # Rename time_step variable column
614 new_df.rename(
615 columns={glbs.TIME_STEP: f"{glbs.TIME_STEP}_mpc"}, inplace=True
616 )
617 new_df.index.direction = "time"
618 new_df[glbs.TIME_STEP] = now
619 new_df.set_index([glbs.TIME_STEP, new_df.index], inplace=True)
620 df = pd.concat([df, new_df])
621 # set the indices once again as concat cant handle indices properly
622 indices = pd.MultiIndex.from_tuples(
623 df.index, names=[glbs.TIME_STEP, "time"]
624 )
625 df.set_index(indices, inplace=True)
626 # Drop column time_step and keep it as an index only
627 if glbs.TIME_STEP in df.columns:
628 df.drop(columns=[glbs.TIME_STEP], inplace=True)
630 return df
632 def cleanup_results(self):
633 """Remove the existing result files."""
634 results_file = self.config.results_file
635 if not results_file:
636 return
637 os.remove(results_file)
639 def calc_and_send_offer(self):
640 """Calculate the flexibility KPIs for current predictions, send the flex offer
641 and set the outputs, write and save the results."""
642 # Calculate the flexibility KPIs for current predictions
643 collocation_time_grid = self.get(glbs.COLLOCATION_TIME_GRID).value
644 self.data.calculate(
645 enable_energy_costs_correction=
646 self.config.correct_costs.enable_energy_costs_correction,
647 calculate_flex_cost=self.config.calculate_costs.calculate_flex_costs,
648 integration_method=self.config.integration_method,
649 collocation_time_grid=collocation_time_grid)
651 # get the full index during flex event including mpc_time_grid index and the
652 # collocation index
653 full_index = np.sort(np.concatenate([collocation_time_grid,
654 self.data.mpc_time_grid]))
655 flex_begin = self.get(glbs.MARKET_TIME).value + self.get(glbs.PREP_TIME).value
656 flex_end = flex_begin + self.get(glbs.FLEX_EVENT_DURATION).value
657 full_flex_offer_index = full_index[(full_index >= flex_begin) &
658 (full_index <= flex_end)]
660 # reindex the power profiles to not send the simulation points to the market,
661 # but only the values on the collocation points and the forward mean of them
662 base_power_profile = self.data.power_profile_base.reindex(
663 collocation_time_grid).reindex(full_flex_offer_index)
664 pos_diff_profile = self.data.kpis_pos.power_flex_offer.value.reindex(
665 collocation_time_grid).reindex(full_flex_offer_index)
666 neg_diff_profile = self.data.kpis_neg.power_flex_offer.value.reindex(
667 collocation_time_grid).reindex(full_flex_offer_index)
669 # fill the mpc_time_grid with forward mean
670 base_power_profile = fill_nans(base_power_profile, method=MEAN)
671 pos_diff_profile = fill_nans(pos_diff_profile, method=MEAN)
672 neg_diff_profile = fill_nans(neg_diff_profile, method=MEAN)
674 # Send flex offer
675 self.send_flex_offer(
676 name=glbs.FLEXIBILITY_OFFER,
677 base_power_profile=base_power_profile,
678 pos_diff_profile=pos_diff_profile,
679 pos_price=self.data.kpis_pos.costs.value,
680 neg_diff_profile=neg_diff_profile,
681 neg_price=self.data.kpis_neg.costs.value,
682 )
684 # set outputs
685 for kpi in self.data.get_kpis().values():
686 if kpi.get_kpi_identifier() not in [
687 kpis_pos.power_flex_within_boundary.get_kpi_identifier(),
688 kpis_neg.power_flex_within_boundary.get_kpi_identifier(),
689 ]:
690 for output in self.config.outputs:
691 if output.name == kpi.get_kpi_identifier():
692 self.set(output.name, kpi.value)
694 # write results
695 self.df = self.write_results(
696 df=self.df,
697 ts=self.get(glbs.TIME_STEP).value,
698 n=self.get(glbs.PREDICTION_HORIZON).value,
699 )
701 # save results
702 if self.config.save_results:
703 self.df.to_csv(self.config.results_file)
705 def send_flex_offer(
706 self,
707 name: str,
708 base_power_profile: pd.Series,
709 pos_diff_profile: pd.Series,
710 pos_price: float,
711 neg_diff_profile: pd.Series,
712 neg_price: float,
713 timestamp: float = None,
714 ):
715 """Send a flex offer as an agent Variable.
717 The first offer is dismissed, since the different MPCs need one time step
718 to fully initialize.
720 Args:
721 name: name of the agent variable
722 base_power_profile: time series of power from baseline mpc
723 pos_diff_profile: power profile for the positive difference (base-pos)
724 in flexibility event time grid
725 pos_price: price for positive flexibility
726 neg_diff_profile: power profile for the negative difference (neg-base)
727 in flexibility event time grid
728 neg_price: price for negative flexibility
729 timestamp: the time offer was generated
731 """
732 if self.offer_count > 0:
733 var = self._variables_dict[name]
734 var.value = FlexOffer(
735 base_power_profile=base_power_profile,
736 pos_diff_profile=pos_diff_profile,
737 pos_price=pos_price,
738 neg_diff_profile=neg_diff_profile,
739 neg_price=neg_price,
740 )
741 if timestamp is None:
742 timestamp = self.env.time
743 var.timestamp = timestamp
744 self.agent.data_broker.send_variable(
745 variable=var.copy(update={"source": self.source}), copy=False,
746 )
747 self.offer_count += 1
749 def check_power_end_deviation(self, tol: float):
750 """Calculate the deviation of the final value of the power profiles
751 and warn the user if it exceeds the tolerance."""
752 logger = logging.getLogger(__name__)
753 dev_pos = np.mean(
754 self.data.power_profile_flex_pos.values[-4:]
755 - self.data.power_profile_base.values[-4:]
756 )
757 dev_neg = np.mean(
758 self.data.power_profile_flex_neg.values[-4:]
759 - self.data.power_profile_base.values[-4:]
760 )
761 if abs(dev_pos) > tol:
762 logger.warning(
763 "There is an average deviation of %.6f kW between the final values of "
764 "power profiles of positive shadow MPC and the baseline. "
765 "Correction of energy costs might be necessary.",
766 dev_pos,
767 )
768 self.set(kpis_pos.power_flex_within_boundary.get_kpi_identifier(), False)
769 else:
770 self.set(kpis_pos.power_flex_within_boundary.get_kpi_identifier(), True)
771 if abs(dev_neg) > tol:
772 logger.warning(
773 "There is an average deviation of %.6f kW between the final values of "
774 "power profiles of negative shadow MPC and the baseline. "
775 "Correction of energy costs might be necessary.",
776 dev_neg,
777 )
778 self.set(kpis_neg.power_flex_within_boundary.get_kpi_identifier(), False)
779 else:
780 self.set(kpis_neg.power_flex_within_boundary.get_kpi_identifier(), True)