flood-adapt 1.0.4__py3-none-any.whl → 1.1.0__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 (45) hide show
  1. flood_adapt/__init__.py +2 -3
  2. flood_adapt/adapter/fiat_adapter.py +8 -3
  3. flood_adapt/adapter/sfincs_adapter.py +2 -2
  4. flood_adapt/adapter/sfincs_offshore.py +1 -1
  5. flood_adapt/config/fiat.py +1 -1
  6. flood_adapt/config/gui.py +185 -8
  7. flood_adapt/database_builder/database_builder.py +155 -129
  8. flood_adapt/database_builder/metrics_utils.py +1834 -0
  9. flood_adapt/dbs_classes/database.py +23 -28
  10. flood_adapt/dbs_classes/dbs_benefit.py +0 -26
  11. flood_adapt/dbs_classes/dbs_event.py +2 -2
  12. flood_adapt/dbs_classes/dbs_measure.py +2 -2
  13. flood_adapt/dbs_classes/dbs_scenario.py +0 -24
  14. flood_adapt/dbs_classes/dbs_static.py +4 -4
  15. flood_adapt/dbs_classes/dbs_strategy.py +2 -4
  16. flood_adapt/dbs_classes/dbs_template.py +65 -25
  17. flood_adapt/flood_adapt.py +63 -12
  18. flood_adapt/misc/exceptions.py +43 -6
  19. {flood_adapt-1.0.4.dist-info → flood_adapt-1.1.0.dist-info}/METADATA +3 -3
  20. {flood_adapt-1.0.4.dist-info → flood_adapt-1.1.0.dist-info}/RECORD +24 -44
  21. flood_adapt/database_builder/templates/infographics/OSM/config_charts.toml +0 -90
  22. flood_adapt/database_builder/templates/infographics/OSM/config_people.toml +0 -57
  23. flood_adapt/database_builder/templates/infographics/OSM/config_risk_charts.toml +0 -121
  24. flood_adapt/database_builder/templates/infographics/OSM/config_roads.toml +0 -65
  25. flood_adapt/database_builder/templates/infographics/US_NSI/config_charts.toml +0 -126
  26. flood_adapt/database_builder/templates/infographics/US_NSI/config_people.toml +0 -60
  27. flood_adapt/database_builder/templates/infographics/US_NSI/config_risk_charts.toml +0 -121
  28. flood_adapt/database_builder/templates/infographics/US_NSI/config_roads.toml +0 -65
  29. flood_adapt/database_builder/templates/infographics/US_NSI/styles.css +0 -45
  30. flood_adapt/database_builder/templates/infometrics/OSM/metrics_additional_risk_configs.toml +0 -4
  31. flood_adapt/database_builder/templates/infometrics/OSM/with_SVI/infographic_metrics_config.toml +0 -143
  32. flood_adapt/database_builder/templates/infometrics/OSM/with_SVI/infographic_metrics_config_risk.toml +0 -153
  33. flood_adapt/database_builder/templates/infometrics/OSM/without_SVI/infographic_metrics_config.toml +0 -127
  34. flood_adapt/database_builder/templates/infometrics/OSM/without_SVI/infographic_metrics_config_risk.toml +0 -57
  35. flood_adapt/database_builder/templates/infometrics/US_NSI/metrics_additional_risk_configs.toml +0 -4
  36. flood_adapt/database_builder/templates/infometrics/US_NSI/with_SVI/infographic_metrics_config.toml +0 -191
  37. flood_adapt/database_builder/templates/infometrics/US_NSI/with_SVI/infographic_metrics_config_risk.toml +0 -153
  38. flood_adapt/database_builder/templates/infometrics/US_NSI/without_SVI/infographic_metrics_config.toml +0 -178
  39. flood_adapt/database_builder/templates/infometrics/US_NSI/without_SVI/infographic_metrics_config_risk.toml +0 -57
  40. flood_adapt/database_builder/templates/infometrics/mandatory_metrics_config.toml +0 -9
  41. flood_adapt/database_builder/templates/infometrics/mandatory_metrics_config_risk.toml +0 -65
  42. /flood_adapt/database_builder/templates/infographics/{OSM/styles.css → styles.css} +0 -0
  43. {flood_adapt-1.0.4.dist-info → flood_adapt-1.1.0.dist-info}/LICENSE +0 -0
  44. {flood_adapt-1.0.4.dist-info → flood_adapt-1.1.0.dist-info}/WHEEL +0 -0
  45. {flood_adapt-1.0.4.dist-info → flood_adapt-1.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1834 @@
1
+ from os import PathLike
2
+ from pathlib import Path
3
+ from typing import Any, Dict, List, Literal, Optional, Union
4
+
5
+ import tomli_w
6
+ from pydantic import BaseModel, Field, field_validator
7
+
8
+ from flood_adapt.adapter.fiat_adapter import _IMPACT_COLUMNS
9
+
10
+
11
+ def combine_filters(*filters):
12
+ """
13
+ Combine multiple SQL filter strings with AND operators.
14
+
15
+ Parameters
16
+ ----------
17
+ *filters : str
18
+ Variable number of filter strings to combine.
19
+
20
+ Returns
21
+ -------
22
+ str
23
+ Combined filter string with AND operators, excluding empty filters.
24
+ """
25
+ return " AND ".join(f for f in filters if f)
26
+
27
+
28
+ def pascal_case(s):
29
+ """
30
+ Convert a string to PascalCase.
31
+
32
+ Parameters
33
+ ----------
34
+ s : str
35
+ Input string to convert.
36
+
37
+ Returns
38
+ -------
39
+ str
40
+ String converted to PascalCase.
41
+ """
42
+ return "".join(word.capitalize() for word in s.split())
43
+
44
+
45
+ class FieldMapping(BaseModel):
46
+ """
47
+ Represents a mapping of a database field to a list of allowed values.
48
+
49
+ Parameters
50
+ ----------
51
+ field_name : str
52
+ The name of the database field/column
53
+ values : List[str]
54
+ List of values that should match this field
55
+
56
+ Methods
57
+ -------
58
+ to_sql_filter()
59
+ Generate SQL filter string for this field mapping.
60
+ """
61
+
62
+ field_name: str
63
+ values: List[str]
64
+
65
+ def to_sql_filter(self) -> str:
66
+ """
67
+ Generate SQL filter string for this field mapping.
68
+
69
+ Returns
70
+ -------
71
+ str
72
+ SQL WHERE clause condition string for this field mapping.
73
+ """
74
+ quoted_values = ", ".join([f"'{v}'" for v in self.values])
75
+ return f"`{self.field_name}` IN ({quoted_values})"
76
+
77
+
78
+ class TypeMapping(BaseModel):
79
+ """
80
+ Container for multiple field mappings that define object type filtering.
81
+
82
+ Parameters
83
+ ----------
84
+ mappings : List[FieldMapping]
85
+ List of field mappings that together define the type criteria
86
+
87
+ Methods
88
+ -------
89
+ add_mapping(field_name, values)
90
+ Add a new field mapping.
91
+ to_sql_filter()
92
+ Generate combined SQL filter string from all mappings.
93
+ """
94
+
95
+ mappings: List[FieldMapping] = Field(default_factory=list)
96
+
97
+ def add_mapping(self, field_name: str, values: List[str]) -> None:
98
+ """
99
+ Add a new field mapping.
100
+
101
+ Parameters
102
+ ----------
103
+ field_name : str
104
+ Name of the database field.
105
+ values : List[str]
106
+ List of allowed values for this field.
107
+ """
108
+ self.mappings.append(FieldMapping(field_name=field_name, values=values))
109
+
110
+ def to_sql_filter(self) -> str:
111
+ """
112
+ Generate combined SQL filter string from all mappings.
113
+
114
+ Returns
115
+ -------
116
+ str
117
+ Combined SQL WHERE clause condition string.
118
+ """
119
+ if not self.mappings:
120
+ return ""
121
+ filter_parts = [mapping.to_sql_filter() for mapping in self.mappings]
122
+ return " AND ".join(filter_parts)
123
+
124
+
125
+ class MetricModel(BaseModel):
126
+ """
127
+ Represents a metric configuration for infometric analysis.
128
+
129
+ Parameters
130
+ ----------
131
+ name : str
132
+ The short name of the metric.
133
+ long_name : Optional[str], default=None
134
+ The long descriptive name of the metric. Defaults to `name` if not provided.
135
+ show_in_metrics_table : Optional[bool], default=True
136
+ Indicates whether the metric should be displayed in the metrics table.
137
+ description : Optional[str], default=None
138
+ A detailed description of the metric. Defaults to `name` if not provided.
139
+ select : str
140
+ The SQL select statement or expression for the metric.
141
+ filter : Optional[str], default=""
142
+ An optional SQL filter to apply to the metric. Defaults to no filter.
143
+
144
+ Methods
145
+ -------
146
+ set_defaults(value, info)
147
+ Sets default values for `long_name` and `description` fields using the `name` field if they are not provided.
148
+ """
149
+
150
+ name: str
151
+ long_name: Optional[str] = None
152
+ show_in_metrics_table: Optional[bool] = True
153
+ description: Optional[str] = None
154
+ select: str
155
+ filter: Optional[str] = "" # This defaults to no filter
156
+
157
+ @field_validator("long_name", "description", mode="after")
158
+ @classmethod
159
+ def set_defaults(cls, value, info):
160
+ """
161
+ Set default values for long_name and description fields.
162
+
163
+ Parameters
164
+ ----------
165
+ value : Any
166
+ The current field value.
167
+ info : Any
168
+ Field validation info containing all field values.
169
+
170
+ Returns
171
+ -------
172
+ str
173
+ The field value or the default value from 'name' field.
174
+ """
175
+ # info.data contains all field values
176
+ if value is None:
177
+ # Use 'name' field as default
178
+ return info.data.get("name")
179
+ return value
180
+
181
+
182
+ class ImpactCategoriesModel(BaseModel):
183
+ """
184
+ Model for defining impact categories with associated colors, field, unit, and bins.
185
+
186
+ Parameters
187
+ ----------
188
+ categories : list[str], default=["Minor", "Major", "Severe"]
189
+ List of impact category names.
190
+ colors : Optional[list[str]], default=["#ffa500", "#ff0000", "#000000"]
191
+ List of colors corresponding to each category.
192
+ field : str
193
+ The database field name used for categorization.
194
+ unit : str
195
+ The unit of measurement for the field.
196
+ bins : list[float]
197
+ List of threshold values for binning the field values.
198
+
199
+ Methods
200
+ -------
201
+ validate_colors_length(colors, info)
202
+ Validate that colors list length matches categories list length.
203
+ validate_bins_length(bins, info)
204
+ Validate that bins list length is one less than categories list length.
205
+ """
206
+
207
+ categories: list[str] = Field(default_factory=lambda: ["Minor", "Major", "Severe"])
208
+ colors: Optional[list[str]] = Field(
209
+ default_factory=lambda: ["#ffa500", "#ff0000", "#000000"]
210
+ )
211
+ field: str = _IMPACT_COLUMNS.inundation_depth
212
+ unit: str
213
+ bins: list[float]
214
+
215
+ @field_validator("colors", mode="before")
216
+ @classmethod
217
+ def validate_colors_length(cls, colors, info):
218
+ """
219
+ Validate that colors list length matches categories list length.
220
+
221
+ Parameters
222
+ ----------
223
+ colors : list[str]
224
+ List of color values.
225
+ info : Any
226
+ Field validation info containing all field values.
227
+
228
+ Returns
229
+ -------
230
+ list[str]
231
+ The validated colors list.
232
+
233
+ Raises
234
+ ------
235
+ ValueError
236
+ If colors length doesn't match categories length.
237
+ """
238
+ categories = info.data.get("categories")
239
+ if categories and colors and len(colors) != len(categories):
240
+ raise ValueError("Length of 'colors' must match length of 'categories'.")
241
+ return colors
242
+
243
+ @field_validator("bins", mode="before")
244
+ @classmethod
245
+ def validate_bins_length(cls, bins, info):
246
+ """
247
+ Validate that bins list length is one less than categories list length.
248
+
249
+ Parameters
250
+ ----------
251
+ bins : list[float]
252
+ List of bin threshold values.
253
+ info : Any
254
+ Field validation info containing all field values.
255
+
256
+ Returns
257
+ -------
258
+ list[float]
259
+ The validated bins list.
260
+
261
+ Raises
262
+ ------
263
+ ValueError
264
+ If bins length is not one less than categories length.
265
+ """
266
+ categories = info.data.get("categories")
267
+ if categories and len(bins) != len(categories) - 1:
268
+ raise ValueError(
269
+ "Length of 'bins' must be one less than length of 'categories'."
270
+ )
271
+ return bins
272
+
273
+
274
+ class BuildingsInfographicModel(BaseModel):
275
+ """
276
+ Model for building infographic configuration.
277
+
278
+ Parameters
279
+ ----------
280
+ types : list[str]
281
+ List of building types.
282
+ icons : list[str]
283
+ List of icon names corresponding to each building type.
284
+ type_mapping : dict[str, TypeMapping]
285
+ Mapping of building types to their database filtering criteria.
286
+ impact_categories : ImpactCategoriesModel
287
+ Impact categories configuration.
288
+
289
+ Methods
290
+ -------
291
+ validate_icons_length(icons, info)
292
+ Validate that icons list length matches types list length.
293
+ get_template(type)
294
+ Get a pre-configured template for OSM or NSI building types.
295
+ """
296
+
297
+ # Define building types
298
+ types: list[str]
299
+ icons: list[str]
300
+ type_mapping: dict[str, TypeMapping]
301
+ # Define impact categories
302
+ impact_categories: ImpactCategoriesModel
303
+
304
+ @field_validator("icons", mode="before")
305
+ @classmethod
306
+ def validate_icons_length(cls, icons, info):
307
+ """
308
+ Validate that icons list length matches types list length.
309
+
310
+ Parameters
311
+ ----------
312
+ icons : list[str]
313
+ List of icon names.
314
+ info : Any
315
+ Field validation info containing all field values.
316
+
317
+ Returns
318
+ -------
319
+ list[str]
320
+ The validated icons list.
321
+
322
+ Raises
323
+ ------
324
+ ValueError
325
+ If icons length doesn't match types length.
326
+ """
327
+ types = info.data.get("types")
328
+ if types and len(icons) != len(types):
329
+ raise ValueError("Length of 'icons' must equal to the length of 'types'.")
330
+ return icons
331
+
332
+ @staticmethod
333
+ def get_template(type: Literal["OSM", "NSI"]):
334
+ """
335
+ Get a pre-configured template for building infographics.
336
+
337
+ Parameters
338
+ ----------
339
+ type : Literal["OSM", "NSI"]
340
+ The database type to create a template for.
341
+
342
+ Returns
343
+ -------
344
+ BuildingsInfographicModel
345
+ Pre-configured building infographic model.
346
+ """
347
+ if type == "OSM":
348
+ config = BuildingsInfographicModel(
349
+ types=["Residential", "Commercial", "Industrial"],
350
+ icons=["house", "cart", "factory"],
351
+ type_mapping={
352
+ "Residential": TypeMapping(
353
+ mappings=[
354
+ FieldMapping(
355
+ field_name=_IMPACT_COLUMNS.primary_object_type,
356
+ values=["residential"],
357
+ )
358
+ ]
359
+ ),
360
+ "Commercial": TypeMapping(
361
+ mappings=[
362
+ FieldMapping(
363
+ field_name=_IMPACT_COLUMNS.primary_object_type,
364
+ values=["commercial"],
365
+ )
366
+ ]
367
+ ),
368
+ "Industrial": TypeMapping(
369
+ mappings=[
370
+ FieldMapping(
371
+ field_name=_IMPACT_COLUMNS.primary_object_type,
372
+ values=["industrial"],
373
+ )
374
+ ]
375
+ ),
376
+ },
377
+ impact_categories=ImpactCategoriesModel(
378
+ unit="meters", bins=[0.25, 1.5]
379
+ ),
380
+ )
381
+ elif type == "NSI":
382
+ config = BuildingsInfographicModel(
383
+ types=[
384
+ "Residential",
385
+ "Commercial",
386
+ "Health facilities",
387
+ "Schools",
388
+ "Emergency facilities",
389
+ ],
390
+ icons=["house", "cart", "hospital", "school", "firetruck"],
391
+ type_mapping={
392
+ "Residential": TypeMapping(
393
+ mappings=[
394
+ FieldMapping(
395
+ field_name=_IMPACT_COLUMNS.primary_object_type,
396
+ values=["RES"],
397
+ )
398
+ ]
399
+ ),
400
+ "Commercial": TypeMapping(
401
+ mappings=[
402
+ FieldMapping(
403
+ field_name="Secondary Object Type",
404
+ values=[
405
+ "COM1",
406
+ "COM2",
407
+ "COM3",
408
+ "COM4",
409
+ "COM5",
410
+ "COM8",
411
+ "COM9",
412
+ ],
413
+ )
414
+ ]
415
+ ),
416
+ "Health facilities": TypeMapping(
417
+ mappings=[
418
+ FieldMapping(
419
+ field_name="Secondary Object Type",
420
+ values=["RES6", "COM6", "COM7"],
421
+ )
422
+ ]
423
+ ),
424
+ "Schools": TypeMapping(
425
+ mappings=[
426
+ FieldMapping(
427
+ field_name="Secondary Object Type",
428
+ values=["EDU1", "EDU2"],
429
+ )
430
+ ]
431
+ ),
432
+ "Emergency facilities": TypeMapping(
433
+ mappings=[
434
+ FieldMapping(
435
+ field_name="Secondary Object Type", values=["GOV2"]
436
+ )
437
+ ]
438
+ ),
439
+ },
440
+ impact_categories=ImpactCategoriesModel(unit="feet", bins=[1, 6]),
441
+ )
442
+ return config
443
+
444
+
445
+ class SviModel(BaseModel):
446
+ """
447
+ Model for Social Vulnerability Index (SVI) configuration.
448
+
449
+ Parameters
450
+ ----------
451
+ classes : list[str], default=["Low", "High"]
452
+ List of vulnerability class names.
453
+ colors : list[str], default=["#D5DEE1", "#88A2AA"]
454
+ List of colors corresponding to each vulnerability class.
455
+ thresholds : list[float], default=[0.7]
456
+ List of threshold values for vulnerability classification.
457
+
458
+ Methods
459
+ -------
460
+ validate_colors_length(colors, info)
461
+ Validate that colors list length matches classes list length.
462
+ validate_thresholds_length(thresholds, info)
463
+ Validate that thresholds list length is one less than classes list length.
464
+ """
465
+
466
+ classes: list[str] = Field(default_factory=lambda: ["Low", "High"])
467
+ colors: list[str] = Field(default_factory=lambda: ["#D5DEE1", "#88A2AA"])
468
+ thresholds: list[float] = Field(default_factory=lambda: [0.7])
469
+
470
+ @field_validator("colors", mode="before")
471
+ @classmethod
472
+ def validate_colors_length(cls, colors, info):
473
+ """
474
+ Validate that colors list length matches classes list length.
475
+
476
+ Parameters
477
+ ----------
478
+ colors : list[str]
479
+ List of color values.
480
+ info : Any
481
+ Field validation info containing all field values.
482
+
483
+ Returns
484
+ -------
485
+ list[str]
486
+ The validated colors list.
487
+
488
+ Raises
489
+ ------
490
+ ValueError
491
+ If colors length doesn't match classes length.
492
+ """
493
+ classes = info.data.get("classes")
494
+ if classes and colors and len(colors) != len(classes):
495
+ raise ValueError("Length of 'colors' must match length of 'classes'.")
496
+ return colors
497
+
498
+ @field_validator("thresholds", mode="before")
499
+ @classmethod
500
+ def validate_thresholds_length(cls, thresholds, info):
501
+ """
502
+ Validate that thresholds list length is one less than classes list length.
503
+
504
+ Parameters
505
+ ----------
506
+ thresholds : list[float]
507
+ List of threshold values.
508
+ info : Any
509
+ Field validation info containing all field values.
510
+
511
+ Returns
512
+ -------
513
+ list[float]
514
+ The validated thresholds list.
515
+
516
+ Raises
517
+ ------
518
+ ValueError
519
+ If thresholds length is not one less than classes length.
520
+ """
521
+ classes = info.data.get("classes")
522
+ if classes and len(thresholds) != len(classes) - 1:
523
+ raise ValueError(
524
+ "Length of 'thresholds' must be one less than length of 'classes'."
525
+ )
526
+ return thresholds
527
+
528
+
529
+ class HomesInfographicModel(BaseModel):
530
+ """
531
+ Model for Homes and SVI (Social Vulnerability Index) infographic configuration.
532
+
533
+ Parameters
534
+ ----------
535
+ svi : SviModel
536
+ SVI classification configuration.
537
+ mapping : TypeMapping
538
+ Database field mapping for filtering relevant objects.
539
+ impact_categories : ImpactCategoriesModel
540
+ Impact categories configuration.
541
+
542
+ Methods
543
+ -------
544
+ get_template(svi_threshold, type)
545
+ Get a pre-configured template for SVI infographics.
546
+ """
547
+
548
+ svi: Optional[SviModel] = None
549
+ mapping: TypeMapping
550
+ impact_categories: ImpactCategoriesModel
551
+
552
+ @staticmethod
553
+ def get_template(
554
+ type: Literal["OSM", "NSI"] = "OSM", svi_threshold: Optional[float] = None
555
+ ):
556
+ """
557
+ Get a pre-configured template for SVI infographics.
558
+
559
+ Parameters
560
+ ----------
561
+ svi_threshold : Optional[float], default=None
562
+ The SVI threshold value for vulnerability classification. If not provided, SVI will be None.
563
+ type : Literal["OSM", "NSI"], default="OSM"
564
+ The database type to create a template for.
565
+
566
+ Returns
567
+ -------
568
+ HomesInfographicModel
569
+ Pre-configured Homes infographic model.
570
+ """
571
+ if svi_threshold is not None:
572
+ svi_model = SviModel(thresholds=[svi_threshold])
573
+ else:
574
+ svi_model = None
575
+
576
+ if type == "OSM":
577
+ config = HomesInfographicModel(
578
+ svi=svi_model,
579
+ mapping=TypeMapping(
580
+ mappings=[
581
+ FieldMapping(
582
+ field_name=_IMPACT_COLUMNS.primary_object_type,
583
+ values=["residential"],
584
+ )
585
+ ]
586
+ ),
587
+ impact_categories=ImpactCategoriesModel(
588
+ categories=["Flooded", "Displaced"],
589
+ colors=None,
590
+ unit="meters",
591
+ bins=[1.5],
592
+ ),
593
+ )
594
+ elif type == "NSI":
595
+ config = HomesInfographicModel(
596
+ svi=svi_model,
597
+ mapping=TypeMapping(
598
+ mappings=[
599
+ FieldMapping(
600
+ field_name=_IMPACT_COLUMNS.primary_object_type,
601
+ values=["RES"],
602
+ )
603
+ ]
604
+ ),
605
+ impact_categories=ImpactCategoriesModel(
606
+ categories=["Flooded", "Displaced"],
607
+ colors=None,
608
+ unit="feet",
609
+ bins=[6],
610
+ ),
611
+ )
612
+ return config
613
+
614
+
615
+ class RoadsInfographicModel(BaseModel):
616
+ """
617
+ Model for roads infographic configuration.
618
+
619
+ Parameters
620
+ ----------
621
+ categories : list[str], default=["Slight", "Minor", "Major", "Severe"]
622
+ List of road impact category names.
623
+ colors : list[str], default=["#e0f7fa", "#80deea", "#26c6da", "#006064"]
624
+ List of colors corresponding to each category.
625
+ icons : list[str], default=["walking_person", "car", "truck", "ambulance"]
626
+ List of icon names for each category.
627
+ users : list[str], default=["Pedestrians", "Cars", "Trucks", "Rescue vehicles"]
628
+ List of road user types for each category.
629
+ thresholds : list[float]
630
+ List of threshold values for categorizing road impacts.
631
+ field : str, default=_IMPACT_COLUMNS.inundation_depth
632
+ The database field name used for categorization.
633
+ unit : str
634
+ The unit of measurement for the field.
635
+ road_length_field : str, default=_IMPACT_COLUMNS.segment_length
636
+ The database field name containing road segment lengths.
637
+
638
+ Methods
639
+ -------
640
+ validate_lengths(v, info)
641
+ Validate that all list attributes have the same length.
642
+ get_template(unit_system)
643
+ Get a pre-configured template for metric or imperial units.
644
+ """
645
+
646
+ categories: list[str] = Field(
647
+ default_factory=lambda: ["Slight", "Minor", "Major", "Severe"]
648
+ )
649
+ colors: list[str] = Field(
650
+ default_factory=lambda: ["#D5DEE1", "#D5DEE1", "#D5DEE1", "#D5DEE1"]
651
+ )
652
+ icons: list[str] = Field(
653
+ default_factory=lambda: ["walking_person", "car", "truck", "ambulance"]
654
+ )
655
+ users: list[str] = Field(
656
+ default_factory=lambda: ["Pedestrians", "Cars", "Trucks", "Rescue vehicles"]
657
+ )
658
+ thresholds: list[float]
659
+ field: str = _IMPACT_COLUMNS.inundation_depth
660
+ unit: str
661
+ road_length_field: str = _IMPACT_COLUMNS.segment_length
662
+
663
+ @field_validator("categories", mode="after")
664
+ @classmethod
665
+ def validate_lengths(cls, v, info):
666
+ """
667
+ Validate that all list attributes have the same length.
668
+
669
+ Parameters
670
+ ----------
671
+ v : list[str]
672
+ The categories list.
673
+ info : Any
674
+ Field validation info containing all field values.
675
+
676
+ Returns
677
+ -------
678
+ list[str]
679
+ The validated categories list.
680
+
681
+ Raises
682
+ ------
683
+ ValueError
684
+ If list attributes don't have the same length.
685
+ """
686
+ # Check that categories, colors, icons, users, thresholds have the same length
687
+ attrs = ["categories", "colors", "icons", "users", "thresholds"]
688
+ lengths = [len(info.data.get(attr, [])) for attr in attrs]
689
+ if len(set(lengths)) > 1:
690
+ raise ValueError(
691
+ f"Attributes {attrs} must all have the same length, got lengths: {lengths}"
692
+ )
693
+ return v
694
+
695
+ @staticmethod
696
+ def get_template(unit_system: Literal["metric", "imperial"]):
697
+ """
698
+ Get a pre-configured template for roads infographics.
699
+
700
+ Parameters
701
+ ----------
702
+ unit_system : Literal["metric", "imperial"]
703
+ The unit system to use for thresholds and measurements.
704
+
705
+ Returns
706
+ -------
707
+ RoadsInfographicModel
708
+ Pre-configured roads infographic model.
709
+ """
710
+ if unit_system == "metric":
711
+ config = RoadsInfographicModel(
712
+ thresholds=[0.1, 0.2, 0.4, 0.8],
713
+ unit="meters",
714
+ )
715
+ elif unit_system == "imperial":
716
+ config = RoadsInfographicModel(
717
+ thresholds=[0.3, 0.5, 1, 2],
718
+ unit="feet",
719
+ )
720
+ return config
721
+
722
+
723
+ class EventInfographicModel(BaseModel):
724
+ """
725
+ Model for event-based infographic configuration.
726
+
727
+ Parameters
728
+ ----------
729
+ buildings : Optional[BuildingsInfographicModel], default=None
730
+ Buildings infographic configuration.
731
+ svi : Optional[SviInfographicModel], default=None
732
+ SVI infographic configuration.
733
+ roads : Optional[RoadsInfographicModel], default=None
734
+ Roads infographic configuration.
735
+ """
736
+
737
+ buildings: Optional[BuildingsInfographicModel] = None
738
+ svi: Optional[HomesInfographicModel] = None
739
+ roads: Optional[RoadsInfographicModel] = None
740
+
741
+
742
+ class FloodExceedanceModel(BaseModel):
743
+ """
744
+ Model for flood exceedance probability configuration.
745
+
746
+ Parameters
747
+ ----------
748
+ column : str, default=_IMPACT_COLUMNS.inundation_depth
749
+ The database column name for flood depth measurements.
750
+ threshold : float, default=0.1
751
+ The flood depth threshold value.
752
+ unit : str, default="meters"
753
+ The unit of measurement for the threshold.
754
+ period : int, default=30
755
+ The time period in years for exceedance analysis.
756
+ """
757
+
758
+ column: str = _IMPACT_COLUMNS.inundation_depth
759
+ threshold: float = 0.1
760
+ unit: str = "meters"
761
+ period: int = 30
762
+
763
+
764
+ class RiskInfographicModel(BaseModel):
765
+ """
766
+ Model for risk-based infographic configuration.
767
+
768
+ Parameters
769
+ ----------
770
+ homes : HomesInfographicModel
771
+ Homes infographic configuration.
772
+ flood_exceedances : FloodExceedanceModel
773
+ Flood exceedance configuration.
774
+
775
+ Methods
776
+ -------
777
+ get_template(type, svi_threshold)
778
+ Get a pre-configured template for risk infographics.
779
+ """
780
+
781
+ homes: HomesInfographicModel
782
+ flood_exceedance: FloodExceedanceModel
783
+
784
+ @staticmethod
785
+ def get_template(
786
+ type: Literal["OSM", "NSI"], svi_threshold: Optional[float] = None
787
+ ):
788
+ """
789
+ Get a pre-configured template for risk infographics.
790
+
791
+ Parameters
792
+ ----------
793
+ type : Literal["OSM", "NSI"]
794
+ The database type to create a template for.
795
+ svi_threshold : Optional[float], default=None
796
+ The SVI threshold value for vulnerability classification.
797
+
798
+ Returns
799
+ -------
800
+ RiskInfographicModel
801
+ Pre-configured risk infographic model.
802
+ """
803
+ if type == "OSM":
804
+ config = RiskInfographicModel(
805
+ homes=HomesInfographicModel.get_template(
806
+ type="OSM", svi_threshold=svi_threshold
807
+ ),
808
+ flood_exceedance=FloodExceedanceModel(),
809
+ )
810
+ elif type == "NSI":
811
+ config = RiskInfographicModel(
812
+ homes=HomesInfographicModel.get_template(
813
+ type="NSI", svi_threshold=svi_threshold
814
+ ),
815
+ flood_exceedance=FloodExceedanceModel(unit="feet", threshold=0.2),
816
+ )
817
+ return config
818
+
819
+
820
+ def get_filter(
821
+ type_mapping: TypeMapping,
822
+ cat_field: str,
823
+ cat_idx: int,
824
+ bins: list[float],
825
+ base_filt="",
826
+ ) -> str:
827
+ """
828
+ Construct a SQL filter string based on provided type mapping and category criteria.
829
+
830
+ Parameters
831
+ ----------
832
+ type_mapping : TypeMapping
833
+ TypeMapping object containing field mappings to filter on.
834
+ cat_field : str
835
+ Name of the field representing the category in the database.
836
+ cat_idx : int
837
+ Index indicating which category bin to use for filtering.
838
+ bins : list[float]
839
+ List of bin thresholds for the category field.
840
+ base_filt : str, default=""
841
+ Additional base filter string to prepend.
842
+
843
+ Returns
844
+ -------
845
+ str
846
+ A SQL filter string combining type and category conditions.
847
+ """
848
+ # Build type filters using TypeMapping
849
+ type_filter = type_mapping.to_sql_filter()
850
+
851
+ # Add category filter
852
+ if cat_idx == 0:
853
+ cat_filter = f"`{cat_field}` <= {bins[0]}"
854
+ elif cat_idx == len(bins):
855
+ cat_filter = f"`{cat_field}` > {bins[-1]}"
856
+ else:
857
+ cat_filter = (
858
+ f"`{cat_field}` <= {bins[cat_idx]} AND `{cat_field}` > {bins[cat_idx-1]}"
859
+ )
860
+
861
+ return combine_filters(base_filt, type_filter, cat_filter)
862
+
863
+
864
+ class Metrics:
865
+ """Main class for managing impact metrics configuration and generation."""
866
+
867
+ def __init__(self, dmg_unit: str, return_periods: list[float]):
868
+ """
869
+ Initialize the Metrics class.
870
+
871
+ Parameters
872
+ ----------
873
+ dmg_unit : str
874
+ The unit of measurement for damage values.
875
+ return_periods : list[float]
876
+ List of return periods in years for risk analysis.
877
+ """
878
+ self.dmg_unit = dmg_unit
879
+ self.return_periods = return_periods
880
+
881
+ # Initialize all metric lists as empty instance attributes
882
+ self.mandatory_metrics_event: list[MetricModel] = []
883
+ self.mandatory_metrics_risk: list[MetricModel] = []
884
+ self.additional_metrics_event: list[MetricModel] = []
885
+ self.additional_metrics_risk: list[MetricModel] = []
886
+ self.infographics_metrics_event: list[MetricModel] = []
887
+ self.infographics_metrics_risk: list[MetricModel] = []
888
+ self.additional_risk_configs: dict = {}
889
+ self.infographics_config: dict = {}
890
+
891
+ @staticmethod
892
+ def write_metrics(metrics, path, aggr_levels=[]):
893
+ """
894
+ Write metrics configuration to a TOML file.
895
+
896
+ Parameters
897
+ ----------
898
+ metrics : list[MetricModel]
899
+ List of metric models to write.
900
+ path : Union[str, Path]
901
+ Path to the output TOML file.
902
+ aggr_levels : list[str], default=[]
903
+ List of aggregation levels.
904
+ """
905
+ attrs = {}
906
+ attrs["aggregateBy"] = aggr_levels
907
+ attrs["queries"] = [metric.model_dump() for metric in metrics]
908
+
909
+ # Save metrics configuration
910
+ with open(path, "wb") as f:
911
+ tomli_w.dump(attrs, f)
912
+
913
+ def write(
914
+ self,
915
+ metrics_path: Union[str, Path, PathLike],
916
+ aggregation_levels: List[str],
917
+ infographics_path: Optional[Union[str, Path, PathLike]] = None,
918
+ ) -> None:
919
+ """
920
+ Write all metrics (mandatory, additional, and infographics) to TOML files.
921
+
922
+ Parameters
923
+ ----------
924
+ metrics_path : Union[str, Path, PathLike]
925
+ The directory path where the metrics configuration files will be saved.
926
+ aggregation_levels : List[str]
927
+ A list of aggregation levels to include in the metrics configuration files.
928
+ infographics_path : Optional[Union[str, Path, PathLike]], default=None
929
+ The directory path where infographics configuration files will be saved.
930
+ Required if infographics configurations are present.
931
+
932
+ Raises
933
+ ------
934
+ ValueError
935
+ If infographics_path is None but infographics configurations exist.
936
+ """
937
+ path_im = Path(metrics_path)
938
+ path_im.mkdir(parents=True, exist_ok=True)
939
+
940
+ # Write mandatory event metrics
941
+ self.write_metrics(
942
+ self.mandatory_metrics_event,
943
+ path_im / "mandatory_metrics_config.toml",
944
+ aggregation_levels,
945
+ )
946
+
947
+ # Write mandatory risk metrics
948
+ if self.mandatory_metrics_risk:
949
+ self.write_metrics(
950
+ self.mandatory_metrics_risk,
951
+ path_im / "mandatory_metrics_config_risk.toml",
952
+ aggregation_levels,
953
+ )
954
+
955
+ # Write additional event metrics if any
956
+ if self.additional_metrics_event:
957
+ self.write_metrics(
958
+ self.additional_metrics_event,
959
+ path_im / "additional_metrics_config.toml",
960
+ aggregation_levels,
961
+ )
962
+
963
+ # Write additional risk metrics if any
964
+ if self.additional_metrics_risk:
965
+ self.write_metrics(
966
+ self.additional_metrics_risk,
967
+ path_im / "additional_metrics_config_risk.toml",
968
+ aggregation_levels,
969
+ )
970
+
971
+ # Write infographics event metrics if any
972
+ if (
973
+ hasattr(self, "infographics_metrics_event")
974
+ and self.infographics_metrics_event
975
+ ):
976
+ self.write_metrics(
977
+ self.infographics_metrics_event,
978
+ path_im / "infographic_metrics_config.toml",
979
+ aggregation_levels,
980
+ )
981
+
982
+ # Write infographics risk metrics if any
983
+ if (
984
+ hasattr(self, "infographics_metrics_risk")
985
+ and self.infographics_metrics_risk
986
+ ):
987
+ self.write_metrics(
988
+ self.infographics_metrics_risk,
989
+ path_im / "infographic_metrics_config_risk.toml",
990
+ aggregation_levels,
991
+ )
992
+
993
+ # Save additional risk configurations if available
994
+ if hasattr(self, "additional_risk_configs") and self.additional_risk_configs:
995
+ with open(path_im / "metrics_additional_risk_configs.toml", "wb") as f:
996
+ tomli_w.dump(self.additional_risk_configs, f)
997
+
998
+ # Save infographics configuration if available
999
+ if self.infographics_config:
1000
+ if infographics_path is None:
1001
+ raise ValueError(
1002
+ "infographics_path must be provided to save infographics configuration."
1003
+ )
1004
+ infographics_path = Path(infographics_path)
1005
+ infographics_path.mkdir(parents=True, exist_ok=True)
1006
+ if "buildings" in self.infographics_config:
1007
+ with open(infographics_path / "config_charts.toml", "wb") as f:
1008
+ tomli_w.dump(self.infographics_config["buildings"], f)
1009
+ if "svi" in self.infographics_config:
1010
+ with open(infographics_path / "config_people.toml", "wb") as f:
1011
+ tomli_w.dump(self.infographics_config["svi"], f)
1012
+ if "roads" in self.infographics_config:
1013
+ with open(infographics_path / "config_roads.toml", "wb") as f:
1014
+ tomli_w.dump(self.infographics_config["roads"], f)
1015
+ if "risk" in self.infographics_config:
1016
+ with open(infographics_path / "config_risk_charts.toml", "wb") as f:
1017
+ tomli_w.dump(self.infographics_config["risk"], f)
1018
+
1019
+ def create_mandatory_metrics_event(self) -> list[MetricModel]:
1020
+ """
1021
+ Create mandatory metrics for event analysis.
1022
+
1023
+ Returns
1024
+ -------
1025
+ list[MetricModel]
1026
+ List of mandatory event metrics.
1027
+ """
1028
+ self.mandatory_metrics_event.append(
1029
+ MetricModel(
1030
+ name="TotalDamageEvent",
1031
+ description="Total building damage",
1032
+ long_name=f"Total building damage ({self.dmg_unit})",
1033
+ select=f"SUM(`{_IMPACT_COLUMNS.total_damage}`)",
1034
+ filter="",
1035
+ show_in_metrics_table=True,
1036
+ )
1037
+ )
1038
+ return self.mandatory_metrics_event
1039
+
1040
+ def create_mandatory_metrics_risk(self) -> list[MetricModel]:
1041
+ """
1042
+ Create mandatory metrics for risk analysis.
1043
+
1044
+ Returns
1045
+ -------
1046
+ list[MetricModel]
1047
+ List of mandatory risk metrics.
1048
+ """
1049
+ self.mandatory_metrics_risk.append(
1050
+ MetricModel(
1051
+ name="ExpectedAnnualDamages",
1052
+ description="Expected annual damages",
1053
+ long_name=f"Expected annual damages ({self.dmg_unit})",
1054
+ select=f"SUM(`{_IMPACT_COLUMNS.risk_ead}`)",
1055
+ filter="",
1056
+ show_in_metrics_table=True,
1057
+ )
1058
+ )
1059
+ for rp in self.return_periods:
1060
+ self.mandatory_metrics_risk.append(
1061
+ MetricModel(
1062
+ name=f"TotalDamageRP{int(rp)}",
1063
+ description=f"Total damage with return period of {int(rp)} years",
1064
+ long_name=f"Total building damage - {int(rp)}Y ({self.dmg_unit})",
1065
+ select=f"SUM(`{_IMPACT_COLUMNS.total_damage_rp.format(years=int(rp))}`)",
1066
+ filter="",
1067
+ show_in_metrics_table=True,
1068
+ )
1069
+ )
1070
+
1071
+ return self.mandatory_metrics_risk
1072
+
1073
+ def add_event_metric(self, metric: MetricModel) -> None:
1074
+ """
1075
+ Add an additional event metric.
1076
+
1077
+ Parameters
1078
+ ----------
1079
+ metric : MetricModel
1080
+ The metric to add to the additional event metrics list.
1081
+
1082
+ Raises
1083
+ ------
1084
+ ValueError
1085
+ If a metric with the same name already exists.
1086
+ """
1087
+ if any(m.name == metric.name for m in self.additional_metrics_event):
1088
+ raise ValueError(f"Event metric with name '{metric.name}' already exists.")
1089
+ self.additional_metrics_event.append(metric)
1090
+
1091
+ def add_risk_metric(self, metric: MetricModel) -> None:
1092
+ """
1093
+ Add an additional risk metric.
1094
+
1095
+ Parameters
1096
+ ----------
1097
+ metric : MetricModel
1098
+ The metric to add to the additional risk metrics list.
1099
+
1100
+ Raises
1101
+ ------
1102
+ ValueError
1103
+ If a metric with the same name already exists.
1104
+ """
1105
+ if any(m.name == metric.name for m in self.additional_metrics_risk):
1106
+ raise ValueError(f"Risk metric with name '{metric.name}' already exists.")
1107
+ self.additional_metrics_risk.append(metric)
1108
+
1109
+ def create_infographics_metrics_event(
1110
+ self,
1111
+ config: EventInfographicModel,
1112
+ base_filt=f"`{_IMPACT_COLUMNS.total_damage}` > 0",
1113
+ ) -> list[MetricModel]:
1114
+ """
1115
+ Create infographic metrics for event analysis.
1116
+
1117
+ Parameters
1118
+ ----------
1119
+ config : EventInfographicModel
1120
+ Configuration for event infographics.
1121
+ base_filt : str, default="`Total Damage` > 0"
1122
+ Base SQL filter to apply to all metrics.
1123
+
1124
+ Returns
1125
+ -------
1126
+ list[MetricModel]
1127
+ List of infographic event metrics.
1128
+ """
1129
+ # Generate queries for all building types and categories
1130
+ if config.buildings:
1131
+ self._setup_buildings(config.buildings, base_filt)
1132
+ if config.svi:
1133
+ self._setup_svi(config.svi, base_filt)
1134
+ if config.roads:
1135
+ self._setup_roads(config.roads)
1136
+ return self.infographics_metrics_event
1137
+
1138
+ def create_infographics_metrics_risk(
1139
+ self,
1140
+ config: RiskInfographicModel,
1141
+ base_filt=f"`{_IMPACT_COLUMNS.risk_ead}` > 0",
1142
+ ) -> list[MetricModel]:
1143
+ """
1144
+ Create infographic metrics for risk analysis.
1145
+
1146
+ Parameters
1147
+ ----------
1148
+ config : RiskInfographicModel
1149
+ Configuration for risk infographics.
1150
+ base_filt : str, default="`Risk (EAD)` > 0"
1151
+ Base SQL filter to apply to all metrics.
1152
+
1153
+ Returns
1154
+ -------
1155
+ list[MetricModel]
1156
+ List of infographic risk metrics.
1157
+ """
1158
+ infographics_metrics_risk = []
1159
+
1160
+ # Get mapping from config.svi
1161
+ mapping = config.homes.mapping
1162
+
1163
+ # Build type filter string using TypeMapping
1164
+ type_cond = mapping.to_sql_filter()
1165
+
1166
+ # FloodedHomes (Exceedance Probability > 50)
1167
+ fe = config.flood_exceedance
1168
+ filter_str = combine_filters(
1169
+ "`Exceedance Probability` > 50", type_cond, base_filt
1170
+ )
1171
+ infographics_metrics_risk.append(
1172
+ MetricModel(
1173
+ name="LikelyFloodedHomes",
1174
+ description=f"Homes likely to flood ({fe.column} > {fe.threshold} {fe.unit}) in {fe.period} year period",
1175
+ select="COUNT(*)",
1176
+ filter=filter_str,
1177
+ long_name=f"Homes likely to flood in {fe.period}-year period (#)",
1178
+ show_in_metrics_table=True,
1179
+ )
1180
+ )
1181
+
1182
+ # ImpactedHomes for each RP - use class return periods and config SVI thresholds
1183
+ rps = self.return_periods
1184
+ if config.homes.svi is not None:
1185
+ svi_thresholds = config.homes.svi.thresholds
1186
+ svi_classes = config.homes.svi.classes
1187
+ else:
1188
+ svi_thresholds = []
1189
+ svi_classes = []
1190
+
1191
+ for rp in rps:
1192
+ # ImpactedHomes{RP} (all homes)
1193
+ filter_str = combine_filters(
1194
+ f"`{fe.column} ({int(rp)}Y)` >= {fe.threshold}", type_cond, base_filt
1195
+ )
1196
+ infographics_metrics_risk.append(
1197
+ MetricModel(
1198
+ name=f"ImpactedHomes{int(rp)}Y",
1199
+ description=f"Number of homes impacted ({fe.column} > {fe.threshold} {fe.unit}) in the {int(rp)}-year event",
1200
+ select="COUNT(*)",
1201
+ filter=filter_str,
1202
+ long_name=f"Flooded homes - RP{int(rp)} (#)",
1203
+ show_in_metrics_table=True,
1204
+ )
1205
+ )
1206
+
1207
+ # Create metrics for each SVI class
1208
+ for j, svi_class in enumerate(svi_classes):
1209
+ # Build SVI condition based on thresholds
1210
+ if j == 0:
1211
+ # First class: SVI < first_threshold
1212
+ svi_cond = f"`SVI` < {svi_thresholds[0]}"
1213
+ elif j == len(svi_classes) - 1:
1214
+ # Last class: SVI >= last_threshold
1215
+ svi_cond = f"`SVI` >= {svi_thresholds[-1]}"
1216
+ else:
1217
+ # Middle classes: previous_threshold <= SVI < current_threshold
1218
+ svi_cond = f"`SVI` >= {svi_thresholds[j-1]} AND `SVI` < {svi_thresholds[j]}"
1219
+
1220
+ # Clean class name for metric naming (remove spaces, special chars)
1221
+ clean_class_name = svi_class.replace(" ", "").replace("-", "")
1222
+
1223
+ filter_str = combine_filters(
1224
+ f"`{fe.column} ({int(rp)}Y)` >= {fe.threshold}",
1225
+ type_cond,
1226
+ svi_cond,
1227
+ base_filt,
1228
+ )
1229
+
1230
+ infographics_metrics_risk.append(
1231
+ MetricModel(
1232
+ name=f"ImpactedHomes{int(rp)}Y{clean_class_name}SVI",
1233
+ description=f"{svi_class} vulnerable homes impacted ({fe.column} > {fe.threshold} {fe.unit}) in the {int(rp)}-year event",
1234
+ select="COUNT(*)",
1235
+ filter=filter_str,
1236
+ long_name=f"Flooded homes with {svi_class} vulnerability - RP{int(rp)} (#)",
1237
+ show_in_metrics_table=True,
1238
+ )
1239
+ )
1240
+
1241
+ self.infographics_metrics_risk = infographics_metrics_risk
1242
+ self.additional_risk_configs = {
1243
+ "flood_exceedance": {
1244
+ "column": fe.column,
1245
+ "threshold": fe.threshold,
1246
+ "period": fe.period,
1247
+ }
1248
+ }
1249
+ self._make_infographics_config_risk(config)
1250
+ return self.infographics_metrics_risk
1251
+
1252
+ def _setup_buildings(self, config: BuildingsInfographicModel, base_filt) -> None:
1253
+ """
1254
+ Configure building metrics and configuration for infographics.
1255
+
1256
+ Parameters
1257
+ ----------
1258
+ config : BuildingsInfographicModel
1259
+ Building infographic configuration.
1260
+ base_filt : str
1261
+ Base SQL filter to apply.
1262
+ """
1263
+ # Generate queries for all building types and categories
1264
+ building_queries = []
1265
+ for btype in config.types:
1266
+ type_mapping = config.type_mapping.get(btype, TypeMapping())
1267
+ for i, cat in enumerate(config.impact_categories.categories):
1268
+ query_name = f"{pascal_case(btype)}{pascal_case(cat)}Count"
1269
+ desc = (
1270
+ f"Number of {btype.lower()} buildings with {cat.lower()} flooding"
1271
+ )
1272
+ long_name = f"{btype} with {cat.lower()} flooding (#)"
1273
+ filter_str = get_filter(
1274
+ type_mapping=type_mapping,
1275
+ cat_field=config.impact_categories.field,
1276
+ cat_idx=i,
1277
+ bins=config.impact_categories.bins,
1278
+ base_filt=base_filt,
1279
+ )
1280
+ building_queries.append(
1281
+ MetricModel(
1282
+ name=query_name,
1283
+ select="COUNT(*)",
1284
+ filter=filter_str,
1285
+ description=desc,
1286
+ long_name=long_name,
1287
+ )
1288
+ )
1289
+ self.infographics_metrics_event.extend(building_queries)
1290
+ self.infographics_config["buildings"] = (
1291
+ self._make_infographics_config_buildings(config)
1292
+ )
1293
+
1294
+ def _setup_svi(self, config: HomesInfographicModel, base_filt) -> None:
1295
+ """
1296
+ Configure SVI metrics and configuration for infographics.
1297
+
1298
+ Parameters
1299
+ ----------
1300
+ config : SviInfographicModel
1301
+ SVI infographic configuration.
1302
+ base_filt : str
1303
+ Base SQL filter to apply.
1304
+ """
1305
+ # Generate queries for all SVI categories and vulnerability levels
1306
+ svi_queries = []
1307
+ cat_field = config.impact_categories.field
1308
+ bins = config.impact_categories.bins
1309
+ if config.svi is not None:
1310
+ svi_thresholds = config.svi.thresholds
1311
+ svi_classes = config.svi.classes
1312
+ else:
1313
+ svi_thresholds = []
1314
+ svi_classes = []
1315
+ mapping = config.mapping
1316
+
1317
+ for i, cat in enumerate(config.impact_categories.categories):
1318
+ for j, svi_class in enumerate(svi_classes):
1319
+ # Build SVI condition based on thresholds
1320
+ if j == 0:
1321
+ # First class: SVI < first_threshold
1322
+ svi_cond = f"`SVI` < {svi_thresholds[0]}"
1323
+ elif j == len(svi_classes) - 1:
1324
+ # Last class: SVI >= last_threshold
1325
+ svi_cond = f"`SVI` >= {svi_thresholds[-1]}"
1326
+ else:
1327
+ # Middle classes: previous_threshold <= SVI < current_threshold
1328
+ svi_cond = f"`SVI` >= {svi_thresholds[j-1]} AND `SVI` < {svi_thresholds[j]}"
1329
+
1330
+ # Build impact category condition based on bins
1331
+ if i == 0:
1332
+ # First category: field <= first_bin
1333
+ cat_cond = f"`{cat_field}` <= {bins[0]}"
1334
+ elif i == len(config.impact_categories.categories) - 1:
1335
+ # Last category: field > last_bin
1336
+ cat_cond = f"`{cat_field}` > {bins[-1]}"
1337
+ else:
1338
+ # Middle categories: previous_bin < field <= current_bin
1339
+ cat_cond = (
1340
+ f"`{cat_field}` > {bins[i-1]} AND `{cat_field}` <= {bins[i]}"
1341
+ )
1342
+
1343
+ # Build type filter using TypeMapping
1344
+ type_cond = mapping.to_sql_filter()
1345
+
1346
+ filter_str = combine_filters(type_cond, cat_cond, svi_cond, base_filt)
1347
+
1348
+ name = f"{cat}{svi_class.replace(' ', '')}Vulnerability"
1349
+ desc = f"Number of {cat.lower()} homes with {svi_class.lower()} vulnerability"
1350
+ long_name = f"{cat} Homes - {svi_class} Vulnerability (#)"
1351
+
1352
+ svi_queries.append(
1353
+ MetricModel(
1354
+ name=name,
1355
+ select="COUNT(*)",
1356
+ filter=filter_str,
1357
+ description=desc,
1358
+ long_name=long_name,
1359
+ show_in_metrics_table=True,
1360
+ )
1361
+ )
1362
+ self.infographics_metrics_event.extend(svi_queries)
1363
+ self.infographics_config["svi"] = self._make_infographics_config_svi(config)
1364
+
1365
+ def _setup_roads(self, config: RoadsInfographicModel) -> None:
1366
+ """
1367
+ Configure roads metrics and configuration for infographics.
1368
+
1369
+ Parameters
1370
+ ----------
1371
+ config : RoadsInfographicModel
1372
+ Roads infographic configuration.
1373
+ """
1374
+ # Generate queries for all road categories
1375
+ road_queries = []
1376
+ cat_field = config.field
1377
+ thresholds = config.thresholds
1378
+ road_length_field = config.road_length_field
1379
+
1380
+ if config.unit == "meters":
1381
+ unit_conversion = 1 / 1000
1382
+ unit = "Kilometers"
1383
+ elif config.unit == "feet":
1384
+ unit_conversion = 1 / 5280
1385
+ unit = "Miles"
1386
+
1387
+ for i, cat in enumerate(config.categories):
1388
+ name = f"{pascal_case(cat)}FloodedRoadsLength"
1389
+ desc = f"{unit} of roads disrupted for {config.users[i].lower()}"
1390
+ long_name = f"Length of roads with {cat.lower()} flooding ({unit})"
1391
+ select = f"SUM(`{road_length_field}`)*{unit_conversion}"
1392
+ filter_str = f"`{cat_field}` >= {thresholds[i]}"
1393
+ road_queries.append(
1394
+ MetricModel(
1395
+ name=name,
1396
+ description=desc,
1397
+ long_name=long_name,
1398
+ select=select,
1399
+ filter=filter_str,
1400
+ show_in_metrics_table=True,
1401
+ )
1402
+ )
1403
+ self.infographics_metrics_event.extend(road_queries)
1404
+ self.infographics_config["roads"] = self._make_infographics_config_roads(config)
1405
+
1406
+ @staticmethod
1407
+ def _make_infographics_config_buildings(
1408
+ buildings_config: BuildingsInfographicModel,
1409
+ ) -> Dict[str, Any]:
1410
+ """
1411
+ Create infographics configuration dictionary for buildings.
1412
+
1413
+ Parameters
1414
+ ----------
1415
+ buildings_config : BuildingsInfographicModel
1416
+ Building infographic configuration.
1417
+
1418
+ Returns
1419
+ -------
1420
+ Dict[str, Any]
1421
+ Configuration dictionary for building infographics.
1422
+ """
1423
+ image_path = "{image_path}"
1424
+ # Default plot configuration, matching your existing template:
1425
+ # Dynamically generate the Info text based on the number of categories and bins
1426
+ info_lines = [f"{buildings_config.impact_categories.field}:<br>"]
1427
+ for idx, cat in enumerate(buildings_config.impact_categories.categories):
1428
+ if idx < len(buildings_config.impact_categories.bins):
1429
+ info_lines.append(
1430
+ f" {cat}: <={buildings_config.impact_categories.bins[idx]} {buildings_config.impact_categories.unit}<br>"
1431
+ )
1432
+ else:
1433
+ # Last category: greater than last bin
1434
+ info_lines.append(
1435
+ f" {cat}: >{buildings_config.impact_categories.bins[-1]} {buildings_config.impact_categories.unit}<br>"
1436
+ )
1437
+
1438
+ other_config: Dict[str, Any] = {
1439
+ "Plot": {
1440
+ "image_scale": 0.15,
1441
+ "numbers_font": 20,
1442
+ "height": 350,
1443
+ "width": 1200,
1444
+ },
1445
+ "Title": {"text": "Building Impacts", "font": 30},
1446
+ "Subtitle": {"font": 25},
1447
+ "Legend": {"font": 20},
1448
+ "Info": {
1449
+ "text": "".join(info_lines),
1450
+ "image": "https://openclipart.org/image/800px/302413",
1451
+ "scale": 0.1,
1452
+ },
1453
+ }
1454
+
1455
+ cfg: Dict[str, Any] = {
1456
+ "Charts": {},
1457
+ "Categories": {},
1458
+ "Slices": {},
1459
+ "Other": other_config,
1460
+ }
1461
+
1462
+ # Categories block - keys have no special characters, Names are original
1463
+ if buildings_config.impact_categories.colors is not None:
1464
+ for k, cat in enumerate(buildings_config.impact_categories.categories):
1465
+ clean_cat_key = pascal_case(cat.replace(" ", "").replace("-", ""))
1466
+ cfg["Categories"][clean_cat_key] = {
1467
+ "Name": cat, # Original name
1468
+ "Color": buildings_config.impact_categories.colors[k],
1469
+ }
1470
+
1471
+ # Charts block - keys have no special characters, Names are original
1472
+ for i, btype in enumerate(buildings_config.types):
1473
+ clean_btype_key = pascal_case(btype.replace(" ", "").replace("-", ""))
1474
+ cfg["Charts"][clean_btype_key] = {
1475
+ "Name": btype, # Original name
1476
+ "Image": f"{image_path}/{buildings_config.icons[i]}.png",
1477
+ }
1478
+
1479
+ # Slices block - reference Chart and Category Names (not keys)
1480
+ for i, btype in enumerate(buildings_config.types):
1481
+ for cat in buildings_config.impact_categories.categories:
1482
+ clean_cat_key = pascal_case(cat.replace(" ", "").replace("-", ""))
1483
+ clean_btype_key = pascal_case(btype.replace(" ", "").replace("-", ""))
1484
+ slice_key = f"{clean_cat_key}{clean_btype_key}"
1485
+ cfg["Slices"][slice_key] = {
1486
+ "Name": f"{cat} {btype}",
1487
+ "Query": f"{pascal_case(btype)}{pascal_case(cat)}Count",
1488
+ "Chart": btype, # Reference Chart Name, not key
1489
+ "Category": cat, # Reference Category Name, not key
1490
+ }
1491
+
1492
+ return cfg
1493
+
1494
+ @staticmethod
1495
+ def _make_infographics_config_svi(
1496
+ svi_config: HomesInfographicModel,
1497
+ ) -> Dict[str, Any]:
1498
+ """
1499
+ Create infographics configuration dictionary for SVI.
1500
+
1501
+ Parameters
1502
+ ----------
1503
+ svi_config : SviInfographicModel
1504
+ SVI infographic configuration.
1505
+
1506
+ Returns
1507
+ -------
1508
+ Dict[str, Any]
1509
+ Configuration dictionary for SVI infographics.
1510
+ """
1511
+ image_path = "{image_path}"
1512
+ svi_classes = svi_config.svi.classes if svi_config.svi is not None else []
1513
+ charts = {}
1514
+ slices = {}
1515
+ categories_cfg = {}
1516
+
1517
+ # Charts block - keys have no special characters, Names are original
1518
+ for cat in svi_config.impact_categories.categories:
1519
+ clean_cat_key = pascal_case(cat.replace(" ", "").replace("-", ""))
1520
+ charts[clean_cat_key] = {
1521
+ "Name": cat, # Original name
1522
+ "Image": f"{image_path}/house.png",
1523
+ }
1524
+
1525
+ # Categories block - keys have no special characters, Names are original
1526
+ for idx, svi_class in enumerate(svi_classes):
1527
+ color = (
1528
+ svi_config.svi.colors[idx] if svi_config.svi is not None else "#cccccc"
1529
+ )
1530
+ clean_svi_key = (
1531
+ pascal_case(svi_class.replace(" ", "").replace("-", ""))
1532
+ + "Vulnerability"
1533
+ )
1534
+ categories_cfg[clean_svi_key] = {
1535
+ "Name": f"{svi_class} Vulnerability", # Original name with "Vulnerability"
1536
+ "Color": color,
1537
+ }
1538
+
1539
+ # Slices block - reference Chart and Category Names (not keys)
1540
+ for cat in svi_config.impact_categories.categories:
1541
+ for svi_class in svi_classes:
1542
+ clean_cat_key = pascal_case(cat.replace(" ", "").replace("-", ""))
1543
+ clean_svi_key = (
1544
+ pascal_case(svi_class.replace(" ", "").replace("-", ""))
1545
+ + "Vulnerability"
1546
+ )
1547
+ slice_key = f"{clean_cat_key}{clean_svi_key}"
1548
+ name = f"{cat} {svi_class.lower()} vulnerability homes"
1549
+ query = (
1550
+ f"{cat}{svi_class.replace(' ', '').replace('-', '')}Vulnerability"
1551
+ )
1552
+ slices[slice_key] = {
1553
+ "Name": name,
1554
+ "Query": query,
1555
+ "Chart": cat, # Reference Chart Name, not key
1556
+ "Category": f"{svi_class} Vulnerability", # Reference Category Name, not key
1557
+ }
1558
+
1559
+ # Info text - dynamically build threshold information
1560
+ bins = svi_config.impact_categories.bins
1561
+ unit = svi_config.impact_categories.unit
1562
+ thresholds = svi_config.svi.thresholds if svi_config.svi is not None else []
1563
+
1564
+ info_lines = [
1565
+ "Thresholds:<br>",
1566
+ ]
1567
+
1568
+ # Add impact category threshold information
1569
+ for i, cat in enumerate(svi_config.impact_categories.categories):
1570
+ if i == 0:
1571
+ info_lines.append(f" {cat}: <= {bins[0]} {unit}<br>")
1572
+ elif i == len(svi_config.impact_categories.categories) - 1:
1573
+ info_lines.append(f" {cat}: > {bins[-1]} {unit}<br>")
1574
+ else:
1575
+ info_lines.append(f" {cat}: {bins[i-1]} - {bins[i]} {unit}<br>")
1576
+
1577
+ info_lines.append("<br>SVI Classes:<br>")
1578
+
1579
+ # Add SVI threshold information
1580
+ for i, svi_class in enumerate(svi_classes):
1581
+ if i == 0:
1582
+ info_lines.append(f" {svi_class}: < {thresholds[0]}<br>")
1583
+ elif i == len(svi_classes) - 1:
1584
+ info_lines.append(f" {svi_class}: >= {thresholds[-1]}<br>")
1585
+ else:
1586
+ info_lines.append(
1587
+ f" {svi_class}: {thresholds[i-1]} - {thresholds[i]}<br>"
1588
+ )
1589
+
1590
+ info_lines.extend(
1591
+ [
1592
+ "'Since some homes do not have an SVI,<br>",
1593
+ "total number of homes might be different <br>",
1594
+ "between this and the above graph.'",
1595
+ ]
1596
+ )
1597
+
1598
+ other_config = {
1599
+ "Plot": {
1600
+ "image_scale": 0.15,
1601
+ "numbers_font": 20,
1602
+ "height": 350,
1603
+ "width": 600,
1604
+ },
1605
+ "Title": {"text": "Impacted Homes", "font": 30},
1606
+ "Subtitle": {"font": 25},
1607
+ "Legend": {"font": 20},
1608
+ "Info": {
1609
+ "text": "".join(info_lines),
1610
+ "image": "https://openclipart.org/image/800px/302413",
1611
+ "scale": 0.1,
1612
+ },
1613
+ }
1614
+ cfg = {
1615
+ "Charts": charts,
1616
+ "Categories": categories_cfg,
1617
+ "Slices": slices,
1618
+ "Other": other_config,
1619
+ }
1620
+ return cfg
1621
+
1622
+ @staticmethod
1623
+ def _make_infographics_config_roads(
1624
+ roads_config: RoadsInfographicModel,
1625
+ ) -> Dict[str, Any]:
1626
+ """
1627
+ Create infographics configuration dictionary for roads.
1628
+
1629
+ Parameters
1630
+ ----------
1631
+ roads_config : RoadsInfographicModel
1632
+ Roads infographic configuration.
1633
+
1634
+ Returns
1635
+ -------
1636
+ Dict[str, Any]
1637
+ Configuration dictionary for roads infographics.
1638
+ """
1639
+ image_path = "{image_path}"
1640
+
1641
+ # Charts block - key has no special characters, Name is original
1642
+ chart_key = "FloodedRoads"
1643
+ chart_name = "Flooded Roads"
1644
+ charts = {chart_key: {"Name": chart_name}}
1645
+
1646
+ # Categories block - keys have no special characters, Names are original
1647
+ categories_cfg = {}
1648
+ for idx, cat in enumerate(roads_config.categories):
1649
+ clean_cat_key = (
1650
+ pascal_case(cat.replace(" ", "").replace("-", "")) + "Flooding"
1651
+ )
1652
+ cat_name = f"{cat} Flooding"
1653
+ categories_cfg[clean_cat_key] = {
1654
+ "Name": cat_name, # Original name with "Flooding"
1655
+ "Color": roads_config.colors[idx],
1656
+ "Image": f"{image_path}/{roads_config.icons[idx]}.png",
1657
+ }
1658
+
1659
+ # Slices block - reference Chart and Category Names (not keys)
1660
+ slices = {}
1661
+ for idx, cat in enumerate(roads_config.categories):
1662
+ clean_cat_key = (
1663
+ pascal_case(cat.replace(" ", "").replace("-", "")) + "Flooding"
1664
+ )
1665
+ cat_name = f"{cat} Flooding"
1666
+ query_name = f"{pascal_case(cat)}FloodedRoadsLength"
1667
+ slices[clean_cat_key] = {
1668
+ "Name": cat_name,
1669
+ "Query": query_name,
1670
+ "Chart": chart_name, # Reference Chart Name, not key
1671
+ "Category": cat_name, # Reference Category Name, not key
1672
+ }
1673
+
1674
+ # Info text
1675
+ thresholds = roads_config.thresholds
1676
+ # Consistent unit naming
1677
+ if roads_config.unit == "feet":
1678
+ unit = "Miles"
1679
+ else:
1680
+ unit = "Kilometers"
1681
+ info_lines = ["Thresholds:<br>"]
1682
+ for user, threshold in zip(roads_config.users, thresholds):
1683
+ info_lines.append(f" {user}: {threshold} {roads_config.unit}<br>")
1684
+ other_config = {
1685
+ "Plot": {
1686
+ "image_scale": 0.1,
1687
+ "numbers_font": 20,
1688
+ "height": 350,
1689
+ "width": 600,
1690
+ },
1691
+ "Title": {"text": "Interrupted roads", "font": 30},
1692
+ "Subtitle": {"font": 25},
1693
+ "Y_axis_title": {"text": unit},
1694
+ "Info": {
1695
+ "text": "".join(info_lines),
1696
+ "image": f"{image_path}/info.png",
1697
+ "scale": 0.1,
1698
+ },
1699
+ }
1700
+ cfg = {
1701
+ "Charts": charts,
1702
+ "Categories": categories_cfg,
1703
+ "Slices": slices,
1704
+ "Other": other_config,
1705
+ }
1706
+ return cfg
1707
+
1708
+ def _make_infographics_config_risk(self, risk_config: RiskInfographicModel) -> dict:
1709
+ """
1710
+ Create infographics configuration dictionary for risk infographics.
1711
+
1712
+ Parameters
1713
+ ----------
1714
+ risk_config : RiskInfographicModel
1715
+ Risk infographic configuration.
1716
+
1717
+ Returns
1718
+ -------
1719
+ dict
1720
+ Configuration dictionary for risk infographics.
1721
+ """
1722
+ image_path = "{image_path}"
1723
+ rps = self.return_periods
1724
+ homes = risk_config.homes
1725
+ svi = homes.svi if homes.svi is not None else None
1726
+
1727
+ # Charts block - keys have no special characters, Names are original
1728
+ charts = {}
1729
+ for rp in rps:
1730
+ chart_key = f"RP{int(rp)}Y"
1731
+ chart_name = f"{int(rp)}Y"
1732
+ charts[chart_key] = {
1733
+ "Name": chart_name, # Original name
1734
+ "Image": f"{image_path}/house.png",
1735
+ }
1736
+
1737
+ # Categories block - keys have no special characters, Names are original
1738
+ categories = {}
1739
+ if svi:
1740
+ for idx, svi_class in enumerate(svi.classes):
1741
+ cat_key = (
1742
+ pascal_case(svi_class.replace(" ", "").replace("-", ""))
1743
+ + "Vulnerability"
1744
+ )
1745
+ cat_name = f"{svi_class} Vulnerability"
1746
+ categories[cat_key] = {
1747
+ "Name": cat_name, # Original name with "Vulnerability"
1748
+ "Color": svi.colors[idx] if svi.colors else "#cccccc",
1749
+ }
1750
+ else:
1751
+ # Create a single "All Homes" category when no SVI data
1752
+ categories["AllHomes"] = {
1753
+ "Name": "All Homes",
1754
+ "Color": "#88A2AA", # Default color
1755
+ }
1756
+
1757
+ # Slices block - reference Chart and Category Names (not keys)
1758
+ slices = {}
1759
+ if svi:
1760
+ for rp in rps:
1761
+ chart_name = f"{int(rp)}Y"
1762
+ for idx, svi_class in enumerate(svi.classes):
1763
+ clean_svi = svi_class.replace(" ", "").replace("-", "")
1764
+ cat_name = f"{svi_class} Vulnerability"
1765
+ slice_key = f"{pascal_case(svi_class.replace(' ', '').replace('-', ''))}VulnerabilityRP{int(rp)}Y"
1766
+ name = f"{int(rp)}Y {svi_class} Vulnerability"
1767
+ query = f"ImpactedHomes{int(rp)}Y{clean_svi}SVI"
1768
+ slices[slice_key] = {
1769
+ "Name": name,
1770
+ "Query": query,
1771
+ "Chart": chart_name, # Reference Chart Name, not key
1772
+ "Category": cat_name, # Reference Category Name, not key
1773
+ }
1774
+ else:
1775
+ # Create slices for each return period without SVI breakdown
1776
+ for rp in rps:
1777
+ chart_name = f"{int(rp)}Y"
1778
+ slice_key = f"AllHomesRP{int(rp)}Y"
1779
+ name = f"{int(rp)}Y All Homes"
1780
+ query = f"ImpactedHomes{int(rp)}Y"
1781
+ slices[slice_key] = {
1782
+ "Name": name,
1783
+ "Query": query,
1784
+ "Chart": chart_name, # Reference Chart Name, not key
1785
+ "Category": "All Homes", # Reference Category Name, not key
1786
+ }
1787
+
1788
+ # Other block: static info, but use config where possible
1789
+ other = {
1790
+ "Expected_Damages": {
1791
+ "title": "Expected annual damages",
1792
+ "query": "ExpectedAnnualDamages",
1793
+ "image": f"{image_path}/money.png",
1794
+ "image_scale": 1.3,
1795
+ "title_font_size": 25,
1796
+ "numbers_font_size": 20,
1797
+ "height": 300,
1798
+ },
1799
+ "Flooded": {
1800
+ "title": f"Number of homes with a high chance of being flooded in a {risk_config.flood_exceedance.period}-year period",
1801
+ "query": "LikelyFloodedHomes",
1802
+ "image": f"{image_path}/house.png",
1803
+ "image_scale": 0.7,
1804
+ "title_font_size": 25,
1805
+ "numbers_font_size": 20,
1806
+ "height": 300,
1807
+ },
1808
+ "Return_Periods": {
1809
+ "title": "Buildings impacted",
1810
+ "font_size": 25,
1811
+ "image_scale": 0.2,
1812
+ "numbers_font": 15,
1813
+ "subtitle_font": 22,
1814
+ "legend_font": 20,
1815
+ "plot_height": 300,
1816
+ },
1817
+ "Info": {
1818
+ "text": (
1819
+ "Thresholds:<br>"
1820
+ f" Impacted: >= {risk_config.flood_exceedance.threshold} {risk_config.flood_exceedance.unit}<br>"
1821
+ ),
1822
+ "image": "https://openclipart.org/image/800px/302413",
1823
+ "scale": 0.1,
1824
+ },
1825
+ }
1826
+
1827
+ cfg = {
1828
+ "Charts": charts,
1829
+ "Categories": categories,
1830
+ "Slices": slices,
1831
+ "Other": other,
1832
+ }
1833
+ self.infographics_config["risk"] = cfg
1834
+ return cfg