diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3ec7d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,220 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +*.lcov +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi/* +!.pixi/config.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule* +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ +# Temporary file for partial code execution +tempCodeRunnerFile.py + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/EXPERIMENTS.md b/EXPERIMENTS.md new file mode 100644 index 0000000..f030f6d --- /dev/null +++ b/EXPERIMENTS.md @@ -0,0 +1,90 @@ +# Experiments +A running log of every validation run, benchmark, and cross-domain test for CNSD. + +**Discipline for this file** (so it stays a record, not a trophy case): +- Every entry is tied to a **commit** and a **fixed seed**. The commit's git timestamp is the authoritative date — no manually entered dates. +- The **run record** (command, commit, environment, config, sample counts) is **auto-generated by the run script** and pasted in verbatim — not transcribed by hand. +- The experiment and its purpose are stated *before* the result. +- Null, weak, and unflattering results (abstention rates, accuracy drops, limitations) are recorded alongside the headline numbers. +- Each entry carries a status: `planned` · `running` · `preliminary` · `validated` · `superseded`. + +**How to reproduce any entry**: check out the commit in its run record, prepare the dataset as described in `data/README` (layout + expected sample count / checksum), install the pinned environment (`requirements.txt`), and run the exact command shown in the run record. Numbers should match within run-to-run noise (seeds are fixed; minor GPU nondeterminism is expected). + +--- + +## Index + +| # | Experiment | Domain | Status | +|---|------------|--------|--------| +| 1 | CWRU baseline (Protocol B) | Bearing (CWRU) | preliminary | +| 2 | Threshold sweep | Bearing (CWRU) | preliminary | +| 3 | Cross-condition robustness (AWGN) | Bearing (CWRU) | preliminary | +| 4 | Multi-seed headline | Bearing (CWRU) | planned | +| 5 | Cross-domain: SEU gearbox | Gear (SEU) | preliminary (failed) | + +--- + +## 1. CWRU baseline — Protocol B (cross-load) + +* **Status:** preliminary +* **Purpose:** confirm the full five-layer pipeline runs end-to-end on real CWRU and establish the baseline diagnosis result. +* **Setup:** train loads 0–2, test load 3. All 10 classes. 12 kHz, window 1024. + +**Run record** +```text +commit: cd7771ab3668caf9b33109c3a0a9d89f24fd111c +command: python validate_run.py --seed 42 +data: 5806 train / 2019 test samples +``` + +**Layer-2 physics verification rate** +| Verdict | Rate | +|---------|------| +| CONFIRMED | TBD | +| CONFLICT | TBD | +| INCONCLUSIVE | TBD | + +**Headline — CNN accuracy by physics verdict** +| Verdict | n | CNN accuracy | +|---------|---|--------------| +| CONFIRMED | TBD | TBD | +| CONFLICT | TBD | TBD | +| INCONCLUSIVE | TBD | TBD | +| **Gap (CONFIRMED - CONFLICT)** | | **TBD** | + +* **Causal (Layer 3)** — `do(Z)`: rung *TBD*, max_contrast *TBD*, p *TBD* +* **Counterfactual (Layer 3B)**: DoWhy available *TBD*; method *TBD* +* **Notes / limitations**: record the INCONCLUSIVE rate and any seed drift. + +--- + +## 5. Cross-domain — SEU gearbox (GearProvider) + +* **Status:** preliminary (failed validation) +* **Purpose:** demonstrate the framework is genuinely domain-agnostic — same engine, different machine class, only the provider changes. +* **Setup:** full pipeline on SEU gearset using `GearProvider` (gear-mesh physics). `N_TEETH_INPUT` confirmed against rig spec; fixed channel chosen up front; threshold tuned on a held-out split. + +**Run record** +```text +commit: +command: python validate_seu.py +data: 5115 train / 5115 test samples +n_teeth_input: 20 | channel: 2 (planetary x-axis) +``` + +**Layer-2 physics verification rate** +| Verdict | Rate | +|---------|------| +| CONFIRMED | 0.2% | +| CONFLICT | 0.3% | +| INCONCLUSIVE | 99.5% | + +**Headline — CNN accuracy by physics verdict** +| Verdict | n | CNN accuracy | +|---------|---|--------------| +| CONFIRMED | 9 | 0.333 | +| CONFLICT | 17 | 0.824 | +| INCONCLUSIVE | 5089 | 0.514 | +| **Gap (CONFIRMED - CONFLICT)** | | **-0.491 (FAILED)** | + +* **Known caveats to report honestly:** The accuracy gap is currently backwards and practically noise due to a 99.5% inconclusive rate. This is pending a strict `tau` threshold calibration sweep for gear physics, as well as confirming that GMF strength aligns with the same numerical scale as bearing physics. diff --git a/cnsd/builder.py b/cnsd/builder.py index 598476c..93fedcc 100644 --- a/cnsd/builder.py +++ b/cnsd/builder.py @@ -93,6 +93,15 @@ def build_provider(config): cond_to_rpm = _coerce_int_keys(params.get('motor_load_rpm', {0: 1797})) return provider_cls(bearing=geometry, cond_to_rpm=cond_to_rpm, fs=fs) + if domain_type == 'gear': + cond_to_rpm = _coerce_int_keys(params.get('motor_load_rpm', {0: 1800})) + return provider_cls( + n_teeth_input=params['n_teeth_input'], + n_teeth_output=params.get('n_teeth_output'), + cond_to_rpm=cond_to_rpm, + fs=fs, + ) + # other registered domains: pass parameters through + fs try: return provider_cls(fs=fs, **params) diff --git a/cnsd/physics/configs.py b/cnsd/physics/configs.py index 1d74ab7..f9e561b 100644 --- a/cnsd/physics/configs.py +++ b/cnsd/physics/configs.py @@ -5,9 +5,9 @@ @dataclass class PhysicsConfig: - bearing: dict[str, float] cond_to_rpm: dict fs: int + bearing: dict[str, float] | None = None name: str = 'custom' @@ -25,4 +25,9 @@ class PhysicsConfig: name='JNU-N205', ) -SEU_PHYSICS = None +SEU_PHYSICS = PhysicsConfig( + bearing=None, + cond_to_rpm={0: 1200, 1: 1800}, + fs=20000, + name='SEU-Gearbox', +) diff --git a/cnsd/physics/gear.py b/cnsd/physics/gear.py new file mode 100644 index 0000000..c99f016 --- /dev/null +++ b/cnsd/physics/gear.py @@ -0,0 +1,88 @@ +"""Gear-mesh physics for gearbox fault verification. + +A gear fault does not produce ball-pass frequencies. The dominant signature is +the gear-mesh frequency (GMF) and its harmonics, with sidebands spaced at the +shaft rotation rate. Different fault types modulate this differently: + + - a localized tooth fault (chip / root crack / missing tooth) produces strong + sidebands around the GMF at the shaft rate of the faulty gear (the defect is + excited once per revolution), + - distributed surface wear raises the GMF harmonics without strong shaft-rate + sidebands. + +This module computes GMF + sidebands and measures their prominence in the +envelope spectrum, mirroring the bearing module's role for rolling elements. +""" + +import numpy as np +from scipy.signal import hilbert + +DEFAULT_FS = 5120 + + +def gear_mesh_frequencies(rpm, n_teeth_input, n_teeth_output=None): + """Gear-mesh frequency and shaft rates (Hz) from teeth counts + input speed. + + GMF = input_shaft_rate * n_teeth_input (the mesh rate is shared by both gears; + teeth_in * f_in == teeth_out * f_out). Returns the GMF, the input shaft rate, + and (if output teeth given) the output shaft rate - the sideband spacings. + """ + f_in = rpm / 60.0 + gmf = f_in * n_teeth_input + out = {'GMF': gmf, 'shaft_input': f_in} + if n_teeth_output: + out['shaft_output'] = gmf / n_teeth_output + return out + + +def envelope_spectrum(signal, fs=DEFAULT_FS): + """Amplitude-envelope spectrum (Hilbert -> |.| -> remove DC -> rFFT).""" + x = np.asarray(signal, float).flatten() + env = np.abs(hilbert(x)) + env = env - env.mean() + mag = np.abs(np.fft.rfft(env)) + freqs = np.fft.rfftfreq(len(env), d=1.0 / fs) + return freqs, mag + + +def _prominence(freqs, mag, f0, rel_tol=0.04): + if f0 <= 0: + return 0.0 + tol = f0 * rel_tol + band = (freqs >= f0 - tol) & (freqs <= f0 + tol) + if not band.any(): + return 0.0 + peak = mag[band].max() + nb = (freqs >= f0 - 6 * tol) & (freqs <= f0 + 6 * tol) & ~band + baseline = np.median(mag[nb]) if nb.any() else np.median(mag) + return float(peak / (baseline + 1e-12)) + + +def sideband_strength(freqs, mag, gmf, shaft_rate, n_sidebands=2): + """Mean prominence of the shaft-rate sidebands around the GMF - the marker of + a localized tooth fault.""" + vals = [] + for k in range(1, n_sidebands + 1): + for f0 in (gmf - k * shaft_rate, gmf + k * shaft_rate): + vals.append(_prominence(freqs, mag, f0)) + return float(np.mean(vals)) if vals else 0.0 + + +def gear_fault_evidence(signal, rpm, n_teeth_input, n_teeth_output=None, fs=DEFAULT_FS): + """Physical evidence for gear-fault families in one window. + + Returns prominence of: + 'mesh' : GMF + harmonics (raised by distributed wear) + 'sideband' : shaft-rate sidebands around GMF (raised by localized faults) + plus the characteristic frequencies in Hz. + """ + freqs, mag = envelope_spectrum(signal, fs) + gf = gear_mesh_frequencies(rpm, n_teeth_input, n_teeth_output) + gmf, shaft = gf['GMF'], gf['shaft_input'] + mesh = np.mean([_prominence(freqs, mag, gmf * h) for h in (1, 2)]) + side = sideband_strength(freqs, mag, gmf, shaft) + return { + 'mesh_strength': float(mesh), + 'sideband_strength': float(side), + 'freqs_hz': {k: float(v) for k, v in gf.items()}, + } diff --git a/cnsd/physics/providers/__init__.py b/cnsd/physics/providers/__init__.py index f438d7b..70a8f4a 100644 --- a/cnsd/physics/providers/__init__.py +++ b/cnsd/physics/providers/__init__.py @@ -7,12 +7,14 @@ from cnsd.physics.providers.base import PhysicsProvider from cnsd.physics.providers.bearing import BearingProvider +from cnsd.physics.providers.gear import GearProvider from cnsd.physics.providers.spectral import SpectralProvider # domain.type (from config) -> provider builder. 'spectral' is the universal # zero-knowledge fallback used when a domain has no dedicated provider. _REGISTRY = { 'bearing': BearingProvider, + 'gear': GearProvider, 'spectral': SpectralProvider, None: SpectralProvider, } diff --git a/cnsd/physics/providers/gear.py b/cnsd/physics/providers/gear.py new file mode 100644 index 0000000..9b16f58 --- /dev/null +++ b/cnsd/physics/providers/gear.py @@ -0,0 +1,96 @@ +"""Gear physics provider: gearbox fault verification via gear-mesh signatures. + +Implements the PhysicsProvider interface for gearboxes. Verifies a predicted gear +fault against the gear-mesh frequency (GMF) and its shaft-rate sidebands: +localized tooth faults (chip, root crack, missing tooth) raise the sidebands; +distributed surface wear raises the mesh harmonics. The SEU gearbox taxonomy +(Health, Chipped, Miss, Root, Surface) maps onto these two evidence channels. +""" + +from cnsd.physics.gear import gear_fault_evidence +from cnsd.physics.providers.base import PhysicsProvider + +# SEU gearbox fault families -> which evidence channel signifies them. +# 'localized' faults show shaft-rate sidebands; 'distributed' wear shows mesh. +_FAMILY_CHANNEL = { + 'Chipped Tooth': 'sideband', + 'Missing Tooth': 'sideband', + 'Root Crack': 'sideband', + 'Surface Wear': 'mesh', +} + +_ROOT_CAUSE = { + 'Chipped Tooth': ( + 'gear tooth', + 'GMF sidebands', + 'a chipped tooth striking once per revolution, producing ' + 'shaft-rate sidebands around the gear-mesh frequency', + ), + 'Missing Tooth': ( + 'gear tooth', + 'GMF sidebands', + 'a missing tooth producing a strong once-per-revolution impact and shaft-rate sidebands', + ), + 'Root Crack': ( + 'gear tooth root', + 'GMF sidebands', + 'a root crack reducing tooth stiffness, modulating the mesh at the shaft rate', + ), + 'Surface Wear': ( + 'gear flank', + 'GMF harmonics', + 'distributed surface wear raising the gear-mesh harmonics', + ), + 'Health': (None, None, 'no gear fault; mesh spectrum is clean'), +} + + +class GearProvider(PhysicsProvider): + families = ['Chipped Tooth', 'Missing Tooth', 'Root Crack', 'Surface Wear'] + + def __init__(self, n_teeth_input, n_teeth_output=None, cond_to_rpm=None, fs=5120): + self.n_teeth_input = n_teeth_input + self.n_teeth_output = n_teeth_output + self.cond_to_rpm = cond_to_rpm or {0: 1800} + self.fs = fs + + def evidence(self, signal, condition): + rpm = self.cond_to_rpm.get(int(condition), next(iter(self.cond_to_rpm.values()), 1800)) + ev = gear_fault_evidence(signal, rpm, self.n_teeth_input, self.n_teeth_output, fs=self.fs) + # localized fault families share the sideband channel; surface wear the mesh + side, mesh = ev['sideband_strength'], ev['mesh_strength'] + return { + 'family_strength': { + 'Chipped Tooth': side, + 'Missing Tooth': side, + 'Root Crack': side, + 'Surface Wear': mesh, + }, + 'frequencies_hz': ev['freqs_hz'], + '_channels': {'sideband': side, 'mesh': mesh}, + } + + def root_cause(self, family, evidence): + comp, freq_name, mechanism = _ROOT_CAUSE.get(family, (None, None, '')) + channel = _FAMILY_CHANNEL.get(family) + strength = evidence.get('_channels', {}).get(channel, 0.0) if channel else 0.0 + gmf = evidence.get('frequencies_hz', {}).get('GMF') + if family in ('Health', None): + statement = 'No gear fault detected; the mesh spectrum is clean.' + elif gmf is not None: + statement = ( + f'Defect on the {comp} ({family}), evidenced by ' + f'{freq_name} around GMF={gmf:.1f} Hz ' + f'(strength {strength:.1f}). {mechanism}.' + ) + else: + statement = f'{family} on the {comp}.' + return { + 'component': comp, + 'fault_type': family, + 'mechanism': mechanism, + 'evidence_frequency': freq_name, + 'evidence_frequency_hz': gmf, + 'evidence_strength': float(strength), + 'statement': statement, + } diff --git a/test/test_layers.py b/test/test_layers.py index a03596a..9bb38e4 100644 --- a/test/test_layers.py +++ b/test/test_layers.py @@ -72,3 +72,34 @@ def test_causal_doZ_is_rung2(): def test_consensus_conflict_forces_review(): assert fuse('CONFLICT', 0.99) == 'MANUAL_REVIEW' assert fuse('CONFIRMED', 0.99) == 'HIGH_CONFIDENCE' + + +# ── gear domain (cross-domain universality) ────────────────────────────────── + + +def test_gear_mesh_frequency_math(): + from cnsd.physics.gear import gear_mesh_frequencies + + gf = gear_mesh_frequencies(1800, 20) # 20 teeth at 1800 rpm (30 Hz) + assert abs(gf['GMF'] - 600.0) < 0.1 + assert abs(gf['shaft_input'] - 30.0) < 0.1 + + +def test_gear_provider_implements_interface(): + from cnsd.physics.providers.base import PhysicsProvider + from cnsd.physics.providers.gear import GearProvider + + p = GearProvider(n_teeth_input=20, cond_to_rpm={0: 1800}, fs=5120) + assert isinstance(p, PhysicsProvider) + sig = np.sin(2 * np.pi * 600 * np.arange(2048) / 5120) + 0.1 * np.random.randn(2048) + ev = p.evidence(sig, 0) + assert 'family_strength' in ev and 'frequencies_hz' in ev + assert p.dominant_family(ev) in p.families + + +def test_gear_registered(): + from cnsd.physics.providers import available_domains, get_provider + from cnsd.physics.providers.gear import GearProvider + + assert get_provider('gear') is GearProvider + assert 'gear' in available_domains() diff --git a/validate_seu.py b/validate_seu.py new file mode 100644 index 0000000..2d66844 --- /dev/null +++ b/validate_seu.py @@ -0,0 +1,188 @@ +""" +validate_seu.py - cross-domain validation: run the full CNSD pipeline on the SEU +gearbox dataset (a NON-bearing domain) using the GearProvider. + +This is the universality test: the same five-layer pipeline, the same engine, a +different machine class - only the physics provider changes. If CNSD diagnoses +gear faults via gear-mesh physics, that demonstrates the provider interface is +genuinely domain-agnostic, not bearing-bound. + +Run after the bearing baseline. Wire your SEU loader into load_seu() below - it +is the only dataset-specific code. SEU gearset data: tab-separated, 8 channels, +header ends at the line 'Data' (~line 16), 5 classes, 2 operating conditions. +""" + +import glob +import os + +import numpy as np + +from cnsd import Dataset +from cnsd.diagnosis.system import CNSD +from cnsd.physics.configs import PhysicsConfig +from cnsd.physics.providers.gear import GearProvider + +# ── SEU gearbox parameters ─────────────────────────────────────────────────── +# Replace N_TEETH_INPUT with the actual driving-gear tooth count of the SEU rig. +# The SEU bench uses a planetary + parallel gearbox; use the parallel-stage +# driving gear tooth count for the gearset experiments. Confirm from the dataset +# documentation and set it here. +N_TEETH_INPUT = 20 # <-- CONFIRM against SEU rig spec +SEU_FS = 20000 # sampling rate (Hz) - confirm against your files +SEU_COND_TO_RPM = {0: 1800, 1: 1800} # SEU '20_0' and '30_2' conditions -> rpm + +# SEU 5-class taxonomy (gearset). Map your integer labels to these families. +SEU_TAXONOMY = { + 0: ('Health', 'None'), + 1: ('Chipped Tooth', 'Medium'), + 2: ('Missing Tooth', 'High'), + 3: ('Root Crack', 'Medium'), + 4: ('Surface Wear', 'Medium'), +} + + +def load_seu(): + """Return (X, y, cond) for the SEU gearset. Replace the body with your loader. + + X : (n, 1024) float per-window normalized vibration windows + y : (n,) int 0=Health, 1=Chipped, 2=Miss, 3=Root, 4=Surface + cond : (n,) int operating condition (0='20_0', 1='30_2') + + SEU gearset files are tab-separated with 8 channels; the header ends at a line + containing 'Data' (~line 16), data starts the next line. Pick one channel + (pre-commit to it before seeing results - no channel cherry-picking). + """ + base_dir = r'E:\301\SEU-dataset\gearbox\gearset' + + label_map = {'Health': 0, 'Chipped': 1, 'Miss': 2, 'Root': 3, 'Surface': 4} + X_list, y_list, cond_list = [], [], [] + + for filepath in glob.glob(os.path.join(base_dir, '**', '*.csv'), recursive=True): + filename = os.path.basename(filepath) + + cond_idx = 0 if '20_0' in filename else (1 if '30_2' in filename else None) + if cond_idx is None: + continue + + label_idx = next((v for k, v in label_map.items() if filename.startswith(k)), None) + if label_idx is None: + continue + + with open(filepath, encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + + data_start = next((i + 1 for i, line in enumerate(lines) if 'Data' in line), 0) + + channel_data = [] + for line in lines[data_start:]: + parts = line.strip().split('\t') + if len(parts) >= 8: + try: + # Picking channel 2 (index 1: planetary gearbox vibration x) + channel_data.append(float(parts[1])) + except ValueError: + pass + + channel_data = np.array(channel_data, dtype=np.float32) + n_windows = len(channel_data) // 1024 + if n_windows == 0: + continue + + windows = channel_data[: n_windows * 1024].reshape((n_windows, 1024)) + + means = windows.mean(axis=1, keepdims=True) + stds = windows.std(axis=1, keepdims=True) + stds[stds == 0] = 1.0 + windows = (windows - means) / stds + + X_list.append(windows) + y_list.extend([label_idx] * n_windows) + cond_list.extend([cond_idx] * n_windows) + + if not X_list: + raise ValueError(f'No SEU dataset files found or parsed successfully in {base_dir}') + + return np.vstack(X_list), np.array(y_list), np.array(cond_list) + + +def headline_by_verdict(report, y_true): + pred = np.array([r['predicted_class'] for r in report.records]) + correct = pred == np.asarray(y_true) + verd = np.array([r['physics_verdict'] for r in report.records]) + out = {} + for v in ('CONFIRMED', 'CONFLICT', 'INCONCLUSIVE'): + m = verd == v + if m.any(): + out[v] = {'n': int(m.sum()), 'acc': float(correct[m].mean())} + return out + + +def main(): + print('=' * 68) + print('CNSD CROSS-DOMAIN VALIDATION - SEU GEARBOX (GearProvider)') + print('=' * 68) + + X, y, cond = load_seu() + X = np.asarray(X, np.float32) + y = np.asarray(y) + cond = np.asarray(cond) + + # gear physics config: the provider is built directly (no bearing geometry) + physics = PhysicsConfig( + bearing=None, cond_to_rpm=SEU_COND_TO_RPM, fs=SEU_FS, name='SEU-gearset' + ) + # train/test split by condition (cross-condition within the gear domain) + te = cond == 1 + tr = ~te + train = Dataset.from_arrays( + X[tr], y[tr], cond[tr], fs=SEU_FS, physics=physics, taxonomy=SEU_TAXONOMY, name='SEU_Train' + ) + test = Dataset.from_arrays( + X[te], y[te], cond[te], fs=SEU_FS, physics=physics, taxonomy=SEU_TAXONOMY, name='SEU_Test' + ) + print( + f'[data] train {len(train.X)} test {len(test.X)} classes={sorted(np.unique(y).tolist())}' + ) + + # CNSD with the gear provider explicitly (config path also works via + # domain.type: gear once you add a YAML) + model = CNSD() + model.fit(train, epochs=30) + # override the symbolic layer with the gear provider for this domain + model.symbolic.provider = GearProvider( + n_teeth_input=N_TEETH_INPUT, cond_to_rpm=SEU_COND_TO_RPM, fs=SEU_FS + ) + model.symbolic.taxonomy = SEU_TAXONOMY + + report = model.diagnose(test) + print(f'\n[pipeline] {report.summary()}') + + vr = report.verification_rate() + print('\n[Layer 2] gear-physics verification rate:') + for k, v in vr.items(): + print(f' {k:13}: {v:.1%}') + + hb = headline_by_verdict(report, test.y) + print('\n[HEADLINE] CNN accuracy by gear-physics verdict:') + for v, d in hb.items(): + print(f' {v:13}: acc={d["acc"]:.3f} (n={d["n"]})') + if 'CONFIRMED' in hb and 'CONFLICT' in hb: + gap = hb['CONFIRMED']['acc'] - hb['CONFLICT']['acc'] + print(f' -> CONFIRMED minus CONFLICT gap: {gap:+.3f}') + + print('\n[examples] gear root-cause diagnoses (CONFIRMED faults):') + seen = set() + for i, r in enumerate(report.records): + if r['physics_verdict'] == 'CONFIRMED' and test.y[i] > 0 and test.y[i] not in seen: + print(f' [Class {test.y[i]}] {r["root_cause"]["statement"]}') + seen.add(test.y[i]) + if len(seen) >= 4: + break + + print('\n' + '=' * 68) + print('CROSS-DOMAIN VALIDATION COMPLETE') + print('=' * 68) + + +if __name__ == '__main__': + main()