Files
LabDataStorageEvaluation/generate_data.py
administrator 96ed7bc918 feat(convert): add task 05 sqlite converter with variant B keys" -m "- convert: convert_sqlite.py builds tribo.db (composite PKs, enforced FKs, Q1-Q7 indexes, track_summary)
- tools: common/track_summary.py fixes the cross-engine steady-state algorithm
- rules: db-sql-schema records the FK enforcement decision and track_summary definition
- specs: 05 aligned with data-naming-units, simtra_profiles added
- docs: FK key-schema study added under docs/research/, README updated
- replace manual CSV reading with CorpusReader for better data handling
- streamline argument parsing and dependency checks using common pipeline functions
- enhance marker writing for task completion tracking
- remove unused regex and validation error classes for cleaner code
2026-07-11 16:08:29 -04:00

420 lines
16 KiB
Python

"""Task 03 - Simulated CSV data generation (~150 MB).
Generates the canonical raw corpus under ./out/csv/ exactly as the
instruments would write it, driven entirely by out/config/lab_config.yaml
(volumes, physical-model coefficients, seed). Also writes
./out/csv/MANIFEST.csv (path, rows, bytes, sha256) and the
./out/.done/03.ok marker. Spec: docs/specs/03_csv_data_generation.md.
"""
from __future__ import annotations
import hashlib
import logging
import math
import shutil
import sys
from datetime import datetime, timedelta
from pathlib import Path
import numpy as np
from common.pipeline import (
check_dependencies,
load_lab_config,
parse_task_args,
remove_stale_marker,
write_marker,
)
logger = logging.getLogger(__name__)
TASK_ID = "03"
DEPENDS_ON = ["01"]
SIZE_GATE_MIB = (120, 200) # abort bounds on the generated tree, spec 03
# One float format per column family (docs/rules/data-determinism.md):
# repr drift between runs must never change file bytes. Precision of the two
# bulk families also sets corpus size; chosen to land in the 140-170 MB
# target band of spec 03.
FMT_COF = "%.16g"
FMT_POSITION = "%.14g"
FMT_FORCE = "%.14g"
FMT_MISC = "%.10g" # wt%, um, nm, GPa, simtra, wear
# RNG stream tags for per-entity derived generators.
S_XRF, S_PROF, S_NANO, S_AFM, S_COF, S_LOOP, S_WEAR, S_SIMTRA = range(1, 9)
class ValidationError(RuntimeError):
pass
class CorpusGenerator:
def __init__(self, cfg: dict, csv_root: Path) -> None:
self.cfg = cfg
self.csv_root = csv_root
self.seed = cfg["seed"]
self.manifest: list[tuple[str, int, int, str]] = []
self.total_rows = 0
self.total_bytes = 0
self.counts = {"coupons": 0, "tracks": 0, "cycle_rows": 0, "loop_rows": 0, "xrf_rows": 0}
self.runs_by_batch = {r["batch_code"]: r for r in cfg["runs"]}
def rng(self, *tags: int) -> np.random.Generator:
return np.random.default_rng([self.seed, *tags])
def write_csv(self, relpath: str, header: str, lines: list[str]) -> None:
text = header + "\n" + "\n".join(lines) + "\n"
data = text.encode("ascii")
path = self.csv_root / relpath
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
sha = hashlib.sha256(data).hexdigest()
self.manifest.append((relpath, len(lines), len(data), sha))
self.total_rows += len(lines)
self.total_bytes += len(data)
# --- top-level flat files ---
def gen_batches(self) -> None:
sched = self.cfg["schedule"]
start = datetime.fromisoformat(sched["deposition_start_date"])
lines = []
for i, b in enumerate(self.cfg["deposition_matrix"]):
date = (start + timedelta(days=i * sched["batch_interval_days"])).date().isoformat()
lines.append(
f"{b['batch_code']},{b['pt_gun_tilt_deg']},{b['au_gun_tilt_deg']},"
f"{b['pt_power_W']},{b['au_power_W']},{b['pt_discharge_V']},{b['au_discharge_V']},{date}"
)
self.write_csv(
"batches.csv",
"batch_code,pt_gun_tilt_deg,au_gun_tilt_deg,pt_power_W,au_power_W,pt_discharge_V,au_discharge_V,deposition_date",
lines,
)
def gen_runs(self) -> None:
plates = self.cfg["friction_assignment"]["plates_per_run"]
lines = [
f"{r['run_code']},{r['environment']},{r['date']},{plates},{r['operator']}"
for r in self.cfg["runs"]
]
self.write_csv("runs.csv", "run_code,environment,date,plates,operator", lines)
def gen_simtra(self) -> None:
pm = self.cfg["physical_models"]["simtra"]
n = self.cfg["volumes"]["simtra_rows_per_batch"]
us_pt = pm["surface_binding_energy_eV"]["pt"]
us_au = pm["surface_binding_energy_eV"]["au"]
for b_idx, b in enumerate(self.cfg["deposition_matrix"], 1):
rng = self.rng(b_idx, S_SIMTRA)
angle = np.linspace(pm["angle_deg_range"][0], pm["angle_deg_range"][1], n)
pt_flux = pm["flux_per_W"] * b["pt_power_W"] * np.cos(np.radians(angle - b["pt_gun_tilt_deg"])) ** 2
au_flux = pm["flux_per_W"] * b["au_power_W"] * np.cos(np.radians(angle - b["au_gun_tilt_deg"])) ** 2
pt_flux = np.maximum(pt_flux * (1 + rng.normal(0, 0.02, n)), 0)
au_flux = np.maximum(au_flux * (1 + rng.normal(0, 0.02, n)), 0)
# Thompson mean emission energy ~ 2 * surface binding energy,
# flux-weighted across the two species.
energy = (pt_flux * 2 * us_pt + au_flux * 2 * us_au) / (pt_flux + au_flux + 1e-12)
energy = energy + rng.normal(0, 0.2, n)
lines = [
f"{FMT_MISC % a},{FMT_MISC % e},{FMT_MISC % p},{FMT_MISC % g}"
for a, e, p, g in zip(angle, energy, pt_flux, au_flux)
]
self.write_csv(f"simtra/simtra_profile_{b['batch_code']}.csv", "angle_deg,energy_eV,pt_flux,au_flux", lines)
# --- per-coupon measurements ---
def gen_xrf(self, rel_dir: str, b_idx: int, w: int, c: int, au_nominal: float) -> float:
gx_n, gy_n = self.cfg["volumes"]["xrf_grid"]
noise_sd = self.cfg["physical_models"]["au_gradient"]["xrf_noise_sd_wtpct"]
rng = self.rng(b_idx, w, c, S_XRF)
au = au_nominal + rng.normal(0, noise_sd, gx_n * gy_n)
lines = []
i = 0
for gy in range(1, gy_n + 1):
for gx in range(1, gx_n + 1):
lines.append(f"{gx},{gy},{FMT_MISC % (100.0 - au[i])},{FMT_MISC % au[i]}")
i += 1
self.write_csv(f"{rel_dir}/xrf_map.csv", "grid_x,grid_y,pt_wtpct,au_wtpct", lines)
self.counts["xrf_rows"] += len(lines)
return float(au.mean())
def gen_profilometry(self, rel_dir: str, b_idx: int, w: int, c: int, row: int, col: int) -> float:
pm = self.cfg["physical_models"]["thickness_um"]
gx_n, gy_n = self.cfg["volumes"]["profilometry_grid"]
# Normalized squared wafer radius: corner coupon (r2=1) is thinnest.
r2 = (((col - 4) / 3.0) ** 2 + ((row - 4) / 3.0) ** 2) / 2.0
nominal = pm["center_um"] - (pm["center_um"] - pm["edge_um"]) * r2
rng = self.rng(b_idx, w, c, S_PROF)
t = nominal + rng.normal(0, pm["noise_sd_um"], gx_n * gy_n)
lines = []
i = 0
for gy in range(1, gy_n + 1):
for gx in range(1, gx_n + 1):
lines.append(f"{gx},{gy},{FMT_MISC % t[i]}")
i += 1
self.write_csv(f"{rel_dir}/profilometry.csv", "grid_x,grid_y,thickness_um", lines)
return float(t.mean())
def gen_nanoindentation(self, rel_dir: str, b_idx: int, w: int, c: int, au_mean: float) -> None:
pm = self.cfg["physical_models"]
n = self.cfg["volumes"]["nanoindentation_indents_per_coupon"]
grid_w = pm["nanoindentation"]["indent_grid"][0]
pitch = pm["nanoindentation"]["grid_pitch_um"]
max_load = pm["nanoindentation"]["max_load_mN"]
rng = self.rng(b_idx, w, c, S_NANO)
h = pm["hardness_GPa"]["intercept"] + pm["hardness_GPa"]["slope_per_au_wtpct"] * au_mean \
+ rng.normal(0, pm["hardness_GPa"]["noise_sd"], n)
er = pm["reduced_modulus_GPa"]["intercept"] + pm["reduced_modulus_GPa"]["slope_per_au_wtpct"] * au_mean \
+ rng.normal(0, pm["reduced_modulus_GPa"]["noise_sd"], n)
lines = [
f"{i + 1},{(i % grid_w) * pitch},{(i // grid_w) * pitch},{FMT_MISC % h[i]},{FMT_MISC % er[i]},{max_load}"
for i in range(n)
]
self.write_csv(
f"{rel_dir}/nanoindentation.csv",
"indent_id,x_um,y_um,hardness_GPa,reduced_modulus_GPa,max_load_mN",
lines,
)
def gen_afm(self, rel_dir: str, b_idx: int, w: int, c: int, coupon_code: str) -> float:
pm = self.cfg["physical_models"]["roughness_ra_nm"]
rng = self.rng(b_idx, w, c, S_AFM)
ra = float(np.exp(rng.normal(math.log(pm["median_nm"]), pm["sigma_log"])))
rq = ra * pm["rq_over_ra"]
self.write_csv(
f"{rel_dir}/afm.csv",
"ra_nm,rq_nm,image_file",
[f"{FMT_MISC % ra},{FMT_MISC % rq},images/afm/{coupon_code}.tiff"],
)
return ra
# --- per-track friction data ---
def cof_ss(self, au_mean: float, environment: str) -> float:
pm = self.cfg["physical_models"]["cof_steady_state"]
value = pm[environment]["intercept"] + pm[environment]["slope_per_au_wtpct"] * au_mean
return max(value, pm["floor"]) # deliberate model floor, spec 01
def gen_track(
self, rel_dir: str, b_idx: int, w: int, c: int, t: int,
track_code: str, run: dict, au_mean: float, plate: int, probe: int, square: int,
) -> None:
tc = self.cfg["test_conditions"]
pm = self.cfg["physical_models"]
v = self.cfg["volumes"]
sched = self.cfg["schedule"]
env = run["environment"]
load_mn = tc["normal_load_mN"]
started = datetime.fromisoformat(f"{run['date']}T{sched['run_start_time']}+00:00") + timedelta(
seconds=((plate - 1) * 12 + (square - 1) * 3 + (t - 1)) * sched["track_interval_s"]
)
counterface_id = f"{run['run_code']}-P{plate}-PR{probe}-B{t}"
self.write_csv(
f"{rel_dir}/track_info.csv",
"track_code,run_code,environment,load_mN,stroke_mm,speed_mm_s,counterface_id,started_at",
[
f"{track_code},{run['run_code']},{env},{load_mn},{FMT_MISC % tc['stroke_mm']},"
f"{FMT_MISC % tc['speed_mm_s']},{counterface_id},{started.strftime('%Y-%m-%dT%H:%M:%SZ')}"
],
)
cof_ss = self.cof_ss(au_mean, env)
cof_rng = self.rng(b_idx, w, c, t, S_COF)
ri = pm["cof_run_in"]
cof_0 = cof_rng.uniform(*ri["cof_0_range"])
tau = cof_rng.uniform(*ri["tau_cycles_range"])
n_cycles = v["cycles_per_track"]
cycles = np.arange(1, n_cycles + 1)
decay = (cof_0 - cof_ss) * np.exp(-cycles / tau)
cof = cof_ss + decay + cof_rng.normal(0, ri["noise_sd"], n_cycles)
self.write_csv(
f"{rel_dir}/cof_vs_cycle.csv",
"cycle,cof",
[f"{cyc},{FMT_COF % val}" for cyc, val in zip(cycles, cof)],
)
self.counts["cycle_rows"] += n_cycles
loop_rng = self.rng(b_idx, w, c, t, S_LOOP)
half_um = tc["stroke_mm"] * 1000.0 / 2.0
pts_half = v["loop_points_per_loop"] // 2
fwd = np.linspace(-half_um, half_um, pts_half)
rev = np.linspace(half_um, -half_um, pts_half)
force_sd = pm["friction_loop"]["force_noise_sd_mN"]
lines: list[str] = []
for k in range(1, v["loops_per_track"] + 1):
cyc = k * v["loop_every_n_cycles"]
mu = cof_ss + (cof_0 - cof_ss) * math.exp(-cyc / tau)
f_fwd = mu * load_mn + loop_rng.normal(0, force_sd, pts_half)
f_rev = -mu * load_mn + loop_rng.normal(0, force_sd, pts_half)
lines.extend(f"{cyc},{FMT_POSITION % p},{FMT_FORCE % f}" for p, f in zip(fwd, f_fwd))
lines.extend(f"{cyc},{FMT_POSITION % p},{FMT_FORCE % f}" for p, f in zip(rev, f_rev))
self.write_csv(f"{rel_dir}/friction_loops.csv", "cycle,position_um,friction_force_mN", lines)
self.counts["loop_rows"] += len(lines)
wm = pm["wear_archard"]
wear_rng = self.rng(b_idx, w, c, t, S_WEAR)
k_arch = wm["k0_mm3_per_N_m"][env] * (1 + wm["slope_per_au_wtpct"][env] * au_mean)
k_arch *= math.exp(wear_rng.normal(0, wm["noise_sigma_log"]))
k_arch = max(k_arch, wm["k_floor_mm3_per_N_m"]) # deliberate model floor, spec 01
sliding_m = n_cycles * 2 * tc["stroke_mm"] / 1000.0
volume_um3 = k_arch * (load_mn / 1000.0) * sliding_m * 1e9
self.write_csv(
f"{rel_dir}/wear.csv",
"wear_volume_um3,k_archard,sliding_distance_m",
[f"{FMT_MISC % volume_um3},{FMT_MISC % k_arch},{FMT_MISC % sliding_m}"],
)
self.counts["tracks"] += 1
# --- hierarchy walk ---
def generate(self) -> None:
self.gen_batches()
self.gen_runs()
self.gen_simtra()
h = self.cfg["hierarchy"]
fa = self.cfg["friction_assignment"]
ag = self.cfg["physical_models"]["au_gradient"]
sched = self.cfg["schedule"]
dep_start = datetime.fromisoformat(sched["deposition_start_date"])
reserve = set(fa["reserve_grid_positions"])
grid_w = h["coupon_grid_per_wafer"][0]
for b_idx, batch in enumerate(self.cfg["deposition_matrix"], 1):
b_code = batch["batch_code"]
run = self.runs_by_batch[b_code]
au_center = ag["batch_center_wtpct"][b_code]
dep_date = (dep_start + timedelta(days=(b_idx - 1) * sched["batch_interval_days"])).date().isoformat()
friction_idx = 0
for w in range(1, h["wafers_per_batch"] + 1):
wafer_code = f"{b_code}-W{w}"
wafer_dir = f"batch_{b_code}/wafer_W{w}"
self.write_csv(
f"{wafer_dir}/wafer_info.csv",
"wafer_code,batch_code,wafer_index,deposition_date,coupons,friction_coupons,reserve_coupons",
[
f"{wafer_code},{b_code},{w},{dep_date},{h['coupons_per_wafer']},"
f"{h['coupons_per_wafer'] - len(reserve)},{len(reserve)}"
],
)
for c in range(1, h["coupons_per_wafer"] + 1):
row = (c - 1) // grid_w + 1
col = (c - 1) % grid_w + 1
coupon_code = f"{wafer_code}-C{c:02d}"
rel_dir = f"{wafer_dir}/coupon_C{c:02d}"
au_nominal = au_center + ag["wafer_span_wtpct"] * (col - 4) / 3.0
au_mean = self.gen_xrf(rel_dir, b_idx, w, c, au_nominal)
thickness = self.gen_profilometry(rel_dir, b_idx, w, c, row, col)
self.gen_nanoindentation(rel_dir, b_idx, w, c, au_mean)
ra = self.gen_afm(rel_dir, b_idx, w, c, coupon_code)
if c in reserve:
assignment = "RESERVE,,,"
else:
plate = friction_idx // 24 + 1
probe = (friction_idx % 24) // 4 + 1
square = friction_idx % 4 + 1
friction_idx += 1
assignment = f"{run['run_code']},{plate},{probe},{square}"
self.write_csv(
f"{rel_dir}/coupon_info.csv",
"coupon_code,batch_code,wafer_code,grid_row,grid_col,thickness_um,ra_nm,au_wtpct_mean,run_code,plate,probe,square",
[
f"{coupon_code},{b_code},{wafer_code},{row},{col},{FMT_MISC % thickness},"
f"{FMT_MISC % ra},{FMT_MISC % au_mean},{assignment}"
],
)
self.counts["coupons"] += 1
if c not in reserve:
plate, probe, square = (int(x) for x in assignment.split(",")[1:])
for t in range(1, fa["tracks_per_friction_coupon"] + 1):
self.gen_track(
f"{rel_dir}/track_T{t}", b_idx, w, c, t,
f"{coupon_code}-T{t}", run, au_mean, plate, probe, square,
)
logger.debug("wafer %s done", wafer_code)
logger.info(
"batch %s done: %d files, %.1f MiB cumulative",
b_code, len(self.manifest), self.total_bytes / (1024 * 1024),
)
def validate_counts(self) -> None:
v = self.cfg["volumes"]
expected = {
"coupons": v["coupons_total"],
"tracks": v["tracks_total"],
"cycle_rows": v["cycle_rows_total"],
"loop_rows": v["loop_points_total"],
"xrf_rows": v["xrf_points_total"],
}
for key, exp in expected.items():
if self.counts[key] != exp:
raise ValidationError(f"count mismatch: {key}: generated {self.counts[key]} != expected {exp}")
logger.info("all entity counts match lab_config volumes: %s", self.counts)
def write_manifest(self) -> int:
lines = [f"{p},{r},{b},{s}" for p, r, b, s in self.manifest]
text = "path,rows,bytes,sha256\n" + "\n".join(lines) + "\n"
data = text.encode("ascii")
(self.csv_root / "MANIFEST.csv").write_bytes(data)
return len(data)
def main() -> int:
args = parse_task_args("Task 03: generate the CSV corpus")
out_root: Path = args.out_root
csv_root = out_root / "csv"
try:
check_dependencies(out_root, DEPENDS_ON)
remove_stale_marker(out_root, TASK_ID)
if csv_root.exists():
logger.info("re-run: removing previous corpus %s", csv_root)
shutil.rmtree(csv_root)
cfg = load_lab_config(out_root)
gen = CorpusGenerator(cfg, csv_root)
gen.generate()
gen.validate_counts()
manifest_bytes = gen.write_manifest()
tree_bytes = gen.total_bytes + manifest_bytes
tree_mib = tree_bytes / (1024 * 1024)
print(f"generated tree size: {tree_mib:.1f} MiB ({tree_bytes} bytes, {len(gen.manifest) + 1} files)")
if not SIZE_GATE_MIB[0] <= tree_mib <= SIZE_GATE_MIB[1]:
raise ValidationError(
f"tree size {tree_mib:.1f} MiB outside gate {SIZE_GATE_MIB[0]}-{SIZE_GATE_MIB[1]} MiB"
)
marker_path = write_marker(out_root, TASK_ID, {
"csv_root": gen.csv_root.as_posix(),
"files": len(gen.manifest),
"total_rows": gen.total_rows,
"total_bytes": tree_bytes,
"coupons": gen.counts["coupons"],
"tracks": gen.counts["tracks"],
"cycle_rows": gen.counts["cycle_rows"],
"loop_points": gen.counts["loop_rows"],
"xrf_points": gen.counts["xrf_rows"],
})
except Exception:
logger.critical("task %s failed", TASK_ID, exc_info=True)
return 1
c = gen.counts
print(
f"rows: total={gen.total_rows} cycles={c['cycle_rows']} loops={c['loop_rows']} "
f"xrf={c['xrf_rows']} coupons={c['coupons']} tracks={c['tracks']}"
)
print(f"manifest: {csv_root / 'MANIFEST.csv'} ({len(gen.manifest)} entries)")
print(f"marker: {marker_path}")
return 0
if __name__ == "__main__":
sys.exit(main())