Skip to content

File MycilaDimmerPhaseControl.h

File List > src > MycilaDimmerPhaseControl.h

Go to the documentation of this file

// SPDX-License-Identifier: MIT
/*
 * Copyright (C) Mathieu Carbou
 */
#pragma once

#include "MycilaDimmer.h"

namespace Mycila {
  class PhaseControlDimmer : public Dimmer {
    public:
      virtual ~PhaseControlDimmer() { end(); }

      // POWER LUT //

      void enablePowerLUT(bool enable) { _powerLUTEnabled = enable; }

      bool isPowerLUTEnabled() const { return _powerLUTEnabled; }

      // DIMMER STATES //

      bool isOnline() const override { return _enabled && _online && (!_powerLUTEnabled || _semiPeriod > 0); }

      // DIMMER CONTROL //

      bool setDutyCycle(float dutyCycle) override {
        // Apply limit and save the wanted duty cycle.
        // It will only be applied when dimmer will be on.
        _dutyCycle = _contrain(dutyCycle, 0, _dutyCycleLimit);

        const float mapped = getDutyCycleMapped();

        if (_powerLUTEnabled) {
          if (mapped == 0) {
            _dutyCycleFire = 0.0f;
          } else if (mapped == 1) {
            _dutyCycleFire = 1.0f;
          } else {
            _dutyCycleFire = _semiPeriod > 0 ? (1.0f - static_cast<float>(_lookupFiringDelay(mapped)) / static_cast<float>(_semiPeriod)) : mapped;
          }
        } else {
          _dutyCycleFire = mapped;
        }

        return isOnline() && _apply();
      }

      // METRICS //

      float getPowerRatio() const override {
        if (_powerLUTEnabled) {
          // With LUT enabled, the mapped duty cycle IS the linearized power ratio
          return getDutyCycleMapped();
        } else {
          // Without LUT, we have a linear firing time.
          // We calculate real Power Ratio from Phase Angle using the physical formula:
          // P_ratio = d - sin(2 * pi * d) / (2 * pi)
          const float duty = getDutyCycleFire();
          // Optimization: use multiplication by inverse instead of division
          return duty - sinf(2.0f * M_PI * duty) * (1.0f / (2.0f * M_PI));
        }
      }

#ifdef MYCILA_JSON_SUPPORT
      virtual void toJson(const JsonObject& root) const {
        Dimmer::toJson(root);
        root["power_lut"] = isPowerLUTEnabled();
      }
#endif

    protected:
      bool _powerLUTEnabled = false;

      static uint16_t _lookupFiringDelay(float dutyCycle) {
        uint32_t duty = dutyCycle * FIRING_DELAY_MAX;
        uint32_t slot = duty * FIRING_DELAYS_SCALE + (FIRING_DELAYS_SCALE >> 1);
        uint32_t index = slot >> 16;
        uint32_t a = FIRING_DELAYS[index];
        uint32_t b = FIRING_DELAYS[index + 1];
        uint32_t delay = a - (((a - b) * (slot & 0xffff)) >> 16); // interpolate a b
        return (delay * _semiPeriod) >> 16;
      }

      bool _calculateDimmerHarmonics(float* array, size_t n) const override {
        // getDutyCycleFire() returns the conduction angle normalized (0-1)
        // Convert to firing angle: α = π × (1 - conduction)
        // At 50% power: α ≈ 90° (π/2), which gives maximum harmonics
        const float firingAngle = M_PI * (1.0f - _dutyCycleFire);

        // Calculate RMS of fundamental component (reference)
        // Formula from Thierry Lequeu: I1_rms = (1/π) × √[2(π - α + ½sin(2α))]
        const float sin_2a = sinf(2.0f * firingAngle);
        const float i1_rms = sqrtf((2.0f / M_PI) * (M_PI - firingAngle + 0.5f * sin_2a));

        if (i1_rms <= 0.001f)
          return false;

        array[0] = 100.0f; // H1 (fundamental) = 100% reference

        // Pre-compute scale factor for efficiency
        const float scale_factor = (2.0f / M_PI) * 0.70710678f * 100.0f / i1_rms;

        // Calculate odd harmonics (H3, H5, H7, ...)
        // Formula for phase-controlled resistive loads (IEEE standard):
        // Hn = (2/π√2) × |cos((n-1)α)/(n-1) - cos((n+1)α)/(n+1)| / I1_rms × 100%
        // This gives the correct harmonic magnitudes relative to the fundamental
        for (size_t i = 1; i < n; i++) {
          const float n_f = static_cast<float>(2 * i + 1); // 3, 5, 7, 9, ...
          const float n_minus_1 = n_f - 1.0f;
          const float n_plus_1 = n_f + 1.0f;

          // Compute Fourier coefficient
          const float coeff = cosf(n_minus_1 * firingAngle) / n_minus_1 -
                              cosf(n_plus_1 * firingAngle) / n_plus_1;

          // Convert to percentage of fundamental
          array[i] = fabsf(coeff) * scale_factor;
        }

        return true;
      }

    private:
      static constexpr uint32_t DIMMER_RESOLUTION = 12;
      static constexpr uint32_t FIRING_DELAYS_LEN = 200;
      static constexpr uint32_t FIRING_DELAY_MAX = (1 << DIMMER_RESOLUTION) - 1;
      static constexpr uint32_t FIRING_DELAYS_SCALE = (FIRING_DELAYS_LEN - 1U) * (1UL << (16 - DIMMER_RESOLUTION));

      // clang-format off
      static constexpr uint16_t FIRING_DELAYS[FIRING_DELAYS_LEN] = {
        0xffff, 0xe877, 0xe240, 0xddd9, 0xda51, 0xd74f, 0xd4aa, 0xd248, 0xd01a, 0xce16,
        0xcc34, 0xca6e, 0xc8c0, 0xc728, 0xc5a1, 0xc42b, 0xc2c3, 0xc168, 0xc019, 0xbed3,
        0xbd98, 0xbc65, 0xbb3b, 0xba17, 0xb8fb, 0xb7e5, 0xb6d5, 0xb5ca, 0xb4c5, 0xb3c4,
        0xb2c8, 0xb1d1, 0xb0dd, 0xafed, 0xaf01, 0xae18, 0xad33, 0xac51, 0xab71, 0xaa95,
        0xa9bb, 0xa8e3, 0xa80e, 0xa73b, 0xa66b, 0xa59c, 0xa4d0, 0xa406, 0xa33d, 0xa276,
        0xa1b1, 0xa0ed, 0xa02b, 0x9f6b, 0x9eac, 0x9dee, 0x9d32, 0x9c76, 0x9bbc, 0x9b04,
        0x9a4c, 0x9996, 0x98e0, 0x982b, 0x9778, 0x96c5, 0x9613, 0x9563, 0x94b2, 0x9403,
        0x9354, 0x92a6, 0x91f9, 0x914c, 0x90a0, 0x8ff5, 0x8f4a, 0x8ea0, 0x8df6, 0x8d4d,
        0x8ca4, 0x8bfb, 0x8b53, 0x8aab, 0x8a04, 0x895d, 0x88b6, 0x8810, 0x876a, 0x86c4,
        0x861e, 0x8579, 0x84d3, 0x842e, 0x8389, 0x82e4, 0x823f, 0x819b, 0x80f6, 0x8051,
        0x7fad, 0x7f08, 0x7e63, 0x7dbf, 0x7d1a, 0x7c75, 0x7bd0, 0x7b2b, 0x7a85, 0x79e0,
        0x793a, 0x7894, 0x77ee, 0x7748, 0x76a1, 0x75fa, 0x7553, 0x74ab, 0x7403, 0x735a,
        0x72b1, 0x7208, 0x715e, 0x70b4, 0x7009, 0x6f5e, 0x6eb2, 0x6e05, 0x6d58, 0x6caa,
        0x6bfb, 0x6b4c, 0x6a9b, 0x69eb, 0x6939, 0x6886, 0x67d3, 0x671e, 0x6668, 0x65b2,
        0x64fa, 0x6442, 0x6388, 0x62cc, 0x6210, 0x6152, 0x6093, 0x5fd3, 0x5f11, 0x5e4d,
        0x5d88, 0x5cc1, 0x5bf8, 0x5b2e, 0x5a62, 0x5993, 0x58c3, 0x57f0, 0x571b, 0x5643,
        0x5569, 0x548d, 0x53ad, 0x52cb, 0x51e6, 0x50fd, 0x5011, 0x4f21, 0x4e2d, 0x4d36,
        0x4c3a, 0x4b39, 0x4a34, 0x4929, 0x4819, 0x4703, 0x45e7, 0x44c3, 0x4399, 0x4266,
        0x412b, 0x3fe5, 0x3e96, 0x3d3b, 0x3bd3, 0x3a5d, 0x38d6, 0x373e, 0x3590, 0x33ca,
        0x31e8, 0x2fe4, 0x2db6, 0x2b54, 0x28af, 0x25ad, 0x2225, 0x1dbe, 0x1787, 0x0000
      };
      // clang-format on
  };
} // namespace Mycila