File size: 8,692 Bytes
fa58ff0
 
 
 
 
 
 
e5f540d
7a80c0b
e5f540d
 
6b18587
 
fa58ff0
60dd41c
fa58ff0
 
60dd41c
4885a3b
 
fa58ff0
 
 
 
 
4885a3b
fa58ff0
 
 
 
 
4885a3b
 
 
 
fa58ff0
4885a3b
60dd41c
fa58ff0
4885a3b
 
fa58ff0
01c98b2
 
 
 
 
 
 
 
fa58ff0
90b7366
fa58ff0
 
 
 
 
 
 
 
90b7366
fa58ff0
20f0fcc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa58ff0
 
 
20f0fcc
fa58ff0
20f0fcc
fa58ff0
20f0fcc
fa58ff0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20f0fcc
 
 
fa58ff0
be0befa
 
6b18587
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa58ff0
 
be0befa
 
fa58ff0
 
393404f
 
 
be0befa
393404f
be0befa
 
 
393404f
01c98b2
 
 
fa58ff0
 
 
 
 
 
 
 
6b18587
 
 
 
 
 
 
 
 
 
 
be0befa
 
 
 
 
 
 
 
 
 
 
20f0fcc
 
 
 
 
 
 
 
 
 
 
 
 
 
fa58ff0
 
 
 
 
be0befa
fa58ff0
 
 
7a80c0b
fa58ff0
 
 
ba2dcff
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
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"<style>{f.read()}</style>", unsafe_allow_html=True)

css_path = pathlib.Path("assets/styles.css")
load_css(css_path)

st.markdown('<h1 class="custom-title">CRRA <span class="crra-hl-toolkit">Toolkit</span>: CDR Portfolio Optimisation</h1>', 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"""<script>
(function scroll{_scroll_ver}() {{
    var doScroll = function() {{
        try {{ window.parent.scrollTo(0, 0); }} catch(e) {{}}
        window.scrollTo(0, 0);
        document.documentElement.scrollTop = 0;
        document.body.scrollTop = 0;
    }};
    setTimeout(doScroll, 80);
}})();
</script>""")

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()