├── .claude └── settings.local.json ├── .github └── workflows │ └── compile-sketch.yml ├── README.md ├── esp32-50hz-frequency-meter-fsd.md └── 50Hz-precisionFrequencyMeter └── 50Hz-precisionFrequencyMeter.ino /.claude/settings.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "allow": [ 4 | "Bash(git add:*)", 5 | "Bash(git commit -m \"$(cat <<''EOF''\nAdd ESP32 50Hz precision frequency meter with Arduino implementation\n\n- Implement I/Q phase-slope algorithm for sub-millihertz frequency measurement\n- Add FreeRTOS multi-task architecture (I2S capture, DSP, MQTT publisher)\n- Support internal ADC (GPIO36) and ES8388 codec input modes\n- Include MQTT integration for real-time metrics publishing\n- Add web interface for status monitoring\n- Fix compatibility with ESP32 Arduino Core 3.x API changes\n- Add comprehensive README with setup instructions and technical details\n\nFeatures:\n- Resolution: ~1-2 mHz @ 1s, <0.2 mHz @ 10s\n- 4 kHz sampling with 10s analysis window\n- Quality metrics (R² correlation, uncertainty estimation)\n- Web dashboard and MQTT telemetry\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude \nEOF\n)\")" 6 | ], 7 | "deny": [], 8 | "ask": [] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/compile-sketch.yml: -------------------------------------------------------------------------------- 1 | name: Compile ESP32 Sketch 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - '50Hz-precisionFrequencyMeter/**' 8 | - '.github/workflows/compile-sketch.yml' 9 | pull_request: 10 | branches: [ main ] 11 | paths: 12 | - '50Hz-precisionFrequencyMeter/**' 13 | - '.github/workflows/compile-sketch.yml' 14 | workflow_dispatch: 15 | 16 | jobs: 17 | compile: 18 | runs-on: ubuntu-latest 19 | 20 | strategy: 21 | matrix: 22 | board: 23 | - fqbn: "esp32:esp32:esp32" 24 | name: "ESP32 Dev Module" 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | 30 | - name: Setup Arduino CLI 31 | uses: arduino/setup-arduino-cli@v1 32 | 33 | - name: Install ESP32 platform 34 | run: | 35 | arduino-cli core update-index --additional-urls https://espressif.github.io/arduino-esp32/package_esp32_index.json 36 | arduino-cli core install esp32:esp32@3.0.7 --additional-urls https://espressif.github.io/arduino-esp32/package_esp32_index.json 37 | 38 | - name: Install required libraries 39 | run: | 40 | arduino-cli lib install "PubSubClient" 41 | 42 | - name: Compile sketch for ${{ matrix.board.name }} 43 | run: | 44 | arduino-cli compile \ 45 | --fqbn ${{ matrix.board.fqbn }} \ 46 | --warnings all \ 47 | --verbose \ 48 | --output-dir build \ 49 | 50Hz-precisionFrequencyMeter/50Hz-precisionFrequencyMeter.ino 50 | 51 | - name: Upload build artifacts 52 | uses: actions/upload-artifact@v4 53 | with: 54 | name: firmware-esp32 55 | path: build/*.bin 56 | retention-days: 30 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⚡ ESP32 50Hz Precision Frequency Meter 2 | 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 4 | [![Platform](https://img.shields.io/badge/platform-ESP32-blue.svg)](https://www.espressif.com/en/products/socs/esp32) 5 | [![Arduino](https://img.shields.io/badge/Arduino-compatible-green.svg)](https://www.arduino.cc/) 6 | 7 | > 🎯 High-precision grid frequency measurement using I/Q phase-slope method on ESP32 8 | 9 | Measure AC grid frequency with **sub-millihertz precision** using affordable ESP32 hardware. Perfect for power quality monitoring, grid stability analysis, and renewable energy integration research. 10 | 11 | ## ✨ Features 12 | 13 | - 📊 **Ultra-high precision**: ~1-2 mHz @ 1s, <0.2 mHz @ 10s (with good SNR) 14 | - 🔌 **Flexible input**: Internal ADC (GPIO36) or external ES8388 codec 15 | - 📡 **MQTT integration**: Real-time metrics publishing 16 | - 🌐 **Web interface**: Built-in HTTP status page 17 | - ⚙️ **FreeRTOS architecture**: Multi-task design for optimal performance 18 | - 🔬 **I/Q demodulation**: Advanced DSP with phase-slope analysis 19 | - 📈 **Quality metrics**: R² correlation and uncertainty estimation 20 | 21 | ## 🎛️ Technical Specifications 22 | 23 | | Parameter | Value | 24 | |-----------|-------| 25 | | Sample Rate | 4 kHz | 26 | | Block Size | 400 samples (0.1s) | 27 | | Analysis Window | 10 seconds | 28 | | Nominal Frequency | 50 Hz | 29 | | Low-pass Filter | 5 Hz (I/Q) | 30 | | Quality Threshold | R² ≥ 0.98 | 31 | 32 | ## 🛠️ Hardware Requirements 33 | 34 | - **ESP32 Development Board** (any variant) 35 | - **Input signal**: Grid voltage via transformer/divider to 0-3.3V 36 | - Default: GPIO36 (ADC1_CH0) 37 | - Optional: ES8388 codec for line-in (ESP32-A1S boards) 38 | - **WiFi network** for MQTT and HTTP access 39 | 40 | ## 📦 Software Requirements 41 | 42 | - Arduino IDE 1.8.x or 2.x 43 | - ESP32 Arduino Core **3.x** (tested with 3.3.1) 44 | - Libraries: 45 | - WiFi (included) 46 | - WebServer (included) 47 | - PubSubClient 48 | 49 | ## 🚀 Quick Start 50 | 51 | ### 1. Configuration 52 | 53 | Edit the following parameters in `50Hz-precisionFrequencyMeter.ino`: 54 | 55 | ```cpp 56 | const char* WIFI_SSID = "YOUR_SSID"; 57 | const char* WIFI_PASS = "YOUR_PASS"; 58 | const char* MQTT_HOST = "192.168.1.10"; 59 | const uint16_t MQTT_PORT = 1883; 60 | ``` 61 | 62 | ### 2. Upload 63 | 64 | 1. Open `50Hz-precisionFrequencyMeter.ino` in Arduino IDE 65 | 2. Select your ESP32 board and port 66 | 3. Click **Upload** 67 | 68 | ### 3. Monitor 69 | 70 | - **Serial Monitor**: 115200 baud for boot messages 71 | - **Web Interface**: `http:///` 72 | - **MQTT Topics**: 73 | - `grid/50hzmeter//state` (retained) 74 | - `grid/50hzmeter//metrics` (real-time) 75 | 76 | ## 📡 MQTT Output 77 | 78 | ### State Message (every 60s, retained) 79 | ```json 80 | { 81 | "fw": "arduino-rtos-iq", 82 | "uptime": 3600, 83 | "fs_hz": 4000, 84 | "win_s": 10.0, 85 | "drops": 0 86 | } 87 | ``` 88 | 89 | ### Metrics Message (every 1s) 90 | ```json 91 | { 92 | "ts": 3600.123, 93 | "f_hz": 50.00012345, 94 | "sigma_f_mhz": 0.15, 95 | "r2": 0.9987, 96 | "window_s": 10.0, 97 | "drops": 0 98 | } 99 | ``` 100 | 101 | ## 🧮 Algorithm Details 102 | 103 | The system uses **I/Q demodulation** with **phase-slope estimation**: 104 | 105 | 1. **Sample**: Acquire 4 kHz ADC samples via I2S 106 | 2. **Correlate**: Mix with 50 Hz reference (cos/sin) to extract I/Q components 107 | 3. **Filter**: Low-pass filter I/Q at 5 Hz to remove noise 108 | 4. **Phase**: Calculate `φ = atan2(Q, I)` for each 0.1s block 109 | 5. **Unwrap**: Remove 2π discontinuities 110 | 6. **Regress**: Linear fit over 10s window → slope in rad/s 111 | 7. **Convert**: `f = slope / (2π)` Hz 112 | 113 | Quality is validated using R² correlation coefficient. 114 | 115 | ## 🔧 Customization 116 | 117 | ### Adjust precision vs. latency 118 | 119 | ```cpp 120 | constexpr int WIN_SEC = 10; // Increase for better precision 121 | ``` 122 | 123 | ### Change input mode 124 | 125 | ```cpp 126 | #define INPUT_MODE INPUT_MODE_ES8388 // For external codec 127 | ``` 128 | 129 | ### Tune DSP parameters 130 | 131 | ```cpp 132 | constexpr float IQ_LP_FC = 5.0f; // Low-pass cutoff 133 | constexpr float R2_MIN = 0.98f; // Quality threshold 134 | ``` 135 | 136 | ## 📊 Performance 137 | 138 | Typical performance with clean AC input (>40dB SNR): 139 | 140 | | Window | Frequency Resolution | Update Rate | 141 | |--------|---------------------|-------------| 142 | | 1s | ~1-2 mHz | 1 Hz | 143 | | 10s | ~0.1-0.2 mHz | 1 Hz | 144 | 145 | ## 🐛 Troubleshooting 146 | 147 | | Issue | Solution | 148 | |-------|----------| 149 | | High `drops` count | Increase queue sizes or reduce CPU load | 150 | | Low R² values | Check input signal quality and noise levels | 151 | | Compilation errors | Ensure ESP32 Core 3.x is installed | 152 | | No MQTT messages | Verify broker address and network connectivity | 153 | 154 | ## 📝 License 155 | 156 | MIT License - see LICENSE file for details 157 | 158 | ## 🙏 Credits 159 | 160 | Inspired by Andreas Spiess's precision frequency measurement techniques. 161 | 162 | ## 🔗 Related Projects 163 | 164 | - [Grid frequency monitoring](https://github.com/topics/grid-frequency) 165 | - [ESP32 DSP applications](https://github.com/topics/esp32-dsp) 166 | 167 | --- 168 | 169 | **Made with ⚡ by the ESP32 community** 170 | -------------------------------------------------------------------------------- /esp32-50hz-frequency-meter-fsd.md: -------------------------------------------------------------------------------- 1 | # ESP32 50 Hz Frequency Meter — Functional Specification (FSD) 2 | 3 | **Revision:** v1.0 4 | **Date:** 2025‑10‑06 5 | **Owner:** Andreas Spiess 6 | **Target HW:** ESP32‑A1S (ES8388 audio codec) or standard ESP32 with I2S ADC 7 | **License:** MIT 8 | 9 | --- 10 | 11 | ## 1. Purpose & Scope 12 | Design and implement a mains‑frequency measurement instrument for 50 Hz grids using an ESP32 with an I2S ADC (ES8388). The device samples an isolated, scaled mains waveform, derives instantaneous phase, and computes frequency from the phase slope with robust statistics. It publishes results via MQTT and stores them in InfluxDB; a small HTTP UI provides status and configuration. 13 | 14 | **Primary KPIs** 15 | - **Short‑term stability (1 s window):** ≤ 2 mHz RMS (typ.) 16 | - **Mid‑term stability (10 s window):** ≤ 0.2 mHz RMS (typ.) 17 | - **Absolute accuracy:** limited by sampling clock; with ±1 ppm clock → ±0.00005 Hz at 50 Hz. Optionally TCXO/GPSDO improves to µHz‑class. 18 | 19 | **Non‑Goals** 20 | - Power‑quality (THD, flicker) analysis beyond basic SNR and harmonic indicators. 21 | - Direct connection to mains (always galvanic isolation). 22 | 23 | --- 24 | 25 | ## 2. System Overview 26 | **Signal chain:** 230 V AC → safety isolation transformer → attenuation/divider → anti‑alias RC → ES8388 line‑in → I2S @ 4 kS/s → DSP (band‑limit, analytic signal, phase unwrap) → robust linear regression → frequency → MQTT/HTTP/InfluxDB. 27 | 28 | **Clocking:** Device uses board audio clock (e.g., 12.288 MHz → 48 kHz base) divided to 4 kS/s. Optional TCXO or GPS‑disciplined clock recommended for high absolute accuracy. 29 | 30 | **Outputs:** Frequency [Hz], rolling σ, SNR, fit R², health/alarms. 31 | 32 | --- 33 | 34 | ## 3. Safety & Compliance 35 | - Use a **mains‑rated isolation transformer** (≥ 1 kV isolation) with primary fuse and proper creepage/clearance. 36 | - Enclose primary wiring; earth chassis if metal. 37 | - Never expose low‑voltage circuits to the primary side. 38 | - Follow local electrical codes. 39 | 40 | --- 41 | 42 | ## 4. Hardware Requirements 43 | ### 4.1 Components 44 | - ESP32‑A1S devboard (with **ES8388** codec), or ESP32 + external I2S ADC (≥ 16 bit). 45 | - Isolation transformer, e.g., **230 → 6 V, 1–2 VA** (low‑distortion type preferred). 46 | - Primary fuse (T80 mA–T160 mA), MOV + RC snubber per transformer datasheet (optional but recommended). 47 | - Secondary divider to ~±1 Vpeak at codec input (example: **R1 = 22 kΩ, R2 = 1.8 kΩ** to ground, tweak per transformer output). 48 | - Anti‑alias RC: **Raa = 330 Ω, Caa = 2.2 µF** (fc ≈ 220 Hz). 49 | - Input protection: series 330 Ω + clamp diodes to codec rails if needed; keep input level within ES8388 line‑in limits. 50 | - Optional clock upgrade: **TCXO** at audio master frequency (e.g., 12.288 MHz ±0.5 ppm) or GPSDO→PLL. 51 | 52 | ### 4.2 Electrical/Analog Specs 53 | - Input after transformer: sine‑like, nominal 6 Vrms (no‑load may rise). 54 | - Target ADC full‑scale utilization: 50–80 % of FS on peaks. 55 | - Effective bandwidth at ADC input: ~0–200 Hz (≥ 40 dB attenuation at 1000 Hz). 56 | - THD not critical for frequency; harmonic energy tolerated by DSP. 57 | 58 | ### 4.3 Power & Housing 59 | - 5 V USB supply for ESP32. 60 | - Transformer powered from mains; keep primary enclosed. 61 | - Provide strain relief and labeling. 62 | 63 | --- 64 | 65 | ## 5. Firmware Requirements (ESP‑IDF or Arduino‑ESP32) 66 | ### 5.1 Tasks & Concurrency 67 | - **I2S Capture Task:** Reads interleaved PCM blocks (mono) at **fs = 4000 Hz**, 16‑bit. Double‑buffered, ≥ 0.5 s of buffer. 68 | - **DSP Task:** Processes 1.0–10.0 s windows; overlaps allowed (e.g., 1 s hop). 69 | - **Publisher Task:** MQTT + local HTTP server; rate‑limits to configured publish period (e.g., 1 Hz). 70 | 71 | ### 5.2 Configuration (persisted JSON/TOML) 72 | - Sampling: `fs`, window length `T_win` (1…20 s), hop `T_hop` (0.5…5 s). 73 | - Filters: LPF cutoff (default 120 Hz), analytic method (`hilbert`|`IQ`). 74 | - Robust stats: outlier filter (`hampel` with window=21, k=3), regression type (`ols`|`theil_sen`). 75 | - MQTT: broker URL, topic base, QoS, retain, credentials. 76 | - InfluxDB (v1 or v2): enable, URL, org/bucket/db, auth. 77 | - HTTP UI: enable, port, CORS. 78 | - Clock correction (optional): `ppm_correction` float. 79 | - Alarms: thresholds for freq deviation, R² min, SNR min. 80 | 81 | ### 5.3 Telemetry & Logging 82 | - Log levels: `error|warn|info|debug|trace`. 83 | - Health: boot time, uptime, dropped buffers, CPU load, heap, last publish status. 84 | 85 | --- 86 | 87 | ## 6. DSP & Algorithms 88 | ### 6.1 Pre‑Processing 89 | 1. **Detrend & scale** audio block to float [−1,1]. 90 | 2. **Band‑limit** with FIR LPF, fc ≈ 120 Hz (e.g., 129 taps, Hamming). 91 | 3. Optional **notch** at 150 Hz if third harmonic pollution is high (usually unnecessary). 92 | 93 | ### 6.2 Analytic Signal 94 | Two interchangeable methods (configurable): 95 | - **Hilbert transform:** `xa = x + j * hilbert(x)` (FIR Hilbert; odd‑symmetric, e.g., 129 taps). 96 | - **Digital I/Q:** Multiply x[n] by numerically generated sin/cos(2π·50 Hz·n/fs) → low‑pass (fc ≈ 5–10 Hz) to remove 100 Hz terms. Prefer Hilbert for simplicity and determinism. 97 | 98 | Compute instantaneous **phase**: `phi[n] = atan2(Im(xa[n]), Re(xa[n]))`, then **unwrap**. 99 | 100 | ### 6.3 Frequency Estimation (Phase‑Slope) 101 | On each analysis window of duration `T_win` seconds with N samples and timestamps `t[n]`: 102 | - Fit `phi[n] ≈ a * t[n] + b` using robust linear regression (default **OLS** with Hampel prefilter; option **Theil–Sen**). 103 | - Frequency: 104 | \[ f = \frac{a}{2\pi} \quad [\text{Hz}] \] 105 | - Uncertainty: use residual std. dev. σφ and time variance to estimate σf. 106 | - Quality metric: **R²** of fit; publish `R2` and `sigma_f`. 107 | 108 | ### 6.4 Outlier Handling & Quality Gates 109 | - **Hampel filter** on phase increments Δφ to reject sudden steps due to spikes; window = 21, k = 3. 110 | - Discard windows with: R² < 0.98, `sigma_f` > configured limit, or SNR < threshold. 111 | - **SNR estimate:** ratio of energy within ±5 Hz of 50 Hz vs remainder in 0–200 Hz band (pre‑bandlimited buffer). 112 | 113 | ### 6.5 Timing & Clock Considerations 114 | - Timestamp samples by sample index; **no RTC needed** for frequency. 115 | - Optional `ppm_correction` applied to `fs` in calculations. 116 | - With TCXO (±0.5 ppm), absolute error at 50 Hz ≤ 0.000025 Hz. 117 | 118 | --- 119 | 120 | ## 7. Interfaces 121 | ### 7.1 MQTT Topics (example) 122 | Base: `grid/50hzmeter//` 123 | - `state` (JSON, retained): firmware, config hash, uptime, last_ok. 124 | - `metrics` (JSON, 1 Hz): 125 | ```json 126 | { 127 | "ts": 1738848000.123, 128 | "f_hz": 49.99982, 129 | "sigma_f_mhz": 0.12, 130 | "r2": 0.996, 131 | "snr_db": 38.4, 132 | "window_s": 10.0, 133 | "hop_s": 1.0, 134 | "fs_hz": 4000, 135 | "drops": 0 136 | } 137 | ``` 138 | - `alarm` (JSON, on change): `{ "type": "low_r2" | "low_snr" | "freq_deviation", "value": ... }` 139 | 140 | ### 7.2 InfluxDB Schema (line protocol) 141 | - **Measurement:** `gridfreq` 142 | - **Tags:** `device_id`, `site`, `grid` (e.g., "UCTE") 143 | - **Fields:** `f_hz` (float), `sigma_f_mhz` (float), `r2` (float), `snr_db` (float), `window_s` (float), `drops` (int) 144 | - **Timestamp:** publish in ns or ms per Influx config 145 | 146 | Example: 147 | ``` 148 | gridfreq,device_id=esp32-a1s-01,site=lab,grid=UCTE f_hz=49.99982,sigma_f_mhz=0.12,r2=0.996,snr_db=38.4,window_s=10.0,drops=0 1738848000123456789 149 | ``` 150 | 151 | ### 7.3 HTTP UI (minimal) 152 | - `/` Status page: live value, sparkline, R², SNR, window/ hop. 153 | - `/config` GET/POST JSON: full configuration with validation. 154 | - `/health` JSON: uptime, heap, CPU, last error, drops. 155 | 156 | --- 157 | 158 | ## 8. Calibration & Validation 159 | 1. **Level check:** Verify ADC input ≈ 0.7 FS on peaks with nominal mains. 160 | 2. **Clock calibration (optional):** Measure a known tone (function generator) at 50.00000 Hz; solve for `ppm_correction` to minimize mean error over ≥ 60 s. 161 | 3. **Cross‑check:** Compare with a trusted source (e.g., mainsfrequency.com EU feed) within expected geographic correlation. 162 | 4. **Stability test:** Allan deviation over 1–60 s; confirm σ scales ~1/√τ. 163 | 164 | --- 165 | 166 | ## 9. Performance Targets & Acceptance Criteria 167 | - Startup to first valid frequency: ≤ 3 s (with T_win = 1 s). 168 | - No audio buffer overruns during 24 h run. 169 | - With decent SNR (> 30 dB), report σf ≤ 2 mHz @ 1 s, ≤ 0.2 mHz @ 10 s. 170 | - MQTT publish success rate ≥ 99.9 % over LAN. 171 | - Configuration persists across reboot; invalid values rejected with error. 172 | 173 | --- 174 | 175 | ## 10. Test Plan (Essentials) 176 | - **Unit tests (DSP):** FIR response, Hilbert phase accuracy on synthetic data, regression slope recovery on chirps ±0.02 Hz. 177 | - **Integration tests:** Record 60 s of ADC data; offline MATLAB/Python reference vs device output Δf ≤ 0.1 mHz (same windowing). 178 | - **Fault injection:** Add impulsive noise bursts; verify Hampel removes outliers and quality gates behave. 179 | - **Throughput:** Stress I2S at 8 kS/s to verify headroom; then run at 4 kS/s. 180 | 181 | --- 182 | 183 | ## 11. Future Enhancements 184 | - **Clock discipline:** GPSDO → Si5351/PLL for absolute µHz accuracy. 185 | - **Multi‑band PQ:** THD, harmonic amplitudes (FFT side‑task). 186 | - **NTP‑stamped frames:** For multi‑site phase comparison (requires known time base). 187 | - **Web UI charts:** rolling Allan deviation, histograms. 188 | 189 | --- 190 | 191 | ## 12. Pseudocode (Reference) 192 | ```pseudo 193 | init(): 194 | load_config() 195 | i2s_init(fs=4000, mono16) 196 | dsp_init_fir_lpf(fc=120Hz, taps=129) 197 | hilbert_init(taps=129) # or IQ path 198 | mqtt_init() 199 | http_init() 200 | 201 | main_loop(): 202 | block = i2s_read_block(N = fs * T_hop) # e.g., 1s hop → N=4000 203 | ring_buffer.push(block) 204 | if ring_buffer.length >= fs * T_win: 205 | x = ring_buffer.last(fs * T_win) 206 | x = lpf_fir(x) 207 | xa = hilbert(x) # complex analytic 208 | phi = unwrap(atan2(imag(xa), real(xa))) 209 | t = [0..len(phi)-1] / fs 210 | phi = hampel_filter(phi, w=21, k=3) 211 | (a,b,R2) = linear_regression(t, phi) 212 | f = a / (2*pi) 213 | sigma_f = estimate_sigma_f(phi, t, a) 214 | snr = estimate_snr(x, fs) 215 | if R2 >= R2_min and snr >= snr_min: 216 | publish_mqtt(f, sigma_f, R2, snr, ...) 217 | write_influx(f, sigma_f, R2, snr, ...) 218 | update_http_cache(...) 219 | ``` 220 | 221 | --- 222 | 223 | ## 13. Bill of Materials (example) 224 | - ESP32‑A1S devkit (ES8388) 225 | - Isolation transformer 230→6 V, 1–2 VA 226 | - Fuse T100 mA, fuse holder; MOV 275 VAC; RC snubber (e.g., 100 Ω + 100 nF X2) 227 | - Resistors: 22 kΩ, 1.8 kΩ, 330 Ω (1%) 228 | - Capacitors: 2.2 µF film/electrolytic (≥ 16 V), assorted 100 nF decouplers 229 | - Enclosure, terminal block, wire, perfboard/PCB 230 | - Optional: 12.288 MHz **TCXO** (±0.5 ppm) compatible with board clocking 231 | 232 | --- 233 | 234 | ## 14. Risks & Mitigations 235 | - **Transformer saturation/line variance:** choose adequate VA rating; keep input level margin; use divider to limit ADC peaks. 236 | - **Clock drift:** add `ppm_correction`; upgrade to TCXO/GPSDO. 237 | - **Noise/harmonics:** sufficient band‑limit and robust phase slope mitigate; optional shielded wiring. 238 | - **Task starvation:** use pinned FreeRTOS tasks with priorities; monitor drops. 239 | 240 | --- 241 | 242 | ## 15. Deliverables 243 | - Firmware source (ESP‑IDF or Arduino) with CI build. 244 | - Config defaults file; sample MQTT/Influx dashboards. 245 | - Hardware wiring diagram and safety notes. 246 | - README with quick‑start and calibration steps. 247 | 248 | --- 249 | 250 | ## 16. Notes / how to wire quickly 251 | 252 | - **Internal ADC mode (default):** Feed your transformer/divider output into **GPIO36 (ADC1_CH0)**. Keep it within ~0.1–2.7 V. Add a small **RC (≈330 Ω + 2.2 µF)** to band-limit around 200 Hz. 253 | - **Publishing:** MQTT topics are `grid/50hzmeter//state` and `/metrics` (JSON). 254 | - **Quality:** `r2` is the regression fit quality; if it falls below ~0.98 you’ll see noisier readings. 255 | - **Window:** ~10 s by default (100 blocks × 0.1 s). Change `WIN_SEC` to 1–20 s based on desired stability. 256 | 257 | ### Switching to ES8388 line-in (ESP32-A1S) 258 | 259 | 1. Change `#define INPUT_MODE` to `INPUT_MODE_ES8388`. 260 | 2. Fill `initES8388LineIn()` with your known-good codec register setup (LINE IN → ADC → I²S, left-justified or I²S standard, 16-bit, 4 kHz or 8 kHz with decimation). 261 | 3. Set `i2s_pin_config_t` to your board’s BCK/WS/SDIN pins (the example uses common A1S pins). 262 | 263 | If you want, I can drop a ready-to-go **ES8388 init** tailored to your A1S dev board and even add **InfluxDB line-protocol** posting and a **JSON config** endpoint—all within this sketch. 264 | -------------------------------------------------------------------------------- /50Hz-precisionFrequencyMeter/50Hz-precisionFrequencyMeter.ino: -------------------------------------------------------------------------------- 1 | /* 2 | ESP32 50 Hz Frequency Meter (I/Q phase-slope) 3 | - RTOS tasks: I2S Capture -> DSP -> MQTT Publisher 4 | - Default input: Internal ADC via I2S (ADC1_CH0, GPIO36) 5 | - Optional (stub): ES8388 I2S line-in on ESP32-A1S boards 6 | 7 | Resolution: ~1–2 mHz @1s, <0.2 mHz @10s (good SNR) 8 | Publish: MQTT JSON + tiny HTTP status page 9 | 10 | Andreas-style: concise, solid, ready to hack :) 11 | */ 12 | 13 | #include 14 | #include 15 | #include 16 | #include 17 | #include "driver/i2s.h" 18 | #include "driver/adc.h" 19 | #include 20 | #include 21 | 22 | // Try to include credentials.h if available 23 | #if __has_include() 24 | #include 25 | #define HAS_CREDENTIALS 26 | #endif 27 | 28 | // ========== CONFIG ========== 29 | #define INPUT_MODE_INTERNAL_ADC 1 30 | #define INPUT_MODE_ES8388 2 31 | #define INPUT_MODE INPUT_MODE_INTERNAL_ADC // switch to ES8388 when ready 32 | 33 | // WiFi & MQTT - from credentials.h if available, otherwise defaults 34 | #ifdef HAS_CREDENTIALS 35 | const char* WIFI_SSID = mySSID; 36 | const char* WIFI_PASS = myPASSWORD; 37 | const char* MQTT_HOST = mqtt_server; 38 | #ifdef mqtt_username 39 | const char* MQTT_USER = mqtt_username; 40 | #else 41 | const char* MQTT_USER = nullptr; 42 | #endif 43 | #ifdef mqtt_password 44 | const char* MQTT_PASS = mqtt_password; 45 | #else 46 | const char* MQTT_PASS = nullptr; 47 | #endif 48 | #else 49 | const char* WIFI_SSID = "YOUR_SSID"; 50 | const char* WIFI_PASS = "YOUR_PASS"; 51 | const char* MQTT_HOST = "192.168.1.10"; 52 | const char* MQTT_USER = nullptr; 53 | const char* MQTT_PASS = nullptr; 54 | #endif 55 | const uint16_t MQTT_PORT = 1883; 56 | String DEVICE_ID = String("esp32-50hz-") + String((uint32_t)ESP.getEfuseMac(), HEX); 57 | String TOPIC_BASE = "grid/50hzmeter/" + DEVICE_ID + "/"; 58 | 59 | // Sampling/DSP 60 | constexpr float FS_HZ = 4000.0f; // sample rate 61 | constexpr float F_REF_HZ = 50.0f; // nominal grid frequency 62 | constexpr int BLOCK_NSAMP = 400; // 0.1 s blocks @ 4kHz 63 | constexpr float BLOCK_SEC = BLOCK_NSAMP / FS_HZ; 64 | constexpr int WIN_SEC = 10; // analysis window ~10 s 65 | constexpr int NBLOCK_WIN = (int)(WIN_SEC / BLOCK_SEC); // 100 blocks 66 | constexpr float IQ_LP_FC = 5.0f; // LPF on I/Q (1st-order) ~5 Hz 67 | constexpr float R2_MIN = 0.98f; // quality gate 68 | 69 | // Queue sizes 70 | constexpr int CAPTURE_QUEUE_LEN = 6; 71 | constexpr int DSP_QUEUE_LEN = 8; 72 | 73 | // ========== GLOBALS ========== 74 | WiFiClient wifiClient; 75 | PubSubClient mqtt(wifiClient); 76 | WebServer http(80); 77 | 78 | // FreeRTOS 79 | QueueHandle_t qCaptureToDSP; 80 | QueueHandle_t qDSPToPub; 81 | 82 | // Buffers for capture blocks 83 | struct Block { 84 | int16_t samples[BLOCK_NSAMP]; 85 | }; 86 | struct Result { 87 | double f_hz; 88 | double r2; 89 | double sigma_f_mhz; 90 | double window_s; 91 | }; 92 | 93 | // Simple status 94 | volatile uint32_t g_drops = 0; 95 | volatile uint32_t g_uptime_sec = 0; 96 | hw_timer_t* uptimeTimer = nullptr; 97 | 98 | // I2S config 99 | i2s_config_t make_i2s_config() { 100 | i2s_config_t config = { 101 | .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX), 102 | .sample_rate = (int)FS_HZ, 103 | .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT, 104 | .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT, 105 | .communication_format = (i2s_comm_format_t)(I2S_COMM_FORMAT_STAND_I2S), 106 | .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1, 107 | .dma_buf_count = 4, 108 | .dma_buf_len = BLOCK_NSAMP, 109 | .use_apll = false, 110 | .tx_desc_auto_clear = false, 111 | .fixed_mclk = 0 112 | }; 113 | return config; 114 | } 115 | 116 | i2s_pin_config_t make_i2s_pins_internaladc() { 117 | // Not used with internal ADC (built-in) 118 | i2s_pin_config_t pins = { 119 | .bck_io_num = I2S_PIN_NO_CHANGE, 120 | .ws_io_num = I2S_PIN_NO_CHANGE, 121 | .data_out_num = I2S_PIN_NO_CHANGE, 122 | .data_in_num = I2S_PIN_NO_CHANGE 123 | }; 124 | return pins; 125 | } 126 | 127 | bool initES8388LineIn(float fs_hz) { 128 | // Minimal placeholder (you can drop in your known-good ES8388 init here). 129 | // This sketch runs out-of-the-box using INTERNAL ADC mode. 130 | // When you’re ready: configure I2S for external pins & set ES8388 for LINE IN -> ADC -> I2S @ fs_hz. 131 | // Return false to avoid pretending it's configured. 132 | return false; 133 | } 134 | 135 | // Uptime ISR 136 | void IRAM_ATTR onUptimeTick() { g_uptime_sec++; } 137 | 138 | // ========== UTIL: Online IIR alpha for block-rate LPF ========== 139 | float lpf_alpha(float fc, float dt) { 140 | float RC = 1.0f / (2.0f * (float)M_PI * fc); 141 | return dt / (RC + dt); 142 | } 143 | 144 | // ========== TASK: I2S Capture ========== 145 | void taskCapture(void* arg) { 146 | size_t bytes_read = 0; 147 | 148 | // I2S init 149 | if (INPUT_MODE == INPUT_MODE_INTERNAL_ADC) { 150 | auto cfg = make_i2s_config(); 151 | cfg.mode = (i2s_mode_t)(cfg.mode | I2S_MODE_ADC_BUILT_IN); 152 | i2s_driver_install(I2S_NUM_0, &cfg, 0, nullptr); 153 | #if SOC_ADC_SUPPORTED 154 | i2s_set_adc_mode(ADC_UNIT_1, ADC1_CHANNEL_0); // GPIO36 155 | #endif 156 | i2s_adc_enable(I2S_NUM_0); 157 | } else { 158 | if (!initES8388LineIn(FS_HZ)) { 159 | Serial.println("[CAP] ES8388 init failed (using internal ADC fallback?)"); 160 | } 161 | auto cfg = make_i2s_config(); 162 | i2s_driver_install(I2S_NUM_0, &cfg, 0, nullptr); 163 | 164 | // TODO: set your I2S pins here for ES8388 165 | i2s_pin_config_t pins = { 166 | .bck_io_num = GPIO_NUM_26, // example pins for many ESP32-A1S boards 167 | .ws_io_num = GPIO_NUM_25, 168 | .data_out_num = I2S_PIN_NO_CHANGE, 169 | .data_in_num = GPIO_NUM_35 170 | }; 171 | i2s_set_pin(I2S_NUM_0, &pins); 172 | } 173 | 174 | while (true) { 175 | Block blk; 176 | int16_t* p = blk.samples; 177 | int remain = BLOCK_NSAMP * sizeof(int16_t); 178 | uint8_t* dst = (uint8_t*)p; 179 | 180 | while (remain > 0) { 181 | size_t br = 0; 182 | esp_err_t e = i2s_read(I2S_NUM_0, dst, remain, &br, pdMS_TO_TICKS(50)); 183 | if (e != ESP_OK) continue; 184 | remain -= br; 185 | dst += br; 186 | } 187 | 188 | if (xQueueSend(qCaptureToDSP, &blk, 0) != pdTRUE) { 189 | g_drops++; 190 | } 191 | } 192 | } 193 | 194 | // ========== DSP Helpers ========== 195 | 196 | // Unwrap phase (simple) 197 | static inline double unwrap(double prev, double now) { 198 | double d = now - prev; 199 | while (d > M_PI) { now -= 2.0 * M_PI; d -= 2.0 * M_PI; } 200 | while (d < -M_PI) { now += 2.0 * M_PI; d += 2.0 * M_PI; } 201 | return now; 202 | } 203 | 204 | // Linear regression slope & R2 for (t,phi) 205 | void linreg(const double* t, const double* y, int N, double& slope, double& r2) { 206 | double sumt=0, sumy=0, sumtt=0, sumty=0; 207 | for (int i=0;i 0) ? (1.0 - ss_res/ss_tot) : 0.0; 222 | } 223 | 224 | // ========== TASK: DSP ========== 225 | void taskDSP(void* arg) { 226 | // Phase timeline buffers (per block) 227 | static double tbuf[NBLOCK_WIN]; 228 | static double phibuf[NBLOCK_WIN]; 229 | int count = 0; 230 | 231 | // I/Q block LPF 232 | const float dt_block = BLOCK_SEC; 233 | const float alpha = lpf_alpha(IQ_LP_FC, dt_block); 234 | double I_lp = 0.0, Q_lp = 0.0; 235 | 236 | // Reference oscillator (complex) using recursive rotation 237 | const double dtheta = 2.0 * M_PI * (double)F_REF_HZ / (double)FS_HZ; 238 | double c = 1.0, s = 0.0; 239 | const double cd = cos(dtheta), sd = sin(dtheta); 240 | 241 | double ph_prev = 0.0; 242 | double t_now = 0.0; 243 | 244 | while (true) { 245 | Block blk; 246 | if (xQueueReceive(qCaptureToDSP, &blk, portMAX_DELAY) != pdTRUE) continue; 247 | 248 | // Accumulate I/Q for this block 249 | double I_sum = 0.0, Q_sum = 0.0; 250 | c = 1.0; s = 0.0; // reset ref per block to avoid drift inside block 251 | for (int i=0;i= (int)roundf(1.0f / BLOCK_SEC)) { 295 | blocks_since_last = 0; 296 | 297 | if (count >= 10) { // need some data 298 | // Normalize time to reduce numeric error 299 | double t0 = tbuf[0]; 300 | static double tn[NBLOCK_WIN]; 301 | for (int i=0;i= R2_MIN) { 312 | Result res { f_hz, r2, sigma_f_mhz, (double)count * BLOCK_SEC }; 313 | if (xQueueSend(qDSPToPub, &res, 0) != pdTRUE) { 314 | // no action; publisher will catch up 315 | } 316 | } 317 | } 318 | } 319 | } 320 | } 321 | 322 | // ========== TASK: MQTT Publisher ========== 323 | void ensureMqtt() { 324 | if (mqtt.connected()) return; 325 | String cid = "freq-" + DEVICE_ID; 326 | mqtt.setServer(MQTT_HOST, MQTT_PORT); 327 | while (!mqtt.connected()) { 328 | mqtt.connect(cid.c_str(), MQTT_USER, MQTT_PASS); 329 | delay(500); 330 | } 331 | } 332 | 333 | void taskPublisher(void* arg) { 334 | while (true) { 335 | Result res; 336 | if (xQueueReceive(qDSPToPub, &res, pdMS_TO_TICKS(1200)) == pdTRUE) { 337 | ensureMqtt(); 338 | 339 | // state (retained once per minute) 340 | static uint32_t lastState = 0; 341 | if (millis() - lastState > 60000 || lastState == 0) { 342 | String state = String("{\"fw\":\"arduino-rtos-iq\",\"uptime\":") + g_uptime_sec + 343 | ",\"fs_hz\":" + FS_HZ + ",\"win_s\":" + res.window_s + 344 | ",\"drops\":" + g_drops + "}"; 345 | mqtt.publish((TOPIC_BASE + "state").c_str(), state.c_str(), true); 346 | lastState = millis(); 347 | } 348 | 349 | // metrics 350 | char buf[256]; 351 | snprintf(buf, sizeof(buf), 352 | "{\"ts\":%.3f,\"f_hz\":%.8f,\"sigma_f_mhz\":%.2f,\"r2\":%.4f," 353 | "\"window_s\":%.1f,\"drops\":%u}", 354 | (double)millis()/1000.0, res.f_hz, res.sigma_f_mhz, res.r2, 355 | res.window_s, (unsigned)g_drops); 356 | 357 | mqtt.publish((TOPIC_BASE + "metrics").c_str(), buf, false); 358 | } else { 359 | // keep MQTT alive 360 | ensureMqtt(); 361 | } 362 | mqtt.loop(); 363 | } 364 | } 365 | 366 | // ========== HTTP ========== 367 | void httpRoot() { 368 | // Minimal status JSON-ish page 369 | String html = "ESP32 50Hz" 370 | "" 371 | "

ESP32 50 Hz Meter

" 372 | "

Device: " + DEVICE_ID + "

" 373 | "

Drops: " + String(g_drops) + "

" 374 | "

Uptime [s]: " + String(g_uptime_sec) + "

" 375 | "

Topics: " + TOPIC_BASE + "state, " + TOPIC_BASE + "metrics

"; 376 | http.send(200, "text/html", html); 377 | } 378 | 379 | // ========== WiFi ========== 380 | void ensureWiFi() { 381 | if (WiFi.status() == WL_CONNECTED) return; 382 | WiFi.mode(WIFI_STA); 383 | WiFi.begin(WIFI_SSID, WIFI_PASS); 384 | while (WiFi.status() != WL_CONNECTED) { 385 | delay(300); 386 | } 387 | } 388 | 389 | // ========== SETUP / LOOP ========== 390 | void setup() { 391 | Serial.begin(115200); 392 | delay(200); 393 | 394 | ensureWiFi(); 395 | 396 | // OTA Setup 397 | ArduinoOTA.setHostname(DEVICE_ID.c_str()); 398 | ArduinoOTA.onStart([]() { 399 | String type = (ArduinoOTA.getCommand() == U_FLASH) ? "sketch" : "filesystem"; 400 | Serial.println("Start OTA updating " + type); 401 | }); 402 | ArduinoOTA.onEnd([]() { 403 | Serial.println("\nOTA End"); 404 | }); 405 | ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { 406 | Serial.printf("Progress: %u%%\r", (progress / (total / 100))); 407 | }); 408 | ArduinoOTA.onError([](ota_error_t error) { 409 | Serial.printf("Error[%u]: ", error); 410 | if (error == OTA_AUTH_ERROR) Serial.println("Auth Failed"); 411 | else if (error == OTA_BEGIN_ERROR) Serial.println("Begin Failed"); 412 | else if (error == OTA_CONNECT_ERROR) Serial.println("Connect Failed"); 413 | else if (error == OTA_RECEIVE_ERROR) Serial.println("Receive Failed"); 414 | else if (error == OTA_END_ERROR) Serial.println("End Failed"); 415 | }); 416 | ArduinoOTA.begin(); 417 | Serial.println("OTA Ready"); 418 | Serial.print("IP address: "); 419 | Serial.println(WiFi.localIP()); 420 | 421 | http.on("/", httpRoot); 422 | http.begin(); 423 | 424 | mqtt.setServer(MQTT_HOST, MQTT_PORT); 425 | 426 | // Uptime 1 Hz 427 | uptimeTimer = timerBegin(1000000); // 1 MHz tick rate 428 | timerAttachInterrupt(uptimeTimer, &onUptimeTick); 429 | timerAlarm(uptimeTimer, 1000000, true, 0); // 1 second alarm, auto-reload 430 | 431 | // Queues 432 | qCaptureToDSP = xQueueCreate(CAPTURE_QUEUE_LEN, sizeof(Block)); 433 | qDSPToPub = xQueueCreate(DSP_QUEUE_LEN, sizeof(Result)); 434 | 435 | // Tasks 436 | xTaskCreatePinnedToCore(taskCapture, "cap", 4096, nullptr, 3, nullptr, 0); 437 | xTaskCreatePinnedToCore(taskDSP, "dsp", 6144, nullptr, 2, nullptr, 1); 438 | xTaskCreatePinnedToCore(taskPublisher,"pub", 4096, nullptr, 1, nullptr, 1); 439 | 440 | Serial.println("Ready."); 441 | } 442 | 443 | void loop() { 444 | // handle HTTP and OTA in main loop (non-blocking) 445 | ArduinoOTA.handle(); 446 | http.handleClient(); 447 | delay(2); 448 | } 449 | --------------------------------------------------------------------------------