mirror of
https://github.com/jdejaegh/irm-kmi-ha.git
synced 2025-06-27 03:35:56 +02:00
Merge pull request #52 from jdejaegh/fix_pollen_data
Fix pollen data parsing
This commit is contained in:
commit
91ff65c19d
10 changed files with 70 additions and 148 deletions
|
@ -139,16 +139,18 @@ The sensor has two additional attributes:
|
|||
|
||||
## Pollen details
|
||||
|
||||
One sensor per pollen is created and each sensor can have one of the following values: active, green, yellow, orange,
|
||||
One sensor per pollen is created and each sensor can have one of the following values: green, yellow, orange,
|
||||
red, purple or none.
|
||||
|
||||
The exact meaning of each color can be found on the IRM KMI webpage: [Pollen allergy and hay fever](https://www.meteo.be/en/weather/forecasts/pollen-allergy-and-hay-fever)
|
||||
|
||||
<img height="200" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/pollens.png" alt="Pollen data"/>
|
||||
|
||||
This data sent to the app would result in oak and ash have the 'active' state, birch would be 'purple' and alder would be 'green'.
|
||||
This data sent to the app would result in grasses have the 'purple' state.
|
||||
All the other pollens would be 'none'.
|
||||
|
||||
Due to a recent update in the pollen SVG format, there may have some edge cases that are not handled by the integration.
|
||||
|
||||
## Custom service `irm_kmi.get_forecasts_radar`
|
||||
|
||||
The service returns a list of Forecast objects (similar to `weather.get_forecasts`) but only data about precipitation is available.
|
||||
|
|
|
@ -143,6 +143,7 @@ MAP_WARNING_ID_TO_SLUG: Final = {
|
|||
17: 'coldspell'}
|
||||
|
||||
POLLEN_NAMES: Final = {'Alder', 'Ash', 'Birch', 'Grasses', 'Hazel', 'Mugwort', 'Oak'}
|
||||
POLLEN_LEVEL_TO_COLOR = {'null': 'green', 'low': 'yellow', 'moderate': 'orange', 'high': 'red', 'very high': 'purple'}
|
||||
|
||||
POLLEN_TO_ICON_MAP: Final = {
|
||||
'alder': 'mdi:tree', 'ash': 'mdi:tree', 'birch': 'mdi:tree', 'grasses': 'mdi:grass', 'hazel': 'mdi:tree',
|
||||
|
|
|
@ -3,32 +3,16 @@ import logging
|
|||
import xml.etree.ElementTree as ET
|
||||
from typing import List
|
||||
|
||||
from custom_components.irm_kmi.const import POLLEN_NAMES
|
||||
from custom_components.irm_kmi.const import POLLEN_LEVEL_TO_COLOR, POLLEN_NAMES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_unavailable_data() -> dict:
|
||||
"""Return all the known pollen with 'none' value"""
|
||||
return {k.lower(): 'none' for k in POLLEN_NAMES}
|
||||
|
||||
|
||||
class PollenParser:
|
||||
"""
|
||||
The SVG looks as follows (see test fixture for a real example)
|
||||
|
||||
Active pollens
|
||||
---------------------------------
|
||||
Oak active
|
||||
Ash active
|
||||
---------------------------------
|
||||
Birch ---|---|---|---|-*-
|
||||
Alder -*-|---|---|---|---
|
||||
|
||||
This classe parses the oak and ash as active, birch as purple and alder as green in the example.
|
||||
For active pollen, check if an active text is present on the same line as the pollen name
|
||||
For the color scale, look for a white dot (nearly) at the same level as the pollen name. From the white dot
|
||||
horizontal position, determine the level
|
||||
Extract pollen level from an SVG provided by the IRM KMI API.
|
||||
To get the data, match pollen names and pollen levels that are vertically aligned. Then, map the value to the
|
||||
corresponding color on the scale.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
@ -37,23 +21,6 @@ class PollenParser:
|
|||
):
|
||||
self._xml = xml_string
|
||||
|
||||
@staticmethod
|
||||
def _validate_svg(elements: List[ET.Element]) -> bool:
|
||||
"""Make sure that the colors of the scale are still where we expect them"""
|
||||
x_values = {"rectgreen": 80,
|
||||
"rectyellow": 95,
|
||||
"rectorange": 110,
|
||||
"rectred": 125,
|
||||
"rectpurple": 140}
|
||||
for e in elements:
|
||||
if e.attrib.get('id', '') in x_values.keys():
|
||||
try:
|
||||
if float(e.attrib.get('x', '0')) != x_values.get(e.attrib.get('id')):
|
||||
return False
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_default_data() -> dict:
|
||||
"""Return all the known pollen with 'none' value"""
|
||||
|
@ -67,7 +34,7 @@ class PollenParser:
|
|||
@staticmethod
|
||||
def get_option_values() -> List[str]:
|
||||
"""List all the values that the pollen can have"""
|
||||
return ['active', 'green', 'yellow', 'orange', 'red', 'purple', 'none']
|
||||
return list(POLLEN_LEVEL_TO_COLOR.values()) + ['none']
|
||||
|
||||
@staticmethod
|
||||
def _extract_elements(root) -> List[ET.Element]:
|
||||
|
@ -79,27 +46,10 @@ class PollenParser:
|
|||
return elements
|
||||
|
||||
@staticmethod
|
||||
def _dot_to_color_value(dot: ET.Element) -> str:
|
||||
"""Map the dot horizontal position to a color or 'none'"""
|
||||
try:
|
||||
cx = float(dot.attrib.get('cx'))
|
||||
except ValueError:
|
||||
return 'none'
|
||||
|
||||
if cx > 155:
|
||||
return 'none'
|
||||
elif cx > 140:
|
||||
return 'purple'
|
||||
elif cx > 125:
|
||||
return 'red'
|
||||
elif cx > 110:
|
||||
return 'orange'
|
||||
elif cx > 95:
|
||||
return 'yellow'
|
||||
elif cx > 80:
|
||||
return 'green'
|
||||
else:
|
||||
return 'none'
|
||||
def _get_elem_text(e) -> str | None:
|
||||
if e.text is not None:
|
||||
return e.text.strip()
|
||||
return None
|
||||
|
||||
def get_pollen_data(self) -> dict:
|
||||
"""From the XML string, parse the SVG and extract the pollen data from the image.
|
||||
|
@ -114,28 +64,15 @@ class PollenParser:
|
|||
|
||||
elements: List[ET.Element] = self._extract_elements(root)
|
||||
|
||||
if not self._validate_svg(elements):
|
||||
_LOGGER.warning("Could not validate SVG pollen data")
|
||||
return pollen_data
|
||||
pollens = {e.attrib.get('x', None): self._get_elem_text(e).lower()
|
||||
for e in elements if 'tspan' in e.tag and self._get_elem_text(e) in POLLEN_NAMES}
|
||||
|
||||
pollens = [e for e in elements if 'tspan' in e.tag and e.text in POLLEN_NAMES]
|
||||
active = [e for e in elements if 'tspan' in e.tag and e.text == 'active']
|
||||
dots = [e for e in elements if 'ellipse' in e.tag
|
||||
and 'fill:#ffffff' in e.attrib.get('style', '')
|
||||
and 3 == float(e.attrib.get('rx', '0'))]
|
||||
pollen_levels = {e.attrib.get('x', None): POLLEN_LEVEL_TO_COLOR[self._get_elem_text(e)]
|
||||
for e in elements if 'tspan' in e.tag and self._get_elem_text(e) in POLLEN_LEVEL_TO_COLOR}
|
||||
|
||||
for pollen in pollens:
|
||||
try:
|
||||
y = float(pollen.attrib.get('y'))
|
||||
if y in [float(e.attrib.get('y')) for e in active]:
|
||||
pollen_data[pollen.text.lower()] = 'active'
|
||||
else:
|
||||
dot = [d for d in dots if y - 3 <= float(d.attrib.get('cy', '0')) <= y + 3]
|
||||
if len(dot) == 1:
|
||||
dot = dot[0]
|
||||
pollen_data[pollen.text.lower()] = self._dot_to_color_value(dot)
|
||||
except ValueError | NameError:
|
||||
_LOGGER.warning("Skipped some data in the pollen SVG")
|
||||
for position, pollen in pollens.items():
|
||||
if position is not None and position in pollen_levels:
|
||||
pollen_data[pollen] = pollen_levels[position]
|
||||
|
||||
_LOGGER.debug(f"Pollen data: {pollen_data}")
|
||||
return pollen_data
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
"""Sensor for pollen from the IRM KMI"""
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from homeassistant.components import sensor
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
|
||||
|
|
BIN
img/pollens.png
BIN
img/pollens.png
Binary file not shown.
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 87 KiB |
42
tests/fixtures/pollen.svg
vendored
42
tests/fixtures/pollen.svg
vendored
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 4.9 KiB |
45
tests/fixtures/pollen_three.svg
vendored
45
tests/fixtures/pollen_three.svg
vendored
|
@ -1,45 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="35 88 129 63.0" version="1.1" id="svg740" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs id="defs737"/>
|
||||
<g id="layer1">
|
||||
<rect id="rectangle-white" x="35" y="88" width="129" height="63.0" rx="5" fill="white" fill-opacity="0.15"/>
|
||||
<g id="g1495" transform="translate(30.342966,94.25)">
|
||||
<g id="layer1-2">
|
||||
<ellipse style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="path268" cx="13.312511" cy="0.0"
|
||||
rx="1.6348698" ry="1.5258785"/>
|
||||
<ellipse style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="path272" cx="16.208567"
|
||||
cy="1.463598" rx="1.1366237" ry="1.1366239"/>
|
||||
<rect style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="rect326" width="0.79407966"
|
||||
height="3.5655735" x="12.923257" y="1.401318"/>
|
||||
<rect style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="rect328" width="0.68508834"
|
||||
height="2.5535111" x="15.866023" y="2.36669"/>
|
||||
</g>
|
||||
</g>
|
||||
<text xml:space="preserve"
|
||||
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
|
||||
x="54.476421" y="98.75" id="text228"><tspan id="tspan226" style="fill:#ffffff;stroke-width:0.264583" x="54.476421" y="98.75">Active pollen</tspan></text>
|
||||
<text xml:space="preserve"
|
||||
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
|
||||
x="139.37601" y="98.75" id="text334"><tspan id="tspan332" style="stroke-width:0.264583" x="139.37601" y="98.75"></tspan></text>
|
||||
<rect style="fill:#607eaa;stroke-width:0.264583" id="rect392" width="127.80161" height="0.44039145"
|
||||
x="35.451504" y="105.5"/>
|
||||
<text xml:space="preserve"
|
||||
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
|
||||
x="42.973724" y="119.0" id="text993"><tspan id="tspan991" style="stroke-width:0.264583" x="42.973724" y="119.0">Alder</tspan></text>
|
||||
<text xml:space="preserve"
|
||||
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
|
||||
x="142.0" y="119.0" id="text1001"><tspan id="tspan999" style="stroke-width:0.264583" x="142.0" y="119.0">active</tspan></text>
|
||||
<text xml:space="preserve"
|
||||
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
|
||||
x="42.973724" y="128.0" id="text993"><tspan id="tspan991" style="stroke-width:0.264583" x="42.973724" y="128.0">Ash</tspan></text>
|
||||
<text xml:space="preserve"
|
||||
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
|
||||
x="142.0" y="128.0" id="text1001"><tspan id="tspan999" style="stroke-width:0.264583" x="142.0" y="128.0">active</tspan></text>
|
||||
<text xml:space="preserve"
|
||||
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
|
||||
x="42.973724" y="137.0" id="text993"><tspan id="tspan991" style="stroke-width:0.264583" x="42.973724" y="137.0">Oak</tspan></text>
|
||||
<text xml:space="preserve"
|
||||
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
|
||||
x="142.0" y="137.0" id="text1001"><tspan id="tspan999" style="stroke-width:0.264583" x="142.0" y="137.0">active</tspan></text>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 3.6 KiB |
2
tests/fixtures/pollen_two.svg
vendored
2
tests/fixtures/pollen_two.svg
vendored
|
@ -1,2 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="35 88 129 63.0" version="1.1" id="svg740" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> <defs id="defs737" /> <g id="layer1"> <rect id="rectangle-white" x="35" y="88" width="129" height="63.0" rx="5" fill="white" fill-opacity="0.15"/> <g id="g1495" transform="translate(30.342966,94.25)"> <g id="layer1-2"> <ellipse style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="path268" cx="13.312511" cy="0.0" rx="1.6348698" ry="1.5258785" /> <ellipse style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="path272" cx="16.208567" cy="1.463598" rx="1.1366237" ry="1.1366239" /> <rect style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="rect326" width="0.79407966" height="3.5655735" x="12.923257" y="1.401318" /> <rect style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="rect328" width="0.68508834" height="2.5535111" x="15.866023" y="2.36669" /> </g> </g> <text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="54.476421" y="98.75" id="text228"><tspan id="tspan226" style="fill:#ffffff;stroke-width:0.264583" x="54.476421" y="98.75">Active pollen</tspan></text> <text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="139.37601" y="98.75" id="text334"><tspan id="tspan332" style="stroke-width:0.264583" x="139.37601" y="98.75"></tspan></text> <rect style="fill:#607eaa;stroke-width:0.264583" id="rect392" width="127.80161" height="0.44039145" x="35.451504" y="105.5" /><text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="42.973724" y="119.0" id="text993"><tspan id="tspan991" style="stroke-width:0.264583" x="42.973724" y="119.0">Alder</tspan></text> <text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="142.0" y="119.0" id="text1001"><tspan id="tspan999" style="stroke-width:0.264583" x="142.0" y="119.0">active</tspan></text><text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="42.973724" y="128.0" id="text993"><tspan id="tspan991" style="stroke-width:0.264583" x="42.973724" y="128.0">Ash</tspan></text> <text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="142.0" y="128.0" id="text1001"><tspan id="tspan999" style="stroke-width:0.264583" x="142.0" y="128.0">active</tspan></text><text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="42.973724" y="137.0" id="text993"><tspan id="tspan991" style="stroke-width:0.264583" x="42.973724" y="137.0">Oak</tspan></text> <text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="142.0" y="137.0" id="text1001"><tspan id="tspan999" style="stroke-width:0.264583" x="142.0" y="137.0">active</tspan></text><rect style="fill:#607eaa;stroke-width:0.264583" id="rect392" width="127.80161" height="0.44039145" x="35.451504" y="141.0" /> <rect style="fill:#70ad47;fill-opacity:1;stroke-width:0.2187" id="rectgreen" width="15.0" height="4.0" x="80.0" y="144.0" /> <rect style="fill:#ffd966;fill-opacity:1;stroke-width:0.2187" id="rectyellow" width="15.0" height="4.0" x="95.0" y="144.0" /> <rect style="fill:#ed7d31;fill-opacity:1;stroke-width:0.2187" id="rectorange" width="15.0" height="4.0" x="110.0" y="144.0" /> <rect style="fill:#c00000;fill-opacity:1;stroke-width:0.2187" id="rectred" width="15.0" height="4.0" x="125.0" y="144.0" /> <rect style="fill:#7030a0;fill-opacity:1;stroke-width:0.2187" id="rectpurple" width="15.0" height="4.0" x="140.0" y="144.0" /> <ellipse style="fill:#70ad47;fill-opacity:1;stroke-width:0.264583" id="path1639" cx="80.0" cy="146.0" rx="2.0" ry="2.0" /> <ellipse style="fill:#7030a0;fill-opacity:1;stroke-width:0.264583" id="path1641" cx="155.0" cy="146.0" rx="2.0" ry="2.0" /> <text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="42.351849" y="148.0" id="text396"><tspan id="tspan394" style="stroke-width:0.264583" x="42.351849" y="148.0">Birch</tspan></text> <ellipse style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="path1644" cx="147.5" cy="146.0" rx="3.0" ry="3.0" /></g> </svg>
|
Before Width: | Height: | Size: 4.4 KiB |
|
@ -12,24 +12,12 @@ def test_svg_pollen_parsing():
|
|||
with open("tests/fixtures/pollen.svg", "r") as file:
|
||||
svg_data = file.read()
|
||||
data = PollenParser(svg_data).get_pollen_data()
|
||||
assert data == {'birch': 'purple', 'oak': 'active', 'hazel': 'none', 'mugwort': 'none', 'alder': 'green',
|
||||
'grasses': 'none', 'ash': 'active'}
|
||||
|
||||
with open("tests/fixtures/pollen_two.svg", "r") as file:
|
||||
svg_data = file.read()
|
||||
data = PollenParser(svg_data).get_pollen_data()
|
||||
assert data == {'birch': 'purple', 'oak': 'active', 'hazel': 'none', 'mugwort': 'none', 'alder': 'active',
|
||||
'grasses': 'none', 'ash': 'active'}
|
||||
|
||||
with open("tests/fixtures/pollen_three.svg", "r") as file:
|
||||
svg_data = file.read()
|
||||
data = PollenParser(svg_data).get_pollen_data()
|
||||
assert data == {'birch': 'none', 'oak': 'active', 'hazel': 'none', 'mugwort': 'none', 'alder': 'active',
|
||||
'grasses': 'none', 'ash': 'active'}
|
||||
assert data == {'birch': 'none', 'oak': 'none', 'hazel': 'none', 'mugwort': 'none', 'alder': 'none',
|
||||
'grasses': 'purple', 'ash': 'none'}
|
||||
|
||||
|
||||
def test_pollen_options():
|
||||
assert PollenParser.get_option_values() == ['active', 'green', 'yellow', 'orange', 'red', 'purple', 'none']
|
||||
assert set(PollenParser.get_option_values()) == {'green', 'yellow', 'orange', 'red', 'purple', 'none'}
|
||||
|
||||
|
||||
def test_pollen_default_values():
|
||||
|
@ -46,8 +34,8 @@ async def test_pollen_data_from_api(
|
|||
api_data = get_api_data("be_forecast_warning.json")
|
||||
|
||||
result = await coordinator._async_pollen_data(api_data)
|
||||
expected = {'mugwort': 'none', 'birch': 'purple', 'alder': 'green', 'ash': 'active', 'oak': 'active',
|
||||
'grasses': 'none', 'hazel': 'none'}
|
||||
expected = {'mugwort': 'none', 'birch': 'none', 'alder': 'none', 'ash': 'none', 'oak': 'none',
|
||||
'grasses': 'purple', 'hazel': 'none'}
|
||||
assert result == expected
|
||||
|
||||
|
||||
|
|
|
@ -7,7 +7,8 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
|
|||
from custom_components.irm_kmi import IrmKmiCoordinator
|
||||
from custom_components.irm_kmi.binary_sensor import IrmKmiWarning
|
||||
from custom_components.irm_kmi.const import CONF_LANGUAGE_OVERRIDE
|
||||
from custom_components.irm_kmi.sensor import IrmKmiNextWarning, IrmKmiNextSunMove
|
||||
from custom_components.irm_kmi.sensor import (IrmKmiNextSunMove,
|
||||
IrmKmiNextWarning)
|
||||
from tests.conftest import get_api_data
|
||||
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue