Electric Network Frequency Analysis tool
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.
# 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.txt1. 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.png2. 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 \
--plot3. Inspect matches in GUI:
python enf_view.py --results results.jsonExtracts 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,0to 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 recordingfrequency_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.pngCompares 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 fromenf_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 orderref_start_utc: Reference window start timeref_end_utc: Reference window end timecorrelation: 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"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 EIFeatures:
- 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 findsource_data/grid_databy walking up from the results JSON; pass--grid-direxplicitly 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
.
├── 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)
The enf_extract.py script uses Quadratically Interpolated FFT (QIFFT) for sub-bin frequency precision:
- Bandpass filter: 4th-order Butterworth filter around target frequency
- Windowing: Hanning window on each frame
- FFT: Zero-padded (default 16×) for fine bin spacing
- Peak finding: Locate maximum magnitude in expected frequency range
- QIFFT interpolation: Quadratic fit on peak and neighbors for sub-bin accuracy
- Aggregation: Average multiple estimates per second to match grid cadence
- Smoothing: Optional median filter for noise reduction
Formula: For magnitude bins α, β, γ at peak k:
δ = 0.5 × (α - γ) / (α - 2β + γ)
f_est = (k + δ) × (fs / N)
The enf_compare.py script uses FFT-accelerated sliding Pearson correlation:
- Load & segment: Grid data is sorted by timestamp and split at large gaps so outages and missing days never become synthetic match windows
- Resample: Each contiguous segment is resampled to regular 1-second intervals independently
- Stable Pearson correlation: Query and candidate windows are mean-centered, and correlation is computed with FFT cross-correlation plus rolling window statistics
- Candidate selection: Top 50 correlation candidates are kept from each contiguous segment
- Threshold coverage: Count samples within the Hz threshold for those candidates
- Composite scoring: 0.4 × correlation + 0.6 × coverage
- Distinct-match filtering: Keep the highest-scoring matches whose start times are separated by at least the configured spacing window
- Ranking: Return the top-N distinct matches in score order
- 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
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.
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)
Use collect_freqgauge_service.py to continuously download FNET gauge images:
python collect_freqgauge_service.py \
--outdir source_data/scraped_images \
--interval 38.6Extract frequency traces from collected images:
python freqgauge_extract.py \
--input source_data/scraped_images \
--output source_data/grid_data/merged.csv \
-j 8View and explore extracted data:
python freqgauge_view_csv.py source_data/grid_data/merged.csvThe 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 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
- 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
python3 -m pip install requestspython3 collect_freqgauge_service.py \
--outdir /var/lib/freqgauge/images \
--log-file /var/log/freqgauge/collector.logOptional flags:
--interval 50(seconds between polls)--timeout 20(HTTP timeout)--once(download one image and exit)--verbose(debug logging)
- Create a service user (optional but recommended):
sudo useradd --system --no-create-home --shell /usr/sbin/nologin freqgauge- 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- 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- Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable freqgauge-collector
sudo systemctl start freqgauge-collector- Check status/logs:
sudo systemctl status freqgauge-collector
sudo journalctl -u freqgauge-collector -fProcessing the Images
Install image stack into the same venv you use for scraping:
.\.venv\Scripts\pip.exe install -r requirements.txtOne 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.csvWhole 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 1000Debug 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\debugUseful 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).
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.csvUse 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.
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