Skip to content

nate-griff/enf

Repository files navigation

enf

Electric Network Frequency Analysis tool

Overview

This repository contains a local Electric Network Frequency (ENF) analysis tool that extracts ENF signatures from audio and video recordings, then compares them against known grid-frequency reference data from one of four North American grids:

  • EI (Eastern Interconnection)
  • WECC (Western Electricity Coordinating Council)
  • ERCOT (Electric Reliability Council of Texas)
  • Quebec

The tool is research-oriented and designed as an investigative aid to narrow down plausible time windows for human review, not as a standalone proof system.

Quick Start

Setup

# Create and activate virtual environment
python -m venv .venv
.\.venv\Scripts\activate          # Windows
source .venv/bin/activate         # Linux/macOS

# Install dependencies
pip install -r requirements.txt

Basic Workflow

1. Extract ENF from audio/video:

python enf_extract.py --input recording.wav --output trace.csv
python enf_extract.py --input recording.mp4 --output trace.csv  # requires ffmpeg on PATH
python enf_extract.py --input recording.wav --output trace.csv --export-figure
python enf_extract.py --input recording.wav --output trace.csv --figure-output trace_overview.png

2. Compare against grid reference data:

python enf_compare.py \
  --trace trace.csv \
  --grid-dir source_data/grid_data \
  --region EI \
  --date 2026-04-20 \
  --top-n 5 \
  --plot

3. Inspect matches in GUI:

python enf_view.py --results results.json

Tools

enf_extract.py — ENF Extraction from Audio/Video

Extracts Electric Network Frequency using Quadratically Interpolated FFT (QIFFT).

Usage:

python enf_extract.py --input FILE [--output OUTPUT.csv] [options]

Reads WAV and FLAC directly. Other audio formats and video inputs are converted to a temporary mono 48 kHz WAV with ffmpeg, so ffmpeg must be installed and available on PATH for those cases.

Key Arguments:

  • --input (required): Audio or video file
  • --output: Output CSV path (default: {input_stem}_enf.csv)
  • --nominal: Expected fundamental grid frequency in Hz (default: 60). This is the center frequency the extractor assumes before it filters or searches for peaks. Change it when working with 50 Hz systems or any source where the mains frequency is known to be different; if this is wrong, the extractor looks in the wrong part of the spectrum.
  • --harmonic: Which harmonic to extract (default: 2 — second harmonic at 120 Hz)
    • Harmonic 2 is recommended — much cleaner results with less noise contamination
    • Result is automatically divided back to fundamental (60 Hz)
  • --bandwidth: Half-bandwidth in Hz around the target harmonic for both the bandpass filter and FFT peak search (default: 0.5). With --nominal 60 --harmonic 2, the default searches 119.5-120.5 Hz and then divides back to the 60 Hz fundamental. Use a narrower bandwidth to reject nearby tones and motor noise; widen it when the recording is driftier, the nominal is uncertain, or the ENF is not staying tightly centered.
  • --frame-sec: Duration of each FFT analysis frame in seconds (default: 1.0). Longer frames usually give steadier and more precise frequency estimates because they contain more cycles, but they blur short-term ENF changes and assume the frequency stays fairly stable inside the frame. Shorter frames react faster to changes but are noisier.
  • --overlap: Fraction of each frame reused by the next frame, from 0 to 1 (default: 0.5). Higher overlap creates more intermediate estimates that are later averaged into the approximately 1 Hz output trace, which can stabilize noisy material at the cost of extra compute and more redundant measurements. Lower overlap is faster but gives fewer estimates to average.
  • --pad-factor: Zero-padding multiplier before the FFT (default: 16). This reduces FFT bin spacing and helps QIFFT place the peak more smoothly between bins, but it does not add new signal information; it mainly trades runtime for finer interpolation. Values in the 8-16 range are usually the practical sweet spot.
  • --median-window: Median filter width applied after the trace is aggregated to roughly one estimate per second (default: 3, 0 to disable). Use it to remove isolated spikes without pulling the whole trace the way a moving average can. Larger windows suppress more outliers but can flatten real short ENF excursions; even values are rounded up internally to the next odd window.
  • --export-figure: Save a two-panel PNG with a 0-250 Hz spectrogram and the final extracted ENF trace
  • --figure-output: Explicit path for the PNG figure; when provided, figure export is enabled automatically

Practical tuning guide:

Goal What to change
Cleaner trace from a steady recording Increase --frame-sec, keep moderate/high --overlap, and use a small --median-window such as 3 or 5
Track faster variation or avoid over-smoothing Decrease --frame-sec, keep --median-window small, or set --median-window 0
Tolerate larger drift or uncertain nominal frequency Increase --bandwidth slightly
Reduce random one-sample spikes Increase --median-window modestly
Improve peak interpolation without changing the basic time resolution Increase --pad-factor, keeping in mind the runtime cost

Output CSV columns:

  • offset_seconds: Seconds from start of recording
  • frequency_hz: Estimated ENF frequency

Example:

python enf_extract.py --input fan.wav --output fan_enf.csv --harmonic 2 --bandwidth 0.5
python enf_extract.py --input fan.wav --output fan_enf.csv --export-figure
python enf_extract.py --input fan.wav --output fan_enf.csv --figure-output fan_overview.png

enf_compare.py — Grid Matching

Compares an extracted ENF trace against grid reference data using FFT-accelerated Pearson correlation on contiguous reference segments.

Usage:

python enf_compare.py --trace TRACE.csv --grid-dir DIR --region REGION [options]

Key Arguments:

  • --trace (required): ENF trace CSV from enf_extract.py
  • --grid-dir (required): Directory containing daily grid CSV files
  • --region (required): Grid region (EI, WECC, ERCOT, or Quebec)
  • --date: Filter grid data to specific date(s) (YYYY-MM-DD, comma-separated)
  • --top-n: Number of top matches to return (default: 3)
  • --min-separation-sec: Minimum separation between returned match start times in seconds (default: 5, use 0 to allow adjacent near-duplicates)
  • --threshold: Hz threshold for "close enough" scoring (default: 0.01)
  • --output: JSON output path (default: {trace_stem}_results.json)
  • --plot: Generate overlay PNG for each top match
  • --recording-time: Known UTC start time (ISO format) for offset display
  • Reference data is only interpolated within short observed runs. Large outages or missing-day gaps are split into separate segments and are not matchable.

Output JSON: Contains ranked matches with:

  • rank: Match order
  • ref_start_utc: Reference window start time
  • ref_end_utc: Reference window end time
  • correlation: Pearson correlation (0–1)
  • threshold_coverage: Fraction of samples within threshold Hz (0–1)
  • composite_score: Weighted score (40% correlation + 60% coverage)

Scoring: The composite score combines:

  • Pearson correlation (40%): Shape similarity, offset-invariant
  • Threshold coverage (60%): Absolute frequency proximity

After scoring, matches are greedily filtered so returned ref_start_utc values stay at least --min-separation-sec apart. This suppresses near-duplicate 1-second-offset windows while still backfilling deeper candidates until top-n distinct matches are found.

Example:

python enf_compare.py \
  --trace fan_enf.csv \
  --grid-dir source_data/grid_data \
  --region EI \
  --date 2026-04-20 \
  --top-n 5 \
  --min-separation-sec 5 \
  --threshold 0.01 \
  --plot \
  --recording-time "2026-04-20T16:36:00"

enf_view.py — GUI Overlay Viewer

Interactive tkinter + matplotlib viewer for visual inspection of ENF matches.

Usage:

# Load from results JSON
python enf_view.py --results results.json

# Or load manually
python enf_view.py --trace trace.csv --grid-dir source_data/grid_data --region EI

Features:

  • Overlay display: Query trace (blue) vs. matched reference (orange)
  • Match stepping: Previous/Next buttons to cycle through top matches
  • Scroll/Zoom: Log-scale zoom slider and time-position scroll
  • Score display: Shows correlation, coverage %, and composite score
  • UTC info: Displays reference time window in plot title
  • Grid-dir auto-discovery: When opened with --results, the viewer will try to find source_data/grid_data by walking up from the results JSON; pass --grid-dir explicitly if your data lives elsewhere

Controls:

  • Match combobox: Jump to any top match
  • Scroll slider: Move time window across the traces
  • Zoom slider: Change visible time range (log scale, narrow ← → wide)
  • Prev/Next buttons: Step through ranked matches

Project Structure

.
├── enf_extract.py           # ENF extraction (audio/video → CSV)
├── enf_compare.py           # Grid matching (CSV → JSON results)
├── enf_view.py              # GUI viewer (JSON → overlay display)
├── freqgauge_view_csv.py    # CSV viewer for grid reference data
├── freqgauge_extract.py     # Extract grid data from FNET images
├── collect_freqgauge_service.py  # Continuous FNET image collection
├── requirements.txt         # Python dependencies
├── sample_data/
│   ├── audio_samples/       # Example audio recordings
│   └── video_samples/       # Example video recordings
└── source_data/
    ├── grid_data/           # Daily grid CSVs from FNET
    └── scraped_images/      # FNET frequency gauge images (from collector)

Technical Details

ENF Extraction Method

The enf_extract.py script uses Quadratically Interpolated FFT (QIFFT) for sub-bin frequency precision:

  1. Bandpass filter: 4th-order Butterworth filter around target frequency
  2. Windowing: Hanning window on each frame
  3. FFT: Zero-padded (default 16×) for fine bin spacing
  4. Peak finding: Locate maximum magnitude in expected frequency range
  5. QIFFT interpolation: Quadratic fit on peak and neighbors for sub-bin accuracy
  6. Aggregation: Average multiple estimates per second to match grid cadence
  7. Smoothing: Optional median filter for noise reduction

Formula: For magnitude bins α, β, γ at peak k:

δ = 0.5 × (α - γ) / (α - 2β + γ)
f_est = (k + δ) × (fs / N)

Matching Algorithm

The enf_compare.py script uses FFT-accelerated sliding Pearson correlation:

  1. Load & segment: Grid data is sorted by timestamp and split at large gaps so outages and missing days never become synthetic match windows
  2. Resample: Each contiguous segment is resampled to regular 1-second intervals independently
  3. Stable Pearson correlation: Query and candidate windows are mean-centered, and correlation is computed with FFT cross-correlation plus rolling window statistics
  4. Candidate selection: Top 50 correlation candidates are kept from each contiguous segment
  5. Threshold coverage: Count samples within the Hz threshold for those candidates
  6. Composite scoring: 0.4 × correlation + 0.6 × coverage
  7. Distinct-match filtering: Keep the highest-scoring matches whose start times are separated by at least the configured spacing window
  8. Ranking: Return the top-N distinct matches in score order

Default Settings

  • Harmonic: 2 (second harmonic at 120 Hz, much cleaner than fundamental)
  • Bandwidth: 0.5 Hz
  • Frame size: 1.0 second
  • Overlap: 50% (0.5 second hop)
  • Zero-padding: 16× (48 kHz × 1s = 48000 points → 768000 points)
  • Median filter: 3-sample window
  • Threshold: 0.01 Hz
  • Reference gap split: 5 seconds
  • Composite weights: 40% correlation, 60% coverage

Dependencies

requests>=2.28.0      # FNET image collection
numpy>=1.24.0
opencv-python>=4.8.0  # Image extraction (freqgauge_extract.py)
pandas>=2.0.0
matplotlib>=3.7.0     # GUI viewers and exported figures
scipy>=1.11.0         # ENF extraction and comparison

External tool: install ffmpeg separately if you want enf_extract.py to accept video files or audio formats other than WAV/FLAC.

Data Sources

Reference Grid Data

Daily CSV files are generated by processing FNET frequency gauge images. Each CSV contains:

timestamp_utc,region,frequency_hz
2026-04-20 16:36:12.457984+00:00,EI,59.980379

Grid regions:

  • EI: Eastern Interconnection (US East)
  • WECC: Western Electricity Coordinating Council (US West)
  • ERCOT: Electric Reliability Council of Texas (Texas)
  • Quebec: Hydro-Québec system (Quebec/Eastern Canada)

Image Collection

Use collect_freqgauge_service.py to continuously download FNET gauge images:

python collect_freqgauge_service.py \
  --outdir source_data/scraped_images \
  --interval 38.6

Image Processing

Extract frequency traces from collected images:

python freqgauge_extract.py \
  --input source_data/scraped_images \
  --output source_data/grid_data/merged.csv \
  -j 8

View and explore extracted data:

python freqgauge_view_csv.py source_data/grid_data/merged.csv

Validation & Testing

The tool was validated end-to-end with:

  • Test recording: fan.wav — 340 seconds, 48 kHz stereo, recorded 2026-04-20 12:36 PM EST
  • Reference data: EI grid data for 2026-04-20
  • Result: Top match found at 16:36:05 UTC

Data Sources (Details)

Data was scraped from FNET's live grid data

Scraping the Images

Use collect_freqgauge_service.py to continuously download the current image from:

https://fnetpublic.utk.edu/freqgauge.php

What it does

  • Downloads one image every 38.6 seconds (default)
  • Saves images under a UTC day folder (YYYY-MM-DD)
  • Adds a UTC timestamp to each filename
  • Logs failures and status messages to a log file and stdout

Install dependency

python3 -m pip install requests

Run manually

python3 collect_freqgauge_service.py \
	--outdir /var/lib/freqgauge/images \
	--log-file /var/log/freqgauge/collector.log

Optional flags:

  • --interval 50 (seconds between polls)
  • --timeout 20 (HTTP timeout)
  • --once (download one image and exit)
  • --verbose (debug logging)

systemd service setup

  1. Create a service user (optional but recommended):
sudo useradd --system --no-create-home --shell /usr/sbin/nologin freqgauge
  1. Create directories and permissions:
sudo mkdir -p /var/lib/freqgauge/images
sudo mkdir -p /var/log/freqgauge
sudo chown -R freqgauge:freqgauge /var/lib/freqgauge /var/log/freqgauge
  1. Create /etc/systemd/system/freqgauge-collector.service:
[Unit]
Description=FNET Frequency Gauge Collector
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=freqgauge
Group=freqgauge
WorkingDirectory=/opt/enf
ExecStart=/usr/bin/python3 /opt/enf/collect_freqgauge_service.py --outdir /var/lib/freqgauge/images --log-file /var/log/freqgauge/collector.log --interval 50
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
  1. Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable freqgauge-collector
sudo systemctl start freqgauge-collector
  1. Check status/logs:
sudo systemctl status freqgauge-collector
sudo journalctl -u freqgauge-collector -f
Processing the Images

Extract traces to CSV (freqgauge_extract.py)

Install image stack into the same venv you use for scraping:

.\.venv\Scripts\pip.exe install -r requirements.txt

One image (one row per x-column × four regions):

.\.venv\Scripts\python.exe freqgauge_extract.py `
  --input testdata\freqgauge_2026-03-20T22-39-56.541331Z.png `
  --output out\sample.csv

Whole tree (recursive freqgauge_*.png / .jpg, including YYYY-MM-DD day folders from the collector). With two or more images, --dedupe-ms bins timestamps and averages frequency_hz for overlapping windows:

.\.venv\Scripts\python.exe freqgauge_extract.py `
  --input path\to\images `
  --output out\merged.csv `
  --dedupe-ms 1000

Debug overlays (cropped plot + binary mask side‑by‑side) to tune color detection and margins:

.\.venv\Scripts\python.exe freqgauge_extract.py `
  --input testdata\some.png `
  --debug-dir out\debug

Useful flags: --window-seconds (default 55), --skip-shape-check if resolution changes, --morphology 0 to disable mask cleanup. For large batches, -j / --jobs N runs extraction in N parallel processes (default 1); try 4–8 on a multi-core machine—each worker holds one full image in RAM, and --debug-dir still runs sequentially after the pool finishes. CSV columns are timestamp_utc, region, frequency_hz by default; add --verbose-csv to include pixel_x and source_path.

Time axis: columns map linearly from (capture_time − window) on the left to capture_time on the right, using the UTC timestamp in the filename. Frequency: 59.95 Hz at the bottom of the inner plot, 60.05 Hz at the top (FREQ_MIN_HZ / FREQ_MAX_HZ in the script).

View extracted CSV (freqgauge_view_csv.py)

Requires matplotlib (included in requirements.txt). The viewer expects columns timestamp_utc, region, and frequency_hz (extra columns such as pixel_x / source_path are ignored).

.\.venv\Scripts\python.exe freqgauge_view_csv.py
.\.venv\Scripts\python.exe freqgauge_view_csv.py out\merged.csv

Use Open CSV (or pass a path on the command line), pick one region at a time, set time zoom with the dropdown (common widths), the log-scale width slider, and/or / +. The dropdown switches to Custom when the slider doesn’t match a preset. Scroll time moves the visible window along the UTC axis. Reset view shows the full time range.

Plot Details (calibration)

Regions

PLOT_REGIONS = {
    "EI":     {"x1": 100, "x2": 1180, "y1": 43,  "y2": 220},
    "WECC":   {"x1": 100, "x2": 1180, "y1": 342, "y2": 520},
    "ERCOT":  {"x1": 100, "x2": 1180, "y1": 642, "y2": 820},
    "Quebec": {"x1": 100, "x2": 1180, "y1": 942, "y2": 1120},
}

Color Codes (RGB) EI: 5.1, 55.7, 87.1 WECC: 2.0, 58.5, 16.9 ERCOT: 88.6, 26.3, 11.8 Quebec: 82.0, 1.6, 79.2

Packages

 
 
 

Contributors