My HABApp Virtual Light Sensor

I know there is an OpenHAB rule template for this, but I prefer to use HABApp Python rules whenever I can. Initially, I created a script inspired by the rule template that relied on the Astro Channel’s total radiation. However, it provided imprecise results and didn’t accurately reflect the light levels outside, especially during twilight.

Why would anyone want a virtual light sensor in the first place? For me, I couldn’t find a decent Z-Wave device rated for outdoor use. Additionally, a virtual sensor can serve as a fallback in case things go south.

For what it’s worth… Below is my code. Feel free to use it as you like.

Stripped down version of param/my_config.yml.

configuration:
  system:
    MY_LOGGER_NAME: "MyRule"

rules/light_sensor_virtual.py.

# HABApp:
#   depends on:
#    - rules/001_init.py
#    - params/my_config.yml
#   reloads on:
#    - params/my_config.yml

import logging
from pysolar.solar import get_altitude
from pysolar.radiation import get_radiation_direct
from datetime import datetime, timezone, timedelta
import warnings

# Suppress warnings from Pysolar
warnings.filterwarnings("ignore", message="I don't know about leap seconds after 2023")
warnings.filterwarnings("ignore", message="parsing timezone aware datetimes is deprecated")

from HABApp import DictParameter, Rule, CONFIG
from HABApp.core.events import ValueChangeEventFilter
from HABApp.openhab.items import NumberItem

# Load configuration parameters
configuration = DictParameter("my_config", "configuration", default_value=None).value

# Define OpenHAB items
smhi_air_pressure_item = NumberItem.get_item("Smhi_Air_Pressure")
smhi_cloud_cover_item = NumberItem.get_item("Smhi_Cloud_Cover")
virtual_outside_light_sensor_item = NumberItem.get_item("Virtual_Outside_Light_Sensor")
virtual_outside_weighted_light_sensor_item = NumberItem.get_item("Virtual_Outside_Weighted_Light_Sensor")

SIMULATION_MODE = False

class VirtualOutsideLightSensorRule(Rule):
    """
    The VirtualOutsideLightSensorRule calculates virtual outdoor light levels based on solar position, atmospheric 
    conditions, and cloud cover. It uses the Pysolar library for precise solar altitude and radiation calculations. 
    Additional logic approximates light intensity during twilight and ensures a smooth, monotonic transition both at 
    sunrise and sunset.

    The rule updates OpenHAB virtual items with calculated lux values, enabling dynamic light-level tracking for 
    automation. In SIMULATION_MODE, it cycles through a range of altitudes to test transitions and visibility of changes.
    """

    def __init__(self):
        super().__init__()
        self.last_update_time = datetime.min  # Initialize with a default value
        self.last_lux_value = None
        self.last_weighted_lux_value = None
        
        # Store last altitude and radiation to enforce monotonic changes
        self.last_altitude = None
        self.last_radiation = None

        self.log = logging.getLogger(f'{configuration["system"]["MY_LOGGER_NAME"]}.{self.rule_name}')
        self.log.setLevel(logging.DEBUG)

        self.latitude = CONFIG.location.latitude
        self.longitude = CONFIG.location.longitude
        self.altitude = CONFIG.location.elevation

        self.cloud_cover_item = smhi_cloud_cover_item

        # Listen for cloud cover changes
        self.cloud_cover_item.listen_event(self.calculate_outside_light, ValueChangeEventFilter())

        # Simulation or periodic execution
        if SIMULATION_MODE:
            self.run.soon(self.simulation_mode)  # Run once in simulation mode
        else:
            self.run.every(timedelta(seconds=10), 120, self.calculate_outside_light)

    def simulation_mode(self, event=None):
        """
        Simulation loop for testing various sun altitudes. 
        It runs through a range of altitudes from -10° to +10° in 0.5° increments, 
        then back down to -10°, simulating a full day scenario.
        """
        angles = [x * 0.5 for x in range(-20, 21)] + [x * 0.5 for x in range(20, -21, -1)]
        for simulated_altitude in angles:
            self.calculate_outside_light(event, simulated_altitude)

    def calculate_outside_light(self, event=None, simulated_altitude=None):
        """
        Calculate light levels based on solar position and cloud cover.

        If simulated_altitude is provided (in simulation mode), 
        that altitude is used instead of calculating the actual solar altitude.
        """
        if not SIMULATION_MODE:
            self.log.debug(f'[{self.rule_name}] triggered by event: {event}')

        current_time = datetime.now(timezone.utc)

        # Determine the sun altitude
        if simulated_altitude is None:
            sun_altitude = get_altitude(self.latitude, self.longitude, current_time)
        else:
            sun_altitude = simulated_altitude

        if not SIMULATION_MODE:
            self.log.debug("")
            self.log.debug("=" * 90)
            self.log.debug(f"Sun altitude: {sun_altitude:.2f}°")

        # Determine radiation level
        atmospheric_radiation = self._calculate_radiation(current_time, sun_altitude)
        if not SIMULATION_MODE:
            self.log.debug(f"Atmospheric radiation: {atmospheric_radiation:.2f} W/m²")

        # Adjust for cloud cover
        cloud_cover = self.cloud_cover_item.get_value(0) or 0
        clear_sky_index = 1.0 - 0.75 * (cloud_cover / 100.0) ** 3.4
        lux = atmospheric_radiation / 0.0079
        weighted_lux = int(lux * clear_sky_index)

        if not SIMULATION_MODE:
            self.log.debug(f"Intermediate values - Cloud cover: {cloud_cover}, Clear sky index: {clear_sky_index}")
            self.log.debug(f"Lux: {lux:.2f}, Weighted lux: {weighted_lux}")

        # Update virtual sensor values if needed
        self._update_virtual_sensors(lux, weighted_lux, sun_altitude)

    def _calculate_radiation(self, current_time, sun_altitude):
        """
        Calculate atmospheric radiation based on sun altitude, ensuring monotonic transitions.

        Logic:
        - Below -12°: Night, 0 W/m²
        - -12° to 0°: Twilight formula (linear increase from 0 at -12° to 25 W/m² at 0°)
        - 0° to 3°: Linearly blend between 25 W/m² at 0° and full direct radiation at 3°.
                     factor = altitude/3
                     radiation = 25*(1 - factor) + direct_radiation*factor
        - Above 3°: Use full direct radiation.

        After calculating the raw radiation, we enforce monotonic transitions relative to the last altitude.
        """
        if not SIMULATION_MODE:
            self.log.debug(f"Calculating radiation for altitude {sun_altitude:.2f}° ...")

        twilight_radiation_at_zero = 25.0
        direct_altitude_threshold = 0.1  # Avoid calling direct radiation at extremely low angles to prevent warnings

        if sun_altitude > 3:
            # High altitude, full direct radiation
            radiation = get_radiation_direct(current_time, max(sun_altitude, direct_altitude_threshold))
            radiation = max(radiation, 0)
            if not SIMULATION_MODE:
                self.log.debug(f"Altitude > 3°, using direct radiation only: {radiation:.2f} W/m²")

        elif 0 <= sun_altitude <= 3:
            # Blend between twilight at 0° and direct radiation at 3°
            factor = sun_altitude / 3.0
            direct_radiation = get_radiation_direct(current_time, max(sun_altitude, direct_altitude_threshold))
            direct_radiation = max(direct_radiation, 0)

            # Linear blend: at 0° = 25 W/m², at 3° = direct radiation
            radiation = (twilight_radiation_at_zero * (1 - factor)) + (direct_radiation * factor)
            if not SIMULATION_MODE:
                self.log.debug(f"0° <= Altitude <= 3°, blending twilight (25.00 W/m²) "
                               f"and direct ({direct_radiation:.2f} W/m²) factor {factor:.2f}: {radiation:.2f} W/m²")

        elif -12 <= sun_altitude < 0:
            # Twilight below horizon
            radiation = max(0, 25.0 + (sun_altitude * 4.0))
            if not SIMULATION_MODE:
                self.log.debug(f"-12° <= Altitude < 0°, twilight formula: {radiation:.2f} W/m²")

        else:
            # Night time
            radiation = 0
            if not SIMULATION_MODE:
                self.log.debug("Altitude < -12°, night time: 0 W/m²")

        # Enforce monotonic transitions based on altitude trend
        if self.last_altitude is not None and self.last_radiation is not None:
            if sun_altitude > self.last_altitude and radiation < self.last_radiation:
                # Sun is rising, radiation should not decrease
                radiation = self.last_radiation
            elif sun_altitude < self.last_altitude and radiation > self.last_radiation:
                # Sun is setting, radiation should not increase
                radiation = self.last_radiation

        self.last_altitude = sun_altitude
        self.last_radiation = radiation

        if not SIMULATION_MODE:
            self.log.debug(f"Calculated radiation: {radiation:.2f} W/m² for altitude {sun_altitude:.2f}°")

        return radiation

    def _update_virtual_sensors(self, lux, weighted_lux, sun_altitude):
        """
        Update the virtual sensor values if needed.

        Uses a 15-minute (900s) time check and checks if lux values have changed significantly 
        (using a threshold) to avoid unnecessary frequent updates.

        Also logs the simulation results for debugging.
        """
        if not SIMULATION_MODE:
            self.log.debug(f"Virtual outside light: {weighted_lux} Lux")
            self.log.debug("=" * 90)
            self.log.debug("")

        current_time = datetime.now()
        update_threshold = 1  # Only update if difference is greater than 1 lux

        # Determine if update is required
        should_update = (
            (current_time - self.last_update_time).total_seconds() > 900 or
            (self.last_lux_value is not None and abs(self.last_lux_value - lux) > update_threshold) or
            (self.last_weighted_lux_value is not None and abs(self.last_weighted_lux_value - weighted_lux) > update_threshold) or
            (self.last_lux_value is None or self.last_weighted_lux_value is None)
        )

        if SIMULATION_MODE:
            self.log.debug(f"SIMULATION_MODE - Virtual outside light @ {sun_altitude:.2f}°: {weighted_lux} Lux")
        elif should_update:
            lux_rounded = round(lux)
            virtual_outside_light_sensor_item.oh_post_update(lux_rounded)
            virtual_outside_weighted_light_sensor_item.oh_post_update(weighted_lux)
            self.last_update_time = current_time
            self.last_lux_value = lux
            self.last_weighted_lux_value = weighted_lux

VirtualOutsideLightSensorRule()

EDIT REASON: Improved script again
EDIT REASON 2: Improved script again. Now using pysolar lib.

3 Likes

I updated the script in the initial post. Improved logic and added location info to config file.

I just updated the script agan

This topic was automatically closed 41 days after the last reply. New replies are no longer allowed.