updated cloud detection and mixed in celestial weather

This commit is contained in:
2026-06-24 00:36:00 -04:00
parent 2985a62d7d
commit 6f24e74b72
3 changed files with 289 additions and 286 deletions

View File

@@ -1,5 +1,5 @@
import time, math, board, smbus2, serial, psutil, pynmea2, bme680
import adafruit_ltr390, threading, os
import adafruit_ltr390, threading, os, statistics
from datetime import datetime, timezone
from collections import deque
from influxdb_client import InfluxDBClient, Point
@@ -13,30 +13,39 @@ def cfg():
load_dotenv(BASE_DIR / '.env', override=True)
load_dotenv(BASE_DIR / '.env.local', override=True)
return {
'LATITUDE': float(os.getenv('LATITUDE', '0.0')),
'LONGITUDE': float(os.getenv('LONGITUDE', '0.0')),
'ALTITUDE': float(os.getenv('ALTITUDE', '0.0')),
'INFLUX_URL': os.getenv('INFLUX_URL', 'http://localhost:8086'),
'INFLUX_TOKEN': os.getenv('INFLUX_TOKEN', ''),
'INFLUX_ORG': os.getenv('INFLUX_ORG', 'weather'),
'INFLUX_BUCKET': os.getenv('INFLUX_BUCKET', 'station'),
'GPS_PORT': os.getenv('GPS_PORT', '/dev/ttyS0'),
'GPS_BAUD': int( os.getenv('GPS_BAUD', '9600')),
'GAS_REFERENCE': int( os.getenv('GAS_REFERENCE', '250000')),
'PRESSURE_TREND_WINDOW':int(os.getenv('PRESSURE_TREND_WINDOW', '60')),
'LUNA_CAL_SAMPLES': int( os.getenv('LUNA_CAL_SAMPLES', '20')),
'LUNA_MAX_DIST_CM': int( os.getenv('LUNA_MAX_DIST_CM', '800')),
'LUNA_MIN_DEPTH_CM': int( os.getenv('LUNA_MIN_DEPTH_CM', '2')),
'AS3935_ADDR': int( os.getenv('AS3935_ADDR', '0x03'), 16),
'AS3935_NOISE_FLOOR': int( os.getenv('AS3935_NOISE_FLOOR', '5')),
'AS3935_WATCHDOG': int( os.getenv('AS3935_WATCHDOG', '3')),
'AS3935_SPIKE_REJ': int( os.getenv('AS3935_SPIKE_REJ', '7')),
'SKIN_TYPE_2_MED': int( os.getenv('SKIN_TYPE_2_MED', '200')),
'LOOP_INTERVAL': float(os.getenv('LOOP_INTERVAL', '1.0')),
'FLOOD_TEMP_THRESHOLD':float(os.getenv('FLOOD_TEMP_THRESHOLD', '10.0')),
'SLUSH_TEMP_THRESHOLD':float(os.getenv('SLUSH_TEMP_THRESHOLD', '0.0')),
'SNOW_STRENGTH_MIN': int( os.getenv('SNOW_STRENGTH_MIN', '700')),
'SEISMIC_NOISE_FLOOR':float(os.getenv('SEISMIC_NOISE_FLOOR', '0.02')),
'LATITUDE': float(os.getenv('LATITUDE', '0.0')),
'LONGITUDE': float(os.getenv('LONGITUDE', '0.0')),
'ALTITUDE': float(os.getenv('ALTITUDE', '0.0')),
'INFLUX_URL': os.getenv('INFLUX_URL', 'http://localhost:8086'),
'INFLUX_TOKEN': os.getenv('INFLUX_TOKEN', ''),
'INFLUX_ORG': os.getenv('INFLUX_ORG', 'weather'),
'INFLUX_BUCKET': os.getenv('INFLUX_BUCKET', 'station'),
'GPS_PORT': os.getenv('GPS_PORT', '/dev/ttyS0'),
'GPS_BAUD': int( os.getenv('GPS_BAUD', '9600')),
'GAS_REFERENCE': int( os.getenv('GAS_REFERENCE', '250000')),
'PRESSURE_TREND_WINDOW': int( os.getenv('PRESSURE_TREND_WINDOW', '60')),
'LUNA_CAL_SAMPLES': int( os.getenv('LUNA_CAL_SAMPLES', '20')),
'LUNA_MAX_DIST_CM': int( os.getenv('LUNA_MAX_DIST_CM', '800')),
'LUNA_MIN_DEPTH_CM': int( os.getenv('LUNA_MIN_DEPTH_CM', '2')),
'AS3935_ADDR': int( os.getenv('AS3935_ADDR', '0x03'), 16),
'AS3935_NOISE_FLOOR': int( os.getenv('AS3935_NOISE_FLOOR', '5')),
'AS3935_WATCHDOG': int( os.getenv('AS3935_WATCHDOG', '3')),
'AS3935_SPIKE_REJ': int( os.getenv('AS3935_SPIKE_REJ', '7')),
'SKIN_TYPE_2_MED': int( os.getenv('SKIN_TYPE_2_MED', '200')),
'LOOP_INTERVAL': float( os.getenv('LOOP_INTERVAL', '1.0')),
'FLOOD_TEMP_THRESHOLD': float( os.getenv('FLOOD_TEMP_THRESHOLD', '10.0')),
'SLUSH_TEMP_THRESHOLD': float( os.getenv('SLUSH_TEMP_THRESHOLD', '0.0')),
'SNOW_STRENGTH_MIN': int( os.getenv('SNOW_STRENGTH_MIN', '700')),
'SEISMIC_NOISE_FLOOR': float( os.getenv('SEISMIC_NOISE_FLOOR', '0.02')),
'CLOUD_BUCKET_SIZE': int( os.getenv('CLOUD_BUCKET_SIZE', '5')),
'CLOUD_HISTORY': int( os.getenv('CLOUD_HISTORY', '20')),
'CLOUD_BASELINE_PCT': float( os.getenv('CLOUD_BASELINE_PCT', '0.9')),
'CLOUD_VOLATILITY_THRESH':float( os.getenv('CLOUD_VOLATILITY_THRESH', '0.3')),
'CLOUD_CLEAR_MAX': float( os.getenv('CLOUD_CLEAR_MAX', '15.0')),
'CLOUD_MOSTLY_CLEAR_MAX': float( os.getenv('CLOUD_MOSTLY_CLEAR_MAX', '40.0')),
'CLOUD_CLOUDY_MAX': float( os.getenv('CLOUD_CLOUDY_MAX', '80.0')),
'CLOUD_MIN_SAMPLES': int( os.getenv('CLOUD_MIN_SAMPLES', '5')),
'CLOUD_LUX_WINDOW': int( os.getenv('CLOUD_LUX_WINDOW', '10')),
}
# ── InfluxDB ──────────────────────────────────────────────────────────────────
@@ -229,10 +238,10 @@ def mpu_loop():
with _mpu_lock:
if _mpu_peak is None or mag > _mpu_peak['magnitude']:
_mpu_peak = {
'seismic_x': round(ax, 3),
'seismic_y': round(ay, 3),
'seismic_z': round(az, 3),
'magnitude': round(mag, 4),
'seismic_x': round(ax, 3),
'seismic_y': round(ay, 3),
'seismic_z': round(az, 3),
'magnitude': round(mag, 4),
}
time.sleep(0.01)
@@ -277,6 +286,72 @@ last_lux_time = time.time()
daylight_start = None
DAYLIGHT_LUX_THRESHOLD = 50
_cloud_baseline = {}
_cloud_lux_window = deque(maxlen=10)
def _el_bucket(solar_el, bucket_size):
if solar_el is None: return None
return round(solar_el / bucket_size) * bucket_size
def moon_phase_factor():
known_new = datetime(2024, 1, 11, tzinfo=timezone.utc)
lunar_cycle = 29.53058867
days = (datetime.now(timezone.utc) - known_new).total_seconds() / 86400
phase = (days % lunar_cycle) / lunar_cycle
return round(math.cos(math.pi * (phase - 0.5)) * 0.5 + 0.5, 3)
def update_cloud_baseline(c, solar_el, lux):
bucket = _el_bucket(solar_el, c['CLOUD_BUCKET_SIZE'])
if bucket is None: return
if bucket not in _cloud_baseline:
_cloud_baseline[bucket] = deque(maxlen=c['CLOUD_HISTORY'])
_cloud_baseline[bucket].append(lux)
def get_expected_lux(c, solar_el):
bucket = _el_bucket(solar_el, c['CLOUD_BUCKET_SIZE'])
if bucket is None: return None
history = _cloud_baseline.get(bucket)
if not history or len(history) < c['CLOUD_MIN_SAMPLES']: return None
sorted_vals = sorted(history)
idx = max(0, int(len(sorted_vals) * c['CLOUD_BASELINE_PCT']) - 1)
baseline = sorted_vals[idx]
if solar_el is not None and solar_el < 0:
baseline = baseline * moon_phase_factor()
return round(baseline, 2)
def classify_clouds(c, lux, solar_el):
# Resize window if config changed
if _cloud_lux_window.maxlen != c['CLOUD_LUX_WINDOW']:
_cloud_lux_window.__init__(maxlen=c['CLOUD_LUX_WINDOW'])
_cloud_lux_window.append(lux)
expected = get_expected_lux(c, solar_el)
if expected is None or expected < 0.1:
return None, None, None
ratio = min(lux / expected, 1.0)
cloud_pct = round((1 - ratio) * 100, 1)
volatility = None
if len(_cloud_lux_window) >= 3:
mean = statistics.mean(_cloud_lux_window)
if mean > 0:
volatility = round(statistics.stdev(_cloud_lux_window) / mean, 3)
vt = c['CLOUD_VOLATILITY_THRESH']
if volatility is not None and volatility > vt and c['CLOUD_CLEAR_MAX'] < cloud_pct < c['CLOUD_CLOUDY_MAX']:
label = 'Partly Cloudy'
elif cloud_pct < c['CLOUD_CLEAR_MAX']:
label = 'Clear'
elif cloud_pct < c['CLOUD_MOSTLY_CLEAR_MAX']:
label = 'Mostly Clear'
elif cloud_pct < c['CLOUD_CLOUDY_MAX']:
label = 'Cloudy'
else:
label = 'Overcast'
return cloud_pct, label, volatility
def visibility_estimate(lux, humidity):
base_km = 50.0
hum_factor = max(0, 1 - ((humidity - 40) / 60)) if humidity > 40 else 1.0
@@ -290,7 +365,7 @@ def read_ltr(c, sensor, solar_el, humidity=None):
null = {
'lux': None, 'uv_index': None, 'solar_wm2': None,
'uv_dose': None, 'clouds': None, 'daily_light_integral': None,
'daylight': None, 'visibility': None,
'daylight': None, 'visibility': None, 'cloud_volatility': None,
}
if not sensor: write(c, 'light', null); return
@@ -323,31 +398,20 @@ def read_ltr(c, sensor, solar_el, humidity=None):
daylight_start = now
daylight_hours = round((now - daylight_start) / 3600, 2) if daylight_start else 0.0
burn_min = round(c['SKIN_TYPE_2_MED'] / (uvi * 25 / 1000), 1) if uvi > 0 else None
cloud_pct, cloud_label, volatility = classify_clouds(c, lux, solar_el)
cloud_pct, cloud_label = None, None
if solar_el is not None and solar_el > 0:
cos_z = math.cos(math.radians(90 - solar_el))
theoretical = 1361 * cos_z * 0.75
if theoretical > 0:
ratio = min((lux / 120) / theoretical, 1.0)
cloud_pct = round((1 - ratio) * 100, 1)
cloud_label = (
'Clear' if cloud_pct < 20 else
'Partly Cloudy' if cloud_pct < 50 else
'Mostly Cloudy' if cloud_pct < 85 else
'Overcast'
)
if cloud_label == 'Clear':
update_cloud_baseline(c, solar_el, lux)
write(c, 'light', {
'lux': lux,
'uv_index': uvi,
'solar_wm2': round(lux / 120, 2),
'uv_dose': round(uv_dose_mj, 2),
'clouds': cloud_pct,
'daily_light_integral': dli,
'daylight':daylight_hours,
'visibility': visibility_estimate(lux, humidity) if humidity else None,
'lux': lux,
'uv_index': uvi,
'solar_wm2': round(lux / 120, 2),
'uv_dose': round(uv_dose_mj, 2),
'clouds': cloud_pct,
'daily_light_integral': dli,
'daylight': daylight_hours,
'visibility': visibility_estimate(lux, humidity) if humidity else None,
}, tags={'clouds_label': cloud_label or ''})
# ── TF-Luna ───────────────────────────────────────────────────────────────────
@@ -394,9 +458,9 @@ def read_luna(c, temp_c):
'slush' if t is not None and t > c['SLUSH_TEMP_THRESHOLD'] else
'snow' if strength > c['SNOW_STRENGTH_MIN'] else 'ice'
)
fields['accumulation'] = depth
fields['accumulation'] = depth
else:
fields['accumulation'] = 0
fields['accumulation'] = 0
write(c, 'accumulation', fields, tags={'accumulation_type': kind})
except Exception as e:
print(f"[TF-Luna] {e}")
@@ -447,8 +511,8 @@ def read_as3935(c):
if avg_late < avg_early - 2: trend = 'Approaching'
elif avg_late > avg_early + 2: trend = 'Retreating'
write(c, 'lightning', {
'lightning_distance': dist,
'lightning_energy': energy,
'lightning_distance': dist,
'lightning_energy': energy,
'lightning_rate': strike_rate,
}, tags={'storm_direction': trend})
except Exception as e:
@@ -466,16 +530,16 @@ def read_gps(c):
if 'GGA' in line:
msg = pynmea2.parse(line)
gps_cache.update({
'latitude': msg.latitude,
'longitude': msg.longitude,
'altitude': msg.altitude,
'latitude': msg.latitude,
'longitude': msg.longitude,
'altitude': msg.altitude,
'gps_satellites': int(msg.num_sats),
})
if gps_cache:
write(c, 'gps', {k: v for k, v in gps_cache.items() if v is not None})
except Exception as e:
print(f"[GPS] {e}")
write(c, 'gps', {'latitude': None, 'longitude': None, 'altitude': None,'gps_satellites': None})
write(c, 'gps', {'latitude': None, 'longitude': None, 'altitude': None, 'gps_satellites': None})
return gps_cache
def solar_elevation(lat, lon):
@@ -494,9 +558,7 @@ def solar_elevation(lat, lon):
def read_rain(c):
try:
write(c, 'rain', {
'precipitation': 0,
}, tags={'raining': False})
write(c, 'rain', {'precipitation': 0}, tags={'raining': False})
except Exception as e:
print(f"[Rain] {e}")
@@ -504,11 +566,7 @@ def read_rain(c):
def read_wind(c):
try:
write(c, 'wind', {
'wind_direction': 0,
'wind_speed': 0,
'wind_gusts': 0,
})
write(c, 'wind', {'wind_direction': 0, 'wind_speed': 0, 'wind_gusts': 0})
except Exception as e:
print(f"[Wind] {e}")
@@ -516,7 +574,6 @@ def read_wind(c):
def read_system(c):
try:
# CPU temp (Linux hwmon/thermal)
cpu_temp = None
temps = psutil.sensors_temperatures()
for key in ('cpu_thermal', 'coretemp', 'k10temp', 'acpitz'):
@@ -529,15 +586,15 @@ def read_system(c):
disk = psutil.disk_usage('/')
write(c, 'system', {
'cpu_temp': cpu_temp,
'cpu_usage': round(psutil.cpu_percent(interval=None), 1),
'cpu_freq_mhz': round(cpu_freq.current, 1) if cpu_freq else None,
'mem_used_mb': round(vm.used / 1024**2, 1),
'mem_total_mb': round(vm.total / 1024**2, 1),
'mem_percent': vm.percent,
'disk_used_gb': round(disk.used / 1024**3, 2),
'disk_total_gb': round(disk.total / 1024**3, 2),
'disk_percent': disk.percent,
'cpu_temp': cpu_temp,
'cpu_usage': round(psutil.cpu_percent(interval=None), 1),
'cpu_freq_mhz': round(cpu_freq.current, 1) if cpu_freq else None,
'mem_used_mb': round(vm.used / 1024**2, 1),
'mem_total_mb': round(vm.total / 1024**2, 1),
'mem_percent': vm.percent,
'disk_used_gb': round(disk.used / 1024**3, 2),
'disk_total_gb': round(disk.total / 1024**3, 2),
'disk_percent': disk.percent,
})
except Exception as e:
print(f"[System] {e}")