Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
348 changes: 300 additions & 48 deletions docker/serviceConfigs/ontop/mapping.obda

Large diffs are not rendered by default.

519 changes: 519 additions & 0 deletions factsheet/frontend/src/components/comparison/RegistryComparison.jsx

Large diffs are not rendered by default.

188 changes: 188 additions & 0 deletions factsheet/frontend/src/components/comparison/registryQuery.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// SPDX-FileCopyrightText: 2026 Jonas Huber <https://github.com/jh-RLI> © Reiner Lemoine Institut
//
// SPDX-License-Identifier: AGPL-3.0-or-later

// P1/P2 of the registry-driven refactor (see Obsidian "10 - Frontend Refactor
// Plan"). Pure helpers — no React — that build SPARQL from the Dimension
// Property Registry contract served by GET /oekg/registry/. Unit-testable.
//
// Contract shape (oekg/registry/loader.py):
// { namespaces, row_anchor, generic_super_property, dimensions: [
// { key, concept, predicate, object_kind, datatype, value_space,
// values: [{ code, iri, label }] } ] }
//
// KEY IDEA — disambiguating shared predicates:
// IAMC dimensions currently share the generic `is about` predicate
// (oeo:IAO_0000136). Selecting `?s <pred> ?technology` alone would bind to
// EVERY annotated concept on the row. Because the token dictionary assigns
// each value to exactly one dimension, we isolate a dimension by constraining
// its variable to that dimension's enum IRIs:
// ?s oeo:IAO_0000136 ?technology .
// FILTER(?technology IN ( <…technology IRIs…> ))
// This needs the token IRIs filled (resolve_terms.py) but NO new predicates.

const TABLE_PRED = "oeo:OEO_00000504"; // table-name predicate (row anchor)
const VALUE_KEY = "quantity_value"; // dimension carrying the numeric measure

export function prefixHeader(registry) {
return Object.entries(registry.namespaces || {})
.map(([p, iri]) => `PREFIX ${p}: <${iri}>`)
.join("\n");
}

export function expandCurie(registry, curie) {
if (!curie || curie.startsWith("http") || !curie.includes(":")) return curie;
const i = curie.indexOf(":");
const prefix = curie.slice(0, i);
const local = curie.slice(i + 1);
const base = (registry.namespaces || {})[prefix];
return base ? `${base}${local}` : curie;
}

// Full http IRI -> <...>; CURIE (prefix declared in the header) -> verbatim.
export function sparqlTerm(iriOrCurie) {
if (!iriOrCurie) return iriOrCurie;
return iriOrCurie.startsWith("http") ? `<${iriOrCurie}>` : iriOrCurie;
}

// Predicates used by >1 iri-dimension (i.e. the generic `is about`) — these
// need enum isolation.
export function sharedPredicates(registry) {
const counts = {};
for (const d of registry.dimensions || []) {
if (d.object_kind !== "iri") continue;
counts[d.predicate] = (counts[d.predicate] || 0) + 1;
}
return new Set(Object.keys(counts).filter((p) => counts[p] > 1));
}

const enumTerms = (dim) =>
(dim.values || []).filter((v) => v.iri).map((v) => sparqlTerm(v.iri));

const tableFilter = (tables) =>
tables && tables.length
? `FILTER(?table_name IN (${tables.map((t) => `"${t}"`).join(", ")})) .`
: "";

// Distinct values of one dimension actually present in the selected tables.
export function dimensionValuesQuery({ registry, dim, tables = [] }) {
let isolate = "";
if (dim.object_kind === "iri" && sharedPredicates(registry).has(dim.predicate)) {
const set = enumTerms(dim);
if (set.length) isolate = `FILTER(?v IN (${set.join(", ")})) .`;
}
return `${prefixHeader(registry)}
SELECT DISTINCT ?v ?table_name WHERE {
?s ${dim.predicate} ?v . ?s ${TABLE_PRED} ?table_name .
${isolate}
${tableFilter(tables)}
}`;
}

// Units present in a table, most common first. If `dim` is given, only units
// that CO-OCCUR with that dimension are returned (e.g. units that actually apply
// when breaking down by technology) — keeps the unit list relevant + small.
export function unitFrequencyQuery({ registry, table, dim }) {
const unitDim = (registry.dimensions || []).find((d) => d.key === "unit");
const pred = unitDim ? unitDim.predicate : "oeo:OEO_00040010";
let cond = "";
if (dim && dim.key !== "unit") {
let iso = "";
if (dim.object_kind === "iri" && sharedPredicates(registry).has(dim.predicate)) {
const set = enumTerms(dim);
if (set.length) iso = ` FILTER(?dv IN (${set.join(", ")}))`;
}
cond = ` ?s ${dim.predicate} ?dv .${iso}`;
}
return `${prefixHeader(registry)}
SELECT ?v (COUNT(?s) AS ?c) WHERE {
?s ${TABLE_PRED} ?t . FILTER(?t = "${table}") ?s ${pred} ?v .${cond}
} GROUP BY ?v ORDER BY DESC(?c)`;
}

// Distinct values of a dimension by frequency (most common first), optionally
// scoped by another (literal) dimension = value — e.g. quantities in a table, or
// the units that occur FOR a chosen quantity (unit follows the quantity).
export function valueFrequencyQuery({ registry, table, dim, scopeDim, scopeValue }) {
let scope = "";
if (scopeDim && scopeValue) {
const esc = String(scopeValue).replace(/"/g, '\\"');
scope = ` ?s ${scopeDim.predicate} ?sv . FILTER(STR(?sv) = "${esc}")`;
}
return `${prefixHeader(registry)}
SELECT ?v (COUNT(?s) AS ?c) WHERE {
?s ${TABLE_PRED} ?t . FILTER(?t = "${table}") ?s ${dim.predicate} ?v .${scope}
} GROUP BY ?v ORDER BY DESC(?c)`;
}

// Cheap existence check: does this table populate this dimension at all?
// Used to show only dimensions/presets that will actually return data.
export function dimensionAskQuery({ registry, table, dim }) {
let iso = "";
if (dim.object_kind === "iri" && sharedPredicates(registry).has(dim.predicate)) {
const set = enumTerms(dim);
if (set.length) iso = ` FILTER(?v IN (${set.join(", ")}))`;
}
return `${prefixHeader(registry)}
ASK { ?s ${TABLE_PRED} ?t . FILTER(?t = "${table}") ?s ${dim.predicate} ?v .${iso} }`;
}

// The comparison query: select the numeric value + the chosen dimension axes,
// filtered by the user's selections.
// dims: array of dimension keys to project (e.g. ["technology","scenario_year"])
// filters: { [dimKey]: code[] } user selections
export function buildComparisonQuery({ registry, tables = [], filters = {}, dims = [] }) {
const shared = sharedPredicates(registry);
const byKey = Object.fromEntries((registry.dimensions || []).map((d) => [d.key, d]));
const selected = dims.map((k) => byKey[k]).filter(Boolean);

const valueDim = byKey[VALUE_KEY];
const patterns = [
`?s ${valueDim ? valueDim.predicate : "oeo:OEO_00140178"} ?value .`,
`?s ${TABLE_PRED} ?table_name .`,
];
const vars = [];
const filterLines = [];

const tf = tableFilter(tables);
if (tf) filterLines.push(tf);

for (const d of selected) {
if (d.key === VALUE_KEY) continue;
const v = `?${d.key}`;
vars.push(v);
patterns.push(`?s ${d.predicate} ${v} .`);

// isolate shared-predicate (generic is-about) dims by their enum set
if (d.object_kind === "iri" && shared.has(d.predicate)) {
const set = enumTerms(d);
if (set.length) patterns.push(`FILTER(${v} IN (${set.join(", ")})) .`);
}

const codes = filters[d.key];
if (codes && codes.length) {
const terms = codes.map((code) => {
if (d.object_kind === "iri") {
const val = (d.values || []).find((x) => x.code === code);
return sparqlTerm(val ? val.iri : code);
}
return `"${code}"`;
});
filterLines.push(`FILTER(${v} IN (${terms.join(", ")})) .`);
}
}

return `${prefixHeader(registry)}
SELECT DISTINCT ?s ?value ?table_name ${vars.join(" ")} WHERE {
${patterns.join("\n ")}
${filterLines.join("\n ")}
}`;
}

// Map a result's full IRI back to a human label for a given dimension.
export function labelForIri(registry, dim, fullIri) {
for (const v of dim.values || []) {
if (v.iri && expandCurie(registry, v.iri) === fullIri) return v.label || v.code;
}
return fullIri;
}
76 changes: 76 additions & 0 deletions factsheet/frontend/src/components/comparison/tibTerms.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// SPDX-FileCopyrightText: 2026 Jonas Huber <https://github.com/jh-RLI> © Reiner Lemoine Institut
//
// SPDX-License-Identifier: AGPL-3.0-or-later

// Resolve an ontology IRI to { label, description } via the TIB Terminology
// Service. Extracted from quantitativeView.jsx so the registry-driven view can
// reuse the exact same resolution (terms → individuals → properties) and cache.
// OEO references all terms under its own base, so we normalise to the oeo IRI
// from the short form before querying.

import axios from "axios";

const cache = {};

export async function resolveTerm(iri) {
if (!iri) return null;
if (cache[iri]) return cache[iri];

const shortForm = iri.split("/").pop().split(":").pop();
const officialIri = `https://openenergyplatform.org/ontology/oeo/${shortForm}`;
const encoded = encodeURIComponent(officialIri);
const baseUrl =
import.meta.env.VITE_TSS_API_BASE?.replace(/\/$/, "") ||
"https://api.terminology.tib.eu/api";
const ontology = import.meta.env.VITE_TSS_DEFAULT_ONTOLOGY || "oeo";

const tryEndpoint = async (endpoint) => {
try {
const res = await axios.get(
`${baseUrl}/ontologies/${ontology}/${endpoint}?iri=${encoded}`
);
const items = res.data?._embedded?.[endpoint];
if (items && items.length > 0) {
const item = items[0];
return {
iri,
label: item.label || shortForm,
description:
item.description && item.description.length > 0
? item.description.join(" ")
: "No official definition provided in the ontology.",
type: endpoint,
};
}
} catch (e) {
/* fall through */
}
return null;
};

let info =
(await tryEndpoint("terms")) ||
(await tryEndpoint("individuals")) ||
(await tryEndpoint("properties"));
if (!info) {
info = {
iri,
label: shortForm,
description: "Term not found in Terminology Service.",
type: "unknown",
};
}
cache[iri] = info;
return info;
}

// Resolve many IRIs; returns a map { iri: info }.
export async function resolveTerms(iris) {
const out = {};
await Promise.all(
[...new Set(iris.filter(Boolean))].map(async (iri) => {
out[iri] = await resolveTerm(iri);
})
);
return out;
}
50 changes: 50 additions & 0 deletions factsheet/frontend/src/components/comparison/useRegistry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: 2026 Jonas Huber <https://github.com/jh-RLI> © Reiner Lemoine Institut
//
// SPDX-License-Identifier: AGPL-3.0-or-later

// P0 of the registry-driven refactor (see Obsidian "10 - Frontend Refactor Plan").
// Fetches the Dimension Property Registry contract from GET /oekg/registry/ once
// per page load and caches it. The contract is the shared vocabulary the
// comparison view uses to build filters + dynamic SPARQL (no hardcoded terms).

import { useEffect, useState } from "react";
import axios from "axios";
import conf from "../../conf.json";

let _cache = null; // module-scope: one fetch per page load
let _inflight = null;

export async function fetchRegistry() {
if (_cache) return _cache;
if (!_inflight) {
_inflight = axios
.get(conf.dimensionRegistry)
.then((res) => {
_cache = res.data;
return _cache;
})
.finally(() => {
_inflight = null;
});
}
return _inflight;
}

export default function useRegistry() {
const [registry, setRegistry] = useState(_cache);
const [error, setError] = useState(null);

useEffect(() => {
let active = true;
if (!_cache) {
fetchRegistry()
.then((r) => active && setRegistry(r))
.catch((e) => active && setError(e));
}
return () => {
active = false;
};
}, []);

return { registry, loading: !registry && !error, error };
}
5 changes: 5 additions & 0 deletions factsheet/frontend/src/components/comparisonBoardMain.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import BreadcrumbsNavGrid from "../styles/oep-theme/components/breadcrumbsNaviga
// Import our new sub-components
import QualitativeView from "./comparison/qualitativeView.jsx";
import QuantitativeView from "./comparison/quantitativeView.jsx";
import RegistryComparison from "./comparison/RegistryComparison.jsx";

const ComparisonBoardMain = ({ params }) => {
const [scenarios, setScenarios] = useState([]);
Expand Down Expand Up @@ -93,6 +94,9 @@ const ComparisonBoardMain = ({ params }) => {
<ToggleButton style={{ width: "250px" }} value="Quantitative">
<EqualizerIcon /> Quantitative
</ToggleButton>
<ToggleButton style={{ width: "250px" }} value="Registry">
<EqualizerIcon /> Registry (beta)
</ToggleButton>
</ToggleButtonGroup>
</Grid>
<Grid item xs={2}></Grid>
Expand All @@ -106,6 +110,7 @@ const ComparisonBoardMain = ({ params }) => {
{alignment === "Quantitative" && (
<QuantitativeView scenarios={scenarios} />
)}
{alignment === "Registry" && <RegistryComparison />}
</Container>
</Grid>
)
Expand Down
3 changes: 2 additions & 1 deletion factsheet/frontend/src/conf.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"toep": "/",
"obdi": "/api/oevkg-query",
"oekgQueryFilter": "oekg/filter-by-criteria/"
"oekgQueryFilter": "oekg/filter-by-criteria/",
"dimensionRegistry": "/oekg/registry/"
}
Loading
Loading