initial commit
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
name: Python Code Quality
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
python-code-quality:
|
||||||
|
uses: brian/CI-templates/.gitea/workflows/python/[email protected]
|
||||||
|
with:
|
||||||
|
python-version: ${{ vars.PYTHON_VERSION }}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: Test Python Package
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ inputs.python-version }}
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
run: pip install uv
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
env:
|
||||||
|
UV_LINK_MODE: copy
|
||||||
|
run: uv sync --all-extras
|
||||||
|
|
||||||
|
- name: Run pytest
|
||||||
|
env:
|
||||||
|
PYTHONPATH: .
|
||||||
|
run: uv run pytest --cov=python_repositories --cov-report=term-missing > coverage.txt
|
||||||
|
|
||||||
|
- name: Post coverage summary to PR
|
||||||
|
env:
|
||||||
|
API_URL: ${{ vars.API_URL }}
|
||||||
|
REPO_OWNER: ${{ github.repository_owner }}
|
||||||
|
REPO_NAME: ${{ github.event.repository.name }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
CI_RUNNER_TOKEN: ${{ secrets.CI_RUNNER_TOKEN }}
|
||||||
|
run: |
|
||||||
|
COVERAGE=$(cat coverage.txt)
|
||||||
|
COMMENT_BODY="**Test Coverage Report:**\n\`\`\`\n$COVERAGE\n\`\`\`"
|
||||||
|
curl -s -X POST "$API_URL/repos/$REPO_OWNER/$REPO_NAME/issues/$PR_NUMBER/comments" \
|
||||||
|
-H "Authorization: token $CI_RUNNER_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"body\": \"$COMMENT_BODY\"}"
|
||||||
@@ -187,4 +187,3 @@ ipython_config.py
|
|||||||
|
|
||||||
# Remove previous ipynb_checkpoints
|
# Remove previous ipynb_checkpoints
|
||||||
# git rm -r .ipynb_checkpoints/
|
# git rm -r .ipynb_checkpoints/
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
repos:
|
||||||
|
# General repository hygiene hooks
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.5.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-added-large-files
|
||||||
|
- id: debug-statements
|
||||||
|
- id: name-tests-test
|
||||||
|
- id: check-merge-conflict
|
||||||
|
|
||||||
|
# Python linting and formatting with Ruff
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.12.8
|
||||||
|
hooks:
|
||||||
|
- id: ruff-check
|
||||||
|
args: [--fix]
|
||||||
|
- id: ruff-format
|
||||||
|
|
||||||
|
# Static type checking with mypy
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
|
rev: v1.8.0
|
||||||
|
hooks:
|
||||||
|
- id: mypy
|
||||||
|
|
||||||
|
# Python syntax modernization with pyupgrade
|
||||||
|
- repo: https://github.com/asottile/pyupgrade
|
||||||
|
rev: v3.20.0
|
||||||
|
hooks:
|
||||||
|
- id: pyupgrade
|
||||||
|
args: ["--py312-plus"]
|
||||||
|
|
||||||
|
# Formatting for Markdown, JSON, and YAML with Prettier
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
|
rev: v3.1.0
|
||||||
|
hooks:
|
||||||
|
- id: prettier
|
||||||
|
files: "\\.(md|json|yaml|yml)$"
|
||||||
@@ -2,17 +2,17 @@ MIT License
|
|||||||
|
|
||||||
Copyright (c) 2025 brian
|
Copyright (c) 2025 brian
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||||
following conditions:
|
following conditions:
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||||
portions of the Software.
|
portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "258945b9",
|
||||||
|
"metadata": {
|
||||||
|
"vscode": {
|
||||||
|
"languageId": "plaintext"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"import requests\n",
|
||||||
|
"\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"language_info": {
|
||||||
|
"name": "python"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 5
|
||||||
|
}
|
||||||
@@ -1,3 +1,273 @@
|
|||||||
# elprisenligenu
|
# ElprisenLigeNu Python API Client
|
||||||
|
|
||||||
Python module to interface with ElPrisenLigeNu's API.
|
A Python library for accessing Danish electricity spot prices from the [ElprisenLigeNu.dk](https://www.elprisenligenu.dk) API.
|
||||||
|
|
||||||
|
[](https://python.org)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
ElprisenLigeNu provides real-time and historical Danish electricity spot prices for the two Danish price zones:
|
||||||
|
|
||||||
|
- **DK1** (West of Great Belt - Jutland and Funen)
|
||||||
|
- **DK2** (East of Great Belt - Zealand and Bornholm)
|
||||||
|
|
||||||
|
This library provides a simple Python interface to fetch hourly electricity prices with automatic timezone handling and type-safe data models.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🔌 Simple API for fetching electricity prices
|
||||||
|
- 🇩🇰 Support for both Danish price zones (DK1/DK2)
|
||||||
|
- ⏰ Automatic timezone handling (Europe/Copenhagen)
|
||||||
|
- 📊 Type-safe data models using Pydantic
|
||||||
|
- 💰 Prices in both DKK and EUR with exchange rates
|
||||||
|
- 🕐 Hourly granularity with start/end timestamps
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install elprisenligenu
|
||||||
|
```
|
||||||
|
|
||||||
|
Or using uv:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add elprisenligenu
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import datetime
|
||||||
|
from elprisenligenu import API
|
||||||
|
|
||||||
|
# Initialize the API client
|
||||||
|
api = API()
|
||||||
|
|
||||||
|
# Get today's prices for Copenhagen area (DK2)
|
||||||
|
prices = api.get_daily_prices(zone="DK2")
|
||||||
|
|
||||||
|
# Get prices for Aarhus area (DK1)
|
||||||
|
prices = api.get_daily_prices(zone="DK1")
|
||||||
|
|
||||||
|
# Get prices for a specific date
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
date = datetime(2024, 10, 29, tzinfo=ZoneInfo("Europe/Copenhagen"))
|
||||||
|
prices = api.get_daily_prices(zone="DK2", date=date)
|
||||||
|
|
||||||
|
# Work with the price data
|
||||||
|
for hour_data in prices:
|
||||||
|
print(f"Time: {hour_data.time_start} - {hour_data.time_end}")
|
||||||
|
print(f"Price: {hour_data.DKK_per_kWh:.4f} DKK/kWh")
|
||||||
|
print(f"Price: {hour_data.EUR_per_kWh:.4f} EUR/kWh")
|
||||||
|
print(f"Exchange rate: {hour_data.EXR:.4f}")
|
||||||
|
print("---")
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### API Class
|
||||||
|
|
||||||
|
```python
|
||||||
|
from elprisenligenu import API
|
||||||
|
|
||||||
|
api = API()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
|
||||||
|
##### `get_daily_prices(zone="DK1", date=datetime.now())`
|
||||||
|
|
||||||
|
Fetch hourly electricity prices for a specific day and zone.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
- `zone` (str): Price zone, either "DK1" or "DK2". Default: "DK1"
|
||||||
|
- `date` (datetime): Date to fetch prices for. Must be timezone-aware. Default: current date
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
|
||||||
|
- `list[HourData]`: List of hourly price data objects
|
||||||
|
|
||||||
|
**Raises:**
|
||||||
|
|
||||||
|
- `ValueError`: If zone is not "DK1" or "DK2", or if date is not timezone-aware
|
||||||
|
- `TypeError`: If date is not a datetime object
|
||||||
|
- `requests.HTTPError`: If the API request fails
|
||||||
|
|
||||||
|
### HourData Model
|
||||||
|
|
||||||
|
The `HourData` class represents electricity price data for a single hour:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from elprisenligenu.dto import HourData
|
||||||
|
|
||||||
|
class HourData:
|
||||||
|
DKK_per_kWh: float # Price in Danish Kroner per kWh
|
||||||
|
EUR_per_kWh: float # Price in Euros per kWh
|
||||||
|
EXR: float # Exchange rate EUR to DKK
|
||||||
|
time_start: datetime # Hour start time
|
||||||
|
time_end: datetime # Hour end time
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Find the cheapest and most expensive hours
|
||||||
|
|
||||||
|
```python
|
||||||
|
from elprisenligenu import API
|
||||||
|
|
||||||
|
api = API()
|
||||||
|
prices = api.get_daily_prices(zone="DK2")
|
||||||
|
|
||||||
|
# Find cheapest hour
|
||||||
|
cheapest = min(prices, key=lambda x: x.DKK_per_kWh)
|
||||||
|
print(f"Cheapest: {cheapest.DKK_per_kWh:.4f} DKK/kWh at {cheapest.time_start.hour}:00")
|
||||||
|
|
||||||
|
# Find most expensive hour
|
||||||
|
expensive = max(prices, key=lambda x: x.DKK_per_kWh)
|
||||||
|
print(f"Most expensive: {expensive.DKK_per_kWh:.4f} DKK/kWh at {expensive.time_start.hour}:00")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Calculate daily average price
|
||||||
|
|
||||||
|
```python
|
||||||
|
from elprisenligenu import API
|
||||||
|
|
||||||
|
api = API()
|
||||||
|
prices = api.get_daily_prices(zone="DK1")
|
||||||
|
|
||||||
|
average_price = sum(hour.DKK_per_kWh for hour in prices) / len(prices)
|
||||||
|
print(f"Average price today: {average_price:.4f} DKK/kWh")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get price data for multiple days
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from elprisenligenu import API
|
||||||
|
|
||||||
|
api = API()
|
||||||
|
timezone = ZoneInfo("Europe/Copenhagen")
|
||||||
|
|
||||||
|
# Get prices for the last 7 days
|
||||||
|
for i in range(7):
|
||||||
|
date = datetime.now(timezone) - timedelta(days=i)
|
||||||
|
try:
|
||||||
|
prices = api.get_daily_prices(zone="DK2", date=date)
|
||||||
|
avg_price = sum(hour.DKK_per_kWh for hour in prices) / len(prices)
|
||||||
|
print(f"{date.strftime('%Y-%m-%d')}: {avg_price:.4f} DKK/kWh")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to get data for {date.strftime('%Y-%m-%d')}: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Price Zones
|
||||||
|
|
||||||
|
Denmark is divided into two electricity price zones:
|
||||||
|
|
||||||
|
- **DK1 (West)**: Jutland and Funen (west of the Great Belt)
|
||||||
|
- Major cities: Aarhus, Aalborg, Odense, Esbjerg
|
||||||
|
- **DK2 (East)**: Zealand, Lolland, Falster, and Bornholm (east of the Great Belt)
|
||||||
|
- Major cities: Copenhagen, Roskilde, Helsingør
|
||||||
|
|
||||||
|
## Data Source
|
||||||
|
|
||||||
|
This library uses the free API provided by [ElprisenLigeNu.dk](https://www.elprisenligenu.dk). The electricity prices are:
|
||||||
|
|
||||||
|
- Spot prices from the Nord Pool electricity market
|
||||||
|
- Provided without VAT or additional fees
|
||||||
|
- Updated daily (tomorrow's prices available from 13:00 the day before)
|
||||||
|
- Historical data available from November 1, 2022
|
||||||
|
- Originally sourced from [ENTSO-E](https://transparency.entsoe.eu/) in EUR, converted to DKK
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- Python 3.12+
|
||||||
|
- Dependencies: `pydantic>=2.12.3`, `requests>=2.32.5`
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/yourusername/elprisenligenu.git
|
||||||
|
cd elprisenligenu
|
||||||
|
|
||||||
|
# Install with development dependencies
|
||||||
|
uv sync --dev
|
||||||
|
|
||||||
|
# Run tests (unit tests only)
|
||||||
|
uv run pytest tests/unit/
|
||||||
|
|
||||||
|
# Run all tests including integration tests (requires internet)
|
||||||
|
uv run pytest
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
uv run pytest --cov=src --cov-report=html
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
uv run mypy .
|
||||||
|
|
||||||
|
# Linting
|
||||||
|
uv run ruff check .
|
||||||
|
|
||||||
|
# Formatting
|
||||||
|
uv run ruff format .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
|
||||||
|
The test suite is organized into:
|
||||||
|
|
||||||
|
- **Unit Tests** (`tests/unit/`): Fast tests that mock external dependencies
|
||||||
|
|
||||||
|
- `test_hour_data.py`: Tests for the HourData model
|
||||||
|
- `test_api.py`: Tests for the API class with mocked HTTP requests
|
||||||
|
|
||||||
|
- **Integration Tests** (`tests/test_integration.py`): Tests that make real API calls
|
||||||
|
- Marked with `@pytest.mark.integration`
|
||||||
|
- Can be skipped with `pytest -m "not integration"`
|
||||||
|
|
||||||
|
### Running Specific Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run only unit tests
|
||||||
|
uv run pytest tests/unit/
|
||||||
|
|
||||||
|
# Run only integration tests (requires internet)
|
||||||
|
uv run pytest -m integration
|
||||||
|
|
||||||
|
# Run tests matching a pattern
|
||||||
|
uv run pytest -k "test_hour_data"
|
||||||
|
|
||||||
|
# Run with verbose output
|
||||||
|
uv run pytest -v
|
||||||
|
|
||||||
|
# Run with coverage report
|
||||||
|
uv run pytest --cov=src --cov-report=term-missing
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
When using this library in public projects, please consider attributing the data source:
|
||||||
|
|
||||||
|
> Electricity prices provided by [ElprisenLigeNu.dk](https://www.elprisenligenu.dk)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- 🐛 [Report issues](https://github.com/yourusername/elprisenligenu/issues)
|
||||||
|
- 📧 Contact: [[email protected]](mailto:[email protected])
|
||||||
|
- 🌐 ElprisenLigeNu.dk: [[email protected]](mailto:[email protected])
|
||||||
|
|
||||||
|
## Related Projects
|
||||||
|
|
||||||
|
- [ElprisenLigeNu.dk](https://www.elprisenligenu.dk) - The source website
|
||||||
|
- [API Documentation](https://www.elprisenligenu.dk/elpris-api) - Official API docs
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
[project]
|
||||||
|
name = "elprisenligenu"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = [
|
||||||
|
{ name = "Brian Bjarke Jensen", email = "[email protected]" }
|
||||||
|
]
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"pydantic>=2.12.3",
|
||||||
|
"requests>=2.32.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"ipykernel>=7.1.0",
|
||||||
|
"mypy>=1.18.2",
|
||||||
|
"pre-commit>=4.3.0",
|
||||||
|
"pytest>=8.4.2",
|
||||||
|
"pytest-cov>=7.0.0",
|
||||||
|
"python-dotenv>=1.2.1",
|
||||||
|
"pyupgrade>=3.21.0",
|
||||||
|
"ruff>=0.14.2",
|
||||||
|
"types-requests>=2.32.4.20250913",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[tool.uv.index]]
|
||||||
|
name = "threadripper-proxpi-cache"
|
||||||
|
url = "http://10.0.0.2:5001/index/"
|
||||||
|
default = true
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
mypy_path = "src"
|
||||||
|
warn_return_any = true # nudge to use stricter types
|
||||||
|
warn_unused_configs = true # nudge to remove unused configs
|
||||||
|
disallow_untyped_defs = true # disallow untyped function definitions
|
||||||
|
disallow_incomplete_defs = true # ensure all parts of a function has type annotation
|
||||||
|
check_untyped_defs = true # dont skip untyped functions
|
||||||
|
disallow_untyped_decorators = false # allow untyped decorators to keep code more readable
|
||||||
|
no_implicit_optional = true # disallow implicit optional types leading to None-mess
|
||||||
|
warn_redundant_casts = true # nudge to use explicit type casts
|
||||||
|
warn_unused_ignores = true # nudge to remove unused ignores
|
||||||
|
warn_no_return = true # catch missing return statements
|
||||||
|
warn_unreachable = true # catch unreachable code
|
||||||
|
show_error_codes = true # show error codes in output
|
||||||
|
explicit_package_bases = true # reduce risk of import confusion
|
||||||
|
namespace_packages = true # enable namespace packages
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .api import API
|
||||||
|
|
||||||
|
__all__ = ["API"]
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Definition of the API class for interacting with the ElprisenLigeNu service."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from typing import Literal
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from elprisenligenu.dto.hour_data import HourData
|
||||||
|
|
||||||
|
|
||||||
|
class API:
|
||||||
|
"""Class for interacting with the ElprisenLigeNu API."""
|
||||||
|
|
||||||
|
BASE_URL = "https://www.elprisenligenu.dk/api/v1/prices"
|
||||||
|
TIMEZONE = ZoneInfo("Europe/Copenhagen")
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize the API class."""
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _endpoint(cls, date: datetime, zone: str) -> str:
|
||||||
|
"""Construct the API endpoint URL for the given date and zone."""
|
||||||
|
year_str = date.strftime("%Y")
|
||||||
|
date_str = date.strftime("%m-%d")
|
||||||
|
return f"{cls.BASE_URL}/{year_str}/{date_str}_{zone}.json"
|
||||||
|
|
||||||
|
def get_daily_prices(
|
||||||
|
self,
|
||||||
|
zone: Literal["DK1", "DK2"] = "DK1",
|
||||||
|
date: datetime = datetime.now(tz=TIMEZONE),
|
||||||
|
) -> list[HourData]:
|
||||||
|
"""Fetch the daily electricity prices.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[HourData]: A list of HourData objects containing the daily electricity price data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Check inputs
|
||||||
|
if zone not in ["DK1", "DK2"]:
|
||||||
|
raise ValueError("zone must be either 'DK1' or 'DK2'")
|
||||||
|
if not isinstance(date, datetime):
|
||||||
|
raise TypeError("date must be a datetime object")
|
||||||
|
if date.tzinfo is None:
|
||||||
|
raise ValueError("date must be timezone-aware")
|
||||||
|
if date.tzinfo != self.TIMEZONE:
|
||||||
|
self.logger.info("Converting date to %s timezone", self.TIMEZONE)
|
||||||
|
date = date.astimezone(self.TIMEZONE)
|
||||||
|
|
||||||
|
# Make request
|
||||||
|
endpoint = self._endpoint(date, zone)
|
||||||
|
response = self.session.get(endpoint)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Convert response to list of HourData
|
||||||
|
data = [HourData(**entry) for entry in response.json()]
|
||||||
|
|
||||||
|
return data
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .hour_data import HourData
|
||||||
|
|
||||||
|
__all__ = ["HourData"]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Definition of HourData DTO for ElprisenLigeNu service."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class HourData(BaseModel):
|
||||||
|
"""Data Transfer Object for hourly electricity price data."""
|
||||||
|
|
||||||
|
DKK_per_kWh: float
|
||||||
|
EUR_per_kWh: float
|
||||||
|
EXR: float
|
||||||
|
time_start: datetime
|
||||||
|
time_end: datetime
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Test package for elprisenligenu."""
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Test configuration and fixtures for elprisenligenu tests."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config: pytest.Config) -> None:
|
||||||
|
"""Configure pytest with custom markers."""
|
||||||
|
config.addinivalue_line(
|
||||||
|
"markers",
|
||||||
|
"integration: marks tests as integration tests (may require internet)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_api_response() -> list[dict]:
|
||||||
|
"""Fixture providing a mock API response."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"DKK_per_kWh": 1.3929,
|
||||||
|
"EUR_per_kWh": 0.18729,
|
||||||
|
"EXR": 7.437118,
|
||||||
|
"time_start": "2024-10-29T00:00:00+01:00",
|
||||||
|
"time_end": "2024-10-29T01:00:00+01:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"DKK_per_kWh": 1.30291,
|
||||||
|
"EUR_per_kWh": 0.17519,
|
||||||
|
"EXR": 7.437118,
|
||||||
|
"time_start": "2024-10-29T01:00:00+01:00",
|
||||||
|
"time_end": "2024-10-29T02:00:00+01:00",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_hour_data() -> dict:
|
||||||
|
"""Fixture providing sample HourData for testing."""
|
||||||
|
|
||||||
|
tz = ZoneInfo("Europe/Copenhagen")
|
||||||
|
return {
|
||||||
|
"DKK_per_kWh": 1.2345,
|
||||||
|
"EUR_per_kWh": 0.1659,
|
||||||
|
"EXR": 7.4371,
|
||||||
|
"time_start": datetime(2024, 10, 29, 12, 0, 0, tzinfo=tz),
|
||||||
|
"time_end": datetime(2024, 10, 29, 13, 0, 0, tzinfo=tz),
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Integration tests for the elprisenligenu module."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from elprisenligenu.api import API
|
||||||
|
from elprisenligenu.dto.hour_data import HourData
|
||||||
|
|
||||||
|
|
||||||
|
class TestElprisenLigenIntegration:
|
||||||
|
"""Integration tests that make real API calls."""
|
||||||
|
|
||||||
|
def setup_method(self) -> None:
|
||||||
|
"""Set up test fixtures before each test method."""
|
||||||
|
self.api = API()
|
||||||
|
self.tz = ZoneInfo("Europe/Copenhagen")
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_get_current_prices_dk1(self) -> None:
|
||||||
|
"""Test fetching current day prices for DK1 (integration test)."""
|
||||||
|
# Act
|
||||||
|
result = self.api.get_daily_prices(zone="DK1")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert all(isinstance(item, HourData) for item in result)
|
||||||
|
|
||||||
|
if result: # If data is available
|
||||||
|
first_hour = result[0]
|
||||||
|
assert isinstance(first_hour.DKK_per_kWh, float)
|
||||||
|
assert isinstance(first_hour.EUR_per_kWh, float)
|
||||||
|
assert isinstance(first_hour.EXR, float)
|
||||||
|
assert isinstance(first_hour.time_start, datetime)
|
||||||
|
assert isinstance(first_hour.time_end, datetime)
|
||||||
|
|
||||||
|
# Basic sanity checks
|
||||||
|
assert first_hour.EXR > 0 # Exchange rate should be positive
|
||||||
|
assert first_hour.time_start < first_hour.time_end
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_get_current_prices_dk2(self) -> None:
|
||||||
|
"""Test fetching current day prices for DK2 (integration test)."""
|
||||||
|
# Act
|
||||||
|
result = self.api.get_daily_prices(zone="DK2")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert all(isinstance(item, HourData) for item in result)
|
||||||
|
|
||||||
|
if result: # If data is available
|
||||||
|
first_hour = result[0]
|
||||||
|
assert isinstance(first_hour.DKK_per_kWh, float)
|
||||||
|
assert isinstance(first_hour.EUR_per_kWh, float)
|
||||||
|
assert isinstance(first_hour.EXR, float)
|
||||||
|
assert isinstance(first_hour.time_start, datetime)
|
||||||
|
assert isinstance(first_hour.time_end, datetime)
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_get_historical_prices(self) -> None:
|
||||||
|
"""Test fetching historical prices (integration test)."""
|
||||||
|
# Arrange - Use a date that should have data (not too far back)
|
||||||
|
historical_date = datetime(2024, 10, 1, tzinfo=self.tz)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = self.api.get_daily_prices(zone="DK1", date=historical_date)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert isinstance(result, list)
|
||||||
|
|
||||||
|
if result: # If historical data is available
|
||||||
|
assert len(result) <= 24 # Should be max 24 hours
|
||||||
|
assert all(isinstance(item, HourData) for item in result)
|
||||||
|
|
||||||
|
# Check that all times are from the requested date
|
||||||
|
for hour_data in result:
|
||||||
|
assert hour_data.time_start.date() == historical_date.date()
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_price_consistency(self) -> None:
|
||||||
|
"""Test that EUR and DKK prices are consistent with exchange rate."""
|
||||||
|
# Act
|
||||||
|
result = self.api.get_daily_prices(zone="DK1")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
if result:
|
||||||
|
for hour_data in result:
|
||||||
|
# Calculate expected DKK price from EUR price and exchange rate
|
||||||
|
expected_dkk = hour_data.EUR_per_kWh * hour_data.EXR
|
||||||
|
|
||||||
|
# Allow for small floating point differences
|
||||||
|
difference = abs(hour_data.DKK_per_kWh - expected_dkk)
|
||||||
|
assert difference < 0.001, (
|
||||||
|
f"Price inconsistency: DKK={hour_data.DKK_per_kWh}, "
|
||||||
|
f"EUR={hour_data.EUR_per_kWh}, EXR={hour_data.EXR}, "
|
||||||
|
f"Expected DKK={expected_dkk}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_time_ordering(self) -> None:
|
||||||
|
"""Test that hours are returned in chronological order."""
|
||||||
|
# Act
|
||||||
|
result = self.api.get_daily_prices(zone="DK1")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
if len(result) > 1:
|
||||||
|
for i in range(1, len(result)):
|
||||||
|
assert result[i - 1].time_start <= result[i].time_start, (
|
||||||
|
f"Hours not in chronological order: "
|
||||||
|
f"{result[i - 1].time_start} > {result[i].time_start}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_hour_duration(self) -> None:
|
||||||
|
"""Test that each hour period is exactly 1 hour long."""
|
||||||
|
# Act
|
||||||
|
result = self.api.get_daily_prices(zone="DK1")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
if result:
|
||||||
|
for hour_data in result:
|
||||||
|
duration = hour_data.time_end - hour_data.time_start
|
||||||
|
assert duration.total_seconds() == 3600, (
|
||||||
|
f"Hour duration is not 1 hour: {duration}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_both_zones_same_day(self) -> None:
|
||||||
|
"""Test that both zones return data for the same day."""
|
||||||
|
# Arrange
|
||||||
|
test_date = datetime(2024, 10, 15, tzinfo=self.tz)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
dk1_result = self.api.get_daily_prices(zone="DK1", date=test_date)
|
||||||
|
dk2_result = self.api.get_daily_prices(zone="DK2", date=test_date)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
# Both zones should have data for the same day
|
||||||
|
if dk1_result and dk2_result:
|
||||||
|
assert len(dk1_result) == len(dk2_result)
|
||||||
|
|
||||||
|
# Times should be the same
|
||||||
|
for dk1_hour, dk2_hour in zip(dk1_result, dk2_result):
|
||||||
|
assert dk1_hour.time_start == dk2_hour.time_start
|
||||||
|
assert dk1_hour.time_end == dk2_hour.time_end
|
||||||
|
|
||||||
|
# Exchange rates should be the same
|
||||||
|
assert dk1_hour.EXR == dk2_hour.EXR
|
||||||
|
|
||||||
|
# But prices can be different between zones
|
||||||
|
# (That's the whole point of having two zones!)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Unit tests package for elprisenligenu."""
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
"""Unit tests for the API class."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from elprisenligenu.api import API
|
||||||
|
from elprisenligenu.dto.hour_data import HourData
|
||||||
|
|
||||||
|
|
||||||
|
class TestAPI(unittest.TestCase):
|
||||||
|
"""Test cases for the API class."""
|
||||||
|
|
||||||
|
api: API
|
||||||
|
tz: ZoneInfo
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
"""Set up test fixtures before any tests are run."""
|
||||||
|
cls.api = API()
|
||||||
|
cls.tz = ZoneInfo("Europe/Copenhagen")
|
||||||
|
|
||||||
|
def test_api_initialization(self) -> None:
|
||||||
|
"""Test API class initialization."""
|
||||||
|
# Act
|
||||||
|
api = API()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertIsNotNone(api.session)
|
||||||
|
self.assertIsInstance(api.session, requests.Session)
|
||||||
|
self.assertIsNotNone(api.logger)
|
||||||
|
self.assertEqual(api.BASE_URL, "https://www.elprisenligenu.dk/api/v1/prices")
|
||||||
|
self.assertEqual(api.TIMEZONE, ZoneInfo("Europe/Copenhagen"))
|
||||||
|
|
||||||
|
def test_endpoint_construction_dk1(self) -> None:
|
||||||
|
"""Test endpoint URL construction for DK1."""
|
||||||
|
# Arrange
|
||||||
|
date = datetime(2024, 10, 29, 12, 0, 0, tzinfo=self.tz)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
endpoint = API._endpoint(date, "DK1")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
expected = "https://www.elprisenligenu.dk/api/v1/prices/2024/10-29_DK1.json"
|
||||||
|
self.assertEqual(endpoint, expected)
|
||||||
|
|
||||||
|
def test_endpoint_construction_dk2(self) -> None:
|
||||||
|
"""Test endpoint URL construction for DK2."""
|
||||||
|
# Arrange
|
||||||
|
date = datetime(2024, 1, 5, 12, 0, 0, tzinfo=self.tz)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
endpoint = API._endpoint(date, "DK2")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
expected = "https://www.elprisenligenu.dk/api/v1/prices/2024/01-05_DK2.json"
|
||||||
|
self.assertEqual(endpoint, expected)
|
||||||
|
|
||||||
|
def test_endpoint_construction_edge_dates(self) -> None:
|
||||||
|
"""Test endpoint URL construction for edge case dates."""
|
||||||
|
# Test December 31st
|
||||||
|
date = datetime(2023, 12, 31, 12, 0, 0, tzinfo=self.tz)
|
||||||
|
endpoint = API._endpoint(date, "DK1")
|
||||||
|
expected = "https://www.elprisenligenu.dk/api/v1/prices/2023/12-31_DK1.json"
|
||||||
|
self.assertEqual(endpoint, expected)
|
||||||
|
|
||||||
|
# Test January 1st
|
||||||
|
date = datetime(2024, 1, 1, 12, 0, 0, tzinfo=self.tz)
|
||||||
|
endpoint = API._endpoint(date, "DK2")
|
||||||
|
expected = "https://www.elprisenligenu.dk/api/v1/prices/2024/01-01_DK2.json"
|
||||||
|
self.assertEqual(endpoint, expected)
|
||||||
|
|
||||||
|
@patch("elprisenligenu.api.requests.Session.get")
|
||||||
|
def test_get_daily_prices_success_dk1(self, mock_get: Mock) -> None:
|
||||||
|
"""Test successful API call for DK1."""
|
||||||
|
# Arrange
|
||||||
|
mock_response_data = [
|
||||||
|
{
|
||||||
|
"DKK_per_kWh": 1.3929,
|
||||||
|
"EUR_per_kWh": 0.18729,
|
||||||
|
"EXR": 7.437118,
|
||||||
|
"time_start": "2024-10-29T00:00:00+01:00",
|
||||||
|
"time_end": "2024-10-29T01:00:00+01:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"DKK_per_kWh": 1.30291,
|
||||||
|
"EUR_per_kWh": 0.17519,
|
||||||
|
"EXR": 7.437118,
|
||||||
|
"time_start": "2024-10-29T01:00:00+01:00",
|
||||||
|
"time_end": "2024-10-29T02:00:00+01:00",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.json.return_value = mock_response_data
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
date = datetime(2024, 10, 29, 12, 0, 0, tzinfo=self.tz)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = self.api.get_daily_prices(zone="DK1", date=date)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
self.assertTrue(all(isinstance(item, HourData) for item in result))
|
||||||
|
self.assertAlmostEqual(result[0].DKK_per_kWh, 1.3929, places=4)
|
||||||
|
self.assertAlmostEqual(result[1].DKK_per_kWh, 1.30291, places=4)
|
||||||
|
|
||||||
|
# Verify API was called correctly
|
||||||
|
expected_url = "https://www.elprisenligenu.dk/api/v1/prices/2024/10-29_DK1.json"
|
||||||
|
mock_get.assert_called_once_with(expected_url)
|
||||||
|
|
||||||
|
@patch("elprisenligenu.api.requests.Session.get")
|
||||||
|
def test_get_daily_prices_success_dk2(self, mock_get: Mock) -> None:
|
||||||
|
"""Test successful API call for DK2."""
|
||||||
|
# Arrange
|
||||||
|
mock_response_data = [
|
||||||
|
{
|
||||||
|
"DKK_per_kWh": 1.5000,
|
||||||
|
"EUR_per_kWh": 0.2000,
|
||||||
|
"EXR": 7.5,
|
||||||
|
"time_start": "2024-10-29T00:00:00+01:00",
|
||||||
|
"time_end": "2024-10-29T01:00:00+01:00",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.json.return_value = mock_response_data
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
date = datetime(2024, 10, 29, 12, 0, 0, tzinfo=self.tz)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = self.api.get_daily_prices(zone="DK2", date=date)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
self.assertAlmostEqual(result[0].DKK_per_kWh, 1.5000, places=4)
|
||||||
|
|
||||||
|
# Verify API was called correctly
|
||||||
|
expected_url = "https://www.elprisenligenu.dk/api/v1/prices/2024/10-29_DK2.json"
|
||||||
|
mock_get.assert_called_once_with(expected_url)
|
||||||
|
|
||||||
|
def test_get_daily_prices_invalid_zone(self) -> None:
|
||||||
|
"""Test validation error for invalid zone."""
|
||||||
|
# Arrange
|
||||||
|
date = datetime(2024, 10, 29, 12, 0, 0, tzinfo=self.tz)
|
||||||
|
|
||||||
|
# Act & Assert
|
||||||
|
with pytest.raises(ValueError, match="zone must be either 'DK1' or 'DK2'"):
|
||||||
|
self.api.get_daily_prices(zone="DK3", date=date) # type: ignore
|
||||||
|
|
||||||
|
def test_get_daily_prices_invalid_date_type(self) -> None:
|
||||||
|
"""Test validation error for invalid date type."""
|
||||||
|
# Act & Assert
|
||||||
|
with pytest.raises(TypeError, match="date must be a datetime object"):
|
||||||
|
self.api.get_daily_prices(zone="DK1", date="2024-10-29") # type: ignore
|
||||||
|
|
||||||
|
def test_get_daily_prices_naive_datetime(self) -> None:
|
||||||
|
"""Test validation error for naive datetime."""
|
||||||
|
# Arrange
|
||||||
|
naive_date = datetime(2024, 10, 29, 12, 0, 0) # No timezone
|
||||||
|
|
||||||
|
# Act & Assert
|
||||||
|
with pytest.raises(ValueError, match="date must be timezone-aware"):
|
||||||
|
self.api.get_daily_prices(zone="DK1", date=naive_date)
|
||||||
|
|
||||||
|
@patch("elprisenligenu.api.requests.Session.get")
|
||||||
|
def test_get_daily_prices_timezone_conversion(self, mock_get: Mock) -> None:
|
||||||
|
"""Test automatic timezone conversion."""
|
||||||
|
# Arrange
|
||||||
|
mock_response_data = [
|
||||||
|
{
|
||||||
|
"DKK_per_kWh": 1.0,
|
||||||
|
"EUR_per_kWh": 0.13,
|
||||||
|
"EXR": 7.5,
|
||||||
|
"time_start": "2024-10-29T00:00:00+01:00",
|
||||||
|
"time_end": "2024-10-29T01:00:00+01:00",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.json.return_value = mock_response_data
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
# Use UTC timezone instead of Copenhagen
|
||||||
|
utc_date = datetime(2024, 10, 29, 11, 0, 0, tzinfo=ZoneInfo("UTC"))
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = self.api.get_daily_prices(zone="DK1", date=utc_date)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
# Should still work, date gets converted internally
|
||||||
|
|
||||||
|
@patch("elprisenligenu.api.requests.Session.get")
|
||||||
|
def test_get_daily_prices_http_error(self, mock_get: Mock) -> None:
|
||||||
|
"""Test handling of HTTP errors."""
|
||||||
|
# Arrange
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found")
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
date = datetime(2024, 10, 29, 12, 0, 0, tzinfo=self.tz)
|
||||||
|
|
||||||
|
# Act & Assert
|
||||||
|
with pytest.raises(requests.HTTPError):
|
||||||
|
self.api.get_daily_prices(zone="DK1", date=date)
|
||||||
|
|
||||||
|
@patch("elprisenligenu.api.requests.Session.get")
|
||||||
|
def test_get_daily_prices_invalid_json(self, mock_get: Mock) -> None:
|
||||||
|
"""Test handling of invalid JSON response."""
|
||||||
|
# Arrange
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_response.json.side_effect = json.JSONDecodeError("Invalid JSON", "", 0)
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
date = datetime(2024, 10, 29, 12, 0, 0, tzinfo=self.tz)
|
||||||
|
|
||||||
|
# Act & Assert
|
||||||
|
with pytest.raises(json.JSONDecodeError):
|
||||||
|
self.api.get_daily_prices(zone="DK1", date=date)
|
||||||
|
|
||||||
|
@patch("elprisenligenu.api.requests.Session.get")
|
||||||
|
def test_get_daily_prices_empty_response(self, mock_get: Mock) -> None:
|
||||||
|
"""Test handling of empty response."""
|
||||||
|
# Arrange
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.json.return_value = []
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
date = datetime(2024, 10, 29, 12, 0, 0, tzinfo=self.tz)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = self.api.get_daily_prices(zone="DK1", date=date)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
@patch("elprisenligenu.api.requests.Session.get")
|
||||||
|
def test_get_daily_prices_malformed_data(self, mock_get: Mock) -> None:
|
||||||
|
"""Test handling of malformed response data."""
|
||||||
|
# Arrange
|
||||||
|
mock_response_data = [
|
||||||
|
{
|
||||||
|
"DKK_per_kWh": "not_a_number", # Invalid type
|
||||||
|
"EUR_per_kWh": 0.18729,
|
||||||
|
"EXR": 7.437118,
|
||||||
|
"time_start": "2024-10-29T00:00:00+01:00",
|
||||||
|
"time_end": "2024-10-29T01:00:00+01:00",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.json.return_value = mock_response_data
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
date = datetime(2024, 10, 29, 12, 0, 0, tzinfo=self.tz)
|
||||||
|
|
||||||
|
# Act & Assert
|
||||||
|
with pytest.raises(Exception): # Pydantic validation error
|
||||||
|
self.api.get_daily_prices(zone="DK1", date=date)
|
||||||
|
|
||||||
|
def test_get_daily_prices_default_parameters(self) -> None:
|
||||||
|
"""Test that default parameters work correctly."""
|
||||||
|
# This test verifies the method signature but doesn't make real API calls
|
||||||
|
# We just check that the method can be called without parameters
|
||||||
|
|
||||||
|
# Act & Assert - should not raise an error for the method signature
|
||||||
|
try:
|
||||||
|
# We expect this to fail with a network error in tests, but the
|
||||||
|
# parameter validation should pass
|
||||||
|
self.api.get_daily_prices()
|
||||||
|
except (requests.RequestException, requests.ConnectionError):
|
||||||
|
# Expected in test environment without internet
|
||||||
|
pass
|
||||||
|
|
||||||
|
@patch("elprisenligenu.api.requests.Session.get")
|
||||||
|
def test_get_daily_prices_full_day_data(self, mock_get: Mock) -> None:
|
||||||
|
"""Test processing a full day of 24 hours."""
|
||||||
|
# Arrange - Create 24 hours of mock data
|
||||||
|
mock_response_data = []
|
||||||
|
for hour in range(24):
|
||||||
|
mock_response_data.append(
|
||||||
|
{
|
||||||
|
"DKK_per_kWh": 1.0 + hour * 0.1,
|
||||||
|
"EUR_per_kWh": 0.13 + hour * 0.01,
|
||||||
|
"EXR": 7.5,
|
||||||
|
"time_start": f"2024-10-29T{hour:02d}:00:00+01:00",
|
||||||
|
"time_end": f"2024-10-29T{(hour + 1) % 24:02d}:00:00+01:00",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.json.return_value = mock_response_data
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
date = datetime(2024, 10, 29, 12, 0, 0, tzinfo=self.tz)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = self.api.get_daily_prices(zone="DK1", date=date)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(len(result), 24)
|
||||||
|
self.assertTrue(all(isinstance(item, HourData) for item in result))
|
||||||
|
|
||||||
|
# Check that prices increase as expected
|
||||||
|
self.assertEqual(result[0].DKK_per_kWh, 1.0)
|
||||||
|
self.assertAlmostEqual(result[23].DKK_per_kWh, 3.3, 3) # 1.0 + 23 * 0.1
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""Unit tests for the HourData DTO model."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from elprisenligenu.dto.hour_data import HourData
|
||||||
|
|
||||||
|
|
||||||
|
class TestHourData(unittest.TestCase):
|
||||||
|
"""Test cases for the HourData model."""
|
||||||
|
|
||||||
|
def test_valid_hour_data_creation(self) -> None:
|
||||||
|
"""Test creating a valid HourData instance."""
|
||||||
|
# Arrange
|
||||||
|
tz = ZoneInfo("Europe/Copenhagen")
|
||||||
|
start_time = datetime(2024, 10, 29, 12, 0, 0, tzinfo=tz)
|
||||||
|
end_time = datetime(2024, 10, 29, 13, 0, 0, tzinfo=tz)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
hour_data = HourData(
|
||||||
|
DKK_per_kWh=1.2345,
|
||||||
|
EUR_per_kWh=0.1659,
|
||||||
|
EXR=7.4371,
|
||||||
|
time_start=start_time,
|
||||||
|
time_end=end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(hour_data.DKK_per_kWh, 1.2345)
|
||||||
|
self.assertEqual(hour_data.EUR_per_kWh, 0.1659)
|
||||||
|
self.assertEqual(hour_data.EXR, 7.4371)
|
||||||
|
self.assertEqual(hour_data.time_start, start_time)
|
||||||
|
self.assertEqual(hour_data.time_end, end_time)
|
||||||
|
|
||||||
|
def test_hour_data_from_api_response(self) -> None:
|
||||||
|
"""Test creating HourData from typical API response data."""
|
||||||
|
|
||||||
|
# Act - Pydantic can parse ISO datetime strings
|
||||||
|
hour_data = HourData(
|
||||||
|
DKK_per_kWh=1.3929,
|
||||||
|
EUR_per_kWh=0.18729,
|
||||||
|
EXR=7.437118,
|
||||||
|
time_start=datetime.fromisoformat("2022-11-25T00:00:00+01:00"),
|
||||||
|
time_end=datetime.fromisoformat("2022-11-25T01:00:00+01:00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(hour_data.DKK_per_kWh, 1.3929)
|
||||||
|
self.assertEqual(hour_data.EUR_per_kWh, 0.18729)
|
||||||
|
self.assertEqual(hour_data.EXR, 7.437118)
|
||||||
|
self.assertEqual(hour_data.time_start.year, 2022)
|
||||||
|
self.assertEqual(hour_data.time_start.month, 11)
|
||||||
|
self.assertEqual(hour_data.time_start.day, 25)
|
||||||
|
self.assertEqual(hour_data.time_start.hour, 0)
|
||||||
|
self.assertEqual(hour_data.time_end.hour, 1)
|
||||||
|
|
||||||
|
def test_hour_data_validation_missing_fields(self) -> None:
|
||||||
|
"""Test validation fails when required fields are missing."""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
incomplete_data = {
|
||||||
|
"DKK_per_kWh": 1.2345,
|
||||||
|
"EUR_per_kWh": 0.1659,
|
||||||
|
# Missing EXR, time_start, time_end
|
||||||
|
}
|
||||||
|
|
||||||
|
# Act & Assert
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
HourData(**incomplete_data) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
errors = exc_info.value.errors()
|
||||||
|
missing_fields = {error["loc"][0] for error in errors}
|
||||||
|
self.assertIn("EXR", missing_fields)
|
||||||
|
self.assertIn("time_start", missing_fields)
|
||||||
|
self.assertIn("time_end", missing_fields)
|
||||||
|
|
||||||
|
def test_hour_data_validation_invalid_types(self) -> None:
|
||||||
|
"""Test validation fails with invalid data types."""
|
||||||
|
# Arrange
|
||||||
|
invalid_data = {
|
||||||
|
"DKK_per_kWh": "not_a_number", # Should be float
|
||||||
|
"EUR_per_kWh": 0.1659,
|
||||||
|
"EXR": 7.4371,
|
||||||
|
"time_start": "2024-10-29T12:00:00+01:00",
|
||||||
|
"time_end": "2024-10-29T13:00:00+01:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Act & Assert
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
HourData(**invalid_data) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
errors = exc_info.value.errors()
|
||||||
|
self.assertTrue(any(error["loc"][0] == "DKK_per_kWh" for error in errors))
|
||||||
|
|
||||||
|
def test_hour_data_negative_prices(self) -> None:
|
||||||
|
"""Test HourData accepts negative prices (real markets)."""
|
||||||
|
# Arrange
|
||||||
|
tz = ZoneInfo("Europe/Copenhagen")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
hour_data = HourData(
|
||||||
|
DKK_per_kWh=-0.5, # Negative price
|
||||||
|
EUR_per_kWh=-0.067,
|
||||||
|
EXR=7.4371,
|
||||||
|
time_start=datetime(2024, 10, 29, 12, 0, 0, tzinfo=tz),
|
||||||
|
time_end=datetime(2024, 10, 29, 13, 0, 0, tzinfo=tz),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(hour_data.DKK_per_kWh, -0.5)
|
||||||
|
self.assertEqual(hour_data.EUR_per_kWh, -0.067)
|
||||||
|
|
||||||
|
def test_hour_data_zero_prices(self) -> None:
|
||||||
|
"""Test HourData accepts zero prices."""
|
||||||
|
# Arrange
|
||||||
|
tz = ZoneInfo("Europe/Copenhagen")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
hour_data = HourData(
|
||||||
|
DKK_per_kWh=0.0,
|
||||||
|
EUR_per_kWh=0.0,
|
||||||
|
EXR=7.4371,
|
||||||
|
time_start=datetime(2024, 10, 29, 12, 0, 0, tzinfo=tz),
|
||||||
|
time_end=datetime(2024, 10, 29, 13, 0, 0, tzinfo=tz),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(hour_data.DKK_per_kWh, 0.0)
|
||||||
|
self.assertEqual(hour_data.EUR_per_kWh, 0.0)
|
||||||
|
|
||||||
|
def test_hour_data_serialization(self) -> None:
|
||||||
|
"""Test HourData can be serialized to JSON."""
|
||||||
|
# Arrange
|
||||||
|
tz = ZoneInfo("Europe/Copenhagen")
|
||||||
|
start_time = datetime(2024, 10, 29, 12, 0, 0, tzinfo=tz)
|
||||||
|
end_time = datetime(2024, 10, 29, 13, 0, 0, tzinfo=tz)
|
||||||
|
|
||||||
|
hour_data = HourData(
|
||||||
|
DKK_per_kWh=1.2345,
|
||||||
|
EUR_per_kWh=0.1659,
|
||||||
|
EXR=7.4371,
|
||||||
|
time_start=start_time,
|
||||||
|
time_end=end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
json_data = hour_data.model_dump()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(json_data["DKK_per_kWh"], 1.2345)
|
||||||
|
self.assertEqual(json_data["EUR_per_kWh"], 0.1659)
|
||||||
|
self.assertEqual(json_data["EXR"], 7.4371)
|
||||||
|
self.assertEqual(json_data["time_start"], start_time)
|
||||||
|
self.assertEqual(json_data["time_end"], end_time)
|
||||||
|
|
||||||
|
def test_hour_data_string_representation(self) -> None:
|
||||||
|
"""Test HourData string representation is readable."""
|
||||||
|
# Arrange
|
||||||
|
tz = ZoneInfo("Europe/Copenhagen")
|
||||||
|
hour_data = HourData(
|
||||||
|
DKK_per_kWh=1.2345,
|
||||||
|
EUR_per_kWh=0.1659,
|
||||||
|
EXR=7.4371,
|
||||||
|
time_start=datetime(2024, 10, 29, 12, 0, 0, tzinfo=tz),
|
||||||
|
time_end=datetime(2024, 10, 29, 13, 0, 0, tzinfo=tz),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
str_repr = str(hour_data)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertIn("DKK_per_kWh=1.2345", str_repr)
|
||||||
|
self.assertIn("EUR_per_kWh=0.1659", str_repr)
|
||||||
|
self.assertIn("EXR=7.4371", str_repr)
|
||||||
|
|
||||||
|
def test_hour_data_equality(self) -> None:
|
||||||
|
"""Test HourData equality comparison."""
|
||||||
|
# Arrange
|
||||||
|
tz = ZoneInfo("Europe/Copenhagen")
|
||||||
|
start_time = datetime(2024, 10, 29, 12, 0, 0, tzinfo=tz)
|
||||||
|
end_time = datetime(2024, 10, 29, 13, 0, 0, tzinfo=tz)
|
||||||
|
|
||||||
|
hour_data1 = HourData(
|
||||||
|
DKK_per_kWh=1.2345,
|
||||||
|
EUR_per_kWh=0.1659,
|
||||||
|
EXR=7.4371,
|
||||||
|
time_start=start_time,
|
||||||
|
time_end=end_time,
|
||||||
|
)
|
||||||
|
hour_data2 = HourData(
|
||||||
|
DKK_per_kWh=1.2345,
|
||||||
|
EUR_per_kWh=0.1659,
|
||||||
|
EXR=7.4371,
|
||||||
|
time_start=start_time,
|
||||||
|
time_end=end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act & Assert
|
||||||
|
self.assertEqual(hour_data1, hour_data2)
|
||||||
|
|
||||||
|
def test_hour_data_inequality(self) -> None:
|
||||||
|
"""Test HourData inequality comparison."""
|
||||||
|
# Arrange
|
||||||
|
tz = ZoneInfo("Europe/Copenhagen")
|
||||||
|
start_time = datetime(2024, 10, 29, 12, 0, 0, tzinfo=tz)
|
||||||
|
end_time = datetime(2024, 10, 29, 13, 0, 0, tzinfo=tz)
|
||||||
|
|
||||||
|
hour_data1 = HourData(
|
||||||
|
DKK_per_kWh=1.2345,
|
||||||
|
EUR_per_kWh=0.1659,
|
||||||
|
EXR=7.4371,
|
||||||
|
time_start=start_time,
|
||||||
|
time_end=end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
hour_data2 = HourData(
|
||||||
|
DKK_per_kWh=2.0000, # Different price
|
||||||
|
EUR_per_kWh=0.1659,
|
||||||
|
EXR=7.4371,
|
||||||
|
time_start=start_time,
|
||||||
|
time_end=end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act & Assert
|
||||||
|
self.assertNotEqual(hour_data1, hour_data2)
|
||||||
Reference in New Issue
Block a user