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

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 eta_thermal_base: str = Field( 

58 default=None, 

59 description="Name of the efficiency variable of the thermal generation unit", 

60 ) 

61 

62 

63class InputsForCalculateFlexCosts(BaseModel): 

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

65 pricing. 

66 

67 """ 

68 

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 ) 

84 

85 @model_validator(mode="after") 

86 def validate_constant_prices(self): 

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

88 

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 ] 

101 

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 ) 

114 

115 return self 

116 

117 

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

119kpis_pos = FlexibilityKPIs(direction="positive") 

120kpis_neg = FlexibilityKPIs(direction="negative") 

121 

122 

123class FlexibilityIndicatorModuleConfig(agentlib.BaseModuleConfig): 

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

125 KPI outputs, and cost calculation settings. 

126 

127 """ 

128 

129 model_config = ConfigDict(extra="forbid") 

130 

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 ] 

169 

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 ] 

315 

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 ] 

331 

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

360 

361 correct_costs: InputsForCorrectFlexCosts = InputsForCorrectFlexCosts() 

362 calculate_costs: InputsForCalculateFlexCosts = InputsForCalculateFlexCosts() 

363 

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 

373 

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 

387 

388class CallBackHandler: 

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

390  

391 Adapter, der self.data schreibt  

392 

393 """ 

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

395 

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 } 

407 

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

418 

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 

428 

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

441 

442 

443 return data 

444 

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 

450 

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 

458 

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) 

463 

464class FlexibilityIndicatorModule(agentlib.BaseModule): 

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

466 from MPC power/energy profiles.""" 

467 

468 config: FlexibilityIndicatorModuleConfig 

469 data: FlexibilityData 

470 callback_handler: CallBackHandler 

471 

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) 

493 

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 ) 

504 

505 def process(self): 

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

507 yield self.env.event() 

508 

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

513 

514 if name == glbs.PROVISION_VAR_NAME: 

515 self.in_provision = inp.value 

516 

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) 

521 

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) 

531 

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 

541 

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. 

544 

545 DataFrame will be updated every time step 

546 

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 

552 

553 Returns: 

554 DataFrame with results of every variable in self.var_list 

555 

556 """ 

557 results = [] 

558 now = self.env.now 

559 

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 

587 

588 # Convert to Series if not already 

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

590 values = pd.Series(values) 

591 

592 all_series.append((name, values)) 

593 

594 # Create the standard grid for reference 

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

596 

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) 

602 

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) 

608 

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) 

629 

630 return df 

631 

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) 

638 

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) 

650 

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

659 

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) 

668 

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) 

673 

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 ) 

683 

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) 

693 

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 ) 

700 

701 # save results 

702 if self.config.save_results: 

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

704 

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. 

716 

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

718 to fully initialize. 

719 

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 

730 

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 

748 

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)