Coverage for agentlib_flexquant/modules/flexibility_indicator.py: 95%

217 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2025-10-20 14: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 enable_energy_costs_correction: bool = Field( 

32 name="enable_energy_costs_correction", 

33 description=( 

34 "Variable determining whether to correct the costs of the flexible energy " 

35 "Define the variable for stored electrical energy in the base MPC model and " 

36 "config as output if the correction of costs is enabled" 

37 ), 

38 default=False, 

39 ) 

40 

41 absolute_power_deviation_tolerance: float = Field( 

42 name="absolute_power_deviation_tolerance", 

43 default=0.1, 

44 description="Absolute tolerance in kW within which no warning is thrown", 

45 ) 

46 

47 stored_energy_variable: Optional[str] = Field( 

48 name="stored_energy_variable", 

49 default=None, 

50 description="Name of the variable representing the stored electrical energy in the " 

51 "baseline config" 

52 ) 

53 

54 

55class InputsForCalculateFlexCosts(BaseModel): 

56 """Configuration for flexibility cost calculation with optional constant pricing.""" 

57 

58 use_constant_electricity_price: bool = Field( 

59 default=False, description="Use constant electricity price" 

60 ) 

61 calculate_flex_costs: bool = Field( 

62 default=True, description="Calculate the flexibility cost" 

63 ) 

64 const_electricity_price: float = Field( 

65 default=np.nan, description="constant electricity price in ct/kWh" 

66 ) 

67 

68 @model_validator(mode="after") 

69 def validate_constant_price(self): 

70 """Validate that a valid constant electricity price is provided 

71 when constant pricing is enabled.""" 

72 if self.use_constant_electricity_price and np.isnan( 

73 self.const_electricity_price 

74 ): 

75 raise ValueError( 

76 ( 

77 f"Constant electricity price must have a valid value in float if it is " 

78 f"to be used for calculation. " 

79 f'Received "use_constant_electricity_price": true, ' 

80 f'"const_electricity_price": {self.const_electricity_price}. ' 

81 f'Please specify them correctly in "calculate_costs" field in flex config.' 

82 ) 

83 ) 

84 return self 

85 

86 

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

88kpis_pos = FlexibilityKPIs(direction="positive") 

89kpis_neg = FlexibilityKPIs(direction="negative") 

90 

91 

92class FlexibilityIndicatorModuleConfig(agentlib.BaseModuleConfig): 

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

94 KPI outputs, and cost calculation settings.""" 

95 

96 model_config = ConfigDict(extra="forbid") 

97 

98 inputs: list[agentlib.AgentVariable] = [ 

99 agentlib.AgentVariable( 

100 name=glbs.POWER_ALIAS_BASE, 

101 unit="W", 

102 type="pd.Series", 

103 description="The power input to the system", 

104 ), 

105 agentlib.AgentVariable( 

106 name=glbs.POWER_ALIAS_NEG, 

107 unit="W", 

108 type="pd.Series", 

109 description="The power input to the system", 

110 ), 

111 agentlib.AgentVariable( 

112 name=glbs.POWER_ALIAS_POS, 

113 unit="W", 

114 type="pd.Series", 

115 description="The power input to the system", 

116 ), 

117 agentlib.AgentVariable( 

118 name=glbs.STORED_ENERGY_ALIAS_BASE, 

119 unit="kWh", 

120 type="pd.Series", 

121 description="Energy stored in the system w.r.t. 0K", 

122 ), 

123 agentlib.AgentVariable( 

124 name=glbs.STORED_ENERGY_ALIAS_NEG, 

125 unit="kWh", 

126 type="pd.Series", 

127 description="Energy stored in the system w.r.t. 0K", 

128 ), 

129 agentlib.AgentVariable( 

130 name=glbs.STORED_ENERGY_ALIAS_POS, 

131 unit="kWh", 

132 type="pd.Series", 

133 description="Energy stored in the system w.r.t. 0K", 

134 ), 

135 ] 

136 

137 outputs: list[agentlib.AgentVariable] = [ 

138 # Flexibility offer 

139 agentlib.AgentVariable(name=glbs.FLEXIBILITY_OFFER, type="FlexOffer"), 

140 # Power KPIs 

141 agentlib.AgentVariable( 

142 name=kpis_neg.power_flex_full.get_kpi_identifier(), 

143 unit="W", 

144 type="pd.Series", 

145 description="Negative power flexibility", 

146 ), 

147 agentlib.AgentVariable( 

148 name=kpis_pos.power_flex_full.get_kpi_identifier(), 

149 unit="W", 

150 type="pd.Series", 

151 description="Positive power flexibility", 

152 ), 

153 agentlib.AgentVariable( 

154 name=kpis_neg.power_flex_offer.get_kpi_identifier(), 

155 unit="W", 

156 type="pd.Series", 

157 description="Negative power flexibility", 

158 ), 

159 agentlib.AgentVariable( 

160 name=kpis_pos.power_flex_offer.get_kpi_identifier(), 

161 unit="W", 

162 type="pd.Series", 

163 description="Positive power flexibility", 

164 ), 

165 agentlib.AgentVariable( 

166 name=kpis_neg.power_flex_offer_min.get_kpi_identifier(), 

167 unit="W", 

168 type="float", 

169 description="Minimum of negative power flexibility", 

170 ), 

171 agentlib.AgentVariable( 

172 name=kpis_pos.power_flex_offer_min.get_kpi_identifier(), 

173 unit="W", 

174 type="float", 

175 description="Minimum of positive power flexibility", 

176 ), 

177 agentlib.AgentVariable( 

178 name=kpis_neg.power_flex_offer_max.get_kpi_identifier(), 

179 unit="W", 

180 type="float", 

181 description="Maximum of negative power flexibility", 

182 ), 

183 agentlib.AgentVariable( 

184 name=kpis_pos.power_flex_offer_max.get_kpi_identifier(), 

185 unit="W", 

186 type="float", 

187 description="Maximum of positive power flexibility", 

188 ), 

189 agentlib.AgentVariable( 

190 name=kpis_neg.power_flex_offer_avg.get_kpi_identifier(), 

191 unit="W", 

192 type="float", 

193 description="Average of negative power flexibility", 

194 ), 

195 agentlib.AgentVariable( 

196 name=kpis_pos.power_flex_offer_avg.get_kpi_identifier(), 

197 unit="W", 

198 type="float", 

199 description="Average of positive power flexibility", 

200 ), 

201 agentlib.AgentVariable( 

202 name=kpis_neg.power_flex_within_boundary.get_kpi_identifier(), 

203 unit="-", 

204 type="bool", 

205 description=( 

206 "Variable indicating whether the baseline power and flex power " 

207 "align at the horizon end" 

208 ), 

209 ), 

210 agentlib.AgentVariable( 

211 name=kpis_pos.power_flex_within_boundary.get_kpi_identifier(), 

212 unit="-", 

213 type="bool", 

214 description=( 

215 "Variable indicating whether the baseline power and flex power " 

216 "align at the horizon end" 

217 ), 

218 ), 

219 # Energy KPIs 

220 agentlib.AgentVariable( 

221 name=kpis_neg.energy_flex.get_kpi_identifier(), 

222 unit="kWh", 

223 type="float", 

224 description="Negative energy flexibility", 

225 ), 

226 agentlib.AgentVariable( 

227 name=kpis_pos.energy_flex.get_kpi_identifier(), 

228 unit="kWh", 

229 type="float", 

230 description="Positive energy flexibility", 

231 ), 

232 # Costs KPIs 

233 agentlib.AgentVariable( 

234 name=kpis_neg.costs.get_kpi_identifier(), 

235 unit="ct", 

236 type="float", 

237 description="Saved costs due to baseline", 

238 ), 

239 agentlib.AgentVariable( 

240 name=kpis_pos.costs.get_kpi_identifier(), 

241 unit="ct", 

242 type="float", 

243 description="Saved costs due to baseline", 

244 ), 

245 agentlib.AgentVariable( 

246 name=kpis_neg.corrected_costs.get_kpi_identifier(), 

247 unit="ct", 

248 type="float", 

249 description="Corrected saved costs due to baseline", 

250 ), 

251 agentlib.AgentVariable( 

252 name=kpis_pos.corrected_costs.get_kpi_identifier(), 

253 unit="ct", 

254 type="float", 

255 description="Corrected saved costs due to baseline", 

256 ), 

257 agentlib.AgentVariable( 

258 name=kpis_neg.costs_rel.get_kpi_identifier(), 

259 unit="ct/kWh", 

260 type="float", 

261 description="Saved costs due to baseline", 

262 ), 

263 agentlib.AgentVariable( 

264 name=kpis_pos.costs_rel.get_kpi_identifier(), 

265 unit="ct/kWh", 

266 type="float", 

267 description="Saved costs due to baseline", 

268 ), 

269 agentlib.AgentVariable( 

270 name=kpis_neg.corrected_costs_rel.get_kpi_identifier(), 

271 unit="ct/kWh", 

272 type="float", 

273 description="Corrected saved costs per energy due to baseline", 

274 ), 

275 agentlib.AgentVariable( 

276 name=kpis_pos.corrected_costs_rel.get_kpi_identifier(), 

277 unit="ct/kWh", 

278 type="float", 

279 description="Corrected saved costs per energy due to baseline", 

280 ), 

281 ] 

282 

283 parameters: list[agentlib.AgentVariable] = [ 

284 agentlib.AgentVariable(name=glbs.PREP_TIME, unit="s", 

285 description="Preparation time"), 

286 agentlib.AgentVariable(name=glbs.MARKET_TIME, unit="s", 

287 description="Market time"), 

288 agentlib.AgentVariable(name=glbs.FLEX_EVENT_DURATION, unit="s", 

289 description="time to switch objective"), 

290 agentlib.AgentVariable(name=glbs.TIME_STEP, unit="s", 

291 description="timestep of the mpc solution"), 

292 agentlib.AgentVariable(name=glbs.PREDICTION_HORIZON, unit="-", 

293 description="prediction horizon of the mpc solution"), 

294 agentlib.AgentVariable(name=glbs.COLLOCATION_TIME_GRID, alias=glbs.COLLOCATION_TIME_GRID, 

295 description="Time grid of the mpc model output") 

296 ] 

297 

298 results_file: Optional[Path] = Field( 

299 default=Path("flexibility_indicator.csv"), 

300 description="User specified results file name", 

301 ) 

302 save_results: Optional[bool] = Field( 

303 validate_default=True, 

304 default=True 

305 ) 

306 price_variable: str = Field( 

307 default="c_pel", description="Name of the price variable sent by a predictor", 

308 ) 

309 power_unit: str = Field( 

310 default="kW", 

311 description="Unit of the power variable" 

312 ) 

313 integration_method: glbs.INTEGRATION_METHOD = Field( 

314 default=glbs.LINEAR, 

315 description="Method set to integrate series variable" 

316 ) 

317 shared_variable_fields: list[str] = ["outputs"] 

318 

319 correct_costs: InputsForCorrectFlexCosts = InputsForCorrectFlexCosts() 

320 calculate_costs: InputsForCalculateFlexCosts = InputsForCalculateFlexCosts() 

321 

322 @model_validator(mode="after") 

323 def check_results_file_extension(self): 

324 """Validate that results_file has a .csv extension.""" 

325 if self.results_file and self.results_file.suffix != ".csv": 

326 raise ValueError( 

327 f"Invalid file extension for 'results_file': '{self.results_file}'. " 

328 f"Expected a '.csv' file." 

329 ) 

330 return self 

331 

332 

333class FlexibilityIndicatorModule(agentlib.BaseModule): 

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

335 from MPC power/energy profiles.""" 

336 

337 config: FlexibilityIndicatorModuleConfig 

338 data: FlexibilityData 

339 

340 def __init__(self, *args, **kwargs): 

341 super().__init__(*args, **kwargs) 

342 self.var_list = [] 

343 for variable in self.variables: 

344 if variable.name in [glbs.FLEXIBILITY_OFFER]: 

345 continue 

346 self.var_list.append(variable.name) 

347 self.time = [] 

348 self.in_provision = False 

349 self.offer_count = 0 

350 self.data = FlexibilityData( 

351 prep_time=self.get(glbs.PREP_TIME).value, 

352 market_time=self.get(glbs.MARKET_TIME).value, 

353 flex_event_duration=self.get(glbs.FLEX_EVENT_DURATION).value, 

354 time_step=self.get(glbs.TIME_STEP).value, 

355 prediction_horizon=self.get(glbs.PREDICTION_HORIZON).value, 

356 ) 

357 self.df = pd.DataFrame(columns=pd.Series(self.var_list)) 

358 

359 def register_callbacks(self): 

360 inputs = self.config.inputs 

361 for var in inputs: 

362 self.agent.data_broker.register_callback( 

363 name=var.name, alias=var.name, callback=self.callback 

364 ) 

365 self.agent.data_broker.register_callback( 

366 name="in_provision", alias="in_provision", callback=self.callback 

367 ) 

368 

369 def process(self): 

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

371 yield self.env.event() 

372 

373 def callback(self, inp, name): 

374 """Handle incoming data by storing power/energy profiles and triggering 

375 flexibility calculations when all required inputs are available.""" 

376 if name == "in_provision": 

377 self.in_provision = inp.value 

378 if self.in_provision: 

379 self._set_inputs_to_none() 

380 

381 if not self.in_provision: 

382 if name == glbs.POWER_ALIAS_BASE: 

383 self.data.power_profile_base = self.data.unify_inputs(inp.value) 

384 elif name == glbs.POWER_ALIAS_NEG: 

385 self.data.power_profile_flex_neg = self.data.unify_inputs(inp.value) 

386 elif name == glbs.POWER_ALIAS_POS: 

387 self.data.power_profile_flex_pos = self.data.unify_inputs(inp.value) 

388 elif name == glbs.STORED_ENERGY_ALIAS_BASE: 

389 self.data.stored_energy_profile_base = self.data.unify_inputs(inp.value) 

390 elif name == glbs.STORED_ENERGY_ALIAS_NEG: 

391 self.data.stored_energy_profile_flex_neg = self.data.unify_inputs(inp.value) 

392 elif name == glbs.STORED_ENERGY_ALIAS_POS: 

393 self.data.stored_energy_profile_flex_pos = self.data.unify_inputs(inp.value) 

394 elif name == self.config.price_variable: 

395 if not self.config.calculate_costs.use_constant_electricity_price: 

396 # price comes from predictor 

397 self.data.electricity_price_series = self.data.unify_inputs(inp.value, 

398 mpc=False) 

399 

400 # set the constant electricity price series if given 

401 if ( 

402 self.config.calculate_costs.use_constant_electricity_price 

403 and self.data.electricity_price_series is None 

404 ): 

405 # get the index for the electricity price series 

406 n = self.get(glbs.PREDICTION_HORIZON).value 

407 ts = self.get(glbs.TIME_STEP).value 

408 grid = np.arange(0, n * ts + ts, ts) 

409 # fill the electricity_price_series with values 

410 self.data.electricity_price_series = pd.Series( 

411 [self.config.calculate_costs.const_electricity_price for i in grid], index=grid) 

412 

413 necessary_input_for_calc_flex = [ 

414 self.data.power_profile_base, 

415 self.data.power_profile_flex_neg, 

416 self.data.power_profile_flex_pos, 

417 ] 

418 

419 if self.config.calculate_costs.calculate_flex_costs: 

420 necessary_input_for_calc_flex.append(self.data.electricity_price_series) 

421 

422 if (all(var is not None for var in necessary_input_for_calc_flex) and 

423 len(necessary_input_for_calc_flex) == 4): 

424 # align the index of price variable to the index of inputs from mpc; 

425 # electricity price signal is usually steps 

426 necessary_input_for_calc_flex[-1] = self.data.electricity_price_series.reindex( 

427 self.data.power_profile_base.index).ffill() 

428 

429 if self.config.correct_costs.enable_energy_costs_correction: 

430 necessary_input_for_calc_flex.extend( 

431 [ 

432 self.data.stored_energy_profile_base, 

433 self.data.stored_energy_profile_flex_neg, 

434 self.data.stored_energy_profile_flex_pos, 

435 ] 

436 ) 

437 

438 if all(var is not None for var in necessary_input_for_calc_flex): 

439 

440 # check the power profile end deviation 

441 if not self.config.correct_costs.enable_energy_costs_correction: 

442 self.check_power_end_deviation( 

443 tol=self.config.correct_costs.absolute_power_deviation_tolerance 

444 ) 

445 

446 # Calculate the flexibility, send the offer, write and save the results 

447 self.calc_and_send_offer() 

448 

449 # set the values to None to reset the callback 

450 self._set_inputs_to_none() 

451 

452 def get_results(self) -> Optional[pd.DataFrame]: 

453 """Open results file of flexibility_indicator.py.""" 

454 results_file = self.config.results_file 

455 try: 

456 results = pd.read_csv(results_file, header=[0], index_col=[0, 1]) 

457 return results 

458 except FileNotFoundError: 

459 self.logger.error("Results file %s was not found.", results_file) 

460 return None 

461 

462 def write_results(self, df: pd.DataFrame, ts: float, n: int) -> pd.DataFrame: 

463 """Write every data of variables in self.var_list in an DataFrame. 

464 

465 DataFrame will be updated every time step 

466 

467 Args: 

468 df: DataFrame which is initialised as an empty DataFrame with columns 

469 according to self.var_list 

470 ts: time step 

471 n: number of time steps during prediction horizon 

472 

473 Returns: 

474 DataFrame with results of every variable in self.var_list 

475 

476 """ 

477 results = [] 

478 now = self.env.now 

479 

480 # First, collect all series and their indices 

481 all_series = [] 

482 for name in self.var_list: 

483 # Get the appropriate values based on name 

484 if name == glbs.POWER_ALIAS_BASE: 

485 values = self.data.power_profile_base 

486 elif name == glbs.POWER_ALIAS_NEG: 

487 values = self.data.power_profile_flex_neg 

488 elif name == glbs.POWER_ALIAS_POS: 

489 values = self.data.power_profile_flex_pos 

490 elif name == glbs.STORED_ENERGY_ALIAS_BASE: 

491 values = self.data.stored_energy_profile_base 

492 elif name == glbs.STORED_ENERGY_ALIAS_NEG: 

493 values = self.data.stored_energy_profile_flex_neg 

494 elif name == glbs.STORED_ENERGY_ALIAS_POS: 

495 values = self.data.stored_energy_profile_flex_pos 

496 elif name == self.config.price_variable: 

497 values = self.data.electricity_price_series 

498 elif name == glbs.COLLOCATION_TIME_GRID: 

499 value = self.get(name).value 

500 values = pd.Series(index=value, data=value) 

501 else: 

502 values = self.get(name).value 

503 

504 # Convert to Series if not already 

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

506 values = pd.Series(values) 

507 

508 all_series.append((name, values)) 

509 

510 # Create the standard grid for reference 

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

512 

513 # Find the union of all indices to create a comprehensive grid 

514 all_indices = set(standard_grid) 

515 for _, series in all_series: 

516 all_indices.update(series.index) 

517 combined_index = sorted(all_indices) 

518 

519 # Reindex all series to the combined grid 

520 for i, (name, series) in enumerate(all_series): 

521 # Reindex to the comprehensive grid 

522 reindexed = series.reindex(combined_index) 

523 results.append(reindexed) 

524 

525 if not now % ts: 

526 self.time.append(now) 

527 new_df = pd.DataFrame(results).T 

528 new_df.columns = self.var_list 

529 # Rename time_step variable column 

530 new_df.rename( 

531 columns={glbs.TIME_STEP: f"{glbs.TIME_STEP}_mpc"}, inplace=True 

532 ) 

533 new_df.index.direction = "time" 

534 new_df[glbs.TIME_STEP] = now 

535 new_df.set_index([glbs.TIME_STEP, new_df.index], inplace=True) 

536 df = pd.concat([df, new_df]) 

537 # set the indices once again as concat cant handle indices properly 

538 indices = pd.MultiIndex.from_tuples( 

539 df.index, names=[glbs.TIME_STEP, "time"] 

540 ) 

541 df.set_index(indices, inplace=True) 

542 # Drop column time_step and keep it as an index only 

543 if glbs.TIME_STEP in df.columns: 

544 df.drop(columns=[glbs.TIME_STEP], inplace=True) 

545 

546 return df 

547 

548 def cleanup_results(self): 

549 """Remove the existing result files.""" 

550 results_file = self.config.results_file 

551 if not results_file: 

552 return 

553 os.remove(results_file) 

554 

555 def calc_and_send_offer(self): 

556 """Calculate the flexibility KPIs for current predictions, send the flex offer 

557 and set the outputs, write and save the results.""" 

558 # Calculate the flexibility KPIs for current predictions 

559 collocation_time_grid = self.get(glbs.COLLOCATION_TIME_GRID).value 

560 self.data.calculate( 

561 enable_energy_costs_correction=self.config.correct_costs.enable_energy_costs_correction, 

562 calculate_flex_cost=self.config.calculate_costs.calculate_flex_costs, 

563 integration_method=self.config.integration_method, 

564 collocation_time_grid=collocation_time_grid) 

565 

566 # get the full index during flex enevt including mpc_time_grid index and the 

567 # collocation index 

568 full_index = np.sort(np.concatenate([collocation_time_grid, self.data.mpc_time_grid])) 

569 flex_begin = self.get(glbs.MARKET_TIME).value + self.get(glbs.PREP_TIME).value 

570 flex_end = flex_begin + self.get(glbs.FLEX_EVENT_DURATION).value 

571 full_flex_offer_index = full_index[(full_index >= flex_begin) & (full_index <= flex_end)] 

572 

573 # reindex the power profiles to not send the simulation points to the market, but only 

574 # the values on the collocation points and the forward mean of them 

575 base_power_profile = self.data.power_profile_base.reindex( 

576 collocation_time_grid).reindex(full_flex_offer_index) 

577 pos_diff_profile = self.data.kpis_pos.power_flex_offer.value.reindex( 

578 collocation_time_grid).reindex(full_flex_offer_index) 

579 neg_diff_profile = self.data.kpis_neg.power_flex_offer.value.reindex( 

580 collocation_time_grid).reindex(full_flex_offer_index) 

581 

582 # fill the mpc_time_grid with forward mean 

583 base_power_profile = fill_nans(base_power_profile, method=MEAN) 

584 pos_diff_profile = fill_nans(pos_diff_profile, method=MEAN) 

585 neg_diff_profile = fill_nans(neg_diff_profile, method=MEAN) 

586 

587 # Send flex offer 

588 self.send_flex_offer( 

589 name=glbs.FLEXIBILITY_OFFER, 

590 base_power_profile=base_power_profile, 

591 pos_diff_profile=pos_diff_profile, 

592 pos_price=self.data.kpis_pos.costs.value, 

593 neg_diff_profile=neg_diff_profile, 

594 neg_price=self.data.kpis_neg.costs.value, 

595 ) 

596 

597 # set outputs 

598 for kpi in self.data.get_kpis().values(): 

599 if kpi.get_kpi_identifier() not in [ 

600 kpis_pos.power_flex_within_boundary.get_kpi_identifier(), 

601 kpis_neg.power_flex_within_boundary.get_kpi_identifier(), 

602 ]: 

603 for output in self.config.outputs: 

604 if output.name == kpi.get_kpi_identifier(): 

605 self.set(output.name, kpi.value) 

606 

607 # write results 

608 self.df = self.write_results( 

609 df=self.df, 

610 ts=self.get(glbs.TIME_STEP).value, 

611 n=self.get(glbs.PREDICTION_HORIZON).value, 

612 ) 

613 

614 # save results 

615 if self.config.save_results: 

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

617 

618 def send_flex_offer( 

619 self, 

620 name: str, 

621 base_power_profile: pd.Series, 

622 pos_diff_profile: pd.Series, 

623 pos_price: float, 

624 neg_diff_profile: pd.Series, 

625 neg_price: float, 

626 timestamp: float = None, 

627 ): 

628 """Send a flex offer as an agent Variable. 

629 

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

631 to fully initialize. 

632 

633 Args: 

634 name: name of the agent variable 

635 base_power_profile: time series of power from baseline mpc 

636 pos_diff_profile: power profile for the positive difference (base-pos) 

637 in flexibility event time grid 

638 pos_price: price for positive flexibility 

639 neg_diff_profile: power profile for the negative difference (neg-base) 

640 in flexibility event time grid 

641 neg_price: price for negative flexibility 

642 timestamp: the time offer was generated 

643 

644 """ 

645 if self.offer_count > 0: 

646 var = self._variables_dict[name] 

647 var.value = FlexOffer( 

648 base_power_profile=base_power_profile, 

649 pos_diff_profile=pos_diff_profile, 

650 pos_price=pos_price, 

651 neg_diff_profile=neg_diff_profile, 

652 neg_price=neg_price, 

653 ) 

654 if timestamp is None: 

655 timestamp = self.env.time 

656 var.timestamp = timestamp 

657 self.agent.data_broker.send_variable( 

658 variable=var.copy(update={"source": self.source}), copy=False, 

659 ) 

660 self.offer_count += 1 

661 

662 def _set_inputs_to_none(self): 

663 self.data.power_profile_base = None 

664 self.data.power_profile_flex_neg = None 

665 self.data.power_profile_flex_pos = None 

666 self.data.electricity_price_series = None 

667 self.data.stored_energy_profile_base = None 

668 self.data.stored_energy_profile_flex_neg = None 

669 self.data.stored_energy_profile_flex_pos = None 

670 

671 def check_power_end_deviation(self, tol: float): 

672 """Calculate the deviation of the final value of the power profiles 

673 and warn the user if it exceeds the tolerance.""" 

674 logger = logging.getLogger(__name__) 

675 dev_pos = np.mean( 

676 self.data.power_profile_flex_pos.values[-4:] 

677 - self.data.power_profile_base.values[-4:] 

678 ) 

679 dev_neg = np.mean( 

680 self.data.power_profile_flex_neg.values[-4:] 

681 - self.data.power_profile_base.values[-4:] 

682 ) 

683 if abs(dev_pos) > tol: 

684 logger.warning( 

685 "There is an average deviation of %.6f kW between the final values of " 

686 "power profiles of positive shadow MPC and the baseline. " 

687 "Correction of energy costs might be necessary.", 

688 dev_pos, 

689 ) 

690 self.set(kpis_pos.power_flex_within_boundary.get_kpi_identifier(), False) 

691 else: 

692 self.set(kpis_pos.power_flex_within_boundary.get_kpi_identifier(), True) 

693 if abs(dev_neg) > tol: 

694 logger.warning( 

695 "There is an average deviation of %.6f kW between the final values of " 

696 "power profiles of negative shadow MPC and the baseline. " 

697 "Correction of energy costs might be necessary.", 

698 dev_neg, 

699 ) 

700 self.set(kpis_neg.power_flex_within_boundary.get_kpi_identifier(), False) 

701 else: 

702 self.set(kpis_neg.power_flex_within_boundary.get_kpi_identifier(), True)