Skip to content

Commit a9a2859

Browse files
committed
feat: added gear-mesh physics + GearProvider (cross-domain: SEU gearbox), engine left unchanged.
1 parent 0b95682 commit a9a2859

8 files changed

Lines changed: 451 additions & 2 deletions

File tree

HANDOFF_TASFIN.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# SEU / Gearbox cross-domain validation - handoff
2+
3+
Goal: run the full CNSD pipeline on SEU gears (a non-bearing domain) to
4+
demonstrate the provider interface is genuinely domain-agnostic. This is the
5+
universality result.
6+
7+
## What's included (built + tested)
8+
9+
- `cnsd/physics/gear.py` - gear-mesh physics: GMF (= teeth x shaft-rate),
10+
shaft-rate sidebands, envelope-spectrum prominence. GMF math verified
11+
(20 teeth @ 1800 rpm -> 600 Hz).
12+
- `cnsd/physics/providers/gear.py` - GearProvider, implements the same
13+
PhysicsProvider interface as BearingProvider. Maps the SEU 5-class taxonomy
14+
(Health/Chipped/Miss/Root/Surface) onto two evidence channels: localized tooth
15+
faults -> GMF sidebands; surface wear -> GMF harmonics.
16+
- `cnsd/physics/providers/__init__.py` - registers domain 'gear'.
17+
- `cnsd/builder.py` - config path: `domain.type: gear` builds the GearProvider
18+
from `physics.parameters.n_teeth_input` (+ optional n_teeth_output).
19+
- `cnsd/physics/configs.py` - PhysicsConfig.bearing is now Optional (gear configs
20+
have no bearing geometry).
21+
- `validate_seu.py` - the run script. Only `load_seu()` is dataset-specific.
22+
- 3 new tests (gear math, interface conformance, registry) - 10/10 pass.
23+
24+
## Your job
25+
26+
1. Wire `load_seu()` in validate_seu.py - return X (n,1024), y (n,), cond (n,).
27+
SEU gearset: tab-separated, 8 channels, header ends at the 'Data' line; pick
28+
ONE channel and commit to it before seeing results (no channel cherry-picking).
29+
2. Set `N_TEETH_INPUT` to the real SEU driving-gear tooth count (I used 20 as a
30+
placeholder - CONFIRM from the rig spec; the GMF is wrong if this is wrong).
31+
3. Confirm `SEU_FS` (sampling rate) and the condition->rpm map.
32+
4. Run `python validate_seu.py`, send me the output.
33+
34+
## Honest caveats (expect these, they are not bugs)
35+
36+
- The prominence threshold (3.0, same as bearings) will likely need tuning on
37+
real SEU signals - same story as the CWRU sweep. Expect a high INCONCLUSIVE
38+
rate at first; sweep tau on a held-out calibration split (NOT the test set, NOT
39+
the training set where the CNN memorizes - the lesson from #11).
40+
- The Chipped/Miss/Root families all share the sideband channel, so the physics
41+
cannot distinguish *between* them (they look alike in the envelope spectrum -
42+
this is physically true, localized tooth faults all produce shaft-rate
43+
sidebands). The provider confirms "localized tooth fault" vs "surface wear" vs
44+
"health"; finer separation is the CNN's job. Report this honestly - it's a real
45+
property of gear physics, not a limitation to hide.
46+
- N_TEETH_INPUT is the single most important parameter. If the GMF is off, every
47+
verdict is meaningless. Triple-check it.
48+
49+
## How it plugs in (no engine change)
50+
51+
The engine never sees gear-specific code - it calls the PhysicsProvider
52+
interface. Same five layers, same diagnose() path, only the provider differs.
53+
That's the universality claim, demonstrated.

cnsd/builder.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ def build_provider(config):
8484
cond_to_rpm = _coerce_int_keys(params.get('motor_load_rpm', {0: 1797}))
8585
return provider_cls(bearing=geometry, cond_to_rpm=cond_to_rpm, fs=fs)
8686

87+
if domain_type == 'gear':
88+
cond_to_rpm = _coerce_int_keys(params.get('motor_load_rpm', {0: 1800}))
89+
return provider_cls(n_teeth_input=params['n_teeth_input'],
90+
n_teeth_output=params.get('n_teeth_output'),
91+
cond_to_rpm=cond_to_rpm, fs=fs)
92+
8793
# other registered domains: pass parameters through + fs
8894
try:
8995
return provider_cls(fs=fs, **params)

cnsd/physics/configs.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
"""Physics configs: bearing geometry + condition->rpm + sampling rate per dataset."""
22
from dataclasses import dataclass
3-
from typing import Dict
3+
from typing import Dict, Optional
44

55

66
@dataclass
77
class PhysicsConfig:
8-
bearing: Dict[str, float]
98
cond_to_rpm: Dict
109
fs: int
10+
bearing: Optional[Dict[str, float]] = None
1111
name: str = 'custom'
1212

1313

cnsd/physics/gear.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Gear-mesh physics for gearbox fault verification.
2+
3+
A gear fault does not produce ball-pass frequencies. The dominant signature is
4+
the gear-mesh frequency (GMF) and its harmonics, with sidebands spaced at the
5+
shaft rotation rate. Different fault types modulate this differently:
6+
7+
- a localized tooth fault (chip / root crack / missing tooth) produces strong
8+
sidebands around the GMF at the shaft rate of the faulty gear (the defect is
9+
excited once per revolution),
10+
- distributed surface wear raises the GMF harmonics without strong shaft-rate
11+
sidebands.
12+
13+
This module computes GMF + sidebands and measures their prominence in the
14+
envelope spectrum, mirroring the bearing module's role for rolling elements.
15+
"""
16+
import numpy as np
17+
from scipy.signal import hilbert
18+
19+
DEFAULT_FS = 5120
20+
21+
22+
def gear_mesh_frequencies(rpm, n_teeth_input, n_teeth_output=None):
23+
"""Gear-mesh frequency and shaft rates (Hz) from teeth counts + input speed.
24+
25+
GMF = input_shaft_rate * n_teeth_input (the mesh rate is shared by both gears;
26+
teeth_in * f_in == teeth_out * f_out). Returns the GMF, the input shaft rate,
27+
and (if output teeth given) the output shaft rate - the sideband spacings.
28+
"""
29+
f_in = rpm / 60.0
30+
gmf = f_in * n_teeth_input
31+
out = {'GMF': gmf, 'shaft_input': f_in}
32+
if n_teeth_output:
33+
out['shaft_output'] = gmf / n_teeth_output
34+
return out
35+
36+
37+
def envelope_spectrum(signal, fs=DEFAULT_FS):
38+
"""Amplitude-envelope spectrum (Hilbert -> |.| -> remove DC -> rFFT)."""
39+
x = np.asarray(signal, float).flatten()
40+
env = np.abs(hilbert(x))
41+
env = env - env.mean()
42+
mag = np.abs(np.fft.rfft(env))
43+
freqs = np.fft.rfftfreq(len(env), d=1.0 / fs)
44+
return freqs, mag
45+
46+
47+
def _prominence(freqs, mag, f0, rel_tol=0.04):
48+
if f0 <= 0:
49+
return 0.0
50+
tol = f0 * rel_tol
51+
band = (freqs >= f0 - tol) & (freqs <= f0 + tol)
52+
if not band.any():
53+
return 0.0
54+
peak = mag[band].max()
55+
nb = (freqs >= f0 - 6 * tol) & (freqs <= f0 + 6 * tol) & ~band
56+
baseline = np.median(mag[nb]) if nb.any() else np.median(mag)
57+
return float(peak / (baseline + 1e-12))
58+
59+
60+
def sideband_strength(freqs, mag, gmf, shaft_rate, n_sidebands=2):
61+
"""Mean prominence of the shaft-rate sidebands around the GMF - the marker of
62+
a localized tooth fault."""
63+
vals = []
64+
for k in range(1, n_sidebands + 1):
65+
for f0 in (gmf - k * shaft_rate, gmf + k * shaft_rate):
66+
vals.append(_prominence(freqs, mag, f0))
67+
return float(np.mean(vals)) if vals else 0.0
68+
69+
70+
def gear_fault_evidence(signal, rpm, n_teeth_input, n_teeth_output=None,
71+
fs=DEFAULT_FS):
72+
"""Physical evidence for gear-fault families in one window.
73+
74+
Returns prominence of:
75+
'mesh' : GMF + harmonics (raised by distributed wear)
76+
'sideband' : shaft-rate sidebands around GMF (raised by localized faults)
77+
plus the characteristic frequencies in Hz.
78+
"""
79+
freqs, mag = envelope_spectrum(signal, fs)
80+
gf = gear_mesh_frequencies(rpm, n_teeth_input, n_teeth_output)
81+
gmf, shaft = gf['GMF'], gf['shaft_input']
82+
mesh = np.mean([_prominence(freqs, mag, gmf * h) for h in (1, 2)])
83+
side = sideband_strength(freqs, mag, gmf, shaft)
84+
return {
85+
'mesh_strength': float(mesh),
86+
'sideband_strength': float(side),
87+
'freqs_hz': {k: float(v) for k, v in gf.items()},
88+
}

cnsd/physics/providers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
from cnsd.physics.providers.base import PhysicsProvider
88
from cnsd.physics.providers.bearing import BearingProvider
99
from cnsd.physics.providers.spectral import SpectralProvider
10+
from cnsd.physics.providers.gear import GearProvider
1011

1112
# domain.type (from config) -> provider builder. 'spectral' is the universal
1213
# zero-knowledge fallback used when a domain has no dedicated provider.
1314
_REGISTRY = {
1415
'bearing': BearingProvider,
16+
'gear': GearProvider,
1517
'spectral': SpectralProvider,
1618
None: SpectralProvider,
1719
}

cnsd/physics/providers/gear.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Gear physics provider: gearbox fault verification via gear-mesh signatures.
2+
3+
Implements the PhysicsProvider interface for gearboxes. Verifies a predicted gear
4+
fault against the gear-mesh frequency (GMF) and its shaft-rate sidebands:
5+
localized tooth faults (chip, root crack, missing tooth) raise the sidebands;
6+
distributed surface wear raises the mesh harmonics. The SEU gearbox taxonomy
7+
(Health, Chipped, Miss, Root, Surface) maps onto these two evidence channels.
8+
"""
9+
from cnsd.physics.providers.base import PhysicsProvider
10+
from cnsd.physics.gear import gear_fault_evidence
11+
12+
# SEU gearbox fault families -> which evidence channel signifies them.
13+
# 'localized' faults show shaft-rate sidebands; 'distributed' wear shows mesh.
14+
_FAMILY_CHANNEL = {
15+
'Chipped Tooth': 'sideband',
16+
'Missing Tooth': 'sideband',
17+
'Root Crack': 'sideband',
18+
'Surface Wear': 'mesh',
19+
}
20+
21+
_ROOT_CAUSE = {
22+
'Chipped Tooth': ('gear tooth', 'GMF sidebands',
23+
'a chipped tooth striking once per revolution, producing '
24+
'shaft-rate sidebands around the gear-mesh frequency'),
25+
'Missing Tooth': ('gear tooth', 'GMF sidebands',
26+
'a missing tooth producing a strong once-per-revolution '
27+
'impact and shaft-rate sidebands'),
28+
'Root Crack': ('gear tooth root', 'GMF sidebands',
29+
'a root crack reducing tooth stiffness, modulating the mesh at '
30+
'the shaft rate'),
31+
'Surface Wear': ('gear flank', 'GMF harmonics',
32+
'distributed surface wear raising the gear-mesh harmonics'),
33+
'Health': (None, None, 'no gear fault; mesh spectrum is clean'),
34+
}
35+
36+
37+
class GearProvider(PhysicsProvider):
38+
families = ['Chipped Tooth', 'Missing Tooth', 'Root Crack', 'Surface Wear']
39+
40+
def __init__(self, n_teeth_input, n_teeth_output=None, cond_to_rpm=None,
41+
fs=5120):
42+
self.n_teeth_input = n_teeth_input
43+
self.n_teeth_output = n_teeth_output
44+
self.cond_to_rpm = cond_to_rpm or {0: 1800}
45+
self.fs = fs
46+
47+
def evidence(self, signal, condition):
48+
rpm = self.cond_to_rpm.get(int(condition),
49+
next(iter(self.cond_to_rpm.values()), 1800))
50+
ev = gear_fault_evidence(signal, rpm, self.n_teeth_input,
51+
self.n_teeth_output, fs=self.fs)
52+
# localized fault families share the sideband channel; surface wear the mesh
53+
side, mesh = ev['sideband_strength'], ev['mesh_strength']
54+
return {
55+
'family_strength': {'Chipped Tooth': side, 'Missing Tooth': side,
56+
'Root Crack': side, 'Surface Wear': mesh},
57+
'frequencies_hz': ev['freqs_hz'],
58+
'_channels': {'sideband': side, 'mesh': mesh},
59+
}
60+
61+
def root_cause(self, family, evidence):
62+
comp, freq_name, mechanism = _ROOT_CAUSE.get(family, (None, None, ''))
63+
channel = _FAMILY_CHANNEL.get(family)
64+
strength = evidence.get('_channels', {}).get(channel, 0.0) if channel else 0.0
65+
gmf = evidence.get('frequencies_hz', {}).get('GMF')
66+
if family in ('Health', None):
67+
statement = 'No gear fault detected; the mesh spectrum is clean.'
68+
elif gmf is not None:
69+
statement = (f'Defect on the {comp} ({family}), evidenced by '
70+
f'{freq_name} around GMF={gmf:.1f} Hz '
71+
f'(strength {strength:.1f}). {mechanism}.')
72+
else:
73+
statement = f'{family} on the {comp}.'
74+
return {'component': comp, 'fault_type': family, 'mechanism': mechanism,
75+
'evidence_frequency': freq_name,
76+
'evidence_frequency_hz': gmf,
77+
'evidence_strength': float(strength), 'statement': statement}

test_layers.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Smoke tests for the CNSD layers (no TensorFlow / DoWhy required)."""
2+
import numpy as np
3+
from cnsd.physics import characteristic_frequencies
4+
from cnsd.physics.providers import BearingProvider, SpectralProvider, get_provider
5+
from cnsd.symbolic import PhysicsRuleEngine
6+
from cnsd.causal import intervention_effect_of_condition
7+
from cnsd.consensus import fuse
8+
9+
_BEARING = {'n_balls': 9, 'd_ball': 0.3126, 'd_pitch': 1.537, 'contact_angle': 0.0}
10+
_TAXONOMY = {0: ('Normal', 'None'), 5: ('Inner Race', 'Medium'), 8: ('Outer Race', 'Medium')}
11+
12+
13+
def _impulse(freq, fs=12000, n=1024, a=6.0):
14+
p = int(fs / freq); s = np.zeros(n)
15+
for i in range(0, n, p):
16+
d = np.exp(-(np.arange(min(p, n - i))) / 25.0); s[i:i+len(d)] += a * d
17+
return s + 0.3 * np.random.randn(n)
18+
19+
20+
def _bearing_engine():
21+
prov = BearingProvider(bearing=_BEARING, cond_to_rpm={0: 1797}, fs=12000)
22+
return PhysicsRuleEngine(provider=prov, taxonomy=_TAXONOMY)
23+
24+
25+
def test_physics_frequencies_match_published_cwru():
26+
cf = characteristic_frequencies(1797, bearing=_BEARING)
27+
assert abs(cf['BPFO'] - 107.4) < 1.0
28+
assert abs(cf['BPFI'] - 162.2) < 1.0
29+
30+
31+
def test_symbolic_confirms_matching_fault():
32+
r = _bearing_engine().diagnose(_impulse(characteristic_frequencies(1797, bearing=_BEARING)['BPFI']), 5, 0)
33+
assert r['verdict'] == 'CONFIRMED'
34+
assert r['predicted_family'] == 'Inner Race'
35+
36+
37+
def test_symbolic_conflicts_when_physics_disagrees():
38+
# inner-race signal but the classifier said outer race (class 8)
39+
r = _bearing_engine().diagnose(_impulse(characteristic_frequencies(1797, bearing=_BEARING)['BPFI']), 8, 0)
40+
assert r['verdict'] == 'CONFLICT'
41+
# root cause follows the physics, overriding the classifier
42+
assert r['root_cause']['fault_type'] == 'Inner Race'
43+
44+
45+
def test_spectral_provider_runs_without_geometry():
46+
# the universal fallback: no machine model, still flags a dominant fault freq
47+
eng = PhysicsRuleEngine(provider=SpectralProvider(fs=12000))
48+
r = eng.diagnose(_impulse(160.0), 1, 0)
49+
assert r['verdict'] in ('CONFIRMED', 'INCONCLUSIVE')
50+
assert 'root_cause' in r
51+
52+
53+
def test_get_provider_falls_back_to_spectral():
54+
assert get_provider('unknown_machine') is SpectralProvider
55+
assert get_provider('bearing') is BearingProvider
56+
57+
58+
def test_causal_doZ_is_rung2():
59+
y = np.random.binomial(1, 0.6, 500); cond = np.random.choice([0, 1, 2, 3], 500)
60+
assert intervention_effect_of_condition(y, cond, n_perm=50)['rung'] == 2
61+
62+
63+
def test_consensus_conflict_forces_review():
64+
assert fuse('CONFLICT', 0.99) == 'MANUAL_REVIEW'
65+
assert fuse('CONFIRMED', 0.99) == 'HIGH_CONFIDENCE'
66+
67+
68+
# ── gear domain (cross-domain universality) ──────────────────────────────────
69+
70+
def test_gear_mesh_frequency_math():
71+
from cnsd.physics.gear import gear_mesh_frequencies
72+
gf = gear_mesh_frequencies(1800, 20) # 20 teeth at 1800 rpm (30 Hz)
73+
assert abs(gf['GMF'] - 600.0) < 0.1
74+
assert abs(gf['shaft_input'] - 30.0) < 0.1
75+
76+
77+
def test_gear_provider_implements_interface():
78+
from cnsd.physics.providers.gear import GearProvider
79+
from cnsd.physics.providers.base import PhysicsProvider
80+
p = GearProvider(n_teeth_input=20, cond_to_rpm={0: 1800}, fs=5120)
81+
assert isinstance(p, PhysicsProvider)
82+
sig = np.sin(2 * np.pi * 600 * np.arange(2048) / 5120) + 0.1 * np.random.randn(2048)
83+
ev = p.evidence(sig, 0)
84+
assert 'family_strength' in ev and 'frequencies_hz' in ev
85+
assert p.dominant_family(ev) in p.families
86+
87+
88+
def test_gear_registered():
89+
from cnsd.physics.providers import get_provider, available_domains
90+
from cnsd.physics.providers.gear import GearProvider
91+
assert get_provider('gear') is GearProvider
92+
assert 'gear' in available_domains()

0 commit comments

Comments
 (0)