Chart Data Endpoints — Implementation Plan¶
Context¶
The results page charts (ModuleCarbonFootprintChart and CarbonFootPrintPerPersonChart) previously used hardcoded mock data. A backend endpoint now returns real aggregated emission data so both charts display actual values. Headcount-derived categories (food, waste, commuting, grey energy) use arbitrary per-FTE placeholders when no real data exists.
Key design decisions made during implementation:
- Automatic chart key derivation: No hardcoded
(module_type_id, subcategory) → chart_keymapping table. Instead, chart keys are derived automatically from the data — using the DBsubcategoryfield for modules where it provides meaningful subdivisions (Equipment, Professional Travel), andEmissionTypeEnum.namefor everything else. - Building split by emission type: The Building module (module_type_id=3) is split into two separate x-axis bars — "Buildings energy consumption" (emission_type=energy) and "Buildings room" (emission_type=grey_energy) — via a
_MODULE_EMISSION_CATEGORYoverride mapping. - Three-scope layout: Scope 1 (Processes, Buildings energy consumption), Scope 2 (Buildings room, Equipment), Scope 3 (External cloud & AI, Purchases, Research facilities, Professional travel). Legacy categories (Unit Gas, Infrastructure Gas) were removed.
- Validation-aware display: Unvalidated module categories still appear in the chart with 0-height bars and greyed-out x-axis labels. The same applies to additional (headcount-derived) categories when headcount is not validated.
- EPFL reference filtering: The per-person chart only shows EPFL reference values for categories that are validated in the user's unit.
- Processes placeholder: A zero-filled "Processes" bar is always present (no module_type_id yet) for future Scope 1 process emissions.
Approach¶
Endpoint: GET /{carbon_report_id}/emission-breakdown¶
Single endpoint serving both results page charts. Returns chart-ready data with keys derived automatically from emission types and subcategories. Values are in tonnes CO2eq (kg / 1000).
Step 1: Pure Calculation Functions + Tests (TDD)¶
Created backend/app/utils/emission_breakdown.py — pure functions, no DB:
Constants¶
from app.models.data_entry_emission import EmissionTypeEnum
# module_type_id → chart category (x-axis grouping)
# Building (module_type_id=3) is split by emission type; see _MODULE_EMISSION_CATEGORY
MODULE_TYPE_TO_CATEGORY: dict[int, str] = {
4: "Equipment",
6: "Research facilities",
2: "Professional travel",
5: "Purchases",
7: "External cloud & AI",
}
# (module_type_id, emission_type_id) → category override
# Splits Building into two separate x-axis bars by emission type
_MODULE_EMISSION_CATEGORY: dict[tuple[int, int], str] = {
(3, EmissionTypeEnum.energy): "Buildings energy consumption",
(3, EmissionTypeEnum.grey_energy): "Buildings room",
}
_SUBCATEGORY_PREFERRED_MODULES: set[int] = {4, 2} # Equipment, Professional Travel
HEADCOUNT_EMISSION_TYPES: set[int] = {
EmissionTypeEnum.food, EmissionTypeEnum.waste,
EmissionTypeEnum.commuting, EmissionTypeEnum.grey_energy,
}
MODULE_TYPE_TO_PER_PERSON_KEY: dict[int, str] = {
3: "infrastructure", 4: "equipment", 6: "itInfrastructure",
2: "professionalTravel", 5: "purchases", 7: "researchCoreFacilities",
}
CATEGORY_TO_MODULE_TYPE_IDS # auto-derived from MODULE_TYPE_TO_CATEGORY + _MODULE_EMISSION_CATEGORY
HEADCOUNT_PER_FTE_KG: dict[str, float] = {
"food": 420.0, "waste": 125.0, "commuting": 1375.0, "greyEnergy": 500.0,
}
# Scope 1 → Scope 2 → Scope 3 ordering
MODULE_BREAKDOWN_ORDER = [
# Scope 1
"Processes",
"Buildings energy consumption",
# Scope 2
"Buildings room",
"Equipment",
# Scope 3
"External cloud & AI",
"Purchases",
"Research facilities",
"Professional travel",
]
CATEGORY_CHART_KEYS: dict[str, list[str]] = {
"Processes": [],
"Buildings energy consumption": ["energy"],
"Buildings room": ["grey_energy"],
"Equipment": ["scientific", "it", "other"],
"External cloud & AI": ["stockage", "virtualisation", "calcul", "ai_provider"],
"Purchases": [],
"Research facilities": [],
"Professional travel": ["plane", "train"],
}
ADDITIONAL_BREAKDOWN_ORDER = ["Commuting", "Food", "Waste", "Grey Energy"]
Helper functions¶
def _is_headcount_only(emission_type_id, module_type_id) -> bool:
"""Return True if this emission should be routed to additional_breakdown.
Headcount emission types are normally headcount-derived. However, if the
(module, emission_type) pair has a specific category override in
_MODULE_EMISSION_CATEGORY, it is real module data (e.g. grey_energy on
Building → "Buildings room").
"""
def _get_category(module_type_id, emission_type_id) -> str | None:
"""Resolve the chart category for a (module, emission_type) pair.
Checks _MODULE_EMISSION_CATEGORY overrides first (e.g. Building split),
then falls back to MODULE_TYPE_TO_CATEGORY.
"""
def _to_chart_key(emission_type_id, subcategory, module_type_id) -> str | None:
"""Derive chart key automatically from the row data.
For modules in _SUBCATEGORY_PREFERRED_MODULES (Equipment, Travel),
uses the subcategory field (lowercased first char for camelCase).
For everything else, uses EmissionTypeEnum.name.
"""
This means:
- Equipment (module_type_id=4): subcategories "Scientific"→
scientific, "It"→it, "Other"→other - Professional Travel (module_type_id=2): subcategories "plane"→
plane, "train"→train - Building energy (module_type_id=3, emission_type=1): emission type →
energy→ category "Buildings energy consumption" - Building room (module_type_id=3, emission_type=6): emission type →
grey_energy→ category "Buildings room" - External cloud & AI (module_type_id=7): emission types →
stockage,virtualisation,calcul,ai_provider
build_chart_breakdown¶
def build_chart_breakdown(
rows: list[tuple[int, int, str | None, float]],
total_fte: float = 0.0,
headcount_validated: bool = False,
validated_module_type_ids: set[int] | None = None,
) -> dict:
Returns:
{
"module_breakdown": [
{ "category": "Processes" },
{
"category": "Buildings energy consumption",
"energy": 9.0,
"energyStdDev": 0.0
},
{
"category": "Buildings room",
"grey_energy": 0.0,
"grey_energyStdDev": 0.0
},
{
"category": "Equipment",
"scientific": 10.0,
"scientificStdDev": 0.0,
"it": 3.0,
"itStdDev": 0.0,
"other": 0.2,
"otherStdDev": 0.0
},
{
"category": "External cloud & AI",
"stockage": 1.0,
"stockageStdDev": 0.0,
"virtualisation": 0.5,
"virtualisationStdDev": 0.0,
"calcul": 0.3,
"calculStdDev": 0.0,
"ai_provider": 0.0,
"ai_providerStdDev": 0.0
},
{ "category": "Purchases" },
{ "category": "Research facilities" },
{
"category": "Professional travel",
"plane": 3.0,
"planeStdDev": 0.0,
"train": 1.5,
"trainStdDev": 0.0
}
],
"additional_breakdown": [
{ "category": "Commuting", "commuting": 8.0, "commutingStdDev": 0.0 },
{ "category": "Food", "food": 2.5, "foodStdDev": 0.0 },
{ "category": "Waste", "waste": 0.6, "wasteStdDev": 0.0 },
{ "category": "Grey Energy", "greyEnergy": 2.5, "greyEnergyStdDev": 0.0 }
],
"per_person_breakdown": {
"infrastructure": 8.3,
"equipment": 5.5,
"itInfrastructure": 5.0,
"professionalTravel": 18.4,
"purchases": 39.1,
"researchCoreFacilities": 3.0,
"commuting": 11.0,
"food": 13.0,
"waste": 0.0,
"greyEnergy": 0.0,
"stdDev": 0
},
"validated_categories": [
"Equipment",
"Professional travel",
"Commuting",
"Food",
"Waste",
"Grey Energy"
],
"total_tonnes_co2eq": 61.7,
"total_fte": 25.5
}
Key behaviors:
- All categories in
MODULE_BREAKDOWN_ORDERalways appear (zero-filled when no data) - "Processes" is a placeholder with no module_type_id; always zero-filled
- Both "Buildings energy consumption" and "Buildings room" are validated when module_type_id=3 is validated
grey_energyfrom module_type_id=3 is NOT filtered as headcount — it maps to "Buildings room" via_MODULE_EMISSION_CATEGORY- All 4 additional categories always appear (0 values when headcount not validated)
validated_categoriesincludes module categories whosemodule_type_ids are all validated, plus all 4 additional categories whenheadcount_validated=True- Per-person breakdown aggregates at module level (not subcategory), divided by FTE
build_treemap¶
def build_treemap(rows: list[tuple[str, float]]) -> list[dict]:
"""Returns: [{"name": str, "value": float, "percentage": float}]"""
Created backend/tests/unit/utils/test_emission_breakdown.py:
| Test | What it verifies |
|---|---|
test_build_chart_breakdown_basic | Equipment keeps scientific/it/other subdivisions; Travel keeps plane/train |
test_build_chart_breakdown_emission_type_for_infra | Building energy → "Buildings energy consumption" bar |
test_build_chart_breakdown_building_room | Building grey_energy → "Buildings room" bar (not filtered as headcount) |
test_build_chart_breakdown_emission_type_for_rcf | External cloud & AI uses emission types stockage/virtualisation/calcul |
test_build_chart_breakdown_empty_input | All categories present with zero values; additional categories present with zeros |
test_build_chart_breakdown_category_ordering | Categories appear in MODULE_BREAKDOWN_ORDER |
test_build_chart_breakdown_headcount_additional | Headcount data in additional_breakdown, not module_breakdown |
test_build_chart_breakdown_headcount_per_fte | Placeholder values scale with FTE |
test_build_chart_breakdown_no_headcount | When headcount not validated, additional categories still appear with 0 values |
test_build_chart_breakdown_per_person | Per-person values = module total kg / FTE / 1000 |
test_build_chart_breakdown_per_person_zero_fte | When FTE=0, per-person values are all 0 (no division by zero) |
test_build_chart_breakdown_stddev_keys | Each value key has a corresponding *StdDev key |
test_build_chart_breakdown_null_filtered | None/null kg_co2eq values excluded from aggregation |
test_build_chart_breakdown_subcategory_aggregation | Multiple rows with same subcategory aggregate correctly |
test_build_chart_breakdown_validated_categories | validated_categories reflects which modules are validated |
test_build_chart_breakdown_validated_includes_additional_when_headcount | Additional categories validated when headcount is validated |
test_build_chart_breakdown_additional_not_validated_without_headcount | Additional categories NOT validated when headcount not validated |
test_build_treemap_basic | Correct treemap entries with percentages |
test_build_treemap_zero_total | Returns empty list |
Step 2: Repository Methods + Tests¶
Modified backend/app/repositories/data_entry_emission_repo.py:
Added get_emission_breakdown(carbon_report_id):
- Joins
DataEntryEmission → DataEntry → CarbonReportModule - Filters:
carbon_report_idmatch,status == VALIDATED,kg_co2eq IS NOT NULL - Groups by
module_type_id,emission_type_id,subcategory - Returns raw tuples:
[(module_type_id, emission_type_id, subcategory, sum_kg_co2eq), ...]
Modified backend/app/repositories/data_entry_repo.py:
- Bugfix:
float(total)→float(total) if total is not None else 0.0to handle NULL SUM results
Extended backend/tests/unit/repositories/test_data_entry_emission_repo.py:
| Test | What it verifies |
|---|---|
test_get_emission_breakdown_basic | Multi-module aggregation with emission_type grouping |
test_get_emission_breakdown_aggregates_same_subcategory | Multiple rows with same subcategory aggregate |
test_get_emission_breakdown_validated_only | Non-validated modules excluded |
test_get_emission_breakdown_empty | No data returns empty list |
Step 3: Service Layer¶
Modified backend/app/services/data_entry_emission_service.py:
Added thin wrapper: get_emission_breakdown(carbon_report_id) → delegates to repo.
Step 4: API Endpoint¶
Modified backend/app/api/v1/carbon_report_module_stats.py:
GET /{carbon_report_id}/emission-breakdown¶
@router.get("/{carbon_report_id}/emission-breakdown")
async def get_emission_breakdown(
carbon_report_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user),
) -> dict:
# 1. Get raw emission rows (repo — only validated modules)
emission_rows = await DataEntryEmissionService(db).get_emission_breakdown(
carbon_report_id=carbon_report_id,
)
# 2. Get FTE totals
fte_stats = await DataEntryService(db).get_stats_by_carbon_report_id(
carbon_report_id=carbon_report_id, aggregate_by="module_type_id",
)
total_fte = sum(fte_stats.values())
# 3. Query ALL module statuses for validation info
module_statuses = {row[0]: row[1] for row in db.execute(
select(CarbonReportModule.module_type_id, CarbonReportModule.status)
.where(CarbonReportModule.carbon_report_id == carbon_report_id)
).all()}
headcount_validated = (
module_statuses.get(ModuleTypeEnum.headcount.value) == ModuleStatus.VALIDATED
)
validated_module_type_ids = {
mid for mid, status in module_statuses.items()
if status == ModuleStatus.VALIDATED
}
# 4. Transform to chart-ready format (pure function)
return build_chart_breakdown(
rows=emission_rows, total_fte=total_fte,
headcount_validated=headcount_validated,
validated_module_type_ids=validated_module_type_ids,
)
Step 5: Frontend Integration¶
Store (frontend/src/stores/modules.ts)¶
- Added
EmissionBreakdownResponseinterface - Added
emissionBreakdown/loadingEmissionBreakdown/errorEmissionBreakdownstate - Added
getEmissionBreakdown(carbonReportId)action
ResultsPage (frontend/src/pages/app/ResultsPage.vue)¶
- Imports
useModuleStore - Calls
fetchEmissionBreakdown()on mount and when carbon report changes - Passes breakdown data as props to both chart components:
<ModuleCarbonFootprintChart
:view-uncertainties="viewUncertainties"
:breakdown-data="moduleStore.state.emissionBreakdown"
/>
<CarbonFootPrintPerPersonChart
:view-uncertainties="viewUncertainties"
:per-person-breakdown="moduleStore.state.emissionBreakdown?.per_person_breakdown"
:validated-categories="moduleStore.state.emissionBreakdown?.validated_categories"
:headcount-validated="moduleStore.state.emissionBreakdown?.validated_categories?.includes('Commuting') ?? false"
/>
ModuleCarbonFootprintChart (frontend/src/components/charts/results/ModuleCarbonFootprintChart.vue)¶
- Accepts
breakdownDataprop (type:EmissionBreakdownResponse | null) datasetSourceusesmodule_breakdownas base; appendsadditional_breakdownwhen toggle is on- Category label mapping via
CATEGORY_LABEL_MAPtranslates backend category names to i18n keys for the x-axis - Series (stacked bars) match chart keys from the backend:
- Buildings energy consumption:
energy(lilac.darker) - Buildings room:
grey_energy(lilac.dark) - Equipment:
scientific,it,other(mauve shades) - Professional travel:
plane,train(babyBlue shades) - External cloud & AI:
stockage,virtualisation,calcul,ai_provider(paleYellowGreen shades) - Additional:
commuting,food,waste,greyEnergy(aqua/mint/periwinkle/skyBlue) - Validation-aware labels:
xAxis.axisLabel.formatteruses rich text — validated category names in black (10px), unvalidated in grey (10px) - Three-scope overlay via
graphicrectangles with graduated grey backgrounds: - Scope 1: lightest (
rgba(248,248,248)) — Processes, Buildings energy consumption - Scope 2: light (
rgba(240,240,240)) — Buildings room, Equipment - Scope 3: medium (
rgba(229,229,229)) — External cloud & AI, Purchases, Research facilities, Professional travel - Additional categories: darker (
rgba(215,215,215)) — shown only when toggle is on, with divider line - No y-axis cap:
maxremoved from yAxis config - Removed all hardcoded mock data
CarbonFootPrintPerPersonChart (frontend/src/components/charts/results/CarbonFootPrintPerPersonChart.vue)¶
- Accepts
perPersonBreakdown,validatedCategories, andheadcountValidatedprops - My Unit row: directly uses
per_person_breakdownvalues from the API - EPFL reference row: hardcoded reference values, filtered to only show values for validated categories (via
CATEGORY_TO_PP_KEYSmapping andvalidatedPPKeyscomputed) - Headcount validation placeholder: when headcount is not validated, shows a validation prompt card instead of the chart
CATEGORY_TO_PP_KEYSmaps backend category names to per-person keys:- "Buildings energy consumption" / "Buildings room" →
['infrastructure'] - "Equipment" →
['equipment'] - "External cloud & AI" →
['researchCoreFacilities'] - "Research facilities" →
['itInfrastructure'] - "Professional travel" →
['professionalTravel'] - "Purchases" →
['purchases'] - "Processes" →
[](no per-person key yet) - Removed legacy categories (Unit Gas, Infrastructure Gas) from series, dimensions, and data
- Removed hardcoded mock data for "My Unit" row
i18n (frontend/src/i18n/results.ts)¶
Added translation keys:
| Key | EN | FR |
|---|---|---|
charts-processes-category | Processes | Processus |
charts-building-energy-subcategory | Buildings energy consumption | Consommation d'énergie des bâtiments |
charts-building-room-subcategory | Buildings room | Locaux des bâtiments |
charts-research-facilities-category | Research facilities | Infrastructures de recherche |
Files Modified¶
| File | Change |
|---|---|
backend/app/utils/emission_breakdown.py | NEW — pure functions + constants with automatic chart key derivation, building split logic |
backend/tests/unit/utils/test_emission_breakdown.py | NEW — 19 TDD tests for pure functions |
backend/app/repositories/data_entry_emission_repo.py | Add get_emission_breakdown query method |
backend/app/repositories/data_entry_repo.py | Bugfix: handle NULL SUM results |
backend/tests/unit/repositories/test_data_entry_emission_repo.py | Add 4 repo tests |
backend/app/services/data_entry_emission_service.py | Add thin service wrapper |
backend/app/api/v1/carbon_report_module_stats.py | Add endpoint with validation status queries |
frontend/src/stores/modules.ts | Add state + getEmissionBreakdown action |
frontend/src/pages/app/ResultsPage.vue | Fetch breakdown data, pass as props |
frontend/src/components/charts/results/ModuleCarbonFootprintChart.vue | Building split series, 3-scope overlay, validation-aware labels, category renames |
frontend/src/components/charts/results/CarbonFootPrintPerPersonChart.vue | Updated category keys, headcount validation placeholder, EPFL filtering |
frontend/src/i18n/results.ts | Added Processes, building subcategory, and research facilities translation keys |
Verification¶
pytest backend/tests/unit/utils/test_emission_breakdown.py— all 19 pure function tests passpytest backend/tests/unit/repositories/test_data_entry_emission_repo.py— all repo tests passGET /api/v1/modules-stats/{id}/emission-breakdownreturns correct chart-ready structure- Only validated modules contribute emission data
- Category ordering follows scope grouping: Scope 1 (Processes, Buildings energy), Scope 2 (Buildings room, Equipment), Scope 3 (External cloud & AI, Purchases, Research facilities, Professional travel)
- Building module split: energy → "Buildings energy consumption", grey_energy → "Buildings room" (not filtered as headcount)
- All categories always present (zero-filled when no data or unvalidated), including "Processes" placeholder
- Additional categories always present (0 values when headcount not validated, greyed-out labels)
- Unvalidated category labels appear grey; validated labels appear black (10px font)
- Three-scope overlay with graduated grey backgrounds (lightest → darkest) + darker additional category zone
- Frontend charts render with real data from API
- "Additional data" toggle shows/hides commuting, food, waste, grey energy bars
- EPFL reference row in per-person chart only shows values for validated categories
- Per-person chart shows validation prompt when headcount is not validated