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}")

View File

@@ -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))))
}

View File

@@ -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 ────────────────────────────────────────────────────────────────