Skip to content
Updated Sep 28, 2026 by Pablo Coufal · Owner: analysisactiveapi-a Edit on GitHub

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 ​

NameInTypeRequiredDefaultDescriptionValidation / NotesDB Mapping
metricQueryEnumNoconsumptionWhat is charted.First slice: consumption only; any other value returns an unavailable entry rather than an error.consumption.kind
mediumQueryEnumYes-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
scopeTypeQueryEnumYes-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
scopeIdQueryUUIDConditional-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
breakdownQueryEnumNosubjectsubject 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.-
granularityQueryEnumYes-year, month, day, hour, 15min.Buckets follow the Europe/Prague calendar (FR2).consumption.slotAt
from / toQueryTimestampYes-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
sourceModeQueryEnumNoeffectiveeffective 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
unitQueryEnumNouser preferenceUnit 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
sortKeyQueryEnumNolabellabel or total — the series total over the requested period.Never reorders points inside a series.-
sortDirectionQueryEnumNoascasc or desc.Ties break on label, then medium, then subject id.-
ownerIco / managerIco / delegatedManagerIco / campusNodeId / penbClassQueryString / UUID / EnumNo-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.
  • breakdown applies to subjects only. A medium is never collapsed into another, whatever the unit: breakdown=none with 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 building subject reads the building's system total for the medium; sector and campusNode sum building values; gauge reads 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. sortValue is returned per series, and every series carries a stable id that 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 from and to; 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 consumption for 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 outputMode and direction give it in the building total formula, and not at all when that mode is exclude or reportOnly; 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 own outputMode decides, so a sub-gauge set to include under an included parent is counted twice by design (see the decision under FR9). Rows left out are named in total.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.value is their sum in the shared unit and total.byMedium states 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 unavailable with 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 fieldSourceNotes
series[].subjectgauge / buildingtype, id and label of what the series represents
series[].mediumgauge.mediumwhich of the requested media this series carries; with one medium requested, every series repeats it
series[].points[].valueconsumption.valuesummed to the requested granularity, converted to unit
series[].idderivedstable series key, unchanged by label, period, granularity or unit
series[].coverage, points[].coverageconsumptionquarter-hours in which every contributing gauge had a value, against quarter-hours in the period or bucket
series[].total, sortValuederivedtotal over the whole requested period, in unit
series[].contributionderivedadd, 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 / rowSumderivedsigned sum of the series contributions / plain sum of the rows as drawn, across every requested medium
total.byMedium[]derivedthe same total split per medium, in the shared unit
provisionalrecalculation statetrue while any contributing value is being recomputed
total.points[]derivedthe selection total per bucket, on the same deduplication rule
servedderivedthe period actually covered, its timezone, and whether edge buckets were clipped

❌ Error Responses ​

StatuserrorCodeWhen
400ERR_CHART_SERIES_INVALID_RANGEto is not later than from, or a boundary is not aligned to a quarter hour
400ERR_CHART_SERIES_UNIT_MISMATCHthe requested unit does not belong to the quantity family of the requested media
400ERR_CHART_SERIES_MEDIUM_MIXthe requested media span more than one quantity family (energy together with water)
403ERR_FORBIDDENthe permission code is missing
404ERR_CHART_SERIES_SUBJECT_NOT_FOUNDa scopeId does not exist in the tenant