- add process_metrics function to record peak RSS and CPU time - update JSON converter to include metrics in completion marker - modify SQLite converter to utilize new metrics for performance tracking - enhance storage size updates with locking mechanism for concurrent access
396 lines
14 KiB
Python
396 lines
14 KiB
Python
"""Task 07 - Convert the CSV corpus to RDF.
|
|
|
|
Streams the corpus into ./out/rdf/dataset.nt.gz (N-Triples, gzip) and
|
|
./out/rdf/dataset.ttl (Turtle) with the task 04 vocabulary (tribo# + PROV-O
|
|
for started_at), then bulk-loads the N-Triples into an embedded on-disk
|
|
oxigraph store (./out/rdf/oxigraph_store/) and smoke-tests it with SPARQL
|
|
COUNT queries. Bulk points (cycles, loop points, map points) carry no
|
|
rdf:type - the containing predicate types them (documented decision per
|
|
spec 07), which is why the triple count lands below the spec's 25-45M
|
|
sketch. Appends serialization and store sizes to storage_sizes.csv and
|
|
writes ./out/.done/07.ok. Spec: docs/specs/07_convert_rdf.md.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import gzip
|
|
import io
|
|
import logging
|
|
import shutil
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from pyoxigraph import Store
|
|
|
|
try:
|
|
from pyoxigraph import RdfFormat
|
|
NT_FORMAT = RdfFormat.N_TRIPLES
|
|
except ImportError: # pyoxigraph < 0.5 uses MIME strings
|
|
NT_FORMAT = "application/n-triples"
|
|
|
|
from common.corpus import CorpusReader, ValidationError
|
|
from common.pipeline import (
|
|
check_dependencies,
|
|
load_lab_config,
|
|
parse_task_args,
|
|
process_metrics,
|
|
remove_stale_marker,
|
|
write_marker,
|
|
)
|
|
from common.storage_sizes import update_storage_sizes
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TASK_ID = "07"
|
|
DEPENDS_ON = ["01", "03"]
|
|
|
|
VOCAB = "https://sandia.gov/ontology/tribology#"
|
|
ID_BASE = "https://sandia.gov/ontology/tribology/id/"
|
|
XSD = "http://www.w3.org/2001/XMLSchema#"
|
|
|
|
PREFIXES = {
|
|
"tribo": VOCAB,
|
|
"batch": ID_BASE + "batch/",
|
|
"wafer": ID_BASE + "wafer/",
|
|
"coupon": ID_BASE + "coupon/",
|
|
"track": ID_BASE + "track/",
|
|
"run": ID_BASE + "run/",
|
|
"instrument": ID_BASE + "instrument/",
|
|
"simtra": ID_BASE + "simtra/",
|
|
"prov": "http://www.w3.org/ns/prov#",
|
|
"xsd": XSD,
|
|
}
|
|
|
|
INT_COLS = {
|
|
"cycle", "grid_x", "grid_y", "indent_id", "plates", "plate", "probe", "square",
|
|
"row_no", "wafer_index", "coupons", "friction_coupons", "reserve_coupons",
|
|
"pt_gun_tilt_deg", "au_gun_tilt_deg", "pt_power_w", "au_power_w",
|
|
"pt_discharge_v", "au_discharge_v",
|
|
}
|
|
DOUBLE_COLS = {
|
|
"cof", "position_um", "friction_force_mn", "pt_wtpct", "au_wtpct", "thickness_um",
|
|
"x_um", "y_um", "hardness_gpa", "reduced_modulus_gpa", "max_load_mn", "ra_nm",
|
|
"rq_nm", "wear_volume_um3", "k_archard", "sliding_distance_m", "load_mn",
|
|
"stroke_mm", "speed_mm_s", "angle_deg", "energy_ev", "pt_flux", "au_flux",
|
|
"au_wtpct_mean",
|
|
}
|
|
DATE_COLS = {"date", "deposition_date"}
|
|
|
|
MEASUREMENTS = [
|
|
("xrf_map.csv", "xrf", "CompositionMeasurement", "m4_tornado"),
|
|
("profilometry.csv", "profilometry", "ProfilometryMeasurement", "profilometer"),
|
|
("nanoindentation.csv", "nanoindentation", "NanoindentationMeasurement", "ti980"),
|
|
]
|
|
|
|
|
|
def lit(col: str, token: str) -> tuple:
|
|
if col in INT_COLS:
|
|
return ("l", token, "integer")
|
|
if col in DOUBLE_COLS:
|
|
return ("l", token, "double")
|
|
if col in DATE_COLS:
|
|
return ("l", token, "date")
|
|
if col == "started_at":
|
|
return ("l", token, "dateTime")
|
|
return ("l", token, None)
|
|
|
|
|
|
def iri(curie: str) -> tuple:
|
|
return ("i", curie)
|
|
|
|
|
|
def node(pairs: list) -> tuple:
|
|
return ("n", pairs)
|
|
|
|
|
|
class RdfEmitter:
|
|
"""Writes the same subject blocks to N-Triples and Turtle streams."""
|
|
|
|
def __init__(self, nt_fh, ttl_fh) -> None:
|
|
self.nt = nt_fh
|
|
self.ttl = ttl_fh
|
|
self.triples = 0
|
|
self._blank = 0
|
|
for prefix, base in PREFIXES.items():
|
|
self.ttl.write(f"@prefix {prefix}: <{base}> .\n")
|
|
self.ttl.write("\n")
|
|
|
|
def _full(self, curie: str) -> str:
|
|
prefix, local = curie.split(":", 1)
|
|
return f"<{PREFIXES[prefix]}{local}>"
|
|
|
|
def _pred_nt(self, pred: str) -> str:
|
|
if pred == "a":
|
|
return "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>"
|
|
return self._full(pred)
|
|
|
|
@staticmethod
|
|
def _escape(text: str) -> str:
|
|
return text.replace("\\", "\\\\").replace('"', '\\"')
|
|
|
|
def _obj_nt(self, obj: tuple) -> str:
|
|
kind = obj[0]
|
|
if kind == "i":
|
|
return self._full(obj[1])
|
|
if kind == "l":
|
|
_, token, dtype = obj
|
|
if dtype is None:
|
|
return f'"{self._escape(token)}"'
|
|
return f'"{token}"^^<{XSD}{dtype}>'
|
|
self._blank += 1
|
|
label = f"_:b{self._blank}"
|
|
for pred, sub_obj in obj[1]:
|
|
self.nt.write(f"{label} {self._pred_nt(pred)} {self._obj_nt(sub_obj)} .\n")
|
|
self.triples += 1
|
|
return label
|
|
|
|
def _obj_ttl(self, obj: tuple) -> str:
|
|
kind = obj[0]
|
|
if kind == "i":
|
|
return obj[1]
|
|
if kind == "l":
|
|
_, token, dtype = obj
|
|
if dtype is None:
|
|
return f'"{self._escape(token)}"'
|
|
if dtype == "integer":
|
|
return token
|
|
return f'"{token}"^^xsd:{dtype}'
|
|
inner = " ; ".join(f"{p} {self._obj_ttl(o)}" for p, o in obj[1])
|
|
return f"[ {inner} ]"
|
|
|
|
def emit(self, subject: str, pairs: list) -> None:
|
|
subj_nt = self._full(subject)
|
|
ttl_parts = []
|
|
for pred, obj in pairs:
|
|
# N-Triples: nested nodes expand to labelled blanks first, so the
|
|
# referencing triple is written after the node's own triples.
|
|
obj_nt = self._obj_nt(obj)
|
|
self.nt.write(f"{subj_nt} {self._pred_nt(pred)} {obj_nt} .\n")
|
|
self.triples += 1
|
|
ttl_parts.append(f"{pred} {self._obj_ttl(obj)}")
|
|
self.ttl.write(subject + " " + " ;\n ".join(ttl_parts) + " .\n")
|
|
|
|
|
|
class RdfConverter:
|
|
def __init__(self, cfg: dict, csv_root: Path, emitter: RdfEmitter) -> None:
|
|
self.cfg = cfg
|
|
self.reader = CorpusReader(csv_root)
|
|
self.em = emitter
|
|
self.counts = {"coupons": 0, "tracks": 0, "cycle_nodes": 0, "loop_nodes": 0, "xrf_nodes": 0}
|
|
|
|
def raw_rows(self, relpath: str) -> list[dict[str, str]]:
|
|
header, rows = self.reader.read(relpath)
|
|
keys = [h.lower() for h in header]
|
|
return [dict(zip(keys, row)) for row in rows]
|
|
|
|
def prop_pairs(self, row: dict[str, str], skip: tuple = ()) -> list:
|
|
return [
|
|
("prov:startedAtTime" if col == "started_at" else f"tribo:{col}", lit(col, token))
|
|
for col, token in row.items()
|
|
if col not in skip and token != ""
|
|
]
|
|
|
|
def convert(self) -> None:
|
|
for inst in self.cfg["instruments"]:
|
|
self.em.emit(f"instrument:{inst['instrument_id']}", [
|
|
("a", iri("tribo:Instrument")),
|
|
("tribo:name", ("l", inst["name"], None)),
|
|
("tribo:role", ("l", inst["role"], None)),
|
|
])
|
|
for row in self.raw_rows("batches.csv"):
|
|
code = row["batch_code"]
|
|
self.em.emit(f"batch:{code}", [("a", iri("tribo:Batch"))] + self.prop_pairs(row))
|
|
points = [
|
|
("tribo:hasPoint", node([("tribo:row_no", ("l", str(no), "integer"))] + self.prop_pairs(p)))
|
|
for no, p in enumerate(self.raw_rows(f"simtra/simtra_profile_{code}.csv"), 1)
|
|
]
|
|
self.em.emit(f"simtra:{code}", [
|
|
("a", iri("tribo:SimtraProfile")),
|
|
("tribo:performedOn", iri(f"batch:{code}")),
|
|
("tribo:performedBy", iri("instrument:simtra")),
|
|
] + points)
|
|
for row in self.raw_rows("runs.csv"):
|
|
self.em.emit(f"run:{row['run_code']}", [("a", iri("tribo:Run"))] + self.prop_pairs(row))
|
|
|
|
h = self.cfg["hierarchy"]
|
|
for batch in self.cfg["deposition_matrix"]:
|
|
b_code = batch["batch_code"]
|
|
for w in range(1, h["wafers_per_batch"] + 1):
|
|
wafer_dir = f"batch_{b_code}/wafer_W{w}"
|
|
info = self.raw_rows(f"{wafer_dir}/wafer_info.csv")[0]
|
|
self.em.emit(f"wafer:{info['wafer_code']}", [
|
|
("a", iri("tribo:Wafer")),
|
|
("tribo:partOf", iri(f"batch:{b_code}")),
|
|
] + self.prop_pairs(info, skip=("batch_code",)))
|
|
for c in range(1, h["coupons_per_wafer"] + 1):
|
|
self.convert_coupon(f"{wafer_dir}/coupon_C{c:02d}")
|
|
logger.info("batch %s converted (%d triples so far)", b_code, self.em.triples)
|
|
|
|
def convert_coupon(self, rel_dir: str) -> None:
|
|
info = self.raw_rows(f"{rel_dir}/coupon_info.csv")[0]
|
|
code = info["coupon_code"]
|
|
subject = f"coupon:{code}"
|
|
is_friction = info["run_code"] != "RESERVE"
|
|
pairs = [("a", iri("tribo:Coupon")), ("tribo:cutFrom", iri(f"wafer:{info['wafer_code']}"))]
|
|
pairs += self.prop_pairs(info, skip=("batch_code", "wafer_code", "run_code"))
|
|
pairs.append(("tribo:run_code", ("l", info["run_code"], None)))
|
|
if is_friction:
|
|
pairs.append(("tribo:duringRun", iri(f"run:{info['run_code']}")))
|
|
|
|
for csv_name, key, cls, inst in MEASUREMENTS:
|
|
points = [
|
|
("tribo:hasPoint" if key != "nanoindentation" else "tribo:hasIndent", node(self.prop_pairs(p)))
|
|
for p in self.raw_rows(f"{rel_dir}/{csv_name}")
|
|
]
|
|
if key == "xrf":
|
|
self.counts["xrf_nodes"] += len(points)
|
|
pairs.append((f"tribo:{key}", node([
|
|
("a", iri(f"tribo:{cls}")),
|
|
("tribo:performedOn", iri(subject)),
|
|
("tribo:performedBy", iri(f"instrument:{inst}")),
|
|
] + points)))
|
|
afm = self.raw_rows(f"{rel_dir}/afm.csv")[0]
|
|
pairs.append(("tribo:afm", node([
|
|
("a", iri("tribo:AFMMeasurement")),
|
|
("tribo:performedOn", iri(subject)),
|
|
("tribo:performedBy", iri("instrument:afm")),
|
|
] + self.prop_pairs(afm))))
|
|
self.em.emit(subject, pairs)
|
|
self.counts["coupons"] += 1
|
|
|
|
if is_friction:
|
|
for t in range(1, self.cfg["friction_assignment"]["tracks_per_friction_coupon"] + 1):
|
|
self.convert_track(f"{rel_dir}/track_T{t}", subject)
|
|
|
|
def convert_track(self, rel_dir: str, coupon_subject: str) -> None:
|
|
info = self.raw_rows(f"{rel_dir}/track_info.csv")[0]
|
|
pairs = [
|
|
("a", iri("tribo:FrictionTest")),
|
|
("tribo:partOf", iri(coupon_subject)),
|
|
("tribo:duringRun", iri(f"run:{info['run_code']}")),
|
|
("tribo:performedBy", iri("instrument:rapid")),
|
|
] + self.prop_pairs(info, skip=("track_code", "run_code"))
|
|
pairs.insert(1, ("tribo:track_code", ("l", info["track_code"], None)))
|
|
|
|
cycles = self.raw_rows(f"{rel_dir}/cof_vs_cycle.csv")
|
|
pairs += [("tribo:hasCycle", node(self.prop_pairs(row))) for row in cycles]
|
|
self.counts["cycle_nodes"] += len(cycles)
|
|
loops = self.raw_rows(f"{rel_dir}/friction_loops.csv")
|
|
pairs += [("tribo:hasLoopPoint", node(self.prop_pairs(row))) for row in loops]
|
|
self.counts["loop_nodes"] += len(loops)
|
|
wear = self.raw_rows(f"{rel_dir}/wear.csv")[0]
|
|
pairs.append(("tribo:wear", node([("a", iri("tribo:WearMeasurement"))] + self.prop_pairs(wear))))
|
|
self.em.emit(f"track:{info['track_code']}", pairs)
|
|
self.counts["tracks"] += 1
|
|
|
|
def validate_counts(self) -> None:
|
|
v = self.cfg["volumes"]
|
|
expected = {
|
|
"coupons": v["coupons_total"],
|
|
"tracks": v["tracks_total"],
|
|
"cycle_nodes": v["cycle_rows_total"],
|
|
"loop_nodes": v["loop_points_total"],
|
|
"xrf_nodes": v["xrf_points_total"],
|
|
}
|
|
for key, exp in expected.items():
|
|
if self.counts[key] != exp:
|
|
raise ValidationError(f"count mismatch: {key}: converted {self.counts[key]} != expected {exp}")
|
|
logger.info("all converted counts match lab_config volumes: %s", self.counts)
|
|
|
|
|
|
def bulk_load(store: Store, nt_gz: Path) -> None:
|
|
with gzip.open(nt_gz, "rb") as fh:
|
|
try:
|
|
store.bulk_load(fh, NT_FORMAT)
|
|
except TypeError:
|
|
store.bulk_load(input=fh, format=NT_FORMAT)
|
|
|
|
|
|
def sparql_count(store: Store, query: str) -> int:
|
|
solutions = store.query(query)
|
|
return int(next(iter(solutions))[0].value)
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_task_args("Task 07: convert the CSV corpus to RDF")
|
|
out_root: Path = args.out_root
|
|
rdf_root = out_root / "rdf"
|
|
nt_gz = rdf_root / "dataset.nt.gz"
|
|
ttl_path = rdf_root / "dataset.ttl"
|
|
store_dir = rdf_root / "oxigraph_store"
|
|
try:
|
|
check_dependencies(out_root, DEPENDS_ON)
|
|
remove_stale_marker(out_root, TASK_ID)
|
|
if rdf_root.exists():
|
|
logger.info("re-run: removing previous output %s", rdf_root)
|
|
shutil.rmtree(rdf_root)
|
|
rdf_root.mkdir(parents=True)
|
|
cfg = load_lab_config(out_root)
|
|
|
|
# mtime=0 keeps the gzip byte-identical across re-runs (data-determinism)
|
|
with open(nt_gz, "wb") as raw:
|
|
gz = gzip.GzipFile(filename="", mode="wb", fileobj=raw, compresslevel=6, mtime=0)
|
|
with io.TextIOWrapper(gz, encoding="ascii", newline="\n") as nt_fh, \
|
|
open(ttl_path, "w", encoding="ascii", newline="\n") as ttl_fh:
|
|
emitter = RdfEmitter(nt_fh, ttl_fh)
|
|
conv = RdfConverter(cfg, out_root / "csv", emitter)
|
|
conv.convert()
|
|
conv.validate_counts()
|
|
triples = emitter.triples
|
|
nt_bytes = nt_gz.stat().st_size
|
|
ttl_bytes = ttl_path.stat().st_size
|
|
logger.info("serialized %d triples: nt.gz %.1f MiB, ttl %.1f MiB",
|
|
triples, nt_bytes / 1048576, ttl_bytes / 1048576)
|
|
|
|
t0 = time.time()
|
|
store = Store(str(store_dir))
|
|
bulk_load(store, nt_gz)
|
|
store.flush()
|
|
load_seconds = round(time.time() - t0, 1)
|
|
|
|
total = sparql_count(store, "SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }")
|
|
if total != triples:
|
|
raise ValidationError(f"store holds {total} triples, serialized {triples}")
|
|
q1 = sparql_count(
|
|
store,
|
|
f"SELECT (COUNT(?c) AS ?n) WHERE {{ <{ID_BASE}track/B722-W2-C13-T2> <{VOCAB}hasCycle> ?c }}",
|
|
)
|
|
if q1 != cfg["volumes"]["cycles_per_track"]:
|
|
raise ValidationError(f"SPARQL Q1 smoke test returned {q1} cycles")
|
|
logger.info("store smoke tests ok: %d triples, Q1 track has %d cycles (load %.1f s)", total, q1, load_seconds)
|
|
del store
|
|
store_bytes = sum(f.stat().st_size for f in store_dir.rglob("*") if f.is_file())
|
|
|
|
update_storage_sizes(out_root / "bench", "rdf", [
|
|
("nt_gz", nt_bytes), ("ttl", ttl_bytes), ("store", store_bytes),
|
|
])
|
|
entries = {
|
|
"triples": triples,
|
|
"nt_gz_bytes": nt_bytes,
|
|
"ttl_bytes": ttl_bytes,
|
|
"store_bytes": store_bytes,
|
|
"store_load_seconds": load_seconds,
|
|
"coupons": conv.counts["coupons"],
|
|
"tracks": conv.counts["tracks"],
|
|
"cycle_nodes": conv.counts["cycle_nodes"],
|
|
"loop_nodes": conv.counts["loop_nodes"],
|
|
}
|
|
entries.update(process_metrics())
|
|
marker_path = write_marker(out_root, TASK_ID, entries)
|
|
except Exception:
|
|
logger.critical("task %s failed", TASK_ID, exc_info=True)
|
|
return 1
|
|
|
|
print(f"task 07 ok: {triples} triples; nt.gz {nt_bytes / 1048576:.1f} MiB, "
|
|
f"ttl {ttl_bytes / 1048576:.1f} MiB, store {store_bytes / 1048576:.1f} MiB "
|
|
f"(bulk load {load_seconds} s)")
|
|
print(f"entities: coupons={conv.counts['coupons']} tracks={conv.counts['tracks']} "
|
|
f"cycles={conv.counts['cycle_nodes']} loop_points={conv.counts['loop_nodes']}")
|
|
print(f"marker: {marker_path}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|