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

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;