Initial commit

This commit is contained in:
2026-03-19 17:30:04 +01:00 Verified
commit 87f585b6c0
98 changed files with 2746 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# environment/__init__.py
import logging
from datetime import datetime
import requests
from constants import HOME_LOCATION
from tools.geometry import Coordinate
from .local_sensors import Sensors
from .pysky import Moon, Sun
from .smhi import SMHI
from .wireless_sensor import WirelessSensor
logger = logging.getLogger(__name__)
class Weather:
def __init__(self, location: Coordinate = HOME_LOCATION) -> None:
self.location = location
self.session = requests.Session()
self.smhi_api = SMHI(self.location, self.session)
self.sensor_data = Sensors()
self.wireless_sensor = WirelessSensor(self.session)
def get(self, date: datetime) -> dict:
logger.debug("Collecting weather data from all sensors...")
return (
self.sensor_data.get()
| self.wireless_sensor.get()
| Moon.get_illumination(date)
| Sun.get_sunset_sunrise(self.location, date)
| self.smhi_api.get()
)
+46
View File
@@ -0,0 +1,46 @@
# environment/local_sensors.py
import logging
logger = logging.getLogger(__name__)
try:
import board
from adafruit_bme280 import basic as adafruit_bme280
except ImportError as e:
logger.warning(
f"BME280 dependencies not installed (Normal for test environments) | {e}"
)
except Exception as e:
logger.error(f"Error importing Raspberry Pi specific dependencies | {e}")
class Sensors:
def __init__(self):
self._bme280 = None
self._data = {}
try:
if "adafruit_bme280" in globals():
self._bme280 = adafruit_bme280.Adafruit_BME280_I2C(
board.I2C(), address=0x76
)
except Exception as e:
logger.error(f"BME280 hardware not found on I2C bus! | {e}")
def _get_data(self):
if self._bme280 is None:
return
try:
self._data = {
"indoor_temp": round(self._bme280.temperature, 1),
"indoor_humidity": round(self._bme280.relative_humidity, 1),
"pressure": round(self._bme280.pressure, 1),
}
except Exception as e:
logger.error(f"Failed getting indoor sensor data: {e}")
def get(self) -> dict:
logger.debug("Measuring environment indoors...")
self._get_data()
return self._data
+152
View File
@@ -0,0 +1,152 @@
# environment/pysky.py
import logging
from datetime import datetime, timedelta
from math import acos, asin, ceil, cos, degrees, fmod
from math import pi as PI
from math import radians, sin, sqrt
from constants import Coordinate
from tools.julian import from_julian, julian_date
logger = logging.getLogger(__name__)
class Sun:
@staticmethod
def _calc(location: Coordinate, date: datetime, elevation: float = 0.0) -> dict:
J_date = julian_date(date)
n = ceil(J_date - (2451545.0 + 0.0009) + 69.184 / 86400.0) - 1
J_ = n + 0.0009 - location.long / 360.0
M_degrees = fmod(357.5291 + 0.98560028 * J_, 360)
M_radians = radians(M_degrees)
C_degrees = (
1.9148 * sin(M_radians)
+ 0.02 * sin(2 * M_radians)
+ 0.0003 * sin(3 * M_radians)
)
L_degrees = fmod(M_degrees + C_degrees + 180.0 + 102.9372, 360)
Lambda_radians = radians(L_degrees)
J_transit = (
2451545.0 + J_ + 0.0053 * sin(M_radians) - 0.0069 * sin(2 * Lambda_radians)
)
sin_d = sin(Lambda_radians) * sin(radians(23.4397))
cos_d = cos(asin(sin_d))
some_cos = (
sin(radians(-0.833 - 2.076 * sqrt(elevation) / 60.0))
- sin(radians(location.lat)) * sin_d
) / (cos(radians(location.lat)) * cos_d)
try:
w0_radians = acos(some_cos)
except ValueError:
return {"sunrise": "--:--", "sunset": "--:--"}
w0_degrees = degrees(w0_radians)
j_rise = J_transit - w0_degrees / 360
j_set = J_transit + w0_degrees / 360
sunset_time = from_julian(j_set)
sunrise_time = from_julian(j_rise)
return {
"sunrise": f"{sunrise_time.hour:02d}:{sunrise_time.minute:02d}",
"sunset": f"{sunset_time.hour:02d}:{sunset_time.minute:02d}",
}
@staticmethod
def get_sunset_sunrise(location: Coordinate, date: datetime | None = None) -> dict:
if date is None:
date = datetime.now()
logger.debug("Calculating Sun trajectory...")
return Sun._calc(location, date)
class Moon:
@staticmethod
def _illumination_name(illumination: float, waxing: bool = True) -> str:
if illumination > 0.996:
return "Fullmåne"
elif illumination > 0.57:
return "Tilltagande halvmåne" if waxing else "Avtagande halvmåne"
elif illumination > 0.43:
return "Halvmåne (tilltagande)" if waxing else "Halvmåne (avtagande)"
elif illumination > 0.02:
return "Tilltagande skära" if waxing else "Avtagande skära"
else:
return "Nymåne"
@staticmethod
def _constrain(d: float) -> float:
t = d % 360
if t < 0:
t += 360
return t
@staticmethod
def _get_illuminated_fraction(jd: float) -> float:
toRad = PI / 180.0
T = (jd - 2451545) / 36525.0
D = (
Moon._constrain(
297.8501921
+ 445267.1114034 * T
- 0.0018819 * T * T
+ 1.0 / 545868.0 * T * T * T
- 1.0 / 113065000.0 * T * T * T * T
)
* toRad
)
M = (
Moon._constrain(
357.5291092
+ 35999.0502909 * T
- 0.0001536 * T * T
+ 1.0 / 24490000.0 * T * T * T
)
* toRad
)
Mp = (
Moon._constrain(
134.9633964
+ 477198.8675055 * T
+ 0.0087414 * T * T
+ 1.0 / 69699.0 * T * T * T
- 1.0 / 14712000.0 * T * T * T * T
)
* toRad
)
i = (
Moon._constrain(
180
- D * 180 / PI
- 6.289 * sin(Mp)
+ 2.1 * sin(M)
- 1.274 * sin(2 * D - Mp)
- 0.658 * sin(2 * D)
- 0.214 * sin(2 * Mp)
- 0.11 * sin(D)
)
* toRad
)
return (1 + cos(i)) / 2
@staticmethod
def get_illumination(date: datetime | None = None) -> dict:
if date is None:
date = datetime.now()
logger.debug("Calculating Moon illumination...")
i = Moon._get_illuminated_fraction(julian_date(date))
i_future = Moon._get_illuminated_fraction(
julian_date(date + timedelta(seconds=1))
)
waxing = i_future > i
return {
"moon_illumination": round(i, 4),
"moon_phase": Moon._illumination_name(i, waxing),
"waxing": waxing,
}
+163
View File
@@ -0,0 +1,163 @@
# environment/smhi.py
import logging
from datetime import datetime
import requests
from tools.geometry import Coordinate, LineString, MultiPolygon, Polygon
logger = logging.getLogger(__name__)
class SMHI:
fire_warning_api = "https://opendata-download-metfcst.smhi.se/api/category/fwif1g/version/1/daily/geotype/point/lon/{lon}/lat/{lat}/data.json"
forecast_api = "https://opendata-download-metfcst.smhi.se/api/category/pmp3g/version/2/geotype/point/lon/{lon}/lat/{lat}/data.json"
alerts_api = (
"https://opendata-download-warnings.smhi.se/ibww/api/version/1/warning.json"
)
def __init__(self, location: Coordinate, session: requests.Session) -> None:
self.location = location
self._session = session
self._data = {"fire_index": {}, "weather_alerts": {}, "forecast": {}}
self._api = {
"fire_index": self._get_fire_warning,
"weather_alerts": self._get_alerts,
"forecast": self._get_forecast,
}
def _calculate_area(self, area_type: str, area_list: list) -> bool:
"""Helper to check if our home coordinates fall within an SMHI warning zone."""
point = self.location.to_Point(reversed=True)
match area_type:
case "Polygon":
return Polygon(area_list[0]).contains(point)
case "LineString":
return LineString(area_list).distance_to(point) <= 0.045
case "MultiPolygon":
return MultiPolygon(area_list).contains(point)
return False
def _get_fire_warning(self) -> bool:
try:
resp = self._session.get(
self.fire_warning_api.format(
lon=self.location.long, lat=self.location.lat
),
timeout=10,
)
resp.raise_for_status()
for param in resp.json()["timeSeries"][0]["parameters"]:
if param["name"] == "fwiindex":
self._data["fire_index"] = {
"data": param["values"][0],
"valid_time": datetime.now(),
}
return True
except requests.RequestException as e:
logger.error(f"Network error getting SMHI Fire index: {e}")
except Exception as e:
logger.error(f"Parsing error for SMHI Fire index: {e}")
return False
def _get_alerts(self) -> bool:
try:
resp = self._session.get(self.alerts_api, timeout=10)
resp.raise_for_status()
area_list = []
for warning in resp.json():
for areas in warning["warningAreas"]:
is_in_area = False
if areas["area"]["type"] == "FeatureCollection":
for features in areas["area"]["features"]:
if self._calculate_area(
features["geometry"]["type"],
features["geometry"]["coordinates"],
):
is_in_area = True
break
elif self._calculate_area(
areas["area"]["geometry"]["type"],
areas["area"]["geometry"]["coordinates"],
):
is_in_area = True
if is_in_area:
area_list.append(
{
"areaName": areas["areaName"]["sv"],
"type": areas["warningLevel"]["code"],
"description": areas["eventDescription"]["code"],
}
)
self._data["weather_alerts"] = {
"data": area_list,
"valid_time": datetime.now(),
}
return True
except requests.RequestException as e:
logger.error(f"Network error getting SMHI Alerts: {e}")
except Exception as e:
logger.error(f"Parsing error for SMHI Alerts: {e}")
return False
def _get_forecast(self) -> bool:
try:
resp = self._session.get(
self.forecast_api.format(lon=self.location.long, lat=self.location.lat),
timeout=10,
)
resp.raise_for_status()
data = resp.json()["timeSeries"][1]
w_data = {x["name"]: x["values"][0] for x in data["parameters"]}
self._data["forecast"] = {
"data": {
"wind": {
"deg": w_data["wd"],
"speed": round(w_data["ws"], 1),
"gust": round(w_data["gust"], 1),
},
"weather_symbol": w_data["Wsymb2"] - 1,
},
"valid_time": datetime.now(),
}
return True
except requests.RequestException as e:
logger.error(f"Network error getting SMHI Forecast: {e}")
except Exception as e:
logger.error(f"Parsing error for SMHI Forecast: {e}")
return False
def get(self) -> dict:
"""Returns valid SMHI data, utilizing cached data if recently fetched."""
valid_data = {}
for api_name, fetch_func in self._api.items():
cache = self._data[api_name]
valid_time = cache.get("valid_time", datetime.fromtimestamp(0))
age_seconds = (datetime.now() - valid_time).total_seconds()
if age_seconds <= 300:
logger.debug(f'Using cached data for "{api_name}"')
valid_data[api_name] = cache["data"]
else:
logger.debug(f'Fetching new data for "{api_name}"...')
if fetch_func():
logger.debug(f'Successfully fetched new data for "{api_name}"')
valid_data[api_name] = self._data[api_name]["data"]
elif age_seconds <= 600:
logger.warning(
f'Failed fetching "{api_name}", falling back to 10-min cache.'
)
valid_data[api_name] = cache["data"]
else:
logger.error(
f'SMHI data "{api_name}" is too old and cannot be refreshed.'
)
return valid_data
+42
View File
@@ -0,0 +1,42 @@
# environment/wireless_sensor.py
import logging
from datetime import datetime
import requests
from constants import SENSOR_HTTP
logger = logging.getLogger(__name__)
class WirelessSensor:
def __init__(self, session: requests.Session) -> None:
self._sensor_data = {}
self._last_poll = None
self._session = session
def _poll_sensor(self) -> None:
try:
resp = self._session.get(SENSOR_HTTP, timeout=6)
resp.raise_for_status()
self._last_poll = datetime.now()
self._sensor_data = resp.json()
except requests.RequestException as e:
logger.error(f"Network error connecting to wireless sensor: {e}")
except Exception as e:
logger.error(f"Error parsing wireless sensor data: {e}")
def get(self) -> dict:
logger.debug("Polling Wireless Sensor...")
self._poll_sensor()
if self._last_poll:
age_seconds = abs((datetime.now() - self._last_poll).total_seconds())
if age_seconds < 600:
return self._sensor_data
else:
logger.warning("Wireless sensor data is too old (over 10 mins).")
return {}