3. Build a CFC 60 Low-Pass Filter for a Seatbelt Load Channel

Why CFC 60 for a Seatbelt Load Channel

Seatbelt load cells measure the restraint force transmitted through the webbing during a crash event. The signal of interest is dominated by low-frequency content: the belt force rises over tens of milliseconds and the injury-relevant peak typically occurs within the first 80–120 ms of the event. CFC 60 (Channel Frequency Class 60) is the SAE J211 / ISO 6487 class specified for seatbelt load channels, and it defines a low-pass filter whose magnitude response must stay within a tolerance corridor up to 60 Hz and roll off steeply above it. The class is defined by a corridor, not by a single transfer function, so several analog or digital implementations are acceptable — but the implementation you choose must be verified against the corridor, and its phase behaviour must be understood because it shifts the time of the peak you will later report.

This case builds a digital CFC 60 filter that can be dropped into a DAQ post-processing chain. The work has three parts: derive the filter coefficients, apply them with correct initial conditions so the start of the record is not corrupted, and verify magnitude and phase against the CFC 60 acceptance limits.

The CFC 60 Acceptance Corridor

SAE J211/1 defines each CFC class by a set of magnitude tolerance points relative to the passband gain. For CFC 60 the key points are:

Frequency (Hz)Relative magnitude (dB)Tolerance
0.1 – 600+0.5 / −0.5 dB
90−0.5 to −1.5corridor
120−2.0 to −4.0corridor
180−6.0 to −10.0corridor
300−12.0 to −18.0corridor
600−18.0 to −24.0corridor
1000−24.0 to −30.0corridor

Two practical consequences follow. First, the passband must be flat to within ±0.5 dB out to 60 Hz, so a filter with a −3 dB corner at 60 Hz is not compliant — it would already be down 3 dB at the class limit. Second, the stopband requirement is gentle enough that a modest-order filter suffices, provided the corner is placed high enough that the 60 Hz point is still inside the passband tolerance.

Choosing the Filter Structure and Corner

A 4th-order Butterworth low-pass is a good fit: it is maximally flat in the passband (so the ±0.5 dB requirement out to 60 Hz is easy to meet) and its −3 dB corner can be placed above 60 Hz while still satisfying the stopband points. A common and defensible choice is a corner frequency of about 100 Hz. At 60 Hz a 4th-order Butterworth at fc = 100 Hz is only about −0.4 dB down, inside the passband corridor; at 300 Hz it is roughly −14 dB, inside the −12 to −18 dB window; at 1000 Hz it is about −28 dB, inside the −24 to −30 dB window. The exact numbers depend on the corner you pick, which is why the verification step below is mandatory rather than optional.

For a digital implementation, the analog prototype is converted with the bilinear transform. The pre-warping of the corner frequency matters: at a 10 kHz sample rate the warping is small but not zero, and skipping it shifts the effective corner. The code below pre-warps explicitly.

Implementation

The filter is implemented as a cascade of two biquad sections (SOS form), which is numerically better conditioned than a single 4th-order transfer function in direct form. The function below designs the coefficients, applies zero-phase forward-backward filtering for the magnitude check, and also provides a causal single-pass mode for the phase check.

import numpy as np
from scipy import signal

FS = 10000.0          # DAQ sample rate, Hz
FC = 100.0            # chosen -3 dB corner, Hz
ORDER = 4             # 4th-order Butterworth

def design_cfc60(fs=FS, fc=FC, order=ORDER):
    # Pre-warp the corner for the bilinear transform
    warped = 2.0 * fs * np.tan(np.pi * fc / fs)
    sos = signal.butter(order, warped, btype='low',
                        analog=True, output='sos')
    # Convert analog SOS to digital via bilinear transform
    sos_d = signal.bilinear_zpk  # placeholder for clarity
    z, p, k = signal.butter(order, warped, btype='low', analog=True,
                            output='zpk')
    zd, pd, kd = signal.bilinear_zpk(z, p, k, fs)
    return signal.zpk2sos(zd, pd, kd)

SOS = design_cfc60()

def apply_cfc60(x, zero_phase=True):
    x = np.asarray(x, dtype=float)
    if zero_phase:
        # Forward-backward: zero net phase, magnitude squared
        return signal.sosfiltfilt(SOS, x)
    # Causal single pass: real phase lag, used for phase verification
    return signal.sosfilt(SOS, x)

Two details are easy to get wrong. First, sosfiltfilt squares the magnitude response, so a filter designed to be −0.4 dB at 60 Hz becomes −0.8 dB after forward-backward application — still inside the ±0.5 dB corridor only if the single-pass figure is comfortably under 0.5 dB. If your corner choice leaves little margin, either lower the corner slightly or verify the two-pass response directly. Second, sosfiltfilt uses reflection padding by default; for a crash pulse that starts near zero and ends near zero this is acceptable, but for a record with a large offset at the boundary, pad explicitly with the mean or with zeros and document the choice.

Verifying Magnitude and Phase

Verification is a frequency-response sweep against the corridor, plus a phase check at the frequencies that matter for peak timing. The snippet below computes the two-pass magnitude response and the single-pass phase, then tests the corridor points.

def verify_cfc60(sos=SOS, fs=FS):
    w, h = signal.sosfreqz(sos, worN=8192, fs=fs)
    mag_db_single = 20*np.log10(np.abs(h) + 1e-12)
    mag_db_two    = 2.0 * mag_db_single          # forward-backward
    phase_deg     = np.unwrap(np.angle(h)) * 180/np.pi

    checks = [(60, -0.5, 0.5), (90, -1.5, -0.5), (120, -4.0, -2.0),
              (180, -10.0, -6.0), (300, -18.0, -12.0),
              (600, -24.0, -18.0), (1000, -30.0, -24.0)]
    report = []
    for f, lo, hi in checks:
        idx = np.argmin(np.abs(w - f))
        m2 = mag_db_two[idx]
        ok = lo <= m2 <= hi
        report.append((f, round(m2, 2), lo, hi, ok))
    return report, phase_deg, w

report, phase_deg, w = verify_cfc60()
for f, m2, lo, hi, ok in report:
    print(f"{f:5d} Hz  two-pass {m2:7.2f} dB  "
          f"corridor [{lo}, {hi}]  {'PASS' if ok else 'FAIL'}")

Run this and read the table. With fc = 100 Hz and a 4th-order Butterworth, the two-pass response at 60 Hz lands near −0.8 dB, which is outside the ±0.5 dB passband corridor — a real and common failure. The fix is to raise the corner: at fc = 120 Hz the two-pass figure at 60 Hz drops to roughly −0.45 dB and the stopband points still fall inside their windows. This is exactly the kind of iteration the corridor forces, and it is why you verify rather than assume.

For phase, the single-pass response at 60 Hz for a 4th-order Butterworth at 120 Hz is on the order of −50° to −60°. That phase lag delays the apparent peak by roughly φ/(360·f) seconds — at 60 Hz and −55°, about 2.5 ms. If your reported peak time must be accurate to a few milliseconds, either use zero-phase filtering (and state that you did) or correct the reported time by the group delay. Never mix the two conventions between channels in the same report.

CFC 60 Digital Filter — Design, Application, Verification 1. Design 4th-order Butterworth fc = 120 Hz (pre-warped) fs = 10 kHz SOS: 2 biquads 2. Apply sosfiltfilt (zero-phase) or sosfilt (causal) pad boundaries explicitly belt load channel 3. Verify sosfreqz sweep corridor check @ 60–1000 Hz phase @ 60 Hz PASS / FAIL per point Magnitude corridor (two-pass) — passband ±0.5 dB to 60 Hz, stopband windows above Frequency (Hz, log scale) dB ±0.5 dB 60 90 120 180 300 600 1000 passband corridor stopband window filter response

Interpreting the Verification Output

The printed table is the acceptance record. A compliant design shows every row marked PASS. The most frequent failure is the 60 Hz passband point, because the two-pass magnitude doubles the single-pass attenuation; if that row fails, raise the corner until it passes while re-checking that the 300 Hz and 1000 Hz rows stay inside their windows. A second failure mode is a stopband point that is too steep — a higher-order filter can push 300 Hz below −18 dB, which is also non-compliant. The corridor is two-sided; being too aggressive is as wrong as being too gentle.

For the phase check, compute the single-pass phase at 60 Hz and convert to a time delay. Record both the zero-phase and causal peak times for the same belt load pulse; the difference is the group delay you must either correct or disclose. In a crash report, the peak belt force value is unaffected by zero-phase filtering, but the peak time is — and that time is often used to correlate belt loading with occupant kinematics.

Do not apply the CFC 60 filter twice by accident. If the DAQ firmware already applies a CFC 60 filter and your post-processing chain applies another, the effective response is the square of the intended one and the passband will fail the ±0.5 dB corridor. Confirm the filter state of the raw channel before adding a stage.
Keep the design parameters (FS, FC, ORDER) and the verification table together in the channel's processing record. When the sample rate changes between tests, the pre-warped corner changes and the coefficients must be regenerated — a filter designed at 10 kHz is not valid at 20 kHz.