.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ WR_PMP_MultiCalib_Policy[[:space:]](8).xlsx filter=lfs diff=lfs merge=lfs -text
Dockerfile CHANGED
@@ -1,16 +1,8 @@
1
- FROM python:3.12
2
- COPY --from=ghcr.io/astral-sh/uv:0.4.20 /uv /bin/uv
3
-
4
- RUN useradd -m -u 1000 user
5
- ENV PATH="/home/user/.local/bin:$PATH"
6
- ENV UV_SYSTEM_PYTHON=1
7
-
8
  WORKDIR /app
9
-
10
- COPY --chown=user ./requirements.txt requirements.txt
11
- RUN uv pip install -r requirements.txt
12
-
13
- COPY --chown=user . /app
14
- USER user
15
-
16
- CMD ["gunicorn", "app:server", "--workers", "4", "--bind", "0.0.0.0:7860"]
 
1
+ FROM python:3.11-slim
 
 
 
 
 
 
2
  WORKDIR /app
3
+ COPY requirements.txt /app/
4
+ RUN pip install --no-cache-dir -r requirements.txt
5
+ COPY . /app
6
+ ENV PORT=7860
7
+ EXPOSE 7860
8
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:server"]
 
 
Requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ dash==2.17.1
2
+ pandas
3
+ plotly
4
+ openpyxl
5
+ gunicorn
WR_PMP_MultiCalib_Policy (8).xlsx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3b441e1cd5c950052db6628d95805a2a25401bdade0271261994d77f02de2b76
3
+ size 150119254
app.py CHANGED
@@ -1,332 +1,577 @@
1
- import dash
2
- import dash_mantine_components as dmc
 
 
 
3
  import plotly.express as px
4
- from dash import Input, Output, callback, dcc, html
5
- from dash_iconify import DashIconify
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
 
 
 
7
 
8
- app = dash.Dash(__name__)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  server = app.server
10
 
11
- df = px.data.gapminder()
12
-
13
-
14
- def create_scatter_plot(selected_year, selected_continent=None):
15
- filtered_df = df[df["year"] == selected_year]
16
-
17
- if selected_continent and selected_continent != "All":
18
- filtered_df = filtered_df[filtered_df["continent"] == selected_continent]
19
-
20
- fig = px.scatter(
21
- filtered_df,
22
- x="gdpPercap",
23
- y="lifeExp",
24
- size="pop",
25
- color="continent",
26
- hover_name="country",
27
- log_x=True,
28
- size_max=60,
29
- title=f"Life Expectancy vs GDP per Capita ({selected_year})",
30
- )
31
-
32
- fig.update_layout(
33
- template="plotly_dark",
34
- paper_bgcolor="rgba(0,0,0,0)",
35
- plot_bgcolor="rgba(0,0,0,0)",
36
- )
37
-
38
- return fig
39
-
40
-
41
- def create_line_chart(selected_country):
42
- country_data = df[df["country"] == selected_country]
43
- fig = px.line(
44
- country_data,
45
- x="year",
46
- y="lifeExp",
47
- title=f"{selected_country} - Life Expectancy",
48
- )
49
- fig.update_layout(
50
- template="plotly_dark",
51
- paper_bgcolor="rgba(0,0,0,0)",
52
- plot_bgcolor="rgba(0,0,0,0)",
53
- )
54
- return fig
55
-
56
-
57
- def create_bar_chart(selected_year):
58
- year_data = df[df["year"] == selected_year]
59
- continent_stats = year_data.groupby("continent")["lifeExp"].mean().reset_index()
60
- fig = px.bar(
61
- continent_stats,
62
- x="continent",
63
- y="lifeExp",
64
- color="continent",
65
- title=f"Average Life Expectancy by Continent ({selected_year})",
66
- )
67
- fig.update_layout(
68
- template="plotly_dark",
69
- paper_bgcolor="rgba(0,0,0,0)",
70
- plot_bgcolor="rgba(0,0,0,0)",
71
- showlegend=False,
72
- )
73
- return fig
74
-
75
-
76
- def create_datacard(title, value, icon, color):
77
- return dmc.Card(
78
- [
79
- dmc.Group(
80
- [
81
- DashIconify(icon=icon, width=30, color=color),
82
- html.Div(
83
- [
84
- dmc.Text(value, size="xl", fw=700, c="white"),
85
- dmc.Text(title, size="sm", c="dimmed"),
86
- ]
87
- ),
88
- ],
89
- align="center",
90
- gap="md",
91
- )
92
- ],
93
- p="md",
94
- className="datacard",
95
- )
96
-
97
-
98
- app.layout = dmc.MantineProvider(
99
- [
100
- html.Link(
101
- href="https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap",
102
- rel="stylesheet",
103
- ),
104
- dmc.Group(
105
- [
106
- DashIconify(icon="twemoji:globe-with-meridians", width=45),
107
- dmc.Text(
108
- "Gapminder World Data Explorer", ml=10, size="xl", fw=900, c="white"
109
- ),
110
- ],
111
- align="center",
112
- className="header",
113
- mb="md",
114
- ),
115
- dmc.Grid(
116
- [
117
- dmc.GridCol(
118
- [
119
- dmc.Stack(
120
- [
121
- dmc.Card(
122
- [
123
- dmc.Text("Controls", size="lg", mb="md"),
124
- dmc.Stack(
125
- [
126
- html.Div(
127
- [
128
- dmc.Text(
129
- "Year:", size="sm", mb=5
130
- ),
131
- dmc.Slider(
132
- id="year-slider",
133
- min=1952,
134
- max=2007,
135
- step=5,
136
- value=2007,
137
- marks=[
138
- {
139
- "value": year,
140
- "label": str(year),
141
- }
142
- for year in [
143
- 1952,
144
- 1967,
145
- 1982,
146
- 1997,
147
- 2007,
148
- ]
149
- ],
150
- ),
151
- ]
152
- ),
153
- html.Div(
154
- [
155
- dmc.Text(
156
- "Continent Filter:",
157
- size="sm",
158
- mb=5,
159
- ),
160
- dmc.Select(
161
- id="continent-dropdown",
162
- data=[
163
- {
164
- "value": "All",
165
- "label": "All Continents",
166
- }
167
- ]
168
- + [
169
- {
170
- "value": cont,
171
- "label": cont,
172
- }
173
- for cont in sorted(
174
- df[
175
- "continent"
176
- ].unique()
177
- )
178
- ],
179
- value="All",
180
- ),
181
- ]
182
- ),
183
- html.Div(
184
- [
185
- dmc.Text(
186
- "Select Country:",
187
- size="sm",
188
- mb=5,
189
- ),
190
- dmc.Select(
191
- id="country-dropdown",
192
- data=[
193
- {
194
- "value": country,
195
- "label": country,
196
- }
197
- for country in sorted(
198
- df[
199
- "country"
200
- ].unique()
201
- )
202
- ],
203
- value="United States",
204
- searchable=True,
205
- ),
206
- ]
207
- ),
208
- ],
209
- gap="lg",
210
- ),
211
- ],
212
- p="md",
213
- className="control-card",
214
- )
215
- ]
216
- )
217
- ],
218
- span=3,
219
- ),
220
- dmc.GridCol(
221
- [
222
- dmc.Stack(
223
- [
224
- html.Div(id="stats-cards"),
225
- dmc.Card(
226
- [dcc.Graph(id="scatter-plot")],
227
- p="sm",
228
- className="chart-card",
229
- ),
230
- ],
231
- gap="md",
232
- )
233
- ],
234
- span=9,
235
- ),
236
- ],
237
- gutter="md",
238
- ),
239
- dmc.Grid(
240
- [
241
- dmc.GridCol(
242
- [
243
- dmc.Card(
244
- [dcc.Graph(id="line-chart")], p="sm", className="chart-card"
245
- )
246
- ],
247
- span=6,
248
- ),
249
- dmc.GridCol(
250
- [
251
- dmc.Card(
252
- [dcc.Graph(id="bar-chart")], p="sm", className="chart-card"
253
- )
254
- ],
255
- span=6,
256
- ),
257
- ],
258
- gutter="md",
259
- mt="md",
260
  ),
261
- ],
262
- forceColorScheme="dark",
263
- theme={"colorScheme": "dark"},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  )
265
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
 
267
- @callback(
268
- Output("scatter-plot", "figure"),
269
- [Input("year-slider", "value"), Input("continent-dropdown", "value")],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  )
271
- def update_scatter_plot(selected_year, selected_continent):
272
- return create_scatter_plot(selected_year, selected_continent)
273
-
274
-
275
- @callback(Output("line-chart", "figure"), Input("country-dropdown", "value"))
276
- def update_line_chart(selected_country):
277
- return create_line_chart(selected_country)
278
-
279
-
280
- @callback(Output("bar-chart", "figure"), Input("year-slider", "value"))
281
- def update_bar_chart(selected_year):
282
- return create_bar_chart(selected_year)
283
-
284
-
285
- @callback(Output("stats-cards", "children"), Input("year-slider", "value"))
286
- def update_stats(selected_year):
287
- year_data = df[df["year"] == selected_year]
288
-
289
- avg_life_exp = round(year_data["lifeExp"].mean(), 1)
290
- total_pop = year_data["pop"].sum()
291
- num_countries = len(year_data)
292
- avg_gdp = round(year_data["gdpPercap"].mean(), 0)
293
-
294
- return dmc.Grid(
295
- [
296
- dmc.GridCol(
297
- create_datacard(
298
- "Life Expectancy",
299
- f"{avg_life_exp} years",
300
- "mdi:heart-pulse",
301
- "#ff6b35",
302
- ),
303
- span=3,
304
- ),
305
- dmc.GridCol(
306
- create_datacard(
307
- "Population",
308
- f"{total_pop / 1e9:.1f}B",
309
- "mdi:account-group",
310
- "#1f77b4",
311
- ),
312
- span=3,
313
- ),
314
- dmc.GridCol(
315
- create_datacard(
316
- "Countries", str(num_countries), "mdi:earth", "#2ca02c"
317
- ),
318
- span=3,
319
- ),
320
- dmc.GridCol(
321
- create_datacard(
322
- "GDP per Capita", f"${avg_gdp:,.0f}", "mdi:currency-usd", "#d62728"
323
- ),
324
- span=3,
325
- ),
326
- ],
327
- gutter="sm",
328
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
 
 
330
 
 
331
  if __name__ == "__main__":
332
- app.run(debug=True, port=8050)
 
 
1
+ # app.py
2
+ import os
3
+ import re
4
+ import pandas as pd
5
+ import plotly.graph_objs as go
6
  import plotly.express as px
7
+ import dash
8
+ from dash import dcc, html, Input, Output
9
+
10
+ # ========= CONFIG =========
11
+ EXCEL_MAIN = "WR_PMP_MultiCalib_Policy.xlsx"
12
+ EXCEL_MUN = "crop_surface_subterraneotheft_parsed.xlsx"
13
+ BASE_YEAR = 2015 # se assente, fallback = primo anno del gruppo
14
+
15
+ SCENARIO_DESCR = {
16
+ "bau": "Business-as-usual: evoluzione osservata, senza nuove misure restrittive.",
17
+ "scenario2": "Scenario 2: restrizioni/progressive o pricing moderato.",
18
+ "scenario5": "Scenario 5: regolazione stringente con segnali di prezzo più forti.",
19
+ "nwt": "No Water Theft: pieno enforcement e azzeramento dei prelievi illegali.",
20
+ }
21
+
22
+ # ========= HELPERS =========
23
+ def _first_existing_column(df, candidates, required=True):
24
+ for c in candidates:
25
+ if c in df.columns:
26
+ return c
27
+ if required:
28
+ raise KeyError(f"Nessuna colonna trovata tra: {candidates}")
29
+ return None
30
+
31
+ def _find_column_regex(df, pattern, required=False):
32
+ """Find first column matching regex (case-insensitive)."""
33
+ pat = re.compile(pattern, re.I)
34
+ for col in df.columns:
35
+ if pat.search(str(col)):
36
+ return col
37
+ if required:
38
+ raise KeyError(f"Nessuna colonna che combacia con regex: {pattern}")
39
+ return None
40
+
41
+ def municipio_label(code, mapper):
42
+ if pd.isna(code):
43
+ return "NA"
44
+ code = int(code)
45
+ name = mapper.get(code, str(code))
46
+ return f"{name} ({code})"
47
+
48
+ def is_donana(v):
49
+ return v == "DONANA"
50
+
51
+ def weighted_mean(group_df, value_col, weights_df):
52
+ g = group_df.merge(weights_df, on="Municipio", how="left")
53
+ g = g.dropna(subset=[value_col, "Hectares"])
54
+ denom = g["Hectares"].sum()
55
+ if denom == 0:
56
+ return None
57
+ return (g[value_col] * g["Hectares"]).sum() / denom
58
+
59
+ # ========= LOAD WR MAIN =========
60
+ def load_wr():
61
+ xf = pd.ExcelFile(EXCEL_MAIN, engine="openpyxl")
62
+ water_sheets = [s for s in xf.sheet_names if s.startswith("WaterSimulations_")]
63
+ crop_sheets = [s for s in xf.sheet_names if s.startswith("CropWaterSim_")]
64
+ if not water_sheets:
65
+ raise ValueError("Nessun foglio 'WaterSimulations_*' trovato.")
66
+ if not crop_sheets:
67
+ raise ValueError("Nessun foglio 'CropWaterSim_*' trovato.")
68
+
69
+ df_gm = pd.concat([pd.read_excel(xf, s) for s in water_sheets], ignore_index=True)
70
+ df_crp = pd.concat([pd.read_excel(xf, s) for s in crop_sheets], ignore_index=True)
71
+
72
+ # Normalizza colonne base
73
+ df_gm = df_gm.rename(columns={"Policy Scenario": "Scenario"})
74
+ df_crp = df_crp.rename(columns={"Policy Scenario": "Scenario"})
75
+ for d in (df_gm, df_crp):
76
+ d["Scenario"] = d["Scenario"].astype(str).str.lower()
77
+ d["Municipio"] = pd.to_numeric(d["Municipio"], errors="coerce").astype("Int64")
78
+ d["Year"] = pd.to_numeric(d["Year"], errors="coerce").astype("Int64")
79
+
80
+ # MainInfo -> ettari per municipio (pesi)
81
+ if "MainInfo" not in xf.sheet_names:
82
+ raise ValueError("Foglio 'MainInfo' non trovato in WR_PMP_MultiCalib_Policy.xlsx")
83
+ df_main = pd.read_excel(xf, sheet_name="MainInfo").rename(columns={"Policy Scenario": "Scenario"})
84
+ df_main["Scenario"] = df_main["Scenario"].astype(str).str.lower()
85
+ df_main["Municipio"] = pd.to_numeric(df_main["Municipio"], errors="coerce").astype("Int64")
86
+ hect_col = _first_existing_column(df_main, ["Total hectares", "Total Hectares", "Hectares"])
87
+ weights = (df_main.groupby("Municipio", as_index=False)[hect_col]
88
+ .first()
89
+ .rename(columns={hect_col: "Hectares"}))
90
+ return df_gm, df_crp, weights
91
 
92
+ df_gm, df_crop, w_hect = load_wr()
93
+ df_crop["Crop"] = df_crop["Crop"].astype(str).str.strip()
94
+ KNOWN_CROPS = set(df_crop["Crop"].dropna().astype(str).str.strip().unique())
95
 
96
+ # ========= MUNICIPIO NAMES + WATER REQUIREMENT (robusto) =========
97
+ def load_municipio_names_and_wr(known_crops):
98
+ """
99
+ Dal file 'crop_surface_subterraneotheft_parsed.xlsx':
100
+ - Mapping codice -> nome municipio
101
+ - Water requirement per crop×municipio = total water / total superficie [m3/ha]
102
+ Robusto a header diversi: cerca con regex e, se manca la colonna 'crop',
103
+ la deduce confrontando valori con i crop di df_crop.
104
+ """
105
+ try:
106
+ mxf = pd.ExcelFile(EXCEL_MUN, engine="openpyxl")
107
+ df = pd.read_excel(mxf, sheet_name=mxf.sheet_names[0])
108
+ except Exception as e:
109
+ print(f"[WR-LOAD] Impossibile leggere {EXCEL_MUN}: {e}")
110
+ return {}, pd.DataFrame(columns=["Municipio","Crop","wr_m3_ha"])
111
+
112
+ # municipio code col (regex per 'municip')
113
+ code_col = _find_column_regex(df, r"(id|cod|codigo).*(munic|muni)", required=False)
114
+ if code_col is None:
115
+ # fallback: prova una lista ampia
116
+ try:
117
+ code_col = _first_existing_column(df, [
118
+ "ID PROVINCIA/MUNICIPIO","ID_PROVINCIA/MUNICIPIO","ID MUNICIPIO","ID_MUNICIPIO",
119
+ "MUNICIPIO_ID","COD_MUNICIPIO","CODIGO_MUNICIPIO","COD MUNICIPIO","CODIGO MUNICIPIO"
120
+ ])
121
+ except Exception:
122
+ # ultima spiaggia: qualunque colonna int-like con pochi NaN
123
+ int_candidates = [c for c in df.columns if pd.api.types.is_integer_dtype(pd.to_numeric(df[c], errors="ignore"))]
124
+ code_col = int_candidates[0] if int_candidates else df.columns[0]
125
+
126
+ # municipio name col
127
+ name_col = _find_column_regex(df, r"(munic|municipios|nombre.*munic)", required=False)
128
+ if name_col is None:
129
+ name_col = _first_existing_column(df, [
130
+ "MUNICIPIOS","Municipio","NOMBRE_MUNICIPIO","NOMBRE MUNICIPIO","MUNICIPIO"
131
+ ], required=False)
132
+
133
+ # crop col (regex ampia: crop|cult|variedad|especie)
134
+ crop_col = _find_column_regex(df, r"(crop|cultiv|coltur|variedad|especie|cult)", required=False)
135
+ if crop_col is None:
136
+ # prova elenco esteso
137
+ for c in ["Crop","CROP","Cultivo","CULTIVO","CULTURE","Cultivar","Variedad","Especie","Nombre cultivo","Nombre Cultivo","CULTIVO NOMBRE"]:
138
+ if c in df.columns:
139
+ crop_col = c
140
+ break
141
+ if crop_col is None:
142
+ # detezione per matching con KNOWN_CROPS
143
+ best_col, best_overlap = None, -1
144
+ cropy_cols = [c for c in df.columns if df[c].dtype == object]
145
+ for c in cropy_cols:
146
+ vals = set(df[c].dropna().astype(str).str.strip().unique())
147
+ overlap = len(vals & known_crops)
148
+ if overlap > best_overlap:
149
+ best_overlap, best_col = overlap, c
150
+ if best_col is not None and best_overlap > 0:
151
+ crop_col = best_col
152
+ else:
153
+ print("[WR-LOAD] Colonna 'Crop' non trovata. Disabilito il grafico 'Total water consumption'.")
154
+ # costruiamo comunque il mapper dei municipi se possibile:
155
+ mapper = {}
156
+ try:
157
+ dd = df[[code_col, name_col]].copy() if name_col else df[[code_col]].copy()
158
+ dd[code_col] = pd.to_numeric(dd[code_col], errors="coerce").astype("Int64")
159
+ if name_col:
160
+ mapper = dict(zip(dd[code_col], dd[name_col]))
161
+ except Exception as _:
162
+ pass
163
+ return mapper, pd.DataFrame(columns=["Municipio","Crop","wr_m3_ha"])
164
+
165
+ # superficie & water col
166
+ sup_col = _find_column_regex(df, r"(total\s*)?(superf|ha)", required=False)
167
+ wat_col = _find_column_regex(df, r"(total\s*)?(agua|water|m3)", required=False)
168
+ if sup_col is None:
169
+ sup_col = _first_existing_column(df, [
170
+ "total superficie","Total Superficie","TOTAL_SUPERFICIE","Superficie total","SUPERFICIE TOTAL",
171
+ "Superficie","SUPERFICIE","Hectareas","Hectáreas","ha","HA"
172
+ ], required=False)
173
+ if wat_col is None:
174
+ wat_col = _first_existing_column(df, [
175
+ "total water","Total Water","TOTAL_WATER","Agua total","Total Agua","TOTAL AGUA",
176
+ "m3","M3","m³","M³","Volumen","VOLUMEN"
177
+ ], required=False)
178
+
179
+ if sup_col is None or wat_col is None:
180
+ print("[WR-LOAD] Colonne 'total superficie' o 'total water' non trovate. Disabilito il grafico consumo.")
181
+ sup_col = sup_col or df.columns[0]
182
+ wat_col = wat_col or df.columns[1]
183
+
184
+ # mapper municipio (se possibile)
185
+ mapper = {}
186
+ try:
187
+ dd = df[[code_col, name_col]].copy() if name_col else df[[code_col]].copy()
188
+ dd[code_col] = pd.to_numeric(dd[code_col], errors="coerce").astype("Int64")
189
+ if name_col:
190
+ mapper = dict(zip(dd[code_col], dd[name_col]))
191
+ except Exception as e:
192
+ print(f"[WR-LOAD] Impossibile costruire il mapper municipio: {e}")
193
+
194
+ # build wr table
195
+ tmp = df[[code_col, crop_col, sup_col, wat_col]].copy()
196
+ tmp[code_col] = pd.to_numeric(tmp[code_col], errors="coerce").astype("Int64")
197
+ tmp = tmp.dropna(subset=[code_col, crop_col, sup_col, wat_col])
198
+ tmp = tmp[pd.to_numeric(tmp[sup_col], errors="coerce") > 0]
199
+ tmp["wr_m3_ha"] = pd.to_numeric(tmp[wat_col], errors="coerce") / pd.to_numeric(tmp[sup_col], errors="coerce")
200
+ wr = tmp[[code_col, crop_col, "wr_m3_ha"]].rename(columns={code_col:"Municipio", crop_col:"Crop"})
201
+ wr["Crop"] = wr["Crop"].astype(str).str.strip()
202
+ return mapper, wr
203
+
204
+ mun_code_to_name, df_wr = load_municipio_names_and_wr(KNOWN_CROPS)
205
+
206
+ # ========= GM % vs base =========
207
+ group_keys = ["Scenario", "Municipio", "Method", "AM", "CM", "RCP"]
208
+ gm_base_2015 = (df_gm[df_gm["Year"].eq(BASE_YEAR)]
209
+ .groupby(group_keys)["GM_PMP"].first().rename("GM_base_2015"))
210
+ first_year_base = (df_gm.sort_values("Year")
211
+ .groupby(group_keys)["GM_PMP"].first().rename("GM_base_fallback"))
212
+ gm_merged = (df_gm.merge(gm_base_2015, on=group_keys, how="left")
213
+ .merge(first_year_base, on=group_keys, how="left"))
214
+ gm_merged["GM_base"] = gm_merged["GM_base_2015"].fillna(gm_merged["GM_base_fallback"])
215
+ gm_merged["GM_perc"] = 100.0 * gm_merged["GM_PMP"] / gm_merged["GM_base"]
216
+
217
+ # ========= Water availability vs BAU baseline (100%) =========
218
+ is_bau = df_gm["Scenario"].eq("bau")
219
+ bau_first = (df_gm[is_bau].sort_values("Year")
220
+ .groupby(["Municipio","Method","AM","CM","RCP"], as_index=False)
221
+ .first()[["Municipio","Method","AM","CM","RCP","Water_PMP"]]
222
+ .rename(columns={"Water_PMP":"Water_BAU_base"}))
223
+ water_all = df_gm.merge(bau_first, on=["Municipio","Method","AM","CM","RCP"], how="left")
224
+ water_all["Water_rel"] = 100.0 * water_all["Water_PMP"] / water_all["Water_BAU_base"]
225
+
226
+ # ========= DASH APP =========
227
+ external_stylesheets = ["https://fonts.googleapis.com/css2?family=Montserrat:wght@500;700&display=swap"]
228
+ app = dash.Dash(__name__, external_stylesheets=external_stylesheets)
229
  server = app.server
230
 
231
+ # Dropdown values
232
+ scenarios = sorted(gm_merged["Scenario"].dropna().unique())
233
+ rcps = sorted(gm_merged["RCP"].dropna().unique())
234
+ methods = sorted(gm_merged["Method"].dropna().unique())
235
+ mun_codes = sorted(gm_merged["Municipio"].dropna().unique())
236
+ mun_options = [{"label": "Doñana (media pesata)", "value": "DONANA"}] + [
237
+ {"label": municipio_label(c, mun_code_to_name), "value": int(c)} for c in mun_codes
238
+ ]
239
+ methods_options = [{"label": "Tutti", "value": "Tutti"}] + [{"label": m, "value": m} for m in methods]
240
+
241
+ app.layout = html.Div(
242
+ style={"fontFamily": "'Montserrat', sans-serif", "backgroundColor": "#f5f7fa", "minHeight": "100vh"},
243
+ children=[
244
+ html.Div(
245
+ style={"background": "linear-gradient(90deg,#0a3967 0,#009ee0 100%)","padding": "22px 0 14px 0",
246
+ "marginBottom": "16px","textAlign": "center","color": "white","boxShadow": "0 6px 20px rgba(10,57,103,.19)"},
247
+ children=[
248
+ html.H1("🌱 Water Reallocation PMP Dashboard 👨‍🌾",
249
+ style={"fontWeight": "700", "fontSize": "2.3rem", "margin": "0"}),
250
+ html.H4("WWF Doñana & IMDEA Water", style={"fontWeight": "500", "marginTop": "6px"})
251
+ ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  ),
253
+
254
+ # Controls + Scenario description
255
+ html.Div([
256
+ html.Div([
257
+ html.Label("Policy Scenario:", style={"marginRight": "8px", "fontWeight": "600"}),
258
+ dcc.Dropdown(id='scenario', options=[{"label": s.upper(), "value": s} for s in scenarios],
259
+ value=scenarios[0] if scenarios else None, clearable=False,
260
+ style={'width': '180px','display': 'inline-block'}),
261
+
262
+ html.Label("Municipio:", style={"marginLeft": "18px","marginRight":"8px","fontWeight":"600"}),
263
+ dcc.Dropdown(id='municipio', options=mun_options, value="DONANA", clearable=False,
264
+ style={'width': '300px','display': 'inline-block'}),
265
+
266
+ html.Label("RCP:", style={"marginLeft": "18px","marginRight":"8px","fontWeight":"600"}),
267
+ dcc.Dropdown(id='rcp', options=[{"label": r, "value": r} for r in rcps],
268
+ value=rcps[0] if rcps else None, clearable=False,
269
+ style={'width': '150px','display': 'inline-block'}),
270
+
271
+ html.Label("Method:", style={"marginLeft": "18px","marginRight":"8px","fontWeight":"600"}),
272
+ dcc.Dropdown(id='method', options=methods_options, value="Tutti", clearable=False,
273
+ style={'width': '160px','display': 'inline-block'}),
274
+ ], style={'padding': '16px 18px 10px 18px', "background":"#fff","borderRadius":"10px",
275
+ "boxShadow":"0 2px 12px rgba(10,57,103,.09)", "margin":"0 14px"}),
276
+
277
+ html.Div(id="scenario-desc",
278
+ style={"margin":"8px 14px 0 14px","background":"#fff","borderRadius":"10px",
279
+ "boxShadow":"0 2px 12px rgba(10,57,103,.08)","padding":"10px 14px",
280
+ "fontSize":"0.95rem","color":"#113"})
281
+ ]),
282
+
283
+ # Row 1: GM trend / Crop stacked
284
+ html.Div([
285
+ html.Div([
286
+ html.Div([
287
+ html.H4("Gross Margin Trend (% vs base)"),
288
+ dcc.Graph(id='gm-graph', config={"displayModeBar": False})
289
+ ], style={"background":"#fff","borderRadius":"12px","boxShadow":"0 2px 10px rgba(10,57,103,.10)",
290
+ "padding":"16px","margin":"0 10px"})
291
+ ], style={'width':'49%','display':'inline-block','verticalAlign':'top'}),
292
+
293
+ html.Div([
294
+ html.Div([
295
+ html.H4("Allocated Area by Crop (stacked)"),
296
+ dcc.Graph(id='crop-graph', config={"displayModeBar": False})
297
+ ], style={"background":"#fff","borderRadius":"12px","boxShadow":"0 2px 10px rgba(10,57,103,.10)",
298
+ "padding":"16px","margin":"0 10px"})
299
+ ], style={'width':'49%','display':'inline-block','verticalAlign':'top'}),
300
+ ], style={'width':'100%','paddingTop':'6px'}),
301
+
302
+ # Row 2: Water availability vs BAU baseline (100%) + Total water consumption
303
+ html.Div([
304
+ html.Div([
305
+ html.H4("Water availability vs BAU baseline (100%)"),
306
+ dcc.Graph(id='water-graph', config={"displayModeBar": False})
307
+ ], style={"background":"#fff","borderRadius":"12px","boxShadow":"0 2px 10px rgba(10,57,103,.10)",
308
+ "padding":"16px","margin":"10px 24px"}),
309
+
310
+ html.Div([
311
+ html.H4("Total water consumption (m³)"),
312
+ dcc.Graph(id='cons-graph', config={"displayModeBar": False})
313
+ ], style={"background":"#fff","borderRadius":"12px","boxShadow":"0 2px 10px rgba(10,57,103,.10)",
314
+ "padding":"16px","margin":"10px 24px"})
315
+ ])
316
+ ]
317
  )
318
 
319
+ # ========= CORE: compute total water consumption =========
320
+ def compute_total_water_timeseries(selected_scenario, selected_mun, selected_rcp, selected_method):
321
+ """
322
+ Calcola (per anno) il consumo idrico assoluto:
323
+ sum_{municipio,crop} [ share(PMP Area) * Hectares(Municipio) * wr_m3_ha(Crop,Municipio) ].
324
+ Media su AM (mean) + banda min/max.
325
+ Se df_wr è vuoto (non rilevato), ritorna None.
326
+ """
327
+ if df_wr is None or df_wr.empty:
328
+ return None
329
+
330
+ # 1) filtro crop portfolio
331
+ crp = df_crop[(df_crop["Scenario"]==selected_scenario) &
332
+ (df_crop["RCP"]==selected_rcp)].copy()
333
+ if selected_method != "Tutti":
334
+ crp = crp[crp["Method"]==selected_method]
335
 
336
+ # per Doñana includo tutti i municipi; altrimenti 1 municipio
337
+ if not is_donana(selected_mun):
338
+ code = int(selected_mun)
339
+ crp = crp[crp["Municipio"]==code].copy()
340
+
341
+ if crp.empty:
342
+ return None
343
+
344
+ # 2) media su CM (e su Method se "Tutti") → manteniamo AM per banda min-max
345
+ crp_mean = (crp.groupby(["Year","AM","Municipio","Crop"], as_index=False)["PMP Area"]
346
+ .mean())
347
+ # 3) merge con ettari e water requirement
348
+ crp_mean = crp_mean.merge(w_hect, on="Municipio", how="left")
349
+ crp_mean = crp_mean.merge(df_wr, on=["Municipio","Crop"], how="left")
350
+
351
+ crp_mean = crp_mean.dropna(subset=["Hectares","wr_m3_ha","PMP Area"])
352
+ if crp_mean.empty:
353
+ return None
354
+
355
+ # 4) acqua assoluta = share * Ha * m3/ha
356
+ crp_mean["water_m3"] = crp_mean["PMP Area"] * crp_mean["Hectares"] * crp_mean["wr_m3_ha"]
357
+
358
+ # 5) aggregazione per anno & AM (somma su municipi e crop)
359
+ agg = (crp_mean.groupby(["Year","AM"], as_index=False)["water_m3"].sum())
360
+
361
+ # 6) riassunto su AM → mean / min / max per banda
362
+ summary = agg.groupby("Year")["water_m3"].agg(["mean","min","max"]).reset_index()
363
+ return summary
364
+
365
+ # ========= CALLBACK =========
366
+ @app.callback(
367
+ Output("scenario-desc","children"),
368
+ Output("gm-graph","figure"),
369
+ Output("crop-graph","figure"),
370
+ Output("water-graph","figure"),
371
+ Output("cons-graph","figure"),
372
+ Input("scenario","value"),
373
+ Input("municipio","value"),
374
+ Input("rcp","value"),
375
+ Input("method","value")
376
  )
377
+ def update_all(selected_scenario, selected_mun, selected_rcp, selected_method):
378
+ # ---------- 1) Descrizione scenario ----------
379
+ desc = SCENARIO_DESCR.get(selected_scenario, "Scenario non documentato.")
380
+ desc_div = html.Div([html.B(selected_scenario.upper()+": "), html.Span(desc)])
381
+
382
+ # ---------- 2) Filtri base ----------
383
+ gmp = gm_merged[(gm_merged["Scenario"]==selected_scenario) &
384
+ (gm_merged["RCP"]==selected_rcp)].copy()
385
+ crp = df_crop[(df_crop["Scenario"]==selected_scenario) &
386
+ (df_crop["RCP"]==selected_rcp)].copy()
387
+ wdf = water_all[(water_all["Scenario"]==selected_scenario) &
388
+ (water_all["RCP"]==selected_rcp)].copy()
389
+ if selected_method != "Tutti":
390
+ gmp = gmp[gmp["Method"]==selected_method]
391
+ crp = crp[crp["Method"]==selected_method]
392
+ wdf = wdf[wdf["Method"]==selected_method]
393
+
394
+ # ---------- 3) GM Trend (% vs base) — y-range [0,100] ----------
395
+ if is_donana(selected_mun):
396
+ tmp = gmp.dropna(subset=["GM_perc"]).copy()
397
+ if tmp.empty:
398
+ fig_gm = go.Figure().update_layout(title="No data", template="plotly_white",
399
+ yaxis=dict(range=[0,100]))
400
+ else:
401
+ wm = (tmp.groupby(["Year","AM"])
402
+ .apply(lambda x: weighted_mean(x, "GM_perc", w_hect))
403
+ .reset_index(name="GM_perc_w")
404
+ .dropna(subset=["GM_perc_w"]))
405
+ summary = wm.groupby("Year")["GM_perc_w"].agg(["mean","min","max"]).reset_index()
406
+ fig_gm = go.Figure()
407
+ fig_gm.add_trace(go.Scatter(x=summary["Year"], y=summary["mean"], mode="lines+markers",
408
+ name="GM Trend", line=dict(width=3)))
409
+ fig_gm.add_trace(go.Scatter(
410
+ x=summary["Year"].tolist()+summary["Year"][::-1].tolist(),
411
+ y=summary["max"].tolist()+summary["min"][::-1].tolist(),
412
+ fill="toself", fillcolor="rgba(9,103,174,0.13)",
413
+ line=dict(color="rgba(255,255,255,0)"), hoverinfo="skip",
414
+ showlegend=True, name="Range (Min–Max)"
415
+ ))
416
+ fig_gm.add_hline(y=100, line_dash="dash", line_color="#8e8e8e",
417
+ annotation_text="Baseline", annotation_position="top left")
418
+ fig_gm.update_layout(yaxis_title="Gross Margin [% vs base]", xaxis_title="Year",
419
+ template="plotly_white", hovermode="x unified",
420
+ legend=dict(bgcolor="rgba(255,255,255,0.88)"),
421
+ yaxis=dict(range=[0,100]))
422
+ else:
423
+ code = int(selected_mun)
424
+ tmp = gmp[gmp["Municipio"]==code].copy().sort_values(["AM","Year"])
425
+ if tmp.empty:
426
+ fig_gm = go.Figure().update_layout(title="No data", template="plotly_white",
427
+ yaxis=dict(range=[0,100]))
428
+ else:
429
+ tmp["GM_perc_MA"] = tmp.groupby("AM")["GM_perc"].transform(lambda x: x.rolling(3, min_periods=1).mean())
430
+ summary = tmp.groupby("Year")["GM_perc_MA"].agg(["mean","min","max"]).reset_index()
431
+ fig_gm = go.Figure()
432
+ fig_gm.add_trace(go.Scatter(x=summary["Year"], y=summary["mean"], mode="lines+markers",
433
+ name="GM Trend", line=dict(width=3)))
434
+ fig_gm.add_trace(go.Scatter(
435
+ x=summary["Year"].tolist()+summary["Year"][::-1].tolist(),
436
+ y=summary["max"].tolist()+summary["min"][::-1].tolist(),
437
+ fill="toself", fillcolor="rgba(9,103,174,0.13)",
438
+ line=dict(color="rgba(255,255,255,0)"), hoverinfo="skip",
439
+ showlegend=True, name="Range (Min–Max)"
440
+ ))
441
+ fig_gm.add_hline(y=100, line_dash="dash", line_color="#8e8e8e",
442
+ annotation_text="Baseline", annotation_position="top left")
443
+ fig_gm.update_layout(yaxis_title="Gross Margin [% vs base]", xaxis_title="Year",
444
+ template="plotly_white", hovermode="x unified",
445
+ legend=dict(bgcolor="rgba(255,255,255,0.88)"),
446
+ title=dict(text=municipio_label(code, mun_code_to_name),
447
+ y=0.98, x=0.02, xanchor='left', yanchor='top'),
448
+ yaxis=dict(range=[0,100]))
449
+
450
+ # ---------- 4) Crop stacked (share 0–1 → 0–100%) ----------
451
+ if is_donana(selected_mun):
452
+ tc = crp.dropna(subset=["PMP Area"]).copy()
453
+ if tc.empty:
454
+ fig_crop = go.Figure().update_layout(title="No crop data", template="plotly_white",
455
+ yaxis=dict(range=[0,1], tickformat=".0%"))
456
+ else:
457
+ tc = tc.merge(w_hect, on="Municipio", how="left").dropna(subset=["Hectares"])
458
+ tc["w_area"] = tc["PMP Area"] * tc["Hectares"]
459
+ wm = (tc.groupby(["Year","Crop"], as_index=False)
460
+ .agg(w_share=("w_area","sum"), H=("Hectares","sum")))
461
+ wm["share"] = wm["w_share"] / wm["H"]
462
+ pivot = wm.pivot(index="Year", columns="Crop", values="share").fillna(0)
463
+ cols = [c for c in pivot.columns if c != "Secano"] + (["Secano"] if "Secano" in pivot.columns else [])
464
+ pivot = pivot[cols]
465
+ fig_crop = go.Figure()
466
+ palette = px.colors.qualitative.Plotly + px.colors.qualitative.Pastel
467
+ for i, crop in enumerate(pivot.columns):
468
+ fig_crop.add_trace(go.Scatter(
469
+ x=pivot.index, y=pivot[crop], mode="lines", stackgroup="one", name=crop,
470
+ line=dict(width=0.8), opacity=0.98, fillcolor=palette[i % len(palette)]
471
+ ))
472
+ fig_crop.update_layout(yaxis_title="Allocated area (share)",
473
+ xaxis_title="Year", template="plotly_white",
474
+ hovermode="x unified", legend_title_text="Crop",
475
+ yaxis=dict(range=[0,1], tickformat=".0%"))
476
+ else:
477
+ code = int(selected_mun)
478
+ tc = crp[crp["Municipio"]==code].copy()
479
+ if tc.empty:
480
+ fig_crop = go.Figure().update_layout(title="No crop data", template="plotly_white",
481
+ yaxis=dict(range=[0,1], tickformat=".0%"))
482
+ else:
483
+ pivot = tc.pivot_table(index="Year", columns="Crop", values="PMP Area", aggfunc="mean").fillna(0)
484
+ cols = [c for c in pivot.columns if c != "Secano"] + (["Secano"] if "Secano" in pivot.columns else [])
485
+ pivot = pivot[cols]
486
+ fig_crop = go.Figure()
487
+ palette = px.colors.qualitative.Plotly + px.colors.qualitative.Pastel
488
+ for i, crop in enumerate(pivot.columns):
489
+ fig_crop.add_trace(go.Scatter(
490
+ x=pivot.index, y=pivot[crop], mode="lines", stackgroup="one", name=crop,
491
+ line=dict(width=0.8), opacity=0.98, fillcolor=palette[i % len(palette)]
492
+ ))
493
+ fig_crop.update_layout(yaxis_title="Allocated area (share)",
494
+ xaxis_title="Year", template="plotly_white",
495
+ hovermode="x unified", legend_title_text="Crop",
496
+ title=dict(text=municipio_label(code, mun_code_to_name),
497
+ y=0.98, x=0.02, xanchor='left', yanchor='top'),
498
+ yaxis=dict(range=[0,1], tickformat=".0%"))
499
+
500
+ # ---------- 5) Water availability vs BAU baseline — y-range [0,100] ----------
501
+ if is_donana(selected_mun):
502
+ tw = wdf.copy().merge(w_hect, on="Municipio", how="left").dropna(subset=["Hectares","Water_rel"])
503
+ if tw.empty:
504
+ fig_w = go.Figure().update_layout(title="No water data", template="plotly_white",
505
+ yaxis=dict(range=[0,100]))
506
+ else:
507
+ tw["w_val"] = tw["Water_rel"] * tw["Hectares"]
508
+ wm = (tw.groupby(["Year","AM"], as_index=False)
509
+ .agg(val=("w_val","sum"), H=("Hectares","sum")))
510
+ wm["Water_rel_w"] = wm["val"] / wm["H"] # in %
511
+ summary = wm.groupby("Year")["Water_rel_w"].agg(["mean","min","max"]).reset_index()
512
+ fig_w = go.Figure()
513
+ fig_w.add_trace(go.Scatter(x=summary["Year"], y=summary["mean"], mode="lines+markers",
514
+ name="Water availability (rel. BAU=100)", line=dict(width=3)))
515
+ fig_w.add_trace(go.Scatter(
516
+ x=summary["Year"].tolist()+summary["Year"][::-1].tolist(),
517
+ y=summary["max"].tolist()+summary["min"][::-1].tolist(),
518
+ fill="toself", fillcolor="rgba(9,103,174,0.13)",
519
+ line=dict(color="rgba(255,255,255,0)"), hoverinfo="skip",
520
+ showlegend=True, name="Range (Min–Max)"
521
+ ))
522
+ fig_w.add_hline(y=100, line_dash="dash", line_color="#8e8e8e",
523
+ annotation_text="BAU baseline", annotation_position="top left")
524
+ fig_w.update_layout(yaxis_title="Water availability [% of BAU baseline]",
525
+ xaxis_title="Year", template="plotly_white", hovermode="x unified",
526
+ yaxis=dict(range=[0,100]))
527
+ else:
528
+ code = int(selected_mun)
529
+ tw = wdf[wdf["Municipio"]==code].copy().sort_values(["AM","Year"])
530
+ if tw.empty:
531
+ fig_w = go.Figure().update_layout(title="No water data", template="plotly_white",
532
+ yaxis=dict(range=[0,100]))
533
+ else:
534
+ summary = tw.groupby("Year")["Water_rel"].agg(["mean","min","max"]).reset_index()
535
+ fig_w = go.Figure()
536
+ fig_w.add_trace(go.Scatter(x=summary["Year"], y=summary["mean"], mode="lines+markers",
537
+ name="Water availability (rel. BAU=100)", line=dict(width=3)))
538
+ fig_w.add_trace(go.Scatter(
539
+ x=summary["Year"].tolist()+summary["Year"][::-1].tolist(),
540
+ y=summary["max"].tolist()+summary["min"][::-1].tolist(),
541
+ fill="toself", fillcolor="rgba(9,103,174,0.13)",
542
+ line=dict(color="rgba(255,255,255,0)"), hoverinfo="skip",
543
+ showlegend=True, name="Range (Min–Max)"
544
+ ))
545
+ fig_w.add_hline(y=100, line_dash="dash", line_color="#8e8e8e",
546
+ annotation_text="BAU baseline", annotation_position="top left")
547
+ fig_w.update_layout(yaxis_title="Water availability [% of BAU baseline]",
548
+ xaxis_title="Year", template="plotly_white", hovermode="x unified",
549
+ title=dict(text=municipio_label(code, mun_code_to_name),
550
+ y=0.98, x=0.02, xanchor='left', yanchor='top'),
551
+ yaxis=dict(range=[0,100]))
552
+
553
+ # ---------- 6) Total water consumption (m³) ----------
554
+ cons = compute_total_water_timeseries(selected_scenario, selected_mun, selected_rcp, selected_method)
555
+ if cons is None or cons.empty:
556
+ fig_c = go.Figure().update_layout(title="Total water consumption not available (check crop/WR columns).",
557
+ template="plotly_white")
558
+ else:
559
+ fig_c = go.Figure()
560
+ fig_c.add_trace(go.Scatter(x=cons["Year"], y=cons["mean"], mode="lines+markers",
561
+ name="Total water (mean)", line=dict(width=3)))
562
+ fig_c.add_trace(go.Scatter(
563
+ x=cons["Year"].tolist()+cons["Year"][::-1].tolist(),
564
+ y=cons["max"].tolist()+cons["min"][::-1].tolist(),
565
+ fill="toself", fillcolor="rgba(9,103,174,0.13)",
566
+ line=dict(color="rgba(255,255,255,0)"), hoverinfo="skip",
567
+ showlegend=True, name="Range (Min–Max)"
568
+ ))
569
+ fig_c.update_layout(yaxis_title="m³", xaxis_title="Year",
570
+ template="plotly_white", hovermode="x unified")
571
 
572
+ return desc_div, fig_gm, fig_crop, fig_w, fig_c
573
 
574
+ # ========= MAIN =========
575
  if __name__ == "__main__":
576
+ port = int(os.environ.get("PORT", 7860))
577
+ app.run(host="0.0.0.0", port=port, debug=False)
crop_surface_subterraneotheft_parsed (1).xlsx ADDED
Binary file (16.8 kB). View file