canruso commited on
Commit
0ebb5d6
·
1 Parent(s): 14506d5

Merge dashboard fixes with updated tier definitions

Browse files

- Real strategy x hydroclimate matrix from actual scenarios only (removes synthetic data and phantom s0036)
- Continuous LOI tiers generated by MergedMetrics; drill-down reconciles with headline tiers
- Radar renders tier values > 4 at true depth (unlabeled tier-5 ring); same fix in distribution PNG
- varmatch exact column matching for app.py find_col; v7 scenario listing
- Keeps Jul 9 tier definitions/descriptions, updated variable set, and ContinuousTiers toggle
- Single flat commit: repo history was squashed after hitting the 1GB LFS storage limit

Files changed (6) hide show
  1. app.py +4 -8
  2. data/loi_mapping.json +65 -81
  3. plines.py +3 -6
  4. radar.py +727 -726
  5. resilience.py +835 -867
  6. varmatch.py +164 -0
app.py CHANGED
@@ -5,6 +5,7 @@ import pandas as pd
5
  import plotly.graph_objs as go
6
  import dash
7
  from dash import dcc, html
 
8
  from dash.dependencies import Input, Output, State
9
  from dash.exceptions import PreventUpdate
10
  from dash import callback_context
@@ -148,15 +149,10 @@ plot_type_descriptions = {
148
 
149
  # --- Helper Functions ---
150
  def find_col(df_in, var_unit, scenario):
 
 
151
  var, unit = var_unit.split("__")
152
- suffix = f"_{unit.lower()}"
153
- matches = [
154
- c for c in df_in.columns
155
- if var in str(c)
156
- and scenario in str(c)
157
- and str(c).lower().endswith(suffix)
158
- ]
159
- return matches[0] if matches else None
160
 
161
  def find_wyt_col(df_in, scenario):
162
  matches = [c for c in df_in.columns if f"CALSIM_WYT_SAC__{scenario}" in str(c) and "WATERYEARTYPE" in str(c)]
 
5
  import plotly.graph_objs as go
6
  import dash
7
  from dash import dcc, html
8
+ import varmatch # vendored byte-for-byte from coeqwalpackage.varmatch; single source of truth for column matching
9
  from dash.dependencies import Input, Output, State
10
  from dash.exceptions import PreventUpdate
11
  from dash import callback_context
 
149
 
150
  # --- Helper Functions ---
151
  def find_col(df_in, var_unit, scenario):
152
+ # Exact, parse-based match via varmatch (no substring over-match; raises on a true
153
+ # ambiguous match rather than silently returning the first column).
154
  var, unit = var_unit.split("__")
155
+ return varmatch.col_for(df_in.columns, var, scenario, unit)
 
 
 
 
 
 
 
156
 
157
  def find_wyt_col(df_in, scenario):
158
  matches = [c for c in df_in.columns if f"CALSIM_WYT_SAC__{scenario}" in str(c) and "WATERYEARTYPE" in str(c)]
data/loi_mapping.json CHANGED
@@ -173,37 +173,37 @@
173
  ],
174
  "NOD_Reservoir_Mean": [
175
  {
176
- "loi": "S_SHSTA_Storage_Tier",
177
  "region": "NOD"
178
  },
179
  {
180
- "loi": "S_TRNTY_Storage_Tier",
181
  "region": "NOD"
182
  },
183
  {
184
- "loi": "S_OROVL_Storage_Tier",
185
  "region": "NOD"
186
  },
187
  {
188
- "loi": "S_FOLSM_Storage_Tier",
189
  "region": "NOD"
190
  }
191
  ],
192
  "SOD_Reservoir_Mean": [
193
  {
194
- "loi": "S_MELON_Storage_Tier",
195
  "region": "SOD"
196
  },
197
  {
198
- "loi": "S_MLRTN_Storage_Tier",
199
  "region": "SOD"
200
  },
201
  {
202
- "loi": "S_SLUIS_CVP_Storage_Tier",
203
  "region": "SOD"
204
  },
205
  {
206
- "loi": "S_SLUIS_SWP_Storage_Tier",
207
  "region": "SOD"
208
  }
209
  ],
@@ -828,10 +828,6 @@
828
  "loi": "26S_NU1",
829
  "region": "NOD"
830
  },
831
- {
832
- "loi": "26S_NU3",
833
- "region": "NOD"
834
- },
835
  {
836
  "loi": "26S_PU1",
837
  "region": "NOD"
@@ -849,7 +845,19 @@
849
  "region": "NOD"
850
  },
851
  {
852
- "loi": "26S_PU99",
 
 
 
 
 
 
 
 
 
 
 
 
853
  "region": "NOD"
854
  },
855
  {
@@ -857,7 +865,11 @@
857
  "region": "NOD"
858
  },
859
  {
860
- "loi": "ELDID_NA1",
 
 
 
 
861
  "region": "NOD"
862
  },
863
  {
@@ -872,6 +884,22 @@
872
  "loi": "ELDID_NU3",
873
  "region": "NOD"
874
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
875
  {
876
  "loi": "NAPA",
877
  "region": "NOD"
@@ -893,45 +921,37 @@
893
  "region": "NOD"
894
  },
895
  {
896
- "loi": "UNION",
897
  "region": "NOD"
898
- }
899
- ],
900
- "SOD_DW_Mean": [
901
- {
902
- "loi": "50_PU",
903
- "region": "SOD"
904
  },
905
  {
906
- "loi": "60N_NU1",
907
- "region": "SOD"
908
- },
909
- {
910
- "loi": "60N_NU2",
911
- "region": "SOD"
912
  },
913
  {
914
- "loi": "60S_NU1",
915
- "region": "SOD"
916
  },
917
  {
918
- "loi": "61_NU2",
919
- "region": "SOD"
920
  },
921
  {
922
- "loi": "62_NU",
923
- "region": "SOD"
924
  },
925
  {
926
- "loi": "71_NU",
927
- "region": "SOD"
928
- },
 
 
929
  {
930
- "loi": "72_NU",
931
  "region": "SOD"
932
  },
933
  {
934
- "loi": "72_PU",
935
  "region": "SOD"
936
  },
937
  {
@@ -942,18 +962,10 @@
942
  "loi": "ACFC",
943
  "region": "SOD"
944
  },
945
- {
946
- "loi": "AMADR",
947
- "region": "SOD"
948
- },
949
  {
950
  "loi": "ANTOC",
951
  "region": "SOD"
952
  },
953
- {
954
- "loi": "BNCIA",
955
- "region": "SOD"
956
- },
957
  {
958
  "loi": "CCWD",
959
  "region": "SOD"
@@ -967,11 +979,7 @@
967
  "region": "SOD"
968
  },
969
  {
970
- "loi": "CSPSO",
971
- "region": "SOD"
972
- },
973
- {
974
- "loi": "CSTIC",
975
  "region": "SOD"
976
  },
977
  {
@@ -983,11 +991,11 @@
983
  "region": "SOD"
984
  },
985
  {
986
- "loi": "ESB414",
987
  "region": "SOD"
988
  },
989
  {
990
- "loi": "ESB415",
991
  "region": "SOD"
992
  },
993
  {
@@ -998,18 +1006,6 @@
998
  "loi": "FRFLD",
999
  "region": "SOD"
1000
  },
1001
- {
1002
- "loi": "GDPUD_NU",
1003
- "region": "SOD"
1004
- },
1005
- {
1006
- "loi": "GRSVL",
1007
- "region": "SOD"
1008
- },
1009
- {
1010
- "loi": "JLIND",
1011
- "region": "SOD"
1012
- },
1013
  {
1014
  "loi": "KCWA",
1015
  "region": "SOD"
@@ -1027,31 +1023,19 @@
1027
  "region": "SOD"
1028
  },
1029
  {
1030
- "loi": "SCVWD",
1031
- "region": "SOD"
1032
- },
1033
- {
1034
- "loi": "SVWRD",
1035
- "region": "SOD"
1036
- },
1037
- {
1038
- "loi": "TLMNE",
1039
- "region": "SOD"
1040
- },
1041
- {
1042
- "loi": "TVAFB",
1043
  "region": "SOD"
1044
  },
1045
  {
1046
- "loi": "UPANG",
1047
  "region": "SOD"
1048
  },
1049
  {
1050
- "loi": "VLLJO",
1051
  "region": "SOD"
1052
  },
1053
  {
1054
- "loi": "WLDWD",
1055
  "region": "SOD"
1056
  }
1057
  ],
 
173
  ],
174
  "NOD_Reservoir_Mean": [
175
  {
176
+ "loi": "S_SHSTA",
177
  "region": "NOD"
178
  },
179
  {
180
+ "loi": "S_TRNTY",
181
  "region": "NOD"
182
  },
183
  {
184
+ "loi": "S_OROVL",
185
  "region": "NOD"
186
  },
187
  {
188
+ "loi": "S_FOLSM",
189
  "region": "NOD"
190
  }
191
  ],
192
  "SOD_Reservoir_Mean": [
193
  {
194
+ "loi": "S_MELON",
195
  "region": "SOD"
196
  },
197
  {
198
+ "loi": "S_MLRTN",
199
  "region": "SOD"
200
  },
201
  {
202
+ "loi": "S_SLUIS_CVP",
203
  "region": "SOD"
204
  },
205
  {
206
+ "loi": "S_SLUIS_SWP",
207
  "region": "SOD"
208
  }
209
  ],
 
828
  "loi": "26S_NU1",
829
  "region": "NOD"
830
  },
 
 
 
 
831
  {
832
  "loi": "26S_PU1",
833
  "region": "NOD"
 
845
  "region": "NOD"
846
  },
847
  {
848
+ "loi": "26S_PU6",
849
+ "region": "NOD"
850
+ },
851
+ {
852
+ "loi": "50_PU",
853
+ "region": "NOD"
854
+ },
855
+ {
856
+ "loi": "60N_NU2",
857
+ "region": "NOD"
858
+ },
859
+ {
860
+ "loi": "AMADR",
861
  "region": "NOD"
862
  },
863
  {
 
865
  "region": "NOD"
866
  },
867
  {
868
+ "loi": "BNCIA",
869
+ "region": "NOD"
870
+ },
871
+ {
872
+ "loi": "CSPSO",
873
  "region": "NOD"
874
  },
875
  {
 
884
  "loi": "ELDID_NU3",
885
  "region": "NOD"
886
  },
887
+ {
888
+ "loi": "GDPUD_NU",
889
+ "region": "NOD"
890
+ },
891
+ {
892
+ "loi": "GRSVL",
893
+ "region": "NOD"
894
+ },
895
+ {
896
+ "loi": "JLIND",
897
+ "region": "NOD"
898
+ },
899
+ {
900
+ "loi": "MHILL_NU",
901
+ "region": "NOD"
902
+ },
903
  {
904
  "loi": "NAPA",
905
  "region": "NOD"
 
921
  "region": "NOD"
922
  },
923
  {
924
+ "loi": "TLMNE",
925
  "region": "NOD"
 
 
 
 
 
 
926
  },
927
  {
928
+ "loi": "TVAFB",
929
+ "region": "NOD"
 
 
 
 
930
  },
931
  {
932
+ "loi": "UNION",
933
+ "region": "NOD"
934
  },
935
  {
936
+ "loi": "UPANG",
937
+ "region": "NOD"
938
  },
939
  {
940
+ "loi": "VLLJO",
941
+ "region": "NOD"
942
  },
943
  {
944
+ "loi": "WLDWD",
945
+ "region": "NOD"
946
+ }
947
+ ],
948
+ "SOD_DW_Mean": [
949
  {
950
+ "loi": "60S_NU1",
951
  "region": "SOD"
952
  },
953
  {
954
+ "loi": "61_NU2",
955
  "region": "SOD"
956
  },
957
  {
 
962
  "loi": "ACFC",
963
  "region": "SOD"
964
  },
 
 
 
 
965
  {
966
  "loi": "ANTOC",
967
  "region": "SOD"
968
  },
 
 
 
 
969
  {
970
  "loi": "CCWD",
971
  "region": "SOD"
 
979
  "region": "SOD"
980
  },
981
  {
982
+ "loi": "EBMUD",
 
 
 
 
983
  "region": "SOD"
984
  },
985
  {
 
991
  "region": "SOD"
992
  },
993
  {
994
+ "loi": "ESB355",
995
  "region": "SOD"
996
  },
997
  {
998
+ "loi": "ESB414",
999
  "region": "SOD"
1000
  },
1001
  {
 
1006
  "loi": "FRFLD",
1007
  "region": "SOD"
1008
  },
 
 
 
 
 
 
 
 
 
 
 
 
1009
  {
1010
  "loi": "KCWA",
1011
  "region": "SOD"
 
1023
  "region": "SOD"
1024
  },
1025
  {
1026
+ "loi": "SBCWD",
 
 
 
 
 
 
 
 
 
 
 
 
1027
  "region": "SOD"
1028
  },
1029
  {
1030
+ "loi": "SCVWD",
1031
  "region": "SOD"
1032
  },
1033
  {
1034
+ "loi": "SVWRD",
1035
  "region": "SOD"
1036
  },
1037
  {
1038
+ "loi": "WSB032",
1039
  "region": "SOD"
1040
  }
1041
  ],
plines.py CHANGED
@@ -43,12 +43,9 @@ for _, _row in _tier_desc_df.iterrows():
43
 
44
  # --- Scenario Descriptions ---
45
  try:
46
- _df_scen = pd.read_csv("data/coeqwal_cs3_scenario_listing_v5.csv", encoding="utf-8")
47
- except Exception:
48
- try:
49
- _df_scen = pd.read_csv("data/coeqwal_cs3_scenario_listing_v5.csv", encoding="latin1")
50
- except Exception:
51
- _df_scen = pd.DataFrame()
52
 
53
  # --- Variable Descriptions ---
54
  try:
 
43
 
44
  # --- Scenario Descriptions ---
45
  try:
46
+ _df_scen = pd.read_csv("data/coeqwal_cs3_scenario_listing_v7.csv", encoding="utf-8")
47
+ except UnicodeDecodeError:
48
+ _df_scen = pd.read_csv("data/coeqwal_cs3_scenario_listing_v7.csv", encoding="latin1")
 
 
 
49
 
50
  # --- Variable Descriptions ---
51
  try:
radar.py CHANGED
@@ -1,726 +1,727 @@
1
- import io
2
- import base64
3
-
4
- import numpy as np
5
- import pandas as pd
6
- import plotly.graph_objs as go
7
- from dash import dcc, html, dash_table
8
- from dash.dash_table.Format import Format, Scheme
9
- from dash.dependencies import Input, Output, State
10
- from scipy.spatial import ConvexHull
11
-
12
- from theme import (
13
- SCENARIO_COLORS, BRAND_TEAL, TEXT_MUTED, FONT_STACK, GRID_COLOR,
14
- SECTION_HEADER, DESCRIPTION_STYLE, LABEL_STYLE, DANGER_RED, MODERN_CARD,
15
- PRIMARY_BUTTON, BORDER_LIGHT,
16
- )
17
- from downloads import download_buttons, register_plotly_png_callback, register_base64_png_callback
18
- # contibuous or discrete tiers
19
- ContinuousTiers = True
20
- if ContinuousTiers:
21
- tier_data = "data/tier_df_continuous.csv"
22
- else:
23
- tier_data = "data/tier_df.csv"
24
-
25
- # --- Tier Data (single pre-computed CSV from MergedMetrics.ipynb) ---
26
- tier_df = pd.read_csv(tier_data)
27
-
28
- ALL_TIER_COLS = [c for c in tier_df.columns if c != "scenario" and not c.endswith("_Std")]
29
-
30
- # --- Scenario Descriptions ---
31
- try:
32
- _df_scen = pd.read_csv("data/coeqwal_cs3_scenario_listing_v5.csv", encoding="utf-8")
33
- except Exception:
34
- try:
35
- _df_scen = pd.read_csv("data/coeqwal_cs3_scenario_listing_v5.csv", encoding="latin1")
36
- except Exception:
37
- _df_scen = pd.DataFrame()
38
- CORE_TIER_COLS = ["NOD_GW_Mean", "SOD_GW_Mean", "NOD_Reservoir_Mean", "SOD_Reservoir_Mean", "Exports_and_Salinity", "InDelta_Salinity"]
39
-
40
- RADAR_PRESETS = {
41
- "All (14)": ALL_TIER_COLS,
42
- }
43
-
44
- TIER_LABELS = {
45
- "NOD_GW_Mean": "NOD Groundwater", "SOD_GW_Mean": "SOD Groundwater",
46
- "NOD_Reservoir_Mean": "NOD Reservoir", "SOD_Reservoir_Mean": "SOD Reservoir",
47
- "Exports_and_Salinity": "Exports and Salinity", "InDelta_Salinity": "In-Delta Salinity",
48
- "NOD_Ag_Mean": "NOD Agriculture", "SOD_Ag_Mean": "SOD Agriculture",
49
- "NOD_DW_Mean": "NOD Drinking Water", "SOD_DW_Mean": "SOD Drinking Water",
50
- "NOD_Eflows_Mean": "NOD Env. Flows", "SOD_Eflows_Mean": "SOD Env. Flows",
51
- "Delta_Ecology": "Delta Ecology", "Salmon_Abundance": "Salmon Abundance",
52
- }
53
-
54
- RADAR_SCENARIOS = sorted(tier_df["scenario"].tolist())
55
-
56
- # --- Tier Descriptions (hover text from tiers_descriptions.csv) ---
57
- _tier_desc_df = pd.read_csv("data/tiers_descriptions.csv")
58
- _tier_desc_df.columns = _tier_desc_df.columns.str.strip()
59
- _tier_desc_df["Outcomes/ Tiers"] = _tier_desc_df["Outcomes/ Tiers"].str.strip()
60
-
61
- _COL_TO_DESC_KEY = {
62
- "NOD_GW_Mean": "NOD GW", "SOD_GW_Mean": "SOD GW",
63
- "NOD_Reservoir_Mean": "NOD Reservoir", "SOD_Reservoir_Mean": "SOD Reservoir",
64
- "Exports_and_Salinity": "Exports and Salinity", "InDelta_Salinity": "InDelta Salinity",
65
- "NOD_Ag_Mean": "NOD Ag", "SOD_Ag_Mean": "SOD Ag",
66
- "NOD_DW_Mean": "NOD DW", "SOD_DW_Mean": "SOD DW",
67
- "NOD_Eflows_Mean": "NOD Eflows", "SOD_Eflows_Mean": "SOD Eflows",
68
- "Delta_Ecology": "Delta Ecology", "Salmon_Abundance": "Salmon Abundance",
69
- }
70
-
71
- _AXIS_DESCRIPTIONS = {}
72
- _TIER_DEFINITIONS = {}
73
- for _, _row in _tier_desc_df.iterrows():
74
- _key = _row["Outcomes/ Tiers"]
75
- _AXIS_DESCRIPTIONS[_key] = str(_row["Description"]).strip()
76
- _TIER_DEFINITIONS[_key] = {1: str(_row["Tier1"]).strip(), 2: str(_row["Tier2"]).strip(), 3: str(_row["Tier3"]).strip(), 4: str(_row["Tier4"]).strip()}
77
-
78
-
79
- def _wrap_hover(text, width=60):
80
- """Wrap text for hover tooltips."""
81
- words = text.split()
82
- lines, current, length = [], [], 0
83
- for word in words:
84
- if length + len(word) + (1 if current else 0) > width:
85
- lines.append(" ".join(current))
86
- current, length = [word], len(word)
87
- else:
88
- current.append(word)
89
- length += len(word) + (1 if len(current) > 1 else 0)
90
- if current:
91
- lines.append(" ".join(current))
92
- return "<br>".join(lines)
93
-
94
-
95
- # --- Table Styling ---
96
- _TABLE_HEADER = {"backgroundColor": BRAND_TEAL, "color": "white", "fontWeight": "600", "fontSize": "12px", "textAlign": "center", "fontFamily": FONT_STACK}
97
- _TABLE_CELL = {"fontSize": "12px", "textAlign": "center", "padding": "8px 12px", "fontFamily": FONT_STACK, "border": f"1px solid {BORDER_LIGHT}"}
98
-
99
-
100
- # --- Polygon Area (Shoelace on polar coords) ---
101
- def compute_polygon_area(r_values, n_axes):
102
- """Shoelace formula on a closed polar polygon with equally-spaced angles."""
103
- angles = np.linspace(0, 2 * np.pi, n_axes, endpoint=False)
104
- x = r_values * np.cos(angles)
105
- y = r_values * np.sin(angles)
106
- x_c, y_c = np.append(x, x[0]), np.append(y, y[0])
107
- return 0.5 * abs(np.sum(x_c[:-1] * y_c[1:] - x_c[1:] * y_c[:-1]))
108
-
109
-
110
- def compute_scenario_areas(radar_df, cols, scenario_col="scenario", max_tier=4):
111
- """Returns {scenario: {"area": float, "pct": float}} for every row in radar_df."""
112
- invert = lambda x: (max_tier + 1) - x
113
- n = len(cols)
114
- max_area = 0.5 * n * max_tier**2 * np.sin(2 * np.pi / n) if n >= 3 else 1.0
115
- areas = {}
116
- for _, row in radar_df.iterrows():
117
- scen = row[scenario_col]
118
- inv = np.clip(invert(row[cols].values.astype(float)), 1, max_tier)
119
- area = compute_polygon_area(inv, n)
120
- areas[scen] = {"area": round(area, 3), "pct": round(100.0 * area / max_area, 1)}
121
- return areas
122
-
123
- def _polygon_perimeter(points):
124
- diffs = points - np.roll(points, -1, axis=0)
125
- return np.sum(np.sqrt((diffs ** 2).sum(axis=1)))
126
-
127
- def compute_scenario_convexity(radar_df, cols, scenario_col="scenario", max_tier=4):
128
- invert = lambda x: (max_tier + 1) - x
129
- n = len(cols)
130
- angles = [2 * np.pi * i / n - np.pi / 2 for i in range(n)]
131
- convexity = {}
132
- for _, row in radar_df.iterrows():
133
- scen = row[scenario_col]
134
- inv = np.clip(invert(row[cols].values.astype(float)), 1, max_tier)
135
- points = np.array([[r * np.cos(a), r * np.sin(a)] for r, a in zip(inv, angles)])
136
- try:
137
- hull = ConvexHull(points)
138
- hull_perim = _polygon_perimeter(points[hull.vertices])
139
- poly_perim = _polygon_perimeter(points)
140
- convexity[scen] = round((hull_perim / poly_perim) * 100, 1)
141
- except Exception:
142
- convexity[scen] = float('nan')
143
- return convexity
144
-
145
- def compute_scenario_area_convexity(radar_df, cols, scenario_col="scenario", max_tier=4):
146
- invert = lambda x: (max_tier + 1) - x
147
- n = len(cols)
148
- angles = [2 * np.pi * i / n - np.pi / 2 for i in range(n)]
149
- area_convexity = {}
150
- for _, row in radar_df.iterrows():
151
- scen = row[scenario_col]
152
- inv = np.clip(invert(row[cols].values.astype(float)), 1, max_tier)
153
- points = np.array([[r * np.cos(a), r * np.sin(a)] for r, a in zip(inv, angles)])
154
- try:
155
- x, y = points[:, 0], points[:, 1]
156
- x_c, y_c = np.append(x, x[0]), np.append(y, y[0])
157
- poly_area = 0.5 * abs(np.sum(x_c[:-1] * y_c[1:] - x_c[1:] * y_c[:-1]))
158
- hull = ConvexHull(points)
159
- hull_pts = points[hull.vertices]
160
- hx, hy = hull_pts[:, 0], hull_pts[:, 1]
161
- hx_c, hy_c = np.append(hx, hx[0]), np.append(hy, hy[0])
162
- hull_area = 0.5 * abs(np.sum(hx_c[:-1] * hy_c[1:] - hx_c[1:] * hy_c[:-1]))
163
- area_convexity[scen] = round((poly_area / hull_area) * 100, 1)
164
- except Exception:
165
- area_convexity[scen] = float('nan')
166
- return area_convexity
167
-
168
- # --- Matplotlib Polar Distribution Render ---
169
- def render_distribution_png(radar_df, cols, scenarios, colors, labels, max_tier=4):
170
- """Render a polar radar with KDE violins + histogram bars along each axis, as base64 PNG."""
171
- import matplotlib
172
- matplotlib.use("Agg")
173
- import matplotlib.pyplot as plt
174
-
175
- try:
176
- from scipy.stats import gaussian_kde
177
- has_scipy = True
178
- except ImportError:
179
- has_scipy = False
180
-
181
- n_axes = len(cols)
182
- angles = np.linspace(0, 2 * np.pi, n_axes, endpoint=False)
183
- invert = lambda x: (max_tier + 1) - x
184
-
185
- fig = plt.figure(figsize=(9, 9))
186
- ax = fig.add_subplot(111, polar=True)
187
- ax.set_theta_offset(np.pi / 2)
188
- ax.set_theta_direction(-1)
189
-
190
- # Radial grid: tier rings
191
- ax.set_ylim(0, max_tier + 0.5)
192
- ax.set_yticks([1, 2, 3, 4])
193
- ax.set_yticklabels(["Tier 4", "Tier 3", "Tier 2", "Tier 1"], fontsize=9, color="#94A3B8")
194
- ax.set_xticks(angles)
195
- ax.set_xticklabels([TIER_LABELS.get(c, c) for c in cols], fontsize=10, color=BRAND_TEAL, fontweight="600")
196
- ax.grid(True, color="#E2E8F0", linewidth=0.8)
197
- ax.spines["polar"].set_color("#E2E8F0")
198
-
199
- # Draw KDE violins + histogram bars along each axis (matched to coeqwalpackage/plotting.py)
200
- violin_width = 0.20
201
- kde_bw = 0.25
202
- bin_width = 0.1
203
- for idx, c in enumerate(cols):
204
- all_vals = invert(radar_df[c].dropna().values)
205
- all_vals = np.clip(all_vals, 1, max_tier)
206
- if len(all_vals) < 2:
207
- continue
208
- angle = angles[idx]
209
-
210
- # KDE violin (symmetric fill around axis)
211
- if has_scipy and len(all_vals) >= 3:
212
- try:
213
- kde = gaussian_kde(all_vals, bw_method=kde_bw)
214
- y_range = np.linspace(0.5, max_tier + 0.5, 50)
215
- density = kde(y_range)
216
- density = density / density.max() * violin_width
217
- left_angles = angle - density
218
- right_angles = angle + density
219
- verts_theta = np.concatenate([left_angles, right_angles[::-1]])
220
- verts_r = np.concatenate([y_range, y_range[::-1]])
221
- ax.fill(verts_theta, verts_r, alpha=0.25, color=BRAND_TEAL, edgecolor=BRAND_TEAL, linewidth=0.5)
222
- except Exception:
223
- pass
224
-
225
- # Histogram bars along axis
226
- hist_edges = np.arange(0.5, max_tier + 0.5 + bin_width, bin_width)
227
- counts, edges = np.histogram(all_vals, bins=hist_edges)
228
- if counts.max() > 0:
229
- for i_bin, count in enumerate(counts):
230
- if count > 0:
231
- bin_center = (edges[i_bin] + edges[i_bin + 1]) / 2
232
- bar_w = (count / counts.max()) * violin_width
233
- bar_h = bin_width * 0.9
234
- ax.fill([angle - bar_w, angle + bar_w, angle + bar_w, angle - bar_w], [bin_center - bar_h / 2, bin_center - bar_h / 2, bin_center + bar_h / 2, bin_center + bar_h / 2], alpha=0.35, color=BRAND_TEAL, edgecolor=BRAND_TEAL, linewidth=0.5)
235
-
236
- # Build dot size thresholds per column from _Std columns (if present)
237
- max_val = (max_tier - 1) / 2 # = 1.5 for max_tier=4
238
- dot_size_map = {}
239
- has_std = any(c.endswith("_Std") for c in radar_df.columns)
240
- for col in cols:
241
- std_col = col.replace("_Mean", "_Std") if "_Mean" in col else col + "_Std"
242
- if std_col not in radar_df.columns:
243
- continue
244
- std_vals = radar_df[std_col].dropna().values.astype(float)
245
- if len(std_vals) < 3:
246
- continue
247
- dot_size_map[col] = {"low": max_val / 3, "mid": 2 * max_val / 3}
248
-
249
- # Highlighted scenario polygons
250
- for i, scen in enumerate(scenarios):
251
- row = radar_df[radar_df["scenario"] == scen]
252
- if row.empty:
253
- continue
254
- raw = row[cols].values.flatten().astype(float)
255
- inv = np.clip(invert(raw), 1, max_tier)
256
- inv_closed = np.append(inv, inv[0])
257
- angles_closed = np.append(angles, angles[0])
258
- ax.plot(angles_closed, inv_closed, color=colors[i], linewidth=2.5, label=labels[i], zorder=5)
259
- ax.fill(angles_closed, inv_closed, color=colors[i], alpha=0.06, zorder=4)
260
- # Per-axis dot sizing driven by std
261
- for col, angle, r in zip(cols, angles, inv):
262
- std_col = col.replace("_Mean", "_Std") if "_Mean" in col else col + "_Std"
263
- thresholds = dot_size_map.get(col)
264
- if thresholds is None or std_col not in radar_df.columns:
265
- ms = 7
266
- else:
267
- std_val = row[std_col].values[0] if std_col in row.columns else None
268
- if std_val is None or (hasattr(std_val, '__float__') and np.isnan(float(std_val))):
269
- ms = 7
270
- elif float(std_val) <= thresholds["low"]:
271
- ms = 5
272
- elif float(std_val) <= thresholds["mid"]:
273
- ms = 10
274
- else:
275
- ms = 15
276
- ax.plot(angle, r, marker="o", color=colors[i], markersize=ms,
277
- markeredgecolor="white", markeredgewidth=1, zorder=6)
278
-
279
- from matplotlib.lines import Line2D
280
- if scenarios:
281
- scenario_legend = ax.legend(loc="upper right", bbox_to_anchor=(1.25, 1.1), fontsize=10, frameon=False)
282
- if has_std and dot_size_map:
283
- size_legend_elements = [
284
- Line2D([0], [0], marker="o", color="gray", label="Low variability", markersize=5, linestyle="None"),
285
- Line2D([0], [0], marker="o", color="gray", label="Med variability", markersize=10, linestyle="None"),
286
- Line2D([0], [0], marker="o", color="gray", label="High variability", markersize=15, linestyle="None"),
287
- ]
288
- size_legend = ax.legend(handles=size_legend_elements, title="Dot size (Std Dev)",
289
- loc="lower right", bbox_to_anchor=(1.25, 0.0), fontsize=9, title_fontsize=10, frameon=False)
290
- ax.add_artist(size_legend)
291
- ax.add_artist(scenario_legend)
292
-
293
- plt.tight_layout()
294
- buf = io.BytesIO()
295
- fig.savefig(buf, format="png", dpi=140, bbox_inches="tight")
296
- plt.close(fig)
297
- buf.seek(0)
298
- return "data:image/png;base64," + base64.b64encode(buf.read()).decode()
299
-
300
-
301
- # --- Empty State Helper ---
302
- def get_empty_radar_figure(message="Select scenarios to compare", cols=None):
303
- """Returns a clean polar grid with centered watermark instead of cartesian box."""
304
- max_tier = 4
305
- invert = lambda x: (max_tier + 1) - x
306
- use_cols = cols or ALL_TIER_COLS
307
- axis_labels = [TIER_LABELS.get(c, c) for c in use_cols]
308
- fig = go.Figure()
309
- # Invisible trace to establish polar grid categories
310
- fig.add_trace(go.Scatterpolar(
311
- r=[0] * len(axis_labels), theta=axis_labels,
312
- mode="markers", marker=dict(size=0, color="rgba(0,0,0,0)"),
313
- showlegend=False, hoverinfo="skip"))
314
- # Axis tip hover anchors - category descriptions
315
- tip_r = max_tier + 0.5
316
- for col, lbl in zip(use_cols, axis_labels):
317
- desc_key = _COL_TO_DESC_KEY.get(col)
318
- if desc_key and desc_key in _AXIS_DESCRIPTIONS:
319
- hover_text = f"<b>{lbl}</b><br>{_wrap_hover(_AXIS_DESCRIPTIONS[desc_key])}"
320
- fig.add_trace(go.Scatterpolar(
321
- r=[tip_r], theta=[lbl], mode="markers",
322
- marker=dict(size=18, color="rgba(0,0,0,0.01)", symbol="circle"),
323
- showlegend=False, hovertemplate=hover_text + "<extra></extra>",
324
- hoverlabel=dict(bgcolor="white", bordercolor="grey", font=dict(size=12, color="black"))))
325
- # Tier ring hover anchors - tier definitions at each axis x tier intersection
326
- for col, lbl in zip(use_cols, axis_labels):
327
- desc_key = _COL_TO_DESC_KEY.get(col)
328
- if not desc_key or desc_key not in _TIER_DEFINITIONS:
329
- continue
330
- tier_defs = _TIER_DEFINITIONS[desc_key]
331
- for tier in range(1, max_tier + 1):
332
- inv_r = invert(tier)
333
- tier_text = tier_defs.get(tier, "")
334
- hover_text = f"<b>{lbl} - Tier {tier}</b><br>{_wrap_hover(tier_text)}"
335
- fig.add_trace(go.Scatterpolar(
336
- r=[inv_r], theta=[lbl], mode="markers",
337
- marker=dict(size=18, color="rgba(0,0,0,0.01)", symbol="circle"),
338
- showlegend=False, hovertemplate=hover_text + "<extra></extra>",
339
- hoverlabel=dict(bgcolor="white", bordercolor="grey", font=dict(size=12, color="black"))))
340
- fig.update_layout(
341
- polar=dict(
342
- radialaxis=dict(
343
- range=[0, 4.5], tickvals=[1, 2, 3, 4],
344
- ticktext=["Tier 4", "Tier 3", "Tier 2", "Tier 1"],
345
- showline=False, gridcolor=GRID_COLOR),
346
- angularaxis=dict(
347
- direction="clockwise", gridcolor=GRID_COLOR,
348
- tickfont=dict(size=13, color=BRAND_TEAL, family=FONT_STACK))),
349
- annotations=[dict(
350
- text=message, xref="paper", yref="paper", x=0.5, y=0.08,
351
- showarrow=False, font=dict(size=14, color="#A0AEC0"),
352
- xanchor="center", yanchor="middle")],
353
- showlegend=False, dragmode=False,
354
- margin=dict(t=30, b=80, l=60, r=60),
355
- height=700, autosize=True)
356
- return fig
357
-
358
-
359
- # --- Figure Builder ---
360
- def build_radar_figure(radar_df, cols, scenario_col, highlight_scenarios,
361
- highlight_colors, highlight_labels, title, max_tier=4, areas=None):
362
- valid_scenarios = [s for s in highlight_scenarios if s in radar_df[scenario_col].values]
363
- if len(valid_scenarios) < 1 or len(cols) < 3:
364
- return get_empty_radar_figure()
365
-
366
- invert = lambda x: (max_tier + 1) - x
367
- axis_labels = [TIER_LABELS.get(c, c) for c in cols]
368
- axis_labels_closed = axis_labels + [axis_labels[0]]
369
-
370
- fig = go.Figure()
371
-
372
- # Envelope: shaded band showing min/max range across ALL scenarios
373
- all_data = radar_df.dropna(subset=cols)
374
- if len(all_data) > 0:
375
- raw_min = all_data[cols].min().values
376
- raw_max = all_data[cols].max().values
377
- inv_inner = np.clip(invert(raw_max), 1, max_tier)
378
- inv_outer = np.clip(invert(raw_min), 1, max_tier)
379
- inv_inner_closed = np.append(inv_inner, inv_inner[0]).tolist()
380
- inv_outer_closed = np.append(inv_outer, inv_outer[0]).tolist()
381
-
382
- fig.add_trace(go.Scatterpolar(
383
- r=inv_inner_closed, theta=axis_labels_closed,
384
- mode="lines", line=dict(color="rgba(0,0,0,0)", width=0),
385
- showlegend=False, hoverinfo="skip"))
386
- fig.add_trace(go.Scatterpolar(
387
- r=inv_outer_closed, theta=axis_labels_closed,
388
- mode="lines", fill="tonext",
389
- fillcolor="rgba(19, 87, 115, 0.08)",
390
- line=dict(color="rgba(19, 87, 115, 0.2)", width=1),
391
- name="All Scenarios Range", hoverinfo="skip"))
392
-
393
- # Highlighted scenario traces
394
- for sc_id in valid_scenarios:
395
- sc_idx = valid_scenarios.index(sc_id)
396
- row = radar_df[radar_df[scenario_col] == sc_id]
397
- if row.empty:
398
- continue
399
- raw_vals = row[cols].values.flatten().tolist()
400
- inv_vals = np.clip(invert(row[cols].values.flatten()), 1, max_tier).tolist()
401
- inv_vals_closed = inv_vals + [inv_vals[0]]
402
-
403
- area_info = areas.get(sc_id, {}) if areas else {}
404
- area_suffix = f"<br>Radar Area: {area_info['pct']:.1f}%" if area_info else ""
405
- hover_texts = [f"{highlight_labels[sc_idx]}<br>{lbl}: Tier {rv:.1f}{area_suffix}" for lbl, rv in zip(axis_labels, raw_vals)]
406
- hover_texts.append(hover_texts[0])
407
-
408
- legend_name = f"{highlight_labels[sc_idx]} ({area_info['pct']:.0f}%)" if area_info else highlight_labels[sc_idx]
409
- color = highlight_colors[sc_idx]
410
- fig.add_trace(go.Scatterpolar(
411
- r=inv_vals_closed, theta=axis_labels_closed,
412
- mode="lines+markers",
413
- line=dict(color=color, width=2.5),
414
- marker=dict(size=8, color=color, line=dict(color="white", width=1)),
415
- name=legend_name,
416
- text=hover_texts, hoverinfo="text"))
417
-
418
- # Axis tip hover anchors - category descriptions
419
- tip_r = max_tier + 0.5
420
- for i, (col, lbl) in enumerate(zip(cols, axis_labels)):
421
- desc_key = _COL_TO_DESC_KEY.get(col)
422
- if desc_key and desc_key in _AXIS_DESCRIPTIONS:
423
- hover_text = f"<b>{lbl}</b><br>{_wrap_hover(_AXIS_DESCRIPTIONS[desc_key])}"
424
- fig.add_trace(go.Scatterpolar(
425
- r=[tip_r], theta=[lbl], mode="markers",
426
- marker=dict(size=18, color="rgba(0,0,0,0.01)", symbol="circle"),
427
- showlegend=False, hovertemplate=hover_text + "<extra></extra>",
428
- hoverlabel=dict(bgcolor="white", bordercolor="grey", font=dict(size=12, color="black"))))
429
-
430
- # Tier ring hover anchors - tier definitions at each axis x tier intersection
431
- for i, (col, lbl) in enumerate(zip(cols, axis_labels)):
432
- desc_key = _COL_TO_DESC_KEY.get(col)
433
- if not desc_key or desc_key not in _TIER_DEFINITIONS:
434
- continue
435
- tier_defs = _TIER_DEFINITIONS[desc_key]
436
- for tier in range(1, max_tier + 1):
437
- inv_r = invert(tier)
438
- tier_text = tier_defs.get(tier, "")
439
- hover_text = f"<b>{lbl} - Tier {tier}</b><br>{_wrap_hover(tier_text)}"
440
- fig.add_trace(go.Scatterpolar(
441
- r=[inv_r], theta=[lbl], mode="markers",
442
- marker=dict(size=18, color="rgba(0,0,0,0.01)", symbol="circle"),
443
- showlegend=False, hovertemplate=hover_text + "<extra></extra>",
444
- hoverlabel=dict(bgcolor="white", bordercolor="grey", font=dict(size=12, color="black"))))
445
-
446
- fig.update_layout(
447
- polar=dict(
448
- radialaxis=dict(
449
- range=[0, max_tier + 0.5],
450
- tickvals=[1, 2, 3, 4],
451
- ticktext=["Tier 4", "Tier 3", "Tier 2", "Tier 1"],
452
- showline=False, gridcolor=GRID_COLOR),
453
- angularaxis=dict(
454
- direction="clockwise", gridcolor=GRID_COLOR,
455
- tickfont=dict(size=13, color=BRAND_TEAL, family=FONT_STACK))),
456
- showlegend=True, dragmode=False,
457
- legend=dict(orientation="h", yanchor="top", y=-0.12, xanchor="center", x=0.5, font=dict(size=12, color=TEXT_MUTED)),
458
- margin=dict(t=30, b=80, l=60, r=60),
459
- height=700, autosize=True)
460
- return fig
461
-
462
-
463
- # --- Sidebar Controls (for the sidebar) ---
464
- def get_sidebar_controls(scen_groups_store_init):
465
- group_opts = [{"label": k, "value": k} for k in sorted(scen_groups_store_init.keys())]
466
- scenario_opts = [{"label": s, "value": s} for s in RADAR_SCENARIOS]
467
- axes_opts = [{"label": TIER_LABELS.get(c, c), "value": c} for c in ALL_TIER_COLS]
468
-
469
- return html.Div([
470
- html.Div("Scenario Group", style=SECTION_HEADER),
471
- dcc.Dropdown(id="radar-group-dropdown", options=group_opts, placeholder="Choose group..."),
472
- html.Div(id="radar-group-desc", style=DESCRIPTION_STYLE),
473
- html.Button("Add to Selection", id="radar-add-group-btn", n_clicks=0, className="btn-primary", style={**PRIMARY_BUTTON, "marginBottom": "16px"}),
474
-
475
- html.Div("Scenarios", style=SECTION_HEADER),
476
- dcc.Dropdown(id="radar-scenario-dropdown", options=scenario_opts, value=[], multi=True, placeholder="Add/remove scenarios..."),
477
- html.Div(id="radar-scenario-desc", style=DESCRIPTION_STYLE),
478
-
479
- html.Div("Tier Groups", style=SECTION_HEADER),
480
- dcc.Dropdown(id="radar-tier-preset", options=[{"label": k, "value": k} for k in RADAR_PRESETS], placeholder="Choose group...", clearable=True),
481
-
482
- html.Div("Tier Categories", style=SECTION_HEADER),
483
- dcc.Dropdown(id="radar-axes-checklist", options=axes_opts, value=list(ALL_TIER_COLS), multi=True, placeholder="Select tiers..."),
484
- html.Div(id="radar-axes-warning", style={"color": DANGER_RED, "marginTop": "5px", "fontSize": "12px", "fontWeight": "500"}),
485
-
486
- html.Div(
487
- "Tier 1 (best) is outermost. Shaded band shows full scenario range. Click legend to toggle.",
488
- style={**DESCRIPTION_STYLE, "marginTop": "16px"}),
489
- ])
490
-
491
-
492
- # --- Main Content (chart area) ---
493
- def get_content():
494
- _toggle_style = {"backgroundColor": "white", "color": BRAND_TEAL, "border": f"1px solid {BORDER_LIGHT}", "borderRadius": "6px", "padding": "8px 20px", "fontSize": "13px", "fontWeight": "600", "cursor": "pointer"}
495
- _area_card = {"backgroundColor": "white", "border": f"1px solid {BORDER_LIGHT}", "borderRadius": "8px", "padding": "16px", "minWidth": "260px", "maxWidth": "300px", "alignSelf": "flex-start", "position": "sticky", "top": "24px"}
496
- return html.Div([
497
- html.H3(id="title-radar", style={"textAlign": "center", "marginTop": "0", "marginBottom": "8px", "color": BRAND_TEAL}),
498
-
499
- # Radar chart + area panel side by side
500
- html.Div(style={"display": "flex", "gap": "16px", "alignItems": "flex-start"}, children=[
501
- dcc.Graph(id="plot-radar", figure=get_empty_radar_figure(), config={"scrollZoom": False, "displayModeBar": False}, responsive=True, style={"height": "75vh", "minHeight": "500px", "flex": "1"}),
502
-
503
- # Radar Area panel (right side)
504
- html.Div(id="radar-area-panel", style=_area_card, children=[
505
- html.Div(style={"display": "flex", "justifyContent": "space-between", "gap": "16px", "marginBottom": "10px", "paddingBottom": "6px", "borderBottom": f"1px solid {BORDER_LIGHT}"}, children=[
506
- html.Div("Scenario", style={"fontSize": "11px", "fontWeight": "700", "color": BRAND_TEAL, "textTransform": "uppercase", "letterSpacing": "1px", "flex": "1"}),
507
- html.Div("Area", style={"fontSize": "11px", "fontWeight": "700", "color": BRAND_TEAL, "textTransform": "uppercase", "letterSpacing": "1px", "width": "60px", "textAlign": "left", "paddingLeft": "12px"}),
508
- html.Div("Per. Conv.", style={"fontSize": "11px", "fontWeight": "700", "color": BRAND_TEAL, "textTransform": "uppercase", "letterSpacing": "1px", "width": "80px", "textAlign": "left"}),
509
- html.Div("Area Conv.", style={"fontSize": "11px", "fontWeight": "700", "color": BRAND_TEAL, "textTransform": "uppercase", "letterSpacing": "1px", "width": "80px", "textAlign": "left"}),
510
- ]),
511
- html.Div(id="radar-area-list", children=[
512
- html.Div("Select scenarios", style={"fontSize": "12px", "color": TEXT_MUTED}),
513
- ]),
514
- ]),
515
- ]),
516
- download_buttons("plot-radar"),
517
-
518
- # Distribution toggle
519
- html.Div(style={"textAlign": "center", "marginTop": "12px"}, children=[
520
- html.Button("Generate Static Plot with Overlaid KDE & Histogram Distribution", id="radar-dist-toggle", n_clicks=0, style=_toggle_style),
521
- ]),
522
- html.Div(id="radar-dist-container", style={"display": "none"}, children=[
523
- dcc.Loading(type="circle", color=BRAND_TEAL, children=[
524
- html.Img(id="radar-dist-img", style={"maxWidth": "100%", "margin": "12px auto", "display": "block"}),
525
- ]),
526
- download_buttons("radar-dist-img"),
527
- ]),
528
-
529
- # Data table
530
- html.Div(id="radar-table-container", style={"marginTop": "20px"}, children=[
531
- html.H4("Scenario Data", style={"color": BRAND_TEAL, "fontSize": "14px", "fontWeight": "600", "marginBottom": "8px"}),
532
- dash_table.DataTable(id="radar-data-table", columns=[], data=[], sort_action="native", style_table={"overflowX": "auto"}, style_header=_TABLE_HEADER, style_cell=_TABLE_CELL),
533
- download_buttons("radar-data-table", has_plot=False),
534
- ]),
535
- ])
536
-
537
-
538
- # --- Callbacks ---
539
- def register_callbacks(app, scen_groups_store_init):
540
- @app.callback(
541
- Output("radar-group-desc", "children"),
542
- Input("radar-group-dropdown", "value"),
543
- prevent_initial_call=True,
544
- )
545
- def show_radar_group_desc(group_name):
546
- if not group_name or group_name not in scen_groups_store_init:
547
- return ""
548
- info = scen_groups_store_init[group_name]
549
- desc = info.get("description", "")
550
- scs = info.get("scenarios", [])
551
- return f"{desc} ({', '.join(scs)})" if scs else desc
552
-
553
- @app.callback(
554
- Output("radar-scenario-dropdown", "value"),
555
- Input("radar-add-group-btn", "n_clicks"),
556
- State("radar-group-dropdown", "value"),
557
- State("radar-scenario-dropdown", "value"),
558
- prevent_initial_call=True,
559
- )
560
- def add_radar_group_to_selection(_, group_name, current):
561
- from dash.exceptions import PreventUpdate
562
- if not group_name or group_name not in scen_groups_store_init:
563
- raise PreventUpdate
564
- current = list(current or [])
565
- scs = scen_groups_store_init[group_name].get("scenarios", [])
566
- available = set(RADAR_SCENARIOS)
567
- valid = [s for s in scs if s in available]
568
- return list(dict.fromkeys(current + valid))
569
-
570
- @app.callback(
571
- Output("radar-scenario-desc", "children"),
572
- Input("radar-scenario-dropdown", "value"),
573
- )
574
- def display_radar_scenario_desc(scen_list):
575
- if not scen_list or _df_scen.empty:
576
- return "Select scenarios to see descriptions"
577
- col_idx = next((c for c in _df_scen.columns if "Index" in c), _df_scen.columns[0])
578
- col_desc = next((c for c in _df_scen.columns if "ShortDescription" in c or "Description" in c), _df_scen.columns[1] if len(_df_scen.columns) > 1 else _df_scen.columns[0])
579
- defs = _df_scen[_df_scen[col_idx].isin(scen_list)].set_index(col_idx)[col_desc].to_dict()
580
- return [html.Div([html.B(f"{s}: "), html.Span(d)]) for s, d in defs.items()]
581
-
582
- @app.callback(
583
- [Output("radar-axes-checklist", "value"), Output("radar-tier-preset", "value")],
584
- Input("radar-tier-preset", "value"),
585
- prevent_initial_call=True,
586
- )
587
- def radar_preset_to_tiers(preset):
588
- from dash.exceptions import PreventUpdate
589
- if not preset:
590
- raise PreventUpdate
591
- return RADAR_PRESETS.get(preset, list(CORE_TIER_COLS)), None
592
-
593
- @app.callback(
594
- [Output("plot-radar", "figure"),
595
- Output("title-radar", "children"),
596
- Output("radar-axes-warning", "children"),
597
- Output("radar-area-list", "children"),
598
- Output("radar-data-table", "columns"),
599
- Output("radar-data-table", "data")],
600
- [Input("radar-scenario-dropdown", "value"),
601
- Input("radar-axes-checklist", "value")],
602
- )
603
- def update_radar_plot(scenarios_selected, axes_selected):
604
- axes_selected = list(axes_selected or [])
605
- scenarios_selected = list(scenarios_selected or [])
606
- empty_area = html.Div("Select scenarios", style={"fontSize": "12px", "color": TEXT_MUTED})
607
- empty_table = ([], [])
608
-
609
- if len(axes_selected) < 3:
610
- warning = f"Select at least 3 tiers ({len(axes_selected)} selected)."
611
- return get_empty_radar_figure(cols=axes_selected), "", warning, empty_area, *empty_table
612
-
613
- if not scenarios_selected:
614
- return get_empty_radar_figure(cols=axes_selected), "Select scenarios to compare", "", empty_area, *empty_table
615
-
616
- # Compute areas for all scenarios (used in figure + area panel + table)
617
- sel_df = tier_df[tier_df["scenario"].isin(scenarios_selected)]
618
- areas = compute_scenario_areas(sel_df, axes_selected)
619
- convexity = compute_scenario_convexity(sel_df, axes_selected)
620
- area_convexity = compute_scenario_area_convexity(sel_df, axes_selected)
621
-
622
- colors = [SCENARIO_COLORS[i % len(SCENARIO_COLORS)] for i in range(len(scenarios_selected))]
623
- labels = list(scenarios_selected)
624
- fig = build_radar_figure(tier_df, axes_selected, "scenario", scenarios_selected, colors, labels, "", areas=areas)
625
- if len(fig.data) == 0:
626
- return get_empty_radar_figure("No matching tier data"), "No matching tier data for selected scenarios", "", empty_area, *empty_table
627
-
628
- title = f"Comparing {len(scenarios_selected)} Scenarios"
629
-
630
- # Radar Area panel - sorted by area descending
631
- sorted_scens = sorted(scenarios_selected, key=lambda s: areas.get(s, {}).get("pct", 0), reverse=True)
632
- area_items = []
633
- for i, scen in enumerate(sorted_scens):
634
- pct = areas.get(scen, {}).get("pct", 0)
635
- color = colors[scenarios_selected.index(scen)]
636
- area_items.append(html.Div(style={"display": "flex", "alignItems": "center", "justifyContent": "space-between", "padding": "6px 0", "borderBottom": f"1px solid {BORDER_LIGHT}" if i < len(sorted_scens) - 1 else "none"}, children=[
637
- html.Div(style={"display": "flex", "alignItems": "center", "gap": "8px", "flex": "1"}, children=[
638
- html.Div(style={"width": "10px", "height": "10px", "borderRadius": "50%", "backgroundColor": color, "flexShrink": "0"}),
639
- html.Span(scen, style={"fontSize": "12px", "fontWeight": "600", "color": "#1A202C"}),
640
- ]),
641
- html.Div(style={"display": "flex", "gap": "8px", "alignItems": "center"}, children=[
642
- html.Span(f"{pct:.1f}%", style={"fontSize": "13px", "fontWeight": "700", "color": BRAND_TEAL, "width": "60px", "textAlign": "right"}),
643
- html.Span(f"{convexity.get(scen, float('nan')):.1f}%", style={"fontSize": "13px", "fontWeight": "700", "color": BRAND_TEAL, "width": "80px", "textAlign": "right"}),
644
- html.Span(f"{area_convexity.get(scen, float('nan')):.1f}%", style={"fontSize": "13px", "fontWeight": "700", "color": BRAND_TEAL, "width": "80px", "textAlign": "left"}),
645
- ]),
646
- ]))
647
-
648
- # Build table (tier data only, no area column)
649
- table_cols = [{"name": "Scenario", "id": "scenario"}]
650
- table_cols += [{"name": TIER_LABELS.get(c, c), "id": c, "type": "numeric", "format": Format(precision=2, scheme=Scheme.fixed)} for c in axes_selected]
651
-
652
- table_data = []
653
- for scen in scenarios_selected:
654
- row = sel_df[sel_df["scenario"] == scen]
655
- if row.empty:
656
- continue
657
- entry = {"scenario": scen}
658
- for c in axes_selected:
659
- entry[c] = round(float(row[c].values[0]), 2) if c in row.columns and pd.notna(row[c].values[0]) else None
660
- table_data.append(entry)
661
-
662
- return fig, title, "", area_items, table_cols, table_data
663
-
664
- @app.callback(
665
- [Output("radar-dist-container", "style"),
666
- Output("radar-dist-img", "src"),
667
- Output("radar-dist-toggle", "children")],
668
- [Input("radar-dist-toggle", "n_clicks"),
669
- Input("radar-scenario-dropdown", "value"),
670
- Input("radar-axes-checklist", "value")],
671
- )
672
- def update_distribution_view(n_clicks, scenarios, axes):
673
- visible = bool(n_clicks and n_clicks % 2 == 1)
674
- btn_text = "Hide Static Plot" if visible else "Generate Static Plot with Overlaid KDE & Histogram Distribution"
675
- if not visible:
676
- return {"display": "none"}, "", btn_text
677
- scenarios = list(scenarios or [])
678
- axes = list(axes or [])
679
- if len(axes) < 3 or not scenarios:
680
- return {"display": "block"}, "", btn_text
681
- colors = [SCENARIO_COLORS[i % len(SCENARIO_COLORS)] for i in range(len(scenarios))]
682
- src = render_distribution_png(tier_df, axes, scenarios, colors, scenarios)
683
- return {"display": "block"}, src, btn_text
684
-
685
- # --- Download Callbacks ---
686
- register_plotly_png_callback(app, "plot-radar")
687
- register_base64_png_callback(app, "radar-dist-img", "dl-png-radar-dist-img")
688
-
689
- @app.callback(
690
- Output("dl-csv-plot-radar", "data"),
691
- Input("dl-csv-btn-plot-radar", "n_clicks"),
692
- [State("radar-scenario-dropdown", "value"), State("radar-axes-checklist", "value")],
693
- prevent_initial_call=True,
694
- )
695
- def download_radar_csv(n_clicks, scenarios, axes):
696
- from dash.exceptions import PreventUpdate
697
- if not n_clicks or not scenarios or not axes:
698
- raise PreventUpdate
699
- out = tier_df[tier_df["scenario"].isin(scenarios)][["scenario"] + list(axes)]
700
- return dcc.send_data_frame(out.to_csv, "radar_data.csv", index=False)
701
-
702
- @app.callback(
703
- Output("dl-csv-radar-dist-img", "data"),
704
- Input("dl-csv-btn-radar-dist-img", "n_clicks"),
705
- [State("radar-scenario-dropdown", "value"), State("radar-axes-checklist", "value")],
706
- prevent_initial_call=True,
707
- )
708
- def download_dist_csv(n_clicks, scenarios, axes):
709
- from dash.exceptions import PreventUpdate
710
- if not n_clicks or not axes:
711
- raise PreventUpdate
712
- out = tier_df[["scenario"] + list(axes)]
713
- return dcc.send_data_frame(out.to_csv, "radar_distribution.csv", index=False)
714
-
715
- @app.callback(
716
- Output("dl-csv-radar-data-table", "data"),
717
- Input("dl-csv-btn-radar-data-table", "n_clicks"),
718
- State("radar-data-table", "data"),
719
- prevent_initial_call=True,
720
- )
721
- def download_table_csv(n_clicks, table_data):
722
- from dash.exceptions import PreventUpdate
723
- if not n_clicks or not table_data:
724
- raise PreventUpdate
725
- out = pd.DataFrame(table_data)
726
- return dcc.send_data_frame(out.to_csv, "radar_table.csv", index=False)
 
 
1
+ import io
2
+ import base64
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+ import plotly.graph_objs as go
7
+ from dash import dcc, html, dash_table
8
+ from dash.dash_table.Format import Format, Scheme
9
+ from dash.dependencies import Input, Output, State
10
+ from scipy.spatial import ConvexHull
11
+
12
+ from theme import (
13
+ SCENARIO_COLORS, BRAND_TEAL, TEXT_MUTED, FONT_STACK, GRID_COLOR,
14
+ SECTION_HEADER, DESCRIPTION_STYLE, LABEL_STYLE, DANGER_RED, MODERN_CARD,
15
+ PRIMARY_BUTTON, BORDER_LIGHT,
16
+ )
17
+ from downloads import download_buttons, register_plotly_png_callback, register_base64_png_callback
18
+ # contibuous or discrete tiers
19
+ ContinuousTiers = True
20
+ if ContinuousTiers:
21
+ tier_data = "data/tier_df_continuous.csv"
22
+ else:
23
+ tier_data = "data/tier_df.csv"
24
+
25
+ # --- Tier Data (single pre-computed CSV from MergedMetrics.ipynb) ---
26
+ tier_df = pd.read_csv(tier_data)
27
+
28
+ ALL_TIER_COLS = [c for c in tier_df.columns if c != "scenario" and not c.endswith("_Std")]
29
+
30
+ # --- Scenario Descriptions ---
31
+ try:
32
+ _df_scen = pd.read_csv("data/coeqwal_cs3_scenario_listing_v7.csv", encoding="utf-8")
33
+ except UnicodeDecodeError:
34
+ _df_scen = pd.read_csv("data/coeqwal_cs3_scenario_listing_v7.csv", encoding="latin1")
35
+ CORE_TIER_COLS = ["NOD_GW_Mean", "SOD_GW_Mean", "NOD_Reservoir_Mean", "SOD_Reservoir_Mean", "Exports_and_Salinity", "InDelta_Salinity"]
36
+
37
+ RADAR_PRESETS = {
38
+ "All (14)": ALL_TIER_COLS,
39
+ }
40
+
41
+ TIER_LABELS = {
42
+ "NOD_GW_Mean": "NOD Groundwater", "SOD_GW_Mean": "SOD Groundwater",
43
+ "NOD_Reservoir_Mean": "NOD Reservoir", "SOD_Reservoir_Mean": "SOD Reservoir",
44
+ "Exports_and_Salinity": "Exports and Salinity", "InDelta_Salinity": "In-Delta Salinity",
45
+ "NOD_Ag_Mean": "NOD Agriculture", "SOD_Ag_Mean": "SOD Agriculture",
46
+ "NOD_DW_Mean": "NOD Drinking Water", "SOD_DW_Mean": "SOD Drinking Water",
47
+ "NOD_Eflows_Mean": "NOD Env. Flows", "SOD_Eflows_Mean": "SOD Env. Flows",
48
+ "Delta_Ecology": "Delta Ecology", "Salmon_Abundance": "Salmon Abundance",
49
+ }
50
+
51
+ RADAR_SCENARIOS = sorted(tier_df["scenario"].tolist())
52
+
53
+ # --- Tier Descriptions (hover text from tiers_descriptions.csv) ---
54
+ _tier_desc_df = pd.read_csv("data/tiers_descriptions.csv")
55
+ _tier_desc_df.columns = _tier_desc_df.columns.str.strip()
56
+ _tier_desc_df["Outcomes/ Tiers"] = _tier_desc_df["Outcomes/ Tiers"].str.strip()
57
+
58
+ _COL_TO_DESC_KEY = {
59
+ "NOD_GW_Mean": "NOD GW", "SOD_GW_Mean": "SOD GW",
60
+ "NOD_Reservoir_Mean": "NOD Reservoir", "SOD_Reservoir_Mean": "SOD Reservoir",
61
+ "Exports_and_Salinity": "Exports and Salinity", "InDelta_Salinity": "InDelta Salinity",
62
+ "NOD_Ag_Mean": "NOD Ag", "SOD_Ag_Mean": "SOD Ag",
63
+ "NOD_DW_Mean": "NOD DW", "SOD_DW_Mean": "SOD DW",
64
+ "NOD_Eflows_Mean": "NOD Eflows", "SOD_Eflows_Mean": "SOD Eflows",
65
+ "Delta_Ecology": "Delta Ecology", "Salmon_Abundance": "Salmon Abundance",
66
+ }
67
+
68
+ _AXIS_DESCRIPTIONS = {}
69
+ _TIER_DEFINITIONS = {}
70
+ for _, _row in _tier_desc_df.iterrows():
71
+ _key = _row["Outcomes/ Tiers"]
72
+ _AXIS_DESCRIPTIONS[_key] = str(_row["Description"]).strip()
73
+ _TIER_DEFINITIONS[_key] = {1: str(_row["Tier1"]).strip(), 2: str(_row["Tier2"]).strip(), 3: str(_row["Tier3"]).strip(), 4: str(_row["Tier4"]).strip()}
74
+
75
+
76
+ def _wrap_hover(text, width=60):
77
+ """Wrap text for hover tooltips."""
78
+ words = text.split()
79
+ lines, current, length = [], [], 0
80
+ for word in words:
81
+ if length + len(word) + (1 if current else 0) > width:
82
+ lines.append(" ".join(current))
83
+ current, length = [word], len(word)
84
+ else:
85
+ current.append(word)
86
+ length += len(word) + (1 if len(current) > 1 else 0)
87
+ if current:
88
+ lines.append(" ".join(current))
89
+ return "<br>".join(lines)
90
+
91
+
92
+ # --- Table Styling ---
93
+ _TABLE_HEADER = {"backgroundColor": BRAND_TEAL, "color": "white", "fontWeight": "600", "fontSize": "12px", "textAlign": "center", "fontFamily": FONT_STACK}
94
+ _TABLE_CELL = {"fontSize": "12px", "textAlign": "center", "padding": "8px 12px", "fontFamily": FONT_STACK, "border": f"1px solid {BORDER_LIGHT}"}
95
+
96
+
97
+ # --- Polygon Area (Shoelace on polar coords) ---
98
+ def compute_polygon_area(r_values, n_axes):
99
+ """Shoelace formula on a closed polar polygon with equally-spaced angles."""
100
+ angles = np.linspace(0, 2 * np.pi, n_axes, endpoint=False)
101
+ x = r_values * np.cos(angles)
102
+ y = r_values * np.sin(angles)
103
+ x_c, y_c = np.append(x, x[0]), np.append(y, y[0])
104
+ return 0.5 * abs(np.sum(x_c[:-1] * y_c[1:] - x_c[1:] * y_c[:-1]))
105
+
106
+
107
+ def compute_scenario_areas(radar_df, cols, scenario_col="scenario", max_tier=4):
108
+ """Returns {scenario: {"area": float, "pct": float}} for every row in radar_df."""
109
+ invert = lambda x: (max_tier + 1) - x
110
+ n = len(cols)
111
+ max_area = 0.5 * n * max_tier**2 * np.sin(2 * np.pi / n) if n >= 3 else 1.0
112
+ areas = {}
113
+ for _, row in radar_df.iterrows():
114
+ scen = row[scenario_col]
115
+ inv = np.clip(invert(row[cols].values.astype(float)), 1, max_tier)
116
+ area = compute_polygon_area(inv, n)
117
+ areas[scen] = {"area": round(area, 3), "pct": round(100.0 * area / max_area, 1)}
118
+ return areas
119
+
120
+ def _polygon_perimeter(points):
121
+ diffs = points - np.roll(points, -1, axis=0)
122
+ return np.sum(np.sqrt((diffs ** 2).sum(axis=1)))
123
+
124
+ def compute_scenario_convexity(radar_df, cols, scenario_col="scenario", max_tier=4):
125
+ invert = lambda x: (max_tier + 1) - x
126
+ n = len(cols)
127
+ angles = [2 * np.pi * i / n - np.pi / 2 for i in range(n)]
128
+ convexity = {}
129
+ for _, row in radar_df.iterrows():
130
+ scen = row[scenario_col]
131
+ inv = np.clip(invert(row[cols].values.astype(float)), 1, max_tier)
132
+ points = np.array([[r * np.cos(a), r * np.sin(a)] for r, a in zip(inv, angles)])
133
+ try:
134
+ hull = ConvexHull(points)
135
+ hull_perim = _polygon_perimeter(points[hull.vertices])
136
+ poly_perim = _polygon_perimeter(points)
137
+ convexity[scen] = round((hull_perim / poly_perim) * 100, 1)
138
+ except Exception:
139
+ convexity[scen] = float('nan')
140
+ return convexity
141
+
142
+ def compute_scenario_area_convexity(radar_df, cols, scenario_col="scenario", max_tier=4):
143
+ invert = lambda x: (max_tier + 1) - x
144
+ n = len(cols)
145
+ angles = [2 * np.pi * i / n - np.pi / 2 for i in range(n)]
146
+ area_convexity = {}
147
+ for _, row in radar_df.iterrows():
148
+ scen = row[scenario_col]
149
+ inv = np.clip(invert(row[cols].values.astype(float)), 1, max_tier)
150
+ points = np.array([[r * np.cos(a), r * np.sin(a)] for r, a in zip(inv, angles)])
151
+ try:
152
+ x, y = points[:, 0], points[:, 1]
153
+ x_c, y_c = np.append(x, x[0]), np.append(y, y[0])
154
+ poly_area = 0.5 * abs(np.sum(x_c[:-1] * y_c[1:] - x_c[1:] * y_c[:-1]))
155
+ hull = ConvexHull(points)
156
+ hull_pts = points[hull.vertices]
157
+ hx, hy = hull_pts[:, 0], hull_pts[:, 1]
158
+ hx_c, hy_c = np.append(hx, hx[0]), np.append(hy, hy[0])
159
+ hull_area = 0.5 * abs(np.sum(hx_c[:-1] * hy_c[1:] - hx_c[1:] * hy_c[:-1]))
160
+ area_convexity[scen] = round((poly_area / hull_area) * 100, 1)
161
+ except Exception:
162
+ area_convexity[scen] = float('nan')
163
+ return area_convexity
164
+
165
+ # --- Matplotlib Polar Distribution Render ---
166
+ def render_distribution_png(radar_df, cols, scenarios, colors, labels, max_tier=5):
167
+ """Render a polar radar with KDE violins + histogram bars along each axis, as base64 PNG."""
168
+ import matplotlib
169
+ matplotlib.use("Agg")
170
+ import matplotlib.pyplot as plt
171
+
172
+ try:
173
+ from scipy.stats import gaussian_kde
174
+ has_scipy = True
175
+ except ImportError:
176
+ has_scipy = False
177
+
178
+ n_axes = len(cols)
179
+ angles = np.linspace(0, 2 * np.pi, n_axes, endpoint=False)
180
+ invert = lambda x: (max_tier + 1) - x
181
+
182
+ fig = plt.figure(figsize=(9, 9))
183
+ ax = fig.add_subplot(111, polar=True)
184
+ ax.set_theta_offset(np.pi / 2)
185
+ ax.set_theta_direction(-1)
186
+
187
+ # Radial grid: tier rings
188
+ ax.set_ylim(0, max_tier + 0.5)
189
+ ax.set_yticks([1, 2, 3, 4, 5])
190
+ ax.set_yticklabels(["", "Tier 4", "Tier 3", "Tier 2", "Tier 1"], fontsize=9, color="#94A3B8")
191
+ ax.set_xticks(angles)
192
+ ax.set_xticklabels([TIER_LABELS.get(c, c) for c in cols], fontsize=10, color=BRAND_TEAL, fontweight="600")
193
+ ax.grid(True, color="#E2E8F0", linewidth=0.8)
194
+ ax.spines["polar"].set_color("#E2E8F0")
195
+
196
+ # Draw KDE violins + histogram bars along each axis (matched to coeqwalpackage/plotting.py)
197
+ violin_width = 0.20
198
+ kde_bw = 0.25
199
+ bin_width = 0.1
200
+ for idx, c in enumerate(cols):
201
+ all_vals = invert(radar_df[c].dropna().values)
202
+ all_vals = np.clip(all_vals, 1, max_tier)
203
+ if len(all_vals) < 2:
204
+ continue
205
+ angle = angles[idx]
206
+
207
+ # KDE violin (symmetric fill around axis)
208
+ if has_scipy and len(all_vals) >= 3:
209
+ try:
210
+ kde = gaussian_kde(all_vals, bw_method=kde_bw)
211
+ y_range = np.linspace(0.5, max_tier + 0.5, 50)
212
+ density = kde(y_range)
213
+ density = density / density.max() * violin_width
214
+ left_angles = angle - density
215
+ right_angles = angle + density
216
+ verts_theta = np.concatenate([left_angles, right_angles[::-1]])
217
+ verts_r = np.concatenate([y_range, y_range[::-1]])
218
+ ax.fill(verts_theta, verts_r, alpha=0.25, color=BRAND_TEAL, edgecolor=BRAND_TEAL, linewidth=0.5)
219
+ except Exception:
220
+ pass
221
+
222
+ # Histogram bars along axis
223
+ hist_edges = np.arange(0.5, max_tier + 0.5 + bin_width, bin_width)
224
+ counts, edges = np.histogram(all_vals, bins=hist_edges)
225
+ if counts.max() > 0:
226
+ for i_bin, count in enumerate(counts):
227
+ if count > 0:
228
+ bin_center = (edges[i_bin] + edges[i_bin + 1]) / 2
229
+ bar_w = (count / counts.max()) * violin_width
230
+ bar_h = bin_width * 0.9
231
+ ax.fill([angle - bar_w, angle + bar_w, angle + bar_w, angle - bar_w], [bin_center - bar_h / 2, bin_center - bar_h / 2, bin_center + bar_h / 2, bin_center + bar_h / 2], alpha=0.35, color=BRAND_TEAL, edgecolor=BRAND_TEAL, linewidth=0.5)
232
+
233
+ # Build dot size thresholds per column from _Std columns (if present)
234
+ max_val = 1.5 # dot-size std thresholds stay on the 1-4 tier scale regardless of plot max_tier
235
+ dot_size_map = {}
236
+ has_std = any(c.endswith("_Std") for c in radar_df.columns)
237
+ for col in cols:
238
+ std_col = col.replace("_Mean", "_Std") if "_Mean" in col else col + "_Std"
239
+ if std_col not in radar_df.columns:
240
+ continue
241
+ std_vals = radar_df[std_col].dropna().values.astype(float)
242
+ if len(std_vals) < 3:
243
+ continue
244
+ dot_size_map[col] = {"low": max_val / 3, "mid": 2 * max_val / 3}
245
+
246
+ # Highlighted scenario polygons
247
+ for i, scen in enumerate(scenarios):
248
+ row = radar_df[radar_df["scenario"] == scen]
249
+ if row.empty:
250
+ continue
251
+ raw = row[cols].values.flatten().astype(float)
252
+ inv = np.clip(invert(raw), 1, max_tier)
253
+ inv_closed = np.append(inv, inv[0])
254
+ angles_closed = np.append(angles, angles[0])
255
+ ax.plot(angles_closed, inv_closed, color=colors[i], linewidth=2.5, label=labels[i], zorder=5)
256
+ ax.fill(angles_closed, inv_closed, color=colors[i], alpha=0.06, zorder=4)
257
+ # Per-axis dot sizing driven by std
258
+ for col, angle, r in zip(cols, angles, inv):
259
+ std_col = col.replace("_Mean", "_Std") if "_Mean" in col else col + "_Std"
260
+ thresholds = dot_size_map.get(col)
261
+ if thresholds is None or std_col not in radar_df.columns:
262
+ ms = 7
263
+ else:
264
+ std_val = row[std_col].values[0] if std_col in row.columns else None
265
+ if std_val is None or (hasattr(std_val, '__float__') and np.isnan(float(std_val))):
266
+ ms = 7
267
+ elif float(std_val) <= thresholds["low"]:
268
+ ms = 5
269
+ elif float(std_val) <= thresholds["mid"]:
270
+ ms = 10
271
+ else:
272
+ ms = 15
273
+ ax.plot(angle, r, marker="o", color=colors[i], markersize=ms,
274
+ markeredgecolor="white", markeredgewidth=1, zorder=6)
275
+
276
+ from matplotlib.lines import Line2D
277
+ if scenarios:
278
+ scenario_legend = ax.legend(loc="upper right", bbox_to_anchor=(1.25, 1.1), fontsize=10, frameon=False)
279
+ if has_std and dot_size_map:
280
+ size_legend_elements = [
281
+ Line2D([0], [0], marker="o", color="gray", label="Low variability", markersize=5, linestyle="None"),
282
+ Line2D([0], [0], marker="o", color="gray", label="Med variability", markersize=10, linestyle="None"),
283
+ Line2D([0], [0], marker="o", color="gray", label="High variability", markersize=15, linestyle="None"),
284
+ ]
285
+ size_legend = ax.legend(handles=size_legend_elements, title="Dot size (Std Dev)",
286
+ loc="lower right", bbox_to_anchor=(1.25, 0.0), fontsize=9, title_fontsize=10, frameon=False)
287
+ ax.add_artist(size_legend)
288
+ ax.add_artist(scenario_legend)
289
+
290
+ plt.tight_layout()
291
+ buf = io.BytesIO()
292
+ fig.savefig(buf, format="png", dpi=140, bbox_inches="tight")
293
+ plt.close(fig)
294
+ buf.seek(0)
295
+ return "data:image/png;base64," + base64.b64encode(buf.read()).decode()
296
+
297
+
298
+ # --- Empty State Helper ---
299
+ def get_empty_radar_figure(message="Select scenarios to compare", cols=None):
300
+ """Returns a clean polar grid with centered watermark instead of cartesian box."""
301
+ max_tier = 5
302
+ invert = lambda x: (max_tier + 1) - x
303
+ use_cols = cols or ALL_TIER_COLS
304
+ axis_labels = [TIER_LABELS.get(c, c) for c in use_cols]
305
+ fig = go.Figure()
306
+ # Invisible trace to establish polar grid categories
307
+ fig.add_trace(go.Scatterpolar(
308
+ r=[0] * len(axis_labels), theta=axis_labels,
309
+ mode="markers", marker=dict(size=0, color="rgba(0,0,0,0)"),
310
+ showlegend=False, hoverinfo="skip"))
311
+ # Axis tip hover anchors - category descriptions
312
+ tip_r = max_tier + 0.5
313
+ for col, lbl in zip(use_cols, axis_labels):
314
+ desc_key = _COL_TO_DESC_KEY.get(col)
315
+ if desc_key and desc_key in _AXIS_DESCRIPTIONS:
316
+ hover_text = f"<b>{lbl}</b><br>{_wrap_hover(_AXIS_DESCRIPTIONS[desc_key])}"
317
+ fig.add_trace(go.Scatterpolar(
318
+ r=[tip_r], theta=[lbl], mode="markers",
319
+ marker=dict(size=18, color="rgba(0,0,0,0.01)", symbol="circle"),
320
+ showlegend=False, hovertemplate=hover_text + "<extra></extra>",
321
+ hoverlabel=dict(bgcolor="white", bordercolor="grey", font=dict(size=12, color="black"))))
322
+ # Tier ring hover anchors - tier definitions at each axis x tier intersection
323
+ for col, lbl in zip(use_cols, axis_labels):
324
+ desc_key = _COL_TO_DESC_KEY.get(col)
325
+ if not desc_key or desc_key not in _TIER_DEFINITIONS:
326
+ continue
327
+ tier_defs = _TIER_DEFINITIONS[desc_key]
328
+ for tier in range(1, max_tier + 1):
329
+ tier_text = tier_defs.get(tier, "")
330
+ if not tier_text:
331
+ continue
332
+ inv_r = invert(tier)
333
+ hover_text = f"<b>{lbl} - Tier {tier}</b><br>{_wrap_hover(tier_text)}"
334
+ fig.add_trace(go.Scatterpolar(
335
+ r=[inv_r], theta=[lbl], mode="markers",
336
+ marker=dict(size=18, color="rgba(0,0,0,0.01)", symbol="circle"),
337
+ showlegend=False, hovertemplate=hover_text + "<extra></extra>",
338
+ hoverlabel=dict(bgcolor="white", bordercolor="grey", font=dict(size=12, color="black"))))
339
+ fig.update_layout(
340
+ polar=dict(
341
+ radialaxis=dict(
342
+ range=[0, max_tier + 0.5], tickvals=[1, 2, 3, 4, 5],
343
+ ticktext=["", "Tier 4", "Tier 3", "Tier 2", "Tier 1"],
344
+ showline=False, gridcolor=GRID_COLOR),
345
+ angularaxis=dict(
346
+ direction="clockwise", gridcolor=GRID_COLOR,
347
+ tickfont=dict(size=13, color=BRAND_TEAL, family=FONT_STACK))),
348
+ annotations=[dict(
349
+ text=message, xref="paper", yref="paper", x=0.5, y=0.08,
350
+ showarrow=False, font=dict(size=14, color="#A0AEC0"),
351
+ xanchor="center", yanchor="middle")],
352
+ showlegend=False, dragmode=False,
353
+ margin=dict(t=30, b=80, l=60, r=60),
354
+ height=700, autosize=True)
355
+ return fig
356
+
357
+
358
+ # --- Figure Builder ---
359
+ def build_radar_figure(radar_df, cols, scenario_col, highlight_scenarios,
360
+ highlight_colors, highlight_labels, title, max_tier=5, areas=None):
361
+ valid_scenarios = [s for s in highlight_scenarios if s in radar_df[scenario_col].values]
362
+ if len(valid_scenarios) < 1 or len(cols) < 3:
363
+ return get_empty_radar_figure()
364
+
365
+ invert = lambda x: (max_tier + 1) - x
366
+ axis_labels = [TIER_LABELS.get(c, c) for c in cols]
367
+ axis_labels_closed = axis_labels + [axis_labels[0]]
368
+
369
+ fig = go.Figure()
370
+
371
+ # Envelope: shaded band showing min/max range across ALL scenarios
372
+ all_data = radar_df.dropna(subset=cols)
373
+ if len(all_data) > 0:
374
+ raw_min = all_data[cols].min().values
375
+ raw_max = all_data[cols].max().values
376
+ inv_inner = np.clip(invert(raw_max), 1, max_tier)
377
+ inv_outer = np.clip(invert(raw_min), 1, max_tier)
378
+ inv_inner_closed = np.append(inv_inner, inv_inner[0]).tolist()
379
+ inv_outer_closed = np.append(inv_outer, inv_outer[0]).tolist()
380
+
381
+ fig.add_trace(go.Scatterpolar(
382
+ r=inv_inner_closed, theta=axis_labels_closed,
383
+ mode="lines", line=dict(color="rgba(0,0,0,0)", width=0),
384
+ showlegend=False, hoverinfo="skip"))
385
+ fig.add_trace(go.Scatterpolar(
386
+ r=inv_outer_closed, theta=axis_labels_closed,
387
+ mode="lines", fill="tonext",
388
+ fillcolor="rgba(19, 87, 115, 0.08)",
389
+ line=dict(color="rgba(19, 87, 115, 0.2)", width=1),
390
+ name="All Scenarios Range", hoverinfo="skip"))
391
+
392
+ # Highlighted scenario traces
393
+ for sc_id in valid_scenarios:
394
+ sc_idx = valid_scenarios.index(sc_id)
395
+ row = radar_df[radar_df[scenario_col] == sc_id]
396
+ if row.empty:
397
+ continue
398
+ raw_vals = row[cols].values.flatten().tolist()
399
+ inv_vals = np.clip(invert(row[cols].values.flatten()), 1, max_tier).tolist()
400
+ inv_vals_closed = inv_vals + [inv_vals[0]]
401
+
402
+ area_info = areas.get(sc_id, {}) if areas else {}
403
+ area_suffix = f"<br>Radar Area: {area_info['pct']:.1f}%" if area_info else ""
404
+ hover_texts = [f"{highlight_labels[sc_idx]}<br>{lbl}: Tier {rv:.1f}{area_suffix}" for lbl, rv in zip(axis_labels, raw_vals)]
405
+ hover_texts.append(hover_texts[0])
406
+
407
+ legend_name = f"{highlight_labels[sc_idx]} ({area_info['pct']:.0f}%)" if area_info else highlight_labels[sc_idx]
408
+ color = highlight_colors[sc_idx]
409
+ fig.add_trace(go.Scatterpolar(
410
+ r=inv_vals_closed, theta=axis_labels_closed,
411
+ mode="lines+markers",
412
+ line=dict(color=color, width=2.5),
413
+ marker=dict(size=8, color=color, line=dict(color="white", width=1)),
414
+ name=legend_name,
415
+ text=hover_texts, hoverinfo="text"))
416
+
417
+ # Axis tip hover anchors - category descriptions
418
+ tip_r = max_tier + 0.5
419
+ for i, (col, lbl) in enumerate(zip(cols, axis_labels)):
420
+ desc_key = _COL_TO_DESC_KEY.get(col)
421
+ if desc_key and desc_key in _AXIS_DESCRIPTIONS:
422
+ hover_text = f"<b>{lbl}</b><br>{_wrap_hover(_AXIS_DESCRIPTIONS[desc_key])}"
423
+ fig.add_trace(go.Scatterpolar(
424
+ r=[tip_r], theta=[lbl], mode="markers",
425
+ marker=dict(size=18, color="rgba(0,0,0,0.01)", symbol="circle"),
426
+ showlegend=False, hovertemplate=hover_text + "<extra></extra>",
427
+ hoverlabel=dict(bgcolor="white", bordercolor="grey", font=dict(size=12, color="black"))))
428
+
429
+ # Tier ring hover anchors - tier definitions at each axis x tier intersection
430
+ for i, (col, lbl) in enumerate(zip(cols, axis_labels)):
431
+ desc_key = _COL_TO_DESC_KEY.get(col)
432
+ if not desc_key or desc_key not in _TIER_DEFINITIONS:
433
+ continue
434
+ tier_defs = _TIER_DEFINITIONS[desc_key]
435
+ for tier in range(1, max_tier + 1):
436
+ tier_text = tier_defs.get(tier, "")
437
+ if not tier_text:
438
+ continue
439
+ inv_r = invert(tier)
440
+ hover_text = f"<b>{lbl} - Tier {tier}</b><br>{_wrap_hover(tier_text)}"
441
+ fig.add_trace(go.Scatterpolar(
442
+ r=[inv_r], theta=[lbl], mode="markers",
443
+ marker=dict(size=18, color="rgba(0,0,0,0.01)", symbol="circle"),
444
+ showlegend=False, hovertemplate=hover_text + "<extra></extra>",
445
+ hoverlabel=dict(bgcolor="white", bordercolor="grey", font=dict(size=12, color="black"))))
446
+
447
+ fig.update_layout(
448
+ polar=dict(
449
+ radialaxis=dict(
450
+ range=[0, max_tier + 0.5],
451
+ tickvals=[1, 2, 3, 4, 5],
452
+ ticktext=["", "Tier 4", "Tier 3", "Tier 2", "Tier 1"],
453
+ showline=False, gridcolor=GRID_COLOR),
454
+ angularaxis=dict(
455
+ direction="clockwise", gridcolor=GRID_COLOR,
456
+ tickfont=dict(size=13, color=BRAND_TEAL, family=FONT_STACK))),
457
+ showlegend=True, dragmode=False,
458
+ legend=dict(orientation="h", yanchor="top", y=-0.12, xanchor="center", x=0.5, font=dict(size=12, color=TEXT_MUTED)),
459
+ margin=dict(t=30, b=80, l=60, r=60),
460
+ height=700, autosize=True)
461
+ return fig
462
+
463
+
464
+ # --- Sidebar Controls (for the sidebar) ---
465
+ def get_sidebar_controls(scen_groups_store_init):
466
+ group_opts = [{"label": k, "value": k} for k in sorted(scen_groups_store_init.keys())]
467
+ scenario_opts = [{"label": s, "value": s} for s in RADAR_SCENARIOS]
468
+ axes_opts = [{"label": TIER_LABELS.get(c, c), "value": c} for c in ALL_TIER_COLS]
469
+
470
+ return html.Div([
471
+ html.Div("Scenario Group", style=SECTION_HEADER),
472
+ dcc.Dropdown(id="radar-group-dropdown", options=group_opts, placeholder="Choose group..."),
473
+ html.Div(id="radar-group-desc", style=DESCRIPTION_STYLE),
474
+ html.Button("Add to Selection", id="radar-add-group-btn", n_clicks=0, className="btn-primary", style={**PRIMARY_BUTTON, "marginBottom": "16px"}),
475
+
476
+ html.Div("Scenarios", style=SECTION_HEADER),
477
+ dcc.Dropdown(id="radar-scenario-dropdown", options=scenario_opts, value=[], multi=True, placeholder="Add/remove scenarios..."),
478
+ html.Div(id="radar-scenario-desc", style=DESCRIPTION_STYLE),
479
+
480
+ html.Div("Tier Groups", style=SECTION_HEADER),
481
+ dcc.Dropdown(id="radar-tier-preset", options=[{"label": k, "value": k} for k in RADAR_PRESETS], placeholder="Choose group...", clearable=True),
482
+
483
+ html.Div("Tier Categories", style=SECTION_HEADER),
484
+ dcc.Dropdown(id="radar-axes-checklist", options=axes_opts, value=list(ALL_TIER_COLS), multi=True, placeholder="Select tiers..."),
485
+ html.Div(id="radar-axes-warning", style={"color": DANGER_RED, "marginTop": "5px", "fontSize": "12px", "fontWeight": "500"}),
486
+
487
+ html.Div(
488
+ "Tier 1 (best) is outermost. Shaded band shows full scenario range. Click legend to toggle.",
489
+ style={**DESCRIPTION_STYLE, "marginTop": "16px"}),
490
+ ])
491
+
492
+
493
+ # --- Main Content (chart area) ---
494
+ def get_content():
495
+ _toggle_style = {"backgroundColor": "white", "color": BRAND_TEAL, "border": f"1px solid {BORDER_LIGHT}", "borderRadius": "6px", "padding": "8px 20px", "fontSize": "13px", "fontWeight": "600", "cursor": "pointer"}
496
+ _area_card = {"backgroundColor": "white", "border": f"1px solid {BORDER_LIGHT}", "borderRadius": "8px", "padding": "16px", "minWidth": "260px", "maxWidth": "300px", "alignSelf": "flex-start", "position": "sticky", "top": "24px"}
497
+ return html.Div([
498
+ html.H3(id="title-radar", style={"textAlign": "center", "marginTop": "0", "marginBottom": "8px", "color": BRAND_TEAL}),
499
+
500
+ # Radar chart + area panel side by side
501
+ html.Div(style={"display": "flex", "gap": "16px", "alignItems": "flex-start"}, children=[
502
+ dcc.Graph(id="plot-radar", figure=get_empty_radar_figure(), config={"scrollZoom": False, "displayModeBar": False}, responsive=True, style={"height": "75vh", "minHeight": "500px", "flex": "1"}),
503
+
504
+ # Radar Area panel (right side)
505
+ html.Div(id="radar-area-panel", style=_area_card, children=[
506
+ html.Div(style={"display": "flex", "justifyContent": "space-between", "gap": "16px", "marginBottom": "10px", "paddingBottom": "6px", "borderBottom": f"1px solid {BORDER_LIGHT}"}, children=[
507
+ html.Div("Scenario", style={"fontSize": "11px", "fontWeight": "700", "color": BRAND_TEAL, "textTransform": "uppercase", "letterSpacing": "1px", "flex": "1"}),
508
+ html.Div("Area", style={"fontSize": "11px", "fontWeight": "700", "color": BRAND_TEAL, "textTransform": "uppercase", "letterSpacing": "1px", "width": "60px", "textAlign": "left", "paddingLeft": "12px"}),
509
+ html.Div("Per. Conv.", style={"fontSize": "11px", "fontWeight": "700", "color": BRAND_TEAL, "textTransform": "uppercase", "letterSpacing": "1px", "width": "80px", "textAlign": "left"}),
510
+ html.Div("Area Conv.", style={"fontSize": "11px", "fontWeight": "700", "color": BRAND_TEAL, "textTransform": "uppercase", "letterSpacing": "1px", "width": "80px", "textAlign": "left"}),
511
+ ]),
512
+ html.Div(id="radar-area-list", children=[
513
+ html.Div("Select scenarios", style={"fontSize": "12px", "color": TEXT_MUTED}),
514
+ ]),
515
+ ]),
516
+ ]),
517
+ download_buttons("plot-radar"),
518
+
519
+ # Distribution toggle
520
+ html.Div(style={"textAlign": "center", "marginTop": "12px"}, children=[
521
+ html.Button("Generate Static Plot with Overlaid KDE & Histogram Distribution", id="radar-dist-toggle", n_clicks=0, style=_toggle_style),
522
+ ]),
523
+ html.Div(id="radar-dist-container", style={"display": "none"}, children=[
524
+ dcc.Loading(type="circle", color=BRAND_TEAL, children=[
525
+ html.Img(id="radar-dist-img", style={"maxWidth": "100%", "margin": "12px auto", "display": "block"}),
526
+ ]),
527
+ download_buttons("radar-dist-img"),
528
+ ]),
529
+
530
+ # Data table
531
+ html.Div(id="radar-table-container", style={"marginTop": "20px"}, children=[
532
+ html.H4("Scenario Data", style={"color": BRAND_TEAL, "fontSize": "14px", "fontWeight": "600", "marginBottom": "8px"}),
533
+ dash_table.DataTable(id="radar-data-table", columns=[], data=[], sort_action="native", style_table={"overflowX": "auto"}, style_header=_TABLE_HEADER, style_cell=_TABLE_CELL),
534
+ download_buttons("radar-data-table", has_plot=False),
535
+ ]),
536
+ ])
537
+
538
+
539
+ # --- Callbacks ---
540
+ def register_callbacks(app, scen_groups_store_init):
541
+ @app.callback(
542
+ Output("radar-group-desc", "children"),
543
+ Input("radar-group-dropdown", "value"),
544
+ prevent_initial_call=True,
545
+ )
546
+ def show_radar_group_desc(group_name):
547
+ if not group_name or group_name not in scen_groups_store_init:
548
+ return ""
549
+ info = scen_groups_store_init[group_name]
550
+ desc = info.get("description", "")
551
+ scs = info.get("scenarios", [])
552
+ return f"{desc} ({', '.join(scs)})" if scs else desc
553
+
554
+ @app.callback(
555
+ Output("radar-scenario-dropdown", "value"),
556
+ Input("radar-add-group-btn", "n_clicks"),
557
+ State("radar-group-dropdown", "value"),
558
+ State("radar-scenario-dropdown", "value"),
559
+ prevent_initial_call=True,
560
+ )
561
+ def add_radar_group_to_selection(_, group_name, current):
562
+ from dash.exceptions import PreventUpdate
563
+ if not group_name or group_name not in scen_groups_store_init:
564
+ raise PreventUpdate
565
+ current = list(current or [])
566
+ scs = scen_groups_store_init[group_name].get("scenarios", [])
567
+ available = set(RADAR_SCENARIOS)
568
+ valid = [s for s in scs if s in available]
569
+ return list(dict.fromkeys(current + valid))
570
+
571
+ @app.callback(
572
+ Output("radar-scenario-desc", "children"),
573
+ Input("radar-scenario-dropdown", "value"),
574
+ )
575
+ def display_radar_scenario_desc(scen_list):
576
+ if not scen_list or _df_scen.empty:
577
+ return "Select scenarios to see descriptions"
578
+ col_idx = next((c for c in _df_scen.columns if "Index" in c), _df_scen.columns[0])
579
+ col_desc = next((c for c in _df_scen.columns if "ShortDescription" in c or "Description" in c), _df_scen.columns[1] if len(_df_scen.columns) > 1 else _df_scen.columns[0])
580
+ defs = _df_scen[_df_scen[col_idx].isin(scen_list)].set_index(col_idx)[col_desc].to_dict()
581
+ return [html.Div([html.B(f"{s}: "), html.Span(d)]) for s, d in defs.items()]
582
+
583
+ @app.callback(
584
+ [Output("radar-axes-checklist", "value"), Output("radar-tier-preset", "value")],
585
+ Input("radar-tier-preset", "value"),
586
+ prevent_initial_call=True,
587
+ )
588
+ def radar_preset_to_tiers(preset):
589
+ from dash.exceptions import PreventUpdate
590
+ if not preset:
591
+ raise PreventUpdate
592
+ return RADAR_PRESETS.get(preset, list(CORE_TIER_COLS)), None
593
+
594
+ @app.callback(
595
+ [Output("plot-radar", "figure"),
596
+ Output("title-radar", "children"),
597
+ Output("radar-axes-warning", "children"),
598
+ Output("radar-area-list", "children"),
599
+ Output("radar-data-table", "columns"),
600
+ Output("radar-data-table", "data")],
601
+ [Input("radar-scenario-dropdown", "value"),
602
+ Input("radar-axes-checklist", "value")],
603
+ )
604
+ def update_radar_plot(scenarios_selected, axes_selected):
605
+ axes_selected = list(axes_selected or [])
606
+ scenarios_selected = list(scenarios_selected or [])
607
+ empty_area = html.Div("Select scenarios", style={"fontSize": "12px", "color": TEXT_MUTED})
608
+ empty_table = ([], [])
609
+
610
+ if len(axes_selected) < 3:
611
+ warning = f"Select at least 3 tiers ({len(axes_selected)} selected)."
612
+ return get_empty_radar_figure(cols=axes_selected), "", warning, empty_area, *empty_table
613
+
614
+ if not scenarios_selected:
615
+ return get_empty_radar_figure(cols=axes_selected), "Select scenarios to compare", "", empty_area, *empty_table
616
+
617
+ # Compute areas for all scenarios (used in figure + area panel + table)
618
+ sel_df = tier_df[tier_df["scenario"].isin(scenarios_selected)]
619
+ areas = compute_scenario_areas(sel_df, axes_selected)
620
+ convexity = compute_scenario_convexity(sel_df, axes_selected)
621
+ area_convexity = compute_scenario_area_convexity(sel_df, axes_selected)
622
+
623
+ colors = [SCENARIO_COLORS[i % len(SCENARIO_COLORS)] for i in range(len(scenarios_selected))]
624
+ labels = list(scenarios_selected)
625
+ fig = build_radar_figure(tier_df, axes_selected, "scenario", scenarios_selected, colors, labels, "", areas=areas)
626
+ if len(fig.data) == 0:
627
+ return get_empty_radar_figure("No matching tier data"), "No matching tier data for selected scenarios", "", empty_area, *empty_table
628
+
629
+ title = f"Comparing {len(scenarios_selected)} Scenarios"
630
+
631
+ # Radar Area panel - sorted by area descending
632
+ sorted_scens = sorted(scenarios_selected, key=lambda s: areas.get(s, {}).get("pct", 0), reverse=True)
633
+ area_items = []
634
+ for i, scen in enumerate(sorted_scens):
635
+ pct = areas.get(scen, {}).get("pct", 0)
636
+ color = colors[scenarios_selected.index(scen)]
637
+ area_items.append(html.Div(style={"display": "flex", "alignItems": "center", "justifyContent": "space-between", "padding": "6px 0", "borderBottom": f"1px solid {BORDER_LIGHT}" if i < len(sorted_scens) - 1 else "none"}, children=[
638
+ html.Div(style={"display": "flex", "alignItems": "center", "gap": "8px", "flex": "1"}, children=[
639
+ html.Div(style={"width": "10px", "height": "10px", "borderRadius": "50%", "backgroundColor": color, "flexShrink": "0"}),
640
+ html.Span(scen, style={"fontSize": "12px", "fontWeight": "600", "color": "#1A202C"}),
641
+ ]),
642
+ html.Div(style={"display": "flex", "gap": "8px", "alignItems": "center"}, children=[
643
+ html.Span(f"{pct:.1f}%", style={"fontSize": "13px", "fontWeight": "700", "color": BRAND_TEAL, "width": "60px", "textAlign": "right"}),
644
+ html.Span(f"{convexity.get(scen, float('nan')):.1f}%", style={"fontSize": "13px", "fontWeight": "700", "color": BRAND_TEAL, "width": "80px", "textAlign": "right"}),
645
+ html.Span(f"{area_convexity.get(scen, float('nan')):.1f}%", style={"fontSize": "13px", "fontWeight": "700", "color": BRAND_TEAL, "width": "80px", "textAlign": "left"}),
646
+ ]),
647
+ ]))
648
+
649
+ # Build table (tier data only, no area column)
650
+ table_cols = [{"name": "Scenario", "id": "scenario"}]
651
+ table_cols += [{"name": TIER_LABELS.get(c, c), "id": c, "type": "numeric", "format": Format(precision=2, scheme=Scheme.fixed)} for c in axes_selected]
652
+
653
+ table_data = []
654
+ for scen in scenarios_selected:
655
+ row = sel_df[sel_df["scenario"] == scen]
656
+ if row.empty:
657
+ continue
658
+ entry = {"scenario": scen}
659
+ for c in axes_selected:
660
+ entry[c] = round(float(row[c].values[0]), 2) if c in row.columns and pd.notna(row[c].values[0]) else None
661
+ table_data.append(entry)
662
+
663
+ return fig, title, "", area_items, table_cols, table_data
664
+
665
+ @app.callback(
666
+ [Output("radar-dist-container", "style"),
667
+ Output("radar-dist-img", "src"),
668
+ Output("radar-dist-toggle", "children")],
669
+ [Input("radar-dist-toggle", "n_clicks"),
670
+ Input("radar-scenario-dropdown", "value"),
671
+ Input("radar-axes-checklist", "value")],
672
+ )
673
+ def update_distribution_view(n_clicks, scenarios, axes):
674
+ visible = bool(n_clicks and n_clicks % 2 == 1)
675
+ btn_text = "Hide Static Plot" if visible else "Generate Static Plot with Overlaid KDE & Histogram Distribution"
676
+ if not visible:
677
+ return {"display": "none"}, "", btn_text
678
+ scenarios = list(scenarios or [])
679
+ axes = list(axes or [])
680
+ if len(axes) < 3 or not scenarios:
681
+ return {"display": "block"}, "", btn_text
682
+ colors = [SCENARIO_COLORS[i % len(SCENARIO_COLORS)] for i in range(len(scenarios))]
683
+ src = render_distribution_png(tier_df, axes, scenarios, colors, scenarios)
684
+ return {"display": "block"}, src, btn_text
685
+
686
+ # --- Download Callbacks ---
687
+ register_plotly_png_callback(app, "plot-radar")
688
+ register_base64_png_callback(app, "radar-dist-img", "dl-png-radar-dist-img")
689
+
690
+ @app.callback(
691
+ Output("dl-csv-plot-radar", "data"),
692
+ Input("dl-csv-btn-plot-radar", "n_clicks"),
693
+ [State("radar-scenario-dropdown", "value"), State("radar-axes-checklist", "value")],
694
+ prevent_initial_call=True,
695
+ )
696
+ def download_radar_csv(n_clicks, scenarios, axes):
697
+ from dash.exceptions import PreventUpdate
698
+ if not n_clicks or not scenarios or not axes:
699
+ raise PreventUpdate
700
+ out = tier_df[tier_df["scenario"].isin(scenarios)][["scenario"] + list(axes)]
701
+ return dcc.send_data_frame(out.to_csv, "radar_data.csv", index=False)
702
+
703
+ @app.callback(
704
+ Output("dl-csv-radar-dist-img", "data"),
705
+ Input("dl-csv-btn-radar-dist-img", "n_clicks"),
706
+ [State("radar-scenario-dropdown", "value"), State("radar-axes-checklist", "value")],
707
+ prevent_initial_call=True,
708
+ )
709
+ def download_dist_csv(n_clicks, scenarios, axes):
710
+ from dash.exceptions import PreventUpdate
711
+ if not n_clicks or not axes:
712
+ raise PreventUpdate
713
+ out = tier_df[["scenario"] + list(axes)]
714
+ return dcc.send_data_frame(out.to_csv, "radar_distribution.csv", index=False)
715
+
716
+ @app.callback(
717
+ Output("dl-csv-radar-data-table", "data"),
718
+ Input("dl-csv-btn-radar-data-table", "n_clicks"),
719
+ State("radar-data-table", "data"),
720
+ prevent_initial_call=True,
721
+ )
722
+ def download_table_csv(n_clicks, table_data):
723
+ from dash.exceptions import PreventUpdate
724
+ if not n_clicks or not table_data:
725
+ raise PreventUpdate
726
+ out = pd.DataFrame(table_data)
727
+ return dcc.send_data_frame(out.to_csv, "radar_table.csv", index=False)
resilience.py CHANGED
@@ -1,867 +1,835 @@
1
- import dash
2
- import numpy as np
3
- import pandas as pd
4
- import plotly.graph_objs as go
5
- from dash import dcc, html, callback_context
6
- from dash.dependencies import Input, Output, State
7
-
8
- from theme import (
9
- BRAND_TEAL, TEXT_MUTED, FONT_STACK, BORDER_LIGHT,
10
- SECTION_HEADER, DESCRIPTION_STYLE, MODERN_CARD,
11
- )
12
- from downloads import download_buttons
13
-
14
- # contibuous or discrete tiers
15
- ContinuousTiers = True
16
- if ContinuousTiers:
17
- tier_data = "data/tier_df_continuous.csv"
18
- else:
19
- tier_data = "data/tier_df.csv"
20
-
21
- # --- Tier Color Scale (discrete 4-tier: green, blue, orange, red) ---
22
- TIER_COLORS = {1: "#2E7D32", 2: "#1565C0", 3: "#EF6C00", 4: "#C62828"}
23
- TIER_BG_COLORS = {1: "rgba(46,125,50,0.15)", 2: "rgba(21,101,192,0.15)", 3: "rgba(239,108,0,0.15)", 4: "rgba(200,40,40,0.15)"}
24
-
25
- # Discrete colorscale for Plotly heatmap (sharp boundaries at tier transitions)
26
- _DISCRETE_COLORSCALE = [
27
- [0.0, "#2E7D32"], [0.25, "#2E7D32"],
28
- [0.25, "#1565C0"], [0.5, "#1565C0"],
29
- [0.5, "#EF6C00"], [0.75, "#EF6C00"],
30
- [0.75, "#C62828"], [1.0, "#C62828"],
31
- ]
32
-
33
- # --- Data Loading ---
34
- import json
35
-
36
- MAPPING_PATH = "data/strategy_mapping.csv"
37
-
38
- _tier_df_real = pd.read_csv(tier_data)
39
- _tier_df_synthetic = pd.read_csv("data/tier_df_synthetic.csv")
40
-
41
- # LOI-level tier data for drill-down
42
- try:
43
- _loi_tiers = pd.read_csv("data/loi_tiers.csv")
44
- with open("data/loi_mapping.json") as f:
45
- LOI_MAPPING = json.load(f)
46
- HAS_LOI_DATA = True
47
- except Exception:
48
- _loi_tiers = pd.DataFrame()
49
- LOI_MAPPING = {}
50
- HAS_LOI_DATA = False
51
-
52
- # Real data needs strategy/hydroclimate columns joined from synthetic (which has them baked in)
53
- # Build a lookup from scenario -> (strategy, hydroclimate) from synthetic
54
- _scen_meta = _tier_df_synthetic[["scenario", "strategy", "hydroclimate"]].drop_duplicates()
55
- _tier_df_real = _tier_df_real.merge(_scen_meta, on="scenario", how="left")
56
-
57
- TIER_DATA = {"synthetic": _tier_df_synthetic, "real": _tier_df_real}
58
-
59
- res_tier_df = _tier_df_synthetic # default for module-level constants
60
- strategy_map = pd.read_csv(MAPPING_PATH)
61
-
62
- TIER_COLS = [c for c in res_tier_df.columns if c not in ("scenario", "strategy", "hydroclimate") and not c.endswith("_Std")]
63
- HYDROCLIMATE_ORDER = ["Historical", "CC50", "CC95", "TAIESM1", "EC-Earth3", "CESM2"]
64
-
65
- # --- Tier Descriptions (hover text) ---
66
- _tier_desc_df = pd.read_csv("data/tiers_descriptions.csv")
67
- _tier_desc_df.columns = _tier_desc_df.columns.str.strip()
68
- _tier_desc_df["Outcomes/ Tiers"] = _tier_desc_df["Outcomes/ Tiers"].str.strip()
69
-
70
- # Map our column names to CSV keys
71
- _COL_TO_DESC_KEY = {
72
- "NOD_GW_Mean": "NOD GW", "SOD_GW_Mean": "SOD GW",
73
- "NOD_Reservoir_Mean": "NOD Reservoir", "SOD_Reservoir_Mean": "SOD Reservoir",
74
- "Exports_and_Salinity": "Exports and Salinity", "InDelta_Salinity": "InDelta Salinity",
75
- "NOD_Ag_Mean": "NOD Ag", "SOD_Ag_Mean": "SOD Ag",
76
- "NOD_DW_Mean": "NOD DW", "SOD_DW_Mean": "SOD DW",
77
- "NOD_Eflows_Mean": "NOD Eflows", "SOD_Eflows_Mean": "SOD Eflows",
78
- "Delta_Ecology": "Delta Ecology", "Salmon_Abundance": "Salmon Abundance",
79
- }
80
-
81
- AXIS_DESCRIPTIONS = {}
82
- TIER_DEFINITIONS = {}
83
- for _, row in _tier_desc_df.iterrows():
84
- key = row["Outcomes/ Tiers"]
85
- AXIS_DESCRIPTIONS[key] = str(row["Description"]).strip()
86
- TIER_DEFINITIONS[key] = {1: str(row["Tier1"]).strip(), 2: str(row["Tier2"]).strip(), 3: str(row["Tier3"]).strip(), 4: str(row["Tier4"]).strip()}
87
-
88
-
89
- def _get_tier_hover(col, val):
90
- """Get tier definition text for a column and tier value."""
91
- desc_key = _COL_TO_DESC_KEY.get(col)
92
- if not desc_key or pd.isna(val):
93
- return ""
94
- tier_int = int(round(val))
95
- tier_int = max(1, min(4, tier_int))
96
- defs = TIER_DEFINITIONS.get(desc_key, {})
97
- tier_text = defs.get(tier_int, "")
98
- if len(tier_text) > 120:
99
- tier_text = tier_text[:120] + "..."
100
- return f"<br><i>Tier {tier_int}: {tier_text}</i>"
101
-
102
- # Hydroclimate metadata for display
103
- HYDROCLIMATE_META = {
104
- "Historical": {"group": "DWR", "desc": "2023 DWR Historical Adjusted"},
105
- "CC50": {"group": "DWR", "desc": "50th percentile climate change (2043)"},
106
- "CC95": {"group": "DWR", "desc": "95th percentile climate change (2043)"},
107
- "TAIESM1": {"group": "GCM", "desc": "TaiESM1 climate projection"},
108
- "EC-Earth3": {"group": "GCM", "desc": "EC-Earth3 climate projection"},
109
- "CESM2": {"group": "GCM", "desc": "CESM2 climate projection"},
110
- }
111
-
112
- # Separator position: after CC95 (index 2), before TAIESM1 (index 3)
113
- _DWR_GCM_BOUNDARY = 3
114
- STRATEGIES = sorted(strategy_map["strategy"].tolist())
115
-
116
- TIER_LABELS = {
117
- "NOD_GW_Mean": "NOD Groundwater", "SOD_GW_Mean": "SOD Groundwater",
118
- "NOD_Reservoir_Mean": "NOD Reservoir", "SOD_Reservoir_Mean": "SOD Reservoir",
119
- "Exports_and_Salinity": "Exports and Salinity", "InDelta_Salinity": "In-Delta Salinity",
120
- "NOD_Ag_Mean": "NOD Agriculture", "SOD_Ag_Mean": "SOD Agriculture",
121
- "NOD_DW_Mean": "NOD Drinking Water", "SOD_DW_Mean": "SOD Drinking Water",
122
- "NOD_Eflows_Mean": "NOD Env. Flows", "SOD_Eflows_Mean": "SOD Env. Flows",
123
- "Delta_Ecology": "Delta Ecology", "Salmon_Abundance": "Salmon Abundance",
124
- }
125
-
126
- # Strategy descriptions from mapping
127
- STRATEGY_DESC = dict(zip(strategy_map["strategy"], strategy_map["description"]))
128
- STRATEGY_THEME = dict(zip(strategy_map["strategy"], strategy_map["theme"]))
129
-
130
- # --- Toggle Styles (matching plines.py) ---
131
- _TOGGLE_BTN_BASE = {
132
- "padding": "7px 24px", "border": "none", "borderRadius": "6px",
133
- "fontSize": "13px", "fontWeight": "600", "cursor": "pointer",
134
- "textAlign": "center", "transition": "all 0.15s ease",
135
- }
136
- _TOGGLE_ACTIVE = {**_TOGGLE_BTN_BASE, "backgroundColor": "white", "color": BRAND_TEAL, "boxShadow": "0 1px 3px rgba(0,0,0,0.1)"}
137
- _TOGGLE_INACTIVE = {**_TOGGLE_BTN_BASE, "backgroundColor": "transparent", "color": TEXT_MUTED}
138
-
139
-
140
- def _get_strategy_options():
141
- opts = []
142
- for s in STRATEGIES:
143
- theme = STRATEGY_THEME.get(s, "")
144
- label = f"{s} ({theme})" if theme else s
145
- opts.append({"label": label, "value": s})
146
- return opts
147
-
148
-
149
- def _get_row_options():
150
- """Build dropdown options for every strategy x hydroclimate combo, grouped by strategy."""
151
- opts = []
152
- for _, mrow in strategy_map.iterrows():
153
- strat = mrow["strategy"]
154
- theme = STRATEGY_THEME.get(strat, "")
155
- for hc in HYDROCLIMATE_ORDER:
156
- hc_col = {"Historical": "historical", "CC50": "cc50", "CC95": "cc95", "TAIESM1": "taiesm1", "EC-Earth3": "ec_earth3", "CESM2": "cesm2"}.get(hc)
157
- if hc_col and pd.notna(mrow.get(hc_col)):
158
- scen = mrow[hc_col]
159
- value = f"{strat}|{hc}"
160
- label = f"{strat} - {hc} ({scen}) [{theme}]"
161
- opts.append({"label": label, "value": value})
162
- return opts
163
-
164
-
165
- def get_sidebar_controls():
166
- _data_toggle = {"padding": "4px 12px", "border": "none", "borderRadius": "4px", "fontSize": "11px", "fontWeight": "600", "cursor": "pointer", "transition": "all 0.15s ease"}
167
- _data_active = {**_data_toggle, "backgroundColor": BRAND_TEAL, "color": "white"}
168
- _data_inactive = {**_data_toggle, "backgroundColor": "transparent", "color": TEXT_MUTED}
169
- hc_options = [{"label": hc, "value": hc} for hc in HYDROCLIMATE_ORDER]
170
- strategy_multi_opts = _get_strategy_options()
171
- return html.Div([
172
- # View 1 controls: single strategy + hydroclimates
173
- html.Div(id="res-sidebar-scenario", children=[
174
- html.Div("Strategy", style=SECTION_HEADER),
175
- dcc.Dropdown(id="res-strategy-dropdown", options=strategy_multi_opts, placeholder="Choose strategy...", clearable=False),
176
- html.Div(id="res-strategy-desc", style=DESCRIPTION_STYLE),
177
- html.Div("Hydroclimates", style={**SECTION_HEADER, "marginTop": "16px"}),
178
- dcc.Dropdown(id="res-hc-dropdown", options=hc_options, value=list(HYDROCLIMATE_ORDER), multi=True, placeholder="Select hydroclimates..."),
179
- ]),
180
-
181
- # View 2 controls: strategy picker + hydroclimate checklist per strategy
182
- html.Div(id="res-sidebar-system", style={"display": "none"}, children=[
183
- html.Div("Strategies", style=SECTION_HEADER),
184
- dcc.Dropdown(id="res-strategies-multi", options=strategy_multi_opts, value=[], multi=True, placeholder="Select strategies..."),
185
- html.Div(id="res-hc-checklist-area", style={"marginTop": "10px"}),
186
- dcc.Store(id="res-system-rows-store", data=[]),
187
- ]),
188
-
189
- # View 3 controls: outcome picker + strategy multi-select
190
- html.Div(id="res-sidebar-outcome", style={"display": "none"}, children=[
191
- html.Div("Outcome", style=SECTION_HEADER),
192
- dcc.Dropdown(id="res-outcome-dropdown", options=[{"label": TIER_LABELS.get(c, c), "value": c} for c in TIER_COLS], placeholder="Choose sector...", clearable=False),
193
- html.Div("Strategies", style={**SECTION_HEADER, "marginTop": "16px"}),
194
- dcc.Dropdown(id="res-outcome-strategies", options=strategy_multi_opts, value=[], multi=True, placeholder="Select strategies to compare..."),
195
- html.Div(id="res-loi-filter-area", style={"marginTop": "12px"}, children=[
196
- html.Div(id="res-loi-filter-content", style={"display": "none"}, children=[
197
- html.Div(id="res-loi-title", style={**SECTION_HEADER, "fontSize": "11px"}),
198
- dcc.Dropdown(id="res-loi-checklist", options=[], value=[], multi=True, placeholder="Filter LOIs..."),
199
- ]),
200
- html.Div(id="res-loi-no-lois", style={"display": "none"}, children=[
201
- html.Div("No sub-LOIs (scalar tier)", style={"fontSize": "10px", "color": TEXT_MUTED}),
202
- ]),
203
- ]),
204
- ]),
205
- ])
206
-
207
-
208
- def get_content():
209
- _data_toggle = {"padding": "4px 10px", "border": "none", "borderRadius": "4px", "fontSize": "10px", "fontWeight": "600", "cursor": "pointer", "transition": "all 0.15s ease"}
210
- _data_active = {**_data_toggle, "backgroundColor": BRAND_TEAL, "color": "white"}
211
- _data_inactive = {**_data_toggle, "backgroundColor": "transparent", "color": TEXT_MUTED}
212
- return html.Div([
213
- # Top bar: view toggle (center) + data source (right)
214
- html.Div(style={"display": "flex", "alignItems": "center", "justifyContent": "center", "position": "relative", "marginBottom": "16px"}, children=[
215
- html.Div(style={"display": "inline-flex", "gap": "4px", "padding": "4px", "backgroundColor": "#EDF2F7", "borderRadius": "8px"}, children=[
216
- html.Button("Scenario", id="res-toggle-scenario", n_clicks=0, style=_TOGGLE_ACTIVE),
217
- html.Button("Outcome", id="res-toggle-outcome", n_clicks=0, style=_TOGGLE_INACTIVE),
218
- html.Button("System-Wide", id="res-toggle-system", n_clicks=0, style=_TOGGLE_INACTIVE),
219
- ]),
220
- # Data source toggle - top right
221
- html.Div(style={"position": "absolute", "right": "0", "display": "flex", "alignItems": "center", "gap": "6px"}, children=[
222
- html.Span("Data:", style={"fontSize": "10px", "color": TEXT_MUTED, "fontWeight": "600"}),
223
- html.Div(style={"display": "inline-flex", "gap": "2px", "padding": "2px", "backgroundColor": "#EDF2F7", "borderRadius": "4px"}, children=[
224
- html.Button("Synthetic", id="res-data-synthetic", n_clicks=1, style=_data_active),
225
- html.Button("Real", id="res-data-real", n_clicks=0, style=_data_inactive),
226
- ]),
227
- dcc.Store(id="res-data-source", data="synthetic"),
228
- html.Div(id="res-data-note", style={"display": "none"}),
229
- ]),
230
- ]),
231
-
232
- # View 1: Scenario-specific
233
- html.Div(id="res-view-scenario", style={"display": "block"}, children=[
234
- html.H3(id="res-title-scenario", children="Select a strategy to view resilience", style={"textAlign": "center", "marginTop": "0", "marginBottom": "8px", "color": BRAND_TEAL}),
235
- dcc.Graph(id="res-plot-scenario", figure=go.Figure(), config={"displayModeBar": False, "scrollZoom": False}, style={"display": "none"}),
236
- html.Div(id="res-stress-row"),
237
- html.Div(id="res-legend-row"),
238
- download_buttons("res-plot-scenario"),
239
- ]),
240
-
241
- # View 2: System-wide
242
- html.Div(id="res-view-system", style={"display": "none"}, children=[
243
- html.H3(id="res-title-system", children="Select strategies to compare", style={"textAlign": "center", "marginTop": "0", "marginBottom": "8px", "color": BRAND_TEAL}),
244
- dcc.Graph(id="res-plot-system", figure=go.Figure(), config={"displayModeBar": False, "scrollZoom": False}, style={"display": "none"}),
245
- html.Div(id="res-stress-row-system"),
246
- html.Div(id="res-legend-row-system"),
247
- download_buttons("res-plot-system"),
248
- ]),
249
-
250
- # View 3: Outcome-specific
251
- html.Div(id="res-view-outcome", style={"display": "none"}, children=[
252
- html.H3(id="res-title-outcome", children="Select a sector and strategies", style={"textAlign": "center", "marginTop": "0", "marginBottom": "8px", "color": BRAND_TEAL}),
253
- dcc.Graph(id="res-plot-outcome", figure=go.Figure(), config={"displayModeBar": False, "scrollZoom": False}, style={"display": "none"}),
254
- html.Div(id="res-legend-row-outcome"),
255
- download_buttons("res-plot-outcome"),
256
- ]),
257
- ])
258
-
259
-
260
- def register_callbacks(app):
261
- # --- View toggle ---
262
- @app.callback(
263
- [Output("res-view-scenario", "style"),
264
- Output("res-view-system", "style"),
265
- Output("res-view-outcome", "style"),
266
- Output("res-sidebar-scenario", "style"),
267
- Output("res-sidebar-system", "style"),
268
- Output("res-sidebar-outcome", "style"),
269
- Output("res-toggle-scenario", "style"),
270
- Output("res-toggle-system", "style"),
271
- Output("res-toggle-outcome", "style")],
272
- [Input("res-toggle-scenario", "n_clicks"),
273
- Input("res-toggle-system", "n_clicks"),
274
- Input("res-toggle-outcome", "n_clicks")],
275
- prevent_initial_call=True,
276
- )
277
- def toggle_view(scen_clicks, sys_clicks, out_clicks):
278
- ctx = callback_context
279
- show, hide = {"display": "block"}, {"display": "none"}
280
- triggered_id = ctx.triggered[0]["prop_id"].split(".")[0] if ctx.triggered else ""
281
-
282
- if triggered_id == "res-toggle-system":
283
- return (hide, show, hide, hide, show, hide, _TOGGLE_INACTIVE, _TOGGLE_ACTIVE, _TOGGLE_INACTIVE)
284
- elif triggered_id == "res-toggle-outcome":
285
- return (hide, hide, show, hide, hide, show, _TOGGLE_INACTIVE, _TOGGLE_INACTIVE, _TOGGLE_ACTIVE)
286
- return (show, hide, hide, show, hide, hide, _TOGGLE_ACTIVE, _TOGGLE_INACTIVE, _TOGGLE_INACTIVE)
287
-
288
- # --- Data source toggle ---
289
- @app.callback(
290
- [Output("res-data-source", "data"),
291
- Output("res-data-synthetic", "style"),
292
- Output("res-data-real", "style"),
293
- Output("res-data-note", "children")],
294
- [Input("res-data-synthetic", "n_clicks"),
295
- Input("res-data-real", "n_clicks")],
296
- prevent_initial_call=True,
297
- )
298
- def toggle_data_source(syn_clicks, real_clicks):
299
- ctx = callback_context
300
- triggered_id = ctx.triggered[0]["prop_id"].split(".")[0] if ctx.triggered else ""
301
- _on = {"padding": "4px 12px", "border": "none", "borderRadius": "4px", "fontSize": "11px", "fontWeight": "600", "cursor": "pointer", "transition": "all 0.15s ease", "backgroundColor": BRAND_TEAL, "color": "white"}
302
- _off = {"padding": "4px 12px", "border": "none", "borderRadius": "4px", "fontSize": "11px", "fontWeight": "600", "cursor": "pointer", "transition": "all 0.15s ease", "backgroundColor": "transparent", "color": TEXT_MUTED}
303
- if triggered_id == "res-data-real":
304
- return "real", _off, _on, "Real data (3 hydroclimates, some sectors incomplete)"
305
- return "synthetic", _on, _off, "Complete data (6 hydroclimates, all sectors filled)"
306
-
307
- # --- View 2: Strategy -> hydroclimate checklist ---
308
- @app.callback(
309
- Output("res-hc-checklist-area", "children"),
310
- Input("res-strategies-multi", "value"),
311
- State("res-system-rows-store", "data"),
312
- )
313
- def build_hc_checklists(strategies, existing_rows):
314
- strategies = list(strategies or [])
315
- if not strategies:
316
- return html.Div("Select strategies above", style={"fontSize": "11px", "color": TEXT_MUTED, "padding": "8px 0"})
317
-
318
- # Track what was previously checked so we don't reset
319
- existing_set = set(existing_rows or [])
320
-
321
- children = []
322
- hc_col_map = {"Historical": "historical", "CC50": "cc50", "CC95": "cc95", "TAIESM1": "taiesm1", "EC-Earth3": "ec_earth3", "CESM2": "cesm2"}
323
- for strat in strategies:
324
- theme = STRATEGY_THEME.get(strat, "")
325
- desc = STRATEGY_DESC.get(strat, "")
326
-
327
- mrow = strategy_map[strategy_map["strategy"] == strat]
328
- if mrow.empty:
329
- continue
330
- mrow = mrow.iloc[0]
331
- available = []
332
- for hc, col in hc_col_map.items():
333
- if pd.notna(mrow.get(col)):
334
- available.append({"label": f"{hc} ({mrow[col]})", "value": f"{strat}|{hc}"})
335
-
336
- # Preserve existing selections; default new strategies to Historical only
337
- strat_existing = [o["value"] for o in available if o["value"] in existing_set]
338
- if not strat_existing:
339
- strat_existing = [f"{strat}|Historical"]
340
-
341
- children.append(html.Div(style={"marginBottom": "10px", "padding": "8px", "backgroundColor": "#F7FAFC", "borderRadius": "6px", "border": f"1px solid {BORDER_LIGHT}"}, children=[
342
- html.Div(f"{strat} ({theme})", style={"fontSize": "12px", "fontWeight": "700", "color": BRAND_TEAL, "marginBottom": "2px"}),
343
- html.Div(desc, style={"fontSize": "10px", "color": TEXT_MUTED, "marginBottom": "6px"}),
344
- dcc.Checklist(
345
- id={"type": "res-hc-check", "strategy": strat},
346
- options=available,
347
- value=strat_existing,
348
- inline=True,
349
- style={"fontSize": "11px"},
350
- inputStyle={"marginRight": "4px"},
351
- labelStyle={"marginRight": "12px", "fontSize": "11px"},
352
- ),
353
- ]))
354
-
355
- return children
356
-
357
- @app.callback(
358
- Output("res-system-rows-store", "data"),
359
- Input({"type": "res-hc-check", "strategy": dash.ALL}, "value"),
360
- prevent_initial_call=True,
361
- )
362
- def collect_system_rows(all_checklist_values):
363
- if not all_checklist_values:
364
- return []
365
- rows = []
366
- for vals in all_checklist_values:
367
- rows.extend(vals or [])
368
- return rows
369
-
370
- # --- Strategy description ---
371
- @app.callback(
372
- Output("res-strategy-desc", "children"),
373
- Input("res-strategy-dropdown", "value"),
374
- prevent_initial_call=True,
375
- )
376
- def show_strategy_desc(strategy):
377
- if not strategy:
378
- return ""
379
- theme = STRATEGY_THEME.get(strategy, "")
380
- desc = STRATEGY_DESC.get(strategy, "")
381
- return f"[{theme}] {desc}" if theme else desc
382
-
383
- # --- View 1: Scenario-specific heatmap ---
384
- @app.callback(
385
- [Output("res-title-scenario", "children"),
386
- Output("res-plot-scenario", "figure"),
387
- Output("res-plot-scenario", "style"),
388
- Output("res-stress-row", "children"),
389
- Output("res-legend-row", "children")],
390
- [Input("res-strategy-dropdown", "value"),
391
- Input("res-data-source", "data"),
392
- Input("res-hc-dropdown", "value")],
393
- )
394
- def update_scenario_view(strategy, data_source, selected_hc):
395
- empty_fig = go.Figure()
396
- empty_fig.update_layout(height=100, margin=dict(t=0, b=0, l=0, r=0), paper_bgcolor="white", plot_bgcolor="white", xaxis=dict(visible=False), yaxis=dict(visible=False))
397
- hide = {"display": "none"}
398
-
399
- selected_hc = list(selected_hc or [])
400
- if not strategy:
401
- return "Select a strategy to view resilience", empty_fig, hide, "", ""
402
- if len(selected_hc) < 1:
403
- return "Select at least one hydroclimate", empty_fig, hide, "", ""
404
-
405
- desc = STRATEGY_DESC.get(strategy, "")
406
- source_label = " (synthetic)" if data_source == "synthetic" else ""
407
- title = f"{strategy}: {desc}{source_label}"
408
-
409
- # Get data for this strategy, filtered to selected hydroclimates
410
- active_df = TIER_DATA.get(data_source, _tier_df_synthetic)
411
- strat_data = active_df[(active_df["strategy"] == strategy) & (active_df["hydroclimate"].isin(selected_hc))].copy()
412
- if strat_data.empty:
413
- return title, empty_fig, hide, "", ""
414
-
415
- # Order by user's selection order
416
- hc_order = {hc: i for i, hc in enumerate(selected_hc)}
417
- strat_data["_hc_order"] = strat_data["hydroclimate"].map(hc_order)
418
- strat_data = strat_data.sort_values("_hc_order").drop(columns=["_hc_order"])
419
-
420
- available_hc = strat_data["hydroclimate"].tolist()
421
- scenario_ids = strat_data["scenario"].tolist()
422
- sector_labels = [TIER_LABELS.get(c, c) for c in TIER_COLS]
423
-
424
- # Build custom hover text matrix
425
- custom_hover = []
426
- for i, col in enumerate(TIER_COLS):
427
- row_hover = []
428
- for j, (_, hc_row) in enumerate(strat_data.iterrows()):
429
- val = hc_row[col]
430
- hc = available_hc[j]
431
- sid = scenario_ids[j]
432
- hc_desc = HYDROCLIMATE_META.get(hc, {}).get("desc", hc)
433
- if pd.isna(val):
434
- row_hover.append(f"<b>{sector_labels[i]}</b><br>{hc} ({sid})<br>{hc_desc}<br>No data")
435
- else:
436
- tier_hover = _get_tier_hover(col, val)
437
- row_hover.append(f"<b>{sector_labels[i]}</b><br>{hc} ({sid})<br>{hc_desc}<br>Tier value: {val:.2f}{tier_hover}")
438
- custom_hover.append(row_hover)
439
-
440
- # Build z-matrix: rows = sectors, columns = hydroclimates
441
- z = []
442
- for col in TIER_COLS:
443
- row_z = []
444
- for _, hc_row in strat_data.iterrows():
445
- val = hc_row[col]
446
- row_z.append(float(val) if pd.notna(val) else np.nan)
447
- z.append(row_z)
448
-
449
- z = np.array(z, dtype=float)
450
- z_rounded = np.round(z)
451
- annotation_text = np.where(np.isnan(z), "-", np.char.mod("%.1f", z))
452
-
453
- # X-axis: hydroclimate + scenario ID on second line
454
- x_labels = [f"{hc}<br>({sid})" for hc, sid in zip(available_hc, scenario_ids)]
455
- fig = go.Figure(data=go.Heatmap(
456
- z=z_rounded,
457
- x=x_labels,
458
- y=sector_labels,
459
- text=annotation_text,
460
- texttemplate="<b>%{text}</b>",
461
- textfont=dict(size=15, color="white", family=FONT_STACK),
462
- colorscale=_DISCRETE_COLORSCALE,
463
- zmin=0.5, zmax=4.5,
464
- showscale=False,
465
- customdata=np.array(custom_hover),
466
- hovertemplate="%{customdata}<extra></extra>",
467
- xgap=3, ygap=3,
468
- ))
469
-
470
- # Separator between DWR and GCM scenarios (wider gap)
471
- dwr_count = sum(1 for hc in available_hc if HYDROCLIMATE_META.get(hc, {}).get("group") == "DWR")
472
-
473
- fig.update_layout(
474
- xaxis=dict(side="top", tickfont=dict(size=14, color=BRAND_TEAL, family=FONT_STACK, weight="bold"), tickangle=0),
475
- yaxis=dict(autorange="reversed", tickfont=dict(size=13, family=FONT_STACK, weight="bold"), dtick=1),
476
- margin=dict(t=60, b=10, l=190, r=40),
477
- height=max(400, len(TIER_COLS) * 36 + 80),
478
- plot_bgcolor="white", paper_bgcolor="white",
479
- dragmode=False,
480
- font=dict(family=FONT_STACK),
481
- )
482
-
483
- # Stress counts
484
- t34_counts = []
485
- t4_counts = []
486
- for j in range(len(available_hc)):
487
- col_vals = z_rounded[:, j]
488
- valid = col_vals[~np.isnan(col_vals)]
489
- t34_counts.append(int(np.sum(valid >= 3)))
490
- t4_counts.append(int(np.sum(valid >= 4)))
491
-
492
- # Stress counts as HTML
493
- stress_cells = []
494
- for j, hc in enumerate(available_hc):
495
- cell_children = [
496
- html.Div(f"Tier 3-4: {t34_counts[j]}", style={"fontSize": "13px", "fontWeight": "700", "color": "#EF6C00"}),
497
- html.Div(f"Tier 4: {t4_counts[j]}", style={"fontSize": "13px", "fontWeight": "700", "color": "#C62828"}),
498
- ]
499
- cell_style = {"textAlign": "center", "flex": "1", "padding": "4px 0"}
500
- stress_cells.append(html.Div(style=cell_style, children=cell_children))
501
-
502
- stress_row = html.Div(style={"display": "flex", "marginLeft": "190px", "marginRight": "40px", "marginTop": "8px", "gap": "3px"}, children=stress_cells)
503
-
504
- # Legend
505
- legend_items = []
506
- for tier, color in TIER_COLORS.items():
507
- legend_items.append(html.Div(style={"display": "flex", "alignItems": "center", "gap": "6px"}, children=[
508
- html.Div(style={"width": "18px", "height": "18px", "backgroundColor": color, "borderRadius": "3px"}),
509
- html.Span(f"Tier {tier}", style={"fontSize": "13px", "fontWeight": "700", "color": color}),
510
- ]))
511
-
512
- legend = html.Div(style={"display": "flex", "justifyContent": "center", "gap": "28px", "marginTop": "16px"}, children=legend_items)
513
-
514
- fig_height = max(400, len(TIER_COLS) * 36 + 80)
515
- show = {"display": "block", "height": f"{fig_height}px"}
516
-
517
- return title, fig, show, stress_row, legend
518
-
519
- # --- Download callbacks ---
520
- from downloads import register_plotly_png_callback
521
- register_plotly_png_callback(app, "res-plot-scenario")
522
-
523
- @app.callback(
524
- Output("dl-csv-res-plot-scenario", "data"),
525
- Input("dl-csv-btn-res-plot-scenario", "n_clicks"),
526
- [State("res-strategy-dropdown", "value"), State("res-data-source", "data"), State("res-hc-dropdown", "value")],
527
- prevent_initial_call=True,
528
- )
529
- def download_scenario_csv(n_clicks, strategy, data_source, selected_hc):
530
- from dash.exceptions import PreventUpdate
531
- if not n_clicks or not strategy:
532
- raise PreventUpdate
533
- active_df = TIER_DATA.get(data_source, _tier_df_synthetic)
534
- selected_hc = list(selected_hc or HYDROCLIMATE_ORDER)
535
- out = active_df[(active_df["strategy"] == strategy) & (active_df["hydroclimate"].isin(selected_hc))].copy()
536
- hc_order = {hc: i for i, hc in enumerate(selected_hc)}
537
- out["_order"] = out["hydroclimate"].map(hc_order)
538
- out = out.sort_values("_order").drop(columns=["_order"])
539
- cols = ["scenario", "strategy", "hydroclimate"] + TIER_COLS
540
- out = out[[c for c in cols if c in out.columns]]
541
- return dcc.send_data_frame(out.to_csv, f"resilience_{strategy}.csv", index=False)
542
-
543
- # --- View 2: System-wide heatmap ---
544
- @app.callback(
545
- [Output("res-title-system", "children"),
546
- Output("res-plot-system", "figure"),
547
- Output("res-plot-system", "style"),
548
- Output("res-stress-row-system", "children"),
549
- Output("res-legend-row-system", "children")],
550
- [Input("res-system-rows-store", "data"),
551
- Input("res-data-source", "data")],
552
- )
553
- def update_system_view(selected_rows, data_source):
554
- empty_fig = go.Figure()
555
- empty_fig.update_layout(height=100, margin=dict(t=0, b=0, l=0, r=0), paper_bgcolor="white", plot_bgcolor="white", xaxis=dict(visible=False), yaxis=dict(visible=False))
556
- hide = {"display": "none"}
557
-
558
- selected_rows = list(selected_rows or [])
559
- if not selected_rows:
560
- return "Select scenario rows to compare", empty_fig, hide, "", ""
561
-
562
- # Parse "strategy|hydroclimate" values
563
- pairs = []
564
- for rv in selected_rows:
565
- parts = rv.split("|")
566
- if len(parts) == 2:
567
- pairs.append((parts[0], parts[1]))
568
-
569
- if not pairs:
570
- return "Select scenario rows to compare", empty_fig, hide, "", ""
571
-
572
- active_df = TIER_DATA.get(data_source, _tier_df_synthetic)
573
-
574
- # Filter to selected strategy x hydroclimate pairs
575
- mask = pd.Series(False, index=active_df.index)
576
- for strat, hc in pairs:
577
- mask |= (active_df["strategy"] == strat) & (active_df["hydroclimate"] == hc)
578
- view_df = active_df[mask].copy()
579
- if view_df.empty:
580
- return "No data available", empty_fig, hide, "", ""
581
-
582
- # Preserve selection order
583
- pair_order = {f"{s}|{h}": i for i, (s, h) in enumerate(pairs)}
584
- view_df["_order"] = view_df.apply(lambda r: pair_order.get(f"{r['strategy']}|{r['hydroclimate']}", 999), axis=1)
585
- view_df = view_df.sort_values("_order").drop(columns=["_order"])
586
-
587
- # Row labels
588
- row_labels = []
589
- for _, row in view_df.iterrows():
590
- row_labels.append(f"{row['strategy']} - {row['hydroclimate']} ({row['scenario']})")
591
-
592
- sector_labels = [TIER_LABELS.get(c, c) for c in TIER_COLS]
593
-
594
- # Build z-matrix: rows = strategy x hydroclimate combos, columns = sectors
595
- z = []
596
- custom_hover = []
597
- for idx, (_, row) in enumerate(view_df.iterrows()):
598
- row_z = []
599
- row_hover = []
600
- for i, col in enumerate(TIER_COLS):
601
- val = row[col]
602
- row_z.append(float(val) if pd.notna(val) else np.nan)
603
- desc = STRATEGY_DESC.get(row["strategy"], "")
604
- if pd.isna(val):
605
- row_hover.append(f"<b>{sector_labels[i]}</b><br>{row['strategy']}: {desc}<br>{row['hydroclimate']} ({row['scenario']})<br>No data")
606
- else:
607
- tier_hover = _get_tier_hover(col, val)
608
- row_hover.append(f"<b>{sector_labels[i]}</b><br>{row['strategy']}: {desc}<br>{row['hydroclimate']} ({row['scenario']})<br>Tier value: {val:.2f}{tier_hover}")
609
- z.append(row_z)
610
- custom_hover.append(row_hover)
611
-
612
- z = np.array(z, dtype=float)
613
- z_rounded = np.round(z)
614
- annotation_text = np.where(np.isnan(z), "-", np.char.mod("%.1f", z))
615
-
616
- fig = go.Figure(data=go.Heatmap(
617
- z=z_rounded,
618
- x=sector_labels,
619
- y=row_labels,
620
- text=annotation_text,
621
- texttemplate="<b>%{text}</b>",
622
- textfont=dict(size=13, color="white", family=FONT_STACK),
623
- colorscale=_DISCRETE_COLORSCALE,
624
- zmin=0.5, zmax=4.5,
625
- showscale=False,
626
- customdata=np.array(custom_hover),
627
- hovertemplate="%{customdata}<extra></extra>",
628
- xgap=3, ygap=3,
629
- ))
630
-
631
- n_rows = len(row_labels)
632
- fig.update_layout(
633
- xaxis=dict(side="top", tickfont=dict(size=12, color=BRAND_TEAL, family=FONT_STACK, weight="bold"), tickangle=45),
634
- yaxis=dict(autorange="reversed", tickfont=dict(size=11, family=FONT_STACK, weight="bold"), dtick=1),
635
- margin=dict(t=120, b=10, l=300, r=40),
636
- height=max(400, n_rows * 32 + 140),
637
- plot_bgcolor="white", paper_bgcolor="white",
638
- dragmode=False,
639
- font=dict(family=FONT_STACK),
640
- )
641
-
642
- source_label = " (synthetic)" if data_source == "synthetic" else ""
643
- n_strategies = len(set(p[0] for p in pairs))
644
- n_hc = len(set(p[1] for p in pairs))
645
- title = f"System-Wide: {n_strategies} strategies x {n_hc} hydroclimates{source_label}"
646
-
647
- # Stress counts per sector column
648
- t34_counts = []
649
- t4_counts = []
650
- for j in range(len(TIER_COLS)):
651
- col_vals = z_rounded[:, j]
652
- valid = col_vals[~np.isnan(col_vals)]
653
- t34_counts.append(int(np.sum(valid >= 3)))
654
- t4_counts.append(int(np.sum(valid >= 4)))
655
-
656
- # Legend
657
- legend_items = []
658
- for tier, color in TIER_COLORS.items():
659
- legend_items.append(html.Div(style={"display": "flex", "alignItems": "center", "gap": "6px"}, children=[
660
- html.Div(style={"width": "18px", "height": "18px", "backgroundColor": color, "borderRadius": "3px"}),
661
- html.Span(f"Tier {tier}", style={"fontSize": "13px", "fontWeight": "700", "color": color}),
662
- ]))
663
- legend = html.Div(style={"display": "flex", "justifyContent": "center", "gap": "28px", "marginTop": "16px"}, children=legend_items)
664
-
665
- fig_height = max(400, n_rows * 32 + 140)
666
- show = {"display": "block", "height": f"{fig_height}px"}
667
-
668
- return title, fig, show, "", legend
669
-
670
- # --- System-wide download callbacks ---
671
- register_plotly_png_callback(app, "res-plot-system")
672
-
673
- @app.callback(
674
- Output("dl-csv-res-plot-system", "data"),
675
- Input("dl-csv-btn-res-plot-system", "n_clicks"),
676
- [State("res-system-rows-store", "data"), State("res-data-source", "data")],
677
- prevent_initial_call=True,
678
- )
679
- def download_system_csv(n_clicks, selected_rows, data_source):
680
- from dash.exceptions import PreventUpdate
681
- if not n_clicks or not selected_rows:
682
- raise PreventUpdate
683
- active_df = TIER_DATA.get(data_source, _tier_df_synthetic)
684
- pairs = [rv.split("|") for rv in selected_rows if "|" in rv]
685
- mask = pd.Series(False, index=active_df.index)
686
- for strat, hc in pairs:
687
- mask |= (active_df["strategy"] == strat) & (active_df["hydroclimate"] == hc)
688
- out = active_df[mask].copy()
689
- cols = ["scenario", "strategy", "hydroclimate"] + TIER_COLS
690
- out = out[[c for c in cols if c in out.columns]]
691
- return dcc.send_data_frame(out.to_csv, "resilience_system_wide.csv", index=False)
692
-
693
- # --- View 3: LOI filter checklist ---
694
- @app.callback(
695
- [Output("res-loi-filter-content", "style"),
696
- Output("res-loi-no-lois", "style"),
697
- Output("res-loi-checklist", "options"),
698
- Output("res-loi-checklist", "value"),
699
- Output("res-loi-title", "children")],
700
- Input("res-outcome-dropdown", "value"),
701
- )
702
- def build_loi_checklist(sector):
703
- show, hide = {"display": "block"}, {"display": "none"}
704
- if not sector or not HAS_LOI_DATA:
705
- return hide, hide, [], [], ""
706
- lois = LOI_MAPPING.get(sector, [])
707
- if not lois:
708
- return hide, show, [], [], ""
709
- loi_names = [l["loi"] for l in lois]
710
- options = [{"label": name, "value": name} for name in loi_names]
711
- return show, hide, options, loi_names, f"Filter LOIs ({len(loi_names)} available)"
712
-
713
-
714
- # --- View 3: Outcome-specific heatmap ---
715
- @app.callback(
716
- [Output("res-title-outcome", "children"),
717
- Output("res-plot-outcome", "figure"),
718
- Output("res-plot-outcome", "style"),
719
- Output("res-legend-row-outcome", "children")],
720
- [Input("res-outcome-dropdown", "value"),
721
- Input("res-outcome-strategies", "value"),
722
- Input("res-data-source", "data"),
723
- Input("res-loi-checklist", "value")],
724
- )
725
- def update_outcome_view(sector, strategies, data_source, selected_lois):
726
- empty_fig = go.Figure()
727
- empty_fig.update_layout(height=100, margin=dict(t=0, b=0, l=0, r=0), paper_bgcolor="white", plot_bgcolor="white", xaxis=dict(visible=False), yaxis=dict(visible=False))
728
- hide = {"display": "none"}
729
-
730
- strategies = list(strategies or [])
731
- if not sector:
732
- return "Select a sector to compare", empty_fig, hide, ""
733
- if not strategies:
734
- return f"{TIER_LABELS.get(sector, sector)} - select strategies", empty_fig, hide, ""
735
-
736
- active_df = TIER_DATA.get(data_source, _tier_df_synthetic)
737
- view_df = active_df[active_df["strategy"].isin(strategies)].copy()
738
- if view_df.empty:
739
- return "No data available", empty_fig, hide, ""
740
-
741
- # If LOI filter is active and we have LOI data, recalculate averages
742
- all_lois = LOI_MAPPING.get(sector, [])
743
- all_loi_names = [l["loi"] for l in all_lois]
744
- selected_lois = list(selected_lois or [])
745
- using_loi_filter = HAS_LOI_DATA and len(all_loi_names) > 0 and len(selected_lois) > 0 and set(selected_lois) != set(all_loi_names)
746
-
747
- if using_loi_filter:
748
- # Recalculate sector average from selected LOIs only
749
- valid_lois = [l for l in selected_lois if l in _loi_tiers.columns]
750
- if valid_lois:
751
- loi_lookup = _loi_tiers.set_index("scenario")
752
- for idx, row in view_df.iterrows():
753
- scen = row["scenario"]
754
- if scen in loi_lookup.index:
755
- loi_vals = loi_lookup.loc[scen, valid_lois]
756
- view_df.at[idx, sector] = loi_vals.mean(skipna=True)
757
- # else: keep the existing value (synthetic or pre-computed)
758
-
759
- # Order by strategy selection, then hydroclimate order
760
- strat_order = {s: i for i, s in enumerate(strategies)}
761
- hc_order = {hc: i for i, hc in enumerate(HYDROCLIMATE_ORDER)}
762
- view_df["_s_order"] = view_df["strategy"].map(strat_order)
763
- view_df["_h_order"] = view_df["hydroclimate"].map(hc_order)
764
- view_df = view_df.sort_values(["_s_order", "_h_order"]).drop(columns=["_s_order", "_h_order"])
765
-
766
- # Get available hydroclimates across all selected strategies
767
- available_hc = [hc for hc in HYDROCLIMATE_ORDER if hc in view_df["hydroclimate"].values]
768
-
769
- # Build z-matrix: rows = strategies, columns = hydroclimates
770
- # Each cell = tier value for the selected sector
771
- row_labels = []
772
- z = []
773
- custom_hover = []
774
- for strat in strategies:
775
- strat_rows = view_df[view_df["strategy"] == strat]
776
- desc = STRATEGY_DESC.get(strat, "")
777
- theme = STRATEGY_THEME.get(strat, "")
778
- row_label = f"{strat} ({theme})"
779
- row_labels.append(row_label)
780
- row_z = []
781
- row_hover = []
782
- for hc in available_hc:
783
- hc_row = strat_rows[strat_rows["hydroclimate"] == hc]
784
- if hc_row.empty:
785
- row_z.append(np.nan)
786
- row_hover.append(f"<b>{row_label}</b><br>{hc}<br>No data")
787
- else:
788
- val = hc_row[sector].values[0]
789
- sid = hc_row["scenario"].values[0]
790
- if pd.isna(val):
791
- row_z.append(np.nan)
792
- row_hover.append(f"<b>{row_label}</b><br>{hc} ({sid})<br>{desc}<br>No data")
793
- else:
794
- row_z.append(float(val))
795
- tier_hover = _get_tier_hover(sector, val)
796
- row_hover.append(f"<b>{row_label}</b><br>{hc} ({sid})<br>{desc}<br>Tier value: {val:.2f}{tier_hover}")
797
- z.append(row_z)
798
- custom_hover.append(row_hover)
799
-
800
- z = np.array(z, dtype=float)
801
- z_rounded = np.round(z)
802
- annotation_text = np.where(np.isnan(z), "-", np.char.mod("%.1f", z))
803
-
804
- fig = go.Figure(data=go.Heatmap(
805
- z=z_rounded,
806
- x=available_hc,
807
- y=row_labels,
808
- text=annotation_text,
809
- texttemplate="<b>%{text}</b>",
810
- textfont=dict(size=15, color="white", family=FONT_STACK),
811
- colorscale=_DISCRETE_COLORSCALE,
812
- zmin=0.5, zmax=4.5,
813
- showscale=False,
814
- customdata=np.array(custom_hover),
815
- hovertemplate="%{customdata}<extra></extra>",
816
- xgap=3, ygap=3,
817
- ))
818
-
819
- n_rows = len(row_labels)
820
- fig.update_layout(
821
- xaxis=dict(side="top", tickfont=dict(size=14, color=BRAND_TEAL, family=FONT_STACK, weight="bold"), tickangle=0),
822
- yaxis=dict(autorange="reversed", tickfont=dict(size=12, family=FONT_STACK, weight="bold"), dtick=1),
823
- margin=dict(t=60, b=10, l=220, r=40),
824
- height=max(300, n_rows * 36 + 100),
825
- plot_bgcolor="white", paper_bgcolor="white",
826
- dragmode=False,
827
- font=dict(family=FONT_STACK),
828
- )
829
-
830
- source_label = " (synthetic)" if data_source == "synthetic" else ""
831
- sector_label = TIER_LABELS.get(sector, sector)
832
- loi_label = f" ({len(selected_lois)}/{len(all_loi_names)} LOIs)" if using_loi_filter else ""
833
- title = f"{sector_label}{loi_label}: {len(strategies)} strategies across hydroclimates{source_label}"
834
-
835
- # Legend
836
- legend_items = []
837
- for tier, color in TIER_COLORS.items():
838
- legend_items.append(html.Div(style={"display": "flex", "alignItems": "center", "gap": "6px"}, children=[
839
- html.Div(style={"width": "18px", "height": "18px", "backgroundColor": color, "borderRadius": "3px"}),
840
- html.Span(f"Tier {tier}", style={"fontSize": "13px", "fontWeight": "700", "color": color}),
841
- ]))
842
- legend = html.Div(style={"display": "flex", "justifyContent": "center", "gap": "28px", "marginTop": "16px"}, children=legend_items)
843
-
844
- fig_height = max(300, n_rows * 36 + 100)
845
- show = {"display": "block", "height": f"{fig_height}px"}
846
-
847
- return title, fig, show, legend
848
-
849
- # --- Outcome download callbacks ---
850
- register_plotly_png_callback(app, "res-plot-outcome")
851
-
852
- @app.callback(
853
- Output("dl-csv-res-plot-outcome", "data"),
854
- Input("dl-csv-btn-res-plot-outcome", "n_clicks"),
855
- [State("res-outcome-dropdown", "value"), State("res-outcome-strategies", "value"), State("res-data-source", "data")],
856
- prevent_initial_call=True,
857
- )
858
- def download_outcome_csv(n_clicks, sector, strategies, data_source):
859
- from dash.exceptions import PreventUpdate
860
- if not n_clicks or not sector or not strategies:
861
- raise PreventUpdate
862
- active_df = TIER_DATA.get(data_source, _tier_df_synthetic)
863
- strategies = list(strategies or [])
864
- out = active_df[active_df["strategy"].isin(strategies)].copy()
865
- cols = ["scenario", "strategy", "hydroclimate", sector]
866
- out = out[[c for c in cols if c in out.columns]]
867
- return dcc.send_data_frame(out.to_csv, f"resilience_outcome_{sector}.csv", index=False)
 
1
+ import dash
2
+ import numpy as np
3
+ import pandas as pd
4
+ import plotly.graph_objs as go
5
+ from dash import dcc, html, callback_context
6
+ from dash.dependencies import Input, Output, State
7
+
8
+ from theme import (
9
+ BRAND_TEAL, TEXT_MUTED, FONT_STACK, BORDER_LIGHT,
10
+ SECTION_HEADER, DESCRIPTION_STYLE, MODERN_CARD,
11
+ )
12
+ from downloads import download_buttons
13
+
14
+ # contibuous or discrete tiers
15
+ ContinuousTiers = True
16
+ if ContinuousTiers:
17
+ tier_data = "data/tier_df_continuous.csv"
18
+ else:
19
+ tier_data = "data/tier_df.csv"
20
+
21
+ # --- Tier Color Scale (discrete 4-tier: green, blue, orange, red) ---
22
+ TIER_COLORS = {1: "#2E7D32", 2: "#1565C0", 3: "#EF6C00", 4: "#C62828"}
23
+ TIER_BG_COLORS = {1: "rgba(46,125,50,0.15)", 2: "rgba(21,101,192,0.15)", 3: "rgba(239,108,0,0.15)", 4: "rgba(200,40,40,0.15)"}
24
+
25
+ # Discrete colorscale for Plotly heatmap (sharp boundaries at tier transitions)
26
+ _DISCRETE_COLORSCALE = [
27
+ [0.0, "#2E7D32"], [0.25, "#2E7D32"],
28
+ [0.25, "#1565C0"], [0.5, "#1565C0"],
29
+ [0.5, "#EF6C00"], [0.75, "#EF6C00"],
30
+ [0.75, "#C62828"], [1.0, "#C62828"],
31
+ ]
32
+
33
+ # --- Data Loading ---
34
+ import json
35
+
36
+ MAPPING_PATH = "data/strategy_mapping.csv"
37
+
38
+ strategy_map = pd.read_csv(MAPPING_PATH)
39
+
40
+ # LOI-level tier data for drill-down
41
+ try:
42
+ _loi_tiers = pd.read_csv("data/loi_tiers.csv")
43
+ with open("data/loi_mapping.json") as f:
44
+ LOI_MAPPING = json.load(f)
45
+ HAS_LOI_DATA = True
46
+ except Exception:
47
+ _loi_tiers = pd.DataFrame()
48
+ LOI_MAPPING = {}
49
+ HAS_LOI_DATA = False
50
+
51
+ # Hydroclimates with real CalSim3 runs. CESM2 is dropped - those runs (s0159-s0183)
52
+ # were never produced, so every CESM2 cell would have been fabricated.
53
+ HYDROCLIMATE_ORDER = ["Historical", "CC50", "CC95", "TAIESM1", "EC-Earth3"]
54
+ _HC_COL_MAP = {"Historical": "historical", "CC50": "cc50", "CC95": "cc95", "TAIESM1": "taiesm1", "EC-Earth3": "ec_earth3"}
55
+
56
+ # Build the real strategy x hydroclimate matrix from the continuous tier data.
57
+ # tier_df_continuous holds one row per real scenario (no strategy/hydroclimate);
58
+ # strategy_map says which real scenario id backs each (strategy, hydroclimate) cell.
59
+ # Cells whose scenario has no real run are omitted - no fabricated rows. This is
60
+ # why phantom strategies like s0036 (no real run) drop out entirely.
61
+ _tier_real = pd.read_csv(tier_data).set_index("scenario")
62
+ _rows = []
63
+ for _, _mrow in strategy_map.iterrows():
64
+ _strat = _mrow["strategy"]
65
+ for _hc, _hc_col in _HC_COL_MAP.items():
66
+ _scen = _mrow.get(_hc_col)
67
+ if pd.isna(_scen) or _scen not in _tier_real.index:
68
+ continue
69
+ _rec = _tier_real.loc[_scen].to_dict()
70
+ _rec["scenario"] = _scen
71
+ _rec["strategy"] = _strat
72
+ _rec["hydroclimate"] = _hc
73
+ _rows.append(_rec)
74
+ res_tier_df = pd.DataFrame(_rows)
75
+
76
+ TIER_COLS = [c for c in res_tier_df.columns if c not in ("scenario", "strategy", "hydroclimate") and not c.endswith("_Std")]
77
+
78
+ # --- Tier Descriptions (hover text) ---
79
+ _tier_desc_df = pd.read_csv("data/tiers_descriptions.csv")
80
+ _tier_desc_df.columns = _tier_desc_df.columns.str.strip()
81
+ _tier_desc_df["Outcomes/ Tiers"] = _tier_desc_df["Outcomes/ Tiers"].str.strip()
82
+
83
+ # Map our column names to CSV keys
84
+ _COL_TO_DESC_KEY = {
85
+ "NOD_GW_Mean": "NOD GW", "SOD_GW_Mean": "SOD GW",
86
+ "NOD_Reservoir_Mean": "NOD Reservoir", "SOD_Reservoir_Mean": "SOD Reservoir",
87
+ "Exports_and_Salinity": "Exports and Salinity", "InDelta_Salinity": "InDelta Salinity",
88
+ "NOD_Ag_Mean": "NOD Ag", "SOD_Ag_Mean": "SOD Ag",
89
+ "NOD_DW_Mean": "NOD DW", "SOD_DW_Mean": "SOD DW",
90
+ "NOD_Eflows_Mean": "NOD Eflows", "SOD_Eflows_Mean": "SOD Eflows",
91
+ "Delta_Ecology": "Delta Ecology", "Salmon_Abundance": "Salmon Abundance",
92
+ }
93
+
94
+ AXIS_DESCRIPTIONS = {}
95
+ TIER_DEFINITIONS = {}
96
+ for _, row in _tier_desc_df.iterrows():
97
+ key = row["Outcomes/ Tiers"]
98
+ AXIS_DESCRIPTIONS[key] = str(row["Description"]).strip()
99
+ TIER_DEFINITIONS[key] = {1: str(row["Tier1"]).strip(), 2: str(row["Tier2"]).strip(), 3: str(row["Tier3"]).strip(), 4: str(row["Tier4"]).strip()}
100
+
101
+
102
+ def _get_tier_hover(col, val):
103
+ """Get tier definition text for a column and tier value."""
104
+ desc_key = _COL_TO_DESC_KEY.get(col)
105
+ if not desc_key or pd.isna(val):
106
+ return ""
107
+ tier_int = int(round(val))
108
+ tier_int = max(1, min(4, tier_int))
109
+ defs = TIER_DEFINITIONS.get(desc_key, {})
110
+ tier_text = defs.get(tier_int, "")
111
+ if len(tier_text) > 120:
112
+ tier_text = tier_text[:120] + "..."
113
+ return f"<br><i>Tier {tier_int}: {tier_text}</i>"
114
+
115
+ # Hydroclimate metadata for display
116
+ HYDROCLIMATE_META = {
117
+ "Historical": {"group": "DWR", "desc": "2023 DWR Historical Adjusted"},
118
+ "CC50": {"group": "DWR", "desc": "50th percentile climate change (2043)"},
119
+ "CC95": {"group": "DWR", "desc": "95th percentile climate change (2043)"},
120
+ "TAIESM1": {"group": "GCM", "desc": "TaiESM1 climate projection"},
121
+ "EC-Earth3": {"group": "GCM", "desc": "EC-Earth3 climate projection"},
122
+ "CESM2": {"group": "GCM", "desc": "CESM2 climate projection"},
123
+ }
124
+
125
+ # Separator position: after CC95 (index 2), before TAIESM1 (index 3)
126
+ _DWR_GCM_BOUNDARY = 3
127
+ # Strategies that actually have real data (drops phantoms like s0036).
128
+ STRATEGIES = sorted(res_tier_df["strategy"].unique().tolist())
129
+
130
+ TIER_LABELS = {
131
+ "NOD_GW_Mean": "NOD Groundwater", "SOD_GW_Mean": "SOD Groundwater",
132
+ "NOD_Reservoir_Mean": "NOD Reservoir", "SOD_Reservoir_Mean": "SOD Reservoir",
133
+ "Exports_and_Salinity": "Exports and Salinity", "InDelta_Salinity": "In-Delta Salinity",
134
+ "NOD_Ag_Mean": "NOD Agriculture", "SOD_Ag_Mean": "SOD Agriculture",
135
+ "NOD_DW_Mean": "NOD Drinking Water", "SOD_DW_Mean": "SOD Drinking Water",
136
+ "NOD_Eflows_Mean": "NOD Env. Flows", "SOD_Eflows_Mean": "SOD Env. Flows",
137
+ "Delta_Ecology": "Delta Ecology", "Salmon_Abundance": "Salmon Abundance",
138
+ }
139
+
140
+ # Strategy descriptions from mapping
141
+ STRATEGY_DESC = dict(zip(strategy_map["strategy"], strategy_map["description"]))
142
+ STRATEGY_THEME = dict(zip(strategy_map["strategy"], strategy_map["theme"]))
143
+
144
+ # --- Toggle Styles (matching plines.py) ---
145
+ _TOGGLE_BTN_BASE = {
146
+ "padding": "7px 24px", "border": "none", "borderRadius": "6px",
147
+ "fontSize": "13px", "fontWeight": "600", "cursor": "pointer",
148
+ "textAlign": "center", "transition": "all 0.15s ease",
149
+ }
150
+ _TOGGLE_ACTIVE = {**_TOGGLE_BTN_BASE, "backgroundColor": "white", "color": BRAND_TEAL, "boxShadow": "0 1px 3px rgba(0,0,0,0.1)"}
151
+ _TOGGLE_INACTIVE = {**_TOGGLE_BTN_BASE, "backgroundColor": "transparent", "color": TEXT_MUTED}
152
+
153
+
154
+ def _get_strategy_options():
155
+ opts = []
156
+ for s in STRATEGIES:
157
+ theme = STRATEGY_THEME.get(s, "")
158
+ label = f"{s} ({theme})" if theme else s
159
+ opts.append({"label": label, "value": s})
160
+ return opts
161
+
162
+
163
+ def _get_row_options():
164
+ """Build dropdown options for every strategy x hydroclimate combo, grouped by strategy."""
165
+ opts = []
166
+ for _, mrow in strategy_map.iterrows():
167
+ strat = mrow["strategy"]
168
+ theme = STRATEGY_THEME.get(strat, "")
169
+ for hc in HYDROCLIMATE_ORDER:
170
+ hc_col = {"Historical": "historical", "CC50": "cc50", "CC95": "cc95", "TAIESM1": "taiesm1", "EC-Earth3": "ec_earth3", "CESM2": "cesm2"}.get(hc)
171
+ if hc_col and pd.notna(mrow.get(hc_col)):
172
+ scen = mrow[hc_col]
173
+ value = f"{strat}|{hc}"
174
+ label = f"{strat} - {hc} ({scen}) [{theme}]"
175
+ opts.append({"label": label, "value": value})
176
+ return opts
177
+
178
+
179
+ def get_sidebar_controls():
180
+ _data_toggle = {"padding": "4px 12px", "border": "none", "borderRadius": "4px", "fontSize": "11px", "fontWeight": "600", "cursor": "pointer", "transition": "all 0.15s ease"}
181
+ _data_active = {**_data_toggle, "backgroundColor": BRAND_TEAL, "color": "white"}
182
+ _data_inactive = {**_data_toggle, "backgroundColor": "transparent", "color": TEXT_MUTED}
183
+ hc_options = [{"label": hc, "value": hc} for hc in HYDROCLIMATE_ORDER]
184
+ strategy_multi_opts = _get_strategy_options()
185
+ return html.Div([
186
+ # View 1 controls: single strategy + hydroclimates
187
+ html.Div(id="res-sidebar-scenario", children=[
188
+ html.Div("Strategy", style=SECTION_HEADER),
189
+ dcc.Dropdown(id="res-strategy-dropdown", options=strategy_multi_opts, placeholder="Choose strategy...", clearable=False),
190
+ html.Div(id="res-strategy-desc", style=DESCRIPTION_STYLE),
191
+ html.Div("Hydroclimates", style={**SECTION_HEADER, "marginTop": "16px"}),
192
+ dcc.Dropdown(id="res-hc-dropdown", options=hc_options, value=list(HYDROCLIMATE_ORDER), multi=True, placeholder="Select hydroclimates..."),
193
+ ]),
194
+
195
+ # View 2 controls: strategy picker + hydroclimate checklist per strategy
196
+ html.Div(id="res-sidebar-system", style={"display": "none"}, children=[
197
+ html.Div("Strategies", style=SECTION_HEADER),
198
+ dcc.Dropdown(id="res-strategies-multi", options=strategy_multi_opts, value=[], multi=True, placeholder="Select strategies..."),
199
+ html.Div(id="res-hc-checklist-area", style={"marginTop": "10px"}),
200
+ dcc.Store(id="res-system-rows-store", data=[]),
201
+ ]),
202
+
203
+ # View 3 controls: outcome picker + strategy multi-select
204
+ html.Div(id="res-sidebar-outcome", style={"display": "none"}, children=[
205
+ html.Div("Outcome", style=SECTION_HEADER),
206
+ dcc.Dropdown(id="res-outcome-dropdown", options=[{"label": TIER_LABELS.get(c, c), "value": c} for c in TIER_COLS], placeholder="Choose sector...", clearable=False),
207
+ html.Div("Strategies", style={**SECTION_HEADER, "marginTop": "16px"}),
208
+ dcc.Dropdown(id="res-outcome-strategies", options=strategy_multi_opts, value=[], multi=True, placeholder="Select strategies to compare..."),
209
+ html.Div(id="res-loi-filter-area", style={"marginTop": "12px"}, children=[
210
+ html.Div(id="res-loi-filter-content", style={"display": "none"}, children=[
211
+ html.Div(id="res-loi-title", style={**SECTION_HEADER, "fontSize": "11px"}),
212
+ dcc.Dropdown(id="res-loi-checklist", options=[], value=[], multi=True, placeholder="Filter LOIs..."),
213
+ ]),
214
+ html.Div(id="res-loi-no-lois", style={"display": "none"}, children=[
215
+ html.Div("No sub-LOIs (scalar tier)", style={"fontSize": "10px", "color": TEXT_MUTED}),
216
+ ]),
217
+ ]),
218
+ ]),
219
+ ])
220
+
221
+
222
+ def get_content():
223
+ return html.Div([
224
+ # Top bar: view toggle (center)
225
+ html.Div(style={"display": "flex", "alignItems": "center", "justifyContent": "center", "position": "relative", "marginBottom": "16px"}, children=[
226
+ html.Div(style={"display": "inline-flex", "gap": "4px", "padding": "4px", "backgroundColor": "#EDF2F7", "borderRadius": "8px"}, children=[
227
+ html.Button("Scenario", id="res-toggle-scenario", n_clicks=0, style=_TOGGLE_ACTIVE),
228
+ html.Button("Outcome", id="res-toggle-outcome", n_clicks=0, style=_TOGGLE_INACTIVE),
229
+ html.Button("System-Wide", id="res-toggle-system", n_clicks=0, style=_TOGGLE_INACTIVE),
230
+ ]),
231
+ ]),
232
+
233
+ # View 1: Scenario-specific
234
+ html.Div(id="res-view-scenario", style={"display": "block"}, children=[
235
+ html.H3(id="res-title-scenario", children="Select a strategy to view resilience", style={"textAlign": "center", "marginTop": "0", "marginBottom": "8px", "color": BRAND_TEAL}),
236
+ dcc.Graph(id="res-plot-scenario", figure=go.Figure(), config={"displayModeBar": False, "scrollZoom": False}, style={"display": "none"}),
237
+ html.Div(id="res-stress-row"),
238
+ html.Div(id="res-legend-row"),
239
+ download_buttons("res-plot-scenario"),
240
+ ]),
241
+
242
+ # View 2: System-wide
243
+ html.Div(id="res-view-system", style={"display": "none"}, children=[
244
+ html.H3(id="res-title-system", children="Select strategies to compare", style={"textAlign": "center", "marginTop": "0", "marginBottom": "8px", "color": BRAND_TEAL}),
245
+ dcc.Graph(id="res-plot-system", figure=go.Figure(), config={"displayModeBar": False, "scrollZoom": False}, style={"display": "none"}),
246
+ html.Div(id="res-stress-row-system"),
247
+ html.Div(id="res-legend-row-system"),
248
+ download_buttons("res-plot-system"),
249
+ ]),
250
+
251
+ # View 3: Outcome-specific
252
+ html.Div(id="res-view-outcome", style={"display": "none"}, children=[
253
+ html.H3(id="res-title-outcome", children="Select a sector and strategies", style={"textAlign": "center", "marginTop": "0", "marginBottom": "8px", "color": BRAND_TEAL}),
254
+ dcc.Graph(id="res-plot-outcome", figure=go.Figure(), config={"displayModeBar": False, "scrollZoom": False}, style={"display": "none"}),
255
+ html.Div(id="res-legend-row-outcome"),
256
+ download_buttons("res-plot-outcome"),
257
+ ]),
258
+ ])
259
+
260
+
261
+ def register_callbacks(app):
262
+ # --- View toggle ---
263
+ @app.callback(
264
+ [Output("res-view-scenario", "style"),
265
+ Output("res-view-system", "style"),
266
+ Output("res-view-outcome", "style"),
267
+ Output("res-sidebar-scenario", "style"),
268
+ Output("res-sidebar-system", "style"),
269
+ Output("res-sidebar-outcome", "style"),
270
+ Output("res-toggle-scenario", "style"),
271
+ Output("res-toggle-system", "style"),
272
+ Output("res-toggle-outcome", "style")],
273
+ [Input("res-toggle-scenario", "n_clicks"),
274
+ Input("res-toggle-system", "n_clicks"),
275
+ Input("res-toggle-outcome", "n_clicks")],
276
+ prevent_initial_call=True,
277
+ )
278
+ def toggle_view(scen_clicks, sys_clicks, out_clicks):
279
+ ctx = callback_context
280
+ show, hide = {"display": "block"}, {"display": "none"}
281
+ triggered_id = ctx.triggered[0]["prop_id"].split(".")[0] if ctx.triggered else ""
282
+
283
+ if triggered_id == "res-toggle-system":
284
+ return (hide, show, hide, hide, show, hide, _TOGGLE_INACTIVE, _TOGGLE_ACTIVE, _TOGGLE_INACTIVE)
285
+ elif triggered_id == "res-toggle-outcome":
286
+ return (hide, hide, show, hide, hide, show, _TOGGLE_INACTIVE, _TOGGLE_INACTIVE, _TOGGLE_ACTIVE)
287
+ return (show, hide, hide, show, hide, hide, _TOGGLE_ACTIVE, _TOGGLE_INACTIVE, _TOGGLE_INACTIVE)
288
+
289
+ # --- View 2: Strategy -> hydroclimate checklist ---
290
+ @app.callback(
291
+ Output("res-hc-checklist-area", "children"),
292
+ Input("res-strategies-multi", "value"),
293
+ State("res-system-rows-store", "data"),
294
+ )
295
+ def build_hc_checklists(strategies, existing_rows):
296
+ strategies = list(strategies or [])
297
+ if not strategies:
298
+ return html.Div("Select strategies above", style={"fontSize": "11px", "color": TEXT_MUTED, "padding": "8px 0"})
299
+
300
+ # Track what was previously checked so we don't reset
301
+ existing_set = set(existing_rows or [])
302
+
303
+ children = []
304
+ for strat in strategies:
305
+ theme = STRATEGY_THEME.get(strat, "")
306
+ desc = STRATEGY_DESC.get(strat, "")
307
+
308
+ strat_rows = res_tier_df[res_tier_df["strategy"] == strat]
309
+ if strat_rows.empty:
310
+ continue
311
+ available = []
312
+ for hc in HYDROCLIMATE_ORDER:
313
+ hc_row = strat_rows[strat_rows["hydroclimate"] == hc]
314
+ if not hc_row.empty:
315
+ available.append({"label": f"{hc} ({hc_row['scenario'].iloc[0]})", "value": f"{strat}|{hc}"})
316
+
317
+ # Preserve existing selections; default new strategies to Historical only
318
+ strat_existing = [o["value"] for o in available if o["value"] in existing_set]
319
+ if not strat_existing:
320
+ strat_existing = [f"{strat}|Historical"]
321
+
322
+ children.append(html.Div(style={"marginBottom": "10px", "padding": "8px", "backgroundColor": "#F7FAFC", "borderRadius": "6px", "border": f"1px solid {BORDER_LIGHT}"}, children=[
323
+ html.Div(f"{strat} ({theme})", style={"fontSize": "12px", "fontWeight": "700", "color": BRAND_TEAL, "marginBottom": "2px"}),
324
+ html.Div(desc, style={"fontSize": "10px", "color": TEXT_MUTED, "marginBottom": "6px"}),
325
+ dcc.Checklist(
326
+ id={"type": "res-hc-check", "strategy": strat},
327
+ options=available,
328
+ value=strat_existing,
329
+ inline=True,
330
+ style={"fontSize": "11px"},
331
+ inputStyle={"marginRight": "4px"},
332
+ labelStyle={"marginRight": "12px", "fontSize": "11px"},
333
+ ),
334
+ ]))
335
+
336
+ return children
337
+
338
+ @app.callback(
339
+ Output("res-system-rows-store", "data"),
340
+ Input({"type": "res-hc-check", "strategy": dash.ALL}, "value"),
341
+ prevent_initial_call=True,
342
+ )
343
+ def collect_system_rows(all_checklist_values):
344
+ if not all_checklist_values:
345
+ return []
346
+ rows = []
347
+ for vals in all_checklist_values:
348
+ rows.extend(vals or [])
349
+ return rows
350
+
351
+ # --- Strategy description ---
352
+ @app.callback(
353
+ Output("res-strategy-desc", "children"),
354
+ Input("res-strategy-dropdown", "value"),
355
+ prevent_initial_call=True,
356
+ )
357
+ def show_strategy_desc(strategy):
358
+ if not strategy:
359
+ return ""
360
+ theme = STRATEGY_THEME.get(strategy, "")
361
+ desc = STRATEGY_DESC.get(strategy, "")
362
+ return f"[{theme}] {desc}" if theme else desc
363
+
364
+ # --- View 1: Scenario-specific heatmap ---
365
+ @app.callback(
366
+ [Output("res-title-scenario", "children"),
367
+ Output("res-plot-scenario", "figure"),
368
+ Output("res-plot-scenario", "style"),
369
+ Output("res-stress-row", "children"),
370
+ Output("res-legend-row", "children")],
371
+ [Input("res-strategy-dropdown", "value"),
372
+ Input("res-hc-dropdown", "value")],
373
+ )
374
+ def update_scenario_view(strategy, selected_hc):
375
+ empty_fig = go.Figure()
376
+ empty_fig.update_layout(height=100, margin=dict(t=0, b=0, l=0, r=0), paper_bgcolor="white", plot_bgcolor="white", xaxis=dict(visible=False), yaxis=dict(visible=False))
377
+ hide = {"display": "none"}
378
+
379
+ selected_hc = list(selected_hc or [])
380
+ if not strategy:
381
+ return "Select a strategy to view resilience", empty_fig, hide, "", ""
382
+ if len(selected_hc) < 1:
383
+ return "Select at least one hydroclimate", empty_fig, hide, "", ""
384
+
385
+ desc = STRATEGY_DESC.get(strategy, "")
386
+ title = f"{strategy}: {desc}"
387
+
388
+ # Get data for this strategy, filtered to selected hydroclimates
389
+ strat_data = res_tier_df[(res_tier_df["strategy"] == strategy) & (res_tier_df["hydroclimate"].isin(selected_hc))].copy()
390
+ if strat_data.empty:
391
+ return title, empty_fig, hide, "", ""
392
+
393
+ # Order by user's selection order
394
+ hc_order = {hc: i for i, hc in enumerate(selected_hc)}
395
+ strat_data["_hc_order"] = strat_data["hydroclimate"].map(hc_order)
396
+ strat_data = strat_data.sort_values("_hc_order").drop(columns=["_hc_order"])
397
+
398
+ available_hc = strat_data["hydroclimate"].tolist()
399
+ scenario_ids = strat_data["scenario"].tolist()
400
+ sector_labels = [TIER_LABELS.get(c, c) for c in TIER_COLS]
401
+
402
+ # Build custom hover text matrix
403
+ custom_hover = []
404
+ for i, col in enumerate(TIER_COLS):
405
+ row_hover = []
406
+ for j, (_, hc_row) in enumerate(strat_data.iterrows()):
407
+ val = hc_row[col]
408
+ hc = available_hc[j]
409
+ sid = scenario_ids[j]
410
+ hc_desc = HYDROCLIMATE_META.get(hc, {}).get("desc", hc)
411
+ if pd.isna(val):
412
+ row_hover.append(f"<b>{sector_labels[i]}</b><br>{hc} ({sid})<br>{hc_desc}<br>No data")
413
+ else:
414
+ tier_hover = _get_tier_hover(col, val)
415
+ row_hover.append(f"<b>{sector_labels[i]}</b><br>{hc} ({sid})<br>{hc_desc}<br>Tier value: {val:.2f}{tier_hover}")
416
+ custom_hover.append(row_hover)
417
+
418
+ # Build z-matrix: rows = sectors, columns = hydroclimates
419
+ z = []
420
+ for col in TIER_COLS:
421
+ row_z = []
422
+ for _, hc_row in strat_data.iterrows():
423
+ val = hc_row[col]
424
+ row_z.append(float(val) if pd.notna(val) else np.nan)
425
+ z.append(row_z)
426
+
427
+ z = np.array(z, dtype=float)
428
+ z_rounded = np.round(z)
429
+ annotation_text = np.where(np.isnan(z), "-", np.char.mod("%.1f", z))
430
+
431
+ # X-axis: hydroclimate + scenario ID on second line
432
+ x_labels = [f"{hc}<br>({sid})" for hc, sid in zip(available_hc, scenario_ids)]
433
+ fig = go.Figure(data=go.Heatmap(
434
+ z=z_rounded,
435
+ x=x_labels,
436
+ y=sector_labels,
437
+ text=annotation_text,
438
+ texttemplate="<b>%{text}</b>",
439
+ textfont=dict(size=15, color="white", family=FONT_STACK),
440
+ colorscale=_DISCRETE_COLORSCALE,
441
+ zmin=0.5, zmax=4.5,
442
+ showscale=False,
443
+ customdata=np.array(custom_hover),
444
+ hovertemplate="%{customdata}<extra></extra>",
445
+ xgap=3, ygap=3,
446
+ ))
447
+
448
+ # Separator between DWR and GCM scenarios (wider gap)
449
+ dwr_count = sum(1 for hc in available_hc if HYDROCLIMATE_META.get(hc, {}).get("group") == "DWR")
450
+
451
+ fig.update_layout(
452
+ xaxis=dict(side="top", tickfont=dict(size=14, color=BRAND_TEAL, family=FONT_STACK, weight="bold"), tickangle=0),
453
+ yaxis=dict(autorange="reversed", tickfont=dict(size=13, family=FONT_STACK, weight="bold"), dtick=1),
454
+ margin=dict(t=60, b=10, l=190, r=40),
455
+ height=max(400, len(TIER_COLS) * 36 + 80),
456
+ plot_bgcolor="white", paper_bgcolor="white",
457
+ dragmode=False,
458
+ font=dict(family=FONT_STACK),
459
+ )
460
+
461
+ # Stress counts
462
+ t34_counts = []
463
+ t4_counts = []
464
+ for j in range(len(available_hc)):
465
+ col_vals = z_rounded[:, j]
466
+ valid = col_vals[~np.isnan(col_vals)]
467
+ t34_counts.append(int(np.sum(valid >= 3)))
468
+ t4_counts.append(int(np.sum(valid >= 4)))
469
+
470
+ # Stress counts as HTML
471
+ stress_cells = []
472
+ for j, hc in enumerate(available_hc):
473
+ cell_children = [
474
+ html.Div(f"Tier 3-4: {t34_counts[j]}", style={"fontSize": "13px", "fontWeight": "700", "color": "#EF6C00"}),
475
+ html.Div(f"Tier 4: {t4_counts[j]}", style={"fontSize": "13px", "fontWeight": "700", "color": "#C62828"}),
476
+ ]
477
+ cell_style = {"textAlign": "center", "flex": "1", "padding": "4px 0"}
478
+ stress_cells.append(html.Div(style=cell_style, children=cell_children))
479
+
480
+ stress_row = html.Div(style={"display": "flex", "marginLeft": "190px", "marginRight": "40px", "marginTop": "8px", "gap": "3px"}, children=stress_cells)
481
+
482
+ # Legend
483
+ legend_items = []
484
+ for tier, color in TIER_COLORS.items():
485
+ legend_items.append(html.Div(style={"display": "flex", "alignItems": "center", "gap": "6px"}, children=[
486
+ html.Div(style={"width": "18px", "height": "18px", "backgroundColor": color, "borderRadius": "3px"}),
487
+ html.Span(f"Tier {tier}", style={"fontSize": "13px", "fontWeight": "700", "color": color}),
488
+ ]))
489
+
490
+ legend = html.Div(style={"display": "flex", "justifyContent": "center", "gap": "28px", "marginTop": "16px"}, children=legend_items)
491
+
492
+ fig_height = max(400, len(TIER_COLS) * 36 + 80)
493
+ show = {"display": "block", "height": f"{fig_height}px"}
494
+
495
+ return title, fig, show, stress_row, legend
496
+
497
+ # --- Download callbacks ---
498
+ from downloads import register_plotly_png_callback
499
+ register_plotly_png_callback(app, "res-plot-scenario")
500
+
501
+ @app.callback(
502
+ Output("dl-csv-res-plot-scenario", "data"),
503
+ Input("dl-csv-btn-res-plot-scenario", "n_clicks"),
504
+ [State("res-strategy-dropdown", "value"), State("res-hc-dropdown", "value")],
505
+ prevent_initial_call=True,
506
+ )
507
+ def download_scenario_csv(n_clicks, strategy, selected_hc):
508
+ from dash.exceptions import PreventUpdate
509
+ if not n_clicks or not strategy:
510
+ raise PreventUpdate
511
+ selected_hc = list(selected_hc or HYDROCLIMATE_ORDER)
512
+ out = res_tier_df[(res_tier_df["strategy"] == strategy) & (res_tier_df["hydroclimate"].isin(selected_hc))].copy()
513
+ hc_order = {hc: i for i, hc in enumerate(selected_hc)}
514
+ out["_order"] = out["hydroclimate"].map(hc_order)
515
+ out = out.sort_values("_order").drop(columns=["_order"])
516
+ cols = ["scenario", "strategy", "hydroclimate"] + TIER_COLS
517
+ out = out[[c for c in cols if c in out.columns]]
518
+ return dcc.send_data_frame(out.to_csv, f"resilience_{strategy}.csv", index=False)
519
+
520
+ # --- View 2: System-wide heatmap ---
521
+ @app.callback(
522
+ [Output("res-title-system", "children"),
523
+ Output("res-plot-system", "figure"),
524
+ Output("res-plot-system", "style"),
525
+ Output("res-stress-row-system", "children"),
526
+ Output("res-legend-row-system", "children")],
527
+ [Input("res-system-rows-store", "data")],
528
+ )
529
+ def update_system_view(selected_rows):
530
+ empty_fig = go.Figure()
531
+ empty_fig.update_layout(height=100, margin=dict(t=0, b=0, l=0, r=0), paper_bgcolor="white", plot_bgcolor="white", xaxis=dict(visible=False), yaxis=dict(visible=False))
532
+ hide = {"display": "none"}
533
+
534
+ selected_rows = list(selected_rows or [])
535
+ if not selected_rows:
536
+ return "Select scenario rows to compare", empty_fig, hide, "", ""
537
+
538
+ # Parse "strategy|hydroclimate" values
539
+ pairs = []
540
+ for rv in selected_rows:
541
+ parts = rv.split("|")
542
+ if len(parts) == 2:
543
+ pairs.append((parts[0], parts[1]))
544
+
545
+ if not pairs:
546
+ return "Select scenario rows to compare", empty_fig, hide, "", ""
547
+
548
+ # Filter to selected strategy x hydroclimate pairs
549
+ mask = pd.Series(False, index=res_tier_df.index)
550
+ for strat, hc in pairs:
551
+ mask |= (res_tier_df["strategy"] == strat) & (res_tier_df["hydroclimate"] == hc)
552
+ view_df = res_tier_df[mask].copy()
553
+ if view_df.empty:
554
+ return "No data available", empty_fig, hide, "", ""
555
+
556
+ # Preserve selection order
557
+ pair_order = {f"{s}|{h}": i for i, (s, h) in enumerate(pairs)}
558
+ view_df["_order"] = view_df.apply(lambda r: pair_order.get(f"{r['strategy']}|{r['hydroclimate']}", 999), axis=1)
559
+ view_df = view_df.sort_values("_order").drop(columns=["_order"])
560
+
561
+ # Row labels
562
+ row_labels = []
563
+ for _, row in view_df.iterrows():
564
+ row_labels.append(f"{row['strategy']} - {row['hydroclimate']} ({row['scenario']})")
565
+
566
+ sector_labels = [TIER_LABELS.get(c, c) for c in TIER_COLS]
567
+
568
+ # Build z-matrix: rows = strategy x hydroclimate combos, columns = sectors
569
+ z = []
570
+ custom_hover = []
571
+ for idx, (_, row) in enumerate(view_df.iterrows()):
572
+ row_z = []
573
+ row_hover = []
574
+ for i, col in enumerate(TIER_COLS):
575
+ val = row[col]
576
+ row_z.append(float(val) if pd.notna(val) else np.nan)
577
+ desc = STRATEGY_DESC.get(row["strategy"], "")
578
+ if pd.isna(val):
579
+ row_hover.append(f"<b>{sector_labels[i]}</b><br>{row['strategy']}: {desc}<br>{row['hydroclimate']} ({row['scenario']})<br>No data")
580
+ else:
581
+ tier_hover = _get_tier_hover(col, val)
582
+ row_hover.append(f"<b>{sector_labels[i]}</b><br>{row['strategy']}: {desc}<br>{row['hydroclimate']} ({row['scenario']})<br>Tier value: {val:.2f}{tier_hover}")
583
+ z.append(row_z)
584
+ custom_hover.append(row_hover)
585
+
586
+ z = np.array(z, dtype=float)
587
+ z_rounded = np.round(z)
588
+ annotation_text = np.where(np.isnan(z), "-", np.char.mod("%.1f", z))
589
+
590
+ fig = go.Figure(data=go.Heatmap(
591
+ z=z_rounded,
592
+ x=sector_labels,
593
+ y=row_labels,
594
+ text=annotation_text,
595
+ texttemplate="<b>%{text}</b>",
596
+ textfont=dict(size=13, color="white", family=FONT_STACK),
597
+ colorscale=_DISCRETE_COLORSCALE,
598
+ zmin=0.5, zmax=4.5,
599
+ showscale=False,
600
+ customdata=np.array(custom_hover),
601
+ hovertemplate="%{customdata}<extra></extra>",
602
+ xgap=3, ygap=3,
603
+ ))
604
+
605
+ n_rows = len(row_labels)
606
+ fig.update_layout(
607
+ xaxis=dict(side="top", tickfont=dict(size=12, color=BRAND_TEAL, family=FONT_STACK, weight="bold"), tickangle=45),
608
+ yaxis=dict(autorange="reversed", tickfont=dict(size=11, family=FONT_STACK, weight="bold"), dtick=1),
609
+ margin=dict(t=120, b=10, l=300, r=40),
610
+ height=max(400, n_rows * 32 + 140),
611
+ plot_bgcolor="white", paper_bgcolor="white",
612
+ dragmode=False,
613
+ font=dict(family=FONT_STACK),
614
+ )
615
+
616
+ n_strategies = len(set(p[0] for p in pairs))
617
+ n_hc = len(set(p[1] for p in pairs))
618
+ title = f"System-Wide: {n_strategies} strategies x {n_hc} hydroclimates"
619
+
620
+ # Stress counts per sector column
621
+ t34_counts = []
622
+ t4_counts = []
623
+ for j in range(len(TIER_COLS)):
624
+ col_vals = z_rounded[:, j]
625
+ valid = col_vals[~np.isnan(col_vals)]
626
+ t34_counts.append(int(np.sum(valid >= 3)))
627
+ t4_counts.append(int(np.sum(valid >= 4)))
628
+
629
+ # Legend
630
+ legend_items = []
631
+ for tier, color in TIER_COLORS.items():
632
+ legend_items.append(html.Div(style={"display": "flex", "alignItems": "center", "gap": "6px"}, children=[
633
+ html.Div(style={"width": "18px", "height": "18px", "backgroundColor": color, "borderRadius": "3px"}),
634
+ html.Span(f"Tier {tier}", style={"fontSize": "13px", "fontWeight": "700", "color": color}),
635
+ ]))
636
+ legend = html.Div(style={"display": "flex", "justifyContent": "center", "gap": "28px", "marginTop": "16px"}, children=legend_items)
637
+
638
+ fig_height = max(400, n_rows * 32 + 140)
639
+ show = {"display": "block", "height": f"{fig_height}px"}
640
+
641
+ return title, fig, show, "", legend
642
+
643
+ # --- System-wide download callbacks ---
644
+ register_plotly_png_callback(app, "res-plot-system")
645
+
646
+ @app.callback(
647
+ Output("dl-csv-res-plot-system", "data"),
648
+ Input("dl-csv-btn-res-plot-system", "n_clicks"),
649
+ [State("res-system-rows-store", "data")],
650
+ prevent_initial_call=True,
651
+ )
652
+ def download_system_csv(n_clicks, selected_rows):
653
+ from dash.exceptions import PreventUpdate
654
+ if not n_clicks or not selected_rows:
655
+ raise PreventUpdate
656
+ pairs = [rv.split("|") for rv in selected_rows if "|" in rv]
657
+ mask = pd.Series(False, index=res_tier_df.index)
658
+ for strat, hc in pairs:
659
+ mask |= (res_tier_df["strategy"] == strat) & (res_tier_df["hydroclimate"] == hc)
660
+ out = res_tier_df[mask].copy()
661
+ cols = ["scenario", "strategy", "hydroclimate"] + TIER_COLS
662
+ out = out[[c for c in cols if c in out.columns]]
663
+ return dcc.send_data_frame(out.to_csv, "resilience_system_wide.csv", index=False)
664
+
665
+ # --- View 3: LOI filter checklist ---
666
+ @app.callback(
667
+ [Output("res-loi-filter-content", "style"),
668
+ Output("res-loi-no-lois", "style"),
669
+ Output("res-loi-checklist", "options"),
670
+ Output("res-loi-checklist", "value"),
671
+ Output("res-loi-title", "children")],
672
+ Input("res-outcome-dropdown", "value"),
673
+ )
674
+ def build_loi_checklist(sector):
675
+ show, hide = {"display": "block"}, {"display": "none"}
676
+ if not sector or not HAS_LOI_DATA:
677
+ return hide, hide, [], [], ""
678
+ lois = LOI_MAPPING.get(sector, [])
679
+ if not lois:
680
+ return hide, show, [], [], ""
681
+ loi_names = [l["loi"] for l in lois]
682
+ options = [{"label": name, "value": name} for name in loi_names]
683
+ return show, hide, options, loi_names, f"Filter LOIs ({len(loi_names)} available)"
684
+
685
+
686
+ # --- View 3: Outcome-specific heatmap ---
687
+ @app.callback(
688
+ [Output("res-title-outcome", "children"),
689
+ Output("res-plot-outcome", "figure"),
690
+ Output("res-plot-outcome", "style"),
691
+ Output("res-legend-row-outcome", "children")],
692
+ [Input("res-outcome-dropdown", "value"),
693
+ Input("res-outcome-strategies", "value"),
694
+ Input("res-loi-checklist", "value")],
695
+ )
696
+ def update_outcome_view(sector, strategies, selected_lois):
697
+ empty_fig = go.Figure()
698
+ empty_fig.update_layout(height=100, margin=dict(t=0, b=0, l=0, r=0), paper_bgcolor="white", plot_bgcolor="white", xaxis=dict(visible=False), yaxis=dict(visible=False))
699
+ hide = {"display": "none"}
700
+
701
+ strategies = list(strategies or [])
702
+ if not sector:
703
+ return "Select a sector to compare", empty_fig, hide, ""
704
+ if not strategies:
705
+ return f"{TIER_LABELS.get(sector, sector)} - select strategies", empty_fig, hide, ""
706
+
707
+ view_df = res_tier_df[res_tier_df["strategy"].isin(strategies)].copy()
708
+ if view_df.empty:
709
+ return "No data available", empty_fig, hide, ""
710
+
711
+ # If LOI filter is active and we have LOI data, recalculate averages
712
+ all_lois = LOI_MAPPING.get(sector, [])
713
+ all_loi_names = [l["loi"] for l in all_lois]
714
+ selected_lois = list(selected_lois or [])
715
+ using_loi_filter = HAS_LOI_DATA and len(all_loi_names) > 0 and len(selected_lois) > 0 and set(selected_lois) != set(all_loi_names)
716
+
717
+ if using_loi_filter:
718
+ # Recalculate sector average from selected LOIs only
719
+ valid_lois = [l for l in selected_lois if l in _loi_tiers.columns]
720
+ if valid_lois:
721
+ loi_lookup = _loi_tiers.set_index("scenario")
722
+ for idx, row in view_df.iterrows():
723
+ scen = row["scenario"]
724
+ if scen in loi_lookup.index:
725
+ loi_vals = loi_lookup.loc[scen, valid_lois]
726
+ view_df.at[idx, sector] = loi_vals.mean(skipna=True)
727
+ # else: keep the existing value (synthetic or pre-computed)
728
+
729
+ # Order by strategy selection, then hydroclimate order
730
+ strat_order = {s: i for i, s in enumerate(strategies)}
731
+ hc_order = {hc: i for i, hc in enumerate(HYDROCLIMATE_ORDER)}
732
+ view_df["_s_order"] = view_df["strategy"].map(strat_order)
733
+ view_df["_h_order"] = view_df["hydroclimate"].map(hc_order)
734
+ view_df = view_df.sort_values(["_s_order", "_h_order"]).drop(columns=["_s_order", "_h_order"])
735
+
736
+ # Get available hydroclimates across all selected strategies
737
+ available_hc = [hc for hc in HYDROCLIMATE_ORDER if hc in view_df["hydroclimate"].values]
738
+
739
+ # Build z-matrix: rows = strategies, columns = hydroclimates
740
+ # Each cell = tier value for the selected sector
741
+ row_labels = []
742
+ z = []
743
+ custom_hover = []
744
+ for strat in strategies:
745
+ strat_rows = view_df[view_df["strategy"] == strat]
746
+ desc = STRATEGY_DESC.get(strat, "")
747
+ theme = STRATEGY_THEME.get(strat, "")
748
+ row_label = f"{strat} ({theme})"
749
+ row_labels.append(row_label)
750
+ row_z = []
751
+ row_hover = []
752
+ for hc in available_hc:
753
+ hc_row = strat_rows[strat_rows["hydroclimate"] == hc]
754
+ if hc_row.empty:
755
+ row_z.append(np.nan)
756
+ row_hover.append(f"<b>{row_label}</b><br>{hc}<br>No data")
757
+ else:
758
+ val = hc_row[sector].values[0]
759
+ sid = hc_row["scenario"].values[0]
760
+ if pd.isna(val):
761
+ row_z.append(np.nan)
762
+ row_hover.append(f"<b>{row_label}</b><br>{hc} ({sid})<br>{desc}<br>No data")
763
+ else:
764
+ row_z.append(float(val))
765
+ tier_hover = _get_tier_hover(sector, val)
766
+ row_hover.append(f"<b>{row_label}</b><br>{hc} ({sid})<br>{desc}<br>Tier value: {val:.2f}{tier_hover}")
767
+ z.append(row_z)
768
+ custom_hover.append(row_hover)
769
+
770
+ z = np.array(z, dtype=float)
771
+ z_rounded = np.round(z)
772
+ annotation_text = np.where(np.isnan(z), "-", np.char.mod("%.1f", z))
773
+
774
+ fig = go.Figure(data=go.Heatmap(
775
+ z=z_rounded,
776
+ x=available_hc,
777
+ y=row_labels,
778
+ text=annotation_text,
779
+ texttemplate="<b>%{text}</b>",
780
+ textfont=dict(size=15, color="white", family=FONT_STACK),
781
+ colorscale=_DISCRETE_COLORSCALE,
782
+ zmin=0.5, zmax=4.5,
783
+ showscale=False,
784
+ customdata=np.array(custom_hover),
785
+ hovertemplate="%{customdata}<extra></extra>",
786
+ xgap=3, ygap=3,
787
+ ))
788
+
789
+ n_rows = len(row_labels)
790
+ fig.update_layout(
791
+ xaxis=dict(side="top", tickfont=dict(size=14, color=BRAND_TEAL, family=FONT_STACK, weight="bold"), tickangle=0),
792
+ yaxis=dict(autorange="reversed", tickfont=dict(size=12, family=FONT_STACK, weight="bold"), dtick=1),
793
+ margin=dict(t=60, b=10, l=220, r=40),
794
+ height=max(300, n_rows * 36 + 100),
795
+ plot_bgcolor="white", paper_bgcolor="white",
796
+ dragmode=False,
797
+ font=dict(family=FONT_STACK),
798
+ )
799
+
800
+ sector_label = TIER_LABELS.get(sector, sector)
801
+ loi_label = f" ({len(selected_lois)}/{len(all_loi_names)} LOIs)" if using_loi_filter else ""
802
+ title = f"{sector_label}{loi_label}: {len(strategies)} strategies across hydroclimates"
803
+
804
+ # Legend
805
+ legend_items = []
806
+ for tier, color in TIER_COLORS.items():
807
+ legend_items.append(html.Div(style={"display": "flex", "alignItems": "center", "gap": "6px"}, children=[
808
+ html.Div(style={"width": "18px", "height": "18px", "backgroundColor": color, "borderRadius": "3px"}),
809
+ html.Span(f"Tier {tier}", style={"fontSize": "13px", "fontWeight": "700", "color": color}),
810
+ ]))
811
+ legend = html.Div(style={"display": "flex", "justifyContent": "center", "gap": "28px", "marginTop": "16px"}, children=legend_items)
812
+
813
+ fig_height = max(300, n_rows * 36 + 100)
814
+ show = {"display": "block", "height": f"{fig_height}px"}
815
+
816
+ return title, fig, show, legend
817
+
818
+ # --- Outcome download callbacks ---
819
+ register_plotly_png_callback(app, "res-plot-outcome")
820
+
821
+ @app.callback(
822
+ Output("dl-csv-res-plot-outcome", "data"),
823
+ Input("dl-csv-btn-res-plot-outcome", "n_clicks"),
824
+ [State("res-outcome-dropdown", "value"), State("res-outcome-strategies", "value")],
825
+ prevent_initial_call=True,
826
+ )
827
+ def download_outcome_csv(n_clicks, sector, strategies):
828
+ from dash.exceptions import PreventUpdate
829
+ if not n_clicks or not sector or not strategies:
830
+ raise PreventUpdate
831
+ strategies = list(strategies or [])
832
+ out = res_tier_df[res_tier_df["strategy"].isin(strategies)].copy()
833
+ cols = ["scenario", "strategy", "hydroclimate", sector]
834
+ out = out[[c for c in cols if c in out.columns]]
835
+ return dcc.send_data_frame(out.to_csv, f"resilience_outcome_{sector}.csv", index=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
varmatch.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single source of truth for CalSim3 column-name grammar and variable matching.
2
+
3
+ Every COEQWAL column encodes the same grammar:
4
+
5
+ Part B (MultiIndex level 1): ``<base>_s####`` e.g. ``S_SHSTA_s0020``
6
+ flattened (dashboard): ``<SOURCE>_<base>_s####_<C>_<D>_<E>_<F>_<UNIT>``
7
+ e.g. ``CALSIM_S_SHSTA_s0020_STORAGE_1MON_L2020A_PER-AVER_TAF``
8
+
9
+ This module is the ONLY place that knows that grammar. It does exact, parse-based
10
+ matching of a variable to its column(s) -- it never substring-matches, so a base
11
+ name that is a prefix of another (``S_SHSTA`` vs ``S_SHSTALEVEL1DV``;
12
+ ``DEL_CVP_TOTAL`` vs ``DEL_CVP_TOTAL_N``) can never collide.
13
+
14
+ Design rules:
15
+ * Pure: depends only on ``re`` / ``numpy``. Imports no other coeqwal module, so it
16
+ can be a dependency of both ``metrics`` and ``cqwlutils`` without a cycle, and can
17
+ be vendored byte-for-byte into the standalone dashboard.
18
+ * No silent fallback: ``col_for`` RAISES on an ambiguous match instead of silently
19
+ returning the first column. Over-matching surfaces immediately.
20
+
21
+ Variable names passed in are always the BARE base (``"S_SHSTA"``, ``"DEL_SWP_TOTA"``) --
22
+ never a trailing-underscore form. The grammar boundary (``_s####``) is supplied here.
23
+ """
24
+
25
+ import re
26
+
27
+ import numpy as np
28
+
29
+ # Known Part-A source prefixes seen in flattened dashboard column names.
30
+ SOURCES = ("CALSIM", "CALCULATED", "MANUAL-ADD", "IWFM")
31
+
32
+ _SCEN = r"s\d+"
33
+ _PARTB_RE = re.compile(rf"^(?P<base>.+)_(?P<scen>{_SCEN})$") # greedy: takes the LAST _s####
34
+ _SRC_RE = re.compile(r"^(?P<src>" + "|".join(SOURCES) + r")_(?P<rest>.+)$")
35
+ _FLAT_RE = re.compile(rf"^(?P<base>.+?)_(?P<scen>{_SCEN})(?:_(?P<tail>.*))?$") # non-greedy: first _s####
36
+
37
+
38
+ def sid(n):
39
+ """Canonical scenario id. Accepts int (20), str ('20' or 's0020') -> 's0020'."""
40
+ if isinstance(n, str):
41
+ m = re.fullmatch(r"s?0*(\d+)", n.strip())
42
+ if not m:
43
+ raise ValueError(f"varmatch.sid: not a scenario id: {n!r}")
44
+ n = int(m.group(1))
45
+ return f"s{int(n):04d}"
46
+
47
+
48
+ def parse_column(col):
49
+ """Parse a column into ``{source, base, scenario, unit}`` or ``None``.
50
+
51
+ Handles both a 7-level MultiIndex tuple and a flattened string. Returns ``None``
52
+ for columns that are not ``<base>_s####`` variable columns (e.g. ``WaterYear``).
53
+ """
54
+ if isinstance(col, tuple):
55
+ pb = parse_partb(col[1])
56
+ if pb is None:
57
+ return None
58
+ base, scen = pb
59
+ unit = str(col[6]).strip() if len(col) > 6 and col[6] is not None else None
60
+ return {"source": str(col[0]).strip(), "base": base, "scenario": scen, "unit": unit}
61
+ return _parse_flat(str(col))
62
+
63
+
64
+ def parse_partb(part_b):
65
+ """``'<base>_s####'`` -> ``(base, 's####')``; ``None`` if no scenario suffix."""
66
+ m = _PARTB_RE.match(str(part_b).strip())
67
+ if not m:
68
+ return None
69
+ return m.group("base").strip(), m.group("scen")
70
+
71
+
72
+ def _parse_flat(s):
73
+ s = s.strip()
74
+ src = None
75
+ m = _SRC_RE.match(s)
76
+ if m:
77
+ src, s = m.group("src"), m.group("rest")
78
+ m = _FLAT_RE.match(s)
79
+ if not m:
80
+ return None
81
+ tail = m.group("tail")
82
+ unit = tail.rsplit("_", 1)[-1] if tail else None
83
+ return {"source": src, "base": m.group("base").strip(), "scenario": m.group("scen"),
84
+ "unit": unit.strip() if unit else None}
85
+
86
+
87
+ def base_of(col):
88
+ r = parse_column(col)
89
+ return r["base"] if r else None
90
+
91
+
92
+ def scenario_of(col):
93
+ r = parse_column(col)
94
+ return r["scenario"] if r else None
95
+
96
+
97
+ def unit_of(col):
98
+ r = parse_column(col)
99
+ return r["unit"] if r else None
100
+
101
+
102
+ def _unit_eq(a, b):
103
+ if b is None:
104
+ return True
105
+ return a is not None and str(a).strip().upper() == str(b).strip().upper()
106
+
107
+
108
+ def mask_for(columns, var, unit=None):
109
+ """Boolean mask selecting every column belonging to base ``var`` (any scenario),
110
+ optionally filtered by ``unit``. Exact base match. Also accepts an exact full
111
+ instance name (``"S_SHSTA_s0154"``) to select that single scenario's column --
112
+ still exact equality, never substring."""
113
+ var = str(var).strip()
114
+ return np.array([
115
+ (r := parse_column(c)) is not None and _unit_eq(r["unit"], unit)
116
+ and (r["base"] == var or f"{r['base']}_{r['scenario']}" == var)
117
+ for c in columns
118
+ ])
119
+
120
+
121
+ def mask_for_many(columns, vars_, unit=None):
122
+ """Union mask over several bases and/or full instance names (exact).
123
+ Replaces ``str.contains('|'.join(...))``."""
124
+ want = {str(v).strip() for v in vars_}
125
+ return np.array([
126
+ (r := parse_column(c)) is not None and _unit_eq(r["unit"], unit)
127
+ and (r["base"] in want or f"{r['base']}_{r['scenario']}" in want)
128
+ for c in columns
129
+ ])
130
+
131
+
132
+ def col_for(columns, var, scenario, unit=None):
133
+ """The single column for (base ``var``, ``scenario``[, ``unit``]).
134
+
135
+ Returns ``None`` if absent. RAISES ``ValueError`` on >1 match (ambiguity) -- never
136
+ silently returns the first, so over-matching is caught at the call site.
137
+ """
138
+ var = str(var).strip()
139
+ scen = sid(scenario)
140
+ matches = [
141
+ c for c in columns
142
+ if (r := parse_column(c)) is not None
143
+ and r["base"] == var and r["scenario"] == scen and _unit_eq(r["unit"], unit)
144
+ ]
145
+ if len(matches) > 1:
146
+ raise ValueError(
147
+ f"varmatch.col_for: ambiguous match for var={var!r} scenario={scen!r} "
148
+ f"unit={unit!r} -> {len(matches)} columns: {matches[:4]}"
149
+ )
150
+ return matches[0] if matches else None
151
+
152
+
153
+ def build_index(columns):
154
+ """``{(base, scenario, unit): column}`` for O(1) exact lookup. Raises on collision."""
155
+ idx = {}
156
+ for c in columns:
157
+ r = parse_column(c)
158
+ if r is None:
159
+ continue
160
+ key = (r["base"], r["scenario"], r["unit"])
161
+ if key in idx:
162
+ raise ValueError(f"varmatch.build_index: duplicate key {key}: {idx[key]} vs {c}")
163
+ idx[key] = c
164
+ return idx