Updated SMHI API and improved the polygon contains logic

This commit is contained in:
2026-04-18 12:37:37 +02:00 Verified
parent 49e52d9613
commit 131b74daf9
5 changed files with 65 additions and 70 deletions
+2 -2
View File
@@ -7,12 +7,12 @@ from tools.geometry import Coordinate
load_dotenv() load_dotenv()
# --- Network & Credentials --- # --- Network & Credentials ---
SENSOR_IP = os.getenv("SENSOR_IP", "192.168.0.188") SENSOR_IP = os.getenv("SENSOR_IP", "10.11.4.78")
SENSOR_HTTP = f"http://{SENSOR_IP}" SENSOR_HTTP = f"http://{SENSOR_IP}"
MQTT_USERNAME = os.getenv("MQTT_USERNAME", "weather_station") MQTT_USERNAME = os.getenv("MQTT_USERNAME", "weather_station")
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD", "SensorData12345!") MQTT_PASSWORD = os.getenv("MQTT_PASSWORD", "SensorData12345!")
MQTT_FALLBACK_IP = os.getenv("MQTT_FALLBACK_IP", "192.168.0.168") MQTT_FALLBACK_IP = os.getenv("MQTT_FALLBACK_IP", "10.11.0.33")
# --- Location --- # --- Location ---
LAT = float(os.getenv("LATITUDE", "56.2006")) LAT = float(os.getenv("LATITUDE", "56.2006"))
+41 -10
View File
@@ -1,7 +1,7 @@
# environment/smhi.py # environment/smhi.py
import logging import logging
from datetime import datetime from datetime import datetime, timezone
import requests import requests
from tools.geometry import Coordinate, LineString, MultiPolygon, Polygon from tools.geometry import Coordinate, LineString, MultiPolygon, Polygon
@@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
class SMHI: 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" 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" forecast_api = "https://opendata-download-metfcst.smhi.se/api/category/snow1g/version/1/geotype/point/lon/{lon}/lat/{lat}/data.json"
alerts_api = ( alerts_api = (
"https://opendata-download-warnings.smhi.se/ibww/api/version/1/warning.json" "https://opendata-download-warnings.smhi.se/ibww/api/version/1/warning.json"
) )
@@ -68,8 +68,31 @@ class SMHI:
resp.raise_for_status() resp.raise_for_status()
area_list = [] area_list = []
now_utc = datetime.now(timezone.utc)
for warning in resp.json(): for warning in resp.json():
for areas in warning["warningAreas"]: for areas in warning["warningAreas"]:
warning_level = areas["warningLevel"]["code"]
if warning_level == "MESSAGE":
continue
start_str = areas.get("approximateStart")
if start_str:
start_time = datetime.fromisoformat(
start_str.replace("Z", "+00:00")
)
if now_utc < start_time:
continue
end_str = areas.get("approximateEnd")
if end_str:
end_time = datetime.fromisoformat(
end_str.replace("Z", "+00:00")
)
if now_utc > end_time:
continue
is_in_area = False is_in_area = False
if areas["area"]["type"] == "FeatureCollection": if areas["area"]["type"] == "FeatureCollection":
for features in areas["area"]["features"]: for features in areas["area"]["features"]:
@@ -89,7 +112,7 @@ class SMHI:
area_list.append( area_list.append(
{ {
"areaName": areas["areaName"]["sv"], "areaName": areas["areaName"]["sv"],
"type": areas["warningLevel"]["code"], "type": warning_level,
"description": areas["eventDescription"]["code"], "description": areas["eventDescription"]["code"],
} }
) )
@@ -107,29 +130,37 @@ class SMHI:
def _get_forecast(self) -> bool: def _get_forecast(self) -> bool:
try: try:
base_url = self.forecast_api.format(
lon=self.location.long, lat=self.location.lat
)
query_string = "?timeseries=1&parameters=wind_from_direction,wind_speed,wind_speed_of_gust,symbol_code"
full_url = base_url + query_string
resp = self._session.get( resp = self._session.get(
self.forecast_api.format(lon=self.location.long, lat=self.location.lat), full_url,
timeout=10, timeout=10,
) )
resp.raise_for_status() resp.raise_for_status()
data = resp.json()["timeSeries"][1] w_data = resp.json()["timeSeries"][0]["data"]
w_data = {x["name"]: x["values"][0] for x in data["parameters"]}
self._data["forecast"] = { self._data["forecast"] = {
"data": { "data": {
"wind": { "wind": {
"deg": w_data["wd"], "deg": w_data["wind_from_direction"],
"speed": round(w_data["ws"], 1), "speed": round(w_data["wind_speed"], 1),
"gust": round(w_data["gust"], 1), "gust": round(w_data["wind_speed_of_gust"], 1),
}, },
"weather_symbol": w_data["Wsymb2"] - 1, "weather_symbol": w_data["symbol_code"] - 1,
}, },
"valid_time": datetime.now(), "valid_time": datetime.now(),
} }
return True return True
except requests.RequestException as e: except requests.RequestException as e:
logger.error(f"Network error getting SMHI Forecast: {e}") logger.error(f"Network error getting SMHI Forecast: {e}")
except KeyError as e:
logger.error(f"Missing expected key in SMHI Forecast data: {e}")
except Exception as e: except Exception as e:
logger.error(f"Parsing error for SMHI Forecast: {e}") logger.error(f"Parsing error for SMHI Forecast: {e}")
return False return False
BIN
View File
Binary file not shown.
View File
+22 -58
View File
@@ -1,6 +1,7 @@
# tools/geometry.py # tools/geometry.py
from dataclasses import dataclass from dataclasses import dataclass
import math
@dataclass @dataclass
@@ -68,8 +69,8 @@ class LineString:
return min(line.distance_to_point(point) for line in self.lineList) return min(line.distance_to_point(point) for line in self.lineList)
def is_intersecting(self, point: Point) -> bool: def is_intersecting(self, point: Point) -> bool:
"""Checks if a given point lies exactly on the LineString.""" """Checks if a given point lies exactly on the LineString (with tolerance)."""
return self.distance_to(point) == 0.0 return math.isclose(self.distance_to(point), 0.0, abs_tol=1e-9)
class Polygon: class Polygon:
@@ -78,72 +79,35 @@ class Polygon:
def __init__(self, points: list[list[float]]) -> None: def __init__(self, points: list[list[float]]) -> None:
self.listPoints: list[Point] = [Point(p[0], p[1]) for p in points] self.listPoints: list[Point] = [Point(p[0], p[1]) for p in points]
def on_line(self, l1: Line, p: Point) -> bool:
"""Checks if collinear point 'p' lies strictly on the line segment 'l1'."""
return min(l1.p1.x, l1.p2.x) <= p.x <= max(l1.p1.x, l1.p2.x) and min(
l1.p1.y, l1.p2.y
) <= p.y <= max(l1.p1.y, l1.p2.y)
def direction(self, a: Point, b: Point, c: Point) -> int:
"""
Finds the orientation of an ordered triplet (a, b, c).
Returns:
0 : Collinear
1 : Clockwise
2 : Counterclockwise
"""
val = (b.y - a.y) * (c.x - b.x) - (b.x - a.x) * (c.y - b.y)
if val == 0:
return 0
return 2 if val < 0 else 1
def is_intersect(self, l1: Line, l2: Line) -> bool:
"""Checks if line segment l1 intersects with line segment l2."""
dir1 = self.direction(l1.p1, l1.p2, l2.p1)
dir2 = self.direction(l1.p1, l1.p2, l2.p2)
dir3 = self.direction(l2.p1, l2.p2, l1.p1)
dir4 = self.direction(l2.p1, l2.p2, l1.p2)
# General case intersection
if dir1 != dir2 and dir3 != dir4:
return True
# Special collinear cases
if dir1 == 0 and self.on_line(l1, l2.p1):
return True
if dir2 == 0 and self.on_line(l1, l2.p2):
return True
if dir3 == 0 and self.on_line(l2, l1.p1):
return True
if dir4 == 0 and self.on_line(l2, l1.p2):
return True
return False
def contains(self, p: Point) -> bool: def contains(self, p: Point) -> bool:
""" """
Determines if a point is strictly inside the Polygon using the Ray-Casting algorithm. Determines if a point is inside the Polygon using the Even-Odd rule.
Draws a horizontal line to the right of the point and counts edge intersections. Includes an explicit check for points lying exactly on the boundary edges.
""" """
n = len(self.listPoints) n = len(self.listPoints)
if n < 3: if n < 3:
return False return False
# Create a horizontal ray starting from the point and going infinitely right inside = False
exline = Line(p, Point(99999.0, p.y)) j = n - 1
count = 0
for i in range(n): for i in range(n):
side = Line(self.listPoints[i], self.listPoints[(i + 1) % n]) pi = self.listPoints[i]
pj = self.listPoints[j]
if self.is_intersect(side, exline): cross_product = (p.y - pi.y) * (pj.x - pi.x) - (p.x - pi.x) * (pj.y - pi.y)
# If the point is collinear with the side, check if it's strictly on the side if math.isclose(cross_product, 0.0, abs_tol=1e-9):
if self.direction(side.p1, p, side.p2) == 0: if min(pi.x, pj.x) <= p.x <= max(pi.x, pj.x) and min(
return self.on_line(side, p) pi.y, pj.y
count += 1 ) <= p.y <= max(pi.y, pj.y):
return True
# If the number of intersections is odd, the point is inside the polygon if ((pi.y > p.y) != (pj.y > p.y)) and (
return bool(count & 1) p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y) + pi.x
):
inside = not inside
j = i
return inside
class MultiPolygon: class MultiPolygon: