Advanced PV Optimization: Estimating Hidden Power Potential

Hi everyone! :sunny:

I’d like to share a solution I’ve been working on to optimize self-consumption. It’s designed for those who want to know exactly how much solar power is available “under the hood” when the system is not running at full capacity.

:mag: The Problem: How much power is “going to waste”?

Most of us base our automations on current production. However, when your battery is full (SoC = 100%) or grid export is limited (or disabled :trophy:), the inverter deliberately moves away from the Maximum Power Point (MPPT).

As a result, your production graph drops, and the string voltage rises toward the Open Circuit Voltage (Voc). A standard sensor might show only 200W of self-consumption even though the sun is blazing and you could be running your AC for free. Without an expensive pyranometer (solar radiation sensor), it is difficult to estimate this “hidden” potential.

:brain: The Solution: Dynamic Mathematical Model (Beta)

I’ve developed an algorithm that analyzes the electrical parameters of your strings and environmental conditions to estimate the real available surplus in real-time.

What does the code take into account?

  1. Cell Physics & Temperature: Panel efficiency drops as they heat up. The code applies a temperature coefficient correction, distinguishing between ground-mounted (better airflow) and roof-mounted panels.
  2. Air Mass Correction: It accounts for the sun’s elevation—at low angles, the atmosphere attenuates radiation more significantly. The code calculates a real-time AM factor.
  3. I-V Curve Modeling: It uses a simplified quadratic model to estimate how much power the inverter is “wasting” based on the voltage rise relative to the optimal Vmp.

:white_check_mark: Prerequisites

Before implementation, ensure your system integrates the following data:

  • DC Voltage (V) and Power (W) sensors per string (provided by your inverter).
  • Sun Position (e.g., solar elevation from the Sunlight Visualizer or the native Sun integration).
  • Weather Data (Outdoor temperature and cloud coverage in %).
  • Battery SoC (if you have energy storage).

:satellite: Data Sources & Sensors

In my setup, I use these integrations:

  • Sunlight Visualizer – for precise sun tracking.
  • Weather Provider (e.g., IMGW, OpenWeatherMap, or AccuWeather) – for temperature and cloud data.

You can, of course, use your own sensors (e.g., a local Zigbee/WiFi weather station) as long as they provide temperature and cloud/solar data.


:hammer_and_wrench: Implementation: Home Assistant (Jinja2)

You can add this as a Template Sensor in your configuration.yaml. While adding it via the UI (Helpers → Template → Sensor) is possible, configuration.yaml is recommended for better readability of complex logic.

Pro Tip: Enter Vmp and Voc values for a single module—the code automatically multiplies them by the number of panels. You can find these values on the sticker on the back of your panels or in the datasheet.

Click to see the Jinja2 Template Sensor
template:
  - sensor:
      - name: "PV Available Surplus (Estimation)"
        unique_id: pv_potential_universal_v1
        unit_of_measurement: "W"
        device_class: power
        state: >
          {# --- 1. INPUTS (Replace with your entities) --- #}
          {% set s_elev = states('sensor.sun_solar_elevation') | float(0) %}
          {% set s_temp = states('sensor.weather_temperature') | float(15) %}
          {% set s_clouds = states('sensor.weather_cloud_coverage') | float(0) %}
          {% set s_soc = states('sensor.battery_soc') | float(100) %}
          
          {# --- 2. PANEL DATA (From single module datasheet) --- #}
          {% set mppt_strings = [
            {
              'name': 'South Ground',
              'v_now': states('sensor.pv_voltage_1') | float(0),
              'p_now': states('sensor.pv_power_1') | float(0),
              'vmp_stc': 31.18,
              'voc_stc': 37.15,
              'panel_count': 12,
              'mount_type': 'ground'
            },
            {
              'name': 'West Roof',
              'v_now': states('sensor.pv_voltage_2') | float(0),
              'p_now': states('sensor.pv_power_2') | float(0),
              'vmp_stc': 30.50,
              'voc_stc': 37.05,
              'panel_count': 6,
              'mount_type': 'roof'
            }
          ] %}

          {# --- 3. LOGIC & BUFFERS --- #}
          {% set t_coeff = -0.0027 %} {# Pmax temp coefficient #}
          {% set inv_eff = 0.96 %}
          {% set buffer = 150 if s_soc < 95 else 50 %}

          {% if s_elev < 2 %} 0
          {% else %}
            {% set am_factor = [0.85, (s_elev / 20)] | min if s_elev < 20 else 1.0 %}
            {% set ns = namespace(total_pot=0, current_p=0) %}
            
            {% for s in mppt_strings %}
              {# Cell temp model: +18K for ground, +28K for roof vs ambient #}
              {% set t_cell = s_temp + (18 if s.mount_type == 'ground' else 28) * (1 - s_clouds/100) %}
              {% set vmp_t = s.panel_count * s.vmp_stc * (1 + t_coeff * (t_cell - 25)) %}
              {% set voc_t = s.panel_count * s.voc_stc * (1 + t_coeff * (t_cell - 25)) %}
              
              {# Estimating hidden power based on DC voltage #}
              {% set p_pot = (s.p_now / (1 - ([(s.v_now - vmp_t) / ([1, voc_t - vmp_t]|max), 0.93]|min)**2)) if s.v_now > vmp_t + 2 else s.p_now %}
              
              {% set ns.total_pot = ns.total_pot + p_pot %}
              {% set ns.current_p = ns.current_p + s.p_now %}
            {% endfor %}

            {% set result = (ns.total_pot * inv_eff * am_factor) - ns.current_p - buffer %}
            {{ [0, result] | max | round(0) }}
          {% endif %}

:computer: Alternative: JavaScript (Node-RED / Logic)

For those who prefer JS-based logic, here is the function for your flows:

Click to see the JS code
function calculatePVSurplus(weather, mpptStrings) {
    if (weather.elev < 2) return 0;
    
    const tCoeff = -0.0027;
    const invEff = 0.96;
    const amFactor = weather.elev < 20 ? Math.min(0.85, weather.elev / 20) : 1.0;
    
    let totalPot = 0;
    let currentP = 0;

    mpptStrings.forEach(s => {
        const tCell = weather.temp + (s.type === 'ground' ? 18 : 28) * (1 - weather.clouds / 100);
        const vmpT = s.count * s.vmpStc * (1 + tCoeff * (tCell - 25));
        const vocT = s.count * s.vocStc * (1 + tCoeff * (tCell - 25));

        let pPot = s.pNow;
        if (s.vNow > vmpT + 2) {
            const ratio = Math.min((s.vNow - vmpT) / Math.max(1, vocT - vmpT), 0.93);
            pPot = s.pNow / (1 - Math.pow(ratio, 2));
        }
        totalPot += pPot;
        currentP += s.pNow;
    });

    const buffer = weather.soc < 95 ? 150 : 50;
    return Math.round(Math.max(0, (totalPot * invEff * amFactor) - currentP - buffer));
}

:chart_with_upwards_trend: Calibration and Data Precision

:warning: Important Note: The accuracy of this sensor depends entirely on the quality of the input data. Cloud coverage from weather providers or solar position estimates are approximations—a cloud over a weather station miles away doesn’t always mean a cloud over your roof. Discrepancies of 100-200W are normal and expected due to weather system latency.

How to verify if it’s working?

  1. On a sunny day with a full battery, note the “Available Surplus” value.
  2. Turn on a heavy load (e.g., a 2kW electric kettle).
  3. The sensor value should drop by approximately 2000W (close to zero). If it remains high or drops deep into negative values, adjust the efficiency coefficients or buffers in the code.

:warning: Practical Tips

  1. Hysteresis: If you plan to use this sensor to trigger devices, add a time condition in your automation (e.g., “surplus above 1000W for at least 2 minutes”). This prevents your relays from “chattering” during fast-moving clouds.
  2. Beta Status: This algorithm is currently in the testing phase. Results may vary depending on panel technology (e.g., Bifacial modules).

I hope you find this useful for your energy management. Let me know how it works for you! :sun_with_face: :battery: