Query (hisRead)
"What is this air handler doing right now?" One line — readAll(ahu and air).hisRead(thisMonth)
— and every matching point's history is on screen: GET /ingest/his?expr=... takes that
expression and returns every matched point's history as one JSON body, and /ui/his.html
(through the gateway: localhost:8080/ui/his.html) is the shell-plus-chart surface over it — type
a query, get charts, flip to a grid, download CSV.
The syntax is Axon-shaped (familiar if you've used SkySpark) but this is not Axon. It is the
two calls people actually reach for in a hurry — a Folio-style tag filter and
hisRead/hisRollup — implemented over Baler's own two stores (the RDF entity graph in
Oxigraph for the filter, point_history in Postgres/TimescaleDB for the history). Everything
else Axon can do in that shell is deliberately out of scope; see "What is and isn't supported"
below.
The query language
Code
Some real ones:
Code
The filter
The filter is Haystack's filter grammar — and/or/not, parentheses, names, and
tag <op> value comparisons with ==, !=, <, <=, >, >=. It compiles to a single
SPARQL SELECT DISTINCT ?s over the entity graph, which is what makes it fast enough to type
against interactively; hisquery.compile_filter() is the whole compiler if you want to see the
translation.
Querying Brick without knowing Brick
Baler's entities are Brick-typed, and Brick is hard to query by hand: the class names
are long (Zone_Air_Temperature_Sensor), the hierarchy matters (a Temperature_Sensor query
that misses every subclass is useless), there are aliases (AHU is Air_Handling_Unit,
Discharge_Air_Temperature_Sensor is Supply_Air_Temperature_Sensor), and a plain word like
temperature isn't a class at all. The filter absorbs all of that. A bare name is matched
three ways at once, and an entity that satisfies any of them counts:
- As a Brick tag word. Brick attaches a tag vocabulary to every class — the words the class
name is made of (
Zone_Air_Temperature_SensorcarriesZone,Air,Temperature,Sensor,Point;Air_Handling_UnitcarriesAHU,Air,Handling,Unit,Equipment). Sozone and air and temperature and sensorfinds every zone air temperature sensor, whatever class it was typed with, andahufinds air handlers and rooftop units, with no Haystack tag anywhere. Haystack's spellings where they differ from Brick's are mapped:temp,sp,cmd,equip,elec,occmeanTemperature,Setpoint,Command,Equipment,Electrical,Occupancy. Aliases and deprecated classes inherit the tags of what they point at, so nothing is left out for lack of its own tag list. And the project's own tag→class mapping rules (rules/haystack_*_to_brick.yaml) run backwards too —runhas no Brick word, but[fan, run, sensor] → Fan_Statusmeansfan and runstill finds everyFan_Statuspoint. - As a Brick class, with its whole subtree. Any name with an uppercase letter or an
underscore is a class name, matched case-insensitively and including every subclass and
every alias:
Temperature_Sensor(ortemperature_sensor) finds the discharge, return, mixed, outside and zone air temperature sensors;Air_Handling_Unit,AHU, andair_handling_unitall find air handlers, rooftop units, DOAS units, and anything typed with Brick's alias classes. Classes the live graph adds beneath Brick count as their parents and resolve by their own name the same way — a Brick extension of yours, or the PROJ fallback classesmapping.pymints (proj:Fan_Status_Variant, a directrdfs:subClassOfof a Brick class). - As a Haystack marker.
ahu,air,temp,sensor,zone... anything ingest wrote as ahaystack:hasTagtriple (every marker a source sent, verbatim). This is what a Haystack-fed point has; the two ways above are what a Brick-only entity (a loaded model, a derivation output) has. Mixed data works because the same word means the same thing through all three. - Structural aliases —
point,equip, andsitealso match on the graph's shape (has abrick:hasPoint, is abrick:isPointOfsomething, and so on), so a BACnet point that arrived with nopointmarker and no class still counts.
Brick's own hierarchy, aliases and tag words come from the vendored ontology/Brick-only.ttl
(1.4.4), read once per process by brick_vocab.py — not from the live graph. That's
deliberate: nothing in docker-compose.yml loads Brick into Oxigraph (see
Architecture), and even when it is loaded, Oxigraph takes tens of seconds on
an rdfs:subClassOf* walk inside a filter over the full ontology. Every hop is resolved in
Python and handed to SPARQL as a bound class list instead, so a filter is ~0.07s with the full
ontology loaded, whether it names 5 classes or 1,400. What the live graph declares on top of
Brick is read from the live graph, in one cheap query per request (hisquery.graph_vocab):
every rdfs:subClassOf, owl:equivalentClass and brick:hasAssociatedTag whose subject
isn't in Brick's namespace, plus every subject typed as a class, tag or shape. So an extension
class is found under its Brick parent, by its own name, through its own aliases, and by the tag
words it declares — see below.
Ontology-level subjects are never entities. With Brick loaded into Oxigraph its ~2,600
class/tag/shape/quantity subjects (and the REC ontology Brick 1.4 embeds) are all in the same
union graph as your equipment, and a readAll(not point) would otherwise list them. They're
dropped after the query by what they are — anything in a vocabulary namespace
(brickschema.org, w3id.org/rec, w3.org, qudt.org), and anything the live graph itself
declares as a class, tag, or shape (so an extension's declarations are excluded too, whatever
namespace they use). This happens in Python, not as a FILTER NOT EXISTS per candidate: that
clause alone was ~0.4s of every query, and it only knew about owl:Class-typed subjects, so
readAll(Quantity) used to hand back Brick's quantity individuals.
When a filter matches nothing and a name is almost a Brick class or tag word, the response
carries hints — Air_Handeling_Unit → Air_Handling_Unit, temperture → temperature —
and the UI shows them as "did you mean" links. It's never an error: the name may be a perfectly
good Haystack marker the vendored Brick can't know about. A class or tag word your extension
declares is never "corrected" to a Brick near-miss.
Your own Brick extension
The reason to pick Brick is that you can extend it, and the filter treats an extension exactly
like Brick. Write it the way Brick's extension guidance
says — your own namespace, rdfs:subClassOf the nearest Brick class, a skos:definition,
optionally owl:equivalentClass aliases and brick:hasAssociatedTag words:
Code
Then an equipment typed acme:Heat_Recovery_AHU (or acme:HRU, or a further subclass of
either) is found by AHU, Air_Handling_Unit, Equipment, ahu, equip, and also by
Heat_Recovery_AHU, heat_recovery_ahu, HRU, hru, and heat and recovery. Its
acme:Wheel_Speed_Sensor point is found by Speed_Sensor, speed and sensor, Sensor,
Point, Wheel_Speed_Sensor, and rotor; the response reports Wheel_Speed_Sensor as its
brickClass. A class declared by rdfs:subClassOf alone, with no a owl:Class, works the
same way. Two things to know:
-
Load it into a graph of its own.
RemoteStore.load_ontologyPUTs into a single named graph (urn:baler:ontology) and replaces it, so it can't hold Brick and your extension at once. Oxigraph's graph-store endpoint takes any graph name:CodeThe query reads through
--union-default-graph, so the graph name is yours to pick. Brick itself does not need to be loaded for any of this to work — the filter needs yourrdfs:subClassOf brick:...edges, and Brick's side of the hierarchy comes from the vendored file. Loading Brick as well (python ontology_demo.py) is fine; it costs ~0.05s per query. -
Stay out of Brick's namespace. Triples whose subject is a
brick:IRI are skipped when the live graph is read (Brick's own are already known, faster, from the pinned file), so a class you mint asbrick:Somethingisn't picked up as an extension. That also means a newer Brick loaded live doesn't extend the vendored one: a class added in Brick 1.5 matches only by exactrdf:typeuntilontology/Brick-only.ttlis bumped (seeontology/README.md).
Comparisons: dis == "AHU-1", unit == "°F", kind != "Bool" compare haystack:* value tags
as-is; id == @some-id matches the entity's own id (and it's the only thing id may be compared
to — id == 5 is a parse error). Ref-valued tags compare by id too: siteRef == @site1, and
equipRef == @ahu-1 also matches through the graph's own brick:isPointOf edge, so it works for
points whose source never sent an equipRef tag. A comparison on a tag the entity doesn't have
is false, as in Haystack (x != 1 doesn't match recs with no x), and so is a comparison
between different kinds (foo < "c" never matches a number). Numbers compare only with the
same unit, as in Haystack: temp > 72°F matches 75°F but not a unitless 75, and
temp > 72 matches only unitless values. There's no unit conversion (2min is not 120s).
A bare name matches a tag with any value, not only markers: dis matches every record with a
dis. Dates and times compare as dates and times (installed < 2020-01-01, ts->time >= 08:00), and ^hot-water is shorthand for hot and water. DateTimes compare by instant
(ts > 2020-09-01T05:00:00Z UTC); == also needs the same Haystack timezone, and ts->tz == "New_York" tests the zone itself. Uri literals (link == `http://x/a`) compare only with Uri
values. Paths step into nested Dicts as well as follow Refs (ref->thru == "e"), and // starts a
comment.
These rules are checked against haxall's own filter test cases on every build
(tests/test_haxall_filter_vectors.py, run on both rdflib and Oxigraph).
After the read (Baler#18), in the order written:
.sort("tag")/.sortr("tag")order the series by a tag (defaultdis, case-insensitive; series missing the tag go last)..first()keeps only the first series..hisClip()trims to thehisReadspan (already the case);.hisClip(span)trims further, e.g..hisRead(thisWeek).hisClip(today)..hisInterpolate()puts every series on the same timestamps (the union of them all), each within its own first..last sample: linear between numbers, the previous value for bools and text. Capped at 500,000 values;hisRollupfirst for long spans.
Lambdas (map(p => ...)) and general Axon aren't supported - a filter path such as
equipRef->dis == "AHU-1" covers most map/findAll uses.
read vs readAll. readAll(filter) returns every match. read(filter) takes the first
match only, and if nothing matches it's a 404, the same as Axon's UnknownRecErr — a
readAll that matches nothing is an empty, successful 200. A bare filter with no
read/readAll and no .hisRead(...) is readAll(filter).hisRead(today).
Equipment expands to its points
This is the one deliberate departure from Axon, and the reason to have the feature. In Axon,
read(ahu).hisRead(today) is an error: an equipment record has no history. Here, any matched
entity that isn't itself a point is expanded to every point it brick:hasPoint (following
owl:sameAs merges, the same expansion ingest.points_of_equip does), so "show me this AHU" is
read(ahu and dis == "AHU-1"), not a hunt for the right equipRef syntax. A filter that matches
points directly (temp and zone) returns those points unexpanded. The expansion is one hop —
brick:hasPoint — so a matched site contributes only points attached directly to it, not
everything under its equipment; query the equipment (readAll(equip and siteRef == @site1))
for that.
Spans
hisRead(span) takes Axon's DateSpan vocabulary. Every span is a half-open [start, end)
range of local days in the request's timezone (see tz below), never a rolling clock window —
today is local midnight to local midnight tomorrow, exactly as SkySpark treats it.
| Span | Meaning |
|---|---|
today, yesterday | one local day |
thisWeek, lastWeek, pastWeek | Mon–Sun weeks; past* is the trailing 7 days ending today |
thisMonth, lastMonth, pastMonth | calendar months; pastMonth is the trailing month ending today |
thisQuarter, lastQuarter | calendar quarters |
thisYear, lastYear, pastYear | calendar years; pastYear is the trailing year ending today |
2026-09-03 | one explicit day |
2026-09 | one explicit month |
2026 | one explicit year |
2026-09-01..2026-09-07 | inclusive date range (both ends are whole days) |
Omit .hisRead(...) entirely and the span is today.
Rollups
.hisRollup(fold, interval) aggregates on the server in one SQL statement instead of shipping
raw rows and folding in the browser — a month of 1-minute data comes back as 720 hourly buckets,
not 43,000 rows. fold is one of avg, min, max, sum, count. interval is an Axon
duration: 15min, 1hr, 6h, 1day, 1wk, 1mo, 1yr (units: s/sec, min, h/hr,
day, wk, mo, yr).
Fixed-length intervals use TimescaleDB's date_bin, anchored at the span start; mo/yr have
no fixed length and use date_trunc in the request timezone, so a 1mo bucket really is a
calendar month and a 1day bucket really starts at local midnight. Boolean points fold as 0/1,
so avg of a Bool point is its duty cycle for the bucket — the most useful single number for a
fan-status or occupancy point over a day.
GET /ingest/his
| Param | Meaning |
|---|---|
expr | the query, required |
tz | IANA timezone for span resolution and calendar bucketing; defaults to BALER_TZ, then UTC |
limit | per-point raw-row cap; defaults to 10,000, max 200,000 (rollups aren't capped — they're already bucketed) |
The response is one object with the resolved span (start/end epoch seconds, the
label, the tz used), the rollup in effect (or null), matched (the entity ids the filter
hit, previewed) and matchedCount, and series — one entry per point with id, dis, unit,
kind (Number/Bool/Str), its brickClass, its parent equip/equipDis, the history
as [{ts, value}, ...] oldest-first, and a truncated flag that's true when limit clipped
that point. Full schema: the Ingest API reference.
limit matters. Axon's hisRead applies its own default row limit for the same reason: a month
of 5-second data across forty points is millions of rows, and one browser request shouldn't be
able to ask for that by accident. When you see truncated: true, add a .hisRollup(...) — that's
what it's for — rather than raising limit.
Errors: a malformed or unsupported expression is a 400 whose body names the problem and the
position in the text (unsupported function 'foo' at position 12; only hisRead(span) and hisRollup(fold, interval) are supported, unknown span 'thisFortnight', id must be compared to a @ref); read(...) with no match is a 404. Nothing is ever silently reinterpreted —
an expression that uses something this grammar doesn't cover fails loudly, by design.
Through the gateway the route is GET /ingest/his, and it requires the viewer role (or
higher) like every other read — see Gateway auth.
Code
/ui/his.html
The page is the query bar and three views of whatever it returns, with no build step and no
charting library — the charts are hand-drawn <canvas>, same as the rest of ui/.
- Query bar — type any expression above and press Enter (or Run). Below it, span chips
(
today,yesterday,thisWeek,thisMonth, ...) and a rollup dropdown rewrite the expression in the bar rather than being hidden state: clickinglastWeekturnsreadAll(ahu).hisRead(today)intoreadAll(ahu).hisRead(lastWeek)in front of you, so the text is always the whole truth about what's on screen, and copying it intocurlgives you the same result. - By unit — the default. Points are grouped onto one chart per unit (
°F,cfm,%,kW...), so every temperature on the AHU shares an axis and a supply/return/mixed-air comparison is one glance.Boolpoints draw as a step chart;Strpoints list as a timeline of value changes. Hovering gives a crosshair and a tooltip with every series' value at that instant; clicking a legend row hides/shows that series; the small↗next to a name opens that one point alone in a new query. - Per point — one small chart per point with min/max/avg/last stats, for when the by-unit chart is too crowded.
- Grid — the same data as a table (one column per point, one row per timestamp), plus a Download CSV button.
- Live — on by default; re-runs the query every 10 seconds while the span's end is still in
the future (
today,thisWeek...), for watching a piece of equipment respond. A span that's entirely in the past (yesterday,2026-08) can't change, so it isn't polled.
The URL updates as you go (his.html?expr=...) so a query is a link you can send someone. The
rest of the UI links here too: every equipment row in the tree on index.html has a 📈 that
opens readAll(id==@<that equip>).hisRead(today), and the point detail panel has
"Open in Query →" for a single point.
Timezones
BALER_TZ (in .env; see .env.example) sets the default tz for GET /his, so today
means the building's today, not the server's UTC today. Any request can override it with
?tz=.... The UI sends the browser's own IANA zone, so it always sees local days; the env var is
for API clients and scripts that don't say. Set it to the zone the building is in — a
multi-site deployment spanning zones should pass tz per request instead (see
Multi-site).
Tag paths
a->b follows the ref tag a and tests b on the entity it points at, the same as Haystack:
Code
- equipRef follows the point's
brick:isPointOfedge, so it works for every point in the graph, not only Haystack-sourced ones. A point whose equip hasn't been linked yet is followed through its rawequipReftag instead. - siteRef also follows Brick structure: the
brick:Sitethat reaches the entity throughhasPart(Site > Building > Floor > Zone, or equipment's own parts), backwards alongfeeds(an AHU feeds a Floor, a VAV a Zone) and on tohasPoint. That's thesiteRef/apishows on every record, andnavwalks site → equip → point the same way. A rawsiteReftag (Haystack imports) still counts when no Brick Site reaches the entity. - Any other ref tag (
spaceRef, a customfooRef) is followed through its raw Haystack value: aurn:...ref names the entity directly, a bare id resolves to the Haystack-sourced record with that id. - The far end is tested with the full filter vocabulary, so
equipRef->ahualso matches points on equipment typedbrick:Rooftop_Unitthat carry noahumarker. - A path that can't be followed (no such ref) is false, and
notof it is true, as in Haystack. - Paths can chain:
equipRef->siteRef->dis == "HQ".
What is and isn't supported
Supported: the grammar above, exactly. Not supported, and each one is an explicit 400 naming what was rejected, never a silent approximation:
- Arithmetic and every other Axon function — no
hisFoldAll,hisInterpolate,hisClip,.map(...), notoDateSpan, no lambdas. There are two calls,hisReadandhisRollup, and that's the whole library. Anything computed from history belongs in a Derivation, which writes a real point you can thenhisReadlike any other. - Unit conversion.
temp > 22°Cdoesn't match75°F; units must be identical. - Paths into nested dicts (
ref->thruwhererefis a Dict) andts->tz. - Time-of-day spans (
hisRead(2026-09-03T08:00..2026-09-03T17:00)) — spans are whole local days. - Writes. There is no
hisWritehere; that'sPOST /ingest/historyor the Haxall drop-in.
For the wider question of what a SkySpark user gives up moving here — and what's on the backlog
to close — see todo/skyspark-haxall-migration-gaps.md in the repo.
Where the code is
src/baler/hisquery.py— tokenizer, parser, filter→SPARQL compiler, span resolution, andrun_query(), which strings them together. Each stage is a pure function sotests/test_hisquery.pyruns the parser, compiler (against an in-memoryrdflibgraph), and span math with no services.src/baler/brick_vocab.py— Brick's class hierarchy, aliases, and per-class tag words, parsed from the vendoredontology/Brick-only.ttlonce per process;tests/test_brick_vocab.pypins the facts the compiler relies on against the real file, so a Brick version bump that changes the file's shape fails loudly.src/baler/timeseries.py—read_range_many()(raw, multi-point, per-point limit),read_rollup()(date_bin/date_truncaggregation),read_point_labels()(unit/label fallback from history rows, for derivation-engine points that carry no graph tags).src/baler/ingest_api.py— theGET /hisroute.tests/test_his_route.pycovers it end-to-end against live Oxigraph and Postgres (pytest -m integration).gateway/njs/policy.js— theGET /ingest/his→viewerrow.ui/his.html,ui/his.js,ui/his.css— the page;ui/app.jsfor the tree/detail deep links.
How rollups stay fast
point_history has two TimescaleDB continuous aggregates, created on first
start: point_history_5m and point_history_1h. They hold sum, count, min
and max per point per bucket, and are refreshed every 5 and 30 minutes over
the last 35 days. A rollup can use a view when the view's bucket divides the
requested interval and the span starts on one of its boundaries.
It then covers the span with the coarsest pieces that give exactly the raw
answer: whole 1-hour buckets that are up to date, 5-minute buckets for what
the hourly view hasn't caught up on, and raw rows for the rest. "Up to date"
comes from TimescaleDB's own bookkeeping (its invalidation logs and each
view's watermark), so the unrefreshed tail, a source backfilling history
since the last refresh, or a span that was never materialized are all read
from raw rows. Results are the raw fold's, never stale. Calendar
(month/year) rollups and odd intervals fold raw samples as before. A day of
614 points at 5-second samples (6.2M rows) folds from about 100k pre-summed
rows; readAll(equip).hisRead(today) takes about 0.4 s end to end on a dev
machine.
Big responses are encoded and decoded in small pieces (timeseries.iter_rows,
http_handler_base.json_pieces), so one large /his read never holds
Python's GIL long enough to stall other requests, such as Ops' health probe.

