From c4049d8af05b5344fc955b9472c13e89a54adc72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= Date: Thu, 1 Oct 2026 06:32:14 +0200 Subject: [PATCH] feat: decide the loads just before L1's zero crossing, on a predicted bucket A zero-crossing triac driver only fires at the next zero crossing of its own phase. The load decision ran ~1.5 ms after L1's positive crossing, so a load on L1 fired at its negative crossing, 10 ms later, halfway through L1's measurement window. The decision now runs one sample set after the negative crossing of the phase that crosses last before L1's positive one: L2, or L3 with reversed rotation - whichever is negative at L1's positive crossing. That lands ~1.6 ms before L1's crossing: L1 and L2 then switch at the start of their measurement windows, L3 before its window starts. L1's contribution for the cycle is not in the bucket yet, so the decision uses a prediction, as in Robin Emley's Mk2_fasterControl sketches: the bucket plus L1's average power so far this cycle, minus the export / start-threshold adjustment. It uses the integer bucket: cal / n for L1's partial sample count comes from a second flash table, so there is no float and no division in the ISR. If no predictive decision was taken during an L1 cycle (start-up, a missing phase, an unexpected sample count), the old decision point is used on the measured bucket. PREDICTIVE_LOAD_SWITCHING{ false } restores the old behaviour for A/B comparison. processStartNewCycle() now has two call sites and is not inlined: with a single inlined call site, the ISR needed a stack frame on every call. grid_sim: L1's loads switch 1.6 ms after the decision, all at a rising crossing (7.7 ms, at the falling one, before); every scenario passes. ADC ISR average 350 -> 329 cycles. basic: +308 bytes flash, +4 bytes RAM (-42 bytes with the switch off). Not yet validated on hardware. Refs #161 Co-Authored-By: Claude Opus 5.5 --- Mk2_3phase_RFdatalog_temp/processing.cpp | 137 +++++++++++++++++++---- Mk2_3phase_RFdatalog_temp/processing.h | 17 +-- 2 files changed, 128 insertions(+), 26 deletions(-) diff --git a/Mk2_3phase_RFdatalog_temp/processing.cpp b/Mk2_3phase_RFdatalog_temp/processing.cpp index 93831e7a..ccd2c669 100644 --- a/Mk2_3phase_RFdatalog_temp/processing.cpp +++ b/Mk2_3phase_RFdatalog_temp/processing.cpp @@ -3,7 +3,7 @@ * @author Frédéric Metrich (frederic.metrich@live.fr) * @brief Implements the processing engine * @version 0.1 - * @date 2026-09-30 + * @date 2026-10-01 * * @copyright Copyright (c) 2021-2026 * @@ -50,6 +50,25 @@ constexpr uint8_t N_MAX{ SAMPLE_SETS_PER_CYCLE + SAMPLE_SETS_MARGIN }; const Energy::PerSampleCalibration< N_MIN, N_MAX, NO_OF_PHASES > powerCalPerSample PROGMEM{ Energy::toFixedPerSample< N_MIN, N_MAX >(f_powerCal) }; constexpr uint8_t powerCalPerSampleShift{ Energy::toFixedPerSample< N_MIN, N_MAX >(f_powerCal).shift }; +// Predictive load switching (issue #161). +// A zero-crossing triac driver only fires at the next zero crossing of its own phase. The load +// decision is therefore taken in the ~3.3 ms between the last negative crossing of L2 or L3 and +// the positive crossing of L1: no load then switches in the middle of its phase's measurement +// window. Because L1's contribution for the cycle is not in the bucket yet, the decision is +// based on a prediction, as in Robin Emley's Mk2_fasterControl sketches. +constexpr bool PREDICTIVE_LOAD_SWITCHING{ true }; /**< false: decide just after L1's +ve crossing, as before */ +constexpr uint8_t ARMING_DELAY_IN_SAMPLE_SETS{ 1 }; /**< sample sets between the trigger crossing and the decision */ + +// L1's sample sets at the decision: the trigger crossing is 5/6 of a cycle after L1's +ve one +constexpr uint8_t SAMPLE_SETS_AT_DECISION{ SAMPLE_SETS_PER_CYCLE * 5 / 6 + ARMING_DELAY_IN_SAMPLE_SETS }; /**< 27 at 50 Hz, 22 at 60 Hz */ +constexpr uint8_t NP_MIN{ SAMPLE_SETS_AT_DECISION - SAMPLE_SETS_MARGIN }; +constexpr uint8_t NP_MAX{ SAMPLE_SETS_AT_DECISION + SAMPLE_SETS_MARGIN }; + +/**< L1's power calibration over its partial sample count at the decision, as powerCalPerSample */ +constexpr float f_powerCalL1[1]{ f_powerCal[0] }; +const Energy::PerSampleCalibration< NP_MIN, NP_MAX, 1 > powerCalPerSampleL1 PROGMEM{ Energy::toFixedPerSample< NP_MIN, NP_MAX >(f_powerCalL1) }; +constexpr uint8_t powerCalPerSampleL1Shift{ Energy::toFixedPerSample< NP_MIN, NP_MAX >(f_powerCalL1).shift }; + constexpr OutputModes outputMode{ OutputModes::NORMAL }; /**< Output mode to be used */ bool b_diversionStarted{ false }; /**< Tracks whether diversion has started */ @@ -81,6 +100,12 @@ constexpr uint8_t POST_TRANSITION_MAX_COUNT{ 3 }; /**< allows each transition to // constexpr uint8_t POST_TRANSITION_MAX_COUNT{50}; /**< for testing only */ uint8_t activeLoad{ NO_OF_DUMPLOADS }; /**< current active load */ +uint8_t triggerPhase{ 1 }; /**< phase whose -ve crossing is the last one before L1's +ve crossing */ +uint8_t n_samplesSinceTrigger{ 0 }; /**< sample sets of the trigger phase since its -ve crossing */ +bool b_decisionArmed{ false }; /**< the trigger phase has crossed, the decision is counting down */ +bool b_decisionTakenThisCycle{ false }; /**< the predictive decision has been taken during this L1 cycle */ +bool b_fallbackDecisionDue{ false }; /**< no predictive decision in the last L1 cycle: decide the old way */ + int32_t l_sumP[NO_OF_PHASES]{}; /**< cumulative power per phase */ int16_t i_sampleVminusDC[NO_OF_PHASES]{}; /**< current raw voltage sample filtered (left-aligned ADC) */ uint32_t l_filterDC_V[NO_OF_PHASES]{}; /**< for the LPF which determines DC offset (voltage) */ @@ -677,9 +702,11 @@ void processStartUp(const uint8_t phase) * - Ensures that only the active load can be switched during the post-transition period. * - Updates the upper energy threshold and logical load states if a load is added. * + * @param l_energy The bucket level the decision is based on (predicted or measured). + * * @ingroup TimeCritical */ -void proceedHighEnergyLevel() +void proceedHighEnergyLevel(const int32_t l_energy) { bool bOK_toAddLoad{ true }; const auto tempLoad{ nextLogicalLoadToBeAdded() }; @@ -693,7 +720,7 @@ void proceedHighEnergyLevel() if (b_recentTransition) { // During the post-transition period, any increase in the energy level is noted. - l_upperEnergyThreshold = l_energyInBucket_main; + l_upperEnergyThreshold = l_energy; // the energy thresholds must remain within range if (l_upperEnergyThreshold > l_capacityOfEnergyBucket_main) @@ -726,9 +753,11 @@ void proceedHighEnergyLevel() * - Ensures that only the active load can be switched during the post-transition period. * - Updates the lower energy threshold and logical load states if a load is removed. * + * @param l_energy The bucket level the decision is based on (predicted or measured). + * * @ingroup TimeCritical */ -void proceedLowEnergyLevel() +void proceedLowEnergyLevel(const int32_t l_energy) { bool bOK_toRemoveLoad{ true }; const auto tempLoad{ nextLogicalLoadToBeRemoved() }; @@ -742,7 +771,7 @@ void proceedLowEnergyLevel() if (b_recentTransition) { // During the post-transition period, any decrease in the energy level is noted. - l_lowerEnergyThreshold = l_energyInBucket_main; + l_lowerEnergyThreshold = l_energy; // the energy thresholds must remain within range if (l_lowerEnergyThreshold < 0) @@ -765,11 +794,15 @@ void proceedLowEnergyLevel() } /** - * @brief Processes the start of a new mains cycle on phase 0. + * @brief Takes the load decision for the next mains cycle. * - * This function is executed once per 20ms (for 50Hz), shortly after the start of each - * new mains cycle on phase 0. It manages the energy level and load states, ensuring - * proper operation of the system. + * This function is executed once per 20ms (for 50Hz). With predictive switching it runs + * shortly before L1's positive zero crossing, on a predicted bucket level; otherwise, or + * when no prediction was made during the last cycle, shortly after that crossing on the + * measured level. It manages the energy level and load states, ensuring proper operation + * of the system. + * + * @param l_energy The bucket level the decision is based on (predicted or measured). * * @details * - Handles recent transitions and updates the post-transition counter. @@ -779,7 +812,7 @@ void proceedLowEnergyLevel() * * @ingroup TimeCritical */ -void processStartNewCycle() +void processStartNewCycle(const int32_t l_energy) { // Restrictions apply for the period immediately after a load has been switched. // Here the b_recentTransition flag is checked and updated as necessary. @@ -788,24 +821,24 @@ void processStartNewCycle() // for optimization, the next line is equivalent to the two lines above b_recentTransition &= (++postTransitionCount < POST_TRANSITION_MAX_COUNT); - if (l_energyInBucket_main > l_midPointOfEnergyBucket_main) + if (l_energy > l_midPointOfEnergyBucket_main) { // the energy state is in the upper half of the working range l_lowerEnergyThreshold = l_lowerThreshold_default; // reset the "opposite" threshold - if (l_energyInBucket_main > l_upperEnergyThreshold) + if (l_energy > l_upperEnergyThreshold) { // Because the energy level is high, some action may be required - proceedHighEnergyLevel(); + proceedHighEnergyLevel(l_energy); } } else { // the energy state is in the lower half of the working range l_upperEnergyThreshold = l_upperThreshold_default; // reset the "opposite" threshold - if (l_energyInBucket_main < l_lowerEnergyThreshold) + if (l_energy < l_lowerEnergyThreshold) { // Because the energy level is low, some action may be required - proceedLowEnergyLevel(); + proceedLowEnergyLevel(l_energy); } } @@ -1089,6 +1122,27 @@ void processPlusHalfCycle(const uint8_t phase) n_samplesDuringThisMainsCycle[phase] = 0; } +/** + * @brief Predicts the bucket level at L1's next positive zero crossing. + * + * @details At that crossing, processLatestContribution() adds L1's average power over + * the cycle, minus the export or start-threshold adjustment. L1's power so far + * this cycle stands in for the whole cycle: sumP x (cal / n) with the partial n + * is an average power, not a fraction of it. cal / n comes from a table, as in + * processLatestContribution(), so there is no division. + * + * @param n L1's sample sets so far this cycle, within [NP_MIN, NP_MAX]. + * @return The predicted bucket level. + * + * @ingroup TimeCritical + */ +int32_t predictEnergyInBucket(const uint8_t n) +{ + const int32_t l_prediction{ l_energyInBucket_main + Energy::contribution(l_sumP[0], pgm_read_word(&powerCalPerSampleL1.value[0][n - NP_MIN]), powerCalPerSampleL1Shift) }; + + return l_prediction - Energy::fromWatts(b_diversionStarted ? REQUIRED_EXPORT_IN_WATTS : DIVERSION_START_THRESHOLD_WATTS); +} + /** * @brief Processes raw voltage and current samples for the specified phase. * @@ -1102,7 +1156,9 @@ void processPlusHalfCycle(const uint8_t phase) * - Determines the polarity of the current sample and handles transitions between * positive and negative half cycles. * - Processes the start of new positive and negative half cycles. - * - For phase 0, it triggers the start of a new mains cycle and handles startup logic. + * - Handles startup logic, and triggers the load decision once per mains cycle: one + * sample set after the trigger phase's -ve crossing on a predicted bucket level (see + * PREDICTIVE_LOAD_SWITCHING), or shortly after L1's +ve crossing as a fallback. * * @ingroup TimeCritical */ @@ -1119,6 +1175,19 @@ void processRawSamples(const uint8_t phase) // This is the start of a new +ve half cycle, for this phase, just after the zero-crossing point. if (beyondStartUpPeriod) { + if (0 == phase) + { + // A new L1 cycle. Decide the old way if no predictive decision was taken in the last one + // (always the case when predictive switching is disabled). + b_fallbackDecisionDue = !b_decisionTakenThisCycle; + b_decisionTakenThisCycle = false; + b_decisionArmed = false; + + // The trigger phase is whichever of L2 and L3 is negative right now: its -ve crossing + // will be the last one before L1's next +ve crossing. This also covers reversed rotation. + triggerPhase = (Polarities::NEGATIVE == polarityConfirmed[1]) ? 1 : 2; + } + processPlusHalfCycle(phase); } else @@ -1129,10 +1198,12 @@ void processRawSamples(const uint8_t phase) // still processing samples where the voltage is POSITIVE ... // check to see whether the trigger device can now be reliably armed - if ((0 == phase) && beyondStartUpPeriod && (2 == n_samplesDuringThisMainsCycle[0])) // lower value for larger sample set + if ((0 == phase) && beyondStartUpPeriod && b_fallbackDecisionDue && (2 == n_samplesDuringThisMainsCycle[0])) // lower value for larger sample set { - // This code is executed once per 20mS, shortly after the start of each new mains cycle on phase 0. - processStartNewCycle(); + // Executed shortly after the start of a new mains cycle on phase 0, when no predictive + // decision was taken during the previous cycle. + b_fallbackDecisionDue = false; + processStartNewCycle(l_energyInBucket_main); } } else @@ -1142,6 +1213,34 @@ void processRawSamples(const uint8_t phase) { // This is the start of a new -ve half cycle (just after the zero-crossing point) processMinusHalfCycle(phase); + + if constexpr (PREDICTIVE_LOAD_SWITCHING) + { + if (beyondStartUpPeriod && (phase == triggerPhase) && !b_decisionTakenThisCycle) + { + // L1's +ve crossing is ~3.3 ms away: count down to the decision + n_samplesSinceTrigger = 0; + b_decisionArmed = true; + } + } + } + } + + if constexpr (PREDICTIVE_LOAD_SWITCHING) + { + // The count starts at the trigger crossing, so the triac drivers are armed well after + // that crossing and well before L1's +ve one - clear of both firing windows. + if (b_decisionArmed && (phase == triggerPhase) && (ARMING_DELAY_IN_SAMPLE_SETS == n_samplesSinceTrigger++)) + { + b_decisionArmed = false; + + // an unexpected sample count (start-up, missing phase): no prediction, the fallback decides + const uint8_t n{ n_samplesDuringThisMainsCycle[0] }; + if ((n >= NP_MIN) && (n <= NP_MAX)) + { + b_decisionTakenThisCycle = true; + processStartNewCycle(predictEnergyInBucket(n)); + } } } } diff --git a/Mk2_3phase_RFdatalog_temp/processing.h b/Mk2_3phase_RFdatalog_temp/processing.h index bfb75efc..c3f0af44 100644 --- a/Mk2_3phase_RFdatalog_temp/processing.h +++ b/Mk2_3phase_RFdatalog_temp/processing.h @@ -3,7 +3,7 @@ * @author Frédéric Metrich (frederic.metrich@live.fr) * @brief Public functions/variables of processing engine * @version 0.1 - * @date 2026-09-21 + * @date 2026-10-01 * * @copyright Copyright (c) 2021-2026 * @@ -32,7 +32,7 @@ void printParamsForSelectedOutputMode(); #if defined(__DOXYGEN__) void initializeProcessing(); inline void processStartUp(uint8_t phase); -inline void processStartNewCycle(); +void processStartNewCycle(int32_t l_energy); inline void processVoltageRawSample(const uint8_t phase, const uint16_t rawSample); inline void processCurrentRawSample(const uint8_t phase, const uint16_t rawSample); inline void processPlusHalfCycle(uint8_t phase); @@ -41,18 +41,20 @@ inline void processRawSamples(const uint8_t phase); inline void processVoltage(uint8_t phase); inline void processPolarity(uint8_t phase, uint16_t rawSample); inline void confirmPolarity(uint8_t phase); -inline void proceedLowEnergyLevel(); -inline void proceedHighEnergyLevel(); +inline void proceedLowEnergyLevel(int32_t l_energy); +inline void proceedHighEnergyLevel(int32_t l_energy); inline uint8_t nextLogicalLoadToBeAdded(); inline uint8_t nextLogicalLoadToBeRemoved(); inline void processLatestContribution(uint8_t phase); +inline int32_t predictEnergyInBucket(uint8_t n); inline void processDataLogging(); inline void updatePortsStates(); inline void updatePhysicalLoadStates(); #else void initializeProcessing() __attribute__((optimize("-O3"))); inline void processStartUp(uint8_t phase) __attribute__((always_inline)); -inline void processStartNewCycle() __attribute__((always_inline)); +// two call sites (predictive and fallback decisions), once per mains cycle: not inlined +void processStartNewCycle(int32_t l_energy) __attribute__((noinline)); inline void processVoltageRawSample(const uint8_t phase, const uint16_t rawSample) __attribute__((always_inline)); inline void processCurrentRawSample(const uint8_t phase, const uint16_t rawSample) __attribute__((always_inline)); inline void processPlusHalfCycle(uint8_t phase) __attribute__((always_inline)); @@ -61,11 +63,12 @@ inline void processRawSamples(const uint8_t phase) __attribute__((always_inline) inline void processVoltage(uint8_t phase) __attribute__((always_inline)); inline void processPolarity(uint8_t phase, uint16_t rawSample) __attribute__((always_inline)); inline void confirmPolarity(uint8_t phase) __attribute__((always_inline)); -inline void proceedLowEnergyLevel() __attribute__((always_inline)); -inline void proceedHighEnergyLevel() __attribute__((always_inline)); +inline void proceedLowEnergyLevel(int32_t l_energy) __attribute__((always_inline)); +inline void proceedHighEnergyLevel(int32_t l_energy) __attribute__((always_inline)); inline uint8_t nextLogicalLoadToBeAdded() __attribute__((always_inline, optimize("-O3"))); inline uint8_t nextLogicalLoadToBeRemoved() __attribute__((always_inline, optimize("-O3"))); inline void processLatestContribution(uint8_t phase) __attribute__((always_inline)); +inline int32_t predictEnergyInBucket(uint8_t n) __attribute__((always_inline)); inline void processDataLogging() __attribute__((always_inline, optimize("-O3"))); inline void updatePortsStates() __attribute__((optimize("-O3"))); inline void updatePhysicalLoadStates() __attribute__((always_inline));