MycilaJSY 13.0.0
Arduino / ESP32 library for the JSY1031, JSY-MK-163, JSY-MK-193, JSY-MK-194, JSY-MK-227, JSY-MK-229, JSY-MK-333 families single-phase and three-phase AC bidirectional meters from Shenzhen Jiansiyan Technologies Co, Ltd.
Loading...
Searching...
No Matches
MycilaDimmer.h
1// SPDX-License-Identifier: MIT
2/*
3 * Copyright (C) 2023-2025 Mathieu Carbou
4 */
5#pragma once
6
7#define MYCILA_DIMMER_VERSION "2.2.0"
8#define MYCILA_DIMMER_VERSION_MAJOR 2
9#define MYCILA_DIMMER_VERSION_MINOR 2
10#define MYCILA_DIMMER_VERSION_REVISION 0
11
12#ifdef MYCILA_JSON_SUPPORT
13 #include <ArduinoJson.h>
14#endif
15
16#include <assert.h>
17#include <esp32-hal-gpio.h>
18
19#include <cstdio>
20
21namespace Mycila {
22 class Dimmer {
23 public:
24 typedef struct {
25 // Output voltage (dimmed)
26 float voltage = 0.0f;
27 float current = 0.0f;
28 float power = 0.0f;
29 float apparentPower = 0.0f;
30 float powerFactor = NAN;
31 float thdi = NAN;
32 } Metrics;
33
34 public:
35 virtual ~Dimmer() {};
36
37 virtual void begin() = 0;
38 virtual void end() = 0;
39 virtual const char* type() const = 0;
40
42 // DIMMER CONFIG //
44
50 void setDutyCycleLimit(float limit) {
51 _dutyCycleLimit = _contrain(limit, 0, 1);
52 if (_dutyCycle > _dutyCycleLimit)
53 setDutyCycle(_dutyCycleLimit);
54 }
55
62 void setDutyCycleMin(float min) {
63 _dutyCycleMin = _contrain(min, 0, _dutyCycleMax);
64 setDutyCycle(_dutyCycle);
65 }
66
73 void setDutyCycleMax(float max) {
74 _dutyCycleMax = _contrain(max, _dutyCycleMin, 1);
75 setDutyCycle(_dutyCycle);
76 }
77
81 float getDutyCycleLimit() const { return _dutyCycleLimit; }
82
86 float getDutyCycleMin() const { return _dutyCycleMin; }
87
91 float getDutyCycleMax() const { return _dutyCycleMax; }
92
94 // POWER LUT //
96
109 void enablePowerLUT(bool enable, uint16_t semiPeriod = 0) {
110 if (!enable) {
111 _powerLUTEnabled = false;
112 return;
113 }
114 // Enabling the LUT
115 if (semiPeriod > 0) {
116 // A semi-period is provided, use it
117 _semiPeriod = semiPeriod;
118 } else {
119 // semiPeriod == 0, use already set semi-period, must be >0
120 if (_semiPeriod == 0) {
121 ESP_LOGE("MycilaDimmer", "enablePowerLUT: semiPeriod must be provided or must be already set when enabling power LUT");
122 }
123 assert(_semiPeriod > 0);
124 }
125 _powerLUTEnabled = true;
126 }
127
131 bool isPowerLUTEnabled() const { return _powerLUTEnabled; }
132
136 uint16_t getPowerLUTSemiPeriod() const { return _powerLUTEnabled ? _semiPeriod : 0; }
137
139 // DIMMER STATES //
141
145 bool isEnabled() const { return _enabled; }
146
150 bool isOnline() const { return _enabled && _online; }
151
156 void setOnline(bool online) {
157 _online = online;
158 if (!_online) {
159 _dutyCycleFire = 0.0f;
160 if (_enabled)
161 _apply();
162 } else {
163 setDutyCycle(_dutyCycle);
164 }
165 }
166
168 // DIMMER CONTROL //
170
174 void on() { setDutyCycle(1); }
175
179 void off() { setDutyCycle(0); }
180
184 bool isOff() const { return !isOn(); }
185
189 bool isOn() const { return isOnline() && _dutyCycle; }
190
194 bool isOnAtFullPower() const { return _dutyCycle >= _dutyCycleMax; }
195
201 bool setDutyCycle(float dutyCycle) {
202 // Apply limit and save the wanted duty cycle.
203 // It will only be applied when dimmer will be on.
204 _dutyCycle = _contrain(dutyCycle, 0, _dutyCycleLimit);
205
206 const float mapped = getDutyCycleMapped();
207
208 if (_powerLUTEnabled) {
209 if (mapped == 0) {
210 _dutyCycleFire = 0.0f;
211 } else if (mapped == 1) {
212 _dutyCycleFire = 1.0f;
213 } else {
214 _dutyCycleFire = 1.0f - static_cast<float>(_lookupFiringDelay(mapped, _semiPeriod)) / static_cast<float>(_semiPeriod);
215 }
216 } else {
217 _dutyCycleFire = mapped;
218 }
219
220 return isOnline() && _apply();
221 }
222
224 // DUTY CYCLE //
226
230 float getDutyCycle() const { return _dutyCycle; }
231
235 float getDutyCycleMapped() const { return _dutyCycleMin + _dutyCycle * (_dutyCycleMax - _dutyCycleMin); }
236
245 float getDutyCycleFire() const { return isOnline() ? _dutyCycleFire : 0.0f; }
246
248 // METRICS //
250
251 // Calculate harmonics based on dimmer firing angle for resistive loads
252 // array[0] = H1 (fundamental), array[1] = H3, array[2] = H5, array[3] = H7, etc.
253 // Only odd harmonics are calculated (even harmonics are negligible for symmetric dimmers)
254 // Returns true if harmonics were calculated, false if dimmer is not active
255 bool calculateHarmonics(float* array, size_t n) const {
256 if (array == nullptr || n == 0)
257 return false;
258
259 // Check if dimmer is active and routing
260 if (!isOnline() || _dutyCycleFire <= 0.0f) {
261 for (size_t i = 0; i < n; i++) {
262 array[i] = 0.0f; // No power, no harmonics
263 }
264 return true;
265 }
266
267 if (_dutyCycleFire >= 1.0f) {
268 array[0] = 100.0f; // H1 (fundamental) = 100% reference
269 for (size_t i = 1; i < n; i++) {
270 array[i] = 0.0f; // No harmonics at full power
271 }
272 return true;
273 }
274
275 // Initialize all values to NAN
276 for (size_t i = 0; i < n; i++) {
277 array[i] = NAN;
278 }
279
280 return _calculateHarmonics(array, n);
281 }
282
283 virtual bool calculateMetrics(Metrics& metrics, float gridVoltage, float loadResistance) const;
284
285#ifdef MYCILA_JSON_SUPPORT
291 virtual void toJson(const JsonObject& root) const {
292 root["type"] = type();
293 root["enabled"] = _enabled;
294 root["online"] = _online;
295 root["state"] = isOn() ? "on" : "off";
296 root["duty_cycle"] = _dutyCycle;
297 root["duty_cycle_mapped"] = getDutyCycleMapped();
298 root["duty_cycle_fire"] = _dutyCycleFire;
299 root["duty_cycle_limit"] = _dutyCycleLimit;
300 root["duty_cycle_min"] = _dutyCycleMin;
301 root["duty_cycle_max"] = _dutyCycleMax;
302 root["power_lut"] = _powerLUTEnabled;
303 root["power_lut_semi_period"] = _semiPeriod;
304 JsonObject harmonics = root["harmonics"].to<JsonObject>();
305 float output[11]; // H1 to H21
306 if (calculateHarmonics(output, 11)) {
307 for (size_t i = 0; i < 11; i++) {
308 if (!std::isnan(output[i])) {
309 char key[8];
310 snprintf(key, sizeof(key), "H%d", static_cast<int>(2 * i + 1));
311 harmonics[key] = output[i];
312 }
313 }
314 }
315 }
316#endif
317
318 protected:
319 bool _enabled = false;
320 bool _online = false;
321
322 float _dutyCycle = 0.0f;
323 float _dutyCycleFire = 0.0f;
324 float _dutyCycleLimit = 1.0f;
325 float _dutyCycleMin = 0.0f;
326 float _dutyCycleMax = 1.0f;
327
328 bool _powerLUTEnabled = false;
329 uint16_t _semiPeriod = 0;
330
331 virtual bool _apply() = 0;
332 virtual bool _calculateHarmonics(float* array, size_t n) const = 0;
333
334 static uint16_t _lookupFiringDelay(float dutyCycle, uint16_t semiPeriod);
335
336 static inline float _contrain(float amt, float low, float high) {
337 return (amt < low) ? low : ((amt > high) ? high : amt);
338 }
339
340 static bool _calculatePhaseControlHarmonics(float dutyCycleFire, float* array, size_t n) {
341 // getDutyCycleFire() returns the conduction angle normalized (0-1)
342 // Convert to firing angle: α = π × (1 - conduction)
343 // At 50% power: α ≈ 90° (π/2), which gives maximum harmonics
344 const float firingAngle = M_PI * (1.0f - dutyCycleFire);
345
346 // Calculate RMS of fundamental component (reference)
347 // Formula from Thierry Lequeu: I1_rms = (1/π) × √[2(π - α + ½sin(2α))]
348 const float sin_2a = sinf(2.0f * firingAngle);
349 const float i1_rms = sqrtf((2.0f / M_PI) * (M_PI - firingAngle + 0.5f * sin_2a));
350
351 if (i1_rms <= 0.001f)
352 return false;
353
354 array[0] = 100.0f; // H1 (fundamental) = 100% reference
355
356 // Pre-compute scale factor for efficiency
357 const float scale_factor = (2.0f / M_PI) * 0.70710678f * 100.0f / i1_rms;
358
359 // Calculate odd harmonics (H3, H5, H7, ...)
360 // Formula for phase-controlled resistive loads (IEEE standard):
361 // Hn = (2/π√2) × |cos((n-1)α)/(n-1) - cos((n+1)α)/(n+1)| / I1_rms × 100%
362 // This gives the correct harmonic magnitudes relative to the fundamental
363 for (size_t i = 1; i < n; i++) {
364 const float n_f = static_cast<float>(2 * i + 1); // 3, 5, 7, 9, ...
365 const float n_minus_1 = n_f - 1.0f;
366 const float n_plus_1 = n_f + 1.0f;
367
368 // Compute Fourier coefficient
369 const float coeff = cosf(n_minus_1 * firingAngle) / n_minus_1 -
370 cosf(n_plus_1 * firingAngle) / n_plus_1;
371
372 // Convert to percentage of fundamental
373 array[i] = fabsf(coeff) * scale_factor;
374 }
375
376 return true;
377 }
378
379 static bool _calculatePhaseControlMetrics(Metrics& metrics, float dutyCycleFire, float gridVoltage, float loadResistance) {
380 if (loadResistance > 0 && gridVoltage > 0) {
381 if (dutyCycleFire > 0) {
382 const float nominalPower = gridVoltage * gridVoltage / loadResistance;
383 if (dutyCycleFire >= 1.0f) {
384 // full power
385 metrics.powerFactor = 1.0f;
386 metrics.thdi = 0.0f;
387 metrics.power = nominalPower;
388 metrics.voltage = gridVoltage;
389 metrics.current = gridVoltage / loadResistance;
390 metrics.apparentPower = nominalPower;
391 return true;
392 } else {
393 // partial power
394 metrics.powerFactor = std::sqrt(dutyCycleFire);
395 metrics.thdi = 100.0f * std::sqrt(1 / dutyCycleFire - 1);
396 metrics.power = dutyCycleFire * nominalPower;
397 metrics.voltage = metrics.powerFactor * gridVoltage;
398 metrics.current = metrics.voltage / loadResistance;
399 metrics.apparentPower = gridVoltage * metrics.current;
400 return true;
401 }
402 } else {
403 // no power
404 metrics.voltage = 0.0f;
405 metrics.current = 0.0f;
406 metrics.power = 0.0f;
407 metrics.apparentPower = 0.0f;
408 metrics.powerFactor = NAN;
409 metrics.thdi = NAN;
410 return true;
411 }
412 } else {
413 return false;
414 }
415 }
416 };
417
418 class VirtualDimmer : public Dimmer {
419 public:
420 virtual ~VirtualDimmer() { end(); }
421
422 void begin() override { _enabled = true; }
423 void end() override { _enabled = false; }
424 const char* type() const override { return "virtual"; }
425 bool calculateMetrics(Metrics& metrics, float gridVoltage, float loadResistance) const override {
426 return false;
427 }
428
429 protected:
430 bool _apply() override { return true; }
431 bool _calculateHarmonics(float* array, size_t n) const override {
432 for (size_t i = 0; i < n; i++) {
433 array[i] = 0.0f; // No harmonics for virtual dimmer
434 }
435 return true;
436 }
437 };
438} // namespace Mycila
439
440#include "MycilaDimmerDFRobot.h"
441#include "MycilaDimmerPWM.h"
442#include "MycilaDimmerThyristor.h"
void off()
Turn off the dimmer.
float getDutyCycleFire() const
Get the real firing duty cycle (conduction duty cycle) applied to the dimmer in the range [0,...
bool isOnline() const
Returns true if the dimmer is marked online.
uint16_t getPowerLUTSemiPeriod() const
Get the semi-period in us used for the power LUT calculations. If LUT is disabled,...
float getDutyCycleMax() const
Get the remapped "1" of the dimmer duty cycle.
bool isOn() const
Check if the dimmer is on.
bool isEnabled() const
Check if the dimmer is enabled (if it was able to initialize correctly)
void setDutyCycleMin(float min)
Duty remapping (equivalent to Shelly Dimmer remapping feature). Useful to calibrate the dimmer when u...
float getDutyCycle() const
Get the power duty cycle configured for the dimmer by teh user.
void setOnline(bool online)
Set the online status of the dimmer.
void on()
Turn on the dimmer at full power.
bool isPowerLUTEnabled() const
Check if the power LUT is enabled.
float getDutyCycleLimit() const
Get the power duty cycle limit of the dimmer.
float getDutyCycleMapped() const
Get the remapped power duty cycle from the currently user set duty cycle.
bool setDutyCycle(float dutyCycle)
Set the power duty.
bool isOff() const
Check if the dimmer is off.
bool isOnAtFullPower() const
Check if the dimmer is on at full power.
void enablePowerLUT(bool enable, uint16_t semiPeriod=0)
Enable or disable the use of power LUT for dimmer curve The power LUT provides a non-linear dimming c...
float getDutyCycleMin() const
Get the remapped "0" of the dimmer duty cycle.
void setDutyCycleMax(float max)
Duty remapping (equivalent to Shelly Dimmer remapping feature). Useful to calibrate the dimmer when u...
void setDutyCycleLimit(float limit)
Set the power duty cycle limit of the dimmer. The duty cycle will be clamped to this limit.