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.