diff --git a/errors/evse_manager.yaml b/errors/evse_manager.yaml index 8a0140709b..9b7389a14f 100644 --- a/errors/evse_manager.yaml +++ b/errors/evse_manager.yaml @@ -21,3 +21,5 @@ errors: description: An Isolation Monitoring Device tripped due to low resistance to the chassis during active charging. - name: MREC11CableCheckFault description: Cable check failed. Isolation monitor self test failed before charging. + - name: VoltagePlausibilityFault + description: Voltage plausibility check failed. Standard deviation between voltage measurements from different sources exceeded threshold for configured duration. diff --git a/modules/EVSE/EvseManager/BUILD.bazel b/modules/EVSE/EvseManager/BUILD.bazel index 311f32734c..5b07e0db4d 100644 --- a/modules/EVSE/EvseManager/BUILD.bazel +++ b/modules/EVSE/EvseManager/BUILD.bazel @@ -6,7 +6,8 @@ IMPLS = [ "token_provider", "random_delay", "dc_external_derate", - "over_voltage" + "over_voltage", + "voltage_plausibility" ] cc_everest_module( diff --git a/modules/EVSE/EvseManager/CMakeLists.txt b/modules/EVSE/EvseManager/CMakeLists.txt index d10fe37294..33a80aa436 100644 --- a/modules/EVSE/EvseManager/CMakeLists.txt +++ b/modules/EVSE/EvseManager/CMakeLists.txt @@ -57,6 +57,7 @@ target_sources(${MODULE_NAME} "random_delay/uk_random_delayImpl.cpp" "dc_external_derate/dc_external_derateImpl.cpp" "over_voltage/OverVoltageMonitor.cpp" + "voltage_plausibility/VoltagePlausibilityMonitor.cpp" ) # ev@c55432ab-152c-45a9-9d2e-7281d50c69c3:v1 diff --git a/modules/EVSE/EvseManager/ErrorHandling.cpp b/modules/EVSE/EvseManager/ErrorHandling.cpp index 0f77c141b6..96b4b24dc6 100644 --- a/modules/EVSE/EvseManager/ErrorHandling.cpp +++ b/modules/EVSE/EvseManager/ErrorHandling.cpp @@ -345,4 +345,21 @@ void ErrorHandling::clear_cable_check_fault() { } } +void ErrorHandling::raise_voltage_plausibility_fault(const std::string& description) { + // raise externally + // High severity as this indicates a serious measurement inconsistency + Everest::error::Error error_object = p_evse->error_factory->create_error( + "evse_manager/VoltagePlausibilityFault", "", description, Everest::error::Severity::High); + p_evse->raise_error(error_object); + process_error(); +} + +void ErrorHandling::clear_voltage_plausibility_fault() { + // clear externally + if (p_evse->error_state_monitor->is_error_active("evse_manager/VoltagePlausibilityFault", "")) { + p_evse->clear_error("evse_manager/VoltagePlausibilityFault"); + process_error(); + } +} + } // namespace module diff --git a/modules/EVSE/EvseManager/ErrorHandling.hpp b/modules/EVSE/EvseManager/ErrorHandling.hpp index 7f1c5b3ebd..ee0e367fb5 100644 --- a/modules/EVSE/EvseManager/ErrorHandling.hpp +++ b/modules/EVSE/EvseManager/ErrorHandling.hpp @@ -96,6 +96,9 @@ class ErrorHandling { void raise_cable_check_fault(const std::string& description); void clear_cable_check_fault(); + void raise_voltage_plausibility_fault(const std::string& description); + void clear_voltage_plausibility_fault(); + protected: void raise_inoperative_error(const Everest::error::Error& caused_by); diff --git a/modules/EVSE/EvseManager/EvseManager.cpp b/modules/EVSE/EvseManager/EvseManager.cpp index 3cbc34808c..579e3e23bb 100644 --- a/modules/EVSE/EvseManager/EvseManager.cpp +++ b/modules/EVSE/EvseManager/EvseManager.cpp @@ -223,6 +223,17 @@ void EvseManager::ready() { charger = std::make_unique(bsp, error_handling, r_powermeter_billing(), store, hw_capabilities.connector_type, config.evse_id); + // Create voltage plausibility monitor for DC charging + voltage_plausibility_monitor = std::make_unique( + [this](const std::string& description) { + if (this->error_handling) { + this->error_handling->raise_voltage_plausibility_fault(description); + } + }, + config.voltage_plausibility_std_deviation_threshold_V, + std::chrono::milliseconds(config.voltage_plausibility_fault_duration_ms), + std::chrono::milliseconds(config.voltage_plausibility_measurement_max_age_ms)); + // Now incoming hardware capabilties can be processed hw_caps_mutex.unlock(); @@ -471,6 +482,10 @@ void EvseManager::ready() { internal_over_voltage_monitor->reset(); internal_over_voltage_monitor->start_monitor(); } + if (voltage_plausibility_monitor) { + voltage_plausibility_monitor->reset(); + voltage_plausibility_monitor->start_monitor(); + } }); r_hlc[0]->subscribe_current_demand_finished([this] { @@ -479,6 +494,9 @@ void EvseManager::ready() { if (not r_over_voltage_monitor.empty()) { r_over_voltage_monitor[0]->call_stop(); } + if (voltage_plausibility_monitor) { + voltage_plausibility_monitor->stop_monitor(); + } if (internal_over_voltage_monitor) { internal_over_voltage_monitor->stop_monitor(); } @@ -501,6 +519,9 @@ void EvseManager::ready() { r_imd[0]->subscribe_isolation_measurement([this](types::isolation_monitor::IsolationMeasurement m) { // new DC isolation monitoring measurement received + if (voltage_plausibility_monitor && m.voltage_V.has_value()) { + voltage_plausibility_monitor->update_isolation_monitor_voltage(m.voltage_V.value()); + } // Check for isolation errors if (charger->get_current_state() == Charger::EvseState::Charging and @@ -568,6 +589,9 @@ void EvseManager::ready() { if (not r_powersupply_DC.empty()) { r_powersupply_DC[0]->subscribe_voltage_current([this](types::power_supply_DC::VoltageCurrent m) { powersupply_measurement = m; + if (voltage_plausibility_monitor) { + voltage_plausibility_monitor->update_power_supply_voltage(m.voltage_V); + } types::iso15118::DcEvsePresentVoltageCurrent present_values; present_values.evse_present_voltage = (m.voltage_V > 0 ? m.voltage_V : 0.0); present_values.evse_present_current = m.current_A; @@ -794,6 +818,12 @@ void EvseManager::ready() { if (not r_over_voltage_monitor.empty()) { r_over_voltage_monitor[0]->call_set_limits(get_emergency_over_voltage_threshold(), get_error_over_voltage_threshold()); + // Subscribe to voltage measurements from over_voltage_monitor for plausibility check + r_over_voltage_monitor[0]->subscribe_voltage_measurement_V([this](float voltage_V) { + if (voltage_plausibility_monitor) { + voltage_plausibility_monitor->update_over_voltage_monitor_voltage(voltage_V); + } + }); } if (internal_over_voltage_monitor) { internal_over_voltage_monitor->set_limits(get_emergency_over_voltage_threshold(), @@ -1072,6 +1102,10 @@ void EvseManager::ready() { if (r_powermeter_billing().size() > 0) { r_powermeter_billing()[0]->subscribe_powermeter([this](types::powermeter::Powermeter p) { + // Update voltage plausibility monitor with powermeter voltage + if (voltage_plausibility_monitor && p.voltage_V.has_value() && p.voltage_V.value().DC.has_value()) { + voltage_plausibility_monitor->update_powermeter_voltage(p.voltage_V.value().DC.value()); + } // Inform charger about current charging current. This is used for slow OC detection. if (p.current_A and p.current_A.value().L1 and p.current_A.value().L2 and p.current_A.value().L3) { charger->set_current_drawn_by_vehicle(p.current_A.value().L1.value(), p.current_A.value().L2.value(), diff --git a/modules/EVSE/EvseManager/EvseManager.hpp b/modules/EVSE/EvseManager/EvseManager.hpp index 7d4cbe38dc..418ac00b62 100644 --- a/modules/EVSE/EvseManager/EvseManager.hpp +++ b/modules/EVSE/EvseManager/EvseManager.hpp @@ -49,6 +49,7 @@ #include "VarContainer.hpp" #include "over_voltage/OverVoltageMonitor.hpp" #include "scoped_lock_timeout.hpp" +#include "voltage_plausibility/VoltagePlausibilityMonitor.hpp" // ev@4bf81b14-a215-475c-a1d3-0a484ae48918:v1 namespace module { @@ -119,6 +120,9 @@ struct Conf { std::string bpt_channel; std::string bpt_generator_mode; std::string bpt_grid_code_island_method; + double voltage_plausibility_std_deviation_threshold_V; + int voltage_plausibility_fault_duration_ms; + int voltage_plausibility_measurement_max_age_ms; }; class EvseManager : public Everest::ModuleBase { @@ -346,6 +350,9 @@ class EvseManager : public Everest::ModuleBase { int32_t reservation_id; Everest::timed_mutex_traceable reservation_mutex; + // Voltage plausibility monitor + std::unique_ptr voltage_plausibility_monitor; + void setup_AC_mode(); void setup_fake_DC_mode(); diff --git a/modules/EVSE/EvseManager/manifest.yaml b/modules/EVSE/EvseManager/manifest.yaml index 771335a0d1..82d76e8ac2 100644 --- a/modules/EVSE/EvseManager/manifest.yaml +++ b/modules/EVSE/EvseManager/manifest.yaml @@ -337,6 +337,28 @@ config: When raising evse_manager/Inoperative use the vendor ID from the original cause type: boolean default: false + voltage_plausibility_std_deviation_threshold_V: + description: >- + Maximum allowed standard deviation in volts between voltage measurements from different + sources (power supply, powermeter, isolation monitor, over voltage monitor) before a + voltage plausibility fault is triggered. During DC charging, if the standard deviation + exceeds this threshold for the configured duration, a fault is raised. + type: number + default: 50.0 + voltage_plausibility_fault_duration_ms: + description: >- + Duration in milliseconds for which the standard deviation must exceed the threshold + before a voltage plausibility fault is raised. A duration of 0 ms means that a fault + will be raised immediately when the threshold is exceeded. + type: integer + default: 10000 + voltage_plausibility_measurement_max_age_ms: + description: >- + Maximum age in milliseconds of a voltage measurement before it is considered stale + and excluded from standard deviation calculation. Measurements older than this value + are not used in the plausibility check. + type: integer + default: 3000 session_id_type: description: >- Type to use for generation of session ids. diff --git a/modules/EVSE/EvseManager/voltage_plausibility/VoltagePlausibilityMonitor.cpp b/modules/EVSE/EvseManager/voltage_plausibility/VoltagePlausibilityMonitor.cpp new file mode 100644 index 0000000000..25eeadb4d7 --- /dev/null +++ b/modules/EVSE/EvseManager/voltage_plausibility/VoltagePlausibilityMonitor.cpp @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Pionix GmbH and Contributors to EVerest + +#include "voltage_plausibility/VoltagePlausibilityMonitor.hpp" + +#include +#include +#include +#include +#include +#include + +namespace module { + +VoltagePlausibilityMonitor::VoltagePlausibilityMonitor(ErrorCallback callback, double std_deviation_threshold_V, + std::chrono::milliseconds fault_duration, + std::chrono::milliseconds measurement_max_age) : + error_callback_(std::move(callback)), + std_deviation_threshold_V_(std_deviation_threshold_V), + fault_duration_(fault_duration), + measurement_max_age_(measurement_max_age) { + timer_thread_ = std::thread(&VoltagePlausibilityMonitor::timer_thread_func, this); +} + +VoltagePlausibilityMonitor::~VoltagePlausibilityMonitor() { + { + std::lock_guard lock(timer_mutex_); + timer_thread_exit_ = true; + timer_armed_ = false; + } + timer_cv_.notify_one(); + if (timer_thread_.joinable()) { + timer_thread_.join(); + } +} + +void VoltagePlausibilityMonitor::start_monitor() { + fault_latched_ = false; + cancel_fault_timer(); + running_ = true; +} + +void VoltagePlausibilityMonitor::stop_monitor() { + running_ = false; + cancel_fault_timer(); +} + +void VoltagePlausibilityMonitor::reset() { + fault_latched_ = false; + cancel_fault_timer(); +} + +void VoltagePlausibilityMonitor::update_power_supply_voltage(double voltage_V) { + std::lock_guard lock(data_mutex_); + power_supply_sample_.voltage_V = voltage_V; + power_supply_sample_.timestamp = std::chrono::steady_clock::now(); + evaluate_voltages(); +} + +void VoltagePlausibilityMonitor::update_powermeter_voltage(double voltage_V) { + std::lock_guard lock(data_mutex_); + powermeter_sample_.voltage_V = voltage_V; + powermeter_sample_.timestamp = std::chrono::steady_clock::now(); + evaluate_voltages(); +} + +void VoltagePlausibilityMonitor::update_isolation_monitor_voltage(double voltage_V) { + std::lock_guard lock(data_mutex_); + isolation_monitor_sample_.voltage_V = voltage_V; + isolation_monitor_sample_.timestamp = std::chrono::steady_clock::now(); + evaluate_voltages(); +} + +void VoltagePlausibilityMonitor::update_over_voltage_monitor_voltage(double voltage_V) { + std::lock_guard lock(data_mutex_); + over_voltage_monitor_sample_.voltage_V = voltage_V; + over_voltage_monitor_sample_.timestamp = std::chrono::steady_clock::now(); + evaluate_voltages(); +} + +void VoltagePlausibilityMonitor::evaluate_voltages() { + // Check running state and fault latch (these are only modified from main thread, so no mutex needed here) + if (!running_ || fault_latched_) { + return; + } + + const auto now = std::chrono::steady_clock::now(); + std::vector valid_voltages; + const auto zero_time = std::chrono::steady_clock::time_point{}; + + // Collect valid voltage samples (not older than max_age) + // A timestamp of zero (default-initialized) means we've never received a value + if (power_supply_sample_.timestamp != zero_time) { + const auto age = std::chrono::duration_cast(now - power_supply_sample_.timestamp); + if (age <= measurement_max_age_) { + valid_voltages.push_back(power_supply_sample_.voltage_V); + } + } + + if (powermeter_sample_.timestamp != zero_time) { + const auto age = std::chrono::duration_cast(now - powermeter_sample_.timestamp); + if (age <= measurement_max_age_) { + valid_voltages.push_back(powermeter_sample_.voltage_V); + } + } + + if (isolation_monitor_sample_.timestamp != zero_time) { + const auto age = + std::chrono::duration_cast(now - isolation_monitor_sample_.timestamp); + if (age <= measurement_max_age_) { + valid_voltages.push_back(isolation_monitor_sample_.voltage_V); + } + } + + if (over_voltage_monitor_sample_.timestamp != zero_time) { + const auto age = + std::chrono::duration_cast(now - over_voltage_monitor_sample_.timestamp); + if (age <= measurement_max_age_) { + valid_voltages.push_back(over_voltage_monitor_sample_.voltage_V); + } + } + + // Need at least 2 valid sources to compute standard deviation + if (valid_voltages.size() < 2) { + cancel_fault_timer(); + return; + } + + // Calculate standard deviation + const double std_deviation = calculate_standard_deviation(valid_voltages); + + // Check against threshold + if (std_deviation > std_deviation_threshold_V_) { + arm_fault_timer(std_deviation); + } else { + cancel_fault_timer(); + } +} + +double VoltagePlausibilityMonitor::calculate_standard_deviation(const std::vector& values) { + if (values.size() < 2) { + return 0.0; + } + + // Calculate mean + double sum = 0.0; + for (const auto& v : values) { + sum += v; + } + const double mean = sum / values.size(); + + // Calculate variance using Welford's algorithm for numerical stability + double variance = 0.0; + for (const auto& v : values) { + const double diff = v - mean; + variance += diff * diff; + } + variance /= values.size(); + + // Return standard deviation + return std::sqrt(variance); +} + +void VoltagePlausibilityMonitor::trigger_fault(const std::string& reason) { + fault_latched_ = true; + running_ = false; + cancel_fault_timer(); + if (error_callback_) { + error_callback_(reason); + } +} + +void VoltagePlausibilityMonitor::arm_fault_timer(double std_deviation_V) { + if (fault_duration_.count() == 0) { + trigger_fault(fmt::format("Voltage standard deviation {:.2f} V exceeded threshold {:.2f} V.", std_deviation_V, + std_deviation_threshold_V_)); + return; + } + + { + std::lock_guard lock(timer_mutex_); + if (timer_armed_) { + timer_std_deviation_snapshot_ = std::max(timer_std_deviation_snapshot_, std_deviation_V); + return; + } + timer_armed_ = true; + timer_std_deviation_snapshot_ = std_deviation_V; + timer_deadline_ = std::chrono::steady_clock::now() + fault_duration_; + } + timer_cv_.notify_one(); +} + +void VoltagePlausibilityMonitor::cancel_fault_timer() { + { + std::lock_guard lock(timer_mutex_); + if (!timer_armed_) { + return; + } + timer_armed_ = false; + } + timer_cv_.notify_one(); +} + +void VoltagePlausibilityMonitor::timer_thread_func() { + std::unique_lock lock(timer_mutex_); + + while (!timer_thread_exit_) { + // Wait until a timer is armed or exit is requested + timer_cv_.wait(lock, [this] { return timer_thread_exit_ || timer_armed_; }); + if (timer_thread_exit_) { + break; + } + + // Capture the current deadline and wait until it expires or is cancelled/updated + auto deadline = timer_deadline_; + while (!timer_thread_exit_ && timer_armed_) { + if (timer_cv_.wait_until(lock, deadline) == std::cv_status::timeout) { + break; + } + // Woken up: check for exit, cancellation or re-arming with a new deadline + if (timer_thread_exit_ || !timer_armed_ || timer_deadline_ != deadline) { + break; + } + } + + if (timer_thread_exit_) { + break; + } + if (!timer_armed_ || timer_deadline_ != deadline) { + // Timer was cancelled or re-armed; go back to waiting + continue; + } + + // Timer expired with this deadline and is still armed + const double std_deviation = timer_std_deviation_snapshot_; + timer_armed_ = false; + + // Release the lock while invoking the callback path + lock.unlock(); + trigger_fault(fmt::format("Voltage standard deviation {:.2f} V exceeded threshold {:.2f} V for at least {} ms.", + std_deviation, std_deviation_threshold_V_, fault_duration_.count())); + lock.lock(); + } +} + +} // namespace module diff --git a/modules/EVSE/EvseManager/voltage_plausibility/VoltagePlausibilityMonitor.hpp b/modules/EVSE/EvseManager/voltage_plausibility/VoltagePlausibilityMonitor.hpp new file mode 100644 index 0000000000..60daeb22f9 --- /dev/null +++ b/modules/EVSE/EvseManager/voltage_plausibility/VoltagePlausibilityMonitor.hpp @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Pionix GmbH and Contributors to EVerest + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace module { + +/// \brief Voltage plausibility monitor used by EvseManager during DC charging. +/// +/// The monitor compares voltage measurements from multiple sources (power supply, powermeter, +/// isolation monitor, over voltage monitor) and checks that they report similar values. If the +/// standard deviation between available sources exceeds a configured threshold for a given +/// duration, a fault is raised. +/// +/// Thread-safety: +/// - Public APIs are intended to be called from EvseManager threads and from callbacks of the +/// external voltage source interfaces. +/// - Internally, a dedicated background thread waits on a fault timer condition and synchronizes +/// access to timer-related state via an internal mutex and condition variable. +class VoltagePlausibilityMonitor { +public: + /// \brief Callback type used to report detected faults. + /// + /// The callback is invoked from an internal monitoring context when a fault is detected. + /// It receives a human-readable description of the fault. + using ErrorCallback = std::function; + + /// \brief Construct a new VoltagePlausibilityMonitor. + /// + /// \param callback Function that will be called whenever a fault is detected. + /// \param std_deviation_threshold_V Maximum allowed standard deviation in volts between + /// voltage sources before a fault is triggered. + /// \param fault_duration Duration for which the standard deviation must exceed the threshold + /// before a fault is raised. A duration of 0 ms means that a fault will + /// be raised immediately when the threshold is exceeded. + /// \param measurement_max_age Maximum age of a voltage measurement before it is considered + /// stale and excluded from standard deviation calculation. + VoltagePlausibilityMonitor(ErrorCallback callback, double std_deviation_threshold_V, + std::chrono::milliseconds fault_duration, std::chrono::milliseconds measurement_max_age); + + /// \brief Destructor joins the internal timer thread before destroying the object. + ~VoltagePlausibilityMonitor(); + + /// \brief Start monitoring of incoming voltage samples. + /// + /// Clears any latched fault state and cancels a pending fault timer, then enables + /// evaluation of voltage samples. + void start_monitor(); + + /// \brief Stop monitoring of incoming voltage samples. + /// + /// Monitoring is disabled and any pending fault timer is cancelled. Existing latched + /// faults remain active until \ref reset() is called. + void stop_monitor(); + + /// \brief Feed a new voltage sample from the power supply. + /// + /// \param voltage_V Measured DC voltage in volts. + void update_power_supply_voltage(double voltage_V); + + /// \brief Feed a new voltage sample from the powermeter. + /// + /// \param voltage_V Measured DC voltage in volts. + void update_powermeter_voltage(double voltage_V); + + /// \brief Feed a new voltage sample from the isolation monitor. + /// + /// \param voltage_V Measured DC voltage in volts. + void update_isolation_monitor_voltage(double voltage_V); + + /// \brief Feed a new voltage sample from the over voltage monitor. + /// + /// \param voltage_V Measured DC voltage in volts. + void update_over_voltage_monitor_voltage(double voltage_V); + + /// \brief Reset the internal fault latch and cancel timers. + /// + /// This clears any previously raised fault and stops the internal fault timer. Monitoring + /// remains disabled until \ref start_monitor() is called again. + void reset(); + +private: + struct VoltageSample { + double voltage_V; + std::chrono::steady_clock::time_point timestamp; + }; + + void timer_thread_func(); + void trigger_fault(const std::string& reason); + void arm_fault_timer(double std_deviation_V); + void cancel_fault_timer(); + void evaluate_voltages(); + double calculate_standard_deviation(const std::vector& values); + + ErrorCallback error_callback_; + double std_deviation_threshold_V_; + std::chrono::milliseconds fault_duration_; + std::chrono::milliseconds measurement_max_age_; + bool running_{false}; + bool fault_latched_{false}; + + std::mutex data_mutex_; + VoltageSample power_supply_sample_; + VoltageSample powermeter_sample_; + VoltageSample isolation_monitor_sample_; + VoltageSample over_voltage_monitor_sample_; + + std::mutex timer_mutex_; + double timer_std_deviation_snapshot_{0.0}; + std::chrono::steady_clock::time_point timer_deadline_{}; + bool timer_armed_{false}; + bool timer_thread_exit_{false}; + std::condition_variable timer_cv_; + std::thread timer_thread_; +}; + +} // namespace module diff --git a/modules/EVSE/OCPP/error_mapping.hpp b/modules/EVSE/OCPP/error_mapping.hpp index fd37a96c74..9de42dcc1b 100644 --- a/modules/EVSE/OCPP/error_mapping.hpp +++ b/modules/EVSE/OCPP/error_mapping.hpp @@ -36,6 +36,7 @@ const std::unordered_map MREC_ERROR_MAP = { {"evse_manager/MREC22ResistanceFault", "CX022"}, {"evse_manager/MREC11CableCheckFault", "CX011"}, {"evse_manager/MREC5OverVoltage", "CX005"}, + {"evse_manager/VoltagePlausibilityFault", ""}, }; const auto EVSE_MANAGER_INOPERATIVE_ERROR = "evse_manager/Inoperative";