Skip to learning content
← All articles
RAG free

Build a working ontology from defence logistics data

Build an ontology from an empty Python file, inspect actual graph records and their sources, and extend a maintenance example to multiple required parts. Six chapters combine plain-language explanations with a downloadable, locally tested implementation.

The question: How do we actually create, validate and use an ontology from operational source records?

You’ll learn: Construct an ontology step by step, trace RDF facts to CSV records, validate and query them, and migrate the model to support several required parts per order.

For: Domain experts, analysts and engineers; concepts are explained before the runnable commands.

Executed teaching prototype · AI-assisted source check . This is not a human expert endorsement.

Chapter 2: Create the ontology yourself, one step at a time

2. Create the ontology yourself, one step at a time#

Start with an empty Python file. By the end of this chapter, that file will create classes, actual equipment records, relationships, validation rules, and a queryable graph. You will see exactly where each statement comes from.

Our first question is deliberately small: Which asset and part type does work order WO-204 refer to, and how many items does it require? We will build three classes for this question. Stock, allocations, inspection evidence, and source provenance enter the fuller model in chapter 3.

Step 1: Set up the tools and declare three classes#

Download and extract the ontology lab ZIP. Open a terminal inside the extracted folder, where you can see ontology_lab.py, requirements.txt, and lessons. The verified environment is Python 3.12.10 with the exact dependency versions in requirements.txt.

On Windows, run:

powershell
python --version
python -m venv .venv
.venv/Scripts/python.exe -m pip install -r requirements.txt

On macOS or Linux, use your installed Python 3.12 interpreter to create the environment and replace .venv/Scripts/python.exe with .venv/bin/python in subsequent commands. Those alternative paths are provided for portability; the included execution record was produced on Windows. Package setup downloads dependencies. The lesson itself reads and writes local files.

Create an empty file called lessons/my_ontology.py in your editor. Add the code below. The imports provide the RDF graph and SHACL validator; M is an identifier namespace. These example.org identifiers are names for things, not addresses the script must fetch.

python
import csv
import json
from pathlib import Path

from pyshacl import validate
from rdflib import Graph, Literal, Namespace
from rdflib.namespace import OWL, RDF, RDFS, XSD

M = Namespace("https://example.org/maintenance/")
HERE = Path(__file__).resolve().parent


def save(graph, output, name):
    output.mkdir(parents=True, exist_ok=True)
    graph.serialize(destination=output / name, format="turtle")
    print(f"{name}: {len(graph)} triples")

def create_schema():
    graph = Graph()
    graph.bind("m", M)
    graph.bind("owl", OWL)
    for kind in (M.Asset, M.PartType, M.WorkOrder):
        graph.add((kind, RDF.type, OWL.Class))
    return graph

output = HERE / "my-results"
graph = create_schema()
save(graph, output, "01_classes.ttl")

Run the file:

powershell
.venv/Scripts/python.exe lessons/my_ontology.py

You should see 01_classes.ttl: 3 triples. Open lessons/my-results/01_classes.ttl. It declares Asset, PartType, and WorkOrder as OWL classes. It does not contain an actual generator yet.

A class describes a kind of thing. An instance identifies one thing of that kind. RDF stores statements as triples: subject, relationship, and value or linked object.201 For example, “GEN-17 is an Asset” is one statement. The complete collection of these statements is the graph.

Step 2: Add properties and two actual records#

Append this block to the same file, below the previous code. Do not replace the earlier steps.

python
def add_properties_and_entities(graph):
    for predicate, target_class in ((M.asset, M.Asset), (M.part, M.PartType)):
        graph.add((predicate, RDF.type, OWL.ObjectProperty))
        graph.add((predicate, RDFS.label, Literal(str(predicate).rsplit("/", 1)[-1])))
        graph.add((predicate, RDFS.range, target_class))
    for predicate, datatype in (
        (M.recordId, XSD.string),
        (M.requiredQuantity, XSD.integer),
        (M.unit, XSD.string),
    ):
        graph.add((predicate, RDF.type, OWL.DatatypeProperty))
        graph.add((predicate, RDFS.range, datatype))
    asset = M["id/ORG-TRAIN/asset/GEN-17"]
    part = M["id/ORG-TRAIN/part/PF-9"]
    for entity, kind, record_id in (
        (asset, M.Asset, "GEN-17"), (part, M.PartType, "PF-9")
    ):
        graph.add((entity, RDF.type, kind))
        graph.add((entity, M.recordId, Literal(record_id)))

add_properties_and_entities(graph)
save(graph, output, "02_schema-and-entities.ttl")

Run the same command again. The new file 02_schema-and-entities.ttl has 19 triples. Open it and find GEN-17 and PF-9.

We used two kinds of properties. m:asset and m:part point to other graph objects. m:recordId, m:requiredQuantity, and m:unit carry literal values such as a string or integer. A property label gives it a readable name.

Why is PF-9 a PartType instead of an Asset? PF-9 identifies a catalogue type of filter. GEN-17 identifies one physical generator. If individual filters need serial numbers, ownership, or maintenance history, introduce a PartInstance class with its own identity. A catalogue type and one physical item answer different questions.

The range statements describe the kinds of targets those relationships have. They are not an error-handling mechanism. OWL supports logical descriptions and inference; missing information is not automatically false.202 We will explicitly require valid links and values with SHACL in step 4. This lesson does not run an OWL reasoner.

Step 3: Turn one source row into a work order#

Open lessons/orders.csv. It contains exactly this fictional row:

csv
order_id,org,asset_ref,part_ref,required_quantity,unit
WO-204,ORG-TRAIN,GENERATOR_017,FILTER_PF9,2,each

The source system says GENERATOR_017; our canonical asset ID is GEN-17. We cannot invent that match from spelling similarity. The small reviewed alias dictionary below maps the source identifiers to records we have already created. Its keys include object kind and organization so two different contexts cannot silently share a mapping.

Resolve identities before adding relationships

Original mapping diagram. The reference implementation reads a reviewed alias table; it does not ask an LLM to merge similar-looking equipment names.

Select a box to inspect its role. On a small screen, scroll the diagram sideways.
lookuplookupmaps toreject
Asset-register row

A structured source record has an identifier in the source system. Preserve that identifier and the file hash so the mapping can be inspected later.

Read the complete diagram as text
  • Asset-register row: A structured source record has an identifier in the source system. Preserve that identifier and the file hash so the mapping can be inspected later.
  • Work-order reference: A work order may use a different source identifier for the same generator. A relationship is published only when the approved mapping resolves that identifier.
  • Unrecognised alias: An unknown identifier is a failure to resolve identity. It is not automatically merged with the closest name.
  • Reviewed alias table: The mapping explicitly associates a source identifier with its canonical asset. Mapping changes should receive review because a wrong merge can attach every later observation to the wrong asset.
  • Canonical Asset: The resulting RDF identifier represents the canonical asset. It connects the work order and inspection records without discarding their original source identities.
  • Stop for correction: Resolve the source identity and update the reviewed mapping before rebuilding. A failed record does not become an apparently verified relationship.
  • Asset-register row → lookup → Reviewed alias table
  • Work-order reference → lookup → Reviewed alias table
  • Reviewed alias table → maps to → Canonical Asset
  • Unrecognised alias → reject → Stop for correction

Append the following code. The checks happen before the graph receives the work-order statements.

python
def map_order(graph, source):
    with source.open(newline="", encoding="utf-8") as handle:
        reader = csv.DictReader(handle)
        expected = ["order_id", "org", "asset_ref", "part_ref", "required_quantity", "unit"]
        if reader.fieldnames != expected:
            raise ValueError("Unexpected CSV columns")
        rows = list(reader)
    if len(rows) != 1:
        raise ValueError("This lesson accepts exactly one source row")
    row = rows[0]
    if row["order_id"] != "WO-204" or row["org"] != "ORG-TRAIN":
        raise ValueError("This lesson only models WO-204 in ORG-TRAIN")
    aliases = {
        ("asset", "ORG-TRAIN", "GENERATOR_017"): M["id/ORG-TRAIN/asset/GEN-17"],
        ("part", "ORG-TRAIN", "FILTER_PF9"): M["id/ORG-TRAIN/part/PF-9"],
    }
    asset = aliases.get(("asset", row["org"], row["asset_ref"]))
    part = aliases.get(("part", row["org"], row["part_ref"]))
    if asset is None or part is None:
        raise ValueError("Unknown alias: review the source mapping")
    if (asset, RDF.type, M.Asset) not in graph or (part, RDF.type, M.PartType) not in graph:
        raise ValueError("Mapped identity is absent from the canonical register")
    quantity_text = row["required_quantity"]
    if not quantity_text.isascii() or not quantity_text.isdecimal() or not 1 <= int(quantity_text) <= 999:
        raise ValueError("Quantity must be a whole number from 1 to 999")
    if row["unit"] != "each":
        raise ValueError("This lesson accepts each, not boxes or other units")
    order = M["id/ORG-TRAIN/order/WO-204"]
    for predicate, value in (
        (RDF.type, M.WorkOrder), (M.recordId, Literal(row["order_id"])),
        (M.asset, asset), (M.part, part),
        (M.requiredQuantity, Literal(int(quantity_text), datatype=XSD.integer)),
        (M.unit, Literal(row["unit"])),
    ):
        graph.add((order, predicate, value))
    return order

order = map_order(graph, HERE / "orders.csv")
save(graph, output, "03_mapped-order.ttl")

Run the file again. 03_mapped-order.ttl now has 25 triples. The new order contributed six statements: its class, record ID, asset link, part link, required quantity, and unit.

Find the order's m:asset property in the Turtle file. Its value is GEN-17's full identifier, not a copied generator name. Follow that identifier to the asset's own statements. That link is what allows a later query to join the two records without guessing what the text means.

Source fieldGraph resultDecision made during mapping
order_idThe order identifier and m:recordIdThis lesson accepts the single specified order
asset_refm:asset → canonical GEN-17A reviewed alias must resolve to an existing Asset
part_refm:part → canonical PF-9A reviewed alias must resolve to an existing PartType
required_quantitym:requiredQuantity → typed integer 2Reject fractions, zero, negative values, and values above this lesson's 999-item bound
unitm:unit → eachReject unsupported units instead of inventing a conversion

This is a small, intentional importer. The full lab reads canonical records and aliases from separate CSV files and supports more than one organization. An organization code in a local file is a scope value; it does not authenticate the person opening that file.

Checkpoint: Change GENERATOR_017 to GENERATOR_018 in this lesson CSV and rerun. It must stop with “Unknown alias: review the source mapping.” Restore the original value before continuing. Existing generated files may remain from the earlier successful run, so only a successful current run establishes that the outputs match your source.

Step 4: Make a rule pass, then deliberately break it#

A SHACL shape is an executable acceptance rule for the supplied graph.203 This shape requires one asset link, one part link, one positive integer requirement within the chosen bound, and the unit each.

Append this block. First it validates the real graph. Then it makes a separate copy and changes the quantity to the string "two". The original graph stays valid.

python
SHAPES = """
@prefix m: <https://example.org/maintenance/> .
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
m:WorkOrderShape a sh:NodeShape ;
    sh:targetClass m:WorkOrder ;
    sh:property [ sh:path m:asset ; sh:minCount 1 ; sh:maxCount 1 ; sh:class m:Asset ] ;
    sh:property [ sh:path m:part ; sh:minCount 1 ; sh:maxCount 1 ; sh:class m:PartType ] ;
    sh:property [ sh:path m:requiredQuantity ; sh:minCount 1 ; sh:maxCount 1 ;
                  sh:datatype xsd:integer ; sh:minInclusive 1 ; sh:maxInclusive 999 ] ;
    sh:property [ sh:path m:unit ; sh:minCount 1 ; sh:maxCount 1 ; sh:in ( "each" ) ] .
"""


def check(graph):
    return validate(
        graph, shacl_graph=Graph().parse(data=SHAPES, format="turtle"),
        inference="none", advanced=False, js=False, do_owl_imports=False,
    )


def demonstrate_validation(graph, order, output):
    (output / "04_shapes.ttl").write_text(SHAPES, encoding="utf-8")
    conforms, report_graph, report_text = check(graph)
    (output / "04_valid-report.txt").write_text(report_text, encoding="utf-8")
    if not conforms:
        raise ValueError("The valid teaching graph failed SHACL")
    broken = Graph()
    for triple in graph:
        broken.add(triple)
    broken.set((order, M.requiredQuantity, Literal("two")))
    broken.serialize(destination=output / "05_broken-graph.ttl", format="turtle")
    bad_conforms, bad_report, bad_text = check(broken)
    (output / "05_invalid-report.txt").write_text(bad_text, encoding="utf-8")
    bad_report.serialize(destination=output / "05_invalid-report.ttl", format="turtle")
    if bad_conforms:
        raise ValueError("The deliberately broken graph unexpectedly passed")
    print(f"valid graph conforms: {conforms}; broken graph conforms: {bad_conforms}")

demonstrate_validation(graph, order, output)

Run the file. In addition to the three graph counts, it should print:

text
valid graph conforms: True; broken graph conforms: False

Open 04_valid-report.txt and 05_invalid-report.txt. The second report identifies the affected work order, m:requiredQuantity, and a datatype violation. 05_broken-graph.ttl contains the actual broken graph, so you can inspect the input that failed. No failure message has been hard-coded as a substitute for validation.

Why validate after checking the CSV? The importer catches source-format and identity problems early. SHACL checks the resulting graph's contract. It still catches a wrong value if a different importer or a later graph edit bypasses those source checks. Neither check proves that the warehouse or maintenance record is physically correct.

Step 5: Query the relationships you created#

Append the final block. SPARQL is a query language for RDF graphs. Read the pattern as a connected question: find this order, follow its asset and part links, then return their record IDs and the order's quantity and unit.

python
QUERY = """
PREFIX m: <https://example.org/maintenance/>
SELECT ?assetId ?partId ?quantity ?unit WHERE {
    ?order a m:WorkOrder ; m:asset ?asset ; m:part ?part ;
           m:requiredQuantity ?quantity ; m:unit ?unit .
    ?asset m:recordId ?assetId .
    ?part m:recordId ?partId .
}
"""


def query_order(graph, order, output):
    conforms, _, _ = check(graph)
    if not conforms:
        raise ValueError("Query refused: graph does not conform to the shapes")
    rows = [
        {"assetId": str(row.assetId), "partId": str(row.partId),
         "quantity": int(row.quantity), "unit": str(row.unit)}
        for row in graph.query(QUERY, initBindings={"order": order})
    ]
    (output / "06_order.rq").write_text(QUERY, encoding="utf-8")
    (output / "06_query-result.json").write_text(json.dumps(rows, indent=2) + "\n", encoding="utf-8")
    print(json.dumps(rows))
    return rows

query_order(graph, order, output)

Run the file again. The final line should be:

json
[{"assetId": "GEN-17", "partId": "PF-9", "quantity": 2, "unit": "each"}]

Open 06_order.rq to inspect the query and 06_query-result.json to inspect its result. The query checks graph conformity before returning an answer and binds the requested order as an RDF value. It does not accept arbitrary query text or perform a business action.

You have now created an ontology schema and populated it from a source row. The query result came from the explicit relationships and values in your graph. It was not generated by a language model.

Check your work and know what to add next#

The ZIP includes lessons/build_step_by_step.py, which contains the same construction functions and a runner. Use it to compare against your own file; it writes to lessons/results/, keeping your my-results/ separate. Run the reference and its tests with:

powershell
.venv/Scripts/python.exe lessons/build_step_by_step.py
.venv/Scripts/python.exe -m unittest discover -s lessons -p test_progressive.py -v

Seven tests were executed successfully. They check the staged graph counts and actual query result, a real SHACL failure, unknown identity, wrong organization, unsupported units, fractional quantity, and refusal to query a graph missing its required asset link. Triple serialization order and blank-node names can differ; those are not changes to the graph's meaning.

What you createdWhat you should verify
Three classes plus propertiesA class is a category, not one specific asset
Two canonical records and a mapped orderThe order links to existing identifiers
An explicit source-to-graph mappingUnknown references fail instead of becoming guessed links
Valid and invalid SHACL reportsThe string "two" genuinely fails the integer rule
A fixed SPARQL query and JSON outputGEN-17 needs 2 items of part type PF-9 for this order

Why not add all possible classes now? Introduce a concept when a question needs its identity, properties, relationships, or history. The full baseline adds StockSnapshot because inventory changes over time, Allocation because reserved and quarantined units need separate evidence, and InspectionEvidence because a status has a source and observation time. A Depot can remain a controlled value until you need depot-specific properties or relationships. More classes alone do not produce a more useful ontology.

This lesson deliberately does not calculate availability. Chapter 3 runs the complete baseline, which adds those three classes, dated records, stock rules, and file-and-row provenance. Its populated data graph has 121 triples; do not compare that count with this lesson's 25-triple graph, which includes its small schema. Keep their output folders separate.

Source 201 ↗

W3C, RDF 1.1 Concepts and Abstract Syntax: https://www.w3.org/TR/rdf11-concepts/ — identifiers and graph statements checked 14 September 2026.

Source 202 ↗

W3C, OWL 2 Web Ontology Language Primer, sections 2 and 4.6: https://www.w3.org/TR/owl2-primer/ — description, inference, and open-world distinctions checked 14 September 2026.

Source 203 ↗

W3C, Shapes Constraint Language (SHACL): https://www.w3.org/TR/shacl/ — validation semantics checked 14 September 2026. The lesson's shapes and data are original examples.