Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/src/howto/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions docs/src/methodology.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

75 changes: 73 additions & 2 deletions reporting/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
f"({exc})"
)

import equivalences

DEFAULT_TOP_N = 10
MAX_TOP_N = 50
MAX_TAG_CHART_ROWS = 40
Expand Down Expand Up @@ -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"<div class='spruce-equiv-note'>{html.escape(item.note)}</div>"
if item.note
else ""
)
return f"""
<div class="spruce-equiv-item">
<span class="spruce-equiv-icon">{item.icon}</span>
<div>
<div class="spruce-equiv-num">{quantity}</div>
<div class="spruce-equiv-label">{html.escape(item.unit)}</div>
{note_html}
</div>
</div>
"""


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"""
<div class="spruce-card spruce-equiv-card">
<div class="spruce-card-label">
<span class="spruce-equiv-dot" style="background:{color}"></span>
{html.escape(group.metric)}
</div>
{items}
</div>
"""
)
source_links = " · ".join(
f"<a href='{url}' target='_blank' rel='noopener'>{html.escape(name)}</a>"
for name, url in equivalences.SOURCES
)
render_html(
"<div class='spruce-equiv-title'>In everyday terms</div>"
f"<div class='spruce-card-grid'>{''.join(cards)}</div>"
"<div class='spruce-equiv-sources'>"
f"Sources: {source_links}"
"</div>"
)


def render_overview(overview: pd.DataFrame, equiv_flavor: str) -> None:
if overview.empty:
st.info("No data available for the current filters.")
return
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand All @@ -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]:
Expand Down
195 changes: 195 additions & 0 deletions reporting/equivalences.py
Original file line number Diff line number Diff line change
@@ -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
Loading