diff --git a/docs/src/howto/dashboard.md b/docs/src/howto/dashboard.md index 57cd0f7..1a9e2ca 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,13 @@ 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. 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 ![SPRUCE dashboard overview](../images/dashboard-overview.png) 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 e119132..5025bcb 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,7 +799,65 @@ def line_area_chart( # --------------------------------------------------------------------------- -def render_overview(overview: pd.DataFrame) -> None: +EQUIV_METRIC_COLORS = { + "Emissions": COLORS["emissions"], + "Energy": COLORS["energy"], + "Water": COLORS["water"], +} + + +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"], flavor + ) + 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) + cards.append( + f""" +
+
+ + {html.escape(group.metric)} +
+ {items} +
+ """ + ) + 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}" + "
" + ) + + +def render_overview(overview: pd.DataFrame, equiv_flavor: str) -> None: if overview.empty: st.info("No data available for the current filters.") return @@ -813,6 +873,7 @@ def render_overview(overview: pd.DataFrame) -> None: ("Coverage (dataset-wide)", metric_value(row["coverage_pct"], " %"), ""), ] ) + render_equivalences(row, equiv_flavor) def render_trend(trend: pd.DataFrame) -> None: @@ -1196,6 +1257,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) @@ -1241,6 +1311,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), @@ -1262,7 +1333,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 new file mode 100644 index 0000000..a34eb2f --- /dev/null +++ b/reporting/equivalences.py @@ -0,0 +1,195 @@ +#!/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 +# 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") + +# Pointers to the origins of the conversion factors (CCF-style). The full +# factor table lives in the documentation methodology page. +SOURCES = ( + ( + "DEFRA", + "https://www.gov.uk/government/collections/" + "government-conversion-factors-for-company-reporting", + ), + ("atmosfair", "https://www.atmosfair.de/en/offset/flight/"), + ( + "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"), + ( + "Eurostat", + "https://ec.europa.eu/eurostat/statistics-explained/index.php" + "?title=Energy_consumption_in_households", + ), +) + + +@dataclass(frozen=True) +class Equivalence: + """A single everyday comparison, e.g. 1,234 "km in a family car".""" + + quantity: float + unit: str + note: str = "" + icon: 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 _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", icon="🚗"), + _second_emission(kg, flavor), + ), + ) + + +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", icon="🏠" + ), + _second_energy(energy, flavor), + ), + ) + + +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", icon="🏊"), + _second_water(water, flavor), + ), + ) + + +def overview_equivalences( + 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, flavor), + energy_equivalences(energy_kwh, flavor), + water_equivalences(water_l, flavor), + ] + + +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}" + 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/report.py b/reporting/report.py index cd9f607..3af9230 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,35 @@ 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}") + source_links = ", ".join( + f"[{name}]({url})" for name, url in equivalences.SOURCES + ) + equiv_body = ( + "\n".join(equiv_lines) + + "\n\n_Order-of-magnitude conversion factors — sources: " + + source_links + + "._\n" + ) + parts.append(section("In Everyday Terms", equiv_body)) + # Top emitters parts.append(section( "Top Emitters by Service", diff --git a/reporting/styles.css b/reporting/styles.css index eb7d066..4b37c7d 100644 --- a/reporting/styles.css +++ b/reporting/styles.css @@ -229,6 +229,77 @@ section[data-testid="stSidebar"] p { overflow-wrap: anywhere; } +.spruce-equiv-title { + color: var(--spruce-muted); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + margin: -0.35rem 0 0.6rem; +} + +.spruce-equiv-card { + display: flex; + flex-direction: column; + gap: 0.7rem; +} + +.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-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-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;