updated cloud detection and mixed in celestial weather
This commit is contained in:
213
sensors/main.py
213
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}")
|
||||
|
||||
@@ -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))))
|
||||
}
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user