import hashlib import os import sys import subprocess import pathlib import streamlit as st from streamlit_option_menu import option_menu from tabs import (tab0_welcome, tab1_inputs, tab2_constraints, tab3_coefficients, tab4_optimisation, tab5_sensitivity, tab6_scenarios) from utils.data_utils import load_slider_data, strip_unit_suffix from utils.state_utils import init_custom_resources from config.config import method_order, resource_order, secondary_resources # ── Auto-update: regenerate JSON files if inputs.csv has changed ─── # Uses a hash file to detect changes — works both locally and on Hugging Face. # Must run before importing config/tabs, which read the generated JSON files. csv = "data/inputs.csv" hash_file = "data/.csv_hash" generated = [ "data/cost_sliders.json", "data/method_groups.json", "data/secondary_resources.json", ] def md5(path): hasher = hashlib.md5() with open(path, "rb") as f: hasher.update(f.read()) return hasher.hexdigest() if os.path.exists(csv): current_hash = md5(csv) stored_hash = open(hash_file).read().strip() if os.path.exists(hash_file) else "" missing = any(not os.path.exists(f) for f in generated) if current_hash != stored_hash or missing: print("inputs.csv has changed — running data/update.py ...") subprocess.run([sys.executable, "data/update.py"], check=True) with open(hash_file, "w") as file: file.write(current_hash) print("Data files updated successfully.") # Force all app modules to reload on next rerun so they read fresh JSONs. # config.config is imported at module level and won't re-execute unless we # evict it from sys.modules before st.rerun() re-runs the script. for _key in list(sys.modules.keys()): if any(_key.startswith(p) for p in ("config", "tabs", "utils")): del sys.modules[_key] st.cache_data.clear() st.rerun() st.set_page_config("Carbon Portfolio Optimiser", layout="wide") def load_css(file_path): with open(file_path) as f: st.markdown(f"", unsafe_allow_html=True) css_path = pathlib.Path("assets/styles.css") load_css(css_path) st.markdown('

CRRA Toolkit: CDR Portfolio Optimisation

', unsafe_allow_html=True) _NAV_OPTIONS = [ "Welcome", "Resource inputs", "Method constraints", "Resource coefficients", "Portfolio generation", "Sensitivity analysis", "Scenario comparison", ] _nav_target = st.session_state.pop("_navigate_to", None) if _nav_target and _nav_target in _NAV_OPTIONS: st.session_state["_menu_default_idx"] = _NAV_OPTIONS.index(_nav_target) st.session_state["_menu_ver"] = st.session_state.get("_menu_ver", 0) + 1 st.session_state["_scroll_ver"] = st.session_state["_menu_ver"] _menu_default_idx = st.session_state.get("_menu_default_idx", 0) _menu_ver = st.session_state.get("_menu_ver", 0) with st.sidebar: selected = option_menu( "Navigation", options=_NAV_OPTIONS, icons=["house", "upload", "gear", "sliders", "graph-up-arrow", "card-checklist", "search"], default_index=_menu_default_idx, orientation="vertical", key=f"crra-menu-{_menu_ver}", styles={ "container":{ "border-radius":"0px", }, "menu-title":{ "font-family": "Segoe UI, sans-serif", "font-weight":"bold", "color": "#ff5f4d", "text-transform": "uppercase", "font-style": "italic", }, "nav-link": { "font-family": "Segoe UI, sans-serif", "margin": "4px 0", "--hover-color": "#F5F5F5", "text-transform": "uppercase", "font-weight":"600", "font-style": "italic", }, "nav-link-selected": { "background-color": "#eae9d8", "color": "#298231", "font-weight": "900", }, } ) # Track sidebar clicks so the next "Next" button starts from the right index if selected in _NAV_OPTIONS: st.session_state["_menu_default_idx"] = _NAV_OPTIONS.index(selected) cost_sliders = load_slider_data() # Initialise resource_caps / custom_resources / enabled_methods / auto_disabled_methods # here (not just in tab1) — the app's default landing tab is "Welcome", so a # user can reach any other tab (e.g. "Portfolio generation") without ever # rendering tab1 first. Several tabs access these via direct attribute # access (st.session_state.resource_caps, not .get()), which crashes with # an uninitialised-key AttributeError if tab1 hasn't run yet. if "resource_caps" not in st.session_state: st.session_state.resource_caps = {} for r in resource_order: if r not in st.session_state.resource_caps: st.session_state.resource_caps[r] = 0.0 init_custom_resources() # Same reasoning for "scenarios" — tab4's "Save scenario" button writes to it # directly, but it was previously only ever initialised inside tab6's render. if "scenarios" not in st.session_state: st.session_state.scenarios = {} if "constraints" not in st.session_state: st.session_state.constraints = {} if "auto_disabled_methods" not in st.session_state: st.session_state.auto_disabled_methods = set() for m in method_order: if m not in st.session_state.constraints: # All methods start disabled; tab2 pre-render sync auto-activates each # method once all its required resources are available (standard > 0 and # hidden resources covered by a custom batch). st.session_state.constraints[m] = { "active": False, "cap_type": "percent", "cap_value": 100, } st.session_state.auto_disabled_methods.add(m) _expected_methods = set(cost_sliders.keys()) _current_methods = set(st.session_state.get("costs", {}).keys()) if "costs" not in st.session_state or st.session_state.costs == {} or _expected_methods != _current_methods: st.session_state.costs = { m: { strip_unit_suffix(r): (0.0 if (m, strip_unit_suffix(r)) in secondary_resources else cost_sliders[m][r]["median"]) for r in cost_sliders[m] } for m in cost_sliders } # Auto-activate/deactivate methods based on real resource availability on # EVERY render, not only when Tab 2 is the active tab — otherwise a user who # goes straight from Tab 1 to Tab 3/4/5 without visiting Tab 2 first would # find every method stuck "inactive" and the optimisation would fail with # "No active methods" despite correctly configured resources. _cost_sliders_norm = { m: {strip_unit_suffix(name): conf for name, conf in resources.items()} for m, resources in cost_sliders.items() } tab2_constraints.refresh_active_methods(_cost_sliders_norm) # When the user navigates TO "Method constraints" from any other tab, force a # full widget re-sync so toggle_M / slider_M keys always reflect the constraint # state. Normal-mode setdefault can't recover keys that were externally set to # False/0; force mode overwrites them unconditionally from constraints (which # remain correct — the summary table at the bottom of tab2 always confirms # this). The flag is consumed (popped) by tab2's own pre-render sync. _prev_tab = st.session_state.get("_active_tab") if selected == "Method constraints" and _prev_tab != "Method constraints": st.session_state["constraints_just_loaded"] = True st.session_state["_active_tab"] = selected _scroll_ver = st.session_state.pop("_scroll_ver", None) if _scroll_ver is not None: st.html(f"""""") if selected == "Welcome": tab0_welcome.render_tab() elif selected == "Resource inputs": tab1_inputs.render_tab(cost_sliders) elif selected == "Method constraints": tab2_constraints.render_tab(cost_sliders) elif selected == "Resource coefficients": tab3_coefficients.render_tab(cost_sliders) elif selected == "Portfolio generation": tab4_optimisation.render_tab() elif selected == "Sensitivity analysis": tab5_sensitivity.render_tab() elif selected == "Scenario comparison": tab6_scenarios.render_tab()