Spaces:
Sleeping
Sleeping
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
- app.py +4 -8
- data/loi_mapping.json +65 -81
- plines.py +3 -6
- radar.py +727 -726
- resilience.py +835 -867
- 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 |
-
|
| 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": "
|
| 177 |
"region": "NOD"
|
| 178 |
},
|
| 179 |
{
|
| 180 |
-
"loi": "
|
| 181 |
"region": "NOD"
|
| 182 |
},
|
| 183 |
{
|
| 184 |
-
"loi": "
|
| 185 |
"region": "NOD"
|
| 186 |
},
|
| 187 |
{
|
| 188 |
-
"loi": "
|
| 189 |
"region": "NOD"
|
| 190 |
}
|
| 191 |
],
|
| 192 |
"SOD_Reservoir_Mean": [
|
| 193 |
{
|
| 194 |
-
"loi": "
|
| 195 |
"region": "SOD"
|
| 196 |
},
|
| 197 |
{
|
| 198 |
-
"loi": "
|
| 199 |
"region": "SOD"
|
| 200 |
},
|
| 201 |
{
|
| 202 |
-
"loi": "
|
| 203 |
"region": "SOD"
|
| 204 |
},
|
| 205 |
{
|
| 206 |
-
"loi": "
|
| 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": "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 853 |
"region": "NOD"
|
| 854 |
},
|
| 855 |
{
|
|
@@ -857,7 +865,11 @@
|
|
| 857 |
"region": "NOD"
|
| 858 |
},
|
| 859 |
{
|
| 860 |
-
"loi": "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": "
|
| 897 |
"region": "NOD"
|
| 898 |
-
}
|
| 899 |
-
],
|
| 900 |
-
"SOD_DW_Mean": [
|
| 901 |
-
{
|
| 902 |
-
"loi": "50_PU",
|
| 903 |
-
"region": "SOD"
|
| 904 |
},
|
| 905 |
{
|
| 906 |
-
"loi": "
|
| 907 |
-
"region": "
|
| 908 |
-
},
|
| 909 |
-
{
|
| 910 |
-
"loi": "60N_NU2",
|
| 911 |
-
"region": "SOD"
|
| 912 |
},
|
| 913 |
{
|
| 914 |
-
"loi": "
|
| 915 |
-
"region": "
|
| 916 |
},
|
| 917 |
{
|
| 918 |
-
"loi": "
|
| 919 |
-
"region": "
|
| 920 |
},
|
| 921 |
{
|
| 922 |
-
"loi": "
|
| 923 |
-
"region": "
|
| 924 |
},
|
| 925 |
{
|
| 926 |
-
"loi": "
|
| 927 |
-
"region": "
|
| 928 |
-
}
|
|
|
|
|
|
|
| 929 |
{
|
| 930 |
-
"loi": "
|
| 931 |
"region": "SOD"
|
| 932 |
},
|
| 933 |
{
|
| 934 |
-
"loi": "
|
| 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": "
|
| 971 |
-
"region": "SOD"
|
| 972 |
-
},
|
| 973 |
-
{
|
| 974 |
-
"loi": "CSTIC",
|
| 975 |
"region": "SOD"
|
| 976 |
},
|
| 977 |
{
|
|
@@ -983,11 +991,11 @@
|
|
| 983 |
"region": "SOD"
|
| 984 |
},
|
| 985 |
{
|
| 986 |
-
"loi": "
|
| 987 |
"region": "SOD"
|
| 988 |
},
|
| 989 |
{
|
| 990 |
-
"loi": "
|
| 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": "
|
| 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": "
|
| 1047 |
"region": "SOD"
|
| 1048 |
},
|
| 1049 |
{
|
| 1050 |
-
"loi": "
|
| 1051 |
"region": "SOD"
|
| 1052 |
},
|
| 1053 |
{
|
| 1054 |
-
"loi": "
|
| 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/
|
| 47 |
-
except
|
| 48 |
-
|
| 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/
|
| 33 |
-
except
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
"
|
| 46 |
-
"
|
| 47 |
-
"
|
| 48 |
-
"
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
"
|
| 63 |
-
"
|
| 64 |
-
"
|
| 65 |
-
"
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
scen =
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
ax.
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
ax.
|
| 192 |
-
ax.
|
| 193 |
-
ax.
|
| 194 |
-
ax.
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
if
|
| 243 |
-
continue
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
inv_closed =
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
std_val =
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
plt.
|
| 294 |
-
buf
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
inv_r = invert(tier)
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
valid_scenarios
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
hover_texts
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
desc_key
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
desc_key
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
html.
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
html.Div("
|
| 508 |
-
html.Div("
|
| 509 |
-
html.Div("
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
State("radar-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
Output("
|
| 597 |
-
Output("radar-
|
| 598 |
-
Output("radar-
|
| 599 |
-
Output("radar-data-table", "
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
html.
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
html.Span(f"{
|
| 644 |
-
html.Span(f"{
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
table_cols
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
Output("radar-dist-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
Input("radar-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
|
|
|
|
|
| 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 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
#
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
"""
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
"
|
| 118 |
-
"
|
| 119 |
-
"
|
| 120 |
-
"
|
| 121 |
-
"
|
| 122 |
-
"
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
"
|
| 133 |
-
"
|
| 134 |
-
"
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
html.Div("
|
| 192 |
-
dcc.Dropdown(id="res-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
html.
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
html.Div(id="res-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
html.Div(id="res-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
Output("res-view-
|
| 266 |
-
Output("res-
|
| 267 |
-
Output("res-sidebar-
|
| 268 |
-
Output("res-sidebar-
|
| 269 |
-
Output("res-
|
| 270 |
-
Output("res-toggle-
|
| 271 |
-
Output("res-toggle-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
Input("res-toggle-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
#
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
)
|
| 529 |
-
def
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
for
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
if not
|
| 705 |
-
return
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 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
|