Initial commit
This commit is contained in:
@@ -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()
|
||||
)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 {}
|
||||
Reference in New Issue
Block a user