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:
# - params/my_config.yml
# reloads on:
# - params/my_config.yml
import logging
import math
from datetime import datetime, timedelta, timezone
from HABApp import DictParameter, Rule, CONFIG
from HABApp.core.events import ValueChangeEventFilter
from HABApp.openhab.items import NumberItem
# Get configuration parameters stored in the my_config.yml file
configuration = DictParameter("my_config", "configuration", default_value=None).value
# Define OpenHAB items (sorted alphabetically)
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")
class VirtualOutsideLightSensorRule(Rule):
"""
VirtualOutsideLightSensorRule replicates the logic of an original Lua script
designed to calculate outdoor light levels based on latitude, longitude,
altitude, and cloud cover.
This Python script is intended to produce the same results as the original Lua script
given the same inputs. The original script can be found at:
https://www.domoticz.com/wiki/Lua_dzVents_-_Solar_Data:_Azimuth_Altitude_Lux
"""
def __init__(self):
super().__init__()
self.log = logging.getLogger(f'{configuration["system"]["MY_LOGGER_NAME"]}.{self.rule_name}')
self.log.setLevel(logging.INFO)
self.latitude = CONFIG.location.latitude
self.longitude = CONFIG.location.longitude
self.altitude = CONFIG.location.elevation
self.cloudiness_in_percentage = True # False if using okta value, True for cloudiness percentage
self.air_pressure_item = smhi_air_pressure_item
self.cloud_cover_item = smhi_cloud_cover_item
# Mapping table for cloudiness % to okta
self.okta_table = [
(0, 0), (18.75, 1), (31.25, 2), (43.75, 3), (56.25, 4),
(68.75, 5), (81.25, 6), (93.75, 7), (100, 8)
]
# Listen to item state changes
self.air_pressure_item.listen_event(self.calculate_outside_light, ValueChangeEventFilter())
self.cloud_cover_item.listen_event(self.calculate_outside_light, ValueChangeEventFilter())
self.run.every(timedelta(seconds=10), 300, self.calculate_outside_light)
@staticmethod
def is_leap_year(year):
"""Check if the given year is a leap year."""
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
def calculate_outside_light(self, event=None):
"""Calculate the outside light level based on various factors."""
self.log.debug(f'[{self.rule_name}] was triggered by: [{event.name if event else "None"}] with event value [{event.value if event else "None"}]')
self.log.debug("=" * 90)
current_time = datetime.now(timezone.utc)
day_of_year = current_time.timetuple().tm_yday
days_in_year = 366 if self.is_leap_year(current_time.year) else 365
solar_declination = self._calculate_solar_declination(day_of_year)
hourly_angle = self._calculate_hourly_angle(current_time)
sun_altitude = self._calculate_sun_altitude(solar_declination, hourly_angle)
atmospheric_radiation = self._calculate_atmospheric_radiation(day_of_year, days_in_year)
cloud_cover_okta = self._calculate_cloud_cover_okta(self.cloud_cover_item.get_value(0))
clear_sky_index = self._calculate_clear_sky_index(cloud_cover_okta)
air_pressure = self.air_pressure_item.get_value(1013.25)
adjusted_pressure = air_pressure - (self.altitude / 8.3)
if adjusted_pressure <= 0:
adjusted_pressure = air_pressure # Avoid division by zero or negative values
relative_airmass = self._calculate_relative_airmass(sun_altitude)
mitigation_coefficient = relative_airmass * air_pressure / adjusted_pressure
lux, weighted_lux = self._calculate_lux_and_weighted_lux(
sun_altitude, atmospheric_radiation, mitigation_coefficient, clear_sky_index
)
self._update_virtual_sensors(lux, weighted_lux)
def _calculate_solar_declination(self, day_of_year):
"""Calculate the solar declination."""
angular_speed = 360 / 365.25
solar_declination = math.degrees(math.asin(0.3978 * math.sin(
math.radians(angular_speed) * (day_of_year - (81 - 2 * math.sin(math.radians(angular_speed) * (day_of_year - 2))))
)))
self.log.debug(f"Solar declination: {solar_declination:.2f} degrees")
return solar_declination
def _calculate_hourly_angle(self, current_time):
"""Calculate the hourly angle."""
time_decimal = current_time.hour + current_time.minute / 60
solar_hour = time_decimal + (4 * self.longitude / 60)
hourly_angle = 15 * (12 - solar_hour)
self.log.debug(f"Time decimal: {time_decimal:.2f}, Solar hour: {solar_hour:.2f}, Hourly angle: {hourly_angle:.2f} degrees")
return hourly_angle
def _calculate_sun_altitude(self, solar_declination, hourly_angle):
"""Calculate the sun altitude."""
sun_altitude = math.degrees(math.asin(
math.sin(math.radians(self.latitude)) * math.sin(math.radians(solar_declination)) +
math.cos(math.radians(self.latitude)) * math.cos(math.radians(solar_declination)) *
math.cos(math.radians(hourly_angle))
))
self.log.debug(f"Sun altitude: {sun_altitude:.2f} degrees")
return sun_altitude
def _calculate_atmospheric_radiation(self, day_of_year, days_in_year):
"""Calculate the atmospheric radiation."""
solar_constant = 1361
atmospheric_radiation = solar_constant * (1 + 0.034 * math.cos(math.radians(360 * day_of_year / days_in_year)))
self.log.debug(f"Radiation at the atmosphere: {atmospheric_radiation:.2f} W/m^2")
return atmospheric_radiation
def _calculate_cloud_cover_okta(self, cloud_cover):
"""Calculate the okta from cloud cover percentage."""
cloud_cover_percentage = float(cloud_cover) if cloud_cover is not None else 0
self.log.debug(f"Cloud cover percentage: {cloud_cover_percentage}%")
cloud_cover_okta = 0
for element in self.okta_table:
if cloud_cover_percentage >= element[0]:
cloud_cover_okta = element[1]
self.log.debug(f"Calculated okta from cloud cover percentage: {cloud_cover_okta}")
return cloud_cover_okta
def _calculate_clear_sky_index(self, cloud_cover_okta):
"""Calculate the clear sky index."""
clear_sky_index = 1.0 - 0.75 * (cloud_cover_okta / 8.0) ** 3.4
self.log.debug(f"Clear sky index (Kc): {clear_sky_index:.2f}")
return clear_sky_index
def _calculate_relative_airmass(self, sun_altitude):
"""Calculate the relative airmass."""
sin_sun_altitude = math.sin(math.radians(sun_altitude))
self.log.debug(f"sin(sun_altitude): {sin_sun_altitude:.2f}")
if sin_sun_altitude > 0:
relative_airmass = math.sqrt(1229 + (614 * sin_sun_altitude) ** 2) - (614 * sin_sun_altitude)
else:
relative_airmass = 1
self.log.debug(f"Relative airmass (after refinement): {relative_airmass:.2f}")
return relative_airmass
def _calculate_lux_and_weighted_lux(self, sun_altitude, atmospheric_radiation, mitigation_coefficient, clear_sky_index):
"""
Calculate the lux and weighted lux based on sun altitude and other factors.
"""
if sun_altitude > 1:
direct_radiation = atmospheric_radiation * math.pow(0.6, mitigation_coefficient) * math.sin(math.radians(sun_altitude))
scattered_radiation = atmospheric_radiation * (0.271 - 0.294 * math.pow(0.6, mitigation_coefficient)) * math.sin(math.radians(sun_altitude))
total_radiation = scattered_radiation + direct_radiation
lux = total_radiation / 0.0079
weighted_lux = int(lux * clear_sky_index)
self.log.debug(
f"Direct radiation: {direct_radiation:.2f}, "
f"Scattered radiation: {scattered_radiation:.2f}, "
f"Total radiation: {total_radiation:.2f}, "
f"Lux: {lux:.2f}, Weighted lux: {weighted_lux}"
)
elif -7 <= sun_altitude <= 1:
twilight_lux = 6.32 - (1 - sun_altitude) / 8 * 6.32
total_radiation = twilight_lux
lux = total_radiation / 0.0079
weighted_lux = int(lux * clear_sky_index)
self.log.debug(f"Twilight lux: {twilight_lux:.2f}, Total radiation: {total_radiation:.2f}, Lux: {lux:.2f}, Weighted lux: {weighted_lux}")
else:
lux = 0
weighted_lux = 0
self.log.debug("Sun altitude < -7 degrees, setting Lux and Weighted lux to 0")
lux = int(lux)
return lux, weighted_lux
def _update_virtual_sensors(self, lux, weighted_lux):
"""Update the virtual sensor values."""
self.log.info(f"Virtual outside light: {weighted_lux} Lux")
self.log.debug("=" * 90)
virtual_outside_light_sensor_item.oh_post_update_if(lux, not_equal=lux)
virtual_outside_weighted_light_sensor_item.oh_post_update_if(weighted_lux, not_equal=weighted_lux)
VirtualOutsideLightSensorRule()
EDIT REASON: Improved script again