Appearance
Consumption Aggregation & Effective Consumption — API Analysis
One endpoint. It answers "how much, over this period" for a gauge or a set of them, which is the question the chart API deliberately does not answer: Chart Engine returns a series at a granularity, and a period that does not align to calendar buckets — a billing period from the 4th to the 4th — cannot be totalled from one without the caller doing the arithmetic. Totals are computed once, here, so the invoice screen, the invoice tolerance check and any report agree.
The invoice comparison itself is composed by Invoice Management: it holds the invoiced values, the previous invoice and the same period last year, and reads this endpoint for the reading-derived side.
The project has no shared API-conventions document yet, so the envelope shapes are stated once here. Success:
json
{
"data": {},
"status": 200,
"message": "OK",
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b21"
}Error:
json
{
"errorCode": "ERR_CONSUMPTION_SUMMARY_INVALID_RANGE",
"errorMessage": "from must be earlier than to.",
"status": 400,
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b21"
}📥 GET /v1/consumption/summary
Totals consumption or cost over an arbitrary period for one gauge or a set of them, grouped as the caller asks — by tariff for an invoice comparison, by gauge or medium for a portfolio figure — with the coverage behind the number and a flag when a recalculation is still pending.
Authorization
Requires permission code tenant.consumption.read, tenant-scoped.
Request Headers
None beyond the application's standard bearer authentication.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation / Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
gaugeId | Query | UUID | Conditional | - | Gauge to total. Repeatable. | At least one gaugeId or buildingId must be given; an id that does not exist in the tenant is an error, never a silently smaller sum. | consumption.gaugeId |
buildingId | Query | UUID | Conditional | - | Building whose gauges are totalled, resolved to its gauge set with each sign applied once. Repeatable. | As above. Richer selection — owner, manager, campus, class — is resolved by Chart Engine and passed here as gauge ids. | gauge.buildingId |
from | Query | Timestamp | Yes | - | Start of the period, inclusive. | ISO 8601. Need not align to a calendar boundary. | consumption.slotAt |
to | Query | Timestamp | Yes | - | End of the period, exclusive. | ISO 8601; must be later than from. | consumption.slotAt |
kind | Query | Enum | No | consumption | Whether to total a quantity or money. | Enum - ConsumptionKind. | consumption.kind |
type | Query | Enum | No | all | Limits to one measured quantity. | Enum - ReadingType. Omitted, the response separates quantities rather than adding them. | consumption.type |
direction | Query | Enum | No | all | Limits to one flow direction. | Enum - GaugeDirection. | consumption.direction |
tariff | Query | Enum | No | all | Limits to one tariff band. Repeatable. | Enum - ReadingTariff. | consumption.tariff |
source | Query | Enum | No | effective | Totals a named source series instead of the effective one. Repeatable; more than one value returns them side by side rather than added. | Enum - ReadingSource. Omitted, the effective value is chosen per interval by the gauge's priority. | consumption.source |
groupBy | Query | Enum | No | none | How the total is broken down. Repeatable: tariff, source, gauge, medium, type, usageType. | Medium, quantity and unit always separate a group even when not asked for, so units never mix. usageType groups by the purpose of the gauge a value came from — heating, hot water, production, other — which is what a caller normalising only the heating share needs. | gauge.purpose for usageType |
aggregate | Query | Enum | No | sum | sum totals the period; max returns the largest single 15-minute value in it. | A sizing question — what breaker or reserved capacity this point needs — is a maximum, not a total, and must not be re-derived by each caller. | - |
unit | Query | Enum | No | canonical | Unit the totals are returned in. | Enum - Unit; must belong to the quantity's family. | consumption.unit |
Request Logic
- Reads
consumptionand its summaries; writes nothing. - Whole days inside the period are read from the daily summary; the partial days at each edge are read from the 15-minute series, so a period that starts and ends mid-day costs one scan of two days rather than of the whole range.
- With no
source, the effective value is chosen per 15-minute interval before anything is added, so the total of a period with partial remote coverage equals no single source series. - With
buildingId, the building resolves to its gauge set, each gauge's sign is applied once, and a gauge reachable twice is counted once. - Coverage is counted in the same pass: gauges asked for, gauges with any value, and intervals covered against intervals in the period.
- A gauge and window still awaiting recalculation marks the affected totals provisional.
- With
aggregate=max, the largest 15-minute value in the period is returned per group, read from the series rather than from a summary, andvaluecarries it withpeakAtnaming the interval it fell in. - A rolling window needs no special support: the caller computes the range — the last 365 days, the last 90 — and passes it as
fromandto.
sql
SELECT tariff, SUM(value) AS value, unit
FROM consumption
WHERE gauge_id = ANY(@gaugeIds)
AND slot_at >= @from AND slot_at < @to
AND kind = @kind
AND source = @resolvedSource
GROUP BY tariff, unit;Transactional Operations
N/A — read-only.
✅ Success Response (200)
The invoice comparison case: one electricity gauge, a billing period from 4 May to 4 June, grouped by tariff.
json
{
"data": {
"period": {
"from": "2026-05-04T00:00:00Z",
"to": "2026-06-04T00:00:00Z"
},
"totals": [
{
"medium": "electricity",
"type": "energy",
"tariff": "high",
"value": 3612.000000,
"unit": "kWh",
"sources": ["remote"]
},
{
"medium": "electricity",
"type": "energy",
"tariff": "low",
"value": 1498.000000,
"unit": "kWh",
"sources": ["remote"]
}
],
"coverage": {
"gaugesRequested": 1,
"gaugesWithData": 1,
"intervalsInPeriod": 2976,
"intervalsCovered": 2976,
"buildingsWithoutTotal": 0
},
"provisional": false
},
"status": 200,
"message": "OK",
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b81"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
period.from, period.to | The requested period, echoed so a stored answer stays self-describing |
totals[].medium | gauge.medium of the contributing gauges |
totals[].type | consumption.type |
totals[].tariff | consumption.tariff; present when tariff is in groupBy |
totals[].value | Sum of consumption.value over the group, after the per-interval source choice |
totals[].unit | consumption.unit, or the requested unit after conversion |
totals[].sources | The distinct consumption.source values that contributed — one entry where a single series covered the period, more where the effective total is a blend |
coverage.gaugesRequested | Count of gauges the selection resolved to |
coverage.gaugesWithData | Count of those with at least one value in the period |
coverage.intervalsInPeriod | Quarter hours between from and to |
coverage.intervalsCovered | Quarter hours in which every contributing gauge had a value |
coverage.buildingsWithoutTotal | Buildings in the selection with no recognised total formula |
totals[].peakAt | The interval carrying the maximum, present only with aggregate=max |
provisional | True while a recalculation is pending for any gauge and interval inside the period |
❌ Error Responses
400 ERR_CONSUMPTION_SUMMARY_NO_SELECTION
Thrown when neither gaugeId nor buildingId is given.
json
{
"errorCode": "ERR_CONSUMPTION_SUMMARY_NO_SELECTION",
"errorMessage": "At least one gaugeId or buildingId is required.",
"status": 400,
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b81"
}400 ERR_CONSUMPTION_SUMMARY_INVALID_RANGE
Thrown when from is not earlier than to, or either is not a valid timestamp.
json
{
"errorCode": "ERR_CONSUMPTION_SUMMARY_INVALID_RANGE",
"errorMessage": "from must be earlier than to.",
"status": 400,
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b81"
}400 ERR_CONSUMPTION_SUMMARY_INVALID_GROUPING
Thrown when groupBy names something that is not a grouping, or unit does not belong to the requested quantity's family.
json
{
"errorCode": "ERR_CONSUMPTION_SUMMARY_INVALID_GROUPING",
"errorMessage": "unit MWh cannot express a volume.",
"status": 400,
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b81"
}404 ERR_GAUGE_NOT_FOUND
Thrown when a requested gauge or building does not exist in the tenant. A selection is never quietly reduced: a total over four gauges when five were asked for is indistinguishable from a real fall in consumption.
json
{
"errorCode": "ERR_GAUGE_NOT_FOUND",
"errorMessage": "Gauge not found.",
"status": 404,
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b81"
}Internal access — the same totals without HTTP
Features in the same application read the totals through a port rather than over HTTP, so the arithmetic has one implementation:
summarise(selection, period, options) -> { totals, coverage, provisional }- Read-only; participates in the caller's transaction.
- Takes the same options the endpoint exposes, and applies the same per-interval source choice before summing.
- Used by Invoice Management for the invoice tolerance check, and available to Chart Engine where a caller wants a total rather than a series.