Knowledge graphs are great at representing structure. What the things are, how they relate and where they belong. Time-series databases are great at storing measurements, like billions of timestamped values, efficiently compressed and indexed. One issue is that interesting questions often span both!
“Show me the average temperature for all costal weather stations.”
That is a question that needs a knowledge graph to find all coastal stations and their sensors, and the time-series database to compute the temperature aggregation. Traditionally, you’d perhaps glue those two worlds together with application code: query the graph, extract IDs, query the database, join in Python (or something!). chrontext, built into maplib, lets you do it in a single SPARQL query.
This post will walk you through a demonstrator on knowledge graph virtualisation using maplib’s chrontext magic. ✨
The scenario
We have 12 Norwegian weather stations: from Blindern in Oslo to Hammerfest in the Arctic. Each station has sensors for temperature, wind speed, and precipitation. The data lives in two places:
Knowledge graph
A knowledge graph built in maplib with station metadata like name, municipality, type (coastal, mountain, urban), coordinates, elevation, and which sensors are installed. Built from a CSV using OTTR templates.
DuckDB database
Contains real weather forecast data fetched from MET Norway’s free Locationforcast API, roughly 9 days of hourly forecasts per station. No API key required.
Step 1: Build the knowledge graph
The station metadata starts as a simple CSV. An OTTR template defines how each row maps to RDF triples:
tpl:Station[
! ottr:IRI ?station_uri,
xsd:string ?name,
ottr:IRI ?municipality,
xsd:double ?latitude,
xsd:double ?longitude,
ottr:IRI ?station_type,
xsd:long ?elevation_m,
xsd:gYear ?installed_year
] :: {
ottr:Triple(?station_uri, rdf:type, wx:WeatherStation),
ottr:Triple(?station_uri, rdfs:label, ?name),
ottr:Triple(?station_uri, wx:municipality, ?municipality),
ottr:Triple(?station_uri, wx:latitude, ?latitude),
ottr:Triple(?station_uri, wx:longitude, ?longitude),
ottr:Triple(?station_uri, wx:stationType, ?station_type),
ottr:Triple(?station_uri, wx:elevation, ?elevation_m),
ottr:Triple(?station_uri, wx:installedYear, ?installed_year)
} .Mapping is also straight forward:
model = Model()
model.add_template(open("tpl/stations.stottr").read())
model.map(ns_tpl + "Station", df_stations)We do the same for sensors, each station gets a temperature, wind speed, and precipitation sensor, linked back to the station via wx:hasSensor.
The key step for chrontext is linking each sensor to its time-series in DuckDB. Each sensor needs an intermediate node with three predicates.

In Python, we build these triples with map_triples:
ct_ns = "https://github.com/DataTreehouse/chrontext#"
for row in df_stations.iter_rows(named=True):
for measurand in measurands:
sensor_id = f"{row['station_id']}_sensor_{measurand}"
ts_node = f"{ns}ts/{sensor_id}"
ts_link_rows.append({"subject": f"{ns}{sensor_id}", "object": ts_node})
ts_extid_rows.append({"subject": ts_node, "object": sensor_id})
ts_resource_rows.append({"subject": ts_node, "object": measurand})
model.map_triples(pl.DataFrame(ts_link_rows), predicate=f"{ct_ns}hasTimeseries")
model.map_triples(pl.DataFrame(ts_extid_rows), predicate=f"{ct_ns}hasExternalId")
model.map_triples(pl.DataFrame(ts_resource_rows), predicate=f"{ct_ns}hasResource")This intermediate-node pattern is how chrontext discovers which time-series to query for each sensor. hasExternalId matches the id column in SQL, and hasResource tells it which resource_sql_map entry to use.
Step 2: Connect DuckDB via chrontext virtualisation
chrontext needs two things: a database wrapper with a query() method that returns a Polars DataFrame, and a resource_sql_map that defines how each measurand maps to SQL.
class WeatherDuckDB:
def __init__(self, path):
self.con = duckdb.connect(path, read_only=True)
self.con.execute("SET TimeZone = 'UTC'")
def query(self, sql: str) -> pl.DataFrame:
return self.con.execute(sql).pl()
db = WeatherDuckDB("data/weather.duckdb")The resource_sql_map uses SQLAlchemy to define a query per measurand. Each query must produce three columns: id (matching ct:hasExternalId), timestamp, and value.
from sqlalchemy import MetaData, Table, Column, select, literal_column
metadata = MetaData()
measurements = Table("measurements", metadata,
Column("station_id"), Column("timestamp"),
Column("temperature"), Column("wind_speed"), Column("precipitation"),
)
def make_resource_sql(measurand: str):
return select(
measurements.c.timestamp,
measurements.c[measurand].label("value"),
).select_from(measurements).add_columns(
literal_column(f"(measurements.station_id || '_sensor_{measurand}')").label("id"),
)
vdb = VirtualizedDatabase(
database=db,
resource_sql_map={name: make_resource_sql(name) for name in measurands},
sql_dialect="postgres",
)Each resource also needs a template describing how the SQL rows map to RDF triple patterns. The template has three parameters (id, timestamp, value), the dp (data point) variable is generated internally:
ct = Prefix("https://github.com/DataTreehouse/chrontext#")
def make_ts_template(name: str) -> Template:
id_var, timestamp_var, value_var, dp_var = (
Variable("id"), Variable("timestamp"), Variable("value"), Variable("dp")
)
return Template(
iri=ct.suf(f"{name}TimeSeries"),
parameters=[
Parameter(variable=id_var, rdf_type=RDFType.Literal(xsd.string)),
Parameter(variable=timestamp_var, rdf_type=RDFType.Literal(xsd.dateTime)),
Parameter(variable=value_var, rdf_type=RDFType.Literal(xsd.double)),
],
instances=[
Triple(id_var, ct.suf("hasDataPoint"), dp_var),
Triple(dp_var, ct.suf("hasValue"), value_var),
Triple(dp_var, ct.suf("hasTimestamp"), timestamp_var),
],
)
model.add_virtualization(
virtualized_database=vdb,
resources={name: make_ts_template(name.title().replace("_", "")) for name in measurands},
)Step 3: One federated SPARQL query
Now we can write a single SPARQL query that spans both worlds using the same model.query() we’d use for a pure graph query.
PREFIX wx: <http://weather.treehouse.example/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX ct: <https://github.com/DataTreehouse/chrontext#>
SELECT ?name ?muni_name (AVG(?temp) AS ?avg_temp)
(MIN(?temp) AS ?min_temp)
(MAX(?temp) AS ?max_temp)
WHERE {
?station a wx:WeatherStation ;
rdfs:label ?name ;
wx:municipality ?muni ;
wx:stationType wx:StationTypeCoastal ;
wx:hasSensor ?sensor .
?muni rdfs:label ?muni_name .
?sensor wx:measurand wx:MeasurandTemperature .
?sensor ct:hasTimeseries ?ts .
?ts ct:hasDataPoint ?dp .
?dp ct:hasTimestamp ?t ;
ct:hasValue ?temp .
}
GROUP BY ?name ?muni_name
ORDER BY ?avg_tempmaplib handles the federation transparently. It defines which triple patterns belong to the in-memory graph and which need the time-series database, pushes the aggregation down to DuckDB, and joins the results using zero-copy Arrow DataFrames.
Why this matters
The demo uses 12 stations and ~9 days of hourly forecasts. That’s small enough to run instantly on a laptop, but the architecture scales to industrial scenarios where it matters a lot more.
Consider an electrical grid operator monitoring 50,000 sensors across hundreds of substations, with years of historical data in a time-series database and a CIM knowledge graph describing the grid topology. Questions as “find all transformers in region X where the load exceeded 90% during peak hours last winter” combine graph traversal (region → substations → transformers → sensors) with time-series aggregation (load readings, time filters, threshold checks). Without federation, that’s a multi-stage pipeline. With chrontext, it’s one SPARQL query.
In benchmarks published in Expert Systems and Applications, chrontext was 10-85× faster than Ontop1. The speedup comes from predicate pushdown, the Rust core, and Apache Arrow.
Running the demo yourself
pip install maplib duckdb polars sqlalchemy
python setup_data.py # fetches real weather data from MET Norway
python main.py # runs the federated queryThe setup script fetches real forecasts data from MET Norway’s Locationforecast API. There is also a Jupyter notebook with a step-by-step walkthrough.
What chrontext supports
chrontext currently virtualises four backends:
PostgreSQL
DuckDB
BigQuery
OPC UA Historical Access
The last one is particularly relevant for industrial IoT. OPC UA is the standard protocol for industrial automation, and chrontext ca query historical data from OPC UA servers directly within a SPARQL query.
chrontext is built into maplib and ships with pip install maplib. The virtualisation features are a part of the commercial add-on, which is always free for academic and personal use.
Resources
About the author

Ontologist who code in C, Java and Python. Been a knowledge graph practitioner for over a decade, and had numerous talks on the topic around the globe. Knowledge Graph Specialist at Data Treehouse. Author of SHACL for the Practitioner and awarded amongst Norway’s Top 50 Women in Tech 2024.
The primary academic alternative for virtualised SPARQL over relational sources.




Thanks for this write up Veronica, I'm looking forward to using chrontext in a railway setup. There's probably going to be an additional spatial dimension to it (a train is moving through the railway topology).