CarlyneB commited on
Commit
fa58ff0
·
1 Parent(s): 3c60617

[DOCS/FIX] First draft of Objective No. 5: TO FACILITATE MAINTENANCE AND UPDATES FOR CG STAFF. (No more hard-coded settings, libraries updated, instructions added to the README)

Browse files
README.md CHANGED
@@ -4,7 +4,7 @@ emoji: 🌍
4
  colorFrom: green
5
  colorTo: blue
6
  sdk: streamlit
7
- sdk_version: "1.45.1"
8
  app_file: app.py
9
  pinned: false
10
  ---
@@ -12,3 +12,596 @@ pinned: false
12
  # Carbon Dioxide Removal Optimizer
13
 
14
  A Streamlit app for optimizing carbon removal portfolios across different methods and constraints.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  colorFrom: green
5
  colorTo: blue
6
  sdk: streamlit
7
+ sdk_version: "1.58.0"
8
  app_file: app.py
9
  pinned: false
10
  ---
 
12
  # Carbon Dioxide Removal Optimizer
13
 
14
  A Streamlit app for optimizing carbon removal portfolios across different methods and constraints.
15
+
16
+ ---
17
+
18
+ ## Data Maintenance Guide
19
+
20
+ This guide is for Carbon Gap staff who need to update the tool's data — **no coding experience required** for most tasks.
21
+
22
+ ---
23
+
24
+ ### How it works (overview)
25
+
26
+ All the data the tool uses (CDR methods, resources, coefficients) comes from a single file:
27
+
28
+ ```
29
+ data/inputs_aggregated.csv
30
+ ```
31
+
32
+ Every time the app starts, it computes a fingerprint (hash) of the CSV and compares it to the last known value. If they differ — meaning the CSV has been modified — it regenerates all data files automatically before loading. You do not need to run anything manually.
33
+
34
+ There are two ways to update the data, depending on whether you are working directly on Hugging Face or on a local copy of the project.
35
+
36
+ ---
37
+
38
+ ### Option 1 — Edit directly on Hugging Face (no local setup needed)
39
+
40
+ This is the simplest option for non-coders with admin access to the Hugging Face Space.
41
+
42
+ **Steps:**
43
+
44
+ 1. Go to the Hugging Face Space → click the **Files** tab
45
+ 2. Open `data/inputs_aggregated.csv` and click **Edit** (pencil icon), or upload a new version of the file
46
+ 3. Commit the change directly on Hugging Face — the Space will restart automatically
47
+ 4. **Check the logs** to confirm the update ran:
48
+ - In the Space, click **⋮ (three dots)** → **Logs**
49
+ - You should see:
50
+ ```
51
+ inputs_aggregated.csv has changed — running data/update.py ...
52
+ [... update details ...]
53
+ Data files updated successfully.
54
+ ```
55
+ 5. If the logs show this message, the app is running with the new data. If not, something went wrong — contact the developer.
56
+
57
+ > **Note:** with this option, only `inputs_aggregated.csv` needs to be uploaded. The app handles the rest automatically on restart.
58
+
59
+ ---
60
+
61
+ ### Option 2 — Work locally and push (recommended for larger changes)
62
+
63
+ Use this option when making bigger changes (new methods, new resources, reordering) where you want to preview the result before publishing.
64
+
65
+ **Step 1 — Get the project on your machine**
66
+
67
+ If you do not already have it:
68
+ - Install [GitHub Desktop](https://desktop.github.com) (free, no command line needed)
69
+ - Open GitHub Desktop → **File** → **Clone repository** → enter the repository URL → choose a local folder → click **Clone**
70
+
71
+ **Step 2 — Edit the CSV**
72
+
73
+ Open `data/inputs_aggregated.csv` in Excel or any spreadsheet editor. Make your changes and save.
74
+
75
+ **Step 3 — Open a terminal in the project folder**
76
+
77
+ - Mac: right-click the project folder → **New Terminal at Folder**
78
+ - Windows: open the folder, click in the address bar, type `cmd`, press Enter
79
+
80
+ Activate the Python environment:
81
+ ```
82
+ source venv/bin/activate # Mac / Linux
83
+ venv\Scripts\activate # Windows
84
+ ```
85
+
86
+ **Step 4 — Run the update script**
87
+
88
+ ```
89
+ python data/update.py
90
+ ```
91
+
92
+ This regenerates all data files and updates the internal fingerprint. Check the output — it should end with `All files updated successfully.`
93
+
94
+ **Step 5 — Commit and push**
95
+
96
+ In GitHub Desktop:
97
+ - You will see all modified files listed on the left
98
+ - Write a short description in the **Summary** field (e.g. "Update coefficients for Afforestation")
99
+ - Click **Commit to main**
100
+ - Click **Push origin**
101
+
102
+ Hugging Face will detect the push and rebuild the Space automatically.
103
+
104
+ ---
105
+
106
+ ### Quick reference — where is each parameter?
107
+
108
+ | I want to change… | File | Where exactly |
109
+ |---|---|---|
110
+ | A coefficient value (min / median / max) | `data/inputs_aggregated.csv` | Columns `min`, `median`, `max` — find the row by `Implementation` + `Resource` |
111
+ | The name of a CDR method | `data/inputs_aggregated.csv` | Column `Implementation` — use Find & Replace |
112
+ | Which group a CDR method belongs to | `data/inputs_aggregated.csv` | Column `Method group` |
113
+ | The name of a resource | `data/inputs_aggregated.csv` | Column `Resource` — use Find & Replace |
114
+ | Which group a resource belongs to | `data/inputs_aggregated.csv` | Column `Resource group` |
115
+ | The unit of a resource group | `data/inputs_aggregated.csv` | Column `Unit` — must be consistent across all rows for that group |
116
+ | Whether a resource is optional for a method | `data/inputs_aggregated.csv` | Column `Is secondary` — `TRUE` = optional, `FALSE` = always active |
117
+ | The display order of methods or resources | `data/inputs_aggregated.csv` | Row order — move rows up/down |
118
+ | The app's colours | `config/config.py` | `CRRA_COLORS` dictionary |
119
+ | The app's font | `config/config.py` | `CRRA_FONT` variable |
120
+ | The tooltip text on a method name | `data/tooltips.xlsx` | Sheet **Methods** — column `Tooltip` |
121
+ | The tooltip text on a resource name | `data/tooltips.xlsx` | Sheet **Resources** — column `Tooltip` |
122
+ | UI spacing, font sizes, button styles | `assets/styles.css` | Standard CSS — search for the element name |
123
+ | Python library versions | `requirements.txt` | One line per library — see Library Maintenance Guide below |
124
+
125
+ > After any change to `inputs_aggregated.csv`, run `python data/update.py`. After changes to `config/config.py` or `assets/styles.css`, simply restart the app.
126
+
127
+ ---
128
+
129
+ ### Before you start
130
+
131
+ **Open a terminal** in the project folder. On Mac: right-click the folder → "New Terminal at Folder". On Windows: open the folder, then type `cmd` in the address bar.
132
+
133
+ **Activate the Python environment** (only needed once per session):
134
+
135
+ - Mac / Linux: `source venv/bin/activate`
136
+ - Windows: `venv\Scripts\activate`
137
+
138
+ You should now see `(venv)` at the start of your terminal line.
139
+
140
+ ---
141
+
142
+ ### File structure (what each file does)
143
+
144
+ | File | What it is | Do you edit it? |
145
+ |------|-----------|-----------------|
146
+ | `data/inputs_aggregated.csv` | **Main data file** — all methods, resources and coefficients | ✅ Yes |
147
+ | `data/update.py` | Script that regenerates everything from the CSV | ❌ No |
148
+ | `data/cost_sliders.json` | Auto-generated — coefficients used by the app | ❌ No (auto) |
149
+ | `data/method_groups.json` | Auto-generated — CDR method categories | ❌ No (auto) |
150
+ | `data/secondary_resources.json` | Auto-generated — optional resource assignments | ❌ No (auto) |
151
+ | `data/inputs_template.xlsx` | Auto-generated — Excel template for users | ❌ No (auto) |
152
+ | `data/resource_template.xlsx` | Auto-generated — Excel template for resources | ❌ No (auto) |
153
+ | `data/tooltips.xlsx` | Tooltip texts shown on hover in the app | ✅ Yes — edit directly |
154
+
155
+ > ⚠️ **Never edit the auto-generated files directly** — your changes will be overwritten the next time `update.py` is run.
156
+
157
+ ---
158
+
159
+ ### Understanding `inputs_aggregated.csv`
160
+
161
+ Each row represents **one resource used by one CDR method**, with the following columns:
162
+
163
+ | Column | Description | Example |
164
+ |--------|-------------|---------|
165
+ | `Implementation` | Name of the CDR method | `Afforestation` |
166
+ | `Method group` | Category the method belongs to | `Forestry` |
167
+ | `Resource` | Name of the resource used | `Unspecified land` |
168
+ | `Resource group` | Category the resource belongs to | `Land` |
169
+ | `Type` | Physical type of the resource | `Physical` |
170
+ | `Unit` | Unit of the coefficient (per MtCO₂) | `Mha/MtCO2` |
171
+ | `min` | Minimum coefficient value | `0.002` |
172
+ | `median` | Default (median) coefficient value | `0.094927` |
173
+ | `max` | Maximum coefficient value | `0.384615` |
174
+ | `Is secondary` | Whether this resource is optional for this method | `FALSE` |
175
+
176
+ > **Primary resources** (`Is secondary = FALSE`) are always active in the model.
177
+ > **Secondary resources** (`Is secondary = TRUE`) are optional — users can toggle them on in the app.
178
+
179
+ ---
180
+
181
+ ## Case 1 — Modify existing coefficient values
182
+
183
+ **When to use:** The literature has been updated and you want to change the min/median/max values for an existing method-resource pair.
184
+
185
+ **Steps:**
186
+
187
+ 1. Open `data/inputs_aggregated.csv` in Excel or a text editor.
188
+ 2. Find the row with the correct `Implementation` and `Resource`.
189
+ 3. Edit the `min`, `median`, and/or `max` columns.
190
+ 4. Save the file.
191
+ 5. In the terminal, run:
192
+ ```
193
+ python data/update.py
194
+ ```
195
+
196
+ ---
197
+
198
+ ## Case 2 — Add a new resource to an existing method
199
+
200
+ **When to use:** A CDR method now uses a resource that wasn't tracked before (e.g., adding water consumption to Afforestation).
201
+
202
+ **Steps:**
203
+
204
+ 1. Open `data/inputs_aggregated.csv`.
205
+ 2. Add a **new row** at the end (or anywhere in the file — order of rows determines display order in the app):
206
+
207
+ | Column | What to enter |
208
+ |--------|--------------|
209
+ | `Implementation` | Exact name of the existing method (copy it from another row to avoid typos) |
210
+ | `Method group` | Same group as the other rows for that method |
211
+ | `Resource` | Name of the resource (must match an existing resource exactly, or see Case 4 to add a new one) |
212
+ | `Resource group` | Same group as the other rows for that resource |
213
+ | `Type` | `Physical` or `Energy` |
214
+ | `Unit` | Must match the unit already used for that resource (e.g., `Mha/MtCO2`) |
215
+ | `min` | Minimum coefficient |
216
+ | `median` | Median coefficient |
217
+ | `max` | Maximum coefficient |
218
+ | `Is secondary` | `TRUE` if optional, `FALSE` if always required |
219
+
220
+ 3. Save the file.
221
+ 4. Run `python data/update.py`.
222
+
223
+ ---
224
+
225
+ ## Case 3 — Add a new CDR method
226
+
227
+ **When to use:** A new carbon removal approach needs to be added to the tool.
228
+
229
+ **Steps:**
230
+
231
+ 1. Open `data/inputs_aggregated.csv`.
232
+ 2. Add **one row per resource** used by the new method. For example, if your new method uses land and water, add 2 rows.
233
+
234
+ For each row, fill in all columns. Use an **existing Method group** (e.g., `Forestry`) or create a new one (the name you write here becomes the category name in the app).
235
+
236
+ 3. Save the file.
237
+ 4. Run `python data/update.py`.
238
+
239
+ > **Note on display order:** The new method will appear in the app in the same position it appears in the CSV. If you want it between two existing methods, insert the rows between those methods' rows.
240
+
241
+ > ⚠️ **Special case — derived methods:** see Case 3b below.
242
+
243
+ ---
244
+
245
+ ## Case 3b — Add a derived CDR method (variant of an existing method)
246
+
247
+ **What is a derived method?**
248
+ Some methods in the tool are not standalone entries in the CSV — they are automatically generated as **variants** of a base method. For example:
249
+ - `Bio-char` in the CSV → generates 3 variants in the app: `Bio-char - Forest biomass`, `Bio-char - Non-forest biomass`, `Bio-char - Unspecified biomass`
250
+ - `Enhanced weathering` in the CSV → generates 4 variants: one per mineral type
251
+
252
+ Each variant is identical to the base method **except** it only keeps the resource relevant to that variant (e.g. `Bio-char - Forest biomass` removes the non-forest and unspecified biomass resources).
253
+
254
+ **When to use:** You want to add a new variant of an existing method (e.g. a new mineral type for Enhanced weathering, or a new biomass type for Bio-char).
255
+
256
+ **Steps:**
257
+
258
+ 1. Open `data/inputs_aggregated.csv` and make sure the base method has a row for the new resource (e.g. add a row for `Enhanced weathering` with the new mineral).
259
+
260
+ 2. Open `data/generate_model_data.py`. Find the two structures at the top of the file:
261
+
262
+ **`REPLACED_BY_DERIVED`** — lists base methods that are fully replaced by their variants (the base name disappears from the app):
263
+ ```
264
+ REPLACED_BY_DERIVED = {"Bio-char", "Enhanced weathering"}
265
+ ```
266
+ If your new base method should disappear from the app once variants exist, add its name here.
267
+
268
+ **`DERIVED_FROM`** — maps each base method to its list of variant names (in display order):
269
+ ```
270
+ DERIVED_FROM = {
271
+ "Bio-char": ["Bio-char - Forest biomass", ...],
272
+ "Enhanced weathering": ["Enhanced weathering - Basalt mineral", ...],
273
+ ...
274
+ }
275
+ ```
276
+ Add the new variant name to the relevant list here.
277
+
278
+ 3. Further down in `create_sliders_inputs()`, add the derivation logic for your new variant — copy the pattern already used for Bio-char or Enhanced weathering: copy the base method's coefficients, then remove the resources that don't apply to this variant.
279
+
280
+ 4. Save `generate_model_data.py` and run:
281
+ ```
282
+ python data/update.py
283
+ ```
284
+
285
+ > ⚠️ This case requires editing Python code. If you are not comfortable with this, contact the developer — provide the name of the new variant and which resource it should keep, and they can add the 5–10 lines needed.
286
+
287
+ ---
288
+
289
+ ## Case 4 — Add a new resource
290
+
291
+ **When to use:** A resource that doesn't exist yet needs to be tracked (e.g., a new type of mineral or energy).
292
+
293
+ **Steps:**
294
+
295
+ 1. Decide which **Resource group** the new resource belongs to (e.g., `Minerals`, `Energy`, `Land`). If it belongs to an existing group, use the exact same group name. If it needs its own group, invent a new name.
296
+
297
+ 2. Open `data/inputs_aggregated.csv` and add one row per method that uses this resource (same as Case 2).
298
+
299
+ 3. **Check the unit:** the unit for all resources in a group must be the same (e.g., all `Land` resources use `Mha/MtCO2`). If your new resource has a different unit, it needs its own Resource group.
300
+
301
+ 4. Save the file and run `python data/update.py`.
302
+
303
+ ---
304
+
305
+ ## Case 5 — Add a new resource group (new category)
306
+
307
+ **When to use:** Your new resource doesn't fit any existing category.
308
+
309
+ **Steps:**
310
+
311
+ Same as Case 4, but in the `Resource group` column, write a **new group name** that has never been used before. The app will automatically create a new section for it in the Resource Inputs tab.
312
+
313
+ The new group will appear in the app **in the position of its first row in the CSV**. Move the rows up or down to control where it appears.
314
+
315
+ ---
316
+
317
+ ## Case 6 — Change whether a resource is optional (secondary) for a method
318
+
319
+ **When to use:** You want to make a resource optional (user can enable/disable it) or make it always required.
320
+
321
+ **Steps:**
322
+
323
+ 1. Open `data/inputs_aggregated.csv`.
324
+ 2. Find the row with the correct `Implementation` and `Resource`.
325
+ 3. Change the `Is secondary` column to `TRUE` (optional) or `FALSE` (always active).
326
+ 4. Save and run `python data/update.py`.
327
+
328
+ ---
329
+
330
+ ## Case 7 — Rename a method or resource
331
+
332
+ **When to use:** Correcting a spelling mistake or updating nomenclature.
333
+
334
+ **Steps:**
335
+
336
+ 1. Open `data/inputs_aggregated.csv`.
337
+ 2. Use **Find & Replace** (Ctrl+H / Cmd+H) to replace the old name with the new name everywhere in the file. Make sure to replace it in both the `Implementation` column (for methods) or `Resource` column (for resources), and also in the `Method group` / `Resource group` columns if the group name changed.
338
+ 3. Save and run `python data/update.py`.
339
+
340
+ > ⚠️ If users have saved scenario files (`.xlsx` or `.zip`) that reference the old name, those files will no longer load correctly after the rename.
341
+
342
+ ---
343
+
344
+ ## Case 8 — Edit tooltip texts (hover descriptions)
345
+
346
+ **When to use:** You want to add, update, or remove the short description that appears when a user hovers over a resource or method name in the app.
347
+
348
+ **Steps:**
349
+
350
+ 1. Open `data/tooltips.xlsx`.
351
+ 2. Go to the relevant sheet:
352
+ - **Resources** — for the hover text on resource names (Tab 1 "Resource Inputs")
353
+ - **Methods** — for the hover text on method names (Tab 2 "Method Constraints")
354
+ 3. Find the row with the name you want to edit. All resource and method names are already listed — you only need to fill in or change the `Tooltip` column.
355
+ 4. To **add** a tooltip: type the text in the `Tooltip` cell for that row.
356
+ 5. To **remove** a tooltip: leave the `Tooltip` cell empty.
357
+ 6. Save the file.
358
+ 7. Restart the app (no need to run `update.py`).
359
+
360
+ > ⚠️ The name in column `Resource` or `Method` must match **exactly** the name used in `inputs_aggregated.csv` (same spelling, same capitalisation). If you rename a method or resource in the CSV, remember to update the name in `tooltips.xlsx` too.
361
+
362
+ ---
363
+
364
+ ## Case 10 — Change the display order of methods or resources
365
+
366
+ **When to use:** You want a method or resource group to appear earlier or later in the app.
367
+
368
+ **Steps:**
369
+
370
+ 1. Open `data/inputs_aggregated.csv`.
371
+ 2. Move the relevant rows up or down. The app displays methods and resource groups **in the same order as they appear in the CSV**.
372
+ 3. Save and run `python data/update.py`.
373
+
374
+ ---
375
+
376
+ ## After any change — checklist
377
+
378
+ **Option 1 (Hugging Face direct):**
379
+ - [ ] Edited and committed `inputs_aggregated.csv` on Hugging Face
380
+ - [ ] Checked the Space logs — confirmed `Data files updated successfully.`
381
+ - [ ] Verified the change appears correctly in the app
382
+
383
+ **Option 2 (local):**
384
+ - [ ] Saved `inputs_aggregated.csv`
385
+ - [ ] Ran `python data/update.py` with no errors
386
+ - [ ] Committed all modified files (CSV, JSON files, templates) and pushed
387
+ - [ ] Verified the change appears correctly in the app after the Space rebuilt
388
+
389
+ ---
390
+
391
+ ## Common mistakes
392
+
393
+ | Mistake | What happens | Fix |
394
+ |---------|-------------|-----|
395
+ | Typo in `Method group` or `Resource group` | A new accidental group is created in the app | Fix the typo and re-run `update.py` |
396
+ | Wrong unit (e.g., `MWh/MtCO2` instead of `Mha/MtCO2`) | The resource appears in the wrong group or breaks the unit display | Correct the `Unit` column |
397
+ | Edited a JSON file directly | Changes are overwritten next time `update.py` runs | Always edit the CSV, never the JSON |
398
+ | Forgot to run `update.py` | The app still shows the old data | Run `python data/update.py` |
399
+ | `min` value equals `max` value | The model automatically halves the `min` to create a range | Intentional for fixed-value resources like Geologic storage — leave as is |
400
+
401
+ ---
402
+
403
+ ## Library Maintenance Guide
404
+
405
+ This section explains how to keep the tool's software dependencies up to date — safely and without breaking anything.
406
+
407
+ ---
408
+
409
+ ### What is a library (dependency)?
410
+
411
+ The tool is built on top of external software packages ("libraries") that handle things like displaying the interface, reading Excel files, or running the optimization. These libraries are listed in `requirements.txt` with their exact version numbers (e.g., `streamlit==1.45.1`).
412
+
413
+ Libraries sometimes release updates — fixing bugs, patching security holes, or adding new features. The risk is that an update can also change how a library behaves and break the tool.
414
+
415
+ ---
416
+
417
+ ### The key libraries and what they do
418
+
419
+ These are the libraries that matter most for this tool:
420
+
421
+ | Library | Role | Risk if updated |
422
+ |---------|------|----------------|
423
+ | **streamlit** | Powers the entire web interface (tabs, sliders, buttons, charts) — currently 1.58.0 | 🔴 High — UI layout and widget behavior can change |
424
+ | **streamlit-option-menu** | The navigation sidebar menu | 🟡 Medium — must stay compatible with streamlit version |
425
+ | **pandas** | Reads and processes CSV and Excel files | 🟡 Medium — occasional API changes |
426
+ | **numpy** | Numerical operations (arrays, math) | 🟡 Medium — used by scipy and pandas |
427
+ | **scipy** | Runs the LP optimization (`linprog`) | 🟡 Medium — optimization results could change |
428
+ | **plotly** | All charts and interactive graphs | 🟡 Medium — chart API evolves |
429
+ | **openpyxl** | Reads and writes Excel files (.xlsx) | 🟢 Low — stable API |
430
+ | **pillow** | Image handling (indirect use) | 🟢 Low |
431
+ | **requests / urllib3 / certifi** | Network and security | 🟢 Low — update freely for security patches |
432
+
433
+ ---
434
+
435
+ ### Dependency chain (what depends on what)
436
+
437
+ Some libraries require specific versions of others to work. Updating one can force updates to others:
438
+
439
+ ```
440
+ streamlit ──────────────► tornado (web server)
441
+ ├───────────► altair (charts)
442
+ ├───────────► pyarrow (data performance)
443
+ ├───────────► narwhals (data compatibility)
444
+ ├───────────► protobuf
445
+ └───────────► cachetools, blinker, click, toml
446
+
447
+ pandas ─────────────────► numpy
448
+ ├───────────────► pyarrow
449
+ └───────────────► python-dateutil, pytz, tzdata
450
+
451
+ scipy ──────────────────► numpy
452
+
453
+ plotly ─────────────────► narwhals
454
+
455
+ openpyxl ──────────────► pillow
456
+ ```
457
+
458
+ **Practical implication:** if you update `streamlit`, you may also need to update `tornado`, `altair`, `pyarrow`, etc. If you update `pandas`, you may need to update `numpy`. These cascading updates are handled automatically by pip (see below), but they can introduce unexpected changes.
459
+
460
+ ---
461
+
462
+ ### When to update
463
+
464
+ #### ✅ Update immediately
465
+ - **Security vulnerabilities** in `requests`, `urllib3`, `certifi`, or `pillow` — these are reported publicly and should be patched fast.
466
+ - Check https://pypi.org/project/[library-name] or subscribe to GitHub security alerts.
467
+
468
+ #### ✅ Update when needed
469
+ - **Streamlit** — when a new feature in the app requires it, or when a critical bug in the current version is causing problems.
470
+ - **pandas / numpy / scipy** — when a bug in the current version is causing incorrect data processing or optimization results.
471
+ - **openpyxl** — when Excel import/export breaks with newer `.xlsx` files.
472
+
473
+ #### ❌ Do NOT update just because a newer version exists
474
+ - "Latest is always better" is not true for production tools. An update that works fine in isolation can break interactions between libraries.
475
+ - The tool has been tested with the versions in `requirements.txt`. Changing them without testing is risky.
476
+
477
+ ---
478
+
479
+ ### How to safely update a library
480
+
481
+ **Never update all libraries at once.** Update one at a time, test the tool after each one.
482
+
483
+ **Step 1 — Back up the current working state**
484
+
485
+ ```
486
+ cp requirements.txt requirements.txt.backup
487
+ ```
488
+
489
+ **Step 2 — Simulate first (dry run)**
490
+
491
+ Before applying anything, check whether the update would cause conflicts:
492
+ ```
493
+ pip install --upgrade streamlit --dry-run
494
+ ```
495
+ If pip reports conflicts, investigate before proceeding.
496
+
497
+ **Step 3 — Update the library**
498
+
499
+ ```
500
+ pip install --upgrade streamlit
501
+ ```
502
+
503
+ Or to a specific version:
504
+ ```
505
+ pip install streamlit==1.58.0
506
+ ```
507
+
508
+ **Step 4 — Verify no dependencies are broken**
509
+
510
+ ```
511
+ pip check
512
+ ```
513
+ This confirms that all installed packages are mutually compatible. If it reports any conflict, do not proceed to the next step — restore the backup instead (see Step 6).
514
+
515
+ **Step 5 — Freeze the new versions to requirements.txt**
516
+
517
+ ```
518
+ pip freeze > requirements.txt
519
+ ```
520
+
521
+ **Step 6 — Test the tool**
522
+
523
+ - Start the app: `streamlit run app.py`
524
+ - Go through every tab
525
+ - Import and export an Excel file
526
+ - Run an optimization
527
+ - Check that charts display correctly
528
+
529
+ **Step 7 — If something breaks**
530
+
531
+ Restore the backup:
532
+ ```
533
+ pip install -r requirements.txt.backup
534
+ cp requirements.txt.backup requirements.txt
535
+ ```
536
+
537
+ ---
538
+
539
+ ### How to install all libraries on a new machine
540
+
541
+ ```
542
+ python -m venv venv
543
+ source venv/bin/activate # Mac/Linux
544
+ # or: venv\Scripts\activate # Windows
545
+ pip install -r requirements.txt
546
+ ```
547
+
548
+ ---
549
+
550
+ ### Checking for outdated libraries
551
+
552
+ To see which installed libraries have newer versions available:
553
+
554
+ ```
555
+ pip list --outdated
556
+ ```
557
+
558
+ This shows a list with current and latest versions. Use this to **identify** what could be updated — but don't update everything at once.
559
+
560
+ ---
561
+
562
+ ### Checking for known security vulnerabilities
563
+
564
+ Install the security scanner:
565
+ ```
566
+ pip install pip-audit
567
+ ```
568
+
569
+ Run it:
570
+ ```
571
+ pip-audit
572
+ ```
573
+
574
+ This will flag any library with a known security issue and suggest a fix. These should be addressed promptly.
575
+
576
+ > **Recommended frequency:** run `pip-audit` at least once a year, even if the tool appears stable. Security vulnerabilities are discovered independently of whether the library has changed — a version that was safe last year may be flagged today.
577
+
578
+ ---
579
+
580
+ ### Using an AI assistant to handle updates
581
+
582
+ Library updates sometimes require code changes alongside the pip update — for example, a deprecated function that was renamed, or a new argument that became required. Doing this manually requires reading changelogs, finding every affected line, and testing the result.
583
+
584
+ An AI coding assistant such as **Claude Code** (available at [claude.ai/code](https://claude.ai/code)) can handle this end-to-end:
585
+
586
+ - Run `pip-audit` or `pip list --outdated` and paste the output into Claude Code
587
+ - Describe the update you want to make (e.g., "update pandas and fix any deprecation warnings")
588
+ - Claude Code will update the library, search the codebase for affected code, apply the necessary fixes, and update `requirements.txt`
589
+
590
+ This is especially useful when:
591
+ - A deprecation warning points to a function that is used in multiple places across the codebase
592
+ - A major version bump changes how a library behaves (e.g., pandas 3.x changing its default string backend)
593
+ - You are not comfortable navigating the code yourself
594
+
595
+ > The AI handles the mechanical parts; you remain responsible for testing the result with `streamlit run app.py` before committing and pushing.
596
+
597
+ ---
598
+
599
+ ### Summary: update strategy
600
+
601
+ ```
602
+ Security fix needed? ──► Update immediately (requests, urllib3, certifi, pillow)
603
+ Something is broken? ──► Update only the relevant library, test thoroughly
604
+ Nothing is broken? ──► Do not update
605
+ ```
606
+
607
+ ---
app.py CHANGED
@@ -1,99 +1,128 @@
1
- import streamlit as st
2
- from streamlit_option_menu import option_menu
3
-
4
- from tabs import (tab0_welcome, tab1_inputs, tab2_constraints,
5
- tab3_coefficients, tab4_optimization, tab5_sensitivity,
6
- tab6_scenarios)
7
- from utils.data_utils import load_slider_data, strip_unit_suffix
8
- from config.config import method_order, secondary_resources
9
- import pathlib
10
-
11
- st.set_page_config("Carbon Portfolio Optimizer", layout="wide")
12
-
13
- # Function to load CSS from the 'assets' folder
14
- def load_css(file_path):
15
- with open(file_path) as f:
16
- st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
17
-
18
- # Load the external CSS
19
- css_path = pathlib.Path("assets/styles.css")
20
- load_css(css_path)
21
-
22
- st.markdown('<h1 class="custom-title">CRRA <span class="crra-hl">Toolkit</span>: CDR Portfolio Optimization</h1>', unsafe_allow_html=True)
23
-
24
- with st.sidebar:
25
- selected = option_menu(
26
- "Navigation",
27
- options=[
28
- "Welcome",
29
- "Resource inputs",
30
- "Method constraints",
31
- "Resource coefficients",
32
- "Portfolio generation",
33
- "Sensitivity analysis",
34
- "Scenario comparison",
35
- ],
36
- icons=["house", "upload", "gear", "sliders", "graph-up-arrow", "card-checklist", "search"],
37
- default_index=0,
38
- orientation="vertical",
39
- key="crra-menu",
40
- styles={
41
- "container":{
42
- "border-radius":"0px",
43
- },
44
- "menu-title":{
45
- "font-family": "Segoe UI, sans-serif",
46
- "font-weight":"bold",
47
- "color": "#ff5f4d",
48
- "text-transform": "uppercase",
49
- "font-style": "italic",
50
- },
51
- "nav-link": {
52
- "font-family": "Segoe UI, sans-serif",
53
- "margin": "4px 0",
54
- "--hover-color": "#F5F5F5",
55
- "text-transform": "uppercase",
56
- "font-weight":"600",
57
- "font-style": "italic",
58
- },
59
- "nav-link-selected": {
60
- "background-color": "#eae9d8",
61
- "color": "#298231",
62
- "font-weight": "900",
63
- },
64
- }
65
- )
66
-
67
- # Initialize data to default values
68
- # Initialize constraints state
69
- if "constraints" not in st.session_state:
70
- st.session_state.constraints = {}
71
- for m in method_order:
72
- if m not in st.session_state.constraints:
73
- st.session_state.constraints[m] = {"active": True, "cap_type": "percent", "cap_value": 100}
74
-
75
- # Initialize cost_sliders
76
- cost_sliders = load_slider_data()
77
- if "costs" not in st.session_state or st.session_state.costs == {}:
78
- st.session_state.costs = {
79
- m: {
80
- strip_unit_suffix(r): (0.0 if (m, strip_unit_suffix(r)) in secondary_resources else cost_sliders[m][r]["median"])
81
- for r in cost_sliders[m]
82
- }
83
- for m in cost_sliders
84
- }
85
-
86
- if selected == "Welcome":
87
- tab0_welcome.render_tab()
88
- elif selected == "Resource inputs":
89
- tab1_inputs.render_tab(cost_sliders)
90
- elif selected == "Method constraints":
91
- tab2_constraints.render_tab()
92
- elif selected == "Resource coefficients":
93
- tab3_coefficients.render_tab(cost_sliders)
94
- elif selected == "Portfolio generation":
95
- tab4_optimization.render_tab()
96
- elif selected == "Sensitivity analysis":
97
- tab5_sensitivity.render_tab()
98
- elif selected == "Scenario comparison":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  tab6_scenarios.render_tab()
 
1
+ import hashlib
2
+ import os
3
+ import sys
4
+ import subprocess
5
+ import pathlib
6
+
7
+ import streamlit as st
8
+ from streamlit_option_menu import option_menu
9
+
10
+ # ── Auto-update: regenerate JSON files if inputs_aggregated.csv has changed ───
11
+ # Uses a hash file to detect changes — works both locally and on Hugging Face.
12
+ # Must run before importing config/tabs, which read the generated JSON files.
13
+ _csv = "data/inputs_aggregated.csv"
14
+ _hash_file = "data/.csv_hash"
15
+ _generated = [
16
+ "data/cost_sliders.json",
17
+ "data/method_groups.json",
18
+ "data/secondary_resources.json",
19
+ ]
20
+
21
+ def _md5(path):
22
+ hasher = hashlib.md5()
23
+ with open(path, "rb") as f:
24
+ hasher.update(f.read())
25
+ return hasher.hexdigest()
26
+
27
+ if os.path.exists(_csv):
28
+ _current_hash = _md5(_csv)
29
+ _stored_hash = open(_hash_file).read().strip() if os.path.exists(_hash_file) else ""
30
+ _missing = any(not os.path.exists(f) for f in _generated)
31
+
32
+ if _current_hash != _stored_hash or _missing:
33
+ print("inputs_aggregated.csv has changed — running data/update.py ...")
34
+ subprocess.run([sys.executable, "data/update.py"], check=True)
35
+ with open(_hash_file, "w") as _f:
36
+ _f.write(_current_hash)
37
+ print("Data files updated successfully.")
38
+
39
+ from tabs import (tab0_welcome, tab1_inputs, tab2_constraints,
40
+ tab3_coefficients, tab4_optimization, tab5_sensitivity,
41
+ tab6_scenarios)
42
+ from utils.data_utils import load_slider_data, strip_unit_suffix
43
+ from config.config import method_order, secondary_resources
44
+
45
+ st.set_page_config("Carbon Portfolio Optimizer", layout="wide")
46
+
47
+ def load_css(file_path):
48
+ with open(file_path) as f:
49
+ st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
50
+
51
+ css_path = pathlib.Path("assets/styles.css")
52
+ load_css(css_path)
53
+
54
+ st.markdown('<h1 class="custom-title">CRRA <span class="crra-hl">Toolkit</span>: CDR Portfolio Optimization</h1>', unsafe_allow_html=True)
55
+
56
+ with st.sidebar:
57
+ selected = option_menu(
58
+ "Navigation",
59
+ options=[
60
+ "Welcome",
61
+ "Resource inputs",
62
+ "Method constraints",
63
+ "Resource coefficients",
64
+ "Portfolio generation",
65
+ "Sensitivity analysis",
66
+ "Scenario comparison",
67
+ ],
68
+ icons=["house", "upload", "gear", "sliders", "graph-up-arrow", "card-checklist", "search"],
69
+ default_index=0,
70
+ orientation="vertical",
71
+ key="crra-menu",
72
+ styles={
73
+ "container":{
74
+ "border-radius":"0px",
75
+ },
76
+ "menu-title":{
77
+ "font-family": "Segoe UI, sans-serif",
78
+ "font-weight":"bold",
79
+ "color": "#ff5f4d",
80
+ "text-transform": "uppercase",
81
+ "font-style": "italic",
82
+ },
83
+ "nav-link": {
84
+ "font-family": "Segoe UI, sans-serif",
85
+ "margin": "4px 0",
86
+ "--hover-color": "#F5F5F5",
87
+ "text-transform": "uppercase",
88
+ "font-weight":"600",
89
+ "font-style": "italic",
90
+ },
91
+ "nav-link-selected": {
92
+ "background-color": "#eae9d8",
93
+ "color": "#298231",
94
+ "font-weight": "900",
95
+ },
96
+ }
97
+ )
98
+
99
+ if "constraints" not in st.session_state:
100
+ st.session_state.constraints = {}
101
+ for m in method_order:
102
+ if m not in st.session_state.constraints:
103
+ st.session_state.constraints[m] = {"active": True, "cap_type": "percent", "cap_value": 100}
104
+
105
+ cost_sliders = load_slider_data()
106
+ if "costs" not in st.session_state or st.session_state.costs == {}:
107
+ st.session_state.costs = {
108
+ m: {
109
+ strip_unit_suffix(r): (0.0 if (m, strip_unit_suffix(r)) in secondary_resources else cost_sliders[m][r]["median"])
110
+ for r in cost_sliders[m]
111
+ }
112
+ for m in cost_sliders
113
+ }
114
+
115
+ if selected == "Welcome":
116
+ tab0_welcome.render_tab()
117
+ elif selected == "Resource inputs":
118
+ tab1_inputs.render_tab(cost_sliders)
119
+ elif selected == "Method constraints":
120
+ tab2_constraints.render_tab()
121
+ elif selected == "Resource coefficients":
122
+ tab3_coefficients.render_tab(cost_sliders)
123
+ elif selected == "Portfolio generation":
124
+ tab4_optimization.render_tab()
125
+ elif selected == "Sensitivity analysis":
126
+ tab5_sensitivity.render_tab()
127
+ elif selected == "Scenario comparison":
128
  tab6_scenarios.render_tab()
assets/styles.css CHANGED
@@ -182,7 +182,8 @@ h1.custom-title {
182
  [data-testid="stAlert"] > div,
183
  [data-testid="stAlert"] > div > div {
184
  border-radius: 0px !important;
185
- clip-path: polygon(1% 0, 100% 0, 99% 100%, 0 100%);
 
186
  }
187
 
188
  [data-testid="stAlert"] p {
@@ -190,7 +191,7 @@ h1.custom-title {
190
  font-size: 0.95rem;
191
  line-height: 1.5;
192
  letter-spacing: 0.01em;
193
- padding-left: 1%;
194
  }
195
 
196
  /* Special warning box (tab1) */
@@ -206,8 +207,8 @@ h1.custom-title {
206
 
207
  .st-key-warning_resources span,
208
  [data-testid="stAlert"] span {
209
- font-style: italic;
210
- padding-left: 1%;
211
  }
212
 
213
  /* ── 8. Metrics ────────────────────────────────────────────── */
@@ -393,20 +394,8 @@ h1.custom-title {
393
  }
394
 
395
  /* Text & number input fields */
396
- [data-testid="stTextInput"] input,
397
- [data-testid="stNumberInput"] input {
398
- border-radius: 0px !important;
399
- border-top: none !important;
400
- border-left: none !important;
401
- border-right: none !important;
402
- background: transparent !important;
403
- box-shadow: none !important;
404
- transition: border-color 0.2s ease;
405
- }
406
- [data-testid="stTextInput"] input:focus,
407
- [data-testid="stNumberInput"] input:focus {
408
- border-bottom-color: var(--green-dark) !important;
409
- box-shadow: none !important;
410
  }
411
 
412
  /* Text area */
@@ -476,13 +465,6 @@ section[data-testid="stExpander"] summary p {
476
  }
477
 
478
  /* ── 12. File uploaders ─────────────────────────────────────── */
479
- [class*="resource_uploader_"],
480
- [class*="inputs_loader_"],
481
- [class*="scenario_loader_"],
482
- .st-key-comparison_loader {
483
- font-family: var(--font);
484
- }
485
-
486
  [class*="resource_uploader_"] p,
487
  [class*="inputs_loader_"] p,
488
  [class*="scenario_loader_"] p,
@@ -491,35 +473,30 @@ section[data-testid="stExpander"] summary p {
491
  font-style: italic;
492
  }
493
 
494
- [class*="resource_uploader_"] [data-testid="stFileUploaderDropzone"],
495
- [class*="inputs_loader_"] [data-testid="stFileUploaderDropzone"],
496
- [class*="scenario_loader_"] [data-testid="stFileUploaderDropzone"],
497
- .st-key-comparison_loader [data-testid="stFileUploaderDropzone"] {
498
- background-color: var(--off-white);
499
- color: var(--green-dark);
500
- font-weight: 700;
501
- letter-spacing: 0.094444em;
502
- text-transform: uppercase;
503
- font-size: 0.875rem;
504
- border-radius: 0px !important;
505
- width: stretch;
506
- }
507
-
508
- [class*="resource_uploader_"] [data-testid="stFileUploaderDropzone"] span svg,
509
- [class*="inputs_loader_"] [data-testid="stFileUploaderDropzone"] span svg,
510
- [class*="scenario_loader_"] [data-testid="stFileUploaderDropzone"] span svg,
511
- .st-key-comparison_loader [data-testid="stFileUploaderDropzone"] span svg {
512
- color: var(--green-dark);
513
  }
514
 
515
  [class*="resource_uploader_"] [data-testid="stBaseButton-secondary"],
516
  [class*="inputs_loader_"] [data-testid="stBaseButton-secondary"],
517
  [class*="scenario_loader_"] [data-testid="stBaseButton-secondary"] {
518
- color: var(--off-white) !important;
 
 
 
 
 
 
 
 
 
 
 
 
519
  font-weight: 700 !important;
520
  text-transform: uppercase;
521
- font-style: italic;
522
- background-color: var(--green-dark) !important;
523
  border: none !important;
524
  }
525
 
@@ -594,10 +571,11 @@ section[data-testid="stExpander"] summary p {
594
  gap: 1.125%;
595
  }
596
 
597
- .st-key-container-btn-new {
598
  display: flex;
599
  flex-direction: row-reverse;
600
  gap: 1.125%;
 
601
  }
602
 
603
  /* ── 17. Scrollbar ──────────────────────────────────────────── */
 
182
  [data-testid="stAlert"] > div,
183
  [data-testid="stAlert"] > div > div {
184
  border-radius: 0px !important;
185
+ /* clip-path: polygon(1% 0, 100% 0, 99% 100%, 0 100%); */
186
+ clip-path: polygon(16px 0, 100% 0, calc(100% - 16px) 100%, 0 100%)
187
  }
188
 
189
  [data-testid="stAlert"] p {
 
191
  font-size: 0.95rem;
192
  line-height: 1.5;
193
  letter-spacing: 0.01em;
194
+ padding-left: 1rem;
195
  }
196
 
197
  /* Special warning box (tab1) */
 
207
 
208
  .st-key-warning_resources span,
209
  [data-testid="stAlert"] span {
210
+ /* font-style: italic; */
211
+ padding-left: 1rem;
212
  }
213
 
214
  /* ── 8. Metrics ────────────────────────────────────────────── */
 
394
  }
395
 
396
  /* Text & number input fields */
397
+ [data-testid="stNumberInputContainer"] {
398
+ background-color: var(--off-white);
 
 
 
 
 
 
 
 
 
 
 
 
399
  }
400
 
401
  /* Text area */
 
465
  }
466
 
467
  /* ── 12. File uploaders ─────────────────────────────────────── */
 
 
 
 
 
 
 
468
  [class*="resource_uploader_"] p,
469
  [class*="inputs_loader_"] p,
470
  [class*="scenario_loader_"] p,
 
473
  font-style: italic;
474
  }
475
 
476
+ [data-testid="stFileUploaderDropzone"]{
477
+ align-items: center;
478
+ flex-direction: row-reverse;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
479
  }
480
 
481
  [class*="resource_uploader_"] [data-testid="stBaseButton-secondary"],
482
  [class*="inputs_loader_"] [data-testid="stBaseButton-secondary"],
483
  [class*="scenario_loader_"] [data-testid="stBaseButton-secondary"] {
484
+ color: var(--green-dark) !important;
485
+ border: none !important;
486
+ border-radius: 0.5rem;
487
+ }
488
+ [class*="resource_uploader_"] [data-testid="stBaseButton-secondary"]:hover,
489
+ [class*="inputs_loader_"] [data-testid="stBaseButton-secondary"]:hover,
490
+ [class*="scenario_loader_"] [data-testid="stBaseButton-secondary"]:hover {
491
+ background-color: var(--green-light);
492
+ }
493
+ [class*="resource_uploader_"] [data-testid="stBaseButton-secondary"] p,
494
+ [class*="inputs_loader_"] [data-testid="stBaseButton-secondary"] p ,
495
+ [class*="scenario_loader_"] [data-testid="stBaseButton-secondary"] p {
496
+ color: var(--green-dark) !important;
497
  font-weight: 700 !important;
498
  text-transform: uppercase;
499
+ font-style: normal;
 
500
  border: none !important;
501
  }
502
 
 
571
  gap: 1.125%;
572
  }
573
 
574
+ [class*="st-key-container-button"] {
575
  display: flex;
576
  flex-direction: row-reverse;
577
  gap: 1.125%;
578
+ justify-content: flex-start;
579
  }
580
 
581
  /* ── 17. Scrollbar ──────────────────────────────────────────── */
config/config.py CHANGED
@@ -1,157 +1,86 @@
1
- import pandas as pd
2
- import plotly.express as px
3
-
4
- CO2_UNIT = "MtCO₂"
5
-
6
- method_groups = {
7
- "Forestry": [
8
- "Afforestation", "Reforestation", "Forest management", "Agroforestry"],
9
- "Soil carbon sequestration (crop, grasslands)": [
10
- "Cropland management", "Pasture management"],
11
- "Blue carbon": [
12
- "Coastal revegetation (mangrove, seaweeds, etc.)"],
13
- "Durable biobased products": [
14
- "Durable biobased products"],
15
- "Bio-char": [
16
- "Bio-char - Forest biomass", "Bio-char - Non-forest biomass", "Bio-char - Unspecified biomass"
17
- ],
18
- "Bio-CCS": [
19
- "BioCCS with combustion", "BioCCS with combustion & growing biomass", "BioCCS with anaerobic digestion/fermentation", "BioCCS with anaerobic digestion/fermentation & growing biomass"
20
- ],
21
- "Enhanced weathering": [
22
- "Enhanced weathering - Basalt mineral", "Enhanced weathering - Olivine mineral", "Enhanced weathering - Wollastonite mineral", "Enhanced weathering - Unspecified mineral"
23
- ],
24
- "Ocean alkalinity enhancement": [
25
- "Mineral OAE / Ocean liming", "Electrochemical OAE"
26
- ],
27
- "Direct air carbon capture and storage (DACCS)": [
28
- "Electrochemical DACCS", "Low-temp DACCS (solid-sorbent/liquid-solvent)",
29
- "High-temp DACCS (solid-sorbent/liquid-solvent)", "Mineral looping DACCS",
30
- "Moisture swing DACCS"
31
- ],
32
- "Direct ocean carbon captureand storage (DOCCS)": ["pH swing"],
33
- "Mineralization": ["Mineralization ex-situ"]
34
- }
35
-
36
- resource_groups = {
37
- "Minerals": ["Basalt mineral", "Olivine mineral", "Wollastonite mineral", "Unspecified mineral"],
38
- "Biomass": ["Forest biomass", "Non-forest biomass", "Unspecified biomass"],
39
- "Land": ["Arable land", "Non-arable land", "Unspecified land"],
40
- "Chemicals": ["Unspecified chemical"],
41
- "Energy": ["Electrical energy", "Thermal energy unspecified grade", "Thermal energy low grade", "Thermal energy high grade"],
42
- "Water": ["Salt water", "Unspecified water"],
43
- "Shoreline": ["Shoreline"],
44
- "Storage": ["Geologic storage"]
45
- }
46
-
47
- resource_unit_map = {
48
- "Land": "Mha",
49
- "Energy": "MWh",
50
- "Biomass": "Mt",
51
- "Minerals": "Mt",
52
- "Chemicals": "Mt",
53
- "Shoreline": "km",
54
- "Water": "bcm",
55
- "Storage": "Mt"
56
- }
57
-
58
- resource_units = {
59
- f"{resource_unit_map[cat]}/MtCO2": resources
60
- for cat, resources in resource_groups.items()
61
- }
62
-
63
- resource_order = sum(resource_groups.values(), [])
64
- method_order = sum(method_groups.values(), [])
65
-
66
- # secondary resources
67
- secondary_df = pd.read_csv("data/secondary_resources.csv")
68
- secondary_resources = set(zip(
69
- secondary_df["method"].str.strip(),
70
- secondary_df["resource"].str.strip(),
71
- ))
72
-
73
- # tooltips
74
- resource_tooltips = {
75
- # "Arable land": "Land used for growing crops.",
76
- # "Non-arable land": "Land not suitable for crop production, e.g., pastures.",
77
- # "Unspecified land": "General land use not specified.",
78
- # "Electrical energy ": "Electricity consumption per ton of CO2 captured.",
79
- # "Thermal energy unspecified grade": "Heat energy consumption per ton of CO2 captured.",
80
- # "Thermal energy low grade": "Heat energy consumption per ton of CO2 captured.",
81
- # "Thermal energy high grade": "Heat energy consumption per ton of CO2 captured.",
82
- "Forest biomass": "Dry quantities of biomass sourced from forests.",
83
- "Non-forest biomass": "Dry quantities of biomass sourced from non-forest areas.",
84
- "Unspecified biomass": "Dry quantities of general biomass, not specified.",
85
- # "Basalt mineral": "Basalt mineral used in enhanced weathering.",
86
- # "Olivine mineral": "Olivine mineral used in enhanced weathering.",
87
- # "Wollastonite mineral": "Wollastonite mineral used in enhanced weathering.",
88
- # "Unspecified mineral": "General mineral not specified for enhanced weathering.",
89
- # "Unspecified chemical": "General chemical resource not specified.",
90
- # "Salt water": "Salt water used in ocean-based methods.",
91
- # "Unspecified water": "General water resource not specified.",
92
- # "Shoreline": "Length of shoreline affected by coastal methods."
93
- }
94
-
95
- method_tooltips = {
96
- "Afforestation": "Methods related to afforestation practices.",
97
- "Reforestation": "Methods related to reforestation practices.",
98
- "Forest management": "Sustainable management of forest resources.",
99
- "Agroforestry": "Integration of trees and shrubs into agricultural systems.",
100
- "Cropland management": "Practices to enhance carbon storage in croplands.",
101
- "Pasture management": "Sustainable practices for managing pastures.",
102
- "Coastal revegetation (mangrove, seaweeds)": "Restoration of coastal ecosystems using mangroves and seaweeds.",
103
- "Durable biobased products": "Production of long-lasting biobased materials.",
104
- "Bio-char - Forest biomass": "Production of bio-char from forest biomass.",
105
- "Bio-char - Non-forest biomass": "Production of bio-char from non-forest biomass.",
106
- "Bio-char - Unspecified biomass": "Production of bio-char from unspecified biomass sources.",
107
- "BioCCS with combustion": "Bioenergy with carbon capture and storage using combustion.",
108
- "BioCCS with combustion & growing biomass": "Bioenergy with carbon capture and storage using combustion and growing biomass.",
109
- "BioCCS with anaerobic digestion/fermentation": "Bioenergy with carbon capture and storage using anaerobic digestion or fermentation.",
110
- "BioCCS with anaerobic digestion/fermentation & growing biomass": "Bioenergy with carbon capture and storage using anaerobic digestion or fermentation and growing biomass.",
111
- "Enhanced weathering - Basalt mineral": "Use of basalt mineral for enhanced weathering.",
112
- "Enhanced weathering - Olivine mineral": "Use of olivine mineral for enhanced weathering.",
113
- "Enhanced weathering - Wollastonite mineral": "Use of wollastonite mineral for enhanced weathering.",
114
- "Enhanced weathering - Unspecified mineral": "Use of unspecified mineral for enhanced weathering.",
115
- "Mineral OAE / Ocean liming": "Ocean alkalinity enhancement using mineral or ocean liming.",
116
- "Electrochemical OAE": "Ocean alkalinity enhancement using electrochemical methods.",
117
- "Electrochemical DACCS": "Direct air carbon capture and storage using electrochemical methods.",
118
- "Low-temp DACCS (solid-sorbent/liquid-solvent)": "Direct air carbon capture and storage using low-temperature solid sorbents or liquid solvents.",
119
- "High-temp DACCS (solid-sorbent/liquid-solvent)": "Direct air carbon capture and storage using high-temperature solid sorbents or liquid solvents.",
120
- "Mineral looping DACCS": "Direct air carbon capture and storage using mineral looping.",
121
- "Moisture swing DACCS": "Direct air carbon capture and storage using moisture swing methods.",
122
- "pH swing": "Direct ocean carbon capture and storage using pH swing methods.",
123
- "Mineralization ex-situ": "Ex-situ mineralization methods for carbon capture."
124
- }
125
-
126
- CRRA_COLORS = {
127
- "off_white": "#eae9d8",
128
- "red": "#ff5f4d",
129
- "green_dark": "#298231",
130
- "green": "#55c95b",
131
- "green_light": "#92ef9b",
132
- "yellow": "#d9e210",
133
- "white": "#ffffff",
134
- "brown_grey": "#665d5b",
135
- "grey": "#a0a0a0",
136
- "black": "#000000",
137
- }
138
-
139
- CRRA_FONT = "Segoe UI, sans-serif"
140
-
141
- PLOTLY_COLOR_SEQUENCE = [
142
- CRRA_COLORS["red"],
143
- CRRA_COLORS["green"],
144
- CRRA_COLORS["yellow"],
145
- CRRA_COLORS["green_dark"],
146
- CRRA_COLORS["brown_grey"],
147
- CRRA_COLORS["green_light"],
148
- CRRA_COLORS["grey"],
149
- ]
150
-
151
- PLOTLY_HEATMAP_SCALE = [
152
- [0.0, CRRA_COLORS["white"]],
153
- [0.5, CRRA_COLORS["green_light"]],
154
- [1.0, CRRA_COLORS["green_dark"]],
155
- ]
156
-
157
- PLOTLY_TEMPLATE = "plotly_white"
 
1
+ import json
2
+ import pandas as pd
3
+ import plotly.express as px
4
+
5
+ CO2_UNIT = "MtCO₂"
6
+
7
+ with open("data/method_groups.json") as _f:
8
+ method_groups: dict[str, list[str]] = json.load(_f)
9
+
10
+ _csv = pd.read_csv("data/inputs_aggregated.csv")
11
+
12
+ # resource_groups: group → [resources], order follows first appearance in CSV
13
+ resource_groups: dict[str, list[str]] = {}
14
+ for _, _row in _csv.drop_duplicates(subset=["Resource group", "Resource"]).iterrows():
15
+ _g, _r = _row["Resource group"], _row["Resource"]
16
+ resource_groups.setdefault(_g, [])
17
+ if _r not in resource_groups[_g]:
18
+ resource_groups[_g].append(_r)
19
+
20
+ # resource_unit_map: group → base unit (e.g. "Mha/MtCO2" → "Mha")
21
+ resource_unit_map: dict[str, str] = {}
22
+ for _, _row in _csv.drop_duplicates(subset=["Resource group"]).iterrows():
23
+ resource_unit_map[_row["Resource group"]] = str(_row["Unit"]).split("/")[0]
24
+
25
+ resource_order = sum(resource_groups.values(), [])
26
+ method_order = sum(method_groups.values(), [])
27
+
28
+ try:
29
+ with open("data/secondary_resources.json") as _f:
30
+ secondary_resources: set[tuple[str, str]] = {tuple(p) for p in json.load(_f)}
31
+ except FileNotFoundError:
32
+ secondary_resources: set[tuple[str, str]] = set()
33
+
34
+ # Tooltips are loaded from data/tooltips.xlsx (two sheets: "Resources" and "Methods").
35
+ # To add or edit a tooltip, open that file — no code change needed.
36
+ # If the file is missing, tooltips are silently disabled (no hover text shown).
37
+ try:
38
+ _tooltips_xl = pd.read_excel("data/tooltips.xlsx", sheet_name=None)
39
+ _df_res = _tooltips_xl.get("Resources", pd.DataFrame(columns=["Resource", "Tooltip"]))
40
+ _df_met = _tooltips_xl.get("Methods", pd.DataFrame(columns=["Method", "Tooltip"]))
41
+ resource_tooltips: dict[str, str] = {
42
+ row["Resource"]: row["Tooltip"]
43
+ for _, row in _df_res.iterrows()
44
+ if pd.notna(row.get("Tooltip")) and str(row.get("Tooltip", "")).strip()
45
+ }
46
+ method_tooltips: dict[str, str] = {
47
+ row["Method"]: row["Tooltip"]
48
+ for _, row in _df_met.iterrows()
49
+ if pd.notna(row.get("Tooltip")) and str(row.get("Tooltip", "")).strip()
50
+ }
51
+ except FileNotFoundError:
52
+ resource_tooltips: dict[str, str] = {}
53
+ method_tooltips: dict[str, str] = {}
54
+
55
+ CRRA_COLORS = {
56
+ "off_white": "#eae9d8",
57
+ "red": "#ff5f4d",
58
+ "green_dark": "#298231",
59
+ "green": "#55c95b",
60
+ "green_light": "#92ef9b",
61
+ "yellow": "#d9e210",
62
+ "white": "#ffffff",
63
+ "brown_grey": "#665d5b",
64
+ "grey": "#a0a0a0",
65
+ "black": "#000000",
66
+ }
67
+
68
+ CRRA_FONT = "Segoe UI, sans-serif"
69
+
70
+ PLOTLY_COLOR_SEQUENCE = [
71
+ CRRA_COLORS["red"],
72
+ CRRA_COLORS["green"],
73
+ CRRA_COLORS["yellow"],
74
+ CRRA_COLORS["green_dark"],
75
+ CRRA_COLORS["brown_grey"],
76
+ CRRA_COLORS["green_light"],
77
+ CRRA_COLORS["grey"],
78
+ ]
79
+
80
+ PLOTLY_HEATMAP_SCALE = [
81
+ [0.0, CRRA_COLORS["white"]],
82
+ [0.5, CRRA_COLORS["green_light"]],
83
+ [1.0, CRRA_COLORS["green_dark"]],
84
+ ]
85
+
86
+ PLOTLY_TEMPLATE = "plotly_white"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
data/.csv_hash ADDED
@@ -0,0 +1 @@
 
 
1
+ ae18d57252350286638943fd58c0300c
data/generate_input_template.py CHANGED
@@ -14,8 +14,8 @@ from utils.data_utils import strip_unit_suffix
14
  with open("data/cost_sliders.json") as f:
15
  cost_sliders = json.load(f)
16
 
17
- secondary_df = pd.read_csv("data/secondary_resources.csv")
18
- secondary_resources = set(zip(secondary_df["method"], secondary_df["resource"]))
19
 
20
  wb = openpyxl.Workbook()
21
 
 
14
  with open("data/cost_sliders.json") as f:
15
  cost_sliders = json.load(f)
16
 
17
+ with open("data/secondary_resources.json") as _f:
18
+ secondary_resources = {tuple(p) for p in json.load(_f)}
19
 
20
  wb = openpyxl.Workbook()
21
 
data/{generate_cost_matrix.py → generate_model_data.py} RENAMED
@@ -14,24 +14,83 @@ def pop_by_name(d: dict, name: str):
14
  if key:
15
  d.pop(key)
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  def create_sliders_inputs():
18
  df = pd.read_csv("data/inputs_aggregated.csv")
19
 
20
  cost_sliders = {}
21
 
22
- # extract data from inputs and create final dictionary for sliders
 
23
  for _, row in df.iterrows():
24
  method = row["Implementation"]
25
- # break bio-char into 3 different cases, based on which biomass is used
26
- """
27
- For the Implementation "Biochar", we must create three different cases:
28
- 1. Bio-char - Forest biomass
29
- 2. Bio-char - Non-forest biomass
30
- 3. Bio-char - Unspecified biomass
31
- Where we have to keep the value for the corresponding resource in:
32
- [Forest biomass OR Non-forest biomass OR Unspecified biomass]
33
- But set the other two to zero, and keep the values in the other resources intact
34
- """
35
  resource = row["Resource"] + " (" + row["Unit"] + ")"
36
  if row["min"] == row["max"] and row["Resource"] != "Geologic storage":
37
  row["min"] = row["min"] * .5
@@ -41,10 +100,9 @@ def create_sliders_inputs():
41
  "max": float(row["max"])
42
  }
43
  cost_sliders.setdefault(method, {})[resource] = entry
44
-
45
- print(cost_sliders)
46
-
47
- # create the three cases for bio-char
48
  cost_sliders["Bio-char - Forest biomass"] = cost_sliders["Bio-char"].copy()
49
  pop_by_name(cost_sliders["Bio-char - Forest biomass"], "Non-forest biomass")
50
  pop_by_name(cost_sliders["Bio-char - Forest biomass"], "Unspecified biomass")
@@ -54,11 +112,9 @@ def create_sliders_inputs():
54
  cost_sliders["Bio-char - Unspecified biomass"] = cost_sliders["Bio-char"].copy()
55
  pop_by_name(cost_sliders["Bio-char - Unspecified biomass"], "Forest biomass")
56
  pop_by_name(cost_sliders["Bio-char - Unspecified biomass"], "Non-forest biomass")
57
- # remove "Bio-char" from the dictionary
58
- if "Bio-char" in cost_sliders:
59
- del cost_sliders["Bio-char"]
60
-
61
- # create all cases for enhanced weathering
62
  cost_sliders["Enhanced weathering - Basalt mineral"] = cost_sliders["Enhanced weathering"].copy()
63
  pop_by_name(cost_sliders["Enhanced weathering - Basalt mineral"], "Olivine mineral")
64
  pop_by_name(cost_sliders["Enhanced weathering - Basalt mineral"], "Wollastonite mineral")
@@ -75,24 +131,33 @@ def create_sliders_inputs():
75
  pop_by_name(cost_sliders["Enhanced weathering - Unspecified mineral"], "Basalt mineral")
76
  pop_by_name(cost_sliders["Enhanced weathering - Unspecified mineral"], "Olivine mineral")
77
  pop_by_name(cost_sliders["Enhanced weathering - Unspecified mineral"], "Wollastonite mineral")
78
- # remove "Enhanced weathering" from the dictionary
79
- if "Enhanced weathering" in cost_sliders:
80
- del cost_sliders["Enhanced weathering"]
81
- # create case for growing biomass in bioccs
82
  cost_sliders["BioCCS with combustion & growing biomass"] = cost_sliders["BioCCS with combustion"].copy()
83
  pop_by_name(cost_sliders["BioCCS with combustion"], "Arable land")
84
  pop_by_name(cost_sliders["BioCCS with combustion & growing biomass"], "Forest biomass")
85
  pop_by_name(cost_sliders["BioCCS with combustion & growing biomass"], "Unspecified biomass")
86
-
87
  cost_sliders["BioCCS with anaerobic digestion/fermentation & growing biomass"] = cost_sliders["BioCCS with anaerobic digestion/fermentation"].copy()
88
  pop_by_name(cost_sliders["BioCCS with anaerobic digestion/fermentation"], "Arable land")
89
  pop_by_name(cost_sliders["BioCCS with anaerobic digestion/fermentation & growing biomass"], "Non-forest biomass")
90
  pop_by_name(cost_sliders["BioCCS with anaerobic digestion/fermentation & growing biomass"], "Unspecified biomass")
91
-
 
92
  with open("data/cost_sliders.json", "w") as f:
93
  json.dump(cost_sliders, f, indent=2)
 
 
 
 
 
 
94
 
95
- print("Saved slider configuration to cost_sliders.json")
 
 
 
96
 
97
  if __name__ == "__main__":
98
  create_sliders_inputs()
 
14
  if key:
15
  d.pop(key)
16
 
17
+ # Which base methods are replaced by derived variants.
18
+ # Methods listed here must have derived entries added in create_sliders_inputs().
19
+ REPLACED_BY_DERIVED = {"Bio-char", "Enhanced weathering"}
20
+
21
+ # Which derived methods follow each base method (in order).
22
+ DERIVED_FROM = {
23
+ "Bio-char": [
24
+ "Bio-char - Forest biomass",
25
+ "Bio-char - Non-forest biomass",
26
+ "Bio-char - Unspecified biomass",
27
+ ],
28
+ "Enhanced weathering": [
29
+ "Enhanced weathering - Basalt mineral",
30
+ "Enhanced weathering - Olivine mineral",
31
+ "Enhanced weathering - Wollastonite mineral",
32
+ "Enhanced weathering - Unspecified mineral",
33
+ ],
34
+ "BioCCS with combustion": ["BioCCS with combustion & growing biomass"],
35
+ "BioCCS with anaerobic digestion/fermentation": [
36
+ "BioCCS with anaerobic digestion/fermentation & growing biomass"
37
+ ],
38
+ }
39
+
40
+
41
+ def build_method_groups(df, cost_sliders):
42
+ """Build the method_groups dict from the CSV Method group column + derivation rules above.
43
+ Group and method order follows first appearance in inputs_aggregated.csv."""
44
+ base_methods_per_group = {}
45
+ for _, row in df.drop_duplicates(subset=["Implementation"]).iterrows():
46
+ m, g = row["Implementation"], row["Method group"]
47
+ base_methods_per_group.setdefault(g, [])
48
+ if m not in base_methods_per_group[g]:
49
+ base_methods_per_group[g].append(m)
50
+
51
+ method_groups = {}
52
+ for g, base_list in base_methods_per_group.items():
53
+ methods = []
54
+ for base in base_list:
55
+ if base not in REPLACED_BY_DERIVED:
56
+ methods.append(base)
57
+ for derived in DERIVED_FROM.get(base, []):
58
+ if derived in cost_sliders:
59
+ methods.append(derived)
60
+ if methods:
61
+ method_groups[g] = methods
62
+ return method_groups
63
+
64
+
65
+ def build_secondary_resources(df, cost_sliders):
66
+ """Build the full set of (method, resource) secondary pairs from the CSV
67
+ 'Is secondary' column, then propagate to derived methods via DERIVED_FROM."""
68
+ # Base pairs from CSV
69
+ base_secondary = set()
70
+ for _, row in df[df["Is secondary"] == True].iterrows():
71
+ base_secondary.add((row["Implementation"], row["Resource"]))
72
+
73
+ # Expand to derived methods: derived methods inherit secondary resources from their base
74
+ full_secondary = set(base_secondary)
75
+ for base, deriveds in DERIVED_FROM.items():
76
+ base_resources_secondary = {r for (m, r) in base_secondary if m == base}
77
+ for derived in deriveds:
78
+ if derived in cost_sliders:
79
+ for r in base_resources_secondary:
80
+ full_secondary.add((derived, r))
81
+
82
+ return full_secondary
83
+
84
+
85
  def create_sliders_inputs():
86
  df = pd.read_csv("data/inputs_aggregated.csv")
87
 
88
  cost_sliders = {}
89
 
90
+ # Build cost_sliders from CSV: one entry per (method, resource) row.
91
+ # Bio-char, Enhanced weathering and BioCCS variants are derived below.
92
  for _, row in df.iterrows():
93
  method = row["Implementation"]
 
 
 
 
 
 
 
 
 
 
94
  resource = row["Resource"] + " (" + row["Unit"] + ")"
95
  if row["min"] == row["max"] and row["Resource"] != "Geologic storage":
96
  row["min"] = row["min"] * .5
 
100
  "max": float(row["max"])
101
  }
102
  cost_sliders.setdefault(method, {})[resource] = entry
103
+
104
+ # ── Derived methods ───────────────────────────────────────────────────────
105
+ # Bio-char: split into 3 biomass-specific variants, remove generic base
 
106
  cost_sliders["Bio-char - Forest biomass"] = cost_sliders["Bio-char"].copy()
107
  pop_by_name(cost_sliders["Bio-char - Forest biomass"], "Non-forest biomass")
108
  pop_by_name(cost_sliders["Bio-char - Forest biomass"], "Unspecified biomass")
 
112
  cost_sliders["Bio-char - Unspecified biomass"] = cost_sliders["Bio-char"].copy()
113
  pop_by_name(cost_sliders["Bio-char - Unspecified biomass"], "Forest biomass")
114
  pop_by_name(cost_sliders["Bio-char - Unspecified biomass"], "Non-forest biomass")
115
+ del cost_sliders["Bio-char"]
116
+
117
+ # Enhanced weathering: split into 4 mineral-specific variants, remove generic base
 
 
118
  cost_sliders["Enhanced weathering - Basalt mineral"] = cost_sliders["Enhanced weathering"].copy()
119
  pop_by_name(cost_sliders["Enhanced weathering - Basalt mineral"], "Olivine mineral")
120
  pop_by_name(cost_sliders["Enhanced weathering - Basalt mineral"], "Wollastonite mineral")
 
131
  pop_by_name(cost_sliders["Enhanced weathering - Unspecified mineral"], "Basalt mineral")
132
  pop_by_name(cost_sliders["Enhanced weathering - Unspecified mineral"], "Olivine mineral")
133
  pop_by_name(cost_sliders["Enhanced weathering - Unspecified mineral"], "Wollastonite mineral")
134
+ del cost_sliders["Enhanced weathering"]
135
+
136
+ # BioCCS: add growing-biomass variants alongside their base methods
 
137
  cost_sliders["BioCCS with combustion & growing biomass"] = cost_sliders["BioCCS with combustion"].copy()
138
  pop_by_name(cost_sliders["BioCCS with combustion"], "Arable land")
139
  pop_by_name(cost_sliders["BioCCS with combustion & growing biomass"], "Forest biomass")
140
  pop_by_name(cost_sliders["BioCCS with combustion & growing biomass"], "Unspecified biomass")
141
+
142
  cost_sliders["BioCCS with anaerobic digestion/fermentation & growing biomass"] = cost_sliders["BioCCS with anaerobic digestion/fermentation"].copy()
143
  pop_by_name(cost_sliders["BioCCS with anaerobic digestion/fermentation"], "Arable land")
144
  pop_by_name(cost_sliders["BioCCS with anaerobic digestion/fermentation & growing biomass"], "Non-forest biomass")
145
  pop_by_name(cost_sliders["BioCCS with anaerobic digestion/fermentation & growing biomass"], "Unspecified biomass")
146
+
147
+ # ── Save outputs ──────────────────────────────────────────────────────────
148
  with open("data/cost_sliders.json", "w") as f:
149
  json.dump(cost_sliders, f, indent=2)
150
+ print("Saved cost_sliders.json")
151
+
152
+ method_groups = build_method_groups(df, cost_sliders)
153
+ with open("data/method_groups.json", "w") as f:
154
+ json.dump(method_groups, f, indent=2)
155
+ print("Saved method_groups.json")
156
 
157
+ secondary_resources = build_secondary_resources(df, cost_sliders)
158
+ with open("data/secondary_resources.json", "w") as f:
159
+ json.dump(sorted(secondary_resources), f, indent=2)
160
+ print(f"Saved secondary_resources.json ({len(secondary_resources)} pairs)")
161
 
162
  if __name__ == "__main__":
163
  create_sliders_inputs()
data/inputs_aggregated.csv CHANGED
@@ -1,84 +1,84 @@
1
- Implementation,Resource,Type,Unit,count,quantile_25,mean,median,quantile_75,std,min,max
2
- Afforestation,Unspecified land,Physical,Mha/MtCO2,36,0.024593,0.115652,0.094927,0.197075,0.112368,0.002,0.384615
3
- Afforestation,Unspecified water,Physical,bcm/MtCO2,2,0.092,0.092,0.092,0.092,0,0.092,0.092
4
- Agroforestry,Arable land,Physical,Mha/MtCO2,12,0.039953,0.450767,0.064101,0.2141,0.90114,0.0025,3.030303
5
- Bio-char,Electrical energy,Energy,MWh/MtCO2,6,8.75E+01,1.67E+05,3.50E+02,3.75E+05,2.58E+05,0.00E+00,5.00E+05
6
- Bio-char,Forest biomass,Physical,Mt/MtCO2,6,0.34,0.472833,0.37,0.55525,0.201624,0.33,0.82
7
- Bio-char,Non-forest biomass,Physical,Mt/MtCO2,8,1.142,1.291,1.3395,1.4885,0.33202,0.818,1.667
8
- Bio-char,Unspecified biomass,Physical,Mt/MtCO2,48,0.943396,1.924255,1.209221,2.522177,1.937824,0.5,12.86
9
- Bio-char,Unspecified land,Physical,Mha/MtCO2,4,0.056402,0.163201,0.163201,0.27,0.123321,0.056402,0.27
10
- Bio-char,Unspecified water,Physical,bcm/MtCO2,9,0,0.000633,0.0001,0.0005,0.001377,-0.0005,0.003
11
- BioCCS with anaerobic digestion/fermentation,Arable land,Physical,Mha/MtCO2,16,0.037937,0.100867,0.058844,0.103034,0.113758,0.022803,0.454545
12
- BioCCS with anaerobic digestion/fermentation,Non-forest biomass,Physical,Mt/MtCO2,6,1.428571,2.689964,1.561743,2.128274,2.62425,1.315,8
13
- BioCCS with anaerobic digestion/fermentation,Unspecified biomass,Physical,Mt/MtCO2,6,1.03357,1.379365,1.14,1.785,0.484913,0.998094,2
14
- BioCCS with anaerobic digestion/fermentation,Unspecified water,Physical,bcm/MtCO2,14,0.395455,0.569805,0.565909,0.654546,0.183277,0.368182,0.913636
15
- BioCCS with combustion,Arable land,Physical,Mha/MtCO2,32,0.038,0.20054,0.0584,0.094595,0.46566,0.022803,1.9451
16
- BioCCS with combustion,Electrical energy,Energy,MWh/MtCO2,18,27777.78,130975.3,101000,111111.1,162410.3,0,500000
17
- BioCCS with combustion,Forest biomass,Physical,Mt/MtCO2,8,0.560764,0.675481,0.59375,0.838675,0.144419,0.555556,0.8547
18
- BioCCS with combustion,Thermal energy unspecified grade,Energy,MWh/MtCO2,22,347222.2,1006242,930611.1,1025000,1052440,0,4000000
19
- BioCCS with combustion,Unspecified biomass,Physical,Mt/MtCO2,8,0.916667,1.264167,1.14,1.625,0.528225,0.666667,2
20
- BioCCS with combustion,Unspecified chemical,Physical,Mt/MtCO2,20,0.00027,4.044119,0.00316,0.016,8.287794,0.00013,20.2
21
- BioCCS with combustion,Unspecified water,Physical,bcm/MtCO2,32,0.006475,0.312937,0.201625,0.542046,0.343155,0.00063,1.296342
22
- "Coastal revegetation (mangrove, seaweeds, etc.)",Non-arable land,Physical,Mha/MtCO2,11,0.043295,0.132018,0.120566,0.197449,0.117064,0.0078,0.328289
23
- Cropland management,Arable land,Physical,Mha/MtCO2,14,0.27062,1.277946,0.420635,1.052335,2.329779,0.082645,9
24
- Durable biobased products,Electrical energy,Energy,MWh/MtCO2,2,4000000,4000000,4000000,4000000,0,4000000,4000000
25
- Durable biobased products,Non-arable land,Physical,Mha/MtCO2,2,6,6,6,6,0,6,6
26
- Durable biobased products,Unspecified biomass,Physical,Mt/MtCO2,6,1.267857,2.607143,3.571429,3.892857,1.842663,0,4
27
- Durable biobased products,Unspecified water,Physical,bcm/MtCO2,2,0.0002,0.0003,0.0003,0.0004,0.000283,0.0001,0.0005
28
- Electrochemical DACCS,Electrical energy,Energy,MWh/MtCO2,10,333333.3,2032081,784125,2750000,2378833,252556,6222222
29
- Electrochemical DACCS,Non-arable land,Physical,Mha/MtCO2,14,0.000272,0.002662,0.000324,0.000881,0.005657,0.000223,0.016
30
- Electrochemical DACCS,Unspecified water,Physical,bcm/MtCO2,6,0.002,0.011,0.005,0.0215,0.012522,0.001,0.027
31
- Electrochemical OAE,Electrical energy,Energy,MWh/MtCO2,8,1600000,2241250,2150000,2500000,1283594,830000,5000000
32
- Electrochemical OAE,Non-arable land,Physical,Mha/MtCO2,2,0.00086,0.00101,0.00101,0.00116,0.000424,0.00071,0.00131
33
- Electrochemical OAE,Shoreline,Physical,km/MtCO2,2,1.20E-02,1.20E-02,1.20E-02,1.20E-02,0.00E+00,1.20E-02,1.20E-02
34
- Electrochemical OAE,Unspecified mineral,Physical,Mt/MtCO2,4,2.4,2.95,2.95,3.5,0.635085,2.4,3.5
35
- Enhanced weathering,Arable land,Physical,Mha/MtCO2,28,0.12875,0.548974,0.25,0.5,0.709916,1.00E-06,2.5
36
- Enhanced weathering,Basalt mineral,Physical,Mt/MtCO2,25,3.33,5.773042,4.3,5.3,4.967901,1,19
37
- Enhanced weathering,Electrical energy,Energy,MWh/MtCO2,8,92850,593431.3,388750,500000,904565.7,9250,2775000
38
- Enhanced weathering,Non-arable land,Physical,Mha/MtCO2,12,0.004429,11.43247,0.009065,28.57143,17.2264,0.0009,40
39
- Enhanced weathering,Olivine mineral,Physical,Mt/MtCO2,26,0.909091,1.429511,1.25,1.58,0.941247,0.27027,3.7
40
- Enhanced weathering,Unspecified mineral,Physical,Mt/MtCO2,19,1.315789,6.356736,2.5,8.625,7.726415,0.5,25
41
- Enhanced weathering,Unspecified water,Physical,bcm/MtCO2,5,0.0005,0.010466,0.000866,0.000866,0.022102,0.0001,0.05
42
- Enhanced weathering,Wollastonite mineral,Physical,Mt/MtCO2,8,1.321429,1.629643,1.564286,1.83,0.742366,0.56,3
43
- Forest management,Unspecified land,Physical,Mha/MtCO2,2,0.438675,0.76865,0.76865,1.098625,0.93331,0.1087,1.4286
44
- High-temp DACCS (solid-sorbent/liquid-solvent),Electrical energy,Energy,MWh/MtCO2,32,361111.1,423787.8,462961.1,500000,163178.3,77000,770000
45
- High-temp DACCS (solid-sorbent/liquid-solvent),Non-arable land,Physical,Mha/MtCO2,23,2.20E-08,0.000229,0.000223,0.000298,0.000299,1.50E-10,0.000991
46
- High-temp DACCS (solid-sorbent/liquid-solvent),Thermal energy high grade,Energy,MWh/MtCO2,38,1472222,1954835,1750000,2314815,643937.6,1125000,3800000
47
- High-temp DACCS (solid-sorbent/liquid-solvent),Unspecified water,Physical,bcm/MtCO2,16,0.002502,0.010221,0.0047,0.00818,0.015887,5E-09,0.05
48
- Low-temp DACCS (solid-sorbent/liquid-solvent),Electrical energy,Energy,MWh/MtCO2,44,218000,895320.2,462961.1,690000,1255695,130000,4611111
49
- Low-temp DACCS (solid-sorbent/liquid-solvent),Non-arable land,Physical,Mha/MtCO2,23,6.25E-08,0.000238,0.000223,0.000298,0.000294,1.00E-11,0.000991
50
- Low-temp DACCS (solid-sorbent/liquid-solvent),Thermal energy low grade,Energy,MWh/MtCO2,30,1432500,1678215,1500000,2062500,516794.3,638888.9,2722222
51
- Low-temp DACCS (solid-sorbent/liquid-solvent),Unspecified water,Physical,bcm/MtCO2,14,7.5E-10,0.003187,0.001008,0.004925,0.005238,-0.002,0.015
52
- Mineral OAE / Ocean liming,Electrical energy,Energy,MWh/MtCO2,12,323625,1153583,606500,1755000,1286024,27000,4000000
53
- Mineral OAE / Ocean liming,Non-arable land,Physical,Mha/MtCO2,4,0,0.000505,0.000355,0.00086,0.000632,0,0.00131
54
- Mineral OAE / Ocean liming,Thermal energy unspecified grade,Energy,MWh/MtCO2,6,1388889,1257630,1458944,1529000,542588.8,160000,1550000
55
- Mineral OAE / Ocean liming,Unspecified mineral,Physical,Mt/MtCO2,8,1.62,2.489,1.743,2.5895,1.554598,1.4,5
56
- Mineral OAE / Ocean liming,Unspecified water,Physical,bcm/MtCO2,2,0.000678,0.000678,0.000678,0.000678,0,0.000678,0.000678
57
- Mineral looping DACCS,Electrical energy,Energy,MWh/MtCO2,8,83333.33,1104167,83333.33,1375000,1793811,0,4000000
58
- Mineral looping DACCS,Non-arable land,Physical,Mha/MtCO2,12,0.000587,0.001065,0.000639,0.000873,0.001064,0.000223,0.0033
59
- Mineral looping DACCS,Thermal energy unspecified grade,Energy,MWh/MtCO2,4,1479167,1715278,1819444,2055556,534015.3,1000000,2222222
60
- Mineral looping DACCS,Unspecified mineral,Physical,Mt/MtCO2,4,0.15,0.225,0.2,0.275,0.206155,0,0.5
61
- Mineral looping DACCS,Unspecified water,Physical,bcm/MtCO2,6,0.000125,0.006762,0.0002,0.010068,0.011116,0,0.026714
62
- Mineralization ex-situ,Electrical energy,Energy,MWh/MtCO2,8,500000,1062500,750000,1250000,979704.8,0,3000000
63
- Mineralization ex-situ,Non-arable land,Physical,Mha/MtCO2,4,0.000298,0.000522,0.000436,0.000659,0.000342,0.000223,0.000991
64
- Mineralization ex-situ,Olivine mineral,Physical,Mt/MtCO2,2,0.8,0.8,0.8,0.8,0,0.8,0.8
65
- Mineralization ex-situ,Thermal energy unspecified grade,Energy,MWh/MtCO2,4,400000,575000,450000,625000,287228.1,400000,1000000
66
- Mineralization ex-situ,Unspecified mineral,Physical,Mt/MtCO2,22,0.4375,3.935,3,7,3.647722,0.04,10
67
- Mineralization ex-situ,Unspecified water,Physical,bcm/MtCO2,14,0.0002,0.050004,0.00545,0.0145,0.106908,0,0.3
68
- Moisture swing DACCS,Electrical energy,Energy,MWh/MtCO2,4,316000,324666.7,324666.7,333333.3,10007.4,316000,333333.3
69
- Moisture swing DACCS,Non-arable land,Physical,Mha/MtCO2,8,0.000169,0.000331,0.000272,0.00038,0.000319,8.00E-06,0.000991
70
- Moisture swing DACCS,Unspecified water,Physical,bcm/MtCO2,4,-0.00015,0.000427,0.000427,0.001,0.000662,-0.00015,0.001
71
- Pasture management,Arable land,Physical,Mha/MtCO2,24,0.102253,0.442368,0.252422,0.522974,0.522145,0.071894,2.289744
72
- Reforestation,Unspecified land,Physical,Mha/MtCO2,34,0.044459,1.564102,0.094927,0.1887,6.825803,0.002,38.8
73
- Reforestation,Unspecified water,Physical,bcm/MtCO2,2,0.092,0.092,0.092,0.092,0,0.092,0.092
74
- pH swing,Electrical energy,Energy,MWh/MtCO2,28,980000,2205112,1768000,3302500,1381909,660000,4670000
75
- pH swing,Salt water,Physical,bcm/MtCO2,4,0.435,4.78,4.78,9.125,5.017174,0.435,9.125
76
- pH swing,Unspecified water,Physical,bcm/MtCO2,4,0.217391,6.608696,6.608696,13,7.380043,0.217391,13
77
- BioCCS with combustion,Geologic storage,Physical,Mt/MtCO2,1,1,1,1,1,0,1,1
78
- BioCCS with anaerobic digestion/fermentation,Geologic storage,Physical,Mt/MtCO2,1,1,1,1,1,0,1,1
79
- Electrochemical DACCS,Geologic storage,Physical,Mt/MtCO2,1,1,1,1,1,0,1,1
80
- Low-temp DACCS (solid-sorbent/liquid-solvent),Geologic storage,Physical,Mt/MtCO2,1,1,1,1,1,0,1,1
81
- High-temp DACCS (solid-sorbent/liquid-solvent),Geologic storage,Physical,Mt/MtCO2,1,1,1,1,1,0,1,1
82
- Mineral looping DACCS,Geologic storage,Physical,Mt/MtCO2,1,1,1,1,1,0,1,1
83
- Moisture swing DACCS,Geologic storage,Physical,Mt/MtCO2,1,1,1,1,1,0,1,1
84
- pH swing,Geologic storage,Physical,Mt/MtCO2,1,1,1,1,1,0,1,1
 
1
+ Implementation,Method group,Resource,Resource group,Type,Unit,count,quantile_25,mean,median,quantile_75,std,min,max,Is secondary
2
+ Afforestation,Forestry,Unspecified land,Land,Physical,Mha/MtCO2,36,0.024593,0.115652,0.094927,0.197075,0.112368,0.002,0.384615,False
3
+ Afforestation,Forestry,Unspecified water,Water,Physical,bcm/MtCO2,2,0.092,0.092,0.092,0.092,0.0,0.092,0.092,True
4
+ Agroforestry,Forestry,Arable land,Land,Physical,Mha/MtCO2,12,0.039953,0.450767,0.064101,0.2141,0.90114,0.0025,3.030303,False
5
+ Bio-char,Bio-char,Electrical energy,Energy,Energy,MWh/MtCO2,6,87.5,167000.0,350.0,375000.0,258000.0,0.0,500000.0,True
6
+ Bio-char,Bio-char,Forest biomass,Biomass,Physical,Mt/MtCO2,6,0.34,0.472833,0.37,0.55525,0.201624,0.33,0.82,False
7
+ Bio-char,Bio-char,Non-forest biomass,Biomass,Physical,Mt/MtCO2,8,1.142,1.291,1.3395,1.4885,0.33202,0.818,1.667,False
8
+ Bio-char,Bio-char,Unspecified biomass,Biomass,Physical,Mt/MtCO2,48,0.943396,1.924255,1.209221,2.522177,1.937824,0.5,12.86,False
9
+ Bio-char,Bio-char,Unspecified land,Land,Physical,Mha/MtCO2,4,0.056402,0.163201,0.163201,0.27,0.123321,0.056402,0.27,True
10
+ Bio-char,Bio-char,Unspecified water,Water,Physical,bcm/MtCO2,9,0.0,0.000633,0.0001,0.0005,0.001377,-0.0005,0.003,True
11
+ BioCCS with anaerobic digestion/fermentation,Bio-CCS,Arable land,Land,Physical,Mha/MtCO2,16,0.037937,0.100867,0.058844,0.103034,0.113758,0.022803,0.454545,False
12
+ BioCCS with anaerobic digestion/fermentation,Bio-CCS,Non-forest biomass,Biomass,Physical,Mt/MtCO2,6,1.428571,2.689964,1.561743,2.128274,2.62425,1.315,8.0,False
13
+ BioCCS with anaerobic digestion/fermentation,Bio-CCS,Unspecified biomass,Biomass,Physical,Mt/MtCO2,6,1.03357,1.379365,1.14,1.785,0.484913,0.998094,2.0,False
14
+ BioCCS with anaerobic digestion/fermentation,Bio-CCS,Unspecified water,Water,Physical,bcm/MtCO2,14,0.395455,0.569805,0.565909,0.654546,0.183277,0.368182,0.913636,True
15
+ BioCCS with combustion,Bio-CCS,Arable land,Land,Physical,Mha/MtCO2,32,0.038,0.20054,0.0584,0.094595,0.46566,0.022803,1.9451,False
16
+ BioCCS with combustion,Bio-CCS,Electrical energy,Energy,Energy,MWh/MtCO2,18,27777.78,130975.3,101000.0,111111.1,162410.3,0.0,500000.0,True
17
+ BioCCS with combustion,Bio-CCS,Forest biomass,Biomass,Physical,Mt/MtCO2,8,0.560764,0.675481,0.59375,0.838675,0.144419,0.555556,0.8547,False
18
+ BioCCS with combustion,Bio-CCS,Thermal energy unspecified grade,Energy,Energy,MWh/MtCO2,22,347222.2,1006242.0,930611.1,1025000.0,1052440.0,0.0,4000000.0,True
19
+ BioCCS with combustion,Bio-CCS,Unspecified biomass,Biomass,Physical,Mt/MtCO2,8,0.916667,1.264167,1.14,1.625,0.528225,0.666667,2.0,False
20
+ BioCCS with combustion,Bio-CCS,Unspecified chemical,Chemicals,Physical,Mt/MtCO2,20,0.00027,4.044119,0.00316,0.016,8.287794,0.00013,20.2,True
21
+ BioCCS with combustion,Bio-CCS,Unspecified water,Water,Physical,bcm/MtCO2,32,0.006475,0.312937,0.201625,0.542046,0.343155,0.00063,1.296342,True
22
+ "Coastal revegetation (mangrove, seaweeds, etc.)",Blue carbon,Non-arable land,Land,Physical,Mha/MtCO2,11,0.043295,0.132018,0.120566,0.197449,0.117064,0.0078,0.328289,False
23
+ Cropland management,"Soil carbon sequestration (crop, grasslands)",Arable land,Land,Physical,Mha/MtCO2,14,0.27062,1.277946,0.420635,1.052335,2.329779,0.082645,9.0,False
24
+ Durable biobased products,Durable biobased products,Electrical energy,Energy,Energy,MWh/MtCO2,2,4000000.0,4000000.0,4000000.0,4000000.0,0.0,4000000.0,4000000.0,True
25
+ Durable biobased products,Durable biobased products,Non-arable land,Land,Physical,Mha/MtCO2,2,6.0,6.0,6.0,6.0,0.0,6.0,6.0,True
26
+ Durable biobased products,Durable biobased products,Unspecified biomass,Biomass,Physical,Mt/MtCO2,6,1.267857,2.607143,3.571429,3.892857,1.842663,0.0,4.0,False
27
+ Durable biobased products,Durable biobased products,Unspecified water,Water,Physical,bcm/MtCO2,2,0.0002,0.0003,0.0003,0.0004,0.000283,0.0001,0.0005,True
28
+ Electrochemical DACCS,Direct air carbon capture and storage (DACCS),Electrical energy,Energy,Energy,MWh/MtCO2,10,333333.3,2032081.0,784125.0,2750000.0,2378833.0,252556.0,6222222.0,False
29
+ Electrochemical DACCS,Direct air carbon capture and storage (DACCS),Non-arable land,Land,Physical,Mha/MtCO2,14,0.000272,0.002662,0.000324,0.000881,0.005657,0.000223,0.016,True
30
+ Electrochemical DACCS,Direct air carbon capture and storage (DACCS),Unspecified water,Water,Physical,bcm/MtCO2,6,0.002,0.011,0.005,0.0215,0.012522,0.001,0.027,False
31
+ Electrochemical OAE,Ocean alkalinity enhancement,Electrical energy,Energy,Energy,MWh/MtCO2,8,1600000.0,2241250.0,2150000.0,2500000.0,1283594.0,830000.0,5000000.0,False
32
+ Electrochemical OAE,Ocean alkalinity enhancement,Non-arable land,Land,Physical,Mha/MtCO2,2,0.00086,0.00101,0.00101,0.00116,0.000424,0.00071,0.00131,True
33
+ Electrochemical OAE,Ocean alkalinity enhancement,Shoreline,Shoreline,Physical,km/MtCO2,2,0.012,0.012,0.012,0.012,0.0,0.012,0.012,False
34
+ Electrochemical OAE,Ocean alkalinity enhancement,Unspecified mineral,Minerals,Physical,Mt/MtCO2,4,2.4,2.95,2.95,3.5,0.635085,2.4,3.5,False
35
+ Enhanced weathering,Enhanced weathering,Arable land,Land,Physical,Mha/MtCO2,28,0.12875,0.548974,0.25,0.5,0.709916,1e-06,2.5,True
36
+ Enhanced weathering,Enhanced weathering,Basalt mineral,Minerals,Physical,Mt/MtCO2,25,3.33,5.773042,4.3,5.3,4.967901,1.0,19.0,False
37
+ Enhanced weathering,Enhanced weathering,Electrical energy,Energy,Energy,MWh/MtCO2,8,92850.0,593431.3,388750.0,500000.0,904565.7,9250.0,2775000.0,True
38
+ Enhanced weathering,Enhanced weathering,Non-arable land,Land,Physical,Mha/MtCO2,12,0.004429,11.43247,0.009065,28.57143,17.2264,0.0009,40.0,True
39
+ Enhanced weathering,Enhanced weathering,Olivine mineral,Minerals,Physical,Mt/MtCO2,26,0.909091,1.429511,1.25,1.58,0.941247,0.27027,3.7,False
40
+ Enhanced weathering,Enhanced weathering,Unspecified mineral,Minerals,Physical,Mt/MtCO2,19,1.315789,6.356736,2.5,8.625,7.726415,0.5,25.0,False
41
+ Enhanced weathering,Enhanced weathering,Unspecified water,Water,Physical,bcm/MtCO2,5,0.0005,0.010466,0.000866,0.000866,0.022102,0.0001,0.05,True
42
+ Enhanced weathering,Enhanced weathering,Wollastonite mineral,Minerals,Physical,Mt/MtCO2,8,1.321429,1.629643,1.564286,1.83,0.742366,0.56,3.0,False
43
+ Forest management,Forestry,Unspecified land,Land,Physical,Mha/MtCO2,2,0.438675,0.76865,0.76865,1.098625,0.93331,0.1087,1.4286,False
44
+ High-temp DACCS (solid-sorbent/liquid-solvent),Direct air carbon capture and storage (DACCS),Electrical energy,Energy,Energy,MWh/MtCO2,32,361111.1,423787.8,462961.1,500000.0,163178.3,77000.0,770000.0,False
45
+ High-temp DACCS (solid-sorbent/liquid-solvent),Direct air carbon capture and storage (DACCS),Non-arable land,Land,Physical,Mha/MtCO2,23,2.2e-08,0.000229,0.000223,0.000298,0.000299,1.5e-10,0.000991,False
46
+ High-temp DACCS (solid-sorbent/liquid-solvent),Direct air carbon capture and storage (DACCS),Thermal energy high grade,Energy,Energy,MWh/MtCO2,38,1472222.0,1954835.0,1750000.0,2314815.0,643937.6,1125000.0,3800000.0,False
47
+ High-temp DACCS (solid-sorbent/liquid-solvent),Direct air carbon capture and storage (DACCS),Unspecified water,Water,Physical,bcm/MtCO2,16,0.002502,0.010221,0.0047,0.00818,0.015887,5e-09,0.05,True
48
+ Low-temp DACCS (solid-sorbent/liquid-solvent),Direct air carbon capture and storage (DACCS),Electrical energy,Energy,Energy,MWh/MtCO2,44,218000.0,895320.2,462961.1,690000.0,1255695.0,130000.0,4611111.0,False
49
+ Low-temp DACCS (solid-sorbent/liquid-solvent),Direct air carbon capture and storage (DACCS),Non-arable land,Land,Physical,Mha/MtCO2,23,6.25e-08,0.000238,0.000223,0.000298,0.000294,1e-11,0.000991,False
50
+ Low-temp DACCS (solid-sorbent/liquid-solvent),Direct air carbon capture and storage (DACCS),Thermal energy low grade,Energy,Energy,MWh/MtCO2,30,1432500.0,1678215.0,1500000.0,2062500.0,516794.3,638888.9,2722222.0,False
51
+ Low-temp DACCS (solid-sorbent/liquid-solvent),Direct air carbon capture and storage (DACCS),Unspecified water,Water,Physical,bcm/MtCO2,14,7.5e-10,0.003187,0.001008,0.004925,0.005238,-0.002,0.015,True
52
+ Mineral OAE / Ocean liming,Ocean alkalinity enhancement,Electrical energy,Energy,Energy,MWh/MtCO2,12,323625.0,1153583.0,606500.0,1755000.0,1286024.0,27000.0,4000000.0,True
53
+ Mineral OAE / Ocean liming,Ocean alkalinity enhancement,Non-arable land,Land,Physical,Mha/MtCO2,4,0.0,0.000505,0.000355,0.00086,0.000632,0.0,0.00131,True
54
+ Mineral OAE / Ocean liming,Ocean alkalinity enhancement,Thermal energy unspecified grade,Energy,Energy,MWh/MtCO2,6,1388889.0,1257630.0,1458944.0,1529000.0,542588.8,160000.0,1550000.0,True
55
+ Mineral OAE / Ocean liming,Ocean alkalinity enhancement,Unspecified mineral,Minerals,Physical,Mt/MtCO2,8,1.62,2.489,1.743,2.5895,1.554598,1.4,5.0,False
56
+ Mineral OAE / Ocean liming,Ocean alkalinity enhancement,Unspecified water,Water,Physical,bcm/MtCO2,2,0.000678,0.000678,0.000678,0.000678,0.0,0.000678,0.000678,True
57
+ Mineral looping DACCS,Direct air carbon capture and storage (DACCS),Electrical energy,Energy,Energy,MWh/MtCO2,8,83333.33,1104167.0,83333.33,1375000.0,1793811.0,0.0,4000000.0,False
58
+ Mineral looping DACCS,Direct air carbon capture and storage (DACCS),Non-arable land,Land,Physical,Mha/MtCO2,12,0.000587,0.001065,0.000639,0.000873,0.001064,0.000223,0.0033,False
59
+ Mineral looping DACCS,Direct air carbon capture and storage (DACCS),Thermal energy unspecified grade,Energy,Energy,MWh/MtCO2,4,1479167.0,1715278.0,1819444.0,2055556.0,534015.3,1000000.0,2222222.0,False
60
+ Mineral looping DACCS,Direct air carbon capture and storage (DACCS),Unspecified mineral,Minerals,Physical,Mt/MtCO2,4,0.15,0.225,0.2,0.275,0.206155,0.0,0.5,False
61
+ Mineral looping DACCS,Direct air carbon capture and storage (DACCS),Unspecified water,Water,Physical,bcm/MtCO2,6,0.000125,0.006762,0.0002,0.010068,0.011116,0.0,0.026714,True
62
+ Mineralization ex-situ,Mineralization,Electrical energy,Energy,Energy,MWh/MtCO2,8,500000.0,1062500.0,750000.0,1250000.0,979704.8,0.0,3000000.0,False
63
+ Mineralization ex-situ,Mineralization,Non-arable land,Land,Physical,Mha/MtCO2,4,0.000298,0.000522,0.000436,0.000659,0.000342,0.000223,0.000991,False
64
+ Mineralization ex-situ,Mineralization,Olivine mineral,Minerals,Physical,Mt/MtCO2,2,0.8,0.8,0.8,0.8,0.0,0.8,0.8,False
65
+ Mineralization ex-situ,Mineralization,Thermal energy unspecified grade,Energy,Energy,MWh/MtCO2,4,400000.0,575000.0,450000.0,625000.0,287228.1,400000.0,1000000.0,False
66
+ Mineralization ex-situ,Mineralization,Unspecified mineral,Minerals,Physical,Mt/MtCO2,22,0.4375,3.935,3.0,7.0,3.647722,0.04,10.0,False
67
+ Mineralization ex-situ,Mineralization,Unspecified water,Water,Physical,bcm/MtCO2,14,0.0002,0.050004,0.00545,0.0145,0.106908,0.0,0.3,False
68
+ Moisture swing DACCS,Direct air carbon capture and storage (DACCS),Electrical energy,Energy,Energy,MWh/MtCO2,4,316000.0,324666.7,324666.7,333333.3,10007.4,316000.0,333333.3,False
69
+ Moisture swing DACCS,Direct air carbon capture and storage (DACCS),Non-arable land,Land,Physical,Mha/MtCO2,8,0.000169,0.000331,0.000272,0.00038,0.000319,8e-06,0.000991,False
70
+ Moisture swing DACCS,Direct air carbon capture and storage (DACCS),Unspecified water,Water,Physical,bcm/MtCO2,4,-0.00015,0.000427,0.000427,0.001,0.000662,-0.00015,0.001,False
71
+ Pasture management,"Soil carbon sequestration (crop, grasslands)",Arable land,Land,Physical,Mha/MtCO2,24,0.102253,0.442368,0.252422,0.522974,0.522145,0.071894,2.289744,False
72
+ Reforestation,Forestry,Unspecified land,Land,Physical,Mha/MtCO2,34,0.044459,1.564102,0.094927,0.1887,6.825803,0.002,38.8,False
73
+ Reforestation,Forestry,Unspecified water,Water,Physical,bcm/MtCO2,2,0.092,0.092,0.092,0.092,0.0,0.092,0.092,True
74
+ pH swing,Direct ocean carbon capture and storage (DOCCS),Electrical energy,Energy,Energy,MWh/MtCO2,28,980000.0,2205112.0,1768000.0,3302500.0,1381909.0,660000.0,4670000.0,False
75
+ pH swing,Direct ocean carbon capture and storage (DOCCS),Salt water,Water,Physical,bcm/MtCO2,4,0.435,4.78,4.78,9.125,5.017174,0.435,9.125,False
76
+ pH swing,Direct ocean carbon capture and storage (DOCCS),Unspecified water,Water,Physical,bcm/MtCO2,4,0.217391,6.608696,6.608696,13.0,7.380043,0.217391,13.0,False
77
+ BioCCS with combustion,Bio-CCS,Geologic storage,Storage,Physical,Mt/MtCO2,1,1.0,1.0,1.0,1.0,0.0,1.0,1.0,False
78
+ BioCCS with anaerobic digestion/fermentation,Bio-CCS,Geologic storage,Storage,Physical,Mt/MtCO2,1,1.0,1.0,1.0,1.0,0.0,1.0,1.0,False
79
+ Electrochemical DACCS,Direct air carbon capture and storage (DACCS),Geologic storage,Storage,Physical,Mt/MtCO2,1,1.0,1.0,1.0,1.0,0.0,1.0,1.0,False
80
+ Low-temp DACCS (solid-sorbent/liquid-solvent),Direct air carbon capture and storage (DACCS),Geologic storage,Storage,Physical,Mt/MtCO2,1,1.0,1.0,1.0,1.0,0.0,1.0,1.0,False
81
+ High-temp DACCS (solid-sorbent/liquid-solvent),Direct air carbon capture and storage (DACCS),Geologic storage,Storage,Physical,Mt/MtCO2,1,1.0,1.0,1.0,1.0,0.0,1.0,1.0,False
82
+ Mineral looping DACCS,Direct air carbon capture and storage (DACCS),Geologic storage,Storage,Physical,Mt/MtCO2,1,1.0,1.0,1.0,1.0,0.0,1.0,1.0,False
83
+ Moisture swing DACCS,Direct air carbon capture and storage (DACCS),Geologic storage,Storage,Physical,Mt/MtCO2,1,1.0,1.0,1.0,1.0,0.0,1.0,1.0,False
84
+ pH swing,Direct ocean carbon capture and storage (DOCCS),Geologic storage,Storage,Physical,Mt/MtCO2,1,1.0,1.0,1.0,1.0,0.0,1.0,1.0,False
data/inputs_template.xlsx CHANGED
Binary files a/data/inputs_template.xlsx and b/data/inputs_template.xlsx differ
 
data/method_groups.json ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Forestry": [
3
+ "Afforestation",
4
+ "Agroforestry",
5
+ "Forest management",
6
+ "Reforestation"
7
+ ],
8
+ "Bio-char": [
9
+ "Bio-char - Forest biomass",
10
+ "Bio-char - Non-forest biomass",
11
+ "Bio-char - Unspecified biomass"
12
+ ],
13
+ "Bio-CCS": [
14
+ "BioCCS with anaerobic digestion/fermentation",
15
+ "BioCCS with anaerobic digestion/fermentation & growing biomass",
16
+ "BioCCS with combustion",
17
+ "BioCCS with combustion & growing biomass"
18
+ ],
19
+ "Blue carbon": [
20
+ "Coastal revegetation (mangrove, seaweeds, etc.)"
21
+ ],
22
+ "Soil carbon sequestration (crop, grasslands)": [
23
+ "Cropland management",
24
+ "Pasture management"
25
+ ],
26
+ "Durable biobased products": [
27
+ "Durable biobased products"
28
+ ],
29
+ "Direct air carbon capture and storage (DACCS)": [
30
+ "Electrochemical DACCS",
31
+ "High-temp DACCS (solid-sorbent/liquid-solvent)",
32
+ "Low-temp DACCS (solid-sorbent/liquid-solvent)",
33
+ "Mineral looping DACCS",
34
+ "Moisture swing DACCS"
35
+ ],
36
+ "Ocean alkalinity enhancement": [
37
+ "Electrochemical OAE",
38
+ "Mineral OAE / Ocean liming"
39
+ ],
40
+ "Enhanced weathering": [
41
+ "Enhanced weathering - Basalt mineral",
42
+ "Enhanced weathering - Olivine mineral",
43
+ "Enhanced weathering - Wollastonite mineral",
44
+ "Enhanced weathering - Unspecified mineral"
45
+ ],
46
+ "Mineralization": [
47
+ "Mineralization ex-situ"
48
+ ],
49
+ "Direct ocean carbon capture and storage (DOCCS)": [
50
+ "pH swing"
51
+ ]
52
+ }
data/resource_template.xlsx CHANGED
Binary files a/data/resource_template.xlsx and b/data/resource_template.xlsx differ
 
data/secondary_resources.csv DELETED
@@ -1,50 +0,0 @@
1
- method,resource
2
- Afforestation,Unspecified water
3
- Reforestation,Unspecified water
4
- Durable biobased products,Non-arable land
5
- Durable biobased products,Electrical energy
6
- Durable biobased products,Unspecified water
7
- Bio-char - Forest biomass,Unspecified land
8
- Bio-char - Forest biomass,Electrical energy
9
- Bio-char - Forest biomass,Unspecified water
10
- Bio-char - Non-forest biomass,Unspecified land
11
- Bio-char - Non-forest biomass,Electrical energy
12
- Bio-char - Non-forest biomass,Unspecified water
13
- Bio-char - Unspecified biomass,Unspecified land
14
- Bio-char - Unspecified biomass,Electrical energy
15
- Bio-char - Unspecified biomass,Unspecified water
16
- BioCCS with combustion,Unspecified chemical
17
- BioCCS with combustion,Electrical energy
18
- BioCCS with combustion,Thermal energy
19
- BioCCS with combustion,Unspecified water
20
- BioCCS with combustion & growing biomass,Unspecified chemical
21
- BioCCS with combustion & growing biomass,Electrical energy
22
- BioCCS with combustion & growing biomass,Thermal energy
23
- BioCCS with combustion & growing biomass,Unspecified water
24
- BioCCS with anaerobic digestion/fermentation,Unspecified water
25
- BioCCS with anaerobic digestion/fermentation & growing biomass,Unspecified water
26
- Enhanced weathering - Basalt mineral,Arable land
27
- Enhanced weathering - Basalt mineral,Non-arable land
28
- Enhanced weathering - Basalt mineral,Electrical energy
29
- Enhanced weathering - Basalt mineral,Unspecified water
30
- Enhanced weathering - Olivine mineral,Arable land
31
- Enhanced weathering - Olivine mineral,Non-arable land
32
- Enhanced weathering - Olivine mineral,Electrical energy
33
- Enhanced weathering - Olivine mineral,Unspecified water
34
- Enhanced weathering - Wollastonite mineral,Arable land
35
- Enhanced weathering - Wollastonite mineral,Non-arable land
36
- Enhanced weathering - Wollastonite mineral,Electrical energy
37
- Enhanced weathering - Wollastonite mineral,Unspecified water
38
- Enhanced weathering - Unspecified mineral,Arable land
39
- Enhanced weathering - Unspecified mineral,Non-arable land
40
- Enhanced weathering - Unspecified mineral,Electrical energy
41
- Enhanced weathering - Unspecified mineral,Unspecified water
42
- Mineral OAE / Ocean liming,Non-arable land
43
- Mineral OAE / Ocean liming,Electrical energy
44
- Mineral OAE / Ocean liming,Thermal energy
45
- Mineral OAE / Ocean liming,Unspecified water
46
- Electrochemical OAE,Non-arable land
47
- Electrochemical DACCS,Non-arable land
48
- Low-temp DACCS (solid-sorbent/liquid-solvent),Unspecified water
49
- High-temp DACCS (solid-sorbent/liquid-solvent),Unspecified water
50
- Mineral looping DACCS,Unspecified water
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
data/secondary_resources.json ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ [
3
+ "Afforestation",
4
+ "Unspecified water"
5
+ ],
6
+ [
7
+ "Bio-char",
8
+ "Electrical energy"
9
+ ],
10
+ [
11
+ "Bio-char",
12
+ "Unspecified land"
13
+ ],
14
+ [
15
+ "Bio-char",
16
+ "Unspecified water"
17
+ ],
18
+ [
19
+ "Bio-char - Forest biomass",
20
+ "Electrical energy"
21
+ ],
22
+ [
23
+ "Bio-char - Forest biomass",
24
+ "Unspecified land"
25
+ ],
26
+ [
27
+ "Bio-char - Forest biomass",
28
+ "Unspecified water"
29
+ ],
30
+ [
31
+ "Bio-char - Non-forest biomass",
32
+ "Electrical energy"
33
+ ],
34
+ [
35
+ "Bio-char - Non-forest biomass",
36
+ "Unspecified land"
37
+ ],
38
+ [
39
+ "Bio-char - Non-forest biomass",
40
+ "Unspecified water"
41
+ ],
42
+ [
43
+ "Bio-char - Unspecified biomass",
44
+ "Electrical energy"
45
+ ],
46
+ [
47
+ "Bio-char - Unspecified biomass",
48
+ "Unspecified land"
49
+ ],
50
+ [
51
+ "Bio-char - Unspecified biomass",
52
+ "Unspecified water"
53
+ ],
54
+ [
55
+ "BioCCS with anaerobic digestion/fermentation",
56
+ "Unspecified water"
57
+ ],
58
+ [
59
+ "BioCCS with anaerobic digestion/fermentation & growing biomass",
60
+ "Unspecified water"
61
+ ],
62
+ [
63
+ "BioCCS with combustion",
64
+ "Electrical energy"
65
+ ],
66
+ [
67
+ "BioCCS with combustion",
68
+ "Thermal energy unspecified grade"
69
+ ],
70
+ [
71
+ "BioCCS with combustion",
72
+ "Unspecified chemical"
73
+ ],
74
+ [
75
+ "BioCCS with combustion",
76
+ "Unspecified water"
77
+ ],
78
+ [
79
+ "BioCCS with combustion & growing biomass",
80
+ "Electrical energy"
81
+ ],
82
+ [
83
+ "BioCCS with combustion & growing biomass",
84
+ "Thermal energy unspecified grade"
85
+ ],
86
+ [
87
+ "BioCCS with combustion & growing biomass",
88
+ "Unspecified chemical"
89
+ ],
90
+ [
91
+ "BioCCS with combustion & growing biomass",
92
+ "Unspecified water"
93
+ ],
94
+ [
95
+ "Durable biobased products",
96
+ "Electrical energy"
97
+ ],
98
+ [
99
+ "Durable biobased products",
100
+ "Non-arable land"
101
+ ],
102
+ [
103
+ "Durable biobased products",
104
+ "Unspecified water"
105
+ ],
106
+ [
107
+ "Electrochemical DACCS",
108
+ "Non-arable land"
109
+ ],
110
+ [
111
+ "Electrochemical OAE",
112
+ "Non-arable land"
113
+ ],
114
+ [
115
+ "Enhanced weathering",
116
+ "Arable land"
117
+ ],
118
+ [
119
+ "Enhanced weathering",
120
+ "Electrical energy"
121
+ ],
122
+ [
123
+ "Enhanced weathering",
124
+ "Non-arable land"
125
+ ],
126
+ [
127
+ "Enhanced weathering",
128
+ "Unspecified water"
129
+ ],
130
+ [
131
+ "Enhanced weathering - Basalt mineral",
132
+ "Arable land"
133
+ ],
134
+ [
135
+ "Enhanced weathering - Basalt mineral",
136
+ "Electrical energy"
137
+ ],
138
+ [
139
+ "Enhanced weathering - Basalt mineral",
140
+ "Non-arable land"
141
+ ],
142
+ [
143
+ "Enhanced weathering - Basalt mineral",
144
+ "Unspecified water"
145
+ ],
146
+ [
147
+ "Enhanced weathering - Olivine mineral",
148
+ "Arable land"
149
+ ],
150
+ [
151
+ "Enhanced weathering - Olivine mineral",
152
+ "Electrical energy"
153
+ ],
154
+ [
155
+ "Enhanced weathering - Olivine mineral",
156
+ "Non-arable land"
157
+ ],
158
+ [
159
+ "Enhanced weathering - Olivine mineral",
160
+ "Unspecified water"
161
+ ],
162
+ [
163
+ "Enhanced weathering - Unspecified mineral",
164
+ "Arable land"
165
+ ],
166
+ [
167
+ "Enhanced weathering - Unspecified mineral",
168
+ "Electrical energy"
169
+ ],
170
+ [
171
+ "Enhanced weathering - Unspecified mineral",
172
+ "Non-arable land"
173
+ ],
174
+ [
175
+ "Enhanced weathering - Unspecified mineral",
176
+ "Unspecified water"
177
+ ],
178
+ [
179
+ "Enhanced weathering - Wollastonite mineral",
180
+ "Arable land"
181
+ ],
182
+ [
183
+ "Enhanced weathering - Wollastonite mineral",
184
+ "Electrical energy"
185
+ ],
186
+ [
187
+ "Enhanced weathering - Wollastonite mineral",
188
+ "Non-arable land"
189
+ ],
190
+ [
191
+ "Enhanced weathering - Wollastonite mineral",
192
+ "Unspecified water"
193
+ ],
194
+ [
195
+ "High-temp DACCS (solid-sorbent/liquid-solvent)",
196
+ "Unspecified water"
197
+ ],
198
+ [
199
+ "Low-temp DACCS (solid-sorbent/liquid-solvent)",
200
+ "Unspecified water"
201
+ ],
202
+ [
203
+ "Mineral OAE / Ocean liming",
204
+ "Electrical energy"
205
+ ],
206
+ [
207
+ "Mineral OAE / Ocean liming",
208
+ "Non-arable land"
209
+ ],
210
+ [
211
+ "Mineral OAE / Ocean liming",
212
+ "Thermal energy unspecified grade"
213
+ ],
214
+ [
215
+ "Mineral OAE / Ocean liming",
216
+ "Unspecified water"
217
+ ],
218
+ [
219
+ "Mineral looping DACCS",
220
+ "Unspecified water"
221
+ ],
222
+ [
223
+ "Reforestation",
224
+ "Unspecified water"
225
+ ]
226
+ ]
data/tooltips.xlsx ADDED
Binary file (8.68 kB). View file
 
data/update.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Run this script after any change to inputs_aggregated.csv to update all
3
+ generated data files and Excel templates.
4
+
5
+ Usage:
6
+ python data/update.py
7
+ """
8
+ import hashlib
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ ROOT = Path(__file__).parent.parent
14
+ PYTHON = sys.executable
15
+
16
+ scripts = [
17
+ ("Coefficients + method groups + secondary resources", "data/generate_model_data.py"),
18
+ ("Input template (inputs_template.xlsx)", "data/generate_input_template.py"),
19
+ ("Resource template (resource_template.xlsx)", "data/generate_resource_template.py"),
20
+ ]
21
+
22
+ print("=" * 60)
23
+ print("CRRA Optimization Tool — data update")
24
+ print("=" * 60)
25
+
26
+ for label, script in scripts:
27
+ print(f"\n▶ {label}")
28
+ result = subprocess.run(
29
+ [PYTHON, str(ROOT / script)],
30
+ cwd=str(ROOT),
31
+ capture_output=True,
32
+ text=True,
33
+ )
34
+ for line in result.stdout.splitlines():
35
+ print(f" {line}")
36
+ if result.returncode != 0:
37
+ print(f" ERROR:\n{result.stderr}")
38
+ sys.exit(result.returncode)
39
+
40
+ print("\n" + "=" * 60)
41
+ print("All files updated successfully.")
42
+ print("=" * 60)
43
+
44
+ # Update the hash file so the app knows the CSV is in sync
45
+ csv_path = ROOT / "data/inputs_aggregated.csv"
46
+ hash_path = ROOT / "data/.csv_hash"
47
+ hasher = hashlib.md5()
48
+ with open(csv_path, "rb") as f:
49
+ hasher.update(f.read())
50
+ with open(hash_path, "w") as f:
51
+ f.write(hasher.hexdigest())
data/update_cost_sliders_with_priority.py DELETED
@@ -1,32 +0,0 @@
1
- import json
2
-
3
- import pandas as pd
4
-
5
- # === Load JSON data ===
6
- with open("cost_sliders.json", "r") as f:
7
- cost_sliders = json.load(f)
8
-
9
- # === Load resource priority CSV ===
10
- df_priority = pd.read_csv("resource_priority.csv")
11
-
12
- # === Validate columns ===
13
- required_cols = {"method", "resource", "priority"}
14
- if not required_cols.issubset(df_priority.columns):
15
- raise ValueError(f"CSV must contain the columns: {required_cols}")
16
-
17
- # === Update the JSON structure ===
18
- for _, row in df_priority.iterrows():
19
- method = row["method"]
20
- resource = row["resource"]
21
- priority = row["priority"]
22
-
23
- if method in cost_sliders and resource in cost_sliders[method]:
24
- cost_sliders[method][resource]["priority"] = priority
25
- else:
26
- print(f"⚠️ Warning: ({method} / {resource}) not found in JSON.")
27
-
28
- # === Save the updated JSON ===
29
- with open("cost_sliders_with_priority.json", "w") as f:
30
- json.dump(cost_sliders, f, indent=2)
31
-
32
- print("✅ Updated JSON saved to cost_sliders_with_priority.json")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
optimization/optimization.py CHANGED
@@ -2,161 +2,225 @@ import numpy as np
2
  from scipy.optimize import linprog
3
 
4
 
5
- def run_optimization(
6
- resource_caps,
7
- method_constraints,
8
- method_costs,
9
- custom_resources=None,
10
- enabled_methods=None,
11
- ):
12
- """
13
- Maximize total CO₂ removal subject to resource caps and method constraints.
14
 
15
- LP variables:
16
- y_m = CO₂ removed by method m (indices 0..M-1)
17
- z_{m,n} = CO₂ drawn from custom batch n by method m (indices M..N-1)
18
- """
19
-
20
- resource_caps = dict(resource_caps)
21
- method_costs = {m: dict(v) for m, v in method_costs.items()}
22
- print("first enabled_methods ----------- ")
23
 
24
- # ── Apply manual allocation overrides ─────────────────────────────────────
 
 
 
25
  enabled_methods = enabled_methods or {}
26
- print(enabled_methods)
27
- for method, resources in method_costs.items():
28
  for resource in list(resources):
29
  if not enabled_methods.get(resource, {}).get(method, True):
30
- method_costs[method][resource] = 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
- # ── Custom batch metadata ─────────────────────────────────────────────────
33
- custom_batch_info = {}
 
 
 
 
 
34
  for batch in (custom_resources or []):
35
- custom_batch_info[batch["name"]] = {
36
  "group": batch["group"],
37
  "amount": float(batch["amount"]),
38
  "primary_methods": set(batch.get("methods", {}).keys()),
39
  }
40
- custom_names = set(custom_batch_info)
41
- ref_to_batches = {}
42
- for bname, info in custom_batch_info.items():
43
- ref_to_batches.setdefault(info["group"], []).append(bname)
44
- if bname not in resource_caps:
45
- resource_caps[bname] = info["amount"]
46
-
47
- # ── Inject primary batch coefficients (fallback: use batch median if coef=0)
48
- for batch in (custom_resources or []):
49
- bname = batch["name"]
50
- for method, coefs in batch["methods"].items():
51
- if method_costs.get(method, {}).get(bname, 0.0) == 0.0:
52
- method_costs.setdefault(method, {})[bname] = coefs["median"]
53
 
54
- # ── Debug custom resources ────────────────────────────────────────────────
55
- print(f"\n{'='*50}")
56
- print(f"custom_resources received: {len(custom_resources or [])} batch(es)")
57
- for batch in (custom_resources or []):
58
- r_key = batch["name"]
59
- print(f" → '{r_key}' | amount: {resource_caps.get(r_key)} | methods: {list(batch['methods'].keys())}")
60
- for method in batch["methods"]:
61
- coef = method_costs.get(method, {}).get(r_key)
62
- print(f" method_costs['{method}']['{r_key}'] = {coef}")
63
- print(method_costs)
64
- print(f"Total resources in matrix: {len(resource_caps)}")
65
- print(f"Total resources in matrix: {resource_caps}")
66
- print(f"Resources: {list(resource_caps.keys())}")
67
- print(f"{'='*50}\n")
68
-
69
- # ── Active methods ────────────────────────────────────────────────────────
70
- methods = [m for m in method_costs if method_constraints.get(m, {}).get("active", True)]
71
- M = len(methods)
72
- method_idx = {m: j for j, m in enumerate(methods)}
73
- if not M:
74
- return False, {"message": "No active methods."}
75
 
76
- # ── Split variables z_{m,n} ───────────────────────────────────────────────
77
- z_pairs = []
78
- for bname, info in custom_batch_info.items():
79
- ref = info["group"]
80
- primary_methods = info["primary_methods"]
81
- if resource_caps.get(bname, 0.0) <= 0:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  continue
83
- for m in methods:
84
  if m not in primary_methods:
85
  continue
86
- c_n = method_costs.get(m, {}).get(bname, 0.0)
87
- if c_n > 0:
88
- z_pairs.append((m, bname))
89
 
90
- Z = len(z_pairs)
91
- z_idx = {pair: M + i for i, pair in enumerate(z_pairs)}
92
- N = M + Z
93
 
94
- print(f"z_pairs ({Z}): {z_pairs}")
95
 
96
- # ── Resource categorisation ───────────────────────────────────────────────
97
- standard_resources = [r for r in resource_caps if r not in custom_names]
98
- resources_with_batches = [r for r in standard_resources if r in ref_to_batches]
99
- resources_no_batches = [r for r in standard_resources if r not in ref_to_batches]
100
-
101
- # ── Build LP constraint matrix ────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  A_rows, b_rows, constraint_labels = [], [], []
103
 
104
- # (A) Standard resources — no custom substitutes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  for r in resources_no_batches:
106
- row = np.zeros(N)
107
- for j, m in enumerate(methods):
108
  row[j] = method_costs.get(m, {}).get(r, 0.0)
109
- if np.any(row[:M] != 0):
110
  A_rows.append(row)
111
  b_rows.append(resource_caps[r])
112
  constraint_labels.append(("resource", r))
113
 
114
- # (B) Standard resources — with custom substitutes (subtract z contribution)
115
- # Σ_m cost(m,r) · (y_m − Σ_n z_{m,n}) ≤ cap(r)
 
116
  for r in resources_with_batches:
117
- row = np.zeros(N)
118
- for j, m in enumerate(methods):
119
- c_s_m = method_costs.get(m, {}).get(r, 0.0)
120
  row[j] = c_s_m
121
- for bname in ref_to_batches.get(r, []):
122
- if (m, bname) in z_idx:
123
- row[z_idx[(m, bname)]] -= c_s_m
124
  if np.any(row != 0):
125
  A_rows.append(row)
126
  b_rows.append(resource_caps[r])
127
  constraint_labels.append(("resource", r))
128
 
129
- # (C) Custom batch capacity: Σ_m cost(m,n) · z_{m,n} ≤ Q_n
130
- for bname, info in custom_batch_info.items():
131
- if resource_caps.get(bname, 0.0) <= 0:
 
132
  continue
133
- row = np.zeros(N)
134
- for j, m in enumerate(methods):
135
- if (m, bname) not in z_idx:
136
  continue
137
- c_n_m = method_costs.get(m, {}).get(bname, 0.0)
138
  if c_n_m > 0:
139
- row[z_idx[(m, bname)]] = c_n_m
140
  if np.any(row != 0):
141
  A_rows.append(row)
142
- b_rows.append(resource_caps.get(bname, 0.0))
143
- constraint_labels.append(("custom_batch", bname))
144
 
145
- # (D1) Σ z_{m,n} ≤ y_m — keeps standard resource usage non-negative
146
- # (D2) y_m ≤ Σ z_{m,n} — only when c_s=0: entire output must come from batch
147
- # D1 + D2 together force y_m = Σz when the standard resource is unavailable
 
148
  coupling_done = set()
149
- for (m, bname) in z_pairs:
150
- ref = custom_batch_info[bname]["group"]
151
  key = (m, ref)
152
  if key in coupling_done:
153
  continue
154
  coupling_done.add(key)
155
- j = method_idx[m]
156
  c_s_m = method_costs.get(m, {}).get(ref, 0.0)
157
- batch_cols = [z_idx[(m, bn)] for bn in ref_to_batches.get(ref, []) if (m, bn) in z_idx]
158
-
159
- row = np.zeros(N)
 
 
 
160
  row[j] = -1.0
161
  for col in batch_cols:
162
  row[col] = 1.0
@@ -165,7 +229,7 @@ def run_optimization(
165
  constraint_labels.append(("coupling_D1", (m, ref)))
166
 
167
  if c_s_m == 0:
168
- row2 = np.zeros(N)
169
  row2[j] = 1.0
170
  for col in batch_cols:
171
  row2[col] = -1.0
@@ -173,103 +237,245 @@ def run_optimization(
173
  b_rows.append(0.0)
174
  constraint_labels.append(("coupling_D2", (m, ref)))
175
 
176
- # (E) Method caps
177
- for j, m in enumerate(methods):
 
 
178
  constraint = method_constraints.get(m, {})
179
  cap_type = constraint.get("cap_type", "percent")
180
  cap_value = constraint.get("cap_value", 100)
181
  if cap_type == "absolute":
182
- row = np.zeros(N)
183
- row[j] = 1.0
184
  A_rows.append(row)
185
  b_rows.append(cap_value)
186
  constraint_labels.append(("method_cap", m))
187
  else:
188
- cap_ratio = cap_value / 100.0
189
- row = np.zeros(N)
190
- row[:M] -= cap_ratio
191
- row[j] += 1.0
192
  A_rows.append(row)
193
  b_rows.append(0.0)
194
  constraint_labels.append(("method_cap", m))
195
 
196
- A_ub = np.array(A_rows) if A_rows else np.empty((0, N))
197
  b_ub = np.array(b_rows)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
 
199
- # Objective: maximize Σ y_m → minimize −Σ y_m
200
- c_obj = np.zeros(N)
201
- c_obj[:M] = -1.0
202
- bounds = [(0, None)] * N
203
 
204
- # ── Solve ─────────────────────────────────────────────────────────────────
205
  try:
206
- result = linprog(c_obj, A_ub=A_ub, b_ub=b_ub, bounds=bounds, method="highs")
207
  if not result.success:
208
  return False, {"message": result.message}
209
 
210
- sol = result.x
211
- y = sol[:M]
212
-
213
- total_removed = float(y.sum())
214
- method_usage = {methods[j]: float(y[j]) for j in range(M) if y[j] > 1e-9}
215
-
216
- # ── Resource usage ────────────────────────────────────────────────────
217
- all_resource_names = standard_resources + list(custom_names)
218
- resource_usage = {r: {} for r in all_resource_names}
219
-
220
- for r in resources_no_batches:
221
- for j, m in enumerate(methods):
222
- used = method_costs.get(m, {}).get(r, 0.0) * y[j]
223
- if used > 1e-9:
224
- resource_usage[r][m] = float(used)
225
-
226
- for r in resources_with_batches:
227
- for j, m in enumerate(methods):
228
- c_s_m = method_costs.get(m, {}).get(r, 0.0)
229
- z_sum = sum(
230
- sol[z_idx[(m, bn)]]
231
- for bn in ref_to_batches.get(r, [])
232
- if (m, bn) in z_idx
233
- )
234
- used = c_s_m * (y[j] - z_sum)
235
- if used > 1e-9:
236
- resource_usage[r][m] = float(used)
237
-
238
- for bname, info in custom_batch_info.items():
239
- primary_methods = info["primary_methods"]
240
- for j, m in enumerate(methods):
241
- if m not in primary_methods:
242
- continue
243
- c_n_m = method_costs.get(m, {}).get(bname, 0.0)
244
- if c_n_m <= 0:
245
- continue
246
- used = c_n_m * sol[z_idx[(m, bname)]] if (m, bname) in z_idx else 0.0
247
- if used > 1e-9:
248
- resource_usage[bname][m] = float(used)
249
-
250
- resource_used_total = {r: sum(v.values()) for r, v in resource_usage.items()}
251
-
252
- all_caps = {**resource_caps,
253
- **{bn: info["amount"] for bn, info in custom_batch_info.items()}}
254
- resource_remaining = {r: all_caps.get(r, 0.0) - resource_used_total.get(r, 0.0)
255
- for r in all_caps}
256
-
257
- # ── Perturbation analysis: marginal CO₂ gain per +1 unit of each resource
258
- resource_actual_gain = {}
259
- for i, (ctype, cid) in enumerate(constraint_labels):
260
- if ctype not in ("resource", "custom_batch"):
261
- continue
262
- b_perturbed = b_ub.copy()
263
- b_perturbed[i] += 1.0
264
- try:
265
- perturbed = linprog(
266
- c_obj, A_ub=A_ub, b_ub=b_perturbed, bounds=bounds, method="highs"
267
- )
268
- if perturbed.success:
269
- new_total = float(perturbed.x[:M].sum())
270
- resource_actual_gain[cid] = max(new_total - total_removed, 0.0)
271
- except Exception:
272
- pass
273
 
274
  return True, {
275
  "total_removed": total_removed,
@@ -281,4 +487,4 @@ def run_optimization(
281
  }
282
 
283
  except Exception as e:
284
- return False, {"message": str(e)}
 
2
  from scipy.optimize import linprog
3
 
4
 
5
+ def apply_enabled_methods(method_costs, enabled_methods):
6
+ """Return a copy of method_costs with coefficients zeroed for disabled (method, resource) pairs.
 
 
 
 
 
 
 
7
 
8
+ Args:
9
+ method_costs (dict): {method: {resource: coefficient}}.
10
+ enabled_methods (dict): {resource: {method: bool}} — False entries disable the coefficient.
 
 
 
 
 
11
 
12
+ Returns:
13
+ dict: deep copy of method_costs with disabled coefficients set to 0.0.
14
+ """
15
+ result = {m: dict(v) for m, v in method_costs.items()}
16
  enabled_methods = enabled_methods or {}
17
+ for method, resources in result.items():
 
18
  for resource in list(resources):
19
  if not enabled_methods.get(resource, {}).get(method, True):
20
+ result[method][resource] = 0.0
21
+ return result
22
+
23
+
24
+ def build_custom_batch_structures(method_costs, resource_caps, custom_resources):
25
+ """Build the custom-batch index structures and inject fallback coefficients.
26
+
27
+ Mutates method_costs (adds fallback median coefficients) and resource_caps
28
+ (adds batch caps that are not yet present). Both dicts are already copies
29
+ when this function is called from run_optimization.
30
+
31
+ Args:
32
+ method_costs (dict): {method: {resource: coefficient}} — mutated in place.
33
+ resource_caps (dict): {resource: cap} — mutated in place.
34
+ custom_resources (list | None): list of custom batch dicts with keys
35
+ name, group, amount, methods.
36
 
37
+ Returns:
38
+ tuple[dict, dict, set]:
39
+ custom_batch_specs – {batch_name: {group, amount, primary_methods}}.
40
+ group_to_custom_batches – {reference_resource: [batch_name, ...]}.
41
+ custom_batch_names – set of all batch names.
42
+ """
43
+ custom_batch_specs = {}
44
  for batch in (custom_resources or []):
45
+ custom_batch_specs[batch["name"]] = {
46
  "group": batch["group"],
47
  "amount": float(batch["amount"]),
48
  "primary_methods": set(batch.get("methods", {}).keys()),
49
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
+ custom_batch_names = set(custom_batch_specs)
52
+ group_to_custom_batches = {}
53
+ for batch_name, specs in custom_batch_specs.items():
54
+ group_to_custom_batches.setdefault(specs["group"], []).append(batch_name)
55
+ if batch_name not in resource_caps:
56
+ resource_caps[batch_name] = specs["amount"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
+ # Fallback: use batch median coefficient if method has none
59
+ for batch in (custom_resources or []):
60
+ batch_name = batch["name"]
61
+ for method, coefs in batch["methods"].items():
62
+ if method_costs.get(method, {}).get(batch_name, 0.0) == 0.0:
63
+ method_costs.setdefault(method, {})[batch_name] = coefs["median"]
64
+
65
+ return custom_batch_specs, group_to_custom_batches, custom_batch_names
66
+
67
+
68
+ def build_lp_indices(active_methods, num_methods, custom_batch_specs, resource_caps, method_costs):
69
+ """Build the LP variable index for custom-batch draw variables z_{m,n}.
70
+
71
+ When the user adds a custom resource batch (e.g. "degraded land patch A"),
72
+ the solver needs to track separately how much of each CDR method's output
73
+ comes from that specific batch vs. the standard resource pool.
74
+ These "draw variables" z_{m,n} represent:
75
+ z_{m,n} = amount of CO₂ method m produces using custom batch n.
76
+ They sit alongside the main y_m variables in the LP column vector.
77
+
78
+ Args:
79
+ active_methods (list[str]): ordered list of active CDR method names.
80
+ num_methods (int): len(active_methods) — index offset for z variables.
81
+ custom_batch_specs (dict): {batch_name: {group, amount, primary_methods}}.
82
+ resource_caps (dict): {resource: cap} — used to skip zero-cap batches.
83
+ method_costs (dict): {method: {resource: coefficient}}.
84
+
85
+ Returns:
86
+ tuple[list, dict, int]:
87
+ custom_usage_pairs – [(method, batch_name)] with non-zero coefficient.
88
+ custom_pair_to_index – {(method, batch_name): lp_column_index}.
89
+ num_lp_vars – total number of LP variables.
90
+ """
91
+ custom_usage_pairs = []
92
+ for batch_name, specs in custom_batch_specs.items():
93
+ primary_methods = specs["primary_methods"]
94
+ if resource_caps.get(batch_name, 0.0) <= 0:
95
  continue
96
+ for m in active_methods:
97
  if m not in primary_methods:
98
  continue
99
+ if method_costs.get(m, {}).get(batch_name, 0.0) > 0:
100
+ custom_usage_pairs.append((m, batch_name))
 
101
 
102
+ custom_pair_to_index = {pair: num_methods + i for i, pair in enumerate(custom_usage_pairs)}
103
+ num_lp_vars = num_methods + len(custom_usage_pairs)
104
+ return custom_usage_pairs, custom_pair_to_index, num_lp_vars
105
 
 
106
 
107
+ def build_constraint_matrix(
108
+ active_methods, method_to_index, num_lp_vars, num_methods,
109
+ resource_caps, method_costs, method_constraints,
110
+ custom_batch_specs, group_to_custom_batches,
111
+ resources_no_batches, resources_with_batches,
112
+ custom_usage_pairs, custom_pair_to_index,
113
+ ):
114
+ """Assemble the LP inequality constraint matrix (A_ub · x ≤ b_ub).
115
+
116
+ Builds five groups of constraints:
117
+ A – standard resources with no custom substitutes.
118
+ B – standard resources that have custom-batch substitutes.
119
+ C – custom batch capacity limits.
120
+ D1/D2 – coupling constraints between y_m and z_{m,n}.
121
+ E – per-method output caps (percent or absolute).
122
+
123
+ Args:
124
+ active_methods (list[str]): ordered active CDR method names.
125
+ method_to_index (dict): {method: lp_column_index}.
126
+ num_lp_vars (int): total LP variable count.
127
+ num_methods (int): number of y_m variables.
128
+ resource_caps (dict): {resource: cap}.
129
+ method_costs (dict): {method: {resource: coefficient}}.
130
+ method_constraints (dict): {method: {cap_type, cap_value}}.
131
+ custom_batch_specs (dict): {batch_name: {group, amount, primary_methods}}.
132
+ group_to_custom_batches (dict): {reference_resource: [batch_name]}.
133
+ resources_no_batches (list[str]): standard resources without substitutes.
134
+ resources_with_batches (list[str]): standard resources that have substitutes.
135
+ custom_usage_pairs (list): [(method, batch_name)].
136
+ custom_pair_to_index (dict): {(method, batch_name): lp_column_index}.
137
+
138
+ Returns:
139
+ tuple[np.ndarray, np.ndarray, list]:
140
+ A_ub – constraint coefficient matrix.
141
+ b_ub – constraint right-hand-side vector.
142
+ constraint_labels – list of (type_str, identifier) per row.
143
+ """
144
  A_rows, b_rows, constraint_labels = [], [], []
145
 
146
+ # ── CONSTRAINT GROUPS ─────────────────────────────────────────────────────
147
+ # Each group adds one row per resource/method to the matrix A_ub.
148
+ # The solver will find the y_m values that maximise total CO₂ removal while
149
+ # keeping every A_ub · x ≤ b_ub inequality satisfied.
150
+ #
151
+ # Plain-English summary of each group:
152
+ # A – "Don't use more of a standard resource than is available."
153
+ # B – "Same, but subtract the part already covered by custom batches."
154
+ # C – "Each custom batch can only supply up to its declared quantity."
155
+ # D – "Book-keeping: custom-batch draw variables can't exceed the method's
156
+ # total output, and if a method has no standard resource it must draw
157
+ # entirely from custom batches."
158
+ # E – "Respect each method's user-defined cap (% or absolute MtCO₂/yr)."
159
+ # ─────────────────────────────────────────────────────────────────────────
160
+
161
+ # (A) Standard resources — no custom substitutes.
162
+ # Rule: total resource consumed across all methods ≤ available amount.
163
  for r in resources_no_batches:
164
+ row = np.zeros(num_lp_vars)
165
+ for j, m in enumerate(active_methods):
166
  row[j] = method_costs.get(m, {}).get(r, 0.0)
167
+ if np.any(row[:num_methods] != 0):
168
  A_rows.append(row)
169
  b_rows.append(resource_caps[r])
170
  constraint_labels.append(("resource", r))
171
 
172
+ # (B) Standard resources that have custom-batch substitutes.
173
+ # Rule: only the portion of the resource NOT already covered by custom batches
174
+ # counts against the standard pool cap. Formula: Σ cost(m,r)·(y_m − Σ z_{m,n}) ≤ cap(r)
175
  for r in resources_with_batches:
176
+ row = np.zeros(num_lp_vars)
177
+ for j, m in enumerate(active_methods):
178
+ c_s_m = method_costs.get(m, {}).get(r, 0.0) # coefficient for standard resource r
179
  row[j] = c_s_m
180
+ for batch_name in group_to_custom_batches.get(r, []):
181
+ if (m, batch_name) in custom_pair_to_index:
182
+ row[custom_pair_to_index[(m, batch_name)]] -= c_s_m # subtract batch draw
183
  if np.any(row != 0):
184
  A_rows.append(row)
185
  b_rows.append(resource_caps[r])
186
  constraint_labels.append(("resource", r))
187
 
188
+ # (C) Custom batch capacity.
189
+ # Rule: total resource drawn from this specific batch ≤ batch quantity Q_n.
190
+ for batch_name, specs in custom_batch_specs.items():
191
+ if resource_caps.get(batch_name, 0.0) <= 0:
192
  continue
193
+ row = np.zeros(num_lp_vars)
194
+ for j, m in enumerate(active_methods):
195
+ if (m, batch_name) not in custom_pair_to_index:
196
  continue
197
+ c_n_m = method_costs.get(m, {}).get(batch_name, 0.0) # coefficient for batch n
198
  if c_n_m > 0:
199
+ row[custom_pair_to_index[(m, batch_name)]] = c_n_m
200
  if np.any(row != 0):
201
  A_rows.append(row)
202
+ b_rows.append(resource_caps.get(batch_name, 0.0))
203
+ constraint_labels.append(("custom_batch", batch_name))
204
 
205
+ # (D) Coupling constraints — link z_{m,n} draw variables to y_m output.
206
+ # D1: Σ z_{m,n} ≤ y_m — can't draw more from batches than the method produces.
207
+ # D2: y_m ≤ Σ z_{m,n} — only added when the method has no standard resource (c_s=0),
208
+ # which forces the method to source 100 % of its output from custom batches.
209
  coupling_done = set()
210
+ for (m, batch_name) in custom_usage_pairs:
211
+ ref = custom_batch_specs[batch_name]["group"]
212
  key = (m, ref)
213
  if key in coupling_done:
214
  continue
215
  coupling_done.add(key)
216
+ j = method_to_index[m]
217
  c_s_m = method_costs.get(m, {}).get(ref, 0.0)
218
+ batch_cols = [
219
+ custom_pair_to_index[(m, sibling)]
220
+ for sibling in group_to_custom_batches.get(ref, [])
221
+ if (m, sibling) in custom_pair_to_index
222
+ ]
223
+ row = np.zeros(num_lp_vars)
224
  row[j] = -1.0
225
  for col in batch_cols:
226
  row[col] = 1.0
 
229
  constraint_labels.append(("coupling_D1", (m, ref)))
230
 
231
  if c_s_m == 0:
232
+ row2 = np.zeros(num_lp_vars)
233
  row2[j] = 1.0
234
  for col in batch_cols:
235
  row2[col] = -1.0
 
237
  b_rows.append(0.0)
238
  constraint_labels.append(("coupling_D2", (m, ref)))
239
 
240
+ # (E) Method output caps — respect the user's per-method limits from Tab 2.
241
+ # Absolute cap: y_m ≤ cap_value (in MtCO₂/yr).
242
+ # Percent cap: y_m ≤ cap_ratio · Σ y_all (fraction of total portfolio output).
243
+ for j, m in enumerate(active_methods):
244
  constraint = method_constraints.get(m, {})
245
  cap_type = constraint.get("cap_type", "percent")
246
  cap_value = constraint.get("cap_value", 100)
247
  if cap_type == "absolute":
248
+ row = np.zeros(num_lp_vars)
249
+ row[j] = 1.0
250
  A_rows.append(row)
251
  b_rows.append(cap_value)
252
  constraint_labels.append(("method_cap", m))
253
  else:
254
+ cap_ratio = cap_value / 100.0
255
+ row = np.zeros(num_lp_vars)
256
+ row[:num_methods] -= cap_ratio
257
+ row[j] += 1.0
258
  A_rows.append(row)
259
  b_rows.append(0.0)
260
  constraint_labels.append(("method_cap", m))
261
 
262
+ A_ub = np.array(A_rows) if A_rows else np.empty((0, num_lp_vars))
263
  b_ub = np.array(b_rows)
264
+ return A_ub, b_ub, constraint_labels
265
+
266
+
267
+ def extract_resource_usage(
268
+ active_methods, num_methods, method_outputs, lp_solution, method_costs,
269
+ resource_caps, custom_batch_specs, group_to_custom_batches,
270
+ custom_pair_to_index, standard_resources,
271
+ resources_no_batches, resources_with_batches, custom_batch_names,
272
+ ):
273
+ """Compute per-method resource consumption from the LP solution.
274
+
275
+ Args:
276
+ active_methods (list[str]): ordered active CDR method names.
277
+ num_methods (int): number of y_m variables.
278
+ method_outputs (np.ndarray): y_m values from LP solution (length num_methods).
279
+ lp_solution (np.ndarray): full LP solution vector x.
280
+ method_costs (dict): {method: {resource: coefficient}}.
281
+ resource_caps (dict): {resource: cap} — used for resource_remaining.
282
+ custom_batch_specs (dict): {batch_name: {group, amount, primary_methods}}.
283
+ group_to_custom_batches (dict): {reference_resource: [batch_name]}.
284
+ custom_pair_to_index (dict): {(method, batch_name): lp_column_index}.
285
+ standard_resources (list[str]): all non-batch resource names.
286
+ resources_no_batches (list[str]): standard resources without substitutes.
287
+ resources_with_batches (list[str]): standard resources with substitutes.
288
+ custom_batch_names (set[str]): set of all custom batch names.
289
+
290
+ Returns:
291
+ tuple[dict, dict, dict]:
292
+ resource_usage – {resource: {method: qty_used}}.
293
+ resource_used_total – {resource: total_qty_used}.
294
+ resource_remaining – {resource: qty_remaining}.
295
+ """
296
+ all_resource_names = standard_resources + list(custom_batch_names)
297
+ resource_usage = {r: {} for r in all_resource_names}
298
+
299
+ # 1e-9 is a near-zero threshold: LP solvers return tiny floating-point residuals
300
+ # (e.g. 0.000000001) instead of exact 0. Values below this threshold are treated
301
+ # as zero so they don't appear as spurious resource usage in the output.
302
+ for r in resources_no_batches:
303
+ for j, m in enumerate(active_methods):
304
+ used = method_costs.get(m, {}).get(r, 0.0) * method_outputs[j]
305
+ if used > 1e-9:
306
+ resource_usage[r][m] = float(used)
307
+
308
+ for r in resources_with_batches:
309
+ for j, m in enumerate(active_methods):
310
+ c_s_m = method_costs.get(m, {}).get(r, 0.0)
311
+ z_sum = sum(
312
+ lp_solution[custom_pair_to_index[(m, batch_name)]]
313
+ for batch_name in group_to_custom_batches.get(r, [])
314
+ if (m, batch_name) in custom_pair_to_index
315
+ )
316
+ used = c_s_m * (method_outputs[j] - z_sum)
317
+ if used > 1e-9:
318
+ resource_usage[r][m] = float(used)
319
+
320
+ for batch_name, specs in custom_batch_specs.items():
321
+ primary_methods = specs["primary_methods"]
322
+ for j, m in enumerate(active_methods):
323
+ if m not in primary_methods:
324
+ continue
325
+ c_n_m = method_costs.get(m, {}).get(batch_name, 0.0)
326
+ if c_n_m <= 0:
327
+ continue
328
+ used = (
329
+ c_n_m * lp_solution[custom_pair_to_index[(m, batch_name)]]
330
+ if (m, batch_name) in custom_pair_to_index
331
+ else 0.0
332
+ )
333
+ if used > 1e-9:
334
+ resource_usage[batch_name][m] = float(used)
335
+
336
+ resource_used_total = {r: sum(v.values()) for r, v in resource_usage.items()}
337
+ all_caps = {
338
+ **resource_caps,
339
+ **{batch_name: specs["amount"] for batch_name, specs in custom_batch_specs.items()},
340
+ }
341
+ resource_remaining = {
342
+ r: all_caps.get(r, 0.0) - resource_used_total.get(r, 0.0)
343
+ for r in all_caps
344
+ }
345
+ return resource_usage, resource_used_total, resource_remaining
346
+
347
+
348
+ def run_perturbation(constraint_labels, A_ub, b_ub, objective_coefficients, bounds, num_methods, total_removed):
349
+ """Compute marginal CDR gain per unit for each binding resource constraint.
350
+
351
+ Re-solves the LP with each resource constraint right-hand side increased by 1
352
+ to measure the actual CO₂ gain that one extra unit would unlock.
353
+
354
+ Args:
355
+ constraint_labels (list): list of (type_str, identifier) per constraint row.
356
+ A_ub (np.ndarray): LP constraint coefficient matrix.
357
+ b_ub (np.ndarray): LP constraint right-hand-side vector.
358
+ objective_coefficients (np.ndarray): LP objective vector.
359
+ bounds (list): variable bounds for linprog.
360
+ num_methods (int): number of y_m variables (for summing total removed).
361
+ total_removed (float): baseline total CO₂ removed.
362
+
363
+ Returns:
364
+ dict: {resource_or_batch_name: CDR_gain_for_plus_1_unit (float)}.
365
+ """
366
+ resource_actual_gain = {}
367
+ for i, (ctype, cid) in enumerate(constraint_labels):
368
+ if ctype not in ("resource", "custom_batch"):
369
+ continue
370
+ b_perturbed = b_ub.copy()
371
+ b_perturbed[i] += 1.0
372
+ try:
373
+ perturbed = linprog(
374
+ objective_coefficients, A_ub=A_ub, b_ub=b_perturbed, bounds=bounds, method="highs"
375
+ )
376
+ if perturbed.success:
377
+ new_total = float(perturbed.x[:num_methods].sum())
378
+ resource_actual_gain[cid] = max(new_total - total_removed, 0.0)
379
+ except Exception:
380
+ pass
381
+ return resource_actual_gain
382
+
383
+
384
+ def run_optimization(
385
+ resource_caps,
386
+ method_constraints,
387
+ method_costs,
388
+ custom_resources=None,
389
+ enabled_methods=None,
390
+ ):
391
+ """Maximize total CO₂ removal subject to resource caps and method constraints.
392
+
393
+ Solves a linear programme (HiGHS solver via scipy.optimize.linprog).
394
+ Also runs a perturbation analysis: for each binding resource constraint,
395
+ re-solves with cap + 1 to compute the marginal CDR gain per unit.
396
+
397
+ LP variables (two types, packed into one vector x):
398
+ y_m = MtCO₂ removed by CDR method m — the main "answer" (indices 0..num_methods-1).
399
+ z_{m,n} = MtCO₂ that method m draws specifically from custom batch n — a helper
400
+ variable introduced when the user adds a custom resource batch; it lets the
401
+ solver track which batch supplies which method without double-counting
402
+ (indices num_methods..num_lp_vars-1).
403
+
404
+ Args:
405
+ resource_caps (dict): {resource: available amount (float)}.
406
+ method_constraints (dict): {method: {active (bool), cap_type (str), cap_value (float)}}.
407
+ method_costs (dict): {method: {resource: coefficient (float)}}.
408
+ custom_resources (list | None): list of custom batch dicts with keys
409
+ name, group, amount, methods.
410
+ enabled_methods (dict | None): {resource: {method: bool}} — False entries
411
+ zero-out the corresponding coefficient before solving.
412
+
413
+ Returns:
414
+ tuple[bool, dict]:
415
+ (True, result) on success, where result contains:
416
+ total_removed (float), method_usage (dict), resource_usage (dict),
417
+ resource_used_total (dict), resource_remaining (dict),
418
+ resource_actual_gain (dict).
419
+ (False, {"message": str}) on failure.
420
+ """
421
+ resource_caps = dict(resource_caps)
422
+ method_costs = apply_enabled_methods(method_costs, enabled_methods)
423
+
424
+ custom_batch_specs, group_to_custom_batches, custom_batch_names = build_custom_batch_structures(
425
+ method_costs, resource_caps, custom_resources
426
+ )
427
+
428
+ active_methods = [m for m in method_costs if method_constraints.get(m, {}).get("active", True)]
429
+ num_methods = len(active_methods)
430
+ method_to_index = {m: j for j, m in enumerate(active_methods)}
431
+ if not num_methods:
432
+ return False, {"message": "No active methods."}
433
+
434
+ custom_usage_pairs, custom_pair_to_index, num_lp_vars = build_lp_indices(
435
+ active_methods, num_methods, custom_batch_specs, resource_caps, method_costs
436
+ )
437
+
438
+ standard_resources = [r for r in resource_caps if r not in custom_batch_names]
439
+ resources_with_batches = [r for r in standard_resources if r in group_to_custom_batches]
440
+ resources_no_batches = [r for r in standard_resources if r not in group_to_custom_batches]
441
+
442
+ A_ub, b_ub, constraint_labels = build_constraint_matrix(
443
+ active_methods, method_to_index, num_lp_vars, num_methods,
444
+ resource_caps, method_costs, method_constraints,
445
+ custom_batch_specs, group_to_custom_batches,
446
+ resources_no_batches, resources_with_batches,
447
+ custom_usage_pairs, custom_pair_to_index,
448
+ )
449
 
450
+ # linprog minimises by convention, so we negate the objective to maximise Σ y_m.
451
+ objective_coefficients = np.zeros(num_lp_vars)
452
+ objective_coefficients[:num_methods] = -1.0 # only y_m variables enter the objective
453
+ bounds = [(0, None)] * num_lp_vars # all variables must be ≥ 0
454
 
 
455
  try:
456
+ result = linprog(objective_coefficients, A_ub=A_ub, b_ub=b_ub, bounds=bounds, method="highs")
457
  if not result.success:
458
  return False, {"message": result.message}
459
 
460
+ lp_solution = result.x
461
+ method_outputs = lp_solution[:num_methods]
462
+ total_removed = float(method_outputs.sum())
463
+ method_usage = {
464
+ active_methods[j]: float(method_outputs[j])
465
+ for j in range(num_methods) if method_outputs[j] > 1e-9
466
+ }
467
+
468
+ resource_usage, resource_used_total, resource_remaining = extract_resource_usage(
469
+ active_methods, num_methods, method_outputs, lp_solution, method_costs,
470
+ resource_caps, custom_batch_specs, group_to_custom_batches,
471
+ custom_pair_to_index, standard_resources,
472
+ resources_no_batches, resources_with_batches, custom_batch_names,
473
+ )
474
+
475
+ resource_actual_gain = run_perturbation(
476
+ constraint_labels, A_ub, b_ub, objective_coefficients, bounds,
477
+ num_methods, total_removed,
478
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
479
 
480
  return True, {
481
  "total_removed": total_removed,
 
487
  }
488
 
489
  except Exception as e:
490
+ return False, {"message": str(e)}
requirements.txt CHANGED
Binary files a/requirements.txt and b/requirements.txt differ
 
tabs/tab0_welcome.py CHANGED
@@ -1,34 +1,43 @@
1
- import streamlit as st
2
- from config.config import CO2_UNIT
3
-
4
- def render_tab():
5
- st.markdown(f"""
6
- <div class='custom-corps'>
7
-
8
- This tool helps you optimize your carbon direct removal portfolio by adjusting resource usage and constraints.
9
-
10
- <h3 class="wt wt-1">Required:</h3>
11
- <ol>
12
- <li><b>Resource inputs</b>: provide the total available quantities of resources for 1 year.</li>
13
- </ol>
14
-
15
- <h3 class="wt wt-2">Optional:</h3>
16
- <ol start="2">
17
- <li><b>Method constraints</b>: select which carbon removal methods are active and set their <b>maximum deployment cap</b> (as % of total potential or absolute value in {CO2_UNIT}/yr).</li>
18
- <li><b>Resource efficiency coefficients</b>: adjust the resource usage for each method.</li>
19
- </ol>
20
-
21
- <h3 class="wt wt-3">Outputs:</h3>
22
- <ol start="4">
23
- <li><b>Portfolio generation</b>: run the optimization to find the best portfolio: how much CO₂ is removed, what methods are used, and in what proportions.</li>
24
- </ol>
25
-
26
- <h3 class="wt wt-4">Explore:</h3>
27
- <ol start="5">
28
- <li><b>Sensitivity analysis</b>: analyze how changes in method caps or resource availability affect the portfolio.</li>
29
- <li><b>Scenario comparison</b>: compare different scenarios based on the optimization results.</li>
30
- </ol>
31
-
32
- Let’s get started! Use the side tabs to navigate through the sections.
33
- </div>
 
 
 
 
 
 
 
 
 
34
  """, unsafe_allow_html=True)
 
1
+ import streamlit as st
2
+ from config.config import CO2_UNIT
3
+ from utils.ui_helpers import show_bar
4
+
5
+ def render_tab():
6
+ """Render the Welcome tab with a step-by-step guide to using the tool.
7
+
8
+ Returns:
9
+ None
10
+ """
11
+ st.markdown('<h1 class="crra-heading">Welcome!</h1>', unsafe_allow_html=True)
12
+ show_bar()
13
+
14
+ st.markdown(f"""
15
+ <div class='custom-corps'>
16
+
17
+ This tool helps you optimize your carbon direct removal portfolio by adjusting resource usage and constraints.
18
+
19
+ <h3 class="wt wt-1">Required:</h3>
20
+ <ol>
21
+ <li><b>Resource inputs</b>: provide the total available quantities of resources for 1 year.</li>
22
+ </ol>
23
+
24
+ <h3 class="wt wt-2">Optional:</h3>
25
+ <ol start="2">
26
+ <li><b>Method constraints</b>: select which carbon removal methods are active and set their <b>maximum deployment cap</b> (as % of total potential or absolute value in {CO2_UNIT}/yr).</li>
27
+ <li><b>Resource efficiency coefficients</b>: adjust the resource usage for each method.</li>
28
+ </ol>
29
+
30
+ <h3 class="wt wt-3">Outputs:</h3>
31
+ <ol start="4">
32
+ <li><b>Portfolio generation</b>: run the optimization to find the best portfolio: how much CO₂ is removed, what methods are used, and in what proportions.</li>
33
+ </ol>
34
+
35
+ <h3 class="wt wt-4">Explore:</h3>
36
+ <ol start="5">
37
+ <li><b>Sensitivity analysis</b>: analyze how changes in method caps or resource availability affect the portfolio.</li>
38
+ <li><b>Scenario comparison</b>: compare different scenarios based on the optimization results.</li>
39
+ </ol>
40
+
41
+ Let’s get started! Use the side tabs to navigate through the sections.
42
+ </div>
43
  """, unsafe_allow_html=True)
tabs/tab1_inputs.py CHANGED
@@ -1,818 +1,869 @@
1
- import pandas as pd
2
- import streamlit as st
3
-
4
- from config.config import (resource_groups, resource_order, resource_tooltips,
5
- resource_unit_map, method_groups, CO2_UNIT, secondary_resources)
6
- from utils.data_utils import render_resource_input, strip_unit_suffix
7
- from utils.ui_helpers import show_bar
8
- from io import BytesIO
9
-
10
- # ─── Custom resource helpers ──────────────────────────────────────────────────
11
-
12
- def standard_resource_names():
13
- return {r for rlist in resource_groups.values() for r in rlist}
14
-
15
-
16
- def init_custom_resources():
17
- if "custom_resources" not in st.session_state:
18
- st.session_state.custom_resources = []
19
- if "enabled_methods" not in st.session_state:
20
- st.session_state.enabled_methods = {}
21
- if "auto_disabled_methods" not in st.session_state:
22
- st.session_state.auto_disabled_methods = set()
23
- for batch in st.session_state.custom_resources:
24
- try:
25
- batch["amount"] = float(batch["amount"])
26
- except (ValueError, TypeError):
27
- batch["amount"] = 0.0
28
- for m, coefs in batch.get("methods", {}).items():
29
- for key in ("min", "median", "max"):
30
- try:
31
- coefs[key] = float(coefs[key])
32
- except (ValueError, TypeError):
33
- coefs[key] = 0.0
34
-
35
-
36
- # ─── Dialog: add / edit a custom resource batch ───────────────────────────────
37
-
38
- @st.dialog("Add a new resource", width="large")
39
- def dialog_add_custom_resource(cost_sliders, edit_index: int | None = None, preset_resources: list | None = None):
40
-
41
- if edit_index is not None and not st.session_state.get(f"_dlg_initialized_{edit_index}"):
42
- for key in list(st.session_state.keys()):
43
- if key.startswith(("_dlg_existing_enable_",
44
- "_dlg_existing_min_", "_dlg_existing_med_", "_dlg_existing_max_",
45
- "_dlg_new_chk_", "_dlg_new_min_", "_dlg_new_med_", "_dlg_new_max_")):
46
- del st.session_state[key]
47
- st.session_state[f"_dlg_initialized_{edit_index}"] = True
48
-
49
- standard_names = standard_resource_names()
50
- existing_names = {
51
- b["name"] for i, b in enumerate(st.session_state.custom_resources)
52
- if i != edit_index
53
- }
54
- existing = st.session_state.custom_resources[edit_index] if edit_index is not None else {}
55
-
56
- resource_to_group = {
57
- r: group
58
- for group, resources in resource_groups.items()
59
- for r in resources
60
- }
61
-
62
- # If opened from a specific resource-group expander ("+ Add resource" inline
63
- # button), restrict the reference-resource choice to that group's resources
64
- # only — the user picked the group by clicking that button.
65
- group_options = preset_resources if preset_resources else resource_order
66
- if preset_resources:
67
- physical_group = resource_to_group.get(preset_resources[0], "")
68
- st.markdown(
69
- f'<p class="caption-input">Adding a new resource batch in the <strong>{physical_group}</strong> group.</p>',
70
- unsafe_allow_html=True,
71
- )
72
-
73
- st.markdown('<p class="dialog-title">1 — Name & group</p>', unsafe_allow_html=True)
74
-
75
- col_name, col_group = st.columns(2)
76
- with col_name:
77
- batch_name = st.text_input(
78
- "Resource name *",
79
- value=existing.get("name", ""),
80
- placeholder="e.g. Degraded forest lands – North",
81
- help="It can't match the name of an existing resource.",
82
- key="label-input-name"
83
- )
84
- with col_group:
85
- default_group_idx = group_options.index(existing["group"]) if existing.get("group") in group_options else 0
86
- batch_group = st.selectbox(
87
- "Resource group *",
88
- options=group_options,
89
- index=default_group_idx,
90
- help="Determines the physical unit of this resource.",
91
- disabled=bool(preset_resources) and len(group_options) == 1,
92
- key="label-input-group"
93
- )
94
-
95
- group = resource_to_group.get(batch_group)
96
- unit = resource_unit_map.get(group, "?")
97
-
98
- st.markdown(f'<p class="caption">Unit for this group: <strong>{unit}</strong></p>', unsafe_allow_html=True)
99
-
100
- st.markdown('<p class="dialog-title">2 — Available quantity</p>', unsafe_allow_html=True)
101
-
102
- batch_amount = st.number_input(
103
- f"Available amount ({unit})",
104
- min_value=0.0,
105
- format="%.2f",
106
- value=float(existing["amount"]) if existing.get("amount") and existing.get("amount") != 0.0 else None,
107
- help=f"Total quantity available for 1 year, in {unit}.",
108
- key="label-input-amount",
109
- placeholder="0.0"
110
- )
111
-
112
- st.markdown('<p class="dialog-title">3 — Existing CDR methods for this resource</p>', unsafe_allow_html=True)
113
-
114
- method_configs = {}
115
-
116
- # All methods linked to this resource (primary + secondary)
117
- all_related_methods = {}
118
- for cdr_method, resource in cost_sliders.items():
119
- for name, coeff in resource.items():
120
- if batch_group == strip_unit_suffix(name):
121
- all_related_methods[cdr_method] = {
122
- "min": coeff["min"],
123
- "median": coeff["median"],
124
- "max": coeff["max"],
125
- }
126
-
127
- # Split into primary (selectable) and secondary (auto-linked in Tab 3)
128
- primary_related = {
129
- m: coeff for m, coeff in all_related_methods.items()
130
- if (m, batch_group) not in secondary_resources
131
- }
132
- secondary_related = {
133
- m: coeff for m, coeff in all_related_methods.items()
134
- if (m, batch_group) in secondary_resources
135
- }
136
-
137
- # group_name -> primary methods only
138
- primary_by_group = {}
139
- for m, coeff in primary_related.items():
140
- method_group = next(
141
- (g for g, methods in method_groups.items() if m in methods),
142
- "Other"
143
- )
144
- primary_by_group.setdefault(method_group, {})[m] = coeff
145
-
146
- if not primary_by_group:
147
- st.markdown('<p class="caption">No primary CDR methods use this resource directly.</p>', unsafe_allow_html=True)
148
-
149
- for method_group, methods in primary_by_group.items():
150
- with st.expander(f"**{method_group}**", expanded=False):
151
- for m, coeff in methods.items():
152
-
153
- already_selected = m in existing.get("methods", {})
154
-
155
- if f"_dlg_existing_enable_{m}" not in st.session_state:
156
- st.session_state[f"_dlg_existing_enable_{m}"] = already_selected
157
-
158
- enabled = st.checkbox(
159
- f"{m}",
160
- key=f"_dlg_existing_enable_{m}",
161
- )
162
-
163
- if not enabled:
164
- continue
165
-
166
- existing_coeff = existing.get("methods", {}).get(m, coeff)
167
-
168
- st.info("Modifying these values only affects this resource batch: the base method coefficients are unchanged.")
169
- col_min, col_med, col_max = st.columns(3)
170
- with col_min:
171
- e_min = st.number_input(
172
- f"Min ({unit}/{CO2_UNIT})",
173
- min_value=0.0,
174
- format="%.10g",
175
- value=float(existing_coeff["min"]),
176
- key=f"_dlg_existing_min_{m}",
177
- )
178
- with col_med:
179
- e_med = st.number_input(
180
- f"Median ({unit}/{CO2_UNIT})",
181
- min_value=0.0,
182
- format="%.10g",
183
- value=float(existing_coeff["median"]),
184
- key=f"_dlg_existing_med_{m}",
185
- )
186
- with col_max:
187
- e_max = st.number_input(
188
- f"Max ({unit}/{CO2_UNIT})",
189
- min_value=0.0,
190
- format="%.10g",
191
- value=float(existing_coeff["max"]),
192
- key=f"_dlg_existing_max_{m}",
193
- )
194
- method_configs[m] = {
195
- "min": e_min,
196
- "median": e_med,
197
- "max": e_max,
198
- }
199
-
200
- if secondary_related:
201
- names = "\n".join(f"- **{m}**" for m in secondary_related)
202
- st.info(
203
- f"""The following methods use this resource as **secondary** and may be assigned later in the **Resource Coefficients** tab:\n{names}
204
- """,
205
- icon="ℹ️",
206
- )
207
-
208
- # -------------------------------------------------
209
- # Part 4 : new/custom methods only
210
- # -------------------------------------------------
211
-
212
- st.markdown('<p class="dialog-title">4 — Add additional CDR methods</p>', unsafe_allow_html=True)
213
- st.markdown(f'<p class="caption">Select additional methods and enter their coefficients in {unit}/{CO2_UNIT}.</p>', unsafe_allow_html=True)
214
-
215
- already_used = set(all_related_methods.keys())
216
-
217
- for group_name, methods in method_groups.items():
218
- available_methods = [
219
- m for m in methods
220
- if m not in already_used
221
- ]
222
- if not available_methods:
223
- continue
224
- with st.expander(f"**{group_name}**", expanded=False):
225
- for m in available_methods:
226
- enabled = st.checkbox(
227
- f"**{m}**",
228
- key=f"_dlg_new_chk_{m}",
229
- )
230
- if enabled:
231
- col_min, col_med, col_max = st.columns(3)
232
- with col_min:
233
- v_min = st.number_input(
234
- f"Min ({unit}/{CO2_UNIT})",
235
- min_value=0.0,
236
- value=None,
237
- format="%.10g",
238
- key=f"_dlg_new_min_{m}",
239
- placeholder="0.0000",
240
- )
241
- with col_med:
242
- v_med = st.number_input(
243
- f"Median ({unit}/{CO2_UNIT})",
244
- min_value=0.0,
245
- value=None,
246
- format="%.10g",
247
- key=f"_dlg_new_med_{m}",
248
- placeholder="0.0000",
249
- )
250
- with col_max:
251
- v_max = st.number_input(
252
- f"Ma ({unit}/{CO2_UNIT})",
253
- min_value=0.0,
254
- value=None,
255
- format="%.10g",
256
- key=f"_dlg_new_max_{m}",
257
- placeholder="0.0000",
258
- )
259
- method_configs[m] = {
260
- "min": v_min,
261
- "median": v_med,
262
- "max": v_max,
263
- }
264
- error_placeholder = st.empty()
265
- col_save, col_cancel = st.columns([2, 2])
266
-
267
- with col_cancel:
268
- if st.button("Cancel", use_container_width=True, key="btn-reset-cancel"):
269
- st.rerun()
270
-
271
- def _validate():
272
- errors = []
273
- if not batch_name.strip():
274
- errors.append("Resource name is required.")
275
- elif batch_name.strip() in standard_names:
276
- errors.append(f"**{batch_name}** matches a standard resource name. Choose a different name.")
277
- elif batch_name.strip() in existing_names:
278
- errors.append(f"**{batch_name}** already exists. Choose a different name.")
279
- if not batch_amount or batch_amount <= 0:
280
- errors.append("Available amount must be greater than 0.")
281
- # if not method_configs:
282
- # errors.append("Select at least one CDR method.")
283
- for m, c in method_configs.items():
284
- if any(v is None for v in [c["min"], c["median"], c["max"]]):
285
- errors.append(f"**{m}**: all coefficient fields (min, median, max) are required.")
286
- continue
287
- if c["min"] > c["max"]:
288
- errors.append(f"**{m}**: min ({c['min']}) cannot be greater than max ({c['max']}).")
289
- if not (c["min"] <= c["median"] <= c["max"]):
290
- errors.append(f"**{m}**: median must be between min and max.")
291
- return errors
292
-
293
- with col_save:
294
- if st.button("Add to session", type="tertiary", use_container_width=True, icon=":material/check:", key="btn-new-add"):
295
- errors = _validate()
296
- if errors:
297
- with error_placeholder.container():
298
- for e in errors:
299
- st.error(e, icon=":material/error:")
300
- else:
301
- batch = {
302
- "name": batch_name.strip(),
303
- "group": batch_group,
304
- "amount": float(batch_amount), # always store as float
305
- "unit": resource_unit_map.get(resource_to_group.get(batch_group), "?"),
306
- "methods": method_configs,
307
- }
308
- if edit_index is not None:
309
- st.session_state.custom_resources[edit_index] = batch
310
- st.session_state.pop(f"custom_amount_{edit_index}", None)
311
- else:
312
- new_idx = len(st.session_state.custom_resources)
313
- st.session_state.custom_resources.append(batch)
314
- st.session_state.pop(f"custom_amount_{new_idx}", None)
315
- st.session_state.pop("latest_result", None)
316
- st.rerun()
317
-
318
-
319
- # ─── Excel helpers ────────────────────────────────────────────────────────────
320
-
321
- def to_excel_bytes(batches: list) -> bytes:
322
- """Serialize all custom resource batches to an Excel file (one row per batch×method)."""
323
- rows = []
324
- for batch in batches:
325
- for m, coefs in batch["methods"].items():
326
- rows.append({
327
- "name": batch["name"],
328
- "group": batch["group"],
329
- "amount": float(batch["amount"]),
330
- "unit": batch["unit"],
331
- "method": m,
332
- "min": float(coefs["min"]),
333
- "median": float(coefs["median"]),
334
- "max": float(coefs["max"]),
335
- })
336
- buf = BytesIO()
337
- with pd.ExcelWriter(buf, engine="openpyxl") as writer:
338
- pd.DataFrame(rows).to_excel(writer, index=False, sheet_name="custom_resources")
339
- return buf.getvalue()
340
-
341
-
342
- def _parse_custom_excel(file) -> list:
343
- df = pd.read_excel(file, sheet_name=0)
344
- required = {"name", "group", "amount", "unit", "method", "min", "median", "max"}
345
- if not required.issubset(df.columns):
346
- raise ValueError(f"Missing columns: {required - set(df.columns)}")
347
- batches = []
348
- for bname in df["name"].unique():
349
- df_b = df[df["name"] == bname]
350
- methods = {
351
- row["method"]: {
352
- "min": float(row["min"]),
353
- "median": float(row["median"]),
354
- "max": float(row["max"]),
355
- }
356
- for _, row in df_b.iterrows()
357
- }
358
- group_name = str(df_b["group"].iloc[0])
359
- _r2g = {r: g for g, rs in resource_groups.items() for r in rs}
360
- batches.append({
361
- "name": str(bname),
362
- "group": group_name,
363
- "amount": float(df_b["amount"].iloc[0]),
364
- "unit": resource_unit_map.get(_r2g.get(group_name), "?"),
365
- "methods": methods,
366
- })
367
- return batches
368
-
369
-
370
- # ─── Load dialog ──────────────────────────────────────────────────────────────
371
-
372
- @st.dialog("Load custom resource batches", width="large")
373
- def dialog_load_custom_batch():
374
- st.markdown('<p class="caption">Import an Excel file previously exported from the <strong>Custom resource</strong> section.</p>', unsafe_allow_html=True)
375
-
376
- uploaded = st.file_uploader("Upload Excel file", type=["xlsx"], key="inputs_loader_custom")
377
- if uploaded is None:
378
- return
379
-
380
- try:
381
- new_batches = _parse_custom_excel(uploaded)
382
- except Exception as e:
383
- st.error(f"Error reading file: {e}")
384
- return
385
-
386
- standard_names = standard_resource_names()
387
- existing_names = {b["name"] for b in st.session_state.custom_resources}
388
-
389
- previews = []
390
- for batch in new_batches:
391
- if batch["name"] in standard_names:
392
- status = "❌ Conflicts with standard resource — will be skipped"
393
- elif batch["name"] in existing_names:
394
- status = "⚠️ Already exists — will be overwritten"
395
- else:
396
- status = "✅ New"
397
- previews.append({
398
- "Resource name": batch["name"],
399
- "Group": batch["group"],
400
- "Amount": f"{batch["amount"]} {resource_unit_map.get(batch["group"], "")}",
401
- "Methods": len(batch["methods"]),
402
- "Status": status,
403
- })
404
- st.dataframe(pd.DataFrame(previews), use_container_width=True, hide_index=True)
405
-
406
- if st.button("↩️ Import all batches", type="primary", use_container_width=True):
407
- imported = 0
408
- for batch in new_batches:
409
- if batch["name"] in standard_names:
410
- continue
411
- if batch["name"] in existing_names:
412
- idx = next(i for i, b in enumerate(st.session_state.custom_resources)
413
- if b["name"] == batch["name"])
414
- st.session_state.custom_resources[idx] = batch
415
- else:
416
- st.session_state.custom_resources.append(batch)
417
- imported += 1
418
- st.session_state.pop("latest_result", None)
419
- st.success(f"Imported {imported} batch(es).")
420
- st.rerun()
421
-
422
-
423
- # ─── Custom resources panel ───────────────────────────────────────────────────
424
-
425
- def _render_custom_batch_inline(global_idx, batch, cost_sliders, unit):
426
- """Render a single custom batch inside its resource-group expander."""
427
- n_methods = len(batch["methods"])
428
- amount_val = float(batch["amount"])
429
- batch["unit"] = unit # keep in sync for Excel export
430
-
431
- # st.markdown(f"🆕 **{batch['name']}** — CDR methods: **{n_methods}**")
432
- with st.container(border=True):
433
- key_amount = f"custom_amount_{global_idx}"
434
- if key_amount not in st.session_state:
435
- st.session_state[key_amount] = amount_val
436
-
437
- col_amount, _, _, _ = st.columns(4)
438
- with col_amount:
439
- new_amount = st.number_input(
440
- f"{batch['name']}",
441
- min_value=0.0,
442
- format="%.2f",
443
- key=key_amount,
444
- )
445
- if new_amount != amount_val:
446
- st.session_state.custom_resources[global_idx]["amount"] = float(new_amount)
447
- st.session_state.pop("latest_result", None)
448
-
449
- col_min = f"Min ({unit}/MtCO₂)"
450
- col_med = f"Median ({unit}/MtCO₂)"
451
- col_max = f"Max ({unit}/MtCO₂)"
452
- rows = [
453
- {
454
- "Method": m,
455
- col_min: float(c["min"]),
456
- col_med: float(c["median"]),
457
- col_max: float(c["max"]),
458
- }
459
- for m, c in batch["methods"].items()
460
- ]
461
- st.dataframe(
462
- pd.DataFrame(rows),
463
- use_container_width=True,
464
- hide_index=True,
465
- key=f"batch_editor_{global_idx}",
466
- column_config={
467
- "Method": st.column_config.TextColumn(disabled=True),
468
- col_min: st.column_config.NumberColumn(format="%.4f", min_value=0.0),
469
- col_med: st.column_config.NumberColumn(format="%.4f", min_value=0.0),
470
- col_max: st.column_config.NumberColumn(format="%.4f", min_value=0.0),
471
- },
472
- )
473
-
474
- with st.container(key="container-btn-new"):
475
- if st.button("Delete", key=f"del_batch_{global_idx}",
476
- type="primary", icon=":material/delete:"):
477
- st.session_state.custom_resources.pop(global_idx)
478
- st.session_state.pop("latest_result", None)
479
- st.rerun()
480
- if st.button("Edit", key=f"edit_batch_{global_idx}",
481
- type="secondary", icon=":material/edit:"):
482
- dialog_add_custom_resource(cost_sliders, edit_index=global_idx)
483
-
484
-
485
-
486
- def panel_custom_resources_controls(cost_sliders):
487
- """Buttons to add / load / download custom resource batches (no batch display here)."""
488
- init_custom_resources()
489
-
490
- st.markdown('<p class="crra-sub-heading">➕ New resource</p>', unsafe_allow_html=True)
491
- st.markdown(
492
- '<p class="caption"> Add sub-batches for any resource group and manually assign them to specific CDR methods.</p>',
493
- unsafe_allow_html=True,
494
- )
495
- batches = st.session_state.custom_resources
496
-
497
- with st.container(key="container-btn"):
498
- if st.button("New resource", type="tertiary", icon=":material/add:", key="btn-new-resource"):
499
- dialog_add_custom_resource(cost_sliders)
500
- if st.button("Load from Excel", type="tertiary", icon=":material/arrow_upload_progress:", key="btn-load"):
501
- dialog_load_custom_batch()
502
- if batches:
503
- st.download_button(
504
- "Download new resources",
505
- data=to_excel_bytes(batches),
506
- file_name="new_resources.xlsx",
507
- mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
508
- icon=":material/download:",
509
- key="download_button_save_custom",
510
- )
511
-
512
-
513
- # ─── Main render ──────────────────────────────────────────────────────────────
514
-
515
- def render_tab(cost_sliders):
516
- if "resource_caps" not in st.session_state:
517
- st.session_state.resource_caps = {}
518
- for r in resource_order:
519
- if r not in st.session_state.resource_caps:
520
- st.session_state.resource_caps[r] = 0.0
521
- init_custom_resources()
522
-
523
- st.markdown('<h1 class="crra-heading">Resource inputs</h1>', unsafe_allow_html=True)
524
- show_bar()
525
-
526
- st.markdown('<p class="objective_title">OBJECTIVE</p>', unsafe_allow_html=True)
527
- st.markdown(
528
- '<p class="text_with_border">This section lets you define the <strong>total available '
529
- 'quantities of each resource</strong> for the optimization model.</p>',
530
- unsafe_allow_html=True,
531
- )
532
-
533
- with st.container(key="warning_resources"):
534
- st.warning(
535
- "Resources with zero availability will block portfolio feasibility if required by active methods.",
536
- icon=":material/warning:",
537
- )
538
-
539
- st.markdown('<p class="crra-sub-heading">Controls</p>', unsafe_allow_html=True)
540
-
541
- if st.button("RESET ALL", key="btn-reset", icon=":material/reset_settings:"):
542
- for r in st.session_state.resource_caps:
543
- st.session_state.resource_caps[r] = 0.0
544
- st.session_state.custom_resources = []
545
- st.session_state.enabled_methods = {}
546
- st.success("All resource values reset to 0.")
547
- st.rerun()
548
-
549
- st.markdown('<p class="crra-sub-sub-heading">Import resource values</p>', unsafe_allow_html=True)
550
- col_template, col_upload = st.columns([1, 1])
551
- with col_template:
552
- st.markdown("<div class='caption'><p>Use the template below to fill in your resource "
553
- "availability values and import them directly into the tool.</p></div>",
554
- unsafe_allow_html=True)
555
- with open("data/resource_template.xlsx", "rb") as f:
556
- st.download_button(
557
- label="Download Excel Template",
558
- data=f,
559
- file_name="resource_template.xlsx",
560
- mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
561
- icon=":material/download:",
562
- key="download_button_resource_template"
563
- )
564
- with col_upload:
565
- uploaded_file = st.file_uploader(
566
- "Upload your Excel file", type="xlsx",
567
- key=f"resource_uploader_{st.session_state.get('resource_uploader_key', 0)}",
568
- )
569
- if uploaded_file is not None and uploaded_file.name != st.session_state.get("resource_last_loaded_file"):
570
- try:
571
- df = pd.read_excel(uploaded_file)
572
- required_cols = {"Resource", "Available Amount"}
573
- if not required_cols.issubset(df.columns):
574
- raise ValueError(f"Missing columns: {required_cols - set(df.columns)}")
575
- for r in st.session_state.resource_caps:
576
- st.session_state.resource_caps[r] = 0.0
577
- st.session_state[f"cap_{r}"] = 0.0
578
- for _, row in df.iterrows():
579
- r = row["Resource"]
580
- val = row["Available Amount"]
581
- if r not in st.session_state.resource_caps:
582
- continue
583
- try:
584
- val = 0.0 if pd.isna(val) else float(val)
585
- except (ValueError, TypeError):
586
- val = 0.0
587
- st.session_state.resource_caps[r] = val
588
- st.session_state[f"cap_{r}"] = val
589
- st.session_state["resource_last_loaded_file"] = uploaded_file.name
590
- st.success("Resource values imported successfully.", icon="✅")
591
- for key in ["excel_loaded_file", "scenario_name_suggestion",
592
- "scenario_loaded_file", "input_file_name_suggestion", "latest_result"]:
593
- st.session_state.pop(key, None)
594
- st.rerun()
595
- except Exception as e:
596
- st.error(f"Error importing resource values: {e}")
597
- elif st.session_state.get("resource_last_loaded_file"):
598
- st.success(f"Loaded: **{st.session_state['resource_last_loaded_file']}**", icon="✅")
599
-
600
- # Standard resource inputs
601
- st.markdown('<p class="crra-sub-heading">Set available resources for one year</p>', unsafe_allow_html=True)
602
-
603
- # Pré-calcul : pour chaque méthode, la liste de ses ressources primaires (non secondaires)
604
- method_to_primary_resources = {}
605
- for cdr_method, resource_coeffs in cost_sliders.items():
606
- for name in resource_coeffs:
607
- r_clean = strip_unit_suffix(name)
608
- if (cdr_method, r_clean) not in secondary_resources:
609
- method_to_primary_resources.setdefault(cdr_method, []).append(r_clean)
610
-
611
- for r in method_to_primary_resources:
612
- pass # no-op, kept for clarity of intent below
613
-
614
- resource_to_group = {
615
- r: g
616
- for g, rlist in resource_groups.items()
617
- for r in rlist
618
- }
619
-
620
- for group, rlist in resource_groups.items():
621
- unit = resource_unit_map.get(group, "")
622
- with st.expander(f"**{group}** ({unit})", expanded=True):
623
-
624
- cols = st.columns(4)
625
-
626
- for i, r in enumerate(rlist):
627
-
628
- tooltip = resource_tooltips.get(r, None)
629
- name_clean = r.split(" (")[0]
630
-
631
- with cols[i % 4]:
632
-
633
- render_resource_input(name_clean, r, tooltip=tooltip)
634
-
635
- related_methods = {}
636
- related_by_group = {}
637
-
638
- for cdr_method, resource in cost_sliders.items():
639
- for name, coeff in resource.items():
640
- if r == strip_unit_suffix(name):
641
- related_methods[cdr_method] = coeff
642
-
643
- for m in related_methods:
644
- method_group = next(
645
- (g for g, methods in method_groups.items() if m in methods),
646
- "Other"
647
- )
648
- related_by_group.setdefault(method_group, []).append(m)
649
-
650
- related_method_names = list(related_methods.keys())
651
- secondary_for_r = [m for m in related_method_names if (m, r) in secondary_resources]
652
- primary_for_r = [m for m in related_method_names if (m, r) not in secondary_resources]
653
-
654
- if r not in st.session_state.enabled_methods:
655
- st.session_state.enabled_methods[r] = {}
656
- for m in related_method_names:
657
- st.session_state.enabled_methods[r].setdefault(m, True)
658
- popover_key_prefix = f"pop_{r}"
659
-
660
- with st.popover(
661
- f":material/tune: Related CDR Methods",
662
- use_container_width=True,
663
- ):
664
- for method_group, methods in related_by_group.items():
665
- primary_in_group = [m for m in methods if m in primary_for_r]
666
- if not primary_in_group:
667
- continue
668
- st.markdown(f"*{method_group}*")
669
- for m in primary_in_group:
670
- widget_key = f"{popover_key_prefix}_{m}"
671
- if widget_key not in st.session_state:
672
- st.session_state[widget_key] = st.session_state.enabled_methods[r][m]
673
-
674
- checked = st.checkbox(m, key=widget_key)
675
- st.session_state.enabled_methods[r][m] = checked
676
-
677
- if secondary_for_r:
678
- st.caption(
679
- "Secondary methods that can be added in the **Resource Coefficient** tab:\n\n"
680
- + "\n".join(f"- {m}" for m in secondary_for_r)
681
- )
682
-
683
- for m in secondary_for_r:
684
- st.session_state.enabled_methods[r][m] = True
685
-
686
- # Custom batches for this group
687
- group_unit = resource_unit_map.get(group, "")
688
- group_batches = [
689
- (idx, b) for idx, b in enumerate(st.session_state.get("custom_resources", []))
690
- if resource_to_group.get(b["group"]) == group
691
- ]
692
- for global_idx, batch in group_batches:
693
- _render_custom_batch_inline(global_idx, batch, cost_sliders, group_unit)
694
-
695
- _, col_button = st.columns([3,1])
696
- with col_button:
697
- if st.button(
698
- "Add resource", key=f"simple-btn-resource-{group}",
699
- type="tertiary", icon=":material/add:", use_container_width=True
700
- ):
701
- dialog_add_custom_resource(cost_sliders, preset_resources=rlist)
702
-
703
- # ─── Vérification globale : si une méthode n'a plus aucune ressource
704
- # primaire active, on la désactive directement (constraints[m]["active"] = False)
705
- # plutôt que de bloquer le décochage. C'est un état valide : l'utilisateur a
706
- # choisi de retirer cette méthode de l'optimisation pour cette ressource.
707
- method_active_resource_count = {}
708
- for r, methods in st.session_state.enabled_methods.items():
709
- for m, ok in methods.items():
710
- if (m, r) in secondary_resources:
711
- continue
712
- method_active_resource_count.setdefault(m, 0)
713
- if ok:
714
- method_active_resource_count[m] += 1
715
-
716
- # Custom batches are INDEPENDENT of the standard resource toggle: a method
717
- # linked to a custom batch with capacity counts as having an active resource,
718
- # even if it is disabled for the standard reference resource.
719
- for batch in st.session_state.get("custom_resources", []):
720
- if float(batch.get("amount") or 0) <= 0:
721
- continue
722
- for m_name in batch.get("methods", {}).keys():
723
- method_active_resource_count.setdefault(m_name, 0)
724
- method_active_resource_count[m_name] += 1
725
-
726
- methods_with_no_active_resource = [
727
- m for m in method_to_primary_resources
728
- if method_active_resource_count.get(m, 0) == 0
729
- ]
730
-
731
- # Re-enable methods that were auto-disabled but now have at least one active resource
732
- for m in list(st.session_state.auto_disabled_methods):
733
- if method_active_resource_count.get(m, 0) > 0:
734
- if m in st.session_state.constraints:
735
- st.session_state.constraints[m]["active"] = True
736
- toggle_key = f"toggle_{m}"
737
- if toggle_key in st.session_state:
738
- st.session_state[toggle_key] = True
739
- st.session_state.auto_disabled_methods.discard(m)
740
-
741
- # Auto-disable methods that now have no active primary resource
742
- newly_disabled = []
743
- for m in methods_with_no_active_resource:
744
- if st.session_state.constraints.get(m, {}).get("active", True):
745
- st.session_state.constraints.setdefault(m, {"active": True, "cap_type": "percent", "cap_value": 100})
746
- st.session_state.constraints[m]["active"] = False
747
- st.session_state.auto_disabled_methods.add(m)
748
- newly_disabled.append(m)
749
- toggle_key = f"toggle_{m}"
750
- if toggle_key in st.session_state:
751
- st.session_state[toggle_key] = False
752
-
753
- if newly_disabled:
754
- st.info(
755
- "The following methods have no active resource left and were "
756
- "automatically deactivated (see Method constraints tab): "
757
- + ", ".join(newly_disabled),
758
- icon=":material/info:"
759
- )
760
-
761
-
762
- # Controls: New resource / Load / Download
763
- panel_custom_resources_controls(cost_sliders)
764
-
765
- # Summary table
766
- st.markdown('<p class="crra-sub-heading">Summary of available resources</p>', unsafe_allow_html=True)
767
-
768
- resource_to_unit = {
769
- r: resource_unit_map[group]
770
- for group, resources in resource_groups.items()
771
- for r in resources
772
- }
773
-
774
- df_caps = pd.DataFrame.from_dict(
775
- {r: st.session_state.resource_caps[r] for r in resource_order
776
- if st.session_state.resource_caps.get(r, 0.0) > 0},
777
- orient="index", columns=["Available Amount"],
778
- ).reindex(resource_order).dropna()
779
- df_caps["Available Amount"] = [
780
- f"{value:,.2f} {resource_to_unit.get(resource, '?')}"
781
- for resource, value in df_caps["Available Amount"].items()
782
- ]
783
-
784
- custom_rows = []
785
- for batch in st.session_state.get("custom_resources", []):
786
- group = resource_to_group.get(batch["group"])
787
- unit = resource_unit_map.get(group, "?")
788
- custom_rows.append({
789
- "Resource": batch["name"],
790
- "Available Amount": f"{float(batch['amount']):,.2f} {unit}",
791
- })
792
-
793
- if not df_caps.empty or custom_rows:
794
- dfs = []
795
- if not df_caps.empty:
796
- df_std = df_caps.copy()
797
- df_std["Type"] = "Standard"
798
- dfs.append(df_std)
799
- if custom_rows:
800
- df_custom = pd.DataFrame(custom_rows).set_index("Resource")
801
- df_custom["Type"] = "New"
802
- dfs.append(df_custom)
803
- df_all = pd.concat(dfs)[["Available Amount", "Type"]].reset_index().rename(columns={"index": "Resource"})
804
- num_rows = len(df_all)
805
- st.dataframe(
806
- df_all,
807
- use_container_width=True,
808
- hide_index=True,
809
- height=(num_rows * 36 + 36),
810
- column_config={
811
- "Resource": st.column_config.TextColumn("Resource", width="large"),
812
- "Available Amount": st.column_config.TextColumn("Available Amount"),
813
- "Type": st.column_config.TextColumn("Type"),
814
- },
815
- row_height=36
816
- )
817
- else:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
818
  st.error("No resources defined yet. Set availability above to populate the summary table.")
 
1
+ import pandas as pd
2
+ import streamlit as st
3
+
4
+ from config.config import (resource_groups, resource_order, resource_tooltips,
5
+ resource_unit_map, method_groups, CO2_UNIT, secondary_resources)
6
+ from utils.data_utils import render_resource_input, strip_unit_suffix, get_resource_to_group, get_resource_to_unit, get_method_group
7
+ from utils.ui_helpers import show_bar
8
+ from io import BytesIO
9
+
10
+
11
+ def standard_resource_names():
12
+ """Return the set of all standard resource names defined in resource_groups config.
13
+
14
+ Returns:
15
+ set[str]: flat set of every resource name across all resource groups.
16
+ """
17
+ return {r for rlist in resource_groups.values() for r in rlist}
18
+
19
+
20
+ def init_custom_resources():
21
+ """Initialise custom resource session state keys and coerce amounts/coefficients to float.
22
+
23
+ Ensures custom_resources, enabled_methods, and auto_disabled_methods exist in
24
+ session state, then sanitises every batch so numeric fields are float (not str).
25
+
26
+ Returns:
27
+ None
28
+ """
29
+ if "custom_resources" not in st.session_state:
30
+ st.session_state.custom_resources = []
31
+ if "enabled_methods" not in st.session_state:
32
+ st.session_state.enabled_methods = {}
33
+ if "auto_disabled_methods" not in st.session_state:
34
+ st.session_state.auto_disabled_methods = set()
35
+ for batch in st.session_state.custom_resources:
36
+ try:
37
+ batch["amount"] = float(batch["amount"])
38
+ except (ValueError, TypeError):
39
+ batch["amount"] = 0.0
40
+ for m, coefs in batch.get("methods", {}).items():
41
+ for key in ("min", "median", "max"):
42
+ try:
43
+ coefs[key] = float(coefs[key])
44
+ except (ValueError, TypeError):
45
+ coefs[key] = 0.0
46
+
47
+
48
+ @st.dialog("Add a new resource", width="large")
49
+ def dialog_add_custom_resource(cost_sliders, edit_index: int | None = None, preset_resources: list | None = None):
50
+ """Dialog for adding or editing a custom resource batch.
51
+
52
+ Opens a modal form where the user picks a name, reference resource group,
53
+ available quantity, and per-method coefficients (min/median/max).
54
+
55
+ Args:
56
+ cost_sliders (dict): full cost slider config {method: {resource: {min, median, max}}}.
57
+ edit_index (int | None): index into custom_resources to edit; None for a new batch.
58
+ preset_resources (list | None): restrict the group selector to this resource list
59
+ (used when launched from a group expander button).
60
+
61
+ Returns:
62
+ None
63
+ """
64
+ if edit_index is not None and not st.session_state.get(f"_dlg_initialized_{edit_index}"):
65
+ for key in list(st.session_state.keys()):
66
+ if key.startswith(("_dlg_existing_enable_",
67
+ "_dlg_existing_min_", "_dlg_existing_med_", "_dlg_existing_max_",
68
+ "_dlg_new_chk_", "_dlg_new_min_", "_dlg_new_med_", "_dlg_new_max_")):
69
+ del st.session_state[key]
70
+ st.session_state[f"_dlg_initialized_{edit_index}"] = True
71
+
72
+ standard_names = standard_resource_names()
73
+ existing_names = {
74
+ b["name"] for i, b in enumerate(st.session_state.custom_resources)
75
+ if i != edit_index
76
+ }
77
+ existing = st.session_state.custom_resources[edit_index] if edit_index is not None else {}
78
+
79
+ resource_to_group = get_resource_to_group()
80
+
81
+ # If opened from a specific resource-group expander ("+ Add resource" inline
82
+ # button), restrict the reference-resource choice to that group's resources
83
+ # only — the user picked the group by clicking that button.
84
+ group_options = preset_resources if preset_resources else resource_order
85
+ if preset_resources:
86
+ physical_group = resource_to_group.get(preset_resources[0], "")
87
+ st.markdown(
88
+ f'<p class="caption-input">Adding a new resource batch in the <strong>{physical_group}</strong> group.</p>',
89
+ unsafe_allow_html=True,
90
+ )
91
+
92
+ st.markdown('<p class="dialog-title">1 — Name & group</p>', unsafe_allow_html=True)
93
+
94
+ col_name, col_group = st.columns(2)
95
+ with col_name:
96
+ batch_name = st.text_input(
97
+ "Resource name *",
98
+ value=existing.get("name", ""),
99
+ placeholder="e.g. Degraded forest lands – North",
100
+ help="It can't match the name of an existing resource.",
101
+ key="label-input-name"
102
+ )
103
+ with col_group:
104
+ default_group_idx = group_options.index(existing["group"]) if existing.get("group") in group_options else 0
105
+ batch_group = st.selectbox(
106
+ "Resource group *",
107
+ options=group_options,
108
+ index=default_group_idx,
109
+ help="Determines the physical unit of this resource.",
110
+ disabled=bool(preset_resources) and len(group_options) == 1,
111
+ key="label-input-group"
112
+ )
113
+
114
+ group = resource_to_group.get(batch_group)
115
+ unit = resource_unit_map.get(group, "?")
116
+
117
+ st.markdown(f'<p class="caption">Unit for this group: <strong>{unit}</strong></p>', unsafe_allow_html=True)
118
+
119
+ st.markdown('<p class="dialog-title">2 — Available quantity</p>', unsafe_allow_html=True)
120
+
121
+ batch_amount = st.number_input(
122
+ f"Available amount ({unit})",
123
+ min_value=0.0,
124
+ format="%.2f",
125
+ value=float(existing["amount"]) if existing.get("amount") and existing.get("amount") != 0.0 else None,
126
+ help=f"Total quantity available for 1 year, in {unit}.",
127
+ key="label-input-amount",
128
+ placeholder="0.0"
129
+ )
130
+
131
+ st.markdown('<p class="dialog-title">3 — Existing CDR methods for this resource</p>', unsafe_allow_html=True)
132
+
133
+ method_configs = {}
134
+
135
+ all_related_methods = {}
136
+ for cdr_method, resource in cost_sliders.items():
137
+ for name, coeff in resource.items():
138
+ if batch_group == strip_unit_suffix(name):
139
+ all_related_methods[cdr_method] = {
140
+ "min": coeff["min"],
141
+ "median": coeff["median"],
142
+ "max": coeff["max"],
143
+ }
144
+
145
+ # Split into primary (selectable) and secondary (auto-linked in Tab 3)
146
+ primary_related = {
147
+ m: coeff for m, coeff in all_related_methods.items()
148
+ if (m, batch_group) not in secondary_resources
149
+ }
150
+ secondary_related = {
151
+ m: coeff for m, coeff in all_related_methods.items()
152
+ if (m, batch_group) in secondary_resources
153
+ }
154
+
155
+ primary_by_group = {}
156
+ for m, coeff in primary_related.items():
157
+ method_group = get_method_group(m)
158
+ primary_by_group.setdefault(method_group, {})[m] = coeff
159
+
160
+ if not primary_by_group:
161
+ st.markdown('<p class="caption">No primary CDR methods use this resource directly.</p>', unsafe_allow_html=True)
162
+
163
+ for method_group, methods in primary_by_group.items():
164
+ with st.expander(f"**{method_group}**", expanded=False):
165
+ for m, coeff in methods.items():
166
+
167
+ already_selected = m in existing.get("methods", {})
168
+
169
+ if f"_dlg_existing_enable_{m}" not in st.session_state:
170
+ st.session_state[f"_dlg_existing_enable_{m}"] = already_selected
171
+
172
+ enabled = st.checkbox(
173
+ f"{m}",
174
+ key=f"_dlg_existing_enable_{m}",
175
+ )
176
+
177
+ if not enabled:
178
+ continue
179
+
180
+ existing_coeff = existing.get("methods", {}).get(m, coeff)
181
+
182
+ st.warning("Modifying these values only affects this resource batch: the base method coefficients are unchanged.")
183
+ col_min, col_med, col_max = st.columns(3)
184
+ with col_min:
185
+ e_min = st.number_input(
186
+ f"Min ({unit}/{CO2_UNIT})",
187
+ min_value=0.0,
188
+ format="%.10g",
189
+ value=float(existing_coeff["min"]),
190
+ key=f"_dlg_existing_min_{m}",
191
+ )
192
+ with col_med:
193
+ e_med = st.number_input(
194
+ f"Median ({unit}/{CO2_UNIT})",
195
+ min_value=0.0,
196
+ format="%.10g",
197
+ value=float(existing_coeff["median"]),
198
+ key=f"_dlg_existing_med_{m}",
199
+ )
200
+ with col_max:
201
+ e_max = st.number_input(
202
+ f"Max ({unit}/{CO2_UNIT})",
203
+ min_value=0.0,
204
+ format="%.10g",
205
+ value=float(existing_coeff["max"]),
206
+ key=f"_dlg_existing_max_{m}",
207
+ )
208
+ method_configs[m] = {
209
+ "min": e_min,
210
+ "median": e_med,
211
+ "max": e_max,
212
+ }
213
+
214
+ if secondary_related:
215
+ names = "\n".join(f"- **{m}**" for m in secondary_related)
216
+ st.info(
217
+ f"""The following methods use this resource as **secondary** and may be assigned later in the **Resource Coefficients** tab:\n{names}
218
+ """,
219
+ icon=":material/info:",
220
+ )
221
+
222
+
223
+ st.markdown('<p class="dialog-title">4 — Add additional CDR methods</p>', unsafe_allow_html=True)
224
+ st.markdown(f'<p class="caption">Select additional methods and enter their coefficients in {unit}/{CO2_UNIT}.</p>', unsafe_allow_html=True)
225
+
226
+ already_used = set(all_related_methods.keys())
227
+
228
+ for group_name, methods in method_groups.items():
229
+ available_methods = [
230
+ m for m in methods
231
+ if m not in already_used
232
+ ]
233
+ if not available_methods:
234
+ continue
235
+ with st.expander(f"**{group_name}**", expanded=False):
236
+ for m in available_methods:
237
+ enabled = st.checkbox(
238
+ f"**{m}**",
239
+ key=f"_dlg_new_chk_{m}",
240
+ )
241
+ if enabled:
242
+ col_min, col_med, col_max = st.columns(3)
243
+ with col_min:
244
+ v_min = st.number_input(
245
+ f"Min ({unit}/{CO2_UNIT})",
246
+ min_value=0.0,
247
+ value=None,
248
+ format="%.10g",
249
+ key=f"_dlg_new_min_{m}",
250
+ placeholder="0.0000",
251
+ )
252
+ with col_med:
253
+ v_med = st.number_input(
254
+ f"Median ({unit}/{CO2_UNIT})",
255
+ min_value=0.0,
256
+ value=None,
257
+ format="%.10g",
258
+ key=f"_dlg_new_med_{m}",
259
+ placeholder="0.0000",
260
+ )
261
+ with col_max:
262
+ v_max = st.number_input(
263
+ f"Ma ({unit}/{CO2_UNIT})",
264
+ min_value=0.0,
265
+ value=None,
266
+ format="%.10g",
267
+ key=f"_dlg_new_max_{m}",
268
+ placeholder="0.0000",
269
+ )
270
+ method_configs[m] = {
271
+ "min": v_min,
272
+ "median": v_med,
273
+ "max": v_max,
274
+ }
275
+ error_placeholder = st.empty()
276
+ col_save, col_cancel = st.columns([2, 2])
277
+
278
+ with col_cancel:
279
+ if st.button("Cancel", width='stretch', key="btn-reset-cancel"):
280
+ st.rerun()
281
+
282
+ def _validate():
283
+ errors = []
284
+ if not batch_name.strip():
285
+ errors.append("Resource name is required.")
286
+ elif batch_name.strip() in standard_names:
287
+ errors.append(f"**{batch_name}** matches a standard resource name. Choose a different name.")
288
+ elif batch_name.strip() in existing_names:
289
+ errors.append(f"**{batch_name}** already exists. Choose a different name.")
290
+ if not batch_amount or batch_amount <= 0:
291
+ errors.append("Available amount must be greater than 0.")
292
+ # if not method_configs:
293
+ # errors.append("Select at least one CDR method.")
294
+ for m, c in method_configs.items():
295
+ if any(v is None for v in [c["min"], c["median"], c["max"]]):
296
+ errors.append(f"**{m}**: all coefficient fields (min, median, max) are required.")
297
+ continue
298
+ if c["min"] > c["max"]:
299
+ errors.append(f"**{m}**: min ({c['min']}) cannot be greater than max ({c['max']}).")
300
+ if not (c["min"] <= c["median"] <= c["max"]):
301
+ errors.append(f"**{m}**: median must be between min and max.")
302
+ return errors
303
+
304
+ with col_save:
305
+ if st.button("Add to session", type="tertiary", width='stretch', icon=":material/check:", key="btn-new-add"):
306
+ errors = _validate()
307
+ if errors:
308
+ with error_placeholder.container():
309
+ for e in errors:
310
+ st.error(e, icon=":material/error:")
311
+ else:
312
+ batch = {
313
+ "name": batch_name.strip(),
314
+ "group": batch_group,
315
+ "amount": float(batch_amount), # always store as float
316
+ "unit": resource_unit_map.get(resource_to_group.get(batch_group), "?"),
317
+ "methods": method_configs,
318
+ }
319
+ if edit_index is not None:
320
+ st.session_state.custom_resources[edit_index] = batch
321
+ st.session_state.pop(f"custom_amount_{edit_index}", None)
322
+ else:
323
+ new_idx = len(st.session_state.custom_resources)
324
+ st.session_state.custom_resources.append(batch)
325
+ st.session_state.pop(f"custom_amount_{new_idx}", None)
326
+ st.session_state.pop("latest_result", None)
327
+ st.rerun()
328
+
329
+
330
+
331
+ def to_excel_bytes(batches: list) -> bytes:
332
+ """Serialise all custom resource batches to an Excel file (one row per batch × method).
333
+
334
+ Args:
335
+ batches (list): list of custom batch dicts with keys name, group, amount, unit, methods.
336
+
337
+ Returns:
338
+ bytes: raw bytes of the .xlsx workbook.
339
+ """
340
+ rows = []
341
+ for batch in batches:
342
+ for m, coefs in batch["methods"].items():
343
+ rows.append({
344
+ "name": batch["name"],
345
+ "group": batch["group"],
346
+ "amount": float(batch["amount"]),
347
+ "unit": batch["unit"],
348
+ "method": m,
349
+ "min": float(coefs["min"]),
350
+ "median": float(coefs["median"]),
351
+ "max": float(coefs["max"]),
352
+ })
353
+ excel_buffer = BytesIO()
354
+ with pd.ExcelWriter(excel_buffer, engine="openpyxl") as writer:
355
+ pd.DataFrame(rows).to_excel(writer, index=False, sheet_name="custom_resources")
356
+ return excel_buffer.getvalue()
357
+
358
+
359
+ def _parse_custom_excel(file) -> list:
360
+ """Parse a custom resource Excel file into a list of batch dicts.
361
+
362
+ Expects columns: name, group, amount, unit, method, min, median, max.
363
+
364
+ Args:
365
+ file: file-like object pointing to an .xlsx workbook.
366
+
367
+ Returns:
368
+ list[dict]: one dict per unique batch name with keys
369
+ name, group, amount, unit, methods.
370
+
371
+ Raises:
372
+ ValueError: if required columns are missing.
373
+ """
374
+ df = pd.read_excel(file, sheet_name=0)
375
+ required = {"name", "group", "amount", "unit", "method", "min", "median", "max"}
376
+ if not required.issubset(df.columns):
377
+ raise ValueError(f"Missing columns: {required - set(df.columns)}")
378
+ batches = []
379
+ for batch_name in df["name"].unique():
380
+ df_b = df[df["name"] == batch_name]
381
+ methods = {
382
+ row["method"]: {
383
+ "min": float(row["min"]),
384
+ "median": float(row["median"]),
385
+ "max": float(row["max"]),
386
+ }
387
+ for _, row in df_b.iterrows()
388
+ }
389
+ group_name = str(df_b["group"].iloc[0])
390
+ resource_to_group = get_resource_to_group()
391
+ batches.append({
392
+ "name": str(batch_name),
393
+ "group": group_name,
394
+ "amount": float(df_b["amount"].iloc[0]),
395
+ "unit": resource_unit_map.get(resource_to_group.get(group_name), "?"),
396
+ "methods": methods,
397
+ })
398
+ return batches
399
+
400
+
401
+
402
+ @st.dialog("Load custom resource batches", width="large")
403
+ def dialog_load_custom_batch():
404
+ """Dialog for importing custom resource batches from an Excel file.
405
+
406
+ Reads an .xlsx file produced by to_excel_bytes, previews each batch's
407
+ status (new / overwrite / conflict), and merges them into session state
408
+ on confirmation.
409
+
410
+ Returns:
411
+ None
412
+ """
413
+ st.markdown('<p class="caption">Import an Excel file previously exported from the <strong>Custom resource</strong> section.</p>', unsafe_allow_html=True)
414
+
415
+ uploaded = st.file_uploader("Upload Excel file", type=["xlsx"], key="inputs_loader_custom")
416
+ if uploaded is None:
417
+ return
418
+
419
+ try:
420
+ new_batches = _parse_custom_excel(uploaded)
421
+ except Exception as e:
422
+ st.error(f"Error reading file: {e}")
423
+ return
424
+
425
+ standard_names = standard_resource_names()
426
+ existing_names = {b["name"] for b in st.session_state.custom_resources}
427
+
428
+ previews = []
429
+ for batch in new_batches:
430
+ if batch["name"] in standard_names:
431
+ status = "❌ Conflicts with standard resource — will be skipped"
432
+ elif batch["name"] in existing_names:
433
+ status = "⚠️ Already exists — will be overwritten"
434
+ else:
435
+ status = "✅ New"
436
+ previews.append({
437
+ "Resource name": batch["name"],
438
+ "Group": batch["group"],
439
+ "Amount": f"{batch["amount"]} {resource_unit_map.get(batch["group"], "")}",
440
+ "Methods": len(batch["methods"]),
441
+ "Status": status,
442
+ })
443
+ st.dataframe(pd.DataFrame(previews), width='stretch', hide_index=True)
444
+
445
+ if st.button("↩️ Import all batches", type="primary", width='stretch'):
446
+ imported = 0
447
+ for batch in new_batches:
448
+ if batch["name"] in standard_names:
449
+ continue
450
+ if batch["name"] in existing_names:
451
+ idx = next(i for i, b in enumerate(st.session_state.custom_resources)
452
+ if b["name"] == batch["name"])
453
+ st.session_state.custom_resources[idx] = batch
454
+ else:
455
+ st.session_state.custom_resources.append(batch)
456
+ imported += 1
457
+ st.session_state.pop("latest_result", None)
458
+ st.success(f"Imported {imported} batch(es).")
459
+ st.rerun()
460
+
461
+
462
+
463
+ def _render_custom_batch_inline(global_idx, batch, cost_sliders, unit):
464
+ """Render a single custom batch inside its resource-group expander."""
465
+ n_methods = len(batch["methods"])
466
+ amount_val = float(batch["amount"])
467
+ batch["unit"] = unit # keep in sync for Excel export
468
+
469
+ # st.markdown(f"🆕 **{batch['name']}** — CDR methods: **{n_methods}**")
470
+ with st.container(border=True):
471
+ key_amount = f"custom_amount_{global_idx}"
472
+ if key_amount not in st.session_state:
473
+ st.session_state[key_amount] = amount_val
474
+
475
+ col_amount, _, _, _ = st.columns(4)
476
+ with col_amount:
477
+ new_amount = st.number_input(
478
+ f"{batch['name']}",
479
+ min_value=0.0,
480
+ format="%.2f",
481
+ key=key_amount,
482
+ )
483
+ if new_amount != amount_val:
484
+ st.session_state.custom_resources[global_idx]["amount"] = float(new_amount)
485
+ st.session_state.pop("latest_result", None)
486
+
487
+ col_min = f"Min ({unit}/MtCO₂)"
488
+ col_med = f"Median ({unit}/MtCO₂)"
489
+ col_max = f"Max ({unit}/MtCO₂)"
490
+ rows = [
491
+ {
492
+ "Method": m,
493
+ col_min: float(c["min"]),
494
+ col_med: float(c["median"]),
495
+ col_max: float(c["max"]),
496
+ }
497
+ for m, c in batch["methods"].items()
498
+ ]
499
+ st.dataframe(
500
+ pd.DataFrame(rows),
501
+ width='stretch',
502
+ hide_index=True,
503
+ key=f"batch_editor_{global_idx}",
504
+ column_config={
505
+ "Method": st.column_config.TextColumn(disabled=True),
506
+ col_min: st.column_config.NumberColumn(format="%.4f", min_value=0.0),
507
+ col_med: st.column_config.NumberColumn(format="%.4f", min_value=0.0),
508
+ col_max: st.column_config.NumberColumn(format="%.4f", min_value=0.0),
509
+ },
510
+ )
511
+
512
+ with st.container(key=f"container-button-{global_idx}"):
513
+ if st.button("Delete", key=f"del_batch_{global_idx}",
514
+ type="primary", icon=":material/delete:"):
515
+ st.session_state.custom_resources.pop(global_idx)
516
+ st.session_state.pop("latest_result", None)
517
+ st.rerun()
518
+ if st.button("Edit", key=f"edit_batch_{global_idx}",
519
+ type="secondary", icon=":material/edit:"):
520
+ dialog_add_custom_resource(cost_sliders, edit_index=global_idx)
521
+
522
+
523
+
524
+ def panel_custom_resources_controls(cost_sliders):
525
+ """Buttons to add / load / download custom resource batches (no batch display here)."""
526
+ init_custom_resources()
527
+
528
+ st.markdown('<p class="crra-sub-heading">➕ New resource</p>', unsafe_allow_html=True)
529
+ st.markdown(
530
+ '<p class="caption"> Add sub-batches for any resource group and manually assign them to specific CDR methods.</p>',
531
+ unsafe_allow_html=True,
532
+ )
533
+ batches = st.session_state.custom_resources
534
+
535
+ with st.container(key="container-btn"):
536
+ if st.button("New resource", type="tertiary", icon=":material/add:", key="btn-new-resource"):
537
+ dialog_add_custom_resource(cost_sliders)
538
+ if st.button("Load from Excel", type="tertiary", icon=":material/arrow_upload_progress:", key="btn-load"):
539
+ dialog_load_custom_batch()
540
+ if batches:
541
+ st.download_button(
542
+ "Download new resources",
543
+ data=to_excel_bytes(batches),
544
+ file_name="new_resources.xlsx",
545
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
546
+ icon=":material/download:",
547
+ key="download_button_save_custom",
548
+ )
549
+
550
+
551
+
552
+ def render_tab(cost_sliders):
553
+ """Render the Resource Inputs tab.
554
+
555
+ Shows per-resource number inputs grouped by resource group, custom batch
556
+ controls, an upload/download template workflow, and a summary table. Also
557
+ auto-deactivates CDR methods whose primary resources are all disabled.
558
+
559
+ Args:
560
+ cost_sliders (dict): full cost slider config {method: {resource: {min, median, max}}}.
561
+
562
+ Returns:
563
+ None
564
+ """
565
+ if "resource_caps" not in st.session_state:
566
+ st.session_state.resource_caps = {}
567
+ for r in resource_order:
568
+ if r not in st.session_state.resource_caps:
569
+ st.session_state.resource_caps[r] = 0.0
570
+ init_custom_resources()
571
+
572
+ st.markdown('<h1 class="crra-heading">Resource inputs</h1>', unsafe_allow_html=True)
573
+ show_bar()
574
+
575
+ st.markdown('<p class="objective_title">OBJECTIVE</p>', unsafe_allow_html=True)
576
+ st.markdown(
577
+ '<p class="text_with_border">This section lets you define the <strong>total available '
578
+ 'quantities of each resource</strong> for the optimization model.</p>',
579
+ unsafe_allow_html=True,
580
+ )
581
+
582
+ with st.container(key="warning_resources"):
583
+ st.warning(
584
+ "Resources with zero availability will block portfolio feasibility if required by active methods.",
585
+ icon=":material/warning:",
586
+ )
587
+
588
+ st.markdown('<p class="crra-sub-heading">Controls</p>', unsafe_allow_html=True)
589
+
590
+ if st.button("RESET ALL", key="btn-reset", icon=":material/reset_settings:"):
591
+ for r in st.session_state.resource_caps:
592
+ st.session_state.resource_caps[r] = 0.0
593
+ st.session_state.custom_resources = []
594
+ st.session_state.enabled_methods = {}
595
+ st.success("All resource values reset to 0.")
596
+ st.rerun()
597
+
598
+ st.markdown('<p class="crra-sub-sub-heading">Import resource values</p>', unsafe_allow_html=True)
599
+ col_template, col_upload = st.columns([1, 1])
600
+ with col_template:
601
+ st.markdown("<div class='caption'><p>Use the template below to fill in your resource "
602
+ "availability values and import them directly into the tool.</p></div>",
603
+ unsafe_allow_html=True)
604
+ with open("data/resource_template.xlsx", "rb") as f:
605
+ st.download_button(
606
+ label="Download Excel Template",
607
+ data=f,
608
+ file_name="resource_template.xlsx",
609
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
610
+ icon=":material/download:",
611
+ key="download_button_resource_template"
612
+ )
613
+ with col_upload:
614
+ uploaded_file = st.file_uploader(
615
+ "Upload your Excel file", type="xlsx",
616
+ key=f"resource_uploader_{st.session_state.get('resource_uploader_key', 0)}"
617
+ )
618
+ if uploaded_file is not None and uploaded_file.name != st.session_state.get("resource_last_loaded_file"):
619
+ try:
620
+ df = pd.read_excel(uploaded_file)
621
+ required_cols = {"Resource", "Available Amount"}
622
+ if not required_cols.issubset(df.columns):
623
+ raise ValueError(f"Missing columns: {required_cols - set(df.columns)}")
624
+ for r in st.session_state.resource_caps:
625
+ st.session_state.resource_caps[r] = 0.0
626
+ st.session_state[f"cap_{r}"] = 0.0
627
+ for _, row in df.iterrows():
628
+ r = row["Resource"]
629
+ val = row["Available Amount"]
630
+ if r not in st.session_state.resource_caps:
631
+ continue
632
+ try:
633
+ val = 0.0 if pd.isna(val) else float(val)
634
+ except (ValueError, TypeError):
635
+ val = 0.0
636
+ st.session_state.resource_caps[r] = val
637
+ st.session_state[f"cap_{r}"] = val
638
+ st.session_state["resource_last_loaded_file"] = uploaded_file.name
639
+ st.success("Resource values imported successfully.", icon="✅")
640
+ for key in ["excel_loaded_file", "scenario_name_suggestion",
641
+ "scenario_loaded_file", "input_file_name_suggestion", "latest_result"]:
642
+ st.session_state.pop(key, None)
643
+ st.rerun()
644
+ except Exception as e:
645
+ st.error(f"Error importing resource values: {e}")
646
+ elif st.session_state.get("resource_last_loaded_file"):
647
+ st.success(f"Loaded: **{st.session_state['resource_last_loaded_file']}**", icon="✅")
648
+
649
+ # Standard resource inputs
650
+ st.markdown('<p class="crra-sub-heading">Set available resources for one year</p>', unsafe_allow_html=True)
651
+
652
+ # Build a lookup: for each CDR method, which resources are "primary" (mandatory)?
653
+ # Secondary resources are optional add-ons — a method can still run without them.
654
+ # A method can only run in the optimisation if at least one primary resource is available.
655
+ method_to_primary_resources = {}
656
+ for cdr_method, resource_coeffs in cost_sliders.items():
657
+ for name in resource_coeffs:
658
+ r_clean = strip_unit_suffix(name)
659
+ if (cdr_method, r_clean) not in secondary_resources:
660
+ method_to_primary_resources.setdefault(cdr_method, []).append(r_clean)
661
+
662
+ for r in method_to_primary_resources:
663
+ pass # no-op, kept for clarity of intent below
664
+
665
+ resource_to_group = get_resource_to_group()
666
+
667
+ for group, rlist in resource_groups.items():
668
+ unit = resource_unit_map.get(group, "")
669
+ with st.expander(f"**{group}** ({unit})", expanded=True):
670
+
671
+ cols = st.columns(4)
672
+
673
+ for i, r in enumerate(rlist):
674
+
675
+ tooltip = resource_tooltips.get(r, None)
676
+ name_clean = r.split(" (")[0]
677
+
678
+ with cols[i % 4]:
679
+
680
+ render_resource_input(name_clean, r, tooltip=tooltip)
681
+
682
+ related_methods = {}
683
+ related_by_group = {}
684
+
685
+ for cdr_method, resource in cost_sliders.items():
686
+ for name, coeff in resource.items():
687
+ if r == strip_unit_suffix(name):
688
+ related_methods[cdr_method] = coeff
689
+
690
+ for m in related_methods:
691
+ related_by_group.setdefault(get_method_group(m), []).append(m)
692
+
693
+ related_method_names = list(related_methods.keys())
694
+ secondary_for_r = [m for m in related_method_names if (m, r) in secondary_resources]
695
+ primary_for_r = [m for m in related_method_names if (m, r) not in secondary_resources]
696
+
697
+ if r not in st.session_state.enabled_methods:
698
+ st.session_state.enabled_methods[r] = {}
699
+ for m in related_method_names:
700
+ st.session_state.enabled_methods[r].setdefault(m, True)
701
+ popover_key_prefix = f"pop_{r}"
702
+
703
+ with st.popover(
704
+ f":material/tune: Related CDR Methods",
705
+ width='stretch',
706
+ ):
707
+ for method_group, methods in related_by_group.items():
708
+ primary_in_group = [m for m in methods if m in primary_for_r]
709
+ if not primary_in_group:
710
+ continue
711
+ st.markdown(f"*{method_group}*")
712
+ for m in primary_in_group:
713
+ widget_key = f"{popover_key_prefix}_{m}"
714
+ if widget_key not in st.session_state:
715
+ st.session_state[widget_key] = st.session_state.enabled_methods[r][m]
716
+
717
+ checked = st.checkbox(m, key=widget_key)
718
+ st.session_state.enabled_methods[r][m] = checked
719
+
720
+ if secondary_for_r:
721
+ st.caption(
722
+ "Secondary methods that can be added in the **Resource Coefficient** tab:\n\n"
723
+ + "\n".join(f"- {m}" for m in secondary_for_r)
724
+ )
725
+
726
+ for m in secondary_for_r:
727
+ st.session_state.enabled_methods[r][m] = True
728
+
729
+ # Custom batches for this group
730
+ group_unit = resource_unit_map.get(group, "")
731
+ group_batches = [
732
+ (idx, b) for idx, b in enumerate(st.session_state.get("custom_resources", []))
733
+ if resource_to_group.get(b["group"]) == group
734
+ ]
735
+ for global_idx, batch in group_batches:
736
+ _render_custom_batch_inline(global_idx, batch, cost_sliders, group_unit)
737
+
738
+ _, col_button = st.columns([3,1])
739
+ with col_button:
740
+ if st.button(
741
+ "Add resource", key=f"simple-btn-resource-{group}",
742
+ type="tertiary", icon=":material/add:", width='stretch'
743
+ ):
744
+ dialog_add_custom_resource(cost_sliders, preset_resources=rlist)
745
+
746
+ # ── AUTO-DISABLE LOGIC ────────────────────────────────────────────────────
747
+ # A CDR method cannot run in the optimisation if it has no usable resources.
748
+ # This block detects that situation automatically and deactivates the method
749
+ # in the Method Constraints tab — so the optimisation never fails silently.
750
+ #
751
+ # The logic runs in three steps every time this tab renders:
752
+ # 1. Count how many active primary resources each method still has.
753
+ # 2. Re-enable any method that was auto-disabled and now has resources again
754
+ # (e.g. the user re-entered a value). Methods the user manually disabled
755
+ # are stored separately and are NOT touched here.
756
+ # 3. Disable any method whose resource count just dropped to zero, and show
757
+ # a notification so the user knows what changed.
758
+ # ─────────────────────────────────────────────────────────────────────────
759
+
760
+ # Step 1 – count active primary resources per method (secondary resources are
761
+ # optional extras; they do not determine whether a method can run).
762
+ method_active_resource_count = {}
763
+ for r, methods in st.session_state.enabled_methods.items():
764
+ for m, ok in methods.items():
765
+ if (m, r) in secondary_resources:
766
+ continue
767
+ method_active_resource_count.setdefault(m, 0)
768
+ if ok:
769
+ method_active_resource_count[m] += 1
770
+
771
+ # Custom batches also count as active resources for the methods they are
772
+ # assigned to, even when the standard reference resource is toggled off.
773
+ for batch in st.session_state.get("custom_resources", []):
774
+ if float(batch.get("amount") or 0) <= 0:
775
+ continue
776
+ for m_name in batch.get("methods", {}).keys():
777
+ method_active_resource_count.setdefault(m_name, 0)
778
+ method_active_resource_count[m_name] += 1
779
+
780
+ methods_with_no_active_resource = [
781
+ m for m in method_to_primary_resources
782
+ if method_active_resource_count.get(m, 0) == 0
783
+ ]
784
+
785
+ # Step 2 – re-enable methods that the tool previously auto-disabled but that
786
+ # now have at least one active resource (user restored a value or added a batch).
787
+ for m in list(st.session_state.auto_disabled_methods):
788
+ if method_active_resource_count.get(m, 0) > 0:
789
+ if m in st.session_state.constraints:
790
+ st.session_state.constraints[m]["active"] = True
791
+ toggle_key = f"toggle_{m}"
792
+ if toggle_key in st.session_state:
793
+ st.session_state[toggle_key] = True
794
+ st.session_state.auto_disabled_methods.discard(m)
795
+
796
+ # Step 3 – disable methods that now have zero active primary resources.
797
+ # We record which methods the tool disabled (auto_disabled_methods) so that
798
+ # step 2 can safely re-enable them later without touching user-chosen settings.
799
+ newly_disabled = []
800
+ for m in methods_with_no_active_resource:
801
+ if st.session_state.constraints.get(m, {}).get("active", True):
802
+ st.session_state.constraints.setdefault(m, {"active": True, "cap_type": "percent", "cap_value": 100})
803
+ st.session_state.constraints[m]["active"] = False
804
+ st.session_state.auto_disabled_methods.add(m)
805
+ newly_disabled.append(m)
806
+ toggle_key = f"toggle_{m}"
807
+ if toggle_key in st.session_state:
808
+ st.session_state[toggle_key] = False
809
+
810
+ if newly_disabled:
811
+ st.info(
812
+ "The following methods have no active resource left and were "
813
+ "automatically deactivated (see Method constraints tab): "
814
+ + ", ".join(newly_disabled),
815
+ icon=":material/info:"
816
+ )
817
+
818
+
819
+ panel_custom_resources_controls(cost_sliders)
820
+
821
+ st.markdown('<p class="crra-sub-heading">Summary of available resources</p>', unsafe_allow_html=True)
822
+
823
+ resource_to_unit = get_resource_to_unit()
824
+
825
+ df_caps = pd.DataFrame.from_dict(
826
+ {r: st.session_state.resource_caps[r] for r in resource_order
827
+ if st.session_state.resource_caps.get(r, 0.0) > 0},
828
+ orient="index", columns=["Available Amount"],
829
+ ).reindex(resource_order).dropna()
830
+ df_caps["Available Amount"] = [
831
+ f"{value:,.2f} {resource_to_unit.get(resource, '?')}"
832
+ for resource, value in df_caps["Available Amount"].items()
833
+ ]
834
+
835
+ custom_rows = []
836
+ for batch in st.session_state.get("custom_resources", []):
837
+ group = resource_to_group.get(batch["group"])
838
+ unit = resource_unit_map.get(group, "?")
839
+ custom_rows.append({
840
+ "Resource": batch["name"],
841
+ "Available Amount": f"{float(batch['amount']):,.2f} {unit}",
842
+ })
843
+
844
+ if not df_caps.empty or custom_rows:
845
+ dfs = []
846
+ if not df_caps.empty:
847
+ df_std = df_caps.copy()
848
+ df_std["Type"] = "Standard"
849
+ dfs.append(df_std)
850
+ if custom_rows:
851
+ df_custom = pd.DataFrame(custom_rows).set_index("Resource")
852
+ df_custom["Type"] = "New"
853
+ dfs.append(df_custom)
854
+ df_all = pd.concat(dfs)[["Available Amount", "Type"]].reset_index().rename(columns={"index": "Resource"})
855
+ num_rows = len(df_all)
856
+ st.dataframe(
857
+ df_all,
858
+ width='stretch',
859
+ hide_index=True,
860
+ height=(num_rows * 36 + 36),
861
+ column_config={
862
+ "Resource": st.column_config.TextColumn("Resource", width="large"),
863
+ "Available Amount": st.column_config.TextColumn("Available Amount"),
864
+ "Type": st.column_config.TextColumn("Type"),
865
+ },
866
+ row_height=36
867
+ )
868
+ else:
869
  st.error("No resources defined yet. Set availability above to populate the summary table.")
tabs/tab2_constraints.py CHANGED
@@ -1,188 +1,201 @@
1
- # tabs/tab2_constraints.py
2
-
3
- import pandas as pd
4
- import streamlit as st
5
-
6
- from config.config import CO2_UNIT, method_groups, method_order, method_tooltips
7
- from utils.ui_helpers import show_bar
8
-
9
-
10
- # ─── Helpers ─────────────────────────────────────────────────────────────────
11
-
12
- def default_constraint():
13
- return {"active": True, "cap_type": "percent", "cap_value": int(100)}
14
-
15
-
16
- def render_method(m, cols, i):
17
- with cols[i % 3]:
18
- tooltip = method_tooltips.get(m, None)
19
-
20
- # ─────────────────────────────────────────
21
- # INIT SAFE
22
- # ─────────────────────────────────────────
23
- if m not in st.session_state.constraints:
24
- st.session_state.constraints[m] = default_constraint()
25
-
26
- constraint = st.session_state.constraints[m]
27
-
28
- toggle_key = f"toggle_{m}"
29
- cap_type_key = f"cap_type_{m}"
30
- slider_key = f"slider_{m}"
31
- manual_key = f"manual_{m}"
32
-
33
- # init widgets ONLY from constraint (not reverse)
34
- st.session_state.setdefault(toggle_key, constraint["active"])
35
- st.session_state.setdefault(cap_type_key, constraint["cap_type"])
36
-
37
- if constraint["cap_type"] == "percent":
38
- st.session_state.setdefault(slider_key, int(constraint["cap_value"]))
39
- else:
40
- st.session_state.setdefault(manual_key, float(constraint["cap_value"]))
41
-
42
- # ─────────────────────────────────────────
43
- # TOGGLE
44
- # ─────────────────────────────────────────
45
- is_active = st.toggle(m, key=toggle_key, help=tooltip)
46
-
47
- st.session_state.constraints[m]["active"] = is_active
48
-
49
- if not is_active:
50
- return
51
-
52
- # ─────────────────────────────────────────
53
- # CAP TYPE
54
- # ─────────────────────────────────────────
55
- cap_type = st.radio(
56
- "Cap value type",
57
- ["percent", "absolute"],
58
- horizontal=True,
59
- key=cap_type_key,
60
- format_func=lambda x: "% of total potential"
61
- if x == "percent"
62
- else f"Absolute ({CO2_UNIT}/yr)",
63
- label_visibility="collapsed",
64
- )
65
-
66
- st.session_state.constraints[m]["cap_type"] = cap_type
67
-
68
- # ─────────────────────────────────────────
69
- # PERCENT
70
- # ─────────────────────────────────────────
71
- if cap_type == "percent":
72
-
73
- value = st.slider(
74
- "Max share (%)",
75
- 0, 100, step=5,
76
- key=slider_key,
77
- )
78
-
79
- st.session_state.constraints[m]["cap_value"] = value
80
-
81
- if value == 0:
82
- st.warning("Cap is 0% but method is active.", icon=":material/warning:")
83
-
84
- # ─────────────────────────────────────────
85
- # ABSOLUTE
86
- # ─────────────────────────────────────────
87
- else:
88
- value = st.number_input(
89
- f"Max cap ({CO2_UNIT}/yr)",
90
- min_value=0.0,
91
- format="%.2f",
92
- value=None,
93
- placeholder="0.00",
94
- key=manual_key,
95
- )
96
-
97
- st.session_state.constraints[m]["cap_value"] = float(value) if value is not None else 0.0
98
-
99
- if not value:
100
- st.warning(f"Cap is 0 {CO2_UNIT}/yr but method is active.", icon=":material/warning:")
101
-
102
- def reset_all_constraints():
103
- for m in method_order:
104
- st.session_state.constraints[m] = default_constraint()
105
- for key in [f"toggle_type_{m}", f"cap_type_{m}", f"slider_{m}_widget", f"manual_{m}_widget"]:
106
- st.session_state.pop(key, None)
107
- st.success("All method constraints reset to default.")
108
- st.rerun()
109
-
110
-
111
- def build_summary_df():
112
- """Build summary dataframe for active methods."""
113
- rows = {}
114
- for m in method_order:
115
- c = st.session_state.constraints[m]
116
- if not c["active"]:
117
- continue
118
- if c["cap_type"] == "percent":
119
- rows[m] = {"Cap value": f"{c['cap_value']}%"}
120
- else:
121
- rows[m] = {"Cap value": f"{c['cap_value']:,.2f} {CO2_UNIT}/yr"}
122
- return pd.DataFrame.from_dict(rows, orient="index") if rows else None
123
-
124
-
125
- # ─── Main render ─────────────────────────────────────────────────────────────
126
-
127
- def render_tab():
128
- st.markdown('<h1 class="crra-heading">Method constraints</h1>', unsafe_allow_html=True)
129
- show_bar()
130
-
131
- st.markdown(
132
- """
133
- <p class="objective_title">OBJECTIVE</p>
134
- """,
135
- unsafe_allow_html=True,
136
- )
137
-
138
- st.markdown(
139
- """
140
- <div class="text_with_border">
141
- <p>
142
- This section lets you control which CDR methods are included in the optimization
143
- and set a <strong>maximum deployment cap</strong> per method.<br>
144
- <li>Activate or deactivate individual methods</li>
145
- <li>Cap each method by <strong>% of its total potential</strong> or by an
146
- <strong>absolute value</strong> in {CO2_UNIT}/yr</li>
147
- </p>
148
- </div>
149
- """.format(CO2_UNIT=CO2_UNIT),
150
- unsafe_allow_html=True,
151
- )
152
-
153
- with st.expander("Manage methods", expanded=True, icon=":material/settings:"):
154
- for group, method_list in method_groups.items():
155
- st.markdown(f"""
156
- <p class="crra-group-name">{group}</p>""",
157
- unsafe_allow_html=True,
158
- )
159
- cols = st.columns(3)
160
- for i, m in enumerate(method_list):
161
- render_method(m, cols, i)
162
-
163
- st.divider()
164
- if st.button("RESET ALL", key="btn-reset", icon=":material/reset_settings:"):
165
- reset_all_constraints()
166
-
167
- st.markdown("""
168
- <p class="crra-sub-heading">Summary of Active methods and caps</p>""",
169
- unsafe_allow_html=True,
170
- )
171
- df = build_summary_df()
172
- if df is not None:
173
- df_display = df.reset_index().rename(columns={"index": "CDR Method"})
174
- df_display = df_display[["CDR Method", "Cap value"]]
175
- num_rows = len(df_display)
176
- st.dataframe(
177
- df_display,
178
- use_container_width=True,
179
- hide_index=True,
180
- height=(num_rows * 36 + 36),
181
- column_config={
182
- "CDR Method": st.column_config.TextColumn("CDR Method", width="large"),
183
- "Cap value": st.column_config.TextColumn("Cap value"),
184
- },
185
- row_height=36
186
- )
187
- else:
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  st.warning("No active methods selected.")
 
1
+ import pandas as pd
2
+ import streamlit as st
3
+
4
+ from config.config import CO2_UNIT, method_groups, method_order, method_tooltips
5
+ from utils.ui_helpers import show_bar
6
+
7
+
8
+
9
+ def default_constraint():
10
+ """Return the default constraint dict for a CDR method (active, 100% cap).
11
+
12
+ Returns:
13
+ dict: {"active": True, "cap_type": "percent", "cap_value": 100}.
14
+ """
15
+ return {"active": True, "cap_type": "percent", "cap_value": int(100)}
16
+
17
+
18
+ def render_method(m, cols, i):
19
+ """Render the constraint widget group (toggle + cap slider/input) for one method.
20
+
21
+ Writes the result into st.session_state.constraints[m].
22
+
23
+ Args:
24
+ m (str): CDR method name.
25
+ cols (list): list of Streamlit column objects.
26
+ i (int): index used to pick the column (i % 3).
27
+
28
+ Returns:
29
+ None
30
+ """
31
+ with cols[i % 3]:
32
+ tooltip = method_tooltips.get(m, None)
33
+
34
+ if m not in st.session_state.constraints:
35
+ st.session_state.constraints[m] = default_constraint()
36
+
37
+ constraint = st.session_state.constraints[m]
38
+
39
+ toggle_key = f"toggle_{m}"
40
+ cap_type_key = f"cap_type_{m}"
41
+ slider_key = f"slider_{m}"
42
+ manual_key = f"manual_{m}"
43
+
44
+ # init widgets ONLY from constraint (not reverse)
45
+ st.session_state.setdefault(toggle_key, constraint["active"])
46
+ st.session_state.setdefault(cap_type_key, constraint["cap_type"])
47
+
48
+ if constraint["cap_type"] == "percent":
49
+ st.session_state.setdefault(slider_key, int(constraint["cap_value"]))
50
+ else:
51
+ st.session_state.setdefault(manual_key, float(constraint["cap_value"]))
52
+
53
+ is_active = st.toggle(m, key=toggle_key, help=tooltip)
54
+
55
+ st.session_state.constraints[m]["active"] = is_active
56
+
57
+ if not is_active:
58
+ return
59
+
60
+ cap_type = st.radio(
61
+ "Cap value type",
62
+ ["percent", "absolute"],
63
+ horizontal=True,
64
+ key=cap_type_key,
65
+ format_func=lambda x: "% of total potential"
66
+ if x == "percent"
67
+ else f"Absolute ({CO2_UNIT}/yr)",
68
+ label_visibility="collapsed",
69
+ )
70
+
71
+ st.session_state.constraints[m]["cap_type"] = cap_type
72
+
73
+ if cap_type == "percent":
74
+
75
+ value = st.slider(
76
+ "Max share (%)",
77
+ 0, 100, step=5,
78
+ key=slider_key,
79
+ )
80
+
81
+ st.session_state.constraints[m]["cap_value"] = value
82
+
83
+ if value == 0:
84
+ st.warning("Cap is 0% but method is active.", icon=":material/warning:")
85
+
86
+ else:
87
+ value = st.number_input(
88
+ f"Max cap ({CO2_UNIT}/yr)",
89
+ min_value=0.0,
90
+ format="%.2f",
91
+ value=None,
92
+ placeholder="0.00",
93
+ key=manual_key,
94
+ )
95
+
96
+ st.session_state.constraints[m]["cap_value"] = float(value) if value is not None else 0.0
97
+
98
+ if not value:
99
+ st.warning(f"Cap is 0 {CO2_UNIT}/yr but method is active.", icon=":material/warning:")
100
+
101
+ def reset_all_constraints():
102
+ """Reset all method constraints to their default values and rerun the app.
103
+
104
+ Returns:
105
+ None
106
+ """
107
+ for m in method_order:
108
+ st.session_state.constraints[m] = default_constraint()
109
+ for key in [f"toggle_type_{m}", f"cap_type_{m}", f"slider_{m}_widget", f"manual_{m}_widget"]:
110
+ st.session_state.pop(key, None)
111
+ st.success("All method constraints reset to default.")
112
+ st.rerun()
113
+
114
+
115
+ def build_summary_df():
116
+ """Build a summary DataFrame of currently active methods and their caps.
117
+
118
+ Returns:
119
+ pd.DataFrame | None: index = method name, column "Cap value" as a formatted
120
+ string. Returns None if no method is active.
121
+ """
122
+ rows = {}
123
+ for m in method_order:
124
+ c = st.session_state.constraints[m]
125
+ if not c["active"]:
126
+ continue
127
+ if c["cap_type"] == "percent":
128
+ rows[m] = {"Cap value": f"{c['cap_value']}%"}
129
+ else:
130
+ rows[m] = {"Cap value": f"{c['cap_value']:,.2f} {CO2_UNIT}/yr"}
131
+ return pd.DataFrame.from_dict(rows, orient="index") if rows else None
132
+
133
+
134
+
135
+ def render_tab():
136
+ """Render the Method Constraints tab.
137
+
138
+ Returns:
139
+ None
140
+ """
141
+ st.markdown('<h1 class="crra-heading">Method constraints</h1>', unsafe_allow_html=True)
142
+ show_bar()
143
+
144
+ st.markdown(
145
+ """
146
+ <p class="objective_title">OBJECTIVE</p>
147
+ """,
148
+ unsafe_allow_html=True,
149
+ )
150
+
151
+ st.markdown(
152
+ """
153
+ <div class="text_with_border">
154
+ <p>
155
+ This section lets you control which CDR methods are included in the optimization
156
+ and set a <strong>maximum deployment cap</strong> per method.<br>
157
+ <li>Activate or deactivate individual methods</li>
158
+ <li>Cap each method by <strong>% of its total potential</strong> or by an
159
+ <strong>absolute value</strong> in {CO2_UNIT}/yr</li>
160
+ </p>
161
+ </div>
162
+ """.format(CO2_UNIT=CO2_UNIT),
163
+ unsafe_allow_html=True,
164
+ )
165
+
166
+ with st.expander("Manage methods", expanded=True, icon=":material/settings:"):
167
+ for group, method_list in method_groups.items():
168
+ st.markdown(f"""
169
+ <p class="crra-group-name">{group}</p>""",
170
+ unsafe_allow_html=True,
171
+ )
172
+ cols = st.columns(3)
173
+ for i, m in enumerate(method_list):
174
+ render_method(m, cols, i)
175
+
176
+ st.divider()
177
+ if st.button("RESET ALL", key="btn-reset", icon=":material/reset_settings:"):
178
+ reset_all_constraints()
179
+
180
+ st.markdown("""
181
+ <p class="crra-sub-heading">Summary of Active methods and caps</p>""",
182
+ unsafe_allow_html=True,
183
+ )
184
+ df = build_summary_df()
185
+ if df is not None:
186
+ df_display = df.reset_index().rename(columns={"index": "CDR Method"})
187
+ df_display = df_display[["CDR Method", "Cap value"]]
188
+ num_rows = len(df_display)
189
+ st.dataframe(
190
+ df_display,
191
+ width='stretch',
192
+ hide_index=True,
193
+ height=(num_rows * 36 + 36),
194
+ column_config={
195
+ "CDR Method": st.column_config.TextColumn("CDR Method", width="large"),
196
+ "Cap value": st.column_config.TextColumn("Cap value"),
197
+ },
198
+ row_height=36
199
+ )
200
+ else:
201
  st.warning("No active methods selected.")
tabs/tab3_coefficients.py CHANGED
@@ -1,422 +1,510 @@
1
- import pandas as pd
2
- import streamlit as st
3
-
4
- from config.config import (method_groups, method_order, resource_groups,
5
- resource_order, secondary_resources, resource_unit_map, CO2_UNIT)
6
- from utils.ui_helpers import format_efficiency_summary, show_bar
7
- from utils.data_utils import strip_unit_suffix
8
-
9
- def _normalize_costs(costs_dict: dict) -> dict:
10
- """Strip unit suffixes from resource keys for legacy scenario compatibility."""
11
- return {strip_unit_suffix(r): v for r, v in costs_dict.items()}
12
-
13
- def _get_slider_params(conf: dict) -> tuple:
14
- """Return (step, fmt) based on the min value of a slider config."""
15
- min_val = conf["min"]
16
- if min_val < 0.01:
17
- return 0.0001, "%.4f"
18
- elif min_val < 1:
19
- return 0.001, "%.3f"
20
- elif min_val < 100:
21
- return 0.01, "%.2f"
22
- else:
23
- return 1.0, "%.0f"
24
-
25
- def is_method_enabled(method, resource, enabled_methods):
26
- return enabled_methods.get(resource, {}).get(method, True)
27
-
28
- def _make_sync_s_to_m(slider_key, manual_key):
29
- """on_change for slider: mirror its value into the number_input."""
30
- def _sync():
31
- st.session_state[manual_key] = st.session_state[slider_key]
32
- return _sync
33
-
34
- def _make_sync_m_to_s(slider_key, manual_key, min_val, max_val):
35
- """on_change for number_input: clamp to [min, max] and sync both widgets."""
36
- def _sync():
37
- val = st.session_state[manual_key]
38
- if val is None:
39
- return
40
- clamped = max(min_val, min(max_val, val))
41
- st.session_state[manual_key] = clamped
42
- st.session_state[slider_key] = clamped
43
- return _sync
44
-
45
-
46
- def _enforce_disabled_coefficients():
47
- # Intentionally a no-op: disabling a method for a resource must NOT permanently
48
- # zero the stored coefficient in session state, because re-enabling would then
49
- # see an existing key at 0 and the pre-init would skip restoration.
50
- # The LP in optimization.py already applies enabled_methods at call time
51
- # (zeroing only for that run, without mutating session state).
52
- pass
53
-
54
-
55
- def render_tab(cost_sliders):
56
- st.markdown('<h1 class="crra-heading">Resource efficiency coefficients</h1>', unsafe_allow_html=True)
57
- show_bar()
58
-
59
- st.markdown(
60
- """
61
- <p class="objective_title">OBJECTIVE</p>
62
- """,
63
- unsafe_allow_html=True,
64
- )
65
-
66
- st.markdown(
67
- """
68
- <div class="text_with_border">
69
- <p>
70
- This section lets you adjust the resource usage coefficients for each CDR method to best fit your country conditions.<br>
71
- <li>Primary resources are always active and initialized with default values.</li>
72
- <li>Secondary resources are initialized at zero and toggled off by default.</li>
73
- <li>Toggling on a secondary resource restores its default value.</li>
74
- </p>
75
- </div>
76
- """.format(CO2_UNIT=CO2_UNIT),
77
- unsafe_allow_html=True,
78
- )
79
-
80
- with st.container(key="warning_resources"):
81
- st.warning(
82
- "Red cells in the table highlight coefficients modified from their **default median values**",
83
- icon=":material/warning:"
84
- )
85
-
86
- # Read the live, per-resource manual allocation state (set in tab1) and the
87
- # custom resource batches — both are read fresh on every render, not cached
88
- # at import time, so toggling a method on/off in tab1 is reflected here
89
- # immediately without needing a session restart.
90
- enabled_methods = st.session_state.get("enabled_methods", {})
91
- custom_resources = st.session_state.get("custom_resources", [])
92
-
93
- _enforce_disabled_coefficients()
94
-
95
- # Normalize cost_sliders once: strip unit suffixes from resource keys
96
- # cost_sliders_norm[method][resource_name] = {min, median, max}
97
- cost_sliders_norm = {
98
- m: _normalize_costs(resources)
99
- for m, resources in cost_sliders.items()
100
- }
101
-
102
- # Build resource display labels: "Arable land" -> "Arable land (Mha/MtCO2)"
103
- resource_display = {
104
- r: f"{r} ({resource_unit_map[cat]}/{CO2_UNIT})"
105
- for cat, resources in resource_groups.items()
106
- for r in resources
107
- }
108
-
109
- # Custom (user-added) resources: name -> {method: {min, median, max}}.
110
- # Primary links come from the dialog (batch["methods"]).
111
- # Secondary links are auto-injected: when the reference resource (batch["group"])
112
- # is secondary for a method, inherit that method's coefficients automatically.
113
- custom_sliders_by_method = {}
114
- custom_resource_names = set()
115
- for batch in custom_resources:
116
- r_name = batch["name"]
117
- custom_resource_names.add(r_name)
118
- # Explicit primary links chosen in the dialog
119
- for m, coefs in batch["methods"].items():
120
- custom_sliders_by_method.setdefault(m, {})[r_name] = coefs
121
- # Auto-inject secondary method links from reference resource cost_sliders
122
- ref = batch["group"]
123
- for m, resources in cost_sliders_norm.items():
124
- if (m, ref) in secondary_resources and r_name not in custom_sliders_by_method.get(m, {}):
125
- ref_coefs = resources.get(ref)
126
- if ref_coefs:
127
- custom_sliders_by_method.setdefault(m, {})[r_name] = ref_coefs
128
-
129
- # custom resource name → reference resource name (batch["group"])
130
- custom_resource_to_sub_group = {
131
- batch["name"]: batch["group"]
132
- for batch in custom_resources
133
- }
134
- resource_to_group = {
135
- r: group
136
- for group, resources in resource_groups.items()
137
- for r in resources
138
- }
139
- custom_resource_to_group = {
140
- r: resource_to_group[s_g]
141
- for r, s_g in custom_resource_to_sub_group.items()
142
- if s_g in resource_to_group
143
- }
144
-
145
- def is_custom_secondary(method_name, custom_r):
146
- """True if the reference resource for custom_r is secondary for method_name."""
147
- sub_group = custom_resource_to_sub_group.get(custom_r)
148
- return sub_group is not None and (method_name, sub_group) in secondary_resources
149
-
150
- # Build reverse index: custom resource name → set of valid methods
151
- valid_methods_for_custom = {}
152
- for m, resources in custom_sliders_by_method.items():
153
- for r in resources:
154
- valid_methods_for_custom.setdefault(r, set()).add(m)
155
-
156
- # Remove stale session state entries: a method should not carry a custom
157
- # resource coefficient if it is not linked to that batch (neither as
158
- # explicit primary nor as secondary auto-injection). Stale values can
159
- # persist across reruns and cause the LP to create spurious z-pairs.
160
- for r_name, valid in valid_methods_for_custom.items():
161
- for m in list(st.session_state.get("costs", {}).keys()):
162
- if r_name in st.session_state.costs.get(m, {}) and m not in valid:
163
- del st.session_state.costs[m][r_name]
164
-
165
- # Pre-initialize costs for custom resources so the summary table shows the
166
- # correct value immediately, without needing to open the expander first.
167
- # Secondary custom resources start at 0 (like standard secondary resources).
168
- for m, resources in custom_sliders_by_method.items():
169
- st.session_state.costs.setdefault(m, {})
170
- for r, conf in resources.items():
171
- if r not in st.session_state.costs[m]:
172
- st.session_state.costs[m][r] = 0.0 if is_custom_secondary(m, r) else conf["median"]
173
-
174
- # ── Method selector ──────────────────────────────────────────────────────
175
- method_choices = []
176
- method_lookup = {}
177
- for group, methods in method_groups.items():
178
- for method in methods:
179
- label = f"{group} | {method}"
180
- method_choices.append(label)
181
- method_lookup[label] = method
182
-
183
- selected_label = st.selectbox("Select a method:", method_choices, key="crra-select")
184
-
185
- if st.button("RESET ALL", key="btn-reset", icon=":material/reset_settings:"):
186
- for m, sliders in cost_sliders_norm.items():
187
- for r, conf in sliders.items():
188
- is_secondary = (m, r) in secondary_resources
189
- st.session_state.costs[m][r] = 0.0 if is_secondary else conf["median"]
190
- for prefix in ["slider", "manual", "toggle"]:
191
- st.session_state.pop(f"{prefix}_{m}_{r}".replace(" ", "_").lower(), None)
192
- for m, sliders in custom_sliders_by_method.items():
193
- for r, conf in sliders.items():
194
- st.session_state.costs.setdefault(m, {})[r] = 0.0 if is_custom_secondary(m, r) else conf["median"]
195
- for prefix in ["slider", "manual", "toggle"]:
196
- st.session_state.pop(f"{prefix}_{m}_{r}".replace(" ", "_").lower(), None)
197
- st.success("All methods reset to default values.")
198
- st.rerun()
199
-
200
- method = method_lookup[selected_label]
201
-
202
- # ── Resource sliders ─────────────────────────────────────────────────────
203
- with st.expander(f"Manage resources for **{method}**", expanded=True):
204
- method_sliders = cost_sliders_norm.get(method, {})
205
- print(method_sliders)
206
- method_costs_norm = _normalize_costs(st.session_state.costs.get(method, {}))
207
- custom_for_method = custom_sliders_by_method.get(method, {})
208
-
209
- for group_name, rlist in resource_groups.items():
210
- used = [r for r in rlist if r in method_sliders]
211
- print(used)
212
- custom_in_group = [
213
- r for r in custom_for_method
214
- if custom_resource_to_group.get(r) == group_name
215
- ] if custom_for_method else []
216
-
217
- if not used and not custom_in_group:
218
- continue
219
- st.markdown(f"""
220
- <p class="crra-group-name">{group_name}</p>""",
221
- unsafe_allow_html=True,
222
- )
223
- for r in used:
224
- enabled = is_method_enabled(method, r, enabled_methods)
225
- if not enabled:
226
- st.info(f"INFO: Method disabled for **{r}** (see Resource Inputs tab)")
227
- continue
228
- conf = method_sliders[r]
229
- display_label = resource_display.get(r, r)
230
- step, fmt = _get_slider_params(conf)
231
- is_secondary = (method, r) in secondary_resources
232
-
233
- toggle_key = f"toggle_{method}_{r}".replace(" ", "_").lower()
234
- slider_key = f"slider_{method}_{r}".replace(" ", "_").lower()
235
- manual_key = f"manual_{method}_{r}".replace(" ", "_").lower()
236
-
237
- current_val = method_costs_norm.get(r, 0.0)
238
-
239
- if is_secondary:
240
- if toggle_key not in st.session_state:
241
- st.session_state[toggle_key] = current_val != 0.0
242
- enabled = st.checkbox(f"Use {display_label}", key=toggle_key)
243
- if not enabled:
244
- st.session_state.costs[method][r] = 0.0
245
- st.markdown("**DISABLED**")
246
- continue
247
- elif current_val == 0.0:
248
- st.session_state.costs[method][r] = conf["median"]
249
- current_val = conf["median"]
250
-
251
- if conf["min"] == conf["max"]:
252
- st.markdown(f"""<p class="crra-resource-name">{display_label} = <strong>{conf['median']}</strong> (fixed)</p>""", unsafe_allow_html=True)
253
- st.session_state.costs[method][r] = conf["median"]
254
- if st.session_state.get("resource_caps", {}).get(r, 0) <= 0:
255
- st.info(f"No available quantity set for **{r}** in Resource Inputs tab: **{method}** won't be used in the optimization.", icon=":material/info:")
256
- continue
257
-
258
- if slider_key not in st.session_state:
259
- st.session_state[slider_key] = current_val
260
- if manual_key not in st.session_state:
261
- st.session_state[manual_key] = st.session_state[slider_key]
262
- print(r)
263
- if st.session_state.get("resource_caps", {}).get(r, 0) <= 0:
264
- st.info(f"No available quantity set for **{r}** in Resource Inputs tab: **{method}** won't be used in the optimization.", icon=":material/info:")
265
-
266
- col1, col2 = st.columns([3, 1])
267
- with col1:
268
- slider_val = st.slider(
269
- label=display_label,
270
- min_value=conf["min"],
271
- max_value=conf["max"],
272
- step=step,
273
- format=fmt,
274
- key=slider_key,
275
- on_change=_make_sync_s_to_m(slider_key, manual_key),
276
- )
277
- with col2:
278
- st.number_input(
279
- label="label",
280
- label_visibility="collapsed",
281
- key=manual_key,
282
- help="Manual override (optional, any value)",
283
- format=fmt,
284
- value=None,
285
- placeholder=str(round(slider_val, 4)),
286
- on_change=_make_sync_m_to_s(slider_key, manual_key, conf["min"], conf["max"]),
287
- )
288
-
289
- _mv = st.session_state.get(manual_key)
290
- st.session_state.costs[method][r] = slider_val if _mv is None else _mv
291
-
292
-
293
- # ── New (custom) resources linked to this method ────────────────
294
-
295
- for r in custom_in_group:
296
- conf = custom_for_method[r]
297
- enabled = is_method_enabled(method, r, enabled_methods)
298
- if not enabled:
299
- st.info("INFO: Method disabled for this resource (see Resource Inputs tab)")
300
- continue
301
-
302
- _is_cust_secondary = is_custom_secondary(method, r)
303
- display_label = r
304
- step, fmt = _get_slider_params(conf)
305
- toggle_key = f"toggle_{method}_{r}".replace(" ", "_").lower()
306
- slider_key = f"slider_{method}_{r}".replace(" ", "_").lower()
307
- manual_key = f"manual_{method}_{r}".replace(" ", "_").lower()
308
-
309
- current_val = st.session_state.costs.get(method, {}).get(r, 0.0 if _is_cust_secondary else conf["median"])
310
-
311
- if _is_cust_secondary:
312
- if toggle_key not in st.session_state:
313
- st.session_state[toggle_key] = current_val != 0.0
314
- use_resource = st.checkbox(f"Use {display_label}", key=toggle_key)
315
- if not use_resource:
316
- st.session_state.costs.setdefault(method, {})[r] = 0.0
317
- st.markdown("**DISABLED**")
318
- continue
319
- elif current_val == 0.0:
320
- st.session_state.costs.setdefault(method, {})[r] = conf["median"]
321
- current_val = conf["median"]
322
-
323
- if slider_key not in st.session_state:
324
- st.session_state[slider_key] = current_val
325
- if manual_key not in st.session_state:
326
- st.session_state[manual_key] = st.session_state[slider_key]
327
-
328
- batch_amount = next((float(b.get("amount", 0)) for b in custom_resources if b["name"] == r), 0.0)
329
- if batch_amount <= 0:
330
- st.info(f"No available quantity set for **{r}** in Resource Inputs — this method won't be used in the optimization.", icon=":material/info:")
331
-
332
- col1, col2 = st.columns([3, 1])
333
- with col1:
334
- slider_val = st.slider(
335
- label=display_label,
336
- min_value=conf["min"],
337
- max_value=conf["max"],
338
- step=step,
339
- format=fmt,
340
- key=slider_key,
341
- on_change=_make_sync_s_to_m(slider_key, manual_key),
342
- )
343
- with col2:
344
- st.number_input(
345
- label="label",
346
- label_visibility="collapsed",
347
- key=manual_key,
348
- help="Manual override (optional, any value)",
349
- format=fmt,
350
- value=None,
351
- placeholder=str(round(slider_val, 4)),
352
- on_change=_make_sync_m_to_s(slider_key, manual_key, conf["min"], conf["max"]),
353
- )
354
-
355
- st.session_state.costs.setdefault(method, {})
356
- _mv = st.session_state.get(manual_key)
357
- st.session_state.costs[method][r] = slider_val if _mv is None else _mv
358
-
359
-
360
- # ── Reset buttons ────────────────────────────────────────────────────
361
- if st.button("Reset selected method", key="btn-reset-method", icon=":material/reset_settings:"):
362
- for r, conf in cost_sliders_norm.get(method, {}).items():
363
- is_secondary = (method, r) in secondary_resources
364
- st.session_state.costs[method][r] = 0.0 if is_secondary else conf["median"]
365
- for prefix in ["slider", "manual", "toggle"]:
366
- st.session_state.pop(f"{prefix}_{method}_{r}".replace(" ", "_").lower(), None)
367
- for r, conf in custom_sliders_by_method.get(method, {}).items():
368
- st.session_state.costs.setdefault(method, {})[r] = conf["median"]
369
- for prefix in ["slider", "manual"]:
370
- st.session_state.pop(f"{prefix}_{method}_{r}".replace(" ", "_").lower(), None)
371
- st.success(f"{method} reset to default values.")
372
- st.rerun()
373
-
374
- # ── Summary table ─────────────────────────────────────────────────────────
375
- st.markdown("""
376
- <p class="crra-sub-heading">Summary of resource usage coefficients</p>""",
377
- unsafe_allow_html=True,
378
- )
379
- all_resource_columns = resource_order + sorted(custom_resource_names)
380
-
381
- df_default = pd.DataFrame(index=method_order, columns=all_resource_columns)
382
- df_current = pd.DataFrame(index=method_order, columns=all_resource_columns)
383
-
384
- for m in method_order:
385
- current_costs = _normalize_costs(st.session_state.costs.get(m, {}))
386
- sliders = dict(cost_sliders_norm.get(m, {}))
387
- sliders.update(custom_sliders_by_method.get(m, {}))
388
-
389
- for r in all_resource_columns:
390
- is_secondary = (m, r) in secondary_resources or is_custom_secondary(m, r)
391
- disabled = not is_method_enabled(m, r, enabled_methods)
392
- current_val = 0.0 if disabled else current_costs.get(r, 0.0)
393
- default_val = 0.0 if disabled else sliders.get(r, {}).get("median", 0.0)
394
-
395
- if is_secondary and current_val == 0.0:
396
- default_val = 0.0
397
-
398
- df_default.at[m, r] = default_val
399
- df_current.at[m, r] = current_val
400
-
401
- df_default = df_default.astype(float).fillna(0.0)
402
- df_current = df_current.astype(float).fillna(0.0)
403
-
404
- resource_display_full = dict(resource_display)
405
- resource_display_full.update({r: r for r in custom_resource_names})
406
-
407
- df_default = df_default.rename(columns=resource_display_full)
408
- df_current = df_current.rename(columns=resource_display_full)
409
-
410
- styled = format_efficiency_summary(df_current, df_default)
411
- num_rows = len(df_current)
412
- st.dataframe(
413
- data=styled,
414
- height=(num_rows * 36 + 37),
415
- use_container_width=True,
416
- column_config={
417
- "_index": st.column_config.TextColumn(
418
- "Method",
419
- width="large"
420
- )},
421
- row_height=36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  )
 
1
+ import pandas as pd
2
+ import streamlit as st
3
+
4
+ from config.config import (method_groups, method_order, resource_groups,
5
+ resource_order, secondary_resources, resource_unit_map, CO2_UNIT)
6
+ from utils.ui_helpers import format_efficiency_summary, show_bar
7
+ from utils.data_utils import strip_unit_suffix, get_resource_to_group
8
+
9
+
10
+ def normalize_costs(costs_dict: dict) -> dict:
11
+ """Strip unit suffixes from resource keys for legacy scenario compatibility."""
12
+ return {strip_unit_suffix(r): v for r, v in costs_dict.items()}
13
+
14
+
15
+ def get_slider_params(conf: dict) -> tuple:
16
+ """Derive slider step size and display format from a coefficient config dict.
17
+
18
+ Args:
19
+ conf (dict): slider config with key "min" (the minimum coefficient value).
20
+
21
+ Returns:
22
+ tuple[float, str]: (step, fmt) where step is the slider increment and
23
+ fmt is a printf-style format string for display.
24
+ """
25
+ min_coeff = conf["min"]
26
+ if min_coeff < 0.01:
27
+ return 0.0001, "%.4f"
28
+ elif min_coeff < 1:
29
+ return 0.001, "%.3f"
30
+ elif min_coeff < 100:
31
+ return 0.01, "%.2f"
32
+ else:
33
+ return 1.0, "%.0f"
34
+
35
+
36
+ def is_method_enabled(method, resource, enabled_methods):
37
+ """Check whether a method is currently enabled for a given resource.
38
+
39
+ Args:
40
+ method (str): CDR method name.
41
+ resource (str): resource name.
42
+ enabled_methods (dict): {resource: {method: bool}} from session state.
43
+
44
+ Returns:
45
+ bool: True if the method is enabled (defaults to True if not explicitly set).
46
+ """
47
+ return enabled_methods.get(resource, {}).get(method, True)
48
+
49
+
50
+ def is_custom_secondary(method_name, custom_r, custom_resource_to_sub_group):
51
+ """Return True if the reference resource for custom_r is secondary for method_name.
52
+
53
+ Args:
54
+ method_name (str): CDR method name.
55
+ custom_r (str): custom batch resource name.
56
+ custom_resource_to_sub_group (dict): {custom_r: reference_resource}.
57
+
58
+ Returns:
59
+ bool
60
+ """
61
+ sub_group = custom_resource_to_sub_group.get(custom_r)
62
+ return sub_group is not None and (method_name, sub_group) in secondary_resources
63
+
64
+
65
+ def make_sync_s_to_m(slider_key, manual_key):
66
+ """on_change for slider: mirror its value into the number_input."""
67
+ def sync():
68
+ st.session_state[manual_key] = st.session_state[slider_key]
69
+ return sync
70
+
71
+
72
+ def make_sync_m_to_s(slider_key, manual_key, min_val, max_val):
73
+ """on_change for number_input: clamp to [min, max] and sync both widgets."""
74
+ def sync():
75
+ val = st.session_state[manual_key]
76
+ if val is None:
77
+ return
78
+ clamped = max(min_val, min(max_val, val))
79
+ st.session_state[manual_key] = clamped
80
+ st.session_state[slider_key] = clamped
81
+ return sync
82
+
83
+
84
+ def build_custom_slider_index(cost_sliders_norm, custom_resources):
85
+ """Build the per-method slider configs and group mappings for custom resource batches.
86
+
87
+ Also initialises session-state cost entries for custom resources (primary at
88
+ median, secondary at 0) and removes stale entries for methods no longer linked
89
+ to a given batch.
90
+
91
+ Args:
92
+ cost_sliders_norm (dict): {method: {resource: {min, median, max}}} with unit suffixes stripped.
93
+ custom_resources (list): list of custom batch dicts from session state.
94
+
95
+ Returns:
96
+ tuple[dict, set, dict, dict]:
97
+ custom_sliders_by_method – {method: {resource: {min, median, max}}}.
98
+ custom_resource_names – set of all custom batch names.
99
+ custom_resource_to_sub_group – {custom_r: reference_resource}.
100
+ custom_resource_to_group – {custom_r: physical_group}.
101
+ """
102
+ custom_sliders_by_method = {}
103
+ custom_resource_names = set()
104
+ for batch in custom_resources:
105
+ r_name = batch["name"]
106
+ custom_resource_names.add(r_name)
107
+ for m, coefs in batch["methods"].items():
108
+ custom_sliders_by_method.setdefault(m, {})[r_name] = coefs
109
+ ref = batch["group"]
110
+ for m, resources in cost_sliders_norm.items():
111
+ if (m, ref) in secondary_resources and r_name not in custom_sliders_by_method.get(m, {}):
112
+ ref_coefs = resources.get(ref)
113
+ if ref_coefs:
114
+ custom_sliders_by_method.setdefault(m, {})[r_name] = ref_coefs
115
+
116
+ custom_resource_to_sub_group = {batch["name"]: batch["group"] for batch in custom_resources}
117
+ resource_to_group = get_resource_to_group()
118
+ custom_resource_to_group = {
119
+ r: resource_to_group[s_g]
120
+ for r, s_g in custom_resource_to_sub_group.items()
121
+ if s_g in resource_to_group
122
+ }
123
+
124
+ # Remove stale cost entries for methods no longer linked to a batch
125
+ valid_methods_for_custom = {}
126
+ for m, resources in custom_sliders_by_method.items():
127
+ for r in resources:
128
+ valid_methods_for_custom.setdefault(r, set()).add(m)
129
+ for r_name, valid in valid_methods_for_custom.items():
130
+ for m in list(st.session_state.get("costs", {}).keys()):
131
+ if r_name in st.session_state.costs.get(m, {}) and m not in valid:
132
+ del st.session_state.costs[m][r_name]
133
+
134
+ # Pre-initialise cost entries for custom resources
135
+ for m, resources in custom_sliders_by_method.items():
136
+ st.session_state.costs.setdefault(m, {})
137
+ for r, conf in resources.items():
138
+ if r not in st.session_state.costs[m]:
139
+ st.session_state.costs[m][r] = (
140
+ 0.0 if is_custom_secondary(m, r, custom_resource_to_sub_group) else conf["median"]
141
+ )
142
+
143
+ return custom_sliders_by_method, custom_resource_names, custom_resource_to_sub_group, custom_resource_to_group
144
+
145
+
146
+ def render_standard_resource_row(method, r, conf, display_label, enabled_methods):
147
+ """Render the slider row for a single standard resource coefficient.
148
+
149
+ Handles the secondary-resource toggle, fixed-value display, and the
150
+ slider + manual number_input pair. Writes the result to
151
+ st.session_state.costs[method][r].
152
+
153
+ Args:
154
+ method (str): CDR method name.
155
+ r (str): resource name.
156
+ conf (dict): {min, median, max} for this (method, resource) pair.
157
+ display_label (str): human-readable resource label with unit.
158
+ enabled_methods (dict): {resource: {method: bool}} from session state.
159
+
160
+ Returns:
161
+ None
162
+ """
163
+ if not is_method_enabled(method, r, enabled_methods):
164
+ st.info(f"INFO: Method disabled for **{r}** (see Resource Inputs tab)")
165
+ return
166
+
167
+ is_secondary = (method, r) in secondary_resources
168
+ step, fmt = get_slider_params(conf)
169
+ toggle_key = f"toggle_{method}_{r}".replace(" ", "_").lower()
170
+ slider_key = f"slider_{method}_{r}".replace(" ", "_").lower()
171
+ manual_key = f"manual_{method}_{r}".replace(" ", "_").lower()
172
+ current_val = normalize_costs(st.session_state.costs.get(method, {})).get(r, 0.0)
173
+
174
+ if is_secondary:
175
+ if toggle_key not in st.session_state:
176
+ st.session_state[toggle_key] = current_val != 0.0
177
+ if not st.checkbox(f"Use {display_label}", key=toggle_key):
178
+ st.session_state.costs[method][r] = 0.0
179
+ st.markdown("**DISABLED**")
180
+ return
181
+ elif current_val == 0.0:
182
+ st.session_state.costs[method][r] = conf["median"]
183
+ current_val = conf["median"]
184
+
185
+ if conf["min"] == conf["max"]:
186
+ st.markdown(
187
+ f'<p class="crra-resource-name">{display_label} = <strong>{conf["median"]}</strong> (fixed)</p>',
188
+ unsafe_allow_html=True,
189
+ )
190
+ st.session_state.costs[method][r] = conf["median"]
191
+ if st.session_state.get("resource_caps", {}).get(r, 0) <= 0:
192
+ st.info(
193
+ f"No available quantity set for **{r}** in Resource Inputs tab: "
194
+ f"**{method}** won't be used in the optimization.",
195
+ icon=":material/info:",
196
+ )
197
+ return
198
+
199
+ if slider_key not in st.session_state:
200
+ st.session_state[slider_key] = current_val
201
+ if manual_key not in st.session_state:
202
+ st.session_state[manual_key] = st.session_state[slider_key]
203
+ if st.session_state.get("resource_caps", {}).get(r, 0) <= 0:
204
+ st.info(
205
+ f"No available quantity set for **{r}** in Resource Inputs tab: "
206
+ f"**{method}** won't be used in the optimization.",
207
+ icon=":material/info:",
208
+ )
209
+
210
+ col1, col2 = st.columns([3, 1])
211
+ with col1:
212
+ slider_val = st.slider(
213
+ label=display_label,
214
+ min_value=conf["min"], max_value=conf["max"], step=step, format=fmt,
215
+ key=slider_key,
216
+ on_change=make_sync_s_to_m(slider_key, manual_key),
217
+ )
218
+ with col2:
219
+ st.number_input(
220
+ label="label", label_visibility="collapsed",
221
+ key=manual_key, help="Manual override (optional, any value)",
222
+ format=fmt, value=None, placeholder=str(round(slider_val, 4)),
223
+ on_change=make_sync_m_to_s(slider_key, manual_key, conf["min"], conf["max"]),
224
+ )
225
+
226
+ manual_value = st.session_state.get(manual_key)
227
+ st.session_state.costs[method][r] = slider_val if manual_value is None else manual_value
228
+
229
+
230
+ def render_custom_resource_row(method, r, conf, enabled_methods, custom_resource_to_sub_group, custom_resources):
231
+ """Render the slider row for a single custom resource batch coefficient.
232
+
233
+ Args:
234
+ method (str): CDR method name.
235
+ r (str): custom resource batch name.
236
+ conf (dict): {min, median, max} for this (method, resource) pair.
237
+ enabled_methods (dict): {resource: {method: bool}} from session state.
238
+ custom_resource_to_sub_group (dict): {custom_r: reference_resource}.
239
+ custom_resources (list): list of custom batch dicts from session state.
240
+
241
+ Returns:
242
+ None
243
+ """
244
+ if not is_method_enabled(method, r, enabled_methods):
245
+ st.info("INFO: Method disabled for this resource (see Resource Inputs tab)")
246
+ return
247
+
248
+ is_sec = is_custom_secondary(method, r, custom_resource_to_sub_group)
249
+ step, fmt = get_slider_params(conf)
250
+ toggle_key = f"toggle_{method}_{r}".replace(" ", "_").lower()
251
+ slider_key = f"slider_{method}_{r}".replace(" ", "_").lower()
252
+ manual_key = f"manual_{method}_{r}".replace(" ", "_").lower()
253
+ current_val = st.session_state.costs.get(method, {}).get(r, 0.0 if is_sec else conf["median"])
254
+
255
+ if is_sec:
256
+ if toggle_key not in st.session_state:
257
+ st.session_state[toggle_key] = current_val != 0.0
258
+ if not st.checkbox(f"Use {r}", key=toggle_key):
259
+ st.session_state.costs.setdefault(method, {})[r] = 0.0
260
+ st.markdown("**DISABLED**")
261
+ return
262
+ elif current_val == 0.0:
263
+ st.session_state.costs.setdefault(method, {})[r] = conf["median"]
264
+ current_val = conf["median"]
265
+
266
+ if slider_key not in st.session_state:
267
+ st.session_state[slider_key] = current_val
268
+ if manual_key not in st.session_state:
269
+ st.session_state[manual_key] = st.session_state[slider_key]
270
+
271
+ batch_amount = next((float(b.get("amount", 0)) for b in custom_resources if b["name"] == r), 0.0)
272
+ if batch_amount <= 0:
273
+ st.info(
274
+ f"No available quantity set for **{r}** in Resource Inputs — "
275
+ "this method won't be used in the optimization.",
276
+ icon=":material/info:",
277
+ )
278
+
279
+ col1, col2 = st.columns([3, 1])
280
+ with col1:
281
+ slider_val = st.slider(
282
+ label=r,
283
+ min_value=conf["min"], max_value=conf["max"], step=step, format=fmt,
284
+ key=slider_key,
285
+ on_change=make_sync_s_to_m(slider_key, manual_key),
286
+ )
287
+ with col2:
288
+ st.number_input(
289
+ label="label", label_visibility="collapsed",
290
+ key=manual_key, help="Manual override (optional, any value)",
291
+ format=fmt, value=None, placeholder=str(round(slider_val, 4)),
292
+ on_change=make_sync_m_to_s(slider_key, manual_key, conf["min"], conf["max"]),
293
+ )
294
+
295
+ st.session_state.costs.setdefault(method, {})
296
+ manual_value = st.session_state.get(manual_key)
297
+ st.session_state.costs[method][r] = slider_val if manual_value is None else manual_value
298
+
299
+
300
+ def panel_method_editor(
301
+ method, cost_sliders_norm, custom_sliders_by_method,
302
+ resource_display, enabled_methods, custom_resources,
303
+ custom_resource_to_group, custom_resource_to_sub_group,
304
+ ):
305
+ """Render the coefficient-editing expander for one CDR method.
306
+
307
+ Shows one slider row per standard resource, then one per custom resource batch,
308
+ grouped by resource group. Includes a per-method reset button.
309
+
310
+ Args:
311
+ method (str): CDR method name.
312
+ cost_sliders_norm (dict): {method: {resource: {min, median, max}}}.
313
+ custom_sliders_by_method (dict): {method: {resource: {min, median, max}}}.
314
+ resource_display (dict): {resource: label_with_unit}.
315
+ enabled_methods (dict): {resource: {method: bool}} from session state.
316
+ custom_resources (list): list of custom batch dicts from session state.
317
+ custom_resource_to_group (dict): {custom_r: physical_group}.
318
+ custom_resource_to_sub_group (dict): {custom_r: reference_resource}.
319
+
320
+ Returns:
321
+ None
322
+ """
323
+ with st.expander(f"Manage resources for **{method}**", expanded=True):
324
+ method_sliders = cost_sliders_norm.get(method, {})
325
+ custom_for_method = custom_sliders_by_method.get(method, {})
326
+
327
+ for group_name, rlist in resource_groups.items():
328
+ used = [r for r in rlist if r in method_sliders]
329
+ custom_in_group = [
330
+ r for r in custom_for_method
331
+ if custom_resource_to_group.get(r) == group_name
332
+ ]
333
+ if not used and not custom_in_group:
334
+ continue
335
+
336
+ st.markdown(f'<p class="crra-group-name">{group_name}</p>', unsafe_allow_html=True)
337
+
338
+ for r in used:
339
+ conf = method_sliders[r]
340
+ render_standard_resource_row(
341
+ method, r, conf, resource_display.get(r, r), enabled_methods
342
+ )
343
+
344
+ for r in custom_in_group:
345
+ render_custom_resource_row(
346
+ method, r, custom_for_method[r],
347
+ enabled_methods, custom_resource_to_sub_group, custom_resources
348
+ )
349
+
350
+ if st.button("Reset selected method", key="btn-reset-method", icon=":material/reset_settings:"):
351
+ for r, conf in cost_sliders_norm.get(method, {}).items():
352
+ is_secondary = (method, r) in secondary_resources
353
+ st.session_state.costs[method][r] = 0.0 if is_secondary else conf["median"]
354
+ for prefix in ["slider", "manual", "toggle"]:
355
+ st.session_state.pop(f"{prefix}_{method}_{r}".replace(" ", "_").lower(), None)
356
+ for r, conf in custom_sliders_by_method.get(method, {}).items():
357
+ st.session_state.costs.setdefault(method, {})[r] = conf["median"]
358
+ for prefix in ["slider", "manual"]:
359
+ st.session_state.pop(f"{prefix}_{method}_{r}".replace(" ", "_").lower(), None)
360
+ st.success(f"{method} reset to default values.")
361
+ st.rerun()
362
+
363
+
364
+ def panel_summary_table(
365
+ cost_sliders_norm, custom_sliders_by_method,
366
+ enabled_methods, custom_resource_names, custom_resource_to_sub_group,
367
+ ):
368
+ """Render the full coefficient summary table with cells highlighted when modified.
369
+
370
+ Args:
371
+ cost_sliders_norm (dict): {method: {resource: {min, median, max}}}.
372
+ custom_sliders_by_method (dict): {method: {resource: {min, median, max}}}.
373
+ enabled_methods (dict): {resource: {method: bool}} from session state.
374
+ custom_resource_names (set[str]): names of all custom resource batches.
375
+ custom_resource_to_sub_group (dict): {custom_r: reference_resource}.
376
+
377
+ Returns:
378
+ None
379
+ """
380
+ st.markdown(
381
+ '<p class="crra-sub-heading">Summary of resource usage coefficients</p>',
382
+ unsafe_allow_html=True,
383
+ )
384
+ resource_display = {
385
+ r: f"{r} ({resource_unit_map[cat]}/{CO2_UNIT})"
386
+ for cat, resources in resource_groups.items()
387
+ for r in resources
388
+ }
389
+ all_resource_columns = resource_order + sorted(custom_resource_names)
390
+
391
+ df_default = pd.DataFrame(index=method_order, columns=all_resource_columns)
392
+ df_current = pd.DataFrame(index=method_order, columns=all_resource_columns)
393
+
394
+ for m in method_order:
395
+ current_costs = normalize_costs(st.session_state.costs.get(m, {}))
396
+ sliders = dict(cost_sliders_norm.get(m, {}))
397
+ sliders.update(custom_sliders_by_method.get(m, {}))
398
+
399
+ for r in all_resource_columns:
400
+ is_sec = (m, r) in secondary_resources or is_custom_secondary(m, r, custom_resource_to_sub_group)
401
+ disabled = not is_method_enabled(m, r, enabled_methods)
402
+ current_val = 0.0 if disabled else current_costs.get(r, 0.0)
403
+ default_val = 0.0 if disabled else sliders.get(r, {}).get("median", 0.0)
404
+ if is_sec and current_val == 0.0:
405
+ default_val = 0.0
406
+ df_default.at[m, r] = default_val
407
+ df_current.at[m, r] = current_val
408
+
409
+ df_default = df_default.astype(float).fillna(0.0)
410
+ df_current = df_current.astype(float).fillna(0.0)
411
+
412
+ resource_display_full = dict(resource_display)
413
+ resource_display_full.update({r: r for r in custom_resource_names})
414
+ df_default = df_default.rename(columns=resource_display_full)
415
+ df_current = df_current.rename(columns=resource_display_full)
416
+
417
+ styled = format_efficiency_summary(df_current, df_default)
418
+ st.dataframe(
419
+ data=styled,
420
+ height=(len(df_current) * 36 + 36),
421
+ width='stretch',
422
+ column_config={"_index": st.column_config.TextColumn("Method", width="large")},
423
+ row_height=36
424
+ )
425
+
426
+
427
+ def render_tab(cost_sliders):
428
+ """Render the Resource Efficiency Coefficients tab.
429
+
430
+ Args:
431
+ cost_sliders (dict): full cost slider config {method: {resource: {min, median, max}}}.
432
+
433
+ Returns:
434
+ None
435
+ """
436
+ st.markdown('<h1 class="crra-heading">Resource efficiency coefficients</h1>', unsafe_allow_html=True)
437
+ show_bar()
438
+
439
+ st.markdown('<p class="objective_title">OBJECTIVE</p>', unsafe_allow_html=True)
440
+ st.markdown(
441
+ '<div class="text_with_border"><p>'
442
+ "This section lets you adjust the resource usage coefficients for each CDR method "
443
+ "to best fit your country conditions.<br>"
444
+ "<li>Primary resources are always active and initialized with default values.</li>"
445
+ "<li>Secondary resources are initialized at zero and toggled off by default.</li>"
446
+ "<li>Toggling on a secondary resource restores its default value.</li>"
447
+ "</p></div>",
448
+ unsafe_allow_html=True,
449
+ )
450
+ with st.container(key="warning_resources"):
451
+ st.warning(
452
+ "Red cells in the table highlight coefficients modified from their **default median values**",
453
+ icon=":material/warning:",
454
+ )
455
+
456
+ enabled_methods = st.session_state.get("enabled_methods", {})
457
+ custom_resources = st.session_state.get("custom_resources", [])
458
+
459
+ cost_sliders_norm = {m: normalize_costs(resources) for m, resources in cost_sliders.items()}
460
+ resource_display = {
461
+ r: f"{r} ({resource_unit_map[cat]}/{CO2_UNIT})"
462
+ for cat, resources in resource_groups.items()
463
+ for r in resources
464
+ }
465
+
466
+ (
467
+ custom_sliders_by_method,
468
+ custom_resource_names,
469
+ custom_resource_to_sub_group,
470
+ custom_resource_to_group,
471
+ ) = build_custom_slider_index(cost_sliders_norm, custom_resources)
472
+
473
+ method_choices = [f"{g} | {m}" for g, methods in method_groups.items() for m in methods]
474
+ method_lookup = {label: label.split(" | ", 1)[1] for label in method_choices}
475
+ selected_label = st.selectbox("Select a method:", method_choices, key="crra-select")
476
+
477
+ if st.button("RESET ALL", key="btn-reset", icon=":material/reset_settings:"):
478
+ for m, sliders in cost_sliders_norm.items():
479
+ for r, conf in sliders.items():
480
+ st.session_state.costs[m][r] = 0.0 if (m, r) in secondary_resources else conf["median"]
481
+ for prefix in ["slider", "manual", "toggle"]:
482
+ st.session_state.pop(f"{prefix}_{m}_{r}".replace(" ", "_").lower(), None)
483
+ for m, sliders in custom_sliders_by_method.items():
484
+ for r, conf in sliders.items():
485
+ st.session_state.costs.setdefault(m, {})[r] = (
486
+ 0.0 if is_custom_secondary(m, r, custom_resource_to_sub_group) else conf["median"]
487
+ )
488
+ for prefix in ["slider", "manual", "toggle"]:
489
+ st.session_state.pop(f"{prefix}_{m}_{r}".replace(" ", "_").lower(), None)
490
+ st.success("All methods reset to default values.")
491
+ st.rerun()
492
+
493
+ panel_method_editor(
494
+ method=method_lookup[selected_label],
495
+ cost_sliders_norm=cost_sliders_norm,
496
+ custom_sliders_by_method=custom_sliders_by_method,
497
+ resource_display=resource_display,
498
+ enabled_methods=enabled_methods,
499
+ custom_resources=custom_resources,
500
+ custom_resource_to_group=custom_resource_to_group,
501
+ custom_resource_to_sub_group=custom_resource_to_sub_group,
502
+ )
503
+
504
+ panel_summary_table(
505
+ cost_sliders_norm=cost_sliders_norm,
506
+ custom_sliders_by_method=custom_sliders_by_method,
507
+ enabled_methods=enabled_methods,
508
+ custom_resource_names=custom_resource_names,
509
+ custom_resource_to_sub_group=custom_resource_to_sub_group,
510
  )
tabs/tab4_optimization.py CHANGED
The diff for this file is too large to render. See raw diff
 
tabs/tab5_sensitivity.py CHANGED
@@ -1,185 +1,245 @@
1
- import copy
2
- import pandas as pd
3
- import streamlit as st
4
- import plotly.express as px
5
-
6
- from config.config import CO2_UNIT, method_order, resource_groups, resource_order, resource_unit_map
7
- from optimization.optimization import run_optimization
8
- from utils.ui_helpers import register_chart_data, show_bar
9
-
10
-
11
- def render_tab():
12
- st.markdown('<h1 class="crra-heading">Sensitivity analysis</h1>', unsafe_allow_html=True)
13
- show_bar()
14
-
15
- st.markdown(
16
- """
17
- <p class="objective_title">OBJECTIVE</p>
18
- """,
19
- unsafe_allow_html=True,
20
- )
21
-
22
- st.markdown(
23
- """
24
- <div class="text_with_border">
25
- <p>Explore how varying <strong>one or multiple resources' availability</strong> or <strong>methods' maximum allocation</strong> affects total CO₂ removed.</p>
26
- </div>
27
- """,
28
- unsafe_allow_html=True
29
- )
30
-
31
- if "resource_caps" not in st.session_state or "constraints" not in st.session_state or "costs" not in st.session_state:
32
- st.warning("Please complete the setup in previous tabs before running sensitivity analysis.")
33
- return
34
-
35
- # Compute baseline once
36
- baseline_success, baseline_result = run_optimization(
37
- resource_caps=st.session_state.resource_caps,
38
- method_constraints=st.session_state.constraints,
39
- method_costs=st.session_state.costs,
40
- custom_resources=st.session_state.get("custom_resources", []),
41
- )
42
- baseline_removed = baseline_result["total_removed"] if baseline_success else 0
43
-
44
- custom_resource_names = [b["name"] for b in st.session_state.get("custom_resources", [])]
45
- all_resources = resource_order + custom_resource_names
46
-
47
- resource_to_unit = {
48
- r: resource_unit_map[group]
49
- for group, resources in resource_groups.items()
50
- for r in resources
51
- }
52
- for b in st.session_state.get("custom_resources", []):
53
- resource_to_unit[b["name"]] = b.get("unit", "")
54
-
55
- tab1, tab2 = st.tabs(["🔁 Resource sensitivity", "🔁 Method sensitivity"])
56
-
57
- with tab1:
58
- st.markdown("""
59
- <p class="crra-sub-heading">Impact of resource availability</p>""",
60
- unsafe_allow_html=True,
61
- )
62
- selected_resources = st.multiselect("Select one or more resources:", all_resources, key="crra-select-resource")
63
-
64
- if not selected_resources:
65
- st.warning("Please select at least one resource to run the sensitivity analysis.")
66
- else:
67
- percentages = list(range(0, 210, 10))
68
- all_data = []
69
-
70
- custom_amount_by_name = {
71
- b["name"]: float(b.get("amount", 0))
72
- for b in st.session_state.get("custom_resources", [])
73
- }
74
-
75
- for resource in selected_resources:
76
- unit = resource_to_unit.get(resource, "")
77
- base_cap = st.session_state.resource_caps.get(resource) or custom_amount_by_name.get(resource, 0)
78
- for pct in percentages:
79
- temp_caps = st.session_state.resource_caps.copy()
80
- actual_amount = base_cap * pct / 100
81
- temp_caps[resource] = actual_amount
82
-
83
- success, result = run_optimization(
84
- resource_caps=temp_caps,
85
- method_constraints=st.session_state.constraints,
86
- method_costs=st.session_state.costs,
87
- custom_resources=st.session_state.get("custom_resources", []),
88
- )
89
-
90
- co2_removed = result["total_removed"] if success else 0
91
- all_data.append({
92
- "Input": resource,
93
- "% of cap": pct,
94
- "Actual amount": round(actual_amount, 4),
95
- "Unit": unit,
96
- f"CO₂ removed ({CO2_UNIT})": co2_removed
97
- })
98
-
99
- df = pd.DataFrame(all_data)
100
- st.markdown("#### Sensitivity to resource availability")
101
- fig = px.line(
102
- df, x="% of cap", y=f"CO₂ removed ({CO2_UNIT})", color="Input", markers=True,
103
- hover_data={"Actual amount": ":.4f", "Unit": True},
104
- )
105
- fig.update_layout(
106
- xaxis_title="Resource availability (% of current cap)",
107
- yaxis_title=f"Total portfolio CO₂ removal ({CO2_UNIT})",
108
- )
109
- fig.add_hline(y=baseline_removed, line_dash="dash", line_color="gray",
110
- annotation_text=f"Baseline: {baseline_removed:,} {CO2_UNIT}")
111
- st.plotly_chart(fig, use_container_width=True)
112
- with st.expander("📊 View / download chart data"):
113
- st.dataframe(df, use_container_width=True, hide_index=True)
114
- register_chart_data("Sensitivity to resource availability", df)
115
-
116
- with tab2:
117
- st.markdown("""
118
- <p class="crra-sub-heading">Impact of max allocation per method</p>""",
119
- unsafe_allow_html=True,
120
- )
121
- selected_methods = st.multiselect("Select one or more methods:", method_order, key="crra-select-method")
122
-
123
- if not selected_methods:
124
- st.warning("Please select at least one method to run the sensitivity analysis.")
125
- else:
126
- percentages = list(range(0, 110, 10))
127
- all_data = []
128
- skipped = []
129
-
130
- for method in selected_methods:
131
- cap_type = st.session_state.constraints[method].get("cap_type", "percent")
132
- current_cap = st.session_state.constraints[method].get("cap_value", 100 if cap_type == "percent" else 0.0)
133
-
134
- if current_cap <= 0:
135
- skipped.append(method)
136
- continue
137
-
138
- for pct in percentages:
139
- temp_constraints = copy.deepcopy(st.session_state.constraints)
140
- if cap_type == "percent":
141
- actual_cap = min(100, current_cap * pct / 100)
142
- temp_constraints[method]["cap_value"] = actual_cap
143
- cap_label = f"{actual_cap:.0f}% of potential"
144
- else:
145
- actual_cap = current_cap * pct / 100
146
- temp_constraints[method]["cap_value"] = actual_cap
147
- cap_label = f"{actual_cap:.2f} {CO2_UNIT}/yr"
148
-
149
- success, result = run_optimization(
150
- resource_caps=st.session_state.resource_caps,
151
- method_constraints=temp_constraints,
152
- method_costs=st.session_state.costs,
153
- custom_resources=st.session_state.get("custom_resources", []),
154
- )
155
- co2_removed = result["total_removed"] if success else 0
156
- all_data.append({
157
- "Method": method,
158
- "Cap type": cap_type,
159
- "% of current cap": pct,
160
- "Actual cap": cap_label,
161
- f"CO₂ removed ({CO2_UNIT})": co2_removed,
162
- })
163
-
164
- for m in skipped:
165
- st.warning(f"Method **{m}** has a cap of 0 — set a value first to include it.", icon="⚠️")
166
-
167
- if all_data:
168
- df = pd.DataFrame(all_data)
169
- fig = px.line(
170
- df, x="% of current cap", y=f"CO₂ removed ({CO2_UNIT})",
171
- color="Method", markers=True,
172
- hover_data={"Actual cap": True, "Cap type": True},
173
- )
174
- fig.add_hline(
175
- y=baseline_removed, line_dash="dash", line_color="lightgray",
176
- annotation_text=f"Baseline: {baseline_removed:,} {CO2_UNIT}",
177
- )
178
- fig.update_layout(
179
- xaxis_title="% of current cap setting",
180
- yaxis_title=f"Total portfolio CO₂ removal ({CO2_UNIT})",
181
- )
182
- st.plotly_chart(fig, use_container_width=True)
183
- with st.expander("📊 View / download chart data"):
184
- st.dataframe(df, use_container_width=True, hide_index=True)
185
- register_chart_data("Sensitivity to method cap", df)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import copy
2
+ import pandas as pd
3
+ import streamlit as st
4
+ import plotly.express as px
5
+ from config.config import CO2_UNIT, method_order, resource_order
6
+ from optimization.optimization import run_optimization
7
+ from utils.data_utils import get_resource_to_unit
8
+ from utils.ui_helpers import register_chart_data, show_bar
9
+
10
+
11
+ def compute_baseline():
12
+ """Run the baseline optimisation and build combined resource metadata.
13
+
14
+ Returns:
15
+ tuple[float, list[str], dict[str, str]]:
16
+ baseline_removed – total CO₂ removed at current inputs (0 if failed).
17
+ all_resources – standard resources + custom batch names.
18
+ resource_to_unit – {resource: unit} including custom batches.
19
+ """
20
+ baseline_success, baseline_result = run_optimization(
21
+ resource_caps=st.session_state.resource_caps,
22
+ method_constraints=st.session_state.constraints,
23
+ method_costs=st.session_state.costs,
24
+ custom_resources=st.session_state.get("custom_resources", []),
25
+ )
26
+ baseline_removed = baseline_result["total_removed"] if baseline_success else 0
27
+
28
+ custom_resource_names = [b["name"] for b in st.session_state.get("custom_resources", [])]
29
+ all_resources = resource_order + custom_resource_names
30
+
31
+ resource_to_unit = get_resource_to_unit()
32
+ for b in st.session_state.get("custom_resources", []):
33
+ resource_to_unit[b["name"]] = b.get("unit", "")
34
+
35
+ return baseline_removed, all_resources, resource_to_unit
36
+
37
+
38
+ def panel_resource_sensitivity(baseline_removed, all_resources, resource_to_unit):
39
+ """Render the resource-availability sensitivity sub-panel.
40
+
41
+ Sweeps each selected resource from 0 % to 200 % of its current cap in
42
+ 10 % steps, re-solves the LP at each point, and plots total CO₂ removed.
43
+
44
+ Args:
45
+ baseline_removed (float): total CO₂ removed at current inputs.
46
+ all_resources (list[str]): standard + custom resource names to select from.
47
+ resource_to_unit (dict[str, str]): {resource: unit} for axis labels.
48
+
49
+ Returns:
50
+ None
51
+ """
52
+ st.markdown(
53
+ '<p class="crra-sub-heading">Impact of resource availability</p>',
54
+ unsafe_allow_html=True,
55
+ )
56
+ selected_resources = st.multiselect(
57
+ "Select one or more resources:", all_resources, key="crra-select-resource"
58
+ )
59
+
60
+ if not selected_resources:
61
+ st.warning("Please select at least one resource to run the sensitivity analysis.")
62
+ return
63
+
64
+ percentages = list(range(0, 210, 10))
65
+ custom_amount_by_name = {
66
+ b["name"]: float(b.get("amount", 0))
67
+ for b in st.session_state.get("custom_resources", [])
68
+ }
69
+ all_data = []
70
+
71
+ for resource in selected_resources:
72
+ unit = resource_to_unit.get(resource, "")
73
+ base_cap = (
74
+ st.session_state.resource_caps.get(resource)
75
+ or custom_amount_by_name.get(resource, 0)
76
+ )
77
+ for pct in percentages:
78
+ perturbed_caps = st.session_state.resource_caps.copy()
79
+ actual_amount = base_cap * pct / 100
80
+ perturbed_caps[resource] = actual_amount
81
+
82
+ success, result = run_optimization(
83
+ resource_caps=perturbed_caps,
84
+ method_constraints=st.session_state.constraints,
85
+ method_costs=st.session_state.costs,
86
+ custom_resources=st.session_state.get("custom_resources", []),
87
+ )
88
+ all_data.append({
89
+ "Input": resource,
90
+ "% of cap": pct,
91
+ "Actual amount": round(actual_amount, 4),
92
+ "Unit": unit,
93
+ f"CO₂ removed ({CO2_UNIT})": result["total_removed"] if success else 0,
94
+ })
95
+
96
+ df = pd.DataFrame(all_data)
97
+ st.markdown("#### Sensitivity to resource availability")
98
+ fig = px.line(
99
+ df, x="% of cap", y=f"CO₂ removed ({CO2_UNIT})", color="Input", markers=True,
100
+ hover_data={"Actual amount": ":.4f", "Unit": True},
101
+ )
102
+ fig.update_layout(
103
+ xaxis_title="Resource availability (% of current cap)",
104
+ yaxis_title=f"Total portfolio CO₂ removal ({CO2_UNIT})",
105
+ )
106
+ fig.add_hline(
107
+ y=baseline_removed, line_dash="dash", line_color="gray",
108
+ annotation_text=f"Baseline: {baseline_removed:,} {CO2_UNIT}",
109
+ )
110
+ st.plotly_chart(fig, width='stretch')
111
+ with st.expander("📊 View / download chart data"):
112
+ st.dataframe(df, width='stretch', hide_index=True)
113
+ register_chart_data("Sensitivity to resource availability", df)
114
+
115
+
116
+ def panel_method_sensitivity(baseline_removed):
117
+ """Render the method-cap sensitivity sub-panel.
118
+
119
+ Sweeps each selected method's cap from 0 % to 100 % of its current value
120
+ in 10 % steps, re-solves the LP at each point, and plots total CO₂ removed.
121
+ Skips methods whose current cap is 0.
122
+
123
+ Args:
124
+ baseline_removed (float): total CO₂ removed at current inputs.
125
+
126
+ Returns:
127
+ None
128
+ """
129
+ st.markdown(
130
+ '<p class="crra-sub-heading">Impact of max allocation per method</p>',
131
+ unsafe_allow_html=True,
132
+ )
133
+ selected_methods = st.multiselect(
134
+ "Select one or more methods:", method_order, key="crra-select-method"
135
+ )
136
+
137
+ if not selected_methods:
138
+ st.warning("Please select at least one method to run the sensitivity analysis.")
139
+ return
140
+
141
+ percentages = list(range(0, 110, 10))
142
+ all_data = []
143
+ skipped = []
144
+
145
+ for method in selected_methods:
146
+ cap_type = st.session_state.constraints[method].get("cap_type", "percent")
147
+ current_cap = st.session_state.constraints[method].get(
148
+ "cap_value", 100 if cap_type == "percent" else 0.0
149
+ )
150
+
151
+ if current_cap <= 0:
152
+ skipped.append(method)
153
+ continue
154
+
155
+ for pct in percentages:
156
+ perturbed_constraints = copy.deepcopy(st.session_state.constraints)
157
+ if cap_type == "percent":
158
+ actual_cap = min(100, current_cap * pct / 100)
159
+ perturbed_constraints[method]["cap_value"] = actual_cap
160
+ cap_label = f"{actual_cap:.0f}% of potential"
161
+ else:
162
+ actual_cap = current_cap * pct / 100
163
+ perturbed_constraints[method]["cap_value"] = actual_cap
164
+ cap_label = f"{actual_cap:.2f} {CO2_UNIT}/yr"
165
+
166
+ success, result = run_optimization(
167
+ resource_caps=st.session_state.resource_caps,
168
+ method_constraints=perturbed_constraints,
169
+ method_costs=st.session_state.costs,
170
+ custom_resources=st.session_state.get("custom_resources", []),
171
+ )
172
+ all_data.append({
173
+ "Method": method,
174
+ "Cap type": cap_type,
175
+ "% of current cap": pct,
176
+ "Actual cap": cap_label,
177
+ f"CO₂ removed ({CO2_UNIT})": result["total_removed"] if success else 0,
178
+ })
179
+
180
+ for m in skipped:
181
+ st.warning(f"Method **{m}** has a cap of 0 — set a value first to include it.", icon="⚠️")
182
+
183
+ if not all_data:
184
+ return
185
+
186
+ df = pd.DataFrame(all_data)
187
+ fig = px.line(
188
+ df, x="% of current cap", y=f"CO₂ removed ({CO2_UNIT})",
189
+ color="Method", markers=True,
190
+ hover_data={"Actual cap": True, "Cap type": True},
191
+ )
192
+ fig.add_hline(
193
+ y=baseline_removed, line_dash="dash", line_color="lightgray",
194
+ annotation_text=f"Baseline: {baseline_removed:,} {CO2_UNIT}",
195
+ )
196
+ fig.update_layout(
197
+ xaxis_title="% of current cap setting",
198
+ yaxis_title=f"Total portfolio CO₂ removal ({CO2_UNIT})",
199
+ )
200
+ st.plotly_chart(fig, width='stretch')
201
+ with st.expander("📊 View / download chart data"):
202
+ st.dataframe(df, width='stretch', hide_index=True)
203
+ register_chart_data("Sensitivity to method cap", df)
204
+
205
+
206
+ def render_tab():
207
+ """Render the Sensitivity Analysis tab.
208
+
209
+ Computes a baseline optimisation from session state, then lets the user
210
+ sweep one or more resources (or method caps) over a range and plots how
211
+ total CO₂ removed changes. Requires resource_caps, constraints, and costs
212
+ to be present in session state.
213
+
214
+ Returns:
215
+ None
216
+ """
217
+ st.markdown('<h1 class="crra-heading">Sensitivity analysis</h1>', unsafe_allow_html=True)
218
+ show_bar()
219
+
220
+ st.markdown('<p class="objective_title">OBJECTIVE</p>', unsafe_allow_html=True)
221
+ st.markdown(
222
+ '<div class="text_with_border">'
223
+ "<p>Explore how varying <strong>one or multiple resources' availability</strong> "
224
+ "or <strong>methods' maximum allocation</strong> affects total CO₂ removed.</p>"
225
+ "</div>",
226
+ unsafe_allow_html=True,
227
+ )
228
+
229
+ if (
230
+ "resource_caps" not in st.session_state
231
+ or "constraints" not in st.session_state
232
+ or "costs" not in st.session_state
233
+ ):
234
+ st.warning("Please complete the setup in previous tabs before running sensitivity analysis.")
235
+ return
236
+
237
+ baseline_removed, all_resources, resource_to_unit = compute_baseline()
238
+
239
+ tab1, tab2 = st.tabs(["🔁 Resource sensitivity", "🔁 Method sensitivity"])
240
+
241
+ with tab1:
242
+ panel_resource_sensitivity(baseline_removed, all_resources, resource_to_unit)
243
+
244
+ with tab2:
245
+ panel_method_sensitivity(baseline_removed)
tabs/tab6_scenarios.py CHANGED
@@ -1,168 +1,199 @@
1
- import json
2
-
3
- import pandas as pd
4
- import plotly.express as px
5
- import streamlit as st
6
- from config.config import CO2_UNIT
7
- from utils.data_utils import is_valid_scenario
8
- from tabs.tab4_optimization import parse_zip_scenario
9
- from utils.ui_helpers import show_bar
10
-
11
-
12
- # ─── Load from file ───────────────────────────────────────────────────────────
13
-
14
- def panel_load_scenario():
15
- st.markdown("""
16
- <p class="crra-sub-heading">Load a scenario from file</p>""",
17
- unsafe_allow_html=True,
18
- )
19
- st.markdown('<p class="caption">Accepts `.json` or `.zip` (CSV export)</p>', unsafe_allow_html=True)
20
-
21
- uploaded = st.file_uploader("Upload scenario", type=["json", "zip"], key="comparison_loader", accept_multiple_files=True)
22
- if uploaded is None:
23
- return
24
- for uploaded_file in uploaded:
25
- if uploaded_file is not None:
26
- try:
27
- if uploaded_file.name.endswith(".json"):
28
- data = json.load(uploaded_file)
29
- if not is_valid_scenario(data):
30
- st.error(f"Invalid scenario format in {uploaded_file.name}.")
31
- continue
32
- else:
33
- data = parse_zip_scenario(uploaded_file)
34
- if data is None:
35
- st.error(f"Invalid ZIP format in {uploaded_file.name}. Expected: resource_caps.csv, method_constraints.csv, method_costs.csv, allocations.csv")
36
- continue
37
- st.session_state.scenarios[uploaded_file.name] = data
38
- except Exception as e:
39
- st.error(f"Error loading {uploaded_file.name}: {e}")
40
-
41
- # ─── Data preparation ─────────────────────────────────────────────────────────
42
-
43
- def _prepare_comparison_data(selected):
44
- all_methods = sorted({
45
- m for name in selected
46
- for m in st.session_state.scenarios[name].get("allocations", {})
47
- })
48
- all_resources = sorted({
49
- r for name in selected
50
- for r in st.session_state.scenarios[name].get("resource_caps", {})
51
- })
52
-
53
- summary_rows, method_alloc_rows, resource_usage_rows = [], [], []
54
-
55
- for name in selected:
56
- scenario = st.session_state.scenarios[name]
57
- allocations = scenario.get("allocations", {})
58
- method_costs = scenario.get("method_costs", {}) # ← fix: was using method_constraints
59
-
60
- row = {
61
- "Scenario": name,
62
- f"CO₂ Removed ({CO2_UNIT})": scenario.get("summary", {}).get("total_co2_removed", 0),
63
- }
64
-
65
- for method in all_methods:
66
- val = allocations.get(method, 0)
67
- row[f"{method} ({CO2_UNIT})"] = val
68
- method_alloc_rows.append({"Scenario": name, "Method": method, f"{CO2_UNIT}": val})
69
-
70
- for resource in all_resources:
71
- total_used = 0
72
- for method, tco2 in allocations.items():
73
- used = method_costs.get(method, {}).get(resource, 0) * tco2
74
- total_used += used
75
- if used > 0:
76
- resource_usage_rows.append({
77
- "Scenario": name, "Method": method,
78
- "Resource": resource, "Used": used,
79
- })
80
- row[f"{resource} used"] = total_used
81
-
82
- summary_rows.append(row)
83
-
84
- return (
85
- pd.DataFrame(summary_rows),
86
- pd.DataFrame(method_alloc_rows),
87
- pd.DataFrame(resource_usage_rows),
88
- )
89
-
90
-
91
- # ─── Charts ───────────────────────────────────────────────────────────────────
92
-
93
- def _show_method_allocations(df):
94
- if df.empty:
95
- return
96
- st.markdown("""<p class="crra-sub-heading">Method Allocations</p>""", unsafe_allow_html=True)
97
- fig = px.bar(df, x="Method", y=f"{CO2_UNIT}", color="Scenario", barmode="group")
98
- fig.update_layout(xaxis_tickangle=-45)
99
- st.plotly_chart(fig, use_container_width=True)
100
-
101
-
102
-
103
-
104
- def _show_resource_usage(df):
105
- if df.empty:
106
- st.info("No resource usage data available (method_costs missing from scenarios?).")
107
- return
108
-
109
- st.markdown("""<p class="crra-sub-heading">Resource Use per Method</p>""", unsafe_allow_html=True)
110
- for method in df["Method"].unique():
111
- df_m = df[df["Method"] == method]
112
- fig = px.bar(df_m, x="Resource", y="Used", color="Scenario", barmode="group", title=method)
113
- fig.update_layout(xaxis_tickangle=-45)
114
- st.plotly_chart(fig, use_container_width=True)
115
-
116
- st.markdown("""<p class="crra-sub-heading">Total Resource Use per Scenario</p>""", unsafe_allow_html=True)
117
- df_total = df.groupby(["Scenario", "Resource"]).sum(numeric_only=True).reset_index()
118
- fig = px.bar(df_total, x="Scenario", y="Used", color="Resource", barmode="group")
119
- fig.update_layout(xaxis_tickangle=-45)
120
- st.plotly_chart(fig, use_container_width=True)
121
-
122
-
123
- # ─── Main tab entry point ─────────────────────────────────────────────────────
124
-
125
- def render_tab():
126
- st.markdown('<h1 class="crra-heading">Scenario Comparison</h1>', unsafe_allow_html=True)
127
- show_bar()
128
-
129
- st.session_state.setdefault("scenarios", {})
130
-
131
- panel_load_scenario()
132
-
133
- if not st.session_state.scenarios:
134
- st.info("No scenarios saved yet. Run an optimization in **Portfolio Optimization** tab or load a file above.")
135
- return
136
-
137
- # Select & manage scenarios
138
- st.markdown("""<p class="crra-sub-heading">Select Scenarios to Compare</p>""", unsafe_allow_html=True)
139
- scenario_names = list(st.session_state.scenarios.keys())
140
- selected = st.multiselect("Select scenarios", scenario_names, default=scenario_names)
141
-
142
- for name in selected:
143
- col1, col2 = st.columns([10, 1])
144
- with col1:
145
- st.markdown(f"**{name}**")
146
- with col2:
147
- if st.button("❌", key=f"remove_{name}"):
148
- st.session_state.scenarios.pop(name, None)
149
- st.rerun()
150
-
151
- if not selected:
152
- st.warning("Please select at least one scenario.")
153
- return
154
-
155
- # Comparison
156
- df_summary, df_methods, df_resources = _prepare_comparison_data(selected)
157
-
158
- st.markdown("""<p class="crra-sub-heading">Summary Table</p>""", unsafe_allow_html=True)
159
- st.dataframe(df_summary.style.format(na_rep="-", thousands=","), use_container_width=True)
160
- st.download_button(
161
- "Export Comparison CSV",
162
- df_summary.to_csv(index=False).encode("utf-8"),
163
- "scenario_comparison.csv", "text/csv",
164
- icon=":material/download:"
165
- )
166
-
167
- _show_method_allocations(df_methods)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  _show_resource_usage(df_resources)
 
1
+ import json
2
+ import pandas as pd
3
+ import plotly.express as px
4
+ import streamlit as st
5
+ from config.config import CO2_UNIT
6
+ from utils.data_utils import is_valid_scenario
7
+ from tabs.tab4_optimization import parse_zip_scenario
8
+ from utils.ui_helpers import show_bar
9
+
10
+
11
+ def panel_load_scenario():
12
+ """Render the file uploader panel to load one or more scenarios for comparison.
13
+
14
+ Accepts JSON and ZIP files. Adds valid scenarios to st.session_state.scenarios.
15
+
16
+ Returns:
17
+ None
18
+ """
19
+ st.markdown("""
20
+ <p class="crra-sub-heading">Load a scenario from file</p>""",
21
+ unsafe_allow_html=True,
22
+ )
23
+ st.markdown('<p class="caption">Accepts `.json` or `.zip` (CSV export)</p>', unsafe_allow_html=True)
24
+
25
+ uploaded = st.file_uploader("Upload scenario", type=["json", "zip"], key="comparison_loader", accept_multiple_files=True)
26
+ if uploaded is None:
27
+ return
28
+ for uploaded_file in uploaded:
29
+ if uploaded_file is not None:
30
+ try:
31
+ if uploaded_file.name.endswith(".json"):
32
+ data = json.load(uploaded_file)
33
+ if not is_valid_scenario(data):
34
+ st.error(f"Invalid scenario format in {uploaded_file.name}.")
35
+ continue
36
+ else:
37
+ data = parse_zip_scenario(uploaded_file)
38
+ if data is None:
39
+ st.error(f"Invalid ZIP format in {uploaded_file.name}. Expected: resource_caps.csv, method_constraints.csv, method_costs.csv, allocations.csv")
40
+ continue
41
+ st.session_state.scenarios[uploaded_file.name] = data
42
+ except Exception as e:
43
+ st.error(f"Error loading {uploaded_file.name}: {e}")
44
+
45
+ def _prepare_comparison_data(selected):
46
+ """Build comparison DataFrames from the currently selected scenarios.
47
+
48
+ Args:
49
+ selected (list[str]): list of scenario names to include.
50
+
51
+ Returns:
52
+ tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
53
+ df_summary — one row per scenario with total CO₂ and per-method values,
54
+ df_methods — long-format method allocation data,
55
+ df_resources — long-format resource usage data per method and scenario.
56
+ """
57
+ all_methods = sorted({
58
+ m for name in selected
59
+ for m in st.session_state.scenarios[name].get("allocations", {})
60
+ })
61
+ all_resources = sorted({
62
+ r for name in selected
63
+ for r in st.session_state.scenarios[name].get("resource_caps", {})
64
+ })
65
+
66
+ summary_rows, method_alloc_rows, resource_usage_rows = [], [], []
67
+
68
+ for name in selected:
69
+ scenario = st.session_state.scenarios[name]
70
+ allocations = scenario.get("allocations", {})
71
+ method_costs = scenario.get("method_costs", {}) # ← fix: was using method_constraints
72
+
73
+ row = {
74
+ "Scenario": name,
75
+ f"CO₂ Removed ({CO2_UNIT})": scenario.get("summary", {}).get("total_co2_removed", 0),
76
+ }
77
+
78
+ for method in all_methods:
79
+ val = allocations.get(method, 0)
80
+ row[f"{method} ({CO2_UNIT})"] = val
81
+ method_alloc_rows.append({"Scenario": name, "Method": method, f"{CO2_UNIT}": val})
82
+
83
+ for resource in all_resources:
84
+ total_used = 0
85
+ for method, tco2 in allocations.items():
86
+ used = method_costs.get(method, {}).get(resource, 0) * tco2
87
+ total_used += used
88
+ if used > 0:
89
+ resource_usage_rows.append({
90
+ "Scenario": name, "Method": method,
91
+ "Resource": resource, "Used": used,
92
+ })
93
+ row[f"{resource} used"] = total_used
94
+
95
+ summary_rows.append(row)
96
+
97
+ return (
98
+ pd.DataFrame(summary_rows),
99
+ pd.DataFrame(method_alloc_rows),
100
+ pd.DataFrame(resource_usage_rows),
101
+ )
102
+
103
+
104
+ def _show_method_allocations(df):
105
+ """Render a grouped bar chart of method allocations across scenarios.
106
+
107
+ Args:
108
+ df (pd.DataFrame): long-format data with columns Scenario, Method, CO₂ value.
109
+
110
+ Returns:
111
+ None
112
+ """
113
+ if df.empty:
114
+ return
115
+ st.markdown("""<p class="crra-sub-heading">Method Allocations</p>""", unsafe_allow_html=True)
116
+ fig = px.bar(df, x="Method", y=f"{CO2_UNIT}", color="Scenario", barmode="group")
117
+ fig.update_layout(xaxis_tickangle=-45)
118
+ st.plotly_chart(fig, width='stretch')
119
+
120
+
121
+
122
+
123
+ def _show_resource_usage(df):
124
+ """Render grouped bar charts of resource usage per method and total per scenario.
125
+
126
+ Args:
127
+ df (pd.DataFrame): long-format data with columns Scenario, Method, Resource, Used.
128
+
129
+ Returns:
130
+ None
131
+ """
132
+ if df.empty:
133
+ st.info("No resource usage data available (method_costs missing from scenarios?).")
134
+ return
135
+
136
+ st.markdown("""<p class="crra-sub-heading">Resource Use per Method</p>""", unsafe_allow_html=True)
137
+ for method in df["Method"].unique():
138
+ df_m = df[df["Method"] == method]
139
+ fig = px.bar(df_m, x="Resource", y="Used", color="Scenario", barmode="group", title=method)
140
+ fig.update_layout(xaxis_tickangle=-45)
141
+ st.plotly_chart(fig, width='stretch')
142
+
143
+ st.markdown("""<p class="crra-sub-heading">Total Resource Use per Scenario</p>""", unsafe_allow_html=True)
144
+ df_total = df.groupby(["Scenario", "Resource"]).sum(numeric_only=True).reset_index()
145
+ fig = px.bar(df_total, x="Scenario", y="Used", color="Resource", barmode="group")
146
+ fig.update_layout(xaxis_tickangle=-45)
147
+ st.plotly_chart(fig, width='stretch')
148
+
149
+
150
+
151
+ def render_tab():
152
+ """Render the Scenario Comparison tab.
153
+
154
+ Returns:
155
+ None
156
+ """
157
+ st.markdown('<h1 class="crra-heading">Scenario Comparison</h1>', unsafe_allow_html=True)
158
+ show_bar()
159
+
160
+ st.session_state.setdefault("scenarios", {})
161
+
162
+ panel_load_scenario()
163
+
164
+ if not st.session_state.scenarios:
165
+ st.info("No scenarios saved yet. Run an optimization in **Portfolio Optimization** tab or load a file above.")
166
+ return
167
+
168
+ # Select & manage scenarios
169
+ st.markdown("""<p class="crra-sub-heading">Select Scenarios to Compare</p>""", unsafe_allow_html=True)
170
+ scenario_names = list(st.session_state.scenarios.keys())
171
+ selected = st.multiselect("Select scenarios", scenario_names, default=scenario_names)
172
+
173
+ for name in selected:
174
+ col1, col2 = st.columns([10, 1])
175
+ with col1:
176
+ st.markdown(f"**{name}**")
177
+ with col2:
178
+ if st.button("❌", key=f"remove_{name}"):
179
+ st.session_state.scenarios.pop(name, None)
180
+ st.rerun()
181
+
182
+ if not selected:
183
+ st.warning("Please select at least one scenario.")
184
+ return
185
+
186
+ # Comparison
187
+ df_summary, df_methods, df_resources = _prepare_comparison_data(selected)
188
+
189
+ st.markdown("""<p class="crra-sub-heading">Summary Table</p>""", unsafe_allow_html=True)
190
+ st.dataframe(df_summary.style.format(na_rep="-", thousands=","), width='stretch')
191
+ st.download_button(
192
+ "Export Comparison CSV",
193
+ df_summary.to_csv(index=False).encode("utf-8"),
194
+ "scenario_comparison.csv", "text/csv",
195
+ icon=":material/download:"
196
+ )
197
+
198
+ _show_method_allocations(df_methods)
199
  _show_resource_usage(df_resources)
utils/data_utils.py CHANGED
@@ -1,143 +1,235 @@
1
- import io
2
- import json
3
- import re
4
- import pandas as pd
5
- import streamlit as st
6
- import openpyxl
7
- from openpyxl.styles import PatternFill, Font
8
- from config.config import resource_unit_map, resource_order, method_order
9
-
10
- @st.cache_data
11
- def load_slider_data():
12
- """Load slider data from a JSON file and cache it for performance."""
13
- with open("data/cost_sliders.json") as f:
14
- data = json.load(f)
15
- return data
16
-
17
- def render_resource_input(name_clean, r, tooltip=None):
18
-
19
- widget_key = f"cap_{r}"
20
-
21
- if widget_key not in st.session_state:
22
- existing = st.session_state.resource_caps.get(r, 0.0)
23
- st.session_state[widget_key] = existing if existing else None
24
-
25
- cap = st.number_input(
26
- label=name_clean,
27
- min_value=0.0,
28
- format="%.2f",
29
- value=None,
30
- placeholder="0.00",
31
- key=widget_key,
32
- help=tooltip
33
- )
34
-
35
- st.session_state.resource_caps[r] = cap if cap is not None else 0.0
36
-
37
- def is_valid_scenario(scenario):
38
- base_ok = (
39
- isinstance(scenario, dict)
40
- and "allocations" in scenario
41
- and "resource_caps" in scenario
42
- and "method_costs" in scenario
43
- and "method_constraints" in scenario
44
- and "summary" in scenario
45
- )
46
- if not base_ok:
47
- return False
48
- return True
49
-
50
- def is_complete_scenario(scenario):
51
- """A scenario that also carries the detailed optimisation outputs and the
52
- per-resource manual allocation state, so reloading it reproduces the
53
- exact same optimisation (not just the same inputs)."""
54
- required_extra = {
55
- "resource_usage", "resource_used_total", "resource_remaining","enabled_methods",
56
- }
57
- return is_valid_scenario(scenario) and required_extra.issubset(scenario.keys())
58
-
59
- def load_inputs_from_excel(file):
60
- xl = pd.read_excel(file, sheet_name=None)
61
-
62
- resource_caps = {}
63
- if "resource_caps" in xl:
64
- for _, row in xl["resource_caps"].iterrows():
65
- resource_caps[row["Resource"]] = float(row["Available Amount"])
66
-
67
- constraints = {}
68
- if "constraints" in xl:
69
- for _, row in xl["constraints"].iterrows():
70
- constraints[row["method"]] = {
71
- "active": bool(row["active"]),
72
- "cap_type": str(row["cap_type (percent or absolute)"]),
73
- "cap_value": float(row["cap_value"])
74
- }
75
- costs = {}
76
- if "coefficients" in xl:
77
- for _, row in xl["coefficients"].iterrows():
78
- m, r, v = row["method"], row["resource"], float(row["value"])
79
- costs.setdefault(m, {})[r] = v
80
-
81
- custom_resources = []
82
- warnings = []
83
- valid_resources = set(resource_order)
84
- valid_methods = set(method_order)
85
- if "custom_resources" in xl:
86
- df_custom = xl["custom_resources"]
87
- required_custom = {"name", "group", "amount", "unit", "method", "min", "median", "max"}
88
- if required_custom.issubset(df_custom.columns):
89
- for bname in df_custom["name"].dropna().unique():
90
- df_b = df_custom[df_custom["name"] == bname]
91
- group = str(df_b["group"].iloc[0])
92
- if group not in valid_resources:
93
- warnings.append(f"New resource \"{bname}\" - group \"{group}\" not found: check spelling.")
94
- continue
95
- methods = {}
96
- for _, row in df_b.iterrows():
97
- m = str(row["method"])
98
- if m not in valid_methods:
99
- warnings.append(f"New resource \"{bname}\" - method \"{m}\" not found: check spelling.")
100
- continue
101
- methods[m] = {
102
- "min": float(row["min"]),
103
- "median": float(row["median"]),
104
- "max": float(row["max"]),
105
- }
106
- custom_resources.append({
107
- "name": str(bname),
108
- "group": group,
109
- "amount": float(df_b["amount"].iloc[0]),
110
- "unit": str(df_b["unit"].iloc[0]),
111
- "methods": methods,
112
- })
113
-
114
- # Per-resource manual method allocation (which CDR methods are allowed to
115
- # use a given resource). Optional sheet for backward compatibility with
116
- # older templates that predate this feature.
117
- enabled_methods = {}
118
- if "enabled_methods" in xl:
119
- df_enabled = xl["enabled_methods"]
120
- required_enabled = {"resource", "method", "enabled"}
121
- if required_enabled.issubset(df_enabled.columns):
122
- for _, row in df_enabled.iterrows():
123
- r, m = row["resource"], row["method"]
124
- enabled_methods.setdefault(r, {})[m] = bool(row["enabled"])
125
-
126
- return resource_caps, constraints, costs, custom_resources, enabled_methods, warnings
127
-
128
- def strip_unit_suffix(resource_name: str) -> str:
129
- """Remove legacy unit suffix from resource name, e.g. 'Unspecified land (ha/tCO2)' -> 'Unspecified land'"""
130
- return re.sub(r"\s*\(.*?\)\s*$", "", resource_name).strip()
131
-
132
- def neutralize_zero_coefficient_methods(method_costs: dict, method_constraints: dict) -> dict:
133
- safe_constraints = {m: c.copy() for m, c in method_constraints.items()}
134
-
135
- for method, constraint in safe_constraints.items():
136
- if not constraint.get("active", True):
137
- continue
138
- coeffs = method_costs.get(method, {})
139
- all_zero = all(v == 0 for v in coeffs.values()) if coeffs else True
140
- if all_zero:
141
- constraint["active"] = False
142
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  return safe_constraints
 
1
+ import io
2
+ import json
3
+ import re
4
+ import pandas as pd
5
+ import streamlit as st
6
+ import openpyxl
7
+ from openpyxl.styles import PatternFill, Font
8
+ from config.config import resource_unit_map, resource_order, method_order, resource_groups, method_groups
9
+
10
+ def get_resource_to_group() -> dict[str, str]:
11
+ """Map each resource name to its resource group.
12
+
13
+ Returns:
14
+ dict[str, str]: resource name → group name (e.g. "Arable land" → "Land").
15
+ """
16
+ return {r: g for g, resources in resource_groups.items() for r in resources}
17
+
18
+ def get_resource_to_unit() -> dict[str, str]:
19
+ """Map each resource name to its physical unit.
20
+
21
+ Returns:
22
+ dict[str, str]: resource name → unit (e.g. "Arable land" → "Mha").
23
+ """
24
+ return {r: resource_unit_map[g] for g, resources in resource_groups.items() for r in resources}
25
+
26
+ def get_method_group(method: str) -> str:
27
+ """Return the group a CDR method belongs to.
28
+
29
+ Args:
30
+ method (str): CDR method name.
31
+
32
+ Returns:
33
+ str: group name, or "Other" if not found.
34
+ """
35
+ return next((g for g, methods in method_groups.items() if method in methods), "Other")
36
+
37
+ @st.cache_data
38
+ def load_slider_data():
39
+ """Load and cache the cost slider configuration from data/cost_sliders.json.
40
+
41
+ Returns:
42
+ dict: nested dict {method: {resource: {min, median, max}}}.
43
+ """
44
+ with open("data/cost_sliders.json") as f:
45
+ data = json.load(f)
46
+ return data
47
+
48
+ def render_resource_input(name_clean, r, tooltip=None):
49
+ """Render a number_input widget for a single resource availability cap.
50
+
51
+ Writes the entered value into st.session_state.resource_caps[r].
52
+
53
+ Args:
54
+ name_clean (str): display label (resource name without unit suffix).
55
+ r (str): canonical resource name used as the session state key.
56
+ tooltip (str | None): optional help text shown on hover.
57
+
58
+ Returns:
59
+ None
60
+ """
61
+ widget_key = f"cap_{r}"
62
+
63
+ if widget_key not in st.session_state:
64
+ existing = st.session_state.resource_caps.get(r, 0.0)
65
+ st.session_state[widget_key] = existing if existing else None
66
+
67
+ cap = st.number_input(
68
+ label=name_clean,
69
+ min_value=0.0,
70
+ format="%.2f",
71
+ value=None,
72
+ placeholder="0.00",
73
+ key=widget_key,
74
+ help=tooltip
75
+ )
76
+
77
+ st.session_state.resource_caps[r] = cap if cap is not None else 0.0
78
+
79
+ def is_valid_scenario(scenario):
80
+ """Check that a scenario dict has the minimum required keys to be loaded.
81
+
82
+ Args:
83
+ scenario (dict): scenario data to validate.
84
+
85
+ Returns:
86
+ bool: True if all base keys are present, False otherwise.
87
+ """
88
+ base_ok = (
89
+ isinstance(scenario, dict)
90
+ and "allocations" in scenario
91
+ and "resource_caps" in scenario
92
+ and "method_costs" in scenario
93
+ and "method_constraints" in scenario
94
+ and "summary" in scenario
95
+ )
96
+ if not base_ok:
97
+ return False
98
+ return True
99
+
100
+ def is_complete_scenario(scenario):
101
+ """Check that a scenario carries full optimisation outputs, not just inputs.
102
+
103
+ A complete scenario can be displayed without re-running the optimisation.
104
+ An incomplete scenario (e.g. exported before resource_actual_gain was added)
105
+ triggers an automatic re-run on load.
106
+
107
+ Args:
108
+ scenario (dict): scenario data to validate.
109
+
110
+ Returns:
111
+ bool: True if the scenario includes all detailed output keys.
112
+ """
113
+ required_extra = {
114
+ "resource_usage", "resource_used_total", "resource_remaining",
115
+ "resource_actual_gain", "enabled_methods",
116
+ }
117
+ return is_valid_scenario(scenario) and required_extra.issubset(scenario.keys())
118
+
119
+ def load_inputs_from_excel(file):
120
+ """Parse an Excel workbook exported from the tool into session-state-ready dicts.
121
+
122
+ Expects sheets: resource_caps, constraints, coefficients.
123
+ Optional sheets: custom_resources, enabled_methods.
124
+
125
+ Args:
126
+ file: file-like object pointing to an .xlsx workbook.
127
+
128
+ Returns:
129
+ tuple: (resource_caps, constraints, costs, custom_resources, enabled_methods, warnings)
130
+ where warnings is a list[str] of non-fatal import issues.
131
+ """
132
+ xl = pd.read_excel(file, sheet_name=None)
133
+
134
+ resource_caps = {}
135
+ if "resource_caps" in xl:
136
+ for _, row in xl["resource_caps"].iterrows():
137
+ resource_caps[row["Resource"]] = float(row["Available Amount"])
138
+
139
+ constraints = {}
140
+ if "constraints" in xl:
141
+ for _, row in xl["constraints"].iterrows():
142
+ constraints[row["method"]] = {
143
+ "active": bool(row["active"]),
144
+ "cap_type": str(row["cap_type (percent or absolute)"]),
145
+ "cap_value": float(row["cap_value"])
146
+ }
147
+ costs = {}
148
+ if "coefficients" in xl:
149
+ for _, row in xl["coefficients"].iterrows():
150
+ m, r, v = row["method"], row["resource"], float(row["value"])
151
+ costs.setdefault(m, {})[r] = v
152
+
153
+ custom_resources = []
154
+ warnings = []
155
+ valid_resources = set(resource_order)
156
+ valid_methods = set(method_order)
157
+ if "custom_resources" in xl:
158
+ df_custom = xl["custom_resources"]
159
+ required_custom = {"name", "group", "amount", "unit", "method", "min", "median", "max"}
160
+ if required_custom.issubset(df_custom.columns):
161
+ for bname in df_custom["name"].dropna().unique():
162
+ df_b = df_custom[df_custom["name"] == bname]
163
+ group = str(df_b["group"].iloc[0])
164
+ if group not in valid_resources:
165
+ warnings.append(f"New resource \"{bname}\" - group \"{group}\" not found: check spelling.")
166
+ continue
167
+ methods = {}
168
+ for _, row in df_b.iterrows():
169
+ m = str(row["method"])
170
+ if m not in valid_methods:
171
+ warnings.append(f"New resource \"{bname}\" - method \"{m}\" not found: check spelling.")
172
+ continue
173
+ methods[m] = {
174
+ "min": float(row["min"]),
175
+ "median": float(row["median"]),
176
+ "max": float(row["max"]),
177
+ }
178
+ custom_resources.append({
179
+ "name": str(bname),
180
+ "group": group,
181
+ "amount": float(df_b["amount"].iloc[0]),
182
+ "unit": str(df_b["unit"].iloc[0]),
183
+ "methods": methods,
184
+ })
185
+
186
+ # Per-resource manual method allocation (which CDR methods are allowed to
187
+ # use a given resource). Optional sheet for backward compatibility with
188
+ # older templates that predate this feature.
189
+ enabled_methods = {}
190
+ if "enabled_methods" in xl:
191
+ df_enabled = xl["enabled_methods"]
192
+ required_enabled = {"resource", "method", "enabled"}
193
+ if required_enabled.issubset(df_enabled.columns):
194
+ for _, row in df_enabled.iterrows():
195
+ r, m = row["resource"], row["method"]
196
+ enabled_methods.setdefault(r, {})[m] = bool(row["enabled"])
197
+
198
+ return resource_caps, constraints, costs, custom_resources, enabled_methods, warnings
199
+
200
+ def strip_unit_suffix(resource_name: str) -> str:
201
+ """Remove trailing unit suffix from a resource name.
202
+
203
+ E.g. "Unspecified land (Mha/MtCO₂)" → "Unspecified land".
204
+
205
+ Args:
206
+ resource_name (str): raw resource name, possibly with a parenthetical unit.
207
+
208
+ Returns:
209
+ str: resource name without the unit suffix.
210
+ """
211
+ return re.sub(r"\s*\(.*?\)\s*$", "", resource_name).strip()
212
+
213
+ def neutralize_zero_coefficient_methods(method_costs: dict, method_constraints: dict) -> dict:
214
+ """Return a copy of method_constraints with methods that have all-zero costs deactivated.
215
+
216
+ Prevents the LP from including methods with no usable resource coefficients.
217
+
218
+ Args:
219
+ method_costs (dict): {method: {resource: coefficient}} cost matrix.
220
+ method_constraints (dict): {method: {active, cap_type, cap_value}}.
221
+
222
+ Returns:
223
+ dict: modified copy of method_constraints with zero-cost methods deactivated.
224
+ """
225
+ safe_constraints = {m: c.copy() for m, c in method_constraints.items()}
226
+
227
+ for method, constraint in safe_constraints.items():
228
+ if not constraint.get("active", True):
229
+ continue
230
+ coeffs = method_costs.get(method, {})
231
+ all_zero = all(v == 0 for v in coeffs.values()) if coeffs else True
232
+ if all_zero:
233
+ constraint["active"] = False
234
+
235
  return safe_constraints
utils/ui_helpers.py CHANGED
@@ -4,6 +4,15 @@ from config.config import CRRA_COLORS, CRRA_FONT, PLOTLY_TEMPLATE
4
 
5
 
6
  def apply_chart_style(fig, height=None):
 
 
 
 
 
 
 
 
 
7
  fig.update_layout(
8
  template=PLOTLY_TEMPLATE,
9
  font=dict(family=CRRA_FONT, size=12, color=CRRA_COLORS["black"]),
@@ -20,12 +29,25 @@ def apply_chart_style(fig, height=None):
20
 
21
 
22
  def register_chart_data(name: str, df: pd.DataFrame):
23
- """Enregistre les données d'un graphique pour l'export consolidé final."""
 
 
 
 
 
 
 
 
24
  st.session_state.setdefault("chart_data_registry", {})
25
  st.session_state["chart_data_registry"][name] = df.copy()
26
 
27
 
28
  def show_bar():
 
 
 
 
 
29
  st.markdown(
30
  """
31
  <div class="et_pb_module">
@@ -42,6 +64,17 @@ def show_bar():
42
 
43
 
44
  def smart_display_format(val):
 
 
 
 
 
 
 
 
 
 
 
45
  if abs(val) < 1e-8:
46
  return ""
47
  elif abs(val) < 0.001:
@@ -57,6 +90,17 @@ def smart_display_format(val):
57
 
58
 
59
  def format_efficiency_summary(df: pd.DataFrame, df_default: pd.DataFrame):
 
 
 
 
 
 
 
 
 
 
 
60
  df_numeric = df.copy()
61
  df_display = df_numeric.map(smart_display_format)
62
 
 
4
 
5
 
6
  def apply_chart_style(fig, height=None):
7
+ """Apply the standard CRRA theme (font, colours, margins) to a Plotly figure.
8
+
9
+ Args:
10
+ fig: Plotly figure object.
11
+ height (int | None): optional fixed height in pixels.
12
+
13
+ Returns:
14
+ fig: the same figure with updated layout.
15
+ """
16
  fig.update_layout(
17
  template=PLOTLY_TEMPLATE,
18
  font=dict(family=CRRA_FONT, size=12, color=CRRA_COLORS["black"]),
 
29
 
30
 
31
  def register_chart_data(name: str, df: pd.DataFrame):
32
+ """Store a chart's underlying DataFrame in the session-level registry for bulk export.
33
+
34
+ Args:
35
+ name (str): unique chart label used as the dict key and export sheet name.
36
+ df (pd.DataFrame): data behind the chart.
37
+
38
+ Returns:
39
+ None
40
+ """
41
  st.session_state.setdefault("chart_data_registry", {})
42
  st.session_state["chart_data_registry"][name] = df.copy()
43
 
44
 
45
  def show_bar():
46
+ """Render the CRRA four-colour decorative bar below page headings.
47
+
48
+ Returns:
49
+ None
50
+ """
51
  st.markdown(
52
  """
53
  <div class="et_pb_module">
 
64
 
65
 
66
  def smart_display_format(val):
67
+ """Format a numeric value with adaptive precision for table display.
68
+
69
+ Near-zero values are shown as an empty string; others use 2–5 decimal
70
+ places depending on magnitude.
71
+
72
+ Args:
73
+ val (float): numeric value to format.
74
+
75
+ Returns:
76
+ str: formatted string, or "" for near-zero values.
77
+ """
78
  if abs(val) < 1e-8:
79
  return ""
80
  elif abs(val) < 0.001:
 
90
 
91
 
92
  def format_efficiency_summary(df: pd.DataFrame, df_default: pd.DataFrame):
93
+ """Build a styled Pandas Styler for the resource-efficiency coefficient summary table.
94
+
95
+ Cells whose value differs from the default median are highlighted in red.
96
+
97
+ Args:
98
+ df (pd.DataFrame): current coefficient values (methods × resources).
99
+ df_default (pd.DataFrame): default median values for the same shape.
100
+
101
+ Returns:
102
+ pandas.io.formats.style.Styler: styled dataframe ready for st.dataframe().
103
+ """
104
  df_numeric = df.copy()
105
  df_display = df_numeric.map(smart_display_format)
106