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

1""" 

2Flexibility indicator module for calculating and distributing energy flexibility offers. 

3 

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 

13 

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 

19 

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 

26 

27 

28class InputsForCorrectFlexCosts(BaseModel): 

29 """Configuration for flexibility cost correction. 

30 

31 """ 

32 

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 ) 

43 

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 ) 

49 

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 ) 

56 

57 

58class InputsForCalculateFlexCosts(BaseModel): 

59 """Configuration for flexibility cost calculation with optional constant 

60 pricing. 

61 

62 """ 

63 

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 ) 

79 

80 @model_validator(mode="after") 

81 def validate_constant_prices(self): 

82 """Validate that valid constant prices are provided when enabled.""" 

83 

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 ] 

96 

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 ) 

109 

110 return self 

111 

112 

113# Pos and neg kpis to get the right names for plotting 

114kpis_pos = FlexibilityKPIs(direction="positive") 

115kpis_neg = FlexibilityKPIs(direction="negative") 

116 

117 

118class FlexibilityIndicatorModuleConfig(agentlib.BaseModuleConfig): 

119 """Configuration for flexibility indicator module with power/energy inputs, 

120 KPI outputs, and cost calculation settings. 

121 

122 """ 

123 

124 model_config = ConfigDict(extra="forbid") 

125 

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 ] 

164 

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 ] 

310 

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 ] 

326 

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"] 

351 

352 correct_costs: InputsForCorrectFlexCosts = InputsForCorrectFlexCosts() 

353 calculate_costs: InputsForCalculateFlexCosts = InputsForCalculateFlexCosts() 

354 

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 

364 

365class CallBackHandler: 

366 """Helper class to manage callback handling for flexibility indicator module. 

367  

368 Adapter, der self.data schreibt  

369 

370 """ 

371 necessary_callback_variables: dict[str,dict[str, bool]] 

372 

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 } 

384 

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}}) 

395 

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 

405 

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 }) 

414 

415 return data 

416 

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 

422 

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 

430 

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) 

435 

436class FlexibilityIndicatorModule(agentlib.BaseModule): 

437 """Module for calculating flexibility KPIs and generating flexibility offers 

438 from MPC power/energy profiles.""" 

439 

440 config: FlexibilityIndicatorModuleConfig 

441 data: FlexibilityData 

442 callback_handler: CallBackHandler 

443 

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) 

464 

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 ) 

475 

476 def process(self): 

477 """Yield control to the simulation environment and wait for events.""" 

478 yield self.env.event() 

479 

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 """ 

484 

485 if name == glbs.PROVISION_VAR_NAME: 

486 self.in_provision = inp.value 

487 

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) 

492 

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) 

502 

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 

512 

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. 

515 

516 DataFrame will be updated every time step 

517 

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 

523 

524 Returns: 

525 DataFrame with results of every variable in self.var_list 

526 

527 """ 

528 results = [] 

529 now = self.env.now 

530 

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 

556 

557 # Convert to Series if not already 

558 if not isinstance(values, pd.Series): 

559 values = pd.Series(values) 

560 

561 all_series.append((name, values)) 

562 

563 # Create the standard grid for reference 

564 standard_grid = np.arange(0, n * ts, ts) 

565 

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) 

571 

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) 

577 

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) 

598 

599 return df 

600 

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) 

607 

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) 

619 

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)] 

628 

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) 

637 

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) 

642 

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 ) 

652 

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) 

662 

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 ) 

669 

670 # save results 

671 if self.config.save_results: 

672 self.df.to_csv(self.config.results_file) 

673 

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. 

685 

686 The first offer is dismissed, since the different MPCs need one time step 

687 to fully initialize. 

688 

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 

699 

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 

717 

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) 

750 

751 

752