Fix pollen parsing

This commit is contained in:
Jules 2024-06-29 15:16:55 +02:00
parent 3404e1649d
commit d16a08f647
Signed by: jdejaegh
GPG key ID: 99D6D184CA66933A
8 changed files with 66 additions and 146 deletions

View file

@ -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',

View file

@ -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

View file

@ -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

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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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