From 3afe8a71a8e5fe8b1f175b999c2355bba2d5a3df Mon Sep 17 00:00:00 2001 From: Davide Polato Date: Wed, 1 Jul 2026 16:52:18 +0200 Subject: [PATCH 1/8] feat: add impact equivalence conversions for dashboard overview Pure-Python helpers that translate emissions (kg CO2e), energy (kWh), and water (L) totals into everyday comparisons: family-car km, flights, home-years, cups of tea, Olympic pools, and bathtubs. Conversion factors are documented constants with cited sources so they stay auditable and easy to tune. No third-party dependencies. --- reporting/equivalences.py | 121 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 reporting/equivalences.py diff --git a/reporting/equivalences.py b/reporting/equivalences.py new file mode 100644 index 0000000..6e91bc8 --- /dev/null +++ b/reporting/equivalences.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +Human-readable impact equivalences for the SPRUCE dashboard overview. + +The overview shows raw totals (kg CO2e, kWh, litres). These helpers translate +those totals into everyday comparisons so the numbers are easier to grasp. + +Conversion factors are documented, order-of-magnitude figures meant to build +intuition, not precise accounting. Each factor cites a source; adjust the +constants here to change every equivalence at once. + +This module is pure Python (no third-party dependencies) so it can be unit +tested without the Streamlit stack. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +# --- Emissions (kg CO2e) --------------------------------------------------- +# Average family car, UK DEFRA/BEIS 2023 GHG conversion factors (~0.17 kg/km). +CO2_KG_PER_CAR_KM = 0.170 +# One-way economy long-haul flight London -> New York (~0.5 t CO2e; atmosfair/ICAO). +FLIGHT_ROUTE = "London โ†’ New York, one-way economy" +CO2_KG_PER_FLIGHT = 500.0 + +# --- Energy (kWh) ---------------------------------------------------------- +# Average EU household electricity use per year (~3,500 kWh; Eurostat/Odyssee). +KWH_PER_HOUSEHOLD_YEAR = 3500.0 +# Boiling one ~0.25 L mug of water in an electric kettle (~0.03 kWh). +KWH_PER_CUP_OF_TEA = 0.03 + +# --- Water (litres) -------------------------------------------------------- +# Olympic-size swimming pool volume: 2,500 m3. +L_PER_OLYMPIC_POOL = 2_500_000.0 +# Typical filled domestic bathtub (~150 L). +L_PER_BATHTUB = 150.0 + + +@dataclass(frozen=True) +class Equivalence: + """A single everyday comparison, e.g. 1,234 "km driven by an average car".""" + + quantity: float + unit: str + note: str = "" + + +@dataclass(frozen=True) +class EquivalenceGroup: + """Equivalences derived from one overview metric.""" + + metric: str + items: tuple[Equivalence, ...] + + +def _non_negative(value: object) -> float: + """Coerce a possibly-missing numeric value to a non-negative float.""" + try: + number = float(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return 0.0 + if number != number or number < 0: # NaN or negative + return 0.0 + return number + + +def emission_equivalences(co2_kg: object) -> EquivalenceGroup: + kg = _non_negative(co2_kg) + return EquivalenceGroup( + "Emissions", + ( + Equivalence(kg / CO2_KG_PER_CAR_KM, "km in a family car"), + Equivalence(kg / CO2_KG_PER_FLIGHT, "flights", FLIGHT_ROUTE), + ), + ) + + +def energy_equivalences(kwh: object) -> EquivalenceGroup: + energy = _non_negative(kwh) + return EquivalenceGroup( + "Energy", + ( + Equivalence( + energy / KWH_PER_HOUSEHOLD_YEAR, "homes powered for a year" + ), + Equivalence(energy / KWH_PER_CUP_OF_TEA, "cups of tea boiled"), + ), + ) + + +def water_equivalences(litres: object) -> EquivalenceGroup: + water = _non_negative(litres) + return EquivalenceGroup( + "Water", + ( + Equivalence(water / L_PER_OLYMPIC_POOL, "Olympic swimming pools"), + Equivalence(water / L_PER_BATHTUB, "bathtubs"), + ), + ) + + +def overview_equivalences( + co2_kg: object, energy_kwh: object, water_l: object +) -> list[EquivalenceGroup]: + """Build every equivalence group shown under the dashboard overview.""" + return [ + emission_equivalences(co2_kg), + energy_equivalences(energy_kwh), + water_equivalences(water_l), + ] + + +def format_quantity(quantity: float) -> str: + """Format an equivalence quantity with thousands separators and adaptive precision.""" + number = _non_negative(quantity) + if number >= 10: + return f"{number:,.0f}" + if number >= 1: + return f"{number:,.1f}" + return f"{number:,.2f}" From bd572db16d3129744268d2453a36fe9e1aad47f3 Mon Sep 17 00:00:00 2001 From: Davide Polato Date: Wed, 1 Jul 2026 16:53:04 +0200 Subject: [PATCH 2/8] feat: show everyday impact equivalences under dashboard overview Render an "In everyday terms" block beneath the overview metric cards, translating the emissions, energy, and water totals into the comparisons requested in #206. Metric labels reuse the dashboard palette (brown emissions, yellow energy, blue water) to stay consistent with the charts. Kept lightweight per the issue: a compact caption block, no extra selector. --- reporting/dashboard.py | 43 ++++++++++++++++++++++++++++++++++++++++++ reporting/styles.css | 40 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/reporting/dashboard.py b/reporting/dashboard.py index e119132..6764117 100644 --- a/reporting/dashboard.py +++ b/reporting/dashboard.py @@ -26,6 +26,8 @@ f"({exc})" ) +import equivalences + DEFAULT_TOP_N = 10 MAX_TOP_N = 50 MAX_TAG_CHART_ROWS = 40 @@ -797,6 +799,46 @@ def line_area_chart( # --------------------------------------------------------------------------- +def equivalence_item_html(item: equivalences.Equivalence) -> str: + quantity = html.escape(equivalences.format_quantity(item.quantity)) + text = f"{quantity} {html.escape(item.unit)}" + if item.note: + text += f" ({html.escape(item.note)})" + return text + + +EQUIV_METRIC_COLORS = { + "Emissions": COLORS["emissions"], + "Energy": COLORS["energy"], + "Water": COLORS["water"], +} + + +def render_equivalences(row: pd.Series) -> None: + groups = equivalences.overview_equivalences( + row["total_emissions_kg"], row["energy_kwh"], row["water_l"] + ) + rows = [] + for group in groups: + color = EQUIV_METRIC_COLORS.get(group.metric, COLORS["muted"]) + items = " · ".join( + equivalence_item_html(item) for item in group.items + ) + rows.append( + "
" + f"" + f"{html.escape(group.metric)}" + f"≈ {items}" + "
" + ) + render_html( + "
" + "
In everyday terms
" + f"{''.join(rows)}" + "
" + ) + + def render_overview(overview: pd.DataFrame) -> None: if overview.empty: st.info("No data available for the current filters.") @@ -813,6 +855,7 @@ def render_overview(overview: pd.DataFrame) -> None: ("Coverage (dataset-wide)", metric_value(row["coverage_pct"], " %"), ""), ] ) + render_equivalences(row) def render_trend(trend: pd.DataFrame) -> None: diff --git a/reporting/styles.css b/reporting/styles.css index eb7d066..5860e79 100644 --- a/reporting/styles.css +++ b/reporting/styles.css @@ -229,6 +229,46 @@ section[data-testid="stSidebar"] p { overflow-wrap: anywhere; } +.spruce-equiv { + background: var(--spruce-surface); + border: 1px solid var(--spruce-border); + border-radius: 8px; + box-shadow: var(--spruce-shadow); + padding: 0.9rem 1.18rem; + margin: -0.55rem 0 1.4rem; +} + +.spruce-equiv-title { + color: var(--spruce-muted); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 0.5rem; +} + +.spruce-equiv-row { + display: flex; + gap: 0.6rem; + padding: 0.22rem 0; + font-size: 0.9rem; + line-height: 1.4; + color: var(--spruce-text); +} + +.spruce-equiv-metric { + color: var(--spruce-muted); + min-width: 92px; + font-weight: 600; +} + +.spruce-equiv-items { + overflow-wrap: anywhere; +} + +.spruce-equiv-note { + color: var(--spruce-muted); +} + .spruce-table { width: 100%; border-collapse: separate; From 58bc1d6bed5e43058c4a0cad3b139abd4606b675 Mon Sep 17 00:00:00 2001 From: Davide Polato Date: Wed, 1 Jul 2026 16:53:44 +0200 Subject: [PATCH 3/8] docs: document dashboard impact equivalences Note the "In everyday terms" overview block in the dashboard how-to and point to reporting/equivalences.py for tuning the conversion factors. --- docs/src/howto/dashboard.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/src/howto/dashboard.md b/docs/src/howto/dashboard.md index 57cd0f7..6af6c9f 100644 --- a/docs/src/howto/dashboard.md +++ b/docs/src/howto/dashboard.md @@ -47,7 +47,7 @@ python scripts/gen_synthetic.py -o output/synth.parquet --force | Section | What it shows | |---|---| -| Overview | Total usage cost, energy, operational emissions, embodied emissions, water usage, and cost coverage for the full input | +| Overview | Total usage cost, energy, operational emissions, embodied emissions, water usage, and cost coverage for the full input, plus everyday equivalences (car km, flights, home-years, cups of tea, pools, baths) | | Trend | Separate energy, total emissions, and water charts by billing period | | Top emitters | Sorted top product/service/operation combinations as a table-first view | | Regions | Emissions share by region, plus regional KPI cards and detail table | @@ -58,6 +58,11 @@ present, choose one tag key to break down emissions by tag value. The cost coverage KPI matches `report.py` and is calculated across the full input, not only the currently selected filters. +The "In everyday terms" block under the overview cards converts the emissions, +energy, and water totals into human-readable equivalences. Conversion factors +are documented, order-of-magnitude figures; edit them in +`reporting/equivalences.py` to change the references or add more comparisons. + ## Screenshots ![SPRUCE dashboard overview](../images/dashboard-overview.png) From c7f87941d7ca1827dbf853ba76aedbb5381a00ba Mon Sep 17 00:00:00 2001 From: Davide Polato Date: Thu, 2 Jul 2026 10:07:14 +0200 Subject: [PATCH 4/8] feat: upgrade equivalences to icon tiles with selectable styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-caption "In everyday terms" block with per-metric cards in the style of Cloud Carbon Footprint and EcoLogits: each equivalence gets an icon, a prominent number, and a muted label, aligned on a fixed icon column. Metric identity is carried by a small colored dot plus the metric name, matching the chart palette without relying on color alone. Add a sidebar "Equivalence style" selector that switches the second comparison of each metric between neutral sets โ€” Everyday (flights, cups of tea, bathtubs), Tech (streaming hours, smartphone charges, washing cycles), and Nature (tree-years, solar-panel days, rainfall). The first comparison (car km, home-years, Olympic pools) stays fixed so totals remain comparable. New factors are documented constants with cited sources. Also trim trailing zeros from equivalence quantities ("8 flights" instead of "8.0 flights"). Refs #206 --- reporting/dashboard.py | 73 +++++++++++++++++++++------------ reporting/equivalences.py | 86 +++++++++++++++++++++++++++++++-------- reporting/styles.css | 58 ++++++++++++++++---------- 3 files changed, 153 insertions(+), 64 deletions(-) diff --git a/reporting/dashboard.py b/reporting/dashboard.py index 6764117..d655bb7 100644 --- a/reporting/dashboard.py +++ b/reporting/dashboard.py @@ -799,14 +799,6 @@ def line_area_chart( # --------------------------------------------------------------------------- -def equivalence_item_html(item: equivalences.Equivalence) -> str: - quantity = html.escape(equivalences.format_quantity(item.quantity)) - text = f"{quantity} {html.escape(item.unit)}" - if item.note: - text += f" ({html.escape(item.note)})" - return text - - EQUIV_METRIC_COLORS = { "Emissions": COLORS["emissions"], "Energy": COLORS["energy"], @@ -814,32 +806,51 @@ def equivalence_item_html(item: equivalences.Equivalence) -> str: } -def render_equivalences(row: pd.Series) -> None: +def equivalence_item_html(item: equivalences.Equivalence) -> str: + quantity = html.escape(equivalences.format_quantity(item.quantity)) + note_html = ( + f"
{html.escape(item.note)}
" + if item.note + else "" + ) + return f""" +
+ {item.icon} +
+
{quantity}
+
{html.escape(item.unit)}
+ {note_html} +
+
+ """ + + +def render_equivalences(row: pd.Series, flavor: str) -> None: groups = equivalences.overview_equivalences( - row["total_emissions_kg"], row["energy_kwh"], row["water_l"] + row["total_emissions_kg"], row["energy_kwh"], row["water_l"], flavor ) - rows = [] + cards = [] for group in groups: color = EQUIV_METRIC_COLORS.get(group.metric, COLORS["muted"]) - items = " · ".join( - equivalence_item_html(item) for item in group.items - ) - rows.append( - "
" - f"" - f"{html.escape(group.metric)}" - f"≈ {items}" - "
" + items = "".join(equivalence_item_html(item) for item in group.items) + cards.append( + f""" +
+
+ + {html.escape(group.metric)} +
+ {items} +
+ """ ) render_html( - "
" "
In everyday terms
" - f"{''.join(rows)}" - "
" + f"
{''.join(cards)}
" ) -def render_overview(overview: pd.DataFrame) -> None: +def render_overview(overview: pd.DataFrame, equiv_flavor: str) -> None: if overview.empty: st.info("No data available for the current filters.") return @@ -855,7 +866,7 @@ def render_overview(overview: pd.DataFrame) -> None: ("Coverage (dataset-wide)", metric_value(row["coverage_pct"], " %"), ""), ] ) - render_equivalences(row) + render_equivalences(row, equiv_flavor) def render_trend(trend: pd.DataFrame) -> None: @@ -1239,6 +1250,15 @@ def render_filters_sidebar( return selected_periods, selected_regions, tag_key +def render_equivalence_sidebar() -> str: + with st.sidebar: + return st.selectbox( + "Equivalence style", + equivalences.FLAVORS, + help="Comparison set used for the overview equivalences.", + ) + + def run_queries(selection: FilterSelection) -> DashboardData: where, params = filter_clause( list(selection.selected_periods), list(selection.selected_regions) @@ -1284,6 +1304,7 @@ def main() -> None: selected_periods, selected_regions, tag_key = render_filters_sidebar( periods, regions, tag_keys ) + equiv_flavor = render_equivalence_sidebar() selection = FilterSelection( input_path=input_path, selected_periods=tuple(selected_periods), @@ -1305,7 +1326,7 @@ def main() -> None: tab_containers = st.tabs(tabs) with tab_containers[0]: - render_overview(data.overview) + render_overview(data.overview, equiv_flavor) with tab_containers[1]: render_trend(data.trend) with tab_containers[2]: diff --git a/reporting/equivalences.py b/reporting/equivalences.py index 6e91bc8..3903ef3 100644 --- a/reporting/equivalences.py +++ b/reporting/equivalences.py @@ -23,27 +23,44 @@ # One-way economy long-haul flight London -> New York (~0.5 t CO2e; atmosfair/ICAO). FLIGHT_ROUTE = "London โ†’ New York, one-way economy" CO2_KG_PER_FLIGHT = 500.0 +# One hour of video streaming (~36 g CO2e; IEA 2020 global average). +CO2_KG_PER_STREAMING_HOUR = 0.036 +# CO2 absorbed by one mature tree in a year (~21 kg; EPA/Arbor Day figures). +CO2_KG_PER_TREE_YEAR = 21.0 # --- Energy (kWh) ---------------------------------------------------------- # Average EU household electricity use per year (~3,500 kWh; Eurostat/Odyssee). KWH_PER_HOUSEHOLD_YEAR = 3500.0 # Boiling one ~0.25 L mug of water in an electric kettle (~0.03 kWh). KWH_PER_CUP_OF_TEA = 0.03 +# Fully charging one smartphone (~0.012 kWh; EPA GHG equivalences). +KWH_PER_PHONE_CHARGE = 0.012 +# Daily output of one ~400 W rooftop solar panel (~1.5 kWh/day). +KWH_PER_SOLAR_PANEL_DAY = 1.5 # --- Water (litres) -------------------------------------------------------- # Olympic-size swimming pool volume: 2,500 m3. L_PER_OLYMPIC_POOL = 2_500_000.0 # Typical filled domestic bathtub (~150 L). L_PER_BATHTUB = 150.0 +# One washing machine cycle on a modern front loader (~50 L). +L_PER_WASHING_CYCLE = 50.0 +# A year of rain on one square metre of land (~750 L; ~715 mm global average). +L_PER_SQM_RAIN_YEAR = 750.0 + +# Selectable comparison styles for the second slot of each metric. The first +# slot (car km, homes, pools) stays fixed so totals remain comparable. +FLAVORS = ("Everyday", "Tech", "Nature") @dataclass(frozen=True) class Equivalence: - """A single everyday comparison, e.g. 1,234 "km driven by an average car".""" + """A single everyday comparison, e.g. 1,234 "km in a family car".""" quantity: float unit: str note: str = "" + icon: str = "" @dataclass(frozen=True) @@ -65,49 +82,85 @@ def _non_negative(value: object) -> float: return number -def emission_equivalences(co2_kg: object) -> EquivalenceGroup: +def _second_emission(kg: float, flavor: str) -> Equivalence: + if flavor == "Tech": + return Equivalence( + kg / CO2_KG_PER_STREAMING_HOUR, "hours of video streaming", icon="๐Ÿ“บ" + ) + if flavor == "Nature": + return Equivalence( + kg / CO2_KG_PER_TREE_YEAR, "tree-years to absorb it", icon="๐ŸŒณ" + ) + return Equivalence(kg / CO2_KG_PER_FLIGHT, "flights", FLIGHT_ROUTE, icon="โœˆ๏ธ") + + +def _second_energy(energy: float, flavor: str) -> Equivalence: + if flavor == "Tech": + return Equivalence( + energy / KWH_PER_PHONE_CHARGE, "smartphone charges", icon="๐Ÿ“ฑ" + ) + if flavor == "Nature": + return Equivalence( + energy / KWH_PER_SOLAR_PANEL_DAY, "solar-panel days", icon="โ˜€๏ธ" + ) + return Equivalence(energy / KWH_PER_CUP_OF_TEA, "cups of tea boiled", icon="โ˜•") + + +def _second_water(water: float, flavor: str) -> Equivalence: + if flavor == "Tech": + return Equivalence( + water / L_PER_WASHING_CYCLE, "washing machine cycles", icon="๐Ÿงบ" + ) + if flavor == "Nature": + return Equivalence( + water / L_PER_SQM_RAIN_YEAR, "mยฒ-years of rainfall", icon="๐ŸŒง๏ธ" + ) + return Equivalence(water / L_PER_BATHTUB, "bathtubs filled", icon="๐Ÿ›") + + +def emission_equivalences(co2_kg: object, flavor: str = "Everyday") -> EquivalenceGroup: kg = _non_negative(co2_kg) return EquivalenceGroup( "Emissions", ( - Equivalence(kg / CO2_KG_PER_CAR_KM, "km in a family car"), - Equivalence(kg / CO2_KG_PER_FLIGHT, "flights", FLIGHT_ROUTE), + Equivalence(kg / CO2_KG_PER_CAR_KM, "km in a family car", icon="๐Ÿš—"), + _second_emission(kg, flavor), ), ) -def energy_equivalences(kwh: object) -> EquivalenceGroup: +def energy_equivalences(kwh: object, flavor: str = "Everyday") -> EquivalenceGroup: energy = _non_negative(kwh) return EquivalenceGroup( "Energy", ( Equivalence( - energy / KWH_PER_HOUSEHOLD_YEAR, "homes powered for a year" + energy / KWH_PER_HOUSEHOLD_YEAR, "homes powered for a year", icon="๐Ÿ " ), - Equivalence(energy / KWH_PER_CUP_OF_TEA, "cups of tea boiled"), + _second_energy(energy, flavor), ), ) -def water_equivalences(litres: object) -> EquivalenceGroup: +def water_equivalences(litres: object, flavor: str = "Everyday") -> EquivalenceGroup: water = _non_negative(litres) return EquivalenceGroup( "Water", ( - Equivalence(water / L_PER_OLYMPIC_POOL, "Olympic swimming pools"), - Equivalence(water / L_PER_BATHTUB, "bathtubs"), + Equivalence(water / L_PER_OLYMPIC_POOL, "Olympic swimming pools", icon="๐ŸŠ"), + _second_water(water, flavor), ), ) def overview_equivalences( - co2_kg: object, energy_kwh: object, water_l: object + co2_kg: object, energy_kwh: object, water_l: object, flavor: str = "Everyday" ) -> list[EquivalenceGroup]: """Build every equivalence group shown under the dashboard overview.""" return [ - emission_equivalences(co2_kg), - energy_equivalences(energy_kwh), - water_equivalences(water_l), + emission_equivalences(co2_kg, flavor), + energy_equivalences(energy_kwh, flavor), + water_equivalences(water_l, flavor), ] @@ -116,6 +169,5 @@ def format_quantity(quantity: float) -> str: number = _non_negative(quantity) if number >= 10: return f"{number:,.0f}" - if number >= 1: - return f"{number:,.1f}" - return f"{number:,.2f}" + text = f"{number:,.1f}" if number >= 1 else f"{number:,.2f}" + return text.rstrip("0").rstrip(".") if "." in text else text diff --git a/reporting/styles.css b/reporting/styles.css index 5860e79..88daf29 100644 --- a/reporting/styles.css +++ b/reporting/styles.css @@ -229,44 +229,60 @@ section[data-testid="stSidebar"] p { overflow-wrap: anywhere; } -.spruce-equiv { - background: var(--spruce-surface); - border: 1px solid var(--spruce-border); - border-radius: 8px; - box-shadow: var(--spruce-shadow); - padding: 0.9rem 1.18rem; - margin: -0.55rem 0 1.4rem; -} - .spruce-equiv-title { color: var(--spruce-muted); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; - margin-bottom: 0.5rem; + margin: -0.35rem 0 0.6rem; } -.spruce-equiv-row { +.spruce-equiv-card { display: flex; - gap: 0.6rem; - padding: 0.22rem 0; - font-size: 0.9rem; - line-height: 1.4; - color: var(--spruce-text); + flex-direction: column; + gap: 0.7rem; } -.spruce-equiv-metric { - color: var(--spruce-muted); - min-width: 92px; - font-weight: 600; +.spruce-equiv-dot { + display: inline-block; + width: 9px; + height: 9px; + border-radius: 50%; + margin-right: 0.35rem; + vertical-align: baseline; +} + +.spruce-equiv-item { + display: flex; + align-items: flex-start; + gap: 0.65rem; } -.spruce-equiv-items { +.spruce-equiv-icon { + flex: 0 0 2rem; + font-size: 1.35rem; + line-height: 1.3; + text-align: center; +} + +.spruce-equiv-num { + color: var(--spruce-text); + font-size: 1.18rem; + font-weight: 720; + line-height: 1.15; overflow-wrap: anywhere; } +.spruce-equiv-label { + color: var(--spruce-muted); + font-size: 0.8rem; + line-height: 1.3; +} + .spruce-equiv-note { color: var(--spruce-muted); + font-size: 0.72rem; + line-height: 1.3; } .spruce-table { From ebbd0b926dec4d01d71660439d6929069e89d987 Mon Sep 17 00:00:00 2001 From: Davide Polato Date: Thu, 2 Jul 2026 10:07:56 +0200 Subject: [PATCH 5/8] docs: mention equivalence style selector in dashboard how-to --- docs/src/howto/dashboard.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/src/howto/dashboard.md b/docs/src/howto/dashboard.md index 6af6c9f..1a9e2ca 100644 --- a/docs/src/howto/dashboard.md +++ b/docs/src/howto/dashboard.md @@ -59,9 +59,11 @@ coverage KPI matches `report.py` and is calculated across the full input, not only the currently selected filters. The "In everyday terms" block under the overview cards converts the emissions, -energy, and water totals into human-readable equivalences. Conversion factors -are documented, order-of-magnitude figures; edit them in -`reporting/equivalences.py` to change the references or add more comparisons. +energy, and water totals into human-readable equivalences. The sidebar +"Equivalence style" selector switches the comparison set (Everyday, Tech, or +Nature). Conversion factors are documented, order-of-magnitude figures; edit +them in `reporting/equivalences.py` to change the references or add more +comparisons. ## Screenshots From 470ffaff98243a15415c902db29ce8d1843e1b20 Mon Sep 17 00:00:00 2001 From: Davide Polato Date: Fri, 3 Jul 2026 19:59:04 +0200 Subject: [PATCH 6/8] feat: surface conversion factor sources in the dashboard Add a user-facing factor table (comparison, factor, source) to equivalences.py, built from the same constants as the conversions so the displayed values cannot drift, and show it in a "Conversion factors and sources" expander under the overview equivalence tiles. --- reporting/dashboard.py | 10 ++++++++++ reporting/equivalences.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/reporting/dashboard.py b/reporting/dashboard.py index d655bb7..4c64dd7 100644 --- a/reporting/dashboard.py +++ b/reporting/dashboard.py @@ -848,6 +848,16 @@ def render_equivalences(row: pd.Series, flavor: str) -> None: "
In everyday terms
" f"
{''.join(cards)}
" ) + with st.expander("Conversion factors and sources"): + st.markdown( + "Order-of-magnitude figures meant to build intuition, not precise " + "accounting.\n\n" + + "| Comparison | Factor | Source |\n|---|---|---|\n" + + "\n".join( + f"| {label} | {factor} | {source} |" + for label, factor, source in equivalences.FACTOR_TABLE + ) + ) def render_overview(overview: pd.DataFrame, equiv_flavor: str) -> None: diff --git a/reporting/equivalences.py b/reporting/equivalences.py index 3903ef3..9950725 100644 --- a/reporting/equivalences.py +++ b/reporting/equivalences.py @@ -52,6 +52,43 @@ # slot (car km, homes, pools) stays fixed so totals remain comparable. FLAVORS = ("Everyday", "Tech", "Nature") +# User-facing factor table: (comparison, factor, source). Values reference the +# constants above so the displayed factors cannot drift from the conversions. +FACTOR_TABLE = ( + ("km in a family car", f"{CO2_KG_PER_CAR_KM} kg CO2e/km", "UK DEFRA/BEIS 2023"), + (f"flight ({FLIGHT_ROUTE})", f"{CO2_KG_PER_FLIGHT:.0f} kg CO2e", "atmosfair/ICAO"), + ( + "hour of video streaming", + f"{CO2_KG_PER_STREAMING_HOUR * 1000:.0f} g CO2e", + "IEA 2020 global average", + ), + ( + "tree-year of CO2 absorption", + f"{CO2_KG_PER_TREE_YEAR:.0f} kg CO2", + "EPA/Arbor Day", + ), + ( + "home powered for a year", + f"{KWH_PER_HOUSEHOLD_YEAR:.0f} kWh", + "Eurostat EU average", + ), + ("cup of tea boiled", f"{KWH_PER_CUP_OF_TEA} kWh", "electric kettle, ~0.25 L"), + ( + "smartphone charge", + f"{KWH_PER_PHONE_CHARGE} kWh", + "EPA GHG equivalences", + ), + ("solar-panel day", f"{KWH_PER_SOLAR_PANEL_DAY} kWh", "~400 W rooftop panel"), + ("Olympic swimming pool", f"{L_PER_OLYMPIC_POOL:,.0f} L", "2,500 m3 volume"), + ("bathtub filled", f"{L_PER_BATHTUB:.0f} L", "typical domestic tub"), + ("washing machine cycle", f"{L_PER_WASHING_CYCLE:.0f} L", "modern front loader"), + ( + "mยฒ-year of rainfall", + f"{L_PER_SQM_RAIN_YEAR:.0f} L", + "~715 mm global land average", + ), +) + @dataclass(frozen=True) class Equivalence: From d32c6811ae3d28a9ecb5b33a872db26ef6f2369b Mon Sep 17 00:00:00 2001 From: Davide Polato Date: Fri, 3 Jul 2026 20:00:31 +0200 Subject: [PATCH 7/8] feat: add everyday equivalences section to the static report Reuse reporting/equivalences.py in report.py so the static report and the dashboard stay consistent: an "In Everyday Terms" section after the billing summary converts the emissions, energy, and water totals with the same factors, followed by the factor/source table. Text only (no icons) so the section renders cleanly in Markdown, HTML, and PDF output. --- reporting/report.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/reporting/report.py b/reporting/report.py index cd9f607..b3c2e88 100644 --- a/reporting/report.py +++ b/reporting/report.py @@ -15,6 +15,8 @@ except ImportError: sys.exit("duckdb is required โ€” run: pip install -r requirements-report.txt") +import equivalences + # --------------------------------------------------------------------------- # Markdown helpers @@ -451,6 +453,32 @@ def main(): md_table(billing_with_total, ["BILLING_PERIOD", "energy_kwh", "operational_kg", "embodied_kg", "water_usage_l"]) )) + # Everyday equivalences (same conversions as the dashboard overview) + if billing_rows: + total_energy = sum(r[1] for r in billing_rows if r[1] is not None) + total_emissions = ( + sum(r[2] for r in billing_rows if r[2] is not None) + + sum(r[3] for r in billing_rows if r[3] is not None) + ) + total_water = sum(r[4] for r in billing_rows if r[4] is not None) + equiv_lines = [] + for group in equivalences.overview_equivalences( + total_emissions, total_energy, total_water + ): + comparisons = " / ".join( + f"{equivalences.format_quantity(item.quantity)} {item.unit}" + + (f" ({item.note})" if item.note else "") + for item in group.items + ) + equiv_lines.append(f"- **{group.metric}** โ‰ˆ {comparisons}") + equiv_body = ( + "\n".join(equiv_lines) + + "\n\nConversion factors are order-of-magnitude figures meant to " + "build intuition, not precise accounting:\n\n" + + md_table(list(equivalences.FACTOR_TABLE), ["Comparison", "Factor", "Source"]) + ) + parts.append(section("In Everyday Terms", equiv_body)) + # Top emitters parts.append(section( "Top Emitters by Service", From 6c4943f03ec196bfb76435c7f8e2b74e1563b4ea Mon Sep 17 00:00:00 2001 From: Davide Polato Date: Sat, 4 Jul 2026 14:19:34 +0200 Subject: [PATCH 8/8] Point equivalence sources at their origins Replace the factor table in the dashboard expander and in the static report with a single line of links to where the conversion factors come from (DEFRA, atmosfair, IEA, EPA, Eurostat). The full factor table moves to the methodology page of the documentation. --- docs/src/methodology.md | 19 +++++++++++++++++ reporting/dashboard.py | 17 +++++++--------- reporting/equivalences.py | 43 +++++++++++++-------------------------- reporting/report.py | 9 +++++--- reporting/styles.css | 15 ++++++++++++++ 5 files changed, 61 insertions(+), 42 deletions(-) diff --git a/docs/src/methodology.md b/docs/src/methodology.md index e0bc466..80eb07e 100644 --- a/docs/src/methodology.md +++ b/docs/src/methodology.md @@ -42,3 +42,22 @@ SPRUCE also estimates water consumption: See the [enrichment modules](modules.md) page for details on how each estimate is computed. +## Everyday equivalences + +The dashboard and the static report translate the emissions, energy, and water totals into everyday comparisons ("In everyday terms"). These conversions use documented, order-of-magnitude factors meant to build intuition, not precise accounting. The factors live in `reporting/equivalences.py`; the table below lists them with their sources. + +| Comparison | Factor | Source | +|---|---|---| +| km in a family car | 0.17 kg CO2e/km | [UK DEFRA/BEIS 2023 conversion factors](https://www.gov.uk/government/collections/government-conversion-factors-for-company-reporting) | +| flight (London โ†’ New York, one-way economy) | 500 kg CO2e | [atmosfair flight calculator](https://www.atmosfair.de/en/offset/flight/) | +| hour of video streaming | 36 g CO2e | [IEA, The carbon footprint of streaming video](https://www.iea.org/commentaries/the-carbon-footprint-of-streaming-video-fact-checking-the-headlines) | +| tree-year of CO2 absorption | 21 kg CO2 | [EPA Greenhouse Gas Equivalencies Calculator](https://www.epa.gov/energy/greenhouse-gas-equivalencies-calculator) | +| home powered for a year | 3,500 kWh | [Eurostat, Energy consumption in households](https://ec.europa.eu/eurostat/statistics-explained/index.php?title=Energy_consumption_in_households) | +| cup of tea boiled | 0.03 kWh | typical electric kettle, ~0.25 L | +| smartphone charge | 0.012 kWh | [EPA Greenhouse Gas Equivalencies Calculator](https://www.epa.gov/energy/greenhouse-gas-equivalencies-calculator) | +| solar-panel day | 1.5 kWh | typical ~400 W rooftop panel | +| Olympic swimming pool | 2,500,000 L | 2,500 mยณ nominal volume | +| bathtub filled | 150 L | typical domestic tub | +| washing machine cycle | 50 L | typical modern front loader | +| mยฒ-year of rainfall | 750 L | ~715 mm global land average | + diff --git a/reporting/dashboard.py b/reporting/dashboard.py index 4c64dd7..5025bcb 100644 --- a/reporting/dashboard.py +++ b/reporting/dashboard.py @@ -844,20 +844,17 @@ def render_equivalences(row: pd.Series, flavor: str) -> None: """ ) + source_links = " ยท ".join( + f"{html.escape(name)}" + for name, url in equivalences.SOURCES + ) render_html( "
In everyday terms
" f"
{''.join(cards)}
" + "
" + f"Sources: {source_links}" + "
" ) - with st.expander("Conversion factors and sources"): - st.markdown( - "Order-of-magnitude figures meant to build intuition, not precise " - "accounting.\n\n" - + "| Comparison | Factor | Source |\n|---|---|---|\n" - + "\n".join( - f"| {label} | {factor} | {source} |" - for label, factor, source in equivalences.FACTOR_TABLE - ) - ) def render_overview(overview: pd.DataFrame, equiv_flavor: str) -> None: diff --git a/reporting/equivalences.py b/reporting/equivalences.py index 9950725..a34eb2f 100644 --- a/reporting/equivalences.py +++ b/reporting/equivalences.py @@ -52,40 +52,25 @@ # slot (car km, homes, pools) stays fixed so totals remain comparable. FLAVORS = ("Everyday", "Tech", "Nature") -# User-facing factor table: (comparison, factor, source). Values reference the -# constants above so the displayed factors cannot drift from the conversions. -FACTOR_TABLE = ( - ("km in a family car", f"{CO2_KG_PER_CAR_KM} kg CO2e/km", "UK DEFRA/BEIS 2023"), - (f"flight ({FLIGHT_ROUTE})", f"{CO2_KG_PER_FLIGHT:.0f} kg CO2e", "atmosfair/ICAO"), +# Pointers to the origins of the conversion factors (CCF-style). The full +# factor table lives in the documentation methodology page. +SOURCES = ( ( - "hour of video streaming", - f"{CO2_KG_PER_STREAMING_HOUR * 1000:.0f} g CO2e", - "IEA 2020 global average", + "DEFRA", + "https://www.gov.uk/government/collections/" + "government-conversion-factors-for-company-reporting", ), + ("atmosfair", "https://www.atmosfair.de/en/offset/flight/"), ( - "tree-year of CO2 absorption", - f"{CO2_KG_PER_TREE_YEAR:.0f} kg CO2", - "EPA/Arbor Day", + "IEA", + "https://www.iea.org/commentaries/" + "the-carbon-footprint-of-streaming-video-fact-checking-the-headlines", ), + ("EPA", "https://www.epa.gov/energy/greenhouse-gas-equivalencies-calculator"), ( - "home powered for a year", - f"{KWH_PER_HOUSEHOLD_YEAR:.0f} kWh", - "Eurostat EU average", - ), - ("cup of tea boiled", f"{KWH_PER_CUP_OF_TEA} kWh", "electric kettle, ~0.25 L"), - ( - "smartphone charge", - f"{KWH_PER_PHONE_CHARGE} kWh", - "EPA GHG equivalences", - ), - ("solar-panel day", f"{KWH_PER_SOLAR_PANEL_DAY} kWh", "~400 W rooftop panel"), - ("Olympic swimming pool", f"{L_PER_OLYMPIC_POOL:,.0f} L", "2,500 m3 volume"), - ("bathtub filled", f"{L_PER_BATHTUB:.0f} L", "typical domestic tub"), - ("washing machine cycle", f"{L_PER_WASHING_CYCLE:.0f} L", "modern front loader"), - ( - "mยฒ-year of rainfall", - f"{L_PER_SQM_RAIN_YEAR:.0f} L", - "~715 mm global land average", + "Eurostat", + "https://ec.europa.eu/eurostat/statistics-explained/index.php" + "?title=Energy_consumption_in_households", ), ) diff --git a/reporting/report.py b/reporting/report.py index b3c2e88..3af9230 100644 --- a/reporting/report.py +++ b/reporting/report.py @@ -471,11 +471,14 @@ def main(): for item in group.items ) equiv_lines.append(f"- **{group.metric}** โ‰ˆ {comparisons}") + source_links = ", ".join( + f"[{name}]({url})" for name, url in equivalences.SOURCES + ) equiv_body = ( "\n".join(equiv_lines) - + "\n\nConversion factors are order-of-magnitude figures meant to " - "build intuition, not precise accounting:\n\n" - + md_table(list(equivalences.FACTOR_TABLE), ["Comparison", "Factor", "Source"]) + + "\n\n_Order-of-magnitude conversion factors โ€” sources: " + + source_links + + "._\n" ) parts.append(section("In Everyday Terms", equiv_body)) diff --git a/reporting/styles.css b/reporting/styles.css index 88daf29..4b37c7d 100644 --- a/reporting/styles.css +++ b/reporting/styles.css @@ -285,6 +285,21 @@ section[data-testid="stSidebar"] p { line-height: 1.3; } +.spruce-equiv-sources { + color: var(--spruce-muted); + font-size: 0.76rem; + margin: -0.9rem 0 1.4rem; +} + +.spruce-equiv-sources a { + color: var(--spruce-accent); + text-decoration: none; +} + +.spruce-equiv-sources a:hover { + text-decoration: underline; +} + .spruce-table { width: 100%; border-collapse: separate;