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

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._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) 

494 

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 ) 

505 

506 def process(self): 

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

508 yield self.env.event() 

509 

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

514 

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 

518 

519 if name == glbs.PROVISION_VAR_NAME: 

520 self.in_provision = inp.value 

521 

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) 

526 

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) 

536 

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 

546 

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. 

549 

550 DataFrame will be updated every time step 

551 

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 

557 

558 Returns: 

559 DataFrame with results of every variable in self.var_list 

560 

561 """ 

562 results = [] 

563 now = self._input_timestamp if self._input_timestamp is not None else self.env.now 

564 

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 

592 

593 # Convert to Series if not already 

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

595 values = pd.Series(values) 

596 

597 all_series.append((name, values)) 

598 

599 # Create the standard grid for reference 

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

601 

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) 

607 

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) 

613 

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 ) 

640 

641 return df 

642 

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) 

649 

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) 

661 

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

670 

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) 

679 

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) 

684 

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 ) 

694 

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) 

704 

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 ) 

711 

712 # save results 

713 if self.config.save_results: 

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

715 

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. 

727 

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

729 to fully initialize. 

730 

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 

741 

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 

759 

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)