Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 16 additions & 17 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,22 @@ on:

jobs:
deploy:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel twine
- name: Build and publish
env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
python setup.py sdist bdist_wheel
twine upload dist/*
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Build and publish
env:
UV_PUBLISH_USERNAME: ${{ secrets.PYPI_USERNAME }}
UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
uv build
uv publish
23 changes: 10 additions & 13 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,27 @@ jobs:
strategy:
matrix:
python-version:
- "3.9"
- "3.10"
- "3.11"
- "3.12"
- "3.13"
- "3.14"
- "pypy-3.9"
- "pypy-3.10"
- "pypy-3.11"

steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: uv sync --group dev
- name: Ruff
run: |
python -m pip install --upgrade pip
python -m pip install flake8
python -m pip install -e .
- name: flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 fmi --count --select=E9,F63,F7,F82 --show-source --statistics
flake8 fmi --count --statistics
uv run ruff check .
uv run ruff format --check .
- name: Test
run: python -m unittest
run: uv run python -m unittest
4 changes: 4 additions & 0 deletions example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from fmi import FMI

f = FMI(place="Lappeenranta")
print(f.observations())
164 changes: 87 additions & 77 deletions fmi/fmi.py
Original file line number Diff line number Diff line change
@@ -1,97 +1,108 @@
import os
import requests
import warnings
from .observation import Observation, Forecast
from bs4 import BeautifulSoup

import requests
from bs4 import BeautifulSoup, XMLParsedAsHTMLWarning

from .observation import Forecast, Observation

class FMI(object):
api_endpoint = 'https://opendata.fmi.fi/wfs'

class FMI:
api_endpoint = "https://opendata.fmi.fi/wfs"

def __init__(self, apikey=None, place=None, coordinates=None):
self.place = os.environ.get('FMI_PLACE', place)
self.coordinates = os.environ.get('FMI_COORDINATES', coordinates)
self.place = os.environ.get("FMI_PLACE", place)
self.coordinates = os.environ.get("FMI_COORDINATES", coordinates)
if apikey is not None:
warnings.simplefilter('default')
warnings.warn('The use of FMI API key is deprecated.',
DeprecationWarning)
warnings.simplefilter("default")
warnings.warn(
"The use of FMI API key is deprecated.",
DeprecationWarning,
stacklevel=2,
)

def _parse_identifier(self, x):
identifier = x['gml:id'].split('-')[-1].lower()
if identifier in ['t2m', 'temperature']:
return 'temperature', 1
if identifier in ['ws_10min', 'windspeedms']:
return 'wind_speed', 1
if identifier in ['wg_10min', 'windgust']:
return 'wind_gust', 1
if identifier in ['wd_10min', 'winddirection']:
return 'wind_direction', 1
if identifier in ['r_1h', 'precipitation1h']:
return 'precipitation_1h', 1
if identifier in ['ri_10min', 'precipitationamount']:
return 'precipitation', 1
if identifier in ['rh', 'humidity']:
return 'humidity', 1
if identifier in ['n_man', 'totalcloudcover']:
return 'cloud_coverage', 12.5 if identifier == 'n_man' else 1
if identifier in ['p_sea', 'pressure']:
return 'pressure', 1
if identifier in ['td', 'dewpoint']:
return 'dew_point', 1
if identifier in ['weathersymbol3']:
return 'weather_symbol', 1
if identifier in ['radiationglobalaccumulation']:
return 'radiation_global_accumulation', 1
if identifier in ['radiationlwaccumulation']:
return 'radiation_long_wave_accumulation', 1
if identifier in ['radiationnetsurfacelwaccumulation']:
return 'radiation_netsurface_long_wave_accumulation', 1
if identifier in ['radiationnetsurfaceswaccumulation']:
return 'radiation_netsurface_short_wave_accumulation', 1
if identifier in ['radiationdiffuseaccumulation']:
return 'radiation_diffuse_accumulation', 1
identifier = x["gml:id"].split("-")[-1].lower()
if identifier in ["t2m", "temperature"]:
return "temperature", 1
if identifier in ["ws_10min", "windspeedms"]:
return "wind_speed", 1
if identifier in ["wg_10min", "windgust"]:
return "wind_gust", 1
if identifier in ["wd_10min", "winddirection"]:
return "wind_direction", 1
if identifier in ["r_1h", "precipitation1h"]:
return "precipitation_1h", 1
if identifier in ["ri_10min", "precipitationamount"]:
return "precipitation", 1
if identifier in ["rh", "humidity"]:
return "humidity", 1
if identifier in ["n_man", "totalcloudcover"]:
return "cloud_coverage", 12.5 if identifier == "n_man" else 1
if identifier in ["p_sea", "pressure"]:
return "pressure", 1
if identifier in ["td", "dewpoint"]:
return "dew_point", 1
if identifier in ["weathersymbol3"]:
return "weather_symbol", 1
if identifier in ["radiationglobalaccumulation"]:
return "radiation_global_accumulation", 1
if identifier in ["radiationlwaccumulation"]:
return "radiation_long_wave_accumulation", 1
if identifier in ["radiationnetsurfacelwaccumulation"]:
return "radiation_netsurface_long_wave_accumulation", 1
if identifier in ["radiationnetsurfaceswaccumulation"]:
return "radiation_netsurface_short_wave_accumulation", 1
if identifier in ["radiationdiffuseaccumulation"]:
return "radiation_diffuse_accumulation", 1
return None, 1

def _parse_response(self, r, klass=Observation):
bs = BeautifulSoup(r.text, 'html.parser')
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
bs = BeautifulSoup(r.text, "html.parser")

d = {}
# Loop over all measurement timeseries
for mts in bs.find_all('wml2:measurementtimeseries'):
for mts in bs.find_all("wml2:measurementtimeseries"):
# Try to parse identifier as "human readable",
# get multiplier also (mainly for cloud coverage)
identifier, multiplier = self._parse_identifier(mts)
if identifier is None:
continue

# Loop through all the measurement points
for p in mts.find_all('wml2:point'):
for p in mts.find_all("wml2:point"):
# Find timestamp
timestamp = p.find('wml2:time').text
timestamp_element = p.find("wml2:time")
if timestamp_element is None:
continue
timestamp = timestamp_element.text
# Find value and multiply if by multiplier
# given in _parse_identifier()
value = float(p.find('wml2:value').text) * multiplier
value_element = p.find("wml2:value")
if value_element is None:
continue
value = float(value_element.text) * multiplier

# If timestamp isn't already initialized,
# initialize as dictionary
if timestamp not in d.keys():
if timestamp not in d:
d[timestamp] = {}

d[timestamp][identifier] = value

return sorted(
[klass(k, v) for k, v in d.items()],
key=lambda x: x.time)
return sorted([klass(k, v) for k, v in d.items()], key=lambda x: x.time)

def get(self, storedquery_id, klass=Observation, **params):
query_params = {
'request': 'getFeature',
'storedquery_id': storedquery_id,
"request": "getFeature",
"storedquery_id": storedquery_id,
}
if self.place is not None:
query_params['place'] = self.place
query_params["place"] = self.place
elif self.coordinates is not None:
query_params['latlon'] = self.coordinates
query_params["latlon"] = self.coordinates
query_params.update(params)

request = requests.get(self.api_endpoint, params=query_params)
Expand All @@ -101,37 +112,36 @@ def get(self, storedquery_id, klass=Observation, **params):

def observations(self, **params):
return self.get(
'fmi::observations::weather::timevaluepair',
maxlocations=1,
**params)
"fmi::observations::weather::timevaluepair", maxlocations=1, **params
)

def forecast(self, model='harmonie', **params):
if model not in ['harmonie']:
def forecast(self, model="harmonie", **params):
if model not in ["harmonie"]:
raise ValueError('model must be "harmonie"')
return self.get(
'fmi::forecast::%s::surface::point::timevaluepair' % (model),
f"fmi::forecast::{model}::surface::point::timevaluepair",
maxlocations=1,
klass=Forecast,
**params)
**params,
)

@staticmethod
def fetch_stations():
response = requests.get('https://cdn.fmi.fi/weather-observations/metadata/all-finnish-observation-stations.fi.json') # noqa: E501
response = requests.get(
"https://cdn.fmi.fi/weather-observations/metadata/all-finnish-observation-stations.fi.json"
) # noqa: E501
response.raise_for_status()
return [
{
'fmisid': station.get('fmisid', None),
'wmo': station.get('wmo', None),
'name': station.get('name', ''),
'latitude': station.get('y', None),
'longitude': station.get('x', None),
'height': station.get('z', None),
'started': station.get('started', 1900),
'groups': [
x.strip()
for x in station.get('groups', '').split(',')
],
"fmisid": station.get("fmisid", None),
"wmo": station.get("wmo", None),
"name": station.get("name", ""),
"latitude": station.get("y", None),
"longitude": station.get("x", None),
"height": station.get("z", None),
"started": station.get("started", 1900),
"groups": [x.strip() for x in station.get("groups", "").split(",")],
}
for station in response.json().get('items', [])
if station['ended'] is None
for station in response.json().get("items", [])
if station["ended"] is None
]
Loading
Loading