From 6f24e74b72e03bb1978b4b202bca6fb1a32ef01f Mon Sep 17 00:00:00 2001 From: ztimson Date: Wed, 24 Jun 2026 00:36:00 -0400 Subject: [PATCH] updated cloud detection and mixed in celestial weather --- sensors/main.py | 213 +++++++++++++++++----------- server/celestial.mjs | 322 +++++++++++++++++++------------------------ server/server.mjs | 40 +++--- 3 files changed, 289 insertions(+), 286 deletions(-) diff --git a/sensors/main.py b/sensors/main.py index bf18310..0fde316 100644 --- a/sensors/main.py +++ b/sensors/main.py @@ -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}") diff --git a/server/celestial.mjs b/server/celestial.mjs index 940c7e6..ca00a8e 100644 --- a/server/celestial.mjs +++ b/server/celestial.mjs @@ -1,228 +1,182 @@ -const RAD = Math.PI / 180 -const DEG = 180 / Math.PI +const RAD = Math.PI / 180; +const DEG = 180 / Math.PI; function jdn(date) { - return (date instanceof Date ? date : new Date(date)) / 86400000 + 2440587.5 + return (date instanceof Date ? date : new Date(date)) / 86400000 + 2440587.5; } function jdnToDate(jd) { - return new Date((jd - 2440587.5) * 86400000) + return new Date((jd - 2440587.5) * 86400000); } function sunPosition(lat, lon, date = new Date()) { - const d = jdn(date) - 2451545.0 - const L = (280.46 + 0.9856474 * d) % 360 - const g = (357.528 + 0.9856003 * d) % 360 - const lam = L + 1.915 * Math.sin(g * RAD) + 0.02 * Math.sin(2 * g * RAD) - const eps = 23.439 - 0.0000004 * d - const sinL = Math.sin(lam * RAD) - const ra = Math.atan2(Math.cos(eps * RAD) * sinL, Math.cos(lam * RAD)) * DEG - const dec = Math.asin(Math.sin(eps * RAD) * sinL) * DEG - const UT = date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600 - const GMST = (6.697375 + 0.0657098242 * d + UT) % 24 - const LMST = (GMST + lon / 15) % 24 - const ha = LMST * 15 - ra - const elev = Math.asin( - Math.sin(lat * RAD) * Math.sin(dec * RAD) + - Math.cos(lat * RAD) * Math.cos(dec * RAD) * Math.cos(ha * RAD) - ) * DEG - const az = Math.atan2( - -Math.sin(ha * RAD), - Math.tan(dec * RAD) * Math.cos(lat * RAD) - Math.sin(lat * RAD) * Math.cos(ha * RAD) - ) * DEG - return { - sun_elevation: Math.round(elev * 10) / 10, - sun_azimuth: Math.round(((az + 360) % 360) * 10) / 10, - } + const d = jdn(date) - 2451545.0; + const L = (280.46 + 0.9856474 * d) % 360; + const g = (357.528 + 0.9856003 * d) % 360; + const lam = L + 1.915 * Math.sin(g * RAD) + 0.02 * Math.sin(2 * g * RAD); + const eps = 23.439 - 0.0000004 * d; + const sinL = Math.sin(lam * RAD); + const ra = Math.atan2(Math.cos(eps * RAD) * sinL, Math.cos(lam * RAD)) * DEG; + const dec = Math.asin(Math.sin(eps * RAD) * sinL) * DEG; + const UT = date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600; + const GMST = (6.697375 + 0.0657098242 * d + UT) % 24; + const LMST = (GMST + lon / 15) % 24; + const ha = LMST * 15 - ra; + const elev = Math.asin( + Math.sin(lat * RAD) * Math.sin(dec * RAD) + + Math.cos(lat * RAD) * Math.cos(dec * RAD) * Math.cos(ha * RAD) + ) * DEG; + const az = Math.atan2( + -Math.sin(ha * RAD), + Math.tan(dec * RAD) * Math.cos(lat * RAD) - Math.sin(lat * RAD) * Math.cos(ha * RAD) + ) * DEG; + return { + sun_elevation: Math.round(elev * 10) / 10, + sun_azimuth: Math.round(((az + 360) % 360) * 10) / 10, + }; } function sunriseSunset(lat, lon, date = new Date()) { - const d = Math.floor(jdn(date)) - 2451545 - const noon = 2451545 + 0.0009 + ((-lon) / 360) + Math.round(d - (-lon) / 360) - const M = (357.5291 + 0.98560028 * (noon - 2451545)) % 360 - const C = 1.9148 * Math.sin(M * RAD) + 0.02 * Math.sin(2 * M * RAD) + 0.0003 * Math.sin(3 * M * RAD) - const lam = (M + C + 180 + 102.9372) % 360 - const jnoon = noon + 0.0053 * Math.sin(M * RAD) - 0.0069 * Math.sin(2 * lam * RAD) - const dec = Math.asin(Math.sin(23.4397 * RAD) * Math.sin(lam * RAD)) * DEG - const cosH = (Math.sin(-0.8333 * RAD) - Math.sin(lat * RAD) * Math.sin(dec * RAD)) / (Math.cos(lat * RAD) * Math.cos(dec * RAD)) + const d = Math.floor(jdn(date)) - 2451545; + const noon = 2451545 + 0.0009 + ((-lon) / 360) + Math.round(d - (-lon) / 360); + const M = (357.5291 + 0.98560028 * (noon - 2451545)) % 360; + const C = 1.9148 * Math.sin(M * RAD) + 0.02 * Math.sin(2 * M * RAD) + 0.0003 * Math.sin(3 * M * RAD); + const lam = (M + C + 180 + 102.9372) % 360; + const jnoon = noon + 0.0053 * Math.sin(M * RAD) - 0.0069 * Math.sin(2 * lam * RAD); + const dec = Math.asin(Math.sin(23.4397 * RAD) * Math.sin(lam * RAD)) * DEG; + const cosH = (Math.sin(-0.8333 * RAD) - Math.sin(lat * RAD) * Math.sin(dec * RAD)) / (Math.cos(lat * RAD) * Math.cos(dec * RAD)); - if (Math.abs(cosH) > 1) { - return { sun_sunrise: null, sun_sunset: null, sun_solar_noon: jdnToDate(jnoon).toISOString(), sun_polar: cosH < -1 ? 'day' : 'night' } - } + if(Math.abs(cosH) > 1) { + return {sunrise: null, sunset: null, daytime: cosH < -1}; + } - const H = Math.acos(cosH) * DEG - const rise = jdnToDate(jnoon - H / 360) - const set = jdnToDate(jnoon + H / 360) - const noon_d = jdnToDate(jnoon) + const H = Math.acos(cosH) * DEG; + const rise = jdnToDate(jnoon - H / 360); + const set = jdnToDate(jnoon + H / 360); + const noon_d = jdnToDate(jnoon); - // Round to nearest second, drop milliseconds - const fmt = d => new Date(Math.round(d.getTime() / 1000) * 1000).toISOString() + // Round to nearest second, drop milliseconds + const fmt = d => new Date(Math.round(d.getTime() / 1000) * 1000).toISOString(); - return { - sun_sunrise: fmt(rise), - sun_sunset: fmt(set), - sun_solar_noon: fmt(noon_d), - sun_day_length_hours: Math.round((set - rise) / 36000) / 100, - sun_golden_hour_morning_start: fmt(new Date(rise - 30 * 60000)), - sun_golden_hour_morning_end: fmt(new Date(rise + 30 * 60000)), - sun_golden_hour_evening_start: fmt(new Date(set - 30 * 60000)), - sun_golden_hour_evening_end: fmt(new Date(set + 30 * 60000)), - } + return { + sunrise: fmt(rise), + sunset: fmt(set), + daylight: Math.round((set - rise) / 36000) / 100, + daytime: cosH < -1 + }; } // Smooth sunspot number approximation from solar flux F10.7 // SSN ≈ 1.61 * F10.7 - 63.7 (linear regression, valid for F10.7 > 70) export function ssnFromFlux(f107) { - if (!f107) return null - return Math.max(0, Math.round(1.61 * f107 - 63.7)) + if(!f107) return null; + return Math.max(0, Math.round(1.61 * f107 - 63.7)); } export function sunActivityLabel(f107) { - if (!f107) return null - if (f107 < 80) return 'Very Low' - if (f107 < 100) return 'Low' - if (f107 < 150) return 'Moderate' - if (f107 < 200) return 'High' - return 'Very High' + if(!f107) return null; + if(f107 < 80) return 'Very Low'; + if(f107 < 100) return 'Low'; + if(f107 < 150) return 'Moderate'; + if(f107 < 200) return 'High'; + return 'Very High'; } function moonPhase(date = new Date()) { - const jd = jdn(date) - const cycle = 29.53058867 - const known = 2451550.1 - const phase = ((jd - known) % cycle + cycle) % cycle - const illum = Math.round((1 - Math.cos(phase / cycle * 2 * Math.PI)) / 2 * 100) - let name - if (phase < 1.85) name = 'New Moon' - else if (phase < 7.38) name = 'Waxing Crescent' - else if (phase < 9.22) name = 'First Quarter' - else if (phase < 14.76) name = 'Waxing Gibbous' - else if (phase < 16.61) name = 'Full Moon' - else if (phase < 22.15) name = 'Waning Gibbous' - else if (phase < 23.99) name = 'Last Quarter' - else name = 'Waning Crescent' - return { moon_phase: name, moon_illumination_pct: illum } + const jd = jdn(date); + const cycle = 29.53058867; + const known = 2451550.1; + const phase = ((jd - known) % cycle + cycle) % cycle; + const illum = Math.round((1 - Math.cos(phase / cycle * 2 * Math.PI)) / 2 * 100); + let name; + if(phase < 1.85) name = 'New Moon'; + else if(phase < 7.38) name = 'Waxing Crescent'; + else if(phase < 9.22) name = 'First Quarter'; + else if(phase < 14.76) name = 'Waxing Gibbous'; + else if(phase < 16.61) name = 'Full Moon'; + else if(phase < 22.15) name = 'Waning Gibbous'; + else if(phase < 23.99) name = 'Last Quarter'; + else name = 'Waning Crescent'; + return {moon_phase: name, moon_illumination: illum}; } function nextMoonEvents(date = new Date()) { - const cycle = 29.53058867 - const jd = jdn(date) - const known = 2451550.1 - const phase = ((jd - known) % cycle + cycle) % cycle - const toNew = (cycle - phase) % cycle - const toFull = phase < 14.76 ? 14.76 - phase : cycle - phase + 14.76 - return { - moon_next_new: jdnToDate(jd + toNew).toISOString(), - moon_next_full: jdnToDate(jd + toFull).toISOString(), - } + const cycle = 29.53058867; + const jd = jdn(date); + const known = 2451550.1; + const phase = ((jd - known) % cycle + cycle) % cycle; + const toNew = (cycle - phase) % cycle; + const toFull = phase < 14.76 ? 14.76 - phase : cycle - phase + 14.76; + return { + moon_new: jdnToDate(jd + toNew).toISOString(), + moon_full: jdnToDate(jd + toFull).toISOString(), + }; } function moonriseMoonset(lat, lon, date = new Date()) { - const base = new Date(date) - base.setUTCHours(0, 0, 0, 0) - let prev = null - let moonrise = null - let moonset = null + const base = new Date(date); + base.setUTCHours(0, 0, 0, 0); + let prev = null; + let moonrise = null; + let moonset = null; - // Scan 48h in 10min steps — covers edge cases where moon rises/sets next UTC day - for (let m = 0; m <= 2880; m += 10) { - const t = new Date(base.getTime() + m * 60000) - const d = jdn(t) - 2451545 - const L = (218.316 + 13.176396 * d) % 360 - const M = (134.963 + 13.064993 * d) % 360 - const F = (93.272 + 13.229350 * d) % 360 - const lon_ = L + 6.289 * Math.sin(M * RAD) - const b = 5.128 * Math.sin(F * RAD) - const dec = Math.asin( - Math.sin(b * RAD) * Math.cos(23.4397 * RAD) + - Math.cos(b * RAD) * Math.sin(23.4397 * RAD) * Math.sin(lon_ * RAD) - ) * DEG - const GMST = (6.697375 + 0.0657098242 * d + (t.getUTCHours() + t.getUTCMinutes() / 60)) % 24 - const LMST = (GMST + lon / 15) % 24 - const ra = Math.atan2( - Math.sin(lon_ * RAD) * Math.cos(23.4397 * RAD) - Math.tan(b * RAD) * Math.sin(23.4397 * RAD), - Math.cos(lon_ * RAD) - ) * DEG - const ha = LMST * 15 - ra - const elev = Math.asin( - Math.sin(lat * RAD) * Math.sin(dec * RAD) + - Math.cos(lat * RAD) * Math.cos(dec * RAD) * Math.cos(ha * RAD) - ) * DEG + // Scan 48h in 10min steps — covers edge cases where moon rises/sets next UTC day + for(let m = 0; m <= 2880; m += 10) { + const t = new Date(base.getTime() + m * 60000); + const d = jdn(t) - 2451545; + const L = (218.316 + 13.176396 * d) % 360; + const M = (134.963 + 13.064993 * d) % 360; + const F = (93.272 + 13.229350 * d) % 360; + const lon_ = L + 6.289 * Math.sin(M * RAD); + const b = 5.128 * Math.sin(F * RAD); + const dec = Math.asin( + Math.sin(b * RAD) * Math.cos(23.4397 * RAD) + + Math.cos(b * RAD) * Math.sin(23.4397 * RAD) * Math.sin(lon_ * RAD) + ) * DEG; + const GMST = (6.697375 + 0.0657098242 * d + (t.getUTCHours() + t.getUTCMinutes() / 60)) % 24; + const LMST = (GMST + lon / 15) % 24; + const ra = Math.atan2( + Math.sin(lon_ * RAD) * Math.cos(23.4397 * RAD) - Math.tan(b * RAD) * Math.sin(23.4397 * RAD), + Math.cos(lon_ * RAD) + ) * DEG; + const ha = LMST * 15 - ra; + const elev = Math.asin( + Math.sin(lat * RAD) * Math.sin(dec * RAD) + + Math.cos(lat * RAD) * Math.cos(dec * RAD) * Math.cos(ha * RAD) + ) * DEG; - if (prev !== null) { - if (prev < 0 && elev >= 0 && !moonrise) moonrise = new Date(t.getTime() - 5 * 60000).toISOString() - if (prev >= 0 && elev < 0 && !moonset) moonset = new Date(t.getTime() - 5 * 60000).toISOString() - } - prev = elev - if (moonrise && moonset) break - } + if(prev !== null) { + if(prev < 0 && elev >= 0 && !moonrise) moonrise = new Date(t.getTime() - 5 * 60000).toISOString(); + if(prev >= 0 && elev < 0 && !moonset) moonset = new Date(t.getTime() - 5 * 60000).toISOString(); + } + prev = elev; + if(moonrise && moonset) break; + } - return { moon_moonrise: moonrise, moon_moonset: moonset } + return {moonrise: moonrise, moonset: moonset}; } function nextSolsticeEquinox(date = new Date()) { - const y = date.getFullYear() - const events = [ - { name: 'March Equinox', date: new Date(Date.UTC(y, 2, 20)) }, - { name: 'June Solstice', date: new Date(Date.UTC(y, 5, 21)) }, - { name: 'September Equinox', date: new Date(Date.UTC(y, 8, 22)) }, - { name: 'December Solstice', date: new Date(Date.UTC(y, 11, 21)) }, - { name: 'March Equinox', date: new Date(Date.UTC(y+1, 2, 20)) }, - ] - const next = events.find(e => e.date > date) - const prev = [...events].reverse().find(e => e.date <= date) - return { - season_next_event: next.name, - season_next_event_date: next.date.toISOString(), - season_last_event: prev?.name || null, - season_last_event_date: prev?.date.toISOString() || null, - } + const y = date.getFullYear(); + return { + summer_solstice: new Date(Date.UTC(y, 5, 21)), + winter_solstice: new Date(Date.UTC(y, 11, 21)), + vernal_equinox: new Date(Date.UTC(y, 2, 20)), + autumnal_equinox: new Date(Date.UTC(y + 1, 2, 20)), + }; } export function getCelestialCurrent(lat, lon, date = new Date()) { - return { - ...sunPosition(lat, lon, date), - ...sunriseSunset(lat, lon, date), - ...moonPhase(date), - ...nextMoonEvents(date), - ...moonriseMoonset(lat, lon, date), - ...nextSolsticeEquinox(date), - } + return { + ...sunPosition(lat, lon, date), + ...sunriseSunset(lat, lon, date), + ...moonPhase(date), + ...nextMoonEvents(date), + ...moonriseMoonset(lat, lon, date), + ...nextSolsticeEquinox(date), + }; } -export function getCelestialHourly(lat, lon, startISO, endISO) { - const start = new Date(startISO) - const end = new Date(endISO) - const results = [] - - for (let d = new Date(start); d <= end; d = new Date(d.getTime() + 3600000)) { - const pos = sunPosition(lat, lon, d) - const phase = moonPhase(d) - results.push({ - time: d.toISOString(), - ...pos, - moon_illumination_pct: phase.moon_illumination_pct, - moon_phase: phase.moon_phase - }) - } - - return results -} - -export function getCelestialDaily(lat, lon, startISO, endISO) { - const start = new Date(startISO) - const end = new Date(endISO) - const results = [] - - for (let d = new Date(start); d <= end; d = new Date(d.getTime() + 86400000)) { - results.push({ - time: d.toISOString(), - ...sunriseSunset(lat, lon, d), - ...moonPhase(d), - ...nextMoonEvents(d), - ...moonriseMoonset(lat, lon, d), - }) - } - - return results +export function getCelestialForecast(lat, lon, hours) { + return hours.map((data) => Object.assign(data, getCelestialCurrent(lat, lon, new Date(data.time)))) } diff --git a/server/server.mjs b/server/server.mjs index dc21a02..e26666c 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -3,7 +3,7 @@ import {resolve, dirname} from 'path'; import {fileURLToPath} from 'url'; import {cfg} from './config.mjs'; import {queryCurrent, queryHourly, queryDaily, getCoords} from './influx.mjs'; -import {getCelestialCurrent, getCelestialHourly, getCelestialDaily} from './celestial.mjs'; +import {getCelestialCurrent, getCelestialHourly, getCelestialDaily, getCelestialForecast} from './celestial.mjs'; import {getSpaceWeather} from './space.mjs'; import {getOpenMeteo} from './openmeteo.mjs'; import {apiReference} from '@scalar/express-api-reference'; @@ -38,8 +38,8 @@ function filterArr(arr, fields) { app.get('/api/current', async (req, res) => { const {fields} = req.query; - const data = await queryCurrent(); - res.json(filterFields(data, fields)); + const [sensors, space] = await Promise.all([queryCurrent(), getCelestialCurrent()]); + res.json(filterFields({...sensors, ...space}, fields)); }); // ── Position ────────────────────────────────────────────────────────────────── @@ -53,23 +53,8 @@ app.get('/api/position', async (req, res) => { // ── Space ───────────────────────────────────────────────────────────────────── app.get('/api/space', async (req, res) => { - const {fields, mode} = req.query; - const start = req.query.start || new Date(new Date().setHours(0, 0, 0, 0)).toISOString(); - const end = req.query.end || new Date().toISOString(); - const coords = await getCoords(); - if(mode === 'hourly') { - const space = await getSpaceWeather(); - Object.assign(space, getCelestialHourly(coords.latitude, coords.longitude, start, end)); - res.json(filterFields(space, fields)); - } else if(mode === 'daily') { - const space = await getSpaceWeather(); - Object.assign(space, getCelestialDaily(coords.latitude, coords.longitude, start, end)); - res.json(filterFields(space, fields)); - } else { - const space = await getSpaceWeather(); - Object.assign(space, getCelestialCurrent(coords.latitude, coords.longitude)); - res.json(filterFields(space, fields)); - } + const {fields} = req.query; + res.json(filterFields(await getSpaceWeather(), fields)); }); // ── Daily History/Forecast ──────────────────────────────────────────────────── @@ -77,7 +62,9 @@ app.get('/api/space', async (req, res) => { app.get('/api/hourly', async (req, res) => { const {fields} = req.query; const start = req.query.start || new Date(new Date().setHours(0, 0, 0, 0)).toISOString(); - const now = new Date().toISOString(); + let now = new Date(); + now.setHours(new Date().getHours() - 1, 0, 0); + now = now.toISOString(); const end = req.query.end || new Date().toISOString(); const coords = await getCoords(); const [sensor, meteo] = await Promise.allSettled([ @@ -86,7 +73,8 @@ app.get('/api/hourly', async (req, res) => { ]); const history = sensor.status === 'fulfilled' ? sensor.value : []; const forecast = meteo.status === 'fulfilled' ? meteo.value.hourly : []; - res.json(filterArr([...history, ...forecast], fields)); + const hourly = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...forecast]); + res.json(filterArr(hourly, fields)); }); // ── Hourly History/Forecast ─────────────────────────────────────────────────── @@ -94,7 +82,10 @@ app.get('/api/hourly', async (req, res) => { app.get('/api/daily', async (req, res) => { const {fields} = req.query; const start = req.query.start || new Date(new Date().setHours(0, 0, 0, 0)).toISOString(); - const now = new Date().toISOString(); + let now = new Date(); + now.setDate(now.getDate() - 1); + now.setHours(0, 0, 0, 0); + now = now.toISOString(); const end = req.query.end || new Date().toISOString(); const coords = await getCoords(); const [sensor, meteo] = await Promise.allSettled([ @@ -103,7 +94,8 @@ app.get('/api/daily', async (req, res) => { ]); const history = sensor.status === 'fulfilled' ? sensor.value : []; const forecast = meteo.status === 'fulfilled' ? meteo.value.daily : []; - res.json(filterArr([...history, ...forecast], fields)); + const daily = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...forecast]); + res.json(filterArr(daily, fields)); }); // ── ADSB Proxy ────────────────────────────────────────────────────────────────