flood-adapt 0.3.9__py3-none-any.whl → 0.3.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. flood_adapt/__init__.py +26 -22
  2. flood_adapt/adapter/__init__.py +9 -9
  3. flood_adapt/adapter/fiat_adapter.py +1541 -1541
  4. flood_adapt/adapter/interface/hazard_adapter.py +70 -70
  5. flood_adapt/adapter/interface/impact_adapter.py +36 -36
  6. flood_adapt/adapter/interface/model_adapter.py +89 -89
  7. flood_adapt/adapter/interface/offshore.py +19 -19
  8. flood_adapt/adapter/sfincs_adapter.py +1848 -1848
  9. flood_adapt/adapter/sfincs_offshore.py +193 -193
  10. flood_adapt/config/config.py +248 -248
  11. flood_adapt/config/fiat.py +219 -219
  12. flood_adapt/config/gui.py +331 -331
  13. flood_adapt/config/sfincs.py +481 -336
  14. flood_adapt/config/site.py +129 -129
  15. flood_adapt/database_builder/database_builder.py +2210 -2210
  16. flood_adapt/database_builder/templates/default_units/imperial.toml +9 -9
  17. flood_adapt/database_builder/templates/default_units/metric.toml +9 -9
  18. flood_adapt/database_builder/templates/green_infra_table/green_infra_lookup_table.csv +10 -10
  19. flood_adapt/database_builder/templates/infographics/OSM/config_charts.toml +90 -90
  20. flood_adapt/database_builder/templates/infographics/OSM/config_people.toml +57 -57
  21. flood_adapt/database_builder/templates/infographics/OSM/config_risk_charts.toml +121 -121
  22. flood_adapt/database_builder/templates/infographics/OSM/config_roads.toml +65 -65
  23. flood_adapt/database_builder/templates/infographics/OSM/styles.css +45 -45
  24. flood_adapt/database_builder/templates/infographics/US_NSI/config_charts.toml +126 -126
  25. flood_adapt/database_builder/templates/infographics/US_NSI/config_people.toml +60 -60
  26. flood_adapt/database_builder/templates/infographics/US_NSI/config_risk_charts.toml +121 -121
  27. flood_adapt/database_builder/templates/infographics/US_NSI/config_roads.toml +65 -65
  28. flood_adapt/database_builder/templates/infographics/US_NSI/styles.css +45 -45
  29. flood_adapt/database_builder/templates/infometrics/OSM/metrics_additional_risk_configs.toml +4 -4
  30. flood_adapt/database_builder/templates/infometrics/OSM/with_SVI/infographic_metrics_config.toml +143 -143
  31. flood_adapt/database_builder/templates/infometrics/OSM/with_SVI/infographic_metrics_config_risk.toml +153 -153
  32. flood_adapt/database_builder/templates/infometrics/OSM/without_SVI/infographic_metrics_config.toml +127 -127
  33. flood_adapt/database_builder/templates/infometrics/OSM/without_SVI/infographic_metrics_config_risk.toml +57 -57
  34. flood_adapt/database_builder/templates/infometrics/US_NSI/metrics_additional_risk_configs.toml +4 -4
  35. flood_adapt/database_builder/templates/infometrics/US_NSI/with_SVI/infographic_metrics_config.toml +191 -191
  36. flood_adapt/database_builder/templates/infometrics/US_NSI/with_SVI/infographic_metrics_config_risk.toml +153 -153
  37. flood_adapt/database_builder/templates/infometrics/US_NSI/without_SVI/infographic_metrics_config.toml +178 -178
  38. flood_adapt/database_builder/templates/infometrics/US_NSI/without_SVI/infographic_metrics_config_risk.toml +57 -57
  39. flood_adapt/database_builder/templates/infometrics/mandatory_metrics_config.toml +9 -9
  40. flood_adapt/database_builder/templates/infometrics/mandatory_metrics_config_risk.toml +65 -65
  41. flood_adapt/database_builder/templates/output_layers/bin_colors.toml +5 -5
  42. flood_adapt/database_builder.py +16 -16
  43. flood_adapt/dbs_classes/__init__.py +21 -21
  44. flood_adapt/dbs_classes/database.py +495 -684
  45. flood_adapt/dbs_classes/dbs_benefit.py +77 -76
  46. flood_adapt/dbs_classes/dbs_event.py +61 -59
  47. flood_adapt/dbs_classes/dbs_measure.py +112 -111
  48. flood_adapt/dbs_classes/dbs_projection.py +34 -34
  49. flood_adapt/dbs_classes/dbs_scenario.py +137 -137
  50. flood_adapt/dbs_classes/dbs_static.py +274 -273
  51. flood_adapt/dbs_classes/dbs_strategy.py +130 -129
  52. flood_adapt/dbs_classes/dbs_template.py +279 -278
  53. flood_adapt/dbs_classes/interface/database.py +107 -139
  54. flood_adapt/dbs_classes/interface/element.py +121 -121
  55. flood_adapt/dbs_classes/interface/static.py +47 -47
  56. flood_adapt/flood_adapt.py +1207 -1178
  57. flood_adapt/misc/database_user.py +16 -16
  58. flood_adapt/misc/exceptions.py +22 -0
  59. flood_adapt/misc/log.py +183 -183
  60. flood_adapt/misc/path_builder.py +54 -54
  61. flood_adapt/misc/utils.py +185 -185
  62. flood_adapt/objects/__init__.py +82 -82
  63. flood_adapt/objects/benefits/benefits.py +61 -61
  64. flood_adapt/objects/events/event_factory.py +135 -135
  65. flood_adapt/objects/events/event_set.py +88 -84
  66. flood_adapt/objects/events/events.py +234 -234
  67. flood_adapt/objects/events/historical.py +58 -58
  68. flood_adapt/objects/events/hurricane.py +68 -67
  69. flood_adapt/objects/events/synthetic.py +46 -50
  70. flood_adapt/objects/forcing/__init__.py +92 -92
  71. flood_adapt/objects/forcing/csv.py +68 -68
  72. flood_adapt/objects/forcing/discharge.py +66 -66
  73. flood_adapt/objects/forcing/forcing.py +150 -150
  74. flood_adapt/objects/forcing/forcing_factory.py +182 -182
  75. flood_adapt/objects/forcing/meteo_handler.py +93 -93
  76. flood_adapt/objects/forcing/netcdf.py +40 -40
  77. flood_adapt/objects/forcing/plotting.py +453 -429
  78. flood_adapt/objects/forcing/rainfall.py +98 -98
  79. flood_adapt/objects/forcing/tide_gauge.py +191 -191
  80. flood_adapt/objects/forcing/time_frame.py +90 -90
  81. flood_adapt/objects/forcing/timeseries.py +564 -564
  82. flood_adapt/objects/forcing/unit_system.py +580 -580
  83. flood_adapt/objects/forcing/waterlevels.py +108 -108
  84. flood_adapt/objects/forcing/wind.py +124 -124
  85. flood_adapt/objects/measures/measure_factory.py +92 -92
  86. flood_adapt/objects/measures/measures.py +529 -529
  87. flood_adapt/objects/object_model.py +74 -68
  88. flood_adapt/objects/projections/projections.py +103 -103
  89. flood_adapt/objects/scenarios/scenarios.py +22 -22
  90. flood_adapt/objects/strategies/strategies.py +89 -89
  91. flood_adapt/workflows/benefit_runner.py +579 -554
  92. flood_adapt/workflows/floodmap.py +85 -85
  93. flood_adapt/workflows/impacts_integrator.py +85 -85
  94. flood_adapt/workflows/scenario_runner.py +70 -70
  95. {flood_adapt-0.3.9.dist-info → flood_adapt-0.3.10.dist-info}/LICENSE +674 -674
  96. {flood_adapt-0.3.9.dist-info → flood_adapt-0.3.10.dist-info}/METADATA +866 -865
  97. flood_adapt-0.3.10.dist-info/RECORD +140 -0
  98. flood_adapt-0.3.9.dist-info/RECORD +0 -139
  99. {flood_adapt-0.3.9.dist-info → flood_adapt-0.3.10.dist-info}/WHEEL +0 -0
  100. {flood_adapt-0.3.9.dist-info → flood_adapt-0.3.10.dist-info}/top_level.txt +0 -0
@@ -1,554 +1,579 @@
1
- import shutil
2
- from typing import Any
3
-
4
- import geopandas as gpd
5
- import numpy as np
6
- import numpy_financial as npf
7
- import pandas as pd
8
- import plotly.graph_objects as go
9
- import tomli
10
- import tomli_w
11
- from fiat_toolbox.metrics_writer.fiat_read_metrics_file import MetricsFileReader
12
-
13
- from flood_adapt.misc.path_builder import (
14
- ObjectDir,
15
- TopLevelDir,
16
- db_path,
17
- )
18
- from flood_adapt.objects.benefits.benefits import Benefit
19
- from flood_adapt.objects.scenarios.scenarios import Scenario
20
- from flood_adapt.workflows.scenario_runner import ScenarioRunner
21
-
22
-
23
- class BenefitRunner:
24
- """Object holding all attributes and methods related to a benefit analysis."""
25
-
26
- benefit: Benefit
27
- _scenarios: pd.DataFrame
28
-
29
- def __init__(self, database, benefit: Benefit):
30
- """Initialize function called when object is created through the load_file or load_dict methods."""
31
- self.database = database
32
- self.benefit = benefit
33
-
34
- # Get output path based on database path
35
- self.results_path = self.database.benefits.output_path.joinpath(
36
- self.benefit.name
37
- )
38
- self.site_info = self.database.site
39
- self.unit = self.site_info.fiat.config.damage_unit
40
-
41
- @property
42
- def scenarios(self) -> pd.DataFrame:
43
- """Get the scenarios of the benefit analysis.
44
-
45
- Returns
46
- -------
47
- pd.DataFrame
48
- a table with the scenarios of the Benefit analysis and their status
49
- """
50
- self._scenarios = self.check_scenarios()
51
- return self._scenarios
52
-
53
- @property
54
- def has_run(self):
55
- return self.has_run_check()
56
-
57
- @property
58
- def results(self):
59
- if hasattr(self, "_results"):
60
- return self._results
61
- self._results = self.get_output()
62
- return self._results
63
-
64
- def get_output(self) -> dict[str, Any]:
65
- if not self.has_run:
66
- raise RuntimeError(
67
- f"Cannot read output since benefit analysis '{self.benefit.name}' has not been run yet."
68
- )
69
-
70
- results_toml = self.results_path.joinpath("results.toml")
71
- results_html = self.results_path.joinpath("benefits.html")
72
- with open(results_toml, mode="rb") as fp:
73
- results = tomli.load(fp)
74
- results["html"] = str(results_html)
75
- self._results = results
76
- return results
77
-
78
- def has_run_check(self) -> bool:
79
- """Check if the benefit analysis has already been run.
80
-
81
- Returns
82
- -------
83
- has_run : bool
84
- True if the analysis has already been run, else False
85
- """
86
- # Output files to check
87
- results_toml = self.results_path.joinpath("results.toml")
88
- results_csv = self.results_path.joinpath("time_series.csv")
89
- results_html = self.results_path.joinpath("benefits.html")
90
-
91
- check = all(
92
- result.exists() for result in [results_toml, results_csv, results_html]
93
- )
94
- return check
95
-
96
- def check_scenarios(self) -> pd.DataFrame:
97
- """Check which scenarios are needed for this benefit calculation and if they have already been created.
98
-
99
- The scenarios attribute of the object is updated accordingly and the table of the scenarios is returned.
100
-
101
- Returns
102
- -------
103
- pd.DataFrame
104
- a table with the scenarios of the Benefit analysis and their status
105
- """
106
- # Define names of scenarios
107
- scenarios_calc = {
108
- "current_no_measures": {},
109
- "current_with_strategy": {},
110
- "future_no_measures": {},
111
- "future_with_strategy": {},
112
- }
113
-
114
- # Use the predefined names for the current projections and no measures strategy
115
- for scenario in scenarios_calc.keys():
116
- scenarios_calc[scenario]["event"] = self.benefit.event_set
117
-
118
- if "current" in scenario:
119
- scenarios_calc[scenario]["projection"] = (
120
- self.benefit.current_situation.projection
121
- )
122
- elif "future" in scenario:
123
- scenarios_calc[scenario]["projection"] = self.benefit.projection
124
-
125
- if "no_measures" in scenario:
126
- scenarios_calc[scenario]["strategy"] = self.benefit.baseline_strategy
127
- else:
128
- scenarios_calc[scenario]["strategy"] = self.benefit.strategy
129
-
130
- # Get the available scenarios
131
- scenarios_avail = [
132
- self.database.scenarios.get(scn)
133
- for scn in self.database.scenarios.summarize_objects()["name"]
134
- ]
135
-
136
- # Check if any of the needed scenarios are already there
137
- for scenario in scenarios_calc.keys():
138
- scn_dict = scenarios_calc[scenario].copy()
139
- scn_dict["name"] = scenario
140
- scenario_obj = Scenario(**scn_dict)
141
- created = [
142
- scn_avl for scn_avl in scenarios_avail if scenario_obj == scn_avl
143
- ]
144
- if len(created) > 0:
145
- runner = ScenarioRunner(self.database, scenario=created[0])
146
- scenarios_calc[scenario]["scenario created"] = created[0].name
147
- scenarios_calc[scenario]["scenario run"] = runner.impacts.has_run
148
- else:
149
- scenarios_calc[scenario]["scenario created"] = "No"
150
- scenarios_calc[scenario]["scenario run"] = False
151
-
152
- df = pd.DataFrame(scenarios_calc).T
153
- scenarios = df.astype(
154
- dtype={
155
- "event": "str",
156
- "projection": "str",
157
- "strategy": "str",
158
- "scenario created": "str",
159
- "scenario run": bool,
160
- }
161
- )
162
- return scenarios
163
-
164
- def ready_to_run(self) -> bool:
165
- """Check if all the required scenarios have already been run.
166
-
167
- Returns
168
- -------
169
- bool
170
- True if required scenarios have been already run
171
- """
172
- check = all(self.scenarios["scenario run"])
173
-
174
- return check
175
-
176
- def run_cost_benefit(self):
177
- """Run the cost-benefit calculation for the total study area and the different aggregation levels."""
178
- # Throw an error if not all runs are finished
179
- if not self.ready_to_run():
180
- # First check is scenarios are there
181
- if "No" in self.scenarios["scenario created"].to_numpy():
182
- raise RuntimeError("Necessary scenarios have not been created yet.")
183
- scens = self.scenarios["scenario created"][~self.scenarios["scenario run"]]
184
- raise RuntimeError(
185
- f"Scenarios {', '.join(scens.values)} need to be run before the cost-benefit analysis can be performed"
186
- )
187
-
188
- # If path for results does not yet exist, make it, and if it does delete it and recreate it
189
- if not self.results_path.is_dir():
190
- self.results_path.mkdir(parents=True)
191
- else:
192
- shutil.rmtree(self.results_path)
193
- self.results_path.mkdir(parents=True)
194
-
195
- # Run the cost-benefit analysis
196
- self.cba()
197
- # Run aggregation benefits
198
- self.cba_aggregation()
199
- # Updates results
200
- self.has_run_check()
201
-
202
- # Cache results
203
- self.results
204
-
205
- def cba(self):
206
- """Cost-benefit analysis for the whole study area."""
207
- # Get EAD for each scenario and save to new dataframe
208
- scenarios = self.scenarios.copy(deep=True)
209
- scenarios["EAD"] = None
210
-
211
- scn_output_path = db_path(TopLevelDir.output, ObjectDir.scenario)
212
-
213
- # Get metrics per scenario
214
- for index, scenario in scenarios.iterrows():
215
- scn_name = scenario["scenario created"]
216
- collective_fn = scn_output_path.joinpath(
217
- scn_name, f"Infometrics_{scn_name}.csv"
218
- )
219
- collective_metrics = MetricsFileReader(
220
- collective_fn,
221
- ).read_metrics_from_file()
222
-
223
- # Fill scenarios EAD column with values from metrics
224
- scenarios.loc[index, "EAD"] = float(
225
- collective_metrics["Value"]["ExpectedAnnualDamages"]
226
- )
227
-
228
- # Get years of interest
229
- year_start = self.benefit.current_situation.year
230
- year_end = self.benefit.future_year
231
-
232
- # Calculate benefits
233
- cba = self._calc_benefits(
234
- years=[year_start, year_end],
235
- risk_no_measures=[
236
- scenarios.loc["current_no_measures", "EAD"],
237
- scenarios.loc["future_no_measures", "EAD"],
238
- ],
239
- risk_with_strategy=[
240
- scenarios.loc["current_with_strategy", "EAD"],
241
- scenarios.loc["future_with_strategy", "EAD"],
242
- ],
243
- discount_rate=self.benefit.discount_rate,
244
- )
245
-
246
- # Save indicators in dictionary
247
- results = {}
248
- # Get net present value of benefits
249
- results["benefits"] = cba["benefits_discounted"].sum()
250
-
251
- # Only if costs are provided do the full cost-benefit analysis
252
- cost_calc = (self.benefit.implementation_cost is not None) and (
253
- self.benefit.annual_maint_cost is not None
254
- )
255
- if cost_calc:
256
- cba = self._calc_costs(
257
- benefits=cba,
258
- implementation_cost=self.benefit.implementation_cost,
259
- annual_maint_cost=self.benefit.annual_maint_cost,
260
- discount_rate=self.benefit.discount_rate,
261
- )
262
- # Calculate costs
263
- results["costs"] = cba["costs_discounted"].sum()
264
- # Benefit to Cost Ratio
265
- results["BCR"] = np.round(results["benefits"] / results["costs"], 2)
266
- # Net present value
267
- results["NPV"] = cba["profits_discounted"].sum()
268
- # Internal Rate of Return
269
- results["IRR"] = np.round(npf.irr(cba["profits"]), 3)
270
-
271
- # Save results
272
- if not self.results_path.is_dir():
273
- self.results_path.mkdir(parents=True)
274
-
275
- # Save indicators in a toml file
276
- indicators = self.results_path.joinpath("results.toml")
277
- with open(indicators, "wb") as f:
278
- tomli_w.dump(results, f)
279
-
280
- # Save time-series in a csv
281
- time_series = self.results_path.joinpath("time_series.csv")
282
- cba.to_csv(time_series)
283
-
284
- # Make html
285
- self._make_html(cba)
286
-
287
- def cba_aggregation(self):
288
- """Zonal Benefits analysis for the different aggregation areas."""
289
- results_path = db_path(TopLevelDir.output, ObjectDir.scenario)
290
- # Get years of interest
291
- year_start = self.benefit.current_situation.year
292
- year_end = self.benefit.future_year
293
-
294
- # Get EAD for each scenario and save to new dataframe
295
- scenarios = self.scenarios.copy(deep=True)
296
-
297
- # Read in the names of the aggregation area types
298
- aggregations = [aggr.name for aggr in self.site_info.fiat.config.aggregation]
299
-
300
- # Check if equity information is available to define variables to use
301
- vars = []
302
- for i, aggr_name in enumerate(aggregations):
303
- if self.site_info.fiat.config.aggregation[i].equity is not None:
304
- vars.append(["EAD", "EWEAD"])
305
- else:
306
- vars.append(["EAD"])
307
-
308
- # Define which names are used in the metric tables
309
- var_metric = {"EAD": "ExpectedAnnualDamages", "EWEAD": "EWEAD"}
310
-
311
- # Prepare dictionary to save values
312
- risk = {}
313
-
314
- # Fill in the dictionary
315
- for i, aggr_name in enumerate(aggregations):
316
- risk[aggr_name] = {}
317
- values = {}
318
- for var in vars[i]:
319
- values[var] = []
320
- for index, scenario in scenarios.iterrows():
321
- scn_name = scenario["scenario created"]
322
- # Get available aggregation levels
323
- aggregation_fn = results_path.joinpath(
324
- scn_name, f"Infometrics_{scn_name}_{aggr_name}.csv"
325
- )
326
- for var in vars[i]:
327
- # Get metrics per scenario and per aggregation
328
- aggregated_metrics = MetricsFileReader(
329
- aggregation_fn,
330
- ).read_aggregated_metric_from_file(var_metric[var])[2:]
331
- aggregated_metrics = aggregated_metrics.loc[
332
- aggregated_metrics.index.dropna()
333
- ]
334
- aggregated_metrics.name = scenario.name
335
- values[var].append(aggregated_metrics)
336
-
337
- # Combine values in a single dataframe
338
- for var in vars[i]:
339
- risk[aggr_name][var] = pd.DataFrame(values[var]).T.astype(float)
340
-
341
- var_output = {"EAD": "Benefits", "EWEAD": "Equity Weighted Benefits"}
342
-
343
- # Calculate benefits
344
- benefits = {}
345
- for i, aggr_name in enumerate(aggregations):
346
- benefits[aggr_name] = pd.DataFrame()
347
- benefits[aggr_name].index = risk[aggr_name]["EAD"].index
348
- for var in vars[i]:
349
- for index, row in risk[aggr_name][var].iterrows():
350
- cba = self._calc_benefits(
351
- years=[year_start, year_end],
352
- risk_no_measures=[
353
- row["current_no_measures"],
354
- row["future_no_measures"],
355
- ],
356
- risk_with_strategy=[
357
- row["current_with_strategy"],
358
- row["future_with_strategy"],
359
- ],
360
- discount_rate=self.benefit.discount_rate,
361
- )
362
- benefits[aggr_name].loc[row.name, var_output[var]] = cba[
363
- "benefits_discounted"
364
- ].sum()
365
-
366
- # Save results
367
- if not self.results_path.is_dir():
368
- self.results_path.mkdir(parents=True)
369
-
370
- # Save benefits per aggregation area (csv and gpkg)
371
- for i, aggr_name in enumerate(aggregations):
372
- csv_filename = self.results_path.joinpath(f"benefits_{aggr_name}.csv")
373
- benefits[aggr_name].to_csv(csv_filename, index=True)
374
-
375
- # Load aggregation areas
376
- ind = [
377
- i
378
- for i, n in enumerate(self.site_info.fiat.config.aggregation)
379
- if n.name == aggr_name
380
- ][0]
381
- aggr_areas_path = (
382
- db_path(TopLevelDir.static)
383
- / self.site_info.fiat.config.aggregation[ind].file
384
- )
385
- aggr_areas = gpd.read_file(aggr_areas_path, engine="pyogrio")
386
- # Define output path
387
- outpath = self.results_path.joinpath(f"benefits_{aggr_name}.gpkg")
388
- # Save file
389
- aggr_areas = aggr_areas.join(
390
- benefits[aggr_name],
391
- on=self.site_info.fiat.config.aggregation[ind].field_name,
392
- )
393
- aggr_areas.to_file(outpath, driver="GPKG")
394
-
395
- @staticmethod
396
- def _calc_benefits(
397
- years: list[int, int],
398
- risk_no_measures: list[float, float],
399
- risk_with_strategy: list[float, float],
400
- discount_rate: float,
401
- ) -> pd.DataFrame:
402
- """Calculate per year benefits and discounted benefits.
403
-
404
- Parameters
405
- ----------
406
- years : list[int, int]
407
- the current and future year for the analysis
408
- risk_no_measures : list[float, float]
409
- the current and future risk value without any measures
410
- risk_with_strategy : list[float, float]
411
- the current and future risk value with the strategy under investigation
412
- discount_rate : float
413
- the yearly discount rate used to calculated the total benefit
414
-
415
- Returns
416
- -------
417
- pd.DataFrame
418
- Dataframe containing the time-series of risks and benefits per year
419
- """
420
- benefits = pd.DataFrame(
421
- data={"risk_no_measures": np.nan, "risk_with_strategy": np.nan},
422
- index=np.arange(years[0], years[1] + 1),
423
- )
424
- benefits.index.names = ["year"]
425
-
426
- # Fill in dataframe
427
- for strat, risk in zip(
428
- ["no_measures", "with_strategy"], [risk_no_measures, risk_with_strategy]
429
- ):
430
- benefits.loc[years[0], f"risk_{strat}"] = risk[0]
431
- benefits.loc[years[1], f"risk_{strat}"] = risk[1]
432
-
433
- # Assume linear trend between current and future
434
- benefits = benefits.interpolate(method="linear")
435
-
436
- # Calculate benefits
437
- benefits["benefits"] = (
438
- benefits["risk_no_measures"] - benefits["risk_with_strategy"]
439
- )
440
- # Calculate discounted benefits using the provided discount rate
441
- benefits["benefits_discounted"] = benefits["benefits"] / (
442
- 1 + discount_rate
443
- ) ** (benefits.index - benefits.index[0])
444
-
445
- return benefits
446
-
447
- @staticmethod
448
- def _calc_costs(
449
- benefits: pd.DataFrame,
450
- implementation_cost: float,
451
- annual_maint_cost: float,
452
- discount_rate: float,
453
- ) -> pd.DataFrame:
454
- """Calculate per year costs and discounted costs.
455
-
456
- Parameters
457
- ----------
458
- benefits : pd.DataFrame
459
- a time series of benefits per year (produced with __calc_benefits method)
460
- implementation_cost : float
461
- initial costs of implementing the adaptation strategy
462
- annual_maint_cost : float
463
- annual maintenance cost of the adaptation strategy
464
- discount_rate : float
465
- yearly discount rate
466
-
467
- Returns
468
- -------
469
- pd.DataFrame
470
- Dataframe containing the time-series of benefits, costs and profits per year
471
- """
472
- benefits = benefits.copy()
473
- benefits["costs"] = np.nan
474
- # implementations costs at current year and maintenance from year 1
475
- benefits.loc[benefits.index[0], "costs"] = implementation_cost
476
- benefits.loc[benefits.index[1:], "costs"] = annual_maint_cost
477
- benefits["costs_discounted"] = benefits["costs"] / (1 + discount_rate) ** (
478
- benefits.index - benefits.index[0]
479
- )
480
-
481
- # Benefit to Cost Ratio
482
- benefits["profits"] = benefits["benefits"] - benefits["costs"]
483
- benefits["profits_discounted"] = benefits["profits"] / (1 + discount_rate) ** (
484
- benefits.index - benefits.index[0]
485
- )
486
-
487
- return benefits
488
-
489
- def _make_html(self, cba):
490
- """Make an html with the time-series of the benefits and discounted benefits."""
491
- # Save a plotly graph in an html
492
- fig = go.Figure()
493
-
494
- # Get only endpoints
495
- cba2 = cba.iloc[[0, -1]]
496
-
497
- # Add graph with benefits
498
- fig.add_trace(
499
- go.Scatter(
500
- x=cba.index,
501
- y=cba["benefits"],
502
- mode="lines",
503
- line_color="black",
504
- name="Interpolated benefits",
505
- )
506
- )
507
- fig.add_trace(
508
- go.Scatter(
509
- x=cba2.index,
510
- y=cba2["benefits"],
511
- mode="markers",
512
- marker_size=10,
513
- marker_color="rgba(53,217,44,1)",
514
- name="Calculated benefits",
515
- )
516
- )
517
-
518
- fig.add_trace(
519
- go.Scatter(
520
- x=cba.index,
521
- y=cba["benefits_discounted"],
522
- mode="lines",
523
- line_color="rgba(35,150,29,1)",
524
- name="Discounted benefits",
525
- ),
526
- )
527
-
528
- fig.add_trace(
529
- go.Scatter(
530
- x=cba.index,
531
- y=cba["benefits_discounted"],
532
- mode="none",
533
- fill="tozeroy",
534
- fillcolor="rgba(35,150,29,0.5)",
535
- name="Benefits",
536
- )
537
- )
538
-
539
- # Update xaxis properties
540
- fig.update_xaxes(title_text="Year")
541
- # Update yaxis properties
542
- fig.update_yaxes(title_text=f"Annual Benefits ({self.unit})")
543
-
544
- fig.update_layout(
545
- autosize=False,
546
- height=400,
547
- width=800,
548
- margin={"r": 0, "l": 0, "b": 0, "t": 0},
549
- font={"size": 12, "color": "black", "family": "Arial"},
550
- )
551
-
552
- # write html to results folder
553
- html = self.results_path.joinpath("benefits.html")
554
- fig.write_html(html)
1
+ import shutil
2
+ from typing import Any
3
+
4
+ import geopandas as gpd
5
+ import numpy as np
6
+ import numpy_financial as npf
7
+ import pandas as pd
8
+ import plotly.graph_objects as go
9
+ import tomli
10
+ import tomli_w
11
+ from fiat_toolbox.metrics_writer.fiat_read_metrics_file import MetricsFileReader
12
+
13
+ from flood_adapt.misc.exceptions import DatabaseError
14
+ from flood_adapt.misc.path_builder import (
15
+ ObjectDir,
16
+ TopLevelDir,
17
+ db_path,
18
+ )
19
+ from flood_adapt.objects.benefits.benefits import Benefit
20
+ from flood_adapt.objects.scenarios.scenarios import Scenario
21
+
22
+
23
+ class BenefitRunner:
24
+ """Object holding all attributes and methods related to a benefit analysis."""
25
+
26
+ benefit: Benefit
27
+ _scenarios: pd.DataFrame
28
+
29
+ def __init__(self, database, benefit: Benefit):
30
+ """Initialize function called when object is created through the load_file or load_dict methods."""
31
+ self.database = database
32
+ self.benefit = benefit
33
+
34
+ # Get output path based on database path
35
+ self.results_path = self.database.benefits.output_path.joinpath(
36
+ self.benefit.name
37
+ )
38
+ self.site_info = self.database.site
39
+ self.unit = self.site_info.fiat.config.damage_unit
40
+
41
+ @property
42
+ def scenarios(self) -> pd.DataFrame:
43
+ """Get the scenarios of the benefit analysis.
44
+
45
+ Returns
46
+ -------
47
+ pd.DataFrame
48
+ a table with the scenarios of the Benefit analysis and their status
49
+ """
50
+ self._scenarios = self.check_scenarios()
51
+ return self._scenarios
52
+
53
+ @property
54
+ def has_run(self):
55
+ return self.has_run_check()
56
+
57
+ @property
58
+ def results(self):
59
+ if hasattr(self, "_results"):
60
+ return self._results
61
+ self._results = self.get_output()
62
+ return self._results
63
+
64
+ def get_output(self) -> dict[str, Any]:
65
+ if not self.has_run:
66
+ raise RuntimeError(
67
+ f"Cannot read output since benefit analysis '{self.benefit.name}' has not been run yet."
68
+ )
69
+
70
+ results_toml = self.results_path.joinpath("results.toml")
71
+ results_html = self.results_path.joinpath("benefits.html")
72
+ with open(results_toml, mode="rb") as fp:
73
+ results = tomli.load(fp)
74
+ results["html"] = str(results_html)
75
+ self._results = results
76
+ return results
77
+
78
+ def has_run_check(self) -> bool:
79
+ """Check if the benefit analysis has already been run.
80
+
81
+ Returns
82
+ -------
83
+ has_run : bool
84
+ True if the analysis has already been run, else False
85
+ """
86
+ # Output files to check
87
+ results_toml = self.results_path.joinpath("results.toml")
88
+ results_csv = self.results_path.joinpath("time_series.csv")
89
+ results_html = self.results_path.joinpath("benefits.html")
90
+
91
+ check = all(
92
+ result.exists() for result in [results_toml, results_csv, results_html]
93
+ )
94
+ return check
95
+
96
+ def check_scenarios(self) -> pd.DataFrame:
97
+ """Check which scenarios are needed for this benefit calculation and if they have already been created.
98
+
99
+ The scenarios attribute of the object is updated accordingly and the table of the scenarios is returned.
100
+
101
+ Returns
102
+ -------
103
+ pd.DataFrame
104
+ a table with the scenarios of the Benefit analysis and their status
105
+ """
106
+ # Define names of scenarios
107
+ scenarios_calc = {
108
+ "current_no_measures": {},
109
+ "current_with_strategy": {},
110
+ "future_no_measures": {},
111
+ "future_with_strategy": {},
112
+ }
113
+
114
+ # Use the predefined names for the current projections and no measures strategy
115
+ for scenario in scenarios_calc.keys():
116
+ scenarios_calc[scenario]["event"] = self.benefit.event_set
117
+
118
+ if "current" in scenario:
119
+ scenarios_calc[scenario]["projection"] = (
120
+ self.benefit.current_situation.projection
121
+ )
122
+ elif "future" in scenario:
123
+ scenarios_calc[scenario]["projection"] = self.benefit.projection
124
+
125
+ if "no_measures" in scenario:
126
+ scenarios_calc[scenario]["strategy"] = self.benefit.baseline_strategy
127
+ else:
128
+ scenarios_calc[scenario]["strategy"] = self.benefit.strategy
129
+
130
+ # Get the available scenarios
131
+ scenarios_avail = [
132
+ self.database.scenarios.get(scn)
133
+ for scn in self.database.scenarios.summarize_objects()["name"]
134
+ ]
135
+
136
+ # Check if any of the needed scenarios are already there
137
+ for scenario in scenarios_calc.keys():
138
+ scn_dict = scenarios_calc[scenario].copy()
139
+ scn_dict["name"] = scenario
140
+ scenario_obj = Scenario(**scn_dict)
141
+ created = [
142
+ scn_avl for scn_avl in scenarios_avail if scenario_obj == scn_avl
143
+ ]
144
+ if len(created) > 0:
145
+ scenarios_calc[scenario]["scenario created"] = created[0].name
146
+ scenarios_calc[scenario]["scenario run"] = (
147
+ self.database.scenarios.has_run_check(created[0].name)
148
+ )
149
+ else:
150
+ scenarios_calc[scenario]["scenario created"] = "No"
151
+ scenarios_calc[scenario]["scenario run"] = False
152
+
153
+ df = pd.DataFrame(scenarios_calc).T
154
+ scenarios = df.astype(
155
+ dtype={
156
+ "event": "str",
157
+ "projection": "str",
158
+ "strategy": "str",
159
+ "scenario created": "str",
160
+ "scenario run": bool,
161
+ }
162
+ )
163
+ return scenarios
164
+
165
+ def create_benefit_scenarios(self) -> None:
166
+ """Create any scenarios that are needed for the (cost-)benefit assessment and are not there already.
167
+
168
+ Parameters
169
+ ----------
170
+ benefit : Benefit
171
+ """
172
+ # Iterate through the scenarios needed and create them if not existing
173
+ for _, row in self.scenarios.iterrows():
174
+ if row["scenario created"] == "No":
175
+ name = "_".join([row["projection"], row["event"], row["strategy"]])
176
+
177
+ try:
178
+ self.database.scenarios.get(name)
179
+ except DatabaseError:
180
+ # If the scenario does not exist, create it
181
+ scenario = Scenario(
182
+ name=name,
183
+ event=row["event"],
184
+ projection=row["projection"],
185
+ strategy=row["strategy"],
186
+ )
187
+ self.database.scenarios.save(scenario)
188
+
189
+ def ready_to_run(self) -> bool:
190
+ """Check if all the required scenarios have already been run.
191
+
192
+ Returns
193
+ -------
194
+ bool
195
+ True if required scenarios have been already run
196
+ """
197
+ check = all(self.scenarios["scenario run"])
198
+
199
+ return check
200
+
201
+ def run_cost_benefit(self):
202
+ """Run the cost-benefit calculation for the total study area and the different aggregation levels."""
203
+ # Throw an error if not all runs are finished
204
+ if not self.ready_to_run():
205
+ # First check is scenarios are there
206
+ if "No" in self.scenarios["scenario created"].to_numpy():
207
+ raise RuntimeError("Necessary scenarios have not been created yet.")
208
+ scens = self.scenarios["scenario created"][~self.scenarios["scenario run"]]
209
+ raise RuntimeError(
210
+ f"Scenarios {', '.join(scens.values)} need to be run before the cost-benefit analysis can be performed"
211
+ )
212
+
213
+ # If path for results does not yet exist, make it, and if it does delete it and recreate it
214
+ if not self.results_path.is_dir():
215
+ self.results_path.mkdir(parents=True)
216
+ else:
217
+ shutil.rmtree(self.results_path)
218
+ self.results_path.mkdir(parents=True)
219
+
220
+ # Run the cost-benefit analysis
221
+ self.cba()
222
+ # Run aggregation benefits
223
+ self.cba_aggregation()
224
+ # Updates results
225
+ self.has_run_check()
226
+
227
+ # Cache results
228
+ self.results
229
+
230
+ def cba(self):
231
+ """Cost-benefit analysis for the whole study area."""
232
+ # Get EAD for each scenario and save to new dataframe
233
+ scenarios = self.scenarios.copy(deep=True)
234
+ scenarios["EAD"] = None
235
+
236
+ scn_output_path = db_path(TopLevelDir.output, ObjectDir.scenario)
237
+
238
+ # Get metrics per scenario
239
+ for index, scenario in scenarios.iterrows():
240
+ scn_name = scenario["scenario created"]
241
+ collective_fn = scn_output_path.joinpath(
242
+ scn_name, f"Infometrics_{scn_name}.csv"
243
+ )
244
+ collective_metrics = MetricsFileReader(
245
+ collective_fn,
246
+ ).read_metrics_from_file()
247
+
248
+ # Fill scenarios EAD column with values from metrics
249
+ scenarios.loc[index, "EAD"] = float(
250
+ collective_metrics["Value"]["ExpectedAnnualDamages"]
251
+ )
252
+
253
+ # Get years of interest
254
+ year_start = self.benefit.current_situation.year
255
+ year_end = self.benefit.future_year
256
+
257
+ # Calculate benefits
258
+ cba = self._calc_benefits(
259
+ years=[year_start, year_end],
260
+ risk_no_measures=[
261
+ scenarios.loc["current_no_measures", "EAD"],
262
+ scenarios.loc["future_no_measures", "EAD"],
263
+ ],
264
+ risk_with_strategy=[
265
+ scenarios.loc["current_with_strategy", "EAD"],
266
+ scenarios.loc["future_with_strategy", "EAD"],
267
+ ],
268
+ discount_rate=self.benefit.discount_rate,
269
+ )
270
+
271
+ # Save indicators in dictionary
272
+ results = {}
273
+ # Get net present value of benefits
274
+ results["benefits"] = cba["benefits_discounted"].sum()
275
+
276
+ # Only if costs are provided do the full cost-benefit analysis
277
+ cost_calc = (self.benefit.implementation_cost is not None) and (
278
+ self.benefit.annual_maint_cost is not None
279
+ )
280
+ if cost_calc:
281
+ cba = self._calc_costs(
282
+ benefits=cba,
283
+ implementation_cost=self.benefit.implementation_cost,
284
+ annual_maint_cost=self.benefit.annual_maint_cost,
285
+ discount_rate=self.benefit.discount_rate,
286
+ )
287
+ # Calculate costs
288
+ results["costs"] = cba["costs_discounted"].sum()
289
+ # Benefit to Cost Ratio
290
+ results["BCR"] = np.round(results["benefits"] / results["costs"], 2)
291
+ # Net present value
292
+ results["NPV"] = cba["profits_discounted"].sum()
293
+ # Internal Rate of Return
294
+ results["IRR"] = np.round(npf.irr(cba["profits"]), 3)
295
+
296
+ # Save results
297
+ if not self.results_path.is_dir():
298
+ self.results_path.mkdir(parents=True)
299
+
300
+ # Save indicators in a toml file
301
+ indicators = self.results_path.joinpath("results.toml")
302
+ with open(indicators, "wb") as f:
303
+ tomli_w.dump(results, f)
304
+
305
+ # Save time-series in a csv
306
+ time_series = self.results_path.joinpath("time_series.csv")
307
+ cba.to_csv(time_series)
308
+
309
+ # Make html
310
+ self._make_html(cba)
311
+
312
+ def cba_aggregation(self):
313
+ """Zonal Benefits analysis for the different aggregation areas."""
314
+ results_path = db_path(TopLevelDir.output, ObjectDir.scenario)
315
+ # Get years of interest
316
+ year_start = self.benefit.current_situation.year
317
+ year_end = self.benefit.future_year
318
+
319
+ # Get EAD for each scenario and save to new dataframe
320
+ scenarios = self.scenarios.copy(deep=True)
321
+
322
+ # Read in the names of the aggregation area types
323
+ aggregations = [aggr.name for aggr in self.site_info.fiat.config.aggregation]
324
+
325
+ # Check if equity information is available to define variables to use
326
+ vars = []
327
+ for i, aggr_name in enumerate(aggregations):
328
+ if self.site_info.fiat.config.aggregation[i].equity is not None:
329
+ vars.append(["EAD", "EWEAD"])
330
+ else:
331
+ vars.append(["EAD"])
332
+
333
+ # Define which names are used in the metric tables
334
+ var_metric = {"EAD": "ExpectedAnnualDamages", "EWEAD": "EWEAD"}
335
+
336
+ # Prepare dictionary to save values
337
+ risk = {}
338
+
339
+ # Fill in the dictionary
340
+ for i, aggr_name in enumerate(aggregations):
341
+ risk[aggr_name] = {}
342
+ values = {}
343
+ for var in vars[i]:
344
+ values[var] = []
345
+ for index, scenario in scenarios.iterrows():
346
+ scn_name = scenario["scenario created"]
347
+ # Get available aggregation levels
348
+ aggregation_fn = results_path.joinpath(
349
+ scn_name, f"Infometrics_{scn_name}_{aggr_name}.csv"
350
+ )
351
+ for var in vars[i]:
352
+ # Get metrics per scenario and per aggregation
353
+ aggregated_metrics = MetricsFileReader(
354
+ aggregation_fn,
355
+ ).read_aggregated_metric_from_file(var_metric[var])[2:]
356
+ aggregated_metrics = aggregated_metrics.loc[
357
+ aggregated_metrics.index.dropna()
358
+ ]
359
+ aggregated_metrics.name = scenario.name
360
+ values[var].append(aggregated_metrics)
361
+
362
+ # Combine values in a single dataframe
363
+ for var in vars[i]:
364
+ risk[aggr_name][var] = pd.DataFrame(values[var]).T.astype(float)
365
+
366
+ var_output = {"EAD": "Benefits", "EWEAD": "Equity Weighted Benefits"}
367
+
368
+ # Calculate benefits
369
+ benefits = {}
370
+ for i, aggr_name in enumerate(aggregations):
371
+ benefits[aggr_name] = pd.DataFrame()
372
+ benefits[aggr_name].index = risk[aggr_name]["EAD"].index
373
+ for var in vars[i]:
374
+ for index, row in risk[aggr_name][var].iterrows():
375
+ cba = self._calc_benefits(
376
+ years=[year_start, year_end],
377
+ risk_no_measures=[
378
+ row["current_no_measures"],
379
+ row["future_no_measures"],
380
+ ],
381
+ risk_with_strategy=[
382
+ row["current_with_strategy"],
383
+ row["future_with_strategy"],
384
+ ],
385
+ discount_rate=self.benefit.discount_rate,
386
+ )
387
+ benefits[aggr_name].loc[row.name, var_output[var]] = cba[
388
+ "benefits_discounted"
389
+ ].sum()
390
+
391
+ # Save results
392
+ if not self.results_path.is_dir():
393
+ self.results_path.mkdir(parents=True)
394
+
395
+ # Save benefits per aggregation area (csv and gpkg)
396
+ for i, aggr_name in enumerate(aggregations):
397
+ csv_filename = self.results_path.joinpath(f"benefits_{aggr_name}.csv")
398
+ benefits[aggr_name].to_csv(csv_filename, index=True)
399
+
400
+ # Load aggregation areas
401
+ ind = [
402
+ i
403
+ for i, n in enumerate(self.site_info.fiat.config.aggregation)
404
+ if n.name == aggr_name
405
+ ][0]
406
+ aggr_areas_path = (
407
+ db_path(TopLevelDir.static)
408
+ / self.site_info.fiat.config.aggregation[ind].file
409
+ )
410
+ aggr_areas = gpd.read_file(aggr_areas_path, engine="pyogrio")
411
+ # Define output path
412
+ outpath = self.results_path.joinpath(f"benefits_{aggr_name}.gpkg")
413
+ # Save file
414
+ aggr_areas = aggr_areas.join(
415
+ benefits[aggr_name],
416
+ on=self.site_info.fiat.config.aggregation[ind].field_name,
417
+ )
418
+ aggr_areas.to_file(outpath, driver="GPKG")
419
+
420
+ @staticmethod
421
+ def _calc_benefits(
422
+ years: list[int, int],
423
+ risk_no_measures: list[float, float],
424
+ risk_with_strategy: list[float, float],
425
+ discount_rate: float,
426
+ ) -> pd.DataFrame:
427
+ """Calculate per year benefits and discounted benefits.
428
+
429
+ Parameters
430
+ ----------
431
+ years : list[int, int]
432
+ the current and future year for the analysis
433
+ risk_no_measures : list[float, float]
434
+ the current and future risk value without any measures
435
+ risk_with_strategy : list[float, float]
436
+ the current and future risk value with the strategy under investigation
437
+ discount_rate : float
438
+ the yearly discount rate used to calculated the total benefit
439
+
440
+ Returns
441
+ -------
442
+ pd.DataFrame
443
+ Dataframe containing the time-series of risks and benefits per year
444
+ """
445
+ benefits = pd.DataFrame(
446
+ data={"risk_no_measures": np.nan, "risk_with_strategy": np.nan},
447
+ index=np.arange(years[0], years[1] + 1),
448
+ )
449
+ benefits.index.names = ["year"]
450
+
451
+ # Fill in dataframe
452
+ for strat, risk in zip(
453
+ ["no_measures", "with_strategy"], [risk_no_measures, risk_with_strategy]
454
+ ):
455
+ benefits.loc[years[0], f"risk_{strat}"] = risk[0]
456
+ benefits.loc[years[1], f"risk_{strat}"] = risk[1]
457
+
458
+ # Assume linear trend between current and future
459
+ benefits = benefits.interpolate(method="linear")
460
+
461
+ # Calculate benefits
462
+ benefits["benefits"] = (
463
+ benefits["risk_no_measures"] - benefits["risk_with_strategy"]
464
+ )
465
+ # Calculate discounted benefits using the provided discount rate
466
+ benefits["benefits_discounted"] = benefits["benefits"] / (
467
+ 1 + discount_rate
468
+ ) ** (benefits.index - benefits.index[0])
469
+
470
+ return benefits
471
+
472
+ @staticmethod
473
+ def _calc_costs(
474
+ benefits: pd.DataFrame,
475
+ implementation_cost: float,
476
+ annual_maint_cost: float,
477
+ discount_rate: float,
478
+ ) -> pd.DataFrame:
479
+ """Calculate per year costs and discounted costs.
480
+
481
+ Parameters
482
+ ----------
483
+ benefits : pd.DataFrame
484
+ a time series of benefits per year (produced with __calc_benefits method)
485
+ implementation_cost : float
486
+ initial costs of implementing the adaptation strategy
487
+ annual_maint_cost : float
488
+ annual maintenance cost of the adaptation strategy
489
+ discount_rate : float
490
+ yearly discount rate
491
+
492
+ Returns
493
+ -------
494
+ pd.DataFrame
495
+ Dataframe containing the time-series of benefits, costs and profits per year
496
+ """
497
+ benefits = benefits.copy()
498
+ benefits["costs"] = np.nan
499
+ # implementations costs at current year and maintenance from year 1
500
+ benefits.loc[benefits.index[0], "costs"] = implementation_cost
501
+ benefits.loc[benefits.index[1:], "costs"] = annual_maint_cost
502
+ benefits["costs_discounted"] = benefits["costs"] / (1 + discount_rate) ** (
503
+ benefits.index - benefits.index[0]
504
+ )
505
+
506
+ # Benefit to Cost Ratio
507
+ benefits["profits"] = benefits["benefits"] - benefits["costs"]
508
+ benefits["profits_discounted"] = benefits["profits"] / (1 + discount_rate) ** (
509
+ benefits.index - benefits.index[0]
510
+ )
511
+
512
+ return benefits
513
+
514
+ def _make_html(self, cba):
515
+ """Make an html with the time-series of the benefits and discounted benefits."""
516
+ # Save a plotly graph in an html
517
+ fig = go.Figure()
518
+
519
+ # Get only endpoints
520
+ cba2 = cba.iloc[[0, -1]]
521
+
522
+ # Add graph with benefits
523
+ fig.add_trace(
524
+ go.Scatter(
525
+ x=cba.index,
526
+ y=cba["benefits"],
527
+ mode="lines",
528
+ line_color="black",
529
+ name="Interpolated benefits",
530
+ )
531
+ )
532
+ fig.add_trace(
533
+ go.Scatter(
534
+ x=cba2.index,
535
+ y=cba2["benefits"],
536
+ mode="markers",
537
+ marker_size=10,
538
+ marker_color="rgba(53,217,44,1)",
539
+ name="Calculated benefits",
540
+ )
541
+ )
542
+
543
+ fig.add_trace(
544
+ go.Scatter(
545
+ x=cba.index,
546
+ y=cba["benefits_discounted"],
547
+ mode="lines",
548
+ line_color="rgba(35,150,29,1)",
549
+ name="Discounted benefits",
550
+ ),
551
+ )
552
+
553
+ fig.add_trace(
554
+ go.Scatter(
555
+ x=cba.index,
556
+ y=cba["benefits_discounted"],
557
+ mode="none",
558
+ fill="tozeroy",
559
+ fillcolor="rgba(35,150,29,0.5)",
560
+ name="Benefits",
561
+ )
562
+ )
563
+
564
+ # Update xaxis properties
565
+ fig.update_xaxes(title_text="Year")
566
+ # Update yaxis properties
567
+ fig.update_yaxes(title_text=f"Annual Benefits ({self.unit})")
568
+
569
+ fig.update_layout(
570
+ autosize=False,
571
+ height=400,
572
+ width=800,
573
+ margin={"r": 0, "l": 0, "b": 0, "t": 0},
574
+ font={"size": 12, "color": "black", "family": "Arial"},
575
+ )
576
+
577
+ # write html to results folder
578
+ html = self.results_path.joinpath("benefits.html")
579
+ fig.write_html(html)