Appearance
Chart Engine — API Analysis
One endpoint. It answers "how did this develop over time", which is the question Consumption Aggregation deliberately does not answer: that one totals an arbitrary period, this one returns a series at a granularity, ordered, with the coverage behind every value. Both read the same stored consumption, so a total shown beside a chart and the chart itself cannot disagree.
Presentation stays out of the response — no colour, no chart type, no legend. Those belong to Chart Types, Dashboard Tiles & Custom Views, which renders this payload.
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_CHART_SERIES_INVALID_RANGE",
"errorMessage": "`to` must be later than `from`.",
"status": 400,
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b21"
}📥 GET /v1/charts/series
Returns one time series per subject and medium — the subject being a gauge, a building, a sector, a campus node or the client — at the requested granularity, in the requested unit, in the requested order, with the total the selection adds up to and the coverage behind each series.
Authorization
Requires permission code tenant.charts.read, tenant-scoped. Subjects the user may not see are removed before aggregation and ordering, so neither the order nor the total can be influenced by data outside the user's reach.
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 |
|---|---|---|---|---|---|---|---|
metric | Query | Enum | No | consumption | What is charted. | First slice: consumption only; any other value returns an unavailable entry rather than an error. | consumption.kind |
medium | Query | Enum | Yes | - | The media charted. Repeatable: several energy media (electricity, gas, heat) can share one chart. | Enum - GaugeMedium. All requested media must belong to one quantity family, so their values can share an axis; energy together with water is ERR_CHART_SERIES_MEDIUM_MIX. First slice: electricity, gas, heat, water. | gauge.medium |
scopeType | Query | Enum | Yes | - | What the selection is made of: gauge, building, sector, campusNode, client. | client takes no id — it means every building the user may see. | gauge.id · building.id |
scopeId | Query | UUID | Conditional | - | The selected item. Repeatable. | Required unless scopeType=client. An id that does not exist in the tenant is an error, never a silently smaller result. | as above |
breakdown | Query | Enum | No | subject | subject gives one series per selected item; none gives a single series for the whole selection. | Scope and breakdown are separate: a campus scope with breakdown=building returns its buildings. | - |
granularity | Query | Enum | Yes | - | year, month, day, hour, 15min. | Buckets follow the Europe/Prague calendar (FR2). | consumption.slotAt |
from / to | Query | Timestamp | Yes | - | Period, from inclusive, to exclusive. | ISO 8601; to later than from; both aligned to a quarter hour. A period that does not fall on bucket boundaries returns clipped edge buckets — see Request Logic. | consumption.slotAt |
sourceMode | Query | Enum | No | effective | effective uses the per-interval winner of the gauge's source priority; a named source returns that series instead. | Enum - ReadingSource. First slice: effective only. | consumption.source |
unit | Query | Enum | No | user preference | Unit the values, totals and sort values are returned in — one per request, shared by every requested medium. | Enum - Unit; must belong to the quantity family of every requested medium. A conversion that cannot be performed returns an unavailable entry for that medium (FR8). | consumption.unit |
sortKey | Query | Enum | No | label | label or total — the series total over the requested period. | Never reorders points inside a series. | - |
sortDirection | Query | Enum | No | asc | asc or desc. | Ties break on label, then medium, then subject id. | - |
ownerIco / managerIco / delegatedManagerIco / campusNodeId / penbClass | Query | String / UUID / Enum | No | - | Attribute filters, repeatable, combined with the selection using AND (FR7). | Repeated values of one filter are OR-ed, different filters are AND-ed; repeated identical values are ignored. IČO normalized to a zero-padded 8-digit value and matched exactly. | building · buildingEnergyProfile |
Request Logic
- Reads the stored consumption series and its summaries; writes nothing.
- Authorisation first: the scope and the attribute filters are resolved to the subjects the user may see, and everything downstream works on that set only.
breakdownapplies to subjects only. A medium is never collapsed into another, whatever the unit:breakdown=nonewith three media returns three series, one per medium, for the whole selection.- The requested media are checked against one another before anything is read: they must share a quantity family, because the answer has one unit and one axis. The series set is then the cross product of authorised subjects and requested media, and a subject with no data for one of the media simply yields no series for it.
- A
buildingsubject reads the building's system total for the medium;sectorandcampusNodesum building values;gaugereads the gauge's own series. No path ever adds a building total and the gauges behind it. - Coarser granularities are sums of the same base, read from the matching summary level; the engine never interpolates and never splits a coarser value.
- Unit conversion is applied before totals and sort values are computed, so everything in the response is in one unit.
- Ordering last.
sortValueis returned per series, and every series carries a stableidthat survives a change of label, period, granularity or unit. - Values still being recalculated mark the response
provisional. - No length or series cap: the period asked for is the period served, at every granularity including
15min. Limits come later, from measurement. - A period that does not fall on bucket boundaries is clipped, not rounded: the first and last buckets are built from the quarter-hours inside the period and carry their effective
fromandto; complete interior buckets are read from the matching summary level. Nothing is prorated, and nothing outside the period enters a value. - Only rows of kind
consumptionfor the medium's measured quantity enter a sum, across tariff bands, in the direction the subject's rule defines — import and export are never added together because they share a medium. - The total of the selection is computed from the same values as the series. A gauge enters it with the sign its
outputModeanddirectiongive it in the building total formula, and not at all when that mode isexcludeorreportOnly; a gauge inside a selected building and a building reachable through two overlapping campus subtrees each enter once. A sub-gauge is never deduplicated against its parent gauge — its ownoutputModedecides, so a sub-gauge set toincludeunder an included parent is counted twice by design (see the decision under FR9). Rows left out are named intotal.excludedSubjects, each with its series, its medium, the reason (excludedByOutputMode,containedInSubject) and, for containment, the subject whose value already carries it. With several media requested,total.valueis their sum in the shared unit andtotal.byMediumstates each medium's share. - The total is also computed per bucket (
total.points), on the same rule, so a table with a column per period does not have to add anything up itself. - Every selected subject and medium appears in the answer. One with no values in the period returns a series of nulls; one the medium does not apply to, a building without a system total, a conversion that failed or a capability that does not exist yet appears in
unavailablewith its own subject, medium and reason. - Coverage is counted in quarter-hours — those in which every contributing gauge had a value — per series, per bucket and for the total, whatever granularity is displayed.
- The response echoes the normalized query, so a saved chart and a cache key describe the same request the engine ran.
sql
SELECT subject_id, medium, bucket, SUM(value) AS value, unit
FROM consumption_summary_day -- or the level matching @granularity
WHERE subject_id = ANY(@authorisedSubjects)
AND bucket >= @from AND bucket < @to
AND medium = ANY(@media)
AND source_mode = @sourceMode
GROUP BY subject_id, medium, bucket, unit
ORDER BY subject_id, medium, bucket;The sketch covers the complete interior buckets. The first and last bucket of a period that does not fall on a boundary are computed from consumption directly, over the quarter-hours inside the period, and returned with their effective from and to.
Transactional Operations
N/A — read-only.
✅ Success Response (200)
One building, electricity and gas on one chart, monthly, ordered by total:
json
{
"data": {
"query": {
"metric": "consumption",
"medium": ["electricity", "gas"],
"granularity": "month",
"unit": "kWh",
"from": "2025-01-01T00:00:00+01:00",
"to": "2026-01-01T00:00:00+01:00"
},
"served": {
"from": "2025-01-01T00:00:00+01:00",
"to": "2026-01-01T00:00:00+01:00",
"timezone": "Europe/Prague",
"clippedEdges": false
},
"provisional": false,
"series": [
{
"id": "building:0c2f…|electricity",
"subject": { "type": "building", "id": "0c2f…", "label": "ZŠ Komenského" },
"medium": "electricity",
"unit": "kWh",
"total": 120000.000000,
"sortValue": 120000.000000,
"contribution": "add",
"coverage": { "intervalsInPeriod": 35040, "intervalsCovered": 35040, "ratio": 1.0 },
"points": [
{
"from": "2025-01-01T00:00:00+01:00",
"to": "2025-02-01T00:00:00+01:00",
"value": 13980.500000,
"coverage": { "intervalsInPeriod": 2976, "intervalsCovered": 2976, "ratio": 1.0 }
}
]
},
{
"id": "building:0c2f…|gas",
"subject": { "type": "building", "id": "0c2f…", "label": "ZŠ Komenského" },
"medium": "gas",
"unit": "kWh",
"total": 110000.000000,
"sortValue": 110000.000000,
"contribution": "add",
"coverage": { "intervalsInPeriod": 35040, "intervalsCovered": 29184, "ratio": 0.8329 },
"points": [
{
"from": "2025-01-01T00:00:00+01:00",
"to": "2025-02-01T00:00:00+01:00",
"value": 12440.000000,
"coverage": { "intervalsInPeriod": 2976, "intervalsCovered": 2976, "ratio": 1.0 }
},
{
"from": "2025-02-01T00:00:00+01:00",
"to": "2025-03-01T00:00:00+01:00",
"value": 4100.000000,
"coverage": { "intervalsInPeriod": 2688, "intervalsCovered": 960, "ratio": 0.3571 }
},
{
"from": "2025-03-01T00:00:00+01:00",
"to": "2025-04-01T00:00:00+01:00",
"value": null,
"coverage": { "intervalsInPeriod": 2972, "intervalsCovered": 0, "ratio": 0.0 }
}
]
}
],
"total": {
"value": 230000.000000,
"unit": "kWh",
"rowSum": 230000.000000,
"excludedSubjects": [],
"byMedium": [
{ "medium": "electricity", "value": 120000.000000 },
{ "medium": "gas", "value": 110000.000000 }
],
"coverage": { "intervalsInPeriod": 70080, "intervalsCovered": 64224, "ratio": 0.9164 },
"points": [
{
"from": "2025-01-01T00:00:00+01:00",
"to": "2025-02-01T00:00:00+01:00",
"value": 26420.500000,
"coverage": { "intervalsInPeriod": 5952, "intervalsCovered": 5952, "ratio": 1.0 }
}
]
},
"unavailable": []
},
"status": 200,
"message": "OK",
"requestId": "018f9c4d-2c11-7a3e-9b77-4f0d3a5e6b21"
}A null point is a bucket with nothing usable behind it, not a zero — a zero is returned as 0. A bucket that is only partly covered keeps the sum of what is known and says so through its own coverage: February above is a real 4 100 kWh over 36 % of the month, not a month that consumed little. Coverage counts quarter-hours at every granularity, so the same figure can be checked against GET /v1/consumption/summary. Every series says what it does to the selection total through contribution: add, subtract or none. A gauge takes the sign its outputMode and direction give it in the building total formula — an export gauge read as subtract is drawn with its own positive values and lowers the total — and a row whose contribution is none is named in total.excludedSubjects with its reason. total.value is the signed sum of the contributions, total.rowSum the plain sum of the rows as drawn, so the two disagree exactly where a row is subtracted or left out. Containment is judged per medium and applies to subjects only — a gauge inside a selected building, a building under a selected campus. A sub-gauge is never contained in its parent gauge: its own outputMode alone decides its contribution.
Water cannot share this answer with energy, so a screen that shows both asks twice — once for the energy media, once for water — and renders each reply as its own chart with its own unit and its own total.
An unsupported combination is reported per segment rather than as an error:
json
"unavailable": [
{
"subject": { "type": "building", "id": "0c2f…", "label": "ZŠ Komenského" },
"medium": "gas",
"reason": "unsupportedConversion",
"detail": "m³ → kWh needs a calorific value for this gauge"
},
{
"subject": { "type": "building", "id": "7ab1…", "label": "ZŠ Nádražní" },
"medium": "heat",
"reason": "noSystemTotal",
"detail": "the building has no system-total gauge for this medium"
},
{ "metric": "cost", "reason": "capabilityDeferred", "pendingDependency": "pricing-data-source" }
]
`reason` is one of `noData` (the subject is applicable but nothing was measured — returned as a null series, not here), `notApplicable`, `noSystemTotal`, `unsupportedConversion` and `capabilityDeferred`. A value the enum does not know at all is a 400, not an `unavailable` entry: the first says the request is wrong, the second says the answer is not ready.Response Data Mapping
| Response field | Source | Notes |
|---|---|---|
series[].subject | gauge / building | type, id and label of what the series represents |
series[].medium | gauge.medium | which of the requested media this series carries; with one medium requested, every series repeats it |
series[].points[].value | consumption.value | summed to the requested granularity, converted to unit |
series[].id | derived | stable series key, unchanged by label, period, granularity or unit |
series[].coverage, points[].coverage | consumption | quarter-hours in which every contributing gauge had a value, against quarter-hours in the period or bucket |
series[].total, sortValue | derived | total over the whole requested period, in unit |
series[].contribution | derived | add, subtract or none — what this series does to total.value, from the subject's outputMode and direction, or from containment in another selected subject |
total.value / rowSum | derived | signed sum of the series contributions / plain sum of the rows as drawn, across every requested medium |
total.byMedium[] | derived | the same total split per medium, in the shared unit |
provisional | recalculation state | true while any contributing value is being recomputed |
total.points[] | derived | the selection total per bucket, on the same deduplication rule |
served | derived | the period actually covered, its timezone, and whether edge buckets were clipped |
❌ Error Responses
| Status | errorCode | When |
|---|---|---|
| 400 | ERR_CHART_SERIES_INVALID_RANGE | to is not later than from, or a boundary is not aligned to a quarter hour |
| 400 | ERR_CHART_SERIES_UNIT_MISMATCH | the requested unit does not belong to the quantity family of the requested media |
| 400 | ERR_CHART_SERIES_MEDIUM_MIX | the requested media span more than one quantity family (energy together with water) |
| 403 | ERR_FORBIDDEN | the permission code is missing |
| 404 | ERR_CHART_SERIES_SUBJECT_NOT_FOUND | a scopeId does not exist in the tenant |