diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..05de509 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,41 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.pyc +*.pyo +.venv +venv +*.egg-info +.mypy_cache +.pytest_cache +.ruff_cache +htmlcov +.coverage + +# Node +frontend/node_modules + +# IDE +.vscode +.idea +*.swp +*.swo + +# Docker +core/compose.dev.yaml + +# User data (mounted at runtime) +user_data +user_data__ + +# Docs and dev files +docs/ +*.md +LICENSE +core/Makefile +core/dev-tools.* +core/requirements-dev.txt +core/tests/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..39c9b9e --- /dev/null +++ b/.env.example @@ -0,0 +1,50 @@ +# Edge Mining environment configuration file +# +# Copy this file to .env and fill in your actual credentials + +# +# Remove the '#' at the beginning of each line to uncomment the settings you want to use. +# replace the placeholder values with your actual credentials or values. +# +# NOTE: Remove the '#' after the value also if you uncomment a line. +# + +# Application Settings +LOG_LEVEL=INFO + +# Set your timezone (e.g., Europe/Rome, America/New_York) +TIMEZONE=Europe/Rome + +# Coordinates for location-based services (e.g., weather forecasting or Sun properties) +LATITUDE=41.9028 +LONGITUDE=12.4964 + +# Persistence Settings +# Adapter type: "in_memory", "sqlite", "sqlalchemy" +PERSISTENCE_ADAPTER=sqlalchemy + +# Policies adapter type: "in_memory", "sqlite", "yaml", "sqlalchemy" +POLICIES_PERSISTENCE_ADAPTER=yaml + +# Database configuration +# For SQLite: sqlite:///data/db/edgemining.db (default) +# For PostgreSQL: postgresql://user:password@localhost:5432/dbname +# For MySQL: mysql+pymysql://user:password@localhost:3306/dbname +DB_PATH=sqlite:///data/db/edgemining.db + +# Alembic Migrations +# Automatically run database migrations on startup (recommended) +RUN_MIGRATIONS_ON_STARTUP=true + +# Create a backup of the database before running migrations (SQLite only) +# Backups are stored in data/db/backups/ +BACKUP_BEFORE_MIGRATION=true + +# Path to the directory for storing optimization policies (when using YAML) +YAML_POLICIES_DIR=data/policies + +# API Settings +API_PORT=8001 + +# Scheduler +SCHEDULER_INTERVAL_SECONDS=5 diff --git a/.gitignore b/.gitignore index 35579e5..0a68684 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,204 @@ -# Ignore user data files -user_data/* \ No newline at end of file +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Development tools +.mypy_cache/ +.bandit/ +.coverage.* +htmlcov/ +.pytest_cache/ +*.log + +# User data directory +data/db/ +user_data/* +user_data__/ + +# Track example templates but ignore any custom files added by users +data/examples/* +data/policies/* +!data/examples/rules/ +!data/examples/rules/start/ +!data/examples/rules/stop/ +!data/examples/rules/start/*.yaml +!data/examples/rules/stop/*.yaml +!data/examples/policies/ +!data/examples/policies/*.yaml + +# SQLAlchemy database files +*.db +*.db-journal +*.db-shm +*.db-wal diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index e2cd465..0000000 --- a/.gitmodules +++ /dev/null @@ -1,8 +0,0 @@ -[submodule "core"] - path = core - url = https://github.com/edge-mining/core.git - branch = dev -[submodule "frontend"] - path = frontend - url = https://github.com/edge-mining/frontend.git - branch = develop \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3bdd85e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,50 @@ +repos: + # Ruff - Fast Python linter and formatter + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.4 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix, --ignore=E501] + files: ^core/ + - id: ruff-format + files: ^core/ + + # # mypy - Static type checking (commented out for now) + # - repo: https://github.com/pre-commit/mirrors-mypy + # rev: v1.13.0 + # hooks: + # - id: mypy + # additional_dependencies: [types-PyYAML==6.0.12.20240917] + # args: [--ignore-missing-imports] + + # bandit - Security linting + - repo: https://github.com/PyCQA/bandit + rev: 1.8.6 + hooks: + - id: bandit + args: [--severity-level=high, --confidence-level=high] + files: ^core/ + exclude: tests/ + + # General hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-merge-conflict + - id: check-added-large-files + - id: check-case-conflict + - id: check-docstring-first + - id: debug-statements + - id: name-tests-test + args: [--pytest-test-first] + + # YAML linting + - repo: https://github.com/adrienverge/yamllint + rev: v1.37.1 + hooks: + - id: yamllint + args: [-d, relaxed] diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..96e3787 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,11 @@ +{ + "recommendations": [ + "ms-python.python", + "ms-python.mypy-type-checker", + "charliermarsh.ruff", + "redhat.vscode-yaml", + "streetsidesoftware.code-spell-checker", + "eamodio.gitlens", + "ms-vscode.vscode-json" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..4069ce5 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,25 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Edge Mining Debugger: Standard Mode", + "type": "debugpy", + "request": "launch", + "console": "integratedTerminal", + "module": "edge_mining", + "args": ["standard"], + "python": "${workspaceFolder}/.venv/bin/python", + "cwd": "${workspaceFolder}/core" + }, + { + "name": "Edge Mining Debugger: CLI Mode", + "type": "debugpy", + "request": "launch", + "console": "integratedTerminal", + "module": "edge_mining", + "args": ["cli", "interactive"], + "python": "${workspaceFolder}/.venv/bin/python", + "cwd": "${workspaceFolder}/core" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9dc332c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,65 @@ +{ + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "python.terminal.activateEnvironment": true, + "ruff.nativeServer": true, + "ruff.interpreter": ["${workspaceFolder}/.venv/bin/python"], + "mypy-type-checker.interpreter": ["${workspaceFolder}/.venv/bin/python"], + "python.analysis.extraPaths": ["${workspaceFolder}/core"], + "python.envFile": "${workspaceFolder}/.env", + "python.testing.pytestEnabled": true, + "python.testing.unittestEnabled": false, + "python.testing.pytestArgs": [ + "core/tests" + ], + "editor.formatOnSave": false, + "editor.formatOnPaste": false, + "editor.rulers": [120], + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true, + "[python]": { + "editor.tabSize": 4, + "editor.insertSpaces": true, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } + }, + "[yaml]": { + "editor.tabSize": 2, + "editor.insertSpaces": true + }, + "[json]": { + "editor.tabSize": 2, + "editor.insertSpaces": true + }, + "emmet.includeLanguages": { + "yaml": "yaml" + }, + "cSpell.words": [ + "edgemining", + "mqtt", + "pydantic", + "sqlite", + "fastapi", + "pytest", + "mypy", + "flake8", + "bandit", + "isort", + "pylint", + "asyncio", + "uvicorn", + "cors", + "middleware", + "datetime", + "enum", + "uuid", + "sheduler", + "satoshi", + "hashrate", + "homeassistant", + "pyasic" + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4c1d2af --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed + +- Cleaned up sidebar header: removed the "Edge Mining" text label and the username placeholder, left-aligned the logo with the sidebar menu items, and refined the green glow to originate from the logo area (#33). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..00fa349 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,33 @@ +# Contributing to Edge Mining + +Thank you for your interest in contributing! Please follow these guidelines to help us maintain a high-quality project. + +> **Note:** This project is a monorepo. The `core/` directory contains the backend, `frontend/` contains the web UI. The root directory manages Docker builds and orchestration. + +## Getting Started + +Refer to the [README.md](./README.md) for project overview and Docker setup. +For backend environment setup and installation instructions, see [DEVELOPMENT_WORKFLOW.md](./DEVELOPMENT_WORKFLOW.md) and [DEV_TOOLS.md](./DEV_TOOLS.md). + +## Development Workflow + +- Use feature branches for your work. +- Follow the _Hexagonal Architecture_ and _Domain Driven Design_ conventions described in the README. +- Clean, update, and verify your environment as described in [DEVELOPMENT_WORKFLOW.md](./DEVELOPMENT_WORKFLOW.md). +- You can use `make` from the repo root for unified commands, or `cd core/ && make` for backend-specific tasks. + +## Code Quality + +- Pre-commit hooks and code formatting are required. See [DEV_TOOLS.md](./DEV_TOOLS.md) for details. +- Pre-commit configuration is at the repo root (`.pre-commit-config.yaml`). + +## Pull Requests + +- **Submit pull requests to the `dev` branch only. Do not submit PRs directly to `main`.** +- Ensure your changes are well-tested. +- Write clear commit messages and PR descriptions. +- Link related issues if applicable. + +## Need Help? + +Check the [Docs repository](https://github.com/edge-mining/docs) or open an issue. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..54eb29a --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,311 @@ +# Edge Mining Development Workflow + +This guide describes the recommended workflow for contributing to the Edge Mining project. + +> **Monorepo note:** This project uses a monorepo layout. Backend code lives in `core/`, frontend in `frontend/`. Docker build and orchestration files are at the repository root. + +## Architecture + +The project uses **Hexagonal Architecture (Ports and Adapters)** to clearly separate the business logic (Domain and Application Layer) from infrastructural dependencies (Database, external APIs, Hardware Control, User Interfaces). + +- **`edge_mining/domain`**: Contains the pure business logic, subdomains and their models (Entities, Value Objects), domain exceptions, and the interfaces (Ports) that define the contracts with the outside world. +- **`edge_mining/application`**: Contains the application services that orchestrate the use cases, utilizing the Domain's Ports. +- **`edge_mining/adapters`**: Contains the concrete implementations of Ports. + - **`domain`**: Adapters strictly used by domain elements. + - **`infrastructure`**: Infrastructure adapters, used cross-domain (logger, persistence, api). +- **`edge_mining/shared`**: Shared elements (and interfaces) used cross-domain. +- **`test`**: Contains application tests. +- **`edge_mining/__main__.py`**: Main entry point, responsible for "wiring" dependencies (Dependency Injection). + +## Initial Setup + +### 1. Clone the repository and enter the directory + +```bash +git clone https://github.com/edge-mining/app.git +cd app +``` + +### 2. Setup development environment + +Create a Python virtual environment (if you have not created it yet). + +```bash +python -m venv .venv +``` + +and activate it before running other commands. + +#### On Linux/macOS: +```bash +source .venv/bin/activate +``` +#### On Windows: +```cmd +.venv\Scripts\activate +``` + +Install the required dependencies and development tools: + +```bash +pip install -r core/requirements.txt +``` + +```bash +pip install -r core/requirements-dev.txt +``` + +Configure environment variables by copying `.env.example` to `.env` and editing the values as needed. + +```bash +cp .env.example .env +nano .env # Edit the .env file +``` + +Key settings: +- `TIMEZONE`: Set your local timezone (e.g., `UTC`, `America/New_York`) +- `LATITUDE` and `LONGITUDE`: Set your location for sunrise/sunset calculations +- `DB_PATH`: Database URL (e.g., `sqlite:///data/db/edgemining.db` or PostgreSQL URL) +- `RUN_MIGRATIONS_ON_STARTUP`: Set to `true` to automatically apply database migrations +- `SCHEDULER_INTERVAL_SECONDS`: Set the interval for the optimization scheduler (default: `60`) + +> **Note:** By default, the application uses SQLAlchemy with SQLite for the database. Migrations are managed with Alembic. See [docs/ALEMBIC_MIGRATIONS.md](docs/ALEMBIC_MIGRATIONS.md) for detailed migration management. + +Run the setup command to install the required dependencies. + +**NOTE**: Use the `make` command if you are on Linux or you are on WSL. Use `dev-tools.ps1` or `dev-tools.bat` if you are on Windows. +For more details, see [DEV_TOOLS.md](DEV_TOOLS.md). + +#### From the repo root (recommended): +```bash +make setup +``` +This sets up both backend and frontend environments. + +#### Or from `core/` directly: +```bash +cd core +make setup +``` + +This command: + +- Installs development dependencies from `requirements-dev.txt`. +- Configures pre-commit hooks for automatic code quality checking. + +### 3. Create required directories + +The application stores all user data in the `data/` directory: +```bash +mkdir -p data/db/backups data/policies data/examples +``` + +**Directory structure:** +- `data/db/` - Database file and automatic backups +- `data/policies/` - Your optimization policy YAML files +- `data/examples/` - Example rules for reference (templates only) + +### 4. Verify everything works + +Run the following command to check code formatting, linting, and tests before starting development. This ensures your environment is set up correctly and all pre-commit checks pass. + +```bash +make pre-commit +``` + +## Execution + +You can run the application in different modes via the main entry point: + +1. **Standard Mode (Default):** Starts the main automation loop that checks available energy and controls miners at regular intervals. Starts a REST API (FastAPI) server also to interact with the system programmatically. +```bash +python -m core/edge_mining +# Or by explicitly specifying +python -m core/edge_mining standard +``` +2. **CLI Mode:** Access the command line interface with an interactive menu to manage miners, energy sources, controller, policies, etc. +```bash +python -m core/edge_mining cli interactive + +``` +You can use the `--help` flag to see all available options: +```bash +python -m core/edge_mining cli --help +``` + +The API will be available at `http://localhost:8001` (or the configured port). You can access the interactive documentation (Swagger UI) at `http://localhost:8001/docs`. + +## Development Workflow + +### 1. Before starting development + +```bash +# Clean temporary files +make clean + +# Update dependencies if necessary +make install-dev +``` + +### 2. During development + +#### Automatic code formatting + +Run the following command to automatically format your code according to the project's style guidelines. + +```bash +make format +``` + +#### Code quality check + +Use this command to check your code for linting issues and ensure it meets quality standards. + +```bash +make lint +``` + +#### Running tests + +Execute this command to run all tests and verify your changes do not break existing functionality. + +```bash +make test +``` + +### 3. Before committing + +Pre-commit hooks run automatically, but you can run them manually. + +```bash +make pre-commit +``` + +If there are errors, fix them and try again. + +### 4. Commit and Push + +```bash +git add . +git commit -m "feat: feature description" +git push +``` + +## Useful Commands + +### Common problem solving + +#### Auto-fix linting issues + +```bash +make lint-fix +``` + +#### Clean the environment completely + +```bash +make clean + +# Remove virtual environment if necessary +rm -rf .venv +python -m venv .venv +make setup +``` + +#### Tests with detailed coverage + +```bash +make test-cov +``` + +This will generate an HTML report in `htmlcov/index.html` + +#### Security check + +```bash +bandit -r core/edge_mining/ +``` + +#### Type checking con mypy + +```bash +mypy core/edge_mining/ +``` + +## Tools Structure + +See the [DEV_TOOLS.md](DEV_TOOLS.md) file for detailed information about the tools used in this project. + +## Troubleshooting + +### Pre-commit doesn't work + +```bash +# Reinstall pre-commit +pre-commit uninstall +make pre-commit-install + +# Update hooks +pre-commit autoupdate +``` + +### Import or dependency errors + +```bash +# Check virtual environment +which python +# Should point to .venv/bin/python + +# Reinstall dependencies +make clean +make install-dev +``` + +### Mypy errors + +```bash +# Mypy is configured to be permissive during development +# Errors don't block commits but it's good to resolve them + +# To run mypy manually: +mypy core/edge_mining/ +``` + +### Formatting conflicts + +```bash +make format +make lint + +# The makefile is configured to handle most conflicts +``` + +## Best Practices + +1. **Always run `make pre-commit` before committing** +2. **Use `make format` to automatically format code** +3. **Write tests for new features** +4. **Maintain high test coverage** +5. **Use type hints when possible** +6. **Follow Python naming conventions (PEP 8)** +7. **Write docstrings for public functions and classes** + +## Commit Conventions + +Use conventional commits: + +- `feat:` for new features +- `fix:` for bug fixes +- `docs:` for documentation updates +- `style:` for formatting changes +- `refactor:` for code refactoring +- `test:` for adding/modifying tests +- `chore:` for maintenance tasks + +Example commit messages: + +```bash +git commit -m "feat: add energy monitoring adapter for solar panels" +git commit -m "fix: resolve memory leak in optimization service" +git commit -m "docs: update API documentation for miner endpoints" +``` diff --git a/DEV_TOOLS.md b/DEV_TOOLS.md new file mode 100644 index 0000000..6ac078b --- /dev/null +++ b/DEV_TOOLS.md @@ -0,0 +1,188 @@ +# Development Tools + +This project uses various tools to maintain code quality and includes a Makefile for common development tasks. The Makefile is cross-platform compatible and works on both Windows and Linux/macOS. + +> **Monorepo note:** This project uses a monorepo layout (`core/` = backend, `frontend/` = web UI). You can run backend dev commands in two ways: +> - **From the repo root:** `make test`, `make lint`, `make format`, etc. (delegates to `core/Makefile`) +> - **From `core/` directly:** `cd core && make test`, etc. +> +> Pre-commit configuration (`.pre-commit-config.yaml`) lives at the repo root. + +### Prerequisites for Windows users: +To use the Makefile on Windows, you must run `make` via Windows Subsystem for Linux (WSL). +- Install [Windows Subsystem for Linux (WSL)](https://learn.microsoft.com/en-us/windows/wsl/install) +- Run the `make` commands from a WSL shell (e.g., Ubuntu) + +**Alternative for Windows users:** If you prefer not to use WSL, you can use the provided alternative scripts: + +PowerShell: +```powershell +cd core +.\dev-tools.ps1 help # Show all available commands +.\dev-tools.ps1 setup # Set up development environment +.\dev-tools.ps1 install # Install dependencies +# ... (same commands as make) +``` + +Command Prompt (Batch): +```cmd +cd core +.\dev-tools.bat help # Show all available commands +.\dev-tools.bat setup # Set up development environment +.\dev-tools.bat install # Install dependencies +# ... (same commands as make) +``` + +### Available commands: +```bash +make help # Show all available commands +make setup # Set up development environment +make install # Install dependencies +make install-dev # Install development dependencies +make format # Format code with ruff +make lint # Run all linting checks +make lint-fix # Run linting and fix what can be auto-fixed +make test # Run tests +make test-cov # Run tests with coverage +make pre-commit # Run pre-commit hooks on all files +make clean # Clean cache and temporary files +``` + +## Quick Setup + +To quickly configure the development environment: + +```bash +make setup +``` + +## Development Dependencies Installation + +```bash +# Production dependencies only +make install + +# Development dependencies +make install-dev +``` + +## Pre-commit Hooks + +Pre-commit hooks are automatically executed on each commit to verify code quality. + +### Installation + +```bash +make pre-commit-install +``` + +### Manual execution on all files + +```bash +make pre-commit +``` + +## Formatting and Linting + +### Automatic formatting + +```bash +make format +``` + +### Complete linting + +```bash +make lint +``` + +### Auto-fix linting issues + +```bash +make lint-fix +``` + +## Tests + +### Running tests + +```bash +make test +``` + +### Tests with coverage + +```bash +make test-cov +``` + +## Available Makefile Commands + +- `make setup` - Sets up the complete development environment +- `make install` - Installs production dependencies +- `make install-dev` - Installs development dependencies +- `make format` - Formats code with black and isort +- `make lint` - Runs all linting checks +- `make lint-fix` - Runs linting and automatically fixes issues +- `make test` - Runs tests +- `make test-cov` - Runs tests with coverage +- `make pre-commit` - Runs pre-commit on all files +- `make pre-commit-install` - Installs pre-commit hooks +- `make clean` - Cleans cache and temporary files + +## Linting and Formatting Tools + +### Ruff - Code formatting + +```bash +cd core +ruff format edge_mining/ +``` + +### Ruff - Linting + +```bash +cd core +ruff check edge_mining/ +``` + +Use the option `--ignore=E501` to disable line length checks. +```bash +ruff check edge_mining/ --ignore=E501 +``` + +### mypy - Type checking + +```bash +cd core +mypy edge_mining/ +``` + +### bandit - Security check + +```bash +cd core +bandit -r edge_mining/ +``` + +## Configurations + +- **`.pre-commit-config.yaml`**: Pre-commit hooks configuration (repo root) +- **`core/pyproject.toml`**: Configuration for mypy, ruff, bandit, pytest and coverage +- **`core/requirements-dev.txt`**: Development dependencies +- **`core/Makefile`**: Backend automation commands (also available via root `Makefile`) +- **`Makefile`**: Root Makefile — unified entry point delegating to `core/Makefile` + +## Troubleshooting + +If pre-commit doesn't work properly: + +1. Make sure git is initialized: `git init` +2. Reinstall pre-commit: `make pre-commit-install` +3. Update hooks: `pre-commit autoupdate` + +If you have dependency issues: + +1. Clean the environment: `make clean` +2. Recreate the virtual environment: `cd core && python -m venv .venv` +3. Reinstall dependencies: `make setup` diff --git a/Dockerfile b/Dockerfile index 4ce684e..4d8739f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,29 @@ COPY frontend/ . RUN npm run build +# Backend-only stage (for development without frontend/nginx) +FROM python:3.11-slim AS backend-dev + +ENV APP_HOME=/app +ENV PYTHONPATH="$APP_HOME/core" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR $APP_HOME + +COPY core/ ./core/ +WORKDIR $APP_HOME/core +COPY core/requirements.txt ./requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +RUN mkdir -p /app/core/data/db/backups \ + && mkdir -p /app/core/data/policies \ + && mkdir -p /app/core/data/examples + +EXPOSE 8001 +CMD ["python", "-m", "edge_mining", "standard"] + + # Runtime: Python backend + nginx FROM python:3.11-slim @@ -38,10 +61,7 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy frontend build into nginx COPY --from=frontend-build /frontend/dist /usr/share/nginx/html -# Copy app version file (served statically by nginx at /version/app) -COPY VERSION.json /usr/share/nginx/html/version.json - EXPOSE 80 # Run both nginx and the Python backend -CMD ["sh", "-c", "nginx -g 'daemon off;' & python -m edge_mining standard"] \ No newline at end of file +CMD ["sh", "-c", "nginx -g 'daemon off;' & python -m edge_mining standard"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f24f78e --- /dev/null +++ b/Makefile @@ -0,0 +1,117 @@ +# Makefile for Edge Mining App (monorepo) +# +# Unified entry point for development and Docker operations. + +.PHONY: help setup venv dev-core dev-frontend format lint lint-fix test test-cov \ + pre-commit pre-commit-install clean build up down logs restart + +# ── Variables ──────────────────────────────────────────────────────── + +VENV := .venv/bin + +help: + @echo "Edge Mining App — Development & Docker Commands" + @echo "================================================" + @echo "" + @echo "Development:" + @echo " setup - Set up full development environment (core + frontend)" + @echo " venv - Create .venv and install all Python dependencies" + @echo " dev-core - Set up core backend development environment only" + @echo " dev-frontend - Install frontend dependencies only" + @echo " format - Format core code with ruff" + @echo " lint - Run all linting checks on core" + @echo " lint-fix - Run linting and auto-fix on core" + @echo " test - Run core tests" + @echo " test-cov - Run core tests with coverage" + @echo " pre-commit - Run pre-commit hooks on all files" + @echo " pre-commit-install - Install pre-commit hooks" + @echo " clean - Clean cache and temporary files" + @echo "" + @echo "Docker:" + @echo " build - Build the Docker image (frontend + backend + nginx)" + @echo " up - Start the application (docker compose up -d)" + @echo " down - Stop the application (docker compose down)" + @echo " restart - Rebuild and restart the application" + @echo " logs - Follow application logs" + +# ── Development ────────────────────────────────────────────────────── + +setup: venv dev-core dev-frontend pre-commit-install + @echo "✅ Full development environment setup complete!" + +venv: + @echo "🐍 Creating virtual environment and installing dependencies..." + test -d .venv || python3 -m venv .venv + $(VENV)/pip install --upgrade pip + $(VENV)/pip install -r core/requirements.txt + @echo "✅ Virtual environment ready!" + +dev-core: + @echo "🐍 Setting up core backend..." + $(VENV)/pip install -r core/requirements-dev.txt + @echo "✅ Core backend setup complete!" + +dev-frontend: + @echo "🌐 Installing frontend dependencies..." + cd frontend && npm install + +format: + @echo "🔧 Formatting code..." + cd core && ../$(VENV)/python -m ruff format edge_mining/ tests/ + @echo "✅ Code formatting complete!" + +lint: + @echo "🔍 Running linting checks..." + cd core && ../$(VENV)/python -m ruff check edge_mining/ + cd core && ../$(VENV)/python -m mypy edge_mining/ || true + cd core && ../$(VENV)/python -m bandit -r edge_mining/ --skip B311,B104 || true + @echo "✅ Linting complete!" + +lint-fix: + @echo "🔧 Running auto-fixable linting..." + cd core && ../$(VENV)/python -m ruff check --fix edge_mining/ + cd core && ../$(VENV)/python -m ruff format edge_mining/ + @echo "✅ Auto-fix complete!" + +test: + @echo "🧪 Running tests..." + cd core && ../$(VENV)/python -m pytest tests/ -v + @echo "✅ Tests complete!" + +test-cov: + @echo "🧪 Running tests with coverage..." + cd core && ../$(VENV)/python -m pytest tests/ -v --cov=edge_mining --cov-report=html --cov-report=term + @echo "✅ Tests with coverage complete!" + +pre-commit: + $(VENV)/pre-commit run --all-files + +pre-commit-install: + $(VENV)/pre-commit install + +clean: + @echo "🧹 Cleaning cache and temporary files..." + find core/ -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find core/ -type f -name "*.pyc" -delete 2>/dev/null || true + find core/ -type f -name "*.pyo" -delete 2>/dev/null || true + find core/ -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true + rm -rf core/build/ core/dist/ core/.coverage core/htmlcov/ core/.pytest_cache/ 2>/dev/null || true + rm -rf frontend/node_modules/.tmp 2>/dev/null || true + @echo "✅ Cleanup complete!" + +# ── Docker ─────────────────────────────────────────────────────────── + +build: + docker compose build + +up: + docker compose up -d + +down: + docker compose down + +restart: build + docker compose up -d + +logs: + docker compose logs -f diff --git a/README.md b/README.md index a19ba71..9bb296a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ -# Edge Mining App +⚠️ **Disclaimer**: *This project is in a preliminary state and under active development. Features and functionality may change significantly.* -This repository bundles the Edge Mining **core backend** and **web frontend** into a single runnable application. - -Edge Mining is intended to be run via **Docker Compose**. +Edge Mining ⚡️🌞 is a software to optimize the use of excess energy, especially from renewable sources, through Bitcoin mining. This system automates the turning on and off of ASIC miner devices based on energy availability, production forecasts, and user-defined policies. --- @@ -14,31 +12,34 @@ Edge Mining is intended to be run via **Docker Compose**. Clone the repository and move into the project root: ```bash -git clone --recurse-submodules https://github.com/edge-mining/app.git +git clone https://github.com/edge-mining/app.git cd app/ ``` ---- +## Run with Docker Compose -## 2. Quick Start with Docker (Recommended) +You can run Edge Mining using `docker compose` in daemon mode using the provided `compose.yaml`. -This setup runs a **single container** that bundles: -- `core`: Edge Mining backend (FastAPI, automation engine) -- `frontend`: Vue web UI (served as static files) -- `nginx`: reverse proxy exposing everything on port `80` +**Important:** The `user_data/` directory is mounted as a volume, ensuring your database, policies, and backups persist even when the container is removed. ### 2.1. First start (one-time initialization) -On the very first run, use the `first_start.sh` helper script, which initializes `user_data/` and then brings up the Docker stack, building the image if needed: +On the very first run, use the `first_start.sh` helper script from `scripts` folder, which initializes `user_data/`, creates the necessary directory structure, and then brings up the Docker stack, building the image if needed: ```bash -./first_start.sh +./scripts/first_start.sh ``` Under the hood this will: - Run `init_user_data.sh` to create/populate the `user_data/` directory - Run `docker compose up -d --build` to build the multi-stage image defined in `Dockerfile` (backend + frontend + nginx) and start a single container exposing the web UI and API on port `80` +Now you can: +- Place your optimization policy YAML files in `user_data/policies/` +- Find rule examples in `user_data/examples/start/` and `user_data/examples/stop/` +- Access the database at `user_data/db/edgemining.db` +- Find automatic backups in `user_data/db/backups/` + ### 2.2. Subsequent starts After the first initialization, you can start the stack directly with Docker Compose (without forcing a rebuild every time): @@ -47,25 +48,32 @@ After the first initialization, you can start the stack directly with Docker Com docker compose up -d ``` +> **Note:** Volumes under `user_data/` are mounted into the container so that configuration and database files persist across restarts. + ### 2.3. Access the application - Web UI: `http://localhost/` - API (via reverse proxy): `http://localhost/api` - API docs (via reverse proxy): `http://localhost/docs` +To see logs: + +```bash +docker compose logs -f +``` + ### 2.4. Stop the stack ```bash docker compose down ``` -Volumes under `user_data/` are mounted into the container so that configuration and database files persist across restarts. - ### 2.5. Environment variables The container supports a couple of environment variables that control runtime behavior: - `TIMEZONE`: timezone used by the backend (default: `Europe/Rome`) +- `LATITUDE` and `LONGITUDE`: used for sunrise/sunset calculations - `SCHEDULER_INTERVAL_SECONDS`: polling interval for the scheduler loop (default: `5` seconds) When using Docker Compose, you can configure them in `compose.yaml` under the `environment` section of the `edge-mining` service. For example: @@ -87,7 +95,7 @@ docker run -d \ -e SCHEDULER_INTERVAL_SECONDS=5 \ edge-mining:latest ``` -``` + ### 2.6. Core interactive CLI mode Once the container is running in the background with: @@ -121,11 +129,6 @@ User-specific data lives in the `user_data/` folder of the this application fold - `user_data/examples/` – example rules files (copied from `core/data/examples/` on first run) - `user_data/db/edgemining.db` – SQLite database file used by the backend -On first run you can: -- Copy or adjust default policies from `core/data/policies/` into `user_data/policies/` -- Copy example rules files from `core/data/examples/` into `user_data/examples/` if you want to use them as templates -- Let the backend create `user_data/db/edgemining.db` automatically, or pre-populate it if you know what you are doing - ### 3.1 Initialize user data (recommended) The `first_start.sh` script already runs `init_user_data.sh` for you, so in normal usage you do not need to call it manually on the first run. @@ -133,7 +136,7 @@ The `first_start.sh` script already runs `init_user_data.sh` for you, so in norm If you prefer to manage things yourself, you can still run the helper script directly to create and populate the `user_data/` directory with default files: ```bash -./init_user_data.sh +./scripts/init_user_data.sh ``` This script: @@ -149,7 +152,7 @@ You may want to re-run it if you intentionally delete the `user_data/` folder an ## 4. Useful Tips - **Logs**: Use `docker compose logs -f` to inspect services when running with Docker. -- **Rebuild after changes**: If you change backend or frontend code, re-run `docker compose up -d --build` (or `./first_start.sh` if you prefer the one-shot helper) to rebuild images. +- **Rebuild after changes**: If you change backend or frontend code, re-run `docker compose up -d --build` (or `./scripts/first_start.sh` if you prefer the one-shot helper) to rebuild images. ## 5. Troubleshooting @@ -167,15 +170,14 @@ docker compose logs ## 6. Updating the Application -When a new version is available, use the `update.sh` script to pull the latest changes (including submodules) and restart the application: +When a new version is available, use the `update.sh` script to pull the latest changes and restart the application: ```bash -./update.sh +./scripts/update.sh ``` This script will: -- Pull the latest changes from the current branch (with `--recurse-submodules`) -- Ensure all submodules are in sync +- Pull the latest changes from the current branch - Re-initialize `user_data/` (copies missing defaults only) - Rebuild and restart the Docker stack @@ -184,71 +186,22 @@ This script will: To switch to a different branch (e.g. from `main` to `dev`), use the `switch_branch.sh` script: ```bash -./switch_branch.sh +./scripts/switch_branch.sh ``` This script will: - Fetch the latest remote branches - Display a numbered list of all available branches - Prompt you to select the desired branch -- Switch with `--recurse-submodules` so that all submodules are correctly updated to the matching commit +- Switch to the selected branch - Rebuild and restart the Docker stack -### 6.2 Updating Submodules (manual) - -This repository uses Git submodules for the `core` and `frontend` components. The submodules are configured to track the `dev` and `develop` branches respectively. - -**After cloning**, the submodules will be checked out at the commit SHAs recorded in the parent repository. To update them to the latest commits from their respective branches: - -```bash -# Update all submodules to the latest commits from their configured branches -git submodule update --remote --merge -``` - -**To update a specific submodule:** - -```bash -# Update core submodule to latest dev branch -cd core -git checkout dev -git pull origin dev -cd .. - -# Update frontend submodule to latest develop branch -cd frontend -git checkout develop -git pull origin develop -cd .. -``` - - -## 7. Updating the Application Version - -After updating the `core` and `frontend` submodules (see above), **but before committing changes to the main `app` repository**, you need to update the `VERSION.json` file with the new application version. This file is served statically by Nginx and must be updated whenever there is a change involving the backend or frontend. - -**Procedure:** - -1. Update the `core` and `frontend` submodules as described above. -2. Edit the `VERSION.json` file and enter the desired new version (for example, by incrementing the version number or adding a date/commit). -3. Only after updating `VERSION.json`, commit the changes in the `app` repository. - -**Example of an update:** - -```bash -# Edit VERSION.json (for example with nano or vim) -nano VERSION.json -# ... update the version ... -git add core frontend VERSION.json -git commit -m "Update core, frontend and VERSION.json" -``` - -**Note:** It is important to keep `VERSION.json` aligned with the actual state of backend and frontend for correct traceability of the distributed version. - --- -**Note:** After updating the submodules, if you want to commit the new submodule references in the main repository: +## 7. Development -```bash -git add core frontend -git commit -m "Update submodules to latest dev/develop branches" -``` +For local development setup (without Docker), available `make` commands, linting, testing, and contribution guidelines, see: + +- [`DEVELOPMENT.md`](DEVELOPMENT.md) — step-by-step setup and daily workflow +- [`DEV_TOOLS.md`](DEV_TOOLS.md) — linting, formatting, testing tools and configuration +- [`CONTRIBUTING.md`](CONTRIBUTING.md) — contribution guidelines and PR rules diff --git a/VERSION.json b/VERSION.json deleted file mode 100644 index deaae04..0000000 --- a/VERSION.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "version": "0.1.0-rev1", - "buildDate": "", - "commit": "" -} diff --git a/compose.yaml b/compose.yaml index d0a552f..3bdb7a1 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,4 +12,19 @@ services: volumes: - ./user_data/:/app/core/data + # Development: backend-only (no nginx/frontend) + # Usage: docker compose --profile dev up core-dev + core-dev: + profiles: ["dev"] + build: + context: . + target: backend-dev + ports: + - "8001:8001" + environment: + - TIMEZONE=Europe/Rome + - SCHEDULER_INTERVAL_SECONDS=5 + volumes: + - ./user_data/:/app/core/data + networks: {} diff --git a/core b/core deleted file mode 160000 index d5a0ac9..0000000 --- a/core +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d5a0ac9e78037848169c1625e1bf1c9725a3cb32 diff --git a/core/.gitattributes b/core/.gitattributes new file mode 100644 index 0000000..176a458 --- /dev/null +++ b/core/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md new file mode 100644 index 0000000..7292c17 --- /dev/null +++ b/core/CHANGELOG.md @@ -0,0 +1,366 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0-rev3] + +### Added + +- **Mining Performance Analysis Domain** (`edge_mining/domain/performance/`): + - Value objects: `MiningReward`, `PoolWorkerStats`, `PoolStats`, `PayoutSchedule` in `value_objects.py` (renamed from misspelled `values_objects.py`) + - Entity `MiningSession` for tracking aggregated mining activity (`entities.py`) + - Common types: `PayoutFrequency` enum, `Satoshi` NewType (`common.py`) + - Domain exceptions: `MiningPoolUnreachableError`, `MiningPoolAuthError`, `MiningPoolResponseError` + - Domain events: `RewardReceivedEvent`, `HashrateDropDetectedEvent` (`events.py`) + - Async `MiningPerformanceTrackerPort` contract for live pool data (stats, rewards history, payout schedule, workers) + +- **Pool Tracker Adapters** (`edge_mining/adapters/domain/performance/trackers/`): + - `OceanMiningPerformanceTracker` — public Ocean pool API integration (no authentication, Bitcoin address based) + - `BraiinsPoolMiningPerformanceTracker` — Braiins Pool v1 API integration using `Pool-Auth-Token` header + - Corresponding adapter factories (`OceanMiningPerformanceTrackerFactory`, `BraiinsPoolMiningPerformanceTrackerFactory`) + - Abstract `MiningPerformanceTrackerAdapterFactory` in `shared/interfaces/factories.py` + +- **REST API** (`edge_mining/adapters/domain/performance/fast_api/router.py`): + - 13 endpoints under `/api/v1` covering tracker CRUD, type discovery, config schema inspection, external service listing, connectivity test, live stats, workers, rewards history, payout schedule + - Error mapping: `MiningPoolAuthError` → 401, `MiningPoolUnreachableError` → 503, `MiningPoolResponseError` → 502, `NotFoundError` → 404, `ConfigurationError` → 400 + +- **Pydantic Schemas** (`edge_mining/adapters/domain/performance/schemas.py`): + - Tracker CRUD schemas with `to_model`/`from_model` converters + - Per-adapter config schemas (Dummy, Ocean requires `bitcoin_address`, Braiins Pool requires `api_token`) + `MINING_PERFORMANCE_TRACKER_CONFIG_SCHEMA_MAP` + - Response schemas: `HashRateSchema`, `PoolWorkerStatsSchema`, `PoolStatsSchema`, `MiningRewardSchema`, `PayoutScheduleSchema`, `MiningPerformanceSnapshotSchema` (all with both `from_model` and `to_model`) + +- **MiningPerformanceSnapshot Value Object** (`edge_mining/domain/performance/value_objects.py`): + - Consolidated snapshot grouping `current_hashrate` + `pool_stats` + `payout_schedule` under a single `timestamp`, following the same pattern as `EnergyStateSnapshot` and `MinerStateSnapshot` + - Exposed to the rule engine via the new `DecisionalContext.mining_performance` field — enables rules on aggregated metrics (24h/7d average hashrate, unpaid balance, estimated next payout, payout frequency/threshold) + +- **Interactive CLI** (`edge_mining/adapters/domain/performance/cli/commands.py`): + - New main menu option "Manage Mining Performance Trackers" (list, create, update, delete, test, show stats/workers/rewards/payout) + - Adapter-aware configuration wizard with dict-dispatch handler map + - Async-to-sync bridging via `run_async_func` helper + +- **Configuration & Wiring**: + - `MINING_PERFORMANCE_TRACKER_CONFIG_TYPE_MAP` and `MINING_PERFORMANCE_TRACKER_EXTERNAL_SERVICE_MAP` in `shared/adapter_maps/performance.py` + - New adapter configs (`DummyMiningPerformanceTrackerConfig`, `OceanMiningPerformanceTrackerConfig`, `BraiinsPoolMiningPerformanceTrackerConfig`) + - Nine new `ConfigurationService` methods for tracker lifecycle management + - `ConfigurationUpdatedEventType.MINING_PERFORMANCE_TRACKER` event type + - `adapter_service.py` registers the three tracker factories + +- **Shared Helper** (`edge_mining/domain/common.py`): + - `utc_now_timestamp()` function to produce fresh `Timestamp` values for dataclass `default_factory` usage + +- **Rate-limit / caching layer for pool trackers** (`edge_mining/adapters/domain/performance/trackers/_base.py`): + - New shared base class `CachedRateLimitedTrackerBase` providing per-method TTL caching and exponential backoff (5s / 10s / 20s / 40s / 80s with ±20% jitter) around HTTP 429 responses + - `MiningPoolRateLimitedError` domain exception (with optional `retry_after` hint) raised when the pool signals throttling; mapped to HTTP 429 by the REST router (including `Retry-After` response header) and displayed with hint in the CLI + - Stale-while-error fallback: when all retries are exhausted a cached value — even if past its TTL — is served in preference to propagating the error; the error is only re-raised when no cached value exists + - In-memory cache keyed by `(method_name, args_tuple)` so methods with arguments (e.g. `get_recent_rewards(limit)`) get separate cache slots + - TTL tables (`TTL_MAP`) tuned per pool: hashrate 60s, pool/worker stats 300s (worker data updates every ~5 min upstream), recent rewards 600s, payout schedule 3600s + - Applied transparently to both `OceanMiningPerformanceTracker` and `BraiinsPoolMiningPerformanceTracker`; `_get()` detects 429 before any auth/5xx mapping and extracts `Retry-After` + +- **Tests** — 107 new unit tests across tracker adapters, configuration service, REST router, and CLI commands +- **Tests** — 15 new unit tests for `CachedRateLimitedTrackerBase` (cache hit/miss, TTL expiry, backoff progression, stale-while-error, retry-after handling, cache invalidation) plus 429-detection tests for the Ocean and Braiins adapters + +- **Home Load — Phase 3: DecisionalContext Integration** + - Extended field resolver (`helpers.py`) with dict key lookup for `home_load.devices..*` paths and `None` guard for `Optional` intermediate fields + - Pre-computed window properties on `LoadEnergyConsumption`: `next_1h`, `next_2h`, `next_4h`, `last_1h`, `last_4h`, `last_24h` + - Example YAML rules: `home_load_start_rules.yaml` (3 rules), `home_load_stop_rules.yaml` (4 rules) + - Fixed existing rules from `home_load_forecast` → `home_load.total_forecast.next_2h.avg_power` + - 5 new unit tests for dict resolver + +- **Home Load — Phase 4: ML Forecast Providers (Statsmodels + XGBoost)** + - ML optional dependencies: `scikit-learn>=1.5.0`, `statsmodels>=0.14.0`, `xgboost>=2.0.0` in `[ml]` extras + - `EnergyLoadForecastProviderAdapter.STATSMODELS` and `.XGBOOST` enum values + - Config dataclasses: `EnergyLoadForecastProviderStatsmodelsConfig`, `EnergyLoadForecastProviderXGBoostConfig` + - Feature engineering utilities (`features.py`): `intervals_to_hourly_series()`, `fill_missing_hours()`, `build_calendar_features()`, `build_lag_features()`, `prepare_supervised_dataset()` + - `LoadConsumptionModel` entity with `model_bytes` (serialized pickle), MAE/RMSE metrics, `is_active` flag + - `LoadConsumptionModelRepository` port + three implementations: InMemory, SQLite, SQLAlchemy + - `load_consumption_models` database table with composite index on `(adapter_type, device_id, is_active)` + - Alembic migration `c3d4e5f6a7b8` for `load_consumption_models` table + - `StatsmodelsForecastProvider` (Holt-Winters exponential smoothing) with factory, lazy import + - `XGBoostForecastProvider` (gradient boosting with calendar + lag features) with factory, lazy import + - `LoadForecastModelTrainingService`: nightly batch training with holdout evaluation + best model promotion + - Pydantic schemas: `EnergyLoadForecastProviderStatsmodelsConfigSchema`, `EnergyLoadForecastProviderXGBoostConfigSchema` + - Scheduler cron job at 04:00 for nightly ML model training + +- **Home Load — API Completion** + - `ConfigurationServiceInterface`: 10 new abstract CRUD methods for `EnergyLoadForecastProvider` (5) and `EnergyLoadHistoryProvider` (5) + - `ConfigurationService`: implemented `add_`, `get_`, `list_`, `update_`, `remove_energy_load_forecast_provider` + - Completed 5 forecast provider REST endpoints (previously stubs returning 501/empty): + - `GET /energy-load-forecast-providers` — list all providers + - `POST /energy-load-forecast-providers` — create and persist provider + - `GET /energy-load-forecast-providers/{id}` — get provider by ID + - `PUT /energy-load-forecast-providers/{id}` — update provider with config deserialization + - `DELETE /energy-load-forecast-providers/{id}` — remove provider + +### Changed +- `MiningPerformanceTrackerPort` methods are now `async`; the dummy tracker adapter has been adapted accordingly +- `OptimizationService` now awaits `get_current_hashrate` calls to match the async port contract, and consolidates the three tracker calls behind a new private helper `_build_mining_performance_snapshot` that returns a single `MiningPerformanceSnapshot` +- Replaced `DecisionalContext.tracker_current_hashrate: Optional[HashRate]` with `mining_performance: Optional[MiningPerformanceSnapshot]`; `DecisionalContextSchema` and the rule engine `OPERATOR_EXAMPLES[LTE]` example updated accordingly (new field path: `mining_performance.current_hashrate.value`) +- Interactive CLI main menu: "Run all optimization units" shifted from option 8 to 9 to accommodate the new tracker menu at option 8 +- Replaced per-module `_utc_now_timestamp()` helpers in `domain/performance/entities.py` and `domain/performance/value_objects.py` with the shared `utc_now_timestamp()` from `domain/common.py` +- `AdapterService`: new factory branches for STATSMODELS and XGBOOST with `model_repo` injection +- `PersistenceSettings`: added `load_consumption_model_repo` field +- `Services` dataclass: added `load_forecast_training_service` field +- `AutomationScheduler`: accepts optional `load_forecast_training_service`, schedules nightly training +- `bootstrap.py`: `LoadConsumptionModelRepository` wired in all three persistence branches (InMemory/SQLite/SQLAlchemy) + +### Fixed +- Replaced latent `default_factory=Timestamp(datetime.now())` bugs (which froze a single timestamp at class-definition time) with the proper callable `utc_now_timestamp`, producing a fresh timestamp per instance +- **Braiins Pool adapter aligned to post-FPPS API schema** (`edge_mining/adapters/domain/performance/trackers/braiins_pool.py`): the adapter was reading pre-FPPS field names that were removed in the November 2023 migration, so `unpaid_balance`, worker `valid_shares`, `payout_schedule.threshold` and `payout_schedule.next_payout_at` were always `None` in the `DecisionalContext`. Mapping now follows the current schema: + - `unpaid_balance` reads `current_balance` (previously `unconfirmed_reward`, removed upstream) + - current-hashrate fallback chain uses `hash_rate_60m` (previously `hash_rate_1h`, which never existed) + - `average_hashrate_7d` is left `None` (Braiins exposes only 24h/yesterday aggregates) + - `PayoutSchedule` is now `DAILY` with `threshold=None` and `next_payout_at=None` (FPPS pays daily, threshold is no longer configurable) + - Worker `valid_shares` maps to `shares_24h` (the only cumulative share metric exposed); `stale_shares`/`rejected_shares` stay `None` as Braiins doesn't surface them + +## [0.1.0-rev2] + +### Added +- **Miner Aggregate Root** (`edge_mining/domain/miner/aggregate_roots.py`): + - Promotes `Miner` to a full aggregate root with feature management capabilities + - Feature CRUD: `add_feature()`, `remove_feature()`, `remove_features_by_controller()` + - Feature queries: `get_active_feature()`, `get_features_by_controller()`, `get_features_by_type()`, `get_controller_ids()`, `has_feature()` + - Feature configuration: `enable_feature()`, `disable_feature()`, `set_feature_priority()` + +- **Miner Feature System** (`edge_mining/domain/miner/ports.py`): + - `MinerFeature` value object with identity based on `(feature_type, controller_id)` pair and configurable priority/enabled state + - `MinerFeatureType` enum with 17 values across 4 categories: monitoring (9), control (4), detection (3) + - `MinerFeaturePort` abstract base with MRO-based introspection via `get_supported_features()` class method + - **Monitoring Ports**: `HashrateMonitorPort`, `PowerMonitorPort`, `StatusMonitorPort`, `HashboardMonitorPort`, `InletTemperatureMonitorPort`, `OutletTemperatureMonitorPort`, `InternalFanSpeedMonitorPort`, `ExternalFanSpeedMonitorPort`, `OperationalMonitorPort` + - **Control Ports**: `MiningControlPort`, `PowerControlPort`, `InternalFanControlPort`, `ExternalFanControlPort` + - **Detection Ports**: `MaxPowerDetectionPort`, `MaxHashrateDetectionPort`, `DeviceInfoPort` + +- **New Value Objects** (`edge_mining/domain/miner/value_objects.py`): + - Measurement types: `Temperature`, `FanSpeed`, `Voltage`, `Frequency` frozen dataclasses with value and unit + - `MinerInfo`: Device information with model, serial number, firmware type (Stock, BOS+, VNish, etc.), firmware version, MAC address, hostname, hashboard/chip/fan count + - `MinerLimit`: Miner limits with optional `max_power` (Watts) and `max_hash_rate` (HashRate) + - `HashboardSnapshot`: Per-board metrics (chip/board temperature, voltage, frequency, hash rate, nominal hash rate, hash rate error) + - Extended `MinerStateSnapshot` with: `inlet_temperature`, `outlet_temperature`, `internal_fan_speed` (list), `hashboards` (list), and convenience properties (`max_chip_temperature`, `avg_board_temperature`, etc.) + +- **New Pydantic Schemas** (`edge_mining/adapters/domain/miner/schemas.py`): + - `TemperatureSchema`, `FanSpeedSchema`, `VoltageSchema`, `FrequencySchema` with unit validation + - `HashboardSnapshotSchema`, `MinerInfoSchema`, `MinerFeatureSchema`, `FeaturePrioritySchema` + - `MinerLimitSchema` with validation, `from_model()`/`to_model()` conversion for `MinerLimit` value object + +- **`miner_features` Database Table** (`edge_mining/adapters/domain/miner/tables.py`): + - Columns: `id`, `miner_id` (FK), `controller_id` (FK), `feature_type`, `priority` (default 50), `enabled` (default True) + - Helper functions: `load_features_for_miner()`, `save_features_for_miner()` + +- **New API Endpoints** (`edge_mining/adapters/domain/miner/fast_api/router.py`): + - `GET /miners/{miner_id}/info` — Get miner device information (model, serial number, firmware version, etc.) + - `GET /miners/{miner_id}/limits` — Get miner limits (max power, max hash rate) via `MaxPowerDetectionPort` and `MaxHashrateDetectionPort` + - `GET /miners/{miner_id}/features` — List miner features + - `POST /miners/{miner_id}/features/{controller_id}/{feature_type}/enable` — Enable a feature + - `POST /miners/{miner_id}/features/{controller_id}/{feature_type}/disable` — Disable a feature + - `PUT /miners/{miner_id}/features/{controller_id}/{feature_type}/priority` — Set feature priority + - `POST /miners/{miner_id}/link-controller/{controller_id}` — Link controller and auto-create features + - `POST /miners/{miner_id}/unlink-controller` — Remove all features from a controller + +### Changed +- **Full Async Refactoring**: + - All `MinerActionServiceInterface` methods are now `async`: `start_miner()`, `stop_miner()`, `get_miner_status()`, `get_miner_consumption()`, `get_miner_hashrate()`, `get_miner_info()`, `sync_all_miners()` + - All `ConfigurationServiceInterface` miner management methods are now `async`: `add_miner()`, `update_miner()`, `remove_miner()`, `activate_miner()`, `deactivate_miner()`, `add_miner_controller()`, `update_miner_controller()`, `remove_miner_controller()` + - Miner controller adapters, energy providers, forecast providers, and external services refactored to support asynchronous operations + - Miner feature port methods updated to `async` + - `OptimizationService` methods `get_decisional_context()` and `test_rules()` are now `async` + +- **`AdapterService`** (`edge_mining/application/services/adapter_service.py`): + - New methods: `get_miner_controller_adapter()`, `get_miner_feature_port()` for dynamic port-based adapter resolution + - `sync_miner_features()` method for reconciling stored vs. actual controller features + - Async initialization of external services with instance caching + +- **`ConfigurationService`** (`edge_mining/application/services/configuration_service.py`): + - New methods: `set_miner_controller()`, `unlink_controller_from_miner()`, `unlink_miner_controller()`, `enable_miner_feature()`, `disable_miner_feature()`, `set_miner_feature_priority()` + +- **`MinerActionService`** (`edge_mining/application/services/miner_action_service.py`): + - Uses `AdapterService` to dynamically resolve feature ports instead of direct controller access + - New `get_miner_info()` method using `DeviceInfoPort` + - New `get_miner_limits()` method using `MaxPowerDetectionPort` and `MaxHashrateDetectionPort` + +- **CLI Commands** (`edge_mining/adapters/domain/miner/cli/commands.py`): + - Refactored miner controller handling to support linking after creation + - New `unlink_controller_from_miner()` command + - Uses `run_async_func()` for async service calls + +- **Dependencies**: Updated `pyasic` to version `0.78.10` + +### Fixed +- Fixed data integrity validation and cleanup for unknown miner features in database + +## [0.1.0-rev1] + +### Added +- **Event-Driven Architecture**: + - `InMemoryEventBus` (`edge_mining/adapters/infrastructure/event_bus/in_memory_event_bus.py`): Dual delivery mode event bus supporting blocking and fire-and-forget handlers via `asyncio.create_task()` + - `ConfigurationUpdatedEvent` (`edge_mining/application/events/configuration_events.py`): Application-level event for cache invalidation with `ConfigurationUpdatedEventType` and `ConfigurationAction` enums + - `MinerStateChangedEvent` (`edge_mining/domain/miner/events.py`): Emitted on miner start/stop with old and new status + - `EnergyStateSnapshotUpdatedEvent` (`edge_mining/domain/energy/events.py`): Emitted when energy state is read + - `RuleEngagedEvent` (`edge_mining/domain/optimization_unit/events.py`): Emitted when a policy rule produces a mining decision + - `DecisionalContextUpdatedEvent` (`edge_mining/domain/policy/events.py`): Emitted when decisional context is composed + +- **WebSocket Infrastructure**: + - `WebSocketManager` (`edge_mining/adapters/infrastructure/websocket/manager.py`): Real-time event broadcasting to connected clients with wildcard topic subscriptions (e.g. `energy.*`, `miner.state`) + - `WebSocketEventHandler` base class and 5 domain handlers: `MinerWebSocketHandler`, `EnergyWebSocketHandler`, `PolicyWebSocketHandler`, `OptimizationUnitWebSocketHandler`, `ConfigurationWebSocketHandler` + - Available topics: `config.updated`, `energy.state`, `miner.state`, `policy.context`, `rule.engaged` + - `WebSocketMessage` NamedTuple and `WebSocketEventRegistration` dataclass for type-safe event routing + +- **Testing**: + - Unit tests for all 5 domain events (`tests/unit/application/events/`) + - Unit tests for `DomainEvent` base class (`tests/unit/domain/test_events.py`) + - Unit tests for `WebSocketManager` (`tests/unit/adapters/infrastructure/websocket/test_websocket_manager.py`): lifecycle, subscriptions, wildcard matching, broadcast + - Unit tests for `InMemoryEventBus` (`tests/unit/adapters/infrastructure/test_in_memory_event_bus.py`) + - Integration tests for configuration event flow (`tests/unit/application/services/test_configuration_event_flow.py`) + +- **Documentation**: + - `docs/architecture/event_bus.md` — Event Bus architecture design + - `docs/WEBSOCKET.md` — WebSocket client guide and architecture + +- **`MinerStateSnapshot` Value Object** (`edge_mining/domain/miner/value_objects.py`): + - New frozen dataclass representing the runtime operational state of a miner + - Fields: `status` (MinerStatus), `hash_rate` (Optional[HashRate]), `power_consumption` (Optional[Watts]) + - Follows the Single Responsibility Principle: separates real-time state from static configuration + +- **`MinerStateSnapshotSchema`** (`edge_mining/adapters/domain/miner/schemas.py`): + - Pydantic schema for serialization/deserialization of `MinerStateSnapshot` + - Methods: `from_model()`, `to_model()` for domain ↔ schema conversion + +### Changed +- **`Miner` Entity** (`edge_mining/domain/miner/entities.py`): + - **BREAKING**: Removed runtime state fields: `status`, `hash_rate`, `power_consumption` + - **BREAKING**: Removed methods: `turn_on()`, `turn_off()`, `update_status()` + - Simplified `deactivate()` to only set `self.active = False` + - Entity now represents only static configuration: `name`, `model`, `hash_rate_max`, `power_consumption_max`, `active`, `controller_id` + +- **`DecisionalContext`** (`edge_mining/domain/policy/value_objects.py`): + - Added `miner_state: Optional[MinerStateSnapshot]` field for runtime state access + - Existing `miner: Optional[Miner]` field retained for static configuration access + +- **`OptimizationPolicy`** (`edge_mining/domain/policy/aggregate_roots.py`): + - Updated `decide_next_action()` to read status from `decisional_context.miner_state.status` instead of `decisional_context.miner.status` + +- **Repositories** (`edge_mining/adapters/domain/miner/repositories.py`): + - `SqliteMinerRepository`: Removed `status`, `hash_rate`, `power_consumption` from schema, SQL queries, and row mapping + - `SqlAlchemyMinerRepository`: Removed runtime state fields from `update()` method + +- **SQLAlchemy Tables** (`edge_mining/adapters/domain/miner/tables.py`): + - Removed `MinerStatusType` custom type class + - Removed `status`, `hash_rate`, `power_consumption` columns from `miners_table` + - Simplified event listeners to only handle `hash_rate_max` and `power_consumption_max` + +- **Pydantic Schemas**: + - `MinerSchema` (`edge_mining/adapters/domain/miner/schemas.py`): Removed `status`, `hash_rate`, `power_consumption` fields + - `MinerCreateSchema`: Removed state fields from creation payload + - `DecisionalContextSchema` (`edge_mining/adapters/domain/policy/schemas.py`): Added `miner_state` field with `MinerStateSnapshotSchema` + +- **API Router** (`edge_mining/adapters/domain/miner/fast_api/router.py`): + - `GET /miners/{miner_id}/status`: Now returns `MinerStateSnapshotSchema` instead of `MinerSchema` + - `GET /miner-controllers/{controller_id}/miner-details`: Now returns `MinerStateSnapshotSchema` + +- **`AdapterService`** (`edge_mining/application/services/adapter_service.py`): + - Event subscription for `ConfigurationUpdatedEvent` to invalidate service caches + +- **`ConfigurationService`** (`edge_mining/application/services/configuration_service.py`): + - Publishes `ConfigurationUpdatedEvent` on all configuration changes + +- **`MinerActionService`** (`edge_mining/application/services/miner_action_service.py`): + - `start_miner()`/`stop_miner()` now publish `MinerStateChangedEvent` via event bus + +- **`bootstrap.py`**: Instantiates `InMemoryEventBus` and injects it into all services; adds `init_websocket_dependencies()` call at startup; runs `sync_all_miners()` on application start + +- **CLI Commands** (`edge_mining/adapters/domain/miner/cli/commands.py`): + - Removed status display from `list_miners()` and `print_miner_details()` + +- **Application Interfaces** (`edge_mining/application/interfaces.py`): + - New `get_miner_limits()` abstract method in `MinerActionServiceInterface` + - `get_miner_status()`: Return type changed from `Optional[MinerStatus]` to `Optional[MinerStateSnapshot]` + - `get_miner_details_from_controller()`: Return type changed from `Optional[Miner]` to `Optional[MinerStateSnapshot]` + - `add_miner()`: Removed `status` parameter + +- **Application Services**: + - `MinerActionService` (`edge_mining/application/services/miner_action_service.py`): Refactored all methods to build and return `MinerStateSnapshot` instead of mutating/persisting the `Miner` entity state + - `ConfigurationService` (`edge_mining/application/services/configuration_service.py`): Removed `status` from `add_miner()` + - `OptimizationService` (`edge_mining/application/services/optimization_service.py`): Updated context building to create `MinerStateSnapshot` objects; removed `miner.turn_on()`/`miner.turn_off()` calls and state persistence after decisions + +- **Rule Engine** (`edge_mining/adapters/infrastructure/rule_engine/schemas.py`): + - Updated operator examples from `miner.status` to `miner_state.status` + +- **YAML Rule Files** (`data/examples/rules/stop/`): + - Updated `advanced_stop_rules.yaml` and `basic_stop_rules.yaml`: Changed field references from `miner.status` to `miner_state.status` + +- **Alembic Migration** (`alembic/versions/4e55fe6113c7_initial_schema_with_all_tables.py`): + - Removed columns `status`, `hash_rate`, `power_consumption` from `miners` table definition + - Removed `MinerStatusType()` reference (custom type no longer exists) + +- **WebSocket event handlers**: Refactored to use topic strings and return `WebSocketMessage` payloads; `broadcast_message()` updated to use `WebSocketMessage` type + +- **`FORECAST_PROVIDER`** added to `ConfigurationUpdatedEventType` enum + +### Fixed +- Fixed unterminated string literal in `MinerActionServiceInterface` docstring +- Fixed connection error handling for Home Assistant API with client reset and improved logging +- Fixed external service update logic to handle missing configuration classes +- Fixed database directory creation for SQLite connections when using SQLAlchemy persistence adapter +- Fixed critical error handling replaced with warnings for missing energy values in Home Assistant monitors +- Fixed missing import for sqlalchemy in migration script + +## [0.1.0] + +### Added +- **Automatic Alembic Migrations**: Database migrations now run automatically on application startup + - New module `edge_mining/adapters/infrastructure/persistence/sqlalchemy/migrations.py` + - CLI tool `scripts/migrate.py` for manual migration management + - Configuration option `RUN_MIGRATIONS_ON_STARTUP` in settings (default: true) + - Commands: `status`, `upgrade`, `downgrade`, `create`, `history` + - Method `initialize_database()` in `BaseSQLAlchemyRepository` that handles complete DB initialization + +- **Documentation**: + - `docs/ALEMBIC_MIGRATIONS.md` - Complete guide to migration system + - `docs/MIGRATION_EXAMPLE.md` - Practical example of adding a field + +- **Testing Infrastructure**: + - **Unit Tests** (42 tests): + - `tests/unit/adapters/domain/energy/test_tables_event_listeners.py` - Complete test suite for SQLAlchemy event listeners + - Tests for `load` event listeners (EntityId, enum, and value object conversions from database) + - Tests for `before_insert/update` event listeners (flattening composites before persistence) + - Tests for `after_insert/update` event listeners (restoring composites after persistence) + - Tests for configuration deserialization and value object round-trip conversion + - **Integration Tests** (34 tests): + - `tests/integration/adapters/persistence/test_sqlalchemy_energy_repositories.py` (21 tests) - Full CRUD operations with real database + - `tests/integration/adapters/persistence/test_alembic_migrations.py` (9 tests) - Alembic migration system validation + - `tests/integration/adapters/persistence/test_e2e_persistence.py` (8 tests) - End-to-end persistence workflows + +### Changed +- **`BaseSQLAlchemyRepository`**: + - Added `initialize_database()` method that encapsulates all database setup logic + - Added `run_migrations` parameter to constructor + - Improved separation of concerns by moving migration logic from bootstrap to repository + - **BREAKING**: Removed `create_all_tables()` method - all schema changes must now go through Alembic migrations + - Fail-fast approach: initialization fails clearly if migrations fail (no silent fallback) +- **SQLAlchemy Event Listeners** (`edge_mining/adapters/domain/energy/tables.py`): + - Enhanced 4-phase conversion system for domain entities ↔ database mapping + - `load` listeners: Convert database strings to EntityId, enums, and value objects + - `before_insert/update` listeners: Flatten value objects to primitives before persistence + - `after_insert/update` listeners: Restore EntityId, enums, and value objects after persistence + - Added type ignore comments with explanatory notes for runtime type conversions + - Fixed foreign key conversions (energy_monitor_id, forecast_provider_id, external_service_id) +- **`bootstrap.py`**: Simplified database initialization using `initialize_database()` +- **`alembic/env.py`**: Updated to use shared metadata registry from SQLAlchemy imperative mapping +- **`.env.example`**: Added migration configuration examples and multi-database support +- **`README.md`**: Added database migrations section with usage instructions +- **Settings**: Added `run_migrations_on_startup` configuration option + +### Fixed +- Migration path calculation now correctly resolves project root +- Better error handling for migration failures with graceful fallback +- Improved encapsulation: database initialization logic moved to appropriate layer +- Fixed import error in `tests/unit/adapters/infrastructure/rule_engine/test_rule_evaluator.py` (OperatorType import path) +- SQLAlchemy event listeners now correctly handle all type conversions between domain and database layers +- EntityId conversions for primary keys and foreign keys (energy_monitor_id, forecast_provider_id, external_service_id) +- Enum conversions (EnergySourceType, EnergyMonitorAdapter) in both directions +- Value object conversions (Watts, Battery, Grid) with proper serialization/deserialization + +## [Previous Versions] diff --git a/core/Makefile b/core/Makefile new file mode 100644 index 0000000..1398a75 --- /dev/null +++ b/core/Makefile @@ -0,0 +1,110 @@ +# Makefile for Edge Mining Development Tools + +# Detect operating system +ifeq ($(OS),Windows_NT) +# On native Windows, instruct to use WSL or the provided scripts +.PHONY: help setup install install-dev format lint lint-fix test test-cov pre-commit pre-commit-install clean +help setup install install-dev format lint lint-fix test test-cov pre-commit pre-commit-install clean: + @echo "Windows environment detected." + @echo "This Makefile is intended to run under WSL (Linux) or on macOS/Linux." + @echo "Please either:" + @echo " - Use WSL and run 'make ' from your Linux shell, or" + @echo " - Use the Windows scripts: .\\dev-tools.ps1 or .\\dev-tools.bat" + @echo "" + @echo "Examples:" + @echo " PowerShell: .\\dev-tools.ps1 help" + @echo " CMD: .\\dev-tools.bat help" + @exit +else + # Unix-like (Linux, macOS) + VENV_BIN := .venv/bin + PYTHON := $(VENV_BIN)/python + PIP := $(VENV_BIN)/pip + PRE_COMMIT := $(VENV_BIN)/pre-commit + +# Default target +help: + @echo "Edge Mining Development Tools" + @echo "=============================" + @echo "" + @echo "Available commands:" + @echo " setup - Set up development environment" + @echo " install - Install dependencies" + @echo " install-dev - Install development dependencies" + @echo " format - Format code with ruff" + @echo " lint - Run all linting checks" + @echo " lint-fix - Run linting and fix what can be auto-fixed" + @echo " test - Run tests" + @echo " test-cov - Run tests with coverage" + @echo " pre-commit - Run pre-commit hooks on all files" + @echo " pre-commit-install - Install pre-commit hooks" + @echo " clean - Clean cache and temporary files" + +# Setup development environment +setup: install-dev pre-commit-install + @echo "✅ Development environment setup complete!" + +# Install production dependencies +install: + $(PIP) install -r requirements.txt + +# Install development dependencies +install-dev: + $(PIP) install -r requirements-dev.txt + +# Format code +format: + @echo "🔧 Formatting code..." + $(PYTHON) -m ruff format edge_mining/ tests/ + @echo "✅ Code formatting complete!" + +# Run linting +lint: + @echo "🔍 Running linting checks..." + $(PYTHON) -m ruff check edge_mining/ + $(PYTHON) -m mypy edge_mining/ || true + $(PYTHON) -m bandit -r edge_mining/ --skip B311,B104 || true + @echo "✅ Linting complete!" + +# Run linting and fix what can be auto-fixed +lint-fix: + @echo "🔧 Running auto-fixable linting..." + $(PYTHON) -m ruff check --fix edge_mining/ + $(PYTHON) -m ruff format edge_mining/ + @echo "✅ Auto-fix complete!" + +# Run tests +test: + @echo "🧪 Running tests..." + $(PYTHON) -m pytest tests/ -v + @echo "✅ Tests complete!" + +# Run tests with coverage +test-cov: + @echo "🧪 Running tests with coverage..." + $(PYTHON) -m pytest tests/ -v --cov=edge_mining --cov-report=html --cov-report=term + @echo "✅ Tests with coverage complete!" + +# Run pre-commit on all files +pre-commit: + @echo "🔧 Running pre-commit hooks..." + $(PRE_COMMIT) run --all-files + @echo "✅ Pre-commit complete!" + +# Install pre-commit hooks +pre-commit-install: + @echo "🔧 Installing pre-commit hooks..." + $(PRE_COMMIT) install + @echo "✅ Pre-commit hooks installed!" + +# Clean cache and temporary files +clean: + @echo "🧹 Cleaning cache and temporary files..." + find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete 2>/dev/null || true + find . -type f -name "*.pyo" -delete 2>/dev/null || true + find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true + rm -rf build/ dist/ .coverage htmlcov/ .pytest_cache/ 2>/dev/null || true + @echo "✅ Cleanup complete!" + +endif diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/alembic.ini b/core/alembic.ini new file mode 100644 index 0000000..d7e9248 --- /dev/null +++ b/core/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = sqlite:///data/db/edgemining.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/core/alembic/README b/core/alembic/README new file mode 100644 index 0000000..2500aa1 --- /dev/null +++ b/core/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. diff --git a/core/alembic/env.py b/core/alembic/env.py new file mode 100644 index 0000000..fb7a8c4 --- /dev/null +++ b/core/alembic/env.py @@ -0,0 +1,80 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Import the shared metadata and ensure all tables are registered +# This uses the imperative mapping approach with a shared registry +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.registry import metadata + +# Import registry loader to ensure all table definitions are registered +# This must happen before any migration operations +from edge_mining.adapters.infrastructure.persistence.sqlalchemy import registry_loader # noqa: F401 + +# Set target metadata for Alembic autogenerate support +target_metadata = metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/core/alembic/script.py.mako b/core/alembic/script.py.mako new file mode 100644 index 0000000..5497a39 --- /dev/null +++ b/core/alembic/script.py.mako @@ -0,0 +1,38 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# Import custom types used in Edge Mining table definitions +import edge_mining.adapters.infrastructure.external_services.tables +import edge_mining.adapters.domain.energy.tables +import edge_mining.adapters.domain.forecast.tables +import edge_mining.adapters.domain.home_load.tables +import edge_mining.adapters.domain.miner.tables +import edge_mining.adapters.domain.notification.tables +import edge_mining.adapters.domain.performance.tables +import edge_mining.adapters.domain.policy.tables + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/core/alembic/versions/4e55fe6113c7_initial_schema_with_all_tables.py b/core/alembic/versions/4e55fe6113c7_initial_schema_with_all_tables.py new file mode 100644 index 0000000..c151755 --- /dev/null +++ b/core/alembic/versions/4e55fe6113c7_initial_schema_with_all_tables.py @@ -0,0 +1,271 @@ +"""Initial schema with all tables + +Revision ID: 4e55fe6113c7 +Revises: +Create Date: 2026-01-23 16:43:04.117164 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +import edge_mining.adapters.domain.energy.tables +import edge_mining.adapters.domain.forecast.tables +import edge_mining.adapters.domain.home_load.tables +import edge_mining.adapters.domain.miner.tables +import edge_mining.adapters.domain.notification.tables +import edge_mining.adapters.domain.performance.tables +import edge_mining.adapters.domain.policy.tables + +# Import custom types used in table definitions +import edge_mining.adapters.infrastructure.external_services.tables +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "4e55fe6113c7" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "external_services", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("adapter_type", sa.String(), nullable=False), + sa.Column( + "config", + edge_mining.adapters.infrastructure.external_services.tables.ExternalServiceConfigType(), + nullable=True, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_external_services_id"), "external_services", ["id"], unique=False) + op.create_table( + "home_profiles", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("devices_json", edge_mining.adapters.domain.home_load.tables.LoadDevicesDictType(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "policies", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column("start_rules", edge_mining.adapters.domain.policy.tables.AutomationRulesListType(), nullable=True), + sa.Column("stop_rules", edge_mining.adapters.domain.policy.tables.AutomationRulesListType(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_index(op.f("ix_policies_id"), "policies", ["id"], unique=False) + op.create_table( + "settings", + sa.Column("id", sa.String(), nullable=False), + sa.Column("settings_json", sa.JSON(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "energy_monitors", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("adapter_type", sa.String(), nullable=False), + sa.Column("config", edge_mining.adapters.domain.energy.tables.EnergyMonitorConfigType(), nullable=True), + sa.Column("external_service_id", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["external_service_id"], + ["external_services.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_energy_monitors_id"), "energy_monitors", ["id"], unique=False) + op.create_table( + "forecast_providers", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("adapter_type", sa.String(), nullable=False), + sa.Column("config", edge_mining.adapters.domain.forecast.tables.ForecastProviderConfigType(), nullable=True), + sa.Column("external_service_id", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["external_service_id"], + ["external_services.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_forecast_providers_id"), "forecast_providers", ["id"], unique=False) + op.create_table( + "energy_load_forecast_providers", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("adapter_type", sa.String(), nullable=False), + sa.Column( + "config", edge_mining.adapters.domain.home_load.tables.EnergyLoadForecastProviderConfigType(), nullable=True + ), + sa.Column("external_service_id", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["external_service_id"], + ["external_services.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_energy_load_forecast_providers_id"), "energy_load_forecast_providers", ["id"], unique=False + ) + op.create_table( + "miner_controllers", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("adapter_type", sa.String(), nullable=False), + sa.Column("config", edge_mining.adapters.domain.miner.tables.MinerControllerConfigType(), nullable=True), + sa.Column("external_service_id", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["external_service_id"], + ["external_services.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_miner_controllers_id"), "miner_controllers", ["id"], unique=False) + op.create_table( + "mining_performance_trackers", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("adapter_type", sa.String(), nullable=False), + sa.Column( + "config", edge_mining.adapters.domain.performance.tables.MiningPerformanceTrackerConfigType(), nullable=True + ), + sa.Column("external_service_id", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["external_service_id"], + ["external_services.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_mining_performance_trackers_id"), "mining_performance_trackers", ["id"], unique=False) + op.create_table( + "notifiers", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("adapter_type", sa.String(), nullable=False), + sa.Column("config", edge_mining.adapters.domain.notification.tables.NotifierConfigType(), nullable=True), + sa.Column("external_service_id", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["external_service_id"], + ["external_services.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_notifiers_id"), "notifiers", ["id"], unique=False) + op.create_table( + "energy_sources", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("type", sa.String(), nullable=False), + sa.Column("nominal_power_max", sa.Float(), nullable=True), + sa.Column("storage", sa.JSON(), nullable=True), + sa.Column("grid", sa.JSON(), nullable=True), + sa.Column("external_source", sa.Float(), nullable=True), + sa.Column("energy_monitor_id", sa.String(), nullable=True), + sa.Column("forecast_provider_id", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["energy_monitor_id"], + ["energy_monitors.id"], + ), + sa.ForeignKeyConstraint( + ["forecast_provider_id"], + ["forecast_providers.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_energy_sources_id"), "energy_sources", ["id"], unique=False) + op.create_table( + "miners", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("model", sa.String(), nullable=True), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("hash_rate_max", sa.String(), nullable=True), + sa.Column("power_consumption_max", sa.Float(), nullable=True), + sa.Column("controller_id", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["controller_id"], + ["miner_controllers.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_miners_id"), "miners", ["id"], unique=False) + op.create_table( + "optimization_units", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column("is_enabled", sa.Boolean(), nullable=False), + sa.Column("policy_id", sa.String(), nullable=True), + sa.Column( + "target_miner_ids", edge_mining.adapters.domain.optimization_unit.tables.EntityIdListType(), nullable=False + ), + sa.Column("energy_source_id", sa.String(), nullable=True), + sa.Column("performance_tracker_id", sa.String(), nullable=True), + sa.Column( + "notifier_ids", edge_mining.adapters.domain.optimization_unit.tables.EntityIdListType(), nullable=False + ), + sa.ForeignKeyConstraint( + ["energy_source_id"], + ["energy_sources.id"], + ), + sa.ForeignKeyConstraint( + ["performance_tracker_id"], + ["mining_performance_trackers.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_optimization_units_id"), "optimization_units", ["id"], unique=False) + op.create_table( + "home_load_power_points", + sa.Column("device_id", sa.String(), nullable=False), + sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False), + sa.Column("power", sa.Float(), nullable=False), + sa.PrimaryKeyConstraint("device_id", "timestamp"), + ) + op.create_index( + "ix_home_load_power_points_device_ts", + "home_load_power_points", + ["device_id", "timestamp"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("ix_home_load_power_points_device_ts", table_name="home_load_power_points") + op.drop_table("home_load_power_points") + op.drop_index(op.f("ix_optimization_units_id"), table_name="optimization_units") + op.drop_table("optimization_units") + op.drop_index(op.f("ix_miners_id"), table_name="miners") + op.drop_table("miners") + op.drop_index(op.f("ix_energy_sources_id"), table_name="energy_sources") + op.drop_table("energy_sources") + op.drop_index(op.f("ix_notifiers_id"), table_name="notifiers") + op.drop_table("notifiers") + op.drop_index(op.f("ix_mining_performance_trackers_id"), table_name="mining_performance_trackers") + op.drop_table("mining_performance_trackers") + op.drop_index(op.f("ix_miner_controllers_id"), table_name="miner_controllers") + op.drop_table("miner_controllers") + op.drop_index(op.f("ix_energy_load_forecast_providers_id"), table_name="energy_load_forecast_providers") + op.drop_table("energy_load_forecast_providers") + op.drop_index(op.f("ix_forecast_providers_id"), table_name="forecast_providers") + op.drop_table("forecast_providers") + op.drop_index(op.f("ix_energy_monitors_id"), table_name="energy_monitors") + op.drop_table("energy_monitors") + op.drop_table("settings") + op.drop_index(op.f("ix_policies_id"), table_name="policies") + op.drop_table("policies") + op.drop_table("home_profiles") + op.drop_index(op.f("ix_external_services_id"), table_name="external_services") + op.drop_table("external_services") + # ### end Alembic commands ### diff --git a/core/alembic/versions/a1b2c3d4e5f6_add_miner_features_table.py b/core/alembic/versions/a1b2c3d4e5f6_add_miner_features_table.py new file mode 100644 index 0000000..df93a90 --- /dev/null +++ b/core/alembic/versions/a1b2c3d4e5f6_add_miner_features_table.py @@ -0,0 +1,64 @@ +"""Add miner_features table and remove controller_id from miners + +Revision ID: a1b2c3d4e5f6 +Revises: 4e55fe6113c7 +Create Date: 2026-01-24 10:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, Sequence[str], None] = "4e55fe6113c7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add miner_features table and migrate controller_id data.""" + # Create miner_features table + op.create_table( + "miner_features", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("miner_id", sa.String(), nullable=False), + sa.Column("controller_id", sa.String(), nullable=False), + sa.Column("feature_type", sa.String(), nullable=False), + sa.Column("priority", sa.Integer(), nullable=False, server_default="50"), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"), + sa.ForeignKeyConstraint( + ["miner_id"], + ["miners.id"], + ), + sa.ForeignKeyConstraint( + ["controller_id"], + ["miner_controllers.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + + # Remove controller_id column from miners table + # Note: SQLite doesn't support DROP COLUMN directly in older versions, + # but Alembic handles this with batch mode on SQLite. + with op.batch_alter_table("miners") as batch_op: + batch_op.drop_column("controller_id") + + +def downgrade() -> None: + """Remove miner_features table and restore controller_id on miners.""" + # Re-add controller_id to miners + with op.batch_alter_table("miners") as batch_op: + batch_op.add_column(sa.Column("controller_id", sa.String(), nullable=True)) + batch_op.create_foreign_key( + "fk_miners_controller_id", + "miner_controllers", + ["controller_id"], + ["id"], + ) + + # Drop miner_features table + op.drop_table("miner_features") diff --git a/core/alembic/versions/b2c3d4e5f6a7_add_energy_load_history_providers.py b/core/alembic/versions/b2c3d4e5f6a7_add_energy_load_history_providers.py new file mode 100644 index 0000000..a003289 --- /dev/null +++ b/core/alembic/versions/b2c3d4e5f6a7_add_energy_load_history_providers.py @@ -0,0 +1,56 @@ +"""Add energy_load_history_providers table + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-04-22 10:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +import edge_mining.adapters.domain.home_load.tables +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "b2c3d4e5f6a7" +down_revision: Union[str, Sequence[str], None] = "a1b2c3d4e5f6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add energy_load_history_providers table.""" + op.create_table( + "energy_load_history_providers", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("adapter_type", sa.String(), nullable=False), + sa.Column( + "config", + edge_mining.adapters.domain.home_load.tables.EnergyLoadHistoryProviderConfigType(), + nullable=True, + ), + sa.Column("external_service_id", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["external_service_id"], + ["external_services.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_energy_load_history_providers_id"), + "energy_load_history_providers", + ["id"], + unique=False, + ) + + +def downgrade() -> None: + """Remove energy_load_history_providers table.""" + op.drop_index( + op.f("ix_energy_load_history_providers_id"), + table_name="energy_load_history_providers", + ) + op.drop_table("energy_load_history_providers") diff --git a/core/alembic/versions/c3d4e5f6a7b8_add_load_consumption_models.py b/core/alembic/versions/c3d4e5f6a7b8_add_load_consumption_models.py new file mode 100644 index 0000000..614f4ca --- /dev/null +++ b/core/alembic/versions/c3d4e5f6a7b8_add_load_consumption_models.py @@ -0,0 +1,47 @@ +"""Add load_consumption_models table + +Revision ID: c3d4e5f6a7b8 +Revises: b2c3d4e5f6a7 +Create Date: 2026-04-22 14:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "c3d4e5f6a7b8" +down_revision: Union[str, None] = "b2c3d4e5f6a7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "load_consumption_models", + sa.Column("id", sa.String(), nullable=False), + sa.Column("device_id", sa.String(), nullable=True), + sa.Column("adapter_type", sa.String(), nullable=False), + sa.Column("trained_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("mae", sa.Float(), nullable=True), + sa.Column("rmse", sa.Float(), nullable=True), + sa.Column("samples_used", sa.Integer(), nullable=False, server_default="0"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="0"), + sa.Column("model_bytes", sa.LargeBinary(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_load_consumption_models_id", "load_consumption_models", ["id"]) + op.create_index( + "ix_load_consumption_models_active", + "load_consumption_models", + ["adapter_type", "device_id", "is_active"], + ) + + +def downgrade() -> None: + op.drop_index("ix_load_consumption_models_active", table_name="load_consumption_models") + op.drop_index("ix_load_consumption_models_id", table_name="load_consumption_models") + op.drop_table("load_consumption_models") diff --git a/core/alembic/versions/d4e5f6a7b8c9_add_tuning_and_backtesting_columns.py b/core/alembic/versions/d4e5f6a7b8c9_add_tuning_and_backtesting_columns.py new file mode 100644 index 0000000..6f381c4 --- /dev/null +++ b/core/alembic/versions/d4e5f6a7b8c9_add_tuning_and_backtesting_columns.py @@ -0,0 +1,35 @@ +"""Add tuning and backtesting columns to load_consumption_models + +Revision ID: d4e5f6a7b8c9 +Revises: c3d4e5f6a7b8 +Create Date: 2026-04-25 10:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "d4e5f6a7b8c9" +down_revision: Union[str, None] = "c3d4e5f6a7b8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("load_consumption_models") as batch_op: + batch_op.add_column(sa.Column("tuning_params", sa.Text(), nullable=True)) + batch_op.add_column(sa.Column("backtest_mae", sa.Float(), nullable=True)) + batch_op.add_column(sa.Column("backtest_rmse", sa.Float(), nullable=True)) + batch_op.add_column(sa.Column("backtest_folds", sa.Integer(), nullable=False, server_default="0")) + + +def downgrade() -> None: + with op.batch_alter_table("load_consumption_models") as batch_op: + batch_op.drop_column("backtest_folds") + batch_op.drop_column("backtest_rmse") + batch_op.drop_column("backtest_mae") + batch_op.drop_column("tuning_params") diff --git a/core/alembic/versions/e5f6a7b8c9d0_add_home_loads_profile_to_optimization_units.py b/core/alembic/versions/e5f6a7b8c9d0_add_home_loads_profile_to_optimization_units.py new file mode 100644 index 0000000..a898849 --- /dev/null +++ b/core/alembic/versions/e5f6a7b8c9d0_add_home_loads_profile_to_optimization_units.py @@ -0,0 +1,29 @@ +"""Add home_loads_profile to optimization_units + +Revision ID: e5f6a7b8c9d0 +Revises: d4e5f6a7b8c9 +Create Date: 2026-04-29 12:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "e5f6a7b8c9d0" +down_revision: Union[str, None] = "d4e5f6a7b8c9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("optimization_units") as batch_op: + batch_op.add_column(sa.Column("home_loads_profile", sa.String(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("optimization_units") as batch_op: + batch_op.drop_column("home_loads_profile") diff --git a/core/compose.dev.yaml b/core/compose.dev.yaml new file mode 100644 index 0000000..884312d --- /dev/null +++ b/core/compose.dev.yaml @@ -0,0 +1,22 @@ +# Development-only compose for running the backend standalone (without frontend/nginx). +# For production, use the root compose.yaml which builds the full multi-stage image. +# +# Usage: +# cd core/ +# docker compose -f compose.dev.yaml up --build + +services: + edge-mining-core-dev: + image: edge-mining-core:dev + build: + context: . + dockerfile: ../Dockerfile + target: backend-dev + container_name: edge-mining-core-dev + environment: + - TIMEZONE=Europe/Rome + - SCHEDULER_INTERVAL_SECONDS=5 + ports: + - "8001:8001" + volumes: + - ./data:/app/core/data diff --git a/core/data/examples/policies/550e8400-e29b-41d4-a716-446655440000.yaml b/core/data/examples/policies/550e8400-e29b-41d4-a716-446655440000.yaml new file mode 100644 index 0000000..bec94a5 --- /dev/null +++ b/core/data/examples/policies/550e8400-e29b-41d4-a716-446655440000.yaml @@ -0,0 +1,141 @@ +id: 550e8400-e29b-41d4-a716-446655440000 +name: Advanced Solar Mining Policy +description: Comprehensive policy for solar-based mining optimization with intelligent start and stop conditions +start_rules: + - id: 550e8400-e29b-41d4-a716-446655440001 + name: Priority Solar Mining + description: 'High priority: Start mining when solar production is very high' + conditions: + all_of: + - field: energy_state.production + operator: gt + value: 3000 + - field: energy_state.battery.state_of_charge + operator: gt + value: 40 + priority: 20 + enabled: true + - id: 550e8400-e29b-41d4-a716-446655440002 + name: Smart Weekend Mining + description: Weekend mining with intelligent conditions + conditions: + all_of: + - field: timestamp.weekday + operator: in + value: [5, 6] + - any_of: + - all_of: + - field: forecast.avg_next_4_hours_power + operator: gt + value: 2000 + - field: energy_state.battery.state_of_charge + operator: gt + value: 70 + - all_of: + - field: energy_state.grid.exporting_power + operator: lt + value: 800 + - field: energy_state.battery.state_of_charge + operator: gt + value: 50 + - not_: + field: timestamp.hour + operator: in + value: [2, 3, 4] + priority: 15 + enabled: true + - id: 550e8400-e29b-41d4-a716-446655440003 + name: Economic Opportunity Mining + description: Start mining when economic conditions are favorable + conditions: + all_of: + - field: energy_state.battery.state_of_charge + operator: gte + value: 85 + - field: energy_state.production + operator: gt + value: 1800 + - any_of: + - field: timestamp.weekday + operator: in + value: [5, 6] + - all_of: + - field: timestamp.weekday + operator: in + value: [0, 1, 2, 3, 4] + - field: timestamp.hour + operator: in + value: [11, 12, 13, 14] + priority: 5 + enabled: true +stop_rules: + - id: 550e8400-e29b-41d4-a716-446655440004 + name: Critical Battery Protection + description: 'CRITICAL: Stop mining immediately when battery is critically low' + conditions: + any_of: + - field: energy_state.battery.state_of_charge + operator: lt + value: 15 + - field: energy_state.battery.remaining_capacity + operator: lt + value: 800 + - all_of: + - field: energy_state.battery.current_power + operator: lt + value: -1000 + - field: energy_state.battery.state_of_charge + operator: lt + value: 25 + priority: 100 + enabled: true + - id: 550e8400-e29b-41d4-a716-446655440005 + name: Grid Import Limit Protection + description: Stop mining when importing too much from grid + conditions: + all_of: + - field: energy_state.grid.importing_power + operator: gt + value: 2500 + - any_of: + - field: forecast.next_hour_power + operator: lt + value: 1000 + - field: energy_state.battery.state_of_charge + operator: lt + value: 60 + - not_: + all_of: + - field: timestamp.hour + operator: in + value: [1, 2, 3, 4, 5] + - field: energy_state.battery.state_of_charge + operator: lt + value: 30 + priority: 80 + enabled: true + - id: 550e8400-e29b-41d4-a716-446655440006 + name: Peak Home Consumption Stop + description: Stop mining during high home energy demand periods + conditions: + all_of: + - field: home_load.total_forecast.next_2h.avg_power + operator: gt + value: 2800 + - field: timestamp.hour + operator: in + value: [17, 18, 19, 20, 21, 22] + - any_of: + - field: energy_state.battery.state_of_charge + operator: lt + value: 70 + - field: energy_state.production + operator: lt + value: 1500 + priority: 60 + enabled: true +metadata: + author: Edge Mining User + version: 6 + created: '2025-08-04' + last_modified: '2026-04-22' diff --git a/core/data/examples/rules/start/advanced_start_rules.yaml b/core/data/examples/rules/start/advanced_start_rules.yaml new file mode 100644 index 0000000..6480f19 --- /dev/null +++ b/core/data/examples/rules/start/advanced_start_rules.yaml @@ -0,0 +1,76 @@ +rules: + - name: "Smart Weekend Mining (On-Grid)" + description: "Weekend mining with intelligent conditions for on-grid systems" + conditions: + all_of: + - field: "timestamp.weekday" + operator: "in" + value: [5, 6] # Saturday, Sunday + - any_of: + - all_of: # Scenario 1: High solar + good battery + - field: "forecast.avg_next_4_hours_power" + operator: "gt" + value: 2000 + - field: "energy_state.battery.state_of_charge" + operator: "gt" + value: 70 + - all_of: # Scenario 2: Exporting to grid + - field: "energy_state.grid.exporting_power" + operator: "lt" + value: 800 # Exporting at least 800W + - field: "energy_state.battery.state_of_charge" + operator: "gt" + value: 50 + - not_: # Not during maintenance hours + field: "timestamp.hour" + operator: "in" + value: [2, 3, 4] # Early morning maintenance + priority: 15 + enabled: true + + - name: "Off-Peak Hours Mining" + description: "Mining during off-peak electricity hours with good conditions" + conditions: + all_of: + - field: "timestamp.hour" + operator: "in" + value: [10, 11, 12, 13, 14, 15] # Peak solar hours + - field: "energy_state.battery.state_of_charge" + operator: "gt" + value: 60 + - field: "home_load.total_forecast.next_2h.avg_power" + operator: "lt" + value: 2000 # Low expected home consumption (next 2h average) + - any_of: + - field: "energy_state.production" + operator: "gt" + value: 2500 + - field: "energy_state.grid.exporting_power" + operator: "lt" + value: 300 # Some export + priority: 10 + enabled: true + + - name: "Economic Opportunity Mining" + description: "Start mining when economic conditions are favorable" + conditions: + all_of: + - field: "energy_state.battery.state_of_charge" + operator: "gte" + value: 85 # High battery + - field: "energy_state.production" + operator: "gt" + value: 1800 + - any_of: + - field: "timestamp.weekday" + operator: "in" + value: [5, 6] # Weekends + - all_of: # Or weekday off-peak + - field: "timestamp.weekday" + operator: "in" + value: [0, 1, 2, 3, 4] + - field: "timestamp.hour" + operator: "in" + value: [11, 12, 13, 14] + priority: 5 + enabled: true diff --git a/core/data/examples/rules/start/basic_start_rules.yaml b/core/data/examples/rules/start/basic_start_rules.yaml new file mode 100644 index 0000000..f9a0fbb --- /dev/null +++ b/core/data/examples/rules/start/basic_start_rules.yaml @@ -0,0 +1,68 @@ +rules: + - name: "Priority Solar Mining" + description: "High priority: Start mining when solar production is very high" + conditions: + all_of: + - field: "energy_state.production" + operator: "gt" + value: 3000 # Very high solar production + - field: "energy_state.battery.state_of_charge" + operator: "gt" + value: 40 # Battery not too low + priority: 20 + enabled: true + + - name: "High Solar + Low Battery" + description: "Start mining when solar production is high and battery is not full" + conditions: + all_of: + - field: "forecast.next_hour_power" + operator: "gt" + value: 2000 # Watts + - field: "energy_state.battery.state_of_charge" + operator: "lt" + value: 90 # Percentage + priority: 20 + enabled: true + + - name: "Battery Full + Excess Solar (On-Grid)" + description: "Start mining when battery is full and energy is exported to the grid" + conditions: + all_of: + - field: "energy_state.battery.state_of_charge" + operator: "gte" + value: 95 + - field: "energy_state.grid.exporting_power" + operator: "gt" + value: 500 + priority: 20 + enabled: true + + - name: "Battery Full + Excess Solar (Off-Grid)" + description: "Start mining when battery is full and forecast predicts high solar production" + conditions: + all_of: + - field: "energy_state.battery.state_of_charge" + operator: "gte" + value: 95 + - field: "forecast.next_hour_power" + operator: "gt" + value: 2000 # Watts + priority: 20 + enabled: true + + - name: "Weekend High Production" + description: "Start mining during weekends with good solar forecast" + conditions: + all_of: + - field: "timestamp.weekday" + operator: "in" + value: [5, 6] # Saturday, Sunday + - field: "forecast.avg_next_4_hours_power" + operator: "gt" + value: 1500 + - field: "energy_state.battery.state_of_charge" + operator: "gt" + value: 50 + priority: 20 + enabled: true diff --git a/core/data/examples/rules/start/home_load_start_rules.yaml b/core/data/examples/rules/start/home_load_start_rules.yaml new file mode 100644 index 0000000..9a676da --- /dev/null +++ b/core/data/examples/rules/start/home_load_start_rules.yaml @@ -0,0 +1,45 @@ +rules: + - name: "Low Home Load Forecast" + description: "Start mining when total household forecast is low for the next 2 hours" + conditions: + all_of: + - field: "home_load.total_forecast.next_2h.total_energy" + operator: "lt" + value: 1000 # Less than 1 kWh total in the next 2 hours + - field: "energy_state.battery.state_of_charge" + operator: "gt" + value: 40 + priority: 15 + enabled: true + + - name: "Low Boiler Forecast + Excess Solar" + description: "Start mining when boiler forecast is low and solar production is good" + conditions: + all_of: + - field: "home_load.devices.boiler.forecast.next_1h.peak_power" + operator: "lt" + value: 500 # Boiler expected to draw less than 500W in next hour + - field: "energy_state.production" + operator: "gt" + value: 2000 + - field: "energy_state.battery.state_of_charge" + operator: "gt" + value: 50 + priority: 12 + enabled: true + + - name: "Low Historical Load + Good Forecast" + description: "Start mining when recent load history is low and solar forecast is positive" + conditions: + all_of: + - field: "home_load.total_history.last_1h.avg_power" + operator: "lt" + value: 800 # Average power in the last hour below 800W + - field: "forecast.next_hour_power" + operator: "gt" + value: 1500 + - field: "energy_state.battery.state_of_charge" + operator: "gt" + value: 45 + priority: 10 + enabled: true diff --git a/core/data/examples/rules/start/mining_performance_start_rules.yaml b/core/data/examples/rules/start/mining_performance_start_rules.yaml new file mode 100644 index 0000000..02b2883 --- /dev/null +++ b/core/data/examples/rules/start/mining_performance_start_rules.yaml @@ -0,0 +1,54 @@ +rules: + - name: "Pre-Payout Push" + description: "Keep mining aggressively when the estimated next payout is close to the pool threshold — don't leave satoshis on the table" + conditions: + all_of: + - field: "mining_performance.pool_stats.estimated_next_payout" + operator: "gt" + value: 80000 # at least 80k sats estimated + - field: "mining_performance.payout_schedule.threshold" + operator: "lte" + value: 100000 # threshold is 100k sats or lower (close enough to reach) + - field: "energy_state.battery.state_of_charge" + operator: "gt" + value: 40 + priority: 30 + enabled: true + + - name: "Hashrate Below Weekly Average — Recovery Mode" + description: "Start mining to recover from a hashrate dip vs the 7-day pool baseline, provided energy conditions are acceptable" + conditions: + all_of: + - field: "mining_performance.pool_stats.average_hashrate_7d.value" + operator: "gt" + value: 0 + - field: "mining_performance.current_hashrate.value" + operator: "lt" + value: 50 # current ≤ 50 TH/s (tune to your fleet nominal) + - field: "energy_state.production" + operator: "gt" + value: 1500 + - field: "energy_state.battery.state_of_charge" + operator: "gt" + value: 50 + priority: 15 + enabled: true + + - name: "Healthy Pool + Healthy Energy" + description: "Start mining when both pool connectivity (recent current_hashrate) and energy conditions are green" + conditions: + all_of: + - field: "mining_performance.current_hashrate.value" + operator: "gt" + value: 0 # tracker is reporting a live hashrate + - field: "mining_performance.payout_schedule.frequency" + operator: "in" + value: ["hourly", "daily", "per_block", "threshold"] # exclude "unknown" + - field: "energy_state.production" + operator: "gt" + value: 2000 + - field: "energy_state.battery.state_of_charge" + operator: "gt" + value: 60 + priority: 10 + enabled: true diff --git a/core/data/examples/rules/stop/advanced_stop_rules.yaml b/core/data/examples/rules/stop/advanced_stop_rules.yaml new file mode 100644 index 0000000..847f720 --- /dev/null +++ b/core/data/examples/rules/stop/advanced_stop_rules.yaml @@ -0,0 +1,128 @@ +rules: + - name: "Critical Battery Protection" + description: "CRITICAL: Stop mining immediately when battery is critically low" + conditions: + any_of: + - field: "energy_state.battery.state_of_charge" + operator: "lt" + value: 15 # Critical battery level + - field: "energy_state.battery.remaining_capacity" + operator: "lt" + value: 800 # Less than 800 WattHours remaining + - all_of: # Battery discharging fast + low SOC + - field: "energy_state.battery.discharging_power" + operator: "gt" + value: 1000 # Discharging more than 1kW + - field: "energy_state.battery.state_of_charge" + operator: "lt" + value: 25 + priority: 100 # Highest priority for safety + enabled: true + + - name: "Grid Import Limit Protection" + description: "Stop mining when importing too much from grid" + conditions: + all_of: + - field: "energy_state.grid.importing_power" + operator: "gt" + value: 2500 # Importing more than 2.5kW + - any_of: + - field: "forecast.next_hour_power" + operator: "lt" + value: 1000 # Low solar forecast + - field: "energy_state.battery.state_of_charge" + operator: "lt" + value: 60 # Battery not high enough + - not_: # Not during emergency charging hours + all_of: + - field: "timestamp.hour" + operator: "in" + value: [1, 2, 3, 4, 5] # Very early morning + - field: "energy_state.battery.state_of_charge" + operator: "lt" + value: 30 + priority: 80 + enabled: true + + - name: "Peak Home Consumption Stop" + description: "Stop mining during high home energy demand periods" + conditions: + all_of: + - field: "home_load.total_forecast.next_2h.avg_power" + operator: "gt" + value: 2800 # High predicted home consumption (next 2h average) + - field: "timestamp.hour" + operator: "in" + value: [17, 18, 19, 20, 21, 22] # Evening hours + - any_of: + - field: "energy_state.battery.state_of_charge" + operator: "lt" + value: 70 + - field: "energy_state.production" + operator: "lt" + value: 1500 # Low current production + priority: 60 + enabled: true + + - name: "Weather-Based Stop" + description: "Stop mining when weather conditions deteriorate" + conditions: + all_of: + - field: "forecast.avg_next_4_hours_power" + operator: "lt" + value: 800 # Very low solar forecast + - field: "energy_state.production" + operator: "lt" + value: 1200 # Current production also low + - any_of: + - field: "energy_state.battery.state_of_charge" + operator: "lt" + value: 50 + - field: "energy_state.grid.current_power" + operator: "gt" + value: 1500 # Importing significant power + priority: 40 + enabled: true + + - name: "Maintenance Window Stop" + description: "Scheduled maintenance stops" + conditions: + any_of: + - field: "timestamp.hour" + operator: "eq" + value: 3 # 3 AM maintenance + - field: "miner_state.status" + operator: "eq" + value: "ERROR" # Stop on miner error + - all_of: # Sunday early morning maintenance + - field: "timestamp.weekday" + operator: "eq" + value: 6 # Sunday + - field: "timestamp.hour" + operator: "in" + value: [2, 3, 4, 5] + priority: 70 + enabled: true + + - name: "Economic Stop" + description: "Stop mining when economic conditions are unfavorable" + conditions: + all_of: + - field: "energy_state.grid.importing_power" + operator: "gt" + value: 2000 # Importing significant power + - field: "energy_state.battery.state_of_charge" + operator: "lt" + value: 40 # Battery getting low + - field: "timestamp.hour" + operator: "in" + value: [16, 17, 18, 19, 20, 21, 22, 23] # Peak rate hours + - any_of: + - field: "forecast.next_hour_power" + operator: "lt" + value: 500 # Very low next hour forecast + - field: "home_load.total_forecast.next_2h.avg_power" + operator: "gt" + value: 2500 # High home consumption expected (next 2h average) + priority: 30 + enabled: true diff --git a/core/data/examples/rules/stop/basic_stop_rules.yaml b/core/data/examples/rules/stop/basic_stop_rules.yaml new file mode 100644 index 0000000..92e9768 --- /dev/null +++ b/core/data/examples/rules/stop/basic_stop_rules.yaml @@ -0,0 +1,65 @@ +rules: + - name: "Low Battery Stop" + description: "Stop mining when battery is low" + conditions: + any_of: + - field: "energy_state.battery.state_of_charge" + operator: "lt" + value: 20 + - field: "energy_state.battery.remaining_capacity" + operator: "lt" + value: 2000 # WattHours + priority: 100 # Highest priority for safety + enabled: true + + - name: "Grid Import Limit" + description: "Stop mining when importing too much from grid" + conditions: + all_of: + - field: "energy_state.grid.importing_power" + operator: "gt" + value: 200 # Importing more than 200W + priority: 20 + enabled: true + + - name: "High Home Consumption" + description: "Stop mining when home load is high and battery is not full" + conditions: + all_of: + - field: "home_load.total_forecast.next_2h.avg_power" + operator: "gt" + value: 2500 + - field: "energy_state.battery.state_of_charge" + operator: "lt" + value: 80 + - field: "timestamp.hour" + operator: "in" + value: [17, 18, 19, 20, 21] # Evening hours + priority: 20 + enabled: true + + - name: "Maintenance Window" + description: "Stop mining during maintenance hours" + conditions: + any_of: + - field: "timestamp.hour" + operator: "eq" + value: 3 # 3 AM maintenance window + - field: "miner_state.status" + operator: "eq" + value: "ERROR" + priority: 20 + enabled: true + + - name: "Quiet Window" + description: "Stop mining during quiet hours" + conditions: + any_of: + - field: "timestamp.hour" + operator: "in" + value: [6, 7, 8] # Early morning + - field: "miner_state.status" + operator: "eq" + value: "ERROR" + priority: 20 + enabled: true diff --git a/core/data/examples/rules/stop/home_load_stop_rules.yaml b/core/data/examples/rules/stop/home_load_stop_rules.yaml new file mode 100644 index 0000000..62a20d5 --- /dev/null +++ b/core/data/examples/rules/stop/home_load_stop_rules.yaml @@ -0,0 +1,62 @@ +rules: + - name: "High Home Load Forecast" + description: "Stop mining when total household forecast is high for the next 2 hours" + conditions: + all_of: + - field: "home_load.total_forecast.next_2h.avg_power" + operator: "gt" + value: 2500 # Household expected to draw over 2.5 kW on average + - field: "energy_state.battery.state_of_charge" + operator: "lt" + value: 70 + priority: 60 + enabled: true + + - name: "Boiler Peak + Low Battery" + description: "Stop mining when boiler is forecasted to peak and battery is not high" + conditions: + all_of: + - field: "home_load.devices.boiler.forecast.next_1h.peak_power" + operator: "gt" + value: 2000 # Boiler expected to draw over 2 kW peak in next hour + - field: "energy_state.battery.state_of_charge" + operator: "lt" + value: 60 + priority: 55 + enabled: true + + - name: "Evening High Load History" + description: "Stop mining during evening hours when recent consumption is high" + conditions: + all_of: + - field: "timestamp.hour" + operator: "in" + value: [17, 18, 19, 20, 21, 22] + - field: "home_load.total_history.last_1h.avg_power" + operator: "gt" + value: 2000 # Currently consuming over 2 kW average + - field: "energy_state.battery.state_of_charge" + operator: "lt" + value: 75 + priority: 50 + enabled: true + + - name: "Multiple Devices High Forecast" + description: "Stop mining when total forecast for next 4 hours shows sustained high load" + conditions: + all_of: + - field: "home_load.total_forecast.next_4h.avg_power" + operator: "gt" + value: 1800 + - field: "home_load.total_forecast.next_4h.peak_power" + operator: "gt" + value: 3000 # Peak expected above 3 kW + - any_of: + - field: "energy_state.battery.state_of_charge" + operator: "lt" + value: 50 + - field: "energy_state.production" + operator: "lt" + value: 1000 + priority: 45 + enabled: true diff --git a/core/data/examples/rules/stop/mining_performance_stop_rules.yaml b/core/data/examples/rules/stop/mining_performance_stop_rules.yaml new file mode 100644 index 0000000..9159e36 --- /dev/null +++ b/core/data/examples/rules/stop/mining_performance_stop_rules.yaml @@ -0,0 +1,55 @@ +rules: + - name: "Hashrate Drop vs 24h Average" + description: "Stop mining when current pool hashrate has dropped well below the 24h average — likely a worker or pool connectivity issue" + conditions: + all_of: + - field: "mining_performance.pool_stats.average_hashrate_24h.value" + operator: "gt" + value: 10 # guard: only evaluate if a meaningful 24h baseline exists + - field: "mining_performance.current_hashrate.value" + operator: "lt" + value: 5 # current ≤ 5 TH/s (tune to your fleet; absolute floor) + priority: 80 + enabled: true + + - name: "Stale Pool Data" + description: "Stop mining if the pool is not reporting a current hashrate at all — safer to pause than mine blindly" + conditions: + all_of: + - field: "mining_performance.current_hashrate.value" + operator: "lte" + value: 0 + - field: "mining_performance.payout_schedule.frequency" + operator: "eq" + value: "unknown" + priority: 70 + enabled: true + + - name: "Payout Far + Low Energy" + description: "When the estimated payout is still distant from the pool threshold AND battery is low, don't burn stored energy on marginal rewards" + conditions: + all_of: + - field: "mining_performance.pool_stats.estimated_next_payout" + operator: "lt" + value: 10000 # less than 10k sats accumulated + - field: "mining_performance.payout_schedule.threshold" + operator: "gte" + value: 100000 # threshold is still ≥ 100k sats away + - field: "energy_state.battery.state_of_charge" + operator: "lt" + value: 35 + priority: 60 + enabled: true + + - name: "Unprofitable Weekly Trend" + description: "Pool's 7-day average hashrate has collapsed vs 24h — possible pool-side degradation, stop and investigate" + conditions: + all_of: + - field: "mining_performance.pool_stats.average_hashrate_24h.value" + operator: "gt" + value: 0 + - field: "mining_performance.pool_stats.average_hashrate_7d.value" + operator: "lt" + value: 20 # 7-day avg very low in TH/s — tune to your fleet + priority: 40 + enabled: true diff --git a/core/data/policies/550e8400-e29b-41d4-a716-446655440000.yaml b/core/data/policies/550e8400-e29b-41d4-a716-446655440000.yaml new file mode 100644 index 0000000..bec94a5 --- /dev/null +++ b/core/data/policies/550e8400-e29b-41d4-a716-446655440000.yaml @@ -0,0 +1,141 @@ +id: 550e8400-e29b-41d4-a716-446655440000 +name: Advanced Solar Mining Policy +description: Comprehensive policy for solar-based mining optimization with intelligent start and stop conditions +start_rules: + - id: 550e8400-e29b-41d4-a716-446655440001 + name: Priority Solar Mining + description: 'High priority: Start mining when solar production is very high' + conditions: + all_of: + - field: energy_state.production + operator: gt + value: 3000 + - field: energy_state.battery.state_of_charge + operator: gt + value: 40 + priority: 20 + enabled: true + - id: 550e8400-e29b-41d4-a716-446655440002 + name: Smart Weekend Mining + description: Weekend mining with intelligent conditions + conditions: + all_of: + - field: timestamp.weekday + operator: in + value: [5, 6] + - any_of: + - all_of: + - field: forecast.avg_next_4_hours_power + operator: gt + value: 2000 + - field: energy_state.battery.state_of_charge + operator: gt + value: 70 + - all_of: + - field: energy_state.grid.exporting_power + operator: lt + value: 800 + - field: energy_state.battery.state_of_charge + operator: gt + value: 50 + - not_: + field: timestamp.hour + operator: in + value: [2, 3, 4] + priority: 15 + enabled: true + - id: 550e8400-e29b-41d4-a716-446655440003 + name: Economic Opportunity Mining + description: Start mining when economic conditions are favorable + conditions: + all_of: + - field: energy_state.battery.state_of_charge + operator: gte + value: 85 + - field: energy_state.production + operator: gt + value: 1800 + - any_of: + - field: timestamp.weekday + operator: in + value: [5, 6] + - all_of: + - field: timestamp.weekday + operator: in + value: [0, 1, 2, 3, 4] + - field: timestamp.hour + operator: in + value: [11, 12, 13, 14] + priority: 5 + enabled: true +stop_rules: + - id: 550e8400-e29b-41d4-a716-446655440004 + name: Critical Battery Protection + description: 'CRITICAL: Stop mining immediately when battery is critically low' + conditions: + any_of: + - field: energy_state.battery.state_of_charge + operator: lt + value: 15 + - field: energy_state.battery.remaining_capacity + operator: lt + value: 800 + - all_of: + - field: energy_state.battery.current_power + operator: lt + value: -1000 + - field: energy_state.battery.state_of_charge + operator: lt + value: 25 + priority: 100 + enabled: true + - id: 550e8400-e29b-41d4-a716-446655440005 + name: Grid Import Limit Protection + description: Stop mining when importing too much from grid + conditions: + all_of: + - field: energy_state.grid.importing_power + operator: gt + value: 2500 + - any_of: + - field: forecast.next_hour_power + operator: lt + value: 1000 + - field: energy_state.battery.state_of_charge + operator: lt + value: 60 + - not_: + all_of: + - field: timestamp.hour + operator: in + value: [1, 2, 3, 4, 5] + - field: energy_state.battery.state_of_charge + operator: lt + value: 30 + priority: 80 + enabled: true + - id: 550e8400-e29b-41d4-a716-446655440006 + name: Peak Home Consumption Stop + description: Stop mining during high home energy demand periods + conditions: + all_of: + - field: home_load.total_forecast.next_2h.avg_power + operator: gt + value: 2800 + - field: timestamp.hour + operator: in + value: [17, 18, 19, 20, 21, 22] + - any_of: + - field: energy_state.battery.state_of_charge + operator: lt + value: 70 + - field: energy_state.production + operator: lt + value: 1500 + priority: 60 + enabled: true +metadata: + author: Edge Mining User + version: 6 + created: '2025-08-04' + last_modified: '2026-04-22' diff --git a/core/edge_mining/__init__.py b/core/edge_mining/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/__main__.py b/core/edge_mining/__main__.py new file mode 100644 index 0000000..ee41418 --- /dev/null +++ b/core/edge_mining/__main__.py @@ -0,0 +1,111 @@ +"""Start Edge Mining.""" + +import asyncio +import sys + +import uvicorn + +from edge_mining.adapters.infrastructure.api.main_api import app as fastapi_app +from edge_mining.adapters.infrastructure.api.setup import init_api_dependencies +from edge_mining.adapters.infrastructure.cli.main_cli import run_cli +from edge_mining.adapters.infrastructure.logging.terminal_logging import TerminalLogger +from edge_mining.adapters.infrastructure.sheduler.jobs import AutomationScheduler +from edge_mining.adapters.infrastructure.websocket.setup import init_websocket_dependencies +from edge_mining.bootstrap import configure_dependencies +from edge_mining.shared.infrastructure import ApplicationMode, Services +from edge_mining.shared.settings.settings import AppSettings + +settings = AppSettings() +logger = TerminalLogger(log_level=settings.log_level) + + +async def main_async(): + """Main entry point for the Edge Mining application.""" + logger.welcome() + + # --- Dependency Injection --- + try: + services: Services = configure_dependencies(logger, settings) + except Exception as e: + logger.critical(f"Failed to configure dependencies. Exiting. {e}") + sys.exit(1) + + # Inject services into CLI and API + init_api_dependencies(services, logger) + init_websocket_dependencies(services, logger) + logger.debug("API dependencies initialized successfully") + + # --- Synchronize Miners Status --- + try: + logger.info("Synchronizing miners status at startup...") + await services.miner_action_service.sync_all_miners() + except Exception as e: + logger.error(f"Failed to synchronize miners status: {e}") + # Continue execution even if synchronization fails + + # --- Determine Run Mode --- + # Example: Use command-line argument to choose mode + if len(sys.argv) > 1: + mode = sys.argv[1] + # Remove mode argument so Click/FastAPI don't see it + sys.argv.pop(1) + else: + mode = ApplicationMode.STANDARD # Default mode + + logger.debug(f"Running in '{mode}' mode.") + + if mode == ApplicationMode.STANDARD.value: + # --- Run the FastAPI server --- + logger.debug("Starting FastAPI server with Uvicorn...") + # Note: Uvicorn might reload and cause DI to run multiple times if + # --reload is used. + # We should to consider more robust DI setup for production APIs. + api_config = uvicorn.Config( + fastapi_app, + host="0.0.0.0", + port=settings.api_port, + log_level=settings.log_level.lower(), + ) + api_server = uvicorn.Server(api_config) + + # --- Run the main automation loop --- + scheduler = AutomationScheduler( + optimization_service=services.optimization_service, + logger=logger, + settings=settings, + home_load_history_service=services.home_load_history_service, + load_forecast_training_service=services.load_forecast_training_service, + ) + + await asyncio.gather( + api_server.serve(), # Run the FastAPI server + scheduler.start(), # Run the automation scheduler + ) + + elif mode == ApplicationMode.CLI.value: + # Run Click CLI with injected services + run_cli(services, logger) + + else: + logger.error( + f"Unknown run mode: '{mode}'. Use '{ApplicationMode.STANDARD.value}', or '{ApplicationMode.CLI.value}'." + ) + sys.exit(1) + + +def main(): + """Main entry point for the Edge Mining application (synchronous wrapper).""" + try: + asyncio.run(main_async()) + except KeyboardInterrupt: + logger.info("Application interrupted by user.") + except Exception as e: + logger.error(f"Unhandled exception during main execution: {e}") + finally: + # Sure to flush logs before exiting + logger.shutdown() + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/core/edge_mining/__version__.py b/core/edge_mining/__version__.py new file mode 100644 index 0000000..3c7e285 --- /dev/null +++ b/core/edge_mining/__version__.py @@ -0,0 +1,4 @@ +"""Edge Mining version information.""" + +__version__ = "0.1.0-rev3" +__version_info__ = tuple(str(x) for x in __version__.split(".")) diff --git a/core/edge_mining/adapters/__init__.py b/core/edge_mining/adapters/__init__.py new file mode 100644 index 0000000..9668141 --- /dev/null +++ b/core/edge_mining/adapters/__init__.py @@ -0,0 +1 @@ +"""Collection of Adapters (Implementations of Ports) for the Edge Mining Application""" diff --git a/core/edge_mining/adapters/application/__init__.py b/core/edge_mining/adapters/application/__init__.py new file mode 100644 index 0000000..e363335 --- /dev/null +++ b/core/edge_mining/adapters/application/__init__.py @@ -0,0 +1,4 @@ +""" +Collection of Adapters (Implementations of Ports) +for the application layer of Edge Mining Application +""" diff --git a/core/edge_mining/adapters/application/services/__init__.py b/core/edge_mining/adapters/application/services/__init__.py new file mode 100644 index 0000000..816d6fe --- /dev/null +++ b/core/edge_mining/adapters/application/services/__init__.py @@ -0,0 +1,4 @@ +""" +Collection of Adapters (Implementations of Ports) +for the application services of Edge Mining Application +""" diff --git a/core/edge_mining/adapters/application/services/configuration/__init__.py b/core/edge_mining/adapters/application/services/configuration/__init__.py new file mode 100644 index 0000000..b663c89 --- /dev/null +++ b/core/edge_mining/adapters/application/services/configuration/__init__.py @@ -0,0 +1 @@ +"""Collection of Adapters (Implementations of Ports) that driving the configuration service of Edge Mining Application""" diff --git a/core/edge_mining/adapters/application/services/configuration/websocket/__init__.py b/core/edge_mining/adapters/application/services/configuration/websocket/__init__.py new file mode 100644 index 0000000..046dc20 --- /dev/null +++ b/core/edge_mining/adapters/application/services/configuration/websocket/__init__.py @@ -0,0 +1 @@ +"""WebSocket adapter for the configuration service.""" diff --git a/core/edge_mining/adapters/application/services/configuration/websocket/handlers.py b/core/edge_mining/adapters/application/services/configuration/websocket/handlers.py new file mode 100644 index 0000000..91d8a6c --- /dev/null +++ b/core/edge_mining/adapters/application/services/configuration/websocket/handlers.py @@ -0,0 +1,34 @@ +"""WebSocket event handler for Configuration application events.""" + +from typing import Any, List + +from edge_mining.adapters.infrastructure.websocket.utils import ( + WebSocketEventHandler, + WebSocketEventRegistration, +) +from edge_mining.adapters.application.services.configuration.websocket.schemas import ConfigurationUpdatedSchema +from edge_mining.application.events.configuration_events import ConfigurationUpdatedEvent +from edge_mining.domain.common import DomainEvent + + +class ConfigurationWebSocketHandler(WebSocketEventHandler): + """Serializes Configuration events for WebSocket broadcasting.""" + + @property + def registrations(self) -> List[WebSocketEventRegistration]: + return [ + WebSocketEventRegistration( + event_type=ConfigurationUpdatedEvent, + topic="config.updated", + serialize=self._serialize_configuration_updated, + ), + ] + + def _serialize_configuration_updated(self, event: DomainEvent) -> dict[str, Any]: + assert isinstance(event, ConfigurationUpdatedEvent) + payload = ConfigurationUpdatedSchema( + entity_type=event.entity_type.value if event.entity_type else "", + entity_id=str(event.entity_id) if event.entity_id else None, + action=event.action.value if event.action else "", + ) + return payload.model_dump(mode="json") diff --git a/core/edge_mining/adapters/application/services/configuration/websocket/schemas.py b/core/edge_mining/adapters/application/services/configuration/websocket/schemas.py new file mode 100644 index 0000000..b269877 --- /dev/null +++ b/core/edge_mining/adapters/application/services/configuration/websocket/schemas.py @@ -0,0 +1,13 @@ +"""WebSocket schemas for Configuration events.""" + +from typing import Optional + +from pydantic import BaseModel, Field + + +class ConfigurationUpdatedSchema(BaseModel): + """WebSocket schema for ConfigurationUpdatedEvent.""" + + entity_type: str = Field(default="", description="Type of the configuration entity") + entity_id: Optional[str] = Field(None, description="ID of the configuration entity") + action: str = Field(default="", description="Action performed (created/updated/removed)") diff --git a/core/edge_mining/adapters/domain/__init__.py b/core/edge_mining/adapters/domain/__init__.py new file mode 100644 index 0000000..386d427 --- /dev/null +++ b/core/edge_mining/adapters/domain/__init__.py @@ -0,0 +1,4 @@ +""" +Collection of Adapters (Implementations of Ports) +for the domains of Edge Mining Application +""" diff --git a/core/edge_mining/adapters/domain/energy/__init__.py b/core/edge_mining/adapters/domain/energy/__init__.py new file mode 100644 index 0000000..7d36f20 --- /dev/null +++ b/core/edge_mining/adapters/domain/energy/__init__.py @@ -0,0 +1 @@ +"""Collection of Adapters (Implementations of Ports) that driving the energy provisioning of Edge Mining Application""" diff --git a/core/edge_mining/adapters/domain/energy/cli/__init__.py b/core/edge_mining/adapters/domain/energy/cli/__init__.py new file mode 100644 index 0000000..726f9b5 --- /dev/null +++ b/core/edge_mining/adapters/domain/energy/cli/__init__.py @@ -0,0 +1 @@ +"""Adapters CLI for the energy provisioning domain.""" diff --git a/core/edge_mining/adapters/domain/energy/cli/commands.py b/core/edge_mining/adapters/domain/energy/cli/commands.py new file mode 100644 index 0000000..6a044de --- /dev/null +++ b/core/edge_mining/adapters/domain/energy/cli/commands.py @@ -0,0 +1,1593 @@ +"""CLI commands for the energy domain.""" + +from typing import List, Optional + +import click + +from edge_mining.adapters.domain.forecast.cli.commands import ( + handle_add_forecast_provider, + print_forecast_provider_details, + select_forecast_providers, +) +from edge_mining.adapters.infrastructure.cli.utils import print_configuration, process_filters +from edge_mining.adapters.infrastructure.external_services.cli.commands import ( + handle_add_external_service, + print_external_service_details, + select_external_service, +) +from edge_mining.application.interfaces import ConfigurationServiceInterface +from edge_mining.domain.common import EntityId, WattHours, Watts +from edge_mining.domain.energy.common import EnergyMonitorAdapter, EnergySourceType +from edge_mining.domain.energy.entities import EnergyMonitor, EnergySource +from edge_mining.domain.energy.exceptions import EnergyMonitorNotFoundError +from edge_mining.domain.energy.value_objects import Battery, Grid +from edge_mining.domain.forecast.exceptions import ForecastProviderNotFoundError +from edge_mining.shared.adapter_configs.energy import EnergyMonitorDummySolarConfig, EnergyMonitorHomeAssistantConfig +from edge_mining.shared.adapter_maps.energy import ( + ENERGY_MONITOR_TYPE_EXTERNAL_SERVICE_MAP, + ENERGY_SOURCE_TYPE_ENERGY_MONITOR_MAP, + ENERGY_SOURCE_TYPE_FORECAST_PROVIDER_TYPE_MAP, +) +from edge_mining.shared.external_services.entities import ExternalService +from edge_mining.shared.interfaces.config import EnergyMonitorConfig +from edge_mining.shared.logging.port import LoggerPort + +from edge_mining.adapters.utils import run_async_func + + +def select_energy_source_type() -> Optional[EnergySourceType]: + """Select an energy source type from the list.""" + energy_source_type_colors = { + EnergySourceType.SOLAR: "yellow", + EnergySourceType.WIND: "blue", + EnergySourceType.GRID: "green", + EnergySourceType.HYDROELECTRIC: "cyan", + EnergySourceType.OTHER: "magenta", + } + click.echo("Select an Energy Source Type:") + for idx, energy_source_type in enumerate(EnergySourceType): + click.echo( + f"{idx}. " + + click.style( + f"{energy_source_type.name}", + fg=energy_source_type_colors.get(energy_source_type, "white"), + ) + ) + + click.echo("") + choice: str = click.prompt("Choose an energy source", type=str) + choice = choice.strip().lower() + + if not choice.isdigit() or int(choice) < 0 or int(choice) >= len(EnergySourceType): + click.echo(click.style("Invalid index. Aborting selection.", fg="red")) + return None + + energy_source_type_values = [energy_source_type.value for energy_source_type in EnergySourceType] + + selected_type = EnergySourceType(energy_source_type_values[int(choice)]) + return selected_type + + +def select_energy_monitors( + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, + default_id: Optional[EntityId] = None, + filter_type: Optional[List[EnergyMonitorAdapter]] = None, + filter_config: Optional[List[EnergyMonitorConfig]] = None, +) -> Optional[EnergyMonitor]: + """Select an energy monitor from the list.""" + click.echo(click.style("\n--- Select Energy Monitor ---", fg="yellow")) + + energy_monitors: List[EnergyMonitor] = configuration_service.list_energy_monitors() + if not energy_monitors: + click.echo(click.style("No energy monitors configured.", fg="yellow")) + return None + + filter_type = process_filters(filter_type) + + if filter_type: + click.echo( + "Filtering energy monitor by types: " + + click.style(f"{', '.join([c.name for c in filter_type])}", fg="blue") + ) + energy_monitors = [em for em in energy_monitors if em.adapter_type in filter_type] + + filter_config = process_filters(filter_config) + + if filter_config: + click.echo( + "Filtering energy monitors by config: " + + click.style(f"{', '.join([type(c).__name__ for c in filter_config])}", fg="blue") + ) + filtered_energy_monitors: List[EnergyMonitor] = [] + for fp in energy_monitors: + for filtered_config_class in filter_config: + if isinstance(fp.config, type(filtered_config_class)): + filtered_energy_monitors.append(fp) + energy_monitors = filtered_energy_monitors + + default_idx = "" + for idx, em in enumerate(energy_monitors): + click.echo( + f"{idx}. " + + "Name: " + + click.style(f"{em.name}, ", fg="blue") + + "ID: " + + click.style(f"{em.id}, ", fg="yellow") + + "Type: " + + click.style(f"{em.adapter_type.name}", fg="green") + ) + + if default_id: + if em.id == default_id: + default_idx = str(idx) + + click.echo("\nb. Back to menu\n") + + em_idx: str = click.prompt("Choose a Energy Monitor index", type=str, default=default_idx) + em_idx = em_idx.strip().lower() + if em_idx == "b": + return None + + if not em_idx.isdigit() or int(em_idx) < 0 or int(em_idx) >= len(energy_monitors): + click.echo(click.style("Invalid index. Aborting selection.", fg="red")) + return None + + selected_em = energy_monitors[int(em_idx)] + return selected_em + + +def handle_add_energy_source(configuration_service: ConfigurationServiceInterface, logger: LoggerPort) -> None: + """Menu to add a new energy source.""" + click.echo(click.style("\n--- Add Energy Source ---", fg="yellow")) + name: str = click.prompt("Name of the energy source", type=str) + source_type: Optional[EnergySourceType] = select_energy_source_type() + + if source_type is None: + click.echo(click.style("Invalid energy source type selected. Aborting.", fg="red")) + return + + nominal_power_max: int = click.prompt("Max nominal power (Watt, eg. 5000)", type=int, default=5000) + storage_nominal_capacity: int = click.prompt( + "Battery nominal capacity (Watt. Insert 0 for No Battery)", + type=int, + default="0", + ) + grid_contracted_power: int = click.prompt("Max power contracted on grid (Watt, eg. 3000)", type=int, default=3200) + external_source_power: int = click.prompt( + "Max power from the external source (Watt. Insert 0 for No external source)", + type=int, + default=0, + ) + + new_energy_source: EnergySource = EnergySource() + new_energy_source.name = name + new_energy_source.type = source_type + new_energy_source.nominal_power_max = Watts(nominal_power_max) + new_energy_source.storage = ( + Battery(nominal_capacity=WattHours(storage_nominal_capacity)) if storage_nominal_capacity > 0 else None + ) + new_energy_source.grid = Grid(contracted_power=Watts(grid_contracted_power)) if grid_contracted_power > 0 else None + new_energy_source.external_source = Watts(external_source_power) if external_source_power > 0 else None + new_energy_source.energy_monitor_id = None + new_energy_source.forecast_provider_id = None + + # Select an Energy Monitor + energy_monitors = configuration_service.list_energy_monitors() + if energy_monitors: + energy_monitor = select_energy_monitors( + configuration_service=configuration_service, + logger=logger, + filter_type=ENERGY_SOURCE_TYPE_ENERGY_MONITOR_MAP.get(source_type, None), + ) + if energy_monitor: + new_energy_source.energy_monitor_id = energy_monitor.id + else: + click.echo("") + click.echo( + click.style( + "No energy monitors configured. Configure an energy monitor first and then add an energy source.", + fg="yellow", + ) + ) + + add_energy_monitor: bool = click.confirm( + "Do you want to add an energy monitor now?", + default=True, + abort=False, + ) + + if add_energy_monitor: + energy_monitor = handle_add_energy_monitor( + energy_source=new_energy_source, + configuration_service=configuration_service, + logger=logger, + ) + if energy_monitor: + click.echo( + click.style( + f"Energy Monitor '{energy_monitor.name}', " + f"Type: {energy_monitor.adapter_type.name} " + f"(ID: {energy_monitor.id}) successfully added to current energy source.", + fg="green", + ) + ) + new_energy_source.energy_monitor_id = energy_monitor.id + else: + click.echo( + click.style( + "No energy monitor configured for this energy source.", + fg="yellow", + ) + ) + click.echo(click.style("Aborting energy source update.", fg="red")) + return None + + # Select an Energy Monitor + forecast_providers = configuration_service.list_forecast_providers() + if forecast_providers: + forecast_provider = select_forecast_providers( + configuration_service=configuration_service, + logger=logger, + filter_type=ENERGY_SOURCE_TYPE_FORECAST_PROVIDER_TYPE_MAP.get(source_type, None), + ) + if forecast_provider: + new_energy_source.forecast_provider_id = forecast_provider.id + else: + click.echo("") + click.echo(click.style("No forecast providers configured.", fg="yellow")) + + add_forecast_provider: bool = click.confirm( + "Do you want to add a forecast provider now?", + default=True, + abort=False, + ) + + if add_forecast_provider: + forecast_provider = handle_add_forecast_provider(configuration_service=configuration_service, logger=logger) + if forecast_provider: + click.echo( + click.style( + f"Forecast Provider '{forecast_provider.name}' " + f"Type: {forecast_provider.adapter_type.name} " + f"(ID: {forecast_provider.id}) successfully added " + "to current energy source.", + fg="green", + ) + ) + new_energy_source.forecast_provider_id = forecast_provider.id + else: + click.echo( + click.style( + "No forecast provider configured for this energy source.", + fg="yellow", + ) + ) + + try: + added: EnergySource = run_async_func( + configuration_service.create_energy_source( + name=new_energy_source.name, + source_type=new_energy_source.type, + nominal_power_max=new_energy_source.nominal_power_max, + storage=new_energy_source.storage, + grid=new_energy_source.grid, + external_source=new_energy_source.external_source, + energy_monitor_id=new_energy_source.energy_monitor_id, + forecast_provider_id=new_energy_source.forecast_provider_id, + ) + ) + click.echo( + click.style( + f"Energy Source '{added.name}' (ID: {added.id}) successfully added.", + fg="green", + ) + ) + except Exception as e: + logger.error(f"Error adding energy source: {e}") + click.echo(click.style(f"Error adding energy source: {e}", fg="red"), err=True) + click.pause("Press any key to return to the menu...") + + +def handle_list_energy_sources(configuration_service: ConfigurationServiceInterface, logger: LoggerPort) -> None: + """List all energy sources.""" + click.echo(click.style("\n--- List Energy Sources ---", fg="yellow")) + + energy_sources: List[EnergySource] = configuration_service.list_energy_sources() + if not energy_sources: + click.echo(click.style("No energy sources configured.", fg="yellow")) + else: + for es in energy_sources: + click.echo( + "-> " + + "Name: " + + click.style(f"{es.name}, ", fg="blue") + + "ID: " + + click.style(f"{es.id}, ", fg="yellow") + + "Type: " + + click.style(f"{es.type.name}, ", fg="green") + + "Max power: " + + click.style(f"{es.nominal_power_max}W", fg="blue") + ) + click.echo("") + click.pause("Press any key to return to the menu...") + + +def select_energy_source( + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, + default_id: Optional[EntityId] = None, + filter_type: Optional[List[EnergySourceType]] = None, +) -> Optional[EnergySource]: + """Select an energy source from the list.""" + click.echo(click.style("\n--- Select Energy Source ---", fg="yellow")) + + energy_sources: List[EnergySource] = configuration_service.list_energy_sources() + if not energy_sources: + click.echo(click.style("No energy sources configured.", fg="yellow")) + return None + + filter_type = process_filters(filter_type) + + if filter_type: + click.echo( + "Filtering energy source by types: " + click.style(f"{', '.join([t.name for t in filter_type])}", fg="blue") + ) + energy_sources = [s for s in energy_sources if s.type in filter_type] + + default_idx = "" + for idx, es in enumerate(energy_sources): + click.echo( + f"{idx}. " + + "Name: " + + click.style(f"{es.name}, ", fg="blue") + + "ID: " + + click.style(f"{es.id}, ", fg="yellow") + + "Type: " + + click.style(f"{es.type.name}", fg="green") + ) + + if default_id: + if es.id == default_id: + default_idx = str(idx) + + click.echo("\nb. Back to menu\n") + + es_idx: str = click.prompt("Choose an Energy Source index", type=str, default=default_idx) + es_idx = es_idx.strip().lower() + if es_idx == "b": + return None + + if not es_idx.isdigit() or int(es_idx) < 0 or int(es_idx) >= len(energy_sources): + click.echo(click.style("Invalid index. Aborting selection.", fg="red")) + return None + + selected_es = energy_sources[int(es_idx)] + return selected_es + + +def print_energy_monitor_config(energy_monitor: EnergyMonitor) -> None: + """Print the configuration of an energy monitor.""" + configuration_class = energy_monitor.config.__class__.__name__ if energy_monitor.config else "---" + click.echo("| Configuration: " + click.style(f"{configuration_class}", fg="cyan")) + if energy_monitor.config: + print_configuration(energy_monitor.config.to_dict()) + + +def print_energy_monitor_details( + energy_monitor: EnergyMonitor, + configuration_service: ConfigurationServiceInterface, + show_external_service: bool = False, + show_energy_source_list: bool = False, +) -> None: + """Print the details of an energy monitor.""" + click.echo("") + click.echo("| Name: " + click.style(energy_monitor.name, fg="blue")) + click.echo("| ID: " + click.style(energy_monitor.id, fg="yellow")) + click.echo("| Adapter: " + click.style(energy_monitor.adapter_type.name, fg="green")) + print_energy_monitor_config(energy_monitor) + click.echo("") + + if show_external_service: + if energy_monitor.external_service_id: + external_service = configuration_service.get_external_service(energy_monitor.external_service_id) + if external_service: + click.echo("EXTERNAL SERVICE DETAILS:") + print_external_service_details( + service=external_service, + configuration_service=configuration_service, + show_config=False, + show_linked_instances=False, + ) + else: + click.echo( + "| External service: " + + click.style(str(energy_monitor.external_service_id), fg="red") + + " (not found)" + ) + else: + click.echo("| External service: None") + click.echo("") + + if show_energy_source_list: + energy_sources: List[EnergySource] = configuration_service.list_energy_sources_by_monitor(energy_monitor.id) + if not energy_sources: + click.echo(click.style("No energy sources assigned to this monitor.", fg="yellow")) + else: + click.echo("Energy sources assigned to this monitor:") + for es in energy_sources: + click.echo( + "-> " + + "Name: " + + click.style(f"{es.name}, ", fg="blue") + + "ID: " + + click.style(f"{es.id}, ", fg="yellow") + + "Type: " + + click.style(f"{es.type.name}, ", fg="green") + + "Max power: " + + click.style(f"{es.nominal_power_max}W", fg="blue") + ) + click.echo("") + + +def print_energy_source_details( + energy_source: EnergySource, + configuration_service: ConfigurationServiceInterface, + show_energy_monitor_details: bool = False, + show_forecast_provider_details: bool = False, +) -> None: + """Print the details of an energy source.""" + click.echo("") + click.echo("| Name: " + click.style(energy_source.name, fg="blue")) + click.echo("| ID: " + click.style(energy_source.id, fg="yellow")) + click.echo("| Type: " + click.style(energy_source.type.name, fg="green")) + click.echo("| Max power: " + str(energy_source.nominal_power_max) + " W") + click.echo( + "| Storage: " + ((str(energy_source.storage.nominal_capacity) + " Wh") if energy_source.storage else "None") + ) + click.echo("| Grid: " + ((str(energy_source.grid.contracted_power) + " W") if energy_source.grid else "None")) + click.echo( + "| External source: " + + ((str(energy_source.external_source) + " W") if energy_source.external_source else "None") + ) + + if show_energy_monitor_details: + if energy_source.energy_monitor_id: + try: + energy_monitor = configuration_service.get_energy_monitor(energy_source.energy_monitor_id) + except EnergyMonitorNotFoundError: + energy_monitor = None + + if energy_monitor: + click.echo("\nENERGY MONITOR DETAILS:") + print_energy_monitor_details( + energy_monitor, + configuration_service, + show_external_service=True, + show_energy_source_list=False, + ) + else: + click.echo( + "| Energy monitor: " + click.style(str(energy_source.energy_monitor_id), fg="red") + " (not found)" + ) + click.echo("") + + if show_forecast_provider_details: + if energy_source.forecast_provider_id: + try: + forecast_provider = configuration_service.get_forecast_provider(energy_source.forecast_provider_id) + except ForecastProviderNotFoundError: + forecast_provider = None + + if forecast_provider: + click.echo("\nFORECAST PROVIDER DETAILS:") + print_forecast_provider_details( + forecast_provider, + configuration_service, + show_energy_source_list=False, + ) + else: + click.echo( + "| Forecast provider: " + + click.style(str(energy_source.forecast_provider_id), fg="red") + + " (not found)" + ) + click.echo("") + + +def update_single_energy_source( + energy_source: EnergySource, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> Optional[EnergySource]: + """Update a single energy source.""" + click.echo(click.style("\n--- Update Energy Source ---", fg="yellow")) + name: str = click.prompt("New name of the energy source", type=str, default=energy_source.name) + nominal_power_max: int = click.prompt( + "Max nominal power (Watt, eg. 5000)", + type=int, + default=energy_source.nominal_power_max, + ) + storage_nominal_capacity: int = click.prompt( + "Battery nominal capacity (Watt. Insert 0 for No Battery)", + type=int, + default=(energy_source.storage.nominal_capacity if energy_source.storage else 0), + ) + grid_contracted_power: int = click.prompt( + "Max power contracted on grid (Watt, eg. 3000)", + type=int, + default=(energy_source.grid.contracted_power if energy_source.grid else 3200), + ) + external_source_power: int = click.prompt( + "Max power from the external source (Watt. Insert 0 for No external source)", + type=int, + default=(energy_source.external_source if energy_source.external_source else 0), + ) + + new_energy_source: EnergySource = EnergySource() + new_energy_source.id = energy_source.id + new_energy_source.name = name + new_energy_source.type = energy_source.type + new_energy_source.nominal_power_max = Watts(nominal_power_max) + new_energy_source.storage = ( + Battery(nominal_capacity=WattHours(storage_nominal_capacity)) if storage_nominal_capacity > 0 else None + ) + new_energy_source.grid = Grid(contracted_power=Watts(grid_contracted_power)) if grid_contracted_power > 0 else None + new_energy_source.external_source = Watts(external_source_power) if external_source_power > 0 else None + new_energy_source.energy_monitor_id = energy_source.energy_monitor_id + new_energy_source.forecast_provider_id = energy_source.forecast_provider_id + + # Select an Energy Monitor + energy_monitors = configuration_service.list_energy_monitors() + if energy_monitors: + energy_monitor = select_energy_monitors( + configuration_service=configuration_service, + logger=logger, + ) + if energy_monitor: + new_energy_source.energy_monitor_id = energy_monitor.id + else: + click.echo("") + click.echo( + click.style( + "No energy monitors configured. Configure an energy monitor first and then add an energy source.", + fg="yellow", + ) + ) + + add_energy_monitor: bool = click.confirm( + "Do you want to add an energy monitor now?", + default=True, + abort=False, + ) + + if add_energy_monitor: + energy_monitor = handle_add_energy_monitor( + energy_source=new_energy_source, + configuration_service=configuration_service, + logger=logger, + ) + if energy_monitor: + click.echo( + click.style( + f"Energy Monitor '{energy_monitor.name}', " + f"Type: {energy_monitor.adapter_type.name} " + f"(ID: {energy_monitor.id}) successfully added to " + "current energy source.", + fg="green", + ) + ) + new_energy_source.energy_monitor_id = energy_monitor.id + else: + click.echo(click.style("Aborting energy source update.", fg="red")) + return None + + # Select a Forecast Provider + forecast_providers = configuration_service.list_forecast_providers() + if forecast_providers: + forecast_provider = select_forecast_providers( + configuration_service=configuration_service, + logger=logger, + default_id=new_energy_source.forecast_provider_id, + filter_type=ENERGY_SOURCE_TYPE_FORECAST_PROVIDER_TYPE_MAP.get(energy_source.type, None), + ) + if forecast_provider: + new_energy_source.forecast_provider_id = forecast_provider.id + + try: + updated: EnergySource = run_async_func( + configuration_service.update_energy_source( + source_id=new_energy_source.id, + name=new_energy_source.name, + source_type=new_energy_source.type, + nominal_power_max=new_energy_source.nominal_power_max, + storage=new_energy_source.storage, + grid=new_energy_source.grid, + external_source=new_energy_source.external_source, + energy_monitor_id=new_energy_source.energy_monitor_id, + forecast_provider_id=new_energy_source.forecast_provider_id, + ) + ) + click.echo( + click.style( + f"Energy Source '{updated.name}' (ID: {updated.id}) successfully updated.", + fg="green", + ) + ) + return updated + except Exception as e: + logger.error(f"Error updating energy source: {e}") + click.echo( + click.style(f"Error updating energy source: {e}", fg="red"), + err=True, + ) + return None + + +def assign_energy_monitor_to_energy_source( + energy_source: EnergySource, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> Optional[EnergySource]: + """Assign an energy monitor to an energy source.""" + click.echo(click.style("\n--- Assign Energy Monitor to Energy Source ---", fg="yellow")) + + energy_monitor = select_energy_monitors(configuration_service, logger) + + if energy_monitor is None: + click.echo(click.style("No energy monitor selected. Aborting assignment.", fg="red")) + click.pause("Press any key to return to the menu...") + return None + + try: + updated_energy_source = run_async_func( + configuration_service.set_energy_monitor_to_energy_source( + energy_source_id=energy_source.id, + energy_monitor_id=energy_monitor.id, + ) + ) + click.echo( + click.style( + f"Energy Monitor '{energy_monitor.name}' assigned to Energy Source " + f"'{updated_energy_source.name}' successfully.", + fg="green", + ) + ) + return updated_energy_source + except Exception as e: + logger.error(f"Error assigning energy monitor: {e}") + click.echo( + click.style(f"Error assigning energy monitor: {e}", fg="red"), + err=True, + ) + return None + + +def delete_single_energy_source( + energy_source: EnergySource, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> bool: + """Delete a single energy source.""" + delete_confirm: bool = click.confirm( + f"Are you sure you want to delete the energy source '{energy_source.name}' (ID: {energy_source.id})?", + abort=False, + default=False, + prompt_suffix="", + ) + if not delete_confirm: + click.echo(click.style("Deletion cancelled.", fg="yellow")) + return False + + try: + removed_energy_source = run_async_func(configuration_service.remove_energy_source(energy_source.id)) + logger.debug(f"Energy Source {removed_energy_source.name} deleted successfully.") + click.echo( + click.style( + f"Energy Source '{removed_energy_source.name}' deleted successfully.", + fg="green", + ) + ) + return True + except Exception as e: + logger.error(f"Error deleting energy source: {e}") + click.echo( + click.style(f"Error deleting energy source: {e}", fg="red"), + err=True, + ) + return False + + +def assign_forecast_provider_to_energy_source( + energy_source: EnergySource, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> Optional[EnergySource]: + """Assign a forecast provider to an energy source.""" + click.echo(click.style("\n--- Assign Forecast Provider to Energy Source ---", fg="yellow")) + forecast_provider = select_forecast_providers(configuration_service, logger) + if forecast_provider is None: + click.echo(click.style("No forecast provider selected. Aborting assignment.", fg="red")) + return None + try: + updated_energy_source = run_async_func( + configuration_service.set_forecast_provider_to_energy_source( + energy_source_id=energy_source.id, + forecast_provider_id=forecast_provider.id, + ) + ) + click.echo( + click.style( + f"Forecast Provider '{forecast_provider.name}' assigned to " + f"Energy Source '{updated_energy_source.name}' successfully.", + fg="green", + ) + ) + return updated_energy_source + except Exception as e: + logger.error(f"Error assigning forecast provider: {e}") + click.echo( + click.style(f"Error assigning forecast provider: {e}", fg="red"), + err=True, + ) + return None + + +def manage_single_energy_source_menu( + energy_source: EnergySource, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> str: + """Menu for managing a single energy source.""" + while True: + click.echo("\n" + click.style("--- MANAGE ENERGY SOURCE ---", fg="blue", bold=True)) + + print_energy_source_details( + energy_source=energy_source, + configuration_service=configuration_service, + show_energy_monitor_details=True, + show_forecast_provider_details=True, + ) + + click.echo("1. Update Energy Source") + click.echo("2. Delete Energy Source") + click.echo("") + click.echo("3. Set Energy Monitor") + click.echo("4. Set Forecast Provider") + click.echo("") + click.echo("b. Back to energy menu") + click.echo("q. Close application") + click.echo("-----------------") + + choice: str = click.prompt("Choose an option", type=str) + choice = choice.strip().lower() + + click.clear() + + if choice == "1": + updated_energy_source = update_single_energy_source( + energy_source=energy_source, + configuration_service=configuration_service, + logger=logger, + ) + energy_source = updated_energy_source or energy_source + continue + + elif choice == "2": + delete_status = delete_single_energy_source( + energy_source=energy_source, + configuration_service=configuration_service, + logger=logger, + ) + if delete_status: + return "b" # Return to the energy menu after deletion + + elif choice == "3": + updated_energy_source = assign_energy_monitor_to_energy_source( + energy_source=energy_source, + configuration_service=configuration_service, + logger=logger, + ) + energy_source = updated_energy_source or energy_source + continue + + elif choice == "4": + updated_energy_source = assign_forecast_provider_to_energy_source( + energy_source=energy_source, + configuration_service=configuration_service, + logger=logger, + ) + energy_source = updated_energy_source or energy_source + continue + + elif choice == "b": + break + elif choice == "q": + break + else: + click.echo(click.style("Invalid choice. Try again.", fg="red")) + click.pause("Press any key to return to the menu...") + + return choice + + +def select_energy_monitor_adapter() -> Optional[EnergyMonitorAdapter]: + """Select an energy monitor adapter from the list.""" + click.echo("Select an Energy Monitor Adapter:") + for idx, adapter in enumerate(EnergyMonitorAdapter): + click.echo(f"{idx}. {adapter.name}") + + click.echo("") + choice: str = click.prompt("Choose an energy monitor adapter", type=str) + choice = choice.strip().lower() + + if not choice.isdigit() or int(choice) < 0 or int(choice) >= len(EnergyMonitorAdapter): + click.echo(click.style("Invalid index. Aborting selection.", fg="red")) + return None + + adapter_type_values = [adapter.value for adapter in EnergyMonitorAdapter] + + selected_adapter = EnergyMonitorAdapter(adapter_type_values[int(choice)]) + return selected_adapter + + +def handle_energy_monitor_dummy_solar_configuration( + energy_monitor: Optional[EnergyMonitor] = None, + energy_source: Optional[EnergySource] = None, +) -> Optional[EnergyMonitorConfig]: + """Handle the configuration for the Dummy Solar energy monitor adapter.""" + click.echo(click.style("\n--- Dummy Solar Energy Monitor Configuration ---", fg="yellow")) + + default_max_consumption_power = Watts(3200) + if energy_monitor: + if isinstance(energy_monitor.config, EnergyMonitorDummySolarConfig): + default_max_consumption_power = energy_monitor.config.max_consumption_power + + max_consumption_power: int = click.prompt( + "Max consumption power (Watt, eg. 3200)", + type=int, + default=default_max_consumption_power, + ) + + return EnergyMonitorDummySolarConfig(max_consumption_power=Watts(max_consumption_power)) + + +def handle_energy_monitor_home_assistant_configuration( + energy_monitor: Optional[EnergyMonitor] = None, + energy_source: Optional[EnergySource] = None, +) -> Optional[EnergyMonitorConfig]: + """Handle the configuration for the Home Assistant energy monitor adapter.""" + click.echo( + click.style( + "\n--- Home Assistant Energy Monitor Configuration ---", + fg="yellow", + ) + ) + + default_entity_production = "" + default_entity_consumption = "" + default_entity_grid = "" + default_entity_battery_soc = "" + default_entity_battery_power = "" + default_entity_battery_remaining_capacity = "" + default_unit_production = "W" + default_unit_consumption = "W" + default_unit_grid = "W" + default_unit_battery_power = "W" + default_unit_battery_remaining_capacity = "Wh" + default_grid_positive_export = False + default_battery_positive_charge = True + if energy_monitor: + if isinstance(energy_monitor.config, EnergyMonitorHomeAssistantConfig): + default_entity_production = energy_monitor.config.entity_production + default_entity_consumption = energy_monitor.config.entity_consumption + default_entity_grid = energy_monitor.config.entity_grid + default_entity_battery_soc = energy_monitor.config.entity_battery_soc + default_entity_battery_power = energy_monitor.config.entity_battery_power + default_entity_battery_remaining_capacity = energy_monitor.config.entity_battery_remaining_capacity + default_unit_production = energy_monitor.config.unit_production + default_unit_consumption = energy_monitor.config.unit_consumption + default_unit_grid = energy_monitor.config.unit_grid + default_unit_battery_power = energy_monitor.config.unit_battery_power + default_unit_battery_remaining_capacity = energy_monitor.config.unit_battery_remaining_capacity + default_grid_positive_export = energy_monitor.config.grid_positive_export + default_battery_positive_charge = energy_monitor.config.battery_positive_charge + + entity_production: str = click.prompt( + "Entity ID for production (e.g. sensor.solar_production)", + type=str, + default=default_entity_production, + ) + entity_consumption: str = click.prompt( + "Entity ID for consumption (e.g. sensor.home_consumption)", + type=str, + default=default_entity_consumption, + ) + + entity_grid: str = "" + if energy_source and energy_source.grid: + entity_grid = click.prompt( + "Entity ID for grid (optional, e.g. sensor.grid_power)", + type=str, + default=default_entity_grid, + ) + + entity_battery_soc: str = "" + entity_battery_power: str = "" + entity_battery_remaining_capacity: str = "" + if energy_source and energy_source.storage: + entity_battery_soc = click.prompt( + "Entity ID for battery state of charge (optional, e.g. sensor.battery_soc)", + type=str, + default=default_entity_battery_soc, + ) + entity_battery_power = click.prompt( + "Entity ID for battery power (optional, e.g. sensor.battery_power)", + type=str, + default=default_entity_battery_power, + ) + entity_battery_remaining_capacity = click.prompt( + "Entity ID for battery remaining capacity (optional, e.g. sensor.battery_remaining_capacity)", + type=str, + default=default_entity_battery_remaining_capacity, + ) + + unit_production: str = click.prompt( + "Unit for production (default: W)", + type=str, + default=default_unit_production, + ) + unit_consumption: str = click.prompt( + "Unit for consumption (default: W)", + type=str, + default=default_unit_consumption, + ) + + unit_grid: str = default_unit_grid + if energy_source and energy_source.grid: + unit_grid = click.prompt("Unit for grid (default: W)", type=str, default=default_unit_grid) + + unit_battery_power: str = default_unit_battery_power + unit_battery_remaining_capacity: str = "Wh" + if energy_source and energy_source.storage: + unit_battery_power = click.prompt( + "Unit for battery power (default: W)", + type=str, + default=default_unit_battery_power, + ) + unit_battery_remaining_capacity = click.prompt( + "Unit for battery remaining capacity (default: Wh)", + type=str, + default=default_unit_battery_remaining_capacity, + ) + + # Set to True if your grid sensor reports positive for EXPORTING energy + grid_positive_export: bool = click.confirm( + "Direction of grid export (Set to true if positive grid power means EXPORTING)", + default=default_grid_positive_export, + ) + # Set to True if your battery sensor reports positive for CHARGING + battery_positive_charge: bool = click.confirm( + "Direction of battery charge (Set to true if positive battery power means CHARGING)", + default=default_battery_positive_charge, + ) + + return EnergyMonitorHomeAssistantConfig( + entity_production=entity_production, + entity_consumption=entity_consumption, + entity_grid=entity_grid, + entity_battery_soc=entity_battery_soc, + entity_battery_power=entity_battery_power, + entity_battery_remaining_capacity=entity_battery_remaining_capacity, + unit_production=unit_production, + unit_consumption=unit_consumption, + unit_grid=unit_grid, + unit_battery_power=unit_battery_power, + unit_battery_remaining_capacity=unit_battery_remaining_capacity, + grid_positive_export=grid_positive_export, + battery_positive_charge=battery_positive_charge, + ) + + +def handle_energy_monitor_configuration( + adapter_type: EnergyMonitorAdapter, + energy_monitor: Optional[EnergyMonitor] = None, + energy_source: Optional[EnergySource] = None, +) -> Optional[EnergyMonitorConfig]: + """ + Handle the configuration of an energy monitor based on the selected adapter type. + """ + if adapter_type == EnergyMonitorAdapter.DUMMY_SOLAR: + return handle_energy_monitor_dummy_solar_configuration( + energy_monitor=energy_monitor, energy_source=energy_source + ) + elif adapter_type == EnergyMonitorAdapter.HOME_ASSISTANT_API: + return handle_energy_monitor_home_assistant_configuration( + energy_monitor=energy_monitor, energy_source=energy_source + ) + else: + click.echo( + click.style( + "Unsupported energy monitor adapter type selected. Aborting.", + fg="red", + ) + ) + return None + + +def handle_add_energy_monitor( + energy_source: Optional[EnergySource], + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> Optional[EnergyMonitor]: + """Menu to add a new energy monitor.""" + click.echo(click.style("\n--- Add Energy Monitor ---", fg="yellow")) + name: str = click.prompt("Name of the energy monitor", type=str) + adapter_type: Optional[EnergyMonitorAdapter] = select_energy_monitor_adapter() + + if adapter_type is None: + click.echo( + click.style( + "Invalid energy monitor adapter type selected. Aborting.", + fg="red", + ) + ) + return None + + new_energy_monitor = EnergyMonitor() + new_energy_monitor.name = name + new_energy_monitor.adapter_type = adapter_type + new_energy_monitor.config = None + new_energy_monitor.external_service_id = None + + config: Optional[EnergyMonitorConfig] = handle_energy_monitor_configuration( + adapter_type=new_energy_monitor.adapter_type, + energy_source=energy_source, + energy_monitor=None, # No existing monitor to update, so pass None + ) + + if config is None: + click.echo(click.style("Invalid configuration. Aborting.", fg="red")) + return None + + new_energy_monitor.config = config + + needed_external_service = ENERGY_MONITOR_TYPE_EXTERNAL_SERVICE_MAP.get(new_energy_monitor.adapter_type, None) + # If an external service is required for the selected adapter type + if needed_external_service: + external_service: Optional[ExternalService] + # If external service is needed, check if some one is already configured + external_services: List[ExternalService] = configuration_service.list_external_services() + if external_services: + external_service = select_external_service( + configuration_service=configuration_service, + logger=logger, + filter_type=[needed_external_service], + ) + if external_service: + new_energy_monitor.external_service_id = external_service.id if external_service else None + else: + click.echo("") + click.echo( + click.style( + "No external services configured. " + "Please configure an external service first " + "and then add an energy monitor.", + fg="yellow", + ) + ) + add_external_service: bool = click.confirm( + "Do you want to add an external service now?", + default=True, + abort=False, + ) + if add_external_service: + external_service = handle_add_external_service( + configuration_service=configuration_service, + logger=logger, + ) + if external_service: + click.echo( + click.style( + f"External Service '{external_service.name}', " + f"Type: {external_service.adapter_type.name} " + f"(ID: {external_service.id}) successfully added " + "to current energy monitor.", + fg="green", + ) + ) + new_energy_monitor.external_service_id = external_service.id + else: + click.echo(click.style("Aborting energy monitor addition.", fg="red")) + return None + + added: Optional[EnergyMonitor] = None + try: + added = run_async_func( + configuration_service.create_energy_monitor( + name=new_energy_monitor.name, + adapter_type=new_energy_monitor.adapter_type, + config=new_energy_monitor.config, + external_service_id=new_energy_monitor.external_service_id, + ) + ) + click.echo( + click.style( + f"Energy Monitor '{added.name}' (ID: {added.id}) successfully added.", + fg="green", + ) + ) + except Exception as e: + added = None + logger.error(f"Error adding energy monitor: {e}") + click.echo( + click.style(f"Error adding energy monitor: {e}", fg="red"), + err=True, + ) + click.pause("Press any key to return to the menu...") + return added + + +def handle_list_energy_monitors(configuration_service: ConfigurationServiceInterface, logger: LoggerPort) -> None: + """List all energy monitors.""" + click.echo(click.style("\n--- List Energy Monitors ---", fg="yellow")) + + energy_monitors = configuration_service.list_energy_monitors() + if not energy_monitors: + click.echo(click.style("No energy monitors configured.", fg="yellow")) + else: + for em in energy_monitors: + click.echo( + "-> " + + "Name: " + + click.style(f"{em.name}, ", fg="blue") + + "ID: " + + click.style(f"{em.id}, ", fg="yellow") + + "Type: " + + click.style(f"{em.adapter_type.name}", fg="green") + ) + click.echo("") + click.pause("Press any key to return to the menu...") + + +def select_energy_monitor( + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, + default_id: Optional[EntityId] = None, +) -> Optional[EnergyMonitor]: + """Select an energy monitor from the list.""" + click.echo(click.style("\n--- Select Energy Monitor ---", fg="yellow")) + + energy_monitors: List[EnergyMonitor] = configuration_service.list_energy_monitors() + if not energy_monitors: + click.echo(click.style("No energy monitors configured.", fg="yellow")) + return None + + default_idx: str = "" + for idx, em in enumerate(energy_monitors): + click.echo( + f"{idx}. " + + "Name: " + + click.style(f"{em.name}, ", fg="blue") + + "ID: " + + click.style(f"{em.id}, ", fg="yellow") + + "Type: " + + click.style(f"{em.adapter_type.name}", fg="green") + ) + + if default_id and em.id == default_id: + default_idx = str(idx) + + click.echo("\nb. Back to menu\n") + + em_idx: str = click.prompt("Choose a Energy Monitor index", type=str, default=default_idx) + em_idx = em_idx.strip().lower() + if em_idx == "b": + return None + + if not em_idx.isdigit() or int(em_idx) < 0 or int(em_idx) >= len(energy_monitors): + click.echo(click.style("Invalid index. Aborting selection.", fg="red")) + return None + + selected_em = energy_monitors[int(em_idx)] + return selected_em + + +def update_single_energy_monitor( + monitor: EnergyMonitor, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> Optional[EnergyMonitor]: + """Update a single energy monitor.""" + click.echo(click.style("\n--- Update Energy Monitor ---", fg="yellow")) + name: str = click.prompt("New name of the energy monitor", type=str, default=monitor.name) + + new_energy_monitor: EnergyMonitor = EnergyMonitor() + new_energy_monitor.id = monitor.id + new_energy_monitor.name = name + new_energy_monitor.adapter_type = monitor.adapter_type + new_energy_monitor.config = monitor.config + new_energy_monitor.external_service_id = monitor.external_service_id + + click.echo("\nDo you want to change the energy monitor configuration?") + change_config: bool = click.confirm("Change configuration", default=True, prompt_suffix="") + if change_config: + config: Optional[EnergyMonitorConfig] = handle_energy_monitor_configuration( + adapter_type=new_energy_monitor.adapter_type, + energy_monitor=new_energy_monitor, + ) + + if config is None: + click.echo(click.style("Invalid configuration. Aborting.", fg="red")) + return None + + new_energy_monitor.config = config + + if new_energy_monitor.config is None: + click.echo(click.style("Energy monitor configuration is required. Aborting.", fg="red")) + return None + + needed_external_service = ENERGY_MONITOR_TYPE_EXTERNAL_SERVICE_MAP.get(new_energy_monitor.adapter_type, None) + + if new_energy_monitor.external_service_id: + click.echo("\nCurrent external service: ") + current_external_service = configuration_service.get_external_service(new_energy_monitor.external_service_id) + if current_external_service: + print_external_service_details( + service=current_external_service, + configuration_service=configuration_service, + show_linked_instances=False, + ) + else: + click.echo( + click.style( + "Current external service is not valid. Please select a new one.", + fg="red", + ) + ) + + if needed_external_service: + external_service: Optional[ExternalService] = None + + # If external service is needed, check if some one is already configured + external_services: List[ExternalService] = configuration_service.list_external_services() + if external_services: + if new_energy_monitor.external_service_id: + # Ask to change the external service + click.echo( + click.style( + "\nDo you want to change the external service for this energy monitor?", + fg="yellow", + ) + ) + change_external_service: bool = click.confirm("Change external service", default=True, prompt_suffix="") + if change_external_service: + external_service = select_external_service( + configuration_service=configuration_service, + logger=logger, + filter_type=[needed_external_service], + ) + + if external_service is None: + click.echo( + click.style( + "No external service selected. Keeping the current one.", + fg="yellow", + ) + ) + else: + new_energy_monitor.external_service_id = external_service.id + else: + # Check if external service exists + current_external_service = configuration_service.get_external_service( + new_energy_monitor.external_service_id + ) + + # If current external service not exists, ask to select a new one + if not current_external_service: + click.echo( + click.style( + "Current external service is not valid. Please select a new one.", + fg="red", + ) + ) + external_service = select_external_service( + configuration_service=configuration_service, + logger=logger, + filter_type=[needed_external_service], + ) + if external_service is None: + click.echo( + click.style( + "No external service selected. Aborting update.", + fg="red", + ) + ) + return None + new_energy_monitor.external_service_id = external_service.id + + if current_external_service and current_external_service.config: + # Check if the current external service is still valid + external_service_valid = current_external_service.config.is_valid( + current_external_service.adapter_type + ) + if not external_service_valid: + click.echo( + click.style( + "Current external service configuration is not valid. Please select a new one.", + fg="red", + ) + ) + external_service = select_external_service( + configuration_service=configuration_service, + logger=logger, + filter_type=[needed_external_service], + ) + if external_service is None: + click.echo( + click.style( + "No external service selected. Aborting update.", + fg="red", + ) + ) + return None + new_energy_monitor.external_service_id = external_service.id + else: + # If no external service is configured, ask to select one + click.echo( + click.style( + "\nDo you want to select an external service for this energy monitor?", + fg="yellow", + ) + ) + add_external_service = click.confirm("Add external service", default=True, prompt_suffix="") + if add_external_service: + external_service = select_external_service( + configuration_service=configuration_service, + logger=logger, + filter_type=[needed_external_service], + ) + if external_service is None: + click.echo( + click.style( + "No external service selected. Aborting update.", + fg="red", + ) + ) + return None + new_energy_monitor.external_service_id = external_service.id + else: + # Missing external service, ask to add one + click.echo("") + click.echo( + click.style( + "No external services configured. " + "Please configure an external service first " + "and then update the energy monitor.", + fg="yellow", + ) + ) + add_external_service = click.confirm( + "Do you want to add an external service now?", + default=True, + abort=False, + ) + if add_external_service: + external_service = handle_add_external_service( + configuration_service=configuration_service, + logger=logger, + ) + if external_service: + click.echo( + click.style( + f"External Service '{external_service.name}', " + f"Type: {external_service.adapter_type.name} " + f"(ID: {external_service.id}) successfully added " + "to current energy monitor.", + fg="green", + ) + ) + new_energy_monitor.external_service_id = external_service.id + else: + click.echo(click.style("Aborting energy monitor addition.", fg="red")) + return None + + try: + updated_monitor: EnergyMonitor = run_async_func( + configuration_service.update_energy_monitor( + monitor_id=new_energy_monitor.id, + name=new_energy_monitor.name, + config=new_energy_monitor.config, + external_service_id=new_energy_monitor.external_service_id, + ) + ) + logger.debug(f"Energy Monitor {updated_monitor.name} updated successfully.") + click.echo( + click.style( + f"Energy Monitor '{updated_monitor.name}' (ID: {updated_monitor.id}) successfully updated.", + fg="green", + ) + ) + return updated_monitor + except Exception as e: + logger.error(f"Error updating energy monitor: {e}") + click.echo( + click.style(f"Error updating energy monitor: {e}", fg="red"), + err=True, + ) + return None + + +def delete_single_energy_monitor( + monitor: EnergyMonitor, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> bool: + """Delete a single energy monitor.""" + delete_confirm = click.confirm( + f"Are you sure you want to delete the energy monitor '{monitor.name}' (ID: {monitor.id})?", + abort=False, + default=False, + prompt_suffix="", + ) + + if not delete_confirm: + click.echo(click.style("Deletion cancelled.", fg="yellow")) + return False + + try: + removed_energy_monitor = run_async_func(configuration_service.remove_energy_monitor(monitor.id)) + logger.debug(f"Energy Monitor {removed_energy_monitor.name} deleted successfully.") + click.echo( + click.style( + f"Energy Monitor '{monitor.name}' deleted successfully.", + fg="green", + ) + ) + return True + except Exception as e: + logger.error(f"Error deleting energy monitor: {e}") + click.echo( + click.style(f"Error deleting energy monitor: {e}", fg="red"), + err=True, + ) + return False + + +def manage_single_energy_monitor_menu( + monitor: EnergyMonitor, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> str: + """Menu for managing a single energy monitor.""" + while True: + click.echo("\n" + click.style("--- MANAGE ENERGY MONITOR ---", fg="blue", bold=True)) + + print_energy_monitor_details( + energy_monitor=monitor, + configuration_service=configuration_service, + show_external_service=True, + show_energy_source_list=True, + ) + + click.echo("1. Update Energy Monitor") + click.echo("2. Delete Energy Monitor") + click.echo("") + click.echo("b. Back to energy menu") + click.echo("q. Close application") + click.echo("-----------------") + + choice: str = click.prompt("Choose an option", type=str) + choice = choice.strip().lower() + + click.clear() + + if choice == "1": + updated_energy_monitor = update_single_energy_monitor( + monitor=monitor, + configuration_service=configuration_service, + logger=logger, + ) + monitor = updated_energy_monitor or monitor + continue + + elif choice == "2": + delete_status = delete_single_energy_monitor( + monitor=monitor, + configuration_service=configuration_service, + logger=logger, + ) + if delete_status: + return "b" + continue + + elif choice == "b": + break + + elif choice == "q": + break + + else: + click.echo(click.style("Invalid choice. Try again.", fg="red")) + click.pause("Press any key to return to the menu...") + + return choice + + +def energy_menu(configuration_service: ConfigurationServiceInterface, logger: LoggerPort) -> str: + """Menu for managing Energy Sources.""" + while True: + click.echo("\n" + click.style("--- ENERGY ---", fg="blue", bold=True)) + click.echo("1. Add an Energy Source") + click.echo("2. List all Energy Sources") + click.echo("3. Manage an Energy Source") + click.echo("") + click.echo("4. Add an Energy Monitor") + click.echo("5. List all Energy Monitors") + click.echo("6. Manage an Energy Monitor") + click.echo("") + click.echo("b. Back to main menu") + click.echo("q. Close application") + click.echo("-----------------") + + choice: str = click.prompt("Choose an option", type=str) + choice = choice.strip().lower() + + click.clear() + + if choice == "1": + handle_add_energy_source(configuration_service=configuration_service, logger=logger) + + elif choice == "2": + handle_list_energy_sources(configuration_service=configuration_service, logger=logger) + + elif choice == "3": + energy_source = select_energy_source(configuration_service, logger) + if energy_source is None: + click.echo(click.style("No energy source selected. Aborting.", fg="red")) + continue + + sub_choice = manage_single_energy_source_menu( + energy_source=energy_source, + configuration_service=configuration_service, + logger=logger, + ) + if sub_choice == "q": + break + + elif choice == "4": + handle_add_energy_monitor( + energy_source=None, + configuration_service=configuration_service, + logger=logger, + ) + + elif choice == "5": + handle_list_energy_monitors(configuration_service=configuration_service, logger=logger) + + elif choice == "6": + monitor = select_energy_monitor(configuration_service, logger) + if monitor is None: + click.echo(click.style("No monitor selected. Aborting.", fg="red")) + continue + + sub_choice = manage_single_energy_monitor_menu( + monitor=monitor, + configuration_service=configuration_service, + logger=logger, + ) + if sub_choice == "q": + choice = "q" # Exit if user chose to quit from energy menu + break + + elif choice == "b": + break + + elif choice == "q": + break + + else: + click.echo(click.style("Invalid choice. Try again.", fg="red")) + click.pause("Press any key to return to the menu...") + + return choice diff --git a/core/edge_mining/adapters/domain/energy/fast_api/__init__.py b/core/edge_mining/adapters/domain/energy/fast_api/__init__.py new file mode 100644 index 0000000..54832c1 --- /dev/null +++ b/core/edge_mining/adapters/domain/energy/fast_api/__init__.py @@ -0,0 +1 @@ +"""Adapter that uses FastAPI infrastructure for energy domain API""" diff --git a/core/edge_mining/adapters/domain/energy/fast_api/router.py b/core/edge_mining/adapters/domain/energy/fast_api/router.py new file mode 100644 index 0000000..5b8bc0d --- /dev/null +++ b/core/edge_mining/adapters/domain/energy/fast_api/router.py @@ -0,0 +1,368 @@ +"""API Router for energy domain.""" + +import uuid +from typing import Annotated, Any, Dict, List, Optional, cast + +from fastapi import APIRouter, Depends, HTTPException + +from edge_mining.adapters.domain.energy.schemas import ( + ENERGY_MONITOR_CONFIG_SCHEMA_MAP, + EnergyMonitorCreateSchema, + EnergyMonitorSchema, + EnergyMonitorUpdateSchema, + EnergySourceCreateSchema, + EnergySourceSchema, + EnergySourceUpdateSchema, +) + +# Import dependency injection setup functions +from edge_mining.adapters.infrastructure.api.setup import get_config_service +from edge_mining.application.interfaces import ConfigurationServiceInterface +from edge_mining.domain.common import EntityId, Watts +from edge_mining.domain.energy.common import EnergyMonitorAdapter, EnergySourceType +from edge_mining.domain.energy.entities import EnergyMonitor, EnergySource +from edge_mining.domain.energy.exceptions import ( + EnergyMonitorAlreadyExistsError, + EnergyMonitorConfigurationError, + EnergyMonitorNotFoundError, + EnergySourceAlreadyExistsError, + EnergySourceConfigurationError, + EnergySourceNotFoundError, +) +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.interfaces.config import Configuration, EnergyMonitorConfig + +router = APIRouter() + + +@router.get("/energy-sources", response_model=List[EnergySourceSchema]) +async def get_energy_sources_list( + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> List[EnergySourceSchema]: + """Get a list of all energy sources.""" + try: + energy_sources: List[EnergySource] = config_service.list_energy_sources() + + # Convert to energy source schema + energy_source_schemas: List[EnergySourceSchema] = [] + + for energy_source in energy_sources: + energy_source_schemas.append(EnergySourceSchema.from_model(energy_source)) + + return energy_source_schemas + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/energy-sources", response_model=EnergySourceSchema) +async def add_energy_source( + energy_source_data: EnergySourceCreateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergySourceSchema: + """Add a new energy source.""" + try: + # Convert to domain model + energy_source_to_add: EnergySource = energy_source_data.to_model() + + # Add the energy source + created_source = await config_service.create_energy_source( + name=energy_source_to_add.name, + source_type=energy_source_to_add.type, + nominal_power_max=energy_source_to_add.nominal_power_max, + storage=energy_source_to_add.storage, + grid=energy_source_to_add.grid, + external_source=energy_source_to_add.external_source, + energy_monitor_id=energy_source_to_add.energy_monitor_id, + forecast_provider_id=energy_source_to_add.forecast_provider_id, + ) + + response = EnergySourceSchema.from_model(created_source) + return response + except EnergySourceAlreadyExistsError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except EnergySourceConfigurationError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/energy-sources/types", response_model=List[EnergySourceType]) +async def get_energy_source_types() -> List[EnergySourceType]: + """Get a list of available energy source types.""" + try: + return [EnergySourceType(source_type.value) for source_type in EnergySourceType] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/energy-sources/{source_id}", response_model=EnergySourceSchema) +async def get_energy_source( + source_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergySourceSchema: + """Get details of a specific energy source.""" + try: + energy_source = config_service.get_energy_source(source_id) + + if energy_source is None: + raise EnergySourceNotFoundError(f"Energy source with id {source_id} not found") + + return EnergySourceSchema.from_model(energy_source) + except EnergySourceNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.put("/energy-sources/{source_id}", response_model=EnergySourceSchema) +async def update_energy_source( + source_id: EntityId, + energy_source_update: EnergySourceUpdateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergySourceSchema: + """Update an existing energy source.""" + try: + energy_source = config_service.get_energy_source(source_id) + + if energy_source is None: + raise EnergySourceNotFoundError(f"Energy source with id {source_id} not found") + + # Update the energy source + updated_source = await config_service.update_energy_source( + source_id=source_id, + name=energy_source_update.name or "", + source_type=energy_source_update.type, + nominal_power_max=Watts(energy_source_update.nominal_power_max) + if energy_source_update.nominal_power_max is not None + else None, + storage=energy_source_update.storage.to_model() if energy_source_update.storage else None, + grid=energy_source_update.grid.to_model() if energy_source_update.grid else None, + external_source=Watts(energy_source_update.external_source) + if energy_source_update.external_source is not None + else None, + energy_monitor_id=EntityId(uuid.UUID(energy_source_update.energy_monitor_id)), + forecast_provider_id=EntityId(uuid.UUID(energy_source_update.forecast_provider_id)), + ) + + response = EnergySourceSchema.from_model(updated_source) + + return response + except EnergySourceNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.delete("/energy-sources/{source_id}", response_model=EnergySourceSchema) +async def delete_energy_source( + source_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergySourceSchema: + """Remove an energy source.""" + try: + deleted_source = await config_service.remove_energy_source(source_id) + + response = EnergySourceSchema.from_model(deleted_source) + + return response + except EnergySourceNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +# Energy Monitors endpoints +@router.get("/energy-monitors", response_model=List[EnergyMonitorSchema]) +async def get_energy_monitors_list( + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> List[EnergyMonitorSchema]: + """Get a list of all energy monitors.""" + try: + energy_monitors: List[EnergyMonitor] = config_service.list_energy_monitors() + + # Convert to energy monitor schema + energy_monitor_schemas: List[EnergyMonitorSchema] = [] + + for energy_monitor in energy_monitors: + energy_monitor_schemas.append(EnergyMonitorSchema.from_model(energy_monitor)) + + return energy_monitor_schemas + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/energy-monitors", response_model=EnergyMonitorSchema) +async def add_energy_monitor( + energy_monitor_data: EnergyMonitorCreateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyMonitorSchema: + """Add a new energy monitor.""" + try: + # Convert to domain model + energy_monitor_to_add: EnergyMonitor = energy_monitor_data.to_model() + + if energy_monitor_to_add.config is None: + raise EnergyMonitorConfigurationError("Energy monitor configuration should be set") + + # Add the energy monitor + created_monitor = await config_service.create_energy_monitor( + name=energy_monitor_to_add.name, + adapter_type=energy_monitor_to_add.adapter_type, + config=energy_monitor_to_add.config, + external_service_id=energy_monitor_to_add.external_service_id, + ) + + response = EnergyMonitorSchema.from_model(created_monitor) + return response + except EnergyMonitorAlreadyExistsError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except EnergyMonitorConfigurationError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/energy-monitors/types", response_model=List[EnergyMonitorAdapter]) +async def get_energy_monitor_types() -> List[EnergyMonitorAdapter]: + """Get a list of available energy monitor types.""" + try: + return [EnergyMonitorAdapter(adapter.value) for adapter in EnergyMonitorAdapter] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get( + "/energy-monitors/types/{adapter_type}/config-schema", + response_model=Dict[str, Any], +) +async def get_energy_monitor_config_schema( + adapter_type: EnergyMonitorAdapter, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> Dict[str, Any]: + """Get the configuration schema for a specific energy monitor type.""" + try: + try: + energy_monitor_adapter = EnergyMonitorAdapter(adapter_type) + except ValueError as e: + raise ValueError(f"Invalid energy monitor adapter type: {adapter_type}") from e + + # Get the corresponding configuration class for the adapter type + energy_monitor_config_type: Optional[type[EnergyMonitorConfig]] = ( + config_service.get_energy_monitor_config_by_type(energy_monitor_adapter) + ) + + if energy_monitor_config_type is None: + raise ValueError(f"No configuration class found for adapter type {adapter_type}") + + # Map the configuration class to its corresponding schema + energy_monitor_config_schema = ENERGY_MONITOR_CONFIG_SCHEMA_MAP.get(energy_monitor_config_type, None) + + if energy_monitor_config_schema is None: + raise ValueError(f"No schema found for energy monitor config class: {energy_monitor_config_type}") + + return energy_monitor_config_schema.model_json_schema() + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get( + "/energy-monitors/types/{adapter_type}/external-services", + response_model=Optional[ExternalServiceAdapter], +) +async def get_energy_monitor_type_external_service_types( + adapter_type: EnergyMonitorAdapter, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> Optional[ExternalServiceAdapter]: + """Get a list of compatible external service types for a specific energy monitor type.""" + try: + needed_external_service = config_service.get_energy_monitor_external_service_adapter(adapter_type) + + return needed_external_service + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/energy-monitors/{monitor_id}", response_model=EnergyMonitorSchema) +async def get_energy_monitor( + monitor_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyMonitorSchema: + """Get details of a specific energy monitor.""" + try: + energy_monitor = config_service.get_energy_monitor(monitor_id) + + if energy_monitor is None: + raise EnergyMonitorNotFoundError(f"Energy monitor with id {monitor_id} not found") + + return EnergyMonitorSchema.from_model(energy_monitor) + except EnergyMonitorNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.put("/energy-monitors/{monitor_id}", response_model=EnergyMonitorSchema) +async def update_energy_monitor( + monitor_id: EntityId, + energy_monitor_update: EnergyMonitorUpdateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyMonitorSchema: + """Update an existing energy monitor.""" + try: + energy_monitor = config_service.get_energy_monitor(monitor_id) + + if energy_monitor is None: + raise EnergyMonitorNotFoundError(f"Energy monitor with id {monitor_id} not found") + + configuration: Optional[Configuration] = None + if energy_monitor_update.config: + config_cls = config_service.get_energy_monitor_config_by_type(energy_monitor.adapter_type) + if config_cls is None: + raise EnergyMonitorConfigurationError( + f"No configuration class found for adapter type {energy_monitor.adapter_type}" + ) + configuration = config_cls.from_dict(energy_monitor_update.config) + + external_service_id: Optional[EntityId] = None + if energy_monitor_update.external_service_id: + external_service_id = EntityId(uuid.UUID(energy_monitor_update.external_service_id)) + + # Update the energy monitor + updated_monitor = await config_service.update_energy_monitor( + monitor_id=monitor_id, + name=energy_monitor_update.name or "", + config=cast(EnergyMonitorConfig, configuration), + external_service_id=external_service_id, + ) + + response = EnergyMonitorSchema.from_model(updated_monitor) + + return response + except EnergyMonitorNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.delete("/energy-monitors/{monitor_id}", response_model=EnergyMonitorSchema) +async def delete_energy_monitor( + monitor_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyMonitorSchema: + """Remove an energy monitor.""" + try: + deleted_monitor = await config_service.remove_energy_monitor(monitor_id) + + response = EnergyMonitorSchema.from_model(deleted_monitor) + + return response + except EnergyMonitorNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e diff --git a/core/edge_mining/adapters/domain/energy/monitors/__init__.py b/core/edge_mining/adapters/domain/energy/monitors/__init__.py new file mode 100644 index 0000000..57a48e1 --- /dev/null +++ b/core/edge_mining/adapters/domain/energy/monitors/__init__.py @@ -0,0 +1 @@ +"""Collection of energy monitor adapters.""" diff --git a/core/edge_mining/adapters/domain/energy/monitors/dummy_solar.py b/core/edge_mining/adapters/domain/energy/monitors/dummy_solar.py new file mode 100644 index 0000000..a0146db --- /dev/null +++ b/core/edge_mining/adapters/domain/energy/monitors/dummy_solar.py @@ -0,0 +1,255 @@ +""" +Dummy adapter (Implementation of Port) that simulates +the energy provisioning of Edge Mining Application +""" + +import random +from datetime import datetime +from typing import Optional + +from edge_mining.domain.common import Percentage, WattHours, Watts, Timestamp +from edge_mining.domain.energy.common import EnergyMonitorAdapter +from edge_mining.domain.energy.entities import EnergySource +from edge_mining.domain.energy.ports import EnergyMonitorPort +from edge_mining.domain.energy.value_objects import ( + Battery, + BatteryState, + EnergyStateSnapshot, + Grid, + GridState, + LoadState, +) +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.adapter_configs.energy import EnergyMonitorDummySolarConfig +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.factories import EnergyMonitorAdapterFactory +from edge_mining.shared.logging.port import LoggerPort + + +class DummySolarEnergyMonitor(EnergyMonitorPort): + """Generates plausible fake energy data.""" + + def __init__( + self, + nominal_max_power: Optional[Watts] = None, + storage: Optional[Battery] = None, + grid: Optional[Grid] = None, + external_source: Optional[Watts] = None, + max_consumption_power: Optional[Watts] = None, + logger: Optional[LoggerPort] = None, + ): + super().__init__(energy_monitor_type=EnergyMonitorAdapter.DUMMY_SOLAR) + self.logger = logger + + self.nominal_max_power = nominal_max_power if nominal_max_power else 5000 + self.storage = storage + self.grid = grid + self.external_source = external_source + self.max_consumption_power = max_consumption_power if max_consumption_power else 3000 + + # --- Storage --- + self.current_soc = None + self.remaining_capacity = WattHours(0.0) + self.storage_max_charging_power = Watts(3000) + self.storage_max_discharging_power = Watts(3000) + + if self.storage: + self.current_soc = Percentage(random.uniform(40.0, 90.0)) # Start with random SOC + self.remaining_capacity = WattHours( + self.storage.nominal_capacity * (self.current_soc / 100.0) + ) # Calculate remaining capacity + + async def get_current_energy_state(self) -> Optional[EnergyStateSnapshot]: + now = datetime.now() + hour = now.hour + + # Simulate solar production (simple sinusoidal based on hour) + if 6 < hour < 20: + # Peak around 1 PM (hour 13) + solar_factor = max(0, 1 - abs(hour - 13) / 7) + production = Watts(random.uniform(500, self.nominal_max_power) * solar_factor) + else: + production = Watts(0.0) + + # Simulate base consumption + consumption = LoadState( + current_power=Watts(random.uniform(150, self.max_consumption_power)), + timestamp=Timestamp(now), + ) + + battery_state = None + grid_power = Watts(0.0) + + if self.storage: + # Simple battery logic: charge if surplus, discharge if deficit + net_power = production - consumption.current_power + battery_power = Watts(0.0) + + if not self.current_soc: + self.current_soc = Percentage(0.0) # Default SOC if not set + + if net_power > 0 and self.current_soc < 100.0: # Charging + charge_power = min(net_power, self.storage_max_charging_power) # Limit charge power + current_soc = min( + 100.0, + self.current_soc + (charge_power / self.storage.nominal_capacity * 100 / 60), + ) # Wh adjustment per minute approx + self.current_soc = Percentage(current_soc) + self.remaining_capacity = WattHours(self.storage.nominal_capacity * (self.current_soc / 100.0)) + battery_power = Watts(charge_power) + + grid_power = Watts(net_power - charge_power) # Export excess + elif net_power < 0 and self.current_soc > 20.0: # Discharging (with buffer) + discharge_power = min(abs(net_power), self.storage_max_discharging_power) # Limit discharge power + current_soc = max( + 0.0, + self.current_soc - (discharge_power / self.storage.nominal_capacity * 100 / 60), + ) + self.current_soc = Percentage(current_soc) + self.remaining_capacity = WattHours(self.storage.nominal_capacity * (self.current_soc / 100.0)) + battery_power = Watts(-discharge_power) + + grid_power = Watts(net_power - battery_power) # Import remaining deficit + else: # Idle or full/empty + grid_power = Watts(net_power) # Import/export directly + + battery_state = BatteryState( + state_of_charge=Percentage(self.current_soc), + remaining_capacity=self.remaining_capacity, + current_power=battery_power, # Positive charging, negative discharging + timestamp=Timestamp(now), + ) + else: + # No battery: grid takes all difference + grid_power = Watts(production - consumption.current_power) + + # Default: the solar system is not connected to the grid (off-grid) + grid_state: Optional[GridState] = None + + if self.grid: + # The solar system is connected to the grid (on-grid) + grid_state = GridState(current_power=grid_power, timestamp=Timestamp(now)) + + snapshot = EnergyStateSnapshot( + production=production, + consumption=consumption, + battery=battery_state, + grid=grid_state, + external_source=None, + timestamp=Timestamp(now), + ) + if self.logger: + self.logger.debug( + f"DummyMonitor: Generated state: Prod={production:.0f}W," + f"Cons={consumption.current_power:.0f}W," + f"Grid={grid_power:.0f}W, SOC={self.current_soc:.1f}%" + ) + return snapshot + + +class DummySolarEnergyMonitorBuilder: + """Builder class for constructing DummySolarEnergyMonitor instances.""" + + def __init__(self, logger: Optional[LoggerPort]): + self.logger: Optional[LoggerPort] = logger + self.nominal_max_power: Optional[Watts] = None + self.storage: Optional[Battery] = None + self.grid: Optional[Grid] = None + self.external_source: Optional[Watts] = None + self.max_consumption_power: Optional[Watts] = None + + def set_nominal_max_power(self, nominal_max_power: Watts) -> "DummySolarEnergyMonitorBuilder": + """Set nominal max inverter power.""" + self.nominal_max_power = nominal_max_power + return self + + def set_max_consumption_power(self, max_consumption_power: Watts) -> "DummySolarEnergyMonitorBuilder": + """Set max load consumption power.""" + self.max_consumption_power = max_consumption_power + return self + + def has_external_source(self, external_source: Watts) -> "DummySolarEnergyMonitorBuilder": + """Add an external source.""" + self.external_source = external_source + return self + + def has_storage(self, storage: Battery) -> "DummySolarEnergyMonitorBuilder": + """Add a battery.""" + self.storage = storage + return self + + def on_grid(self, grid: Grid) -> "DummySolarEnergyMonitorBuilder": + """Is an on-grid solar system.""" + self.grid = grid + return self + + def build(self) -> DummySolarEnergyMonitor: + """Build and validate the DummySolarEnergyMonitor instance.""" + + monitor = DummySolarEnergyMonitor( + nominal_max_power=self.nominal_max_power, + storage=self.storage, + grid=self.grid, + external_source=self.external_source, + max_consumption_power=self.max_consumption_power, + ) + + return monitor + + +class DummySolarEnergyMonitorFactory(EnergyMonitorAdapterFactory): + """ + Creates a factory for Dummy Solar energy monitor adapter. + + This factory aims to simplifying the building of Dummy Solar. + """ + + def __init__(self): + self._energy_source: Optional[EnergySource] = None + + def from_energy_source(self, energy_source: EnergySource) -> None: + """Set the reference energy source""" + self._energy_source = energy_source + + def create( + self, + config: Optional[Configuration], + logger: Optional[LoggerPort], + external_service: Optional[ExternalServicePort], + ) -> EnergyMonitorPort: + """Create an energy source adapter""" + + # Use builder pattern to create the adapter + builder = DummySolarEnergyMonitorBuilder(logger=logger) + + if not isinstance(config, EnergyMonitorDummySolarConfig): + raise ValueError( + "Invalid configuration type for Dummy Solar energy monitor. Expected EnergyMonitorDummySolarConfig." + ) + + # Get the config from the energy monitor config + energy_monitor_config: EnergyMonitorDummySolarConfig = config + + if energy_monitor_config.max_consumption_power: + builder.set_max_consumption_power(energy_monitor_config.max_consumption_power) + + # if energy source has been set, we use it + if self._energy_source: + # If energy source has a nominal max power, we use it + if self._energy_source.nominal_power_max: + builder.set_nominal_max_power(self._energy_source.nominal_power_max) + + # If energy source has a battery connected, we expect to produce data for it + if self._energy_source.storage: + builder.has_storage(self._energy_source.storage) + + # If energy source is on grid, we expect to use it + if self._energy_source.grid: + builder.on_grid(self._energy_source.grid) + + # If energy source has an external source, we take it + if self._energy_source.external_source: + builder.has_external_source(self._energy_source.external_source) + + # --- Build the adapter --- + return builder.build() diff --git a/core/edge_mining/adapters/domain/energy/monitors/home_assistant_api.py b/core/edge_mining/adapters/domain/energy/monitors/home_assistant_api.py new file mode 100644 index 0000000..9ebe5de --- /dev/null +++ b/core/edge_mining/adapters/domain/energy/monitors/home_assistant_api.py @@ -0,0 +1,450 @@ +""" +Home Assistant API adapter (Implementation of Port) +for the energy provisioning of Edge Mining Application using the Home Assistant API +""" + +from datetime import datetime +from typing import Optional, cast + +from edge_mining.adapters.infrastructure.homeassistant.homeassistant_api import ( + ServiceHomeAssistantAPI, +) +from edge_mining.domain.common import Timestamp, Watts +from edge_mining.domain.energy.common import EnergyMonitorAdapter +from edge_mining.domain.energy.entities import EnergySource +from edge_mining.domain.energy.exceptions import ( + EnergyMonitorConfigurationError, + EnergyMonitorError, +) +from edge_mining.domain.energy.ports import EnergyMonitorPort +from edge_mining.domain.energy.value_objects import ( + BatteryState, + EnergyStateSnapshot, + GridState, + LoadState, +) +from edge_mining.shared.adapter_configs.energy import EnergyMonitorHomeAssistantConfig +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.interfaces.factories import EnergyMonitorAdapterFactory +from edge_mining.shared.logging.port import LoggerPort + + +class HomeAssistantAPIEnergyMonitorFactory(EnergyMonitorAdapterFactory): + """ + Creates a factory for HomeAssistantAPI energy monitor adapter. + + This factory aims to simplifying the building of HomeAssistantAPI. + """ + + def __init__(self): + self._energy_source: Optional[EnergySource] = None + + def from_energy_source(self, energy_source: EnergySource) -> None: + """Set the reference energy source""" + self._energy_source = energy_source + + def create( + self, + config: Optional[Configuration], + logger: Optional[LoggerPort], + external_service: Optional[ExternalServicePort], + ) -> EnergyMonitorPort: + """Create an energy monitor adapter""" + + # Needs to have the Home Assistant API service as external_service + if not external_service: + raise EnergyMonitorError("HomeAssistantAPI Service is required for HomeAssistantAPI energy monitor.") + + if not external_service.external_service_type == ExternalServiceAdapter.HOME_ASSISTANT_API: + raise EnergyMonitorError("External service must be of type HomeAssistantAPI") + + if not isinstance(config, EnergyMonitorHomeAssistantConfig): + raise EnergyMonitorConfigurationError( + "Invalid configuration type for HomeAssistantAPI energy monitor. " + "Expected EnergyMonitorHomeAssistantConfig." + ) + + # Get the config from the energy monitor config + energy_monitor_config: EnergyMonitorHomeAssistantConfig = config + + service_home_assistant_api = cast(ServiceHomeAssistantAPI, external_service) + + # Use builder pattern to create the adapter, in this way + # we can easily add more configuration options in the future + # based on the config provided by the user. + builder = HomeAssistantAPIEnergyMonitorBuilder(home_assistant=service_home_assistant_api, logger=logger) + + # --- Production --- + if energy_monitor_config.entity_production: + if energy_monitor_config.unit_production: + builder.set_production_entity( + entity_id=energy_monitor_config.entity_production, + unit=energy_monitor_config.unit_production, + ) + else: + builder.set_production_entity(entity_id=energy_monitor_config.entity_production) + + # --- Consumption --- + if energy_monitor_config.entity_consumption: + if energy_monitor_config.unit_consumption: + builder.set_consumption_entity( + entity_id=energy_monitor_config.entity_consumption, + unit=energy_monitor_config.unit_consumption, + ) + else: + builder.set_consumption_entity(entity_id=energy_monitor_config.entity_consumption) + + # --- Grid --- + if energy_monitor_config.entity_grid: + if energy_monitor_config.unit_grid: + builder.set_grid_entity( + entity_id=energy_monitor_config.entity_grid, + unit=energy_monitor_config.unit_grid, + positive_export=energy_monitor_config.grid_positive_export, + ) + else: + builder.set_grid_entity(entity_id=energy_monitor_config.entity_grid) + + # --- Battery --- + if energy_monitor_config.entity_battery_soc and energy_monitor_config.entity_battery_power: + if energy_monitor_config.unit_battery_power: + builder.set_battery_entities( + soc_entity_id=energy_monitor_config.entity_battery_soc, + power_entity_id=energy_monitor_config.entity_battery_power, + power_unit=energy_monitor_config.unit_battery_power, + ) + else: + builder.set_battery_entities( + soc_entity_id=energy_monitor_config.entity_battery_soc, + power_entity_id=energy_monitor_config.entity_battery_power, + ) + # --- Battery Remaining Capacity --- + if energy_monitor_config.entity_battery_remaining_capacity: + if energy_monitor_config.unit_battery_remaining_capacity: + builder.set_battery_remaining_capacity_entity( + entity_id=energy_monitor_config.entity_battery_remaining_capacity, + unit=energy_monitor_config.unit_battery_remaining_capacity, + ) + else: + builder.set_battery_remaining_capacity_entity( + entity_id=energy_monitor_config.entity_battery_remaining_capacity + ) + + # --- Build the adapter --- + return builder.build() + + +class HomeAssistantAPIEnergyMonitorBuilder: + """Builder class for constructing HomeAssistantAPIEnergyMonitor instances.""" + + def __init__(self, home_assistant: ServiceHomeAssistantAPI, logger: Optional[LoggerPort]): + self.home_assistant: ServiceHomeAssistantAPI = home_assistant + self.logger: Optional[LoggerPort] = logger + self.entity_production: Optional[str] = None + self.entity_consumption: Optional[str] = None + self.entity_grid: Optional[str] = None + self.entity_battery_soc: Optional[str] = None + self.entity_battery_power: Optional[str] = None + self.entity_battery_remaining_capacity: Optional[str] = None + self.unit_production: str = "W" + self.unit_consumption: str = "W" + self.unit_grid: str = "W" + self.unit_battery_power: str = "W" + self.unit_battery_remaining_capacity: str = "Wh" + self.grid_positive_export: bool = False + self.battery_positive_charge: bool = True + + def set_production_entity(self, entity_id: str, unit: str = "W") -> "HomeAssistantAPIEnergyMonitorBuilder": + """Set entity for monitoring the production""" + self.entity_production = entity_id + self.unit_production = unit.lower() + return self + + def set_consumption_entity(self, entity_id: str, unit: str = "W") -> "HomeAssistantAPIEnergyMonitorBuilder": + """Set entity for monitoring the consumption""" + self.entity_consumption = entity_id + self.unit_consumption = unit.lower() + return self + + def set_grid_entity( + self, entity_id: str, unit: str = "W", positive_export: bool = False + ) -> "HomeAssistantAPIEnergyMonitorBuilder": + """Set entity for monitoring the grid""" + self.entity_grid = entity_id + self.unit_grid = unit.lower() + self.grid_positive_export = positive_export + return self + + def set_battery_entities( + self, + soc_entity_id: str, + power_entity_id: str, + power_unit: str = "W", + positive_charge: bool = True, + ) -> "HomeAssistantAPIEnergyMonitorBuilder": + """Set entities for monitoring the battery""" + self.entity_battery_soc = soc_entity_id + self.entity_battery_power = power_entity_id + + self.unit_battery_power = power_unit.lower() + self.battery_positive_charge = positive_charge + + return self + + def set_battery_remaining_capacity_entity( + self, entity_id: str, unit: str = "Wh" + ) -> "HomeAssistantAPIEnergyMonitorBuilder": + """Set entity for monitoring the battery remaining capacity""" + self.entity_battery_remaining_capacity = entity_id + self.unit_battery_remaining_capacity = unit.lower() + return self + + def build(self) -> "HomeAssistantAPIEnergyMonitor": + """Build and validate the HomeAssistantAPIEnergyMonitor instance.""" + + if not self.entity_consumption: + raise EnergyMonitorError("Consumption entity is required") + + if self.entity_battery_soc and not self.entity_battery_power: + raise EnergyMonitorError("Battery power entity is required when battery SOC is configured") + + monitor = HomeAssistantAPIEnergyMonitor( + home_assistant=self.home_assistant, + logger=self.logger, + entity_production=self.entity_production, + entity_consumption=self.entity_consumption, + entity_grid=self.entity_grid, + entity_battery_soc=self.entity_battery_soc, + entity_battery_power=self.entity_battery_power, + entity_battery_remaining_capacity=self.entity_battery_remaining_capacity, + unit_production=self.unit_production, + unit_consumption=self.unit_consumption, + unit_grid=self.unit_grid, + unit_battery_power=self.unit_battery_power, + unit_battery_remaining_capacity=self.unit_battery_remaining_capacity, + grid_positive_export=self.grid_positive_export, + battery_positive_charge=self.battery_positive_charge, + ) + + return monitor + + +class HomeAssistantAPIEnergyMonitor(EnergyMonitorPort): + """ + Fetches energy data from a Home Assistant instance via its REST API. + + Requires careful configuration of entity IDs. + Make sure the House Consumption entity EXCLUDES the consumption of miners, + possibly using a template sensor in Home Assistant. + """ + + def __init__( + self, + home_assistant: ServiceHomeAssistantAPI, + logger: Optional[LoggerPort], + entity_production: Optional[str], + entity_consumption: Optional[str], + entity_grid: Optional[str], + entity_battery_soc: Optional[str], + entity_battery_power: Optional[str], + entity_battery_remaining_capacity: Optional[str], + unit_production: str = "W", + unit_consumption: str = "W", + unit_grid: str = "W", + unit_battery_power: str = "W", + unit_battery_remaining_capacity: str = "Wh", + grid_positive_export: bool = False, + battery_positive_charge: bool = True, + ): + super().__init__(energy_monitor_type=EnergyMonitorAdapter.HOME_ASSISTANT_API) + + # Initialize the HomeAssistant API Service + self.home_assistant = home_assistant + self.logger = logger + + self.entity_production = entity_production + self.entity_consumption = entity_consumption + self.entity_grid = entity_grid + self.entity_battery_soc = entity_battery_soc + self.entity_battery_power = entity_battery_power + self.entity_battery_remaining_capacity = entity_battery_remaining_capacity + self.unit_production = unit_production.lower() + self.unit_consumption = unit_consumption.lower() + self.unit_grid = unit_grid.lower() + self.unit_battery_power = unit_battery_power.lower() + self.unit_battery_remaining_capacity = unit_battery_remaining_capacity.lower() + self.grid_positive_export = grid_positive_export + self.battery_positive_charge = battery_positive_charge + + self._log_configuration() + + def _log_configuration(self): + """Log the current configuration of the monitor.""" + if self.logger: + self.logger.debug( + f"Entities Configured: " + f"Production='{self.entity_production}', " + f"Consumption='{self.entity_consumption}', " + f"Grid='{self.entity_grid}', " + f"BatterySOC='{self.entity_battery_soc}', " + f"BatteryPower='{self.entity_battery_power}', " + f"BatteryRemaining='{self.entity_battery_remaining_capacity}'" + ) + self.logger.debug( + f"Units: " + f"Production='{self.unit_production}', " + f"Consumption='{self.unit_consumption}', " + f"Grid='{self.unit_grid}', " + f"BatteryPower='{self.unit_battery_power}', " + f"BatteryRemaining='{self.unit_battery_remaining_capacity}'" + ) + self.logger.debug( + f"Conventions: " + f"Grid Positive Export='{self.grid_positive_export}', " + f"Battery Positive Charge='{self.battery_positive_charge}'" + ) + + async def get_current_energy_state(self) -> Optional[EnergyStateSnapshot]: + if self.logger: + self.logger.debug("Fetching current energy state from Home Assistant...") + now = Timestamp(datetime.now()) + + # --- Production --- + if self.entity_production: + state_production, _ = await self.home_assistant.get_entity_state(self.entity_production) + production_watts = self.home_assistant.parse_power( + state_production, + self.unit_production, + self.entity_production or "N/A", + ) + else: + production_watts = None + + # --- Consumption --- + if self.entity_consumption: + state_consumption, _ = await self.home_assistant.get_entity_state(self.entity_consumption) + consumption_watts = self.home_assistant.parse_power( + state_consumption, + self.unit_consumption, + self.entity_consumption or "N/A", + ) + else: + consumption_watts = None + + # --- Grid --- + if self.entity_grid: + state_grid, _ = await self.home_assistant.get_entity_state(self.entity_grid) + grid_watts_raw = self.home_assistant.parse_power(state_grid, self.unit_grid, self.entity_grid or "N/A") + else: + grid_watts_raw = None + + # --- Battery --- + if self.entity_battery_soc and self.entity_battery_power: + state_battery_soc, _ = await self.home_assistant.get_entity_state(self.entity_battery_soc) + state_battery_power, _ = await self.home_assistant.get_entity_state(self.entity_battery_power) + battery_soc = self.home_assistant.parse_percentage(state_battery_soc, self.entity_battery_soc or "N/A") + battery_power_raw = self.home_assistant.parse_power( + state_battery_power, + self.unit_battery_power, + self.entity_battery_power or "N/A", + ) + else: + battery_soc = None + battery_power_raw = None + + if self.entity_battery_remaining_capacity: + state_battery_remaining_capacity, _ = await self.home_assistant.get_entity_state( + self.entity_battery_remaining_capacity + ) + battery_remaining_capacity = self.home_assistant.parse_energy( + state_battery_remaining_capacity, + self.unit_battery_remaining_capacity, + self.entity_battery_remaining_capacity or "N/A", + ) + else: + battery_remaining_capacity = None + + # --- Apply Conventions --- + # Grid: We want positive for IMPORTING, negative for EXPORTING + if grid_watts_raw is not None: + grid_watts = -grid_watts_raw if self.grid_positive_export else grid_watts_raw + else: + grid_watts = None + if self.entity_grid: + if self.logger: + self.logger.warning( + f"Could not retrieve grid value (Entity: {self.entity_grid}). Continuing without grid data." + ) + + # Battery: We want positive for CHARGING, negative for DISCHARGING + if battery_power_raw is not None: + battery_power = battery_power_raw if self.battery_positive_charge else -battery_power_raw + else: + battery_power = None + if self.entity_battery_soc and self.entity_battery_power: + if self.logger: + self.logger.warning( + f"Could not retrieve battery power value " + f"(Entity: {self.entity_battery_power}). " + "Continuing without battery data." + ) + + # Check if essential values are missing + if production_watts is None and self.entity_production: + if self.logger: + self.logger.warning( + f"Could not retrieve production value (Entity: {self.entity_production}). Defaulting to 0W." + ) + if consumption_watts is None and self.entity_consumption: + if self.logger: + self.logger.error(f"Missing critical value: House Consumption (Entity: {self.entity_consumption})") + + reading_timestamp = now + + # Fill defaults if entities weren't configured + production_watts = production_watts if production_watts is not None else Watts(0.0) + consumption_watts = consumption_watts if consumption_watts is not None else Watts(0.0) + + consumption_state = LoadState(current_power=consumption_watts, timestamp=reading_timestamp) + + # Create GridState if relevant entities are available + grid_state: Optional[GridState] = None + if grid_watts is not None: + grid_state = GridState(current_power=Watts(grid_watts), timestamp=reading_timestamp) + + # Construct BatteryState if relevant entities are available + battery_state: Optional[BatteryState] = None + if battery_soc is not None and battery_power is not None: + battery_state = BatteryState( + state_of_charge=battery_soc, + remaining_capacity=battery_remaining_capacity, + current_power=Watts(battery_power), + timestamp=reading_timestamp, + ) + elif self.entity_battery_soc: # Log if configured but data missing + if self.logger: + self.logger.warning( + "Battery SOC entity configured, but could not create full BatteryState (missing power or SOC?)." + ) + + snapshot = EnergyStateSnapshot( + production=production_watts, + consumption=consumption_state, + battery=battery_state, + grid=grid_state, + external_source=None, # TODO: Add external source + timestamp=reading_timestamp, + ) + + if self.logger: + self.logger.info( + f"HA Monitor: Energy State fetched: Prod={snapshot.production:.0f}W, " + f"Cons={snapshot.consumption.current_power:.0f}W, " + f"Grid={snapshot.grid.current_power if snapshot.grid else 'N/A'}W, " + f"SOC={snapshot.battery.state_of_charge if snapshot.battery else 'N/A'}%, " + f"BattPwr={snapshot.battery.current_power if snapshot.battery else 'N/A'}W" + ) + + return snapshot diff --git a/core/edge_mining/adapters/domain/energy/monitors/home_assistant_mqtt.py b/core/edge_mining/adapters/domain/energy/monitors/home_assistant_mqtt.py new file mode 100644 index 0000000..3ed9aa9 --- /dev/null +++ b/core/edge_mining/adapters/domain/energy/monitors/home_assistant_mqtt.py @@ -0,0 +1,483 @@ +import math +import ssl # Per TLS +import threading +import time +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional, Tuple, Union + +import paho.mqtt.client as mqtt + +from edge_mining.domain.common import Percentage, Timestamp, WattHours, Watts +from edge_mining.domain.energy.common import EnergyMonitorAdapter +from edge_mining.domain.energy.ports import EnergyMonitorPort +from edge_mining.domain.energy.value_objects import BatteryState, EnergyStateSnapshot, GridState, LoadState +from edge_mining.shared.logging.port import LoggerPort + + +class MqttEnergyMonitor(EnergyMonitorPort): + """ + Fetches energy data by subscribing to topics on an MQTT broker. + + It maintains the latest received values internally and returns a snapshot + when get_current_energy_state is called. + """ + + def __init__( + self, + broker_host: str, + broker_port: int, + username: Optional[str], + password: Optional[str], + client_id: str, + topics: Dict[str, Optional[str]], # Map internal name to topic string + units: Dict[str, str], + conventions: Dict[str, bool], + battery_capacity_wh: Optional[float], + max_data_age_seconds: int, + logger: Optional[LoggerPort] = None, + ): + super().__init__(energy_monitor_type=EnergyMonitorAdapter.HOME_ASSISTANT_MQTT) + + self.logger = logger + + self.broker_host = broker_host + self.broker_port = broker_port + self.username = username + self.password = password + self.client_id = f"{client_id}-{int(time.time())}" + self.topics_map = {k: v for k, v in topics.items() if v} + self.units_map = units + self.conventions = conventions + self.battery_capacity = WattHours(battery_capacity_wh) if battery_capacity_wh else None + self.max_data_age = timedelta(seconds=max_data_age_seconds) + + # Store latest value by internal sensor name + self._latest_values: Dict[str, Any] = {} + # Store last update timestamp by internal sensor name + self._last_update_times: Dict[str, datetime] = {} + # Lock for thread-safe access to latest values and timestamps + self._lock = threading.Lock() + self._connected = threading.Event() + self._client: Optional[mqtt.Client] = None + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + + if self.logger: + self.logger.info(f"Initializing MqttEnergyMonitor for {broker_host}:{broker_port}") + self.logger.debug(f"Client ID: {self.client_id}") + self.logger.debug(f"Topics configured: {self.topics_map}") + self.logger.debug(f"Units: {self.units_map}") + self.logger.debug(f"Conventions: {self.conventions}") + if self.battery_capacity: + self.logger.debug(f"Static Battery Capacity: {self.battery_capacity} Wh") + self.logger.debug(f"Max data age: {self.max_data_age} seconds") + + if not self.topics_map: + if self.logger: + self.logger.warning("MQTT Energy Monitor initialized, but no topics were configured.") + # The adapter will still run, but won't receive any data. + + self._setup_client() + + def _setup_client(self): + """Configure the MQTT client and start the loop in a separate thread.""" + try: + try: + self._client = mqtt.Client(client_id=self.client_id, protocol=mqtt.MQTTv5) + if self.logger: + self.logger.debug("Using MQTTv5 protocol.") + except ValueError: + if self.logger: + self.logger.warning("MQTTv5 not supported by paho-mqtt version or broker? Falling back to v3.1.1.") + self._client = mqtt.Client(client_id=self.client_id) # Default (v3.1.1) + + self._client.on_connect = self._on_connect + self._client.on_disconnect = self._on_disconnect + self._client.on_message = self._on_message + self._client.on_log = self._on_log + + if self.username: + self._client.username_pw_set(self.username, self.password) + + # TLS management (it is basic, add more options if needed) + if self.broker_port == 8883: # Porta standard per MQTTS + if self.logger: + self.logger.info("Configuring TLS for MQTT connection (port 8883 detected).") + # Use default system certs + self._client.tls_set(tls_version=ssl.PROTOCOL_TLS_CLIENT) + # For custom certs: self._client.tls_set(ca_certs="ca.crt", + # certfile="client.crt", keyfile="client.key") + + if self.logger: + self.logger.info(f"Connecting MQTT client to {self.broker_host}:{self.broker_port}...") + self._client.connect_async(self.broker_host, self.broker_port, 60) + + # Run the MQTT loop in a separate thread to avoid blocking + self._thread = threading.Thread(target=self._mqtt_loop, daemon=True) + self._thread.start() + + except Exception as e: + if self.logger: + self.logger.error(f"Failed to setup MQTT client: {e}") + self._client = None + + def _mqtt_loop(self): + """Function run in a separate thread to keep the MQTT loop running.""" + if not self._client: + return + if self.logger: + self.logger.info("MQTT client loop started.") + while not self._stop_event.is_set(): + try: + rc = self._client.loop(timeout=1.0) + if rc != mqtt.MQTT_ERR_SUCCESS: + if self.logger: + self.logger.warning(f"MQTT loop returned error code: {rc}. Attempting to handle.") + # Paho will automatically handle reconnections, + # but we can log or handle specific cases if needed. + time.sleep(5) # Wait before next loop iteration + except Exception as e: + if self.logger: + self.logger.error(f"Exception in MQTT loop: {e}") + # In case of a severe error, try to reconnect after a pause + if not self._stop_event.is_set(): + time.sleep(10) + try: + if not self._client.is_connected(): + if self.logger: + self.logger.info("Attempting to reconnect MQTT client...") + self._client.reconnect() + except Exception as reconn_e: + if self.logger: + self.logger.error(f"Failed to manually reconnect MQTT: {reconn_e}") + + if self.logger: + self.logger.info("MQTT client loop stopped.") + if self._client.is_connected(): + if self.logger: + self.logger.debug("Disconnecting MQTT client cleanly.") + self._client.disconnect() + + def stop(self): + """Stop the MQTT client and its loop thread.""" + if self.logger: + self.logger.info("Stopping MQTT Energy Monitor...") + self._stop_event.set() + if self._client: + # We dont disconnect here, loop_stop will handle it if necessary + # Calling loop_stop waits for the thread to finish the current loop + self._client.loop_stop() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=5.0) # Wait for the thread to finish + if self._thread.is_alive(): + if self.logger: + self.logger.warning("MQTT loop thread did not stop gracefully.") + if self.logger: + self.logger.info("MQTT Energy Monitor stopped.") + + def _on_log(self, client, userdata, level, buf): + """Callback per i log interni di paho-mqtt.""" + # Mappa i livelli di paho a quelli di logging di Python se necessario + # Maps the paho log levels to logger logging levels + if level == mqtt.MQTT_LOG_ERR: + if self.logger: + self.logger.error(f"PAHO-MQTT: {buf}") + elif level == mqtt.MQTT_LOG_WARNING: + if self.logger: + self.logger.warning(f"PAHO-MQTT: {buf}") + elif level == mqtt.MQTT_LOG_INFO: + if self.logger: + self.logger.info(f"PAHO-MQTT: {buf}") + else: # MQTT_LOG_DEBUG, MQTT_LOG_NOTICE + if self.logger: + self.logger.debug(f"PAHO-MQTT: {buf}") + + def _on_connect(self, client, userdata, flags, rc, properties=None): + """Callback when connected to the MQTT broker.""" + if rc == 0: + if self.logger: + self.logger.info(f"Successfully connected to MQTT broker: {self.broker_host}:{self.broker_port}") + self._connected.set() # Connection successful, set the connected flag + # Subscribe to configured topics + for internal_name, topic in self.topics_map.items(): + if topic: + if self.logger: + self.logger.info(f"Subscribing to topic '{topic}' for '{internal_name}'") + # Use QoS 1 for better reliability if the broker supports it well + result, mid = client.subscribe(topic, qos=1) + if result != mqtt.MQTT_ERR_SUCCESS: + if self.logger: + self.logger.error(f"Failed to subscribe to topic '{topic}': {mqtt.error_string(result)}") + else: + if self.logger: + self.logger.debug(f"Subscription request sent for '{topic}' (MID: {mid})") + + else: + if self.logger: + self.logger.error(f"Failed to connect to MQTT broker: {mqtt.connack_string(rc)}") + self._connected.clear() + + def _on_disconnect(self, client, userdata, rc, properties=None): + """Callback when disconnected from the MQTT broker.""" + self._connected.clear() + if self.logger: + self.logger.warning( + f"Disconnected from MQTT broker (rc: {rc}). Reconnection should be attempted automatically by Paho." + ) + + def _on_message(self, client, userdata, msg): + """Callback when a message is received on a subscribed topic.""" + try: + topic = msg.topic + payload = msg.payload.decode("utf-8") + if self.logger: + self.logger.debug(f"MQTT message received: Topic='{topic}', Payload='{payload}'") + + # Find the internal sensor name for this topic + internal_name = None + for name, configured_topic in self.topics_map.items(): + # We can user `mqtt.topic_matches_sub` if we want to support wildcards in topics + if configured_topic == topic: + internal_name = name + break + + if internal_name: + # Parse the payload based on the internal sensor name + parsed_value: Optional[Union[Watts, Percentage]] = None + unit = self.units_map.get(internal_name, "W").lower() # Default a Watts + + if internal_name in [ + "solar_production", + "house_consumption", + "grid_power", + "battery_power", + ]: + parsed_value = self._parse_power(payload, unit, topic) + # Apply conventions for grid and battery power + if parsed_value: + if internal_name == "grid_power": + if self.conventions.get("grid_positive_export", False): + parsed_value = Watts(parsed_value * -1) # Positive for export + elif internal_name == "battery_power": + if not self.conventions.get("battery_positive_charge", True): + parsed_value = Watts(parsed_value * -1) # Positive for charge + + elif internal_name == "battery_soc": + parsed_value = self._parse_percentage(payload, topic) + + else: + if self.logger: + self.logger.warning(f"Received message for unhandled internal sensor name: '{internal_name}'") + + # Update the internal state in a thread-safe manner + if parsed_value is not None: + with self._lock: + self._latest_values[internal_name] = parsed_value + self._last_update_times[internal_name] = datetime.now(timezone.utc) # Usa UTC + if self.logger: + self.logger.debug( + f"Stored '{internal_name}' = {parsed_value} " + f"(Timestamp: {self._last_update_times[internal_name]})" + ) + else: + if self.logger: + self.logger.warning(f"Could not parse value for topic '{topic}', payload '{payload}'") + + else: + if self.logger: + self.logger.warning(f"Received message on unexpected topic: '{topic}'") + + except Exception as e: + if self.logger: + self.logger.error(f"Error processing MQTT message (Topic: {msg.topic}): {e}") + + def _parse_power( + self, + state: Optional[str], + configured_unit: str, + entity_id_for_log: str, + ) -> Optional[Watts]: + """Helper to parse power values from MQTT messages.""" + if state is None: + return None + try: + value = float(state) + if math.isnan(value): + return None + if configured_unit.lower() == "kw": + value *= 1000 + elif configured_unit.lower() != "w": + if self.logger: + self.logger.warning( + f"Unsupported unit '{configured_unit}' for topic '{entity_id_for_log}'. Assuming Watts." + ) + return Watts(value) + except (ValueError, TypeError): + return None + + def _parse_percentage(self, state: Optional[str], entity_id_for_log: str) -> Optional[Percentage]: + """Helper to parse percentage values from MQTT messages.""" + if state is None: + return None + try: + value = float(state) + if math.isnan(value): + return None + return Percentage(max(0.0, min(100.0, value))) # Clamp 0-100 + except (ValueError, TypeError): + return None + + async def get_current_energy_state(self) -> Optional[EnergyStateSnapshot]: + """ + Give the latest energy state snapshot based on received MQTT messages. + Checks if the data is too old and logs warnings if values are stale or missing.""" + if not self._connected.is_set(): + if self.logger: + self.logger.warning("MQTT client not connected. Cannot provide energy state.") + # We could try to read old values, but it's risky. Better to return None. + return None + + # Access the latest values and timestamps in a thread-safe manner + with self._lock: + latest_values = self._latest_values.copy() + last_update_times = self._last_update_times.copy() + + now = datetime.now(timezone.utc) + snapshot_time = Timestamp(now.astimezone()) # Convert to local timezone for snapshot + is_stale = False + + # Get the latest values and check if they are stale + production, stale_prod = self._get_value("solar_production", latest_values, last_update_times, now) + consumption, stale_cons = self._get_value("house_consumption", latest_values, last_update_times, now) + grid_power, stale_grid = self._get_value("grid_power", latest_values, last_update_times, now) + battery_soc, stale_soc = self._get_value("battery_soc", latest_values, last_update_times, now) + battery_power, stale_power = self._get_value("battery_power", latest_values, last_update_times, now) + + is_stale = any([stale_prod, stale_cons, stale_grid, stale_soc, stale_power]) + + # Check if critical data is missing (never received) + if self.topics_map.get("solar_production") and production is None: + if self.logger: + self.logger.warning( + f"Could not retrieve Solar Production (Topic: {self.topics_map['solar_production']}). " + "Defaulting to 0W." + ) + if self.topics_map.get("house_consumption") and consumption is None: + if self.logger: + self.logger.error( + f"Missing critical value: House Consumption (Topic: {self.topics_map['house_consumption']})" + ) + if self.topics_map.get("grid_power") and grid_power is None: + if self.logger: + self.logger.warning( + f"Could not retrieve Grid Power (Topic: {self.topics_map['grid_power']}). " + "Continuing without grid data." + ) + # Battery: log warning if configured but missing + if self.topics_map.get("battery_soc") and self.topics_map.get("battery_power"): + if battery_soc is None: + if self.logger: + self.logger.warning( + f"Could not retrieve Battery SOC (Topic: {self.topics_map['battery_soc']}). " + "Continuing without battery data." + ) + if battery_power is None: + if self.logger: + self.logger.warning( + f"Could not retrieve Battery Power (Topic: {self.topics_map['battery_power']}). " + "Continuing without battery data." + ) + + if is_stale: + if self.logger: + self.logger.warning( + "One or more sensor values are stale. Using last known values, but they might be inaccurate." + ) + # Decision: Continue with stale data or return None? For now, we continue. + # if is_stale: return None # Safer option + + # Fill defaults if not configured or missing (but not critical) + production = production if production is not None else Watts(0.0) + consumption = consumption if consumption is not None else Watts(0.0) + grid_power = grid_power if grid_power is not None else Watts(0.0) + + # Build BatteryState if possible + battery_state: Optional[BatteryState] = None + if battery_soc is not None and battery_power is not None and self.battery_capacity is not None: + battery_state = BatteryState( + state_of_charge=battery_soc, + remaining_capacity=self.battery_capacity, + current_power=battery_power, + timestamp=snapshot_time, + ) + elif self.topics_map.get("battery_soc"): + if self.logger: + self.logger.debug( + "Battery SOC topic configured, but full BatteryState cannot be created " + + "(missing power topic/value or static capacity setting?)." + ) + + load_state = LoadState( + current_power=consumption, + timestamp=snapshot_time, + ) + grid_state = GridState( + current_power=grid_power, + timestamp=snapshot_time, + ) + + snapshot = EnergyStateSnapshot( + production=production, + consumption=load_state, + battery=battery_state, + grid=grid_state, + external_source=None, + timestamp=snapshot_time, + ) + + if self.logger: + production_str: str = f"{snapshot.production:.0f}W" if snapshot.production else "N/A" + consumption_str: str = f"{snapshot.consumption.current_power:.0f}W" if snapshot.consumption else "N/A" + grid_str: str = f"{snapshot.grid.current_power:.0f}W" if snapshot.grid else "N/A" + self.logger.info( + f"MQTT Monitor: State Snapshot: Prod={production_str}, " + f"Cons={consumption_str}, Grid={grid_str}, " + f"SOC={snapshot.battery.state_of_charge if snapshot.battery else 'N/A'}%, " + f"BattPwr={snapshot.battery.current_power if snapshot.battery else 'N/A'}W " + f"(Stale: {is_stale})" + ) + + return snapshot + + def _get_value( + self, + name: str, + latest_values: Dict[str, Any], + last_update_times: Dict[str, datetime], + now: datetime, + ) -> Tuple[Optional[Any], bool]: + """Helper to get the latest value and check if it's stale.""" + value = latest_values.get(name) + last_update = last_update_times.get(name) + stale = False + # Only if topic is configured + if self.topics_map.get(name): + if value is None: + if self.logger: + self.logger.warning( + f"No value received yet for sensor '{name}' (Topic: {self.topics_map.get(name)})" + ) + # Should this be considered an error only if it's a critical sensor? + # For now, we do not consider it a critical error if it has NEVER been received + elif last_update is None or (now - last_update) > self.max_data_age: + if self.logger: + self.logger.warning( + f"Data for sensor '{name}' is stale (Last update: {last_update}," + f" Age: {now - last_update if last_update else 'N/A'})" + ) + stale = True + # Should we consider stale data as unavailable for calculation? + # It depends on criticality. For now, we use them but log a warning. + # We could choose to return None for the value here. + + return value, stale diff --git a/core/edge_mining/adapters/domain/energy/repositories.py b/core/edge_mining/adapters/domain/energy/repositories.py new file mode 100644 index 0000000..c2612c0 --- /dev/null +++ b/core/edge_mining/adapters/domain/energy/repositories.py @@ -0,0 +1,747 @@ +"""Repositories for the Energy domain.""" + +import copy +import json +import sqlite3 +from typing import Any, Dict, List, Optional + +from sqlalchemy import select + +from edge_mining.adapters.domain.energy.tables import energy_monitors_table, energy_sources_table +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.adapters.infrastructure.persistence.sqlite import BaseSqliteRepository +from edge_mining.domain.common import EntityId, WattHours, Watts +from edge_mining.domain.energy.common import EnergyMonitorAdapter, EnergySourceType +from edge_mining.domain.energy.entities import EnergyMonitor, EnergySource +from edge_mining.domain.energy.exceptions import ( + EnergyMonitorConfigurationError, + EnergyMonitorError, + EnergySourceAlreadyExistsError, + EnergySourceConfigurationError, + EnergySourceError, + EnergySourceNotFoundError, +) +from edge_mining.domain.energy.ports import EnergyMonitorRepository, EnergySourceRepository +from edge_mining.domain.energy.value_objects import Battery, Grid +from edge_mining.shared.adapter_maps.energy import ENERGY_MONITOR_CONFIG_TYPE_MAP +from edge_mining.shared.interfaces.config import EnergyMonitorConfig + + +class InMemoryEnergySourceRepository(EnergySourceRepository): + """In-Memory implementation for the Energy Source Repository.""" + + def __init__( + self, + initial_energy_sources: Optional[Dict[EntityId, EnergySource]] = None, + ): + self._energy_sources: Dict[EntityId, EnergySource] = ( + copy.deepcopy(initial_energy_sources) if initial_energy_sources else {} + ) + + def add(self, energy_source: EnergySource) -> None: + """Add an energy source to the In-Memory repository.""" + if energy_source.id in self._energy_sources: + # Handle update or raise error depending on desired behavior + print(f"Warning: Energy Source {energy_source.id} already exists, overwriting.") + self._energy_sources[energy_source.id] = copy.deepcopy(energy_source) + + def get_by_id(self, energy_source_id: EntityId) -> Optional[EnergySource]: + """Get an energy source by ID from the In-Memory repository.""" + return copy.deepcopy(self._energy_sources.get(energy_source_id)) + + def get_all(self) -> List[EnergySource]: + """Get all energy sources from the In-Memory repository.""" + return [copy.deepcopy(e) for e in self._energy_sources.values()] + + def update(self, energy_source: EnergySource) -> None: + """Update an energy source in the In-Memory repository.""" + if energy_source.id not in self._energy_sources: + raise EnergySourceError(f"Energy Source {energy_source.id} not found for update.") + self._energy_sources[energy_source.id] = copy.deepcopy(energy_source) + + def remove(self, energy_source_id: EntityId) -> None: + """Remove an energy source from the In-Memory repository.""" + if energy_source_id in self._energy_sources: + del self._energy_sources[energy_source_id] + + +class SqliteEnergySourceRepository(EnergySourceRepository): + """SQLite implementation for the Energy Source Repository.""" + + def __init__(self, db: BaseSqliteRepository): + self._db = db + self.logger = db.logger + + self._create_tables() + + def _create_tables(self): + """Create the tables for the Energy Source Repository.""" + self.logger.debug(f"Ensuring SQLite tables exist for Energy Source Repository in {self._db.db_path}...") + sql_statements = [ + """ + CREATE TABLE IF NOT EXISTS energy_sources ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + nominal_power_max REAL, + storage TEXT, -- JSON object of Battery + grid TEXT, -- JSON object of Grid + external_source REAL, + energy_monitor_id TEXT, + forecast_provider_id TEXT + ); + """ + ] + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + for statement in sql_statements: + cursor.execute(statement) + self.logger.debug("Energy Sources tables checked/created successfully.") + except sqlite3.Error as e: + self.logger.error(f"Error creating SQLite tables: {e}") + raise EnergySourceConfigurationError(f"DB error creating tables: {e}") from e + finally: + if conn: + conn.close() + + def _dict_to_battery(self, data: Dict[str, Any]) -> Battery: + """Deserialize a dictionary (from JSON) into an Battery object.""" + return Battery(nominal_capacity=WattHours(data["nominal_capacity"])) + + def _dict_to_grid(self, data: Dict[str, Any]) -> Grid: + """Deserialize a dictionary (from JSON) into an Grid object.""" + return Grid(contracted_power=Watts(data["contracted_power"])) + + def _row_to_energy_source(self, row: sqlite3.Row) -> Optional[EnergySource]: + """Convert a SQLite row to an EnergySource object.""" + if not row: + return None + try: + energy_source_type = EnergySourceType(row["type"]) + + # Deserialize the storage and grid from the database row + storage = self._dict_to_battery(json.loads(row["storage"])) if row["storage"] else None + grid = self._dict_to_grid(json.loads(row["grid"])) if row["grid"] else None + + return EnergySource( + id=EntityId(row["id"]), + name=row["name"], + type=energy_source_type, + nominal_power_max=(Watts(row["nominal_power_max"]) if row["nominal_power_max"] else None), + storage=storage, + grid=grid, + external_source=(Watts(row["external_source"]) if row["external_source"] else None), + energy_monitor_id=(EntityId(row["energy_monitor_id"]) if row["energy_monitor_id"] else None), + forecast_provider_id=(EntityId(row["forecast_provider_id"]) if row["forecast_provider_id"] else None), + ) + except (ValueError, KeyError) as e: + self.logger.error(f"Error deserializing EnergySource from DB row: {row}. Error: {e}") + return None + + def add(self, energy_source: EnergySource) -> None: + """Add an energy source to the SQLite database.""" + self.logger.debug(f"Adding energy source {energy_source.id} to SQLite.") + + sql = """ + INSERT INTO energy_sources (id, name, type, nominal_power_max, storage, grid, external_source, + energy_monitor_id, forecast_provider_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + conn = self._db.get_connection() + try: + # Serialize the storage and grid to JSON for storage + storage_json = json.dumps(energy_source.storage.__dict__) if energy_source.storage else None + grid_json = json.dumps(energy_source.grid.__dict__) if energy_source.grid else None + + with conn: + cursor = conn.cursor() + cursor.execute( + sql, + ( + energy_source.id, + energy_source.name, + energy_source.type.value, + energy_source.nominal_power_max, + storage_json, + grid_json, + energy_source.external_source, + energy_source.energy_monitor_id, + energy_source.forecast_provider_id, + ), + ) + except sqlite3.IntegrityError as e: + self.logger.error(f"Integrity error adding energy source {energy_source.id}: {e}") + # Could mean that the ID already exists + raise EnergySourceAlreadyExistsError( + f"Energy Source with ID {energy_source.id} already exists or constraint violation: {e}" + ) from e + except sqlite3.Error as e: + self.logger.error(f"SQLite error adding energy source {energy_source.id}: {e}") + raise EnergySourceError(f"DB error adding energy source: {e}") from e + finally: + if conn: + conn.close() + + def get_by_id(self, energy_source_id: EntityId) -> Optional[EnergySource]: + """Get an energy source by ID from the SQLite database.""" + self.logger.debug(f"Getting energy source {energy_source_id} from SQLite.") + + sql = "SELECT * FROM energy_sources WHERE id = ?" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (energy_source_id,)) + row = cursor.fetchone() + return self._row_to_energy_source(row) + except sqlite3.Error as e: + self.logger.error(f"SQLite error getting energy source {energy_source_id}: {e}") + return None # Or raise exception? Returning None is more forgiving + finally: + if conn: + conn.close() + + def get_all(self) -> List[EnergySource]: + """Get all energy sources from the SQLite database.""" + self.logger.debug("Getting all energy sources from SQLite.") + + sql = "SELECT * FROM energy_sources" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql) + rows = cursor.fetchall() + energy_sources = [] + for row in rows: + energy_source = self._row_to_energy_source(row) + if energy_source: + energy_sources.append(energy_source) + except sqlite3.Error as e: + self.logger.error(f"SQLite error getting all energy sources: {e}") + return [] + finally: + if conn: + conn.close() + return energy_sources + + def update(self, energy_source: EnergySource) -> None: + """Update an energy source in the SQLite database.""" + self.logger.debug(f"Updating energy source {energy_source.id} in SQLite.") + + sql = """ + UPDATE energy_sources + SET name = ?, type = ?, nominal_power_max = ?, storage = ?, grid = ?, external_source = ?, + energy_monitor_id = ?, forecast_provider_id = ? + WHERE id = ? + """ + conn = self._db.get_connection() + try: + # Serialize the storage and grid to JSON for storage + storage_json = json.dumps(energy_source.storage.__dict__) if energy_source.storage else None + grid_json = json.dumps(energy_source.grid.__dict__) if energy_source.grid else None + + with conn: + cursor = conn.cursor() + cursor.execute( + sql, + ( + energy_source.name, + energy_source.type.value, + energy_source.nominal_power_max, + storage_json, + grid_json, + energy_source.external_source, + energy_source.energy_monitor_id, + energy_source.forecast_provider_id, + energy_source.id, + ), + ) + if cursor.rowcount == 0: + raise EnergySourceNotFoundError(f"No energy source found with ID {energy_source.id} for update.") + except sqlite3.Error as e: + self.logger.error(f"Error updating energy source {energy_source.id} in SQLite: {e}") + raise EnergySourceError(f"DB error updating energy source {energy_source.id}: {e}") from e + finally: + if conn: + conn.close() + + def remove(self, energy_source_id: EntityId) -> None: + """Remove an energy source from the SQLite database.""" + self.logger.debug(f"Removing energy source {energy_source_id} from SQLite.") + + sql = "DELETE FROM energy_sources WHERE id = ?" + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + cursor.execute(sql, (energy_source_id,)) + if cursor.rowcount == 0: + raise EnergySourceNotFoundError(f"No energy source found with ID {energy_source_id} for removal.") + except sqlite3.Error as e: + self.logger.error(f"SQLite error removing energy source {energy_source_id}: {e}") + raise EnergySourceError(f"DB error removing energy source {energy_source_id}: {e}") from e + finally: + if conn: + conn.close() + + +class InMemoryEnergyMonitorRepository(EnergyMonitorRepository): + """In-Memory implementation for the Energy Monitor Repository.""" + + def __init__( + self, + initial_energy_monitors: Optional[Dict[EntityId, EnergyMonitor]] = None, + ): + self._energy_monitors: Dict[EntityId, EnergyMonitor] = ( + copy.deepcopy(initial_energy_monitors) if initial_energy_monitors else {} + ) + + def add(self, energy_monitor: EnergyMonitor) -> None: + """Add an energy monitor to the In-Memory repository.""" + if energy_monitor.id in self._energy_monitors: + # Handle update or raise error depending on desired behavior + print(f"Warning: Energy Monitor {energy_monitor.id} already exists, overwriting.") + self._energy_monitors[energy_monitor.id] = copy.deepcopy(energy_monitor) + + def get_by_id(self, energy_monitor_id: EntityId) -> Optional[EnergyMonitor]: + """Get an energy monitor by ID from the In-Memory repository.""" + return copy.deepcopy(self._energy_monitors.get(energy_monitor_id)) + + def get_all(self) -> List[EnergyMonitor]: + """Get all energy monitors from the In-Memory repository.""" + return [copy.deepcopy(e) for e in self._energy_monitors.values()] + + def update(self, energy_monitor: EnergyMonitor) -> None: + """Update an energy monitor in the In-Memory repository.""" + if energy_monitor.id in self._energy_monitors: + self._energy_monitors[energy_monitor.id] = copy.deepcopy(energy_monitor) + + def remove(self, energy_monitor_id: EntityId) -> None: + """Remove an energy monitor from the In-Memory repository.""" + if energy_monitor_id in self._energy_monitors: + del self._energy_monitors[energy_monitor_id] + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[EnergyMonitor]: + """Get all energy monitors associated with a specific external service ID.""" + return [ + copy.deepcopy(em) for em in self._energy_monitors.values() if em.external_service_id == external_service_id + ] + + +class SqliteEnergyMonitorRepository(EnergyMonitorRepository): + """SQLite implementation for the Energy Monitor Repository.""" + + def __init__(self, db: BaseSqliteRepository): + self._db = db + self.logger = db.logger + + self._create_tables() + + def _create_tables(self): + """Create the tables for the Energy Monitor Repository.""" + self.logger.debug(f"Ensuring SQLite tables exist for Energy Monitor Repository in {self._db.db_path}...") + sql_statements = [ + """ + CREATE TABLE IF NOT EXISTS energy_monitors ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + adapter_type TEXT NOT NULL, + config TEXT, -- JSON object of config + external_service_id TEXT -- Optional ID for external service integration + ); + """ + ] + conn = self._db.get_connection() + + try: + with conn: + cursor = conn.cursor() + for statement in sql_statements: + cursor.execute(statement) + + self.logger.debug("Energy Monitors tables checked/created successfully.") + + except sqlite3.Error as e: + self.logger.error(f"Error creating SQLite tables: {e}") + raise EnergySourceError(f"DB error creating tables: {e}") from e + finally: + if conn: + conn.close() + + def _deserialize_config(self, adapter_type: EnergyMonitorAdapter, config_json: str) -> EnergyMonitorConfig: + """Deserialize a JSON string into EnergyMonitorConfig object.""" + data: dict = json.loads(config_json) + + if adapter_type not in ENERGY_MONITOR_CONFIG_TYPE_MAP: + raise EnergyMonitorConfigurationError( + f"Error reading EnergyMonitor configuration. Invalid type '{adapter_type}'" + ) + + config_class: Optional[type[EnergyMonitorConfig]] = ENERGY_MONITOR_CONFIG_TYPE_MAP.get(adapter_type) + if not config_class: + raise EnergyMonitorConfigurationError(f"Error creating EnergyMonitor configuration. Type '{adapter_type}'") + + config_instance = config_class.from_dict(data) + if not isinstance(config_instance, EnergyMonitorConfig): + raise EnergyMonitorConfigurationError( + f"Deserialized config is not of type EnergyMonitorConfig for adapter type {adapter_type}." + ) + return config_instance + + def _row_to_energy_monitor(self, row: sqlite3.Row) -> Optional[EnergyMonitor]: + """Convert a SQLite row to an EnergyMonitor object.""" + if not row: + return None + try: + energy_monitor_adapter_type = EnergyMonitorAdapter(row["adapter_type"]) + + # Deserialize the config from the database row + config = self._deserialize_config(energy_monitor_adapter_type, row["config"]) + + return EnergyMonitor( + id=EntityId(row["id"]), + name=row["name"], + adapter_type=energy_monitor_adapter_type, + config=config, + external_service_id=(EntityId(row["external_service_id"]) if row["external_service_id"] else None), + ) + except (ValueError, KeyError) as e: + self.logger.error(f"Error deserializing EnergyMonitor from DB row: {row}. Error: {e}") + return None + + def add(self, energy_monitor: EnergyMonitor) -> None: + """Add an energy monitor to the SQLite database.""" + self.logger.debug(f"Adding energy monitor {energy_monitor.id} to SQLite.") + + sql = """ + INSERT INTO energy_monitors (id, name, adapter_type, config, external_service_id) + VALUES (?, ?, ?, ?, ?) + """ + conn = self._db.get_connection() + try: + # Serialize the config to JSON for storage + config_json: str = "" + if energy_monitor.config: + config_json = json.dumps(energy_monitor.config.to_dict()) + + with conn: + cursor = conn.cursor() + cursor.execute( + sql, + ( + energy_monitor.id, + energy_monitor.name, + energy_monitor.adapter_type.value, + config_json, + energy_monitor.external_service_id, + ), + ) + except sqlite3.IntegrityError as e: + self.logger.error(f"Error adding energy monitor {energy_monitor.id} to SQLite: {e}") + # Could mean that the ID already exists + raise EnergySourceAlreadyExistsError( + f"Energy Monitor with ID {energy_monitor.id} already exists or constraint violation: {e}" + ) from e + except sqlite3.Error as e: + self.logger.error(f"SQLite error adding energy monitor {energy_monitor.id}: {e}") + raise EnergySourceError(f"DB error adding energy monitor: {e}") from e + finally: + if conn: + conn.close() + + def get_by_id(self, energy_monitor_id: EntityId) -> Optional[EnergyMonitor]: + """Get an energy monitor by ID from the SQLite database.""" + self.logger.debug(f"Getting energy monitor {energy_monitor_id} from SQLite.") + + sql = "SELECT * FROM energy_monitors WHERE id = ?" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (energy_monitor_id,)) + row = cursor.fetchone() + return self._row_to_energy_monitor(row) + except sqlite3.Error as e: + self.logger.error(f"SQLite error getting energy monitor {energy_monitor_id}: {e}") + return None # Or raise exception? Returning None is more forgiving + finally: + if conn: + conn.close() + + def get_all(self) -> List[EnergyMonitor]: + """Get all energy monitors from the SQLite database.""" + self.logger.debug("Getting all energy monitors from SQLite.") + + sql = "SELECT * FROM energy_monitors" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql) + rows = cursor.fetchall() + energy_monitors = [] + for row in rows: + energy_monitor = self._row_to_energy_monitor(row) + if energy_monitor: + energy_monitors.append(energy_monitor) + except sqlite3.Error as e: + self.logger.error(f"SQLite error getting all energy monitors: {e}") + return [] + finally: + if conn: + conn.close() + return energy_monitors + + def update(self, energy_monitor: EnergyMonitor) -> None: + """Update an energy monitor in the SQLite database.""" + self.logger.debug(f"Updating energy monitor {energy_monitor.id} in SQLite.") + + sql = """ + UPDATE energy_monitors + SET name = ?, adapter_type = ?, config = ?, external_service_id = ? + WHERE id = ? + """ + conn = self._db.get_connection() + try: + # Serialize the config to JSON for storage + config_json: str = "" + if energy_monitor.config: + config_json = json.dumps(energy_monitor.config.to_dict()) + + with conn: + cursor = conn.cursor() + cursor.execute( + sql, + ( + energy_monitor.name, + energy_monitor.adapter_type.value, + config_json, + energy_monitor.external_service_id, + energy_monitor.id, + ), + ) + if cursor.rowcount == 0: + raise EnergySourceNotFoundError(f"No energy monitor found with ID {energy_monitor.id} for update.") + except sqlite3.Error as e: + self.logger.error(f"Error updating energy monitor {energy_monitor.id} in SQLite: {e}") + raise EnergySourceError(f"DB error updating energy monitor {energy_monitor.id}: {e}") from e + finally: + if conn: + conn.close() + + def remove(self, energy_monitor_id: EntityId) -> None: + """Remove an energy monitor from the SQLite database.""" + self.logger.debug(f"Removing energy monitor {energy_monitor_id} from SQLite.") + + sql = "DELETE FROM energy_monitors WHERE id = ?" + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + cursor.execute(sql, (energy_monitor_id,)) + if cursor.rowcount == 0: + self.logger.warning(f"Attempt to remove non-existent energy monitor with ID {energy_monitor_id}.") + # There is no need to raise an exception here, removing a + # non-existent is idempotent. + except sqlite3.Error as e: + self.logger.error(f"SQLite error removing energy monitor {energy_monitor_id}: {e}") + raise EnergyMonitorError(f"DB error removing energy monitor {energy_monitor_id}: {e}") from e + finally: + if conn: + conn.close() + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[EnergyMonitor]: + """Get all energy monitors associated with a specific external service ID.""" + self.logger.debug(f"Getting energy monitors for external service {external_service_id} from SQLite.") + + sql = "SELECT * FROM energy_monitors WHERE external_service_id = ?" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (external_service_id,)) + rows = cursor.fetchall() + energy_monitors = [] + for row in rows: + energy_monitor = self._row_to_energy_monitor(row) + if energy_monitor: + energy_monitors.append(energy_monitor) + return energy_monitors + except sqlite3.Error as e: + self.logger.error(f"SQLite error getting energy monitors for external service {external_service_id}: {e}") + return [] + finally: + if conn: + conn.close() + + +class SqlAlchemyEnergySourceRepository(EnergySourceRepository): + """SQLAlchemy implementation of EnergySourceRepository. + + This repository works directly with the imperatively mapped EnergySource domain entity. + Composite value objects (Battery, Grid, Watts) are automatically handled by SQLAlchemy's + composite() mapping defined in tables.py. + + Args: + db: BaseSQLAlchemyRepository instance for database operations + """ + + def __init__(self, db: BaseSQLAlchemyRepository): + """Initialize repository with database instance. + + Args: + db: BaseSQLAlchemyRepository instance + """ + self._db = db + self.logger = db.logger + + def add(self, energy_source: EnergySource) -> None: + """Add an energy source to the repository.""" + session = self._db.get_session() + try: + session.add(energy_source) + session.commit() + finally: + session.close() + + def get_by_id(self, energy_source_id: EntityId) -> Optional[EnergySource]: + """Get an energy source by ID.""" + session = self._db.get_session() + try: + stmt = select(EnergySource).where(energy_sources_table.c.id == str(energy_source_id)) + entity = session.execute(stmt).scalar_one_or_none() + return entity + finally: + session.close() + + def get_all(self) -> List[EnergySource]: + """Get all energy sources.""" + session = self._db.get_session() + try: + stmt = select(EnergySource) + entities = session.execute(stmt).scalars().all() + return list(entities) + finally: + session.close() + + def update(self, energy_source: EnergySource) -> None: + """Update an energy source.""" + session = self._db.get_session() + try: + stmt = select(EnergySource).where(energy_sources_table.c.id == str(energy_source.id)) + existing_entity = session.execute(stmt).scalar_one_or_none() + + if existing_entity: + existing_entity.name = energy_source.name + existing_entity.type = energy_source.type + existing_entity.nominal_power_max = energy_source.nominal_power_max + existing_entity.storage = energy_source.storage + existing_entity.grid = energy_source.grid + existing_entity.external_source = energy_source.external_source + existing_entity.energy_monitor_id = energy_source.energy_monitor_id + existing_entity.forecast_provider_id = energy_source.forecast_provider_id + + session.commit() + finally: + session.close() + + def remove(self, energy_source_id: EntityId) -> None: + """Remove an energy source by ID.""" + session = self._db.get_session() + try: + stmt = select(EnergySource).where(energy_sources_table.c.id == str(energy_source_id)) + entity = session.execute(stmt).scalar_one_or_none() + + if entity: + session.delete(entity) + session.commit() + finally: + session.close() + + +class SqlAlchemyEnergyMonitorRepository(EnergyMonitorRepository): + """SQLAlchemy implementation of EnergyMonitorRepository. + + This repository works directly with the imperatively mapped EnergyMonitor domain entity. + The config field is automatically converted between EnergyMonitorConfig objects and JSON + strings by the custom TypeDecorator and event listener defined in tables.py. + + Args: + db: BaseSQLAlchemyRepository instance for database operations + """ + + def __init__(self, db: BaseSQLAlchemyRepository): + """Initialize repository with database instance. + + Args: + db: BaseSQLAlchemyRepository instance + """ + self._db = db + self.logger = db.logger + + def add(self, energy_monitor: EnergyMonitor) -> None: + """Add an energy monitor to the repository.""" + session = self._db.get_session() + try: + session.add(energy_monitor) + session.commit() + finally: + session.close() + + def get_by_id(self, energy_monitor_id: EntityId) -> Optional[EnergyMonitor]: + """Get an energy monitor by ID.""" + session = self._db.get_session() + try: + stmt = select(EnergyMonitor).where(energy_monitors_table.c.id == str(energy_monitor_id)) + entity = session.execute(stmt).scalar_one_or_none() + return entity + finally: + session.close() + + def get_all(self) -> List[EnergyMonitor]: + """Get all energy monitors.""" + session = self._db.get_session() + try: + stmt = select(EnergyMonitor) + entities = session.execute(stmt).scalars().all() + return list(entities) + finally: + session.close() + + def update(self, energy_monitor: EnergyMonitor) -> None: + """Update an energy monitor.""" + session = self._db.get_session() + try: + stmt = select(EnergyMonitor).where(energy_monitors_table.c.id == str(energy_monitor.id)) + existing_entity = session.execute(stmt).scalar_one_or_none() + + if existing_entity: + existing_entity.name = energy_monitor.name + existing_entity.adapter_type = energy_monitor.adapter_type + existing_entity.config = energy_monitor.config + existing_entity.external_service_id = energy_monitor.external_service_id + + session.commit() + finally: + session.close() + + def remove(self, energy_monitor_id: EntityId) -> None: + """Remove an energy monitor by ID.""" + session = self._db.get_session() + try: + stmt = select(EnergyMonitor).where(energy_monitors_table.c.id == str(energy_monitor_id)) + entity = session.execute(stmt).scalar_one_or_none() + + if entity: + session.delete(entity) + session.commit() + finally: + session.close() + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[EnergyMonitor]: + """Get energy monitors by external service ID.""" + session = self._db.get_session() + try: + stmt = select(EnergyMonitor).where(energy_monitors_table.c.external_service_id == str(external_service_id)) + entities = session.execute(stmt).scalars().all() + return list(entities) + finally: + session.close() diff --git a/core/edge_mining/adapters/domain/energy/schemas.py b/core/edge_mining/adapters/domain/energy/schemas.py new file mode 100644 index 0000000..b5d0566 --- /dev/null +++ b/core/edge_mining/adapters/domain/energy/schemas.py @@ -0,0 +1,811 @@ +"""Validation schemas for energy domain.""" + +import uuid +from datetime import datetime +from typing import Dict, Optional, Union, cast + +from pydantic import BaseModel, Field, computed_field, field_serializer, field_validator + +from edge_mining.domain.common import EntityId, Percentage, Timestamp, WattHours, Watts +from edge_mining.domain.energy.common import EnergyMonitorAdapter, EnergySourceType +from edge_mining.domain.energy.entities import EnergyMonitor, EnergySource +from edge_mining.domain.energy.value_objects import ( + Battery, + BatteryState, + EnergyStateSnapshot, + Grid, + GridState, + LoadState, +) +from edge_mining.shared.adapter_configs.energy import EnergyMonitorDummySolarConfig, EnergyMonitorHomeAssistantConfig +from edge_mining.shared.adapter_maps.energy import ENERGY_MONITOR_CONFIG_TYPE_MAP +from edge_mining.shared.interfaces.config import EnergyMonitorConfig + + +class BatterySchema(BaseModel): + """Schema for Battery value object.""" + + nominal_capacity: float = Field(..., ge=0, description="Battery nominal capacity in Wh") + + @field_validator("nominal_capacity") + @classmethod + def validate_nominal_capacity(cls, v: float) -> float: + """Validate nominal capacity is positive.""" + if v < 0: + raise ValueError("Nominal capacity must be zero or positive") + return v + + def to_model(self) -> Battery: + """Convert BatterySchema to Battery domain value object.""" + return Battery(nominal_capacity=WattHours(self.nominal_capacity)) + + +class GridSchema(BaseModel): + """Schema for Grid value object.""" + + contracted_power: float = Field(..., ge=0, description="Grid contracted power in Watts") + + @field_validator("contracted_power") + @classmethod + def validate_contracted_power(cls, v: float) -> float: + """Validate contracted power is positive.""" + if v < 0: + raise ValueError("Contracted power must be zero or positive") + return v + + def to_model(self) -> Grid: + """Convert GridSchema to Grid domain value object.""" + return Grid(contracted_power=Watts(self.contracted_power)) + + +class LoadStateSchema(BaseModel): + """Schema for LoadState value object.""" + + current_power: float = Field(..., description="Current power in Watts") + timestamp: datetime = Field(default_factory=datetime.now, description="Timestamp of the load state") + + @field_validator("current_power") + @classmethod + def validate_current_power(cls, v: float) -> float: + """Validate current power.""" + if v < 0: + raise ValueError("Current power must be non-negative") + return v + + @classmethod + def from_model(cls, load_state: LoadState) -> "LoadStateSchema": + """Create schema from LoadState value object.""" + return cls( + current_power=float(load_state.current_power), + timestamp=load_state.timestamp, + ) + + def to_model(self) -> LoadState: + """Convert schema to LoadState value object.""" + return LoadState( + current_power=Watts(self.current_power), + timestamp=Timestamp(self.timestamp), + ) + + +class BatteryStateSchema(BaseModel): + """Schema for BatteryState value object.""" + + state_of_charge: float = Field(..., ge=0, le=100, description="State of charge as percentage (0-100)") + remaining_capacity: Optional[float] = Field(None, ge=0, description="Remaining capacity in WattHours") + current_power: float = Field(..., description="Current power in Watts (positive=charging, negative=discharging)") + timestamp: datetime = Field(default_factory=datetime.now, description="Timestamp of the battery state") + + @computed_field # type: ignore[prop-decorator] + @property + def charging_power(self) -> float: + """Returns the power being used to charge the battery (read-only).""" + return max(self.current_power, 0.0) + + @computed_field # type: ignore[prop-decorator] + @property + def discharging_power(self) -> float: + """Returns the power being used to discharge the battery (read-only).""" + return max(-self.current_power, 0.0) + + @field_validator("state_of_charge") + @classmethod + def validate_soc(cls, v: float) -> float: + """Validate state of charge is between 0 and 100.""" + if not 0 <= v <= 100: + raise ValueError("State of charge must be between 0 and 100") + return v + + @classmethod + def from_model(cls, battery_state: BatteryState) -> "BatteryStateSchema": + """Create schema from BatteryState value object.""" + return cls( + state_of_charge=float(battery_state.state_of_charge), + remaining_capacity=float(battery_state.remaining_capacity) if battery_state.remaining_capacity else None, + current_power=float(battery_state.current_power), + timestamp=battery_state.timestamp, + ) + + def to_model(self) -> BatteryState: + """Convert schema to BatteryState value object.""" + return BatteryState( + state_of_charge=Percentage(self.state_of_charge), + remaining_capacity=WattHours(self.remaining_capacity) if self.remaining_capacity else None, + current_power=Watts(self.current_power), + timestamp=Timestamp(self.timestamp), + ) + + +class GridStateSchema(BaseModel): + """Schema for GridState value object.""" + + current_power: float = Field(..., description="Current power in Watts (positive=importing, negative=exporting)") + timestamp: datetime = Field(default_factory=datetime.now, description="Timestamp of the grid state") + + @computed_field # type: ignore[prop-decorator] + @property + def importing_power(self) -> float: + """Returns the power being imported from the grid.""" + return max(self.current_power, 0.0) + + @computed_field # type: ignore[prop-decorator] + @property + def exporting_power(self) -> float: + """Returns the power being exported to the grid.""" + return max(-self.current_power, 0.0) + + @classmethod + def from_model(cls, grid_state: GridState) -> "GridStateSchema": + """Create schema from GridState value object.""" + return cls( + current_power=float(grid_state.current_power), + timestamp=grid_state.timestamp, + ) + + def to_model(self) -> GridState: + """Convert schema to GridState value object.""" + return GridState( + current_power=Watts(self.current_power), + timestamp=Timestamp(self.timestamp), + ) + + +class EnergyStateSnapshotSchema(BaseModel): + """Schema for EnergyStateSnapshot value object.""" + + production: float = Field(..., ge=0, description="Current production in Watts") + consumption: LoadStateSchema = Field(..., description="Current consumption state (excluding miner)") + battery: Optional[BatteryStateSchema] = Field(None, description="Battery state if present") + grid: Optional[GridStateSchema] = Field(None, description="Grid state if present") + external_source: Optional[float] = Field(None, ge=0, description="External source power in Watts") + timestamp: datetime = Field(default_factory=datetime.now, description="Timestamp of the energy snapshot") + + @field_validator("production", "external_source") + @classmethod + def validate_power(cls, v: Optional[float]) -> Optional[float]: + """Validate power values are non-negative if provided.""" + if v is not None and v < 0: + raise ValueError("Power must be non-negative") + return v + + @classmethod + def from_model(cls, snapshot: EnergyStateSnapshot) -> "EnergyStateSnapshotSchema": + """Create schema from EnergyStateSnapshot value object.""" + return cls( + production=float(snapshot.production), + consumption=LoadStateSchema.from_model(snapshot.consumption), + battery=BatteryStateSchema.from_model(snapshot.battery) if snapshot.battery else None, + grid=GridStateSchema.from_model(snapshot.grid) if snapshot.grid else None, + external_source=float(snapshot.external_source) if snapshot.external_source else None, + timestamp=snapshot.timestamp, + ) + + def to_model(self) -> EnergyStateSnapshot: + """Convert schema to EnergyStateSnapshot value object.""" + return EnergyStateSnapshot( + production=Watts(self.production), + consumption=self.consumption.to_model(), + battery=self.battery.to_model() if self.battery else None, + grid=self.grid.to_model() if self.grid else None, + external_source=Watts(self.external_source) if self.external_source else None, + timestamp=Timestamp(self.timestamp), + ) + + +class EnergySourceSchema(BaseModel): + """Schema for EnergySource entity with complete validation.""" + + id: str = Field(..., description="Unique identifier for the energy source") + name: str = Field(default="", description="Energy source name") + type: EnergySourceType = Field(default=EnergySourceType.SOLAR, description="Type of energy source") + nominal_power_max: Optional[float] = Field(default=None, ge=0, description="Maximum nominal power in Watts") + storage: Optional[BatterySchema] = Field(default=None, description="Battery storage configuration") + grid: Optional[GridSchema] = Field(default=None, description="Grid connection configuration") + external_source: Optional[float] = Field(default=None, ge=0, description="External source power in Watts") + energy_monitor_id: Optional[str] = Field(default=None, description="ID of the associated energy monitor") + forecast_provider_id: Optional[str] = Field(default=None, description="ID of the associated forecast provider") + + @field_validator("id") + @classmethod + def validate_id(cls, v: str) -> str: + """Validate that the id is a valid EntityId.""" + try: + EntityId(uuid.UUID(v)) + except ValueError as e: + raise ValueError(f"Invalid energy source id: {e}") from e + return v + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate energy source name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("type") + @classmethod + def validate_type(cls, v: str) -> EnergySourceType: + """Validate that type is a recognized EnergySourceType.""" + adapter_values = [adapter.value for adapter in EnergySourceType] + if v not in adapter_values: + raise ValueError(f"adapter_type must be one of {adapter_values}") + return EnergySourceType(v) + + @field_validator("energy_monitor_id") + @classmethod + def validate_energy_monitor_id(cls, v: Optional[str]) -> Optional[str]: + """Validate energy monitor ID.""" + if v is not None: + try: + EntityId(uuid.UUID(v)) + except ValueError as e: + raise ValueError(f"Invalid energy monitor id: {e}") from e + return v + + @field_validator("forecast_provider_id") + @classmethod + def validate_forecast_provider_id(cls, v: Optional[str]) -> Optional[str]: + """Validate forecast provider ID.""" + if v is not None: + try: + EntityId(uuid.UUID(v)) + except ValueError as e: + raise ValueError(f"Invalid forecast provider id: {e}") from e + return v + + @field_validator("nominal_power_max") + @classmethod + def validate_nominal_power_max(cls, v: Optional[float]) -> Optional[float]: + """Validate nominal power max is positive.""" + if v is not None and v < 0: + raise ValueError("Nominal power max must be zero or positive") + return v + + @field_validator("external_source") + @classmethod + def validate_external_source(cls, v: Optional[float]) -> Optional[float]: + """Validate external source power is positive.""" + if v is not None and v < 0: + raise ValueError("External source power must be zero or positive") + return v + + @classmethod + def from_model(cls, energy_source: EnergySource) -> "EnergySourceSchema": + """Create EnergySourceSchema from an EnergySource domain entity.""" + storage = None + if energy_source.storage: + storage = BatterySchema(nominal_capacity=float(energy_source.storage.nominal_capacity)) + grid = None + if energy_source.grid: + grid = GridSchema(contracted_power=float(energy_source.grid.contracted_power)) + return cls( + id=str(energy_source.id), + name=energy_source.name, + type=energy_source.type, + nominal_power_max=float(energy_source.nominal_power_max) if energy_source.nominal_power_max else None, + storage=storage, + grid=grid, + external_source=float(energy_source.external_source) if energy_source.external_source else None, + energy_monitor_id=str(energy_source.energy_monitor_id) if energy_source.energy_monitor_id else None, + forecast_provider_id=( + str(energy_source.forecast_provider_id) if energy_source.forecast_provider_id else None + ), + ) + + @field_serializer("id") + def serialize_id(self, value: str) -> str: + """Serialize EntityId to string.""" + return str(value) + + @field_serializer("energy_monitor_id") + def serialize_energy_monitor_id(self, value: Optional[str]) -> Optional[str]: + """Serialize energy monitor ID to string.""" + return str(value) if value else None + + @field_serializer("forecast_provider_id") + def serialize_forecast_provider_id(self, value: Optional[str]) -> Optional[str]: + """Serialize forecast provider ID to string.""" + return str(value) if value else None + + def to_model(self) -> EnergySource: + """Convert EnergySourceSchema back to EnergySource domain model instance.""" + return EnergySource( + id=EntityId(uuid.UUID(self.id)), + name=self.name, + type=self.type, + nominal_power_max=Watts(self.nominal_power_max) if self.nominal_power_max is not None else None, + storage=self.storage.to_model() if self.storage else None, + grid=self.grid.to_model() if self.grid else None, + external_source=Watts(self.external_source) if self.external_source is not None else None, + energy_monitor_id=EntityId(uuid.UUID(self.energy_monitor_id)) if self.energy_monitor_id else None, + forecast_provider_id=EntityId(uuid.UUID(self.forecast_provider_id)) if self.forecast_provider_id else None, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + arbitrary_types_allowed = True + json_encoders = { + uuid.UUID: str, + EnergySourceType: lambda v: v.value, + } + + +class EnergySourceCreateSchema(BaseModel): + """Schema for creating a new energy source.""" + + name: str = Field(default="", description="Energy source name") + type: EnergySourceType = Field(default=EnergySourceType.SOLAR, description="Type of energy source") + nominal_power_max: Optional[float] = Field(default=None, ge=0, description="Maximum nominal power in Watts") + storage: Optional[BatterySchema] = Field(default=None, description="Battery storage configuration") + grid: Optional[GridSchema] = Field(default=None, description="Grid connection configuration") + external_source: Optional[float] = Field(default=None, ge=0, description="External source power in Watts") + energy_monitor_id: Optional[str] = Field(default=None, description="ID of the associated energy monitor") + forecast_provider_id: Optional[str] = Field(default=None, description="ID of the associated forecast provider") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate energy source name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("type") + @classmethod + def validate_type(cls, v: str) -> EnergySourceType: + """Validate that type is a recognized EnergySourceType.""" + adapter_values = [adapter.value for adapter in EnergySourceType] + if v not in adapter_values: + raise ValueError(f"adapter_type must be one of {adapter_values}") + return EnergySourceType(v) + + @field_validator("energy_monitor_id") + @classmethod + def validate_energy_monitor_id(cls, v: Optional[str]) -> Optional[str]: + """Validate energy monitor ID.""" + if v is not None: + try: + EntityId(uuid.UUID(v)) + except ValueError as e: + raise ValueError(f"Invalid energy monitor id: {e}") from e + return v + + @field_validator("forecast_provider_id") + @classmethod + def validate_forecast_provider_id(cls, v: Optional[str]) -> Optional[str]: + """Validate forecast provider ID.""" + if v is not None: + try: + EntityId(uuid.UUID(v)) + except ValueError as e: + raise ValueError(f"Invalid forecast provider id: {e}") from e + return v + + @field_validator("nominal_power_max") + @classmethod + def validate_nominal_power_max(cls, v: Optional[float]) -> Optional[float]: + """Validate nominal power max is positive.""" + if v is not None and v < 0: + raise ValueError("Nominal power max must be zero or positive") + return v + + @field_validator("external_source") + @classmethod + def validate_external_source(cls, v: Optional[float]) -> Optional[float]: + """Validate external source power is positive.""" + if v is not None and v < 0: + raise ValueError("External source power must be zero or positive") + return v + + def to_model(self) -> EnergySource: + """Convert EnergySourceCreateSchema to an EnergySource domain model instance.""" + return EnergySource( + id=EntityId(uuid.uuid4()), + name=self.name, + type=self.type, + nominal_power_max=Watts(self.nominal_power_max) if self.nominal_power_max is not None else None, + storage=self.storage.to_model() if self.storage else None, + grid=self.grid.to_model() if self.grid else None, + external_source=Watts(self.external_source) if self.external_source is not None else None, + energy_monitor_id=EntityId(uuid.UUID(self.energy_monitor_id)) if self.energy_monitor_id else None, + forecast_provider_id=EntityId(uuid.UUID(self.forecast_provider_id)) if self.forecast_provider_id else None, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + json_encoders = { + uuid.UUID: str, + EnergySourceType: lambda v: v.value, + } + + +class EnergySourceUpdateSchema(BaseModel): + """Schema for updating an existing energy source.""" + + name: str = Field(default="", description="Energy source name") + type: EnergySourceType = Field(default=EnergySourceType.SOLAR, description="Type of energy source") + nominal_power_max: Optional[float] = Field(default=None, ge=0, description="Maximum nominal power in Watts") + storage: Optional[BatterySchema] = Field(default=None, description="Battery storage configuration") + grid: Optional[GridSchema] = Field(default=None, description="Grid connection configuration") + external_source: Optional[float] = Field(default=None, ge=0, description="External source power in Watts") + energy_monitor_id: Optional[str] = Field(default=None, description="ID of the associated energy monitor") + forecast_provider_id: Optional[str] = Field(default=None, description="ID of the associated forecast provider") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate energy source name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("energy_monitor_id") + @classmethod + def validate_energy_monitor_id(cls, v: Optional[str]) -> Optional[str]: + """Validate energy monitor ID.""" + if v is not None: + try: + EntityId(uuid.UUID(v)) + except ValueError as e: + raise ValueError(f"Invalid energy monitor id: {e}") from e + return v + + @field_validator("forecast_provider_id") + @classmethod + def validate_forecast_provider_id(cls, v: Optional[str]) -> Optional[str]: + """Validate forecast provider ID.""" + if v is not None: + try: + EntityId(uuid.UUID(v)) + except ValueError as e: + raise ValueError(f"Invalid forecast provider id: {e}") from e + return v + + @field_validator("nominal_power_max") + @classmethod + def validate_nominal_power_max(cls, v: Optional[float]) -> Optional[float]: + """Validate nominal power max is positive.""" + if v is not None and v < 0: + raise ValueError("Nominal power max must be zero or positive") + return v + + @field_validator("external_source") + @classmethod + def validate_external_source(cls, v: Optional[float]) -> Optional[float]: + """Validate external source power is positive.""" + if v is not None and v < 0: + raise ValueError("External source power must be zero or positive") + return v + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + json_encoders = { + uuid.UUID: str, + EnergySourceType: lambda v: v.value, + } + + +class EnergyMonitorSchema(BaseModel): + """Schema for EnergyMonitor entity with complete validation.""" + + id: str = Field(..., description="Unique identifier for the energy monitor") + name: str = Field(default="", description="Energy monitor name") + adapter_type: EnergyMonitorAdapter = Field( + default=EnergyMonitorAdapter.DUMMY_SOLAR, description="Type of energy monitor adapter" + ) + config: Optional[dict] = Field(default=None, description="Energy monitor configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @field_validator("id") + @classmethod + def validate_id(cls, v: str) -> str: + """Validate that the id is a valid EntityId.""" + try: + EntityId(uuid.UUID(v)) + except ValueError as e: + raise ValueError(f"Invalid energy monitor id: {e}") from e + return v + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate energy monitor name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("adapter_type") + @classmethod + def validate_adapter_type(cls, v: str) -> EnergyMonitorAdapter: + """Validate that adapter_type is a recognized EnergyMonitorAdapter.""" + adapter_values = [adapter.value for adapter in EnergyMonitorAdapter] + if v not in adapter_values: + raise ValueError(f"adapter_type must be one of {adapter_values}") + return EnergyMonitorAdapter(v) + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate external service ID.""" + if v is not None: + try: + EntityId(uuid.UUID(v)) + except ValueError as e: + raise ValueError(f"Invalid external service id: {e}") from e + return v + + @classmethod + def from_model(cls, energy_monitor: EnergyMonitor) -> "EnergyMonitorSchema": + """Create EnergyMonitorSchema from an EnergyMonitor domain entity.""" + return cls( + id=str(energy_monitor.id), + name=energy_monitor.name, + adapter_type=energy_monitor.adapter_type, + config=energy_monitor.config.to_dict() if energy_monitor.config else None, + external_service_id=str(energy_monitor.external_service_id) if energy_monitor.external_service_id else None, + ) + + @field_serializer("id") + def serialize_id(self, value: str) -> str: + """Serialize EntityId to string.""" + return str(value) + + @field_serializer("external_service_id") + def serialize_external_service_id(self, value: Optional[str]) -> Optional[str]: + """Serialize external service ID to string.""" + return str(value) if value else None + + def to_model(self) -> EnergyMonitor: + """Convert EnergyMonitorSchema to EnergyMonitor domain entity.""" + configuration: Optional[EnergyMonitorConfig] = None + if self.config: + config_class = ENERGY_MONITOR_CONFIG_TYPE_MAP.get(self.adapter_type, None) + if config_class: + configuration = cast(EnergyMonitorConfig, config_class.from_dict(self.config)) + + return EnergyMonitor( + id=EntityId(uuid.UUID(self.id)), + name=self.name, + adapter_type=self.adapter_type, + config=configuration, + external_service_id=EntityId(uuid.UUID(self.external_service_id)) if self.external_service_id else None, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + arbitrary_types_allowed = True + json_encoders = { + uuid.UUID: str, + EnergyMonitorAdapter: lambda v: v.value, + } + + +class EnergyMonitorCreateSchema(BaseModel): + """Schema for creating a new energy monitor.""" + + name: str = Field(default="", description="Energy monitor name") + adapter_type: EnergyMonitorAdapter = Field( + default=EnergyMonitorAdapter.DUMMY_SOLAR, description="Type of energy monitor adapter" + ) + config: Optional[dict] = Field(default=None, description="Energy monitor configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate energy monitor name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("adapter_type") + @classmethod + def validate_adapter_type(cls, v: str) -> EnergyMonitorAdapter: + """Validate that adapter_type is a recognized EnergyMonitorAdapter.""" + adapter_values = [adapter.value for adapter in EnergyMonitorAdapter] + if v not in adapter_values: + raise ValueError(f"adapter_type must be one of {adapter_values}") + return EnergyMonitorAdapter(v) + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate external service ID.""" + if v is not None: + try: + EntityId(uuid.UUID(v)) + except ValueError as e: + raise ValueError(f"Invalid external service id: {e}") from e + return v + + def to_model(self) -> EnergyMonitor: + """Convert EnergyMonitorCreateSchema to EnergyMonitor domain entity.""" + configuration: Optional[EnergyMonitorConfig] = None + if self.config: + config_class = ENERGY_MONITOR_CONFIG_TYPE_MAP.get(self.adapter_type, None) + if config_class: + configuration = cast(EnergyMonitorConfig, config_class.from_dict(self.config)) + else: + if self.adapter_type: + # If adapter type is provided but config is not, initialize with default config + config_class = ENERGY_MONITOR_CONFIG_TYPE_MAP.get(self.adapter_type, None) + if config_class: + configuration = cast(EnergyMonitorConfig, config_class()) + + return EnergyMonitor( + id=EntityId(uuid.uuid4()), + name=self.name, + adapter_type=self.adapter_type, + config=configuration, + external_service_id=EntityId(uuid.UUID(self.external_service_id)) if self.external_service_id else None, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + json_encoders = { + uuid.UUID: str, + EnergyMonitorAdapter: lambda v: v.value, + } + + +class EnergyMonitorUpdateSchema(BaseModel): + """Schema for updating an existing energy monitor.""" + + name: str = Field(default="", description="Energy monitor name") + config: Optional[dict] = Field(default=None, description="Energy monitor configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate energy monitor name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate external service ID.""" + if v is not None: + try: + EntityId(uuid.UUID(v)) + except ValueError as e: + raise ValueError(f"Invalid external service id: {e}") from e + return v + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + +class EnergyMonitorDummySolarConfigSchema(BaseModel): + """Schema for Dummy Solar EnergyMonitorConfig.""" + + max_consumption_power: float = Field(default=3200.0, description="Maximum consumption power in Watts") + + @field_validator("max_consumption_power") + @classmethod + def validate_max_consumption_power(cls, v: float) -> float: + """Validate max consumption power is positive.""" + if v < 0: + raise ValueError("Max consumption power must be zero or positive") + return v + + def to_model(self) -> EnergyMonitorDummySolarConfig: + """Convert schema to EnergyMonitorDummySolarConfig domain entity.""" + return EnergyMonitorDummySolarConfig( + max_consumption_power=Watts(self.max_consumption_power), + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + +class EnergyMonitorHomeAssistantConfigSchema(BaseModel): + """Schema for Home Assistant EnergyMonitorConfig.""" + + entity_production: str = Field(..., description="Home Assistant production entity") + entity_consumption: str = Field(..., description="Home Assistant consumption entity") + entity_grid: str = Field(default="", description="Home Assistant grid entity") + entity_battery_soc: str = Field(default="", description="Home Assistant battery SOC entity") + entity_battery_power: str = Field(default="", description="Home Assistant battery power entity") + entity_battery_remaining_capacity: str = Field( + default="", description="Home Assistant battery remaining capacity entity" + ) + unit_production: str = Field(default="W", description="Production unit") + unit_consumption: str = Field(default="W", description="Consumption unit") + unit_grid: str = Field(default="W", description="Grid unit") + unit_battery_power: str = Field(default="W", description="Battery power unit") + unit_battery_remaining_capacity: str = Field(default="Wh", description="Battery remaining capacity unit") + grid_positive_export: bool = Field(default=False, description="Grid positive export direction") + battery_positive_charge: bool = Field(default=True, description="Battery positive charge direction") + + @field_validator("entity_production", "entity_consumption") + @classmethod + def validate_required_entities(cls, v: str) -> str: + """Validate that required entities are not empty.""" + v = v.strip() + if not v: + raise ValueError("Required entity must not be empty") + return v + + def to_model(self) -> EnergyMonitorHomeAssistantConfig: + """Convert schema to EnergyMonitorHomeAssistantConfig domain entity.""" + return EnergyMonitorHomeAssistantConfig( + entity_production=self.entity_production, + entity_consumption=self.entity_consumption, + entity_grid=self.entity_grid, + entity_battery_soc=self.entity_battery_soc, + entity_battery_power=self.entity_battery_power, + entity_battery_remaining_capacity=self.entity_battery_remaining_capacity, + unit_production=self.unit_production, + unit_consumption=self.unit_consumption, + unit_grid=self.unit_grid, + unit_battery_power=self.unit_battery_power, + unit_battery_remaining_capacity=self.unit_battery_remaining_capacity, + grid_positive_export=self.grid_positive_export, + battery_positive_charge=self.battery_positive_charge, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + +ENERGY_MONITOR_CONFIG_SCHEMA_MAP: Dict[ + type[EnergyMonitorConfig], + Union[type[EnergyMonitorDummySolarConfigSchema], type[EnergyMonitorHomeAssistantConfigSchema]], +] = { + EnergyMonitorDummySolarConfig: EnergyMonitorDummySolarConfigSchema, + EnergyMonitorHomeAssistantConfig: EnergyMonitorHomeAssistantConfigSchema, +} diff --git a/core/edge_mining/adapters/domain/energy/tables.py b/core/edge_mining/adapters/domain/energy/tables.py new file mode 100644 index 0000000..b593d26 --- /dev/null +++ b/core/edge_mining/adapters/domain/energy/tables.py @@ -0,0 +1,366 @@ +"""SQLAlchemy ORM mappings for Energy domain entities. + +This module implements imperative (classical) mapping of the domain entities +to database tables. The domain entities are mapped directly without +creating separate ORM model classes, maintaining domain purity. + +The mappings handle value objects (Battery, Grid, Watts) using SQLAlchemy event listeners +to convert between domain objects and database columns. Complex value objects (Battery, Grid) +are stored as JSON for flexibility, while simple value objects (Watts) are stored as floats. + +All tables and mappings use the shared metadata and mapper registry from +the sqlalchemy.registry module, which are available as module-level singletons. + +⚠️ DEVELOPER WARNING ⚠️ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +ANY SCHEMA CHANGE (adding/removing/modifying tables or columns) REQUIRES an +Alembic migration. Do NOT modify this file without creating a migration: + + python scripts/migrate.py create "Description of your change" + +For detailed instructions, see: ../docs/ALEMBIC_MIGRATIONS.md +For a step-by-step example, see: ../docs/MIGRATION_EXAMPLE.md +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +""" + +import json +import uuid +from typing import Any, Optional + +from sqlalchemy import JSON, Column, Float, ForeignKey, String, Table, event + +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.common import ConfigurationType +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.registry import mapper_registry, metadata +from edge_mining.domain.common import EntityId, WattHours, Watts +from edge_mining.domain.energy.common import EnergyMonitorAdapter, EnergySourceType +from edge_mining.domain.energy.entities import EnergyMonitor, EnergySource +from edge_mining.domain.energy.exceptions import EnergyMonitorConfigurationError +from edge_mining.domain.energy.value_objects import Battery, Grid +from edge_mining.shared.adapter_maps.energy import ENERGY_MONITOR_CONFIG_TYPE_MAP +from edge_mining.shared.interfaces.config import EnergyMonitorConfig + + +class EnergyMonitorConfigType(ConfigurationType): + """SQLAlchemy type for EnergyMonitorConfig serialization. + + Inherits from ConfigurationType to handle JSON serialization/deserialization. + """ + + +def _deserialize_energy_monitor_config( + adapter_type: EnergyMonitorAdapter, config_json: str +) -> Optional[EnergyMonitorConfig]: + """Deserialize JSON string to EnergyMonitorConfig based on adapter type. + + Args: + adapter_type: The type of energy monitor adapter + config_json: JSON string representation of config + + Returns: + EnergyMonitorConfig instance or None + """ + if not config_json: + return None + + data: dict = json.loads(config_json) + + if adapter_type not in ENERGY_MONITOR_CONFIG_TYPE_MAP: + raise EnergyMonitorConfigurationError( + f"Error reading EnergyMonitor configuration. Invalid type '{adapter_type}'" + ) + + config_class: Optional[type[EnergyMonitorConfig]] = ENERGY_MONITOR_CONFIG_TYPE_MAP.get(adapter_type) + if not config_class: + raise EnergyMonitorConfigurationError(f"Error creating EnergyMonitor configuration. Type '{adapter_type}'") + + config_instance = config_class.from_dict(data) + if not isinstance(config_instance, EnergyMonitorConfig): + raise EnergyMonitorConfigurationError( + f"Deserialized config is not of type EnergyMonitorConfig for adapter type {adapter_type}." + ) + return config_instance + + +@event.listens_for(EnergyMonitor, "load") +def _receive_energy_monitor_load(target: EnergyMonitor, context) -> None: + """Event listener that deserializes config after loading from database. + + Args: + target: The EnergyMonitor instance being loaded + context: SQLAlchemy context + """ + # Convert id string to EntityId if needed + if hasattr(target, "id") and target.id is not None: + if isinstance(target.id, str): # type: ignore[arg-type,misc] + target.id = EntityId(uuid.UUID(target.id)) # type: ignore[assignment] + + # Convert foreign keys to EntityId + # NOTE: SQLAlchemy returns strings for UUID columns that need conversion to EntityId + if hasattr(target, "external_service_id") and target.external_service_id is not None: + if isinstance(target.external_service_id, str): # type: ignore + target.external_service_id = EntityId(uuid.UUID(target.external_service_id)) # type: ignore + + # Convert adapter_type string to enum if needed + if isinstance(target.adapter_type, str): + try: + target.adapter_type = EnergyMonitorAdapter(target.adapter_type) + except ValueError: + # If conversion fails, leave as string (will fail in config deserialization) + pass + + if target.config and isinstance(target.config, str): + target.config = _deserialize_energy_monitor_config(target.adapter_type, target.config) + + +@event.listens_for(EnergyMonitor, "before_insert") +@event.listens_for(EnergyMonitor, "before_update") +def _flatten_energy_monitor_composites(mapper, connection, target: Any) -> None: + """Event listener that flattens value objects before persisting. + + Args: + mapper: SQLAlchemy mapper + connection: Database connection + target: The EnergyMonitor instance being persisted + """ + # Convert EnergyMonitorAdapter enum to string + if hasattr(target, "adapter_type") and target.adapter_type is not None: + if isinstance(target.adapter_type, EnergyMonitorAdapter): + target.adapter_type = target.adapter_type.value + + +@event.listens_for(EnergyMonitor, "after_insert") +@event.listens_for(EnergyMonitor, "after_update") +def _restore_energy_monitor_composites(mapper, connection, target: Any) -> None: + """Event listener that restores value objects after persisting. + + Args: + mapper: SQLAlchemy mapper + connection: Database connection + target: The EnergyMonitor instance that was persisted + """ + # Restore id to EntityId if it was converted to string + # NOTE: After SQLAlchemy flush, IDs may be strings and need restoration to EntityId + if hasattr(target, "id") and target.id is not None: + if isinstance(target.id, str): # type: ignore + target.id = EntityId(uuid.UUID(target.id)) # type: ignore + + # Restore external_service_id to EntityId if needed + if hasattr(target, "external_service_id") and target.external_service_id is not None: + if isinstance(target.external_service_id, str): # type: ignore + target.external_service_id = EntityId(uuid.UUID(target.external_service_id)) # type: ignore + + # Restore adapter_type to enum + if hasattr(target, "adapter_type") and target.adapter_type is not None: + if isinstance(target.adapter_type, str): + try: + target.adapter_type = EnergyMonitorAdapter(target.adapter_type) + except ValueError: + pass + + +# Define the energy_monitors table using imperative style +energy_monitors_table = Table( + "energy_monitors", + metadata, + # Primary Key + Column("id", String, primary_key=True, index=True), + # Basic attributes + Column("name", String, nullable=False), + Column("adapter_type", String, nullable=False, default="DUMMY_SOLAR"), + # Config stored as JSON string with automatic conversion + Column("config", EnergyMonitorConfigType, nullable=True), + # External service reference + Column("external_service_id", String, ForeignKey("external_services.id"), nullable=True), +) + +# Define the energy_sources table using imperative style +energy_sources_table = Table( + "energy_sources", + metadata, + # Primary Key + Column("id", String, primary_key=True, index=True), + # Basic attributes + Column("name", String, nullable=False), + Column("type", String, nullable=False, default="SOLAR"), + # Watts value objects stored as float + Column("nominal_power_max", Float, nullable=True), + # Battery Value Object stored as JSON + Column("storage", JSON, nullable=True), + # Grid Value Object stored as JSON + Column("grid", JSON, nullable=True), + # External source power (Watts stored as float) + Column("external_source", Float, nullable=True), + # Foreign Keys to other entities + Column("energy_monitor_id", String, ForeignKey("energy_monitors.id"), nullable=True), + Column("forecast_provider_id", String, ForeignKey("forecast_providers.id"), nullable=True), +) + +# Map EnergyMonitor first (parent in some relationships) +mapper_registry.map_imperatively( + EnergyMonitor, + energy_monitors_table, +) + +# Map EnergySource - use event listeners for all value object conversions +mapper_registry.map_imperatively( + EnergySource, + energy_sources_table, + # Don't exclude properties - let SQLAlchemy load them, then convert in event listener +) + + +# Event listeners for value object conversions +@event.listens_for(EnergySource, "load") +def _receive_energy_source_load(target: EnergySource, context) -> None: + """Event listener that reconstructs value objects after loading. + + Args: + target: The EnergySource instance being loaded + context: SQLAlchemy context + """ + # SQLAlchemy will load the raw column values as floats/strings + # We need to convert them to proper value objects + + # Convert id string to EntityId if needed + # NOTE: Type checker marks this as unreachable because EntityId is typed as UUID, + # but SQLAlchemy can return strings from the database that need conversion + if hasattr(target, "id") and target.id is not None: + if isinstance(target.id, str): # type: ignore + target.id = EntityId(uuid.UUID(target.id)) # type: ignore + if isinstance(target.id, str): # type: ignore + target.id = EntityId(uuid.UUID(target.id)) # type: ignore + + # Convert foreign keys to EntityId + # NOTE: SQLAlchemy returns strings for UUID columns that need conversion to EntityId + if hasattr(target, "energy_monitor_id") and target.energy_monitor_id is not None: + if isinstance(target.energy_monitor_id, str): # type: ignore + target.energy_monitor_id = EntityId(uuid.UUID(target.energy_monitor_id)) # type: ignore + + if hasattr(target, "forecast_provider_id") and target.forecast_provider_id is not None: + if isinstance(target.forecast_provider_id, str): # type: ignore + target.forecast_provider_id = EntityId(uuid.UUID(target.forecast_provider_id)) # type: ignore + + # Convert type string to enum if needed + if hasattr(target, "type") and target.type is not None: + if isinstance(target.type, str): + try: + target.type = EnergySourceType(target.type) + except ValueError: + # If conversion fails, leave as string + pass + + # Convert nominal_power_max to Watts (loaded as float) + if hasattr(target, "nominal_power_max") and target.nominal_power_max is not None: + if not isinstance(target.nominal_power_max, type(Watts(0.0))): + target.nominal_power_max = Watts(float(target.nominal_power_max)) + + # Convert external_source to Watts (loaded as float) + if hasattr(target, "external_source") and target.external_source is not None: + if not isinstance(target.external_source, type(Watts(0.0))): + target.external_source = Watts(float(target.external_source)) + + # Reconstruct Battery from JSON (loaded as dict) + if hasattr(target, "storage") and target.storage is not None: + if isinstance(target.storage, dict): + target.storage = Battery(nominal_capacity=WattHours(target.storage["nominal_capacity"])) + elif isinstance(target.storage, str): + # Handle case where it's still a JSON string + storage_data = json.loads(target.storage) + target.storage = Battery(nominal_capacity=WattHours(storage_data["nominal_capacity"])) + + # Reconstruct Grid from JSON (loaded as dict) + if hasattr(target, "grid") and target.grid is not None: + if isinstance(target.grid, dict): + target.grid = Grid(contracted_power=Watts(target.grid["contracted_power"])) + elif isinstance(target.grid, str): + # Handle case where it's still a JSON string + grid_data = json.loads(target.grid) + target.grid = Grid(contracted_power=Watts(grid_data["contracted_power"])) + + +@event.listens_for(EnergySource, "before_insert") +@event.listens_for(EnergySource, "before_update") +def _flatten_energy_source_composites(mapper, connection, target: Any) -> None: + """Event listener that flattens value objects before persisting. + + Args: + mapper: SQLAlchemy mapper + connection: Database connection + target: The EnergySource instance being persisted + """ + # Convert EnergySourceType enum to string + if hasattr(target, "type") and target.type is not None: + if isinstance(target.type, EnergySourceType): + target.type = target.type.value + + # Flatten nominal_power_max (Watts) to float + if hasattr(target, "nominal_power_max") and target.nominal_power_max is not None: + target.nominal_power_max = float(target.nominal_power_max) + + # Flatten external_source (Watts) to float + if hasattr(target, "external_source") and target.external_source is not None: + target.external_source = float(target.external_source) + + # Serialize Battery to JSON + if hasattr(target, "storage") and target.storage is not None: + if not isinstance(target.storage, (dict, str)): + target.storage = {"nominal_capacity": float(target.storage.nominal_capacity)} + + # Serialize Grid to JSON + if hasattr(target, "grid") and target.grid is not None: + if not isinstance(target.grid, (dict, str)): + target.grid = {"contracted_power": float(target.grid.contracted_power)} + + +@event.listens_for(EnergySource, "after_insert") +@event.listens_for(EnergySource, "after_update") +def _restore_energy_source_composites(mapper, connection, target: Any) -> None: + """Event listener that restores value objects after persisting. + + Args: + mapper: SQLAlchemy mapper + connection: Database connection + target: The EnergySource instance that was persisted + """ + # Restore id to EntityId if it was converted to string + # NOTE: After SQLAlchemy flush, IDs may be strings and need restoration to EntityId + if hasattr(target, "id") and target.id is not None: + if isinstance(target.id, str): # type: ignore + target.id = EntityId(uuid.UUID(target.id)) # type: ignore + + # Restore foreign keys to EntityId + # NOTE: Foreign key UUIDs may be strings after persist and need conversion + if hasattr(target, "energy_monitor_id") and target.energy_monitor_id is not None: + if isinstance(target.energy_monitor_id, str): # type: ignore + target.energy_monitor_id = EntityId(uuid.UUID(target.energy_monitor_id)) # type: ignore + + if hasattr(target, "forecast_provider_id") and target.forecast_provider_id is not None: + if isinstance(target.forecast_provider_id, str): # type: ignore + target.forecast_provider_id = EntityId(uuid.UUID(target.forecast_provider_id)) # type: ignore + + # Restore type to enum + if hasattr(target, "type") and target.type is not None: + if isinstance(target.type, str): + try: + target.type = EnergySourceType(target.type) + except ValueError: + pass + + # Restore Watts values + if hasattr(target, "nominal_power_max") and target.nominal_power_max is not None: + if not isinstance(target.nominal_power_max, type(Watts(0.0))): + target.nominal_power_max = Watts(float(target.nominal_power_max)) + + if hasattr(target, "external_source") and target.external_source is not None: + if not isinstance(target.external_source, type(Watts(0.0))): + target.external_source = Watts(float(target.external_source)) + + # Restore Battery from dict + if hasattr(target, "storage") and target.storage is not None: + if isinstance(target.storage, dict): + target.storage = Battery(nominal_capacity=WattHours(target.storage["nominal_capacity"])) + + # Restore Grid from dict + if hasattr(target, "grid") and target.grid is not None: + if isinstance(target.grid, dict): + target.grid = Grid(contracted_power=Watts(target.grid["contracted_power"])) diff --git a/core/edge_mining/adapters/domain/energy/websocket/__init__.py b/core/edge_mining/adapters/domain/energy/websocket/__init__.py new file mode 100644 index 0000000..15f904b --- /dev/null +++ b/core/edge_mining/adapters/domain/energy/websocket/__init__.py @@ -0,0 +1 @@ +"""WebSocket adapter for the Energy domain.""" diff --git a/core/edge_mining/adapters/domain/energy/websocket/handlers.py b/core/edge_mining/adapters/domain/energy/websocket/handlers.py new file mode 100644 index 0000000..7f90880 --- /dev/null +++ b/core/edge_mining/adapters/domain/energy/websocket/handlers.py @@ -0,0 +1,40 @@ +"""WebSocket event handler for the Energy domain.""" + +from typing import Any, List + +from edge_mining.adapters.domain.energy.schemas import EnergyStateSnapshotSchema +from edge_mining.adapters.domain.energy.websocket.schemas import EnergyStateSnapshotUpdatedSchema +from edge_mining.adapters.infrastructure.websocket.utils import ( + WebSocketEventHandler, + WebSocketEventRegistration, +) +from edge_mining.domain.common import DomainEvent +from edge_mining.domain.energy.events import EnergyStateSnapshotUpdatedEvent + + +class EnergyWebSocketHandler(WebSocketEventHandler): + """Serializes Energy domain events for WebSocket broadcasting.""" + + @property + def registrations(self) -> List[WebSocketEventRegistration]: + return [ + WebSocketEventRegistration( + event_type=EnergyStateSnapshotUpdatedEvent, + topic="energy.state", + serialize=self._serialize_energy_state_snapshot_updated, + ), + ] + + def _serialize_energy_state_snapshot_updated(self, event: DomainEvent) -> dict[str, Any]: + assert isinstance(event, EnergyStateSnapshotUpdatedEvent) + payload = EnergyStateSnapshotUpdatedSchema( + optimization_unit_id=str(event.optimization_unit_id) if event.optimization_unit_id else None, + optimization_unit_name=event.optimization_unit_name, + energy_source_id=str(event.energy_source_id) if event.energy_source_id else None, + energy_state_snapshot=( + EnergyStateSnapshotSchema.from_model(event.energy_state_snapshot) + if event.energy_state_snapshot + else None + ), + ) + return payload.model_dump(mode="json") diff --git a/core/edge_mining/adapters/domain/energy/websocket/schemas.py b/core/edge_mining/adapters/domain/energy/websocket/schemas.py new file mode 100644 index 0000000..5ea3550 --- /dev/null +++ b/core/edge_mining/adapters/domain/energy/websocket/schemas.py @@ -0,0 +1,16 @@ +"""WebSocket event schemas for the Energy domain.""" + +from typing import Optional + +from pydantic import BaseModel, Field + +from edge_mining.adapters.domain.energy.schemas import EnergyStateSnapshotSchema + + +class EnergyStateSnapshotUpdatedSchema(BaseModel): + """WebSocket schema for EnergyStateSnapshotUpdatedEvent.""" + + optimization_unit_id: Optional[str] = Field(None, description="ID of the optimization unit") + optimization_unit_name: str = Field(default="", description="Name of the optimization unit") + energy_source_id: Optional[str] = Field(None, description="ID of the energy source") + energy_state_snapshot: Optional[EnergyStateSnapshotSchema] = Field(None, description="Energy state snapshot data") diff --git a/core/edge_mining/adapters/domain/forecast/__init__.py b/core/edge_mining/adapters/domain/forecast/__init__.py new file mode 100644 index 0000000..c6ad189 --- /dev/null +++ b/core/edge_mining/adapters/domain/forecast/__init__.py @@ -0,0 +1 @@ +"""Adapters for the Forecast domain.""" diff --git a/core/edge_mining/adapters/domain/forecast/cli/__init__.py b/core/edge_mining/adapters/domain/forecast/cli/__init__.py new file mode 100644 index 0000000..3950650 --- /dev/null +++ b/core/edge_mining/adapters/domain/forecast/cli/__init__.py @@ -0,0 +1 @@ +"""Adapters CLI for the energy forecast domain.""" diff --git a/core/edge_mining/adapters/domain/forecast/cli/commands.py b/core/edge_mining/adapters/domain/forecast/cli/commands.py new file mode 100644 index 0000000..627eee5 --- /dev/null +++ b/core/edge_mining/adapters/domain/forecast/cli/commands.py @@ -0,0 +1,596 @@ +"""CLI commands for the energy forecast domain.""" + +from typing import List, Optional + +import click + +from edge_mining.adapters.infrastructure.cli.utils import print_configuration, process_filters +from edge_mining.adapters.infrastructure.external_services.cli.commands import ( + print_external_service_details, + select_external_service, +) +from edge_mining.application.interfaces import ConfigurationServiceInterface +from edge_mining.domain.common import EntityId +from edge_mining.domain.forecast.common import ForecastProviderAdapter +from edge_mining.domain.forecast.entities import ForecastProvider +from edge_mining.shared.adapter_configs.forecast import ( + ForecastProviderDummySolarConfig, + ForecastProviderHomeAssistantConfig, +) +from edge_mining.shared.adapter_maps.forecast import FORECAST_PROVIDER_TYPE_EXTERNAL_SERVICE_MAP +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.external_services.entities import ExternalService +from edge_mining.shared.interfaces.config import ForecastProviderConfig +from edge_mining.shared.logging.port import LoggerPort + +from edge_mining.adapters.utils import run_async_func + + +def select_forecast_provider_adapter() -> Optional[ForecastProviderAdapter]: + """Select a forecast provider adapter from the available options.""" + click.echo("Select a Forecast Provider Adapter:") + for idx, adapter in enumerate(ForecastProviderAdapter): + click.echo(f"{idx}. " + click.style(f"{adapter.name}", fg="blue")) + + click.echo("") + choice: str = click.prompt("Choose a forecast provider adapter", type=str, default="") + choice = choice.strip().lower() + + if not choice.isdigit() or int(choice) < 0 or int(choice) >= len(ForecastProviderAdapter): + click.echo(click.style("Invalid index. Aborting selection.", fg="red")) + return None + + adapter_type_values = [adapter.value for adapter in ForecastProviderAdapter] + + selected_adapter = ForecastProviderAdapter(adapter_type_values[int(choice)]) + return selected_adapter + + +def handle_forecast_provider_dummy_config() -> ForecastProviderConfig: + """Handle the configuration for the dummy forecast provider.""" + click.echo(click.style("\n--- Dummy Forecast Configuration ---", fg="yellow")) + + latitude: float = click.prompt("Latitude", type=float, default=41.90) + longitude: float = click.prompt("Longitude", type=float, default=12.49) + capacity_kwp: float = click.prompt("Capacity (kWp)", type=float, default=0.0) + efficiency_percent: float = click.prompt("Efficiency (%)", type=float, default=80.0) + production_start_hour: int = click.prompt("Production Start Hour (0-23)", type=int, default=6) + production_end_hour: int = click.prompt("Production End Hour (0-23)", type=int, default=20) + + return ForecastProviderDummySolarConfig( + latitude=latitude, + longitude=longitude, + capacity_kwp=capacity_kwp, + efficiency_percent=efficiency_percent, + production_start_hour=production_start_hour, + production_end_hour=production_end_hour, + ) + + +def handle_forecast_provider_home_assistant_api_config() -> ForecastProviderConfig: + """Handle the configuration for the Home Assistant API forecast provider.""" + click.echo(click.style("\n--- Home Assistant API Configuration ---", fg="yellow")) + + entity_forecast_power_actual_h: str = click.prompt("Entity Forecast Power Actual (h)", type=str, default="") + entity_forecast_power_next_1h: str = click.prompt("Entity Forecast Power Next 1h", type=str, default="") + entity_forecast_power_next_12h: str = click.prompt("Entity Forecast Power Next 12h", type=str, default="") + entity_forecast_power_next_24h: str = click.prompt("Entity Forecast Power Next 24h", type=str, default="") + entity_forecast_energy_actual_h: str = click.prompt("Entity Forecast Energy Actual (h)", type=str, default="") + entity_forecast_energy_next_1h: str = click.prompt("Entity Forecast Energy Next 1h", type=str, default="") + entity_forecast_energy_today: str = click.prompt("Entity Forecast Energy Today", type=str, default="") + entity_forecast_energy_tomorrow: str = click.prompt("Entity Forecast Energy Tomorrow", type=str, default="") + entity_forecast_energy_remaining_today: str = click.prompt( + "Entity Forecast Energy Remaining Today", type=str, default="" + ) + + unit_forecast_power_actual_h: str = click.prompt("Unit Forecast Power Actual (h)", type=str, default="W") + unit_forecast_power_next_1h: str = click.prompt("Unit Forecast Power Next 1h", type=str, default="W") + unit_forecast_power_next_12h: str = click.prompt("Unit Forecast Power Next 12h", type=str, default="W") + unit_forecast_power_next_24h: str = click.prompt("Unit Forecast Power Next 24h", type=str, default="W") + unit_forecast_energy_actual_h: str = click.prompt("Unit Forecast Energy Actual (h)", type=str, default="kWh") + unit_forecast_energy_next_1h: str = click.prompt("Unit Forecast Energy Next 1h", type=str, default="kWh") + unit_forecast_energy_today: str = click.prompt("Unit Forecast Energy Today", type=str, default="kWh") + unit_forecast_energy_tomorrow: str = click.prompt("Unit Forecast Energy Tomorrow", type=str, default="kWh") + unit_forecast_energy_remaining_today: str = click.prompt( + "Unit Forecast Energy Remaining Today", type=str, default="kWh" + ) + return ForecastProviderHomeAssistantConfig( + entity_forecast_power_actual_h=entity_forecast_power_actual_h, + entity_forecast_power_next_1h=entity_forecast_power_next_1h, + entity_forecast_power_next_12h=entity_forecast_power_next_12h, + entity_forecast_power_next_24h=entity_forecast_power_next_24h, + entity_forecast_energy_actual_h=entity_forecast_energy_actual_h, + entity_forecast_energy_next_1h=entity_forecast_energy_next_1h, + entity_forecast_energy_today=entity_forecast_energy_today, + entity_forecast_energy_tomorrow=entity_forecast_energy_tomorrow, + entity_forecast_energy_remaining_today=entity_forecast_energy_remaining_today, + unit_forecast_power_actual_h=unit_forecast_power_actual_h, + unit_forecast_power_next_1h=unit_forecast_power_next_1h, + unit_forecast_power_next_12h=unit_forecast_power_next_12h, + unit_forecast_power_next_24h=unit_forecast_power_next_24h, + unit_forecast_energy_actual_h=unit_forecast_energy_actual_h, + unit_forecast_energy_next_1h=unit_forecast_energy_next_1h, + unit_forecast_energy_today=unit_forecast_energy_today, + unit_forecast_energy_tomorrow=unit_forecast_energy_tomorrow, + unit_forecast_energy_remaining_today=unit_forecast_energy_remaining_today, + ) + + +def handle_forecast_provider_configuration( + adapter_type: ForecastProviderAdapter, +) -> Optional[ForecastProviderConfig]: + """Handle the configuration of a forecast provider based on the selected adapter type.""" + if adapter_type.value == ForecastProviderAdapter.DUMMY_SOLAR.value: + return handle_forecast_provider_dummy_config() + elif adapter_type.value == ForecastProviderAdapter.HOME_ASSISTANT_API.value: + return handle_forecast_provider_home_assistant_api_config() + else: + click.echo(click.style("Unsupported forecast provider adapter type.", fg="red")) + return None + + +def handle_add_forecast_provider( + configuration_service: ConfigurationServiceInterface, logger: LoggerPort +) -> Optional[ForecastProvider]: + """Menu to add a new forecast provider.""" + click.echo(click.style("\n--- Add Forecast Provider ---", fg="yellow")) + + name: str = click.prompt("Name of the forecast provider", type=str) + adapter_type: Optional[ForecastProviderAdapter] = select_forecast_provider_adapter() + + if adapter_type is None: + click.echo( + click.style( + "Invalid forecast provider adapter type selected. Aborting.", + fg="red", + ) + ) + return None + + config: Optional[ForecastProviderConfig] = handle_forecast_provider_configuration(adapter_type=adapter_type) + + if config is None: + click.echo(click.style("Invalid configuration. Aborting.", fg="red")) + return None + + external_service_id: Optional[EntityId] = None + if adapter_type != ForecastProviderAdapter.DUMMY_SOLAR: + adapter_type_filter = FORECAST_PROVIDER_TYPE_EXTERNAL_SERVICE_MAP.get(adapter_type, None) + external_service: Optional[ExternalService] = select_external_service( + configuration_service=configuration_service, + logger=logger, + filter_type=adapter_type_filter, + ) + external_service_id = external_service.id if external_service else None + + added: Optional[ForecastProvider] = None + try: + added = run_async_func( + configuration_service.create_forecast_provider( + name=name, + adapter_type=adapter_type, + config=config, + external_service_id=external_service_id, + ) + ) + click.echo( + click.style( + f"Forecast Provider '{added.name}' successfully added (ID: {added.id}).", + fg="green", + ) + ) + except Exception as e: + logger.error(f"Error adding forecast provider: {e}") + click.echo(click.style(f"Error: {e}", fg="red"), err=True) + click.pause("Press any key to return to the menu...") + return added + + +def handle_list_forecast_providers(configuration_service: ConfigurationServiceInterface, logger: LoggerPort) -> None: + """List all forecast providers.""" + click.echo(click.style("\n--- List Forecast Providers ---", fg="yellow")) + + forecast_providers: List[ForecastProvider] = configuration_service.list_forecast_providers() + if not forecast_providers: + click.echo(click.style("No forecast providers found.", fg="yellow")) + else: + for provider in forecast_providers: + click.echo( + "-> " + + "Name: " + + click.style(f"{provider.name}, ", fg="blue") + + "ID: " + + click.style(f"{provider.id}, ", fg="yellow") + + "Type: " + + click.style(f"{provider.adapter_type.name}", fg="green") + ) + + click.echo("") + click.pause("Press any key to return to the menu...") + + +def select_forecast_providers( + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, + default_id: Optional[EntityId] = None, + filter_type: Optional[List[ForecastProviderAdapter]] = None, + filter_config: Optional[List[ForecastProviderConfig]] = None, +) -> Optional[ForecastProvider]: + """Select a forecast provider from the list.""" + click.echo(click.style("\n--- Select Forecast Provider ---", fg="yellow")) + + forecast_providers: List[ForecastProvider] = configuration_service.list_forecast_providers() + + filter_type = process_filters(filter_type) + + if filter_type: + click.echo( + "Filtering forecast providers by types: " + + click.style(f"{', '.join([t.name for t in filter_type])}", fg="blue") + ) + forecast_providers = [fp for fp in forecast_providers if fp.adapter_type in filter_type] + + filter_config = process_filters(filter_config) + if filter_config: + click.echo( + "Filtering forecast providers by config: " + + click.style(f"{', '.join([type(c).__name__ for c in filter_config])}", fg="blue") + ) + filtered_forecast_providers: List[ForecastProvider] = [] + for fp in forecast_providers: + for filtered_config_class in filter_config: + if isinstance(fp.config, type(filtered_config_class)): + filtered_forecast_providers.append(fp) + forecast_providers = filtered_forecast_providers + + if not forecast_providers: + click.echo(click.style("No forecast providers configured.", fg="yellow")) + click.pause("Press any key to return to the menu...") + return None + + default_idx = "" + for idx, fp in enumerate(forecast_providers): + click.echo( + f"{idx}. " + + "Name: " + + click.style(f"{fp.name}, ", fg="blue") + + "ID: " + + click.style(f"{fp.id}, ", fg="yellow") + + "Type: " + + click.style(f"{fp.adapter_type.name}", fg="green") + ) + + if default_id: + if fp.id == default_id: + default_idx = str(idx) + + click.echo("\nb. Back to menu\n") + + fp_idx: str = click.prompt("Choose a Forecast Provider index", type=str, default=default_idx) + fp_idx = fp_idx.strip().lower() + + if fp_idx == "b": + return None + + if not fp_idx.isdigit() or int(fp_idx) < 0 or int(fp_idx) >= len(forecast_providers): + click.echo(click.style("Invalid index. Aborting selection.", fg="red")) + return None + + selected_fp = forecast_providers[int(fp_idx)] + return selected_fp + + +def print_forecast_provider_config( + forecast_provider: ForecastProvider, +) -> None: + """Print the configuration of a forecast provider.""" + configuration_class = forecast_provider.config.__class__.__name__ if forecast_provider.config else "---" + click.echo("| Configuration: " + click.style(f"{configuration_class}", fg="cyan")) + if forecast_provider.config: + print_configuration(forecast_provider.config.to_dict()) + + +def print_forecast_provider_details( + forecast_provider: ForecastProvider, + configuration_service: ConfigurationServiceInterface, + show_energy_source_list: bool = False, + show_external_service: bool = False, +) -> None: + """Print the details of a forecast provider.""" + click.echo("") + click.echo("| Name: " + click.style(forecast_provider.name, fg="blue")) + click.echo("| ID: " + click.style(forecast_provider.id, fg="yellow")) + click.echo("| Adapter: " + click.style(forecast_provider.adapter_type.name, fg="green")) + click.echo("| External Service ID: " + click.style(forecast_provider.external_service_id or "---", fg="magenta")) + print_forecast_provider_config(forecast_provider) + click.echo("") + + if show_energy_source_list: + energy_sources = configuration_service.list_energy_sources_by_forecast_provider(forecast_provider.id) + if not energy_sources: + click.echo( + click.style( + "No energy sources assigned to this forecast provider.", + fg="yellow", + ) + ) + else: + click.echo("Energy sources assigned to this forecast provider:") + for es in energy_sources: + click.echo( + "-> " + + "Name: " + + click.style(f"{es.name}, ", fg="blue") + + "ID: " + + click.style(f"{es.id}, ", fg="yellow") + + "Type: " + + click.style(f"{es.type.name}", fg="green") + ) + click.echo("") + + if show_external_service: + if forecast_provider.external_service_id: + external_service = configuration_service.get_external_service(forecast_provider.external_service_id) + if external_service: + click.echo("External Service:") + click.echo("| Name: " + click.style(external_service.name, fg="blue")) + click.echo("| ID: " + click.style(external_service.id, fg="yellow")) + click.echo("| Adapter: " + click.style(external_service.adapter_type.name, fg="green")) + click.echo(f"| Name: {external_service.name} (ID: {external_service.id})") + click.echo("") + + +def update_single_forecast_provider( + forecast_provider: ForecastProvider, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> Optional[ForecastProvider]: + """Update a single forecast provider.""" + click.echo(click.style("\n--- Update Forecast Provider ---", fg="yellow")) + name: str = click.prompt( + "New name for the forecast provider", + type=str, + default=forecast_provider.name, + ) + + config: Optional[ForecastProviderConfig] = handle_forecast_provider_configuration( + adapter_type=forecast_provider.adapter_type + ) + + if config is None: + click.echo(click.style("Invalid configuration. Aborting.", fg="red")) + return None + + external_service_id: Optional[EntityId] = forecast_provider.external_service_id + needed_external_service_type: Optional[ExternalServiceAdapter] = FORECAST_PROVIDER_TYPE_EXTERNAL_SERVICE_MAP.get( + forecast_provider.adapter_type, None + ) + + # Ask to change the external service + change_external_service: bool = False + remove_external_service: bool = False + if external_service_id: + actual_external_service: Optional[ExternalService] = configuration_service.get_external_service( + external_service_id + ) + if actual_external_service: + click.echo("\nCurrent external service: ") + print_external_service_details( + service=actual_external_service, + configuration_service=configuration_service, + show_linked_instances=False, + ) + else: + click.echo( + click.style( + "\nCurrent external service not found. It might have been deleted.", + fg="yellow", + ) + ) + + # An external service is set but the forecast provider does not require it + if not needed_external_service_type: + click.echo( + click.style( + "\nThis forecast provider does not require an external service. Do you want to remove it?", + fg="yellow", + ) + ) + remove_external_service = click.confirm("Remove external service", default=False, prompt_suffix="") + if remove_external_service: + external_service_id = None + click.echo(click.style("External service removed.", fg="green")) + else: + click.echo( + click.style( + "\nDo you want to change the external service for this forecast provider?", + fg="yellow", + ) + ) + change_external_service = click.confirm("Change external service", default=False, prompt_suffix="") + + if needed_external_service_type: + if change_external_service: + external_service: Optional[ExternalService] = select_external_service( + configuration_service=configuration_service, + logger=logger, + filter_type=[needed_external_service_type], + ) + + if external_service is None: + click.echo( + click.style( + "No external service selected. Keeping the current one.", + fg="yellow", + ) + ) + else: + external_service_id = external_service.id + else: + click.echo( + click.style( + "No external service required for this forecast provider. Keeping the current one.", + fg="yellow", + ) + ) + + try: + updated: ForecastProvider = run_async_func( + configuration_service.update_forecast_provider( + provider_id=forecast_provider.id, + name=name, + adapter_type=forecast_provider.adapter_type, + config=config, + external_service_id=external_service_id, + ) + ) + logger.debug(f"Forecast Provider '{updated.name}' updated successfully.") + click.echo( + click.style( + f"Forecast Provider '{updated.name}' successfully updated (ID: {updated.id}).", + fg="green", + ) + ) + return updated + except Exception as e: + logger.error(f"Error updating forecast provider: {e}") + click.echo(click.style(f"Error: {e}", fg="red"), err=True) + return None + + +def delete_single_forecast_provider( + forecast_provider: ForecastProvider, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> bool: + """Delete a single forecast provider.""" + delete_confirm: bool = click.confirm( + f"Are you sure you want to delete the forecast provider '{forecast_provider.name}' " + f"(ID: {forecast_provider.id})?", + abort=False, + default=False, + prompt_suffix="", + ) + if not delete_confirm: + click.echo(click.style("Deletion cancelled.", fg="yellow")) + return False + + try: + run_async_func(configuration_service.remove_forecast_provider(forecast_provider.id)) + logger.debug(f"Forecast Provider '{forecast_provider.name}' deleted successfully.") + click.echo( + click.style( + f"Forecast Provider '{forecast_provider.name}' successfully deleted.", + fg="green", + ) + ) + return True + except Exception as e: + logger.error(f"Error deleting forecast provider: {e}") + click.echo(click.style(f"Error: {e}", fg="red"), err=True) + return False + + +def manage_single_forecast_provider_menu( + forecast_provider: ForecastProvider, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> str: + """Menu for managing a single forecast provider.""" + while True: + click.echo("\n" + click.style("--- MANAGE FORECAST PROVIDER ---", fg="blue", bold=True)) + + print_forecast_provider_details( + forecast_provider=forecast_provider, + configuration_service=configuration_service, + show_energy_source_list=True, + show_external_service=True, + ) + + click.echo("1. Update Forecast Provider") + click.echo("2. Delete Forecast Provider") + click.echo("") + click.echo("b. Back to energy menu") + click.echo("q. Close application") + click.echo("-----------------") + + choice: str = click.prompt("Choose an option", type=str) + choice = choice.strip().lower() + + click.clear() + + if choice == "1": + updated_forecast_provider = update_single_forecast_provider( + forecast_provider=forecast_provider, + configuration_service=configuration_service, + logger=logger, + ) + forecast_provider = updated_forecast_provider or forecast_provider + continue + + elif choice == "2": + delete_status = delete_single_forecast_provider( + forecast_provider=forecast_provider, + configuration_service=configuration_service, + logger=logger, + ) + if delete_status: + return "b" # Return to the energy menu after deletion + + elif choice == "b": + break + + elif choice == "q": + break + + else: + click.echo(click.style("Invalid choice. Try again.", fg="red")) + click.pause("Press any key to return to the menu...") + + return choice + + +def forecast_menu(configuration_service: ConfigurationServiceInterface, logger: LoggerPort) -> str: + """Menu for managing Forecast Providers.""" + while True: + click.echo("\n" + click.style("--- FORECAST ---", fg="yellow", bold=True)) + click.echo("1. Add Forecast Provider") + click.echo("2. List Forecast Providers") + click.echo("3. Manage Forecast Provider") + click.echo("") + click.echo("b. Back to Main Menu") + click.echo("q. Quit") + click.echo("-----------------") + + choice: str = click.prompt("Select an action", type=str).strip().lower() + + if choice == "1": + handle_add_forecast_provider(configuration_service, logger) + elif choice == "2": + handle_list_forecast_providers(configuration_service, logger) + elif choice == "3": + forecast_provider = select_forecast_providers(configuration_service, logger) + if forecast_provider is None: + click.echo(click.style("No forecast provider selected. Aborting.", fg="red")) + continue + + sub_choice = manage_single_forecast_provider_menu( + forecast_provider=forecast_provider, + configuration_service=configuration_service, + logger=logger, + ) + if sub_choice == "q": + choice = "q" # Exit if user chose to quit from energy menu + break + + elif choice == "b": + break + + elif choice == "q": + break + + else: + click.echo(click.style("Invalid option. Please try again.", fg="red")) + click.pause("Press any key to return to the menu...") + + return choice diff --git a/core/edge_mining/adapters/domain/forecast/fast_api/__init__.py b/core/edge_mining/adapters/domain/forecast/fast_api/__init__.py new file mode 100644 index 0000000..49039a0 --- /dev/null +++ b/core/edge_mining/adapters/domain/forecast/fast_api/__init__.py @@ -0,0 +1 @@ +"""Adapter that uses FastAPI infrastructure for energy forecast domain API""" diff --git a/core/edge_mining/adapters/domain/forecast/fast_api/router.py b/core/edge_mining/adapters/domain/forecast/fast_api/router.py new file mode 100644 index 0000000..2bb6d1e --- /dev/null +++ b/core/edge_mining/adapters/domain/forecast/fast_api/router.py @@ -0,0 +1,225 @@ +"""API Router for forecast domain.""" + +import uuid +from typing import Annotated, Any, Dict, List, Optional, cast + +from fastapi import APIRouter, Depends, HTTPException + +from edge_mining.adapters.domain.forecast.schemas import ( + FORECAST_PROVIDER_CONFIG_SCHEMA_MAP, + ForecastProviderCreateSchema, + ForecastProviderSchema, + ForecastProviderUpdateSchema, +) + +# Import dependency injection setup functions +from edge_mining.adapters.infrastructure.api.setup import get_config_service +from edge_mining.application.interfaces import ConfigurationServiceInterface +from edge_mining.domain.common import EntityId +from edge_mining.domain.forecast.common import ForecastProviderAdapter +from edge_mining.domain.forecast.entities import ForecastProvider +from edge_mining.domain.forecast.exceptions import ( + ForecastProviderAlreadyExistsError, + ForecastProviderConfigurationError, + ForecastProviderNotFoundError, +) +from edge_mining.shared.adapter_maps.forecast import FORECAST_PROVIDER_CONFIG_TYPE_MAP +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.interfaces.config import Configuration, ForecastProviderConfig + +router = APIRouter() + + +@router.get("/forecast-providers", response_model=List[ForecastProviderSchema]) +async def get_forecast_providers_list( + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> List[ForecastProviderSchema]: + """Get a list of all forecast providers.""" + try: + forecast_providers: List[ForecastProvider] = config_service.list_forecast_providers() + + # Convert to forecast provider schema + forecast_provider_schemas: List[ForecastProviderSchema] = [] + + for forecast_provider in forecast_providers: + forecast_provider_schemas.append(ForecastProviderSchema.from_model(forecast_provider)) + + return forecast_provider_schemas + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/forecast-providers", response_model=ForecastProviderSchema) +async def add_forecast_provider( + forecast_provider_data: ForecastProviderCreateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> ForecastProviderSchema: + """Add a new forecast provider.""" + try: + # Convert to domain model + forecast_provider_to_add: ForecastProvider = forecast_provider_data.to_model() + + if forecast_provider_to_add.config is None: + raise ForecastProviderConfigurationError("Forecast provider configuration should be set") + + # Add the forecast provider + created_provider = await config_service.create_forecast_provider( + name=forecast_provider_to_add.name, + adapter_type=forecast_provider_to_add.adapter_type, + config=forecast_provider_to_add.config, + external_service_id=forecast_provider_to_add.external_service_id, + ) + + response = ForecastProviderSchema.from_model(created_provider) + return response + except ForecastProviderAlreadyExistsError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except ForecastProviderConfigurationError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/forecast-providers/types", response_model=List[ForecastProviderAdapter]) +async def get_forecast_provider_types() -> List[ForecastProviderAdapter]: + """Get a list of available forecast provider types.""" + try: + return [ForecastProviderAdapter(adapter.value) for adapter in ForecastProviderAdapter] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get( + "/forecast-providers/types/{adapter_type}/config-schema", + response_model=Dict[str, Any], +) +async def get_forecast_provider_config_schema( + adapter_type: ForecastProviderAdapter, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> Dict[str, Any]: + """Get the configuration schema for a specific forecast provider type.""" + try: + try: + forecast_adapter = ForecastProviderAdapter(adapter_type) + except ValueError as e: + raise ValueError(f"Invalid forecast provider adapter type: {adapter_type}") from e + + # Get the corresponding configuration class for the adapter type + forecast_config_type: Optional[type[ForecastProviderConfig]] = ( + config_service.get_forecast_provider_config_by_type(forecast_adapter) + ) + + if forecast_config_type is None: + raise ForecastProviderConfigurationError(f"No configuration class found for adapter type {adapter_type}") + + # Map the configuration class to its corresponding schema + forecast_config_schema = FORECAST_PROVIDER_CONFIG_SCHEMA_MAP.get(forecast_config_type, None) + + if forecast_config_schema is None: + raise ForecastProviderConfigurationError(f"No schema found for configuration class {forecast_config_type}") + + return forecast_config_schema.model_json_schema() + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get( + "/forecast-providers/types/{adapter_type}/external-services", + response_model=Optional[ExternalServiceAdapter], +) +async def get_forecast_provider_type_external_service_types( + adapter_type: ForecastProviderAdapter, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> Optional[ExternalServiceAdapter]: + """ "Get a list of compatible external service types for a specific forecast provider type.""" + try: + needed_external_service = config_service.get_forecast_provider_external_service_adapter(adapter_type) + + return needed_external_service + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/forecast-providers/{provider_id}", response_model=ForecastProviderSchema) +async def get_forecast_provider( + provider_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> ForecastProviderSchema: + """Get details of a specific forecast provider.""" + try: + forecast_provider = config_service.get_forecast_provider(provider_id) + + if forecast_provider is None: + raise ForecastProviderNotFoundError(f"Forecast Provider with ID {provider_id} not found") + + return ForecastProviderSchema.from_model(forecast_provider) + except ForecastProviderNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.put("/forecast-providers/{provider_id}", response_model=ForecastProviderSchema) +async def update_forecast_provider( + provider_id: EntityId, + forecast_provider_update: ForecastProviderUpdateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> ForecastProviderSchema: + """Update an existing forecast provider.""" + try: + forecast_provider = config_service.get_forecast_provider(provider_id) + + if forecast_provider is None: + raise ForecastProviderNotFoundError(f"Forecast Provider with ID {provider_id} not found") + + configuration: Optional[Configuration] = None + if forecast_provider_update.config: + config_cls = FORECAST_PROVIDER_CONFIG_TYPE_MAP.get(forecast_provider.adapter_type, None) + if config_cls is None: + raise ForecastProviderConfigurationError( + f"No configuration class found for adapter type {forecast_provider.adapter_type}" + ) + configuration = config_cls.from_dict(forecast_provider_update.config) + + external_service_id: Optional[EntityId] = None + if forecast_provider_update.external_service_id: + external_service_id = EntityId(uuid.UUID(forecast_provider_update.external_service_id)) + + # Update the forecast provider + updated_provider = await config_service.update_forecast_provider( + provider_id=provider_id, + name=forecast_provider_update.name or "", + adapter_type=forecast_provider.adapter_type, + config=cast(ForecastProviderConfig, configuration), + external_service_id=external_service_id, + ) + + response = ForecastProviderSchema.from_model(updated_provider) + + return response + except ForecastProviderNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.delete("/forecast-providers/{provider_id}", response_model=ForecastProviderSchema) +async def delete_forecast_provider( + provider_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> ForecastProviderSchema: + """Remove a forecast provider.""" + try: + deleted_provider = await config_service.remove_forecast_provider(provider_id) + + response = ForecastProviderSchema.from_model(deleted_provider) + + return response + except ForecastProviderNotFoundError as e: + raise HTTPException(status_code=404, detail="Forecast Provider not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e diff --git a/core/edge_mining/adapters/domain/forecast/providers/__init__.py b/core/edge_mining/adapters/domain/forecast/providers/__init__.py new file mode 100644 index 0000000..160fa14 --- /dev/null +++ b/core/edge_mining/adapters/domain/forecast/providers/__init__.py @@ -0,0 +1 @@ +"""Collection of energy forecast provider adapters.""" diff --git a/core/edge_mining/adapters/domain/forecast/providers/dummy_solar.py b/core/edge_mining/adapters/domain/forecast/providers/dummy_solar.py new file mode 100644 index 0000000..910d0a2 --- /dev/null +++ b/core/edge_mining/adapters/domain/forecast/providers/dummy_solar.py @@ -0,0 +1,149 @@ +""" +Dummy adapter (Implementation of Port) that simulates +the energy forecast for Edge Mining Application +""" + +import random +from datetime import datetime, timedelta +from typing import Optional + +from edge_mining.domain.common import Timestamp, WattHours, Watts +from edge_mining.domain.energy.entities import EnergySource +from edge_mining.domain.forecast.aggregate_root import Forecast +from edge_mining.domain.forecast.common import ForecastProviderAdapter +from edge_mining.domain.forecast.exceptions import ForecastError +from edge_mining.domain.forecast.ports import ForecastProviderPort +from edge_mining.domain.forecast.value_objects import ( + ForecastInterval, + ForecastPowerPoint, +) +from edge_mining.shared.adapter_configs.forecast import ForecastProviderDummySolarConfig +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.interfaces.factories import ForecastAdapterFactory +from edge_mining.shared.logging.port import LoggerPort + + +class DummyForecastProviderFactory(ForecastAdapterFactory): + """ + Factory for creating a DummySolarForecastProvider instance. + """ + + def __init__(self): + self._energy_source: Optional[EnergySource] = None + + def from_energy_source(self, energy_source: EnergySource) -> None: + """Set the reference energy source""" + self._energy_source = energy_source + + def create( + self, + config: Optional[Configuration], + logger: Optional[LoggerPort], + external_service: Optional[ExternalServicePort], + ) -> ForecastProviderPort: + """ + Creates a DummySolarForecastProvider instance. + """ + if not isinstance(config, ForecastProviderDummySolarConfig): + raise ForecastError( + "Invalid configuration type for HomeAssistantAPI forecast provider. " + "Expected ForecastProviderDummySolarConfig." + ) + + # Get the config from the forecast provider config + forecast_provider_config: ForecastProviderDummySolarConfig = config + + capacity_kwp = forecast_provider_config.capacity_kwp + if not capacity_kwp: + if self._energy_source and self._energy_source.nominal_power_max: + capacity_kwp = self._energy_source.nominal_power_max + + return DummySolarForecastProvider( + latitude=forecast_provider_config.latitude, + longitude=forecast_provider_config.longitude, + capacity_kwp=capacity_kwp, + efficiency_percent=forecast_provider_config.efficiency_percent, + production_start_hour=forecast_provider_config.production_start_hour, + production_end_hour=forecast_provider_config.production_end_hour, + logger=logger, + ) + + +class DummySolarForecastProvider(ForecastProviderPort): + """Dummy implementation of the ForecastProviderPort.""" + + def __init__( + self, + latitude: Optional[float] = None, + longitude: Optional[float] = None, + capacity_kwp: float = 5.0, + efficiency_percent: float = 80.0, + production_start_hour: int = 6, + production_end_hour: int = 20, + logger: Optional[LoggerPort] = None, + ): + """Initializes the DummySolarForecastProvider.""" + super().__init__(forecast_provider_type=ForecastProviderAdapter.DUMMY_SOLAR) + self.logger = logger + + self.latitude = latitude + self.longitude = longitude + self.capacity_kwp = capacity_kwp + self.efficiency_percent = efficiency_percent + self.production_start_hour = production_start_hour + self.production_end_hour = production_end_hour + # You can set default values or use the ones from settings if needed + + async def get_forecast(self) -> Optional[Forecast]: + # Generates a plausible fake solar forecast. + if self.logger: + self.logger.debug( + f"DummySolarForecastProvider: " + f"Generating forecast for {self.latitude},{self.longitude} " + f"({self.capacity_kwp} kWp)" + ) + now = datetime.now() + forecast: Forecast = Forecast(timestamp=Timestamp(now)) + base_max_watts = self.capacity_kwp * 1000 * (self.efficiency_percent / 100) + + peak_hour = 13 + total_production_hours = self.production_end_hour - self.production_start_hour + + for i in range(24): # Forecast for next 24 hours + future_time = now + timedelta(hours=i) + hour = future_time.hour + + if self.production_start_hour < hour < self.production_end_hour: + # Simple sinusoidal based on hour + solar_factor = max(0, 1 - abs(hour - peak_hour) / (total_production_hours / 2)) + # Add some randomness + noise = random.uniform(0.7, 1.0) + predicted_power = Watts(base_max_watts * solar_factor * noise) + else: + predicted_power = Watts(0.0) + + # Generate forecast for energy based on peak power + predicted_energy = WattHours(predicted_power) + + # Create a forecast power point for this hour + forecast_point = ForecastPowerPoint(timestamp=Timestamp(future_time), power=predicted_power) + + start_time = Timestamp(now) if i == 0 else Timestamp(now + timedelta(hours=i - 1)) + end_time = Timestamp(future_time) + + # Create a forecast interval for this hour + interval = ForecastInterval( + start=start_time, + end=end_time, + energy=predicted_energy, # Energy in Wh for the hour + energy_remaining=None, # Remaining energy in Wh for the hour + power_points=[forecast_point], + ) + + # Add the forecast interval to the forecast + forecast.intervals.append(interval) + + if self.logger: + self.logger.debug(f"DummyForecastProvider: Generated {len(forecast.intervals)} predictions.") + return forecast diff --git a/core/edge_mining/adapters/domain/forecast/providers/home_assistant_api.py b/core/edge_mining/adapters/domain/forecast/providers/home_assistant_api.py new file mode 100644 index 0000000..fdcedf2 --- /dev/null +++ b/core/edge_mining/adapters/domain/forecast/providers/home_assistant_api.py @@ -0,0 +1,560 @@ +""" +Home Assistant API adapter (Implementation of Port) +for the energy forecast of Edge Mining Application +""" + +from datetime import datetime, time, timedelta +from typing import List, Optional, cast + +from edge_mining.adapters.infrastructure.homeassistant.homeassistant_api import ( + ServiceHomeAssistantAPI, +) +from edge_mining.domain.common import Timestamp, WattHours, Watts +from edge_mining.domain.energy.entities import EnergySource +from edge_mining.domain.forecast.aggregate_root import Forecast +from edge_mining.domain.forecast.common import ForecastProviderAdapter +from edge_mining.domain.forecast.exceptions import ForecastError +from edge_mining.domain.forecast.ports import ForecastProviderPort +from edge_mining.domain.forecast.value_objects import ( + ForecastInterval, + ForecastPowerPoint, +) +from edge_mining.shared.adapter_configs.forecast import ( + ForecastProviderHomeAssistantConfig, +) +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.interfaces.factories import ForecastAdapterFactory +from edge_mining.shared.logging.port import LoggerPort + + +class HomeAssistantForecastProviderFactory(ForecastAdapterFactory): + """ + Factory for creating HomeAssistantForecastProvider instances. + """ + + def __init__(self): + self._energy_source: Optional[EnergySource] = None + + def from_energy_source(self, energy_source: EnergySource) -> None: + """Set the reference energy source""" + self._energy_source = energy_source + + def create( + self, + config: Optional[Configuration], + logger: Optional[LoggerPort], + external_service: Optional[ExternalServicePort], + ) -> "HomeAssistantForecastProvider": + """Creates a HomeAssistantForecastProvider instance.""" + + # Needs to have the Home Assistant API service as external_service + if not external_service: + raise ForecastError("External service is required for HomeAssistantForecastProvider.") + + if not external_service.external_service_type == ExternalServiceAdapter.HOME_ASSISTANT_API: + raise ForecastError("External service must be of type Home Assistant API") + + if not isinstance(config, ForecastProviderHomeAssistantConfig): + raise ForecastError( + "Invalid configuration type for HomeAssistantAPI forecast provider. " + "Expected ForecastProviderHomeAssistantConfig." + ) + + # Get the config from the forecast provider config + forecast_provider_config: ForecastProviderHomeAssistantConfig = config + service_home_assistant_api = cast(ServiceHomeAssistantAPI, external_service) + + # Use the builder to configure the provider, in this way we can + # ensure that all required entities are set. + builder = HomeAssistantForecastProviderBuilder(home_assistant=service_home_assistant_api, logger=logger) + + # Configure the builder with the entities and units + if forecast_provider_config.entity_forecast_power_actual_h: + builder.set_actual_power_entity( + forecast_provider_config.entity_forecast_power_actual_h, + forecast_provider_config.unit_forecast_power_actual_h, + ) + if forecast_provider_config.entity_forecast_power_next_1h: + builder.set_next_1h_power_entity( + forecast_provider_config.entity_forecast_power_next_1h, + forecast_provider_config.unit_forecast_power_next_1h, + ) + if forecast_provider_config.entity_forecast_power_next_12h: + builder.set_next_12h_power_entity( + forecast_provider_config.entity_forecast_power_next_12h, + forecast_provider_config.unit_forecast_power_next_12h, + ) + if forecast_provider_config.entity_forecast_power_next_24h: + builder.set_next_24h_power_entity( + forecast_provider_config.entity_forecast_power_next_24h, + forecast_provider_config.unit_forecast_power_next_24h, + ) + if forecast_provider_config.entity_forecast_energy_actual_h: + builder.set_actual_energy_entity( + forecast_provider_config.entity_forecast_energy_actual_h, + forecast_provider_config.unit_forecast_energy_actual_h, + ) + if forecast_provider_config.entity_forecast_energy_next_1h: + builder.set_next_1h_energy_entity( + forecast_provider_config.entity_forecast_energy_next_1h, + forecast_provider_config.unit_forecast_energy_next_1h, + ) + if forecast_provider_config.entity_forecast_energy_today: + builder.set_today_energy_entity( + forecast_provider_config.entity_forecast_energy_today, + forecast_provider_config.unit_forecast_energy_today, + ) + if forecast_provider_config.entity_forecast_energy_tomorrow: + builder.set_tomorrow_energy_entity( + forecast_provider_config.entity_forecast_energy_tomorrow, + forecast_provider_config.unit_forecast_energy_tomorrow, + ) + if forecast_provider_config.entity_forecast_energy_remaining_today: + builder.set_remaining_today_energy_entity( + forecast_provider_config.entity_forecast_energy_remaining_today, + forecast_provider_config.unit_forecast_energy_remaining_today, + ) + + # --- Build the adapter --- + return builder.build() + + +class HomeAssistantForecastProviderBuilder: + """Builder for HomeAssistantForecastProvider instances.""" + + def __init__(self, home_assistant: ServiceHomeAssistantAPI, logger: Optional[LoggerPort]): + """Initializes the HomeAssistantForecastProviderBuilder.""" + self.home_assistant: ServiceHomeAssistantAPI = home_assistant + self.logger: Optional[LoggerPort] = logger + self.entity_forecast_power_actual_h: Optional[str] = None + self.entity_forecast_power_next_1h: Optional[str] = None + self.entity_forecast_power_next_12h: Optional[str] = None + self.entity_forecast_power_next_24h: Optional[str] = None + self.entity_forecast_energy_actual_h: Optional[str] = None + self.entity_forecast_energy_next_1h: Optional[str] = None + self.entity_forecast_energy_today: Optional[str] = None + self.entity_forecast_energy_tomorrow: Optional[str] = None + self.entity_forecast_energy_remaining_today: Optional[str] = None + self.unit_forecast_power_actual_h: str = "W" + self.unit_forecast_power_next_1h: str = "W" + self.unit_forecast_power_next_12h: str = "W" + self.unit_forecast_power_next_24h: str = "W" + self.unit_forecast_energy_actual_h: str = "kWh" + self.unit_forecast_energy_next_1h: str = "kWh" + self.unit_forecast_energy_today: str = "kWh" + self.unit_forecast_energy_tomorrow: str = "kWh" + self.unit_forecast_energy_remaining_today: str = "kWh" + + def set_actual_power_entity(self, entity_id: str, unit: str = "W") -> "HomeAssistantForecastProviderBuilder": + """Sets the entity ID for the actual solar power forecast.""" + self.entity_forecast_power_actual_h = entity_id + self.unit_forecast_power_actual_h = unit.lower() + return self + + def set_next_1h_power_entity(self, entity_id: str, unit: str = "W") -> "HomeAssistantForecastProviderBuilder": + """Sets the entity ID for the next 1 hour solar power forecast.""" + self.entity_forecast_power_next_1h = entity_id + self.unit_forecast_power_next_1h = unit.lower() + return self + + def set_next_12h_power_entity(self, entity_id: str, unit: str = "W") -> "HomeAssistantForecastProviderBuilder": + """Sets the entity ID for the next 12 hours solar power forecast.""" + self.entity_forecast_power_next_12h = entity_id + self.unit_forecast_power_next_12h = unit.lower() + return self + + def set_next_24h_power_entity(self, entity_id: str, unit: str = "W") -> "HomeAssistantForecastProviderBuilder": + """Sets the entity ID for the next 24 hours solar power forecast.""" + self.entity_forecast_power_next_24h = entity_id + self.unit_forecast_power_next_24h = unit.lower() + return self + + def set_actual_energy_entity(self, entity_id: str, unit: str = "kWh") -> "HomeAssistantForecastProviderBuilder": + """Sets the entity ID for the actual solar energy forecast.""" + self.entity_forecast_energy_actual_h = entity_id + self.unit_forecast_energy_actual_h = unit.lower() + return self + + def set_next_1h_energy_entity(self, entity_id: str, unit: str = "kWh") -> "HomeAssistantForecastProviderBuilder": + """Sets the entity ID for the next 1 hour solar energy forecast.""" + self.entity_forecast_energy_next_1h = entity_id + self.unit_forecast_energy_next_1h = unit.lower() + return self + + def set_today_energy_entity(self, entity_id: str, unit: str = "kWh") -> "HomeAssistantForecastProviderBuilder": + """Sets the entity ID for the today solar energy forecast.""" + self.entity_forecast_energy_today = entity_id + self.unit_forecast_energy_today = unit.lower() + return self + + def set_tomorrow_energy_entity(self, entity_id: str, unit: str = "kWh") -> "HomeAssistantForecastProviderBuilder": + """Sets the entity ID for the tomorrow solar energy forecast.""" + self.entity_forecast_energy_tomorrow = entity_id + self.unit_forecast_energy_tomorrow = unit.lower() + return self + + def set_remaining_today_energy_entity( + self, entity_id: str, unit: str = "kWh" + ) -> "HomeAssistantForecastProviderBuilder": + """Sets the entity ID for the remaining energy forecast for today.""" + self.entity_forecast_energy_remaining_today = entity_id + self.unit_forecast_energy_remaining_today = unit.lower() + return self + + def build(self) -> "HomeAssistantForecastProvider": + """Builds the HomeAssistantForecastProvider instance.""" + if not self.entity_forecast_power_actual_h: + raise ValueError("Entity ID for actual solar power forecast is required.") + if not self.entity_forecast_energy_actual_h: + raise ValueError("Entity ID for actual solar energy forecast is required.") + + forecast_provider = HomeAssistantForecastProvider( + home_assistant=self.home_assistant, + entity_forecast_power_actual_h=self.entity_forecast_power_actual_h, + entity_forecast_power_next_1h=self.entity_forecast_power_next_1h, + entity_forecast_power_next_12h=self.entity_forecast_power_next_12h, + entity_forecast_power_next_24h=self.entity_forecast_power_next_24h, + entity_forecast_energy_actual_h=self.entity_forecast_energy_actual_h, + entity_forecast_energy_next_1h=self.entity_forecast_energy_next_1h, + entity_forecast_energy_today=self.entity_forecast_energy_today, + entity_forecast_energy_tomorrow=self.entity_forecast_energy_tomorrow, + entity_forecast_energy_remaining_today=self.entity_forecast_energy_remaining_today, + unit_forecast_power_actual_h=self.unit_forecast_power_actual_h, + unit_forecast_power_next_1h=self.unit_forecast_power_next_1h, + unit_forecast_power_next_12h=self.unit_forecast_power_next_12h, + unit_forecast_power_next_24h=self.unit_forecast_power_next_24h, + unit_forecast_energy_actual_h=self.unit_forecast_energy_actual_h, + unit_forecast_energy_next_1h=self.unit_forecast_energy_next_1h, + unit_forecast_energy_today=self.unit_forecast_energy_today, + unit_forecast_energy_tomorrow=self.unit_forecast_energy_tomorrow, + unit_forecast_energy_remaining_today=self.unit_forecast_energy_remaining_today, + logger=self.logger, + ) + + return forecast_provider + + +class HomeAssistantForecastProvider(ForecastProviderPort): + """ + Fetches energy forecast from a Home Assistant instance via its REST API. + + Requires careful configuration of entity IDs in the .env file. + """ + + def __init__( + self, + home_assistant: ServiceHomeAssistantAPI, + entity_forecast_power_actual_h: Optional[str], + entity_forecast_power_next_1h: Optional[str], + entity_forecast_power_next_12h: Optional[str], + entity_forecast_power_next_24h: Optional[str], + entity_forecast_energy_actual_h: Optional[str], + entity_forecast_energy_next_1h: Optional[str], + entity_forecast_energy_today: Optional[str], + entity_forecast_energy_tomorrow: Optional[str], + entity_forecast_energy_remaining_today: Optional[str], + unit_forecast_power_actual_h: str = "W", + unit_forecast_power_next_1h: str = "W", + unit_forecast_power_next_12h: str = "W", + unit_forecast_power_next_24h: str = "W", + unit_forecast_energy_actual_h: str = "kWh", + unit_forecast_energy_next_1h: str = "kWh", + unit_forecast_energy_today: str = "kWh", + unit_forecast_energy_tomorrow: str = "kWh", + unit_forecast_energy_remaining_today: str = "kWh", + logger: Optional[LoggerPort] = None, + ): + # Initialize the HomeAssistant API Service + super().__init__(forecast_provider_type=ForecastProviderAdapter.HOME_ASSISTANT_API) + self.home_assistant = home_assistant + self.logger = logger + + self.entity_forecast_power_actual_h = entity_forecast_power_actual_h + self.entity_forecast_power_next_1h = entity_forecast_power_next_1h + self.entity_forecast_power_next_12h = entity_forecast_power_next_12h + self.entity_forecast_power_next_24h = entity_forecast_power_next_24h + self.entity_forecast_energy_actual_h = entity_forecast_energy_actual_h + self.entity_forecast_energy_next_1h = entity_forecast_energy_next_1h + self.entity_forecast_energy_today = entity_forecast_energy_today + self.entity_forecast_energy_tomorrow = entity_forecast_energy_tomorrow + self.entity_forecast_energy_remaining_today = entity_forecast_energy_remaining_today + self.unit_forecast_power_actual_h = unit_forecast_power_actual_h.lower() + self.unit_forecast_power_next_1h = unit_forecast_power_next_1h.lower() + self.unit_forecast_power_next_12h = unit_forecast_power_next_12h.lower() + self.unit_forecast_power_next_24h = unit_forecast_power_next_24h.lower() + self.unit_forecast_energy_actual_h = unit_forecast_energy_actual_h.lower() + self.unit_forecast_energy_next_1h = unit_forecast_energy_next_1h.lower() + self.unit_forecast_energy_today = unit_forecast_energy_today.lower() + self.unit_forecast_energy_tomorrow = unit_forecast_energy_tomorrow.lower() + self.unit_forecast_energy_remaining_today = unit_forecast_energy_remaining_today.lower() + + if self.logger: + self.logger.debug( + f"Entities Configured for Power:" + f"Actual='{entity_forecast_power_actual_h}', " + f"Next 1h='{entity_forecast_power_next_1h}', " + f"Next 12h='{entity_forecast_power_next_12h}', " + f"Next 24h='{entity_forecast_power_next_24h}'" + ) + self.logger.debug( + f"Entities Configured for Energy:" + f"Actual='{entity_forecast_energy_actual_h}', " + f"Today='{entity_forecast_energy_next_1h}', " + f"Tomorow='{entity_forecast_energy_tomorrow}', " + f"Remaining='{entity_forecast_energy_remaining_today}'" + ) + + self.logger.debug( + f"Units for Power:" + f"Actual='{unit_forecast_power_actual_h}', " + f"Next 1h='{unit_forecast_power_next_1h}', " + f"Next 12h='{unit_forecast_power_next_12h}', " + f"Next 24h='{unit_forecast_power_next_24h}'" + ) + self.logger.debug( + f"Units Configured for Energy:" + f"Actual='{unit_forecast_energy_actual_h}', " + f"Next 1h='{unit_forecast_energy_next_1h}', " + f"Today='{unit_forecast_energy_today}', " + f"Tomorrow='{unit_forecast_energy_tomorrow}', " + f"Remaining='{unit_forecast_energy_remaining_today}'" + ) + + async def get_forecast(self) -> Optional[Forecast]: + """Fetches the energy production forecast.""" + if self.logger: + self.logger.debug("Fetching forecast energy state from Home Assistant...") + + # --- Actual Power h --- + if self.entity_forecast_power_actual_h: + state_forecast_power_actual_h, _ = await self.home_assistant.get_entity_state( + self.entity_forecast_power_actual_h + ) + power_actual_h = self.home_assistant.parse_power( + state_forecast_power_actual_h, + self.unit_forecast_power_actual_h, + self.entity_forecast_power_actual_h or "N/A", + ) + else: + power_actual_h = None + + # --- Next Power 1h --- + if self.entity_forecast_power_next_1h: + state_forecast_power_next_1h, _ = await self.home_assistant.get_entity_state( + self.entity_forecast_power_next_1h + ) + power_next_1h = self.home_assistant.parse_power( + state_forecast_power_next_1h, + self.unit_forecast_power_next_1h, + self.entity_forecast_power_next_1h or "N/A", + ) + else: + power_next_1h = None + + # --- Next Power 12h --- + if self.entity_forecast_power_next_12h: + state_forecast_power_next_12h, _ = await self.home_assistant.get_entity_state( + self.entity_forecast_power_next_12h + ) + power_next_12h = self.home_assistant.parse_power( + state_forecast_power_next_12h, + self.unit_forecast_power_next_12h, + self.entity_forecast_power_next_12h or "N/A", + ) + else: + power_next_12h = None + + # --- Next Power 24h --- + if self.entity_forecast_power_next_24h: + state_forecast_power_next_24h, _ = await self.home_assistant.get_entity_state( + self.entity_forecast_power_next_24h + ) + power_next_24h = self.home_assistant.parse_power( + state_forecast_power_next_24h, + self.unit_forecast_power_next_24h, + self.entity_forecast_power_next_24h or "N/A", + ) + else: + power_next_24h = None + + # --- Actual Energy h --- + if self.entity_forecast_energy_actual_h: + state_forecast_energy_actual_h, _ = await self.home_assistant.get_entity_state( + self.entity_forecast_energy_actual_h + ) + energy_actual_h = self.home_assistant.parse_energy( + state_forecast_energy_actual_h, + self.unit_forecast_energy_actual_h, + self.entity_forecast_energy_actual_h or "N/A", + ) + else: + energy_actual_h = None + + # --- Next Energy 1h --- + if self.entity_forecast_energy_next_1h: + state_forecast_energy_next_1h, _ = await self.home_assistant.get_entity_state( + self.entity_forecast_energy_next_1h + ) + energy_next_1h = self.home_assistant.parse_energy( + state_forecast_energy_next_1h, + self.unit_forecast_energy_next_1h, + self.entity_forecast_energy_next_1h or "N/A", + ) + else: + energy_next_1h = None + + # --- Today Energy --- + if self.entity_forecast_energy_today: + state_forecast_energy_today, _ = await self.home_assistant.get_entity_state( + self.entity_forecast_energy_today + ) + energy_today = self.home_assistant.parse_energy( + state_forecast_energy_today, + self.unit_forecast_energy_today, + self.entity_forecast_energy_today or "N/A", + ) + else: + energy_today = None + + # --- Tomorrow Energy --- + if self.entity_forecast_energy_tomorrow: + state_forecast_energy_tomorrow, _ = await self.home_assistant.get_entity_state( + self.entity_forecast_energy_tomorrow + ) + energy_tomorrow = self.home_assistant.parse_energy( + state_forecast_energy_tomorrow, + self.unit_forecast_energy_tomorrow, + self.entity_forecast_energy_tomorrow or "N/A", + ) + else: + energy_tomorrow = None + + # --- Remaining Energy Today --- + if self.entity_forecast_energy_remaining_today: + state_forecast_energy_remaining_today, _ = await self.home_assistant.get_entity_state( + self.entity_forecast_energy_remaining_today + ) + energy_remaining_today = self.home_assistant.parse_energy( + state_forecast_energy_remaining_today, + self.unit_forecast_energy_remaining_today, + self.entity_forecast_energy_remaining_today or "N/A", + ) + else: + energy_remaining_today = None + + # Check if essential values are missing - log warnings but continue with partial data + if energy_today is None and self.entity_forecast_energy_today: + if self.logger: + self.logger.warning( + f"Could not retrieve forecast energy today (Entity: {self.entity_forecast_energy_today}). " + "Continuing with partial forecast data." + ) + if energy_tomorrow is None and self.entity_forecast_energy_tomorrow: + if self.logger: + self.logger.warning( + f"Could not retrieve forecast energy tomorrow (Entity: {self.entity_forecast_energy_tomorrow}). " + "Continuing with partial forecast data." + ) + + # Add here other checks for critical values as needed + + now = Timestamp(datetime.now()) + actual_hour = datetime.now().replace(minute=0, second=0, microsecond=0) + end_of_today = datetime.combine(now, time.max) + + forecast: Forecast = Forecast(timestamp=Timestamp(now)) + + # Create forecast intervals + forecast_interval_actual_h = ForecastInterval( + start=Timestamp(actual_hour), + end=Timestamp(actual_hour), + energy=WattHours(energy_actual_h) if energy_actual_h else None, + energy_remaining=None, + power_points=[], + ) + forecast_interval_1h = ForecastInterval( + start=Timestamp(actual_hour), + end=Timestamp(actual_hour + timedelta(hours=1)), + energy=WattHours(energy_next_1h) if energy_next_1h else None, + energy_remaining=None, + power_points=[], + ) + forecast_interval_12h = ForecastInterval( + start=Timestamp(actual_hour), + end=Timestamp(actual_hour + timedelta(hours=12)), + energy=None, + energy_remaining=None, + power_points=[], + ) + forecast_interval_24h = ForecastInterval( + start=Timestamp(actual_hour), + end=Timestamp(actual_hour + timedelta(hours=24)), + energy=None, + energy_remaining=None, + power_points=[], + ) + forecast_interval_today = ForecastInterval( + start=Timestamp(actual_hour), + end=Timestamp(end_of_today), + energy=WattHours(energy_today) if energy_today else None, + energy_remaining=WattHours(energy_remaining_today) if energy_remaining_today else None, + power_points=[], + ) + forecast_interval_tomorrow = ForecastInterval( + start=Timestamp(end_of_today + timedelta(seconds=1)), + end=Timestamp(end_of_today + timedelta(days=1)), + energy=WattHours(energy_tomorrow) if energy_tomorrow else None, + energy_remaining=None, + power_points=[], + ) + + # Add power data + if power_actual_h is not None: + forecast_interval_actual_h.power_points.append( + ForecastPowerPoint( + timestamp=Timestamp(actual_hour), + power=Watts(power_actual_h), + ) + ) + if power_next_1h is not None: + forecast_interval_1h.power_points.append( + ForecastPowerPoint( + timestamp=Timestamp(actual_hour + timedelta(hours=1)), + power=Watts(power_next_1h), + ) + ) + if power_next_12h is not None: + forecast_interval_12h.power_points.append( + ForecastPowerPoint( + timestamp=Timestamp(actual_hour + timedelta(hours=12)), + power=Watts(power_next_12h), + ) + ) + if power_next_24h is not None: + forecast_interval_24h.power_points.append( + ForecastPowerPoint( + timestamp=Timestamp(actual_hour + timedelta(hours=24)), + power=Watts(power_next_24h), + ) + ) + + forecast_intervals: List[ForecastInterval] = [ + forecast_interval_actual_h, + forecast_interval_1h, + forecast_interval_12h, + forecast_interval_24h, + forecast_interval_today, + forecast_interval_tomorrow, + ] + + # Add intervals to forecast if they contain data + for interval in forecast_intervals: + if interval.power_points or interval.energy is not None or interval.energy_remaining is not None: + forecast.intervals.append(interval) + + if self.logger: + self.logger.debug(f"HA Monitor: Forecast Intervals fetched: {forecast.intervals}") + + return forecast diff --git a/core/edge_mining/adapters/domain/forecast/repositories.py b/core/edge_mining/adapters/domain/forecast/repositories.py new file mode 100644 index 0000000..7dea082 --- /dev/null +++ b/core/edge_mining/adapters/domain/forecast/repositories.py @@ -0,0 +1,396 @@ +"""Repositories for Forecast Domain.""" + +import json +import sqlite3 +from typing import List, Optional + +from sqlalchemy import select + +from edge_mining.adapters.domain.forecast.tables import forecast_providers_table +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.adapters.infrastructure.persistence.sqlite import BaseSqliteRepository +from edge_mining.domain.common import EntityId +from edge_mining.domain.exceptions import ConfigurationError +from edge_mining.domain.forecast.common import ForecastProviderAdapter +from edge_mining.domain.forecast.entities import ForecastProvider +from edge_mining.domain.forecast.exceptions import ( + ForecastProviderAlreadyExistsError, + ForecastProviderConfigurationError, + ForecastProviderError, + ForecastProviderNotFoundError, +) +from edge_mining.domain.forecast.ports import ForecastProviderRepository +from edge_mining.shared.adapter_maps.forecast import FORECAST_PROVIDER_CONFIG_TYPE_MAP +from edge_mining.shared.interfaces.config import ForecastProviderConfig + +# Simple In-Memory implementation for testing and basic use + + +class InMemoryForecastProviderRepository(ForecastProviderRepository): + """In-memory implementation of ForecastProviderRepository for testing purposes.""" + + def __init__(self): + self._forecast_providers: List[ForecastProvider] = [] + + def add(self, forecast_provider: ForecastProvider) -> None: + self._forecast_providers.append(forecast_provider) + + def get_by_id(self, forecast_provider_id: EntityId) -> Optional[ForecastProvider]: + for forecast_provider in self._forecast_providers: + if forecast_provider.id == forecast_provider_id: + return forecast_provider + return None + + def get_all(self) -> List[ForecastProvider]: + return self._forecast_providers + + def update(self, forecast_provider: ForecastProvider) -> None: + for i, existing_forecast_provider in enumerate(self._forecast_providers): + if existing_forecast_provider.id == forecast_provider.id: + self._forecast_providers[i] = forecast_provider + return + + def remove(self, forecast_provider_id: EntityId) -> None: + self._forecast_providers = [n for n in self._forecast_providers if n.id != forecast_provider_id] + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[ForecastProvider]: + """Get all forecast providers associated with a specific external service ID.""" + return ( + [fp for fp in self._forecast_providers if fp.external_service_id == external_service_id] + if external_service_id + else [] + ) + + +class SqliteForecastProviderRepository(ForecastProviderRepository): + """SQLite implementation of ForecastProviderRepository.""" + + def __init__(self, db: BaseSqliteRepository): + self._db = db + self.logger = db.logger + + self._create_tables() + + def _create_tables(self): + """Create the necessary table for the Forecast Provider if it does not exist.""" + self.logger.debug(f"Ensuring SQLite tables exist for Forecast Provider Repository in {self._db.db_path}...") + sql_statements = [ + """ + CREATE TABLE IF NOT EXISTS forecast_providers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + adapter_type TEXT NOT NULL, + config TEXT, -- JSON object of config + external_service_id TEXT -- Optional ID for external service integration + ); + """ + ] + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + for statement in sql_statements: + cursor.execute(statement) + + self.logger.debug("Forecast providers tables checked/created successfully.") + except sqlite3.Error as e: + self.logger.error(f"Error creating SQLite tables: {e}") + raise ConfigurationError(f"DB error creating tables: {e}") from e + finally: + if conn: + conn.close() + + def _deserialize_config(self, adapter_type: ForecastProviderAdapter, config_json: str) -> ForecastProviderConfig: + """Deserialize a JSON string into ForecastProviderConfig object.""" + data: dict = json.loads(config_json) + + if adapter_type not in FORECAST_PROVIDER_CONFIG_TYPE_MAP: + raise ForecastProviderConfigurationError( + f"Error reading ForecastProvider configuration. Invalid type '{adapter_type}'" + ) + + config_class: Optional[type[ForecastProviderConfig]] = FORECAST_PROVIDER_CONFIG_TYPE_MAP.get(adapter_type) + if not config_class: + raise ForecastProviderConfigurationError( + f"Error creating ForecastProvider configuration. Type '{adapter_type}'" + ) + + config_instance = config_class.from_dict(data) + if not isinstance(config_instance, ForecastProviderConfig): + raise ForecastProviderConfigurationError( + f"Deserialized config is not of type ForecastProviderConfig for adapter type {adapter_type}." + ) + return config_instance + + def _row_to_forecast_provider(self, row: sqlite3.Row) -> Optional[ForecastProvider]: + """Deserialize a row from the database into a ForecastProvider object.""" + if not row: + return None + try: + forecast_provider_type = ForecastProviderAdapter(row["adapter_type"]) + + # Deserialize config from the database row + config = self._deserialize_config(forecast_provider_type, row["config"]) + + return ForecastProvider( + id=EntityId(row["id"]), + name=row["name"], + adapter_type=forecast_provider_type, + config=config, + external_service_id=(EntityId(row["external_service_id"]) if row["external_service_id"] else None), + ) + except (ValueError, KeyError) as e: + self.logger.error(f"Error deserializing ForecastProvider from DB row: {row}. Error: {e}") + return None + + def add(self, forecast_provider: ForecastProvider) -> None: + """Add a new forecast provider to the repository.""" + self.logger.debug(f"Adding forecast provider {forecast_provider.id} to SQLite repository.") + sql = """ + INSERT INTO forecast_providers (id, name, adapter_type, config, external_service_id) + VALUES (?, ?, ?, ?, ?); + """ + conn = self._db.get_connection() + try: + # Serialize config to JSON for storage + config_json: str = "" + if forecast_provider.config: + config_json = json.dumps(forecast_provider.config.to_dict()) + + with conn: + cursor = conn.cursor() + cursor.execute( + sql, + ( + forecast_provider.id, + forecast_provider.name, + forecast_provider.adapter_type.value, + config_json, + forecast_provider.external_service_id, + ), + ) + except sqlite3.IntegrityError as e: + self.logger.error(f"Integrity error adding forecast provider {forecast_provider.id}: {e}") + # Could mean that the ID already exists + raise ForecastProviderAlreadyExistsError( + f"forecast provider with ID {forecast_provider.id} already exists or constraint violation: {e}" + ) from e + except sqlite3.Error as e: + self.logger.error(f"SQLite error adding forecast provider {forecast_provider.id}: {e}") + raise ForecastProviderError(f"DB error adding forecast provider: {e}") from e + finally: + if conn: + conn.close() + + def get_by_id(self, forecast_provider_id: EntityId) -> Optional[ForecastProvider]: + """Retrieve a forecast provider by its ID.""" + self.logger.debug(f"Retrieving forecast provider {forecast_provider_id} from SQLite repository.") + sql = "SELECT * FROM forecast_providers WHERE id = ?;" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (forecast_provider_id,)) + row = cursor.fetchone() + return self._row_to_forecast_provider(row) + except sqlite3.Error as e: + self.logger.error(f"SQLite error retrieving forecast provider {forecast_provider_id}: {e}") + raise ForecastProviderNotFoundError(f"DB error retrieving forecast provider: {e}") from e + finally: + if conn: + conn.close() + + def get_all(self) -> List[ForecastProvider]: + """Retrieve all forecast providers from the repository.""" + self.logger.debug("Retrieving all forecast providers from SQLite repository.") + sql = "SELECT * FROM forecast_providers;" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql) + rows = cursor.fetchall() + forecast_providers = [] + for row in rows: + forecast_provider = self._row_to_forecast_provider(row) + if forecast_provider: + forecast_providers.append(forecast_provider) + except sqlite3.Error as e: + self.logger.error(f"SQLite error retrieving all forecast providers: {e}") + return [] + finally: + if conn: + conn.close() + return forecast_providers + + def update(self, forecast_provider: ForecastProvider) -> None: + """Update an existing forecast provider in the repository.""" + self.logger.debug(f"Updating forecast provider {forecast_provider.id} in SQLite repository.") + sql = """ + UPDATE forecast_providers + SET name = ?, adapter_type = ?, config = ?, external_service_id = ? + WHERE id = ?; + """ + conn = self._db.get_connection() + try: + # Serialize config to JSON for storage + config_json: str = "" + if forecast_provider.config: + config_json = json.dumps(forecast_provider.config.to_dict()) + + with conn: + cursor = conn.cursor() + cursor.execute( + sql, + ( + forecast_provider.name, + forecast_provider.adapter_type.value, + config_json, + forecast_provider.external_service_id, + forecast_provider.id, + ), + ) + if cursor.rowcount == 0: + raise ForecastProviderNotFoundError(f"Forecast Provider with ID {forecast_provider.id} not found.") + except sqlite3.Error as e: + self.logger.error(f"SQLite error updating forecast provider {forecast_provider.id}: {e}") + raise ForecastProviderError(f"DB error updating forecast provider: {e}") from e + finally: + if conn: + conn.close() + + def remove(self, forecast_provider_id: EntityId) -> None: + """Remove a forecast provider from the repository.""" + self.logger.debug(f"Removing forecast provider {forecast_provider_id} from SQLite repository.") + sql = "DELETE FROM forecast_providers WHERE id = ?;" + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + cursor.execute(sql, (forecast_provider_id,)) + if cursor.rowcount == 0: + self.logger.warning(f"Attempted to remove non-existent forecast provider {forecast_provider_id}.") + # There is no need to raise an exception here, removing a + # non-existent is idempotent. + except sqlite3.Error as e: + self.logger.error(f"SQLite error removing forecast provider {forecast_provider_id}: {e}") + raise ForecastProviderError(f"DB error removing forecast provider: {e}") from e + finally: + if conn: + conn.close() + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[ForecastProvider]: + """Get all forecast providers associated with a specific external service ID.""" + self.logger.debug( + f"Retrieving forecast providers for external service {external_service_id} from SQLite repository." + ) + sql = "SELECT * FROM forecast_providers WHERE external_service_id = ?;" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (external_service_id,)) + rows = cursor.fetchall() + forecast_providers = [] + for row in rows: + forecast_provider = self._row_to_forecast_provider(row) + if forecast_provider: + forecast_providers.append(forecast_provider) + return forecast_providers + except sqlite3.Error as e: + self.logger.error( + f"SQLite error retrieving forecast providers for external service {external_service_id}: {e}" + ) + return [] + finally: + if conn: + conn.close() + + +class SqlAlchemyForecastProviderRepository(ForecastProviderRepository): + """SQLAlchemy implementation of ForecastProviderRepository. + + This repository works directly with the imperatively mapped ForecastProvider domain entity. + The config field is automatically converted between ForecastProviderConfig objects and JSON + strings by the custom TypeDecorator and event listener defined in tables.py. + + Args: + db: BaseSQLAlchemyRepository instance for database operations + """ + + def __init__(self, db: BaseSQLAlchemyRepository): + """Initialize repository with database instance. + + Args: + db: BaseSQLAlchemyRepository instance + """ + self._db = db + self.logger = db.logger + + def add(self, forecast_provider: ForecastProvider) -> None: + """Add a forecast provider to the repository.""" + session = self._db.get_session() + try: + session.add(forecast_provider) + session.commit() + finally: + session.close() + + def get_by_id(self, forecast_provider_id: EntityId) -> Optional[ForecastProvider]: + """Get a forecast provider by ID.""" + session = self._db.get_session() + try: + stmt = select(ForecastProvider).where(forecast_providers_table.c.id == str(forecast_provider_id)) + entity = session.execute(stmt).scalar_one_or_none() + return entity + finally: + session.close() + + def get_all(self) -> List[ForecastProvider]: + """Get all forecast providers.""" + session = self._db.get_session() + try: + stmt = select(ForecastProvider) + entities = session.execute(stmt).scalars().all() + return list(entities) + finally: + session.close() + + def update(self, forecast_provider: ForecastProvider) -> None: + """Update a forecast provider.""" + session = self._db.get_session() + try: + stmt = select(ForecastProvider).where(forecast_providers_table.c.id == str(forecast_provider.id)) + existing_entity = session.execute(stmt).scalar_one_or_none() + + if existing_entity: + existing_entity.name = forecast_provider.name + existing_entity.adapter_type = forecast_provider.adapter_type + existing_entity.config = forecast_provider.config + existing_entity.external_service_id = forecast_provider.external_service_id + + session.commit() + finally: + session.close() + + def remove(self, forecast_provider_id: EntityId) -> None: + """Remove a forecast provider by ID.""" + session = self._db.get_session() + try: + stmt = select(ForecastProvider).where(forecast_providers_table.c.id == str(forecast_provider_id)) + entity = session.execute(stmt).scalar_one_or_none() + + if entity: + session.delete(entity) + session.commit() + finally: + session.close() + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[ForecastProvider]: + """Get forecast providers by external service ID.""" + session = self._db.get_session() + try: + stmt = select(ForecastProvider).where( + forecast_providers_table.c.external_service_id == str(external_service_id) + ) + entities = session.execute(stmt).scalars().all() + return list(entities) + finally: + session.close() diff --git a/core/edge_mining/adapters/domain/forecast/schemas.py b/core/edge_mining/adapters/domain/forecast/schemas.py new file mode 100644 index 0000000..5c0fc9f --- /dev/null +++ b/core/edge_mining/adapters/domain/forecast/schemas.py @@ -0,0 +1,698 @@ +"""Validation schemas for forecast domain.""" + +import uuid +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Union, cast + +from pydantic import BaseModel, Field, computed_field, field_serializer, field_validator + +from edge_mining.domain.common import EntityId, Timestamp, WattHours, Watts +from edge_mining.domain.forecast.aggregate_root import Forecast +from edge_mining.domain.forecast.common import ForecastProviderAdapter +from edge_mining.domain.forecast.entities import ForecastProvider +from edge_mining.domain.forecast.value_objects import ForecastInterval, ForecastPowerPoint, Sun +from edge_mining.shared.adapter_configs.forecast import ( + ForecastProviderDummySolarConfig, + ForecastProviderHomeAssistantConfig, +) +from edge_mining.shared.adapter_maps.forecast import FORECAST_PROVIDER_CONFIG_TYPE_MAP +from edge_mining.shared.interfaces.config import ForecastProviderConfig +from edge_mining.shared.timezone import get_timezone +from edge_mining.shared.timezone import now as timezone_now + + +class ForecastPowerPointSchema(BaseModel): + """Schema for ForecastPowerPoint value object.""" + + timestamp: datetime = Field(..., description="Timestamp for the power prediction") + power: float = Field(..., ge=0, description="Predicted power output in Watts") + + @field_validator("timestamp") + @classmethod + def ensure_aware_timestamp(cls, v: datetime) -> datetime: + """Ensure timestamp is timezone-aware.""" + if v.tzinfo is None: + return v.replace(tzinfo=get_timezone()) + return v + + @field_validator("power") + @classmethod + def validate_power(cls, v: float) -> float: + """Validate power is non-negative.""" + if v < 0: + raise ValueError("power must be non-negative") + return v + + @classmethod + def from_model(cls, power_point: ForecastPowerPoint) -> "ForecastPowerPointSchema": + """Create ForecastPowerPointSchema from ForecastPowerPoint value object.""" + return cls( + timestamp=power_point.timestamp, + power=float(power_point.power), + ) + + def to_model(self) -> ForecastPowerPoint: + """Convert ForecastPowerPointSchema to ForecastPowerPoint value object.""" + return ForecastPowerPoint( + timestamp=Timestamp(self.timestamp), + power=Watts(self.power), + ) + + +class ForecastIntervalSchema(BaseModel): + """Schema for ForecastInterval value object.""" + + start: datetime = Field(..., description="Start timestamp of the forecast interval") + end: datetime = Field(..., description="End timestamp of the forecast interval") + energy: Optional[float] = Field(default=None, ge=0, description="Total energy expected in WattHours") + energy_remaining: Optional[float] = Field(default=None, ge=0, description="Remaining energy in WattHours") + power_points: List[ForecastPowerPointSchema] = Field( + default_factory=list, description="Power predictions within interval" + ) + + @field_validator("start", "end") + @classmethod + def ensure_aware_timestamps(cls, v: datetime) -> datetime: + """Ensure timestamps are timezone-aware.""" + if v.tzinfo is None: + return v.replace(tzinfo=get_timezone()) + return v + + @computed_field # type: ignore[prop-decorator] + @property + def duration(self) -> float: + """Calculate the duration of the interval in seconds.""" + return (self.end - self.start).total_seconds() + + @computed_field # type: ignore[prop-decorator] + @property + def avg_power(self) -> float: + """Calculate the average power over the interval in Watts.""" + if not self.power_points: + return 0.0 + + total_power = sum(point.power for point in self.power_points) + return total_power / len(self.power_points) if total_power else 0.0 + + @field_validator("energy", "energy_remaining") + @classmethod + def validate_energy(cls, v: Optional[float]) -> Optional[float]: + """Validate energy values are non-negative if provided.""" + if v is not None and v < 0: + raise ValueError("energy values must be non-negative") + return v + + @classmethod + def from_model(cls, interval: ForecastInterval) -> "ForecastIntervalSchema": + """Create ForecastIntervalSchema from ForecastInterval value object.""" + return cls( + start=interval.start, + end=interval.end, + energy=float(interval.energy) if interval.energy is not None else None, + energy_remaining=float(interval.energy_remaining) if interval.energy_remaining is not None else None, + power_points=[ForecastPowerPointSchema.from_model(pp) for pp in interval.power_points], + ) + + def to_model(self) -> ForecastInterval: + """Convert ForecastIntervalSchema to ForecastInterval value object.""" + return ForecastInterval( + start=Timestamp(self.start), + end=Timestamp(self.end), + energy=WattHours(self.energy) if self.energy is not None else None, + energy_remaining=WattHours(self.energy_remaining) if self.energy_remaining is not None else None, + power_points=[pp.to_model() for pp in self.power_points], + ) + + +class SunSchema(BaseModel): + """Schema for Sun value object.""" + + dawn: datetime = Field(..., description="Dawn time") + sunrise: datetime = Field(..., description="Sunrise time") + noon: datetime = Field(..., description="Solar noon time") + midnight: datetime = Field(..., description="Solar midnight time") + sunset: datetime = Field(..., description="Sunset time") + dusk: datetime = Field(..., description="Dusk time") + daylight: float = Field(..., description="Daylight duration in seconds") + night: float = Field(..., description="Night duration in seconds") + twilight: float = Field(..., description="Twilight duration in seconds") + azimuth: Optional[float] = Field(None, description="Sun azimuth in degrees") + zenith: Optional[float] = Field(None, description="Sun zenith in degrees") + elevation: Optional[float] = Field(None, description="Sun elevation in degrees") + + @field_validator("dawn", "sunrise", "noon", "midnight", "sunset", "dusk") + @classmethod + def ensure_aware_timestamps(cls, v: datetime) -> datetime: + """Ensure timestamps are timezone-aware.""" + if v.tzinfo is None: + return v.replace(tzinfo=get_timezone()) + return v + + @computed_field # type: ignore[prop-decorator] + @property + def time_before_sunrise(self) -> Optional[float]: + """Returns the time remaining until sunrise in seconds.""" + now = timezone_now() + if self.sunrise < now: + return None + return (self.sunrise - now).total_seconds() + + @computed_field # type: ignore[prop-decorator] + @property + def time_after_sunrise(self) -> float: + """Returns the time elapsed since sunrise in seconds.""" + return (timezone_now() - self.sunrise).total_seconds() + + @computed_field # type: ignore[prop-decorator] + @property + def time_before_sunset(self) -> Optional[float]: + """Returns the time remaining until sunset in seconds.""" + now = timezone_now() + if self.sunset < now: + return None + return (self.sunset - now).total_seconds() + + @computed_field # type: ignore[prop-decorator] + @property + def time_after_sunset(self) -> float: + """Returns the time elapsed since sunset in seconds.""" + return (timezone_now() - self.sunset).total_seconds() + + @classmethod + def from_model(cls, sun: Sun) -> "SunSchema": + """Create schema from Sun value object.""" + return cls( + dawn=sun.dawn, + sunrise=sun.sunrise, + noon=sun.noon, + midnight=sun.midnight, + sunset=sun.sunset, + dusk=sun.dusk, + daylight=sun.daylight.total_seconds(), + night=sun.night.total_seconds(), + twilight=sun.twilight.total_seconds(), + azimuth=sun.azimuth, + zenith=sun.zenith, + elevation=sun.elevation, + ) + + def to_model(self) -> Sun: + """Convert schema to Sun value object.""" + return Sun( + dawn=self.dawn, + sunrise=self.sunrise, + noon=self.noon, + midnight=self.midnight, + sunset=self.sunset, + dusk=self.dusk, + daylight=timedelta(seconds=self.daylight), + night=timedelta(seconds=self.night), + twilight=timedelta(seconds=self.twilight), + azimuth=self.azimuth, + zenith=self.zenith, + elevation=self.elevation, + ) + + +class ForecastSchema(BaseModel): + """Schema for Forecast aggregate root.""" + + id: str = Field(..., description="Unique identifier for the forecast") + timestamp: datetime = Field(..., description="When this forecast was generated or last updated") + intervals: List[ForecastIntervalSchema] = Field(default_factory=list, description="Forecast intervals") + + @field_validator("timestamp") + @classmethod + def ensure_aware_timestamp(cls, v: datetime) -> datetime: + """Ensure timestamp is timezone-aware.""" + if v.tzinfo is None: + return v.replace(tzinfo=get_timezone()) + return v + + @computed_field # type: ignore[prop-decorator] + @property + def next_hour_power(self) -> Optional[float]: + """Get the forecasted power for the next hour in Watts.""" + if not self.intervals: + return None + + # Sort intervals by start time + sorted_intervals = sorted(self.intervals, key=lambda i: i.start) + + # Find the first interval that starts in the next hour + next_hour_start = timezone_now() + timedelta(hours=1) + + for interval in sorted_intervals: + if interval.start <= next_hour_start < interval.end: + # Get the average power in this interval + return interval.avg_power + + return None + + @computed_field # type: ignore[prop-decorator] + @property + def avg_next_4_hours_power(self) -> float: + """Get the average predicted power for the next 4 hours in Watts.""" + if not self.intervals: + return 0.0 + + # Sort intervals by start time + sorted_intervals = sorted(self.intervals, key=lambda i: i.start) + + total_power = 0.0 + count = 0 + + # Calculate average power over the next 4 hours + now = timezone_now() + four_hours_later = now + timedelta(hours=4) + + for interval in sorted_intervals: + if interval.start < four_hours_later and interval.end > now: + total_power += interval.avg_power + count += 1 + + if count == 0: + return 0.0 + + avg_power = total_power / count + return round(avg_power, 3) + + @computed_field # type: ignore[prop-decorator] + @property + def next_hour_energy(self) -> float: + """Get the predicted energy for the next hour in WattHours.""" + if not self.intervals: + return 0.0 + + now = timezone_now() + one_hour_later = now + timedelta(hours=1) + + total_energy = 0.0 + + for interval in self.intervals: + # Calculate overlap window + overlap_start = max(interval.start, now) + overlap_end = min(interval.end, one_hour_later) + + if overlap_start < overlap_end and interval.energy is not None: + # Calculate energy for the overlapping interval + overlap_duration_sec = (overlap_end - overlap_start).total_seconds() + interval_duration_sec = interval.duration + + if interval_duration_sec > 0: + # Compute the energy ratio for the overlapping interval + ratio = overlap_duration_sec / interval_duration_sec + total_energy += interval.energy * ratio + + return round(total_energy, 3) + + @field_validator("id") + @classmethod + def validate_id(cls, v: str) -> str: + """Validate that id is a valid UUID string.""" + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("id must be a valid UUID string") from exc + return v + + @classmethod + def from_model(cls, forecast: Forecast) -> "ForecastSchema": + """Create ForecastSchema from Forecast aggregate root.""" + return cls( + id=str(forecast.id), + timestamp=forecast.timestamp, + intervals=[ForecastIntervalSchema.from_model(interval) for interval in forecast.intervals], + ) + + def to_model(self) -> Forecast: + """Convert ForecastSchema to Forecast aggregate root.""" + return Forecast( + id=EntityId(uuid.UUID(self.id)), + timestamp=Timestamp(self.timestamp), + intervals=[interval.to_model() for interval in self.intervals], + ) + + @field_serializer("id") + def serialize_id(self, value: str) -> str: + """Serialize id field.""" + return str(value) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + arbitrary_types_allowed = True + json_encoders = { + uuid.UUID: str, + datetime: lambda v: v.isoformat(), + timedelta: lambda v: v.total_seconds(), + } + + +class ForecastProviderSchema(BaseModel): + """Schema for ForecastProvider entity with complete validation.""" + + id: str = Field(..., description="Unique identifier for the forecast provider") + name: str = Field(default="", description="Forecast provider name") + adapter_type: ForecastProviderAdapter = Field( + default=ForecastProviderAdapter.DUMMY_SOLAR, description="Type of forecast provider adapter" + ) + config: dict = Field(default={}, description="Forecast provider configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @field_validator("id") + @classmethod + def validate_id(cls, v: str) -> str: + """Validate that id is a valid UUID string.""" + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("id must be a valid UUID string") from exc + return v + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate forecast provider name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("adapter_type") + @classmethod + def validate_adapter_type(cls, v: str) -> ForecastProviderAdapter: + """Validate that adapter_type is a recognized ForecastProviderAdapter.""" + adapter_values = [adapter.value for adapter in ForecastProviderAdapter] + if v not in adapter_values: + raise ValueError(f"adapter_type must be one of {adapter_values}") + return ForecastProviderAdapter(v) + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that external_service_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("external_service_id must be a valid UUID string") from exc + return v + + @classmethod + def from_model(cls, forecast_provider: ForecastProvider) -> "ForecastProviderSchema": + """Create ForecastProviderSchema from a ForecastProvider domain model instance.""" + return cls( + id=str(forecast_provider.id), + name=forecast_provider.name, + adapter_type=forecast_provider.adapter_type, + config=forecast_provider.config.to_dict() if forecast_provider.config else {}, + external_service_id=( + str(forecast_provider.external_service_id) if forecast_provider.external_service_id else None + ), + ) + + @field_serializer("id") + def serialize_id(self, value: str) -> str: + """Serialize id field.""" + return str(value) + + @field_serializer("external_service_id") + def serialize_external_service_id(self, value: Optional[str]) -> Optional[str]: + """Serialize external_service_id field.""" + return str(value) if value is not None else None + + def to_model(self) -> ForecastProvider: + """Convert ForecastProviderSchema to ForecastProvider domain model instance.""" + configuration: Optional[ForecastProviderConfig] = None + if self.config: + config_class = FORECAST_PROVIDER_CONFIG_TYPE_MAP.get(self.adapter_type, None) + if config_class: + configuration = cast(ForecastProviderConfig, config_class.from_dict(self.config)) + + return ForecastProvider( + id=EntityId(uuid.UUID(self.id)), + name=self.name, + adapter_type=self.adapter_type, + config=configuration, + external_service_id=EntityId(uuid.UUID(self.external_service_id)) if self.external_service_id else None, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + arbitrary_types_allowed = True + json_encoders = { + uuid.UUID: str, + ForecastProviderAdapter: lambda v: v.value, + } + + +class ForecastProviderCreateSchema(BaseModel): + """Schema for creating a new forecast provider.""" + + name: str = Field(default="", description="Forecast provider name") + adapter_type: ForecastProviderAdapter = Field( + default=ForecastProviderAdapter.DUMMY_SOLAR, description="Type of forecast provider adapter" + ) + config: Optional[dict] = Field(default=None, description="Forecast provider configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate forecast provider name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("adapter_type") + @classmethod + def validate_adapter_type(cls, v: str) -> ForecastProviderAdapter: + """Validate that adapter_type is a recognized ForecastProviderAdapter.""" + adapter_values = [adapter.value for adapter in ForecastProviderAdapter] + if v not in adapter_values: + raise ValueError(f"adapter_type must be one of {adapter_values}") + return ForecastProviderAdapter(v) + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that external_service_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("external_service_id must be a valid UUID string") from exc + return v + + def to_model(self) -> ForecastProvider: + """Convert ForecastProviderCreateSchema to a ForecastProvider domain model instance.""" + configuration: Optional[ForecastProviderConfig] = None + if self.config: + config_class = FORECAST_PROVIDER_CONFIG_TYPE_MAP.get(self.adapter_type, None) + if config_class: + configuration = cast(ForecastProviderConfig, config_class.from_dict(self.config)) + else: + if self.adapter_type: + # If adapter type is provided but config is not, initialize with default config + config_class = FORECAST_PROVIDER_CONFIG_TYPE_MAP.get(self.adapter_type, None) + if config_class: + configuration = cast(ForecastProviderConfig, config_class()) + + return ForecastProvider( + id=EntityId(uuid.uuid4()), + name=self.name, + adapter_type=self.adapter_type, + config=configuration, + external_service_id=EntityId(uuid.UUID(self.external_service_id)) if self.external_service_id else None, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + json_encoders = { + uuid.UUID: str, + ForecastProviderAdapter: lambda v: v.value, + } + + +class ForecastProviderUpdateSchema(BaseModel): + """Schema for updating an existing forecast provider.""" + + name: str = Field(default="", description="Forecast provider name") + config: Optional[dict] = Field(default=None, description="Forecast provider configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate forecast provider name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that external_service_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("external_service_id must be a valid UUID string") from exc + return v + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + json_encoders = { + uuid.UUID: str, + } + + +class ForecastProviderDummySolarConfigSchema(BaseModel): + """Schema for Dummy Solar ForecastProviderConfig.""" + + latitude: float = Field(default=41.90, description="Latitude for solar calculations") + longitude: float = Field(default=12.49, description="Longitude for solar calculations") + capacity_kwp: float = Field(default=0.0, ge=0, description="Solar panel capacity in kWp") + efficiency_percent: float = Field(default=80.0, ge=0, le=100, description="Solar panel efficiency percentage") + production_start_hour: int = Field(default=6, ge=0, le=23, description="Hour when production starts (0-23)") + production_end_hour: int = Field(default=20, ge=0, le=23, description="Hour when production ends (0-23)") + + @field_validator("latitude") + @classmethod + def validate_latitude(cls, v: float) -> float: + """Validate latitude is within valid range.""" + if not -90 <= v <= 90: + raise ValueError("latitude must be between -90 and 90") + return v + + @field_validator("longitude") + @classmethod + def validate_longitude(cls, v: float) -> float: + """Validate longitude is within valid range.""" + if not -180 <= v <= 180: + raise ValueError("longitude must be between -180 and 180") + return v + + @field_validator("capacity_kwp") + @classmethod + def validate_capacity_kwp(cls, v: float) -> float: + """Validate capacity is non-negative.""" + if v < 0: + raise ValueError("capacity_kwp must be non-negative") + return v + + @field_validator("efficiency_percent") + @classmethod + def validate_efficiency_percent(cls, v: float) -> float: + """Validate efficiency is between 0 and 100.""" + if not 0 <= v <= 100: + raise ValueError("efficiency_percent must be between 0 and 100") + return v + + @field_validator("production_start_hour", "production_end_hour") + @classmethod + def validate_hour(cls, v: int) -> int: + """Validate hour is between 0 and 23.""" + if not 0 <= v <= 23: + raise ValueError("hour must be between 0 and 23") + return v + + def to_model(self) -> ForecastProviderDummySolarConfig: + """Convert schema to ForecastProviderDummySolarConfig adapter configuration model instance.""" + return ForecastProviderDummySolarConfig( + latitude=self.latitude, + longitude=self.longitude, + capacity_kwp=self.capacity_kwp, + efficiency_percent=self.efficiency_percent, + production_start_hour=self.production_start_hour, + production_end_hour=self.production_end_hour, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + +class ForecastProviderHomeAssistantConfigSchema(BaseModel): + """Schema for Home Assistant ForecastProviderConfig.""" + + entity_forecast_power_actual_h: str = Field( + default="", description="Home Assistant power forecast actual hour entity" + ) + entity_forecast_power_next_1h: str = Field(default="", description="Home Assistant power forecast next 1h entity") + entity_forecast_power_next_12h: str = Field(default="", description="Home Assistant power forecast next 12h entity") + entity_forecast_power_next_24h: str = Field(default="", description="Home Assistant power forecast next 24h entity") + entity_forecast_energy_actual_h: str = Field( + default="", description="Home Assistant energy forecast actual hour entity" + ) + entity_forecast_energy_next_1h: str = Field(default="", description="Home Assistant energy forecast next 1h entity") + entity_forecast_energy_today: str = Field(default="", description="Home Assistant energy forecast today entity") + entity_forecast_energy_tomorrow: str = Field( + default="", description="Home Assistant energy forecast tomorrow entity" + ) + entity_forecast_energy_remaining_today: str = Field( + default="", description="Home Assistant energy forecast remaining today entity" + ) + unit_forecast_power_actual_h: str = Field(default="W", description="Power forecast actual hour unit") + unit_forecast_power_next_1h: str = Field(default="W", description="Power forecast next 1h unit") + unit_forecast_power_next_12h: str = Field(default="W", description="Power forecast next 12h unit") + unit_forecast_power_next_24h: str = Field(default="W", description="Power forecast next 24h unit") + unit_forecast_energy_actual_h: str = Field(default="kWh", description="Energy forecast actual hour unit") + unit_forecast_energy_next_1h: str = Field(default="kWh", description="Energy forecast next 1h unit") + unit_forecast_energy_today: str = Field(default="kWh", description="Energy forecast today unit") + unit_forecast_energy_tomorrow: str = Field(default="kWh", description="Energy forecast tomorrow unit") + unit_forecast_energy_remaining_today: str = Field(default="kWh", description="Energy forecast remaining today unit") + + def to_model(self) -> ForecastProviderHomeAssistantConfig: + """Convert schema to ForecastProviderHomeAssistantConfig adapter configuration model instance.""" + return ForecastProviderHomeAssistantConfig( + entity_forecast_power_actual_h=self.entity_forecast_power_actual_h, + entity_forecast_power_next_1h=self.entity_forecast_power_next_1h, + entity_forecast_power_next_12h=self.entity_forecast_power_next_12h, + entity_forecast_power_next_24h=self.entity_forecast_power_next_24h, + entity_forecast_energy_actual_h=self.entity_forecast_energy_actual_h, + entity_forecast_energy_next_1h=self.entity_forecast_energy_next_1h, + entity_forecast_energy_today=self.entity_forecast_energy_today, + entity_forecast_energy_tomorrow=self.entity_forecast_energy_tomorrow, + entity_forecast_energy_remaining_today=self.entity_forecast_energy_remaining_today, + unit_forecast_power_actual_h=self.unit_forecast_power_actual_h, + unit_forecast_power_next_1h=self.unit_forecast_power_next_1h, + unit_forecast_power_next_12h=self.unit_forecast_power_next_12h, + unit_forecast_power_next_24h=self.unit_forecast_power_next_24h, + unit_forecast_energy_actual_h=self.unit_forecast_energy_actual_h, + unit_forecast_energy_next_1h=self.unit_forecast_energy_next_1h, + unit_forecast_energy_today=self.unit_forecast_energy_today, + unit_forecast_energy_tomorrow=self.unit_forecast_energy_tomorrow, + unit_forecast_energy_remaining_today=self.unit_forecast_energy_remaining_today, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + +FORECAST_PROVIDER_CONFIG_SCHEMA_MAP: Dict[ + type[ForecastProviderConfig], + Union[type[ForecastProviderDummySolarConfigSchema], type[ForecastProviderHomeAssistantConfigSchema]], +] = { + ForecastProviderDummySolarConfig: ForecastProviderDummySolarConfigSchema, + ForecastProviderHomeAssistantConfig: ForecastProviderHomeAssistantConfigSchema, +} diff --git a/core/edge_mining/adapters/domain/forecast/tables.py b/core/edge_mining/adapters/domain/forecast/tables.py new file mode 100644 index 0000000..2b19a05 --- /dev/null +++ b/core/edge_mining/adapters/domain/forecast/tables.py @@ -0,0 +1,140 @@ +"""SQLAlchemy ORM mappings for Forecast domain entities. + +This module implements imperative (classical) mapping of the domain entities +to database tables. The domain entities are mapped directly without +creating separate ORM model classes, maintaining domain purity. + +All tables and mappings use the shared metadata and mapper registry from +the sqlalchemy.registry module, which are available as module-level singletons. + +⚠️ DEVELOPER WARNING ⚠️ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +ANY SCHEMA CHANGE (adding/removing/modifying tables or columns) REQUIRES an +Alembic migration. Do NOT modify this file without creating a migration: + + python scripts/migrate.py create "Description of your change" + +For detailed instructions, see: ../docs/ALEMBIC_MIGRATIONS.md +For a step-by-step example, see: ../docs/MIGRATION_EXAMPLE.md +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +""" + +import json +import uuid +from typing import Any, Optional + +from sqlalchemy import Column, ForeignKey, String, Table, event + +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.common import ConfigurationType +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.registry import mapper_registry, metadata +from edge_mining.domain.common import EntityId +from edge_mining.domain.forecast.common import ForecastProviderAdapter +from edge_mining.domain.forecast.entities import ForecastProvider +from edge_mining.domain.forecast.exceptions import ForecastProviderConfigurationError +from edge_mining.shared.adapter_maps.forecast import FORECAST_PROVIDER_CONFIG_TYPE_MAP +from edge_mining.shared.interfaces.config import ForecastProviderConfig + + +class ForecastProviderConfigType(ConfigurationType): + """SQLAlchemy type for ForecastProviderConfig serialization. + + Inherits from ConfigurationType to handle JSON serialization/deserialization. + """ + + +def _deserialize_forecast_provider_config( + adapter_type: ForecastProviderAdapter, config_json: str +) -> Optional[ForecastProviderConfig]: + """Deserialize JSON string to ForecastProviderConfig based on adapter type.""" + if not config_json: + return None + + data: dict = json.loads(config_json) + + if adapter_type not in FORECAST_PROVIDER_CONFIG_TYPE_MAP: + raise ForecastProviderConfigurationError( + f"Error reading ForecastProvider configuration. Invalid type '{adapter_type}'" + ) + + config_class: Optional[type[ForecastProviderConfig]] = FORECAST_PROVIDER_CONFIG_TYPE_MAP.get(adapter_type) + if not config_class: + raise ForecastProviderConfigurationError( + f"Error creating ForecastProvider configuration. Type '{adapter_type}'" + ) + + config_instance = config_class.from_dict(data) + if not isinstance(config_instance, ForecastProviderConfig): + raise ForecastProviderConfigurationError( + f"Deserialized config is not of type ForecastProviderConfig for adapter type {adapter_type}." + ) + return config_instance + + +@event.listens_for(ForecastProvider, "load") +def _receive_forecast_provider_load(target: ForecastProvider, context) -> None: + """Event listener that deserializes config after loading from database. + + Args: + target: The ForecastProvider instance being loaded + context: SQLAlchemy context + """ + # Convert id string to EntityId if needed + if hasattr(target, "id") and target.id is not None: + if isinstance(target.id, str): # type: ignore[arg-type,misc] + target.id = EntityId(uuid.UUID(target.id)) # type: ignore[assignment] + + # Convert foreign keys to EntityId + # NOTE: SQLAlchemy returns strings for UUID columns that need conversion to EntityId + if hasattr(target, "external_service_id") and target.external_service_id is not None: + if isinstance(target.external_service_id, str): # type: ignore + target.external_service_id = EntityId(uuid.UUID(target.external_service_id)) # type: ignore + + # Convert adapter_type string to enum if needed + if isinstance(target.adapter_type, str): + try: + target.adapter_type = ForecastProviderAdapter(target.adapter_type) + except ValueError: + # If conversion fails, leave as string (will fail in config deserialization) + pass + + if target.config and isinstance(target.config, str): + target.config = _deserialize_forecast_provider_config(target.adapter_type, target.config) + + +@event.listens_for(ForecastProvider, "before_insert") +@event.listens_for(ForecastProvider, "before_update") +def _flatten_forecast_provider_composites(mapper, connection, target: Any) -> None: + """Convert enum attributes to primitive values before persisting.""" + if hasattr(target, "adapter_type") and target.adapter_type is not None: + if isinstance(target.adapter_type, ForecastProviderAdapter): + target.adapter_type = target.adapter_type.value + + +@event.listens_for(ForecastProvider, "after_insert") +@event.listens_for(ForecastProvider, "after_update") +def _restore_forecast_provider_composites(mapper, connection, target: Any) -> None: + """Restore enum attributes after persist operations.""" + if hasattr(target, "adapter_type") and target.adapter_type is not None: + if isinstance(target.adapter_type, str): + try: + target.adapter_type = ForecastProviderAdapter(target.adapter_type) + except ValueError: + pass + + +# Define the forecast_providers table using imperative style +forecast_providers_table = Table( + "forecast_providers", + metadata, + Column("id", String, primary_key=True, index=True), + Column("name", String, nullable=False), + Column("adapter_type", String, nullable=False), + Column("config", ForecastProviderConfigType, nullable=True), + Column("external_service_id", String, ForeignKey("external_services.id"), nullable=True), +) + +# Map ForecastProvider +mapper_registry.map_imperatively( + ForecastProvider, + forecast_providers_table, +) diff --git a/core/edge_mining/adapters/domain/home_load/__init__.py b/core/edge_mining/adapters/domain/home_load/__init__.py new file mode 100644 index 0000000..0bc88c2 --- /dev/null +++ b/core/edge_mining/adapters/domain/home_load/__init__.py @@ -0,0 +1 @@ +"""Adapters for the Home Load domain.""" diff --git a/core/edge_mining/adapters/domain/home_load/fast_api/__init__.py b/core/edge_mining/adapters/domain/home_load/fast_api/__init__.py new file mode 100644 index 0000000..ab53578 --- /dev/null +++ b/core/edge_mining/adapters/domain/home_load/fast_api/__init__.py @@ -0,0 +1 @@ +"""Adapter that uses FastAPI infrastructure for Home load domain API""" diff --git a/core/edge_mining/adapters/domain/home_load/fast_api/router.py b/core/edge_mining/adapters/domain/home_load/fast_api/router.py new file mode 100644 index 0000000..ddb599a --- /dev/null +++ b/core/edge_mining/adapters/domain/home_load/fast_api/router.py @@ -0,0 +1,969 @@ +"""API Router for home load domain.""" + +import uuid +from datetime import datetime, timedelta, timezone +from typing import Annotated, Any, Dict, List, Optional, cast + +from fastapi import APIRouter, Depends, HTTPException, Query + +from edge_mining.adapters.domain.home_load.history_providers.helpers import group_power_points_into_intervals +from edge_mining.adapters.domain.home_load.schemas import ( + ENERGY_LOAD_FORECAST_PROVIDER_CONFIG_SCHEMA_MAP, + ENERGY_LOAD_HISTORY_PROVIDER_CONFIG_SCHEMA_MAP, + EnergyLoadForecastProviderCreateSchema, + EnergyLoadForecastProviderSchema, + EnergyLoadForecastProviderUpdateSchema, + EnergyLoadHistoryProviderCreateSchema, + EnergyLoadHistoryProviderSchema, + EnergyLoadHistoryProviderUpdateSchema, + HomeLoadPowerPointSchema, + HomeLoadsProfileSchema, + LoadConsumptionModelSchema, + LoadDeviceCreateSchema, + LoadDeviceSchema, + LoadDeviceUpdateSchema, + LoadEnergyConsumptionSchema, +) + +# Import dependency injection setup functions +from edge_mining.adapters.infrastructure.api.setup import ( + get_adapter_service, + get_config_service, + get_home_load_history_service, + get_load_forecast_training_service, +) +from edge_mining.application.interfaces import ( + AdapterServiceInterface, + ConfigurationServiceInterface, + HomeLoadHistoryServiceInterface, + LoadForecastTrainingServiceInterface, +) +from edge_mining.domain.common import EntityId, Timestamp +from edge_mining.domain.home_load.aggregate_roots import HomeLoadsProfile +from edge_mining.domain.home_load.common import ( + EnergyLoadForecastProviderAdapter, + EnergyLoadHistoryProviderAdapter, + LoadDeviceCategory, +) +from edge_mining.domain.home_load.entities import EnergyLoadForecastProvider, EnergyLoadHistoryProvider, LoadDevice +from edge_mining.domain.home_load.exceptions import ( + EnergyLoadForecastProviderAlreadyExistsError, + EnergyLoadForecastProviderConfigurationError, + EnergyLoadForecastProviderError, + EnergyLoadForecastProviderNotFoundError, + EnergyLoadHistoryProviderAlreadyExistsError, + EnergyLoadHistoryProviderConfigurationError, + EnergyLoadHistoryProviderNotFoundError, + HomeLoadsProfileAddDeviceError, + HomeLoadsProfileAlreadyExistsError, + HomeLoadsProfileDeviceNotFoundError, + HomeLoadsProfileNotFoundError, + HomeLoadsProfileRemoveDeviceError, +) +from edge_mining.domain.home_load.value_objects import LoadEnergyConsumption +from edge_mining.shared.adapter_maps.home_load import ( + ENERGY_LOAD_FORECAST_PROVIDER_CONFIG_TYPE_MAP, + ENERGY_LOAD_HISTORY_PROVIDER_CONFIG_TYPE_MAP, +) +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.interfaces.config import EnergyLoadForecastProviderConfig, EnergyLoadHistoryProviderConfig + +router = APIRouter() + + +# Home Loads Profile endpoints +@router.get("/home-loads-profiles", response_model=List[HomeLoadsProfileSchema]) +async def get_home_loads_profiles_list( + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> List[HomeLoadsProfileSchema]: + """Get a list of all home loads profiles.""" + try: + profiles: List[HomeLoadsProfile] = config_service.list_home_loads_profiles() + + # Convert to home loads profile schema + profile_schemas: List[HomeLoadsProfileSchema] = [] + + for profile in profiles: + profile_schemas.append(HomeLoadsProfileSchema.from_model(profile)) + + return profile_schemas + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/home-loads-profiles", response_model=HomeLoadsProfileSchema) +async def add_home_loads_profile( + profile_name: str, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> HomeLoadsProfileSchema: + """Add a new home loads profile.""" + try: + # Add the profile + added_profile = config_service.add_home_loads_profile(profile_name) + + # For now, return the created profile + return HomeLoadsProfileSchema.from_model(added_profile) + except HomeLoadsProfileAlreadyExistsError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/home-loads-profiles/{profile_id}", response_model=HomeLoadsProfileSchema) +async def get_home_loads_profile( + profile_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> HomeLoadsProfileSchema: + """Get details of a specific home loads profile.""" + try: + profile = config_service.get_home_loads_profile(profile_id) + + if profile is None: + raise HomeLoadsProfileNotFoundError(f"Home Loads Profile with ID {profile_id} not found") + return HomeLoadsProfileSchema.from_model(profile) + except HomeLoadsProfileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.put("/home-loads-profiles/{profile_id}", response_model=HomeLoadsProfileSchema) +async def update_home_loads_profile( + profile_id: EntityId, + profile_new_name: str, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> HomeLoadsProfileSchema: + """Update an existing home loads profile.""" + try: + profile = config_service.update_home_loads_profile(profile_id, profile_new_name) + if profile is None: + raise HomeLoadsProfileNotFoundError(f"Home Loads Profile with ID {profile_id} not found") + response = HomeLoadsProfileSchema.from_model(profile) + return response + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.delete("/home-loads-profiles/{profile_id}", response_model=HomeLoadsProfileSchema) +async def delete_home_loads_profile( + profile_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> HomeLoadsProfileSchema: + """Remove a home loads profile.""" + try: + deleted_profile = config_service.remove_home_loads_profile(profile_id) + response = HomeLoadsProfileSchema.from_model(deleted_profile) + return response + except HomeLoadsProfileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +# Load Device endpoints +@router.get("/home-loads-profiles/{profile_id}/devices", response_model=List[LoadDeviceSchema]) +async def get_load_devices_list( + profile_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> List[LoadDeviceSchema]: + """Get a list of all load devices in a profile.""" + try: + profile = config_service.get_home_loads_profile(profile_id) + + if profile is None: + raise HomeLoadsProfileNotFoundError(f"Home Loads Profile with ID {profile_id} not found") + + devices: List[LoadDeviceSchema] = [] + for device in profile.devices: + devices.append(LoadDeviceSchema.from_model(device)) + + return devices + except HomeLoadsProfileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/home-loads-profiles/{profile_id}/devices", response_model=LoadDeviceSchema) +async def add_load_device( + profile_id: EntityId, + device_data: LoadDeviceCreateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> LoadDeviceSchema: + """Add a new load device to a profile.""" + try: + # Convert to domain model + device_to_add: LoadDevice = device_data.to_model() + + added_device = config_service.add_load_device_to_profile(profile_id=profile_id, load_device=device_to_add) + + if added_device is None: + raise HomeLoadsProfileAddDeviceError(f"Failed to add load device to profile {profile_id}") + + return LoadDeviceSchema.from_model(added_device) + except HomeLoadsProfileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except HomeLoadsProfileAddDeviceError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/home-loads-profiles/{profile_id}/devices/{device_id}", response_model=LoadDeviceSchema) +async def get_load_device( + profile_id: EntityId, + device_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> LoadDeviceSchema: + """Get details of a specific load device.""" + try: + profile = config_service.get_home_loads_profile(profile_id) + + if profile is None: + raise HomeLoadsProfileNotFoundError(f"Home Loads Profile with ID {profile_id} not found") + + # Find the specific device + device = next((d for d in profile.devices if d.id == device_id), None) + + if device is None: + raise HomeLoadsProfileDeviceNotFoundError( + f"Load Device with ID {device_id} not found in Home Loads Profile {profile_id}" + ) + return LoadDeviceSchema.from_model(device) + except HomeLoadsProfileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except HomeLoadsProfileDeviceNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.put("/home-loads-profiles/{profile_id}/devices/{device_id}", response_model=LoadDeviceSchema) +async def update_load_device( + profile_id: EntityId, + device_id: EntityId, + device_update: LoadDeviceUpdateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> LoadDeviceSchema: + """Update an existing load device.""" + try: + profile = config_service.get_home_loads_profile(profile_id) + + if profile is None: + raise HomeLoadsProfileNotFoundError(f"Home Loads Profile with ID {profile_id} not found") + + # Find the specific device + device = next((d for d in profile.devices if d.id == device_id), None) + + if device is None: + raise HomeLoadsProfileDeviceNotFoundError( + f"Load Device with ID {device_id} not found in Home Loads Profile {profile_id}" + ) + + # Remove the old device + deleted_device = config_service.remove_load_device_from_profile(profile_id, device_id) + + if deleted_device is None: + raise HomeLoadsProfileRemoveDeviceError( + f"Failed to remove existing load device with ID {device_id} from profile {profile_id}" + ) + + # Add the updated device + forecast_provider_id = ( + EntityId(uuid.UUID(device_update.energy_load_forecast_provider_id)) + if device_update.energy_load_forecast_provider_id + else device.energy_load_forecast_provider_id + ) + history_provider_id = ( + EntityId(uuid.UUID(device_update.energy_load_history_provider_id)) + if device_update.energy_load_history_provider_id + else device.energy_load_history_provider_id + ) + category = ( + LoadDeviceCategory(device_update.category) + if isinstance(device_update.category, str) + else device_update.category + ) + new_device = LoadDevice( + id=device.id, + name=device_update.name or device.name, + category=category, + enabled=device_update.enabled, + energy_load_forecast_provider_id=forecast_provider_id, + energy_load_history_provider_id=history_provider_id, + ) + + device_added = config_service.add_load_device_to_profile( + profile_id=profile_id, + load_device=new_device, + ) + + if device_added is None: + raise HomeLoadsProfileAddDeviceError(f"Failed to add updated load device to profile {profile_id}") + + return LoadDeviceSchema.from_model(device_added) + except HomeLoadsProfileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except HomeLoadsProfileDeviceNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except HomeLoadsProfileRemoveDeviceError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except HomeLoadsProfileAddDeviceError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.delete("/home-loads-profiles/{profile_id}/devices/{device_id}", response_model=LoadDeviceSchema) +async def delete_load_device( + profile_id: EntityId, + device_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> LoadDeviceSchema: + """Remove a load device from a profile.""" + try: + delete_load_device = config_service.remove_load_device_from_profile(profile_id, device_id) + + if delete_load_device is None: + raise HomeLoadsProfileRemoveDeviceError( + f"Failed to remove load device with ID {device_id} from profile {profile_id}" + ) + response = LoadDeviceSchema.from_model(delete_load_device) + return response + except HomeLoadsProfileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except HomeLoadsProfileRemoveDeviceError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +# Energy Load Forecast Provider endpoints +@router.get("/energy-load-forecast-providers", response_model=List[EnergyLoadForecastProviderSchema]) +async def get_energy_load_forecast_providers_list( + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> List[EnergyLoadForecastProviderSchema]: + """Get a list of all energy load forecast providers.""" + try: + providers = config_service.list_energy_load_forecast_providers() + return [EnergyLoadForecastProviderSchema.from_model(p) for p in providers] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/energy-load-forecast-providers", response_model=EnergyLoadForecastProviderSchema) +async def add_energy_load_forecast_provider( + provider_data: EnergyLoadForecastProviderCreateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyLoadForecastProviderSchema: + """Add a new energy load forecast provider.""" + try: + provider_to_add: EnergyLoadForecastProvider = provider_data.to_model() + + if provider_to_add.config is None: + raise EnergyLoadForecastProviderConfigurationError( + "Energy Load Forecast provider configuration should be set" + ) + + added = config_service.add_energy_load_forecast_provider(provider_to_add) + return EnergyLoadForecastProviderSchema.from_model(added) + + except EnergyLoadForecastProviderAlreadyExistsError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except EnergyLoadForecastProviderConfigurationError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/energy-load-forecast-providers/types", response_model=List[EnergyLoadForecastProviderAdapter]) +async def get_energy_load_forecast_provider_types() -> List[EnergyLoadForecastProviderAdapter]: + """Get a list of available energy load forecast provider types.""" + try: + return [EnergyLoadForecastProviderAdapter(adapter.value) for adapter in EnergyLoadForecastProviderAdapter] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get( + "/energy-load-forecast-providers/types/{adapter_type}/external-services", + response_model=Optional[ExternalServiceAdapter], +) +async def get_energy_load_forecast_provider_type_external_service_types( + adapter_type: EnergyLoadForecastProviderAdapter, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> Optional[ExternalServiceAdapter]: + """Get the compatible external service type for a specific energy load forecast provider type.""" + try: + return config_service.get_energy_load_forecast_provider_external_service_adapter(adapter_type) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get( + "/energy-load-forecast-providers/types/{adapter_type}/config-schema", + response_model=Dict[str, Any], +) +async def get_energy_load_forecast_provider_config_schema( + adapter_type: EnergyLoadForecastProviderAdapter, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> Dict[str, Any]: + """Get the configuration schema for a specific energy load forecast provider type.""" + try: + try: + provider_adapter = EnergyLoadForecastProviderAdapter(adapter_type.value) + except ValueError as e: + raise ValueError(f"Invalid energy load forecast provider adapter type: {adapter_type}") from e + + # Get the corresponding configuration class for the adapter type + provider_config_type: Optional[type[EnergyLoadForecastProviderConfig]] = ( + ENERGY_LOAD_FORECAST_PROVIDER_CONFIG_TYPE_MAP.get(provider_adapter) + ) + + if provider_config_type is None: + raise EnergyLoadForecastProviderConfigurationError( + f"No configuration class found for adapter type {adapter_type}" + ) + + # Get the corresponding schema class + schema_class = ENERGY_LOAD_FORECAST_PROVIDER_CONFIG_SCHEMA_MAP.get(provider_config_type) + if schema_class is None: + raise EnergyLoadForecastProviderConfigurationError( + f"No schema found for configuration class {provider_config_type}" + ) + + # Return the JSON schema + return schema_class.model_json_schema() + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/energy-load-forecast-providers/{provider_id}", response_model=EnergyLoadForecastProviderSchema) +async def get_energy_load_forecast_provider( + provider_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyLoadForecastProviderSchema: + """Get details of a specific energy load forecast provider.""" + try: + provider = config_service.get_energy_load_forecast_provider(provider_id) + if provider is None: + raise EnergyLoadForecastProviderNotFoundError( + f"Energy Load Forecast Provider with ID {provider_id} not found" + ) + return EnergyLoadForecastProviderSchema.from_model(provider) + except EnergyLoadForecastProviderNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.put("/energy-load-forecast-providers/{provider_id}", response_model=EnergyLoadForecastProviderSchema) +async def update_energy_load_forecast_provider( + provider_id: EntityId, + provider_update: EnergyLoadForecastProviderUpdateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyLoadForecastProviderSchema: + """Update an existing energy load forecast provider.""" + try: + existing = config_service.get_energy_load_forecast_provider(provider_id) + if existing is None: + raise EnergyLoadForecastProviderNotFoundError( + f"Energy Load Forecast Provider with ID {provider_id} not found" + ) + existing.name = provider_update.name or existing.name + if provider_update.config is not None and existing.adapter_type: + config_type = ENERGY_LOAD_FORECAST_PROVIDER_CONFIG_TYPE_MAP.get(existing.adapter_type) + if config_type: + existing.config = cast(EnergyLoadForecastProviderConfig, config_type.from_dict(provider_update.config)) + if provider_update.external_service_id is not None: + existing.external_service_id = EntityId(uuid.UUID(provider_update.external_service_id)) + updated = config_service.update_energy_load_forecast_provider(existing) + return EnergyLoadForecastProviderSchema.from_model(updated) + except EnergyLoadForecastProviderNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.delete("/energy-load-forecast-providers/{provider_id}", response_model=EnergyLoadForecastProviderSchema) +async def delete_energy_load_forecast_provider( + provider_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyLoadForecastProviderSchema: + """Remove an energy load forecast provider.""" + try: + removed = config_service.remove_energy_load_forecast_provider(provider_id) + return EnergyLoadForecastProviderSchema.from_model(removed) + except EnergyLoadForecastProviderNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +# --- Energy Load History Provider endpoints --- + + +@router.get("/energy-load-history-providers", response_model=List[EnergyLoadHistoryProviderSchema]) +async def get_energy_load_history_providers_list( + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> List[EnergyLoadHistoryProviderSchema]: + """Get a list of all energy load history providers.""" + try: + providers = config_service.list_energy_load_history_providers() + return [EnergyLoadHistoryProviderSchema.from_model(p) for p in providers] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/energy-load-history-providers", response_model=EnergyLoadHistoryProviderSchema) +async def add_energy_load_history_provider( + provider_data: EnergyLoadHistoryProviderCreateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyLoadHistoryProviderSchema: + """Add a new energy load history provider.""" + try: + provider_to_add: EnergyLoadHistoryProvider = provider_data.to_model() + added = config_service.add_energy_load_history_provider(provider_to_add) + return EnergyLoadHistoryProviderSchema.from_model(added) + except EnergyLoadHistoryProviderAlreadyExistsError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except EnergyLoadHistoryProviderConfigurationError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/energy-load-history-providers/types", response_model=List[EnergyLoadHistoryProviderAdapter]) +async def get_energy_load_history_provider_types() -> List[EnergyLoadHistoryProviderAdapter]: + """Get a list of available energy load history provider types.""" + try: + return [EnergyLoadHistoryProviderAdapter(adapter.value) for adapter in EnergyLoadHistoryProviderAdapter] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get( + "/energy-load-history-providers/types/{adapter_type}/external-services", + response_model=Optional[ExternalServiceAdapter], +) +async def get_energy_load_history_provider_type_external_service_types( + adapter_type: EnergyLoadHistoryProviderAdapter, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> Optional[ExternalServiceAdapter]: + """Get the compatible external service type for a specific energy load history provider type.""" + try: + return config_service.get_energy_load_history_provider_external_service_adapter(adapter_type) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get( + "/energy-load-history-providers/types/{adapter_type}/config-schema", + response_model=Dict[str, Any], +) +async def get_energy_load_history_provider_config_schema( + adapter_type: EnergyLoadHistoryProviderAdapter, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> Dict[str, Any]: + """Get the configuration schema for a specific energy load history provider type.""" + try: + try: + provider_adapter = EnergyLoadHistoryProviderAdapter(adapter_type.value) + except ValueError as e: + raise ValueError(f"Invalid energy load history provider adapter type: {adapter_type}") from e + + provider_config_type: Optional[type[EnergyLoadHistoryProviderConfig]] = ( + ENERGY_LOAD_HISTORY_PROVIDER_CONFIG_TYPE_MAP.get(provider_adapter) + ) + + if provider_config_type is None: + return {} # Some adapters (e.g. DUMMY) have no configuration + + schema_class = ENERGY_LOAD_HISTORY_PROVIDER_CONFIG_SCHEMA_MAP.get(provider_config_type) + if schema_class is None: + raise EnergyLoadHistoryProviderConfigurationError( + f"No schema found for configuration class {provider_config_type}" + ) + + return schema_class.model_json_schema() + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/energy-load-history-providers/{provider_id}", response_model=EnergyLoadHistoryProviderSchema) +async def get_energy_load_history_provider( + provider_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyLoadHistoryProviderSchema: + """Get details of a specific energy load history provider.""" + try: + provider = config_service.get_energy_load_history_provider(provider_id) + if provider is None: + raise EnergyLoadHistoryProviderNotFoundError( + f"Energy Load History Provider with ID {provider_id} not found" + ) + return EnergyLoadHistoryProviderSchema.from_model(provider) + except EnergyLoadHistoryProviderNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.put("/energy-load-history-providers/{provider_id}", response_model=EnergyLoadHistoryProviderSchema) +async def update_energy_load_history_provider( + provider_id: EntityId, + provider_update: EnergyLoadHistoryProviderUpdateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyLoadHistoryProviderSchema: + """Update an existing energy load history provider.""" + try: + existing = config_service.get_energy_load_history_provider(provider_id) + if existing is None: + raise EnergyLoadHistoryProviderNotFoundError( + f"Energy Load History Provider with ID {provider_id} not found" + ) + existing.name = provider_update.name or existing.name + if provider_update.config is not None and existing.adapter_type: + config_type = ENERGY_LOAD_HISTORY_PROVIDER_CONFIG_TYPE_MAP.get(existing.adapter_type) + if config_type: + existing.config = cast(EnergyLoadHistoryProviderConfig, config_type.from_dict(provider_update.config)) + if provider_update.external_service_id is not None: + existing.external_service_id = EntityId(uuid.UUID(provider_update.external_service_id)) + updated = config_service.update_energy_load_history_provider(existing) + return EnergyLoadHistoryProviderSchema.from_model(updated) + except EnergyLoadHistoryProviderNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.delete("/energy-load-history-providers/{provider_id}", response_model=EnergyLoadHistoryProviderSchema) +async def delete_energy_load_history_provider( + provider_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyLoadHistoryProviderSchema: + """Remove an energy load history provider.""" + try: + removed = config_service.remove_energy_load_history_provider(provider_id) + return EnergyLoadHistoryProviderSchema.from_model(removed) + except EnergyLoadHistoryProviderNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +# --- Device History Data endpoints --- + + +@router.get( + "/home-loads-profiles/{profile_id}/devices/{device_id}/history", + response_model=List[HomeLoadPowerPointSchema], +) +async def get_device_history( + profile_id: EntityId, + device_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], + history_service: Annotated[HomeLoadHistoryServiceInterface, Depends(get_home_load_history_service)], + start: datetime = Query(..., description="Start of the time window (ISO 8601)"), + end: datetime = Query(..., description="End of the time window (ISO 8601)"), +) -> List[HomeLoadPowerPointSchema]: + """Get historical power points for a specific device within a time window.""" + try: + # Validate that profile and device exist + profile = config_service.get_home_loads_profile(profile_id) + if profile is None: + raise HomeLoadsProfileNotFoundError(f"Home Loads Profile with ID {profile_id} not found") + + device = next((d for d in profile.devices if d.id == device_id), None) + if device is None: + raise HomeLoadsProfileDeviceNotFoundError( + f"Load Device with ID {device_id} not found in Home Loads Profile {profile_id}" + ) + + points = history_service.get_device_history(device_id, Timestamp(start), Timestamp(end)) + return [HomeLoadPowerPointSchema.from_model(p) for p in points] + except HomeLoadsProfileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except HomeLoadsProfileDeviceNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get( + "/home-loads-profiles/{profile_id}/devices/{device_id}/forecast", + response_model=LoadEnergyConsumptionSchema, +) +async def get_device_forecast( + profile_id: EntityId, + device_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], + adapter_service: Annotated[AdapterServiceInterface, Depends(get_adapter_service)], + history_service: Annotated[HomeLoadHistoryServiceInterface, Depends(get_home_load_history_service)], + hours_ahead: int = Query(default=3, ge=1, le=48, description="Forecast horizon in hours"), + history_hours: int = Query(default=72, ge=1, le=720, description="Hours of history to feed the model"), +) -> LoadEnergyConsumptionSchema: + """Get energy consumption forecast for a specific device.""" + try: + profile = config_service.get_home_loads_profile(profile_id) + if profile is None: + raise HomeLoadsProfileNotFoundError(f"Home Loads Profile with ID {profile_id} not found") + + device = next((d for d in profile.devices if d.id == device_id), None) + if device is None: + raise HomeLoadsProfileDeviceNotFoundError( + f"Load Device with ID {device_id} not found in Home Loads Profile {profile_id}" + ) + + if not device.energy_load_forecast_provider_id: + raise HTTPException( + status_code=400, + detail=f"Device '{device.name}' has no forecast provider configured.", + ) + + forecast_provider = adapter_service.get_home_load_forecast_provider(device.energy_load_forecast_provider_id) + if forecast_provider is None: + raise HTTPException( + status_code=500, + detail=f"Could not initialize forecast provider for device '{device.name}'.", + ) + + now = Timestamp(datetime.now(timezone.utc)) + history_start = Timestamp(now - timedelta(hours=history_hours)) + power_points = history_service.get_device_history(device_id, history_start, now) + + if not power_points: + raise HTTPException( + status_code=400, + detail=f"No history data available for device '{device.name}'. Collect history first.", + ) + + intervals = group_power_points_into_intervals(power_points) + consumption = LoadEnergyConsumption(timestamp=now, intervals=intervals) + + forecast = forecast_provider.get_consumption_forecast(consumption, hours_ahead=hours_ahead) + if forecast is None: + raise HTTPException( + status_code=500, + detail=f"Forecast provider returned no data for device '{device.name}'.", + ) + + return LoadEnergyConsumptionSchema.from_model(forecast) + except HomeLoadsProfileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except HomeLoadsProfileDeviceNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except EnergyLoadForecastProviderError as e: + min_hours = getattr(forecast_provider, "min_required_history_hours", None) + detail = str(e) + if min_hours: + detail += f" (minimum required: {min_hours} hours)" + raise HTTPException(status_code=400, detail=detail) from e + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post( + "/home-loads-profiles/{profile_id}/devices/{device_id}/history/collect", + response_model=Dict[str, str], +) +async def collect_device_history( + profile_id: EntityId, + device_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], + history_service: Annotated[HomeLoadHistoryServiceInterface, Depends(get_home_load_history_service)], + lookback_hours: int = Query(default=24, ge=1, le=720, description="Hours of history to fetch on first collection"), +) -> Dict[str, str]: + """Fetch power points from the history provider and store them in the database.""" + try: + profile = config_service.get_home_loads_profile(profile_id) + if profile is None: + raise HomeLoadsProfileNotFoundError(f"Home Loads Profile with ID {profile_id} not found") + + device = next((d for d in profile.devices if d.id == device_id), None) + if device is None: + raise HomeLoadsProfileDeviceNotFoundError( + f"Load Device with ID {device_id} not found in Home Loads Profile {profile_id}" + ) + + if not device.energy_load_history_provider_id: + raise HTTPException( + status_code=400, + detail=f"Device '{device.name}' has no history provider configured.", + ) + + await history_service.collect_devices([device_id], lookback_hours=lookback_hours) + return {"status": "completed", "detail": f"History collection completed for device '{device.name}'."} + except HomeLoadsProfileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except HomeLoadsProfileDeviceNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.delete( + "/home-loads-profiles/{profile_id}/devices/{device_id}/history", + response_model=Dict[str, str], +) +async def delete_device_history( + profile_id: EntityId, + device_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], + history_service: Annotated[HomeLoadHistoryServiceInterface, Depends(get_home_load_history_service)], +) -> Dict[str, str]: + """Delete all stored power points for a specific device.""" + try: + profile = config_service.get_home_loads_profile(profile_id) + if profile is None: + raise HomeLoadsProfileNotFoundError(f"Home Loads Profile with ID {profile_id} not found") + + device = next((d for d in profile.devices if d.id == device_id), None) + if device is None: + raise HomeLoadsProfileDeviceNotFoundError( + f"Load Device with ID {device_id} not found in Home Loads Profile {profile_id}" + ) + + removed = history_service.clear_device_history(device_id) + return { + "status": "completed", + "detail": f"Deleted {removed} power points for device '{device.name}'.", + } + except HomeLoadsProfileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except HomeLoadsProfileDeviceNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +# --- History Collection endpoints --- + + +@router.post("/history/collect", response_model=Dict[str, str]) +async def trigger_history_collection( + history_service: Annotated[HomeLoadHistoryServiceInterface, Depends(get_home_load_history_service)], + lookback_hours: int = Query(default=24, ge=1, le=720, description="Hours of history to fetch on first collection"), +) -> Dict[str, str]: + """Manually trigger power-point collection for all enabled devices.""" + try: + await history_service.collect_all(lookback_hours=lookback_hours) + return {"status": "completed", "detail": "History collection completed for all eligible devices."} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/history/collect/devices", response_model=Dict[str, str]) +async def trigger_history_collection_for_devices( + device_ids: List[str], + history_service: Annotated[HomeLoadHistoryServiceInterface, Depends(get_home_load_history_service)], + lookback_hours: int = Query(default=24, ge=1, le=720, description="Hours of history to fetch on first collection"), +) -> Dict[str, str]: + """Manually trigger power-point collection for specific devices.""" + try: + parsed_ids = [EntityId(uuid.UUID(did)) for did in device_ids] + await history_service.collect_devices(parsed_ids, lookback_hours=lookback_hours) + return { + "status": "completed", + "detail": f"History collection completed for {len(parsed_ids)} device(s).", + } + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid device ID: {e}") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +# --- Training endpoints --- + + +@router.post("/training/trigger", response_model=Dict[str, str]) +async def trigger_training_all( + training_service: Annotated[LoadForecastTrainingServiceInterface, Depends(get_load_forecast_training_service)], + weeks_lookback: int = Query(default=8, ge=1, le=52, description="Weeks of history to use"), +) -> Dict[str, str]: + """Trigger ML model training for all enabled devices.""" + try: + await training_service.train_all(weeks_lookback=weeks_lookback) + return {"status": "completed", "detail": "Training completed for all eligible devices."} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post( + "/home-loads-profiles/{profile_id}/devices/{device_id}/training/trigger", + response_model=Dict[str, str], +) +async def trigger_training_device( + profile_id: EntityId, + device_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], + training_service: Annotated[LoadForecastTrainingServiceInterface, Depends(get_load_forecast_training_service)], + weeks_lookback: int = Query(default=8, ge=1, le=52, description="Weeks of history to use"), +) -> Dict[str, str]: + """Trigger ML model training for a specific device.""" + try: + # Validate that profile and device exist + profile = config_service.get_home_loads_profile(profile_id) + if profile is None: + raise HomeLoadsProfileNotFoundError(f"Home Loads Profile with ID {profile_id} not found") + + device = next((d for d in profile.devices if d.id == device_id), None) + if device is None: + raise HomeLoadsProfileDeviceNotFoundError( + f"Load Device with ID {device_id} not found in Home Loads Profile {profile_id}" + ) + + await training_service.train_device(device_id, weeks_lookback=weeks_lookback) + return {"status": "completed", "detail": f"Training completed for device '{device.name}'."} + except HomeLoadsProfileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except HomeLoadsProfileDeviceNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +# --- Training Models endpoints --- + + +@router.get("/training/models", response_model=List[LoadConsumptionModelSchema]) +async def get_training_models( + training_service: Annotated[LoadForecastTrainingServiceInterface, Depends(get_load_forecast_training_service)], + device_id: Optional[str] = Query(default=None, description="Filter by device UUID"), +) -> List[LoadConsumptionModelSchema]: + """List trained ML models, optionally filtered by device.""" + try: + filter_device_id = EntityId(uuid.UUID(device_id)) if device_id else None + models = training_service.get_models(device_id=filter_device_id) + return [LoadConsumptionModelSchema.from_model(m) for m in models] + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid device_id: {e}") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.delete("/training/models/{model_id}", status_code=204) +async def delete_training_model( + model_id: str, + training_service: Annotated[LoadForecastTrainingServiceInterface, Depends(get_load_forecast_training_service)], +) -> None: + """Delete a trained ML model by ID.""" + try: + entity_id = EntityId(uuid.UUID(model_id)) + training_service.delete_model(entity_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid model_id: {e}") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e diff --git a/core/edge_mining/adapters/domain/home_load/forecast_providers/__init__.py b/core/edge_mining/adapters/domain/home_load/forecast_providers/__init__.py new file mode 100644 index 0000000..da0cc0b --- /dev/null +++ b/core/edge_mining/adapters/domain/home_load/forecast_providers/__init__.py @@ -0,0 +1 @@ +"""Collection of home load forecast provider adapters.""" diff --git a/core/edge_mining/adapters/domain/home_load/forecast_providers/dummy.py b/core/edge_mining/adapters/domain/home_load/forecast_providers/dummy.py new file mode 100644 index 0000000..2843fe5 --- /dev/null +++ b/core/edge_mining/adapters/domain/home_load/forecast_providers/dummy.py @@ -0,0 +1,101 @@ +""" +Dummy adapter (Implementation of Port) that simulates +the home loads forecast for Edge Mining Application. +""" + +import random +from datetime import datetime, timedelta, timezone +from typing import List, Optional + +from edge_mining.domain.common import Timestamp, WattHours, Watts +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter +from edge_mining.domain.home_load.exceptions import EnergyLoadForecastProviderError +from edge_mining.domain.home_load.ports import EnergyLoadForecastProviderPort +from edge_mining.domain.home_load.value_objects import HomeLoadEnergyInterval, HomeLoadPowerPoint, LoadEnergyConsumption +from edge_mining.shared.adapter_configs.home_load import EnergyLoadForecastProviderDummyConfig +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.interfaces.factories import EnergyLoadForecastAdapterFactory +from edge_mining.shared.logging.port import LoggerPort + + +class DummyEnergyLoadForecastProviderFactory(EnergyLoadForecastAdapterFactory): + """Factory for creating a DummyEnergyLoadForecastProvider instance.""" + + def create( + self, + config: Optional[Configuration], + logger: Optional[LoggerPort], + external_service: Optional[ExternalServicePort], + ) -> "DummyEnergyLoadForecastProvider": + if config is not None and not isinstance(config, EnergyLoadForecastProviderDummyConfig): + raise EnergyLoadForecastProviderError( + "Invalid configuration type for Dummy energy load forecast provider. " + "Expected EnergyLoadForecastProviderDummyConfig." + ) + + load_power_max = 500.0 + if isinstance(config, EnergyLoadForecastProviderDummyConfig): + load_power_max = config.load_power_max + + return DummyEnergyLoadForecastProvider( + load_power_max=load_power_max, + logger=logger, + ) + + +class DummyEnergyLoadForecastProvider(EnergyLoadForecastProviderPort): + """Generates a very basic fake energy load forecast. + + Ignores historical data and emits a random average load per hour bounded + by ``load_power_max``. Useful as a placeholder until an ML/DL forecaster + is wired in. + """ + + def __init__( + self, + load_power_max: float = 500.0, + logger: Optional[LoggerPort] = None, + ): + super().__init__(forecast_provider_type=EnergyLoadForecastProviderAdapter.DUMMY) + self._logger = logger + self.load_power_max = load_power_max + + def get_consumption_forecast( + self, consumption_history: LoadEnergyConsumption, hours_ahead: int = 3 + ) -> Optional[LoadEnergyConsumption]: + """Produce a naive forecast of hourly consumption over ``hours_ahead``.""" + if hours_ahead <= 0: + return None + + now = Timestamp(datetime.now(timezone.utc)) + + if consumption_history.intervals: + # Simple baseline: replay the average of the last observed hour. + baseline_power = consumption_history.intervals[-1].avg_power + else: + baseline_power = Watts(random.uniform(200.0, self.load_power_max)) + + intervals: List[HomeLoadEnergyInterval] = [] + for i in range(hours_ahead): + start = now + timedelta(hours=i) + end = start + timedelta(hours=1) + point = HomeLoadPowerPoint(timestamp=start, power=baseline_power) + intervals.append( + HomeLoadEnergyInterval( + start=start, + end=end, + power_points=[point], + energy=WattHours(float(baseline_power)), + ) + ) + + forecast = LoadEnergyConsumption(timestamp=now, intervals=intervals) + + if self._logger: + self._logger.debug( + f"DummyEnergyLoadForecastProvider: baseline {baseline_power:.0f}W, " + f"{hours_ahead}h ahead, avg_power={forecast.avg_power:.0f}W" + ) + + return forecast diff --git a/core/edge_mining/adapters/domain/home_load/forecast_providers/features.py b/core/edge_mining/adapters/domain/home_load/forecast_providers/features.py new file mode 100644 index 0000000..44476ff --- /dev/null +++ b/core/edge_mining/adapters/domain/home_load/forecast_providers/features.py @@ -0,0 +1,140 @@ +"""Feature engineering utilities for ML-based home load forecast providers. + +Converts LoadEnergyConsumption interval data into structured feature arrays +suitable for scikit-learn / statsmodels / XGBoost models. +""" + +from datetime import datetime, timedelta +from typing import List, Optional, Tuple + +from edge_mining.domain.home_load.value_objects import LoadEnergyConsumption + + +def intervals_to_hourly_series( + consumption: LoadEnergyConsumption, +) -> List[Tuple[datetime, float]]: + """Convert intervals to a sorted list of (timestamp, avg_power) pairs. + + Missing hours are NOT filled — that is the caller's responsibility + (e.g. via ``fill_missing_hours``). + """ + pairs: List[Tuple[datetime, float]] = [] + for interval in consumption.intervals: + pairs.append((interval.start, float(interval.avg_power))) + pairs.sort(key=lambda x: x[0]) + return pairs + + +def fill_missing_hours( + series: List[Tuple[datetime, float]], + start: Optional[datetime] = None, + end: Optional[datetime] = None, + fill_value: float = 0.0, +) -> List[Tuple[datetime, float]]: + """Ensure contiguous hourly coverage by inserting fill_value for missing slots. + + If *start*/*end* are not provided they default to the min/max of the + existing series. + """ + if not series: + return [] + + existing = {ts.replace(minute=0, second=0, microsecond=0): power for ts, power in series} + first = start or min(existing) + last = end or max(existing) + + result: List[Tuple[datetime, float]] = [] + current = first.replace(minute=0, second=0, microsecond=0) + last_rounded = last.replace(minute=0, second=0, microsecond=0) + while current <= last_rounded: + result.append((current, existing.get(current, fill_value))) + current += timedelta(hours=1) + return result + + +def build_calendar_features(timestamps: List[datetime]) -> List[List[float]]: + """Build calendar feature vectors for a list of timestamps. + + Each row contains: + [hour_of_day, day_of_week, is_weekend, month] + + All values are numeric (float) for direct use in sklearn / XGBoost. + """ + features: List[List[float]] = [] + for ts in timestamps: + hour = float(ts.hour) + dow = float(ts.weekday()) # 0=Mon … 6=Sun + is_weekend = 1.0 if dow >= 5 else 0.0 + month = float(ts.month) + features.append([hour, dow, is_weekend, month]) + return features + + +def build_lag_features( + power_values: List[float], + lags: Optional[List[int]] = None, +) -> List[List[Optional[float]]]: + """Build lag feature vectors from a power time series. + + Default lags: 1h, 2h, 3h, 24h (same hour yesterday), 168h (same hour last week). + + Returns a list of rows; each row has one value per lag. + Positions where the lag is not available are filled with ``None``. + """ + if lags is None: + lags = [1, 2, 3, 24, 168] + + rows: List[List[Optional[float]]] = [] + for i in range(len(power_values)): + row: List[Optional[float]] = [] + for lag in lags: + idx = i - lag + row.append(power_values[idx] if idx >= 0 else None) + rows.append(row) + return rows + + +def prepare_supervised_dataset( + consumption: LoadEnergyConsumption, + hours_ahead: int = 3, + lags: Optional[List[int]] = None, +) -> Tuple[List[List[float]], List[float]]: + """Build X (features) and y (targets) from historical consumption. + + Each sample is one historical hour; features are calendar + lag; + target is the avg_power ``hours_ahead`` hours later. + + Rows where lags or target are unavailable are dropped. + + Returns (X, y) where X is a list of feature rows and y is a list of targets. + """ + if lags is None: + lags = [1, 2, 3, 24, 168] + + series = intervals_to_hourly_series(consumption) + series = fill_missing_hours(series) + + if not series: + return [], [] + + timestamps = [ts for ts, _ in series] + powers = [p for _, p in series] + calendar = build_calendar_features(timestamps) + lag_rows = build_lag_features(powers, lags=lags) + + max_lag = max(lags) if lags else 0 + + X: List[List[float]] = [] + y: List[float] = [] + + for i in range(max_lag, len(series) - hours_ahead): + lag_row = lag_rows[i] + # skip if any lag is None (should not happen past max_lag, but be safe) + if any(v is None for v in lag_row): + continue + features = calendar[i] + [float(v) for v in lag_row] # type: ignore[arg-type] + target = powers[i + hours_ahead] + X.append(features) + y.append(target) + + return X, y diff --git a/core/edge_mining/adapters/domain/home_load/forecast_providers/naive_last_hour.py b/core/edge_mining/adapters/domain/home_load/forecast_providers/naive_last_hour.py new file mode 100644 index 0000000..1461f72 --- /dev/null +++ b/core/edge_mining/adapters/domain/home_load/forecast_providers/naive_last_hour.py @@ -0,0 +1,108 @@ +"""NaiveLastHour forecast provider for energy load consumption.""" + +from datetime import datetime, timedelta, timezone +from typing import List, Optional + +from edge_mining.domain.common import Timestamp, WattHours, Watts +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter +from edge_mining.domain.home_load.exceptions import EnergyLoadForecastProviderError +from edge_mining.domain.home_load.ports import EnergyLoadForecastProviderPort +from edge_mining.domain.home_load.value_objects import ( + HomeLoadEnergyInterval, + HomeLoadPowerPoint, + LoadEnergyConsumption, +) +from edge_mining.shared.adapter_configs.home_load import EnergyLoadForecastProviderNaiveLastHourConfig +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.interfaces.factories import EnergyLoadForecastAdapterFactory +from edge_mining.shared.logging.port import LoggerPort + + +class NaiveLastHourForecastProviderFactory(EnergyLoadForecastAdapterFactory): + """Factory for creating a NaiveLastHourForecastProvider instance.""" + + def create( + self, + config: Optional[Configuration], + logger: Optional[LoggerPort], + external_service: Optional[ExternalServicePort], + ) -> "NaiveLastHourForecastProvider": + if config is not None and not isinstance(config, EnergyLoadForecastProviderNaiveLastHourConfig): + raise EnergyLoadForecastProviderError( + "Invalid configuration type for NaiveLastHour energy load forecast provider. " + "Expected EnergyLoadForecastProviderNaiveLastHourConfig." + ) + + hours_ahead = 3 + if isinstance(config, EnergyLoadForecastProviderNaiveLastHourConfig): + hours_ahead = config.hours_ahead + + return NaiveLastHourForecastProvider( + hours_ahead=hours_ahead, + logger=logger, + ) + + +class NaiveLastHourForecastProvider(EnergyLoadForecastProviderPort): + """Forecast by repeating the average power of the last hour for N hours ahead. + + This is the simplest non-trivial baseline: it assumes the near future will + look like the recent past. Always available as a fallback even with very + little historical data (only 1 hour needed). + """ + + def __init__(self, hours_ahead: int = 3, logger: Optional[LoggerPort] = None): + super().__init__(forecast_provider_type=EnergyLoadForecastProviderAdapter.NAIVE_LAST_HOUR) + self._hours_ahead = hours_ahead + self._logger = logger + + @property + def min_required_history_hours(self) -> int: # noqa: D102 + return 1 + + def get_consumption_forecast( + self, consumption_history: LoadEnergyConsumption, hours_ahead: int = 3 + ) -> Optional[LoadEnergyConsumption]: + effective_hours = self._hours_ahead or hours_ahead + if effective_hours <= 0: + return None + + now = Timestamp(datetime.now(timezone.utc)) + + # Compute baseline from the last hour of history + last_hour = consumption_history.in_last_hours(1, now=now) + if last_hour.intervals: + baseline_power = last_hour.avg_power + elif consumption_history.intervals: + # Fallback: use overall average if last hour is empty + baseline_power = consumption_history.avg_power + else: + # No history at all — cannot forecast + return None + + if float(baseline_power) <= 0: + baseline_power = Watts(0.0) + + intervals: List[HomeLoadEnergyInterval] = [] + for i in range(effective_hours): + start = Timestamp(now + timedelta(hours=i)) + end = Timestamp(start + timedelta(hours=1)) + point = HomeLoadPowerPoint(timestamp=start, power=baseline_power) + intervals.append( + HomeLoadEnergyInterval( + start=start, + end=end, + power_points=[point], + energy=WattHours(float(baseline_power)), + ) + ) + + forecast = LoadEnergyConsumption(timestamp=now, intervals=intervals) + + if self._logger: + self._logger.debug( + f"NaiveLastHourForecastProvider: baseline {baseline_power:.0f}W, " + f"{effective_hours}h ahead, total_energy={forecast.total_energy:.0f}Wh" + ) + return forecast diff --git a/core/edge_mining/adapters/domain/home_load/forecast_providers/naive_persistence.py b/core/edge_mining/adapters/domain/home_load/forecast_providers/naive_persistence.py new file mode 100644 index 0000000..3ab742e --- /dev/null +++ b/core/edge_mining/adapters/domain/home_load/forecast_providers/naive_persistence.py @@ -0,0 +1,129 @@ +"""NaivePersistence forecast provider for energy load consumption. + +Forecasts by repeating the consumption profile from the *same hours of the +previous day*. Unlike ``NaiveLastHour`` (which repeats a single recent average), +this provider preserves the intra-day shape of the load profile — capturing +morning peaks, afternoon dips, etc. + +Inspired by the "naive/persistence" method used in EMHASS. +""" + +from datetime import datetime, timedelta, timezone +from typing import List, Optional + +from edge_mining.domain.common import Timestamp, WattHours, Watts +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter +from edge_mining.domain.home_load.exceptions import EnergyLoadForecastProviderError +from edge_mining.domain.home_load.ports import EnergyLoadForecastProviderPort +from edge_mining.domain.home_load.value_objects import ( + HomeLoadEnergyInterval, + HomeLoadPowerPoint, + LoadEnergyConsumption, +) +from edge_mining.shared.adapter_configs.home_load import EnergyLoadForecastProviderNaivePersistenceConfig +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.interfaces.factories import EnergyLoadForecastAdapterFactory +from edge_mining.shared.logging.port import LoggerPort + + +class NaivePersistenceForecastProviderFactory(EnergyLoadForecastAdapterFactory): + """Factory for creating a NaivePersistenceForecastProvider instance.""" + + def create( + self, + config: Optional[Configuration], + logger: Optional[LoggerPort], + external_service: Optional[ExternalServicePort], + ) -> "NaivePersistenceForecastProvider": + if config is not None and not isinstance(config, EnergyLoadForecastProviderNaivePersistenceConfig): + raise EnergyLoadForecastProviderError( + "Invalid configuration type for NaivePersistence energy load forecast provider. " + "Expected EnergyLoadForecastProviderNaivePersistenceConfig." + ) + + hours_ahead = 24 + delta_days = 1 + if isinstance(config, EnergyLoadForecastProviderNaivePersistenceConfig): + hours_ahead = config.hours_ahead + delta_days = config.delta_days + + return NaivePersistenceForecastProvider( + hours_ahead=hours_ahead, + delta_days=delta_days, + logger=logger, + ) + + +class NaivePersistenceForecastProvider(EnergyLoadForecastProviderPort): + """Forecast by repeating the load profile from ``delta_days`` ago. + + For each future hour, this provider looks up the corresponding hour from + ``delta_days`` days in the past and uses that power value. If a specific + hour slot is missing from history, the overall history average is used as + fallback. + """ + + def __init__( + self, + hours_ahead: int = 24, + delta_days: int = 1, + logger: Optional[LoggerPort] = None, + ): + super().__init__(forecast_provider_type=EnergyLoadForecastProviderAdapter.NAIVE_PERSISTENCE) + self._hours_ahead = hours_ahead + self._delta_days = delta_days + self._logger = logger + + @property + def min_required_history_hours(self) -> int: # noqa: D102 + return self._delta_days * 24 + + def get_consumption_forecast( + self, consumption_history: LoadEnergyConsumption, hours_ahead: int = 24 + ) -> Optional[LoadEnergyConsumption]: + effective_hours = self._hours_ahead + if effective_hours <= 0: + return None + + if not consumption_history.intervals: + return None + + now = Timestamp(datetime.now(timezone.utc)) + fallback_power = consumption_history.avg_power + + # Build an hour-of-day → power lookup from the reference day + reference_date = (now - timedelta(days=self._delta_days)).date() + hour_power: dict[int, float] = {} + for interval in consumption_history.intervals: + if interval.start.date() == reference_date: + hour_power[interval.start.hour] = float(interval.avg_power) + + intervals: List[HomeLoadEnergyInterval] = [] + for i in range(effective_hours): + start = Timestamp(now + timedelta(hours=i)) + end = Timestamp(start + timedelta(hours=1)) + target_hour = start.hour + + power = Watts(hour_power.get(target_hour, float(fallback_power))) + if float(power) < 0: + power = Watts(0.0) + + point = HomeLoadPowerPoint(timestamp=start, power=power) + intervals.append( + HomeLoadEnergyInterval( + start=start, + end=end, + power_points=[point], + energy=WattHours(float(power)), + ) + ) + + forecast = LoadEnergyConsumption(timestamp=now, intervals=intervals) + + if self._logger: + self._logger.debug( + f"NaivePersistenceForecastProvider: delta_days={self._delta_days}, " + f"{effective_hours}h ahead, total_energy={forecast.total_energy:.0f}Wh" + ) + return forecast diff --git a/core/edge_mining/adapters/domain/home_load/forecast_providers/seasonal_baseline.py b/core/edge_mining/adapters/domain/home_load/forecast_providers/seasonal_baseline.py new file mode 100644 index 0000000..54909dc --- /dev/null +++ b/core/edge_mining/adapters/domain/home_load/forecast_providers/seasonal_baseline.py @@ -0,0 +1,130 @@ +"""SeasonalBaseline forecast provider for energy load consumption.""" + +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from typing import Dict, List, Optional, Tuple + +from edge_mining.domain.common import Timestamp, WattHours, Watts +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter +from edge_mining.domain.home_load.exceptions import EnergyLoadForecastProviderError +from edge_mining.domain.home_load.ports import EnergyLoadForecastProviderPort +from edge_mining.domain.home_load.value_objects import ( + HomeLoadEnergyInterval, + HomeLoadPowerPoint, + LoadEnergyConsumption, +) +from edge_mining.shared.adapter_configs.home_load import EnergyLoadForecastProviderSeasonalBaselineConfig +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.interfaces.factories import EnergyLoadForecastAdapterFactory +from edge_mining.shared.logging.port import LoggerPort + + +class SeasonalBaselineForecastProviderFactory(EnergyLoadForecastAdapterFactory): + """Factory for creating a SeasonalBaselineForecastProvider instance.""" + + def create( + self, + config: Optional[Configuration], + logger: Optional[LoggerPort], + external_service: Optional[ExternalServicePort], + ) -> "SeasonalBaselineForecastProvider": + if config is not None and not isinstance(config, EnergyLoadForecastProviderSeasonalBaselineConfig): + raise EnergyLoadForecastProviderError( + "Invalid configuration type for SeasonalBaseline energy load forecast provider. " + "Expected EnergyLoadForecastProviderSeasonalBaselineConfig." + ) + + hours_ahead = 3 + weeks_lookback = 4 + if isinstance(config, EnergyLoadForecastProviderSeasonalBaselineConfig): + hours_ahead = config.hours_ahead + weeks_lookback = config.weeks_lookback + + return SeasonalBaselineForecastProvider( + hours_ahead=hours_ahead, + weeks_lookback=weeks_lookback, + logger=logger, + ) + + +class SeasonalBaselineForecastProvider(EnergyLoadForecastProviderPort): + """Forecast by averaging historical power for each (hour_of_day, day_of_week) slot. + + Uses a configurable look-back window (default 4 weeks) to build a profile + of typical consumption per time slot. For CONTINUOUS and SEASONAL devices + this is a strong baseline. + + If insufficient data exists for a particular slot, falls back to the global + average across all available data. + """ + + def __init__( + self, + hours_ahead: int = 3, + weeks_lookback: int = 4, + logger: Optional[LoggerPort] = None, + ): + super().__init__(forecast_provider_type=EnergyLoadForecastProviderAdapter.SEASONAL_BASELINE) + self._hours_ahead = hours_ahead + self._weeks_lookback = weeks_lookback + self._logger = logger + + def get_consumption_forecast( + self, consumption_history: LoadEnergyConsumption, hours_ahead: int = 3 + ) -> Optional[LoadEnergyConsumption]: + effective_hours = self._hours_ahead or hours_ahead + if effective_hours <= 0: + return None + + if not consumption_history.intervals: + return None + + # Build seasonal profile: (day_of_week, hour_of_day) → list of avg_power + profile: Dict[Tuple[int, int], List[float]] = defaultdict(list) + for interval in consumption_history.intervals: + dow = interval.start.weekday() # 0=Monday + hod = interval.start.hour + power = float(interval.avg_power) + if power > 0: + profile[(dow, hod)].append(power) + + if not profile: + return None + + # Global fallback: average of all observed power values + all_powers = [p for powers in profile.values() for p in powers] + global_avg = sum(all_powers) / len(all_powers) if all_powers else 0.0 + + now = Timestamp(datetime.now(timezone.utc)) + intervals: List[HomeLoadEnergyInterval] = [] + for i in range(effective_hours): + start = Timestamp(now + timedelta(hours=i)) + end = Timestamp(start + timedelta(hours=1)) + + dow = start.weekday() + hod = start.hour + slot_values = profile.get((dow, hod)) + if slot_values: + slot_power = Watts(sum(slot_values) / len(slot_values)) + else: + slot_power = Watts(global_avg) + + point = HomeLoadPowerPoint(timestamp=start, power=slot_power) + intervals.append( + HomeLoadEnergyInterval( + start=start, + end=end, + power_points=[point], + energy=WattHours(float(slot_power)), + ) + ) + + forecast = LoadEnergyConsumption(timestamp=now, intervals=intervals) + + if self._logger: + self._logger.debug( + f"SeasonalBaselineForecastProvider: {len(profile)} slots, " + f"{effective_hours}h ahead, total_energy={forecast.total_energy:.0f}Wh" + ) + return forecast diff --git a/core/edge_mining/adapters/domain/home_load/forecast_providers/skforecast_provider.py b/core/edge_mining/adapters/domain/home_load/forecast_providers/skforecast_provider.py new file mode 100644 index 0000000..ce83daf --- /dev/null +++ b/core/edge_mining/adapters/domain/home_load/forecast_providers/skforecast_provider.py @@ -0,0 +1,410 @@ +"""Skforecast ForecasterRecursive provider for energy load consumption. + +Uses ``skforecast.recursive.ForecasterRecursive`` with a configurable +scikit-learn regressor backend. The forecaster handles auto-regressive +multi-step prediction natively: it feeds its own predictions back as input +for subsequent steps. + +Supported sklearn models (selected via ``sklearn_model`` config string): + RandomForestRegressor, GradientBoostingRegressor, ExtraTreesRegressor, + KNeighborsRegressor, Ridge, Lasso, ElasticNet, AdaBoostRegressor, + MLPRegressor, SVR. + +If a pre-trained model exists in ``model_repo`` it is loaded. Otherwise +the provider fits on-the-fly from the supplied consumption history. +""" + +import pickle +from datetime import datetime, timedelta, timezone +from typing import Dict, List, Optional, Type + +from edge_mining.domain.common import EntityId, Timestamp, WattHours, Watts +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter +from edge_mining.domain.home_load.exceptions import EnergyLoadForecastProviderError +from edge_mining.domain.home_load.ports import EnergyLoadForecastProviderPort, LoadConsumptionModelRepository +from edge_mining.domain.home_load.value_objects import ( + HomeLoadEnergyInterval, + HomeLoadPowerPoint, + LoadEnergyConsumption, +) +from edge_mining.shared.adapter_configs.home_load import EnergyLoadForecastProviderSkforecastConfig +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.interfaces.factories import EnergyLoadForecastAdapterFactory +from edge_mining.shared.logging.port import LoggerPort + +from .features import fill_missing_hours, intervals_to_hourly_series + +# --------------------------------------------------------------------------- +# Lazy imports — heavy dependencies +# --------------------------------------------------------------------------- +_SKFORECAST_AVAILABLE = False +try: + import pandas as pd + from skforecast.recursive import ForecasterRecursive + + _SKFORECAST_AVAILABLE = True +except ImportError: + pd = None # type: ignore[assignment] + ForecasterRecursive = None # type: ignore[assignment,misc] + +# --------------------------------------------------------------------------- +# Mapping from config string → sklearn class (lazy-resolved) +# --------------------------------------------------------------------------- +_SKLEARN_MODEL_REGISTRY: Dict[str, str] = { + "RandomForestRegressor": "sklearn.ensemble.RandomForestRegressor", + "GradientBoostingRegressor": "sklearn.ensemble.GradientBoostingRegressor", + "ExtraTreesRegressor": "sklearn.ensemble.ExtraTreesRegressor", + "AdaBoostRegressor": "sklearn.ensemble.AdaBoostRegressor", + "KNeighborsRegressor": "sklearn.neighbors.KNeighborsRegressor", + "Ridge": "sklearn.linear_model.Ridge", + "Lasso": "sklearn.linear_model.Lasso", + "ElasticNet": "sklearn.linear_model.ElasticNet", + "MLPRegressor": "sklearn.neural_network.MLPRegressor", + "SVR": "sklearn.svm.SVR", +} + + +def _resolve_sklearn_model(name: str) -> object: + """Instantiate an sklearn regressor by its class name.""" + import importlib + + fqn = _SKLEARN_MODEL_REGISTRY.get(name) + if fqn is None: + raise EnergyLoadForecastProviderError( + f"Unsupported sklearn model '{name}'. Available: {list(_SKLEARN_MODEL_REGISTRY.keys())}" + ) + module_path, class_name = fqn.rsplit(".", 1) + module = importlib.import_module(module_path) + cls: Type = getattr(module, class_name) + return cls() + + +class SkforecastForecastProviderFactory(EnergyLoadForecastAdapterFactory): + """Factory for creating a SkforecastForecastProvider instance.""" + + def __init__(self, model_repo: Optional[LoadConsumptionModelRepository] = None) -> None: + self._model_repo = model_repo + + def create( + self, + config: Optional[Configuration], + logger: Optional[LoggerPort], + external_service: Optional[ExternalServicePort], + ) -> "SkforecastForecastProvider": + if config is not None and not isinstance(config, EnergyLoadForecastProviderSkforecastConfig): + raise EnergyLoadForecastProviderError( + "Invalid configuration type for Skforecast energy load forecast provider. " + "Expected EnergyLoadForecastProviderSkforecastConfig." + ) + + hours_ahead = 24 + weeks_lookback = 8 + sklearn_model = "RandomForestRegressor" + num_lags = 72 + if isinstance(config, EnergyLoadForecastProviderSkforecastConfig): + hours_ahead = config.hours_ahead + weeks_lookback = config.weeks_lookback + sklearn_model = config.sklearn_model + num_lags = config.num_lags + + return SkforecastForecastProvider( + hours_ahead=hours_ahead, + weeks_lookback=weeks_lookback, + sklearn_model=sklearn_model, + num_lags=num_lags, + model_repo=self._model_repo, + logger=logger, + ) + + +class SkforecastForecastProvider(EnergyLoadForecastProviderPort): + """Forecast provider using skforecast ForecasterRecursive. + + Uses a configurable sklearn regressor wrapped in ``ForecasterRecursive`` + which automatically manages lag features and recursive multi-step + prediction. + + If a pre-trained model is available in ``model_repo``, it is loaded and + used directly. Otherwise, fits on-the-fly from the provided history. + """ + + def __init__( + self, + hours_ahead: int = 24, + weeks_lookback: int = 8, + sklearn_model: str = "RandomForestRegressor", + num_lags: int = 72, + model_repo: Optional[LoadConsumptionModelRepository] = None, + device_id: Optional[EntityId] = None, + logger: Optional[LoggerPort] = None, + ): + super().__init__(forecast_provider_type=EnergyLoadForecastProviderAdapter.SKFORECAST) + self._hours_ahead = hours_ahead + self._weeks_lookback = weeks_lookback + self._sklearn_model = sklearn_model + self._num_lags = num_lags + self._model_repo = model_repo + self._device_id = device_id + self._logger = logger + + @property + def min_required_history_hours(self) -> int: # noqa: D102 + return self._num_lags + 48 + self._hours_ahead + + def get_consumption_forecast( + self, consumption_history: LoadEnergyConsumption, hours_ahead: int = 24 + ) -> Optional[LoadEnergyConsumption]: + if not _SKFORECAST_AVAILABLE: + if self._logger: + self._logger.warning("skforecast is not installed. Skipping Skforecast forecast.") + return None + + effective_hours = self._hours_ahead + if effective_hours <= 0: + return None + + if not consumption_history.intervals: + return None + + # Try saved model first + forecast = self._predict_from_saved_model(effective_hours) + if forecast is not None: + return forecast + + # Fallback: fit on-the-fly + return self._fit_and_predict(consumption_history, effective_hours) + + def _predict_from_saved_model(self, steps: int) -> Optional[LoadEnergyConsumption]: + """Load a pre-trained ForecasterRecursive from model_repo and predict.""" + if self._model_repo is None: + return None + + model_entity = self._model_repo.get_active_model(EnergyLoadForecastProviderAdapter.SKFORECAST, self._device_id) + if model_entity is None or model_entity.model_bytes is None: + return None + + try: + forecaster = pickle.loads(model_entity.model_bytes) # noqa: S301 + predictions = forecaster.predict(steps=steps) + return self._build_forecast(predictions.tolist()) + except Exception as exc: + if self._logger: + self._logger.warning(f"Failed to predict from saved skforecast model: {exc}") + return None + + def _fit_and_predict( + self, consumption_history: LoadEnergyConsumption, steps: int + ) -> Optional[LoadEnergyConsumption]: + """Fit ForecasterRecursive on the fly and predict.""" + series = intervals_to_hourly_series(consumption_history) + series = fill_missing_hours(series) + powers = [p for _, p in series] + + if len(powers) < self._num_lags + steps: + if self._logger: + self._logger.debug( + f"Insufficient data for skforecast: {len(powers)} points, " + f"need {self._num_lags + steps} (lags + steps)." + ) + return None + + try: + regressor = _resolve_sklearn_model(self._sklearn_model) + forecaster = ForecasterRecursive(estimator=regressor, lags=self._num_lags) + + y = pd.Series(powers, name="power") + forecaster.fit(y=y) + + predictions = forecaster.predict(steps=steps) + return self._build_forecast(predictions.tolist()) + except Exception as exc: + if self._logger: + self._logger.warning(f"Skforecast on-the-fly fit failed: {exc}") + return None + + @staticmethod + def _build_forecast(predictions: List[float]) -> LoadEnergyConsumption: + """Convert a list of predicted power values to LoadEnergyConsumption.""" + now = Timestamp(datetime.now(timezone.utc)) + intervals: List[HomeLoadEnergyInterval] = [] + for i, power_val in enumerate(predictions): + start = Timestamp(now + timedelta(hours=i)) + end = Timestamp(start + timedelta(hours=1)) + power = Watts(max(0.0, float(power_val))) + point = HomeLoadPowerPoint(timestamp=start, power=power) + intervals.append( + HomeLoadEnergyInterval( + start=start, + end=end, + power_points=[point], + energy=WattHours(float(power)), + ) + ) + return LoadEnergyConsumption(timestamp=now, intervals=intervals) + + @staticmethod + def tune( + y_series: "pd.Series", + sklearn_model_name: str = "RandomForestRegressor", + num_lags: int = 72, + steps: int = 24, + n_trials: int = 20, + metric: str = "mean_absolute_error", + ) -> tuple: + """Run Bayesian hyperparameter optimisation via Optuna. + + Returns ``(best_params, tuned_forecaster)`` where *best_params* is a + dict of the winning hyperparameter combination and *tuned_forecaster* + is the ``ForecasterRecursive`` already refit with those params. + + This is a **static helper** so it can be called from the training + service without instantiating a full provider. + """ + import optuna + from skforecast.model_selection import TimeSeriesFold, bayesian_search_forecaster + + optuna.logging.set_verbosity(optuna.logging.WARNING) + + regressor = _resolve_sklearn_model(sklearn_model_name) + forecaster = ForecasterRecursive(estimator=regressor, lags=num_lags) + + cv = TimeSeriesFold( + steps=steps, + initial_train_size=len(y_series) - steps * 2, + refit=False, + fixed_train_size=False, + ) + + search_space = _build_search_space(sklearn_model_name) + + results_df, _study = bayesian_search_forecaster( + forecaster=forecaster, + y=y_series, + cv=cv, + search_space=search_space, + metric=metric, + n_trials=n_trials, + return_best=True, + verbose=False, + show_progress=False, + ) + + best_params = results_df.iloc[0].to_dict() if not results_df.empty else {} + # Keep only hyperparameter keys (filter out metric columns) + param_keys = {k for k in best_params if k not in ("mean_absolute_error", "mean_squared_error", metric)} + best_params = {k: v for k, v in best_params.items() if k in param_keys} + + # return_best=True refits the forecaster in-place with the best params + return best_params, forecaster + + @staticmethod + def backtest( + forecaster: "ForecasterRecursive", + y_series: "pd.Series", + steps: int = 24, + folds: int = 3, + metric: str = "mean_absolute_error", + ) -> dict: + """Run rolling-window backtesting on an already-fit forecaster. + + Returns a dict with ``backtest_mae``, ``backtest_rmse`` and + ``backtest_folds``. + """ + import numpy as np + from skforecast.model_selection import TimeSeriesFold, backtesting_forecaster + + # Need at least window_size + steps*(folds+1) data points + window = getattr(forecaster, "window_size", steps) + min_required = window + steps * (folds + 1) + if len(y_series) < min_required: + return {"backtest_mae": None, "backtest_rmse": None, "backtest_folds": 0} + + initial_train_size = len(y_series) - steps * folds + if initial_train_size <= window: + return {"backtest_mae": None, "backtest_rmse": None, "backtest_folds": 0} + + cv = TimeSeriesFold( + steps=steps, + initial_train_size=initial_train_size, + refit=False, + fixed_train_size=False, + ) + + metric_values, predictions = backtesting_forecaster( + forecaster=forecaster, + y=y_series, + cv=cv, + metric=[metric, "mean_squared_error"], + verbose=False, + show_progress=False, + ) + + # metric_values is a DataFrame with one row, columns = metric names + bt_mae = float(metric_values[metric].iloc[0]) if metric in metric_values.columns else None + bt_mse = ( + float(metric_values["mean_squared_error"].iloc[0]) + if "mean_squared_error" in metric_values.columns + else None + ) + bt_rmse = float(np.sqrt(bt_mse)) if bt_mse is not None else None + + # Number of folds = number of complete prediction windows + actual_folds = len(predictions) // steps if len(predictions) >= steps else 0 + + return { + "backtest_mae": bt_mae, + "backtest_rmse": bt_rmse, + "backtest_folds": actual_folds, + } + + +def _build_search_space(sklearn_model_name: str): + """Return an Optuna search_space callable for the given model.""" + import optuna + + def _rf_space(trial: optuna.Trial) -> dict: + return { + "n_estimators": trial.suggest_int("n_estimators", 50, 400), + "max_depth": trial.suggest_int("max_depth", 3, 20), + "min_samples_leaf": trial.suggest_int("min_samples_leaf", 1, 10), + "lags": trial.suggest_categorical("lags", [24, 48, 72]), + } + + def _gb_space(trial: optuna.Trial) -> dict: + return { + "n_estimators": trial.suggest_int("n_estimators", 50, 400), + "max_depth": trial.suggest_int("max_depth", 3, 15), + "learning_rate": trial.suggest_float("learning_rate", 0.01, 0.3, log=True), + "lags": trial.suggest_categorical("lags", [24, 48, 72]), + } + + def _ridge_space(trial: optuna.Trial) -> dict: + return { + "alpha": trial.suggest_float("alpha", 0.01, 100.0, log=True), + "lags": trial.suggest_categorical("lags", [24, 48, 72]), + } + + def _knn_space(trial: optuna.Trial) -> dict: + return { + "n_neighbors": trial.suggest_int("n_neighbors", 3, 30), + "weights": trial.suggest_categorical("weights", ["uniform", "distance"]), + "lags": trial.suggest_categorical("lags", [24, 48, 72]), + } + + def _default_space(trial: optuna.Trial) -> dict: + return { + "lags": trial.suggest_categorical("lags", [24, 48, 72]), + } + + space_map = { + "RandomForestRegressor": _rf_space, + "ExtraTreesRegressor": _rf_space, + "GradientBoostingRegressor": _gb_space, + "AdaBoostRegressor": _gb_space, + "Ridge": _ridge_space, + "Lasso": _ridge_space, + "ElasticNet": _ridge_space, + "KNeighborsRegressor": _knn_space, + } + return space_map.get(sklearn_model_name, _default_space) diff --git a/core/edge_mining/adapters/domain/home_load/forecast_providers/statsmodels_hw.py b/core/edge_mining/adapters/domain/home_load/forecast_providers/statsmodels_hw.py new file mode 100644 index 0000000..d8d1b87 --- /dev/null +++ b/core/edge_mining/adapters/domain/home_load/forecast_providers/statsmodels_hw.py @@ -0,0 +1,194 @@ +"""Statsmodels (Holt-Winters / SARIMA) forecast provider for energy load consumption.""" + +import pickle +from datetime import datetime, timedelta, timezone +from typing import List, Optional + +from edge_mining.domain.common import EntityId, Timestamp, WattHours, Watts +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter +from edge_mining.domain.home_load.exceptions import EnergyLoadForecastProviderError +from edge_mining.domain.home_load.ports import EnergyLoadForecastProviderPort, LoadConsumptionModelRepository +from edge_mining.domain.home_load.value_objects import ( + HomeLoadEnergyInterval, + HomeLoadPowerPoint, + LoadEnergyConsumption, +) +from edge_mining.shared.adapter_configs.home_load import EnergyLoadForecastProviderStatsmodelsConfig +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.interfaces.factories import EnergyLoadForecastAdapterFactory +from edge_mining.shared.logging.port import LoggerPort + +from .features import fill_missing_hours, intervals_to_hourly_series + +# Lazy imports to avoid hard dependency when [ml] extras are not installed. +_HW_AVAILABLE = False +try: + from statsmodels.tsa.holtwinters import ExponentialSmoothing + + _HW_AVAILABLE = True +except ImportError: + ExponentialSmoothing = None # type: ignore[misc,assignment] + + +class StatsmodelsForecastProviderFactory(EnergyLoadForecastAdapterFactory): + """Factory for creating a StatsmodelsForecastProvider instance.""" + + def __init__(self, model_repo: Optional[LoadConsumptionModelRepository] = None) -> None: + self._model_repo = model_repo + + def create( + self, + config: Optional[Configuration], + logger: Optional[LoggerPort], + external_service: Optional[ExternalServicePort], + ) -> "StatsmodelsForecastProvider": + if config is not None and not isinstance(config, EnergyLoadForecastProviderStatsmodelsConfig): + raise EnergyLoadForecastProviderError( + "Invalid configuration type for Statsmodels energy load forecast provider. " + "Expected EnergyLoadForecastProviderStatsmodelsConfig." + ) + + hours_ahead = 3 + weeks_lookback = 8 + seasonal_periods = 24 + if isinstance(config, EnergyLoadForecastProviderStatsmodelsConfig): + hours_ahead = config.hours_ahead + weeks_lookback = config.weeks_lookback + seasonal_periods = config.seasonal_periods + + return StatsmodelsForecastProvider( + hours_ahead=hours_ahead, + weeks_lookback=weeks_lookback, + seasonal_periods=seasonal_periods, + model_repo=self._model_repo, + logger=logger, + ) + + +class StatsmodelsForecastProvider(EnergyLoadForecastProviderPort): + """Forecast provider using Holt-Winters exponential smoothing from statsmodels. + + If a pre-trained model exists in ``model_repo`` it will be used. + Otherwise the provider fits a new model on-the-fly from the supplied history + (slower, but always works as a fallback). + """ + + def __init__( + self, + hours_ahead: int = 3, + weeks_lookback: int = 8, + seasonal_periods: int = 24, + model_repo: Optional[LoadConsumptionModelRepository] = None, + device_id: Optional[EntityId] = None, + logger: Optional[LoggerPort] = None, + ): + super().__init__(forecast_provider_type=EnergyLoadForecastProviderAdapter.STATSMODELS) + self._hours_ahead = hours_ahead + self._weeks_lookback = weeks_lookback + self._seasonal_periods = seasonal_periods + self._model_repo = model_repo + self._device_id = device_id + self._logger = logger + + @property + def min_required_history_hours(self) -> int: # noqa: D102 + return self._seasonal_periods * 2 + + def get_consumption_forecast( + self, consumption_history: LoadEnergyConsumption, hours_ahead: int = 3 + ) -> Optional[LoadEnergyConsumption]: + if not _HW_AVAILABLE: + raise EnergyLoadForecastProviderError( + "statsmodels is not installed. Install the [ml] extras to enable Holt-Winters forecasting." + ) + + effective_hours = self._hours_ahead or hours_ahead + if effective_hours <= 0: + return None + + # Try to load a pre-trained model + predictions = self._predict_from_saved_model(effective_hours) + + # Fallback: fit on-the-fly + if predictions is None: + predictions = self._fit_and_predict(consumption_history, effective_hours) + + if predictions is None: + return None + + return self._build_forecast(predictions) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _predict_from_saved_model(self, hours_ahead: int) -> Optional[List[float]]: + """Try to load a saved model from the repository and forecast.""" + if self._model_repo is None: + return None + model_entity = self._model_repo.get_active_model( + adapter_type=EnergyLoadForecastProviderAdapter.STATSMODELS, + device_id=self._device_id, + ) + if model_entity is None or model_entity.model_bytes is None: + return None + try: + fitted = pickle.loads(model_entity.model_bytes) # noqa: S301 + forecast = fitted.forecast(hours_ahead) + return [max(0.0, float(v)) for v in forecast] + except Exception as exc: + if self._logger: + self._logger.warning(f"Failed to use saved statsmodels model: {exc}") + return None + + def _fit_and_predict(self, consumption_history: LoadEnergyConsumption, hours_ahead: int) -> Optional[List[float]]: + """Fit a Holt-Winters model on the provided history and forecast.""" + series = intervals_to_hourly_series(consumption_history) + series = fill_missing_hours(series) + + if len(series) < self._seasonal_periods * 2: + raise EnergyLoadForecastProviderError( + f"Insufficient data for Holt-Winters forecasting: {len(series)} hourly data points " + f"available, but at least {self._seasonal_periods * 2} are required. " + f"Collect more history before requesting a forecast." + ) + + # Limit lookback + max_points = self._weeks_lookback * 7 * 24 + if len(series) > max_points: + series = series[-max_points:] + + powers = [p for _, p in series] + + try: + model = ExponentialSmoothing( + powers, + trend="add", + seasonal="add", + seasonal_periods=self._seasonal_periods, + ) + fitted = model.fit(optimized=True) + forecast = fitted.forecast(hours_ahead) + return [max(0.0, float(v)) for v in forecast] + except Exception as exc: + raise EnergyLoadForecastProviderError(f"Holt-Winters model fitting failed: {exc}") from exc + + def _build_forecast(self, predictions: List[float]) -> LoadEnergyConsumption: + """Convert a list of predicted avg_power values to LoadEnergyConsumption.""" + now = Timestamp(datetime.now(timezone.utc)) + intervals: List[HomeLoadEnergyInterval] = [] + for i, power_val in enumerate(predictions): + start = Timestamp(now + timedelta(hours=i)) + end = Timestamp(start + timedelta(hours=1)) + power = Watts(power_val) + point = HomeLoadPowerPoint(timestamp=start, power=power) + intervals.append( + HomeLoadEnergyInterval( + start=start, + end=end, + power_points=[point], + energy=WattHours(power_val), + ) + ) + return LoadEnergyConsumption(timestamp=now, intervals=intervals) diff --git a/core/edge_mining/adapters/domain/home_load/forecast_providers/typical_profile.py b/core/edge_mining/adapters/domain/home_load/forecast_providers/typical_profile.py new file mode 100644 index 0000000..023a33c --- /dev/null +++ b/core/edge_mining/adapters/domain/home_load/forecast_providers/typical_profile.py @@ -0,0 +1,154 @@ +"""TypicalProfile forecast provider for energy load consumption. + +Forecasts by computing the "typical" consumption profile: historical data is +grouped by **(month, day_of_week, hour_of_day)** and averaged. This captures +both weekly patterns (workday vs. weekend) *and* seasonal variation (summer vs. +winter) — more granular than ``SeasonalBaseline`` which only uses (dow, hour). +""" + +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from typing import Dict, List, Optional, Tuple + +from edge_mining.domain.common import Timestamp, WattHours, Watts +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter +from edge_mining.domain.home_load.exceptions import EnergyLoadForecastProviderError +from edge_mining.domain.home_load.ports import EnergyLoadForecastProviderPort +from edge_mining.domain.home_load.value_objects import ( + HomeLoadEnergyInterval, + HomeLoadPowerPoint, + LoadEnergyConsumption, +) +from edge_mining.shared.adapter_configs.home_load import EnergyLoadForecastProviderTypicalProfileConfig +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.interfaces.factories import EnergyLoadForecastAdapterFactory +from edge_mining.shared.logging.port import LoggerPort + + +class TypicalProfileForecastProviderFactory(EnergyLoadForecastAdapterFactory): + """Factory for creating a TypicalProfileForecastProvider instance.""" + + def create( + self, + config: Optional[Configuration], + logger: Optional[LoggerPort], + external_service: Optional[ExternalServicePort], + ) -> "TypicalProfileForecastProvider": + if config is not None and not isinstance(config, EnergyLoadForecastProviderTypicalProfileConfig): + raise EnergyLoadForecastProviderError( + "Invalid configuration type for TypicalProfile energy load forecast provider. " + "Expected EnergyLoadForecastProviderTypicalProfileConfig." + ) + + hours_ahead = 24 + weeks_lookback = 8 + if isinstance(config, EnergyLoadForecastProviderTypicalProfileConfig): + hours_ahead = config.hours_ahead + weeks_lookback = config.weeks_lookback + + return TypicalProfileForecastProvider( + hours_ahead=hours_ahead, + weeks_lookback=weeks_lookback, + logger=logger, + ) + + +class TypicalProfileForecastProvider(EnergyLoadForecastProviderPort): + """Forecast by averaging historical power for each (month, dow, hour) slot. + + Compared to ``SeasonalBaseline`` (which groups by ``(dow, hour)`` only), + this provider also factors in the **month**, so the profile naturally adapts + to seasonal consumption changes (heating in winter, AC in summer, etc.). + + If insufficient data exists for the exact (month, dow, hour) triplet, the + provider falls back to (dow, hour) and then to the global average. + """ + + def __init__( + self, + hours_ahead: int = 24, + weeks_lookback: int = 8, + logger: Optional[LoggerPort] = None, + ): + super().__init__(forecast_provider_type=EnergyLoadForecastProviderAdapter.TYPICAL_PROFILE) + self._hours_ahead = hours_ahead + self._weeks_lookback = weeks_lookback + self._logger = logger + + @property + def min_required_history_hours(self) -> int: # noqa: D102 + return self._weeks_lookback * 168 # weeks × 168 h/week + + def get_consumption_forecast( + self, consumption_history: LoadEnergyConsumption, hours_ahead: int = 24 + ) -> Optional[LoadEnergyConsumption]: + effective_hours = self._hours_ahead + if effective_hours <= 0: + return None + + if not consumption_history.intervals: + return None + + # Build profiles at two granularity levels + # Level 1 (precise): (month, dow, hour) → list[power] + profile_mdh: Dict[Tuple[int, int, int], List[float]] = defaultdict(list) + # Level 2 (fallback): (dow, hour) → list[power] + profile_dh: Dict[Tuple[int, int], List[float]] = defaultdict(list) + + for interval in consumption_history.intervals: + month = interval.start.month + dow = interval.start.weekday() + hod = interval.start.hour + power = float(interval.avg_power) + if power >= 0: + profile_mdh[(month, dow, hod)].append(power) + profile_dh[(dow, hod)].append(power) + + if not profile_dh: + return None + + # Global fallback + all_powers = [p for powers in profile_dh.values() for p in powers] + global_avg = sum(all_powers) / len(all_powers) if all_powers else 0.0 + + now = Timestamp(datetime.now(timezone.utc)) + intervals: List[HomeLoadEnergyInterval] = [] + for i in range(effective_hours): + start = Timestamp(now + timedelta(hours=i)) + end = Timestamp(start + timedelta(hours=1)) + + month = start.month + dow = start.weekday() + hod = start.hour + + # Try precise (month, dow, hour) first, then (dow, hour), then global + mdh_values = profile_mdh.get((month, dow, hod)) + if mdh_values: + slot_power = Watts(sum(mdh_values) / len(mdh_values)) + else: + dh_values = profile_dh.get((dow, hod)) + if dh_values: + slot_power = Watts(sum(dh_values) / len(dh_values)) + else: + slot_power = Watts(global_avg) + + point = HomeLoadPowerPoint(timestamp=start, power=slot_power) + intervals.append( + HomeLoadEnergyInterval( + start=start, + end=end, + power_points=[point], + energy=WattHours(float(slot_power)), + ) + ) + + forecast = LoadEnergyConsumption(timestamp=now, intervals=intervals) + + if self._logger: + self._logger.debug( + f"TypicalProfileForecastProvider: {len(profile_mdh)} (m,d,h) slots, " + f"{len(profile_dh)} (d,h) slots, " + f"{effective_hours}h ahead, total_energy={forecast.total_energy:.0f}Wh" + ) + return forecast diff --git a/core/edge_mining/adapters/domain/home_load/forecast_providers/xgboost_provider.py b/core/edge_mining/adapters/domain/home_load/forecast_providers/xgboost_provider.py new file mode 100644 index 0000000..ccf1be0 --- /dev/null +++ b/core/edge_mining/adapters/domain/home_load/forecast_providers/xgboost_provider.py @@ -0,0 +1,233 @@ +"""XGBoost forecast provider for energy load consumption.""" + +import pickle +from datetime import datetime, timedelta, timezone +from typing import List, Optional + +from edge_mining.domain.common import EntityId, Timestamp, WattHours, Watts +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter +from edge_mining.domain.home_load.exceptions import EnergyLoadForecastProviderError +from edge_mining.domain.home_load.ports import EnergyLoadForecastProviderPort, LoadConsumptionModelRepository +from edge_mining.domain.home_load.value_objects import ( + HomeLoadEnergyInterval, + HomeLoadPowerPoint, + LoadEnergyConsumption, +) +from edge_mining.shared.adapter_configs.home_load import EnergyLoadForecastProviderXGBoostConfig +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.interfaces.factories import EnergyLoadForecastAdapterFactory +from edge_mining.shared.logging.port import LoggerPort + +from .features import ( + build_calendar_features, + fill_missing_hours, + intervals_to_hourly_series, + prepare_supervised_dataset, +) + +# Lazy imports +_XGB_AVAILABLE = False +try: + import xgboost as xgb + + _XGB_AVAILABLE = True +except ImportError: + xgb = None # type: ignore[assignment] + + +class XGBoostForecastProviderFactory(EnergyLoadForecastAdapterFactory): + """Factory for creating an XGBoostForecastProvider instance.""" + + def __init__(self, model_repo: Optional[LoadConsumptionModelRepository] = None) -> None: + self._model_repo = model_repo + + def create( + self, + config: Optional[Configuration], + logger: Optional[LoggerPort], + external_service: Optional[ExternalServicePort], + ) -> "XGBoostForecastProvider": + if config is not None and not isinstance(config, EnergyLoadForecastProviderXGBoostConfig): + raise EnergyLoadForecastProviderError( + "Invalid configuration type for XGBoost energy load forecast provider. " + "Expected EnergyLoadForecastProviderXGBoostConfig." + ) + + hours_ahead = 3 + weeks_lookback = 8 + n_estimators = 100 + max_depth = 6 + learning_rate = 0.1 + if isinstance(config, EnergyLoadForecastProviderXGBoostConfig): + hours_ahead = config.hours_ahead + weeks_lookback = config.weeks_lookback + n_estimators = config.n_estimators + max_depth = config.max_depth + learning_rate = config.learning_rate + + return XGBoostForecastProvider( + hours_ahead=hours_ahead, + weeks_lookback=weeks_lookback, + n_estimators=n_estimators, + max_depth=max_depth, + learning_rate=learning_rate, + model_repo=self._model_repo, + logger=logger, + ) + + +class XGBoostForecastProvider(EnergyLoadForecastProviderPort): + """Forecast provider using XGBoost gradient boosting. + + Uses calendar features (hour, day-of-week, is-weekend, month) and lag + features (1h, 2h, 3h, 24h, 168h) to predict avg_power per future hour. + + If a pre-trained model exists in ``model_repo`` it is used. + Otherwise, fits on-the-fly from the supplied history. + """ + + def __init__( + self, + hours_ahead: int = 3, + weeks_lookback: int = 8, + n_estimators: int = 100, + max_depth: int = 6, + learning_rate: float = 0.1, + model_repo: Optional[LoadConsumptionModelRepository] = None, + device_id: Optional[EntityId] = None, + logger: Optional[LoggerPort] = None, + ): + super().__init__(forecast_provider_type=EnergyLoadForecastProviderAdapter.XGBOOST) + self._hours_ahead = hours_ahead + self._weeks_lookback = weeks_lookback + self._n_estimators = n_estimators + self._max_depth = max_depth + self._learning_rate = learning_rate + self._model_repo = model_repo + self._device_id = device_id + self._logger = logger + + @property + def min_required_history_hours(self) -> int: # noqa: D102 + # max lag (168h) + minimum training samples (48) + forecast horizon + return 168 + 48 + self._hours_ahead + + def get_consumption_forecast( + self, consumption_history: LoadEnergyConsumption, hours_ahead: int = 3 + ) -> Optional[LoadEnergyConsumption]: + if not _XGB_AVAILABLE: + if self._logger: + self._logger.warning("xgboost is not installed — cannot produce forecast") + return None + + effective_hours = self._hours_ahead or hours_ahead + if effective_hours <= 0: + return None + + # Try saved model first + predictions = self._predict_from_saved_model(consumption_history, effective_hours) + + # Fallback: fit on-the-fly + if predictions is None: + predictions = self._fit_and_predict(consumption_history, effective_hours) + + if predictions is None: + return None + + return self._build_forecast(predictions) + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _predict_from_saved_model( + self, consumption_history: LoadEnergyConsumption, hours_ahead: int + ) -> Optional[List[float]]: + if self._model_repo is None: + return None + model_entity = self._model_repo.get_active_model( + adapter_type=EnergyLoadForecastProviderAdapter.XGBOOST, + device_id=self._device_id, + ) + if model_entity is None or model_entity.model_bytes is None: + return None + try: + saved_model = pickle.loads(model_entity.model_bytes) # noqa: S301 + return self._predict_future(saved_model, consumption_history, hours_ahead) + except Exception as exc: + if self._logger: + self._logger.warning(f"Failed to use saved XGBoost model: {exc}") + return None + + def _fit_and_predict(self, consumption_history: LoadEnergyConsumption, hours_ahead: int) -> Optional[List[float]]: + X, y = prepare_supervised_dataset(consumption_history, hours_ahead=hours_ahead) + if len(X) < 48: + if self._logger: + self._logger.debug(f"Insufficient training data for XGBoost ({len(X)} samples, need 48)") + return None + + try: + model = xgb.XGBRegressor( + n_estimators=self._n_estimators, + max_depth=self._max_depth, + learning_rate=self._learning_rate, + objective="reg:squarederror", + verbosity=0, + ) + model.fit(X, y) + return self._predict_future(model, consumption_history, hours_ahead) + except Exception as exc: + if self._logger: + self._logger.warning(f"XGBoost fit failed: {exc}") + return None + + def _predict_future( + self, model: "xgb.XGBRegressor", consumption_history: LoadEnergyConsumption, hours_ahead: int + ) -> Optional[List[float]]: + """Build feature rows for the next N hours and predict.""" + series = intervals_to_hourly_series(consumption_history) + series = fill_missing_hours(series) + if not series: + return None + + powers = [p for _, p in series] + now = datetime.now(timezone.utc) + lags = [1, 2, 3, 24, 168] + + predictions: List[float] = [] + # Iteratively predict one step at a time, appending predictions to powers + extended_powers = list(powers) + for step in range(hours_ahead): + future_ts = now + timedelta(hours=step) + cal = build_calendar_features([future_ts])[0] + lag_row = [] + n = len(extended_powers) + for lag in lags: + idx = n - lag + lag_row.append(extended_powers[idx] if idx >= 0 else 0.0) + feature_row = [cal + lag_row] + pred = float(model.predict(feature_row)[0]) + pred = max(0.0, pred) + predictions.append(pred) + extended_powers.append(pred) + + return predictions + + def _build_forecast(self, predictions: List[float]) -> LoadEnergyConsumption: + now = Timestamp(datetime.now(timezone.utc)) + intervals: List[HomeLoadEnergyInterval] = [] + for i, power_val in enumerate(predictions): + start = Timestamp(now + timedelta(hours=i)) + end = Timestamp(start + timedelta(hours=1)) + power = Watts(power_val) + point = HomeLoadPowerPoint(timestamp=start, power=power) + intervals.append( + HomeLoadEnergyInterval( + start=start, + end=end, + power_points=[point], + energy=WattHours(power_val), + ) + ) + return LoadEnergyConsumption(timestamp=now, intervals=intervals) diff --git a/core/edge_mining/adapters/domain/home_load/history_providers/__init__.py b/core/edge_mining/adapters/domain/home_load/history_providers/__init__.py new file mode 100644 index 0000000..d501d6a --- /dev/null +++ b/core/edge_mining/adapters/domain/home_load/history_providers/__init__.py @@ -0,0 +1 @@ +"""Collection of home load history provider adapters.""" diff --git a/core/edge_mining/adapters/domain/home_load/history_providers/dummy.py b/core/edge_mining/adapters/domain/home_load/history_providers/dummy.py new file mode 100644 index 0000000..198aa1c --- /dev/null +++ b/core/edge_mining/adapters/domain/home_load/history_providers/dummy.py @@ -0,0 +1,41 @@ +"""Dummy adapter that serves cached power points from the history repository.""" + +from typing import List, Optional + +from edge_mining.adapters.domain.home_load.history_providers.helpers import group_power_points_into_intervals +from edge_mining.domain.common import EntityId, Timestamp +from edge_mining.domain.home_load.common import EnergyLoadHistoryProviderAdapter +from edge_mining.domain.home_load.ports import EnergyLoadHistoryProviderPort, EnergyLoadHistoryRepository +from edge_mining.domain.home_load.value_objects import HomeLoadEnergyInterval, HomeLoadPowerPoint +from edge_mining.shared.logging.port import LoggerPort + + +class DummyEnergyLoadHistoryProvider(EnergyLoadHistoryProviderPort): + """Dummy history provider that reads directly from the history repository. + + No external fetching — it just serves whatever has already been ingested + into the repo for the bound device. Useful for testing and as a fallback. + """ + + def __init__( + self, + device_id: EntityId, + history_repo: EnergyLoadHistoryRepository, + logger: Optional[LoggerPort] = None, + ): + super().__init__(device_id=device_id, provider_type=EnergyLoadHistoryProviderAdapter.DUMMY) + self._history_repo = history_repo + self._logger = logger + + async def get_power_points(self, start: Timestamp, end: Timestamp) -> List[HomeLoadPowerPoint]: + """Return cached power points for this device in [start, end).""" + if self._logger: + self._logger.debug(f"DummyEnergyLoadHistoryProvider: get_power_points({self.device_id}, [{start}, {end}))") + return self._history_repo.get_power_points(self.device_id, start, end) + + async def get_history(self, start: Timestamp, end: Timestamp) -> List[HomeLoadEnergyInterval]: + """Return 1-hour consumption intervals for this device in [start, end).""" + if self._logger: + self._logger.debug(f"DummyEnergyLoadHistoryProvider: get_history({self.device_id}, [{start}, {end}))") + power_points = await self.get_power_points(start, end) + return group_power_points_into_intervals(power_points, start=start, end=end) diff --git a/core/edge_mining/adapters/domain/home_load/history_providers/helpers.py b/core/edge_mining/adapters/domain/home_load/history_providers/helpers.py new file mode 100644 index 0000000..27fd983 --- /dev/null +++ b/core/edge_mining/adapters/domain/home_load/history_providers/helpers.py @@ -0,0 +1,60 @@ +"""Shared helpers for home load history provider adapters.""" + +from datetime import timedelta +from typing import List, Optional + +from edge_mining.domain.common import Timestamp, WattHours +from edge_mining.domain.home_load.value_objects import HomeLoadEnergyInterval, HomeLoadPowerPoint + + +def group_power_points_into_intervals( + power_points: List[HomeLoadPowerPoint], + start: Optional[Timestamp] = None, + end: Optional[Timestamp] = None, +) -> List[HomeLoadEnergyInterval]: + """Group power points into contiguous 1-hour intervals. + + Intervals walk forward from ``start`` (or first point) by 1-hour steps + up to ``end`` (or last point). Empty intervals contribute zero energy + so downstream consumers see a contiguous timeline. + """ + if not power_points and (start is None or end is None): + return [] + + sorted_points = sorted(power_points, key=lambda p: p.timestamp) + + if start is None: + start = sorted_points[0].timestamp + if end is None: + end = sorted_points[-1].timestamp + if start >= end: + raise ValueError("Start timestamp must be before end timestamp.") + + intervals: List[HomeLoadEnergyInterval] = [] + current_start = start + while current_start < end: + current_end = min(current_start + timedelta(hours=1), end) + + interval_points = [p for p in sorted_points if current_start <= p.timestamp < current_end] + + if interval_points: + intervals.append( + HomeLoadEnergyInterval.create_from_power_points( + start=current_start, + end=current_end, + power_points=interval_points, + ) + ) + else: + intervals.append( + HomeLoadEnergyInterval( + start=current_start, + end=current_end, + energy=WattHours(0.0), + power_points=[], + ) + ) + + current_start = current_end + + return intervals diff --git a/core/edge_mining/adapters/domain/home_load/history_providers/home_assistant_api_history.py b/core/edge_mining/adapters/domain/home_load/history_providers/home_assistant_api_history.py new file mode 100644 index 0000000..006c720 --- /dev/null +++ b/core/edge_mining/adapters/domain/home_load/history_providers/home_assistant_api_history.py @@ -0,0 +1,191 @@ +""" +Home Assistant API Energy Load History adapter (Implementation of Port) +for the energy home loads domain of Edge Mining Application. + +The adapter is device-scoped: each instance is bound at construction time to a +single ``LoadDevice`` via its ``device_id``. History is fetched from Home +Assistant and opportunistically cached into the ``EnergyLoadHistoryRepository`` +for that device. +""" + +from datetime import datetime, timedelta, timezone +from typing import List, Optional, cast + +from edge_mining.adapters.domain.home_load.history_providers.helpers import group_power_points_into_intervals +from edge_mining.adapters.infrastructure.homeassistant.homeassistant_api import ( + ServiceHomeAssistantAPI, +) +from edge_mining.adapters.infrastructure.homeassistant.models import EntityHistory +from edge_mining.adapters.infrastructure.homeassistant.utils import EntityState +from edge_mining.domain.common import EntityId, Timestamp, Watts +from edge_mining.domain.home_load.common import EnergyLoadHistoryProviderAdapter +from edge_mining.domain.home_load.entities import LoadDevice +from edge_mining.domain.home_load.exceptions import ( + EnergyLoadHistoryProviderConfigurationError, + EnergyLoadHistoryProviderError, +) +from edge_mining.domain.home_load.ports import EnergyLoadHistoryProviderPort, EnergyLoadHistoryRepository +from edge_mining.domain.home_load.value_objects import HomeLoadEnergyInterval, HomeLoadPowerPoint +from edge_mining.shared.adapter_configs.home_load import ( + EnergyLoadHistoryProviderHomeAssistantAPIConfig, +) +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.interfaces.factories import EnergyLoadHistoryAdapterFactory +from edge_mining.shared.logging.port import LoggerPort + + +class HomeAssistantAPIEnergyLoadHistoryProviderFactory(EnergyLoadHistoryAdapterFactory): + """Factory for ``HomeAssistantAPIEnergyLoadHistoryProvider`` instances. + + The infrastructure repository is injected at factory construction time + (one repo serves all devices). ``from_load_device`` binds the device-scope + before ``create`` is called. + """ + + def __init__(self, history_repo: EnergyLoadHistoryRepository): + self._history_repo = history_repo + self._load_device: Optional[LoadDevice] = None + + def from_load_device(self, load_device: LoadDevice) -> None: + """Bind the factory to the LoadDevice this adapter will serve.""" + self._load_device = load_device + + def create( + self, + config: Optional[Configuration], + logger: Optional[LoggerPort], + external_service: Optional[ExternalServicePort], + ) -> "HomeAssistantAPIEnergyLoadHistoryProvider": + """Build a device-scoped Home Assistant API history adapter.""" + if self._load_device is None: + raise EnergyLoadHistoryProviderConfigurationError( + "from_load_device(...) must be called before create(...)." + ) + + if not external_service: + raise EnergyLoadHistoryProviderError("External service is required for EnergyLoadHistoryProviderAdapter.") + + if external_service.external_service_type != ExternalServiceAdapter.HOME_ASSISTANT_API: + raise EnergyLoadHistoryProviderError("External service must be of type Home Assistant API") + + if not isinstance(config, EnergyLoadHistoryProviderHomeAssistantAPIConfig): + raise EnergyLoadHistoryProviderConfigurationError( + "Invalid configuration type for HomeAssistantAPI energy load history provider. " + "Expected EnergyLoadHistoryProviderHomeAssistantAPIConfig." + ) + + service_home_assistant_api = cast(ServiceHomeAssistantAPI, external_service) + + return HomeAssistantAPIEnergyLoadHistoryProvider( + device_id=self._load_device.id, + entity_power=config.entity_power, + home_assistant=service_home_assistant_api, + history_repo=self._history_repo, + logger=logger, + ) + + +class HomeAssistantAPIEnergyLoadHistoryProvider(EnergyLoadHistoryProviderPort): + """Fetches energy load history for one LoadDevice from a Home Assistant instance. + + Caches raw power points in the injected ``EnergyLoadHistoryRepository`` + (infrastructure dependency — not part of the port contract) to avoid + re-hitting Home Assistant for already-observed windows. + """ + + _CACHE_STALENESS = timedelta(minutes=5) + + def __init__( + self, + device_id: EntityId, + entity_power: str, + home_assistant: ServiceHomeAssistantAPI, + history_repo: EnergyLoadHistoryRepository, + logger: Optional[LoggerPort] = None, + ): + super().__init__( + device_id=device_id, + provider_type=EnergyLoadHistoryProviderAdapter.HOME_ASSISTANT_API, + ) + self._home_assistant = home_assistant + self._history_repo = history_repo + self._logger = logger + + if not entity_power or entity_power.strip() == "": + raise EnergyLoadHistoryProviderConfigurationError("Power entity must be provided and cannot be empty.") + self._entity_power = entity_power + + if self._logger: + self._logger.debug(f"HA history adapter bound to device {device_id} (entity='{entity_power}')") + + async def get_power_points(self, start: Timestamp, end: Timestamp) -> List[HomeLoadPowerPoint]: + """Return power points for the bound device in [start, end). + + Hits the cache first; fetches missing or stale tail from Home Assistant. + """ + if start >= end: + return [] + + cached = self._history_repo.get_power_points(self.device_id, start, end) + + latest_cached: Optional[Timestamp] = max((p.timestamp for p in cached), default=None) + now_ts = Timestamp(datetime.now(timezone.utc)) + + if latest_cached is None: + fetched = await self._fetch_from_home_assistant(start, end) + if fetched: + self._history_repo.add_power_points(self.device_id, fetched) + return sorted(fetched, key=lambda p: p.timestamp) + + if now_ts - latest_cached > self._CACHE_STALENESS and latest_cached < end: + if self._logger: + self._logger.debug( + f"Cache tail stale for device {self.device_id}: " + f"latest={latest_cached}, now={now_ts}. Fetching incremental." + ) + tail = await self._fetch_from_home_assistant(latest_cached, end) + if tail: + self._history_repo.add_power_points(self.device_id, tail) + cached.extend(tail) + + return sorted(cached, key=lambda p: p.timestamp) + + async def get_history(self, start: Timestamp, end: Timestamp) -> List[HomeLoadEnergyInterval]: + """Return 1-hour consumption intervals for the bound device in [start, end).""" + if self._logger: + self._logger.debug(f"Computing 1h intervals for device {self.device_id} in [{start}, {end}).") + power_points = await self.get_power_points(start, end) + return group_power_points_into_intervals(power_points, start=start, end=end) + + async def _fetch_from_home_assistant(self, start: Timestamp, end: Timestamp) -> List[HomeLoadPowerPoint]: + """Fetch raw power points from Home Assistant REST API.""" + entity_history: Optional[EntityHistory] = await self._home_assistant.get_entity_history( + self._entity_power, start, end + ) + if not entity_history: + if self._logger: + self._logger.error(f"No history data found for entity '{self._entity_power}'") + return [] + + points: List[HomeLoadPowerPoint] = [] + for raw in entity_history.history: + if raw.value is None or raw.value.lower() in ( + EntityState.UNAVAILABLE.value, + EntityState.UNKNOWN.value, + ): + if self._logger: + self._logger.error(f"Invalid power data point '{raw.value}'. Skipping.") + continue + + unit = raw.unit or "W" + parsed = self._home_assistant.parse_power(raw.value, unit, self._entity_power or "N/A") + if parsed is None: + if self._logger: + self._logger.error(f"Failed to parse power '{raw.value}' for '{self._entity_power}'. Skipping.") + continue + + points.append(HomeLoadPowerPoint(timestamp=Timestamp(raw.timestamp), power=Watts(parsed))) + + return points diff --git a/core/edge_mining/adapters/domain/home_load/repositories.py b/core/edge_mining/adapters/domain/home_load/repositories.py new file mode 100644 index 0000000..25540c6 --- /dev/null +++ b/core/edge_mining/adapters/domain/home_load/repositories.py @@ -0,0 +1,1688 @@ +"""Repositories for the Home loads domain.""" + +import copy +import json +import sqlite3 +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from sqlalchemy import delete, func, insert, select + +from edge_mining.adapters.domain.home_load.tables import ( + energy_load_forecast_providers_table, + energy_load_history_providers_table, + home_load_power_points_table, + home_profiles_table, + load_consumption_models_table, +) +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.adapters.infrastructure.persistence.sqlite import BaseSqliteRepository +from edge_mining.domain.common import EntityId, Timestamp, Watts +from edge_mining.domain.exceptions import ConfigurationError +from edge_mining.domain.home_load.aggregate_roots import HomeLoadsProfile +from edge_mining.domain.home_load.common import ( + EnergyLoadForecastProviderAdapter, + EnergyLoadHistoryProviderAdapter, + LoadDeviceCategory, +) +from edge_mining.domain.home_load.entities import ( + EnergyLoadForecastProvider, + EnergyLoadHistoryProvider, + LoadConsumptionModel, + LoadDevice, +) +from edge_mining.domain.home_load.exceptions import ( + EnergyLoadForecastProviderAlreadyExistsError, + EnergyLoadForecastProviderConfigurationError, + EnergyLoadForecastProviderError, + EnergyLoadForecastProviderNotFoundError, + EnergyLoadHistoryProviderAlreadyExistsError, + EnergyLoadHistoryProviderConfigurationError, + EnergyLoadHistoryProviderError, + EnergyLoadHistoryProviderNotFoundError, +) +from edge_mining.domain.home_load.ports import ( + EnergyLoadForecastProviderRepository, + EnergyLoadHistoryProviderRepository, + EnergyLoadHistoryRepository, + HomeLoadsProfileRepository, + LoadConsumptionModelRepository, +) +from edge_mining.domain.home_load.value_objects import HomeLoadPowerPoint +from edge_mining.shared.adapter_maps.home_load import ( + ENERGY_LOAD_FORECAST_PROVIDER_CONFIG_TYPE_MAP, + ENERGY_LOAD_HISTORY_PROVIDER_CONFIG_TYPE_MAP, +) +from edge_mining.shared.interfaces.config import EnergyLoadForecastProviderConfig, EnergyLoadHistoryProviderConfig + + +# --- HomeLoadsProfile Repositories --- + + +def _device_to_dict(device: LoadDevice) -> Dict[str, Any]: + return { + "id": str(device.id), + "name": device.name, + "category": device.category.value, + "enabled": device.enabled, + "energy_load_forecast_provider_id": ( + str(device.energy_load_forecast_provider_id) if device.energy_load_forecast_provider_id else None + ), + "energy_load_history_provider_id": ( + str(device.energy_load_history_provider_id) if device.energy_load_history_provider_id else None + ), + } + + +def _dict_to_device(data: Dict[str, Any]) -> LoadDevice: + forecast_id = data.get("energy_load_forecast_provider_id") + history_id = data.get("energy_load_history_provider_id") + return LoadDevice( + id=EntityId(uuid.UUID(data["id"])), + name=data["name"], + category=LoadDeviceCategory(data["category"]), + enabled=bool(data.get("enabled", True)), + energy_load_forecast_provider_id=EntityId(uuid.UUID(forecast_id)) if forecast_id else None, + energy_load_history_provider_id=EntityId(uuid.UUID(history_id)) if history_id else None, + ) + + +class InMemoryHomeLoadsProfileRepository(HomeLoadsProfileRepository): + """In-memory implementation for the Home Loads Profile Repository.""" + + def __init__(self, initial_profiles: Optional[List[HomeLoadsProfile]] = None): + self._profiles: Dict[EntityId, HomeLoadsProfile] = {} + if initial_profiles: + for profile in initial_profiles: + self._profiles[profile.id] = copy.deepcopy(profile) + + def add(self, profile: HomeLoadsProfile) -> None: + self._profiles[profile.id] = copy.deepcopy(profile) + + def get_by_id(self, profile_id: EntityId) -> Optional[HomeLoadsProfile]: + profile = self._profiles.get(profile_id) + return copy.deepcopy(profile) if profile else None + + def get_all(self) -> List[HomeLoadsProfile]: + return [copy.deepcopy(p) for p in self._profiles.values()] + + def update(self, profile: HomeLoadsProfile) -> None: + self._profiles[profile.id] = copy.deepcopy(profile) + + def remove(self, profile_id: EntityId) -> None: + self._profiles.pop(profile_id, None) + + def get_by_energy_load_forecast_provider_id(self, provider_id: EntityId) -> List[HomeLoadsProfile]: + return [ + copy.deepcopy(profile) + for profile in self._profiles.values() + if any(device.energy_load_forecast_provider_id == provider_id for device in profile.devices) + ] + + +class SqliteHomeLoadsProfileRepository(HomeLoadsProfileRepository): + """SQLite implementation for the Home Loads Profile Repository.""" + + def __init__(self, db: BaseSqliteRepository): + self._db = db + self.logger = db.logger + self._create_tables() + + def _create_tables(self): + self.logger.debug(f"Ensuring SQLite tables exist for Home Loads Profile Repository in {self._db.db_path}...") + sql_statements = [ + """ + CREATE TABLE IF NOT EXISTS home_profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + devices_json TEXT -- JSON list of LoadDevice dicts + ); + """ + ] + + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + for statement in sql_statements: + cursor.execute(statement) + self.logger.debug("Home Loads Profile tables checked/created successfully.") + except sqlite3.Error as e: + self.logger.error(f"Error creating SQLite tables: {e}") + raise ConfigurationError(f"DB error creating tables: {e}") from e + finally: + if conn: + conn.close() + + def _row_to_profile(self, row: sqlite3.Row) -> Optional[HomeLoadsProfile]: + if not row: + return None + try: + devices_data: List = json.loads(row["devices_json"] or "[]") + devices = [_dict_to_device(dev) for dev in devices_data if isinstance(dev, dict)] + return HomeLoadsProfile(id=EntityId(uuid.UUID(row["id"])), name=row["name"], devices=devices) + except (json.JSONDecodeError, ValueError, KeyError, TypeError) as e: + self.logger.error(f"Error deserializing HomeLoadsProfile from DB row: {dict(row)}. Error: {e}") + return None + + def add(self, profile: HomeLoadsProfile) -> None: + self.logger.debug(f"Adding home loads profile '{profile.name}' ({profile.id}) to SQLite.") + sql = "INSERT INTO home_profiles (id, name, devices_json) VALUES (?, ?, ?)" + conn = self._db.get_connection() + try: + devices_json = json.dumps([_device_to_dict(dev) for dev in profile.devices]) + with conn: + conn.execute(sql, (str(profile.id), profile.name, devices_json)) + except sqlite3.Error as e: + self.logger.error(f"SQLite error adding profile {profile.id}: {e}") + raise ConfigurationError(f"DB error adding profile: {e}") from e + finally: + if conn: + conn.close() + + def get_by_id(self, profile_id: EntityId) -> Optional[HomeLoadsProfile]: + sql = "SELECT * FROM home_profiles WHERE id = ?" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (str(profile_id),)) + row = cursor.fetchone() + return self._row_to_profile(row) if row else None + except sqlite3.Error as e: + self.logger.error(f"SQLite error getting profile {profile_id}: {e}") + return None + finally: + if conn: + conn.close() + + def get_all(self) -> List[HomeLoadsProfile]: + sql = "SELECT * FROM home_profiles" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql) + rows = cursor.fetchall() + profiles: List[HomeLoadsProfile] = [] + for row in rows: + profile = self._row_to_profile(row) + if profile: + profiles.append(profile) + return profiles + except sqlite3.Error as e: + self.logger.error(f"SQLite error getting all profiles: {e}") + return [] + finally: + if conn: + conn.close() + + def update(self, profile: HomeLoadsProfile) -> None: + sql = "UPDATE home_profiles SET name = ?, devices_json = ? WHERE id = ?" + conn = self._db.get_connection() + try: + devices_json = json.dumps([_device_to_dict(dev) for dev in profile.devices]) + with conn: + conn.execute(sql, (profile.name, devices_json, str(profile.id))) + except sqlite3.Error as e: + self.logger.error(f"SQLite error updating profile {profile.id}: {e}") + raise ConfigurationError(f"DB error updating profile: {e}") from e + finally: + if conn: + conn.close() + + def remove(self, profile_id: EntityId) -> None: + sql = "DELETE FROM home_profiles WHERE id = ?" + conn = self._db.get_connection() + try: + with conn: + conn.execute(sql, (str(profile_id),)) + except sqlite3.Error as e: + self.logger.error(f"SQLite error removing profile {profile_id}: {e}") + raise ConfigurationError(f"DB error removing profile: {e}") from e + finally: + if conn: + conn.close() + + def get_by_energy_load_forecast_provider_id(self, provider_id: EntityId) -> List[HomeLoadsProfile]: + return [ + profile + for profile in self.get_all() + if any(device.energy_load_forecast_provider_id == provider_id for device in profile.devices) + ] + + +# --- EnergyLoadForecastProvider Repositories --- + + +class InMemoryEnergyLoadForecastProviderRepository(EnergyLoadForecastProviderRepository): + """In-memory implementation of EnergyLoadForecastProviderRepository for testing purposes.""" + + def __init__(self): + self._energy_load_forecast_providers: List[EnergyLoadForecastProvider] = [] + + def add(self, energy_load_forecast_provider: EnergyLoadForecastProvider) -> None: + self._energy_load_forecast_providers.append(energy_load_forecast_provider) + + def get_by_id(self, energy_load_forecast_provider_id: EntityId) -> Optional[EnergyLoadForecastProvider]: + for energy_load_forecast_provider in self._energy_load_forecast_providers: + if energy_load_forecast_provider.id == energy_load_forecast_provider_id: + return energy_load_forecast_provider + return None + + def get_all(self) -> List[EnergyLoadForecastProvider]: + return self._energy_load_forecast_providers + + def update(self, energy_load_forecast_provider: EnergyLoadForecastProvider) -> None: + for i, existing_provider in enumerate(self._energy_load_forecast_providers): + if existing_provider.id == energy_load_forecast_provider.id: + self._energy_load_forecast_providers[i] = energy_load_forecast_provider + return + + def remove(self, energy_load_forecast_provider_id: EntityId) -> None: + self._energy_load_forecast_providers = [ + n for n in self._energy_load_forecast_providers if n.id != energy_load_forecast_provider_id + ] + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[EnergyLoadForecastProvider]: + """Retrieve all energy load forecast providers linked to a specific external service.""" + return ( + [ + provider + for provider in self._energy_load_forecast_providers + if provider.external_service_id == external_service_id + ] + if external_service_id + else [] + ) + + +class SqliteEnergyLoadForecastProviderRepository(EnergyLoadForecastProviderRepository): + """SQLite implementation of EnergyLoadForecastProviderRepository.""" + + def __init__(self, db: BaseSqliteRepository): + self._db = db + self.logger = db.logger + + self._create_tables() + + def _create_tables(self): + self.logger.debug( + f"Ensuring SQLite tables exist for Energy Load Forecast Provider Repository in {self._db.db_path}..." + ) + sql_statements = [ + """ + CREATE TABLE IF NOT EXISTS energy_load_forecast_providers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + adapter_type TEXT NOT NULL, + config TEXT, + external_service_id TEXT + ); + """ + ] + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + for statement in sql_statements: + cursor.execute(statement) + except sqlite3.Error as e: + self.logger.error(f"Error creating SQLite tables: {e}") + raise ConfigurationError(f"DB error creating tables: {e}") from e + finally: + if conn: + conn.close() + + def _deserialize_config( + self, adapter_type: EnergyLoadForecastProviderAdapter, config_json: str + ) -> EnergyLoadForecastProviderConfig: + data: dict = json.loads(config_json) + + if adapter_type not in ENERGY_LOAD_FORECAST_PROVIDER_CONFIG_TYPE_MAP: + raise EnergyLoadForecastProviderNotFoundError( + f"Error reading EnergyLoadForecastProvider configuration. Invalid type '{adapter_type}'" + ) + + config_class: Optional[type[EnergyLoadForecastProviderConfig]] = ( + ENERGY_LOAD_FORECAST_PROVIDER_CONFIG_TYPE_MAP.get(adapter_type) + ) + if not config_class: + raise EnergyLoadForecastProviderNotFoundError( + f"Error creating EnergyLoadForecastProviderConfig configuration. Type '{adapter_type}'" + ) + + config_instance = config_class.from_dict(data) + if not isinstance(config_instance, EnergyLoadForecastProviderConfig): + raise EnergyLoadForecastProviderConfigurationError( + f"Deserialized config is not of type EnergyLoadForecastProviderConfig " + f"for adapter type {adapter_type}." + ) + return config_instance + + def _row_to_energy_load_forecast_provider(self, row: sqlite3.Row) -> Optional[EnergyLoadForecastProvider]: + if not row: + return None + try: + provider_type = EnergyLoadForecastProviderAdapter(row["adapter_type"]) + config = self._deserialize_config(provider_type, row["config"]) + + return EnergyLoadForecastProvider( + id=EntityId(row["id"]), + name=row["name"], + adapter_type=provider_type, + config=config, + external_service_id=(EntityId(row["external_service_id"]) if row["external_service_id"] else None), + ) + except (ValueError, KeyError) as e: + self.logger.error(f"Error deserializing EnergyLoadForecastProvider from DB row: {row}. Error: {e}") + return None + + def add(self, energy_load_forecast_provider: EnergyLoadForecastProvider) -> None: + self.logger.debug(f"Adding forecast provider {energy_load_forecast_provider.id} to SQLite repository.") + sql = """ + INSERT INTO energy_load_forecast_providers (id, name, adapter_type, config, external_service_id) + VALUES (?, ?, ?, ?, ?); + """ + conn = self._db.get_connection() + try: + config_json: str = "" + if energy_load_forecast_provider.config: + config_json = json.dumps(energy_load_forecast_provider.config.to_dict()) + + with conn: + cursor = conn.cursor() + cursor.execute( + sql, + ( + energy_load_forecast_provider.id, + energy_load_forecast_provider.name, + energy_load_forecast_provider.adapter_type.value, + config_json, + energy_load_forecast_provider.external_service_id, + ), + ) + except sqlite3.IntegrityError as e: + self.logger.error( + f"Integrity error adding energy load forecast provider {energy_load_forecast_provider.id}: {e}" + ) + raise EnergyLoadForecastProviderAlreadyExistsError( + f"Energy load forecast provider with ID {energy_load_forecast_provider.id} " + f"already exists or constraint violation: {e}" + ) from e + except sqlite3.Error as e: + self.logger.error( + f"SQLite error adding energy load forecast provider {energy_load_forecast_provider.id}: {e}" + ) + raise EnergyLoadForecastProviderError(f"DB error adding energy load forecast provider: {e}") from e + finally: + if conn: + conn.close() + + def get_by_id(self, energy_load_forecast_provider_id: EntityId) -> Optional[EnergyLoadForecastProvider]: + sql = "SELECT * FROM energy_load_forecast_providers WHERE id = ?;" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (energy_load_forecast_provider_id,)) + row = cursor.fetchone() + return self._row_to_energy_load_forecast_provider(row) + except sqlite3.Error as e: + self.logger.error( + f"SQLite error retrieving energy load forecast provider {energy_load_forecast_provider_id}: {e}" + ) + raise EnergyLoadForecastProviderNotFoundError( + f"DB error retrieving energy load forecast provider: {e}" + ) from e + finally: + if conn: + conn.close() + + def get_all(self) -> List[EnergyLoadForecastProvider]: + sql = "SELECT * FROM energy_load_forecast_providers;" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql) + rows = cursor.fetchall() + energy_load_forecast_providers = [] + for row in rows: + provider = self._row_to_energy_load_forecast_provider(row) + if provider: + energy_load_forecast_providers.append(provider) + except sqlite3.Error as e: + self.logger.error(f"SQLite error retrieving all energy load forecast providers: {e}") + return [] + finally: + if conn: + conn.close() + return energy_load_forecast_providers + + def update(self, energy_load_forecast_provider: EnergyLoadForecastProvider) -> None: + sql = """ + UPDATE energy_load_forecast_providers + SET name = ?, adapter_type = ?, config = ?, external_service_id = ? + WHERE id = ?; + """ + conn = self._db.get_connection() + try: + config_json = json.dumps(energy_load_forecast_provider.config) + + with conn: + cursor = conn.cursor() + cursor.execute( + sql, + ( + energy_load_forecast_provider.name, + energy_load_forecast_provider.adapter_type.value, + config_json, + energy_load_forecast_provider.external_service_id, + energy_load_forecast_provider.id, + ), + ) + if cursor.rowcount == 0: + raise EnergyLoadForecastProviderNotFoundError( + f"Energy Load Forecast Provider with ID {energy_load_forecast_provider.id} not found." + ) + except sqlite3.Error as e: + self.logger.error( + f"SQLite error updating energy load forecast provider {energy_load_forecast_provider.id}: {e}" + ) + raise EnergyLoadForecastProviderError(f"DB error updating energy load forecast provider: {e}") from e + finally: + if conn: + conn.close() + + def remove(self, energy_load_forecast_provider_id: EntityId) -> None: + sql = "DELETE FROM energy_load_forecast_providers WHERE id = ?;" + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + cursor.execute(sql, (energy_load_forecast_provider_id,)) + if cursor.rowcount == 0: + self.logger.warning( + f"Attempted to remove non-existent energy load forecast provider " + f"{energy_load_forecast_provider_id}." + ) + except sqlite3.Error as e: + self.logger.error( + f"SQLite error removing energy load forecast provider {energy_load_forecast_provider_id}: {e}" + ) + raise EnergyLoadForecastProviderError(f"DB error removing energy load forecast provider: {e}") from e + finally: + if conn: + conn.close() + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[EnergyLoadForecastProvider]: + sql = "SELECT * FROM energy_load_forecast_providers WHERE external_service_id = ?;" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (external_service_id,)) + rows = cursor.fetchall() + energy_load_forecast_providers = [] + for row in rows: + provider = self._row_to_energy_load_forecast_provider(row) + if provider: + energy_load_forecast_providers.append(provider) + return energy_load_forecast_providers + except sqlite3.Error as e: + self.logger.error( + f"SQLite error retrieving energy load forecast providers by external service ID " + f"{external_service_id}: {e}" + ) + return [] + finally: + if conn: + conn.close() + + +# --- SQLAlchemy implementations --- + + +class SqlAlchemyEnergyLoadForecastProviderRepository(EnergyLoadForecastProviderRepository): + """SQLAlchemy implementation of EnergyLoadForecastProviderRepository.""" + + def __init__(self, db: BaseSQLAlchemyRepository): + self._db = db + self.logger = db.logger + + def add(self, energy_load_forecast_provider: EnergyLoadForecastProvider) -> None: + session = self._db.get_session() + try: + session.add(energy_load_forecast_provider) + session.commit() + finally: + session.close() + + def get_by_id(self, energy_load_forecast_provider_id: EntityId) -> Optional[EnergyLoadForecastProvider]: + session = self._db.get_session() + try: + stmt = select(EnergyLoadForecastProvider).where( + energy_load_forecast_providers_table.c.id == str(energy_load_forecast_provider_id) + ) + entity = session.execute(stmt).scalar_one_or_none() + return entity + finally: + session.close() + + def get_all(self) -> List[EnergyLoadForecastProvider]: + session = self._db.get_session() + try: + stmt = select(EnergyLoadForecastProvider) + entities = session.execute(stmt).scalars().all() + return list(entities) + finally: + session.close() + + def update(self, energy_load_forecast_provider: EnergyLoadForecastProvider) -> None: + session = self._db.get_session() + try: + stmt = select(EnergyLoadForecastProvider).where( + energy_load_forecast_providers_table.c.id == str(energy_load_forecast_provider.id) + ) + existing_entity = session.execute(stmt).scalar_one_or_none() + + if existing_entity: + existing_entity.name = energy_load_forecast_provider.name + existing_entity.adapter_type = energy_load_forecast_provider.adapter_type + existing_entity.config = energy_load_forecast_provider.config + existing_entity.external_service_id = energy_load_forecast_provider.external_service_id + + session.commit() + finally: + session.close() + + def remove(self, energy_load_forecast_provider_id: EntityId) -> None: + session = self._db.get_session() + try: + stmt = select(EnergyLoadForecastProvider).where( + energy_load_forecast_providers_table.c.id == str(energy_load_forecast_provider_id) + ) + entity = session.execute(stmt).scalar_one_or_none() + + if entity: + session.delete(entity) + session.commit() + finally: + session.close() + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[EnergyLoadForecastProvider]: + session = self._db.get_session() + try: + stmt = select(EnergyLoadForecastProvider).where( + energy_load_forecast_providers_table.c.external_service_id == str(external_service_id) + ) + entities = session.execute(stmt).scalars().all() + return list(entities) + finally: + session.close() + + +class SqlAlchemyHomeLoadsProfileRepository(HomeLoadsProfileRepository): + """SQLAlchemy implementation of the HomeLoadsProfileRepository.""" + + def __init__(self, db: BaseSQLAlchemyRepository): + self._db = db + self.logger = db.logger + + def add(self, profile: HomeLoadsProfile) -> None: + session = self._db.get_session() + try: + session.add(profile) + session.commit() + finally: + session.close() + + def get_by_id(self, profile_id: EntityId) -> Optional[HomeLoadsProfile]: + session = self._db.get_session() + try: + stmt = select(HomeLoadsProfile).where(home_profiles_table.c.id == str(profile_id)) + entity = session.execute(stmt).scalar_one_or_none() + return entity + finally: + session.close() + + def get_all(self) -> List[HomeLoadsProfile]: + session = self._db.get_session() + try: + stmt = select(HomeLoadsProfile) + entities = session.execute(stmt).scalars().all() + return list(entities) + finally: + session.close() + + def update(self, profile: HomeLoadsProfile) -> None: + session = self._db.get_session() + try: + stmt = select(HomeLoadsProfile).where(home_profiles_table.c.id == str(profile.id)) + existing_entity = session.execute(stmt).scalar_one_or_none() + + if existing_entity: + existing_entity.name = profile.name + existing_entity.devices = profile.devices + session.commit() + finally: + session.close() + + def remove(self, profile_id: EntityId) -> None: + session = self._db.get_session() + try: + stmt = select(HomeLoadsProfile).where(home_profiles_table.c.id == str(profile_id)) + entity = session.execute(stmt).scalar_one_or_none() + if entity: + session.delete(entity) + session.commit() + finally: + session.close() + + def get_by_energy_load_forecast_provider_id(self, provider_id: EntityId) -> List[HomeLoadsProfile]: + return [ + profile + for profile in self.get_all() + if any(device.energy_load_forecast_provider_id == provider_id for device in profile.devices) + ] + + +# --- EnergyLoadHistory (per-device power-point time series) Repositories --- + + +class InMemoryEnergyLoadHistoryRepository(EnergyLoadHistoryRepository): + """In-memory power-point store, indexed by device and kept sorted by timestamp.""" + + def __init__(self) -> None: + self._store: Dict[EntityId, List[HomeLoadPowerPoint]] = {} + + def _sorted_points(self, device_id: EntityId) -> List[HomeLoadPowerPoint]: + bucket = self._store.setdefault(device_id, []) + bucket.sort(key=lambda p: p.timestamp) + return bucket + + def add_power_point(self, device_id: EntityId, power_point: HomeLoadPowerPoint) -> None: + self._store.setdefault(device_id, []).append(power_point) + + def add_power_points(self, device_id: EntityId, power_points: List[HomeLoadPowerPoint]) -> None: + if not power_points: + return + self._store.setdefault(device_id, []).extend(power_points) + + def get_power_points(self, device_id: EntityId, start: Timestamp, end: Timestamp) -> List[HomeLoadPowerPoint]: + return [p for p in self._sorted_points(device_id) if start <= p.timestamp < end] + + def get_latest_timestamp(self, device_id: EntityId) -> Optional[Timestamp]: + points = self._store.get(device_id) + if not points: + return None + return max(p.timestamp for p in points) + + def purge_before(self, device_id: EntityId, timestamp: Timestamp) -> int: + bucket = self._store.get(device_id) + if not bucket: + return 0 + kept = [p for p in bucket if p.timestamp >= timestamp] + removed = len(bucket) - len(kept) + self._store[device_id] = kept + return removed + + def remove_power_points_by_time_range(self, device_id: EntityId, start: Timestamp, end: Timestamp) -> None: + bucket = self._store.get(device_id) + if not bucket: + return + self._store[device_id] = [p for p in bucket if not (start <= p.timestamp < end)] + + def clear_device_history(self, device_id: EntityId) -> int: + bucket = self._store.pop(device_id, []) + return len(bucket) + + +class SqliteEnergyLoadHistoryRepository(EnergyLoadHistoryRepository): + """SQLite implementation of the device-scoped power-point time series. + + Uses a composite primary key (device_id, timestamp) so re-ingesting the + same window is idempotent (``INSERT OR IGNORE``). Retention and range + queries lean on the implicit PK index for O(log n) behavior. + """ + + def __init__(self, db: BaseSqliteRepository): + self._db = db + self.logger = db.logger + self._create_tables() + + def _create_tables(self) -> None: + self.logger.debug(f"Ensuring SQLite tables exist for Energy Load History Repository in {self._db.db_path}...") + sql = """ + CREATE TABLE IF NOT EXISTS home_load_power_points ( + device_id TEXT NOT NULL, + timestamp TIMESTAMP NOT NULL, + power REAL NOT NULL, + PRIMARY KEY (device_id, timestamp) + ); + """ + conn = self._db.get_connection() + try: + with conn: + conn.execute(sql) + except sqlite3.Error as e: + self.logger.error(f"Error creating SQLite tables: {e}") + raise ConfigurationError(f"DB error creating tables: {e}") from e + finally: + if conn: + conn.close() + + def add_power_point(self, device_id: EntityId, power_point: HomeLoadPowerPoint) -> None: + self.add_power_points(device_id, [power_point]) + + def add_power_points(self, device_id: EntityId, power_points: List[HomeLoadPowerPoint]) -> None: + if not power_points: + return + sql = """ + INSERT OR IGNORE INTO home_load_power_points (device_id, timestamp, power) + VALUES (?, ?, ?); + """ + conn = self._db.get_connection() + try: + rows = [(str(device_id), p.timestamp, float(p.power)) for p in power_points] + with conn: + conn.executemany(sql, rows) + except sqlite3.Error as e: + self.logger.error(f"SQLite error inserting power points for device {device_id}: {e}") + raise ConfigurationError(f"DB error inserting power points: {e}") from e + finally: + if conn: + conn.close() + + def get_power_points(self, device_id: EntityId, start: Timestamp, end: Timestamp) -> List[HomeLoadPowerPoint]: + sql = """ + SELECT timestamp, power + FROM home_load_power_points + WHERE device_id = ? AND timestamp >= ? AND timestamp < ? + ORDER BY timestamp ASC; + """ + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (str(device_id), start, end)) + rows = cursor.fetchall() + return [ + HomeLoadPowerPoint(timestamp=Timestamp(row["timestamp"]), power=Watts(row["power"])) for row in rows + ] + except sqlite3.Error as e: + self.logger.error(f"SQLite error reading power points for device {device_id}: {e}") + return [] + finally: + if conn: + conn.close() + + def get_latest_timestamp(self, device_id: EntityId) -> Optional[Timestamp]: + sql = "SELECT MAX(timestamp) AS ts FROM home_load_power_points WHERE device_id = ?;" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (str(device_id),)) + row = cursor.fetchone() + if not row or row["ts"] is None: + return None + return Timestamp(row["ts"]) + except sqlite3.Error as e: + self.logger.error(f"SQLite error getting latest timestamp for device {device_id}: {e}") + return None + finally: + if conn: + conn.close() + + def purge_before(self, device_id: EntityId, timestamp: Timestamp) -> int: + sql = "DELETE FROM home_load_power_points WHERE device_id = ? AND timestamp < ?;" + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + cursor.execute(sql, (str(device_id), timestamp)) + return cursor.rowcount or 0 + except sqlite3.Error as e: + self.logger.error(f"SQLite error purging power points for device {device_id}: {e}") + return 0 + finally: + if conn: + conn.close() + + def remove_power_points_by_time_range(self, device_id: EntityId, start: Timestamp, end: Timestamp) -> None: + sql = "DELETE FROM home_load_power_points WHERE device_id = ? AND timestamp >= ? AND timestamp < ?;" + conn = self._db.get_connection() + try: + with conn: + conn.execute(sql, (str(device_id), start, end)) + except sqlite3.Error as e: + self.logger.error(f"SQLite error removing range for device {device_id}: {e}") + finally: + if conn: + conn.close() + + def clear_device_history(self, device_id: EntityId) -> int: + sql = "DELETE FROM home_load_power_points WHERE device_id = ?;" + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + cursor.execute(sql, (str(device_id),)) + return cursor.rowcount or 0 + except sqlite3.Error as e: + self.logger.error(f"SQLite error clearing history for device {device_id}: {e}") + return 0 + finally: + if conn: + conn.close() + + +class SqlAlchemyEnergyLoadHistoryRepository(EnergyLoadHistoryRepository): + """SQLAlchemy Core implementation of the device-scoped power-point store. + + Core (not imperative mapping) is intentional: ``HomeLoadPowerPoint`` is a + Value Object, not an Entity — we serialize/deserialize manually and avoid + polluting the domain with ORM state. + """ + + def __init__(self, db: BaseSQLAlchemyRepository): + self._db = db + self.logger = db.logger + + def add_power_point(self, device_id: EntityId, power_point: HomeLoadPowerPoint) -> None: + self.add_power_points(device_id, [power_point]) + + def add_power_points(self, device_id: EntityId, power_points: List[HomeLoadPowerPoint]) -> None: + if not power_points: + return + rows = [{"device_id": str(device_id), "timestamp": p.timestamp, "power": float(p.power)} for p in power_points] + session = self._db.get_session() + try: + dialect_name = session.bind.dialect.name if session.bind else "" + if dialect_name == "sqlite": + from sqlalchemy.dialects.sqlite import insert as sqlite_insert + + stmt = sqlite_insert(home_load_power_points_table).on_conflict_do_nothing( + index_elements=["device_id", "timestamp"] + ) + elif dialect_name == "postgresql": + from sqlalchemy.dialects.postgresql import insert as pg_insert + + stmt = pg_insert(home_load_power_points_table).on_conflict_do_nothing( + index_elements=["device_id", "timestamp"] + ) + else: + stmt = insert(home_load_power_points_table) + session.execute(stmt, rows) + session.commit() + finally: + session.close() + + def get_power_points(self, device_id: EntityId, start: Timestamp, end: Timestamp) -> List[HomeLoadPowerPoint]: + session = self._db.get_session() + try: + stmt = ( + select( + home_load_power_points_table.c.timestamp, + home_load_power_points_table.c.power, + ) + .where(home_load_power_points_table.c.device_id == str(device_id)) + .where(home_load_power_points_table.c.timestamp >= start) + .where(home_load_power_points_table.c.timestamp < end) + .order_by(home_load_power_points_table.c.timestamp.asc()) + ) + rows = session.execute(stmt).all() + return [ + HomeLoadPowerPoint( + timestamp=Timestamp(ts if ts.tzinfo else ts.replace(tzinfo=timezone.utc)), + power=Watts(power), + ) + for ts, power in rows + ] + finally: + session.close() + + def get_latest_timestamp(self, device_id: EntityId) -> Optional[Timestamp]: + session = self._db.get_session() + try: + stmt = select(func.max(home_load_power_points_table.c.timestamp)).where( + home_load_power_points_table.c.device_id == str(device_id) + ) + latest = session.execute(stmt).scalar_one_or_none() + if latest is None: + return None + if isinstance(latest, datetime) and latest.tzinfo is None: + latest = latest.replace(tzinfo=timezone.utc) + return Timestamp(latest) + finally: + session.close() + + def purge_before(self, device_id: EntityId, timestamp: Timestamp) -> int: + session = self._db.get_session() + try: + stmt = delete(home_load_power_points_table).where( + home_load_power_points_table.c.device_id == str(device_id), + home_load_power_points_table.c.timestamp < timestamp, + ) + result = session.execute(stmt) + session.commit() + return result.rowcount or 0 + finally: + session.close() + + def remove_power_points_by_time_range(self, device_id: EntityId, start: Timestamp, end: Timestamp) -> None: + session = self._db.get_session() + try: + stmt = delete(home_load_power_points_table).where( + home_load_power_points_table.c.device_id == str(device_id), + home_load_power_points_table.c.timestamp >= start, + home_load_power_points_table.c.timestamp < end, + ) + session.execute(stmt) + session.commit() + finally: + session.close() + + def clear_device_history(self, device_id: EntityId) -> int: + session = self._db.get_session() + try: + stmt = delete(home_load_power_points_table).where( + home_load_power_points_table.c.device_id == str(device_id), + ) + result = session.execute(stmt) + session.commit() + return result.rowcount or 0 + finally: + session.close() + + +# --- EnergyLoadHistoryProvider Repositories --- + + +class InMemoryEnergyLoadHistoryProviderRepository(EnergyLoadHistoryProviderRepository): + """In-memory implementation of EnergyLoadHistoryProviderRepository.""" + + def __init__(self): + self._providers: List[EnergyLoadHistoryProvider] = [] + + def add(self, energy_load_history_provider: EnergyLoadHistoryProvider) -> None: + self._providers.append(energy_load_history_provider) + + def get_by_id(self, energy_load_history_provider_id: EntityId) -> Optional[EnergyLoadHistoryProvider]: + for provider in self._providers: + if provider.id == energy_load_history_provider_id: + return provider + return None + + def get_all(self) -> List[EnergyLoadHistoryProvider]: + return self._providers + + def update(self, energy_load_history_provider: EnergyLoadHistoryProvider) -> None: + for i, existing in enumerate(self._providers): + if existing.id == energy_load_history_provider.id: + self._providers[i] = energy_load_history_provider + return + + def remove(self, energy_load_history_provider_id: EntityId) -> None: + self._providers = [p for p in self._providers if p.id != energy_load_history_provider_id] + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[EnergyLoadHistoryProvider]: + if not external_service_id: + return [] + return [p for p in self._providers if p.external_service_id == external_service_id] + + +class SqliteEnergyLoadHistoryProviderRepository(EnergyLoadHistoryProviderRepository): + """SQLite implementation of EnergyLoadHistoryProviderRepository.""" + + def __init__(self, db: BaseSqliteRepository): + self._db = db + self.logger = db.logger + self._create_tables() + + def _create_tables(self): + self.logger.debug( + f"Ensuring SQLite tables exist for Energy Load History Provider Repository in {self._db.db_path}..." + ) + sql_statements = [ + """ + CREATE TABLE IF NOT EXISTS energy_load_history_providers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + adapter_type TEXT NOT NULL, + config TEXT, + external_service_id TEXT + ); + """ + ] + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + for statement in sql_statements: + cursor.execute(statement) + except sqlite3.Error as e: + self.logger.error(f"Error creating SQLite tables: {e}") + raise ConfigurationError(f"DB error creating tables: {e}") from e + finally: + if conn: + conn.close() + + def _deserialize_config( + self, adapter_type: EnergyLoadHistoryProviderAdapter, config_json: str + ) -> Optional[EnergyLoadHistoryProviderConfig]: + if not config_json: + return None + data: dict = json.loads(config_json) + + if adapter_type not in ENERGY_LOAD_HISTORY_PROVIDER_CONFIG_TYPE_MAP: + raise EnergyLoadHistoryProviderNotFoundError( + f"Error reading EnergyLoadHistoryProvider configuration. Invalid type '{adapter_type}'" + ) + + config_class: Optional[type[EnergyLoadHistoryProviderConfig]] = ( + ENERGY_LOAD_HISTORY_PROVIDER_CONFIG_TYPE_MAP.get(adapter_type) + ) + if not config_class: + return None + + config_instance = config_class.from_dict(data) + if not isinstance(config_instance, EnergyLoadHistoryProviderConfig): + raise EnergyLoadHistoryProviderConfigurationError( + f"Deserialized config is not of type EnergyLoadHistoryProviderConfig " + f"for adapter type {adapter_type}." + ) + return config_instance + + def _row_to_provider(self, row: sqlite3.Row) -> Optional[EnergyLoadHistoryProvider]: + if not row: + return None + try: + provider_type = EnergyLoadHistoryProviderAdapter(row["adapter_type"]) + config = self._deserialize_config(provider_type, row["config"]) + return EnergyLoadHistoryProvider( + id=EntityId(row["id"]), + name=row["name"], + adapter_type=provider_type, + config=config, + external_service_id=(EntityId(row["external_service_id"]) if row["external_service_id"] else None), + ) + except (ValueError, KeyError) as e: + self.logger.error(f"Error deserializing EnergyLoadHistoryProvider from DB row: {row}. Error: {e}") + return None + + def add(self, energy_load_history_provider: EnergyLoadHistoryProvider) -> None: + self.logger.debug(f"Adding history provider {energy_load_history_provider.id} to SQLite repository.") + sql = """ + INSERT INTO energy_load_history_providers (id, name, adapter_type, config, external_service_id) + VALUES (?, ?, ?, ?, ?); + """ + conn = self._db.get_connection() + try: + config_json: str = "" + if energy_load_history_provider.config: + config_json = json.dumps(energy_load_history_provider.config.to_dict()) + with conn: + conn.execute( + sql, + ( + energy_load_history_provider.id, + energy_load_history_provider.name, + energy_load_history_provider.adapter_type.value, + config_json, + energy_load_history_provider.external_service_id, + ), + ) + except sqlite3.IntegrityError as e: + self.logger.error( + f"Integrity error adding energy load history provider {energy_load_history_provider.id}: {e}" + ) + raise EnergyLoadHistoryProviderAlreadyExistsError( + f"Energy load history provider with ID {energy_load_history_provider.id} " + f"already exists or constraint violation: {e}" + ) from e + except sqlite3.Error as e: + self.logger.error( + f"SQLite error adding energy load history provider {energy_load_history_provider.id}: {e}" + ) + raise EnergyLoadHistoryProviderError(f"DB error adding energy load history provider: {e}") from e + finally: + if conn: + conn.close() + + def get_by_id(self, energy_load_history_provider_id: EntityId) -> Optional[EnergyLoadHistoryProvider]: + sql = "SELECT * FROM energy_load_history_providers WHERE id = ?;" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (energy_load_history_provider_id,)) + row = cursor.fetchone() + return self._row_to_provider(row) + except sqlite3.Error as e: + self.logger.error( + f"SQLite error retrieving energy load history provider {energy_load_history_provider_id}: {e}" + ) + raise EnergyLoadHistoryProviderNotFoundError( + f"DB error retrieving energy load history provider: {e}" + ) from e + finally: + if conn: + conn.close() + + def get_all(self) -> List[EnergyLoadHistoryProvider]: + sql = "SELECT * FROM energy_load_history_providers;" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql) + rows = cursor.fetchall() + providers = [] + for row in rows: + provider = self._row_to_provider(row) + if provider: + providers.append(provider) + return providers + except sqlite3.Error as e: + self.logger.error(f"SQLite error retrieving all energy load history providers: {e}") + return [] + finally: + if conn: + conn.close() + + def update(self, energy_load_history_provider: EnergyLoadHistoryProvider) -> None: + sql = """ + UPDATE energy_load_history_providers + SET name = ?, adapter_type = ?, config = ?, external_service_id = ? + WHERE id = ?; + """ + conn = self._db.get_connection() + try: + config_json = "" + if energy_load_history_provider.config: + config_json = json.dumps(energy_load_history_provider.config.to_dict()) + with conn: + cursor = conn.cursor() + cursor.execute( + sql, + ( + energy_load_history_provider.name, + energy_load_history_provider.adapter_type.value, + config_json, + energy_load_history_provider.external_service_id, + energy_load_history_provider.id, + ), + ) + if cursor.rowcount == 0: + raise EnergyLoadHistoryProviderNotFoundError( + f"Energy Load History Provider with ID {energy_load_history_provider.id} not found." + ) + except sqlite3.Error as e: + self.logger.error( + f"SQLite error updating energy load history provider {energy_load_history_provider.id}: {e}" + ) + raise EnergyLoadHistoryProviderError(f"DB error updating energy load history provider: {e}") from e + finally: + if conn: + conn.close() + + def remove(self, energy_load_history_provider_id: EntityId) -> None: + sql = "DELETE FROM energy_load_history_providers WHERE id = ?;" + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + cursor.execute(sql, (energy_load_history_provider_id,)) + if cursor.rowcount == 0: + self.logger.warning( + f"Attempted to remove non-existent energy load history provider " + f"{energy_load_history_provider_id}." + ) + except sqlite3.Error as e: + self.logger.error( + f"SQLite error removing energy load history provider {energy_load_history_provider_id}: {e}" + ) + raise EnergyLoadHistoryProviderError(f"DB error removing energy load history provider: {e}") from e + finally: + if conn: + conn.close() + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[EnergyLoadHistoryProvider]: + sql = "SELECT * FROM energy_load_history_providers WHERE external_service_id = ?;" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (external_service_id,)) + rows = cursor.fetchall() + providers = [] + for row in rows: + provider = self._row_to_provider(row) + if provider: + providers.append(provider) + return providers + except sqlite3.Error as e: + self.logger.error( + f"SQLite error retrieving energy load history providers by external service ID " + f"{external_service_id}: {e}" + ) + return [] + finally: + if conn: + conn.close() + + +class SqlAlchemyEnergyLoadHistoryProviderRepository(EnergyLoadHistoryProviderRepository): + """SQLAlchemy implementation of EnergyLoadHistoryProviderRepository.""" + + def __init__(self, db: BaseSQLAlchemyRepository): + self._db = db + self.logger = db.logger + + def add(self, energy_load_history_provider: EnergyLoadHistoryProvider) -> None: + session = self._db.get_session() + try: + session.add(energy_load_history_provider) + session.commit() + finally: + session.close() + + def get_by_id(self, energy_load_history_provider_id: EntityId) -> Optional[EnergyLoadHistoryProvider]: + session = self._db.get_session() + try: + stmt = select(EnergyLoadHistoryProvider).where( + energy_load_history_providers_table.c.id == str(energy_load_history_provider_id) + ) + entity = session.execute(stmt).scalar_one_or_none() + return entity + finally: + session.close() + + def get_all(self) -> List[EnergyLoadHistoryProvider]: + session = self._db.get_session() + try: + stmt = select(EnergyLoadHistoryProvider) + entities = session.execute(stmt).scalars().all() + return list(entities) + finally: + session.close() + + def update(self, energy_load_history_provider: EnergyLoadHistoryProvider) -> None: + session = self._db.get_session() + try: + stmt = select(EnergyLoadHistoryProvider).where( + energy_load_history_providers_table.c.id == str(energy_load_history_provider.id) + ) + existing_entity = session.execute(stmt).scalar_one_or_none() + if existing_entity: + existing_entity.name = energy_load_history_provider.name + existing_entity.adapter_type = energy_load_history_provider.adapter_type + existing_entity.config = energy_load_history_provider.config + existing_entity.external_service_id = energy_load_history_provider.external_service_id + session.commit() + finally: + session.close() + + def remove(self, energy_load_history_provider_id: EntityId) -> None: + session = self._db.get_session() + try: + stmt = select(EnergyLoadHistoryProvider).where( + energy_load_history_providers_table.c.id == str(energy_load_history_provider_id) + ) + entity = session.execute(stmt).scalar_one_or_none() + if entity: + session.delete(entity) + session.commit() + finally: + session.close() + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[EnergyLoadHistoryProvider]: + session = self._db.get_session() + try: + stmt = select(EnergyLoadHistoryProvider).where( + energy_load_history_providers_table.c.external_service_id == str(external_service_id) + ) + entities = session.execute(stmt).scalars().all() + return list(entities) + finally: + session.close() + + +# --- LoadConsumptionModel Repositories --- + + +class InMemoryLoadConsumptionModelRepository(LoadConsumptionModelRepository): + """In-memory implementation of LoadConsumptionModelRepository.""" + + def __init__(self) -> None: + self._models: Dict[str, LoadConsumptionModel] = {} + + def add(self, model: LoadConsumptionModel) -> None: + self._models[str(model.id)] = copy.deepcopy(model) + + def get_by_id(self, model_id: EntityId) -> Optional[LoadConsumptionModel]: + model = self._models.get(str(model_id)) + return copy.deepcopy(model) if model else None + + def get_active_model( + self, + adapter_type: EnergyLoadForecastProviderAdapter, + device_id: Optional[EntityId] = None, + ) -> Optional[LoadConsumptionModel]: + for model in self._models.values(): + if model.adapter_type == adapter_type and model.is_active: + if device_id is None and model.device_id is None: + return copy.deepcopy(model) + if device_id is not None and model.device_id is not None: + if str(model.device_id) == str(device_id): + return copy.deepcopy(model) + return None + + def get_all(self, device_id: Optional[EntityId] = None) -> List[LoadConsumptionModel]: + models = list(self._models.values()) + if device_id is not None: + models = [m for m in models if m.device_id is not None and str(m.device_id) == str(device_id)] + return [copy.deepcopy(m) for m in models] + + def update(self, model: LoadConsumptionModel) -> None: + key = str(model.id) + if key in self._models: + self._models[key] = copy.deepcopy(model) + + def remove(self, model_id: EntityId) -> None: + self._models.pop(str(model_id), None) + + +class SqliteLoadConsumptionModelRepository(LoadConsumptionModelRepository): + """SQLite implementation of LoadConsumptionModelRepository.""" + + def __init__(self, db: BaseSqliteRepository): + self._db = db + self.logger = db.logger + self._create_tables() + + def _create_tables(self) -> None: + self.logger.debug(f"Ensuring SQLite tables exist for LoadConsumptionModel Repository in {self._db.db_path}...") + sql = """ + CREATE TABLE IF NOT EXISTS load_consumption_models ( + id TEXT PRIMARY KEY, + device_id TEXT, + adapter_type TEXT NOT NULL, + trained_at TIMESTAMP, + mae REAL, + rmse REAL, + samples_used INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 0, + model_bytes BLOB + ); + """ + idx_sql = """ + CREATE INDEX IF NOT EXISTS ix_load_consumption_models_active + ON load_consumption_models (adapter_type, device_id, is_active); + """ + conn = self._db.get_connection() + try: + with conn: + conn.execute(sql) + conn.execute(idx_sql) + except sqlite3.Error as e: + self.logger.error(f"Error creating SQLite tables for LoadConsumptionModel: {e}") + raise ConfigurationError(f"DB error creating tables: {e}") from e + finally: + if conn: + conn.close() + + def _row_to_model(self, row: sqlite3.Row) -> Optional[LoadConsumptionModel]: + if not row: + return None + try: + return LoadConsumptionModel( + id=EntityId(uuid.UUID(row["id"])), + device_id=EntityId(uuid.UUID(row["device_id"])) if row["device_id"] else None, + adapter_type=EnergyLoadForecastProviderAdapter(row["adapter_type"]), + trained_at=row["trained_at"], + mae=row["mae"], + rmse=row["rmse"], + samples_used=row["samples_used"], + is_active=bool(row["is_active"]), + model_bytes=row["model_bytes"], + ) + except (ValueError, KeyError) as e: + self.logger.error(f"Error deserializing LoadConsumptionModel from DB row: {e}") + return None + + def add(self, model: LoadConsumptionModel) -> None: + sql = """ + INSERT INTO load_consumption_models + (id, device_id, adapter_type, trained_at, mae, rmse, samples_used, is_active, model_bytes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + """ + conn = self._db.get_connection() + try: + with conn: + conn.execute( + sql, + ( + str(model.id), + str(model.device_id) if model.device_id else None, + model.adapter_type.value + if isinstance(model.adapter_type, EnergyLoadForecastProviderAdapter) + else model.adapter_type, + model.trained_at, + model.mae, + model.rmse, + model.samples_used, + int(model.is_active), + model.model_bytes, + ), + ) + except sqlite3.Error as e: + self.logger.error(f"SQLite error adding LoadConsumptionModel {model.id}: {e}") + raise ConfigurationError(f"DB error adding LoadConsumptionModel: {e}") from e + finally: + if conn: + conn.close() + + def get_by_id(self, model_id: EntityId) -> Optional[LoadConsumptionModel]: + sql = "SELECT * FROM load_consumption_models WHERE id = ?;" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (str(model_id),)) + row = cursor.fetchone() + return self._row_to_model(row) + except sqlite3.Error as e: + self.logger.error(f"SQLite error retrieving LoadConsumptionModel {model_id}: {e}") + return None + finally: + if conn: + conn.close() + + def get_active_model( + self, + adapter_type: EnergyLoadForecastProviderAdapter, + device_id: Optional[EntityId] = None, + ) -> Optional[LoadConsumptionModel]: + adapter_val = ( + adapter_type.value if isinstance(adapter_type, EnergyLoadForecastProviderAdapter) else adapter_type + ) + if device_id is not None: + sql = """ + SELECT * FROM load_consumption_models + WHERE adapter_type = ? AND device_id = ? AND is_active = 1 + LIMIT 1; + """ + params = (adapter_val, str(device_id)) + else: + sql = """ + SELECT * FROM load_consumption_models + WHERE adapter_type = ? AND device_id IS NULL AND is_active = 1 + LIMIT 1; + """ + params = (adapter_val,) + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, params) + row = cursor.fetchone() + return self._row_to_model(row) + except sqlite3.Error as e: + self.logger.error(f"SQLite error retrieving active model for {adapter_type}: {e}") + return None + finally: + if conn: + conn.close() + + def get_all(self, device_id: Optional[EntityId] = None) -> List[LoadConsumptionModel]: + if device_id is not None: + sql = "SELECT * FROM load_consumption_models WHERE device_id = ? ORDER BY trained_at DESC;" + params = (str(device_id),) + else: + sql = "SELECT * FROM load_consumption_models ORDER BY trained_at DESC;" + params = () + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, params) + rows = cursor.fetchall() + models: List[LoadConsumptionModel] = [] + for row in rows: + model = self._row_to_model(row) + if model: + models.append(model) + return models + except sqlite3.Error as e: + self.logger.error(f"SQLite error retrieving all LoadConsumptionModels: {e}") + return [] + finally: + if conn: + conn.close() + + def update(self, model: LoadConsumptionModel) -> None: + sql = """ + UPDATE load_consumption_models + SET device_id = ?, adapter_type = ?, trained_at = ?, mae = ?, rmse = ?, + samples_used = ?, is_active = ?, model_bytes = ? + WHERE id = ?; + """ + conn = self._db.get_connection() + try: + with conn: + conn.execute( + sql, + ( + str(model.device_id) if model.device_id else None, + model.adapter_type.value + if isinstance(model.adapter_type, EnergyLoadForecastProviderAdapter) + else model.adapter_type, + model.trained_at, + model.mae, + model.rmse, + model.samples_used, + int(model.is_active), + model.model_bytes, + str(model.id), + ), + ) + except sqlite3.Error as e: + self.logger.error(f"SQLite error updating LoadConsumptionModel {model.id}: {e}") + raise ConfigurationError(f"DB error updating LoadConsumptionModel: {e}") from e + finally: + if conn: + conn.close() + + def remove(self, model_id: EntityId) -> None: + sql = "DELETE FROM load_consumption_models WHERE id = ?;" + conn = self._db.get_connection() + try: + with conn: + conn.execute(sql, (str(model_id),)) + except sqlite3.Error as e: + self.logger.error(f"SQLite error removing LoadConsumptionModel {model_id}: {e}") + raise ConfigurationError(f"DB error removing LoadConsumptionModel: {e}") from e + finally: + if conn: + conn.close() + + +class SqlAlchemyLoadConsumptionModelRepository(LoadConsumptionModelRepository): + """SQLAlchemy implementation of LoadConsumptionModelRepository.""" + + def __init__(self, db: BaseSQLAlchemyRepository): + self._db = db + self.logger = db.logger + + def add(self, model: LoadConsumptionModel) -> None: + session = self._db.get_session() + try: + session.add(model) + session.commit() + finally: + session.close() + + def get_by_id(self, model_id: EntityId) -> Optional[LoadConsumptionModel]: + session = self._db.get_session() + try: + stmt = select(LoadConsumptionModel).where(load_consumption_models_table.c.id == str(model_id)) + entity = session.execute(stmt).scalar_one_or_none() + return entity + finally: + session.close() + + def get_active_model( + self, + adapter_type: EnergyLoadForecastProviderAdapter, + device_id: Optional[EntityId] = None, + ) -> Optional[LoadConsumptionModel]: + session = self._db.get_session() + try: + adapter_val = ( + adapter_type.value if isinstance(adapter_type, EnergyLoadForecastProviderAdapter) else adapter_type + ) + stmt = ( + select(LoadConsumptionModel) + .where(load_consumption_models_table.c.adapter_type == adapter_val) + .where(load_consumption_models_table.c.is_active == True) # noqa: E712 + ) + if device_id is not None: + stmt = stmt.where(load_consumption_models_table.c.device_id == str(device_id)) + else: + stmt = stmt.where(load_consumption_models_table.c.device_id.is_(None)) + entity = session.execute(stmt).scalar_one_or_none() + return entity + finally: + session.close() + + def get_all(self, device_id: Optional[EntityId] = None) -> List[LoadConsumptionModel]: + session = self._db.get_session() + try: + stmt = select(LoadConsumptionModel) + if device_id is not None: + stmt = stmt.where(load_consumption_models_table.c.device_id == str(device_id)) + stmt = stmt.order_by(load_consumption_models_table.c.trained_at.desc()) + return list(session.execute(stmt).scalars().all()) + finally: + session.close() + + def update(self, model: LoadConsumptionModel) -> None: + session = self._db.get_session() + try: + stmt = select(LoadConsumptionModel).where(load_consumption_models_table.c.id == str(model.id)) + existing = session.execute(stmt).scalar_one_or_none() + if existing: + existing.device_id = model.device_id + existing.adapter_type = model.adapter_type + existing.trained_at = model.trained_at + existing.mae = model.mae + existing.rmse = model.rmse + existing.samples_used = model.samples_used + existing.is_active = model.is_active + existing.model_bytes = model.model_bytes + session.commit() + finally: + session.close() + + def remove(self, model_id: EntityId) -> None: + session = self._db.get_session() + try: + stmt = select(LoadConsumptionModel).where(load_consumption_models_table.c.id == str(model_id)) + entity = session.execute(stmt).scalar_one_or_none() + if entity: + session.delete(entity) + session.commit() + finally: + session.close() diff --git a/core/edge_mining/adapters/domain/home_load/schemas.py b/core/edge_mining/adapters/domain/home_load/schemas.py new file mode 100644 index 0000000..441b210 --- /dev/null +++ b/core/edge_mining/adapters/domain/home_load/schemas.py @@ -0,0 +1,1184 @@ +"""Validation schemas for home load domain.""" + +import uuid +from datetime import datetime +from typing import Dict, List, Optional, Union, cast + +from pydantic import BaseModel, Field, computed_field, field_serializer, field_validator + +from edge_mining.domain.common import EntityId, Timestamp, WattHours +from edge_mining.domain.home_load.aggregate_roots import HomeLoadsProfile +from edge_mining.domain.home_load.common import ( + EnergyLoadForecastProviderAdapter, + EnergyLoadHistoryProviderAdapter, + LoadDeviceCategory, +) +from edge_mining.domain.home_load.entities import ( + EnergyLoadForecastProvider, + EnergyLoadHistoryProvider, + LoadConsumptionModel, + LoadDevice, +) +from edge_mining.domain.home_load.value_objects import ( + HomeLoadEnergyInterval, + HomeLoadPowerPoint, + HomeLoadsConsumption, + LoadDeviceConsumption, + LoadEnergyConsumption, +) +from edge_mining.shared.adapter_configs.home_load import ( + EnergyLoadForecastProviderDummyConfig, + EnergyLoadForecastProviderNaiveLastHourConfig, + EnergyLoadForecastProviderNaivePersistenceConfig, + EnergyLoadForecastProviderSeasonalBaselineConfig, + EnergyLoadForecastProviderSkforecastConfig, + EnergyLoadForecastProviderStatsmodelsConfig, + EnergyLoadForecastProviderTypicalProfileConfig, + EnergyLoadForecastProviderXGBoostConfig, + EnergyLoadHistoryProviderHomeAssistantAPIConfig, +) +from edge_mining.shared.adapter_maps.home_load import ( + ENERGY_LOAD_FORECAST_PROVIDER_CONFIG_TYPE_MAP, + ENERGY_LOAD_HISTORY_PROVIDER_CONFIG_TYPE_MAP, +) +from edge_mining.shared.interfaces.config import EnergyLoadForecastProviderConfig, EnergyLoadHistoryProviderConfig + + +class HomeLoadEnergyIntervalSchema(BaseModel): + """Schema for HomeLoadEnergyInterval value object.""" + + start: datetime = Field(..., description="Interval start timestamp") + end: datetime = Field(..., description="Interval end timestamp") + energy: Optional[float] = Field(default=None, description="Energy in watt-hours") + avg_power: Optional[float] = Field(default=None, description="Average power in watts") + + +class LoadEnergyConsumptionSchema(BaseModel): + """Schema for LoadEnergyConsumption value object.""" + + timestamp: datetime = Field(..., description="When this consumption data was generated") + intervals: List[HomeLoadEnergyIntervalSchema] = Field( + default_factory=list, description="List of consumption intervals" + ) + + @classmethod + def from_model(cls, consumption: LoadEnergyConsumption) -> "LoadEnergyConsumptionSchema": + """Create schema from domain model.""" + intervals = [ + HomeLoadEnergyIntervalSchema( + start=cast(datetime, interval.start), + end=cast(datetime, interval.end), + energy=float(interval.energy) if interval.energy is not None else None, + avg_power=float(interval.avg_power), + ) + for interval in consumption.intervals + ] + + return cls( + timestamp=cast(datetime, consumption.timestamp), + intervals=intervals, + ) + + def to_model(self) -> LoadEnergyConsumption: + """Convert schema to domain model.""" + intervals: List[HomeLoadEnergyInterval] = [] + for interval_schema in self.intervals: + intervals.append( + HomeLoadEnergyInterval( + start=Timestamp(interval_schema.start), + end=Timestamp(interval_schema.end), + energy=None if interval_schema.energy is None else WattHours(interval_schema.energy), + ) + ) + + return LoadEnergyConsumption( + timestamp=Timestamp(self.timestamp), + intervals=intervals, + ) + + +class LoadDeviceConsumptionSchema(BaseModel): + """Schema for LoadDeviceConsumption value object (device-bound history + forecast).""" + + device_id: str = Field(..., description="Device UUID") + device_name: str = Field(..., description="Device unique name within profile") + device_category: LoadDeviceCategory = Field(..., description="Device category") + history: LoadEnergyConsumptionSchema = Field( + default_factory=lambda: LoadEnergyConsumptionSchema(timestamp=datetime.now(), intervals=[]), + description="Measured consumption time series.", + ) + forecast: LoadEnergyConsumptionSchema = Field( + default_factory=lambda: LoadEnergyConsumptionSchema(timestamp=datetime.now(), intervals=[]), + description="Predicted consumption time series.", + ) + + @classmethod + def from_model(cls, consumption: LoadDeviceConsumption) -> "LoadDeviceConsumptionSchema": + return cls( + device_id=str(consumption.device_id), + device_name=consumption.device_name, + device_category=consumption.device_category, + history=LoadEnergyConsumptionSchema.from_model(consumption.history), + forecast=LoadEnergyConsumptionSchema.from_model(consumption.forecast), + ) + + def to_model(self) -> LoadDeviceConsumption: + return LoadDeviceConsumption( + device_id=EntityId(uuid.UUID(self.device_id)), + device_name=self.device_name, + device_category=self.device_category, + history=self.history.to_model(), + forecast=self.forecast.to_model(), + ) + + +class HomeLoadsConsumptionSchema(BaseModel): + """Schema for HomeLoadsConsumption value object (unified household view).""" + + per_device: List[LoadDeviceConsumptionSchema] = Field(default_factory=list) + total_history: LoadEnergyConsumptionSchema = Field( + default_factory=lambda: LoadEnergyConsumptionSchema(timestamp=datetime.now(), intervals=[]), + description="Aggregated household history.", + ) + total_forecast: LoadEnergyConsumptionSchema = Field( + default_factory=lambda: LoadEnergyConsumptionSchema(timestamp=datetime.now(), intervals=[]), + description="Aggregated household forecast.", + ) + + @classmethod + def from_model(cls, consumption: HomeLoadsConsumption) -> "HomeLoadsConsumptionSchema": + return cls( + per_device=[LoadDeviceConsumptionSchema.from_model(d) for d in consumption.per_device], + total_history=LoadEnergyConsumptionSchema.from_model(consumption.total_history), + total_forecast=LoadEnergyConsumptionSchema.from_model(consumption.total_forecast), + ) + + def to_model(self) -> HomeLoadsConsumption: + return HomeLoadsConsumption( + per_device=[d.to_model() for d in self.per_device], + total_history=self.total_history.to_model(), + total_forecast=self.total_forecast.to_model(), + ) + + +class LoadDeviceSchema(BaseModel): + """Schema for LoadDevice entity with complete validation.""" + + id: str = Field(..., description="Unique identifier for the load device") + name: str = Field(default="", description="Load device name") + category: LoadDeviceCategory = Field( + default=LoadDeviceCategory.OCCASIONAL, description="Category of load device (e.g., controllable, continuous)" + ) + enabled: bool = Field(default=True, description="Whether the load device is active in the system") + energy_load_forecast_provider_id: Optional[str] = Field( + default=None, description="ID of the energy load forecast provider associated with this load device" + ) + energy_load_history_provider_id: Optional[str] = Field( + default=None, description="ID of the energy load history provider associated with this load device" + ) + + @field_validator("id") + @classmethod + def validate_id(cls, v: str) -> str: + """Validate that id is a valid UUID string.""" + try: + uuid.UUID(v) + return v + except ValueError as e: + raise ValueError(f"Invalid UUID format: {v}") from e + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate device name.""" + if not v.strip(): + raise ValueError("Device name cannot be empty") + return v.strip() + + @field_validator("energy_load_forecast_provider_id", "energy_load_history_provider_id") + @classmethod + def validate_provider_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that provider ID is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("Provider ID must be a valid UUID string") from exc + return v + + @classmethod + def from_model(cls, load_device: LoadDevice) -> "LoadDeviceSchema": + """Create schema from domain model.""" + return cls( + id=str(load_device.id), + name=load_device.name, + category=load_device.category, + enabled=load_device.enabled, + energy_load_forecast_provider_id=( + str(load_device.energy_load_forecast_provider_id) + if load_device.energy_load_forecast_provider_id + else None + ), + energy_load_history_provider_id=( + str(load_device.energy_load_history_provider_id) + if load_device.energy_load_history_provider_id + else None + ), + ) + + @field_serializer("id") + def serialize_id(self, value: str) -> str: + """Serialize id field.""" + return value + + @field_serializer("energy_load_forecast_provider_id", "energy_load_history_provider_id") + def serialize_provider_id(self, value: Optional[str]) -> Optional[str]: + """Serialize provider ID field.""" + return value + + def to_model(self) -> LoadDevice: + """Convert schema to domain model.""" + forecast_provider_id = ( + EntityId(uuid.UUID(self.energy_load_forecast_provider_id)) + if self.energy_load_forecast_provider_id + else None + ) + history_provider_id = ( + EntityId(uuid.UUID(self.energy_load_history_provider_id)) if self.energy_load_history_provider_id else None + ) + return LoadDevice( + id=EntityId(uuid.UUID(self.id)), + name=self.name, + category=LoadDeviceCategory(self.category) if isinstance(self.category, str) else self.category, + enabled=self.enabled, + energy_load_forecast_provider_id=forecast_provider_id, + energy_load_history_provider_id=history_provider_id, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + + +class LoadDeviceCreateSchema(BaseModel): + """Schema for creating a new load device.""" + + name: str = Field(default="", description="Load device name") + category: LoadDeviceCategory = Field(default=LoadDeviceCategory.OCCASIONAL, description="Category of load device") + enabled: bool = Field(default=True, description="Whether the load device is active in the system") + energy_load_forecast_provider_id: Optional[str] = Field( + default=None, description="ID of the energy load forecast provider associated with this load device" + ) + energy_load_history_provider_id: Optional[str] = Field( + default=None, description="ID of the energy load history provider associated with this load device" + ) + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate device name.""" + if not v.strip(): + raise ValueError("Device name cannot be empty") + return v.strip() + + @field_validator("energy_load_forecast_provider_id", "energy_load_history_provider_id") + @classmethod + def validate_provider_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that provider ID is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("Provider ID must be a valid UUID string") from exc + return v + + @field_serializer("energy_load_forecast_provider_id", "energy_load_history_provider_id") + def serialize_provider_id(self, value: Optional[str]) -> Optional[str]: + """Serialize provider ID field.""" + return value + + def to_model(self) -> LoadDevice: + """Convert schema to domain model.""" + forecast_provider_id = ( + EntityId(uuid.UUID(self.energy_load_forecast_provider_id)) + if self.energy_load_forecast_provider_id + else None + ) + history_provider_id = ( + EntityId(uuid.UUID(self.energy_load_history_provider_id)) if self.energy_load_history_provider_id else None + ) + return LoadDevice( + id=EntityId(uuid.uuid4()), + name=self.name, + category=LoadDeviceCategory(self.category) if isinstance(self.category, str) else self.category, + enabled=self.enabled, + energy_load_forecast_provider_id=forecast_provider_id, + energy_load_history_provider_id=history_provider_id, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + + +class LoadDeviceUpdateSchema(BaseModel): + """Schema for updating an existing load device.""" + + name: str = Field(default="", description="Load device name") + category: LoadDeviceCategory = Field(default=LoadDeviceCategory.OCCASIONAL, description="Category of load device") + enabled: bool = Field(default=True, description="Whether the load device is active in the system") + energy_load_forecast_provider_id: Optional[str] = Field( + default=None, description="ID of the energy load forecast provider associated with this load device" + ) + energy_load_history_provider_id: Optional[str] = Field( + default=None, description="ID of the energy load history provider associated with this load device" + ) + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate device name.""" + if not v.strip(): + raise ValueError("Device name cannot be empty") + return v.strip() + + @field_validator("energy_load_forecast_provider_id", "energy_load_history_provider_id") + @classmethod + def validate_provider_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that provider ID is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("Provider ID must be a valid UUID string") from exc + return v + + @field_serializer("energy_load_forecast_provider_id", "energy_load_history_provider_id") + def serialize_provider_id(self, value: Optional[str]) -> Optional[str]: + """Serialize provider ID field.""" + return value + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + + +class HomeLoadsProfileSchema(BaseModel): + """Schema for HomeLoadsProfile aggregate root.""" + + id: str = Field(..., description="Unique identifier for the home loads profile") + name: str = Field(default="Default Home Profile", description="Profile name") + devices: List[LoadDeviceSchema] = Field(default_factory=list, description="Load devices in this profile") + + @field_validator("id") + @classmethod + def validate_id(cls, v: str) -> str: + """Validate that id is a valid UUID string.""" + try: + uuid.UUID(v) + return v + except ValueError as e: + raise ValueError(f"Invalid UUID format: {v}") from e + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate profile name.""" + if not v.strip(): + raise ValueError("Profile name cannot be empty") + return v.strip() + + @classmethod + def from_model(cls, profile: HomeLoadsProfile) -> "HomeLoadsProfileSchema": + """Create schema from domain model.""" + devices = [] + for device in profile.devices: + devices.append(LoadDeviceSchema.from_model(device)) + + return cls( + id=str(profile.id), + name=profile.name, + devices=devices, + ) + + @field_serializer("id") + def serialize_id(self, value: str) -> str: + """Serialize id field.""" + return value + + def to_model(self) -> HomeLoadsProfile: + """Convert schema to domain model.""" + devices = [] + for device_schema in self.devices: + devices.append(device_schema.to_model()) + + return HomeLoadsProfile( + id=EntityId(uuid.UUID(self.id)), + name=self.name, + devices=devices, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + + +class HomeLoadsProfileCreateSchema(BaseModel): + """Schema for creating a new home loads profile.""" + + name: str = Field(default="Default Home Profile", description="Profile name") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate profile name.""" + if not v.strip(): + raise ValueError("Profile name cannot be empty") + return v.strip() + + def to_model(self) -> HomeLoadsProfile: + """Convert schema to domain model.""" + return HomeLoadsProfile( + id=EntityId(uuid.uuid4()), + name=self.name, + devices=[], + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + + +class HomeLoadsProfileUpdateSchema(BaseModel): + """Schema for updating an existing home loads profile.""" + + name: str = Field(default="", description="Profile name") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate profile name.""" + if not v.strip(): + raise ValueError("Profile name cannot be empty") + return v.strip() + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + + +class EnergyLoadForecastProviderSchema(BaseModel): + """Schema for EnergyLoadForecastProvider entity with complete validation.""" + + id: str = Field(..., description="Unique identifier for the energy load forecast provider") + name: str = Field(default="", description="Energy load forecast provider name") + adapter_type: EnergyLoadForecastProviderAdapter = Field( + default=EnergyLoadForecastProviderAdapter.DUMMY, + description="Type of energy load forecast provider adapter", + ) + config: dict = Field(default={}, description="Energy load forecast provider configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @computed_field # type: ignore[prop-decorator] + @property + def min_required_history_hours(self) -> int: + """Minimum hours of historical data the provider needs to produce a forecast.""" + adapter = self.adapter_type + cfg = self.config or {} + + if adapter == EnergyLoadForecastProviderAdapter.NAIVE_LAST_HOUR: + return 1 + if adapter == EnergyLoadForecastProviderAdapter.NAIVE_PERSISTENCE: + delta_days = int(cfg.get("delta_days", 1)) + return delta_days * 24 + if adapter == EnergyLoadForecastProviderAdapter.SKFORECAST: + num_lags = int(cfg.get("num_lags", 72)) + hours_ahead = int(cfg.get("hours_ahead", 24)) + return num_lags + 48 + hours_ahead + if adapter == EnergyLoadForecastProviderAdapter.STATSMODELS: + seasonal_periods = int(cfg.get("seasonal_periods", 24)) + return seasonal_periods * 2 + if adapter == EnergyLoadForecastProviderAdapter.TYPICAL_PROFILE: + weeks_lookback = int(cfg.get("weeks_lookback", 8)) + return weeks_lookback * 168 + if adapter == EnergyLoadForecastProviderAdapter.XGBOOST: + hours_ahead = int(cfg.get("hours_ahead", 3)) + return 168 + 48 + hours_ahead + return 0 + + @field_validator("id") + @classmethod + def validate_id(cls, v: str) -> str: + """Validate that id is a valid UUID string.""" + try: + uuid.UUID(v) + return v + except ValueError as e: + raise ValueError(f"Invalid UUID format: {v}") from e + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate provider name.""" + if not v.strip(): + raise ValueError("Provider name cannot be empty") + return v.strip() + + @field_validator("adapter_type") + @classmethod + def validate_adapter_type(cls, v: str) -> EnergyLoadForecastProviderAdapter: + """Validate that adapter_type is a recognized EnergyLoadForecastProviderAdapter.""" + adapter_values = [adapter.value for adapter in EnergyLoadForecastProviderAdapter] + if v not in adapter_values: + raise ValueError(f"adapter_type must be one of {adapter_values}") + return EnergyLoadForecastProviderAdapter(v) + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that external_service_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("external_service_id must be a valid UUID string") from exc + return v + + @classmethod + def from_model(cls, provider: EnergyLoadForecastProvider) -> "EnergyLoadForecastProviderSchema": + """Create schema from domain model.""" + config_dict = {} + if provider.config: + config_dict = provider.config.to_dict() + + return cls( + id=str(provider.id), + name=provider.name, + adapter_type=provider.adapter_type, + config=config_dict, + external_service_id=str(provider.external_service_id) if provider.external_service_id else None, + ) + + @field_serializer("id") + def serialize_id(self, value: str) -> str: + """Serialize id field.""" + return value + + @field_serializer("external_service_id") + def serialize_external_service_id(self, value: Optional[str]) -> Optional[str]: + """Serialize external service id field.""" + return value + + def to_model(self) -> EnergyLoadForecastProvider: + """Convert schema to domain model.""" + configuration: Optional[EnergyLoadForecastProviderConfig] = None + if self.config: + config_type = ENERGY_LOAD_FORECAST_PROVIDER_CONFIG_TYPE_MAP.get(self.adapter_type) + if config_type: + configuration = cast(EnergyLoadForecastProviderConfig, config_type.from_dict(self.config)) + + return EnergyLoadForecastProvider( + id=EntityId(uuid.UUID(self.id)), + name=self.name, + adapter_type=self.adapter_type, + config=configuration, + external_service_id=EntityId(uuid.UUID(self.external_service_id)) if self.external_service_id else None, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + arbitrary_types_allowed = True + json_encoders = { + uuid.UUID: str, + EnergyLoadForecastProviderAdapter: lambda v: v.value, + } + + +class EnergyLoadForecastProviderCreateSchema(BaseModel): + """Schema for creating a new energy load forecast provider.""" + + name: str = Field(default="", description="Energy load forecast provider name") + adapter_type: EnergyLoadForecastProviderAdapter = Field( + default=EnergyLoadForecastProviderAdapter.DUMMY, + description="Type of energy load forecast provider adapter", + ) + config: Optional[dict] = Field(default=None, description="Energy load forecast provider configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate provider name.""" + if not v.strip(): + raise ValueError("Provider name cannot be empty") + return v.strip() + + @field_validator("adapter_type") + @classmethod + def validate_adapter_type(cls, v: str) -> EnergyLoadForecastProviderAdapter: + """Validate that adapter_type is a recognized EnergyLoadForecastProviderAdapter.""" + adapter_values = [adapter.value for adapter in EnergyLoadForecastProviderAdapter] + if v not in adapter_values: + raise ValueError(f"adapter_type must be one of {adapter_values}") + return EnergyLoadForecastProviderAdapter(v) + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that external_service_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("external_service_id must be a valid UUID string") from exc + return v + + def to_model(self) -> EnergyLoadForecastProvider: + """Convert schema to domain model.""" + configuration: Optional[EnergyLoadForecastProviderConfig] = None + if self.config: + config_type = ENERGY_LOAD_FORECAST_PROVIDER_CONFIG_TYPE_MAP.get(self.adapter_type) + if config_type: + configuration = cast(EnergyLoadForecastProviderConfig, config_type.from_dict(self.config)) + + return EnergyLoadForecastProvider( + id=EntityId(uuid.uuid4()), + name=self.name, + adapter_type=self.adapter_type, + config=configuration, + external_service_id=EntityId(uuid.UUID(self.external_service_id)) if self.external_service_id else None, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + json_encoders = { + uuid.UUID: str, + EnergyLoadForecastProviderAdapter: lambda v: v.value, + } + + +class EnergyLoadForecastProviderUpdateSchema(BaseModel): + """Schema for updating an existing energy load forecast provider.""" + + name: str = Field(default="", description="Energy load forecast provider name") + config: Optional[dict] = Field(default=None, description="Energy load forecast provider configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate provider name.""" + if not v.strip(): + raise ValueError("Provider name cannot be empty") + return v.strip() + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that external_service_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("external_service_id must be a valid UUID string") from exc + return v + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + +class EnergyLoadForecastProviderDummyConfigSchema(BaseModel): + """Schema for Dummy EnergyLoadForecastProviderConfig.""" + + load_power_max: float = Field(default=500.0, ge=0, description="Maximum load power in Watts") + + @field_validator("load_power_max") + @classmethod + def validate_load_power_max(cls, v: float) -> float: + """Validate load power max is non-negative.""" + if v < 0: + raise ValueError("Maximum load power cannot be negative") + return v + + def to_model(self) -> EnergyLoadForecastProviderDummyConfig: + """Convert schema to domain model.""" + return EnergyLoadForecastProviderDummyConfig(load_power_max=self.load_power_max) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + +class EnergyLoadForecastProviderNaiveLastHourConfigSchema(BaseModel): + """Schema for NaiveLastHour EnergyLoadForecastProviderConfig.""" + + hours_ahead: int = Field(default=3, ge=1, le=72, description="Number of hours to forecast ahead") + + def to_model(self) -> EnergyLoadForecastProviderNaiveLastHourConfig: + """Convert schema to domain model.""" + return EnergyLoadForecastProviderNaiveLastHourConfig(hours_ahead=self.hours_ahead) + + class Config: + use_enum_values = True + validate_assignment = True + + +class EnergyLoadForecastProviderNaivePersistenceConfigSchema(BaseModel): + """Schema for NaivePersistence EnergyLoadForecastProviderConfig.""" + + hours_ahead: int = Field(default=24, ge=1, le=72, description="Number of hours to forecast ahead") + delta_days: int = Field(default=1, ge=1, le=7, description="Number of days back to use as reference") + + def to_model(self) -> EnergyLoadForecastProviderNaivePersistenceConfig: + """Convert schema to domain model.""" + return EnergyLoadForecastProviderNaivePersistenceConfig( + hours_ahead=self.hours_ahead, + delta_days=self.delta_days, + ) + + class Config: + use_enum_values = True + validate_assignment = True + + +class EnergyLoadForecastProviderSeasonalBaselineConfigSchema(BaseModel): + """Schema for SeasonalBaseline EnergyLoadForecastProviderConfig.""" + + hours_ahead: int = Field(default=3, ge=1, le=72, description="Number of hours to forecast ahead") + weeks_lookback: int = Field(default=4, ge=1, le=52, description="Number of weeks of history to use for profiling") + + def to_model(self) -> EnergyLoadForecastProviderSeasonalBaselineConfig: + """Convert schema to domain model.""" + return EnergyLoadForecastProviderSeasonalBaselineConfig( + hours_ahead=self.hours_ahead, + weeks_lookback=self.weeks_lookback, + ) + + class Config: + use_enum_values = True + validate_assignment = True + + +class EnergyLoadForecastProviderTypicalProfileConfigSchema(BaseModel): + """Schema for TypicalProfile EnergyLoadForecastProviderConfig.""" + + hours_ahead: int = Field(default=24, ge=1, le=72, description="Number of hours to forecast ahead") + weeks_lookback: int = Field( + default=8, ge=1, le=52, description="Weeks of history to build the typical profile from" + ) + + def to_model(self) -> EnergyLoadForecastProviderTypicalProfileConfig: + """Convert schema to domain model.""" + return EnergyLoadForecastProviderTypicalProfileConfig( + hours_ahead=self.hours_ahead, + weeks_lookback=self.weeks_lookback, + ) + + class Config: + use_enum_values = True + validate_assignment = True + + +class EnergyLoadForecastProviderSkforecastConfigSchema(BaseModel): + """Schema for Skforecast EnergyLoadForecastProviderConfig.""" + + hours_ahead: int = Field(default=24, ge=1, le=72, description="Number of hours to forecast ahead") + weeks_lookback: int = Field(default=8, ge=1, le=52, description="Weeks of history for training") + sklearn_model: str = Field( + default="RandomForestRegressor", + description="Name of the sklearn regressor class to use as backend", + ) + num_lags: int = Field(default=72, ge=6, le=336, description="Number of lag features (hours)") + + def to_model(self) -> EnergyLoadForecastProviderSkforecastConfig: + """Convert schema to domain model.""" + return EnergyLoadForecastProviderSkforecastConfig( + hours_ahead=self.hours_ahead, + weeks_lookback=self.weeks_lookback, + sklearn_model=self.sklearn_model, + num_lags=self.num_lags, + ) + + class Config: + use_enum_values = True + validate_assignment = True + + +class EnergyLoadForecastProviderStatsmodelsConfigSchema(BaseModel): + """Schema for Statsmodels EnergyLoadForecastProviderConfig.""" + + hours_ahead: int = Field(default=3, ge=1, le=72, description="Number of hours to forecast ahead") + weeks_lookback: int = Field(default=8, ge=1, le=52, description="Weeks of history for training") + method: str = Field(default="hw", description="Statsmodels method: 'hw' (Holt-Winters) or 'sarima'") + seasonal_periods: int = Field(default=24, ge=1, le=168, description="Hours in a seasonal cycle") + + def to_model(self) -> EnergyLoadForecastProviderStatsmodelsConfig: + """Convert schema to domain model.""" + return EnergyLoadForecastProviderStatsmodelsConfig( + hours_ahead=self.hours_ahead, + weeks_lookback=self.weeks_lookback, + method=self.method, + seasonal_periods=self.seasonal_periods, + ) + + class Config: + use_enum_values = True + validate_assignment = True + + +class EnergyLoadForecastProviderXGBoostConfigSchema(BaseModel): + """Schema for XGBoost EnergyLoadForecastProviderConfig.""" + + hours_ahead: int = Field(default=3, ge=1, le=72, description="Number of hours to forecast ahead") + weeks_lookback: int = Field(default=8, ge=1, le=52, description="Weeks of history for training") + n_estimators: int = Field(default=100, ge=10, le=1000, description="Number of boosting rounds") + max_depth: int = Field(default=6, ge=1, le=15, description="Maximum tree depth") + learning_rate: float = Field(default=0.1, gt=0.0, le=1.0, description="Learning rate") + + def to_model(self) -> EnergyLoadForecastProviderXGBoostConfig: + """Convert schema to domain model.""" + return EnergyLoadForecastProviderXGBoostConfig( + hours_ahead=self.hours_ahead, + weeks_lookback=self.weeks_lookback, + n_estimators=self.n_estimators, + max_depth=self.max_depth, + learning_rate=self.learning_rate, + ) + + class Config: + use_enum_values = True + validate_assignment = True + + +ENERGY_LOAD_FORECAST_PROVIDER_CONFIG_SCHEMA_MAP: Dict[ + type[EnergyLoadForecastProviderConfig], + Union[ + type[EnergyLoadForecastProviderDummyConfigSchema], + type[EnergyLoadForecastProviderNaiveLastHourConfigSchema], + type[EnergyLoadForecastProviderNaivePersistenceConfigSchema], + type[EnergyLoadForecastProviderSeasonalBaselineConfigSchema], + type[EnergyLoadForecastProviderSkforecastConfigSchema], + type[EnergyLoadForecastProviderStatsmodelsConfigSchema], + type[EnergyLoadForecastProviderTypicalProfileConfigSchema], + type[EnergyLoadForecastProviderXGBoostConfigSchema], + ], +] = { + EnergyLoadForecastProviderDummyConfig: EnergyLoadForecastProviderDummyConfigSchema, + EnergyLoadForecastProviderNaiveLastHourConfig: EnergyLoadForecastProviderNaiveLastHourConfigSchema, + EnergyLoadForecastProviderNaivePersistenceConfig: EnergyLoadForecastProviderNaivePersistenceConfigSchema, + EnergyLoadForecastProviderSeasonalBaselineConfig: EnergyLoadForecastProviderSeasonalBaselineConfigSchema, + EnergyLoadForecastProviderSkforecastConfig: EnergyLoadForecastProviderSkforecastConfigSchema, + EnergyLoadForecastProviderStatsmodelsConfig: EnergyLoadForecastProviderStatsmodelsConfigSchema, + EnergyLoadForecastProviderTypicalProfileConfig: EnergyLoadForecastProviderTypicalProfileConfigSchema, + EnergyLoadForecastProviderXGBoostConfig: EnergyLoadForecastProviderXGBoostConfigSchema, +} + + +# --- Energy Load History Provider Schemas --- + + +class EnergyLoadHistoryProviderSchema(BaseModel): + """Schema for EnergyLoadHistoryProvider entity.""" + + id: str = Field(..., description="Unique identifier for the energy load history provider") + name: str = Field(default="", description="Energy load history provider name") + adapter_type: EnergyLoadHistoryProviderAdapter = Field( + default=EnergyLoadHistoryProviderAdapter.DUMMY, + description="Type of energy load history provider adapter", + ) + config: Optional[dict] = Field(default=None, description="Energy load history provider configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @field_validator("id") + @classmethod + def validate_id(cls, v: str) -> str: + """Validate that id is a valid UUID string.""" + try: + uuid.UUID(v) + return v + except ValueError as e: + raise ValueError(f"Invalid UUID format: {v}") from e + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate provider name.""" + if not v.strip(): + raise ValueError("Provider name cannot be empty") + return v.strip() + + @field_validator("adapter_type") + @classmethod + def validate_adapter_type(cls, v: str) -> EnergyLoadHistoryProviderAdapter: + """Validate that adapter_type is a recognized EnergyLoadHistoryProviderAdapter.""" + adapter_values = [adapter.value for adapter in EnergyLoadHistoryProviderAdapter] + if v not in adapter_values: + raise ValueError(f"adapter_type must be one of {adapter_values}") + return EnergyLoadHistoryProviderAdapter(v) + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that external_service_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("external_service_id must be a valid UUID string") from exc + return v + + @classmethod + def from_model(cls, provider: EnergyLoadHistoryProvider) -> "EnergyLoadHistoryProviderSchema": + """Create schema from domain model.""" + config_dict = None + if provider.config: + config_dict = provider.config.to_dict() + + return cls( + id=str(provider.id), + name=provider.name, + adapter_type=provider.adapter_type, + config=config_dict, + external_service_id=str(provider.external_service_id) if provider.external_service_id else None, + ) + + @field_serializer("id") + def serialize_id(self, value: str) -> str: + """Serialize id field.""" + return value + + @field_serializer("external_service_id") + def serialize_external_service_id(self, value: Optional[str]) -> Optional[str]: + """Serialize external service id field.""" + return value + + def to_model(self) -> EnergyLoadHistoryProvider: + """Convert schema to domain model.""" + configuration: Optional[EnergyLoadHistoryProviderConfig] = None + if self.config: + config_type = ENERGY_LOAD_HISTORY_PROVIDER_CONFIG_TYPE_MAP.get(self.adapter_type) + if config_type: + configuration = cast(EnergyLoadHistoryProviderConfig, config_type.from_dict(self.config)) + + return EnergyLoadHistoryProvider( + id=EntityId(uuid.UUID(self.id)), + name=self.name, + adapter_type=self.adapter_type, + config=configuration, + external_service_id=EntityId(uuid.UUID(self.external_service_id)) if self.external_service_id else None, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + arbitrary_types_allowed = True + json_encoders = { + uuid.UUID: str, + EnergyLoadHistoryProviderAdapter: lambda v: v.value, + } + + +class EnergyLoadHistoryProviderCreateSchema(BaseModel): + """Schema for creating a new energy load history provider.""" + + name: str = Field(default="", description="Energy load history provider name") + adapter_type: EnergyLoadHistoryProviderAdapter = Field( + default=EnergyLoadHistoryProviderAdapter.DUMMY, + description="Type of energy load history provider adapter", + ) + config: Optional[dict] = Field(default=None, description="Energy load history provider configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate provider name.""" + if not v.strip(): + raise ValueError("Provider name cannot be empty") + return v.strip() + + @field_validator("adapter_type") + @classmethod + def validate_adapter_type(cls, v: str) -> EnergyLoadHistoryProviderAdapter: + """Validate that adapter_type is a recognized EnergyLoadHistoryProviderAdapter.""" + adapter_values = [adapter.value for adapter in EnergyLoadHistoryProviderAdapter] + if v not in adapter_values: + raise ValueError(f"adapter_type must be one of {adapter_values}") + return EnergyLoadHistoryProviderAdapter(v) + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that external_service_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("external_service_id must be a valid UUID string") from exc + return v + + def to_model(self) -> EnergyLoadHistoryProvider: + """Convert schema to domain model.""" + configuration: Optional[EnergyLoadHistoryProviderConfig] = None + if self.config: + config_type = ENERGY_LOAD_HISTORY_PROVIDER_CONFIG_TYPE_MAP.get(self.adapter_type) + if config_type: + configuration = cast(EnergyLoadHistoryProviderConfig, config_type.from_dict(self.config)) + + return EnergyLoadHistoryProvider( + id=EntityId(uuid.uuid4()), + name=self.name, + adapter_type=self.adapter_type, + config=configuration, + external_service_id=EntityId(uuid.UUID(self.external_service_id)) if self.external_service_id else None, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + json_encoders = { + uuid.UUID: str, + EnergyLoadHistoryProviderAdapter: lambda v: v.value, + } + + +class EnergyLoadHistoryProviderUpdateSchema(BaseModel): + """Schema for updating an existing energy load history provider.""" + + name: str = Field(default="", description="Energy load history provider name") + config: Optional[dict] = Field(default=None, description="Energy load history provider configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate provider name.""" + if not v.strip(): + raise ValueError("Provider name cannot be empty") + return v.strip() + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that external_service_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("external_service_id must be a valid UUID string") from exc + return v + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + +class EnergyLoadHistoryProviderHomeAssistantAPIConfigSchema(BaseModel): + """Schema for HomeAssistantAPI EnergyLoadHistoryProviderConfig.""" + + entity_power: str = Field(default="", description="Home Assistant entity ID for power sensor") + unit_power: str = Field(default="W", description="Unit of power measurement") + + @field_validator("entity_power") + @classmethod + def validate_entity_power(cls, v: str) -> str: + """Validate entity_power is not empty.""" + if not v.strip(): + raise ValueError("entity_power cannot be empty") + return v.strip() + + def to_model(self) -> EnergyLoadHistoryProviderHomeAssistantAPIConfig: + """Convert schema to domain model.""" + return EnergyLoadHistoryProviderHomeAssistantAPIConfig( + entity_power=self.entity_power, unit_power=self.unit_power + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + +ENERGY_LOAD_HISTORY_PROVIDER_CONFIG_SCHEMA_MAP: Dict[ + type[EnergyLoadHistoryProviderConfig], + Union[type[EnergyLoadHistoryProviderHomeAssistantAPIConfigSchema]], +] = { + EnergyLoadHistoryProviderHomeAssistantAPIConfig: EnergyLoadHistoryProviderHomeAssistantAPIConfigSchema, +} + + +class HomeLoadPowerPointSchema(BaseModel): + """Schema for HomeLoadPowerPoint value object.""" + + timestamp: datetime = Field(..., description="Measurement timestamp") + power: float = Field(..., description="Power in watts") + + @classmethod + def from_model(cls, point: HomeLoadPowerPoint) -> "HomeLoadPowerPointSchema": + return cls( + timestamp=cast(datetime, point.timestamp), + power=float(point.power), + ) + + +class LoadConsumptionModelSchema(BaseModel): + """Schema for LoadConsumptionModel entity (without serialized model bytes).""" + + id: str = Field(..., description="Model unique identifier") + device_id: Optional[str] = Field(default=None, description="Device this model was trained for") + adapter_type: EnergyLoadForecastProviderAdapter = Field(..., description="ML adapter type") + trained_at: Optional[datetime] = Field(default=None, description="Training timestamp") + mae: Optional[float] = Field(default=None, description="Mean absolute error on holdout") + rmse: Optional[float] = Field(default=None, description="Root mean squared error on holdout") + samples_used: int = Field(default=0, description="Number of training samples") + is_active: bool = Field(default=False, description="Whether the model is currently active") + tuning_params: Optional[dict] = Field(default=None, description="Best hyperparameters from Optuna tuning") + backtest_mae: Optional[float] = Field(default=None, description="MAE from rolling-window backtesting") + backtest_rmse: Optional[float] = Field(default=None, description="RMSE from rolling-window backtesting") + backtest_folds: int = Field(default=0, description="Number of folds used in backtesting") + + @classmethod + def from_model(cls, model: LoadConsumptionModel) -> "LoadConsumptionModelSchema": + return cls( + id=str(model.id), + device_id=str(model.device_id) if model.device_id else None, + adapter_type=model.adapter_type, + trained_at=model.trained_at, + mae=model.mae, + rmse=model.rmse, + samples_used=model.samples_used, + is_active=model.is_active, + tuning_params=model.tuning_params, + backtest_mae=model.backtest_mae, + backtest_rmse=model.backtest_rmse, + backtest_folds=model.backtest_folds, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True diff --git a/core/edge_mining/adapters/domain/home_load/tables.py b/core/edge_mining/adapters/domain/home_load/tables.py new file mode 100644 index 0000000..4a5e15b --- /dev/null +++ b/core/edge_mining/adapters/domain/home_load/tables.py @@ -0,0 +1,454 @@ +"""SQLAlchemy ORM mappings for HomeLoad domain entities. + +This module implements imperative (classical) mapping of the domain entities +to database tables. The domain entities are mapped directly without +creating separate ORM model classes, maintaining domain purity. + +The mappings handle complex objects using SQLAlchemy event listeners and custom types: +- LoadDevice dictionaries are serialized to JSON and reconstructed after loading +- EnergyLoadForecastProviderConfig is serialized using custom ConfigurationType +- EntityId value objects are implicitly converted to/from strings + +All tables and mappings use the shared metadata and mapper registry from +the sqlalchemy.registry module, which are available as module-level singletons. + +WARNING - DEVELOPER WARNING +ANY SCHEMA CHANGE (adding/removing/modifying tables or columns) REQUIRES an +Alembic migration. Do NOT modify this file without creating a migration: + + python scripts/migrate.py create "Description of your change" + +For detailed instructions, see: ../docs/ALEMBIC_MIGRATIONS.md +For a step-by-step example, see: ../docs/MIGRATION_EXAMPLE.md +""" + +import json +import uuid +from typing import Any, Optional + +from sqlalchemy import ( + JSON, + Boolean, + Column, + DateTime, + Float, + ForeignKey, + Index, + Integer, + LargeBinary, + String, + Table, + Text, + TypeDecorator, + event, +) + +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.common import ConfigurationType +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.registry import mapper_registry, metadata +from edge_mining.domain.common import EntityId +from edge_mining.domain.home_load.aggregate_roots import HomeLoadsProfile +from edge_mining.domain.home_load.common import ( + EnergyLoadForecastProviderAdapter, + EnergyLoadHistoryProviderAdapter, + LoadDeviceCategory, +) +from edge_mining.domain.home_load.entities import ( + EnergyLoadForecastProvider, + EnergyLoadHistoryProvider, + LoadConsumptionModel, + LoadDevice, +) +from edge_mining.domain.home_load.exceptions import ( + EnergyLoadForecastProviderConfigurationError, + EnergyLoadHistoryProviderConfigurationError, +) +from edge_mining.shared.adapter_maps.home_load import ( + ENERGY_LOAD_FORECAST_PROVIDER_CONFIG_TYPE_MAP, + ENERGY_LOAD_HISTORY_PROVIDER_CONFIG_TYPE_MAP, +) +from edge_mining.shared.interfaces.config import EnergyLoadForecastProviderConfig, EnergyLoadHistoryProviderConfig + + +class EnergyLoadForecastProviderConfigType(ConfigurationType): + """SQLAlchemy type for EnergyLoadForecastProviderConfig serialization. + + Inherits from ConfigurationType to handle JSON serialization/deserialization. + """ + + +def _deserialize_energy_load_forecast_provider_config( + adapter_type: EnergyLoadForecastProviderAdapter, config_json: str +) -> Optional[EnergyLoadForecastProviderConfig]: + """Deserialize JSON string to EnergyLoadForecastProviderConfig based on adapter type.""" + if not config_json: + return None + + data: dict = json.loads(config_json) + + if adapter_type not in ENERGY_LOAD_FORECAST_PROVIDER_CONFIG_TYPE_MAP: + raise EnergyLoadForecastProviderConfigurationError( + f"Error reading EnergyLoadForecastProvider configuration. Invalid type '{adapter_type}'" + ) + + config_class: Optional[type[EnergyLoadForecastProviderConfig]] = ENERGY_LOAD_FORECAST_PROVIDER_CONFIG_TYPE_MAP.get( + adapter_type + ) + if not config_class: + raise EnergyLoadForecastProviderConfigurationError( + f"Error creating EnergyLoadForecastProvider configuration. Type '{adapter_type}'" + ) + + config_instance = config_class.from_dict(data) + if not isinstance(config_instance, EnergyLoadForecastProviderConfig): + raise EnergyLoadForecastProviderConfigurationError( + f"Deserialized config is not of type EnergyLoadForecastProviderConfig for adapter type {adapter_type}." + ) + return config_instance + + +@event.listens_for(EnergyLoadForecastProvider, "load") +def _receive_energy_load_forecast_provider_load(target: EnergyLoadForecastProvider, context) -> None: + """Event listener that deserializes config after loading from database.""" + # Convert id string to EntityId if needed + if hasattr(target, "id") and target.id is not None: + if isinstance(target.id, str): # type: ignore[arg-type,misc] + target.id = EntityId(uuid.UUID(target.id)) # type: ignore[assignment] + + # Convert foreign keys to EntityId + if hasattr(target, "external_service_id") and target.external_service_id is not None: + if isinstance(target.external_service_id, str): # type: ignore + target.external_service_id = EntityId(uuid.UUID(target.external_service_id)) # type: ignore + + # Convert adapter_type string to enum if needed + if isinstance(target.adapter_type, str): + try: + target.adapter_type = EnergyLoadForecastProviderAdapter(target.adapter_type) + except ValueError: + pass + + if target.config and isinstance(target.config, str): + target.config = _deserialize_energy_load_forecast_provider_config(target.adapter_type, target.config) + + +@event.listens_for(EnergyLoadForecastProvider, "before_insert") +@event.listens_for(EnergyLoadForecastProvider, "before_update") +def _flatten_energy_load_forecast_provider_composites(mapper, connection, target: Any) -> None: + """Convert enum attributes to primitive values before persisting.""" + if hasattr(target, "adapter_type") and target.adapter_type is not None: + if isinstance(target.adapter_type, EnergyLoadForecastProviderAdapter): + target.adapter_type = target.adapter_type.value + + +@event.listens_for(EnergyLoadForecastProvider, "after_insert") +@event.listens_for(EnergyLoadForecastProvider, "after_update") +def _restore_energy_load_forecast_provider_composites(mapper, connection, target: Any) -> None: + """Restore enum attributes after persist operations.""" + if hasattr(target, "adapter_type") and target.adapter_type is not None: + if isinstance(target.adapter_type, str): + try: + target.adapter_type = EnergyLoadForecastProviderAdapter(target.adapter_type) + except ValueError: + pass + + +# Define the energy_load_forecast_providers table using imperative style +energy_load_forecast_providers_table = Table( + "energy_load_forecast_providers", + metadata, + Column("id", String, primary_key=True, index=True), + Column("name", String, nullable=False), + Column("adapter_type", String, nullable=False), + Column("config", EnergyLoadForecastProviderConfigType, nullable=True), + Column("external_service_id", String, ForeignKey("external_services.id"), nullable=True), +) + +# Map EnergyLoadForecastProvider +mapper_registry.map_imperatively( + EnergyLoadForecastProvider, + energy_load_forecast_providers_table, +) + + +# Custom TypeDecorator for LoadDevice list serialization +class LoadDevicesDictType(TypeDecorator): + """Custom type for serializing List[LoadDevice] to a JSON array.""" + + impl = JSON + cache_ok = True + + def process_bind_param(self, value, dialect): + """Convert List[LoadDevice] to a JSON list for database storage.""" + if value is None: + return None + return [ + { + "id": str(device.id), + "name": device.name, + "category": device.category.value, + "enabled": device.enabled, + "energy_load_forecast_provider_id": ( + str(device.energy_load_forecast_provider_id) if device.energy_load_forecast_provider_id else None + ), + "energy_load_history_provider_id": ( + str(device.energy_load_history_provider_id) if device.energy_load_history_provider_id else None + ), + } + for device in value + ] + + def process_result_value(self, value, dialect): + """Return raw JSON list - will be reconstructed in event listener.""" + return value + + +# HomeLoadsProfile table +home_profiles_table = Table( + "home_profiles", + metadata, + Column("id", String, primary_key=True), + Column("name", String, nullable=False), + Column("devices_json", LoadDevicesDictType, nullable=True), +) + + +# Event listener to reconstruct LoadDevice objects after loading +@event.listens_for(HomeLoadsProfile, "load") +def _receive_home_profile_load(target, context): + """Reconstruct LoadDevice objects from JSON after loading from database.""" + if isinstance(target.id, str): + target.id = EntityId(uuid.UUID(target.id)) + + if target.devices and isinstance(target.devices, list): + reconstructed: list = [] + for device_data in target.devices: + if not isinstance(device_data, dict): + continue + forecast_id = device_data.get("energy_load_forecast_provider_id") + history_id = device_data.get("energy_load_history_provider_id") + reconstructed.append( + LoadDevice( + id=EntityId(uuid.UUID(device_data["id"])), + name=device_data["name"], + category=LoadDeviceCategory(device_data["category"]), + enabled=bool(device_data.get("enabled", True)), + energy_load_forecast_provider_id=(EntityId(uuid.UUID(forecast_id)) if forecast_id else None), + energy_load_history_provider_id=(EntityId(uuid.UUID(history_id)) if history_id else None), + ) + ) + target.devices = reconstructed + elif target.devices is None: + target.devices = [] + + +# Map HomeLoadsProfile aggregate root to table +mapper_registry.map_imperatively( + HomeLoadsProfile, + home_profiles_table, + properties={ + "id": home_profiles_table.c.id, + "name": home_profiles_table.c.name, + "devices": home_profiles_table.c.devices_json, + }, +) + + +# --- EnergyLoadHistoryProvider table + mapping --- + + +class EnergyLoadHistoryProviderConfigType(ConfigurationType): + """SQLAlchemy type for EnergyLoadHistoryProviderConfig serialization.""" + + +def _deserialize_energy_load_history_provider_config( + adapter_type: EnergyLoadHistoryProviderAdapter, config_json: str +) -> Optional[EnergyLoadHistoryProviderConfig]: + """Deserialize JSON string to EnergyLoadHistoryProviderConfig based on adapter type.""" + if not config_json: + return None + + data: dict = json.loads(config_json) + + if adapter_type not in ENERGY_LOAD_HISTORY_PROVIDER_CONFIG_TYPE_MAP: + raise EnergyLoadHistoryProviderConfigurationError( + f"Error reading EnergyLoadHistoryProvider configuration. Invalid type '{adapter_type}'" + ) + + config_class: Optional[type[EnergyLoadHistoryProviderConfig]] = ENERGY_LOAD_HISTORY_PROVIDER_CONFIG_TYPE_MAP.get( + adapter_type + ) + if not config_class: + # Some adapters (e.g. DUMMY) have no config + return None + + config_instance = config_class.from_dict(data) + if not isinstance(config_instance, EnergyLoadHistoryProviderConfig): + raise EnergyLoadHistoryProviderConfigurationError( + f"Deserialized config is not of type EnergyLoadHistoryProviderConfig for adapter type {adapter_type}." + ) + return config_instance + + +@event.listens_for(EnergyLoadHistoryProvider, "load") +def _receive_energy_load_history_provider_load(target: EnergyLoadHistoryProvider, context) -> None: + """Event listener that deserializes config after loading from database.""" + if hasattr(target, "id") and target.id is not None: + if isinstance(target.id, str): # type: ignore[arg-type,misc] + target.id = EntityId(uuid.UUID(target.id)) # type: ignore[assignment] + + if hasattr(target, "external_service_id") and target.external_service_id is not None: + if isinstance(target.external_service_id, str): # type: ignore + target.external_service_id = EntityId(uuid.UUID(target.external_service_id)) # type: ignore + + if isinstance(target.adapter_type, str): + try: + target.adapter_type = EnergyLoadHistoryProviderAdapter(target.adapter_type) + except ValueError: + pass + + if target.config and isinstance(target.config, str): + target.config = _deserialize_energy_load_history_provider_config(target.adapter_type, target.config) + + +@event.listens_for(EnergyLoadHistoryProvider, "before_insert") +@event.listens_for(EnergyLoadHistoryProvider, "before_update") +def _flatten_energy_load_history_provider_composites(mapper, connection, target: Any) -> None: + """Convert enum attributes to primitive values before persisting.""" + if hasattr(target, "adapter_type") and target.adapter_type is not None: + if isinstance(target.adapter_type, EnergyLoadHistoryProviderAdapter): + target.adapter_type = target.adapter_type.value + + +@event.listens_for(EnergyLoadHistoryProvider, "after_insert") +@event.listens_for(EnergyLoadHistoryProvider, "after_update") +def _restore_energy_load_history_provider_composites(mapper, connection, target: Any) -> None: + """Restore enum attributes after persist operations.""" + if hasattr(target, "adapter_type") and target.adapter_type is not None: + if isinstance(target.adapter_type, str): + try: + target.adapter_type = EnergyLoadHistoryProviderAdapter(target.adapter_type) + except ValueError: + pass + + +energy_load_history_providers_table = Table( + "energy_load_history_providers", + metadata, + Column("id", String, primary_key=True, index=True), + Column("name", String, nullable=False), + Column("adapter_type", String, nullable=False), + Column("config", EnergyLoadHistoryProviderConfigType, nullable=True), + Column("external_service_id", String, ForeignKey("external_services.id"), nullable=True), +) + +mapper_registry.map_imperatively( + EnergyLoadHistoryProvider, + energy_load_history_providers_table, +) + + +# HomeLoadPowerPoint table (device-scoped time series). +# +# Not imperatively mapped: HomeLoadPowerPoint is a Value Object (frozen +# dataclass) and the SQLAlchemy repository interacts with this table via +# Core (insert/select statements) to keep the domain model pure. +# +# Composite primary key (device_id, timestamp) yields: +# - natural uniqueness per device over time +# - idempotent ingestion (re-fetching the same HA window is a no-op) +# - clustered index on (device_id, timestamp) for O(log n) range scans +home_load_power_points_table = Table( + "home_load_power_points", + metadata, + Column("device_id", String, nullable=False, primary_key=True), + Column("timestamp", DateTime(timezone=True), nullable=False, primary_key=True), + Column("power", Float, nullable=False), + Index("ix_home_load_power_points_device_ts", "device_id", "timestamp"), +) + + +# --- LoadConsumptionModel table + mapping --- +# +# Stores trained ML models (Holt-Winters, XGBoost, etc.) with serialized +# weights in `model_bytes` (LargeBinary / BLOB). The `is_active` flag +# designates the currently promoted model per (adapter_type, device_id) +# combination. + +load_consumption_models_table = Table( + "load_consumption_models", + metadata, + Column("id", String, primary_key=True, index=True), + Column("device_id", String, nullable=True), + Column("adapter_type", String, nullable=False), + Column("trained_at", DateTime(timezone=True), nullable=True), + Column("mae", Float, nullable=True), + Column("rmse", Float, nullable=True), + Column("samples_used", Integer, nullable=False, default=0), + Column("is_active", Boolean, nullable=False, default=False), + Column("model_bytes", LargeBinary, nullable=True), + Column("tuning_params", Text, nullable=True), + Column("backtest_mae", Float, nullable=True), + Column("backtest_rmse", Float, nullable=True), + Column("backtest_folds", Integer, nullable=False, default=0), + Index("ix_load_consumption_models_active", "adapter_type", "device_id", "is_active"), +) + + +@event.listens_for(LoadConsumptionModel, "load") +def _receive_load_consumption_model_load(target: LoadConsumptionModel, context) -> None: + """Reconstruct domain types after loading from database.""" + if hasattr(target, "id") and target.id is not None: + if isinstance(target.id, str): + target.id = EntityId(uuid.UUID(target.id)) + + if hasattr(target, "device_id") and target.device_id is not None: + if isinstance(target.device_id, str): + target.device_id = EntityId(uuid.UUID(target.device_id)) + + if isinstance(target.adapter_type, str): + try: + target.adapter_type = EnergyLoadForecastProviderAdapter(target.adapter_type) + except ValueError: + pass + + if hasattr(target, "tuning_params") and isinstance(target.tuning_params, str): + try: + target.tuning_params = json.loads(target.tuning_params) + except (json.JSONDecodeError, TypeError): + target.tuning_params = None + + +@event.listens_for(LoadConsumptionModel, "before_insert") +@event.listens_for(LoadConsumptionModel, "before_update") +def _flatten_load_consumption_model_composites(mapper, connection, target: Any) -> None: + """Convert enum attributes to primitive values before persisting.""" + if hasattr(target, "adapter_type") and target.adapter_type is not None: + if isinstance(target.adapter_type, EnergyLoadForecastProviderAdapter): + target.adapter_type = target.adapter_type.value + + if hasattr(target, "tuning_params") and target.tuning_params is not None: + if isinstance(target.tuning_params, dict): + target.tuning_params = json.dumps(target.tuning_params) + + +@event.listens_for(LoadConsumptionModel, "after_insert") +@event.listens_for(LoadConsumptionModel, "after_update") +def _restore_load_consumption_model_composites(mapper, connection, target: Any) -> None: + """Restore enum attributes after persist operations.""" + if hasattr(target, "adapter_type") and target.adapter_type is not None: + if isinstance(target.adapter_type, str): + try: + target.adapter_type = EnergyLoadForecastProviderAdapter(target.adapter_type) + except ValueError: + pass + + if hasattr(target, "tuning_params") and isinstance(target.tuning_params, str): + try: + target.tuning_params = json.loads(target.tuning_params) + except (json.JSONDecodeError, TypeError): + target.tuning_params = None + + +mapper_registry.map_imperatively( + LoadConsumptionModel, + load_consumption_models_table, +) diff --git a/core/edge_mining/adapters/domain/miner/__init__.py b/core/edge_mining/adapters/domain/miner/__init__.py new file mode 100644 index 0000000..4f4aa14 --- /dev/null +++ b/core/edge_mining/adapters/domain/miner/__init__.py @@ -0,0 +1 @@ +"""Adapters API for the Miner domain.""" diff --git a/core/edge_mining/adapters/domain/miner/cli/__init__.py b/core/edge_mining/adapters/domain/miner/cli/__init__.py new file mode 100644 index 0000000..fcc7ca5 --- /dev/null +++ b/core/edge_mining/adapters/domain/miner/cli/__init__.py @@ -0,0 +1 @@ +"""Adapters CLI for the Miner domain.""" diff --git a/core/edge_mining/adapters/domain/miner/cli/commands.py b/core/edge_mining/adapters/domain/miner/cli/commands.py new file mode 100644 index 0000000..8b06727 --- /dev/null +++ b/core/edge_mining/adapters/domain/miner/cli/commands.py @@ -0,0 +1,1303 @@ +"""CLI commands for the Miner domain.""" + +from typing import List, Optional, Union, cast + +import click + +from edge_mining.adapters.infrastructure.cli.utils import print_configuration, process_filters +from edge_mining.adapters.infrastructure.external_services.cli.commands import ( + handle_add_external_service, + print_external_service_details, + select_external_service, +) +from edge_mining.adapters.utils import run_async_func +from edge_mining.application.interfaces import ConfigurationServiceInterface, MinerActionServiceInterface +from edge_mining.domain.common import EntityId, Watts +from edge_mining.domain.miner.common import MinerControllerAdapter, MinerControllerProtocol +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.entities import MinerController +from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.shared.adapter_configs.miner import ( + MinerControllerDummyConfig, + MinerControllerGenericSocketHomeAssistantAPIConfig, + MinerControllerPyASICConfig, +) +from edge_mining.shared.adapter_maps.miner import MINER_CONTROLLER_TYPE_EXTERNAL_SERVICE_MAP +from edge_mining.shared.external_services.entities import ExternalService +from edge_mining.shared.interfaces.config import MinerControllerConfig +from edge_mining.shared.logging.port import LoggerPort + + +def handle_add_miner(configuration_service: ConfigurationServiceInterface, logger: LoggerPort) -> None: + """Menu to add a new miner.""" + click.echo(click.style("\n--- Add Miner ---", fg="yellow")) + name: str = click.prompt("Name of the miner", type=str) + model: str = click.prompt("Model of the miner (optional, press Enter to skip)", type=str, default="") + hash_rate_max: float = click.prompt("Max HashRate (eg. 100.0)", type=float, default=100.0) + hash_rate_unit: str = click.prompt("HashRate unit (eg. TH/s, GH/s)", type=str, default="TH/s") + power_consumption_max: float = click.prompt("Max power consumption (Watt, eg. 3200.0)", type=float, default=3200.0) + + new_miner = Miner() + new_miner.name = name + new_miner.model = model if model else None + new_miner.hash_rate_max = HashRate(value=hash_rate_max, unit=hash_rate_unit) + new_miner.power_consumption_max = Watts(power_consumption_max) + + # Select a Miner Controller (will be linked after creation) + miner_controller: Optional[MinerController] = None + + miner_controllers = configuration_service.list_miner_controllers() + if miner_controllers: + miner_controller = select_miner_controller(configuration_service, logger) + else: + click.echo("") + click.echo(click.style("No Miner Controller configured.", fg="yellow")) + + add_miner_controller: bool = click.confirm( + "Do you want to add a Miner Controller now?", + default=True, + abort=False, + ) + + if add_miner_controller: + miner_controller = handle_add_miner_controller( + miner=new_miner, + configuration_service=configuration_service, + logger=logger, + ) + if miner_controller: + click.echo( + click.style( + f"Miner Controller '{miner_controller.name}', " + f"Type: {miner_controller.adapter_type.name} " + f"(ID: {miner_controller.id}) successfully added.", + fg="green", + ) + ) + else: + click.echo( + click.style( + "No miner controller configured for this miner.", + fg="yellow", + ) + ) + + try: + added = run_async_func( + configuration_service.add_miner( + name=new_miner.name, + model=new_miner.model, + hash_rate_max=new_miner.hash_rate_max, + power_consumption_max=new_miner.power_consumption_max, + ) + ) + + # Link controller if selected + if miner_controller: + run_async_func(configuration_service.set_miner_controller(miner_controller.id, added.id)) + + click.echo( + click.style( + f"Miner '{added.name}' (ID: {added.id}) successfully added.", + fg="green", + ) + ) + except Exception as e: + logger.error(f"Error adding miner: {e}") + click.echo(click.style(f"Error adding miner: {e}", fg="red"), err=True) + click.pause("Press any key to return to the menu...") + + +def list_miners(configuration_service: ConfigurationServiceInterface): + """List all configured miners.""" + miners = configuration_service.list_miners() + if not miners: + click.echo(click.style("No miner configured.", fg="yellow")) + else: + for m in miners: + hashrate_str = f"{m.hash_rate_max.value} {m.hash_rate_max.unit}" if m.hash_rate_max else "N/A" + model_str = f"{m.model}" if m.model else "N/A" + click.echo( + "-> " + + "Name: " + + click.style(f"{m.name}, ", fg="blue") + + "Model: " + + click.style(f"{model_str}, ", fg="white") + + "ID: " + + click.style(f"{m.id}, ", fg="yellow") + + "Max Power: " + + click.style(f"{m.power_consumption_max}W, ", fg="cyan") + + "Max HashRate: " + + click.style( + f"{hashrate_str}, ", + fg="magenta", + ) + + "Active: " + + click.style(f"{m.active}", fg="green" if m.active else "red") + ) + click.echo("") + + +def handle_list_miners(configuration_service: ConfigurationServiceInterface, logger: LoggerPort): + """Handle List all configured miners.""" + click.echo(click.style("\n--- Configured Miner ---", fg="yellow")) + + list_miners(configuration_service) + + click.pause("Press any key to return to the menu...") + + +def select_miner( + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, + default_id: Optional[EntityId] = None, + allow_multiple: bool = False, + only_ids: Optional[List[EntityId]] = None, + exclude_ids: Optional[List[EntityId]] = None, +) -> Union[Optional[Miner], List[Miner]]: + """Select one ore more miners from the list.""" + + miners = configuration_service.list_miners() + if not miners: + click.echo(click.style("No miner configured.", fg="yellow")) + return None + else: + if only_ids: + miners = [m for m in miners if m.id in only_ids] + if exclude_ids: + miners = [m for m in miners if m.id not in exclude_ids] + if not miners: + click.echo(click.style("No miner available after applying filters.", fg="yellow")) + return None + elif len(miners) == 1: + allow_multiple = False + + if allow_multiple: + click.echo(click.style("\n--- Select Miners ---", fg="yellow")) + else: + click.echo(click.style("\n--- Select Miner ---", fg="yellow")) + + default_idx = "" + for idx, m in enumerate(miners): + hashrate_str = f"{m.hash_rate_max.value} {m.hash_rate_max.unit}" if m.hash_rate_max else "N/A" + model_str = f"{m.model}" if m.model else "N/A" + click.echo( + f"{idx}. " + + "Name: " + + click.style(f"{m.name}, ", fg="blue") + + "Model: " + + click.style(f"{model_str}, ", fg="white") + + "ID: " + + click.style(f"{m.id}, ", fg="yellow") + + "Max Power: " + + click.style(f"{m.power_consumption_max}W, ", fg="cyan") + + "Max HashRate: " + + click.style(f"{hashrate_str}, ", fg="magenta") + + "Active: " + + click.style(f"{m.active}", fg="green" if m.active else "red") + ) + + if default_id: + if m.id == default_id: + default_idx = str(idx) + + click.echo("\nb. Back to menu\n") + + if allow_multiple: + miner_indices: str = click.prompt( + "Choose Miner indices (comma-separated, e.g., 0,2,3)", type=str, default=default_idx + ) + miner_indices = miner_indices.strip().lower() + if miner_indices == "b": + return None + + # Parse comma-separated indices + selected_miners = [] + try: + indices = [idx.strip() for idx in miner_indices.split(",")] + for idx_str in indices: + if not idx_str.isdigit(): + click.echo(click.style(f"Invalid index '{idx_str}'. Skipping.", fg="yellow")) + continue + + idx = int(idx_str) + if idx < 0 or idx >= len(miners): + click.echo(click.style(f"Index {idx} out of range. Skipping.", fg="yellow")) + continue + + selected_miners.append(miners[idx]) + + if not selected_miners: + click.echo(click.style("No valid miners selected. Aborting selection.", fg="red")) + return None + + return selected_miners + + except Exception as e: + click.echo(click.style(f"Error parsing indices: {e}. Aborting selection.", fg="red")) + return None + else: + miner_idx: str = click.prompt("Choose a Miner index", type=str, default=default_idx) + miner_idx = miner_idx.strip().lower() + if miner_idx == "b": + return None + + if not miner_idx.isdigit() or int(miner_idx) < 0 or int(miner_idx) >= len(miners): + click.echo(click.style("Invalid index. Aborting selection.", fg="red")) + return None + + selected_miner = miners[int(miner_idx)] + return selected_miner + + +def update_single_miner( + selected_miner: Miner, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> Optional[Miner]: + """Menu to update a miner's details.""" + name: str = click.prompt("New name of the miner", type=str, default=selected_miner.name) + model: str = click.prompt( + "Model of the miner (optional, press Enter to skip)", + type=str, + default=selected_miner.model if selected_miner.model else "", + ) + hash_rate: float = click.prompt( + "Max HashRate (eg. 100.0)", + type=float, + default=selected_miner.hash_rate_max.value if selected_miner.hash_rate_max else 100.0, + ) + hash_rate_unit: str = click.prompt( + "HashRate unit (eg. TH/s, GH/s)", + type=str, + default=selected_miner.hash_rate_max.unit if selected_miner.hash_rate_max else "", + ) + power_consumption: float = click.prompt( + "Max power consumption (Watt, eg. 3200.0)", + type=float, + default=selected_miner.power_consumption_max, + ) + + # Select a Miner Controller + miner_controllers = configuration_service.list_miner_controllers() + if miner_controllers: + miner_controller = select_miner_controller(configuration_service, logger) + if miner_controller: + click.echo(click.style(f"Controller '{miner_controller.name}' will be linked after update.", fg="yellow")) + else: + click.echo(click.style("Miner Controller will not be changed!", fg="yellow")) + + hash_rate_max = HashRate(value=hash_rate, unit=hash_rate_unit) + + try: + updated = run_async_func( + configuration_service.update_miner( + miner_id=selected_miner.id, + name=name, + model=model if model else None, + hash_rate_max=hash_rate_max, + power_consumption_max=Watts(power_consumption), + ) + ) + + # If a new controller was selected, link it + if miner_controllers and miner_controller: + run_async_func(configuration_service.set_miner_controller(miner_controller.id, updated.id)) + # Re-read miner to get updated features + updated = configuration_service.get_miner(updated.id) or updated + + click.echo( + click.style( + f"Miner '{updated.name}' (ID: {updated.id}) successfully updated.", + fg="green", + ) + ) + except Exception as e: + logger.error(f"Error updating miner: {e}") + click.echo(click.style(f"Error updating miner: {e}", fg="red"), err=True) + updated = None + + click.pause("Press any key to return to the menu...") + + return updated + + +def delete_single_miner( + selected_miner: Miner, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> bool: + """Delete a specific Miner.""" + delete_confirm = click.confirm( + f"Are you sure you want to remove the Miner '{selected_miner.name}' (ID: {selected_miner.id})?", + abort=False, + default=False, + prompt_suffix="", + ) + if not delete_confirm: + click.echo(click.style("Deletion cancelled.", fg="yellow")) + return False + try: + removed_miner = run_async_func(configuration_service.remove_miner(miner_id=selected_miner.id)) + logger.info(f"Miner '{removed_miner.name}' (ID: {removed_miner.id}) successfully removed.") + click.echo( + click.style( + f"Miner '{removed_miner.name}' (ID: {removed_miner.id}) successfully removed.", + fg="green", + ) + ) + except Exception as e: + logger.error(f"Error removing miner: {e}") + click.echo(click.style(f"Error removing miner: {e}", fg="red"), err=True) + return False + else: + return True + + +def assign_controller_to_miner( + selected_miner: Miner, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> Optional[Miner]: + """Assign a controller to a miner.""" + click.echo(click.style("\n--- Assign Controller to Miner ---", fg="yellow")) + + controller = select_miner_controller(configuration_service, logger) + + if controller is None: + click.echo(click.style("No controller selected. Aborting.", fg="red")) + return None + + try: + run_async_func(configuration_service.set_miner_controller(controller.id, selected_miner.id)) + + # Re-read miner to get updated features + updated_miner = configuration_service.get_miner(selected_miner.id) + if not updated_miner: + click.echo(click.style("Error: could not re-read miner after linking controller.", fg="red")) + return None + + click.echo( + click.style( + f"Controller Miner '{controller.name}' successfully assigned " + f"to miner '{updated_miner.name}' (ID: {updated_miner.id}).", + fg="green", + ) + ) + except Exception as e: + logger.error(f"Error assigning controller to miner: {e}") + click.echo( + click.style(f"Error assigning controller to miner: {e}", fg="red"), + err=True, + ) + return None + + return updated_miner + + +def start_miner( + miner: Miner, + configuration_service: ConfigurationServiceInterface, + miner_action_service: MinerActionServiceInterface, +) -> Miner: + """Start a miner.""" + try: + status = run_async_func(miner_action_service.start_miner(miner_id=miner.id, notifiers=[])) + if status: + click.echo( + click.style( + f"Miner '{miner.name}' (ID: {miner.id}) started successfully.", + fg="green", + ) + ) + else: + click.echo( + click.style( + f"Failed to start Miner '{miner.name}' (ID: {miner.id}).", + fg="red", + ) + ) + except Exception as e: + click.echo(click.style(f"Error starting miner: {e}", fg="red"), err=True) + + updated_miner = configuration_service.get_miner(miner.id) + return updated_miner or miner + + +def stop_miner( + miner: Miner, + configuration_service: ConfigurationServiceInterface, + miner_action_service: MinerActionServiceInterface, +) -> Miner: + """stop a miner.""" + try: + status = run_async_func(miner_action_service.stop_miner(miner_id=miner.id, notifiers=[])) + if status: + click.echo( + click.style( + f"Miner '{miner.name}' (ID: {miner.id}) stopped successfully.", + fg="green", + ) + ) + else: + click.echo( + click.style( + f"Failed to stop Miner '{miner.name}' (ID: {miner.id}).", + fg="red", + ) + ) + except Exception as e: + click.echo(click.style(f"Error stopping miner: {e}", fg="red"), err=True) + + updated_miner = configuration_service.get_miner(miner.id) + return updated_miner or miner + + +def get_miner_status( + miner: Miner, + configuration_service: ConfigurationServiceInterface, + miner_action_service: MinerActionServiceInterface, +) -> Miner: + """Get the status of a miner.""" + try: + _ = run_async_func(miner_action_service.get_miner_status(miner_id=miner.id)) + updated_miner = configuration_service.get_miner(miner.id) + miner = updated_miner or miner + except Exception as e: + click.echo(click.style(f"Error getting miner status: {e}", fg="red"), err=True) + return miner + + +def handle_manage_miner( + configuration_service: ConfigurationServiceInterface, + miner_action_service: MinerActionServiceInterface, + logger: LoggerPort, +) -> str: + """Menu to manage a miner.""" + selected_miner = select_miner(configuration_service, logger) + + if selected_miner is None: + click.echo(click.style("No miner selected. Aborting.", fg="red")) + return "b" + + if isinstance(selected_miner, list): + selected_miner = selected_miner[0] # Just pick the first one for management + + choice = manage_single_miner_menu( + miner=selected_miner, + configuration_service=configuration_service, + miner_action_service=miner_action_service, + logger=logger, + ) + + return choice + + +def print_miner_details( + miner: Miner, + configuration_service: ConfigurationServiceInterface, + show_controller_details: bool = True, + show_external_service: bool = True, +) -> None: + """Print details of a selected miner.""" + click.echo("") + click.echo("| Name: " + click.style(miner.name, fg="blue")) + click.echo("| ID: " + click.style(miner.id, fg="yellow")) + click.echo( + "| Max HashRate: " + str(miner.hash_rate_max.value) + if miner.hash_rate_max + else "N/A" + " " + miner.hash_rate_max.unit + if miner.hash_rate_max + else "N/A" + ) + click.echo("| Max Power Consumption: " + str(miner.power_consumption_max) + " W") + click.echo("| Active: " + click.style(miner.active, fg="green" if miner.active else "red")) + + controller_ids = miner.get_controller_ids() + click.echo("| Controllers: " + (str(len(controller_ids)) if controller_ids else "None")) + + if show_controller_details and controller_ids: + for cid in controller_ids: + controller = configuration_service.get_miner_controller(cid) + if controller: + click.echo(f"\nCONTROLLER DETAILS (ID: {cid}):") + print_miner_controller_details( + controller=controller, + configuration_service=configuration_service, + show_miner_list=False, + show_external_service=show_external_service, + ) + else: + click.echo("| Controller ID: " + click.style(str(cid), fg="red") + " (not found)") + + if miner.features: + click.echo("\nFEATURES:") + for f in miner.features: + status = click.style("enabled", fg="green") if f.enabled else click.style("disabled", fg="red") + click.echo(f" - {f.feature_type.value} (controller: {f.controller_id}, priority: {f.priority}, {status})") + + click.echo("") + + +def manage_single_miner_menu( + miner: Miner, + configuration_service: ConfigurationServiceInterface, + miner_action_service: MinerActionServiceInterface, + logger: LoggerPort, +) -> str: + """Menu for managing a specific Miner.""" + while True: + click.echo("\n" + click.style("--- MANAGE MINER ---", fg="blue", bold=True)) + + print_miner_details(miner, configuration_service) + + click.echo("1. Activate Miner") + click.echo("2. Deactivate Miner") + click.echo("3. Update Miner") + click.echo("4. Set Miner Controller") + click.echo("5. Delete Miner") + click.echo("") + + if miner.get_controller_ids(): + click.echo("6. Start Miner") + click.echo("7. Stop Miner") + click.echo("8. Get Miner Status") + click.echo("") + + click.echo("b. Back to miner menu") + click.echo("q. Close application") + click.echo("-----------------") + + choice: str = click.prompt("Choose an option", type=str) + choice = choice.strip().lower() + + click.clear() + + if choice == "1": + try: + miner = run_async_func(configuration_service.activate_miner(miner.id)) + logger.info(f"Miner {miner.name} activated successfully.") + except Exception as e: + logger.error(f"Error activating miner: {e}") + click.echo( + click.style(f"Error activating miner: {e}", fg="red"), + err=True, + ) + continue + + elif choice == "2": + try: + miner = run_async_func(configuration_service.deactivate_miner(miner.id)) + logger.info(f"Miner {miner.name} deactivated successfully.") + except Exception as e: + logger.error(f"Error deactivating miner: {e}") + click.echo( + click.style(f"Error deactivating miner: {e}", fg="red"), + err=True, + ) + continue + + elif choice == "3": + updated_miner = update_single_miner( + selected_miner=miner, + configuration_service=configuration_service, + logger=logger, + ) + miner = updated_miner or miner # Update miner if it was successfully updated + continue + + elif choice == "4": + updated_miner = assign_controller_to_miner( + selected_miner=miner, + configuration_service=configuration_service, + logger=logger, + ) + miner = updated_miner or miner # Update miner if it was successfully updated + continue + + elif choice == "5": + delete_status = delete_single_miner( + selected_miner=miner, + configuration_service=configuration_service, + logger=logger, + ) + if delete_status: + return "b" # Return to menu if deletion was successful + + elif choice == "6" and miner.get_controller_ids(): + miner = start_miner( + miner=miner, + configuration_service=configuration_service, + miner_action_service=miner_action_service, + ) + continue + elif choice == "7" and miner.get_controller_ids(): + miner = stop_miner( + miner=miner, + configuration_service=configuration_service, + miner_action_service=miner_action_service, + ) + continue + elif choice == "8" and miner.get_controller_ids(): + updated_miner = get_miner_status( + miner=miner, + configuration_service=configuration_service, + miner_action_service=miner_action_service, + ) + miner = updated_miner or miner + continue + + elif choice == "b": + break + + elif choice == "q": + break + + return choice + + +def select_miner_controller_type() -> Optional[MinerControllerAdapter]: + """Select a miner controller type from the list.""" + click.echo("Select a Miner Controller Type:") + for idx, controller_type in enumerate(MinerControllerAdapter): + click.echo(f"{idx}. {controller_type.name}") + + click.echo("") + choice: str = click.prompt("Choose a controller", type=str) + choice = choice.strip().lower() + + if not choice.isdigit() or int(choice) < 0 or int(choice) >= len(MinerControllerAdapter): + click.echo(click.style("Invalid index. Aborting selection.", fg="red")) + return None + + controller_type_values = [controller_type.value for controller_type in MinerControllerAdapter] + + selected_type = MinerControllerAdapter(controller_type_values[int(choice)]) + return selected_type + + +def handle_miner_controller_dummy_config( + miner: Optional[Miner], + current_config: Optional[MinerControllerConfig] = None, +) -> MinerControllerConfig: + """Handle configuration for the Dummy Miner Controller.""" + click.echo(click.style("\n--- Dummy Miner Controller Configuration ---", fg="yellow")) + + # Defaults from miner if available or hardcoded values + default_power = miner.power_consumption_max if miner else 3200.0 + default_hash_rate = miner.hash_rate_max.value if miner and miner.hash_rate_max else 90.0 + default_hash_rate_unit = miner.hash_rate_max.unit if miner and miner.hash_rate_max else "TH/s" + + # Try to get defaults from current_config + if current_config and current_config.is_valid(MinerControllerAdapter.DUMMY): + config: MinerControllerDummyConfig = cast(MinerControllerDummyConfig, current_config) + + default_power = config.power_max + default_hash_rate = config.hashrate_max.value + default_hash_rate_unit = config.hashrate_max.unit + + power_max: float = click.prompt( + "Max power consumption (Watt, eg. 3200.0)", + type=float, + default=default_power, + ) + hash_rate_max_value: float = click.prompt("Max HashRate value (eg. 90.0)", type=float, default=default_hash_rate) + hash_rate_max_unit: str = click.prompt( + "Max HashRate unit (eg. TH/s)", + type=str, + default=default_hash_rate_unit, + ) + + return MinerControllerDummyConfig( + power_max=power_max, + hashrate_max=HashRate(value=hash_rate_max_value, unit=hash_rate_max_unit), + ) + + +def handle_miner_controller_generic_socket_home_assistant_api_config( + miner: Optional[Miner], + current_config: Optional[MinerControllerConfig] = None, +) -> MinerControllerConfig: + """Handle configuration for the Generic Socket Home Assistant API Miner Controller.""" + click.echo(click.style("\n--- Generic Socket Home Assistant API Miner Controller Configuration ---", fg="yellow")) + + # Default values from hardcoded values + default_entity_switch = "switch.miner_socket" + default_entity_power = "sensor.miner_power" + default_unit_power = "W" + + # Try to get defaults from current_config + if current_config and current_config.is_valid(MinerControllerAdapter.GENERIC_SOCKET_HOME_ASSISTANT_API): + config: MinerControllerGenericSocketHomeAssistantAPIConfig = cast( + MinerControllerGenericSocketHomeAssistantAPIConfig, current_config + ) + default_entity_switch = config.entity_switch + default_entity_power = config.entity_power + default_unit_power = config.unit_power + + entity_switch: str = click.prompt( + "Entity ID for the switch (eg. switch.miner_socket)", + type=str, + default=default_entity_switch, + ) + entity_power: str = click.prompt( + "Entity ID for the power sensor (eg. sensor.miner_power)", + type=str, + default=default_entity_power, + ) + unit_power: str = click.prompt( + "Unit of power measurement (eg. W, kW)", + type=str, + default=default_unit_power, + ) + + return MinerControllerGenericSocketHomeAssistantAPIConfig( + entity_switch=entity_switch, + entity_power=entity_power, + unit_power=unit_power, + ) + + +def handle_miner_controller_pyasic_config( + miner: Optional[Miner], current_config: Optional[MinerControllerConfig] = None +) -> MinerControllerConfig: + """Handle configuration for the PyASIC Miner Controller.""" + click.echo(click.style("\n--- PyASIC Miner Controller Configuration ---", fg="yellow")) + + # Default values from hardcoded values + default_ip = "192.168.1.100" + default_port: Optional[int] = None + default_username: Optional[str] = None + default_password: Optional[str] = None + default_protocol: Optional[MinerControllerProtocol] = MinerControllerProtocol.WEB + + # Try to get defaults from current_config + if current_config and current_config.is_valid(MinerControllerAdapter.PYASIC): + config: MinerControllerPyASICConfig = cast(MinerControllerPyASICConfig, current_config) + default_ip = config.ip or default_ip + default_port = config.port or default_port + default_username = config.username or default_username + default_password = config.password or default_password + default_protocol = config.protocol or default_protocol + + ip: str = click.prompt( + "IP address of the PyASIC miner (eg. 192.168.1.100)", + type=str, + default=default_ip, + ) + + protocol: MinerControllerProtocol = click.prompt( + "Protocol to use to connect to the PyASIC miner", + type=click.Choice([p.value for p in MinerControllerProtocol]), + default=default_protocol.value if default_protocol else None, + ) + protocol = MinerControllerProtocol(protocol) + + port_input = click.prompt( + "Port of the PyASIC miner (eg. 80, press Enter for default)", + type=str, + default="", + ) + port: Optional[int] = None if port_input == "" else int(port_input) + + username_input = click.prompt( + "Username of the PyASIC miner (eg. root, press Enter for default)", + type=str, + default="", + ) + username: Optional[str] = username_input if username_input != "" else default_username + + password_input = click.prompt( + "Password of the PyASIC miner (empty represents 'use the default miner password')", + type=str, + default="", + ) + password: Optional[str] = password_input if password_input != "" else default_password + if password == "": + password = None + + return MinerControllerPyASICConfig(ip=ip, port=port, username=username, password=password, protocol=protocol) + + +def handle_miner_controller_configuration( + adapter_type: MinerControllerAdapter, + miner: Optional[Miner], + current_config: Optional[MinerControllerConfig] = None, +) -> Optional[MinerControllerConfig]: + """Handle configuration for the selected Miner Controller type.""" + config: Optional[MinerControllerConfig] = None + + if adapter_type.value == MinerControllerAdapter.DUMMY.value: + config = handle_miner_controller_dummy_config(miner=miner, current_config=current_config) + elif adapter_type.value == MinerControllerAdapter.GENERIC_SOCKET_HOME_ASSISTANT_API.value: + config = handle_miner_controller_generic_socket_home_assistant_api_config( + miner=miner, current_config=current_config + ) + elif adapter_type.value == MinerControllerAdapter.PYASIC.value: + config = handle_miner_controller_pyasic_config(miner=miner, current_config=current_config) + else: + click.echo(click.style("Unsupported controller type selected. Aborting.", fg="red")) + return config + + +def handle_add_miner_controller( + miner: Optional[Miner], + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> Optional[MinerController]: + """Menu to add a new Miner Controller.""" + click.echo(click.style("\n--- Add Miner Controller ---", fg="yellow")) + name: str = click.prompt("Name of the controller", type=str) + adapter_type: Optional[MinerControllerAdapter] = select_miner_controller_type() + + if adapter_type is None: + click.echo(click.style("Invalid controller type selected. Aborting.", fg="red")) + return None + + new_controller = MinerController() + new_controller.name = name + new_controller.adapter_type = adapter_type + new_controller.config = None + new_controller.external_service_id = None + + config: Optional[MinerControllerConfig] = handle_miner_controller_configuration( + adapter_type=new_controller.adapter_type, miner=miner, current_config=None + ) + + if config is None: + click.echo(click.style("Invalid configuration. Aborting.", fg="red")) + return None + + new_controller.config = config + + needed_external_service = MINER_CONTROLLER_TYPE_EXTERNAL_SERVICE_MAP.get(adapter_type, None) + # If an external service is required for the selected adapter type + external_service: Optional[ExternalService] = None + if needed_external_service: + # If external service is needed, check if some one is already configured + external_services: List[ExternalService] = configuration_service.list_external_services() + if external_services: + external_service = select_external_service( + configuration_service=configuration_service, + logger=logger, + filter_type=[needed_external_service], + ) + if external_service: + new_controller.external_service_id = external_service.id if external_service else None + else: + click.echo("") + click.echo( + click.style( + "No external services configured. Please configure an external service first " + "and then add a miner controller.", + fg="yellow", + ) + ) + add_external_service: bool = click.confirm( + "Do you want to add an external service now?", + default=True, + abort=False, + ) + if add_external_service: + external_service = handle_add_external_service( + configuration_service=configuration_service, + logger=logger, + ) + if external_service: + click.echo( + click.style( + f"External Service '{external_service.name}', " + f"Type: {external_service.adapter_type.name} " + f"(ID: {external_service.id}) successfully added to current miner controller.", + fg="green", + ) + ) + new_controller.external_service_id = external_service.id + else: + click.echo(click.style("Aborting miner controller addition.", fg="red")) + return None + + try: + added_controller = run_async_func( + configuration_service.add_miner_controller( + name=new_controller.name, + adapter=new_controller.adapter_type, + config=new_controller.config, + external_service_id=new_controller.external_service_id, + ) + ) + click.echo( + click.style( + f"Miner Controller '{added_controller.name}' (ID: {added_controller.id}) successfully added.", + fg="green", + ) + ) + except Exception as e: + added_controller = None + logger.error(f"Error adding miner controller: {e}") + click.echo( + click.style(f"Error adding miner controller: {e}", fg="red"), + err=True, + ) + click.pause("Press any key to return to the menu...") + return added_controller + + +def handle_list_miner_controllers(configuration_service: ConfigurationServiceInterface, logger: LoggerPort) -> None: + """List all configured Miner Controllers.""" + click.echo(click.style("\n--- Configured Miner Controllers ---", fg="yellow")) + + controllers = configuration_service.list_miner_controllers() + if not controllers: + click.echo(click.style("No miner controllers configured.", fg="yellow")) + else: + for c in controllers: + click.echo( + "-> " + + "Name: " + + click.style(f"{c.name}, ", fg="blue") + + "ID: " + + click.style(f"{c.id}, ", fg="yellow") + + "Type: " + + click.style(f"{c.adapter_type.name}", fg="green") + ) + click.echo("") + click.pause("Press any key to return to the menu...") + + +def print_miner_controller_details( + controller: MinerController, + configuration_service: ConfigurationServiceInterface, + show_miner_list: bool = False, + show_external_service: bool = False, +) -> None: + """Print details of a selected Miner Controller.""" + click.echo("") + click.echo("| Name: " + click.style(controller.name, fg="blue")) + click.echo("| ID: " + click.style(controller.id, fg="yellow")) + click.echo("| Adapter Type: " + click.style(controller.adapter_type.name, fg="green")) + print_miner_controller_config(controller) + click.echo("") + + if show_external_service: + if controller.external_service_id: + external_service = configuration_service.get_external_service(controller.external_service_id) + if external_service: + click.echo("EXTERNAL SERVICE DETAILS:") + print_external_service_details( + service=external_service, + configuration_service=configuration_service, + show_config=False, + show_linked_instances=False, + ) + else: + click.echo( + "| External service: " + click.style(str(controller.external_service_id), fg="red") + " (not found)" + ) + else: + click.echo("| External service: None") + click.echo("") + + if show_miner_list: + miners = configuration_service.list_miners_by_controller(controller.id) + if not miners: + click.echo(click.style("No miners assigned to this controller.", fg="yellow")) + else: + click.echo("Miners assigned to this controller:") + for m in miners: + click.echo( + "-> " + + "Name: " + + click.style(f"{m.name}, ", fg="blue") + + "ID: " + + click.style(f"{m.id}, ", fg="yellow") + + "Type: " + + click.style(f"{m.power_consumption_max}", fg="green") + ) + click.echo("") + + +def print_miner_controller_config(controller: MinerController) -> None: + """Print the configuration of a selected Miner Controller.""" + configuration_class = controller.config.__class__.__name__ if controller.config else "---" + click.echo("| Configuration: " + click.style(f"{configuration_class}", fg="cyan")) + if controller.config: + print_configuration(controller.config.to_dict()) + + +def update_single_miner_controller( + controller: MinerController, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> Optional[MinerController]: + """Menu to update a miner controller""" + name: str = click.prompt("New name of the controller", type=str, default=controller.name) + + # Get current config to pass as default + current_config: Optional[MinerControllerConfig] = controller.config + # Get current external service id + external_service_id: Optional[EntityId] = controller.external_service_id + + config: Optional[MinerControllerConfig] = handle_miner_controller_configuration( + adapter_type=controller.adapter_type, + miner=None, # No miner needed for controller update + current_config=current_config, # Current config values as default + ) + + if config is None: + click.echo(click.style("Invalid configuration. Aborting.", fg="red")) + return None + + try: + updated_controller = run_async_func( + configuration_service.update_miner_controller( + controller_id=controller.id, name=name, config=config, external_service_id=external_service_id + ) + ) + logger.info(f"Miner Controller '{updated_controller.name}' (ID: {updated_controller.id}) successfully updated.") + except Exception as e: + logger.error(f"Error updating miner controller: {e}") + click.echo( + click.style(f"Error updating miner controller: {e}", fg="red"), + err=True, + ) + updated_controller = None + + click.pause("Press any key to return to the menu...") + + return updated_controller + + +def delete_single_miner_controller( + controller: MinerController, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> bool: + """Delete a specific Miner Controller.""" + delete_confirm = click.confirm( + f"Are you sure you want to remove the Miner Controller '{controller.name}' (ID: {controller.id})?", + abort=False, + default=False, + prompt_suffix="", + ) + + if not delete_confirm: + click.echo(click.style("Deletion cancelled.", fg="yellow")) + return False + + try: + removed_controller = run_async_func(configuration_service.remove_miner_controller(controller_id=controller.id)) + logger.info(f"Miner Controller '{removed_controller.name}' (ID: {removed_controller.id}) successfully removed.") + except Exception as e: + logger.error(f"Error removing miner controller: {e}") + click.echo( + click.style(f"Error removing miner controller: {e}", fg="red"), + err=True, + ) + return False + else: + return True + + +def manage_single_miner_controller_menu( + controller: MinerController, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> str: + """Menu for managing a specific Miner Controller.""" + while True: + click.echo("\n" + click.style("--- MANAGE MINER CONTROLLER ---", fg="blue", bold=True)) + + print_miner_controller_details( + controller, configuration_service, show_miner_list=True, show_external_service=True + ) + + click.echo("1. Update Controller") + click.echo("2. Delete Controller") + click.echo("") + click.echo("b. Back to miner menu") + click.echo("q. Close application") + click.echo("-----------------") + + choice: str = click.prompt("Choose an option", type=str, default="") + choice = choice.strip().lower() + + click.clear() + + if choice == "1": + updated_controller = update_single_miner_controller( + controller=controller, + configuration_service=configuration_service, + logger=logger, + ) + controller = updated_controller or controller # Update controller if it was successfully updated + continue + + elif choice == "2": + delete_status = delete_single_miner_controller( + controller=controller, + configuration_service=configuration_service, + logger=logger, + ) + if delete_status: + return "b" # Return to menu if deletion was successful + continue + + elif choice == "b": + break + + elif choice == "q": + break + + else: + click.echo(click.style("Invalid choice. Try again.", fg="red")) + click.pause("Press any key to return to the menu...") + + return choice + + +def select_miner_controller( + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, + default_id: Optional[EntityId] = None, + filter_type: Optional[List[MinerControllerAdapter]] = None, +) -> Optional[MinerController]: + """Select a miner controller from the list.""" + click.echo(click.style("\n--- Select Miner Controller ---", fg="yellow")) + + controllers = configuration_service.list_miner_controllers() + if not controllers: + click.echo(click.style("No miner controllers configured.", fg="yellow")) + return None + + filter_type = process_filters(filter_type) + + if filter_type: + click.echo( + "Filtering miner controller by types: " + + click.style(f"{', '.join([c.name for c in filter_type])}", fg="blue") + ) + controllers = [c for c in controllers if c.adapter_type in filter_type] + + default_idx = "" + for idx, c in enumerate(controllers): + click.echo( + f"{idx}. " + + "Name: " + + click.style(f"{c.name}, ", fg="blue") + + "ID: " + + click.style(f"{c.id}, ", fg="yellow") + + "Type: " + + click.style(f"{c.adapter_type.name}", fg="green") + ) + + if default_id: + if c.id == default_id: + default_idx = str(idx) + + click.echo("\nb. Back to menu\n") + + controller_idx: str = click.prompt("Choose a Controller index", type=str, default=default_idx) + controller_idx = controller_idx.strip().lower() + if controller_idx == "b": + return None + + if not controller_idx.isdigit() or int(controller_idx) < 0 or int(controller_idx) >= len(controllers): + click.echo(click.style("Invalid index. Aborting selection.", fg="red")) + return None + + selected_controller = controllers[int(controller_idx)] + return selected_controller + + +def handle_manage_miner_controller(configuration_service: ConfigurationServiceInterface, logger: LoggerPort) -> str: + """Menu to manage a miner controller.""" + controller = select_miner_controller(configuration_service, logger) + + if controller is None: + click.echo(click.style("No controller selected. Aborting.", fg="red")) + return "b" + + choice = manage_single_miner_controller_menu( + controller=controller, + configuration_service=configuration_service, + logger=logger, + ) + + return choice + + +def miner_menu( + configuration_service: ConfigurationServiceInterface, + miner_action_service: MinerActionServiceInterface, + logger: LoggerPort, +) -> str: + """Menu for managing Miners.""" + while True: + click.echo("\n" + click.style("--- MINER ---", fg="blue", bold=True)) + click.echo("1. Add a Miner") + click.echo("2. List all Miners") + click.echo("3. Manage a Miner") + click.echo("") + click.echo("4. Add a Miner Controller") + click.echo("5. List Miner Controllers") + click.echo("6. Manage a Miner Controller") + click.echo("") + click.echo("b. Back to main menu") + click.echo("q. Close application") + click.echo("-----------------") + + choice: str = click.prompt("Choose an option", type=str) + choice = choice.strip().lower() + + click.clear() + + if choice == "1": + handle_add_miner(configuration_service=configuration_service, logger=logger) + + elif choice == "2": + handle_list_miners(configuration_service=configuration_service, logger=logger) + + elif choice == "3": + sub_choice = handle_manage_miner( + configuration_service=configuration_service, miner_action_service=miner_action_service, logger=logger + ) + if sub_choice == "q": + break + + elif choice == "4": + handle_add_miner_controller( + miner=None, + configuration_service=configuration_service, + logger=logger, + ) + + elif choice == "5": + handle_list_miner_controllers(configuration_service=configuration_service, logger=logger) + + elif choice == "6": + controller = select_miner_controller(configuration_service, logger) + if controller is None: + click.echo(click.style("No controller selected. Aborting.", fg="red")) + continue + + sub_choice = manage_single_miner_controller_menu( + controller=controller, + configuration_service=configuration_service, + logger=logger, + ) + if sub_choice == "q": + choice = "q" # Exit if user chose to quit from controller menu + break + + elif choice == "b": + break + + elif choice == "q": + break + + else: + click.echo(click.style("Invalid choice. Try again.", fg="red")) + click.pause("Press any key to return to the menu...") + + return choice diff --git a/core/edge_mining/adapters/domain/miner/controllers/__init__.py b/core/edge_mining/adapters/domain/miner/controllers/__init__.py new file mode 100644 index 0000000..0b75fff --- /dev/null +++ b/core/edge_mining/adapters/domain/miner/controllers/__init__.py @@ -0,0 +1 @@ +"""Collection of controllers for the miner domain.""" diff --git a/core/edge_mining/adapters/domain/miner/controllers/dummy.py b/core/edge_mining/adapters/domain/miner/controllers/dummy.py new file mode 100644 index 0000000..e60e737 --- /dev/null +++ b/core/edge_mining/adapters/domain/miner/controllers/dummy.py @@ -0,0 +1,332 @@ +"""Dummy adapter (Implementation of Feature Ports) that simulates a miner for Edge Mining Application""" + +import random +from typing import List, Optional + +from edge_mining.domain.common import Watts +from edge_mining.domain.miner.common import MinerStatus +from edge_mining.domain.miner.ports import ( + DeviceInfoPort, + ExternalFanControlPort, + ExternalFanSpeedMonitorPort, + HashboardMonitorPort, + HashrateMonitorPort, + InletTemperatureMonitorPort, + InternalFanControlPort, + InternalFanSpeedMonitorPort, + MiningControlPort, + OperationalMonitorPort, + OutletTemperatureMonitorPort, + PowerControlPort, + PowerMonitorPort, + StatusMonitorPort, +) +from edge_mining.domain.miner.value_objects import ( + FanSpeed, + Frequency, + HashboardSnapshot, + HashRate, + MinerInfo, + Temperature, + Voltage, +) +from edge_mining.shared.logging.port import LoggerPort + + +class DummyMinerController( + HashrateMonitorPort, + PowerMonitorPort, + StatusMonitorPort, + HashboardMonitorPort, + InletTemperatureMonitorPort, + OutletTemperatureMonitorPort, + InternalFanSpeedMonitorPort, + ExternalFanSpeedMonitorPort, + MiningControlPort, + PowerControlPort, + InternalFanControlPort, + ExternalFanControlPort, + DeviceInfoPort, + OperationalMonitorPort, +): + """Simulates miner control without real hardware. + + Implements all 16 feature ports for testing and development. + """ + + def __init__( + self, + initial_status: MinerStatus = MinerStatus.UNKNOWN, + power_max: Optional[Watts] = None, + hashrate_max: Optional[HashRate] = None, + logger: Optional[LoggerPort] = None, + ): + self._status = initial_status + self._power_max = power_max or Watts(3200.0) + self._hashrate_max = hashrate_max or HashRate(90, "TH/s") + self.logger = logger + + self._power: Watts = Watts(0.0) + self._internal_fan_speed: float = 0.0 + self._external_fan_speed: float = 0.0 + + # --- DeviceInfoPort --- + + async def get_device_info(self) -> Optional[MinerInfo]: + """Gets the device information of the miner.""" + if self.logger: + self.logger.debug("DummyController: Fetching device info...") + + # Simulate some dummy device info + model = "DummyMiner X1" + serial_number = "DMX1-01}" + firmware_type = "Stock" + firmware_version = "1.0.0" + mac_address = "00:11:22:33:10:99" + hostname = "edgemining-dummyminer" + + info = MinerInfo( + model=model, + serial_number=serial_number, + firmware_type=firmware_type, + firmware_version=firmware_version, + mac_address=mac_address, + hostname=hostname, + ) + + if self.logger: + self.logger.debug(f"DummyController: Device info fetched: {info}") + + return info + + # --- MiningControlPort --- + + async def start_miner(self) -> bool: + """Start the miner.""" + if self.logger: + self.logger.debug(f"DummyController: Received START (current: {self._status.name})") + if self._status != MinerStatus.ON: + self._status = MinerStatus.STARTING + if self.logger: + self.logger.debug("DummyController: Setting status to STARTING") + return True + + async def stop_miner(self) -> bool: + """Stop the miner.""" + if self.logger: + self.logger.debug(f"DummyController: Received STOP (current: {self._status.name})") + if self._status == MinerStatus.ON: + self._status = MinerStatus.STOPPING + if self.logger: + self.logger.debug("DummyController: Setting status to STOPPING") + return True + + # --- StatusMonitorPort --- + + async def get_miner_status(self) -> MinerStatus: + """Get the status of the miner.""" + if self._status == MinerStatus.STARTING: + if random.random() < 0.8: + if self.logger: + self.logger.debug("DummyController: Simulating finished starting -> ON") + self._status = MinerStatus.ON + else: + if self.logger: + self.logger.debug("DummyController: Simulating still STARTING") + + elif self._status == MinerStatus.STOPPING: + if random.random() < 0.9: + if self.logger: + self.logger.debug("DummyController: Simulating finished stopping -> OFF") + self._status = MinerStatus.OFF + else: + if self.logger: + self.logger.debug("DummyController: Simulating still STOPPING") + + status = self._status + if self.logger: + self.logger.debug(f"DummyController: Reporting status {status.name}") + return status + + async def get_blocks_found(self) -> Optional[int]: + """Gets the total number of blocks found.""" + return 0 + + async def get_system_uptime(self) -> Optional[int]: + """Gets the system uptime in seconds.""" + return 3600 + + # --- PowerMonitorPort --- + + async def get_miner_power(self) -> Optional[Watts]: + """Get the power of the miner.""" + status = self._status + if status == MinerStatus.ON: + power = Watts(random.uniform(500, self._power_max)) + if self.logger: + self.logger.debug(f"DummyController: Reporting power {power:.0f}W") + elif status == MinerStatus.STARTING: + power = Watts(random.uniform(10, 200)) + if self.logger: + self.logger.debug(f"DummyController: Reporting power {power:.0f}W") + else: + if self.logger: + self.logger.debug(f"DummyController: Reporting power 0W (status: {status.name})") + power = Watts(0.0) + + self._power = power + return power + + # --- HashrateMonitorPort --- + + async def get_miner_hashrate(self) -> Optional[HashRate]: + """Get the hash rate of the miner.""" + status = self._status + if status == MinerStatus.ON: + hash_rate = HashRate( + value=random.uniform(0, self._hashrate_max.value), + unit=self._hashrate_max.unit, + ) + if self.logger: + self.logger.debug(f"DummyController: Reporting hash rate {hash_rate.value:.2f} {hash_rate.unit}") + return hash_rate + else: + if self.logger: + self.logger.debug(f"DummyController: Reporting hash rate 0 (status: {status.name})") + return HashRate(value=0.0, unit="TH/s") + + # --- HashboardMonitorPort --- + + async def get_hashboards(self) -> List[HashboardSnapshot]: + """Get simulated hashboard data.""" + num_boards = 3 + snapshots: List[HashboardSnapshot] = [] + for i in range(num_boards): + if self._status == MinerStatus.ON: + chip_temp = Temperature(value=round(random.uniform(55.0, 85.0), 1)) + board_temp = Temperature(value=round(random.uniform(45.0, 70.0), 1)) + voltage = Voltage(value=round(random.uniform(11.8, 12.6), 2)) + frequency = Frequency(value=round(random.uniform(400.0, 650.0), 1)) + hr_value = round(random.uniform(0, self._hashrate_max.value / num_boards), 2) + nominal_hr = round(self._hashrate_max.value / num_boards, 2) + hash_rate = HashRate(value=hr_value, unit=self._hashrate_max.unit) + nominal_hash_rate = HashRate(value=nominal_hr, unit=self._hashrate_max.unit) + error_val = round(nominal_hr - hr_value, 4) + hash_rate_error = HashRate(value=error_val, unit=self._hashrate_max.unit) + elif self._status in (MinerStatus.STARTING, MinerStatus.STOPPING): + chip_temp = Temperature(value=round(random.uniform(30.0, 55.0), 1)) + board_temp = Temperature(value=round(random.uniform(25.0, 45.0), 1)) + voltage = Voltage(value=round(random.uniform(11.0, 12.0), 2)) + frequency = Frequency(value=0.0) + hash_rate = HashRate(value=0.0, unit=self._hashrate_max.unit) + nominal_hash_rate = None + hash_rate_error = None + else: + chip_temp = Temperature(value=round(random.uniform(20.0, 30.0), 1)) + board_temp = Temperature(value=round(random.uniform(18.0, 28.0), 1)) + voltage = Voltage(value=0.0) + frequency = Frequency(value=0.0) + hash_rate = HashRate(value=0.0, unit=self._hashrate_max.unit) + nominal_hash_rate = None + hash_rate_error = None + + snapshots.append( + HashboardSnapshot( + index=i, + chip_temperature=chip_temp, + board_temperature=board_temp, + voltage=voltage, + frequency=frequency, + hash_rate=hash_rate, + nominal_hash_rate=nominal_hash_rate, + hash_rate_error=hash_rate_error, + ) + ) + + if self.logger: + self.logger.debug(f"DummyController: Reporting {len(snapshots)} hashboards") + return snapshots + + # --- InletTemperatureMonitorPort --- + + async def get_inlet_temperature(self) -> Optional[Temperature]: + """Get simulated inlet air temperature.""" + temp = Temperature(value=round(random.uniform(18.0, 35.0), 1)) + if self.logger: + self.logger.debug(f"DummyController: Reporting inlet temperature {temp.value}{temp.unit}") + return temp + + # --- OutletTemperatureMonitorPort --- + + async def get_outlet_temperature(self) -> Optional[Temperature]: + """Get simulated outlet air temperature.""" + if self._status == MinerStatus.ON: + temp = Temperature(value=round(random.uniform(40.0, 65.0), 1)) + else: + temp = Temperature(value=round(random.uniform(18.0, 30.0), 1)) + if self.logger: + self.logger.debug(f"DummyController: Reporting outlet temperature {temp.value}{temp.unit}") + return temp + + # --- InternalFanSpeedMonitorPort --- + + async def get_internal_fan_speed(self) -> List[FanSpeed]: + """Get simulated internal fan speed.""" + if self._status == MinerStatus.ON: + rpm = random.uniform(3000.0, 6000.0) + elif self._status in (MinerStatus.STARTING, MinerStatus.STOPPING): + rpm = random.uniform(1000.0, 3000.0) + else: + rpm = 0.0 + fan = FanSpeed(value=round(rpm, 0)) + if self.logger: + self.logger.debug(f"DummyController: Reporting internal fan speed {fan.value} {fan.unit}") + return [fan] + + # --- ExternalFanSpeedMonitorPort --- + + async def get_external_fan_speed(self) -> Optional[FanSpeed]: + """Get simulated external fan speed.""" + if self._external_fan_speed > 0: + rpm = self._external_fan_speed * 60.0 # percent to RPM approximation + else: + rpm = 0.0 + fan = FanSpeed(value=round(rpm, 0)) + if self.logger: + self.logger.debug(f"DummyController: Reporting external fan speed {fan.value} {fan.unit}") + return fan + + # --- PowerControlPort --- + + async def power_on(self) -> bool: + """Simulate hard power on.""" + if self.logger: + self.logger.debug(f"DummyController: Received POWER ON (current: {self._status.name})") + if self._status in (MinerStatus.OFF, MinerStatus.UNKNOWN): + self._status = MinerStatus.STARTING + return True + + async def power_off(self) -> bool: + """Simulate hard power off.""" + if self.logger: + self.logger.debug(f"DummyController: Received POWER OFF (current: {self._status.name})") + self._status = MinerStatus.OFF + return True + + # --- InternalFanControlPort --- + + async def set_internal_fan_speed(self, speed_percent: float) -> bool: + """Simulate setting internal fan speed.""" + if self.logger: + self.logger.debug(f"DummyController: Setting internal fan speed to {speed_percent:.0f}%") + self._internal_fan_speed = max(0.0, min(100.0, speed_percent)) + return True + + # --- ExternalFanControlPort --- + + async def set_external_fan_speed(self, speed_percent: float) -> bool: + """Simulate setting external fan speed.""" + if self.logger: + self.logger.debug(f"DummyController: Setting external fan speed to {speed_percent:.0f}%") + self._external_fan_speed = max(0.0, min(100.0, speed_percent)) + return True diff --git a/core/edge_mining/adapters/domain/miner/controllers/generic_socket_home_assistant_api.py b/core/edge_mining/adapters/domain/miner/controllers/generic_socket_home_assistant_api.py new file mode 100644 index 0000000..a3fa724 --- /dev/null +++ b/core/edge_mining/adapters/domain/miner/controllers/generic_socket_home_assistant_api.py @@ -0,0 +1,183 @@ +""" +Generic socket Home Assistant API adapter (Implementation of Feature Ports) +that controls a miner via Home Assistant's entities of a smart socket. +""" + +from typing import Dict, Optional, cast + +from edge_mining.adapters.infrastructure.homeassistant.homeassistant_api import ( + ServiceHomeAssistantAPI, +) +from edge_mining.domain.common import Watts +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.common import MinerStatus +from edge_mining.domain.miner.exceptions import MinerControllerConfigurationError, MinerControllerError +from edge_mining.domain.miner.ports import ( + PowerControlPort, + PowerMonitorPort, + StatusMonitorPort, +) +from edge_mining.shared.adapter_configs.miner import MinerControllerGenericSocketHomeAssistantAPIConfig +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.interfaces.factories import MinerControllerAdapterFactory +from edge_mining.shared.logging.port import LoggerPort + + +class GenericSocketHomeAssistantAPIMinerControllerAdapterFactory(MinerControllerAdapterFactory): + """ + Create a factory for Generic Socket Home Assistant API Miner Controller Adapter. + This factory is used to create instances of the adapter. + """ + + def __init__(self): + self._miner: Optional[Miner] = None + + def from_miner(self, miner: Miner): + """Set the miner for this controller.""" + self._miner = miner + + def create( + self, + config: Optional[Configuration], + logger: Optional[LoggerPort], + external_service: Optional[ExternalServicePort], + ) -> "GenericSocketHomeAssistantAPIMinerController": + """Create an miner controller adapter instance.""" + + # Needs to have the Home Assistant API service as external_service + if not external_service: + raise MinerControllerError( + "HomeAssistantAPI Service is required for Generic Socket Home Assistant API Miner Controller." + ) + + if not external_service.external_service_type == ExternalServiceAdapter.HOME_ASSISTANT_API: + raise MinerControllerError("External service must be of type HomeAssistantAPI") + + if not isinstance(config, MinerControllerGenericSocketHomeAssistantAPIConfig): + raise MinerControllerConfigurationError( + "Invalid configuration for Generic Socket Home Assistant API Miner Controller." + ) + + # Get the config from the provided configuration + miner_controller_configuration: MinerControllerGenericSocketHomeAssistantAPIConfig = config + + service_home_assistant_api = cast(ServiceHomeAssistantAPI, external_service) + + return GenericSocketHomeAssistantAPIMinerController( + home_assistant=service_home_assistant_api, + entity_switch=miner_controller_configuration.entity_switch, + entity_power=miner_controller_configuration.entity_power, + unit_power=miner_controller_configuration.unit_power, + logger=logger, + ) + + +class GenericSocketHomeAssistantAPIMinerController( + PowerMonitorPort, + StatusMonitorPort, + PowerControlPort, +): + """Controls a miner via Home Assistant's entities of a smart socket. + + Implements: PowerMonitorPort, StatusMonitorPort, PowerControlPort. + """ + + def __init__( + self, + home_assistant: ServiceHomeAssistantAPI, + entity_switch: str, + entity_power: str, + unit_power: str = "W", + logger: Optional[LoggerPort] = None, + ): + # Initialize the HomeAssistant API Service + self.home_assistant = home_assistant + self.logger = logger + + self.entity_switch = entity_switch + self.entity_power = entity_power + self.unit_power = unit_power.lower() + + self._log_configuration() + + def _log_configuration(self): + if self.logger: + self.logger.debug( + f"Entities Configured: Switch={self.entity_switch}, Power={self.entity_power}, Unit={self.unit_power}" + ) + + # --- PowerMonitorPort --- + + async def get_power(self) -> Optional[Watts]: + """Gets the current power consumption, if available.""" + if self.logger: + self.logger.debug("Fetching power consumption from Home Assistant...") + + state_power, _ = await self.home_assistant.get_entity_state(self.entity_power) + power_watts = self.home_assistant.parse_power( + state_power, + self.unit_power, + self.entity_power or "N/A", + ) + + if self.logger: + self.logger.debug(f"Power consumption fetched: {power_watts}") + + return power_watts + + # --- StatusMonitorPort --- + + async def get_status(self) -> MinerStatus: + """Gets the current operational status of the miner.""" + if self.logger: + self.logger.debug("Fetching miner status from Home Assistant...") + + state_switch, _ = await self.home_assistant.get_entity_state(self.entity_switch) + state_status = self.home_assistant.parse_bool(state_switch, self.entity_switch or "N/A") + + state_map: Dict[Optional[bool], MinerStatus] = { + True: MinerStatus.ON, + False: MinerStatus.OFF, + None: MinerStatus.UNKNOWN, + } + + miner_status = state_map.get(state_status, MinerStatus.UNKNOWN) + + if self.logger: + self.logger.debug(f"Miner status fetched: {miner_status}") + + return miner_status + + # --- PowerControlPort --- + + async def power_off(self) -> bool: + """Attempts to power off the miner via smart plug. Returns True on success.""" + if self.logger: + self.logger.debug("Sending power off command to miner via Home Assistant...") + + success = await self.home_assistant.set_entity_state( + self.entity_switch, + str(False), + ) + + if self.logger: + self.logger.debug(f"Power off command sent. Success: {success}") + + return success + + async def power_on(self) -> bool: + """Attempts to power on the miner via smart plug. Returns True on success.""" + if self.logger: + self.logger.debug("Sending power on command to miner via Home Assistant...") + + success = await self.home_assistant.set_entity_state( + self.entity_switch, + str(True), + ) + + if self.logger: + self.logger.debug(f"Power on command sent. Success: {success}") + + return success diff --git a/core/edge_mining/adapters/domain/miner/controllers/pyasic.py b/core/edge_mining/adapters/domain/miner/controllers/pyasic.py new file mode 100644 index 0000000..5fe3223 --- /dev/null +++ b/core/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -0,0 +1,1267 @@ +""" +pyasic adapter (Implementation of Feature Ports) +that controls a miner via pyasic. +""" + +from typing import Dict, List, Optional, Tuple, cast + +import pyasic +from pyasic import AnyMiner +from pyasic.device.algorithm.hashrate import AlgoHashRate +from pyasic.rpc.base import BaseMinerRPCAPI +from pyasic.ssh.base import BaseSSH +from pyasic.web.base import BaseWebAPI + +from edge_mining.domain.common import Watts +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.common import MinerControllerProtocol, MinerStatus +from edge_mining.domain.miner.exceptions import MinerControllerConfigurationError +from edge_mining.domain.miner.ports import ( + DeviceInfoPort, + HashboardMonitorPort, + HashrateMonitorPort, + InletTemperatureMonitorPort, + InternalFanControlPort, + InternalFanSpeedMonitorPort, + MaxHashrateDetectionPort, + MaxPowerDetectionPort, + MiningControlPort, + OperationalMonitorPort, + OutletTemperatureMonitorPort, + PowerMonitorPort, + StatusMonitorPort, +) +from edge_mining.domain.miner.value_objects import ( + FanSpeed, + Frequency, + HashboardSnapshot, + HashRate, + MinerInfo, + Temperature, + Voltage, +) +from edge_mining.shared.adapter_configs.miner import MinerControllerPyASICConfig +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.interfaces.factories import MinerControllerAdapterFactory +from edge_mining.shared.logging.port import LoggerPort + + +class PyASICMinerControllerAdapterFactory(MinerControllerAdapterFactory): + """ + Create a factory for pyasic Miner Controller Adapter. + This factory is used to create instances of the adapter. + """ + + def __init__(self): + self._miner: Optional[Miner] = None + + def from_miner(self, miner: Miner): + """Set the miner for this controller.""" + self._miner = miner + + def create( + self, + config: Optional[Configuration] = None, + logger: Optional[LoggerPort] = None, + external_service: Optional[ExternalServicePort] = None, + ) -> "PyASICMinerController": + """Create a miner controller adapter instance.""" + + if not isinstance(config, MinerControllerPyASICConfig): + raise MinerControllerConfigurationError("Invalid configuration for pyasic Miner Controller.") + + # Get the config from the provided configuration + miner_controller_configuration: MinerControllerPyASICConfig = config + + return PyASICMinerController( + ip=miner_controller_configuration.ip, + protocol=miner_controller_configuration.protocol, + port=miner_controller_configuration.port, + username=miner_controller_configuration.username, + password=miner_controller_configuration.password, + logger=logger, + ) + + +class PyASICMinerController( + HashrateMonitorPort, + PowerMonitorPort, + StatusMonitorPort, + HashboardMonitorPort, + InletTemperatureMonitorPort, + OutletTemperatureMonitorPort, + InternalFanSpeedMonitorPort, + MiningControlPort, + InternalFanControlPort, + DeviceInfoPort, + MaxPowerDetectionPort, + MaxHashrateDetectionPort, + OperationalMonitorPort, +): + """Controls a miner via pyasic. Implements multiple feature ports.""" + + def __init__( + self, + ip: str, + port: Optional[int] = None, + username: Optional[str] = None, + password: Optional[str] = None, + protocol: Optional[MinerControllerProtocol] = None, + logger: Optional[LoggerPort] = None, + ): + self.logger = logger + + self.ip = ip + self.password = password + self.port = port + self.username = username + self.protocol = protocol + + self._miner: Optional[AnyMiner] = None + + self._log_configuration() + + def _log_configuration(self): + if self.logger: + self.logger.debug( + f"PyASIC Controller configured: IP={self.ip}, protocol={self.protocol}, " + f"port={self.port}, username={self.username}, pwd={'***' if self.password else None}" + ) + + async def _get_miner(self) -> None: + """Retrieve the pyasic miner instance.""" + if self._miner is None: + try: + miner = await pyasic.get_miner(self.ip) + if miner is not None: + self._miner = cast(AnyMiner, miner) + + # Set additional parameters like protocol, password,port + if self.protocol == MinerControllerProtocol.RPC: + if isinstance(self._miner.rpc, BaseMinerRPCAPI): + if self.port: + self._miner.rpc.port = self.port + if self.password: + self._miner.rpc.pwd = self.password + else: + if self.logger: + self.logger.error("Unknown PyASIC Miner Controller RPC Protocol") + elif self.protocol == MinerControllerProtocol.WEB: + if isinstance(self._miner.web, BaseWebAPI): + if self.port: + self._miner.web.port = self.port + if self.password: + self._miner.web.pwd = self.password + if self.username: + self._miner.web.username = self.username + else: + if self.logger: + self.logger.error("Unknown PyASIC Miner Controller Web Protocol") + elif self.protocol == MinerControllerProtocol.SSH: + if isinstance(self._miner.ssh, BaseSSH): + if self.port: + self._miner.ssh.port = self.port + if self.password: + self._miner.ssh.pwd = self.password + if self.username: + self._miner.ssh.username = self.username + else: + if self.logger: + self.logger.error("Unknown PyASIC Miner Controller SSH Protocol") + else: + if self.logger: + self.logger.error(f"Unknown PyASIC Miner Controller Protocol: {self.protocol}") + + if self.logger: + self.logger.debug( + f"Miner identified: type={type(self._miner).__name__}, " + f"model={self._miner.raw_model}, firmware={self._miner.firmware}, " + f"rpc={type(self._miner.rpc).__name__ if self._miner.rpc else None}, " + f"web={type(self._miner.web).__name__ if self._miner.web else None}, " + f"ssh={type(self._miner.ssh).__name__ if self._miner.ssh else None}, " + f"expected_hashboards={self._miner.expected_hashboards}" + ) + except Exception as e: + if self.logger: + self.logger.error(f"Failed to retrieve miner instance from {self.ip}: {e}") + + # --- DeviceInfoDetectionPort --- + + async def get_device_info(self) -> Optional[MinerInfo]: + """Gets the device identification information of the miner, if available.""" + + if self.logger: + self.logger.debug(f"Fetching model from {self.ip}...") + + await self._get_miner() + + if not self._miner: + if self.logger: + self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") + return None + + miner = self._miner + + # --- pyasic native values (may be None for some firmwares) --- + hashboard_count = miner.expected_hashboards + chip_count = miner.expected_chips + fan_count = miner.expected_fans + + serial_number = await miner.get_serial_number() + mac_address = await miner.get_mac() + model = await miner.get_model() + firmware_type = str(miner.firmware) if miner.firmware else None + firmware_version = await miner.get_fw_ver() + hostname = await miner.get_hostname() + + # --- RPC fallbacks for missing fields --- + if miner.rpc is not None: + rpc_info = await self._fetch_device_info_from_rpc(miner) + + if firmware_version is None and rpc_info.get("firmware_version"): + firmware_version = rpc_info["firmware_version"] + + if (model is None or str(model) in ("", "Unknown", "Unknown (BOS+)")) and rpc_info.get("model"): + model = rpc_info["model"] + + if hashboard_count is None and rpc_info.get("hashboard_count") is not None: + hashboard_count = rpc_info["hashboard_count"] + + if chip_count is None and rpc_info.get("chip_count") is not None: + chip_count = rpc_info["chip_count"] + + if fan_count is None and rpc_info.get("fan_count") is not None: + fan_count = rpc_info["fan_count"] + + if mac_address is None and rpc_info.get("mac_address"): + mac_address = rpc_info["mac_address"] + + if hostname is None and rpc_info.get("hostname"): + hostname = rpc_info["hostname"] + + if serial_number is None and rpc_info.get("serial_number"): + serial_number = rpc_info["serial_number"] + + return MinerInfo( + model=str(model) if model is not None else None, + serial_number=str(serial_number) if serial_number is not None else None, + firmware_type=firmware_type, + firmware_version=str(firmware_version) if firmware_version is not None else None, + mac_address=str(mac_address) if mac_address is not None else None, + hostname=str(hostname) if hostname is not None else None, + hashboard_count=int(hashboard_count) if hashboard_count is not None else None, + chip_count=int(chip_count) if chip_count is not None else None, + fan_count=int(fan_count) if fan_count is not None else None, + ) + + async def _fetch_device_info_from_rpc(self, miner: AnyMiner) -> dict: + """Fetch device info fields from direct RPC commands and the Luci web API. + + Queries RPC (version, config, devdetails, fans, pools) and + Luci web endpoints (iface_status, cfg_data) to fill gaps + left by pyasic native methods. + """ + info: Dict[str, object] = {} + + # --- version → firmware_version --- + try: + ver_data = await miner.rpc.send_command("version") + version_entries = ver_data.get("VERSION", []) + if version_entries: + entry = version_entries[0] + # Try BOSer, then CGMiner, then BMMiner key + fw = entry.get("BOSer") or entry.get("CGMiner") or entry.get("BMMiner") + if fw: + info["firmware_version"] = str(fw) + except Exception as e: + if self.logger: + self.logger.debug(f"RPC version failed for device info: {e}") + + # --- config → hashboard_count (ASC Count) --- + try: + cfg_data = await miner.rpc.send_command("config") + config_entries = cfg_data.get("CONFIG", []) + if config_entries: + asc_count = config_entries[0].get("ASC Count") + if asc_count is not None and int(asc_count) > 0: + info["hashboard_count"] = int(asc_count) + except Exception as e: + if self.logger: + self.logger.debug(f"RPC config failed for device info: {e}") + + # --- devdetails → model, chip_count (intermittent on some firmwares) --- + try: + dd_data = await miner.rpc.send_command("devdetails") + details = dd_data.get("DEVDETAILS", []) + if details: + model_str = details[0].get("Model") + if model_str: + info["model"] = str(model_str) + # Chips per board × number of boards = total chip count + chips_per_board = details[0].get("Chips") + if chips_per_board is not None: + board_count = info.get("hashboard_count") or len(details) + info["chip_count"] = int(chips_per_board) * int(board_count) + except Exception as e: + if self.logger: + self.logger.debug(f"RPC devdetails failed for device info: {e}") + + # --- fans → fan_count --- + try: + fans_data = await miner.rpc.send_command("fans") + fans_list = fans_data.get("FANS", []) + if fans_list: + info["fan_count"] = len(fans_list) + except Exception as e: + if self.logger: + self.logger.debug(f"RPC fans failed for device info: {e}") + + # --- pools → hostname (from pool worker name) --- + try: + pools_data = await miner.rpc.send_command("pools") + pools_list = pools_data.get("POOLS", []) + for pool in pools_list: + user = pool.get("User", "") + if "." in user: + # Worker name format: "username.worker" — worker is often the hostname + worker = user.rsplit(".", 1)[1] + if worker: + info["hostname"] = str(worker) + break + except Exception as e: + if self.logger: + self.logger.debug(f"RPC pools failed for device info: {e}") + + # --- Luci web API → mac_address --- + # --- GraphQL API → model, hostname, serial_number (hwid) --- + web_info = await self._fetch_device_info_from_web() + if web_info.get("mac_address"): + info["mac_address"] = web_info["mac_address"] + if web_info.get("model") and "model" not in info: + info["model"] = web_info["model"] + if web_info.get("hostname"): + info.setdefault("hostname", web_info["hostname"]) + if web_info.get("serial_number"): + info["serial_number"] = web_info["serial_number"] + + if self.logger: + self.logger.debug(f"RPC device info fallback: {info}") + + return info + + async def _fetch_device_info_from_web(self) -> dict: + """Fetch device info from the miner's web APIs (GraphQL + Luci). + + - GraphQL (/graphql): hwid (serial), hostname, modelName — no auth required. + - Luci (form auth): MAC address from iface_status/lan — requires credentials. + """ + import httpx + + info: Dict[str, str] = {} + base_url = f"http://{self.ip}" + + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client: + # --- GraphQL (no auth needed) → serial_number, hostname, model --- + try: + gql_query = {"query": "{ bos { hostname hwid } bosminer { info { modelName } } }"} + resp = await client.post(f"{base_url}/graphql", json=gql_query) + if resp.status_code == 200: + gql_data = resp.json().get("data", {}) + bos = gql_data.get("bos") or {} + bosminer = gql_data.get("bosminer") or {} + + hwid = bos.get("hwid") + if hwid: + info["serial_number"] = str(hwid) + + hostname = bos.get("hostname") + if hostname: + info["hostname"] = str(hostname) + + model_name = (bosminer.get("info") or {}).get("modelName") + if model_name: + info["model"] = str(model_name) + except Exception as e: + if self.logger: + self.logger.debug(f"GraphQL device info failed: {e}") + + # --- Luci (form auth) → MAC address --- + if self.password: + try: + luci_headers = { + "User-Agent": "BTC Tools v0.1", + "Content-Type": "application/x-www-form-urlencoded", + } + login_data = { + "luci_username": self.username or "root", + "luci_password": self.password, + } + await client.post( + f"{base_url}/cgi-bin/luci", + headers=luci_headers, + data=login_data, + ) + + if client.cookies: + resp = await client.get( + f"{base_url}/cgi-bin/luci/admin/network/iface_status/lan", + headers={"User-Agent": "BTC Tools v0.1"}, + ) + if resp.status_code == 200: + data = resp.json() + if isinstance(data, list) and data: + mac = data[0].get("macaddr") + if mac: + info["mac_address"] = str(mac).upper() + elif self.logger: + self.logger.debug("Luci form auth failed (no session cookie)") + except Exception as e: + if self.logger: + self.logger.debug(f"Luci MAC fetch failed: {e}") + + except Exception as e: + if self.logger: + self.logger.debug(f"Web device info fetch failed: {e}") + + return info + + # --- MaxPowerDetectionPort --- + + async def get_max_power(self) -> Optional[Watts]: + """Gets the maximum power consumption of the miner, if available.""" + + if self.logger: + self.logger.debug(f"Fetching max power from {self.ip}...") + + await self._get_miner() + + if not self._miner: + if self.logger: + self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") + return None + + miner = self._miner + wattage = await miner.get_wattage_limit() + if wattage is None: + if self.logger: + self.logger.debug(f"Failed to fetch max power from {self.ip}...") + return None + max_power_watts = Watts(wattage) + + if self.logger: + self.logger.debug(f"Max power fetched: {max_power_watts}") + + return max_power_watts + + # --- MaxHashrateDetectionPort --- + + async def get_max_hashrate(self) -> Optional[HashRate]: + """Gets the maximum hash rate of the miner, if available.""" + + if self.logger: + self.logger.debug(f"Fetching max hash rate from {self.ip}...") + + await self._get_miner() + + if not self._miner: + if self.logger: + self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") + return None + + miner = self._miner + hashrate = await miner.get_expected_hashrate() + if hashrate is None: + if self.logger: + self.logger.debug(f"Failed to fetch max hash rate from {self.ip}...") + return None + normalized_value, normalized_unit = self._normalize_hashrate_unit( + value=float(hashrate), + unit=str(hashrate.unit), + ) + max_hashrate = HashRate(value=normalized_value, unit=normalized_unit) + + if self.logger: + self.logger.debug(f"Max hash rate fetched: {max_hashrate}") + + return max_hashrate + + # --- HashrateMonitorPort --- + + async def get_hashrate(self) -> Optional[HashRate]: + """Gets the current hash rate, if available.""" + + if self.logger: + self.logger.debug(f"Fetching hashrate from {self.ip}...") + + await self._get_miner() + + if not self._miner: + if self.logger: + self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") + return None + + miner = self._miner + hashrate: Optional[AlgoHashRate] = await miner.get_hashrate() + if hashrate is None: + if self.logger: + self.logger.debug(f"Failed to fetch hashrate from {self.ip}...") + return None + normalized_value, normalized_unit = self._normalize_hashrate_unit( + value=float(hashrate), + unit=str(hashrate.unit), + ) + real_hashrate = HashRate(value=normalized_value, unit=normalized_unit) + + if self.logger: + self.logger.debug(f"Hashrate fetched: {real_hashrate}") + + return real_hashrate + + # --- PowerMonitorPort --- + + async def get_power(self) -> Optional[Watts]: + """Gets the current power consumption, if available.""" + if self.logger: + self.logger.debug(f"Fetching power consumption from {self.ip}...") + + await self._get_miner() + + if not self._miner: + if self.logger: + self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") + return None + + miner = self._miner + wattage = await miner.get_wattage() + if wattage is None: + if self.logger: + self.logger.debug(f"Failed to fetch power consumption from {self.ip}...") + return None + power_watts = Watts(wattage) + + if self.logger: + self.logger.debug(f"Power consumption fetched: {power_watts}") + + return power_watts + + # --- StatusMonitorPort --- + + async def get_status(self) -> MinerStatus: + """Gets the current operational status of the miner.""" + if self.logger: + self.logger.debug(f"Fetching miner status from {self.ip}...") + + await self._get_miner() + + if not self._miner: + if self.logger: + self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") + return MinerStatus.UNKNOWN + + miner = self._miner + mining_state = await miner.is_mining() + + # Map the bool result from is_mining() to MinerStatus + state_map: Dict[Optional[bool], MinerStatus] = { + True: MinerStatus.ON, + False: MinerStatus.OFF, + None: MinerStatus.UNKNOWN, + } + + # If miner status is not provided, we can try to derive it + if mining_state is None: + if self.logger: + self.logger.debug("Mining state is not provided, deriving miner status...") + derived_state = await self._derive_miner_status() + miner_status = state_map.get(derived_state, MinerStatus.UNKNOWN) + else: + miner_status = state_map.get(mining_state, MinerStatus.UNKNOWN) + + if self.logger: + self.logger.debug(f"Miner status fetched: {miner_status}") + + return miner_status + + # --- OperationalMonitorPort --- + + async def get_blocks_found(self) -> Optional[int]: + """Gets the total number of blocks found by the miner.""" + await self._get_miner() + if not self._miner or not self._miner.rpc: + return None + try: + summary = await self._miner.rpc.send_command("summary") + + blocks_found = summary.get("SUMMARY", [{}])[0].get("Found Blocks") + return blocks_found if blocks_found is not None else None + except Exception: + return None + + async def get_system_uptime(self) -> Optional[int]: + """Gets the system uptime in seconds.""" + await self._get_miner() + if not self._miner or not self._miner.rpc: + return None + try: + summary = await self._miner.rpc.send_command("summary") + elapsed = summary.get("SUMMARY", [{}])[0].get("Elapsed") + return int(elapsed) if elapsed is not None else None + except Exception: + return None + + # --- HashboardMonitorPort --- + + async def get_hashboards(self) -> List[HashboardSnapshot]: + """Gets the current state of all hashboards.""" + if self.logger: + self.logger.debug(f"Fetching hashboard data from {self.ip}...") + + await self._get_miner() + + if not self._miner: + if self.logger: + self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") + return [] + + miner = self._miner + hashboards = await miner.get_hashboards() + + if self.logger: + self.logger.debug( + f"pyasic get_hashboards() returned {len(hashboards)} boards. " + f"Raw data: {[(hb.slot, hb.chip_temp, hb.temp, hb.voltage, hb.hashrate) for hb in hashboards]}" + ) + + # Fallback: if get_hashboards() returns empty (e.g. expected_hashboards is None), + # query the RPC directly and build HashBoard objects from raw data. + if not hashboards: + if self.logger: + self.logger.debug(f"get_hashboards() returned empty for {self.ip}, trying RPC fallback...") + hashboards = await self._fetch_hashboards_fallback(miner) + + if not hashboards: + if self.logger: + self.logger.debug(f"No hashboard data available from {self.ip}") + return [] + + # Supplement missing voltage/frequency from devdetails RPC + # pyasic's _get_hashboards() only extracts Chips from devdetails, + # but BOSer firmware also reports Voltage and Frequency there. + devdetails_extra = await self._fetch_devdetails_extra(miner) + + snapshots: List[HashboardSnapshot] = [] + for idx, hb in enumerate(hashboards): + chip_temp = Temperature(value=float(hb.chip_temp)) if hb.chip_temp is not None else None + board_temp = Temperature(value=float(hb.temp)) if hb.temp is not None else None + + hb_hashrate = None + if hb.hashrate is not None: + hr_val, hr_unit = self._normalize_hashrate_unit( + value=float(hb.hashrate), + unit=str(hb.hashrate.unit) if hasattr(hb.hashrate, "unit") else "TH/s", + ) + hb_hashrate = HashRate(value=hr_val, unit=hr_unit) + + hb_voltage = Voltage(value=round(float(hb.voltage), 2)) if hb.voltage is not None else None + hb_frequency: Optional[Frequency] = None + + # Fill voltage/frequency from devdetails if pyasic didn't provide them + if idx < len(devdetails_extra): + extra = devdetails_extra[idx] + if hb_voltage is None and extra.get("voltage") is not None: + hb_voltage = Voltage(value=round(float(extra["voltage"]), 2)) + if extra.get("frequency") is not None: + hb_frequency = Frequency(value=float(extra["frequency"])) + + snapshots.append( + HashboardSnapshot( + index=idx, + chip_temperature=chip_temp, + board_temperature=board_temp, + voltage=hb_voltage, + frequency=hb_frequency, + hash_rate=hb_hashrate, + nominal_hash_rate=None, + hash_rate_error=None, + ) + ) + + if self.logger: + self.logger.debug(f"Hashboard data fetched: {len(snapshots)} boards from {self.ip}") + + return snapshots + + # --- InletTemperatureMonitorPort --- + + async def get_inlet_temperature(self) -> Optional[Temperature]: + """Gets the current inlet air temperature, if available.""" + if self.logger: + self.logger.debug(f"Fetching inlet temperature from {self.ip}...") + + await self._get_miner() + + if not self._miner: + return None + + miner = self._miner + data = await miner.get_data() + if data is None or data.env_temp is None: + return None + + return Temperature(value=float(data.env_temp)) + + # --- OutletTemperatureMonitorPort --- + + async def get_outlet_temperature(self) -> Optional[Temperature]: + """Gets the current outlet air temperature, if available.""" + if self.logger: + self.logger.debug(f"Fetching outlet temperature from {self.ip}...") + + # pyasic does not typically provide separate outlet temperature + # Some miners expose this through env_temp or specific board data + return None + + # --- InternalFanSpeedMonitorPort --- + + async def get_internal_fan_speed(self) -> List[FanSpeed]: + """Gets the current internal fan speed, if available.""" + miner_fans: List[FanSpeed] = [] + + if self.logger: + self.logger.debug(f"Fetching internal fan speed from {self.ip}...") + + await self._get_miner() + + if not self._miner: + return [] + + miner = self._miner + fans = await miner.get_fans() + if fans is None: + return [] + + for fan in fans: + if fan.speed is not None: + miner_fans.append(FanSpeed(value=float(fan.speed))) + + return miner_fans + + # --- MiningControlPort --- + + async def start_mining(self) -> bool: + """Attempts to start mining. Returns True on success.""" + if self.logger: + self.logger.debug(f"Sending start command to miner at {self.ip}...") + + await self._get_miner() + + if not self._miner: + if self.logger: + self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") + return False + + miner = self._miner + success = await miner.resume_mining() + + if self.logger: + self.logger.debug(f"Start command sent. Success: {success}") + + return success or False + + async def stop_mining(self) -> bool: + """Attempts to stop mining. Returns True on success.""" + if self.logger: + self.logger.debug(f"Sending stop command to miner at {self.ip}...") + + await self._get_miner() + + if not self._miner: + if self.logger: + self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") + return False + + miner = self._miner + success = await miner.stop_mining() + + if self.logger: + self.logger.debug(f"Stop command sent. Success: {success}") + + return success or False + + # --- InternalFanControlPort --- + + async def set_internal_fan_speed(self, speed_percent: float) -> bool: + """Sets internal fan speed as a percentage (0-100). Returns True on success.""" + if self.logger: + self.logger.debug(f"Setting internal fan speed to {speed_percent}% on {self.ip}...") + + await self._get_miner() + + if not self._miner: + if self.logger: + self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") + return False + + miner = self._miner + try: + success = await miner.set_fan_speed(int(speed_percent)) + if self.logger: + self.logger.debug(f"Fan speed set. Success: {success}") + return success or False + except Exception as e: + if self.logger: + self.logger.error(f"Failed to set fan speed on {self.ip}: {e}") + return False + + # --- Private helpers --- + + async def _fetch_devdetails_extra(self, miner: AnyMiner) -> List[dict]: + """Fetch Voltage and Frequency from devdetails RPC. + + pyasic's _get_hashboards() only extracts Chips from devdetails, + but some firmwares (e.g. BOSer) also report Voltage and Frequency. + Returns a list of dicts (one per board, positional order) with + 'voltage' and 'frequency' keys. + """ + if miner.rpc is None: + return [] + + try: + rpc_data = await miner.rpc.send_command("devdetails") + except Exception as e: + if self.logger: + self.logger.debug(f"devdetails RPC failed: {e}") + return [] + + details = rpc_data.get("DEVDETAILS", []) + if not details: + return [] + + # Sort by ID and extract voltage/frequency by positional index + sorted_details = sorted(details, key=lambda d: d.get("ID", 0)) + result = [] + for d in sorted_details: + result.append( + { + "voltage": d.get("Voltage"), + "frequency": d.get("Frequency"), + } + ) + + if self.logger: + self.logger.debug(f"devdetails extra: {result}") + + return result + + async def _fetch_hashboards_fallback(self, miner: AnyMiner) -> list: + """Fallback: fetch hashboard data directly when get_hashboards() returns empty. + + This handles cases where pyasic's expected_hashboards is None (e.g., unrecognized + miner model/firmware combination), causing get_hashboards() to return []. + Tries gRPC first (BOSer), then RPC (BOSMiner/legacy), building HashBoard objects + from the raw response. + """ + if self.logger: + self.logger.debug( + f"Fallback check: miner.web={miner.web}, " + f"type={type(miner.web).__name__ if miner.web else None}, " + f"has get_hashboards={hasattr(miner.web, 'get_hashboards') if miner.web else False}, " + f"miner type={type(miner).__name__}" + ) + + # --- Try gRPC (BOSer firmware) --- + grpc_result = await self._try_grpc_hashboards(miner) + if grpc_result is not None: + return grpc_result + + # --- Try Luci web overview (BOSMinerWebAPI) --- + luci_result = await self._try_luci_hashboards(miner) + if luci_result is not None: + return luci_result + + # --- Try RPC (BOSMiner / legacy firmware) --- + rpc_result = await self._try_rpc_hashboards(miner) + if rpc_result is not None: + return rpc_result + + if self.logger: + self.logger.debug("No RPC or web interface available for fallback") + return [] + + async def _try_grpc_hashboards(self, miner: AnyMiner) -> Optional[list]: + """Try fetching hashboard data via gRPC. Returns None if not available.""" + from pyasic.data import HashBoard + + web_api = miner.web + + # If the current web class doesn't support get_hashboards, + # try BOSerWebAPI directly (handles pyasic misidentifying BOSer as BOSMiner) + if web_api is None or not hasattr(web_api, "get_hashboards"): + try: + from pyasic.web.braiins_os.boser import BOSerWebAPI + + web_api = BOSerWebAPI(str(miner.ip)) + if self.logger: + self.logger.debug(f"Created direct BOSerWebAPI for {self.ip}") + except ImportError: + return None + + try: + grpc_data = await web_api.get_hashboards() + if self.logger: + self.logger.debug(f"gRPC fallback raw response: {grpc_data}") + except Exception as e: + if self.logger: + self.logger.debug(f"gRPC fallback failed: {e}") + return None + + if not grpc_data or not grpc_data.get("hashboards"): + return None + + grpc_boards = sorted(grpc_data["hashboards"], key=lambda x: int(x.get("id", 0))) + hashboards = [] + for idx, board in enumerate(grpc_boards): + hb = HashBoard(slot=idx, expected_chips=miner.expected_chips) + hb.missing = False + + if board.get("boardTemp") is not None: + hb.temp = int(board["boardTemp"]["degreeC"]) + if board.get("highestChipTemp") is not None: + hb.chip_temp = int(board["highestChipTemp"]["temperature"]["degreeC"]) + if board.get("chipsCount") is not None: + hb.chips = board["chipsCount"] + if board.get("serialNumber") is not None: + hb.serial_number = board["serialNumber"] + if board.get("stats") is not None: + try: + real_hr = board["stats"]["realHashrate"]["last5S"] + if real_hr and real_hr.get("gigahashPerSecond") is not None: + hb.hashrate = miner.algo.hashrate( + rate=float(real_hr["gigahashPerSecond"]), + unit=miner.algo.unit.GH, + ) + except (KeyError, TypeError): + pass + hashboards.append(hb) + + if self.logger: + self.logger.debug(f"gRPC fallback: found {len(hashboards)} hashboards from {self.ip}") + return hashboards + + async def _try_luci_hashboards(self, miner: AnyMiner) -> Optional[list]: + """Try fetching hashboard data via Luci web API (get_api_status). + + The get_api_status endpoint returns all RPC data in one call, including + temps, devs, devdetails, fans, and summary. This is the most complete + data source on BOSer firmware when gRPC is unavailable. + """ + from pyasic.data import HashBoard + + if miner.web is None or not hasattr(miner.web, "get_api_status"): + return None + + # Ensure web credentials are set (override defaults if controller has credentials) + if self.password: + miner.web.pwd = self.password + if self.username: + miner.web.username = self.username + + try: + api_status = await miner.web.get_api_status() + except Exception as e: + if self.logger: + self.logger.debug(f"Luci get_api_status failed: {e}") + return None + + if not api_status or not isinstance(api_status, dict): + return None + + # Parse temps from api_status (may be "Not ready" on old firmware) + temps_list: list = [] + try: + temps_data = api_status["temps"][0] + if temps_data.get("TEMPS"): + for board in temps_data["TEMPS"]: + temps_list.append( + { + "chip_temp": round(board["Chip"]) if board.get("Chip") is not None else None, + "board_temp": round(board["Board"]) if board.get("Board") is not None else None, + } + ) + else: + status_info = temps_data.get("STATUS", [{}])[0] + if self.logger: + self.logger.debug( + f"Luci temps not available: {status_info.get('Msg', 'unknown')} " + f"(code {status_info.get('Code', '?')})" + ) + except (KeyError, IndexError, TypeError): + pass + + # Parse devs from api_status + devs_list: list = [] + try: + for dev in api_status["devs"][0]["DEVS"]: + devs_list.append({"mhs": dev.get("MHS 1m") or dev.get("MHS 5m") or dev.get("MHS av")}) + except (KeyError, IndexError, TypeError): + pass + + # Parse devdetails from api_status for voltage and frequency + details_list: list = [] + try: + for detail in api_status["devdetails"][0]["DEVDETAILS"]: + details_list.append( + { + "voltage": detail.get("Voltage"), + "frequency": detail.get("Frequency"), + } + ) + except (KeyError, IndexError, TypeError): + pass + + board_count = max(len(temps_list), len(devs_list), len(details_list)) + if board_count == 0: + return None + + hashboards = [] + for idx in range(board_count): + hb = HashBoard(slot=idx, expected_chips=miner.expected_chips) + hb.missing = False + + if idx < len(temps_list): + t = temps_list[idx] + if t.get("chip_temp") is not None: + hb.chip_temp = t["chip_temp"] + if t.get("board_temp") is not None: + hb.temp = t["board_temp"] + + if idx < len(devs_list): + dev = devs_list[idx] + mhs = dev.get("mhs") + if mhs is not None: + hb.hashrate = miner.algo.hashrate(rate=mhs, unit=miner.algo.unit.MH) + + if idx < len(details_list): + d = details_list[idx] + if d.get("voltage") is not None: + hb.voltage = round(float(d["voltage"]), 2) + + hashboards.append(hb) + + if self.logger: + self.logger.debug(f"Luci api_status: found {len(hashboards)} hashboards from {self.ip}") + return hashboards + + async def _try_rpc_hashboards(self, miner: AnyMiner) -> Optional[list]: + """Try fetching hashboard data via RPC. Returns None if not available.""" + from pyasic.data import HashBoard + + if miner.rpc is None: + return None + + try: + rpc_data = await miner.rpc.multicommand("temps", "devdetails", "devs", "stats") + except Exception as e: + if self.logger: + self.logger.debug(f"RPC fallback multicommand failed: {e}") + return None + + if self.logger: + self.logger.debug(f"RPC fallback raw response keys: {list(rpc_data.keys())}") + + # Parse temps by positional index (BOSMiner firmware) + temps_list: list = [] + try: + for board in rpc_data["temps"][0]["TEMPS"]: + temps_list.append( + { + "chip_temp": round(board["Chip"]) if board.get("Chip") is not None else None, + "board_temp": round(board["Board"]) if board.get("Board") is not None else None, + } + ) + except (KeyError, IndexError, TypeError): + pass + + # Parse temps from stats if temps command didn't return data (BOSer firmware). + # The stats response contains temperature fields like temp_chip_N, temp_board_N, temp2_N, etc. + if not temps_list: + try: + stats_entries = rpc_data["stats"][0]["STATS"] + if self.logger: + self.logger.debug(f"RPC stats entries: {stats_entries}") + for stat in stats_entries: + # Look for per-chain temperature keys found in various CGMiner-based firmwares + # Common patterns: temp_chip_1..N, temp_board_1..N, temp2_1..N, temp_1..N + chain_idx = 0 + while True: + chain_num = chain_idx + 1 + chip_temp = None + board_temp = None + + # Try various known key patterns for chip temperature + for key in [f"temp_chip_{chain_num}", f"temp2_{chain_num}", f"temp{chain_num}"]: + val = stat.get(key) + if val is not None and float(val) > 0: + chip_temp = round(float(val)) + break + + # Try various known key patterns for board temperature + for key in [f"temp_board_{chain_num}", f"temp_pcb_{chain_num}", f"temp{chain_num}"]: + val = stat.get(key) + if val is not None and float(val) > 0: + # Avoid using the same key for both if chip_temp already used it + if board_temp is None: + board_temp = round(float(val)) + + if chip_temp is None and board_temp is None: + break + temps_list.append({"chip_temp": chip_temp, "board_temp": board_temp}) + chain_idx += 1 + except (KeyError, IndexError, TypeError): + pass + + # Parse devdetails by positional index + details_list: list = [] + try: + for detail in rpc_data["devdetails"][0]["DEVDETAILS"]: + details_list.append( + { + "chips": detail.get("Chips"), + "voltage": detail.get("Voltage"), + "frequency": detail.get("Frequency"), + } + ) + except (KeyError, IndexError, TypeError): + pass + + # Parse devs by positional index + devs_list: list = [] + try: + for dev in rpc_data["devs"][0]["DEVS"]: + devs_list.append({"mhs": dev.get("MHS 1m") or dev.get("MHS 5m") or dev.get("MHS av")}) + except (KeyError, IndexError, TypeError): + pass + + board_count = max(len(temps_list), len(details_list), len(devs_list)) + if board_count == 0: + return None + + hashboards = [] + for idx in range(board_count): + hb = HashBoard(slot=idx, expected_chips=miner.expected_chips) + hb.missing = False + + if idx < len(temps_list): + t = temps_list[idx] + if t.get("chip_temp") is not None: + hb.chip_temp = t["chip_temp"] + if t.get("board_temp") is not None: + hb.temp = t["board_temp"] + + if idx < len(details_list): + d = details_list[idx] + if d.get("chips") is not None: + hb.chips = d["chips"] + if d.get("voltage") is not None: + hb.voltage = round(float(d["voltage"]), 2) + + if idx < len(devs_list): + dev = devs_list[idx] + mhs = dev.get("mhs") + if mhs is not None: + hb.hashrate = miner.algo.hashrate(rate=mhs, unit=miner.algo.unit.MH) + + hashboards.append(hb) + + if self.logger: + self.logger.debug(f"RPC fallback: found {len(hashboards)} hashboards from {self.ip}") + return hashboards + + async def _derive_miner_status(self) -> Optional[bool]: + """Derives the miner status based on hashrate and power consumption. + + Returns True if miner is ON (both hashrate > 0 and power > IDLE_WATTAGE_THRESHOLD), + False if miner is OFF, or None if status cannot be determined. + + The IDLE_WATTAGE_THRESHOLD is set to a low value (1W) to distinguish between + truly off (near 0W) and on (consuming power, even for low-power miners like Bitaxe). + """ + IDLE_WATTAGE_THRESHOLD = 1 # Low threshold to work with low-power miners (e.g., Bitaxe ~13W) + + hashrate: Optional[HashRate] = await self.get_hashrate() + wattage: Optional[Watts] = await self.get_power() + + if self.logger: + self.logger.debug( + f"_derive_miner_status: hashrate={hashrate}, wattage={wattage}W (threshold: {IDLE_WATTAGE_THRESHOLD}W)" + ) + + # Miner is ON only if BOTH conditions are met: + # 1. Producing hashrate (hashrate > 0) + # 2. Consuming power above idle threshold (wattage > IDLE_WATTAGE_THRESHOLD) + has_hashrate = hashrate is not None and hashrate.value > 0 + has_power_consumption = wattage is not None and wattage > IDLE_WATTAGE_THRESHOLD + + if has_hashrate and has_power_consumption: + return True + + # If we have both values but conditions aren't met, miner is OFF + if hashrate is not None and wattage is not None: + return False + + # Can't determine status - missing hashrate or power data + return None + + def _normalize_hashrate_unit( + self, + value: float, + unit: str, + reference_unit: Optional[str] = None, + decimals: int = 2, + ) -> Tuple[float, str]: + """Normalize a hashrate to the most suitable unit. + + If `reference_unit` is provided and recognized, the value is converted to that unit. + Otherwise, it will be scaled to a human-friendly unit (e.g. 1000 H/s -> 1 KH/s). + """ + + unit = unit.strip() + reference_unit = reference_unit.strip() if reference_unit else None + + # Decimal scaling is used for hashrates (k=1000). + factors_to_hs = { + "H/s": 1.0, + "KH/s": 1e3, + "MH/s": 1e6, + "GH/s": 1e9, + "TH/s": 1e12, + "PH/s": 1e15, + "EH/s": 1e18, + } + + if unit not in factors_to_hs: + return (round(value, decimals) if decimals is not None else value), unit + + # Convert input to H/s + value_hs = value * factors_to_hs[unit] + + # Convert to a specific reference unit if requested + if reference_unit and reference_unit in factors_to_hs: + converted = value_hs / factors_to_hs[reference_unit] + return (round(converted, decimals) if decimals is not None else converted), reference_unit + + # Choose the most suitable unit (keep value in [1, 1000) when possible) + ordered_units = ["H/s", "KH/s", "MH/s", "GH/s", "TH/s", "PH/s", "EH/s"] + abs_value_hs = abs(value_hs) + chosen_unit = "H/s" + + for candidate_unit in ordered_units: + candidate_value = abs_value_hs / factors_to_hs[candidate_unit] + chosen_unit = candidate_unit + if candidate_value < 1000: + break + + converted = value_hs / factors_to_hs[chosen_unit] + return (round(converted, decimals) if decimals is not None else converted), chosen_unit diff --git a/core/edge_mining/adapters/domain/miner/fast_api/__init__.py b/core/edge_mining/adapters/domain/miner/fast_api/__init__.py new file mode 100644 index 0000000..8020c7c --- /dev/null +++ b/core/edge_mining/adapters/domain/miner/fast_api/__init__.py @@ -0,0 +1 @@ +"""Adapter that uses FastAPI infrastructure for miner domain API""" diff --git a/core/edge_mining/adapters/domain/miner/fast_api/router.py b/core/edge_mining/adapters/domain/miner/fast_api/router.py new file mode 100644 index 0000000..341d94c --- /dev/null +++ b/core/edge_mining/adapters/domain/miner/fast_api/router.py @@ -0,0 +1,719 @@ +"""API Router for miner domain""" + +import uuid +from typing import Annotated, Any, Dict, List, Optional, cast + +from fastapi import APIRouter, Depends, HTTPException + +from edge_mining.adapters.domain.miner.schemas import ( + MINER_CONTROLLER_CONFIG_SCHEMA_MAP, + FeaturePrioritySchema, + MinerControllerCreateSchema, + MinerControllerSchema, + MinerControllerUpdateSchema, + MinerCreateSchema, + MinerFeatureSchema, + MinerInfoSchema, + MinerLimitSchema, + MinerSchema, + MinerStateSnapshotSchema, + MinerUpdateSchema, +) + +# Import dependency injection setup functions +from edge_mining.adapters.infrastructure.api.setup import ( + get_adapter_service, + get_config_service, + get_miner_action_service, +) +from edge_mining.application.interfaces import ( + AdapterServiceInterface, + ConfigurationServiceInterface, + MinerActionServiceInterface, +) +from edge_mining.domain.common import EntityId, Watts +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.common import MinerControllerAdapter, MinerFeatureType +from edge_mining.domain.miner.exceptions import ( + MinerControllerAlreadyExistsError, + MinerControllerConfigurationError, + MinerControllerNotFoundError, + MinerNotFoundError, +) +from edge_mining.domain.notification.exceptions import NotifierNotFoundError +from edge_mining.domain.notification.ports import NotificationPort +from edge_mining.domain.optimization_unit.aggregate_roots import EnergyOptimizationUnit +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.interfaces.config import Configuration, MinerControllerConfig + +router = APIRouter() + + +@router.get("/miners", response_model=List[MinerSchema]) +async def get_miners_list( + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> List[MinerSchema]: + """Get a list of all configured miners.""" + try: + miners = config_service.list_miners() + + # Convert to miner schema + miner_schemas: List[MinerSchema] = [] + + for miner in miners: + miner_schemas.append(MinerSchema.from_model(miner)) + + return miner_schemas + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/miners/{miner_id}", response_model=MinerSchema) +async def get_miner_details( + miner_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MinerSchema: + """Get details for a specific miner.""" + try: + miner: Optional[Miner] = config_service.get_miner(miner_id) + + if miner is None: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found") + + response = MinerSchema.from_model(miner) + + return response + except MinerNotFoundError as e: # Catch specific domain errors if needed + raise HTTPException(status_code=404, detail="Miner not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/miners", response_model=MinerSchema) +async def add_miner( + miner_schema: MinerCreateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MinerSchema: + """Add a new miner.""" + try: + miner_to_add: Miner = miner_schema.to_model() + + new_miner = await config_service.add_miner( + name=miner_to_add.name, + model=miner_to_add.model, + hash_rate_max=miner_to_add.hash_rate_max, + power_consumption_max=miner_to_add.power_consumption_max, + ) + + response = MinerSchema.from_model(new_miner) + + return response + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.put("/miners/{miner_id}", response_model=MinerSchema) +async def update_miner( + miner_id: EntityId, + miner_update: MinerUpdateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MinerSchema: + """Update a miner's details.""" + try: + miner = config_service.get_miner(miner_id) + + if miner is None: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found") + + hash_rate_max = miner_update.hash_rate_max.to_model() if miner_update.hash_rate_max else None + power_consumption_max = ( + Watts(miner_update.power_consumption_max) if miner_update.power_consumption_max is not None else None + ) + + miner_updated = await config_service.update_miner( + miner_id=miner.id, + name=miner_update.name or "", + model=miner_update.model, + hash_rate_max=hash_rate_max, + power_consumption_max=power_consumption_max, + active=miner.active, + ) + + response = MinerSchema.from_model(miner_updated) + + return response + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.delete("/miners/{miner_id}", response_model=MinerSchema) +async def remove_miner( + miner_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MinerSchema: + """Remove a miner.""" + try: + deleted_miner = await config_service.remove_miner(miner_id) + + response = MinerSchema.from_model(deleted_miner) + + return response + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/miners/{miner_id}/start", response_model=MinerSchema) +async def start_miner( + miner_id: EntityId, + action_service: Annotated[MinerActionServiceInterface, Depends(get_miner_action_service)], + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], + adapter_service: Annotated[AdapterServiceInterface, Depends(get_adapter_service)], +) -> MinerSchema: + """Start a miner.""" + try: + # If the miner has been inserted into an optimization unit, we can get the notifiers of the unit (if any) + # and use them to notify the miner action + optimization_units: List[EnergyOptimizationUnit] = config_service.filter_optimization_units( + filter_by_miners=[miner_id] + ) + notifiers: List[NotificationPort] = [] + if optimization_units is not None and len(optimization_units) > 0: + notifier_ids: List[EntityId] = [] + # Extract notifier IDs from optimization units + for unit in optimization_units: + if unit.notifier_ids: + notifier_ids.extend(unit.notifier_ids) + + # Remove duplicates + notifier_ids = list(set(notifier_ids)) + + if notifier_ids: + # Get the notifiers from the configuration service + for notifier_id in notifier_ids: + try: + notifier = await adapter_service.get_notifier(notifier_id) + if notifier is not None: + notifiers.append(notifier) + except NotifierNotFoundError: + continue + + success = await action_service.start_miner(miner_id, notifiers) + + if success: + miner = config_service.get_miner(miner_id) + + if miner is None: + raise MinerNotFoundError(f"Miner with ID {miner_id} can not be started") + + response = MinerSchema.from_model(miner) + + return response + else: + raise HTTPException(status_code=500, detail="Failed to start miner") + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/miners/{miner_id}/stop", response_model=MinerSchema) +async def stop_miner( + miner_id: EntityId, + action_service: Annotated[MinerActionServiceInterface, Depends(get_miner_action_service)], + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], + adapter_service: Annotated[AdapterServiceInterface, Depends(get_adapter_service)], +) -> MinerSchema: + """Stop a miner.""" + try: + # If the miner has been inserted into an optimization unit, we can get the notifiers of the unit (if any) + # and use them to notify the miner action + optimization_units: List[EnergyOptimizationUnit] = config_service.filter_optimization_units( + filter_by_miners=[miner_id] + ) + notifiers: List[NotificationPort] = [] + if optimization_units is not None and len(optimization_units) > 0: + notifier_ids: List[EntityId] = [] + # Extract notifier IDs from optimization units + for unit in optimization_units: + if unit.notifier_ids: + notifier_ids.extend(unit.notifier_ids) + + # Remove duplicates + notifier_ids = list(set(notifier_ids)) + + if notifier_ids: + # Get the notifiers from the configuration service + for notifier_id in notifier_ids: + try: + notifier = await adapter_service.get_notifier(notifier_id) + if notifier is not None: + notifiers.append(notifier) + except NotifierNotFoundError: + continue + + success = await action_service.stop_miner(miner_id, notifiers) + + if success: + miner = config_service.get_miner(miner_id) + + if miner is None: + raise MinerNotFoundError(f"Miner with ID {miner_id} can not be stopped") + + response = MinerSchema.from_model(miner) + + return response + else: + raise HTTPException(status_code=500, detail="Failed to stop miner") + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/miners/{miner_id}/status", response_model=MinerStateSnapshotSchema) +async def get_miner_status( + miner_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], + action_service: Annotated[MinerActionServiceInterface, Depends(get_miner_action_service)], +) -> MinerStateSnapshotSchema: + """Get the current status of a miner.""" + try: + snapshot = await action_service.get_miner_status(miner_id) + + if snapshot is None: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found") + + response = MinerStateSnapshotSchema.from_model(snapshot) + + return response + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/miners/{miner_id}/info", response_model=Optional[MinerInfoSchema]) +async def get_miner_info( + miner_id: EntityId, + action_service: Annotated[MinerActionServiceInterface, Depends(get_miner_action_service)], +) -> Optional[MinerInfoSchema]: + """Get device information for a specific miner.""" + try: + info = await action_service.get_miner_info(miner_id) + + if info is None: + return None + + return MinerInfoSchema.from_model(info) + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except MinerControllerConfigurationError as e: + raise HTTPException(status_code=422, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/miners/{miner_id}/limits", response_model=Optional[MinerLimitSchema]) +async def get_miner_limits( + miner_id: EntityId, + action_service: Annotated[MinerActionServiceInterface, Depends(get_miner_action_service)], +) -> Optional[MinerLimitSchema]: + """Get limits for a specific miner.""" + try: + limits = await action_service.get_miner_limits(miner_id) + + return MinerLimitSchema.from_model(limits) + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except MinerControllerConfigurationError as e: + raise HTTPException(status_code=422, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/miners/{miner_id}/activate", response_model=MinerSchema) +async def activate_miner( + miner_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MinerSchema: + """Activate a miner.""" + try: + miner = await config_service.activate_miner(miner_id) + + response = MinerSchema.from_model(miner) + + return response + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/miners/{miner_id}/deactivate", response_model=MinerSchema) +async def deactivate_miner( + miner_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MinerSchema: + """Deactivate a miner.""" + try: + miner = await config_service.deactivate_miner(miner_id) + + response = MinerSchema.from_model(miner) + + return response + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/miners/{miner_id}/features", response_model=List[MinerFeatureSchema]) +async def get_miner_features( + miner_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> List[MinerFeatureSchema]: + """Get all features associated with a miner.""" + try: + miner = config_service.get_miner(miner_id) + + if miner is None: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found") + + return [MinerFeatureSchema.from_model(feature) for feature in miner.features] + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/miners/{miner_id}/features/{controller_id}/{feature_type}/enable", response_model=MinerSchema) +async def enable_miner_feature( + miner_id: EntityId, + controller_id: EntityId, + feature_type: str, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MinerSchema: + """Enable a specific feature on a miner.""" + try: + ft = MinerFeatureType(feature_type) + miner = await config_service.enable_miner_feature(miner_id, controller_id, ft) + return MinerSchema.from_model(miner) + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid feature type: {feature_type}") from e + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/miners/{miner_id}/features/{controller_id}/{feature_type}/disable", response_model=MinerSchema) +async def disable_miner_feature( + miner_id: EntityId, + controller_id: EntityId, + feature_type: str, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MinerSchema: + """Disable a specific feature on a miner.""" + try: + ft = MinerFeatureType(feature_type) + miner = await config_service.disable_miner_feature(miner_id, controller_id, ft) + return MinerSchema.from_model(miner) + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid feature type: {feature_type}") from e + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.put("/miners/{miner_id}/features/{controller_id}/{feature_type}/priority", response_model=MinerSchema) +async def set_miner_feature_priority( + miner_id: EntityId, + controller_id: EntityId, + feature_type: str, + body: FeaturePrioritySchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MinerSchema: + """Set the priority of a specific feature on a miner.""" + try: + ft = MinerFeatureType(feature_type) + miner = await config_service.set_miner_feature_priority(miner_id, controller_id, ft, body.priority) + return MinerSchema.from_model(miner) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/miners/{miner_id}/set-controller", response_model=MinerSchema) +async def set_miner_controller( + miner_id: EntityId, + controller_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MinerSchema: + """Associate a controller to a miner, auto-creating features for all supported feature types.""" + try: + await config_service.set_miner_controller(controller_id, miner_id) + + # Re-read the miner to get updated features + miner = config_service.get_miner(miner_id) + + if miner is None: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found") + + response = MinerSchema.from_model(miner) + + return response + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except MinerControllerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner controller not found") from e + except MinerControllerConfigurationError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/miners/{miner_id}/unlink-controller", response_model=MinerSchema) +async def unlink_controller_from_miner( + miner_id: EntityId, + controller_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MinerSchema: + """Remove all features provided by a controller from a miner.""" + try: + await config_service.unlink_controller_from_miner(controller_id, miner_id) + + miner = config_service.get_miner(miner_id) + + if miner is None: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found") + + response = MinerSchema.from_model(miner) + + return response + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/miner-controllers", response_model=List[MinerControllerSchema]) +async def get_miner_controllers( + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> List[MinerControllerSchema]: + """Get a list of all miner controllers.""" + try: + controllers = config_service.list_miner_controllers() + + # Convert to controller schema + controller_schemas: List[MinerControllerSchema] = [] + + for controller in controllers: + controller_schemas.append(MinerControllerSchema.from_model(controller)) + + return controller_schemas + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/miner-controllers", response_model=MinerControllerSchema) +async def add_miner_controller( + controller_schema: MinerControllerCreateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MinerControllerSchema: + """Add a new miner controller.""" + try: + controller_to_add = controller_schema.to_model() + + if controller_to_add.config is None: + raise MinerControllerConfigurationError("Miner controller configuration should be set") + + new_controller = await config_service.add_miner_controller( + name=controller_to_add.name, + adapter=controller_to_add.adapter_type, + config=controller_to_add.config, + external_service_id=controller_to_add.external_service_id, + ) + + response = MinerControllerSchema.from_model(new_controller) + + return response + except MinerControllerAlreadyExistsError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except MinerControllerConfigurationError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/miner-controllers/types", response_model=List[MinerControllerAdapter]) +async def get_miner_controller_types() -> List[MinerControllerAdapter]: + """Get a list of available miner controller types.""" + try: + return [MinerControllerAdapter(adapter.value) for adapter in MinerControllerAdapter] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get( + "/miner-controllers/types/{adapter_type}/config-schema", + response_model=Dict[str, Any], +) +async def get_miner_controller_config_schema( + adapter_type: MinerControllerAdapter, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> Dict[str, Any]: + """Get the configuration schema for a specific miner controller type.""" + try: + try: + miner_controller_adapter = MinerControllerAdapter(adapter_type) + except ValueError as e: + raise ValueError(f"Invalid miner controller adapter type: {adapter_type}") from e + + # Get the corresponding configuration class for the adapter type + miner_controller_config_type: Optional[type[MinerControllerConfig]] = ( + config_service.get_miner_controller_config_by_type(miner_controller_adapter) + ) + + if miner_controller_config_type is None: + raise ValueError(f"No configuration class found for adapter type: {adapter_type}") + + # Map the configuration class to its corresponding schema + miner_controller_config_schema = MINER_CONTROLLER_CONFIG_SCHEMA_MAP.get(miner_controller_config_type, None) + + if miner_controller_config_schema is None: + raise ValueError(f"No schema found for miner controller config class: {miner_controller_config_type}") + + return miner_controller_config_schema.model_json_schema() + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get( + "/miner-controllers/types/{adapter_type}/external-services", + response_model=Optional[ExternalServiceAdapter], +) +async def get_miner_controller_type_external_service_types( + adapter_type: MinerControllerAdapter, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> Optional[ExternalServiceAdapter]: + """Get a list of compatible external service types for a specific miner controller type.""" + try: + needed_external_service = config_service.get_miner_controller_external_service_adapter(adapter_type) + + return needed_external_service + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/miner-controllers/{controller_id}", response_model=MinerControllerSchema) +async def get_miner_controller( + controller_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MinerControllerSchema: + """Get details for a specific miner controller.""" + try: + controller = config_service.get_miner_controller(controller_id) + + if controller is None: + raise MinerControllerNotFoundError(f"Miner controller with ID {controller_id} not found") + + response = MinerControllerSchema.from_model(controller) + + return response + except MinerControllerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner controller not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/miner-controllers/{controller_id}/miner-details", response_model=MinerStateSnapshotSchema) +async def get_miner_details_from_controller( + controller_id: EntityId, + action_service: Annotated[MinerActionServiceInterface, Depends(get_miner_action_service)], +) -> MinerStateSnapshotSchema: + """Get miner details directly from a specific controller.""" + try: + snapshot = await action_service.get_miner_details_from_controller(controller_id) + + if snapshot is None: + raise MinerControllerNotFoundError(f"Miner controller with ID {controller_id} not found") + + response = MinerStateSnapshotSchema.from_model(snapshot) + + return response + except MinerControllerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner controller not found") from e + except MinerControllerConfigurationError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.put("/miner-controllers/{controller_id}", response_model=MinerControllerSchema) +async def update_miner_controller( + controller_id: EntityId, + controller_update: MinerControllerUpdateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MinerControllerSchema: + """Update an existing miner controller""" + try: + controller = config_service.get_miner_controller(controller_id) + + if controller is None: + raise MinerControllerNotFoundError(f"Miner controller with ID {controller_id} not found") + + configuration: Optional[Configuration] = None + if controller_update.config: + config_cls = config_service.get_miner_controller_config_by_type(controller.adapter_type) + if config_cls is None: + raise MinerControllerConfigurationError( + f"No configuration class found for adapter typ {controller.adapter_type}" + ) + configuration = config_cls.from_dict(controller_update.config) + + external_service_id: Optional[EntityId] = None + if controller_update.external_service_id: + external_service_id = EntityId(uuid.UUID(controller_update.external_service_id)) + + updated_controller = await config_service.update_miner_controller( + controller_id=controller.id, + name=controller_update.name or "", + config=cast(MinerControllerConfig, configuration), + external_service_id=external_service_id, + ) + + response = MinerControllerSchema.from_model(updated_controller) + + return response + except MinerControllerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner controller not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.delete("/miner-controllers/{controller_id}", response_model=MinerControllerSchema) +async def remove_miner_controller( + controller_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MinerControllerSchema: + """Remove a miner controller.""" + try: + deleted_controller = await config_service.remove_miner_controller(controller_id) + + response = MinerControllerSchema.from_model(deleted_controller) + + return response + except MinerControllerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner controller not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e diff --git a/core/edge_mining/adapters/domain/miner/repositories.py b/core/edge_mining/adapters/domain/miner/repositories.py new file mode 100644 index 0000000..8ae0d8c --- /dev/null +++ b/core/edge_mining/adapters/domain/miner/repositories.py @@ -0,0 +1,869 @@ +"""Repositories for the Miner domain.""" + +import copy +import json +import sqlite3 +from typing import Any, Dict, List, Optional + +from sqlalchemy import select + +from edge_mining.adapters.domain.miner.tables import ( + delete_features_for_miner, + load_features_for_miner, + miner_controllers_table, + miner_features_table, + miners_table, + save_features_for_miner, +) +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.adapters.infrastructure.persistence.sqlite import BaseSqliteRepository +from edge_mining.domain.common import EntityId, Watts +from edge_mining.domain.miner.common import MinerControllerAdapter, MinerFeatureType +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.entities import MinerController +from edge_mining.domain.miner.exceptions import ( + MinerControllerAlreadyExistsError, + MinerControllerConfigurationError, + MinerControllerError, + MinerControllerNotFoundError, + MinerError, +) +from edge_mining.domain.miner.ports import MinerControllerRepository, MinerRepository +from edge_mining.domain.miner.value_objects import HashRate, MinerFeature +from edge_mining.shared.adapter_maps.miner import MINER_CONTROLLER_CONFIG_TYPE_MAP +from edge_mining.shared.interfaces.config import MinerControllerConfig + +# Simple In-Memory implementation for testing and basic use + + +class InMemoryMinerRepository(MinerRepository): + """In-Memory implementation for the Miner Repository.""" + + def __init__(self, initial_miners: Optional[Dict[EntityId, Miner]] = None): + self._miners: Dict[EntityId, Miner] = copy.deepcopy(initial_miners) if initial_miners else {} + + def add(self, miner: Miner) -> None: + """Add a miner to the In-Memory repository.""" + if miner.id in self._miners: + # Handle update or raise error depending on desired behavior + print(f"Warning: Miner {miner.id} already exists, overwriting.") + self._miners[miner.id] = copy.deepcopy(miner) + + def get_by_id(self, miner_id: EntityId) -> Optional[Miner]: + """Get a miner by ID from the In-Memory repository.""" + return copy.deepcopy(self._miners.get(miner_id)) + + def get_all(self) -> List[Miner]: + """Get all miners from the In-Memory repository.""" + return [copy.deepcopy(m) for m in self._miners.values()] + + def update(self, miner: Miner) -> None: + """Update a miner in the In-Memory repository.""" + if miner.id not in self._miners: + raise ValueError(f"Miner {miner.id} not found for update.") + self._miners[miner.id] = copy.deepcopy(miner) + + def remove(self, miner_id: EntityId) -> None: + """Remove a miner from the In-Memory repository.""" + if miner_id in self._miners: + del self._miners[miner_id] + + def get_by_controller_id(self, controller_id: EntityId) -> List[Miner]: + """Get all miners that have at least one feature provided by the given controller.""" + if not controller_id: + return [] + return [ + copy.deepcopy(m) for m in self._miners.values() if any(f.controller_id == controller_id for f in m.features) + ] + + +class SqliteMinerRepository(MinerRepository): + """SQLite implementation for the Miner Repository.""" + + TABLE_NAME = "miners" + FEATURES_TABLE_NAME = "miner_features" + + SCHEMA = { + "id": "TEXT PRIMARY KEY", + "name": "TEXT NOT NULL", + "model": "TEXT", + "active": "INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1))", + "hash_rate_max": "TEXT", + "power_consumption_max": "REAL", + } + + FEATURES_SCHEMA = { + "id": "INTEGER PRIMARY KEY AUTOINCREMENT", + "miner_id": "TEXT NOT NULL", + "controller_id": "TEXT NOT NULL", + "feature_type": "TEXT NOT NULL", + "priority": "INTEGER NOT NULL DEFAULT 50", + "enabled": "INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1))", + } + + def __init__(self, db: BaseSqliteRepository): + self._db = db + self.logger = db.logger + + self._db.create_tables(table_name=self.TABLE_NAME, schema=self.SCHEMA) + self._db.create_tables(table_name=self.FEATURES_TABLE_NAME, schema=self.FEATURES_SCHEMA) + + def _dict_to_hashrate(self, data: Dict[str, Any]) -> HashRate: + """Deserialize a dictionary (from JSON) into an HashRate object.""" + return HashRate(value=float(data["value"]), unit=data["unit"]) + + def _hashrate_to_dict(self, hash_rate: Optional[HashRate]) -> Dict[str, Any]: + """Serializes an HashRate object into a dictionary for JSON.""" + return { + "value": hash_rate.value if hash_rate else 0, + "unit": hash_rate.unit if hash_rate else "TH/s", + } + + def _row_to_miner(self, row: sqlite3.Row, conn: Optional[sqlite3.Connection] = None) -> Optional[Miner]: + """Deserialize a row from the database into a Miner object.""" + if not row: + return None + try: + hash_rate_max_data = json.loads(row["hash_rate_max"]) if row["hash_rate_max"] else None + hash_rate_max = self._dict_to_hashrate(hash_rate_max_data) if hash_rate_max_data else None + + miner_id = EntityId(row["id"]) + features: List[MinerFeature] = [] + if conn: + features = self._load_features(conn, miner_id) + + return Miner( + id=miner_id, + name=row["name"] if row["name"] is not None else "", + model=row["model"] if row["model"] is not None else None, + active=(row["active"] == 1 if row["active"] is not None else False), + hash_rate_max=hash_rate_max, + power_consumption_max=( + Watts(row["power_consumption_max"]) if row["power_consumption_max"] is not None else None + ), + features=features, + ) + except (ValueError, KeyError) as e: + self.logger.error(f"Error deserializing Miner from DB row: {row}. Error: {e}") + return None + + def _load_features(self, conn: sqlite3.Connection, miner_id: EntityId) -> List[MinerFeature]: + """Load features for a miner from the features table.""" + sql = f"SELECT * FROM {self.FEATURES_TABLE_NAME} WHERE miner_id = ?" + cursor = conn.cursor() + cursor.execute(sql, (str(miner_id),)) + rows = cursor.fetchall() + features = [] + for r in rows: + features.append( + MinerFeature( + feature_type=MinerFeatureType(r["feature_type"]), + controller_id=EntityId(r["controller_id"]), + priority=r["priority"], + enabled=bool(r["enabled"]), + ) + ) + return features + + def _save_features(self, conn: sqlite3.Connection, miner_id: EntityId, features: List[MinerFeature]) -> None: + """Replace all features for a miner.""" + conn.execute(f"DELETE FROM {self.FEATURES_TABLE_NAME} WHERE miner_id = ?", (str(miner_id),)) + for f in features: + conn.execute( + f"INSERT INTO {self.FEATURES_TABLE_NAME} (miner_id, controller_id, feature_type, priority, enabled) VALUES (?, ?, ?, ?, ?)", + (str(miner_id), str(f.controller_id), f.feature_type.value, f.priority, int(f.enabled)), + ) + + def add(self, miner: Miner) -> None: + """Add a miner to the SQLite database.""" + self.logger.debug(f"Adding miner {miner.id} to SQLite.") + + sql = f""" + INSERT INTO {self.TABLE_NAME} (id, name, model, active, hash_rate_max, power_consumption_max) + VALUES (?, ?, ?, ?, ?, ?) + """ + conn = self._db.get_connection() + try: + hash_rate_max_json = json.dumps(self._hashrate_to_dict(miner.hash_rate_max)) + + with conn: + conn.execute( + sql, + ( + miner.id, + miner.name, + miner.model, + miner.active, + hash_rate_max_json, + (float(miner.power_consumption_max) if miner.power_consumption_max is not None else 0.0), + ), + ) + self._save_features(conn, miner.id, miner.features) + except sqlite3.IntegrityError as e: + self.logger.error(f"Integrity error adding miner {miner.id}: {e}") + # Could mean that the ID already exists + raise MinerError(f"Miner with ID {miner.id} already exists or constraint violation: {e}") from e + except sqlite3.Error as e: + self.logger.error(f"SQLite error adding miner {miner.id}: {e}") + raise MinerError(f"DB error adding miner: {e}") from e + finally: + if conn: + conn.close() + + def get_by_id(self, miner_id: EntityId) -> Optional[Miner]: + """Get a miner by ID from the SQLite database.""" + self.logger.debug(f"Getting miner {miner_id} from SQLite.") + + sql = f"SELECT * FROM {self.TABLE_NAME} WHERE id = ?" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (miner_id,)) + row = cursor.fetchone() + return self._row_to_miner(row, conn) + except sqlite3.Error as e: + self.logger.error(f"SQLite error getting miner {miner_id}: {e}") + return None + finally: + if conn: + conn.close() + + def get_all(self) -> List[Miner]: + """Get all miners from the SQLite database.""" + self.logger.debug("Getting all miners from SQLite.") + + sql = f"SELECT * FROM {self.TABLE_NAME}" + conn = self._db.get_connection() + miners = [] + try: + cursor = conn.cursor() + cursor.execute(sql) + rows = cursor.fetchall() + for row in rows: + miner = self._row_to_miner(row, conn) + if miner: + miners.append(miner) + except sqlite3.Error as e: + self.logger.error(f"SQLite error getting all miners: {e}") + return [] + finally: + if conn: + conn.close() + return miners + + def update(self, miner: Miner) -> None: + """Update a miner in the SQLite database.""" + self.logger.debug(f"Updating miner {miner.id} in SQLite.") + + sql = f""" + UPDATE {self.TABLE_NAME} + SET name = ?, model = ?, active = ?, hash_rate_max = ?, power_consumption_max = ? + WHERE id = ? + """ + conn = self._db.get_connection() + try: + hash_rate_max_json = json.dumps(self._hashrate_to_dict(miner.hash_rate_max)) + + with conn: + cursor = conn.cursor() + cursor.execute( + sql, + ( + miner.name, + miner.model, + miner.active, + hash_rate_max_json, + (float(miner.power_consumption_max) if miner.power_consumption_max is not None else 0.0), + miner.id, + ), + ) + if cursor.rowcount == 0: + raise MinerError(f"No miner found with ID {miner.id} for update.") + self._save_features(conn, miner.id, miner.features) + except sqlite3.Error as e: + self.logger.error(f"SQLite error updating miner {miner.id}: {e}") + raise MinerError(f"DB error updating miner: {e}") from e + finally: + if conn: + conn.close() + + def remove(self, miner_id: EntityId) -> None: + """Remove a miner from the SQLite database.""" + self.logger.debug(f"Removing miner {miner_id} from SQLite.") + + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + # Delete features first + cursor.execute(f"DELETE FROM {self.FEATURES_TABLE_NAME} WHERE miner_id = ?", (str(miner_id),)) + cursor.execute(f"DELETE FROM {self.TABLE_NAME} WHERE id = ?", (str(miner_id),)) + if cursor.rowcount == 0: + self.logger.warning(f"Attempt to remove non-existent miner with ID {miner_id}.") + except sqlite3.Error as e: + self.logger.error(f"SQLite error removing miner {miner_id}: {e}") + raise MinerError(f"DB error removing miner: {e}") from e + finally: + if conn: + conn.close() + + def get_by_controller_id(self, controller_id: EntityId) -> List[Miner]: + """Get all miners that have at least one feature provided by the given controller.""" + self.logger.debug(f"Getting miners by controller ID {controller_id} from SQLite.") + + sql = f""" + SELECT DISTINCT m.* FROM {self.TABLE_NAME} m + INNER JOIN {self.FEATURES_TABLE_NAME} f ON m.id = f.miner_id + WHERE f.controller_id = ? + """ + conn = self._db.get_connection() + miners = [] + try: + cursor = conn.cursor() + cursor.execute(sql, (str(controller_id),)) + rows = cursor.fetchall() + for row in rows: + miner = self._row_to_miner(row, conn) + if miner: + miners.append(miner) + return miners + except sqlite3.Error as e: + self.logger.error(f"SQLite error getting miners by controller ID {controller_id}: {e}") + return [] + finally: + if conn: + conn.close() + + +class SqlAlchemyMinerRepository(MinerRepository): + """SQLAlchemy-based implementation of the MinerRepository port. + + Features are persisted in the miner_features table and loaded/saved + separately from the Miner entity (which is mapped without the features field). + """ + + def __init__(self, db: BaseSQLAlchemyRepository): + self._db = db + self.logger = db.logger + + def _populate_features(self, session, miner: Miner) -> Miner: + """Load features from DB and attach to the miner entity.""" + miner.features = load_features_for_miner(session, miner.id) + return miner + + def add(self, miner: Miner) -> None: + """Add a new miner to the repository.""" + session = self._db.get_session() + try: + features = list(miner.features) + session.add(miner) + session.flush() + save_features_for_miner(session, miner.id, features) + session.commit() + miner.features = features + finally: + session.close() + + def get_by_id(self, miner_id: EntityId) -> Optional[Miner]: + """Retrieve a miner by its ID.""" + session = self._db.get_session() + try: + stmt = select(Miner).where(miners_table.c.id == str(miner_id)) + entity = session.execute(stmt).scalar_one_or_none() + if entity: + self._populate_features(session, entity) + return entity + finally: + session.close() + + def get_all(self) -> List[Miner]: + """Retrieve all miners from the repository.""" + session = self._db.get_session() + try: + stmt = select(Miner) + entities = session.execute(stmt).scalars().all() + for entity in entities: + self._populate_features(session, entity) + return list(entities) + finally: + session.close() + + def update(self, miner: Miner) -> None: + """Update an existing miner in the repository.""" + session = self._db.get_session() + try: + stmt = select(Miner).where(miners_table.c.id == str(miner.id)) + existing_entity = session.execute(stmt).scalar_one_or_none() + + if existing_entity: + existing_entity.name = miner.name + existing_entity.model = miner.model + existing_entity.active = miner.active + existing_entity.hash_rate_max = miner.hash_rate_max + existing_entity.power_consumption_max = miner.power_consumption_max + + save_features_for_miner(session, miner.id, miner.features) + session.commit() + existing_entity.features = list(miner.features) + finally: + session.close() + + def remove(self, miner_id: EntityId) -> None: + """Remove a miner from the repository.""" + session = self._db.get_session() + try: + delete_features_for_miner(session, miner_id) + stmt = select(Miner).where(miners_table.c.id == str(miner_id)) + entity = session.execute(stmt).scalar_one_or_none() + + if entity: + session.delete(entity) + session.commit() + finally: + session.close() + + def get_by_controller_id(self, controller_id: EntityId) -> List[Miner]: + """Retrieve all miners that have at least one feature from the given controller.""" + session = self._db.get_session() + try: + # Subquery: distinct miner_ids from miner_features where controller matches + subq = ( + select(miner_features_table.c.miner_id) + .where(miner_features_table.c.controller_id == str(controller_id)) + .distinct() + .subquery() + ) + stmt = select(Miner).where(miners_table.c.id.in_(select(subq))) + entities = session.execute(stmt).scalars().all() + for entity in entities: + self._populate_features(session, entity) + return list(entities) + finally: + session.close() + + +class InMemoryMinerControllerRepository(MinerControllerRepository): + """In-Memory implementation for the Miner Controller Repository.""" + + def __init__( + self, + initial_miner_controllers: Optional[Dict[EntityId, MinerController]] = None, + ): + self._miner_controllers: Dict[EntityId, MinerController] = ( + copy.deepcopy(initial_miner_controllers) if initial_miner_controllers else {} + ) + + def add(self, miner_controller: MinerController) -> None: + """Add a miner controller to the In-Memory repository.""" + if miner_controller.id in self._miner_controllers: + # Handle update or raise error depending on desired behavior + print(f"Warning: Miner Controller {miner_controller.id} already exists, overwriting.") + self._miner_controllers[miner_controller.id] = copy.deepcopy(miner_controller) + + def get_by_id(self, miner_controller_id: EntityId) -> Optional[MinerController]: + """Get a miner controller by ID from the In-Memory repository.""" + return copy.deepcopy(self._miner_controllers.get(miner_controller_id)) + + def get_all(self) -> List[MinerController]: + """Get all miner controllers from the In-Memory repository.""" + return [copy.deepcopy(m) for m in self._miner_controllers.values()] + + def update(self, miner_controller: MinerController) -> None: + """Update a miner controller in the In-Memory repository.""" + if miner_controller.id not in self._miner_controllers: + raise ValueError(f"Miner Controller {miner_controller.id} not found for update.") + self._miner_controllers[miner_controller.id] = copy.deepcopy(miner_controller) + + def remove(self, miner_controller_id: EntityId) -> None: + """Remove a miner controller from the In-Memory repository.""" + if miner_controller_id in self._miner_controllers: + del self._miner_controllers[miner_controller_id] + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[MinerController]: + """Get all miner controllers associated with a specific external service ID.""" + return ( + [ + copy.deepcopy(mc) + for mc in self._miner_controllers.values() + if mc.external_service_id == external_service_id + ] + if external_service_id + else [] + ) + + +class SqliteMinerControllerRepository(MinerControllerRepository): + """SQLite implementation for the Miner Controller Repository.""" + + TABLE_NAME = "miner_controllers" + + # Declarative schema definition + # NOTE: If you modify SCHEMA, update BaseSqliteRepository.CURRENT_DB_VERSION + SCHEMA = { + "id": "TEXT PRIMARY KEY", + "name": "TEXT NOT NULL", + "adapter_type": "TEXT NOT NULL", + "config": "TEXT", # JSON object of config + "external_service_id": "TEXT", # Optional ID for external service integration + } + + def __init__(self, db: BaseSqliteRepository): + self._db = db + self.logger = db.logger + + # BaseSqliteRepository generates CREATE TABLE SQL automatically + self._db.create_tables( + table_name=self.TABLE_NAME, + schema=self.SCHEMA, + ) + + def _deserialize_config(self, adapter_type: MinerControllerAdapter, config_json: str) -> MinerControllerConfig: + """Deserialize a JSON string into MinerControllerConfig object.""" + data: dict = json.loads(config_json) + + if adapter_type not in MINER_CONTROLLER_CONFIG_TYPE_MAP: + raise MinerControllerConfigurationError( + f"Error reading MinerController configuration. Invalid type '{adapter_type}'" + ) + + config_class: Optional[type[MinerControllerConfig]] = MINER_CONTROLLER_CONFIG_TYPE_MAP.get(adapter_type) + if not config_class: + raise MinerControllerConfigurationError( + f"Error creating MinerController configuration. Type '{adapter_type}'" + ) + + config_instance = config_class.from_dict(data) + if not isinstance(config_instance, MinerControllerConfig): + raise MinerControllerConfigurationError( + f"Deserialized config is not of type MinerControllerConfig for adapter type {adapter_type}." + ) + return config_instance + + def _row_to_miner_controller(self, row: sqlite3.Row) -> Optional[MinerController]: + """Deserialize a row from the database into a MinerController object.""" + if not row: + return None + try: + miner_controller_type = MinerControllerAdapter(row["adapter_type"]) + + # Deserialize the config from the database row + config = self._deserialize_config(miner_controller_type, row["config"]) + + return MinerController( + id=EntityId(row["id"]), + name=row["name"], + adapter_type=miner_controller_type, + config=config, + external_service_id=(EntityId(row["external_service_id"]) if row["external_service_id"] else None), + ) + except (ValueError, KeyError) as e: + self.logger.error(f"Error deserializing MinerController from DB row: {row}. Error: {e}") + return None + + def add(self, miner_controller: MinerController) -> None: + """Add a miner controller to the SQLite database.""" + self.logger.debug(f"Adding miner controller {miner_controller.id} to SQLite.") + + sql = f""" + INSERT INTO {self.TABLE_NAME} (id, name, adapter_type, config, external_service_id) + VALUES (?, ?, ?, ?, ?) + """ + conn = self._db.get_connection() + try: + # Serialize config to JSON for storage + config_json: str = "" + if miner_controller.config: + config_json = json.dumps(miner_controller.config.to_dict()) + + with conn: + cursor = conn.cursor() + cursor.execute( + sql, + ( + miner_controller.id, + miner_controller.name, + miner_controller.adapter_type.value, + config_json, + miner_controller.external_service_id, + ), + ) + except sqlite3.IntegrityError as e: + self.logger.error(f"Integrity error adding miner controller {miner_controller.id}: {e}") + # Could mean that the ID already exists + raise MinerControllerAlreadyExistsError( + f"Miner Controller with ID {miner_controller.id} already exists or constraint violation: {e}" + ) from e + except sqlite3.Error as e: + self.logger.error(f"SQLite error adding miner controller {miner_controller.id}: {e}") + raise MinerControllerError(f"DB error adding miner controller: {e}") from e + finally: + if conn: + conn.close() + + def get_by_id(self, miner_controller_id: EntityId) -> Optional[MinerController]: + """Get a miner controller by ID from the SQLite database.""" + self.logger.debug(f"Getting miner controller {miner_controller_id} from SQLite.") + + sql = f"SELECT * FROM {self.TABLE_NAME} WHERE id = ?;" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (miner_controller_id,)) + row = cursor.fetchone() + return self._row_to_miner_controller(row) + except sqlite3.Error as e: + self.logger.error(f"SQLite error getting miner controller {miner_controller_id}: {e}") + return None # Or raise exception? Returning None is more forgiving + finally: + if conn: + conn.close() + + def get_all(self) -> List[MinerController]: + """Get all miner controllers from the SQLite database.""" + self.logger.debug("Getting all miner controllers from SQLite.") + + sql = f"SELECT * FROM {self.TABLE_NAME}" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql) + rows = cursor.fetchall() + miner_controllers = [] + for row in rows: + miner_controller = self._row_to_miner_controller(row) + if miner_controller: + miner_controllers.append(miner_controller) + except sqlite3.Error as e: + self.logger.error(f"SQLite error getting all miner controllers: {e}") + return [] + finally: + if conn: + conn.close() + return miner_controllers + + def update(self, miner_controller: MinerController) -> None: + """Update a miner controller in the SQLite database.""" + self.logger.debug(f"Updating miner controller {miner_controller.id} in SQLite.") + + sql = f""" + UPDATE {self.TABLE_NAME} + SET name = ?, adapter_type = ?, config = ?, external_service_id = ? + WHERE id = ? + """ + conn = self._db.get_connection() + try: + # Serialize config to JSON for storage + config_json: str = "" + if miner_controller.config: + config_json = json.dumps(miner_controller.config.to_dict()) + + with conn: + cursor = conn.cursor() + cursor.execute( + sql, + ( + miner_controller.name, + miner_controller.adapter_type.value, + config_json, + miner_controller.external_service_id, + miner_controller.id, + ), + ) + if cursor.rowcount == 0: + raise MinerControllerNotFoundError( + f"No miner controller found with ID {miner_controller.id} for update." + ) + except sqlite3.Error as e: + self.logger.error(f"SQLite error updating miner controller {miner_controller.id}: {e}") + raise MinerControllerError(f"DB error updating miner controller: {e}") from e + finally: + if conn: + conn.close() + + def remove(self, miner_controller_id: EntityId) -> None: + """Remove a miner controller from the SQLite database.""" + self.logger.debug(f"Removing miner controller {miner_controller_id} from SQLite.") + + sql = f"DELETE FROM {self.TABLE_NAME} WHERE id = ?" + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + cursor.execute(sql, (miner_controller_id,)) + if cursor.rowcount == 0: + self.logger.warning( + f"Attempt to remove non-existent miner controller with ID {miner_controller_id}." + ) + # There is no need to raise an exception here, removing a + # non-existent is idempotent. + except sqlite3.Error as e: + self.logger.error(f"SQLite error removing miner controller {miner_controller_id}: {e}") + raise MinerControllerError(f"DB error removing miner controller: {e}") from e + finally: + if conn: + conn.close() + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[MinerController]: + """Get all miner controllers associated with a specific external service ID.""" + self.logger.debug(f"Getting miner controllers for external service ID {external_service_id} from SQLite.") + + sql = f"SELECT * FROM {self.TABLE_NAME} WHERE external_service_id = ?" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (external_service_id,)) + rows = cursor.fetchall() + miner_controllers = [] + for row in rows: + miner_controller = self._row_to_miner_controller(row) + if miner_controller: + miner_controllers.append(miner_controller) + return miner_controllers + except sqlite3.Error as e: + self.logger.error( + f"SQLite error getting miner controllers by external service ID {external_service_id}: {e}" + ) + return [] + finally: + if conn: + conn.close() + + +class SqlAlchemyMinerControllerRepository(MinerControllerRepository): + """SQLAlchemy-based implementation of the MinerControllerRepository port. + + This repository works directly with the imperatively mapped MinerController domain entity. + The config field is automatically converted between MinerControllerConfig objects and JSON + strings by the custom TypeDecorator and event listener defined in tables.py. + + Args: + db: BaseSQLAlchemyRepository instance for database operations + """ + + def __init__(self, db: BaseSQLAlchemyRepository): + """Initialize repository with database instance. + + Args: + db: BaseSQLAlchemyRepository instance + """ + self._db = db + self.logger = db.logger + + def add(self, miner_controller: MinerController) -> None: + """Add a new miner controller to the repository. + + Args: + miner_controller: Domain entity to persist + """ + session = self._db.get_session() + try: + session.add(miner_controller) + session.commit() + except Exception as e: + session.rollback() + if "UNIQUE constraint failed" in str(e) or "already exists" in str(e): + raise MinerControllerAlreadyExistsError( + f"Miner Controller with ID {miner_controller.id} already exists: {e}" + ) from e + raise MinerControllerError(f"Error adding miner controller: {e}") from e + finally: + session.close() + + def get_by_id(self, miner_controller_id: EntityId) -> Optional[MinerController]: + """Retrieve a miner controller by its ID. + + Args: + miner_controller_id: Unique identifier of the miner controller + + Returns: + Domain entity if found, None otherwise + """ + session = self._db.get_session() + try: + stmt = select(MinerController).where(miner_controllers_table.c.id == str(miner_controller_id)) + entity = session.execute(stmt).scalar_one_or_none() + return entity + finally: + session.close() + + def get_all(self) -> List[MinerController]: + """Retrieve all miner controllers from the repository. + + Returns: + List of all miner controller domain entities + """ + session = self._db.get_session() + try: + stmt = select(MinerController) + entities = session.execute(stmt).scalars().all() + return list(entities) + finally: + session.close() + + def update(self, miner_controller: MinerController) -> None: + """Update an existing miner controller in the repository. + + Args: + miner_controller: Domain entity with updated state + """ + session = self._db.get_session() + try: + stmt = select(MinerController).where(miner_controllers_table.c.id == str(miner_controller.id)) + existing_controller = session.execute(stmt).scalar_one_or_none() + + if existing_controller: + # Update all fields from the new entity + existing_controller.name = miner_controller.name + existing_controller.adapter_type = miner_controller.adapter_type + existing_controller.external_service_id = miner_controller.external_service_id + existing_controller.config = miner_controller.config + + # SQLAlchemy's dirty tracking + TypeDecorator will handle serialization automatically + session.commit() + else: + raise MinerControllerNotFoundError( + f"No miner controller found with ID {miner_controller.id} for update." + ) + except MinerControllerNotFoundError: + raise + except Exception as e: + session.rollback() + raise MinerControllerError(f"Error updating miner controller: {e}") from e + finally: + session.close() + + def remove(self, miner_controller_id: EntityId) -> None: + """Remove a miner controller from the repository. + + Args: + miner_controller_id: Unique identifier of the miner controller to remove + """ + session = self._db.get_session() + try: + stmt = select(MinerController).where(miner_controllers_table.c.id == str(miner_controller_id)) + entity = session.execute(stmt).scalar_one_or_none() + + if entity: + session.delete(entity) + session.commit() + finally: + session.close() + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[MinerController]: + """Retrieve all miner controllers associated with a specific external service. + + Args: + external_service_id: Unique identifier of the external service + + Returns: + List of miner controller domain entities associated with the external service + """ + session = self._db.get_session() + try: + stmt = select(MinerController).where( + miner_controllers_table.c.external_service_id == str(external_service_id) + ) + entities = session.execute(stmt).scalars().all() + return list(entities) + finally: + session.close() diff --git a/core/edge_mining/adapters/domain/miner/schemas.py b/core/edge_mining/adapters/domain/miner/schemas.py new file mode 100644 index 0000000..fb228e1 --- /dev/null +++ b/core/edge_mining/adapters/domain/miner/schemas.py @@ -0,0 +1,925 @@ +"""Validation schemas for miner domain.""" + +import ipaddress +import uuid +from typing import Dict, Optional, Union, cast + +from pydantic import BaseModel, Field, field_serializer, field_validator + +from edge_mining.domain.common import EntityId, Watts +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.common import ( + MinerControllerAdapter, + MinerControllerProtocol, + MinerFeatureType, + MinerStatus, +) +from edge_mining.domain.miner.entities import MinerController +from edge_mining.domain.miner.value_objects import ( + FanSpeed, + Frequency, + HashboardSnapshot, + HashRate, + MinerFeature, + MinerInfo, + MinerLimit, + MinerStateSnapshot, + Temperature, + Voltage, +) +from edge_mining.shared.adapter_configs.miner import ( + MinerControllerDummyConfig, + MinerControllerGenericSocketHomeAssistantAPIConfig, + MinerControllerPyASICConfig, +) +from edge_mining.shared.adapter_maps.miner import MINER_CONTROLLER_CONFIG_TYPE_MAP +from edge_mining.shared.interfaces.config import MinerControllerConfig + + +class HashRateSchema(BaseModel): + """Schema for HashRate value object.""" + + value: float = Field(..., ge=0, description="Hash rate value, must be zero or positive") + unit: str = Field(default="TH/s", description="Hash rate unit") + + @field_validator("unit") + @classmethod + def validate_unit(cls, v: str) -> str: + """Validate hash rate unit.""" + allowed_units = ["H/s", "MH/s", "GH/s", "TH/s", "PH/s", "EH/s"] + if v not in allowed_units: + raise ValueError(f"Unit must be one of {allowed_units}") + return v + + @field_validator("value") + @classmethod + def validate_value(cls, v: float) -> float: + """Validate hash rate value.""" + if v < 0: + raise ValueError("Hash rate value must be zero or positive") + return v + + def to_model(self) -> HashRate: + """Convert HashRateSchema to HashRate domain value object.""" + return HashRate(value=self.value, unit=self.unit) + + +class TemperatureSchema(BaseModel): + """Schema for Temperature value object.""" + + value: float = Field(..., description="Temperature value") + unit: str = Field(default="°C", description="Temperature unit") + + def to_model(self) -> Temperature: + """Convert TemperatureSchema to Temperature domain value object.""" + return Temperature(value=self.value, unit=self.unit) + + +class FanSpeedSchema(BaseModel): + """Schema for FanSpeed value object.""" + + value: float = Field(..., ge=0, description="Fan speed value, must be zero or positive") + unit: str = Field(default="RPM", description="Fan speed unit") + + def to_model(self) -> FanSpeed: + """Convert FanSpeedSchema to FanSpeed domain value object.""" + return FanSpeed(value=self.value, unit=self.unit) + + +class VoltageSchema(BaseModel): + """Schema for Voltage value object.""" + + value: float = Field(..., description="Voltage value") + unit: str = Field(default="V", description="Voltage unit") + + def to_model(self) -> Voltage: + """Convert VoltageSchema to Voltage domain value object.""" + return Voltage(value=self.value, unit=self.unit) + + +class FrequencySchema(BaseModel): + """Schema for Frequency value object.""" + + value: float = Field(..., ge=0, description="Frequency value, must be zero or positive") + unit: str = Field(default="MHz", description="Frequency unit") + + def to_model(self) -> Frequency: + """Convert FrequencySchema to Frequency domain value object.""" + return Frequency(value=self.value, unit=self.unit) + + +class FeaturePrioritySchema(BaseModel): + """Schema for setting feature priority.""" + + priority: int = Field(..., ge=1, le=100, description="Priority value (1-100, higher wins)") + + +class MinerFeatureSchema(BaseModel): + """Schema for MinerFeature value object.""" + + feature_type: str = Field(..., description="Feature type") + controller_id: str = Field(..., description="ID of the controller providing this feature") + priority: int = Field(default=50, ge=1, le=100, description="Priority (1-100, higher wins)") + enabled: bool = Field(default=True, description="Whether this feature is enabled") + + @field_validator("controller_id") + @classmethod + def validate_controller_id(cls, v: str) -> str: + """Validate that controller_id is a valid UUID string.""" + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("controller_id must be a valid UUID string") from exc + return v + + @field_validator("feature_type") + @classmethod + def validate_feature_type(cls, v: str) -> str: + """Validate that feature_type is a recognized MinerFeatureType.""" + valid_types = [ft.value for ft in MinerFeatureType] + if v not in valid_types: + raise ValueError(f"feature_type must be one of {valid_types}") + return v + + @classmethod + def from_model(cls, feature: MinerFeature) -> "MinerFeatureSchema": + """Create MinerFeatureSchema from a MinerFeature value object.""" + return cls( + feature_type=feature.feature_type.value, + controller_id=str(feature.controller_id), + priority=feature.priority, + enabled=feature.enabled, + ) + + def to_model(self) -> MinerFeature: + """Convert MinerFeatureSchema to MinerFeature value object.""" + return MinerFeature( + feature_type=MinerFeatureType(self.feature_type), + controller_id=EntityId(uuid.UUID(self.controller_id)), + priority=self.priority, + enabled=self.enabled, + ) + + +class MinerSchema(BaseModel): + """Schema for Miner entity with complete validation.""" + + id: str = Field(..., description="Unique identifier for the miner") + name: str = Field(default="", description="Miner name") + model: Optional[str] = Field(default=None, description="Miner model/hardware identifier") + hash_rate_max: Optional[HashRateSchema] = Field(default=None, description="Maximum hash rate") + power_consumption_max: Optional[float] = Field(default=None, ge=0, description="Maximum power consumption in Watts") + active: bool = Field(default=True, description="Whether the miner is active in the system") + features: list[MinerFeatureSchema] = Field(default_factory=list, description="Features provided by controllers") + controller_ids: list[str] = Field(default_factory=list, description="IDs of associated controllers (computed)") + + @field_validator("id") + @classmethod + def validate_id(cls, v: str) -> str: + """Validate that id is a valid UUID string.""" + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("id must be a valid UUID string") from exc + return v + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate miner name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("power_consumption_max") + @classmethod + def validate_power_max(cls, v: Optional[float]) -> Optional[float]: + """Validate power consumption max values.""" + if v is not None and v < 0: + raise ValueError("Power consumption max cannot be negative") + return v + + @classmethod + def from_model(cls, miner: Miner) -> "MinerSchema": + """Create MinerSchema from a Miner domain model instance.""" + hash_rate_max: Optional[HashRateSchema] = None + if miner.hash_rate_max: + hash_rate_max = HashRateSchema(value=miner.hash_rate_max.value, unit=miner.hash_rate_max.unit) + + return cls( + id=str(miner.id), + name=miner.name, + model=miner.model, + hash_rate_max=hash_rate_max, + power_consumption_max=miner.power_consumption_max, + active=miner.active, + features=[MinerFeatureSchema.from_model(f) for f in miner.features], + controller_ids=[str(cid) for cid in miner.get_controller_ids()], + ) + + @field_serializer("id") + def serialize_id(self, value: str) -> str: + """Serialize id field.""" + return str(value) + + def to_model(self) -> Miner: + """Convert MinerSchema back to Miner domain model instance.""" + return Miner( + id=EntityId(uuid.UUID(self.id)), + name=self.name, + model=self.model, + hash_rate_max=( + HashRate(value=self.hash_rate_max.value, unit=self.hash_rate_max.unit) if self.hash_rate_max else None + ), + power_consumption_max=Watts(self.power_consumption_max) if self.power_consumption_max is not None else None, + active=self.active, + features=[f.to_model() for f in self.features], + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + arbitrary_types_allowed = True + json_encoders = { + uuid.UUID: str, + MinerControllerAdapter: lambda v: v.value, + } + + +class HashboardSnapshotSchema(BaseModel): + """Schema for HashboardSnapshot value object.""" + + index: int = Field(..., description="Hashboard slot/position index") + chip_temperature: Optional[TemperatureSchema] = Field(default=None, description="Chip temperature") + board_temperature: Optional[TemperatureSchema] = Field(default=None, description="Board temperature") + voltage: Optional[VoltageSchema] = Field(default=None, description="Voltage") + frequency: Optional[FrequencySchema] = Field(default=None, description="Frequency") + hash_rate: Optional[HashRateSchema] = Field(default=None, description="Current hash rate") + nominal_hash_rate: Optional[HashRateSchema] = Field(default=None, description="Nominal hash rate") + hash_rate_error: Optional[HashRateSchema] = Field(default=None, description="Hash rate error") + + @classmethod + def from_model(cls, hb: HashboardSnapshot) -> "HashboardSnapshotSchema": + """Create HashboardSnapshotSchema from a HashboardSnapshot value object.""" + return cls( + index=hb.index, + chip_temperature=( + TemperatureSchema(value=hb.chip_temperature.value, unit=hb.chip_temperature.unit) + if hb.chip_temperature + else None + ), + board_temperature=( + TemperatureSchema(value=hb.board_temperature.value, unit=hb.board_temperature.unit) + if hb.board_temperature + else None + ), + voltage=VoltageSchema(value=hb.voltage.value, unit=hb.voltage.unit) if hb.voltage else None, + frequency=FrequencySchema(value=hb.frequency.value, unit=hb.frequency.unit) if hb.frequency else None, + hash_rate=(HashRateSchema(value=hb.hash_rate.value, unit=hb.hash_rate.unit) if hb.hash_rate else None), + nominal_hash_rate=( + HashRateSchema(value=hb.nominal_hash_rate.value, unit=hb.nominal_hash_rate.unit) + if hb.nominal_hash_rate + else None + ), + hash_rate_error=( + HashRateSchema(value=hb.hash_rate_error.value, unit=hb.hash_rate_error.unit) + if hb.hash_rate_error + else None + ), + ) + + def to_model(self) -> HashboardSnapshot: + """Convert HashboardSnapshotSchema to HashboardSnapshot value object.""" + return HashboardSnapshot( + index=self.index, + chip_temperature=self.chip_temperature.to_model() if self.chip_temperature else None, + board_temperature=self.board_temperature.to_model() if self.board_temperature else None, + voltage=self.voltage.to_model() if self.voltage else None, + frequency=self.frequency.to_model() if self.frequency else None, + hash_rate=self.hash_rate.to_model() if self.hash_rate else None, + nominal_hash_rate=self.nominal_hash_rate.to_model() if self.nominal_hash_rate else None, + hash_rate_error=self.hash_rate_error.to_model() if self.hash_rate_error else None, + ) + + +class MinerStateSnapshotSchema(BaseModel): + """Schema for MinerStateSnapshot value object (runtime operational state).""" + + status: MinerStatus = Field(default=MinerStatus.UNKNOWN, description="Current miner status") + hash_rate: Optional[HashRateSchema] = Field(default=None, description="Current hash rate") + power_consumption: Optional[float] = Field(default=None, description="Current power consumption in Watts") + inlet_temperature: Optional[TemperatureSchema] = Field(default=None, description="Inlet temperature") + outlet_temperature: Optional[TemperatureSchema] = Field(default=None, description="Outlet temperature") + internal_fan_speed: list[FanSpeedSchema] = Field(default_factory=list, description="Internal fan speeds") + external_fan_speed: Optional[FanSpeedSchema] = Field(default=None, description="External fan speed") + hashboards: list[HashboardSnapshotSchema] = Field(default_factory=list, description="Per-hashboard data") + blocks_found: Optional[int] = Field(default=None, description="Blocks found count") + system_uptime: Optional[int] = Field(default=None, description="System uptime in seconds") + max_chip_temperature: Optional[TemperatureSchema] = Field( + default=None, description="Maximum chip temperature across all hashboards" + ) + max_board_temperature: Optional[TemperatureSchema] = Field( + default=None, description="Maximum board temperature across all hashboards" + ) + avg_chip_temperature: Optional[TemperatureSchema] = Field( + default=None, description="Average chip temperature across all hashboards" + ) + avg_board_temperature: Optional[TemperatureSchema] = Field( + default=None, description="Average board temperature across all hashboards" + ) + + @classmethod + def from_model(cls, snapshot: MinerStateSnapshot) -> "MinerStateSnapshotSchema": + """Create MinerStateSnapshotSchema from a MinerStateSnapshot value object.""" + hash_rate: Optional[HashRateSchema] = None + if snapshot.hash_rate: + hash_rate = HashRateSchema(value=snapshot.hash_rate.value, unit=snapshot.hash_rate.unit) + + max_chip_temp = snapshot.max_chip_temperature + max_board_temp = snapshot.max_board_temperature + avg_chip_temp = snapshot.avg_chip_temperature + avg_board_temp = snapshot.avg_board_temperature + + return cls( + status=snapshot.status, + hash_rate=hash_rate, + power_consumption=snapshot.power_consumption, + inlet_temperature=( + TemperatureSchema(value=snapshot.inlet_temperature.value, unit=snapshot.inlet_temperature.unit) + if snapshot.inlet_temperature + else None + ), + outlet_temperature=( + TemperatureSchema(value=snapshot.outlet_temperature.value, unit=snapshot.outlet_temperature.unit) + if snapshot.outlet_temperature + else None + ), + internal_fan_speed=[FanSpeedSchema(value=fs.value, unit=fs.unit) for fs in snapshot.internal_fan_speed], + external_fan_speed=( + FanSpeedSchema(value=snapshot.external_fan_speed.value, unit=snapshot.external_fan_speed.unit) + if snapshot.external_fan_speed + else None + ), + hashboards=[HashboardSnapshotSchema.from_model(hb) for hb in snapshot.hashboards], + blocks_found=snapshot.blocks_found, + system_uptime=snapshot.system_uptime, + max_chip_temperature=( + TemperatureSchema(value=max_chip_temp.value, unit=max_chip_temp.unit) if max_chip_temp else None + ), + max_board_temperature=( + TemperatureSchema(value=max_board_temp.value, unit=max_board_temp.unit) if max_board_temp else None + ), + avg_chip_temperature=( + TemperatureSchema(value=avg_chip_temp.value, unit=avg_chip_temp.unit) if avg_chip_temp else None + ), + avg_board_temperature=( + TemperatureSchema(value=avg_board_temp.value, unit=avg_board_temp.unit) if avg_board_temp else None + ), + ) + + def to_model(self) -> MinerStateSnapshot: + """Convert MinerStateSnapshotSchema to MinerStateSnapshot value object.""" + return MinerStateSnapshot( + status=MinerStatus(self.status) if isinstance(self.status, str) else self.status, + hash_rate=(HashRate(value=self.hash_rate.value, unit=self.hash_rate.unit) if self.hash_rate else None), + power_consumption=Watts(self.power_consumption) if self.power_consumption is not None else None, + inlet_temperature=self.inlet_temperature.to_model() if self.inlet_temperature else None, + outlet_temperature=self.outlet_temperature.to_model() if self.outlet_temperature else None, + internal_fan_speed=[fs.to_model() for fs in self.internal_fan_speed], + external_fan_speed=self.external_fan_speed.to_model() if self.external_fan_speed else None, + hashboards=[hb.to_model() for hb in self.hashboards], + blocks_found=self.blocks_found, + system_uptime=self.system_uptime, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + json_encoders = { + MinerStatus: lambda v: v.value, + } + + +class MinerInfoSchema(BaseModel): + """Schema for MinerInfo value object.""" + + model: Optional[str] = Field(default=None, description="Miner model") + serial_number: Optional[str] = Field(default=None, description="Serial number") + firmware_type: Optional[str] = Field( + default=None, description="Firmware type (e.g. Stock, BOS+, VNish, ePIC, LuxOS)" + ) + firmware_version: Optional[str] = Field(default=None, description="Firmware version") + mac_address: Optional[str] = Field(default=None, description="MAC address") + hostname: Optional[str] = Field(default=None, description="Hostname") + hashboard_count: Optional[int] = Field(default=None, description="Number of hashboards") + chip_count: Optional[int] = Field(default=None, description="Number of chips") + fan_count: Optional[int] = Field(default=None, description="Number of fans") + + @classmethod + def from_model(cls, info: MinerInfo) -> "MinerInfoSchema": + """Create MinerInfoSchema from a MinerInfo value object.""" + return cls( + model=info.model, + serial_number=info.serial_number, + firmware_type=info.firmware_type, + firmware_version=info.firmware_version, + mac_address=info.mac_address, + hostname=info.hostname, + hashboard_count=info.hashboard_count, + chip_count=info.chip_count, + fan_count=info.fan_count, + ) + + +class MinerLimitSchema(BaseModel): + """Schema for MinerLimit value object.""" + + max_power: Optional[float] = Field(default=None, ge=0, description="Maximum power consumption in Watts") + max_hash_rate: Optional[HashRateSchema] = Field(default=None, description="Maximum hash rate") + + @field_validator("max_power") + @classmethod + def validate_max_power(cls, v: Optional[float]) -> Optional[float]: + """Validate that max_power is non-negative if provided.""" + if v is not None and v < 0: + raise ValueError("max_power cannot be negative") + return v + + def to_model(self) -> MinerLimit: + """Convert MinerLimitSchema to MinerLimit value object.""" + return MinerLimit( + max_power=Watts(self.max_power) if self.max_power is not None else None, + max_hash_rate=self.max_hash_rate.to_model() if self.max_hash_rate else None, + ) + + @classmethod + def from_model(cls, limit: Optional[MinerLimit]) -> "MinerLimitSchema": + """Create MinerLimitSchema from a MinerLimit value object.""" + if not limit: + return cls() + + max_hash_rate_schema: Optional[HashRateSchema] = None + if limit.max_hash_rate: + max_hash_rate_schema = HashRateSchema(value=limit.max_hash_rate.value, unit=limit.max_hash_rate.unit) + + return cls( + max_power=limit.max_power if limit.max_power else None, + max_hash_rate=max_hash_rate_schema, + ) + + +class MinerCreateSchema(BaseModel): + """Schema for creating a new miner.""" + + name: str = Field(default="", description="Miner name") + model: Optional[str] = Field(default=None, description="Miner model/hardware identifier") + hash_rate_max: Optional[HashRateSchema] = Field(default=None, description="Maximum hash rate") + power_consumption_max: Optional[float] = Field(default=None, ge=0, description="Maximum power consumption in Watts") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate miner name.""" + v = v.strip() + if not v: + v = "" + return v + + def to_model(self) -> Miner: + """Convert MinerCreateSchema to a Miner domain model instance.""" + return Miner( + id=EntityId(uuid.uuid4()), + name=self.name, + model=self.model, + hash_rate_max=( + HashRate(value=self.hash_rate_max.value, unit=self.hash_rate_max.unit) if self.hash_rate_max else None + ), + power_consumption_max=Watts(self.power_consumption_max) if self.power_consumption_max is not None else None, + active=True, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + json_encoders = { + uuid.UUID: str, + } + + +class MinerUpdateSchema(BaseModel): + """Schema for updating an existing miner.""" + + name: str = Field(default="", description="Miner name") + model: Optional[str] = Field(default=None, description="Miner model/hardware identifier") + hash_rate_max: Optional[HashRateSchema] = Field(default=None, description="Maximum hash rate") + power_consumption_max: Optional[float] = Field(default=None, ge=0, description="Maximum power consumption in Watts") + active: Optional[bool] = Field(default=None, description="Whether the miner is active") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate miner name.""" + v = v.strip() + if not v: + v = "" + return v + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + json_encoders = { + uuid.UUID: str, + } + + +class MinerControllerSchema(BaseModel): + """Schema for MinerController entity with complete validation.""" + + id: str = Field(..., description="Unique identifier for the miner controller") + name: str = Field(default="", description="Controller name") + adapter_type: MinerControllerAdapter = Field( + default=MinerControllerAdapter.DUMMY, description="Type of controller adapter" + ) + config: dict = Field(default={}, description="Controller configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @field_validator("id") + @classmethod + def validate_id(cls, v: str) -> str: + """Validate that id is a valid UUID string.""" + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("id must be a valid UUID string") from exc + return v + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate controller name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("adapter_type") + @classmethod + def validate_adapter_type(cls, v: str) -> MinerControllerAdapter: + """Validate that adapter_type is a recognized MinerControllerAdapter.""" + adapter_values = [adapter.value for adapter in MinerControllerAdapter] + if v not in adapter_values: + raise ValueError(f"adapter_type must be one of {adapter_values}") + return MinerControllerAdapter(v) + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that external_service_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("external_service_id must be a valid UUID string") from exc + return v + + @classmethod + def from_model(cls, controller: MinerController) -> "MinerControllerSchema": + """Create MinerControllerSchema from a MinerController domain model instance.""" + return cls( + id=str(controller.id), + name=controller.name, + adapter_type=controller.adapter_type, + config=controller.config.to_dict() if controller.config else {}, + external_service_id=str(controller.external_service_id) if controller.external_service_id else None, + ) + + @field_serializer("id") + def serialize_id(self, value: str) -> str: + """Serialize id field.""" + return str(value) + + @field_serializer("external_service_id") + def serialize_external_service_id(self, value: Optional[str]) -> Optional[str]: + """Serialize external_service_id field.""" + return str(value) if value is not None else None + + def to_model(self) -> MinerController: + """Convert MinerControllerSchema to MinerController domain model instance.""" + configuration: Optional[MinerControllerConfig] = None + if self.config: + config_class = MINER_CONTROLLER_CONFIG_TYPE_MAP.get(self.adapter_type, None) + if config_class: + configuration = cast(MinerControllerConfig, config_class.from_dict(self.config)) + + return MinerController( + id=EntityId(uuid.UUID(self.id)), + name=self.name, + adapter_type=self.adapter_type, + config=configuration, + external_service_id=EntityId(uuid.UUID(self.external_service_id)) if self.external_service_id else None, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + arbitrary_types_allowed = True + json_encoders = { + uuid.UUID: str, + MinerControllerAdapter: lambda v: v.value, + } + + +class MinerControllerCreateSchema(BaseModel): + """Schema for creating a new miner controller.""" + + name: str = Field(default="", description="Controller name") + adapter_type: MinerControllerAdapter = Field( + default=MinerControllerAdapter.DUMMY, description="Type of controller adapter" + ) + config: Optional[dict] = Field(default=None, description="Controller configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate controller name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("adapter_type") + @classmethod + def validate_adapter_type(cls, v: str) -> MinerControllerAdapter: + """Validate that adapter_type is a recognized MinerControllerAdapter.""" + adapter_values = [adapter.value for adapter in MinerControllerAdapter] + if v not in adapter_values: + raise ValueError(f"adapter_type must be one of {adapter_values}") + return MinerControllerAdapter(v) + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that external_service_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("external_service_id must be a valid UUID string") from exc + return v + + def to_model(self) -> MinerController: + """Convert MinerControllerCreateSchema to a MinerController domain model instance.""" + configuration: Optional[MinerControllerConfig] = None + if self.config: + config_class = MINER_CONTROLLER_CONFIG_TYPE_MAP.get(self.adapter_type, None) + if config_class: + configuration = cast(MinerControllerConfig, config_class.from_dict(self.config)) + else: + if self.adapter_type: + # If adapter type is provided but config is not, initialize with default config + config_class = MINER_CONTROLLER_CONFIG_TYPE_MAP.get(self.adapter_type, None) + if config_class: + configuration = cast(MinerControllerConfig, config_class()) + + return MinerController( + id=EntityId(uuid.uuid4()), + name=self.name, + adapter_type=self.adapter_type, + config=configuration, + external_service_id=EntityId(uuid.UUID(self.external_service_id)) if self.external_service_id else None, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + json_encoders = { + uuid.UUID: str, + MinerControllerAdapter: lambda v: v.value, + } + + +class MinerControllerUpdateSchema(BaseModel): + """Schema for updating an existing miner controller.""" + + name: str = Field(default="", description="Controller name") + config: Optional[dict] = Field(default=None, description="Controller configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate controller name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that external_service_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("external_service_id must be a valid UUID string") from exc + return v + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + json_encoders = { + uuid.UUID: str, + } + + +class MinerControllerDummyConfigSchema(BaseModel): + """Schema for Dummy MinerControllerConfig.""" + + initial_status: MinerStatus = Field(default=MinerStatus.UNKNOWN, description="Initial status of the miner") + power_max: float = Field(default=3200.0, description="Maximum power consumption in Watts") + hashrate_max: HashRateSchema = Field(default=HashRateSchema(value=90, unit="TH/s"), description="Maximum hash rate") + + @field_validator("initial_status") + @classmethod + def validate_initial_status(cls, v: str) -> str: + """Validate initial status.""" + allowed_statuses = [status.value for status in MinerStatus] + if v not in allowed_statuses: + raise ValueError(f"Initial status must be one of {allowed_statuses}") + return v + + @field_validator("power_max") + @classmethod + def validate_power_max(cls, v: float) -> float: + """Validate power max.""" + if v < 0: + raise ValueError("Power max cannot be negative") + return v + + def to_model(self) -> MinerControllerDummyConfig: + """ + Convert MinerControllerDummyConfigSchema to MinerControllerDummyConfig adapter configuration model instance. + """ + return MinerControllerDummyConfig( + initial_status=self.initial_status.value, + power_max=self.power_max, + hashrate_max=HashRate(value=self.hashrate_max.value, unit=self.hashrate_max.unit), + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + +class MinerControllerGenericSocketHomeAssistantAPIConfigSchema(BaseModel): + """Schema for MinerControllerGenericSocketHomeAssistantAPIConfig.""" + + entity_switch: str = Field(..., description="Home Assistant switch entity for the miner") + entity_power: str = Field(..., description="Home Assistant power sensor entity for the miner") + unit_power: str = Field(default="W", description="Power unit of the sensor") + + @field_validator("entity_switch", "entity_power") + @classmethod + def validate_entity_id(cls, v: str) -> str: + """Validate that the value is a plausible Home Assistant entity ID.""" + v = v.strip() + if not v or "." not in v: + raise ValueError("Entity ID must be a non-empty string containing a dot (e.g., 'domain.object_id')") + return v + + def to_model(self) -> MinerControllerGenericSocketHomeAssistantAPIConfig: + """ + Convert schema to MinerControllerGenericSocketHomeAssistantAPIConfig adapter configuration model instance. + """ + return MinerControllerGenericSocketHomeAssistantAPIConfig( + entity_switch=self.entity_switch, + entity_power=self.entity_power, + unit_power=self.unit_power, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + +class MinerControllerPyASICConfigSchema(BaseModel): + """Schema for MinerControllerPyASICConfig.""" + + ip: str = Field(..., description="IP address of the PyASIC miner") + port: Optional[int] = Field( + None, description="Port of the PyASIC miner (empty represents 'use the default miner port')" + ) + username: Optional[str] = Field( + None, description="Username of the PyASIC miner (empty represents 'use the default miner username')" + ) + password: Optional[str] = Field( + None, description="Password of the PyASIC miner (empty represents 'use the default miner password')" + ) + protocol: Optional[MinerControllerProtocol] = Field( + default=MinerControllerProtocol.WEB, description="Protocol to use for connecting to the miner" + ) + + @field_validator("ip") + @classmethod + def validate_ip(cls, v: str) -> str: + """Validate that the value is a plausible IP address.""" + v = v.strip() + if not v: + raise ValueError("IP address must be a non-empty string") + try: + ipaddress.ip_address(str(v)) + except ValueError as e: + raise ValueError(f"Invalid IP address: {v}") from e + return v + + @field_validator("port") + @classmethod + def validate_port(cls, v: Optional[int]) -> Optional[int]: + """Validate that the value is a plausible port number.""" + if v is not None: + if not (0 < v < 65536): + raise ValueError("Port must be between 1 and 65535") + return v + + @field_validator("username") + @classmethod + def validate_username(cls, v: Optional[str]) -> Optional[str]: + """Validate that the value is a plausible username.""" + if v is not None: + v = v.strip() + if not v: + v = None + return v + + @field_validator("password") + @classmethod + def validate_password(cls, v: Optional[str]) -> Optional[str]: + """Validate that the value is a plausible password.""" + if v is not None: + v = v.strip() + if not v: + v = None + return v + + @field_validator("protocol") + @classmethod + def validate_protocol(cls, v: str) -> MinerControllerProtocol: + """Validate that protocol is a recognized MinerControllerProtocol.""" + protocol_values = [protocol.value for protocol in MinerControllerProtocol] + if v not in protocol_values: + raise ValueError(f"protocol must be one of {protocol_values}") + return MinerControllerProtocol(v) + + def to_model(self) -> MinerControllerPyASICConfig: + """ + Convert schema to MinerControllerPyASICConfig adapter configuration model instance. + """ + + return MinerControllerPyASICConfig( + ip=self.ip, + port=self.port, + username=self.username, + password=self.password, + protocol=self.protocol, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + +MINER_CONTROLLER_CONFIG_SCHEMA_MAP: Dict[ + type[MinerControllerConfig], + Union[ + type[MinerControllerDummyConfigSchema], + type[MinerControllerGenericSocketHomeAssistantAPIConfigSchema], + type[MinerControllerPyASICConfigSchema], + ], +] = { + MinerControllerDummyConfig: MinerControllerDummyConfigSchema, + MinerControllerGenericSocketHomeAssistantAPIConfig: MinerControllerGenericSocketHomeAssistantAPIConfigSchema, + MinerControllerPyASICConfig: MinerControllerPyASICConfigSchema, +} diff --git a/core/edge_mining/adapters/domain/miner/tables.py b/core/edge_mining/adapters/domain/miner/tables.py new file mode 100644 index 0000000..3216c28 --- /dev/null +++ b/core/edge_mining/adapters/domain/miner/tables.py @@ -0,0 +1,319 @@ +"""SQLAlchemy ORM mappings for Miner domain entities. + +This module implements imperative (classical) mapping of the domain entities +to database tables. The domain entities are mapped directly without +creating separate ORM model classes, maintaining domain purity. + +The mappings handle value objects and enums using SQLAlchemy event listeners and custom types: +- HashRate is serialized to JSON strings and reconstructed after loading +- Watts is flattened to float columns for persistence and reconstructed after loading +- MinerStatus enum uses custom TypeDecorator for string conversion +- MinerControllerConfig is serialized using custom ConfigurationType + +All tables and mappings use the shared metadata and mapper registry from +the sqlalchemy.registry module, which are available as module-level singletons. + +⚠️ DEVELOPER WARNING ⚠️ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +ANY SCHEMA CHANGE (adding/removing/modifying tables or columns) REQUIRES an +Alembic migration. Do NOT modify this file without creating a migration: + + python scripts/migrate.py create "Description of your change" + +For detailed instructions, see: ../docs/ALEMBIC_MIGRATIONS.md +For a step-by-step example, see: ../docs/MIGRATION_EXAMPLE.md +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +""" + +import json +import uuid +from typing import Any, Optional + +from sqlalchemy import Boolean, Column, Float, ForeignKey, Integer, String, Table, event + +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.common import ConfigurationType +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.registry import mapper_registry, metadata +from edge_mining.domain.common import EntityId, Watts +from edge_mining.domain.miner.common import MinerControllerAdapter, MinerFeatureType +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.entities import MinerController +from edge_mining.domain.miner.exceptions import MinerControllerConfigurationError +from edge_mining.domain.miner.value_objects import HashRate, MinerFeature +from edge_mining.shared.adapter_maps.miner import MINER_CONTROLLER_CONFIG_TYPE_MAP +from edge_mining.shared.interfaces.config import MinerControllerConfig + + +class MinerControllerConfigType(ConfigurationType): + """SQLAlchemy type for MinerControllerConfig serialization. + + Inherits from ConfigurationType to handle JSON serialization/deserialization. + """ + + +def _deserialize_miner_controller_config( + adapter_type: MinerControllerAdapter, config_json: str +) -> Optional[MinerControllerConfig]: + """Deserialize JSON string to MinerControllerConfig based on adapter type. + + Args: + adapter_type: The type of miner controller adapter + config_json: JSON string representation of config + + Returns: + MinerControllerConfig instance or None + """ + if not config_json: + return None + + data: dict = json.loads(config_json) + + if adapter_type not in MINER_CONTROLLER_CONFIG_TYPE_MAP: + raise MinerControllerConfigurationError( + f"Error reading MinerController configuration. Invalid type '{adapter_type}'" + ) + + config_class: Optional[type[MinerControllerConfig]] = MINER_CONTROLLER_CONFIG_TYPE_MAP.get(adapter_type) + if not config_class: + raise MinerControllerConfigurationError(f"Error creating MinerController configuration. Type '{adapter_type}'") + + config_instance = config_class.from_dict(data) + if not isinstance(config_instance, MinerControllerConfig): + raise MinerControllerConfigurationError( + f"Deserialized config is not of type MinerControllerConfig for adapter type {adapter_type}." + ) + return config_instance + + +@event.listens_for(MinerController, "load") +def _receive_miner_controller_load(target: MinerController, context) -> None: + """Event listener that deserializes config after loading from database. + + Args: + target: The MinerController instance being loaded + context: SQLAlchemy context + """ + # Convert id string to EntityId if needed + if hasattr(target, "id") and target.id is not None: + if isinstance(target.id, str): + target.id = EntityId(uuid.UUID(target.id)) + + # Convert foreign keys to EntityId + if hasattr(target, "external_service_id") and target.external_service_id is not None: + if isinstance(target.external_service_id, str): + target.external_service_id = EntityId(uuid.UUID(target.external_service_id)) + + # Convert adapter_type string to enum if needed + if isinstance(target.adapter_type, str): + try: + target.adapter_type = MinerControllerAdapter(target.adapter_type) + except ValueError: + # If conversion fails, leave as string (will fail in config deserialization) + pass + + if target.config and isinstance(target.config, str): + target.config = _deserialize_miner_controller_config(target.adapter_type, target.config) + + +@event.listens_for(MinerController, "before_insert") +@event.listens_for(MinerController, "before_update") +def _flatten_miner_controller_composites(mapper, connection, target: Any) -> None: + """Convert enum attributes to primitive values before persisting.""" + if hasattr(target, "adapter_type") and target.adapter_type is not None: + if isinstance(target.adapter_type, MinerControllerAdapter): + target.adapter_type = target.adapter_type.value + + +@event.listens_for(MinerController, "after_insert") +@event.listens_for(MinerController, "after_update") +def _restore_miner_controller_composites(mapper, connection, target: Any) -> None: + """Restore enum attributes after persist operations.""" + if hasattr(target, "adapter_type") and target.adapter_type is not None: + if isinstance(target.adapter_type, str): + try: + target.adapter_type = MinerControllerAdapter(target.adapter_type) + except ValueError: + pass + + +# Define the miner_controllers table using imperative style +miner_controllers_table = Table( + "miner_controllers", + metadata, + # Primary Key + Column("id", String, primary_key=True, index=True), + # Basic attributes + Column("name", String, nullable=False), + Column("adapter_type", String, nullable=False, default="DUMMY"), + # Config stored as JSON string with automatic conversion + Column("config", MinerControllerConfigType, nullable=True), + # External service reference + Column("external_service_id", String, ForeignKey("external_services.id"), nullable=True), +) + +# Define the miners table using imperative style +miners_table = Table( + "miners", + metadata, + # Primary Key + Column("id", String, primary_key=True, index=True), + # Basic attributes + Column("name", String, nullable=False), + Column("model", String, nullable=True), + Column("active", Boolean, nullable=False, default=True), + # Hash Rate Max Value Object - stored as TEXT (JSON) in SQLite to match existing schema + Column("hash_rate_max", String, nullable=True), + # Power Consumption Max (Watts Value Object stored as float) + Column("power_consumption_max", Float, nullable=True), +) + +# Define the miner_features table (feature-based architecture) +miner_features_table = Table( + "miner_features", + metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("miner_id", String, ForeignKey("miners.id", ondelete="CASCADE"), nullable=False, index=True), + Column("controller_id", String, ForeignKey("miner_controllers.id", ondelete="CASCADE"), nullable=False, index=True), + Column("feature_type", String, nullable=False), + Column("priority", Integer, nullable=False, default=50), + Column("enabled", Boolean, nullable=False, default=True), +) + +# Map MinerController (no relationship to Miner — features bridge them now) +mapper_registry.map_imperatively( + MinerController, + miner_controllers_table, +) + +# Map Miner — features are loaded/saved by the repository, not by ORM relationship +mapper_registry.map_imperatively( + Miner, + miners_table, + exclude_properties=["features"], +) + + +# Event listeners for value object conversions +@event.listens_for(Miner, "load") +def _receive_miner_load(target: Miner, context) -> None: + """Event listener that reconstructs value objects after loading.""" + # Reconstruct hash_rate_max (HashRate) from JSON string + if hasattr(target, "hash_rate_max") and target.hash_rate_max: + if isinstance(target.hash_rate_max, str): + try: + hash_rate_max_data = json.loads(target.hash_rate_max) + target.hash_rate_max = HashRate( + value=hash_rate_max_data.get("value"), unit=hash_rate_max_data.get("unit", "TH/s") + ) + except (json.JSONDecodeError, TypeError, KeyError): + target.hash_rate_max = None + + # Convert power_consumption_max to Watts (it's loaded as float) + if hasattr(target, "power_consumption_max") and target.power_consumption_max is not None: + if not isinstance(target.power_consumption_max, type(Watts(0.0))): + target.power_consumption_max = Watts(float(target.power_consumption_max)) + + # Initialize features as empty list — repository will populate it + if not hasattr(target, "features") or target.features is None: + object.__setattr__(target, "features", []) + + +@event.listens_for(Miner, "before_insert") +@event.listens_for(Miner, "before_update") +def _flatten_miner_value_objects(mapper, connection, target: Miner) -> None: + """Event listener that flattens value objects before persisting. + + Args: + mapper: SQLAlchemy mapper + connection: Database connection + target: The Miner instance being persisted + """ + # Flatten hash_rate_max (HashRate) to JSON string + if hasattr(target, "hash_rate_max") and target.hash_rate_max is not None: + if not isinstance(target.hash_rate_max, str): + hash_rate_max_dict = {"value": target.hash_rate_max.value, "unit": target.hash_rate_max.unit} + target.hash_rate_max = json.dumps(hash_rate_max_dict) # type: ignore[assignment] + + # Flatten power_consumption_max (Watts) to float + if hasattr(target, "power_consumption_max") and target.power_consumption_max is not None: + target.power_consumption_max = float(target.power_consumption_max) # type: ignore[assignment] + + +@event.listens_for(Miner, "after_insert") +@event.listens_for(Miner, "after_update") +def _restore_miner_composites(mapper, connection, target: Any) -> None: + """Event listener that restores value objects after persisting.""" + # Restore id to EntityId if it was converted to string + if hasattr(target, "id") and target.id is not None: + if isinstance(target.id, str): + target.id = EntityId(uuid.UUID(target.id)) + + # Restore hash_rate_max from JSON string + if hasattr(target, "hash_rate_max") and target.hash_rate_max is not None: + if isinstance(target.hash_rate_max, str): + try: + hash_rate_max_data = json.loads(target.hash_rate_max) + target.hash_rate_max = HashRate( + value=hash_rate_max_data.get("value"), unit=hash_rate_max_data.get("unit", "TH/s") + ) + except (json.JSONDecodeError, TypeError, KeyError): + pass + + # Restore Watts values + if hasattr(target, "power_consumption_max") and target.power_consumption_max is not None: + if not isinstance(target.power_consumption_max, type(Watts(0.0))): + target.power_consumption_max = Watts(float(target.power_consumption_max)) + + +# --- Helper functions for feature persistence (used by repositories) --- + + +def load_features_for_miner(session, miner_id: EntityId) -> list[MinerFeature]: + """Load MinerFeature VOs from the miner_features table for a given miner. + + Unknown feature types (e.g. removed/renamed) are silently skipped. + """ + from sqlalchemy import select + + stmt = select(miner_features_table).where(miner_features_table.c.miner_id == str(miner_id)) + rows = session.execute(stmt).fetchall() + features = [] + for row in rows: + try: + feature_type = MinerFeatureType(row.feature_type) + except ValueError: + # Skip features whose type no longer exists in the enum + continue + features.append( + MinerFeature( + feature_type=feature_type, + controller_id=EntityId(uuid.UUID(row.controller_id)), + priority=row.priority, + enabled=bool(row.enabled), + ) + ) + return features + + +def save_features_for_miner(session, miner_id: EntityId, features: list[MinerFeature]) -> None: + """Persist MinerFeature VOs to the miner_features table for a given miner. + + Replaces all existing features for the miner (delete + re-insert). + """ + # Delete existing features + session.execute(miner_features_table.delete().where(miner_features_table.c.miner_id == str(miner_id))) + # Insert new features + for f in features: + session.execute( + miner_features_table.insert().values( + miner_id=str(miner_id), + controller_id=str(f.controller_id), + feature_type=f.feature_type.value, + priority=f.priority, + enabled=f.enabled, + ) + ) + + +def delete_features_for_miner(session, miner_id: EntityId) -> None: + """Delete all features for a given miner.""" + session.execute(miner_features_table.delete().where(miner_features_table.c.miner_id == str(miner_id))) diff --git a/core/edge_mining/adapters/domain/miner/websocket/__init__.py b/core/edge_mining/adapters/domain/miner/websocket/__init__.py new file mode 100644 index 0000000..ea5f387 --- /dev/null +++ b/core/edge_mining/adapters/domain/miner/websocket/__init__.py @@ -0,0 +1 @@ +"""WebSocket adapter for the Miner domain.""" diff --git a/core/edge_mining/adapters/domain/miner/websocket/handlers.py b/core/edge_mining/adapters/domain/miner/websocket/handlers.py new file mode 100644 index 0000000..43733c4 --- /dev/null +++ b/core/edge_mining/adapters/domain/miner/websocket/handlers.py @@ -0,0 +1,35 @@ +"""WebSocket event handler for the Miner domain.""" + +from typing import Any, List + +from edge_mining.adapters.domain.miner.websocket.schemas import MinerStateChangedSchema +from edge_mining.adapters.infrastructure.websocket.utils import ( + WebSocketEventHandler, + WebSocketEventRegistration, +) +from edge_mining.domain.common import DomainEvent +from edge_mining.domain.miner.events import MinerStateChangedEvent + + +class MinerWebSocketHandler(WebSocketEventHandler): + """Serializes Miner domain events for WebSocket broadcasting.""" + + @property + def registrations(self) -> List[WebSocketEventRegistration]: + return [ + WebSocketEventRegistration( + event_type=MinerStateChangedEvent, + topic="miner.state", + serialize=self._serialize_miner_state_changed, + ), + ] + + def _serialize_miner_state_changed(self, event: DomainEvent) -> dict[str, Any]: + assert isinstance(event, MinerStateChangedEvent) + payload = MinerStateChangedSchema( + miner_id=str(event.miner_id) if event.miner_id else None, + miner_name=event.miner_name, + old_status=event.old_status.value if event.old_status else None, + new_status=event.new_status.value if event.new_status else None, + ) + return payload.model_dump(mode="json") diff --git a/core/edge_mining/adapters/domain/miner/websocket/schemas.py b/core/edge_mining/adapters/domain/miner/websocket/schemas.py new file mode 100644 index 0000000..6f899a0 --- /dev/null +++ b/core/edge_mining/adapters/domain/miner/websocket/schemas.py @@ -0,0 +1,14 @@ +"""WebSocket event schemas for the Miner domain.""" + +from typing import Optional + +from pydantic import BaseModel, Field + + +class MinerStateChangedSchema(BaseModel): + """WebSocket schema for MinerStateChangedEvent.""" + + miner_id: Optional[str] = Field(None, description="ID of the miner") + miner_name: str = Field(default="", description="Name of the miner") + old_status: Optional[str] = Field(None, description="Previous miner status") + new_status: Optional[str] = Field(None, description="New miner status") diff --git a/core/edge_mining/adapters/domain/notification/__init__.py b/core/edge_mining/adapters/domain/notification/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/adapters/domain/notification/cli/__init__.py b/core/edge_mining/adapters/domain/notification/cli/__init__.py new file mode 100644 index 0000000..c742b53 --- /dev/null +++ b/core/edge_mining/adapters/domain/notification/cli/__init__.py @@ -0,0 +1 @@ +"""Adapters CLI for the Notification domain.""" diff --git a/core/edge_mining/adapters/domain/notification/cli/commands.py b/core/edge_mining/adapters/domain/notification/cli/commands.py new file mode 100644 index 0000000..5449458 --- /dev/null +++ b/core/edge_mining/adapters/domain/notification/cli/commands.py @@ -0,0 +1,717 @@ +"""CLI commands for the notification domain.""" + +from typing import List, Optional, Union + +import click + +from edge_mining.adapters.infrastructure.cli.utils import ( + print_configuration, + process_filters, +) +from edge_mining.adapters.infrastructure.external_services.cli.commands import ( + handle_add_external_service, + print_external_service_details, + select_external_service, +) +from edge_mining.application.interfaces import ConfigurationServiceInterface +from edge_mining.domain.common import EntityId +from edge_mining.domain.notification.common import NotificationAdapter +from edge_mining.domain.notification.entities import Notifier +from edge_mining.shared.adapter_configs.notification import ( + DummyNotificationConfig, + TelegramNotificationConfig, +) +from edge_mining.shared.adapter_maps.notification import ( + NOTIFIER_TYPE_EXTERNAL_SERVICE_MAP, +) +from edge_mining.shared.external_services.entities import ExternalService +from edge_mining.shared.interfaces.config import NotificationConfig +from edge_mining.shared.logging.port import LoggerPort + +from edge_mining.adapters.utils import run_async_func + + +def select_notifier_adapter() -> Optional[NotificationAdapter]: + """Select a notifier adapter type from the list.""" + click.echo("Select Notifier Adapter:") + for idx, adapter in enumerate(NotificationAdapter): + click.echo(f"{idx}. {adapter.name}") + + click.echo("") + choice: str = click.prompt("Choose a Notifier", type=str) + choice = choice.strip().lower() + + if not choice.isdigit() or int(choice) < 0 or int(choice) >= len(NotificationAdapter): + click.echo(click.style("Invalid index. Aborting selection.", fg="red")) + return None + + notifier_type_values = [n.value for n in NotificationAdapter] + + selected_type = NotificationAdapter(notifier_type_values[int(choice)]) + return selected_type + + +def handle_notifier_dummy_config() -> NotificationConfig: + """Handle configuration for the Dummy notifier.""" + return DummyNotificationConfig() + + +def handle_notifier_telegram_config() -> NotificationConfig: + """Handle configuration for the Telegram notifier.""" + bot_token: str = click.prompt("Telegram Bot Token", type=str) + chat_id: str = click.prompt("Telegram Chat ID", type=str) + + return TelegramNotificationConfig(bot_token=bot_token, chat_id=chat_id) + + +def handle_notifier_configuration( + adapter_type: NotificationAdapter, +) -> Optional[NotificationConfig]: + """Handle configuration for a notifier.""" + config: Optional[NotificationConfig] = None + if adapter_type == NotificationAdapter.DUMMY: + config = handle_notifier_dummy_config() + if adapter_type == NotificationAdapter.TELEGRAM: + config = handle_notifier_telegram_config() + if config is None: + click.echo(click.style("Unsupported notifier type selected. Aborting.", fg="red")) + return config + + +def handle_add_notifier(configuration_service: ConfigurationServiceInterface, logger: LoggerPort) -> Optional[Notifier]: + """Menu to add a new notifier.""" + click.echo(click.style("\n--- Add Notifier ---", fg="yellow")) + name: str = click.prompt("Name of the notifier", type=str) + adapter_type: Optional[NotificationAdapter] = select_notifier_adapter() + + if adapter_type is None: + click.echo(click.style("Invalid notifier type selected. Aborting.", fg="red")) + return None + + new_notifier: Notifier = Notifier() + new_notifier.name = name + new_notifier.adapter_type = adapter_type + new_notifier.config = None + new_notifier.external_service_id = None + + config: Optional[NotificationConfig] = handle_notifier_configuration(adapter_type=new_notifier.adapter_type) + if config is None: + click.echo(click.style("Invalid configuration. Aborting.", fg="red")) + return None + + new_notifier.config = config + + needed_external_service = NOTIFIER_TYPE_EXTERNAL_SERVICE_MAP.get(new_notifier.adapter_type, None) + # If an external service is required for the selected adapter type + if needed_external_service: + # If external service is needed, check if some one is already configured + external_services: List[ExternalService] = configuration_service.list_external_services() + external_service: Optional[ExternalService] = None + if external_services: + external_service = select_external_service( + configuration_service=configuration_service, + logger=logger, + filter_type=[needed_external_service], + ) + if external_service: + new_notifier.external_service_id = external_service.id + else: + click.echo("") + click.echo( + click.style( + "No external services configured. " + "Please configure an external service first " + "and then add a notifier.", + fg="yellow", + ) + ) + add_external_service: bool = click.confirm( + "Do you want to add an external service now?", + default=True, + abort=False, + ) + if add_external_service: + external_service = handle_add_external_service( + configuration_service=configuration_service, + logger=logger, + ) + if external_service: + click.echo( + click.style( + f"External Service '{external_service.name}', " + f"Type: {external_service.adapter_type.name} " + f"(ID: {external_service.id}) successfully added to current notifier.", + fg="green", + ) + ) + new_notifier.external_service_id = external_service.id + else: + click.echo(click.style("Aborting notifier addition.", fg="red")) + return None + + added: Optional[Notifier] = None + try: + added = run_async_func( + configuration_service.add_notifier( + name=new_notifier.name, + adapter_type=new_notifier.adapter_type, + config=new_notifier.config, + external_service_id=new_notifier.external_service_id, + ) + ) + click.echo( + click.style( + f"Notifier '{added.name}' (ID: {added.id}) successfully added.", + fg="green", + ) + ) + except Exception as e: + added = None + logger.error(f"Error adding notifier: {e}") + click.echo(click.style(f"Error adding notifier: {e}", fg="red"), err=True) + click.pause("Press any key to return to the menu...") + return added + + +def handle_list_notifiers(configuration_service: ConfigurationServiceInterface, logger: LoggerPort) -> None: + """List all notifiers.""" + click.echo(click.style("\n--- Configured Notifiers ---", fg="yellow")) + + notifiers: List[Notifier] = configuration_service.list_notifiers() + if not notifiers: + click.echo(click.style("No notifiers configured.", fg="yellow")) + else: + for n in notifiers: + click.echo( + "-> " + + "Name: " + + click.style(f"{n.name}, ", fg="blue") + + "ID: " + + click.style(f"{n.id}, ", fg="yellow") + + "Type: " + + click.style(f"{n.adapter_type.name}", fg="green") + ) + click.echo("") + click.pause("Press any key to return to the menu...") + + +def select_notifier( + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, + default_id: Optional[EntityId] = None, + filter_type: Optional[List[NotificationAdapter]] = None, + allow_multiple: bool = False, + only_ids: Optional[List[EntityId]] = None, + exclude_ids: Optional[List[EntityId]] = None, +) -> Union[Optional[Notifier], List[Notifier]]: + """Select one or more notifiers from the list.""" + + notifiers: List[Notifier] = configuration_service.list_notifiers() + if not notifiers: + allow_multiple = False + else: + if only_ids: + notifiers = [n for n in notifiers if n.id in only_ids] + if exclude_ids: + notifiers = [n for n in notifiers if n.id not in exclude_ids] + if len(notifiers) == 1: + allow_multiple = False + + if allow_multiple: + click.echo(click.style("\n--- Select Notifiers ---", fg="yellow")) + else: + click.echo(click.style("\n--- Select Notifier ---", fg="yellow")) + + if not notifiers: + click.echo(click.style("No notifiers configured.", fg="yellow")) + return None + + filter_type = process_filters(filter_type) + + if filter_type: + click.echo( + "Filtering notifier by types: " + click.style(f"{', '.join([n.name for n in filter_type])}", fg="blue") + ) + notifiers = [n for n in notifiers if n.adapter_type in filter_type] + + default_idx = "" + for idx, n in enumerate(notifiers): + click.echo( + f"{idx}. " + + "Name: " + + click.style(f"{n.name}, ", fg="blue") + + "ID: " + + click.style(f"{n.id}, ", fg="yellow") + + "Type: " + + click.style(f"{n.adapter_type.name}", fg="green") + ) + + if default_id and n.id == default_id: + default_idx = str(idx) + + click.echo("\nb. Back to menu\n") + + if allow_multiple: + notifier_indices: str = click.prompt("Choose Notifier indices (comma separated)", type=str, default=default_idx) + notifier_indices = notifier_indices.strip().lower() + if notifier_indices == "b": + return None + + # Parse comma-separated indices + selected_notifiers: List[Notifier] = [] + try: + indices = [idx.strip() for idx in notifier_indices.split(",")] + for idx_str in indices: + if not idx_str.isdigit(): + click.echo(click.style(f"Invalid index '{idx_str}'. Skipping.", fg="yellow")) + continue + + idx = int(idx_str) + if idx < 0 or idx >= len(notifiers): + click.echo(click.style(f"Index {idx} out of range. Skipping.", fg="yellow")) + continue + + selected_notifiers.append(notifiers[idx]) + + if not selected_notifiers: + click.echo(click.style("No valid notifiers selected. Aborting selection.", fg="red")) + return None + + return selected_notifiers + except Exception as e: + click.echo(click.style(f"Error parsing indices: {e}. Aborting selection.", fg="red")) + return None + else: + n_idx: str = click.prompt("Choose a Notifier index", type=str, default=default_idx) + n_idx = n_idx.strip().lower() + if n_idx == "b": + return None + + if not n_idx.isdigit() or int(n_idx) < 0 or int(n_idx) >= len(notifiers): + click.echo(click.style("Invalid index. Aborting selection.", fg="red")) + return None + + selected_n = notifiers[int(n_idx)] + return selected_n + + +def print_notifier_config(notifier: Notifier) -> None: + """Print the configuration of a notifier.""" + configuration_class = notifier.config.__class__.__name__ if notifier.config else "---" + click.echo("| Configuration: " + click.style(f"{configuration_class}", fg="cyan")) + if notifier.config: + print_configuration(notifier.config.to_dict()) + + +def print_notifier_details( + notifier: Notifier, + configuration_service: ConfigurationServiceInterface, + show_external_service: bool = False, + show_optimization_unit_list: bool = False, +) -> None: + """Print the details of a notifier.""" + click.echo("") + click.echo("| Name: " + click.style(notifier.name, fg="blue")) + click.echo("| ID: " + click.style(notifier.id, fg="yellow")) + click.echo("| Adapter: " + click.style(notifier.adapter_type.name, fg="green")) + print_notifier_config(notifier) + click.echo("") + + if show_external_service: + if notifier.external_service_id: + external_service = configuration_service.get_external_service(notifier.external_service_id) + if external_service: + click.echo("EXTERNAL SERVICE DETAILS:") + print_external_service_details( + service=external_service, + configuration_service=configuration_service, + show_config=False, + show_linked_instances=False, + ) + else: + click.echo( + "| External service: " + click.style(str(notifier.external_service_id), fg="red") + " (not found)" + ) + else: + click.echo("| External service: None") + click.echo("") + + if show_optimization_unit_list: + optimization_units = configuration_service.filter_optimization_units(filter_by_notifiers=[notifier.id]) + if not optimization_units: + click.echo(click.style("No optimization units use this notifier.", fg="yellow")) + else: + click.echo("Optimization Units using this notifier:") + for ou in optimization_units: + click.echo( + "-> " + + "Name: " + + click.style(f"{ou.name}, ", fg="blue") + + "Enabled: " + + click.style( + f"{ou.is_enabled}", + fg="green" if ou.is_enabled else "red", + ) + ) + click.echo("") + + +def update_single_notifier( + notifier: Notifier, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> Optional[Notifier]: + """Update a single notifier.""" + click.echo(click.style("\n--- Update Notifier ---", fg="yellow")) + name: str = click.prompt("New name of the notifier", type=str, default=notifier.name) + + new_notifier: Notifier = Notifier() + new_notifier.name = name + new_notifier.adapter_type = notifier.adapter_type + new_notifier.config = notifier.config + new_notifier.external_service_id = notifier.external_service_id + + click.echo("\nDo you want to change the notifier configuration?") + change_config: bool = click.confirm("Change configuration", default=True, prompt_suffix="") + if change_config: + config: Optional[NotificationConfig] = handle_notifier_configuration(adapter_type=new_notifier.adapter_type) + if config is None: + click.echo(click.style("Invalid configuration. Aborting.", fg="red")) + return None + # Update the notifier configuration + new_notifier.config = config + + if new_notifier.config is None: + click.echo(click.style("Notifier configuration is required. Aborting.", fg="red")) + return None + + needed_external_service = NOTIFIER_TYPE_EXTERNAL_SERVICE_MAP.get(new_notifier.adapter_type, None) + + if new_notifier.external_service_id: + click.echo("\nCurrent external service: ") + current_external_service = configuration_service.get_external_service(new_notifier.external_service_id) + if current_external_service: + print_external_service_details( + service=current_external_service, + configuration_service=configuration_service, + show_linked_instances=False, + ) + else: + click.echo( + click.style( + "Current external service is not valid. Please select a new one.", + fg="red", + ) + ) + + if needed_external_service: + external_service: Optional[ExternalService] = None + + # If external service is needed, check if some one is already configured + external_services: List[ExternalService] = configuration_service.list_external_services() + if external_services: + if new_notifier.external_service_id: + # Ask to change the external service + click.echo( + click.style( + "\nDo you want to change the external service for this notifier?", + fg="yellow", + ) + ) + change_external_service: bool = click.confirm("Change external service", default=True, prompt_suffix="") + if change_external_service: + external_service = select_external_service( + configuration_service=configuration_service, + logger=logger, + filter_type=[needed_external_service], + ) + + if external_service is None: + click.echo( + click.style( + "No external service selected. Keeping the current one.", + fg="yellow", + ) + ) + else: + new_notifier.external_service_id = external_service.id + else: + # Check if external service exists + current_external_service = configuration_service.get_external_service( + new_notifier.external_service_id + ) + + # If current external service not exists, ask to select a new one + if not current_external_service: + click.echo( + click.style( + "Current external service is not valid. Please select a new one.", + fg="red", + ) + ) + external_service = select_external_service( + configuration_service=configuration_service, + logger=logger, + filter_type=[needed_external_service], + ) + if external_service is None: + click.echo( + click.style( + "No external service selected. Aborting update.", + fg="red", + ) + ) + return None + new_notifier.external_service_id = external_service.id + + if current_external_service and current_external_service.config: + # Check if the current external service is still valid + external_service_valid = current_external_service.config.is_valid( + current_external_service.adapter_type + ) + if not external_service_valid: + click.echo( + click.style( + "Current external service configuration is not valid. Please select a new one.", + fg="red", + ) + ) + external_service = select_external_service( + configuration_service=configuration_service, + logger=logger, + filter_type=[needed_external_service], + ) + if external_service is None: + click.echo( + click.style( + "No external service selected. Aborting update.", + fg="red", + ) + ) + return None + new_notifier.external_service_id = external_service.id + else: + # If no external service is configured, ask to select one + click.echo( + click.style( + "\nDo you want to select an external service for this notifier?", + fg="yellow", + ) + ) + add_external_service = click.confirm("Add external service", default=True, prompt_suffix="") + if add_external_service: + external_service = select_external_service( + configuration_service=configuration_service, + logger=logger, + filter_type=[needed_external_service], + ) + if external_service is None: + click.echo( + click.style( + "No external service selected. Aborting update.", + fg="red", + ) + ) + return None + new_notifier.external_service_id = external_service.id + else: + # Missing external service, ask to add one + click.echo("") + click.echo( + click.style( + "No external services configured. Please configure an external service first " + "and then update the notifier.", + fg="yellow", + ) + ) + add_external_service = click.confirm( + "Do you want to add an external service now?", + default=True, + abort=False, + ) + if add_external_service: + external_service = handle_add_external_service( + configuration_service=configuration_service, + logger=logger, + ) + if external_service: + click.echo( + click.style( + f"External Service '{external_service.name}', " + f"Type: {external_service.adapter_type.name} " + f"(ID: {external_service.id}) successfully added to current notifier.", + fg="green", + ) + ) + new_notifier.external_service_id = external_service.id + else: + click.echo(click.style("Aborting energy monitor addition.", fg="red")) + return None + + try: + updated_notifier = run_async_func( + configuration_service.update_notifier( + notifier_id=new_notifier.id, + name=new_notifier.name, + config=new_notifier.config, + external_service_id=new_notifier.external_service_id, + ) + ) + click.echo( + click.style( + f"Notifier '{updated_notifier.name}' (ID: {updated_notifier.id}) successfully updated.", + fg="green", + ) + ) + except Exception as e: + updated_notifier = None + logger.error(f"Error updating notifier: {e}") + click.echo(click.style(f"Error updating notifier: {e}", fg="red"), err=True) + return None + finally: + click.pause("Press any key to return to the menu...") + + return updated_notifier + + +def delete_single_notifier( + notifier: Notifier, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> bool: + """Delete a single notifier.""" + delete_confirm: bool = click.confirm( + f"Are you sure you want to delete the notifier '{notifier.name}' (ID: {notifier.id})?", + abort=False, + default=False, + prompt_suffix="", + ) + if not delete_confirm: + click.echo(click.style("Deletion cancelled.", fg="yellow")) + return False + + try: + removed = run_async_func(configuration_service.remove_notifier(notifier_id=notifier.id)) + logger.info(f"Notifier '{removed.name}' (ID: {removed.id}) successfully removed.") + click.echo( + click.style( + f"Notifier '{removed.name}' (ID: {removed.id}) successfully removed.", + fg="green", + ) + ) + return True + except Exception as e: + logger.error(f"Error deleting notifier: {e}") + click.echo(click.style(f"Error deleting notifier: {e}", fg="red"), err=True) + return False + + +def manage_single_notifier_menu( + notifier: Notifier, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> str: + """Menu for managing a single notifier.""" + while True: + click.echo("\n" + click.style("--- MANAGE NOTIFIER ---", fg="blue", bold=True)) + print_notifier_details( + notifier=notifier, + configuration_service=configuration_service, + show_external_service=True, + show_optimization_unit_list=True, + ) + click.echo("1. Update Notifier") + click.echo("2. Delete Notifier") + click.echo("") + click.echo("b. Back to notifier menu") + click.echo("q. Close application") + click.echo("-----------------") + + choice: str = click.prompt("Choose an option", type=str) + choice = choice.strip().lower() + + click.clear() + + if choice == "1": + updated_notifier = update_single_notifier( + notifier=notifier, + configuration_service=configuration_service, + logger=logger, + ) + notifier = updated_notifier or notifier + continue + elif choice == "2": + delete_status = delete_single_notifier( + notifier=notifier, + configuration_service=configuration_service, + logger=logger, + ) + if delete_status: + return "b" + elif choice == "b": + break + elif choice == "q": + break + else: + click.echo(click.style("Invalid choice. Try again.", fg="red")) + click.pause("Press any key to return to the menu...") + + return choice + + +def handle_manage_notifier(configuration_service: ConfigurationServiceInterface, logger: LoggerPort) -> str: + """Menu to manage a notifier.""" + selected_notifier = select_notifier(configuration_service, logger) + if selected_notifier is None: + click.echo(click.style("No notifier selected. Aborting.", fg="red")) + return "b" + + if isinstance(selected_notifier, list): + selected_notifier = selected_notifier[0] + + choice = manage_single_notifier_menu( + notifier=selected_notifier, + configuration_service=configuration_service, + logger=logger, + ) + return choice + + +def notifier_menu(configuration_service: ConfigurationServiceInterface, logger: LoggerPort) -> str: + """Menu for managing Notifiers.""" + while True: + click.echo("\n" + click.style("--- NOTIFIER MENU ---", fg="blue", bold=True)) + click.echo("1. Add Notifier") + click.echo("2. List Notifiers") + click.echo("3. Manage Notifier") + click.echo("") + click.echo("b. Back to main menu") + click.echo("q. Close application") + click.echo("-----------------") + + choice: str = click.prompt("Choose an option", type=str) + choice = choice.strip().lower() + + click.clear() + + if choice == "1": + handle_add_notifier(configuration_service, logger) + elif choice == "2": + handle_list_notifiers(configuration_service, logger) + elif choice == "3": + sub_choice = handle_manage_notifier( + configuration_service=configuration_service, + logger=logger, + ) + if sub_choice == "q": + choice = "q" + break + elif choice == "b": + break + elif choice == "q": + break + else: + click.echo(click.style("Invalid choice. Try again.", fg="red")) + click.pause("Press any key to return to the menu...") + + return choice diff --git a/core/edge_mining/adapters/domain/notification/fast_api/__init__.py b/core/edge_mining/adapters/domain/notification/fast_api/__init__.py new file mode 100644 index 0000000..c8f8352 --- /dev/null +++ b/core/edge_mining/adapters/domain/notification/fast_api/__init__.py @@ -0,0 +1 @@ +"""Adapter that uses FastAPI infrastructure for notification domain API""" diff --git a/core/edge_mining/adapters/domain/notification/fast_api/router.py b/core/edge_mining/adapters/domain/notification/fast_api/router.py new file mode 100644 index 0000000..2ae5c06 --- /dev/null +++ b/core/edge_mining/adapters/domain/notification/fast_api/router.py @@ -0,0 +1,254 @@ +"""API Router for notification domain""" + +import uuid +from typing import Annotated, Any, Dict, List, Optional, cast + +from fastapi import APIRouter, Depends, HTTPException + +from edge_mining.adapters.domain.notification.schemas import ( + NOTIFICATION_CONFIG_SCHEMA_MAP, + NotifierCreateSchema, + NotifierSchema, + NotifierUpdateSchema, +) + +# Import dependency injection setup functions +from edge_mining.adapters.infrastructure.api.setup import ( + get_adapter_service, + get_config_service, +) +from edge_mining.application.interfaces import ( + AdapterServiceInterface, + ConfigurationServiceInterface, +) +from edge_mining.domain.common import EntityId +from edge_mining.domain.notification.common import NotificationAdapter +from edge_mining.domain.notification.entities import Notifier +from edge_mining.domain.notification.exceptions import ( + NotifierAlreadyExistsError, + NotifierConfigurationError, + NotifierNotFoundError, +) +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.interfaces.config import Configuration, NotificationConfig + +router = APIRouter() + + +@router.get("/notifiers", response_model=List[NotifierSchema]) +async def get_notifiers_list( + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> List[NotifierSchema]: + """Get a list of all configured notifiers.""" + try: + notifiers = config_service.list_notifiers() + + # Convert to notifier schema + notifier_schemas: List[NotifierSchema] = [] + + for notifier in notifiers: + notifier_schemas.append(NotifierSchema.from_model(notifier)) + + return notifier_schemas + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/notifiers/types", response_model=List[NotificationAdapter]) +async def get_notifier_types() -> List[NotificationAdapter]: + """Get a list of available notifier types.""" + try: + return [NotificationAdapter(adapter.value) for adapter in NotificationAdapter] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get( + "/notifiers/types/{adapter_type}/config-schema", + response_model=Dict[str, Any], +) +async def get_notifier_config_schema( + adapter_type: NotificationAdapter, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> Dict[str, Any]: + """Get the configuration schema for a specific notifier type.""" + try: + try: + notification_adapter = NotificationAdapter(adapter_type) + except ValueError as e: + raise ValueError(f"Invalid notification adapter type: {adapter_type}") from e + + # Get the corresponding configuration class for the adapter type + notifier_config_type: Optional[type[NotificationConfig]] = config_service.get_notifier_config_by_type( + notification_adapter + ) + + if notifier_config_type is None: + raise NotifierConfigurationError(f"No configuration class found for adapter type {adapter_type}") + + # Map the configuration class to its corresponding schema + notifier_config_schema = NOTIFICATION_CONFIG_SCHEMA_MAP.get(notifier_config_type, None) + + if notifier_config_schema is None: + raise NotifierConfigurationError(f"No schema found for configuration class {notifier_config_type}") + + return notifier_config_schema.model_json_schema() + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get( + "/notifiers/types/{adapter_type}/external-services", + response_model=Optional[ExternalServiceAdapter], +) +async def get_notifier_type_external_service_types( + adapter_type: NotificationAdapter, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> Optional[ExternalServiceAdapter]: + """Get a list of compatible external service types for a specific notifier type.""" + try: + needed_external_service = config_service.get_notifier_external_service_adapter(adapter_type) + + return needed_external_service + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/notifiers/{notifier_id}", response_model=NotifierSchema) +async def get_notifier_details( + notifier_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> NotifierSchema: + """Get details for a specific notifier.""" + try: + notifier: Optional[Notifier] = config_service.get_notifier(notifier_id) + + if notifier is None: + raise NotifierNotFoundError(f"Notifier with ID {notifier_id} not found") + + response = NotifierSchema.from_model(notifier) + + return response + except NotifierNotFoundError as e: # Catch specific domain errors if needed + raise HTTPException(status_code=404, detail="Notifier not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/notifiers", response_model=NotifierSchema) +async def add_notifier( + notifier_schema: NotifierCreateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> NotifierSchema: + """Add a new notifier.""" + try: + notifier_to_add: Notifier = notifier_schema.to_model() + + if notifier_to_add.config is None: + raise NotifierConfigurationError("Notifier configuration should be set") + + new_notifier = await config_service.add_notifier( + name=notifier_to_add.name, + adapter_type=notifier_to_add.adapter_type, + config=notifier_to_add.config, + external_service_id=notifier_to_add.external_service_id, + ) + + response = NotifierSchema.from_model(new_notifier) + + return response + except NotifierAlreadyExistsError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except NotifierConfigurationError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.put("/notifiers/{notifier_id}", response_model=NotifierSchema) +async def update_notifier( + notifier_id: EntityId, + notifier_update: NotifierUpdateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> NotifierSchema: + """Update a notifier's details.""" + try: + notifier = config_service.get_notifier(notifier_id) + + if notifier is None: + raise NotifierNotFoundError(f"Notifier with ID {notifier_id} not found") + + configuration: Optional[Configuration] = None + if notifier_update.config: + config_cls = config_service.get_notifier_config_by_type(notifier.adapter_type) + if config_cls is None: + raise NotifierConfigurationError( + f"No configuration class found for adapter type {notifier.adapter_type}" + ) + configuration = config_cls.from_dict(notifier_update.config) + + external_service_id: Optional[EntityId] = None + if notifier_update.external_service_id: + external_service_id = EntityId(uuid.UUID(notifier_update.external_service_id)) + + updated_notifier = await config_service.update_notifier( + notifier_id=notifier.id, + name=notifier_update.name or "", + config=cast(NotificationConfig, configuration), + external_service_id=external_service_id, + ) + + response = NotifierSchema.from_model(updated_notifier) + + return response + except NotifierNotFoundError as e: + raise HTTPException(status_code=404, detail="Notifier not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.delete("/notifiers/{notifier_id}", response_model=NotifierSchema) +async def remove_notifier( + notifier_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> NotifierSchema: + """Remove a notifier.""" + try: + deleted_notifier = await config_service.remove_notifier(notifier_id) + + response = NotifierSchema.from_model(deleted_notifier) + + return response + except NotifierNotFoundError as e: + raise HTTPException(status_code=404, detail="Notifier not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/notifiers/{notifier_id}/test", response_model=Dict[str, str]) +async def test_notifier( + notifier_id: EntityId, + adapter_service: Annotated[AdapterServiceInterface, Depends(get_adapter_service)], +) -> Dict[str, str]: + """Test a notifier by sending a test notification.""" + try: + notifier_port = await adapter_service.get_notifier(notifier_id) + + if notifier_port is None: + raise NotifierNotFoundError(f"Notifier with ID {notifier_id} not found") + + # Send a test notification + test_title = "Test Notification" + test_message = "This is a test notification from Edge Mining System" + send_status = await notifier_port.send_notification(test_title, test_message) + + status_string = "success" if send_status else "failed" + message_string = "Test notification sent successfully" if send_status else "Failed to send test notification" + + return {"status": status_string, "message": message_string} + except NotifierNotFoundError as e: + raise HTTPException(status_code=404, detail="Notifier not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to send test notification: {str(e)}") from e diff --git a/core/edge_mining/adapters/domain/notification/notifiers/__init__.py b/core/edge_mining/adapters/domain/notification/notifiers/__init__.py new file mode 100644 index 0000000..a67d7d4 --- /dev/null +++ b/core/edge_mining/adapters/domain/notification/notifiers/__init__.py @@ -0,0 +1 @@ +"""Collection of notifier adapters.""" diff --git a/core/edge_mining/adapters/domain/notification/notifiers/dummy.py b/core/edge_mining/adapters/domain/notification/notifiers/dummy.py new file mode 100644 index 0000000..9065121 --- /dev/null +++ b/core/edge_mining/adapters/domain/notification/notifiers/dummy.py @@ -0,0 +1,20 @@ +""" +Dummy adapter (Implementation of Port) that simulates a notification sender +for Edge Mining Application +""" + +import logging + +from edge_mining.domain.notification.ports import NotificationPort + +logger = logging.getLogger(__name__) + + +class DummyNotifier(NotificationPort): + """Prints notifications to the console/log.""" + + async def send_notification(self, title: str, message: str) -> bool: + full_message = f"--- NOTIFICATION ---\nTitle: {title}\nMessage: {message}\n--------------------" + print(full_message) + logger.info("Notification Sent: Title='%s'", title) + return True diff --git a/core/edge_mining/adapters/domain/notification/notifiers/telegram.py b/core/edge_mining/adapters/domain/notification/notifiers/telegram.py new file mode 100644 index 0000000..e37930a --- /dev/null +++ b/core/edge_mining/adapters/domain/notification/notifiers/telegram.py @@ -0,0 +1,145 @@ +""" +Telegram adapter (Implementation of Port) that uses telegram +as notification sender for Edge Mining Application +""" + +from typing import Optional + +import re + +import telegram +from telegram.constants import ParseMode +from telegram.error import TelegramError + +from edge_mining.domain.notification.exceptions import NotifierConfigurationError +from edge_mining.domain.notification.ports import NotificationPort +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.adapter_configs.notification import TelegramNotificationConfig +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.factories import NotificationAdapterFactory +from edge_mining.shared.logging.port import LoggerPort + + +class TelegramNotifierFactory(NotificationAdapterFactory): + """ + Creates a factory for Telegram notification adapter. + + This factory aims to simplifying the building of Telegram. + """ + + def create( + self, + config: Optional[Configuration], + logger: Optional[LoggerPort], + external_service: Optional[ExternalServicePort], + ) -> NotificationPort: + """Create a notification adapter""" + + if not isinstance(config, TelegramNotificationConfig): + raise NotifierConfigurationError( + "Invalid configuration type for Telegram notifier. Expected TelegramNotificationConfig." + ) + + # Get the config from the energy monitor config + notifier_config: TelegramNotificationConfig = config + + if not notifier_config.bot_token: + raise NotifierConfigurationError("Bot Token is required for Telegram notifier.") + + if not notifier_config.chat_id: + raise NotifierConfigurationError("Chat ID is required for Telegram notifier.") + + return TelegramNotifier( + bot_token=notifier_config.bot_token, + chat_id=notifier_config.chat_id, + logger=logger, + ) + + +# MarkdownV2 Special Characters That Need to Be Escaped +# See: https://core.telegram.org/bots/api#markdownv2-style +ESCAPE_CHARS = r"_*[]()~`>#+-=|{}.!" + + +def escape_markdown_v2(text: str) -> str: + """Helper function to escape text for Telegram MarkdownV2 parsing.""" + # Use a regex to find and replace all special characters required + return re.sub(f"([{re.escape(ESCAPE_CHARS)}])", r"\\\1", text) + + +class TelegramNotifier(NotificationPort): + """Sends notifications to a specified Telegram chat using a bot.""" + + def __init__(self, bot_token: str, chat_id: str, logger: Optional[LoggerPort]): + self.logger = logger + + if not bot_token or not chat_id: + raise ValueError("Telegram Bot Token and Chat ID are required.") + + self.token = bot_token + self.chat_id = chat_id + if self.logger: + self.logger.info(f"Initializing TelegramNotifier for chat ID {chat_id}.") + + try: + # Create the Bot instance. + self.bot = telegram.Bot(token=self.token) + + if self.logger: + self.logger.debug("Telegram Bot instance created.") + except Exception as e: + if self.logger: + self.logger.error(f"Failed to initialize Telegram Bot instance: {e}") + raise ConnectionError(f"Could not initialize Telegram Bot: {e}") from e + + async def send_notification(self, title: str, message: str) -> bool: + """Sends a formatted notification message to the configured Telegram chat.""" + if not self.bot: + if self.logger: + self.logger.error("Telegram Bot not initialized. Cannot send notification.") + return False + + # Format the message using MarkdownV2 (make sure to escape!) + escaped_title = escape_markdown_v2(title) + escaped_message = escape_markdown_v2(message) + formatted_message = f"*{escaped_title}*\n\n{escaped_message}" + + # Limit the message length (Telegram has a limit of 4096 characters) + max_len = 4096 + if len(formatted_message) > max_len: + if self.logger: + self.logger.warning(f"Notification message exceeds Telegram limit ({max_len} chars). Truncating.") + # Truncate preserving the base format + truncated_message = escape_markdown_v2( + message[: max_len - len(escaped_title) - 20] + ) # Leave space for title and "..." + formatted_message = f"*{escaped_title}*\n\n{truncated_message}\n\n\\.\\.\\. \\(truncated\\)" + + if self.logger: + self.logger.debug(f"Sending notification to Telegram chat {self.chat_id}: Title='{title}'") + try: + await self.bot.send_message( + chat_id=self.chat_id, + text=formatted_message, + parse_mode=ParseMode.MARKDOWN_V2, + ) + if self.logger: + self.logger.info(f"Successfully sent notification to Telegram chat {self.chat_id}") + return True + except TelegramError as e: + # Handle specific Telegram API errors + if self.logger: + self.logger.error(f"Telegram API error sending notification: {e}") + if "chat not found" in str(e).lower(): + if self.logger: + self.logger.error(f"Invalid chat_id configured: {self.chat_id}") + elif "bot was blocked by the user" in str(e).lower(): + if self.logger: + self.logger.warning(f"Bot was blocked by the user in chat {self.chat_id}.") + # Other specific errors can be handled here + return False + except Exception as e: + # Handle other errors (e.g. network) + if self.logger: + self.logger.error(f"Unexpected error sending notification via Telegram: {e}") + return False diff --git a/core/edge_mining/adapters/domain/notification/repositories.py b/core/edge_mining/adapters/domain/notification/repositories.py new file mode 100644 index 0000000..ffb0b10 --- /dev/null +++ b/core/edge_mining/adapters/domain/notification/repositories.py @@ -0,0 +1,385 @@ +"""Repositories for Notification Domain.""" + +import json +import sqlite3 +from typing import List, Optional + +from sqlalchemy import select + +from edge_mining.adapters.domain.notification.tables import notifiers_table +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.adapters.infrastructure.persistence.sqlite import BaseSqliteRepository +from edge_mining.domain.common import EntityId +from edge_mining.domain.exceptions import ConfigurationError +from edge_mining.domain.notification.common import NotificationAdapter +from edge_mining.domain.notification.entities import Notifier +from edge_mining.domain.notification.exceptions import ( + NotifierAlreadyExistsError, + NotifierConfigurationError, + NotifierError, + NotifierNotFoundError, +) +from edge_mining.domain.notification.ports import NotifierRepository +from edge_mining.shared.adapter_maps.notification import NOTIFIER_CONFIG_TYPE_MAP +from edge_mining.shared.interfaces.config import NotificationConfig + +# Simple In-Memory implementation for testing and basic use + + +class InMemoryNotifierRepository(NotifierRepository): + """In-memory implementation of NotifierRepository for testing purposes.""" + + def __init__(self): + self._notifiers: List[Notifier] = [] + + def add(self, notifier: Notifier) -> None: + self._notifiers.append(notifier) + + def get_by_id(self, notifier_id: EntityId) -> Optional[Notifier]: + for notifier in self._notifiers: + if notifier.id == notifier_id: + return notifier + return None + + def get_all(self) -> List[Notifier]: + return self._notifiers + + def update(self, notifier: Notifier) -> None: + for i, existing_notifier in enumerate(self._notifiers): + if existing_notifier.id == notifier.id: + self._notifiers[i] = notifier + return + + def remove(self, notifier_id: EntityId) -> None: + self._notifiers = [n for n in self._notifiers if n.id != notifier_id] + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[Notifier]: + """Retrieve a list of notifiers by their associated external service ID.""" + return [n for n in self._notifiers if n.external_service_id == external_service_id] + + +class SqliteNotifierRepository(NotifierRepository): + """SQLite implementation of NotifierRepository.""" + + def __init__(self, db: BaseSqliteRepository): + self._db = db + self.logger = db.logger + + self._create_tables() + + def _create_tables(self): + """Create the necessary table for the Notifier if it does not exist.""" + self.logger.debug(f"Ensuring SQLite tables exist for Notifier Repository in {self._db.db_path}...") + sql_statements = [ + """ + CREATE TABLE IF NOT EXISTS notifiers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + adapter_type TEXT NOT NULL, + config TEXT, -- JSON object of config + external_service_id TEXT -- Optional ID for external service integration + ); + """ + ] + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + for statement in sql_statements: + cursor.execute(statement) + + self.logger.debug("Notifiers tables checked/created successfully.") + except sqlite3.Error as e: + self.logger.error(f"Error creating SQLite tables: {e}") + raise ConfigurationError(f"DB error creating tables: {e}") from e + finally: + if conn: + conn.close() + + def _deserialize_config(self, adapter_type: NotificationAdapter, config_json: str) -> NotificationConfig: + """Deserialize a JSON string into NotificationConfig object.""" + data: dict = json.loads(config_json) + + if adapter_type not in NOTIFIER_CONFIG_TYPE_MAP: + raise NotifierConfigurationError(f"Error reading Notifier configuration. Invalid type '{adapter_type}'") + + config_class: Optional[type[NotificationConfig]] = NOTIFIER_CONFIG_TYPE_MAP.get(adapter_type) + if not config_class: + raise NotifierConfigurationError(f"Error creating Notifier configuration. Type '{adapter_type}'") + + config_instance = config_class.from_dict(data) + if not isinstance(config_instance, NotificationConfig): + raise NotifierConfigurationError( + f"Deserialized config is not of type NotificationConfig for adapter type '{adapter_type}'" + ) + return config_instance + + def _row_to_notifier(self, row: sqlite3.Row) -> Optional[Notifier]: + """Deserialize a row from the database into a Notifier object.""" + if not row: + return None + try: + adapter_type = NotificationAdapter(row["adapter_type"]) + + # Deserialize the config from the database row + config = self._deserialize_config(adapter_type, row["config"]) + + return Notifier( + id=EntityId(row["id"]), + name=row["name"], + adapter_type=adapter_type, + config=config, + external_service_id=(EntityId(row["external_service_id"]) if row["external_service_id"] else None), + ) + except (ValueError, KeyError) as e: + self.logger.error(f"Error deserializing Notifier from DB row: {row}. Error: {e}") + return None + + def add(self, notifier: Notifier) -> None: + """Add a new notifier to the repository.""" + self.logger.debug(f"Adding notifier {notifier.id} to SQLite repository.") + sql = """ + INSERT INTO notifiers (id, name, adapter_type, config, external_service_id) + VALUES (?, ?, ?, ?, ?); + """ + conn = self._db.get_connection() + try: + # Serialize config to JSON for storage + config_json: str = "" + if notifier.config: + config_json = json.dumps(notifier.config.to_dict()) + + with conn: + cursor = conn.cursor() + cursor.execute( + sql, + ( + notifier.id, + notifier.name, + notifier.adapter_type.value, + config_json, + notifier.external_service_id, + ), + ) + except sqlite3.IntegrityError as e: + self.logger.error(f"Integrity error adding notifier {notifier.id}: {e}") + # Could mean that the ID already exists + raise NotifierAlreadyExistsError( + f"notifier with ID {notifier.id} already exists or constraint violation: {e}" + ) from e + except sqlite3.Error as e: + self.logger.error(f"SQLite error adding notifier {notifier.id}: {e}") + raise NotifierError(f"DB error adding notifier: {e}") from e + finally: + if conn: + conn.close() + + def get_by_id(self, notifier_id: EntityId) -> Optional[Notifier]: + """Retrieve a notifier by its ID.""" + self.logger.debug(f"Retrieving notifier {notifier_id} from SQLite repository.") + sql = "SELECT * FROM notifiers WHERE id = ?;" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (notifier_id,)) + row = cursor.fetchone() + return self._row_to_notifier(row) + except sqlite3.Error as e: + self.logger.error(f"SQLite error retrieving notifier {notifier_id}: {e}") + raise NotifierNotFoundError(f"DB error retrieving notifier: {e}") from e + finally: + if conn: + conn.close() + + def get_all(self) -> List[Notifier]: + """Retrieve all notifiers from the repository.""" + self.logger.debug("Retrieving all notifiers from SQLite repository.") + sql = "SELECT * FROM notifiers;" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql) + rows = cursor.fetchall() + notifiers = [] + for row in rows: + notifier = self._row_to_notifier(row) + if notifier: + notifiers.append(notifier) + except sqlite3.Error as e: + self.logger.error(f"SQLite error retrieving all notifiers: {e}") + return [] + finally: + if conn: + conn.close() + return notifiers + + def update(self, notifier: Notifier) -> None: + """Update an existing notifier in the repository.""" + self.logger.debug(f"Updating notifier {notifier.id} in SQLite repository.") + sql = """ + UPDATE notifiers + SET name = ?, adapter_type = ?, config = ?, external_service_id = ? + WHERE id = ?; + """ + conn = self._db.get_connection() + try: + # Serialize config to JSON for storage + config_json: str = "" + if notifier.config: + config_json = json.dumps(notifier.config.to_dict()) + + with conn: + cursor = conn.cursor() + cursor.execute( + sql, + ( + notifier.name, + notifier.adapter_type.value, + config_json, + notifier.external_service_id, + notifier.id, + ), + ) + if cursor.rowcount == 0: + raise NotifierNotFoundError(f"Notifier with ID {notifier.id} not found.") + except sqlite3.Error as e: + self.logger.error(f"SQLite error updating notifier {notifier.id}: {e}") + raise NotifierError(f"DB error updating notifier: {e}") from e + finally: + if conn: + conn.close() + + def remove(self, notifier_id: EntityId) -> None: + """Remove a notifier from the repository.""" + self.logger.debug(f"Removing notifier {notifier_id} from SQLite repository.") + sql = "DELETE FROM notifiers WHERE id = ?;" + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + cursor.execute(sql, (notifier_id,)) + if cursor.rowcount == 0: + self.logger.warning(f"Attempted to remove non-existent notifier {notifier_id}.") + # There is no need to raise an exception here, removing a + # non-existent is idempotent. + except sqlite3.Error as e: + self.logger.error(f"SQLite error removing notifier {notifier_id}: {e}") + raise NotifierError(f"DB error removing notifier: {e}") from e + finally: + if conn: + conn.close() + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[Notifier]: + """Retrieve a list of notifiers by their associated external service ID.""" + self.logger.debug(f"Retrieving notifiers for external service {external_service_id} from SQLite repository.") + sql = "SELECT * FROM notifiers WHERE external_service_id = ?;" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (external_service_id,)) + rows = cursor.fetchall() + notifiers = [] + for row in rows: + notifier = self._row_to_notifier(row) + if notifier: + notifiers.append(notifier) + return notifiers + except sqlite3.Error as e: + self.logger.error(f"SQLite error retrieving notifiers for external service {external_service_id}: {e}") + return [] + finally: + if conn: + conn.close() + + +# SQLAlchemy implementation + + +class SqlAlchemyNotifierRepository(NotifierRepository): + """SQLAlchemy implementation of NotifierRepository. + + This repository works directly with the imperatively mapped Notifier domain entity. + The config field is automatically converted between NotificationConfig objects and JSON + strings by the custom TypeDecorator and event listener defined in tables.py. + + Args: + db: BaseSQLAlchemyRepository instance for database operations + """ + + def __init__(self, db: BaseSQLAlchemyRepository): + """Initialize repository with database instance. + + Args: + db: BaseSQLAlchemyRepository instance + """ + self._db = db + self.logger = db.logger + + def add(self, notifier: Notifier) -> None: + """Add a notifier to the repository.""" + session = self._db.get_session() + try: + session.add(notifier) + session.commit() + finally: + session.close() + + def get_by_id(self, notifier_id: EntityId) -> Optional[Notifier]: + """Get a notifier by ID.""" + session = self._db.get_session() + try: + stmt = select(Notifier).where(notifiers_table.c.id == str(notifier_id)) + entity = session.execute(stmt).scalar_one_or_none() + return entity + finally: + session.close() + + def get_all(self) -> List[Notifier]: + """Get all notifiers.""" + session = self._db.get_session() + try: + stmt = select(Notifier) + entities = session.execute(stmt).scalars().all() + return list(entities) + finally: + session.close() + + def update(self, notifier: Notifier) -> None: + """Update a notifier.""" + session = self._db.get_session() + try: + stmt = select(Notifier).where(notifiers_table.c.id == str(notifier.id)) + existing_entity = session.execute(stmt).scalar_one_or_none() + + if existing_entity: + existing_entity.name = notifier.name + existing_entity.adapter_type = notifier.adapter_type + existing_entity.config = notifier.config + existing_entity.external_service_id = notifier.external_service_id + + session.commit() + finally: + session.close() + + def remove(self, notifier_id: EntityId) -> None: + """Remove a notifier by ID.""" + session = self._db.get_session() + try: + stmt = select(Notifier).where(notifiers_table.c.id == str(notifier_id)) + entity = session.execute(stmt).scalar_one_or_none() + + if entity: + session.delete(entity) + session.commit() + finally: + session.close() + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[Notifier]: + """Get notifiers by external service ID.""" + session = self._db.get_session() + try: + stmt = select(Notifier).where(notifiers_table.c.external_service_id == str(external_service_id)) + entities = session.execute(stmt).scalars().all() + return list(entities) + finally: + session.close() diff --git a/core/edge_mining/adapters/domain/notification/schemas.py b/core/edge_mining/adapters/domain/notification/schemas.py new file mode 100644 index 0000000..8dd3877 --- /dev/null +++ b/core/edge_mining/adapters/domain/notification/schemas.py @@ -0,0 +1,301 @@ +"""Validation schemas for notification domain.""" + +import uuid +from typing import Dict, Optional, Union, cast + +from pydantic import BaseModel, Field, field_serializer, field_validator + +from edge_mining.domain.common import EntityId +from edge_mining.domain.notification.common import NotificationAdapter +from edge_mining.domain.notification.entities import Notifier +from edge_mining.shared.adapter_configs.notification import ( + DummyNotificationConfig, + TelegramNotificationConfig, +) +from edge_mining.shared.adapter_maps.notification import NOTIFIER_CONFIG_TYPE_MAP +from edge_mining.shared.interfaces.config import NotificationConfig + + +class NotifierSchema(BaseModel): + """Schema for Notifier entity with complete validation.""" + + id: str = Field(..., description="Unique identifier for the notifier") + name: str = Field(default="", description="Notifier name") + adapter_type: NotificationAdapter = Field( + default=NotificationAdapter.DUMMY, description="Type of notification adapter" + ) + config: dict = Field(default={}, description="Notifier configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @field_validator("id") + @classmethod + def validate_id(cls, v: str) -> str: + """Validate that id is a valid UUID string.""" + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("id must be a valid UUID string") from exc + return v + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate notifier name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("adapter_type") + @classmethod + def validate_adapter_type(cls, v: str) -> NotificationAdapter: + """Validate that adapter_type is a recognized NotificationAdapter.""" + adapter_values = [adapter.value for adapter in NotificationAdapter] + if v not in adapter_values: + raise ValueError(f"adapter_type must be one of {adapter_values}") + return NotificationAdapter(v) + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that external_service_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("external_service_id must be a valid UUID string") from exc + return v + + @classmethod + def from_model(cls, notifier: Notifier) -> "NotifierSchema": + """Create NotifierSchema from a Notifier domain model instance.""" + return cls( + id=str(notifier.id), + name=notifier.name, + adapter_type=notifier.adapter_type, + config=notifier.config.to_dict() if notifier.config else {}, + external_service_id=str(notifier.external_service_id) if notifier.external_service_id else None, + ) + + @field_serializer("id") + def serialize_id(self, value: str) -> str: + """Serialize id field.""" + return str(value) + + @field_serializer("external_service_id") + def serialize_external_service_id(self, value: Optional[str]) -> Optional[str]: + """Serialize external_service_id field.""" + return str(value) if value is not None else None + + def to_model(self) -> Notifier: + """Convert NotifierSchema to Notifier domain model instance.""" + configuration: Optional[NotificationConfig] = None + if self.config: + config_class = NOTIFIER_CONFIG_TYPE_MAP.get(self.adapter_type, None) + if config_class: + configuration = cast(NotificationConfig, config_class.from_dict(self.config)) + + return Notifier( + id=EntityId(uuid.UUID(self.id)), + name=self.name, + adapter_type=self.adapter_type, + config=configuration, + external_service_id=EntityId(uuid.UUID(self.external_service_id)) if self.external_service_id else None, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + arbitrary_types_allowed = True + json_encoders = { + uuid.UUID: str, + NotificationAdapter: lambda v: v.value, + } + + +class NotifierCreateSchema(BaseModel): + """Schema for creating a new notifier.""" + + name: str = Field(default="", description="Notifier name") + adapter_type: NotificationAdapter = Field( + default=NotificationAdapter.DUMMY, description="Type of notification adapter" + ) + config: Optional[dict] = Field(default=None, description="Notifier configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate notifier name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("adapter_type") + @classmethod + def validate_adapter_type(cls, v: str) -> NotificationAdapter: + """Validate that adapter_type is a recognized NotificationAdapter.""" + adapter_values = [adapter.value for adapter in NotificationAdapter] + if v not in adapter_values: + raise ValueError(f"adapter_type must be one of {adapter_values}") + return NotificationAdapter(v) + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that external_service_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("external_service_id must be a valid UUID string") from exc + return v + + def to_model(self) -> Notifier: + """Convert NotifierCreateSchema to a Notifier domain model instance.""" + configuration: Optional[NotificationConfig] = None + if self.config: + config_class = NOTIFIER_CONFIG_TYPE_MAP.get(self.adapter_type, None) + if config_class: + configuration = cast(NotificationConfig, config_class.from_dict(self.config)) + + return Notifier( + id=EntityId(uuid.uuid4()), + name=self.name, + adapter_type=self.adapter_type, + config=configuration, + external_service_id=EntityId(uuid.UUID(self.external_service_id)) if self.external_service_id else None, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + json_encoders = { + uuid.UUID: str, + NotificationAdapter: lambda v: v.value, + } + + +class NotifierUpdateSchema(BaseModel): + """Schema for updating an existing notifier.""" + + name: str = Field(default="", description="Notifier name") + config: Optional[dict] = Field(default=None, description="Notifier configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate notifier name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that external_service_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("external_service_id must be a valid UUID string") from exc + return v + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + json_encoders = { + uuid.UUID: str, + } + + +class DummyNotificationConfigSchema(BaseModel): + """Schema for Dummy NotificationConfig.""" + + message: str = Field(default="This is a dummy notification", description="Default message for dummy notifications") + + @field_validator("message") + @classmethod + def validate_message(cls, v: str) -> str: + """Validate message.""" + v = v.strip() + if not v: + raise ValueError("Message cannot be empty") + return v + + def to_model(self) -> DummyNotificationConfig: + """ + Convert DummyNotificationConfigSchema to DummyNotificationConfig adapter configuration model instance. + """ + return DummyNotificationConfig( + message=self.message, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + +class TelegramNotificationConfigSchema(BaseModel): + """Schema for TelegramNotificationConfig.""" + + bot_token: str = Field(..., description="Telegram bot token") + chat_id: str = Field(..., description="Telegram chat ID") + + @field_validator("bot_token") + @classmethod + def validate_bot_token(cls, v: str) -> str: + """Validate bot token.""" + v = v.strip() + if not v: + raise ValueError("Bot token cannot be empty") + # Basic format validation for Telegram bot tokens + if not v.startswith("bot"): + v = f"bot{v}" + if ":" not in v: + raise ValueError("Bot token must contain ':' separator") + return v + + @field_validator("chat_id") + @classmethod + def validate_chat_id(cls, v: str) -> str: + """Validate chat ID.""" + v = v.strip() + if not v: + raise ValueError("Chat ID cannot be empty") + return v + + def to_model(self) -> TelegramNotificationConfig: + """ + Convert schema to TelegramNotificationConfig adapter configuration model instance. + """ + return TelegramNotificationConfig( + bot_token=self.bot_token, + chat_id=self.chat_id, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + +NOTIFICATION_CONFIG_SCHEMA_MAP: Dict[ + type[NotificationConfig], + Union[type[DummyNotificationConfigSchema], type[TelegramNotificationConfigSchema]], +] = { + DummyNotificationConfig: DummyNotificationConfigSchema, + TelegramNotificationConfig: TelegramNotificationConfigSchema, +} diff --git a/core/edge_mining/adapters/domain/notification/tables.py b/core/edge_mining/adapters/domain/notification/tables.py new file mode 100644 index 0000000..2b1f70c --- /dev/null +++ b/core/edge_mining/adapters/domain/notification/tables.py @@ -0,0 +1,129 @@ +"""SQLAlchemy ORM mappings for Notification domain entities. + +This module implements imperative (classical) mapping of the domain entities +to database tables. The domain entities are mapped directly without +creating separate ORM model classes, maintaining domain purity. + +All tables and mappings use the shared metadata and mapper registry from +the sqlalchemy.registry module, which are available as module-level singletons. + +⚠️ DEVELOPER WARNING ⚠️ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +ANY SCHEMA CHANGE (adding/removing/modifying tables or columns) REQUIRES an +Alembic migration. Do NOT modify this file without creating a migration: + + python scripts/migrate.py create "Description of your change" + +For detailed instructions, see: ../docs/ALEMBIC_MIGRATIONS.md +For a step-by-step example, see: ../docs/MIGRATION_EXAMPLE.md +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +""" + +import json +import uuid +from typing import Any, Optional + +from sqlalchemy import Column, ForeignKey, String, Table, event + +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.common import ConfigurationType +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.registry import mapper_registry, metadata +from edge_mining.domain.common import EntityId +from edge_mining.domain.notification.common import NotificationAdapter +from edge_mining.domain.notification.entities import Notifier +from edge_mining.domain.notification.exceptions import NotifierConfigurationError +from edge_mining.shared.adapter_maps.notification import NOTIFIER_CONFIG_TYPE_MAP +from edge_mining.shared.interfaces.config import NotificationConfig + + +class NotifierConfigType(ConfigurationType): + """SQLAlchemy type for NotificationConfig serialization. + + Inherits from ConfigurationType to handle JSON serialization/deserialization. + """ + + +def _deserialize_notifier_config(adapter_type: NotificationAdapter, config_json: str) -> Optional[NotificationConfig]: + """Deserialize JSON string to NotificationConfig based on adapter type.""" + if not config_json: + return None + + data: dict = json.loads(config_json) + + if adapter_type not in NOTIFIER_CONFIG_TYPE_MAP: + raise NotifierConfigurationError(f"Error reading Notifier configuration. Invalid type '{adapter_type}'") + + config_class: Optional[type[NotificationConfig]] = NOTIFIER_CONFIG_TYPE_MAP.get(adapter_type) + if not config_class: + raise NotifierConfigurationError(f"Error creating Notifier configuration. Type '{adapter_type}'") + + config_instance = config_class.from_dict(data) + if not isinstance(config_instance, NotificationConfig): + raise NotifierConfigurationError( + f"Deserialized config is not of type NotificationConfig for adapter type {adapter_type}." + ) + return config_instance + + +@event.listens_for(Notifier, "load") +def _receive_notifier_load(target: Notifier, context) -> None: + """Event listener that deserializes config after loading from database.""" + # Convert id string to EntityId if needed + if hasattr(target, "id") and target.id is not None: + if isinstance(target.id, str): # type: ignore[arg-type,misc] + target.id = EntityId(uuid.UUID(target.id)) # type: ignore[assignment] + + # Convert foreign keys to EntityId + # NOTE: SQLAlchemy returns strings for UUID columns that need conversion to EntityId + if hasattr(target, "external_service_id") and target.external_service_id is not None: + if isinstance(target.external_service_id, str): # type: ignore + target.external_service_id = EntityId(uuid.UUID(target.external_service_id)) # type: ignore + + # Convert adapter_type string to enum if needed + if isinstance(target.adapter_type, str): + try: + target.adapter_type = NotificationAdapter(target.adapter_type) + except ValueError: + # If conversion fails, leave as string (will fail in config deserialization) + pass + + if target.config and isinstance(target.config, str): + target.config = _deserialize_notifier_config(target.adapter_type, target.config) + + +@event.listens_for(Notifier, "before_insert") +@event.listens_for(Notifier, "before_update") +def _flatten_notifier_composites(mapper, connection, target: Any) -> None: + """Convert enum attributes to primitive values before persisting.""" + if hasattr(target, "adapter_type") and target.adapter_type is not None: + if isinstance(target.adapter_type, NotificationAdapter): + target.adapter_type = target.adapter_type.value + + +@event.listens_for(Notifier, "after_insert") +@event.listens_for(Notifier, "after_update") +def _restore_notifier_composites(mapper, connection, target: Any) -> None: + """Restore enum attributes after persist operations.""" + if hasattr(target, "adapter_type") and target.adapter_type is not None: + if isinstance(target.adapter_type, str): + try: + target.adapter_type = NotificationAdapter(target.adapter_type) + except ValueError: + pass + + +# Define the notifiers table using imperative style +notifiers_table = Table( + "notifiers", + metadata, + Column("id", String, primary_key=True, index=True), + Column("name", String, nullable=False), + Column("adapter_type", String, nullable=False), + Column("config", NotifierConfigType, nullable=True), + Column("external_service_id", String, ForeignKey("external_services.id"), nullable=True), +) + +# Map Notifier +mapper_registry.map_imperatively( + Notifier, + notifiers_table, +) diff --git a/core/edge_mining/adapters/domain/optimization_unit/__init__.py b/core/edge_mining/adapters/domain/optimization_unit/__init__.py new file mode 100644 index 0000000..f82e86e --- /dev/null +++ b/core/edge_mining/adapters/domain/optimization_unit/__init__.py @@ -0,0 +1 @@ +"""Adapters for the Optimization Unit domain.""" diff --git a/core/edge_mining/adapters/domain/optimization_unit/cli/__init__.py b/core/edge_mining/adapters/domain/optimization_unit/cli/__init__.py new file mode 100644 index 0000000..1c3355c --- /dev/null +++ b/core/edge_mining/adapters/domain/optimization_unit/cli/__init__.py @@ -0,0 +1 @@ +"""Adapters CLI for the Energy Optimization Unit domain.""" diff --git a/core/edge_mining/adapters/domain/optimization_unit/cli/commands.py b/core/edge_mining/adapters/domain/optimization_unit/cli/commands.py new file mode 100644 index 0000000..03442f7 --- /dev/null +++ b/core/edge_mining/adapters/domain/optimization_unit/cli/commands.py @@ -0,0 +1,863 @@ +"""CLI commands for the Energy Optimization Unit domain.""" + +from typing import List, Optional, Union + +import click + +from edge_mining.adapters.domain.energy.cli.commands import print_energy_source_details, select_energy_source +from edge_mining.adapters.domain.miner.cli.commands import print_miner_details, select_miner +from edge_mining.adapters.domain.notification.cli.commands import print_notifier_details, select_notifier +from edge_mining.adapters.domain.policy.cli.commands import ( + print_optimization_policy_details, + select_optimization_policy, +) +from edge_mining.adapters.utils import run_async_func +from edge_mining.application.interfaces import ConfigurationServiceInterface +from edge_mining.domain.common import EntityId +from edge_mining.domain.energy.entities import EnergySource +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.notification.entities import Notifier +from edge_mining.domain.optimization_unit.aggregate_roots import EnergyOptimizationUnit +from edge_mining.domain.policy.aggregate_roots import OptimizationPolicy +from edge_mining.shared.logging.port import LoggerPort + + +def handle_add_optimization_unit(configuration_service: ConfigurationServiceInterface, logger: LoggerPort): + """Menu to add a new optimization unit.""" + + click.echo(click.style("\n--- Creates a new Energy Optimization Unit ---", fg="yellow")) + + name: str = click.prompt("Name of the energy optimization unit", type=str) + description: str = click.prompt("Description (optional)", type=str, default="") + + # Select an energy source + click.echo("") + click.echo(click.style("What Energy Source do you want to use?", fg="yellow")) + selected_energy_source: Optional[EnergySource] = select_energy_source(configuration_service, logger) + if not selected_energy_source: + click.echo(click.style("No energy source selected. Aborting operation.", fg="red"), err=True) + click.pause("Press any key to return to the menu...") + return + + # Select an optimization policy + click.echo("") + click.echo(click.style("What Optimization Policy do you want to use?", fg="yellow")) + selected_policy: Optional[OptimizationPolicy] = select_optimization_policy(configuration_service) + if not selected_policy: + click.echo(click.style("No optimization policy selected. Aborting operation.", fg="red"), err=True) + click.pause("Press any key to return to the menu...") + return + + # Select target miners + click.echo("") + click.echo(click.style("What Target Miners do you want to control?", fg="yellow")) + selected_miners: Union[Optional[Miner], List[Miner]] = select_miner( + configuration_service=configuration_service, logger=logger, default_id=None, allow_multiple=True + ) + if not selected_miners: + click.echo(click.style("No target miners selected. Aborting operation.", fg="red"), err=True) + click.pause("Press any key to return to the menu...") + return + if isinstance(selected_miners, Miner): + selected_miners = [selected_miners] + + # Select notifiers + click.echo("") + click.echo(click.style("What Notifiers do you want to use?", fg="yellow")) + selected_notifiers: Union[Optional[Notifier], List[Notifier]] = select_notifier( + configuration_service=configuration_service, + logger=logger, + default_id=None, + allow_multiple=True, + ) + if isinstance(selected_notifiers, Notifier): + selected_notifiers = [selected_notifiers] + + # To be implemented in the next release + performance_tracker_id = None + + try: + target_miner_ids = [m.id for m in selected_miners] if selected_miners else [] + notifier_ids = [n.id for n in selected_notifiers] if selected_notifiers else [] + + created = run_async_func( + configuration_service.create_optimization_unit( + name=name, + description=description if description else None, + energy_source_id=selected_energy_source.id if selected_energy_source else None, + target_miner_ids=target_miner_ids, + policy_id=selected_policy.id if selected_policy else None, + performance_tracker_id=performance_tracker_id, + notifier_ids=notifier_ids, + ) + ) + if not created: + raise ValueError("Failed to create the optimization unit.") + click.echo( + click.style( + f"Energy Optimization Unit '{created.name}' successfully created (ID: {created.id}).", + fg="green", + ) + ) + except Exception as e: + logger.error(f"Error creating optimization unit: {e}") + click.echo(click.style(f"Error: {e}", fg="red"), err=True) + click.pause("Press any key to return to the menu...") + + +def list_optimization_units( + configuration_service: ConfigurationServiceInterface, +) -> None: + """List all configured optimization units.""" + units = configuration_service.list_optimization_units() + if not units: + click.echo(click.style("No optimization units configured.", fg="yellow")) + else: + for u in units: + click.echo( + "-> " + + "Name: " + + click.style(f"{u.name}, ", fg="blue") + + "ID: " + + click.style(f"{u.id}, ", fg="yellow") + + "Description: " + + click.style(f"{u.description if u.description else 'N/A'}, ", fg="cyan") + ) + + +def handle_list_optimization_units( + configuration_service: ConfigurationServiceInterface, +) -> None: + """Menu to list all configured optimization units.""" + click.echo(click.style("\n--- Configured Energy Optimization Units ---", fg="yellow")) + + list_optimization_units(configuration_service) + + click.pause("Press any key to return to the menu...") + + +def print_optimization_unit_details( + optimization_unit: EnergyOptimizationUnit, configuration_service: ConfigurationServiceInterface +) -> None: + """Print details of a specific optimization unit.""" + energy_source = ( + configuration_service.get_energy_source(optimization_unit.energy_source_id) + if optimization_unit.energy_source_id + else None + ) + policy = configuration_service.get_policy(optimization_unit.policy_id) if optimization_unit.policy_id else None + miners = ( + [configuration_service.get_miner(m_id) for m_id in optimization_unit.target_miner_ids] + if optimization_unit.target_miner_ids + else [] + ) + notifiers = ( + [configuration_service.get_notifier(n_id) for n_id in optimization_unit.notifier_ids] + if optimization_unit.notifier_ids + else [] + ) + + click.echo("") + click.echo("| Name: " + click.style(optimization_unit.name, fg="blue")) + click.echo("| ID: " + click.style(optimization_unit.id, fg="yellow")) + click.echo( + "| Description: " + + click.style( + optimization_unit.description if optimization_unit.description else "N/A", + fg="cyan", + ) + ) + click.echo( + "| Enabled: " + + click.style( + "Yes" if optimization_unit.is_enabled else "No", fg="green" if optimization_unit.is_enabled else "red" + ) + ) + + click.echo("") + click.echo("> Energy Source Details:") + if energy_source: + print_energy_source_details( + energy_source=energy_source, + configuration_service=configuration_service, + show_energy_monitor_details=False, + show_forecast_provider_details=False, + ) + else: + click.echo(click.style("| No energy source configured.", fg="red")) + + click.echo("> Target Miners Details:") + if miners: + for miner_idx, miner in enumerate(miners): + if miner: + click.echo("|---- Miner " + str(miner_idx + 1) + " ----") + print_miner_details( + miner=miner, + configuration_service=configuration_service, + show_controller_details=False, + show_external_service=False, + ) + click.echo("--------------------") + else: + click.echo(click.style("| No target miners configured.", fg="red")) + + click.echo("> Optimization Policy Details:") + if policy: + print_optimization_policy_details(policy=policy, show_rule_details=False) + else: + click.echo(click.style("| No optimization policy configured.", fg="red")) + + click.echo("> Notifiers Details:") + if notifiers: + for notifier_idx, notifier in enumerate(notifiers): + if notifier: + click.echo("|---- Notifier " + str(notifier_idx + 1) + " ----") + print_notifier_details( + notifier=notifier, + configuration_service=configuration_service, + show_external_service=False, + show_optimization_unit_list=False, + ) + click.echo("--------------------") + else: + click.echo(click.style("| No notifiers configured.", fg="red")) + + +def handle_activate_optimization_unit( + optimization_unit: EnergyOptimizationUnit, + configuration_service: ConfigurationServiceInterface, +) -> None: + """Activate an optimization unit.""" + if optimization_unit.is_enabled: + click.echo(click.style("The optimization unit is already active.", fg="yellow")) + click.pause("Press any key to return to the menu...") + return + + try: + run_async_func(configuration_service.activate_optimization_unit(optimization_unit.id)) + click.echo(click.style("Optimization unit activated successfully.", fg="green")) + except Exception as e: + click.echo(click.style(f"Error activating optimization unit: {e}", fg="red")) + + click.pause("Press any key to return to the menu...") + + +def handle_deactivate_optimization_unit( + optimization_unit: EnergyOptimizationUnit, + configuration_service: ConfigurationServiceInterface, +) -> None: + """Deactivate an optimization unit.""" + if not optimization_unit.is_enabled: + click.echo(click.style("The optimization unit is already inactive.", fg="yellow")) + click.pause("Press any key to return to the menu...") + return + + try: + run_async_func(configuration_service.deactivate_optimization_unit(optimization_unit.id)) + click.echo(click.style("Optimization unit deactivated successfully.", fg="green")) + except Exception as e: + click.echo(click.style(f"Error deactivating optimization unit: {e}", fg="red")) + + click.pause("Press any key to return to the menu...") + + +def update_optimization_unit( + optimization_unit: EnergyOptimizationUnit, configuration_service: ConfigurationServiceInterface, logger: LoggerPort +) -> Optional[EnergyOptimizationUnit]: + """Update an optimization unit.""" + click.echo(click.style("\n--- Update Energy Optimization Unit ---", fg="yellow")) + + name: str = click.prompt( + "Name of the energy optimization unit", + type=str, + default=optimization_unit.name, + ) + description: str = click.prompt( + "Description (optional)", + type=str, + default=optimization_unit.description if optimization_unit.description else "", + ) + + new_optimization_unit: EnergyOptimizationUnit = EnergyOptimizationUnit() + new_optimization_unit.name = name + new_optimization_unit.description = description + new_optimization_unit.is_enabled = optimization_unit.is_enabled + new_optimization_unit.energy_source_id = optimization_unit.energy_source_id + new_optimization_unit.target_miner_ids = optimization_unit.target_miner_ids + new_optimization_unit.policy_id = optimization_unit.policy_id + new_optimization_unit.notifier_ids = optimization_unit.notifier_ids + new_optimization_unit.performance_tracker_id = optimization_unit.performance_tracker_id + + click.echo("\nDo you want to change the energy source?") + change_energy_source: bool = click.confirm("Change energy source?", default=True, prompt_suffix="") + if change_energy_source: + selected_energy_source: Optional[EnergySource] = select_energy_source(configuration_service, logger) + if selected_energy_source is None: + click.echo(click.style("Invalid energy source selected. Aborting operation.", fg="red"), err=True) + return None + # Update energy source + new_optimization_unit.energy_source_id = selected_energy_source.id + + click.echo("\nDo you want to change the optimization policy?") + change_policy: bool = click.confirm("Change optimization policy?", default=True, prompt_suffix="") + if change_policy: + selected_policy: Optional[OptimizationPolicy] = select_optimization_policy(configuration_service) + if selected_policy is None: + click.echo(click.style("Invalid optimization policy selected. Aborting operation.", fg="red"), err=True) + return None + # Update policy + new_optimization_unit.policy_id = selected_policy.id + + click.echo("\nDo you want to change the target miners?") + change_miners: bool = click.confirm("Change target miners?", default=True, prompt_suffix="") + if change_miners: + selected_miners: Union[Optional[Miner], List[Miner]] = select_miner( + configuration_service=configuration_service, + logger=logger, + default_id=None, + allow_multiple=True, + ) + if not selected_miners: + click.echo(click.style("No target miners selected. Aborting operation.", fg="red"), err=True) + return None + if isinstance(selected_miners, Miner): + selected_miners = [selected_miners] + new_optimization_unit.target_miner_ids = [m.id for m in selected_miners] + + click.echo("\nDo you want to change the notifiers?") + change_notifiers: bool = click.confirm("Change notifiers?", default=True, prompt_suffix="") + if change_notifiers: + selected_notifiers: Union[Optional[Notifier], List[Notifier]] = select_notifier( + configuration_service=configuration_service, + logger=logger, + default_id=None, + allow_multiple=True, + ) + if isinstance(selected_notifiers, Notifier): + selected_notifiers = [selected_notifiers] + new_optimization_unit.notifier_ids = [n.id for n in selected_notifiers] if selected_notifiers else [] + + # Home forecast provider and performance tracker updates will be implemented in the next release + + try: + updated = run_async_func( + configuration_service.update_optimization_unit( + unit_id=optimization_unit.id, + name=new_optimization_unit.name, + description=new_optimization_unit.description, + energy_source_id=new_optimization_unit.energy_source_id, + target_miner_ids=new_optimization_unit.target_miner_ids, + policy_id=new_optimization_unit.policy_id, + performance_tracker_id=new_optimization_unit.performance_tracker_id, + notifier_ids=new_optimization_unit.notifier_ids, + is_enabled=new_optimization_unit.is_enabled, + ) + ) + click.echo( + click.style( + f"Energy Optimization Unit '{updated.name}' successfully updated.", + fg="green", + ) + ) + except Exception as e: + updated = None + logger.error(f"Error updating optimization unit: {e}") + click.echo(click.style(f"Error updating optimization unit: {e}", fg="red"), err=True) + return None + finally: + click.pause("Press any key to return to the menu...") + + return updated + + +def delete_single_optimization_unit( + optimization_unit: EnergyOptimizationUnit, + configuration_service: ConfigurationServiceInterface, +) -> bool: + """Delete a single optimization unit.""" + confirm_delete: bool = click.confirm( + f"Are you sure you want to delete the optimization unit '{optimization_unit.name}' " + f"(ID: {optimization_unit.id})?", + default=False, + prompt_suffix="", + ) + if not confirm_delete: + click.echo(click.style("Deletion cancelled.", fg="yellow")) + return False + + try: + run_async_func(configuration_service.remove_optimization_unit(optimization_unit.id)) + click.echo(click.style("Optimization unit deleted successfully.", fg="green")) + return True + except Exception as e: + click.echo(click.style(f"Error deleting optimization unit: {e}", fg="red")) + return False + + +def manage_assign_energy_source( + optimization_unit: EnergyOptimizationUnit, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> None: + """Assign an energy source to an optimization unit.""" + click.echo(click.style("\n--- Assign Energy Source to Optimization Unit ---", fg="yellow")) + + selected_energy_source: Optional[EnergySource] = select_energy_source(configuration_service, logger) + if not selected_energy_source: + click.echo(click.style("No energy source selected. Aborting operation.", fg="red"), err=True) + click.pause("Press any key to return to the menu...") + return + + try: + run_async_func( + configuration_service.assign_energy_source_to_optimization_unit( + unit_id=optimization_unit.id, energy_source_id=selected_energy_source.id + ) + ) + click.echo(click.style(f"Energy source '{selected_energy_source.name}' assigned successfully.", fg="green")) + except Exception as e: + logger.error(f"Error assigning energy source: {e}") + click.echo(click.style(f"Error assigning energy source: {e}", fg="red")) + click.pause("Press any key to return to the menu...") + + +def manage_assign_optimization_policy( + optimization_unit: EnergyOptimizationUnit, + configuration_service: ConfigurationServiceInterface, +) -> None: + """Assign an optimization policy to an optimization unit.""" + click.echo(click.style("\n--- Assign Optimization Policy to Optimization Unit ---", fg="yellow")) + + selected_policy: Optional[OptimizationPolicy] = select_optimization_policy(configuration_service) + if not selected_policy: + click.echo(click.style("No optimization policy selected. Aborting operation.", fg="red"), err=True) + click.pause("Press any key to return to the menu...") + return + + try: + run_async_func( + configuration_service.assign_policy_to_optimization_unit( + unit_id=optimization_unit.id, policy_id=selected_policy.id + ) + ) + click.echo(click.style(f"Optimization policy '{selected_policy.name}' assigned successfully.", fg="green")) + except Exception as e: + click.echo(click.style(f"Error assigning optimization policy: {e}", fg="red")) + click.pause("Press any key to return to the menu...") + + +def manage_assign_target_miners( + optimization_unit: EnergyOptimizationUnit, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> None: + """Assign target miners to an optimization unit.""" + click.echo(click.style("\n--- Assign Target Miners to Optimization Unit ---", fg="yellow")) + + selected_miners: Union[Optional[Miner], List[Miner]] = select_miner( + configuration_service=configuration_service, logger=logger, default_id=None, allow_multiple=True + ) + if not selected_miners: + click.echo(click.style("No target miners selected. Aborting operation.", fg="red"), err=True) + click.pause("Press any key to return to the menu...") + return + if isinstance(selected_miners, Miner): + selected_miners = [selected_miners] + + try: + target_miner_ids = [m.id for m in selected_miners] + run_async_func( + configuration_service.assign_miners_to_optimization_unit( + unit_id=optimization_unit.id, miner_ids=target_miner_ids + ) + ) + click.echo(click.style(f"{len(selected_miners)} Target miners assigned successfully.", fg="green")) + except Exception as e: + logger.error(f"Error assigning target miners: {e}") + click.echo(click.style(f"Error assigning target miners: {e}", fg="red")) + click.pause("Press any key to return to the menu...") + + +def manage_add_target_miner( + optimization_unit: EnergyOptimizationUnit, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> None: + """Add a target miner to an optimization unit.""" + click.echo(click.style("\n--- Add Target Miner to Optimization Unit ---", fg="yellow")) + + selected_miner: Union[Optional[Miner], List[Miner]] = select_miner( + configuration_service=configuration_service, + logger=logger, + default_id=None, + allow_multiple=False, + exclude_ids=optimization_unit.target_miner_ids, + ) + if not selected_miner: + click.echo(click.style("No target miner selected. Aborting operation.", fg="red"), err=True) + click.pause("Press any key to return to the menu...") + return + if isinstance(selected_miner, list): + selected_miner = selected_miner[0] + + # Check if the miner already exists in the optimization unit + if selected_miner.id in optimization_unit.target_miner_ids: + click.echo( + click.style( + f"Target miner '{selected_miner.name}' is already assigned to this optimization unit.", fg="red" + ) + ) + click.pause("Press any key to return to the menu...") + return + + try: + run_async_func( + configuration_service.add_miner_to_optimization_unit( + unit_id=optimization_unit.id, miner_id=selected_miner.id + ) + ) + click.echo(click.style(f"Target miner '{selected_miner.name}' added successfully.", fg="green")) + except Exception as e: + logger.error(f"Error adding target miner: {e}") + click.echo(click.style(f"Error adding target miner: {e}", fg="red")) + click.pause("Press any key to return to the menu...") + + +def manage_remove_target_miner( + optimization_unit: EnergyOptimizationUnit, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> None: + """Remove a target miner from an optimization unit.""" + click.echo(click.style("\n--- Remove Target Miner from Optimization Unit ---", fg="yellow")) + + if not optimization_unit.target_miner_ids: + click.echo(click.style("No target miners assigned to this optimization unit.", fg="red")) + click.pause("Press any key to return to the menu...") + return + + selected_miner: Union[Optional[Miner], List[Miner]] = select_miner( + configuration_service=configuration_service, + logger=logger, + default_id=None, + allow_multiple=False, + only_ids=optimization_unit.target_miner_ids, + ) + if not selected_miner: + click.echo(click.style("No target miner selected. Aborting operation.", fg="red"), err=True) + click.pause("Press any key to return to the menu...") + return + if isinstance(selected_miner, list): + selected_miner = selected_miner[0] + + try: + run_async_func( + configuration_service.remove_miner_from_optimization_unit( + unit_id=optimization_unit.id, miner_id=selected_miner.id + ) + ) + click.echo(click.style(f"Target miner '{selected_miner.name}' removed successfully.", fg="green")) + except Exception as e: + logger.error(f"Error removing target miner: {e}") + click.echo(click.style(f"Error removing target miner: {e}", fg="red")) + click.pause("Press any key to return to the menu...") + + +def manage_assign_notifiers( + optimization_unit: EnergyOptimizationUnit, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> None: + """Assign notifiers to an optimization unit.""" + click.echo(click.style("\n--- Assign Notifiers to Optimization Unit ---", fg="yellow")) + + selected_notifiers: Union[Optional[Notifier], List[Notifier]] = select_notifier( + configuration_service=configuration_service, + logger=logger, + default_id=None, + allow_multiple=True, + ) + if isinstance(selected_notifiers, Notifier): + selected_notifiers = [selected_notifiers] + + try: + notifier_ids = [n.id for n in selected_notifiers] if selected_notifiers else [] + run_async_func( + configuration_service.assign_notifiers_to_optimization_unit( + unit_id=optimization_unit.id, notifier_ids=notifier_ids + ) + ) + click.echo(click.style(f"{len(notifier_ids)} Notifiers assigned successfully.", fg="green")) + except Exception as e: + logger.error(f"Error assigning notifiers: {e}") + click.echo(click.style(f"Error assigning notifiers: {e}", fg="red")) + click.pause("Press any key to return to the menu...") + + +def manage_add_notifier( + optimization_unit: EnergyOptimizationUnit, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> None: + """Add a notifier to an optimization unit.""" + click.echo(click.style("\n--- Add Notifier to Optimization Unit ---", fg="yellow")) + + selected_notifier: Union[Optional[Notifier], List[Notifier]] = select_notifier( + configuration_service=configuration_service, + logger=logger, + default_id=None, + allow_multiple=False, + exclude_ids=optimization_unit.notifier_ids, + ) + if not selected_notifier: + click.echo(click.style("No notifier selected. Aborting operation.", fg="red"), err=True) + click.pause("Press any key to return to the menu...") + return + if isinstance(selected_notifier, list): + selected_notifier = selected_notifier[0] + + try: + run_async_func( + configuration_service.add_notifier_to_optimization_unit( + unit_id=optimization_unit.id, notifier_id=selected_notifier.id + ) + ) + click.echo(click.style(f"Notifier '{selected_notifier.name}' added successfully.", fg="green")) + except Exception as e: + logger.error(f"Error adding notifier: {e}") + click.echo(click.style(f"Error adding notifier: {e}", fg="red")) + click.pause("Press any key to return to the menu...") + + +def manage_remove_notifier( + optimization_unit: EnergyOptimizationUnit, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> None: + """Remove a notifier from an optimization unit.""" + click.echo(click.style("\n--- Remove Notifier from Optimization Unit ---", fg="yellow")) + + if not optimization_unit.notifier_ids: + click.echo(click.style("No notifiers assigned to this optimization unit.", fg="red")) + click.pause("Press any key to return to the menu...") + return + + selected_notifier: Union[Optional[Notifier], List[Notifier]] = select_notifier( + configuration_service=configuration_service, + logger=logger, + default_id=None, + allow_multiple=False, + only_ids=optimization_unit.notifier_ids, + ) + if isinstance(selected_notifier, list): + selected_notifier = selected_notifier[0] + + if not selected_notifier: + click.echo(click.style("No notifier selected. Aborting operation.", fg="red"), err=True) + click.pause("Press any key to return to the menu...") + return + + try: + run_async_func( + configuration_service.remove_notifier_from_optimization_unit( + unit_id=optimization_unit.id, notifier_id=selected_notifier.id + ) + ) + click.echo(click.style(f"Notifier '{selected_notifier.name}' removed successfully.", fg="green")) + except Exception as e: + logger.error(f"Error removing notifier: {e}") + click.echo(click.style(f"Error removing notifier: {e}", fg="red")) + click.pause("Press any key to return to the menu...") + + +def manage_single_optimization_unit_menu( + unit_id: EntityId, configuration_service: ConfigurationServiceInterface, logger: LoggerPort +) -> str: + """Menu for managing a single optimization unit.""" + while True: + # Refresh optimization unit data + optimization_unit = configuration_service.get_optimization_unit(unit_id) + if not optimization_unit: + click.echo(click.style("Optimization unit not found. Returning to previous menu.", fg="red")) + return "b" + + click.clear() + click.echo( + click.style( + f"=== Manage Optimization Unit: {optimization_unit.name} ===", + fg="yellow", + ) + ) + + print_optimization_unit_details(optimization_unit, configuration_service) + + click.echo("") + click.echo("") + + click.echo("1. Activate optimization unit") + click.echo("2. Deactivate optimization unit") + click.echo("3. Update optimization unit") + click.echo("4. Delete optimization unit") + click.echo("") + click.echo("5. Assign Energy Source") + click.echo("6. Assign Optimization Policy") + click.echo("7. Assign Target Miners") + click.echo("8. Add a Target Miner") + click.echo("9. Remove a Target Miner") + click.echo("10. Assign Notifiers") + click.echo("11. Add a Notifier") + click.echo("12. Remove a Notifier") + click.echo("") + click.echo("b. Back to optimization unit menu") + click.echo("q. Close application") + click.echo("-----------------") + + choice: str = click.prompt("Choose an option", type=str, default="b") + choice = choice.strip().lower() + + if choice == "1": + handle_activate_optimization_unit(optimization_unit, configuration_service) + continue + elif choice == "2": + handle_deactivate_optimization_unit(optimization_unit, configuration_service) + continue + elif choice == "3": + updated_optimization_unit = update_optimization_unit(optimization_unit, configuration_service, logger) + optimization_unit = updated_optimization_unit or optimization_unit + continue + elif choice == "4": + delete_status = delete_single_optimization_unit(optimization_unit, configuration_service) + if delete_status: + return "b" + continue + elif choice == "5": + manage_assign_energy_source(optimization_unit, configuration_service, logger) + continue + elif choice == "6": + manage_assign_optimization_policy(optimization_unit, configuration_service) + continue + elif choice == "7": + manage_assign_target_miners(optimization_unit, configuration_service, logger) + continue + elif choice == "8": + manage_add_target_miner(optimization_unit, configuration_service, logger) + continue + elif choice == "9": + manage_remove_target_miner(optimization_unit, configuration_service, logger) + continue + elif choice == "10": + manage_assign_notifiers(optimization_unit, configuration_service, logger) + continue + elif choice == "11": + manage_add_notifier(optimization_unit, configuration_service, logger) + continue + elif choice == "12": + manage_remove_notifier(optimization_unit, configuration_service, logger) + continue + elif choice == "b": + break + elif choice == "q": + break + else: + click.echo(click.style("Invalid choice. Try again.", fg="red")) + click.pause("Press any key to return to the menu...") + + return choice + + +def select_optimization_unit( + configuration_service: ConfigurationServiceInterface, logger: LoggerPort, default_id: Optional[EntityId] = None +) -> Optional[EnergyOptimizationUnit]: + """Select an optimization unit.""" + click.echo(click.style("\n--- Select an Energy Optimization Unit ---", fg="yellow")) + + units = configuration_service.list_optimization_units() + if not units: + click.echo(click.style("No optimization units configured.", fg="red")) + return None + + default_idx = "" + for idx, unit in enumerate(units): + click.echo( + f"{idx}. " + + "Name: " + + click.style(f"{unit.name}, ", fg="blue") + + "ID: " + + click.style(f"{unit.id}, ", fg="yellow") + + "Description: " + + click.style(f"{unit.description if unit.description else 'N/A'}, ", fg="cyan") + + "Enabled: " + + click.style(f"{'Yes' if unit.is_enabled else 'No'}", fg="green" if unit.is_enabled else "red") + ) + + if default_id: + if unit.id == default_id: + default_idx = str(idx) + + click.echo("\nb. Back to menu\n") + + unit_idx: str = click.prompt("Choose an optimization unit index", type=str, default=default_idx) + unit_idx = unit_idx.strip().lower() + if unit_idx == "b": + return None + + if not unit_idx.isdigit() or int(unit_idx) < 0 or int(unit_idx) >= len(units): + click.echo(click.style("Invalid optimization unit index selected.", fg="red")) + return None + + selected_unit = units[int(unit_idx)] + return selected_unit + + +def handle_manage_optimization_unit( + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> str: + """Menu to manage a specific optimization unit.""" + selected_optimization_unit = select_optimization_unit(configuration_service, logger) + + if not selected_optimization_unit: + click.echo(click.style("No optimization unit selected. Aborting.", fg="red")) + return "b" + + choice = manage_single_optimization_unit_menu( + unit_id=selected_optimization_unit.id, configuration_service=configuration_service, logger=logger + ) + + return choice + + +def optimization_unit_menu(configuration_service: ConfigurationServiceInterface, logger: LoggerPort) -> str: + """Menu for managing Optimization Units.""" + while True: + click.echo("\n" + click.style("--- MENU ENERGY OPTIMIZATION UNIT ---", fg="yellow", bold=True)) + click.echo("1. Add an Energy Optimization Unit") + click.echo("2. List all Energy Optimization Units") + click.echo("3. Manage an Energy Optimization Unit") + click.echo("") + click.echo("b. Back to main menu") + click.echo("q. Close application") + click.echo("---------------------------------") + + choice: str = click.prompt("Choose an option", type=str) + choice = choice.strip().lower() + + click.clear() + + if choice == "1": + handle_add_optimization_unit(configuration_service=configuration_service, logger=logger) + elif choice == "2": + handle_list_optimization_units(configuration_service=configuration_service) + elif choice == "3": + sub_choice = handle_manage_optimization_unit(configuration_service=configuration_service, logger=logger) + if sub_choice == "q": + break + elif choice == "b": + break + elif choice == "q": + break + else: + click.echo(click.style("Invalid choice. Try again.", fg="red")) + click.pause("Press any key to return to the menu...") + + return choice diff --git a/core/edge_mining/adapters/domain/optimization_unit/fast_api/__init__.py b/core/edge_mining/adapters/domain/optimization_unit/fast_api/__init__.py new file mode 100644 index 0000000..b53cc30 --- /dev/null +++ b/core/edge_mining/adapters/domain/optimization_unit/fast_api/__init__.py @@ -0,0 +1 @@ +"""Adapters API for the Energy Optimization Unit domain.""" diff --git a/core/edge_mining/adapters/domain/optimization_unit/fast_api/router.py b/core/edge_mining/adapters/domain/optimization_unit/fast_api/router.py new file mode 100644 index 0000000..de7957e --- /dev/null +++ b/core/edge_mining/adapters/domain/optimization_unit/fast_api/router.py @@ -0,0 +1,464 @@ +"""API Router for optimization unit domain.""" + +import uuid +from typing import Annotated, List, Optional + +from fastapi import APIRouter, Depends, HTTPException + +from edge_mining.adapters.domain.optimization_unit.schemas import ( + EnergyOptimizationUnitCreateSchema, + EnergyOptimizationUnitSchema, + EnergyOptimizationUnitUpdateSchema, +) +from edge_mining.adapters.domain.policy.schemas import DecisionalContextSchema + +# Import dependency injection setup functions +from edge_mining.adapters.infrastructure.api.setup import get_config_service, get_optimization_service +from edge_mining.application.interfaces import ConfigurationServiceInterface, OptimizationServiceInterface +from edge_mining.domain.common import EntityId +from edge_mining.domain.optimization_unit.aggregate_roots import EnergyOptimizationUnit +from edge_mining.domain.optimization_unit.exceptions import ( + OptimizationUnitAlreadyExistsError, + OptimizationUnitConfigurationError, + OptimizationUnitNotFoundError, +) + +router = APIRouter() + + +@router.get("/optimization-units", response_model=List[EnergyOptimizationUnitSchema]) +async def get_optimization_units_list( + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> List[EnergyOptimizationUnitSchema]: + """Get a list of all configured optimization units.""" + try: + optimization_units: List[EnergyOptimizationUnit] = config_service.list_optimization_units() + + # Convert to optimization unit schema + optimization_unit_schemas: List[EnergyOptimizationUnitSchema] = [] + + for optimization_unit in optimization_units: + optimization_unit_schemas.append(EnergyOptimizationUnitSchema.from_model(optimization_unit)) + + return optimization_unit_schemas + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/optimization-units", response_model=EnergyOptimizationUnitSchema) +async def add_optimization_unit( + optimization_unit_data: EnergyOptimizationUnitCreateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyOptimizationUnitSchema: + """Add a new optimization unit.""" + try: + # Convert to domain model + optimization_unit_to_add: EnergyOptimizationUnit = optimization_unit_data.to_model() + + # Add the optimization unit + created_unit = await config_service.create_optimization_unit( + name=optimization_unit_to_add.name, + description=optimization_unit_to_add.description, + policy_id=optimization_unit_to_add.policy_id, + target_miner_ids=optimization_unit_to_add.target_miner_ids, + energy_source_id=optimization_unit_to_add.energy_source_id, + performance_tracker_id=optimization_unit_to_add.performance_tracker_id, + home_loads_profile_id=optimization_unit_to_add.home_loads_profile, + notifier_ids=optimization_unit_to_add.notifier_ids, + ) + + if created_unit is None: + raise HTTPException(status_code=500, detail="Failed to create optimization unit") + + response = EnergyOptimizationUnitSchema.from_model(created_unit) + return response + except OptimizationUnitAlreadyExistsError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except OptimizationUnitConfigurationError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/optimization-units/{unit_id}", response_model=EnergyOptimizationUnitSchema) +async def get_optimization_unit( + unit_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyOptimizationUnitSchema: + """Get details of a specific optimization unit.""" + try: + optimization_unit: Optional[EnergyOptimizationUnit] = config_service.get_optimization_unit(unit_id) + + if optimization_unit is None: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found") + + return EnergyOptimizationUnitSchema.from_model(optimization_unit) + except OptimizationUnitNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.put("/optimization-units/{unit_id}", response_model=EnergyOptimizationUnitSchema) +async def update_optimization_unit( + unit_id: EntityId, + optimization_unit_update: EnergyOptimizationUnitUpdateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyOptimizationUnitSchema: + """Update an existing optimization unit.""" + try: + optimization_unit = config_service.get_optimization_unit(unit_id) + + if optimization_unit is None: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found") + + # Parse entity IDs + policy_id: Optional[EntityId] = None + if optimization_unit_update.policy_id: + policy_id = EntityId(uuid.UUID(optimization_unit_update.policy_id)) + + target_miner_ids: List[EntityId] = [] + if optimization_unit_update.target_miner_ids: + target_miner_ids = [EntityId(uuid.UUID(miner_id)) for miner_id in optimization_unit_update.target_miner_ids] + + energy_source_id: Optional[EntityId] = None + if optimization_unit_update.energy_source_id: + energy_source_id = EntityId(uuid.UUID(optimization_unit_update.energy_source_id)) + + performance_tracker_id: Optional[EntityId] = None + if optimization_unit_update.performance_tracker_id: + performance_tracker_id = EntityId(uuid.UUID(optimization_unit_update.performance_tracker_id)) + + notifier_ids: List[EntityId] = [] + if optimization_unit_update.notifier_ids: + notifier_ids = [EntityId(uuid.UUID(notifier_id)) for notifier_id in optimization_unit_update.notifier_ids] + + home_loads_profile_id: Optional[EntityId] = None + if optimization_unit_update.home_loads_profile_id: + home_loads_profile_id = EntityId(uuid.UUID(optimization_unit_update.home_loads_profile_id)) + + # Update the optimization unit + updated_unit = await config_service.update_optimization_unit( + unit_id=unit_id, + name=optimization_unit_update.name or "", + description=optimization_unit_update.description, + policy_id=policy_id, + target_miner_ids=target_miner_ids, + energy_source_id=energy_source_id, + performance_tracker_id=performance_tracker_id, + home_loads_profile_id=home_loads_profile_id, + notifier_ids=notifier_ids, + ) + + response = EnergyOptimizationUnitSchema.from_model(updated_unit) + + return response + except OptimizationUnitNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.delete("/optimization-units/{unit_id}", response_model=EnergyOptimizationUnitSchema) +async def delete_optimization_unit( + unit_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyOptimizationUnitSchema: + """Remove an optimization unit.""" + try: + deleted_unit = await config_service.remove_optimization_unit(unit_id) + + response = EnergyOptimizationUnitSchema.from_model(deleted_unit) + + return response + except OptimizationUnitNotFoundError as e: + raise HTTPException(status_code=404, detail="Optimization Unit not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/optimization-units/{unit_id}/enable", response_model=EnergyOptimizationUnitSchema) +async def enable_optimization_unit( + unit_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyOptimizationUnitSchema: + """Enable an optimization unit.""" + try: + optimization_unit = config_service.get_optimization_unit(unit_id) + + if optimization_unit is None: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found") + + enabled_unit = await config_service.activate_optimization_unit(unit_id) + + response = EnergyOptimizationUnitSchema.from_model(enabled_unit) + + return response + except OptimizationUnitNotFoundError as e: + raise HTTPException(status_code=404, detail="Optimization Unit not found") from e + except OptimizationUnitConfigurationError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/optimization-units/{unit_id}/disable", response_model=EnergyOptimizationUnitSchema) +async def disable_optimization_unit( + unit_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyOptimizationUnitSchema: + """Disable an optimization unit.""" + try: + optimization_unit = config_service.get_optimization_unit(unit_id) + + if optimization_unit is None: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found") + + disabled_unit = await config_service.deactivate_optimization_unit(unit_id) + + response = EnergyOptimizationUnitSchema.from_model(disabled_unit) + + return response + except OptimizationUnitNotFoundError as e: + raise HTTPException(status_code=404, detail="Optimization Unit not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/optimization-units/{unit_id}/energy-source", response_model=EnergyOptimizationUnitSchema) +async def assign_energy_source( + unit_id: EntityId, + energy_source_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyOptimizationUnitSchema: + """Assign an energy source to an optimization unit.""" + try: + optimization_unit = config_service.get_optimization_unit(unit_id) + + if optimization_unit is None: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found") + + updated_unit = await config_service.assign_energy_source_to_optimization_unit(unit_id, energy_source_id) + + response = EnergyOptimizationUnitSchema.from_model(updated_unit) + + return response + except OptimizationUnitNotFoundError as e: + raise HTTPException(status_code=404, detail="Optimization Unit not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/optimization-units/{unit_id}/policy", response_model=EnergyOptimizationUnitSchema) +async def assign_policy( + unit_id: EntityId, + policy_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyOptimizationUnitSchema: + """Assign an optimization policy to an optimization unit.""" + try: + optimization_unit = config_service.get_optimization_unit(unit_id) + + if optimization_unit is None: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found") + + updated_unit = await config_service.assign_policy_to_optimization_unit(unit_id, policy_id) + + response = EnergyOptimizationUnitSchema.from_model(updated_unit) + + return response + except OptimizationUnitNotFoundError as e: + raise HTTPException(status_code=404, detail="Optimization Unit not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/optimization-units/{unit_id}/miners", response_model=EnergyOptimizationUnitSchema) +async def assign_miners( + unit_id: EntityId, + miner_ids: List[EntityId], + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyOptimizationUnitSchema: + """Assign target miners to an optimization unit.""" + try: + optimization_unit = config_service.get_optimization_unit(unit_id) + + if optimization_unit is None: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found") + + updated_unit = await config_service.assign_miners_to_optimization_unit(unit_id, miner_ids) + + response = EnergyOptimizationUnitSchema.from_model(updated_unit) + + return response + except OptimizationUnitNotFoundError as e: + raise HTTPException(status_code=404, detail="Optimization Unit not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/optimization-units/{unit_id}/miners/single", response_model=EnergyOptimizationUnitSchema) +async def add_target_miner( + unit_id: EntityId, + miner_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyOptimizationUnitSchema: + """Add a single target miner to an optimization unit.""" + try: + optimization_unit = config_service.get_optimization_unit(unit_id) + + if optimization_unit is None: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found") + + updated_unit = await config_service.add_miner_to_optimization_unit(unit_id, miner_id) + + response = EnergyOptimizationUnitSchema.from_model(updated_unit) + + return response + except OptimizationUnitNotFoundError as e: + raise HTTPException(status_code=404, detail="Optimization Unit not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.delete("/optimization-units/{unit_id}/miners/{miner_id}", response_model=EnergyOptimizationUnitSchema) +async def remove_target_miner( + unit_id: EntityId, + miner_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyOptimizationUnitSchema: + """Remove a target miner from an optimization unit.""" + try: + optimization_unit = config_service.get_optimization_unit(unit_id) + + if optimization_unit is None: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found") + + updated_unit = await config_service.remove_miner_from_optimization_unit(unit_id, miner_id) + + response = EnergyOptimizationUnitSchema.from_model(updated_unit) + + return response + except OptimizationUnitNotFoundError as e: + raise HTTPException(status_code=404, detail="Optimization Unit not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/optimization-units/{unit_id}/home-loads-profile", response_model=EnergyOptimizationUnitSchema) +async def assign_home_loads_profile( + unit_id: EntityId, + home_loads_profile_id: Optional[EntityId] = None, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)] = None, +) -> EnergyOptimizationUnitSchema: + """Assign a home loads profile to an optimization unit.""" + try: + optimization_unit = config_service.get_optimization_unit(unit_id) + + if optimization_unit is None: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found") + + updated_unit = await config_service.assign_home_loads_profile_to_optimization_unit( + unit_id, home_loads_profile_id + ) + + response = EnergyOptimizationUnitSchema.from_model(updated_unit) + + return response + except OptimizationUnitNotFoundError as e: + raise HTTPException(status_code=404, detail="Optimization Unit not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/optimization-units/{unit_id}/notifiers", response_model=EnergyOptimizationUnitSchema) +async def assign_notifiers( + unit_id: EntityId, + notifier_ids: List[EntityId], + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyOptimizationUnitSchema: + """Assign notifiers to an optimization unit.""" + try: + optimization_unit = config_service.get_optimization_unit(unit_id) + + if optimization_unit is None: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found") + + updated_unit = await config_service.assign_notifiers_to_optimization_unit(unit_id, notifier_ids) + + response = EnergyOptimizationUnitSchema.from_model(updated_unit) + + return response + except OptimizationUnitNotFoundError as e: + raise HTTPException(status_code=404, detail="Optimization Unit not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/optimization-units/{unit_id}/notifiers/single", response_model=EnergyOptimizationUnitSchema) +async def add_notifier( + unit_id: EntityId, + notifier_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyOptimizationUnitSchema: + """Add a single notifier to an optimization unit.""" + try: + optimization_unit = config_service.get_optimization_unit(unit_id) + + if optimization_unit is None: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found") + + updated_unit = await config_service.add_notifier_to_optimization_unit(unit_id, notifier_id) + + response = EnergyOptimizationUnitSchema.from_model(updated_unit) + + return response + except OptimizationUnitNotFoundError as e: + raise HTTPException(status_code=404, detail="Optimization Unit not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.delete("/optimization-units/{unit_id}/notifiers/{notifier_id}", response_model=EnergyOptimizationUnitSchema) +async def remove_notifier( + unit_id: EntityId, + notifier_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> EnergyOptimizationUnitSchema: + """Remove a notifier from an optimization unit.""" + try: + optimization_unit = config_service.get_optimization_unit(unit_id) + + if optimization_unit is None: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found") + + updated_unit = await config_service.remove_notifier_from_optimization_unit(unit_id, notifier_id) + + response = EnergyOptimizationUnitSchema.from_model(updated_unit) + + return response + except OptimizationUnitNotFoundError as e: + raise HTTPException(status_code=404, detail="Optimization Unit not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/optimization-units/{unit_id}/decisional-context", response_model=DecisionalContextSchema) +async def get_decisional_context( + unit_id: EntityId, + optimization_service: Annotated[OptimizationServiceInterface, Depends(get_optimization_service)], +) -> DecisionalContextSchema: + """Get the current decisional context for an optimization unit.""" + try: + context = await optimization_service.get_decisional_context(unit_id) + + if context is None: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found") + + return DecisionalContextSchema.from_model(context) + except OptimizationUnitNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e diff --git a/core/edge_mining/adapters/domain/optimization_unit/repositories.py b/core/edge_mining/adapters/domain/optimization_unit/repositories.py new file mode 100644 index 0000000..f8592ae --- /dev/null +++ b/core/edge_mining/adapters/domain/optimization_unit/repositories.py @@ -0,0 +1,408 @@ +"""Repositories for the Optimization Unit domain.""" + +import copy +import json +import sqlite3 +from typing import Dict, List, Optional + +from sqlalchemy import select + +from edge_mining.adapters.domain.optimization_unit.tables import optimization_units_table +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.adapters.infrastructure.persistence.sqlite import BaseSqliteRepository +from edge_mining.domain.common import EntityId +from edge_mining.domain.optimization_unit.aggregate_roots import EnergyOptimizationUnit +from edge_mining.domain.optimization_unit.exceptions import ( + OptimizationUnitAlreadyExistsError, + OptimizationUnitConfigurationError, + OptimizationUnitError, + OptimizationUnitNotFoundError, +) +from edge_mining.domain.optimization_unit.ports import EnergyOptimizationUnitRepository + + +class InMemoryOptimizationUnitRepository(EnergyOptimizationUnitRepository): + """In-Memory implementation for the Optimization Unit Repository.""" + + def __init__( + self, + initial_units: Optional[Dict[EntityId, EnergyOptimizationUnit]] = None, + ): + self._optimization_units: Dict[EntityId, EnergyOptimizationUnit] = ( + copy.deepcopy(initial_units) if initial_units else {} + ) + + def add(self, optimization_unit: EnergyOptimizationUnit) -> None: + """Add an optimization unit to the In-Memory repository.""" + if optimization_unit.id in self._optimization_units: + # Handle update or raise error depending on desired behavior + print(f"Warning: Optimization Unit {optimization_unit.id} already exists, overwriting.") + self._optimization_units[optimization_unit.id] = copy.deepcopy(optimization_unit) + + def get_by_id(self, optimization_unit_id: EntityId) -> Optional[EnergyOptimizationUnit]: + """Get an optimization unit by ID from the In-Memory repository.""" + return copy.deepcopy(self._optimization_units.get(optimization_unit_id)) + + def get_all_enabled(self) -> List[EnergyOptimizationUnit]: + """Get all enabled optimization units from the In-Memory repository.""" + return [copy.deepcopy(u) for u in self._optimization_units.values() if u.is_enabled] + + def get_all(self) -> List[EnergyOptimizationUnit]: + """Get all optimization units from the In-Memory repository.""" + return [copy.deepcopy(u) for u in self._optimization_units.values()] + + def update(self, optimization_unit: EnergyOptimizationUnit) -> None: + """Update an optimization unit in the In-Memory repository.""" + if optimization_unit.id not in self._optimization_units: + raise ValueError(f"Optimization Unit {optimization_unit.id} not found for update.") + self._optimization_units[optimization_unit.id] = copy.deepcopy(optimization_unit) + + def remove(self, optimization_unit_id: EntityId) -> None: + """Remove an optimization unit from the In-Memory repository.""" + if optimization_unit_id in self._optimization_units: + del self._optimization_units[optimization_unit_id] + + +class SqliteOptimizationUnitRepository(EnergyOptimizationUnitRepository): + """SQLite implementation for the Optimization Unit Repository.""" + + def __init__(self, db: BaseSqliteRepository): + self._db = db + self.logger = db.logger + + self._create_tables() + + def _create_tables(self): + """Create the necessary tables for the Optimization Unit domain if they do not exist.""" + self.logger.debug(f"Ensuring SQLite tables exist for Optimization Unit Repository in {self._db.db_path}...") + sql_statements = [ + """ + CREATE TABLE IF NOT EXISTS optimization_units ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + is_enabled INTEGER NOT NULL DEFAULT 0 CHECK(is_enabled IN (0,1)), + policy_id TEXT, + target_miner_ids TEXT, -- JSON list of MinerId strings + energy_source_id TEXT, + performance_tracker_id TEXT, + home_loads_profile_id TEXT, + notifier_ids TEXT -- JSON list of NotifierId strings + ); + """ + ] + + conn = self._db.get_connection() + + try: + with conn: + cursor = conn.cursor() + for statement in sql_statements: + cursor.execute(statement) + + self.logger.debug("Optimization Units tables checked/created successfully.") + except sqlite3.Error as e: + self.logger.error(f"Error creating SQLite tables: {e}") + raise OptimizationUnitConfigurationError(f"DB error creating tables: {e}") from e + finally: + if conn: + conn.close() + + def _row_to_optimization_unit(self, row: sqlite3.Row) -> Optional[EnergyOptimizationUnit]: + """Deserialize a row from the database into a EnergyOptimizationUnit object.""" + if not row: + return None + try: + # Deserialize JSON lists of target IDs + target_ids_data = json.loads(row["target_miner_ids"] or "[]") + notifier_ids_data = json.loads(row["notifier_ids"] or "[]") + + target_miner_ids = [EntityId(tid) for tid in target_ids_data] + notifier_ids = [EntityId(nid) for nid in notifier_ids_data] + + return EnergyOptimizationUnit( + id=EntityId(row["id"]), + name=row["name"], + description=row["description"], + is_enabled=bool(row["is_enabled"]), + policy_id=(EntityId(row["policy_id"]) if row["policy_id"] else None), + target_miner_ids=target_miner_ids, + energy_source_id=(EntityId(row["energy_source_id"]) if row["energy_source_id"] else None), + performance_tracker_id=( + EntityId(row["performance_tracker_id"]) if row["performance_tracker_id"] else None + ), + home_loads_profile=( + EntityId(row["home_loads_profile_id"]) + if row.keys().__contains__("home_loads_profile_id") and row["home_loads_profile_id"] + else None + ), + notifier_ids=notifier_ids, + ) + except (ValueError, KeyError) as e: + self.logger.error(f"Error deserializing Optimization Unit from DB row: {row}. Error: {e}") + return None + + def add(self, optimization_unit: EnergyOptimizationUnit) -> None: + """Add an optimization unit to the SQLite database.""" + self.logger.debug(f"Adding optimization unit {optimization_unit.id} to SQLite.") + sql = """ + INSERT INTO optimization_units (id, name, description, is_enabled, policy_id, target_miner_ids, + energy_source_id, performance_tracker_id, home_loads_profile_id, notifier_ids) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + conn = self._db.get_connection() + try: + # Serialize JSON lists of target IDs + target_ids_json = json.dumps([str(tid) for tid in optimization_unit.target_miner_ids]) + notifier_ids_json = json.dumps([str(nid) for nid in optimization_unit.notifier_ids]) + + with conn: + conn.execute( + sql, + ( + optimization_unit.id, + optimization_unit.name, + optimization_unit.description, + 1 if optimization_unit.is_enabled else 0, + optimization_unit.policy_id, + target_ids_json, + optimization_unit.energy_source_id, + optimization_unit.performance_tracker_id, + str(optimization_unit.home_loads_profile) if optimization_unit.home_loads_profile else None, + notifier_ids_json, + ), + ) + except sqlite3.IntegrityError as e: + self.logger.error(f"Integrity error adding optimization unit '{optimization_unit.name}': {e}") + raise OptimizationUnitAlreadyExistsError( + f"Optimization Unit with ID {optimization_unit.id} or name " + f"'{optimization_unit.name}' already exists: {e}" + ) from e + except Exception as e: + self.logger.error(f"Error adding optimization unit '{optimization_unit.name}': {e}") + raise + finally: + if conn: + conn.close() + + def get_by_id(self, optimization_unit_id: EntityId) -> Optional[EnergyOptimizationUnit]: + """Get an optimization unit by ID from the SQLite database.""" + self.logger.debug(f"Getting optimization unit {optimization_unit_id} from SQLite.") + sql = "SELECT * FROM optimization_units WHERE id = ?" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (optimization_unit_id,)) + row = cursor.fetchone() + return self._row_to_optimization_unit(row) + except sqlite3.Error as e: + self.logger.error(f"SQLite error getting optimization unit {optimization_unit_id}: {e}") + return None + finally: + if conn: + conn.close() + + def get_all_enabled(self) -> List[EnergyOptimizationUnit]: + """Get all enabled optimization units from the SQLite database.""" + self.logger.debug("Getting all enabled optimization units from SQLite.") + sql = "SELECT * FROM optimization_units WHERE is_enabled = 1" + conn = self._db.get_connection() + optimization_units = [] + try: + cursor = conn.cursor() + cursor.execute(sql) + rows = cursor.fetchall() + for row in rows: + optimization_unit = self._row_to_optimization_unit(row) + if optimization_unit: + optimization_units.append(optimization_unit) + except sqlite3.Error as e: + self.logger.error(f"SQLite error getting all enabled optimization units: {e}") + return [] + finally: + if conn: + conn.close() + return optimization_units + + def get_all(self) -> List[EnergyOptimizationUnit]: + """Get all optimization units from the SQLite database.""" + self.logger.debug("Getting all optimization units from SQLite.") + sql = "SELECT * FROM optimization_units" + conn = self._db.get_connection() + optimization_units = [] + try: + cursor = conn.cursor() + cursor.execute(sql) + rows = cursor.fetchall() + for row in rows: + optimization_unit = self._row_to_optimization_unit(row) + if optimization_unit: + optimization_units.append(optimization_unit) + except sqlite3.Error as e: + self.logger.error(f"SQLite error getting all enabled optimization units: {e}") + return [] + finally: + if conn: + conn.close() + return optimization_units + + def update(self, optimization_unit: EnergyOptimizationUnit) -> None: + """Update an optimization unit in the SQLite database.""" + self.logger.debug(f"Updating optimization unit {optimization_unit.id} in SQLite.") + sql = """ + UPDATE optimization_units + SET name = ?, description = ?, is_enabled = ?, policy_id = ?, target_miner_ids = ?, energy_source_id = ?, + performance_tracker_id = ?, home_loads_profile_id = ?, notifier_ids = ? + WHERE id = ? + """ + conn = self._db.get_connection() + try: + # Serialize JSON lists of target IDs + target_ids_json = json.dumps([str(tid) for tid in optimization_unit.target_miner_ids]) + notifier_ids_json = json.dumps([str(nid) for nid in optimization_unit.notifier_ids]) + + with conn: + cursor = conn.cursor() + cursor.execute( + sql, + ( + optimization_unit.name, + optimization_unit.description, + 1 if optimization_unit.is_enabled else 0, + optimization_unit.policy_id, + target_ids_json, + optimization_unit.energy_source_id, + optimization_unit.performance_tracker_id, + str(optimization_unit.home_loads_profile) if optimization_unit.home_loads_profile else None, + notifier_ids_json, + optimization_unit.id, + ), + ) + if cursor.rowcount == 0: + raise OptimizationUnitNotFoundError( + f"No optimization unit found with ID {optimization_unit.id} for update." + ) + except sqlite3.Error as e: + self.logger.error(f"SQLite error updating optimization unit {optimization_unit.id}: {e}") + raise OptimizationUnitError(f"DB error updating optimization unit: {e}") from e + finally: + if conn: + conn.close() + + def remove(self, optimization_unit_id: EntityId) -> None: + """Remove an optimization unit from the SQLite database.""" + self.logger.debug(f"Removing optimization unit {optimization_unit_id} from SQLite.") + sql = "DELETE FROM optimization_units WHERE id = ?" + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + cursor.execute(sql, (optimization_unit_id,)) + if cursor.rowcount == 0: + self.logger.warning( + f"Attempt to remove non-existent optimization unit with ID {optimization_unit_id}." + ) + # There is no need to raise an exception here, removing a + # non-existent is idempotent. + except sqlite3.Error as e: + self.logger.error(f"SQLite error removing optimization unit {optimization_unit_id}: {e}") + raise OptimizationUnitError(f"DB error removing optimization unit: {e}") from e + finally: + if conn: + conn.close() + + +# SQLAlchemy implementation + + +class SqlAlchemyOptimizationUnitRepository(EnergyOptimizationUnitRepository): + """SQLAlchemy implementation of EnergyOptimizationUnitRepository. + + This repository works directly with the imperatively mapped EnergyOptimizationUnit domain entity. + + Args: + db: BaseSQLAlchemyRepository instance for database operations + """ + + def __init__(self, db: BaseSQLAlchemyRepository): + """Initialize repository with database instance. + + Args: + db: BaseSQLAlchemyRepository instance + """ + self._db = db + self.logger = db.logger + + def add(self, optimization_unit: EnergyOptimizationUnit) -> None: + """Add an optimization unit to the repository.""" + session = self._db.get_session() + try: + session.add(optimization_unit) + session.commit() + finally: + session.close() + + def get_by_id(self, optimization_unit_id: EntityId) -> Optional[EnergyOptimizationUnit]: + """Get an optimization unit by ID.""" + session = self._db.get_session() + try: + stmt = select(EnergyOptimizationUnit).where(optimization_units_table.c.id == str(optimization_unit_id)) + entity = session.execute(stmt).scalar_one_or_none() + return entity + finally: + session.close() + + def get_all_enabled(self) -> List[EnergyOptimizationUnit]: + """Get all enabled optimization units.""" + session = self._db.get_session() + try: + stmt = select(EnergyOptimizationUnit).where(optimization_units_table.c.is_enabled) + entities = session.execute(stmt).scalars().all() + return list(entities) + finally: + session.close() + + def get_all(self) -> List[EnergyOptimizationUnit]: + """Get all optimization units.""" + session = self._db.get_session() + try: + stmt = select(EnergyOptimizationUnit) + entities = session.execute(stmt).scalars().all() + return list(entities) + finally: + session.close() + + def update(self, optimization_unit: EnergyOptimizationUnit) -> None: + """Update an optimization unit.""" + session = self._db.get_session() + try: + stmt = select(EnergyOptimizationUnit).where(optimization_units_table.c.id == str(optimization_unit.id)) + existing_entity = session.execute(stmt).scalar_one_or_none() + + if existing_entity: + existing_entity.name = optimization_unit.name + existing_entity.description = optimization_unit.description + existing_entity.is_enabled = optimization_unit.is_enabled + existing_entity.policy_id = optimization_unit.policy_id + existing_entity.target_miner_ids = optimization_unit.target_miner_ids + existing_entity.energy_source_id = optimization_unit.energy_source_id + existing_entity.performance_tracker_id = optimization_unit.performance_tracker_id + existing_entity.home_loads_profile = optimization_unit.home_loads_profile + existing_entity.notifier_ids = optimization_unit.notifier_ids + + session.commit() + finally: + session.close() + + def remove(self, optimization_unit_id: EntityId) -> None: + """Remove an optimization unit by ID.""" + session = self._db.get_session() + try: + stmt = select(EnergyOptimizationUnit).where(optimization_units_table.c.id == str(optimization_unit_id)) + entity = session.execute(stmt).scalar_one_or_none() + + if entity: + session.delete(entity) + session.commit() + finally: + session.close() diff --git a/core/edge_mining/adapters/domain/optimization_unit/schemas.py b/core/edge_mining/adapters/domain/optimization_unit/schemas.py new file mode 100644 index 0000000..85d1228 --- /dev/null +++ b/core/edge_mining/adapters/domain/optimization_unit/schemas.py @@ -0,0 +1,436 @@ +"""Validation schemas for optimization unit domain.""" + +import uuid +from typing import List, Optional + +from pydantic import BaseModel, Field, field_serializer, field_validator + +from edge_mining.domain.common import EntityId +from edge_mining.domain.optimization_unit.aggregate_roots import EnergyOptimizationUnit + + +class EnergyOptimizationUnitSchema(BaseModel): + """Schema for EnergyOptimizationUnit aggregate root with complete validation.""" + + id: str = Field(..., description="Unique identifier for the energy optimization unit") + name: str = Field(default="", description="Energy optimization unit name") + description: Optional[str] = Field(default=None, description="Energy optimization unit description") + is_enabled: bool = Field(default=False, description="Whether the optimization unit is enabled") + policy_id: Optional[str] = Field(default=None, description="ID of the policy to be used for optimization") + target_miner_ids: List[str] = Field(default_factory=list, description="List of target miner IDs to be controlled") + energy_source_id: Optional[str] = Field(default=None, description="ID of the energy source to be used") + performance_tracker_id: Optional[str] = Field(default=None, description="ID of the performance tracker to be used") + home_loads_profile_id: Optional[str] = Field(default=None, description="ID of the home loads profile to be used") + notifier_ids: List[str] = Field(default_factory=list, description="List of notifier IDs to be used") + + @field_validator("id") + @classmethod + def validate_id(cls, v: str) -> str: + """Validate that id is a valid UUID string.""" + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("id must be a valid UUID string") from exc + return v + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate optimization unit name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("description") + @classmethod + def validate_description(cls, v: Optional[str]) -> Optional[str]: + """Validate optimization unit description.""" + if v is not None: + v = v.strip() + if not v: + v = None + return v + + @field_validator("policy_id") + @classmethod + def validate_policy_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that policy_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("policy_id must be a valid UUID string") from exc + return v + + @field_validator("target_miner_ids") + @classmethod + def validate_target_miner_ids(cls, v: List[str]) -> List[str]: + """Validate that all target_miner_ids are valid UUID strings.""" + for miner_id in v: + try: + uuid.UUID(miner_id) + except ValueError as exc: + raise ValueError(f"target_miner_id '{miner_id}' must be a valid UUID string") from exc + return v + + @field_validator("energy_source_id") + @classmethod + def validate_energy_source_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that energy_source_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("energy_source_id must be a valid UUID string") from exc + return v + + @field_validator("performance_tracker_id") + @classmethod + def validate_performance_tracker_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that performance_tracker_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("performance_tracker_id must be a valid UUID string") from exc + return v + + @field_validator("home_loads_profile_id") + @classmethod + def validate_home_loads_profile_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that home_loads_profile_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("home_loads_profile_id must be a valid UUID string") from exc + return v + + @field_validator("notifier_ids") + @classmethod + def validate_notifier_ids(cls, v: List[str]) -> List[str]: + """Validate that all notifier_ids are valid UUID strings.""" + for notifier_id in v: + try: + uuid.UUID(notifier_id) + except ValueError as exc: + raise ValueError(f"notifier_id '{notifier_id}' must be a valid UUID string") from exc + return v + + @classmethod + def from_model(cls, optimization_unit: EnergyOptimizationUnit) -> "EnergyOptimizationUnitSchema": + """Create EnergyOptimizationUnitSchema from an EnergyOptimizationUnit domain model instance.""" + return cls( + id=str(optimization_unit.id), + name=optimization_unit.name, + description=optimization_unit.description, + is_enabled=optimization_unit.is_enabled, + policy_id=str(optimization_unit.policy_id) if optimization_unit.policy_id else None, + target_miner_ids=[str(miner_id) for miner_id in optimization_unit.target_miner_ids], + energy_source_id=str(optimization_unit.energy_source_id) if optimization_unit.energy_source_id else None, + performance_tracker_id=( + str(optimization_unit.performance_tracker_id) if optimization_unit.performance_tracker_id else None + ), + home_loads_profile_id=( + str(optimization_unit.home_loads_profile) if optimization_unit.home_loads_profile else None + ), + notifier_ids=[str(notifier_id) for notifier_id in optimization_unit.notifier_ids], + ) + + @field_serializer("id") + def serialize_id(self, value: str) -> str: + """Serialize id field.""" + return str(value) + + @field_serializer("policy_id") + def serialize_policy_id(self, value: Optional[str]) -> Optional[str]: + """Serialize policy_id field.""" + return str(value) if value is not None else None + + @field_serializer("target_miner_ids") + def serialize_target_miner_ids(self, value: List[str]) -> List[str]: + """Serialize target_miner_ids field.""" + return [str(miner_id) for miner_id in value] + + @field_serializer("energy_source_id") + def serialize_energy_source_id(self, value: Optional[str]) -> Optional[str]: + """Serialize energy_source_id field.""" + return str(value) if value is not None else None + + @field_serializer("performance_tracker_id") + def serialize_performance_tracker_id(self, value: Optional[str]) -> Optional[str]: + """Serialize performance_tracker_id field.""" + return str(value) if value is not None else None + + @field_serializer("home_loads_profile_id") + def serialize_home_loads_profile_id(self, value: Optional[str]) -> Optional[str]: + """Serialize home_loads_profile_id field.""" + return str(value) if value is not None else None + + @field_serializer("notifier_ids") + def serialize_notifier_ids(self, value: List[str]) -> List[str]: + """Serialize notifier_ids field.""" + return [str(notifier_id) for notifier_id in value] + + def to_model(self) -> EnergyOptimizationUnit: + """Convert EnergyOptimizationUnitSchema back to EnergyOptimizationUnit domain model instance.""" + return EnergyOptimizationUnit( + id=EntityId(uuid.UUID(self.id)), + name=self.name, + description=self.description, + is_enabled=self.is_enabled, + policy_id=EntityId(uuid.UUID(self.policy_id)) if self.policy_id else None, + target_miner_ids=[EntityId(uuid.UUID(miner_id)) for miner_id in self.target_miner_ids], + energy_source_id=EntityId(uuid.UUID(self.energy_source_id)) if self.energy_source_id else None, + performance_tracker_id=( + EntityId(uuid.UUID(self.performance_tracker_id)) if self.performance_tracker_id else None + ), + home_loads_profile=( + EntityId(uuid.UUID(self.home_loads_profile_id)) if self.home_loads_profile_id else None + ), + notifier_ids=[EntityId(uuid.UUID(notifier_id)) for notifier_id in self.notifier_ids], + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + arbitrary_types_allowed = True + json_encoders = { + uuid.UUID: str, + } + + +class EnergyOptimizationUnitCreateSchema(BaseModel): + """Schema for creating a new energy optimization unit.""" + + name: str = Field(default="", description="Energy optimization unit name") + description: Optional[str] = Field(default=None, description="Energy optimization unit description") + policy_id: Optional[str] = Field(default=None, description="ID of the policy to be used for optimization") + target_miner_ids: List[str] = Field(default_factory=list, description="List of target miner IDs to be controlled") + energy_source_id: Optional[str] = Field(default=None, description="ID of the energy source to be used") + performance_tracker_id: Optional[str] = Field(default=None, description="ID of the performance tracker to be used") + home_loads_profile_id: Optional[str] = Field(default=None, description="ID of the home loads profile to be used") + notifier_ids: List[str] = Field(default_factory=list, description="List of notifier IDs to be used") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate optimization unit name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("description") + @classmethod + def validate_description(cls, v: Optional[str]) -> Optional[str]: + """Validate optimization unit description.""" + if v is not None: + v = v.strip() + if not v: + v = None + return v + + @field_validator("policy_id") + @classmethod + def validate_policy_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that policy_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("policy_id must be a valid UUID string") from exc + return v + + @field_validator("target_miner_ids") + @classmethod + def validate_target_miner_ids(cls, v: List[str]) -> List[str]: + """Validate that all target_miner_ids are valid UUID strings.""" + for miner_id in v: + try: + uuid.UUID(miner_id) + except ValueError as exc: + raise ValueError(f"target_miner_id '{miner_id}' must be a valid UUID string") from exc + return v + + @field_validator("energy_source_id") + @classmethod + def validate_energy_source_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that energy_source_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("energy_source_id must be a valid UUID string") from exc + return v + + @field_validator("performance_tracker_id") + @classmethod + def validate_performance_tracker_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that performance_tracker_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("performance_tracker_id must be a valid UUID string") from exc + return v + + @field_validator("home_loads_profile_id") + @classmethod + def validate_home_loads_profile_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that home_loads_profile_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("home_loads_profile_id must be a valid UUID string") from exc + return v + + @field_validator("notifier_ids") + @classmethod + def validate_notifier_ids(cls, v: List[str]) -> List[str]: + """Validate that all notifier_ids are valid UUID strings.""" + for notifier_id in v: + try: + uuid.UUID(notifier_id) + except ValueError as exc: + raise ValueError(f"notifier_id '{notifier_id}' must be a valid UUID string") from exc + return v + + def to_model(self) -> EnergyOptimizationUnit: + """Convert EnergyOptimizationUnitCreateSchema to an EnergyOptimizationUnit domain model instance.""" + return EnergyOptimizationUnit( + id=EntityId(uuid.uuid4()), + name=self.name, + description=self.description, + is_enabled=False, + policy_id=EntityId(uuid.UUID(self.policy_id)) if self.policy_id else None, + target_miner_ids=[EntityId(uuid.UUID(miner_id)) for miner_id in self.target_miner_ids], + energy_source_id=EntityId(uuid.UUID(self.energy_source_id)) if self.energy_source_id else None, + performance_tracker_id=( + EntityId(uuid.UUID(self.performance_tracker_id)) if self.performance_tracker_id else None + ), + home_loads_profile=( + EntityId(uuid.UUID(self.home_loads_profile_id)) if self.home_loads_profile_id else None + ), + notifier_ids=[EntityId(uuid.UUID(notifier_id)) for notifier_id in self.notifier_ids], + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + json_encoders = { + uuid.UUID: str, + } + + +class EnergyOptimizationUnitUpdateSchema(BaseModel): + """Schema for updating an existing energy optimization unit.""" + + name: str = Field(default="", description="Energy optimization unit name") + description: Optional[str] = Field(default=None, description="Energy optimization unit description") + policy_id: Optional[str] = Field(default=None, description="ID of the policy to be used for optimization") + target_miner_ids: List[str] = Field(default_factory=list, description="List of target miner IDs to be controlled") + energy_source_id: Optional[str] = Field(default=None, description="ID of the energy source to be used") + performance_tracker_id: Optional[str] = Field(default=None, description="ID of the performance tracker to be used") + home_loads_profile_id: Optional[str] = Field(default=None, description="ID of the home loads profile to be used") + notifier_ids: List[str] = Field(default_factory=list, description="List of notifier IDs to be used") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate optimization unit name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("description") + @classmethod + def validate_description(cls, v: Optional[str]) -> Optional[str]: + """Validate optimization unit description.""" + if v is not None: + v = v.strip() + if not v: + v = None + return v + + @field_validator("policy_id") + @classmethod + def validate_policy_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that policy_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("policy_id must be a valid UUID string") from exc + return v + + @field_validator("target_miner_ids") + @classmethod + def validate_target_miner_ids(cls, v: List[str]) -> List[str]: + """Validate that all target_miner_ids are valid UUID strings.""" + for miner_id in v: + try: + uuid.UUID(miner_id) + except ValueError as exc: + raise ValueError(f"target_miner_id '{miner_id}' must be a valid UUID string") from exc + return v + + @field_validator("energy_source_id") + @classmethod + def validate_energy_source_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that energy_source_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("energy_source_id must be a valid UUID string") from exc + return v + + @field_validator("performance_tracker_id") + @classmethod + def validate_performance_tracker_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that performance_tracker_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("performance_tracker_id must be a valid UUID string") from exc + return v + + @field_validator("home_loads_profile_id") + @classmethod + def validate_home_loads_profile_id(cls, v: Optional[str]) -> Optional[str]: + """Validate that home_loads_profile_id is a valid UUID string if provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("home_loads_profile_id must be a valid UUID string") from exc + return v + + @field_validator("notifier_ids") + @classmethod + def validate_notifier_ids(cls, v: List[str]) -> List[str]: + """Validate that all notifier_ids are valid UUID strings.""" + for notifier_id in v: + try: + uuid.UUID(notifier_id) + except ValueError as exc: + raise ValueError(f"notifier_id '{notifier_id}' must be a valid UUID string") from exc + return v + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + json_encoders = { + uuid.UUID: str, + } diff --git a/core/edge_mining/adapters/domain/optimization_unit/tables.py b/core/edge_mining/adapters/domain/optimization_unit/tables.py new file mode 100644 index 0000000..76f4691 --- /dev/null +++ b/core/edge_mining/adapters/domain/optimization_unit/tables.py @@ -0,0 +1,75 @@ +"""SQLAlchemy ORM mappings for OptimizationUnit domain entities. + +This module implements imperative (classical) mapping of the domain entities +to database tables. The domain entities are mapped directly without +creating separate ORM model classes, maintaining domain purity. + +All tables and mappings use the shared metadata and mapper registry from +the sqlalchemy.registry module, which are available as module-level singletons. + +⚠️ DEVELOPER WARNING ⚠️ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +ANY SCHEMA CHANGE (adding/removing/modifying tables or columns) REQUIRES an +Alembic migration. Do NOT modify this file without creating a migration: + + python scripts/migrate.py create "Description of your change" + +For detailed instructions, see: ../docs/ALEMBIC_MIGRATIONS.md +For a step-by-step example, see: ../docs/MIGRATION_EXAMPLE.md +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +""" + +import json +import uuid +from typing import List + +from sqlalchemy import Boolean, Column, ForeignKey, String, Table, TypeDecorator + +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.registry import mapper_registry, metadata +from edge_mining.domain.common import EntityId +from edge_mining.domain.optimization_unit.aggregate_roots import EnergyOptimizationUnit + + +class EntityIdListType(TypeDecorator): + """Custom SQLAlchemy type that converts List[EntityId] to/from JSON string.""" + + impl = String + cache_ok = True + + def process_bind_param(self, value, dialect) -> str: + """Convert List[EntityId] to JSON string before storing in DB.""" + if value is None: + return json.dumps([]) + if not value: + return json.dumps([]) + return json.dumps([str(eid) for eid in value]) + + def process_result_value(self, value, dialect) -> List[EntityId]: + """Convert JSON string to List[EntityId] when reading from DB.""" + if not value: + return [] + + return [EntityId(uuid.UUID(eid)) for eid in json.loads(value)] + + +# Define the optimization_units table using imperative style +optimization_units_table = Table( + "optimization_units", + metadata, + Column("id", String, primary_key=True, index=True), + Column("name", String, nullable=False), + Column("description", String, nullable=True), + Column("is_enabled", Boolean, nullable=False, default=False), + Column("policy_id", String, nullable=True), # TODO: Add ForeignKey when policies table exists + Column("target_miner_ids", EntityIdListType, nullable=False), # JSON list - could be association table + Column("energy_source_id", String, ForeignKey("energy_sources.id"), nullable=True), + Column("performance_tracker_id", String, ForeignKey("mining_performance_trackers.id"), nullable=True), + Column("home_loads_profile", String, nullable=True), + Column("notifier_ids", EntityIdListType, nullable=False), # JSON list - could be association table +) + +# Map EnergyOptimizationUnit +mapper_registry.map_imperatively( + EnergyOptimizationUnit, + optimization_units_table, +) diff --git a/core/edge_mining/adapters/domain/optimization_unit/websocket/__init__.py b/core/edge_mining/adapters/domain/optimization_unit/websocket/__init__.py new file mode 100644 index 0000000..4a41f8b --- /dev/null +++ b/core/edge_mining/adapters/domain/optimization_unit/websocket/__init__.py @@ -0,0 +1 @@ +"""WebSocket adapter for the Optimization Unit domain.""" diff --git a/core/edge_mining/adapters/domain/optimization_unit/websocket/handlers.py b/core/edge_mining/adapters/domain/optimization_unit/websocket/handlers.py new file mode 100644 index 0000000..b10211b --- /dev/null +++ b/core/edge_mining/adapters/domain/optimization_unit/websocket/handlers.py @@ -0,0 +1,38 @@ +"""WebSocket event handler for the Optimization Unit domain.""" + +from typing import Any, List + +from edge_mining.adapters.domain.optimization_unit.websocket.schemas import RuleEngagedSchema +from edge_mining.adapters.infrastructure.websocket.utils import ( + WebSocketEventHandler, + WebSocketEventRegistration, +) +from edge_mining.domain.common import DomainEvent +from edge_mining.domain.optimization_unit.events import RuleEngagedEvent + + +class OptimizationUnitWebSocketHandler(WebSocketEventHandler): + """Serializes Optimization Unit domain events for WebSocket broadcasting.""" + + @property + def registrations(self) -> List[WebSocketEventRegistration]: + return [ + WebSocketEventRegistration( + event_type=RuleEngagedEvent, + topic="rule.engaged", + serialize=self._serialize_rule_engaged, + ), + ] + + def _serialize_rule_engaged(self, event: DomainEvent) -> dict[str, Any]: + assert isinstance(event, RuleEngagedEvent) + payload = RuleEngagedSchema( + optimization_unit_id=str(event.optimization_unit_id) if event.optimization_unit_id else None, + optimization_unit_name=event.optimization_unit_name, + policy_id=str(event.policy_id) if event.policy_id else None, + policy_name=event.policy_name, + miner_id=str(event.miner_id) if event.miner_id else None, + decision=event.decision.value if event.decision else None, + miner_status=event.miner_status, + ) + return payload.model_dump(mode="json") diff --git a/core/edge_mining/adapters/domain/optimization_unit/websocket/schemas.py b/core/edge_mining/adapters/domain/optimization_unit/websocket/schemas.py new file mode 100644 index 0000000..53a65dc --- /dev/null +++ b/core/edge_mining/adapters/domain/optimization_unit/websocket/schemas.py @@ -0,0 +1,17 @@ +"""WebSocket event schemas for the Optimization Unit domain.""" + +from typing import Optional + +from pydantic import BaseModel, Field + + +class RuleEngagedSchema(BaseModel): + """WebSocket schema for RuleEngagedEvent.""" + + optimization_unit_id: Optional[str] = Field(None, description="ID of the optimization unit") + optimization_unit_name: str = Field(default="", description="Name of the optimization unit") + policy_id: Optional[str] = Field(None, description="ID of the policy") + policy_name: str = Field(default="", description="Name of the policy") + miner_id: Optional[str] = Field(None, description="ID of the miner") + decision: Optional[str] = Field(None, description="Mining decision (start_mining/stop_mining/maintain_state)") + miner_status: str = Field(default="", description="Current miner status") diff --git a/core/edge_mining/adapters/domain/performance/__init__.py b/core/edge_mining/adapters/domain/performance/__init__.py new file mode 100644 index 0000000..e884e6c --- /dev/null +++ b/core/edge_mining/adapters/domain/performance/__init__.py @@ -0,0 +1 @@ +"""Adapters for the Performance domain.""" diff --git a/core/edge_mining/adapters/domain/performance/cli/__init__.py b/core/edge_mining/adapters/domain/performance/cli/__init__.py new file mode 100644 index 0000000..28613ca --- /dev/null +++ b/core/edge_mining/adapters/domain/performance/cli/__init__.py @@ -0,0 +1 @@ +"""CLI commands for the mining performance tracker domain.""" diff --git a/core/edge_mining/adapters/domain/performance/cli/commands.py b/core/edge_mining/adapters/domain/performance/cli/commands.py new file mode 100644 index 0000000..7f0f48a --- /dev/null +++ b/core/edge_mining/adapters/domain/performance/cli/commands.py @@ -0,0 +1,475 @@ +"""CLI commands for the mining performance tracker domain.""" + +from typing import List, Optional + +import click + +from edge_mining.adapters.infrastructure.cli.utils import print_configuration +from edge_mining.adapters.utils import run_async_func +from edge_mining.application.interfaces import ( + AdapterServiceInterface, + ConfigurationServiceInterface, +) +from edge_mining.domain.common import EntityId +from edge_mining.domain.performance.common import MiningPerformanceTrackerAdapter +from edge_mining.domain.performance.entities import MiningPerformanceTracker +from edge_mining.domain.performance.exceptions import ( + MiningPoolAuthError, + MiningPoolRateLimitedError, + MiningPoolResponseError, + MiningPoolUnreachableError, +) +from edge_mining.shared.adapter_configs.performance import ( + MiningPerformanceTrackerBraiinsPoolConfig, + MiningPerformanceTrackerDummyConfig, + MiningPerformanceTrackerOceanConfig, +) +from edge_mining.shared.interfaces.config import MiningPerformanceTrackerConfig +from edge_mining.shared.logging.port import LoggerPort + + +def select_mining_performance_tracker_adapter() -> Optional[MiningPerformanceTrackerAdapter]: + """Prompt the user to select a tracker adapter type.""" + click.echo("Select Mining Performance Tracker Adapter:") + for idx, adapter in enumerate(MiningPerformanceTrackerAdapter): + click.echo(f"{idx}. {adapter.name}") + + click.echo("") + choice: str = click.prompt("Choose a Tracker", type=str) + choice = choice.strip().lower() + + if not choice.isdigit() or int(choice) < 0 or int(choice) >= len(MiningPerformanceTrackerAdapter): + click.echo(click.style("Invalid index. Aborting selection.", fg="red")) + return None + + values = [a.value for a in MiningPerformanceTrackerAdapter] + return MiningPerformanceTrackerAdapter(values[int(choice)]) + + +def handle_tracker_dummy_config() -> MiningPerformanceTrackerConfig: + """Prompt the dummy tracker message.""" + message: str = click.prompt( + "Dummy message", + type=str, + default="This is a dummy performance tracker", + ) + return MiningPerformanceTrackerDummyConfig(message=message) + + +def handle_tracker_ocean_config() -> MiningPerformanceTrackerConfig: + """Prompt Ocean tracker configuration fields.""" + bitcoin_address: str = click.prompt("Bitcoin payout address", type=str) + api_base_url: str = click.prompt( + "Ocean API base URL", + type=str, + default="https://api.ocean.xyz", + ) + request_timeout_seconds: int = click.prompt( + "Request timeout (seconds)", + type=int, + default=10, + ) + return MiningPerformanceTrackerOceanConfig( + bitcoin_address=bitcoin_address, + api_base_url=api_base_url, + request_timeout_seconds=request_timeout_seconds, + ) + + +def handle_tracker_braiins_config() -> MiningPerformanceTrackerConfig: + """Prompt Braiins Pool tracker configuration fields.""" + api_token: str = click.prompt("Braiins Pool API token", type=str, hide_input=True) + api_base_url: str = click.prompt( + "Braiins Pool API base URL", + type=str, + default="https://pool.braiins.com", + ) + request_timeout_seconds: int = click.prompt( + "Request timeout (seconds)", + type=int, + default=10, + ) + return MiningPerformanceTrackerBraiinsPoolConfig( + api_token=api_token, + api_base_url=api_base_url, + request_timeout_seconds=request_timeout_seconds, + ) + + +_TRACKER_CONFIG_HANDLERS = { + MiningPerformanceTrackerAdapter.DUMMY: handle_tracker_dummy_config, + MiningPerformanceTrackerAdapter.OCEAN: handle_tracker_ocean_config, + MiningPerformanceTrackerAdapter.BRAIINS_POOL: handle_tracker_braiins_config, +} + + +def handle_tracker_configuration( + adapter_type: MiningPerformanceTrackerAdapter, +) -> Optional[MiningPerformanceTrackerConfig]: + """Dispatch to the matching configuration handler.""" + handler = _TRACKER_CONFIG_HANDLERS.get(adapter_type) + if handler is None: + click.echo(click.style("Unsupported tracker type selected. Aborting.", fg="red")) + return None + return handler() + + +def print_tracker_config(tracker: MiningPerformanceTracker) -> None: + """Print the configuration of a tracker.""" + configuration_class = tracker.config.__class__.__name__ if tracker.config else "---" + click.echo("| Configuration: " + click.style(f"{configuration_class}", fg="cyan")) + if tracker.config: + print_configuration(tracker.config.to_dict()) + + +def print_tracker_details(tracker: MiningPerformanceTracker) -> None: + """Print the details of a tracker.""" + click.echo("") + click.echo("| Name: " + click.style(tracker.name, fg="blue")) + click.echo("| ID: " + click.style(str(tracker.id), fg="yellow")) + click.echo("| Adapter: " + click.style(tracker.adapter_type.name, fg="green")) + external_service_id = str(tracker.external_service_id) if tracker.external_service_id else "None" + click.echo("| External service: " + click.style(external_service_id, fg="magenta")) + print_tracker_config(tracker) + click.echo("") + + +def handle_add_mining_performance_tracker( + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> Optional[MiningPerformanceTracker]: + """Menu flow to add a new mining performance tracker.""" + click.echo(click.style("\n--- Add Mining Performance Tracker ---", fg="yellow")) + name: str = click.prompt("Name of the tracker", type=str) + adapter_type: Optional[MiningPerformanceTrackerAdapter] = select_mining_performance_tracker_adapter() + if adapter_type is None: + click.echo(click.style("Invalid tracker type selected. Aborting.", fg="red")) + return None + + config: Optional[MiningPerformanceTrackerConfig] = handle_tracker_configuration(adapter_type) + if config is None: + click.echo(click.style("Invalid configuration. Aborting.", fg="red")) + return None + + added: Optional[MiningPerformanceTracker] = None + try: + added = run_async_func( + configuration_service.add_mining_performance_tracker( + name=name, + adapter_type=adapter_type, + config=config, + external_service_id=None, + ) + ) + click.echo( + click.style( + f"Mining performance tracker '{added.name}' (ID: {added.id}) successfully added.", + fg="green", + ) + ) + except Exception as e: + logger.error(f"Error adding mining performance tracker: {e}") + click.echo(click.style(f"Error adding mining performance tracker: {e}", fg="red"), err=True) + added = None + click.pause("Press any key to return to the menu...") + return added + + +def handle_list_mining_performance_trackers( + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> None: + """List all configured trackers.""" + click.echo(click.style("\n--- Configured Mining Performance Trackers ---", fg="yellow")) + trackers: List[MiningPerformanceTracker] = configuration_service.list_mining_performance_trackers() + if not trackers: + click.echo(click.style("No mining performance trackers configured.", fg="yellow")) + else: + for t in trackers: + click.echo( + "-> " + + "Name: " + + click.style(f"{t.name}, ", fg="blue") + + "ID: " + + click.style(f"{t.id}, ", fg="yellow") + + "Type: " + + click.style(f"{t.adapter_type.name}", fg="green") + ) + click.echo("") + click.pause("Press any key to return to the menu...") + + +def select_mining_performance_tracker( + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, + default_id: Optional[EntityId] = None, +) -> Optional[MiningPerformanceTracker]: + """Prompt the user to pick one tracker from the configured list.""" + trackers: List[MiningPerformanceTracker] = configuration_service.list_mining_performance_trackers() + + click.echo(click.style("\n--- Select Mining Performance Tracker ---", fg="yellow")) + if not trackers: + click.echo(click.style("No mining performance trackers configured.", fg="yellow")) + return None + + default_idx = "" + for idx, t in enumerate(trackers): + click.echo( + f"{idx}. " + + "Name: " + + click.style(f"{t.name}, ", fg="blue") + + "ID: " + + click.style(f"{t.id}, ", fg="yellow") + + "Type: " + + click.style(f"{t.adapter_type.name}", fg="green") + ) + if default_id and t.id == default_id: + default_idx = str(idx) + + click.echo("\nb. Back to menu\n") + raw: str = click.prompt("Choose a Tracker index", type=str, default=default_idx) + raw = raw.strip().lower() + if raw == "b": + return None + + if not raw.isdigit() or int(raw) < 0 or int(raw) >= len(trackers): + click.echo(click.style("Invalid index. Aborting selection.", fg="red")) + return None + + return trackers[int(raw)] + + +def update_single_mining_performance_tracker( + tracker: MiningPerformanceTracker, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> Optional[MiningPerformanceTracker]: + """Update a single tracker.""" + click.echo(click.style("\n--- Update Mining Performance Tracker ---", fg="yellow")) + new_name: str = click.prompt("New name of the tracker", type=str, default=tracker.name) + + change_config: bool = click.confirm("Change configuration", default=True, prompt_suffix="") + new_config: Optional[MiningPerformanceTrackerConfig] = tracker.config + if change_config: + new_config = handle_tracker_configuration(tracker.adapter_type) + if new_config is None: + click.echo(click.style("Invalid configuration. Aborting.", fg="red")) + return None + + if new_config is None: + click.echo(click.style("Tracker configuration is required. Aborting.", fg="red")) + return None + + updated: Optional[MiningPerformanceTracker] = None + try: + updated = run_async_func( + configuration_service.update_mining_performance_tracker( + tracker_id=tracker.id, + name=new_name, + config=new_config, + external_service_id=tracker.external_service_id, + ) + ) + click.echo( + click.style( + f"Mining performance tracker '{updated.name}' (ID: {updated.id}) successfully updated.", + fg="green", + ) + ) + except Exception as e: + logger.error(f"Error updating mining performance tracker: {e}") + click.echo(click.style(f"Error updating mining performance tracker: {e}", fg="red"), err=True) + updated = None + finally: + click.pause("Press any key to return to the menu...") + return updated + + +def delete_single_mining_performance_tracker( + tracker: MiningPerformanceTracker, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> bool: + """Delete a single tracker after confirmation.""" + confirm: bool = click.confirm( + f"Are you sure you want to delete the tracker '{tracker.name}' (ID: {tracker.id})?", + default=False, + prompt_suffix="", + ) + if not confirm: + click.echo(click.style("Deletion cancelled.", fg="yellow")) + return False + + try: + removed = run_async_func(configuration_service.remove_mining_performance_tracker(tracker_id=tracker.id)) + logger.info(f"Mining performance tracker '{removed.name}' (ID: {removed.id}) successfully removed.") + click.echo( + click.style( + f"Mining performance tracker '{removed.name}' (ID: {removed.id}) successfully removed.", + fg="green", + ) + ) + return True + except Exception as e: + logger.error(f"Error deleting mining performance tracker: {e}") + click.echo(click.style(f"Error deleting mining performance tracker: {e}", fg="red"), err=True) + return False + + +def check_single_mining_performance_tracker( + tracker: MiningPerformanceTracker, + adapter_service: AdapterServiceInterface, + logger: LoggerPort, +) -> bool: + """Test reachability of a tracker by calling get_payout_schedule().""" + click.echo(click.style("\n--- Test Mining Performance Tracker ---", fg="yellow")) + try: + port = run_async_func(adapter_service.get_mining_performance_tracker(tracker.id)) + if port is None: + click.echo(click.style("Tracker adapter not available. Aborting test.", fg="red")) + return False + + schedule = run_async_func(port.get_payout_schedule()) + if schedule is None: + click.echo(click.style("Tracker returned no payout schedule.", fg="yellow")) + else: + click.echo( + click.style( + f"Tracker reachable. Payout frequency: {schedule.frequency.value}, threshold: {schedule.threshold}", + fg="green", + ) + ) + return True + except MiningPoolRateLimitedError as e: + hint = f" (retry after {int(e.retry_after)}s)" if e.retry_after else "" + click.echo(click.style(f"Pool rate-limited{hint}: {e}", fg="red"), err=True) + except MiningPoolAuthError as e: + click.echo(click.style(f"Authentication failed: {e}", fg="red"), err=True) + except MiningPoolUnreachableError as e: + click.echo(click.style(f"Pool unreachable: {e}", fg="red"), err=True) + except MiningPoolResponseError as e: + click.echo(click.style(f"Pool returned invalid response: {e}", fg="red"), err=True) + except Exception as e: + logger.error(f"Error testing mining performance tracker: {e}") + click.echo(click.style(f"Error testing mining performance tracker: {e}", fg="red"), err=True) + return False + + +def manage_single_mining_performance_tracker_menu( + tracker: MiningPerformanceTracker, + configuration_service: ConfigurationServiceInterface, + adapter_service: Optional[AdapterServiceInterface], + logger: LoggerPort, +) -> str: + """Menu for managing a single mining performance tracker.""" + while True: + click.echo("\n" + click.style("--- MANAGE MINING PERFORMANCE TRACKER ---", fg="blue", bold=True)) + print_tracker_details(tracker) + click.echo("1. Update Tracker") + click.echo("2. Delete Tracker") + if adapter_service is not None: + click.echo("3. Test Tracker") + click.echo("") + click.echo("b. Back to tracker menu") + click.echo("q. Close application") + click.echo("-----------------") + + choice: str = click.prompt("Choose an option", type=str) + choice = choice.strip().lower() + click.clear() + + if choice == "1": + updated_tracker = update_single_mining_performance_tracker( + tracker=tracker, + configuration_service=configuration_service, + logger=logger, + ) + tracker = updated_tracker or tracker + continue + if choice == "2": + deleted = delete_single_mining_performance_tracker( + tracker=tracker, + configuration_service=configuration_service, + logger=logger, + ) + if deleted: + return "b" + elif choice == "3" and adapter_service is not None: + check_single_mining_performance_tracker( + tracker=tracker, + adapter_service=adapter_service, + logger=logger, + ) + click.pause("Press any key to return to the menu...") + elif choice == "b": + break + elif choice == "q": + break + else: + click.echo(click.style("Invalid choice. Try again.", fg="red")) + click.pause("Press any key to return to the menu...") + + return choice + + +def handle_manage_mining_performance_tracker( + configuration_service: ConfigurationServiceInterface, + adapter_service: Optional[AdapterServiceInterface], + logger: LoggerPort, +) -> str: + """Entry point to manage an existing tracker (select → manage menu).""" + selected = select_mining_performance_tracker(configuration_service, logger) + if selected is None: + click.echo(click.style("No mining performance tracker selected. Aborting.", fg="red")) + return "b" + + return manage_single_mining_performance_tracker_menu( + tracker=selected, + configuration_service=configuration_service, + adapter_service=adapter_service, + logger=logger, + ) + + +def mining_performance_tracker_menu( + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, + adapter_service: Optional[AdapterServiceInterface] = None, +) -> str: + """Menu for managing Mining Performance Trackers.""" + while True: + click.echo("\n" + click.style("--- MINING PERFORMANCE TRACKER MENU ---", fg="blue", bold=True)) + click.echo("1. Add Tracker") + click.echo("2. List Trackers") + click.echo("3. Manage Tracker") + click.echo("") + click.echo("b. Back to main menu") + click.echo("q. Close application") + click.echo("-----------------") + + choice: str = click.prompt("Choose an option", type=str) + choice = choice.strip().lower() + click.clear() + + if choice == "1": + handle_add_mining_performance_tracker(configuration_service, logger) + elif choice == "2": + handle_list_mining_performance_trackers(configuration_service, logger) + elif choice == "3": + sub_choice = handle_manage_mining_performance_tracker( + configuration_service=configuration_service, + adapter_service=adapter_service, + logger=logger, + ) + if sub_choice == "q": + choice = "q" + break + elif choice == "b": + break + elif choice == "q": + break + else: + click.echo(click.style("Invalid choice. Try again.", fg="red")) + click.pause("Press any key to return to the menu...") + + return choice diff --git a/core/edge_mining/adapters/domain/performance/fast_api/__init__.py b/core/edge_mining/adapters/domain/performance/fast_api/__init__.py new file mode 100644 index 0000000..ce93676 --- /dev/null +++ b/core/edge_mining/adapters/domain/performance/fast_api/__init__.py @@ -0,0 +1 @@ +"""FastAPI adapters for the Mining Performance domain.""" diff --git a/core/edge_mining/adapters/domain/performance/fast_api/router.py b/core/edge_mining/adapters/domain/performance/fast_api/router.py new file mode 100644 index 0000000..609ef80 --- /dev/null +++ b/core/edge_mining/adapters/domain/performance/fast_api/router.py @@ -0,0 +1,391 @@ +"""API Router for the mining performance tracker domain.""" + +import uuid +from typing import Annotated, Any, Dict, List, Optional, cast + +from fastapi import APIRouter, Depends, HTTPException, Query + +from edge_mining.adapters.domain.performance.schemas import ( + MINING_PERFORMANCE_TRACKER_CONFIG_SCHEMA_MAP, + MiningPerformanceTrackerCreateSchema, + MiningPerformanceTrackerSchema, + MiningPerformanceTrackerUpdateSchema, + MiningRewardSchema, + PayoutScheduleSchema, + PoolStatsSchema, + PoolWorkerStatsSchema, +) +from edge_mining.adapters.infrastructure.api.setup import ( + get_adapter_service, + get_config_service, +) +from edge_mining.application.interfaces import ( + AdapterServiceInterface, + ConfigurationServiceInterface, +) +from edge_mining.domain.common import EntityId +from edge_mining.domain.performance.common import MiningPerformanceTrackerAdapter +from edge_mining.domain.performance.entities import MiningPerformanceTracker +from edge_mining.domain.performance.exceptions import ( + MiningPerformanceTrackerAlreadyExistsError, + MiningPerformanceTrackerConfigurationError, + MiningPerformanceTrackerNotFoundError, + MiningPoolAuthError, + MiningPoolRateLimitedError, + MiningPoolResponseError, + MiningPoolUnreachableError, +) +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.interfaces.config import ( + Configuration, + MiningPerformanceTrackerConfig, +) + +router = APIRouter() + + +@router.get("/mining-performance-trackers", response_model=List[MiningPerformanceTrackerSchema]) +async def get_mining_performance_trackers_list( + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> List[MiningPerformanceTrackerSchema]: + """Get a list of all configured mining performance trackers.""" + try: + trackers = config_service.list_mining_performance_trackers() + return [MiningPerformanceTrackerSchema.from_model(t) for t in trackers] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get( + "/mining-performance-trackers/types", + response_model=List[MiningPerformanceTrackerAdapter], +) +async def get_mining_performance_tracker_types() -> List[MiningPerformanceTrackerAdapter]: + """Get a list of available mining performance tracker types.""" + try: + return [MiningPerformanceTrackerAdapter(a.value) for a in MiningPerformanceTrackerAdapter] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get( + "/mining-performance-trackers/types/{adapter_type}/config-schema", + response_model=Dict[str, Any], +) +async def get_mining_performance_tracker_config_schema( + adapter_type: MiningPerformanceTrackerAdapter, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> Dict[str, Any]: + """Get the configuration JSON schema for a specific tracker type.""" + try: + try: + tracker_adapter = MiningPerformanceTrackerAdapter(adapter_type) + except ValueError as e: + raise ValueError(f"Invalid mining performance tracker adapter type: {adapter_type}") from e + + config_type: Optional[type[MiningPerformanceTrackerConfig]] = ( + config_service.get_mining_performance_tracker_config_by_type(tracker_adapter) + ) + if config_type is None: + raise MiningPerformanceTrackerConfigurationError( + f"No configuration class found for adapter type {adapter_type}" + ) + + schema_cls = MINING_PERFORMANCE_TRACKER_CONFIG_SCHEMA_MAP.get(config_type, None) + if schema_cls is None: + raise MiningPerformanceTrackerConfigurationError(f"No schema found for configuration class {config_type}") + + return schema_cls.model_json_schema() + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get( + "/mining-performance-trackers/types/{adapter_type}/external-services", + response_model=Optional[ExternalServiceAdapter], +) +async def get_mining_performance_tracker_external_service_types( + adapter_type: MiningPerformanceTrackerAdapter, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> Optional[ExternalServiceAdapter]: + """Get the compatible external service adapter for a specific tracker type, if any.""" + try: + return config_service.get_mining_performance_tracker_external_service_adapter(adapter_type) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get( + "/mining-performance-trackers/{tracker_id}", + response_model=MiningPerformanceTrackerSchema, +) +async def get_mining_performance_tracker_details( + tracker_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MiningPerformanceTrackerSchema: + """Get details for a specific mining performance tracker.""" + try: + tracker: Optional[MiningPerformanceTracker] = config_service.get_mining_performance_tracker(tracker_id) + if tracker is None: + raise MiningPerformanceTrackerNotFoundError(f"Mining performance tracker {tracker_id} not found") + return MiningPerformanceTrackerSchema.from_model(tracker) + except MiningPerformanceTrackerNotFoundError as e: + raise HTTPException(status_code=404, detail="Mining performance tracker not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/mining-performance-trackers", response_model=MiningPerformanceTrackerSchema) +async def add_mining_performance_tracker( + tracker_schema: MiningPerformanceTrackerCreateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MiningPerformanceTrackerSchema: + """Add a new mining performance tracker.""" + try: + tracker_to_add: MiningPerformanceTracker = tracker_schema.to_model() + + if tracker_to_add.config is None: + raise MiningPerformanceTrackerConfigurationError("Mining performance tracker configuration should be set") + + new_tracker = await config_service.add_mining_performance_tracker( + name=tracker_to_add.name, + adapter_type=tracker_to_add.adapter_type, + config=tracker_to_add.config, + external_service_id=tracker_to_add.external_service_id, + ) + return MiningPerformanceTrackerSchema.from_model(new_tracker) + except MiningPerformanceTrackerAlreadyExistsError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except MiningPerformanceTrackerConfigurationError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.put( + "/mining-performance-trackers/{tracker_id}", + response_model=MiningPerformanceTrackerSchema, +) +async def update_mining_performance_tracker( + tracker_id: EntityId, + tracker_update: MiningPerformanceTrackerUpdateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MiningPerformanceTrackerSchema: + """Update a mining performance tracker's details.""" + try: + tracker = config_service.get_mining_performance_tracker(tracker_id) + if tracker is None: + raise MiningPerformanceTrackerNotFoundError(f"Mining performance tracker {tracker_id} not found") + + configuration: Optional[Configuration] = None + if tracker_update.config: + config_cls = config_service.get_mining_performance_tracker_config_by_type(tracker.adapter_type) + if config_cls is None: + raise MiningPerformanceTrackerConfigurationError( + f"No configuration class found for adapter type {tracker.adapter_type}" + ) + configuration = config_cls.from_dict(tracker_update.config) + + external_service_id: Optional[EntityId] = None + if tracker_update.external_service_id: + external_service_id = EntityId(uuid.UUID(tracker_update.external_service_id)) + + updated_tracker = await config_service.update_mining_performance_tracker( + tracker_id=tracker.id, + name=tracker_update.name or "", + config=cast(MiningPerformanceTrackerConfig, configuration), + external_service_id=external_service_id, + ) + return MiningPerformanceTrackerSchema.from_model(updated_tracker) + except MiningPerformanceTrackerNotFoundError as e: + raise HTTPException(status_code=404, detail="Mining performance tracker not found") from e + except MiningPerformanceTrackerConfigurationError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.delete( + "/mining-performance-trackers/{tracker_id}", + response_model=MiningPerformanceTrackerSchema, +) +async def remove_mining_performance_tracker( + tracker_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MiningPerformanceTrackerSchema: + """Remove a mining performance tracker.""" + try: + deleted = await config_service.remove_mining_performance_tracker(tracker_id) + return MiningPerformanceTrackerSchema.from_model(deleted) + except MiningPerformanceTrackerNotFoundError as e: + raise HTTPException(status_code=404, detail="Mining performance tracker not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post( + "/mining-performance-trackers/{tracker_id}/test", + response_model=Dict[str, str], +) +async def test_mining_performance_tracker( + tracker_id: EntityId, + adapter_service: Annotated[AdapterServiceInterface, Depends(get_adapter_service)], +) -> Dict[str, str]: + """Test connectivity to a mining performance tracker by fetching payout schedule.""" + try: + tracker_port = await adapter_service.get_mining_performance_tracker(tracker_id) + if tracker_port is None: + raise MiningPerformanceTrackerNotFoundError(f"Mining performance tracker {tracker_id} not found") + + await tracker_port.get_payout_schedule() + return {"status": "success", "message": "Mining performance tracker reachable"} + except MiningPerformanceTrackerNotFoundError as e: + raise HTTPException(status_code=404, detail="Mining performance tracker not found") from e + except MiningPoolRateLimitedError as e: + headers = {"Retry-After": str(int(e.retry_after))} if e.retry_after else None + raise HTTPException(status_code=429, detail=f"Pool rate-limited: {str(e)}", headers=headers) from e + except MiningPoolAuthError as e: + raise HTTPException(status_code=401, detail=f"Pool authentication failed: {str(e)}") from e + except MiningPoolUnreachableError as e: + raise HTTPException(status_code=503, detail=f"Pool unreachable: {str(e)}") from e + except MiningPoolResponseError as e: + raise HTTPException(status_code=502, detail=f"Pool returned invalid response: {str(e)}") from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to test tracker: {str(e)}") from e + + +@router.get( + "/mining-performance-trackers/{tracker_id}/stats", + response_model=PoolStatsSchema, +) +async def get_mining_performance_tracker_stats( + tracker_id: EntityId, + adapter_service: Annotated[AdapterServiceInterface, Depends(get_adapter_service)], +) -> PoolStatsSchema: + """Get live pool statistics for a mining performance tracker.""" + try: + tracker_port = await adapter_service.get_mining_performance_tracker(tracker_id) + if tracker_port is None: + raise MiningPerformanceTrackerNotFoundError(f"Mining performance tracker {tracker_id} not found") + + stats = await tracker_port.get_pool_stats() + if stats is None: + raise HTTPException(status_code=502, detail="Pool returned no statistics") + + return PoolStatsSchema.from_model(stats) + except HTTPException: + raise + except MiningPerformanceTrackerNotFoundError as e: + raise HTTPException(status_code=404, detail="Mining performance tracker not found") from e + except MiningPoolRateLimitedError as e: + headers = {"Retry-After": str(int(e.retry_after))} if e.retry_after else None + raise HTTPException(status_code=429, detail=f"Pool rate-limited: {str(e)}", headers=headers) from e + except MiningPoolAuthError as e: + raise HTTPException(status_code=401, detail=f"Pool authentication failed: {str(e)}") from e + except MiningPoolUnreachableError as e: + raise HTTPException(status_code=503, detail=f"Pool unreachable: {str(e)}") from e + except MiningPoolResponseError as e: + raise HTTPException(status_code=502, detail=f"Pool returned invalid response: {str(e)}") from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch stats: {str(e)}") from e + + +@router.get( + "/mining-performance-trackers/{tracker_id}/workers", + response_model=List[PoolWorkerStatsSchema], +) +async def get_mining_performance_tracker_workers( + tracker_id: EntityId, + adapter_service: Annotated[AdapterServiceInterface, Depends(get_adapter_service)], +) -> List[PoolWorkerStatsSchema]: + """Get live per-worker statistics for a mining performance tracker.""" + try: + tracker_port = await adapter_service.get_mining_performance_tracker(tracker_id) + if tracker_port is None: + raise MiningPerformanceTrackerNotFoundError(f"Mining performance tracker {tracker_id} not found") + + workers = await tracker_port.get_worker_stats([]) + return [PoolWorkerStatsSchema.from_model(w) for w in workers] + except MiningPerformanceTrackerNotFoundError as e: + raise HTTPException(status_code=404, detail="Mining performance tracker not found") from e + except MiningPoolRateLimitedError as e: + headers = {"Retry-After": str(int(e.retry_after))} if e.retry_after else None + raise HTTPException(status_code=429, detail=f"Pool rate-limited: {str(e)}", headers=headers) from e + except MiningPoolAuthError as e: + raise HTTPException(status_code=401, detail=f"Pool authentication failed: {str(e)}") from e + except MiningPoolUnreachableError as e: + raise HTTPException(status_code=503, detail=f"Pool unreachable: {str(e)}") from e + except MiningPoolResponseError as e: + raise HTTPException(status_code=502, detail=f"Pool returned invalid response: {str(e)}") from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch workers: {str(e)}") from e + + +@router.get( + "/mining-performance-trackers/{tracker_id}/rewards", + response_model=List[MiningRewardSchema], +) +async def get_mining_performance_tracker_rewards( + tracker_id: EntityId, + adapter_service: Annotated[AdapterServiceInterface, Depends(get_adapter_service)], + limit: int = Query(default=10, ge=1, le=500, description="Maximum number of rewards to return"), +) -> List[MiningRewardSchema]: + """Get recent rewards for a mining performance tracker (live passthrough).""" + try: + tracker_port = await adapter_service.get_mining_performance_tracker(tracker_id) + if tracker_port is None: + raise MiningPerformanceTrackerNotFoundError(f"Mining performance tracker {tracker_id} not found") + + rewards = await tracker_port.get_recent_rewards(limit=limit) + return [MiningRewardSchema.from_model(r) for r in rewards] + except MiningPerformanceTrackerNotFoundError as e: + raise HTTPException(status_code=404, detail="Mining performance tracker not found") from e + except MiningPoolRateLimitedError as e: + headers = {"Retry-After": str(int(e.retry_after))} if e.retry_after else None + raise HTTPException(status_code=429, detail=f"Pool rate-limited: {str(e)}", headers=headers) from e + except MiningPoolAuthError as e: + raise HTTPException(status_code=401, detail=f"Pool authentication failed: {str(e)}") from e + except MiningPoolUnreachableError as e: + raise HTTPException(status_code=503, detail=f"Pool unreachable: {str(e)}") from e + except MiningPoolResponseError as e: + raise HTTPException(status_code=502, detail=f"Pool returned invalid response: {str(e)}") from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch rewards: {str(e)}") from e + + +@router.get( + "/mining-performance-trackers/{tracker_id}/payout-schedule", + response_model=PayoutScheduleSchema, +) +async def get_mining_performance_tracker_payout_schedule( + tracker_id: EntityId, + adapter_service: Annotated[AdapterServiceInterface, Depends(get_adapter_service)], +) -> PayoutScheduleSchema: + """Get the payout schedule for a mining performance tracker.""" + try: + tracker_port = await adapter_service.get_mining_performance_tracker(tracker_id) + if tracker_port is None: + raise MiningPerformanceTrackerNotFoundError(f"Mining performance tracker {tracker_id} not found") + + schedule = await tracker_port.get_payout_schedule() + if schedule is None: + raise HTTPException(status_code=502, detail="Pool returned no payout schedule") + + return PayoutScheduleSchema.from_model(schedule) + except HTTPException: + raise + except MiningPerformanceTrackerNotFoundError as e: + raise HTTPException(status_code=404, detail="Mining performance tracker not found") from e + except MiningPoolRateLimitedError as e: + headers = {"Retry-After": str(int(e.retry_after))} if e.retry_after else None + raise HTTPException(status_code=429, detail=f"Pool rate-limited: {str(e)}", headers=headers) from e + except MiningPoolAuthError as e: + raise HTTPException(status_code=401, detail=f"Pool authentication failed: {str(e)}") from e + except MiningPoolUnreachableError as e: + raise HTTPException(status_code=503, detail=f"Pool unreachable: {str(e)}") from e + except MiningPoolResponseError as e: + raise HTTPException(status_code=502, detail=f"Pool returned invalid response: {str(e)}") from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch payout schedule: {str(e)}") from e diff --git a/core/edge_mining/adapters/domain/performance/repositories.py b/core/edge_mining/adapters/domain/performance/repositories.py new file mode 100644 index 0000000..095c696 --- /dev/null +++ b/core/edge_mining/adapters/domain/performance/repositories.py @@ -0,0 +1,409 @@ +"""Repositories for Performance Tracker Domain.""" + +import copy +import json +import sqlite3 +from typing import Dict, List, Optional + +from sqlalchemy import select + +from edge_mining.adapters.domain.performance.tables import mining_performance_trackers_table +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.adapters.infrastructure.persistence.sqlite import BaseSqliteRepository +from edge_mining.domain.common import EntityId +from edge_mining.domain.exceptions import ConfigurationError +from edge_mining.domain.performance.common import MiningPerformanceTrackerAdapter +from edge_mining.domain.performance.entities import MiningPerformanceTracker +from edge_mining.domain.performance.exceptions import ( + MiningPerformanceTrackerAlreadyExistsError, + MiningPerformanceTrackerConfigurationError, + MiningPerformanceTrackerNotFoundError, +) +from edge_mining.domain.performance.ports import MiningPerformanceTrackerRepository +from edge_mining.shared.adapter_maps.performance import ( + MINING_PERFORMANCE_TRACKER_CONFIG_TYPE_MAP, +) +from edge_mining.shared.interfaces.config import MiningPerformanceTrackerConfig + + +class InMemoryMiningPerformanceTrackerRepository(MiningPerformanceTrackerRepository): + """In-Memory implementation of the MiningPerformanceTrackerRepository.""" + + def __init__( + self, + initial_trackers: Optional[Dict[EntityId, MiningPerformanceTracker]] = None, + ): + self._trackers: Dict[EntityId, MiningPerformanceTracker] = ( + copy.deepcopy(initial_trackers) if initial_trackers else {} + ) + + def add(self, tracker: MiningPerformanceTracker) -> None: + if tracker.id in self._trackers: + raise MiningPerformanceTrackerAlreadyExistsError( + f"Performance Tracker with ID {tracker.id} already exists." + ) + self._trackers[tracker.id] = tracker + + def get_by_id(self, tracker_id: EntityId) -> Optional[MiningPerformanceTracker]: + return copy.deepcopy(self._trackers.get(tracker_id)) + + def get_all(self) -> List[MiningPerformanceTracker]: + return [copy.deepcopy(t) for t in self._trackers.values()] + + def update(self, tracker: MiningPerformanceTracker) -> None: + if tracker.id not in self._trackers: + raise MiningPerformanceTrackerNotFoundError(f"Performance Tracker with ID {tracker.id} not found.") + self._trackers[tracker.id] = copy.deepcopy(tracker) + + def remove(self, tracker_id: EntityId) -> None: + if tracker_id in self._trackers: + del self._trackers[tracker_id] + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[MiningPerformanceTracker]: + return [copy.deepcopy(t) for t in self._trackers.values() if t.external_service_id == external_service_id] + + +class SqliteMiningPerformanceTrackerRepository(MiningPerformanceTrackerRepository): + """SQLite implementation of the MiningPerformanceTrackerRepository.""" + + def __init__(self, db: BaseSqliteRepository): + self._db = db + self.logger = db.logger + self._create_tables() + + def _create_tables(self): + """Create the necessary tables for the Performance Tracker domain if they do not exist.""" + self.logger.debug(f"Ensuring SQLite tables exist for Performance Tracker Repository in {self._db.db_path}...") + sql_statements = [ + """ + CREATE TABLE IF NOT EXISTS mining_performance_trackers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + adapter_type TEXT NOT NULL, + config TEXT, -- JSON object of config + external_service_id TEXT -- Optional ID for external service integration + ); + """ + ] + + conn = self._db.get_connection() + + try: + with conn: + for sql in sql_statements: + conn.execute(sql) + self.logger.debug("Mining Performance Tracker tables checked/created successfully.") + except sqlite3.Error as e: + self.logger.error(f"SQLite error creating tables: {e}") + raise ConfigurationError(f"SQLite error creating tables: {e}") from e + finally: + if conn: + conn.close() + + def _deserialize_config( + self, adapter_type: MiningPerformanceTrackerAdapter, config_json: str + ) -> MiningPerformanceTrackerConfig: + """Deserialize the JSON string into a MiningPerformanceTrackerConfig object.""" + data: dict = json.loads(config_json) + + if adapter_type not in MINING_PERFORMANCE_TRACKER_CONFIG_TYPE_MAP: + raise MiningPerformanceTrackerConfigurationError( + f"Error reading MiningPerformanceTracker configuration. Invalid type '{adapter_type}'" + ) + + config_class: Optional[type[MiningPerformanceTrackerConfig]] = MINING_PERFORMANCE_TRACKER_CONFIG_TYPE_MAP.get( + adapter_type + ) + if not config_class: + raise MiningPerformanceTrackerConfigurationError( + f"Error creating MiningPerformanceTracker configuration. Type '{adapter_type}'" + ) + + config_instance = config_class.from_dict(data) + if not isinstance(config_instance, MiningPerformanceTrackerConfig): + raise MiningPerformanceTrackerConfigurationError( + f"Error creating MiningPerformanceTracker configuration. Type '{adapter_type}'" + ) + return config_instance + + def _row_to_tracker(self, row: sqlite3.Row) -> Optional[MiningPerformanceTracker]: + """Deserialize a row from the database into a MiningPerformanceTracker object.""" + if not row: + return None + try: + adapter_type = MiningPerformanceTrackerAdapter(row["adapter_type"]) + + # Deserialize config from the database row + config = self._deserialize_config(adapter_type, row["config"]) + + return MiningPerformanceTracker( + id=EntityId(row["id"]), + name=row["name"], + adapter_type=adapter_type, + config=config, + external_service_id=(EntityId(row["external_service_id"]) if row["external_service_id"] else None), + ) + except (ValueError, KeyError) as e: + self.logger.error(f"Error deserializing MiningPerformanceTracker from DB row: {row}. Error: {e}") + return None + + def add(self, tracker: MiningPerformanceTracker) -> None: + """Add a new mining performance tracker to the repository.""" + self.logger.debug(f"Adding mining performance tracker {tracker.id} to SQLite repository.") + sql = """ + INSERT INTO mining_performance_trackers (id, name, adapter_type, config, external_service_id) + VALUES (?, ?, ?, ?, ?); + """ + conn = self._db.get_connection() + try: + # Serialize config to JSON for storage + config_json: str = "" + if tracker.config: + config_json = json.dumps(tracker.config.to_dict()) + + with conn: + cursor = conn.cursor() + cursor.execute( + sql, + ( + tracker.id, + tracker.name, + tracker.adapter_type.value, + config_json, + tracker.external_service_id, + ), + ) + except sqlite3.IntegrityError as e: + self.logger.error(f"Integrity error adding mining performance tracker {tracker.id}: {e}") + # Could mean that the ID already exists + raise MiningPerformanceTrackerAlreadyExistsError( + f"Mining performance tracker with ID {tracker.id} already exists or constraint violation: {e}" + ) from e + except sqlite3.Error as e: + self.logger.error(f"SQLite error adding mining performance tracker {tracker.id}: {e}") + raise MiningPerformanceTrackerConfigurationError(f"DB error adding mining performance tracker: {e}") from e + finally: + if conn: + conn.close() + + def get_by_id(self, tracker_id: EntityId) -> Optional[MiningPerformanceTracker]: + """Retrieve a mining performance tracker by its ID.""" + self.logger.debug(f"Retrieving mining performance tracker {tracker_id} from SQLite repository.") + sql = "SELECT * FROM mining_performance_trackers WHERE id = ?;" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (tracker_id,)) + row = cursor.fetchone() + return self._row_to_tracker(row) + except sqlite3.Error as e: + self.logger.error(f"SQLite error retrieving mining performance tracker {tracker_id}: {e}") + raise MiningPerformanceTrackerNotFoundError(f"DB error retrieving mining performance tracker: {e}") from e + finally: + if conn: + conn.close() + + def get_all(self) -> List[MiningPerformanceTracker]: + """Retrieve all mining performance trackers from the repository.""" + self.logger.debug("Retrieving all mining performance trackers from SQLite repository.") + sql = "SELECT * FROM mining_performance_trackers;" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql) + rows = cursor.fetchall() + trackers = [] + for row in rows: + tracker = self._row_to_tracker(row) + if tracker: + trackers.append(tracker) + except sqlite3.Error as e: + self.logger.error(f"SQLite error retrieving all mining performance trackers: {e}") + return [] + finally: + if conn: + conn.close() + return trackers + + def update(self, tracker: MiningPerformanceTracker) -> None: + """Update an existing mining performance tracker in the repository.""" + self.logger.debug(f"Updating mining performance tracker {tracker.id} in SQLite repository.") + sql = """ + UPDATE mining_performance_trackers + SET name = ?, adapter_type = ?, config = ?, external_service_id = ? + WHERE id = ?; + """ + conn = self._db.get_connection() + try: + # Serialize config to JSON for storage + config_json: str = "" + if tracker.config: + config_json = json.dumps(tracker.config.to_dict()) + + with conn: + cursor = conn.cursor() + cursor.execute( + sql, + ( + tracker.name, + tracker.adapter_type.value, + config_json, + tracker.external_service_id, + tracker.id, + ), + ) + if cursor.rowcount == 0: + raise MiningPerformanceTrackerNotFoundError( + f"mining performance tracker with ID {tracker.id} not found." + ) + except sqlite3.Error as e: + self.logger.error(f"SQLite error updating mining performance tracker {tracker.id}: {e}") + raise MiningPerformanceTrackerConfigurationError( + f"DB error updating mining performance tracker: {e}" + ) from e + finally: + if conn: + conn.close() + + def remove(self, tracker_id: EntityId) -> None: + """Remove a mining performance tracker from the repository.""" + self.logger.debug(f"Removing mining performance tracker {tracker_id} from SQLite repository.") + sql = "DELETE FROM mining_performance_trackers WHERE id = ?;" + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + cursor.execute(sql, (tracker_id,)) + if cursor.rowcount == 0: + self.logger.warning(f"Attempted to remove non-existent mining performance tracker {tracker_id}.") + # There is no need to raise an exception here, removing a + # non-existent is idempotent. + except sqlite3.Error as e: + self.logger.error(f"SQLite error removing mining performance tracker {tracker_id}: {e}") + raise MiningPerformanceTrackerConfigurationError( + f"DB error removing mining performance tracker: {e}" + ) from e + finally: + if conn: + conn.close() + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[MiningPerformanceTracker]: + """Get all mining performance trackers associated with a specific external service ID.""" + self.logger.debug( + f"Retrieving mining performance trackers for external service {external_service_id} from SQLite repository." + ) + sql = "SELECT * FROM mining_performance_trackers WHERE external_service_id = ?;" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (external_service_id,)) + rows = cursor.fetchall() + trackers = [] + for row in rows: + tracker = self._row_to_tracker(row) + if tracker: + trackers.append(tracker) + return trackers + except sqlite3.Error as e: + self.logger.error( + f"SQLite error retrieving mining performance trackers for external service {external_service_id}: {e}" + ) + return [] + finally: + if conn: + conn.close() + + +# SQLAlchemy implementation + + +class SqlAlchemyMiningPerformanceTrackerRepository(MiningPerformanceTrackerRepository): + """SQLAlchemy implementation of MiningPerformanceTrackerRepository. + + This repository works directly with the imperatively mapped MiningPerformanceTracker domain entity. + The config field is automatically converted between MiningPerformanceTrackerConfig objects and JSON + strings by the custom TypeDecorator and event listener defined in tables.py. + + Args: + db: BaseSQLAlchemyRepository instance for database operations + """ + + def __init__(self, db: BaseSQLAlchemyRepository): + """Initialize repository with database instance. + + Args: + db: BaseSQLAlchemyRepository instance + """ + self._db = db + self.logger = db.logger + + def add(self, tracker: MiningPerformanceTracker) -> None: + """Add a mining performance tracker to the repository.""" + session = self._db.get_session() + try: + session.add(tracker) + session.commit() + finally: + session.close() + + def get_by_id(self, tracker_id: EntityId) -> Optional[MiningPerformanceTracker]: + """Get a mining performance tracker by ID.""" + session = self._db.get_session() + try: + stmt = select(MiningPerformanceTracker).where(mining_performance_trackers_table.c.id == str(tracker_id)) + entity = session.execute(stmt).scalar_one_or_none() + return entity + finally: + session.close() + + def get_all(self) -> List[MiningPerformanceTracker]: + """Get all mining performance trackers.""" + session = self._db.get_session() + try: + stmt = select(MiningPerformanceTracker) + entities = session.execute(stmt).scalars().all() + return list(entities) + finally: + session.close() + + def update(self, tracker: MiningPerformanceTracker) -> None: + """Update a mining performance tracker.""" + session = self._db.get_session() + try: + stmt = select(MiningPerformanceTracker).where(mining_performance_trackers_table.c.id == str(tracker.id)) + existing_entity = session.execute(stmt).scalar_one_or_none() + + if existing_entity: + existing_entity.name = tracker.name + existing_entity.adapter_type = tracker.adapter_type + existing_entity.config = tracker.config + existing_entity.external_service_id = tracker.external_service_id + + session.commit() + finally: + session.close() + + def remove(self, tracker_id: EntityId) -> None: + """Remove a mining performance tracker by ID.""" + session = self._db.get_session() + try: + stmt = select(MiningPerformanceTracker).where(mining_performance_trackers_table.c.id == str(tracker_id)) + entity = session.execute(stmt).scalar_one_or_none() + + if entity: + session.delete(entity) + session.commit() + finally: + session.close() + + def get_by_external_service_id(self, external_service_id: EntityId) -> List[MiningPerformanceTracker]: + """Get mining performance trackers by external service ID.""" + session = self._db.get_session() + try: + stmt = select(MiningPerformanceTracker).where( + mining_performance_trackers_table.c.external_service_id == str(external_service_id) + ) + entities = session.execute(stmt).scalars().all() + return list(entities) + finally: + session.close() diff --git a/core/edge_mining/adapters/domain/performance/schemas.py b/core/edge_mining/adapters/domain/performance/schemas.py new file mode 100644 index 0000000..7decd33 --- /dev/null +++ b/core/edge_mining/adapters/domain/performance/schemas.py @@ -0,0 +1,500 @@ +"""Validation schemas for the mining performance tracker domain.""" + +import uuid +from datetime import datetime +from typing import Dict, List, Optional, Union, cast + +from pydantic import BaseModel, Field, field_serializer, field_validator + +from edge_mining.domain.common import EntityId, Timestamp +from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.domain.performance.common import ( + MiningPerformanceTrackerAdapter, + PayoutFrequency, + Satoshi, +) +from edge_mining.domain.performance.entities import MiningPerformanceTracker +from edge_mining.domain.performance.value_objects import ( + MiningPerformanceSnapshot, + MiningReward, + PayoutSchedule, + PoolStats, + PoolWorkerStats, +) +from edge_mining.shared.adapter_configs.performance import ( + MiningPerformanceTrackerBraiinsPoolConfig, + MiningPerformanceTrackerDummyConfig, + MiningPerformanceTrackerOceanConfig, +) +from edge_mining.shared.adapter_maps.performance import ( + MINING_PERFORMANCE_TRACKER_CONFIG_TYPE_MAP, +) +from edge_mining.shared.interfaces.config import MiningPerformanceTrackerConfig + + +class MiningPerformanceTrackerSchema(BaseModel): + """Schema for MiningPerformanceTracker entity.""" + + id: str = Field(..., description="Unique identifier for the tracker") + name: str = Field(default="", description="Tracker name") + adapter_type: MiningPerformanceTrackerAdapter = Field( + default=MiningPerformanceTrackerAdapter.DUMMY, + description="Type of performance tracker adapter", + ) + config: dict = Field(default={}, description="Tracker configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @field_validator("id") + @classmethod + def validate_id(cls, v: str) -> str: + """Validate that id is a valid UUID string.""" + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("id must be a valid UUID string") from exc + return v + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Strip tracker name.""" + return v.strip() + + @field_validator("adapter_type") + @classmethod + def validate_adapter_type(cls, v: str) -> MiningPerformanceTrackerAdapter: + """Validate that adapter_type is a recognized MiningPerformanceTrackerAdapter.""" + adapter_values = [adapter.value for adapter in MiningPerformanceTrackerAdapter] + if v not in adapter_values: + raise ValueError(f"adapter_type must be one of {adapter_values}") + return MiningPerformanceTrackerAdapter(v) + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate external_service_id is a UUID string when provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("external_service_id must be a valid UUID string") from exc + return v + + @classmethod + def from_model(cls, tracker: MiningPerformanceTracker) -> "MiningPerformanceTrackerSchema": + """Create the schema from a MiningPerformanceTracker entity.""" + return cls( + id=str(tracker.id), + name=tracker.name, + adapter_type=tracker.adapter_type, + config=tracker.config.to_dict() if tracker.config else {}, + external_service_id=str(tracker.external_service_id) if tracker.external_service_id else None, + ) + + @field_serializer("id") + def serialize_id(self, value: str) -> str: + """Serialize id field.""" + return str(value) + + @field_serializer("external_service_id") + def serialize_external_service_id(self, value: Optional[str]) -> Optional[str]: + """Serialize external_service_id field.""" + return str(value) if value is not None else None + + def to_model(self) -> MiningPerformanceTracker: + """Convert to a MiningPerformanceTracker entity.""" + configuration: Optional[MiningPerformanceTrackerConfig] = None + if self.config: + config_class = MINING_PERFORMANCE_TRACKER_CONFIG_TYPE_MAP.get(self.adapter_type, None) + if config_class: + configuration = cast(MiningPerformanceTrackerConfig, config_class.from_dict(self.config)) + + return MiningPerformanceTracker( + id=EntityId(uuid.UUID(self.id)), + name=self.name, + adapter_type=self.adapter_type, + config=configuration, + external_service_id=EntityId(uuid.UUID(self.external_service_id)) if self.external_service_id else None, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + arbitrary_types_allowed = True + json_encoders = { + uuid.UUID: str, + MiningPerformanceTrackerAdapter: lambda v: v.value, + } + + +class MiningPerformanceTrackerCreateSchema(BaseModel): + """Schema for creating a new mining performance tracker.""" + + name: str = Field(default="", description="Tracker name") + adapter_type: MiningPerformanceTrackerAdapter = Field( + default=MiningPerformanceTrackerAdapter.DUMMY, + description="Type of performance tracker adapter", + ) + config: Optional[dict] = Field(default=None, description="Tracker configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Strip tracker name.""" + return v.strip() + + @field_validator("adapter_type") + @classmethod + def validate_adapter_type(cls, v: str) -> MiningPerformanceTrackerAdapter: + """Validate that adapter_type is a recognized MiningPerformanceTrackerAdapter.""" + adapter_values = [adapter.value for adapter in MiningPerformanceTrackerAdapter] + if v not in adapter_values: + raise ValueError(f"adapter_type must be one of {adapter_values}") + return MiningPerformanceTrackerAdapter(v) + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate external_service_id is a UUID string when provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("external_service_id must be a valid UUID string") from exc + return v + + def to_model(self) -> MiningPerformanceTracker: + """Convert to a MiningPerformanceTracker entity (new UUID).""" + configuration: Optional[MiningPerformanceTrackerConfig] = None + if self.config: + config_class = MINING_PERFORMANCE_TRACKER_CONFIG_TYPE_MAP.get(self.adapter_type, None) + if config_class: + configuration = cast(MiningPerformanceTrackerConfig, config_class.from_dict(self.config)) + + return MiningPerformanceTracker( + id=EntityId(uuid.uuid4()), + name=self.name, + adapter_type=self.adapter_type, + config=configuration, + external_service_id=EntityId(uuid.UUID(self.external_service_id)) if self.external_service_id else None, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + json_encoders = { + uuid.UUID: str, + MiningPerformanceTrackerAdapter: lambda v: v.value, + } + + +class MiningPerformanceTrackerUpdateSchema(BaseModel): + """Schema for updating an existing mining performance tracker.""" + + name: str = Field(default="", description="Tracker name") + config: Optional[dict] = Field(default=None, description="Tracker configuration") + external_service_id: Optional[str] = Field(default=None, description="ID of external service") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Strip tracker name.""" + return v.strip() + + @field_validator("external_service_id") + @classmethod + def validate_external_service_id(cls, v: Optional[str]) -> Optional[str]: + """Validate external_service_id is a UUID string when provided.""" + if v is not None: + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("external_service_id must be a valid UUID string") from exc + return v + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + json_encoders = { + uuid.UUID: str, + } + + +class DummyMiningPerformanceTrackerConfigSchema(BaseModel): + """Schema for the dummy performance tracker configuration.""" + + message: str = Field( + default="This is a dummy performance tracker", + description="Informational message for the dummy tracker", + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + +class OceanMiningPerformanceTrackerConfigSchema(BaseModel): + """Schema for the Ocean.xyz performance tracker configuration.""" + + bitcoin_address: str = Field(..., description="Bitcoin payout address registered on Ocean") + api_base_url: str = Field( + default="https://api.ocean.xyz", + description="Base URL for the Ocean public API", + ) + request_timeout_seconds: int = Field(default=10, ge=1, description="HTTP request timeout in seconds") + + @field_validator("bitcoin_address") + @classmethod + def validate_bitcoin_address(cls, v: str) -> str: + """Bitcoin address must not be blank.""" + v = v.strip() + if not v: + raise ValueError("bitcoin_address cannot be empty") + return v + + @field_validator("api_base_url") + @classmethod + def validate_api_base_url(cls, v: str) -> str: + """Base URL must not be blank.""" + v = v.strip() + if not v: + raise ValueError("api_base_url cannot be empty") + return v + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + +class BraiinsPoolMiningPerformanceTrackerConfigSchema(BaseModel): + """Schema for the Braiins Pool performance tracker configuration.""" + + api_token: str = Field(..., description="Access profile token generated in Braiins Pool settings") + api_base_url: str = Field( + default="https://pool.braiins.com", + description="Base URL for the Braiins Pool API", + ) + request_timeout_seconds: int = Field(default=10, ge=1, description="HTTP request timeout in seconds") + + @field_validator("api_token") + @classmethod + def validate_api_token(cls, v: str) -> str: + """API token must not be blank.""" + v = v.strip() + if not v: + raise ValueError("api_token cannot be empty") + return v + + @field_validator("api_base_url") + @classmethod + def validate_api_base_url(cls, v: str) -> str: + """Base URL must not be blank.""" + v = v.strip() + if not v: + raise ValueError("api_base_url cannot be empty") + return v + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + +MINING_PERFORMANCE_TRACKER_CONFIG_SCHEMA_MAP: Dict[ + type[MiningPerformanceTrackerConfig], + Union[ + type[DummyMiningPerformanceTrackerConfigSchema], + type[OceanMiningPerformanceTrackerConfigSchema], + type[BraiinsPoolMiningPerformanceTrackerConfigSchema], + ], +] = { + MiningPerformanceTrackerDummyConfig: DummyMiningPerformanceTrackerConfigSchema, + MiningPerformanceTrackerOceanConfig: OceanMiningPerformanceTrackerConfigSchema, + MiningPerformanceTrackerBraiinsPoolConfig: BraiinsPoolMiningPerformanceTrackerConfigSchema, +} + + +class HashRateSchema(BaseModel): + """Schema representing a hash rate value.""" + + value: float = Field(..., description="Hash rate value") + unit: str = Field(default="TH/s", description="Hash rate unit") + + def to_model(self) -> HashRate: + """Convert schema to HashRate value object.""" + return HashRate(value=self.value, unit=self.unit) + + +class PoolWorkerStatsSchema(BaseModel): + """Schema for a single worker's pool statistics.""" + + worker_name: str + hashrate: Optional[HashRateSchema] = None + last_share_at: Optional[datetime] = None + valid_shares: Optional[int] = None + stale_shares: Optional[int] = None + rejected_shares: Optional[int] = None + + @classmethod + def from_model(cls, worker: PoolWorkerStats) -> "PoolWorkerStatsSchema": + """Build the schema from a PoolWorkerStats value object.""" + return cls( + worker_name=worker.worker_name, + hashrate=HashRateSchema(value=worker.hashrate.value, unit=worker.hashrate.unit) + if worker.hashrate + else None, + last_share_at=worker.last_share_at, + valid_shares=worker.valid_shares, + stale_shares=worker.stale_shares, + rejected_shares=worker.rejected_shares, + ) + + def to_model(self) -> PoolWorkerStats: + """Convert schema to PoolWorkerStats value object.""" + return PoolWorkerStats( + worker_name=self.worker_name, + hashrate=self.hashrate.to_model() if self.hashrate else None, + last_share_at=Timestamp(self.last_share_at) if self.last_share_at else None, + valid_shares=self.valid_shares, + stale_shares=self.stale_shares, + rejected_shares=self.rejected_shares, + ) + + +class PoolStatsSchema(BaseModel): + """Schema for account-level pool statistics.""" + + current_hashrate: Optional[HashRateSchema] = None + average_hashrate_24h: Optional[HashRateSchema] = None + average_hashrate_7d: Optional[HashRateSchema] = None + unpaid_balance: Optional[int] = None + estimated_next_payout: Optional[int] = None + workers: List[PoolWorkerStatsSchema] = Field(default_factory=list) + timestamp: datetime + + @classmethod + def from_model(cls, stats: PoolStats) -> "PoolStatsSchema": + """Build the schema from a PoolStats value object.""" + return cls( + current_hashrate=HashRateSchema(value=stats.current_hashrate.value, unit=stats.current_hashrate.unit) + if stats.current_hashrate + else None, + average_hashrate_24h=HashRateSchema( + value=stats.average_hashrate_24h.value, unit=stats.average_hashrate_24h.unit + ) + if stats.average_hashrate_24h + else None, + average_hashrate_7d=HashRateSchema( + value=stats.average_hashrate_7d.value, unit=stats.average_hashrate_7d.unit + ) + if stats.average_hashrate_7d + else None, + unpaid_balance=int(stats.unpaid_balance) if stats.unpaid_balance is not None else None, + estimated_next_payout=int(stats.estimated_next_payout) if stats.estimated_next_payout is not None else None, + workers=[PoolWorkerStatsSchema.from_model(w) for w in stats.workers], + timestamp=stats.timestamp, + ) + + def to_model(self) -> PoolStats: + """Convert schema to PoolStats value object.""" + return PoolStats( + current_hashrate=self.current_hashrate.to_model() if self.current_hashrate else None, + average_hashrate_24h=self.average_hashrate_24h.to_model() if self.average_hashrate_24h else None, + average_hashrate_7d=self.average_hashrate_7d.to_model() if self.average_hashrate_7d else None, + unpaid_balance=Satoshi(self.unpaid_balance) if self.unpaid_balance is not None else None, + estimated_next_payout=( + Satoshi(self.estimated_next_payout) if self.estimated_next_payout is not None else None + ), + workers=[w.to_model() for w in self.workers], + timestamp=Timestamp(self.timestamp), + ) + + +class MiningRewardSchema(BaseModel): + """Schema for a single mining reward.""" + + amount: int = Field(..., description="Reward amount in satoshi") + timestamp: datetime + + @classmethod + def from_model(cls, reward: MiningReward) -> "MiningRewardSchema": + """Build the schema from a MiningReward value object.""" + return cls(amount=int(reward.amount), timestamp=reward.timestamp) + + def to_model(self) -> MiningReward: + """Convert schema to MiningReward value object.""" + return MiningReward(amount=Satoshi(self.amount), timestamp=Timestamp(self.timestamp)) + + +class PayoutScheduleSchema(BaseModel): + """Schema for a payout schedule.""" + + frequency: PayoutFrequency = PayoutFrequency.UNKNOWN + threshold: Optional[int] = None + next_payout_at: Optional[datetime] = None + + @classmethod + def from_model(cls, schedule: PayoutSchedule) -> "PayoutScheduleSchema": + """Build the schema from a PayoutSchedule value object.""" + return cls( + frequency=schedule.frequency, + threshold=int(schedule.threshold) if schedule.threshold is not None else None, + next_payout_at=schedule.next_payout_at, + ) + + def to_model(self) -> PayoutSchedule: + """Convert schema to PayoutSchedule value object.""" + return PayoutSchedule( + frequency=self.frequency, + threshold=Satoshi(self.threshold) if self.threshold is not None else None, + next_payout_at=Timestamp(self.next_payout_at) if self.next_payout_at else None, + ) + + +class MiningPerformanceSnapshotSchema(BaseModel): + """Schema for the consolidated mining performance snapshot.""" + + current_hashrate: Optional[HashRateSchema] = None + pool_stats: Optional[PoolStatsSchema] = None + payout_schedule: Optional[PayoutScheduleSchema] = None + timestamp: datetime + + @classmethod + def from_model(cls, snapshot: MiningPerformanceSnapshot) -> "MiningPerformanceSnapshotSchema": + """Build the schema from a MiningPerformanceSnapshot value object.""" + return cls( + current_hashrate=HashRateSchema(value=snapshot.current_hashrate.value, unit=snapshot.current_hashrate.unit) + if snapshot.current_hashrate + else None, + pool_stats=PoolStatsSchema.from_model(snapshot.pool_stats) if snapshot.pool_stats else None, + payout_schedule=PayoutScheduleSchema.from_model(snapshot.payout_schedule) + if snapshot.payout_schedule + else None, + timestamp=snapshot.timestamp, + ) + + def to_model(self) -> MiningPerformanceSnapshot: + """Convert schema to MiningPerformanceSnapshot value object.""" + return MiningPerformanceSnapshot( + current_hashrate=self.current_hashrate.to_model() if self.current_hashrate else None, + pool_stats=self.pool_stats.to_model() if self.pool_stats else None, + payout_schedule=self.payout_schedule.to_model() if self.payout_schedule else None, + timestamp=Timestamp(self.timestamp), + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True diff --git a/core/edge_mining/adapters/domain/performance/tables.py b/core/edge_mining/adapters/domain/performance/tables.py new file mode 100644 index 0000000..c6ca37e --- /dev/null +++ b/core/edge_mining/adapters/domain/performance/tables.py @@ -0,0 +1,135 @@ +"""SQLAlchemy ORM mappings for Performance domain entities. + +This module implements imperative (classical) mapping of the domain entities +to database tables. The domain entities are mapped directly without +creating separate ORM model classes, maintaining domain purity. + +All tables and mappings use the shared metadata and mapper registry from +the sqlalchemy.registry module, which are available as module-level singletons. + +⚠️ DEVELOPER WARNING ⚠️ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +ANY SCHEMA CHANGE (adding/removing/modifying tables or columns) REQUIRES an +Alembic migration. Do NOT modify this file without creating a migration: + + python scripts/migrate.py create "Description of your change" + +For detailed instructions, see: ../docs/ALEMBIC_MIGRATIONS.md +For a step-by-step example, see: ../docs/MIGRATION_EXAMPLE.md +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +""" + +import json +import uuid +from typing import Any, Optional + +from sqlalchemy import Column, ForeignKey, String, Table, event + +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.common import ConfigurationType +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.registry import mapper_registry, metadata +from edge_mining.domain.common import EntityId +from edge_mining.domain.performance.common import MiningPerformanceTrackerAdapter +from edge_mining.domain.performance.entities import MiningPerformanceTracker +from edge_mining.domain.performance.exceptions import MiningPerformanceTrackerConfigurationError +from edge_mining.shared.adapter_maps.performance import MINING_PERFORMANCE_TRACKER_CONFIG_TYPE_MAP +from edge_mining.shared.interfaces.config import MiningPerformanceTrackerConfig + + +class MiningPerformanceTrackerConfigType(ConfigurationType): + """SQLAlchemy type for MiningPerformanceTrackerConfig serialization. + + Inherits from ConfigurationType to handle JSON serialization/deserialization. + """ + + +def _deserialize_mining_performance_tracker_config( + adapter_type: MiningPerformanceTrackerAdapter, config_json: str +) -> Optional[MiningPerformanceTrackerConfig]: + """Deserialize JSON string to MiningPerformanceTrackerConfig based on adapter type.""" + if not config_json: + return None + + data: dict = json.loads(config_json) + + if adapter_type not in MINING_PERFORMANCE_TRACKER_CONFIG_TYPE_MAP: + raise MiningPerformanceTrackerConfigurationError( + f"Error reading MiningPerformanceTracker configuration. Invalid type '{adapter_type}'" + ) + + config_class: Optional[type[MiningPerformanceTrackerConfig]] = MINING_PERFORMANCE_TRACKER_CONFIG_TYPE_MAP.get( + adapter_type + ) + if not config_class: + raise MiningPerformanceTrackerConfigurationError( + f"Error creating MiningPerformanceTracker configuration. Type '{adapter_type}'" + ) + + config_instance = config_class.from_dict(data) + if not isinstance(config_instance, MiningPerformanceTrackerConfig): + raise MiningPerformanceTrackerConfigurationError( + f"Deserialized config is not of type MiningPerformanceTrackerConfig for adapter type {adapter_type}." + ) + return config_instance + + +@event.listens_for(MiningPerformanceTracker, "load") +def _receive_mining_performance_tracker_load(target: MiningPerformanceTracker, context) -> None: + """Event listener that deserializes config after loading from database.""" + # Convert id string to EntityId if needed + if hasattr(target, "id") and target.id is not None: + if isinstance(target.id, str): # type: ignore[arg-type,misc] + target.id = EntityId(uuid.UUID(target.id)) # type: ignore[assignment] + + # Convert foreign keys to EntityId + if hasattr(target, "external_service_id") and target.external_service_id is not None: + if isinstance(target.external_service_id, str): # type: ignore + target.external_service_id = EntityId(uuid.UUID(target.external_service_id)) # type: ignore + + # Convert adapter_type string to enum if needed + if isinstance(target.adapter_type, str): + try: + target.adapter_type = MiningPerformanceTrackerAdapter(target.adapter_type) + except ValueError: + pass + + if target.config and isinstance(target.config, str): + target.config = _deserialize_mining_performance_tracker_config(target.adapter_type, target.config) + + +@event.listens_for(MiningPerformanceTracker, "before_insert") +@event.listens_for(MiningPerformanceTracker, "before_update") +def _flatten_mining_performance_tracker_composites(mapper, connection, target: Any) -> None: + """Convert enum attributes to primitive values before persisting.""" + if hasattr(target, "adapter_type") and target.adapter_type is not None: + if isinstance(target.adapter_type, MiningPerformanceTrackerAdapter): + target.adapter_type = target.adapter_type.value + + +@event.listens_for(MiningPerformanceTracker, "after_insert") +@event.listens_for(MiningPerformanceTracker, "after_update") +def _restore_mining_performance_tracker_composites(mapper, connection, target: Any) -> None: + """Restore enum attributes after persist operations.""" + if hasattr(target, "adapter_type") and target.adapter_type is not None: + if isinstance(target.adapter_type, str): + try: + target.adapter_type = MiningPerformanceTrackerAdapter(target.adapter_type) + except ValueError: + pass + + +# Define the mining_performance_trackers table using imperative style +mining_performance_trackers_table = Table( + "mining_performance_trackers", + metadata, + Column("id", String, primary_key=True, index=True), + Column("name", String, nullable=False), + Column("adapter_type", String, nullable=False), + Column("config", MiningPerformanceTrackerConfigType, nullable=True), + Column("external_service_id", String, ForeignKey("external_services.id"), nullable=True), +) + +# Map MiningPerformanceTracker +mapper_registry.map_imperatively( + MiningPerformanceTracker, + mining_performance_trackers_table, +) diff --git a/core/edge_mining/adapters/domain/performance/trackers/__init__.py b/core/edge_mining/adapters/domain/performance/trackers/__init__.py new file mode 100644 index 0000000..5984377 --- /dev/null +++ b/core/edge_mining/adapters/domain/performance/trackers/__init__.py @@ -0,0 +1 @@ +"""Collection of miner performance tracker adapters.""" diff --git a/core/edge_mining/adapters/domain/performance/trackers/_base.py b/core/edge_mining/adapters/domain/performance/trackers/_base.py new file mode 100644 index 0000000..34832cc --- /dev/null +++ b/core/edge_mining/adapters/domain/performance/trackers/_base.py @@ -0,0 +1,128 @@ +"""Shared base class for mining performance trackers. + +Provides per-method TTL caching and exponential backoff with jitter around +HTTP 429 rate-limit responses. Adapters declare their own ``TTL_MAP`` so that +polling frequency can be tuned to each pool's data freshness semantics. + +On rate-limit events the base implements a *stale-while-error* strategy: +if a stale cached value exists for the requested key it is returned, otherwise +the underlying :class:`MiningPoolRateLimitedError` is re-raised. +""" + +import asyncio +import random +import time +from dataclasses import dataclass +from typing import Any, Awaitable, Callable, ClassVar, Dict, Optional, Tuple, TypeVar + +from edge_mining.domain.performance.exceptions import MiningPoolRateLimitedError +from edge_mining.shared.logging.port import LoggerPort + +T = TypeVar("T") + +# Backoff schedule (seconds). Applied in order; after the last slot is consumed +# the rate-limit error is propagated to the caller. +_BACKOFF_SCHEDULE_SECONDS: Tuple[float, ...] = (5.0, 10.0, 20.0, 40.0, 80.0) + +# Jitter is a multiplicative factor applied to each delay: uniform(0.8, 1.2). +_JITTER_FRACTION: float = 0.2 + + +@dataclass +class _CacheEntry: + """A cached value and the timestamp when it was stored (monotonic seconds).""" + + value: Any + stored_at: float + + +class CachedRateLimitedTrackerBase: + """Base class that wraps pool API calls with caching and backoff. + + Subclasses must set :attr:`TTL_MAP` to declare how long each logical method + key's response can be cached. Keys are free-form strings chosen by the + subclass (typically the port method name, e.g. ``"current_hashrate"``). + + A subclass that has not declared a key in :attr:`TTL_MAP` uses + :attr:`DEFAULT_TTL_SECONDS` (``60`` by default). + """ + + TTL_MAP: ClassVar[Dict[str, int]] = {} + DEFAULT_TTL_SECONDS: ClassVar[int] = 60 + + def __init__(self, logger: Optional[LoggerPort] = None) -> None: + self._cache: Dict[Tuple[str, Tuple[Any, ...]], _CacheEntry] = {} + self._cache_logger = logger + + def _ttl_for(self, key: str) -> int: + """Return the TTL (seconds) for a cache key, falling back to the default.""" + return self.TTL_MAP.get(key, self.DEFAULT_TTL_SECONDS) + + async def _cached_call( + self, + key: str, + fetch: Callable[[], Awaitable[T]], + args: Tuple[Any, ...] = (), + ) -> T: + """Run ``fetch`` with per-key TTL caching and 429 backoff. + + If a fresh cached value exists (inside TTL) it is returned without + invoking the fetch. On :class:`MiningPoolRateLimitedError` a stale cache + hit — if any — is returned as a fallback. All other exceptions are + propagated unchanged. + """ + cache_key = (key, args) + now = time.monotonic() + ttl = self._ttl_for(key) + + cached = self._cache.get(cache_key) + if cached is not None and (now - cached.stored_at) < ttl: + return cached.value # type: ignore[no-any-return] + + try: + value = await self._with_backoff(fetch) + except MiningPoolRateLimitedError: + if cached is not None: + if self._cache_logger: + self._cache_logger.warning( + f"Rate-limited on '{key}': serving stale cached value " f"({int(now - cached.stored_at)}s old)." + ) + return cached.value # type: ignore[no-any-return] + raise + + self._cache[cache_key] = _CacheEntry(value=value, stored_at=time.monotonic()) + return value + + async def _with_backoff(self, fetch: Callable[[], Awaitable[T]]) -> T: + """Retry ``fetch`` on 429 with 5/10/20/40/80s + jitter, max 5 attempts.""" + last_error: Optional[MiningPoolRateLimitedError] = None + for attempt, base_delay in enumerate(_BACKOFF_SCHEDULE_SECONDS): + try: + return await fetch() + except MiningPoolRateLimitedError as exc: + last_error = exc + delay = self._resolve_delay(exc, base_delay) + if self._cache_logger: + self._cache_logger.warning( + f"Rate-limited by pool API (attempt {attempt + 1}/" + f"{len(_BACKOFF_SCHEDULE_SECONDS)}); retrying in {delay:.1f}s." + ) + await asyncio.sleep(delay) + # All retries exhausted — propagate the last rate-limit error. + raise last_error if last_error else MiningPoolRateLimitedError("Rate limit retries exhausted") + + @staticmethod + def _resolve_delay(exc: MiningPoolRateLimitedError, base_delay: float) -> float: + """Respect ``Retry-After`` hint if larger than the scheduled delay; always jitter.""" + delay = max(base_delay, exc.retry_after or 0.0) + jitter = random.uniform(1.0 - _JITTER_FRACTION, 1.0 + _JITTER_FRACTION) + return delay * jitter + + def _invalidate_cache(self, key: Optional[str] = None) -> None: + """Invalidate either a single key (all argument combinations) or the whole cache.""" + if key is None: + self._cache.clear() + return + to_delete = [k for k in self._cache if k[0] == key] + for k in to_delete: + del self._cache[k] diff --git a/core/edge_mining/adapters/domain/performance/trackers/braiins_pool.py b/core/edge_mining/adapters/domain/performance/trackers/braiins_pool.py new file mode 100644 index 0000000..e4f4fa3 --- /dev/null +++ b/core/edge_mining/adapters/domain/performance/trackers/braiins_pool.py @@ -0,0 +1,347 @@ +""" +Braiins Pool adapter (Implementation of Port) that fetches mining performance +data from the Braiins Pool REST API. + +Reference: https://pool.braiins.com +Authentication is done via the `Pool-Auth-Token` header carrying a token the user +generates in `Settings > Access Profiles`. All responses are wrapped in a +`{"btc": { ... }}` envelope. +""" + +import asyncio +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from edge_mining.domain.common import EntityId, Timestamp +from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.domain.performance.common import PayoutFrequency, Satoshi +from edge_mining.adapters.domain.performance.trackers._base import ( + CachedRateLimitedTrackerBase, +) +from edge_mining.domain.performance.exceptions import ( + MiningPerformanceTrackerConfigurationError, + MiningPoolAuthError, + MiningPoolRateLimitedError, + MiningPoolResponseError, + MiningPoolUnreachableError, +) +from edge_mining.domain.performance.ports import MiningPerformanceTrackerPort +from edge_mining.domain.performance.value_objects import ( + MiningReward, + PayoutSchedule, + PoolStats, + PoolWorkerStats, +) +from edge_mining.shared.adapter_configs.performance import ( + MiningPerformanceTrackerBraiinsPoolConfig, +) +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.interfaces.factories import ( + MiningPerformanceTrackerAdapterFactory, +) +from edge_mining.shared.logging.port import LoggerPort + +# 1 BTC = 100_000_000 satoshis +_SATS_PER_BTC = 100_000_000 + +# Multipliers to convert a hash rate value from the unit advertised by Braiins into TH/s. +_UNIT_TO_THS: Dict[str, float] = { + "h/s": 1e-12, + "kh/s": 1e-9, + "mh/s": 1e-6, + "gh/s": 1e-3, + "th/s": 1.0, + "ph/s": 1e3, + "eh/s": 1e6, +} + + +def _hashrate_from_value(value: Any, unit: Optional[str]) -> Optional[HashRate]: + """Convert a Braiins hash rate value (given its unit) into a HashRate in TH/s.""" + if value is None: + return None + try: + raw = float(value) + except (TypeError, ValueError): + return None + factor = _UNIT_TO_THS.get((unit or "gh/s").strip().lower(), _UNIT_TO_THS["gh/s"]) + return HashRate(value=raw * factor, unit="TH/s") + + +def _btc_string_to_sats(value: Any) -> Optional[Satoshi]: + """Convert a decimal BTC value (typically a string) into Satoshis.""" + if value is None: + return None + try: + btc = float(value) + except (TypeError, ValueError): + return None + return Satoshi(int(round(btc * _SATS_PER_BTC))) + + +def _parse_retry_after(header_value: Optional[str]) -> Optional[float]: + """Parse a Retry-After header. Supports integer seconds; HTTP-date is ignored.""" + if header_value is None: + return None + try: + return float(header_value.strip()) + except (TypeError, ValueError): + return None + + +def _parse_timestamp(value: Any) -> Optional[Timestamp]: + """Parse a UNIX seconds or ISO 8601 timestamp into a Timestamp.""" + if value is None: + return None + if isinstance(value, (int, float)): + try: + return Timestamp(datetime.fromtimestamp(float(value), tz=timezone.utc)) + except (OverflowError, OSError, ValueError): + return None + if isinstance(value, str): + try: + return Timestamp(datetime.fromisoformat(value.replace("Z", "+00:00"))) + except ValueError: + try: + return Timestamp(datetime.fromtimestamp(float(value), tz=timezone.utc)) + except (TypeError, ValueError, OverflowError, OSError): + return None + return None + + +class BraiinsPoolMiningPerformanceTracker(CachedRateLimitedTrackerBase, MiningPerformanceTrackerPort): + """Adapter that talks to the Braiins Pool REST API.""" + + TTL_MAP = { + "current_hashrate": 60, + "pool_stats": 300, + "worker_stats": 300, + "payout_schedule": 3600, + "recent_rewards": 600, + } + + def __init__( + self, + config: MiningPerformanceTrackerBraiinsPoolConfig, + logger: Optional[LoggerPort] = None, + ): + super().__init__(logger=logger) + self._config = config + self._logger = logger + + async def _get(self, path: str) -> Dict[str, Any]: + """ + Perform an authenticated GET against the Braiins Pool API and unwrap the + `{"btc": {...}}` envelope. Raises domain exceptions on transport errors. + """ + import aiohttp + + url = f"{self._config.api_base_url.rstrip('/')}{path}" + headers = {"Pool-Auth-Token": self._config.api_token} + timeout = aiohttp.ClientTimeout(total=self._config.request_timeout_seconds) + + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url, headers=headers) as response: + if response.status == 429: + retry_after = _parse_retry_after(response.headers.get("Retry-After")) + raise MiningPoolRateLimitedError( + f"Braiins Pool rate-limited ({path})", + retry_after=retry_after, + ) + if response.status in (401, 403): + raise MiningPoolAuthError(f"Braiins Pool rejected credentials ({response.status}) for {path}") + if response.status >= 500: + raise MiningPoolUnreachableError(f"Braiins Pool returned status {response.status} for {path}") + try: + payload = await response.json(content_type=None) + except Exception as exc: # noqa: BLE001 - intentionally broad + raise MiningPoolResponseError(f"Braiins Pool returned non-JSON body for {path}: {exc}") from exc + + if response.status >= 400: + raise MiningPoolResponseError(f"Braiins Pool HTTP {response.status} for {path}: {payload}") + except (asyncio.TimeoutError, aiohttp.ClientError) as exc: + raise MiningPoolUnreachableError(f"Braiins Pool unreachable ({path}): {exc}") from exc + + if not isinstance(payload, dict): + raise MiningPoolResponseError(f"Braiins Pool returned unexpected payload for {path}: {payload!r}") + + data = payload.get("btc", payload) + if not isinstance(data, dict): + raise MiningPoolResponseError(f"Braiins Pool returned unexpected btc-envelope for {path}: {data!r}") + return data + + async def get_current_hashrate(self, miner_ids: List[EntityId]) -> Optional[HashRate]: + """Return the current account hashrate from `/accounts/profile/json/btc`.""" + return await self._cached_call("current_hashrate", self._fetch_current_hashrate) + + async def _fetch_current_hashrate(self) -> Optional[HashRate]: + try: + data = await self._get("/accounts/profile/json/btc") + except (MiningPoolUnreachableError, MiningPoolAuthError) as exc: + if self._logger: + self._logger.warning(f"Braiins: cannot fetch current hashrate: {exc}") + return None + + unit = data.get("hash_rate_unit") + raw = data.get("hash_rate_5m") or data.get("hash_rate_60m") or data.get("hash_rate_24h") + return _hashrate_from_value(raw, unit) + + async def get_recent_rewards(self, miner_id: Optional[EntityId] = None, limit: int = 10) -> List[MiningReward]: + """Return recent daily rewards from `/accounts/rewards/json/btc`.""" + return await self._cached_call( + "recent_rewards", + lambda: self._fetch_recent_rewards(limit), + args=(limit,), + ) + + async def _fetch_recent_rewards(self, limit: int) -> List[MiningReward]: + try: + data = await self._get("/accounts/rewards/json/btc") + except (MiningPoolUnreachableError, MiningPoolAuthError) as exc: + if self._logger: + self._logger.warning(f"Braiins: cannot fetch recent rewards: {exc}") + return [] + + entries = data.get("daily_rewards") or data.get("rewards") or [] + if not isinstance(entries, list): + return [] + + rewards: List[MiningReward] = [] + for entry in entries: + if not isinstance(entry, dict): + continue + amount = _btc_string_to_sats(entry.get("total_reward") or entry.get("reward")) + if amount is None: + continue + ts = _parse_timestamp(entry.get("date") or entry.get("timestamp")) + rewards.append(MiningReward(amount=amount, timestamp=ts) if ts is not None else MiningReward(amount=amount)) + + rewards.sort(key=lambda r: r.timestamp, reverse=True) + return rewards[:limit] + + async def get_pool_stats(self) -> Optional[PoolStats]: + """Combine `/accounts/profile/json/btc` and `/accounts/workers/json/btc` into PoolStats.""" + return await self._cached_call("pool_stats", self._fetch_pool_stats) + + async def _fetch_pool_stats(self) -> Optional[PoolStats]: + try: + profile = await self._get("/accounts/profile/json/btc") + except (MiningPoolUnreachableError, MiningPoolAuthError) as exc: + if self._logger: + self._logger.warning(f"Braiins: cannot fetch profile: {exc}") + return None + + try: + workers_payload = await self._get("/accounts/workers/json/btc") + except (MiningPoolUnreachableError, MiningPoolAuthError) as exc: + if self._logger: + self._logger.warning(f"Braiins: cannot fetch workers: {exc}") + workers_payload = {} + + unit = profile.get("hash_rate_unit") + workers = self._parse_workers(workers_payload.get("workers"), unit) + + # Post-FPPS (Nov 2023) Braiins removed `unconfirmed_reward` and `hash_rate_7d` + # from the profile. `current_balance` now carries the unpaid balance, and no + # 7-day aggregate is exposed — callers must fall back to 24h if they need it. + return PoolStats( + current_hashrate=_hashrate_from_value(profile.get("hash_rate_5m"), unit), + average_hashrate_24h=_hashrate_from_value(profile.get("hash_rate_24h"), unit), + average_hashrate_7d=None, + unpaid_balance=_btc_string_to_sats(profile.get("current_balance")), + estimated_next_payout=_btc_string_to_sats(profile.get("estimated_reward")), + workers=workers, + ) + + async def get_worker_stats(self, miner_ids: List[EntityId]) -> List[PoolWorkerStats]: + """Fetch per-worker statistics from `/accounts/workers/json/btc`.""" + return await self._cached_call("worker_stats", self._fetch_worker_stats) + + async def _fetch_worker_stats(self) -> List[PoolWorkerStats]: + try: + data = await self._get("/accounts/workers/json/btc") + except (MiningPoolUnreachableError, MiningPoolAuthError) as exc: + if self._logger: + self._logger.warning(f"Braiins: cannot fetch worker stats: {exc}") + return [] + + unit = data.get("hash_rate_unit") + return self._parse_workers(data.get("workers"), unit) + + async def get_payout_schedule(self) -> Optional[PayoutSchedule]: + """Return the payout policy for Braiins Pool (daily under FPPS).""" + return await self._cached_call("payout_schedule", self._fetch_payout_schedule) + + async def _fetch_payout_schedule(self) -> Optional[PayoutSchedule]: + # Post-FPPS Braiins pays out daily and no longer exposes a configurable + # threshold; `threshold` and `next_payout_at` stay None by design. + return PayoutSchedule(frequency=PayoutFrequency.DAILY) + + @staticmethod + def _parse_workers(raw_workers: Any, unit: Optional[str]) -> List[PoolWorkerStats]: + """Parse workers from either a list or a dict keyed by worker name.""" + items: List[tuple] = [] + if isinstance(raw_workers, list): + items = [(None, w) for w in raw_workers] + elif isinstance(raw_workers, dict): + items = list(raw_workers.items()) + else: + return [] + + workers: List[PoolWorkerStats] = [] + for key, worker in items: + if not isinstance(worker, dict): + continue + name = worker.get("worker_name") or worker.get("name") or key + if not name: + continue + # Braiins only exposes aggregated share counts over fixed windows + # (`shares_5m`/`shares_60m`/`shares_24h`); we surface the 24h total as + # `valid_shares` — the most cumulative metric useful for stale-worker + # detection. Stale/rejected counts are not exposed by the API. + workers.append( + PoolWorkerStats( + worker_name=str(name), + hashrate=_hashrate_from_value( + worker.get("hash_rate_5m") or worker.get("hash_rate"), + worker.get("hash_rate_unit") or unit, + ), + last_share_at=_parse_timestamp(worker.get("last_share") or worker.get("last_share_at")), + valid_shares=_safe_int(worker.get("shares_24h") or worker.get("valid_shares")), + stale_shares=_safe_int(worker.get("stale_shares")), + rejected_shares=_safe_int(worker.get("rejected_shares")), + ) + ) + return workers + + +def _safe_int(value: Any) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +class BraiinsPoolMiningPerformanceTrackerFactory(MiningPerformanceTrackerAdapterFactory): + """Factory for the Braiins Pool mining performance tracker adapter.""" + + def create( + self, + config: Optional[Configuration], + logger: Optional[LoggerPort], + external_service: Optional[ExternalServicePort], + ) -> MiningPerformanceTrackerPort: + if not isinstance(config, MiningPerformanceTrackerBraiinsPoolConfig): + raise MiningPerformanceTrackerConfigurationError( + "Invalid configuration type for Braiins Pool mining performance tracker. " + "Expected MiningPerformanceTrackerBraiinsPoolConfig." + ) + if not config.api_token or not config.api_token.strip(): + raise MiningPerformanceTrackerConfigurationError( + "Braiins Pool mining performance tracker requires a non-empty api_token." + ) + return BraiinsPoolMiningPerformanceTracker(config=config, logger=logger) diff --git a/core/edge_mining/adapters/domain/performance/trackers/dummy.py b/core/edge_mining/adapters/domain/performance/trackers/dummy.py new file mode 100644 index 0000000..278e375 --- /dev/null +++ b/core/edge_mining/adapters/domain/performance/trackers/dummy.py @@ -0,0 +1,91 @@ +""" +Dummy adapter (Implementation of Port) that simulates +a miner performance tracker for Edge Mining Application +""" + +import random +from typing import List, Optional + +from edge_mining.domain.common import EntityId +from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.domain.performance.common import PayoutFrequency, Satoshi +from edge_mining.domain.performance.exceptions import ( + MiningPerformanceTrackerConfigurationError, +) +from edge_mining.domain.performance.ports import MiningPerformanceTrackerPort +from edge_mining.domain.performance.value_objects import ( + MiningReward, + PayoutSchedule, + PoolStats, + PoolWorkerStats, +) +from edge_mining.shared.adapter_configs.performance import ( + MiningPerformanceTrackerDummyConfig, +) +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.interfaces.factories import ( + MiningPerformanceTrackerAdapterFactory, +) +from edge_mining.shared.logging.port import LoggerPort + + +class DummyMiningPerformanceTracker(MiningPerformanceTrackerPort): + """Dummy implementation of the MiningPerformanceTrackerPort.""" + + def __init__( + self, + config: Optional[MiningPerformanceTrackerDummyConfig] = None, + logger: Optional[LoggerPort] = None, + ): + self._config = config or MiningPerformanceTrackerDummyConfig() + self._logger = logger + + async def get_current_hashrate(self, miner_ids: List[EntityId]) -> Optional[HashRate]: + if self._logger: + self._logger.debug(f"DummyMiningPerformanceTracker: simulating hashrate for {len(miner_ids)} miners") + return HashRate(value=random.uniform(90.0, 110.0), unit="TH/s") + + async def get_recent_rewards(self, miner_id: Optional[EntityId] = None, limit: int = 10) -> List[MiningReward]: + if self._logger: + self._logger.debug(f"DummyMiningPerformanceTracker: simulating rewards for {miner_id} (limit={limit})") + return [] + + async def get_pool_stats(self) -> Optional[PoolStats]: + return PoolStats( + current_hashrate=HashRate(value=random.uniform(90.0, 110.0), unit="TH/s"), + average_hashrate_24h=HashRate(value=random.uniform(85.0, 105.0), unit="TH/s"), + average_hashrate_7d=HashRate(value=random.uniform(80.0, 100.0), unit="TH/s"), + unpaid_balance=Satoshi(0), + estimated_next_payout=Satoshi(0), + workers=[], + ) + + async def get_worker_stats(self, miner_ids: List[EntityId]) -> List[PoolWorkerStats]: + return [ + PoolWorkerStats( + worker_name=str(miner_id), + hashrate=HashRate(value=random.uniform(90.0, 110.0), unit="TH/s"), + ) + for miner_id in miner_ids + ] + + async def get_payout_schedule(self) -> Optional[PayoutSchedule]: + return PayoutSchedule(frequency=PayoutFrequency.UNKNOWN) + + +class DummyMiningPerformanceTrackerFactory(MiningPerformanceTrackerAdapterFactory): + """Factory for the DummyMiningPerformanceTracker.""" + + def create( + self, + config: Optional[Configuration], + logger: Optional[LoggerPort], + external_service: Optional[ExternalServicePort], + ) -> MiningPerformanceTrackerPort: + if config is not None and not isinstance(config, MiningPerformanceTrackerDummyConfig): + raise MiningPerformanceTrackerConfigurationError( + "Invalid configuration type for Dummy mining performance tracker. " + "Expected MiningPerformanceTrackerDummyConfig." + ) + return DummyMiningPerformanceTracker(config=config, logger=logger) diff --git a/core/edge_mining/adapters/domain/performance/trackers/ocean.py b/core/edge_mining/adapters/domain/performance/trackers/ocean.py new file mode 100644 index 0000000..76a0550 --- /dev/null +++ b/core/edge_mining/adapters/domain/performance/trackers/ocean.py @@ -0,0 +1,310 @@ +""" +Ocean.xyz adapter (Implementation of Port) that fetches mining performance +data from the Ocean pool public REST API. + +Reference: https://api.ocean.xyz +The pool identifies users by their payout Bitcoin address; no API token is +required to read public per-user statistics. +""" + +import asyncio +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +from edge_mining.domain.common import EntityId, Timestamp +from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.domain.performance.common import PayoutFrequency, Satoshi +from edge_mining.adapters.domain.performance.trackers._base import ( + CachedRateLimitedTrackerBase, +) +from edge_mining.domain.performance.exceptions import ( + MiningPerformanceTrackerConfigurationError, + MiningPoolRateLimitedError, + MiningPoolResponseError, + MiningPoolUnreachableError, +) +from edge_mining.domain.performance.ports import MiningPerformanceTrackerPort +from edge_mining.domain.performance.value_objects import ( + MiningReward, + PayoutSchedule, + PoolStats, + PoolWorkerStats, +) +from edge_mining.shared.adapter_configs.performance import ( + MiningPerformanceTrackerOceanConfig, +) +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.interfaces.factories import ( + MiningPerformanceTrackerAdapterFactory, +) +from edge_mining.shared.logging.port import LoggerPort + +# Conversion factor: Ocean returns hashrate in H/s, the domain uses TH/s. +_HS_TO_THS = 1e-12 + + +def _hashrate_from_hs(value: Any) -> Optional[HashRate]: + """Convert a raw H/s value (possibly as a string) into a HashRate in TH/s.""" + if value is None: + return None + try: + hs = float(value) + except (TypeError, ValueError): + return None + return HashRate(value=hs * _HS_TO_THS, unit="TH/s") + + +def _parse_retry_after(header_value: Optional[str]) -> Optional[float]: + """Parse a Retry-After header. Supports integer seconds; HTTP-date is ignored.""" + if header_value is None: + return None + try: + return float(header_value.strip()) + except (TypeError, ValueError): + return None + + +def _parse_timestamp(value: Any) -> Optional[Timestamp]: + """Parse a UNIX seconds or ISO 8601 timestamp into a Timestamp.""" + if value is None: + return None + if isinstance(value, (int, float)): + try: + return Timestamp(datetime.fromtimestamp(float(value), tz=timezone.utc)) + except (OverflowError, OSError, ValueError): + return None + if isinstance(value, str): + try: + return Timestamp(datetime.fromisoformat(value.replace("Z", "+00:00"))) + except ValueError: + try: + return Timestamp(datetime.fromtimestamp(float(value), tz=timezone.utc)) + except (TypeError, ValueError, OverflowError, OSError): + return None + return None + + +class OceanMiningPerformanceTracker(CachedRateLimitedTrackerBase, MiningPerformanceTrackerPort): + """Adapter that talks to the Ocean.xyz public REST API.""" + + TTL_MAP = { + "current_hashrate": 60, + "pool_stats": 300, + "worker_stats": 300, + "payout_schedule": 3600, + "recent_rewards": 600, + } + + def __init__( + self, + config: MiningPerformanceTrackerOceanConfig, + logger: Optional[LoggerPort] = None, + ): + super().__init__(logger=logger) + self._config = config + self._logger = logger + + async def _get(self, path: str) -> Dict[str, Any]: + """ + Perform an HTTP GET against the Ocean API and unwrap the `result` + envelope. Raises domain exceptions on transport or response errors. + """ + import aiohttp + + url = f"{self._config.api_base_url.rstrip('/')}{path}" + timeout = aiohttp.ClientTimeout(total=self._config.request_timeout_seconds) + + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url) as response: + if response.status == 429: + retry_after = _parse_retry_after(response.headers.get("Retry-After")) + raise MiningPoolRateLimitedError( + f"Ocean API rate-limited ({path})", + retry_after=retry_after, + ) + if response.status >= 500: + raise MiningPoolUnreachableError(f"Ocean API returned status {response.status} for {path}") + try: + payload = await response.json(content_type=None) + except Exception as exc: # noqa: BLE001 - intentionally broad + raise MiningPoolResponseError(f"Ocean API returned non-JSON body for {path}: {exc}") from exc + + if response.status >= 400: + raise MiningPoolResponseError(f"Ocean API HTTP {response.status} for {path}: {payload}") + except (asyncio.TimeoutError, aiohttp.ClientError) as exc: + raise MiningPoolUnreachableError(f"Ocean API unreachable ({path}): {exc}") from exc + + if not isinstance(payload, dict): + raise MiningPoolResponseError(f"Ocean API returned unexpected payload for {path}: {payload!r}") + + if "error" in payload and payload.get("error"): + raise MiningPoolResponseError(f"Ocean API error for {path}: {payload['error']}") + + result = payload.get("result", payload) + if not isinstance(result, dict): + return {"_raw": result} + return result + + async def get_current_hashrate(self, miner_ids: List[EntityId]) -> Optional[HashRate]: + """Return the current account-level hashrate from `/v1/user_hashrate/{btc}`.""" + return await self._cached_call("current_hashrate", self._fetch_current_hashrate) + + async def _fetch_current_hashrate(self) -> Optional[HashRate]: + try: + data = await self._get(f"/v1/user_hashrate/{self._config.bitcoin_address}") + except MiningPoolUnreachableError as exc: + if self._logger: + self._logger.warning(f"Ocean: cannot fetch current hashrate: {exc}") + return None + + raw = data.get("hashrate_300s") or data.get("hashrate_60s") or data.get("hashrate_1h") + return _hashrate_from_hs(raw) + + async def get_recent_rewards(self, miner_id: Optional[EntityId] = None, limit: int = 10) -> List[MiningReward]: + """Return recent earnings derived from `/v1/earnpay/{btc}/{from_ts}`.""" + return await self._cached_call( + "recent_rewards", + lambda: self._fetch_recent_rewards(limit), + args=(limit,), + ) + + async def _fetch_recent_rewards(self, limit: int) -> List[MiningReward]: + # Use a reasonable look-back window proportional to the requested limit + # (Ocean pays out roughly per block for active miners — ~30 days is wide enough). + from_ts = int((datetime.now(timezone.utc) - timedelta(days=30)).timestamp()) + try: + data = await self._get(f"/v1/earnpay/{self._config.bitcoin_address}/{from_ts}") + except MiningPoolUnreachableError as exc: + if self._logger: + self._logger.warning(f"Ocean: cannot fetch recent rewards: {exc}") + return [] + + entries = data.get("earnings") or data.get("payouts") or data.get("_raw") or [] + if not isinstance(entries, list): + return [] + + rewards: List[MiningReward] = [] + for entry in entries: + if not isinstance(entry, dict): + continue + amount_raw = entry.get("amount_sat") or entry.get("satoshi") or entry.get("amount") + try: + amount = Satoshi(int(amount_raw)) if amount_raw is not None else None + except (TypeError, ValueError): + amount = None + if amount is None: + continue + ts = _parse_timestamp(entry.get("timestamp") or entry.get("ts") or entry.get("time")) + rewards.append(MiningReward(amount=amount, timestamp=ts) if ts is not None else MiningReward(amount=amount)) + + rewards.sort(key=lambda r: r.timestamp, reverse=True) + return rewards[:limit] + + async def get_pool_stats(self) -> Optional[PoolStats]: + """Combine `/v1/user_hashrate` and `/v1/user_hashrate_full` into a PoolStats VO.""" + return await self._cached_call("pool_stats", self._fetch_pool_stats) + + async def _fetch_pool_stats(self) -> Optional[PoolStats]: + try: + summary = await self._get(f"/v1/user_hashrate/{self._config.bitcoin_address}") + except MiningPoolUnreachableError as exc: + if self._logger: + self._logger.warning(f"Ocean: cannot fetch pool stats summary: {exc}") + return None + + try: + full = await self._get(f"/v1/user_hashrate_full/{self._config.bitcoin_address}") + except MiningPoolUnreachableError as exc: + if self._logger: + self._logger.warning(f"Ocean: cannot fetch pool worker details: {exc}") + full = {} + + workers_raw = full.get("workers") if isinstance(full, dict) else None + workers: List[PoolWorkerStats] = [] + if isinstance(workers_raw, list): + workers = [w for w in (self._build_worker_stats(w) for w in workers_raw) if w is not None] + + unpaid = summary.get("unpaid_balance_sat") or summary.get("unpaid_balance") + try: + unpaid_sats = Satoshi(int(unpaid)) if unpaid is not None else None + except (TypeError, ValueError): + unpaid_sats = None + + return PoolStats( + current_hashrate=_hashrate_from_hs(summary.get("hashrate_300s")), + average_hashrate_24h=_hashrate_from_hs(summary.get("hashrate_24h") or summary.get("hashrate_1d")), + average_hashrate_7d=_hashrate_from_hs(summary.get("hashrate_7d")), + unpaid_balance=unpaid_sats, + estimated_next_payout=None, + workers=workers, + ) + + async def get_worker_stats(self, miner_ids: List[EntityId]) -> List[PoolWorkerStats]: + """Fetch per-worker statistics from `/v1/user_hashrate_full/{btc}`.""" + return await self._cached_call("worker_stats", self._fetch_worker_stats) + + async def _fetch_worker_stats(self) -> List[PoolWorkerStats]: + try: + data = await self._get(f"/v1/user_hashrate_full/{self._config.bitcoin_address}") + except MiningPoolUnreachableError as exc: + if self._logger: + self._logger.warning(f"Ocean: cannot fetch worker stats: {exc}") + return [] + + workers_raw = data.get("workers") + if not isinstance(workers_raw, list): + return [] + + return [w for w in (self._build_worker_stats(w) for w in workers_raw) if w is not None] + + async def get_payout_schedule(self) -> Optional[PayoutSchedule]: + """Ocean pays out per block when a payout threshold is reached.""" + return PayoutSchedule(frequency=PayoutFrequency.THRESHOLD) + + @staticmethod + def _build_worker_stats(worker: Any) -> Optional[PoolWorkerStats]: + if not isinstance(worker, dict): + return None + name = worker.get("name") or worker.get("worker_name") or worker.get("worker") + if not name: + return None + return PoolWorkerStats( + worker_name=str(name), + hashrate=_hashrate_from_hs(worker.get("hashrate_300s") or worker.get("hashrate")), + last_share_at=_parse_timestamp(worker.get("last_share") or worker.get("last_share_at")), + valid_shares=_safe_int(worker.get("valid_shares")), + stale_shares=_safe_int(worker.get("stale_shares")), + rejected_shares=_safe_int(worker.get("rejected_shares")), + ) + + +def _safe_int(value: Any) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +class OceanMiningPerformanceTrackerFactory(MiningPerformanceTrackerAdapterFactory): + """Factory for the Ocean.xyz mining performance tracker adapter.""" + + def create( + self, + config: Optional[Configuration], + logger: Optional[LoggerPort], + external_service: Optional[ExternalServicePort], + ) -> MiningPerformanceTrackerPort: + if not isinstance(config, MiningPerformanceTrackerOceanConfig): + raise MiningPerformanceTrackerConfigurationError( + "Invalid configuration type for Ocean mining performance tracker. " + "Expected MiningPerformanceTrackerOceanConfig." + ) + if not config.bitcoin_address or not config.bitcoin_address.strip(): + raise MiningPerformanceTrackerConfigurationError( + "Ocean mining performance tracker requires a non-empty bitcoin_address." + ) + return OceanMiningPerformanceTracker(config=config, logger=logger) diff --git a/core/edge_mining/adapters/domain/policy/__init__.py b/core/edge_mining/adapters/domain/policy/__init__.py new file mode 100644 index 0000000..80caac4 --- /dev/null +++ b/core/edge_mining/adapters/domain/policy/__init__.py @@ -0,0 +1 @@ +"""Adapters for the Optimization Policy domain.""" diff --git a/core/edge_mining/adapters/domain/policy/cli/__init__.py b/core/edge_mining/adapters/domain/policy/cli/__init__.py new file mode 100644 index 0000000..70abfdb --- /dev/null +++ b/core/edge_mining/adapters/domain/policy/cli/__init__.py @@ -0,0 +1 @@ +"""Adapters CLI for the policy domain.""" diff --git a/core/edge_mining/adapters/domain/policy/cli/commands.py b/core/edge_mining/adapters/domain/policy/cli/commands.py new file mode 100644 index 0000000..18f8237 --- /dev/null +++ b/core/edge_mining/adapters/domain/policy/cli/commands.py @@ -0,0 +1,746 @@ +"""CLI commands for the policy domain.""" + +from typing import Any, Dict, List, Optional + +import click + +from edge_mining.application.interfaces import ConfigurationServiceInterface +from edge_mining.domain.common import EntityId +from edge_mining.domain.miner.common import MinerStatus +from edge_mining.domain.policy.aggregate_roots import OptimizationPolicy +from edge_mining.domain.policy.common import ( + OPERATOR_SYMBOLS, + MiningDecision, + OperatorType, + RuleType, +) +from edge_mining.domain.policy.entities import AutomationRule +from edge_mining.domain.policy.exceptions import PolicyError, PolicyNotFoundError +from edge_mining.shared.logging.port import LoggerPort + +from edge_mining.adapters.utils import run_async_func + + +def select_mining_decision() -> Optional[MiningDecision]: + """Select a mining decision from the available options.""" + click.echo("Select a Mining Decision:") + for idx, decision in enumerate(MiningDecision): + color = ( + "green" + if decision == MiningDecision.START_MINING + else ("red" if decision == MiningDecision.STOP_MINING else "yellow") + ) + click.echo(f"{idx}. {click.style(decision.value, fg=color)}") + + click.echo("") + choice: str = click.prompt("Choose a mining decision", type=str) + choice = choice.strip().lower() + + if not choice.isdigit() or int(choice) < 0 or int(choice) >= len(MiningDecision): + click.echo(click.style("Invalid choice. Please try again.", fg="red")) + return None + + decision_values = [decision.value for decision in MiningDecision] + selected_decision = MiningDecision(decision_values[int(choice)]) + return selected_decision + + +def select_rule_type() -> Optional[RuleType]: + """Select a rule type from the available options.""" + click.echo("Select a Rule Type:") + for idx, rule_type in enumerate(RuleType): + color = "green" if rule_type == RuleType.START else "red" + click.echo(f"{idx}. {click.style(rule_type.value, fg=color)}") + + click.echo("") + choice: str = click.prompt("Choose a rule type", type=str) + choice = choice.strip().lower() + + if not choice.isdigit() or int(choice) < 0 or int(choice) >= len(RuleType): + click.echo(click.style("Invalid choice. Please try again.", fg="red")) + return None + + rule_type_values = [rule_type.value for rule_type in RuleType] + selected_rule_type = RuleType(rule_type_values[int(choice)]) + return selected_rule_type + + +def create_rule_conditions() -> Dict[str, Any]: + """Create rule conditions interactively.""" + conditions = {} + + click.echo(click.style("\n--- Define Rule Conditions ---", fg="yellow")) + click.echo("Available condition types:") + click.echo("1. Battery SOC greater than (battery_soc_gt)") + click.echo("2. Battery SOC less than (battery_soc_lt)") + click.echo("3. Solar forecast greater than (solar_forecast_gt)") + click.echo("4. Solar forecast less than (solar_forecast_lt)") + click.echo("5. Done (finish adding conditions)") + + while True: + choice = click.prompt("\nSelect condition type (1-5)", type=int, default=5) + + if choice == 5: + break + elif choice == 1: + value = click.prompt("Enter battery SOC percentage (0-100)", type=float) + conditions["battery_soc_gt"] = value + elif choice == 2: + value = click.prompt("Enter battery SOC percentage (0-100)", type=float) + conditions["battery_soc_lt"] = value + elif choice == 3: + value = click.prompt("Enter solar forecast power (W)", type=int) + conditions["solar_forecast_gt"] = value + elif choice == 4: + value = click.prompt("Enter solar forecast power (W)", type=int) + conditions["solar_forecast_lt"] = value + else: + click.echo(click.style("Invalid choice. Please try again.", fg="red")) + + return conditions + + +def handle_add_optimization_policy(configuration_service: ConfigurationServiceInterface, logger: LoggerPort) -> None: + """Menu to add a new optimization policy.""" + click.echo(click.style("\n--- Add Optimization Policy ---", fg="yellow")) + + name: str = click.prompt("Name of the optimization policy", type=str) + description: str = click.prompt("Description (optional)", type=str, default="") + + try: + new_policy = run_async_func(configuration_service.create_policy(name=name, description=description)) + + if not new_policy: + click.echo(click.style("Failed to create optimization policy.", fg="red")) + raise PolicyError("Policy creation failed") + + click.echo( + click.style( + f"Optimization policy '{name}' created successfully!", + fg="green", + ) + ) + + click.echo() + click.echo( + click.style(f"{RuleType.START.name}", fg="green") + + " rules are evaluated when the miner is in " + + click.style(f"{MinerStatus.OFF.name}", fg="red") + + " status" + ) + click.echo( + click.style(f"{RuleType.STOP.name}", fg="red") + + " rules are evaluated when the miner is in " + + click.style(f"{MinerStatus.ON.name}", fg="green") + + " status" + ) + click.echo() + + conditions: Dict[str, Any] = dict() + + # Now add rules to the policy if requested + if click.confirm("Add start rules?", default=True): + click.echo("\nAdding START rules:") + while True: + rule_name = click.prompt("Start rule name", type=str) + rule_description = click.prompt("Start rule description (optional)", type=str, default="") + rule_priority = click.prompt("Start rule priority (default is 10)", type=int, default=10) + conditions = create_rule_conditions() + + if conditions: + run_async_func( + configuration_service.add_rule_to_policy( + policy_id=new_policy.id, + rule_type=RuleType.START, + name=rule_name, + priority=rule_priority, + conditions=conditions, + description=rule_description, + ) + ) + click.echo(click.style(f"Start rule '{rule_name}' added!", fg="green")) + + if not click.confirm("Add another start rule?", default=False): + break + else: + break + + # Add stop rules + if click.confirm("Add stop rules?", default=True): + click.echo("\nAdding STOP rules:") + while True: + rule_name = click.prompt("Stop rule name", type=str) + rule_description = click.prompt("Stop rule description (optional)", type=str, default="") + rule_priority = click.prompt("Stop rule priority (default is 10)", type=int, default=10) + conditions = create_rule_conditions() + + if conditions: + run_async_func( + configuration_service.add_rule_to_policy( + policy_id=new_policy.id, + rule_type=RuleType.STOP, + name=rule_name, + priority=rule_priority, + conditions=conditions, + description=rule_description, + ) + ) + click.echo(click.style(f"Stop rule '{rule_name}' added!", fg="green")) + + if not click.confirm("Add another stop rule?", default=False): + break + else: + break + + if conditions.keys(): + click.echo( + click.style( + "Rules added successfully to Optimization policy '{name}'", + fg="green", + ) + ) + + except (PolicyError, PolicyNotFoundError) as e: + click.echo(click.style(f"Policy error: {str(e)}", fg="red")) + logger.error(f"Policy error adding optimization policy: {str(e)}") + except Exception as e: # Catch-all for unexpected errors + click.echo(click.style(f"Error adding optimization policy: {str(e)}", fg="red")) + logger.error(f"Error adding optimization policy: {str(e)}") + + click.pause("Press any key to return to the menu...") + + +def handle_list_optimization_policies( + configuration_service: ConfigurationServiceInterface, +) -> None: + """List all optimization policies.""" + click.echo(click.style("\n--- List Optimization Policies ---", fg="yellow")) + + policies: List[OptimizationPolicy] = configuration_service.list_policies() + if not policies: + click.echo(click.style("No optimization policies found.", fg="red")) + else: + for idx, policy in enumerate(policies): + click.echo(f"{idx}. ID: {click.style(policy.id, fg='yellow')}") + click.echo(f" Name: {click.style(policy.name, fg='blue')}") + if policy.description: + click.echo(f" Description: {click.style(policy.description, fg='cyan')}") + click.echo(f" Start rules: {click.style(str(len(policy.start_rules)), fg='green')}") + click.echo(f" Stop rules: {click.style(str(len(policy.stop_rules)), fg='red')}") + + click.echo("") + click.pause("Press any key to return to the menu...") + + +def select_optimization_policy( + configuration_service: ConfigurationServiceInterface, + default_id: Optional[EntityId] = None, +) -> Optional[OptimizationPolicy]: + """Select an optimization policy from the list.""" + click.echo(click.style("\n--- Select Optimization Policy ---", fg="yellow")) + + policies: List[OptimizationPolicy] = configuration_service.list_policies() + if not policies: + click.echo(click.style("No optimization policies found.", fg="red")) + return None + + default_idx = "" + for idx, policy in enumerate(policies): + if default_id and policy.id == default_id: + default_idx = str(idx) + click.echo(f"{idx}. ID: {click.style(policy.id, fg='yellow')}") + click.echo(f" Name: {click.style(policy.name, fg='blue')}") + if policy.description: + click.echo(f" {click.style(policy.description, fg='cyan')}") + + click.echo("\nb. Back to menu\n") + + policy_idx: str = click.prompt("Choose an Optimization Policy index", type=str, default=default_idx) + policy_idx = policy_idx.strip().lower() + if policy_idx == "b": + return None + + if not policy_idx.isdigit() or int(policy_idx) < 0 or int(policy_idx) >= len(policies): + click.echo(click.style("Invalid choice. Please try again.", fg="red")) + return None + + selected_policy = policies[int(policy_idx)] + return selected_policy + + +def print_optimization_policy_details(policy: OptimizationPolicy, show_rule_details: bool = True) -> None: + """Print the details of an optimization policy.""" + click.echo("") + click.echo("| ID: " + click.style(str(policy.id), fg="yellow")) + click.echo("| Name: " + click.style(policy.name, fg="blue")) + if policy.description: + click.echo("| Description: " + click.style(policy.description, fg="cyan")) + + if show_rule_details: + click.echo(f"| Start Rules: {click.style(str(len(policy.start_rules)), fg='green')}") + if policy.start_rules: + for rule in policy.start_rules: + print_rule_details(rule) + else: + click.echo("|- No start rules defined") + + click.echo("|------------------------------------------------------------------------") + + click.echo(f"| Stop Rules: {click.style(str(len(policy.stop_rules)), fg='red')}") + if policy.stop_rules: + for rule in policy.stop_rules: + print_rule_details(rule) + else: + click.echo("|- No stop rules defined") + + +def print_rule_details(rule: AutomationRule) -> None: + """Print the details of a single automation rule.""" + priority_str = click.style(str(rule.priority), fg="yellow") + click.echo(f"| [{priority_str}] {click.style(rule.name, fg='blue')}") + click.echo(f" Rule ID: {click.style(rule.id, fg='yellow')}") + click.echo(f" Enabled: {click.style('Yes' if rule.enabled else 'No', fg='green' if rule.enabled else 'red')}") + + print_rule_conditions(rule) + click.echo("") + + +def print_rule_conditions(rule: AutomationRule) -> None: + """Print the conditions of a rule.""" + click.echo(" Conditions:") + + if not rule.conditions: + click.echo(f" {click.style('No conditions defined', fg='red')}") + return + + print_rule_condition_items(rule.conditions) + + +def print_rule_condition_items(conditions: Dict, step: int = 0, prefix: Optional[str] = None) -> None: + """Print the items of a rule condition.""" + + for key, value in conditions.items(): + if value: + if isinstance(value, list): + blocks_key: str = " " + " " * step + if prefix: + blocks_key += f"[{prefix}] " + click.echo(f"{blocks_key}{click.style(f'{key}', fg='yellow')}:") + for idx, item in enumerate(value): + blocks_value: str = " " + " " * step + if isinstance(item, dict): + if "field" in item and "operator" in item and "value" in item: + # This is a condition item + operator_symbol = OPERATOR_SYMBOLS.get( + OperatorType(item["operator"]), + item["operator"], + ) + operator_symbol_str = click.style(operator_symbol, fg="green") + + field_str = click.style(item["field"], fg="cyan") + value_str = click.style(str(item["value"]), fg="magenta") + + condition_str = f"{field_str} {operator_symbol_str} {value_str}" + click.echo(f"{blocks_value}[{idx + 1}] -> {condition_str}") + else: + print_rule_condition_items(item, step + 1, str(idx + 1)) + else: + click.echo(f"{blocks_value}[{idx + 1}] -> {click.style(str(item), fg='cyan')}") + # click.echo(f" {click.style(f'{key}', fg='yellow')}: {value}") + + +def update_single_optimization_policy( + policy: OptimizationPolicy, +) -> OptimizationPolicy: + """Update a single optimization policy.""" + click.echo(click.style(f"\n--- Update Optimization Policy: {policy.name} ---", fg="yellow")) + + # Note: The ConfigurationServiceInterface doesn't have a direct update_policy method, + # so we'll focus on updating rules and use the existing policy structure + + click.echo("Note: Policy name and description updates require recreating the policy.") + click.echo("You can manage rules using the rule management options.") + + return policy + + +def delete_single_optimization_policy( + policy: OptimizationPolicy, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> bool: + """Delete a single optimization policy.""" + click.echo(click.style(f"\n--- Delete Optimization Policy: {policy.name} ---", fg="red")) + + if not click.confirm(f"Are you sure you want to delete '{policy.name}'?", default=False): + return False + + try: + run_async_func(configuration_service.delete_policy(policy.id)) + click.echo(click.style("Optimization policy deleted successfully!", fg="green")) + return True + except (PolicyError, PolicyNotFoundError) as e: + click.echo(click.style(f"Policy error: {str(e)}", fg="red")) + logger.error(f"Policy error deleting optimization policy: {str(e)}") + except Exception as e: # Catch-all for unexpected errors + click.echo(click.style(f"Error deleting optimization policy: {str(e)}", fg="red")) + logger.error(f"Error deleting optimization policy: {str(e)}") + + return False + + +def manage_single_optimization_policy_menu( + policy: OptimizationPolicy, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> str: + """Menu to manage a single optimization policy.""" + while True: + click.clear() + click.echo( + click.style( + f"=== Manage Optimization Policy: {policy.name} ===", + fg="yellow", + ) + ) + + print_optimization_policy_details(policy) + + click.echo("\nOptions:") + click.echo("1. Update policy (manage rules)") + click.echo("2. Sort rules by priority") + click.echo("3. Delete policy") + click.echo("b. Back to policies menu") + click.echo("q. Quit") + + choice = click.prompt("Choose an option", type=str, default="b") + choice = choice.strip().lower() + + if choice == "1": + result = manage_policy_rules_menu(policy, configuration_service, logger) + if result == "q": + return "q" + elif choice == "2": + try: + run_async_func(configuration_service.sort_policy_rules(policy.id)) + click.echo( + click.style( + f"Rules of Policy '{policy.name}' sorted by priority.", + fg="green", + ) + ) + except (PolicyError, PolicyNotFoundError) as e: + click.echo(click.style(f"Policy error: {str(e)}", fg="red")) + logger.error(f"Policy error when sorting rules: {str(e)}") + except Exception as e: # Catch-all for unexpected errors + click.echo( + click.style( + f"Error sorting rules for the policy: {str(e)}", + fg="red", + ) + ) + logger.error(f"Error sorting rules for the policy: {str(e)}") + click.pause("Press any key to continue...") + elif choice == "3": + if delete_single_optimization_policy(policy, configuration_service, logger): + return "b" # Return to previous menu + click.pause("Press any key to continue...") + elif choice == "b": + return "b" + elif choice == "q": + return "q" + else: + click.echo(click.style("Invalid choice. Please try again.", fg="red")) + click.pause("Press any key to continue...") + + +def manage_policy_rules_menu( + policy: OptimizationPolicy, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> str: + """Menu to manage rules within a policy.""" + while True: + click.clear() + click.echo(click.style(f"=== Manage Rules for Policy: {policy.name} ===", fg="yellow")) + + print_optimization_policy_details(policy) + + click.echo("\nRule Management Options:") + click.echo("1. Add start rule") + click.echo("2. Add stop rule") + click.echo("3. Edit start rule") + click.echo("4. Edit stop rule") + click.echo("5. Delete start rule") + click.echo("6. Delete stop rule") + click.echo("b. Back to policy menu") + click.echo("q. Quit") + + choice = click.prompt("Choose an option", type=str, default="b") + choice = choice.strip().lower() + + if choice == "1": + add_rule_to_policy(policy, RuleType.START, configuration_service, logger) + click.pause("Press any key to continue...") + elif choice == "2": + add_rule_to_policy(policy, RuleType.STOP, configuration_service, logger) + click.pause("Press any key to continue...") + elif choice == "3": + edit_policy_rule(policy, RuleType.START, configuration_service, logger) + click.pause("Press any key to continue...") + elif choice == "4": + edit_policy_rule(policy, RuleType.STOP, configuration_service, logger) + click.pause("Press any key to continue...") + elif choice == "5": + delete_policy_rule(policy, RuleType.START, configuration_service, logger) + click.pause("Press any key to continue...") + elif choice == "6": + delete_policy_rule(policy, RuleType.STOP, configuration_service, logger) + click.pause("Press any key to continue...") + elif choice == "b": + return "b" + elif choice == "q": + return "q" + else: + click.echo(click.style("Invalid choice. Please try again.", fg="red")) + click.pause("Press any key to continue...") + + +def add_rule_to_policy( + policy: OptimizationPolicy, + rule_type: RuleType, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> None: + """Add a rule to a policy.""" + type_name = "START" if rule_type == RuleType.START else "STOP" + click.echo(click.style(f"\n--- Add {type_name} Rule ---", fg="yellow")) + + rule_name = click.prompt("Rule name", type=str) + rule_description = click.prompt("Rule description (optional)", type=str, default="") + rule_priority = click.prompt("Rule priority (default is 10)", type=int, default=10) + conditions = create_rule_conditions() + + if not conditions: + click.echo(click.style("No conditions specified. Rule not created.", fg="red")) + return + + try: + run_async_func( + configuration_service.add_rule_to_policy( + policy_id=policy.id, + rule_type=rule_type, + name=rule_name, + priority=rule_priority, + conditions=conditions, + description=rule_description, + ) + ) + click.echo( + click.style( + f"{type_name} rule '{rule_name}' added successfully!", + fg="green", + ) + ) + except (PolicyError, PolicyNotFoundError) as e: + click.echo(click.style(f"Policy error: {str(e)}", fg="red")) + logger.error(f"Policy error adding rule to policy: {str(e)}") + except Exception as e: # Catch-all for unexpected errors + click.echo(click.style(f"Error adding rule: {str(e)}", fg="red")) + logger.error(f"Error adding rule to policy: {str(e)}") + + +def edit_policy_rule( + policy: OptimizationPolicy, + rule_type: RuleType, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> None: + """Edit a rule in a policy.""" + type_name = "START" if rule_type == RuleType.START else "STOP" + rules = policy.start_rules if rule_type == RuleType.START else policy.stop_rules + + if not rules: + click.echo(click.style(f"No {type_name.lower()} rules found.", fg="red")) + return + + click.echo(click.style(f"\n--- Edit {type_name} Rule ---", fg="yellow")) + click.echo("Select a rule to edit:") + + for idx, rule in enumerate(rules): + click.echo(f"{idx}. {click.style(rule.name, fg='blue')}") + + try: + rule_idx = click.prompt("Choose rule index", type=int) + if rule_idx < 0 or rule_idx >= len(rules): + click.echo(click.style("Invalid rule index.", fg="red")) + return + + selected_rule: AutomationRule = rules[rule_idx] + + new_name = click.prompt("New rule name", type=str, default=selected_rule.name) + new_description = click.prompt( + "New rule description (optional)", + type=str, + default=selected_rule.description, + ) + new_rule_priority = click.prompt( + f"New rule priority (default is {selected_rule.priority})", + type=int, + default=selected_rule.priority, + ) + rule_enabled = click.confirm( + "Enable rule?", + default=selected_rule.enabled, + ) + new_conditions = create_rule_conditions() + + if not new_conditions: + click.echo(click.style("No conditions specified. Rule not updated.", fg="red")) + return + + run_async_func( + configuration_service.update_policy_rule( + policy_id=policy.id, + rule_id=selected_rule.id, + name=new_name, + priority=new_rule_priority, + conditions=new_conditions, + description=new_description, + enabled=rule_enabled, + ) + ) + click.echo(click.style(f"Rule '{new_name}' updated successfully!", fg="green")) + + except (ValueError, IndexError): + click.echo(click.style("Invalid input.", fg="red")) + except (PolicyError, PolicyNotFoundError) as e: + click.echo(click.style(f"Policy error: {str(e)}", fg="red")) + logger.error(f"Policy error updating rule: {str(e)}") + except Exception as e: # Catch-all for unexpected errors + click.echo(click.style(f"Error updating rule: {str(e)}", fg="red")) + logger.error(f"Error updating rule: {str(e)}") + + +def delete_policy_rule( + policy: OptimizationPolicy, + rule_type: RuleType, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> None: + """Delete a rule from a policy.""" + type_name = "START" if rule_type == RuleType.START else "STOP" + rules = policy.start_rules if rule_type == RuleType.START else policy.stop_rules + + if not rules: + click.echo(click.style(f"No {type_name.lower()} rules found.", fg="red")) + return + + click.echo(click.style(f"\n--- Delete {type_name} Rule ---", fg="yellow")) + click.echo("Select a rule to delete:") + + for idx, rule in enumerate(rules): + click.echo(f"{idx}. {click.style(rule.name, fg='blue')}") + + try: + rule_idx = click.prompt("Choose rule index", type=int) + if rule_idx < 0 or rule_idx >= len(rules): + click.echo(click.style("Invalid rule index.", fg="red")) + return + + selected_rule = rules[rule_idx] + + if not click.confirm( + f"Are you sure you want to delete '{selected_rule.name}'?", + default=False, + ): + return + + run_async_func(configuration_service.delete_policy_rule(policy_id=policy.id, rule_id=selected_rule.id)) + click.echo( + click.style( + f"Rule '{selected_rule.name}' deleted successfully!", + fg="green", + ) + ) + + except (ValueError, IndexError): + click.echo(click.style("Invalid input.", fg="red")) + except (PolicyError, PolicyNotFoundError) as e: + click.echo(click.style(f"Policy error: {str(e)}", fg="red")) + logger.error(f"Policy error deleting rule: {str(e)}") + except Exception as e: # Catch-all for unexpected errors + click.echo(click.style(f"Error deleting rule: {str(e)}", fg="red")) + logger.error(f"Error deleting rule: {str(e)}") + + +def optimization_policies_menu(configuration_service: ConfigurationServiceInterface, logger: LoggerPort) -> str: + """Main menu for managing optimization policies.""" + while True: + click.clear() + click.echo(click.style("--- Optimization Policies Management ---", fg="yellow")) + click.echo("1. Add new optimization policy") + click.echo("2. List optimization policies") + click.echo("3. Manage existing policy") + click.echo("b. Back to main menu") + click.echo("q. Quit") + + choice = click.prompt("Choose an option", type=str, default="b") + choice = choice.strip().lower() + + if choice == "1": + handle_add_optimization_policy(configuration_service, logger) + elif choice == "2": + handle_list_optimization_policies(configuration_service) + elif choice == "3": + policy = select_optimization_policy(configuration_service) + if policy: + result = manage_single_optimization_policy_menu(policy, configuration_service, logger) + if result == "q": + return "q" + elif choice == "b": + return "b" + elif choice == "q": + return "q" + else: + click.echo(click.style("Invalid choice. Please try again.", fg="red")) + click.pause("Press any key to continue...") + + +def policy_menu(configuration_service: ConfigurationServiceInterface, logger: LoggerPort) -> str: + """Main policy menu.""" + while True: + click.echo("\n" + click.style("--- POLICY & RULE ---", fg="blue", bold=True)) + click.echo("1. Add an Optimization Policy") + click.echo("2. List all Optimization Policies") + click.echo("3. Manage an Optimization Policy") + click.echo("") + click.echo("b. Back to main menu") + click.echo("q. Quit") + + choice: str = click.prompt("Choose an option", type=str) + choice = choice.strip().lower() + + click.clear() + + if choice == "1": + handle_add_optimization_policy(configuration_service, logger) + elif choice == "2": + handle_list_optimization_policies(configuration_service) + elif choice == "3": + policy = select_optimization_policy(configuration_service) + if policy is None: + click.echo(click.style("No Optimization Policy selected. Aborting.", fg="red")) + continue + + sub_choice = manage_single_optimization_policy_menu(policy, configuration_service, logger) + if sub_choice == "q": + break + elif choice == "b": + break + elif choice == "q": + break + else: + click.echo(click.style("Invalid choice. Try again.", fg="red")) + click.pause("Press any key to return to the menu...") + return choice diff --git a/core/edge_mining/adapters/domain/policy/fast_api/__init__.py b/core/edge_mining/adapters/domain/policy/fast_api/__init__.py new file mode 100644 index 0000000..7d7be03 --- /dev/null +++ b/core/edge_mining/adapters/domain/policy/fast_api/__init__.py @@ -0,0 +1 @@ +"""Adapter that uses FastAPI infrastructure for policy domain API""" diff --git a/core/edge_mining/adapters/domain/policy/fast_api/router.py b/core/edge_mining/adapters/domain/policy/fast_api/router.py new file mode 100644 index 0000000..8349051 --- /dev/null +++ b/core/edge_mining/adapters/domain/policy/fast_api/router.py @@ -0,0 +1,418 @@ +"""API Router for policy domain""" + +from typing import Annotated, List + +from fastapi import APIRouter, Depends, HTTPException + +from edge_mining.adapters.domain.policy.schemas import ( + AutomationRuleCreateSchema, + AutomationRuleSchema, + AutomationRuleUpdateSchema, + DecisionalContextSchema, + DecisionalContextStructureSchema, + OptimizationPolicyCreateSchema, + OptimizationPolicySchema, + OptimizationPolicyUpdateSchema, + PolicyCheckSchema, +) + +# Import dependency injection setup functions +from edge_mining.adapters.infrastructure.api.setup import get_config_service +from edge_mining.application.interfaces import ConfigurationServiceInterface +from edge_mining.domain.common import EntityId +from edge_mining.domain.policy.aggregate_roots import OptimizationPolicy +from edge_mining.domain.policy.common import RuleType +from edge_mining.domain.policy.entities import AutomationRule +from edge_mining.domain.policy.exceptions import ( + PolicyConfigurationError, + PolicyError, + PolicyNotFoundError, + RuleNotFoundError, +) + +router = APIRouter() + + +@router.get("/policies", response_model=List[OptimizationPolicySchema]) +async def get_policies_list( + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> List[OptimizationPolicySchema]: + """Get a list of all optimization policies""" + try: + policies = config_service.list_policies() + + # Convert policies to schema + policy_schemas: List[OptimizationPolicySchema] = [] + + # TODO: Here we are losing metadata information. Consider adding it to the schema if needed. + for policy in policies: + policy_schemas.append(OptimizationPolicySchema.from_model(policy)) + + return policy_schemas + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") from e + + +@router.post("/policies", response_model=OptimizationPolicySchema) +async def add_policy( + policy_schema: OptimizationPolicyCreateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> OptimizationPolicySchema: + """Add a new optimization policy""" + try: + policy_to_add: OptimizationPolicy = policy_schema.to_model() + + # Create policy using configuration service + new_policy = await config_service.create_policy( + name=policy_to_add.name, + description=policy_to_add.description or "", + ) + + if policy_to_add.start_rules: + for rule in policy_to_add.start_rules: + await config_service.add_rule_to_policy( + policy_id=new_policy.id, + rule_type=RuleType.START, + name=rule.name, + priority=rule.priority, + conditions=rule.conditions, + description=rule.description or "", + ) + if policy_to_add.stop_rules: + for rule in policy_to_add.stop_rules: + await config_service.add_rule_to_policy( + policy_id=new_policy.id, + rule_type=RuleType.STOP, + name=rule.name, + priority=rule.priority, + conditions=rule.conditions, + description=rule.description or "", + ) + + return OptimizationPolicySchema.from_model(new_policy) + + except PolicyError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except PolicyConfigurationError as e: + raise HTTPException(status_code=422, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") from e + + +@router.get("/policies/{policy_id}", response_model=OptimizationPolicySchema) +async def get_policy( + policy_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> OptimizationPolicySchema: + """Get a specific optimization policy""" + try: + policy = config_service.get_policy(policy_id) + if not policy: + raise PolicyNotFoundError() + return OptimizationPolicySchema.from_model(policy) + except PolicyNotFoundError as e: + raise HTTPException(status_code=404, detail="Policy not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") from e + + +@router.put("/policies/{policy_id}", response_model=OptimizationPolicySchema) +async def update_policy( + policy_id: EntityId, + policy_update: OptimizationPolicyUpdateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> OptimizationPolicySchema: + """Update an existing optimization policy""" + try: + # Get existing policy + existing_policy = config_service.get_policy(policy_id) + if not existing_policy: + raise HTTPException(status_code=404, detail="Policy not found") + + # Update policy fields + updated_policy = await config_service.update_policy( + policy_id=policy_id, + name=policy_update.name or existing_policy.name, + description=policy_update.description or existing_policy.description or "", + ) + + return OptimizationPolicySchema.from_model(updated_policy) + + except PolicyNotFoundError as e: + raise HTTPException(status_code=404, detail="Policy not found") from e + except PolicyError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") from e + + +@router.delete("/policies/{policy_id}", response_model=OptimizationPolicySchema) +async def delete_policy( + policy_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> OptimizationPolicySchema: + """Delete an optimization policy""" + try: + # Get policy before deletion + policy = config_service.get_policy(policy_id) + if not policy: + raise HTTPException(status_code=404, detail="Policy not found") + + # Delete policy + await config_service.delete_policy(policy_id) + + return OptimizationPolicySchema.from_model(policy) + + except PolicyNotFoundError as e: + raise HTTPException(status_code=404, detail="Policy not found") from e + except PolicyError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") from e + + +@router.get("/policies/{policy_id}/check", response_model=PolicyCheckSchema) +async def check_policy( + policy_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> PolicyCheckSchema: + """Check if a policy is valid and can be used.""" + errors = [] + warnings = [] + valid = False + policy_name = None + start_rules_count = 0 + stop_rules_count = 0 + enabled_start_rules_count = 0 + enabled_stop_rules_count = 0 + + try: + # Get policy to extract metadata + policy = config_service.get_policy(policy_id) + if not policy: + raise PolicyNotFoundError() + + policy_name = policy.name + start_rules_count = len(policy.start_rules) + stop_rules_count = len(policy.stop_rules) + enabled_start_rules_count = sum(1 for rule in policy.start_rules if rule.enabled) + enabled_stop_rules_count = sum(1 for rule in policy.stop_rules if rule.enabled) + + # Add warnings for disabled rules + disabled_start_rules = start_rules_count - enabled_start_rules_count + disabled_stop_rules = stop_rules_count - enabled_stop_rules_count + + if disabled_start_rules > 0: + warnings.append(f"{disabled_start_rules} start rule(s) are disabled") + if disabled_stop_rules > 0: + warnings.append(f"{disabled_stop_rules} stop rule(s) are disabled") + + # Perform the actual validation + valid = config_service.check_policy(policy_id) + + except PolicyNotFoundError: + errors.append("Policy not found") + except PolicyError as e: + errors.append(str(e)) + valid = False + except Exception as e: + errors.append(f"Unexpected error: {str(e)}") + + return PolicyCheckSchema( + valid=valid, + policy_id=str(policy_id), + policy_name=policy_name, + errors=errors, + warnings=warnings, + start_rules_count=start_rules_count, + stop_rules_count=stop_rules_count, + enabled_start_rules_count=enabled_start_rules_count, + enabled_stop_rules_count=enabled_stop_rules_count, + ) + + +# Policy rule management endpoints +@router.post("/policies/{policy_id}/rules", response_model=AutomationRuleSchema) +async def add_rule_to_policy( + policy_id: EntityId, + rule_schema: AutomationRuleCreateSchema, + rule_type: RuleType, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> AutomationRuleSchema: + """Add a new rule to an existing optimization policy""" + try: + rule_to_add: AutomationRule = rule_schema.to_model() + + new_rule = await config_service.add_rule_to_policy( + policy_id=policy_id, + rule_type=rule_type, + name=rule_to_add.name, + priority=rule_to_add.priority, + conditions=rule_to_add.conditions, + description=rule_to_add.description or "", + ) + + return AutomationRuleSchema.from_model(new_rule) + + except PolicyNotFoundError as e: + raise HTTPException(status_code=404, detail="Policy not found") from e + except PolicyError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") from e + + +@router.get("/policies/{policy_id}/types/{rule_type}", response_model=List[AutomationRuleSchema]) +async def get_policy_rules( + policy_id: EntityId, + rule_type: RuleType, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> List[AutomationRuleSchema]: + """Get all rules of a specific type for a policy""" + try: + rules = config_service.get_policy_rules(policy_id, rule_type) + automation_rule_schemas: List[AutomationRuleSchema] = [] + for rule in rules: + automation_rule_schemas.append(AutomationRuleSchema.from_model(rule)) + return automation_rule_schemas + except PolicyNotFoundError as e: + raise HTTPException(status_code=404, detail="Policy not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") from e + + +@router.get("/policies/{policy_id}/rules/{rule_id}", response_model=AutomationRuleSchema) +async def get_policy_rule( + policy_id: EntityId, + rule_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> AutomationRuleSchema: + """Get a specific rule for a policy""" + try: + rule = config_service.get_policy_rule(policy_id, rule_id) + if not rule: + raise RuleNotFoundError() + return AutomationRuleSchema.from_model(rule) + except PolicyNotFoundError as e: + raise HTTPException(status_code=404, detail="Policy not found") from e + except RuleNotFoundError as e: + raise HTTPException(status_code=404, detail="Rule not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") from e + + +@router.get("/policies/{policy_id}/rules/{rule_id}/enable", response_model=AutomationRuleSchema) +async def enable_policy_rule( + policy_id: EntityId, + rule_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> AutomationRuleSchema: + """Enable a specific rule for a policy""" + try: + rule: AutomationRule = await config_service.enable_policy_rule(policy_id, rule_id) + + return AutomationRuleSchema.from_model(rule) + except PolicyNotFoundError as e: + raise HTTPException(status_code=404, detail="Policy not found") from e + except RuleNotFoundError as e: + raise HTTPException(status_code=404, detail="Rule not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") from e + + +@router.get("/policies/{policy_id}/rules/{rule_id}/disable", response_model=AutomationRuleSchema) +async def disable_policy_rule( + policy_id: EntityId, + rule_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> AutomationRuleSchema: + """Disable a specific rule for a policy""" + try: + rule: AutomationRule = await config_service.disable_policy_rule(policy_id, rule_id) + + return AutomationRuleSchema.from_model(rule) + except PolicyNotFoundError as e: + raise HTTPException(status_code=404, detail="Policy not found") from e + except RuleNotFoundError as e: + raise HTTPException(status_code=404, detail="Rule not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") from e + + +@router.put("/policies/{policy_id}/rules/{rule_id}", response_model=AutomationRuleSchema) +async def update_policy_rule( + policy_id: EntityId, + rule_id: EntityId, + rule_schema: AutomationRuleUpdateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> AutomationRuleSchema: + """Update a specific rule for a policy""" + try: + # Get existing rule + existing_rule = config_service.get_policy_rule(policy_id, rule_id) + if not existing_rule: + raise RuleNotFoundError() + + conditions: dict = existing_rule.conditions + if rule_schema.conditions is not None: + conditions = rule_schema.conditions.to_model() + + updated_rule = await config_service.update_policy_rule( + policy_id=policy_id, + rule_id=rule_id, + name=rule_schema.name or existing_rule.name, + priority=rule_schema.priority if rule_schema.priority is not None else existing_rule.priority, + enabled=rule_schema.enabled if rule_schema.enabled is not None else existing_rule.enabled, + conditions=conditions, + description=rule_schema.description or existing_rule.description, + ) + + return AutomationRuleSchema.from_model(updated_rule) + + except PolicyNotFoundError as e: + raise HTTPException(status_code=404, detail="Policy not found") from e + except RuleNotFoundError as e: + raise HTTPException(status_code=404, detail="Rule not found") from e + except PolicyError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") from e + + +@router.delete("/policies/{policy_id}/rules/{rule_id}", response_model=AutomationRuleSchema) +async def delete_policy_rule( + policy_id: EntityId, + rule_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> AutomationRuleSchema: + """Delete a specific rule from a policy""" + try: + # Get rule before deletion + rule = config_service.get_policy_rule(policy_id, rule_id) + if not rule: + raise HTTPException(status_code=404, detail="Rule not found") + + deleted_rule = await config_service.delete_policy_rule(policy_id, rule_id) + return AutomationRuleSchema.from_model(deleted_rule) + + except PolicyNotFoundError as e: + raise HTTPException(status_code=404, detail="Policy not found") from e + except PolicyError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") from e + + +@router.get("/decisional-context/structure", response_model=DecisionalContextStructureSchema) +async def get_decisional_context_structure_endpoint() -> DecisionalContextStructureSchema: + """ + Get the complete structure of the DecisionalContext. + + Returns a hierarchical representation of all fields available in the decisional context, + including their types and descriptions. This is useful for: + - Understanding what data is available for rule conditions + - Building UIs for rule creation + - Documentation purposes + - Validating field paths in rule conditions + """ + return DecisionalContextSchema.get_structure() diff --git a/core/edge_mining/adapters/domain/policy/repositories.py b/core/edge_mining/adapters/domain/policy/repositories.py new file mode 100644 index 0000000..b167d09 --- /dev/null +++ b/core/edge_mining/adapters/domain/policy/repositories.py @@ -0,0 +1,736 @@ +""" +This module contains the adapter classes implementing the OptimizationPolicyRepository interface. +""" + +import copy +import json +import os +import sqlite3 +import uuid +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple, cast + +import yaml +from pydantic import ValidationError +from sqlalchemy import select + +from edge_mining.adapters.domain.policy.schemas import AutomationRuleSchema, MetadataSchema, OptimizationPolicySchema +from edge_mining.adapters.domain.policy.tables import policies_table +from edge_mining.adapters.domain.policy.yaml.utils import CustomDumper +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.adapters.infrastructure.persistence.sqlite import BaseSqliteRepository +from edge_mining.domain.common import EntityId +from edge_mining.domain.policy.aggregate_roots import AutomationRule, OptimizationPolicy +from edge_mining.domain.policy.exceptions import ( + PolicyAlreadyExistsError, + PolicyConfigurationError, + PolicyError, + PolicyNotFoundError, +) +from edge_mining.domain.policy.ports import OptimizationPolicyRepository +from edge_mining.shared.logging.port import LoggerPort + +# Simple In-Memory implementation for testing and basic use + + +class InMemoryOptimizationPolicyRepository(OptimizationPolicyRepository): + """In-Memory implementation of the OptimizationPolicyRepository.""" + + def __init__( + self, + initial_policies: Optional[Dict[EntityId, OptimizationPolicy]] = None, + ): + self._policies: Dict[EntityId, OptimizationPolicy] = copy.deepcopy(initial_policies) if initial_policies else {} + + def add(self, policy: OptimizationPolicy) -> None: + if policy.id in self._policies: + print(f"Warning: Policy {policy.id} already exists, overwriting.") + self._policies[policy.id] = copy.deepcopy(policy) + + def get_by_id(self, policy_id: EntityId) -> Optional[OptimizationPolicy]: + return copy.deepcopy(self._policies.get(policy_id)) + + def get_all(self) -> List[OptimizationPolicy]: + return [copy.deepcopy(p) for p in self._policies.values()] + + def update(self, policy: OptimizationPolicy) -> None: + if policy.id not in self._policies: + raise ValueError(f"Policy {policy.id} not found for update.") + self._policies[policy.id] = copy.deepcopy(policy) + + def remove(self, policy_id: EntityId) -> None: + if policy_id not in self._policies: + raise ValueError(f"Policy {policy_id} not found for removal.") + del self._policies[policy_id] + + +class SqliteOptimizationPolicyRepository(OptimizationPolicyRepository): + """SQLite implementation of the OptimizationPolicyRepository.""" + + def __init__(self, db: BaseSqliteRepository): + self._db = db + self.logger = db.logger + + self._create_tables() + + def _create_tables(self): + """Create the necessary tables for the Optimization Policy domain if they do not exist.""" + self.logger.debug(f"Ensuring SQLite tables exist for Optimization Policy Repository in {self._db.db_path}...") + sql_statements = [ + """ + CREATE TABLE IF NOT EXISTS policies ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + start_rules TEXT, -- JSON list of AutomationRule dicts + stop_rules TEXT -- JSON list of AutomationRule dicts + ); + """ + ] + + conn = self._db.get_connection() + + try: + with conn: + cursor = conn.cursor() + for statement in sql_statements: + cursor.execute(statement) + + self.logger.debug("Optimization Policies tables checked/created successfully.") + except sqlite3.Error as e: + self.logger.error(f"Error creating SQLite tables: {e}") + raise PolicyConfigurationError(f"DB error creating tables: {e}") from e + finally: + if conn: + conn.close() + + def _dict_to_rule(self, data: Dict[str, Any]) -> AutomationRule: + # Deserialize a dictionary (from JSON) into an AutomationRule object + return AutomationRule( + id=EntityId(uuid.UUID(data["id"])), # Convert UUID string + priority=data["priority"], + name=data["name"], + description=data["description"], + enabled=data["enabled"], + conditions=data["conditions"], + ) + + def _rule_to_dict(self, rule: AutomationRule) -> Dict[str, Any]: + # Serializes an AutomationRule object into a dictionary for JSON + return { + "id": str(rule.id), + "name": rule.name, + "description": rule.description, + "priority": rule.priority, + "enabled": rule.enabled, + "conditions": rule.conditions, + } + + def _row_to_policy(self, row: sqlite3.Row) -> Optional[OptimizationPolicy]: + if not row: + return None + try: + # Deserialize JSON lists of rules and target IDs + start_rules_data = json.loads(row["start_rules"] or "[]") + stop_rules_data = json.loads(row["stop_rules"] or "[]") + + start_rules = [self._dict_to_rule(r) for r in start_rules_data] + stop_rules = [self._dict_to_rule(r) for r in stop_rules_data] + + return OptimizationPolicy( + id=row["id"], # UUID is already converted by detect_types + name=row["name"], + description=row["description"], + start_rules=start_rules, + stop_rules=stop_rules, + ) + except (json.JSONDecodeError, ValueError, KeyError, TypeError) as e: + self.logger.error(f"Error deserializing Policy from DB line: {dict(row)}. Error: {e}") + return None + + def add(self, policy: OptimizationPolicy) -> None: + self.logger.debug(f"Adding policy '{policy.name}' ({policy.id}) to SQLite.") + sql = """ + INSERT INTO policies (id, name, description, start_rules, stop_rules) + VALUES (?, ?, ?, ?, ?) + """ + conn = self._db.get_connection() + try: + # Serialize rules and target IDs to JSON + start_rules_json = json.dumps([self._rule_to_dict(r) for r in policy.start_rules]) + stop_rules_json = json.dumps([self._rule_to_dict(r) for r in policy.stop_rules]) + + with conn: + conn.execute( + sql, + ( + policy.id, # UUID + policy.name, + policy.description, + start_rules_json, + stop_rules_json, + ), + ) + except sqlite3.IntegrityError as e: + self.logger.error(f"Integrity error adding policy '{policy.name}': {e}") + raise PolicyError(f"Policy with ID {policy.id} or name '{policy.name}' already exists: {e}") from e + except sqlite3.Error as e: + self.logger.error(f"SQLite error adding policy '{policy.name}': {e}") + raise PolicyError(f"DB error adding policy: {e}") from e + finally: + if conn: + conn.close() + + def get_by_id(self, policy_id: EntityId) -> Optional[OptimizationPolicy]: + self.logger.debug(f"Getting policy {policy_id} from SQLite.") + sql = "SELECT * FROM policies WHERE id = ?" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (policy_id,)) # Pass UUID directly + row = cursor.fetchone() + return self._row_to_policy(row) + except sqlite3.Error as e: + self.logger.error(f"SQLite error getting policy {policy_id}: {e}") + return None + finally: + if conn: + conn.close() + + def get_all(self) -> List[OptimizationPolicy]: + self.logger.debug("Getting all policies from SQLite.") + sql = "SELECT * FROM policies ORDER BY name" + conn = self._db.get_connection() + policies = [] + try: + cursor = conn.cursor() + cursor.execute(sql) + rows = cursor.fetchall() + for row in rows: + policy = self._row_to_policy(row) + if policy: + policies.append(policy) + except sqlite3.Error as e: + self.logger.error(f"SQLite error getting all policies: {e}") + return [] + finally: + if conn: + conn.close() + return policies + + def update(self, policy: OptimizationPolicy) -> None: + self.logger.debug(f"Updating policy '{policy.name}' ({policy.id}) in SQLite.") + # Activation Management: If this policy becomes active, deactivates the others + conn = self._db.get_connection() + try: + with conn: # Transaction + cursor = conn.cursor() + + # Update the current policy + sql_update = """ + UPDATE policies + SET name = ?, description = ?, start_rules = ?, stop_rules = ? + WHERE id = ? + """ + start_rules_json = json.dumps([self._rule_to_dict(r) for r in policy.start_rules]) + stop_rules_json = json.dumps([self._rule_to_dict(r) for r in policy.stop_rules]) + + cursor.execute( + sql_update, + ( + policy.name, + policy.description, + start_rules_json, + stop_rules_json, + policy.id, # UUID + ), + ) + + if cursor.rowcount == 0: + raise PolicyError(f"No policies found with ID {policy.id}.") + + except sqlite3.IntegrityError as e: + self.logger.error(f"Integrity error updating policy '{policy.name}': {e}") + # There might be a conflict over the name UNIQUE + raise PolicyError(f"Constraint error updating policy (duplicate name?): {e}") from e + except sqlite3.Error as e: + self.logger.error(f"SQLite error updating policy '{policy.name}': {e}") + raise PolicyError(f"EDB error updating policy: {e}") from e + finally: + if conn: + conn.close() + + def remove(self, policy_id: EntityId) -> None: + self.logger.debug(f"Removing policy {policy_id} from SQLite.") + sql = "DELETE FROM policies WHERE id = ?" + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + cursor.execute(sql, (policy_id,)) + if cursor.rowcount == 0: + raise PolicyError(f"No policies found with ID {policy_id}.") + except sqlite3.Error as e: + self.logger.error(f"SQLite error removing policy {policy_id}: {e}") + raise PolicyError(f"DB error removing policy: {e}") from e + finally: + if conn: + conn.close() + + +class YamlOptimizationPolicyRepository(OptimizationPolicyRepository): + """YAML file-based implementation of OptimizationPolicyRepository.""" + + def __init__(self, policies_directory: str, logger: Optional[LoggerPort] = None): + """ + Initialize the YAML policy repository. + + Args: + policies_directory: Path to the directory containing policy YAML files + logger: Optional logger for debugging + """ + self.policies_directory = Path(policies_directory) + self.logger = logger + + # Create directory if it doesn't exist + self.policies_directory.mkdir(parents=True, exist_ok=True) + + if self.logger: + self.logger.debug(f"Initialized YamlOptimizationPolicyRepository with directory: {self.policies_directory}") + + def _get_policy_file_path(self, policy_id: EntityId) -> Path: + """Get the file path for a policy based on its ID.""" + return Path(os.path.join(self.policies_directory.resolve(), f"{policy_id}.yaml")) + + def _get_policy_file_path_by_name(self, name: str) -> Path: + """Get a potential file path for a policy based on its name (for searching).""" + # Sanitize name for filename + safe_name = "".join(c for c in name if c.isalnum() or c in (" ", "-", "_")).strip() + safe_name = safe_name.replace(" ", "_").lower() + return Path(os.path.join(self.policies_directory, f"{safe_name}.yaml")) + + def _load_policy_from_file(self, file_path: Path) -> Tuple[Optional[OptimizationPolicy], Optional[MetadataSchema]]: + """Load a policy from a YAML file.""" + try: + # Check if the file exists + if not os.path.isfile(file_path): + return None, None + + with open(file_path, "r", encoding="utf-8") as f: + yaml_content = yaml.safe_load(f) + + if yaml_content is None: + if self.logger: + self.logger.warning(f"Empty YAML file: {file_path}") + return None, None + + # Parse and validate the YAML content + policy_schema = OptimizationPolicySchema(**yaml_content) + + # Removing extension from file name to use as policy ID + file_name = os.path.split(file_path)[-1] + file_name_id = file_name.replace(".yaml", "").strip() + + # Check if file name and ID match + if not (file_name_id == policy_schema.id): + if self.logger: + self.logger.warning( + f"Policy file name '{file_name_id}' does not match policy ID '{policy_schema.id}'. " + f"Using file name as ID: {file_name_id}" + ) + # Use the file name as the ID if they don't match + policy_schema.id = file_name_id + + # Convert to domain objects + policy_id = EntityId(cast(uuid.UUID, file_name_id)) # Use filename as ID + + # Convert AutomationRuleSchema to AutomationRule entities + start_rules = self._load_and_sort_rules(policy_schema.start_rules) + + if len(start_rules) > 0: + if self.logger: + self.logger.debug(f"Successfully loaded {len(start_rules)} start rules from {file_path}") + else: + if self.logger: + self.logger.warning(f"No start rules found in {file_path}") + + stop_rules = self._load_and_sort_rules(policy_schema.stop_rules) + + if len(stop_rules) > 0: + if self.logger: + self.logger.debug(f"Successfully loaded {len(start_rules)} stop rules from {file_path}") + else: + if self.logger: + self.logger.warning(f"No stop rules found in {file_path}") + + # Create the OptimizationPolicy domain object + policy = OptimizationPolicy( + id=policy_id, + name=policy_schema.name, + description=policy_schema.description, + start_rules=start_rules, + stop_rules=stop_rules, + ) + + return policy, policy_schema.metadata + + except yaml.YAMLError as e: + if self.logger: + self.logger.error(f"YAML parsing error in {file_path}: {e}") + raise PolicyError(f"Invalid YAML in policy file {file_path}: {e}") from e + except ValueError as e: + if self.logger: + self.logger.error(f"Validation error in {file_path}: {e}") + raise PolicyConfigurationError(f"Policy validation error in {file_path}: {e}") from e + except Exception as e: + if self.logger: + self.logger.error(f"Unexpected error loading policy from {file_path}: {e}") + raise PolicyError(f"Failed to load policy from {file_path}: {e}") from e + + def _load_and_sort_rules(self, rule_schemas: List[AutomationRuleSchema]) -> List[AutomationRule]: + """Load rule schemas and sort rules by priority.""" + rules: List[AutomationRule] = [] + + if not rule_schemas: + return [] + + # Load the rules + for rule_schema in rule_schemas: + try: + # rule_schema.model_validate() # Validate the rule schema + rule = rule_schema.to_model() # Convert to domain model + rules.append(rule) + except ValidationError as e: + if self.logger: + self.logger.error( + f"Validation error in rule schema {rule_schema.id} | {rule_schema.name}: {e}. Skipping rule..." + ) + + # Sort by priority (highest first) + return sorted(rules, key=lambda r: r.priority, reverse=True) + + def _save_policy_to_file( + self, + policy: OptimizationPolicy, + metadata: Optional[MetadataSchema] = None, + ) -> None: + """Save a policy to a YAML file.""" + file_path = self._get_policy_file_path(policy.id) + + # Sort rules by priority before saving + policy.sort_rules() + + try: + # Convert OptimizationPolicy to OptimizationPolicySchema + policy_schema = self._policy_to_schema(policy) + + if metadata: + # Add metadata if provided + policy_schema.metadata = metadata + + # Convert schema to dict for YAML serialization + yaml_content = policy_schema.model_dump(exclude_none=True, exclude_unset=True) + + # Write to file + with open(file_path, "w", encoding="utf-8") as f: + yaml.dump( + yaml_content, + f, + Dumper=CustomDumper, + default_flow_style=False, + allow_unicode=True, + sort_keys=False, + indent=2, + width=1000, + default_style=None, + ) + + if self.logger: + self.logger.debug(f"Saved policy '{policy.name}' to {file_path}") + + except Exception as e: + if self.logger: + self.logger.error(f"Failed to save policy '{policy.name}' to {file_path}: {e}") + raise PolicyError(f"Failed to save policy to file: {e}") from e + + def _policy_to_schema(self, policy: OptimizationPolicy) -> OptimizationPolicySchema: + """Convert an OptimizationPolicy to OptimizationPolicySchema for YAML serialization.""" + try: + # Create OptimizationPolicySchema instance + return OptimizationPolicySchema.from_model(policy) + except Exception as e: + if self.logger: + self.logger.error(f"Error converting policy '{policy.name}' to schema: {e}") + raise PolicyError(f"Failed to convert policy to schema: {e}") from e + + # Repository interface implementation + + def add(self, policy: OptimizationPolicy) -> None: + """Add a policy to the YAML repository.""" + if self.logger: + self.logger.debug(f"Adding policy '{policy.name}' ({policy.id})") + + file_path = self._get_policy_file_path(policy.id) + + if file_path.exists(): + raise PolicyAlreadyExistsError(f"Policy with ID {policy.id} already exists") + + self._save_policy_to_file(policy) + + def get_by_id(self, policy_id: EntityId) -> Optional[OptimizationPolicy]: + """Get a policy by its ID.""" + if self.logger: + self.logger.debug(f"Getting policy {policy_id}") + + file_path = self._get_policy_file_path(policy_id) + policy, metadata = self._load_policy_from_file(file_path) + return policy + + def get_all(self) -> List[OptimizationPolicy]: + """Get all policies from the YAML repository.""" + if self.logger: + self.logger.debug("Getting all policies") + + policies: List[OptimizationPolicy] = [] + + if not self.policies_directory.exists(): + if self.logger: + self.logger.warning(f"Policies directory {self.policies_directory} does not exist") + return policies + + if self.logger: + self.logger.debug(f"Scanning policies directory: {self.policies_directory.resolve()}") + + try: + # Scan the policies directory for YAML files + for yaml_file in self.policies_directory.glob("*.yaml"): + if yaml_file.is_file(): + policy, metadata = self._load_policy_from_file(yaml_file) + if policy: + policies.append(policy) + except Exception as e: + if self.logger: + self.logger.error(f"Error scanning policy directory: {e}") + raise PolicyError(f"Failed to scan policies directory: {e}") from e + + return policies + + def update(self, policy: OptimizationPolicy) -> None: + """Update a policy in the YAML repository.""" + if self.logger: + self.logger.debug(f"Updating policy '{policy.name}' ({policy.id})") + + file_path = self._get_policy_file_path(policy.id) + + if not os.path.isfile(file_path): + raise PolicyNotFoundError(f"Policy with ID {policy.id} not found") + + # Get existing policy metadata and update last modified date and version + existing_policy, metadata = self._load_policy_from_file(file_path) + if metadata: + metadata = MetadataSchema( + author=metadata.author, + version=metadata.version, + created=metadata.created, + last_modified=metadata.last_modified, + ) + metadata.last_modified = datetime.now().strftime("%Y-%m-%d") + # Increment version + metadata.version = metadata.version + 1 if metadata.version else 1 + + self._save_policy_to_file(policy, metadata) + + def remove(self, policy_id: EntityId) -> None: + """Remove a policy from the YAML repository.""" + if self.logger: + self.logger.debug(f"Removing policy {policy_id}") + + file_path = self._get_policy_file_path(policy_id) + + if not file_path.exists(): + raise PolicyNotFoundError(f"Policy with ID {policy_id} not found") + + try: + file_path.unlink() + if self.logger: + self.logger.debug(f"Removed policy file: {file_path}") + except Exception as e: + if self.logger: + self.logger.error(f"Failed to remove policy file {file_path}: {e}") + raise PolicyError(f"Failed to remove policy file: {e}") from e + + +class SqlAlchemyOptimizationPolicyRepository(OptimizationPolicyRepository): + """SQLAlchemy implementation of the OptimizationPolicyRepository. + + This repository works directly with the imperatively mapped OptimizationPolicy domain entity. + The start_rules and stop_rules fields are automatically converted between List[AutomationRule] + and JSON strings by the custom TypeDecorator and event listener defined in tables.py. + + Args: + db: BaseSQLAlchemyRepository instance for database operations + """ + + def __init__(self, db: BaseSQLAlchemyRepository): + """Initialize repository with database instance. + + Args: + db: BaseSQLAlchemyRepository instance + """ + self._db = db + self.logger = db.logger + + self._policies_table = policies_table + + def add(self, policy: OptimizationPolicy) -> None: + """Add a new optimization policy to the repository. + + Args: + policy: Domain entity to persist + + Raises: + PolicyAlreadyExistsError: If a policy with the same ID or name already exists + PolicyError: For other database errors + """ + if self.logger: + self.logger.debug(f"Adding policy '{policy.name}' ({policy.id}) to SQLAlchemy.") + + session = self._db.get_session() + try: + session.add(policy) + session.commit() + except Exception as e: + session.rollback() + if "UNIQUE constraint failed" in str(e) or "already exists" in str(e): + if self.logger: + self.logger.error(f"Integrity error adding policy '{policy.name}': {e}") + raise PolicyAlreadyExistsError( + f"Policy with ID {policy.id} or name '{policy.name}' already exists" + ) from e + if self.logger: + self.logger.error(f"SQLAlchemy error adding policy '{policy.name}': {e}") + raise PolicyError(f"DB error adding policy: {e}") from e + finally: + session.close() + + def get_by_id(self, policy_id: EntityId) -> Optional[OptimizationPolicy]: + """Retrieve an optimization policy by its ID. + + Args: + policy_id: Unique identifier of the policy + + Returns: + Domain entity if found, None otherwise + """ + if self.logger: + self.logger.debug(f"Getting policy {policy_id} from SQLAlchemy.") + + session = self._db.get_session() + try: + stmt = select(OptimizationPolicy).where(self._policies_table.c.id == str(policy_id)) + entity = session.execute(stmt).scalar_one_or_none() + return entity + except Exception as e: + if self.logger: + self.logger.error(f"SQLAlchemy error getting policy {policy_id}: {e}") + return None + finally: + session.close() + + def get_all(self) -> List[OptimizationPolicy]: + """Retrieve all optimization policies from the repository. + + Returns: + List of all optimization policy domain entities + """ + if self.logger: + self.logger.debug("Getting all policies from SQLAlchemy.") + + session = self._db.get_session() + try: + stmt = select(OptimizationPolicy).order_by(self._policies_table.c.name) + entities = session.execute(stmt).scalars().all() + return list(entities) + except Exception as e: + if self.logger: + self.logger.error(f"SQLAlchemy error getting all policies: {e}") + return [] + finally: + session.close() + + def update(self, policy: OptimizationPolicy) -> None: + """Update an existing optimization policy in the repository. + + Args: + policy: Domain entity with updated state + + Raises: + PolicyNotFoundError: If no policy with the given ID exists + PolicyError: For constraint violations or other database errors + """ + if self.logger: + self.logger.debug(f"Updating policy '{policy.name}' ({policy.id}) in SQLAlchemy.") + + session = self._db.get_session() + try: + stmt = select(OptimizationPolicy).where(self._policies_table.c.id == str(policy.id)) + existing_policy = session.execute(stmt).scalar_one_or_none() + + if existing_policy: + # Update all fields from the new entity + existing_policy.name = policy.name + existing_policy.description = policy.description + existing_policy.start_rules = policy.start_rules + existing_policy.stop_rules = policy.stop_rules + + # SQLAlchemy's dirty tracking + TypeDecorator will handle serialization automatically + session.commit() + else: + raise PolicyNotFoundError(f"No policy found with ID {policy.id} for update.") + except PolicyNotFoundError: + raise + except Exception as e: + session.rollback() + if "UNIQUE constraint failed" in str(e) or "duplicate" in str(e).lower(): + if self.logger: + self.logger.error(f"Integrity error updating policy '{policy.name}': {e}") + raise PolicyError(f"Constraint error updating policy (duplicate name?): {e}") from e + if self.logger: + self.logger.error(f"SQLAlchemy error updating policy '{policy.name}': {e}") + raise PolicyError(f"DB error updating policy: {e}") from e + finally: + session.close() + + def remove(self, policy_id: EntityId) -> None: + """Remove an optimization policy from the repository. + + Args: + policy_id: Unique identifier of the policy to remove + + Raises: + PolicyNotFoundError: If no policy with the given ID exists + PolicyError: For other database errors + """ + if self.logger: + self.logger.debug(f"Removing policy {policy_id} from SQLAlchemy.") + + session = self._db.get_session() + try: + stmt = select(OptimizationPolicy).where(self._policies_table.c.id == str(policy_id)) + entity = session.execute(stmt).scalar_one_or_none() + + if entity: + session.delete(entity) + session.commit() + else: + raise PolicyNotFoundError(f"No policy found with ID {policy_id}.") + except PolicyNotFoundError: + raise + except Exception as e: + session.rollback() + if self.logger: + self.logger.error(f"SQLAlchemy error removing policy {policy_id}: {e}") + raise PolicyError(f"DB error removing policy: {e}") from e + finally: + session.close() diff --git a/core/edge_mining/adapters/domain/policy/schemas.py b/core/edge_mining/adapters/domain/policy/schemas.py new file mode 100644 index 0000000..fb8684b --- /dev/null +++ b/core/edge_mining/adapters/domain/policy/schemas.py @@ -0,0 +1,533 @@ +"""Validation schemas for optimization policies.""" + +import uuid +from datetime import datetime +from typing import List, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator, model_validator + +from edge_mining.adapters.domain.energy.schemas import EnergySourceSchema, EnergyStateSnapshotSchema +from edge_mining.adapters.domain.forecast.schemas import ForecastSchema, SunSchema +from edge_mining.adapters.domain.home_load.schemas import HomeLoadsConsumptionSchema +from edge_mining.adapters.domain.miner.schemas import MinerSchema, MinerStateSnapshotSchema +from edge_mining.adapters.domain.performance.schemas import MiningPerformanceSnapshotSchema +from edge_mining.adapters.domain.policy.utils import FieldStructureSchema, _extract_schema_structure +from edge_mining.domain.common import EntityId +from edge_mining.domain.policy.aggregate_roots import OptimizationPolicy +from edge_mining.domain.policy.common import OperatorType +from edge_mining.domain.policy.entities import AutomationRule +from edge_mining.domain.policy.exceptions import UnsupportedConditionError +from edge_mining.domain.policy.value_objects import DecisionalContext + + +class RuleConditionSchema(BaseModel): + """Single condition within a rule.""" + + field: str = Field(..., description="Field path in DecisionalContext (dot notation)") + operator: OperatorType = Field(..., description="Comparison operator") + value: Union[int, float, str, bool, List[Union[int, float, str]]] = Field( + ..., description="Value to compare against" + ) + + @field_validator("field") + @classmethod + def validate_field_path(cls, v): + """Validate that field path is not empty and contains valid characters.""" + if not v or not isinstance(v, str): + raise ValueError("Field path must be a non-empty string") + + # Basic validation - could be enhanced with actual field path checking + allowed_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._") + if not all(c in allowed_chars for c in v): + raise ValueError("Field path contains invalid characters") + + return v + + @field_validator("operator", mode="before") + @classmethod + def validate_operator(cls, v: Union[str, OperatorType]) -> OperatorType: + """Validate operator type.""" + if isinstance(v, str): + try: + return OperatorType(v.lower()) + except KeyError as e: + raise ValueError(f"Invalid operator: {v}. Must be one of {list(OperatorType)}") from e + elif isinstance(v, OperatorType): + return v + else: + raise ValueError("Operator must be a string or an OperatorType enum value") + + @field_serializer("operator") + def serialize_operator(self, operator: OperatorType) -> str: + """Serialize operator as string value.""" + return operator.value + + def to_model(self) -> dict: + """Convert schema to dict for domain model.""" + return self.model_dump() + + +class LogicalGroupSchema(BaseModel): + """Logical grouping of conditions (AND/OR).""" + + all_of: Optional[List[Union[RuleConditionSchema, "LogicalGroupSchema"]]] = Field( + None, description="All conditions must be true (AND logic)" + ) + any_of: Optional[List[Union[RuleConditionSchema, "LogicalGroupSchema"]]] = Field( + None, description="At least one condition must be true (OR logic)" + ) + not_: Optional[Union[RuleConditionSchema, "LogicalGroupSchema"]] = Field( + None, description="Negation of the condition/group" + ) + + @field_validator("all_of", "any_of") + @classmethod + def validate_logical_groups(cls, v: Optional[List]): + """Ensure at least one logical operator is specified.""" + if v is None: + return v + if not isinstance(v, List) or len(v) == 0: + raise ValueError("Logical group must be a non-empty list") + return v + + def to_model(self) -> dict: + """Convert schema to dict for domain model.""" + return self.model_dump(exclude_none=True, exclude_unset=True) + + +class AutomationRuleSchema(BaseModel): + """Schema for a single automation rule.""" + + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Unique identifier for the rule (auto-generated if not provided)", + ) + name: str = Field(..., description="Unique name for the rule") + description: Optional[str] = Field(None, description="Human-readable description") + conditions: Union[LogicalGroupSchema, RuleConditionSchema] = Field( + ..., description="Conditions that must be met for the rule to trigger" + ) + priority: int = Field( + default=0, + description="Rule priority (higher numbers = higher priority)", + ) + enabled: bool = Field(default=True, description="Whether the rule is active") + + @field_validator("id") + @classmethod + def validate_id(cls, v: str) -> str: + """Validate rule ID.""" + if not v or not isinstance(v, str) or len(v.strip()) == 0: + return str(uuid.uuid4()) + return v.strip() + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate rule name.""" + if not v or not isinstance(v, str) or len(v.strip()) == 0: + raise ValueError("Rule name must be a non-empty string") + return v.strip() + + @field_validator("conditions") + def validate_conditions( + cls, v: Union[LogicalGroupSchema, RuleConditionSchema] + ) -> Union[LogicalGroupSchema, RuleConditionSchema]: + """Ensure exactly one logical operator is specified.""" + if isinstance(v, LogicalGroupSchema): + operators = [v.all_of, v.any_of, v.not_] + non_none_count = sum(1 for op in operators if op is not None) + if non_none_count != 1: + raise ValueError("Exactly one logical operator (all_of, any_of, not_) must be specified") + return v + + @field_serializer("id") + def serialize_id(self, rule_id: str) -> str: + """Serialize rule ID as string.""" + return str(rule_id) + + @field_serializer("conditions") + def serialize_conditions(self, conditions: Union[LogicalGroupSchema, RuleConditionSchema]) -> dict: + """Serialize conditions.""" + if isinstance(conditions, LogicalGroupSchema): + return conditions.model_dump(exclude_none=True, exclude_unset=True) + else: + return conditions.model_dump() + + def to_model(self) -> AutomationRule: + """Convert schema to AutomationRule domain entity.""" + # Convert conditions to dict for domain model + conditions_dict = {} + if isinstance(self.conditions, (LogicalGroupSchema, RuleConditionSchema)): + conditions_dict = self.conditions.to_model() + + return AutomationRule( + id=EntityId(uuid.UUID(self.id)), + name=self.name, + description=self.description or "", + priority=self.priority, + enabled=self.enabled, + conditions=conditions_dict, + ) + + @classmethod + def from_model(cls, rule: AutomationRule) -> "AutomationRuleSchema": + """Convert domain model to schema.""" + return cls( + id=str(rule.id), + name=rule.name, + description=rule.description, + conditions=convert_conditions_to_schema(rule.conditions), + priority=rule.priority, + enabled=rule.enabled, + ) + + model_config = ConfigDict(from_attributes=True) + + +class MetadataSchema(BaseModel): + """Schema for optional metadata.""" + + author: Optional[str] = Field(None, description="Author of the policy") + version: Optional[int] = Field(None, description="Version of the policy schema") + created: Optional[str] = Field(None, description="Creation date") + last_modified: Optional[str] = Field(None, description="Last modified date") + + +class OptimizationPolicySchema(BaseModel): + """Schema for an optimization policy.""" + + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Unique identifier for the policy (auto-generated if not provided)", + ) + name: str = Field(..., description="Policy name") + description: Optional[str] = Field(None, description="Policy description") + + # Rules embedded directly in the policy file + start_rules: List[AutomationRuleSchema] = Field(default_factory=list, description="Rules for starting mining") + stop_rules: List[AutomationRuleSchema] = Field(default_factory=list, description="Rules for stopping mining") + + # Metadata + metadata: Optional[MetadataSchema] = Field( + default_factory=lambda: MetadataSchema( + author="Edge Mining User", + version=1, + created=datetime.now().strftime("%Y-%m-%d"), + last_modified=datetime.now().strftime("%Y-%m-%d"), + ) + ) + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate policy name.""" + if not v or not isinstance(v, str) or len(v.strip()) == 0: + raise ValueError("Policy name must be a non-empty string") + return v.strip() + + @field_validator("id") + @classmethod + def validate_id(cls, v: str) -> str: + """Validate policy ID.""" + if not v or not isinstance(v, str) or len(v.strip()) == 0: + return str(uuid.uuid4()) + return v.strip() + + @field_serializer("id") + def serialize_id(self, policy_id: str) -> str: + """Serialize policy ID as string.""" + return str(policy_id) + + @field_validator("start_rules", "stop_rules") + @classmethod + def validate_rule_ids_unique(cls, v: List[AutomationRuleSchema]) -> List[AutomationRuleSchema]: + """Ensure rule ids are unique within each rule type.""" + if not v: + return v + ids = [rule.id for rule in v] + if len(ids) != len(set(ids)): + duplicates = [id for id in ids if ids.count(id) > 1] + raise ValueError(f"Duplicate rule id found: {duplicates}") + return v + + @model_validator(mode="after") + def validate_overall_rule_ids(self) -> "OptimizationPolicySchema": + """Ensure rule ids are unique across all rules in the policy.""" + all_rule_ids = [] + all_rule_ids.extend([rule.id for rule in self.start_rules]) + all_rule_ids.extend([rule.id for rule in self.stop_rules]) + + if len(all_rule_ids) != len(set(all_rule_ids)): + duplicates = [id for id in all_rule_ids if all_rule_ids.count(id) > 1] + raise ValueError(f"Duplicate rule ids found across start and stop rules: {duplicates}") + + return self + + def to_model(self) -> OptimizationPolicy: + """Convert schema to OptimizationPolicy domain aggregate root.""" + return OptimizationPolicy( + id=EntityId(uuid.UUID(self.id)), + name=self.name, + description=self.description, + start_rules=[rule.to_model() for rule in self.start_rules], + stop_rules=[rule.to_model() for rule in self.stop_rules], + ) + + @classmethod + def from_model( + cls, policy: OptimizationPolicy, metadata: Optional[MetadataSchema] = None + ) -> "OptimizationPolicySchema": + """Create schema from OptimizationPolicy domain aggregate root.""" + start_rules: List[AutomationRuleSchema] = [AutomationRuleSchema.from_model(rule) for rule in policy.start_rules] + stop_rules: List[AutomationRuleSchema] = [AutomationRuleSchema.from_model(rule) for rule in policy.stop_rules] + + return cls( + id=str(policy.id), + name=policy.name, + description=policy.description, + start_rules=start_rules, + stop_rules=stop_rules, + metadata=metadata + or MetadataSchema( + author="Edge Mining User", + version=1, + created=datetime.now().strftime("%Y-%m-%d"), + last_modified=datetime.now().strftime("%Y-%m-%d"), + ), + ) + + model_config = ConfigDict(from_attributes=True) + + +class AutomationRuleCreateSchema(BaseModel): + """Schema for creating a new automation rule.""" + + name: str = Field(..., description="Unique name for the rule") + description: Optional[str] = Field(None, description="Human-readable description") + conditions: Union[LogicalGroupSchema, RuleConditionSchema] = Field( + ..., description="Conditions that must be met for the rule to trigger" + ) + priority: int = Field( + default=0, + description="Rule priority (higher numbers = higher priority)", + ) + enabled: bool = Field(default=True, description="Whether the rule is active") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate rule name.""" + if not v or not isinstance(v, str) or len(v.strip()) == 0: + raise ValueError("Rule name must be a non-empty string") + return v.strip() + + def to_model(self) -> AutomationRule: + """Convert create schema to AutomationRule domain entity.""" + # Convert conditions to dict for domain model + conditions_dict = {} + if isinstance(self.conditions, (LogicalGroupSchema, RuleConditionSchema)): + conditions_dict = self.conditions.to_model() + + return AutomationRule( + id=EntityId(uuid.uuid4()), # Generate new ID for create + name=self.name, + description=self.description or "", + priority=self.priority, + enabled=self.enabled, + conditions=conditions_dict, + ) + + +class OptimizationPolicyCreateSchema(BaseModel): + """Schema for creating a new optimization policy.""" + + name: str = Field(..., description="Policy name") + description: Optional[str] = Field(None, description="Policy description") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate policy name.""" + if not v or not isinstance(v, str) or len(v.strip()) == 0: + raise ValueError("Policy name must be a non-empty string") + return v.strip() + + def to_model(self) -> OptimizationPolicy: + """Convert schema to OptimizationPolicy domain aggregate root.""" + return OptimizationPolicy(id=EntityId(uuid.uuid4()), name=self.name, description=self.description) + + +class OptimizationPolicyUpdateSchema(BaseModel): + """Schema for updating an existing optimization policy.""" + + name: Optional[str] = Field(None, description="Policy name") + description: Optional[str] = Field(None, description="Policy description") + + @field_validator("name") + @classmethod + def validate_name(cls, v: Optional[str]) -> Optional[str]: + """Validate policy name.""" + if v is not None: + if not isinstance(v, str) or len(v.strip()) == 0: + raise ValueError("Policy name must be a non-empty string") + return v.strip() + return v + + +class AutomationRuleUpdateSchema(BaseModel): + """Schema for updating an existing automation rule.""" + + name: Optional[str] = Field(None, description="Unique name for the rule") + description: Optional[str] = Field(None, description="Human-readable description") + conditions: Optional[Union[LogicalGroupSchema, RuleConditionSchema]] = Field( + None, description="Conditions that must be met for the rule to trigger" + ) + priority: Optional[int] = Field( + None, + description="Rule priority (higher numbers = higher priority)", + ) + enabled: Optional[bool] = Field(None, description="Whether the rule is active") + + @field_validator("name") + @classmethod + def validate_name(cls, v: Optional[str]) -> Optional[str]: + """Validate rule name.""" + if v is not None: + if not isinstance(v, str) or len(v.strip()) == 0: + raise ValueError("Rule name must be a non-empty string") + return v.strip() + return v + + +class PolicyCheckSchema(BaseModel): + """Schema for policy validation check.""" + + valid: bool = Field(..., description="Whether the policy is valid and can be used") + policy_id: str = Field(..., description="ID of the checked policy") + policy_name: Optional[str] = Field(None, description="Name of the checked policy") + errors: List[str] = Field(default_factory=list, description="List of validation errors found") + warnings: List[str] = Field(default_factory=list, description="List of validation warnings") + start_rules_count: int = Field(default=0, description="Number of start rules in the policy") + stop_rules_count: int = Field(default=0, description="Number of stop rules in the policy") + enabled_start_rules_count: int = Field(default=0, description="Number of enabled start rules") + enabled_stop_rules_count: int = Field(default=0, description="Number of enabled stop rules") + + @field_serializer("policy_id") + def serialize_policy_id(self, policy_id: str) -> str: + """Serialize policy ID as string.""" + return str(policy_id) + + model_config = ConfigDict(from_attributes=True) + + +# Helper methods for converting from domain models to schemas +def convert_conditions_to_schema(conditions: dict) -> Union[LogicalGroupSchema, RuleConditionSchema]: + """Recursively convert conditions dict to appropriate schema.""" + # Check if conditions are a logical group or a single rule condition + if isinstance(conditions, dict): + conditions_dict_keys = set(conditions.keys()) + logical_group_keys = set(LogicalGroupSchema.model_fields.keys()) + rule_condition_keys = set(RuleConditionSchema.model_fields.keys()) + + # Check if any key from conditions matches LogicalGroupSchema keys + if conditions_dict_keys.intersection(logical_group_keys): + # It's a logical group - create instance with only the matching fields + logical_group_data = {k: v for k, v in conditions.items() if k in logical_group_keys and v is not None} + return LogicalGroupSchema(**logical_group_data) + elif conditions_dict_keys.intersection(rule_condition_keys): + # It's a single rule condition - create instance with only the matching fields + rule_condition_data = {k: v for k, v in conditions.items() if k in rule_condition_keys} + return RuleConditionSchema(**rule_condition_data) + else: + # It's an unknown format, raise an error + raise UnsupportedConditionError(f"Invalid conditions format: {conditions}") + else: + # If conditions is not a dict, raise an error + raise UnsupportedConditionError(f"Expected conditions to be a dict, got {type(conditions)}") + + +# Update forward references +LogicalGroupSchema.model_rebuild() + + +class DecisionalContextStructureSchema(BaseModel): + """Schema representing the complete structure of DecisionalContext.""" + + fields: List[FieldStructureSchema] = Field(..., description="List of all fields in the decisional context") + total_fields: int = Field(..., description="Total number of fields (including nested)") + + +class DecisionalContextSchema(BaseModel): + """Schema for DecisionalContext value object.""" + + energy_source: Optional[EnergySourceSchema] = Field(None, description="Energy source information") + energy_state: Optional[EnergyStateSnapshotSchema] = Field(None, description="Current energy state snapshot") + forecast: Optional[ForecastSchema] = Field(None, description="Energy production forecast") + home_load: Optional[HomeLoadsConsumptionSchema] = Field( + None, description="Household consumption (per-device history + forecast + totals)" + ) + mining_performance: Optional[MiningPerformanceSnapshotSchema] = Field( + None, description="Consolidated mining performance snapshot from the pool" + ) + sun: Optional[SunSchema] = Field(None, description="Sun position and timing information") + miner: Optional[MinerSchema] = Field(None, description="Miner static configuration") + miner_state: Optional[MinerStateSnapshotSchema] = Field(None, description="Miner runtime state snapshot") + timestamp: datetime = Field(default_factory=datetime.now, description="Timestamp of this decisional context") + + @staticmethod + def get_structure() -> DecisionalContextStructureSchema: + """ + Generate the complete structure of DecisionalContext with all nested fields. + + Returns: + DecisionalContextStructureSchema with all fields and their types + """ + fields = _extract_schema_structure(DecisionalContextSchema) + + # Count total fields including nested + def count_fields(field_list: List[FieldStructureSchema]) -> int: + count = len(field_list) + for field in field_list: + if field.children: + count += count_fields(field.children) + return count + + total = count_fields(fields) + + return DecisionalContextStructureSchema(fields=fields, total_fields=total) + + @classmethod + def from_model(cls, context: DecisionalContext) -> "DecisionalContextSchema": + """Create schema from DecisionalContext value object.""" + return cls( + energy_source=EnergySourceSchema.from_model(context.energy_source) if context.energy_source else None, + energy_state=EnergyStateSnapshotSchema.from_model(context.energy_state) if context.energy_state else None, + forecast=ForecastSchema.from_model(context.forecast) if context.forecast else None, + home_load=(HomeLoadsConsumptionSchema.from_model(context.home_load) if context.home_load else None), + mining_performance=( + MiningPerformanceSnapshotSchema.from_model(context.mining_performance) + if context.mining_performance + else None + ), + sun=SunSchema.from_model(context.sun) if context.sun else None, + miner=MinerSchema.from_model(context.miner) if context.miner else None, + miner_state=(MinerStateSnapshotSchema.from_model(context.miner_state) if context.miner_state else None), + timestamp=context.timestamp, + ) + + def to_model(self) -> DecisionalContext: + """Convert schema to DecisionalContext value object.""" + return DecisionalContext( + energy_source=self.energy_source.to_model() if self.energy_source else None, + energy_state=self.energy_state.to_model() if self.energy_state else None, + forecast=self.forecast.to_model() if self.forecast else None, + home_load=self.home_load.to_model() if self.home_load else None, + mining_performance=(self.mining_performance.to_model() if self.mining_performance else None), + sun=self.sun.to_model() if self.sun else None, + miner=self.miner.to_model() if self.miner else None, + miner_state=self.miner_state.to_model() if self.miner_state else None, + timestamp=self.timestamp, + ) + + model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True) diff --git a/core/edge_mining/adapters/domain/policy/tables.py b/core/edge_mining/adapters/domain/policy/tables.py new file mode 100644 index 0000000..0304cc4 --- /dev/null +++ b/core/edge_mining/adapters/domain/policy/tables.py @@ -0,0 +1,146 @@ +"""SQLAlchemy ORM mappings for Policy domain entities. + +This module implements imperative (classical) mapping of the domain entities +to database tables. The domain entities are mapped directly without +creating separate ORM model classes, maintaining domain purity. + +All tables and mappings use the shared metadata and mapper registry from +the sqlalchemy.registry module, which are available as module-level singletons. + +⚠️ DEVELOPER WARNING ⚠️ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +ANY SCHEMA CHANGE (adding/removing/modifying tables or columns) REQUIRES an +Alembic migration. Do NOT modify this file without creating a migration: + + python scripts/migrate.py create "Description of your change" + +For detailed instructions, see: ../docs/ALEMBIC_MIGRATIONS.md +For a step-by-step example, see: ../docs/MIGRATION_EXAMPLE.md +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +""" + +import json +from typing import Any, Dict, List, Optional + +from sqlalchemy import Column, String, Table, TypeDecorator, event + +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.registry import mapper_registry, metadata +from edge_mining.domain.policy.aggregate_roots import AutomationRule, OptimizationPolicy + + +class AutomationRulesListType(TypeDecorator): + """Custom SQLAlchemy type that converts List[AutomationRule] to/from JSON string. + + This type handles serialization when writing to the database. + Deserialization is handled by the @event.listens_for decorator on the entity. + """ + + impl = String + cache_ok = True + + def process_bind_param(self, value: Optional[List[AutomationRule]], dialect) -> Optional[str]: + """Convert List[AutomationRule] to JSON string before storing in DB. + + Args: + value: List of AutomationRule instances or None + dialect: SQLAlchemy dialect + + Returns: + JSON string representation or None + """ + if value is None: + return None + + # Serialize rules to JSON + rules_data = [] + for rule in value: + rule_dict = { + "id": str(rule.id), + "name": rule.name, + "description": rule.description, + "priority": rule.priority, + "enabled": rule.enabled, + "conditions": rule.conditions, + } + rules_data.append(rule_dict) + + return json.dumps(rules_data) + + def process_result_value(self, value: Optional[str], dialect) -> Optional[str]: + """Return the JSON string as-is. Actual deserialization happens in the event listener. + + Args: + value: JSON string from database or None + dialect: SQLAlchemy dialect + + Returns: + JSON string or None (will be converted to List[AutomationRule] by event listener) + """ + return value # Return as string, event listener will convert + + +def _deserialize_automation_rules(rules_json: Optional[str]) -> List[AutomationRule]: + """Deserialize JSON string to List[AutomationRule]. + + Args: + rules_json: JSON string representation of rules + + Returns: + List of AutomationRule instances + """ + if not rules_json: + return [] + + rules_data: List[Dict[str, Any]] = json.loads(rules_json) + rules = [] + + for rule_data in rules_data: + rule = AutomationRule( + id=rule_data["id"], # EntityId will handle UUID conversion + priority=rule_data["priority"], + name=rule_data["name"], + description=rule_data["description"], + enabled=rule_data["enabled"], + conditions=rule_data["conditions"], + ) + rules.append(rule) + + return rules + + +@event.listens_for(OptimizationPolicy, "load") +def _receive_optimization_policy_load(target: OptimizationPolicy, context) -> None: + """Event listener that deserializes rules after loading from database. + + Args: + target: The OptimizationPolicy instance being loaded + context: SQLAlchemy context + """ + # During load, SQLAlchemy may pass the JSON string before type conversion + # We need to check at runtime and convert if necessary + if target.start_rules and isinstance(target.start_rules, str): # type: ignore + target.start_rules = _deserialize_automation_rules(target.start_rules) # type: ignore + + if target.stop_rules and isinstance(target.stop_rules, str): # type: ignore + target.stop_rules = _deserialize_automation_rules(target.stop_rules) # type: ignore + + +# Define the policies table using imperative style +policies_table = Table( + "policies", + metadata, + # Primary Key + Column("id", String, primary_key=True, index=True), + # Basic attributes + Column("name", String, nullable=False, unique=True), + Column("description", String, nullable=True), + # Rules stored as JSON strings with automatic conversion + Column("start_rules", AutomationRulesListType, nullable=True), + Column("stop_rules", AutomationRulesListType, nullable=True), +) + +# Map OptimizationPolicy to the policies table +mapper_registry.map_imperatively( + OptimizationPolicy, + policies_table, +) diff --git a/core/edge_mining/adapters/domain/policy/utils.py b/core/edge_mining/adapters/domain/policy/utils.py new file mode 100644 index 0000000..0c8c5e4 --- /dev/null +++ b/core/edge_mining/adapters/domain/policy/utils.py @@ -0,0 +1,341 @@ +"""Utility functions for policy domain adapters.""" + +from datetime import date, datetime, time +from enum import Enum +from typing import Any, List, Optional, Union, get_args, get_origin + +from pydantic import BaseModel, Field + +# Built-in types with their accessible properties +_BUILTIN_TYPE_PROPERTIES = { + datetime: { + "year": ("int", "Year (e.g., 2024)"), + "month": ("int", "Month (1-12)"), + "day": ("int", "Day of the month (1-31)"), + "hour": ("int", "Hour (0-23)"), + "minute": ("int", "Minute (0-59)"), + "second": ("int", "Second (0-59)"), + "microsecond": ("int", "Microsecond (0-999999)"), + "weekday": ("int", "Day of the week (0=Monday, 6=Sunday)"), + "isoweekday": ("int", "ISO day of the week (1=Monday, 7=Sunday)"), + }, + date: { + "year": ("int", "Year (e.g., 2024)"), + "month": ("int", "Month (1-12)"), + "day": ("int", "Day of the month (1-31)"), + "weekday": ("int", "Day of the week (0=Monday, 6=Sunday)"), + "isoweekday": ("int", "ISO day of the week (1=Monday, 7=Sunday)"), + }, + time: { + "hour": ("int", "Hour (0-23)"), + "minute": ("int", "Minute (0-59)"), + "second": ("int", "Second (0-59)"), + "microsecond": ("int", "Microsecond (0-999999)"), + }, +} + + +class FieldStructureSchema(BaseModel): + """Schema representing a single field in the decisional context structure.""" + + path: str = Field(..., description="Full path in dot notation (e.g., 'energy_state.battery.state_of_charge')") + type: str = Field(..., description="Type of the field (e.g., 'float', 'str', 'datetime')") + description: Optional[str] = Field(None, description="Description of the field") + is_optional: bool = Field(default=False, description="Whether the field is optional (can be None)") + values: Optional[List[str]] = Field(None, description="Possible values") + children: Optional[List["FieldStructureSchema"]] = Field( + None, description="Nested fields if this is a complex type" + ) + + +# Update forward references for recursive schema +FieldStructureSchema.model_rebuild() + + +def _extract_schema_structure( + schema_class: type[BaseModel], prefix: str = "", visited: Optional[set] = None +) -> List[FieldStructureSchema]: + """ + Recursively extract structure from a Pydantic schema. + + Args: + schema_class: The Pydantic schema class to extract from + prefix: Current path prefix in dot notation + visited: Set of already visited schema classes to avoid infinite recursion + + Returns: + List of FieldStructureSchema representing the schema structure + """ + if visited is None: + visited = set() + + # Avoid infinite recursion + schema_id = id(schema_class) + if schema_id in visited: + return [] + + visited.add(schema_id) + + fields_structure = [] + + # Get all fields from the schema + if hasattr(schema_class, "model_fields"): + for field_name, field_info in schema_class.model_fields.items(): + # Skip 'id' fields from the structure + if field_name == "id": + continue + + # Skip fields that are references to other entities (ending with _id) + if field_name.endswith("_id"): + continue + + # Skip 'timestamp' and other datetime fields in nested schemas (but not at root level) + if prefix and field_name in ("timestamp", "created_at", "updated_at", "last_modified"): + continue + + # Build the full path + full_path = f"{prefix}.{field_name}" if prefix else field_name + + # Get type information + field_type = field_info.annotation + + # Check if this is a nested schema (BaseModel subclass) + children = None + inner_type: Any = field_type + + # Handle Optional types to get the inner type + origin = get_origin(field_type) + if origin is Union: + args = get_args(field_type) + non_none_args = [arg for arg in args if arg is not type(None)] + if len(non_none_args) == 1: + inner_type = non_none_args[0] + + type_name, is_optional, values = _get_field_type_name(field_type) + + # Get description from Field + description = field_info.description + + # Check if the type is a built-in type with known properties + builtin_children = _get_builtin_type_children(inner_type, full_path) + if builtin_children: + children = builtin_children + # Check if inner_type is a List and extract element type + else: + inner_origin = get_origin(inner_type) + if inner_origin is list: + list_args = get_args(inner_type) + if list_args and isinstance(list_args[0], type) and issubclass(list_args[0], BaseModel): + # Extract structure from list element type with array notation + # Use .0, .1, etc. to indicate array element access + array_path = f"{full_path}.0" + children = _extract_schema_structure(list_args[0], array_path, visited.copy()) + else: + children = None + # Recursively process nested BaseModel schemas (non-list) + elif isinstance(inner_type, type) and issubclass(inner_type, BaseModel): + children = _extract_schema_structure(inner_type, full_path, visited.copy()) + else: + children = None + + field_struct = FieldStructureSchema( + path=full_path, + type=type_name, + description=description, + is_optional=is_optional, + values=values, + children=children if children else None, + ) + + fields_structure.append(field_struct) + + # Extract @computed_field decorated properties (Pydantic v2) + # Check if the schema has model_computed_fields (Pydantic v2 computed fields) + if hasattr(schema_class, "model_computed_fields"): + for field_name, computed_info in schema_class.model_computed_fields.items(): + # Build the full path + full_path = f"{prefix}.{field_name}" if prefix else field_name + + # Try to infer type from computed field annotations + property_type = "Any" + is_optional = False + + # Get the property wrapper + if hasattr(computed_info, "wrapped_property"): + prop = computed_info.wrapped_property + if hasattr(prop.fget, "__annotations__") and "return" in prop.fget.__annotations__: + return_type = prop.fget.__annotations__["return"] + property_type, is_optional, _ = _get_field_type_name(return_type) + + # Get docstring as description + description = prop.fget.__doc__.strip() if prop.fget and prop.fget.__doc__ else None + else: + description = None + + field_struct = FieldStructureSchema( + path=full_path, + type=property_type, + description=description, + is_optional=is_optional, + values=None, + children=None, + ) + + fields_structure.append(field_struct) + + # Also extract @property decorated methods (for backwards compatibility) + # Look for properties in the original domain class if the schema has a model_config + elif hasattr(schema_class, "model_config") and hasattr(schema_class.model_config, "from_attributes"): + # Try to find properties by inspecting the class + for attr_name in dir(schema_class): + if attr_name.startswith("_") or attr_name in ["model_fields", "model_config"]: + continue + + try: + attr = getattr(schema_class, attr_name) + if isinstance(attr, property): + # Build the full path + full_path = f"{prefix}.{attr_name}" if prefix else attr_name + + # Try to infer type from property getter annotations if available + property_type = "Any" + is_optional = False + if hasattr(attr.fget, "__annotations__") and "return" in attr.fget.__annotations__: + return_type = attr.fget.__annotations__["return"] + property_type, is_optional, _ = _get_field_type_name(return_type) + + # Get docstring as description + description = attr.fget.__doc__.strip() if attr.fget and attr.fget.__doc__ else None + + field_struct = FieldStructureSchema( + path=full_path, + type=property_type, + description=description, + is_optional=is_optional, + values=None, + children=None, + ) + + fields_structure.append(field_struct) + except (AttributeError, TypeError): + # Skip if we can't access the attribute + continue + + return fields_structure + + +def _get_builtin_type_children(field_type: Any, parent_path: str) -> Optional[List[FieldStructureSchema]]: + """ + Extract child properties for built-in types like datetime, date, time. + + Args: + field_type: The field type to check + parent_path: The parent path for building full paths + + Returns: + List of FieldStructureSchema if the type has known properties, None otherwise + """ + # Handle Optional types to get the inner type + origin = get_origin(field_type) + if origin is Union: + args = get_args(field_type) + non_none_args = [arg for arg in args if arg is not type(None)] + if len(non_none_args) == 1: + field_type = non_none_args[0] + + # Check if this type is in our built-in types mapping + for builtin_type, properties in _BUILTIN_TYPE_PROPERTIES.items(): + if field_type is builtin_type or (isinstance(field_type, type) and issubclass(field_type, builtin_type)): + children = [] + for prop_name, (prop_type, prop_desc) in properties.items(): + child_path = f"{parent_path}.{prop_name}" + children.append( + FieldStructureSchema( + path=child_path, + type=prop_type, + description=prop_desc, + is_optional=False, + values=None, + children=None, + ) + ) + return children + + return None + + +def _get_field_type_name(field_type: Any) -> tuple[str, bool, Optional[List[str]]]: + """ + Extract a readable type name from a field type annotation. + + Returns: + Tuple of (type_name, is_optional, values) + - type_name: The base type without Optional wrapper + - is_optional: Whether the field is Optional + - values: List of possible values if it's an Enum, None otherwise + """ + is_optional = False + values = None + + # Handle Optional types + origin = get_origin(field_type) + if origin is Union: + args = get_args(field_type) + # Filter out NoneType to get the actual type for Optional + non_none_args = [arg for arg in args if arg is not type(None)] + if len(non_none_args) == 1 and type(None) in args: + # This is Optional[T] + is_optional = True + field_type = non_none_args[0] + origin = get_origin(field_type) + elif len(args) > 1: + # Multiple types in Union, not just Optional + type_names = [_get_field_type_name(arg)[0] for arg in non_none_args] + return f"Union[{', '.join(type_names)}]", is_optional, None + + # Re-check origin after unwrapping Optional + if origin is list: + args = get_args(field_type) + if args: + inner_name, _, _ = _get_field_type_name(args[0]) + return f"List[{inner_name}]", is_optional, None + elif origin is dict: + args = get_args(field_type) + if args and len(args) >= 2: + key_type, _, _ = _get_field_type_name(args[0]) + value_type, _, _ = _get_field_type_name(args[1]) + return f"Dict[{key_type}, {value_type}]", is_optional, None + return "Dict", is_optional, None + elif origin: + return str(field_type), is_optional, None + + # Check if it's an Enum + if isinstance(field_type, type) and issubclass(field_type, Enum): + values = [item.value for item in field_type] + # Determine the base type from the enum values + if values: + first_value = values[0] + if isinstance(first_value, str): + type_name = "str" + elif isinstance(first_value, int): + type_name = "int" + elif isinstance(first_value, float): + type_name = "float" + elif isinstance(first_value, bool): + type_name = "bool" + else: + type_name = type(first_value).__name__ + else: + type_name = "str" # Default to str if no values + return type_name, is_optional, values + + # Handle BaseModel subclasses (return class name without "Schema" suffix for clarity) + type_name_attr = getattr(field_type, "__name__", None) + if type_name_attr: + type_name = str(type_name_attr) + # Remove "Schema" suffix if present for cleaner output + if type_name.endswith("Schema"): + type_name = type_name[:-6] + return type_name, is_optional, None + + return str(field_type), is_optional, None diff --git a/core/edge_mining/adapters/domain/policy/websocket/__init__.py b/core/edge_mining/adapters/domain/policy/websocket/__init__.py new file mode 100644 index 0000000..ea634b6 --- /dev/null +++ b/core/edge_mining/adapters/domain/policy/websocket/__init__.py @@ -0,0 +1 @@ +"""WebSocket adapter for the Policy domain.""" diff --git a/core/edge_mining/adapters/domain/policy/websocket/handlers.py b/core/edge_mining/adapters/domain/policy/websocket/handlers.py new file mode 100644 index 0000000..3eaa32a --- /dev/null +++ b/core/edge_mining/adapters/domain/policy/websocket/handlers.py @@ -0,0 +1,36 @@ +"""WebSocket event handler for the Policy domain.""" + +from typing import Any, List + +from edge_mining.adapters.domain.policy.schemas import DecisionalContextSchema +from edge_mining.adapters.domain.policy.websocket.schemas import DecisionalContextUpdatedSchema +from edge_mining.adapters.infrastructure.websocket.utils import ( + WebSocketEventHandler, + WebSocketEventRegistration, +) +from edge_mining.domain.common import DomainEvent +from edge_mining.domain.policy.events import DecisionalContextUpdatedEvent + + +class PolicyWebSocketHandler(WebSocketEventHandler): + """Serializes Policy domain events for WebSocket broadcasting.""" + + @property + def registrations(self) -> List[WebSocketEventRegistration]: + return [ + WebSocketEventRegistration( + event_type=DecisionalContextUpdatedEvent, + topic="policy.context", + serialize=self._serialize_decisional_context_updated, + ), + ] + + def _serialize_decisional_context_updated(self, event: DomainEvent) -> dict[str, Any]: + assert isinstance(event, DecisionalContextUpdatedEvent) + payload = DecisionalContextUpdatedSchema( + optimization_unit_id=str(event.optimization_unit_id) if event.optimization_unit_id else None, + optimization_unit_name=event.optimization_unit_name, + context=(DecisionalContextSchema.from_model(event.context) if event.context else None), + target_miner_ids=[str(mid) for mid in event.target_miner_ids], + ) + return payload.model_dump(mode="json") diff --git a/core/edge_mining/adapters/domain/policy/websocket/schemas.py b/core/edge_mining/adapters/domain/policy/websocket/schemas.py new file mode 100644 index 0000000..8cb8ef3 --- /dev/null +++ b/core/edge_mining/adapters/domain/policy/websocket/schemas.py @@ -0,0 +1,16 @@ +"""WebSocket event schemas for the Policy domain.""" + +from typing import List, Optional + +from pydantic import BaseModel, Field + +from edge_mining.adapters.domain.policy.schemas import DecisionalContextSchema + + +class DecisionalContextUpdatedSchema(BaseModel): + """WebSocket schema for DecisionalContextUpdatedEvent.""" + + optimization_unit_id: Optional[str] = Field(None, description="ID of the optimization unit") + optimization_unit_name: str = Field(default="", description="Name of the optimization unit") + context: Optional[DecisionalContextSchema] = Field(None, description="Decisional context data") + target_miner_ids: List[str] = Field(default_factory=list, description="IDs of target miners") diff --git a/core/edge_mining/adapters/domain/policy/yaml/__init__.py b/core/edge_mining/adapters/domain/policy/yaml/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/adapters/domain/policy/yaml/utils.py b/core/edge_mining/adapters/domain/policy/yaml/utils.py new file mode 100644 index 0000000..e5458ea --- /dev/null +++ b/core/edge_mining/adapters/domain/policy/yaml/utils.py @@ -0,0 +1,30 @@ +"""Collection of utility functions for YAML handling in policy management.""" + +from yaml import SafeDumper + + +class CustomDumper(SafeDumper): + """Custom YAML dumper for better formatting.""" + + def increase_indent(self, flow=False, indentless=False): + """ + Forces array elements to be indented correctly, causing them to always be + indented relative to their parent container, rather than aligned with the + root element. + """ + return super(CustomDumper, self).increase_indent(flow, False) + + def represent_list(self, data): + """ + Checks whether a list contains only primitive values (int, float, str) + and in that case uses the flow style (square brackets), otherwise + uses the normal block style. + """ + # Check if this list should be in flow style (for value arrays) + if len(data) > 0 and all(isinstance(item, (int, float, str)) for item in data): + return self.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=True) + return self.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=False) + + +# Add the custom list representer +CustomDumper.add_representer(list, CustomDumper.represent_list) diff --git a/core/edge_mining/adapters/domain/user/__init__.py b/core/edge_mining/adapters/domain/user/__init__.py new file mode 100644 index 0000000..94fc66f --- /dev/null +++ b/core/edge_mining/adapters/domain/user/__init__.py @@ -0,0 +1 @@ +"""Adapters for the User Settings domain.""" diff --git a/core/edge_mining/adapters/domain/user/repositories.py b/core/edge_mining/adapters/domain/user/repositories.py new file mode 100644 index 0000000..2aa4236 --- /dev/null +++ b/core/edge_mining/adapters/domain/user/repositories.py @@ -0,0 +1,177 @@ +""" +This module contains the adapter classes implementing the SettingsRepository interface. +""" + +import copy +import json +import sqlite3 +from typing import Dict, Optional + +from sqlalchemy import select + +from edge_mining.adapters.domain.user.tables import settings_table +from edge_mining.adapters.infrastructure.persistence.sqlite import BaseSqliteRepository +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.domain.exceptions import ConfigurationError +from edge_mining.domain.user.common import UserId +from edge_mining.domain.user.entities import SystemSettings +from edge_mining.shared.settings.ports import SettingsRepository + +# Simple In-Memory implementation for testing and basic use + + +class InMemorySettingsRepository(SettingsRepository): + """In-Memory implementation of the SettingsRepository.""" + + # We dont have different users, so we use a single ID. + _SETTINGS_ID: str = "global_settings" + + def __init__(self, initial_settings: Optional[Dict[UserId, SystemSettings]] = None): + self._settings: Dict[UserId, SystemSettings] = copy.deepcopy(initial_settings) if initial_settings else {} + + def get_settings(self, user_id: Optional[UserId]) -> Optional[SystemSettings]: + user_id = user_id or UserId(self._SETTINGS_ID) + if user_id in self._settings: + return copy.deepcopy(self._settings[user_id]) + return None + + def save_settings(self, user_id: Optional[UserId], settings: SystemSettings) -> None: + user_id = user_id or UserId(self._SETTINGS_ID) + self._settings[user_id] = copy.deepcopy(settings) + + +class SqliteSettingsRepository(SettingsRepository): + """SQLite implementation of the SettingsRepository.""" + + # We dont have different users, so we use a single ID. + _SETTINGS_ID: str = "global_settings" + + def __init__(self, db: BaseSqliteRepository): + self._db = db + self.logger = db.logger + + self._create_tables() + + def _create_tables(self): + """Create the necessary tables for the Settings domain if they do not exist.""" + self.logger.debug(f"Ensuring SQLite tables exist for Settings Repository in {self._db.db_path}...") + sql_statements = [ + """ + CREATE TABLE IF NOT EXISTS settings ( + id TEXT PRIMARY KEY, -- e.g., 'global' + settings_json TEXT NOT NULL -- JSON blob + ); + """ + ] + + conn = self._db.get_connection() + + try: + with conn: + cursor = conn.cursor() + for statement in sql_statements: + cursor.execute(statement) + + self.logger.debug("Settings tables checked/created successfully.") + except sqlite3.Error as e: + self.logger.error(f"Error creating SQLite tables: {e}") + raise ConfigurationError(f"DB error creating tables: {e}") from e + finally: + if conn: + conn.close() + + def get_settings(self, user_id: Optional[UserId]) -> Optional[SystemSettings]: + self.logger.debug("Getting settings from SQLite.") + user_id = user_id or UserId(self._SETTINGS_ID) + sql = "SELECT settings_json FROM settings WHERE id = ?" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (user_id,)) + row = cursor.fetchone() + if row: + settings_dict = json.loads(row["settings_json"]) + return SystemSettings(id=user_id, settings=settings_dict) + else: + self.logger.info("No settings found in DB, returning None.") + return None # No settings found in DB, return None + except (sqlite3.Error, json.JSONDecodeError) as e: + self.logger.error(f"Errore SQLite o JSON ottenendo settings: {e}") + return None + finally: + if conn: + conn.close() + + def save_settings(self, user_id: Optional[UserId], settings: SystemSettings) -> None: + self.logger.debug("Saving settings to SQLite.") + user_id = user_id or UserId(self._SETTINGS_ID) + sql = "INSERT OR REPLACE INTO settings (id, settings_json) VALUES (?, ?)" + conn = self._db.get_connection() + try: + settings_json = json.dumps(settings.settings) # Serialize the inner dictionary + with conn: + conn.execute(sql, (user_id, settings_json)) + except sqlite3.Error as e: + self.logger.error(f"SQLite error saving settings: {e}") + raise ConfigurationError(f"SQLite error saving settings: {e}") from e + finally: + if conn: + conn.close() + + +# SQLAlchemy implementation + + +class SqlAlchemySettingsRepository(SettingsRepository): + """SQLAlchemy implementation of the SettingsRepository. + + This repository works directly with the imperatively mapped SystemSettings entity. + The settings field is stored as JSON in the database. + + Args: + db: BaseSQLAlchemyRepository instance for database operations + """ + + # We dont have different users, so we use a single ID. + _SETTINGS_ID: str = "global_settings" + + def __init__(self, db: BaseSQLAlchemyRepository): + """Initialize repository with database instance. + + Args: + db: BaseSQLAlchemyRepository instance + """ + self._db = db + self.logger = db.logger + + def get_settings(self, user_id: Optional[UserId]) -> Optional[SystemSettings]: + """Get settings for a user (or global settings if user_id is None).""" + user_id = user_id or UserId(self._SETTINGS_ID) + session = self._db.get_session() + try: + stmt = select(SystemSettings).where(settings_table.c.id == str(user_id)) + entity = session.execute(stmt).scalar_one_or_none() + return entity + finally: + session.close() + + def save_settings(self, user_id: Optional[UserId], settings: SystemSettings) -> None: + """Save settings for a user (or global settings if user_id is None).""" + user_id = user_id or UserId(self._SETTINGS_ID) + session = self._db.get_session() + try: + # Check if settings already exist + stmt = select(SystemSettings).where(settings_table.c.id == str(user_id)) + existing_entity = session.execute(stmt).scalar_one_or_none() + + if existing_entity: + # Update existing settings + existing_entity.settings = settings.settings + session.commit() + else: + # Create new settings with the provided user_id + new_settings = SystemSettings(id=user_id, settings=settings.settings) + session.add(new_settings) + session.commit() + finally: + session.close() diff --git a/core/edge_mining/adapters/domain/user/tables.py b/core/edge_mining/adapters/domain/user/tables.py new file mode 100644 index 0000000..2380599 --- /dev/null +++ b/core/edge_mining/adapters/domain/user/tables.py @@ -0,0 +1,40 @@ +"""SQLAlchemy table definitions for User/Settings domain. + +This module defines the database schema and ORM mappings for the User and Settings entities +using SQLAlchemy's imperative (classical) mapping style. + +⚠️ DEVELOPER WARNING ⚠️ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +ANY SCHEMA CHANGE (adding/removing/modifying tables or columns) REQUIRES an +Alembic migration. Do NOT modify this file without creating a migration: + + python scripts/migrate.py create "Description of your change" + +For detailed instructions, see: ../docs/ALEMBIC_MIGRATIONS.md +For a step-by-step example, see: ../docs/MIGRATION_EXAMPLE.md +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +""" + +from sqlalchemy import JSON, Column, String, Table + +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.registry import mapper_registry, metadata +from edge_mining.domain.user.entities import SystemSettings + + +# Settings table +settings_table = Table( + "settings", + metadata, + Column("id", String, primary_key=True), + Column("settings_json", JSON, nullable=False), +) + +# Map SystemSettings entity to settings table +mapper_registry.map_imperatively( + SystemSettings, + settings_table, + properties={ + "id": settings_table.c.id, + "settings": settings_table.c.settings_json, + }, +) diff --git a/core/edge_mining/adapters/infrastructure/__init__.py b/core/edge_mining/adapters/infrastructure/__init__.py new file mode 100644 index 0000000..b9f55e4 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/__init__.py @@ -0,0 +1 @@ +"""Collection of Adapters (Implementations of Ports) for the infrastructure of Edge Mining Application""" diff --git a/core/edge_mining/adapters/infrastructure/api/__init__.py b/core/edge_mining/adapters/infrastructure/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/adapters/infrastructure/api/main_api.py b/core/edge_mining/adapters/infrastructure/api/main_api.py new file mode 100644 index 0000000..31df305 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/api/main_api.py @@ -0,0 +1,123 @@ +"""Initializes the FastAPI application for the Edge Mining system.""" + +from contextlib import asynccontextmanager +from typing import Annotated + +from fastapi import Depends, FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware + +from edge_mining.__version__ import __version__ +from edge_mining.adapters.domain.energy.fast_api.router import router as energy_router +from edge_mining.adapters.domain.forecast.fast_api.router import router as forecast_router +from edge_mining.adapters.domain.home_load.fast_api.router import router as home_load_router +from edge_mining.adapters.domain.miner.fast_api.router import router as miner_router +from edge_mining.adapters.domain.notification.fast_api.router import router as notification_router +from edge_mining.adapters.domain.optimization_unit.fast_api.router import router as optimization_unit_router +from edge_mining.adapters.domain.performance.fast_api.router import router as performance_router +from edge_mining.adapters.domain.policy.fast_api.router import router as policy_router + +# Import dependency injection setup functions +from edge_mining.adapters.infrastructure.api.setup import get_logger, get_optimization_service, get_service_container +from edge_mining.adapters.infrastructure.external_services.fast_api.router import router as external_services_router +from edge_mining.adapters.infrastructure.rule_engine.fast_api.router import router as rule_engine_router +from edge_mining.adapters.infrastructure.websocket.router import router as ws_router +from edge_mining.application.services.optimization_service import OptimizationService +from edge_mining.shared.logging.port import LoggerPort + + +@asynccontextmanager +async def app_lifespan(api_app: FastAPI): + """Application lifespan - startup and shutdown logic.""" + # Startup + try: + container = await get_service_container() + if not container.is_initialized(): + # This should not happen if properly initialized in main + raise RuntimeError("Services not initialized before FastAPI startup!") + + container.logger.info("FastAPI application started successfully") + + # We can add other startup logic here + # e.g., database connections, external service checks, etc. + + except Exception as e: + print(f"Failed to start FastAPI application: {e}") + raise + + yield # Application is running + + # Shutdown + try: + container.logger.info("FastAPI application shutting down...") + # Add cleanup logic here if needed + except Exception as e: + print(f"Error during shutdown: {e}") + + +app = FastAPI( + title="Edge Mining Core API", + description="Core API for managing and monitoring the bitcoin mining energy optimization system.", + version=__version__, + lifespan=app_lifespan, +) + +# TODO: set only localhost origins +origins = ["*"] + +# User CORS +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(energy_router, prefix="/api/v1", tags=["energy"]) +app.include_router(miner_router, prefix="/api/v1", tags=["mining"]) +app.include_router(policy_router, prefix="/api/v1", tags=["policy"]) +app.include_router(optimization_unit_router, prefix="/api/v1", tags=["optimization_unit"]) +app.include_router(external_services_router, prefix="/api/v1", tags=["external_services"]) +app.include_router(rule_engine_router, prefix="/api/v1", tags=["rule_engine"]) +app.include_router(notification_router, prefix="/api/v1", tags=["notification"]) +app.include_router(forecast_router, prefix="/api/v1", tags=["forecast"]) +app.include_router(home_load_router, prefix="/api/v1", tags=["home_load"]) +app.include_router(performance_router, prefix="/api/v1", tags=["performance"]) +app.include_router(ws_router, tags=["websocket"]) +# Add more routers here (e.g., for configuration) + + +@app.get("/health", tags=["system"]) +async def health_check(): + """Basic health check endpoint.""" + return {"status": "ok"} + + +@app.get("/api/version", tags=["system"]) +async def get_version(): + """Get the current version of the Edge Mining software.""" + return { + "version": __version__, + "name": "Edge Mining", + } + + +# Example endpoint using dependency injection +@app.post("/api/v1/evaluate", tags=["system"]) +async def trigger_evaluation( + logger: Annotated[LoggerPort, Depends(get_logger)], # Inject logger + optimization_service: Annotated[OptimizationService, Depends(get_optimization_service)], # Inject service +): + """Manually run all enabled optimization units.""" + logger.info("API run all enabled optimization units...") + try: + await optimization_service.run_all_enabled_units() + return {"message": "All optimization units run successfully."} + except Exception as e: + logger.error("Error during API run optimization units.") + raise HTTPException(status_code=500, detail=f"Evaluation failed: {e}") from e + + +# --- To run this API (after setting up services): --- +# uvicorn edge_mining.adapters.infrastructure.api.main_api:app --reload diff --git a/core/edge_mining/adapters/infrastructure/api/setup.py b/core/edge_mining/adapters/infrastructure/api/setup.py new file mode 100644 index 0000000..0fcab6b --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/api/setup.py @@ -0,0 +1,134 @@ +"""Setup for FastAPI application with Dependency Injection.""" + +from typing import Optional + +from fastapi import Depends, HTTPException + +from edge_mining.application.interfaces import ( + AdapterServiceInterface, + ConfigurationServiceInterface, + HomeLoadHistoryServiceInterface, + LoadForecastTrainingServiceInterface, + MinerActionServiceInterface, + OptimizationServiceInterface, +) +from edge_mining.shared.infrastructure import Services +from edge_mining.shared.logging.port import LoggerPort + + +class ServiceContainer: + """Container for application services - thread-safe singleton pattern.""" + + _instance: Optional["ServiceContainer"] = None + _services: Optional[Services] = None + _logger: Optional[LoggerPort] = None + _initialized: bool = False + + def __new__(cls) -> "ServiceContainer": + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def initialize(self, services: Services, logger: LoggerPort) -> None: + """Initialize the container with services.""" + if self._initialized: + return # Already initialized + + self._services = services + self._logger = logger + self._initialized = True + + def is_initialized(self) -> bool: + """Check if container is initialized.""" + return self._initialized + + @property + def services(self) -> Services: + """Get services instance.""" + if not self._initialized or self._services is None: + raise HTTPException( + status_code=500, + detail="Services not initialized. Application startup failed.", + ) + return self._services + + @property + def logger(self) -> LoggerPort: + """Get logger instance.""" + if not self._initialized or self._logger is None: + raise HTTPException( + status_code=500, + detail="Logger not initialized. Application startup failed.", + ) + return self._logger + + +# Global container instance +_container = ServiceContainer() + + +# FastAPI dependency functions - these are what we inject in endpoints +async def get_service_container() -> ServiceContainer: + """Get the service container.""" + return _container + + +async def get_adapter_service( + container: ServiceContainer = Depends(get_service_container), +) -> AdapterServiceInterface: + """Get AdapterService via dependency injection.""" + return container.services.adapter_service + + +async def get_config_service( + container: ServiceContainer = Depends(get_service_container), +) -> ConfigurationServiceInterface: + """Get ConfigurationService via dependency injection.""" + return container.services.configuration_service + + +async def get_miner_action_service( + container: ServiceContainer = Depends(get_service_container), +) -> MinerActionServiceInterface: + """Get MinerActionService via dependency injection.""" + return container.services.miner_action_service + + +async def get_optimization_service( + container: ServiceContainer = Depends(get_service_container), +) -> OptimizationServiceInterface: + """Get OptimizationService via dependency injection.""" + return container.services.optimization_service + + +async def get_home_load_history_service( + container: ServiceContainer = Depends(get_service_container), +) -> HomeLoadHistoryServiceInterface: + """Get HomeLoadHistoryService via dependency injection.""" + return container.services.home_load_history_service + + +async def get_load_forecast_training_service( + container: ServiceContainer = Depends(get_service_container), +) -> LoadForecastTrainingServiceInterface: + """Get LoadForecastTrainingService via dependency injection.""" + service = container.services.load_forecast_training_service + if service is None: + raise HTTPException( + status_code=503, + detail="ML training service not available. Install ML dependencies.", + ) + return service + + +async def get_logger( + container: ServiceContainer = Depends(get_service_container), +) -> LoggerPort: + """Get LoggerPort via dependency injection.""" + return container.logger + + +# Initialization function to replace set_api_services +def init_api_dependencies(services: Services, logger: LoggerPort) -> None: + """Initialize API dependencies - call this during app startup.""" + _container.initialize(services, logger) diff --git a/core/edge_mining/adapters/infrastructure/cli/__init__.py b/core/edge_mining/adapters/infrastructure/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/adapters/infrastructure/cli/commands.py b/core/edge_mining/adapters/infrastructure/cli/commands.py new file mode 100644 index 0000000..a99bf71 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/cli/commands.py @@ -0,0 +1,223 @@ +"""CLI Commands for the Edge Mining Application""" + +from typing import cast +from uuid import UUID + +import click + +from edge_mining.adapters.domain.miner.cli.commands import list_miners +from edge_mining.adapters.domain.optimization_unit.cli.commands import list_optimization_units +from edge_mining.adapters.infrastructure.cli.setup import cli +from edge_mining.adapters.infrastructure.cli.utils import run_evaluation +from edge_mining.application.interfaces import OptimizationServiceInterface +from edge_mining.domain.common import EntityId, Watts +from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.shared.infrastructure import Services + +from edge_mining.adapters.utils import run_async_func + + +@cli.command("run-evaluation") +@click.pass_context +def manage_run_evaluation(ctx: click.Context): + """Run a manual evaluation cycle for the Edge Mining system.""" + services: Services = ctx.obj[Services] + + optimization_service: OptimizationServiceInterface = services.optimization_service + if not optimization_service: + click.echo("Error: Optimization Service not initialized.", err=True) + return + run_evaluation(optimization_service=optimization_service) + + +@click.group() +def optimization_unit(): + """Optimization Unit Management""" + pass + + +@optimization_unit.command("create") +@click.argument("name") +@click.option("--description", help="Description for the Optimization Unit") +@click.option("--energy_source_id", help="ID of the energy source to use") +@click.option("--target_miner_ids", help="Comma-separated list of target miner IDs") +@click.option("--policy_id", help="ID of the policy to use") +@click.option("--performance_tracker_id", help="ID of the performance tracker") +@click.option("--notifier_ids", help="Comma-separated list of notifier IDs") +@click.pass_context +def create_optimization_unit( + ctx: click.Context, + name: str, + description: str, + energy_source_id_str: str, + target_miner_ids_str: str, + policy_id_str: str, + performance_tracker_id_str: str, + notifier_ids_str: str, +): + """Create a new optimization unit.""" + services: Services = ctx.obj[Services] + + configuration_service = services.configuration_service + if not configuration_service: + click.echo("Error: Configuration Services not initialized.", err=True) + return + + try: + target_miner_ids = ( + [EntityId(cast(UUID, miner_id.strip())) for miner_id in target_miner_ids_str.split(",")] + if target_miner_ids_str + else [] + ) + notifier_ids = ( + [EntityId(cast(UUID, notifier_id.strip())) for notifier_id in notifier_ids_str.split(",")] + if notifier_ids_str + else [] + ) + energy_source_id = EntityId(cast(UUID, energy_source_id_str)) if energy_source_id_str else None + policy_id = EntityId(cast(UUID, policy_id_str)) if policy_id_str else None + performance_tracker_id = ( + EntityId(cast(UUID, performance_tracker_id_str)) if performance_tracker_id_str else None + ) + + created = run_async_func( + configuration_service.create_optimization_unit( + name=name, + description=description, + energy_source_id=energy_source_id, + target_miner_ids=target_miner_ids, + policy_id=policy_id, + performance_tracker_id=performance_tracker_id, + notifier_ids=notifier_ids, + ) + ) + if not created: + click.echo("Error: Optimization Unit creation failed.", err=True) + return + click.echo(f"Optimization Unit '{created.name}' created successfully.") + except Exception as e: + click.echo(f"Error creating optimization unit: {e}", err=True) + + +@optimization_unit.command("list") +@click.pass_context +def handle_list_optimization_units(ctx: click.Context): + """List all configured optimization units.""" + services: Services = ctx.obj[Services] + + configuration_service = services.configuration_service + if not configuration_service: + click.echo("Error: Configuration Services not initialized.", err=True) + return + + list_optimization_units(configuration_service) + + +@cli.group() +def miner(): + """Manage Miners""" + pass + + +@miner.command("add") +@click.argument("name") +@click.option("--hash_rate", help="Max HashRate of the miner", type=float, default=100.0) +@click.option("--hash_rate_units", help="HashRate units (e.g. TH/s, GH/s)", default="TH/s") +@click.option("--power_consumption", help="Max power consumption", type=float, default=3200.0) +@click.option("--controller_id", help="Reference ID of miner controller", type=int, default=None) +@click.pass_context +def add_miner( + ctx: click.Context, + name: str, + hash_rate_str: str, + hash_rate_unit_str: str, + power_consumption_str: str, + controller_id_str: str, +): + """Add a new miner to the system.""" + services: Services = ctx.obj[Services] + + configuration_service = services.configuration_service + if not configuration_service: + click.echo("Error: Configuration Services not initialized.", err=True) + return + try: + controller_id = EntityId(cast(UUID, controller_id_str)) if controller_id_str else None + hash_rate = HashRate(value=float(hash_rate_str), unit=hash_rate_unit_str) + power_consumption = Watts(float(power_consumption_str)) + + added = run_async_func( + configuration_service.add_miner( + name=name, + hash_rate_max=hash_rate, + power_consumption_max=power_consumption, + controller_id=controller_id, + ) + ) + click.echo(f"Miner '{added.name}' ({added.id}) added successfully.") + except Exception as e: + click.echo(f"Error adding miner: {e}", err=True) + + +@miner.command("list") +@click.pass_context +def handle_list_miners(ctx: click.Context): + """List all configured miners.""" + services: Services = ctx.obj[Services] + + configuration_service = services.configuration_service + if not configuration_service: + click.echo("Error: Configuration Services not initialized.", err=True) + return + + list_miners(configuration_service) + + +@miner.command("remove") +@click.argument("miner_id") +@click.pass_context +def remove_miner(ctx: click.Context, miner_id_str: str): + """Remove a miner from the system.""" + services: Services = ctx.obj[Services] + + configuration_service = services.configuration_service + if not configuration_service: + click.echo("Error: Configuration Services not initialized.", err=True) + return + + miner_id = EntityId(cast(UUID, miner_id_str)) + + try: + run_async_func(configuration_service.remove_miner(miner_id=miner_id)) + click.echo(f"Miner {miner_id} removed.") + except Exception as e: + click.echo(f"Error removing miner: {e}", err=True) + + +@cli.group() +def policy(): + """Manage Optimization Policies""" + pass + + +@policy.command("create") +@click.argument("name") +@click.option("--description", help="Description for the Policy") +@click.pass_context +def create_policy(ctx: click.Context, name: str, description: str): + """Create a new optimization policy.""" + services: Services = ctx.obj[Services] + + configuration_service = services.configuration_service + if not configuration_service: + click.echo("Error: Configuration Services not initialized.", err=True) + return + + try: + created = run_async_func(configuration_service.create_policy(name=name, description=description)) + click.echo(f"Optimization Policy '{created.name}' ({created.description}) created successfully.") + except Exception as e: + click.echo(f"Error adding miner: {e}", err=True) + + +# # TODO: Add commands for policy management (create, list, activate, add-rule) diff --git a/core/edge_mining/adapters/infrastructure/cli/interactive.py b/core/edge_mining/adapters/infrastructure/cli/interactive.py new file mode 100644 index 0000000..f586664 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/cli/interactive.py @@ -0,0 +1,125 @@ +"""Interactive CLI for Edge Mining.""" + +import click + +from edge_mining.adapters.domain.energy.cli.commands import energy_menu +from edge_mining.adapters.domain.forecast.cli.commands import forecast_menu +from edge_mining.adapters.domain.miner.cli.commands import miner_menu +from edge_mining.adapters.domain.notification.cli.commands import notifier_menu +from edge_mining.adapters.domain.optimization_unit.cli.commands import optimization_unit_menu +from edge_mining.adapters.domain.performance.cli.commands import mining_performance_tracker_menu +from edge_mining.adapters.domain.policy.cli.commands import policy_menu +from edge_mining.adapters.infrastructure.cli.setup import cli +from edge_mining.adapters.infrastructure.cli.utils import run_evaluation +from edge_mining.adapters.infrastructure.external_services.cli.commands import external_services_menu +from edge_mining.shared.infrastructure import Services +from edge_mining.shared.logging.port import LoggerPort + + +# --- Main Menu Logic --- +@cli.command("interactive") +@click.pass_context +def interactive(ctx: click.Context): + """Interactive main CLI menu for Edge Mining.""" + services: Services = ctx.obj[Services] + logger: LoggerPort = ctx.obj[LoggerPort] + + click.echo(click.style("Welcome to the Edge Mining CLI!", fg="cyan", bold=True)) + click.echo("Select an option or use 'q' to quit.") + + sub_choice: str = "" + + while True: + click.echo(click.style("\n--- Main Menu ---", fg="blue")) + click.echo("1. Manage Energy") + click.echo("2. Manage Forecast") + click.echo("3. Manage Miners") + click.echo("4. Manage Policies") + click.echo("5. Manage Notifiers") + click.echo("") + click.echo("6. Manage Energy Optimization Units") + click.echo("") + click.echo("7. Manage External Services") + click.echo("") + click.echo("8. Manage Mining Performance Trackers") + click.echo("") + click.echo("9. Run all optimization units") + click.echo("q. Close application") + click.echo("--------------------------") + + sub_choice = "" + choice: str = click.prompt("Choose an option", type=str) + choice = choice.strip().lower() + + if choice == "1": + sub_choice = energy_menu( + configuration_service=services.configuration_service, + logger=logger, + ) + + if sub_choice == "q": + break + elif choice == "2": + sub_choice = forecast_menu( + configuration_service=services.configuration_service, + logger=logger, + ) + + if sub_choice == "q": + break + elif choice == "3": + sub_choice = miner_menu( + configuration_service=services.configuration_service, + miner_action_service=services.miner_action_service, + logger=logger, + ) + + if sub_choice == "q": + break + elif choice == "4": + sub_choice = policy_menu( + configuration_service=services.configuration_service, + logger=logger, + ) + + if sub_choice == "q": + break + elif choice == "5": + sub_choice = notifier_menu( + configuration_service=services.configuration_service, + logger=logger, + ) + + if sub_choice == "q": + break + elif choice == "6": + sub_choice = optimization_unit_menu( + configuration_service=services.configuration_service, + logger=logger, + ) + + if sub_choice == "q": + break + elif choice == "7": + sub_choice = external_services_menu( + configuration_service=services.configuration_service, + logger=logger, + ) + + if sub_choice == "q": + break + elif choice == "8": + sub_choice = mining_performance_tracker_menu( + configuration_service=services.configuration_service, + logger=logger, + adapter_service=services.adapter_service, + ) + + if sub_choice == "q": + break + elif choice == "9": + run_evaluation(services.optimization_service) + elif choice == "q": + break + else: + click.echo(click.style("Invalid option, please try again.", fg="red")) diff --git a/core/edge_mining/adapters/infrastructure/cli/main_cli.py b/core/edge_mining/adapters/infrastructure/cli/main_cli.py new file mode 100644 index 0000000..2c34d8a --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/cli/main_cli.py @@ -0,0 +1,38 @@ +"""Terminal CLI infrastructure adapter""" + +from edge_mining.shared.infrastructure import Services +from edge_mining.shared.logging.port import LoggerPort + +from edge_mining.adapters.infrastructure.cli.setup import cli + +from edge_mining.adapters.infrastructure.cli.interactive import interactive +from edge_mining.adapters.infrastructure.cli.commands import ( + optimization_unit, + miner, + policy, + manage_run_evaluation, +) + +# Add interactive CLI command +cli.add_command(interactive, name="interactive") +# Add optimization unit CLI commands +cli.add_command(optimization_unit, name="optimization-unit") +# Add miner CLI commands +cli.add_command(miner, name="miner") +# Add policy CLI commands +cli.add_command(policy, name="policy") +# Add run evaluation command +cli.add_command(manage_run_evaluation, name="run-evaluation") + + +# --- CLI main execution --- +def run_cli(services: Services, logger: LoggerPort): + """ + Main function to launch the CLI. + """ + + # Creates context object to pass services and logger + context_data = {Services: services, LoggerPort: logger} + + # Creates a context object to pass services and logger using class names + cli.main(obj=context_data) diff --git a/core/edge_mining/adapters/infrastructure/cli/setup.py b/core/edge_mining/adapters/infrastructure/cli/setup.py new file mode 100644 index 0000000..a20e43f --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/cli/setup.py @@ -0,0 +1,20 @@ +""" +Edge Mining CLI Setup. +""" + +import click + +from edge_mining.shared.infrastructure import Services +from edge_mining.shared.logging.port import LoggerPort + + +@click.group() +@click.pass_context +def cli(ctx: click.Context): + """Edge Mining CLI""" + + if not isinstance(ctx.obj, dict) or not all( + isinstance(ctx.obj.get(k), v) for k, v in {Services: Services, LoggerPort: LoggerPort}.items() + ): + print("WARNING: ctx.obj does not contain expected pre-initialized dependencies.") + click.echo(click.style("Welcome to the Edge Mining CLI!", fg="red", bold=True)) diff --git a/core/edge_mining/adapters/infrastructure/cli/utils.py b/core/edge_mining/adapters/infrastructure/cli/utils.py new file mode 100644 index 0000000..2415bd2 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/cli/utils.py @@ -0,0 +1,54 @@ +"""Utility functions for CLI commands.""" + +import asyncio +from typing import Any, Dict, List, Optional, Union + +import click + +from edge_mining.application.interfaces import OptimizationServiceInterface + + +def run_evaluation(optimization_service: OptimizationServiceInterface): + """Manually trigger one evaluation cycle.""" + if not optimization_service: + click.echo("Error: Optimization Services not initialized.", err=True) + return + + click.echo("Manually running evaluation cycle...") + try: + asyncio.run(optimization_service.run_all_enabled_units()) + click.echo("Evaluation cycle finished.") + except Exception as e: + click.echo(f"Error during evaluation: {e}", err=True) + + +def process_filters( + filter_type: Optional[Union[Any, List[Any]]] = None, +) -> Optional[Union[List[Any]]]: + """Process filter types for CLI commands.""" + if filter_type is None: + return None + + # Convert single item to list + if not isinstance(filter_type, list): + filter_type_list = [filter_type] + else: + filter_type_list = filter_type + + return filter_type_list + + +def print_configuration(configuration: Dict): + """Print configuration in a structured format.""" + for key, value in configuration.items(): + if isinstance(value, dict): + click.echo(f"|-- {key}:") + for sub_key, sub_value in value.items(): + click.echo(f"| |-- {sub_key}: " + click.style(f"{sub_value}", fg="blue")) + else: + # For other types, just print the value directly + if value is None: + value = "None" + elif isinstance(value, str): + value = f'"{value}"' + click.echo(f"|-- {key}: " + click.style(f"{value}", fg="blue")) diff --git a/core/edge_mining/adapters/infrastructure/event_bus/__init__.py b/core/edge_mining/adapters/infrastructure/event_bus/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/adapters/infrastructure/event_bus/in_memory_event_bus.py b/core/edge_mining/adapters/infrastructure/event_bus/in_memory_event_bus.py new file mode 100644 index 0000000..5223690 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/event_bus/in_memory_event_bus.py @@ -0,0 +1,58 @@ +"""In-memory implementation of the event bus.""" + +import asyncio +from collections import defaultdict +from typing import Callable, Type + +from edge_mining.application.interfaces import EventBusInterface +from edge_mining.domain.common import DomainEvent +from edge_mining.shared.logging.port import LoggerPort + + +class InMemoryEventBus(EventBusInterface): + """In-memory implementation of the event bus with blocking/fire-and-forget support.""" + + def __init__(self, logger: LoggerPort) -> None: + self._logger = logger + # dict[Type[DomainEvent], list[tuple[Callable, bool]]] + # bool = is_blocking + self._handlers: dict[Type[DomainEvent], list[tuple[Callable, bool]]] = defaultdict(list) + + def subscribe( + self, + event_type: Type[DomainEvent], + handler: Callable, + blocking: bool = True, + ) -> None: + self._handlers[event_type].append((handler, blocking)) + self._logger.debug( + f"EventBus: subscribed {handler.__qualname__} to {event_type.__name__} (blocking={blocking})" + ) + + async def publish(self, event: DomainEvent) -> None: + handlers = self._handlers.get(type(event), []) + + if not handlers: + return + + self._logger.debug( + f"EventBus: publishing {event.event_type} (id={event.event_id[:8]}..., handlers={len(handlers)})" + ) + + # 1. Blocking handlers — the publisher WAITS, exceptions are propagated + for handler, is_blocking in handlers: + if is_blocking: + await handler(event) + + # 2. Fire-and-forget handlers — the publisher DOES NOT wait, exceptions are caught + for handler, is_blocking in handlers: + if not is_blocking: + asyncio.create_task(self._safe_execute(handler, event)) + + async def _safe_execute(self, handler: Callable, event: DomainEvent) -> None: + try: + await handler(event) + except Exception as e: + self._logger.warning( + f"EventBus: fire-and-forget handler {handler.__qualname__} failed for {event.event_type}: {e}" + ) diff --git a/core/edge_mining/adapters/infrastructure/external_services/__init__.py b/core/edge_mining/adapters/infrastructure/external_services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/adapters/infrastructure/external_services/cli/__init__.py b/core/edge_mining/adapters/infrastructure/external_services/cli/__init__.py new file mode 100644 index 0000000..c29978c --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/external_services/cli/__init__.py @@ -0,0 +1 @@ +"""Adapters CLI for the External Service domain.""" diff --git a/core/edge_mining/adapters/infrastructure/external_services/cli/commands.py b/core/edge_mining/adapters/infrastructure/external_services/cli/commands.py new file mode 100644 index 0000000..741bc5a --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/external_services/cli/commands.py @@ -0,0 +1,419 @@ +"""CLI commands for the External Service domain.""" + +from typing import List, Optional, Union + +import click + +from edge_mining.adapters.infrastructure.cli.utils import ( + print_configuration, + process_filters, +) +from edge_mining.application.interfaces import ConfigurationServiceInterface +from edge_mining.domain.common import EntityId +from edge_mining.domain.energy.entities import EnergyMonitor +from edge_mining.domain.forecast.entities import ForecastProvider +from edge_mining.domain.home_load.entities import EnergyLoadForecastProvider +from edge_mining.domain.miner.entities import MinerController +from edge_mining.domain.notification.entities import Notifier +from edge_mining.shared.adapter_configs.external_services import ( + ExternalServiceHomeAssistantConfig, +) +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.external_services.entities import ExternalService +from edge_mining.shared.interfaces.config import ExternalServiceConfig +from edge_mining.shared.logging.port import LoggerPort + +from edge_mining.adapters.utils import run_async_func + + +def select_external_service_type() -> Optional[ExternalServiceAdapter]: + """Prompt user to select an external service adapter type.""" + click.echo("Select an External Service Type:") + for idx, es_type in enumerate(ExternalServiceAdapter): + click.echo(f"{idx}. {es_type.name}") + + click.echo("") + choice: str = click.prompt("Choose an external service", type=str, default="") + choice = choice.strip().lower() + + if not choice.isdigit() or int(choice) < 0 or int(choice) >= len(ExternalServiceAdapter): + click.echo(click.style("Invalid index. Aborting selection.", fg="red")) + return None + + es_type_values = [controller_type.value for controller_type in ExternalServiceAdapter] + + selected_type = ExternalServiceAdapter(es_type_values[int(choice)]) + return selected_type + + +def handle_external_service_home_assistant_api_config() -> Optional[ExternalServiceConfig]: + """Prompt user for Home Assistant API configuration.""" + click.echo(click.style("\n--- Home Assistant API Configuration ---", fg="yellow")) + + url: str = click.prompt("Home Assistant URL", type=str) + token: str = click.prompt("Long-Lived Access Token", type=str) + + return ExternalServiceHomeAssistantConfig(url=url, token=token) + + +def handle_external_service_configuration( + adapter_type: ExternalServiceAdapter, +) -> Optional[ExternalServiceConfig]: + """Prompt user for configuration based on the selected external service adapter type.""" + if adapter_type.value == ExternalServiceAdapter.HOME_ASSISTANT_API.value: + return handle_external_service_home_assistant_api_config() + else: + click.echo( + click.style( + f"Configuration for {adapter_type.name} is not implemented yet.", + fg="red", + ) + ) + return None + + +def handle_add_external_service( + configuration_service: ConfigurationServiceInterface, logger: LoggerPort +) -> Optional[ExternalService]: + """Menu to add a new external service""" + click.echo(click.style("\n--- Add External Service ---", fg="yellow")) + name: str = click.prompt("Name of the external service", type=str) + adapter_type: Optional[ExternalServiceAdapter] = select_external_service_type() + + if adapter_type is None: + click.echo(click.style("Invalid external service type selected. Aborting.", fg="red")) + return None + + config: Optional[ExternalServiceConfig] = handle_external_service_configuration(adapter_type) + + if config is None: + click.echo(click.style("Invalid configuration provided. Aborting.", fg="red")) + return None + + try: + created_service = run_async_func( + configuration_service.create_external_service(name=name, adapter_type=adapter_type, config=config) + ) + click.echo( + click.style( + f"External Service '{created_service.name}' (ID: {created_service.id}) successfully added.", + fg="green", + ) + ) + except Exception as e: + created_service = None + logger.error(f"Error creating external service: {e}") + click.echo(click.style(f"Error: {e}", fg="red"), err=True) + + click.pause("Press any key to return to the menu...") + return created_service + + +def handle_list_external_services(configuration_service: ConfigurationServiceInterface, logger: LoggerPort) -> None: + """Menu to list all configured external services.""" + click.echo(click.style("\n--- Configured External Services ---", fg="yellow")) + + services = configuration_service.list_external_services() + if not services: + click.echo(click.style("No external services configured.", fg="yellow")) + else: + for service in services: + click.echo( + "-> " + + "Name: " + + click.style(f"{service.name}, ", fg="blue") + + "ID: " + + click.style(f"{service.id}, ", fg="yellow") + + "Type: " + + click.style(f"{service.adapter_type.name}", fg="green") + ) + + click.echo("") + click.pause("Press any key to return to the menu...") + + +def select_external_service( + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, + default_id: Optional[EntityId] = None, + filter_type: Optional[Union[ExternalServiceAdapter, List[ExternalServiceAdapter]]] = None, +) -> Optional[ExternalService]: + """Select an external service from the list of configured services.""" + click.echo(click.style("\n--- Select External Service ---", fg="yellow")) + + services = configuration_service.list_external_services() + + filter_type = process_filters(filter_type) + + if filter_type: + click.echo( + "Filtering services by types: " + click.style(f"{', '.join([t.name for t in filter_type])}", fg="blue") + ) + services = [s for s in services if s.adapter_type in filter_type] + + if not services: + click.echo(click.style("No external services configured.", fg="yellow")) + return None + + default_idx = "" + for idx, service in enumerate(services): + click.echo( + f"{idx}. " + + "Name: " + + click.style(f"{service.name}, ", fg="blue") + + "ID: " + + click.style(f"{service.id}, ", fg="yellow") + + "Type: " + + click.style(f"{service.adapter_type.name}", fg="green") + ) + + if default_id: + if service.id == default_id: + default_idx = str(idx) + + click.echo("\nb. Back to menu\n") + + choice: str = click.prompt("Choose an external service by index", type=str, default=default_idx) + choice = choice.strip().lower() + if choice == "b": + return None + if not choice.isdigit() or int(choice) < 0 or int(choice) >= len(services): + click.echo(click.style("Invalid index. Aborting selection.", fg="red")) + return None + + selected_service = services[int(choice)] + return selected_service + + +def print_external_service_config(external_service: ExternalService) -> None: + """Print the configuration of a selected External Service.""" + configuration_class = external_service.config.__class__.__name__ if external_service.config else "---" + click.echo("| Configuration: " + click.style(f"{configuration_class}", fg="cyan")) + if external_service.config: + print_configuration(external_service.config.to_dict()) + + +def print_external_service_details( + service: ExternalService, + configuration_service: ConfigurationServiceInterface, + show_config: bool = True, + show_linked_instances: bool = False, +) -> None: + """Print details of the selected external service.""" + click.echo("") + click.echo("| Name: " + click.style(service.name, fg="blue")) + click.echo("| ID: " + click.style(service.id, fg="yellow")) + click.echo("| Adapter: " + click.style(service.adapter_type.name, fg="green")) + if show_config: + print_external_service_config(service) + click.echo("") + + if show_linked_instances: + external_service_linked_entities = configuration_service.get_entities_by_external_service(service.id) + + e: Union[ + EnergyMonitor, + MinerController, + ForecastProvider, + EnergyLoadForecastProvider, + Notifier, + ] + if external_service_linked_entities.energy_monitors: + click.echo("Energy Monitors assigned:") + for e in external_service_linked_entities.energy_monitors: + click.echo(f"-> Name: {e.name} (ID: {e.id})") + click.echo("") + + if external_service_linked_entities.miner_controllers: + click.echo("Miner Controllers assigned:") + for e in external_service_linked_entities.miner_controllers: + click.echo(f"-> Name: {e.name} (ID: {e.id})") + click.echo("") + + if external_service_linked_entities.forecast_providers: + click.echo("Forecast Providers assigned:") + for e in external_service_linked_entities.forecast_providers: + click.echo(f"-> Name: {e.name} (ID: {e.id})") + click.echo("") + + if external_service_linked_entities.energy_load_forecast_providers: + click.echo("Energy Load Forecast Providers assigned:") + for e in external_service_linked_entities.energy_load_forecast_providers: + click.echo(f"-> Name: {e.name} (ID: {e.id})") + click.echo("") + + if external_service_linked_entities.notifiers: + click.echo("Notifiers assigned:") + for e in external_service_linked_entities.notifiers: + click.echo(f"-> Name: {e.name} (ID: {e.id})") + click.echo("") + + +def update_single_external_service( + service: ExternalService, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> Optional[ExternalService]: + """Menu to update an external service""" + name: str = click.prompt("New name of the external service", type=str, default=service.name) + config: Optional[ExternalServiceConfig] = handle_external_service_configuration(adapter_type=service.adapter_type) + + if config is None: + click.echo(click.style("Invalid configuration. Aborting.", fg="red")) + return None + + try: + updated_external_service = run_async_func( + configuration_service.update_external_service(service_id=service.id, name=name, config=config) + ) + except Exception as e: + logger.error(f"Error updating external service: {e}") + click.echo( + click.style(f"Error updating external service: {e}", fg="red"), + err=True, + ) + updated_external_service = None + + click.pause("Press any key to return to the menu...") + + return updated_external_service + + +def delete_single_external_service( + service: ExternalService, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> bool: + """Delete a specific external service.""" + delete_confirm = click.confirm( + f"Are you sure you want to remove the External Service '{service.name}' (ID: {service.id})?", + abort=False, + default=False, + prompt_suffix="", + ) + + if not delete_confirm: + click.echo(click.style("Removal cancelled.", fg="yellow")) + return False + + try: + removed_external_service = run_async_func(configuration_service.remove_external_service(service.id)) + click.echo( + click.style( + f"External Service '{removed_external_service.name}' successfully deleted.", + fg="green", + ) + ) + except Exception as e: + logger.error(f"Error deleting external service: {e}") + click.echo( + click.style(f"Error removing external service: {e}", fg="red"), + err=True, + ) + return False + else: + return True + + +def manage_single_external_service_menu( + selected_service: ExternalService, + configuration_service: ConfigurationServiceInterface, + logger: LoggerPort, +) -> str: + """Menu to manage a single external service.""" + while True: + click.echo("\n" + click.style("--- MANAGE EXTERNAL SERVICE ---", fg="yellow", bold=True)) + + print_external_service_details(selected_service, configuration_service, show_linked_instances=True) + + click.echo("1. Update External Service") + click.echo("2. Delete External Service") + click.echo("") + click.echo("b. Back to external services menu") + click.echo("q. Close application") + click.echo("-----------------") + + choice: str = click.prompt("Choose an option", type=str, default="") + choice = choice.strip().lower() + + click.clear() + + if choice == "1": + updated_external_service = update_single_external_service( + service=selected_service, + configuration_service=configuration_service, + logger=logger, + ) + selected_service = ( + updated_external_service or selected_service + ) # Update external service if it was successfully updated + continue + + elif choice == "2": + delete_status = delete_single_external_service( + service=selected_service, + configuration_service=configuration_service, + logger=logger, + ) + if delete_status: + return "b" # Return to menu if deletion was successful + continue + + elif choice == "b": + break + + elif choice == "q": + break + else: + click.echo(click.style("Invalid choice. Try again.", fg="red")) + click.pause("Press any key to return to the menu...") + + return choice + + +def external_services_menu(configuration_service: ConfigurationServiceInterface, logger: LoggerPort) -> str: + """Menu for managing External Services.""" + while True: + click.echo("\n" + click.style("--- EXTERNAL SERVICES ---", fg="blue", bold=True)) + click.echo("1. Add an External Service") + click.echo("2. List all External Services") + click.echo("3. Manage an External Service") + click.echo("") + click.echo("b. Back to main menu") + click.echo("q. Close application") + click.echo("-----------------") + + choice: str = click.prompt("Choose an option", type=str) + choice = choice.strip().lower() + + click.clear() + + if choice == "1": + handle_add_external_service(configuration_service=configuration_service, logger=logger) + elif choice == "2": + handle_list_external_services(configuration_service=configuration_service, logger=logger) + elif choice == "3": + service = select_external_service(configuration_service, logger) + if service is None: + click.echo(click.style("No external service selected. Aborting.", fg="red")) + continue + + sub_choice = manage_single_external_service_menu( + selected_service=service, + configuration_service=configuration_service, + logger=logger, + ) + if sub_choice == "q": + choice = "q" # Exit if user chose to quit from external service menu + break + + elif choice == "b": + break + + elif choice == "q": + break + + else: + click.echo(click.style("Invalid choice. Try again.", fg="red")) + click.pause("Press any key to return to the menu...") + return choice diff --git a/core/edge_mining/adapters/infrastructure/external_services/fast_api/__init__.py b/core/edge_mining/adapters/infrastructure/external_services/fast_api/__init__.py new file mode 100644 index 0000000..1dd1c9e --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/external_services/fast_api/__init__.py @@ -0,0 +1 @@ +"""Adapter that uses FastAPI infrastructure for external services""" diff --git a/core/edge_mining/adapters/infrastructure/external_services/fast_api/router.py b/core/edge_mining/adapters/infrastructure/external_services/fast_api/router.py new file mode 100644 index 0000000..34e8ea7 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/external_services/fast_api/router.py @@ -0,0 +1,265 @@ +"""API Router for external services domain""" + +from datetime import datetime +from typing import Annotated, Any, Dict, List, Optional, cast + +from fastapi import APIRouter, Depends, HTTPException + +# Import dependency injection setup functions +from edge_mining.adapters.infrastructure.api.setup import get_adapter_service, get_config_service +from edge_mining.adapters.infrastructure.external_services.schemas import ( + EXTERNAL_SERVICE_CONFIG_SCHEMA_MAP, + ExternalServiceCreateSchema, + ExternalServiceLinkedEntitiesSchema, + ExternalServiceSchema, + ExternalServiceStatusEnum, + ExternalServiceStatusSchema, + ExternalServiceUpdateSchema, +) +from edge_mining.application.interfaces import ( + AdapterServiceInterface, + ConfigurationServiceInterface, +) +from edge_mining.domain.common import EntityId +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.external_services.entities import ExternalService +from edge_mining.shared.external_services.exceptions import ( + ExternalServiceAlreadyExistsError, + ExternalServiceConfigurationError, + ExternalServiceNotFoundError, +) +from edge_mining.shared.interfaces.config import Configuration, ExternalServiceConfig + +router = APIRouter() + + +@router.get("/external-services", response_model=List[ExternalServiceSchema]) +async def get_external_services_list( + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> List[ExternalServiceSchema]: + """Get a list of all external services""" + try: + external_services: List[ExternalService] = config_service.list_external_services() + + # Convert to external service schema + external_service_schemas: List[ExternalServiceSchema] = [] + + for external_service in external_services: + external_service_schemas.append(ExternalServiceSchema.from_model(external_service)) + + return external_service_schemas + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/external-services", response_model=ExternalServiceSchema) +async def add_external_service( + external_service_data: ExternalServiceCreateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> ExternalServiceSchema: + """Add a new external service""" + try: + # Convert to domain model + external_service_to_add: ExternalService = external_service_data.to_model() + + if external_service_to_add.config is None: + raise ExternalServiceConfigurationError("External service configuration should be set") + + # Add the external service + created_service = await config_service.create_external_service( + name=external_service_to_add.name, + adapter_type=external_service_to_add.adapter_type, + config=external_service_to_add.config, + ) + + response = ExternalServiceSchema.from_model(created_service) + return response + except ExternalServiceAlreadyExistsError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except ExternalServiceConfigurationError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/external-services/types", response_model=List[ExternalServiceAdapter]) +async def get_external_service_types() -> List[ExternalServiceAdapter]: + """Get a list of available external service types""" + try: + return [ExternalServiceAdapter(adapter.value) for adapter in ExternalServiceAdapter] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get( + "/external-services/types/{adapter_type}/config-schema", + response_model=Dict[str, Any], +) +async def get_external_service_config_schema( + adapter_type: ExternalServiceAdapter, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> Dict[str, Any]: + """Get the configuration schema for a specific external service type""" + try: + try: + external_service_adapter = ExternalServiceAdapter(adapter_type) + except ValueError as e: + raise ValueError(f"Invalid external service adapter type: {adapter_type}") from e + + # Get the corresponding configuration class for the adapter type + external_service_config_type: Optional[type[ExternalServiceConfig]] = ( + config_service.get_external_service_config_by_type(external_service_adapter) + ) + + if external_service_config_type is None: + raise ValueError(f"No configuration class found for adapter type {adapter_type}") + + # Map the configuration class to its corresponding schema + external_service_config_schema = EXTERNAL_SERVICE_CONFIG_SCHEMA_MAP.get(external_service_config_type, None) + + if external_service_config_schema is None: + raise ValueError(f"No schema found for external service config class: {external_service_config_type}") + + return external_service_config_schema.model_json_schema() + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/external-services/{service_id}", response_model=ExternalServiceSchema) +async def get_external_service( + service_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> ExternalServiceSchema: + """Get details of a specific external service""" + try: + external_service = config_service.get_external_service(service_id) + + if external_service is None: + raise ExternalServiceNotFoundError(f"External service with id {service_id} not found") + + return ExternalServiceSchema.from_model(external_service) + except ExternalServiceNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.put("/external-services/{service_id}", response_model=ExternalServiceSchema) +async def update_external_service( + service_id: EntityId, + external_service_update: ExternalServiceUpdateSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> ExternalServiceSchema: + """Update an existing external service""" + try: + external_service = config_service.get_external_service(service_id) + + if external_service is None: + raise ExternalServiceNotFoundError(f"External service with id {service_id} not found") + + configuration: Optional[Configuration] = None + if external_service_update.config: + config_cls = config_service.get_external_service_config_by_type(external_service.adapter_type) + if config_cls is None: + raise ExternalServiceConfigurationError( + f"No configuration class found for adapter type {external_service.adapter_type}" + ) + configuration = config_cls.from_dict(external_service_update.config) + + # Update the external service + updated_service = await config_service.update_external_service( + service_id=service_id, + name=external_service_update.name or "", + config=cast(ExternalServiceConfig, configuration), + ) + + response = ExternalServiceSchema.from_model(updated_service) + + return response + except ExternalServiceNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.delete("/external-services/{service_id}", response_model=ExternalServiceSchema) +async def delete_external_service( + service_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> ExternalServiceSchema: + """Remove an external service.""" + try: + deleted_service = await config_service.remove_external_service(service_id) + + response = ExternalServiceSchema.from_model(deleted_service) + + return response + except ExternalServiceNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/external-services/{service_id}/status", response_model=ExternalServiceStatusSchema) +async def get_external_service_status( + service_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], + adapter_service: Annotated[AdapterServiceInterface, Depends(get_adapter_service)], +) -> ExternalServiceStatusSchema: + """Get the connection status of a specific external service""" + try: + external_service_adapter = await adapter_service.get_external_service(service_id) + + if external_service_adapter is None: + raise ExternalServiceNotFoundError(f"External service with id {service_id} not found") + + external_service = config_service.get_external_service(service_id) + if external_service is None: + raise ExternalServiceNotFoundError(f"External service with id {service_id} not found") + + try: + is_connected = await external_service_adapter.is_connected() + status = ExternalServiceStatusEnum.CONNECTED if is_connected else ExternalServiceStatusEnum.DISCONNECTED + error_message = None + except Exception as e: + status = ExternalServiceStatusEnum.DISCONNECTED + error_message = str(e) + + return ExternalServiceStatusSchema( + name=external_service.name, + status=status, + last_check=datetime.now(), + error_message=error_message, + ) + except ExternalServiceNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/external-services/{service_id}/linked-entities", response_model=ExternalServiceLinkedEntitiesSchema) +async def get_external_service_linked_entities( + service_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> ExternalServiceLinkedEntitiesSchema: + """Get all entities linked to a specific external service""" + try: + external_service = config_service.get_external_service(service_id) + if external_service is None: + raise ExternalServiceNotFoundError(f"External service with id {service_id} not found") + + linked_entities = config_service.get_entities_by_external_service(service_id) + + return ExternalServiceLinkedEntitiesSchema.from_model(linked_entities) + except ExternalServiceNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e diff --git a/core/edge_mining/adapters/infrastructure/external_services/repositories.py b/core/edge_mining/adapters/infrastructure/external_services/repositories.py new file mode 100644 index 0000000..9c02e41 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/external_services/repositories.py @@ -0,0 +1,350 @@ +"""Repositories for External Service.""" + +import json +import sqlite3 +from typing import List, Optional + +from sqlalchemy import select + +from edge_mining.adapters.infrastructure.external_services.tables import external_services_table +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.adapters.infrastructure.persistence.sqlite import BaseSqliteRepository +from edge_mining.domain.common import EntityId +from edge_mining.domain.exceptions import ConfigurationError +from edge_mining.shared.adapter_maps.external_services import ( + EXTERNAL_SERVICE_CONFIG_TYPE_MAP, +) +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.external_services.entities import ExternalService +from edge_mining.shared.external_services.exceptions import ( + ExternalServiceAlreadyExistsError, + ExternalServiceConfigurationError, + ExternalServiceError, + ExternalServiceNotFoundError, +) +from edge_mining.shared.external_services.ports import ExternalServiceRepository +from edge_mining.shared.interfaces.config import ExternalServiceConfig + +# Simple In-Memory implementation for testing and basic use + + +class InMemoryExternalServiceRepository(ExternalServiceRepository): + """In-memory implementation of ExternalServiceRepository for testing purposes.""" + + def __init__(self): + self._external_services: List[ExternalService] = [] + + def add(self, external_service: ExternalService) -> None: + self._external_services.append(external_service) + + def get_by_id(self, external_service_id: EntityId) -> Optional[ExternalService]: + for external_service in self._external_services: + if external_service.id == external_service_id: + return external_service + return None + + def get_all(self) -> List[ExternalService]: + return self._external_services + + def update(self, external_service: ExternalService) -> None: + for i, existing_external_service in enumerate(self._external_services): + if existing_external_service.id == external_service.id: + self._external_services[i] = external_service + return + + def remove(self, external_service_id: EntityId) -> None: + self._external_services = [n for n in self._external_services if n.id != external_service_id] + + +class SqliteExternalServiceRepository(ExternalServiceRepository): + """SQLite implementation of ExternalServiceRepository.""" + + def __init__(self, db: BaseSqliteRepository): + self._db = db + self.logger = db.logger + + self._create_tables() + + def _create_tables(self): + """Create the necessary table for the External Service if it does not exist.""" + self.logger.debug(f"Ensuring SQLite tables exist for External Service Repository in {self._db.db_path}...") + sql_statements = [ + """ + CREATE TABLE IF NOT EXISTS external_services ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + adapter_type TEXT NOT NULL, + config TEXT -- JSON object of config + ); + """ + ] + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + for statement in sql_statements: + cursor.execute(statement) + + self.logger.debug("External services tables checked/created successfully.") + except sqlite3.Error as e: + self.logger.error(f"Error creating SQLite tables: {e}") + raise ConfigurationError(f"DB error creating tables: {e}") from e + finally: + if conn: + conn.close() + + def _deserialize_config(self, adapter_type: ExternalServiceAdapter, config_json: str) -> ExternalServiceConfig: + """Deserialize a JSON string into ExternalServiceConfig object.""" + data: dict = json.loads(config_json) + + if adapter_type not in EXTERNAL_SERVICE_CONFIG_TYPE_MAP: + raise ExternalServiceConfigurationError( + f"Error reading External Service configuration. Invalid type '{adapter_type}'" + ) + + config_class: Optional[type[ExternalServiceConfig]] = EXTERNAL_SERVICE_CONFIG_TYPE_MAP.get(adapter_type) + if not config_class: + raise ExternalServiceConfigurationError( + f"Error creating External Service configuration. Type '{adapter_type}'" + ) + + config_instance = config_class.from_dict(data) + if not isinstance(config_instance, ExternalServiceConfig): + raise ExternalServiceConfigurationError( + f"Deserialized config is not of type ExternalServiceConfig for adapter '{adapter_type}'" + ) + return config_instance + + def _row_to_external_service(self, row: sqlite3.Row) -> Optional[ExternalService]: + """Deserialize a row from the database into a ExternalService object.""" + if not row: + return None + try: + adapter_type = ExternalServiceAdapter(row["adapter_type"]) + + # Deserialize the config from the database row + config = self._deserialize_config(adapter_type, row["config"]) + + return ExternalService( + id=EntityId(row["id"]), + name=row["name"], + adapter_type=adapter_type, + config=config, + ) + except (ValueError, KeyError) as e: + self.logger.error(f"Error deserializing ExternalService from DB row: {row}. Error: {e}") + return None + + def add(self, external_service: ExternalService) -> None: + """Add a new external service to the repository.""" + self.logger.debug(f"Adding external service {external_service.id} to SQLite repository.") + sql = """ + INSERT INTO external_services (id, name, adapter_type, config) + VALUES (?, ?, ?, ?); + """ + conn = self._db.get_connection() + try: + # Serialize config to JSON for storage + config_json: str = "" + if external_service.config: + config_json = json.dumps(external_service.config.to_dict()) + + with conn: + cursor = conn.cursor() + cursor.execute( + sql, + ( + external_service.id, + external_service.name, + external_service.adapter_type.value, + config_json, + ), + ) + except sqlite3.IntegrityError as e: + self.logger.error(f"Integrity error adding external service {external_service.id}: {e}") + # Could mean that the ID already exists + raise ExternalServiceAlreadyExistsError( + f"external service with ID {external_service.id} already exists or constraint violation: {e}" + ) from e + except sqlite3.Error as e: + self.logger.error(f"SQLite error adding external service {external_service.id}: {e}") + raise ExternalServiceError(f"DB error adding external service: {e}") from e + finally: + if conn: + conn.close() + + def get_by_id(self, external_service_id: EntityId) -> Optional[ExternalService]: + """Retrieve a external service by its ID.""" + self.logger.debug(f"Retrieving external service {external_service_id} from SQLite repository.") + sql = "SELECT * FROM external_services WHERE id = ?;" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql, (external_service_id,)) + row = cursor.fetchone() + return self._row_to_external_service(row) + except sqlite3.Error as e: + self.logger.error(f"SQLite error retrieving external service {external_service_id}: {e}") + raise ExternalServiceNotFoundError(f"DB error retrieving external service: {e}") from e + finally: + if conn: + conn.close() + + def get_all(self) -> List[ExternalService]: + """Retrieve all external services from the repository.""" + self.logger.debug("Retrieving all external services from SQLite repository.") + sql = "SELECT * FROM external_services;" + conn = self._db.get_connection() + try: + cursor = conn.cursor() + cursor.execute(sql) + rows = cursor.fetchall() + external_services = [] + for row in rows: + external_service = self._row_to_external_service(row) + if external_service: + external_services.append(external_service) + return external_services + except sqlite3.Error as e: + self.logger.error(f"SQLite error retrieving all external services: {e}") + return [] + finally: + if conn: + conn.close() + + def update(self, external_service: ExternalService) -> None: + """Update an existing external service in the repository.""" + self.logger.debug(f"Updating external service {external_service.id} in SQLite repository.") + sql = """ + UPDATE external_services + SET name = ?, adapter_type = ?, config = ? + WHERE id = ?; + """ + conn = self._db.get_connection() + try: + # Serialize config to JSON for storage + config_json: str = "" + if external_service.config: + config_json = json.dumps(external_service.config.to_dict()) + + with conn: + cursor = conn.cursor() + cursor.execute( + sql, + ( + external_service.name, + external_service.adapter_type.value, + config_json, + external_service.id, + ), + ) + if cursor.rowcount == 0: + raise ExternalServiceNotFoundError(f"External service with ID {external_service.id} not found.") + except sqlite3.Error as e: + self.logger.error(f"SQLite error updating external service {external_service.id}: {e}") + raise ExternalServiceError(f"DB error updating external service: {e}") from e + finally: + if conn: + conn.close() + + def remove(self, external_service_id: EntityId) -> None: + """Remove a external service from the repository.""" + self.logger.debug(f"Removing external service {external_service_id} from SQLite repository.") + sql = "DELETE FROM external_services WHERE id = ?;" + conn = self._db.get_connection() + try: + with conn: + cursor = conn.cursor() + cursor.execute(sql, (external_service_id,)) + if cursor.rowcount == 0: + self.logger.warning(f"Attempted to remove non-existent external service {external_service_id}.") + # There is no need to raise an exception here, removing a + # non-existent is idempotent. + except sqlite3.Error as e: + self.logger.error(f"SQLite error removing external service {external_service_id}: {e}") + raise ExternalServiceError(f"DB error removing external service: {e}") from e + finally: + if conn: + conn.close() + + +# SQLAlchemy implementation + + +class SqlAlchemyExternalServiceRepository(ExternalServiceRepository): + """SQLAlchemy implementation of ExternalServiceRepository. + + This repository works directly with the imperatively mapped ExternalService domain entity. + The config field is automatically converted between ExternalServiceConfig objects and JSON + strings by the custom TypeDecorator and event listener defined in tables.py. + + Args: + db: BaseSQLAlchemyRepository instance for database operations + """ + + def __init__(self, db: BaseSQLAlchemyRepository): + """Initialize repository with database instance. + + Args: + db: BaseSQLAlchemyRepository instance + """ + self._db = db + self.logger = db.logger + + def add(self, external_service: ExternalService) -> None: + """Add an external service to the repository.""" + session = self._db.get_session() + try: + session.add(external_service) + session.commit() + finally: + session.close() + + def get_by_id(self, external_service_id: EntityId) -> Optional[ExternalService]: + """Get an external service by ID.""" + session = self._db.get_session() + try: + stmt = select(ExternalService).where(external_services_table.c.id == str(external_service_id)) + entity = session.execute(stmt).scalar_one_or_none() + return entity + finally: + session.close() + + def get_all(self) -> List[ExternalService]: + """Get all external services.""" + session = self._db.get_session() + try: + stmt = select(ExternalService) + entities = session.execute(stmt).scalars().all() + return list(entities) + finally: + session.close() + + def update(self, external_service: ExternalService) -> None: + """Update an external service.""" + session = self._db.get_session() + try: + stmt = select(ExternalService).where(external_services_table.c.id == str(external_service.id)) + existing_entity = session.execute(stmt).scalar_one_or_none() + + if existing_entity: + existing_entity.name = external_service.name + existing_entity.adapter_type = external_service.adapter_type + existing_entity.config = external_service.config + + session.commit() + finally: + session.close() + + def remove(self, external_service_id: EntityId) -> None: + """Remove an external service by ID.""" + session = self._db.get_session() + try: + stmt = select(ExternalService).where(external_services_table.c.id == str(external_service_id)) + entity = session.execute(stmt).scalar_one_or_none() + + if entity: + session.delete(entity) + session.commit() + finally: + session.close() diff --git a/core/edge_mining/adapters/infrastructure/external_services/schemas.py b/core/edge_mining/adapters/infrastructure/external_services/schemas.py new file mode 100644 index 0000000..bb222c7 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/external_services/schemas.py @@ -0,0 +1,319 @@ +"""Validation schemas for external services.""" + +import uuid +from datetime import datetime +from enum import Enum +from typing import Dict, List, Optional, Union, cast + +from pydantic import BaseModel, Field, field_serializer, field_validator + +from edge_mining.adapters.domain.energy.schemas import EnergyMonitorSchema +from edge_mining.adapters.domain.forecast.schemas import ForecastProviderSchema +from edge_mining.adapters.domain.home_load.schemas import ( + EnergyLoadForecastProviderSchema, + EnergyLoadHistoryProviderSchema, +) +from edge_mining.adapters.domain.miner.schemas import MinerControllerSchema +from edge_mining.adapters.domain.notification.schemas import NotifierSchema +from edge_mining.domain.common import EntityId +from edge_mining.shared.adapter_configs.external_services import ExternalServiceHomeAssistantConfig +from edge_mining.shared.adapter_maps.external_services import EXTERNAL_SERVICE_CONFIG_TYPE_MAP +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.external_services.entities import ExternalService +from edge_mining.shared.external_services.value_objects import ( + ExternalServiceLinkedEntities, +) +from edge_mining.shared.interfaces.config import ExternalServiceConfig + + +class ExternalServiceSchema(BaseModel): + """Schema for ExternalService entity with complete validation.""" + + id: str = Field(..., description="Unique identifier for the external service") + name: str = Field(default="", description="External service name") + adapter_type: ExternalServiceAdapter = Field( + default=ExternalServiceAdapter.HOME_ASSISTANT_API, description="Type of external service adapter" + ) + config: Optional[dict] = Field(default={}, description="External service configuration") + + @field_validator("id") + @classmethod + def validate_id(cls, v: str) -> str: + """Validate that the id is a valid EntityId.""" + try: + EntityId(uuid.UUID(v)) + except ValueError as e: + raise ValueError(f"Invalid external service id: {e}") from e + return v + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate external service name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("adapter_type") + @classmethod + def validate_adapter_type(cls, v: str) -> ExternalServiceAdapter: + """Validate that adapter_type is a recognized ExternalServiceAdapter.""" + adapter_values = [adapter.value for adapter in ExternalServiceAdapter] + if v not in adapter_values: + raise ValueError(f"adapter_type must be one of {adapter_values}") + return ExternalServiceAdapter(v) + + @field_serializer("id") + def serialize_id(self, id_value: EntityId) -> str: + """Serialize EntityId to string.""" + return str(id_value) + + def to_model(self) -> ExternalService: + """Convert ExternalServiceSchema to ExternalService domain entity.""" + configuration: Optional[ExternalServiceConfig] = None + if self.config: + config_class = EXTERNAL_SERVICE_CONFIG_TYPE_MAP.get(self.adapter_type, None) + if config_class: + configuration = cast(ExternalServiceConfig, config_class.from_dict(self.config)) + + return ExternalService( + id=EntityId(uuid.uuid4()), + name=self.name, + adapter_type=self.adapter_type, + config=configuration, + ) + + @classmethod + def from_model(cls, service: ExternalService) -> "ExternalServiceSchema": + """Create ExternalServiceSchema from an ExternalService domain entity.""" + return cls( + id=str(service.id), + name=service.name, + adapter_type=service.adapter_type, + config=service.config.to_dict() if service.config else {}, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + arbitrary_types_allowed = True + json_encoders = { + EntityId: str, + ExternalServiceAdapter: lambda v: v.value, + } + + +class ExternalServiceCreateSchema(BaseModel): + """Schema for creating a new external service.""" + + name: str = Field(..., description="External service name") + adapter_type: ExternalServiceAdapter = Field( + default=ExternalServiceAdapter.HOME_ASSISTANT_API, description="Type of external service adapter" + ) + config: Optional[dict] = Field(default=None, description="External service configuration") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate external service name.""" + v = v.strip() + if not v: + v = "" + return v + + @field_validator("adapter_type") + @classmethod + def validate_adapter_type(cls, v: str) -> ExternalServiceAdapter: + """Validate that adapter_type is a recognized ExternalServiceAdapter.""" + adapter_values = [adapter.value for adapter in ExternalServiceAdapter] + if v not in adapter_values: + raise ValueError(f"adapter_type must be one of {adapter_values}") + return ExternalServiceAdapter(v) + + def to_model(self) -> ExternalService: + """Convert ExternalServiceCreateSchema to ExternalService domain entity.""" + configuration: Optional[ExternalServiceConfig] = None + if self.config: + config_class = EXTERNAL_SERVICE_CONFIG_TYPE_MAP.get(self.adapter_type, None) + if config_class: + configuration = cast(ExternalServiceConfig, config_class.from_dict(self.config)) + + return ExternalService( + id=EntityId(uuid.uuid4()), + name=self.name, + adapter_type=self.adapter_type, + config=configuration, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + json_encoders = { + EntityId: str, + ExternalServiceAdapter: lambda v: v.value, + } + + +class ExternalServiceUpdateSchema(BaseModel): + """Schema for updating an existing external service.""" + + name: str = Field(default="", description="External service name") + config: Optional[dict] = Field(default=None, description="External service configuration") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate external service name.""" + v = v.strip() + if not v: + v = "" + return v + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + +class ExternalServiceLinkedEntitiesSchema(BaseModel): + """Schema for ExternalServiceLinkedEntities value object.""" + + miner_controllers: List[MinerControllerSchema] + energy_monitors: List[EnergyMonitorSchema] + forecast_providers: List[ForecastProviderSchema] + energy_load_forecast_providers: List[EnergyLoadForecastProviderSchema] + energy_load_history_providers: List[EnergyLoadHistoryProviderSchema] + notifiers: List[NotifierSchema] + + @classmethod + def from_model(cls, linked_entities: ExternalServiceLinkedEntities) -> "ExternalServiceLinkedEntitiesSchema": + """Create ExternalServiceLinkedEntitiesSchema from ExternalServiceLinkedEntities value object.""" + return cls( + miner_controllers=[ + MinerControllerSchema.from_model(controller) for controller in linked_entities.miner_controllers + ], + energy_monitors=[EnergyMonitorSchema.from_model(monitor) for monitor in linked_entities.energy_monitors], + forecast_providers=[ + ForecastProviderSchema.from_model(provider) for provider in linked_entities.forecast_providers + ], + energy_load_forecast_providers=[ + EnergyLoadForecastProviderSchema.from_model(provider) + for provider in linked_entities.energy_load_forecast_providers + ], + energy_load_history_providers=[ + EnergyLoadHistoryProviderSchema.from_model(provider) + for provider in linked_entities.energy_load_history_providers + ], + notifiers=[NotifierSchema.from_model(notifier) for notifier in linked_entities.notifiers], + ) + + def to_model(self) -> ExternalServiceLinkedEntities: + """Convert schema to ExternalServiceLinkedEntities domain value object.""" + return ExternalServiceLinkedEntities( + miner_controllers=[item.to_model() for item in self.miner_controllers], + energy_monitors=[item.to_model() for item in self.energy_monitors], + forecast_providers=[item.to_model() for item in self.forecast_providers], + energy_load_forecast_providers=[item.to_model() for item in self.energy_load_forecast_providers], + energy_load_history_providers=[item.to_model() for item in self.energy_load_history_providers], + notifiers=[item.to_model() for item in self.notifiers], + ) + + +class ExternalServiceHomeAssistantConfigSchema(BaseModel): + """ + Schema for Home Assistant external service configuration. + It encapsulates the configuration parameters to connect to a Home Assistant instance. + """ + + url: str = Field(..., description="URL of the Home Assistant instance") + token: str = Field(..., description="Long-lived access token for Home Assistant API") + + @field_validator("url") + @classmethod + def validate_url(cls, v: str) -> str: + """Validate that the URL is not empty and is a valid URL format.""" + v = v.strip() + if not v: + raise ValueError("URL must not be empty") + # Basic URL format validation + if not (v.startswith("http://") or v.startswith("https://")): + raise ValueError("URL must start with http:// or https://") + return v + + @field_validator("token") + @classmethod + def validate_token(cls, v: str) -> str: + """Validate that the token is not empty.""" + v = v.strip() + if not v: + raise ValueError("Token must not be empty") + return v + + def to_model(self) -> ExternalServiceConfig: + """Convert schema to ExternalServiceConfig domain entity.""" + return ExternalServiceHomeAssistantConfig( + url=self.url, + token=self.token, + ) + + @classmethod + def from_model(cls, config: ExternalServiceConfig) -> "ExternalServiceHomeAssistantConfigSchema": + """Create schema from an ExternalServiceConfig domain entity.""" + if not isinstance(config, ExternalServiceHomeAssistantConfig): + raise ValueError("Invalid config type for Home Assistant configuration schema") + return cls( + url=config.url, + token=config.token, + ) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + +class ExternalServiceStatusEnum(str, Enum): + """Enum for external service connection status.""" + + CONNECTED = "connected" + DISCONNECTED = "disconnected" + UNAUTHORIZED = "unauthorized" + + +class ExternalServiceStatusSchema(BaseModel): + """Schema for external service status response.""" + + name: str = Field(..., description="Name of the external service") + status: ExternalServiceStatusEnum = Field(..., description="Connection status of the external service") + last_check: datetime = Field(..., description="Timestamp of the last status check") + error_message: Optional[str] = Field(default=None, description="Error message if status is not connected") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate external service name.""" + v = v.strip() + if not v: + raise ValueError("Name must not be empty") + return v + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + json_encoders = { + datetime: lambda v: v.isoformat(), + } + + +EXTERNAL_SERVICE_CONFIG_SCHEMA_MAP: Dict[ + type[ExternalServiceConfig], Union[type[ExternalServiceHomeAssistantConfigSchema]] +] = {ExternalServiceHomeAssistantConfig: ExternalServiceHomeAssistantConfigSchema} diff --git a/core/edge_mining/adapters/infrastructure/external_services/tables.py b/core/edge_mining/adapters/infrastructure/external_services/tables.py new file mode 100644 index 0000000..ccafc6f --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/external_services/tables.py @@ -0,0 +1,125 @@ +"""SQLAlchemy ORM mappings for ExternalService entities. + +This module implements imperative (classical) mapping of the domain entities +to database tables. The domain entities are mapped directly without +creating separate ORM model classes, maintaining domain purity. + +All tables and mappings use the shared metadata and mapper registry from +the sqlalchemy.registry module, which are available as module-level singletons. + +⚠️ DEVELOPER WARNING ⚠️ +═══════════════════════════════════════════════════════════════════════════════ +ANY SCHEMA CHANGE (adding/removing/modifying tables or columns) REQUIRES an +Alembic migration. Do NOT modify this file without creating a migration: + + python scripts/migrate.py create "Description of your change" + +For detailed instructions, see: ../docs/ALEMBIC_MIGRATIONS.md +For a step-by-step example, see: ../docs/MIGRATION_EXAMPLE.md +═══════════════════════════════════════════════════════════════════════════════ +""" + +import json +import uuid +from typing import Any, Optional + +from sqlalchemy import Column, String, Table, event + +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.common import ConfigurationType +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.registry import mapper_registry, metadata +from edge_mining.domain.common import EntityId +from edge_mining.shared.adapter_maps.external_services import EXTERNAL_SERVICE_CONFIG_TYPE_MAP +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.external_services.entities import ExternalService +from edge_mining.shared.external_services.exceptions import ExternalServiceConfigurationError +from edge_mining.shared.interfaces.config import ExternalServiceConfig + + +class ExternalServiceConfigType(ConfigurationType): + """SQLAlchemy type for ExternalServiceConfig serialization. + + Inherits from ConfigurationType to handle JSON serialization/deserialization. + """ + + +def _deserialize_external_service_config( + adapter_type: ExternalServiceAdapter, config_json: str +) -> Optional[ExternalServiceConfig]: + """Deserialize JSON string to ExternalServiceConfig based on adapter type.""" + if not config_json: + return None + + data: dict = json.loads(config_json) + + if adapter_type not in EXTERNAL_SERVICE_CONFIG_TYPE_MAP: + raise ExternalServiceConfigurationError( + f"Error reading ExternalService configuration. Invalid type '{adapter_type}'" + ) + + config_class: Optional[type[ExternalServiceConfig]] = EXTERNAL_SERVICE_CONFIG_TYPE_MAP.get(adapter_type) + if not config_class: + raise ExternalServiceConfigurationError(f"Error creating ExternalService configuration. Type '{adapter_type}'") + + config_instance = config_class.from_dict(data) + if not isinstance(config_instance, ExternalServiceConfig): + raise ExternalServiceConfigurationError( + f"Deserialized config is not of type ExternalServiceConfig for adapter type {adapter_type}." + ) + return config_instance + + +@event.listens_for(ExternalService, "load") +def _receive_external_service_load(target: ExternalService, context) -> None: + """Event listener that deserializes config after loading from database.""" + # Convert id string to EntityId if needed + if hasattr(target, "id") and target.id is not None: + if isinstance(target.id, str): # type: ignore[arg-type,misc] + target.id = EntityId(uuid.UUID(target.id)) # type: ignore[assignment] + + # Convert adapter_type from string to enum if necessary + if isinstance(target.adapter_type, str): + try: + target.adapter_type = ExternalServiceAdapter(target.adapter_type) + except ValueError: + pass + + if target.config and isinstance(target.config, str): + target.config = _deserialize_external_service_config(target.adapter_type, target.config) + + +@event.listens_for(ExternalService, "before_insert") +@event.listens_for(ExternalService, "before_update") +def _flatten_external_service_composites(mapper, connection, target: Any) -> None: + """Convert enum attributes to primitive values before persisting.""" + if hasattr(target, "adapter_type") and target.adapter_type is not None: + if isinstance(target.adapter_type, ExternalServiceAdapter): + target.adapter_type = target.adapter_type.value + + +@event.listens_for(ExternalService, "after_insert") +@event.listens_for(ExternalService, "after_update") +def _restore_external_service_composites(mapper, connection, target: Any) -> None: + """Restore enum attributes after persist operations.""" + if hasattr(target, "adapter_type") and target.adapter_type is not None: + if isinstance(target.adapter_type, str): + try: + target.adapter_type = ExternalServiceAdapter(target.adapter_type) + except ValueError: + pass + + +# Define the external_services table using imperative style +external_services_table = Table( + "external_services", + metadata, + Column("id", String, primary_key=True, index=True), + Column("name", String, nullable=False), + Column("adapter_type", String, nullable=False), + Column("config", ExternalServiceConfigType, nullable=True), +) + +# Map ExternalService +mapper_registry.map_imperatively( + ExternalService, + external_services_table, +) diff --git a/core/edge_mining/adapters/infrastructure/homeassistant/__init__.py b/core/edge_mining/adapters/infrastructure/homeassistant/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/adapters/infrastructure/homeassistant/homeassistant_api.py b/core/edge_mining/adapters/infrastructure/homeassistant/homeassistant_api.py new file mode 100644 index 0000000..0d5fae8 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/homeassistant/homeassistant_api.py @@ -0,0 +1,514 @@ +""" +The Home Assistant API Infrastructure External Service Adapter + +The REST API for Home Assistant has been superseded by the websocket API. +I use it only for simplicity, in the future I plan to switch to websocket API + +https://github.com/home-assistant/architecture/discussions/1074#discussioncomment-9196867 + +and + +https://github.com/home-assistant/developers.home-assistant/pull/2150 +""" + +import asyncio +import math # For isnan +from typing import List, Optional, Tuple + +import aiohttp +from homeassistant_api import Client, Domain, Entity, History +from homeassistant_api.errors import ( + EndpointNotFoundError, + HomeassistantAPIError, + RequestError, + RequestTimeoutError, + UnauthorizedError, +) + +from edge_mining.adapters.infrastructure.homeassistant.models import EntityHistory, HistoryDataPoint +from edge_mining.adapters.infrastructure.homeassistant.utils import ( + STATE_SERVICE_MAP, + SWITCH_STATE_MAP, + SwitchDomain, + TurnService, +) +from edge_mining.domain.common import Percentage, Timestamp, WattHours, Watts +from edge_mining.shared.adapter_configs.external_services import ( + ExternalServiceHomeAssistantConfig, +) +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.external_services.exceptions import ( + ExternalServiceConfigurationError, + ExternalServiceError, +) +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import ExternalServiceConfig +from edge_mining.shared.interfaces.factories import ExternalServiceFactory +from edge_mining.shared.logging.port import LoggerPort + + +class ServiceHomeAssistantAPI(ExternalServicePort): + """ + Use Home Assistant instance via its REST API as external service. + + Requires careful configuration of HA parameters in the .env file. + """ + + def __init__(self, api_url: str, token: str, logger: Optional[LoggerPort]): + super().__init__(external_service_type=ExternalServiceAdapter.HOME_ASSISTANT_API) + self.logger = logger + + if not api_url or not token: + raise ValueError("Home Assistant URL and Token are required.") + + # Remove final slash if present + api_url = api_url.rstrip("/") + + self.api_url = f"{api_url}/api" + self.token = token + + self.client: Optional[Client] = None + + async def connect(self) -> None: + """Connect to the Home Assistant API.""" + if self.logger: + self.logger.info(f"Initializing HomeAssistantAPI for {self.api_url}") + + # Initialize Home Assistant client + try: + self.client = Client(self.api_url, self.token, use_async=True) + + # Test connection during initialization (optional but recommended) + await self.client.async_get_config() + if self.logger: + self.logger.info("Successfully connected to Home Assistant API.") + except UnauthorizedError: + self.client = None + if self.logger: + self.logger.error( + "Home Assistant API authentication failed during connection. " + "Please verify your access token is valid." + ) + except (RequestError, HomeassistantAPIError) as e: + self.client = None + if self.logger: + self.logger.error(f"Home Assistant API error during connection: {e}") + except aiohttp.ClientError: + self.client = None + if self.logger: + self.logger.warning( + f"Home Assistant is unreachable at {self.api_url}. The service will be marked as disconnected." + ) + except Exception as e: + self.client = None + if self.logger: + self.logger.error(f"An unexpected error occurred connecting to Home Assistant: {e}") + + async def disconnect(self) -> None: + """Disconnect from the Home Assistant API.""" + if self.logger: + self.logger.info("Disconnecting from Home Assistant API.") + + if self.client: + try: + await self.client.async_cache_session.close() + except Exception: + pass + self.client = None + + async def is_connected(self) -> bool: + """Check if the external service is connected.""" + if not self.client: + if self.logger: + self.logger.error("Home Assistant client is not connected.") + return False + + try: + await self.client.async_get_config() + if self.logger: + self.logger.info("Successfully connected to Home Assistant API.") + return True + except (Exception, HomeassistantAPIError) as e: + if self.logger: + self.logger.error(f"Home Assistant API connection check failed: {e}") + return False + + async def get_entity_state(self, entity_id: Optional[str]) -> Tuple[Optional[str], Optional[str]]: + """Safely retrieves the state and unit of an entity.""" + if not entity_id: + return None, None + if not self.client: + if self.logger: + self.logger.error("Home Assistant client is not initialized.") + return None, None + try: + entity: Optional[Entity] = await self.client.async_get_entity(entity_id=entity_id) + if not entity: + if self.logger: + self.logger.warning(f"Home Assistant entity '{entity_id}' not found.") + return None, None + # Check if state is unavailable or unknown + state = entity.state.state # The actual value as a string + if state is None or state.lower() in ["unavailable", "unknown"]: + if self.logger: + self.logger.warning(f"Home Assistant entity '{entity_id}' is unavailable or unknown.") + return None, None + + unit = entity.state.attributes.get("unit_of_measurement") + if self.logger: + self.logger.debug(f"Fetched HA entity '{entity_id}': State='{state}', Unit='{unit}'") + return state, unit + except EndpointNotFoundError: + if self.logger: + self.logger.error( + f"Home Assistant entity '{entity_id}' does not exist. " + "Please verify the entity ID is correct in your configuration." + ) + return None, None + except UnauthorizedError: + if self.logger: + self.logger.error("Home Assistant API authentication failed. Please verify your access token is valid.") + return None, None + except RequestTimeoutError: + if self.logger: + self.logger.error( + f"Home Assistant API request timed out while fetching entity '{entity_id}'. " + "The server may be overloaded or unreachable." + ) + return None, None + except (RequestError, HomeassistantAPIError) as e: + if self.logger: + self.logger.error(f"Home Assistant API error while fetching entity '{entity_id}': {e}") + return None, None + except Exception as e: + if self.logger: + self.logger.error(f"Unexpected error getting Home Assistant entity '{entity_id}': {e}") + return None, None + + async def set_entity_state(self, entity_id: Optional[str], state: str) -> bool: + """Sets the state of an entity.""" + if not entity_id: + return False + if not self.client: + if self.logger: + self.logger.error("Home Assistant client is not initialized.") + return False + + try: + # Home Assistant does not allow setting state directly via the API for most entities. + # Instead, a common method is typically call a service. + + # Get the entity domain (e.g., 'switch', 'light') from the entity_id + domain_str = entity_id.split(".")[0] + + switchable_domains = [s.value for s in SwitchDomain] + if domain_str not in switchable_domains: + if self.logger: + self.logger.error(f"Setting state for domain '{domain_str}' is not supported.") + return False + + state = state.lower() + if state not in STATE_SERVICE_MAP: + if self.logger: + self.logger.error( + f"Invalid state '{state}' for entity '{entity_id}'. " + f"Must be one of {list(STATE_SERVICE_MAP.keys())}." + ) + return False + + # Get the domain object + domain: Optional[Domain] = await self.client.async_get_domain(domain_str) + if not domain: + if self.logger: + self.logger.error(f"Home Assistant domain '{domain_str}' not found.") + return False + + turn_service: TurnService = STATE_SERVICE_MAP[state] + + if turn_service.value not in domain.services: + if self.logger: + self.logger.error(f"Service '{turn_service.value}' not available for domain '{domain_str}'.") + return False + + # Call the service to change the state + await self.client.async_trigger_service(domain=domain_str, service=turn_service.value, entity_id=entity_id) + + if self.logger: + self.logger.debug( + f"Request to set HA entity '{entity_id}' to state '{state}' via service '{turn_service}'." + ) + + # Due to async nature of HA, we may not get the updated state immediately and this check may fail + # even if the command was successful, so we need to wait a bit to get the updated state + await asyncio.sleep(1) # Wait a moment for the state to update + current_state_str, _ = await self.get_entity_state(entity_id) + current_state_str = current_state_str.lower() if current_state_str else "" + current_state_value: Optional[bool] = SWITCH_STATE_MAP.get(current_state_str, None) + desired_state_value: Optional[bool] = SWITCH_STATE_MAP.get(state, None) + + if current_state_value is not None and desired_state_value is not None: + if current_state_value != desired_state_value: + if self.logger: + self.logger.error( + f"Failed to set Home Assistant entity '{entity_id}' to state '{state}'. " + f"Current state is '{current_state_value}'." + ) + return False + + if self.logger: + self.logger.debug(f"Successfully set HA entity '{entity_id}' to state '{state}'.") + + return True + except EndpointNotFoundError: + if self.logger: + self.logger.error( + f"Home Assistant entity '{entity_id}' does not exist. " + "Please verify the entity ID is correct in your configuration." + ) + return False + except UnauthorizedError: + if self.logger: + self.logger.error("Home Assistant API authentication failed. Please verify your access token is valid.") + return False + except RequestTimeoutError: + if self.logger: + self.logger.error( + f"Home Assistant API request timed out while setting entity '{entity_id}'. " + "The server may be overloaded or unreachable." + ) + return False + except (RequestError, HomeassistantAPIError) as e: + if self.logger: + self.logger.error(f"Home Assistant API error while setting entity '{entity_id}': {e}") + return False + except Exception as e: + if self.logger: + self.logger.error(f"Unexpected error setting Home Assistant entity '{entity_id}': {e}") + return False + + async def get_entity_history(self, entity_id: str, start: Timestamp, end: Timestamp) -> Optional[EntityHistory]: + """Retrieves the history of a Home Assistant entity.""" + if self.logger: + self.logger.debug(f"Fetching history for entity '{entity_id}' from {start} to {end}...") + + if not entity_id: + if self.logger: + self.logger.debug("No entity_id provided for history fetch.") + return None + + if not self.client: + if self.logger: + self.logger.error("Home Assistant client is not initialized.") + return None + + # The homeassistant_api library's construct_params does not URL-encode + # query values, so a "+" in "+00:00" is interpreted as a space by the + # HA server, causing "Invalid end_time". Work around this by converting + # to naive UTC datetimes – HA treats naive timestamps as UTC. + from datetime import timezone as _tz + + _start = start.astimezone(_tz.utc).replace(tzinfo=None) if start.tzinfo else start + _end = end.astimezone(_tz.utc).replace(tzinfo=None) if end.tzinfo else end + + try: + entity: Optional[Entity] = await self.client.async_get_entity(entity_id=entity_id) + + if not entity: + if self.logger: + self.logger.warning(f"Home Assistant entity '{entity_id}' not found.") + return None + + history_for_entity: Optional[History] = None + async for history in self.client.async_get_entity_histories((entity,), _start, _end): + history_for_entity = history + break + + if not history_for_entity: + if self.logger: + self.logger.debug(f"No history found for entity '{entity_id}'.") + return None + + if self.logger: + self.logger.debug( + f"Retrieved history for entity '{entity_id}' with {len(history_for_entity.states)} entries." + ) + + # history_for_entity.states is a tuple of State objects. + # We iterate over it to create a list of HistoryDataPoint objects. + data_points: List[HistoryDataPoint] = [] + for state in history_for_entity.states: + if state.last_updated is None: + if self.logger: + self.logger.warning( + f"State entry for entity '{entity_id}' has no 'last_updated' timestamp. Skipping entry." + ) + continue + + data_points.append( + HistoryDataPoint( + timestamp=Timestamp(state.last_updated), + value=state.state, + unit=state.attributes.get("unit_of_measurement", ""), + ) + ) + + if self.logger: + self.logger.debug( + f"Retrieved and processed {len(data_points)} history entries for entity '{entity_id}'." + ) + + entity_history = EntityHistory(entity_id=entity_id, history=data_points) + entity_history.sort_by_timestamp() + + return entity_history + except Exception as e: + if self.logger: + self.logger.error(f"Error fetching history for entity '{entity_id}': {e}") + return None + + def parse_power( + self, + state: Optional[str], + configured_unit: str, + entity_id_for_log: str, + ) -> Optional[Watts]: + """Parses state string to Watts, handling units (W/kW) and errors.""" + if state is None: + return None + try: + value = float(state) + if math.isnan(value): + if self.logger: + self.logger.warning( + f"Parsed NaN value for entity '{entity_id_for_log}', state='{state}'. Treating as missing." + ) + return None + if configured_unit.lower() == "kw": + value *= 1000 # Convert kW to W + elif configured_unit.lower() != "w": + if self.logger: + self.logger.warning( + f"Unsupported unit '{configured_unit}' " + f"configured for entity '{entity_id_for_log}'. " + f"Assuming Watts." + ) + + return Watts(value) + except (ValueError, TypeError) as e: + if self.logger: + self.logger.error( + f"Could not parse power value for entity '{entity_id_for_log}' from state='{state}': {e}" + ) + return None + + def parse_energy( + self, + state: Optional[str], + configured_unit: str, + entity_id_for_log: str, + ) -> Optional[WattHours]: + """Parses state string to Watt Hours, handling units (Wh/kWh) and errors.""" + if state is None: + return None + try: + value = float(state) + if math.isnan(value): + if self.logger: + self.logger.warning( + f"Parsed NaN value for entity '{entity_id_for_log}', state='{state}'. Treating as missing." + ) + return None + if configured_unit.lower() == "kwh": + value *= 1000 # Convert kWh to Wh + elif configured_unit.lower() != "wh": + if self.logger: + self.logger.warning( + f"Unsupported unit '{configured_unit}' " + f"configured for entity '{entity_id_for_log}'. " + f"Assuming WattHours." + ) + + return WattHours(value) + except (ValueError, TypeError) as e: + if self.logger: + self.logger.error( + f"Could not parse energy value for entity '{entity_id_for_log}' from state='{state}': {e}" + ) + return None + + def parse_percentage(self, state: Optional[str], entity_id_for_log: str) -> Optional[Percentage]: + """Parses state string to Percentage, handling errors.""" + if state is None: + return None + try: + value = float(state) + if math.isnan(value): + if self.logger: + self.logger.warning( + f"Parsed NaN value for entity '{entity_id_for_log}', state='{state}'. Treating as missing." + ) + return None + return Percentage(max(0.0, min(100.0, value))) # Clamp between 0 and 100 + except (ValueError, TypeError) as e: + if self.logger: + self.logger.error( + f"Could not parse percentage value for entity '{entity_id_for_log}' from state='{state}': {e}" + ) + return None + + def parse_bool(self, state: Optional[str], entity_id_for_log: str) -> Optional[bool]: + """Parses state string to boolean, handling errors.""" + if state is None: + return None + try: + state_lower = state.lower() + if state_lower in ["on", "true", "1"]: + return True + elif state_lower in ["off", "false", "0"]: + return False + elif state_lower in ["unavailable", "unknown"]: + return None + else: + if self.logger: + self.logger.warning( + f"Could not parse boolean value for entity '{entity_id_for_log}' from state='{state}'." + ) + return None + except Exception as e: + if self.logger: + self.logger.error( + f"Unexpected error parsing boolean value for entity '{entity_id_for_log}' from state='{state}': {e}" + ) + return None + + +class ServiceHomeAssistantAPIFactory(ExternalServiceFactory): + """ + Creates a factory for Home Assistant API External Service. + + This factory aims to simplifying the building of Home Assistant API. + """ + + def create(self, config: Optional[ExternalServiceConfig], logger: Optional[LoggerPort]) -> ExternalServicePort: + """Create an Home Assistant API Service""" + + if not config: + raise ExternalServiceConfigurationError("Configuration is required for Home Assistant API service.") + + if not isinstance(config, ExternalServiceHomeAssistantConfig): + raise ExternalServiceError("Invalid configuration type for Home Assistant API service.") + + # Get the config from the external service config + external_service_ha_config: ExternalServiceHomeAssistantConfig = config + + if external_service_ha_config.url is None: + raise ExternalServiceConfigurationError("URL is required for Home Assistant API service.") + + if external_service_ha_config.token is None: + raise ExternalServiceConfigurationError("Token is required for Home Assistant API service.") + + return ServiceHomeAssistantAPI( + api_url=external_service_ha_config.url, + token=external_service_ha_config.token, + logger=logger, + ) diff --git a/core/edge_mining/adapters/infrastructure/homeassistant/models.py b/core/edge_mining/adapters/infrastructure/homeassistant/models.py new file mode 100644 index 0000000..8fef285 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/homeassistant/models.py @@ -0,0 +1,27 @@ +"""Collection of data models for Home Assistant integration.""" + +from dataclasses import dataclass +from typing import List, Optional + +from edge_mining.domain.common import Timestamp + + +@dataclass +class HistoryDataPoint: + """A single data point in the history of an entity.""" + + timestamp: Timestamp + value: str + unit: Optional[str] + + +@dataclass +class EntityHistory: + """Historical data for a specific entity.""" + + entity_id: str + history: List[HistoryDataPoint] + + def sort_by_timestamp(self) -> None: + """Sorts the history data points by their timestamp.""" + self.history.sort(key=lambda point: point.timestamp) diff --git a/core/edge_mining/adapters/infrastructure/homeassistant/utils.py b/core/edge_mining/adapters/infrastructure/homeassistant/utils.py new file mode 100644 index 0000000..28fd0da --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/homeassistant/utils.py @@ -0,0 +1,47 @@ +"""Collection of utility for Home Assistant integration.""" + +from enum import Enum +from typing import Dict + + +class SwitchDomain(Enum): + """Enum for the different switch domains in Home Assistant.""" + + SWITCH = "switch" + LIGHT = "light" + FAN = "fan" + + +class TurnService(Enum): + """Enum for the different services in Home Assistant.""" + + TURN_ON = "turn_on" + TURN_OFF = "turn_off" + + +class EntityState(Enum): + """Enum for the different states of an entity.""" + + ON = "on" + OFF = "off" + UNAVAILABLE = "unavailable" + UNKNOWN = "unknown" + + +STATE_SERVICE_MAP: Dict[str, TurnService] = { + "on": TurnService.TURN_ON, + "true": TurnService.TURN_ON, + "1": TurnService.TURN_ON, + "off": TurnService.TURN_OFF, + "false": TurnService.TURN_OFF, + "0": TurnService.TURN_OFF, +} + +SWITCH_STATE_MAP: Dict[str, bool] = { + "on": True, + "true": True, + "1": True, + "off": False, + "false": False, + "0": False, +} diff --git a/core/edge_mining/adapters/infrastructure/logging/__init__.py b/core/edge_mining/adapters/infrastructure/logging/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/adapters/infrastructure/logging/terminal_logging.py b/core/edge_mining/adapters/infrastructure/logging/terminal_logging.py new file mode 100644 index 0000000..2e838c2 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/logging/terminal_logging.py @@ -0,0 +1,138 @@ +"""The terminal log.""" + +import json +import sys +import traceback +from pprint import pformat + +from loguru import logger + +from edge_mining.shared.logging.port import LoggerPort + + +class TerminalLogger(LoggerPort): + """Terminal logger class.""" + + def __init__(self, name="", log_level="INFO"): + self.name = name + self.log_level = log_level + self.default_log() + + def show_log_level(self, record): + """Allows to show stuff in the log based on the global setting.""" + pass + + def default_log(self): + """Set the same debug level to all the project dependencies.""" + logger.remove() + + logger.add( + sys.stdout, + level=self.log_level, + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}", + colorize=True, + backtrace=False, + diagnose=True, + ) + + # logging.basicConfig( + # level=self.log_level, + # format='%(asctime)s - %(levelname)s - %(message)s', + # handlers=[logging.StreamHandler(sys.stdout)] # Log to console + # ) + + # self.logger = logging.getLogger(self.name) + + def __call__(self, msg, level="DEBUG"): + """Alias of self.log()""" + self.log(msg, level) + + def debug(self, msg): + """Logs a DEBUG message""" + self.log(msg, level="DEBUG") + + def info(self, msg): + """Logs an INFO message""" + self.log(msg, level="INFO") + + def warning(self, msg): + """Logs a WARNING message""" + self.log(msg, level="WARNING") + + def error(self, msg): + """Logs an ERROR message""" + self.log(msg, level="ERROR") + + # Only print the traceback if an exception handler is being executed + if sys.exc_info()[0] is not None: + traceback.print_exc() + + def critical(self, msg): + """Logs a CRITICAL message""" + self.log(msg, level="CRITICAL") + + # Only print the traceback if an exception handler is being executed + if sys.exc_info()[0] is not None: + traceback.print_exc() + + def log(self, msg, level="DEBUG"): + """Log a message""" + + # prettify + if isinstance(msg, str): + pass + elif type(msg) in [dict, list]: # TODO: should be recursive + try: + msg = json.dumps(msg, indent=4) + except Exception: + msg = msg + else: + msg = pformat(msg) + + log_method = getattr(logger, level.lower(), logger.debug) # Default to debug if level is unknown + + log_method(msg) + + def welcome(self): + """Welcome message in the terminal.""" + + print("\n\n") + with open("edge_mining/welcome.txt", "r", encoding="utf-8") as f: + print(f.read()) + print("\n\n") + + print("Hey! 👋 I'm Edge Mining. Mine your energy! ⚡⛏️") + print("\n\n") + + def shutdown(self): + """Sure that log are written to the file before exiting.""" + + logger.complete() + + # Print a goodbye message + print("Shutting down...") + print("Goodbye! 👋") + + def log_examples(self): + """Log examples for the log engine.""" + + for c in [ + self, + "Hello from logging!", + {"ready", "set", "go"}, + [1, 4, "finchelabarcavalascialandare"], + {"a": 1, "b": {"c": 2}}, + ]: + self.debug(c) + self.info(c) + self.warning(c) + self.error(c) + self.critical(c) + + def intentional_error(): + print(42 / 0) + + try: + intentional_error() + except Exception: + self.error("This error is just for demonstration purposes. Don't worry, I got it covered! 😉") diff --git a/core/edge_mining/adapters/infrastructure/messaging/__init__.py b/core/edge_mining/adapters/infrastructure/messaging/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/adapters/infrastructure/messaging/mqtt.py b/core/edge_mining/adapters/infrastructure/messaging/mqtt.py new file mode 100644 index 0000000..cdf9044 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/messaging/mqtt.py @@ -0,0 +1,30 @@ +"""MQTT message bus adapter for edge mining application.""" + +import threading +import time +from typing import List, Optional + +import paho.mqtt.client as mqtt + + +class BaseMQTTBus: + """Base class for MQTT message bus.""" + + def __init__( + self, + broker_host: str, + broker_port: int, + username: Optional[str], + password: Optional[str], + client_id: str, + topics: List[str], # Map internal name to topic string + ): + self.broker_host = broker_host + self.broker_port = broker_port + self.username = username + self.password = password + self.client_id = f"{client_id}-{int(time.time())}" # Add timestamp for more uniqueness + self._connected = threading.Event() + self._client: Optional[mqtt.Client] = None + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() diff --git a/core/edge_mining/adapters/infrastructure/persistence/__init__.py b/core/edge_mining/adapters/infrastructure/persistence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/adapters/infrastructure/persistence/sqlalchemy/__init__.py b/core/edge_mining/adapters/infrastructure/persistence/sqlalchemy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/adapters/infrastructure/persistence/sqlalchemy/base.py b/core/edge_mining/adapters/infrastructure/persistence/sqlalchemy/base.py new file mode 100644 index 0000000..7417c32 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/persistence/sqlalchemy/base.py @@ -0,0 +1,262 @@ +"""Database configuration and session management for SQLAlchemy. + +This module provides centralized SQLAlchemy configuration similar to BaseSqliteRepository. +It manages the database engine, session factory, metadata, and a shared mapper registry +for imperative mapping of domain entities. + +The BaseSQLAlchemyRepository class handles: +- Engine and session factory creation +- Database schema initialization (migrations + table creation) +- Shared metadata and mapper registry management +""" + +from typing import Generator, Optional + +from sqlalchemy import Engine, MetaData, create_engine +from sqlalchemy.orm import Session, registry, sessionmaker + +# Import registry_loader at module level to ensure all table definitions are registered +# This must happen before any migration or table creation operations +from edge_mining.adapters.infrastructure.persistence.sqlalchemy import registry_loader # noqa: F401 +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.migrations import run_migrations +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.registry import mapper_registry, metadata +from edge_mining.shared.logging.port import LoggerPort + + +class BaseSQLAlchemyRepository: + """Base class for SQLAlchemy repositories. + + This class provides centralized database configuration and session management, + similar to BaseSqliteRepository. It creates a single engine, session factory, + and shared mapper registry for imperative mapping. + + Attributes: + db_path: Path to the database file (for SQLite) or database URL + logger: Logger instance for logging database operations + echo: If True, SQLAlchemy will log all SQL statements + """ + + # Shared resources + _engine: Optional[Engine] = None + _SessionLocal = None + + def __init__( + self, + db_path: str, + logger: Optional[LoggerPort] = None, + echo: bool = False, + run_migrations: bool = True, + backup_before_migration: bool = True, + ): + """Initialize the SQLAlchemy repository base. + + Args: + db_path: Database path/URL. If None, uses DATABASE_URL env var or default SQLite path + logger: Logger instance for logging database operations + echo: If True, SQLAlchemy will log all SQL statements + run_migrations: If True, automatically run Alembic migrations + backup_before_migration: If True, create database backup before running migrations + """ + self.logger = logger + self.db_path = db_path + self.run_migrations = run_migrations + self.backup_before_migration = backup_before_migration + + # Initialize shared resources if not already initialized + if BaseSQLAlchemyRepository._engine is None: + self._initialize_shared_resources(echo) + + def _initialize_shared_resources(self, echo: bool = False) -> None: + """Initialize shared database resources (engine, session factory, metadata, registry). + + Args: + echo: If True, SQLAlchemy will log all SQL statements + """ + if self.logger: + self.logger.debug(f"Initializing SQLAlchemy with database: {self.db_path}") + + # Create engine with appropriate settings + connect_args = {} + if self.db_path.startswith("sqlite"): + connect_args = {"check_same_thread": False} + + BaseSQLAlchemyRepository._engine = create_engine( + self.db_path, + connect_args=connect_args, + echo=echo, + ) + + # Create session factory + # expire_on_commit=False prevents attributes from being marked as expired + # after commit, allowing detached objects to retain their values + BaseSQLAlchemyRepository._SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + expire_on_commit=False, + bind=BaseSQLAlchemyRepository._engine, + ) + + if self.logger: + self.logger.info("SQLAlchemy initialized successfully") + + @classmethod + def get_mapper_registry(cls) -> registry: + """Get the shared mapper registry for imperative mappings. + + Returns: + Shared registry instance for mapping domain entities to tables + """ + return mapper_registry + + @classmethod + def get_metadata(cls) -> MetaData: + """Get the shared metadata instance for table definitions. + + Returns: + Shared MetaData instance + """ + return metadata + + @classmethod + def get_engine(cls) -> Engine: + """Get the shared SQLAlchemy engine. + + Returns: + SQLAlchemy Engine instance + """ + if cls._engine is None: + raise RuntimeError("Engine not initialized. Create a BaseSQLAlchemyRepository instance first.") + return cls._engine + + def get_session(self) -> Session: + """Create a new database session. + + Returns: + New SQLAlchemy Session instance + + Note: + Caller is responsible for closing the session + """ + if BaseSQLAlchemyRepository._SessionLocal is None: + raise RuntimeError("Session factory not initialized.") + return BaseSQLAlchemyRepository._SessionLocal() + + def get_db(self) -> Generator[Session, None, None]: + """Dependency injection helper for database sessions. + + Yields: + Session: SQLAlchemy database session + + Usage: + Can be used with FastAPI's Depends() or manually in application layer. + """ + session = self.get_session() + try: + yield session + finally: + session.close() + + def initialize_database(self) -> None: + """Initialize database schema using Alembic migrations. + + This method handles the complete database initialization workflow: + 1. Imports all table definitions (via registry_loader imported at module level) + 2. Runs Alembic migrations to create/update the database schema + 3. Validates data integrity + + Database schema creation is EXCLUSIVELY managed through Alembic migrations: + - On first run: Alembic creates the database and applies the initial migration + - On subsequent runs: Alembic applies only pending migrations + - All schema changes (new tables, columns, indexes, etc.) must be done via migrations + + If migrations are disabled (run_migrations=False), the database must be + initialized manually using: alembic upgrade head + + Raises: + RuntimeError: If engine is not initialized or no migrations are found + Exception: If migrations fail to execute + """ + if self.logger: + self.logger.debug("Initializing database schema...") + + # Run Alembic migrations if enabled + if self.run_migrations: + if self.logger: + self.logger.info("Running Alembic migrations...") + + try: + run_migrations( + db_url=self.db_path, + logger=self.logger, + backup_enabled=self.backup_before_migration, + ) + + if self.logger: + self.logger.info("Database initialization complete") + except Exception as e: + if self.logger: + self.logger.error(f"Failed to run migrations: {e}") + raise + else: + if self.logger: + self.logger.warning( + "Automatic migrations disabled. Database must be initialized manually with: alembic upgrade head" + ) + + # Validate data integrity after migrations + self._cleanup_unknown_miner_features() + + def _cleanup_unknown_miner_features(self) -> None: + """Remove miner_features rows whose feature_type is not in MinerFeatureType. + + This handles the case where feature types were renamed or removed between + application versions, preventing load errors at runtime. + """ + from sqlalchemy import select + + from edge_mining.adapters.domain.miner.tables import miner_features_table + from edge_mining.domain.miner.common import MinerFeatureType + + valid_types = {ft.value for ft in MinerFeatureType} + + try: + session = self.get_session() + try: + rows = session.execute( + select( + miner_features_table.c.id, + miner_features_table.c.feature_type, + miner_features_table.c.miner_id, + ) + ).fetchall() + + unknown_rows = [r for r in rows if r.feature_type not in valid_types] + + if not unknown_rows: + return + + unknown_ids = [r.id for r in unknown_rows] + unknown_descriptions = [ + f"feature_type='{r.feature_type}' (miner_id={r.miner_id})" for r in unknown_rows + ] + + if self.logger: + self.logger.warning( + f"Found {len(unknown_rows)} miner feature(s) with unknown type, removing: " + + ", ".join(unknown_descriptions) + ) + + session.execute(miner_features_table.delete().where(miner_features_table.c.id.in_(unknown_ids))) + session.commit() + + if self.logger: + self.logger.info(f"Removed {len(unknown_ids)} obsolete miner feature(s) from database.") + except Exception as e: + session.rollback() + if self.logger: + self.logger.error(f"Failed to clean up unknown miner features: {e}") + finally: + session.close() + except Exception as e: + if self.logger: + self.logger.error(f"Failed to clean up unknown miner features: {e}") diff --git a/core/edge_mining/adapters/infrastructure/persistence/sqlalchemy/common.py b/core/edge_mining/adapters/infrastructure/persistence/sqlalchemy/common.py new file mode 100644 index 0000000..cfa0973 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/persistence/sqlalchemy/common.py @@ -0,0 +1,55 @@ +"""Collection of common SQLAlchemy models and types.""" + +import json +from typing import Optional + +from sqlalchemy import String, TypeDecorator + +from edge_mining.shared.interfaces.config import Configuration + + +class ConfigurationType(TypeDecorator): + """Generic SQLAlchemy type for Configuration subclasses. + + This base type handles serialization of Configuration objects to/from JSON strings. + It converts Configuration instances to JSON when writing to the database and returns + the JSON string as-is when reading (actual deserialization happens in event listeners). + + This class follows the DRY principle by providing common functionality for all + Configuration-based types across different domains. + """ + + impl = String + cache_ok = True + + def process_bind_param(self, value: Optional[Configuration], dialect) -> Optional[str]: + """Convert Configuration to JSON string before storing in DB. + + Args: + value: Configuration instance or None + dialect: SQLAlchemy dialect + + Returns: + JSON string representation or None + """ + if value is None: + return None + if isinstance(value, str): + # Already serialized + return value + # Serialize config to JSON using the to_dict method + return json.dumps(value.to_dict()) + + def process_result_value(self, value: Optional[str], dialect) -> Optional[str]: + """Return the JSON string as-is. + + Actual deserialization happens in the event listener specific to each entity. + + Args: + value: JSON string from database or None + dialect: SQLAlchemy dialect + + Returns: + JSON string or None (will be converted to Configuration by event listener) + """ + return value diff --git a/core/edge_mining/adapters/infrastructure/persistence/sqlalchemy/migrations.py b/core/edge_mining/adapters/infrastructure/persistence/sqlalchemy/migrations.py new file mode 100644 index 0000000..b8f4729 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/persistence/sqlalchemy/migrations.py @@ -0,0 +1,367 @@ +"""Alembic migrations management for SQLAlchemy. + +This module provides functions to run Alembic migrations programmatically, +maintaining DDD and hexagonal architecture principles by keeping migration +logic separate from domain logic. + +The migrations are run automatically during application startup when using +SQLAlchemy as the persistence adapter. +""" + +import shutil +from datetime import datetime +from pathlib import Path +from typing import Optional +from urllib.parse import urlparse + +from sqlalchemy import create_engine + +from alembic import command +from alembic.config import Config +from alembic.runtime.migration import MigrationContext +from alembic.script import ScriptDirectory +from edge_mining.shared.logging.port import LoggerPort + + +def _find_project_root() -> Path: + """Find project root by looking for alembic.ini marker file. + + This method walks up the directory tree from the current file until it finds + a directory containing alembic.ini, which marks the project root. + + Returns: + Path to the project root directory + + Raises: + FileNotFoundError: If alembic.ini cannot be found in any parent directory + """ + current_path = Path(__file__).resolve() + + # Walk up the directory tree + for parent in [current_path] + list(current_path.parents): + alembic_ini = parent / "alembic.ini" + if alembic_ini.exists(): + return parent + + # If we get here, we couldn't find the project root + raise FileNotFoundError( + "Could not find project root (alembic.ini not found in any parent directory). " + "Make sure alembic.ini exists at the project root." + ) + + +def backup_database(db_url: str, logger: Optional[LoggerPort] = None) -> Optional[str]: + """Create a backup of the SQLite database file before migrations. + + Args: + db_url: Database URL (currently only SQLite is supported) + logger: Logger instance for logging backup operations + + Returns: + Path to the backup file if created, None if backup was not needed or failed + + Note: + Only SQLite databases are backed up. For other database types, + this function returns None and logs a warning. + """ + # Only backup SQLite databases + if not db_url.startswith("sqlite"): + if logger: + logger.warning("Database backup is only supported for SQLite databases") + return None + + try: + # Parse the SQLite database path from the URL + # Format: sqlite:///path/to/file.db or sqlite:///./relative/path.db + parsed = urlparse(db_url) + db_path = parsed.path + + # Remove leading slash for absolute paths on Unix, but keep for relative paths + if db_path.startswith("/") and not db_path.startswith("///"): + db_path = db_path[1:] + + db_file = Path(db_path) + + # Check if database file exists + if not db_file.exists(): + if logger: + logger.debug(f"Database file does not exist yet: {db_file}. Skipping backup.") + return None + + # Create backups directory if it doesn't exist + backup_dir = db_file.parent / "backups" + backup_dir.mkdir(parents=True, exist_ok=True) + + # Create backup filename with timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_file = backup_dir / f"{db_file.stem}_backup_{timestamp}{db_file.suffix}" + + # Copy the database file + shutil.copy2(db_file, backup_file) + + if logger: + logger.info(f"Database backed up to: {backup_file}") + + return str(backup_file) + + except Exception as e: + if logger: + logger.error(f"Failed to create database backup: {e}") + # Don't raise - backup failure should not prevent migrations + return None + + +def get_alembic_config(db_url: str, script_location: Optional[str] = None) -> Config: + """Create and configure an Alembic Config object. + + Args: + db_url: Database URL for SQLAlchemy connection + script_location: Path to alembic directory. If None, uses default location + + Returns: + Configured Alembic Config object + """ + # Determine alembic directory location + if script_location is None: + # Find project root by looking for alembic.ini + project_root = _find_project_root() + script_location = str(project_root / "alembic") + alembic_ini_path = str(project_root / "alembic.ini") + else: + # If script_location is provided, assume alembic.ini is in the parent directory + alembic_ini_path = str(Path(script_location).parent / "alembic.ini") + + # Verify paths exist + if not Path(script_location).exists(): + raise FileNotFoundError(f"Alembic script location not found: {script_location}") + if not Path(alembic_ini_path).exists(): + raise FileNotFoundError(f"Alembic configuration file not found: {alembic_ini_path}") + + # Create Alembic config + alembic_cfg = Config(alembic_ini_path) + + # Override database URL from settings + alembic_cfg.set_main_option("sqlalchemy.url", db_url) + alembic_cfg.set_main_option("script_location", script_location) + + return alembic_cfg + + +def run_migrations( + db_url: str, + logger: Optional[LoggerPort] = None, + script_location: Optional[str] = None, + backup_enabled: bool = True, +) -> None: + """Run Alembic migrations to upgrade database to the latest revision. + + This function checks if there are pending migrations and only runs them + if the database is not already at the latest revision. + + Args: + db_url: Database URL for SQLAlchemy connection + logger: Logger instance for logging migration operations + script_location: Path to alembic directory. If None, uses default location + backup_enabled: If True, create a backup of SQLite database before migrations + + Raises: + Exception: If migrations fail to execute + """ + # Check if there are pending migrations + if not has_pending_migrations(db_url, logger, script_location): + if logger: + logger.info("Database is already up to date - no migrations needed") + return + + if logger: + logger.info("Pending migrations detected - starting migration process...") + + # Create database backup if enabled (only when migrations will actually run) + if backup_enabled: + backup_database(db_url, logger) + + try: + alembic_cfg = get_alembic_config(db_url, script_location) + + # Run migrations to head (latest revision) + command.upgrade(alembic_cfg, "head") + + if logger: + logger.info("Alembic migrations completed successfully") + + except Exception as e: + if logger: + logger.error(f"Error running Alembic migrations: {e}") + raise + + +def check_current_revision( + db_url: str, + logger: Optional[LoggerPort] = None, + script_location: Optional[str] = None, +) -> Optional[str]: + """Check the current database revision. + + Args: + db_url: Database URL for SQLAlchemy connection + logger: Logger instance for logging + script_location: Path to alembic directory. If None, uses default location + + Returns: + Current revision ID or None if no migrations have been applied + """ + try: + # Get current revision by connecting directly to the database + # Note: This returns None if no migrations have been applied + engine = create_engine(db_url) + with engine.connect() as connection: + context = MigrationContext.configure(connection) + current_rev = context.get_current_revision() + + if logger: + if current_rev: + logger.debug(f"Current database revision: {current_rev}") + else: + logger.debug("No migrations have been applied yet") + + return current_rev + + except Exception as e: + if logger: + logger.warning(f"Could not check current revision: {e}") + return None + + +def has_pending_migrations( + db_url: str, + logger: Optional[LoggerPort] = None, + script_location: Optional[str] = None, +) -> bool: + """Check if there are pending migrations to apply. + + Args: + db_url: Database URL for SQLAlchemy connection + logger: Logger instance for logging + script_location: Path to alembic directory. If None, uses default location + + Returns: + True if there are pending migrations, False otherwise + """ + try: + alembic_cfg = get_alembic_config(db_url, script_location) + script = ScriptDirectory.from_config(alembic_cfg) + + # Get head (latest) revision from migration scripts + head_rev = script.get_current_head() + + # If there's no head revision, there are no migration files + if head_rev is None: + if logger: + logger.warning( + "No migration files found in alembic/versions. " + "Please create an initial migration with: alembic revision --autogenerate -m 'Initial schema'" + ) + raise RuntimeError("No Alembic migrations found. Database cannot be initialized without migrations.") + + # Get current database revision + current_rev = check_current_revision(db_url, logger, script_location) + + # If current is None, database needs initialization (migrations pending) + if current_rev is None: + if logger: + logger.debug("Database not initialized - migrations pending") + return True + + # If current revision differs from head, migrations are pending + if current_rev != head_rev: + if logger: + logger.debug(f"Migrations pending: current={current_rev}, head={head_rev}") + return True + + if logger: + logger.debug("Database is up to date - no pending migrations") + return False + + except Exception as e: + if logger: + logger.error(f"Could not check for pending migrations: {e}") + raise + + +def create_migration( + db_url: str, + message: str, + logger: Optional[LoggerPort] = None, + script_location: Optional[str] = None, + autogenerate: bool = True, +) -> None: + """Create a new Alembic migration script. + + This is typically used during development when schema changes are made. + Not recommended for automatic execution during application startup. + + Args: + db_url: Database URL for SQLAlchemy connection + message: Migration message/description + logger: Logger instance for logging + script_location: Path to alembic directory. If None, uses default location + autogenerate: Whether to auto-generate migration from model changes + + Raises: + Exception: If migration creation fails + """ + if logger: + logger.info(f"Creating new migration: {message}") + + try: + alembic_cfg = get_alembic_config(db_url, script_location) + + # Create new migration + command.revision( + alembic_cfg, + message=message, + autogenerate=autogenerate, + ) + + if logger: + logger.info(f"Migration '{message}' created successfully") + + except Exception as e: + if logger: + logger.error(f"Error creating migration: {e}") + raise + + +def downgrade_migration( + db_url: str, + revision: str = "-1", + logger: Optional[LoggerPort] = None, + script_location: Optional[str] = None, +) -> None: + """Downgrade database to a specific revision. + + Args: + db_url: Database URL for SQLAlchemy connection + revision: Target revision (default: -1 for previous revision) + logger: Logger instance for logging + script_location: Path to alembic directory. If None, uses default location + + Raises: + Exception: If downgrade fails + """ + if logger: + logger.warning(f"Downgrading database to revision: {revision}") + + try: + alembic_cfg = get_alembic_config(db_url, script_location) + + # Downgrade to specified revision + command.downgrade(alembic_cfg, revision) + + if logger: + logger.info("Database downgrade completed successfully") + + except Exception as e: + if logger: + logger.error(f"Error downgrading database: {e}") + raise diff --git a/core/edge_mining/adapters/infrastructure/persistence/sqlalchemy/registry.py b/core/edge_mining/adapters/infrastructure/persistence/sqlalchemy/registry.py new file mode 100644 index 0000000..107017f --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/persistence/sqlalchemy/registry.py @@ -0,0 +1,19 @@ +"""Shared SQLAlchemy metadata and mapper registry. + +This module provides singleton instances of MetaData and mapper registry +that are shared across all domain table definitions and mappings. + +By keeping these as module-level singletons, we avoid initialization order +issues and allow imports to happen in any order. +""" + +from sqlalchemy import MetaData +from sqlalchemy.orm import registry + +# Shared metadata instance for all table definitions +# All tables across all domains will use this single metadata instance +metadata = MetaData() + +# Shared mapper registry for all imperative mappings +# All domain entities will be registered with this single registry +mapper_registry = registry(metadata=metadata) diff --git a/core/edge_mining/adapters/infrastructure/persistence/sqlalchemy/registry_loader.py b/core/edge_mining/adapters/infrastructure/persistence/sqlalchemy/registry_loader.py new file mode 100644 index 0000000..f1c36b4 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/persistence/sqlalchemy/registry_loader.py @@ -0,0 +1,33 @@ +"""Registry loader for SQLAlchemy table definitions. + +This module ensures all table definitions are imported and registered +with the shared mapper registry before create_all_tables() is called. + +Import this module once before calling create_all_tables() to ensure +all domain tables are properly registered. +""" + +# Import all table definitions to register them with the mapper registry +# The imports are used only for their side effects (table registration) +# so we can safely ignore unused import warnings + +from edge_mining.adapters.domain.energy import tables as _energy_tables # noqa: F401 +from edge_mining.adapters.domain.forecast import tables as _forecast_tables # noqa: F401 +from edge_mining.adapters.domain.home_load import tables as _home_load_tables # noqa: F401 +from edge_mining.adapters.domain.miner import tables as _miner_tables # noqa: F401 +from edge_mining.adapters.domain.notification import tables as _notification_tables # noqa: F401 +from edge_mining.adapters.domain.optimization_unit import tables as _optimization_unit_tables # noqa: F401 +from edge_mining.adapters.domain.performance import tables as _performance_tables # noqa: F401 +from edge_mining.adapters.domain.policy import tables as _policy_tables # noqa: F401 +from edge_mining.adapters.domain.user import tables as _user_tables # noqa: F401 +from edge_mining.adapters.infrastructure.external_services import tables as _external_services_tables # noqa: F401 + + +def ensure_tables_registered() -> None: + """Ensure all table definitions are registered. + + This function exists primarily for explicit documentation purposes. + The actual registration happens when this module is imported. + Call this function if you want to be explicit about the registration step. + """ + pass # Tables are registered on module import diff --git a/core/edge_mining/adapters/infrastructure/persistence/sqlite.py b/core/edge_mining/adapters/infrastructure/persistence/sqlite.py new file mode 100644 index 0000000..0b50db5 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/persistence/sqlite.py @@ -0,0 +1,306 @@ +""" +This module contains the BaseSqliteRepository class, which is the base class for all SQLite repositories. +It provides a base implementation for creating tables and getting connections to the SQLite database. +""" + +import sqlite3 +import uuid + +from edge_mining.shared.logging.port import LoggerPort + +# Register an adapter and a converter +sqlite3.register_adapter(uuid.UUID, lambda u: str(u)) +# sqlite3.register_converter("UUID", lambda u: uuid.UUID(u.decode("utf-8"))) + + +class BaseSqliteRepository: + """Base class for SQLite repositories. + + This class provides centralized database schema versioning. + Increment CURRENT_DB_VERSION when making ANY schema changes across the application. + """ + + # Global database schema version + CURRENT_DB_VERSION = "1.1.0" + + def __init__(self, db_path: str, logger: LoggerPort): + self.db_path = db_path + self.logger = logger + self._ensure_schema_migrations_table() + + def get_connection(self): + """Obtain a database connection.""" + try: + # We set a timeout for blocking operations + conn = sqlite3.connect(self.db_path, timeout=10, detect_types=sqlite3.PARSE_DECLTYPES) + conn.row_factory = sqlite3.Row # Accessing columns by name + conn.execute("PRAGMA foreign_keys = ON;") # Enable foreign keys if used + + return conn + except sqlite3.Error as e: + self.logger.error(f"SQLite DB connection error ({self.db_path}): {e}") + raise ConnectionError(f"SQLite Connection Error: {e}") from e + + def _ensure_schema_migrations_table(self): + """Create the schema_migrations table if it does not exist.""" + self.logger.debug(f"Ensuring schema_migrations table exists in {self.db_path}...") + sql = """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version TEXT NOT NULL UNIQUE, + description TEXT NOT NULL, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """ + conn = self.get_connection() + try: + with conn: + cursor = conn.cursor() + cursor.execute(sql) + self.logger.debug("schema_migrations table checked/created successfully.") + except sqlite3.Error as e: + self.logger.error(f"Error creating schema_migrations table: {e}") + raise + finally: + if conn: + conn.close() + + def get_table_columns(self, table_name: str) -> set: + """Get the set of column names for a given table.""" + conn = self.get_connection() + try: + cursor = conn.cursor() + cursor.execute(f"PRAGMA table_info({table_name})") + columns = {row[1] for row in cursor.fetchall()} # row[1] is column name + return columns + except sqlite3.Error as e: + self.logger.error(f"Error getting columns for table {table_name}: {e}") + return set() + finally: + if conn: + conn.close() + + def add_column_safe(self, table_name: str, column_name: str, column_def: str): + """Safely add a column to a table if it doesn't exist.""" + conn = self.get_connection() + try: + cursor = conn.cursor() + alter_sql = f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_def}" + with conn: + cursor.execute(alter_sql) + self.logger.info(f"Successfully added column '{column_name}' to table '{table_name}'") + except sqlite3.Error as e: + self.logger.error(f"Error adding column {column_name} to {table_name}: {e}") + raise + finally: + if conn: + conn.close() + + def _schema_to_create_table(self, table_name: str, schema: dict[str, str]) -> str: + """Generate CREATE TABLE SQL from schema dictionary. + + Args: + table_name: Name of the table + schema: Dictionary mapping column names to their SQL definitions + + Returns: + str: Complete CREATE TABLE IF NOT EXISTS SQL statement + """ + columns_def = [] + for col_name, col_def in schema.items(): + columns_def.append(f" {col_name} {col_def}") + + columns_sql = ",\n".join(columns_def) + + return f"CREATE TABLE IF NOT EXISTS {table_name} (\n{columns_sql}\n);" + + def get_current_db_version(self) -> str | None: + """Get the current global database schema version. + + Returns: + str | None: Current version string (e.g., "1.1.0") or None if no migrations applied + """ + conn = self.get_connection() + try: + cursor = conn.cursor() + cursor.execute("SELECT version FROM schema_migrations ORDER BY id DESC LIMIT 1") + row = cursor.fetchone() + return row[0] if row else None + except sqlite3.Error as e: + self.logger.error(f"Error getting current database version: {e}") + return None + finally: + if conn: + conn.close() + + def apply_migration(self, version: str, description: str) -> None: + """Apply a new migration version to the database. + + Args: + version: Version string (e.g., "1.1.0") + description: Human-readable description of changes in this version + + Raises: + sqlite3.IntegrityError: If version already exists + """ + conn = self.get_connection() + try: + cursor = conn.cursor() + with conn: + cursor.execute( + """ + INSERT INTO schema_migrations (version, description, applied_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + """, + (version, description), + ) + self.logger.info(f"Applied database migration version {version}: {description}") + except sqlite3.IntegrityError: + # Version already exists - this is expected if migration was already applied + self.logger.debug(f"Migration version {version} already applied, skipping") + except sqlite3.Error as e: + self.logger.error(f"Error applying migration version {version}: {e}") + raise + finally: + if conn: + conn.close() + + def is_migration_applied(self, version: str) -> bool: + """Check if a specific migration version has been applied. + + Args: + version: Version string to check (e.g., "1.1.0") + + Returns: + bool: True if version exists in migrations history + """ + conn = self.get_connection() + try: + cursor = conn.cursor() + cursor.execute( + "SELECT COUNT(*) FROM schema_migrations WHERE version = ?", + (version,), + ) + row = cursor.fetchone() + count: int = row[0] if row else 0 + return count > 0 + except sqlite3.Error as e: + self.logger.error(f"Error checking migration version {version}: {e}") + return False + finally: + if conn: + conn.close() + + def migrate_schema_if_needed( + self, + table_name: str, + expected_schema: dict[str, str], + target_version: str, + migration_description: str, + table_was_created: bool = False, + ) -> None: + """Smart auto-migration: adds missing columns, warns about extra ones. + + This method checks if a migration is needed and applies it using global database versioning. + It only applies the migration if the target version hasn't been applied yet. + + Args: + table_name: Name of the table to migrate + expected_schema: Dictionary mapping column names to their SQL definitions + target_version: Global database version this migration targets (e.g., "1.1.0") + migration_description: Human-readable description of changes + table_was_created: True if table was just created (not existing before) + """ + # Check if this migration version was already applied + if self.is_migration_applied(target_version): + self.logger.debug(f"Migration {target_version} already applied, skipping schema check for '{table_name}'") + return + + self.logger.debug(f"Checking if schema migration is needed for '{table_name}' table...") + + current_columns = self.get_table_columns(table_name) + expected_column_names = set(expected_schema.keys()) + + # SAFE OPERATION: Add missing columns + missing_columns = expected_column_names - current_columns + if missing_columns: + self.logger.info(f"Missing columns detected in '{table_name}' table: {missing_columns}") + for col_name in missing_columns: + col_def = expected_schema[col_name] + # Remove PRIMARY KEY constraint for ALTER TABLE (not allowed) + if "PRIMARY KEY" in col_def: + self.logger.warning( + f"Cannot add PRIMARY KEY column '{col_name}' to existing table. Manual migration required." + ) + continue + self.add_column_safe(table_name, col_name, col_def) + + # Apply migration if changes were made OR if table was just created + if missing_columns or table_was_created: + self.apply_migration(target_version, migration_description) + + # WARNING: Extra columns (potential schema drift) + extra_columns = current_columns - expected_column_names + if extra_columns: + self.logger.warning( + f"Database table '{table_name}' has unexpected columns: {extra_columns}. " + f"These columns are not defined in the current schema. " + f"Manual migration or cleanup may be required. " + f"No automatic removal will be performed to prevent data loss." + ) + + if not missing_columns and not extra_columns and not table_was_created: + self.logger.debug(f"Schema for '{table_name}' table is up to date.") + + def create_tables( + self, + table_name: str, + schema: dict[str, str], + ) -> None: + """Create tables from schema definition and apply migrations. + + Uses the global CURRENT_DB_VERSION from the class for versioning. + Automatically generates CREATE TABLE SQL from the schema dictionary. + + Args: + table_name: Name of the table to create + schema: Dictionary mapping column names to their SQL definitions (with inline comments) + """ + self.logger.debug(f"Ensuring SQLite tables exist in {self.db_path}...") + + # Generate CREATE TABLE SQL from schema + create_table_sql = self._schema_to_create_table(table_name, schema) + + conn = self.get_connection() + table_existed = False + try: + cursor = conn.cursor() + # Check if table exists before creation + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,)) + table_existed = cursor.fetchone() is not None + + with conn: + cursor.execute(create_table_sql) + + table_display_name = table_name.replace("_", " ").capitalize() + self.logger.debug(f"{table_display_name} table checked/created successfully.") + except sqlite3.Error as e: + self.logger.error(f"Error creating SQLite tables: {e}") + from edge_mining.domain.exceptions import ConfigurationError + + raise ConfigurationError(f"DB error creating tables: {e}") from e + finally: + if conn: + conn.close() + + # Auto-generate migration description based on table status + migration_description = f"Schema version {self.CURRENT_DB_VERSION} for {table_name} table" + + # Apply smart auto-migration after table creation + self.migrate_schema_if_needed( + table_name=table_name, + expected_schema=schema, + target_version=self.CURRENT_DB_VERSION, + migration_description=migration_description, + table_was_created=not table_existed, + ) diff --git a/core/edge_mining/adapters/infrastructure/rule_engine/__init__.py b/core/edge_mining/adapters/infrastructure/rule_engine/__init__.py new file mode 100644 index 0000000..80f2648 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/rule_engine/__init__.py @@ -0,0 +1 @@ +"""Adapter for rule engine infrastructure concerns.""" diff --git a/core/edge_mining/adapters/infrastructure/rule_engine/custom/__init__.py b/core/edge_mining/adapters/infrastructure/rule_engine/custom/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/adapters/infrastructure/rule_engine/custom/helpers.py b/core/edge_mining/adapters/infrastructure/rule_engine/custom/helpers.py new file mode 100644 index 0000000..21fcd07 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/rule_engine/custom/helpers.py @@ -0,0 +1,137 @@ +"""Rule condition evaluator for YAML-based rules.""" + +import re +from typing import Any, Union + +from edge_mining.adapters.domain.policy.schemas import ( + LogicalGroupSchema, + RuleConditionSchema, + convert_conditions_to_schema, +) +from edge_mining.domain.policy.common import OperatorType +from edge_mining.domain.policy.value_objects import DecisionalContext + + +class RuleEvaluator: + """Evaluates rule conditions against decisional context.""" + + @staticmethod + def evaluate_rule_conditions( + context: DecisionalContext, + conditions: Union[dict, RuleConditionSchema, LogicalGroupSchema], + ) -> bool: + """Evaluate rule conditions against the decisional context.""" + + # Convert conditions to schema if they are in dict format + if isinstance(conditions, dict): + conditions = RuleEvaluator._convert_conditions_to_schema(conditions) + + if isinstance(conditions, RuleConditionSchema): + return RuleEvaluator._evaluate_single_condition(context, conditions) + elif isinstance(conditions, LogicalGroupSchema): + return RuleEvaluator._evaluate_logical_group(context, conditions) + else: + raise ValueError(f"Unsupported condition type: {type(conditions)}") + + @staticmethod + def _convert_conditions_to_schema( + conditions: dict, + ) -> Union[LogicalGroupSchema, RuleConditionSchema]: + try: + return convert_conditions_to_schema(conditions) + except Exception as e: + print(f"Error converting conditions to schema: {e}") + raise + + @staticmethod + def _evaluate_single_condition(context: DecisionalContext, condition: RuleConditionSchema) -> bool: + """Evaluate a single condition.""" + try: + # Get field value from context using dot notation + field_value = RuleEvaluator._get_field_value(context, condition.field) + + if field_value is None: + print(f"Field '{condition.field}' not found in context.") + return False + + # Apply operator + return RuleEvaluator._apply_operator(field_value, condition.operator, condition.value) + + except Exception as e: + print(f"Error evaluating condition '{condition.field}': {e}") + return False + + @staticmethod + def _evaluate_logical_group(context: DecisionalContext, group: LogicalGroupSchema) -> bool: + """Evaluate a logical group (AND/OR/NOT).""" + if group.all_of: + # ALL conditions must be true (AND) + return all(RuleEvaluator.evaluate_rule_conditions(context, cond) for cond in group.all_of) + + elif group.any_of: + # ANY condition must be true (OR) + return any(RuleEvaluator.evaluate_rule_conditions(context, cond) for cond in group.any_of) + + elif group.not_: + # NOT condition + return not RuleEvaluator.evaluate_rule_conditions(context, group.not_) + + return False + + @staticmethod + def _get_field_value(context: DecisionalContext, field_path: str) -> Any: + """Get value from DecisionalContext using dot notation. + + Supports: + - Attribute access (dataclass fields, properties) + - Dict key lookup (e.g. ``home_load.devices.boiler``) + """ + parts = field_path.split(".") + current = context + + for part in parts: + if current is None: + return None + if isinstance(current, dict): + current = current.get(part) + elif hasattr(current, part): + current = getattr(current, part) + else: + return None + + return current + + @staticmethod + def _apply_operator(field_value: Any, operator: OperatorType, expected_value: Any) -> bool: + """Apply comparison operator.""" + try: + if operator == OperatorType.EQ: + return bool(field_value == expected_value) + elif operator == OperatorType.NE: + return bool(field_value != expected_value) + elif operator == OperatorType.GT: + return float(field_value) > float(expected_value) + elif operator == OperatorType.GTE: + return float(field_value) >= float(expected_value) + elif operator == OperatorType.LT: + return float(field_value) < float(expected_value) + elif operator == OperatorType.LTE: + return float(field_value) <= float(expected_value) + elif operator == OperatorType.IN: + return field_value in expected_value + elif operator == OperatorType.NOT_IN: + return field_value not in expected_value + elif operator == OperatorType.CONTAINS: + return str(expected_value) in str(field_value) + elif operator == OperatorType.STARTS_WITH: + return str(field_value).startswith(str(expected_value)) + elif operator == OperatorType.ENDS_WITH: + return str(field_value).endswith(str(expected_value)) + elif operator == OperatorType.REGEX: + return bool(re.match(str(expected_value), str(field_value))) + else: + raise ValueError(f"Unsupported operator: {operator}") + + except (ValueError, TypeError) as e: + print(f"Error applying operator {operator}: {e}") + return False diff --git a/core/edge_mining/adapters/infrastructure/rule_engine/engine.py b/core/edge_mining/adapters/infrastructure/rule_engine/engine.py new file mode 100644 index 0000000..79e306d --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/rule_engine/engine.py @@ -0,0 +1,53 @@ +"""Rule engine infrastructure adapter for automation rules.""" + +from typing import List + +from edge_mining.adapters.infrastructure.rule_engine.custom.helpers import RuleEvaluator +from edge_mining.domain.policy.common import RuleEngineType +from edge_mining.domain.policy.entities import AutomationRule +from edge_mining.domain.policy.services import RuleEngine +from edge_mining.domain.policy.value_objects import DecisionalContext +from edge_mining.shared.logging.port import LoggerPort + + +class CustomRuleEngine(RuleEngine): + """Custom rule engine for automation rules.""" + + def __init__(self, logger: LoggerPort): + self.rules: List[AutomationRule] = [] + self.logger = logger + + def get_type(self) -> RuleEngineType: + """Returns the type of the rule engine.""" + return RuleEngineType.CUSTOM + + def load_rules(self, rules: List[AutomationRule]) -> None: + """Load rules""" + + # Store the rules + self.rules = rules + + self.logger.debug(f"Successfully loaded {len(rules)} rules into CustomRuleEngine") + + def evaluate(self, context: DecisionalContext) -> bool: + """ + Evaluate all rules against the decisional context. + Returns True if any rule matches, False if no rules match + """ + + # Sort rules by priority (higher first) + sorted_rules = sorted(self.rules, key=lambda r: r.priority, reverse=True) + + for rule in sorted_rules: + if not rule.enabled: + continue + + try: + if RuleEvaluator.evaluate_rule_conditions(context, rule.conditions): + self.logger.debug(f"Rule '{rule.name}' matched!") + return True # Rule matched, return True + except (ValueError, AttributeError) as e: + self.logger.error(f"Error evaluating rule '{rule.name}': {e}") + continue + + return False # No rules matched, return False diff --git a/core/edge_mining/adapters/infrastructure/rule_engine/factory.py b/core/edge_mining/adapters/infrastructure/rule_engine/factory.py new file mode 100644 index 0000000..3888d4e --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/rule_engine/factory.py @@ -0,0 +1,31 @@ +"""Factory for creating RuleEngine instances.""" + +from typing import Optional + +from edge_mining.adapters.infrastructure.rule_engine.engine import CustomRuleEngine +from edge_mining.domain.policy.common import RuleEngineType +from edge_mining.domain.policy.services import RuleEngine +from edge_mining.shared.logging.port import LoggerPort + + +class RuleEngineFactory: + """Factory for creating RuleEngine instances.""" + + def create( + self, + engine_type: RuleEngineType = RuleEngineType.CUSTOM, + logger: Optional[LoggerPort] = None, + ) -> RuleEngine: + """ + Creates a rule engine instance based on the specified type. + """ + if not logger: + raise ValueError("Logger is required to create a RuleEngine instance.") + + if engine_type == RuleEngineType.CUSTOM: + # CustomRuleEngine is suitable for simple rule evaluations + # where rules are loaded and evaluated in a straightforward manner. + return CustomRuleEngine(logger=logger) + else: + raise ValueError(f"Unsupported rule engine type: {engine_type}") + # Future extensions can include more complex rule engines diff --git a/core/edge_mining/adapters/infrastructure/rule_engine/fast_api/__init__.py b/core/edge_mining/adapters/infrastructure/rule_engine/fast_api/__init__.py new file mode 100644 index 0000000..bdaf8d0 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/rule_engine/fast_api/__init__.py @@ -0,0 +1 @@ +"""Adapter that uses FastAPI infrastructure for rule engine API""" diff --git a/core/edge_mining/adapters/infrastructure/rule_engine/fast_api/router.py b/core/edge_mining/adapters/infrastructure/rule_engine/fast_api/router.py new file mode 100644 index 0000000..ef710bd --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/rule_engine/fast_api/router.py @@ -0,0 +1,135 @@ +"""API Router for rule engine operations.""" + +import uuid +from typing import Annotated, List, Optional + +from fastapi import APIRouter, Depends, HTTPException + +# Import dependency injection setup functions +from edge_mining.adapters.infrastructure.api.setup import ( + get_adapter_service, + get_config_service, + get_optimization_service, +) +from edge_mining.adapters.infrastructure.rule_engine.schemas import ( + OPERATOR_DESCRIPTIONS, + OPERATOR_EXAMPLES, + OPERATOR_TYPES, + OperatorInfoSchema, + RuleEngineConfigSchema, + RuleEngineInfoSchema, + RuleEvaluationRequestSchema, + RuleValidationRequestSchema, + RuleValidationResultSchema, +) +from edge_mining.application.interfaces import ( + AdapterServiceInterface, + ConfigurationServiceInterface, + OptimizationServiceInterface, +) +from edge_mining.domain.common import EntityId +from edge_mining.domain.policy.common import OPERATOR_SYMBOLS, OperatorType, RuleEngineType +from edge_mining.domain.policy.entities import AutomationRule +from edge_mining.domain.policy.value_objects import DecisionalContext + +router = APIRouter() + + +@router.get("/rule-engine/config", response_model=RuleEngineConfigSchema) +async def get_rule_engine_config( + adapter_service: Annotated[AdapterServiceInterface, Depends(get_adapter_service)], +) -> RuleEngineConfigSchema: + """Get current rule engine configuration.""" + rule_engine = adapter_service.get_rule_engine() + + if rule_engine: + return RuleEngineConfigSchema.from_model(rule_engine) + else: + raise HTTPException(status_code=404, detail="Rule engine not configured") + + +@router.get("/rule-engine/info", response_model=RuleEngineInfoSchema) +async def get_rule_engine_info() -> RuleEngineInfoSchema: + """Get information about rule engine capabilities and supported features.""" + + # Create operator info for all supported operators + operator_infos = [] + + for operator in OperatorType: + operator_infos.append( + OperatorInfoSchema( + operator=operator, + symbol=OPERATOR_SYMBOLS.get(operator, "?"), + description=OPERATOR_DESCRIPTIONS.get(operator, "No description available"), + example_usage=OPERATOR_EXAMPLES.get(operator, "No example available"), + supported_types=OPERATOR_TYPES.get(operator, ["any"]), + ) + ) + + return RuleEngineInfoSchema( + supported_engines=list(RuleEngineType), + supported_operators=operator_infos, + max_nesting_level=10, + supported_field_types=["string", "number", "boolean", "array", "object"], + ) + + +@router.post("/rule-engine/evaluate", response_model=bool) +async def evaluate_rules( + evaluation_request: RuleEvaluationRequestSchema, + optimization_service: Annotated[OptimizationServiceInterface, Depends(get_optimization_service)], +) -> bool: + """Evaluate a set of rules against the provided context.""" + + try: + # Convert schema rules to domain entities + automation_rules: List[AutomationRule] = [] + for rule_schema in evaluation_request.rules: + automation_rules.append(rule_schema.to_model()) + + context: Optional[DecisionalContext] = await optimization_service.get_decisional_context( + EntityId(uuid.UUID(evaluation_request.optimization_unit)) + ) + + if not context: + raise ValueError("Decisional context could not be created") + + result = await optimization_service.test_rules(automation_rules, context) + + return result + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal server error during evaluation: {str(e)}") from e + + +@router.post("/rule-engine/validate", response_model=RuleValidationResultSchema) +async def validate_rule_conditions( + request: RuleValidationRequestSchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> RuleValidationResultSchema: + """Validate rule conditions for syntax and field path correctness.""" + + validation_errors = [] + syntax_errors: List[str] = [] + field_errors: List[str] = [] + is_valid = True + + try: + # Basic validation of conditions structure + conditions_dict = request.conditions.to_model() + + # Validate using ConfigurationService + is_valid, syntax_errors, field_errors = config_service.validate_rule_conditions(conditions_dict) + + # Collect all validation errors + validation_errors = syntax_errors + field_errors + + except Exception as e: + is_valid = False + validation_errors.append(f"Validation failed: {str(e)}") + + return RuleValidationResultSchema( + is_valid=is_valid, + validation_errors=validation_errors, + syntax_errors=syntax_errors, + field_errors=field_errors, + ) diff --git a/core/edge_mining/adapters/infrastructure/rule_engine/schemas.py b/core/edge_mining/adapters/infrastructure/rule_engine/schemas.py new file mode 100644 index 0000000..d7f922f --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/rule_engine/schemas.py @@ -0,0 +1,152 @@ +"""Validation schemas for rule engine operations.""" + +from typing import Dict, List, Union + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from edge_mining.adapters.domain.policy.schemas import AutomationRuleSchema, LogicalGroupSchema, RuleConditionSchema +from edge_mining.domain.policy.common import OperatorType, RuleEngineType +from edge_mining.domain.policy.services import RuleEngine + + +class RuleEngineConfigSchema(BaseModel): + """Schema for rule engine configuration.""" + + engine_type: RuleEngineType = Field(default=RuleEngineType.CUSTOM, description="Type of rule engine to use") + + @field_validator("engine_type", mode="before") + @classmethod + def validate_engine_type(cls, v: Union[str, RuleEngineType]) -> RuleEngineType: + """Validate engine type.""" + if isinstance(v, str): + try: + return RuleEngineType(v.lower()) + except ValueError as e: + raise ValueError(f"Invalid engine type: {v}. Must be one of {list(RuleEngineType)}") from e + elif isinstance(v, RuleEngineType): + return v + else: + raise ValueError("Engine type must be a string or RuleEngineType enum value") + + @classmethod + def from_model(cls, rule_engine: RuleEngine) -> "RuleEngineConfigSchema": + """Create schema from rule engine model.""" + return cls(engine_type=rule_engine.get_type()) + + model_config = ConfigDict(from_attributes=True) + + +class RuleEvaluationRequestSchema(BaseModel): + """Schema for rule evaluation request.""" + + rules: List[AutomationRuleSchema] = Field(..., description="List of rules to evaluate") + context: dict = Field(..., description="Decisional context data for rule evaluation") + optimization_unit: str = Field(..., description="Optimization unit for the evaluation") + + @field_validator("rules") + @classmethod + def validate_rules_not_empty(cls, v: List[AutomationRuleSchema]) -> List[AutomationRuleSchema]: + """Ensure at least one rule is provided.""" + if not v: + raise ValueError("At least one rule must be provided for evaluation") + return v + + @field_validator("context") + @classmethod + def validate_context_not_empty(cls, v: dict) -> dict: + """Ensure context is not empty.""" + if not v: + raise ValueError("Context must not be empty") + return v + + model_config = ConfigDict(from_attributes=True) + + +class RuleValidationRequestSchema(BaseModel): + """Schema for rule validation request.""" + + conditions: Union[LogicalGroupSchema, RuleConditionSchema] = Field(..., description="Rule conditions to validate") + + model_config = ConfigDict(from_attributes=True) + + +class RuleValidationResultSchema(BaseModel): + """Schema for rule validation result.""" + + is_valid: bool = Field(..., description="Whether the rule conditions are valid") + validation_errors: List[str] = Field(default_factory=list, description="List of validation errors, if any") + syntax_errors: List[str] = Field(default_factory=list, description="List of syntax errors, if any") + field_errors: List[str] = Field(default_factory=list, description="List of field path errors, if any") + + model_config = ConfigDict(from_attributes=True) + + +class OperatorInfoSchema(BaseModel): + """Schema for operator information.""" + + operator: OperatorType = Field(..., description="Operator type") + symbol: str = Field(..., description="Symbolic representation") + description: str = Field(..., description="Human-readable description") + example_usage: str = Field(..., description="Example of how to use this operator") + supported_types: List[str] = Field(..., description="List of supported data types") + + model_config = ConfigDict(from_attributes=True) + + +class RuleEngineInfoSchema(BaseModel): + """Schema for rule engine information and capabilities.""" + + supported_engines: List[RuleEngineType] = Field(..., description="List of supported engine types") + supported_operators: List[OperatorInfoSchema] = Field(..., description="List of supported operators") + max_nesting_level: int = Field(default=10, description="Maximum nesting level for logical groups") + supported_field_types: List[str] = Field( + default=["string", "number", "boolean", "array"], description="Supported field types in context" + ) + + model_config = ConfigDict(from_attributes=True) + + +OPERATOR_DESCRIPTIONS: Dict[OperatorType, str] = { + OperatorType.EQ: "Equal to - checks if field value equals the specified value", + OperatorType.NE: "Not equal to - checks if field value does not equal the specified value", + OperatorType.GT: "Greater than - checks if field value is greater than the specified value", + OperatorType.GTE: "Greater than or equal - checks if field value is greater than or equal to specified value", + OperatorType.LT: "Less than - checks if field value is less than the specified value", + OperatorType.LTE: "Less than or equal - checks if field value is less than or equal to specified value", + OperatorType.IN: "In list - checks if field value is contained in the specified list", + OperatorType.NOT_IN: "Not in list - checks if field value is not contained in the specified list", + OperatorType.CONTAINS: "Contains - checks if field value contains the specified substring", + OperatorType.STARTS_WITH: "Starts with - checks if field value starts with the specified string", + OperatorType.ENDS_WITH: "Ends with - checks if field value ends with the specified string", + OperatorType.REGEX: "Regular expression - checks if field value matches the specified regex pattern", +} + +OPERATOR_EXAMPLES: Dict[OperatorType, str] = { + OperatorType.EQ: '{"field": "energy_state.battery.percentage", "operator": "eq", "value": 50}', + OperatorType.NE: '{"field": "miner_state.status", "operator": "ne", "value": "running"}', + OperatorType.GT: '{"field": "energy_state.grid.power", "operator": "gt", "value": 1000}', + OperatorType.GTE: '{"field": "forecast.total_energy", "operator": "gte", "value": 5000}', + OperatorType.LT: '{"field": "energy_state.battery.percentage", "operator": "lt", "value": 20}', + OperatorType.LTE: '{"field": "mining_performance.current_hashrate.value", "operator": "lte", "value": 50}', + OperatorType.IN: '{"field": "miner_state.status", "operator": "in", "value": ["running", "mining"]}', + OperatorType.NOT_IN: '{"field": "energy_source.type", "operator": "not_in", "value": ["GRID"]}', + OperatorType.CONTAINS: '{"field": "miner.name", "operator": "contains", "value": "antminer"}', + OperatorType.STARTS_WITH: '{"field": "energy_source.name", "operator": "starts_with", "value": "solar"}', + OperatorType.ENDS_WITH: '{"field": "energy_source.name", "operator": "ends_with", "value": "_panel"}', + OperatorType.REGEX: '{"field": "miner.model", "operator": "regex", "value": "^S[0-9]+$"}', +} + +OPERATOR_TYPES: Dict[OperatorType, List[str]] = { + OperatorType.EQ: ["string", "number", "boolean"], + OperatorType.NE: ["string", "number", "boolean"], + OperatorType.GT: ["number"], + OperatorType.GTE: ["number"], + OperatorType.LT: ["number"], + OperatorType.LTE: ["number"], + OperatorType.IN: ["string", "number"], + OperatorType.NOT_IN: ["string", "number"], + OperatorType.CONTAINS: ["string"], + OperatorType.STARTS_WITH: ["string"], + OperatorType.ENDS_WITH: ["string"], + OperatorType.REGEX: ["string"], +} diff --git a/core/edge_mining/adapters/infrastructure/sheduler/__ini__.py b/core/edge_mining/adapters/infrastructure/sheduler/__ini__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/adapters/infrastructure/sheduler/jobs.py b/core/edge_mining/adapters/infrastructure/sheduler/jobs.py new file mode 100644 index 0000000..c2e8ebe --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/sheduler/jobs.py @@ -0,0 +1,128 @@ +"""Job scheduler for running optimization tasks at regular intervals.""" + +from typing import Optional + +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +from edge_mining.application.interfaces import HomeLoadHistoryServiceInterface, OptimizationServiceInterface +from edge_mining.application.services.load_forecast_training_service import LoadForecastModelTrainingService +from edge_mining.shared.logging.port import LoggerPort +from edge_mining.shared.scheduler.port import SchedulerPort +from edge_mining.shared.settings.settings import AppSettings + + +class AutomationScheduler(SchedulerPort): + """Scheduler for running optimization jobs at regular intervals.""" + + def __init__( + self, + optimization_service: OptimizationServiceInterface, + logger: LoggerPort, + settings: AppSettings, + home_load_history_service: Optional[HomeLoadHistoryServiceInterface] = None, + load_forecast_training_service: Optional[LoadForecastModelTrainingService] = None, + ): + self.optimization_service = optimization_service + self.home_load_history_service = home_load_history_service + self.load_forecast_training_service = load_forecast_training_service + self.logger = logger + self.settings = settings + self.scheduler = AsyncIOScheduler(timezone=self.settings.timezone) + + self._job_id = "evaluate_mining" + self._history_collect_job_id = "collect_load_history" + self._history_purge_job_id = "purge_load_history" + self._model_training_job_id = "train_load_forecast_models" + + async def _run_evaluation_job(self): + """Wrapper to call the optimization service's run method.""" + self.logger.debug(f"Scheduler triggered. Running job: {self._job_id}.") + try: + await self.optimization_service.run_all_enabled_units() + except Exception as e: + self.logger.error(f"Error during scheduled job: {self._job_id}. {e}") + + async def _run_history_collect_job(self): + """Collect power points from all history providers.""" + self.logger.debug(f"Scheduler triggered. Running job: {self._history_collect_job_id}.") + if not self.home_load_history_service: + return + try: + await self.home_load_history_service.collect_all() + except Exception as e: + self.logger.error(f"Error during scheduled job: {self._history_collect_job_id}. {e}") + + async def _run_history_purge_job(self): + """Purge old power points beyond retention window.""" + self.logger.debug(f"Scheduler triggered. Running job: {self._history_purge_job_id}.") + if not self.home_load_history_service: + return + try: + await self.home_load_history_service.purge_all( + retention_days=self.settings.history_retention_days, + ) + except Exception as e: + self.logger.error(f"Error during scheduled job: {self._history_purge_job_id}. {e}") + + async def _run_model_training_job(self): + """Train ML forecast models on collected history.""" + self.logger.debug(f"Scheduler triggered. Running job: {self._model_training_job_id}.") + if not self.load_forecast_training_service: + return + try: + await self.load_forecast_training_service.train_all() + except Exception as e: + self.logger.error(f"Error during scheduled job: {self._model_training_job_id}. {e}") + + async def start(self): + """Adds the job and starts the scheduler.""" + interval = self.settings.scheduler_interval_seconds + self.logger.debug(f"Starting scheduler. job |{self._job_id}| will run every {interval} seconds.") + + self.scheduler.add_job( + self._run_evaluation_job, + "interval", + seconds=interval, + id=self._job_id, + replace_existing=True, + ) + + if self.home_load_history_service: + ingestion_interval = self.settings.history_ingestion_interval_seconds + self.logger.debug( + f"Scheduling history ingestion every {ingestion_interval}s " + f"and purge daily (retention={self.settings.history_retention_days}d)." + ) + self.scheduler.add_job( + self._run_history_collect_job, + "interval", + seconds=ingestion_interval, + id=self._history_collect_job_id, + replace_existing=True, + ) + self.scheduler.add_job( + self._run_history_purge_job, + "cron", + hour=3, + minute=0, + id=self._history_purge_job_id, + replace_existing=True, + ) + + if self.load_forecast_training_service: + self.logger.debug("Scheduling nightly ML model training at 04:00.") + self.scheduler.add_job( + self._run_model_training_job, + "cron", + hour=4, + minute=0, + id=self._model_training_job_id, + replace_existing=True, + ) + + self.logger.debug("Scheduler started.") + self.scheduler.start() + + def stop(self): + self.logger.debug(f"Scheduler stopped. Job: {self._job_id}") + self.scheduler.shutdown() diff --git a/core/edge_mining/adapters/infrastructure/sun/__init__.py b/core/edge_mining/adapters/infrastructure/sun/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/adapters/infrastructure/sun/factories.py b/core/edge_mining/adapters/infrastructure/sun/factories.py new file mode 100644 index 0000000..a54f43a --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/sun/factories.py @@ -0,0 +1,77 @@ +"""Collection of factories to create Sun.""" + +from datetime import datetime + +from astral import LocationInfo +from astral.sun import sun, daylight, night, twilight, midnight, zenith_and_azimuth, elevation + +from edge_mining.application.interfaces import SunFactoryInterface +from edge_mining.domain.policy.value_objects import Sun + + +class AstralSunFactory(SunFactoryInterface): + """ + Factory to create Sun Value Objects using the astral library. + """ + + def __init__( + self, + latitude: float, + longitude: float, + timezone: str, + name: str = "", + region: str = "", + ): + """ + Initializes the factory with location information. + """ + location_info = LocationInfo( + name=name, + region=region, + timezone=timezone, + latitude=latitude, + longitude=longitude, + ) + self._location = location_info + + def create_sun_for_date(self, for_date: datetime = datetime.now()) -> Sun: + """ + Creates a Sun object for a specific date. + """ + s = sun(self._location.observer, date=for_date) + + # Calculate night duration + night_start, night_end = night(self._location.observer, date=for_date) + night_duration = night_end - night_start + + # Obtain zenith and azimuth values + zenith_value, azimuth_value = zenith_and_azimuth(self._location.observer, dateandtime=for_date) + + # Calculate daylight duration + daylight_start, daylight_end = daylight(self._location.observer, date=for_date) + daylight_duration = daylight_end - daylight_start + + # Calculate twilight duration + twilight_start, twilight_end = twilight(self._location.observer, date=for_date) + twilight_duration = twilight_end - twilight_start + + # Calculate elevation + elevation_value = elevation(self._location.observer, dateandtime=for_date) + + # Obtain midnight time + midnight_time = midnight(self._location.observer, date=for_date) + + return Sun( + dawn=s["dawn"], + sunrise=s["sunrise"], + noon=s["noon"], + midnight=midnight_time, + sunset=s["sunset"], + dusk=s["dusk"], + daylight=daylight_duration, + night=night_duration, + twilight=twilight_duration, + azimuth=azimuth_value, + zenith=zenith_value, + elevation=elevation_value, + ) diff --git a/core/edge_mining/adapters/infrastructure/websocket/__init__.py b/core/edge_mining/adapters/infrastructure/websocket/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/adapters/infrastructure/websocket/manager.py b/core/edge_mining/adapters/infrastructure/websocket/manager.py new file mode 100644 index 0000000..aba03dc --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/websocket/manager.py @@ -0,0 +1,160 @@ +"""WebSocket manager for broadcasting domain events to connected clients. + +Acts as an aggregator: each subdomain provides a ``WebSocketEventHandler`` +that declares which event it handles and how to serialize it. +The manager reads ``event_type`` and ``serialize`` from each handler, +subscribes on the event bus, and broadcasts pre-serialized payloads – +mirroring the pattern used by ``main_api.py`` which aggregates FastAPI +routers from each subdomain. +""" + +import json +from fnmatch import fnmatch +from typing import List + +from fastapi import WebSocket + +from edge_mining.adapters.domain.energy.websocket.handlers import EnergyWebSocketHandler +from edge_mining.adapters.domain.miner.websocket.handlers import MinerWebSocketHandler +from edge_mining.adapters.domain.optimization_unit.websocket.handlers import OptimizationUnitWebSocketHandler +from edge_mining.adapters.domain.policy.websocket.handlers import PolicyWebSocketHandler +from edge_mining.adapters.application.services.configuration.websocket.handlers import ConfigurationWebSocketHandler +from edge_mining.adapters.infrastructure.websocket.utils import WebSocketEventHandler, WebSocketMessage +from edge_mining.application.interfaces import EventBusInterface +from edge_mining.domain.common import DomainEvent +from edge_mining.shared.logging.port import LoggerPort + + +class WebSocketManager: + """Manages WebSocket connections and broadcasts domain events to subscribers. + + Each subdomain handler exposes a ``registrations`` property + returning a list of ``WebSocketEventRegistration`` items. + Each registration binds a domain event class to a serialization function. + + The manager iterates over all registrations, subscribes on the event bus, + and broadcasts the serialized results. + """ + + def __init__(self, event_bus: EventBusInterface, logger: LoggerPort) -> None: + self._logger = logger + self._connections: dict[WebSocket, set[str]] = {} + self._available_topics: List[str] = [] + + # Collect all subdomain handlers + handlers: List[WebSocketEventHandler] = [ + ConfigurationWebSocketHandler(), + EnergyWebSocketHandler(), + MinerWebSocketHandler(), + OptimizationUnitWebSocketHandler(), + PolicyWebSocketHandler(), + ] + + # Subscribe to the event bus for every registration across all handlers + for handler in handlers: + for registration in handler.registrations: + self._available_topics.append(registration.topic) + event_bus.subscribe( + registration.event_type, + self._make_callback(registration.topic, registration.serialize), + blocking=False, + ) + + @property + def available_topics(self) -> List[str]: + """Return the list of all topics that clients can subscribe to.""" + return list(self._available_topics) + + def _make_callback(self, topic, serialize_fn): + """Create an async callback that serializes the event and broadcasts it.""" + + async def _callback(event: DomainEvent) -> None: + payload = serialize_fn(event) + await self.broadcast_message(WebSocketMessage(topic, payload)) + + return _callback + + async def connect(self, websocket: WebSocket) -> None: + """Accept a new WebSocket connection. No subscriptions by default.""" + await websocket.accept() + self._connections[websocket] = set() + self._logger.debug(f"WebSocket connected: {websocket.client}") + + def disconnect(self, websocket: WebSocket) -> None: + """Remove a WebSocket connection.""" + self._connections.pop(websocket, None) + self._logger.debug(f"WebSocket disconnected: {websocket.client}") + + def subscribe(self, websocket: WebSocket, topics: list[str]) -> None: + """Add topic subscriptions for a connected client.""" + if websocket in self._connections: + self._connections[websocket].update(topics) + self._logger.debug(f"WebSocket {websocket.client} subscribed to: {topics}") + + def unsubscribe(self, websocket: WebSocket, topics: list[str]) -> None: + """Remove topic subscriptions for a connected client.""" + if websocket in self._connections: + self._connections[websocket] -= set(topics) + + async def broadcast_message(self, message: WebSocketMessage) -> None: + """Broadcast a pre-serialized payload to all clients subscribed to the topic. + + Called by subdomain handlers after they have serialized their events. + """ + raw = json.dumps({"topic": message.topic, "payload": message.payload}) + + disconnected: list[WebSocket] = [] + + for ws, subscriptions in self._connections.items(): + if not subscriptions: + continue + if self._matches(subscriptions, message.topic): + try: + await ws.send_text(raw) + except Exception: + disconnected.append(ws) + + # Clean up dead connections + for ws in disconnected: + self.disconnect(ws) + + def _matches(self, subscriptions: set[str], topic: str) -> bool: + """Check if any subscription pattern matches the topic.""" + return any(fnmatch(topic, pattern) for pattern in subscriptions) + + async def handle_client_messages(self, websocket: WebSocket) -> None: + """Listen for messages from a connected client. + + Expected message format: + {"subscribe": ["energy.*", "miner.state"]} + {"unsubscribe": ["energy.*"]} + {"get_topics": true} + """ + try: + while True: + data = await websocket.receive_json() + if data.get("get_topics"): + await websocket.send_json( + { + "type": "available_topics", + "topics": sorted(self._available_topics), + } + ) + if "subscribe" in data and isinstance(data["subscribe"], list): + self.subscribe(websocket, data["subscribe"]) + await websocket.send_json( + { + "type": "subscribed", + "topics": sorted(self._connections[websocket]), + } + ) + if "unsubscribe" in data and isinstance(data["unsubscribe"], list): + self.unsubscribe(websocket, data["unsubscribe"]) + await websocket.send_json( + { + "type": "subscribed", + "topics": sorted(self._connections[websocket]), + } + ) + except Exception: + self.disconnect(websocket) diff --git a/core/edge_mining/adapters/infrastructure/websocket/router.py b/core/edge_mining/adapters/infrastructure/websocket/router.py new file mode 100644 index 0000000..b74f706 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/websocket/router.py @@ -0,0 +1,41 @@ +"""FastAPI WebSocket endpoint for real-time domain events.""" + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from edge_mining.adapters.infrastructure.websocket.manager import WebSocketManager + +router = APIRouter() + +# WebSocketManager instance — will be set from outside during initialization +_ws_manager: WebSocketManager | None = None + + +def init_websocket_manager(manager: WebSocketManager) -> None: + """Initialize the WebSocket manager for the router.""" + global _ws_manager + _ws_manager = manager + + +def get_ws_manager() -> WebSocketManager: + """Get the WebSocket manager instance.""" + if _ws_manager is None: + raise RuntimeError("WebSocketManager not initialized") + return _ws_manager + + +@router.websocket("/ws/events") +async def websocket_events(websocket: WebSocket): + """WebSocket endpoint for real-time domain events. + + After connecting, the client sends subscription messages: + {"subscribe": ["energy.*", "miner.state"]} + + The server pushes events matching the subscribed topics: + {"topic": "energy.state", "payload": {...}} + """ + manager = get_ws_manager() + await manager.connect(websocket) + try: + await manager.handle_client_messages(websocket) + except WebSocketDisconnect: + manager.disconnect(websocket) diff --git a/core/edge_mining/adapters/infrastructure/websocket/setup.py b/core/edge_mining/adapters/infrastructure/websocket/setup.py new file mode 100644 index 0000000..30bce42 --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/websocket/setup.py @@ -0,0 +1,12 @@ +"""Setup for WebSocket infrastructure with Dependency Injection.""" + +from edge_mining.adapters.infrastructure.websocket.manager import WebSocketManager +from edge_mining.adapters.infrastructure.websocket.router import init_websocket_manager +from edge_mining.shared.infrastructure import Services +from edge_mining.shared.logging.port import LoggerPort + + +def init_websocket_dependencies(services: Services, logger: LoggerPort) -> None: + """Initialize WebSocket dependencies - call this during app startup.""" + ws_manager = WebSocketManager(event_bus=services.event_bus, logger=logger) + init_websocket_manager(ws_manager) diff --git a/core/edge_mining/adapters/infrastructure/websocket/utils.py b/core/edge_mining/adapters/infrastructure/websocket/utils.py new file mode 100644 index 0000000..44c1edc --- /dev/null +++ b/core/edge_mining/adapters/infrastructure/websocket/utils.py @@ -0,0 +1,49 @@ +"""Base class for domain-specific WebSocket event handlers.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, Callable, List, NamedTuple, Type + +from edge_mining.domain.common import DomainEvent + + +class WebSocketMessage(NamedTuple): + """Typed container returned by WebSocket serialization functions.""" + + topic: str + payload: dict[str, Any] + + +@dataclass(frozen=True) +class WebSocketEventRegistration: + """A single event-to-topic binding. + + *event_type* is the domain event class to subscribe to. + *topic* is the WebSocket topic string clients use to subscribe. + *serialize* converts a domain event into a payload dict. + """ + + event_type: Type[DomainEvent] + topic: str + serialize: Callable[[DomainEvent], dict[str, Any]] + + +class WebSocketEventHandler(ABC): + """Each subdomain implements this to declare which events it handles + and how to serialize them for WebSocket clients. + + The handler knows nothing about the event bus or the WebSocket manager. + It only provides a list of ``WebSocketEventRegistration`` items, + each mapping a domain event class to a serialization function. + + A subdomain with one event returns one registration; + a subdomain with *N* events returns *N* registrations. + The ``WebSocketManager`` iterates over all registrations, + subscribes on the event bus, and broadcasts the results. + """ + + @property + @abstractmethod + def registrations(self) -> List[WebSocketEventRegistration]: + """Return the event registrations handled by this subdomain.""" + ... diff --git a/core/edge_mining/adapters/utils.py b/core/edge_mining/adapters/utils.py new file mode 100644 index 0000000..88b3cde --- /dev/null +++ b/core/edge_mining/adapters/utils.py @@ -0,0 +1,35 @@ +"""Collection of utility functions for adapters.""" + +import asyncio +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Coroutine, TypeVar + +T = TypeVar("T") + + +def run_async_func(func: Coroutine[Any, Any, T]) -> T: + """ + Executes an asynchronous function (coroutine) from a synchronous context, + handling the presence of an already running event loop. + + If no event loop is running, the coroutine is executed directly using asyncio.run(). + If an event loop is already running (e.g., in environments like FastAPI), + the coroutine is executed in a separate thread to avoid conflicts with the main event loop. + + Args: + func: A coroutine function (e.g., my_async_func()). + + Returns: + The result returned by the coroutine. + + Raises: + Propagates any exceptions raised by the coroutine. + """ + + try: + asyncio.get_running_loop() # Triggers RuntimeError if no running event loop + with ThreadPoolExecutor(1) as pool: + return pool.submit(lambda: asyncio.run(func)).result() + + except RuntimeError: + return asyncio.run(func) diff --git a/core/edge_mining/application/__init__.py b/core/edge_mining/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/application/events/__init__.py b/core/edge_mining/application/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/application/events/common.py b/core/edge_mining/application/events/common.py new file mode 100644 index 0000000..c691960 --- /dev/null +++ b/core/edge_mining/application/events/common.py @@ -0,0 +1,24 @@ +"""Common types for application-level events.""" + +from enum import Enum + + +class ConfigurationUpdatedEventType(Enum): + """Enum for the different types of configuration updates.""" + + ENERGY_MONITOR = "energy_monitor" + MINER_CONTROLLER = "miner_controller" + NOTIFIER = "notifier" + EXTERNAL_SERVICE = "external_service" + FORECAST_PROVIDER = "forecast_provider" + MINING_PERFORMANCE_TRACKER = "mining_performance_tracker" + UNKNOWN = "" + + +class ConfigurationAction(Enum): + """Enum for the possible actions on a configuration entity.""" + + CREATED = "created" + UPDATED = "updated" + REMOVED = "removed" + UNKNOWN = "" diff --git a/core/edge_mining/application/events/configuration_events.py b/core/edge_mining/application/events/configuration_events.py new file mode 100644 index 0000000..fd5f71c --- /dev/null +++ b/core/edge_mining/application/events/configuration_events.py @@ -0,0 +1,20 @@ +"""Configuration application events.""" + +from dataclasses import dataclass +from typing import Optional + +from edge_mining.application.events.common import ConfigurationAction, ConfigurationUpdatedEventType +from edge_mining.domain.common import DomainEvent, EntityId + + +@dataclass +class ConfigurationUpdatedEvent(DomainEvent): + """Event emitted when a configuration is created, updated, or removed. + + Application-level event: does not carry the modified entity's data, + but only the information needed to invalidate the adapters' cache. + """ + + entity_type: ConfigurationUpdatedEventType = ConfigurationUpdatedEventType.UNKNOWN + entity_id: Optional[EntityId] = None + action: ConfigurationAction = ConfigurationAction.UNKNOWN diff --git a/core/edge_mining/application/interfaces.py b/core/edge_mining/application/interfaces.py new file mode 100644 index 0000000..c41c9cb --- /dev/null +++ b/core/edge_mining/application/interfaces.py @@ -0,0 +1,1035 @@ +"""Edge Mining Application Interfaces Module""" + +from abc import ABC, abstractmethod +from datetime import datetime +from typing import Any, Callable, Dict, List, Optional, Type + +from edge_mining.domain.common import DomainEvent, EntityId, Timestamp, Watts +from edge_mining.domain.energy.common import EnergyMonitorAdapter, EnergySourceType +from edge_mining.domain.energy.entities import EnergyMonitor, EnergySource +from edge_mining.domain.energy.ports import EnergyMonitorPort +from edge_mining.domain.energy.value_objects import Battery, Grid +from edge_mining.domain.forecast.common import ForecastProviderAdapter +from edge_mining.domain.forecast.entities import ForecastProvider +from edge_mining.domain.forecast.ports import ForecastProviderPort +from edge_mining.domain.home_load.aggregate_roots import HomeLoadsProfile +from edge_mining.domain.home_load.entities import ( + EnergyLoadForecastProvider, + EnergyLoadHistoryProvider, + LoadConsumptionModel, + LoadDevice, +) +from edge_mining.domain.home_load.common import ( + EnergyLoadForecastProviderAdapter, + EnergyLoadHistoryProviderAdapter, +) +from edge_mining.domain.home_load.ports import EnergyLoadForecastProviderPort, EnergyLoadHistoryProviderPort +from edge_mining.domain.home_load.value_objects import HomeLoadPowerPoint +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.common import MinerControllerAdapter, MinerFeatureType +from edge_mining.domain.miner.entities import MinerController +from edge_mining.domain.miner.ports import MinerFeaturePort +from edge_mining.domain.miner.value_objects import HashRate, MinerInfo, MinerLimit, MinerStateSnapshot +from edge_mining.domain.notification.common import NotificationAdapter +from edge_mining.domain.notification.entities import Notifier +from edge_mining.domain.notification.ports import NotificationPort +from edge_mining.domain.optimization_unit.aggregate_roots import EnergyOptimizationUnit +from edge_mining.domain.performance.common import MiningPerformanceTrackerAdapter +from edge_mining.domain.performance.entities import MiningPerformanceTracker +from edge_mining.domain.performance.ports import MiningPerformanceTrackerPort +from edge_mining.domain.policy.aggregate_roots import OptimizationPolicy +from edge_mining.domain.policy.common import RuleType +from edge_mining.domain.policy.entities import AutomationRule +from edge_mining.domain.policy.services import RuleEngine +from edge_mining.domain.policy.value_objects import DecisionalContext, Sun +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.external_services.entities import ExternalService +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.external_services.value_objects import ExternalServiceLinkedEntities +from edge_mining.shared.interfaces.config import ( + EnergyMonitorConfig, + ExternalServiceConfig, + ForecastProviderConfig, + MinerControllerConfig, + MiningPerformanceTrackerConfig, + NotificationConfig, +) + + +class AdapterServiceInterface(ABC): + """Base interface for all adapter services in the Edge Mining application.""" + + @abstractmethod + async def get_energy_monitor(self, energy_source: EnergySource) -> Optional[EnergyMonitorPort]: + """Get an energy monitor adapter instance.""" + + @abstractmethod + async def get_miner_controller_adapter(self, miner: Miner, controller_id: EntityId) -> Optional[MinerFeaturePort]: + """Get a miner controller adapter instance for a specific controller.""" + + @abstractmethod + async def get_miner_feature_port(self, miner: Miner, feature_type: MinerFeatureType) -> Optional[MinerFeaturePort]: + """Get the adapter implementing the highest-priority active feature port for a miner.""" + + @abstractmethod + async def sync_miner_features(self, miner: Miner) -> bool: + """Reconcile stored features with what controllers actually support. + + Returns True if any changes were made. + """ + + @abstractmethod + async def get_all_notifiers(self) -> List[NotificationPort]: + """Get all notifier adapter instances""" + + @abstractmethod + async def get_notifier(self, notifier_id: EntityId) -> Optional[NotificationPort]: + """Get a specific notifier adapter instance by ID.""" + + @abstractmethod + async def get_notifiers(self, notifier_ids: List[EntityId]) -> List[NotificationPort]: + """Get a list of specific notifiers adapter instance by IDs.""" + + @abstractmethod + async def get_forecast_provider(self, energy_source: EnergySource) -> Optional[ForecastProviderPort]: + """Get a forecast provider adapter instance.""" + + @abstractmethod + def get_home_load_forecast_provider( + self, energy_load_forecast_provider_id: EntityId + ) -> Optional[EnergyLoadForecastProviderPort]: + """Get an home load forecast provider adapter instance.""" + + @abstractmethod + async def get_home_load_history_provider( + self, energy_load_history_provider_id: EntityId, device_id: EntityId + ) -> Optional[EnergyLoadHistoryProviderPort]: + """Get an energy load history provider adapter instance.""" + + @abstractmethod + async def get_mining_performance_tracker(self, tracker_id: EntityId) -> Optional[MiningPerformanceTrackerPort]: + """Get a mining performance tracker adapter instance.""" + + @abstractmethod + async def get_external_service(self, external_service_id: EntityId) -> Optional[ExternalServicePort]: + """Get a specific external service instance by ID.""" + + @abstractmethod + def get_rule_engine(self) -> Optional[RuleEngine]: + """Get the rule engine instance.""" + + @abstractmethod + def clear_all_adapters(self): + """Clear adapter cache""" + + @abstractmethod + def remove_adapter(self, entity_id: EntityId): + """Remove a specific adapter from the cache.""" + + @abstractmethod + def clear_all_services(self): + """Clear external services cache""" + + +class OptimizationServiceInterface(ABC): + """Base interface for optimization services in the Edge Mining application.""" + + @abstractmethod + async def run_all_enabled_units(self): + """Run the optimization process for all enabled units.""" + + @abstractmethod + async def test_rules(self, rules: List[AutomationRule], context: DecisionalContext) -> bool: + """Test a specific automation rule against a given context.""" + + @abstractmethod + async def get_decisional_context(self, optimization_unit_id: EntityId) -> Optional[DecisionalContext]: + """Get the decisional context for a specific optimization unit.""" + + +class HomeLoadHistoryServiceInterface(ABC): + """Base interface for home load history ingestion and retention.""" + + @abstractmethod + async def collect_all(self, lookback_hours: int = 24) -> None: + """Collect power points from all history providers for all enabled devices.""" + + @abstractmethod + async def collect_devices(self, device_ids: List[EntityId], lookback_hours: int = 24) -> None: + """Collect power points for the specified devices only.""" + + @abstractmethod + async def purge_all(self, retention_days: int = 90) -> None: + """Purge power points older than retention_days for all devices.""" + + @abstractmethod + def get_device_history(self, device_id: EntityId, start: Timestamp, end: Timestamp) -> List[HomeLoadPowerPoint]: + """Retrieve stored power points for a device in a time window.""" + + @abstractmethod + def clear_device_history(self, device_id: EntityId) -> int: + """Delete all stored power points for a device. Returns the number of rows deleted.""" + + +class LoadForecastTrainingServiceInterface(ABC): + """Base interface for ML model training and model listing.""" + + @abstractmethod + async def train_all(self, weeks_lookback: int = 8) -> None: + """Train models for every device that has sufficient history.""" + + @abstractmethod + async def train_device(self, device_id: EntityId, weeks_lookback: int = 8) -> None: + """Train models for a single device.""" + + @abstractmethod + def get_models(self, device_id: Optional[EntityId] = None) -> List[LoadConsumptionModel]: + """Retrieve trained models, optionally filtered by device.""" + + @abstractmethod + def delete_model(self, model_id: EntityId) -> None: + """Delete a trained model by ID.""" + + +class MinerActionServiceInterface(ABC): + """Base interface for miner action services in the Edge Mining application.""" + + @abstractmethod + async def start_miner(self, miner_id: EntityId, notifiers: List[NotificationPort]) -> bool: + """Start a specific miner.""" + + @abstractmethod + async def stop_miner(self, miner_id: EntityId, notifiers: List[NotificationPort]) -> bool: + """Stop a specific miner.""" + + @abstractmethod + async def get_miner_consumption(self, miner_id: EntityId) -> Optional[Watts]: + """Gets the current power consumption of the specified miner.""" + + @abstractmethod + async def get_miner_hashrate(self, miner_id: EntityId) -> Optional[HashRate]: + """Gets the current hash rate of the specified miner.""" + + @abstractmethod + async def get_miner_status(self, miner_id: EntityId) -> Optional[MinerStateSnapshot]: + """Gets the current status of the specified miner as a state snapshot.""" + + @abstractmethod + async def get_miner_info(self, miner_id: EntityId) -> Optional[MinerInfo]: + """Gets the information of the specified miner.""" + + @abstractmethod + async def get_miner_limits(self, miner_id: EntityId) -> Optional[MinerLimit]: + """Gets the limits of the specified miner.""" + + @abstractmethod + async def sync_all_miners(self, include_inactive: bool = False) -> None: + """Synchronizes the status of all miners from their controllers.""" + + @abstractmethod + async def get_miner_details_from_controller(self, controller_id: EntityId) -> Optional[MinerStateSnapshot]: + """Get details of a miner from its controller as a state snapshot.""" + + +class ConfigurationServiceInterface(ABC): + """Base interface for configuration services in the Edge Mining application.""" + + # --- Miner Management --- + @abstractmethod + async def add_miner( + self, + name: str, + model: Optional[str] = None, + hash_rate_max: Optional[HashRate] = None, + power_consumption_max: Optional[Watts] = None, + active: bool = True, + ) -> Miner: + """Add a miner to the system.""" + + @abstractmethod + def get_miner(self, miner_id: EntityId) -> Optional[Miner]: + """Get a miner by its ID.""" + + @abstractmethod + def list_miners(self) -> List[Miner]: + """List all miners in the system.""" + + @abstractmethod + async def remove_miner(self, miner_id: EntityId) -> Miner: + """Remove a miner from the system.""" + + @abstractmethod + async def update_miner( + self, + miner_id: EntityId, + name: str, + model: Optional[str] = None, + hash_rate_max: Optional[HashRate] = None, + power_consumption_max: Optional[Watts] = None, + active: bool = True, + ) -> Miner: + """Update a miner in the system.""" + + @abstractmethod + async def activate_miner(self, miner_id: EntityId) -> Miner: + """Activate a miner in the system.""" + + @abstractmethod + async def deactivate_miner(self, miner_id: EntityId) -> Miner: + """Deactivate a miner in the system.""" + + @abstractmethod + def list_miners_by_controller(self, controller_id: EntityId) -> List[Miner]: + """List all miners associated with a specific controller.""" + + @abstractmethod + def check_miner(self, miner: Miner) -> bool: + """Check if a miner is valid and can be used.""" + + @abstractmethod + async def add_miner_controller( + self, + name: str, + adapter: MinerControllerAdapter, + config: MinerControllerConfig, + external_service_id: Optional[EntityId] = None, + ) -> MinerController: + """Add a miner controller to the system.""" + + @abstractmethod + def get_miner_controller(self, controller_id: EntityId) -> Optional[MinerController]: + """Get a miner controller by its ID.""" + + @abstractmethod + def list_miner_controllers(self) -> List[MinerController]: + """List all miner controllers in the system.""" + + @abstractmethod + async def unlink_miner_controller(self, miner_controller_id: EntityId) -> None: + """Unlink a miner controller from all miners.""" + + @abstractmethod + async def remove_miner_controller(self, controller_id: EntityId) -> MinerController: + """Remove a miner controller from the system.""" + + @abstractmethod + async def update_miner_controller( + self, + controller_id: EntityId, + name: str, + config: MinerControllerConfig, + external_service_id: Optional[EntityId] = None, + ) -> MinerController: + """ + Update a miner controller in the system. + This method updates the name and configuration only of an existing miner controller + and avoid to change the adapter type. + """ + + @abstractmethod + async def set_miner_controller(self, controller_id: EntityId, miner_id: EntityId) -> None: + """Associate a controller to a miner, auto-creating features for all supported feature types.""" + + @abstractmethod + async def unlink_controller_from_miner(self, controller_id: EntityId, miner_id: EntityId) -> None: + """Remove all features provided by a controller from a miner.""" + + @abstractmethod + async def enable_miner_feature( + self, miner_id: EntityId, controller_id: EntityId, feature_type: MinerFeatureType + ) -> Miner: + """Enable a specific feature on a miner.""" + + @abstractmethod + async def disable_miner_feature( + self, miner_id: EntityId, controller_id: EntityId, feature_type: MinerFeatureType + ) -> Miner: + """Disable a specific feature on a miner.""" + + @abstractmethod + async def set_miner_feature_priority( + self, miner_id: EntityId, controller_id: EntityId, feature_type: MinerFeatureType, priority: int + ) -> Miner: + """Set the priority of a specific feature on a miner.""" + + @abstractmethod + def check_miner_controller(self, controller: MinerController) -> bool: + """Check if a miner controller is valid and can be used.""" + + @abstractmethod + def get_miner_controller_config_by_type( + self, adapter_type: MinerControllerAdapter + ) -> Optional[type[MinerControllerConfig]]: + """Get the configuration class for a specific miner controller adapter type.""" + + @abstractmethod + def get_miner_controller_external_service_adapter( + self, adapter_type: MinerControllerAdapter + ) -> Optional[ExternalServiceAdapter]: + """Get the external service adapter type for a specific miner controller adapter type.""" + + # --- Notifier Management --- + @abstractmethod + async def add_notifier( + self, + name: str, + adapter_type: NotificationAdapter, + config: Optional[NotificationConfig], + external_service_id: Optional[EntityId] = None, + ) -> Notifier: + """Add a new notifier.""" + + @abstractmethod + def get_notifier(self, notifier_id: EntityId) -> Optional[Notifier]: + """Get a notifier by its ID.""" + + @abstractmethod + def list_notifiers(self) -> List[Notifier]: + """List all notifiers in the system.""" + + @abstractmethod + async def remove_notifier(self, notifier_id: EntityId) -> Notifier: + """Remove a notifier from the system.""" + + @abstractmethod + async def update_notifier( + self, + notifier_id: EntityId, + name: str, + config: NotificationConfig, + external_service_id: Optional[EntityId] = None, + ) -> Notifier: + """Update a notifier in the system.""" + + @abstractmethod + def check_notifier(self, notifier: Notifier) -> bool: + """Check if a notifier is valid and can be used.""" + + @abstractmethod + def get_notifier_config_by_type(self, adapter_type: NotificationAdapter) -> Optional[type[NotificationConfig]]: + """Get the configuration class for a specific notifier adapter type.""" + + @abstractmethod + def get_notifier_external_service_adapter( + self, adapter_type: NotificationAdapter + ) -> Optional[ExternalServiceAdapter]: + """Get the external service adapter type for a specific notification adapter type.""" + + # --- Mining Performance Tracker Management --- + @abstractmethod + async def add_mining_performance_tracker( + self, + name: str, + adapter_type: MiningPerformanceTrackerAdapter, + config: Optional[MiningPerformanceTrackerConfig], + external_service_id: Optional[EntityId] = None, + ) -> MiningPerformanceTracker: + """Add a new mining performance tracker.""" + + @abstractmethod + def get_mining_performance_tracker(self, tracker_id: EntityId) -> Optional[MiningPerformanceTracker]: + """Get a mining performance tracker by its ID.""" + + @abstractmethod + def list_mining_performance_trackers(self) -> List[MiningPerformanceTracker]: + """List all mining performance trackers in the system.""" + + @abstractmethod + async def update_mining_performance_tracker( + self, + tracker_id: EntityId, + name: str, + config: MiningPerformanceTrackerConfig, + external_service_id: Optional[EntityId] = None, + ) -> MiningPerformanceTracker: + """Update a mining performance tracker in the system.""" + + @abstractmethod + async def unlink_mining_performance_tracker(self, tracker_id: EntityId) -> None: + """Detach a mining performance tracker from any optimization unit that references it.""" + + @abstractmethod + async def remove_mining_performance_tracker(self, tracker_id: EntityId) -> MiningPerformanceTracker: + """Remove a mining performance tracker from the system.""" + + @abstractmethod + def check_mining_performance_tracker(self, tracker: MiningPerformanceTracker) -> bool: + """Check if a mining performance tracker is valid and can be used.""" + + @abstractmethod + def get_mining_performance_tracker_config_by_type( + self, adapter_type: MiningPerformanceTrackerAdapter + ) -> Optional[type[MiningPerformanceTrackerConfig]]: + """Get the configuration class for a specific tracker adapter type.""" + + @abstractmethod + def get_mining_performance_tracker_external_service_adapter( + self, adapter_type: MiningPerformanceTrackerAdapter + ) -> Optional[ExternalServiceAdapter]: + """Get the external service adapter type for a specific tracker adapter type.""" + + # --- Policy Management --- + @abstractmethod + async def create_policy(self, name: str, description: str = "") -> OptimizationPolicy: + """Create a new policy.""" + + @abstractmethod + def get_policy(self, policy_id: EntityId) -> Optional[OptimizationPolicy]: + """Get a policy by its ID.""" + + @abstractmethod + def list_policies(self) -> List[OptimizationPolicy]: + """List all policies in the system.""" + + @abstractmethod + async def add_rule_to_policy( + self, + policy_id: EntityId, + rule_type: RuleType, + name: str, + priority: int, + conditions: Dict, + description: str = "", + ) -> AutomationRule: + """Add a rule to a policy.""" + + @abstractmethod + def get_policy_rules(self, policy_id: EntityId, rule_type: RuleType) -> List[AutomationRule]: + """Get all rules of a policy.""" + + @abstractmethod + def get_policy_rule(self, policy_id: EntityId, rule_id: EntityId) -> Optional[AutomationRule]: + """Get a rule by its ID.""" + + @abstractmethod + async def update_policy_rule( + self, + policy_id: EntityId, + rule_id: EntityId, + name: str, + priority: int, + enabled: bool, + conditions: Dict, + description: str = "", + ) -> AutomationRule: + """Update a rule in a policy.""" + + @abstractmethod + async def delete_policy_rule(self, policy_id: EntityId, rule_id: EntityId) -> AutomationRule: + """Delete a rule from a policy.""" + + @abstractmethod + async def enable_policy_rule(self, policy_id: EntityId, rule_id: EntityId) -> AutomationRule: + """Set a rule as enabled.""" + + @abstractmethod + async def disable_policy_rule(self, policy_id: EntityId, rule_id: EntityId) -> AutomationRule: + """Set a rule as disabled.""" + + @abstractmethod + async def delete_policy(self, policy_id: EntityId) -> Optional[OptimizationPolicy]: + """Delete a policy from the system.""" + + @abstractmethod + def check_policy(self, policy_id: EntityId) -> bool: + """Check if a policy is valid and can be used.""" + + @abstractmethod + async def update_policy( + self, + policy_id: EntityId, + name: str, + description: str = "", + ) -> OptimizationPolicy: + """Update a policy in the system.""" + + @abstractmethod + async def sort_policy_rules(self, policy_id: EntityId) -> None: + """Sort the rules of a policy by priority.""" + + @abstractmethod + def validate_rule_conditions(self, conditions: Dict) -> tuple[bool, List[str], List[str]]: + """ + Validate rule conditions structure and semantics. + + Args: + conditions: Dictionary representing the rule conditions + + Returns: + Tuple[bool, List[str], List[str]]: (is_valid, syntax_errors, field_errors) + """ + + # --- Optimization Unit Management --- + @abstractmethod + async def create_optimization_unit( + self, + name: str, + description: Optional[str] = None, + is_enabled: bool = False, + policy_id: Optional[EntityId] = None, + target_miner_ids: Optional[List[EntityId]] = None, + energy_source_id: Optional[EntityId] = None, + performance_tracker_id: Optional[EntityId] = None, + home_loads_profile_id: Optional[EntityId] = None, + notifier_ids: Optional[List[EntityId]] = None, + ) -> Optional[EnergyOptimizationUnit]: + """Create an optimization unit into the system.""" + + @abstractmethod + def get_optimization_unit(self, unit_id: EntityId) -> Optional[EnergyOptimizationUnit]: + """Get an optimization unit by its ID.""" + + @abstractmethod + def list_optimization_units(self) -> List[EnergyOptimizationUnit]: + """List all optimization units in the system.""" + + @abstractmethod + def filter_optimization_units( + self, + filter_by_miners: Optional[List[EntityId]] = None, + filter_by_energy_source: Optional[EntityId] = None, + filter_by_policy: Optional[EntityId] = None, + filter_by_performance_tracker: Optional[EntityId] = None, + filter_by_notifiers: Optional[List[EntityId]] = None, + ) -> List[EnergyOptimizationUnit]: + """Filter optimization units based on various criteria.""" + + @abstractmethod + async def remove_optimization_unit(self, unit_id: EntityId) -> EnergyOptimizationUnit: + """Remove an optimization unit from the system.""" + + @abstractmethod + async def update_optimization_unit( + self, + unit_id: EntityId, + name: str, + description: Optional[str] = None, + is_enabled: Optional[bool] = None, + policy_id: Optional[EntityId] = None, + target_miner_ids: Optional[List[EntityId]] = None, + energy_source_id: Optional[EntityId] = None, + performance_tracker_id: Optional[EntityId] = None, + home_loads_profile_id: Optional[EntityId] = None, + notifier_ids: Optional[List[EntityId]] = None, + ) -> EnergyOptimizationUnit: + """Update an optimization unit in the system.""" + + @abstractmethod + async def activate_optimization_unit(self, unit_id: EntityId) -> EnergyOptimizationUnit: + """Activate an optimization unit in the system.""" + + @abstractmethod + async def deactivate_optimization_unit(self, unit_id: EntityId) -> EnergyOptimizationUnit: + """Deactivate an optimization unit in the system.""" + + @abstractmethod + async def assign_miners_to_optimization_unit( + self, unit_id: EntityId, miner_ids: List[EntityId] + ) -> EnergyOptimizationUnit: + """Assign target miners to an optimization unit.""" + + @abstractmethod + async def add_miner_to_optimization_unit(self, unit_id: EntityId, miner_id: EntityId) -> EnergyOptimizationUnit: + """Add a miner to an optimization unit.""" + + @abstractmethod + async def remove_miner_from_optimization_unit( + self, unit_id: EntityId, miner_id: EntityId + ) -> EnergyOptimizationUnit: + """Remove a miner from an optimization unit.""" + + @abstractmethod + async def assign_policy_to_optimization_unit( + self, unit_id: EntityId, policy_id: EntityId + ) -> EnergyOptimizationUnit: + """Assign a policy to an optimization unit.""" + + @abstractmethod + async def assign_energy_source_to_optimization_unit( + self, unit_id: EntityId, energy_source_id: EntityId + ) -> EnergyOptimizationUnit: + """Assign an energy source to an optimization unit.""" + + @abstractmethod + async def assign_performance_tracker_to_optimization_unit( + self, unit_id: EntityId, performance_tracker_id: EntityId + ) -> EnergyOptimizationUnit: + """Assign a performance tracker to an optimization unit.""" + + @abstractmethod + async def assign_home_loads_profile_to_optimization_unit( + self, unit_id: EntityId, home_loads_profile_id: Optional[EntityId] + ) -> EnergyOptimizationUnit: + """Assign a home loads profile to an optimization unit.""" + + @abstractmethod + async def assign_notifiers_to_optimization_unit( + self, unit_id: EntityId, notifier_ids: List[EntityId] + ) -> EnergyOptimizationUnit: + """Assign notifiers to an optimization unit.""" + + @abstractmethod + async def add_notifier_to_optimization_unit( + self, unit_id: EntityId, notifier_id: EntityId + ) -> EnergyOptimizationUnit: + """Add a notifier to an optimization unit.""" + + @abstractmethod + async def remove_notifier_from_optimization_unit( + self, unit_id: EntityId, notifier_id: EntityId + ) -> EnergyOptimizationUnit: + """Remove a notifier from an optimization unit.""" + + @abstractmethod + def check_optimization_unit(self, optimization_unit: EnergyOptimizationUnit, strict: bool = False) -> bool: + """Check if an optimization unit is valid and can be used.""" + + # --- External Service Management --- + @abstractmethod + async def create_external_service( + self, + name: str, + adapter_type: ExternalServiceAdapter, + config: ExternalServiceConfig, + ) -> ExternalService: + """Create a new external service.""" + + @abstractmethod + def get_external_service(self, service_id: EntityId) -> Optional[ExternalService]: + """Get an external service by its ID.""" + + @abstractmethod + def list_external_services(self) -> List[ExternalService]: + """List all external services in the system.""" + + @abstractmethod + def get_entities_by_external_service(self, service_id: EntityId) -> ExternalServiceLinkedEntities: + """Get entities associated with this external service""" + + @abstractmethod + async def unlink_external_service(self, service_id: EntityId) -> None: + """Remove the association of an external service from all entities.""" + + @abstractmethod + async def remove_external_service(self, service_id: EntityId) -> ExternalService: + """Remove an external service from the system.""" + + @abstractmethod + async def update_external_service( + self, + service_id: EntityId, + name: str, + config: ExternalServiceConfig, + ) -> ExternalService: + """ + Update an external service in the system. + This method updates the name and configuration only of an existing external service. + """ + + @abstractmethod + def check_external_service(self, external_service: ExternalService) -> bool: + """Check if an external service is valid and can be used.""" + + @abstractmethod + def get_external_service_config_by_type( + self, adapter_type: ExternalServiceAdapter + ) -> Optional[type[ExternalServiceConfig]]: + """Get the configuration class for a specific external service adapter type.""" + + # --- Energy Source Management --- + @abstractmethod + async def create_energy_source( + self, + name: str, + source_type: EnergySourceType, + nominal_power_max: Optional[Watts] = None, + storage: Optional[Battery] = None, + grid: Optional[Grid] = None, + external_source: Optional[Watts] = None, + energy_monitor_id: Optional[EntityId] = None, + forecast_provider_id: Optional[EntityId] = None, + ) -> EnergySource: + """Create a new energy source.""" + + @abstractmethod + def get_energy_source(self, source_id: EntityId) -> Optional[EnergySource]: + """Get an energy source by its ID.""" + + @abstractmethod + def list_energy_sources(self) -> List[EnergySource]: + """List all energy sources in the system.""" + + @abstractmethod + async def remove_energy_source(self, source_id: EntityId) -> EnergySource: + """Remove an energy source from the system.""" + + @abstractmethod + async def update_energy_source( + self, + source_id: EntityId, + name: str, + source_type: EnergySourceType, + nominal_power_max: Optional[Watts] = None, + storage: Optional[Battery] = None, + grid: Optional[Grid] = None, + external_source: Optional[Watts] = None, + energy_monitor_id: Optional[EntityId] = None, + forecast_provider_id: Optional[EntityId] = None, + ) -> EnergySource: + """Update an energy source in the system.""" + + @abstractmethod + def check_energy_source(self, energy_source: EnergySource) -> bool: + """Check if an energy source is valid and can be used.""" + + @abstractmethod + async def create_energy_monitor( + self, + name: str, + adapter_type: EnergyMonitorAdapter, + config: EnergyMonitorConfig, + external_service_id: Optional[EntityId] = None, + ) -> EnergyMonitor: + """Create a new energy monitor.""" + + @abstractmethod + def get_energy_monitor(self, monitor_id: EntityId) -> Optional[EnergyMonitor]: + """Get an energy monitor by its ID.""" + + @abstractmethod + def list_energy_monitors(self) -> List[EnergyMonitor]: + """List all energy monitors in the system.""" + + @abstractmethod + async def unlink_energy_monitor(self, monitor_id: EntityId) -> None: + """Unlink an energy monitor from all associated energy sources.""" + + @abstractmethod + async def remove_energy_monitor(self, monitor_id: EntityId) -> EnergyMonitor: + """Remove an energy monitor from the system.""" + + @abstractmethod + async def update_energy_monitor( + self, + monitor_id: EntityId, + name: str, + config: EnergyMonitorConfig, + external_service_id: Optional[EntityId] = None, + ) -> EnergyMonitor: + """Update an energy monitor in the system.""" + + @abstractmethod + async def set_energy_monitor_to_energy_source( + self, energy_source_id: EntityId, energy_monitor_id: EntityId + ) -> EnergySource: + """Set an energy monitor to an energy source.""" + + @abstractmethod + async def set_forecast_provider_to_energy_source( + self, energy_source_id: EntityId, forecast_provider_id: EntityId + ) -> EnergySource: + """Set a forecast provider to an energy source.""" + + @abstractmethod + def list_energy_sources_by_monitor(self, monitor_id: EntityId) -> List[EnergySource]: + """List all energy sources that use a specific energy monitor.""" + + @abstractmethod + def list_energy_sources_by_forecast_provider(self, forecast_provider_id: EntityId) -> List[EnergySource]: + """List all energy sources that use a specific forecast provider.""" + + @abstractmethod + def check_energy_monitor(self, energy_monitor: EnergyMonitor) -> bool: + """Check if an energy monitor is valid and can be used.""" + + @abstractmethod + def get_energy_monitor_config_by_type( + self, adapter_type: EnergyMonitorAdapter + ) -> Optional[type[EnergyMonitorConfig]]: + """Get the configuration class for a specific energy monitor adapter type.""" + + @abstractmethod + def get_energy_monitor_external_service_adapter( + self, adapter_type: EnergyMonitorAdapter + ) -> Optional[ExternalServiceAdapter]: + """Get the external service adapter type for a specific energy monitor adapter type.""" + + # --- Forecast Provider Management --- + @abstractmethod + async def create_forecast_provider( + self, + name: str, + adapter_type: ForecastProviderAdapter, + config: ForecastProviderConfig, + external_service_id: Optional[EntityId] = None, + ) -> ForecastProvider: + """Create a new forecast provider.""" + + @abstractmethod + def get_forecast_provider(self, provider_id: EntityId) -> Optional[ForecastProvider]: + """Get a forecast provider by its ID.""" + + @abstractmethod + def list_forecast_providers(self) -> List[ForecastProvider]: + """List all forecast providers in the system.""" + + @abstractmethod + async def remove_forecast_provider(self, provider_id: EntityId) -> ForecastProvider: + """Remove a forecast provider from the system.""" + + @abstractmethod + async def update_forecast_provider( + self, + provider_id: EntityId, + name: str, + adapter_type: ForecastProviderAdapter, + config: ForecastProviderConfig, + external_service_id: Optional[EntityId] = None, + ) -> ForecastProvider: + """Update a forecast provider in the system.""" + + @abstractmethod + def check_forecast_provider(self, provider: ForecastProvider) -> bool: + """Check if a forecast provider is valid and can be used.""" + + # --- Home loads Management --- + @abstractmethod + def add_home_loads_profile(self, name: str) -> HomeLoadsProfile: + """Add a home loads profile to the system.""" + + @abstractmethod + def get_home_loads_profile(self, profile_id: EntityId) -> Optional[HomeLoadsProfile]: + """Get a home loads profile by its ID.""" + + @abstractmethod + def list_home_loads_profiles(self) -> List[HomeLoadsProfile]: + """List all home loads profiles in the system.""" + + @abstractmethod + def remove_home_loads_profile(self, profile_id: EntityId) -> HomeLoadsProfile: + """Remove a home loads profile from the system. Raises HomeLoadsProfileNotFoundError.""" + + @abstractmethod + def update_home_loads_profile(self, profile_id: EntityId, name: str) -> HomeLoadsProfile: + """Update a home loads profile in the system. Raises HomeLoadsProfileNotFoundError.""" + + @abstractmethod + def add_load_device_to_profile(self, profile_id: EntityId, load_device: LoadDevice) -> LoadDevice: + """Add a load device to a home loads profile. Raises HomeLoadsProfileNotFoundError.""" + + @abstractmethod + def remove_load_device_from_profile( + self, + profile_id: EntityId, + device_id: EntityId, + ) -> LoadDevice: + """Remove a load device from a home loads profile. Raises on missing profile or device.""" + + # --- Energy Load Forecast Provider Management --- + @abstractmethod + def add_energy_load_forecast_provider(self, provider: EnergyLoadForecastProvider) -> EnergyLoadForecastProvider: + """Add a new energy load forecast provider.""" + + @abstractmethod + def get_energy_load_forecast_provider(self, provider_id: EntityId) -> Optional[EnergyLoadForecastProvider]: + """Get an energy load forecast provider by ID.""" + + @abstractmethod + def list_energy_load_forecast_providers(self) -> List[EnergyLoadForecastProvider]: + """List all energy load forecast providers.""" + + @abstractmethod + def update_energy_load_forecast_provider(self, provider: EnergyLoadForecastProvider) -> EnergyLoadForecastProvider: + """Update an existing energy load forecast provider.""" + + @abstractmethod + def remove_energy_load_forecast_provider(self, provider_id: EntityId) -> EnergyLoadForecastProvider: + """Remove an energy load forecast provider.""" + + @abstractmethod + def get_energy_load_forecast_provider_external_service_adapter( + self, adapter_type: EnergyLoadForecastProviderAdapter + ) -> Optional[ExternalServiceAdapter]: + """Get the external service adapter type for a specific energy load forecast provider adapter type.""" + + # --- Energy Load History Provider Management --- + @abstractmethod + def add_energy_load_history_provider(self, provider: EnergyLoadHistoryProvider) -> EnergyLoadHistoryProvider: + """Add a new energy load history provider.""" + + @abstractmethod + def get_energy_load_history_provider(self, provider_id: EntityId) -> Optional[EnergyLoadHistoryProvider]: + """Get an energy load history provider by ID.""" + + @abstractmethod + def list_energy_load_history_providers(self) -> List[EnergyLoadHistoryProvider]: + """List all energy load history providers.""" + + @abstractmethod + def update_energy_load_history_provider(self, provider: EnergyLoadHistoryProvider) -> EnergyLoadHistoryProvider: + """Update an existing energy load history provider.""" + + @abstractmethod + def remove_energy_load_history_provider(self, provider_id: EntityId) -> EnergyLoadHistoryProvider: + """Remove an energy load history provider.""" + + @abstractmethod + def get_energy_load_history_provider_external_service_adapter( + self, adapter_type: EnergyLoadHistoryProviderAdapter + ) -> Optional[ExternalServiceAdapter]: + """Get the external service adapter type for a specific energy load history provider adapter type.""" + + @abstractmethod + def get_forecast_provider_config_by_type( + self, adapter_type: ForecastProviderAdapter + ) -> Optional[type[ForecastProviderConfig]]: + """Get the configuration class for a specific forecast provider adapter type.""" + + @abstractmethod + def get_forecast_provider_external_service_adapter( + self, adapter_type: ForecastProviderAdapter + ) -> Optional[ExternalServiceAdapter]: + """Get the external service adapter type for a specific forecast provider adapter type.""" + + # --- Settings Management --- + @abstractmethod + def get_all_settings(self) -> dict: + """Get all settings.""" + + @abstractmethod + async def update_setting(self, key: str, value: Any) -> None: + """Update a setting.""" + + +class SunFactoryInterface(ABC): + """Base interface for Sun factories in the Edge Mining application.""" + + @abstractmethod + def create_sun_for_date(self, for_date: datetime = datetime.now()) -> Sun: + """Create a Sun object for a specific date.""" + + +class EventBusInterface(ABC): + """Application interface for the domain event bus.""" + + @abstractmethod + async def publish(self, event: DomainEvent) -> None: + """Publish an event. Blocking handlers are executed before returning.""" + ... + + @abstractmethod + def subscribe( + self, + event_type: Type[DomainEvent], + handler: Callable, + blocking: bool = True, + ) -> None: + """Register a handler for a specific event type. + + Args: + event_type: The class of the event to listen for. + handler: Async coroutine that receives the event. + blocking: If True, the publisher waits for the handler to complete. + If False, the handler is executed in fire-and-forget mode. + """ + ... diff --git a/core/edge_mining/application/services/__init__.py b/core/edge_mining/application/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/application/services/adapter_service.py b/core/edge_mining/application/services/adapter_service.py new file mode 100644 index 0000000..610715c --- /dev/null +++ b/core/edge_mining/application/services/adapter_service.py @@ -0,0 +1,1017 @@ +""" +This service is responsible for creating and managing adapters for the application. +""" + +from typing import Dict, List, Optional, Union + +from edge_mining.adapters.domain.energy.monitors.dummy_solar import DummySolarEnergyMonitorFactory +from edge_mining.adapters.domain.energy.monitors.home_assistant_api import HomeAssistantAPIEnergyMonitorFactory +from edge_mining.adapters.domain.forecast.providers.dummy_solar import DummyForecastProviderFactory +from edge_mining.adapters.domain.forecast.providers.home_assistant_api import HomeAssistantForecastProviderFactory +from edge_mining.adapters.domain.home_load.forecast_providers.dummy import DummyEnergyLoadForecastProviderFactory +from edge_mining.adapters.domain.home_load.forecast_providers.naive_last_hour import ( + NaiveLastHourForecastProviderFactory, +) +from edge_mining.adapters.domain.home_load.forecast_providers.naive_persistence import ( + NaivePersistenceForecastProviderFactory, +) +from edge_mining.adapters.domain.home_load.forecast_providers.seasonal_baseline import ( + SeasonalBaselineForecastProviderFactory, +) +from edge_mining.adapters.domain.home_load.forecast_providers.skforecast_provider import ( + SkforecastForecastProviderFactory, +) +from edge_mining.adapters.domain.home_load.forecast_providers.statsmodels_hw import ( + StatsmodelsForecastProviderFactory, +) +from edge_mining.adapters.domain.home_load.forecast_providers.typical_profile import ( + TypicalProfileForecastProviderFactory, +) +from edge_mining.adapters.domain.home_load.forecast_providers.xgboost_provider import ( + XGBoostForecastProviderFactory, +) +from edge_mining.adapters.domain.home_load.history_providers.dummy import DummyEnergyLoadHistoryProvider +from edge_mining.adapters.domain.home_load.history_providers.home_assistant_api_history import ( + HomeAssistantAPIEnergyLoadHistoryProviderFactory, +) +from edge_mining.adapters.domain.miner.controllers.dummy import DummyMinerController +from edge_mining.adapters.domain.miner.controllers.generic_socket_home_assistant_api import ( + GenericSocketHomeAssistantAPIMinerControllerAdapterFactory, +) +from edge_mining.adapters.domain.miner.controllers.pyasic import PyASICMinerControllerAdapterFactory +from edge_mining.adapters.domain.notification.notifiers.dummy import DummyNotifier +from edge_mining.adapters.domain.notification.notifiers.telegram import TelegramNotifierFactory +from edge_mining.adapters.domain.performance.trackers.braiins_pool import ( + BraiinsPoolMiningPerformanceTrackerFactory, +) +from edge_mining.adapters.domain.performance.trackers.dummy import DummyMiningPerformanceTrackerFactory +from edge_mining.adapters.domain.performance.trackers.ocean import OceanMiningPerformanceTrackerFactory +from edge_mining.adapters.infrastructure.homeassistant.homeassistant_api import ServiceHomeAssistantAPIFactory +from edge_mining.adapters.infrastructure.rule_engine.factory import RuleEngineFactory +from edge_mining.application.events.common import ConfigurationUpdatedEventType +from edge_mining.application.events.configuration_events import ConfigurationUpdatedEvent +from edge_mining.application.interfaces import AdapterServiceInterface, EventBusInterface +from edge_mining.domain.common import EntityId +from edge_mining.domain.energy.common import EnergyMonitorAdapter +from edge_mining.domain.energy.entities import EnergyMonitor, EnergySource +from edge_mining.domain.energy.ports import EnergyMonitorPort, EnergyMonitorRepository +from edge_mining.domain.forecast.common import ForecastProviderAdapter +from edge_mining.domain.forecast.entities import ForecastProvider +from edge_mining.domain.forecast.ports import ForecastProviderPort, ForecastProviderRepository +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter, EnergyLoadHistoryProviderAdapter +from edge_mining.domain.home_load.entities import EnergyLoadForecastProvider, EnergyLoadHistoryProvider, LoadDevice +from edge_mining.domain.home_load.ports import ( + EnergyLoadForecastProviderPort, + EnergyLoadForecastProviderRepository, + EnergyLoadHistoryProviderPort, + EnergyLoadHistoryProviderRepository, + EnergyLoadHistoryRepository, + LoadConsumptionModelRepository, +) +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.common import MinerControllerAdapter, MinerFeatureType +from edge_mining.domain.miner.entities import MinerController +from edge_mining.domain.miner.ports import MinerControllerRepository, MinerFeaturePort, MinerRepository +from edge_mining.domain.miner.value_objects import MinerFeature +from edge_mining.domain.notification.common import NotificationAdapter +from edge_mining.domain.notification.entities import Notifier +from edge_mining.domain.notification.ports import NotificationPort, NotifierRepository +from edge_mining.domain.performance.common import MiningPerformanceTrackerAdapter +from edge_mining.domain.performance.entities import MiningPerformanceTracker +from edge_mining.domain.performance.ports import MiningPerformanceTrackerPort, MiningPerformanceTrackerRepository +from edge_mining.domain.policy.common import RuleEngineType +from edge_mining.domain.policy.services import RuleEngine +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.external_services.entities import ExternalService +from edge_mining.shared.external_services.ports import ExternalServicePort, ExternalServiceRepository +from edge_mining.shared.interfaces.factories import ( + EnergyLoadForecastAdapterFactory, + EnergyMonitorAdapterFactory, + ExternalServiceFactory, + ForecastAdapterFactory, + MinerControllerAdapterFactory, + MiningPerformanceTrackerAdapterFactory, +) +from edge_mining.shared.logging.port import LoggerPort + + +class AdapterService(AdapterServiceInterface): + """ + This service is responsible for creating and managing adapters for the application. + """ + + def __init__( + self, + energy_monitor_repo: EnergyMonitorRepository, + miner_controller_repo: MinerControllerRepository, + miner_repo: MinerRepository, + notifier_repo: NotifierRepository, + forecast_provider_repo: ForecastProviderRepository, + mining_performance_tracker_repo: MiningPerformanceTrackerRepository, + energy_load_forecast_provider_repo: EnergyLoadForecastProviderRepository, + energy_load_history_provider_repo: EnergyLoadHistoryProviderRepository, + home_load_history_repo: EnergyLoadHistoryRepository, + external_service_repo: ExternalServiceRepository, + event_bus: EventBusInterface, + logger: Optional[LoggerPort] = None, + load_consumption_model_repo: Optional[LoadConsumptionModelRepository] = None, + ): + self.energy_monitor_repo = energy_monitor_repo + self.miner_controller_repo = miner_controller_repo + self.miner_repo = miner_repo + self.notifier_repo = notifier_repo + self.forecast_provider_repo = forecast_provider_repo + self.mining_performance_tracker_repo = mining_performance_tracker_repo + self.energy_load_forecast_provider_repo = energy_load_forecast_provider_repo + self.energy_load_history_provider_repo = energy_load_history_provider_repo + self.home_load_history_repo = home_load_history_repo + self.external_service_repo = external_service_repo + self.load_consumption_model_repo = load_consumption_model_repo + # Cache for already created instances + self._instance_cache: Dict[ + EntityId, + Optional[ + Union[ + EnergyMonitorPort, + MinerFeaturePort, + NotificationPort, + ForecastProviderPort, + EnergyLoadForecastProviderPort, + EnergyLoadHistoryProviderPort, + MiningPerformanceTrackerPort, + ] + ], + ] = {} + # Cache for already created external services + self._service_cache: Dict[EntityId, ExternalServicePort] = {} + + self.logger = logger + + self._subscribe_events(event_bus) + + def _subscribe_events(self, event_bus: EventBusInterface) -> None: + """Register all event subscriptions for this service.""" + event_bus.subscribe( + ConfigurationUpdatedEvent, + self.on_configuration_updated, + blocking=True, + ) + + async def _initialize_external_service(self, external_service: ExternalService) -> Optional[ExternalServicePort]: + """Initialize an external service""" + # If the external service already exists, we use it + if external_service.id in self._service_cache: + if self.logger: + self.logger.debug( + f"Returning cached instance " + f"for external service ID {external_service.id} " + f"(Type: {external_service.adapter_type})" + ) + return self._service_cache[external_service.id] + + try: + external_service_factory: Optional[ExternalServiceFactory] = None + + if external_service.adapter_type == ExternalServiceAdapter.HOME_ASSISTANT_API: + # --- Home Assistant API --- + + external_service_factory = ServiceHomeAssistantAPIFactory() + else: + raise ValueError(f"Unsupported external service type: {external_service.adapter_type}") + + instance_service = external_service_factory.create(config=external_service.config, logger=self.logger) + + # Connect to the external service asynchronously + await instance_service.connect() + + self._service_cache[external_service.id] = instance_service + return instance_service + except Exception as e: + if self.logger: + self.logger.error( + f"Failed to initialize External Service '{external_service.name}' " + f"(Type: {external_service.adapter_type}): {e}" + ) + return None + + async def _initialize_energy_monitor_adapter( + self, energy_source: EnergySource, energy_monitor: EnergyMonitor + ) -> Optional[EnergyMonitorPort]: + """Initialize an energy monitor adapter.""" + # If the adapter has already been created, we use it. + if energy_monitor.id in self._instance_cache: + if self.logger: + self.logger.debug( + f"Returning cached adapter instance " + f"for energy monitor ID {energy_monitor.id} " + f"(Type: {energy_monitor.adapter_type})" + ) + + cached_instance = self._instance_cache[energy_monitor.id] + + if not cached_instance: + # If the cached instance is None, we return it + # to indicate that the adapter was not initialized. + if self.logger: + self.logger.warning( + f"Cached instance for energy monitor ID {energy_monitor.id} is None. Reinitializing adapter." + ) + return None + + # Check if the cached instance is of the correct type + if not isinstance(cached_instance, EnergyMonitorPort): + if self.logger: + self.logger.warning( + f"Cached instance for energy monitor ID {energy_monitor.id} " + f"is not of type EnergyMonitorPort. Reinitializing adapter." + ) + return None + + # If the cached instance is valid, we return it + return cached_instance + + # Retrieve the external service associated to the energy monitor + external_service: Optional[ExternalServicePort] = None + if energy_monitor.external_service_id: + external_service = await self.get_external_service(energy_monitor.external_service_id) + if not external_service: + raise ValueError( + "Unable to load external service " + f"{energy_monitor.external_service_id} " + f"for energy monitor {energy_monitor.name}" + ) + + try: + energy_monitor_adapter_factory: Optional[EnergyMonitorAdapterFactory] = None + + if energy_monitor.adapter_type == EnergyMonitorAdapter.DUMMY_SOLAR: + # --- Dummy Solar --- + if not energy_source: + raise ValueError("EnergySource is required for DummySolar energy monitor.") + + energy_monitor_adapter_factory = DummySolarEnergyMonitorFactory() + + # Set energy source as reference + energy_monitor_adapter_factory.from_energy_source(energy_source) + elif energy_monitor.adapter_type == EnergyMonitorAdapter.HOME_ASSISTANT_API: + # --- Home Assistant API --- + if not energy_monitor.config: + raise ValueError("EnergyMonitor config is required for HomeAssistantAPI energy monitor.") + + energy_monitor_adapter_factory = HomeAssistantAPIEnergyMonitorFactory() + # Actually HomeAssistantAPI Energy Monitor + # does not needs an energy source as reference + else: + raise ValueError(f"Unsupported energy monitor adapter type: {energy_monitor.adapter_type}") + + instance = energy_monitor_adapter_factory.create( + config=energy_monitor.config, + logger=self.logger, + external_service=external_service, + ) + + self._instance_cache[energy_monitor.id] = instance + return instance + except Exception as e: + if self.logger: + self.logger.error( + f"Failed to initialize adapter '{energy_monitor.name}' " + f"(Type: {energy_monitor.adapter_type}) using factory: {e}" + ) + return None + + async def _initialize_miner_controller_adapter( + self, miner: Miner, miner_controller: MinerController + ) -> Optional[MinerFeaturePort]: + """Initialize a miner controller adapter.""" + # If the adapter has already been created, we use it. + if miner_controller.id in self._instance_cache: + if self.logger: + self.logger.debug( + f"Returning cached adapter instance " + f"for miner controller ID {miner_controller.id} " + f"(Type: {miner_controller.adapter_type})" + ) + + cached_instance = self._instance_cache[miner_controller.id] + if not cached_instance: + if self.logger: + self.logger.warning( + f"Cached instance for miner controller ID {miner_controller.id} " + f"is None. Reinitializing adapter." + ) + return None + + if not isinstance(cached_instance, MinerFeaturePort): + if self.logger: + self.logger.warning( + f"Cached instance for miner controller ID {miner_controller.id} " + f"is not of type MinerFeaturePort. Reinitializing adapter." + ) + return None + + return cached_instance + + # Retrieve the external service associated to the miner controller + external_service: Optional[ExternalServicePort] = None + if miner_controller.external_service_id: + external_service = await self.get_external_service(miner_controller.external_service_id) + if not external_service: + raise ValueError( + f"Unable to load external service {miner_controller.external_service_id} " + f"for miner controller {miner_controller.name}" + ) + + try: + miner_controller_factory: Optional[MinerControllerAdapterFactory] = None + instance: Optional[MinerFeaturePort] = None + + if miner_controller.adapter_type == MinerControllerAdapter.DUMMY: + if miner.power_consumption_max is None or miner.hash_rate_max is None: + raise ValueError( + "Miner power consumption max and hash rate max are required for DummyMinerController." + ) + # --- Dummy Controller --- + instance = DummyMinerController( + power_max=miner.power_consumption_max, + hashrate_max=miner.hash_rate_max, + logger=self.logger, + ) + elif miner_controller.adapter_type == MinerControllerAdapter.GENERIC_SOCKET_HOME_ASSISTANT_API: + # --- Generic Socket Home Assistant API Controller --- + miner_controller_factory = GenericSocketHomeAssistantAPIMinerControllerAdapterFactory() + + miner_controller_factory.from_miner(miner) + + instance = miner_controller_factory.create( + config=miner_controller.config, + logger=self.logger, + external_service=external_service, + ) + elif miner_controller.adapter_type == MinerControllerAdapter.PYASIC: + # --- PyASIC Controller --- + miner_controller_factory = PyASICMinerControllerAdapterFactory() + + miner_controller_factory.from_miner(miner) + + instance = miner_controller_factory.create( + config=miner_controller.config, + logger=self.logger, + external_service=external_service, + ) + else: + raise ValueError(f"Unsupported miner controller adapter type: {miner_controller.adapter_type}") + + self._instance_cache[miner_controller.id] = instance + return instance + except Exception as e: + if self.logger: + self.logger.error( + f"Failed to initialize adapter '{miner_controller.name}' " + f"(Type: {miner_controller.adapter_type}) using factory: {e}" + ) + return None + + async def _initialize_notifier_adapter(self, notifier: Notifier) -> Optional[NotificationPort]: + """Initialize a notifier adapter.""" + # If the adapter has already been created, we use it. + if notifier.id in self._instance_cache: + if self.logger: + self.logger.debug( + f"Returning cached adapter instance for notifier ID {notifier.id} (Type: {notifier.adapter_type})" + ) + + cached_instance = self._instance_cache[notifier.id] + + if not cached_instance: + # If the cached instance is None, we return it + # to indicate that the adapter was not initialized. + if self.logger: + self.logger.warning( + f"Cached instance for notifier ID {notifier.id} is None. Reinitializing adapter." + ) + return None + + # Check if the cached instance is of the correct type + if not isinstance(cached_instance, NotificationPort): + if self.logger: + self.logger.warning( + f"Cached instance for notifier ID {notifier.id} " + f"is not of type NotificationPort. Reinitializing adapter." + ) + return None + + # If the cached instance is valid, we return it + return cached_instance + + # Retrieve the external service associated to the notifier + external_service: Optional[ExternalServicePort] = None + if notifier.external_service_id: + external_service = await self.get_external_service(notifier.external_service_id) + if not external_service: + raise ValueError( + f"Unable to load external service {notifier.external_service_id} for notifier {notifier.name}" + ) + try: + instance: Optional[NotificationPort] = None + + if notifier.adapter_type == NotificationAdapter.DUMMY: + # --- Dummy Notifier --- + instance = DummyNotifier() + elif notifier.adapter_type == NotificationAdapter.TELEGRAM: + # --- Telegram Notifier --- + instance = TelegramNotifierFactory().create( + config=notifier.config, + logger=self.logger, + external_service=external_service, + ) + else: + raise ValueError(f"Unsupported notifier adapter type: {notifier.adapter_type}") + + self._instance_cache[notifier.id] = instance + return instance + except Exception as e: + if self.logger: + self.logger.error( + f"Failed to initialize adapter '{notifier.name}' (Type: {notifier.adapter_type}) using factory: {e}" + ) + return None + + async def _initialize_forecast_provider_adapter( + self, energy_source: EnergySource, forecast_provider: ForecastProvider + ) -> Optional[ForecastProviderPort]: + """Initialize a forecast provider adapter.""" + # If the adapter has already been created, we use it. + if forecast_provider.id in self._instance_cache: + if self.logger: + self.logger.debug( + f"Returning cached adapter instance " + f"for forecast provider ID {forecast_provider.id} " + f"(Type: {forecast_provider.adapter_type})" + ) + cached_instance = self._instance_cache[forecast_provider.id] + + if not cached_instance: + # If the cached instance is None, we return it + # to indicate that the adapter was not initialized. + if self.logger: + self.logger.warning( + "Cached instance for forecast provider " + f"ID {forecast_provider.id} " + f"is None. Reinitializing adapter." + ) + return None + + # Check if the cached instance is of the correct type + if not isinstance(cached_instance, ForecastProviderPort): + if self.logger: + self.logger.warning( + "Cached instance for forecast provider " + f"ID {forecast_provider.id} " + f"is not of type ForecastProviderPort. Reinitializing adapter." + ) + return None + + # If the cached instance is valid, we return it + return cached_instance + + # Retrieve the external service associated to the forecast provider + if forecast_provider.external_service_id: + external_service = await self.get_external_service(forecast_provider.external_service_id) + if not external_service: + raise ValueError( + f"Unable to load external service {forecast_provider.external_service_id} " + f"for forecast provider {forecast_provider.name}" + ) + + try: + forecast_provider_adapter_factory: Optional[ForecastAdapterFactory] = None + + if forecast_provider.adapter_type == ForecastProviderAdapter.DUMMY_SOLAR: + # --- Dummy Forecast Provider --- + if not energy_source: + raise ValueError("EnergySource is required for DummySolar forecast provider.") + + forecast_provider_adapter_factory = DummyForecastProviderFactory() + + # Set energy source as reference + forecast_provider_adapter_factory.from_energy_source(energy_source) + elif forecast_provider.adapter_type == ForecastProviderAdapter.HOME_ASSISTANT_API: + # --- Home Assistant API Forecast Provider --- + if not forecast_provider.config: + raise ValueError("ForecastProvider config is required for HomeAssistantAPI forecast provider.") + + forecast_provider_adapter_factory = HomeAssistantForecastProviderFactory() + else: + raise ValueError(f"Unsupported forecast provider adapter type: {forecast_provider.adapter_type}") + + instance = forecast_provider_adapter_factory.create( + config=forecast_provider.config, + logger=self.logger, + external_service=external_service, + ) + + self._instance_cache[forecast_provider.id] = instance + return instance + except Exception as e: + if self.logger: + self.logger.error( + f"Failed to initialize adapter '{forecast_provider.name}' " + f"(Type: {forecast_provider.adapter_type}) using factory: {e}" + ) + return None + + def _initialize_energy_load_forecast_provider_adapter( + self, energy_load_forecast_provider: EnergyLoadForecastProvider + ) -> Optional[EnergyLoadForecastProviderPort]: + """Initialize a home forecast provider adapter.""" + # If the adapter has already been created, we use it. + if energy_load_forecast_provider.id in self._instance_cache: + if self.logger: + self.logger.debug( + f"Returning cached adapter instance " + f"for home forecast provider ID {energy_load_forecast_provider.id} " + f"(Type: {energy_load_forecast_provider.adapter_type})" + ) + cached_instance = self._instance_cache[energy_load_forecast_provider.id] + + if not cached_instance: + # If the cached instance is None, we return it + # to indicate that the adapter was not initialized. + if self.logger: + self.logger.warning( + f"Cached instance for home forecast provider ID " + f"{energy_load_forecast_provider.id} is None. Reinitializing adapter." + ) + return None + + # Check if the cached instance is of the correct type + if not isinstance(cached_instance, EnergyLoadForecastProviderPort): + if self.logger: + self.logger.warning( + f"Cached instance for home forecast provider ID " + f"{energy_load_forecast_provider.id} is not of type EnergyLoadForecastProviderPort. " + "Reinitializing adapter." + ) + return None + + # If the cached instance is valid, we return it + return cached_instance + + try: + factory: Optional[EnergyLoadForecastAdapterFactory] = None + + if energy_load_forecast_provider.adapter_type == EnergyLoadForecastProviderAdapter.DUMMY: + factory = DummyEnergyLoadForecastProviderFactory() + elif energy_load_forecast_provider.adapter_type == EnergyLoadForecastProviderAdapter.NAIVE_LAST_HOUR: + factory = NaiveLastHourForecastProviderFactory() + elif energy_load_forecast_provider.adapter_type == EnergyLoadForecastProviderAdapter.NAIVE_PERSISTENCE: + factory = NaivePersistenceForecastProviderFactory() + elif energy_load_forecast_provider.adapter_type == EnergyLoadForecastProviderAdapter.SEASONAL_BASELINE: + factory = SeasonalBaselineForecastProviderFactory() + elif energy_load_forecast_provider.adapter_type == EnergyLoadForecastProviderAdapter.SKFORECAST: + factory = SkforecastForecastProviderFactory(model_repo=self.load_consumption_model_repo) + elif energy_load_forecast_provider.adapter_type == EnergyLoadForecastProviderAdapter.STATSMODELS: + factory = StatsmodelsForecastProviderFactory(model_repo=self.load_consumption_model_repo) + elif energy_load_forecast_provider.adapter_type == EnergyLoadForecastProviderAdapter.TYPICAL_PROFILE: + factory = TypicalProfileForecastProviderFactory() + elif energy_load_forecast_provider.adapter_type == EnergyLoadForecastProviderAdapter.XGBOOST: + factory = XGBoostForecastProviderFactory(model_repo=self.load_consumption_model_repo) + else: + raise ValueError( + f"Unsupported home forecast provider adapter type: {energy_load_forecast_provider.adapter_type}" + ) + + instance = factory.create( + config=energy_load_forecast_provider.config, + logger=self.logger, + external_service=None, + ) + + self._instance_cache[energy_load_forecast_provider.id] = instance + return instance + except Exception as e: + if self.logger: + self.logger.error( + f"Failed to initialize adapter '{energy_load_forecast_provider.name}' " + f"(Type: {energy_load_forecast_provider.adapter_type}) using factory: {e}" + ) + return None + + async def _initialize_mining_performance_tracker_adapter( + self, tracker: MiningPerformanceTracker + ) -> Optional[MiningPerformanceTrackerPort]: + """Initialize a mining performance tracker adapter.""" + # If the adapter has already been created, we use it. + if tracker.id in self._instance_cache: + if self.logger: + self.logger.debug( + f"Returning cached adapter instance " + f"for mining performance tracker ID {tracker.id} " + f"(Type: {tracker.adapter_type})" + ) + cached_instance = self._instance_cache[tracker.id] + + if not cached_instance: + # If the cached instance is None, we return it + # to indicate that the adapter was not initialized. + if self.logger: + self.logger.warning( + f"Cached instance for mining performance tracker ID {tracker.id} " + f"is None. Reinitializing adapter." + ) + return None + + # Check if the cached instance is of the correct type + if not isinstance(cached_instance, MiningPerformanceTrackerPort): + if self.logger: + self.logger.warning( + f"Cached instance for mining performance tracker ID {tracker.id} " + f"is not of type MiningPerformanceTrackerPort. Reinitializing adapter." + ) + return None + + # If the cached instance is valid, we return it + return cached_instance + + # Retrieve the external service associated to the tracker (if any) + external_service: Optional[ExternalServicePort] = None + if tracker.external_service_id: + external_service = await self.get_external_service(tracker.external_service_id) + if not external_service: + raise ValueError( + f"Unable to load external service {tracker.external_service_id} " + f"for mining performance tracker {tracker.name}" + ) + + try: + tracker_factory: Optional[MiningPerformanceTrackerAdapterFactory] = None + + if tracker.adapter_type == MiningPerformanceTrackerAdapter.DUMMY: + # --- Dummy Tracker --- + tracker_factory = DummyMiningPerformanceTrackerFactory() + elif tracker.adapter_type == MiningPerformanceTrackerAdapter.OCEAN: + # --- Ocean.xyz Tracker --- + tracker_factory = OceanMiningPerformanceTrackerFactory() + elif tracker.adapter_type == MiningPerformanceTrackerAdapter.BRAIINS_POOL: + # --- Braiins Pool Tracker --- + tracker_factory = BraiinsPoolMiningPerformanceTrackerFactory() + else: + raise ValueError(f"Unsupported mining performance tracker adapter type: {tracker.adapter_type}") + + instance = tracker_factory.create( + config=tracker.config, + logger=self.logger, + external_service=external_service, + ) + + self._instance_cache[tracker.id] = instance + return instance + except Exception as e: + if self.logger: + self.logger.error( + f"Failed to initialize adapter '{tracker.name}' (Type: {tracker.adapter_type}) using factory: {e}" + ) + return None + + async def get_energy_monitor(self, energy_source: EnergySource) -> Optional[EnergyMonitorPort]: + """Get an energy monitor adapter instance.""" + if not energy_source.energy_monitor_id: + if self.logger: + self.logger.error(f"EnergySource {energy_source.name} does not have an associated EnergyMonitor ID.") + return None + energy_monitor = self.energy_monitor_repo.get_by_id(energy_source.energy_monitor_id) + if not energy_monitor: + if self.logger: + self.logger.error( + f"EnergyMonitor ID {energy_source.energy_monitor_id} not found or not an EnergyMonitor." + ) + return None + return await self._initialize_energy_monitor_adapter(energy_source, energy_monitor) + + async def get_miner_controller_adapter(self, miner: Miner, controller_id: EntityId) -> Optional[MinerFeaturePort]: + """Get a miner controller adapter instance for a specific controller.""" + miner_controller = self.miner_controller_repo.get_by_id(controller_id) + if not miner_controller: + if self.logger: + self.logger.error(f"Miner Controller ID {controller_id} not found.") + return None + return await self._initialize_miner_controller_adapter(miner, miner_controller) + + async def sync_miner_features(self, miner: Miner) -> bool: + """Reconcile stored features with what controllers actually support. + + For each controller associated with the miner, discovers which features + the adapter supports and adds missing ones / removes stale ones. + Returns True if any changes were made (and persisted). + """ + changed = False + controller_ids = miner.get_controller_ids() + + for controller_id in controller_ids: + adapter = await self.get_miner_controller_adapter(miner, controller_id) + if not adapter: + continue + + supported = set(adapter.__class__.get_supported_features()) + stored = {f.feature_type for f in miner.get_features_by_controller(controller_id)} + + # Add missing features + for feature_type in supported - stored: + feature = MinerFeature( + feature_type=feature_type, + controller_id=controller_id, + priority=50, + enabled=True, + ) + try: + miner.add_feature(feature) + changed = True + except ValueError: + pass + + # Remove stale features (stored but no longer supported) + for feature_type in stored - supported: + miner.remove_feature(feature_type, controller_id) + changed = True + + if changed: + self.miner_repo.update(miner) + if self.logger: + self.logger.info(f"Reconciled features for miner {miner.name}.") + + return changed + + async def get_miner_feature_port(self, miner: Miner, feature_type: MinerFeatureType) -> Optional[MinerFeaturePort]: + """Get the adapter implementing the highest-priority active feature for a miner. + + Resolves the active MinerFeature for the given feature_type, retrieves + the associated controller adapter, and verifies it supports the feature. + If the feature is not found, triggers a one-time reconciliation to catch + features added or removed by code changes. + """ + active_feature = miner.get_active_feature(feature_type) + + # Lazy reconciliation: if feature not found, sync and retry once + if not active_feature: + reconciled = await self.sync_miner_features(miner) + if reconciled: + active_feature = miner.get_active_feature(feature_type) + + if not active_feature: + if self.logger: + self.logger.debug(f"No active feature of type {feature_type.value} for miner {miner.name}.") + return None + + adapter = await self.get_miner_controller_adapter(miner, active_feature.controller_id) + if not adapter: + return None + + # Verify the adapter actually supports the requested feature type + supported = adapter.__class__.get_supported_features() + if feature_type not in supported: + if self.logger: + self.logger.error( + f"Adapter for controller {active_feature.controller_id} " + f"does not support feature {feature_type.value}." + ) + return None + + return adapter + + async def get_all_notifiers(self) -> List[NotificationPort]: + """Get all notifier adapter instances""" + notifier_instances = [] + notifiers = self.notifier_repo.get_all() + if not notifiers or not len(notifiers) > 0: + if self.logger: + self.logger.error("Notifiers not configured.") + return [] + + for notifier in notifiers: + instance = await self._initialize_notifier_adapter(notifier) + if instance: + notifier_instances.append(instance) + else: + if self.logger: + self.logger.warning(f"Notifier ID {notifier.id} not found or not a Notification category.") + return notifier_instances + + async def get_notifier(self, notifier_id: EntityId) -> Optional[NotificationPort]: + """Get a specific notifier adapter instance by ID.""" + notifier = self.notifier_repo.get_by_id(notifier_id) + if not notifier: + if self.logger: + self.logger.error(f"Notifier ID {notifier_id} not found or not a Notifier.") + return None + return await self._initialize_notifier_adapter(notifier) + + async def get_notifiers(self, notifier_ids: List[EntityId]) -> List[NotificationPort]: + """Get a list of specific notifier adapter instances by IDs.""" + notifier_instances: List[NotificationPort] = [] + for notifier_id in notifier_ids: + notifier = self.notifier_repo.get_by_id(notifier_id) + if not notifier: + if self.logger: + self.logger.error(f"Notifier ID {notifier_id} not found or not a Notifier.") + continue + + instance = await self._initialize_notifier_adapter(notifier) + if instance: + notifier_instances.append(instance) + else: + if self.logger: + self.logger.warning(f"Notifier ID {notifier.id} not found or not a Notification category.") + return notifier_instances + + async def get_forecast_provider(self, energy_source: EnergySource) -> Optional[ForecastProviderPort]: + """Get a forecast provider adapter instance.""" + if not energy_source.forecast_provider_id: + if self.logger: + self.logger.error(f"EnergySource {energy_source.name} does not have an associated ForecastProvider ID.") + return None + forecast_provider = self.forecast_provider_repo.get_by_id(energy_source.forecast_provider_id) + if not forecast_provider: + if self.logger: + self.logger.error( + f"Forecast Provider ID {energy_source.forecast_provider_id} not found or not a Forecast Provider." + ) + return None + return await self._initialize_forecast_provider_adapter(energy_source, forecast_provider) + + def get_home_load_forecast_provider( + self, energy_load_forecast_provider_id: EntityId + ) -> Optional[EnergyLoadForecastProviderPort]: + """Get an home load forecast provider adapter instance.""" + energy_load_forecast_provider = self.energy_load_forecast_provider_repo.get_by_id( + energy_load_forecast_provider_id + ) + if not energy_load_forecast_provider: + if self.logger: + self.logger.error( + f"Home Forecast Provider ID {energy_load_forecast_provider_id} not found or not a Home Forecast Provider." + ) + return None + return self._initialize_energy_load_forecast_provider_adapter(energy_load_forecast_provider) + + async def _initialize_energy_load_history_provider_adapter( + self, energy_load_history_provider: EnergyLoadHistoryProvider, device_id: EntityId + ) -> Optional[EnergyLoadHistoryProviderPort]: + """Initialize an energy load history provider adapter.""" + cache_key = energy_load_history_provider.id + if cache_key in self._instance_cache: + cached_instance = self._instance_cache[cache_key] + if cached_instance and isinstance(cached_instance, EnergyLoadHistoryProviderPort): + return cached_instance + return None + + # Resolve external service if needed + external_service: Optional[ExternalServicePort] = None + if energy_load_history_provider.external_service_id: + external_service = await self.get_external_service(energy_load_history_provider.external_service_id) + if not external_service: + raise ValueError( + f"Unable to load external service {energy_load_history_provider.external_service_id} " + f"for history provider {energy_load_history_provider.name}" + ) + + try: + instance: Optional[EnergyLoadHistoryProviderPort] = None + + if energy_load_history_provider.adapter_type == EnergyLoadHistoryProviderAdapter.DUMMY: + instance = DummyEnergyLoadHistoryProvider( + device_id=device_id, + history_repo=self.home_load_history_repo, + logger=self.logger, + ) + elif energy_load_history_provider.adapter_type == EnergyLoadHistoryProviderAdapter.HOME_ASSISTANT_API: + if not energy_load_history_provider.config: + raise ValueError( + "EnergyLoadHistoryProvider config is required for HomeAssistantAPI history provider." + ) + if not external_service: + raise ValueError( + f"External service is required for HomeAssistantAPI history provider " + f"'{energy_load_history_provider.name}'. " + f"Please set external_service_id on the provider." + ) + # Resolve the LoadDevice for the factory + factory = HomeAssistantAPIEnergyLoadHistoryProviderFactory( + history_repo=self.home_load_history_repo, + ) + # Build a minimal LoadDevice for the factory binding + + load_device = LoadDevice(id=device_id) + factory.from_load_device(load_device) + instance = factory.create( + config=energy_load_history_provider.config, + logger=self.logger, + external_service=external_service, + ) + else: + raise ValueError( + f"Unsupported energy load history provider adapter type: " + f"{energy_load_history_provider.adapter_type}" + ) + + self._instance_cache[cache_key] = instance + return instance + except Exception as e: + if self.logger: + self.logger.error( + f"Failed to initialize adapter '{energy_load_history_provider.name}' " + f"(Type: {energy_load_history_provider.adapter_type}): {e}" + ) + return None + + async def get_home_load_history_provider( + self, energy_load_history_provider_id: EntityId, device_id: EntityId + ) -> Optional[EnergyLoadHistoryProviderPort]: + """Get an energy load history provider adapter instance.""" + energy_load_history_provider = self.energy_load_history_provider_repo.get_by_id(energy_load_history_provider_id) + if not energy_load_history_provider: + if self.logger: + self.logger.error(f"Home History Provider ID {energy_load_history_provider_id} not found.") + return None + return await self._initialize_energy_load_history_provider_adapter(energy_load_history_provider, device_id) + + async def get_mining_performance_tracker(self, tracker_id: EntityId) -> Optional[MiningPerformanceTrackerPort]: + """Get a mining performance tracker adapter instance.""" + tracker = self.mining_performance_tracker_repo.get_by_id(tracker_id) + if not tracker: + if self.logger: + self.logger.error( + f"Mining Performance Tracker ID {tracker_id} not found or not a Mining Performance Tracker." + ) + return None + return await self._initialize_mining_performance_tracker_adapter(tracker) + + async def get_external_service(self, external_service_id: EntityId) -> Optional[ExternalServicePort]: + """Get a specific external service instance by ID.""" + external_service = self.external_service_repo.get_by_id(external_service_id) + if not external_service: + if self.logger: + self.logger.error(f"External Service ID {external_service_id} not found or not an External Service.") + return None + return await self._initialize_external_service(external_service) + + def get_rule_engine(self) -> Optional[RuleEngine]: + """Creates a new Rule Engine instance.""" + try: + # For now, we default to the 'custom' engine. + # This could be driven by configuration in the future. + factory = RuleEngineFactory() + engine = factory.create(engine_type=RuleEngineType.CUSTOM, logger=self.logger) + return engine + except Exception as e: + if self.logger: + self.logger.error(f"Failed to create RuleEngine instance: {e}") + return None + + def clear_all_adapters(self): + """Clear adapter cache""" + if self.logger: + self.logger.info("Clearing all adapters.") + self._instance_cache = {} # Reset the cache + + def remove_adapter(self, entity_id: EntityId): + """Remove a specific adapter from the cache.""" + if entity_id in self._instance_cache: + del self._instance_cache[entity_id] + if self.logger: + self.logger.info(f"Removed adapter with ID {entity_id} from cache.") + else: + if self.logger: + self.logger.warning(f"No adapter found with ID {entity_id} to remove.") + + def clear_all_services(self): + """Clear external services cache""" + if self.logger: + self.logger.info("Clearing all external services.") + self._service_cache = {} # Reset the cache + + def remove_service(self, external_service_id: EntityId): + """Remove a specific external service from the cache.""" + if external_service_id in self._service_cache: + del self._service_cache[external_service_id] + if self.logger: + self.logger.info(f"Removed external service with ID {external_service_id} from cache.") + else: + if self.logger: + self.logger.warning(f"No external service found with ID {external_service_id} to remove.") + + async def on_configuration_updated(self, event: ConfigurationUpdatedEvent) -> None: + """Handler for cache invalidation when a configuration changes.""" + if self.logger: + self.logger.debug(f"Cache invalidation: {event.entity_type} {event.entity_id} ({event.action})") + + if event.entity_id is None: + return + + if event.entity_type == ConfigurationUpdatedEventType.EXTERNAL_SERVICE: + # Invalidate the external service AND all adapters that may depend on it + self._service_cache.pop(event.entity_id, None) + self._instance_cache.clear() + else: + # Invalidate the specific adapter + self._instance_cache.pop(event.entity_id, None) diff --git a/core/edge_mining/application/services/configuration_service.py b/core/edge_mining/application/services/configuration_service.py new file mode 100644 index 0000000..e738a24 --- /dev/null +++ b/core/edge_mining/application/services/configuration_service.py @@ -0,0 +1,2475 @@ +""" +Configuration service for managing all domain entities of edge mining application. +""" + +from typing import Any, Dict, List, Optional + +from edge_mining.application.events.common import ( + ConfigurationAction, + ConfigurationUpdatedEventType, +) +from edge_mining.application.events.configuration_events import ConfigurationUpdatedEvent +from edge_mining.application.interfaces import AdapterServiceInterface, ConfigurationServiceInterface, EventBusInterface +from edge_mining.domain.common import EntityId, Watts +from edge_mining.domain.energy.common import EnergyMonitorAdapter, EnergySourceType +from edge_mining.domain.energy.entities import EnergyMonitor, EnergySource +from edge_mining.domain.energy.exceptions import ( + EnergyMonitorConfigurationError, + EnergyMonitorNotFoundError, + EnergySourceNotFoundError, +) +from edge_mining.domain.energy.ports import EnergyMonitorRepository, EnergySourceRepository +from edge_mining.domain.energy.value_objects import Battery, Grid +from edge_mining.domain.forecast.common import ForecastProviderAdapter +from edge_mining.domain.forecast.entities import ForecastProvider +from edge_mining.domain.forecast.exceptions import ForecastProviderConfigurationError, ForecastProviderNotFoundError +from edge_mining.domain.forecast.ports import ForecastProviderRepository +from edge_mining.domain.home_load.aggregate_roots import HomeLoadsProfile +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter, EnergyLoadHistoryProviderAdapter +from edge_mining.domain.home_load.entities import EnergyLoadForecastProvider, EnergyLoadHistoryProvider, LoadDevice +from edge_mining.domain.home_load.exceptions import ( + EnergyLoadForecastProviderConfigurationError, + EnergyLoadForecastProviderNotFoundError, + EnergyLoadHistoryProviderConfigurationError, + EnergyLoadHistoryProviderNotFoundError, + HomeLoadsProfileNotFoundError, +) +from edge_mining.domain.home_load.ports import ( + EnergyLoadForecastProviderRepository, + EnergyLoadHistoryProviderRepository, + HomeLoadsProfileRepository, +) +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.common import MinerControllerAdapter, MinerFeatureType +from edge_mining.domain.miner.entities import MinerController +from edge_mining.domain.miner.exceptions import ( + MinerControllerConfigurationError, + MinerControllerNotFoundError, + MinerNotFoundError, +) +from edge_mining.domain.miner.ports import MinerControllerRepository, MinerRepository +from edge_mining.domain.miner.value_objects import HashRate, MinerFeature +from edge_mining.domain.notification.common import NotificationAdapter +from edge_mining.domain.notification.entities import Notifier +from edge_mining.domain.notification.exceptions import NotifierConfigurationError, NotifierNotFoundError +from edge_mining.domain.notification.ports import NotifierRepository +from edge_mining.domain.optimization_unit.aggregate_roots import EnergyOptimizationUnit +from edge_mining.domain.optimization_unit.exceptions import ( + OptimizationUnitConfigurationError, + OptimizationUnitNotFoundError, +) +from edge_mining.domain.optimization_unit.ports import EnergyOptimizationUnitRepository +from edge_mining.domain.performance.common import MiningPerformanceTrackerAdapter +from edge_mining.domain.performance.entities import MiningPerformanceTracker +from edge_mining.domain.performance.exceptions import ( + MiningPerformanceTrackerConfigurationError, + MiningPerformanceTrackerNotFoundError, +) +from edge_mining.domain.performance.ports import MiningPerformanceTrackerRepository +from edge_mining.domain.policy.aggregate_roots import OptimizationPolicy +from edge_mining.domain.policy.common import RuleType +from edge_mining.domain.policy.entities import AutomationRule +from edge_mining.domain.policy.exceptions import ( + PolicyAlreadyExistsError, + PolicyConfigurationError, + PolicyError, + PolicyNotFoundError, + RuleNotFoundError, +) +from edge_mining.domain.policy.ports import OptimizationPolicyRepository +from edge_mining.domain.policy.services import RuleValidationService +from edge_mining.domain.user.common import UserId +from edge_mining.domain.user.entities import SystemSettings +from edge_mining.shared.adapter_maps.energy import ( + ENERGY_MONITOR_CONFIG_TYPE_MAP, + ENERGY_MONITOR_TYPE_EXTERNAL_SERVICE_MAP, + ENERGY_SOURCE_TYPE_FORECAST_PROVIDER_CONFIG_MAP, + ENERGY_SOURCE_TYPE_FORECAST_PROVIDER_TYPE_MAP, +) +from edge_mining.shared.adapter_maps.external_services import EXTERNAL_SERVICE_CONFIG_TYPE_MAP +from edge_mining.shared.adapter_maps.forecast import ( + FORECAST_PROVIDER_CONFIG_TYPE_MAP, + FORECAST_PROVIDER_TYPE_EXTERNAL_SERVICE_MAP, +) +from edge_mining.shared.adapter_maps.home_load import ( + ENERGY_LOAD_FORECAST_PROVIDER_EXTERNAL_SERVICE_MAP, + ENERGY_LOAD_HISTORY_PROVIDER_EXTERNAL_SERVICE_MAP, +) +from edge_mining.shared.adapter_maps.miner import ( + MINER_CONTROLLER_CONFIG_TYPE_MAP, + MINER_CONTROLLER_TYPE_EXTERNAL_SERVICE_MAP, +) +from edge_mining.shared.adapter_maps.notification import NOTIFIER_CONFIG_TYPE_MAP, NOTIFIER_TYPE_EXTERNAL_SERVICE_MAP +from edge_mining.shared.adapter_maps.performance import ( + MINING_PERFORMANCE_TRACKER_CONFIG_TYPE_MAP, + MINING_PERFORMANCE_TRACKER_TYPE_EXTERNAL_SERVICE_MAP, +) +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.external_services.entities import ExternalService +from edge_mining.shared.external_services.exceptions import ( + ExternalServiceConfigurationError, + ExternalServiceNotFoundError, +) +from edge_mining.shared.external_services.ports import ExternalServiceRepository +from edge_mining.shared.external_services.value_objects import ExternalServiceLinkedEntities +from edge_mining.shared.infrastructure import PersistenceSettings +from edge_mining.shared.interfaces.config import ( + EnergyMonitorConfig, + ExternalServiceConfig, + ForecastProviderConfig, + MinerControllerConfig, + MiningPerformanceTrackerConfig, + NotificationConfig, +) +from edge_mining.shared.logging.port import LoggerPort +from edge_mining.shared.settings.ports import SettingsRepository + + +class ConfigurationService(ConfigurationServiceInterface): + """Handles configuration of miners, policies, and system settings.""" + + def __init__( + self, + persistence_settings: PersistenceSettings, + event_bus: EventBusInterface, + logger: LoggerPort, + adapter_service: Optional[AdapterServiceInterface] = None, + ): + # Domains + self.external_service_repo: ExternalServiceRepository = persistence_settings.external_service_repo + self.energy_source_repo: EnergySourceRepository = persistence_settings.energy_source_repo + self.energy_monitor_repo: EnergyMonitorRepository = persistence_settings.energy_monitor_repo + self.miner_repo: MinerRepository = persistence_settings.miner_repo + self.miner_controller_repo: MinerControllerRepository = persistence_settings.miner_controller_repo + self.policy_repo: OptimizationPolicyRepository = persistence_settings.policy_repo + self.optimization_unit_repo: EnergyOptimizationUnitRepository = persistence_settings.optimization_unit_repo + self.forecast_provider_repo: ForecastProviderRepository = persistence_settings.forecast_provider_repo + self.energy_load_forecast_provider_repo: EnergyLoadForecastProviderRepository = ( + persistence_settings.energy_load_forecast_provider_repo + ) + self.energy_load_history_provider_repo: EnergyLoadHistoryProviderRepository = ( + persistence_settings.energy_load_history_provider_repo + ) + self.home_profile_repo: HomeLoadsProfileRepository = persistence_settings.home_profile_repo + self.mining_performance_tracker_repo: MiningPerformanceTrackerRepository = ( + persistence_settings.mining_performance_tracker_repo + ) + self.notifier_repo: NotifierRepository = persistence_settings.notifier_repo + self.settings_repo: SettingsRepository = persistence_settings.settings_repo + + # Infrastructure + self._event_bus = event_bus + self.logger = logger + self.adapter_service = adapter_service + + # --- External Service Management --- + async def create_external_service( + self, + name: str, + adapter_type: ExternalServiceAdapter, + config: ExternalServiceConfig, + ) -> ExternalService: + """Create a new external service.""" + self.logger.debug(f"Creating external service '{name}' with adapter {adapter_type}") + + external_service = ExternalService(name=name, adapter_type=adapter_type, config=config) + + self.check_external_service(external_service) + + self.external_service_repo.add(external_service) + + await self._event_bus.publish( + ConfigurationUpdatedEvent( + entity_type=ConfigurationUpdatedEventType.EXTERNAL_SERVICE, + entity_id=external_service.id, + action=ConfigurationAction.CREATED, + ) + ) + + return external_service + + def get_external_service(self, service_id: EntityId) -> Optional[ExternalService]: + """Get an external service by its ID.""" + external_service = self.external_service_repo.get_by_id(service_id) + + if not external_service: + return None + + return external_service + + def list_external_services(self) -> List[ExternalService]: + """List all external services in the system.""" + return self.external_service_repo.get_all() + + def get_entities_by_external_service(self, service_id: EntityId) -> ExternalServiceLinkedEntities: + """Get entities associated with this external service""" + miner_controllers: List[MinerController] = self.miner_controller_repo.get_by_external_service_id(service_id) + energy_monitors: List[EnergyMonitor] = self.energy_monitor_repo.get_by_external_service_id(service_id) + forecast_providers: List[ForecastProvider] = self.forecast_provider_repo.get_by_external_service_id(service_id) + energy_load_forecast_providers: List[EnergyLoadForecastProvider] = ( + self.energy_load_forecast_provider_repo.get_by_external_service_id(service_id) + ) + energy_load_history_providers: List[EnergyLoadHistoryProvider] = ( + self.energy_load_history_provider_repo.get_by_external_service_id(service_id) + ) + notifiers: List[Notifier] = self.notifier_repo.get_by_external_service_id(service_id) + + external_service_linked_entities = ExternalServiceLinkedEntities( + miner_controllers=miner_controllers, + energy_monitors=energy_monitors, + forecast_providers=forecast_providers, + energy_load_forecast_providers=energy_load_forecast_providers, + energy_load_history_providers=energy_load_history_providers, + notifiers=notifiers, + ) + return external_service_linked_entities + + async def unlink_external_service(self, service_id: EntityId) -> None: + """Remove the association of an external service from all entities.""" + self.logger.debug(f"Unlinking external service {service_id}") + + # Get entities associated with this external service + external_service_linked_entities = self.get_entities_by_external_service(service_id) + + # Unlink from miner controllers + for controller in external_service_linked_entities.miner_controllers: + self.logger.debug( + f"Unlinking controller {controller.name} ({controller.id}) from external service {service_id}" + ) + controller.external_service_id = None + self.miner_controller_repo.update(controller) + + # Unlink from energy monitors + for monitor in external_service_linked_entities.energy_monitors: + self.logger.debug( + f"Unlinking energy monitor {monitor.name} ({monitor.id}) from external service {service_id}" + ) + monitor.external_service_id = None + self.energy_monitor_repo.update(monitor) + + # Unlink from forecast providers + for forecast_provider in external_service_linked_entities.forecast_providers: + self.logger.debug( + f"Unlinking forecast provider {forecast_provider.name} " + f"({forecast_provider.id}) from external service {service_id}" + ) + forecast_provider.external_service_id = None + self.forecast_provider_repo.update(forecast_provider) + + # Unlink from home forecast providers + for energy_load_forecast_provider in external_service_linked_entities.energy_load_forecast_providers: + self.logger.debug( + f"Unlinking home forecast provider {energy_load_forecast_provider.name} " + f"({energy_load_forecast_provider.id}) from external service {service_id}" + ) + energy_load_forecast_provider.external_service_id = None + self.energy_load_forecast_provider_repo.update(energy_load_forecast_provider) + + # Unlink from home history providers + for energy_load_history_provider in external_service_linked_entities.energy_load_history_providers: + self.logger.debug( + f"Unlinking home history provider {energy_load_history_provider.name} " + f"({energy_load_history_provider.id}) from external service {service_id}" + ) + energy_load_history_provider.external_service_id = None + self.energy_load_history_provider_repo.update(energy_load_history_provider) + + # Unlink from notifiers + for notifier in external_service_linked_entities.notifiers: + self.logger.debug(f"Unlinking notifier {notifier.name} ({notifier.id}) from external service {service_id}") + notifier.external_service_id = None + self.notifier_repo.update(notifier) + + async def remove_external_service(self, service_id: EntityId) -> ExternalService: + """Remove an external service from the system.""" + self.logger.debug(f"Removing external service {service_id}") + + external_service = self.external_service_repo.get_by_id(service_id) + + if not external_service: + raise ExternalServiceNotFoundError(f"External Service with ID {service_id} not found.") + + # Unlink the external service from all associated entities before removal + await self.unlink_external_service(service_id) + + self.external_service_repo.remove(service_id) + + await self._event_bus.publish( + ConfigurationUpdatedEvent( + entity_type=ConfigurationUpdatedEventType.EXTERNAL_SERVICE, + entity_id=service_id, + action=ConfigurationAction.REMOVED, + ) + ) + + return external_service + + async def update_external_service( + self, + service_id: EntityId, + name: str, + config: ExternalServiceConfig, + ) -> ExternalService: + """ + Update an external service in the system. + This method updates the name and configuration only of an existing external service. + """ + + external_service = self.external_service_repo.get_by_id(service_id) + + if not external_service: + raise ExternalServiceNotFoundError(f"External Service with ID {name} not found.") + + self.logger.debug(f"Updating external service {service_id} ({name})") + + external_service.name = name + external_service.config = config + + self.check_external_service(external_service) + + self.external_service_repo.update(external_service) + + await self._event_bus.publish( + ConfigurationUpdatedEvent( + entity_type=ConfigurationUpdatedEventType.EXTERNAL_SERVICE, + entity_id=service_id, + action=ConfigurationAction.UPDATED, + ) + ) + + return external_service + + def check_external_service(self, external_service: ExternalService) -> bool: + """Check if an external service is valid and can be used.""" + self.logger.debug(f"Checking external service {external_service.id} ({external_service.name})") + + if not external_service: + raise ExternalServiceNotFoundError("External Service not found.") + + # Checks if the configuration is valid for the given adapter type + if external_service.config is None or not external_service.config.is_valid(external_service.adapter_type): + raise ExternalServiceConfigurationError( + f"Invalid configuration for External Service {external_service.name} " + f"with adapter {external_service.adapter_type}." + ) + + self.logger.debug(f"External Service {external_service.id} ({external_service.name}) is valid.") + return True + + def get_external_service_config_by_type( + self, adapter_type: ExternalServiceAdapter + ) -> Optional[type[ExternalServiceConfig]]: + """Get the configuration class for a specific external service adapter type.""" + self.logger.debug(f"Getting configuration for external service adapter {adapter_type}") + if adapter_type not in EXTERNAL_SERVICE_CONFIG_TYPE_MAP: + raise ExternalServiceConfigurationError( + f"Adapter type {adapter_type} is not supported for external service configuration." + ) + + return EXTERNAL_SERVICE_CONFIG_TYPE_MAP.get(adapter_type, None) + + # --- Energy Source Management --- + async def create_energy_source( + self, + name: str, + source_type: EnergySourceType, + nominal_power_max: Optional[Watts] = None, + storage: Optional[Battery] = None, + grid: Optional[Grid] = None, + external_source: Optional[Watts] = None, + energy_monitor_id: Optional[EntityId] = None, + forecast_provider_id: Optional[EntityId] = None, + ) -> EnergySource: + """Create a new energy source.""" + self.logger.debug(f"Creating energy source '{name}' with type {source_type}") + + energy_source = EnergySource( + name=name, + type=source_type, + nominal_power_max=nominal_power_max, + storage=storage, + grid=grid, + external_source=external_source, + energy_monitor_id=energy_monitor_id, + forecast_provider_id=forecast_provider_id, + ) + + self.check_energy_source(energy_source) + + self.energy_source_repo.add(energy_source) + + return energy_source + + def get_energy_source(self, source_id: EntityId) -> Optional[EnergySource]: + """Get an energy source by its ID.""" + energy_source = self.energy_source_repo.get_by_id(source_id) + + if not energy_source: + raise EnergySourceNotFoundError(f"Energy Source with ID {source_id} not found.") + + return energy_source + + def list_energy_sources(self) -> List[EnergySource]: + """List all energy sources in the system.""" + return self.energy_source_repo.get_all() + + async def remove_energy_source(self, source_id: EntityId) -> EnergySource: + """Remove an energy source from the system.""" + self.logger.debug(f"Removing energy source {source_id}") + + energy_source = self.energy_source_repo.get_by_id(source_id) + + if not energy_source: + raise EnergySourceNotFoundError(f"Energy Source with ID {source_id} not found.") + + self.energy_source_repo.remove(source_id) + + return energy_source + + async def update_energy_source( + self, + source_id: EntityId, + name: str, + source_type: EnergySourceType, + nominal_power_max: Optional[Watts] = None, + storage: Optional[Battery] = None, + grid: Optional[Grid] = None, + external_source: Optional[Watts] = None, + energy_monitor_id: Optional[EntityId] = None, + forecast_provider_id: Optional[EntityId] = None, + ) -> EnergySource: + """Update an energy source in the system.""" + self.logger.debug(f"Updating energy source {source_id} ({name})") + + energy_source = self.energy_source_repo.get_by_id(source_id) + + if not energy_source: + raise EnergySourceNotFoundError(f"Energy Source with ID {source_id} not found.") + + energy_source.name = name + energy_source.type = source_type + energy_source.nominal_power_max = nominal_power_max + energy_source.storage = storage + energy_source.grid = grid + energy_source.external_source = external_source + energy_source.energy_monitor_id = energy_monitor_id + energy_source.forecast_provider_id = forecast_provider_id + + self.check_energy_source(energy_source) + + self.energy_source_repo.update(energy_source) + + return energy_source + + def check_energy_source(self, energy_source: EnergySource) -> bool: + """Check if an energy source is valid and can be used.""" + self.logger.debug(f"Checking energy source {energy_source.id} ({energy_source.name})") + + if energy_source.forecast_provider_id: + # Checks if the forecast provider exists + provider = self.forecast_provider_repo.get_by_id(energy_source.forecast_provider_id) + if not provider: + raise ForecastProviderNotFoundError( + f"Forecast Provider with ID {energy_source.forecast_provider_id} not found." + ) + + # Checks if the forecast provider type is compatible with the source type + required_types = ENERGY_SOURCE_TYPE_FORECAST_PROVIDER_TYPE_MAP.get(energy_source.type, None) + if required_types: + is_allowed_type = any([(provider.adapter_type == required_type) for required_type in required_types]) + if not is_allowed_type: + raise ForecastProviderConfigurationError( + f"Forecast Provider {provider.id} Type {provider.adapter_type} " + "is not compatible with Energy Source {energy_source.name} " + f"of type {energy_source.type}." + ) + + # Check if forecast provider is valid for the actual forecast provider type + if provider.config is None: + raise ForecastProviderConfigurationError( + f"Missing configuration for Forecast Provider {provider.id} " + f"into Energy Source {energy_source.name}." + ) + + if not provider.config.is_valid(provider.adapter_type): + raise ForecastProviderConfigurationError( + f"Mismatch between Forecast Provider {provider.id} configuration " + f"and adapter type {provider.adapter_type} for " + f"Energy Source {energy_source.name}." + ) + + # Checks if the forecast provider configuration is compatible with the + # source type + required_classes = ENERGY_SOURCE_TYPE_FORECAST_PROVIDER_CONFIG_MAP.get(energy_source.type, None) + if required_classes: + is_allowed_class = any( + [isinstance(provider.config, required_class) for required_class in required_classes] + ) + if not is_allowed_class: + raise ForecastProviderConfigurationError( + f"Forecast Provider Configuration {provider.id} is not compatible " + f"with Energy Source {energy_source.name} of type {energy_source.type}." + ) + + self.logger.debug(f"Energy Source {energy_source.id} ({energy_source.name}) is valid.") + return True + + async def create_energy_monitor( + self, + name: str, + adapter_type: EnergyMonitorAdapter, + config: EnergyMonitorConfig, + external_service_id: Optional[EntityId] = None, + ) -> EnergyMonitor: + """Create a new energy monitor.""" + self.logger.debug(f"Creating energy monitor '{name}' with adapter {adapter_type}") + + energy_monitor = EnergyMonitor( + name=name, + adapter_type=adapter_type, + config=config, + external_service_id=external_service_id, + ) + + self.check_energy_monitor(energy_monitor) + + self.energy_monitor_repo.add(energy_monitor) + + await self._event_bus.publish( + ConfigurationUpdatedEvent( + entity_type=ConfigurationUpdatedEventType.ENERGY_MONITOR, + entity_id=energy_monitor.id, + action=ConfigurationAction.CREATED, + ) + ) + + return energy_monitor + + def get_energy_monitor(self, monitor_id: EntityId) -> Optional[EnergyMonitor]: + """Get an energy monitor by its ID.""" + energy_monitor = self.energy_monitor_repo.get_by_id(monitor_id) + + if not energy_monitor: + raise EnergyMonitorNotFoundError(f"Energy Monitor with ID {monitor_id} not found.") + + return energy_monitor + + def list_energy_monitors(self) -> List[EnergyMonitor]: + """List all energy monitors in the system.""" + return self.energy_monitor_repo.get_all() + + async def unlink_energy_monitor(self, monitor_id: EntityId) -> None: + """Unlink an energy monitor from all associated energy sources.""" + self.logger.debug(f"Unlinking energy monitor {monitor_id}") + + # Get all energy sources that use this monitor + energy_sources: List[EnergySource] = self.energy_source_repo.get_all() + + for source in energy_sources: + if source.energy_monitor_id == monitor_id: + self.logger.debug(f"Unlinking energy monitor {monitor_id} from energy source {source.id}") + source.energy_monitor_id = None + self.energy_source_repo.update(source) + + async def remove_energy_monitor(self, monitor_id: EntityId) -> EnergyMonitor: + """Remove an energy monitor from the system.""" + + energy_monitor = self.energy_monitor_repo.get_by_id(monitor_id) + + if not energy_monitor: + raise EnergyMonitorNotFoundError(f"Energy Monitor with ID {monitor_id} not found.") + + # Unlink the energy monitor from all associated energy sources before delete + await self.unlink_energy_monitor(monitor_id) + + self.energy_monitor_repo.remove(monitor_id) + + await self._event_bus.publish( + ConfigurationUpdatedEvent( + entity_type=ConfigurationUpdatedEventType.ENERGY_MONITOR, + entity_id=monitor_id, + action=ConfigurationAction.REMOVED, + ) + ) + + return energy_monitor + + async def update_energy_monitor( + self, + monitor_id: EntityId, + name: str, + config: EnergyMonitorConfig, + external_service_id: Optional[EntityId] = None, + ) -> EnergyMonitor: + """Update an energy monitor in the system.""" + self.logger.info(f"Updating energy monitor {monitor_id} ({name})") + + energy_monitor = self.energy_monitor_repo.get_by_id(monitor_id) + + if not energy_monitor: + raise EnergyMonitorNotFoundError(f"Energy Monitor with ID {monitor_id} not found.") + + # Check if the config is valid for the current adapter type + config_type = ENERGY_MONITOR_CONFIG_TYPE_MAP[energy_monitor.adapter_type] + if config_type and not isinstance(config, config_type): + raise EnergyMonitorConfigurationError( + f"Invalid configuration type for energy monitor {monitor_id}. " + f"Expected {config_type}, " + f"got {type(config).__name__}." + ) + + energy_monitor.name = name + energy_monitor.config = config + energy_monitor.external_service_id = external_service_id + + self.check_energy_monitor(energy_monitor) + + self.energy_monitor_repo.update(energy_monitor) + + await self._event_bus.publish( + ConfigurationUpdatedEvent( + entity_type=ConfigurationUpdatedEventType.ENERGY_MONITOR, + entity_id=monitor_id, + action=ConfigurationAction.UPDATED, + ) + ) + + return energy_monitor + + async def set_energy_monitor_to_energy_source( + self, energy_source_id: EntityId, energy_monitor_id: EntityId + ) -> EnergySource: + """Set an energy monitor to an energy source.""" + self.logger.debug(f"Setting energy monitor {energy_monitor_id} to energy source {energy_source_id}") + + energy_source = self.energy_source_repo.get_by_id(energy_source_id) + + if not energy_source: + raise EnergySourceNotFoundError(f"Energy Source with ID {energy_source_id} not found.") + + energy_monitor = self.energy_monitor_repo.get_by_id(energy_monitor_id) + + if not energy_monitor: + raise EnergyMonitorNotFoundError(f"Energy Monitor with ID {energy_monitor_id} not found.") + + energy_source.energy_monitor_id = energy_monitor_id + + self.energy_source_repo.update(energy_source) + + return energy_source + + async def set_forecast_provider_to_energy_source( + self, energy_source_id: EntityId, forecast_provider_id: EntityId + ) -> EnergySource: + """Set a forecast provider to an energy source.""" + self.logger.debug(f"Setting forecast provider {forecast_provider_id} to energy source {energy_source_id}") + + energy_source = self.energy_source_repo.get_by_id(energy_source_id) + + if not energy_source: + raise EnergySourceNotFoundError(f"Energy Source with ID {energy_source_id} not found.") + + forecast_provider = self.forecast_provider_repo.get_by_id(forecast_provider_id) + + if not forecast_provider: + raise ForecastProviderNotFoundError(f"Forecast Provider with ID {forecast_provider_id} not found.") + + energy_source.forecast_provider_id = forecast_provider_id + + self.energy_source_repo.update(energy_source) + + return energy_source + + def list_energy_sources_by_monitor(self, monitor_id: EntityId) -> List[EnergySource]: + """List all energy sources that use a specific energy monitor.""" + self.logger.debug(f"Listing energy sources using energy monitor {monitor_id}") + + energy_sources: List[EnergySource] = self.energy_source_repo.get_all() + + filtered_sources = [source for source in energy_sources if source.energy_monitor_id == monitor_id] + + return filtered_sources + + def list_energy_sources_by_forecast_provider(self, forecast_provider_id: EntityId) -> List[EnergySource]: + """List all energy sources that use a specific forecast provider.""" + self.logger.debug(f"Listing energy sources using forecast provider {forecast_provider_id}") + energy_sources: List[EnergySource] = self.energy_source_repo.get_all() + filtered_sources = [source for source in energy_sources if source.forecast_provider_id == forecast_provider_id] + return filtered_sources + + def check_energy_monitor(self, energy_monitor: EnergyMonitor) -> bool: + """Check if an energy monitor is valid and can be used.""" + self.logger.debug(f"Checking energy monitor {energy_monitor.id} ({energy_monitor.name})") + + if energy_monitor.external_service_id: + external_service = self.external_service_repo.get_by_id(energy_monitor.external_service_id) + if not external_service: + raise ExternalServiceNotFoundError( + f"External Service with ID {energy_monitor.external_service_id} not found." + ) + + # Checks if the external service is compatible with the adapter type + required_external_service_type = ENERGY_MONITOR_TYPE_EXTERNAL_SERVICE_MAP.get( + energy_monitor.adapter_type, None + ) + if required_external_service_type and external_service.adapter_type != required_external_service_type: + raise EnergyMonitorConfigurationError( + f"External Service {energy_monitor.external_service_id} is not compatible " + f"with Energy Monitor {energy_monitor.name} using adapter {energy_monitor.adapter_type}." + ) + + # Checks if the configuration is valid for the given adapter type + if energy_monitor.config is None or not energy_monitor.config.is_valid(energy_monitor.adapter_type): + raise EnergyMonitorConfigurationError( + f"Invalid configuration for Energy Monitor {energy_monitor.name} " + f"with adapter {energy_monitor.adapter_type}." + ) + + self.logger.debug(f"Energy monitor {energy_monitor.id} ({energy_monitor.name}) is valid.") + return True + + def get_energy_monitor_config_by_type( + self, adapter_type: EnergyMonitorAdapter + ) -> Optional[type[EnergyMonitorConfig]]: + """Get the configuration class for a specific energy monitor adapter type.""" + self.logger.debug(f"Getting configuration for energy monitor adapter {adapter_type}") + if adapter_type not in ENERGY_MONITOR_CONFIG_TYPE_MAP: + raise EnergyMonitorConfigurationError( + f"Adapter type {adapter_type} is not supported for energy monitor configuration." + ) + return ENERGY_MONITOR_CONFIG_TYPE_MAP.get(adapter_type, None) + + def get_energy_monitor_external_service_adapter( + self, adapter_type: EnergyMonitorAdapter + ) -> Optional[ExternalServiceAdapter]: + """Get the external service adapter type for a specific energy monitor adapter type.""" + self.logger.debug(f"Getting external service adapter for energy monitor adapter {adapter_type}") + if adapter_type not in ENERGY_MONITOR_TYPE_EXTERNAL_SERVICE_MAP: + raise EnergyMonitorConfigurationError( + f"Adapter type {adapter_type} is not supported for energy monitor configuration." + ) + return ENERGY_MONITOR_TYPE_EXTERNAL_SERVICE_MAP.get(adapter_type, None) + + # --- Forecast Provider Management --- + async def create_forecast_provider( + self, + name: str, + adapter_type: ForecastProviderAdapter, + config: ForecastProviderConfig, + external_service_id: Optional[EntityId] = None, + ) -> ForecastProvider: + """Create a new forecast provider.""" + self.logger.debug(f"Creating forecast provider '{name}' with adapter {adapter_type}") + + forecast_provider = ForecastProvider( + name=name, + adapter_type=adapter_type, + config=config, + external_service_id=external_service_id, + ) + + self.check_forecast_provider(forecast_provider) + + self.forecast_provider_repo.add(forecast_provider) + + await self._event_bus.publish( + ConfigurationUpdatedEvent( + entity_type=ConfigurationUpdatedEventType.FORECAST_PROVIDER, + entity_id=forecast_provider.id, + action=ConfigurationAction.CREATED, + ) + ) + + return forecast_provider + + def get_forecast_provider(self, provider_id: EntityId) -> Optional[ForecastProvider]: + """Get a forecast provider by its ID.""" + forecast_provider = self.forecast_provider_repo.get_by_id(provider_id) + + if not forecast_provider: + raise ForecastProviderNotFoundError(f"Forecast Provider with ID {provider_id} not found.") + + return forecast_provider + + def list_forecast_providers(self) -> List[ForecastProvider]: + """List all forecast providers in the system.""" + return self.forecast_provider_repo.get_all() + + async def remove_forecast_provider(self, provider_id: EntityId) -> ForecastProvider: + """Remove a forecast provider from the system.""" + self.logger.debug(f"Removing forecast provider {provider_id}") + + forecast_provider = self.forecast_provider_repo.get_by_id(provider_id) + + if not forecast_provider: + raise ForecastProviderNotFoundError(f"Forecast Provider with ID {provider_id} not found.") + + self.forecast_provider_repo.remove(provider_id) + + await self._event_bus.publish( + ConfigurationUpdatedEvent( + entity_type=ConfigurationUpdatedEventType.FORECAST_PROVIDER, + entity_id=provider_id, + action=ConfigurationAction.REMOVED, + ) + ) + + return forecast_provider + + async def update_forecast_provider( + self, + provider_id: EntityId, + name: str, + adapter_type: ForecastProviderAdapter, + config: ForecastProviderConfig, + external_service_id: Optional[EntityId] = None, + ) -> ForecastProvider: + """Update a forecast provider in the system.""" + self.logger.debug(f"Updating forecast provider {provider_id} ({name})") + + forecast_provider = self.forecast_provider_repo.get_by_id(provider_id) + + if not forecast_provider: + raise ForecastProviderNotFoundError(f"Forecast Provider with ID {provider_id} not found.") + + forecast_provider.name = name + forecast_provider.adapter_type = adapter_type + forecast_provider.config = config + forecast_provider.external_service_id = external_service_id + + self.check_forecast_provider(forecast_provider) + + self.forecast_provider_repo.update(forecast_provider) + + await self._event_bus.publish( + ConfigurationUpdatedEvent( + entity_type=ConfigurationUpdatedEventType.FORECAST_PROVIDER, + entity_id=provider_id, + action=ConfigurationAction.UPDATED, + ) + ) + + return forecast_provider + + def check_forecast_provider(self, provider: ForecastProvider) -> bool: + """Check if a forecast provider is valid and can be used.""" + self.logger.debug(f"Checking forecast provider {provider.id} ({provider.name})") + + if provider.external_service_id: + external_service = self.external_service_repo.get_by_id(provider.external_service_id) + if not external_service: + raise ExternalServiceNotFoundError( + f"External Service with ID {provider.external_service_id} not found." + ) + + # Checks if the external service is compatible with the adapter type + required_external_service_type = FORECAST_PROVIDER_TYPE_EXTERNAL_SERVICE_MAP.get( + provider.adapter_type, None + ) + if required_external_service_type and external_service.adapter_type != required_external_service_type: + raise ForecastProviderConfigurationError( + f"External Service {provider.external_service_id} is not compatible " + f"with Forecast Provider {provider.name} using adapter {provider.adapter_type}." + ) + + # Checks if the configuration is valid for the given adapter type + if provider.config is None or not provider.config.is_valid(provider.adapter_type): + raise ForecastProviderConfigurationError( + f"Invalid configuration for Forecast Provider {provider.name} with adapter {provider.adapter_type}." + ) + + self.logger.debug(f"Forecast provider {provider.id} ({provider.name}) is valid.") + return True + + def get_forecast_provider_config_by_type( + self, adapter_type: ForecastProviderAdapter + ) -> Optional[type[ForecastProviderConfig]]: + """Get the configuration class for a specific forecast provider adapter type.""" + self.logger.debug(f"Getting configuration for forecast provider adapter {adapter_type}") + if adapter_type not in FORECAST_PROVIDER_CONFIG_TYPE_MAP: + raise ForecastProviderConfigurationError( + f"Adapter type {adapter_type} is not supported for forecast provider configuration." + ) + return FORECAST_PROVIDER_CONFIG_TYPE_MAP.get(adapter_type, None) + + def get_forecast_provider_external_service_adapter( + self, adapter_type: ForecastProviderAdapter + ) -> Optional[ExternalServiceAdapter]: + """Get the external service adapter type for a specific forecast provider adapter type.""" + self.logger.debug(f"Getting external service adapter for forecast provider adapter {adapter_type}") + if adapter_type not in FORECAST_PROVIDER_TYPE_EXTERNAL_SERVICE_MAP: + raise ForecastProviderConfigurationError( + f"Adapter type {adapter_type} is not supported for forecast provider configuration." + ) + return FORECAST_PROVIDER_TYPE_EXTERNAL_SERVICE_MAP.get(adapter_type, None) + + # --- Optimization Unit Management --- + async def create_optimization_unit( + self, + name: str, + description: Optional[str] = None, + is_enabled: bool = False, + policy_id: Optional[EntityId] = None, + target_miner_ids: Optional[List[EntityId]] = None, + energy_source_id: Optional[EntityId] = None, + performance_tracker_id: Optional[EntityId] = None, + home_loads_profile_id: Optional[EntityId] = None, + notifier_ids: Optional[List[EntityId]] = None, + ) -> Optional[EnergyOptimizationUnit]: + """Create an optimization unit into the system.""" + self.logger.info(f"Adding optimization unit {name} ({description}), Active: {is_enabled}") + + optimization_unit = EnergyOptimizationUnit( + name=name, + description=description, + is_enabled=is_enabled, + policy_id=policy_id, + target_miner_ids=target_miner_ids or [], + energy_source_id=energy_source_id, + performance_tracker_id=performance_tracker_id, + home_loads_profile=home_loads_profile_id, + notifier_ids=notifier_ids or [], + ) + + self.check_optimization_unit(optimization_unit) + + self.optimization_unit_repo.add(optimization_unit) + + return optimization_unit + + def get_optimization_unit(self, unit_id: EntityId) -> Optional[EnergyOptimizationUnit]: + """Get an optimization unit by its ID.""" + optimization_unit = self.optimization_unit_repo.get_by_id(unit_id) + + if not optimization_unit: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found.") + + return optimization_unit + + def list_optimization_units(self) -> List[EnergyOptimizationUnit]: + """List all optimization units in the system.""" + return self.optimization_unit_repo.get_all() + + def filter_optimization_units( + self, + filter_by_miners: Optional[List[EntityId]] = None, + filter_by_energy_source: Optional[EntityId] = None, + filter_by_policy: Optional[EntityId] = None, + filter_by_performance_tracker: Optional[EntityId] = None, + filter_by_notifiers: Optional[List[EntityId]] = None, + ) -> List[EnergyOptimizationUnit]: + """Filter optimization units based on various criteria.""" + # eous -> Energy optimization units + eous = self.list_optimization_units() + + if filter_by_miners is not None: + eous = [eou for eou in eous if set(eou.target_miner_ids).intersection(filter_by_miners)] + if filter_by_energy_source is not None: + eous = [eou for eou in eous if eou.energy_source_id == filter_by_energy_source] + if filter_by_policy is not None: + eous = [eou for eou in eous if eou.policy_id == filter_by_policy] + if filter_by_performance_tracker is not None: + eous = [eou for eou in eous if eou.performance_tracker_id == filter_by_performance_tracker] + if filter_by_notifiers is not None: + eous = [eou for eou in eous if set(eou.notifier_ids).intersection(filter_by_notifiers)] + return eous + + async def remove_optimization_unit(self, unit_id: EntityId) -> EnergyOptimizationUnit: + """Remove an optimization unit from the system.""" + self.logger.info(f"Removing optimization unit {unit_id}") + + optimization_unit = self.optimization_unit_repo.get_by_id(unit_id) + + if not optimization_unit: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found.") + + self.optimization_unit_repo.remove(unit_id) + + return optimization_unit + + async def update_optimization_unit( + self, + unit_id: EntityId, + name: str, + description: Optional[str] = None, + is_enabled: Optional[bool] = None, + policy_id: Optional[EntityId] = None, + target_miner_ids: Optional[List[EntityId]] = None, + energy_source_id: Optional[EntityId] = None, + performance_tracker_id: Optional[EntityId] = None, + home_loads_profile_id: Optional[EntityId] = None, + notifier_ids: Optional[List[EntityId]] = None, + ) -> EnergyOptimizationUnit: + """Update an optimization unit in the system.""" + self.logger.info(f"Updating optimization unit {unit_id} ({name})") + + optimization_unit = self.optimization_unit_repo.get_by_id(unit_id) + + if not optimization_unit: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found.") + + optimization_unit.name = name + optimization_unit.description = description + + if is_enabled is not None: + optimization_unit.is_enabled = is_enabled + if policy_id is not None: + optimization_unit.policy_id = policy_id + if target_miner_ids is not None: + optimization_unit.target_miner_ids = target_miner_ids + if energy_source_id is not None: + optimization_unit.energy_source_id = energy_source_id + if performance_tracker_id is not None: + optimization_unit.performance_tracker_id = performance_tracker_id + if home_loads_profile_id is not None: + optimization_unit.assign_home_loads_profile(home_loads_profile_id) + if notifier_ids is not None: + optimization_unit.notifier_ids = notifier_ids + + # On update, perform a strict checks if the optimization unit is enabled + try: + self.check_optimization_unit(optimization_unit=optimization_unit, strict=optimization_unit.is_enabled) + except Exception as e: + self.logger.error(f"Optimization unit configuration error: {e}") + optimization_unit.disable() + + self.optimization_unit_repo.update(optimization_unit) + + return optimization_unit + + async def activate_optimization_unit(self, unit_id: EntityId) -> EnergyOptimizationUnit: + """Activate an optimization unit in the system.""" + self.logger.info(f"Activating optimization unit {unit_id}") + + optimization_unit = self.optimization_unit_repo.get_by_id(unit_id) + + if not optimization_unit: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found.") + + self.check_optimization_unit(optimization_unit=optimization_unit, strict=True) + + if optimization_unit.policy_id is None: + raise OptimizationUnitConfigurationError( + f"Optimization Unit {unit_id} must have a policy assigned before activation." + ) + self.check_policy(optimization_unit.policy_id) + + optimization_unit.enable() + + self.optimization_unit_repo.update(optimization_unit) + + return optimization_unit + + async def deactivate_optimization_unit(self, unit_id: EntityId) -> EnergyOptimizationUnit: + """Deactivate an optimization unit in the system.""" + self.logger.info(f"Deactivating optimization unit {unit_id}") + + optimization_unit = self.optimization_unit_repo.get_by_id(unit_id) + + if not optimization_unit: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found.") + + optimization_unit.disable() + + self.optimization_unit_repo.update(optimization_unit) + + return optimization_unit + + async def assign_miners_to_optimization_unit( + self, unit_id: EntityId, miner_ids: List[EntityId] + ) -> EnergyOptimizationUnit: + """Assign target miners to an optimization unit.""" + self.logger.info(f"Assigning miners {miner_ids} to optimization unit {unit_id}") + + optimization_unit = self.optimization_unit_repo.get_by_id(unit_id) + + if not optimization_unit: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found.") + + optimization_unit.target_miner_ids = miner_ids + + self.check_optimization_unit(optimization_unit) + + self.optimization_unit_repo.update(optimization_unit) + + return optimization_unit + + async def add_miner_to_optimization_unit(self, unit_id: EntityId, miner_id: EntityId) -> EnergyOptimizationUnit: + """Add a miner to an optimization unit.""" + self.logger.info(f"Adding miner {miner_id} to optimization unit {unit_id}") + + optimization_unit = self.optimization_unit_repo.get_by_id(unit_id) + + if not optimization_unit: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found.") + + if miner_id not in optimization_unit.target_miner_ids: + optimization_unit.target_miner_ids.append(miner_id) + else: + self.logger.warning(f"Miner {miner_id} is already part of the optimization unit {unit_id}.") + + self.check_optimization_unit(optimization_unit) + + self.optimization_unit_repo.update(optimization_unit) + + return optimization_unit + + async def remove_miner_from_optimization_unit( + self, unit_id: EntityId, miner_id: EntityId + ) -> EnergyOptimizationUnit: + """Remove a miner from an optimization unit.""" + self.logger.info(f"Removing miner {miner_id} from optimization unit {unit_id}") + + optimization_unit = self.optimization_unit_repo.get_by_id(unit_id) + + if not optimization_unit: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found.") + + if miner_id in optimization_unit.target_miner_ids: + optimization_unit.target_miner_ids.remove(miner_id) + else: + self.logger.warning(f"Miner {miner_id} is not part of the optimization unit {unit_id}.") + + self.check_optimization_unit(optimization_unit) + self.optimization_unit_repo.update(optimization_unit) + + return optimization_unit + + async def assign_policy_to_optimization_unit( + self, unit_id: EntityId, policy_id: EntityId + ) -> EnergyOptimizationUnit: + """Assign a policy to an optimization unit.""" + self.logger.info(f"Assigning policy {policy_id} to optimization unit {unit_id}") + + optimization_unit = self.optimization_unit_repo.get_by_id(unit_id) + + if not optimization_unit: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found.") + + optimization_unit.policy_id = policy_id + self.check_optimization_unit(optimization_unit) + self.optimization_unit_repo.update(optimization_unit) + + return optimization_unit + + async def assign_energy_source_to_optimization_unit( + self, unit_id: EntityId, energy_source_id: EntityId + ) -> EnergyOptimizationUnit: + """Assign an energy source to an optimization unit.""" + self.logger.info(f"Assigning energy source {energy_source_id} to optimization unit {unit_id}") + + optimization_unit = self.optimization_unit_repo.get_by_id(unit_id) + + if not optimization_unit: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found.") + + optimization_unit.energy_source_id = energy_source_id + self.check_optimization_unit(optimization_unit) + self.optimization_unit_repo.update(optimization_unit) + + return optimization_unit + + async def assign_performance_tracker_to_optimization_unit( + self, unit_id: EntityId, performance_tracker_id: EntityId + ) -> EnergyOptimizationUnit: + """Assign a performance tracker to an optimization unit.""" + self.logger.info(f"Assigning performance tracker {performance_tracker_id} to optimization unit {unit_id}") + + optimization_unit = self.optimization_unit_repo.get_by_id(unit_id) + + if not optimization_unit: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found.") + + optimization_unit.performance_tracker_id = performance_tracker_id + self.check_optimization_unit(optimization_unit) + self.optimization_unit_repo.update(optimization_unit) + + return optimization_unit + + async def assign_home_loads_profile_to_optimization_unit( + self, unit_id: EntityId, home_loads_profile_id: Optional[EntityId] + ) -> EnergyOptimizationUnit: + """Assign a home loads profile to an optimization unit.""" + self.logger.info(f"Assigning home loads profile {home_loads_profile_id} to optimization unit {unit_id}") + + optimization_unit = self.optimization_unit_repo.get_by_id(unit_id) + + if not optimization_unit: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found.") + + optimization_unit.assign_home_loads_profile(home_loads_profile_id) + self.optimization_unit_repo.update(optimization_unit) + + return optimization_unit + + async def assign_notifiers_to_optimization_unit( + self, unit_id: EntityId, notifier_ids: List[EntityId] + ) -> EnergyOptimizationUnit: + """Assign notifiers to an optimization unit.""" + self.logger.info(f"Assigning notifiers {notifier_ids} to optimization unit {unit_id}") + + optimization_unit = self.optimization_unit_repo.get_by_id(unit_id) + + if not optimization_unit: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found.") + + optimization_unit.notifier_ids = notifier_ids + + self.check_optimization_unit(optimization_unit) + + self.optimization_unit_repo.update(optimization_unit) + + return optimization_unit + + async def add_notifier_to_optimization_unit( + self, unit_id: EntityId, notifier_id: EntityId + ) -> EnergyOptimizationUnit: + """Add a notifier to an optimization unit.""" + self.logger.info(f"Adding notifier {notifier_id} to optimization unit {unit_id}") + + optimization_unit = self.optimization_unit_repo.get_by_id(unit_id) + + if not optimization_unit: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found.") + + if notifier_id not in optimization_unit.notifier_ids: + optimization_unit.notifier_ids.append(notifier_id) + else: + self.logger.warning(f"Notifier {notifier_id} is already part of the optimization unit {unit_id}.") + + self.check_optimization_unit(optimization_unit) + self.optimization_unit_repo.update(optimization_unit) + + return optimization_unit + + async def remove_notifier_from_optimization_unit( + self, unit_id: EntityId, notifier_id: EntityId + ) -> EnergyOptimizationUnit: + """Remove a notifier from an optimization unit.""" + self.logger.info(f"Removing notifier {notifier_id} from optimization unit {unit_id}") + + optimization_unit = self.optimization_unit_repo.get_by_id(unit_id) + + if not optimization_unit: + raise OptimizationUnitNotFoundError(f"Optimization Unit with ID {unit_id} not found.") + + if notifier_id in optimization_unit.notifier_ids: + optimization_unit.notifier_ids.remove(notifier_id) + else: + self.logger.warning(f"Notifier {notifier_id} is not part of the optimization unit {unit_id}.") + + self.check_optimization_unit(optimization_unit) + self.optimization_unit_repo.update(optimization_unit) + + return optimization_unit + + def check_optimization_unit(self, optimization_unit: EnergyOptimizationUnit, strict: bool = False) -> bool: + """Check if an optimization unit is valid and can be used.""" + self.logger.debug(f"Checking optimization unit {optimization_unit.id} ({optimization_unit.name})") + + if not optimization_unit: + raise OptimizationUnitNotFoundError("Optimization Unit not found.") + + # Check id the policy is valid + if optimization_unit.policy_id: + policy = self.policy_repo.get_by_id(optimization_unit.policy_id) + if not policy: + raise PolicyNotFoundError(f"Optimization Policy with ID {optimization_unit.policy_id} not found.") + else: + if strict: + raise OptimizationUnitConfigurationError( + f"Optimization Unit {optimization_unit.id} must have a policy assigned." + ) + + # Check if the miners are valid + if optimization_unit.target_miner_ids: + for miner_id in optimization_unit.target_miner_ids: + miner = self.miner_repo.get_by_id(miner_id) + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + else: + if strict: + raise OptimizationUnitConfigurationError( + f"Optimization Unit {optimization_unit.id} must have at least one target miner assigned." + ) + + # Check if the energy source is valid + if optimization_unit.energy_source_id: + energy_source = self.energy_source_repo.get_by_id(optimization_unit.energy_source_id) + if not energy_source: + raise EnergySourceNotFoundError( + f"Energy Source with ID {optimization_unit.energy_source_id} not found." + ) + else: + if strict: + raise OptimizationUnitConfigurationError( + f"Optimization Unit {optimization_unit.id} must have an energy source assigned." + ) + + # Check if the performance tracker is valid + if optimization_unit.performance_tracker_id: + performance_tracker = self.mining_performance_tracker_repo.get_by_id( + optimization_unit.performance_tracker_id + ) + if not performance_tracker: + raise MiningPerformanceTrackerNotFoundError( + f"Mining Performance Tracker with ID {optimization_unit.performance_tracker_id} not found." + ) + + # Check if notifiers are valid + if optimization_unit.notifier_ids: + for notifier_id in optimization_unit.notifier_ids: + notifier = self.notifier_repo.get_by_id(notifier_id) + if not notifier: + raise NotifierNotFoundError(f"Notifier with ID {notifier_id} not found.") + + self.logger.debug(f"Optimization unit {optimization_unit.id} ({optimization_unit.name}) is valid.") + return True + + # --- Miner Management --- + async def add_miner( + self, + name: str, + model: Optional[str] = None, + hash_rate_max: Optional[HashRate] = None, + power_consumption_max: Optional[Watts] = None, + active: bool = True, + ) -> Miner: + """Add a miner to the system.""" + + hash_rate_str = f"{hash_rate_max.value}{hash_rate_max.unit}" if hash_rate_max else "Unknown" + + self.logger.info( + f"Adding miner '{name}' (Model: {model or 'N/A'}), " + f"Max Hashrate: {hash_rate_str}, " + f"Max Power: {power_consumption_max}W, Active: {active}" + ) + + miner = Miner( + name=name, + model=model, + hash_rate_max=hash_rate_max, + power_consumption_max=power_consumption_max, + active=active, + ) + + self.check_miner(miner) + self.miner_repo.add(miner) + + return miner + + def get_miner(self, miner_id: EntityId) -> Optional[Miner]: + """Get a miner by its ID.""" + miner = self.miner_repo.get_by_id(miner_id) + + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + + return miner + + def list_miners(self) -> List[Miner]: + """List all miners in the system.""" + return self.miner_repo.get_all() + + async def remove_miner(self, miner_id: EntityId) -> Miner: + """Remove a miner from the system.""" + self.logger.info(f"Removing miner {miner_id}") + + miner = self.miner_repo.get_by_id(miner_id) + + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + + self.miner_repo.remove(miner_id) + + return miner + + async def update_miner( + self, + miner_id: EntityId, + name: str, + model: Optional[str] = None, + hash_rate_max: Optional[HashRate] = None, + power_consumption_max: Optional[Watts] = None, + active: bool = True, + ) -> Miner: + """Update a miner in the system.""" + self.logger.info(f"Updating miner {miner_id} ({name})") + + miner = self.miner_repo.get_by_id(miner_id) + + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + + miner.name = name + miner.model = model + miner.hash_rate_max = hash_rate_max + miner.power_consumption_max = power_consumption_max + miner.active = active + + self.check_miner(miner) + self.miner_repo.update(miner) + + return miner + + async def activate_miner(self, miner_id: EntityId) -> Miner: + """Activate a miner in the system.""" + self.logger.info(f"Activating miner {miner_id}") + + miner = self.miner_repo.get_by_id(miner_id) + + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + + miner.activate() + + self.miner_repo.update(miner) + + return miner + + async def deactivate_miner(self, miner_id: EntityId) -> Miner: + """Deactivate a miner in the system.""" + self.logger.info(f"Deactivating miner {miner_id}") + + miner = self.miner_repo.get_by_id(miner_id) + + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + + miner.deactivate() + + self.miner_repo.update(miner) + + return miner + + def list_miners_by_controller(self, controller_id: EntityId) -> List[Miner]: + """List all miners associated with a specific controller.""" + miners: List[Miner] = self.miner_repo.get_by_controller_id(controller_id) + + if not miners: + self.logger.warning(f"No miners found for controller {controller_id}") + + return miners + + def check_miner(self, miner: Miner) -> bool: + """Check if a miner is valid and can be used.""" + self.logger.debug(f"Checking miner {miner.id} ({miner.name})") + + if not miner: + raise MinerNotFoundError("Miner not found.") + + # Verify all referenced controllers exist + for controller_id in miner.get_controller_ids(): + controller = self.miner_controller_repo.get_by_id(controller_id) + if not controller: + raise MinerControllerNotFoundError(f"Miner Controller with ID {controller_id} not found.") + + self.logger.debug(f"Miner {miner.id} ({miner.name}) is valid.") + return True + + async def add_miner_controller( + self, + name: str, + adapter: MinerControllerAdapter, + config: Optional[MinerControllerConfig], + external_service_id: Optional[EntityId] = None, + ) -> MinerController: + """Add a miner controller to the system.""" + self.logger.info(f"Adding miner controller '{name}' with adapter {adapter}") + + controller = MinerController( + name=name, + adapter_type=adapter, + config=config, + external_service_id=external_service_id, + ) + + self.check_miner_controller(controller) + self.miner_controller_repo.add(controller) + + await self._event_bus.publish( + ConfigurationUpdatedEvent( + entity_type=ConfigurationUpdatedEventType.MINER_CONTROLLER, + entity_id=controller.id, + action=ConfigurationAction.CREATED, + ) + ) + + return controller + + def get_miner_controller(self, controller_id: EntityId) -> Optional[MinerController]: + """Get a miner controller by its ID.""" + controller = self.miner_controller_repo.get_by_id(controller_id) + + if not controller: + raise MinerControllerNotFoundError(f"Controller with ID {controller_id} not found.") + + return controller + + def list_miner_controllers(self) -> List[MinerController]: + """List all miner controllers in the system.""" + return self.miner_controller_repo.get_all() + + async def unlink_miner_controller(self, miner_controller_id: EntityId) -> None: + """Unlink a miner controller from all miners (remove all features from that controller).""" + self.logger.info(f"Unlinking controller {miner_controller_id} from all miners") + + miners: List[Miner] = self.miner_repo.get_by_controller_id(miner_controller_id) + + for miner in miners: + self.logger.info( + f"Removing features from miner {miner.name} ({miner.id}) for controller {miner_controller_id}" + ) + miner.remove_features_by_controller(miner_controller_id) + self.miner_repo.update(miner) + + async def remove_miner_controller(self, controller_id: EntityId) -> MinerController: + """Remove a miner controller from the system.""" + self.logger.info(f"Removing miner controller {controller_id}") + + controller = self.miner_controller_repo.get_by_id(controller_id) + + if not controller: + raise MinerControllerNotFoundError(f"Controller with ID {controller_id} not found.") + + # Unlink the controller from all miners before removal + await self.unlink_miner_controller(controller_id) + + self.miner_controller_repo.remove(controller_id) + + await self._event_bus.publish( + ConfigurationUpdatedEvent( + entity_type=ConfigurationUpdatedEventType.MINER_CONTROLLER, + entity_id=controller_id, + action=ConfigurationAction.REMOVED, + ) + ) + + return controller + + async def update_miner_controller( + self, + controller_id: EntityId, + name: str, + config: MinerControllerConfig, + external_service_id: Optional[EntityId] = None, + ) -> MinerController: + """ + Update a miner controller in the system. + This method updates the name and configuration only of an existing miner controller + and avoid to change the adapter type. + """ + self.logger.info(f"Updating miner controller {controller_id} ({name})") + + controller = self.miner_controller_repo.get_by_id(controller_id) + + if not controller: + raise MinerControllerNotFoundError(f"Controller with ID {controller_id} not found.") + + # Check if the config is valid for the current adapter type + config_type = MINER_CONTROLLER_CONFIG_TYPE_MAP[controller.adapter_type] + if config_type and not isinstance(config, config_type): + raise MinerControllerConfigurationError( + f"Invalid configuration type for controller {controller_id}. " + f"Expected {config_type}, " + f"got {type(config).__name__}." + ) + + controller.name = name + controller.config = config + controller.external_service_id = external_service_id + + self.check_miner_controller(controller) + + self.miner_controller_repo.update(controller) + + await self._event_bus.publish( + ConfigurationUpdatedEvent( + entity_type=ConfigurationUpdatedEventType.MINER_CONTROLLER, + entity_id=controller_id, + action=ConfigurationAction.UPDATED, + ) + ) + + return controller + + async def set_miner_controller(self, controller_id: EntityId, miner_id: EntityId) -> None: + """Associate a controller to a miner, auto-creating features for all supported feature types.""" + self.logger.info(f"Adding controller {controller_id} to miner {miner_id}") + + miner = self.miner_repo.get_by_id(miner_id) + + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + + controller = self.miner_controller_repo.get_by_id(controller_id) + if not controller: + raise MinerControllerNotFoundError(f"Controller with ID {controller_id} does not exist.") + + # Discover supported features via adapter's MRO + if not self.adapter_service: + raise MinerControllerConfigurationError("Adapter service is required to discover supported features.") + + adapter = await self.adapter_service.get_miner_controller_adapter(miner, controller_id) + if not adapter: + raise MinerControllerConfigurationError(f"Could not initialize adapter for controller {controller_id}.") + + supported_features = adapter.__class__.get_supported_features() + + # Auto-create features (enabled=True, priority=50) + for feature_type in supported_features: + feature = MinerFeature( + feature_type=feature_type, + controller_id=controller_id, + priority=50, + enabled=True, + ) + try: + miner.add_feature(feature) + except ValueError: + # Feature already exists for this (type, controller) pair — skip + pass + + self.miner_repo.update(miner) + + async def unlink_controller_from_miner(self, controller_id: EntityId, miner_id: EntityId) -> None: + """Remove all features provided by a controller from a specific miner.""" + self.logger.info(f"Unlinking controller {controller_id} from miner {miner_id}") + + miner = self.miner_repo.get_by_id(miner_id) + + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + + miner.remove_features_by_controller(controller_id) + self.miner_repo.update(miner) + + async def enable_miner_feature( + self, miner_id: EntityId, controller_id: EntityId, feature_type: MinerFeatureType + ) -> Miner: + """Enable a specific feature on a miner.""" + self.logger.info(f"Enabling feature {feature_type} from controller {controller_id} on miner {miner_id}") + miner = self.miner_repo.get_by_id(miner_id) + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + miner.enable_feature(feature_type, controller_id) + self.miner_repo.update(miner) + return miner + + async def disable_miner_feature( + self, miner_id: EntityId, controller_id: EntityId, feature_type: MinerFeatureType + ) -> Miner: + """Disable a specific feature on a miner.""" + self.logger.info(f"Disabling feature {feature_type} from controller {controller_id} on miner {miner_id}") + miner = self.miner_repo.get_by_id(miner_id) + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + miner.disable_feature(feature_type, controller_id) + self.miner_repo.update(miner) + return miner + + async def set_miner_feature_priority( + self, miner_id: EntityId, controller_id: EntityId, feature_type: MinerFeatureType, priority: int + ) -> Miner: + """Set the priority of a specific feature on a miner.""" + self.logger.info( + f"Setting priority {priority} for feature {feature_type} from controller {controller_id} on miner {miner_id}" + ) + miner = self.miner_repo.get_by_id(miner_id) + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + miner.set_priority(feature_type, controller_id, priority) + self.miner_repo.update(miner) + return miner + + def check_miner_controller(self, controller: MinerController) -> bool: + """Check if a miner controller is valid and can be used.""" + self.logger.debug(f"Checking miner controller {controller.id} ({controller.name})") + + # Checks if the configuration is valid for the given adapter type + if controller.config is None or not controller.config.is_valid(controller.adapter_type): + raise MinerControllerConfigurationError( + f"Invalid configuration for Miner Controller {controller.name} with adapter {controller.adapter_type}." + ) + + self.logger.debug(f"Miner controller {controller.id} ({controller.name}) is valid.") + return True + + def get_miner_controller_config_by_type( + self, adapter_type: MinerControllerAdapter + ) -> Optional[type[MinerControllerConfig]]: + """Get the configuration class for a specific miner controller adapter type.""" + self.logger.debug(f"Getting configuration for miner controller adapter {adapter_type}") + if adapter_type not in MINER_CONTROLLER_CONFIG_TYPE_MAP: + raise MinerControllerConfigurationError( + f"Adapter type {adapter_type} is not supported for miner controller configuration." + ) + + return MINER_CONTROLLER_CONFIG_TYPE_MAP.get(adapter_type, None) + + def get_miner_controller_external_service_adapter( + self, adapter_type: MinerControllerAdapter + ) -> Optional[ExternalServiceAdapter]: + """Get the external service adapter type for a specific miner controller adapter type.""" + self.logger.debug(f"Getting external service adapter for miner controller adapter {adapter_type}") + if adapter_type not in MINER_CONTROLLER_TYPE_EXTERNAL_SERVICE_MAP: + raise MinerControllerConfigurationError( + f"Adapter type {adapter_type} is not supported for miner controller external service mapping." + ) + return MINER_CONTROLLER_TYPE_EXTERNAL_SERVICE_MAP.get(adapter_type, None) + + # --- Notifier Management --- + async def add_notifier( + self, + name: str, + adapter_type: NotificationAdapter, + config: Optional[NotificationConfig], + external_service_id: Optional[EntityId] = None, + ) -> Notifier: + """Add a new notifier.""" + self.logger.debug(f"Adding notifier '{name}' with adapter {adapter_type}") + + notifier = Notifier( + name=name, + adapter_type=adapter_type, + config=config, + external_service_id=external_service_id, + ) + + self.check_notifier(notifier) + + self.notifier_repo.add(notifier) + + await self._event_bus.publish( + ConfigurationUpdatedEvent( + entity_type=ConfigurationUpdatedEventType.NOTIFIER, + entity_id=notifier.id, + action=ConfigurationAction.CREATED, + ) + ) + + return notifier + + def get_notifier(self, notifier_id: EntityId) -> Optional[Notifier]: + """Get a notifier by its ID.""" + notifier = self.notifier_repo.get_by_id(notifier_id) + if not notifier: + raise NotifierNotFoundError(f"Notifier with ID {notifier_id} not found.") + return notifier + + def list_notifiers(self) -> List[Notifier]: + """List all notifiers in the system.""" + return self.notifier_repo.get_all() + + async def remove_notifier(self, notifier_id: EntityId) -> Notifier: + """Remove a notifier from the system.""" + self.logger.debug(f"Removing notifier {notifier_id}") + + notifier = self.notifier_repo.get_by_id(notifier_id) + if not notifier: + raise NotifierNotFoundError(f"Notifier with ID {notifier_id} not found.") + + self.notifier_repo.remove(notifier_id) + + await self._event_bus.publish( + ConfigurationUpdatedEvent( + entity_type=ConfigurationUpdatedEventType.NOTIFIER, + entity_id=notifier_id, + action=ConfigurationAction.REMOVED, + ) + ) + + return notifier + + async def update_notifier( + self, + notifier_id: EntityId, + name: str, + config: NotificationConfig, + external_service_id: Optional[EntityId] = None, + ) -> Notifier: + """Update a notifier in the system.""" + self.logger.debug(f"Updating notifier {notifier_id} ({name})") + + notifier = self.notifier_repo.get_by_id(notifier_id) + if not notifier: + raise NotifierNotFoundError(f"Notifier with ID {notifier_id} not found.") + + notifier.name = name + notifier.config = config + notifier.external_service_id = external_service_id + + self.check_notifier(notifier) + self.notifier_repo.update(notifier) + + await self._event_bus.publish( + ConfigurationUpdatedEvent( + entity_type=ConfigurationUpdatedEventType.NOTIFIER, + entity_id=notifier_id, + action=ConfigurationAction.UPDATED, + ) + ) + + return notifier + + def check_notifier(self, notifier: Notifier) -> bool: + """Check if a notifier is valid and can be used.""" + self.logger.debug(f"Checking notifier {notifier.id} ({notifier.name})") + + if notifier.external_service_id: + external_service = self.external_service_repo.get_by_id(notifier.external_service_id) + if not external_service: + raise ExternalServiceNotFoundError( + f"External Service with ID {notifier.external_service_id} not found." + ) + + # Checks if the external service is compatible with the notifier's adapter + # type + required_external_service_type = NOTIFIER_TYPE_EXTERNAL_SERVICE_MAP.get(notifier.adapter_type, None) + if required_external_service_type and external_service.adapter_type != required_external_service_type: + raise NotifierConfigurationError( + f"External Service {external_service.id} is not compatible " + f"with Notifier {notifier.name} using adapter " + f"{notifier.adapter_type}. " + f"Expected type {required_external_service_type}." + ) + + # Checks if the configuration is valid for the given adapter type + if notifier.config is None or not notifier.config.is_valid(notifier.adapter_type): + raise NotifierNotFoundError( + f"Invalid configuration for Notifier {notifier.name} with adapter {notifier.adapter_type}." + ) + + self.logger.debug(f"Notifier {notifier.id} ({notifier.name}) is valid.") + return True + + def get_notifier_config_by_type(self, adapter_type: NotificationAdapter) -> Optional[type[NotificationConfig]]: + """Get the configuration class for a specific notifier adapter type.""" + self.logger.debug(f"Getting configuration for notifier adapter {adapter_type}") + if adapter_type not in NOTIFIER_CONFIG_TYPE_MAP: + raise NotifierConfigurationError( + f"Adapter type {adapter_type} is not supported for notifier configuration." + ) + + return NOTIFIER_CONFIG_TYPE_MAP.get(adapter_type, None) + + def get_notifier_external_service_adapter( + self, adapter_type: NotificationAdapter + ) -> Optional[ExternalServiceAdapter]: + """Get the external service adapter type for a specific notification adapter type.""" + self.logger.debug(f"Getting external service adapter for notifier adapter {adapter_type}") + if adapter_type not in NOTIFIER_TYPE_EXTERNAL_SERVICE_MAP: + raise NotifierConfigurationError( + f"Adapter type {adapter_type} is not supported for notifier external service mapping." + ) + return NOTIFIER_TYPE_EXTERNAL_SERVICE_MAP.get(adapter_type, None) + + # --- Home Loads Profile Management --- + def add_home_loads_profile(self, name: str) -> HomeLoadsProfile: + """Create and persist a new home loads profile.""" + profile = HomeLoadsProfile(name=name) + self.home_profile_repo.add(profile) + self.logger.info(f"Added home loads profile '{profile.name}' ({profile.id}).") + return profile + + def get_home_loads_profile(self, profile_id: EntityId) -> Optional[HomeLoadsProfile]: + """Get a home loads profile by ID.""" + return self.home_profile_repo.get_by_id(profile_id) + + def list_home_loads_profiles(self) -> List[HomeLoadsProfile]: + """List all home loads profiles.""" + return self.home_profile_repo.get_all() + + def update_home_loads_profile(self, profile_id: EntityId, name: str) -> HomeLoadsProfile: + """Rename an existing home loads profile.""" + profile = self.home_profile_repo.get_by_id(profile_id) + if not profile: + raise HomeLoadsProfileNotFoundError(f"Home Loads Profile with ID {profile_id} not found.") + profile.name = name + self.home_profile_repo.update(profile) + return profile + + def remove_home_loads_profile(self, profile_id: EntityId) -> HomeLoadsProfile: + """Remove a home loads profile by ID.""" + profile = self.home_profile_repo.get_by_id(profile_id) + if not profile: + raise HomeLoadsProfileNotFoundError(f"Home Loads Profile with ID {profile_id} not found.") + self.home_profile_repo.remove(profile_id) + return profile + + def add_load_device_to_profile(self, profile_id: EntityId, load_device: LoadDevice) -> LoadDevice: + """Append a load device to a profile (raises on duplicate device name).""" + profile = self.home_profile_repo.get_by_id(profile_id) + if not profile: + raise HomeLoadsProfileNotFoundError(f"Home Loads Profile with ID {profile_id} not found.") + profile.add_device(load_device) + self.home_profile_repo.update(profile) + return load_device + + def remove_load_device_from_profile(self, profile_id: EntityId, device_id: EntityId) -> LoadDevice: + """Remove a load device from a profile.""" + profile = self.home_profile_repo.get_by_id(profile_id) + if not profile: + raise HomeLoadsProfileNotFoundError(f"Home Loads Profile with ID {profile_id} not found.") + removed = profile.remove_device(device_id) + self.home_profile_repo.update(profile) + return removed + + # --- Energy Load Forecast Provider Management --- + def add_energy_load_forecast_provider(self, provider: EnergyLoadForecastProvider) -> EnergyLoadForecastProvider: + """Add a new energy load forecast provider.""" + self.energy_load_forecast_provider_repo.add(provider) + self.logger.info(f"Added energy load forecast provider '{provider.name}' ({provider.id}).") + return provider + + def get_energy_load_forecast_provider(self, provider_id: EntityId) -> Optional[EnergyLoadForecastProvider]: + """Get an energy load forecast provider by ID.""" + return self.energy_load_forecast_provider_repo.get_by_id(provider_id) + + def list_energy_load_forecast_providers(self) -> List[EnergyLoadForecastProvider]: + """List all energy load forecast providers.""" + return self.energy_load_forecast_provider_repo.get_all() + + def update_energy_load_forecast_provider(self, provider: EnergyLoadForecastProvider) -> EnergyLoadForecastProvider: + """Update an existing energy load forecast provider.""" + existing = self.energy_load_forecast_provider_repo.get_by_id(provider.id) + if not existing: + raise EnergyLoadForecastProviderNotFoundError( + f"Energy Load Forecast Provider with ID {provider.id} not found." + ) + self.energy_load_forecast_provider_repo.update(provider) + self.logger.info(f"Updated energy load forecast provider '{provider.name}' ({provider.id}).") + return provider + + def remove_energy_load_forecast_provider(self, provider_id: EntityId) -> EnergyLoadForecastProvider: + """Remove an energy load forecast provider.""" + provider = self.energy_load_forecast_provider_repo.get_by_id(provider_id) + if not provider: + raise EnergyLoadForecastProviderNotFoundError( + f"Energy Load Forecast Provider with ID {provider_id} not found." + ) + self.energy_load_forecast_provider_repo.remove(provider_id) + self.logger.info(f"Removed energy load forecast provider '{provider.name}' ({provider.id}).") + return provider + + # --- Energy Load History Provider Management --- + def add_energy_load_history_provider(self, provider: EnergyLoadHistoryProvider) -> EnergyLoadHistoryProvider: + """Add a new energy load history provider.""" + self.energy_load_history_provider_repo.add(provider) + self.logger.info(f"Added energy load history provider '{provider.name}' ({provider.id}).") + return provider + + def get_energy_load_history_provider(self, provider_id: EntityId) -> Optional[EnergyLoadHistoryProvider]: + """Get an energy load history provider by ID.""" + return self.energy_load_history_provider_repo.get_by_id(provider_id) + + def list_energy_load_history_providers(self) -> List[EnergyLoadHistoryProvider]: + """List all energy load history providers.""" + return self.energy_load_history_provider_repo.get_all() + + def update_energy_load_history_provider(self, provider: EnergyLoadHistoryProvider) -> EnergyLoadHistoryProvider: + """Update an existing energy load history provider.""" + existing = self.energy_load_history_provider_repo.get_by_id(provider.id) + if not existing: + raise EnergyLoadHistoryProviderNotFoundError( + f"Energy Load History Provider with ID {provider.id} not found." + ) + self.energy_load_history_provider_repo.update(provider) + self.logger.info(f"Updated energy load history provider '{provider.name}' ({provider.id}).") + return provider + + def remove_energy_load_history_provider(self, provider_id: EntityId) -> EnergyLoadHistoryProvider: + """Remove an energy load history provider.""" + provider = self.energy_load_history_provider_repo.get_by_id(provider_id) + if not provider: + raise EnergyLoadHistoryProviderNotFoundError( + f"Energy Load History Provider with ID {provider_id} not found." + ) + self.energy_load_history_provider_repo.remove(provider_id) + self.logger.info(f"Removed energy load history provider '{provider.name}' ({provider.id}).") + return provider + + def get_energy_load_forecast_provider_external_service_adapter( + self, adapter_type: EnergyLoadForecastProviderAdapter + ) -> Optional[ExternalServiceAdapter]: + """Get the external service adapter type for a specific energy load forecast provider adapter type.""" + self.logger.debug(f"Getting external service adapter for energy load forecast provider adapter {adapter_type}") + if adapter_type not in ENERGY_LOAD_FORECAST_PROVIDER_EXTERNAL_SERVICE_MAP: + raise EnergyLoadForecastProviderConfigurationError( + f"Adapter type {adapter_type} is not supported for energy load forecast provider configuration." + ) + return ENERGY_LOAD_FORECAST_PROVIDER_EXTERNAL_SERVICE_MAP.get(adapter_type, None) + + def get_energy_load_history_provider_external_service_adapter( + self, adapter_type: EnergyLoadHistoryProviderAdapter + ) -> Optional[ExternalServiceAdapter]: + """Get the external service adapter type for a specific energy load history provider adapter type.""" + self.logger.debug(f"Getting external service adapter for energy load history provider adapter {adapter_type}") + if adapter_type not in ENERGY_LOAD_HISTORY_PROVIDER_EXTERNAL_SERVICE_MAP: + raise EnergyLoadHistoryProviderConfigurationError( + f"Adapter type {adapter_type} is not supported for energy load history provider configuration." + ) + return ENERGY_LOAD_HISTORY_PROVIDER_EXTERNAL_SERVICE_MAP.get(adapter_type, None) + + # --- Mining Performance Tracker Management --- + async def add_mining_performance_tracker( + self, + name: str, + adapter_type: MiningPerformanceTrackerAdapter, + config: Optional[MiningPerformanceTrackerConfig], + external_service_id: Optional[EntityId] = None, + ) -> MiningPerformanceTracker: + """Add a new mining performance tracker.""" + self.logger.debug(f"Adding mining performance tracker '{name}' with adapter {adapter_type}") + + tracker = MiningPerformanceTracker( + name=name, + adapter_type=adapter_type, + config=config, + external_service_id=external_service_id, + ) + + self.check_mining_performance_tracker(tracker) + + self.mining_performance_tracker_repo.add(tracker) + + await self._event_bus.publish( + ConfigurationUpdatedEvent( + entity_type=ConfigurationUpdatedEventType.MINING_PERFORMANCE_TRACKER, + entity_id=tracker.id, + action=ConfigurationAction.CREATED, + ) + ) + + return tracker + + def get_mining_performance_tracker(self, tracker_id: EntityId) -> Optional[MiningPerformanceTracker]: + """Get a mining performance tracker by its ID.""" + tracker = self.mining_performance_tracker_repo.get_by_id(tracker_id) + if not tracker: + raise MiningPerformanceTrackerNotFoundError(f"Mining Performance Tracker with ID {tracker_id} not found.") + return tracker + + def list_mining_performance_trackers(self) -> List[MiningPerformanceTracker]: + """List all mining performance trackers in the system.""" + return self.mining_performance_tracker_repo.get_all() + + async def update_mining_performance_tracker( + self, + tracker_id: EntityId, + name: str, + config: MiningPerformanceTrackerConfig, + external_service_id: Optional[EntityId] = None, + ) -> MiningPerformanceTracker: + """Update a mining performance tracker in the system.""" + self.logger.debug(f"Updating mining performance tracker {tracker_id} ({name})") + + tracker = self.mining_performance_tracker_repo.get_by_id(tracker_id) + if not tracker: + raise MiningPerformanceTrackerNotFoundError(f"Mining Performance Tracker with ID {tracker_id} not found.") + + tracker.name = name + tracker.config = config + tracker.external_service_id = external_service_id + + self.check_mining_performance_tracker(tracker) + self.mining_performance_tracker_repo.update(tracker) + + await self._event_bus.publish( + ConfigurationUpdatedEvent( + entity_type=ConfigurationUpdatedEventType.MINING_PERFORMANCE_TRACKER, + entity_id=tracker_id, + action=ConfigurationAction.UPDATED, + ) + ) + + return tracker + + async def unlink_mining_performance_tracker(self, tracker_id: EntityId) -> None: + """Detach a mining performance tracker from any optimization unit that references it.""" + self.logger.debug(f"Unlinking mining performance tracker {tracker_id}") + + optimization_units: List[EnergyOptimizationUnit] = self.optimization_unit_repo.get_all() + for unit in optimization_units: + if unit.performance_tracker_id == tracker_id: + self.logger.debug(f"Unlinking mining performance tracker {tracker_id} from optimization unit {unit.id}") + unit.performance_tracker_id = None + self.optimization_unit_repo.update(unit) + + async def remove_mining_performance_tracker(self, tracker_id: EntityId) -> MiningPerformanceTracker: + """Remove a mining performance tracker from the system.""" + self.logger.debug(f"Removing mining performance tracker {tracker_id}") + + tracker = self.mining_performance_tracker_repo.get_by_id(tracker_id) + if not tracker: + raise MiningPerformanceTrackerNotFoundError(f"Mining Performance Tracker with ID {tracker_id} not found.") + + await self.unlink_mining_performance_tracker(tracker_id) + + self.mining_performance_tracker_repo.remove(tracker_id) + + await self._event_bus.publish( + ConfigurationUpdatedEvent( + entity_type=ConfigurationUpdatedEventType.MINING_PERFORMANCE_TRACKER, + entity_id=tracker_id, + action=ConfigurationAction.REMOVED, + ) + ) + + return tracker + + def check_mining_performance_tracker(self, tracker: MiningPerformanceTracker) -> bool: + """Check if a mining performance tracker is valid and can be used.""" + self.logger.debug(f"Checking mining performance tracker {tracker.id} ({tracker.name})") + + if tracker.external_service_id: + external_service = self.external_service_repo.get_by_id(tracker.external_service_id) + if not external_service: + raise ExternalServiceNotFoundError(f"External Service with ID {tracker.external_service_id} not found.") + + required_external_service_type = MINING_PERFORMANCE_TRACKER_TYPE_EXTERNAL_SERVICE_MAP.get( + tracker.adapter_type, None + ) + if required_external_service_type and external_service.adapter_type != required_external_service_type: + raise MiningPerformanceTrackerConfigurationError( + f"External Service {external_service.id} is not compatible " + f"with Mining Performance Tracker {tracker.name} using adapter " + f"{tracker.adapter_type}. " + f"Expected type {required_external_service_type}." + ) + + if tracker.config is None or not tracker.config.is_valid(tracker.adapter_type): + raise MiningPerformanceTrackerConfigurationError( + f"Invalid configuration for Mining Performance Tracker " + f"{tracker.name} with adapter {tracker.adapter_type}." + ) + + self.logger.debug(f"Mining Performance Tracker {tracker.id} ({tracker.name}) is valid.") + return True + + def get_mining_performance_tracker_config_by_type( + self, adapter_type: MiningPerformanceTrackerAdapter + ) -> Optional[type[MiningPerformanceTrackerConfig]]: + """Get the configuration class for a specific tracker adapter type.""" + self.logger.debug(f"Getting configuration for mining performance tracker adapter {adapter_type}") + if adapter_type not in MINING_PERFORMANCE_TRACKER_CONFIG_TYPE_MAP: + raise MiningPerformanceTrackerConfigurationError( + f"Adapter type {adapter_type} is not supported for mining performance tracker configuration." + ) + return MINING_PERFORMANCE_TRACKER_CONFIG_TYPE_MAP.get(adapter_type, None) + + def get_mining_performance_tracker_external_service_adapter( + self, adapter_type: MiningPerformanceTrackerAdapter + ) -> Optional[ExternalServiceAdapter]: + """Get the external service adapter type for a specific tracker adapter type.""" + self.logger.debug(f"Getting external service adapter for mining performance tracker adapter {adapter_type}") + if adapter_type not in MINING_PERFORMANCE_TRACKER_TYPE_EXTERNAL_SERVICE_MAP: + raise MiningPerformanceTrackerConfigurationError( + f"Adapter type {adapter_type} is not supported for mining performance tracker external service mapping." + ) + return MINING_PERFORMANCE_TRACKER_TYPE_EXTERNAL_SERVICE_MAP.get(adapter_type, None) + + # --- Policy Management --- + async def create_policy(self, name: str, description: str = "") -> OptimizationPolicy: + """Create a new policy.""" + self.logger.info(f"Creating policy '{name}'") + + policy = OptimizationPolicy(name=name, description=description) + + # Check if policy with the same id already exists + existing_policy = self.policy_repo.get_by_id(policy.id) + if existing_policy: + raise PolicyAlreadyExistsError(f"Policy with id '{policy.id}' already exists.") + + self.policy_repo.add(policy) + + return policy + + def get_policy(self, policy_id: EntityId) -> Optional[OptimizationPolicy]: + """Get a policy by its ID.""" + return self.policy_repo.get_by_id(policy_id) + + def list_policies(self) -> List[OptimizationPolicy]: + """List all policies in the system.""" + return self.policy_repo.get_all() + + async def add_rule_to_policy( + self, + policy_id: EntityId, + rule_type: RuleType, + name: str, + priority: int, + conditions: Dict, + description: str = "", + ) -> AutomationRule: + """Add a rule to a policy.""" + policy = self.policy_repo.get_by_id(policy_id) + + if not policy: + raise PolicyNotFoundError(f"Policy with ID {policy_id} not found.") + + rule = AutomationRule( + name=name, + description=description, + priority=priority, + conditions=conditions, + ) + if rule_type == RuleType.START: + policy.start_rules.append(rule) + elif rule_type == RuleType.STOP: + policy.stop_rules.append(rule) + else: + raise PolicyConfigurationError(f"Invalid Rule Type. Must be {RuleType.START} or {RuleType.STOP}.") + + self.policy_repo.update(policy) + self.logger.debug(f"Added {rule_type.value} rule '{name}' to policy '{policy.name}'") + + return rule + + def get_policy_rules(self, policy_id: EntityId, rule_type: RuleType) -> List[AutomationRule]: + """Get all rules of a policy.""" + policy = self.policy_repo.get_by_id(policy_id) + + if not policy: + raise PolicyError(f"Policy with ID {policy_id} not found.") + + if rule_type == RuleType.START: + return policy.start_rules + elif rule_type == RuleType.STOP: + return policy.stop_rules + else: + raise ValueError(f"Invalid rule_type. Must be {RuleType.START} or {RuleType.STOP}.") + + def get_policy_rule(self, policy_id: EntityId, rule_id: EntityId) -> Optional[AutomationRule]: + """Get a rule by its ID.""" + policy = self.policy_repo.get_by_id(policy_id) + + if not policy: + raise PolicyError(f"Policy with ID {policy_id} not found.") + + for rule in policy.start_rules + policy.stop_rules: + if str(rule.id) == str(rule_id): + return rule + + raise RuleNotFoundError(f"Rule with ID {rule_id} not found in policy {policy_id}.") + + async def update_policy_rule( + self, + policy_id: EntityId, + rule_id: EntityId, + name: str, + priority: int, + enabled: bool, + conditions: Dict, + description: str = "", + ) -> AutomationRule: + """Update a rule in a policy.""" + policy = self.policy_repo.get_by_id(policy_id) + + if not policy: + raise PolicyNotFoundError(f"Policy with ID {policy_id} not found.") + + for rule in policy.start_rules + policy.stop_rules: + if rule.id == rule_id: + rule.name = name + rule.conditions = conditions + rule.priority = priority + rule.enabled = enabled + + if description: + rule.description = description + + self.policy_repo.update(policy) + + self.logger.info(f"Updated rule '{name}' in policy '{policy.name}'") + + return rule + + raise PolicyError(f"Rule with ID {rule_id} not found in policy {policy_id}.") + + async def delete_policy_rule(self, policy_id: EntityId, rule_id: EntityId) -> AutomationRule: + """Delete a rule from a policy.""" + policy = self.policy_repo.get_by_id(policy_id) + + if not policy: + raise PolicyError(f"Policy with ID {policy_id} not found.") + + for rule in policy.start_rules + policy.stop_rules: + if rule.id == rule_id: + if rule in policy.start_rules: + policy.start_rules.remove(rule) + else: + policy.stop_rules.remove(rule) + + self.policy_repo.update(policy) + + self.logger.info(f"Deleted rule '{rule.name}' from policy '{policy.name}'") + + return rule + raise PolicyError(f"Rule with ID {rule_id} not found in policy {policy_id}.") + + async def enable_policy_rule(self, policy_id: EntityId, rule_id: EntityId) -> AutomationRule: + """Set a rule as enabled.""" + self.logger.info(f"Setting rule {rule_id} of policy {policy_id} as active.") + + policy = self.policy_repo.get_by_id(policy_id) + + if not policy: + raise PolicyNotFoundError(f"Policy with ID {policy_id} not found.") + + # Find the rule in the policy's start or stop rules + rule = None + for r in policy.start_rules + policy.stop_rules: + if str(r.id) == str(rule_id): + rule = r + break + + if not rule: + raise RuleNotFoundError(f"Rule with ID {rule_id} not found in policy {policy_id}.") + + # Set the rule as enabled + rule.enabled = True + self.policy_repo.update(policy) # Persist change for each policy + + return rule + + async def disable_policy_rule(self, policy_id: EntityId, rule_id: EntityId) -> AutomationRule: + """Set a rule as disabled.""" + self.logger.info(f"Setting rule {rule_id} of policy {policy_id} as disabled.") + + policy = self.policy_repo.get_by_id(policy_id) + + if not policy: + raise PolicyError(f"Policy with ID {policy_id} not found.") + + # Find the rule in the policy's start or stop rules + rule = None + for r in policy.start_rules + policy.stop_rules: + if str(r.id) == str(rule_id): + rule = r + break + + if not rule: + raise RuleNotFoundError(f"Rule with ID {rule_id} not found in policy {policy_id}.") + + # Set the rule as disabled + rule.enabled = False + self.policy_repo.update(policy) # Persist change for each policy + + return rule + + async def delete_policy(self, policy_id: EntityId) -> Optional[OptimizationPolicy]: + """Delete a policy from the system.""" + self.logger.info(f"Deleting policy {policy_id}") + + policy = self.policy_repo.get_by_id(policy_id) + + if not policy: + raise PolicyError(f"Policy with ID {policy_id} not found.") + + self.policy_repo.remove(policy_id) + + self.logger.info(f"Policy {policy_id} | {policy.name} deleted successfully.") + + return policy + + def check_policy(self, policy_id: EntityId) -> bool: + """Check if a policy is valid and can be used.""" + self.logger.debug(f"Checking policy {policy_id}") + + policy = self.policy_repo.get_by_id(policy_id) + + if not policy: + raise PolicyNotFoundError(f"Policy with ID {policy_id} not found.") + + # Check if start rules contain at least one rule to stop the miner + if not policy.start_rules or len(policy.start_rules) == 0: + raise PolicyError("Policy must have at least one start rule with a STOP MINING action.") + + # Check if stop rules contain at least one rule to start the miner + if not policy.stop_rules or len(policy.stop_rules) == 0: + raise PolicyError("Policy must have at least one stop rule with a START MINING action.") + + # Check conditions of all active rules + for rule in policy.start_rules + policy.stop_rules: + if rule.enabled: + is_valid, syntax_errors, field_errors = self.validate_rule_conditions(rule.conditions) + if not is_valid: + raise PolicyConfigurationError( + f"Rule {rule.id} ({rule.name}) has invalid conditions. " + f"Syntax Errors: {syntax_errors}, Field Errors: {field_errors}" + ) + + self.logger.debug(f"Policy {policy.id} ({policy.name}) is valid.") + return True + + async def update_policy( + self, + policy_id: EntityId, + name: str, + description: str = "", + ) -> OptimizationPolicy: + """Update a policy in the system.""" + self.logger.info(f"Updating policy {policy_id} ({name})") + + policy = self.policy_repo.get_by_id(policy_id) + + if not policy: + raise PolicyNotFoundError(f"Policy with ID {policy_id} not found") + + policy.name = name + policy.description = description + + self.logger.debug(f"Updated policy {name} ({policy_id})") + self.policy_repo.update(policy) + + return policy + + async def sort_policy_rules(self, policy_id: EntityId) -> None: + """Sort the rules of a policy by priority.""" + policy = self.policy_repo.get_by_id(policy_id) + + if not policy: + raise PolicyNotFoundError(f"Policy with ID {policy_id} not found") + + # Sort start rules by priority + policy.start_rules.sort(key=lambda r: r.priority) + # Sort stop rules by priority + policy.stop_rules.sort(key=lambda r: r.priority) + + self.logger.info(f"Sorted rules for policy {policy.name} by priority") + self.policy_repo.update(policy) + + def validate_rule_conditions(self, conditions: Dict) -> tuple[bool, List[str], List[str]]: + """ + Validate rule conditions structure and semantics. + + Args: + conditions: Dictionary representing the rule conditions + + Returns: + Tuple[bool, List[str], List[str]]: (is_valid, syntax_errors, field_errors) + """ + validation_service = RuleValidationService() + return validation_service.validate_conditions(conditions) + + # --- Settings Management --- + def get_all_settings(self) -> Dict[str, Any]: + """Get all settings.""" + user_id: UserId = UserId("global_settings") + settings: Optional[SystemSettings] = self.settings_repo.get_settings(user_id) + return settings.settings if settings else {} + + async def update_setting(self, key: str, value: Any) -> None: + """Update a setting.""" + user_id: UserId = UserId("global_settings") + settings = self.settings_repo.get_settings(user_id) + + if not settings: + settings = SystemSettings(id=user_id) # Create if doesn't exist + + self.logger.info(f"Updating setting '{key}' to '{value}'") + + settings.set_setting(key, value) + + self.settings_repo.save_settings(user_id, settings) diff --git a/core/edge_mining/application/services/home_load_history_service.py b/core/edge_mining/application/services/home_load_history_service.py new file mode 100644 index 0000000..50589f1 --- /dev/null +++ b/core/edge_mining/application/services/home_load_history_service.py @@ -0,0 +1,179 @@ +"""Service for collecting and purging home load consumption history.""" + +from datetime import datetime, timedelta, timezone +from typing import List, Optional + +from edge_mining.application.interfaces import ( + AdapterServiceInterface, + EventBusInterface, + HomeLoadHistoryServiceInterface, +) +from edge_mining.domain.common import EntityId, Timestamp +from edge_mining.domain.home_load.events import ( + LoadConsumptionHistoryCollectedEvent, + LoadConsumptionHistoryPurgedEvent, +) +from edge_mining.domain.home_load.ports import ( + EnergyLoadHistoryRepository, + HomeLoadsProfileRepository, +) +from edge_mining.domain.home_load.value_objects import HomeLoadPowerPoint +from edge_mining.shared.logging.port import LoggerPort + + +class HomeLoadHistoryService(HomeLoadHistoryServiceInterface): + """Collects power-point data from history providers and manages retention.""" + + def __init__( + self, + home_loads_repo: HomeLoadsProfileRepository, + home_load_history_repo: EnergyLoadHistoryRepository, + adapter_service: AdapterServiceInterface, + event_bus: Optional[EventBusInterface] = None, + logger: Optional[LoggerPort] = None, + ): + self.home_loads_repo = home_loads_repo + self.home_load_history_repo = home_load_history_repo + self.adapter_service = adapter_service + self._event_bus = event_bus + self.logger = logger + + async def collect_all(self, lookback_hours: int = 24) -> None: + """Collect power points from all history providers for all enabled devices. + + For each enabled LoadDevice that has an energy_load_history_provider_id, + fetches new power points since the last known timestamp (delta ingestion) + and persists them in the history repository. + """ + profiles = self.home_loads_repo.get_all() + if not profiles: + if self.logger: + self.logger.debug("No home load profiles found. Skipping history collection.") + return + + for profile in profiles: + for device in profile.devices: + if not device.enabled: + continue + if not device.energy_load_history_provider_id: + continue + await self._collect_for_device( + device_id=device.id, + device_name=device.name, + provider_id=device.energy_load_history_provider_id, + lookback_hours=lookback_hours, + ) + + async def _collect_for_device( + self, + device_id: EntityId, + device_name: str, + provider_id: EntityId, + lookback_hours: int = 24, + ) -> None: + """Collect power points for a single device from its history provider.""" + history_provider = await self.adapter_service.get_home_load_history_provider(provider_id, device_id) + if not history_provider: + if self.logger: + self.logger.warning(f"History provider {provider_id} not found for device '{device_name}'. Skipping.") + return + + now = Timestamp(datetime.now(timezone.utc)) + last_ts = self.home_load_history_repo.get_latest_timestamp(device_id) + if last_ts is not None: + start = last_ts + else: + start = Timestamp(now - timedelta(hours=lookback_hours)) + + try: + power_points = await history_provider.get_power_points(start, now) + except Exception as e: + if self.logger: + self.logger.error( + f"Error fetching power points for device '{device_name}' " f"from provider {provider_id}: {e}" + ) + return + + if not power_points: + return + + self.home_load_history_repo.add_power_points(device_id, power_points) + if self.logger: + self.logger.debug(f"Collected {len(power_points)} power points for device '{device_name}'.") + + if self._event_bus: + await self._event_bus.publish( + LoadConsumptionHistoryCollectedEvent( + device_id=device_id, + device_name=device_name, + points_collected=len(power_points), + ) + ) + + async def purge_all(self, retention_days: int = 90) -> None: + """Purge power points older than retention_days for all devices. + + Iterates all profiles and their devices, purging historical data that + exceeds the retention window. + """ + cutoff = Timestamp(datetime.now() - timedelta(days=retention_days)) + profiles = self.home_loads_repo.get_all() + if not profiles: + return + + for profile in profiles: + for device in profile.devices: + try: + purged = self.home_load_history_repo.purge_before(device.id, cutoff) + except Exception as e: + if self.logger: + self.logger.error(f"Error purging history for device '{device.name}': {e}") + continue + + if purged > 0: + if self.logger: + self.logger.debug( + f"Purged {purged} power points for device '{device.name}' " + f"(older than {retention_days} days)." + ) + if self._event_bus: + await self._event_bus.publish( + LoadConsumptionHistoryPurgedEvent( + device_id=device.id, + device_name=device.name, + points_purged=purged, + ) + ) + + def get_device_history(self, device_id: EntityId, start: Timestamp, end: Timestamp) -> List[HomeLoadPowerPoint]: + """Retrieve stored power points for a device in a time window.""" + return self.home_load_history_repo.get_power_points(device_id, start, end) + + def clear_device_history(self, device_id: EntityId) -> int: + """Delete all stored power points for a device.""" + removed = self.home_load_history_repo.clear_device_history(device_id) + if self.logger: + self.logger.info(f"Cleared {removed} power points for device {device_id}.") + return removed + + async def collect_devices(self, device_ids: List[EntityId], lookback_hours: int = 24) -> None: + """Collect power points for the specified devices only.""" + profiles = self.home_loads_repo.get_all() + if not profiles: + return + + target_ids = set(device_ids) + for profile in profiles: + for device in profile.devices: + if device.id not in target_ids: + continue + if not device.energy_load_history_provider_id: + if self.logger: + self.logger.warning(f"Device '{device.name}' has no history provider configured. Skipping.") + continue + await self._collect_for_device( + device_id=device.id, + device_name=device.name, + provider_id=device.energy_load_history_provider_id, + lookback_hours=lookback_hours, + ) diff --git a/core/edge_mining/application/services/load_forecast_training_service.py b/core/edge_mining/application/services/load_forecast_training_service.py new file mode 100644 index 0000000..f3a8058 --- /dev/null +++ b/core/edge_mining/application/services/load_forecast_training_service.py @@ -0,0 +1,405 @@ +"""Service for training ML forecast models on collected home load history.""" + +import pickle +from datetime import datetime, timedelta, timezone +from typing import List, Optional + +from edge_mining.adapters.domain.home_load.forecast_providers.features import ( + fill_missing_hours, + intervals_to_hourly_series, + prepare_supervised_dataset, +) +from edge_mining.adapters.domain.home_load.history_providers.helpers import group_power_points_into_intervals +from edge_mining.application.interfaces import LoadForecastTrainingServiceInterface +from edge_mining.domain.common import EntityId, Timestamp +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter +from edge_mining.domain.home_load.entities import LoadConsumptionModel +from edge_mining.domain.home_load.ports import ( + EnergyLoadHistoryRepository, + HomeLoadsProfileRepository, + LoadConsumptionModelRepository, +) +from edge_mining.domain.home_load.value_objects import LoadEnergyConsumption +from edge_mining.shared.logging.port import LoggerPort + + +class LoadForecastModelTrainingService(LoadForecastTrainingServiceInterface): + """Trains ML models (Statsmodels, XGBoost) on historical home load data. + + Designed to be run nightly via the scheduler. For each enabled device + that has enough history, trains both a Holt-Winters and an XGBoost model, + evaluates them against a holdout set, and promotes the best one to active. + """ + + def __init__( + self, + home_loads_repo: HomeLoadsProfileRepository, + history_repo: EnergyLoadHistoryRepository, + model_repo: LoadConsumptionModelRepository, + logger: Optional[LoggerPort] = None, + ): + self._home_loads_repo = home_loads_repo + self._history_repo = history_repo + self._model_repo = model_repo + self._logger = logger + + async def train_all(self, weeks_lookback: int = 8) -> None: + """Train models for every device that has sufficient history.""" + profiles = self._home_loads_repo.get_all() + if not profiles: + if self._logger: + self._logger.debug("No home load profiles found. Skipping training.") + return + + for profile in profiles: + for device in profile.devices: + if not device.enabled: + continue + try: + await self._train_for_device(device.id, device.name, weeks_lookback) + except Exception as exc: + if self._logger: + self._logger.error(f"Training failed for device '{device.name}': {exc}") + + async def train_device(self, device_id: EntityId, weeks_lookback: int = 8) -> None: + """Train models for a single device identified by device_id.""" + profiles = self._home_loads_repo.get_all() + device_name: Optional[str] = None + for profile in profiles: + for device in profile.devices: + if device.id == device_id: + device_name = device.name + break + if device_name is not None: + break + + if device_name is None: + if self._logger: + self._logger.warning(f"Device {device_id} not found in any profile. Skipping training.") + return + + await self._train_for_device(device_id, device_name, weeks_lookback) + + def get_models(self, device_id: Optional[EntityId] = None) -> List[LoadConsumptionModel]: + """Retrieve trained models, optionally filtered by device.""" + return self._model_repo.get_all(device_id) + + def delete_model(self, model_id: EntityId) -> None: + """Delete a trained model by ID.""" + self._model_repo.remove(model_id) + + async def _train_for_device( + self, + device_id: EntityId, + device_name: str, + weeks_lookback: int, + ) -> None: + """Train HW + XGBoost models for one device, promote the better one.""" + now = Timestamp(datetime.now(timezone.utc)) + lookback_start = Timestamp(now - timedelta(weeks=weeks_lookback)) + + power_points = self._history_repo.get_power_points(device_id, lookback_start, now) + if len(power_points) < 48 * 2: # at least 48 hours of data for train+holdout + if self._logger: + self._logger.debug( + f"Insufficient history for device '{device_name}' ({len(power_points)} points). Skipping training." + ) + return + + # Build LoadEnergyConsumption from power points + intervals = group_power_points_into_intervals(power_points) + consumption = LoadEnergyConsumption(timestamp=now, intervals=intervals) + + # Split: last 24h as holdout + holdout_start = Timestamp(now - timedelta(hours=24)) + train_consumption = consumption.in_window(lookback_start, holdout_start) + holdout_consumption = consumption.in_window(holdout_start, now) + + if len(train_consumption.intervals) < 48 or len(holdout_consumption.intervals) < 12: + if self._logger: + self._logger.debug(f"Not enough data after split for device '{device_name}'. Skipping.") + return + + hw_model = self._train_hw(train_consumption, holdout_consumption, device_id, device_name) + xgb_model = self._train_xgb(train_consumption, holdout_consumption, device_id, device_name) + skf_model = self._train_skforecast(train_consumption, holdout_consumption, device_id, device_name) + + # Promote the best model + candidates = [m for m in [hw_model, xgb_model, skf_model] if m is not None and m.mae is not None] + if not candidates: + if self._logger: + self._logger.warning(f"No model trained successfully for device '{device_name}'.") + return + + best = min(candidates, key=lambda m: m.mae) # type: ignore[arg-type] + best.is_active = True + + # Deactivate previous active models for this device + for adapter_type in [ + EnergyLoadForecastProviderAdapter.STATSMODELS, + EnergyLoadForecastProviderAdapter.XGBOOST, + EnergyLoadForecastProviderAdapter.SKFORECAST, + ]: + old = self._model_repo.get_active_model(adapter_type, device_id) + if old is not None: + old.is_active = False + self._model_repo.update(old) + + # Persist all trained models + for model in candidates: + self._model_repo.add(model) + + if self._logger: + self._logger.info( + f"Trained models for device '{device_name}': best={best.adapter_type.value} MAE={best.mae:.2f}" + ) + + def _train_hw( + self, + train: LoadEnergyConsumption, + holdout: LoadEnergyConsumption, + device_id: EntityId, + device_name: str, + ) -> Optional[LoadConsumptionModel]: + """Train Holt-Winters and evaluate on holdout.""" + try: + from statsmodels.tsa.holtwinters import ExponentialSmoothing + except ImportError: + return None + + series = intervals_to_hourly_series(train) + series = fill_missing_hours(series) + powers = [p for _, p in series] + + seasonal_periods = 24 + if len(powers) < seasonal_periods * 2: + return None + + try: + model = ExponentialSmoothing(powers, trend="add", seasonal="add", seasonal_periods=seasonal_periods) + fitted = model.fit(optimized=True) + model_bytes = pickle.dumps(fitted) + + # Evaluate on holdout + holdout_series = intervals_to_hourly_series(holdout) + holdout_series = fill_missing_hours(holdout_series) + holdout_powers = [p for _, p in holdout_series] + + n_eval = min(len(holdout_powers), 24) + if n_eval == 0: + return None + + forecast = fitted.forecast(n_eval) + mae = sum(abs(float(forecast[i]) - holdout_powers[i]) for i in range(n_eval)) / n_eval + rmse = (sum((float(forecast[i]) - holdout_powers[i]) ** 2 for i in range(n_eval)) / n_eval) ** 0.5 + + return LoadConsumptionModel( + device_id=device_id, + adapter_type=EnergyLoadForecastProviderAdapter.STATSMODELS, + trained_at=datetime.now(), + mae=mae, + rmse=rmse, + samples_used=len(powers), + is_active=False, + model_bytes=model_bytes, + ) + except Exception as exc: + if self._logger: + self._logger.warning(f"Holt-Winters training failed for '{device_name}': {exc}") + return None + + def _train_xgb( + self, + train: LoadEnergyConsumption, + holdout: LoadEnergyConsumption, + device_id: EntityId, + device_name: str, + ) -> Optional[LoadConsumptionModel]: + """Train XGBoost and evaluate on holdout.""" + try: + import xgboost as xgb + except ImportError: + return None + + hours_ahead = 3 + X_train, y_train = prepare_supervised_dataset(train, hours_ahead=hours_ahead) + if len(X_train) < 48: + return None + + try: + model = xgb.XGBRegressor( + n_estimators=100, max_depth=6, learning_rate=0.1, objective="reg:squarederror", verbosity=0 + ) + model.fit(X_train, y_train) + model_bytes = pickle.dumps(model) + + # Evaluate on holdout + X_holdout, y_holdout = prepare_supervised_dataset(holdout, hours_ahead=hours_ahead) + if len(X_holdout) < 3: + # If holdout has insufficient supervised pairs, use raw MAE + holdout_series = intervals_to_hourly_series(holdout) + holdout_series = fill_missing_hours(holdout_series) + holdout_powers = [p for _, p in holdout_series] + if not holdout_powers: + return None + # Predict on holdout features (from training data end) + X_eval, y_eval = prepare_supervised_dataset( + LoadEnergyConsumption( + timestamp=holdout.timestamp, + intervals=list(train.intervals) + list(holdout.intervals), + ), + hours_ahead=hours_ahead, + ) + # Use last portion as holdout + n_eval = min(len(holdout_powers), len(X_eval)) + if n_eval == 0: + return None + X_eval = X_eval[-n_eval:] + y_eval = y_eval[-n_eval:] + else: + X_eval, y_eval = X_holdout, y_holdout + n_eval = len(y_eval) + + predictions = model.predict(X_eval) + mae = sum(abs(float(predictions[i]) - y_eval[i]) for i in range(n_eval)) / n_eval + rmse = (sum((float(predictions[i]) - y_eval[i]) ** 2 for i in range(n_eval)) / n_eval) ** 0.5 + + return LoadConsumptionModel( + device_id=device_id, + adapter_type=EnergyLoadForecastProviderAdapter.XGBOOST, + trained_at=datetime.now(), + mae=mae, + rmse=rmse, + samples_used=len(X_train), + is_active=False, + model_bytes=model_bytes, + ) + except Exception as exc: + if self._logger: + self._logger.warning(f"XGBoost training failed for '{device_name}': {exc}") + return None + + def _train_skforecast( + self, + train: LoadEnergyConsumption, + holdout: LoadEnergyConsumption, + device_id: EntityId, + device_name: str, + sklearn_model: str = "RandomForestRegressor", + num_lags: int = 72, + perform_tuning: bool = True, + tuning_trials: int = 20, + ) -> Optional[LoadConsumptionModel]: + """Train skforecast ForecasterRecursive and evaluate on holdout. + + If ``perform_tuning`` is True and Optuna is available, Bayesian + hyperparameter tuning is run after the initial fit to find the + best combination of model hyperparameters and lag count. + """ + try: + import pandas as pd_ + from skforecast.recursive import ForecasterRecursive as FR + + from edge_mining.adapters.domain.home_load.forecast_providers.skforecast_provider import ( + SkforecastForecastProvider, + _resolve_sklearn_model, + ) + except ImportError: + return None + + series = intervals_to_hourly_series(train) + series = fill_missing_hours(series) + powers = [p for _, p in series] + + if len(powers) < num_lags + 24: + return None + + try: + y = pd_.Series(powers, name="power") + tuning_params: Optional[dict] = None + + # --- Optuna tuning (optional) --- + if perform_tuning and len(powers) >= num_lags + 48 + 24: + try: + best_params, tuned_forecaster = SkforecastForecastProvider.tune( + y_series=y, + sklearn_model_name=sklearn_model, + num_lags=num_lags, + steps=24, + n_trials=tuning_trials, + ) + tuning_params = best_params + model_bytes = pickle.dumps(tuned_forecaster) + forecaster = tuned_forecaster + + if self._logger: + self._logger.debug(f"Optuna tuning completed for '{device_name}': {best_params}") + except Exception as tune_exc: + if self._logger: + self._logger.warning(f"Optuna tuning failed for '{device_name}', using base fit: {tune_exc}") + # Fallback to base fit + regressor = _resolve_sklearn_model(sklearn_model) + forecaster = FR(estimator=regressor, lags=num_lags) + forecaster.fit(y=y) + model_bytes = pickle.dumps(forecaster) + else: + # Base fit without tuning + regressor = _resolve_sklearn_model(sklearn_model) + forecaster = FR(estimator=regressor, lags=num_lags) + forecaster.fit(y=y) + model_bytes = pickle.dumps(forecaster) + + # Evaluate on holdout + holdout_series = intervals_to_hourly_series(holdout) + holdout_series = fill_missing_hours(holdout_series) + holdout_powers = [p for _, p in holdout_series] + + n_eval = min(len(holdout_powers), 24) + if n_eval == 0: + return None + + predictions = forecaster.predict(steps=n_eval) + pred_list = predictions.tolist() + mae = sum(abs(float(pred_list[i]) - holdout_powers[i]) for i in range(n_eval)) / n_eval + rmse = (sum((float(pred_list[i]) - holdout_powers[i]) ** 2 for i in range(n_eval)) / n_eval) ** 0.5 + + # --- Rolling-window backtesting --- + backtest_mae: Optional[float] = None + backtest_rmse: Optional[float] = None + backtest_folds: int = 0 + try: + bt_result = SkforecastForecastProvider.backtest( + forecaster=forecaster, + y_series=y, + steps=24, + folds=3, + ) + backtest_mae = bt_result.get("backtest_mae") + backtest_rmse = bt_result.get("backtest_rmse") + backtest_folds = bt_result.get("backtest_folds", 0) + if self._logger: + self._logger.debug( + f"Backtesting for '{device_name}': MAE={backtest_mae}, RMSE={backtest_rmse}, folds={backtest_folds}" + ) + except Exception as bt_exc: + if self._logger: + self._logger.warning(f"Backtesting failed for '{device_name}': {bt_exc}") + + return LoadConsumptionModel( + device_id=device_id, + adapter_type=EnergyLoadForecastProviderAdapter.SKFORECAST, + trained_at=datetime.now(), + mae=mae, + rmse=rmse, + samples_used=len(powers), + is_active=False, + model_bytes=model_bytes, + tuning_params=tuning_params, + backtest_mae=backtest_mae, + backtest_rmse=backtest_rmse, + backtest_folds=backtest_folds, + ) + except Exception as exc: + if self._logger: + self._logger.warning(f"Skforecast training failed for '{device_name}': {exc}") + return None diff --git a/core/edge_mining/application/services/miner_action_service.py b/core/edge_mining/application/services/miner_action_service.py new file mode 100644 index 0000000..ced9434 --- /dev/null +++ b/core/edge_mining/application/services/miner_action_service.py @@ -0,0 +1,484 @@ +"""Action service for miners, energy, and optimizations.""" + +from typing import List, Optional + +from edge_mining.application.interfaces import AdapterServiceInterface, EventBusInterface, MinerActionServiceInterface +from edge_mining.domain.common import EntityId, Watts +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.common import MinerFeatureType, MinerStatus +from edge_mining.domain.miner.events import MinerStateChangedEvent +from edge_mining.domain.miner.exceptions import ( + MinerControllerConfigurationError, + MinerNotActiveError, + MinerNotFoundError, +) +from edge_mining.domain.miner.ports import ( + DeviceInfoPort, + HashboardMonitorPort, + HashrateMonitorPort, + InternalFanSpeedMonitorPort, + MaxHashrateDetectionPort, + MaxPowerDetectionPort, + MinerRepository, + MiningControlPort, + OperationalMonitorPort, + PowerControlPort, + PowerMonitorPort, + StatusMonitorPort, +) +from edge_mining.domain.miner.value_objects import HashRate, MinerFeature, MinerInfo, MinerLimit, MinerStateSnapshot +from edge_mining.domain.notification.ports import NotificationPort +from edge_mining.shared.logging.port import LoggerPort + + +class MinerActionService(MinerActionServiceInterface): + """Handles actions on miners""" + + def __init__( + self, + adapter_service: AdapterServiceInterface, + miner_repo: MinerRepository, + event_bus: Optional[EventBusInterface] = None, + logger: Optional[LoggerPort] = None, + ): + # Services + self.adapter_service = adapter_service + + # Domains + self.miner_repo = miner_repo + + # Infrastructure + self._event_bus = event_bus + self.logger = logger + + async def get_miner_info(self, miner_id: EntityId) -> Optional[MinerInfo]: + """Gets the information of the specified miner.""" + if self.logger: + self.logger.info(f"Getting info for miner {miner_id}") + + miner: Optional[Miner] = self.miner_repo.get_by_id(miner_id) + + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + + port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.DEVICE_INFO_DETECTION) + if not port or not isinstance(port, DeviceInfoPort): + raise MinerControllerConfigurationError(f"No device info port available for miner {miner_id}.") + + return await port.get_device_info() + + async def get_miner_limits(self, miner_id: EntityId) -> Optional[MinerLimit]: + """Gets the limits of the specified miner.""" + if self.logger: + self.logger.info(f"Getting limits for miner {miner_id}") + + miner: Optional[Miner] = self.miner_repo.get_by_id(miner_id) + + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + + # --- Retrieve max power limit --- + max_power = None + power_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MAX_POWER_DETECTION) + if power_port and isinstance(power_port, MaxPowerDetectionPort): + max_power = await power_port.get_max_power() + else: + if self.logger: + self.logger.warning(f"No max power detection port available for miner {miner_id}. Returning None.") + + # --- Retrieve max hash rate limit --- + max_hash_rate = None + hashrate_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHRATE_MONITORING) + if hashrate_port and isinstance(hashrate_port, MaxHashrateDetectionPort): + max_hash_rate = await hashrate_port.get_max_hashrate() + else: + if self.logger: + self.logger.warning(f"No hashrate monitor port available for miner {miner_id}. Returning None.") + + return MinerLimit(max_power=max_power, max_hash_rate=max_hash_rate) if max_power or max_hash_rate else None + + async def _notify(self, notifiers: List[NotificationPort], title: str, message: str): + """Sends a notification using the configured notifiers.""" + + for notifier in notifiers: + if notifier: + try: + await notifier.send_notification(title, message) + except Exception as e: + if self.logger: + self.logger.error(f"Failed to send notification: {e}") + + # --- Miner Actions --- + async def start_miner(self, miner_id: EntityId, notifiers: Optional[List[NotificationPort]] = None) -> bool: + """Starts the specified miner.""" + if self.logger: + self.logger.info(f"Starting miner {miner_id}") + + miner: Optional[Miner] = self.miner_repo.get_by_id(miner_id) + + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + + if not miner.active: + raise MinerNotActiveError(f"Miner {miner_id} is not active and cannot be started.") + + # Try MINING_CONTROL first, then POWER_CONTROL as fallback + mining_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MINING_CONTROL) + power_ctrl_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_CONTROL) + + if not mining_port and not power_ctrl_port: + raise MinerControllerConfigurationError(f"No mining or power control available for miner {miner_id}.") + + # Get current status + status_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) + current_status = MinerStatus.UNKNOWN + if status_port and isinstance(status_port, StatusMonitorPort): + current_status = await status_port.get_status() + + success = False + if mining_port and isinstance(mining_port, MiningControlPort): + success = await mining_port.start_mining() + elif power_ctrl_port and isinstance(power_ctrl_port, PowerControlPort): + success = await power_ctrl_port.power_on() + + if success: + if self.logger: + self.logger.info(f"Miner {miner.id} ({miner.name}) started successfully.") + + # Publish miner state changed event + if self._event_bus: + await self._event_bus.publish( + MinerStateChangedEvent( + miner_id=miner.id, + miner_name=miner.name, + old_status=current_status, + new_status=MinerStatus.ON, + ) + ) + + if notifiers: + await self._notify( + notifiers, + "Edge Mining Info", + f"Miner {miner.id} ({miner.name}) started.", + ) + else: + if self.logger: + self.logger.error(f"Failed to start miner {miner.id} ({miner.name}).") + + return success + + async def stop_miner(self, miner_id: EntityId, notifiers: Optional[List[NotificationPort]] = None) -> bool: + """Stops the specified miner.""" + if self.logger: + self.logger.info(f"Stopping miner {miner_id}") + + miner: Optional[Miner] = self.miner_repo.get_by_id(miner_id) + + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + + if not miner.active: + raise MinerNotActiveError(f"Miner {miner_id} is not active and cannot be stopped.") + + # Try MINING_CONTROL first, then POWER_CONTROL as fallback + mining_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MINING_CONTROL) + power_ctrl_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_CONTROL) + + if not mining_port and not power_ctrl_port: + raise MinerControllerConfigurationError(f"No mining or power control available for miner {miner_id}.") + + # Get current status + status_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) + current_status = MinerStatus.UNKNOWN + if status_port and isinstance(status_port, StatusMonitorPort): + current_status = await status_port.get_status() + + success = False + if mining_port and isinstance(mining_port, MiningControlPort): + success = await mining_port.stop_mining() + elif power_ctrl_port and isinstance(power_ctrl_port, PowerControlPort): + success = await power_ctrl_port.power_off() + + if success: + if self.logger: + self.logger.info(f"Miner {miner.id} ({miner.name}) stopped successfully.") + + # Publish miner state changed event + if self._event_bus: + await self._event_bus.publish( + MinerStateChangedEvent( + miner_id=miner.id, + miner_name=miner.name, + old_status=current_status, + new_status=MinerStatus.OFF, + ) + ) + + if notifiers: + await self._notify( + notifiers, + "Edge Mining Info", + f"Miner {miner.id} ({miner.name}) stopped.", + ) + else: + if self.logger: + self.logger.error(f"Failed to stop miner {miner.id} ({miner.name}).") + + return success + + async def get_miner_consumption(self, miner_id: EntityId) -> Optional[Watts]: + """Gets the current power consumption of the specified miner.""" + if self.logger: + self.logger.info(f"Getting power consumption for miner {miner_id}") + + miner: Optional[Miner] = self.miner_repo.get_by_id(miner_id) + + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + + port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) + if not port or not isinstance(port, PowerMonitorPort): + raise MinerControllerConfigurationError(f"No power monitor available for miner {miner_id}.") + + return await port.get_power() + + async def get_miner_hashrate(self, miner_id: EntityId) -> Optional[HashRate]: + """Gets the current hash rate of the specified miner.""" + if self.logger: + self.logger.info(f"Getting hash rate for miner {miner_id}") + + miner: Optional[Miner] = self.miner_repo.get_by_id(miner_id) + + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + + port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHRATE_MONITORING) + if not port or not isinstance(port, HashrateMonitorPort): + raise MinerControllerConfigurationError(f"No hashrate monitor available for miner {miner_id}.") + + return await port.get_hashrate() + + async def get_miner_status(self, miner_id: EntityId) -> MinerStateSnapshot: + """Gets the current status of the specified miner as a state snapshot.""" + if self.logger: + self.logger.info(f"Getting status for miner {miner_id}") + + miner: Optional[Miner] = self.miner_repo.get_by_id(miner_id) + + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + + # Query individual feature ports + status_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) + current_status = MinerStatus.UNKNOWN + if status_port and isinstance(status_port, StatusMonitorPort): + current_status = await status_port.get_status() + + hashrate_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHRATE_MONITORING) + current_hashrate = None + if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): + current_hashrate = await hashrate_port.get_hashrate() + + power_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) + current_power = None + if power_port and isinstance(power_port, PowerMonitorPort): + current_power = await power_port.get_power() + + hashboard_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHBOARD_MONITORING) + current_hashboards = [] + if hashboard_port and isinstance(hashboard_port, HashboardMonitorPort): + current_hashboards = await hashboard_port.get_hashboards() + + internal_fan_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.FAN_SPEED_INTERNAL_MONITORING + ) + internal_fan_speed = [] + if internal_fan_port and isinstance(internal_fan_port, InternalFanSpeedMonitorPort): + internal_fan_speed = await internal_fan_port.get_internal_fan_speed() + + operational_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.OPERATIONAL_MONITORING + ) + blocks_found = None + system_uptime = None + if operational_port and isinstance(operational_port, OperationalMonitorPort): + blocks_found = await operational_port.get_blocks_found() + system_uptime = await operational_port.get_system_uptime() + + return MinerStateSnapshot( + status=current_status, + hash_rate=current_hashrate, + power_consumption=current_power, + hashboards=current_hashboards, + internal_fan_speed=internal_fan_speed, + blocks_found=blocks_found, + system_uptime=system_uptime, + ) + + async def sync_all_miners(self, include_inactive: bool = False) -> None: + """Synchronizes the status of all miners from their controllers. + + This method retrieves all miners from the repository and queries their + respective controllers. Miners without a configured controller or with + errors are logged but do not block the synchronization of other miners. + + Static configuration (model) is updated if detected from the controller. + Runtime state (status, hashrate, power) is not persisted — it is + captured in MinerStateSnapshot as needed by consumers. + """ + if self.logger: + self.logger.info("Starting synchronization of all miners status...") + + miners: List[Miner] = self.miner_repo.get_all() + if not include_inactive: + miners = [miner for miner in miners if miner.active] + + if not miners: + if self.logger: + self.logger.warning("No miners found in the repository.") + return + + synced_count = 0 + error_count = 0 + + for miner in miners: + try: + if self.logger: + self.logger.debug(f"Syncing status for miner {miner.id} ({miner.name})...") + + status_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.STATUS_MONITORING + ) + if not status_port or not isinstance(status_port, StatusMonitorPort): + if self.logger: + self.logger.warning(f"No status monitor for miner {miner.id} ({miner.name}). Skipping.") + error_count += 1 + continue + + current_status = await status_port.get_status() + + hashrate_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.HASHRATE_MONITORING + ) + current_hashrate = None + if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): + current_hashrate = await hashrate_port.get_hashrate() + + power_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) + current_power = None + if power_port and isinstance(power_port, PowerMonitorPort): + current_power = await power_port.get_power() + + synced_count += 1 + + if self.logger: + self.logger.debug( + f"Miner {miner.id} ({miner.name}) synced: status={current_status.name}, " + f"power={current_power}W, hashrate={current_hashrate}" + ) + + except MinerControllerConfigurationError as e: + if self.logger: + self.logger.warning(f"Configuration error for miner {miner.id} ({miner.name}): {e}") + error_count += 1 + except Exception as e: + if self.logger: + self.logger.error(f"Error syncing miner {miner.id} ({miner.name}): {e}") + error_count += 1 + + if self.logger: + self.logger.info(f"Miners status synchronization completed: {synced_count} synced, {error_count} errors.") + + async def get_miner_details_from_controller(self, controller_id: EntityId) -> MinerStateSnapshot: + """Get details of a miner from its controller as a state snapshot.""" + if self.logger: + self.logger.info(f"Getting miner details from controller {controller_id}") + + # Create a temporary miner with features for all possible feature types + # so the adapter service can resolve the controller + + temp_features = [MinerFeature(feature_type=ft, controller_id=controller_id) for ft in MinerFeatureType] + temp_miner = Miner( + name="Unknown", + model="Unknown", + hash_rate_max=None, + power_consumption_max=None, + active=True, + features=temp_features, + ) + + # Query via feature ports + status_port = await self.adapter_service.get_miner_feature_port(temp_miner, MinerFeatureType.STATUS_MONITORING) + current_status = MinerStatus.UNKNOWN + if status_port and isinstance(status_port, StatusMonitorPort): + current_status = await status_port.get_status() + + operational_port = await self.adapter_service.get_miner_feature_port( + temp_miner, MinerFeatureType.OPERATIONAL_MONITORING + ) + blocks_found = None + system_uptime = None + if operational_port and isinstance(operational_port, OperationalMonitorPort): + blocks_found = await operational_port.get_blocks_found() + system_uptime = await operational_port.get_system_uptime() + + hashrate_port = await self.adapter_service.get_miner_feature_port( + temp_miner, MinerFeatureType.HASHRATE_MONITORING + ) + current_hashrate = None + if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): + current_hashrate = await hashrate_port.get_hashrate() + + power_port = await self.adapter_service.get_miner_feature_port(temp_miner, MinerFeatureType.POWER_MONITORING) + current_power = None + if power_port and isinstance(power_port, PowerMonitorPort): + current_power = await power_port.get_power() + + temperature_port = await self.adapter_service.get_miner_feature_port( + temp_miner, MinerFeatureType.HASHBOARD_MONITORING + ) + current_hashboards = [] + if temperature_port and isinstance(temperature_port, HashboardMonitorPort): + current_hashboards = await temperature_port.get_hashboards() + + internal_fan_port = await self.adapter_service.get_miner_feature_port( + temp_miner, MinerFeatureType.FAN_SPEED_INTERNAL_MONITORING + ) + internal_fan_speed = [] + if internal_fan_port and isinstance(internal_fan_port, InternalFanSpeedMonitorPort): + internal_fan_speed = await internal_fan_port.get_internal_fan_speed() + + has_no_details = all( + ( + current_status == MinerStatus.UNKNOWN, + current_hashrate is None, + current_power is None, + ) + ) + + if has_no_details: + if self.logger: + self.logger.warning( + "No details retrieved from controller " + f"{controller_id}. Check controller connectivity and configuration." + ) + raise MinerControllerConfigurationError( + "Failed to retrieve details from controller " + f"{controller_id}. Check controller connectivity and configuration." + ) + + snapshot = MinerStateSnapshot( + status=current_status, + hash_rate=current_hashrate, + power_consumption=current_power, + hashboards=current_hashboards, + internal_fan_speed=internal_fan_speed, + blocks_found=blocks_found, + system_uptime=system_uptime, + ) + + if self.logger: + self.logger.debug(f"Retrieved miner details for controller {controller_id}") + + return snapshot diff --git a/core/edge_mining/application/services/optimization_service.py b/core/edge_mining/application/services/optimization_service.py new file mode 100644 index 0000000..fd1ff76 --- /dev/null +++ b/core/edge_mining/application/services/optimization_service.py @@ -0,0 +1,1183 @@ +""" +The Optimization Runner Service is responsible for running the optimization process. +It is responsible for: +- Evaluating the policy +- Getting the current energy state +- Getting the forecast +- Executing the decision +""" + +import asyncio +from datetime import datetime, timedelta +from typing import Dict, List, Optional + +from edge_mining.application.interfaces import ( + AdapterServiceInterface, + EventBusInterface, + OptimizationServiceInterface, + SunFactoryInterface, +) +from edge_mining.domain.common import EntityId, Timestamp, WattHours +from edge_mining.domain.energy.entities import EnergySource +from edge_mining.domain.energy.events import EnergyStateSnapshotUpdatedEvent +from edge_mining.domain.energy.ports import EnergyMonitorPort, EnergySourceRepository +from edge_mining.domain.energy.value_objects import EnergyStateSnapshot +from edge_mining.domain.forecast.aggregate_root import Forecast +from edge_mining.domain.forecast.ports import ForecastProviderPort +from edge_mining.domain.home_load.aggregate_roots import HomeLoadsProfile +from edge_mining.domain.home_load.entities import LoadDevice +from edge_mining.domain.home_load.ports import ( + EnergyLoadForecastProviderPort, + EnergyLoadHistoryProviderPort, + HomeLoadsProfileRepository, +) +from edge_mining.domain.home_load.value_objects import ( + HomeLoadEnergyInterval, + HomeLoadsConsumption, + LoadDeviceConsumption, + LoadEnergyConsumption, +) +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.common import MinerFeatureType, MinerStatus +from edge_mining.domain.miner.events import MinerStateChangedEvent +from edge_mining.domain.miner.exceptions import MinerError +from edge_mining.domain.miner.ports import ( + HashboardMonitorPort, + HashrateMonitorPort, + InternalFanSpeedMonitorPort, + MinerFeaturePort, + MinerRepository, + MiningControlPort, + OperationalMonitorPort, + PowerControlPort, + PowerMonitorPort, + StatusMonitorPort, +) +from edge_mining.domain.miner.value_objects import MinerStateSnapshot +from edge_mining.domain.notification.ports import NotificationPort +from edge_mining.domain.optimization_unit.aggregate_roots import EnergyOptimizationUnit +from edge_mining.domain.optimization_unit.events import RuleEngagedEvent +from edge_mining.domain.optimization_unit.exceptions import OptimizationUnitNotFoundError +from edge_mining.domain.optimization_unit.ports import EnergyOptimizationUnitRepository +from edge_mining.domain.performance.ports import MiningPerformanceTrackerPort +from edge_mining.domain.performance.value_objects import MiningPerformanceSnapshot +from edge_mining.domain.policy.aggregate_roots import OptimizationPolicy +from edge_mining.domain.policy.common import MiningDecision +from edge_mining.domain.policy.entities import AutomationRule +from edge_mining.domain.policy.events import DecisionalContextUpdatedEvent +from edge_mining.domain.policy.exceptions import PolicyError, RuleEngineError, RuleEvaluationError, RuleLoadError +from edge_mining.domain.policy.ports import OptimizationPolicyRepository +from edge_mining.domain.policy.services import RuleEngine +from edge_mining.domain.policy.value_objects import DecisionalContext, Sun +from edge_mining.shared.logging.port import LoggerPort + + +class OptimizationService(OptimizationServiceInterface): + """Service for the optimization process.""" + + def __init__( + self, + optimization_unit_repo: EnergyOptimizationUnitRepository, + energy_source_repo: EnergySourceRepository, + policy_repo: OptimizationPolicyRepository, + miner_repo: MinerRepository, + home_loads_repo: HomeLoadsProfileRepository, + adapter_service: AdapterServiceInterface, + sun_factory: SunFactoryInterface, + event_bus: Optional[EventBusInterface] = None, + logger: Optional[LoggerPort] = None, + forecast_mix_alpha: float = 0.5, + forecast_mix_beta: float = 0.5, + ): + # Domains + + # Repositories + self.optimization_unit_repo = optimization_unit_repo + self.energy_source_repo = energy_source_repo + self.policy_repo = policy_repo + self.miner_repo = miner_repo + self.home_loads_repo = home_loads_repo + + # Infrastructure + self.sun_factory = sun_factory + self.adapter_service = adapter_service + self._event_bus = event_bus + self.logger = logger + + # Forecast blending (α/β mix of forecast with last real measurement) + self.forecast_mix_alpha = forecast_mix_alpha + self.forecast_mix_beta = forecast_mix_beta + + @staticmethod + def _sum_consumptions(consumptions: List[LoadEnergyConsumption]) -> LoadEnergyConsumption: + """Sum a list of LoadEnergyConsumption by matching (start, end) intervals.""" + now_ts = Timestamp(datetime.now()) + if not consumptions: + return LoadEnergyConsumption(timestamp=now_ts, intervals=[]) + + buckets: Dict[tuple, List[HomeLoadEnergyInterval]] = {} + for consumption in consumptions: + for interval in consumption.intervals: + buckets.setdefault((interval.start, interval.end), []).append(interval) + + merged: List[HomeLoadEnergyInterval] = [] + for (start, end), intervals in sorted(buckets.items(), key=lambda kv: kv[0][0]): + total_energy = WattHours(sum(float(i.energy) for i in intervals if i.energy is not None)) + power_points = [p for i in intervals for p in i.power_points] + merged.append( + HomeLoadEnergyInterval( + start=start, + end=end, + energy=total_energy if total_energy else None, + power_points=power_points, + ) + ) + + return LoadEnergyConsumption(timestamp=now_ts, intervals=merged) + + async def _build_home_loads_consumption( + self, + home_loads_profile: Optional[HomeLoadsProfile], + forecast_providers: Dict[EntityId, EnergyLoadForecastProviderPort], + history_providers: Dict[EntityId, EnergyLoadHistoryProviderPort], + unit_name: str, + ) -> Optional[HomeLoadsConsumption]: + """Assemble per-device history+forecast and their household totals. + + For each device, history is fetched from its history provider (if any) + over a 24-hour look-back window. Forecast is obtained by calling each + device's forecast provider with the device history. + """ + if home_loads_profile is None: + return None + + now = Timestamp(datetime.now()) + window_start = Timestamp(now - timedelta(hours=24)) + empty_consumption = LoadEnergyConsumption(timestamp=now, intervals=[]) + + per_device: List[LoadDeviceConsumption] = [] + for device in home_loads_profile.devices: + # --- History --- + device_history = empty_consumption + history_provider = history_providers.get(device.id) + if history_provider is not None: + try: + intervals = await history_provider.get_history(window_start, now) + if intervals: + device_history = LoadEnergyConsumption(timestamp=now, intervals=intervals) + elif self.logger: + self.logger.debug(f"[HomeLoad] History provider for '{device.name}' returned empty intervals") + except Exception as e: + if self.logger: + self.logger.warning( + f"Error getting load history for device '{device.name}' " + f"in optimization unit '{unit_name}': {e}" + ) + elif self.logger: + self.logger.debug( + f"[HomeLoad] No history provider for device '{device.name}' " + f"(history_provider_id={device.energy_load_history_provider_id})" + ) + + # --- Forecast --- + device_forecast = empty_consumption + forecast_provider = forecast_providers.get(device.id) + if forecast_provider is not None: + try: + result = forecast_provider.get_consumption_forecast(device_history) + if result is not None: + device_forecast = result + elif self.logger: + self.logger.debug(f"[HomeLoad] Forecast provider for '{device.name}' returned None") + except Exception as e: + if self.logger: + self.logger.warning( + f"Error getting load forecast for device '{device.name}' " + f"in optimization unit '{unit_name}': {e}" + ) + elif self.logger: + self.logger.debug( + f"[HomeLoad] No forecast provider for device '{device.name}' " + f"(forecast_provider_id={device.energy_load_forecast_provider_id})" + ) + + # --- Mix forecast with last real measurement (α/β blending) --- + if device_forecast.intervals and device_history.intervals: + last_real_power = device_history.intervals[-1].avg_power + device_forecast = LoadEnergyConsumption.mix( + device_forecast, + last_real_power, + alpha=self.forecast_mix_alpha, + beta=self.forecast_mix_beta, + ) + + per_device.append(self._make_device_consumption(device, device_history, device_forecast)) + + return HomeLoadsConsumption( + per_device=per_device, + total_history=self._sum_consumptions([d.history for d in per_device]), + total_forecast=self._sum_consumptions([d.forecast for d in per_device]), + ) + + @staticmethod + def _make_device_consumption( + device: LoadDevice, + history: LoadEnergyConsumption, + forecast: LoadEnergyConsumption, + ) -> LoadDeviceConsumption: + return LoadDeviceConsumption( + device_id=device.id, + device_name=device.name, + device_category=device.category, + history=history, + forecast=forecast, + ) + + async def _build_mining_performance_snapshot( + self, + tracker: MiningPerformanceTrackerPort, + miner_ids: List[EntityId], + optimization_unit_name: str, + ) -> Optional[MiningPerformanceSnapshot]: + """Fetch live pool data and consolidate it into a single snapshot.""" + try: + current_hashrate = await tracker.get_current_hashrate(miner_ids=miner_ids) + pool_stats = await tracker.get_pool_stats() + payout_schedule = await tracker.get_payout_schedule() + return MiningPerformanceSnapshot( + current_hashrate=current_hashrate, + pool_stats=pool_stats, + payout_schedule=payout_schedule, + ) + except Exception as e: + if self.logger: + self.logger.warning( + f"Error getting mining performance tracker for optimization unit '{optimization_unit_name}': {e}" + ) + return None + + async def _notify_unit(self, notifiers: List[NotificationPort], title: str, message: str): + """Notify the unit.""" + if not notifiers: + return + + for notifier in notifiers: + try: + success = await notifier.send_notification(title, message) + if not success: + if self.logger: + self.logger.warning(f"Notifier {type(notifier).__name__} reported failure for: {title}") + except Exception as e: + if self.logger: + self.logger.error(f"Failed to send notification via {type(notifier).__name__}: {e}") + + async def test_rules(self, rules: List[AutomationRule], decisional_context: DecisionalContext) -> bool: + """Test a specific automation rule against a given context.""" + # Create the rule engine instance + rule_engine = self.adapter_service.get_rule_engine() + if not rule_engine: + if self.logger: + self.logger.error("Rule engine not available. Cannot process policy.") + raise RuleEngineError("Rule engine not available. Cannot process policy.") + + if not rules: + if self.logger: + self.logger.error("No rules provided for testing.") + raise RuleLoadError("No rules provided for testing.") + + # Check if at least one rule is enabled + active_rules = any([rule.enabled for rule in rules]) + if not active_rules: + if self.logger: + self.logger.error("At least one rule must be enabled.") + raise RuleEvaluationError("At least one rule must be enabled.") + + # Load rules into rule engine + rule_engine.load_rules(rules) + + # Evaluate the rules in the rule engine + return rule_engine.evaluate(decisional_context) + + async def get_decisional_context(self, optimization_unit_id: EntityId) -> Optional[DecisionalContext]: + """Get the decisional context for a specific optimization unit.""" + optimization_unit = self.optimization_unit_repo.get_by_id(optimization_unit_id) + if not optimization_unit: + if self.logger: + self.logger.error(f"Optimization unit ID {optimization_unit_id} not found.") + raise OptimizationUnitNotFoundError(f"Optimization unit ID {optimization_unit_id} not found.") + + # --- Energy Source --- + energy_source: Optional[EnergySource] = None + if optimization_unit.energy_source_id: + energy_source = self.energy_source_repo.get_by_id(optimization_unit.energy_source_id) + if not energy_source: + if self.logger: + self.logger.error( + f"Energy source for optimization unit '{optimization_unit.name}' " + f"(Config ID: {optimization_unit.energy_source_id}) not found " + f"or failed to initialize. Skipping optimization unit." + ) + + # --- Energy Monitor --- + energy_monitor: Optional[EnergyMonitorPort] = None + if energy_source and energy_source.energy_monitor_id: + energy_monitor = await self.adapter_service.get_energy_monitor(energy_source) + if not energy_monitor: + if self.logger: + self.logger.error( + f"Energy monitor for energy source '{energy_source.name}' " + f"(Config ID: {energy_source.energy_monitor_id}) not found. " + f"Skipping optimization unit." + ) + + # --- Forecast Provider --- + forecast_provider: Optional[ForecastProviderPort] = None + if energy_source and energy_source.forecast_provider_id: + forecast_provider = await self.adapter_service.get_forecast_provider(energy_source) + # Forecast is optional, so log a warning if it's missing but continue + if not forecast_provider: + if self.logger: + self.logger.warning( + f"Forecast provider for energy source '{energy_source.name}' " + f"(Config ID: {energy_source.forecast_provider_id}) not found. " + f"Skipping optimization unit." + ) + + # --- Home Loads --- + home_loads_profile: Optional[HomeLoadsProfile] = None + if optimization_unit.home_loads_profile: + profile = self.home_loads_repo.get_by_id(optimization_unit.home_loads_profile) + if profile: + home_loads_profile = profile + + # --- Home Loads Forecast Provider --- + energy_load_forecast_providers: Dict[EntityId, EnergyLoadForecastProviderPort] = {} + if home_loads_profile and home_loads_profile.devices: + for load_device in home_loads_profile.devices: + if load_device.energy_load_forecast_provider_id: + energy_load_forecast_provider = self.adapter_service.get_home_load_forecast_provider( + load_device.energy_load_forecast_provider_id + ) + # Energy load forecast provider is optional, so log a warning if it's + # missing but continue + if not energy_load_forecast_provider: + if self.logger: + self.logger.warning( + f"Energy load forecast provider for " + f"load device '{load_device.name}' of " + f"optimization unit '{optimization_unit.name}' " + f"(Config ID: {load_device.energy_load_forecast_provider_id}) " + "not found. Skipping forecast provider." + ) + + if energy_load_forecast_provider: + energy_load_forecast_providers[load_device.id] = energy_load_forecast_provider + + # --- Home Loads History Provider --- + energy_load_history_providers: Dict[EntityId, EnergyLoadHistoryProviderPort] = {} + if home_loads_profile and home_loads_profile.devices: + for load_device in home_loads_profile.devices: + if load_device.energy_load_history_provider_id: + energy_load_history_provider = await self.adapter_service.get_home_load_history_provider( + load_device.energy_load_history_provider_id, load_device.id + ) + if not energy_load_history_provider: + if self.logger: + self.logger.warning( + f"Energy load history provider for " + f"load device '{load_device.name}' of " + f"optimization unit '{optimization_unit.name}' " + f"(Config ID: {load_device.energy_load_history_provider_id}) " + "not found. Skipping history provider." + ) + else: + energy_load_history_providers[load_device.id] = energy_load_history_provider + + # --- Energy State --- + if energy_source and energy_monitor: + try: + energy_state: Optional[EnergyStateSnapshot] = None + energy_state = await energy_monitor.get_current_energy_state() + if not energy_state: + if self.logger: + self.logger.error( + f"Could not retrieve energy state for optimization unit '{optimization_unit.name}'. " + "Skipping." + ) + # Publish energy state snapshot event + if self._event_bus: + await self._event_bus.publish( + EnergyStateSnapshotUpdatedEvent( + optimization_unit_id=optimization_unit.id, + optimization_unit_name=optimization_unit.name, + energy_source_id=energy_source.id, + energy_state_snapshot=energy_state, + ) + ) + except Exception as e: + if self.logger: + self.logger.error( + f"Error getting energy state for optimization unit '{optimization_unit.name}': {e}" + ) + + # --- Solar Forecast --- + forecast_data: Optional[Forecast] = None + if forecast_provider: + try: + forecast_data = await forecast_provider.get_forecast() + except Exception as e: + if self.logger: + self.logger.warning( + f"Error getting solar forecast for optimization unit '{optimization_unit.name}': {e}" + ) + + # --- Home Load Consumption (per-device history + forecast) --- + home_load = await self._build_home_loads_consumption( + home_loads_profile, + energy_load_forecast_providers, + energy_load_history_providers, + optimization_unit.name, + ) + + # --- Target Miners --- + # Process only the first enabled miner in the optimization unit + if not optimization_unit.target_miner_ids: + if self.logger: + self.logger.info(f"No target miners configured for optimization unit '{optimization_unit.name}'.") + else: + miner_ids = optimization_unit.target_miner_ids + + miner: Optional[Miner] = None + miner_state: Optional[MinerStateSnapshot] = None + for miner_id in miner_ids: + # --- Miner --- + miner = self.miner_repo.get_by_id(miner_id) + if not miner: + if self.logger: + self.logger.error( + f"Miner {miner_id} in optimization unit '{optimization_unit.name}' not found in repository." + ) + continue # Try next miner if available + + if not miner.active: + if self.logger: + self.logger.warning( + f"Miner {miner_id} in optimization unit '{optimization_unit.name}' is not active. Skipping miner." + ) + continue # Try next miner if available + + if not miner.get_controller_ids(): + if self.logger: + self.logger.warning( + f"Miner {miner_id} in optimization unit '{optimization_unit.name}' has no controllers. Skipping miner." + ) + continue # Try next miner if available + + # --- Query current state via feature ports --- + status_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.STATUS_MONITORING + ) + if not status_port or not isinstance(status_port, StatusMonitorPort): + if self.logger: + self.logger.error(f"No status monitor port for miner {miner_id}. Skipping.") + continue + + current_status = await status_port.get_status() + + operational_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.OPERATIONAL_MONITORING + ) + blocks_found = None + system_uptime = None + if operational_port and isinstance(operational_port, OperationalMonitorPort): + blocks_found = await operational_port.get_blocks_found() + system_uptime = await operational_port.get_system_uptime() + + hashrate_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.HASHRATE_MONITORING + ) + current_hashrate = None + if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): + current_hashrate = await hashrate_port.get_hashrate() + + power_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) + current_power = None + if power_port and isinstance(power_port, PowerMonitorPort): + current_power = await power_port.get_power() + + hashboard_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.HASHBOARD_MONITORING + ) + current_hashboards = [] + if hashboard_port and isinstance(hashboard_port, HashboardMonitorPort): + current_hashboards = await hashboard_port.get_hashboards() + + internal_fan_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.FAN_SPEED_INTERNAL_MONITORING + ) + internal_fan_speed = [] + if internal_fan_port and isinstance(internal_fan_port, InternalFanSpeedMonitorPort): + internal_fan_speed = await internal_fan_port.get_internal_fan_speed() + + # Build the miner state snapshot + miner_state = MinerStateSnapshot( + status=current_status, + hash_rate=current_hashrate, + power_consumption=current_power, + hashboards=current_hashboards, + internal_fan_speed=internal_fan_speed, + blocks_found=blocks_found, + system_uptime=system_uptime, + ) + + break # We found a valid miner and controller, we can stop looking for more miners + + # --- Mining Performance Tracker --- + mining_performance: Optional[MiningPerformanceSnapshot] = None + mining_performance_tracker: Optional[MiningPerformanceTrackerPort] = None + if optimization_unit.performance_tracker_id: + try: + mining_performance_tracker = await self.adapter_service.get_mining_performance_tracker( + optimization_unit.performance_tracker_id + ) + except Exception as e: + if self.logger: + self.logger.error( + f"Error getting mining performance tracker for optimization unit " + f"'{optimization_unit.name}': {e}" + ) + # Mining performance tracker is optional, so log a warning if it's missing + # but continue + if not mining_performance_tracker: + if self.logger: + self.logger.warning( + f"Mining performance tracker for optimization unit " + f"'{optimization_unit.name}' " + f"(Config ID: {optimization_unit.performance_tracker_id}) not found. " + f"Skipping mining performance tracker." + ) + else: + if optimization_unit.target_miner_ids: + mining_performance = await self._build_mining_performance_snapshot( + mining_performance_tracker, + optimization_unit.target_miner_ids, + optimization_unit.name, + ) + + # Creates the Sun object for the current date. + sun: Sun = self.sun_factory.create_sun_for_date() + + # Create the decisional context without the miner yet, + # as we will add it later after fetching the miner status. + # This allows us to have a single context for the unit. + # The context will be updated for each miner in the unit. + context = DecisionalContext( + energy_source=energy_source, + energy_state=energy_state, + forecast=forecast_data, + home_load=home_load, + mining_performance=mining_performance, + sun=sun, + miner=miner, + miner_state=miner_state, + ) + + # Publish decisional context event + if self._event_bus: + await self._event_bus.publish( + DecisionalContextUpdatedEvent( + optimization_unit_id=optimization_unit.id, + optimization_unit_name=optimization_unit.name, + context=context, + target_miner_ids=list(optimization_unit.target_miner_ids), + ) + ) + + return context + + async def run_all_enabled_units(self): + """Run the optimization process for all enabled units.""" + if self.logger: + self.logger.debug("Starting optimization run for all enabled units...") + + enabled_units = self.optimization_unit_repo.get_all_enabled() + + if not enabled_units: + if self.logger: + self.logger.debug("No enabled energy optimization units found.") + return + + unit_tasks = [self._process_unit(unit) for unit in enabled_units] + # Don't stop for an error in a unit + await asyncio.gather(*unit_tasks, return_exceptions=False) + + if self.logger: + self.logger.debug(f"Optimization run for all units finished. {len(enabled_units)} units processed.") + + async def _process_unit(self, optimization_unit: EnergyOptimizationUnit): + if self.logger: + self.logger.debug(f"Processing Optimization Unit: '{optimization_unit.name}' (ID: {optimization_unit.id})") + + # --- Notifiers --- + unit_notifiers: List[NotificationPort] = [] + try: + unit_notifiers = await self.adapter_service.get_notifiers(optimization_unit.notifier_ids) + except Exception as e: + if self.logger: + self.logger.error(f"Error getting notifiers for optimization unit '{optimization_unit.name}': {e}") + + # --- Policy --- + if not optimization_unit.policy_id: + if self.logger: + self.logger.warning(f"Optimization unit '{optimization_unit.name}' has no policy assigned. Skipping.") + return + policy: Optional[OptimizationPolicy] = None + policy = self.policy_repo.get_by_id(optimization_unit.policy_id) + if not policy: + if self.logger: + self.logger.error( + f"Policy ID {optimization_unit.policy_id} for optimization unit " + f"'{optimization_unit.name}' not found. Skipping." + ) + return + else: + if self.logger: + self.logger.debug(f"Optimization unit '{optimization_unit.name}' > Using policy '{policy.name}'.") + + # --- Energy Source --- + energy_source: Optional[EnergySource] = None + if optimization_unit.energy_source_id: + energy_source = self.energy_source_repo.get_by_id(optimization_unit.energy_source_id) + if not energy_source: + if self.logger: + self.logger.error( + f"Energy source for optimization unit '{optimization_unit.name}' " + f"(Config ID: {optimization_unit.energy_source_id}) not found " + f"or failed to initialize. Skipping optimization unit." + ) + await self._notify_unit( + unit_notifiers, + f"Optimizer Error ({optimization_unit.name})", + "Energy source unavailable.", + ) + return + else: + if self.logger: + self.logger.debug( + f"Optimization unit '{optimization_unit.name}' > Using energy source '{energy_source.name}'." + ) + + # --- Energy Monitor --- + energy_monitor: Optional[EnergyMonitorPort] = None + if energy_source.energy_monitor_id: + try: + energy_monitor = await self.adapter_service.get_energy_monitor(energy_source) + except Exception as e: + if self.logger: + self.logger.critical(f"Error getting energy monitor for energy source '{energy_source.name}': {e}") + if not energy_monitor: + if self.logger: + self.logger.error( + f"Energy monitor for energy source '{energy_source.name}' " + f"(Config ID: {energy_source.energy_monitor_id}) not found. " + f"Skipping optimization unit." + ) + await self._notify_unit( + unit_notifiers, + f"Optimizer Error ({optimization_unit.name})", + "Energy monitor unavailable.", + ) + return + + # --- Forecast Provider --- + forecast_provider: Optional[ForecastProviderPort] = None + if energy_source.forecast_provider_id: + try: + forecast_provider = await self.adapter_service.get_forecast_provider(energy_source) + except Exception as e: + if self.logger: + self.logger.error(f"Error getting forecast provider for energy source '{energy_source.name}': {e}") + # Forecast is optional, so log a warning if it's missing but continue + if not forecast_provider: + if self.logger: + self.logger.warning( + f"Forecast provider for energy source '{energy_source.name}' " + f"(Config ID: {energy_source.forecast_provider_id}) not found. " + f"Skipping optimization unit." + ) + + # --- Home Loads --- + home_loads_profile: Optional[HomeLoadsProfile] = None + if optimization_unit.home_loads_profile: + home_loads_profile = self.home_loads_repo.get_by_id(optimization_unit.home_loads_profile) + + # --- Energy Load Forecast Providers (per LoadDevice) --- + energy_load_forecast_providers: Dict[EntityId, EnergyLoadForecastProviderPort] = {} + if home_loads_profile and home_loads_profile.devices: + for load_device in home_loads_profile.devices: + if not load_device.energy_load_forecast_provider_id: + continue + try: + provider = self.adapter_service.get_home_load_forecast_provider( + load_device.energy_load_forecast_provider_id + ) + except Exception as e: + provider = None + if self.logger: + self.logger.error( + f"Error getting energy load forecast provider for load device " + f"'{load_device.name}' in optimization unit '{optimization_unit.name}': {e}" + ) + if provider: + energy_load_forecast_providers[load_device.id] = provider + elif self.logger: + self.logger.warning( + f"Energy load forecast provider for load device '{load_device.name}' " + f"(Config ID: {load_device.energy_load_forecast_provider_id}) not found. " + f"Skipping forecast provider for this device." + ) + + # --- Energy Load History Providers (per LoadDevice) --- + energy_load_history_providers: Dict[EntityId, EnergyLoadHistoryProviderPort] = {} + if home_loads_profile and home_loads_profile.devices: + for load_device in home_loads_profile.devices: + if not load_device.energy_load_history_provider_id: + continue + try: + h_provider = self.adapter_service.get_home_load_history_provider( + load_device.energy_load_history_provider_id, load_device.id + ) + except Exception as e: + h_provider = None + if self.logger: + self.logger.error( + f"Error getting energy load history provider for load device " + f"'{load_device.name}' in optimization unit '{optimization_unit.name}': {e}" + ) + if h_provider: + energy_load_history_providers[load_device.id] = h_provider + elif self.logger: + self.logger.warning( + f"Energy load history provider for load device '{load_device.name}' " + f"(Config ID: {load_device.energy_load_history_provider_id}) not found. " + f"Skipping history provider for this device." + ) + + # --- Mining Performance Tracker --- + mining_performance_tracker: Optional[MiningPerformanceTrackerPort] = None + if optimization_unit.performance_tracker_id: + try: + mining_performance_tracker = await self.adapter_service.get_mining_performance_tracker( + optimization_unit.performance_tracker_id + ) + except Exception as e: + if self.logger: + self.logger.error( + "Error getting mining performance tracker " + f"for optimization unit '{optimization_unit.name}': {e}" + ) + # Mining performance tracker is optional, so log a warning if it's missing + # but continue + if not mining_performance_tracker: + if self.logger: + self.logger.warning( + f"Mining performance tracker for optimization unit " + f"'{optimization_unit.name}' " + f"(Config ID: {optimization_unit.performance_tracker_id}) not found. " + f"Skipping mining performance tracker." + ) + + # --- Energy State --- + try: + energy_state: Optional[EnergyStateSnapshot] = None + energy_state = await energy_monitor.get_current_energy_state() + if not energy_state: + if self.logger: + self.logger.error( + f"Could not retrieve energy state for optimization unit '{optimization_unit.name}'. Skipping." + ) + await self._notify_unit( + unit_notifiers, + f"Optimizer Error ({optimization_unit.name})", + "Failed to retrieve energy state.", + ) + return + + # Publish energy state snapshot event + if self._event_bus: + await self._event_bus.publish( + EnergyStateSnapshotUpdatedEvent( + optimization_unit_id=optimization_unit.id, + optimization_unit_name=optimization_unit.name, + energy_source_id=energy_source.id, + energy_state_snapshot=energy_state, + ) + ) + except Exception as e: + if self.logger: + self.logger.error(f"Error getting energy state for optimization unit '{optimization_unit.name}': {e}") + await self._notify_unit( + unit_notifiers, + f"Optimizer Error ({optimization_unit.name})", + f"Energy state error: {e}", + ) + return + + # --- Solar Forecast --- + forecast_data: Optional[Forecast] = None + if forecast_provider: + try: + # Assuming the forecast provider needs parameters from its config, + # or that the resolver has already injected them. If specific parameters + # are needed for the optimization unit (e.g. lat/lon), they should be + # part of the adapter's config or passed here if the resolver doesn't handle them. + # For now, assuming the resolver provides a ready-to-use adapter. + # (the configuration has already done outside of the edge mining application) + + forecast_data = await forecast_provider.get_forecast() + except Exception as e: + if self.logger: + self.logger.warning( + f"Error getting solar forecast for optimization unit '{optimization_unit.name}': {e}" + ) + else: + if self.logger: + self.logger.debug( + f"No solar forecast provider configured for optimization unit '{optimization_unit.name}'." + ) + + # --- Home Load Consumption (per-device history + forecast) --- + home_load = await self._build_home_loads_consumption( + home_loads_profile, + energy_load_forecast_providers, + energy_load_history_providers, + optimization_unit.name, + ) + + # --- Target Miners --- + # Process each target miner in this optimization unit + if not optimization_unit.target_miner_ids: + if self.logger: + self.logger.debug(f"No target miners configured for optimization unit '{optimization_unit.name}'.") + return + + # --- Mining Performance Tracker --- + mining_performance: Optional[MiningPerformanceSnapshot] = None + if mining_performance_tracker: + mining_performance = await self._build_mining_performance_snapshot( + mining_performance_tracker, + optimization_unit.target_miner_ids, + optimization_unit.name, + ) + + # Creates the Sun object for the current date. + sun: Sun = self.sun_factory.create_sun_for_date() + + # Create the decisional context without the miner yet, + # as we will add it later after fetching the miner status. + # This allows us to have a single context for the unit. + # The context will be updated for each miner in the unit. + context = DecisionalContext( + energy_source=energy_source, + energy_state=energy_state, + forecast=forecast_data, + home_load=home_load, + mining_performance=mining_performance, + sun=sun, + ) + + # Publish decisional context event + if self._event_bus: + await self._event_bus.publish( + DecisionalContextUpdatedEvent( + optimization_unit_id=optimization_unit.id, + optimization_unit_name=optimization_unit.name, + context=context, + target_miner_ids=list(optimization_unit.target_miner_ids), + ) + ) + + # TODO: should we manage miners singularly or together? + # TODO: should we serialize the miner process or run them in parallel? + # For now, we will run them in parallel, but I imagine that is not the best approach + # for tracking the energy used for each miner. + miner_processing_tasks = [] + for miner_id in optimization_unit.target_miner_ids: + miner_processing_tasks.append( + self._process_single_miner_in_unit( + optimization_unit=optimization_unit, + policy=policy, + context=context, + miner_id=miner_id, + notifiers=unit_notifiers, + ) + ) + await asyncio.gather(*miner_processing_tasks, return_exceptions=False) + + if self.logger: + self.logger.debug( + f"Finished processing for optimization unit '{optimization_unit.name}'. " + f"{len(miner_processing_tasks)} miners controlled." + ) + + async def _process_single_miner_in_unit( + self, + optimization_unit: EnergyOptimizationUnit, + policy: OptimizationPolicy, + context: DecisionalContext, + miner_id: EntityId, + notifiers: List[NotificationPort], + ): + # --- Miner --- + miner: Optional[Miner] = None + miner = self.miner_repo.get_by_id(miner_id) + if not miner: + if self.logger: + self.logger.error( + f"Miner {miner_id} in optimization unit '{optimization_unit.name}' not found in repository." + ) + message = f"Miner {miner_id} not found in optimization unit '{optimization_unit.name}'." + await self._notify_unit( + notifiers, + f"Optimizer Error ({optimization_unit.name})", + message, + ) + return + + # --- Miner Controller (via feature ports) --- + status_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) + if not status_port or not isinstance(status_port, StatusMonitorPort): + if self.logger: + self.logger.error(f"No status monitor available for miner {miner_id}. Cannot control miner.") + await self._notify_unit( + notifiers, + f"Optimizer Error ({optimization_unit.name} / {miner_id})", + "Status monitor unavailable.", + ) + return + + mining_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MINING_CONTROL) + + if not mining_port: + if self.logger: + self.logger.error( + f"No mining control port available " + f"for miner {miner_id} in optimization unit " + f"'{optimization_unit.name}'. Cannot control miner." + ) + await self._notify_unit( + notifiers, + f"Optimizer Error ({optimization_unit.name} / {miner_id})", + "Miner controller unavailable.", + ) + return + + # Get current status and make decision + try: + # Query current state via feature ports + current_status = await status_port.get_status() + + operational_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.OPERATIONAL_MONITORING + ) + blocks_found = None + system_uptime = None + if operational_port and isinstance(operational_port, OperationalMonitorPort): + blocks_found = await operational_port.get_blocks_found() + system_uptime = await operational_port.get_system_uptime() + + hashrate_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.HASHRATE_MONITORING + ) + current_hashrate = None + if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): + current_hashrate = await hashrate_port.get_hashrate() + + power_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) + current_power = None + if power_port and isinstance(power_port, PowerMonitorPort): + current_power = await power_port.get_power() + + # Build the miner state snapshot + miner_state = MinerStateSnapshot( + status=current_status, + hash_rate=current_hashrate, + power_consumption=current_power, + blocks_found=blocks_found, + system_uptime=system_uptime, + ) + + # Creates a copy of the context with the miner included, so that the policy + # can access miner-specific data, without modifying the original context. + # This is important to keep the context consistent across all miners in + # the unit. + decisional_context = DecisionalContext( + energy_source=context.energy_source, + energy_state=context.energy_state, + forecast=context.forecast, + home_load=context.home_load, + mining_performance=context.mining_performance, + sun=context.sun, + miner=miner, # Static config + miner_state=miner_state, # Runtime state snapshot + ) + + # Create the rule engine instance + rule_engine: Optional[RuleEngine] = None + try: + rule_engine = self.adapter_service.get_rule_engine() + except Exception as e: + if self.logger: + self.logger.critical(f"Error getting rule engine: {e}") + if not rule_engine: + if self.logger: + self.logger.error( + f"Rule engine not available for optimization unit " + f"'{optimization_unit.name}'. Cannot process policy." + ) + await self._notify_unit( + notifiers, + f"Optimizer Error ({optimization_unit.name} / {miner_id})", + "Rule engine unavailable.", + ) + return + + decision = policy.decide_next_action(decisional_context=decisional_context, rule_engine=rule_engine) + if self.logger: + self.logger.info( + f"Optimization unit '{optimization_unit.name}', " + f"Miner {miner_id}: Status={current_status.name}, " + f"Policy='{policy.name}', Decision={decision.name}" + ) + + # Publish rule engaged event + if self._event_bus: + await self._event_bus.publish( + RuleEngagedEvent( + optimization_unit_id=optimization_unit.id, + optimization_unit_name=optimization_unit.name, + policy_id=policy.id, + policy_name=policy.name, + miner_id=miner_id, + decision=decision, + miner_status=current_status.name, + ) + ) + + await self._execute_miner_decision( + mining_port, + status_port, + miner_id, + decision, + current_status, + notifiers, + optimization_unit.name, + ) + + except (MinerError, PolicyError) as e: + if self.logger: + self.logger.error( + f"Domain error processing miner {miner_id} in optimization unit '{optimization_unit.name}': {e}" + ) + await self._notify_unit( + notifiers, + f"Optimizer Error ({optimization_unit.name} / {miner_id})", + f"Domain error: {e}", + ) + except Exception as e: # Other exceptions (e.g. IO from the controller) + if self.logger: + if self.logger: + self.logger.error( + f"Unexpected error processing miner {miner_id} " + f"in optimization unit '{optimization_unit.name}': {e}" + ) + await self._notify_unit( + notifiers, + f"Optimizer Error ({optimization_unit.name} / {miner_id})", + f"Runtime error: {e}", + ) + + async def _execute_miner_decision( + self, + mining_port: MinerFeaturePort, + status_port: StatusMonitorPort, + miner_id: EntityId, + decision: MiningDecision, + current_status: MinerStatus, + notifiers: List[NotificationPort], + unit_name: str, + ): + action_taken = False + success = False + message_suffix = f" (Optimization Unit: {unit_name})" + + if decision == MiningDecision.START_MINING and current_status != MinerStatus.ON: + if self.logger: + self.logger.info(f"Executing START for miner {miner_id} via {type(mining_port).__name__}") + if isinstance(mining_port, MiningControlPort): + success = await mining_port.start_mining() + elif isinstance(mining_port, PowerControlPort): + success = await mining_port.power_on() + action_taken = True + if success: + await self._notify_unit( + notifiers, + f"Miner Started: {miner_id}", + f"Miner {miner_id} was started." + message_suffix, + ) + else: + await self._notify_unit( + notifiers, + f"Miner Start Failed: {miner_id}", + f"Attempt to start miner {miner_id} failed." + message_suffix, + ) + + elif decision == MiningDecision.STOP_MINING and current_status == MinerStatus.ON: + if self.logger: + self.logger.info(f"Executing STOP for miner {miner_id} via {type(mining_port).__name__}") + if isinstance(mining_port, MiningControlPort): + success = await mining_port.stop_mining() + elif isinstance(mining_port, PowerControlPort): + success = await mining_port.power_off() + action_taken = True + if success: + await self._notify_unit( + notifiers, + f"Miner Stopped: {miner_id}", + f"Miner {miner_id} was stopped." + message_suffix, + ) + else: + await self._notify_unit( + notifiers, + f"Miner Stop Failed: {miner_id}", + f"Attempt to stop miner {miner_id} failed." + message_suffix, + ) + + if action_taken: + if not success: + if self.logger: + self.logger.error( + f"Command {decision.name} for miner {miner_id} failed using controller {type(mining_port).__name__}." + ) + else: + miner = self.miner_repo.get_by_id(miner_id) + + # Get new miner state to publish in the event + new_status = await status_port.get_status() + + # Publish miner state changed event + if self._event_bus: + await self._event_bus.publish( + MinerStateChangedEvent( + miner_id=miner_id, + miner_name=miner.name if miner else "", + old_status=current_status, + new_status=new_status, + ) + ) + else: + if self.logger: + self.logger.debug( + f"No action taken for miner {miner_id} (Decision: {decision.name}, " + f"Current Status: {current_status.name})." + ) diff --git a/core/edge_mining/application/use_cases/__init__.py b/core/edge_mining/application/use_cases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/bootstrap.py b/core/edge_mining/bootstrap.py new file mode 100644 index 0000000..ea1db67 --- /dev/null +++ b/core/edge_mining/bootstrap.py @@ -0,0 +1,396 @@ +"""Bootstrap operations""" + +import os +from typing import Optional + +from edge_mining.adapters.domain.energy.repositories import ( + InMemoryEnergyMonitorRepository, + InMemoryEnergySourceRepository, + SqlAlchemyEnergyMonitorRepository, + SqlAlchemyEnergySourceRepository, + SqliteEnergyMonitorRepository, + SqliteEnergySourceRepository, +) +from edge_mining.adapters.domain.forecast.repositories import ( + InMemoryForecastProviderRepository, + SqlAlchemyForecastProviderRepository, + SqliteForecastProviderRepository, +) +from edge_mining.adapters.domain.home_load.repositories import ( + InMemoryEnergyLoadForecastProviderRepository, + InMemoryEnergyLoadHistoryProviderRepository, + InMemoryEnergyLoadHistoryRepository, + InMemoryHomeLoadsProfileRepository, + InMemoryLoadConsumptionModelRepository, + SqlAlchemyEnergyLoadForecastProviderRepository, + SqlAlchemyEnergyLoadHistoryProviderRepository, + SqlAlchemyEnergyLoadHistoryRepository, + SqlAlchemyHomeLoadsProfileRepository, + SqlAlchemyLoadConsumptionModelRepository, + SqliteEnergyLoadForecastProviderRepository, + SqliteEnergyLoadHistoryProviderRepository, + SqliteEnergyLoadHistoryRepository, + SqliteHomeLoadsProfileRepository, + SqliteLoadConsumptionModelRepository, +) +from edge_mining.adapters.domain.miner.repositories import ( + InMemoryMinerControllerRepository, + InMemoryMinerRepository, + SqlAlchemyMinerControllerRepository, + SqlAlchemyMinerRepository, + SqliteMinerControllerRepository, + SqliteMinerRepository, +) +from edge_mining.adapters.domain.notification.repositories import ( + InMemoryNotifierRepository, + SqlAlchemyNotifierRepository, + SqliteNotifierRepository, +) +from edge_mining.adapters.domain.optimization_unit.repositories import ( + InMemoryOptimizationUnitRepository, + SqlAlchemyOptimizationUnitRepository, + SqliteOptimizationUnitRepository, +) +from edge_mining.adapters.domain.performance.repositories import ( + InMemoryMiningPerformanceTrackerRepository, + SqlAlchemyMiningPerformanceTrackerRepository, + SqliteMiningPerformanceTrackerRepository, +) +from edge_mining.adapters.domain.policy.repositories import ( + InMemoryOptimizationPolicyRepository, + SqlAlchemyOptimizationPolicyRepository, + SqliteOptimizationPolicyRepository, + YamlOptimizationPolicyRepository, +) +from edge_mining.adapters.domain.user.repositories import ( + InMemorySettingsRepository, + SqlAlchemySettingsRepository, + SqliteSettingsRepository, +) +from edge_mining.adapters.infrastructure.external_services.repositories import ( + InMemoryExternalServiceRepository, + SqlAlchemyExternalServiceRepository, + SqliteExternalServiceRepository, +) +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.adapters.infrastructure.persistence.sqlite import BaseSqliteRepository +from edge_mining.adapters.infrastructure.event_bus.in_memory_event_bus import InMemoryEventBus +from edge_mining.adapters.infrastructure.sun.factories import AstralSunFactory +from edge_mining.application.interfaces import SunFactoryInterface +from edge_mining.application.services.adapter_service import AdapterService +from edge_mining.application.services.configuration_service import ConfigurationService +from edge_mining.application.services.home_load_history_service import HomeLoadHistoryService +from edge_mining.application.services.load_forecast_training_service import LoadForecastModelTrainingService +from edge_mining.application.services.miner_action_service import MinerActionService +from edge_mining.application.services.optimization_service import OptimizationService +from edge_mining.domain.energy.ports import ( + EnergyMonitorRepository, + EnergySourceRepository, +) +from edge_mining.domain.forecast.ports import ForecastProviderRepository +from edge_mining.domain.home_load.ports import ( + EnergyLoadForecastProviderRepository, + EnergyLoadHistoryProviderRepository, + EnergyLoadHistoryRepository, + HomeLoadsProfileRepository, + LoadConsumptionModelRepository, +) +from edge_mining.domain.miner.ports import MinerControllerRepository, MinerRepository +from edge_mining.domain.notification.ports import NotifierRepository +from edge_mining.domain.optimization_unit.ports import EnergyOptimizationUnitRepository +from edge_mining.domain.performance.ports import MiningPerformanceTrackerRepository +from edge_mining.domain.policy.ports import OptimizationPolicyRepository +from edge_mining.shared.external_services.ports import ExternalServiceRepository +from edge_mining.shared.infrastructure import PersistenceSettings, Services +from edge_mining.shared.logging.port import LoggerPort +from edge_mining.shared.settings.common import PersistenceAdapter +from edge_mining.shared.settings.ports import SettingsRepository +from edge_mining.shared.settings.settings import AppSettings + + +def configure_persistence(logger: LoggerPort, settings: AppSettings) -> PersistenceSettings: + """ + Configures the persistence layer based on the settings. + """ + logger.debug("Configuring persistence...") + + persistence_adapter: PersistenceAdapter = PersistenceAdapter(settings.persistence_adapter) + policies_persistence_adapter: PersistenceAdapter = PersistenceAdapter(settings.policies_persistence_adapter) + + # Initialize SQLite DB base repository if needed + sqlite_db: Optional[BaseSqliteRepository] = None + if PersistenceAdapter.SQLITE in [ + persistence_adapter, + policies_persistence_adapter, + ]: + db_path = settings.db_path + db_dir = os.path.dirname(db_path) + if db_dir and not os.path.exists(db_dir): + logger.debug(f"Creating database directory: {db_dir}") + os.makedirs(db_dir, exist_ok=True) + + logger.debug(f"Using SQLite persistence adapter (DB: {db_path}).") + sqlite_db = BaseSqliteRepository(db_path=db_path, logger=logger) + + # Initialize SQLAlchemy DB base repository if needed + sqlalchemy_db: Optional[BaseSQLAlchemyRepository] = None + if PersistenceAdapter.SQLALCHEMY in [ + persistence_adapter, + policies_persistence_adapter, + ]: + db_url = settings.db_path + if db_url.startswith("sqlite:///"): + db_dir = os.path.dirname(db_url.replace("sqlite:///", "")) + if db_dir and not os.path.exists(db_dir): + logger.debug(f"Creating database directory: {db_dir}") + os.makedirs(db_dir, exist_ok=True) + + logger.debug(f"Using SQLAlchemy persistence adapter (DB URL: {db_url}).") + sqlalchemy_db = BaseSQLAlchemyRepository( + db_path=db_url, + logger=logger, + run_migrations=settings.run_migrations_on_startup, + backup_before_migration=settings.backup_before_migration, + ) + + # Initialize database schema (migrations + tables) + sqlalchemy_db.initialize_database() + + # Initialize repositories based on the selected persistence adapter + energy_source_repo: EnergySourceRepository + energy_monitor_repo: EnergyMonitorRepository + miner_repo: MinerRepository + miner_controller_repo: MinerControllerRepository + forecast_provider_repo: ForecastProviderRepository + notifier_repo: NotifierRepository + mining_performance_tracker_repo: MiningPerformanceTrackerRepository + settings_repo: SettingsRepository + home_profile_repo: HomeLoadsProfileRepository + energy_load_forecast_provider_repo: EnergyLoadForecastProviderRepository + energy_load_history_provider_repo: EnergyLoadHistoryProviderRepository + home_load_history_repo: EnergyLoadHistoryRepository + load_consumption_model_repo: LoadConsumptionModelRepository + optimization_unit_repo: EnergyOptimizationUnitRepository + external_service_repo: ExternalServiceRepository + + if persistence_adapter == PersistenceAdapter.IN_MEMORY: + # Pre-populate in-memory repos with some test data + # (used for debug or development) + energy_source_repo = InMemoryEnergySourceRepository() + energy_monitor_repo = InMemoryEnergyMonitorRepository() + miner_repo = InMemoryMinerRepository() + miner_controller_repo = InMemoryMinerControllerRepository() + forecast_provider_repo = InMemoryForecastProviderRepository() + notifier_repo = InMemoryNotifierRepository() + mining_performance_tracker_repo = InMemoryMiningPerformanceTrackerRepository() + settings_repo = InMemorySettingsRepository() + home_profile_repo = InMemoryHomeLoadsProfileRepository() + energy_load_forecast_provider_repo = InMemoryEnergyLoadForecastProviderRepository() + energy_load_history_provider_repo = InMemoryEnergyLoadHistoryProviderRepository() + home_load_history_repo = InMemoryEnergyLoadHistoryRepository() + load_consumption_model_repo = InMemoryLoadConsumptionModelRepository() + optimization_unit_repo = InMemoryOptimizationUnitRepository() + external_service_repo = InMemoryExternalServiceRepository() + + logger.debug("Using InMemory persistence adapters.") + elif persistence_adapter == PersistenceAdapter.SQLITE: + if not sqlite_db: + raise ValueError( + "SQLite DB repository is not initialized. Ensure that the persistence adapter is set to SQLITE." + ) + + # Instantiate all SQLite repositories passing the DB base + + energy_source_repo = SqliteEnergySourceRepository(db=sqlite_db) + energy_monitor_repo = SqliteEnergyMonitorRepository(db=sqlite_db) + miner_repo = SqliteMinerRepository(db=sqlite_db) + miner_controller_repo = SqliteMinerControllerRepository(db=sqlite_db) + forecast_provider_repo = SqliteForecastProviderRepository(db=sqlite_db) + notifier_repo = SqliteNotifierRepository(db=sqlite_db) + mining_performance_tracker_repo = SqliteMiningPerformanceTrackerRepository(db=sqlite_db) + settings_repo = SqliteSettingsRepository(db=sqlite_db) + home_profile_repo = SqliteHomeLoadsProfileRepository(db=sqlite_db) + energy_load_forecast_provider_repo = SqliteEnergyLoadForecastProviderRepository(db=sqlite_db) + energy_load_history_provider_repo = SqliteEnergyLoadHistoryProviderRepository(db=sqlite_db) + home_load_history_repo = SqliteEnergyLoadHistoryRepository(db=sqlite_db) + load_consumption_model_repo = SqliteLoadConsumptionModelRepository(db=sqlite_db) + optimization_unit_repo = SqliteOptimizationUnitRepository(db=sqlite_db) + external_service_repo = SqliteExternalServiceRepository(db=sqlite_db) + + # user_repo: UserRepository = SqliteUserRepository( + # db_path=db_path, logger=logger + # ) # If implemented + elif persistence_adapter == PersistenceAdapter.SQLALCHEMY: + if not sqlalchemy_db: + raise ValueError( + "SQLAlchemy DB repository is not initialized. Ensure that the persistence adapter is set to SQLALCHEMY." + ) + + # Instantiate all SQLAlchemy repositories passing the DB base + energy_source_repo = SqlAlchemyEnergySourceRepository(db=sqlalchemy_db) + energy_monitor_repo = SqlAlchemyEnergyMonitorRepository(db=sqlalchemy_db) + miner_repo = SqlAlchemyMinerRepository(db=sqlalchemy_db) + miner_controller_repo = SqlAlchemyMinerControllerRepository(db=sqlalchemy_db) + forecast_provider_repo = SqlAlchemyForecastProviderRepository(db=sqlalchemy_db) + notifier_repo = SqlAlchemyNotifierRepository(db=sqlalchemy_db) + mining_performance_tracker_repo = SqlAlchemyMiningPerformanceTrackerRepository(db=sqlalchemy_db) + settings_repo = SqlAlchemySettingsRepository(db=sqlalchemy_db) + home_profile_repo = SqlAlchemyHomeLoadsProfileRepository(db=sqlalchemy_db) + energy_load_forecast_provider_repo = SqlAlchemyEnergyLoadForecastProviderRepository(db=sqlalchemy_db) + energy_load_history_provider_repo = SqlAlchemyEnergyLoadHistoryProviderRepository(db=sqlalchemy_db) + home_load_history_repo = SqlAlchemyEnergyLoadHistoryRepository(db=sqlalchemy_db) + load_consumption_model_repo = SqlAlchemyLoadConsumptionModelRepository(db=sqlalchemy_db) + optimization_unit_repo = SqlAlchemyOptimizationUnitRepository(db=sqlalchemy_db) + external_service_repo = SqlAlchemyExternalServiceRepository(db=sqlalchemy_db) + + # user_repo: UserRepository = SqliteUserRepository( + # db_path=db_path, logger=logger + # ) # If implemented + else: + raise ValueError(f"Unsupported persistence_adapter: {settings.persistence_adapter}") + + # Initialize specific policies repositories based on the selected + # persistence adapter + policy_repo: OptimizationPolicyRepository + if policies_persistence_adapter == PersistenceAdapter.IN_MEMORY: + policy_repo = InMemoryOptimizationPolicyRepository() + + logger.debug("Using InMemory policies persistence adapter.") + elif policies_persistence_adapter == PersistenceAdapter.SQLITE: + if not sqlite_db: + raise ValueError( + "SQLite DB repository is not initialized. " + "Ensure that the policies persistence adapter is set to SQLITE." + ) + policy_repo = SqliteOptimizationPolicyRepository(db=sqlite_db) + + logger.debug("Using SQLite policies persistence adapter.") + elif policies_persistence_adapter == PersistenceAdapter.SQLALCHEMY: + if not sqlalchemy_db: + raise ValueError( + "SQLAlchemy DB repository is not initialized. " + "Ensure that the policies persistence adapter is set to SQLALCHEMY." + ) + policy_repo = SqlAlchemyOptimizationPolicyRepository(db=sqlalchemy_db) + + logger.debug("Using SQLAlchemy policies persistence adapter.") + elif policies_persistence_adapter == PersistenceAdapter.YAML: + policy_repo = YamlOptimizationPolicyRepository(policies_directory=settings.yaml_policies_dir, logger=logger) + logger.debug("Using YAML policies persistence adapter.") + + persistence_settings: PersistenceSettings = PersistenceSettings( + energy_source_repo=energy_source_repo, + energy_monitor_repo=energy_monitor_repo, + miner_repo=miner_repo, + miner_controller_repo=miner_controller_repo, + forecast_provider_repo=forecast_provider_repo, + home_profile_repo=home_profile_repo, + energy_load_forecast_provider_repo=energy_load_forecast_provider_repo, + energy_load_history_provider_repo=energy_load_history_provider_repo, + home_load_history_repo=home_load_history_repo, + load_consumption_model_repo=load_consumption_model_repo, + notifier_repo=notifier_repo, + optimization_unit_repo=optimization_unit_repo, + policy_repo=policy_repo, + mining_performance_tracker_repo=mining_performance_tracker_repo, + external_service_repo=external_service_repo, + settings_repo=settings_repo, + ) + + return persistence_settings + + +def configure_dependencies(logger: LoggerPort, settings: AppSettings) -> Services: + """ + Performs Dependency Injection - Creates instances of adapters and services. + Returns the main application services. + """ + + logger.debug("Configuring dependencies...") + + # --- Factories --- + sun_factory: SunFactoryInterface = AstralSunFactory( + latitude=settings.latitude, + longitude=settings.longitude, + timezone=settings.timezone, + ) + + # --- Persistence --- + persistence_settings: PersistenceSettings = configure_persistence(logger, settings) + + logger.debug("Instantiating application services...") + + # --- Event Bus --- + event_bus = InMemoryEventBus(logger) + + adapter_service = AdapterService( + energy_monitor_repo=persistence_settings.energy_monitor_repo, + miner_controller_repo=persistence_settings.miner_controller_repo, + miner_repo=persistence_settings.miner_repo, + notifier_repo=persistence_settings.notifier_repo, + forecast_provider_repo=persistence_settings.forecast_provider_repo, + energy_load_forecast_provider_repo=persistence_settings.energy_load_forecast_provider_repo, + energy_load_history_provider_repo=persistence_settings.energy_load_history_provider_repo, + home_load_history_repo=persistence_settings.home_load_history_repo, + mining_performance_tracker_repo=persistence_settings.mining_performance_tracker_repo, + external_service_repo=persistence_settings.external_service_repo, + event_bus=event_bus, + logger=logger, + load_consumption_model_repo=persistence_settings.load_consumption_model_repo, + ) + + optimization_service = OptimizationService( + optimization_unit_repo=persistence_settings.optimization_unit_repo, + energy_source_repo=persistence_settings.energy_source_repo, + policy_repo=persistence_settings.policy_repo, + miner_repo=persistence_settings.miner_repo, + home_loads_repo=persistence_settings.home_profile_repo, + adapter_service=adapter_service, + sun_factory=sun_factory, + event_bus=event_bus, + logger=logger, + forecast_mix_alpha=settings.forecast_mix_alpha, + forecast_mix_beta=settings.forecast_mix_beta, + ) + + miner_action_service = MinerActionService( + adapter_service=adapter_service, + miner_repo=persistence_settings.miner_repo, + event_bus=event_bus, + logger=logger, + ) + + config_service = ConfigurationService( + persistence_settings=persistence_settings, + event_bus=event_bus, + logger=logger, + adapter_service=adapter_service, + ) + + home_load_history_service = HomeLoadHistoryService( + home_loads_repo=persistence_settings.home_profile_repo, + home_load_history_repo=persistence_settings.home_load_history_repo, + adapter_service=adapter_service, + event_bus=event_bus, + logger=logger, + ) + + load_forecast_training_service = LoadForecastModelTrainingService( + home_loads_repo=persistence_settings.home_profile_repo, + history_repo=persistence_settings.home_load_history_repo, + model_repo=persistence_settings.load_consumption_model_repo, + logger=logger, + ) + + services = Services( + adapter_service=adapter_service, + optimization_service=optimization_service, + miner_action_service=miner_action_service, + configuration_service=config_service, + home_load_history_service=home_load_history_service, + load_forecast_training_service=load_forecast_training_service, + event_bus=event_bus, + ) + + logger.debug("Dependency configuration complete.") + return services diff --git a/core/edge_mining/domain/__init__.py b/core/edge_mining/domain/__init__.py new file mode 100644 index 0000000..9dad8f5 --- /dev/null +++ b/core/edge_mining/domain/__init__.py @@ -0,0 +1 @@ +"""Collection of domains for the Edge Mining application.""" diff --git a/core/edge_mining/domain/common.py b/core/edge_mining/domain/common.py new file mode 100644 index 0000000..d00a656 --- /dev/null +++ b/core/edge_mining/domain/common.py @@ -0,0 +1,70 @@ +"""Collection of Common Objects for the Edge Mining application domain.""" + +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import Any, NewType, Tuple + +# Example Value Objects using NewType for stronger typing +Watts = NewType("Watts", float) +WattHours = NewType("WattHours", float) +Percentage = NewType("Percentage", float) # 0.0 to 100.0 +Timestamp = NewType("Timestamp", datetime) +EntityId = NewType("EntityId", uuid.UUID) + +TimePeriod = Tuple[datetime, datetime] + + +def utc_now_timestamp() -> "Timestamp": + """Return the current UTC time as a Timestamp.""" + return Timestamp(datetime.now(timezone.utc)) + + +@dataclass(frozen=True) +class ValueObject: + """Base class for value objects.""" + + pass # Base class for value objects if needed + + +@dataclass +class Entity: + """Base class for entities.""" + + id: EntityId = field(default_factory=lambda: EntityId(uuid.uuid4())) + + +@dataclass +class AggregateRoot: + """Base class for aggregate roots.""" + + id: EntityId = field(default_factory=lambda: EntityId(uuid.uuid4())) + + +class AdapterType(Enum): + """Base class for adapter types.""" + + pass # Base class for adapter types if needed + + +@dataclass +class DomainEvent: + """Base class for all domain events.""" + + event_id: str = field(default_factory=lambda: str(uuid.uuid4())) + occurred_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + def to_dict(self) -> dict[str, Any]: + """Serialization for WebSocket/logging. Override in subtypes if needed.""" + from dataclasses import asdict + + result = asdict(self) + result["occurred_at"] = self.occurred_at.isoformat() + result["event_type"] = self.event_type + return result + + @property + def event_type(self) -> str: + """Event type derived from class name. Useful for serialization.""" + return self.__class__.__name__ diff --git a/core/edge_mining/domain/energy/__init__.py b/core/edge_mining/domain/energy/__init__.py new file mode 100644 index 0000000..8ebe5fd --- /dev/null +++ b/core/edge_mining/domain/energy/__init__.py @@ -0,0 +1 @@ +"""Energy System Monitoring subdomain.""" diff --git a/core/edge_mining/domain/energy/common.py b/core/edge_mining/domain/energy/common.py new file mode 100644 index 0000000..7d474f4 --- /dev/null +++ b/core/edge_mining/domain/energy/common.py @@ -0,0 +1,23 @@ +"""Collection of Common Objects for the Energy System Monitoring domain of the Edge Mining application.""" + +from enum import Enum + +from edge_mining.domain.common import AdapterType + + +class EnergySourceType(Enum): + """Enum for the different energy sources.""" + + SOLAR = "solar" + WIND = "wind" + GRID = "grid" + HYDROELECTRIC = "hydroelectric" + OTHER = "other" + + +class EnergyMonitorAdapter(AdapterType): + """Enum for the different energy monitor adapters.""" + + DUMMY_SOLAR = "dummy_solar" + HOME_ASSISTANT_API = "home_assistant_api" + HOME_ASSISTANT_MQTT = "home_assistant_mqtt" diff --git a/core/edge_mining/domain/energy/entities.py b/core/edge_mining/domain/energy/entities.py new file mode 100644 index 0000000..ecf9529 --- /dev/null +++ b/core/edge_mining/domain/energy/entities.py @@ -0,0 +1,66 @@ +"""Collection of Entities for the Energy System Monitoring domain of the Edge Mining application.""" + +from dataclasses import dataclass +from typing import Optional + +from edge_mining.domain.common import Entity, EntityId, Watts +from edge_mining.domain.energy.common import EnergyMonitorAdapter, EnergySourceType +from edge_mining.domain.energy.value_objects import Battery, Grid +from edge_mining.shared.interfaces.config import EnergyMonitorConfig + + +@dataclass +class EnergySource(Entity): + """Entity for an energy source.""" + + name: str = "" + type: EnergySourceType = EnergySourceType.SOLAR + nominal_power_max: Optional[Watts] = None + storage: Optional[Battery] = None + grid: Optional[Grid] = None + external_source: Optional[Watts] = None # e.g., external generator + + energy_monitor_id: Optional[EntityId] = None # Energy monitor to be used + forecast_provider_id: Optional[EntityId] = None # Forecast provider to be used + + def connect_to_grid(self, grid: Grid): + """Connect to the grid.""" + self.grid = grid + + def disconnect_from_grid(self): + """Disconnect from the grid.""" + self.grid = None + + def connect_to_external_source(self, external_source: Watts): + """Connect to the external source.""" + self.external_source = external_source + + def disconnect_from_external_source(self): + """Disconnect from the external source.""" + self.external_source = None + + def connect_to_storage(self, storage: Battery): + """Connect to the storage.""" + self.storage = storage + + def disconnect_from_storage(self): + """Disconnect from the storage.""" + self.storage = None + + def use_energy_monitor(self, energy_monitor_id: EntityId): + """Use the energy monitor.""" + self.energy_monitor_id = energy_monitor_id + + def use_forecast_provider(self, forecast_provider_id: EntityId): + """Use a forecast provider.""" + self.forecast_provider_id = forecast_provider_id + + +@dataclass +class EnergyMonitor(Entity): + """Entity for an energy monitor.""" + + name: str = "" + adapter_type: EnergyMonitorAdapter = EnergyMonitorAdapter.DUMMY_SOLAR + config: Optional[EnergyMonitorConfig] = None + external_service_id: Optional[EntityId] = None diff --git a/core/edge_mining/domain/energy/events.py b/core/edge_mining/domain/energy/events.py new file mode 100644 index 0000000..5ad4eb9 --- /dev/null +++ b/core/edge_mining/domain/energy/events.py @@ -0,0 +1,17 @@ +"""Energy domain events.""" + +from dataclasses import dataclass +from typing import Optional + +from edge_mining.domain.common import DomainEvent, EntityId +from edge_mining.domain.energy.value_objects import EnergyStateSnapshot + + +@dataclass +class EnergyStateSnapshotUpdatedEvent(DomainEvent): + """Event emitted when a new energy state snapshot is read.""" + + optimization_unit_id: Optional[EntityId] = None + optimization_unit_name: str = "" + energy_source_id: Optional[EntityId] = None + energy_state_snapshot: Optional[EnergyStateSnapshot] = None diff --git a/core/edge_mining/domain/energy/exceptions.py b/core/edge_mining/domain/energy/exceptions.py new file mode 100644 index 0000000..ccf83d5 --- /dev/null +++ b/core/edge_mining/domain/energy/exceptions.py @@ -0,0 +1,57 @@ +"""Collection of Exceptions.""" + +from edge_mining.domain.exceptions import DomainError + + +class EnergyError(DomainError): + """Base class for energy-related errors.""" + + pass + + +class EnergyMonitorError(EnergyError): + """Errors related to energy monitors.""" + + pass + + +class EnergyMonitorNotFoundError(EnergyMonitorError): + """Energy monitor not found.""" + + pass + + +class EnergyMonitorAlreadyExistsError(EnergyMonitorError): + """Energy monitor already exists.""" + + pass + + +class EnergyMonitorConfigurationError(EnergyMonitorError): + """Error with the configuration.""" + + pass + + +class EnergySourceError(EnergyError): + """Errors related to energy sources.""" + + pass + + +class EnergySourceNotFoundError(EnergySourceError): + """Energy source not found.""" + + pass + + +class EnergySourceAlreadyExistsError(EnergySourceError): + """Energy source already exists.""" + + pass + + +class EnergySourceConfigurationError(EnergySourceError): + """Errors related to energy source configuration.""" + + pass diff --git a/core/edge_mining/domain/energy/ports.py b/core/edge_mining/domain/energy/ports.py new file mode 100644 index 0000000..4aa3c61 --- /dev/null +++ b/core/edge_mining/domain/energy/ports.py @@ -0,0 +1,85 @@ +"""Collection of Ports for the Energy System Monitoring domain of the Edge Mining application.""" + +from abc import ABC, abstractmethod +from typing import List, Optional + +from edge_mining.domain.common import EntityId +from edge_mining.domain.energy.common import EnergyMonitorAdapter +from edge_mining.domain.energy.entities import EnergyMonitor, EnergySource +from edge_mining.domain.energy.value_objects import EnergyStateSnapshot + + +class EnergyMonitorPort(ABC): + """Port for the Energy Monitor.""" + + def __init__(self, energy_monitor_type: EnergyMonitorAdapter): + """Initialize the Energy Monitor.""" + self.energy_monitor_type = energy_monitor_type + + @abstractmethod + async def get_current_energy_state(self) -> Optional[EnergyStateSnapshot]: + """Fetches the latest energy readings from the plant.""" + raise NotImplementedError + + +class EnergySourceRepository(ABC): + """Port for the Energy Source Repository.""" + + @abstractmethod + def add(self, energy_source: EnergySource) -> None: + """Adds a new energy source to the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_id(self, energy_source_id: EntityId) -> Optional[EnergySource]: + """Retrieves an energy source by its ID.""" + raise NotImplementedError + + @abstractmethod + def get_all(self) -> List[EnergySource]: + """Retrieves all energy sources from the repository.""" + raise NotImplementedError + + @abstractmethod + def update(self, energy_source: EnergySource) -> None: + """Updates an energy source in the repository.""" + raise NotImplementedError + + @abstractmethod + def remove(self, energy_source_id: EntityId) -> None: + """Removes an energy source from the repository.""" + raise NotImplementedError + + +class EnergyMonitorRepository(ABC): + """Port for the Energy Monitor Repository.""" + + @abstractmethod + def add(self, energy_monitor: EnergyMonitor) -> None: + """Adds a new energy monitor to the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_id(self, energy_monitor_id: EntityId) -> Optional[EnergyMonitor]: + """Retrieves an energy monitor by its ID.""" + raise NotImplementedError + + @abstractmethod + def get_all(self) -> List[EnergyMonitor]: + """Retrieves all energy monitors from the repository.""" + raise NotImplementedError + + @abstractmethod + def update(self, energy_monitor: EnergyMonitor) -> None: + """Updates an energy monitor in the repository.""" + raise NotImplementedError + + @abstractmethod + def remove(self, energy_monitor_id: EntityId) -> None: + """Removes an energy monitor from the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_external_service_id(self, external_service_id: EntityId) -> List[EnergyMonitor]: + """Retrieves a list of energy monitors by its associated external service ID.""" + raise NotImplementedError diff --git a/core/edge_mining/domain/energy/value_objects.py b/core/edge_mining/domain/energy/value_objects.py new file mode 100644 index 0000000..1f9b6c2 --- /dev/null +++ b/core/edge_mining/domain/energy/value_objects.py @@ -0,0 +1,85 @@ +"""Collection of Value Objects for the Energy System Monitoring domain of the Edge Mining application.""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional + +from edge_mining.domain.common import ( + Percentage, + Timestamp, + ValueObject, + WattHours, + Watts, +) + + +@dataclass(frozen=True) +class Battery(ValueObject): + """Value Object for a battery.""" + + nominal_capacity: WattHours + + +@dataclass(frozen=True) +class Grid(ValueObject): + """Value Object for a grid.""" + + contracted_power: Watts + + +@dataclass(frozen=True) +class LoadState(ValueObject): + """Value Object for an energy load state.""" + + current_power: Watts + timestamp: Timestamp = field(default_factory=Timestamp(datetime.now())) + + +@dataclass(frozen=True) +class BatteryState(ValueObject): + """Value Object for a battery state.""" + + state_of_charge: Percentage + remaining_capacity: Optional[WattHours] + current_power: Watts # Positive when charging, negative when discharging + timestamp: Timestamp = field(default_factory=Timestamp(datetime.now())) + + @property + def charging_power(self) -> Watts: + """Returns the power being used to charge the battery.""" + return max(self.current_power, Watts(0.0)) + + @property + def discharging_power(self) -> Watts: + """Returns the power being used to discharge the battery.""" + return max(Watts(-self.current_power), Watts(0.0)) + + +@dataclass(frozen=True) +class GridState(ValueObject): + """Value Object for a grid state.""" + + current_power: Watts # Positive importing, negative exporting + timestamp: Timestamp = field(default_factory=Timestamp(datetime.now())) + + @property + def importing_power(self) -> Watts: + """Returns the power being imported from the grid.""" + return max(self.current_power, Watts(0.0)) + + @property + def exporting_power(self) -> Watts: + """Returns the power being exported to the grid.""" + return max(Watts(-self.current_power), Watts(0.0)) + + +@dataclass(frozen=True) +class EnergyStateSnapshot(ValueObject): + """Value Object for an energy state snapshot.""" + + production: Watts + consumption: LoadState # Load excluding miner + battery: Optional[BatteryState] # Can be None if no battery is present + grid: Optional[GridState] # Can be None if no grid is present (e.g., off-grid) + external_source: Optional[Watts] # For example, external generator -> future use + timestamp: Timestamp = field(default_factory=Timestamp(datetime.now())) diff --git a/core/edge_mining/domain/exceptions.py b/core/edge_mining/domain/exceptions.py new file mode 100644 index 0000000..66b4c47 --- /dev/null +++ b/core/edge_mining/domain/exceptions.py @@ -0,0 +1,13 @@ +"""Collection of Exceptions for the Edge Mining application domain.""" + + +class DomainError(Exception): + """Base class for domain-specific errors.""" + + pass + + +class ConfigurationError(DomainError): + """Errors related to system configuration.""" + + pass diff --git a/core/edge_mining/domain/forecast/__init__.py b/core/edge_mining/domain/forecast/__init__.py new file mode 100644 index 0000000..9fb95e8 --- /dev/null +++ b/core/edge_mining/domain/forecast/__init__.py @@ -0,0 +1 @@ +"""Energy Forecast subdomain.""" diff --git a/core/edge_mining/domain/forecast/aggregate_root.py b/core/edge_mining/domain/forecast/aggregate_root.py new file mode 100644 index 0000000..59be1a5 --- /dev/null +++ b/core/edge_mining/domain/forecast/aggregate_root.py @@ -0,0 +1,168 @@ +""" "Collection of Aggregate Roots for the Forecast domain of the Edge Mining application.""" + +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import List, Optional + +from edge_mining.domain.common import AggregateRoot, Timestamp, WattHours, Watts +from edge_mining.domain.forecast.value_objects import ForecastInterval +from edge_mining.shared.timezone import now as timezone_now + + +@dataclass +class Forecast(AggregateRoot): + """Aggregate Root for the Forecast domain.""" + + timestamp: Timestamp = field(default_factory=Timestamp(datetime.now())) + intervals: List[ForecastInterval] = field(default_factory=list) + + @property + def next_hour_power(self) -> Optional[Watts]: + """Get the forecasted power for the next hour.""" + if not self.intervals: + return None + + # Sort intervals by start time + self.sort_intervals() + + # Find the first interval that starts in the next hour + next_hour_start = timezone_now() + timedelta(hours=1) + + for interval in self.intervals: + if interval.start <= next_hour_start < interval.end: + # Get the average power in this interval + if interval.power_points: + return interval.avg_power + + return None + + @property + def avg_next_4_hours_power(self) -> Watts: + """Get the average predicted power for the next 4 hours.""" + if not self.intervals: + return Watts(0.0) + + # Sort intervals by start time + self.sort_intervals() + + total_power = 0.0 + count = 0 + + # Calculate average power over the next 4 hours + now = timezone_now() + four_hours_later = now + timedelta(hours=4) + + for interval in self.intervals: + if interval.start < four_hours_later and interval.end > now: + total_power += interval.avg_power + count += 1 + + if count == 0: + return Watts(0.0) + + avg_power = total_power / count + return Watts(round(avg_power, 3)) + + @property + def next_hour_energy(self) -> WattHours: + """Get the predicted energy for the next hour.""" + if not self.intervals: + return WattHours(0.0) + + # Sort intervals by start time + self.sort_intervals() + + total_energy = WattHours(0.0) + + # Calculate energy for the next hour + now = timezone_now() + next_hour_start = now + timedelta(hours=1) + next_hour_end = next_hour_start + timedelta(hours=1) + + for interval in self.intervals: + if interval.start < next_hour_end and interval.end > next_hour_start: + # Calculate overlap window + overlap_start = max(interval.start, next_hour_start) + overlap_end = min(interval.end, next_hour_end) + if overlap_start < overlap_end: + # Calculate energy for the overlapping interval + overlap_duration_sec = (overlap_end - overlap_start).total_seconds() + interval_duration_sec = interval.duration.total_seconds() + + if interval_duration_sec > 0: + # Compute the energy ratio for the overlapping interval + ratio = overlap_duration_sec / interval_duration_sec + if interval.energy is not None: + # Ensure energy is not None before multiplying + total_energy = WattHours(total_energy + float(interval.energy) * ratio) + + return WattHours(round(total_energy, 3)) if total_energy > 0 else WattHours(0.0) + + def sort_intervals(self) -> None: + """Sort intervals and power points within them.""" + self.intervals.sort(key=lambda i: i.start) + for interval in self.intervals: + interval.power_points.sort(key=lambda p: p.timestamp) + + def get_power_at_time(self, time: datetime) -> Optional[Watts]: + """ + Get the forecasted power at a specific time, using linear interpolation if necessary. + """ + self.sort_intervals() # Ensure intervals are sorted + + total_points = [p for i in self.intervals for p in i.power_points] + if not total_points: + return None + + # Find the interval that contains the time + point_first = None + point_last = None + + for point in total_points: + if point.timestamp == time: + return point.power # Exact match + if point.timestamp < time: + point_first = point + elif point.timestamp > time: + point_last = point + break # If we found a point after the time, we can stop + + # If we don't have both points, we can't interpolate + if point_first is None or point_last is None: + return None + + # Linear interpolation + time_diff = (point_last.timestamp - point_first.timestamp).total_seconds() + time_diff_target = (time - point_first.timestamp).total_seconds() + if time_diff == 0: + return point_first.power + ratio = time_diff_target / time_diff + interpolated_power = point_first.power + (point_last.power - point_first.power) * ratio + interpolated_power = round(interpolated_power, 3) # Round to 3 decimal places + return Watts(interpolated_power) + + def get_energy_over_interval(self, start: Timestamp, end: Timestamp) -> Optional[WattHours]: + """Get the total energy forecasted over a specific time interval.""" + total_energy = WattHours(0.0) + + self.sort_intervals() # Ensure intervals are sorted + + for interval in self.intervals: + # Calculate overlap window + overlap_start = max(interval.start, start) + overlap_end = min(interval.end, end) + if overlap_start < overlap_end: + # Calculate energy for the overlapping interval + overlap_duration_sec = (overlap_end - overlap_start).total_seconds() + interval_duration_sec = interval.duration.total_seconds() + + if interval_duration_sec > 0: + # Compute the energy ratio for the overlapping interval + ratio = overlap_duration_sec / interval_duration_sec + if interval.energy is not None: + # Ensure energy is not None before multiplying + total_energy = WattHours(total_energy + interval.energy * ratio) + + if total_energy > 0: + return WattHours(round(total_energy, 3)) # Round to 3 decimal places + return None # No energy forecasted in the interval diff --git a/core/edge_mining/domain/forecast/common.py b/core/edge_mining/domain/forecast/common.py new file mode 100644 index 0000000..ea5ffc2 --- /dev/null +++ b/core/edge_mining/domain/forecast/common.py @@ -0,0 +1,12 @@ +""" +Common classes for the Energy Forecast domain of the Edge Mining application. +""" + +from edge_mining.domain.common import AdapterType + + +class ForecastProviderAdapter(AdapterType): + """Types of forecast provider adapter.""" + + DUMMY_SOLAR = "dummy_solar" + HOME_ASSISTANT_API = "home_assistant_api" diff --git a/core/edge_mining/domain/forecast/entities.py b/core/edge_mining/domain/forecast/entities.py new file mode 100644 index 0000000..fb4037a --- /dev/null +++ b/core/edge_mining/domain/forecast/entities.py @@ -0,0 +1,18 @@ +"""Collection of Entities for the Forcast domain of the Edge Mining application.""" + +from dataclasses import dataclass +from typing import Optional + +from edge_mining.domain.common import Entity, EntityId +from edge_mining.domain.forecast.common import ForecastProviderAdapter +from edge_mining.shared.interfaces.config import ForecastProviderConfig + + +@dataclass +class ForecastProvider(Entity): + """Entity for a forecast provider.""" + + name: str = "" + adapter_type: ForecastProviderAdapter = ForecastProviderAdapter.DUMMY_SOLAR + config: Optional[ForecastProviderConfig] = None + external_service_id: Optional[EntityId] = None diff --git a/core/edge_mining/domain/forecast/exceptions.py b/core/edge_mining/domain/forecast/exceptions.py new file mode 100644 index 0000000..2b880fb --- /dev/null +++ b/core/edge_mining/domain/forecast/exceptions.py @@ -0,0 +1,33 @@ +"""Collection of Exceptions.""" + +from edge_mining.domain.exceptions import DomainError + + +class ForecastError(DomainError): + """Base class for forecast-specific errors.""" + + pass + + +class ForecastProviderError(ForecastError): + """Errors related to forecast provider.""" + + pass + + +class ForecastProviderNotFoundError(ForecastProviderError): + """Forecast Provider not found.""" + + pass + + +class ForecastProviderAlreadyExistsError(ForecastProviderError): + """Forecast Provider already exists.""" + + pass + + +class ForecastProviderConfigurationError(ForecastProviderError): + """Error with the configuration.""" + + pass diff --git a/core/edge_mining/domain/forecast/ports.py b/core/edge_mining/domain/forecast/ports.py new file mode 100644 index 0000000..b96f3cf --- /dev/null +++ b/core/edge_mining/domain/forecast/ports.py @@ -0,0 +1,56 @@ +"""Collection of Ports for the Energy Forecast domain of the Edge Mining application.""" + +from abc import ABC, abstractmethod +from typing import List, Optional + +from edge_mining.domain.common import EntityId +from edge_mining.domain.forecast.aggregate_root import Forecast +from edge_mining.domain.forecast.common import ForecastProviderAdapter +from edge_mining.domain.forecast.entities import ForecastProvider + + +class ForecastProviderPort(ABC): + """Port for the Forecast Provider.""" + + def __init__(self, forecast_provider_type: ForecastProviderAdapter): + """Initialize the Forecast Provider.""" + self.forecast_provider_type = forecast_provider_type + + @abstractmethod + async def get_forecast(self) -> Optional[Forecast]: + """Fetches the energy production forecast.""" + raise NotImplementedError + + +class ForecastProviderRepository(ABC): + """Port for the Energy Monitor Repository.""" + + @abstractmethod + def add(self, forecast_provider: ForecastProvider) -> None: + """Adds a new forecast provider to the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_id(self, forecast_provider_id: EntityId) -> Optional[ForecastProvider]: + """Retrieves a forecast provider by its ID.""" + raise NotImplementedError + + @abstractmethod + def get_all(self) -> List[ForecastProvider]: + """Retrieves all forecast providers from the repository.""" + raise NotImplementedError + + @abstractmethod + def update(self, forecast_provider: ForecastProvider) -> None: + """Updates a forecast provider in the repository.""" + raise NotImplementedError + + @abstractmethod + def remove(self, forecast_provider_id: EntityId) -> None: + """Removes a forecast provider from the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_external_service_id(self, external_service_id: EntityId) -> List[ForecastProvider]: + """Retrieves a list of forecast providers by its associated external service ID.""" + raise NotImplementedError diff --git a/core/edge_mining/domain/forecast/value_objects.py b/core/edge_mining/domain/forecast/value_objects.py new file mode 100644 index 0000000..81e7767 --- /dev/null +++ b/core/edge_mining/domain/forecast/value_objects.py @@ -0,0 +1,100 @@ +"""Collection of Value Objects for the Energy Forecast domain of the Edge Mining application.""" + +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import List, Optional + +from edge_mining.domain.common import Timestamp, ValueObject, WattHours, Watts +from edge_mining.shared.timezone import now as timezone_now + + +@dataclass(frozen=True) +class Sun(ValueObject): + """Value Object for sunrise and sunset times.""" + + # The time in the morning when the sun is a specific number of degrees + # below the horizon. + dawn: datetime + # The time in the morning when the top of the sun breaks the horizon. + sunrise: datetime + # The time when the sun is at its highest point directly above the observer. + noon: datetime + # The time when the sun is at its lowest point. + midnight: datetime + # The time in the evening when the sun is about to disappear below the horizon. + sunset: datetime + # The time in the evening when the sun is a specific number of degrees + # below the horizon. + dusk: datetime + # The time when the sun is up i.e. between sunrise and sunset. + daylight: timedelta + # The time between astronomical dusk of one day and astronomical dawn of the next. + night: timedelta + # The time between dawn and sunrise or between sunset and dusk. + twilight: timedelta + + # The number of degrees clockwise from North at which the sun can be seen. + azimuth: Optional[float] = field(default=None) + # The angle of the sun down from directly above the observer. + zenith: Optional[float] = field(default=None) + # The number of degrees up from the horizon at which the sun can be seen. + elevation: Optional[float] = field(default=None) + + @property + def time_before_sunrise(self) -> Optional[timedelta]: + """Returns the time remaining until sunrise.""" + now = timezone_now() + if self.sunrise < now: + return None + return self.sunrise - now + + @property + def time_after_sunrise(self) -> Optional[timedelta]: + """Returns the time elapsed since sunrise.""" + return timezone_now() - self.sunrise + + @property + def time_before_sunset(self) -> Optional[timedelta]: + """Returns the time remaining until sunset.""" + now = timezone_now() + if self.sunset < now: + return None + return self.sunset - now + + @property + def time_after_sunset(self) -> Optional[timedelta]: + """Returns the time elapsed since sunset.""" + return timezone_now() - self.sunset + + +@dataclass(frozen=True) +class ForecastPowerPoint(ValueObject): + """Value Object for a single forecast power point.""" + + timestamp: Timestamp + power: Watts + + +@dataclass(frozen=True) +class ForecastInterval(ValueObject): + """Value Object for a forecast energy interval.""" + + start: Timestamp + end: Timestamp + energy: Optional[WattHours] = None + energy_remaining: Optional[WattHours] = None + power_points: List[ForecastPowerPoint] = field(default_factory=list) + + @property + def duration(self) -> timedelta: + """Calculate the duration of the interval""" + return self.end - self.start + + @property + def avg_power(self) -> Watts: + """Calculate the average power over the interval.""" + if not self.power_points: + return Watts(0.0) + + total_power = sum(point.power for point in self.power_points) + return Watts(total_power / len(self.power_points)) if total_power else Watts(0.0) diff --git a/core/edge_mining/domain/home_load/__init__.py b/core/edge_mining/domain/home_load/__init__.py new file mode 100644 index 0000000..9582955 --- /dev/null +++ b/core/edge_mining/domain/home_load/__init__.py @@ -0,0 +1 @@ +"""Home Consumption Analytics subdomain.""" diff --git a/core/edge_mining/domain/home_load/aggregate_roots.py b/core/edge_mining/domain/home_load/aggregate_roots.py new file mode 100644 index 0000000..d54f0a1 --- /dev/null +++ b/core/edge_mining/domain/home_load/aggregate_roots.py @@ -0,0 +1,44 @@ +""" +Collection of Aggregate Roots for the Home Consumption Analytics domain +of the Edge Mining application. +""" + +from dataclasses import dataclass, field +from typing import List + +from edge_mining.domain.common import AggregateRoot, EntityId +from edge_mining.domain.home_load.entities import LoadDevice +from edge_mining.domain.home_load.exceptions import HomeLoadsProfileAddDeviceError + + +@dataclass +class HomeLoadsProfile(AggregateRoot): + """Aggregate Root for the Home Loads.""" + + name: str = "Default Home Profile" + devices: List[LoadDevice] = field(default_factory=list) + + def __post_init__(self) -> None: + """Enforce the device-name uniqueness invariant on construction.""" + seen: set[str] = set() + for device in self.devices: + if device.name in seen: + raise HomeLoadsProfileAddDeviceError(f"Duplicate device name '{device.name}' in profile '{self.name}'.") + seen.add(device.name) + + def add_device(self, device: LoadDevice) -> None: + """Append a device enforcing name uniqueness within this profile.""" + if any(existing.name == device.name for existing in self.devices): + raise HomeLoadsProfileAddDeviceError( + f"A device named '{device.name}' already exists in profile '{self.name}'." + ) + self.devices.append(device) + + def remove_device(self, device_id: EntityId) -> LoadDevice: + """Remove a device by id; raises if not found.""" + for idx, existing in enumerate(self.devices): + if existing.id == device_id: + return self.devices.pop(idx) + from edge_mining.domain.home_load.exceptions import HomeLoadsProfileDeviceNotFoundError + + raise HomeLoadsProfileDeviceNotFoundError(f"Device with id {device_id} not found in profile '{self.name}'.") diff --git a/core/edge_mining/domain/home_load/common.py b/core/edge_mining/domain/home_load/common.py new file mode 100644 index 0000000..7fcb4c8 --- /dev/null +++ b/core/edge_mining/domain/home_load/common.py @@ -0,0 +1,51 @@ +""" +Common classes for the Home Load domain of the Edge Mining application. +""" + +from enum import Enum + +from edge_mining.domain.common import AdapterType + + +class LoadDeviceCategory(Enum): + """ + Categories for load devices based on consumption patterns. + + CONTROLLABLE: + Programmable loads like washing machines or dishwashers. + Consumption concentrated in specific time windows, and the loads have predictable patterns + based on user-selected start times. + CONTINUOUS: + Always-on or semi-continuous loads like fridges or boilers. + Repetitive pattern on hourly/daily basis. They operate almost constantly with activation/deactivation cycles. + SEASONAL: + Weather-dependent loads like heating or air conditioning (heating, AC). + Heavily dependent on season and external temperature. + OCCASIONAL: + Infrequent or irregular usage devices (vacuum cleaner, power tools). + """ + + CONTROLLABLE = "controllable" + CONTINUOUS = "continuous" + SEASONAL = "seasonal" + OCCASIONAL = "occasional" + + +class EnergyLoadForecastProviderAdapter(AdapterType): + """Types of energy load forecast provider adapter.""" + + DUMMY = "dummy" + NAIVE_LAST_HOUR = "naive_last_hour" + NAIVE_PERSISTENCE = "naive_persistence" + SEASONAL_BASELINE = "seasonal_baseline" + SKFORECAST = "skforecast" + STATSMODELS = "statsmodels" + TYPICAL_PROFILE = "typical_profile" + XGBOOST = "xgboost" + + +class EnergyLoadHistoryProviderAdapter(AdapterType): + """Types of energy load history provider adapter.""" + + DUMMY = "dummy" + HOME_ASSISTANT_API = "home_assistant_api" diff --git a/core/edge_mining/domain/home_load/entities.py b/core/edge_mining/domain/home_load/entities.py new file mode 100644 index 0000000..6dfcbbb --- /dev/null +++ b/core/edge_mining/domain/home_load/entities.py @@ -0,0 +1,68 @@ +"""Collection of Entities for the Home Consumption Analytics domain of the Edge Mining application.""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional + +from edge_mining.domain.common import Entity, EntityId +from edge_mining.domain.home_load.common import ( + EnergyLoadForecastProviderAdapter, + EnergyLoadHistoryProviderAdapter, + LoadDeviceCategory, +) +from edge_mining.shared.interfaces.config import EnergyLoadForecastProviderConfig, EnergyLoadHistoryProviderConfig + + +@dataclass +class LoadDevice(Entity): + """Entity for a load device.""" + + name: str = "" # e.g., "Dishwasher", "EV Charger" + category: LoadDeviceCategory = LoadDeviceCategory.OCCASIONAL + enabled: bool = True # Whether the device is active in the system + + energy_load_forecast_provider_id: Optional[EntityId] = None # Energy load forecast provider to be used + energy_load_history_provider_id: Optional[EntityId] = None # Energy load history provider to be used + + +@dataclass +class EnergyLoadForecastProvider(Entity): + """Entity for a energy load forecast provider.""" + + name: str = "" + adapter_type: EnergyLoadForecastProviderAdapter = EnergyLoadForecastProviderAdapter.DUMMY + config: Optional[EnergyLoadForecastProviderConfig] = None + external_service_id: Optional[EntityId] = None + + +@dataclass +class EnergyLoadHistoryProvider(Entity): + """Entity for an energy load history provider.""" + + name: str = "" + adapter_type: EnergyLoadHistoryProviderAdapter = EnergyLoadHistoryProviderAdapter.DUMMY + config: Optional[EnergyLoadHistoryProviderConfig] = None + external_service_id: Optional[EntityId] = None + + +@dataclass +class LoadConsumptionModel(Entity): + """Entity for a trained ML model used by ML-based forecast providers. + + Stores model metadata and serialized weights. The forecast provider + adapter loads the model from this entity instead of re-training on + every forecast call. + """ + + device_id: Optional[EntityId] = None # None = aggregate model for all devices + adapter_type: EnergyLoadForecastProviderAdapter = EnergyLoadForecastProviderAdapter.STATSMODELS + trained_at: Optional[datetime] = None + mae: Optional[float] = None # mean absolute error on holdout + rmse: Optional[float] = None # root mean squared error on holdout + samples_used: int = 0 # number of training samples + is_active: bool = False # promoted to production + model_bytes: Optional[bytes] = field(default=None, repr=False) # serialized model (pickle/joblib) + tuning_params: Optional[dict] = field(default=None) # best hyperparameters from Optuna tuning + backtest_mae: Optional[float] = None # MAE from rolling-window backtesting + backtest_rmse: Optional[float] = None # RMSE from rolling-window backtesting + backtest_folds: int = 0 # number of folds used in backtesting diff --git a/core/edge_mining/domain/home_load/events.py b/core/edge_mining/domain/home_load/events.py new file mode 100644 index 0000000..5a34a89 --- /dev/null +++ b/core/edge_mining/domain/home_load/events.py @@ -0,0 +1,24 @@ +"""Home load domain events.""" + +from dataclasses import dataclass +from typing import Optional + +from edge_mining.domain.common import DomainEvent, EntityId + + +@dataclass +class LoadConsumptionHistoryCollectedEvent(DomainEvent): + """Event emitted after collecting power points for a device.""" + + device_id: Optional[EntityId] = None + device_name: str = "" + points_collected: int = 0 + + +@dataclass +class LoadConsumptionHistoryPurgedEvent(DomainEvent): + """Event emitted after purging old power points for a device.""" + + device_id: Optional[EntityId] = None + device_name: str = "" + points_purged: int = 0 diff --git a/core/edge_mining/domain/home_load/exceptions.py b/core/edge_mining/domain/home_load/exceptions.py new file mode 100644 index 0000000..4f08d24 --- /dev/null +++ b/core/edge_mining/domain/home_load/exceptions.py @@ -0,0 +1,93 @@ +"""Collection of Exceptions.""" + +from edge_mining.domain.exceptions import DomainError + + +class HomeLoadError(DomainError): + """Base class for home load-related errors.""" + + pass + + +class HomeLoadsProfileAlreadyExistsError(HomeLoadError): + """Home Loads Profile already exists.""" + + pass + + +class HomeLoadsProfileNotFoundError(HomeLoadError): + """Home Loads Profile not found.""" + + pass + + +class HomeLoadsProfileAddDeviceError(HomeLoadError): + """Error adding device to Home Loads Profile.""" + + pass + + +class HomeLoadsProfileDeviceNotFoundError(HomeLoadError): + """Load Device not found in Home Loads Profile.""" + + pass + + +class HomeLoadsProfileRemoveDeviceError(HomeLoadError): + """Error removing device from Home Loads Profile.""" + + pass + + +class EnergyLoadForecastError(HomeLoadError): + """Base class for energy load forecast-specific errors.""" + + pass + + +class EnergyLoadForecastProviderError(EnergyLoadForecastError): + """Errors related to energy load forecast provider.""" + + pass + + +class EnergyLoadForecastProviderNotFoundError(EnergyLoadForecastProviderError): + """Energy Load Forecast Provider not found.""" + + pass + + +class EnergyLoadForecastProviderAlreadyExistsError(EnergyLoadForecastProviderError): + """Energy Load Forecast Provider already exists.""" + + pass + + +class EnergyLoadForecastProviderConfigurationError(EnergyLoadForecastProviderError): + """Error with the configuration.""" + + pass + + +class EnergyLoadHistoryProviderError(HomeLoadError): + """Errors related to energy load history provider.""" + + pass + + +class EnergyLoadHistoryProviderNotFoundError(EnergyLoadHistoryProviderError): + """Energy Load History Provider not found.""" + + pass + + +class EnergyLoadHistoryProviderAlreadyExistsError(EnergyLoadHistoryProviderError): + """Energy Load History Provider already exists.""" + + pass + + +class EnergyLoadHistoryProviderConfigurationError(EnergyLoadHistoryProviderError): + """Error with the configuration.""" + + pass diff --git a/core/edge_mining/domain/home_load/ports.py b/core/edge_mining/domain/home_load/ports.py new file mode 100644 index 0000000..91ef4f2 --- /dev/null +++ b/core/edge_mining/domain/home_load/ports.py @@ -0,0 +1,259 @@ +"""Collection of Ports for the Home Consumption Analytics domain of the Edge Mining application.""" + +from abc import ABC, abstractmethod +from typing import List, Optional + +from edge_mining.domain.common import EntityId, Timestamp +from edge_mining.domain.home_load.aggregate_roots import HomeLoadsProfile +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter, EnergyLoadHistoryProviderAdapter +from edge_mining.domain.home_load.entities import ( + EnergyLoadForecastProvider, + EnergyLoadHistoryProvider, + LoadConsumptionModel, +) +from edge_mining.domain.home_load.value_objects import HomeLoadEnergyInterval, HomeLoadPowerPoint, LoadEnergyConsumption + + +class EnergyLoadHistoryRepository(ABC): + """Port for device-scoped persistence of HomeLoadPowerPoint time series. + + Every operation is scoped to a single ``LoadDevice`` via its ``device_id``: + the repository supports multiple devices as independent, per-key streams. + """ + + @abstractmethod + def add_power_point(self, device_id: EntityId, power_point: HomeLoadPowerPoint) -> None: + """Append a single power point for the given device.""" + raise NotImplementedError + + @abstractmethod + def add_power_points(self, device_id: EntityId, power_points: List[HomeLoadPowerPoint]) -> None: + """Append multiple power points for the given device in one batch.""" + raise NotImplementedError + + @abstractmethod + def get_power_points(self, device_id: EntityId, start: Timestamp, end: Timestamp) -> List[HomeLoadPowerPoint]: + """Retrieve power points for ``device_id`` within the window [start, end).""" + raise NotImplementedError + + @abstractmethod + def get_latest_timestamp(self, device_id: EntityId) -> Optional[Timestamp]: + """Return the newest timestamp stored for ``device_id``, or None if empty. + + Used by ingestion pipelines to resume fetching from the last known point + and by the rule engine to evaluate staleness. + """ + raise NotImplementedError + + @abstractmethod + def purge_before(self, device_id: EntityId, timestamp: Timestamp) -> int: + """Delete all power points for ``device_id`` with timestamp < ``timestamp``. + + Returns the number of rows deleted (useful for retention metrics). + """ + raise NotImplementedError + + @abstractmethod + def remove_power_points_by_time_range(self, device_id: EntityId, start: Timestamp, end: Timestamp) -> None: + """Remove all power points for ``device_id`` within the window [start, end).""" + raise NotImplementedError + + @abstractmethod + def clear_device_history(self, device_id: EntityId) -> int: + """Delete all power points for ``device_id``. + + Returns the number of rows deleted. + """ + raise NotImplementedError + + +class EnergyLoadHistoryProviderPort(ABC): + """Port for retrieving historical energy load consumption data for a single device. + + The port is device-scoped: each provider instance is bound at construction + time to the ``LoadDevice`` it covers. The underlying persistence (cache or + local repo) is an infrastructure concern of the concrete adapter — it is + NOT exposed on the port contract, so domain code can rely on history + providers without knowing whether they cache, stream or query live. + """ + + def __init__(self, device_id: EntityId, provider_type: EnergyLoadHistoryProviderAdapter): + """Initialize the EnergyLoadHistory Provider bound to ``device_id``.""" + self.device_id = device_id + self.provider_type = provider_type + + @abstractmethod + async def get_power_points(self, start: Timestamp, end: Timestamp) -> List[HomeLoadPowerPoint]: + """Retrieve raw power points for this device in the window [start, end).""" + raise NotImplementedError + + @abstractmethod + async def get_history(self, start: Timestamp, end: Timestamp) -> List[HomeLoadEnergyInterval]: + """Retrieve consumption intervals (typically 1h buckets) for this device.""" + raise NotImplementedError + + +class EnergyLoadForecastProviderPort(ABC): + """Port for the Energy Load Forecast Provider.""" + + def __init__(self, forecast_provider_type: EnergyLoadForecastProviderAdapter): + """Initialize the EnergyLoadForecast Provider.""" + self.forecast_provider_type = forecast_provider_type + + @property + def min_required_history_hours(self) -> int: + """Minimum hours of historical data required for this provider to produce a forecast. + + Providers that need more history should override this property. + Returns 0 by default (no minimum requirement). + """ + return 0 + + @abstractmethod + def get_consumption_forecast( + self, consumption_history: LoadEnergyConsumption, hours_ahead: int = 3 + ) -> Optional[LoadEnergyConsumption]: + """Provide an aggregated forecast of load energy consumption based on the given history.""" + raise NotImplementedError + + +class HomeLoadsProfileRepository(ABC): + """Port for the Home Loads Profile Repository.""" + + @abstractmethod + def add(self, profile: HomeLoadsProfile) -> None: + """Adds a new home loads profile to the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_id(self, profile_id: EntityId) -> Optional[HomeLoadsProfile]: + """Retrieves an home loads profile by its ID.""" + raise NotImplementedError + + @abstractmethod + def get_all(self) -> List[HomeLoadsProfile]: + """Retrieves all home loads profiles in the repository.""" + raise NotImplementedError + + @abstractmethod + def update(self, profile: HomeLoadsProfile) -> None: + """Updates the state of an existing home loads profile in the repository.""" + raise NotImplementedError + + @abstractmethod + def remove(self, profile_id: EntityId) -> None: + """Removes an home loads profile from the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_energy_load_forecast_provider_id(self, provider_id: EntityId) -> List[HomeLoadsProfile]: + """Retrieves profiles whose LoadDevices reference the given energy load forecast provider.""" + raise NotImplementedError + + +class EnergyLoadForecastProviderRepository(ABC): + """Port for the Energy Load Forecast Provider Repository.""" + + @abstractmethod + def add(self, energy_load_forecast_provider: EnergyLoadForecastProvider) -> None: + """Adds a new energy load forecast provider to the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_id(self, energy_load_forecast_provider_id: EntityId) -> Optional[EnergyLoadForecastProvider]: + """Retrieves an energy load forecast provider by its ID.""" + raise NotImplementedError + + @abstractmethod + def get_all(self) -> List[EnergyLoadForecastProvider]: + """Retrieves all energy load forecast providers in the repository.""" + raise NotImplementedError + + @abstractmethod + def update(self, energy_load_forecast_provider: EnergyLoadForecastProvider) -> None: + """Updates the state of an existing energy load forecast provider in the repository.""" + raise NotImplementedError + + @abstractmethod + def remove(self, energy_load_forecast_provider_id: EntityId) -> None: + """Removes an energy load forecast provider from the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_external_service_id(self, external_service_id: EntityId) -> List[EnergyLoadForecastProvider]: + """ + Retrieves all energy load forecast providers associated with a specific external service ID. + """ + raise NotImplementedError + + +class EnergyLoadHistoryProviderRepository(ABC): + """Port for the Energy Load History Provider Repository.""" + + @abstractmethod + def add(self, energy_load_history_provider: EnergyLoadHistoryProvider) -> None: + """Adds a new energy load history provider to the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_id(self, energy_load_history_provider_id: EntityId) -> Optional[EnergyLoadHistoryProvider]: + """Retrieves an energy load history provider by its ID.""" + raise NotImplementedError + + @abstractmethod + def get_all(self) -> List[EnergyLoadHistoryProvider]: + """Retrieves all energy load history providers in the repository.""" + raise NotImplementedError + + @abstractmethod + def update(self, energy_load_history_provider: EnergyLoadHistoryProvider) -> None: + """Updates the state of an existing energy load history provider.""" + raise NotImplementedError + + @abstractmethod + def remove(self, energy_load_history_provider_id: EntityId) -> None: + """Removes an energy load history provider from the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_external_service_id(self, external_service_id: EntityId) -> List[EnergyLoadHistoryProvider]: + """Retrieves all energy load history providers linked to a specific external service.""" + raise NotImplementedError + + +class LoadConsumptionModelRepository(ABC): + """Port for persistence of trained LoadConsumptionModel instances.""" + + @abstractmethod + def add(self, model: LoadConsumptionModel) -> None: + """Persist a newly trained model.""" + raise NotImplementedError + + @abstractmethod + def get_by_id(self, model_id: EntityId) -> Optional[LoadConsumptionModel]: + """Retrieve a model by ID.""" + raise NotImplementedError + + @abstractmethod + def get_active_model( + self, + adapter_type: EnergyLoadForecastProviderAdapter, + device_id: Optional[EntityId] = None, + ) -> Optional[LoadConsumptionModel]: + """Retrieve the currently active (promoted) model for a given adapter type and device.""" + raise NotImplementedError + + @abstractmethod + def get_all(self, device_id: Optional[EntityId] = None) -> List[LoadConsumptionModel]: + """Retrieve all models, optionally filtered by device_id.""" + raise NotImplementedError + + @abstractmethod + def update(self, model: LoadConsumptionModel) -> None: + """Update an existing model (e.g. promote to active).""" + raise NotImplementedError + + @abstractmethod + def remove(self, model_id: EntityId) -> None: + """Remove a model.""" + raise NotImplementedError diff --git a/core/edge_mining/domain/home_load/value_objects.py b/core/edge_mining/domain/home_load/value_objects.py new file mode 100644 index 0000000..93ddf1a --- /dev/null +++ b/core/edge_mining/domain/home_load/value_objects.py @@ -0,0 +1,283 @@ +"""Collection of Value Objects for the Home Consumption Analytics domain of the Edge Mining application.""" + +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from typing import List, Optional + +from edge_mining.domain.common import EntityId, Timestamp, ValueObject, WattHours, Watts +from edge_mining.domain.home_load.common import LoadDeviceCategory + + +@dataclass(frozen=True) +class HomeLoadPowerPoint(ValueObject): + """Value Object for a single home loads power consumption point.""" + + timestamp: Timestamp + power: Watts + + +@dataclass(frozen=True) +class HomeLoadEnergyInterval(ValueObject): + """ + Value Object for a home load energy consumption interval. + In most cases this can be understood as a 1 hour time range + """ + + start: Timestamp + end: Timestamp + energy: Optional[WattHours] = None + power_points: List[HomeLoadPowerPoint] = field(default_factory=list) + + def __post_init__(self): + """Post-initialization validation.""" + if self.start >= self.end: + raise ValueError("Interval start time must be before end time.") + + for point in self.power_points: + if not (self.start <= point.timestamp <= self.end): + raise ValueError( + f"Power point timestamp {point.timestamp} is outside the interval [{self.start}, {self.end}]." + ) + + @classmethod + def create_from_power_points( + cls, + start: Timestamp, + end: Timestamp, + power_points: List[HomeLoadPowerPoint], + ) -> "HomeLoadEnergyInterval": + """Factory method to create an interval and calculate its energy from power points.""" + total_power = sum(point.power for point in power_points) + avg_power = Watts(total_power / len(power_points)) if power_points else Watts(0.0) + + duration_hours = (end - start).total_seconds() / 3600.0 + calculated_energy = WattHours(avg_power * duration_hours) + + return cls( + start=start, + end=end, + power_points=power_points, + energy=calculated_energy, + ) + + @property + def duration(self) -> timedelta: + """Calculate the duration of the interval""" + return self.end - self.start + + @property + def avg_power(self) -> Watts: + """Calculate the average power over the interval.""" + if not self.power_points: + return Watts(0.0) + + total_power = sum(point.power for point in self.power_points) + return Watts(total_power / len(self.power_points)) if total_power else Watts(0.0) + + +@dataclass(frozen=True) +class LoadEnergyConsumption(ValueObject): + """ + Value Object for a time series of load energy consumption. + Intended to be agnostic: can represent history, forecast, per-device or aggregate. + Intervals are typically 1 hour time ranges. + """ + + timestamp: Timestamp = field(default_factory=Timestamp(datetime.now(timezone.utc))) + intervals: List[HomeLoadEnergyInterval] = field(default_factory=list) + + @property + def total_energy(self) -> WattHours: + """Sum of energy across all intervals.""" + return WattHours(sum(float(i.energy) for i in self.intervals if i.energy is not None)) + + @property + def avg_energy(self) -> WattHours: + """Average of per-interval energy.""" + if not self.intervals: + return WattHours(0.0) + + total_energy = sum(float(interval.energy) for interval in self.intervals if interval.energy) + return WattHours(total_energy / len(self.intervals)) if total_energy else WattHours(0.0) + + @property + def avg_power(self) -> Watts: + """Average of per-interval average power.""" + if not self.intervals: + return Watts(0.0) + + total_power = sum(interval.avg_power for interval in self.intervals) + return Watts(total_power / len(self.intervals)) if total_power else Watts(0.0) + + @property + def peak_power(self) -> Watts: + """Maximum avg_power observed across intervals.""" + if not self.intervals: + return Watts(0.0) + return Watts(max(float(i.avg_power) for i in self.intervals)) + + def in_window(self, start: Timestamp, end: Timestamp) -> "LoadEnergyConsumption": + """Return a subset whose intervals overlap the given window [start, end).""" + if start >= end: + return LoadEnergyConsumption(timestamp=self.timestamp, intervals=[]) + filtered = [i for i in self.intervals if i.start < end and i.end > start] + return LoadEnergyConsumption(timestamp=self.timestamp, intervals=filtered) + + def in_next_hours(self, hours: int, now: Optional[Timestamp] = None) -> "LoadEnergyConsumption": + """Return a subset covering the next `hours` starting from `now` (defaults to datetime.now).""" + anchor = now if now is not None else Timestamp(datetime.now(timezone.utc)) + return self.in_window(anchor, Timestamp(anchor + timedelta(hours=hours))) + + def in_last_hours(self, hours: int, now: Optional[Timestamp] = None) -> "LoadEnergyConsumption": + """Return a subset covering the last `hours` up to `now`.""" + anchor = now if now is not None else Timestamp(datetime.now(timezone.utc)) + return self.in_window(Timestamp(anchor - timedelta(hours=hours)), anchor) + + # Pre-computed window properties for rule engine paths + # e.g. home_load.total_forecast.next_1h.total_energy + + @property + def next_1h(self) -> "LoadEnergyConsumption": + """Subset covering the next 1 hour from now.""" + return self.in_next_hours(1) + + @property + def next_2h(self) -> "LoadEnergyConsumption": + """Subset covering the next 2 hours from now.""" + return self.in_next_hours(2) + + @property + def next_4h(self) -> "LoadEnergyConsumption": + """Subset covering the next 4 hours from now.""" + return self.in_next_hours(4) + + @property + def next_6h(self) -> "LoadEnergyConsumption": + """Subset covering the next 6 hours from now.""" + return self.in_next_hours(6) + + @property + def next_8h(self) -> "LoadEnergyConsumption": + """Subset covering the next 8 hours from now.""" + return self.in_next_hours(8) + + @property + def next_12h(self) -> "LoadEnergyConsumption": + """Subset covering the next 12 hours from now.""" + return self.in_next_hours(12) + + @property + def next_24h(self) -> "LoadEnergyConsumption": + """Subset covering the next 24 hours from now.""" + return self.in_next_hours(24) + + @property + def last_1h(self) -> "LoadEnergyConsumption": + """Subset covering the last 1 hour up to now.""" + return self.in_last_hours(1) + + @property + def last_4h(self) -> "LoadEnergyConsumption": + """Subset covering the last 4 hours up to now.""" + return self.in_last_hours(4) + + @property + def last_12h(self) -> "LoadEnergyConsumption": + """Subset covering the last 12 hours up to now.""" + return self.in_last_hours(12) + + @property + def last_24h(self) -> "LoadEnergyConsumption": + """Subset covering the last 24 hours up to now.""" + return self.in_last_hours(24) + + @staticmethod + def mix( + forecast: "LoadEnergyConsumption", + last_real_power: Watts, + alpha: float = 0.5, + beta: float = 0.5, + ) -> "LoadEnergyConsumption": + """Blend the first forecast interval with the last measured power. + + Implements the mix formula: + + P_mix(k) = α · P̂(k) + β · P_real(k-1) + + Only the **first** interval is blended; the remaining forecast is + returned unchanged. This improves short-term accuracy when the + optimisation loop runs frequently (e.g. every 5 s). + + :param forecast: The original forecast consumption. + :param last_real_power: The most recent measured power value (W). + :param alpha: Weight for the forecast side (default 0.5). + :param beta: Weight for the real-measurement side (default 0.5). + :returns: A new ``LoadEnergyConsumption`` with the blended first interval. + """ + if not forecast.intervals: + return forecast + + first = forecast.intervals[0] + blended_power = Watts(alpha * first.avg_power + beta * float(last_real_power)) + + duration_hours = first.duration.total_seconds() / 3600.0 + blended_energy = WattHours(blended_power * duration_hours) if duration_hours > 0 else first.energy + + blended_interval = HomeLoadEnergyInterval( + start=first.start, + end=first.end, + energy=blended_energy, + power_points=first.power_points, + ) + + new_intervals = [blended_interval] + list(forecast.intervals[1:]) + return LoadEnergyConsumption(timestamp=forecast.timestamp, intervals=new_intervals) + + +@dataclass(frozen=True) +class LoadDeviceConsumption(ValueObject): + """Consumption (history + forecast) for a single LoadDevice. + + Binds the generic ``LoadEnergyConsumption`` time series to the identity + of a LoadDevice so downstream consumers (policy engine, UI) can reason + per-device without losing track of "who is consuming what". + """ + + device_id: EntityId + device_name: str + device_category: LoadDeviceCategory + history: LoadEnergyConsumption = field(default_factory=LoadEnergyConsumption) + forecast: LoadEnergyConsumption = field(default_factory=LoadEnergyConsumption) + + +@dataclass(frozen=True) +class HomeLoadsConsumption(ValueObject): + """Unified household consumption view for the DecisionalContext. + + Carries: + - ``per_device``: individual device history+forecast, keyed by unique name. + - ``total_history`` / ``total_forecast``: aggregated household time series. + + Exposes ``devices`` as a name-indexed mapping for readable rule paths + (e.g., ``home_load.devices.boiler.forecast.total_energy``). + """ + + per_device: List[LoadDeviceConsumption] = field(default_factory=list) + total_history: LoadEnergyConsumption = field(default_factory=LoadEnergyConsumption) + total_forecast: LoadEnergyConsumption = field(default_factory=LoadEnergyConsumption) + + @property + def devices(self) -> "dict[str, LoadDeviceConsumption]": + """Device-name-indexed map for rule engine path navigation. + + Relies on the uniqueness invariant enforced by ``HomeLoadsProfile``. + """ + return {d.device_name: d for d in self.per_device} + + def device_by_name(self, name: str) -> Optional[LoadDeviceConsumption]: + """Lookup by (unique) device name.""" + return self.devices.get(name) + + def device_by_id(self, device_id: EntityId) -> Optional[LoadDeviceConsumption]: + """Lookup by device id.""" + return next((d for d in self.per_device if d.device_id == device_id), None) diff --git a/core/edge_mining/domain/miner/__init__.py b/core/edge_mining/domain/miner/__init__.py new file mode 100644 index 0000000..1aa836b --- /dev/null +++ b/core/edge_mining/domain/miner/__init__.py @@ -0,0 +1 @@ +"""Mining Device Management subdomain.""" diff --git a/core/edge_mining/domain/miner/aggregate_roots.py b/core/edge_mining/domain/miner/aggregate_roots.py new file mode 100644 index 0000000..fb6bc64 --- /dev/null +++ b/core/edge_mining/domain/miner/aggregate_roots.py @@ -0,0 +1,135 @@ +"""Collection of Aggregate Roots for the Mining Device Management domain of the Edge Mining application.""" + +from dataclasses import dataclass, field +from typing import List, Optional + +from edge_mining.domain.common import AggregateRoot, EntityId, Watts +from edge_mining.domain.miner.common import MinerFeatureType +from edge_mining.domain.miner.value_objects import HashRate, MinerFeature + + +@dataclass +class Miner(AggregateRoot): + """Aggregate root for a miner. + + Represents the physical mining asset and its intrinsic (static) properties. + Runtime operational state (status, current hash rate, current power consumption) + is captured separately in MinerStateSnapshot. + + Aggregates MinerFeature value objects, each representing a capability + provided by a controller. Multiple controllers can provide features + to the same miner. + """ + + name: str = "" + model: Optional[str] = None + hash_rate_max: Optional[HashRate] = None # Max hash rate for the miner + power_consumption_max: Optional[Watts] = None # Max power consumption for the miner + active: bool = True # Is the miner active in the system? + + features: List[MinerFeature] = field(default_factory=list) + + def activate(self): + """Activate the miner.""" + self.active = True + + def deactivate(self): + """Deactivate the miner.""" + self.active = False + + # --- Feature management (aggregate root invariants) --- + + def add_feature(self, feature: MinerFeature) -> None: + """Add a feature to the miner. + + Raises ValueError if a feature with the same (feature_type, controller_id) already exists. + """ + for existing in self.features: + if existing.feature_type == feature.feature_type and existing.controller_id == feature.controller_id: + raise ValueError( + f"Feature {feature.feature_type.value} from controller {feature.controller_id} already exists." + ) + self.features.append(feature) + + def remove_feature(self, feature_type: MinerFeatureType, controller_id: EntityId) -> None: + """Remove a specific feature by type and controller.""" + self.features = [ + f for f in self.features if not (f.feature_type == feature_type and f.controller_id == controller_id) + ] + + def remove_features_by_controller(self, controller_id: EntityId) -> None: + """Remove all features provided by a specific controller.""" + self.features = [f for f in self.features if f.controller_id != controller_id] + + def get_active_feature(self, feature_type: MinerFeatureType) -> Optional[MinerFeature]: + """Get the highest-priority enabled feature of the given type. + + Returns None if no enabled feature of this type exists. + """ + candidates = [f for f in self.features if f.feature_type == feature_type and f.enabled] + if not candidates: + return None + return max(candidates, key=lambda f: f.priority) + + def get_features_by_controller(self, controller_id: EntityId) -> List[MinerFeature]: + """Get all features provided by a specific controller.""" + return [f for f in self.features if f.controller_id == controller_id] + + def get_features_by_type(self, feature_type: MinerFeatureType) -> List[MinerFeature]: + """Get all features of a specific type (all controllers).""" + return [f for f in self.features if f.feature_type == feature_type] + + def get_controller_ids(self) -> List[EntityId]: + """Get all unique controller IDs associated with this miner.""" + return list({f.controller_id for f in self.features}) + + def has_feature(self, feature_type: MinerFeatureType) -> bool: + """Check if the miner has at least one enabled feature of the given type.""" + return any(f.feature_type == feature_type and f.enabled for f in self.features) + + def enable_feature(self, feature_type: MinerFeatureType, controller_id: EntityId) -> None: + """Enable a specific feature. Replaces the immutable VO with an enabled copy.""" + self.features = [ + MinerFeature( + feature_type=f.feature_type, + controller_id=f.controller_id, + priority=f.priority, + enabled=True, + ) + if f.feature_type == feature_type and f.controller_id == controller_id + else f + for f in self.features + ] + + def disable_feature(self, feature_type: MinerFeatureType, controller_id: EntityId) -> None: + """Disable a specific feature. Replaces the immutable VO with a disabled copy.""" + self.features = [ + MinerFeature( + feature_type=f.feature_type, + controller_id=f.controller_id, + priority=f.priority, + enabled=False, + ) + if f.feature_type == feature_type and f.controller_id == controller_id + else f + for f in self.features + ] + + def set_priority(self, feature_type: MinerFeatureType, controller_id: EntityId, priority: int) -> None: + """Set the priority of a specific feature. + + Raises ValueError if priority is out of range [1, 100]. + """ + if not 1 <= priority <= 100: + raise ValueError(f"Priority must be between 1 and 100, got {priority}.") + self.features = [ + MinerFeature( + feature_type=f.feature_type, + controller_id=f.controller_id, + priority=priority, + enabled=f.enabled, + ) + if f.feature_type == feature_type and f.controller_id == controller_id + else f + for f in self.features + ] diff --git a/core/edge_mining/domain/miner/common.py b/core/edge_mining/domain/miner/common.py new file mode 100644 index 0000000..1b45a95 --- /dev/null +++ b/core/edge_mining/domain/miner/common.py @@ -0,0 +1,58 @@ +"""Collection of Common Objects for the Mining Device Management domain of the Edge Mining application.""" + +from enum import Enum + +from edge_mining.domain.common import AdapterType + + +class MinerStatus(Enum): + """Enum for the different miner statuses.""" + + UNKNOWN = "unknown" + OFF = "off" + ON = "on" + STARTING = "starting" + STOPPING = "stopping" + ERROR = "error" + + +class MinerFeatureType(Enum): + """Types of features that a miner can support, provided by controllers.""" + + # Monitoring (read-only) + HASHRATE_MONITORING = "hashrate_monitoring" + POWER_MONITORING = "power_monitoring" + STATUS_MONITORING = "status_monitoring" + HASHBOARD_MONITORING = "hashboard_monitoring" + INLET_TEMPERATURE_MONITORING = "inlet_temperature_monitoring" + OUTLET_TEMPERATURE_MONITORING = "outlet_temperature_monitoring" + FAN_SPEED_INTERNAL_MONITORING = "fan_speed_internal_monitoring" + FAN_SPEED_EXTERNAL_MONITORING = "fan_speed_external_monitoring" + OPERATIONAL_MONITORING = "operational_monitoring" + + # Control (write) + MINING_CONTROL = "mining_control" + POWER_CONTROL = "power_control" + INTERNAL_FAN_CONTROL = "internal_fan_control" + EXTERNAL_FAN_CONTROL = "external_fan_control" + + # Info + MAX_POWER_DETECTION = "max_power_detection" + MAX_HASHRATE_DETECTION = "max_hashrate_detection" + DEVICE_INFO_DETECTION = "device_info_detection" + + +class MinerControllerAdapter(AdapterType): + """Types of miner controller adapter.""" + + DUMMY = "dummy" + GENERIC_SOCKET_HOME_ASSISTANT_API = "generic_socket_home_assistant_api" + PYASIC = "pyasic" + + +class MinerControllerProtocol(Enum): + """Types of miner controller protocols.""" + + WEB = "web" + RPC = "rpc" + SSH = "ssh" diff --git a/core/edge_mining/domain/miner/entities.py b/core/edge_mining/domain/miner/entities.py new file mode 100644 index 0000000..470dda6 --- /dev/null +++ b/core/edge_mining/domain/miner/entities.py @@ -0,0 +1,18 @@ +"""Collection of Entities for the Mining Device Management domain of the Edge Mining application.""" + +from dataclasses import dataclass +from typing import Optional + +from edge_mining.domain.common import Entity, EntityId +from edge_mining.domain.miner.common import MinerControllerAdapter +from edge_mining.shared.interfaces.config import MinerControllerConfig + + +@dataclass +class MinerController(Entity): + """Entity for a miner controller.""" + + name: str = "" + adapter_type: MinerControllerAdapter = MinerControllerAdapter.DUMMY # Default to dummy controller + config: Optional[MinerControllerConfig] = None + external_service_id: Optional[EntityId] = None diff --git a/core/edge_mining/domain/miner/events.py b/core/edge_mining/domain/miner/events.py new file mode 100644 index 0000000..62563fd --- /dev/null +++ b/core/edge_mining/domain/miner/events.py @@ -0,0 +1,17 @@ +"""Miner domain events.""" + +from dataclasses import dataclass +from typing import Optional + +from edge_mining.domain.common import DomainEvent, EntityId +from edge_mining.domain.miner.common import MinerStatus + + +@dataclass +class MinerStateChangedEvent(DomainEvent): + """Event emitted when a miner changes state (started/stopped).""" + + miner_id: Optional[EntityId] = None + miner_name: str = "" + old_status: Optional[MinerStatus] = None + new_status: Optional[MinerStatus] = None diff --git a/core/edge_mining/domain/miner/exceptions.py b/core/edge_mining/domain/miner/exceptions.py new file mode 100644 index 0000000..9fd47a2 --- /dev/null +++ b/core/edge_mining/domain/miner/exceptions.py @@ -0,0 +1,45 @@ +"""Collection of Exceptions.""" + +from edge_mining.domain.exceptions import DomainError + + +class MinerError(DomainError): + """Errors related to miners.""" + + pass + + +class MinerNotFoundError(MinerError): + """Miner not found.""" + + pass + + +class MinerNotActiveError(MinerError): + """Miner not active.""" + + pass + + +class MinerControllerError(DomainError): + """Errors related to miner controllers.""" + + pass + + +class MinerControllerNotFoundError(MinerControllerError): + """Miner controller not found.""" + + pass + + +class MinerControllerAlreadyExistsError(MinerControllerError): + """Miner controller already exists.""" + + pass + + +class MinerControllerConfigurationError(MinerControllerError): + """Error with the configuration.""" + + pass diff --git a/core/edge_mining/domain/miner/ports.py b/core/edge_mining/domain/miner/ports.py new file mode 100644 index 0000000..435e25a --- /dev/null +++ b/core/edge_mining/domain/miner/ports.py @@ -0,0 +1,318 @@ +"""Collection of Ports for the Mining Device Management domain of the Edge Mining application.""" + +from abc import ABC, abstractmethod +from typing import ClassVar, List, Optional + +from edge_mining.domain.common import EntityId, Watts +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.common import MinerFeatureType, MinerStatus +from edge_mining.domain.miner.entities import MinerController +from edge_mining.domain.miner.value_objects import ( + FanSpeed, + HashboardSnapshot, + HashRate, + MinerInfo, + Temperature, +) + +# --- Feature Ports --- + + +class MinerFeaturePort(ABC): + """Base port for all miner feature ports. + + Each concrete port declares its feature_type as a ClassVar. + Adapters declare their capabilities by implementing these ports via multiple inheritance. + Feature discovery is introspective via MRO. + """ + + feature_type: ClassVar[MinerFeatureType] + + @classmethod + def get_supported_features(cls) -> List[MinerFeatureType]: + """Introspect MRO to discover all supported feature types. + + Walks the class hierarchy and collects feature_type from each + MinerFeaturePort subclass that declares one. + """ + features = [] + for base in cls.__mro__: + if ( + base is not MinerFeaturePort + and isinstance(base, type) + and issubclass(base, MinerFeaturePort) + and "feature_type" in base.__dict__ + ): + features.append(base.feature_type) + return features + + +# --- Monitoring Ports (read-only) --- + + +class HashrateMonitorPort(MinerFeaturePort): + """Port for monitoring miner hashrate.""" + + feature_type = MinerFeatureType.HASHRATE_MONITORING + + @abstractmethod + async def get_hashrate(self) -> Optional[HashRate]: + """Gets the current hash rate, if available.""" + raise NotImplementedError + + +class PowerMonitorPort(MinerFeaturePort): + """Port for monitoring miner power consumption.""" + + feature_type = MinerFeatureType.POWER_MONITORING + + @abstractmethod + async def get_power(self) -> Optional[Watts]: + """Gets the current power consumption, if available.""" + raise NotImplementedError + + +class StatusMonitorPort(MinerFeaturePort): + """Port for monitoring miner operational status.""" + + feature_type = MinerFeatureType.STATUS_MONITORING + + @abstractmethod + async def get_status(self) -> MinerStatus: + """Gets the current operational status of the miner.""" + raise NotImplementedError + + +class HashboardMonitorPort(MinerFeaturePort): + """Port for monitoring per-hashboard data (temperatures, voltage, frequency, hashrate).""" + + feature_type = MinerFeatureType.HASHBOARD_MONITORING + + @abstractmethod + async def get_hashboards(self) -> List[HashboardSnapshot]: + """Gets the current state of all hashboards.""" + raise NotImplementedError + + +class InletTemperatureMonitorPort(MinerFeaturePort): + """Port for monitoring inlet air temperature.""" + + feature_type = MinerFeatureType.INLET_TEMPERATURE_MONITORING + + @abstractmethod + async def get_inlet_temperature(self) -> Optional[Temperature]: + """Gets the current inlet air temperature, if available.""" + raise NotImplementedError + + +class OutletTemperatureMonitorPort(MinerFeaturePort): + """Port for monitoring outlet air temperature.""" + + feature_type = MinerFeatureType.OUTLET_TEMPERATURE_MONITORING + + @abstractmethod + async def get_outlet_temperature(self) -> Optional[Temperature]: + """Gets the current outlet air temperature, if available.""" + raise NotImplementedError + + +class InternalFanSpeedMonitorPort(MinerFeaturePort): + """Port for monitoring internal fan speed.""" + + feature_type = MinerFeatureType.FAN_SPEED_INTERNAL_MONITORING + + @abstractmethod + async def get_internal_fan_speed(self) -> List[FanSpeed]: + """Gets the current internal fans speed, if available.""" + raise NotImplementedError + + +class ExternalFanSpeedMonitorPort(MinerFeaturePort): + """Port for monitoring external fan speed.""" + + feature_type = MinerFeatureType.FAN_SPEED_EXTERNAL_MONITORING + + @abstractmethod + async def get_external_fan_speed(self) -> Optional[FanSpeed]: + """Gets the current external fan speed, if available.""" + raise NotImplementedError + + +class OperationalMonitorPort(MinerFeaturePort): + """Port for monitoring overall miner operational state (e.g., blocks found, uptime).""" + + feature_type = MinerFeatureType.OPERATIONAL_MONITORING + + @abstractmethod + async def get_blocks_found(self) -> Optional[int]: + """Gets the total number of blocks found by the miner, if available.""" + raise NotImplementedError + + @abstractmethod + async def get_system_uptime(self) -> Optional[int]: + """Gets the system uptime in seconds, if available.""" + raise NotImplementedError + + +# --- Control Ports (write) --- + + +class MiningControlPort(MinerFeaturePort): + """Port for software-level mining start/stop control.""" + + feature_type = MinerFeatureType.MINING_CONTROL + + @abstractmethod + async def start_mining(self) -> bool: + """Attempts to start mining. Returns True on success.""" + raise NotImplementedError + + @abstractmethod + async def stop_mining(self) -> bool: + """Attempts to stop mining. Returns True on success.""" + raise NotImplementedError + + +class PowerControlPort(MinerFeaturePort): + """Port for hard power on/off control (e.g., smart plug).""" + + feature_type = MinerFeatureType.POWER_CONTROL + + @abstractmethod + async def power_on(self) -> bool: + """Attempts to power on the miner. Returns True on success.""" + raise NotImplementedError + + @abstractmethod + async def power_off(self) -> bool: + """Attempts to power off the miner. Returns True on success.""" + raise NotImplementedError + + +class InternalFanControlPort(MinerFeaturePort): + """Port for controlling internal fan speed via firmware.""" + + feature_type = MinerFeatureType.INTERNAL_FAN_CONTROL + + @abstractmethod + async def set_internal_fan_speed(self, speed_percent: float) -> bool: + """Sets internal fan speed as a percentage (0-100). Returns True on success.""" + raise NotImplementedError + + +class ExternalFanControlPort(MinerFeaturePort): + """Port for controlling external fan speed (e.g., ESPHome devices).""" + + feature_type = MinerFeatureType.EXTERNAL_FAN_CONTROL + + @abstractmethod + async def set_external_fan_speed(self, speed_percent: float) -> bool: + """Sets external fan speed as a percentage (0-100). Returns True on success.""" + raise NotImplementedError + + +# --- Info Ports --- + + +class MaxPowerDetectionPort(MinerFeaturePort): + """Port for detecting miner maximum power consumption.""" + + feature_type = MinerFeatureType.MAX_POWER_DETECTION + + @abstractmethod + async def get_max_power(self) -> Optional[Watts]: + """Gets the maximum power consumption of the miner, if available.""" + raise NotImplementedError + + +class MaxHashrateDetectionPort(MinerFeaturePort): + """Port for detecting miner maximum hash rate.""" + + feature_type = MinerFeatureType.MAX_HASHRATE_DETECTION + + @abstractmethod + async def get_max_hashrate(self) -> Optional[HashRate]: + """Gets the maximum hash rate of the miner, if available.""" + raise NotImplementedError + + +class DeviceInfoPort(MinerFeaturePort): + """Port for detecting miner device information (model, serial number, firmware version, etc.).""" + + feature_type = MinerFeatureType.DEVICE_INFO_DETECTION + + @abstractmethod + async def get_device_info(self) -> Optional[MinerInfo]: + """Gets the device identification information of the miner, if available.""" + raise NotImplementedError + + +# --- Repository Ports --- + + +class MinerRepository(ABC): + """Port for the Miner Repository.""" + + @abstractmethod + def add(self, miner: Miner) -> None: + """Adds a new miner to the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_id(self, miner_id: EntityId) -> Optional[Miner]: + """Retrieves a miner by its ID.""" + raise NotImplementedError + + @abstractmethod + def get_all(self) -> List[Miner]: + """Retrieves all miners in the repository.""" + raise NotImplementedError + + @abstractmethod + def update(self, miner: Miner) -> None: + """Updates the state of an existing miner in the repository.""" + raise NotImplementedError + + @abstractmethod + def remove(self, miner_id: EntityId) -> None: + """Removes a miner from the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_controller_id(self, controller_id: EntityId) -> List[Miner]: + """Retrieves a list of miners that have at least one feature provided by the given controller.""" + raise NotImplementedError + + +class MinerControllerRepository(ABC): + """Port for the Miner Controller Repository.""" + + @abstractmethod + def add(self, miner_controller: MinerController) -> None: + """Adds a new miner controller to the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_id(self, miner_controller_id: EntityId) -> Optional[MinerController]: + """Retrieves a miner controller by its ID.""" + raise NotImplementedError + + @abstractmethod + def get_all(self) -> List[MinerController]: + """Retrieves all miner controllers in the repository.""" + raise NotImplementedError + + @abstractmethod + def update(self, miner_controller: MinerController) -> None: + """Updates the state of an existing miner controller in the repository.""" + raise NotImplementedError + + @abstractmethod + def remove(self, miner_controller_id: EntityId) -> None: + """Removes a miner controller from the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_external_service_id(self, external_service_id: EntityId) -> List[MinerController]: + """Retrieves a list of miner controllers by its associated external service ID.""" + raise NotImplementedError diff --git a/core/edge_mining/domain/miner/value_objects.py b/core/edge_mining/domain/miner/value_objects.py new file mode 100644 index 0000000..25d3659 --- /dev/null +++ b/core/edge_mining/domain/miner/value_objects.py @@ -0,0 +1,155 @@ +"""Collection of Value Objects for the Mining Device Management domain of the Edge Mining application.""" + +from dataclasses import dataclass, field +from typing import List, Optional + +from edge_mining.domain.common import EntityId, ValueObject, Watts +from edge_mining.domain.miner.common import MinerFeatureType, MinerStatus + + +@dataclass(frozen=True) +class HashRate(ValueObject): + """Value Object for a hash rate.""" + + value: float # e.g., TH/s + unit: str = "TH/s" + + +@dataclass(frozen=True) +class Temperature(ValueObject): + """Value Object for a temperature measurement.""" + + value: float + unit: str = "°C" + + +@dataclass(frozen=True) +class FanSpeed(ValueObject): + """Value Object for a fan speed measurement.""" + + value: float + unit: str = "RPM" + + +@dataclass(frozen=True) +class Voltage(ValueObject): + """Value Object for a voltage measurement.""" + + value: float + unit: str = "V" + + +@dataclass(frozen=True) +class Frequency(ValueObject): + """Value Object for a frequency measurement.""" + + value: float + unit: str = "MHz" + + +@dataclass(frozen=True) +class MinerInfo(ValueObject): + """Value Object for miner device information.""" + + model: Optional[str] = None + serial_number: Optional[str] = None + firmware_type: Optional[str] = None + firmware_version: Optional[str] = None + mac_address: Optional[str] = None + hostname: Optional[str] = None + hashboard_count: Optional[int] = None + chip_count: Optional[int] = None + fan_count: Optional[int] = None + + +@dataclass(frozen=True) +class MinerLimit(ValueObject): + """Value Object representing limits for a miner.""" + + max_power: Optional[Watts] = None + max_hash_rate: Optional[HashRate] = None + + +@dataclass(frozen=True) +class MinerFeature(ValueObject): + """Value Object representing a single capability provided by a controller to a miner. + + Identity is given by the pair (feature_type, controller_id). + """ + + feature_type: MinerFeatureType + controller_id: EntityId + priority: int = 50 # 1-100, higher = higher priority + enabled: bool = True + + +@dataclass(frozen=True) +class HashboardSnapshot(ValueObject): + """Value Object representing a snapshot of a single hashboard's state. + + Aggregates per-board metrics: temperatures, electrical parameters, and hashrate data. + """ + + index: int + chip_temperature: Optional[Temperature] = None + board_temperature: Optional[Temperature] = None + voltage: Optional[Voltage] = None + frequency: Optional[Frequency] = None + hash_rate: Optional[HashRate] = None + nominal_hash_rate: Optional[HashRate] = None + hash_rate_error: Optional[HashRate] = None + + +@dataclass(frozen=True) +class MinerStateSnapshot(ValueObject): + """Value Object representing a snapshot of a miner's operational state at a given moment. + + This is used by the Rule Engine, Policy Rules, and the DecisionalContext + for decision-making. It has no repository — it is created on-the-fly + from controller data. + + Per-board data (chip/board temperature, voltage, frequency) is in hashboards. + Convenience properties (max_chip_temperature, max_board_temperature) are provided + for rule engine access without iterating boards. + """ + + status: MinerStatus = MinerStatus.UNKNOWN + hash_rate: Optional[HashRate] = None + power_consumption: Optional[Watts] = None + inlet_temperature: Optional[Temperature] = None + outlet_temperature: Optional[Temperature] = None + internal_fan_speed: List[FanSpeed] = field(default_factory=list) + external_fan_speed: Optional[FanSpeed] = None + hashboards: List[HashboardSnapshot] = field(default_factory=list) + blocks_found: Optional[int] = None + system_uptime: Optional[int] = None # seconds + + @property + def max_chip_temperature(self) -> Optional[Temperature]: + """Returns the maximum chip temperature across all hashboards.""" + temps = [hb.chip_temperature for hb in self.hashboards if hb.chip_temperature is not None] + return max(temps, key=lambda t: t.value) if temps else None + + @property + def max_board_temperature(self) -> Optional[Temperature]: + """Returns the maximum board temperature across all hashboards.""" + temps = [hb.board_temperature for hb in self.hashboards if hb.board_temperature is not None] + return max(temps, key=lambda t: t.value) if temps else None + + @property + def avg_chip_temperature(self) -> Optional[Temperature]: + """Returns the average chip temperature across all hashboards.""" + temps = [hb.chip_temperature for hb in self.hashboards if hb.chip_temperature is not None] + if not temps: + return None + avg = round(sum(t.value for t in temps) / len(temps), 1) + return Temperature(value=avg, unit=temps[0].unit) + + @property + def avg_board_temperature(self) -> Optional[Temperature]: + """Returns the average board temperature across all hashboards.""" + temps = [hb.board_temperature for hb in self.hashboards if hb.board_temperature is not None] + if not temps: + return None + avg = round(sum(t.value for t in temps) / len(temps), 1) + return Temperature(value=avg, unit=temps[0].unit) diff --git a/core/edge_mining/domain/notification/__init__.py b/core/edge_mining/domain/notification/__init__.py new file mode 100644 index 0000000..cef7e8e --- /dev/null +++ b/core/edge_mining/domain/notification/__init__.py @@ -0,0 +1 @@ +"""Notification subdomain.""" diff --git a/core/edge_mining/domain/notification/common.py b/core/edge_mining/domain/notification/common.py new file mode 100644 index 0000000..3dbec4d --- /dev/null +++ b/core/edge_mining/domain/notification/common.py @@ -0,0 +1,12 @@ +""" +Common classes for the Notification domain of the Edge Mining application. +""" + +from edge_mining.domain.common import AdapterType + + +class NotificationAdapter(AdapterType): + """Types of notification adapter.""" + + DUMMY = "dummy" + TELEGRAM = "telegram" diff --git a/core/edge_mining/domain/notification/entities.py b/core/edge_mining/domain/notification/entities.py new file mode 100644 index 0000000..d66ea96 --- /dev/null +++ b/core/edge_mining/domain/notification/entities.py @@ -0,0 +1,18 @@ +"""Collection of Entities for the Notification domain of the Edge Mining application.""" + +from dataclasses import dataclass +from typing import Optional + +from edge_mining.domain.common import Entity, EntityId +from edge_mining.domain.notification.common import NotificationAdapter +from edge_mining.shared.interfaces.config import NotificationConfig + + +@dataclass +class Notifier(Entity): + """Entity for an energy monitor.""" + + name: str = "" + adapter_type: NotificationAdapter = NotificationAdapter.DUMMY # Default to dummy notifier + config: Optional[NotificationConfig] = None + external_service_id: Optional[EntityId] = None diff --git a/core/edge_mining/domain/notification/exceptions.py b/core/edge_mining/domain/notification/exceptions.py new file mode 100644 index 0000000..e25795c --- /dev/null +++ b/core/edge_mining/domain/notification/exceptions.py @@ -0,0 +1,33 @@ +"""Collection of Exceptions.""" + +from edge_mining.domain.exceptions import DomainError + + +class NotificationError(DomainError): + """Errors related to notifications.""" + + pass + + +class NotifierError(NotificationError): + """Errors related to notifier.""" + + pass + + +class NotifierNotFoundError(NotifierError): + """Notifier not found.""" + + pass + + +class NotifierAlreadyExistsError(NotifierError): + """Notifier already exists.""" + + pass + + +class NotifierConfigurationError(NotifierError): + """Error with the configuration.""" + + pass diff --git a/core/edge_mining/domain/notification/ports.py b/core/edge_mining/domain/notification/ports.py new file mode 100644 index 0000000..c0743d4 --- /dev/null +++ b/core/edge_mining/domain/notification/ports.py @@ -0,0 +1,52 @@ +"""Collection of Ports for the Notification domain of the Edge Mining application.""" + +# Is it really necessary to have a domain dedicated to the notification service? + +from abc import ABC, abstractmethod +from typing import List, Optional + +from edge_mining.domain.common import EntityId +from edge_mining.domain.notification.entities import Notifier + + +class NotificationPort(ABC): + """Port for the Notification.""" + + @abstractmethod + async def send_notification(self, title: str, message: str) -> bool: + """Sends a notification to the configured channel(s).""" + raise NotImplementedError + + +class NotifierRepository(ABC): + """Port for the Notifier Repository.""" + + @abstractmethod + def add(self, notifier: Notifier) -> None: + """Adds a new notifier to the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_id(self, notifier_id: EntityId) -> Optional[Notifier]: + """Retrieves an notifier by its ID.""" + raise NotImplementedError + + @abstractmethod + def get_all(self) -> List[Notifier]: + """Retrieves all notifiers from the repository.""" + raise NotImplementedError + + @abstractmethod + def update(self, notifier: Notifier) -> None: + """Updates an notifier in the repository.""" + raise NotImplementedError + + @abstractmethod + def remove(self, notifier_id: EntityId) -> None: + """Removes an notifier from the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_external_service_id(self, external_service_id: EntityId) -> List[Notifier]: + """Retrieves a list of notifiers by its associated external service ID.""" + raise NotImplementedError diff --git a/core/edge_mining/domain/optimization_unit/__init__.py b/core/edge_mining/domain/optimization_unit/__init__.py new file mode 100644 index 0000000..bb8c069 --- /dev/null +++ b/core/edge_mining/domain/optimization_unit/__init__.py @@ -0,0 +1,3 @@ +""" +Energy Optimization Unit for the Edge Mining. +""" diff --git a/core/edge_mining/domain/optimization_unit/aggregate_roots.py b/core/edge_mining/domain/optimization_unit/aggregate_roots.py new file mode 100644 index 0000000..0324aa3 --- /dev/null +++ b/core/edge_mining/domain/optimization_unit/aggregate_roots.py @@ -0,0 +1,79 @@ +""" +Aggregate Roots for the Optimization Unit. + +Holds a reference to the policy to be used for the optimization, the target miners, +the energy source and so on. +""" + +from dataclasses import dataclass, field +from typing import List, Optional + +from edge_mining.domain.common import AggregateRoot, EntityId + + +@dataclass +class EnergyOptimizationUnit(AggregateRoot): + """Aggregate Root for the Energy Optimization Unit.""" + + name: str = "" + description: Optional[str] = None + is_enabled: bool = False + + # References to entities + policy_id: Optional[EntityId] = None # Policy to be used for the optimization + target_miner_ids: List[EntityId] = field(default_factory=list) # Miners to be controlled + energy_source_id: Optional[EntityId] = None # Energy source to be used + home_loads_profile: Optional[EntityId] = None # Home loads to manage + + # References to adapters + performance_tracker_id: Optional[EntityId] = None # Performance tracker to be used + notifier_ids: List[EntityId] = field(default_factory=list) # Notifiers to be used + + # We could add specific state attributes, + # like the last energy snapshot or the last decision taken. + # last_energy_snapshot: Optional[EnergyStateSnapshot] = None + # last_decision: Optional[MiningDecision] = None + + def add_target_miner(self, miner_id: EntityId): + """Add a target miner to the energy optimization unit.""" + if miner_id not in self.target_miner_ids: + self.target_miner_ids.append(miner_id) + + def remove_target_miner(self, miner_id: EntityId): + """Remove a target miner from the energy optimization unit.""" + if miner_id in self.target_miner_ids: + self.target_miner_ids.remove(miner_id) + + def assign_policy(self, policy_id: EntityId): + """Assign a policy to the energy optimization unit.""" + self.policy_id = policy_id + + def assign_energy_source(self, energy_source_id: EntityId): + """Assign an energy source to the energy optimization unit.""" + self.energy_source_id = energy_source_id + + def assign_home_loads_profile(self, profile_id: EntityId): + """Assign a home loads profile to the energy optimization unit.""" + self.home_loads_profile = profile_id + + def assign_performance_tracker(self, performance_tracker_id: EntityId): + """Assign a performance tracker to the energy optimization unit.""" + self.performance_tracker_id = performance_tracker_id + + def add_notifier(self, notifier_id: EntityId): + """Add a notifier to the energy optimization unit.""" + if notifier_id not in self.notifier_ids: + self.notifier_ids.append(notifier_id) + + def remove_notifier(self, notifier_id: EntityId): + """Remove a notifier from the energy optimization unit.""" + if notifier_id in self.notifier_ids: + self.notifier_ids.remove(notifier_id) + + def enable(self): + """Enable the energy optimization unit.""" + self.is_enabled = True + + def disable(self): + """Disable the energy optimization unit.""" + self.is_enabled = False diff --git a/core/edge_mining/domain/optimization_unit/common.py b/core/edge_mining/domain/optimization_unit/common.py new file mode 100644 index 0000000..3d156b3 --- /dev/null +++ b/core/edge_mining/domain/optimization_unit/common.py @@ -0,0 +1,3 @@ +""" +Common classes for the Optimization Unit. +""" diff --git a/core/edge_mining/domain/optimization_unit/events.py b/core/edge_mining/domain/optimization_unit/events.py new file mode 100644 index 0000000..68a1a28 --- /dev/null +++ b/core/edge_mining/domain/optimization_unit/events.py @@ -0,0 +1,20 @@ +"""Optimization unit domain events.""" + +from dataclasses import dataclass +from typing import Optional + +from edge_mining.domain.common import DomainEvent, EntityId +from edge_mining.domain.policy.common import MiningDecision + + +@dataclass +class RuleEngagedEvent(DomainEvent): + """Event emitted when a policy rule produces a mining decision.""" + + optimization_unit_id: Optional[EntityId] = None + optimization_unit_name: str = "" + policy_id: Optional[EntityId] = None + policy_name: str = "" + miner_id: Optional[EntityId] = None + decision: Optional[MiningDecision] = None + miner_status: str = "" diff --git a/core/edge_mining/domain/optimization_unit/exceptions.py b/core/edge_mining/domain/optimization_unit/exceptions.py new file mode 100644 index 0000000..47a9913 --- /dev/null +++ b/core/edge_mining/domain/optimization_unit/exceptions.py @@ -0,0 +1,27 @@ +"""Collection of Exceptions.""" + +from edge_mining.domain.exceptions import DomainError + + +class OptimizationUnitError(DomainError): + """Errors related to optimization units.""" + + pass + + +class OptimizationUnitNotFoundError(OptimizationUnitError): + """Optimization unit not found.""" + + pass + + +class OptimizationUnitAlreadyExistsError(OptimizationUnitError): + """Optimization unit already exists.""" + + pass + + +class OptimizationUnitConfigurationError(OptimizationUnitError): + """Error in optimization unit configuration.""" + + pass diff --git a/core/edge_mining/domain/optimization_unit/ports.py b/core/edge_mining/domain/optimization_unit/ports.py new file mode 100644 index 0000000..dfe30d4 --- /dev/null +++ b/core/edge_mining/domain/optimization_unit/ports.py @@ -0,0 +1,41 @@ +"""Collection of Ports for the Energy Optimization Unit domain of the Edge Mining application.""" + +from abc import ABC, abstractmethod +from typing import List, Optional + +from edge_mining.domain.common import EntityId +from edge_mining.domain.optimization_unit.aggregate_roots import EnergyOptimizationUnit + + +class EnergyOptimizationUnitRepository(ABC): + """Port for the Energy Optimization Unit Repository.""" + + @abstractmethod + def add(self, optimization_unit: EnergyOptimizationUnit) -> None: + """Add an energy optimization unit to the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_id(self, optimization_unit_id: EntityId) -> Optional[EnergyOptimizationUnit]: + """Get an energy optimization unit by its ID.""" + raise NotImplementedError + + @abstractmethod + def get_all_enabled(self) -> List[EnergyOptimizationUnit]: + """Get all enabled energy optimization units.""" + raise NotImplementedError + + @abstractmethod + def get_all(self) -> List[EnergyOptimizationUnit]: + """Get all energy optimization units.""" + raise NotImplementedError + + @abstractmethod + def update(self, optimization_unit: EnergyOptimizationUnit) -> None: + """Update an energy optimization unit in the repository.""" + raise NotImplementedError + + @abstractmethod + def remove(self, optimization_unit_id: EntityId) -> None: + """Remove an energy optimization unit from the repository.""" + raise NotImplementedError diff --git a/core/edge_mining/domain/optimization_unit/value_objects.py b/core/edge_mining/domain/optimization_unit/value_objects.py new file mode 100644 index 0000000..368d777 --- /dev/null +++ b/core/edge_mining/domain/optimization_unit/value_objects.py @@ -0,0 +1,3 @@ +""" +Value Objects for the Energy Optimization Unit. +""" diff --git a/core/edge_mining/domain/performance/__init__.py b/core/edge_mining/domain/performance/__init__.py new file mode 100644 index 0000000..779756f --- /dev/null +++ b/core/edge_mining/domain/performance/__init__.py @@ -0,0 +1 @@ +"""Mining Performace Analysis subdomain.""" diff --git a/core/edge_mining/domain/performance/common.py b/core/edge_mining/domain/performance/common.py new file mode 100644 index 0000000..7d71965 --- /dev/null +++ b/core/edge_mining/domain/performance/common.py @@ -0,0 +1,27 @@ +"""Collection of Common Objects for the Mining Performance Analysis domain of the Edge Mining application.""" + +from enum import Enum +from typing import NewType + +from edge_mining.domain.common import AdapterType + +# Using Satoshi as the unit for rewards +Satoshi = NewType("Satoshi", int) + + +class MiningPerformanceTrackerAdapter(AdapterType): + """Types of mining performance tracker adapter.""" + + DUMMY = "dummy" + OCEAN = "ocean" + BRAIINS_POOL = "braiins_pool" + + +class PayoutFrequency(str, Enum): + """How often a pool issues payouts.""" + + UNKNOWN = "unknown" + PER_BLOCK = "per_block" + HOURLY = "hourly" + DAILY = "daily" + THRESHOLD = "threshold" diff --git a/core/edge_mining/domain/performance/entities.py b/core/edge_mining/domain/performance/entities.py new file mode 100644 index 0000000..a8d233f --- /dev/null +++ b/core/edge_mining/domain/performance/entities.py @@ -0,0 +1,34 @@ +"""Collection of Entities for the Mining Performance Analysis domain of the Edge Mining application.""" + +from dataclasses import dataclass, field +from typing import List, Optional + +from edge_mining.domain.common import Entity, EntityId, Timestamp, utc_now_timestamp +from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.domain.performance.common import ( + MiningPerformanceTrackerAdapter, + Satoshi, +) +from edge_mining.shared.interfaces.config import MiningPerformanceTrackerConfig + + +@dataclass +class MiningPerformanceTracker(Entity): + """Entity for tracking mining performance.""" + + name: str = "" + adapter_type: MiningPerformanceTrackerAdapter = MiningPerformanceTrackerAdapter.DUMMY + config: Optional[MiningPerformanceTrackerConfig] = None + external_service_id: Optional[EntityId] = None + + +@dataclass +class MiningSession(Entity): + """Entity for a mining session.""" + + tracker_id: Optional[EntityId] = None + miner_ids: List[EntityId] = field(default_factory=list) + start_time: Timestamp = field(default_factory=utc_now_timestamp) + end_time: Optional[Timestamp] = None + total_reward: Optional[Satoshi] = None + average_hashrate: Optional[HashRate] = None diff --git a/core/edge_mining/domain/performance/events.py b/core/edge_mining/domain/performance/events.py new file mode 100644 index 0000000..09b9e70 --- /dev/null +++ b/core/edge_mining/domain/performance/events.py @@ -0,0 +1,27 @@ +"""Mining Performance Analysis domain events.""" + +from dataclasses import dataclass +from typing import Optional + +from edge_mining.domain.common import DomainEvent, EntityId +from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.domain.performance.value_objects import MiningReward + + +@dataclass +class RewardReceivedEvent(DomainEvent): + """Event emitted when a new mining reward is observed by a tracker.""" + + tracker_id: Optional[EntityId] = None + miner_id: Optional[EntityId] = None + reward: Optional[MiningReward] = None + + +@dataclass +class HashrateDropDetectedEvent(DomainEvent): + """Event emitted when the observed pool-side hashrate falls under a threshold.""" + + tracker_id: Optional[EntityId] = None + miner_id: Optional[EntityId] = None + expected: Optional[HashRate] = None + actual: Optional[HashRate] = None diff --git a/core/edge_mining/domain/performance/exceptions.py b/core/edge_mining/domain/performance/exceptions.py new file mode 100644 index 0000000..e94296e --- /dev/null +++ b/core/edge_mining/domain/performance/exceptions.py @@ -0,0 +1,57 @@ +"""Collection of Exceptions.""" + +from edge_mining.domain.exceptions import DomainError + + +class MiningPerformanceTrackerError(DomainError): + """Base class for performance tracker-specific errors.""" + + pass + + +class MiningPerformanceTrackerNotFoundError(MiningPerformanceTrackerError): + """Performance Tracker not found.""" + + pass + + +class MiningPerformanceTrackerAlreadyExistsError(MiningPerformanceTrackerError): + """Performance Tracker already exists.""" + + pass + + +class MiningPerformanceTrackerConfigurationError(MiningPerformanceTrackerError): + """Error with the configuration.""" + + pass + + +class MiningPoolUnreachableError(MiningPerformanceTrackerError): + """The mining pool API could not be reached (timeout, network error, 5xx).""" + + pass + + +class MiningPoolAuthError(MiningPerformanceTrackerError): + """Authentication against the mining pool API failed (401/403).""" + + pass + + +class MiningPoolResponseError(MiningPerformanceTrackerError): + """The mining pool API returned an unexpected or malformed response.""" + + pass + + +class MiningPoolRateLimitedError(MiningPerformanceTrackerError): + """The mining pool API returned HTTP 429 (or equivalent throttling). + + Carries an optional ``retry_after`` hint (seconds) extracted from the + ``Retry-After`` header, when present. + """ + + def __init__(self, message: str = "", retry_after: float | None = None): + super().__init__(message) + self.retry_after = retry_after diff --git a/core/edge_mining/domain/performance/ports.py b/core/edge_mining/domain/performance/ports.py new file mode 100644 index 0000000..7ea17ae --- /dev/null +++ b/core/edge_mining/domain/performance/ports.py @@ -0,0 +1,80 @@ +"""Collection of Ports for the Mining Performance Analysis domain of the Edge Mining application.""" + +from abc import ABC, abstractmethod +from typing import List, Optional + +from edge_mining.domain.common import EntityId +from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.domain.performance.entities import MiningPerformanceTracker +from edge_mining.domain.performance.value_objects import ( + MiningReward, + PayoutSchedule, + PoolStats, + PoolWorkerStats, +) + + +class MiningPerformanceTrackerPort(ABC): + """Port for the Mining Performance Tracker.""" + + @abstractmethod + async def get_current_hashrate(self, miner_ids: List[EntityId]) -> Optional[HashRate]: + """Gets the current hashrate from the pool or devices.""" + raise NotImplementedError + + @abstractmethod + async def get_recent_rewards(self, miner_id: Optional[EntityId] = None, limit: int = 10) -> List[MiningReward]: + """Gets recent mining rewards.""" + raise NotImplementedError + + @abstractmethod + async def get_pool_stats(self) -> Optional[PoolStats]: + """Gets account-level pool statistics (hashrate aggregates, balance, payout).""" + raise NotImplementedError + + @abstractmethod + async def get_worker_stats(self, miner_ids: List[EntityId]) -> List[PoolWorkerStats]: + """Gets per-worker statistics as reported by the pool.""" + raise NotImplementedError + + @abstractmethod + async def get_payout_schedule(self) -> Optional[PayoutSchedule]: + """Gets the payout policy advertised by the pool, if known.""" + raise NotImplementedError + + +class MiningPerformanceTrackerRepository(ABC): + """Port for the Mining Performance Tracker Repository.""" + + @abstractmethod + def add(self, tracker: MiningPerformanceTracker) -> None: + """Adds a new mining performance tracker to the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_id(self, tracker_id: EntityId) -> Optional[MiningPerformanceTracker]: + """Retrieves a mining performance tracker by its ID.""" + raise NotImplementedError + + @abstractmethod + def get_all(self) -> List[MiningPerformanceTracker]: + """Retrieves all mining performance trackers from the repository.""" + raise NotImplementedError + + @abstractmethod + def update(self, tracker: MiningPerformanceTracker) -> None: + """Updates a mining performance tracker in the repository.""" + raise NotImplementedError + + @abstractmethod + def remove(self, tracker_id: EntityId) -> None: + """Removes a mining performance tracker from the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_external_service_id(self, external_service_id: EntityId) -> List[MiningPerformanceTracker]: + """ + Retrieves a list of mining performance trackers + by its associated external service ID. + """ + raise NotImplementedError diff --git a/core/edge_mining/domain/performance/value_objects.py b/core/edge_mining/domain/performance/value_objects.py new file mode 100644 index 0000000..675f2fb --- /dev/null +++ b/core/edge_mining/domain/performance/value_objects.py @@ -0,0 +1,65 @@ +"""Collection of Value Objects for the Mining Performance Analysis domain of the Edge Mining application.""" + +from dataclasses import dataclass, field +from typing import List, Optional + +from edge_mining.domain.common import Timestamp, ValueObject, utc_now_timestamp +from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.domain.performance.common import PayoutFrequency, Satoshi + + +@dataclass(frozen=True) +class MiningReward(ValueObject): + """Value Object for a mining reward.""" + + amount: Satoshi + timestamp: Timestamp = field(default_factory=utc_now_timestamp) + + +@dataclass(frozen=True) +class PoolWorkerStats(ValueObject): + """Value Object describing live statistics of a single worker as reported by the pool.""" + + worker_name: str + hashrate: Optional[HashRate] = None + last_share_at: Optional[Timestamp] = None + valid_shares: Optional[int] = None + stale_shares: Optional[int] = None + rejected_shares: Optional[int] = None + + +@dataclass(frozen=True) +class PoolStats(ValueObject): + """Value Object aggregating account-level statistics returned by a mining pool.""" + + current_hashrate: Optional[HashRate] = None + average_hashrate_24h: Optional[HashRate] = None + average_hashrate_7d: Optional[HashRate] = None + unpaid_balance: Optional[Satoshi] = None + estimated_next_payout: Optional[Satoshi] = None + workers: List[PoolWorkerStats] = field(default_factory=list) + timestamp: Timestamp = field(default_factory=utc_now_timestamp) + + +@dataclass(frozen=True) +class PayoutSchedule(ValueObject): + """Value Object describing the payout policy advertised by the pool.""" + + frequency: PayoutFrequency = PayoutFrequency.UNKNOWN + threshold: Optional[Satoshi] = None + next_payout_at: Optional[Timestamp] = None + + +@dataclass(frozen=True) +class MiningPerformanceSnapshot(ValueObject): + """Consolidated snapshot of live data returned by a Mining Performance Tracker. + + Grouped into a single Value Object so that decision-making components can + consume pool-side state as a cohesive unit, with one timestamp certifying + the freshness of all contained fields. + """ + + current_hashrate: Optional[HashRate] = None + pool_stats: Optional[PoolStats] = None + payout_schedule: Optional[PayoutSchedule] = None + timestamp: Timestamp = field(default_factory=utc_now_timestamp) diff --git a/core/edge_mining/domain/policy/__init__.py b/core/edge_mining/domain/policy/__init__.py new file mode 100644 index 0000000..f5d974f --- /dev/null +++ b/core/edge_mining/domain/policy/__init__.py @@ -0,0 +1 @@ +"""Energy Optimization subdomain.""" diff --git a/core/edge_mining/domain/policy/aggregate_roots.py b/core/edge_mining/domain/policy/aggregate_roots.py new file mode 100644 index 0000000..bbb2885 --- /dev/null +++ b/core/edge_mining/domain/policy/aggregate_roots.py @@ -0,0 +1,78 @@ +""" +Collection of Aggregate Roots for the Energy Optimization domain +of the Edge Mining application. +""" + +from dataclasses import dataclass, field +from typing import List, Optional + +from edge_mining.domain.common import AggregateRoot +from edge_mining.domain.miner.common import MinerStatus +from edge_mining.domain.policy.common import MiningDecision +from edge_mining.domain.policy.entities import AutomationRule +from edge_mining.domain.policy.services import RuleEngine +from edge_mining.domain.policy.value_objects import DecisionalContext + + +@dataclass +class OptimizationPolicy(AggregateRoot): + """Aggregate Root for the Optimization Policy.""" + + name: str = "" + description: Optional[str] = None + + start_rules: List[AutomationRule] = field(default_factory=list) + stop_rules: List[AutomationRule] = field(default_factory=list) + + def sort_rules(self) -> None: + """Sort rules by priority.""" + self.start_rules.sort(key=lambda r: r.priority, reverse=True) + self.stop_rules.sort(key=lambda r: r.priority, reverse=True) + + def decide_next_action(self, decisional_context: DecisionalContext, rule_engine: RuleEngine) -> MiningDecision: + """ + Applies the policy rules to determine the next action. + This is the core decision-making logic. + """ + + if not decisional_context.miner_state: + raise ValueError("Error while evaluating policy: Miner state is not set in the context.") + + print(f"Policy '{self.name}': Evaluating state for miner status {decisional_context.miner_state.status.name}") + + # Logic: + # 1. If miner is OFF, check START rules. If any match -> START_MINING + # 2. If miner is ON, check STOP rules. If any match -> STOP_MINING + # 3. Otherwise -> MAINTAIN_STATE + + # This is the location where the magic happens! + + # Sort the rules by priority before evaluation + self.sort_rules() + + # Load rules into the rule engine based on miner status + if decisional_context.miner_state.status in [ + MinerStatus.OFF, + MinerStatus.ERROR, + MinerStatus.UNKNOWN, + ]: + rule_engine.load_rules(self.start_rules) + + # Evaluate the rules in the rule engine + if rule_engine.evaluate(decisional_context): + # If any START rule matches, return START_MINING decision + return MiningDecision.START_MINING + elif decisional_context.miner_state.status in [MinerStatus.ON]: + rule_engine.load_rules(self.stop_rules) + + # Evaluate the rules in the rule engine + if rule_engine.evaluate(decisional_context): + # If any STOP rule matches, return STOP_MINING decision + return MiningDecision.STOP_MINING + else: + # For STARTING/STOPPING states, usually maintain state until confirmed + # ON/OFF + return MiningDecision.MAINTAIN_STATE + + # If no rules matched, maintain the current state + return MiningDecision.MAINTAIN_STATE diff --git a/core/edge_mining/domain/policy/common.py b/core/edge_mining/domain/policy/common.py new file mode 100644 index 0000000..6d278b6 --- /dev/null +++ b/core/edge_mining/domain/policy/common.py @@ -0,0 +1,64 @@ +"""Collection of Common Objects for the Energy Optimization domain of the Edge Mining application.""" + +from enum import Enum +from typing import Dict + + +# Decision object +class MiningDecision(Enum): + """Types of different mining decisions.""" + + START_MINING = "start_mining" + STOP_MINING = "stop_mining" + MAINTAIN_STATE = "maintain_state" + # Could add more granular decisions later, e.g., ADJUST_POWER + + +# Rule type +class RuleType(Enum): + """Types of different rules.""" + + START = "start" + STOP = "stop" + # Could add more types of rules in the future + + +class RuleEngineType(Enum): + """Types of rule engines.""" + + CUSTOM = "custom" # Custom rule engine + + +# Operator types for rule conditions +class OperatorType(Enum): + """Supported operators for rule conditions.""" + + EQ = "eq" # equal + NE = "ne" # not equal + GT = "gt" # greater than + GTE = "gte" # greater than or equal + LT = "lt" # less than + LTE = "lte" # less than or equal + IN = "in" # in list/array + NOT_IN = "not_in" # not in list/array + CONTAINS = "contains" # string contains + STARTS_WITH = "starts_with" # string starts with + ENDS_WITH = "ends_with" # string ends with + REGEX = "regex" # regex match + + +# Mapping of operators to their symbolic representation +OPERATOR_SYMBOLS: Dict[OperatorType, str] = { + OperatorType.EQ: "==", + OperatorType.NE: "!=", + OperatorType.GT: ">", + OperatorType.GTE: ">=", + OperatorType.LT: "<", + OperatorType.LTE: "<=", + OperatorType.IN: "∈", + OperatorType.NOT_IN: "∉", + OperatorType.CONTAINS: "⊃", + OperatorType.STARTS_WITH: "^", + OperatorType.ENDS_WITH: "$", + OperatorType.REGEX: "~", +} diff --git a/core/edge_mining/domain/policy/entities.py b/core/edge_mining/domain/policy/entities.py new file mode 100644 index 0000000..3add6ee --- /dev/null +++ b/core/edge_mining/domain/policy/entities.py @@ -0,0 +1,16 @@ +"""Collection of Entities for the Energy Optimization domain of the Edge Mining application.""" + +from dataclasses import dataclass, field + +from edge_mining.domain.common import Entity + + +@dataclass +class AutomationRule(Entity): + """Entity for an automation rule.""" + + name: str = "" + description: str = "" + priority: int = 0 # Priority for rule evaluation (higher numbers = higher priority) + enabled: bool = True + conditions: dict = field(default_factory=dict) diff --git a/core/edge_mining/domain/policy/events.py b/core/edge_mining/domain/policy/events.py new file mode 100644 index 0000000..5da676c --- /dev/null +++ b/core/edge_mining/domain/policy/events.py @@ -0,0 +1,17 @@ +"""Policy domain events.""" + +from dataclasses import dataclass, field +from typing import List, Optional + +from edge_mining.domain.common import DomainEvent, EntityId +from edge_mining.domain.policy.value_objects import DecisionalContext + + +@dataclass +class DecisionalContextUpdatedEvent(DomainEvent): + """Event emitted when a new decisional context is composed for an optimization unit.""" + + optimization_unit_id: Optional[EntityId] = None + optimization_unit_name: str = "" + context: Optional[DecisionalContext] = None + target_miner_ids: List[EntityId] = field(default_factory=list) diff --git a/core/edge_mining/domain/policy/exceptions.py b/core/edge_mining/domain/policy/exceptions.py new file mode 100644 index 0000000..b095c91 --- /dev/null +++ b/core/edge_mining/domain/policy/exceptions.py @@ -0,0 +1,81 @@ +"""Collection of Exceptions.""" + +from edge_mining.domain.exceptions import DomainError + + +class PolicyError(DomainError): + """Errors related to optimization policies.""" + + pass + + +class PolicyNotFoundError(PolicyError): + """Optimization policy not found.""" + + pass + + +class PolicyAlreadyExistsError(PolicyError): + """Optimization policy already exists.""" + + pass + + +class InvalidRuleError(PolicyError): + """Invalid automation rule.""" + + pass + + +class RuleNotFoundError(PolicyError): + """Automation rule not found.""" + + pass + + +class PolicyConfigurationError(PolicyError): + """Error in policy configuration.""" + + pass + + +class RuleEngineError(Exception): + """Base exception for rule engine errors.""" + + pass + + +class RuleLoadError(RuleEngineError): + """Exception raised when rules fail to load.""" + + pass + + +class RuleEvaluationError(RuleEngineError): + """Exception raised during rule evaluation.""" + + pass + + +class RuleValidationError(RuleEngineError): + """Exception raised during rule validation.""" + + pass + + +class UnsupportedConditionError(RuleEngineError): + """Exception raised when an unsupported condition is used.""" + + pass + + +class UnsupportedOperatorError(RuleEngineError): + """Exception raised when an unsupported operator is used.""" + + pass + + +class InvalidContextError(RuleEngineError): + """Exception raised when the decisional context is invalid.""" + + pass diff --git a/core/edge_mining/domain/policy/ports.py b/core/edge_mining/domain/policy/ports.py new file mode 100644 index 0000000..aa4e1f2 --- /dev/null +++ b/core/edge_mining/domain/policy/ports.py @@ -0,0 +1,37 @@ +"""Collection of Ports for the Energy Optimization domain of the Edge Mining application.""" + +from abc import ABC, abstractmethod +from typing import List, Optional + +from edge_mining.domain.common import EntityId +from edge_mining.domain.policy.aggregate_roots import OptimizationPolicy + + +class OptimizationPolicyRepository(ABC): + """Port for the Optimization Policy Repository.""" + + @abstractmethod + def add(self, policy: OptimizationPolicy) -> None: + """Adds a policy to the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_id(self, policy_id: EntityId) -> Optional[OptimizationPolicy]: + """Gets a policy by its ID.""" + raise NotImplementedError + + @abstractmethod + def get_all(self) -> List[OptimizationPolicy]: + """Gets all policies from the repository.""" + raise NotImplementedError + + @abstractmethod + def update(self, policy: OptimizationPolicy) -> None: + """Updates a policy in the repository.""" + # Handles activating/deactivating policies as well + raise NotImplementedError + + @abstractmethod + def remove(self, policy_id: EntityId) -> None: + """Removes a policy by its ID.""" + raise NotImplementedError diff --git a/core/edge_mining/domain/policy/services.py b/core/edge_mining/domain/policy/services.py new file mode 100644 index 0000000..038edf5 --- /dev/null +++ b/core/edge_mining/domain/policy/services.py @@ -0,0 +1,318 @@ +"""Domain services for the Energy Optimization domain.""" + +import re +from abc import ABC, abstractmethod +from typing import Any, List, Tuple, Union + +from edge_mining.domain.policy.common import OperatorType, RuleEngineType +from edge_mining.domain.policy.entities import AutomationRule +from edge_mining.domain.policy.value_objects import DecisionalContext + + +class RuleValidationService: + """Domain service for validating automation rule conditions.""" + + def validate_conditions(self, conditions: dict) -> Tuple[bool, List[str], List[str]]: + """ + Validate rule conditions structure and semantics. + + Args: + conditions: Dictionary representing the rule conditions + + Returns: + Tuple[bool, List[str], List[str]]: (is_valid, syntax_errors, field_errors) + """ + return self._validate_recursively(conditions, "") + + def _validate_recursively(self, cond_dict: Any, path: str = "") -> Tuple[bool, List[str], List[str]]: + """ + Recursively validate condition structure and return validation results. + + Returns: + tuple: (is_valid, syntax_errors, field_errors) + """ + syntax_errors: List[str] = [] + field_errors: List[str] = [] + is_valid = True + + if not isinstance(cond_dict, dict): + syntax_errors.append(f"Invalid condition at {path}: expected dict, got {type(cond_dict).__name__}") + return False, syntax_errors, field_errors + + # Check if it's a rule condition (has field, operator, value) + if self._is_rule_condition(cond_dict): + # Validate field path + field_path = cond_dict.get("field", "") + valid_field, field_error = self._validate_field_path(path, field_path) + if not valid_field: + field_errors.append(field_error) + is_valid = False + + # Validate operator + operator = cond_dict.get("operator") + if operator is None: + syntax_errors.append(f"Missing operator at {path}") + is_valid = False + else: + valid_operator, operator_error = self._validate_operator(path, operator) + if not valid_operator: + syntax_errors.append(operator_error) + is_valid = False + + # Validate value + value = cond_dict.get("value") + if operator is not None: + valid_value, value_error = self._validate_value(path, value, field_path, operator) + if not valid_value: + syntax_errors.append(value_error) + is_valid = False + + # Check logical groups (all_of, any_of, not_) + elif self._is_logical_group(cond_dict): + for key, sub_conditions in cond_dict.items(): + if key in ["all_of", "any_of"] and isinstance(sub_conditions, list): + for i, sub_cond in enumerate(sub_conditions): + sub_valid, sub_syntax_errors, sub_field_errors = self._validate_recursively( + sub_cond, f"{path}.{key}[{i}]" + ) + if not sub_valid: + is_valid = False + syntax_errors.extend(sub_syntax_errors) + field_errors.extend(sub_field_errors) + elif key == "not_" and sub_conditions: + sub_valid, sub_syntax_errors, sub_field_errors = self._validate_recursively( + sub_conditions, f"{path}.{key}" + ) + if not sub_valid: + is_valid = False + syntax_errors.extend(sub_syntax_errors) + field_errors.extend(sub_field_errors) + else: + syntax_errors.append(f"Invalid condition structure at {path}: {cond_dict}") + is_valid = False + + return is_valid, syntax_errors, field_errors + + def _is_rule_condition(self, cond_dict: dict) -> bool: + """Check if dictionary represents a rule condition.""" + return "field" in cond_dict and "operator" in cond_dict and "value" in cond_dict + + def _is_logical_group(self, cond_dict: dict) -> bool: + """Check if dictionary represents a logical group.""" + return any(k in cond_dict for k in ["all_of", "any_of", "not_"]) + + def _validate_field_path(self, path: str, field_path: str) -> Tuple[bool, str]: + """ + Validate if the given field path is valid within the decisional context. + + Args: + path: The current path in the condition structure + field_path: The field path to validate + + Returns: + Tuple[bool, str]: (is_valid, error_message) + """ + if not field_path or not isinstance(field_path, str): + return False, f"Invalid field path at {path}: '{field_path}'" + + # Validate field path exists in DecisionalContext structure + parts = field_path.split(".") + current_type = DecisionalContext + + for i, part in enumerate(parts): + # Check if part is a numeric index (for array access) + if part.isdigit(): + # Numeric index - extract the item type from the list/array + if hasattr(current_type, "__origin__"): + # Handle List, Sequence, etc. + if hasattr(current_type, "__args__") and current_type.__args__: + # Get the item type from List[ItemType] + current_type = current_type.__args__[0] + continue + else: + return ( + False, + f"Invalid field path at {path}: '{field_path}' - " + f"cannot determine item type for array index '{part}'", + ) + else: + return ( + False, + f"Invalid field path at {path}: '{field_path}' - index '{part}' used on non-array type", + ) + + # Check if current_type is a built-in type (doesn't have __annotations__) + # and try to access the attribute directly + if not hasattr(current_type, "__annotations__"): + # Try to access the attribute on the type to verify it exists + if hasattr(current_type, part): + # Attribute exists - this is valid for built-in types like datetime.hour + # Since we can't introspect further on built-in types, this must be a leaf node + if i < len(parts) - 1: + return ( + False, + f"Invalid field path at {path}: '{field_path}' - " + f"cannot traverse beyond '{part}' on built-in type", + ) + else: + return True, "" + else: + type_name = current_type.__name__ if hasattr(current_type, "__name__") else str(current_type) + return ( + False, + f"Invalid field path at {path}: '{field_path}' - attribute '{part}' not found on {type_name}", + ) + + annotations = current_type.__annotations__ + + if part in annotations: + field_type = annotations[part] + + # Handle Optional types (Union[X, None]) + if hasattr(field_type, "__origin__"): + if field_type.__origin__ is Union: + args = [arg for arg in field_type.__args__ if arg is not type(None)] + if args: + field_type = args[0] + + current_type = field_type + elif hasattr(current_type, part): + attr = getattr(current_type, part) + if isinstance(attr, property): + if hasattr(attr.fget, "__annotations__") and "return" in attr.fget.__annotations__: + field_type = attr.fget.__annotations__["return"] + current_type = field_type + else: + if i < len(parts) - 1: + return ( + False, + f"Invalid field path at {path}: '{field_path}' - " + f"cannot traverse beyond property '{part}' without type annotation", + ) + else: + return True, "" + else: + return ( + False, + f"Invalid field path at {path}: '{field_path}' - '{part}' is not a valid field or property", + ) + else: + available_fields = list(annotations.keys()) + properties = [ + name for name in dir(current_type) if isinstance(getattr(current_type, name, None), property) + ] + available_fields.extend(properties) + return ( + False, + f"Invalid field path at {path}: '{field_path}' - " + f"field '{part}' not found in {current_type.__name__}. " + f"Available fields: {', '.join(available_fields)}", + ) + + return True, "" + + def _validate_operator(self, path: str, operator: Union[str, OperatorType]) -> Tuple[bool, str]: + """ + Validate if the given operator is valid. + + Args: + path: The current path in the condition structure + operator: The operator to validate + + Returns: + Tuple[bool, str]: (is_valid, error_message) + """ + try: + if isinstance(operator, str): + if operator.lower() not in [op.value for op in OperatorType]: + raise ValueError(f"Invalid operator: {operator}") + elif not isinstance(operator, OperatorType): + raise ValueError(f"Invalid operator type: {type(operator)}") + except ValueError: + return False, f"Invalid operator at {path}: '{operator}'" + + return True, "" + + def _validate_value( + self, path: str, value: Any, field_path: str, operator: Union[str, OperatorType] + ) -> Tuple[bool, str]: + """ + Validate if the given value is valid for the specified field and operator. + + Args: + path: The current path in the condition structure + value: The value to validate + field_path: The field path this value is for + operator: The operator being used + + Returns: + Tuple[bool, str]: (is_valid, error_message) + """ + if value is None: + return False, f"Missing or null value at {path}" + + # Normalize operator + if isinstance(operator, str): + try: + operator_type = OperatorType(operator.lower()) + except ValueError: + return False, f"Cannot validate value: invalid operator '{operator}' at {path}" + else: + operator_type = operator + + # Validate value type based on operator + if operator_type in [OperatorType.IN, OperatorType.NOT_IN]: + if not isinstance(value, (list, tuple)): + return False, f"Value at {path} must be a list/array for '{operator_type.value}' operator" + if len(value) == 0: + return False, f"Value list at {path} cannot be empty for '{operator_type.value}' operator" + + elif operator_type in [OperatorType.CONTAINS, OperatorType.STARTS_WITH, OperatorType.ENDS_WITH]: + if not isinstance(value, str): + return False, f"Value at {path} must be a string for '{operator_type.value}' operator" + if len(value) == 0: + return False, f"Value string at {path} cannot be empty for '{operator_type.value}' operator" + + elif operator_type == OperatorType.REGEX: + if not isinstance(value, str): + return False, f"Value at {path} must be a string (regex pattern) for '{operator_type.value}' operator" + try: + re.compile(value) + except re.error as e: + return False, f"Invalid regex pattern at {path}: {str(e)}" + + elif operator_type in [OperatorType.GT, OperatorType.GTE, OperatorType.LT, OperatorType.LTE]: + if not isinstance(value, (int, float, str)): + return ( + False, + f"Value at {path} must be numeric or comparable for '{operator_type.value}' operator", + ) + + return True, "" + + +class RuleEngine(ABC): + """Domain service for rule evaluation.""" + + @abstractmethod + def load_rules(self, rules: List[AutomationRule]) -> None: + """ + Loads rules. This method should be called before evaluating any rules. + """ + raise NotImplementedError + + @abstractmethod + def evaluate(self, context: DecisionalContext) -> bool: + """ + Evaluates rules based on the given context and returns True if any rule matches. + If no rules match, returns False. + This is the core decision-making logic of the rule engine. + """ + raise NotImplementedError + + @abstractmethod + def get_type(self) -> RuleEngineType: + """ + Returns the type of the rule engine. + """ + raise NotImplementedError diff --git a/core/edge_mining/domain/policy/value_objects.py b/core/edge_mining/domain/policy/value_objects.py new file mode 100644 index 0000000..4eee9e2 --- /dev/null +++ b/core/edge_mining/domain/policy/value_objects.py @@ -0,0 +1,35 @@ +"""Collection of Value Objects for the Energy Optimization domain of the Edge Mining application.""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional + +from edge_mining.domain.common import ValueObject +from edge_mining.domain.energy.entities import EnergySource +from edge_mining.domain.energy.value_objects import EnergyStateSnapshot +from edge_mining.domain.forecast.aggregate_root import Forecast +from edge_mining.domain.forecast.value_objects import Sun +from edge_mining.domain.home_load.value_objects import HomeLoadsConsumption +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.value_objects import MinerStateSnapshot +from edge_mining.domain.performance.value_objects import MiningPerformanceSnapshot + + +@dataclass(frozen=True) +class DecisionalContext(ValueObject): + """Value Object for the context of a mining decision.""" + + energy_source: Optional[EnergySource] + energy_state: Optional[EnergyStateSnapshot] + + forecast: Optional[Forecast] + + home_load: Optional[HomeLoadsConsumption] = None + + mining_performance: Optional[MiningPerformanceSnapshot] = None + + sun: Optional[Sun] = field(default=None) + + miner: Optional[Miner] = field(default=None) + miner_state: Optional[MinerStateSnapshot] = field(default=None) + timestamp: datetime = field(default_factory=datetime.now) diff --git a/core/edge_mining/domain/user/__init__.py b/core/edge_mining/domain/user/__init__.py new file mode 100644 index 0000000..d134422 --- /dev/null +++ b/core/edge_mining/domain/user/__init__.py @@ -0,0 +1 @@ +"""User Settings subdomain.""" diff --git a/core/edge_mining/domain/user/common.py b/core/edge_mining/domain/user/common.py new file mode 100644 index 0000000..16d613d --- /dev/null +++ b/core/edge_mining/domain/user/common.py @@ -0,0 +1,5 @@ +"""Collection of Common Objects for the User Settings domain of the Edge Mining application.""" + +from typing import NewType + +UserId = NewType("UserId", str) # Or use UUID diff --git a/core/edge_mining/domain/user/entities.py b/core/edge_mining/domain/user/entities.py new file mode 100644 index 0000000..9596813 --- /dev/null +++ b/core/edge_mining/domain/user/entities.py @@ -0,0 +1,31 @@ +"""Collection of Entities for the User Settings domain of the Edge Mining application.""" + +from dataclasses import dataclass, field +from typing import Any, Dict + +from edge_mining.domain.common import Entity +from edge_mining.domain.user.common import UserId + + +@dataclass +class User(Entity): + """Entity for a user.""" + + username: str = "" + # Add password hash, roles etc. if needed + + +@dataclass +class SystemSettings: + """Entity for the system settings.""" + + id: UserId # Or a fixed ID like 'global_settings' + settings: Dict[str, Any] = field(default_factory=dict) + + def get_setting(self, key: str, default: Any = None) -> Any: + """Get a setting by its key.""" + return self.settings.get(key, default) + + def set_setting(self, key: str, value: Any): + """Set a setting by its key.""" + self.settings[key] = value diff --git a/core/edge_mining/domain/user/ports.py b/core/edge_mining/domain/user/ports.py new file mode 100644 index 0000000..a1ff909 --- /dev/null +++ b/core/edge_mining/domain/user/ports.py @@ -0,0 +1,18 @@ +"""Collection of Ports for the User Settings domain of the Edge Mining application.""" + +from abc import ABC, abstractmethod +from typing import Optional + +from edge_mining.domain.user.common import UserId +from edge_mining.domain.user.entities import User + + +class UserRepository(ABC): + """Port for the User Repository.""" + + @abstractmethod + def get_by_id(self, user_id: UserId) -> Optional[User]: + """Gets a user by its ID.""" + raise NotImplementedError + + # ... other methods as needed diff --git a/core/edge_mining/shared/__init__.py b/core/edge_mining/shared/__init__.py new file mode 100644 index 0000000..bbd3047 --- /dev/null +++ b/core/edge_mining/shared/__init__.py @@ -0,0 +1 @@ +"""Collection of shared elements that are not domain specific but are used across the application.""" diff --git a/core/edge_mining/shared/adapter_configs/__init__.py b/core/edge_mining/shared/adapter_configs/__init__.py new file mode 100644 index 0000000..28532f7 --- /dev/null +++ b/core/edge_mining/shared/adapter_configs/__init__.py @@ -0,0 +1 @@ +"""Collection of shared adapter configurations for the Edge Mining application.""" diff --git a/core/edge_mining/shared/adapter_configs/energy.py b/core/edge_mining/shared/adapter_configs/energy.py new file mode 100644 index 0000000..8339d2d --- /dev/null +++ b/core/edge_mining/shared/adapter_configs/energy.py @@ -0,0 +1,69 @@ +"""Collection of adapters configuration for the energy domain of the Edge Mining application.""" + +from dataclasses import asdict, dataclass, field + +from edge_mining.domain.common import Watts +from edge_mining.domain.energy.common import EnergyMonitorAdapter +from edge_mining.shared.interfaces.config import EnergyMonitorConfig + + +@dataclass(frozen=True) +class EnergyMonitorDummySolarConfig(EnergyMonitorConfig): + """Energy monitor configuration""" + + max_consumption_power: Watts = field(default=Watts(3200.0)) # Default max consumption power + + def is_valid(self, adapter_type: EnergyMonitorAdapter) -> bool: + """ + Check if the configuration is valid for the given adapter type. + For Dummy Solar, it is always valid. + """ + return adapter_type == EnergyMonitorAdapter.DUMMY_SOLAR + + def to_dict(self) -> dict: + """Converts the configuration object into a serializable dictionary""" + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + """Create a configuration object from a dictionary""" + max_consumption_power = Watts(data.get("max_consumption_power", 3200.0)) + return EnergyMonitorDummySolarConfig(max_consumption_power=max_consumption_power) + + +@dataclass(frozen=True) +class EnergyMonitorHomeAssistantConfig(EnergyMonitorConfig): + """ + Energy monitor configuration. It encapsulate the configuration parameters + to retrieve energy data from Home Assistant. + """ + + entity_production: str + entity_consumption: str + entity_grid: str = field(default="") + entity_battery_soc: str = field(default="") + entity_battery_power: str = field(default="") + entity_battery_remaining_capacity: str = field(default="") + unit_production: str = field(default="W") + unit_consumption: str = field(default="W") + unit_grid: str = field(default="W") + unit_battery_power: str = field(default="W") + unit_battery_remaining_capacity: str = field(default="Wh") + grid_positive_export: bool = field(default=False) + battery_positive_charge: bool = field(default=True) + + def is_valid(self, adapter_type: EnergyMonitorAdapter) -> bool: + """ + Check if the configuration is valid for the given adapter type. + For Home Assistant, it is always valid. + """ + return adapter_type == EnergyMonitorAdapter.HOME_ASSISTANT_API + + def to_dict(self) -> dict: + """Converts the configuration object into a serializable dictionary""" + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + """Create a configuration object from a dictionary""" + return cls(**data) diff --git a/core/edge_mining/shared/adapter_configs/external_services.py b/core/edge_mining/shared/adapter_configs/external_services.py new file mode 100644 index 0000000..360f56b --- /dev/null +++ b/core/edge_mining/shared/adapter_configs/external_services.py @@ -0,0 +1,33 @@ +"""Collection of adapters configuration for the external services of the Edge Mining application.""" + +from dataclasses import asdict, dataclass + +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.interfaces.config import ExternalServiceConfig + + +@dataclass(frozen=True) +class ExternalServiceHomeAssistantConfig(ExternalServiceConfig): + """ + Home Assistant external service configuration. It encapsulates the configuration parameters + to connect to a Home Assistant instance. + """ + + url: str + token: str + + def is_valid(self, adapter_type: ExternalServiceAdapter) -> bool: + """ + Check if the configuration is valid for the given adapter type. + For Home Assistant, it is always valid. + """ + return adapter_type == ExternalServiceAdapter.HOME_ASSISTANT_API + + def to_dict(self) -> dict: + """Converts the configuration object into a serializable dictionary""" + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + """Create a configuration object from a dictionary""" + return cls(**data) diff --git a/core/edge_mining/shared/adapter_configs/forecast.py b/core/edge_mining/shared/adapter_configs/forecast.py new file mode 100644 index 0000000..6184435 --- /dev/null +++ b/core/edge_mining/shared/adapter_configs/forecast.py @@ -0,0 +1,83 @@ +""" +Collection of adapters configuration for the energy forecast domain +of the Edge Mining application. +""" + +from dataclasses import asdict, dataclass, field + +from edge_mining.domain.forecast.common import ForecastProviderAdapter +from edge_mining.shared.interfaces.config import ForecastProviderConfig + + +@dataclass(frozen=False) +class ForecastProviderDummySolarConfig(ForecastProviderConfig): + """ + Forecast provider configuration. It encapsulate the configuration parameters + to retrieve forecast data from a dummy solar forecast provider. + """ + + latitude: float = field(default=41.90) + longitude: float = field(default=12.49) + capacity_kwp: float = field(default=0.0) + efficiency_percent: float = field(default=80.0) + production_start_hour: int = field(default=6) + production_end_hour: int = field(default=20) + + def is_valid(self, adapter_type: ForecastProviderAdapter) -> bool: + """ + Check if the configuration is valid for the given adapter type. + For Dummy Solar, it is always valid. + """ + return adapter_type == ForecastProviderAdapter.DUMMY_SOLAR + + def to_dict(self) -> dict: + """Converts the configuration object into a serializable dictionary""" + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + """Create a configuration object from a dictionary""" + return cls(**data) + + +@dataclass(frozen=True) +class ForecastProviderHomeAssistantConfig(ForecastProviderConfig): + """ + Forecast provider configuration. It encapsulate the configuration parameters + to retrieve forecast data from Home Assistant. + """ + + entity_forecast_power_actual_h: str = field(default="") + entity_forecast_power_next_1h: str = field(default="") + entity_forecast_power_next_12h: str = field(default="") + entity_forecast_power_next_24h: str = field(default="") + entity_forecast_energy_actual_h: str = field(default="") + entity_forecast_energy_next_1h: str = field(default="") + entity_forecast_energy_today: str = field(default="") + entity_forecast_energy_tomorrow: str = field(default="") + entity_forecast_energy_remaining_today: str = field(default="") + unit_forecast_power_actual_h: str = field(default="W") + unit_forecast_power_next_1h: str = field(default="W") + unit_forecast_power_next_12h: str = field(default="W") + unit_forecast_power_next_24h: str = field(default="W") + unit_forecast_energy_actual_h: str = field(default="kWh") + unit_forecast_energy_next_1h: str = field(default="kWh") + unit_forecast_energy_today: str = field(default="kWh") + unit_forecast_energy_tomorrow: str = field(default="kWh") + unit_forecast_energy_remaining_today: str = field(default="kWh") + + def is_valid(self, adapter_type: ForecastProviderAdapter) -> bool: + """ + Check if the configuration is valid for the given adapter type. + For Home Assistant, it is always valid. + """ + return adapter_type == ForecastProviderAdapter.HOME_ASSISTANT_API + + def to_dict(self) -> dict: + """Converts the configuration object into a serializable dictionary""" + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + """Create a configuration object from a dictionary""" + return cls(**data) diff --git a/core/edge_mining/shared/adapter_configs/home_load.py b/core/edge_mining/shared/adapter_configs/home_load.py new file mode 100644 index 0000000..bb0f711 --- /dev/null +++ b/core/edge_mining/shared/adapter_configs/home_load.py @@ -0,0 +1,204 @@ +""" +Collection of adapters configuration for the home load forecast domain +of the Edge Mining application. +""" + +from dataclasses import asdict, dataclass, field + +from edge_mining.domain.home_load.common import ( + EnergyLoadForecastProviderAdapter, + EnergyLoadHistoryProviderAdapter, +) +from edge_mining.shared.interfaces.config import ( + EnergyLoadForecastProviderConfig, + EnergyLoadHistoryProviderConfig, +) + + +@dataclass(frozen=True) +class EnergyLoadForecastProviderDummyConfig(EnergyLoadForecastProviderConfig): + """ + Energy Load Forecast provider configuration. It encapsulate the configuration parameters + to retrieve home forecast data from a dummy provider. + """ + + load_power_max: float = field(default=500.0) + + def is_valid(self, adapter_type: EnergyLoadForecastProviderAdapter) -> bool: + """ + Check if the configuration is valid for the given adapter type. + For Dummy Energy Load Forecast, it is always valid. + """ + return adapter_type == EnergyLoadForecastProviderAdapter.DUMMY + + def to_dict(self) -> dict: + """Converts the configuration object into a serializable dictionary""" + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + """Create a configuration object from a dictionary""" + return cls(**data) + + +@dataclass(frozen=True) +class EnergyLoadForecastProviderNaiveLastHourConfig(EnergyLoadForecastProviderConfig): + """Configuration for NaiveLastHour forecast provider.""" + + hours_ahead: int = field(default=3) + + def is_valid(self, adapter_type: EnergyLoadForecastProviderAdapter) -> bool: + return adapter_type == EnergyLoadForecastProviderAdapter.NAIVE_LAST_HOUR + + def to_dict(self) -> dict: + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + return cls(**data) + + +@dataclass(frozen=True) +class EnergyLoadForecastProviderNaivePersistenceConfig(EnergyLoadForecastProviderConfig): + """Configuration for NaivePersistence forecast provider (repeat yesterday's profile).""" + + hours_ahead: int = field(default=24) + delta_days: int = field(default=1) + + def is_valid(self, adapter_type: EnergyLoadForecastProviderAdapter) -> bool: + return adapter_type == EnergyLoadForecastProviderAdapter.NAIVE_PERSISTENCE + + def to_dict(self) -> dict: + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + return cls(**data) + + +@dataclass(frozen=True) +class EnergyLoadForecastProviderSeasonalBaselineConfig(EnergyLoadForecastProviderConfig): + """Configuration for SeasonalBaseline forecast provider.""" + + hours_ahead: int = field(default=3) + weeks_lookback: int = field(default=4) + + def is_valid(self, adapter_type: EnergyLoadForecastProviderAdapter) -> bool: + return adapter_type == EnergyLoadForecastProviderAdapter.SEASONAL_BASELINE + + def to_dict(self) -> dict: + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + return cls(**data) + + +@dataclass(frozen=True) +class EnergyLoadForecastProviderTypicalProfileConfig(EnergyLoadForecastProviderConfig): + """Configuration for TypicalProfile forecast provider (monthly + weekly + hourly avg).""" + + hours_ahead: int = field(default=24) + weeks_lookback: int = field(default=8) + + def is_valid(self, adapter_type: EnergyLoadForecastProviderAdapter) -> bool: + return adapter_type == EnergyLoadForecastProviderAdapter.TYPICAL_PROFILE + + def to_dict(self) -> dict: + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + return cls(**data) + + +@dataclass(frozen=True) +class EnergyLoadForecastProviderSkforecastConfig(EnergyLoadForecastProviderConfig): + """Configuration for skforecast ForecasterRecursive provider. + + ``sklearn_model`` selects the sklearn regressor backend by name, e.g. + ``"RandomForestRegressor"``, ``"Ridge"``, ``"KNeighborsRegressor"`` etc. + """ + + hours_ahead: int = field(default=24) + weeks_lookback: int = field(default=8) + sklearn_model: str = field(default="RandomForestRegressor") + num_lags: int = field(default=72) + + def is_valid(self, adapter_type: EnergyLoadForecastProviderAdapter) -> bool: + return adapter_type == EnergyLoadForecastProviderAdapter.SKFORECAST + + def to_dict(self) -> dict: + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + return cls(**data) + + +@dataclass(frozen=True) +class EnergyLoadForecastProviderStatsmodelsConfig(EnergyLoadForecastProviderConfig): + """Configuration for Statsmodels (Holt-Winters / SARIMA) forecast provider.""" + + hours_ahead: int = field(default=3) + weeks_lookback: int = field(default=8) + method: str = field(default="hw") # "hw" (Holt-Winters) or "sarima" + seasonal_periods: int = field(default=24) # hours in a seasonal cycle + + def is_valid(self, adapter_type: EnergyLoadForecastProviderAdapter) -> bool: + return adapter_type == EnergyLoadForecastProviderAdapter.STATSMODELS + + def to_dict(self) -> dict: + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + return cls(**data) + + +@dataclass(frozen=True) +class EnergyLoadForecastProviderXGBoostConfig(EnergyLoadForecastProviderConfig): + """Configuration for XGBoost forecast provider.""" + + hours_ahead: int = field(default=3) + weeks_lookback: int = field(default=8) + n_estimators: int = field(default=100) + max_depth: int = field(default=6) + learning_rate: float = field(default=0.1) + + def is_valid(self, adapter_type: EnergyLoadForecastProviderAdapter) -> bool: + return adapter_type == EnergyLoadForecastProviderAdapter.XGBOOST + + def to_dict(self) -> dict: + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + return cls(**data) + + +@dataclass(frozen=True) +class EnergyLoadHistoryProviderHomeAssistantAPIConfig(EnergyLoadHistoryProviderConfig): + """ + Energy Load History provider configuration. It encapsulate the configuration parameters + to retrieve historical energy load data from Home Assistant API. + """ + + entity_power: str = field(default="") + unit_power: str = field(default="W") + + def is_valid(self, adapter_type: EnergyLoadHistoryProviderAdapter) -> bool: + """ + Check if the configuration is valid for the given adapter type. + For Home Assistant API, it is always valid. + """ + return adapter_type == EnergyLoadHistoryProviderAdapter.HOME_ASSISTANT_API + + def to_dict(self) -> dict: + """Converts the configuration object into a serializable dictionary""" + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + """Create a configuration object from a dictionary""" + return cls(**data) diff --git a/core/edge_mining/shared/adapter_configs/miner.py b/core/edge_mining/shared/adapter_configs/miner.py new file mode 100644 index 0000000..a6b0036 --- /dev/null +++ b/core/edge_mining/shared/adapter_configs/miner.py @@ -0,0 +1,139 @@ +""" +Collection of adapters configuration for the miner domain +of the Edge Mining application. +""" + +import ipaddress +from dataclasses import asdict, dataclass, field +from enum import Enum +from typing import Optional + +from edge_mining.domain.miner.common import MinerControllerAdapter, MinerControllerProtocol +from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.shared.interfaces.config import MinerControllerConfig + + +@dataclass(frozen=True) +class MinerControllerDummyConfig(MinerControllerConfig): + """ + Miner controller configuration. It encapsulate the configuration parameters + to control a miner with dummy controller. + """ + + initial_status: str = field(default="UNKNOWN") + power_max: float = field(default=3200.0) + hashrate_max: HashRate = field(default=HashRate(90, "TH/s")) + + def is_valid(self, adapter_type: MinerControllerAdapter) -> bool: + """ + Check if the configuration is valid for the given adapter type. + For Dummy Miner Controller, it is always valid. + """ + return adapter_type == MinerControllerAdapter.DUMMY + + def to_dict(self) -> dict: + """Converts the configuration object into a serializable dictionary""" + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + """Create a configuration object from a dictionary""" + hashrate_max = HashRate(90, "TH/s") + if "hashrate_max" in data: + hashrate_dict: dict = data.get("hashrate_max", None) + hashrate_max = HashRate( + value=hashrate_dict.get("value", 0), + unit=hashrate_dict.get("unit", "TH/s"), + ) + return MinerControllerDummyConfig( + initial_status=data.get("initial_status", "UNKNOWN"), + power_max=data.get("power_max", 3200.0), + hashrate_max=hashrate_max, + ) + + +@dataclass(frozen=True) +class MinerControllerGenericSocketHomeAssistantAPIConfig(MinerControllerConfig): + """ + Miner controller configuration. It encapsulate the configuration parameters + to control a miner via Home Assistant's entities of a smart socket. + """ + + entity_switch: str = field(default="switch.miner_socket") + entity_power: str = field(default="sensor.miner_power") + unit_power: str = field(default="W") + + def is_valid(self, adapter_type: MinerControllerAdapter) -> bool: + """ + Check if the configuration is valid for the given adapter type. + For Generic Socket Home Assistant API Miner Controller, + it is valid if the adapter type matches. + """ + return adapter_type == MinerControllerAdapter.GENERIC_SOCKET_HOME_ASSISTANT_API + + def to_dict(self) -> dict: + """Converts the configuration object into a serializable dictionary""" + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + """Create a configuration object from a dictionary""" + return cls(**data) + + +@dataclass(frozen=True) +class MinerControllerPyASICConfig(MinerControllerConfig): + """ + Miner controller configuration. It encapsulates the configuration parameters + to control a miner via pyasic. + """ + + ip: str = field(default="192.168.1.100") + port: Optional[int] = field(default=None) # None represents "use the default" + username: Optional[str] = field(default=None) # None represents "use the default" + password: Optional[str] = field(default=None) # None represents "use the default" + protocol: Optional[MinerControllerProtocol] = field(default=MinerControllerProtocol.WEB) + + def is_valid(self, adapter_type: MinerControllerAdapter) -> bool: + """ + Check if the configuration is valid for the given adapter type. + For the pyasic Miner Controller, it is valid if the adapter type matches, + and the IP is a valid IP address. + """ + try: + ipaddress.ip_address(self.ip) + return adapter_type == MinerControllerAdapter.PYASIC + except ValueError: + return False + + def to_dict(self) -> dict: + """Converts the configuration object into a serializable dictionary""" + result = asdict(self) + + # Convert all enum values to their string representation + for key, value in result.items(): + if isinstance(value, Enum): + result[key] = value.value + + return result + + @classmethod + def from_dict(cls, data: dict): + """Create a configuration object from a dictionary""" + protocol = MinerControllerProtocol.WEB + if "protocol" in data: + protocol_value = data.get("protocol") + if protocol_value: + # Check if the protocol value is a known miner controller protocol + is_known_protocol = any( + protocol_value == protocol_member.value for protocol_member in MinerControllerProtocol + ) + protocol = MinerControllerProtocol(protocol_value) if is_known_protocol else MinerControllerProtocol.WEB + + return MinerControllerPyASICConfig( + ip=data.get("ip", "192.168.1.100"), + port=data.get("port", None), + username=data.get("username", None), + password=data.get("password", None), + protocol=protocol, + ) diff --git a/core/edge_mining/shared/adapter_configs/notification.py b/core/edge_mining/shared/adapter_configs/notification.py new file mode 100644 index 0000000..7833c9b --- /dev/null +++ b/core/edge_mining/shared/adapter_configs/notification.py @@ -0,0 +1,62 @@ +""" +Collection of adapters configuration for the notification domain +of the Edge Mining application. +""" + +from dataclasses import asdict, dataclass + +from edge_mining.domain.notification.common import NotificationAdapter +from edge_mining.shared.interfaces.config import NotificationConfig + + +@dataclass(frozen=True) +class DummyNotificationConfig(NotificationConfig): + """ + Dummy notification configuration. It encapsulate the configuration parameters + to send notifications via a dummy adapter. + """ + + message: str = "This is a dummy notification" + + def is_valid(self, adapter_type: NotificationAdapter) -> bool: + """ + Check if the configuration is valid for the given adapter type. + For Dummy Notification, it is always valid. + """ + return adapter_type == NotificationAdapter.DUMMY + + def to_dict(self) -> dict: + """Converts the configuration object into a serializable dictionary""" + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + """Create a configuration object from a dictionary""" + return cls(**data) + + +@dataclass(frozen=True) +class TelegramNotificationConfig(NotificationConfig): + """ + Telegram notification configuration. It encapsulate the configuration parameters + to send notifications via Telegram. + """ + + bot_token: str + chat_id: str + + def is_valid(self, adapter_type: NotificationAdapter) -> bool: + """ + Check if the configuration is valid for the given adapter type. + For Telegram Notification, it is always valid. + """ + return adapter_type == NotificationAdapter.TELEGRAM + + def to_dict(self) -> dict: + """Converts the configuration object into a serializable dictionary""" + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + """Create a configuration object from a dictionary""" + return cls(**data) diff --git a/core/edge_mining/shared/adapter_configs/performance.py b/core/edge_mining/shared/adapter_configs/performance.py new file mode 100644 index 0000000..0bbf2d7 --- /dev/null +++ b/core/edge_mining/shared/adapter_configs/performance.py @@ -0,0 +1,105 @@ +""" +Collection of adapters configuration for the performace tracker domain +of the Edge Mining application. +""" + +from dataclasses import asdict, dataclass + +from edge_mining.domain.performance.common import MiningPerformanceTrackerAdapter +from edge_mining.shared.interfaces.config import MiningPerformanceTrackerConfig + + +@dataclass(frozen=True) +class MiningPerformanceTrackerDummyConfig(MiningPerformanceTrackerConfig): + """ + Dummy mining performance tracker configuration. It encapsulates the configuration parameters + to track performance via a dummy adapter. + """ + + message: str = "This is a dummy performance tracker" + + def is_valid(self, adapter_type: MiningPerformanceTrackerAdapter) -> bool: + """ + Check if the configuration is valid for the given adapter type. + For Dummy Performance Tracker, it is always valid. + """ + return adapter_type == MiningPerformanceTrackerAdapter.DUMMY + + def to_dict(self) -> dict: + """Converts the configuration object into a serializable dictionary""" + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + """Create a configuration object from a dictionary""" + return cls(**data) + + +@dataclass(frozen=True) +class MiningPerformanceTrackerBraiinsPoolConfig(MiningPerformanceTrackerConfig): + """ + Braiins Pool mining performance tracker configuration. + + Braiins requires an API token that the user generates in + `Settings > Access Profiles`; the token is sent via the `Pool-Auth-Token` header. + """ + + api_token: str = "" + api_base_url: str = "https://pool.braiins.com" + request_timeout_seconds: int = 10 + + def is_valid(self, adapter_type: MiningPerformanceTrackerAdapter) -> bool: + """Check the configuration is valid for a Braiins Pool tracker.""" + if adapter_type != MiningPerformanceTrackerAdapter.BRAIINS_POOL: + return False + if not self.api_token or not self.api_token.strip(): + return False + if not self.api_base_url or not self.api_base_url.strip(): + return False + if self.request_timeout_seconds <= 0: + return False + return True + + def to_dict(self) -> dict: + """Converts the configuration object into a serializable dictionary""" + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + """Create a configuration object from a dictionary""" + return cls(**data) + + +@dataclass(frozen=True) +class MiningPerformanceTrackerOceanConfig(MiningPerformanceTrackerConfig): + """ + Ocean.xyz mining performance tracker configuration. + + Ocean identifies users by their payout Bitcoin address; no API token is required + to access public per-user statistics. + """ + + bitcoin_address: str = "" + api_base_url: str = "https://api.ocean.xyz" + request_timeout_seconds: int = 10 + + def is_valid(self, adapter_type: MiningPerformanceTrackerAdapter) -> bool: + """Check the configuration is valid for an Ocean tracker.""" + if adapter_type != MiningPerformanceTrackerAdapter.OCEAN: + return False + if not self.bitcoin_address or not self.bitcoin_address.strip(): + return False + if not self.api_base_url or not self.api_base_url.strip(): + return False + if self.request_timeout_seconds <= 0: + return False + return True + + def to_dict(self) -> dict: + """Converts the configuration object into a serializable dictionary""" + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + """Create a configuration object from a dictionary""" + return cls(**data) diff --git a/core/edge_mining/shared/adapter_maps/__init__.py b/core/edge_mining/shared/adapter_maps/__init__.py new file mode 100644 index 0000000..d20701a --- /dev/null +++ b/core/edge_mining/shared/adapter_maps/__init__.py @@ -0,0 +1 @@ +"""Collection of shared adapter maps for the Edge Mining application.""" diff --git a/core/edge_mining/shared/adapter_maps/energy.py b/core/edge_mining/shared/adapter_maps/energy.py new file mode 100644 index 0000000..c909c4a --- /dev/null +++ b/core/edge_mining/shared/adapter_maps/energy.py @@ -0,0 +1,108 @@ +"""Collection of adapters maps for the energy domain of the Edge Mining application.""" + +from typing import Dict, List, Optional, Union + +from edge_mining.adapters.domain.energy.monitors.dummy_solar import DummySolarEnergyMonitor +from edge_mining.adapters.domain.energy.monitors.home_assistant_api import ( + HomeAssistantAPIEnergyMonitor, +) +from edge_mining.adapters.domain.forecast.providers.dummy_solar import DummySolarForecastProvider +from edge_mining.adapters.domain.forecast.providers.home_assistant_api import ( + HomeAssistantForecastProvider, +) +from edge_mining.domain.energy.common import EnergyMonitorAdapter, EnergySourceType +from edge_mining.domain.forecast.common import ForecastProviderAdapter +from edge_mining.shared.adapter_configs.energy import ( + EnergyMonitorDummySolarConfig, + EnergyMonitorHomeAssistantConfig, +) +from edge_mining.shared.adapter_configs.forecast import ( + ForecastProviderDummySolarConfig, + ForecastProviderHomeAssistantConfig, +) +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.interfaces.config import EnergyMonitorConfig + +# Mapping of energy source types to forecast providers types. +ENERGY_SOURCE_TYPE_FORECAST_PROVIDER_TYPE_MAP: Dict[EnergySourceType, Optional[List[ForecastProviderAdapter]]] = { + EnergySourceType.SOLAR: [ + ForecastProviderAdapter.DUMMY_SOLAR, + ForecastProviderAdapter.HOME_ASSISTANT_API, + ], + EnergySourceType.WIND: [ForecastProviderAdapter.HOME_ASSISTANT_API], + EnergySourceType.GRID: [ForecastProviderAdapter.HOME_ASSISTANT_API], + EnergySourceType.HYDROELECTRIC: [ForecastProviderAdapter.HOME_ASSISTANT_API], + EnergySourceType.OTHER: [ForecastProviderAdapter.HOME_ASSISTANT_API], +} + +# Mapping of energy source types to forecast providers configuration classes. +ENERGY_SOURCE_TYPE_FORECAST_PROVIDER_CONFIG_MAP: Dict[ + EnergySourceType, + Optional[ + List[ + Union[ + type[ForecastProviderDummySolarConfig], + type[ForecastProviderHomeAssistantConfig], + ] + ] + ], +] = { + EnergySourceType.SOLAR: [ + ForecastProviderDummySolarConfig, + ForecastProviderHomeAssistantConfig, + ], + EnergySourceType.WIND: None, + EnergySourceType.GRID: None, + EnergySourceType.HYDROELECTRIC: None, + EnergySourceType.OTHER: None, +} + +# Mapping of energy source types to forecast providers instance classes. +ENERGY_SOURCE_TYPE_FORECAST_PROVIDER_CLASS_MAP: Dict[ + EnergySourceType, + Optional[List[Union[type[DummySolarForecastProvider], type[HomeAssistantForecastProvider]]]], +] = { + EnergySourceType.SOLAR: [ + DummySolarForecastProvider, + HomeAssistantForecastProvider, + ], + EnergySourceType.WIND: None, + EnergySourceType.GRID: None, + EnergySourceType.HYDROELECTRIC: None, + EnergySourceType.OTHER: None, +} + +ENERGY_SOURCE_TYPE_ENERGY_MONITOR_MAP: Dict[EnergySourceType, Optional[List[EnergyMonitorAdapter]]] = { + EnergySourceType.SOLAR: [ + EnergyMonitorAdapter.DUMMY_SOLAR, + EnergyMonitorAdapter.HOME_ASSISTANT_API, + ], + EnergySourceType.WIND: [EnergyMonitorAdapter.HOME_ASSISTANT_API], + EnergySourceType.GRID: [EnergyMonitorAdapter.HOME_ASSISTANT_API], + EnergySourceType.HYDROELECTRIC: [EnergyMonitorAdapter.HOME_ASSISTANT_API], + EnergySourceType.OTHER: [EnergyMonitorAdapter.HOME_ASSISTANT_API], +} + +ENERGY_SOURCE_TYPE_ENERGY_MONITOR_CLASS_MAP: Dict[ + EnergySourceType, + Optional[List[Union[type[DummySolarEnergyMonitor], type[HomeAssistantAPIEnergyMonitor]]]], +] = { + EnergySourceType.SOLAR: [ + DummySolarEnergyMonitor, + HomeAssistantAPIEnergyMonitor, + ], + EnergySourceType.WIND: [HomeAssistantAPIEnergyMonitor], + EnergySourceType.GRID: [HomeAssistantAPIEnergyMonitor], + EnergySourceType.HYDROELECTRIC: [HomeAssistantAPIEnergyMonitor], + EnergySourceType.OTHER: [HomeAssistantAPIEnergyMonitor], +} + +ENERGY_MONITOR_CONFIG_TYPE_MAP: Dict[EnergyMonitorAdapter, Optional[type[EnergyMonitorConfig]]] = { + EnergyMonitorAdapter.DUMMY_SOLAR: EnergyMonitorDummySolarConfig, + EnergyMonitorAdapter.HOME_ASSISTANT_API: EnergyMonitorHomeAssistantConfig, +} + +ENERGY_MONITOR_TYPE_EXTERNAL_SERVICE_MAP: Dict[EnergyMonitorAdapter, Optional[ExternalServiceAdapter]] = { + EnergyMonitorAdapter.DUMMY_SOLAR: None, # Dummy does not use an external service + EnergyMonitorAdapter.HOME_ASSISTANT_API: ExternalServiceAdapter.HOME_ASSISTANT_API, +} diff --git a/core/edge_mining/shared/adapter_maps/external_services.py b/core/edge_mining/shared/adapter_maps/external_services.py new file mode 100644 index 0000000..9473f43 --- /dev/null +++ b/core/edge_mining/shared/adapter_maps/external_services.py @@ -0,0 +1,13 @@ +"""Collection of adapters maps for the external services of the Edge Mining application.""" + +from typing import Dict, Optional + +from edge_mining.shared.adapter_configs.external_services import ( + ExternalServiceHomeAssistantConfig, +) +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.interfaces.config import ExternalServiceConfig + +EXTERNAL_SERVICE_CONFIG_TYPE_MAP: Dict[ExternalServiceAdapter, Optional[type[ExternalServiceConfig]]] = { + ExternalServiceAdapter.HOME_ASSISTANT_API: ExternalServiceHomeAssistantConfig +} diff --git a/core/edge_mining/shared/adapter_maps/forecast.py b/core/edge_mining/shared/adapter_maps/forecast.py new file mode 100644 index 0000000..0d85d92 --- /dev/null +++ b/core/edge_mining/shared/adapter_maps/forecast.py @@ -0,0 +1,24 @@ +""" +Collection of adapters maps for the energy forecast domain +of the Edge Mining application. +""" + +from typing import Dict, Optional + +from edge_mining.domain.forecast.common import ForecastProviderAdapter +from edge_mining.shared.adapter_configs.forecast import ( + ForecastProviderDummySolarConfig, + ForecastProviderHomeAssistantConfig, +) +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.interfaces.config import ForecastProviderConfig + +FORECAST_PROVIDER_CONFIG_TYPE_MAP: Dict[ForecastProviderAdapter, Optional[type[ForecastProviderConfig]]] = { + ForecastProviderAdapter.DUMMY_SOLAR: ForecastProviderDummySolarConfig, + ForecastProviderAdapter.HOME_ASSISTANT_API: ForecastProviderHomeAssistantConfig, +} + +FORECAST_PROVIDER_TYPE_EXTERNAL_SERVICE_MAP: Dict[ForecastProviderAdapter, Optional[ExternalServiceAdapter]] = { + ForecastProviderAdapter.DUMMY_SOLAR: None, # Dummy does not use an external service + ForecastProviderAdapter.HOME_ASSISTANT_API: ExternalServiceAdapter.HOME_ASSISTANT_API, +} diff --git a/core/edge_mining/shared/adapter_maps/home_load.py b/core/edge_mining/shared/adapter_maps/home_load.py new file mode 100644 index 0000000..af4994e --- /dev/null +++ b/core/edge_mining/shared/adapter_maps/home_load.py @@ -0,0 +1,61 @@ +""" +Collection of adapters maps for the home load forecast domain +of the Edge Mining application. +""" + +from typing import Dict, Optional + +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter, EnergyLoadHistoryProviderAdapter +from edge_mining.shared.adapter_configs.home_load import ( + EnergyLoadForecastProviderDummyConfig, + EnergyLoadForecastProviderNaiveLastHourConfig, + EnergyLoadForecastProviderNaivePersistenceConfig, + EnergyLoadForecastProviderSeasonalBaselineConfig, + EnergyLoadForecastProviderSkforecastConfig, + EnergyLoadForecastProviderStatsmodelsConfig, + EnergyLoadForecastProviderTypicalProfileConfig, + EnergyLoadForecastProviderXGBoostConfig, + EnergyLoadHistoryProviderHomeAssistantAPIConfig, +) +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.interfaces.config import EnergyLoadForecastProviderConfig, EnergyLoadHistoryProviderConfig + +ENERGY_LOAD_FORECAST_PROVIDER_CONFIG_TYPE_MAP: Dict[ + EnergyLoadForecastProviderAdapter, Optional[type[EnergyLoadForecastProviderConfig]] +] = { + EnergyLoadForecastProviderAdapter.DUMMY: EnergyLoadForecastProviderDummyConfig, + EnergyLoadForecastProviderAdapter.NAIVE_LAST_HOUR: EnergyLoadForecastProviderNaiveLastHourConfig, + EnergyLoadForecastProviderAdapter.NAIVE_PERSISTENCE: EnergyLoadForecastProviderNaivePersistenceConfig, + EnergyLoadForecastProviderAdapter.SEASONAL_BASELINE: EnergyLoadForecastProviderSeasonalBaselineConfig, + EnergyLoadForecastProviderAdapter.SKFORECAST: EnergyLoadForecastProviderSkforecastConfig, + EnergyLoadForecastProviderAdapter.STATSMODELS: EnergyLoadForecastProviderStatsmodelsConfig, + EnergyLoadForecastProviderAdapter.TYPICAL_PROFILE: EnergyLoadForecastProviderTypicalProfileConfig, + EnergyLoadForecastProviderAdapter.XGBOOST: EnergyLoadForecastProviderXGBoostConfig, +} + +ENERGY_LOAD_FORECAST_PROVIDER_EXTERNAL_SERVICE_MAP: Dict[ + EnergyLoadForecastProviderAdapter, Optional[ExternalServiceAdapter] +] = { + EnergyLoadForecastProviderAdapter.DUMMY: None, + EnergyLoadForecastProviderAdapter.NAIVE_LAST_HOUR: None, + EnergyLoadForecastProviderAdapter.NAIVE_PERSISTENCE: None, + EnergyLoadForecastProviderAdapter.SEASONAL_BASELINE: None, + EnergyLoadForecastProviderAdapter.SKFORECAST: None, + EnergyLoadForecastProviderAdapter.STATSMODELS: None, + EnergyLoadForecastProviderAdapter.TYPICAL_PROFILE: None, + EnergyLoadForecastProviderAdapter.XGBOOST: None, +} + +ENERGY_LOAD_HISTORY_PROVIDER_CONFIG_TYPE_MAP: Dict[ + EnergyLoadHistoryProviderAdapter, Optional[type[EnergyLoadHistoryProviderConfig]] +] = { + EnergyLoadHistoryProviderAdapter.DUMMY: None, + EnergyLoadHistoryProviderAdapter.HOME_ASSISTANT_API: EnergyLoadHistoryProviderHomeAssistantAPIConfig, +} + +ENERGY_LOAD_HISTORY_PROVIDER_EXTERNAL_SERVICE_MAP: Dict[ + EnergyLoadHistoryProviderAdapter, Optional[ExternalServiceAdapter] +] = { + EnergyLoadHistoryProviderAdapter.DUMMY: None, + EnergyLoadHistoryProviderAdapter.HOME_ASSISTANT_API: ExternalServiceAdapter.HOME_ASSISTANT_API, +} diff --git a/core/edge_mining/shared/adapter_maps/miner.py b/core/edge_mining/shared/adapter_maps/miner.py new file mode 100644 index 0000000..b08d110 --- /dev/null +++ b/core/edge_mining/shared/adapter_maps/miner.py @@ -0,0 +1,27 @@ +""" +Collection of adapters maps for the miner domain +of the Edge Mining application. +""" + +from typing import Dict, Optional + +from edge_mining.domain.miner.common import MinerControllerAdapter +from edge_mining.shared.adapter_configs.miner import ( + MinerControllerDummyConfig, + MinerControllerGenericSocketHomeAssistantAPIConfig, + MinerControllerPyASICConfig, +) +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.interfaces.config import MinerControllerConfig + +MINER_CONTROLLER_CONFIG_TYPE_MAP: Dict[MinerControllerAdapter, Optional[type[MinerControllerConfig]]] = { + MinerControllerAdapter.DUMMY: MinerControllerDummyConfig, + MinerControllerAdapter.PYASIC: MinerControllerPyASICConfig, + MinerControllerAdapter.GENERIC_SOCKET_HOME_ASSISTANT_API: MinerControllerGenericSocketHomeAssistantAPIConfig, +} + +MINER_CONTROLLER_TYPE_EXTERNAL_SERVICE_MAP: Dict[MinerControllerAdapter, Optional[ExternalServiceAdapter]] = { + MinerControllerAdapter.DUMMY: None, # Dummy does not use an external service + MinerControllerAdapter.PYASIC: None, # PyASIC does not use an external service + MinerControllerAdapter.GENERIC_SOCKET_HOME_ASSISTANT_API: ExternalServiceAdapter.HOME_ASSISTANT_API, +} diff --git a/core/edge_mining/shared/adapter_maps/notification.py b/core/edge_mining/shared/adapter_maps/notification.py new file mode 100644 index 0000000..d8ac161 --- /dev/null +++ b/core/edge_mining/shared/adapter_maps/notification.py @@ -0,0 +1,24 @@ +""" +Collection of adapters maps for the notification domain +of the Edge Mining application. +""" + +from typing import Dict, Optional + +from edge_mining.domain.notification.common import NotificationAdapter +from edge_mining.shared.adapter_configs.notification import ( + DummyNotificationConfig, + TelegramNotificationConfig, +) +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.interfaces.config import NotificationConfig + +NOTIFIER_CONFIG_TYPE_MAP: Dict[NotificationAdapter, Optional[type[NotificationConfig]]] = { + NotificationAdapter.DUMMY: DummyNotificationConfig, + NotificationAdapter.TELEGRAM: TelegramNotificationConfig, +} + +NOTIFIER_TYPE_EXTERNAL_SERVICE_MAP: Dict[NotificationAdapter, Optional[ExternalServiceAdapter]] = { + NotificationAdapter.DUMMY: None, # Dummy does not use an external service + NotificationAdapter.TELEGRAM: None, # Telegram does not use an external service +} diff --git a/core/edge_mining/shared/adapter_maps/performance.py b/core/edge_mining/shared/adapter_maps/performance.py new file mode 100644 index 0000000..f4f17e8 --- /dev/null +++ b/core/edge_mining/shared/adapter_maps/performance.py @@ -0,0 +1,31 @@ +""" +Collection of adapters maps for the performace tracker domain +of the Edge Mining application. +""" + +from typing import Dict, Optional + +from edge_mining.domain.performance.common import MiningPerformanceTrackerAdapter +from edge_mining.shared.adapter_configs.performance import ( + MiningPerformanceTrackerBraiinsPoolConfig, + MiningPerformanceTrackerDummyConfig, + MiningPerformanceTrackerOceanConfig, +) +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.interfaces.config import MiningPerformanceTrackerConfig + +MINING_PERFORMANCE_TRACKER_CONFIG_TYPE_MAP: Dict[ + MiningPerformanceTrackerAdapter, Optional[type[MiningPerformanceTrackerConfig]] +] = { + MiningPerformanceTrackerAdapter.DUMMY: MiningPerformanceTrackerDummyConfig, + MiningPerformanceTrackerAdapter.OCEAN: MiningPerformanceTrackerOceanConfig, + MiningPerformanceTrackerAdapter.BRAIINS_POOL: MiningPerformanceTrackerBraiinsPoolConfig, +} + +MINING_PERFORMANCE_TRACKER_TYPE_EXTERNAL_SERVICE_MAP: Dict[ + MiningPerformanceTrackerAdapter, Optional[ExternalServiceAdapter] +] = { + MiningPerformanceTrackerAdapter.DUMMY: None, + MiningPerformanceTrackerAdapter.OCEAN: None, + MiningPerformanceTrackerAdapter.BRAIINS_POOL: None, +} diff --git a/core/edge_mining/shared/external_services/__init__.py b/core/edge_mining/shared/external_services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/shared/external_services/common.py b/core/edge_mining/shared/external_services/common.py new file mode 100644 index 0000000..37f5192 --- /dev/null +++ b/core/edge_mining/shared/external_services/common.py @@ -0,0 +1,9 @@ +"""Collection of Common Objects for the External Services shared domain of the Edge Mining application.""" + +from enum import Enum + + +class ExternalServiceAdapter(Enum): + """Types of external service adapter.""" + + HOME_ASSISTANT_API = "home_assistant_api" diff --git a/core/edge_mining/shared/external_services/entities.py b/core/edge_mining/shared/external_services/entities.py new file mode 100644 index 0000000..4e994f1 --- /dev/null +++ b/core/edge_mining/shared/external_services/entities.py @@ -0,0 +1,20 @@ +""" +Collection of Entities for the External Sources +of the Edge Mining application. +""" + +from dataclasses import dataclass +from typing import Optional + +from edge_mining.domain.common import Entity +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.interfaces.config import ExternalServiceConfig + + +@dataclass +class ExternalService(Entity): + """Entity for an external source""" + + name: str = "" + adapter_type: ExternalServiceAdapter = ExternalServiceAdapter.HOME_ASSISTANT_API + config: Optional[ExternalServiceConfig] = None diff --git a/core/edge_mining/shared/external_services/exceptions.py b/core/edge_mining/shared/external_services/exceptions.py new file mode 100644 index 0000000..deb2bb5 --- /dev/null +++ b/core/edge_mining/shared/external_services/exceptions.py @@ -0,0 +1,25 @@ +"""Collection of Exceptions.""" + + +class ExternalServiceError(Exception): + """Base class for external service specific errors.""" + + pass + + +class ExternalServiceNotFoundError(ExternalServiceError): + """External Service not found.""" + + pass + + +class ExternalServiceAlreadyExistsError(ExternalServiceError): + """ExternalService already exists.""" + + pass + + +class ExternalServiceConfigurationError(ExternalServiceError): + """Errors related to external service configuration.""" + + pass diff --git a/core/edge_mining/shared/external_services/ports.py b/core/edge_mining/shared/external_services/ports.py new file mode 100644 index 0000000..0b05dad --- /dev/null +++ b/core/edge_mining/shared/external_services/ports.py @@ -0,0 +1,60 @@ +"""The External Services port.""" + +from abc import ABC, abstractmethod +from typing import List, Optional + +from edge_mining.domain.common import EntityId +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.external_services.entities import ExternalService + + +class ExternalServicePort(ABC): + """Interface for external service.""" + + def __init__(self, external_service_type: ExternalServiceAdapter): + """Initialize the External Service.""" + self.external_service_type = external_service_type + + @abstractmethod + async def connect(self) -> None: + """Connect to the external service.""" + pass + + @abstractmethod + async def disconnect(self) -> None: + """Disconnect from the external service.""" + pass + + @abstractmethod + async def is_connected(self) -> bool: + """Check if the external service is connected.""" + pass + + +class ExternalServiceRepository(ABC): + """Port for the External Service Repository.""" + + @abstractmethod + def add(self, external_service: ExternalService) -> None: + """Adds a new external service to the repository.""" + raise NotImplementedError + + @abstractmethod + def get_by_id(self, external_service_id: EntityId) -> Optional[ExternalService]: + """Retrieves an external service by its ID.""" + raise NotImplementedError + + @abstractmethod + def get_all(self) -> List[ExternalService]: + """Retrieves all external services from the repository.""" + raise NotImplementedError + + @abstractmethod + def update(self, external_service: ExternalService) -> None: + """Updates an external service in the repository.""" + raise NotImplementedError + + @abstractmethod + def remove(self, external_service_id: EntityId) -> None: + """Removes an external service from the repository.""" + raise NotImplementedError diff --git a/core/edge_mining/shared/external_services/value_objects.py b/core/edge_mining/shared/external_services/value_objects.py new file mode 100644 index 0000000..b535005 --- /dev/null +++ b/core/edge_mining/shared/external_services/value_objects.py @@ -0,0 +1,23 @@ +"""Collection of Value Objects for the External Service domain of the Edge Mining application.""" + +from dataclasses import dataclass +from typing import List + +from edge_mining.domain.common import ValueObject +from edge_mining.domain.energy.entities import EnergyMonitor +from edge_mining.domain.forecast.entities import ForecastProvider +from edge_mining.domain.home_load.entities import EnergyLoadForecastProvider, EnergyLoadHistoryProvider +from edge_mining.domain.miner.entities import MinerController +from edge_mining.domain.notification.entities import Notifier + + +@dataclass(frozen=True) +class ExternalServiceLinkedEntities(ValueObject): + """Value Object for entities linked to an External Service""" + + miner_controllers: List[MinerController] + energy_monitors: List[EnergyMonitor] + forecast_providers: List[ForecastProvider] + energy_load_forecast_providers: List[EnergyLoadForecastProvider] + energy_load_history_providers: List[EnergyLoadHistoryProvider] + notifiers: List[Notifier] diff --git a/core/edge_mining/shared/infrastructure.py b/core/edge_mining/shared/infrastructure.py new file mode 100644 index 0000000..5ab2a81 --- /dev/null +++ b/core/edge_mining/shared/infrastructure.py @@ -0,0 +1,76 @@ +"""Shared objects for infrastructure layer of Edge Mining application.""" + +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +from edge_mining.application.interfaces import ( + MinerActionServiceInterface, + AdapterServiceInterface, + ConfigurationServiceInterface, + EventBusInterface, + HomeLoadHistoryServiceInterface, + LoadForecastTrainingServiceInterface, + OptimizationServiceInterface, +) +from edge_mining.domain.energy.ports import ( + EnergyMonitorRepository, + EnergySourceRepository, +) +from edge_mining.domain.forecast.ports import ForecastProviderRepository +from edge_mining.domain.home_load.ports import ( + EnergyLoadForecastProviderRepository, + EnergyLoadHistoryProviderRepository, + EnergyLoadHistoryRepository, + HomeLoadsProfileRepository, + LoadConsumptionModelRepository, +) +from edge_mining.domain.miner.ports import MinerControllerRepository, MinerRepository +from edge_mining.domain.notification.ports import NotifierRepository +from edge_mining.domain.optimization_unit.ports import EnergyOptimizationUnitRepository +from edge_mining.domain.performance.ports import MiningPerformanceTrackerRepository +from edge_mining.domain.policy.ports import OptimizationPolicyRepository +from edge_mining.shared.external_services.ports import ExternalServiceRepository +from edge_mining.shared.settings.ports import SettingsRepository + + +class ApplicationMode(str, Enum): + """Application run mode.""" + + STANDARD = "standard" + CLI = "cli" + + +@dataclass(frozen=True) +class PersistenceSettings: + """Persistence reporitory adapters""" + + energy_source_repo: EnergySourceRepository + energy_monitor_repo: EnergyMonitorRepository + miner_repo: MinerRepository + miner_controller_repo: MinerControllerRepository + forecast_provider_repo: ForecastProviderRepository + home_profile_repo: HomeLoadsProfileRepository + energy_load_forecast_provider_repo: EnergyLoadForecastProviderRepository + energy_load_history_provider_repo: EnergyLoadHistoryProviderRepository + home_load_history_repo: EnergyLoadHistoryRepository + load_consumption_model_repo: LoadConsumptionModelRepository + policy_repo: OptimizationPolicyRepository + mining_performance_tracker_repo: MiningPerformanceTrackerRepository + optimization_unit_repo: EnergyOptimizationUnitRepository + notifier_repo: NotifierRepository + external_service_repo: ExternalServiceRepository + settings_repo: SettingsRepository + + +@dataclass(frozen=True) +class Services: + """Service layer adapters""" + + adapter_service: AdapterServiceInterface + optimization_service: OptimizationServiceInterface + miner_action_service: MinerActionServiceInterface + configuration_service: ConfigurationServiceInterface + home_load_history_service: HomeLoadHistoryServiceInterface + load_forecast_training_service: Optional[LoadForecastTrainingServiceInterface] + event_bus: EventBusInterface diff --git a/core/edge_mining/shared/interfaces/__init__.py b/core/edge_mining/shared/interfaces/__init__.py new file mode 100644 index 0000000..a8d9c2e --- /dev/null +++ b/core/edge_mining/shared/interfaces/__init__.py @@ -0,0 +1 @@ +"""Collection of Interfaces for shared components of the Edge Mining application.""" diff --git a/core/edge_mining/shared/interfaces/config.py b/core/edge_mining/shared/interfaces/config.py new file mode 100644 index 0000000..758790f --- /dev/null +++ b/core/edge_mining/shared/interfaces/config.py @@ -0,0 +1,96 @@ +"""Interfaces for the configurations.""" + +from abc import ABC, abstractmethod + +from edge_mining.domain.energy.common import EnergyMonitorAdapter +from edge_mining.domain.forecast.common import ForecastProviderAdapter +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter, EnergyLoadHistoryProviderAdapter +from edge_mining.domain.miner.common import MinerControllerAdapter +from edge_mining.domain.notification.common import NotificationAdapter +from edge_mining.domain.performance.common import MiningPerformanceTrackerAdapter +from edge_mining.shared.external_services.common import ExternalServiceAdapter + + +class Configuration(ABC): + """Base interface for all configurations""" + + @abstractmethod + def to_dict(self) -> dict: + """Converts the configuration object into a serializable dictionary""" + + @classmethod + @abstractmethod + def from_dict(cls, data: dict) -> "Configuration": + """Create a configuration object from a dictionary""" + + +class EnergyMonitorConfig(Configuration): + """Base interface for Energy Monitor configurations.""" + + @abstractmethod + def is_valid(self, adapter_type: EnergyMonitorAdapter) -> bool: + """Check if the configuration is valid for the given adapter type.""" + pass + + +class NotificationConfig(Configuration): + """Base interface for Notification configurations.""" + + @abstractmethod + def is_valid(self, adapter_type: NotificationAdapter) -> bool: + """Check if the configuration is valid for the given adapter type.""" + pass + + +class ForecastProviderConfig(Configuration): + """Base interface for Forecast Provider configurations.""" + + @abstractmethod + def is_valid(self, adapter_type: ForecastProviderAdapter) -> bool: + """Check if the configuration is valid for the given adapter type.""" + pass + + +class EnergyLoadForecastProviderConfig(Configuration): + """Base interface for Energy Load Forecast Provider configurations.""" + + @abstractmethod + def is_valid(self, adapter_type: EnergyLoadForecastProviderAdapter) -> bool: + """Check if the configuration is valid for the given adapter type.""" + pass + + +class EnergyLoadHistoryProviderConfig(Configuration): + """Base interface for Energy Load History Provider configurations.""" + + @abstractmethod + def is_valid(self, adapter_type: EnergyLoadHistoryProviderAdapter) -> bool: + """Check if the configuration is valid for the given adapter type.""" + pass + + +class MinerControllerConfig(Configuration): + """Base interface for Miner Controller configurations.""" + + @abstractmethod + def is_valid(self, adapter_type: MinerControllerAdapter) -> bool: + """Check if the configuration is valid for the given adapter type.""" + pass + + +class MiningPerformanceTrackerConfig(Configuration): + """Base interface for Mining Performance Tracker configurations.""" + + @abstractmethod + def is_valid(self, adapter_type: MiningPerformanceTrackerAdapter) -> bool: + """Check if the configuration is valid for the given adapter type.""" + pass + + +class ExternalServiceConfig(Configuration): + """Base interface for External Service configurations.""" + + @abstractmethod + def is_valid(self, adapter_type: ExternalServiceAdapter) -> bool: + """Check if the configuration is valid for the given adapter type.""" + pass diff --git a/core/edge_mining/shared/interfaces/factories.py b/core/edge_mining/shared/interfaces/factories.py new file mode 100644 index 0000000..fc4a216 --- /dev/null +++ b/core/edge_mining/shared/interfaces/factories.py @@ -0,0 +1,86 @@ +"""Interfaces for the factories.""" + +from abc import ABC, abstractmethod +from typing import Any, Optional + +from edge_mining.domain.energy.entities import EnergySource +from edge_mining.domain.home_load.entities import LoadDevice +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import Configuration, ExternalServiceConfig +from edge_mining.shared.logging.port import LoggerPort + + +class ExternalServiceFactory(ABC): + """Abstract factory for external services""" + + @abstractmethod + def create(self, config: Optional[ExternalServiceConfig], logger: LoggerPort) -> Any: + """Create an external service""" + pass + + +class AdapterFactory(ABC): + """Abstract factory for adapters""" + + @abstractmethod + def create( + self, + config: Optional[Configuration], + logger: Optional[LoggerPort], + external_service: Optional[ExternalServicePort], + ) -> Any: + """Create an adapter""" + pass + + +class EnergyMonitorAdapterFactory(AdapterFactory): + """Abstract factory for energy monitor adapters""" + + @abstractmethod + def from_energy_source(self, energy_source: EnergySource) -> None: + """Set the reference energy source""" + pass + + +class MinerControllerAdapterFactory(AdapterFactory): + """Abstract factory for miner control adapters""" + + @abstractmethod + def from_miner(self, miner: Miner) -> None: + """Set the reference miner""" + pass + + +class NotificationAdapterFactory(AdapterFactory): + """Abstract factory for notification adapters""" + + +class ForecastAdapterFactory(AdapterFactory): + """Abstract factory for forecast adapters""" + + @abstractmethod + def from_energy_source(self, energy_source: EnergySource) -> None: + """Set the reference energy source""" + pass + + +class EnergyLoadForecastAdapterFactory(AdapterFactory): + """Abstract factory for energy load forecast adapters.""" + + +class EnergyLoadHistoryAdapterFactory(AdapterFactory): + """Abstract factory for energy load history adapters (device-scoped).""" + + @abstractmethod + def from_load_device(self, load_device: LoadDevice) -> None: + """Bind the factory to the LoadDevice this adapter will serve. + + Must be called before ``create`` so the resulting adapter knows its + ``device_id`` scope. + """ + pass + + +class MiningPerformanceTrackerAdapterFactory(AdapterFactory): + """Abstract factory for mining performance tracker adapters""" diff --git a/core/edge_mining/shared/logging/__init__.py b/core/edge_mining/shared/logging/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/shared/logging/port.py b/core/edge_mining/shared/logging/port.py new file mode 100644 index 0000000..e24a017 --- /dev/null +++ b/core/edge_mining/shared/logging/port.py @@ -0,0 +1,62 @@ +"""Log Port""" + +from abc import ABC, abstractmethod + + +class LoggerPort(ABC): + """Port for the Logger.""" + + @abstractmethod + def show_log_level(self, record): + """Allows to show stuff in the log based on the global setting.""" + raise NotImplementedError + + @abstractmethod + def default_log(self): + """Set the same debug level to all the project dependencies.""" + raise NotImplementedError + + @abstractmethod + def debug(self, msg): + """Logs a DEBUG message""" + raise NotImplementedError + + @abstractmethod + def info(self, msg): + """Logs an INFO message""" + raise NotImplementedError + + @abstractmethod + def warning(self, msg): + """Logs a WARNING message""" + raise NotImplementedError + + @abstractmethod + def error(self, msg): + """Logs an ERROR message""" + raise NotImplementedError + + @abstractmethod + def critical(self, msg): + """Logs a CRITICAL message""" + raise NotImplementedError + + @abstractmethod + def log(self, msg, level="DEBUG"): + """Log a message""" + raise NotImplementedError + + @abstractmethod + def welcome(self): + """Welcome message in the terminal.""" + raise NotImplementedError + + @abstractmethod + def shutdown(self): + """Sure that log are written to the file before exiting.""" + raise NotImplementedError + + @abstractmethod + def log_examples(self): + """Log examples for the log engine.""" + raise NotImplementedError diff --git a/core/edge_mining/shared/scheduler/__init__.py b/core/edge_mining/shared/scheduler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/edge_mining/shared/scheduler/port.py b/core/edge_mining/shared/scheduler/port.py new file mode 100644 index 0000000..ea80b2a --- /dev/null +++ b/core/edge_mining/shared/scheduler/port.py @@ -0,0 +1,17 @@ +"""Scheduler Port""" + +from abc import ABC, abstractmethod + + +class SchedulerPort(ABC): + """Port for the Scheduler.""" + + @abstractmethod + async def start(self): + """Starts the scheduler""" + raise NotImplementedError + + @abstractmethod + def stop(self): + """Stops the scheduler""" + raise NotImplementedError diff --git a/core/edge_mining/shared/settings/common.py b/core/edge_mining/shared/settings/common.py new file mode 100644 index 0000000..89ba606 --- /dev/null +++ b/core/edge_mining/shared/settings/common.py @@ -0,0 +1,12 @@ +"""Collection of Common Objects for the Settings shared domain of the Edge Mining application.""" + +from enum import Enum + + +class PersistenceAdapter(Enum): + """Types of persistence adapter.""" + + IN_MEMORY = "in_memory" + SQLITE = "sqlite" + SQLALCHEMY = "sqlalchemy" + YAML = "yaml" diff --git a/core/edge_mining/shared/settings/ports.py b/core/edge_mining/shared/settings/ports.py new file mode 100644 index 0000000..605b137 --- /dev/null +++ b/core/edge_mining/shared/settings/ports.py @@ -0,0 +1,21 @@ +"""Repository settings Port""" + +from abc import ABC, abstractmethod +from typing import Optional + +from edge_mining.domain.user.common import UserId +from edge_mining.domain.user.entities import SystemSettings + + +class SettingsRepository(ABC): + """Port for the Settings Repository.""" + + @abstractmethod + def get_settings(self, user_id: Optional[UserId]) -> Optional[SystemSettings]: # Assuming single settings object + """Gets the settings.""" + raise NotImplementedError + + @abstractmethod + def save_settings(self, user_id: Optional[UserId], settings: SystemSettings) -> None: + """Saves the settings.""" + raise NotImplementedError diff --git a/core/edge_mining/shared/settings/settings.py b/core/edge_mining/shared/settings/settings.py new file mode 100644 index 0000000..c44fc81 --- /dev/null +++ b/core/edge_mining/shared/settings/settings.py @@ -0,0 +1,45 @@ +"""Settings module for Edge Mining application.""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + +# Using pydantic-settings for easy environment variable loading + + +class AppSettings(BaseSettings): + """Settings for the Edge Mining application.""" + + # Application settings + log_level: str = "INFO" + timezone: str = "Europe/Rome" # Default timezone + latitude: float = 41.9028 # Default latitude for Rome + longitude: float = 12.4964 # Default longitude for Rome + + # Adapters Configuration (select which ones to use) + persistence_adapter: str = "sqlalchemy" # Options: "in_memory", "sqlite", "yaml", "sqlalchemy" + policies_persistence_adapter: str = "yaml" # Options: "in_memory", "sqlite", "yaml", "sqlalchemy" + + db_path: str = "sqlite:///data/db/edgemining.db" # Database URL + + # Database migration settings + run_migrations_on_startup: bool = True # Automatically run Alembic migrations on startup + backup_before_migration: bool = True # Create database backup before running migrations + + yaml_policies_dir: str = "data/policies" # Directory for YAML policies + + # API Settings + api_port: int = 8001 + + # Scheduler settings + scheduler_interval_seconds: int = 5 # Evaluate every 5 seconds + history_ingestion_interval_seconds: int = 120 # Collect power points every 2 minutes + history_retention_days: int = 90 # Purge power points older than 90 days + + # Forecast mix settings (α/β blending of forecast with last real measurement) + forecast_mix_alpha: float = 0.5 # Weight for the forecasted value + forecast_mix_beta: float = 0.5 # Weight for the last real measured value + + model_config = SettingsConfigDict( + env_file=".env", # Load .env file if exists + env_file_encoding="utf-8", + extra="ignore", # Ignore extra fields from env + ) diff --git a/core/edge_mining/shared/timezone.py b/core/edge_mining/shared/timezone.py new file mode 100644 index 0000000..9feba46 --- /dev/null +++ b/core/edge_mining/shared/timezone.py @@ -0,0 +1,18 @@ +"""Timezone utility for the Edge Mining application.""" + +from datetime import datetime +from functools import lru_cache +from zoneinfo import ZoneInfo + +from edge_mining.shared.settings.settings import AppSettings + + +@lru_cache(maxsize=1) +def get_timezone() -> ZoneInfo: + """Get the application's configured timezone.""" + return ZoneInfo(AppSettings().timezone) + + +def now() -> datetime: + """Get current datetime in the application's configured timezone.""" + return datetime.now(tz=get_timezone()) diff --git a/core/edge_mining/welcome.txt b/core/edge_mining/welcome.txt new file mode 100644 index 0000000..af34715 --- /dev/null +++ b/core/edge_mining/welcome.txt @@ -0,0 +1,20 @@ +.................................................... +.................................................... +.................................................... +.................. .......... ................ +................ ........ ............... +.............. ### ...... ### ............... +............ ### ### ............... +............ ### ### ................. +............ ################ ................... +............ # ..................... +.......................... # ..................... +.......................... # ............... +.......................... # ## ............... +.......................... # ### ............... +.......................... #### ................ +.......................... ## .................. +.......................... .................... +........................... ...................... +.................................................... +.................................................... diff --git a/core/pyproject.toml b/core/pyproject.toml new file mode 100644 index 0000000..2b89f81 --- /dev/null +++ b/core/pyproject.toml @@ -0,0 +1,234 @@ +[build-system] +requires = ["setuptools>=64", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.dynamic] +version = {attr = "edge_mining.__version__.__version__"} + +[project] +name = "edge-mining" +dynamic = ["version"] +description = "Software to optimize the use of excess energy, especially from renewable sources, through Bitcoin mining" +readme = "README.md" +license = {text = "MIT"} +authors = [ + {name = "Edge Mining Team", email = "asd@asd.it"} +] +maintainers = [ + {name = "Edge Mining Team", email = "asd@asd.it"} +] +classifiers = [ + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.11", + "Topic :: Home Automation", + "Topic :: Scientific/Engineering", + "Topic :: System :: Distributed Computing", + "Topic :: Utilities", +] +keywords = [ + "bitcoin", + "mining", + "energy", + "renewable", + "solar", + "automation", + "asic", + "optimization", + "homeassistant", + "hexagonal-architecture", + "domain-driven-design", +] +requires-python = ">=3.11,<4.0" +dependencies = [ + "pydantic>=2.12.5", + "pyyaml>=6.0.2", + "pydantic-settings>=2.8.1", + "apscheduler>=3.11.0", + "click>=8.1.8", + "loguru>=0.7.3", + "sqlalchemy>=2.0.0", + "alembic>=1.13.0", +] + +[project.optional-dependencies] +api = [ + "fastapi>=0.115.12", + "uvicorn[standard]>=0.34.1", +] +homeassistant = [ + "homeassistant_api==4.2.2.post1", +] +mqtt = [ + "paho-mqtt>=2.1.0", +] +telegram = [ + "python-telegram-bot>=20.0", +] +solar = [ + "astral>=3.2", +] +pyasic = [ + "pyasic==0.78.10" +] +ml = [ + "scikit-learn>=1.5.0", + "statsmodels>=0.14.0", + "xgboost>=2.0.0", + "skforecast>=0.14", + "optuna>=3.0", +] +all = [ + "edge-mining[api,homeassistant,mqtt,telegram,solar,pyasic]", +] +dev = [ + "pytest>=6.0", + "pytest-cov", + "mypy", + "ruff", + "bandit", + "pre-commit", +] + +[project.urls] +Homepage = "https://github.com/edge-mining/app" +Documentation = "https://github.com/edge-mining/docs" +Repository = "https://github.com/edge-mining/app" +"Bug Tracker" = "https://github.com/edge-mining/app/issues" +Changelog = "https://github.com/edge-mining/app/blob/main/core/CHANGELOG.md" + +[project.scripts] +edge-mining = "edge_mining.__main__:main" + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false +disallow_incomplete_defs = false +check_untyped_defs = true +disallow_untyped_decorators = false +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true +ignore_missing_imports = true +explicit_package_bases = true +disable_error_code = ["call-overload"] +exclude = [ + "tests/.*\\.py$", + "__pycache__/", + "\\.venv/", + "venv/", + "build/", + "dist/" +] + +[[tool.mypy.overrides]] +module = "tests.*" +ignore_errors = true + +[tool.ruff] +line-length = 120 +indent-width = 4 +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "tests", + "venv", +] + +[tool.ruff.lint] +select = ["E", "F", "W", "B"] +fixable = ["ALL"] +unfixable = [] +# Add BLE001 to ignore catching too general exception +ignore = ["BLE001", "B008"] +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +[tool.ruff.format] +# Like Black, use double quotes for strings. +quote-style = "double" +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = false +# Like Black, automatically detect the appropriate line ending. +line-ending = "auto" + + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = [ + "-v", + "-ra", + "-q", + "--strict-markers", + "--strict-config", +] +testpaths = [ + "tests", +] +python_files = [ + "test_*.py", + "*_test.py", +] +python_classes = [ + "Test*", +] +python_functions = [ + "test_*", +] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", + "unit: marks tests as unit tests", +] + +[tool.coverage.run] +source = ["edge_mining"] +omit = [ + "*/tests/*", + "*/venv/*", + "*/.venv/*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "if self.debug:", + "if settings.DEBUG", + "raise AssertionError", + "raise NotImplementedError", + "if 0:", + "if __name__ == .__main__.:", +] + +[tool.bandit] +exclude_dirs = ["tests", "venv", ".venv"] +skips = ["B101", "B601", "B311", "B104"] diff --git a/core/requirements-dev.txt b/core/requirements-dev.txt new file mode 100644 index 0000000..47c7f39 --- /dev/null +++ b/core/requirements-dev.txt @@ -0,0 +1,17 @@ +# Development Dependencies +# Testing +pytest==8.3.3 +pytest-cov==6.0.0 +pytest-asyncio==0.25.0 +pytest-mock==3.14.0 + +# Code Quality and Linting +ruff==0.12.9 +mypy==1.13.0 +bandit==1.8.0 + +# Pre-commit hooks +pre-commit==4.0.1 + +# Type checking stubs +types-PyYAML==6.0.12.20240917 diff --git a/core/requirements.txt b/core/requirements.txt new file mode 100644 index 0000000..71fe8ab --- /dev/null +++ b/core/requirements.txt @@ -0,0 +1,29 @@ +# Core Dependencies +pydantic==2.12.5 +pyyaml==6.0.2 +pydantic-settings==2.8.1 +apscheduler==3.11.0 +click==8.1.8 +loguru==0.7.3 + +# Database ORM and Migrations +sqlalchemy>=2.0.0 +alembic>=1.13.0 + +# Optional - For API Driving Adapter +fastapi==0.115.12 +uvicorn[standard]==0.34.1 + +# Optional - For specific Driven Adapters +paho-mqtt==2.1.0 +homeassistant_api==4.2.2.post1 +python-telegram-bot>=20.0 +astral==3.2 +pyasic==0.78.10 + +# Optional - For ML Forecast Adapters +scikit-learn>=1.5.0 +statsmodels>=0.14.0 +xgboost>=2.0.0 +skforecast>=0.14 +optuna>=3.0 diff --git a/core/scripts/README.md b/core/scripts/README.md new file mode 100644 index 0000000..0ffccb0 --- /dev/null +++ b/core/scripts/README.md @@ -0,0 +1,90 @@ +# Scripts Directory + +Utility scripts for Edge Mining development and maintenance. + +## Available Scripts + +### migrate.py + +Database migration management tool using Alembic. + +**Usage:** +```bash +# Check current database version +python scripts/migrate.py status + +# Apply all pending migrations +python scripts/migrate.py upgrade + +# Rollback last migration +python scripts/migrate.py downgrade + +# Rollback multiple migrations +python scripts/migrate.py downgrade 3 + +# Create new migration +python scripts/migrate.py create "Description of changes" + +# Create empty migration (no autogenerate) +python scripts/migrate.py create "Custom migration" --no-autogenerate + +# View migration history +python scripts/migrate.py history +``` + +**Configuration:** + +The script automatically loads configuration from `.env` file: +- `DB_PATH`: Database connection URL +- `LOG_LEVEL`: Logging verbosity + +**See Also:** +- [Alembic Migrations Guide](../../docs/ALEMBIC_MIGRATIONS.md) +- [Migration Example](../../docs/MIGRATION_EXAMPLE.md) + +## Adding New Scripts + +When adding new utility scripts to this directory: + +1. Make them executable: `chmod +x scripts/your_script.py` +2. Add a shebang: `#!/usr/bin/env python3` +3. Document usage in this README +4. Use the project's settings and logger where appropriate + +Example template: + +```python +#!/usr/bin/env python3 +"""Short description of the script.""" + +import argparse +import sys +from pathlib import Path + +# Add project root to path +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from edge_mining.shared.settings.settings import AppSettings +from edge_mining.adapters.infrastructure.logging.terminal_logging import TerminalLogger + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser(description="Script description") + # Add arguments + args = parser.parse_args() + + # Load settings + settings = AppSettings() + logger = TerminalLogger(log_level=settings.log_level) + + # Script logic here + logger.info("Script starting...") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` diff --git a/core/scripts/migrate.py b/core/scripts/migrate.py new file mode 100755 index 0000000..dd7c78a --- /dev/null +++ b/core/scripts/migrate.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""CLI utility for managing Alembic migrations. + +This script provides convenient commands for managing database migrations +while respecting the application's settings and configuration. + +Usage: + cd app/core + python -m scripts.migrate status # Check current revision + python -m scripts.migrate upgrade # Apply all pending migrations + python -m scripts.migrate downgrade [n] # Rollback n migrations (default: 1) + python -m scripts.migrate create "msg" # Create new migration + python -m scripts.migrate history # Show migration history + + OR use directly: + python scripts/migrate.py status +""" + +import argparse +import sys +from pathlib import Path + +# Add project root to path for direct script execution +if __name__ == "__main__": + project_root = Path(__file__).parent.parent + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + +from edge_mining.adapters.infrastructure.logging.terminal_logging import TerminalLogger +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.migrations import ( + check_current_revision, + create_migration, + downgrade_migration, + get_alembic_config, + run_migrations, +) +from edge_mining.shared.settings.settings import AppSettings + + +def main(): + """Main entry point for migration CLI.""" + parser = argparse.ArgumentParser( + description="Manage Alembic database migrations", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # Status command + subparsers.add_parser("status", help="Show current database revision") + + # Upgrade command + subparsers.add_parser("upgrade", help="Apply all pending migrations") + + # Downgrade command + downgrade_parser = subparsers.add_parser("downgrade", help="Rollback migrations") + downgrade_parser.add_argument( + "steps", + nargs="?", + default="1", + help="Number of migrations to rollback (default: 1)", + ) + + # Create command + create_parser = subparsers.add_parser("create", help="Create new migration") + create_parser.add_argument("message", help="Migration message/description") + create_parser.add_argument( + "--no-autogenerate", + action="store_true", + help="Create empty migration (no autogenerate)", + ) + + # History command + subparsers.add_parser("history", help="Show migration history") + + args = parser.parse_args() + + # Load settings + settings = AppSettings() + logger = TerminalLogger(log_level=settings.log_level) + + db_url = settings.db_path + + if not args.command: + parser.print_help() + return 0 + + try: + if args.command == "status": + current_rev = check_current_revision(db_url, logger) + if current_rev: + logger.info(f"Current database revision: {current_rev}") + else: + logger.warning("No migrations have been applied yet") + + elif args.command == "upgrade": + logger.info("Applying pending migrations...") + run_migrations(db_url, logger) + logger.info("✓ Migrations applied successfully") + + elif args.command == "downgrade": + steps = args.steps + if steps.isdigit(): + revision = f"-{steps}" + else: + revision = steps + + logger.warning(f"Rolling back {steps} migration(s)...") + downgrade_migration(db_url, revision, logger) + logger.info("✓ Rollback completed") + + elif args.command == "create": + autogenerate = not args.no_autogenerate + logger.info(f"Creating migration: {args.message}") + create_migration( + db_url, + args.message, + logger, + autogenerate=autogenerate, + ) + logger.info("✓ Migration created successfully") + + elif args.command == "history": + from alembic import command + + alembic_cfg = get_alembic_config(db_url) + command.history(alembic_cfg, verbose=True) + + return 0 + + except Exception as e: + logger.error(f"Error: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/core/scripts/validate_migrations.py b/core/scripts/validate_migrations.py new file mode 100755 index 0000000..f33e1c3 --- /dev/null +++ b/core/scripts/validate_migrations.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Validation script to check Alembic migrations setup. + +This script verifies that: +- Alembic is properly configured +- Database migrations are up to date +- All table definitions are registered +- The migration system is working correctly + +Usage: + cd /root/edge-mining/core-step1 + python -m scripts.validate_migrations + OR + python scripts/validate_migrations.py +""" + +import sys +from pathlib import Path + +# Add project root to path for direct script execution +if __name__ == "__main__": + project_root = Path(__file__).parent.parent + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + +from edge_mining.adapters.infrastructure.logging.terminal_logging import TerminalLogger +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.migrations import ( + check_current_revision, + get_alembic_config, +) +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.registry import metadata +from edge_mining.shared.settings.settings import AppSettings + + +def main(): + """Run validation checks.""" + logger = TerminalLogger(log_level="INFO") + settings = AppSettings() + + logger.info("=" * 60) + logger.info("Alembic Migrations Setup Validation") + logger.info("=" * 60) + + checks_passed = 0 + checks_failed = 0 + + # Check 1: Settings + logger.info("\n[1/5] Checking settings...") + try: + db_url = settings.db_path + migrations_enabled = settings.run_migrations_on_startup + logger.info(f" ✓ Database URL: {db_url}") + logger.info(f" ✓ Migrations on startup: {migrations_enabled}") + checks_passed += 1 + except Exception as e: + logger.error(f" ✗ Settings check failed: {e}") + checks_failed += 1 + + # Check 2: Alembic Configuration + logger.info("\n[2/5] Checking Alembic configuration...") + try: + alembic_cfg = get_alembic_config(settings.db_path) + script_location = alembic_cfg.get_main_option("script_location") + logger.info(" ✓ Alembic config loaded") + logger.info(f" ✓ Script location: {script_location}") + checks_passed += 1 + except Exception as e: + logger.error(f" ✗ Alembic config check failed: {e}") + checks_failed += 1 + return 1 + + # Check 3: Table Definitions + logger.info("\n[3/5] Checking table definitions...") + try: + # Import registry loader to ensure all tables are registered + from edge_mining.adapters.infrastructure.persistence.sqlalchemy import registry_loader # noqa: F401 + + tables = list(metadata.tables.keys()) + if tables: + logger.info(f" ✓ Found {len(tables)} table definition(s):") + for table in sorted(tables): + logger.info(f" - {table}") + checks_passed += 1 + else: + logger.warning(" ⚠ No table definitions found (this may be expected for a fresh setup)") + checks_passed += 1 + except Exception as e: + logger.error(f" ✗ Table definitions check failed: {e}") + checks_failed += 1 + + # Check 4: Database Connection + logger.info("\n[4/5] Checking database connection...") + try: + current_rev = check_current_revision(settings.db_path, logger=None) + if current_rev: + logger.info(" ✓ Database connected") + logger.info(f" ✓ Current revision: {current_rev}") + else: + logger.info(" ✓ Database connected") + logger.warning(" ⚠ No migrations applied yet (database is empty)") + checks_passed += 1 + except Exception as e: + logger.error(f" ✗ Database connection check failed: {e}") + checks_failed += 1 + + # Check 5: Migration Files + logger.info("\n[5/5] Checking migration files...") + try: + if script_location: + versions_dir = Path(script_location) / "versions" + if versions_dir.exists(): + migration_files = list(versions_dir.glob("*.py")) + migration_files = [f for f in migration_files if not f.name.startswith("__")] + logger.info(f" ✓ Migrations directory: {versions_dir}") + logger.info(f" ✓ Found {len(migration_files)} migration file(s)") + for mig_file in sorted(migration_files): + logger.info(f" - {mig_file.name}") + checks_passed += 1 + else: + logger.error(f" ✗ Migrations directory not found: {versions_dir}") + checks_failed += 1 + else: + logger.error(" ✗ Could not determine script location") + checks_failed += 1 + except Exception as e: + logger.error(f" ✗ Migration files check failed: {e}") + checks_failed += 1 + + # Summary + logger.info("\n" + "=" * 60) + logger.info("Validation Summary") + logger.info("=" * 60) + logger.info(f"Checks passed: {checks_passed}/5") + logger.info(f"Checks failed: {checks_failed}/5") + + if checks_failed == 0: + logger.info("\n✓ All checks passed! Alembic migrations are properly configured.") + logger.info("\nNext steps:") + logger.info(" - Run migrations: python scripts/migrate.py upgrade") + logger.info(" - Check status: python scripts/migrate.py status") + logger.info(" - Start application: python -m edge_mining") + return 0 + else: + logger.error("\n✗ Some checks failed. Please review the errors above.") + logger.info("\nTroubleshooting:") + logger.info(" - Check that alembic.ini exists in project root") + logger.info(" - Verify database connection in .env file") + logger.info(" - Ensure all dependencies are installed: pip install -r requirements.txt") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/core/tests/__init__.py b/core/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/tests/integration/__init__.py b/core/tests/integration/__init__.py new file mode 100644 index 0000000..17a9d23 --- /dev/null +++ b/core/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests for Edge Mining application.""" diff --git a/core/tests/integration/adapters/__init__.py b/core/tests/integration/adapters/__init__.py new file mode 100644 index 0000000..b6f3e15 --- /dev/null +++ b/core/tests/integration/adapters/__init__.py @@ -0,0 +1 @@ +"""Integration tests for adapters layer.""" diff --git a/core/tests/integration/adapters/persistence/__init__.py b/core/tests/integration/adapters/persistence/__init__.py new file mode 100644 index 0000000..58fe03a --- /dev/null +++ b/core/tests/integration/adapters/persistence/__init__.py @@ -0,0 +1 @@ +"""Integration tests for persistence adapters.""" diff --git a/core/tests/integration/adapters/persistence/conftest.py b/core/tests/integration/adapters/persistence/conftest.py new file mode 100644 index 0000000..e1e2afd --- /dev/null +++ b/core/tests/integration/adapters/persistence/conftest.py @@ -0,0 +1,113 @@ +"""Shared fixtures for persistence integration tests.""" + +import os +import tempfile +from pathlib import Path +from typing import Generator + +import pytest + +from edge_mining.adapters.infrastructure.logging.terminal_logging import TerminalLogger +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.shared.logging.port import LoggerPort + + +@pytest.fixture +def logger() -> LoggerPort: + """Create a logger for testing.""" + return TerminalLogger(log_level="DEBUG") + + +@pytest.fixture +def test_db_path() -> Generator[str, None, None]: + """Create a temporary database file path for testing. + + Yields: + Path to a temporary database file that will be cleaned up after the test. + """ + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + db_path = f.name + + yield db_path + + # Cleanup + if os.path.exists(db_path): + os.unlink(db_path) + + +@pytest.fixture +def test_db_url(test_db_path: str) -> str: + """Create a SQLite database URL for testing. + + Args: + test_db_path: Path to the temporary database file + + Returns: + SQLite database URL string + """ + return f"sqlite:///{test_db_path}" + + +@pytest.fixture +def sqlalchemy_repo(test_db_url: str, logger: LoggerPort) -> Generator[BaseSQLAlchemyRepository, None, None]: + """Create a BaseSQLAlchemyRepository instance with migrations. + + Args: + test_db_url: Database URL for testing + logger: Logger instance + + Yields: + Initialized BaseSQLAlchemyRepository instance + """ + # Reset class-level shared resources for clean test + BaseSQLAlchemyRepository._engine = None + BaseSQLAlchemyRepository._SessionLocal = None + + repo = BaseSQLAlchemyRepository( + db_path=test_db_url, + logger=logger, + run_migrations=True, + backup_before_migration=False, # No backup for test databases + ) + + # Initialize the database with migrations + repo.initialize_database() + + yield repo + + # Cleanup: close any remaining connections + if BaseSQLAlchemyRepository._engine: + BaseSQLAlchemyRepository._engine.dispose() + + +@pytest.fixture +def sqlalchemy_repo_no_migrations( + test_db_url: str, logger: LoggerPort +) -> Generator[BaseSQLAlchemyRepository, None, None]: + """Create a BaseSQLAlchemyRepository instance without running migrations. + + Useful for testing migration behavior separately. + + Args: + test_db_url: Database URL for testing + logger: Logger instance + + Yields: + BaseSQLAlchemyRepository instance (not yet initialized) + """ + # Reset class-level shared resources for clean test + BaseSQLAlchemyRepository._engine = None + BaseSQLAlchemyRepository._SessionLocal = None + + repo = BaseSQLAlchemyRepository( + db_path=test_db_url, + logger=logger, + run_migrations=False, + backup_before_migration=False, + ) + + yield repo + + # Cleanup + if BaseSQLAlchemyRepository._engine: + BaseSQLAlchemyRepository._engine.dispose() diff --git a/core/tests/integration/adapters/persistence/test_alembic_migrations.py b/core/tests/integration/adapters/persistence/test_alembic_migrations.py new file mode 100644 index 0000000..c78e76b --- /dev/null +++ b/core/tests/integration/adapters/persistence/test_alembic_migrations.py @@ -0,0 +1,290 @@ +"""Integration tests for Alembic migrations. + +These tests verify that Alembic migrations correctly create and update +the database schema, and that the migration system integrates properly +with the SQLAlchemy repository layer. +""" + +import os +import tempfile +from pathlib import Path + +import pytest +from sqlalchemy import inspect, text + +from edge_mining.adapters.infrastructure.logging.terminal_logging import TerminalLogger +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.migrations import ( + check_current_revision, + run_migrations, +) + + +class TestAlembicMigrations: + """Integration tests for Alembic migration system.""" + + def test_run_migrations_creates_tables(self, test_db_url: str, logger): + """Test that running migrations creates all expected tables.""" + # Reset shared resources + BaseSQLAlchemyRepository._engine = None + BaseSQLAlchemyRepository._SessionLocal = None + + repo = BaseSQLAlchemyRepository( + db_path=test_db_url, + logger=logger, + run_migrations=True, + backup_before_migration=False, + ) + repo.initialize_database() + + # Inspect the database schema + inspector = inspect(repo._engine) + table_names = inspector.get_table_names() + + # Verify key tables exist + assert "energy_sources" in table_names + assert "energy_monitors" in table_names + assert "alembic_version" in table_names # Alembic tracking table + + def test_migrations_create_correct_columns(self, test_db_url: str, logger): + """Test that migrations create tables with correct columns.""" + BaseSQLAlchemyRepository._engine = None + BaseSQLAlchemyRepository._SessionLocal = None + + repo = BaseSQLAlchemyRepository( + db_path=test_db_url, + logger=logger, + run_migrations=True, + backup_before_migration=False, + ) + repo.initialize_database() + + inspector = inspect(repo._engine) + + # Check energy_sources columns + energy_sources_columns = {col["name"] for col in inspector.get_columns("energy_sources")} + expected_source_cols = { + "id", + "name", + "type", + "nominal_power_max", + "storage", + "grid", + "external_source", + "energy_monitor_id", + "forecast_provider_id", + } + assert expected_source_cols.issubset(energy_sources_columns) + + # Check energy_monitors columns + energy_monitors_columns = {col["name"] for col in inspector.get_columns("energy_monitors")} + expected_monitor_cols = {"id", "name", "adapter_type", "config", "external_service_id"} + assert expected_monitor_cols.issubset(energy_monitors_columns) + + def test_alembic_version_tracking(self, test_db_url: str, logger): + """Test that Alembic correctly tracks the current migration version.""" + BaseSQLAlchemyRepository._engine = None + BaseSQLAlchemyRepository._SessionLocal = None + + repo = BaseSQLAlchemyRepository( + db_path=test_db_url, + logger=logger, + run_migrations=True, + backup_before_migration=False, + ) + repo.initialize_database() + + # Get current revision from alembic_version table + session = repo.get_session() + try: + result = session.execute(text("SELECT version_num FROM alembic_version")) + version = result.scalar_one_or_none() + assert version is not None + assert len(version) == 12 # Alembic revision IDs are 12 characters + finally: + session.close() + + def test_migrations_idempotent(self, test_db_url: str, logger): + """Test that running migrations multiple times is safe (idempotent).""" + BaseSQLAlchemyRepository._engine = None + BaseSQLAlchemyRepository._SessionLocal = None + + repo = BaseSQLAlchemyRepository( + db_path=test_db_url, + logger=logger, + run_migrations=True, + backup_before_migration=False, + ) + + # Run migrations twice + repo.initialize_database() + repo.initialize_database() + + # Should still work without errors + inspector = inspect(repo._engine) + table_names = inspector.get_table_names() + assert "energy_sources" in table_names + + def test_initialize_without_migrations_creates_tables(self, test_db_url: str, logger): + """Test that initializing without migrations warns but doesn't create tables.""" + BaseSQLAlchemyRepository._engine = None + BaseSQLAlchemyRepository._SessionLocal = None + + repo = BaseSQLAlchemyRepository( + db_path=test_db_url, + logger=logger, + run_migrations=False, # Skip migrations + backup_before_migration=False, + ) + repo.initialize_database() + + # Tables should NOT be created when migrations are disabled + # The system only creates tables through Alembic migrations + inspector = inspect(repo._engine) + table_names = inspector.get_table_names() + assert len(table_names) == 0, "No tables should exist when migrations are disabled" + + # alembic_version should also not exist when migrations are skipped + assert "alembic_version" not in table_names + + def test_database_backup_before_migration(self, test_db_path: str, logger): + """Test that database backup is created when enabled.""" + # Create initial database + test_db_url = f"sqlite:///{test_db_path}" + + BaseSQLAlchemyRepository._engine = None + BaseSQLAlchemyRepository._SessionLocal = None + + # Create a simple database first + repo = BaseSQLAlchemyRepository( + db_path=test_db_url, + logger=logger, + run_migrations=False, + backup_before_migration=False, + ) + repo.initialize_database() + + # Add some dummy data + session = repo.get_session() + session.execute(text("CREATE TABLE IF NOT EXISTS test_table (id INTEGER PRIMARY KEY, name TEXT)")) + session.execute(text("INSERT INTO test_table (id, name) VALUES (1, 'test')")) + session.commit() + session.close() + + # Close the engine + repo._engine.dispose() + + # Now test backup functionality + BaseSQLAlchemyRepository._engine = None + BaseSQLAlchemyRepository._SessionLocal = None + + repo2 = BaseSQLAlchemyRepository( + db_path=test_db_url, + logger=logger, + run_migrations=True, + backup_before_migration=True, # Enable backup + ) + + # Run migrations (which should trigger backup) + repo2.initialize_database() + + # Check if backup file was created + backup_dir = Path(test_db_path).parent / "backups" + if backup_dir.exists(): + backup_files = list(backup_dir.glob("*.db")) + # Backup might be created, depending on migration implementation + # This test verifies the backup mechanism doesn't cause errors + + def test_schema_matches_table_definitions(self, test_db_url: str, logger): + """Test that the migrated schema matches our table definitions.""" + BaseSQLAlchemyRepository._engine = None + BaseSQLAlchemyRepository._SessionLocal = None + + repo = BaseSQLAlchemyRepository( + db_path=test_db_url, + logger=logger, + run_migrations=True, + backup_before_migration=False, + ) + repo.initialize_database() + + inspector = inspect(repo._engine) + + # Verify foreign keys are set up correctly + fks = inspector.get_foreign_keys("energy_sources") + fk_columns = {fk["constrained_columns"][0] for fk in fks} + + # Energy sources should have foreign keys to energy_monitors and forecast_providers + # (exact foreign keys depend on your schema) + # This is a basic check that foreign key introspection works + + # Verify indexes + indexes = inspector.get_indexes("energy_sources") + # Primary key should be indexed + pk_constraints = inspector.get_pk_constraint("energy_sources") + assert pk_constraints["constrained_columns"] == ["id"] + + def test_migration_can_insert_and_query_data(self, test_db_url: str, logger): + """Test that after migration, we can insert and query data.""" + BaseSQLAlchemyRepository._engine = None + BaseSQLAlchemyRepository._SessionLocal = None + + repo = BaseSQLAlchemyRepository( + db_path=test_db_url, + logger=logger, + run_migrations=True, + backup_before_migration=False, + ) + repo.initialize_database() + + session = repo.get_session() + try: + # Insert test data + session.execute( + text( + """ + INSERT INTO energy_sources (id, name, type, nominal_power_max) + VALUES (:id, :name, :type, :power) + """ + ), + {"id": "test-id-123", "name": "Test Source", "type": "SOLAR", "power": 5000.0}, + ) + session.commit() + + # Query data + result = session.execute( + text("SELECT name, type FROM energy_sources WHERE id = :id"), {"id": "test-id-123"} + ) + row = result.fetchone() + assert row is not None + assert row[0] == "Test Source" + assert row[1] == "SOLAR" + finally: + session.close() + + +class TestMigrationHelperFunctions: + """Test migration helper functions.""" + + def test_check_current_revision(self, test_db_url: str, logger): + """Test checking current migration revision.""" + BaseSQLAlchemyRepository._engine = None + BaseSQLAlchemyRepository._SessionLocal = None + + repo = BaseSQLAlchemyRepository( + db_path=test_db_url, + logger=logger, + run_migrations=True, + backup_before_migration=False, + ) + repo.initialize_database() + + # Check current revision + revision = check_current_revision(test_db_url, logger) + assert revision is not None + assert isinstance(revision, str) + assert len(revision) == 12 # Alembic revision format + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/core/tests/integration/adapters/persistence/test_e2e_persistence.py b/core/tests/integration/adapters/persistence/test_e2e_persistence.py new file mode 100644 index 0000000..6b43434 --- /dev/null +++ b/core/tests/integration/adapters/persistence/test_e2e_persistence.py @@ -0,0 +1,328 @@ +"""End-to-end integration tests for SQLAlchemy persistence layer. + +These tests verify the complete flow: database initialization → migrations → +CRUD operations → value object persistence → querying across entities. +""" + +import uuid + +import pytest +from sqlalchemy import inspect + +from edge_mining.adapters.domain.energy.repositories import ( + SqlAlchemyEnergyMonitorRepository, + SqlAlchemyEnergySourceRepository, +) +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.domain.common import EntityId, WattHours, Watts +from edge_mining.domain.energy.common import EnergyMonitorAdapter, EnergySourceType +from edge_mining.domain.energy.entities import EnergyMonitor, EnergySource +from edge_mining.domain.energy.value_objects import Battery, Grid +from edge_mining.shared.adapter_configs.energy import EnergyMonitorDummySolarConfig + + +class TestEndToEndPersistenceFlow: + """End-to-end tests for complete persistence scenarios.""" + + @pytest.fixture + def repositories(self, sqlalchemy_repo: BaseSQLAlchemyRepository): + """Create all energy repositories.""" + return { + "energy_source": SqlAlchemyEnergySourceRepository(db=sqlalchemy_repo), + "energy_monitor": SqlAlchemyEnergyMonitorRepository(db=sqlalchemy_repo), + } + + def test_complete_solar_system_setup(self, repositories): + """Test creating a complete solar energy system with monitor and source.""" + source_repo = repositories["energy_source"] + monitor_repo = repositories["energy_monitor"] + + # Step 1: Create and persist an energy monitor + config = EnergyMonitorDummySolarConfig() + energy_monitor = EnergyMonitor( + name="Solar Panel Monitor", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=config, + ) + monitor_repo.add(energy_monitor) + + # Step 2: Create an energy source with battery and grid + battery = Battery(nominal_capacity=WattHours(20000.0)) + grid = Grid(contracted_power=Watts(5000.0)) + + energy_source = EnergySource( + name="Rooftop Solar Array", + type=EnergySourceType.SOLAR, + nominal_power_max=Watts(10000.0), + ) + energy_source.connect_to_storage(battery) + energy_source.connect_to_grid(grid) + energy_source.use_energy_monitor(energy_monitor.id) + + source_repo.add(energy_source) + + # Step 3: Retrieve and verify the complete setup + retrieved_source = source_repo.get_by_id(energy_source.id) + retrieved_monitor = monitor_repo.get_by_id(energy_monitor.id) + + # Verify energy source + assert retrieved_source is not None + assert retrieved_source.name == "Rooftop Solar Array" + assert retrieved_source.type == EnergySourceType.SOLAR + assert float(retrieved_source.nominal_power_max) == 10000.0 + + # Verify battery + assert retrieved_source.storage is not None + assert isinstance(retrieved_source.storage, Battery) + assert float(retrieved_source.storage.nominal_capacity) == 20000.0 + + # Verify grid + assert retrieved_source.grid is not None + assert isinstance(retrieved_source.grid, Grid) + assert float(retrieved_source.grid.contracted_power) == 5000.0 + + # Verify monitor reference (compare as strings since energy_monitor.id may be string after persistence) + assert str(retrieved_source.energy_monitor_id) == str(energy_monitor.id) + + # Verify monitor + assert retrieved_monitor is not None + assert retrieved_monitor.name == "Solar Panel Monitor" + assert retrieved_monitor.adapter_type == EnergyMonitorAdapter.DUMMY_SOLAR + + def test_multiple_sources_single_monitor(self, repositories): + """Test scenario with multiple energy sources sharing one monitor.""" + source_repo = repositories["energy_source"] + monitor_repo = repositories["energy_monitor"] + + # Create one monitor + config = EnergyMonitorDummySolarConfig() + monitor = EnergyMonitor( + name="Shared Monitor", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=config, + ) + monitor_repo.add(monitor) + + # Create multiple energy sources using the same monitor + source1 = EnergySource( + name="Solar Panel 1", + type=EnergySourceType.SOLAR, + nominal_power_max=Watts(5000.0), + ) + source1.use_energy_monitor(monitor.id) + + source2 = EnergySource( + name="Solar Panel 2", + type=EnergySourceType.SOLAR, + nominal_power_max=Watts(3000.0), + ) + source2.use_energy_monitor(monitor.id) + + source_repo.add(source1) + source_repo.add(source2) + + # Retrieve all sources + all_sources = source_repo.get_all() + + # Filter sources using our monitor (compare as strings) + monitor_id_str = str(monitor.id) + sources_with_monitor = [s for s in all_sources if str(s.energy_monitor_id) == monitor_id_str] + assert len(sources_with_monitor) == 2 + + names = {s.name for s in sources_with_monitor} + assert "Solar Panel 1" in names + assert "Solar Panel 2" in names + + def test_update_and_retrieve_complex_changes(self, repositories): + """Test updating complex nested value objects.""" + source_repo = repositories["energy_source"] + + # Create initial source with battery + initial_battery = Battery(nominal_capacity=WattHours(10000.0)) + energy_source = EnergySource( + name="Evolving System", + type=EnergySourceType.WIND, # Use WIND instead of non-existent HYBRID + nominal_power_max=Watts(6000.0), + ) + energy_source.connect_to_storage(initial_battery) + source_repo.add(energy_source) + + # Update: change battery capacity + new_battery = Battery(nominal_capacity=WattHours(25000.0)) + energy_source.disconnect_from_storage() + energy_source.connect_to_storage(new_battery) + + # Add grid connection + grid = Grid(contracted_power=Watts(4000.0)) + energy_source.connect_to_grid(grid) + + # Update power rating + energy_source.nominal_power_max = Watts(8000.0) + + source_repo.update(energy_source) + + # Retrieve and verify all changes + retrieved = source_repo.get_by_id(energy_source.id) + assert retrieved is not None + assert float(retrieved.storage.nominal_capacity) == 25000.0 + assert retrieved.grid is not None + assert float(retrieved.grid.contracted_power) == 4000.0 + assert float(retrieved.nominal_power_max) == 8000.0 + + def test_delete_and_orphan_references(self, repositories): + """Test deleting entities and handling orphaned references.""" + source_repo = repositories["energy_source"] + monitor_repo = repositories["energy_monitor"] + + # Create monitor and source + config = EnergyMonitorDummySolarConfig() + monitor = EnergyMonitor( + name="Temporary Monitor", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=config, + ) + monitor_repo.add(monitor) + + energy_source = EnergySource( + name="Temporary Source", + type=EnergySourceType.SOLAR, + nominal_power_max=Watts(3000.0), + ) + energy_source.use_energy_monitor(monitor.id) + source_repo.add(energy_source) + + # Delete monitor (source will have orphaned reference) + monitor_repo.remove(monitor.id) + + # Source should still exist but with dangling reference (compare as strings) + retrieved_source = source_repo.get_by_id(energy_source.id) + assert retrieved_source is not None + assert str(retrieved_source.energy_monitor_id) == str(monitor.id) # Reference still exists + + # Monitor should be gone + retrieved_monitor = monitor_repo.get_by_id(monitor.id) + assert retrieved_monitor is None + + def test_batch_operations(self, repositories): + """Test batch creation and retrieval of multiple entities.""" + source_repo = repositories["energy_source"] + + # Create multiple diverse energy sources + sources = [ + EnergySource( + name=f"Solar Array {i}", + type=EnergySourceType.SOLAR, + nominal_power_max=Watts(1000.0 * i), + ) + for i in range(1, 6) + ] + + # Add batteries to some + for i, source in enumerate(sources): + if i % 2 == 0: + battery = Battery(nominal_capacity=WattHours(5000.0 * (i + 1))) + source.connect_to_storage(battery) + + # Persist all + for source in sources: + source_repo.add(source) + + # Retrieve all and verify + all_sources = source_repo.get_all() + assert len(all_sources) >= 5 + + # Verify specific sources + retrieved_ids = {str(s.id) for s in all_sources} + for source in sources: + assert str(source.id) in retrieved_ids + + def test_value_object_edge_cases(self, repositories): + """Test edge cases for value objects (zero values, very large values).""" + source_repo = repositories["energy_source"] + + # Create source with edge case values + energy_source = EnergySource( + name="Edge Case System", + type=EnergySourceType.SOLAR, + nominal_power_max=Watts(0.0), # Zero power + ) + + # Very large battery + huge_battery = Battery(nominal_capacity=WattHours(1000000.0)) + energy_source.connect_to_storage(huge_battery) + + # Small grid + tiny_grid = Grid(contracted_power=Watts(0.1)) + energy_source.connect_to_grid(tiny_grid) + + source_repo.add(energy_source) + + # Retrieve and verify edge cases handled correctly + retrieved = source_repo.get_by_id(energy_source.id) + assert retrieved is not None + assert float(retrieved.nominal_power_max) == 0.0 + assert float(retrieved.storage.nominal_capacity) == 1000000.0 + assert float(retrieved.grid.contracted_power) == 0.1 + + def test_schema_consistency_after_operations(self, sqlalchemy_repo: BaseSQLAlchemyRepository, repositories): + """Test that schema remains consistent after various operations.""" + source_repo = repositories["energy_source"] + + # Perform various operations + source = EnergySource( + name="Consistency Test", + type=EnergySourceType.SOLAR, + nominal_power_max=Watts(5000.0), + ) + source_repo.add(source) + + source.name = "Updated Name" + source_repo.update(source) + + source_repo.remove(source.id) + + # Verify schema integrity + inspector = inspect(sqlalchemy_repo._engine) + tables = inspector.get_table_names() + + # Core tables should still exist + assert "energy_sources" in tables + assert "energy_monitors" in tables + + # Columns should be intact + columns = {col["name"] for col in inspector.get_columns("energy_sources")} + assert "id" in columns + assert "storage" in columns + assert "grid" in columns + + def test_concurrent_repository_sessions(self, sqlalchemy_repo: BaseSQLAlchemyRepository): + """Test that multiple repository instances can work with the same database.""" + # Create two separate repository instances + repo1 = SqlAlchemyEnergySourceRepository(db=sqlalchemy_repo) + repo2 = SqlAlchemyEnergySourceRepository(db=sqlalchemy_repo) + + # Add via repo1 + source = EnergySource( + name="Shared Source", + type=EnergySourceType.SOLAR, + nominal_power_max=Watts(4000.0), + ) + repo1.add(source) + + # Retrieve via repo2 + retrieved = repo2.get_by_id(source.id) + assert retrieved is not None + assert retrieved.name == "Shared Source" + + # Update via repo2 + retrieved.name = "Updated via Repo2" + repo2.update(retrieved) + + # Verify via repo1 + final = repo1.get_by_id(source.id) + assert final is not None + assert final.name == "Updated via Repo2" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/core/tests/integration/adapters/persistence/test_sqlalchemy_energy_repositories.py b/core/tests/integration/adapters/persistence/test_sqlalchemy_energy_repositories.py new file mode 100644 index 0000000..7b6f1a4 --- /dev/null +++ b/core/tests/integration/adapters/persistence/test_sqlalchemy_energy_repositories.py @@ -0,0 +1,393 @@ +"""Integration tests for SQLAlchemy Energy repositories. + +These tests verify that the SQLAlchemy repositories correctly persist and retrieve +domain entities with their value objects (Battery, Grid, Watts) using the imperative +mapping and event listeners defined in tables.py. +""" + +import uuid + +import pytest + +from edge_mining.adapters.domain.energy.repositories import ( + SqlAlchemyEnergyMonitorRepository, + SqlAlchemyEnergySourceRepository, +) +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.domain.common import EntityId, WattHours, Watts +from edge_mining.domain.energy.common import EnergyMonitorAdapter, EnergySourceType +from edge_mining.domain.energy.entities import EnergyMonitor, EnergySource +from edge_mining.domain.energy.value_objects import Battery, Grid +from edge_mining.shared.adapter_configs.energy import EnergyMonitorDummySolarConfig + + +class TestSqlAlchemyEnergySourceRepository: + """Integration tests for SqlAlchemyEnergySourceRepository. + + These tests verify CRUD operations and value object persistence. + """ + + @pytest.fixture + def repository(self, sqlalchemy_repo: BaseSQLAlchemyRepository) -> SqlAlchemyEnergySourceRepository: + """Create an EnergySource repository instance.""" + return SqlAlchemyEnergySourceRepository(db=sqlalchemy_repo) + + def test_add_and_get_simple_energy_source(self, repository: SqlAlchemyEnergySourceRepository): + """Test adding and retrieving a simple energy source.""" + # Create a simple energy source + energy_source = EnergySource( + name="Solar Panel System", + type=EnergySourceType.SOLAR, + nominal_power_max=Watts(6000.0), + ) + original_id = energy_source.id + + # Add to repository + repository.add(energy_source) + + # Retrieve and verify + retrieved = repository.get_by_id(original_id) + assert retrieved is not None + assert retrieved.id == original_id + assert retrieved.name == "Solar Panel System" + assert retrieved.type == EnergySourceType.SOLAR + assert isinstance(retrieved.nominal_power_max, type(Watts(0.0))) + assert float(retrieved.nominal_power_max) == 6000.0 + + def test_add_energy_source_with_battery(self, repository: SqlAlchemyEnergySourceRepository): + """Test adding energy source with Battery value object.""" + battery = Battery(nominal_capacity=WattHours(10000.0)) + energy_source = EnergySource( + name="Solar with Storage", + type=EnergySourceType.SOLAR, + nominal_power_max=Watts(5000.0), + ) + energy_source.connect_to_storage(battery) + + repository.add(energy_source) + + # Retrieve and verify battery is correctly deserialized + retrieved = repository.get_by_id(energy_source.id) + assert retrieved is not None + assert retrieved.storage is not None + assert isinstance(retrieved.storage, Battery) + assert float(retrieved.storage.nominal_capacity) == 10000.0 + + def test_add_energy_source_with_grid(self, repository: SqlAlchemyEnergySourceRepository): + """Test adding energy source with Grid value object.""" + grid = Grid(contracted_power=Watts(3000.0)) + energy_source = EnergySource( + name="Solar with Grid", + type=EnergySourceType.SOLAR, + nominal_power_max=Watts(5000.0), + ) + energy_source.connect_to_grid(grid) + + repository.add(energy_source) + + # Retrieve and verify grid is correctly deserialized + retrieved = repository.get_by_id(energy_source.id) + assert retrieved is not None + assert retrieved.grid is not None + assert isinstance(retrieved.grid, Grid) + assert float(retrieved.grid.contracted_power) == 3000.0 + + def test_add_energy_source_with_all_value_objects(self, repository: SqlAlchemyEnergySourceRepository): + """Test adding energy source with all value objects (Battery, Grid, external_source).""" + battery = Battery(nominal_capacity=WattHours(20000.0)) + grid = Grid(contracted_power=Watts(5000.0)) + + energy_source = EnergySource( + name="Complete Solar System", + type=EnergySourceType.SOLAR, + nominal_power_max=Watts(8000.0), + ) + energy_source.connect_to_storage(battery) + energy_source.connect_to_grid(grid) + energy_source.external_source = Watts(1000.0) + + repository.add(energy_source) + + # Retrieve and verify all value objects + retrieved = repository.get_by_id(energy_source.id) + assert retrieved is not None + assert retrieved.storage is not None + assert float(retrieved.storage.nominal_capacity) == 20000.0 + assert retrieved.grid is not None + assert float(retrieved.grid.contracted_power) == 5000.0 + assert retrieved.external_source is not None + assert float(retrieved.external_source) == 1000.0 + + def test_get_all(self, repository: SqlAlchemyEnergySourceRepository): + """Test retrieving all energy sources.""" + # Add multiple energy sources + source1 = EnergySource(name="Solar 1", type=EnergySourceType.SOLAR, nominal_power_max=Watts(3000.0)) + source2 = EnergySource(name="Wind 1", type=EnergySourceType.WIND, nominal_power_max=Watts(2000.0)) + source3 = EnergySource(name="Hydro 1", type=EnergySourceType.HYDROELECTRIC, nominal_power_max=Watts(5000.0)) + + repository.add(source1) + repository.add(source2) + repository.add(source3) + + # Retrieve all + all_sources = repository.get_all() + assert len(all_sources) == 3 + + names = {source.name for source in all_sources} + assert "Solar 1" in names + assert "Wind 1" in names + assert "Hydro 1" in names + + def test_update_energy_source(self, repository: SqlAlchemyEnergySourceRepository): + """Test updating an energy source.""" + energy_source = EnergySource( + name="Original Name", + type=EnergySourceType.SOLAR, + nominal_power_max=Watts(3000.0), + ) + repository.add(energy_source) + + # Update properties + energy_source.name = "Updated Name" + energy_source.nominal_power_max = Watts(4000.0) + battery = Battery(nominal_capacity=WattHours(15000.0)) + energy_source.connect_to_storage(battery) + + repository.update(energy_source) + + # Retrieve and verify updates + retrieved = repository.get_by_id(energy_source.id) + assert retrieved is not None + assert retrieved.name == "Updated Name" + assert float(retrieved.nominal_power_max) == 4000.0 + assert retrieved.storage is not None + assert float(retrieved.storage.nominal_capacity) == 15000.0 + + def test_update_removes_optional_value_objects(self, repository: SqlAlchemyEnergySourceRepository): + """Test that updating can remove optional value objects (set to None).""" + battery = Battery(nominal_capacity=WattHours(10000.0)) + energy_source = EnergySource( + name="Solar with Battery", + type=EnergySourceType.SOLAR, + nominal_power_max=Watts(5000.0), + ) + energy_source.connect_to_storage(battery) + repository.add(energy_source) + + # Disconnect battery + energy_source.disconnect_from_storage() + repository.update(energy_source) + + # Verify battery is removed + retrieved = repository.get_by_id(energy_source.id) + assert retrieved is not None + assert retrieved.storage is None + + def test_remove_energy_source(self, repository: SqlAlchemyEnergySourceRepository): + """Test removing an energy source.""" + energy_source = EnergySource( + name="To Be Removed", + type=EnergySourceType.SOLAR, + nominal_power_max=Watts(2000.0), + ) + repository.add(energy_source) + + # Verify it exists + assert repository.get_by_id(energy_source.id) is not None + + # Remove + repository.remove(energy_source.id) + + # Verify it's gone + assert repository.get_by_id(energy_source.id) is None + + def test_get_by_id_nonexistent(self, repository: SqlAlchemyEnergySourceRepository): + """Test getting a nonexistent energy source returns None.""" + fake_id = EntityId(uuid.uuid4()) + result = repository.get_by_id(fake_id) + assert result is None + + def test_persistence_of_watts_value_objects(self, repository: SqlAlchemyEnergySourceRepository): + """Test that Watts value objects are correctly persisted as floats and reconstructed.""" + energy_source = EnergySource( + name="Watts Test", + type=EnergySourceType.SOLAR, + nominal_power_max=Watts(7500.5), + ) + energy_source.external_source = Watts(1250.75) + + repository.add(energy_source) + + # Retrieve and verify Watts types are restored + retrieved = repository.get_by_id(energy_source.id) + assert retrieved is not None + assert isinstance(retrieved.nominal_power_max, type(Watts(0.0))) + assert float(retrieved.nominal_power_max) == 7500.5 + assert isinstance(retrieved.external_source, type(Watts(0.0))) + assert float(retrieved.external_source) == 1250.75 + + +class TestSqlAlchemyEnergyMonitorRepository: + """Integration tests for SqlAlchemyEnergyMonitorRepository. + + These tests verify CRUD operations and config serialization/deserialization. + """ + + @pytest.fixture + def repository(self, sqlalchemy_repo: BaseSQLAlchemyRepository) -> SqlAlchemyEnergyMonitorRepository: + """Create an EnergyMonitor repository instance.""" + return SqlAlchemyEnergyMonitorRepository(db=sqlalchemy_repo) + + def test_add_and_get_energy_monitor(self, repository: SqlAlchemyEnergyMonitorRepository): + """Test adding and retrieving an energy monitor.""" + config = EnergyMonitorDummySolarConfig() + energy_monitor = EnergyMonitor( + name="Test Monitor", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=config, + ) + original_id = energy_monitor.id + + repository.add(energy_monitor) + + # Retrieve and verify + retrieved = repository.get_by_id(original_id) + assert retrieved is not None + assert retrieved.id == original_id + assert retrieved.name == "Test Monitor" + assert retrieved.adapter_type == EnergyMonitorAdapter.DUMMY_SOLAR + assert retrieved.config is not None + assert isinstance(retrieved.config, EnergyMonitorDummySolarConfig) + + def test_add_energy_monitor_without_config(self, repository: SqlAlchemyEnergyMonitorRepository): + """Test adding energy monitor without config (None).""" + energy_monitor = EnergyMonitor( + name="No Config Monitor", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=None, + ) + + repository.add(energy_monitor) + + # Retrieve and verify + retrieved = repository.get_by_id(energy_monitor.id) + assert retrieved is not None + assert retrieved.config is None + + def test_get_all(self, repository: SqlAlchemyEnergyMonitorRepository): + """Test retrieving all energy monitors.""" + config1 = EnergyMonitorDummySolarConfig() + config2 = EnergyMonitorDummySolarConfig() + + monitor1 = EnergyMonitor(name="Monitor 1", adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, config=config1) + monitor2 = EnergyMonitor(name="Monitor 2", adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, config=config2) + + repository.add(monitor1) + repository.add(monitor2) + + all_monitors = repository.get_all() + assert len(all_monitors) == 2 + + names = {monitor.name for monitor in all_monitors} + assert "Monitor 1" in names + assert "Monitor 2" in names + + def test_update_energy_monitor(self, repository: SqlAlchemyEnergyMonitorRepository): + """Test updating an energy monitor.""" + config = EnergyMonitorDummySolarConfig() + energy_monitor = EnergyMonitor( + name="Original Monitor", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=config, + ) + repository.add(energy_monitor) + + # Update properties + energy_monitor.name = "Updated Monitor" + repository.update(energy_monitor) + + # Retrieve and verify + retrieved = repository.get_by_id(energy_monitor.id) + assert retrieved is not None + assert retrieved.name == "Updated Monitor" + + def test_remove_energy_monitor(self, repository: SqlAlchemyEnergyMonitorRepository): + """Test removing an energy monitor.""" + config = EnergyMonitorDummySolarConfig() + energy_monitor = EnergyMonitor( + name="To Be Removed", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=config, + ) + repository.add(energy_monitor) + + # Verify it exists + assert repository.get_by_id(energy_monitor.id) is not None + + # Remove + repository.remove(energy_monitor.id) + + # Verify it's gone + assert repository.get_by_id(energy_monitor.id) is None + + def test_get_by_external_service_id(self, repository: SqlAlchemyEnergyMonitorRepository): + """Test retrieving energy monitors by external service ID.""" + external_service_id = EntityId(uuid.uuid4()) + other_service_id = EntityId(uuid.uuid4()) + + config = EnergyMonitorDummySolarConfig() + + # Create monitors with same external service ID + monitor1 = EnergyMonitor( + name="Monitor 1", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=config, + external_service_id=external_service_id, + ) + monitor2 = EnergyMonitor( + name="Monitor 2", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=config, + external_service_id=external_service_id, + ) + monitor3 = EnergyMonitor( + name="Monitor 3", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=config, + external_service_id=other_service_id, + ) + + repository.add(monitor1) + repository.add(monitor2) + repository.add(monitor3) + + # Get by external service ID + monitors = repository.get_by_external_service_id(external_service_id) + assert len(monitors) == 2 + + names = {monitor.name for monitor in monitors} + assert "Monitor 1" in names + assert "Monitor 2" in names + assert "Monitor 3" not in names + + def test_config_serialization_round_trip(self, repository: SqlAlchemyEnergyMonitorRepository): + """Test that config objects survive serialization round trip.""" + config = EnergyMonitorDummySolarConfig() + energy_monitor = EnergyMonitor( + name="Config Test", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=config, + ) + + repository.add(energy_monitor) + + # Retrieve and verify config type is preserved + retrieved = repository.get_by_id(energy_monitor.id) + assert retrieved is not None + assert retrieved.config is not None + assert type(retrieved.config) == type(config) + assert isinstance(retrieved.config, EnergyMonitorDummySolarConfig) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/core/tests/integration/adapters/persistence/test_sqlalchemy_external_service_repositories.py b/core/tests/integration/adapters/persistence/test_sqlalchemy_external_service_repositories.py new file mode 100644 index 0000000..c470ed9 --- /dev/null +++ b/core/tests/integration/adapters/persistence/test_sqlalchemy_external_service_repositories.py @@ -0,0 +1,65 @@ +"""Integration tests for SQLAlchemy External Service repository.""" + +import pytest + +from edge_mining.adapters.infrastructure.external_services.repositories import SqlAlchemyExternalServiceRepository +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.shared.adapter_configs.external_services import ExternalServiceHomeAssistantConfig +from edge_mining.shared.external_services.common import ExternalServiceAdapter +from edge_mining.shared.external_services.entities import ExternalService + + +class TestSqlAlchemyExternalServiceRepository: + """Integration tests for SqlAlchemyExternalServiceRepository.""" + + @pytest.fixture + def repository(self, sqlalchemy_repo: BaseSQLAlchemyRepository) -> SqlAlchemyExternalServiceRepository: + """Create an ExternalService repository instance.""" + return SqlAlchemyExternalServiceRepository(db=sqlalchemy_repo) + + def test_add_and_get_external_service_with_enum_adapter(self, repository: SqlAlchemyExternalServiceRepository): + """Regression test: enum adapter_type must persist without sqlite binding errors.""" + service = ExternalService( + name="External Service Test", + adapter_type=ExternalServiceAdapter.HOME_ASSISTANT_API, + config=ExternalServiceHomeAssistantConfig(url="http://ha.local", token="token"), + ) + original_id = service.id + + # This call used to fail with sqlite3.ProgrammingError when adapter_type was an enum instance. + repository.add(service) + + # Entity should still expose enum type after commit. + assert service.adapter_type == ExternalServiceAdapter.HOME_ASSISTANT_API + + retrieved = repository.get_by_id(original_id) + assert retrieved is not None + assert retrieved.id == original_id + assert retrieved.name == "External Service Test" + assert retrieved.adapter_type == ExternalServiceAdapter.HOME_ASSISTANT_API + assert isinstance(retrieved.config, ExternalServiceHomeAssistantConfig) + + def test_update_external_service_with_enum_adapter(self, repository: SqlAlchemyExternalServiceRepository): + """Regression test: enum adapter_type must remain valid through update commit.""" + service = ExternalService( + name="Original External Service", + adapter_type=ExternalServiceAdapter.HOME_ASSISTANT_API, + config=ExternalServiceHomeAssistantConfig(url="http://ha.local", token="token"), + ) + repository.add(service) + + service.name = "Updated External Service" + service.adapter_type = ExternalServiceAdapter.HOME_ASSISTANT_API + repository.update(service) + + # Entity should still expose enum type after update commit. + assert service.adapter_type == ExternalServiceAdapter.HOME_ASSISTANT_API + + retrieved = repository.get_by_id(service.id) + assert retrieved is not None + assert retrieved.name == "Updated External Service" + assert retrieved.adapter_type == ExternalServiceAdapter.HOME_ASSISTANT_API + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/core/tests/integration/adapters/persistence/test_sqlalchemy_forecast_repositories.py b/core/tests/integration/adapters/persistence/test_sqlalchemy_forecast_repositories.py new file mode 100644 index 0000000..cf94ff6 --- /dev/null +++ b/core/tests/integration/adapters/persistence/test_sqlalchemy_forecast_repositories.py @@ -0,0 +1,65 @@ +"""Integration tests for SQLAlchemy Forecast repositories.""" + +import pytest + +from edge_mining.adapters.domain.forecast.repositories import SqlAlchemyForecastProviderRepository +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.domain.forecast.common import ForecastProviderAdapter +from edge_mining.domain.forecast.entities import ForecastProvider +from edge_mining.shared.adapter_configs.forecast import ForecastProviderDummySolarConfig + + +class TestSqlAlchemyForecastProviderRepository: + """Integration tests for SqlAlchemyForecastProviderRepository.""" + + @pytest.fixture + def repository(self, sqlalchemy_repo: BaseSQLAlchemyRepository) -> SqlAlchemyForecastProviderRepository: + """Create a ForecastProvider repository instance.""" + return SqlAlchemyForecastProviderRepository(db=sqlalchemy_repo) + + def test_add_and_get_forecast_provider_with_enum_adapter(self, repository: SqlAlchemyForecastProviderRepository): + """Regression test: enum adapter_type must persist without sqlite binding errors.""" + provider = ForecastProvider( + name="Forecast Test Provider", + adapter_type=ForecastProviderAdapter.DUMMY_SOLAR, + config=ForecastProviderDummySolarConfig(), + ) + original_id = provider.id + + # This call used to fail with sqlite3.ProgrammingError when adapter_type was an enum instance. + repository.add(provider) + + # Entity should still expose enum type after commit. + assert provider.adapter_type == ForecastProviderAdapter.DUMMY_SOLAR + + retrieved = repository.get_by_id(original_id) + assert retrieved is not None + assert retrieved.id == original_id + assert retrieved.name == "Forecast Test Provider" + assert retrieved.adapter_type == ForecastProviderAdapter.DUMMY_SOLAR + assert isinstance(retrieved.config, ForecastProviderDummySolarConfig) + + def test_update_forecast_provider_with_enum_adapter(self, repository: SqlAlchemyForecastProviderRepository): + """Regression test: enum adapter_type must remain valid through update commit.""" + provider = ForecastProvider( + name="Original Forecast Provider", + adapter_type=ForecastProviderAdapter.DUMMY_SOLAR, + config=ForecastProviderDummySolarConfig(), + ) + repository.add(provider) + + provider.name = "Updated Forecast Provider" + provider.adapter_type = ForecastProviderAdapter.DUMMY_SOLAR + repository.update(provider) + + # Entity should still expose enum type after update commit. + assert provider.adapter_type == ForecastProviderAdapter.DUMMY_SOLAR + + retrieved = repository.get_by_id(provider.id) + assert retrieved is not None + assert retrieved.name == "Updated Forecast Provider" + assert retrieved.adapter_type == ForecastProviderAdapter.DUMMY_SOLAR + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/core/tests/integration/adapters/persistence/test_sqlalchemy_home_load_repositories.py b/core/tests/integration/adapters/persistence/test_sqlalchemy_home_load_repositories.py new file mode 100644 index 0000000..24e0c73 --- /dev/null +++ b/core/tests/integration/adapters/persistence/test_sqlalchemy_home_load_repositories.py @@ -0,0 +1,66 @@ +"""Integration tests for SQLAlchemy Home Load repository.""" + +import pytest + +from edge_mining.adapters.domain.home_load.repositories import SqlAlchemyEnergyLoadForecastProviderRepository +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter +from edge_mining.domain.home_load.entities import EnergyLoadForecastProvider +from edge_mining.shared.adapter_configs.home_load import EnergyLoadForecastProviderDummyConfig + + +class TestSqlAlchemyEnergyLoadForecastProviderRepository: + """Integration tests for SqlAlchemyEnergyLoadForecastProviderRepository.""" + + @pytest.fixture + def repository(self, sqlalchemy_repo: BaseSQLAlchemyRepository) -> SqlAlchemyEnergyLoadForecastProviderRepository: + """Create a EnergyLoadForecastProvider repository instance.""" + return SqlAlchemyEnergyLoadForecastProviderRepository(db=sqlalchemy_repo) + + def test_add_and_get_energy_load_forecast_provider_with_enum_adapter( + self, repository: SqlAlchemyEnergyLoadForecastProviderRepository + ): + """Regression test: enum adapter_type must persist without sqlite binding errors.""" + provider = EnergyLoadForecastProvider( + name="Home Forecast Test", + adapter_type=EnergyLoadForecastProviderAdapter.DUMMY, + config=EnergyLoadForecastProviderDummyConfig(load_power_max=650.0), + ) + original_id = provider.id + + repository.add(provider) + + assert provider.adapter_type == EnergyLoadForecastProviderAdapter.DUMMY + + retrieved = repository.get_by_id(original_id) + assert retrieved is not None + assert retrieved.id == original_id + assert retrieved.name == "Home Forecast Test" + assert retrieved.adapter_type == EnergyLoadForecastProviderAdapter.DUMMY + assert isinstance(retrieved.config, EnergyLoadForecastProviderDummyConfig) + + def test_update_energy_load_forecast_provider_with_enum_adapter( + self, repository: SqlAlchemyEnergyLoadForecastProviderRepository + ): + """Regression test: enum adapter_type must remain valid through update commit.""" + provider = EnergyLoadForecastProvider( + name="Original Home Forecast", + adapter_type=EnergyLoadForecastProviderAdapter.DUMMY, + config=EnergyLoadForecastProviderDummyConfig(load_power_max=400.0), + ) + repository.add(provider) + + provider.name = "Updated Home Forecast" + provider.adapter_type = EnergyLoadForecastProviderAdapter.DUMMY + repository.update(provider) + + assert provider.adapter_type == EnergyLoadForecastProviderAdapter.DUMMY + + retrieved = repository.get_by_id(provider.id) + assert retrieved is not None + assert retrieved.name == "Updated Home Forecast" + assert retrieved.adapter_type == EnergyLoadForecastProviderAdapter.DUMMY + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/core/tests/integration/adapters/persistence/test_sqlalchemy_miner_controller_repositories.py b/core/tests/integration/adapters/persistence/test_sqlalchemy_miner_controller_repositories.py new file mode 100644 index 0000000..0c40f19 --- /dev/null +++ b/core/tests/integration/adapters/persistence/test_sqlalchemy_miner_controller_repositories.py @@ -0,0 +1,62 @@ +"""Integration tests for SQLAlchemy Miner Controller repository.""" + +import pytest + +from edge_mining.adapters.domain.miner.repositories import SqlAlchemyMinerControllerRepository +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.domain.miner.common import MinerControllerAdapter +from edge_mining.domain.miner.entities import MinerController +from edge_mining.shared.adapter_configs.miner import MinerControllerDummyConfig + + +class TestSqlAlchemyMinerControllerRepository: + """Integration tests for SqlAlchemyMinerControllerRepository.""" + + @pytest.fixture + def repository(self, sqlalchemy_repo: BaseSQLAlchemyRepository) -> SqlAlchemyMinerControllerRepository: + """Create a MinerController repository instance.""" + return SqlAlchemyMinerControllerRepository(db=sqlalchemy_repo) + + def test_add_and_get_miner_controller_with_enum_adapter(self, repository: SqlAlchemyMinerControllerRepository): + """Regression test: enum adapter_type must persist without sqlite binding errors.""" + controller = MinerController( + name="Miner Controller Test", + adapter_type=MinerControllerAdapter.DUMMY, + config=MinerControllerDummyConfig(), + ) + original_id = controller.id + + repository.add(controller) + + assert controller.adapter_type == MinerControllerAdapter.DUMMY + + retrieved = repository.get_by_id(original_id) + assert retrieved is not None + assert retrieved.id == original_id + assert retrieved.name == "Miner Controller Test" + assert retrieved.adapter_type == MinerControllerAdapter.DUMMY + assert isinstance(retrieved.config, MinerControllerDummyConfig) + + def test_update_miner_controller_with_enum_adapter(self, repository: SqlAlchemyMinerControllerRepository): + """Regression test: enum adapter_type must remain valid through update commit.""" + controller = MinerController( + name="Original Miner Controller", + adapter_type=MinerControllerAdapter.DUMMY, + config=MinerControllerDummyConfig(), + ) + repository.add(controller) + + controller.name = "Updated Miner Controller" + controller.adapter_type = MinerControllerAdapter.DUMMY + repository.update(controller) + + assert controller.adapter_type == MinerControllerAdapter.DUMMY + + retrieved = repository.get_by_id(controller.id) + assert retrieved is not None + assert retrieved.name == "Updated Miner Controller" + assert retrieved.adapter_type == MinerControllerAdapter.DUMMY + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/core/tests/integration/adapters/persistence/test_sqlalchemy_notifier_repositories.py b/core/tests/integration/adapters/persistence/test_sqlalchemy_notifier_repositories.py new file mode 100644 index 0000000..4ba25ba --- /dev/null +++ b/core/tests/integration/adapters/persistence/test_sqlalchemy_notifier_repositories.py @@ -0,0 +1,65 @@ +"""Integration tests for SQLAlchemy Notifier repository.""" + +import pytest + +from edge_mining.adapters.domain.notification.repositories import SqlAlchemyNotifierRepository +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.domain.notification.common import NotificationAdapter +from edge_mining.domain.notification.entities import Notifier +from edge_mining.shared.adapter_configs.notification import DummyNotificationConfig + + +class TestSqlAlchemyNotifierRepository: + """Integration tests for SqlAlchemyNotifierRepository.""" + + @pytest.fixture + def repository(self, sqlalchemy_repo: BaseSQLAlchemyRepository) -> SqlAlchemyNotifierRepository: + """Create a Notifier repository instance.""" + return SqlAlchemyNotifierRepository(db=sqlalchemy_repo) + + def test_add_and_get_notifier_with_enum_adapter(self, repository: SqlAlchemyNotifierRepository): + """Regression test: enum adapter_type must persist without sqlite binding errors.""" + notifier = Notifier( + name="Notifier Test", + adapter_type=NotificationAdapter.DUMMY, + config=DummyNotificationConfig(message="test"), + ) + original_id = notifier.id + + # This call used to fail with sqlite3.ProgrammingError when adapter_type was an enum instance. + repository.add(notifier) + + # Entity should still expose enum type after commit. + assert notifier.adapter_type == NotificationAdapter.DUMMY + + retrieved = repository.get_by_id(original_id) + assert retrieved is not None + assert retrieved.id == original_id + assert retrieved.name == "Notifier Test" + assert retrieved.adapter_type == NotificationAdapter.DUMMY + assert isinstance(retrieved.config, DummyNotificationConfig) + + def test_update_notifier_with_enum_adapter(self, repository: SqlAlchemyNotifierRepository): + """Regression test: enum adapter_type must remain valid through update commit.""" + notifier = Notifier( + name="Original Notifier", + adapter_type=NotificationAdapter.DUMMY, + config=DummyNotificationConfig(message="before"), + ) + repository.add(notifier) + + notifier.name = "Updated Notifier" + notifier.adapter_type = NotificationAdapter.DUMMY + repository.update(notifier) + + # Entity should still expose enum type after update commit. + assert notifier.adapter_type == NotificationAdapter.DUMMY + + retrieved = repository.get_by_id(notifier.id) + assert retrieved is not None + assert retrieved.name == "Updated Notifier" + assert retrieved.adapter_type == NotificationAdapter.DUMMY + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/core/tests/integration/adapters/persistence/test_sqlalchemy_performance_repositories.py b/core/tests/integration/adapters/persistence/test_sqlalchemy_performance_repositories.py new file mode 100644 index 0000000..54a3113 --- /dev/null +++ b/core/tests/integration/adapters/persistence/test_sqlalchemy_performance_repositories.py @@ -0,0 +1,62 @@ +"""Integration tests for SQLAlchemy Mining Performance repository.""" + +import pytest + +from edge_mining.adapters.domain.performance.repositories import SqlAlchemyMiningPerformanceTrackerRepository +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository +from edge_mining.domain.performance.common import MiningPerformanceTrackerAdapter +from edge_mining.domain.performance.entities import MiningPerformanceTracker +from edge_mining.shared.adapter_configs.performance import MiningPerformanceTrackerDummyConfig + + +class TestSqlAlchemyMiningPerformanceTrackerRepository: + """Integration tests for SqlAlchemyMiningPerformanceTrackerRepository.""" + + @pytest.fixture + def repository(self, sqlalchemy_repo: BaseSQLAlchemyRepository) -> SqlAlchemyMiningPerformanceTrackerRepository: + """Create a MiningPerformanceTracker repository instance.""" + return SqlAlchemyMiningPerformanceTrackerRepository(db=sqlalchemy_repo) + + def test_add_and_get_tracker_with_enum_adapter(self, repository: SqlAlchemyMiningPerformanceTrackerRepository): + """Regression test: enum adapter_type must persist without sqlite binding errors.""" + tracker = MiningPerformanceTracker( + name="Performance Tracker Test", + adapter_type=MiningPerformanceTrackerAdapter.DUMMY, + config=MiningPerformanceTrackerDummyConfig(message="hello"), + ) + original_id = tracker.id + + repository.add(tracker) + + assert tracker.adapter_type == MiningPerformanceTrackerAdapter.DUMMY + + retrieved = repository.get_by_id(original_id) + assert retrieved is not None + assert retrieved.id == original_id + assert retrieved.name == "Performance Tracker Test" + assert retrieved.adapter_type == MiningPerformanceTrackerAdapter.DUMMY + assert isinstance(retrieved.config, MiningPerformanceTrackerDummyConfig) + + def test_update_tracker_with_enum_adapter(self, repository: SqlAlchemyMiningPerformanceTrackerRepository): + """Regression test: enum adapter_type must remain valid through update commit.""" + tracker = MiningPerformanceTracker( + name="Original Tracker", + adapter_type=MiningPerformanceTrackerAdapter.DUMMY, + config=MiningPerformanceTrackerDummyConfig(message="before"), + ) + repository.add(tracker) + + tracker.name = "Updated Tracker" + tracker.adapter_type = MiningPerformanceTrackerAdapter.DUMMY + repository.update(tracker) + + assert tracker.adapter_type == MiningPerformanceTrackerAdapter.DUMMY + + retrieved = repository.get_by_id(tracker.id) + assert retrieved is not None + assert retrieved.name == "Updated Tracker" + assert retrieved.adapter_type == MiningPerformanceTrackerAdapter.DUMMY + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/core/tests/unit/adapters/__init__.py b/core/tests/unit/adapters/__init__.py new file mode 100644 index 0000000..7e841a2 --- /dev/null +++ b/core/tests/unit/adapters/__init__.py @@ -0,0 +1 @@ +"""Collection of unit test fot the adapters layer.""" diff --git a/core/tests/unit/adapters/domain/__init__.py b/core/tests/unit/adapters/domain/__init__.py new file mode 100644 index 0000000..187d043 --- /dev/null +++ b/core/tests/unit/adapters/domain/__init__.py @@ -0,0 +1 @@ +"""Collection of unit tests for the adapter domain layer.""" diff --git a/core/tests/unit/adapters/domain/energy/__init__.py b/core/tests/unit/adapters/domain/energy/__init__.py new file mode 100644 index 0000000..b724851 --- /dev/null +++ b/core/tests/unit/adapters/domain/energy/__init__.py @@ -0,0 +1 @@ +"""Unit tests for energy domain adapters.""" diff --git a/core/tests/unit/adapters/domain/energy/test_tables_event_listeners.py b/core/tests/unit/adapters/domain/energy/test_tables_event_listeners.py new file mode 100644 index 0000000..25816be --- /dev/null +++ b/core/tests/unit/adapters/domain/energy/test_tables_event_listeners.py @@ -0,0 +1,624 @@ +"""Unit tests for SQLAlchemy event listeners and type converters. + +These tests verify the event listeners and custom types used for value object +serialization/deserialization in the energy domain tables. +""" + +import json +from unittest.mock import MagicMock, Mock + +import pytest + +from edge_mining.adapters.domain.energy.tables import ( + EnergyMonitorConfigType, + _deserialize_energy_monitor_config, + _flatten_energy_source_composites, + _receive_energy_monitor_load, + _receive_energy_source_load, +) +from edge_mining.domain.common import EntityId, WattHours, Watts +from edge_mining.domain.energy.common import EnergyMonitorAdapter +from edge_mining.domain.energy.entities import EnergyMonitor, EnergySource +from edge_mining.domain.energy.exceptions import EnergyMonitorConfigurationError +from edge_mining.domain.energy.value_objects import Battery, Grid +from edge_mining.shared.adapter_configs.energy import EnergyMonitorDummySolarConfig + + +class TestEnergySourceLoadEventListener: + """Unit tests for _receive_energy_source_load event listener.""" + + def test_converts_float_to_watts_for_nominal_power_max(self): + """Test that float nominal_power_max is converted to Watts.""" + # Create a mock EnergySource with float values + target = EnergySource(name="Test") + target.nominal_power_max = 5000.0 # Simulate database load (float) + + # Call the event listener + _receive_energy_source_load(target, None) + + # Verify conversion to Watts + assert isinstance(target.nominal_power_max, type(Watts(0.0))) + assert float(target.nominal_power_max) == 5000.0 + + def test_converts_float_to_watts_for_external_source(self): + """Test that float external_source is converted to Watts.""" + target = EnergySource(name="Test") + target.external_source = 1500.0 # Simulate database load + + _receive_energy_source_load(target, None) + + assert isinstance(target.external_source, type(Watts(0.0))) + assert float(target.external_source) == 1500.0 + + def test_converts_dict_to_battery(self): + """Test that dict storage is converted to Battery object.""" + target = EnergySource(name="Test") + target.storage = {"nominal_capacity": 10000.0} # Simulate JSON load + + _receive_energy_source_load(target, None) + + assert isinstance(target.storage, Battery) + assert float(target.storage.nominal_capacity) == 10000.0 + + def test_converts_json_string_to_battery(self): + """Test that JSON string storage is converted to Battery object.""" + target = EnergySource(name="Test") + target.storage = json.dumps({"nominal_capacity": 15000.0}) + + _receive_energy_source_load(target, None) + + assert isinstance(target.storage, Battery) + assert float(target.storage.nominal_capacity) == 15000.0 + + def test_converts_dict_to_grid(self): + """Test that dict grid is converted to Grid object.""" + target = EnergySource(name="Test") + target.grid = {"contracted_power": 3000.0} + + _receive_energy_source_load(target, None) + + assert isinstance(target.grid, Grid) + assert float(target.grid.contracted_power) == 3000.0 + + def test_converts_json_string_to_grid(self): + """Test that JSON string grid is converted to Grid object.""" + target = EnergySource(name="Test") + target.grid = json.dumps({"contracted_power": 5000.0}) + + _receive_energy_source_load(target, None) + + assert isinstance(target.grid, Grid) + assert float(target.grid.contracted_power) == 5000.0 + + def test_handles_none_values(self): + """Test that None values are handled correctly.""" + target = EnergySource(name="Test") + target.nominal_power_max = None + target.external_source = None + target.storage = None + target.grid = None + + # Should not raise errors + _receive_energy_source_load(target, None) + + assert target.nominal_power_max is None + assert target.external_source is None + assert target.storage is None + assert target.grid is None + + def test_skips_already_converted_watts(self): + """Test that already-converted Watts objects are not re-converted.""" + target = EnergySource(name="Test") + original_watts = Watts(4000.0) + target.nominal_power_max = original_watts + + _receive_energy_source_load(target, None) + + # Should remain as Watts (not converted again) + assert target.nominal_power_max is original_watts + + +class TestEnergySourceFlattenEventListener: + """Unit tests for _flatten_energy_source_composites event listener.""" + + def test_flattens_watts_to_float(self): + """Test that Watts objects are flattened to floats.""" + target = EnergySource(name="Test") + target.nominal_power_max = Watts(6000.0) + target.external_source = Watts(2000.0) + + _flatten_energy_source_composites(None, None, target) + + assert isinstance(target.nominal_power_max, float) + assert target.nominal_power_max == 6000.0 + assert isinstance(target.external_source, float) + assert target.external_source == 2000.0 + + def test_flattens_battery_to_dict(self): + """Test that Battery is serialized to dict.""" + target = EnergySource(name="Test") + battery = Battery(nominal_capacity=WattHours(12000.0)) + target.storage = battery + + _flatten_energy_source_composites(None, None, target) + + assert isinstance(target.storage, dict) + assert target.storage == {"nominal_capacity": 12000.0} + + def test_flattens_grid_to_dict(self): + """Test that Grid is serialized to dict.""" + target = EnergySource(name="Test") + grid = Grid(contracted_power=Watts(4000.0)) + target.grid = grid + + _flatten_energy_source_composites(None, None, target) + + assert isinstance(target.grid, dict) + assert target.grid == {"contracted_power": 4000.0} + + def test_handles_none_values(self): + """Test that None values remain None.""" + target = EnergySource(name="Test") + target.nominal_power_max = None + target.external_source = None + target.storage = None + target.grid = None + + _flatten_energy_source_composites(None, None, target) + + assert target.nominal_power_max is None + assert target.external_source is None + assert target.storage is None + assert target.grid is None + + def test_skips_already_flattened_values(self): + """Test that already-flattened values are not re-flattened.""" + target = EnergySource(name="Test") + target.storage = {"nominal_capacity": 10000.0} # Already a dict + + _flatten_energy_source_composites(None, None, target) + + # Should remain as dict + assert isinstance(target.storage, dict) + + +class TestEnergyMonitorConfigDeserialization: + """Unit tests for _deserialize_energy_monitor_config.""" + + def test_deserializes_dummy_solar_config(self): + """Test deserialization of EnergyMonitorDummySolarConfig.""" + config_json = json.dumps({"max_consumption_power": 3200.0}) + result = _deserialize_energy_monitor_config(EnergyMonitorAdapter.DUMMY_SOLAR, config_json) + + assert result is not None + assert isinstance(result, EnergyMonitorDummySolarConfig) + + def test_returns_none_for_empty_json(self): + """Test that empty JSON returns None.""" + result = _deserialize_energy_monitor_config(EnergyMonitorAdapter.DUMMY_SOLAR, "") + assert result is None + + def test_raises_error_for_invalid_adapter_type(self): + """Test that invalid adapter type raises error.""" + config_json = json.dumps({}) + + # Create a mock adapter type that's not in the map + with pytest.raises(EnergyMonitorConfigurationError, match="Invalid type"): + _deserialize_energy_monitor_config("INVALID_TYPE", config_json) + + def test_raises_error_for_malformed_json(self): + """Test that malformed JSON raises error.""" + config_json = "not valid json" + + with pytest.raises(json.JSONDecodeError): + _deserialize_energy_monitor_config(EnergyMonitorAdapter.DUMMY_SOLAR, config_json) + + +class TestEnergyMonitorLoadEventListener: + """Unit tests for _receive_energy_monitor_load event listener.""" + + def test_converts_string_id_to_entity_id(self): + """Test that string id is converted to EntityId.""" + import uuid + + target = EnergyMonitor( + name="Test", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=None, + ) + test_uuid = uuid.uuid4() + target.id = str(test_uuid) # Simulate database load (string) + + _receive_energy_monitor_load(target, None) + + # EntityId is a NewType wrapping UUID, so check for UUID type + assert isinstance(target.id, uuid.UUID) + assert target.id == test_uuid + + def test_converts_string_external_service_id_to_entity_id(self): + """Test that string external_service_id is converted to EntityId.""" + import uuid + + target = EnergyMonitor( + name="Test", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=None, + ) + test_uuid = uuid.uuid4() + target.external_service_id = str(test_uuid) # Simulate database load + + _receive_energy_monitor_load(target, None) + + # EntityId is a NewType wrapping UUID, so check for UUID type + assert isinstance(target.external_service_id, uuid.UUID) + assert target.external_service_id == test_uuid + + def test_converts_string_adapter_type_to_enum(self): + """Test that string adapter_type is converted to enum.""" + target = EnergyMonitor( + name="Test", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=None, + ) + target.adapter_type = "dummy_solar" # Simulate database load (string) + + _receive_energy_monitor_load(target, None) + + assert isinstance(target.adapter_type, EnergyMonitorAdapter) + assert target.adapter_type == EnergyMonitorAdapter.DUMMY_SOLAR + + def test_deserializes_config_from_json_string(self): + """Test that JSON config string is deserialized.""" + target = EnergyMonitor( + name="Test", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=None, + ) + target.config = json.dumps({"max_consumption_power": 3200.0}) # Simulate database load + + _receive_energy_monitor_load(target, None) + + assert target.config is not None + assert isinstance(target.config, EnergyMonitorDummySolarConfig) + + def test_handles_none_values(self): + """Test that None values are handled correctly.""" + target = EnergyMonitor( + name="Test", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=None, + ) + target.external_service_id = None + + _receive_energy_monitor_load(target, None) + + assert target.config is None + assert target.external_service_id is None + + def test_handles_invalid_adapter_type_string(self): + """Test behavior with invalid adapter type string.""" + target = EnergyMonitor( + name="Test", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=None, + ) + target.adapter_type = "INVALID_TYPE" + + # Should not raise during conversion, but will fail later in config deserialization + _receive_energy_monitor_load(target, None) + + # adapter_type should remain as string since conversion failed + assert target.adapter_type == "INVALID_TYPE" + + def test_skips_already_converted_entity_id(self): + """Test that already-converted EntityId objects are not re-converted.""" + import uuid + + target = EnergyMonitor( + name="Test", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=None, + ) + original_id = EntityId(uuid.uuid4()) + target.id = original_id + + _receive_energy_monitor_load(target, None) + + # Should remain as EntityId (not converted again) + assert target.id is original_id + + def test_skips_already_converted_adapter_type_enum(self): + """Test that already-converted enum objects are not re-converted.""" + target = EnergyMonitor( + name="Test", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=None, + ) + + _receive_energy_monitor_load(target, None) + + # Should remain as enum + assert isinstance(target.adapter_type, EnergyMonitorAdapter) + assert target.adapter_type == EnergyMonitorAdapter.DUMMY_SOLAR + + +class TestEnergyMonitorConfigType: + """Unit tests for EnergyMonitorConfigType SQLAlchemy custom type.""" + + def test_config_type_inheritance(self): + """Test that EnergyMonitorConfigType inherits from ConfigurationType.""" + config_type = EnergyMonitorConfigType() + # Verify it's properly instantiated + assert config_type is not None + + +class TestEnergyMonitorFlattenEventListener: + """Unit tests for _flatten_energy_monitor_composites event listener.""" + + def test_flattens_adapter_type_enum_to_string(self): + """Test that EnergyMonitorAdapter enum is converted to string.""" + from edge_mining.adapters.domain.energy.tables import _flatten_energy_monitor_composites + + target = EnergyMonitor( + name="Test", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=None, + ) + + _flatten_energy_monitor_composites(None, None, target) + + assert isinstance(target.adapter_type, str) + assert target.adapter_type == "dummy_solar" + + def test_handles_none_adapter_type(self): + """Test that None adapter_type is handled correctly.""" + from edge_mining.adapters.domain.energy.tables import _flatten_energy_monitor_composites + + target = EnergyMonitor( + name="Test", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=None, + ) + target.adapter_type = None + + _flatten_energy_monitor_composites(None, None, target) + + assert target.adapter_type is None + + def test_skips_already_flattened_string(self): + """Test that already-flattened string values are not re-flattened.""" + from edge_mining.adapters.domain.energy.tables import _flatten_energy_monitor_composites + + target = EnergyMonitor( + name="Test", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=None, + ) + target.adapter_type = "dummy_solar" # Already a string + + _flatten_energy_monitor_composites(None, None, target) + + assert target.adapter_type == "dummy_solar" + + +class TestEnergyMonitorRestoreEventListener: + """Unit tests for _restore_energy_monitor_composites event listener.""" + + def test_restores_string_id_to_entity_id(self): + """Test that string id is restored to EntityId after persistence.""" + import uuid + from edge_mining.adapters.domain.energy.tables import _restore_energy_monitor_composites + + target = EnergyMonitor( + name="Test", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=None, + ) + test_uuid = uuid.uuid4() + target.id = str(test_uuid) # Simulate post-persist string + + _restore_energy_monitor_composites(None, None, target) + + # EntityId is a NewType wrapping UUID, so check for UUID type + assert isinstance(target.id, uuid.UUID) + assert target.id == test_uuid + + def test_restores_string_external_service_id_to_entity_id(self): + """Test that string external_service_id is restored to EntityId.""" + import uuid + from edge_mining.adapters.domain.energy.tables import _restore_energy_monitor_composites + + target = EnergyMonitor( + name="Test", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=None, + ) + test_uuid = uuid.uuid4() + target.external_service_id = str(test_uuid) + + _restore_energy_monitor_composites(None, None, target) + + # EntityId is a NewType wrapping UUID, so check for UUID type + assert isinstance(target.external_service_id, uuid.UUID) + assert target.external_service_id == test_uuid + + def test_restores_string_adapter_type_to_enum(self): + """Test that string adapter_type is restored to enum.""" + from edge_mining.adapters.domain.energy.tables import _restore_energy_monitor_composites + + target = EnergyMonitor( + name="Test", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=None, + ) + target.adapter_type = "dummy_solar" # Simulate post-flatten string + + _restore_energy_monitor_composites(None, None, target) + + assert isinstance(target.adapter_type, EnergyMonitorAdapter) + assert target.adapter_type == EnergyMonitorAdapter.DUMMY_SOLAR + + def test_handles_none_values(self): + """Test that None values remain None.""" + from edge_mining.adapters.domain.energy.tables import _restore_energy_monitor_composites + + target = EnergyMonitor( + name="Test", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=None, + ) + target.external_service_id = None + target.adapter_type = None + + _restore_energy_monitor_composites(None, None, target) + + assert target.external_service_id is None + assert target.adapter_type is None + + +class TestEnergySourceRestoreEventListener: + """Unit tests for _restore_energy_source_composites event listener.""" + + def test_restores_string_id_to_entity_id(self): + """Test that string id is restored to EntityId after persistence.""" + import uuid + from edge_mining.adapters.domain.energy.tables import _restore_energy_source_composites + + target = EnergySource(name="Test") + test_uuid = uuid.uuid4() + target.id = str(test_uuid) + + _restore_energy_source_composites(None, None, target) + + # EntityId is a NewType wrapping UUID, so check for UUID type + assert isinstance(target.id, uuid.UUID) + assert target.id == test_uuid + + def test_restores_foreign_keys_to_entity_id(self): + """Test that foreign key strings are restored to EntityId.""" + import uuid + from edge_mining.adapters.domain.energy.tables import _restore_energy_source_composites + from edge_mining.domain.energy.common import EnergySourceType + + target = EnergySource(name="Test", type=EnergySourceType.SOLAR) + monitor_uuid = uuid.uuid4() + provider_uuid = uuid.uuid4() + target.energy_monitor_id = str(monitor_uuid) + target.forecast_provider_id = str(provider_uuid) + + _restore_energy_source_composites(None, None, target) + + # EntityId is a NewType wrapping UUID, so check for UUID type + assert isinstance(target.energy_monitor_id, uuid.UUID) + assert target.energy_monitor_id == monitor_uuid + assert isinstance(target.forecast_provider_id, uuid.UUID) + assert target.forecast_provider_id == provider_uuid + + def test_restores_type_string_to_enum(self): + """Test that type string is restored to EnergySourceType enum.""" + from edge_mining.adapters.domain.energy.tables import _restore_energy_source_composites + from edge_mining.domain.energy.common import EnergySourceType + + target = EnergySource(name="Test") + target.type = "solar" # Simulate post-flatten string + + _restore_energy_source_composites(None, None, target) + + assert isinstance(target.type, EnergySourceType) + assert target.type == EnergySourceType.SOLAR + + def test_restores_float_to_watts(self): + """Test that float values are restored to Watts.""" + from edge_mining.adapters.domain.energy.tables import _restore_energy_source_composites + + target = EnergySource(name="Test") + target.nominal_power_max = 5000.0 # Float after flatten + target.external_source = 1500.0 + + _restore_energy_source_composites(None, None, target) + + assert isinstance(target.nominal_power_max, type(Watts(0.0))) + assert float(target.nominal_power_max) == 5000.0 + assert isinstance(target.external_source, type(Watts(0.0))) + assert float(target.external_source) == 1500.0 + + def test_restores_dict_to_battery_and_grid(self): + """Test that dict values are restored to Battery and Grid objects.""" + from edge_mining.adapters.domain.energy.tables import _restore_energy_source_composites + + target = EnergySource(name="Test") + target.storage = {"nominal_capacity": 10000.0} # Dict after flatten + target.grid = {"contracted_power": 3000.0} + + _restore_energy_source_composites(None, None, target) + + assert isinstance(target.storage, Battery) + assert float(target.storage.nominal_capacity) == 10000.0 + assert isinstance(target.grid, Grid) + assert float(target.grid.contracted_power) == 3000.0 + + def test_handles_none_values(self): + """Test that None values remain None.""" + from edge_mining.adapters.domain.energy.tables import _restore_energy_source_composites + + target = EnergySource(name="Test") + target.nominal_power_max = None + target.external_source = None + target.storage = None + target.grid = None + target.energy_monitor_id = None + target.forecast_provider_id = None + + _restore_energy_source_composites(None, None, target) + + assert target.nominal_power_max is None + assert target.external_source is None + assert target.storage is None + assert target.grid is None + assert target.energy_monitor_id is None + assert target.forecast_provider_id is None + + +class TestValueObjectRoundTrip: + """Integration-style unit tests for value object round-trip conversions.""" + + def test_battery_round_trip(self): + """Test Battery serialization and deserialization.""" + # Create original + original_battery = Battery(nominal_capacity=WattHours(8000.0)) + + # Simulate serialization (what happens before_insert) + serialized = {"nominal_capacity": float(original_battery.nominal_capacity)} + + # Simulate deserialization (what happens on load) + deserialized_battery = Battery(nominal_capacity=WattHours(serialized["nominal_capacity"])) + + assert float(deserialized_battery.nominal_capacity) == float(original_battery.nominal_capacity) + + def test_grid_round_trip(self): + """Test Grid serialization and deserialization.""" + original_grid = Grid(contracted_power=Watts(3500.0)) + + # Serialize + serialized = {"contracted_power": float(original_grid.contracted_power)} + + # Deserialize + deserialized_grid = Grid(contracted_power=Watts(serialized["contracted_power"])) + + assert float(deserialized_grid.contracted_power) == float(original_grid.contracted_power) + + def test_watts_round_trip(self): + """Test Watts serialization and deserialization.""" + original_watts = Watts(7500.5) + + # Serialize to float + serialized = float(original_watts) + + # Deserialize back to Watts + deserialized_watts = Watts(serialized) + + assert float(deserialized_watts) == float(original_watts) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/core/tests/unit/adapters/domain/home_load/__init__.py b/core/tests/unit/adapters/domain/home_load/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/tests/unit/adapters/domain/home_load/test_home_load_api_endpoints.py b/core/tests/unit/adapters/domain/home_load/test_home_load_api_endpoints.py new file mode 100644 index 0000000..bd9a181 --- /dev/null +++ b/core/tests/unit/adapters/domain/home_load/test_home_load_api_endpoints.py @@ -0,0 +1,235 @@ +"""Unit tests for home load API endpoints: device history, training trigger, models list.""" + +import uuid +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from edge_mining.adapters.domain.home_load.fast_api.router import router +from edge_mining.adapters.infrastructure.api.setup import ( + get_config_service, + get_home_load_history_service, + get_load_forecast_training_service, +) +from edge_mining.domain.common import EntityId, Timestamp, Watts +from edge_mining.domain.home_load.aggregate_roots import HomeLoadsProfile +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter +from edge_mining.domain.home_load.entities import LoadConsumptionModel, LoadDevice +from edge_mining.domain.home_load.value_objects import HomeLoadPowerPoint + + +# --- Fixtures --- + + +@pytest.fixture +def device_id() -> EntityId: + return EntityId(uuid.uuid4()) + + +@pytest.fixture +def profile_id() -> EntityId: + return EntityId(uuid.uuid4()) + + +@pytest.fixture +def profile_with_device(profile_id, device_id) -> HomeLoadsProfile: + device = LoadDevice(id=device_id, name="Dishwasher", enabled=True) + return HomeLoadsProfile(id=profile_id, name="Test Home", devices=[device]) + + +@pytest.fixture +def mock_config_service(profile_with_device): + svc = MagicMock() + svc.get_home_loads_profile.return_value = profile_with_device + return svc + + +@pytest.fixture +def mock_history_service(): + return MagicMock() + + +@pytest.fixture +def mock_training_service(): + svc = AsyncMock() + svc.get_models = MagicMock(return_value=[]) + return svc + + +@pytest.fixture +def client(mock_config_service, mock_history_service, mock_training_service): + app = FastAPI() + app.include_router(router, prefix="/api/v1") + + app.dependency_overrides[get_config_service] = lambda: mock_config_service + app.dependency_overrides[get_home_load_history_service] = lambda: mock_history_service + app.dependency_overrides[get_load_forecast_training_service] = lambda: mock_training_service + + return TestClient(app) + + +# --- Device History Endpoint Tests --- + + +class TestGetDeviceHistory: + def test_returns_power_points(self, client, mock_history_service, profile_id, device_id): + now = datetime.now() + points = [ + HomeLoadPowerPoint(timestamp=Timestamp(now - timedelta(hours=1)), power=Watts(100.0)), + HomeLoadPowerPoint(timestamp=Timestamp(now), power=Watts(200.0)), + ] + mock_history_service.get_device_history.return_value = points + + start = (now - timedelta(hours=2)).isoformat() + end = now.isoformat() + response = client.get( + f"/api/v1/home-loads-profiles/{profile_id}/devices/{device_id}/history", + params={"start": start, "end": end}, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert data[0]["power"] == 100.0 + assert data[1]["power"] == 200.0 + + def test_returns_empty_list(self, client, mock_history_service, profile_id, device_id): + mock_history_service.get_device_history.return_value = [] + now = datetime.now() + + response = client.get( + f"/api/v1/home-loads-profiles/{profile_id}/devices/{device_id}/history", + params={"start": (now - timedelta(hours=1)).isoformat(), "end": now.isoformat()}, + ) + + assert response.status_code == 200 + assert response.json() == [] + + def test_profile_not_found(self, client, mock_config_service): + mock_config_service.get_home_loads_profile.return_value = None + unknown_profile = uuid.uuid4() + device = uuid.uuid4() + now = datetime.now() + + response = client.get( + f"/api/v1/home-loads-profiles/{unknown_profile}/devices/{device}/history", + params={"start": (now - timedelta(hours=1)).isoformat(), "end": now.isoformat()}, + ) + + assert response.status_code == 404 + + def test_device_not_found(self, client, profile_id): + unknown_device = uuid.uuid4() + now = datetime.now() + + response = client.get( + f"/api/v1/home-loads-profiles/{profile_id}/devices/{unknown_device}/history", + params={"start": (now - timedelta(hours=1)).isoformat(), "end": now.isoformat()}, + ) + + assert response.status_code == 404 + + +# --- Training Trigger Endpoint Tests --- + + +class TestTriggerTrainingAll: + def test_trigger_training_all_success(self, client, mock_training_service): + mock_training_service.train_all = AsyncMock() + + response = client.post("/api/v1/training/trigger") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "completed" + + def test_trigger_training_all_with_weeks_lookback(self, client, mock_training_service): + mock_training_service.train_all = AsyncMock() + + response = client.post("/api/v1/training/trigger", params={"weeks_lookback": 4}) + + assert response.status_code == 200 + + +class TestTriggerTrainingDevice: + def test_trigger_device_training_success(self, client, mock_training_service, profile_id, device_id): + mock_training_service.train_device = AsyncMock() + + response = client.post( + f"/api/v1/home-loads-profiles/{profile_id}/devices/{device_id}/training/trigger", + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "completed" + assert "Dishwasher" in data["detail"] + + def test_trigger_device_training_profile_not_found(self, client, mock_config_service): + mock_config_service.get_home_loads_profile.return_value = None + unknown = uuid.uuid4() + device = uuid.uuid4() + + response = client.post( + f"/api/v1/home-loads-profiles/{unknown}/devices/{device}/training/trigger", + ) + + assert response.status_code == 404 + + def test_trigger_device_training_device_not_found(self, client, profile_id): + unknown_device = uuid.uuid4() + + response = client.post( + f"/api/v1/home-loads-profiles/{profile_id}/devices/{unknown_device}/training/trigger", + ) + + assert response.status_code == 404 + + +# --- Training Models List Endpoint Tests --- + + +class TestGetTrainingModels: + def test_list_models_empty(self, client, mock_training_service): + mock_training_service.get_models.return_value = [] + + response = client.get("/api/v1/training/models") + + assert response.status_code == 200 + assert response.json() == [] + + def test_list_models_returns_data(self, client, mock_training_service, device_id): + model = LoadConsumptionModel( + device_id=device_id, + adapter_type=EnergyLoadForecastProviderAdapter.STATSMODELS, + trained_at=datetime.now(), + mae=1.5, + rmse=2.0, + samples_used=100, + is_active=True, + ) + mock_training_service.get_models.return_value = [model] + + response = client.get("/api/v1/training/models") + + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["mae"] == 1.5 + assert data[0]["is_active"] is True + assert data[0]["device_id"] == str(device_id) + + def test_list_models_filtered_by_device(self, client, mock_training_service, device_id): + mock_training_service.get_models.return_value = [] + + response = client.get("/api/v1/training/models", params={"device_id": str(device_id)}) + + assert response.status_code == 200 + mock_training_service.get_models.assert_called_once() + + def test_list_models_invalid_device_id(self, client): + response = client.get("/api/v1/training/models", params={"device_id": "not-a-uuid"}) + + assert response.status_code == 400 diff --git a/core/tests/unit/adapters/domain/performance/__init__.py b/core/tests/unit/adapters/domain/performance/__init__.py new file mode 100644 index 0000000..80d91a2 --- /dev/null +++ b/core/tests/unit/adapters/domain/performance/__init__.py @@ -0,0 +1 @@ +"""Unit tests for performance domain adapters.""" diff --git a/core/tests/unit/adapters/domain/performance/test_braiins_pool_tracker.py b/core/tests/unit/adapters/domain/performance/test_braiins_pool_tracker.py new file mode 100644 index 0000000..86b8efd --- /dev/null +++ b/core/tests/unit/adapters/domain/performance/test_braiins_pool_tracker.py @@ -0,0 +1,425 @@ +"""Unit tests for the Braiins Pool mining performance tracker adapter.""" + +from datetime import datetime, timezone +from typing import Any, Dict, List +from unittest.mock import patch + +import pytest + +from edge_mining.adapters.domain.performance.trackers.braiins_pool import ( + BraiinsPoolMiningPerformanceTracker, + BraiinsPoolMiningPerformanceTrackerFactory, +) +from edge_mining.domain.common import EntityId +from edge_mining.domain.performance.common import MiningPerformanceTrackerAdapter +from edge_mining.domain.performance.exceptions import ( + MiningPerformanceTrackerConfigurationError, + MiningPoolAuthError, + MiningPoolRateLimitedError, + MiningPoolResponseError, + MiningPoolUnreachableError, +) +from edge_mining.shared.adapter_configs.performance import ( + MiningPerformanceTrackerBraiinsPoolConfig, +) + + +@pytest.fixture +def config() -> MiningPerformanceTrackerBraiinsPoolConfig: + return MiningPerformanceTrackerBraiinsPoolConfig( + api_token="test-token", + api_base_url="https://pool.braiins.com", + request_timeout_seconds=5, + ) + + +@pytest.fixture +def tracker(config) -> BraiinsPoolMiningPerformanceTracker: + return BraiinsPoolMiningPerformanceTracker(config=config) + + +def _patched_get(tracker: BraiinsPoolMiningPerformanceTracker, mapping: Dict[str, Any]): + async def fake_get(path: str) -> Dict[str, Any]: + if path not in mapping: + raise AssertionError(f"Unexpected path requested by adapter: {path}") + value = mapping[path] + if isinstance(value, Exception): + raise value + return value + + return patch.object(tracker, "_get", side_effect=fake_get) + + +@pytest.mark.asyncio +async def test_get_current_hashrate_converts_ghs_to_ths(tracker: BraiinsPoolMiningPerformanceTracker): + payload = {"hash_rate_5m": 150_000.0, "hash_rate_unit": "Gh/s"} # 150_000 Gh/s = 150 TH/s + with _patched_get(tracker, {"/accounts/profile/json/btc": payload}): + result = await tracker.get_current_hashrate(miner_ids=[]) + + assert result is not None + assert result.unit == "TH/s" + assert result.value == pytest.approx(150.0, rel=1e-6) + + +@pytest.mark.asyncio +async def test_get_current_hashrate_defaults_unit_to_ghs(tracker: BraiinsPoolMiningPerformanceTracker): + payload = {"hash_rate_5m": 1000.0} # no unit → default to Gh/s → 1.0 TH/s + with _patched_get(tracker, {"/accounts/profile/json/btc": payload}): + result = await tracker.get_current_hashrate(miner_ids=[]) + assert result is not None + assert result.value == pytest.approx(1.0, rel=1e-6) + + +@pytest.mark.asyncio +async def test_get_current_hashrate_returns_none_on_auth_error(tracker: BraiinsPoolMiningPerformanceTracker): + with _patched_get(tracker, {"/accounts/profile/json/btc": MiningPoolAuthError("bad token")}): + result = await tracker.get_current_hashrate(miner_ids=[]) + assert result is None + + +@pytest.mark.asyncio +async def test_get_worker_stats_parses_dict_style_workers(tracker: BraiinsPoolMiningPerformanceTracker): + payload = { + "hash_rate_unit": "Gh/s", + "workers": { + "rig01": {"hash_rate_5m": 50_000.0, "last_share": 1_700_000_000, "state": "OK"}, + "rig02": {"hash_rate_5m": 25_000.0, "last_share": 1_699_999_000}, + }, + } + with _patched_get(tracker, {"/accounts/workers/json/btc": payload}): + workers = await tracker.get_worker_stats(miner_ids=[]) + + assert len(workers) == 2 + by_name = {w.worker_name: w for w in workers} + assert by_name["rig01"].hashrate is not None + assert by_name["rig01"].hashrate.value == pytest.approx(50.0, rel=1e-6) + assert by_name["rig01"].last_share_at is not None + assert by_name["rig02"].hashrate is not None + assert by_name["rig02"].hashrate.value == pytest.approx(25.0, rel=1e-6) + + +@pytest.mark.asyncio +async def test_get_worker_stats_parses_list_style_workers(tracker: BraiinsPoolMiningPerformanceTracker): + payload = { + "hash_rate_unit": "Gh/s", + "workers": [ + {"worker_name": "rig01", "hash_rate_5m": 10_000.0}, + {"worker_name": "rig02", "hash_rate_5m": 5_000.0}, + ], + } + with _patched_get(tracker, {"/accounts/workers/json/btc": payload}): + workers = await tracker.get_worker_stats(miner_ids=[]) + + assert len(workers) == 2 + assert {w.worker_name for w in workers} == {"rig01", "rig02"} + + +@pytest.mark.asyncio +async def test_get_pool_stats_merges_profile_and_workers(tracker: BraiinsPoolMiningPerformanceTracker): + profile = { + "hash_rate_unit": "Gh/s", + "hash_rate_5m": 100_000.0, + "hash_rate_24h": 90_000.0, + "current_balance": "0.00012345", + "estimated_reward": "0.00023456", + } + workers_payload = { + "hash_rate_unit": "Gh/s", + "workers": [{"worker_name": "rig01", "hash_rate_5m": 100_000.0}], + } + with _patched_get( + tracker, + { + "/accounts/profile/json/btc": profile, + "/accounts/workers/json/btc": workers_payload, + }, + ): + stats = await tracker.get_pool_stats() + + assert stats is not None + assert stats.current_hashrate is not None + assert stats.current_hashrate.value == pytest.approx(100.0, rel=1e-6) + assert stats.average_hashrate_24h is not None + assert stats.average_hashrate_24h.value == pytest.approx(90.0, rel=1e-6) + # Braiins post-FPPS does not expose a 7-day aggregate. + assert stats.average_hashrate_7d is None + assert stats.unpaid_balance == 12345 + assert stats.estimated_next_payout == 23456 + assert len(stats.workers) == 1 + assert stats.workers[0].worker_name == "rig01" + + +@pytest.mark.asyncio +async def test_get_pool_stats_returns_none_when_profile_unreachable(tracker): + with _patched_get( + tracker, + {"/accounts/profile/json/btc": MiningPoolUnreachableError("nope")}, + ): + stats = await tracker.get_pool_stats() + assert stats is None + + +@pytest.mark.asyncio +async def test_get_recent_rewards_converts_btc_string_to_sats(tracker: BraiinsPoolMiningPerformanceTracker): + payload = { + "daily_rewards": [ + {"date": "2026-01-10", "total_reward": "0.00010000"}, + {"date": "2026-01-11", "total_reward": "0.00020000"}, + {"date": "2026-01-12", "total_reward": "0.00030000"}, + ] + } + with _patched_get(tracker, {"/accounts/rewards/json/btc": payload}): + rewards = await tracker.get_recent_rewards(miner_id=EntityId("any"), limit=2) + + assert len(rewards) == 2 + # Sorted descending, so 30000 sat then 20000 sat + assert rewards[0].amount == 30_000 + assert rewards[1].amount == 20_000 + + +@pytest.mark.asyncio +async def test_get_recent_rewards_returns_empty_on_auth_error(tracker): + with _patched_get(tracker, {"/accounts/rewards/json/btc": MiningPoolAuthError("nope")}): + rewards = await tracker.get_recent_rewards() + assert rewards == [] + + +@pytest.mark.asyncio +async def test_get_payout_schedule_is_daily(tracker: BraiinsPoolMiningPerformanceTracker): + # Post-FPPS Braiins pays daily and no longer exposes a configurable threshold. + schedule = await tracker.get_payout_schedule() + assert schedule is not None + assert schedule.frequency.value == "daily" + assert schedule.threshold is None + assert schedule.next_payout_at is None + + +# --- HTTP-level error mapping -------------------------------------------------- + + +class _FakeResponse: + def __init__( + self, + status: int, + payload: Any = None, + raise_json: Exception = None, + headers: dict = None, + ): + self.status = status + self._payload = payload + self._raise_json = raise_json + self.headers = headers or {} + + async def __aenter__(self) -> "_FakeResponse": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + async def json(self, content_type=None): + if self._raise_json is not None: + raise self._raise_json + return self._payload + + +class _FakeSession: + def __init__(self, response: _FakeResponse = None, on_get: Exception = None): + self._response = response + self._on_get = on_get + self.seen_headers: List[dict] = [] + + async def __aenter__(self) -> "_FakeSession": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + def get(self, url: str, headers: dict = None): + if headers is not None: + self.seen_headers.append(dict(headers)) + if self._on_get is not None: + raise self._on_get + return self._response + + +@pytest.mark.asyncio +async def test_http_401_raises_auth_error(tracker: BraiinsPoolMiningPerformanceTracker): + fake_response = _FakeResponse(status=401, payload={"error": "unauthorized"}) + + def _session_factory(*_args, **_kwargs): + return _FakeSession(response=fake_response) + + with patch("aiohttp.ClientSession", _session_factory): + with pytest.raises(MiningPoolAuthError): + await tracker._get("/accounts/profile/json/btc") + + +@pytest.mark.asyncio +async def test_http_5xx_raises_unreachable(tracker: BraiinsPoolMiningPerformanceTracker): + fake_response = _FakeResponse(status=503, payload={}) + + def _session_factory(*_args, **_kwargs): + return _FakeSession(response=fake_response) + + with patch("aiohttp.ClientSession", _session_factory): + with pytest.raises(MiningPoolUnreachableError): + await tracker._get("/accounts/profile/json/btc") + + +@pytest.mark.asyncio +async def test_http_timeout_raises_unreachable(tracker: BraiinsPoolMiningPerformanceTracker): + import asyncio + + def _session_factory(*_args, **_kwargs): + return _FakeSession(on_get=asyncio.TimeoutError()) + + with patch("aiohttp.ClientSession", _session_factory): + with pytest.raises(MiningPoolUnreachableError): + await tracker._get("/accounts/profile/json/btc") + + +@pytest.mark.asyncio +async def test_http_429_raises_rate_limited_with_retry_after( + tracker: BraiinsPoolMiningPerformanceTracker, +): + fake_response = _FakeResponse(status=429, payload={}, headers={"Retry-After": "15"}) + + def _session_factory(*_args, **_kwargs): + return _FakeSession(response=fake_response) + + with patch("aiohttp.ClientSession", _session_factory): + with pytest.raises(MiningPoolRateLimitedError) as exc_info: + await tracker._get("/accounts/profile/json/btc") + + assert exc_info.value.retry_after == pytest.approx(15.0) + + +@pytest.mark.asyncio +async def test_http_429_takes_priority_over_auth_even_when_authenticated( + tracker: BraiinsPoolMiningPerformanceTracker, +): + # An authenticated endpoint can still be throttled; 429 must win over 401/403 mapping. + fake_response = _FakeResponse(status=429, payload={}, headers={}) + + def _session_factory(*_args, **_kwargs): + return _FakeSession(response=fake_response) + + with patch("aiohttp.ClientSession", _session_factory): + with pytest.raises(MiningPoolRateLimitedError): + await tracker._get("/accounts/profile/json/btc") + + +@pytest.mark.asyncio +async def test_http_unwraps_btc_envelope(tracker: BraiinsPoolMiningPerformanceTracker): + fake_response = _FakeResponse(status=200, payload={"btc": {"hash_rate_5m": 42.0}}) + + def _session_factory(*_args, **_kwargs): + return _FakeSession(response=fake_response) + + with patch("aiohttp.ClientSession", _session_factory): + result = await tracker._get("/accounts/profile/json/btc") + assert result == {"hash_rate_5m": 42.0} + + +@pytest.mark.asyncio +async def test_http_sends_auth_header(tracker: BraiinsPoolMiningPerformanceTracker): + fake_response = _FakeResponse(status=200, payload={"btc": {}}) + sessions: List[_FakeSession] = [] + + def _session_factory(*_args, **_kwargs): + session = _FakeSession(response=fake_response) + sessions.append(session) + return session + + with patch("aiohttp.ClientSession", _session_factory): + await tracker._get("/accounts/profile/json/btc") + + assert sessions, "Session was not created" + assert sessions[0].seen_headers, "No headers were sent" + assert sessions[0].seen_headers[0].get("Pool-Auth-Token") == "test-token" + + +# --- Factory ------------------------------------------------------------------ + + +def test_factory_rejects_wrong_config_type(): + factory = BraiinsPoolMiningPerformanceTrackerFactory() + with pytest.raises(MiningPerformanceTrackerConfigurationError): + factory.create(config=None, logger=None, external_service=None) + + +def test_factory_rejects_empty_token(): + factory = BraiinsPoolMiningPerformanceTrackerFactory() + bad_config = MiningPerformanceTrackerBraiinsPoolConfig(api_token=" ") + with pytest.raises(MiningPerformanceTrackerConfigurationError): + factory.create(config=bad_config, logger=None, external_service=None) + + +def test_factory_returns_braiins_adapter(config): + factory = BraiinsPoolMiningPerformanceTrackerFactory() + adapter = factory.create(config=config, logger=None, external_service=None) + assert isinstance(adapter, BraiinsPoolMiningPerformanceTracker) + + +def test_config_is_valid_for_braiins_adapter_type(config): + assert config.is_valid(MiningPerformanceTrackerAdapter.BRAIINS_POOL) is True + assert config.is_valid(MiningPerformanceTrackerAdapter.OCEAN) is False + + +def test_config_invalid_when_token_empty(): + bad = MiningPerformanceTrackerBraiinsPoolConfig(api_token="") + assert bad.is_valid(MiningPerformanceTrackerAdapter.BRAIINS_POOL) is False + + +def test_config_round_trip(): + original = MiningPerformanceTrackerBraiinsPoolConfig( + api_token="abc", + api_base_url="https://example.test", + request_timeout_seconds=7, + ) + restored = MiningPerformanceTrackerBraiinsPoolConfig.from_dict(original.to_dict()) + assert restored == original + + +# --- Parsing helpers ---------------------------------------------------------- + + +def test_btc_string_to_sats_handles_floats_and_strings(): + from edge_mining.adapters.domain.performance.trackers.braiins_pool import _btc_string_to_sats + + assert _btc_string_to_sats("0.00000001") == 1 + assert _btc_string_to_sats(0.5) == 50_000_000 + assert _btc_string_to_sats(None) is None + assert _btc_string_to_sats("not-a-number") is None + + +def test_parse_timestamp_handles_unix_and_iso(): + from edge_mining.adapters.domain.performance.trackers.braiins_pool import _parse_timestamp + + assert _parse_timestamp(1_700_000_000) == datetime(2023, 11, 14, 22, 13, 20, tzinfo=timezone.utc) + assert _parse_timestamp("2023-11-14T22:13:20+00:00") == datetime( + 2023, 11, 14, 22, 13, 20, tzinfo=timezone.utc + ) + assert _parse_timestamp(None) is None + + +def test_hashrate_unit_mapping_handles_all_units(): + from edge_mining.adapters.domain.performance.trackers.braiins_pool import _hashrate_from_value + + assert _hashrate_from_value(1, "H/s").value == pytest.approx(1e-12) + assert _hashrate_from_value(1, "Gh/s").value == pytest.approx(1e-3) + assert _hashrate_from_value(1, "TH/s").value == pytest.approx(1.0) + assert _hashrate_from_value(1, "Ph/s").value == pytest.approx(1e3) + assert _hashrate_from_value(None, "Gh/s") is None + + +@pytest.mark.asyncio +async def test_response_body_not_json_raises_response_error(tracker: BraiinsPoolMiningPerformanceTracker): + fake_response = _FakeResponse(status=200, raise_json=ValueError("not json")) + + def _session_factory(*_args, **_kwargs): + return _FakeSession(response=fake_response) + + with patch("aiohttp.ClientSession", _session_factory): + with pytest.raises(MiningPoolResponseError): + await tracker._get("/accounts/profile/json/btc") diff --git a/core/tests/unit/adapters/domain/performance/test_ocean_tracker.py b/core/tests/unit/adapters/domain/performance/test_ocean_tracker.py new file mode 100644 index 0000000..121045d --- /dev/null +++ b/core/tests/unit/adapters/domain/performance/test_ocean_tracker.py @@ -0,0 +1,367 @@ +"""Unit tests for the Ocean.xyz mining performance tracker adapter.""" + +from datetime import datetime, timezone +from typing import Any, Dict, List +from unittest.mock import patch + +import pytest + +from edge_mining.adapters.domain.performance.trackers.ocean import ( + OceanMiningPerformanceTracker, + OceanMiningPerformanceTrackerFactory, +) +from edge_mining.domain.common import EntityId +from edge_mining.domain.performance.exceptions import ( + MiningPerformanceTrackerConfigurationError, + MiningPoolRateLimitedError, + MiningPoolResponseError, + MiningPoolUnreachableError, +) +from edge_mining.shared.adapter_configs.performance import ( + MiningPerformanceTrackerOceanConfig, +) + + +@pytest.fixture +def config() -> MiningPerformanceTrackerOceanConfig: + return MiningPerformanceTrackerOceanConfig( + bitcoin_address="bc1qexampleexampleexampleexampleexampleexample", + api_base_url="https://api.ocean.xyz", + request_timeout_seconds=5, + ) + + +@pytest.fixture +def tracker(config) -> OceanMiningPerformanceTracker: + return OceanMiningPerformanceTracker(config=config) + + +def _patched_get(tracker: OceanMiningPerformanceTracker, mapping: Dict[str, Any]): + """Patch tracker._get to return canned payloads keyed by path.""" + + async def fake_get(path: str) -> Dict[str, Any]: + if path not in mapping: + raise AssertionError(f"Unexpected path requested by adapter: {path}") + value = mapping[path] + if isinstance(value, Exception): + raise value + return value + + return patch.object(tracker, "_get", side_effect=fake_get) + + +@pytest.mark.asyncio +async def test_get_current_hashrate_returns_ths_from_hs(tracker: OceanMiningPerformanceTracker): + payload = {"hashrate_300s": 1.5e14} # 150 TH/s in H/s + with _patched_get( + tracker, + {f"/v1/user_hashrate/{tracker._config.bitcoin_address}": payload}, + ): + result = await tracker.get_current_hashrate(miner_ids=[]) + + assert result is not None + assert result.unit == "TH/s" + assert result.value == pytest.approx(150.0, rel=1e-6) + + +@pytest.mark.asyncio +async def test_get_current_hashrate_returns_none_on_unreachable(tracker: OceanMiningPerformanceTracker): + with _patched_get( + tracker, + {f"/v1/user_hashrate/{tracker._config.bitcoin_address}": MiningPoolUnreachableError("boom")}, + ): + result = await tracker.get_current_hashrate(miner_ids=[]) + assert result is None + + +@pytest.mark.asyncio +async def test_get_worker_stats_parses_workers(tracker: OceanMiningPerformanceTracker): + payload = { + "workers": [ + { + "name": "rig01", + "hashrate_300s": 5e13, + "last_share": 1_700_000_000, + "valid_shares": 42, + "stale_shares": 1, + "rejected_shares": 0, + }, + {"name": "rig02", "hashrate": "2.5e13"}, + ] + } + with _patched_get( + tracker, + {f"/v1/user_hashrate_full/{tracker._config.bitcoin_address}": payload}, + ): + workers = await tracker.get_worker_stats(miner_ids=[]) + + assert len(workers) == 2 + assert workers[0].worker_name == "rig01" + assert workers[0].hashrate is not None + assert workers[0].hashrate.value == pytest.approx(50.0, rel=1e-6) + assert workers[0].valid_shares == 42 + assert workers[0].last_share_at is not None + assert workers[0].last_share_at.tzinfo is not None + assert workers[1].worker_name == "rig02" + assert workers[1].hashrate is not None + assert workers[1].hashrate.value == pytest.approx(25.0, rel=1e-6) + + +@pytest.mark.asyncio +async def test_get_pool_stats_combines_summary_and_workers(tracker: OceanMiningPerformanceTracker): + summary = { + "hashrate_300s": 1e14, + "hashrate_24h": 9e13, + "hashrate_7d": 8e13, + "unpaid_balance_sat": 12345, + } + full = {"workers": [{"name": "rig01", "hashrate_300s": 1e14}]} + addr = tracker._config.bitcoin_address + with _patched_get( + tracker, + { + f"/v1/user_hashrate/{addr}": summary, + f"/v1/user_hashrate_full/{addr}": full, + }, + ): + stats = await tracker.get_pool_stats() + + assert stats is not None + assert stats.current_hashrate is not None + assert stats.current_hashrate.value == pytest.approx(100.0, rel=1e-6) + assert stats.average_hashrate_24h is not None + assert stats.average_hashrate_24h.value == pytest.approx(90.0, rel=1e-6) + assert stats.average_hashrate_7d is not None + assert stats.average_hashrate_7d.value == pytest.approx(80.0, rel=1e-6) + assert stats.unpaid_balance == 12345 + assert len(stats.workers) == 1 + assert stats.workers[0].worker_name == "rig01" + + +@pytest.mark.asyncio +async def test_get_pool_stats_returns_none_when_summary_unreachable(tracker: OceanMiningPerformanceTracker): + addr = tracker._config.bitcoin_address + with _patched_get( + tracker, + {f"/v1/user_hashrate/{addr}": MiningPoolUnreachableError("nope")}, + ): + stats = await tracker.get_pool_stats() + assert stats is None + + +@pytest.mark.asyncio +async def test_get_recent_rewards_sorted_and_limited(tracker: OceanMiningPerformanceTracker): + addr = tracker._config.bitcoin_address + payload = { + "earnings": [ + {"amount_sat": 100, "timestamp": 1_700_000_000}, + {"amount_sat": 200, "timestamp": 1_700_000_500}, + {"amount_sat": 300, "timestamp": 1_700_001_000}, + ] + } + + captured_paths: List[str] = [] + + async def fake_get(path: str) -> Dict[str, Any]: + captured_paths.append(path) + return payload + + with patch.object(tracker, "_get", side_effect=fake_get): + rewards = await tracker.get_recent_rewards(miner_id=EntityId("any"), limit=2) + + assert len(captured_paths) == 1 + assert captured_paths[0].startswith(f"/v1/earnpay/{addr}/") + assert len(rewards) == 2 + # Sorted by timestamp descending (most recent first) + assert rewards[0].amount == 300 + assert rewards[1].amount == 200 + + +@pytest.mark.asyncio +async def test_get_recent_rewards_returns_empty_on_unreachable(tracker: OceanMiningPerformanceTracker): + async def fake_get(path: str) -> Dict[str, Any]: + raise MiningPoolUnreachableError("net down") + + with patch.object(tracker, "_get", side_effect=fake_get): + rewards = await tracker.get_recent_rewards() + assert rewards == [] + + +@pytest.mark.asyncio +async def test_get_payout_schedule_is_threshold(tracker: OceanMiningPerformanceTracker): + schedule = await tracker.get_payout_schedule() + assert schedule is not None + assert schedule.frequency.value == "threshold" + + +# --- HTTP-level error mapping -------------------------------------------------- + + +class _FakeResponse: + def __init__( + self, + status: int, + payload: Any = None, + raise_json: Exception = None, + headers: Dict[str, str] = None, + ): + self.status = status + self._payload = payload + self._raise_json = raise_json + self.headers = headers or {} + + async def __aenter__(self) -> "_FakeResponse": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + async def json(self, content_type=None): + if self._raise_json is not None: + raise self._raise_json + return self._payload + + +class _FakeSession: + def __init__(self, response: _FakeResponse = None, on_get: Exception = None): + self._response = response + self._on_get = on_get + + async def __aenter__(self) -> "_FakeSession": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + def get(self, url: str): + if self._on_get is not None: + raise self._on_get + return self._response + + +@pytest.mark.asyncio +async def test_http_5xx_raises_unreachable(tracker: OceanMiningPerformanceTracker): + fake_response = _FakeResponse(status=503, payload={}) + + def _session_factory(*_args, **_kwargs): + return _FakeSession(response=fake_response) + + with patch("aiohttp.ClientSession", _session_factory): + with pytest.raises(MiningPoolUnreachableError): + await tracker._get("/v1/user_hashrate/whatever") + + +@pytest.mark.asyncio +async def test_http_error_envelope_raises_response_error(tracker: OceanMiningPerformanceTracker): + fake_response = _FakeResponse(status=200, payload={"error": "address not found"}) + + def _session_factory(*_args, **_kwargs): + return _FakeSession(response=fake_response) + + with patch("aiohttp.ClientSession", _session_factory): + with pytest.raises(MiningPoolResponseError): + await tracker._get("/v1/user_hashrate/whatever") + + +@pytest.mark.asyncio +async def test_http_timeout_raises_unreachable(tracker: OceanMiningPerformanceTracker): + import asyncio + + def _session_factory(*_args, **_kwargs): + return _FakeSession(on_get=asyncio.TimeoutError()) + + with patch("aiohttp.ClientSession", _session_factory): + with pytest.raises(MiningPoolUnreachableError): + await tracker._get("/v1/user_hashrate/whatever") + + +@pytest.mark.asyncio +async def test_http_429_raises_rate_limited_with_retry_after(tracker: OceanMiningPerformanceTracker): + fake_response = _FakeResponse(status=429, payload={}, headers={"Retry-After": "42"}) + + def _session_factory(*_args, **_kwargs): + return _FakeSession(response=fake_response) + + with patch("aiohttp.ClientSession", _session_factory): + with pytest.raises(MiningPoolRateLimitedError) as exc_info: + await tracker._get("/v1/user_hashrate/whatever") + + assert exc_info.value.retry_after == pytest.approx(42.0) + + +@pytest.mark.asyncio +async def test_http_429_without_retry_after_header(tracker: OceanMiningPerformanceTracker): + fake_response = _FakeResponse(status=429, payload={}, headers={}) + + def _session_factory(*_args, **_kwargs): + return _FakeSession(response=fake_response) + + with patch("aiohttp.ClientSession", _session_factory): + with pytest.raises(MiningPoolRateLimitedError) as exc_info: + await tracker._get("/v1/user_hashrate/whatever") + + assert exc_info.value.retry_after is None + + +# --- Factory ------------------------------------------------------------------ + + +def test_factory_rejects_wrong_config_type(): + factory = OceanMiningPerformanceTrackerFactory() + with pytest.raises(MiningPerformanceTrackerConfigurationError): + factory.create(config=None, logger=None, external_service=None) + + +def test_factory_rejects_empty_address(): + factory = OceanMiningPerformanceTrackerFactory() + bad_config = MiningPerformanceTrackerOceanConfig(bitcoin_address=" ") + with pytest.raises(MiningPerformanceTrackerConfigurationError): + factory.create(config=bad_config, logger=None, external_service=None) + + +def test_factory_returns_ocean_adapter(config): + factory = OceanMiningPerformanceTrackerFactory() + adapter = factory.create(config=config, logger=None, external_service=None) + assert isinstance(adapter, OceanMiningPerformanceTracker) + + +def test_config_is_valid_for_ocean_adapter_type(config): + from edge_mining.domain.performance.common import MiningPerformanceTrackerAdapter + + assert config.is_valid(MiningPerformanceTrackerAdapter.OCEAN) is True + assert config.is_valid(MiningPerformanceTrackerAdapter.DUMMY) is False + + +def test_config_invalid_when_address_empty(): + from edge_mining.domain.performance.common import MiningPerformanceTrackerAdapter + + bad = MiningPerformanceTrackerOceanConfig(bitcoin_address="") + assert bad.is_valid(MiningPerformanceTrackerAdapter.OCEAN) is False + + +def test_config_round_trip(): + original = MiningPerformanceTrackerOceanConfig( + bitcoin_address="bc1qabc", + api_base_url="https://example.test", + request_timeout_seconds=7, + ) + restored = MiningPerformanceTrackerOceanConfig.from_dict(original.to_dict()) + assert restored == original + + +# Sanity check: parsing helpers used internally. +def test_parse_timestamp_handles_unix_seconds(): + from edge_mining.adapters.domain.performance.trackers.ocean import _parse_timestamp + + ts = _parse_timestamp(1_700_000_000) + assert ts is not None + assert ts == datetime(2023, 11, 14, 22, 13, 20, tzinfo=timezone.utc) + + +def test_hashrate_from_hs_handles_string_value(): + from edge_mining.adapters.domain.performance.trackers.ocean import _hashrate_from_hs + + hr = _hashrate_from_hs("1.0e14") + assert hr is not None + assert hr.value == pytest.approx(100.0, rel=1e-6) + assert hr.unit == "TH/s" diff --git a/core/tests/unit/adapters/domain/performance/test_performance_cli.py b/core/tests/unit/adapters/domain/performance/test_performance_cli.py new file mode 100644 index 0000000..fa51681 --- /dev/null +++ b/core/tests/unit/adapters/domain/performance/test_performance_cli.py @@ -0,0 +1,376 @@ +"""Smoke tests for the mining performance tracker CLI helpers.""" + +from __future__ import annotations + +import uuid +from unittest.mock import AsyncMock, MagicMock + +import pytest +from click.testing import CliRunner + +from edge_mining.adapters.domain.performance.cli.commands import ( + delete_single_mining_performance_tracker, + handle_add_mining_performance_tracker, + handle_list_mining_performance_trackers, + handle_tracker_braiins_config, + handle_tracker_configuration, + handle_tracker_dummy_config, + handle_tracker_ocean_config, + print_tracker_details, + select_mining_performance_tracker_adapter, + check_single_mining_performance_tracker, + update_single_mining_performance_tracker, +) +from edge_mining.domain.common import EntityId +from edge_mining.domain.performance.common import ( + MiningPerformanceTrackerAdapter, + PayoutFrequency, + Satoshi, +) +from edge_mining.domain.performance.entities import MiningPerformanceTracker +from edge_mining.domain.performance.exceptions import MiningPoolAuthError +from edge_mining.domain.performance.value_objects import PayoutSchedule +from edge_mining.shared.adapter_configs.performance import ( + MiningPerformanceTrackerBraiinsPoolConfig, + MiningPerformanceTrackerDummyConfig, + MiningPerformanceTrackerOceanConfig, +) + + +@pytest.fixture +def logger() -> MagicMock: + """Return a mock logger port.""" + return MagicMock() + + +@pytest.fixture +def configuration_service() -> MagicMock: + """Return a mock ConfigurationServiceInterface.""" + return MagicMock() + + +@pytest.fixture +def adapter_service() -> MagicMock: + """Return a mock AdapterServiceInterface.""" + return MagicMock() + + +def _make_tracker( + adapter_type: MiningPerformanceTrackerAdapter = MiningPerformanceTrackerAdapter.DUMMY, +) -> MiningPerformanceTracker: + """Create a tracker fixture for tests.""" + if adapter_type == MiningPerformanceTrackerAdapter.OCEAN: + config = MiningPerformanceTrackerOceanConfig(bitcoin_address="bc1qtest") + elif adapter_type == MiningPerformanceTrackerAdapter.BRAIINS_POOL: + config = MiningPerformanceTrackerBraiinsPoolConfig(api_token="tok") + else: + config = MiningPerformanceTrackerDummyConfig(message="hi") + + return MiningPerformanceTracker( + id=EntityId(uuid.uuid4()), + name=f"test-{adapter_type.value}", + adapter_type=adapter_type, + config=config, + ) + + +# -- Adapter selection -------------------------------------------------------- + + +def test_select_adapter_returns_selected_enum() -> None: + """Valid numeric input selects the matching adapter.""" + runner = CliRunner() + result = runner.invoke(_wrap(select_mining_performance_tracker_adapter), input="1\n") + assert result.exit_code == 0 + # The function itself returns the enum; CliRunner captures only stdout. + # We verify side-effects instead by checking the echoed options. + assert "OCEAN" in result.output or "DUMMY" in result.output + + +def test_select_adapter_rejects_out_of_range() -> None: + """Invalid index prints an error and returns None (exit 0).""" + runner = CliRunner() + result = runner.invoke(_wrap(select_mining_performance_tracker_adapter), input="99\n") + assert result.exit_code == 0 + assert "Invalid index" in result.output + + +# -- Configuration handlers --------------------------------------------------- + + +def test_dummy_config_handler_uses_default_message() -> None: + """The dummy config handler yields a DummyConfig entity with entered message.""" + runner = CliRunner() + result = runner.invoke(_wrap(handle_tracker_dummy_config), input="\n") + assert result.exit_code == 0 + + +def test_ocean_config_handler_builds_ocean_config() -> None: + """The Ocean config handler builds an Ocean config with the entered values.""" + runner = CliRunner() + result = runner.invoke( + _wrap(handle_tracker_ocean_config), + input="bc1qtest\n\n\n", + ) + assert result.exit_code == 0 + assert "Bitcoin payout address" in result.output + + +def test_braiins_config_handler_builds_braiins_config() -> None: + """The Braiins config handler builds a Braiins config with the entered token.""" + runner = CliRunner() + result = runner.invoke( + _wrap(handle_tracker_braiins_config), + input="token\n\n\n", + ) + assert result.exit_code == 0 + + +def test_handle_tracker_configuration_dispatches_to_dummy() -> None: + """handle_tracker_configuration returns a DummyConfig for the DUMMY adapter.""" + runner = CliRunner() + result = runner.invoke( + _wrap_with_arg(handle_tracker_configuration, MiningPerformanceTrackerAdapter.DUMMY), + input="\n", + ) + assert result.exit_code == 0 + + +# -- List / select / print ---------------------------------------------------- + + +def test_handle_list_empty(configuration_service: MagicMock, logger: MagicMock) -> None: + """Empty list path is exercised without errors.""" + configuration_service.list_mining_performance_trackers.return_value = [] + runner = CliRunner() + result = runner.invoke( + _wrap_with_args( + handle_list_mining_performance_trackers, + configuration_service, + logger, + ), + input="\n", + ) + assert result.exit_code == 0 + assert "No mining performance trackers" in result.output + + +def test_handle_list_populated(configuration_service: MagicMock, logger: MagicMock) -> None: + """Populated list prints every tracker.""" + trackers = [ + _make_tracker(MiningPerformanceTrackerAdapter.DUMMY), + _make_tracker(MiningPerformanceTrackerAdapter.OCEAN), + ] + configuration_service.list_mining_performance_trackers.return_value = trackers + runner = CliRunner() + result = runner.invoke( + _wrap_with_args( + handle_list_mining_performance_trackers, + configuration_service, + logger, + ), + input="\n", + ) + assert result.exit_code == 0 + assert "DUMMY" in result.output + assert "OCEAN" in result.output + + +def test_print_tracker_details_includes_config() -> None: + """print_tracker_details prints name, ID, adapter and config class.""" + tracker = _make_tracker(MiningPerformanceTrackerAdapter.DUMMY) + runner = CliRunner() + result = runner.invoke(_wrap_with_arg(print_tracker_details, tracker)) + assert result.exit_code == 0 + assert tracker.name in result.output + assert "MiningPerformanceTrackerDummyConfig" in result.output + + +# -- Add ---------------------------------------------------------------------- + + +def test_handle_add_invokes_configuration_service( + configuration_service: MagicMock, logger: MagicMock +) -> None: + """handle_add_mining_performance_tracker calls add on the service when config is valid.""" + added_tracker = _make_tracker(MiningPerformanceTrackerAdapter.DUMMY) + configuration_service.add_mining_performance_tracker = AsyncMock(return_value=added_tracker) + + runner = CliRunner() + # name, adapter choice 0 (DUMMY), dummy message default, then pause + result = runner.invoke( + _wrap_with_args(handle_add_mining_performance_tracker, configuration_service, logger), + input="test\n0\n\n\n", + ) + assert result.exit_code == 0 + configuration_service.add_mining_performance_tracker.assert_awaited_once() + + +# -- Update ------------------------------------------------------------------- + + +def test_update_single_keeps_configuration_when_declined( + configuration_service: MagicMock, logger: MagicMock +) -> None: + """Declining the configuration change still forwards the existing config.""" + tracker = _make_tracker(MiningPerformanceTrackerAdapter.DUMMY) + configuration_service.update_mining_performance_tracker = AsyncMock(return_value=tracker) + + runner = CliRunner() + # default name confirmed, "n" to skip config change, pause prompt accepts anything + result = runner.invoke( + _wrap_with_args( + update_single_mining_performance_tracker, + tracker, + configuration_service, + logger, + ), + input="\nn\n\n", + ) + assert result.exit_code == 0 + configuration_service.update_mining_performance_tracker.assert_awaited_once() + call_kwargs = configuration_service.update_mining_performance_tracker.await_args.kwargs + assert call_kwargs["config"] is tracker.config + + +# -- Delete ------------------------------------------------------------------- + + +def test_delete_single_cancels_on_negative_confirm( + configuration_service: MagicMock, logger: MagicMock +) -> None: + """The delete helper cancels when the user declines confirmation.""" + tracker = _make_tracker() + runner = CliRunner() + result = runner.invoke( + _wrap_with_args( + delete_single_mining_performance_tracker, + tracker, + configuration_service, + logger, + ), + input="n\n", + ) + assert result.exit_code == 0 + assert "Deletion cancelled" in result.output + configuration_service.remove_mining_performance_tracker.assert_not_called() + + +def test_delete_single_invokes_service_on_confirm( + configuration_service: MagicMock, logger: MagicMock +) -> None: + """Confirming deletion forwards the call to the configuration service.""" + tracker = _make_tracker() + configuration_service.remove_mining_performance_tracker = AsyncMock(return_value=tracker) + + runner = CliRunner() + result = runner.invoke( + _wrap_with_args( + delete_single_mining_performance_tracker, + tracker, + configuration_service, + logger, + ), + input="y\n", + ) + assert result.exit_code == 0 + configuration_service.remove_mining_performance_tracker.assert_awaited_once() + + +# -- Test tracker ------------------------------------------------------------- + + +def test_test_single_reports_reachable(adapter_service: MagicMock, logger: MagicMock) -> None: + """When the port returns a payout schedule the helper reports reachability.""" + tracker = _make_tracker() + port = MagicMock() + port.get_payout_schedule = AsyncMock( + return_value=PayoutSchedule(frequency=PayoutFrequency.THRESHOLD, threshold=Satoshi(100_000)) + ) + adapter_service.get_mining_performance_tracker = AsyncMock(return_value=port) + + runner = CliRunner() + result = runner.invoke( + _wrap_with_args( + check_single_mining_performance_tracker, + tracker, + adapter_service, + logger, + ) + ) + assert result.exit_code == 0 + assert "Tracker reachable" in result.output + + +def test_test_single_reports_auth_error(adapter_service: MagicMock, logger: MagicMock) -> None: + """Auth errors from the pool are surfaced on stderr without aborting the CLI.""" + tracker = _make_tracker() + port = MagicMock() + port.get_payout_schedule = AsyncMock(side_effect=MiningPoolAuthError("bad token")) + adapter_service.get_mining_performance_tracker = AsyncMock(return_value=port) + + runner = CliRunner(mix_stderr=False) + result = runner.invoke( + _wrap_with_args( + check_single_mining_performance_tracker, + tracker, + adapter_service, + logger, + ) + ) + assert result.exit_code == 0 + assert "Authentication failed" in result.stderr + + +def test_test_single_when_port_missing(adapter_service: MagicMock, logger: MagicMock) -> None: + """When no port can be resolved the helper reports the tracker as unavailable.""" + tracker = _make_tracker() + adapter_service.get_mining_performance_tracker = AsyncMock(return_value=None) + + runner = CliRunner() + result = runner.invoke( + _wrap_with_args( + check_single_mining_performance_tracker, + tracker, + adapter_service, + logger, + ) + ) + assert result.exit_code == 0 + assert "Tracker adapter not available" in result.output + + +# -- Helpers ------------------------------------------------------------------ + + +def _wrap(fn): + """Wrap a no-arg helper in a click.Command so CliRunner can invoke it.""" + import click + + @click.command() + def _cmd(): + fn() + + return _cmd + + +def _wrap_with_arg(fn, arg): + """Wrap a one-arg helper.""" + import click + + @click.command() + def _cmd(): + fn(arg) + + return _cmd + + +def _wrap_with_args(fn, *args): + """Wrap a multi-arg helper.""" + import click + + @click.command() + def _cmd(): + fn(*args) + + return _cmd diff --git a/core/tests/unit/adapters/domain/performance/test_performance_router.py b/core/tests/unit/adapters/domain/performance/test_performance_router.py new file mode 100644 index 0000000..a5fb280 --- /dev/null +++ b/core/tests/unit/adapters/domain/performance/test_performance_router.py @@ -0,0 +1,510 @@ +"""Endpoint tests for the mining performance tracker API router.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import List, Optional +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from edge_mining.adapters.domain.performance.fast_api.router import router +from edge_mining.adapters.infrastructure.api.setup import ( + get_adapter_service, + get_config_service, +) +from edge_mining.domain.common import EntityId +from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.domain.performance.common import ( + MiningPerformanceTrackerAdapter, + PayoutFrequency, + Satoshi, +) +from edge_mining.domain.performance.entities import MiningPerformanceTracker +from edge_mining.domain.performance.exceptions import ( + MiningPerformanceTrackerNotFoundError, + MiningPoolAuthError, + MiningPoolUnreachableError, +) +from edge_mining.domain.performance.value_objects import ( + MiningReward, + PayoutSchedule, + PoolStats, + PoolWorkerStats, +) +from edge_mining.shared.adapter_configs.performance import ( + MiningPerformanceTrackerBraiinsPoolConfig, + MiningPerformanceTrackerDummyConfig, + MiningPerformanceTrackerOceanConfig, +) + + +def _make_client( + *, + config_service: MagicMock, + adapter_service: MagicMock, +) -> TestClient: + """Build a FastAPI TestClient with the tracker router and DI overrides.""" + app = FastAPI() + app.include_router(router, prefix="/api/v1") + app.dependency_overrides[get_config_service] = lambda: config_service + app.dependency_overrides[get_adapter_service] = lambda: adapter_service + return TestClient(app) + + +@pytest.fixture +def config_service() -> MagicMock: + """Provide a mocked ConfigurationServiceInterface.""" + return MagicMock() + + +@pytest.fixture +def adapter_service() -> MagicMock: + """Provide a mocked AdapterServiceInterface.""" + return MagicMock() + + +@pytest.fixture +def client(config_service: MagicMock, adapter_service: MagicMock) -> TestClient: + """Provide a TestClient wired to the mocked services.""" + return _make_client(config_service=config_service, adapter_service=adapter_service) + + +@pytest.fixture +def dummy_tracker() -> MiningPerformanceTracker: + """Build a dummy tracker entity usable across tests.""" + return MiningPerformanceTracker( + id=EntityId(uuid.uuid4()), + name="Dummy", + adapter_type=MiningPerformanceTrackerAdapter.DUMMY, + config=MiningPerformanceTrackerDummyConfig(message="hello"), + external_service_id=None, + ) + + +# -- List --------------------------------------------------------------------- + + +def test_list_returns_all_trackers(client: TestClient, config_service: MagicMock, dummy_tracker) -> None: + """GET /mining-performance-trackers returns the list of configured trackers.""" + config_service.list_mining_performance_trackers.return_value = [dummy_tracker] + + response = client.get("/api/v1/mining-performance-trackers") + + assert response.status_code == 200 + body = response.json() + assert len(body) == 1 + assert body[0]["id"] == str(dummy_tracker.id) + assert body[0]["adapter_type"] == MiningPerformanceTrackerAdapter.DUMMY.value + + +# -- Types / config-schema / external-services -------------------------------- + + +def test_types_lists_all_adapter_types(client: TestClient) -> None: + """GET /mining-performance-trackers/types enumerates known adapters.""" + response = client.get("/api/v1/mining-performance-trackers/types") + assert response.status_code == 200 + body = response.json() + assert set(body) == {a.value for a in MiningPerformanceTrackerAdapter} + + +def test_config_schema_returns_json_schema(client: TestClient, config_service: MagicMock) -> None: + """GET .../types/{adapter}/config-schema returns the Pydantic JSON schema.""" + config_service.get_mining_performance_tracker_config_by_type.return_value = MiningPerformanceTrackerOceanConfig + + response = client.get( + f"/api/v1/mining-performance-trackers/types/{MiningPerformanceTrackerAdapter.OCEAN.value}/config-schema" + ) + + assert response.status_code == 200 + schema = response.json() + assert "properties" in schema + assert "bitcoin_address" in schema["properties"] + + +def test_config_schema_unknown_type_returns_500(client: TestClient, config_service: MagicMock) -> None: + """If no config class is registered for an adapter type, the endpoint returns 500.""" + config_service.get_mining_performance_tracker_config_by_type.return_value = None + + response = client.get( + f"/api/v1/mining-performance-trackers/types/{MiningPerformanceTrackerAdapter.DUMMY.value}/config-schema" + ) + assert response.status_code == 500 + + +def test_external_services_returns_adapter_or_none(client: TestClient, config_service: MagicMock) -> None: + """GET .../types/{adapter}/external-services returns the compatible external service (None here).""" + config_service.get_mining_performance_tracker_external_service_adapter.return_value = None + + response = client.get( + f"/api/v1/mining-performance-trackers/types/{MiningPerformanceTrackerAdapter.DUMMY.value}/external-services" + ) + assert response.status_code == 200 + assert response.json() is None + + +# -- Detail ------------------------------------------------------------------- + + +def test_get_details_returns_tracker(client: TestClient, config_service: MagicMock, dummy_tracker) -> None: + """GET /mining-performance-trackers/{id} returns tracker details.""" + config_service.get_mining_performance_tracker.return_value = dummy_tracker + + response = client.get(f"/api/v1/mining-performance-trackers/{dummy_tracker.id}") + assert response.status_code == 200 + assert response.json()["id"] == str(dummy_tracker.id) + + +def test_get_details_missing_returns_404(client: TestClient, config_service: MagicMock) -> None: + """GET /mining-performance-trackers/{id} returns 404 when the tracker is missing.""" + config_service.get_mining_performance_tracker.return_value = None + + tracker_id = uuid.uuid4() + response = client.get(f"/api/v1/mining-performance-trackers/{tracker_id}") + assert response.status_code == 404 + + +# -- Create ------------------------------------------------------------------- + + +def test_create_tracker_accepts_dummy_config( + client: TestClient, config_service: MagicMock, dummy_tracker +) -> None: + """POST /mining-performance-trackers creates a tracker and returns its schema.""" + config_service.add_mining_performance_tracker = AsyncMock(return_value=dummy_tracker) + + payload = { + "name": "dummy-1", + "adapter_type": MiningPerformanceTrackerAdapter.DUMMY.value, + "config": {"message": "hi"}, + "external_service_id": None, + } + + response = client.post("/api/v1/mining-performance-trackers", json=payload) + + assert response.status_code == 200 + config_service.add_mining_performance_tracker.assert_awaited_once() + assert response.json()["id"] == str(dummy_tracker.id) + + +def test_create_tracker_rejects_missing_config(client: TestClient) -> None: + """POST /mining-performance-trackers rejects a payload without configuration.""" + payload = { + "name": "dummy-1", + "adapter_type": MiningPerformanceTrackerAdapter.DUMMY.value, + "config": None, + "external_service_id": None, + } + response = client.post("/api/v1/mining-performance-trackers", json=payload) + assert response.status_code == 400 + + +def test_create_tracker_rejects_invalid_adapter_type(client: TestClient) -> None: + """POST /mining-performance-trackers rejects an unknown adapter type.""" + payload = { + "name": "bogus", + "adapter_type": "not-a-real-adapter", + "config": {"message": "hi"}, + } + response = client.post("/api/v1/mining-performance-trackers", json=payload) + assert response.status_code == 422 + + +# -- Update ------------------------------------------------------------------- + + +def test_update_tracker_persists_new_config( + client: TestClient, config_service: MagicMock, dummy_tracker +) -> None: + """PUT /mining-performance-trackers/{id} updates a tracker's configuration.""" + config_service.get_mining_performance_tracker.return_value = dummy_tracker + config_service.get_mining_performance_tracker_config_by_type.return_value = MiningPerformanceTrackerDummyConfig + updated = MiningPerformanceTracker( + id=dummy_tracker.id, + name="renamed", + adapter_type=MiningPerformanceTrackerAdapter.DUMMY, + config=MiningPerformanceTrackerDummyConfig(message="updated"), + ) + config_service.update_mining_performance_tracker = AsyncMock(return_value=updated) + + response = client.put( + f"/api/v1/mining-performance-trackers/{dummy_tracker.id}", + json={"name": "renamed", "config": {"message": "updated"}}, + ) + + assert response.status_code == 200 + assert response.json()["name"] == "renamed" + config_service.update_mining_performance_tracker.assert_awaited_once() + + +def test_update_missing_tracker_returns_404(client: TestClient, config_service: MagicMock) -> None: + """PUT /mining-performance-trackers/{id} returns 404 when the tracker is missing.""" + config_service.get_mining_performance_tracker.return_value = None + + response = client.put( + f"/api/v1/mining-performance-trackers/{uuid.uuid4()}", + json={"name": "renamed"}, + ) + assert response.status_code == 404 + + +# -- Remove ------------------------------------------------------------------- + + +def test_remove_tracker_returns_deleted_schema( + client: TestClient, config_service: MagicMock, dummy_tracker +) -> None: + """DELETE /mining-performance-trackers/{id} returns the removed tracker.""" + config_service.remove_mining_performance_tracker = AsyncMock(return_value=dummy_tracker) + + response = client.delete(f"/api/v1/mining-performance-trackers/{dummy_tracker.id}") + assert response.status_code == 200 + assert response.json()["id"] == str(dummy_tracker.id) + + +def test_remove_missing_tracker_returns_404(client: TestClient, config_service: MagicMock) -> None: + """DELETE returns 404 when the tracker cannot be found.""" + config_service.remove_mining_performance_tracker = AsyncMock( + side_effect=MiningPerformanceTrackerNotFoundError("missing") + ) + + response = client.delete(f"/api/v1/mining-performance-trackers/{uuid.uuid4()}") + assert response.status_code == 404 + + +# -- Test / stats / workers / rewards / payout-schedule ----------------------- + + +class _FakeTrackerPort: + """Minimal fake tracker port for adapter_service overrides.""" + + def __init__( + self, + *, + hashrate_payload: Optional[HashRate] = None, + stats: Optional[PoolStats] = None, + workers: Optional[List[PoolWorkerStats]] = None, + rewards: Optional[List[MiningReward]] = None, + payout_schedule: Optional[PayoutSchedule] = None, + raises: Optional[Exception] = None, + ) -> None: + self._hashrate = hashrate_payload + self._stats = stats + self._workers = workers or [] + self._rewards = rewards or [] + self._schedule = payout_schedule + self._raises = raises + + async def get_current_hashrate(self, miner_ids): # noqa: D401 + """Return the canned hashrate.""" + if self._raises: + raise self._raises + return self._hashrate + + async def get_pool_stats(self): # noqa: D401 + """Return the canned pool stats.""" + if self._raises: + raise self._raises + return self._stats + + async def get_worker_stats(self, miner_ids): # noqa: D401 + """Return the canned workers list.""" + if self._raises: + raise self._raises + return self._workers + + async def get_recent_rewards(self, miner_id=None, limit=10): # noqa: D401 + """Return the canned rewards list (respecting limit).""" + if self._raises: + raise self._raises + return self._rewards[:limit] + + async def get_payout_schedule(self): # noqa: D401 + """Return the canned payout schedule.""" + if self._raises: + raise self._raises + return self._schedule + + +def test_test_tracker_reports_success(client: TestClient, adapter_service: MagicMock) -> None: + """POST /{id}/test returns success when the tracker is reachable.""" + adapter_service.get_mining_performance_tracker = AsyncMock( + return_value=_FakeTrackerPort(payout_schedule=PayoutSchedule(frequency=PayoutFrequency.DAILY)) + ) + response = client.post(f"/api/v1/mining-performance-trackers/{uuid.uuid4()}/test") + assert response.status_code == 200 + assert response.json()["status"] == "success" + + +def test_test_tracker_maps_auth_error_to_401(client: TestClient, adapter_service: MagicMock) -> None: + """POST /{id}/test maps MiningPoolAuthError to HTTP 401.""" + adapter_service.get_mining_performance_tracker = AsyncMock( + return_value=_FakeTrackerPort(raises=MiningPoolAuthError("bad token")) + ) + response = client.post(f"/api/v1/mining-performance-trackers/{uuid.uuid4()}/test") + assert response.status_code == 401 + + +def test_test_tracker_maps_unreachable_to_503(client: TestClient, adapter_service: MagicMock) -> None: + """POST /{id}/test maps MiningPoolUnreachableError to HTTP 503.""" + adapter_service.get_mining_performance_tracker = AsyncMock( + return_value=_FakeTrackerPort(raises=MiningPoolUnreachableError("timeout")) + ) + response = client.post(f"/api/v1/mining-performance-trackers/{uuid.uuid4()}/test") + assert response.status_code == 503 + + +def test_test_tracker_unknown_returns_404(client: TestClient, adapter_service: MagicMock) -> None: + """POST /{id}/test returns 404 when no tracker port is available.""" + adapter_service.get_mining_performance_tracker = AsyncMock(return_value=None) + response = client.post(f"/api/v1/mining-performance-trackers/{uuid.uuid4()}/test") + assert response.status_code == 404 + + +def test_stats_endpoint_returns_pool_stats(client: TestClient, adapter_service: MagicMock) -> None: + """GET /{id}/stats returns the serialized pool statistics.""" + stats = PoolStats( + current_hashrate=HashRate(value=12.5, unit="TH/s"), + average_hashrate_24h=HashRate(value=11.0, unit="TH/s"), + unpaid_balance=Satoshi(42_000), + estimated_next_payout=Satoshi(10_000), + workers=[ + PoolWorkerStats( + worker_name="rig1", + hashrate=HashRate(value=6.0, unit="TH/s"), + valid_shares=100, + ) + ], + ) + adapter_service.get_mining_performance_tracker = AsyncMock( + return_value=_FakeTrackerPort(stats=stats) + ) + + response = client.get(f"/api/v1/mining-performance-trackers/{uuid.uuid4()}/stats") + assert response.status_code == 200 + body = response.json() + assert body["current_hashrate"]["value"] == 12.5 + assert body["unpaid_balance"] == 42_000 + assert len(body["workers"]) == 1 + assert body["workers"][0]["worker_name"] == "rig1" + + +def test_stats_endpoint_when_port_returns_none(client: TestClient, adapter_service: MagicMock) -> None: + """GET /{id}/stats maps a None pool response to HTTP 502.""" + adapter_service.get_mining_performance_tracker = AsyncMock(return_value=_FakeTrackerPort(stats=None)) + response = client.get(f"/api/v1/mining-performance-trackers/{uuid.uuid4()}/stats") + assert response.status_code == 502 + + +def test_workers_endpoint_returns_worker_stats(client: TestClient, adapter_service: MagicMock) -> None: + """GET /{id}/workers returns the list of worker statistics.""" + workers = [ + PoolWorkerStats(worker_name="w1", valid_shares=10), + PoolWorkerStats(worker_name="w2", valid_shares=20), + ] + adapter_service.get_mining_performance_tracker = AsyncMock( + return_value=_FakeTrackerPort(workers=workers) + ) + response = client.get(f"/api/v1/mining-performance-trackers/{uuid.uuid4()}/workers") + assert response.status_code == 200 + body = response.json() + assert [w["worker_name"] for w in body] == ["w1", "w2"] + + +def test_rewards_endpoint_respects_limit(client: TestClient, adapter_service: MagicMock) -> None: + """GET /{id}/rewards returns rewards honoring the limit query parameter.""" + now = datetime.now(timezone.utc) + rewards = [MiningReward(amount=Satoshi(i * 1000), timestamp=now) for i in range(1, 6)] + adapter_service.get_mining_performance_tracker = AsyncMock( + return_value=_FakeTrackerPort(rewards=rewards) + ) + + response = client.get( + f"/api/v1/mining-performance-trackers/{uuid.uuid4()}/rewards", + params={"limit": 3}, + ) + assert response.status_code == 200 + body = response.json() + assert len(body) == 3 + assert body[0]["amount"] == 1000 + + +def test_payout_schedule_endpoint_returns_schema(client: TestClient, adapter_service: MagicMock) -> None: + """GET /{id}/payout-schedule returns the payout schedule schema.""" + schedule = PayoutSchedule(frequency=PayoutFrequency.THRESHOLD, threshold=Satoshi(100_000)) + adapter_service.get_mining_performance_tracker = AsyncMock( + return_value=_FakeTrackerPort(payout_schedule=schedule) + ) + response = client.get(f"/api/v1/mining-performance-trackers/{uuid.uuid4()}/payout-schedule") + assert response.status_code == 200 + body = response.json() + assert body["frequency"] == PayoutFrequency.THRESHOLD.value + assert body["threshold"] == 100_000 + + +# -- Schema round-trip -------------------------------------------------------- + + +def test_ocean_config_schema_json_schema_surface() -> None: + """Ocean config schema exposes required/optional fields via model_json_schema().""" + from edge_mining.adapters.domain.performance.schemas import ( + OceanMiningPerformanceTrackerConfigSchema, + ) + + schema = OceanMiningPerformanceTrackerConfigSchema.model_json_schema() + assert "bitcoin_address" in schema["properties"] + assert "api_base_url" in schema["properties"] + assert "bitcoin_address" in schema["required"] + + +def test_braiins_config_schema_json_schema_surface() -> None: + """Braiins config schema exposes required/optional fields via model_json_schema().""" + from edge_mining.adapters.domain.performance.schemas import ( + BraiinsPoolMiningPerformanceTrackerConfigSchema, + ) + + schema = BraiinsPoolMiningPerformanceTrackerConfigSchema.model_json_schema() + assert "api_token" in schema["properties"] + assert "api_token" in schema["required"] + + +def test_tracker_schema_from_model_roundtrip() -> None: + """MiningPerformanceTrackerSchema round-trips through from_model/to_model.""" + from edge_mining.adapters.domain.performance.schemas import ( + MiningPerformanceTrackerSchema, + ) + + tracker = MiningPerformanceTracker( + id=EntityId(uuid.uuid4()), + name="ocean", + adapter_type=MiningPerformanceTrackerAdapter.OCEAN, + config=MiningPerformanceTrackerOceanConfig(bitcoin_address="bc1qtest"), + ) + schema = MiningPerformanceTrackerSchema.from_model(tracker) + rebuilt = schema.to_model() + assert rebuilt.id == tracker.id + assert rebuilt.adapter_type == tracker.adapter_type + assert isinstance(rebuilt.config, MiningPerformanceTrackerOceanConfig) + assert rebuilt.config.bitcoin_address == "bc1qtest" + + +def test_braiins_tracker_schema_roundtrip() -> None: + """Round-trip the Braiins tracker schema through from_model/to_model.""" + from edge_mining.adapters.domain.performance.schemas import ( + MiningPerformanceTrackerSchema, + ) + + tracker = MiningPerformanceTracker( + id=EntityId(uuid.uuid4()), + name="braiins", + adapter_type=MiningPerformanceTrackerAdapter.BRAIINS_POOL, + config=MiningPerformanceTrackerBraiinsPoolConfig(api_token="tok"), + ) + schema = MiningPerformanceTrackerSchema.from_model(tracker) + rebuilt = schema.to_model() + assert isinstance(rebuilt.config, MiningPerformanceTrackerBraiinsPoolConfig) + assert rebuilt.config.api_token == "tok" diff --git a/core/tests/unit/adapters/domain/performance/test_tracker_base.py b/core/tests/unit/adapters/domain/performance/test_tracker_base.py new file mode 100644 index 0000000..bed8d1e --- /dev/null +++ b/core/tests/unit/adapters/domain/performance/test_tracker_base.py @@ -0,0 +1,234 @@ +"""Unit tests for :class:`CachedRateLimitedTrackerBase`. + +Exercises the cache+backoff plumbing shared by mining pool adapters: +per-key TTL caching, 429 retry schedule, stale-while-error fallback and +cache invalidation helpers. +""" + +from typing import Any, Awaitable, Callable, ClassVar, Dict, List +from unittest.mock import patch + +import pytest + +from edge_mining.adapters.domain.performance.trackers._base import ( + CachedRateLimitedTrackerBase, + _BACKOFF_SCHEDULE_SECONDS, +) +from edge_mining.domain.performance.exceptions import MiningPoolRateLimitedError + + +class _Tracker(CachedRateLimitedTrackerBase): + """Concrete subclass used solely to exercise the base class.""" + + TTL_MAP: ClassVar[Dict[str, int]] = {"short": 1, "long": 3600} + DEFAULT_TTL_SECONDS: ClassVar[int] = 30 + + +@pytest.fixture +def tracker() -> _Tracker: + return _Tracker() + + +@pytest.fixture(autouse=True) +def _no_sleep(): + """Replace ``asyncio.sleep`` with an immediate no-op for all tests.""" + async def _fast_sleep(_delay: float) -> None: + return None + + with patch( + "edge_mining.adapters.domain.performance.trackers._base.asyncio.sleep", + side_effect=_fast_sleep, + ) as mock: + yield mock + + +@pytest.fixture(autouse=True) +def _deterministic_jitter(): + """Pin jitter to 1.0 so delay values are predictable.""" + with patch( + "edge_mining.adapters.domain.performance.trackers._base.random.uniform", + return_value=1.0, + ) as mock: + yield mock + + +def _counter_fetch(values: List[Any]) -> Callable[[], Awaitable[Any]]: + """Return an async fetch that yields ``values`` in order, then raises ``StopIteration``.""" + iterator = iter(values) + + async def fetch() -> Any: + try: + item = next(iterator) + except StopIteration as exc: + raise AssertionError("fetch invoked more times than expected") from exc + if isinstance(item, Exception): + raise item + return item + + return fetch + + +@pytest.mark.asyncio +async def test_cache_hit_within_ttl_does_not_refetch(tracker: _Tracker) -> None: + fetch = _counter_fetch([42]) + first = await tracker._cached_call("long", fetch) + # The fetch iterator only has one value — a second hit proves we served from cache. + second = await tracker._cached_call("long", fetch) + assert first == second == 42 + + +@pytest.mark.asyncio +async def test_cache_miss_after_ttl_refetches(tracker: _Tracker) -> None: + t = [1000.0] + + def now() -> float: + return t[0] + + with patch( + "edge_mining.adapters.domain.performance.trackers._base.time.monotonic", + side_effect=now, + ): + fetch = _counter_fetch([1, 2]) + first = await tracker._cached_call("short", fetch) + assert first == 1 + # Advance time past the 1s TTL → entry is stale, fetch runs again. + t[0] += 5.0 + second = await tracker._cached_call("short", fetch) + assert second == 2 + + +@pytest.mark.asyncio +async def test_default_ttl_applies_when_key_not_mapped(tracker: _Tracker) -> None: + assert tracker._ttl_for("unknown") == _Tracker.DEFAULT_TTL_SECONDS + + +@pytest.mark.asyncio +async def test_args_distinguish_cache_keys(tracker: _Tracker) -> None: + async def fetch_10() -> int: + return 10 + + async def fetch_20() -> int: + return 20 + + a = await tracker._cached_call("long", fetch_10, args=(10,)) + b = await tracker._cached_call("long", fetch_20, args=(20,)) + # Same logical key, different args → distinct cache entries. + assert a == 10 and b == 20 + + +@pytest.mark.asyncio +async def test_with_backoff_retries_exactly_schedule_length(tracker: _Tracker, _no_sleep) -> None: + fetch = _counter_fetch([MiningPoolRateLimitedError("429")] * len(_BACKOFF_SCHEDULE_SECONDS)) + + with pytest.raises(MiningPoolRateLimitedError): + await tracker._with_backoff(fetch) + + # One sleep per attempt: 5 attempts → 5 sleeps. + assert _no_sleep.call_count == len(_BACKOFF_SCHEDULE_SECONDS) + + +@pytest.mark.asyncio +async def test_with_backoff_applies_scheduled_delays(tracker: _Tracker, _no_sleep) -> None: + fetch = _counter_fetch([MiningPoolRateLimitedError()] * len(_BACKOFF_SCHEDULE_SECONDS)) + with pytest.raises(MiningPoolRateLimitedError): + await tracker._with_backoff(fetch) + + observed_delays = [call.args[0] for call in _no_sleep.call_args_list] + assert observed_delays == list(_BACKOFF_SCHEDULE_SECONDS) + + +@pytest.mark.asyncio +async def test_with_backoff_succeeds_on_later_attempt(tracker: _Tracker, _no_sleep) -> None: + fetch = _counter_fetch( + [MiningPoolRateLimitedError(), MiningPoolRateLimitedError(), "ok"] + ) + result = await tracker._with_backoff(fetch) + assert result == "ok" + # Two failed attempts → two sleeps before the success. + assert _no_sleep.call_count == 2 + + +def test_resolve_delay_honors_retry_after_when_larger() -> None: + exc = MiningPoolRateLimitedError("x", retry_after=60.0) + # Jitter is pinned to 1.0 by the autouse fixture. + assert _Tracker._resolve_delay(exc, base_delay=5.0) == pytest.approx(60.0) + + +def test_resolve_delay_ignores_retry_after_when_smaller() -> None: + exc = MiningPoolRateLimitedError("x", retry_after=1.0) + assert _Tracker._resolve_delay(exc, base_delay=20.0) == pytest.approx(20.0) + + +def test_resolve_delay_without_retry_after() -> None: + exc = MiningPoolRateLimitedError("x") + assert _Tracker._resolve_delay(exc, base_delay=10.0) == pytest.approx(10.0) + + +@pytest.mark.asyncio +async def test_stale_while_error_serves_cached_value_on_rate_limit( + tracker: _Tracker, +) -> None: + t = [1000.0] + + def now() -> float: + return t[0] + + with patch( + "edge_mining.adapters.domain.performance.trackers._base.time.monotonic", + side_effect=now, + ): + # First call: populate the cache with a known value. + fetch_ok = _counter_fetch(["fresh"]) + assert await tracker._cached_call("short", fetch_ok) == "fresh" + + # Advance past TTL so the next call attempts a re-fetch. + t[0] += 10.0 + + # All retries raise 429 → the stale value should be returned. + fetch_429 = _counter_fetch( + [MiningPoolRateLimitedError()] * len(_BACKOFF_SCHEDULE_SECONDS) + ) + result = await tracker._cached_call("short", fetch_429) + assert result == "fresh" + + +@pytest.mark.asyncio +async def test_rate_limit_reraises_when_no_cached_value(tracker: _Tracker) -> None: + fetch = _counter_fetch( + [MiningPoolRateLimitedError("hard fail")] * len(_BACKOFF_SCHEDULE_SECONDS) + ) + with pytest.raises(MiningPoolRateLimitedError): + await tracker._cached_call("short", fetch) + + +@pytest.mark.asyncio +async def test_non_rate_limit_error_propagates(tracker: _Tracker) -> None: + class Boom(RuntimeError): + pass + + async def fetch() -> int: + raise Boom("kaboom") + + with pytest.raises(Boom): + await tracker._cached_call("short", fetch) + + +@pytest.mark.asyncio +async def test_invalidate_cache_single_key(tracker: _Tracker) -> None: + await tracker._cached_call("long", _counter_fetch([1])) + await tracker._cached_call("short", _counter_fetch([2])) + tracker._invalidate_cache("long") + # "short" survives — a second hit still serves cached value. + assert await tracker._cached_call("short", _counter_fetch([999])) == 2 + # "long" is gone — fetch runs again. + assert await tracker._cached_call("long", _counter_fetch([42])) == 42 + + +@pytest.mark.asyncio +async def test_invalidate_cache_clears_all(tracker: _Tracker) -> None: + await tracker._cached_call("long", _counter_fetch([1])) + await tracker._cached_call("short", _counter_fetch([2])) + tracker._invalidate_cache() + # Both keys had to refetch. + assert await tracker._cached_call("long", _counter_fetch([10])) == 10 + assert await tracker._cached_call("short", _counter_fetch([20])) == 20 diff --git a/core/tests/unit/adapters/home_load/test_backtesting.py b/core/tests/unit/adapters/home_load/test_backtesting.py new file mode 100644 index 0000000..930675e --- /dev/null +++ b/core/tests/unit/adapters/home_load/test_backtesting.py @@ -0,0 +1,171 @@ +"""Unit tests for F7 — Rolling-window backtesting integration. + +Tests the ``backtest()`` static method on ``SkforecastForecastProvider``, +the new ``backtest_mae / backtest_rmse / backtest_folds`` fields on +``LoadConsumptionModel``, and the corresponding schema fields. +""" + +import pytest + +from edge_mining.adapters.domain.home_load.forecast_providers.skforecast_provider import ( + _SKFORECAST_AVAILABLE, +) +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter +from edge_mining.domain.home_load.entities import LoadConsumptionModel + +pytestmark = pytest.mark.skipif(not _SKFORECAST_AVAILABLE, reason="skforecast not installed") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_series(hours: int = 300): + """Create a pandas Series of synthetic hourly power values.""" + import pandas as pd + + values = [300.0 + (i % 24) * 10 + (i % 7) * 5 for i in range(hours)] + return pd.Series(values, name="power") + + +def _fit_forecaster(y, lags: int = 24): + """Return a fitted ForecasterRecursive on *y*.""" + from skforecast.recursive import ForecasterRecursive + from sklearn.linear_model import Ridge + + forecaster = ForecasterRecursive(estimator=Ridge(), lags=lags) + forecaster.fit(y=y) + return forecaster + + +# --------------------------------------------------------------------------- +# backtest() static method tests +# --------------------------------------------------------------------------- + +class TestSkforecastBacktest: + """Tests for SkforecastForecastProvider.backtest().""" + + def test_backtest_returns_dict_with_expected_keys(self): + from edge_mining.adapters.domain.home_load.forecast_providers.skforecast_provider import ( + SkforecastForecastProvider, + ) + + y = _make_series(300) + forecaster = _fit_forecaster(y) + result = SkforecastForecastProvider.backtest( + forecaster=forecaster, + y_series=y, + steps=24, + folds=3, + ) + assert isinstance(result, dict) + assert "backtest_mae" in result + assert "backtest_rmse" in result + assert "backtest_folds" in result + + def test_backtest_mae_is_positive(self): + from edge_mining.adapters.domain.home_load.forecast_providers.skforecast_provider import ( + SkforecastForecastProvider, + ) + + y = _make_series(300) + forecaster = _fit_forecaster(y) + result = SkforecastForecastProvider.backtest( + forecaster=forecaster, + y_series=y, + steps=24, + folds=3, + ) + assert result["backtest_mae"] is not None + assert result["backtest_mae"] >= 0 + + def test_backtest_rmse_is_positive(self): + from edge_mining.adapters.domain.home_load.forecast_providers.skforecast_provider import ( + SkforecastForecastProvider, + ) + + y = _make_series(300) + forecaster = _fit_forecaster(y) + result = SkforecastForecastProvider.backtest( + forecaster=forecaster, + y_series=y, + steps=24, + folds=3, + ) + assert result["backtest_rmse"] is not None + assert result["backtest_rmse"] >= 0 + + def test_backtest_folds_positive(self): + from edge_mining.adapters.domain.home_load.forecast_providers.skforecast_provider import ( + SkforecastForecastProvider, + ) + + y = _make_series(300) + forecaster = _fit_forecaster(y) + result = SkforecastForecastProvider.backtest( + forecaster=forecaster, + y_series=y, + steps=24, + folds=3, + ) + assert result["backtest_folds"] > 0 + + def test_backtest_too_short_series_returns_zeros(self): + from edge_mining.adapters.domain.home_load.forecast_providers.skforecast_provider import ( + SkforecastForecastProvider, + ) + + # Very short series — not enough for even 2*steps training + y = _make_series(30) + forecaster = _fit_forecaster(y, lags=6) + result = SkforecastForecastProvider.backtest( + forecaster=forecaster, + y_series=y, + steps=24, + folds=3, + ) + assert result["backtest_mae"] is None + assert result["backtest_folds"] == 0 + + +# --------------------------------------------------------------------------- +# LoadConsumptionModel backtest fields tests +# --------------------------------------------------------------------------- + +class TestLoadConsumptionModelBacktestFields: + """Tests for backtest_mae/rmse/folds fields on the entity.""" + + def test_defaults(self): + model = LoadConsumptionModel() + assert model.backtest_mae is None + assert model.backtest_rmse is None + assert model.backtest_folds == 0 + + def test_set_values(self): + model = LoadConsumptionModel(backtest_mae=12.5, backtest_rmse=15.3, backtest_folds=5) + assert model.backtest_mae == 12.5 + assert model.backtest_rmse == 15.3 + assert model.backtest_folds == 5 + + def test_schema_includes_backtest_fields(self): + from edge_mining.adapters.domain.home_load.schemas import LoadConsumptionModelSchema + + model = LoadConsumptionModel( + adapter_type=EnergyLoadForecastProviderAdapter.SKFORECAST, + backtest_mae=8.2, + backtest_rmse=10.1, + backtest_folds=4, + ) + schema = LoadConsumptionModelSchema.from_model(model) + assert schema.backtest_mae == 8.2 + assert schema.backtest_rmse == 10.1 + assert schema.backtest_folds == 4 + + def test_schema_backtest_defaults(self): + from edge_mining.adapters.domain.home_load.schemas import LoadConsumptionModelSchema + + model = LoadConsumptionModel() + schema = LoadConsumptionModelSchema.from_model(model) + assert schema.backtest_mae is None + assert schema.backtest_rmse is None + assert schema.backtest_folds == 0 diff --git a/core/tests/unit/adapters/home_load/test_naive_persistence_forecast_provider.py b/core/tests/unit/adapters/home_load/test_naive_persistence_forecast_provider.py new file mode 100644 index 0000000..84a89d4 --- /dev/null +++ b/core/tests/unit/adapters/home_load/test_naive_persistence_forecast_provider.py @@ -0,0 +1,213 @@ +"""Unit tests for NaivePersistence forecast provider.""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from edge_mining.adapters.domain.home_load.forecast_providers.naive_persistence import ( + NaivePersistenceForecastProvider, + NaivePersistenceForecastProviderFactory, +) +from edge_mining.domain.common import Timestamp, WattHours, Watts +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter +from edge_mining.domain.home_load.exceptions import EnergyLoadForecastProviderError +from edge_mining.domain.home_load.value_objects import ( + HomeLoadEnergyInterval, + HomeLoadPowerPoint, + LoadEnergyConsumption, +) +from edge_mining.shared.adapter_configs.home_load import ( + EnergyLoadForecastProviderNaivePersistenceConfig, + EnergyLoadForecastProviderDummyConfig, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_history(hours: int = 48, base_power: float = 300.0) -> LoadEnergyConsumption: + """Build a synthetic hourly history going back ``hours`` hours from now. + + Power follows a simple pattern based on hour-of-day to make assertions + deterministic: ``base_power + hour_of_day * 10``. + """ + now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0) + intervals = [] + for i in range(hours, 0, -1): + start = Timestamp(now - timedelta(hours=i)) + end = Timestamp(start + timedelta(hours=1)) + power = Watts(base_power + start.hour * 10) + intervals.append( + HomeLoadEnergyInterval( + start=start, + end=end, + power_points=[HomeLoadPowerPoint(timestamp=start, power=power)], + energy=WattHours(float(power)), + ) + ) + return LoadEnergyConsumption(timestamp=Timestamp(now), intervals=intervals) + + +# --------------------------------------------------------------------------- +# Factory tests +# --------------------------------------------------------------------------- + +class TestNaivePersistenceForecastProviderFactory: + """Tests for the factory.""" + + def test_create_with_default_config(self): + factory = NaivePersistenceForecastProviderFactory() + provider = factory.create(config=None, logger=None, external_service=None) + assert isinstance(provider, NaivePersistenceForecastProvider) + + def test_create_with_valid_config(self): + config = EnergyLoadForecastProviderNaivePersistenceConfig(hours_ahead=12, delta_days=2) + factory = NaivePersistenceForecastProviderFactory() + provider = factory.create(config=config, logger=None, external_service=None) + assert isinstance(provider, NaivePersistenceForecastProvider) + assert provider._hours_ahead == 12 + assert provider._delta_days == 2 + + def test_create_with_wrong_config_type_raises(self): + config = EnergyLoadForecastProviderDummyConfig() + factory = NaivePersistenceForecastProviderFactory() + with pytest.raises(EnergyLoadForecastProviderError): + factory.create(config=config, logger=None, external_service=None) + + +# --------------------------------------------------------------------------- +# Provider tests +# --------------------------------------------------------------------------- + +class TestNaivePersistenceForecastProvider: + """Tests for the provider.""" + + def test_adapter_type(self): + provider = NaivePersistenceForecastProvider() + assert provider.forecast_provider_type == EnergyLoadForecastProviderAdapter.NAIVE_PERSISTENCE + + def test_min_required_history_hours_default(self): + provider = NaivePersistenceForecastProvider(delta_days=1) + assert provider.min_required_history_hours == 24 + + def test_min_required_history_hours_custom(self): + provider = NaivePersistenceForecastProvider(delta_days=3) + assert provider.min_required_history_hours == 72 + + def test_returns_none_for_empty_history(self): + provider = NaivePersistenceForecastProvider(hours_ahead=3) + empty = LoadEnergyConsumption(timestamp=Timestamp(datetime.now(timezone.utc)), intervals=[]) + assert provider.get_consumption_forecast(empty) is None + + def test_returns_none_for_zero_hours(self): + provider = NaivePersistenceForecastProvider(hours_ahead=0) + history = _make_history(48) + assert provider.get_consumption_forecast(history) is None + + def test_forecast_length_matches_hours_ahead(self): + provider = NaivePersistenceForecastProvider(hours_ahead=6) + history = _make_history(48) + forecast = provider.get_consumption_forecast(history) + assert forecast is not None + assert len(forecast.intervals) == 6 + + def test_forecast_default_24h(self): + provider = NaivePersistenceForecastProvider(hours_ahead=24) + history = _make_history(48) + forecast = provider.get_consumption_forecast(history) + assert forecast is not None + assert len(forecast.intervals) == 24 + + def test_forecast_uses_yesterday_profile(self): + """Each forecast hour should match the power from the same hour yesterday.""" + provider = NaivePersistenceForecastProvider(hours_ahead=6, delta_days=1) + history = _make_history(48, base_power=300.0) + forecast = provider.get_consumption_forecast(history) + assert forecast is not None + + for interval in forecast.intervals: + expected_power = 300.0 + interval.start.hour * 10 + assert float(interval.avg_power) == pytest.approx(expected_power, abs=1.0) + + def test_forecast_delta_days_2(self): + """With delta_days=2, power should come from 2 days ago.""" + provider = NaivePersistenceForecastProvider(hours_ahead=3, delta_days=2) + history = _make_history(72, base_power=200.0) + forecast = provider.get_consumption_forecast(history) + assert forecast is not None + assert len(forecast.intervals) == 3 + + def test_forecast_intervals_are_contiguous(self): + provider = NaivePersistenceForecastProvider(hours_ahead=4) + history = _make_history(48) + forecast = provider.get_consumption_forecast(history) + assert forecast is not None + for i in range(len(forecast.intervals) - 1): + assert forecast.intervals[i].end == forecast.intervals[i + 1].start + + def test_forecast_power_non_negative(self): + provider = NaivePersistenceForecastProvider(hours_ahead=6) + history = _make_history(48, base_power=0.0) + forecast = provider.get_consumption_forecast(history) + assert forecast is not None + for interval in forecast.intervals: + assert float(interval.avg_power) >= 0.0 + + def test_fallback_to_avg_when_reference_missing(self): + """When reference day has gaps, fallback to history average.""" + now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0) + # Only 2 hours of history — not enough for a full reference day + intervals = [] + for i in [2, 1]: + start = Timestamp(now - timedelta(hours=i)) + end = Timestamp(start + timedelta(hours=1)) + power = Watts(500.0) + intervals.append( + HomeLoadEnergyInterval( + start=start, end=end, + power_points=[HomeLoadPowerPoint(timestamp=start, power=power)], + energy=WattHours(500.0), + ) + ) + sparse_history = LoadEnergyConsumption(timestamp=Timestamp(now), intervals=intervals) + + provider = NaivePersistenceForecastProvider(hours_ahead=3, delta_days=1) + forecast = provider.get_consumption_forecast(sparse_history) + assert forecast is not None + # Should still produce 3 intervals, falling back to avg_power + assert len(forecast.intervals) == 3 + + +# --------------------------------------------------------------------------- +# Config tests +# --------------------------------------------------------------------------- + +class TestNaivePersistenceConfig: + """Tests for the config dataclass.""" + + def test_defaults(self): + config = EnergyLoadForecastProviderNaivePersistenceConfig() + assert config.hours_ahead == 24 + assert config.delta_days == 1 + + def test_custom_values(self): + config = EnergyLoadForecastProviderNaivePersistenceConfig(hours_ahead=12, delta_days=3) + assert config.hours_ahead == 12 + assert config.delta_days == 3 + + def test_is_valid(self): + config = EnergyLoadForecastProviderNaivePersistenceConfig() + assert config.is_valid(EnergyLoadForecastProviderAdapter.NAIVE_PERSISTENCE) is True + assert config.is_valid(EnergyLoadForecastProviderAdapter.DUMMY) is False + + def test_to_dict_from_dict_roundtrip(self): + config = EnergyLoadForecastProviderNaivePersistenceConfig(hours_ahead=8, delta_days=2) + d = config.to_dict() + restored = EnergyLoadForecastProviderNaivePersistenceConfig.from_dict(d) + assert restored == config + + def test_frozen(self): + config = EnergyLoadForecastProviderNaivePersistenceConfig() + with pytest.raises(AttributeError): + config.hours_ahead = 10 # type: ignore[misc] diff --git a/core/tests/unit/adapters/home_load/test_optuna_tuning.py b/core/tests/unit/adapters/home_load/test_optuna_tuning.py new file mode 100644 index 0000000..ec5e032 --- /dev/null +++ b/core/tests/unit/adapters/home_load/test_optuna_tuning.py @@ -0,0 +1,180 @@ +"""Unit tests for Optuna Bayesian tuning integration (F6). + +Tests the ``tune()`` static method on ``SkforecastForecastProvider``, +the ``_build_search_space`` helper, and the ``tuning_params`` field on +``LoadConsumptionModel``. +""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from edge_mining.adapters.domain.home_load.forecast_providers.skforecast_provider import ( + _SKFORECAST_AVAILABLE, +) +from edge_mining.domain.common import Timestamp, WattHours, Watts +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter +from edge_mining.domain.home_load.entities import LoadConsumptionModel +from edge_mining.domain.home_load.value_objects import ( + HomeLoadEnergyInterval, + HomeLoadPowerPoint, + LoadEnergyConsumption, +) + +pytestmark = pytest.mark.skipif(not _SKFORECAST_AVAILABLE, reason="skforecast not installed") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_series(hours: int = 300): + """Create a pandas Series of synthetic hourly power values.""" + import pandas as pd + + values = [300.0 + (i % 24) * 10 + (i % 7) * 5 for i in range(hours)] + return pd.Series(values, name="power") + + +def _make_history(hours: int = 300, base_power: float = 300.0) -> LoadEnergyConsumption: + """Build hourly LoadEnergyConsumption for training service tests.""" + now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0) + intervals = [] + for i in range(hours, 0, -1): + start = Timestamp(now - timedelta(hours=i)) + end = Timestamp(start + timedelta(hours=1)) + power = Watts(base_power + start.hour * 10 + (i % 7) * 5) + intervals.append( + HomeLoadEnergyInterval( + start=start, + end=end, + power_points=[HomeLoadPowerPoint(timestamp=start, power=power)], + energy=WattHours(float(power)), + ) + ) + return LoadEnergyConsumption(timestamp=Timestamp(now), intervals=intervals) + + +# --------------------------------------------------------------------------- +# tune() static method tests +# --------------------------------------------------------------------------- + +class TestSkforecastTune: + """Tests for SkforecastForecastProvider.tune().""" + + def test_tune_returns_params_and_forecaster(self): + from edge_mining.adapters.domain.home_load.forecast_providers.skforecast_provider import ( + SkforecastForecastProvider, + ) + + y = _make_series(300) + best_params, tuned_forecaster = SkforecastForecastProvider.tune( + y_series=y, + sklearn_model_name="Ridge", + num_lags=24, + steps=24, + n_trials=3, # small for speed + ) + assert isinstance(best_params, dict) + assert tuned_forecaster is not None + + def test_tune_with_random_forest(self): + from edge_mining.adapters.domain.home_load.forecast_providers.skforecast_provider import ( + SkforecastForecastProvider, + ) + + y = _make_series(300) + best_params, tuned_forecaster = SkforecastForecastProvider.tune( + y_series=y, + sklearn_model_name="RandomForestRegressor", + num_lags=24, + steps=24, + n_trials=3, + ) + assert isinstance(best_params, dict) + # Tuned forecaster should be able to predict + preds = tuned_forecaster.predict(steps=6) + assert len(preds) == 6 + + def test_tune_with_kneighbors(self): + from edge_mining.adapters.domain.home_load.forecast_providers.skforecast_provider import ( + SkforecastForecastProvider, + ) + + y = _make_series(300) + best_params, tuned = SkforecastForecastProvider.tune( + y_series=y, + sklearn_model_name="KNeighborsRegressor", + num_lags=24, + steps=24, + n_trials=3, + ) + assert isinstance(best_params, dict) + + +# --------------------------------------------------------------------------- +# _build_search_space tests +# --------------------------------------------------------------------------- + +class TestBuildSearchSpace: + """Tests for the search space builder.""" + + def test_rf_space_callable(self): + from edge_mining.adapters.domain.home_load.forecast_providers.skforecast_provider import ( + _build_search_space, + ) + + space = _build_search_space("RandomForestRegressor") + assert callable(space) + + def test_ridge_space_callable(self): + from edge_mining.adapters.domain.home_load.forecast_providers.skforecast_provider import ( + _build_search_space, + ) + + space = _build_search_space("Ridge") + assert callable(space) + + def test_unknown_model_returns_default_space(self): + from edge_mining.adapters.domain.home_load.forecast_providers.skforecast_provider import ( + _build_search_space, + ) + + space = _build_search_space("SomeUnknownModel") + assert callable(space) + + +# --------------------------------------------------------------------------- +# LoadConsumptionModel.tuning_params tests +# --------------------------------------------------------------------------- + +class TestLoadConsumptionModelTuningParams: + """Tests for the tuning_params field on the entity.""" + + def test_default_is_none(self): + model = LoadConsumptionModel() + assert model.tuning_params is None + + def test_can_set_dict(self): + params = {"n_estimators": 200, "max_depth": 10, "lags": 48} + model = LoadConsumptionModel(tuning_params=params) + assert model.tuning_params == params + assert model.tuning_params["n_estimators"] == 200 + + def test_schema_includes_tuning_params(self): + from edge_mining.adapters.domain.home_load.schemas import LoadConsumptionModelSchema + + params = {"alpha": 0.5, "lags": 24} + model = LoadConsumptionModel( + adapter_type=EnergyLoadForecastProviderAdapter.SKFORECAST, + tuning_params=params, + ) + schema = LoadConsumptionModelSchema.from_model(model) + assert schema.tuning_params == params + + def test_schema_tuning_params_none(self): + from edge_mining.adapters.domain.home_load.schemas import LoadConsumptionModelSchema + + model = LoadConsumptionModel() + schema = LoadConsumptionModelSchema.from_model(model) + assert schema.tuning_params is None diff --git a/core/tests/unit/adapters/home_load/test_skforecast_forecast_provider.py b/core/tests/unit/adapters/home_load/test_skforecast_forecast_provider.py new file mode 100644 index 0000000..2f594ce --- /dev/null +++ b/core/tests/unit/adapters/home_load/test_skforecast_forecast_provider.py @@ -0,0 +1,267 @@ +"""Unit tests for Skforecast forecast provider.""" + +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock + +import pytest + +from edge_mining.adapters.domain.home_load.forecast_providers.skforecast_provider import ( + SkforecastForecastProvider, + SkforecastForecastProviderFactory, + _resolve_sklearn_model, + _SKFORECAST_AVAILABLE, +) +from edge_mining.domain.common import EntityId, Timestamp, WattHours, Watts +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter +from edge_mining.domain.home_load.exceptions import EnergyLoadForecastProviderError +from edge_mining.domain.home_load.value_objects import ( + HomeLoadEnergyInterval, + HomeLoadPowerPoint, + LoadEnergyConsumption, +) +from edge_mining.shared.adapter_configs.home_load import ( + EnergyLoadForecastProviderDummyConfig, + EnergyLoadForecastProviderSkforecastConfig, +) + +pytestmark = pytest.mark.skipif(not _SKFORECAST_AVAILABLE, reason="skforecast not installed") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_history(hours: int = 200, base_power: float = 300.0) -> LoadEnergyConsumption: + """Build a synthetic hourly history. + + Power pattern: ``base_power + hour_of_day * 10 + sin-like wobble``. + Needs to be long enough for num_lags + forecast horizon. + """ + now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0) + intervals = [] + for i in range(hours, 0, -1): + start = Timestamp(now - timedelta(hours=i)) + end = Timestamp(start + timedelta(hours=1)) + power = Watts(base_power + start.hour * 10 + (i % 7) * 5) + intervals.append( + HomeLoadEnergyInterval( + start=start, + end=end, + power_points=[HomeLoadPowerPoint(timestamp=start, power=power)], + energy=WattHours(float(power)), + ) + ) + return LoadEnergyConsumption(timestamp=Timestamp(now), intervals=intervals) + + +# --------------------------------------------------------------------------- +# sklearn resolver tests +# --------------------------------------------------------------------------- + +class TestSklearnModelResolver: + """Tests for _resolve_sklearn_model.""" + + def test_random_forest(self): + model = _resolve_sklearn_model("RandomForestRegressor") + assert model.__class__.__name__ == "RandomForestRegressor" + + def test_ridge(self): + model = _resolve_sklearn_model("Ridge") + assert model.__class__.__name__ == "Ridge" + + def test_kneighbors(self): + model = _resolve_sklearn_model("KNeighborsRegressor") + assert model.__class__.__name__ == "KNeighborsRegressor" + + def test_gradient_boosting(self): + model = _resolve_sklearn_model("GradientBoostingRegressor") + assert model.__class__.__name__ == "GradientBoostingRegressor" + + def test_unsupported_model_raises(self): + with pytest.raises(EnergyLoadForecastProviderError, match="Unsupported sklearn model"): + _resolve_sklearn_model("FakeModel") + + +# --------------------------------------------------------------------------- +# Factory tests +# --------------------------------------------------------------------------- + +class TestSkforecastForecastProviderFactory: + """Tests for the factory.""" + + def test_create_with_default_config(self): + factory = SkforecastForecastProviderFactory() + provider = factory.create(config=None, logger=None, external_service=None) + assert isinstance(provider, SkforecastForecastProvider) + + def test_create_with_valid_config(self): + config = EnergyLoadForecastProviderSkforecastConfig( + hours_ahead=12, weeks_lookback=4, sklearn_model="Ridge", num_lags=48 + ) + factory = SkforecastForecastProviderFactory() + provider = factory.create(config=config, logger=None, external_service=None) + assert isinstance(provider, SkforecastForecastProvider) + assert provider._hours_ahead == 12 + assert provider._sklearn_model == "Ridge" + assert provider._num_lags == 48 + + def test_create_with_model_repo(self): + mock_repo = MagicMock() + factory = SkforecastForecastProviderFactory(model_repo=mock_repo) + provider = factory.create(config=None, logger=None, external_service=None) + assert provider._model_repo is mock_repo + + def test_create_with_wrong_config_type_raises(self): + config = EnergyLoadForecastProviderDummyConfig() + factory = SkforecastForecastProviderFactory() + with pytest.raises(EnergyLoadForecastProviderError): + factory.create(config=config, logger=None, external_service=None) + + +# --------------------------------------------------------------------------- +# Provider tests +# --------------------------------------------------------------------------- + +class TestSkforecastForecastProvider: + """Tests for the provider.""" + + def test_adapter_type(self): + provider = SkforecastForecastProvider() + assert provider.forecast_provider_type == EnergyLoadForecastProviderAdapter.SKFORECAST + + def test_min_required_history_hours(self): + provider = SkforecastForecastProvider(num_lags=72, hours_ahead=24) + assert provider.min_required_history_hours == 72 + 48 + 24 + + def test_returns_none_for_empty_history(self): + provider = SkforecastForecastProvider(hours_ahead=6, num_lags=24) + empty = LoadEnergyConsumption(timestamp=Timestamp(datetime.now(timezone.utc)), intervals=[]) + assert provider.get_consumption_forecast(empty) is None + + def test_returns_none_for_zero_hours(self): + provider = SkforecastForecastProvider(hours_ahead=0) + history = _make_history(200) + assert provider.get_consumption_forecast(history) is None + + def test_returns_none_for_insufficient_history(self): + provider = SkforecastForecastProvider(hours_ahead=24, num_lags=72) + short_history = _make_history(50) # < 72 + 24 = 96 needed + assert provider.get_consumption_forecast(short_history) is None + + def test_forecast_length_matches_hours_ahead(self): + provider = SkforecastForecastProvider(hours_ahead=6, num_lags=24) + history = _make_history(200) + forecast = provider.get_consumption_forecast(history) + assert forecast is not None + assert len(forecast.intervals) == 6 + + def test_forecast_with_random_forest(self): + provider = SkforecastForecastProvider( + hours_ahead=12, num_lags=24, sklearn_model="RandomForestRegressor" + ) + history = _make_history(200) + forecast = provider.get_consumption_forecast(history) + assert forecast is not None + assert len(forecast.intervals) == 12 + + def test_forecast_with_ridge(self): + provider = SkforecastForecastProvider( + hours_ahead=6, num_lags=24, sklearn_model="Ridge" + ) + history = _make_history(200) + forecast = provider.get_consumption_forecast(history) + assert forecast is not None + assert len(forecast.intervals) == 6 + + def test_forecast_intervals_are_contiguous(self): + provider = SkforecastForecastProvider(hours_ahead=8, num_lags=24) + history = _make_history(200) + forecast = provider.get_consumption_forecast(history) + assert forecast is not None + for i in range(len(forecast.intervals) - 1): + assert forecast.intervals[i].end == forecast.intervals[i + 1].start + + def test_forecast_power_non_negative(self): + provider = SkforecastForecastProvider(hours_ahead=6, num_lags=24) + history = _make_history(200) + forecast = provider.get_consumption_forecast(history) + assert forecast is not None + for interval in forecast.intervals: + assert float(interval.avg_power) >= 0.0 + + def test_saved_model_used_when_available(self): + """If model_repo returns a saved model, it should be used.""" + import pickle + + import pandas as pd + from skforecast.recursive import ForecasterRecursive + from sklearn.linear_model import Ridge + + # Train a small model + history = _make_history(200) + from edge_mining.adapters.domain.home_load.forecast_providers.features import ( + fill_missing_hours, + intervals_to_hourly_series, + ) + + series = intervals_to_hourly_series(history) + series = fill_missing_hours(series) + powers = [p for _, p in series] + y = pd.Series(powers, name="power") + forecaster = ForecasterRecursive(estimator=Ridge(), lags=24) + forecaster.fit(y=y) + model_bytes = pickle.dumps(forecaster) + + # Mock model_repo + mock_model = MagicMock() + mock_model.model_bytes = model_bytes + mock_repo = MagicMock() + mock_repo.get_active_model.return_value = mock_model + + provider = SkforecastForecastProvider( + hours_ahead=6, num_lags=24, model_repo=mock_repo + ) + forecast = provider.get_consumption_forecast(history) + assert forecast is not None + assert len(forecast.intervals) == 6 + mock_repo.get_active_model.assert_called_once() + + +# --------------------------------------------------------------------------- +# Config tests +# --------------------------------------------------------------------------- + +class TestSkforecastConfig: + """Tests for the config dataclass.""" + + def test_defaults(self): + config = EnergyLoadForecastProviderSkforecastConfig() + assert config.hours_ahead == 24 + assert config.weeks_lookback == 8 + assert config.sklearn_model == "RandomForestRegressor" + assert config.num_lags == 72 + + def test_custom_values(self): + config = EnergyLoadForecastProviderSkforecastConfig( + hours_ahead=12, weeks_lookback=4, sklearn_model="Ridge", num_lags=48 + ) + assert config.sklearn_model == "Ridge" + assert config.num_lags == 48 + + def test_is_valid(self): + config = EnergyLoadForecastProviderSkforecastConfig() + assert config.is_valid(EnergyLoadForecastProviderAdapter.SKFORECAST) is True + assert config.is_valid(EnergyLoadForecastProviderAdapter.DUMMY) is False + + def test_to_dict_from_dict_roundtrip(self): + config = EnergyLoadForecastProviderSkforecastConfig( + hours_ahead=6, sklearn_model="Lasso", num_lags=36 + ) + d = config.to_dict() + restored = EnergyLoadForecastProviderSkforecastConfig.from_dict(d) + assert restored == config + + def test_frozen(self): + config = EnergyLoadForecastProviderSkforecastConfig() + with pytest.raises(AttributeError): + config.hours_ahead = 10 # type: ignore[misc] diff --git a/core/tests/unit/adapters/home_load/test_typical_profile_forecast_provider.py b/core/tests/unit/adapters/home_load/test_typical_profile_forecast_provider.py new file mode 100644 index 0000000..122b218 --- /dev/null +++ b/core/tests/unit/adapters/home_load/test_typical_profile_forecast_provider.py @@ -0,0 +1,274 @@ +"""Unit tests for TypicalProfile forecast provider.""" + +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +import pytest + +from edge_mining.adapters.domain.home_load.forecast_providers.typical_profile import ( + TypicalProfileForecastProvider, + TypicalProfileForecastProviderFactory, +) +from edge_mining.domain.common import Timestamp, WattHours, Watts +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter +from edge_mining.domain.home_load.exceptions import EnergyLoadForecastProviderError +from edge_mining.domain.home_load.value_objects import ( + HomeLoadEnergyInterval, + HomeLoadPowerPoint, + LoadEnergyConsumption, +) +from edge_mining.shared.adapter_configs.home_load import ( + EnergyLoadForecastProviderDummyConfig, + EnergyLoadForecastProviderTypicalProfileConfig, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_history(weeks: int = 4, base_power: float = 300.0, ref_time: datetime = None) -> LoadEnergyConsumption: + """Build a synthetic hourly history going back ``weeks`` weeks. + + Power follows a deterministic pattern: + ``base_power + (month * 5) + (dow * 3) + (hour * 10)`` + so each (month, dow, hour) has a unique, predictable value. + """ + now = ref_time if ref_time else datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0) + total_hours = weeks * 168 + intervals = [] + for i in range(total_hours, 0, -1): + start = Timestamp(now - timedelta(hours=i)) + end = Timestamp(start + timedelta(hours=1)) + power = Watts(base_power + start.month * 5 + start.weekday() * 3 + start.hour * 10) + intervals.append( + HomeLoadEnergyInterval( + start=start, + end=end, + power_points=[HomeLoadPowerPoint(timestamp=start, power=power)], + energy=WattHours(float(power)), + ) + ) + return LoadEnergyConsumption(timestamp=Timestamp(now), intervals=intervals) + + +def _make_sparse_history(hours: int = 48, base_power: float = 400.0) -> LoadEnergyConsumption: + """History with only a few hours, some (month, dow, hour) combos missing.""" + now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0) + intervals = [] + for i in range(hours, 0, -1): + start = Timestamp(now - timedelta(hours=i)) + end = Timestamp(start + timedelta(hours=1)) + power = Watts(base_power + start.hour * 10) + intervals.append( + HomeLoadEnergyInterval( + start=start, + end=end, + power_points=[HomeLoadPowerPoint(timestamp=start, power=power)], + energy=WattHours(float(power)), + ) + ) + return LoadEnergyConsumption(timestamp=Timestamp(now), intervals=intervals) + + +# --------------------------------------------------------------------------- +# Factory tests +# --------------------------------------------------------------------------- + +class TestTypicalProfileForecastProviderFactory: + """Tests for the factory.""" + + def test_create_with_default_config(self): + factory = TypicalProfileForecastProviderFactory() + provider = factory.create(config=None, logger=None, external_service=None) + assert isinstance(provider, TypicalProfileForecastProvider) + + def test_create_with_valid_config(self): + config = EnergyLoadForecastProviderTypicalProfileConfig(hours_ahead=12, weeks_lookback=4) + factory = TypicalProfileForecastProviderFactory() + provider = factory.create(config=config, logger=None, external_service=None) + assert isinstance(provider, TypicalProfileForecastProvider) + assert provider._hours_ahead == 12 + assert provider._weeks_lookback == 4 + + def test_create_with_wrong_config_type_raises(self): + config = EnergyLoadForecastProviderDummyConfig() + factory = TypicalProfileForecastProviderFactory() + with pytest.raises(EnergyLoadForecastProviderError): + factory.create(config=config, logger=None, external_service=None) + + +# --------------------------------------------------------------------------- +# Provider tests +# --------------------------------------------------------------------------- + +class TestTypicalProfileForecastProvider: + """Tests for the provider.""" + + def test_adapter_type(self): + provider = TypicalProfileForecastProvider() + assert provider.forecast_provider_type == EnergyLoadForecastProviderAdapter.TYPICAL_PROFILE + + def test_min_required_history_hours_default(self): + provider = TypicalProfileForecastProvider(weeks_lookback=8) + assert provider.min_required_history_hours == 8 * 168 + + def test_min_required_history_hours_custom(self): + provider = TypicalProfileForecastProvider(weeks_lookback=2) + assert provider.min_required_history_hours == 2 * 168 + + def test_returns_none_for_empty_history(self): + provider = TypicalProfileForecastProvider(hours_ahead=6) + empty = LoadEnergyConsumption(timestamp=Timestamp(datetime.now(timezone.utc)), intervals=[]) + assert provider.get_consumption_forecast(empty) is None + + def test_returns_none_for_zero_hours(self): + provider = TypicalProfileForecastProvider(hours_ahead=0) + history = _make_history(4) + assert provider.get_consumption_forecast(history) is None + + def test_forecast_length_matches_hours_ahead(self): + provider = TypicalProfileForecastProvider(hours_ahead=6) + history = _make_history(4) + forecast = provider.get_consumption_forecast(history) + assert forecast is not None + assert len(forecast.intervals) == 6 + + def test_forecast_default_24h(self): + provider = TypicalProfileForecastProvider(hours_ahead=24) + history = _make_history(4) + forecast = provider.get_consumption_forecast(history) + assert forecast is not None + assert len(forecast.intervals) == 24 + + def test_forecast_uses_month_dow_hour_profile(self): + """Power should match the (month, dow, hour) average from history.""" + # Use a fixed reference time deep in a month so 4 weeks back stays in the same month. + fixed_now = datetime(2026, 7, 29, 12, 0, 0, tzinfo=timezone.utc) + provider = TypicalProfileForecastProvider(hours_ahead=6) + history = _make_history(4, base_power=300.0, ref_time=fixed_now) + with patch( + "edge_mining.adapters.domain.home_load.forecast_providers.typical_profile.datetime", + wraps=datetime, + ) as mock_dt: + mock_dt.now.return_value = fixed_now + forecast = provider.get_consumption_forecast(history) + assert forecast is not None + + for interval in forecast.intervals: + expected = 300.0 + interval.start.month * 5 + interval.start.weekday() * 3 + interval.start.hour * 10 + assert float(interval.avg_power) == pytest.approx(expected, abs=1.0) + + def test_forecast_intervals_are_contiguous(self): + provider = TypicalProfileForecastProvider(hours_ahead=8) + history = _make_history(4) + forecast = provider.get_consumption_forecast(history) + assert forecast is not None + for i in range(len(forecast.intervals) - 1): + assert forecast.intervals[i].end == forecast.intervals[i + 1].start + + def test_forecast_power_non_negative(self): + provider = TypicalProfileForecastProvider(hours_ahead=6) + history = _make_history(4, base_power=0.0) + forecast = provider.get_consumption_forecast(history) + assert forecast is not None + for interval in forecast.intervals: + assert float(interval.avg_power) >= 0.0 + + def test_fallback_to_dow_hour_when_month_missing(self): + """When exact (month, dow, hour) isn't available, fall back to (dow, hour).""" + now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0) + # Create history from a different month to force fallback + intervals = [] + different_month_start = now.replace(month=(now.month % 12) + 1, day=1) + # But that might be in the future — use past month instead + if now.month == 1: + different_month_start = now.replace(year=now.year - 1, month=12, day=1) + else: + different_month_start = now.replace(month=now.month - 1, day=1) + + for i in range(168): # 1 week in the different month + start = Timestamp(different_month_start + timedelta(hours=i)) + end = Timestamp(start + timedelta(hours=1)) + power = Watts(500.0) + intervals.append( + HomeLoadEnergyInterval( + start=start, end=end, + power_points=[HomeLoadPowerPoint(timestamp=start, power=power)], + energy=WattHours(500.0), + ) + ) + history = LoadEnergyConsumption(timestamp=Timestamp(now), intervals=intervals) + + provider = TypicalProfileForecastProvider(hours_ahead=3) + forecast = provider.get_consumption_forecast(history) + assert forecast is not None + assert len(forecast.intervals) == 3 + # All should be 500.0 from (dow, hour) fallback + for interval in forecast.intervals: + assert float(interval.avg_power) == pytest.approx(500.0, abs=1.0) + + def test_fallback_to_global_avg_when_all_missing(self): + """When no matching (dow, hour) slots exist, uses global average.""" + now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0) + # Create only 2 intervals at very specific times + intervals = [] + for i in [2, 1]: + start = Timestamp(now - timedelta(hours=i)) + end = Timestamp(start + timedelta(hours=1)) + power = Watts(750.0) + intervals.append( + HomeLoadEnergyInterval( + start=start, end=end, + power_points=[HomeLoadPowerPoint(timestamp=start, power=power)], + energy=WattHours(750.0), + ) + ) + sparse = LoadEnergyConsumption(timestamp=Timestamp(now), intervals=intervals) + + provider = TypicalProfileForecastProvider(hours_ahead=24) + forecast = provider.get_consumption_forecast(sparse) + assert forecast is not None + assert len(forecast.intervals) == 24 + + def test_sparse_history_still_produces_forecast(self): + """With limited history, provider should still produce valid intervals.""" + provider = TypicalProfileForecastProvider(hours_ahead=6) + history = _make_sparse_history(48) + forecast = provider.get_consumption_forecast(history) + assert forecast is not None + assert len(forecast.intervals) == 6 + + +# --------------------------------------------------------------------------- +# Config tests +# --------------------------------------------------------------------------- + +class TestTypicalProfileConfig: + """Tests for the config dataclass.""" + + def test_defaults(self): + config = EnergyLoadForecastProviderTypicalProfileConfig() + assert config.hours_ahead == 24 + assert config.weeks_lookback == 8 + + def test_custom_values(self): + config = EnergyLoadForecastProviderTypicalProfileConfig(hours_ahead=12, weeks_lookback=4) + assert config.hours_ahead == 12 + assert config.weeks_lookback == 4 + + def test_is_valid(self): + config = EnergyLoadForecastProviderTypicalProfileConfig() + assert config.is_valid(EnergyLoadForecastProviderAdapter.TYPICAL_PROFILE) is True + assert config.is_valid(EnergyLoadForecastProviderAdapter.DUMMY) is False + + def test_to_dict_from_dict_roundtrip(self): + config = EnergyLoadForecastProviderTypicalProfileConfig(hours_ahead=6, weeks_lookback=2) + d = config.to_dict() + restored = EnergyLoadForecastProviderTypicalProfileConfig.from_dict(d) + assert restored == config + + def test_frozen(self): + config = EnergyLoadForecastProviderTypicalProfileConfig() + with pytest.raises(AttributeError): + config.hours_ahead = 10 # type: ignore[misc] diff --git a/core/tests/unit/adapters/infrastructure/__init__.py b/core/tests/unit/adapters/infrastructure/__init__.py new file mode 100644 index 0000000..6082c93 --- /dev/null +++ b/core/tests/unit/adapters/infrastructure/__init__.py @@ -0,0 +1 @@ +"""Collection of unit tests for the infrastructure adapters layer.""" diff --git a/core/tests/unit/adapters/infrastructure/rule_engine/__init__.py b/core/tests/unit/adapters/infrastructure/rule_engine/__init__.py new file mode 100644 index 0000000..a7d691c --- /dev/null +++ b/core/tests/unit/adapters/infrastructure/rule_engine/__init__.py @@ -0,0 +1 @@ +"""Collection of unit test for the rule engine.""" diff --git a/core/tests/unit/adapters/infrastructure/rule_engine/test_custom_rule_engine.py b/core/tests/unit/adapters/infrastructure/rule_engine/test_custom_rule_engine.py new file mode 100644 index 0000000..1883b8e --- /dev/null +++ b/core/tests/unit/adapters/infrastructure/rule_engine/test_custom_rule_engine.py @@ -0,0 +1,278 @@ +"""Unit tests for CustomRuleEngine class.""" + +import unittest +from unittest.mock import Mock, patch + +try: + from edge_mining.adapters.infrastructure.rule_engine.engine import CustomRuleEngine + from edge_mining.domain.policy.entities import AutomationRule + from edge_mining.domain.policy.value_objects import DecisionalContext + from edge_mining.shared.logging.port import LoggerPort +except ImportError as e: + print(f"Import error: {e}") + raise + + +class TestCustomRuleEngine(unittest.TestCase): + """Test cases for CustomRuleEngine class.""" + + def setUp(self): + """Set up test fixtures before each test method.""" + self.mock_logger = Mock(spec=LoggerPort) + self.engine = CustomRuleEngine(self.mock_logger) + + # Create mock automation rules + self.mock_rule_high_priority = Mock(spec=AutomationRule) + self.mock_rule_high_priority.name = "High Priority Rule" + self.mock_rule_high_priority.priority = 10 + self.mock_rule_high_priority.enabled = True + self.mock_rule_high_priority.conditions = ["condition1"] + + self.mock_rule_low_priority = Mock(spec=AutomationRule) + self.mock_rule_low_priority.name = "Low Priority Rule" + self.mock_rule_low_priority.priority = 5 + self.mock_rule_low_priority.enabled = True + self.mock_rule_low_priority.conditions = ["condition2"] + + self.mock_rule_disabled = Mock(spec=AutomationRule) + self.mock_rule_disabled.name = "Disabled Rule" + self.mock_rule_disabled.priority = 15 + self.mock_rule_disabled.enabled = False + self.mock_rule_disabled.conditions = ["condition3"] + + # Create mock decisional context + self.mock_context = Mock(spec=DecisionalContext) + + def test_init(self): + """Test CustomRuleEngine initialization.""" + engine = CustomRuleEngine(self.mock_logger) + + self.assertEqual(engine.rules, []) + self.assertEqual(engine.logger, self.mock_logger) + + def test_load_rules_empty_list(self): + """Test loading an empty list of rules.""" + rules = [] + + self.engine.load_rules(rules) + + self.assertEqual(self.engine.rules, []) + self.mock_logger.debug.assert_called_once_with("Successfully loaded 0 rules into CustomRuleEngine") + + def test_load_rules_single_rule(self): + """Test loading a single rule.""" + rules = [self.mock_rule_high_priority] + + self.engine.load_rules(rules) + + self.assertEqual(self.engine.rules, rules) + self.mock_logger.debug.assert_called_once_with("Successfully loaded 1 rules into CustomRuleEngine") + + def test_load_rules_multiple_rules(self): + """Test loading multiple rules.""" + rules = [self.mock_rule_high_priority, self.mock_rule_low_priority] + + self.engine.load_rules(rules) + + self.assertEqual(self.engine.rules, rules) + self.mock_logger.debug.assert_called_once_with("Successfully loaded 2 rules into CustomRuleEngine") + + def test_load_rules_overwrites_existing(self): + """Test that loading rules overwrites existing rules.""" + # Load initial rules + initial_rules = [self.mock_rule_high_priority] + self.engine.load_rules(initial_rules) + + # Load new rules + new_rules = [self.mock_rule_low_priority] + self.engine.load_rules(new_rules) + + self.assertEqual(self.engine.rules, new_rules) + self.assertEqual(len(self.engine.rules), 1) + + @patch("edge_mining.adapters.infrastructure.rule_engine.engine.RuleEvaluator") + def test_evaluate_no_rules(self, mock_rule_evaluator): + """Test evaluation when no rules are loaded.""" + result = self.engine.evaluate(self.mock_context) + + self.assertFalse(result) + mock_rule_evaluator.evaluate_rule_conditions.assert_not_called() + + @patch("edge_mining.adapters.infrastructure.rule_engine.engine.RuleEvaluator") + def test_evaluate_single_rule_matches(self, mock_rule_evaluator): + """Test evaluation when single rule matches.""" + mock_rule_evaluator.evaluate_rule_conditions.return_value = True + + self.engine.load_rules([self.mock_rule_high_priority]) + result = self.engine.evaluate(self.mock_context) + + self.assertTrue(result) + mock_rule_evaluator.evaluate_rule_conditions.assert_called_once_with( + self.mock_context, self.mock_rule_high_priority.conditions + ) + self.mock_logger.debug.assert_any_call("Rule 'High Priority Rule' matched!") + + @patch("edge_mining.adapters.infrastructure.rule_engine.engine.RuleEvaluator") + def test_evaluate_single_rule_no_match(self, mock_rule_evaluator): + """Test evaluation when single rule doesn't match.""" + mock_rule_evaluator.evaluate_rule_conditions.return_value = False + + self.engine.load_rules([self.mock_rule_high_priority]) + result = self.engine.evaluate(self.mock_context) + + self.assertFalse(result) + mock_rule_evaluator.evaluate_rule_conditions.assert_called_once_with( + self.mock_context, self.mock_rule_high_priority.conditions + ) + + @patch("edge_mining.adapters.infrastructure.rule_engine.engine.RuleEvaluator") + def test_evaluate_multiple_rules_first_matches(self, mock_rule_evaluator): + """Test evaluation when first rule (by priority) matches.""" + mock_rule_evaluator.evaluate_rule_conditions.return_value = True + + rules = [self.mock_rule_low_priority, self.mock_rule_high_priority] + self.engine.load_rules(rules) + result = self.engine.evaluate(self.mock_context) + + self.assertTrue(result) + # Should only evaluate the high priority rule (first in sorted order) + mock_rule_evaluator.evaluate_rule_conditions.assert_called_once_with( + self.mock_context, self.mock_rule_high_priority.conditions + ) + self.mock_logger.debug.assert_any_call("Rule 'High Priority Rule' matched!") + + @patch("edge_mining.adapters.infrastructure.rule_engine.engine.RuleEvaluator") + def test_evaluate_multiple_rules_second_matches(self, mock_rule_evaluator): + """Test evaluation when second rule (by priority) matches.""" + # First call returns False, second returns True + mock_rule_evaluator.evaluate_rule_conditions.side_effect = [ + False, + True, + ] + + rules = [self.mock_rule_low_priority, self.mock_rule_high_priority] + self.engine.load_rules(rules) + result = self.engine.evaluate(self.mock_context) + + self.assertTrue(result) + # Should evaluate both rules in priority order + self.assertEqual(mock_rule_evaluator.evaluate_rule_conditions.call_count, 2) + self.mock_logger.debug.assert_any_call("Rule 'Low Priority Rule' matched!") + + @patch("edge_mining.adapters.infrastructure.rule_engine.engine.RuleEvaluator") + def test_evaluate_priority_sorting(self, mock_rule_evaluator): + """Test that rules are evaluated in priority order (highest first).""" + mock_rule_evaluator.evaluate_rule_conditions.side_effect = [ + False, + False, + ] + + # Add rules in random order + rules = [self.mock_rule_low_priority, self.mock_rule_high_priority] + self.engine.load_rules(rules) + result = self.engine.evaluate(self.mock_context) + + self.assertFalse(result) + # Verify rules were called in priority order (high priority first) + calls = mock_rule_evaluator.evaluate_rule_conditions.call_args_list + self.assertEqual(len(calls), 2) + # First call should be for high priority rule + self.assertEqual(calls[0][0][1], self.mock_rule_high_priority.conditions) + # Second call should be for low priority rule + self.assertEqual(calls[1][0][1], self.mock_rule_low_priority.conditions) + + @patch("edge_mining.adapters.infrastructure.rule_engine.engine.RuleEvaluator") + def test_evaluate_skips_disabled_rules(self, mock_rule_evaluator): + """Test that disabled rules are skipped during evaluation.""" + mock_rule_evaluator.evaluate_rule_conditions.return_value = False + + rules = [self.mock_rule_disabled, self.mock_rule_high_priority] + self.engine.load_rules(rules) + result = self.engine.evaluate(self.mock_context) + + self.assertFalse(result) + # Should only evaluate the enabled rule + mock_rule_evaluator.evaluate_rule_conditions.assert_called_once_with( + self.mock_context, self.mock_rule_high_priority.conditions + ) + + @patch("edge_mining.adapters.infrastructure.rule_engine.engine.RuleEvaluator") + def test_evaluate_handles_value_error(self, mock_rule_evaluator): + """Test evaluation handles ValueError exceptions gracefully.""" + mock_rule_evaluator.evaluate_rule_conditions.side_effect = ValueError("Test error") + + self.engine.load_rules([self.mock_rule_high_priority]) + result = self.engine.evaluate(self.mock_context) + + self.assertFalse(result) + self.mock_logger.error.assert_called_once_with("Error evaluating rule 'High Priority Rule': Test error") + + @patch("edge_mining.adapters.infrastructure.rule_engine.engine.RuleEvaluator") + def test_evaluate_handles_attribute_error(self, mock_rule_evaluator): + """Test evaluation handles AttributeError exceptions gracefully.""" + mock_rule_evaluator.evaluate_rule_conditions.side_effect = AttributeError("Test error") + + self.engine.load_rules([self.mock_rule_high_priority]) + result = self.engine.evaluate(self.mock_context) + + self.assertFalse(result) + self.mock_logger.error.assert_called_once_with("Error evaluating rule 'High Priority Rule': Test error") + + @patch("edge_mining.adapters.infrastructure.rule_engine.engine.RuleEvaluator") + def test_evaluate_continues_after_error(self, mock_rule_evaluator): + """Test that evaluation continues with other rules after an error.""" + # First rule throws exception, second rule matches + mock_rule_evaluator.evaluate_rule_conditions.side_effect = [ + ValueError("Test error"), + True, + ] + + rules = [self.mock_rule_high_priority, self.mock_rule_low_priority] + self.engine.load_rules(rules) + result = self.engine.evaluate(self.mock_context) + + self.assertTrue(result) + self.assertEqual(mock_rule_evaluator.evaluate_rule_conditions.call_count, 2) + self.mock_logger.error.assert_called_once_with("Error evaluating rule 'High Priority Rule': Test error") + self.mock_logger.debug.assert_any_call("Rule 'Low Priority Rule' matched!") + + @patch("edge_mining.adapters.infrastructure.rule_engine.engine.RuleEvaluator") + def test_evaluate_all_rules_fail(self, mock_rule_evaluator): + """Test evaluation when all rules fail to match.""" + mock_rule_evaluator.evaluate_rule_conditions.return_value = False + + rules = [self.mock_rule_high_priority, self.mock_rule_low_priority] + self.engine.load_rules(rules) + result = self.engine.evaluate(self.mock_context) + + self.assertFalse(result) + self.assertEqual(mock_rule_evaluator.evaluate_rule_conditions.call_count, 2) + + def test_evaluate_with_mixed_enabled_disabled_rules(self): + """Test evaluation with a mix of enabled and disabled rules.""" + # Create a rule that should match + enabled_rule = Mock(spec=AutomationRule) + enabled_rule.name = "Enabled Rule" + enabled_rule.priority = 8 + enabled_rule.enabled = True + enabled_rule.conditions = ["condition"] + + with patch("edge_mining.adapters.infrastructure.rule_engine.engine.RuleEvaluator") as mock_evaluator: + mock_evaluator.evaluate_rule_conditions.return_value = True + + rules = [ + self.mock_rule_disabled, + enabled_rule, + self.mock_rule_low_priority, + ] + self.engine.load_rules(rules) + result = self.engine.evaluate(self.mock_context) + + self.assertTrue(result) + # Should only evaluate enabled rules + self.assertEqual(mock_evaluator.evaluate_rule_conditions.call_count, 1) + mock_evaluator.evaluate_rule_conditions.assert_called_with(self.mock_context, enabled_rule.conditions) + + +if __name__ == "__main__": + unittest.main() diff --git a/core/tests/unit/adapters/infrastructure/rule_engine/test_rule_evaluator.py b/core/tests/unit/adapters/infrastructure/rule_engine/test_rule_evaluator.py new file mode 100644 index 0000000..a008f0a --- /dev/null +++ b/core/tests/unit/adapters/infrastructure/rule_engine/test_rule_evaluator.py @@ -0,0 +1,613 @@ +"""Unit tests for RuleEvaluator class.""" + +import unittest +from datetime import datetime +from unittest.mock import Mock, patch + +from edge_mining.adapters.domain.policy.schemas import LogicalGroupSchema, RuleConditionSchema +from edge_mining.domain.policy.common import OperatorType +from edge_mining.adapters.infrastructure.rule_engine.custom.helpers import RuleEvaluator +from edge_mining.domain.policy.exceptions import UnsupportedConditionError +from edge_mining.domain.policy.value_objects import DecisionalContext + + +class TestRuleEvaluator(unittest.TestCase): + """Test cases for RuleEvaluator class.""" + + def setUp(self): + """Set up test fixtures before each test method.""" + # Create mock DecisionalContext + self.mock_context = Mock(spec=DecisionalContext) + + # Set up nested mock attributes for dot notation testing + self.mock_context.energy_state = Mock() + self.mock_context.energy_state.battery = Mock() + self.mock_context.energy_state.battery.state_of_charge = 75 + self.mock_context.energy_state.production = 1200 + + self.mock_context.miner = Mock() + self.mock_context.miner_state = Mock() + self.mock_context.miner_state.status = "ON" + + self.mock_context.timestamp = datetime(2025, 8, 13, 14, 30) # Tuesday 14:30 + + self.mock_context.forecast = Mock() + self.mock_context.forecast.next_hour_power = Mock(value=1500) + self.mock_context.forecast.avg_next_4_hours_power = Mock(value=1800) + + # === Tests for evaluate_rule_conditions === + + def test_evaluate_rule_conditions_single_condition_dict(self): + """Test evaluating a single condition from dict format.""" + conditions_dict = { + "field": "energy_state.battery.state_of_charge", + "operator": "gt", + "value": 50, + } + + result = RuleEvaluator.evaluate_rule_conditions(self.mock_context, conditions_dict) + + self.assertTrue(result) + + def test_evaluate_rule_conditions_logical_group_dict(self): + """Test evaluating a logical group from dict format.""" + conditions_dict = { + "all_of": [ + { + "field": "energy_state.battery.state_of_charge", + "operator": "gt", + "value": 50, + }, + { + "field": "energy_state.production", + "operator": "gt", + "value": 1000, + }, + ] + } + + result = RuleEvaluator.evaluate_rule_conditions(self.mock_context, conditions_dict) + + self.assertTrue(result) + + def test_evaluate_rule_conditions_unsupported_type(self): + """Test that unsupported condition types raise ValueError.""" + # The method expects a dict but we pass a string + with self.assertRaises(ValueError) as cm: + RuleEvaluator.evaluate_rule_conditions(self.mock_context, "invalid_condition") + + # The actual error message will be about unsupported condition type + self.assertIn("Unsupported condition type", str(cm.exception)) + + # === Tests for _convert_conditions_to_schema === + + def test_convert_conditions_to_schema_rule_condition(self): + """Test converting dict to RuleConditionSchema.""" + conditions_dict = { + "field": "miner_state.status", + "operator": "eq", + "value": "ON", + } + + result = RuleEvaluator._convert_conditions_to_schema(conditions_dict) + + self.assertIsInstance(result, RuleConditionSchema) + self.assertEqual(result.field, "miner_state.status") + self.assertEqual(result.operator, OperatorType.EQ) + self.assertEqual(result.value, "ON") + + def test_convert_conditions_to_schema_logical_group(self): + """Test converting dict to LogicalGroupSchema.""" + conditions_dict = {"all_of": [{"field": "test.field", "operator": "eq", "value": 1}]} + + result = RuleEvaluator._convert_conditions_to_schema(conditions_dict) + + self.assertIsInstance(result, LogicalGroupSchema) + self.assertIsNotNone(result.all_of) + self.assertEqual(len(result.all_of), 1) + + def test_convert_conditions_to_schema_invalid_format(self): + """Test that invalid dict format raises ValueError.""" + conditions_dict = {"invalid_key": "invalid_value"} + + with self.assertRaises(UnsupportedConditionError) as cm: + RuleEvaluator._convert_conditions_to_schema(conditions_dict) + + self.assertIn("Invalid conditions format", str(cm.exception)) + + def test_convert_conditions_to_schema_non_dict(self): + """Test that non-dict input raises ValueError.""" + with self.assertRaises(UnsupportedConditionError) as cm: + RuleEvaluator._convert_conditions_to_schema("not_a_dict") + + self.assertIn("Expected conditions to be a dict", str(cm.exception)) + + def test_convert_conditions_to_schema_exception_handling(self): + """Test exception handling in schema conversion.""" + # Use an invalid operator to force a validation error + conditions_dict = { + "field": "test.field", + "operator": "invalid_operator", # This will cause validation error + "value": 1, + } + + with self.assertRaises(Exception): + RuleEvaluator._convert_conditions_to_schema(conditions_dict) + + # === Tests for _evaluate_single_condition === + + def test_evaluate_single_condition_success(self): + """Test successful evaluation of a single condition.""" + condition = RuleConditionSchema( + field="energy_state.battery.state_of_charge", + operator=OperatorType.GT, + value=50, + ) + + result = RuleEvaluator._evaluate_single_condition(self.mock_context, condition) + + self.assertTrue(result) + + def test_evaluate_single_condition_field_not_found(self): + """Test single condition with non-existent field.""" + condition = RuleConditionSchema(field="non.existent.field", operator=OperatorType.EQ, value=100) + + result = RuleEvaluator._evaluate_single_condition(self.mock_context, condition) + + self.assertFalse(result) + + def test_evaluate_single_condition_none_value(self): + """Test single condition when field value is None.""" + self.mock_context.energy_state.battery.state_of_charge = None + + condition = RuleConditionSchema( + field="energy_state.battery.state_of_charge", + operator=OperatorType.GT, + value=50, + ) + + result = RuleEvaluator._evaluate_single_condition(self.mock_context, condition) + + self.assertFalse(result) + + @patch("edge_mining.adapters.infrastructure.rule_engine.custom.helpers.RuleEvaluator._get_field_value") + def test_evaluate_single_condition_exception_handling(self, mock_get_field): + """Test exception handling in single condition evaluation.""" + mock_get_field.side_effect = Exception("Field access error") + + condition = RuleConditionSchema(field="test.field", operator=OperatorType.EQ, value=100) + + result = RuleEvaluator._evaluate_single_condition(self.mock_context, condition) + + self.assertFalse(result) + + # === Tests for _evaluate_logical_group === + + # === Direct Tests for Logical Group Functionality === + + def test_logical_group_direct_evaluation(self): + """Test logical group evaluation using direct schema objects.""" + # Test _evaluate_logical_group directly with mock schema objects + mock_group = Mock(spec=LogicalGroupSchema) + mock_group.all_of = None + mock_group.any_of = None + mock_group.not_ = None + + # Test with no operators (should return False) + result = RuleEvaluator._evaluate_logical_group(self.mock_context, mock_group) + self.assertFalse(result) + + def test_basic_single_condition_scenarios(self): + """Test various single condition scenarios.""" + # High battery scenario + test_cases = [ + # (field_value, operator, expected_value, expected_result) + (80, "gt", 70, True), # 80 > 70 = True + (60, "gt", 70, False), # 60 > 70 = False + (1500, "gte", 1000, True), # 1500 >= 1000 = True + ("ON", "eq", "ON", True), # "ON" == "ON" = True + ("OFF", "ne", "ERROR", True), # "OFF" != "ERROR" = True + ] + + for ( + field_value, + operator, + expected_value, + expected_result, + ) in test_cases: + with self.subTest( + field_value=field_value, + operator=operator, + expected_value=expected_value, + ): + # Mock the field value + self.mock_context.test_field = field_value + + conditions_dict = { + "field": "test_field", + "operator": operator, + "value": expected_value, + } + + result = RuleEvaluator.evaluate_rule_conditions(self.mock_context, conditions_dict) + self.assertEqual(result, expected_result) + + def test_field_access_edge_cases(self): + """Test edge cases for field access.""" + # Test various field access patterns + test_cases = [ + ("energy_state", True), # Simple field exists + ( + "energy_state.battery.state_of_charge", + True, + ), # Nested field exists + ] + + for field_path, should_exist in test_cases: + with self.subTest(field_path=field_path): + result = RuleEvaluator._get_field_value(self.mock_context, field_path) + if should_exist: + self.assertIsNotNone(result) + + # Test non-existent simple field + result = RuleEvaluator._get_field_value(self.mock_context, "non_existent") + self.assertIsNone(result) + + # For deeply nested non-existent paths, Mock returns Mock objects + # but the RuleEvaluator should handle this gracefully in real scenarios + # where the actual context object would properly return None + + def test_realistic_mining_decision_simple(self): + """Test a realistic mining decision with both conditions evaluated together.""" + # Scenario: Start mining if battery > 70% AND production > 1000W + conditions = { + "all_of": [ + { + "field": "energy_state.battery.state_of_charge", + "operator": "gt", + "value": 70, + }, + { + "field": "energy_state.production", + "operator": "gt", + "value": 1000, + }, + ] + } + result = RuleEvaluator.evaluate_rule_conditions(self.mock_context, conditions) + self.assertTrue(result) # Both conditions must be True + + def test_evaluate_logical_group_no_operator(self): + """Test logical group with no operators specified.""" + # Create a LogicalGroupSchema manually with all_of=[] to avoid validation + group = Mock(spec=LogicalGroupSchema) + group.all_of = None + group.any_of = None + group.not_ = None + + result = RuleEvaluator._evaluate_logical_group(self.mock_context, group) + + self.assertFalse(result) + + # === Tests for _get_field_value === + + def test_get_field_value_simple_path(self): + """Test getting field value with simple path.""" + result = RuleEvaluator._get_field_value(self.mock_context, "energy_state") + + self.assertEqual(result, self.mock_context.energy_state) + + def test_get_field_value_nested_path(self): + """Test getting field value with nested path.""" + result = RuleEvaluator._get_field_value(self.mock_context, "energy_state.battery.state_of_charge") + + self.assertEqual(result, 75) + + def test_get_field_value_nonexistent_path(self): + """Test getting field value with non-existent path.""" + result = RuleEvaluator._get_field_value(self.mock_context, "non.existent.field") + + self.assertIsNone(result) + + def test_get_field_value_partial_path_missing(self): + """Test getting field value when intermediate path doesn't exist.""" + # Remove the missing attribute to make it truly missing + ( + delattr(self.mock_context.energy_state, "missing") + if hasattr(self.mock_context.energy_state, "missing") + else None + ) + + result = RuleEvaluator._get_field_value(self.mock_context, "energy_state.missing.field") + + self.assertIsNone(result) + + # === Tests for _apply_operator === + + def test_apply_operator_eq_true(self): + """Test EQ operator when values are equal.""" + result = RuleEvaluator._apply_operator(75, OperatorType.EQ, 75) + self.assertTrue(result) + + def test_apply_operator_eq_false(self): + """Test EQ operator when values are not equal.""" + result = RuleEvaluator._apply_operator(75, OperatorType.EQ, 50) + self.assertFalse(result) + + def test_apply_operator_ne_true(self): + """Test NE operator when values are not equal.""" + result = RuleEvaluator._apply_operator(75, OperatorType.NE, 50) + self.assertTrue(result) + + def test_apply_operator_ne_false(self): + """Test NE operator when values are equal.""" + result = RuleEvaluator._apply_operator(75, OperatorType.NE, 75) + self.assertFalse(result) + + def test_apply_operator_gt_true(self): + """Test GT operator when first value is greater.""" + result = RuleEvaluator._apply_operator(75, OperatorType.GT, 50) + self.assertTrue(result) + + def test_apply_operator_gt_false(self): + """Test GT operator when first value is not greater.""" + result = RuleEvaluator._apply_operator(50, OperatorType.GT, 75) + self.assertFalse(result) + + def test_apply_operator_gte_true_greater(self): + """Test GTE operator when first value is greater.""" + result = RuleEvaluator._apply_operator(75, OperatorType.GTE, 50) + self.assertTrue(result) + + def test_apply_operator_gte_true_equal(self): + """Test GTE operator when values are equal.""" + result = RuleEvaluator._apply_operator(75, OperatorType.GTE, 75) + self.assertTrue(result) + + def test_apply_operator_gte_false(self): + """Test GTE operator when first value is less.""" + result = RuleEvaluator._apply_operator(50, OperatorType.GTE, 75) + self.assertFalse(result) + + def test_apply_operator_lt_true(self): + """Test LT operator when first value is less.""" + result = RuleEvaluator._apply_operator(50, OperatorType.LT, 75) + self.assertTrue(result) + + def test_apply_operator_lt_false(self): + """Test LT operator when first value is not less.""" + result = RuleEvaluator._apply_operator(75, OperatorType.LT, 50) + self.assertFalse(result) + + def test_apply_operator_lte_true_less(self): + """Test LTE operator when first value is less.""" + result = RuleEvaluator._apply_operator(50, OperatorType.LTE, 75) + self.assertTrue(result) + + def test_apply_operator_lte_true_equal(self): + """Test LTE operator when values are equal.""" + result = RuleEvaluator._apply_operator(75, OperatorType.LTE, 75) + self.assertTrue(result) + + def test_apply_operator_lte_false(self): + """Test LTE operator when first value is greater.""" + result = RuleEvaluator._apply_operator(75, OperatorType.LTE, 50) + self.assertFalse(result) + + def test_apply_operator_in_true(self): + """Test IN operator when value is in list.""" + result = RuleEvaluator._apply_operator("ON", OperatorType.IN, ["ON", "OFF", "ERROR"]) + self.assertTrue(result) + + def test_apply_operator_in_false(self): + """Test IN operator when value is not in list.""" + result = RuleEvaluator._apply_operator("STARTING", OperatorType.IN, ["ON", "OFF", "ERROR"]) + self.assertFalse(result) + + def test_apply_operator_not_in_true(self): + """Test NOT_IN operator when value is not in list.""" + result = RuleEvaluator._apply_operator("STARTING", OperatorType.NOT_IN, ["ON", "OFF", "ERROR"]) + self.assertTrue(result) + + def test_apply_operator_not_in_false(self): + """Test NOT_IN operator when value is in list.""" + result = RuleEvaluator._apply_operator("ON", OperatorType.NOT_IN, ["ON", "OFF", "ERROR"]) + self.assertFalse(result) + + def test_apply_operator_contains_true(self): + """Test CONTAINS operator when expected value is in field value.""" + result = RuleEvaluator._apply_operator("solar_panel_1", OperatorType.CONTAINS, "solar") + self.assertTrue(result) + + def test_apply_operator_contains_false(self): + """Test CONTAINS operator when expected value is not in field value.""" + result = RuleEvaluator._apply_operator("battery_monitor", OperatorType.CONTAINS, "solar") + self.assertFalse(result) + + def test_apply_operator_starts_with_true(self): + """Test STARTS_WITH operator when field value starts with expected value.""" + result = RuleEvaluator._apply_operator("HIGH_PRIORITY", OperatorType.STARTS_WITH, "HIGH") + self.assertTrue(result) + + def test_apply_operator_starts_with_false(self): + """Test STARTS_WITH operator when field value doesn't start with expected value.""" + result = RuleEvaluator._apply_operator("LOW_PRIORITY", OperatorType.STARTS_WITH, "HIGH") + self.assertFalse(result) + + def test_apply_operator_ends_with_true(self): + """Test ENDS_WITH operator when field value ends with expected value.""" + result = RuleEvaluator._apply_operator("SYSTEM_LEVEL", OperatorType.ENDS_WITH, "LEVEL") + self.assertTrue(result) + + def test_apply_operator_ends_with_false(self): + """Test ENDS_WITH operator when field value doesn't end with expected value.""" + result = RuleEvaluator._apply_operator("SYSTEM_STATE", OperatorType.ENDS_WITH, "LEVEL") + self.assertFalse(result) + + def test_apply_operator_regex_true(self): + """Test REGEX operator when pattern matches.""" + result = RuleEvaluator._apply_operator("12345", OperatorType.REGEX, r"^\d+$") + self.assertTrue(result) + + def test_apply_operator_regex_false(self): + """Test REGEX operator when pattern doesn't match.""" + result = RuleEvaluator._apply_operator("abc123", OperatorType.REGEX, r"^\d+$") + self.assertFalse(result) + + def test_apply_operator_unsupported(self): + """Test that unsupported operators raise ValueError.""" + + # Create a mock operator that doesn't exist in OperatorType + class FakeOperator: + def __str__(self): + return "FAKE_OP" + + fake_operator = FakeOperator() + + # The _apply_operator should return False for unsupported operators due to try/catch + result = RuleEvaluator._apply_operator(75, fake_operator, 50) + self.assertFalse(result) + + def test_apply_operator_value_error_conversion(self): + """Test operator with values that can't be converted (e.g., string to float).""" + result = RuleEvaluator._apply_operator("not_a_number", OperatorType.GT, 50) + self.assertFalse(result) + + def test_apply_operator_type_error(self): + """Test operator with incompatible types.""" + result = RuleEvaluator._apply_operator(None, OperatorType.GT, 50) + self.assertFalse(result) + + # === Integration Tests === + + def test_complex_nested_logical_groups(self): + """Test complex nested logical groups.""" + # Simplified version - test basic AND/OR combination + conditions_dict = { + "all_of": [ + { + "field": "energy_state.battery.state_of_charge", + "operator": "gt", + "value": 50, + }, + { + "field": "energy_state.production", + "operator": "gt", + "value": 1000, + }, + ] + } + + result = RuleEvaluator.evaluate_rule_conditions(self.mock_context, conditions_dict) + + self.assertTrue(result) + + def test_end_to_end_evaluation_flow(self): + """Test end-to-end evaluation from dict to final result.""" + # Test a realistic scenario: start mining if battery > 70% AND production > 1000W + self.mock_context.timestamp = datetime(2025, 8, 13, 14, 30) # 14:30 + + conditions_dict = { + "all_of": [ + { + "field": "energy_state.battery.state_of_charge", + "operator": "gt", + "value": 70, + }, + { + "field": "energy_state.production", + "operator": "gt", + "value": 1000, + }, + ] + } + + result = RuleEvaluator.evaluate_rule_conditions(self.mock_context, conditions_dict) + + self.assertTrue(result) + + def test_weekend_condition_evaluation(self): + """Test evaluation with timestamp-based weekend conditions.""" + # Create a mock timestamp instead of trying to modify datetime + mock_timestamp = Mock() + mock_timestamp.weekday.return_value = 5 + self.mock_context.timestamp = mock_timestamp + + conditions_dict = { + "field": "timestamp.weekday", + "operator": "in", + "value": [5, 6], # Weekend + } + + # We need to mock the _get_field_value since timestamp.weekday requires special handling + with patch.object(RuleEvaluator, "_get_field_value", return_value=5): + result = RuleEvaluator.evaluate_rule_conditions(self.mock_context, conditions_dict) + self.assertTrue(result) + + # === Tests for dict key lookup in _get_field_value === + + def test_get_field_value_dict_key_lookup(self): + """Test field resolver traverses dict keys (e.g. home_load.devices.boiler).""" + self.mock_context.home_load = Mock() + self.mock_context.home_load.devices = { + "boiler": Mock(forecast=Mock(total_energy=1500.0)), + } + + result = RuleEvaluator._get_field_value( + self.mock_context, "home_load.devices.boiler.forecast.total_energy" + ) + self.assertEqual(result, 1500.0) + + def test_get_field_value_dict_key_missing(self): + """Test field resolver returns None for missing dict key.""" + self.mock_context.home_load = Mock() + self.mock_context.home_load.devices = {"boiler": Mock()} + + result = RuleEvaluator._get_field_value( + self.mock_context, "home_load.devices.fridge.forecast.total_energy" + ) + self.assertIsNone(result) + + def test_get_field_value_none_intermediate(self): + """Test field resolver returns None when intermediate value is None.""" + self.mock_context.home_load = None + + result = RuleEvaluator._get_field_value( + self.mock_context, "home_load.devices.boiler" + ) + self.assertIsNone(result) + + def test_evaluate_condition_with_dict_path(self): + """Test end-to-end evaluation through dict key path.""" + self.mock_context.home_load = Mock() + self.mock_context.home_load.total_forecast = Mock() + self.mock_context.home_load.total_forecast.avg_power = 2800.0 + + conditions_dict = { + "field": "home_load.total_forecast.avg_power", + "operator": "gt", + "value": 2500, + } + + result = RuleEvaluator.evaluate_rule_conditions(self.mock_context, conditions_dict) + self.assertTrue(result) + + def test_evaluate_condition_device_dict_path(self): + """Test evaluation using device name in dict path.""" + self.mock_context.home_load = Mock() + self.mock_context.home_load.devices = { + "boiler": Mock(forecast=Mock(next_1h=Mock(peak_power=2200.0))), + } + + conditions_dict = { + "field": "home_load.devices.boiler.forecast.next_1h.peak_power", + "operator": "gt", + "value": 2000, + } + + result = RuleEvaluator.evaluate_rule_conditions(self.mock_context, conditions_dict) + self.assertTrue(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/core/tests/unit/adapters/infrastructure/test_in_memory_event_bus.py b/core/tests/unit/adapters/infrastructure/test_in_memory_event_bus.py new file mode 100644 index 0000000..069aee5 --- /dev/null +++ b/core/tests/unit/adapters/infrastructure/test_in_memory_event_bus.py @@ -0,0 +1,166 @@ +"""Unit tests for InMemoryEventBus.""" + +import asyncio +import unittest +from dataclasses import dataclass +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from edge_mining.adapters.infrastructure.event_bus.in_memory_event_bus import InMemoryEventBus +from edge_mining.domain.common import DomainEvent + + +@dataclass +class EventA(DomainEvent): + value: str = "" + + +@dataclass +class EventB(DomainEvent): + value: int = 0 + + +@pytest.fixture +def logger(): + mock = MagicMock() + mock.debug = MagicMock() + mock.warning = MagicMock() + return mock + + +@pytest.fixture +def bus(logger): + return InMemoryEventBus(logger) + + +@pytest.mark.asyncio +async def test_subscribe_and_publish(bus): + handler = AsyncMock(__qualname__="test_handler") + bus.subscribe(EventA, handler, blocking=True) + + event = EventA(value="hello") + await bus.publish(event) + + handler.assert_awaited_once_with(event) + + +@pytest.mark.asyncio +async def test_publish_no_handlers(bus): + """Publishing an event with no subscribers should not fail.""" + await bus.publish(EventA(value="ignored")) + + +@pytest.mark.asyncio +async def test_blocking_handler_awaited(bus): + """Blocking handler is awaited before publish returns.""" + call_order = [] + + async def handler(event): + call_order.append("handler") + + bus.subscribe(EventA, handler, blocking=True) + await bus.publish(EventA(value="test")) + call_order.append("after_publish") + + assert call_order == ["handler", "after_publish"] + + +@pytest.mark.asyncio +async def test_fire_and_forget_handler(bus): + """Fire-and-forget handler runs after publish returns.""" + completed = asyncio.Event() + + async def handler(event): + completed.set() + + bus.subscribe(EventA, handler, blocking=False) + await bus.publish(EventA(value="test")) + + # Give the fire-and-forget task a chance to run + await asyncio.wait_for(completed.wait(), timeout=1.0) + assert completed.is_set() + + +@pytest.mark.asyncio +async def test_blocking_before_fire_and_forget(bus): + """Blocking handlers execute before fire-and-forget handlers.""" + order = [] + + async def blocking_handler(event): + order.append("blocking") + + async def ff_handler(event): + order.append("fire_and_forget") + + bus.subscribe(EventA, blocking_handler, blocking=True) + bus.subscribe(EventA, ff_handler, blocking=False) + + await bus.publish(EventA(value="test")) + # Let fire-and-forget tasks complete + await asyncio.sleep(0.05) + + assert order == ["blocking", "fire_and_forget"] + + +@pytest.mark.asyncio +async def test_blocking_handler_exception_propagates(bus): + """Exceptions from blocking handlers propagate to the publisher.""" + + async def failing_handler(event): + raise ValueError("handler failed") + + bus.subscribe(EventA, failing_handler, blocking=True) + + with pytest.raises(ValueError, match="handler failed"): + await bus.publish(EventA(value="test")) + + +@pytest.mark.asyncio +async def test_fire_and_forget_handler_exception_caught(bus, logger): + """Exceptions from fire-and-forget handlers are caught and logged.""" + completed = asyncio.Event() + + async def failing_handler(event): + completed.set() + raise ValueError("ff handler failed") + + bus.subscribe(EventA, failing_handler, blocking=False) + await bus.publish(EventA(value="test")) + + await asyncio.wait_for(completed.wait(), timeout=1.0) + await asyncio.sleep(0.05) # Let _safe_execute finish + + logger.warning.assert_called_once() + assert "ff handler failed" in logger.warning.call_args[0][0] + + +@pytest.mark.asyncio +async def test_multiple_handlers(bus): + """Multiple handlers for the same event type are all invoked.""" + handler1 = AsyncMock(__qualname__="handler1") + handler2 = AsyncMock(__qualname__="handler2") + + bus.subscribe(EventA, handler1, blocking=True) + bus.subscribe(EventA, handler2, blocking=True) + + event = EventA(value="multi") + await bus.publish(event) + + handler1.assert_awaited_once_with(event) + handler2.assert_awaited_once_with(event) + + +@pytest.mark.asyncio +async def test_handler_receives_only_subscribed_type(bus): + """Handlers only receive events of the type they subscribed to.""" + handler_a = AsyncMock(__qualname__="handler_a") + handler_b = AsyncMock(__qualname__="handler_b") + + bus.subscribe(EventA, handler_a, blocking=True) + bus.subscribe(EventB, handler_b, blocking=True) + + await bus.publish(EventA(value="a")) + + handler_a.assert_awaited_once() + handler_b.assert_not_awaited() diff --git a/core/tests/unit/adapters/infrastructure/websocket/__init__.py b/core/tests/unit/adapters/infrastructure/websocket/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/tests/unit/adapters/infrastructure/websocket/test_websocket_manager.py b/core/tests/unit/adapters/infrastructure/websocket/test_websocket_manager.py new file mode 100644 index 0000000..cf6adda --- /dev/null +++ b/core/tests/unit/adapters/infrastructure/websocket/test_websocket_manager.py @@ -0,0 +1,328 @@ +"""Unit tests for WebSocketManager.""" + +import json +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from edge_mining.adapters.infrastructure.websocket.manager import WebSocketManager +from edge_mining.adapters.infrastructure.websocket.utils import WebSocketMessage +from edge_mining.application.events.configuration_events import ConfigurationUpdatedEvent +from edge_mining.domain.energy.events import EnergyStateSnapshotUpdatedEvent +from edge_mining.domain.miner.events import MinerStateChangedEvent +from edge_mining.domain.optimization_unit.events import RuleEngagedEvent +from edge_mining.domain.policy.events import DecisionalContextUpdatedEvent +from edge_mining.application.interfaces import EventBusInterface +from edge_mining.domain.common import EntityId, Watts +from edge_mining.domain.miner.common import MinerStatus +from edge_mining.domain.policy.common import MiningDecision + + +@pytest.fixture +def logger(): + mock = MagicMock() + mock.debug = MagicMock() + mock.info = MagicMock() + mock.warning = MagicMock() + mock.error = MagicMock() + return mock + + +@pytest.fixture +def mock_event_bus(): + bus = MagicMock(spec=EventBusInterface) + bus.subscribe = MagicMock() + return bus + + +@pytest.fixture +def manager(mock_event_bus, logger): + return WebSocketManager(event_bus=mock_event_bus, logger=logger) + + +def make_ws(): + """Create a mock WebSocket.""" + ws = AsyncMock() + ws.client = ("127.0.0.1", 8000) + ws.accept = AsyncMock() + ws.send_text = AsyncMock() + ws.send_json = AsyncMock() + ws.receive_json = AsyncMock() + return ws + + +# --- Event bus subscription --- + + +def test_subscribe_events_registers_all_handlers(mock_event_bus, logger): + """WebSocketManager should subscribe to all event types on construction.""" + manager = WebSocketManager(event_bus=mock_event_bus, logger=logger) + assert mock_event_bus.subscribe.call_count == 5 + subscribed_types = {call.args[0] for call in mock_event_bus.subscribe.call_args_list} + assert subscribed_types == { + ConfigurationUpdatedEvent, + RuleEngagedEvent, + MinerStateChangedEvent, + EnergyStateSnapshotUpdatedEvent, + DecisionalContextUpdatedEvent, + } + # All should be fire-and-forget + for call in mock_event_bus.subscribe.call_args_list: + assert call.kwargs.get("blocking") is False or call.args[2] is False + + +def test_available_topics_returns_all_registered_topics(mock_event_bus, logger): + """available_topics should list every topic declared by subdomain handlers.""" + manager = WebSocketManager(event_bus=mock_event_bus, logger=logger) + assert sorted(manager.available_topics) == [ + "config.updated", + "energy.state", + "miner.state", + "policy.context", + "rule.engaged", + ] + + +# --- Connection management --- + + +@pytest.mark.asyncio +async def test_connect(manager): + ws = make_ws() + await manager.connect(ws) + assert ws in manager._connections + ws.accept.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_disconnect(manager): + ws = make_ws() + await manager.connect(ws) + manager.disconnect(ws) + assert ws not in manager._connections + + +def test_disconnect_unknown_ws(manager): + ws = make_ws() + manager.disconnect(ws) # Should not raise + + +# --- Subscription --- + + +@pytest.mark.asyncio +async def test_subscribe(manager): + ws = make_ws() + await manager.connect(ws) + manager.subscribe(ws, ["energy.*", "miner.state"]) + assert manager._connections[ws] == {"energy.*", "miner.state"} + + +@pytest.mark.asyncio +async def test_unsubscribe(manager): + ws = make_ws() + await manager.connect(ws) + manager.subscribe(ws, ["energy.*", "miner.state"]) + manager.unsubscribe(ws, ["energy.*"]) + assert manager._connections[ws] == {"miner.state"} + + +@pytest.mark.asyncio +async def test_subscribe_not_connected(manager): + ws = make_ws() + manager.subscribe(ws, ["energy.*"]) # Should not raise + assert ws not in manager._connections + + +# --- Topic matching --- + + +def test_matches_exact(manager): + assert manager._matches({"energy.state"}, "energy.state") is True + assert manager._matches({"energy.state"}, "miner.state") is False + + +def test_matches_wildcard(manager): + assert manager._matches({"energy.*"}, "energy.state") is True + assert manager._matches({"energy.*"}, "miner.state") is False + + +def test_matches_star_all(manager): + assert manager._matches({"*"}, "energy.state") is True + assert manager._matches({"*"}, "miner.state") is True + assert manager._matches({"*"}, "config.updated") is True + + +# --- Broadcasting --- + + +@pytest.mark.asyncio +async def test_broadcast_sends_to_matching_subscriber(manager): + ws = make_ws() + await manager.connect(ws) + manager.subscribe(ws, ["energy.*"]) + + await manager.broadcast_message( + WebSocketMessage( + topic="energy.state", + payload={"optimization_unit_name": "Unit 1"}, + ) + ) + + ws.send_text.assert_awaited_once() + sent = json.loads(ws.send_text.call_args[0][0]) + assert sent["topic"] == "energy.state" + assert sent["payload"]["optimization_unit_name"] == "Unit 1" + + +@pytest.mark.asyncio +async def test_broadcast_skips_non_matching_subscriber(manager): + ws = make_ws() + await manager.connect(ws) + manager.subscribe(ws, ["miner.*"]) + + await manager.broadcast_message( + WebSocketMessage( + topic="energy.state", + payload={"optimization_unit_name": "Unit 1"}, + ) + ) + + ws.send_text.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_broadcast_skips_client_with_no_subscriptions(manager): + ws = make_ws() + await manager.connect(ws) + # No subscribe call + + await manager.broadcast_message(WebSocketMessage(topic="energy.state", payload={})) + + ws.send_text.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_broadcast_cleans_dead_connections(manager): + ws = make_ws() + ws.send_text.side_effect = Exception("Connection closed") + await manager.connect(ws) + manager.subscribe(ws, ["energy.*"]) + + await manager.broadcast_message(WebSocketMessage(topic="energy.state", payload={})) + + assert ws not in manager._connections + + +@pytest.mark.asyncio +async def test_broadcast_multiple_clients(manager): + ws1 = make_ws() + ws2 = make_ws() + ws3 = make_ws() + + await manager.connect(ws1) + await manager.connect(ws2) + await manager.connect(ws3) + + manager.subscribe(ws1, ["energy.*"]) + manager.subscribe(ws2, ["miner.*"]) + manager.subscribe(ws3, ["*"]) + + await manager.broadcast_message(WebSocketMessage(topic="energy.state", payload={})) + + ws1.send_text.assert_awaited_once() + ws2.send_text.assert_not_awaited() + ws3.send_text.assert_awaited_once() + + +# --- Subdomain handler broadcasting --- + + +@pytest.mark.asyncio +async def test_broadcast_rule_engaged_event(manager): + ws = make_ws() + await manager.connect(ws) + manager.subscribe(ws, ["rule.*"]) + + await manager.broadcast_message( + WebSocketMessage( + topic="rule.engaged", + payload={ + "optimization_unit_name": "Unit 1", + "policy_name": "Solar Policy", + "decision": "start_mining", + "miner_status": "OFF", + }, + ) + ) + + ws.send_text.assert_awaited_once() + sent = json.loads(ws.send_text.call_args[0][0]) + assert sent["topic"] == "rule.engaged" + + +@pytest.mark.asyncio +async def test_broadcast_miner_state_event(manager): + ws = make_ws() + await manager.connect(ws) + manager.subscribe(ws, ["miner.state"]) + + await manager.broadcast_message( + WebSocketMessage( + topic="miner.state", + payload={ + "miner_name": "Antminer", + "old_status": "off", + "new_status": "on", + }, + ) + ) + + ws.send_text.assert_awaited_once() + sent = json.loads(ws.send_text.call_args[0][0]) + assert sent["topic"] == "miner.state" + + +@pytest.mark.asyncio +async def test_broadcast_config_updated_event(manager): + ws = make_ws() + await manager.connect(ws) + manager.subscribe(ws, ["config.*"]) + + await manager.broadcast_message( + WebSocketMessage( + topic="config.updated", + payload={"entity_type": "", "entity_id": None, "action": ""}, + ) + ) + + ws.send_text.assert_awaited_once() + sent = json.loads(ws.send_text.call_args[0][0]) + assert sent["topic"] == "config.updated" + + +# --- get_topics --- + + +@pytest.mark.asyncio +async def test_get_topics_returns_available_topics(manager): + """Client sending {"get_topics": true} should receive the full list of available topics.""" + ws = make_ws() + ws.receive_json.side_effect = [{"get_topics": True}, Exception("done")] + await manager.connect(ws) + + await manager.handle_client_messages(ws) + + ws.send_json.assert_awaited_once_with( + { + "type": "available_topics", + "topics": [ + "config.updated", + "energy.state", + "miner.state", + "policy.context", + "rule.engaged", + ], + } + ) diff --git a/core/tests/unit/application/__init__.py b/core/tests/unit/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/tests/unit/application/events/__init__.py b/core/tests/unit/application/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/tests/unit/application/events/test_configuration_events.py b/core/tests/unit/application/events/test_configuration_events.py new file mode 100644 index 0000000..bb62520 --- /dev/null +++ b/core/tests/unit/application/events/test_configuration_events.py @@ -0,0 +1,60 @@ +"""Unit tests for ConfigurationUpdatedEvent.""" + +import unittest +import uuid + +from edge_mining.application.events.common import ConfigurationAction, ConfigurationUpdatedEventType +from edge_mining.application.events.configuration_events import ConfigurationUpdatedEvent +from edge_mining.domain.common import DomainEvent, EntityId + + +class TestConfigurationUpdatedEvent(unittest.TestCase): + """Test cases for ConfigurationUpdatedEvent.""" + + def test_inherits_from_domain_event(self): + event = ConfigurationUpdatedEvent() + self.assertIsInstance(event, DomainEvent) + + def test_creation_with_properties(self): + entity_id = EntityId(uuid.uuid4()) + event = ConfigurationUpdatedEvent( + entity_type=ConfigurationUpdatedEventType.ENERGY_MONITOR, + entity_id=entity_id, + action=ConfigurationAction.CREATED, + ) + self.assertEqual(event.entity_type, ConfigurationUpdatedEventType.ENERGY_MONITOR) + self.assertEqual(event.entity_id, entity_id) + self.assertEqual(event.action, ConfigurationAction.CREATED) + + def test_has_event_id_and_occurred_at(self): + event = ConfigurationUpdatedEvent() + self.assertIsNotNone(event.event_id) + self.assertIsNotNone(event.occurred_at) + + def test_event_type_property(self): + event = ConfigurationUpdatedEvent() + self.assertEqual(event.event_type, "ConfigurationUpdatedEvent") + + def test_to_dict_includes_all_fields(self): + entity_id = EntityId(uuid.uuid4()) + event = ConfigurationUpdatedEvent( + entity_type=ConfigurationUpdatedEventType.NOTIFIER, + entity_id=entity_id, + action=ConfigurationAction.REMOVED, + ) + result = event.to_dict() + self.assertEqual(result["entity_type"], ConfigurationUpdatedEventType.NOTIFIER) + self.assertEqual(result["action"], ConfigurationAction.REMOVED) + self.assertEqual(result["event_type"], "ConfigurationUpdatedEvent") + self.assertIn("event_id", result) + self.assertIn("occurred_at", result) + + def test_defaults(self): + event = ConfigurationUpdatedEvent() + self.assertEqual(event.entity_type, ConfigurationUpdatedEventType.UNKNOWN) + self.assertIsNone(event.entity_id) + self.assertEqual(event.action, ConfigurationAction.UNKNOWN) + + +if __name__ == "__main__": + unittest.main() diff --git a/core/tests/unit/application/events/test_energy_events.py b/core/tests/unit/application/events/test_energy_events.py new file mode 100644 index 0000000..9b2d29a --- /dev/null +++ b/core/tests/unit/application/events/test_energy_events.py @@ -0,0 +1,57 @@ +"""Unit tests for EnergyStateSnapshotUpdatedEvent.""" + +import unittest +import uuid + +from edge_mining.domain.energy.events import EnergyStateSnapshotUpdatedEvent +from edge_mining.domain.common import DomainEvent, EntityId + + +class TestEnergyStateSnapshotUpdatedEvent(unittest.TestCase): + """Test cases for EnergyStateSnapshotUpdatedEvent.""" + + def test_inherits_from_domain_event(self): + event = EnergyStateSnapshotUpdatedEvent() + self.assertIsInstance(event, DomainEvent) + + def test_creation_with_properties(self): + unit_id = EntityId(uuid.uuid4()) + source_id = EntityId(uuid.uuid4()) + event = EnergyStateSnapshotUpdatedEvent( + optimization_unit_id=unit_id, + optimization_unit_name="Solar Unit", + energy_source_id=source_id, + ) + self.assertEqual(event.optimization_unit_id, unit_id) + self.assertEqual(event.optimization_unit_name, "Solar Unit") + self.assertEqual(event.energy_source_id, source_id) + + def test_has_event_id_and_occurred_at(self): + event = EnergyStateSnapshotUpdatedEvent() + self.assertIsNotNone(event.event_id) + self.assertIsNotNone(event.occurred_at) + + def test_event_type_property(self): + event = EnergyStateSnapshotUpdatedEvent() + self.assertEqual(event.event_type, "EnergyStateSnapshotUpdatedEvent") + + def test_to_dict_includes_all_fields(self): + event = EnergyStateSnapshotUpdatedEvent( + optimization_unit_name="Unit 1", + ) + result = event.to_dict() + self.assertEqual(result["optimization_unit_name"], "Unit 1") + self.assertEqual(result["event_type"], "EnergyStateSnapshotUpdatedEvent") + self.assertIn("event_id", result) + self.assertIn("occurred_at", result) + + def test_defaults(self): + event = EnergyStateSnapshotUpdatedEvent() + self.assertIsNone(event.optimization_unit_id) + self.assertEqual(event.optimization_unit_name, "") + self.assertIsNone(event.energy_source_id) + self.assertIsNone(event.energy_state_snapshot) + + +if __name__ == "__main__": + unittest.main() diff --git a/core/tests/unit/application/events/test_miner_events.py b/core/tests/unit/application/events/test_miner_events.py new file mode 100644 index 0000000..0a9513d --- /dev/null +++ b/core/tests/unit/application/events/test_miner_events.py @@ -0,0 +1,63 @@ +"""Unit tests for MinerStateChangedEvent.""" + +import unittest +import uuid + +from edge_mining.domain.miner.events import MinerStateChangedEvent +from edge_mining.domain.common import DomainEvent, EntityId +from edge_mining.domain.miner.common import MinerStatus + + +class TestMinerStateChangedEvent(unittest.TestCase): + """Test cases for MinerStateChangedEvent.""" + + def test_inherits_from_domain_event(self): + event = MinerStateChangedEvent() + self.assertIsInstance(event, DomainEvent) + + def test_creation_with_properties(self): + miner_id = EntityId(uuid.uuid4()) + event = MinerStateChangedEvent( + miner_id=miner_id, + miner_name="Antminer S19", + old_status=MinerStatus.OFF, + new_status=MinerStatus.ON, + ) + self.assertEqual(event.miner_id, miner_id) + self.assertEqual(event.miner_name, "Antminer S19") + self.assertEqual(event.old_status, MinerStatus.OFF) + self.assertEqual(event.new_status, MinerStatus.ON) + + def test_has_event_id_and_occurred_at(self): + event = MinerStateChangedEvent() + self.assertIsNotNone(event.event_id) + self.assertIsNotNone(event.occurred_at) + + def test_event_type_property(self): + event = MinerStateChangedEvent() + self.assertEqual(event.event_type, "MinerStateChangedEvent") + + def test_to_dict_includes_all_fields(self): + miner_id = EntityId(uuid.uuid4()) + event = MinerStateChangedEvent( + miner_id=miner_id, + miner_name="Test Miner", + old_status=MinerStatus.ON, + new_status=MinerStatus.OFF, + ) + result = event.to_dict() + self.assertEqual(result["miner_name"], "Test Miner") + self.assertEqual(result["event_type"], "MinerStateChangedEvent") + self.assertIn("event_id", result) + self.assertIn("occurred_at", result) + + def test_defaults(self): + event = MinerStateChangedEvent() + self.assertIsNone(event.miner_id) + self.assertEqual(event.miner_name, "") + self.assertIsNone(event.old_status) + self.assertIsNone(event.new_status) + + +if __name__ == "__main__": + unittest.main() diff --git a/core/tests/unit/application/events/test_optimization_events.py b/core/tests/unit/application/events/test_optimization_events.py new file mode 100644 index 0000000..1dedffe --- /dev/null +++ b/core/tests/unit/application/events/test_optimization_events.py @@ -0,0 +1,74 @@ +"""Unit tests for RuleEngagedEvent.""" + +import unittest +import uuid + +from edge_mining.domain.optimization_unit.events import RuleEngagedEvent +from edge_mining.domain.common import DomainEvent, EntityId +from edge_mining.domain.policy.common import MiningDecision + + +class TestRuleEngagedEvent(unittest.TestCase): + """Test cases for RuleEngagedEvent.""" + + def test_inherits_from_domain_event(self): + event = RuleEngagedEvent() + self.assertIsInstance(event, DomainEvent) + + def test_creation_with_properties(self): + unit_id = EntityId(uuid.uuid4()) + policy_id = EntityId(uuid.uuid4()) + miner_id = EntityId(uuid.uuid4()) + event = RuleEngagedEvent( + optimization_unit_id=unit_id, + optimization_unit_name="Unit 1", + policy_id=policy_id, + policy_name="Solar Policy", + miner_id=miner_id, + decision=MiningDecision.START_MINING, + miner_status="OFF", + ) + self.assertEqual(event.optimization_unit_id, unit_id) + self.assertEqual(event.optimization_unit_name, "Unit 1") + self.assertEqual(event.policy_id, policy_id) + self.assertEqual(event.policy_name, "Solar Policy") + self.assertEqual(event.miner_id, miner_id) + self.assertEqual(event.decision, MiningDecision.START_MINING) + self.assertEqual(event.miner_status, "OFF") + + def test_has_event_id_and_occurred_at(self): + event = RuleEngagedEvent() + self.assertIsNotNone(event.event_id) + self.assertIsNotNone(event.occurred_at) + + def test_event_type_property(self): + event = RuleEngagedEvent() + self.assertEqual(event.event_type, "RuleEngagedEvent") + + def test_to_dict_includes_all_fields(self): + unit_id = EntityId(uuid.uuid4()) + event = RuleEngagedEvent( + optimization_unit_id=unit_id, + optimization_unit_name="Unit 1", + decision=MiningDecision.STOP_MINING, + miner_status="ON", + ) + result = event.to_dict() + self.assertEqual(result["optimization_unit_name"], "Unit 1") + self.assertEqual(result["event_type"], "RuleEngagedEvent") + self.assertIn("event_id", result) + self.assertIn("occurred_at", result) + + def test_defaults(self): + event = RuleEngagedEvent() + self.assertIsNone(event.optimization_unit_id) + self.assertEqual(event.optimization_unit_name, "") + self.assertIsNone(event.policy_id) + self.assertEqual(event.policy_name, "") + self.assertIsNone(event.miner_id) + self.assertIsNone(event.decision) + self.assertEqual(event.miner_status, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/core/tests/unit/application/events/test_policy_events.py b/core/tests/unit/application/events/test_policy_events.py new file mode 100644 index 0000000..83e9517 --- /dev/null +++ b/core/tests/unit/application/events/test_policy_events.py @@ -0,0 +1,65 @@ +"""Unit tests for DecisionalContextUpdatedEvent.""" + +import unittest +import uuid + +from edge_mining.domain.policy.events import DecisionalContextUpdatedEvent +from edge_mining.domain.common import DomainEvent, EntityId +from edge_mining.domain.policy.value_objects import DecisionalContext + + +class TestDecisionalContextUpdatedEvent(unittest.TestCase): + """Test cases for DecisionalContextUpdatedEvent.""" + + def test_inherits_from_domain_event(self): + event = DecisionalContextUpdatedEvent() + self.assertIsInstance(event, DomainEvent) + + def test_creation_with_properties(self): + unit_id = EntityId(uuid.uuid4()) + miner_id_1 = EntityId(uuid.uuid4()) + miner_id_2 = EntityId(uuid.uuid4()) + context = DecisionalContext( + energy_source=None, + energy_state=None, + forecast=None, + home_load=None, + mining_performance=None, + ) + event = DecisionalContextUpdatedEvent( + optimization_unit_id=unit_id, + optimization_unit_name="Solar Unit", + context=context, + target_miner_ids=[miner_id_1, miner_id_2], + ) + self.assertEqual(event.optimization_unit_id, unit_id) + self.assertEqual(event.optimization_unit_name, "Solar Unit") + self.assertIs(event.context, context) + self.assertEqual(event.target_miner_ids, [miner_id_1, miner_id_2]) + + def test_has_event_id_and_occurred_at(self): + event = DecisionalContextUpdatedEvent() + self.assertIsNotNone(event.event_id) + self.assertIsNotNone(event.occurred_at) + + def test_event_type_property(self): + event = DecisionalContextUpdatedEvent() + self.assertEqual(event.event_type, "DecisionalContextUpdatedEvent") + + def test_defaults(self): + event = DecisionalContextUpdatedEvent() + self.assertIsNone(event.optimization_unit_id) + self.assertEqual(event.optimization_unit_name, "") + self.assertIsNone(event.context) + self.assertEqual(event.target_miner_ids, []) + + def test_target_miner_ids_default_is_independent(self): + """Each instance should get its own list.""" + event1 = DecisionalContextUpdatedEvent() + event2 = DecisionalContextUpdatedEvent() + event1.target_miner_ids.append(EntityId(uuid.uuid4())) + self.assertEqual(len(event2.target_miner_ids), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/core/tests/unit/application/services/__init__.py b/core/tests/unit/application/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/tests/unit/application/services/test_configuration_event_flow.py b/core/tests/unit/application/services/test_configuration_event_flow.py new file mode 100644 index 0000000..f23fc43 --- /dev/null +++ b/core/tests/unit/application/services/test_configuration_event_flow.py @@ -0,0 +1,285 @@ +"""Integration tests for ConfigurationService → EventBus → AdapterService flow.""" + +import uuid +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from edge_mining.adapters.infrastructure.event_bus.in_memory_event_bus import InMemoryEventBus +from edge_mining.application.events.common import ConfigurationAction, ConfigurationUpdatedEventType +from edge_mining.application.events.configuration_events import ConfigurationUpdatedEvent +from edge_mining.application.services.adapter_service import AdapterService +from edge_mining.application.services.configuration_service import ConfigurationService +from edge_mining.domain.common import EntityId +from edge_mining.shared.infrastructure import PersistenceSettings + + +def make_entity_id(): + return EntityId(uuid.uuid4()) + + +@pytest.fixture +def logger(): + mock = MagicMock() + mock.debug = MagicMock() + mock.info = MagicMock() + mock.warning = MagicMock() + mock.error = MagicMock() + return mock + + +@pytest.fixture +def mock_event_bus(): + bus = AsyncMock() + bus.publish = AsyncMock() + return bus + + +@pytest.fixture +def mock_persistence(): + """Create a mock PersistenceSettings with all repos.""" + ps = MagicMock(spec=PersistenceSettings) + + # Each repo mock needs get_by_id, add, update, remove, get_all + for repo_name in [ + "external_service_repo", + "energy_source_repo", + "energy_monitor_repo", + "miner_repo", + "miner_controller_repo", + "policy_repo", + "optimization_unit_repo", + "forecast_provider_repo", + "energy_load_forecast_provider_repo", + "energy_load_history_provider_repo", + "home_profile_repo", + "mining_performance_tracker_repo", + "notifier_repo", + "settings_repo", + ]: + repo = MagicMock() + repo.get_all.return_value = [] + repo.get_by_id.return_value = None + setattr(ps, repo_name, repo) + + return ps + + +@pytest.fixture +def config_service(mock_persistence, mock_event_bus, logger): + return ConfigurationService( + persistence_settings=mock_persistence, + event_bus=mock_event_bus, + logger=logger, + ) + + +# --- Test that ConfigurationService publishes events --- + + +@pytest.mark.asyncio +async def test_create_external_service_publishes_event(config_service, mock_event_bus): + """Creating an external service should publish a ConfigurationUpdatedEvent.""" + from edge_mining.shared.external_services.common import ExternalServiceAdapter + + mock_config = MagicMock() + mock_config.is_valid.return_value = True + + service = await config_service.create_external_service( + name="Test HA", + adapter_type=ExternalServiceAdapter.HOME_ASSISTANT_API, + config=mock_config, + ) + + mock_event_bus.publish.assert_awaited_once() + event = mock_event_bus.publish.call_args[0][0] + assert isinstance(event, ConfigurationUpdatedEvent) + assert event.entity_type == ConfigurationUpdatedEventType.EXTERNAL_SERVICE + assert event.action == ConfigurationAction.CREATED + assert event.entity_id == service.id + + +@pytest.mark.asyncio +async def test_create_energy_monitor_publishes_event(config_service, mock_event_bus): + """Creating an energy monitor should publish a ConfigurationUpdatedEvent.""" + from edge_mining.domain.energy.common import EnergyMonitorAdapter + + mock_config = MagicMock() + mock_config.is_valid.return_value = True + + monitor = await config_service.create_energy_monitor( + name="Solar Monitor", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=mock_config, + ) + + mock_event_bus.publish.assert_awaited_once() + event = mock_event_bus.publish.call_args[0][0] + assert isinstance(event, ConfigurationUpdatedEvent) + assert event.entity_type == ConfigurationUpdatedEventType.ENERGY_MONITOR + assert event.action == ConfigurationAction.CREATED + + +@pytest.mark.asyncio +async def test_update_notifier_publishes_event(config_service, mock_event_bus, mock_persistence): + """Updating a notifier should publish a ConfigurationUpdatedEvent.""" + from edge_mining.domain.notification.common import NotificationAdapter + from edge_mining.domain.notification.entities import Notifier + + notifier_id = make_entity_id() + existing = Notifier( + id=notifier_id, + name="Old", + adapter_type=NotificationAdapter.DUMMY, + config=MagicMock(), + ) + mock_persistence.notifier_repo.get_by_id.return_value = existing + + mock_config = MagicMock() + mock_config.is_valid.return_value = True + + await config_service.update_notifier( + notifier_id=notifier_id, + name="New", + config=mock_config, + ) + + mock_event_bus.publish.assert_awaited_once() + event = mock_event_bus.publish.call_args[0][0] + assert event.entity_type == ConfigurationUpdatedEventType.NOTIFIER + assert event.action == ConfigurationAction.UPDATED + + +@pytest.mark.asyncio +async def test_remove_miner_controller_publishes_event(config_service, mock_event_bus, mock_persistence): + """Removing a miner controller should publish a ConfigurationUpdatedEvent.""" + from edge_mining.domain.miner.common import MinerControllerAdapter + from edge_mining.domain.miner.entities import MinerController + + ctrl_id = make_entity_id() + existing = MinerController( + id=ctrl_id, + name="Ctrl", + adapter_type=MinerControllerAdapter.DUMMY, + config=MagicMock(), + ) + mock_persistence.miner_controller_repo.get_by_id.return_value = existing + mock_persistence.miner_repo.get_by_controller_id.return_value = [] + + await config_service.remove_miner_controller(controller_id=ctrl_id) + + mock_event_bus.publish.assert_awaited_once() + event = mock_event_bus.publish.call_args[0][0] + assert event.entity_type == ConfigurationUpdatedEventType.MINER_CONTROLLER + assert event.action == ConfigurationAction.REMOVED + assert event.entity_id == ctrl_id + + +# --- Test end-to-end flow with real InMemoryEventBus --- + + +@pytest.mark.asyncio +async def test_end_to_end_cache_invalidation(mock_persistence, logger): + """End-to-end: creating an energy monitor triggers cache invalidation in AdapterService.""" + from edge_mining.domain.energy.common import EnergyMonitorAdapter + + event_bus = InMemoryEventBus(logger) + + adapter_service = AdapterService( + energy_monitor_repo=mock_persistence.energy_monitor_repo, + miner_controller_repo=mock_persistence.miner_controller_repo, + miner_repo=mock_persistence.miner_repo, + notifier_repo=mock_persistence.notifier_repo, + forecast_provider_repo=mock_persistence.forecast_provider_repo, + energy_load_forecast_provider_repo=mock_persistence.energy_load_forecast_provider_repo, + energy_load_history_provider_repo=mock_persistence.energy_load_history_provider_repo, + home_load_history_repo=MagicMock(), + mining_performance_tracker_repo=mock_persistence.mining_performance_tracker_repo, + external_service_repo=mock_persistence.external_service_repo, + event_bus=event_bus, + logger=logger, + ) + + config_service = ConfigurationService( + persistence_settings=mock_persistence, + event_bus=event_bus, + logger=logger, + ) + + # Pre-populate adapter cache with a fake entry + fake_id = make_entity_id() + adapter_service._instance_cache[fake_id] = MagicMock() + assert fake_id in adapter_service._instance_cache + + mock_config = MagicMock() + mock_config.is_valid.return_value = True + + # Create an energy monitor - this should trigger cache invalidation + monitor = await config_service.create_energy_monitor( + name="Test Monitor", + adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR, + config=mock_config, + ) + + # The monitor's own ID should have been popped (even though it was just created, + # the handler tries to pop it from instance_cache) + # The fake_id should still be there since it's a different entity + assert fake_id in adapter_service._instance_cache + + +@pytest.mark.asyncio +async def test_external_service_update_clears_all_instance_cache(mock_persistence, logger): + """Updating an external service should clear the entire instance cache.""" + from edge_mining.shared.external_services.common import ExternalServiceAdapter + from edge_mining.shared.external_services.entities import ExternalService + + event_bus = InMemoryEventBus(logger) + + adapter_service = AdapterService( + energy_monitor_repo=mock_persistence.energy_monitor_repo, + miner_controller_repo=mock_persistence.miner_controller_repo, + miner_repo=mock_persistence.miner_repo, + notifier_repo=mock_persistence.notifier_repo, + forecast_provider_repo=mock_persistence.forecast_provider_repo, + energy_load_forecast_provider_repo=mock_persistence.energy_load_forecast_provider_repo, + energy_load_history_provider_repo=mock_persistence.energy_load_history_provider_repo, + home_load_history_repo=MagicMock(), + mining_performance_tracker_repo=mock_persistence.mining_performance_tracker_repo, + external_service_repo=mock_persistence.external_service_repo, + event_bus=event_bus, + logger=logger, + ) + + config_service = ConfigurationService( + persistence_settings=mock_persistence, + event_bus=event_bus, + logger=logger, + ) + + # Pre-populate caches + svc_id = make_entity_id() + adapter_id = make_entity_id() + adapter_service._service_cache[svc_id] = MagicMock() + adapter_service._instance_cache[adapter_id] = MagicMock() + + # Stub the repo to return existing service + mock_config = MagicMock() + mock_config.is_valid.return_value = True + existing = ExternalService( + id=svc_id, + name="HA", + adapter_type=ExternalServiceAdapter.HOME_ASSISTANT_API, + config=mock_config, + ) + mock_persistence.external_service_repo.get_by_id.return_value = existing + + await config_service.update_external_service( + service_id=svc_id, + name="HA Updated", + config=mock_config, + ) + + # Service cache should have the entry removed + assert svc_id not in adapter_service._service_cache + # Instance cache should be fully cleared (conservative approach for external_service) + assert len(adapter_service._instance_cache) == 0 diff --git a/core/tests/unit/application/services/test_configuration_service_performance.py b/core/tests/unit/application/services/test_configuration_service_performance.py new file mode 100644 index 0000000..143aa35 --- /dev/null +++ b/core/tests/unit/application/services/test_configuration_service_performance.py @@ -0,0 +1,349 @@ +"""Unit tests for Mining Performance Tracker CRUD on ConfigurationService.""" + +import uuid +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from edge_mining.application.events.common import ( + ConfigurationAction, + ConfigurationUpdatedEventType, +) +from edge_mining.application.events.configuration_events import ConfigurationUpdatedEvent +from edge_mining.application.services.configuration_service import ConfigurationService +from edge_mining.domain.common import EntityId +from edge_mining.domain.optimization_unit.aggregate_roots import EnergyOptimizationUnit +from edge_mining.domain.performance.common import MiningPerformanceTrackerAdapter +from edge_mining.domain.performance.entities import MiningPerformanceTracker +from edge_mining.domain.performance.exceptions import ( + MiningPerformanceTrackerConfigurationError, + MiningPerformanceTrackerNotFoundError, +) +from edge_mining.shared.adapter_configs.performance import ( + MiningPerformanceTrackerBraiinsPoolConfig, + MiningPerformanceTrackerDummyConfig, + MiningPerformanceTrackerOceanConfig, +) +from edge_mining.shared.infrastructure import PersistenceSettings + + +def make_entity_id() -> EntityId: + return EntityId(uuid.uuid4()) + + +@pytest.fixture +def logger(): + mock = MagicMock() + mock.debug = MagicMock() + mock.info = MagicMock() + mock.warning = MagicMock() + mock.error = MagicMock() + return mock + + +@pytest.fixture +def mock_event_bus(): + bus = AsyncMock() + bus.publish = AsyncMock() + return bus + + +@pytest.fixture +def mock_persistence(): + ps = MagicMock(spec=PersistenceSettings) + for repo_name in [ + "external_service_repo", + "energy_source_repo", + "energy_monitor_repo", + "miner_repo", + "miner_controller_repo", + "policy_repo", + "optimization_unit_repo", + "forecast_provider_repo", + "energy_load_forecast_provider_repo", + "energy_load_history_provider_repo", + "home_profile_repo", + "mining_performance_tracker_repo", + "notifier_repo", + "settings_repo", + ]: + repo = MagicMock() + repo.get_all.return_value = [] + repo.get_by_id.return_value = None + setattr(ps, repo_name, repo) + return ps + + +@pytest.fixture +def service(mock_persistence, mock_event_bus, logger): + return ConfigurationService( + persistence_settings=mock_persistence, + event_bus=mock_event_bus, + logger=logger, + ) + + +# --- add ---------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_add_dummy_tracker_persists_and_publishes_event(service, mock_persistence, mock_event_bus): + config = MiningPerformanceTrackerDummyConfig() + + tracker = await service.add_mining_performance_tracker( + name="dummy-1", + adapter_type=MiningPerformanceTrackerAdapter.DUMMY, + config=config, + ) + + assert isinstance(tracker, MiningPerformanceTracker) + assert tracker.name == "dummy-1" + assert tracker.adapter_type == MiningPerformanceTrackerAdapter.DUMMY + assert tracker.config == config + + mock_persistence.mining_performance_tracker_repo.add.assert_called_once_with(tracker) + mock_event_bus.publish.assert_awaited_once() + event = mock_event_bus.publish.call_args[0][0] + assert isinstance(event, ConfigurationUpdatedEvent) + assert event.entity_type == ConfigurationUpdatedEventType.MINING_PERFORMANCE_TRACKER + assert event.action == ConfigurationAction.CREATED + assert event.entity_id == tracker.id + + +@pytest.mark.asyncio +async def test_add_ocean_tracker_with_valid_config(service, mock_persistence): + config = MiningPerformanceTrackerOceanConfig(bitcoin_address="bc1qabc") + + tracker = await service.add_mining_performance_tracker( + name="ocean-1", + adapter_type=MiningPerformanceTrackerAdapter.OCEAN, + config=config, + ) + assert tracker.adapter_type == MiningPerformanceTrackerAdapter.OCEAN + mock_persistence.mining_performance_tracker_repo.add.assert_called_once() + + +@pytest.mark.asyncio +async def test_add_rejects_invalid_config(service, mock_event_bus): + # Ocean config with empty address is invalid + bad = MiningPerformanceTrackerOceanConfig(bitcoin_address="") + + with pytest.raises(MiningPerformanceTrackerConfigurationError): + await service.add_mining_performance_tracker( + name="bad", + adapter_type=MiningPerformanceTrackerAdapter.OCEAN, + config=bad, + ) + + mock_event_bus.publish.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_add_rejects_none_config(service): + with pytest.raises(MiningPerformanceTrackerConfigurationError): + await service.add_mining_performance_tracker( + name="missing-config", + adapter_type=MiningPerformanceTrackerAdapter.DUMMY, + config=None, + ) + + +@pytest.mark.asyncio +async def test_add_rejects_wrong_adapter_config_pairing(service): + # Pass a Dummy config with the Ocean adapter type: is_valid returns False + wrong = MiningPerformanceTrackerDummyConfig() + with pytest.raises(MiningPerformanceTrackerConfigurationError): + await service.add_mining_performance_tracker( + name="cross", + adapter_type=MiningPerformanceTrackerAdapter.OCEAN, + config=wrong, + ) + + +# --- get / list --------------------------------------------------------------- + + +def test_get_returns_tracker_when_present(service, mock_persistence): + tracker_id = make_entity_id() + tracker = MiningPerformanceTracker( + id=tracker_id, + name="t1", + adapter_type=MiningPerformanceTrackerAdapter.DUMMY, + config=MiningPerformanceTrackerDummyConfig(), + ) + mock_persistence.mining_performance_tracker_repo.get_by_id.return_value = tracker + + assert service.get_mining_performance_tracker(tracker_id) is tracker + + +def test_get_raises_when_missing(service, mock_persistence): + mock_persistence.mining_performance_tracker_repo.get_by_id.return_value = None + with pytest.raises(MiningPerformanceTrackerNotFoundError): + service.get_mining_performance_tracker(make_entity_id()) + + +def test_list_delegates_to_repo(service, mock_persistence): + sample = [MiningPerformanceTracker(name="a"), MiningPerformanceTracker(name="b")] + mock_persistence.mining_performance_tracker_repo.get_all.return_value = sample + + assert service.list_mining_performance_trackers() == sample + + +# --- update ------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_update_changes_fields_and_publishes(service, mock_persistence, mock_event_bus): + tracker_id = make_entity_id() + tracker = MiningPerformanceTracker( + id=tracker_id, + name="old", + adapter_type=MiningPerformanceTrackerAdapter.OCEAN, + config=MiningPerformanceTrackerOceanConfig(bitcoin_address="bc1qold"), + ) + mock_persistence.mining_performance_tracker_repo.get_by_id.return_value = tracker + + new_config = MiningPerformanceTrackerOceanConfig(bitcoin_address="bc1qnew") + updated = await service.update_mining_performance_tracker( + tracker_id=tracker_id, + name="new", + config=new_config, + ) + + assert updated.name == "new" + assert updated.config is new_config + mock_persistence.mining_performance_tracker_repo.update.assert_called_once_with(tracker) + + mock_event_bus.publish.assert_awaited_once() + event = mock_event_bus.publish.call_args[0][0] + assert event.entity_type == ConfigurationUpdatedEventType.MINING_PERFORMANCE_TRACKER + assert event.action == ConfigurationAction.UPDATED + assert event.entity_id == tracker_id + + +@pytest.mark.asyncio +async def test_update_missing_tracker_raises(service, mock_persistence): + mock_persistence.mining_performance_tracker_repo.get_by_id.return_value = None + with pytest.raises(MiningPerformanceTrackerNotFoundError): + await service.update_mining_performance_tracker( + tracker_id=make_entity_id(), + name="x", + config=MiningPerformanceTrackerDummyConfig(), + ) + + +# --- remove + unlink ---------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_remove_unlinks_from_optimization_units_and_publishes(service, mock_persistence, mock_event_bus): + tracker_id = make_entity_id() + other_tracker_id = make_entity_id() + tracker = MiningPerformanceTracker( + id=tracker_id, + name="to-remove", + adapter_type=MiningPerformanceTrackerAdapter.DUMMY, + config=MiningPerformanceTrackerDummyConfig(), + ) + unit_linked = EnergyOptimizationUnit(name="linked") + unit_linked.performance_tracker_id = tracker_id + unit_other = EnergyOptimizationUnit(name="other") + unit_other.performance_tracker_id = other_tracker_id + unit_none = EnergyOptimizationUnit(name="none") + + mock_persistence.mining_performance_tracker_repo.get_by_id.return_value = tracker + mock_persistence.optimization_unit_repo.get_all.return_value = [unit_linked, unit_other, unit_none] + + removed = await service.remove_mining_performance_tracker(tracker_id) + + assert removed is tracker + assert unit_linked.performance_tracker_id is None + assert unit_other.performance_tracker_id == other_tracker_id + assert unit_none.performance_tracker_id is None + mock_persistence.optimization_unit_repo.update.assert_called_once_with(unit_linked) + mock_persistence.mining_performance_tracker_repo.remove.assert_called_once_with(tracker_id) + + mock_event_bus.publish.assert_awaited_once() + event = mock_event_bus.publish.call_args[0][0] + assert event.entity_type == ConfigurationUpdatedEventType.MINING_PERFORMANCE_TRACKER + assert event.action == ConfigurationAction.REMOVED + assert event.entity_id == tracker_id + + +@pytest.mark.asyncio +async def test_remove_missing_tracker_raises(service, mock_persistence): + mock_persistence.mining_performance_tracker_repo.get_by_id.return_value = None + with pytest.raises(MiningPerformanceTrackerNotFoundError): + await service.remove_mining_performance_tracker(make_entity_id()) + + +@pytest.mark.asyncio +async def test_unlink_updates_only_linked_units(service, mock_persistence): + tracker_id = make_entity_id() + unit_a = EnergyOptimizationUnit(name="a") + unit_a.performance_tracker_id = tracker_id + unit_b = EnergyOptimizationUnit(name="b") + unit_b.performance_tracker_id = None + mock_persistence.optimization_unit_repo.get_all.return_value = [unit_a, unit_b] + + await service.unlink_mining_performance_tracker(tracker_id) + + assert unit_a.performance_tracker_id is None + assert unit_b.performance_tracker_id is None + mock_persistence.optimization_unit_repo.update.assert_called_once_with(unit_a) + + +# --- check -------------------------------------------------------------------- + + +def test_check_accepts_valid_tracker(service): + tracker = MiningPerformanceTracker( + name="ok", + adapter_type=MiningPerformanceTrackerAdapter.OCEAN, + config=MiningPerformanceTrackerOceanConfig(bitcoin_address="bc1qok"), + ) + assert service.check_mining_performance_tracker(tracker) is True + + +def test_check_rejects_missing_config(service): + tracker = MiningPerformanceTracker( + name="bad", + adapter_type=MiningPerformanceTrackerAdapter.DUMMY, + config=None, + ) + with pytest.raises(MiningPerformanceTrackerConfigurationError): + service.check_mining_performance_tracker(tracker) + + +def test_check_rejects_missing_external_service(service, mock_persistence): + mock_persistence.external_service_repo.get_by_id.return_value = None + tracker = MiningPerformanceTracker( + name="bad", + adapter_type=MiningPerformanceTrackerAdapter.DUMMY, + config=MiningPerformanceTrackerDummyConfig(), + external_service_id=make_entity_id(), + ) + from edge_mining.shared.external_services.exceptions import ExternalServiceNotFoundError + + with pytest.raises(ExternalServiceNotFoundError): + service.check_mining_performance_tracker(tracker) + + +# --- helpers ------------------------------------------------------------------ + + +def test_get_config_by_type_returns_registered_class(service): + result = service.get_mining_performance_tracker_config_by_type(MiningPerformanceTrackerAdapter.OCEAN) + assert result is MiningPerformanceTrackerOceanConfig + + +def test_get_config_by_type_for_braiins(service): + result = service.get_mining_performance_tracker_config_by_type(MiningPerformanceTrackerAdapter.BRAIINS_POOL) + assert result is MiningPerformanceTrackerBraiinsPoolConfig + + +def test_external_service_adapter_is_none_for_all_current_trackers(service): + for adapter_type in MiningPerformanceTrackerAdapter: + assert ( + service.get_mining_performance_tracker_external_service_adapter(adapter_type) is None + ) diff --git a/core/tests/unit/application/services/test_home_load_history_service.py b/core/tests/unit/application/services/test_home_load_history_service.py new file mode 100644 index 0000000..535bf3f --- /dev/null +++ b/core/tests/unit/application/services/test_home_load_history_service.py @@ -0,0 +1,79 @@ +"""Unit tests for HomeLoadHistoryService.get_device_history.""" + +import uuid +from datetime import datetime, timedelta +from unittest.mock import MagicMock + +import pytest + +from edge_mining.application.services.home_load_history_service import HomeLoadHistoryService +from edge_mining.domain.common import EntityId, Timestamp, Watts +from edge_mining.domain.home_load.value_objects import HomeLoadPowerPoint + + +@pytest.fixture +def mock_home_loads_repo(): + return MagicMock() + + +@pytest.fixture +def mock_history_repo(): + return MagicMock() + + +@pytest.fixture +def mock_adapter_service(): + return MagicMock() + + +@pytest.fixture +def logger(): + mock = MagicMock() + mock.debug = MagicMock() + mock.info = MagicMock() + mock.warning = MagicMock() + mock.error = MagicMock() + return mock + + +@pytest.fixture +def service(mock_home_loads_repo, mock_history_repo, mock_adapter_service, logger): + return HomeLoadHistoryService( + home_loads_repo=mock_home_loads_repo, + home_load_history_repo=mock_history_repo, + adapter_service=mock_adapter_service, + event_bus=None, + logger=logger, + ) + + +class TestGetDeviceHistory: + def test_returns_power_points_from_repo(self, service, mock_history_repo): + device_id = EntityId(uuid.uuid4()) + now = datetime.now() + start = Timestamp(now - timedelta(hours=24)) + end = Timestamp(now) + + expected_points = [ + HomeLoadPowerPoint(timestamp=Timestamp(now - timedelta(hours=2)), power=Watts(100.0)), + HomeLoadPowerPoint(timestamp=Timestamp(now - timedelta(hours=1)), power=Watts(150.0)), + ] + mock_history_repo.get_power_points.return_value = expected_points + + result = service.get_device_history(device_id, start, end) + + assert result == expected_points + mock_history_repo.get_power_points.assert_called_once_with(device_id, start, end) + + def test_returns_empty_list_when_no_data(self, service, mock_history_repo): + device_id = EntityId(uuid.uuid4()) + now = datetime.now() + start = Timestamp(now - timedelta(hours=24)) + end = Timestamp(now) + + mock_history_repo.get_power_points.return_value = [] + + result = service.get_device_history(device_id, start, end) + + assert result == [] + mock_history_repo.get_power_points.assert_called_once_with(device_id, start, end) diff --git a/core/tests/unit/application/services/test_load_forecast_training_service.py b/core/tests/unit/application/services/test_load_forecast_training_service.py new file mode 100644 index 0000000..e2ea813 --- /dev/null +++ b/core/tests/unit/application/services/test_load_forecast_training_service.py @@ -0,0 +1,123 @@ +"""Unit tests for LoadForecastModelTrainingService.train_device and get_models.""" + +import uuid +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from edge_mining.application.services.load_forecast_training_service import LoadForecastModelTrainingService +from edge_mining.domain.common import EntityId +from edge_mining.domain.home_load.aggregate_roots import HomeLoadsProfile +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter +from edge_mining.domain.home_load.entities import LoadConsumptionModel, LoadDevice + + +@pytest.fixture +def mock_home_loads_repo(): + return MagicMock() + + +@pytest.fixture +def mock_history_repo(): + return MagicMock() + + +@pytest.fixture +def mock_model_repo(): + return MagicMock() + + +@pytest.fixture +def logger(): + mock = MagicMock() + mock.debug = MagicMock() + mock.info = MagicMock() + mock.warning = MagicMock() + mock.error = MagicMock() + return mock + + +@pytest.fixture +def service(mock_home_loads_repo, mock_history_repo, mock_model_repo, logger): + return LoadForecastModelTrainingService( + home_loads_repo=mock_home_loads_repo, + history_repo=mock_history_repo, + model_repo=mock_model_repo, + logger=logger, + ) + + +@pytest.fixture +def device_id() -> EntityId: + return EntityId(uuid.uuid4()) + + +@pytest.fixture +def profile_with_device(device_id): + device = LoadDevice(id=device_id, name="Dishwasher", enabled=True) + profile = HomeLoadsProfile(name="Test Home", devices=[device]) + return profile + + +class TestTrainDevice: + @pytest.mark.asyncio + async def test_train_device_calls_train_for_device(self, service, mock_home_loads_repo, device_id, profile_with_device): + mock_home_loads_repo.get_all.return_value = [profile_with_device] + + with patch.object(service, "_train_for_device", new_callable=AsyncMock) as mock_train: + await service.train_device(device_id) + mock_train.assert_awaited_once_with(device_id, "Dishwasher", 8) + + @pytest.mark.asyncio + async def test_train_device_with_custom_lookback(self, service, mock_home_loads_repo, device_id, profile_with_device): + mock_home_loads_repo.get_all.return_value = [profile_with_device] + + with patch.object(service, "_train_for_device", new_callable=AsyncMock) as mock_train: + await service.train_device(device_id, weeks_lookback=4) + mock_train.assert_awaited_once_with(device_id, "Dishwasher", 4) + + @pytest.mark.asyncio + async def test_train_device_unknown_device_skips(self, service, mock_home_loads_repo, logger): + mock_home_loads_repo.get_all.return_value = [] + unknown_id = EntityId(uuid.uuid4()) + + with patch.object(service, "_train_for_device", new_callable=AsyncMock) as mock_train: + await service.train_device(unknown_id) + mock_train.assert_not_awaited() + logger.warning.assert_called_once() + + @pytest.mark.asyncio + async def test_train_device_finds_device_across_profiles(self, service, mock_home_loads_repo, device_id): + device = LoadDevice(id=device_id, name="Target", enabled=True) + profile1 = HomeLoadsProfile(name="Home 1", devices=[]) + profile2 = HomeLoadsProfile(name="Home 2", devices=[device]) + mock_home_loads_repo.get_all.return_value = [profile1, profile2] + + with patch.object(service, "_train_for_device", new_callable=AsyncMock) as mock_train: + await service.train_device(device_id) + mock_train.assert_awaited_once_with(device_id, "Target", 8) + + +class TestGetModels: + def test_get_models_delegates_to_repo(self, service, mock_model_repo): + expected = [ + LoadConsumptionModel( + adapter_type=EnergyLoadForecastProviderAdapter.STATSMODELS, + mae=1.0, + is_active=True, + ) + ] + mock_model_repo.get_all.return_value = expected + + result = service.get_models() + + assert result == expected + mock_model_repo.get_all.assert_called_once_with(None) + + def test_get_models_with_device_filter(self, service, mock_model_repo, device_id): + mock_model_repo.get_all.return_value = [] + + service.get_models(device_id=device_id) + + mock_model_repo.get_all.assert_called_once_with(device_id) diff --git a/core/tests/unit/domain/__init__.py b/core/tests/unit/domain/__init__.py new file mode 100644 index 0000000..dd2a29e --- /dev/null +++ b/core/tests/unit/domain/__init__.py @@ -0,0 +1 @@ +"""Collection of unit tests for domain logic.""" diff --git a/core/tests/unit/domain/energy/__init__.py b/core/tests/unit/domain/energy/__init__.py new file mode 100644 index 0000000..4d2d458 --- /dev/null +++ b/core/tests/unit/domain/energy/__init__.py @@ -0,0 +1 @@ +"""Test package for energy domain unit tests.""" diff --git a/core/tests/unit/domain/energy/conftest.py b/core/tests/unit/domain/energy/conftest.py new file mode 100644 index 0000000..8b4bfc2 --- /dev/null +++ b/core/tests/unit/domain/energy/conftest.py @@ -0,0 +1,133 @@ +"""Shared fixtures for energy domain tests.""" + +import uuid + +import pytest + +from edge_mining.domain.common import EntityId, WattHours, Watts +from edge_mining.domain.energy.common import EnergyMonitorAdapter, EnergySourceType +from edge_mining.domain.energy.entities import EnergyMonitor, EnergySource +from edge_mining.domain.energy.value_objects import Battery, Grid + + +@pytest.fixture +def energy_source_id(): + """Fixture providing a sample EntityId for energy sources.""" + return EntityId(uuid.uuid4()) + + +@pytest.fixture +def solar_energy_source(): + """Fixture providing a solar energy source.""" + return EnergySource( + name="Solar Panel System", + type=EnergySourceType.SOLAR, + nominal_power_max=Watts(6000), + ) + + +@pytest.fixture +def wind_energy_source(): + """Fixture providing a wind energy source.""" + return EnergySource(name="Wind Turbine", type=EnergySourceType.WIND, nominal_power_max=Watts(3000)) + + +@pytest.fixture +def basic_battery(): + """Fixture providing a basic battery.""" + return Battery(nominal_capacity=WattHours(10000)) + + +@pytest.fixture +def large_battery(): + """Fixture providing a large capacity battery.""" + return Battery(nominal_capacity=WattHours(40000)) + + +@pytest.fixture +def standard_grid(): + """Fixture providing a standard grid connection.""" + return Grid(contracted_power=Watts(3000)) + + +@pytest.fixture +def high_power_grid(): + """Fixture providing a high power grid connection.""" + return Grid(contracted_power=Watts(6000)) + + +@pytest.fixture +def energy_monitor(): + """Fixture providing a dummy energy monitor.""" + return EnergyMonitor(name="Test Monitor", adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR) + + +@pytest.fixture +def external_generator_power(): + """Fixture providing external generator power rating.""" + return Watts(5000) + + +@pytest.fixture +def complete_energy_system(solar_energy_source, basic_battery, standard_grid): + """Fixture providing a complete energy system setup.""" + solar_energy_source.connect_to_storage(basic_battery) + solar_energy_source.connect_to_grid(standard_grid) + return solar_energy_source + + +@pytest.fixture +def sample_energy_source(): + """Fixture providing a sample energy source for tests.""" + return EnergySource( + name="Test Solar Panel", + type=EnergySourceType.SOLAR, + nominal_power_max=Watts(5000), + ) + + +@pytest.fixture +def sample_battery(): + """Fixture providing a sample battery for tests.""" + return Battery(nominal_capacity=WattHours(10000)) + + +@pytest.fixture +def sample_grid(): + """Fixture providing a sample grid for tests.""" + return Grid(contracted_power=Watts(3000)) + + +# Additional fixtures specific to EnergyMonitor tests +@pytest.fixture +def home_assistant_api_monitor(): + """Fixture providing a Home Assistant API energy monitor.""" + return EnergyMonitor( + name="Home Assistant API Monitor", + adapter_type=EnergyMonitorAdapter.HOME_ASSISTANT_API, + ) + + +@pytest.fixture +def home_assistant_mqtt_monitor(): + """Fixture providing a Home Assistant MQTT energy monitor.""" + return EnergyMonitor( + name="Home Assistant MQTT Monitor", + adapter_type=EnergyMonitorAdapter.HOME_ASSISTANT_MQTT, + ) + + +@pytest.fixture +def dummy_monitor(): + """Fixture providing a dummy energy monitor.""" + return EnergyMonitor(name="Dummy Monitor", adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR) + + +@pytest.fixture +def monitor_with_service(): + """Fixture providing a monitor with external service.""" + return EnergyMonitor( + name="Service Monitor", + adapter_type=EnergyMonitorAdapter.HOME_ASSISTANT_API, + external_service_id=EntityId(uuid.uuid4()), + ) diff --git a/core/tests/unit/domain/energy/test_energy_monitor.py b/core/tests/unit/domain/energy/test_energy_monitor.py new file mode 100644 index 0000000..4ee2984 --- /dev/null +++ b/core/tests/unit/domain/energy/test_energy_monitor.py @@ -0,0 +1,172 @@ +"""Unit tests for EnergyMonitor entity.""" + +import uuid + +from edge_mining.domain.common import EntityId +from edge_mining.domain.energy.common import EnergyMonitorAdapter +from edge_mining.domain.energy.entities import EnergyMonitor + + +class TestEnergyMonitor: + """Test suite for EnergyMonitor entity.""" + + def test_energy_monitor_creation_with_defaults(self): + """Test creating an EnergyMonitor with default values.""" + monitor = EnergyMonitor() + + assert monitor.name == "" + assert monitor.adapter_type == EnergyMonitorAdapter.DUMMY_SOLAR + assert monitor.config is None + assert monitor.external_service_id is None + assert isinstance(monitor.id, uuid.UUID) + + def test_energy_monitor_creation_with_custom_values(self): + """Test creating an EnergyMonitor with custom values.""" + custom_id = EntityId(uuid.uuid4()) + service_id = EntityId(uuid.uuid4()) + + monitor = EnergyMonitor( + id=custom_id, + name="Solar Production Monitor", + adapter_type=EnergyMonitorAdapter.HOME_ASSISTANT_API, + external_service_id=service_id, + ) + + assert monitor.id == custom_id + assert monitor.name == "Solar Production Monitor" + assert monitor.adapter_type == EnergyMonitorAdapter.HOME_ASSISTANT_API + assert monitor.external_service_id == service_id + + def test_energy_monitor_different_adapter_types(self): + """Test creating energy monitors with different adapter types.""" + api_monitor = EnergyMonitor( + name="HomeAssistant API Monitor", + adapter_type=EnergyMonitorAdapter.HOME_ASSISTANT_API, + ) + + dummy_monitor = EnergyMonitor(name="Dummy Monitor", adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR) + + mqtt_monitor = EnergyMonitor( + name="HomeAssistant MQTT Monitor", + adapter_type=EnergyMonitorAdapter.HOME_ASSISTANT_MQTT, + ) + + assert api_monitor.adapter_type == EnergyMonitorAdapter.HOME_ASSISTANT_API + assert dummy_monitor.adapter_type == EnergyMonitorAdapter.DUMMY_SOLAR + assert mqtt_monitor.adapter_type == EnergyMonitorAdapter.HOME_ASSISTANT_MQTT + + def test_energy_monitor_with_config(self): + """Test energy monitor with configuration.""" + monitor = EnergyMonitor( + name="Configured Monitor", + adapter_type=EnergyMonitorAdapter.HOME_ASSISTANT_API, + ) + + # Test that config can be set (even if None initially) + assert monitor.config is None + + def test_energy_monitor_with_external_service(self): + """Test energy monitor with external service reference.""" + service_id = EntityId(uuid.uuid4()) + + monitor = EnergyMonitor( + name="External Service Monitor", + adapter_type=EnergyMonitorAdapter.HOME_ASSISTANT_API, + external_service_id=service_id, + ) + + assert monitor.external_service_id == service_id + + def test_energy_monitor_identity_persistence(self): + """Test that entity identity is maintained through operations.""" + monitor = EnergyMonitor(name="Test Monitor") + original_id = monitor.id + + # Modify properties + monitor.name = "Updated Monitor" + monitor.adapter_type = EnergyMonitorAdapter.HOME_ASSISTANT_API + monitor.external_service_id = EntityId(uuid.uuid4()) + + # Verify ID hasn't changed + assert monitor.id == original_id + + def test_energy_monitor_name_update(self): + """Test updating energy monitor name.""" + monitor = EnergyMonitor(name="Original Name") + + monitor.name = "Updated Name" + + assert monitor.name == "Updated Name" + + def test_energy_monitor_adapter_type_update(self): + """Test updating energy monitor adapter type.""" + monitor = EnergyMonitor(adapter_type=EnergyMonitorAdapter.DUMMY_SOLAR) + + monitor.adapter_type = EnergyMonitorAdapter.HOME_ASSISTANT_API + + assert monitor.adapter_type == EnergyMonitorAdapter.HOME_ASSISTANT_API + + def test_energy_monitor_external_service_update(self): + """Test updating external service reference.""" + monitor = EnergyMonitor() + service_id = EntityId(uuid.uuid4()) + + assert monitor.external_service_id is None + + monitor.external_service_id = service_id + assert monitor.external_service_id == service_id + + # Test removing service reference + monitor.external_service_id = None + assert monitor.external_service_id is None + + def test_energy_monitor_complete_setup(self): + """Test complete energy monitor setup workflow.""" + service_id = EntityId(uuid.uuid4()) + + monitor = EnergyMonitor() + + # Configure monitor step by step + monitor.name = "Complete API Monitor" + monitor.adapter_type = EnergyMonitorAdapter.HOME_ASSISTANT_API + monitor.external_service_id = service_id + + # Verify complete configuration + assert monitor.name == "Complete API Monitor" + assert monitor.adapter_type == EnergyMonitorAdapter.HOME_ASSISTANT_API + assert monitor.external_service_id == service_id + assert isinstance(monitor.id, uuid.UUID) + + +class TestEnergyMonitorWithFixtures: + """Additional tests using fixtures.""" + + def test_energy_monitor_with_fixture(self, energy_monitor): + """Test energy monitor using fixture.""" + assert energy_monitor.name == "Test Monitor" + assert energy_monitor.adapter_type == EnergyMonitorAdapter.DUMMY_SOLAR + assert energy_monitor.config is None + assert energy_monitor.external_service_id is None + + def test_fixture_isolation(self, energy_monitor): + """Test that fixtures are isolated between tests.""" + # Modify the monitor + energy_monitor.name = "Modified Monitor" + energy_monitor.adapter_type = EnergyMonitorAdapter.HOME_ASSISTANT_API + + assert energy_monitor.name == "Modified Monitor" + # Next test should get a fresh fixture + + def test_multiple_monitors_independence(self): + """Test that multiple monitor instances are independent.""" + monitor1 = EnergyMonitor(name="Monitor 1") + monitor2 = EnergyMonitor(name="Monitor 2") + + assert monitor1.id != monitor2.id + assert monitor1.name != monitor2.name + + # Modify one monitor + monitor1.adapter_type = EnergyMonitorAdapter.HOME_ASSISTANT_API + + # Other monitor should be unchanged + assert monitor2.adapter_type == EnergyMonitorAdapter.DUMMY_SOLAR diff --git a/core/tests/unit/domain/energy/test_energy_source.py b/core/tests/unit/domain/energy/test_energy_source.py new file mode 100644 index 0000000..1a02e14 --- /dev/null +++ b/core/tests/unit/domain/energy/test_energy_source.py @@ -0,0 +1,239 @@ +"""Unit tests for EnergySource entity.""" + +import uuid + +from edge_mining.domain.common import EntityId, WattHours, Watts +from edge_mining.domain.energy.common import EnergySourceType +from edge_mining.domain.energy.entities import EnergySource +from edge_mining.domain.energy.value_objects import Battery, Grid + + +class TestEnergySource: + """Test suite for EnergySource entity.""" + + def test_energy_source_creation_with_defaults(self): + """Test creating an EnergySource with default values.""" + energy_source = EnergySource() + + assert energy_source.name == "" + assert energy_source.type == EnergySourceType.SOLAR + assert energy_source.nominal_power_max is None + assert energy_source.storage is None + assert energy_source.grid is None + assert energy_source.external_source is None + assert energy_source.energy_monitor_id is None + assert energy_source.forecast_provider_id is None + assert isinstance(energy_source.id, uuid.UUID) + + def test_energy_source_creation_with_custom_values(self): + """Test creating an EnergySource with custom values.""" + custom_id = EntityId(uuid.uuid4()) + battery = Battery(nominal_capacity=WattHours(15000)) + grid = Grid(contracted_power=Watts(3000)) + + energy_source = EnergySource( + id=custom_id, + name="Solar Panel Array", + type=EnergySourceType.SOLAR, + nominal_power_max=Watts(5000), + storage=battery, + grid=grid, + external_source=Watts(1000), + ) + + assert energy_source.id == custom_id + assert energy_source.name == "Solar Panel Array" + assert energy_source.type == EnergySourceType.SOLAR + assert energy_source.nominal_power_max == Watts(5000) + assert energy_source.storage == battery + assert energy_source.grid == grid + assert energy_source.external_source == Watts(1000) + + def test_connect_to_grid(self): + """Test connecting energy source to grid.""" + energy_source = EnergySource() + grid = Grid(contracted_power=Watts(3000)) + + energy_source.connect_to_grid(grid) + + assert energy_source.grid == grid + + def test_disconnect_from_grid(self): + """Test disconnecting energy source from grid.""" + energy_source = EnergySource() + grid = Grid(contracted_power=Watts(3000)) + energy_source.connect_to_grid(grid) + + energy_source.disconnect_from_grid() + + assert energy_source.grid is None + + def test_connect_to_external_source(self): + """Test connecting to external power source.""" + energy_source = EnergySource() + external_power = Watts(2500) + + energy_source.connect_to_external_source(external_power) + + assert energy_source.external_source == external_power + + def test_disconnect_from_external_source(self): + """Test disconnecting from external power source.""" + energy_source = EnergySource() + energy_source.connect_to_external_source(Watts(2500)) + + energy_source.disconnect_from_external_source() + + assert energy_source.external_source is None + + def test_connect_to_storage(self): + """Test connecting energy source to battery storage.""" + energy_source = EnergySource() + battery = Battery(nominal_capacity=WattHours(15000)) + + energy_source.connect_to_storage(battery) + + assert energy_source.storage == battery + + def test_disconnect_from_storage(self): + """Test disconnecting energy source from battery storage.""" + energy_source = EnergySource() + battery = Battery(nominal_capacity=WattHours(15000)) + energy_source.connect_to_storage(battery) + + energy_source.disconnect_from_storage() + + assert energy_source.storage is None + + def test_use_energy_monitor(self): + """Test setting energy monitor for the energy source.""" + energy_source = EnergySource() + monitor_id = EntityId(uuid.uuid4()) + + energy_source.use_energy_monitor(monitor_id) + + assert energy_source.energy_monitor_id == monitor_id + + def test_use_forecast_provider(self): + """Test setting forecast provider for the energy source.""" + energy_source = EnergySource() + forecast_id = EntityId(uuid.uuid4()) + + energy_source.use_forecast_provider(forecast_id) + + assert energy_source.forecast_provider_id == forecast_id + + def test_energy_source_type_variations(self): + """Test creating energy sources with different types.""" + wind_source = EnergySource(name="Wind Turbine", type=EnergySourceType.WIND) + hydro_source = EnergySource(name="Hydro Plant", type=EnergySourceType.HYDROELECTRIC) + solar_source = EnergySource(name="Solar Plant", type=EnergySourceType.SOLAR) + + assert wind_source.type == EnergySourceType.WIND + assert hydro_source.type == EnergySourceType.HYDROELECTRIC + assert solar_source.type == EnergySourceType.SOLAR + + def test_complete_configuration_workflow(self): + """Test a complete configuration workflow for an energy source.""" + # Create energy source + energy_source = EnergySource( + name="Complete Solar System", + type=EnergySourceType.SOLAR, + nominal_power_max=Watts(8000), + ) + + # Add storage + battery = Battery(nominal_capacity=WattHours(15000)) + energy_source.connect_to_storage(battery) + + # Connect to grid + grid = Grid(contracted_power=Watts(3000)) + energy_source.connect_to_grid(grid) + + # Add external backup + energy_source.connect_to_external_source(Watts(3000)) + + # Configure monitoring + monitor_id = EntityId(uuid.uuid4()) + forecast_id = EntityId(uuid.uuid4()) + energy_source.use_energy_monitor(monitor_id) + energy_source.use_forecast_provider(forecast_id) + + # Verify complete configuration + assert energy_source.name == "Complete Solar System" + assert energy_source.type == EnergySourceType.SOLAR + assert energy_source.nominal_power_max == Watts(8000) + assert energy_source.storage == battery + assert energy_source.grid == grid + assert energy_source.external_source == Watts(3000) + assert energy_source.energy_monitor_id == monitor_id + assert energy_source.forecast_provider_id == forecast_id + + def test_partial_disconnection_workflow(self): + """Test disconnecting only some components.""" + energy_source = EnergySource(name="Test Source") + + # Setup all connections + battery = Battery(nominal_capacity=WattHours(15000)) + grid = Grid(contracted_power=Watts(3000)) + external_source = Watts(1500) + energy_source.connect_to_storage(battery) + energy_source.connect_to_grid(grid) + energy_source.connect_to_external_source(external_source) + + # Disconnect only storage + energy_source.disconnect_from_storage() + + # Verify only storage is disconnected + assert energy_source.storage is None + assert energy_source.grid == grid + assert energy_source.external_source == external_source + + def test_multiple_reconnections(self): + """Test reconnecting components multiple times.""" + energy_source = EnergySource() + + # First connection + first_battery = Battery(nominal_capacity=WattHours(5000)) + energy_source.connect_to_storage(first_battery) + assert energy_source.storage == first_battery + + # Reconnect with different battery + second_battery = Battery(nominal_capacity=WattHours(8000)) + energy_source.connect_to_storage(second_battery) + assert energy_source.storage == second_battery + assert energy_source.storage != first_battery + + def test_energy_source_identity_persistence(self): + """Test that entity identity is maintained through operations.""" + energy_source = EnergySource(name="Test Source") + original_id = energy_source.id + + # Perform various operations + battery = Battery(nominal_capacity=WattHours(5000)) + energy_source.connect_to_storage(battery) + energy_source.disconnect_from_storage() + energy_source.use_energy_monitor(EntityId(uuid.uuid4())) + + # Verify ID hasn't changed + assert energy_source.id == original_id + + +class TestEnergySourceWithFixtures: + """Additional tests using fixtures.""" + + def test_energy_source_with_fixtures(self, sample_energy_source, sample_battery, sample_grid): + """Test energy source operations using fixtures.""" + # Use fixtures + sample_energy_source.connect_to_storage(sample_battery) + sample_energy_source.connect_to_grid(sample_grid) + + assert sample_energy_source.storage == sample_battery + assert sample_energy_source.grid == sample_grid + assert sample_energy_source.name == "Test Solar Panel" + + def test_fixture_isolation(self, sample_energy_source): + """Test that fixtures are isolated between tests.""" + # This should start fresh even though previous test modified the fixture + assert sample_energy_source.storage is None + assert sample_energy_source.grid is None diff --git a/core/tests/unit/domain/home_load/__init__.py b/core/tests/unit/domain/home_load/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/tests/unit/domain/home_load/test_load_consumption_model_repository.py b/core/tests/unit/domain/home_load/test_load_consumption_model_repository.py new file mode 100644 index 0000000..9b4e6d1 --- /dev/null +++ b/core/tests/unit/domain/home_load/test_load_consumption_model_repository.py @@ -0,0 +1,85 @@ +"""Unit tests for InMemoryLoadConsumptionModelRepository.get_all.""" + +import uuid + +import pytest + +from edge_mining.adapters.domain.home_load.repositories import InMemoryLoadConsumptionModelRepository +from edge_mining.domain.common import EntityId +from edge_mining.domain.home_load.common import EnergyLoadForecastProviderAdapter +from edge_mining.domain.home_load.entities import LoadConsumptionModel + + +@pytest.fixture +def repo() -> InMemoryLoadConsumptionModelRepository: + return InMemoryLoadConsumptionModelRepository() + + +@pytest.fixture +def device_id_a() -> EntityId: + return EntityId(uuid.uuid4()) + + +@pytest.fixture +def device_id_b() -> EntityId: + return EntityId(uuid.uuid4()) + + +def _make_model( + device_id: EntityId, + adapter: EnergyLoadForecastProviderAdapter = EnergyLoadForecastProviderAdapter.STATSMODELS, + is_active: bool = False, +) -> LoadConsumptionModel: + return LoadConsumptionModel( + device_id=device_id, + adapter_type=adapter, + is_active=is_active, + mae=1.5, + samples_used=100, + ) + + +class TestInMemoryLoadConsumptionModelGetAll: + def test_get_all_empty(self, repo): + assert repo.get_all() == [] + + def test_get_all_returns_all_models(self, repo, device_id_a, device_id_b): + m1 = _make_model(device_id_a) + m2 = _make_model(device_id_b, adapter=EnergyLoadForecastProviderAdapter.XGBOOST) + repo.add(m1) + repo.add(m2) + + result = repo.get_all() + assert len(result) == 2 + result_ids = {str(m.id) for m in result} + assert str(m1.id) in result_ids + assert str(m2.id) in result_ids + + def test_get_all_filtered_by_device_id(self, repo, device_id_a, device_id_b): + m1 = _make_model(device_id_a) + m2 = _make_model(device_id_b) + m3 = _make_model(device_id_a, adapter=EnergyLoadForecastProviderAdapter.XGBOOST) + repo.add(m1) + repo.add(m2) + repo.add(m3) + + result = repo.get_all(device_id=device_id_a) + assert len(result) == 2 + for m in result: + assert str(m.device_id) == str(device_id_a) + + def test_get_all_filtered_returns_empty_for_unknown_device(self, repo, device_id_a): + repo.add(_make_model(device_id_a)) + unknown = EntityId(uuid.uuid4()) + assert repo.get_all(device_id=unknown) == [] + + def test_get_all_returns_deep_copies(self, repo, device_id_a): + m1 = _make_model(device_id_a) + repo.add(m1) + + result = repo.get_all() + assert len(result) == 1 + result[0].mae = 999.0 + # Original should be unchanged + original = repo.get_by_id(m1.id) + assert original.mae == 1.5 diff --git a/core/tests/unit/domain/home_load/test_load_energy_consumption_mix.py b/core/tests/unit/domain/home_load/test_load_energy_consumption_mix.py new file mode 100644 index 0000000..661140a --- /dev/null +++ b/core/tests/unit/domain/home_load/test_load_energy_consumption_mix.py @@ -0,0 +1,120 @@ +"""Unit tests for LoadEnergyConsumption.mix() — α/β forecast blending.""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from edge_mining.domain.common import Timestamp, WattHours, Watts +from edge_mining.domain.home_load.value_objects import ( + HomeLoadEnergyInterval, + HomeLoadPowerPoint, + LoadEnergyConsumption, +) + + +def _ts(offset_hours: int = 0) -> Timestamp: + """Utility: create a UTC timestamp offset from a fixed base.""" + base = datetime(2026, 4, 25, 10, 0, 0, tzinfo=timezone.utc) + return Timestamp(base + timedelta(hours=offset_hours)) + + +def _interval(start_h: int, end_h: int, power: float) -> HomeLoadEnergyInterval: + """Build a 1-hour interval with a single power point and pre-computed energy.""" + start = _ts(start_h) + end = _ts(end_h) + pp = HomeLoadPowerPoint(timestamp=start, power=Watts(power)) + duration_hours = (end - start).total_seconds() / 3600.0 + return HomeLoadEnergyInterval( + start=start, + end=end, + energy=WattHours(power * duration_hours), + power_points=[pp], + ) + + +def _consumption(*powers: float) -> LoadEnergyConsumption: + """Build a LoadEnergyConsumption with N hourly intervals starting at hour 0.""" + intervals = [_interval(i, i + 1, p) for i, p in enumerate(powers)] + return LoadEnergyConsumption(timestamp=_ts(0), intervals=intervals) + + +class TestMixForecast: + def test_empty_forecast_returns_unchanged(self): + empty = LoadEnergyConsumption(timestamp=_ts(0), intervals=[]) + result = LoadEnergyConsumption.mix(empty, Watts(500.0)) + assert result.intervals == [] + + def test_default_alpha_beta_equal_weights(self): + forecast = _consumption(200.0, 300.0, 400.0) + last_real = Watts(100.0) + + result = LoadEnergyConsumption.mix(forecast, last_real) + + # First interval: 0.5 * 200 + 0.5 * 100 = 150 + assert result.intervals[0].avg_power == pytest.approx(200.0) # power_points unchanged + assert float(result.intervals[0].energy) == pytest.approx(150.0) # blended energy + # Remaining intervals unchanged + assert float(result.intervals[1].energy) == float(forecast.intervals[1].energy) + assert float(result.intervals[2].energy) == float(forecast.intervals[2].energy) + + def test_alpha_1_beta_0_keeps_forecast(self): + forecast = _consumption(200.0, 300.0) + last_real = Watts(999.0) + + result = LoadEnergyConsumption.mix(forecast, last_real, alpha=1.0, beta=0.0) + + # 1.0 * 200 + 0.0 * 999 = 200 + assert float(result.intervals[0].energy) == pytest.approx(200.0) + + def test_alpha_0_beta_1_uses_real_only(self): + forecast = _consumption(200.0, 300.0) + last_real = Watts(500.0) + + result = LoadEnergyConsumption.mix(forecast, last_real, alpha=0.0, beta=1.0) + + # 0.0 * 200 + 1.0 * 500 = 500 + assert float(result.intervals[0].energy) == pytest.approx(500.0) + + def test_custom_weights(self): + forecast = _consumption(200.0, 300.0) + last_real = Watts(100.0) + + result = LoadEnergyConsumption.mix(forecast, last_real, alpha=0.25, beta=0.75) + + # 0.25 * 200 + 0.75 * 100 = 50 + 75 = 125 + assert float(result.intervals[0].energy) == pytest.approx(125.0) + + def test_single_interval_forecast(self): + forecast = _consumption(400.0) + last_real = Watts(200.0) + + result = LoadEnergyConsumption.mix(forecast, last_real) + + # 0.5 * 400 + 0.5 * 200 = 300 + assert float(result.intervals[0].energy) == pytest.approx(300.0) + assert len(result.intervals) == 1 + + def test_preserves_timestamp(self): + forecast = _consumption(100.0, 200.0) + result = LoadEnergyConsumption.mix(forecast, Watts(50.0)) + assert result.timestamp == forecast.timestamp + + def test_preserves_power_points(self): + forecast = _consumption(100.0, 200.0) + result = LoadEnergyConsumption.mix(forecast, Watts(50.0)) + assert result.intervals[0].power_points == forecast.intervals[0].power_points + + def test_preserves_interval_times(self): + forecast = _consumption(100.0, 200.0, 300.0) + result = LoadEnergyConsumption.mix(forecast, Watts(50.0)) + for i in range(3): + assert result.intervals[i].start == forecast.intervals[i].start + assert result.intervals[i].end == forecast.intervals[i].end + + def test_does_not_mutate_original(self): + forecast = _consumption(200.0, 300.0) + original_energy = float(forecast.intervals[0].energy) + + LoadEnergyConsumption.mix(forecast, Watts(100.0)) + + assert float(forecast.intervals[0].energy) == original_energy diff --git a/core/tests/unit/domain/home_load/test_load_energy_consumption_windows.py b/core/tests/unit/domain/home_load/test_load_energy_consumption_windows.py new file mode 100644 index 0000000..e0b46a5 --- /dev/null +++ b/core/tests/unit/domain/home_load/test_load_energy_consumption_windows.py @@ -0,0 +1,152 @@ +"""Unit tests for LoadEnergyConsumption extended window properties (F2 — 24h horizon).""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from edge_mining.domain.common import Timestamp, WattHours, Watts +from edge_mining.domain.home_load.value_objects import ( + HomeLoadEnergyInterval, + HomeLoadPowerPoint, + LoadEnergyConsumption, +) + +# Fixed "now" used as anchor for all tests +_NOW = datetime(2026, 4, 25, 12, 0, 0, tzinfo=timezone.utc) + + +def _ts(offset_hours: float = 0) -> Timestamp: + return Timestamp(_NOW + timedelta(hours=offset_hours)) + + +def _interval(start_h: float, end_h: float, power: float = 100.0) -> HomeLoadEnergyInterval: + start = _ts(start_h) + end = _ts(end_h) + pp = HomeLoadPowerPoint(timestamp=start, power=Watts(power)) + duration_hours = (end - start).total_seconds() / 3600.0 + return HomeLoadEnergyInterval( + start=start, + end=end, + energy=WattHours(power * duration_hours), + power_points=[pp], + ) + + +def _make_24h_forecast() -> LoadEnergyConsumption: + """Build a 24-hour forecast with one interval per hour starting at _NOW.""" + intervals = [_interval(h, h + 1, power=float(100 + h * 10)) for h in range(24)] + return LoadEnergyConsumption(timestamp=_ts(0), intervals=intervals) + + +def _make_24h_history() -> LoadEnergyConsumption: + """Build a 24-hour history ending at _NOW.""" + intervals = [_interval(-24 + h, -23 + h, power=float(50 + h * 5)) for h in range(24)] + return LoadEnergyConsumption(timestamp=_ts(0), intervals=intervals) + + +class TestExtendedNextWindowProperties: + """Verify next_Xh properties return correct number of intervals.""" + + def test_next_1h(self): + forecast = _make_24h_forecast() + subset = forecast.in_next_hours(1, now=_ts(0)) + assert len(subset.intervals) == 1 + + def test_next_2h(self): + forecast = _make_24h_forecast() + subset = forecast.in_next_hours(2, now=_ts(0)) + assert len(subset.intervals) == 2 + + def test_next_4h(self): + forecast = _make_24h_forecast() + subset = forecast.in_next_hours(4, now=_ts(0)) + assert len(subset.intervals) == 4 + + def test_next_6h(self): + forecast = _make_24h_forecast() + subset = forecast.in_next_hours(6, now=_ts(0)) + assert len(subset.intervals) == 6 + + def test_next_8h(self): + forecast = _make_24h_forecast() + subset = forecast.in_next_hours(8, now=_ts(0)) + assert len(subset.intervals) == 8 + + def test_next_12h(self): + forecast = _make_24h_forecast() + subset = forecast.in_next_hours(12, now=_ts(0)) + assert len(subset.intervals) == 12 + + def test_next_24h(self): + forecast = _make_24h_forecast() + subset = forecast.in_next_hours(24, now=_ts(0)) + assert len(subset.intervals) == 24 + + def test_next_24h_total_energy(self): + forecast = _make_24h_forecast() + subset = forecast.in_next_hours(24, now=_ts(0)) + assert float(subset.total_energy) == float(forecast.total_energy) + + +class TestExtendedLastWindowProperties: + """Verify last_Xh properties return correct number of intervals.""" + + def test_last_1h(self): + history = _make_24h_history() + subset = history.in_last_hours(1, now=_ts(0)) + assert len(subset.intervals) == 1 + + def test_last_4h(self): + history = _make_24h_history() + subset = history.in_last_hours(4, now=_ts(0)) + assert len(subset.intervals) == 4 + + def test_last_12h(self): + history = _make_24h_history() + subset = history.in_last_hours(12, now=_ts(0)) + assert len(subset.intervals) == 12 + + def test_last_24h(self): + history = _make_24h_history() + subset = history.in_last_hours(24, now=_ts(0)) + assert len(subset.intervals) == 24 + + def test_last_24h_total_energy(self): + history = _make_24h_history() + subset = history.in_last_hours(24, now=_ts(0)) + assert float(subset.total_energy) == float(history.total_energy) + + +class TestWindowAggregates: + """Verify energy/power aggregates on windowed subsets.""" + + def test_next_6h_avg_power(self): + forecast = _make_24h_forecast() + subset = forecast.in_next_hours(6, now=_ts(0)) + # Intervals 0..5 have power 100,110,120,130,140,150 → avg = 125 + assert float(subset.avg_power) == pytest.approx(125.0) + + def test_next_6h_peak_power(self): + forecast = _make_24h_forecast() + subset = forecast.in_next_hours(6, now=_ts(0)) + assert float(subset.peak_power) == pytest.approx(150.0) + + def test_next_12h_total_energy(self): + forecast = _make_24h_forecast() + subset = forecast.in_next_hours(12, now=_ts(0)) + # Powers: 100,110,...,210 → sum = 12 * 100 + 10*(0+1+..+11) = 1200+660 = 1860 + # Each 1h interval → energy = power, so total = 1860 + assert float(subset.total_energy) == pytest.approx(1860.0) + + def test_partial_window_returns_overlapping_intervals(self): + """Window that starts mid-interval still returns that interval.""" + forecast = _make_24h_forecast() + subset = forecast.in_window(_ts(0.5), _ts(2.5)) + # Intervals [0,1), [1,2), [2,3) all overlap [0.5, 2.5) + assert len(subset.intervals) == 3 + + def test_empty_window(self): + forecast = _make_24h_forecast() + subset = forecast.in_next_hours(1, now=_ts(100)) + assert len(subset.intervals) == 0 + assert float(subset.total_energy) == 0.0 diff --git a/core/tests/unit/domain/test_events.py b/core/tests/unit/domain/test_events.py new file mode 100644 index 0000000..86bc097 --- /dev/null +++ b/core/tests/unit/domain/test_events.py @@ -0,0 +1,55 @@ +"""Unit tests for DomainEvent base class.""" + +import unittest +from dataclasses import dataclass +from datetime import datetime, timezone + +from edge_mining.domain.common import DomainEvent + + +@dataclass +class SampleEvent(DomainEvent): + """Concrete event for testing.""" + + name: str = "" + + +class TestDomainEvent(unittest.TestCase): + """Test cases for DomainEvent base class.""" + + def test_event_id_auto_generated(self): + event = SampleEvent(name="test") + self.assertIsInstance(event.event_id, str) + self.assertTrue(len(event.event_id) > 0) + + def test_event_id_unique(self): + event1 = SampleEvent(name="a") + event2 = SampleEvent(name="b") + self.assertNotEqual(event1.event_id, event2.event_id) + + def test_occurred_at_auto_generated_utc(self): + event = SampleEvent(name="test") + self.assertIsInstance(event.occurred_at, datetime) + self.assertEqual(event.occurred_at.tzinfo, timezone.utc) + + def test_event_type_returns_class_name(self): + event = SampleEvent(name="test") + self.assertEqual(event.event_type, "SampleEvent") + + def test_to_dict_serializes_correctly(self): + event = SampleEvent(name="test") + result = event.to_dict() + self.assertEqual(result["name"], "test") + self.assertEqual(result["event_type"], "SampleEvent") + self.assertIsInstance(result["occurred_at"], str) + self.assertIn("event_id", result) + + def test_to_dict_datetime_is_iso_format(self): + event = SampleEvent(name="test") + result = event.to_dict() + # Should be parseable as ISO + datetime.fromisoformat(result["occurred_at"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/dev-tools.bat b/dev-tools.bat new file mode 100644 index 0000000..91c7c0f --- /dev/null +++ b/dev-tools.bat @@ -0,0 +1,217 @@ +@echo off +REM Batch script for Edge Mining Development Tools (Windows CMD alternative to Makefile) + +setlocal EnableDelayedExpansion + +REM Variables +set VENV=.venv\Scripts +set PYTHON=%VENV%\python.exe +set PIP=%VENV%\pip.exe +set PRE_COMMIT=%VENV%\pre-commit.exe + +REM Get command from first argument +set COMMAND=%1 +if "%COMMAND%"=="" set COMMAND=help + +REM Main command dispatcher +if /i "%COMMAND%"=="help" goto :help +if /i "%COMMAND%"=="setup" goto :setup +if /i "%COMMAND%"=="venv" goto :venv +if /i "%COMMAND%"=="dev-core" goto :dev-core +if /i "%COMMAND%"=="dev-frontend" goto :dev-frontend +if /i "%COMMAND%"=="format" goto :format +if /i "%COMMAND%"=="lint" goto :lint +if /i "%COMMAND%"=="lint-fix" goto :lint-fix +if /i "%COMMAND%"=="test" goto :test +if /i "%COMMAND%"=="test-cov" goto :test-cov +if /i "%COMMAND%"=="pre-commit" goto :pre-commit +if /i "%COMMAND%"=="pre-commit-install" goto :pre-commit-install +if /i "%COMMAND%"=="clean" goto :clean +if /i "%COMMAND%"=="build" goto :build +if /i "%COMMAND%"=="up" goto :up +if /i "%COMMAND%"=="down" goto :down +if /i "%COMMAND%"=="restart" goto :restart +if /i "%COMMAND%"=="logs" goto :logs + +echo Unknown command: %COMMAND% +echo. +goto :help + +:help +echo Edge Mining App — Development ^& Docker Commands (Batch) +echo ======================================================== +echo. +echo Development: +echo setup - Set up full development environment (core + frontend) +echo venv - Create .venv and install all Python dependencies +echo dev-core - Set up core backend development environment only +echo dev-frontend - Install frontend dependencies only +echo format - Format core code with ruff +echo lint - Run all linting checks on core +echo lint-fix - Run linting and auto-fix on core +echo test - Run core tests +echo test-cov - Run core tests with coverage +echo pre-commit - Run pre-commit hooks on all files +echo pre-commit-install - Install pre-commit hooks +echo clean - Clean cache and temporary files +echo. +echo Docker: +echo build - Build the Docker image (frontend + backend + nginx) +echo up - Start the application (docker compose up -d) +echo down - Stop the application (docker compose down) +echo restart - Rebuild and restart the application +echo logs - Follow application logs +echo. +echo Usage: dev-tools.bat ^ +echo Example: dev-tools.bat setup +goto :end + +:setup +call :do_venv +call :do_dev_core +call :do_dev_frontend +call :do_pre_commit_install +echo ✅ Full development environment setup complete! +goto :end + +:venv +call :do_venv +goto :end + +:do_venv +echo 🐍 Creating virtual environment and installing dependencies... +if not exist .venv ( + python -m venv .venv +) +%PIP% install --upgrade pip +%PIP% install -r core\requirements.txt +echo ✅ Virtual environment ready! +exit /b + +:dev-core +call :do_dev_core +goto :end + +:do_dev_core +echo 🐍 Setting up core backend... +%PIP% install -r core\requirements-dev.txt +exit /b + +:dev-frontend +call :do_dev_frontend +goto :end + +:do_dev_frontend +echo 🌐 Installing frontend dependencies... +pushd frontend +call npm install +popd +exit /b + +:format +echo 🔧 Formatting code... +pushd core +..\%PYTHON% -m ruff format edge_mining/ tests/ +popd +echo ✅ Code formatting complete! +goto :end + +:lint +echo 🔍 Running linting checks... +pushd core +..\%PYTHON% -m ruff check edge_mining/ +..\%PYTHON% -m mypy edge_mining/ +..\%PYTHON% -m bandit -r edge_mining/ --skip B311,B104 +popd +echo ✅ Linting complete! +goto :end + +:lint-fix +echo 🔧 Running auto-fixable linting... +pushd core +..\%PYTHON% -m ruff check --fix edge_mining/ +..\%PYTHON% -m ruff format edge_mining/ +popd +echo ✅ Auto-fix complete! +goto :end + +:test +echo 🧪 Running tests... +pushd core +..\%PYTHON% -m pytest tests/ -v +popd +echo ✅ Tests complete! +goto :end + +:test-cov +echo 🧪 Running tests with coverage... +pushd core +..\%PYTHON% -m pytest tests/ -v --cov=edge_mining --cov-report=html --cov-report=term +popd +echo ✅ Tests with coverage complete! +goto :end + +:pre-commit +echo 🔧 Running pre-commit hooks... +call :do_pre_commit +goto :end + +:do_pre_commit +%PRE_COMMIT% run --all-files +exit /b + +:pre-commit-install +call :do_pre_commit_install +goto :end + +:do_pre_commit_install +echo 🔧 Installing pre-commit hooks... +%PRE_COMMIT% install +exit /b + +:clean +echo 🧹 Cleaning cache and temporary files... +pushd core +for /d /r . %%d in (__pycache__) do @if exist "%%d" rd /s /q "%%d" 2>nul +for /r . %%f in (*.pyc *.pyo) do @if exist "%%f" del /q "%%f" 2>nul +for /d /r . %%d in (*.egg-info) do @if exist "%%d" rd /s /q "%%d" 2>nul +if exist build rd /s /q build 2>nul +if exist dist rd /s /q dist 2>nul +if exist .coverage del /q .coverage 2>nul +if exist htmlcov rd /s /q htmlcov 2>nul +if exist .pytest_cache rd /s /q .pytest_cache 2>nul +popd +if exist frontend\node_modules\.tmp rd /s /q frontend\node_modules\.tmp 2>nul +echo ✅ Cleanup complete! +goto :end + +:build +echo 🐳 Building Docker image... +docker compose build +echo ✅ Docker build complete! +goto :end + +:up +echo 🐳 Starting application... +docker compose up -d +echo ✅ Application started! +goto :end + +:down +echo 🐳 Stopping application... +docker compose down +echo ✅ Application stopped! +goto :end + +:restart +echo 🐳 Rebuilding and restarting application... +docker compose build +docker compose up -d +echo ✅ Application restarted! +goto :end + +:logs +docker compose logs -f +goto :end + +:end diff --git a/dev-tools.ps1 b/dev-tools.ps1 new file mode 100644 index 0000000..192cf14 --- /dev/null +++ b/dev-tools.ps1 @@ -0,0 +1,220 @@ +# PowerShell script for Edge Mining Development Tools (Windows alternative to Makefile) + +param( + [Parameter(Position=0)] + [string]$Command = "help" +) + +# Variables +$VENV = ".venv\Scripts" +$PYTHON = "$VENV\python.exe" +$PIP = "$VENV\pip.exe" +$PRE_COMMIT = "$VENV\pre-commit.exe" + +function Show-Help { + Write-Host "Edge Mining App — Development & Docker Commands (PowerShell)" -ForegroundColor Green + Write-Host "============================================================" -ForegroundColor Green + Write-Host "" + Write-Host "Development:" -ForegroundColor Yellow + Write-Host " setup - Set up full development environment (core + frontend)" + Write-Host " venv - Create .venv and install all Python dependencies" + Write-Host " dev-core - Set up core backend development environment only" + Write-Host " dev-frontend - Install frontend dependencies only" + Write-Host " format - Format core code with ruff" + Write-Host " lint - Run all linting checks on core" + Write-Host " lint-fix - Run linting and auto-fix on core" + Write-Host " test - Run core tests" + Write-Host " test-cov - Run core tests with coverage" + Write-Host " pre-commit - Run pre-commit hooks on all files" + Write-Host " pre-commit-install - Install pre-commit hooks" + Write-Host " clean - Clean cache and temporary files" + Write-Host "" + Write-Host "Docker:" -ForegroundColor Yellow + Write-Host " build - Build the Docker image (frontend + backend + nginx)" + Write-Host " up - Start the application (docker compose up -d)" + Write-Host " down - Stop the application (docker compose down)" + Write-Host " restart - Rebuild and restart the application" + Write-Host " logs - Follow application logs" + Write-Host "" + Write-Host "Usage: .\dev-tools.ps1 " -ForegroundColor Cyan + Write-Host "Example: .\dev-tools.ps1 setup" -ForegroundColor Cyan +} + +function Setup-Venv { + Write-Host "🐍 Creating virtual environment and installing dependencies..." -ForegroundColor Blue + if (-not (Test-Path .venv)) { + & python -m venv .venv + } + & $PIP install --upgrade pip + & $PIP install -r core\requirements.txt + Write-Host "✅ Virtual environment ready!" -ForegroundColor Green +} + +function Setup-DevCore { + Write-Host "🐍 Setting up core backend..." -ForegroundColor Blue + & $PIP install -r core\requirements-dev.txt + Write-Host "✅ Core backend setup complete!" -ForegroundColor Green +} + +function Setup-DevFrontend { + Write-Host "🌐 Installing frontend dependencies..." -ForegroundColor Blue + Push-Location frontend + & npm install + Pop-Location + Write-Host "✅ Frontend dependencies installed!" -ForegroundColor Green +} + +function Setup-Environment { + Setup-Venv + Setup-DevCore + Setup-DevFrontend + Install-PreCommitHooks + Write-Host "✅ Full development environment setup complete!" -ForegroundColor Green +} + +function Format-Code { + Write-Host "🔧 Formatting code..." -ForegroundColor Blue + Push-Location core + & ..\.venv\Scripts\python.exe -m ruff format edge_mining/ tests/ + Pop-Location + Write-Host "✅ Code formatting complete!" -ForegroundColor Green +} + +function Run-Lint { + Write-Host "🔍 Running linting checks..." -ForegroundColor Blue + Push-Location core + & ..\.venv\Scripts\python.exe -m ruff check edge_mining/ + & ..\.venv\Scripts\python.exe -m mypy edge_mining/ + & ..\.venv\Scripts\python.exe -m bandit -r edge_mining/ --skip B311,B104 + Pop-Location + Write-Host "✅ Linting complete!" -ForegroundColor Green +} + +function Run-LintFix { + Write-Host "🔧 Running auto-fixable linting..." -ForegroundColor Blue + Push-Location core + & ..\.venv\Scripts\python.exe -m ruff check --fix edge_mining/ + & ..\.venv\Scripts\python.exe -m ruff format edge_mining/ + Pop-Location + Write-Host "✅ Auto-fix complete!" -ForegroundColor Green +} + +function Run-Tests { + Write-Host "🧪 Running tests..." -ForegroundColor Blue + Push-Location core + & ..\.venv\Scripts\python.exe -m pytest tests/ -v + Pop-Location + Write-Host "✅ Tests complete!" -ForegroundColor Green +} + +function Run-TestsWithCoverage { + Write-Host "🧪 Running tests with coverage..." -ForegroundColor Blue + Push-Location core + & ..\.venv\Scripts\python.exe -m pytest tests/ -v --cov=edge_mining --cov-report=html --cov-report=term + Pop-Location + Write-Host "✅ Tests with coverage complete!" -ForegroundColor Green +} + +function Run-PreCommit { + Write-Host "🔧 Running pre-commit hooks..." -ForegroundColor Blue + & $PRE_COMMIT run --all-files + Write-Host "✅ Pre-commit complete!" -ForegroundColor Green +} + +function Install-PreCommitHooks { + Write-Host "🔧 Installing pre-commit hooks..." -ForegroundColor Blue + & $PRE_COMMIT install + Write-Host "✅ Pre-commit hooks installed!" -ForegroundColor Green +} + +function Clean-Cache { + Write-Host "🧹 Cleaning cache and temporary files..." -ForegroundColor Blue + + Push-Location core + + # Remove __pycache__ directories + Get-ChildItem -Path . -Recurse -Directory -Name "__pycache__" | ForEach-Object { + Remove-Item -Path $_ -Recurse -Force -ErrorAction SilentlyContinue + } + + # Remove .pyc and .pyo files + Get-ChildItem -Path . -Recurse -Include "*.pyc", "*.pyo" | Remove-Item -Force -ErrorAction SilentlyContinue + + # Remove .egg-info directories + Get-ChildItem -Path . -Recurse -Directory -Name "*.egg-info" | ForEach-Object { + Remove-Item -Path $_ -Recurse -Force -ErrorAction SilentlyContinue + } + + # Remove build artifacts + @("build", "dist", ".coverage", "htmlcov", ".pytest_cache") | ForEach-Object { + if (Test-Path $_) { + Remove-Item -Path $_ -Recurse -Force -ErrorAction SilentlyContinue + } + } + + Pop-Location + + # Remove frontend temp files + if (Test-Path "frontend\node_modules\.tmp") { + Remove-Item -Path "frontend\node_modules\.tmp" -Recurse -Force -ErrorAction SilentlyContinue + } + + Write-Host "✅ Cleanup complete!" -ForegroundColor Green +} + +function Docker-Build { + Write-Host "🐳 Building Docker image..." -ForegroundColor Blue + & docker compose build + Write-Host "✅ Docker build complete!" -ForegroundColor Green +} + +function Docker-Up { + Write-Host "🐳 Starting application..." -ForegroundColor Blue + & docker compose up -d + Write-Host "✅ Application started!" -ForegroundColor Green +} + +function Docker-Down { + Write-Host "🐳 Stopping application..." -ForegroundColor Blue + & docker compose down + Write-Host "✅ Application stopped!" -ForegroundColor Green +} + +function Docker-Restart { + Write-Host "🐳 Rebuilding and restarting application..." -ForegroundColor Blue + & docker compose build + & docker compose up -d + Write-Host "✅ Application restarted!" -ForegroundColor Green +} + +function Docker-Logs { + & docker compose logs -f +} + +# Main command dispatcher +switch ($Command.ToLower()) { + "help" { Show-Help } + "setup" { Setup-Environment } + "venv" { Setup-Venv } + "dev-core" { Setup-DevCore } + "dev-frontend" { Setup-DevFrontend } + "format" { Format-Code } + "lint" { Run-Lint } + "lint-fix" { Run-LintFix } + "test" { Run-Tests } + "test-cov" { Run-TestsWithCoverage } + "pre-commit" { Run-PreCommit } + "pre-commit-install" { Install-PreCommitHooks } + "clean" { Clean-Cache } + "build" { Docker-Build } + "up" { Docker-Up } + "down" { Docker-Down } + "restart" { Docker-Restart } + "logs" { Docker-Logs } + default { + Write-Host "Unknown command: $Command" -ForegroundColor Red + Write-Host "" + Show-Help + exit 1 + } +} diff --git a/docs/ALEMBIC_MIGRATIONS.md b/docs/ALEMBIC_MIGRATIONS.md new file mode 100644 index 0000000..70df6e4 --- /dev/null +++ b/docs/ALEMBIC_MIGRATIONS.md @@ -0,0 +1,369 @@ +# Managing Migrations with Alembic + +## Overview + +The automatic migration system integrates Alembic into the hexagonal architecture while maintaining separation between domain and infrastructure. + +## DDD Principles and Hexagonal Architecture + +### Separation of Concerns + +- **Domain**: Domain entities remain pure and independent of persistence +- **Adapters**: SQLAlchemy repositories map entities to tables +- **Infrastructure**: Alembic manages database schema migrations + +### Imperative Mapping + +We use SQLAlchemy's imperative mapping to keep domain entities clean: + +```python +# Domain entities don't know about SQLAlchemy +@dataclass +class Miner(Entity): + """Entity for a miner.""" + + name: str = "" + model: Optional[str] = None + status: MinerStatus = MinerStatus.UNKNOWN + hash_rate: Optional[HashRate] = None # Hash rate in GH/s or TH/s + hash_rate_max: Optional[HashRate] = None # Max hash rate for the miner + power_consumption: Optional[Watts] = None # Can be dynamic or fixed + power_consumption_max: Optional[Watts] = None # Max power consumption for the miner + active: bool = True # Is the miner active in the system? + +# Mapping happens separately in the adapter +mapper_registry.map_imperatively(Miner, miners_table) +``` + +## Configuration + +### Settings + +In `edge_mining/shared/settings/settings.py`: + +```python +class AppSettings(BaseSettings): + persistence_adapter: str = "sqlalchemy" + db_path: str = "sqlite:///edgemining.db" + run_migrations_on_startup: bool = True # Automatic migrations + backup_before_migration: bool = True # Database backup before migrations +``` + +### Environment Variables + +You can configure migrations via `.env`: + +```bash +# Enable/disable automatic migrations +RUN_MIGRATIONS_ON_STARTUP=true + +# Create database backup before migrations (SQLite only) +BACKUP_BEFORE_MIGRATION=true + +# Database URL +DB_PATH=sqlite:///edgemining.db +# DB_PATH=postgresql://user:pass@localhost/dbname +``` + +## Startup Workflow + +### 1. Automatic Bootstrap + +When the application starts with `persistence_adapter=sqlalchemy`: + +```python +# In bootstrap.py - simplified code +sqlalchemy_db = BaseSQLAlchemyRepository( + db_path=db_url, + logger=logger, + run_migrations=settings.run_migrations_on_startup, + backup_before_migration=settings.backup_before_migration, +) + +# Single method that handles all initialization +sqlalchemy_db.initialize_database() +``` + +### 2. Automatic Execution + +The `initialize_database()` method in `BaseSQLAlchemyRepository`: + +1. **Loads table definitions** via `registry_loader` (imported at module level) +2. **Runs Alembic migrations** (if `run_migrations=True`) + - **Checks for pending migrations** + - Compares current database revision with latest migration script + - Skips migration process if database is already up to date + - **Raises RuntimeError if no migration files exist** (enforces Alembic-only approach) + - **Creates database backup** (if enabled and migrations are pending) + - Only for SQLite databases + - Backup file format: `dbname_backup_YYYYMMDD_HHMMSS.db` + - **Applies all pending migrations** with `alembic upgrade head` +3. **Database schema is EXCLUSIVELY managed through Alembic migrations** + - No fallback to `metadata.create_all()` + - First run requires an initial migration (e.g., `alembic revision --autogenerate -m "Initial schema"`) + - All subsequent schema changes must be done via migrations + - If migrations fail, initialization fails (fail-fast) + +## Initial Setup (New Projects) + +For a completely new project or empty database: + +1. **Generate Initial Migration** + ```bash + # This will auto-detect all tables defined in the codebase + alembic revision --autogenerate -m "Initial schema with all tables" + ``` + +2. **Review Generated Migration** + ```python + # In alembic/versions/xxx_initial_schema_with_all_tables.py + # Verify that all tables are included + # Check that custom types are properly imported + ``` + +3. **Apply Migration** + ```bash + # Start the application (migrations run automatically) + python -m edge_mining + + # Or run manually + alembic upgrade head + ``` + +**Note**: The migration template (`alembic/script.py.mako`) automatically includes imports for all custom types, so auto-generated migrations should work without manual import fixes. + +## Creating New Migrations + +### Development Workflow + +1. **Modify Domain Entities** + ```python + # Add a new attribute + @dataclass + class Miner(Entity): + """Entity for a miner.""" + + name: str = "" + model: Optional[str] = None + status: MinerStatus = MinerStatus.UNKNOWN + hash_rate: Optional[HashRate] = None # Hash rate in GH/s or TH/s + hash_rate_max: Optional[HashRate] = None # Max hash rate for the miner + power_consumption: Optional[Watts] = None # Can be dynamic or fixed + power_consumption_max: Optional[Watts] = None # Max power consumption for the miner + active: bool = True # Is the miner active in the system? + + temperature: Optional[float] = None + ``` + +2. **Update Table Definition** + ```python + # In edge_mining/adapters/domain/miner/tables.py + miners_table = Table( + "miners", + metadata, + Column("temperature", Float, nullable=True), + ... + ) + ``` + +3. **Generate Migration** + ```bash + python scripts/migrate.py create "Add temperature field to miners" + ``` + +4. **Verify Generated Migration** + ```python + # In alembic/versions/xxx_add_temperature_field.py + def upgrade() -> None: + op.add_column('miners', sa.Column('temperature', sa.Float(), nullable=True)) + + def downgrade() -> None: + op.drop_column('miners', 'temperature') + ``` + +5. **Apply Migration** + ```bash + # Manually + python scripts/migrate.py upgrade + + # Or restart the application (if run_migrations_on_startup=true) + python -m edge_mining + ``` + +## Alembic Commands + +### Check Migration Status + +```bash +# Show current revision +python scripts/migrate.py status + +# Show migration history +python scripts/migrate.py history + +# Or use Alembic directly +alembic current +alembic history --verbose +``` + +### Managing Migrations + +```bash +# Create new migration (autogenerate) +python scripts/migrate.py create "Description" + +# Create empty migration +python scripts/migrate.py create "Description" --no-autogenerate + +# Apply all migrations +python scripts/migrate.py upgrade + +# Rollback migrations +python scripts/migrate.py downgrade [n] + +# Or use Alembic directly +alembic revision --autogenerate -m "Description" +alembic revision -m "Description" +alembic upgrade head +alembic upgrade +alembic downgrade -1 +alembic downgrade +``` + +### SQL Visualization + +```bash +# Show SQL without executing +alembic upgrade head --sql + +# Generate SQL for offline migration +alembic upgrade head --sql > migration.sql +``` + +## Best Practices + +### Development + +1. **Always verify autogenerated migrations**: Alembic may not detect all changes +2. **Test upgrade and downgrade**: Make sure both work +3. **Use descriptive names**: `add_temperature_to_miners` instead of `update_table` +4. **Commit migrations with code**: Migrations are part of the code +5. **Never delete the initial migration**: It's required for creating new databases +6. **Custom types are auto-imported**: The template (`script.py.mako`) includes all custom type imports + +### Production + +1. **Disable autogenerate**: Use only tested migrations +2. **Backup before migrations**: Always! (automatically done if `backup_before_migration=true`) +3. **Test on staging environment**: Before production +4. **Monitor execution**: Log all migration steps + +### Multi-Database + +For databases other than SQLite, configure the appropriate connection URL: + +```python +# PostgreSQL +DB_PATH=postgresql://user:password@localhost:5432/edgemining + +# MySQL +DB_PATH=mysql+pymysql://user:password@localhost:3306/edgemining + +# SQL Server +DB_PATH=mssql+pyodbc://user:password@localhost/edgemining?driver=ODBC+Driver+17+for+SQL+Server +``` + +## Troubleshooting + +### Failed Migration + +If a migration fails midway: + +```bash +# Check status +alembic current + +# If necessary, manually force the revision +alembic stamp + +# Manually repair the database if needed +# Then retry migration +alembic upgrade head +``` + +### Merge Conflicts + +If two developers create migrations in parallel: + +```bash +# Create a merge migration +alembic merge -m "Merge revisions" + +# Apply the merge +alembic upgrade head +``` + +### Complete Reset (Development Only!) + +```bash +# WARNING: Deletes all data! +rm edgemining.db + +# Option 1: Run the application (migrations apply automatically) +python -m edge_mining + +# Option 2: Run migrations manually +alembic upgrade head +``` + +### Missing Initial Migration + +If you encounter an error like "No Alembic migrations found": + +```bash +# Generate the initial migration +alembic revision --autogenerate -m "Initial schema with all tables" + +# Review the generated file in alembic/versions/ +# Then apply it +alembic upgrade head +``` + +## CI/CD Integration + +### Pre-deploy Check + +```bash +#!/bin/bash +# check_migrations.sh + +# Verify all migrations are applied +alembic current | grep "head" || { + echo "Pending migrations found!" + alembic history --verbose + exit 1 +} +``` + +### Automated Testing + +```python +# tests/unit/adapters/persistence/test_migrations.py +def test_migrations_apply_cleanly(): + """Test that all migrations can be applied from scratch.""" + # Drop database + # Run migrations + # Verify schema + +def test_migrations_reversible(): + """Test that migrations can be downgraded and reapplied.""" + # Apply all migrations + # Downgrade one step + # Upgrade again +``` + +## References + +- [Alembic Documentation](https://alembic.sqlalchemy.org/) +- [SQLAlchemy Imperative Mapping](https://docs.sqlalchemy.org/en/20/orm/mapping_styles.html#imperative-mapping) +- See also: `docs/SQLALCHEMY_ALEMBIC_GUIDE.md` for implementation details diff --git a/docs/MIGRATION_EXAMPLE.md b/docs/MIGRATION_EXAMPLE.md new file mode 100644 index 0000000..d04a80a --- /dev/null +++ b/docs/MIGRATION_EXAMPLE.md @@ -0,0 +1,367 @@ +# Practical Example: Adding a "Temperature" Field to Miners + +This example shows the complete workflow for adding a new field to the domain while maintaining DDD and hexagonal architecture. + +## Step 1: Modify the Domain Entity + +**File**: `edge_mining/domain/miner/entities.py` + +```python +@dataclass +class Miner(Entity): + """Entity for a miner.""" + + name: str = "" + model: Optional[str] = None + status: MinerStatus = MinerStatus.UNKNOWN + hash_rate: Optional[HashRate] = None + hash_rate_max: Optional[HashRate] = None + power_consumption: Optional[Watts] = None # Watts, non float! + power_consumption_max: Optional[Watts] = None + active: bool = True # Default True + controller_id: Optional[EntityId] = None + + temperature: Optional[float] = None # NEW FIELD +``` + +## Step 2: Update the Table Definition + +**File**: `edge_mining/adapters/domain/miner/tables.py` + +```python +from sqlalchemy import Boolean, Column, Float, ForeignKey, String, Table + +from edge_mining.adapters.infrastructure.persistence.sqlalchemy.registry import metadata + +miners_table = Table( + "miners", + metadata, + # Primary Key + Column("id", String, primary_key=True, index=True), + # Basic attributes + Column("name", String, nullable=False), + Column("model", String, nullable=True), + Column("status", MinerStatusType, nullable=False, default="UNKNOWN"), # Custom type! + Column("active", Boolean, nullable=False, default=True), + # Hash Rate Value Object - stored as JSON in single String column + Column("hash_rate", String, nullable=True), # JSON, non separate value/unit + Column("hash_rate_max", String, nullable=True), + # Power Consumption (Watts Value Object stored as float) + Column("power_consumption", Float, nullable=True), + Column("power_consumption_max", Float, nullable=True), + # Foreign Key to MinerController + Column("controller_id", String, ForeignKey("miner_controllers.id"), nullable=True), + # NEW COLUMN + Column("temperature", Float, nullable=True), +) +``` + +**Important notes**: +- `HashRate` is serialized as **JSON in a single String column**, not in separate `value`/`unit` columns +- `status` uses custom `MinerStatusType` for enum ↔ string conversion +- `controller_id` is a **Foreign Key** to `miner_controllers.id` + +## Step 3: Verify Imperative Mapping and Event Listeners + +**File**: `edge_mining/adapters/domain/miner/tables.py` + +The imperative mapping is already configured and **requires no changes** for simple fields like `temperature`: + +```python +# Map Miner (child in the relationship) +mapper_registry.map_imperatively( + Miner, + miners_table, + properties={ + # Relationship to controller + "controller": relationship( + "MinerController", + foreign_keys=[miners_table.c.controller_id], + lazy="joined", + ), + }, +) +``` + +**Note**: No need to explicitly map every field! SQLAlchemy automatically maps columns with the same name as entity attributes. The `properties` dictionary contains only **relationships** and special configurations. + +**Event Listeners**: For simple fields like `float`, no changes are needed. Event listeners `_receive_miner_load` and `_flatten_miner_value_objects` only handle conversions for **complex value objects** (HashRate, Watts). + +## Step 4: SQLAlchemy Repository + +**File**: `edge_mining/adapters/domain/miner/repositories.py` + +**IMPORTANT**: With imperative mapping, the `SqlAlchemyMinerRepository` works **directly with mapped domain entities**. + +SQLAlchemy and event listeners automatically handle all conversions between domain and database. + +**How the repository works**: + +```python +class SqlAlchemyMinerRepository(MinerRepository): + def add(self, miner: Miner) -> None: + """Add a new miner to the repository.""" + session = self._db.get_session() + try: + session.add(miner) # Directly the domain entity! + session.commit() + finally: + session.close() + + def get_by_id(self, miner_id: EntityId) -> Optional[Miner]: + """Retrieve a miner by its ID.""" + session = self._db.get_session() + try: + stmt = select(Miner).where(Miner.id == miner_id) + return session.execute(stmt).scalar_one_or_none() # Returns the entity! + finally: + session.close() +``` + +**For the new `temperature` field**: being a simple `float`, SQLAlchemy handles it automatically. No repository changes needed! + +**If the field were a complex value object**: you would need to add conversions in event listeners `_receive_miner_load` (reconstruction after loading) and `_flatten_miner_value_objects` (flattening before saving). + +## Step 5: Generate the Migration + +```bash +# Automatically generate the migration +python scripts/migrate.py create "Add temperature field to miners" +``` + +This will create a file like: +`alembic/versions/xxx_add_temperature_field_to_miners.py` + +```python +"""Add temperature field to miners + +Revision ID: abc123def456 +Revises: cde387d5834c +Create Date: 2026-01-22 20:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'abc123def456' +down_revision: Union[str, None] = 'cde387d5834c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column('miners', sa.Column('temperature', sa.Float(), nullable=True)) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column('miners', 'temperature') +``` + +## Step 6: Verify the Migration + +```bash +# Display the SQL that will be executed +alembic upgrade head --sql +``` + +**Note**: The `python scripts/migrate.py` script **does not support** the `--sql` flag. To view the generated SQL, use Alembic directly. + +## Step 7: Apply the Migration + +### Option A: Automatic (on startup) + +```bash +# With RUN_MIGRATIONS_ON_STARTUP=true and BACKUP_BEFORE_MIGRATION=true +python -m edge_mining +``` + +**The system automatically**: +1. ✅ Checks if there are pending migrations +2. ✅ Creates a database backup (if enabled and there are migrations to apply) + - Backup format: `edgemining_backup_YYYYMMDD_HHMMSS.db` +3. ✅ Applies **only** the necessary migrations +4. ✅ Skips completely if the database is already up to date + +**Configuration** (`.env`): +```bash +RUN_MIGRATIONS_ON_STARTUP=true +BACKUP_BEFORE_MIGRATION=true # Automatic backup (SQLite only) +``` + +### Option B: Manual + +```bash +# Apply pending migrations +python scripts/migrate.py upgrade + +# Verify current status +python scripts/migrate.py status + +# Display history +python scripts/migrate.py history +``` + +## Step 8: Test + +```python +# test_miner_temperature.py +def test_miner_with_temperature(): + """Test that miner can store and retrieve temperature.""" + # Create miner with dataclass syntax + miner = Miner( + id=EntityId.generate(), + name="Test Miner", + temperature=65.5, + ) + + # Save to repository + miner_repo.add(miner) + + # Retrieve from repository + retrieved = miner_repo.get_by_id(miner.id) + + # Verify + assert retrieved is not None + assert retrieved.temperature == 65.5 + assert retrieved.name == "Test Miner" +``` + +**Note**: The `Miner` class is a `@dataclass`, so it is instantiated directly by passing parameters. SQLAlchemy handles persistence automatically through imperative mapping. + +## Rollback (If Necessary) + +If something goes wrong: + +```bash +# Rollback one migration +python scripts/migrate.py downgrade 1 + +# Or return to a specific revision +alembic downgrade cde387d5834c +``` + +## Best Practices + +### ✅ DO + +1. **Always add optional fields initially** (`nullable=True`) +2. **Test on test database first** +3. **Verify the autogenerated migration** (it might not be perfect) +4. **Add appropriate default values** for NOT NULL fields +5. **Document the reason for the change** in the migration message + +### ❌ DON'T + +1. **Don't modify migrations already applied** in production +2. **Don't remove fields without a data migration plan** +3. **Don't make breaking changes without deployment strategy** +4. **Don't ignore migration errors** +5. **Don't apply migrations without backup** + +## Complex Migrations + +### Rename a Column + +```python +def upgrade() -> None: + op.alter_column('miners', 'old_name', new_column_name='new_name') + +def downgrade() -> None: + op.alter_column('miners', 'new_name', new_column_name='old_name') +``` + +### Add NOT NULL Field with Default + +```python +def upgrade() -> None: + # First add as nullable with default + op.add_column('miners', sa.Column('required_field', sa.String(), nullable=True)) + + # Populate existing values + op.execute("UPDATE miners SET required_field = 'default_value' WHERE required_field IS NULL") + + # Make NOT NULL + op.alter_column('miners', 'required_field', nullable=False) + +def downgrade() -> None: + op.drop_column('miners', 'required_field') +``` + +### Create Index + +```python +def upgrade() -> None: + op.create_index('idx_miners_temperature', 'miners', ['temperature']) + +def downgrade() -> None: + op.drop_index('idx_miners_temperature', 'miners') +``` + +## Complete Workflow Summary + +```bash +# 1. Modify the code (domain, tables, repository) + +# 2. Generate migration +python scripts/migrate.py create "Description of changes" + +# 3. Verify the generated file +cat alembic/versions/xxx_descrizione_modifiche.py + +# 4. Test on development database +python scripts/migrate.py upgrade + +# 5. Verify it works +python -m edge_mining + +# 6. Commit everything together +git add edge_mining/ alembic/versions/ +git commit -m "feat: add temperature field to miners" +``` + +## Common Troubleshooting + +### Migration Not Detected + +```bash +# Make sure registry_loader is imported +# Verify the table is in metadata +python -c "from edge_mining.adapters.infrastructure.persistence.sqlalchemy import registry_loader; \ + from edge_mining.adapters.infrastructure.persistence.sqlalchemy.registry import metadata; \ + print(list(metadata.tables.keys()))" +``` + +### Migration Conflict + +If two developers create migrations in parallel: + +```bash +# Create a merge migration +alembic merge -m "Merge migrations" rev1 rev2 +alembic upgrade head +``` + +### Database Out of Sync + +```bash +# Force the current revision +alembic stamp head + +# Or return to a specific revision +alembic stamp +``` + +## Conclusion + +This workflow maintains: +- ✅ **DDD**: Domain entities always pure +- ✅ **Hexagonal Architecture**: Separate adapters +- ✅ **Version Control**: Traceable migrations +- ✅ **Safety**: Rollback supported +- ✅ **Automation**: Automatic migrations on startup diff --git a/docs/WEBSOCKET.md b/docs/WEBSOCKET.md new file mode 100644 index 0000000..2efb42a --- /dev/null +++ b/docs/WEBSOCKET.md @@ -0,0 +1,253 @@ +# WebSocket Client Guide + +## Connection + +Connect to the WebSocket endpoint at: + +``` +ws://:/ws/events +``` + +After the connection is established, the server does **not** send any messages by default. You must explicitly subscribe to topics of interest. + +## Protocol + +All messages exchanged between client and server are JSON objects. + +### Discover Available Topics + +Request the list of all topics the server can produce: + +```json +{ "get_topics": true } +``` + +Response: + +```json +{ + "type": "available_topics", + "topics": [ + "config.updated", + "energy.state", + "miner.state", + "policy.context", + "rule.engaged" + ] +} +``` + +### Subscribe to Topics + +Send a `subscribe` message with an array of topic patterns: + +```json +{ "subscribe": ["energy.*", "miner.state"] } +``` + +Response (confirmation with the full list of your active subscriptions): + +```json +{ + "type": "subscribed", + "topics": ["energy.*", "miner.state"] +} +``` + +### Unsubscribe from Topics + +```json +{ "unsubscribe": ["energy.*"] } +``` + +Response: + +```json +{ + "type": "subscribed", + "topics": ["miner.state"] +} +``` + +### Receiving Events + +Once subscribed, events are pushed as they occur: + +```json +{ + "topic": "energy.state", + "payload": { + "optimization_unit_id": "550e8400-e29b-41d4-a716-446655440000", + "optimization_unit_name": "Solar Unit 1", + "energy_source_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "energy_state_snapshot": { ... } + } +} +``` + +## Topic Pattern Matching + +Subscription patterns use glob-style matching (similar to file path wildcards): + +| Pattern | Matches | +|--------------------|----------------------------------------------------------| +| `energy.state` | Only `energy.state` | +| `energy.*` | Any topic starting with `energy.` (e.g. `energy.state`) | +| `miner.*` | Any topic starting with `miner.` (e.g. `miner.state`) | +| `*` | All topics | +| `*.state` | Any topic ending with `.state` | + +## Available Topics and Payloads + +### `energy.state` + +Emitted when a new energy state snapshot is read from an energy source. + +```json +{ + "topic": "energy.state", + "payload": { + "optimization_unit_id": "string | null", + "optimization_unit_name": "string", + "energy_source_id": "string | null", + "energy_state_snapshot": "object | null" + } +} +``` + +### `miner.state` + +Emitted when a miner changes its operational status (started, stopped, etc.). + +```json +{ + "topic": "miner.state", + "payload": { + "miner_id": "string | null", + "miner_name": "string", + "old_status": "string | null", + "new_status": "string | null" + } +} +``` + +### `rule.engaged` + +Emitted when a policy rule produces a mining decision for an optimization unit. + +```json +{ + "topic": "rule.engaged", + "payload": { + "optimization_unit_id": "string | null", + "optimization_unit_name": "string", + "policy_id": "string | null", + "policy_name": "string", + "miner_id": "string | null", + "decision": "string | null", + "miner_status": "string" + } +} +``` + +`decision` values: `"start_mining"`, `"stop_mining"`, `"maintain_state"`. + +### `policy.context` + +Emitted when a new decisional context is composed for an optimization unit. + +```json +{ + "topic": "policy.context", + "payload": { + "optimization_unit_id": "string | null", + "optimization_unit_name": "string", + "context": "object | null", + "target_miner_ids": ["string"] + } +} +``` + +### `config.updated` + +Emitted when a configuration entity is created, updated, or removed. + +```json +{ + "topic": "config.updated", + "payload": { + "entity_type": "string", + "entity_id": "string | null", + "action": "string" + } +} +``` + +`action` values: `"created"`, `"updated"`, `"removed"`. + +## Client Examples + +### JavaScript / Browser + +```javascript +const ws = new WebSocket("ws://localhost:8000/ws/events"); + +ws.onopen = () => { + // Discover available topics + ws.send(JSON.stringify({ get_topics: true })); + + // Subscribe to all energy and miner events + ws.send(JSON.stringify({ subscribe: ["energy.*", "miner.*"] })); +}; + +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + if (data.type === "available_topics") { + console.log("Available topics:", data.topics); + return; + } + + if (data.type === "subscribed") { + console.log("Active subscriptions:", data.topics); + return; + } + + // Domain event + console.log(`[${data.topic}]`, data.payload); +}; + +ws.onclose = () => { + console.log("Disconnected"); +}; +``` + +### Python (websockets library) + +```python +import asyncio +import json +import websockets + +async def main(): + async with websockets.connect("ws://localhost:8000/ws/events") as ws: + # Subscribe to everything + await ws.send(json.dumps({"subscribe": ["*"]})) + + async for raw in ws: + data = json.loads(raw) + if data.get("type") == "subscribed": + print(f"Subscribed to: {data['topics']}") + else: + print(f"[{data['topic']}] {data['payload']}") + +asyncio.run(main()) +``` + +## Notes + +- A newly connected client receives **no events** until it sends a `subscribe` message. +- Multiple `subscribe` messages are cumulative — subscriptions are added, not replaced. +- To replace all subscriptions, unsubscribe from `["*"]` first, then subscribe to the desired topics. +- Dead connections are automatically cleaned up by the server when a send fails. +- All ID fields are serialized as strings (UUID format). diff --git a/docs/architecture/event-bus-design.md b/docs/architecture/event-bus-design.md new file mode 100644 index 0000000..d976b89 --- /dev/null +++ b/docs/architecture/event-bus-design.md @@ -0,0 +1,285 @@ +# Event Bus Architecture Design + +## Overview + +The event bus provides decoupled communication between application services. Services publish domain events without knowing who consumes them; subscribers react independently. This eliminates circular dependencies between services (e.g. `ConfigurationService` → `AdapterService` cache invalidation) and enables cross-cutting concerns like WebSocket broadcasting without polluting business logic. + +The bus is fully async and supports two delivery modes per subscriber: **blocking** (publisher waits) and **fire-and-forget** (publisher continues immediately). This models the real-world distinction between critical operations that must complete before the flow continues and best-effort side effects that can happen in the background. + +## Architecture Diagram + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Publishers │ +│ │ +│ ┌───────────────────────────┐ ┌─────────────────────┐ │ +│ │ ConfigurationService │ │ OptimizationService │ │ +│ │ │ │ │ │ +│ │ publish(ConfigurationUpd) │ │ publish( │ │ +│ │ │ │ EnergyStateUpd) │ │ +│ └────────┬──────────────────┘ │ DecisionalCtxUpd) │ │ +│ │ │ RuleEngagedEvent) │ │ +│ │ │ MinerStateChanged) │ │ +│ │ └────────┬────────────┘ │ +│ │ │ │ +│ │ ┌───────────────────────────┘ │ +│ │ │ ┌──────────────────┐ │ +│ │ │ │MinerActionService│ │ +│ │ │ │ publish( │ │ +│ │ │ │ MinerStateChgd) │ │ +│ │ │ └───────┬──────────┘ │ +└───────────┼──┼──────────┼────────────────────────────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ InMemoryEventBus │ +│ │ +│ 1. Execute blocking handlers sequentially (await each) │ +│ 2. Dispatch fire-and-forget handlers via asyncio.create_task() │ +│ │ +└───────────┬──────────────────────────────┬───────────────────────┘ + │ │ + ▼ ▼ + ┌──────────────────────┐ ┌─────────────────────────┐ + │ blocking=True │ │ blocking=False │ + │ │ │ │ + │ AdapterService │ │ WebSocketManager │ + │ .on_configuration_ │ │ (broadcasts to clients │ + │ updated() │ │ via /ws/events) │ + │ (cache invalidation) │ │ │ + └──────────────────────┘ └─────────────────────────┘ +``` + +## Key Components + +### `DomainEvent` (base dataclass) + +Located in `edge_mining/domain/common.py`. + +Base class for all events in the system. Provides automatic ID generation, timestamp, and a serialization helper: + +```python +@dataclass +class DomainEvent: + event_id: str = field(default_factory=lambda: str(uuid.uuid4())) + occurred_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + @property + def event_type(self) -> str: + return self.__class__.__name__ +``` + +### `EventBusInterface` (ABC) + +Located in `edge_mining/application/interfaces.py`. + +The application-layer port that services depend on. Defines two operations: + +```python +class EventBusInterface(ABC): + @abstractmethod + async def publish(self, event: DomainEvent) -> None: ... + + @abstractmethod + def subscribe( + self, + event_type: Type[DomainEvent], + handler: Callable, + blocking: bool = True, + ) -> None: ... +``` + +The `blocking` parameter controls delivery semantics per subscriber. + +### `InMemoryEventBus` (adapter) + +Located in `edge_mining/adapters/infrastructure/event_bus/in_memory_event_bus.py`. + +The single concrete implementation. On `publish()`: + +1. **Blocking handlers** are awaited sequentially — exceptions propagate to the publisher. +2. **Fire-and-forget handlers** are dispatched via `asyncio.create_task()` — exceptions are caught and logged as warnings. + +```python +async def publish(self, event: DomainEvent) -> None: + handlers = self._handlers.get(type(event), []) + + # 1. Blocking — publisher WAITS, exceptions propagate + for handler, is_blocking in handlers: + if is_blocking: + await handler(event) + + # 2. Fire-and-forget — publisher CONTINUES, exceptions caught + for handler, is_blocking in handlers: + if not is_blocking: + asyncio.create_task(self._safe_execute(handler, event)) +``` + +### `Services` (dataclass) + +Located in `edge_mining/shared/infrastructure.py`. + +The DI container carries the `event_bus` instance alongside all application services: + +```python +@dataclass(frozen=True) +class Services: + adapter_service: AdapterServiceInterface + optimization_service: OptimizationServiceInterface + miner_action_service: MinerActionServiceInterface + configuration_service: ConfigurationServiceInterface + event_bus: EventBusInterface +``` + +## File Structure + +``` +edge_mining/ +├── domain/ +│ ├── common.py # DomainEvent base class +│ ├── energy/events.py # EnergyStateSnapshotUpdatedEvent +│ ├── miner/events.py # MinerStateChangedEvent +│ ├── optimization_unit/events.py # RuleEngagedEvent +│ └── policy/events.py # DecisionalContextUpdatedEvent +│ +├── application/ +│ ├── interfaces.py # EventBusInterface (port) +│ ├── events/ +│ │ ├── common.py # ConfigurationAction, ConfigurationUpdatedEventType enums +│ │ └── configuration_events.py # ConfigurationUpdatedEvent +│ └── services/ +│ ├── configuration_service.py # publishes ConfigurationUpdatedEvent +│ ├── optimization_service.py # publishes Energy/DecisionalContext/RuleEngaged/MinerStateChanged +│ ├── miner_action_service.py # publishes MinerStateChangedEvent +│ └── adapter_service.py # subscribes to ConfigurationUpdatedEvent (blocking) +│ +├── adapters/infrastructure/ +│ └── event_bus/ +│ └── in_memory_event_bus.py # InMemoryEventBus implementation +│ +├── shared/ +│ └── infrastructure.py # Services dataclass (holds event_bus) +│ +└── bootstrap.py # Wires event bus, services, and subscriptions +``` + +## Registered Events + +### Domain Events + +| Event | Subdomain | Publisher(s) | Description | +|---|---|---|---| +| `EnergyStateSnapshotUpdatedEvent` | energy | `OptimizationService` | New energy state snapshot read from a source | +| `MinerStateChangedEvent` | miner | `OptimizationService`, `MinerActionService` | Miner operational status changed (on/off) | +| `RuleEngagedEvent` | optimization_unit | `OptimizationService` | Policy rule produced a mining decision | +| `DecisionalContextUpdatedEvent` | policy | `OptimizationService` | Decisional context composed for an optimization unit | + +### Application Events + +| Event | Layer | Publisher | Description | +|---|---|---|---| +| `ConfigurationUpdatedEvent` | application | `ConfigurationService` | Entity created, updated, or removed | + +`ConfigurationUpdatedEvent` uses typed enums instead of raw strings: + +- `ConfigurationUpdatedEventType`: `ENERGY_MONITOR`, `MINER_CONTROLLER`, `NOTIFIER`, `EXTERNAL_SERVICE` +- `ConfigurationAction`: `CREATED`, `UPDATED`, `REMOVED` + +## Registered Subscribers + +| Event | Subscriber | Blocking | Purpose | +|---|---|---|---| +| `ConfigurationUpdatedEvent` | `AdapterService.on_configuration_updated` | **Yes** | Invalidate adapter instance cache | +| `EnergyStateSnapshotUpdatedEvent` | `WebSocketManager` (via handler) | No | Broadcast to frontend | +| `MinerStateChangedEvent` | `WebSocketManager` (via handler) | No | Broadcast to frontend | +| `RuleEngagedEvent` | `WebSocketManager` (via handler) | No | Broadcast to frontend | +| `DecisionalContextUpdatedEvent` | `WebSocketManager` (via handler) | No | Broadcast to frontend | +| `ConfigurationUpdatedEvent` | `WebSocketManager` (via handler) | No | Broadcast to frontend | + +## Subscription Wiring + +Subscriptions are established in two ways, both during application startup: + +**Self-registering services** — `AdapterService` subscribes itself in its constructor: + +```python +class AdapterService: + def __init__(self, ..., event_bus: EventBusInterface, ...): + self._subscribe_events(event_bus) + + def _subscribe_events(self, event_bus: EventBusInterface) -> None: + event_bus.subscribe( + ConfigurationUpdatedEvent, + self.on_configuration_updated, + blocking=True, + ) +``` + +**WebSocketManager** — subscribes all its handlers' registrations in its constructor (see `docs/architecture/websocket-design.md` for details). + +Both are instantiated in `bootstrap.py` → `configure_dependencies()`, where the same `InMemoryEventBus` instance is injected into all services. + +## Delivery Semantics + +### Blocking (`blocking=True`) + +The publisher awaits the handler. If it raises, the exception propagates to the publisher. Used for operations that **must** complete before the business flow continues. + +Current blocking handlers are in-memory operations (cache invalidation via `dict.pop()`), so failure probability is near zero. If a blocking handler were to perform real I/O in the future, the error handling strategy should be revisited. + +### Fire-and-forget (`blocking=False`) + +The handler runs as a detached `asyncio.Task`. Exceptions are caught by `_safe_execute()` and logged as warnings. The publisher never knows if it succeeded or failed. + +No retry mechanism is implemented. If a WebSocket broadcast fails because a client disconnected, retrying would be pointless. If transient failures become a real concern, a retry/backpressure layer can be added at the bus level without changing any publisher or subscriber. + +## How to Add a New Event + +### Step 1 — Define the event + +Create a dataclass extending `DomainEvent` in the relevant subdomain: + +```python +# edge_mining/domain/miner/events.py + +@dataclass +class MinerHashrateUpdatedEvent(DomainEvent): + miner_id: Optional[EntityId] = None + hashrate: float = 0.0 +``` + +### Step 2 — Publish from a service + +Call `event_bus.publish()` after the business operation: + +```python +# In the application service +if self._event_bus: + await self._event_bus.publish( + MinerHashrateUpdatedEvent( + miner_id=miner.id, + hashrate=current_hashrate, + ) + ) +``` + +The `if self._event_bus` guard allows services to work without an event bus in testing or standalone modes. + +### Step 3 — Subscribe (if needed) + +For a blocking subscriber in another service: + +```python +event_bus.subscribe(MinerHashrateUpdatedEvent, some_service.on_hashrate_updated, blocking=True) +``` + +For WebSocket broadcasting, add a registration to the relevant subdomain handler (see `docs/architecture/websocket-design.md`). + +## Design Decisions + +- **Single bus, two delivery modes.** A single `InMemoryEventBus` serves both internal (cache invalidation) and external (WebSocket) subscribers. The `blocking` flag provides the necessary semantic distinction without the complexity of separate bus instances. +- **Async-first.** All handlers are async coroutines. The bus uses `await` for blocking and `asyncio.create_task()` for fire-and-forget, matching the async nature of the application services (Home Assistant API, PyASIC sockets, Telegram API). +- **Exception propagation for blocking handlers.** Blocking handlers today are in-memory operations that should never fail. If they do, it's a bug, and the exception should surface to the caller rather than being silently swallowed. +- **Application-level configuration event.** `ConfigurationUpdatedEvent` uses a generic structure with typed enums (`ConfigurationUpdatedEventType`, `ConfigurationAction`) rather than per-entity event classes. The primary use case is cache invalidation, where the subscriber only needs to know *which cache entry* is stale, not the full entity data. This avoids class proliferation (3 actions × N entity types) while retaining type safety through enums. +- **Domain events in subdomains.** Domain events (`EnergyStateSnapshotUpdatedEvent`, `MinerStateChangedEvent`, etc.) live in their respective subdomain's `events.py`, following DDD ownership principles. If a subdomain were extracted into a separate bounded context, its events would travel with it. +- **Optional event bus injection.** Services accept `event_bus: Optional[EventBusInterface] = None` and guard publishes with `if self._event_bus`. This keeps services testable without requiring a bus mock in every unit test. diff --git a/docs/architecture/websocket-design.md b/docs/architecture/websocket-design.md new file mode 100644 index 0000000..adf12d1 --- /dev/null +++ b/docs/architecture/websocket-design.md @@ -0,0 +1,263 @@ +# WebSocket Architecture Design + +## Overview + +The WebSocket layer provides real-time push notifications to connected clients whenever domain events occur in the system. It follows the same DDD and hexagonal architecture principles used throughout the codebase, with each subdomain owning its own serialization logic while a central manager handles connection management and message delivery. + +The design mirrors the pattern used by `main_api.py`, which aggregates FastAPI routers from each subdomain: the `WebSocketManager` aggregates `WebSocketEventHandler` instances from each subdomain and wires them to the event bus automatically. + +## Architecture Diagram + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Domain Layer │ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ ┌────────────────┐ │ +│ │ EnergyState │ │ MinerState │ │ RuleEngaged │ │ +│ │ SnapshotUpdated │ │ ChangedEvent │ │ Event │ │ +│ │ Event │ │ │ │ │ │ +│ └────────┬─────────┘ └────────┬─────────┘ └───────┬────────┘ │ +│ │ │ │ │ +│ ┌────────┴──────────┐ ┌───────┴──────────┐ │ +│ │ DecisionalContext │ │ Configuration │ │ +│ │ UpdatedEvent │ │ UpdatedEvent │ (application) │ +│ └────────┬──────────┘ └───────┬──────────┘ │ +└───────────┼─────────────────────┼────────────────────────────────┘ + │ │ + ▼ ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Event Bus │ +│ (subscribes with blocking=False for fire-and-forget delivery) │ +└──────────────────────────┬───────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ WebSocketManager (infrastructure adapter) │ +│ │ +│ - Collects all WebSocketEventHandler instances │ +│ - Iterates their registrations (event_type + topic + serialize) │ +│ - Subscribes callbacks on event bus │ +│ - Broadcasts serialized payloads to matching clients │ +│ - Exposes available_topics for client discovery │ +└──────────────────────────┬───────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ FastAPI WebSocket Endpoint /ws/events │ +│ (accepts connections, delegates to WebSocketManager) │ +└──────────────────────────────────────────────────────────────────┘ +``` + +## Key Components + +### `WebSocketEventRegistration` (dataclass) + +Located in `edge_mining/adapters/infrastructure/websocket/utils.py`. + +A frozen dataclass that binds a domain event to a WebSocket topic and a serialization function: + +```python +@dataclass(frozen=True) +class WebSocketEventRegistration: + event_type: Type[DomainEvent] # domain event class to subscribe to + topic: str # topic string for client subscriptions + serialize: Callable[[DomainEvent], dict[str, Any]] # converts event → payload dict +``` + +The `topic` field is declared alongside the event binding, making it inspectable and discoverable without executing the serialize function. + +### `WebSocketMessage` (NamedTuple) + +Located in `edge_mining/adapters/infrastructure/websocket/utils.py`. + +A typed container used internally by the manager to pair a topic with its serialized payload: + +```python +class WebSocketMessage(NamedTuple): + topic: str + payload: dict[str, Any] +``` + +### `WebSocketEventHandler` (ABC) + +Located in `edge_mining/adapters/infrastructure/websocket/utils.py`. + +Abstract base class that each subdomain handler must extend. It declares a single abstract property: + +```python +class WebSocketEventHandler(ABC): + @property + @abstractmethod + def registrations(self) -> List[WebSocketEventRegistration]: + ... +``` + +The handler knows nothing about the event bus or the WebSocket manager. It is a pure serializer: given a domain event, it returns a payload dict. The manager is responsible for wiring everything together. + +### `WebSocketManager` + +Located in `edge_mining/adapters/infrastructure/websocket/manager.py`. + +The central aggregator. On construction it: + +1. Instantiates all subdomain handlers. +2. Iterates each handler's `registrations`. +3. Collects all declared topics into `_available_topics`. +4. Subscribes an async callback on the event bus for each registration (with `blocking=False`). + +When a domain event fires, the corresponding callback serializes it via the handler's `serialize` function, wraps the result in a `WebSocketMessage`, and broadcasts it to all connected clients whose subscription patterns match the topic. + +The manager also supports a `get_topics` client command to return the full list of available topics. + +### WebSocket Router + +Located in `edge_mining/adapters/infrastructure/websocket/router.py`. + +Exposes the `/ws/events` FastAPI WebSocket endpoint. The `WebSocketManager` instance is injected via the `init_websocket_manager()` function during application startup. + +### Initialization + +Located in `edge_mining/adapters/infrastructure/websocket/setup.py`. + +Called from `__main__.py` during application boot: + +```python +def init_websocket_dependencies(services: Services, logger: LoggerPort) -> None: + ws_manager = WebSocketManager(event_bus=services.event_bus, logger=logger) + init_websocket_manager(ws_manager) +``` + +## File Structure + +``` +edge_mining/ +├── adapters/ +│ ├── infrastructure/websocket/ # Core WebSocket infrastructure +│ │ ├── utils.py # WebSocketMessage, WebSocketEventRegistration, WebSocketEventHandler ABC +│ │ ├── manager.py # WebSocketManager (aggregator) +│ │ ├── router.py # FastAPI /ws/events endpoint +│ │ └── setup.py # Dependency initialization +│ │ +│ ├── domain/ # Domain subdomain handlers +│ │ ├── energy/websocket/ +│ │ │ ├── handlers.py # EnergyWebSocketHandler +│ │ │ └── schemas.py # EnergyStateSnapshotUpdatedSchema +│ │ ├── miner/websocket/ +│ │ │ ├── handlers.py # MinerWebSocketHandler +│ │ │ └── schemas.py # MinerStateChangedSchema +│ │ ├── optimization_unit/websocket/ +│ │ │ ├── handlers.py # OptimizationUnitWebSocketHandler +│ │ │ └── schemas.py # RuleEngagedSchema +│ │ └── policy/websocket/ +│ │ ├── handlers.py # PolicyWebSocketHandler +│ │ └── schemas.py # DecisionalContextUpdatedSchema +│ │ +│ └── application/services/configuration/websocket/ # Application-layer handler +│ ├── handlers.py # ConfigurationWebSocketHandler +│ └── schemas.py # ConfigurationUpdatedSchema +│ +├── domain/ # Domain events (source of truth) +│ ├── energy/events.py # EnergyStateSnapshotUpdatedEvent +│ ├── miner/events.py # MinerStateChangedEvent +│ ├── optimization_unit/events.py # RuleEngagedEvent +│ └── policy/events.py # DecisionalContextUpdatedEvent +│ +└── application/events/ + ├── common.py # ConfigurationAction, ConfigurationUpdatedEventType enums + └── configuration_events.py # ConfigurationUpdatedEvent +``` + +## Currently Registered Topics + +| Topic | Domain Event | Handler Class | +|--------------------|-------------------------------------|--------------------------------------| +| `config.updated` | `ConfigurationUpdatedEvent` | `ConfigurationWebSocketHandler` | +| `energy.state` | `EnergyStateSnapshotUpdatedEvent` | `EnergyWebSocketHandler` | +| `miner.state` | `MinerStateChangedEvent` | `MinerWebSocketHandler` | +| `rule.engaged` | `RuleEngagedEvent` | `OptimizationUnitWebSocketHandler` | +| `policy.context` | `DecisionalContextUpdatedEvent` | `PolicyWebSocketHandler` | + +## How to Add a New WebSocket Event + +Follow these steps to expose a new domain event via WebSocket. The example below adds a hypothetical `MinerHashrateUpdatedEvent` from the miner subdomain. + +### Step 1 — Define the domain event + +Create or update the event dataclass in the relevant subdomain's `events.py`: + +```python +# edge_mining/domain/miner/events.py + +@dataclass +class MinerHashrateUpdatedEvent(DomainEvent): + miner_id: Optional[EntityId] = None + miner_name: str = "" + hashrate: float = 0.0 +``` + +### Step 2 — Create the WebSocket schema + +Add a Pydantic schema in the subdomain's `websocket/schemas.py`: + +```python +# edge_mining/adapters/domain/miner/websocket/schemas.py + +class MinerHashrateUpdatedSchema(BaseModel): + miner_id: Optional[str] = Field(None, description="ID of the miner") + miner_name: str = Field(default="", description="Name of the miner") + hashrate: float = Field(default=0.0, description="Current hashrate in TH/s") +``` + +### Step 3 — Register in the handler + +Add a new `WebSocketEventRegistration` entry and its serialize method in the subdomain's handler: + +```python +# edge_mining/adapters/domain/miner/websocket/handlers.py + +class MinerWebSocketHandler(WebSocketEventHandler): + + @property + def registrations(self) -> List[WebSocketEventRegistration]: + return [ + WebSocketEventRegistration( + event_type=MinerStateChangedEvent, + topic="miner.state", + serialize=self._serialize_miner_state_changed, + ), + # NEW registration + WebSocketEventRegistration( + event_type=MinerHashrateUpdatedEvent, + topic="miner.hashrate", + serialize=self._serialize_miner_hashrate_updated, + ), + ] + + def _serialize_miner_hashrate_updated(self, event: DomainEvent) -> dict[str, Any]: + assert isinstance(event, MinerHashrateUpdatedEvent) + payload = MinerHashrateUpdatedSchema( + miner_id=str(event.miner_id) if event.miner_id else None, + miner_name=event.miner_name, + hashrate=event.hashrate, + ) + return payload.model_dump(mode="json") +``` + +**That's it.** No changes to the `WebSocketManager` are needed. It discovers the new registration automatically via the handler's `registrations` property. The new topic `miner.hashrate` will appear in `available_topics` and clients subscribing to `miner.*` will receive it automatically. + +### Step 4 (new subdomain only) — Create a new handler class + +If the event belongs to a **new** subdomain that doesn't have a handler yet: + +1. Create `adapters/domain//websocket/handlers.py` with a class extending `WebSocketEventHandler`. +2. Create `adapters/domain//websocket/schemas.py` with the Pydantic schema(s). +3. Import and instantiate the new handler in `WebSocketManager.__init__` (add it to the `handlers` list). + +## Design Decisions + +- **Handlers are pure serializers.** They have no dependency on the event bus or the WebSocket manager. This eliminates circular imports and makes handlers trivially testable. +- **Topics are declarative.** Each `WebSocketEventRegistration` declares its topic explicitly, making it inspectable without executing any code. The manager collects all topics for client discovery. +- **One handler per subdomain, N registrations per handler.** A subdomain with multiple events simply returns multiple entries in its `registrations` list. No new handler classes are needed. +- **`fnmatch` wildcard matching.** Clients can subscribe using glob patterns (`energy.*`, `*`), which is matched via Python's `fnmatch` module. +- **`blocking=False` for all subscriptions.** WebSocket broadcasting is fire-and-forget — it must never block the domain service that raised the event. +- **Configuration events live in the application layer.** Unlike domain events, configuration events (`ConfigurationUpdatedEvent`) are raised by application services and their handler is placed under `adapters/application/services/configuration/websocket/`. diff --git a/docs/entities/automation_rule.md b/docs/entities/automation_rule.md new file mode 100644 index 0000000..d29f561 --- /dev/null +++ b/docs/entities/automation_rule.md @@ -0,0 +1,87 @@ +# Automation Rule + +## Description + +The `AutomationRule` entity represents an individual decision-making rule within the Edge Mining optimization system. Each rule defines specific conditions that must be met for the system to take a particular action (start or stop mining). These rules form the building blocks of the optimization logic, allowing for flexible and customizable automation strategies that can adapt to various energy scenarios and operational requirements. + +## Properties + +| Property | Description | +| :------------ | :-------------------------------------------------------------------------------------------------------------------------- | +| `name` | A user-friendly name to identify the rule (e.g., "High Solar Production", "Low Battery Threshold", "Peak Grid Hours"). | +| `description` | Detailed description explaining what the rule does and when it should trigger. | +| `priority` | Numeric priority for rule evaluation, where higher numbers indicate higher priority. Rules are evaluated in priority order. | +| `enabled` | Boolean flag that determines whether the rule is active. Disabled rules are ignored during evaluation. | +| `conditions` | Dictionary containing the specific conditions and parameters that define when the rule should trigger. | + +## Relationships + +- **Belongs to `OptimizationPolicy`**: Each `AutomationRule` is part of either the _start rules_ or _stop rules_ collection within an `OptimizationPolicy`. +- **Evaluated by `RuleEngine`**: Rules are processed by the rule engine service that interprets the conditions and determines if they match the current context. +- **Uses `DecisionalContext`**: Rule evaluation is performed against comprehensive system state data provided by the decisional context. +- **Influences `MiningDecision`**: When a rule's conditions are met, it contributes to the final mining decision output. + +## Key Operations + +The `AutomationRule` is primarily a data container, but it participates in several important operations: + +- **Condition Evaluation**: The rule's conditions are analyzed by the rule engine against the current system context. +- **Priority Ordering**: Rules are automatically sorted by priority within their parent optimization policy. +- **Enable/Disable Control**: Rules can be dynamically activated or deactivated without removing them from the policy. + +## Rule Types and Usage Patterns + +- **Energy Production Rules**: Trigger based on current or forecasted energy production levels + + - "Start mining when solar production exceeds 2000W" + - "Stop mining when production drops below 1000W" + +- **Energy Storage Rules**: Based on battery state and capacity + + - "Start mining when battery is above 80% charge" + - "Stop mining when battery drops below 20%" + +- **Grid Interaction Rules**: Consider grid import/export status + + - "Start mining during peak grid hours" + - "Stop mining when importing energy from grid" + +- **Time-Based Rules**: Incorporate scheduling and time constraints + + - "Stop mining at 6 PM for evening peak load" + - "Start mining only during daylight hours" + +- **Forecast-Driven Rules**: Use predicted energy availability + + - "Start mining if next 4 hours show high solar forecast" + - "Stop mining if weather forecast shows cloudy conditions" + +- **Load Management Rules**: Consider household energy consumption + - "Stop mining when home load exceeds available production" + - "Start mining only when home needs are satisfied" + +## Condition Structure + +The `conditions` dictionary contains rule-specific parameters that define the evaluation logic. Common condition types include: + +- **Threshold Conditions**: Numeric comparisons (greater than, less than, equals) +- **Time Conditions**: Schedule-based triggers and time ranges +- **State Conditions**: Specific system states or status values +- **Composite Conditions**: Complex logic combining multiple factors +- **Forecast Conditions**: Future-looking predictions and trends + +## Priority System + +- **High Priority (90-100)**: Critical safety and protection rules +- **Medium Priority (50-89)**: Standard optimization rules +- **Low Priority (1-49)**: Fine-tuning and preference rules +- **Priority 0**: Lowest priority, evaluated last + +## Enable/Disable Functionality + +- **Dynamic Control**: Rules can be enabled or disabled without system restart +- **Testing and Debugging**: Allows temporary rule deactivation for troubleshooting +- **Seasonal Adjustments**: Rules can be enabled/disabled based on changing conditions +- **Maintenance Mode**: Critical rules can be disabled during system maintenance + +This entity provides the granular control needed to implement smart energy optimization strategies, allowing users to define precise conditions for automated mining operations while maintaining flexibility and control over system behavior. diff --git a/docs/entities/battery.md b/docs/entities/battery.md new file mode 100644 index 0000000..1fbef7c --- /dev/null +++ b/docs/entities/battery.md @@ -0,0 +1,15 @@ +# Battery + +## Description + +The `Battery` entity represents a battery storage system within the energy source. It defines the fundamental characteristics of energy storage capacity and serves as a reference for battery-related operations in the Edge Mining system. + +## Properties + +| Property | Description | +| :----------------- | :-------------------------------------------------------------------------------------------------------------------------------- | +| `nominal_capacity` | The total energy capacity that the battery can hold, measured in Watt-hours. This represents the nominal energy storage capacity. | + +## Relationships + +- **Part of `EnergySource`**: A `Battery` can be connected to an [`EnergySource`](energy_source.md) to provide energy storage capabilities and used in the [`DecisionalContext`](decisional_context.md). diff --git a/docs/entities/battery_state.md b/docs/entities/battery_state.md new file mode 100644 index 0000000..9c43c8f --- /dev/null +++ b/docs/entities/battery_state.md @@ -0,0 +1,21 @@ +# Battery State + +## Description + +The `BatteryState` entity captures the state of a battery at a particular moment in time. It provides detailed information about the battery's charge level, power flow direction, and remaining capacity. This entity is essential for tracking energy storage systems and making informed decisions about energy management. + +## Properties + +| Property | Description | +| :------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ | +| `state_of_charge` | The current charge level of the battery, expressed as a percentage of its nominal capacity. | +| `remaining_capacity` | The remaining energy stored in the battery, measured in Watt-hours. This can be `None` if not available. | +| `current_power` | The power currently flowing to or from the battery, measured in Watts. A positive value indicates charging, negative indicates discharging. | +| `timestamp` | The date and time when the battery state was recorded. | +| `charging_power` | A computed property that returns the power being used to charge the battery (always a positive value or zero). | +| `discharging_power` | A computed property that returns the power being drawn from the battery (always a positive value or zero). | + +## Relationships + +- **Snapshot of `Battery`**: `BatteryState` represents the current state of a `Battery` entity at a specific point in time. +- **Part of `EnergyStateSnapshot`**: `BatteryState` is an optional sub-entity within the [`EnergyStateSnapshot`](energy_state_snapshot.md), representing the battery's contribution to the overall energy system state. diff --git a/docs/entities/consumption_forecast.md b/docs/entities/consumption_forecast.md new file mode 100644 index 0000000..5653b83 --- /dev/null +++ b/docs/entities/consumption_forecast.md @@ -0,0 +1,17 @@ +# Consumption Forecast + +## Description + +The `ConsumptionForecast` entity represents a prediction of energy consumption patterns for the home or or a specific load. It contains time-series data mapping timestamps to predicted power consumption values, enabling the optimization system to make informed decisions about future energy needs and mining operations scheduling. + +## Properties + +| Property | Description | +| :---------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `predicted_watts` | A dictionary mapping timestamps to predicted power consumption values in Watts. Each entry represents the expected energy consumption at a specific future time. | +| `generated_at` | The timestamp indicating when this forecast was generated, used to track forecast freshness and validity. | + +## Relationships + +- **Used by `DecisionalContext`**: A `ConsumptionForecast` can be part of the [`DecisionalContext`](decisional_context.md), providing crucial information about expected future energy requirements of the home or a specific load. +- **Generated by Home Forecast Providers**: Home Forecast services or local algorithms generate `ConsumptionForecast` instances based on historical consumption patterns and other factors. diff --git a/docs/entities/decisional_context.md b/docs/entities/decisional_context.md new file mode 100644 index 0000000..08d76eb --- /dev/null +++ b/docs/entities/decisional_context.md @@ -0,0 +1,68 @@ +# Decisional Context + +## Description + +The `DecisionalContext` entity represents a comprehensive snapshot of all relevant system data at the moment a mining decision needs to be made. It serves as the complete information package that optimization policies and automation rules use to evaluate current conditions and make informed decisions about mining operations. This entity aggregates real-time data, forecasts, and system state information into a single, cohesive context for decision-making. + +## Properties + +| Property | Description | +| :------------------------- | :-------------------------------------------------------------------------------------------------------------------------------- | +| `energy_source` | The `EnergySource` representing the current energy generation system being monitored. | +| `energy_state` | An [`EnergyStateSnapshot`](energy_state_snapshot.md) containing real-time energy production, consumption, battery, and grid data. | +| `forecast` | Optional [`Forecast`](forecast.md) containing predicted energy production data. | +| `home_load_forecast` | Optional [`ConsumptionForecast`](consumption_forecast.md) with predicted household energy consumption patterns. | +| `tracker_current_hashrate` | Optional current hash rate performance data from mining operations. | +| `sun` | Optional [`Sun`](sun.md) containing astronomical data for the sun. | +| `miner` | Optional [`Miner`](miner.md) entity representing the mining device whose operation is being decided. | +| `timestamp` | The exact time when this decisional context was created, enabling time-based rule evaluation. | + +## Relationships + +- **Used by `OptimizationPolicy`**: Provides the complete data context needed for policy decision-making. +- **Evaluated by `AutomationRule`**: Individual rules analyze specific aspects of the context to determine if their conditions are met. +- **Processed by `RuleEngine`**: The rule engine uses context data to evaluate rule conditions and generate decisions. +- **Integrates Multiple Domains**: Combines data from energy, forecast, miner, and home load domains into a unified view. +- **Supports Time-based Decisions**: Timestamp enables time-sensitive rule evaluation and scheduling. + +## Key Operations + +The `DecisionalContext` is primarily a data container, but it supports several important operational patterns: + +- **Context Assembly**: Aggregates data from multiple system components into a single decision-making package. +- **Rule Evaluation Support**: Provides all necessary data for automation rules to assess their conditions. +- **Time-based Analysis**: Enables rules to consider both current state and temporal factors. +- **Cross-domain Integration**: Allows rules to consider interactions between energy, mining, and consumption systems. + +## Data Integration Patterns + +- **Real-time Data**: Current energy production, consumption, and system states +- **Predictive Data**: Forecasts for energy production and household consumption +- **Performance Data**: Mining operation metrics and hash rate information +- **Environmental Data**: Sun position and astronomical calculations +- **Historical Context**: Time-stamped data for trend analysis + +## Usage in Decision-Making + +- **Energy Availability Assessment**: Rules can evaluate current production vs. consumption to determine excess energy availability. +- **Future Planning**: Forecast data enables rules to consider upcoming energy conditions before making decisions. +- **Load Balancing**: Home load forecasts help rules ensure household energy needs are prioritized. +- **Solar Optimization**: Sun data enables rules to optimize for peak solar production periods. +- **Performance Monitoring**: Hash rate data allows rules to consider mining efficiency in their decisions. + +## Context Validation + +- **Miner Requirement**: Critical that the miner property is set, as policies require knowledge of current miner status. +- **Energy State Requirement**: Energy state snapshot is essential for all energy-based decision rules. +- **Optional Forecasts**: Forecast data enhances decision quality but is not always required. +- **Timestamp Accuracy**: Ensures time-based rules can accurately evaluate temporal conditions. + +## Decision Support Capabilities + +- **Comprehensive Analysis**: Provides complete system view for sophisticated decision-making. +- **Multi-factor Evaluation**: Enables rules to consider multiple simultaneous conditions. +- **Predictive Planning**: Supports forward-looking decisions based on forecast data. +- **Real-time Responsiveness**: Includes current system state for immediate decision needs. +- **Historical Context**: Timestamp enables trend analysis and time-based patterns. + +This entity serves as the foundation for intelligent, data-driven mining decisions by providing optimization policies and automation rules with complete, accurate, and timely information about all relevant system conditions. diff --git a/docs/entities/energy_monitor.md b/docs/entities/energy_monitor.md new file mode 100644 index 0000000..15d2949 --- /dev/null +++ b/docs/entities/energy_monitor.md @@ -0,0 +1,61 @@ +# Energy Monitor + +## Description + +The `EnergyMonitor` entity acts as an intermediary between the Edge Mining system and physical or software-based energy monitoring devices. Its purpose is to abstract the specific details of how to communicate with different types of energy monitoring hardware or services. It translates generic energy monitoring requests from the system into specific API calls or protocols that the actual monitoring device understands, providing real-time data about energy production, consumption, and storage. + +## Properties + +| Property | Description | +| :-------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `name` | A user-friendly name for the energy monitor (e.g., "Home Assistant Solar Monitor", "SolarEdge Inverter Monitor"). | +| `adapter_type` | Specifies the type of adapter to use for communication. This determines which communication protocol or API will be used. Examples: `DUMMY_SOLAR` (for testing), `HOME_ASSISTANT_API`, `HOME_ASSISTANT_MQTT`. | +| `config` | A set of configuration parameters required by the specific adapter. This could include things like an IP address, API key, entity IDs, MQTT topics, username, or password. | +| `external_service_id` | The unique identifier of the external service this monitor connects to, if applicable (e.g., Home Assistant Service ID). | + +## Relationships + +- **Monitors `EnergySource`**: An `EnergyMonitor` is responsible for monitoring one `EnergySource` entity. It receives requests from the Optimization Service and uses its adapter to fetch real-time data from the actual monitoring hardware or service. The link between an `EnergySource` and its `EnergyMonitor` is established via the `energy_monitor_id` property in the `EnergySource` entity. +- **Produces `EnergyStateSnapshot`**: The monitor collects comprehensive energy data and packages it into an `EnergyStateSnapshot` that contains all current system metrics. +- **Tracks `BatteryState`**: When monitoring energy storage systems, the monitor provides detailed battery information including state of charge, remaining capacity, and charging/discharging power. +- **Monitors `GridState`**: For grid-connected systems, the monitor tracks power import/export status and current grid interaction metrics. +- **Measures `LoadState`**: The monitor tracks current energy consumption (excluding mining loads). + +## Key Operations + +The `EnergyMonitor` itself doesn't have operations like `get_production` directly within its entity definition. Instead, its primary role is defined by its behavior within the system's infrastructure layer. The application uses the `adapter_type` and `config` of the monitor to instantiate a specific **Adapter** (e.g., `HomeAssistantAPIEnergyMonitor`). This adapter then provides the concrete implementation for actions like: + +- **`get_current_energy_state()`**: Returns a complete `EnergyStateSnapshot` containing all current energy system metrics including production, consumption, battery status, and grid interaction. +- **Fetching real-time energy production data**: Current power output from renewable sources (solar panels, wind turbines, etc.), measured in Watts. +- **Retrieving energy consumption metrics**: Current load state with timestamped power measurements, excluding mining operations. +- **Monitoring battery status**: Comprehensive battery metrics including: + - State of charge (as percentage) + - Remaining capacity (in WattHours) + - Current charging/discharging power (Watts, positive when charging, negative when discharging) + - Calculated charging and discharging power properties +- **Tracking grid interaction**: Grid state monitoring including: + - Current power flow (Watts, positive when importing, negative when exporting) + - Calculated importing and exporting power properties + - Grid connection status +- **Collecting external source data**: Monitoring of external power sources like generators for supplementary energy. + +## Data Structures + +The `EnergyMonitor` works with several sub-entities to provide structured energy data: + +- **`EnergyStateSnapshot`**: A comprehensive snapshot containing: + + - `production` (Watts): Current energy production + - `consumption` (LoadState): Current load excluding miners + - `battery` (BatteryState, optional): Battery status if present + - `grid` (GridState, optional): Grid interaction if connected + - `external_source` (Watts, optional): External generator power + - `timestamp`: When the snapshot was taken + +- **`BatteryState`**: Detailed battery information including state of charge, capacity, and power flow with computed charging/discharging properties. + +- **`GridState`**: Grid interaction data with computed import/export power calculations. + +- **`LoadState`**: Timestamped power consumption measurements for system loads. + +This design allows the core domain logic to remain independent of the specific technologies used to monitor energy systems, whether they are hardware-based monitoring devices, cloud services, or home automation platforms. diff --git a/docs/entities/energy_optimization_unit.md b/docs/entities/energy_optimization_unit.md new file mode 100644 index 0000000..1e2ca9b --- /dev/null +++ b/docs/entities/energy_optimization_unit.md @@ -0,0 +1,62 @@ +# Energy Optimization Unit + +## Description + +The `EnergyOptimizationUnit` entity represents a complete optimization configuration that orchestrates automated mining operations within the Edge Mining system. It serves as the central coordination hub that brings together optimization policies, target miners, energy sources, and various monitoring services to create a comprehensive automated mining solution. This entity encapsulates all the components needed to intelligently manage mining operations based on energy availability and optimization strategies. + +## Properties + +| Property | Description | +| :-------------------------- | :-------------------------------------------------------------------------------------------------------------------------------- | +| `name` | A user-friendly name to identify the optimization unit (e.g., "Main Solar Mining Unit", "Grid Cost Optimizer"). | +| `description` | Optional detailed description explaining the unit's purpose and optimization strategy. | +| `is_enabled` | Boolean flag that determines whether the optimization unit is active and should process optimization decisions. | +| `policy_id` | The unique identifier of the [`OptimizationPolicy`](optimization_policy.md) that defines the decision-making rules for this unit. | +| `target_miner_ids` | A list of unique identifiers for the [`Miner`](miner.md) entities that this unit will control and optimize. | +| `energy_source_id` | The unique identifier of the [`EnergySource`](energy_source.md) that provides energy data for optimization decisions. | +| `home_forecast_provider_id` | Optional identifier for a home load forecast provider that predicts household energy consumption patterns. | +| `performance_tracker_id` | Optional identifier for a performance tracking service that monitors mining efficiency and profitability metrics. | +| `notifier_ids` | A list of identifiers for notification services that alert users about optimization decisions and system status changes. | + +## Relationships + +- **Uses [`OptimizationPolicy`](optimization_policy.md)**: Each unit is associated with a specific optimization policy that defines the automation rules and decision-making logic. +- **Controls [`Miner`](miner.md) entities**: The unit manages one or more mining devices, coordinating their operations based on optimization decisions. +- **Monitors [`EnergySource`](energy_source.md)**: Connected to an energy source that provides real-time production data and forecasting information. +- **Integrates Home Forecast Providers**: Uses home load forecast providers to understand household energy consumption patterns. +- **Tracks Performance**: Optional integration with mining performance tracking services for efficiency monitoring. +- **Sends Notifications**: Connects to notification services to alert users about system status and decisions. + +## Key Operations + +- **`add_target_miner(miner_id)`**: Adds a new miner to the list of devices controlled by this optimization unit. +- **`remove_target_miner(miner_id)`**: Removes a miner from the unit's control, stopping optimization for that device. +- **`assign_policy(policy_id)`**: Associates the unit with a specific optimization policy that defines decision-making rules. +- **`assign_energy_source(energy_source_id)`**: Connects the unit to an energy source for monitoring production and consumption data. +- **`assign_home_forecast_provider(home_forecast_provider_id)`**: Integrates household energy consumption forecasting for better optimization decisions. +- **`assign_performance_tracker(performance_tracker_id)`**: Connects performance monitoring services to track mining efficiency. +- **`add_notifier(notifier_id)`**: Adds a notification service to alert users about system status and optimization decisions. +- **`remove_notifier(notifier_id)`**: Removes a notification service from the unit's notification list. +- **`enable()`**: Activates the optimization unit, allowing it to process optimization decisions and control miners. +- **`disable()`**: Deactivates the optimization unit, stopping all automated optimization operations. + +## Optimization Workflow + +The `EnergyOptimizationUnit` coordinates the following optimization process: + +1. **Data Collection**: Gathers real-time energy data from the connected energy source +2. **Context Building**: Assembles a comprehensive decisional context including energy state, forecasts, and miner status +3. **Policy Evaluation**: Uses the assigned optimization policy to evaluate automation rules against current conditions +4. **Decision Making**: Generates mining decisions (start, stop, or maintain state) based on rule evaluation +5. **Action Execution**: Sends control commands to target miners based on optimization decisions +6. **Performance Tracking**: Monitors mining efficiency and system performance through connected trackers +7. **Notification**: Alerts users about important decisions and system status changes + +## Configuration Management + +- **Modular Design**: Each component (policy, miners, energy source, etc.) can be independently configured and updated +- **Dynamic Reconfiguration**: Components can be added, removed, or changed without stopping the optimization process +- **Enable/Disable Control**: Units can be temporarily disabled for maintenance or testing without losing configuration +- **Multiple Units**: Multiple optimization units can operate simultaneously with different configurations + +This entity serves as the orchestration layer that transforms energy data and optimization policies into intelligent, automated mining operations, providing users with a comprehensive solution for energy-efficient bitcoin mining. diff --git a/docs/entities/energy_source.md b/docs/entities/energy_source.md new file mode 100644 index 0000000..ebbd587 --- /dev/null +++ b/docs/entities/energy_source.md @@ -0,0 +1,36 @@ +# Energy Source + +## Description + +The `EnergySource` entity represents a source of electrical energy in the Edge Mining system. It can be a renewable energy source (like solar panels or wind turbines), a grid connection, or other types of power generation. This entity manages all the relevant information about a specific energy source, such as its type, capacity, connected storage systems, and monitoring configuration. + +## Properties + +| Property | Description | +| :--------------------- | :----------------------------------------------------------------------------------------------------------------------------------- | +| `name` | A user-friendly name to identify the energy source (e.g., "Rooftop Solar Array", "Grid Connection"). | +| `type` | The type of energy source. Possible values are: `SOLAR`, `WIND`, `GRID`, `HYDROELECTRIC`, `OTHER`. | +| `nominal_power_max` | The maximum theoretical power output that the energy source can provide, measured in Watts. | +| `storage` | An optional `Battery` value object representing connected energy storage system with its nominal capacity. | +| `grid` | An optional `Grid` value object representing grid connection with its contracted power limit. | +| `external_source` | An optional external power source (e.g., external generator), measured in Watts, for future use. | +| `energy_monitor_id` | The unique identifier of the `EnergyMonitor` that tracks the real-time performance of this energy source. | +| `forecast_provider_id` | The unique identifier of a forecast provider service used to predict future energy production (e.g., weather-based solar forecasts). | + +## Relationships + +- **Monitored by `EnergyMonitor`**: Each `EnergySource` can be associated with an [`EnergyMonitor`](energy_monitor.md) that tracks its real-time performance metrics. This relationship is established through the `energy_monitor_id`. +- **Has Storage**: An `EnergySource` can be connected to a `Battery` storage system to store energy. +- **Connected to Grid**: An `EnergySource` can be connected to the electrical `Grid` to import/export energy. +- **Uses Forecast Provider**: An `EnergySource` can use external forecast services to predict future energy production based on weather conditions or other factors. + +## Key Operations + +- **`connect_to_grid(grid)`**: Connects the energy source to the electrical grid with specified contracted power limits. +- **`disconnect_from_grid()`**: Disconnects the energy source from the electrical grid. +- **`connect_to_external_source(external_source)`**: Connects an external power source (like a generator) to supplement energy production. +- **`disconnect_from_external_source()`**: Disconnects the external power source. +- **`connect_to_storage(storage)`**: Connects a battery storage system to store excess energy production. +- **`disconnect_from_storage()`**: Disconnects the battery storage system. +- **`use_energy_monitor(energy_monitor_id)`**: Associates an energy monitor with this source to track real-time performance metrics. +- **`use_forecast_provider(forecast_provider_id)`**: Associates a forecast provider service to predict future energy production. diff --git a/docs/entities/energy_state_snapshot.md b/docs/entities/energy_state_snapshot.md new file mode 100644 index 0000000..f151bcb --- /dev/null +++ b/docs/entities/energy_state_snapshot.md @@ -0,0 +1,21 @@ +# Energy State Snapshot + +## Description + +The `EnergyStateSnapshot` entity provides a comprehensive snapshot of the entire energy system's state at a single point in time. It aggregates data from energy source, storage system, grid connection, and consumption loads to create a complete picture of the current energy situation. This snapshot is crucial for making informed decisions about mining operations and energy optimization. + +## Properties + +| Property | Description | +| :---------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `production` | The total amount of energy being produced by the source (e.g., solar panels, wind turbines), measured in Watts. | +| `consumption` | The energy being consumed by the system's loads, excluding the miner, represented by a [`LoadState`](load_state.md) entity. | +| `battery` | The state of the battery system, if one is present. This is represented by a [`BatteryState`](battery_state.md) entity and can be `None`. | +| `grid` | The state of the grid connection, if the system is connected to the electrical grid. This is represented by a [`GridState`](grid_state.md) sub-entity and can be `None`. | +| `external_source` | Power from any other external source, such as a backup generator, measured in Watts. This is optional and for future use. | +| `timestamp` | The date and time when the snapshot was taken, ensuring temporal consistency across all measurements. | + +## Relationships + +- **Generated by `EnergyMonitor`**: The [`EnergyMonitor`](energy_monitor.md) is responsible for creating `EnergyStateSnapshot` instances by gathering data from various energy system components and sensors. +- **Used by [`DecisionalContext`](decisional_context.md)**: The `EnergyStateSnapshot` is a key component of the decision-making process, providing current system state to optimization policies. diff --git a/docs/entities/forecast.md b/docs/entities/forecast.md new file mode 100644 index 0000000..e15bfe2 --- /dev/null +++ b/docs/entities/forecast.md @@ -0,0 +1,62 @@ +# Forecast Entity + +## Description + +The `Forecast` entity represents a comprehensive energy production prediction for the Edge Mining system. It contains detailed forecasting data organized in time intervals, providing the system with the ability to predict future energy availability and plan mining operations accordingly. This entity aggregates multiple forecast intervals and provides intelligent methods to extract specific predictions for different time horizons. + +## Properties + +| Property | Description | +| :---------- | :------------------------------------------------------------------------------------------------------------------------------------------ | +| `timestamp` | The timestamp when this forecast was generated or last updated. | +| `intervals` | A list of `ForecastInterval` sub-entities containing detailed predictions for specific time periods with power points and energy estimates. | + +## Sub-Entities + +The `Forecast` entity encapsulates several sub-entities that provide structured forecasting data: + +- **`ForecastInterval`**: Represents a specific time period with detailed energy predictions, containing: + + - `start` and `end` timestamps defining the forecast period + - `energy` (total predicted energy production for the interval) + - `energy_remaining` (remaining energy available in the interval) + - `power_points` (list of `ForecastPowerPoint` sub-entities with timestamped power predictions) + - Computed properties for `duration` and `avg_power` + +- **`ForecastPowerPoint`**: Represents a single prediction point containing: + + - `timestamp` (specific time for the prediction) + - `power` (predicted power output at that time in Watts) + +- **`Sun`**: Contains astronomical data for solar forecasting including: + - Solar position data (`dawn`, `sunrise`, `noon`, `sunset`, `dusk`) + - Daylight duration calculations (`daylight`, `night`, `twilight`) + - Solar geometry data (`azimuth`, `zenith`, `elevation`) + - Time-based properties for sunrise/sunset calculations + +## Relationships + +- **Generated by `ForecastProvider`**: Each `Forecast` is produced by a `ForecastProvider` that fetches prediction data from external services. +- **Used by Optimization Services**: The forecast data is consumed by the system's optimization algorithms to plan mining operations based on predicted energy availability. +- **Contains `ForecastInterval` sub-entities**: Multiple time-based forecast intervals that provide granular predictions. +- **Integrates `Sun` data**: For solar energy sources, incorporates astronomical calculations to enhance prediction accuracy. + +## Key Operations + +- **`next_hour_power`**: Returns the forecasted power output for the next hour based on available forecast intervals. +- **`avg_next_4_hours_power`**: Calculates the average predicted power over the next 4 hours for medium-term planning. +- **`next_hour_energy`**: Provides the total energy expected to be produced in the next hour. +- **`sort_intervals()`**: Organizes forecast intervals and their power points in chronological order. +- **`get_power_at_time(time)`**: Retrieves the forecasted power at a specific timestamp, using linear interpolation between available data points when necessary. +- **`get_energy_over_interval(start, end)`**: Calculates the total predicted energy production over a custom time interval by analyzing overlapping forecast periods. + +## Data Processing Capabilities + +The `Forecast` entity provides advanced data processing features: + +- **Linear Interpolation**: When exact time matches aren't available, the system interpolates between nearby power points to provide accurate predictions. +- **Interval Overlap Calculation**: For custom time ranges, the system calculates energy production by analyzing overlapping forecast intervals and proportionally distributing energy estimates. +- **Time-based Aggregation**: Automatic calculation of average power values and total energy estimates across different time horizons. +- **Chronological Organization**: Automatic sorting of forecast data to ensure consistent time-series processing. + +This entity serves as the central hub for all energy production predictions, enabling the Edge Mining system to make informed decisions about when to operate mining equipment based on forecasted energy availability. diff --git a/docs/entities/forecast_interval.md b/docs/entities/forecast_interval.md new file mode 100644 index 0000000..4dd0e49 --- /dev/null +++ b/docs/entities/forecast_interval.md @@ -0,0 +1,50 @@ +# Forecast Interval + +## Description + +The `ForecastInterval` entity represents a specific time period within a forecast with detailed energy production predictions. It serves as a fundamental building block of the forecasting system, containing both aggregate energy estimates for the entire interval and granular power predictions at specific time points. This entity enables the system to understand energy availability patterns across different time periods. + +## Properties + +| Property | Description | +| :----------------- | :----------------------------------------------------------------------------------------------------------- | +| `start` | The beginning timestamp of the forecast interval. | +| `end` | The ending timestamp of the forecast interval. | +| `energy` | Optional total energy expected to be produced during this interval, measured in WattHours. | +| `energy_remaining` | Optional remaining energy available in this interval (useful for real-time tracking), measured in WattHours. | +| `power_points` | A list of `ForecastPowerPoint` sub-entities containing timestamped power predictions within this interval. | + +## Sub-Entities + +- **`ForecastPowerPoint`**: Individual prediction points within the interval, each containing: + - `timestamp`: Specific time for the power prediction + - `power`: Predicted power output at that moment (in Watts) + +## Relationships + +- **Belongs to `Forecast`**: Each `ForecastInterval` is part of a larger `Forecast` entity that aggregates multiple intervals. +- **Contains `ForecastPowerPoint` sub-entities**: Multiple timestamped power predictions that provide granular detail within the interval. +- **Used by Optimization Algorithms**: The interval data is analyzed by the system to determine optimal mining operation windows. + +## Key Operations + +- **`duration`**: Calculated property that returns the time span of the interval as a `timedelta`. +- **`avg_power`**: Calculated property that computes the average power across all power points in the interval, returning the mean predicted power output. + +## Data Processing Features + +The `ForecastInterval` provides several computational capabilities: + +- **Average Power Calculation**: Automatically computes the mean power output across all power points within the interval. +- **Duration Calculation**: Provides the exact time span covered by the interval for energy calculations. +- **Power Point Management**: Organizes multiple granular power predictions within the interval timeframe. +- **Energy Distribution**: Supports both total energy estimates and remaining energy tracking for real-time applications. + +## Usage Patterns + +- **Energy Planning**: Used to understand total energy availability during specific time windows. +- **Power Profiling**: Provides detailed power variation patterns within the interval through power points. +- **Real-time Monitoring**: Tracks remaining energy as actual production occurs. +- **Optimization Input**: Serves as input data for algorithms that determine optimal mining schedules (_work in progress_). + +This entity is essential for translating weather-based predictions into actionable energy production forecasts that the Edge Mining system can use for intelligent operation planning. diff --git a/docs/entities/forecast_power_point.md b/docs/entities/forecast_power_point.md new file mode 100644 index 0000000..5919181 --- /dev/null +++ b/docs/entities/forecast_power_point.md @@ -0,0 +1,48 @@ +# Forecast Power Point + +## Description + +The `ForecastPowerPoint` entity represents a single, precise prediction of power output at a specific moment in time. It serves as the most granular unit of forecasting data in the Edge Mining system, providing timestamped power predictions that enable detailed analysis of expected energy production patterns. These entities are the building blocks for creating comprehensive energy forecasts and supporting sophisticated interpolation algorithms. + +## Properties + +| Property | Description | +| :---------- | :------------------------------------------------------------------------ | +| `timestamp` | The exact time for which this power prediction is made. | +| `power` | The predicted power output at the specified timestamp, measured in Watts. | + +## Relationships + +- **Belongs to `ForecastInterval`**: Each `ForecastPowerPoint` is contained within a `ForecastInterval` that defines the broader time period. +- **Part of `Forecast` time series**: Multiple power points across intervals form a complete time-series forecast. +- **Used for Interpolation**: Serves as anchor points for calculating power predictions at intermediate times. + +## Key Operations + +The `ForecastPowerPoint` is primarily a data container, but it participates in several important operations: + +- **Linear Interpolation**: Used as reference points for calculating power predictions between available timestamps. +- **Time Series Analysis**: Provides discrete data points for trend analysis and pattern recognition. +- **Chronological Sorting**: Automatically organized in time order within forecast intervals. + +## Usage Patterns + +- **Precision Forecasting**: Provides exact power predictions for specific moments, enabling precise energy planning. +- **Interpolation Base**: Serves as anchor points for calculating power values at any intermediate time. +- **Trend Analysis**: Multiple power points reveal patterns in expected energy production over time. +- **Real-time Comparison**: Used to compare actual power production against predictions for accuracy assessment. + +## Data Processing Integration + +- **Interpolation Support**: The `Forecast` entity uses power points to calculate predicted power at any time through linear interpolation between adjacent points. +- **Average Calculations**: Power points within an interval are used to compute average power output for that period. +- **Time-based Queries**: Enables precise power predictions for any requested timestamp within the forecast timeframe. + +## Example Applications + +- **Mining Schedule Optimization**: Determining exact power availability at planned mining start times. +- **Energy Balance Calculations**: Predicting precise power output for load balancing decisions. +- **Performance Monitoring**: Comparing actual vs. predicted power output for forecast accuracy evaluation. +- **Grid Integration**: Providing precise power predictions for grid export/import planning. + +This entity represents the fundamental unit of forecast precision, enabling the Edge Mining system to make highly accurate predictions about energy availability at any specific moment in time. diff --git a/docs/entities/forecast_provider.md b/docs/entities/forecast_provider.md new file mode 100644 index 0000000..0d7e70f --- /dev/null +++ b/docs/entities/forecast_provider.md @@ -0,0 +1,29 @@ +# Forecast Provider + +## Description + +The `ForecastProvider` entity acts as an intermediary between the Edge Mining system and external forecasting services that predict future energy production. Its purpose is to abstract the specific details of how to communicate with different types of weather forecasting services or energy prediction platforms. It translates generic forecast requests from the system into specific API calls or protocols that the actual forecasting service understands, providing predictions about future energy generation based on weather conditions and other factors. + +## Properties + +| Property | Description | +| :-------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | A user-friendly name for the forecast provider (e.g., "HomeAssistant Forecast", "SolarForecast API"). | +| `adapter_type` | Specifies the type of adapter to use for communication. This determines which forecasting service or API will be used. Examples: `DUMMY_SOLAR` (for testing), `HOME_ASSISTANT_API`. | +| `config` | A set of configuration parameters required by the specific adapter. This could include things like an API key, location coordinates, service endpoints, or authentication tokens. | +| `external_service_id` | The unique identifier of the external service this provider needs to use, if applicable (e.g., HomeAssistant Service). | + +## Relationships + +- **Used by `EnergySource`**: A `ForecastProvider` can be associated with one `EnergySource` entities to provide energy production forecasts. The link is established via the `forecast_provider_id` property in the `EnergySource` entity. +- **Produces `Forecast`**: The provider generates forecast data packaged into `Forecast` entities containing detailed predictions about future energy production. + +## Key Operations + +The `ForecastProvider` itself doesn't have operations like `get_forecast` directly within its entity definition. Instead, its primary role is defined by its behavior within the system's infrastructure layer. The application uses the `adapter_type` and `config` of the provider to instantiate a specific **Adapter** (e.g., `HomeAssistantAPIForecastProvider`). This adapter then provides the concrete implementation for actions like: + +- **`get_forecast()`**: Returns a complete `Forecast` entity containing predicted energy production data over multiple time intervals. +- **Fetching weather-based predictions**: Retrieving solar irradiance forecasts, wind speed predictions, or other weather-dependent factors affecting energy production. +- **Providing time-series forecasts**: Generating detailed predictions with specific power values at different time points. + +This design allows the core domain logic to remain independent of the specific forecasting technologies or services used, whether they are commercial weather APIs, machine learning models, or integrated home automation forecasting systems. diff --git a/docs/entities/grid.md b/docs/entities/grid.md new file mode 100644 index 0000000..ecb89c1 --- /dev/null +++ b/docs/entities/grid.md @@ -0,0 +1,17 @@ +# Grid + +## Description + +The `Grid` entity represents the connection to the main electrical grid infrastructure. It defines the contractual and physical limitations of the grid connection, serving as a reference for grid-related operations and energy management decisions in the Edge Mining system. + +## Properties + +| Property | Description | +| :----------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `contracted_power` | The maximum power that can be drawn from the grid according to the contract with the utility provider, measured in Watts. This represents the legal and technical limit for grid power consumption. | + +## Relationships + +- **Current state tracked by `GridState`**: The current operational state of the grid connection is represented by the [`GridState`](grid_state.md) entity, which tracks real-time power flow. +- **Monitored by `EnergyMonitor`**: The [`EnergyMonitor`](energy_monitor.md) uses `Grid` information to understand the constraints and capabilities of the grid connection during energy system monitoring. +- **Part of `EnergySource`**: A `Grid` can be associated with an [`EnergySource`](energy_source.md) to provide grid connectivity for energy import/export operations. diff --git a/docs/entities/grid_state.md b/docs/entities/grid_state.md new file mode 100644 index 0000000..fb92ff6 --- /dev/null +++ b/docs/entities/grid_state.md @@ -0,0 +1,19 @@ +# Grid State + +## Description + +The `GridState` entity represents the operational state of the electrical grid connection at a specific point in time. It tracks the bidirectional power flow between the local energy system and the main electrical grid, providing essential information for energy management and optimization decisions. + +## Properties + +| Property | Description | +| :---------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `current_power` | The power currently being exchanged with the grid, measured in Watts. A positive value indicates power is being imported from the grid, while a negative value means power is being exported to the grid. | +| `timestamp` | The date and time when the grid state was recorded, ensuring temporal accuracy for power flow measurements. | +| `importing_power` | A computed property that returns the power being imported from the grid (always a positive value or zero). | +| `exporting_power` | A computed property that returns the power being exported to the grid (always a positive value or zero). | + +## Relationships + +- **State of `Grid`**: `GridState` represents the current operational state of a [`Grid`](grid.md) entity at a specific moment. +- **Part of `EnergyStateSnapshot`**: `GridState` is an optional sub-entity within the [`EnergyStateSnapshot`](energy_state_snapshot.md), representing the grid's contribution to the overall energy system state. diff --git a/docs/entities/hash_rate.md b/docs/entities/hash_rate.md new file mode 100644 index 0000000..79e7936 --- /dev/null +++ b/docs/entities/hash_rate.md @@ -0,0 +1,17 @@ +# Hash Rate + +## Description + +The `HashRate` entity represents the computational performance metric of a bitcoin mining device. It quantifies the mining power in terms of hash calculations per second, which is a critical indicator of mining efficiency and performance in the Edge Mining system. + +## Properties + +| Property | Description | +| :------- | :---------------------------------------------------------------------------------------------------------------------------------- | +| `value` | The numerical value of the hash rate, representing the computational performance. | +| `unit` | The unit of measurement for the hash rate, with a default of "TH/s" (terahashes per second). Other common units include GH/s, EH/s. | + +## Relationships + +- **Performance metric for `Miner`**: The `HashRate` serves as a performance indicator for [`Miner`](miner.md) entities, tracking their computational output. +- **Used in `DecisionalContext`**: The `tracker_current_hashrate` in the decision-making context providing the current miner pool performance data for optimization decisions. diff --git a/docs/entities/load_state.md b/docs/entities/load_state.md new file mode 100644 index 0000000..48a6b87 --- /dev/null +++ b/docs/entities/load_state.md @@ -0,0 +1,16 @@ +# Load State + +## Description + +The `LoadState` entity represents the energy consumption state of non-mining loads at a specific point in time. This includes all energy-consuming devices and systems in the facility except for the mining equipment itself, providing crucial information for energy balance calculations and optimization decisions. + +## Properties + +| Property | Description | +| :-------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `current_power` | The amount of power being consumed by the load at the time of measurement, measured in Watts. This represents the instantaneous energy consumption of all non-mining systems. | +| `timestamp` | The date and time when the load state was recorded, ensuring temporal accuracy for energy consumption measurements. | + +## Relationships + +- **Part of `EnergyStateSnapshot`**: `LoadState` is a sub-entity of the [`EnergyStateSnapshot`](energy_state_snapshot.md), representing the consumption component of the overall energy system state. diff --git a/docs/entities/miner.md b/docs/entities/miner.md new file mode 100644 index 0000000..d50ef63 --- /dev/null +++ b/docs/entities/miner.md @@ -0,0 +1,28 @@ +# Miner + +## Description + +The `Miner` entity represents a physical or virtual bitcoin mining device in the Edge Mining system. Its primary role is to perform the computational work (hashing) required for mining. This entity holds all the relevant information about a specific miner, such as its operational status, performance metrics, and configuration. + +## Properties + +| Property | Description | +| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | A user-friendly name to identify the miner (e.g., "Antminer S19 Pro"). | +| `status` | The current operational state of the miner. Possible values are: `ON`, `OFF`, `STARTING`, `STOPPING`, `ERROR`, `UNKNOWN`. | +| `hash_rate` | The current processing power of the miner, measured in Gigahashes or Terahashes per second (GH/s or TH/s). This indicates how fast the miner is working. | +| `hash_rate_max` | The maximum theoretical hash rate that the miner can achieve. | +| `power_consumption` | The amount of electricity the miner is currently consuming, measured in Watts. | +| `power_consumption_max` | The maximum power the miner can draw. | +| `active` | A boolean flag that indicates if the miner is actively managed by the system. An inactive miner is ignored by the system's control logic. | +| `controller_id` | The unique identifier of the `MinerController` that is responsible for managing this miner. | + +## Relationships + +- **Managed by `MinerController`**: Each `Miner` instance is associated with a [`MinerController`](miner_controller.md). The controller is responsible for sending commands to the miner, such as `turn_on` and `turn_off`, and for receiving status updates from it. This relationship is established through the `controller_id`. + +## Key Operations + +- **`turn_on()` / `turn_off()`**: These actions change the intended state of the miner to `STARTING` or `STOPPING`. The actual transition to `ON` or `OFF` depends on the feedback from the physical device, managed via the `MinerController`. +- **`update_status()`**: This operation is used to update the miner's status based on real-world data, such as a change in hash rate or power consumption. +- **`activate()` / `deactivate()`**: These operations control whether the miner is considered part of the active fleet managed by the system. diff --git a/docs/entities/miner_controller.md b/docs/entities/miner_controller.md new file mode 100644 index 0000000..3965949 --- /dev/null +++ b/docs/entities/miner_controller.md @@ -0,0 +1,28 @@ +# Miner Controller + +## Description + +The `MinerController` entity acts as an intermediary between the Edge Mining system and a physical or software-based miner controller. Its purpose is to abstract the specific details of how to communicate with different types of mining hardware or management software. It translates generic commands from the system (like "turn on miner X") into specific API calls or protocols that the actual controller understands. + +## Properties + +| Property | Description | +| :-------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | A user-friendly name for the controller (e.g., "HomeAssistant API Controller"). | +| `adapter_type` | Specifies the type of adapter to use for communication. This determines which communication protocol or API will be used. Examples: `DUMMY` (for testing), `AWESOME_MINER`, `SSH`. | +| `config` | A set of configuration parameters required by the specific adapter. This could include things like an IP address, API key, entity IDs, username, or password. | +| `external_service_id` | The unique identifier of the service this controller needs, if applicable. | + +## Relationships + +- **Manages `Miner`**: A `MinerController` is responsible for managing only one [`Miner`](miner.md) entity. It receives requests from the application's service (e.g., "turn on this miner") and uses its adapter to execute these commands on the actual mining hardware. The link between a `Miner` and its `MinerController` is established via the `controller_id` property in the `Miner` entity. + +## Key Operations + +The `MinerController` itself doesn't have operations like `turn_on` directly within its entity definition. Instead, its primary role is defined by its behavior within the system's infrastructure layer. The application uses the `adapter_type` and `config` of the controller to instantiate a specific **Adapter** (e.g., `GenericSocketHomeAssistantAPIMinerController`). This adapter then provides the concrete implementation for actions like: + +- **Starting a miner** +- **Stopping a miner** +- **Fetching real-time data** (hash rate, power consumption) + +This design allows the core domain logic to remain independent of the specific technologies used to control the miners. diff --git a/docs/entities/notifier.md b/docs/entities/notifier.md new file mode 100644 index 0000000..16b9ddf --- /dev/null +++ b/docs/entities/notifier.md @@ -0,0 +1,18 @@ +# Notifier + +## Description + +The `Notifier` entity represents a notification channel configuration within the Edge Mining system. It manages the setup and configuration for specific notification adapters that are responsible for sending alerts, status updates, and system notifications through various communication services such as Telegram, email, or other messaging platforms. + +## Properties + +| Property | Description | +| :-------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | A user-friendly name to identify the notifier (e.g., "Telegram Bot", "Email Alerts"). | +| `adapter_type` | The type of notification adapter to use, determining which communication protocol or service will be used for sending notifications. | +| `config` | Configuration parameters required by the specific notification adapter, such as API keys, chat IDs, email addresses, or connection settings. This can be `None` if no configuration is needed. | +| `external_service_id` | The unique identifier of an external service associated with the notifier, if applicable. This can be `None` for standalone notifiers. | + +## Relationships + +- **Used by `EnergyOptimizationUnit`**: An [`EnergyOptimizationUnit`](energy_optimization_unit.md) can have multiple `Notifier` entities to send alerts, status updates, and notifications about energy optimization activities and mining operations. diff --git a/docs/entities/optimization_policy.md b/docs/entities/optimization_policy.md new file mode 100644 index 0000000..ee317c5 --- /dev/null +++ b/docs/entities/optimization_policy.md @@ -0,0 +1,57 @@ +# Optimization Policy + +## Description + +The `OptimizationPolicy` entity represents a comprehensive decision-making framework for automated mining operations in the Edge Mining system. It serves as the central intelligence that determines when to start or stop mining operations based on current energy conditions, forecasts, and predefined automation rules. This entity encapsulates the core optimization logic that enables the system to automatically adapt mining behavior to maximize efficiency and profitability while respecting energy constraints. + +## Properties + +| Property | Description | +| :------------ | :------------------------------------------------------------------------------------------------------------------- | +| `name` | A user-friendly name to identify the optimization policy (e.g., "Solar Priority Policy", "Grid Cost Optimization"). | +| `description` | Optional detailed description explaining the policy's purpose and optimization strategy. | +| `start_rules` | A list of [`AutomationRule`](automation_rule.md) sub-entities that define conditions for starting mining operations. | +| `stop_rules` | A list of [`AutomationRule`](automation_rule.md) sub-entities that define conditions for stopping mining operations. | + +## Relationships + +- **Contains [`AutomationRule`](automation_rule.md) sub-entities**: Multiple rules organized into start and stop categories that define the decision-making logic. +- **Uses [`RuleEngine`](rule_engine.md) service**: Collaborates with the rule engine service to evaluate rules against the current context. +- **Processes [`DecisionalContext`](decisional_context.md)**: Analyzes comprehensive system state data to make informed mining decisions. +- **Produces `MiningDecision`**: Generates actionable decisions for the mining system (START_MINING, STOP_MINING, MAINTAIN_STATE). +- **Manages [`Miner`](miner.md) operations**: Indirectly controls miner behavior through decision outputs. + +## Key Operations + +- **`sort_rules()`**: Organizes automation rules by priority, ensuring higher-priority rules are evaluated first. +- **`decide_next_action()`**: Core decision-making method that analyzes the current system state and returns an appropriate mining decision. + +## Decision-Making Logic + +The `OptimizationPolicy` implements smart decision logic: + +- **Context-Aware Analysis**: Evaluates current miner status to determine which rule set to apply (start rules for OFF miners, stop rules for ON miners). +- **Priority-Based Rule Evaluation**: Processes rules in priority order to ensure the most important conditions are checked first. +- **State-Specific Logic**: + - For OFF/ERROR/UNKNOWN miners: Evaluates start rules + - For ON miners: Evaluates stop rules + - For STARTING/STOPPING miners: Maintains state until confirmation +- **Fail-Safe Behavior**: Defaults to MAINTAIN_STATE when no rules match, ensuring stable operation. + +## Rule Processing Workflow + +1. **Status Assessment**: Determines current miner operational status +2. **Rule Selection**: Chooses appropriate rule set based on miner status +3. **Priority Sorting**: Organizes rules by priority for optimal evaluation order +4. **Rule Engine Integration**: Loads rules into the rule engine for evaluation +5. **Context Evaluation**: Processes current system context against loaded rules +6. **Decision Generation**: Returns appropriate mining decision based on rule matches + +## Usage Patterns + +- **Energy Optimization**: Automatically starts mining when excess energy is available and stops when energy is needed elsewhere. +- **Cost Minimization**: Optimizes mining operations based on energy costs and grid pricing. +- **Solar Maximization**: Prioritizes solar energy utilization for sustainable mining operations. +- **Load Balancing**: Coordinates mining operations with household energy consumption patterns. + +This entity serves as the brain of the automated mining system, enabling intelligent, context-aware decision-making that adapts to changing energy conditions and operational requirements. diff --git a/docs/entities/rule_engine.md b/docs/entities/rule_engine.md new file mode 100644 index 0000000..ecc3514 --- /dev/null +++ b/docs/entities/rule_engine.md @@ -0,0 +1,73 @@ +# Rule Engine + +## Description + +The `RuleEngine` entity serves as the computational engine that evaluates automation rules within the Edge Mining optimization system. It acts as an intermediary between the optimization policies and the actual rule evaluation logic, providing a standardized interface for processing different types of automation rules against system context data. This entity abstracts the complexity of rule evaluation, enabling flexible and extensible decision-making logic. + +## Properties + +The `RuleEngine` is defined as an abstract service interface, with concrete implementations providing specific evaluation algorithms. The entity itself doesn't have traditional properties but rather defines behavioral contracts for rule processing. + +## Relationships + +- **Used by [`OptimizationPolicy`](optimization_policy.md)**: The optimization policy delegates rule evaluation to the rule engine for consistent processing. +- **Processes [`AutomationRule`](automation_rule.md)**: Takes collections of automation rules and evaluates them against system context. +- **Analyzes [`DecisionalContext`](decisional_context.md)**: Uses comprehensive system state data to determine if rule conditions are satisfied. + +## Key Operations + +- **`load_rules(rules)`**: Prepares a collection of automation rules for evaluation by loading them into the engine's processing context. +- **`evaluate(context)`**: Core evaluation method that processes loaded rules against the provided decisional context and returns True if any rule conditions are met. + +## Rule Processing Workflow + +1. **Rule Loading**: Accepts a list of automation rules and prepares them for evaluation +2. **Context Analysis**: Examines the decisional context to extract relevant data for rule evaluation +3. **Condition Matching**: Iterates through loaded rules and evaluates their conditions against context data +4. **Priority Handling**: Processes rules according to their priority levels +5. **Result Determination**: Returns boolean result indicating whether any rule conditions were satisfied + +## Evaluation Logic + +- **Sequential Processing**: Rules are typically evaluated in priority order until a match is found +- **Short-Circuit Evaluation**: Evaluation may stop at the first matching rule for efficiency +- **Condition Parsing**: Interprets rule conditions dictionary to determine evaluation criteria +- **Data Type Handling**: Manages different data types and comparison operations within rule conditions +- **Boolean Logic**: Supports complex condition combinations within individual rules + +## Rule Types Supported + +- **Threshold Rules**: Numeric comparisons against energy production, consumption, or battery levels +- **Time-Based Rules**: Scheduling and temporal condition evaluation +- **State Rules**: System status and operational state matching +- **Forecast Rules**: Future-looking condition evaluation using prediction data +- **Composite Rules**: Complex conditions combining multiple criteria + +## Implementation Patterns + +- **Strategy Pattern**: Different rule engines can implement varying evaluation strategies +- **Plugin Architecture**: Extensible design allows for custom rule evaluation logic +- **Performance Optimization**: Efficient rule processing for real-time decision-making +- **Error Handling**: Robust error management for invalid or malformed rules + +## Usage Scenarios + +- **Energy Optimization**: Evaluating energy production and consumption thresholds +- **Time Scheduling**: Processing time-based mining schedules and restrictions +- **Safety Rules**: Enforcing protective conditions and emergency stops +- **Efficiency Optimization**: Assessing performance metrics and optimization targets +- **Cost Management**: Evaluating energy costs and economic conditions + +## Extensibility Features + +- **Custom Operators**: Support for specialized comparison and evaluation operators +- **Rule Validators**: Built-in validation for rule syntax and logic consistency + +## Integration Points + +- **Policy Integration**: Seamless integration with optimization policies for automated decision-making +- **Context Processing**: Efficient handling of complex decisional context data +- **Rule Management**: Support for dynamic rule loading and updating +- **Result Handling**: Clear and consistent evaluation result reporting + +This entity provides the computational foundation for smart mining automation, enabling advanced rule-based decision-making that can adapt to complex energy scenarios and operational requirements while maintaining high performance and reliability. diff --git a/docs/entities/sun.md b/docs/entities/sun.md new file mode 100644 index 0000000..85ada32 --- /dev/null +++ b/docs/entities/sun.md @@ -0,0 +1,65 @@ +# Sun + +## Description + +The `Sun` entity contains comprehensive astronomical data and solar position information crucial for automation rules in the Edge Mining system. It provides detailed information about daily solar patterns, including sunrise and sunset times, solar angles, and daylight duration calculations. This entity enables the system to understand solar energy potential based on astronomical factors. + +## Properties + +| Property | Description | +| :---------- | :---------------------------------------------------------------------------------------------------------- | +| `dawn` | The time in the morning when the sun is a specific number of degrees below the horizon (astronomical dawn). | +| `sunrise` | The time in the morning when the top of the sun breaks the horizon and becomes visible. | +| `noon` | The time when the sun reaches its highest point directly above the observer (solar noon). | +| `midnight` | The time when the sun is at its lowest point below the horizon (solar midnight). | +| `sunset` | The time in the evening when the sun disappears below the horizon. | +| `dusk` | The time in the evening when the sun is a specific number of degrees below the horizon (astronomical dusk). | +| `daylight` | The total duration when the sun is above the horizon (between sunrise and sunset). | +| `night` | The duration between astronomical dusk of one day and astronomical dawn of the next day. | +| `twilight` | The duration of twilight periods (between dawn and sunrise, or between sunset and dusk). | +| `azimuth` | Optional: The number of degrees clockwise from North at which the sun can be observed. | +| `zenith` | Optional: The angle of the sun measured down from directly above the observer. | +| `elevation` | Optional: The number of degrees up from the horizon at which the sun can be observed. | + +## Relationships + +- **Used by `DecisionalContext`**: A `Sun` is part of the [`DecisionalContext`](decisional_context.md), providing crucial information about the sun's position and daylight patterns for decision-making. + +## Key Operations + +- **`time_before_sunrise`**: Returns the time remaining until sunrise, or None if sunrise has already occurred. +- **`time_after_sunrise`**: Returns the duration that has elapsed since sunrise. +- **`time_before_sunset`**: Returns the time remaining until sunset, or None if sunset has already occurred. +- **`time_after_sunset`**: Returns the duration that has elapsed since sunset. + +## Solar Position Data + +The `Sun` entity provides comprehensive solar geometry information: + +- **Azimuth Angle**: Directional orientation of the sun for optimal solar panel positioning calculations. +- **Zenith Angle**: Solar elevation data for irradiance calculations and shading analysis. +- **Elevation Angle**: Height of the sun above the horizon for direct solar exposure assessment. + +## Usage Patterns + +- **Solar Energy Optimization**: Determining optimal times for solar energy production based on sun position. +- **Daylight Planning**: Understanding available daylight hours for energy generation planning. +- **Seasonal Adjustments**: Adapting mining schedules based on changing daylight patterns throughout the year. + +## Time-based Calculations + +The entity provides several computed properties for real-time solar planning: + +- **Sunrise/Sunset Timing**: Dynamic calculation of time remaining until key solar events. +- **Daylight Duration**: Total available sunlight hours for energy production planning. +- **Twilight Periods**: Extended periods of reduced sunlight that may still contribute to solar generation. +- **Night Duration**: Complete darkness periods when solar generation is not possible. + +## Integration with Forecasting + +- **Weather Correlation**: Combines astronomical data with weather forecasts for enhanced solar predictions. +- **Irradiance Modeling**: Provides the geometric foundation for calculating expected solar irradiance. +- **Seasonal Adaptation**: Enables forecasting systems to account for changing solar patterns throughout the year. +- **Geographic Accuracy**: Supports location-specific solar calculations based on local astronomical conditions. + +This entity is essential for any solar-based energy forecasting, providing the astronomical foundation that enables accurate prediction of solar energy availability throughout different times of day and seasons. diff --git a/docs/home_load/home_load_forecast_providers.md b/docs/home_load/home_load_forecast_providers.md new file mode 100644 index 0000000..eb3e101 --- /dev/null +++ b/docs/home_load/home_load_forecast_providers.md @@ -0,0 +1,433 @@ +# Home Load Forecast Providers + +This document describes all available **Energy Load Forecast Providers** in +EdgeMining. Each provider implements `EnergyLoadForecastProviderPort` and +produces a `LoadEnergyConsumption` forecast for a configurable time horizon. + +Providers are selected per-device via the `EnergyLoadForecastProviderAdapter` +enum and configured through a corresponding dataclass. + +--- + +## Provider Summary + +| Adapter Enum | Provider Class | Category | Dependencies | Pre-trained Model | +|---|---|---|---|---| +| `DUMMY` | `DummyEnergyLoadForecastProvider` | Testing | None | No | +| `NAIVE_LAST_HOUR` | `NaiveLastHourForecastProvider` | Baseline | None | No | +| `NAIVE_PERSISTENCE` | `NaivePersistenceForecastProvider` | Baseline | None | No | +| `SEASONAL_BASELINE` | `SeasonalBaselineForecastProvider` | Statistical | None | No | +| `TYPICAL_PROFILE` | `TypicalProfileForecastProvider` | Statistical | None | No | +| `STATSMODELS` | `StatsmodelsForecastProvider` | ML | `statsmodels` | Yes | +| `XGBOOST` | `XGBoostForecastProvider` | ML | `xgboost` | Yes | +| `SKFORECAST` | `SkforecastForecastProvider` | ML | `skforecast`, `scikit-learn` | Yes | + +--- + +## Baseline Providers + +### DUMMY + +**Purpose**: development and testing only. Generates random power values so +that the rest of the pipeline can run without real sensor data. + +**Algorithm**: if history is available, takes the last interval's average power +as baseline; otherwise picks a random value in `[200, load_power_max]`. Each +forecast hour applies small random noise. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `load_power_max` | `float` | `500.0` | Upper bound for generated power (W) | + +| Property | Value | +|---|---| +| Min required history | 0 hours | +| Forecast horizon | N/A (config-driven) | +| File | `adapters/domain/home_load/forecast_providers/dummy.py` | + +--- + +### NAIVE_LAST_HOUR + +**Purpose**: simplest real-world baseline. Repeats the most recent measured +power into the future — useful as a short-horizon fallback when no other +provider is available. + +**Algorithm**: computes the average power over the last 1 hour of history and +projects that flat value for every forecast hour. Falls back to the overall +history average if the last hour has no data. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `hours_ahead` | `int` | `3` | Forecast horizon in hours | + +| Property | Value | +|---|---| +| Min required history | 1 hour | +| Best for | Very short horizons (1–3 h), instant fallback | +| File | `adapters/domain/home_load/forecast_providers/naive_last_hour.py` | + +--- + +### NAIVE_PERSISTENCE + +**Purpose**: strong intra-day baseline that assumes tomorrow looks like +yesterday. + +**Algorithm**: builds an `hour → power` map from the same calendar date +`delta_days` ago, then replays that 24 h profile forward. Missing hours fall +back to the global history average. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `hours_ahead` | `int` | `24` | Forecast horizon in hours | +| `delta_days` | `int` | `1` | How many days back to look (1 = yesterday) | + +| Property | Value | +|---|---| +| Min required history | `delta_days × 24` hours (default 24) | +| Best for | Devices with regular daily patterns; ML-free fallback | +| File | `adapters/domain/home_load/forecast_providers/naive_persistence.py` | + +--- + +## Statistical Providers + +### SEASONAL_BASELINE + +**Purpose**: lightweight statistical forecast that captures weekly +seasonality without any ML dependency. + +**Algorithm**: groups all history by `(day_of_week, hour_of_day)` and averages +each slot. For each forecast hour, looks up the matching `(dow, hod)` bucket. +Falls back to the global average across all slots. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `hours_ahead` | `int` | `3` | Forecast horizon in hours | +| `weeks_lookback` | `int` | `4` | Weeks of history to consider | + +| Property | Value | +|---|---| +| Min required history | 0 hours (degrades gracefully) | +| Best for | Quick start with ≥1 week of data | +| File | `adapters/domain/home_load/forecast_providers/seasonal_baseline.py` | + +--- + +### TYPICAL_PROFILE + +**Purpose**: more refined statistical forecast that adds **monthly** grouping +on top of weekly seasonality. Captures how consumption changes across seasons +(e.g. heating in winter vs. cooling in summer). + +**Algorithm**: two-level profile lookup: +1. **Primary**: `(month, day_of_week, hour_of_day)` — average power for this + exact month + weekday + hour combination. +2. **Fallback**: `(day_of_week, hour_of_day)` — ignores month, same as + `SEASONAL_BASELINE` logic. +3. **Global**: overall average if both levels miss. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `hours_ahead` | `int` | `24` | Forecast horizon in hours | +| `weeks_lookback` | `int` | `8` | Weeks of history to consider | + +| Property | Value | +|---|---| +| Min required history | `weeks_lookback × 168` hours (default 1 344 h ≈ 8 weeks) | +| Best for | Devices with seasonal patterns; new installations with ≥2 months of data | +| File | `adapters/domain/home_load/forecast_providers/typical_profile.py` | + +--- + +## ML Providers + +All ML providers share these traits: + +- **Lazy imports**: heavy dependencies (`statsmodels`, `xgboost`, `skforecast`) + are imported at runtime. If a library is missing, the provider gracefully + returns `None` (except `STATSMODELS` which raises). +- **Pre-trained model support**: each looks for an active `LoadConsumptionModel` + in the model repository. If found, the serialised model is loaded via + `pickle`. Otherwise, the provider fits on-the-fly from history. +- **Nightly training**: `LoadForecastModelTrainingService.train_all()` trains + all ML providers (HW, XGBoost, skforecast), evaluates on a 24 h holdout, + and promotes the best model (lowest MAE) to active. + +### STATSMODELS + +**Purpose**: Holt-Winters exponential smoothing — a classical time-series +method that captures trend and daily seasonality (period = 24 h). + +**Algorithm**: `ExponentialSmoothing(trend="add", seasonal="add", +seasonal_periods=24)` from `statsmodels`. Fits on hourly power series derived +from history intervals. Forecast calls `fitted.forecast(hours_ahead)`. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `hours_ahead` | `int` | `3` | Forecast horizon in hours | +| `weeks_lookback` | `int` | `8` | Weeks of history for training | +| `method` | `str` | `"hw"` | Model family (`"hw"` = Holt-Winters; `"sarima"` reserved) | +| `seasonal_periods` | `int` | `24` | Seasonal cycle length in hours | + +| Property | Value | +|---|---| +| Min required history | `seasonal_periods × 2` hours (default 48) | +| Best for | Smooth loads with clear 24 h seasonality (e.g. household aggregate) | +| File | `adapters/domain/home_load/forecast_providers/statsmodels_hw.py` | + +--- + +### XGBOOST + +**Purpose**: gradient-boosted trees using hand-crafted calendar + lag features +with iterative 1-step-ahead prediction. + +**Algorithm**: trains an `XGBRegressor` on a supervised dataset built from: +- **Calendar features**: `hour_of_day`, `day_of_week`, `is_weekend`, `month`. +- **Lag features**: power at `t-1h`, `t-2h`, `t-3h`, `t-24h`, `t-168h`. + +Prediction iterates 1 step at a time, appending the previous prediction as +the next lag input. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `hours_ahead` | `int` | `3` | Forecast horizon in hours | +| `weeks_lookback` | `int` | `8` | Weeks of history for training | +| `n_estimators` | `int` | `100` | Number of boosting rounds | +| `max_depth` | `int` | `6` | Maximum tree depth | +| `learning_rate` | `float` | `0.1` | Boosting learning rate | + +| Property | Value | +|---|---| +| Min required history | `168 + 48 + hours_ahead` hours (default 219) | +| Best for | Non-linear patterns, devices with strong weekly periodicity | +| File | `adapters/domain/home_load/forecast_providers/xgboost_provider.py` | + +--- + +### SKFORECAST + +**Purpose**: auto-regressive multi-step forecasting via `skforecast`'s +`ForecasterRecursive`, wrapping **any** scikit-learn regressor. The forecaster +feeds its own predictions back as input for subsequent steps, producing native +multi-step forecasts without manual lag iteration. + +**Algorithm**: `ForecasterRecursive(estimator=, lags=num_lags)` +fits on hourly power series. Prediction calls `forecaster.predict(steps=N)`. + +**Supported sklearn backends** (selected via `sklearn_model` config string): + +| Backend | Strengths | Best for | +|---|---|---| +| `RandomForestRegressor` | Robust to outlier, feature importance | Medium-large datasets | +| `GradientBoostingRegressor` | High accuracy, handles non-linearity | Production | +| `ExtraTreesRegressor` | Fast training, good trade-off | Quick screening | +| `KNeighborsRegressor` | No heavy training, adaptive | Regular profiles | +| `Ridge` | Interpretable, very fast | Linear relationships | +| `Lasso` | Sparse features, fast | Feature selection | +| `ElasticNet` | Mix of Ridge + Lasso | Balanced regularisation | +| `AdaBoostRegressor` | Adaptive boosting | Bias reduction | +| `MLPRegressor` | Captures complex patterns | Large datasets | +| `SVR` | Good on small datasets | Low-data scenarios | + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `hours_ahead` | `int` | `24` | Forecast horizon in hours | +| `weeks_lookback` | `int` | `8` | Weeks of history for training | +| `sklearn_model` | `str` | `"RandomForestRegressor"` | Name of the sklearn regressor class | +| `num_lags` | `int` | `72` | Number of lag observations used as features | + +| Property | Value | +|---|---| +| Min required history | `num_lags + 48 + hours_ahead` hours (default 144) | +| Best for | General-purpose ML forecasting with model competition | +| File | `adapters/domain/home_load/forecast_providers/skforecast_provider.py` | + +#### Optuna Bayesian Tuning (F6) + +The `SkforecastForecastProvider.tune()` static method runs Bayesian +hyperparameter optimisation via `optuna` + `bayesian_search_forecaster`. +It searches: + +- **Lag count**: categorical over `[24, 48, 72]` +- **Model hyperparameters**: per-model search spaces (e.g. `n_estimators`, + `max_depth`, `learning_rate`, `alpha`, `n_neighbors`) + +The training service calls `tune()` automatically during nightly training +(configurable via `perform_tuning` / `tuning_trials` parameters). Best +parameters are stored in `LoadConsumptionModel.tuning_params`. + +#### Rolling-Window Backtesting (F7) + +The `SkforecastForecastProvider.backtest()` static method evaluates a fitted +forecaster on the full training set using `backtesting_forecaster` with +`TimeSeriesFold`. Returns: + +- `backtest_mae` — MAE across all folds +- `backtest_rmse` — RMSE across all folds +- `backtest_folds` — number of evaluation windows + +Backtesting runs automatically after training. Results are stored on the +`LoadConsumptionModel` entity alongside the holdout metrics. + +--- + +## Choosing a Provider + +``` +Is this for development/testing? + └─ Yes → DUMMY + +Do you have < 1 hour of history? + └─ Yes → NAIVE_LAST_HOUR (flat repeat of last reading) + +Do you have ~ 1 day of history? + └─ Yes → NAIVE_PERSISTENCE (yesterday's profile) + +Do you have 1–4 weeks of history? + └─ Yes → SEASONAL_BASELINE (weekly pattern average) + +Do you have 2+ months of history? + └─ Yes → TYPICAL_PROFILE (monthly + weekly pattern) + +Do you have 1+ week and want ML? + └─ Yes → STATSMODELS (Holt-Winters) or XGBOOST + +Do you have 1+ week, want best accuracy, and can install skforecast? + └─ Yes → SKFORECAST (auto-regressive multi-model with tuning) +``` + +In production, **SKFORECAST** is recommended as the primary provider. The +nightly training service automatically competes Holt-Winters, XGBoost, and +skforecast models, promoting the best one. The simpler providers +(`NAIVE_PERSISTENCE`, `SEASONAL_BASELINE`) serve as robust fallbacks. + +--- + +## Architecture + +### Port & Adapter Pattern + +``` +Domain Adapters +┌──────────────────┐ ┌───────────────────────────┐ +│ EnergyLoadFore- │ │ DummyProvider │ +│ castProviderPort │◄──────────│ NaiveLastHourProvider │ +│ │ │ NaivePersistenceProvider │ +│ + adapter_type │ │ SeasonalBaselineProvider │ +│ + min_history │ │ TypicalProfileProvider │ +│ + get_forecast()│ │ StatsmodelsProvider │ +│ │ │ XGBoostProvider │ +│ │ │ SkforecastProvider │ +└──────────────────┘ └───────────────────────────┘ +``` + +Each provider has: + +1. **Enum value** in `EnergyLoadForecastProviderAdapter` (domain layer) +2. **Config dataclass** in `shared/adapter_configs/home_load.py` +3. **Factory class** implementing `EnergyLoadForecastAdapterFactory` +4. **Schema class** in `adapters/domain/home_load/schemas.py` +5. **Wiring** in `AdapterService` factory dispatch + `adapter_maps` + +### Shared Feature Engineering + +ML providers share helper functions from +`adapters/domain/home_load/forecast_providers/features.py`: + +| Function | Used by | Description | +|---|---|---| +| `intervals_to_hourly_series()` | HW, XGB, Skforecast | Converts `LoadEnergyConsumption` intervals to `[(timestamp, power)]` | +| `fill_missing_hours()` | HW, XGB, Skforecast | Forward-fills gaps in hourly series | +| `build_calendar_features()` | XGB | Extracts `[hour, dow, is_weekend, month]` | +| `build_lag_features()` | XGB | Creates lag columns `[1h, 2h, 3h, 24h, 168h]` | +| `prepare_supervised_dataset()` | XGB | Combines calendar + lag features into `(X, y)` | + +### Model Lifecycle + +``` +Nightly Training (04:00) + │ + ├─ For each enabled device: + │ ├─ Fetch 8 weeks of history + │ ├─ Split: train (all - 24h) / holdout (last 24h) + │ │ + │ ├─ _train_hw() → LoadConsumptionModel (STATSMODELS) + │ ├─ _train_xgb() → LoadConsumptionModel (XGBOOST) + │ └─ _train_skforecast() → LoadConsumptionModel (SKFORECAST) + │ ├─ Optuna tuning (optional, default ON) + │ └─ Rolling-window backtesting + │ + │ Compare MAE → promote best → is_active = True + │ Persist all models to LoadConsumptionModelRepository + │ + └─ Done + +Forecast (every 5s optimisation loop) + │ + ├─ Provider checks model_repo for active model + ├─ If found → pickle.loads() → predict + └─ If not → fit on-the-fly from history → predict +``` + +--- + +## LoadConsumptionModel Entity + +The `LoadConsumptionModel` entity stores trained model metadata and weights: + +| Field | Type | Description | +|---|---|---| +| `device_id` | `Optional[EntityId]` | Device this model was trained for (`None` = aggregate) | +| `adapter_type` | `EnergyLoadForecastProviderAdapter` | Which ML provider created it | +| `trained_at` | `Optional[datetime]` | Training timestamp | +| `mae` | `Optional[float]` | Mean Absolute Error on 24 h holdout | +| `rmse` | `Optional[float]` | Root Mean Squared Error on 24 h holdout | +| `samples_used` | `int` | Number of training data points | +| `is_active` | `bool` | Whether this model is the current production model | +| `model_bytes` | `Optional[bytes]` | Serialised model (pickle) | +| `tuning_params` | `Optional[dict]` | Best hyperparameters from Optuna tuning | +| `backtest_mae` | `Optional[float]` | MAE from rolling-window backtesting | +| `backtest_rmse` | `Optional[float]` | RMSE from rolling-window backtesting | +| `backtest_folds` | `int` | Number of backtesting evaluation folds | + +### ML Model Competition and Promotion + +During each training run (nightly at 04:00 or triggered manually via API), +the service trains **three candidate models** for every enabled device: +Holt-Winters (STATSMODELS), XGBoost, and Skforecast. Each candidate is +evaluated against a **holdout set** consisting of the last 24 hours of +history data. + +**Selection criterion**: the candidate with the **lowest MAE** (Mean Absolute +Error) on the holdout wins and is promoted to `is_active = True`. All +previously active models for that device are demoted to `is_active = False`. + +```python +candidates = [hw_model, xgb_model, skf_model] +best = min(candidates, key=lambda m: m.mae) +best.is_active = True +``` + +**What `is_active` means in practice**: + +| `is_active` | Meaning | +|---|---| +| `True` | This is the **production model** — the forecast provider will load and use it for predictions | +| `False` | Historical/archived model — kept for audit and comparison but not used for live forecasts | + +**How providers consume the active model**: when a forecast provider +(STATSMODELS, XGBOOST, or SKFORECAST) needs to produce a prediction, it +queries `LoadConsumptionModelRepository.get_active_model(adapter_type, +device_id)`. If an active model exists, it is deserialised from +`model_bytes` via `pickle.loads()` and used directly. If no active model is +found (e.g. training has never run), the provider falls back to +**fit-on-the-fly** from the supplied history — slower but ensures a forecast +is always available. + +**Other stored metrics** (`rmse`, `backtest_mae`, `backtest_rmse`, +`backtest_folds`, `tuning_params`) are **informational only** — they are not +used for model selection. They are persisted for monitoring, comparison, and +debugging via the `GET /training/models` API endpoint. diff --git a/frontend b/frontend deleted file mode 160000 index 9f176b5..0000000 --- a/frontend +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9f176b547470bb835c83478c49d5517b21118bd1 diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..52ffc4d --- /dev/null +++ b/frontend/.env @@ -0,0 +1 @@ +VITE_API_BASE_URL="" \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/frontend/.vscode/launch.json b/frontend/.vscode/launch.json new file mode 100644 index 0000000..676f0e4 --- /dev/null +++ b/frontend/.vscode/launch.json @@ -0,0 +1,23 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Webapp", + "request": "launch", + "runtimeExecutable": "npx", + "runtimeArgs": [ + "vite" + ], + "type": "node", + "serverReadyAction": { + "action": "debugWithChrome", + "pattern": "localhost:.*m([0-9]+)", + "uriFormat": "http://localhost:%s/", + "killOnServerStop": true + } + } + ] +} \ No newline at end of file diff --git a/frontend/.vscode/tasks.json b/frontend/.vscode/tasks.json new file mode 100644 index 0000000..95172fb --- /dev/null +++ b/frontend/.vscode/tasks.json @@ -0,0 +1,22 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "dev", + "label": "npm: dev", + "detail": "vite", + "isBackground": true, + "problemMatcher": [ + { + "base": "$tsc-watch", + "background": { + "activeOnStart": true, + "beginsPattern": ".*", + "endsPattern": ".*ready in.*ms.*" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/frontend/API_IMPLEMENTATION_STATUS.md b/frontend/API_IMPLEMENTATION_STATUS.md new file mode 100644 index 0000000..76fc26d --- /dev/null +++ b/frontend/API_IMPLEMENTATION_STATUS.md @@ -0,0 +1,382 @@ +# Edge Mining API Implementation Status + +This document lists all the FastAPI endpoints exposed by the backend and tracks their implementation status in the frontend. + +## API Base URL +All endpoints are prefixed with the API base URL (typically `/api/v1`). + +--- + +## 1. Energy Sources API (`/energy-sources`) + +### Endpoints: + +| Method | Endpoint | Description | Frontend Service | Frontend Page | Status | +|--------|----------|-------------|------------------|---------------|---------| +| GET | `/energy-sources` | Get list of all energy sources | `EnergySourceService.getEnergySources()` | `EnergySourcesSettingsView.vue` | ✅ Implemented | +| POST | `/energy-sources` | Create a new energy source | `EnergySourceService.addEnergySource()` | `EnergySourcesSettingsView.vue` | ✅ Implemented | +| GET | `/energy-sources/types` | Get available energy source types | `EnergySourceService.getEnergySourceTypes()` | - | ✅ Implemented | +| GET | `/energy-sources/{source_id}` | Get specific energy source details | `EnergySourceService.getEnergySource()` | - | ✅ Implemented | +| PUT | `/energy-sources/{source_id}` | Update an energy source | `EnergySourceService.updateEnergySource()` | - | ✅ Implemented | +| DELETE | `/energy-sources/{source_id}` | Delete an energy source | `EnergySourceService.deleteEnergySource()` | - | ✅ Implemented | + +**Location in backend:** `core/edge_mining/adapters/domain/energy/fast_api/router.py` + +--- + +## 2. Energy Monitors API (`/energy-monitors`) + +### Endpoints: + +| Method | Endpoint | Description | Frontend Service | Frontend Page | Status | +|--------|----------|-------------|------------------|---------------|---------| +| GET | `/energy-monitors` | Get list of all energy monitors | `EnergyMonitorService.getEnergyMonitors()` | `EnergyMonitorSettingsView.vue` | ✅ Implemented | +| POST | `/energy-monitors` | Create a new energy monitor | `EnergyMonitorService.addEnergyMonitor()` | `EnergyMonitorSettingsView.vue` | ✅ Implemented | +| GET | `/energy-monitors/types` | Get available energy monitor types | `EnergyMonitorService.getAdapterTypes()` | `EnergyMonitorSettingsView.vue` | ✅ Implemented | +| GET | `/energy-monitors/types/{adapter_type}/config-schema` | Get config schema for monitor type | `EnergyMonitorService.getConfigSchema()` | `EnergyMonitorSettingsView.vue` | ✅ Implemented | +| GET | `/energy-monitors/types/{adapter_type}/external-services` | Get compatible external service type for monitor type | `EnergyMonitorService.getExternalServices()` | `EnergyMonitorSettingsView.vue` | ✅ Implemented | +| GET | `/energy-monitors/{monitor_id}` | Get specific energy monitor | `EnergyMonitorService.getEnergyMonitor()` | - | ✅ Implemented | +| PUT | `/energy-monitors/{monitor_id}` | Update an energy monitor | `EnergyMonitorService.updateEnergyMonitor()` | - | ✅ Implemented | +| DELETE | `/energy-monitors/{monitor_id}` | Delete an energy monitor | `EnergyMonitorService.deleteEnergyMonitor()` | - | ✅ Implemented | + +**Location in backend:** `core/edge_mining/adapters/domain/energy/fast_api/router.py` + +--- + +## 3. Miners API (`/miners`) + +### Endpoints: + +| Method | Endpoint | Description | Frontend Service | Frontend Page | Status | +|--------|----------|-------------|------------------|---------------|---------| +| GET | `/miners` | Get list of all miners | `MinerService.getMiners()` | `MinersSettingsView.vue` | ✅ Implemented | +| POST | `/miners` | Create a new miner | `MinerService.addMiner()` | `MinersSettingsView.vue` | ✅ Implemented | +| GET | `/miners/{miner_id}` | Get specific miner details | `MinerService.getMiner()` | - | ✅ Implemented | +| PUT | `/miners/{miner_id}` | Update a miner | `MinerService.updateMiner()` | - | ✅ Implemented | +| DELETE | `/miners/{miner_id}` | Delete a miner | `MinerService.deleteMiner()` | - | ✅ Implemented | +| POST | `/miners/{miner_id}/start` | Start a miner | `MinerService.startMiner()` | - | ✅ Implemented | +| POST | `/miners/{miner_id}/stop` | Stop a miner | `MinerService.stopMiner()` | - | ✅ Implemented | +| GET | `/miners/{miner_id}/status` | Get miner status | `MinerService.getMinerStatus()` | - | ✅ Implemented | +| POST | `/miners/{miner_id}/activate` | Activate a miner | `MinerService.activateMiner()` | - | ✅ Implemented | +| POST | `/miners/{miner_id}/deactivate` | Deactivate a miner | `MinerService.deactivateMiner()` | - | ✅ Implemented | +| POST | `/miners/{miner_id}/set-controller` | Set miner controller | `MinerService.setMinerController()` | - | ✅ Implemented | + +**Location in backend:** `core/edge_mining/adapters/domain/miner/fast_api/router.py` + +--- + +## 4. Miner Controllers API (`/miner-controllers`) + +### Endpoints: + +| Method | Endpoint | Description | Frontend Service | Frontend Page | Status | +|--------|----------|-------------|------------------|---------------|---------| +| GET | `/miner-controllers` | Get list of all miner controllers | `MinerControllerService.getMinerControllers()` | `MinerControllersSettingsView.vue` | ✅ Implemented | +| POST | `/miner-controllers` | Create a new miner controller | `MinerControllerService.addMinerController()` | `MinerControllersSettingsView.vue` | ✅ Implemented | +| GET | `/miner-controllers/types` | Get available controller types | `MinerControllerService.getAdapterTypes()` | `MinerControllersSettingsView.vue` | ✅ Implemented | +| GET | `/miner-controllers/types/{adapter_type}/config-schema` | Get config schema for controller type | `MinerControllerService.getConfigSchema()` | `MinerControllersSettingsView.vue` | ✅ Implemented | +| GET | `/miner-controllers/{controller_id}` | Get specific controller | `MinerControllerService.getMinerController()` | - | ✅ Implemented | +| PUT | `/miner-controllers/{controller_id}` | Update a controller | `MinerControllerService.updateMinerController()` | `MinerControllersSettingsView.vue` | ✅ Implemented | +| DELETE | `/miner-controllers/{controller_id}` | Delete a controller | `MinerControllerService.deleteMinerController()` | `MinerControllersSettingsView.vue` | ✅ Implemented | + +**Location in backend:** `core/edge_mining/adapters/domain/miner/fast_api/router.py` + +--- + +## 5. Policies API (`/policies`) + +### Endpoints: + +| Method | Endpoint | Description | Frontend Service | Frontend Page | Status | +|--------|----------|-------------|------------------|---------------|---------| +| GET | `/policies` | Get list of all optimization policies | `PolicyService.getPolicies()` | `PoliciesSettingsView.vue` | ✅ Implemented | +| POST | `/policies` | Create a new policy | `PolicyService.addPolicy()` | `PoliciesSettingsView.vue` | ✅ Implemented | +| GET | `/policies/{policy_id}` | Get specific policy | `PolicyService.getPolicy()` | `PoliciesSettingsView.vue` | ✅ Implemented | +| PUT | `/policies/{policy_id}` | Update a policy | `PolicyService.updatePolicy()` | `PoliciesSettingsView.vue` | ✅ Implemented | +| DELETE | `/policies/{policy_id}` | Delete a policy | `PolicyService.deletePolicy()` | `PoliciesSettingsView.vue` | ✅ Implemented | +| GET | `/policies/{policy_id}/check` | Check if policy is valid | `PolicyService.checkPolicy()` | `PoliciesSettingsView.vue` | ✅ Implemented | + +**Location in backend:** `core/edge_mining/adapters/domain/policy/fast_api/router.py` + +--- + +## 6. Policy Rules API (`/policies/{policy_id}/rules`) + +### Endpoints: + +| Method | Endpoint | Description | Frontend Service | Frontend Page | Status | +|--------|----------|-------------|------------------|---------------|---------| +| POST | `/policies/{policy_id}/rules` | Add rule to policy | `PolicyService.addRule()` | `PoliciesSettingsView.vue` | ✅ Implemented | +| GET | `/policies/{policy_id}/types/{rule_type}` | Get rules by type | `PolicyService.getRulesByType()` | - | ✅ Implemented | +| GET | `/policies/{policy_id}/rules/{rule_id}` | Get specific rule | `PolicyService.getRule()` | - | ✅ Implemented | +| PUT | `/policies/{policy_id}/rules/{rule_id}` | Update a rule | `PolicyService.updateRule()` | `PoliciesSettingsView.vue` | ✅ Implemented | +| DELETE | `/policies/{policy_id}/rules/{rule_id}` | Delete a rule | `PolicyService.deleteRule()` | `PoliciesSettingsView.vue` | ✅ Implemented | +| GET | `/policies/{policy_id}/rules/{rule_id}/enable` | Enable a rule | `PolicyService.enableRule()` | `PoliciesSettingsView.vue` | ✅ Implemented | +| GET | `/policies/{policy_id}/rules/{rule_id}/disable` | Disable a rule | `PolicyService.disableRule()` | `PoliciesSettingsView.vue` | ✅ Implemented | + +**Location in backend:** `core/edge_mining/adapters/domain/policy/fast_api/router.py` + +--- + +## 7. Decisional Context API (`/decisional-context`) + +### Endpoints: + +| Method | Endpoint | Description | Frontend Service | Frontend Page | Status | +|--------|----------|-------------|------------------|---------------|---------| +| GET | `/decisional-context/structure` | Get the complete structure of the DecisionalContext | `PolicyService.getDecisionalContextStructure()` | `PoliciesSettingsView.vue` | ✅ Implemented | + +**Location in backend:** `core/edge_mining/adapters/domain/policy/fast_api/router.py` + +--- + +## 8. Forecast Providers API (`/forecast-providers`) + +### Endpoints: + +| Method | Endpoint | Description | Frontend Service | Frontend Page | Status | +|--------|----------|-------------|------------------|---------------|---------| +| GET | `/forecast-providers` | Get list of all forecast providers | `ForecastProviderService.getForecastProviders()` | `ForecastProvidersSettingsView.vue` | ✅ Implemented | +| POST | `/forecast-providers` | Create a new forecast provider | `ForecastProviderService.addForecastProvider()` | `ForecastProvidersSettingsView.vue` | ✅ Implemented | +| GET | `/forecast-providers/types` | Get available provider types | `ForecastProviderService.getAdapterTypes()` | `ForecastProvidersSettingsView.vue` | ✅ Implemented | +| GET | `/forecast-providers/types/{adapter_type}/config-schema` | Get config schema for provider type | `ForecastProviderService.getConfigSchema()` | `ForecastProvidersSettingsView.vue` | ✅ Implemented | +| GET | `/forecast-providers/types/{adapter_type}/external-services` | Get compatible external service type for provider type | `ForecastProviderService.getExternalServices()` | `ForecastProvidersSettingsView.vue` | ✅ Implemented | +| GET | `/forecast-providers/{provider_id}` | Get specific provider | `ForecastProviderService.getForecastProvider()` | - | ✅ Implemented | +| PUT | `/forecast-providers/{provider_id}` | Update a provider | `ForecastProviderService.updateForecastProvider()` | `ForecastProvidersSettingsView.vue` | ✅ Implemented | +| DELETE | `/forecast-providers/{provider_id}` | Delete a provider | `ForecastProviderService.deleteForecastProvider()` | `ForecastProvidersSettingsView.vue` | ✅ Implemented | + +**Location in backend:** `core/edge_mining/adapters/domain/forecast/fast_api/router.py` + +--- + +## 9. Notifiers API (`/notifiers`) + +### Endpoints: + +| Method | Endpoint | Description | Frontend Service | Frontend Page | Status | +|--------|----------|-------------|------------------|---------------|---------| +| GET | `/notifiers` | Get list of all notifiers | `NotifierService.getNotifiers()` | `NotifiersSettingsView.vue` | ✅ Implemented | +| POST | `/notifiers` | Create a new notifier | `NotifierService.addNotifier()` | `NotifiersSettingsView.vue` | ✅ Implemented | +| GET | `/notifiers/types` | Get available notifier types | `NotifierService.getAdapterTypes()` | `NotifiersSettingsView.vue` | ✅ Implemented | +| GET | `/notifiers/types/{adapter_type}/config-schema` | Get config schema for notifier type | `NotifierService.getConfigSchema()` | `NotifiersSettingsView.vue` | ✅ Implemented | +| GET | `/notifiers/types/{adapter_type}/external-services` | Get compatible external service type for notifier type | `NotifierService.getExternalServices()` | `NotifiersSettingsView.vue` | ✅ Implemented | +| GET | `/notifiers/{notifier_id}` | Get specific notifier | `NotifierService.getNotifier()` | - | ✅ Implemented | +| PUT | `/notifiers/{notifier_id}` | Update a notifier | `NotifierService.updateNotifier()` | `NotifiersSettingsView.vue` | ✅ Implemented | +| DELETE | `/notifiers/{notifier_id}` | Delete a notifier | `NotifierService.deleteNotifier()` | `NotifiersSettingsView.vue` | ✅ Implemented | +| POST | `/notifiers/{notifier_id}/test` | Test a notifier | `NotifierService.testNotifier()` | `NotifiersSettingsView.vue` | ✅ Implemented | + +**Location in backend:** `core/edge_mining/adapters/domain/notification/fast_api/router.py` + +--- + +## 10. External Services API (`/external-services`) + +### Endpoints: + +| Method | Endpoint | Description | Frontend Service | Frontend Page | Status | +|--------|----------|-------------|------------------|---------------|---------| +| GET | `/external-services` | Get list of all external services | `ExternalServiceService.getExternalServices()` | `ExternalServicesSettingsView.vue` | ✅ Implemented | +| POST | `/external-services` | Create a new external service | `ExternalServiceService.addExternalService()` | `ExternalServicesSettingsView.vue` | ✅ Implemented | +| GET | `/external-services/types` | Get available service types | `ExternalServiceService.getAdapterTypes()` | `ExternalServicesSettingsView.vue` | ✅ Implemented | +| GET | `/external-services/types/{adapter_type}/config-schema` | Get config schema for service type | `ExternalServiceService.getConfigSchema()` | `ExternalServicesSettingsView.vue` | ✅ Implemented | +| GET | `/external-services/{service_id}` | Get specific service | `ExternalServiceService.getExternalService()` | - | ✅ Implemented | +| PUT | `/external-services/{service_id}` | Update a service | `ExternalServiceService.updateExternalService()` | `ExternalServicesSettingsView.vue` | ✅ Implemented | +| DELETE | `/external-services/{service_id}` | Delete a service | `ExternalServiceService.deleteExternalService()` | `ExternalServicesSettingsView.vue` | ✅ Implemented | +| GET | `/external-services/{service_id}/status` | Get connection status of a service | `ExternalServiceService.getExternalServiceStatus()` | `ExternalServicesSettingsView.vue` | ✅ Implemented | +| GET | `/external-services/{service_id}/linked-entities` | Get all entities linked to a service | `ExternalServiceService.getLinkedEntities()` | `ExternalServicesSettingsView.vue` | ✅ Implemented | + +**Location in backend:** `core/edge_mining/adapters/infrastructure/external_services/fast_api/router.py` + +--- + +## 11. Rule Engine API (`/rule-engine`) + +### Endpoints: + +| Method | Endpoint | Description | Frontend Service | Frontend Page | Status | +|--------|----------|-------------|------------------|---------------|---------| +| GET | `/rule-engine/config` | Get rule engine configuration | `RuleEngineService.getConfig()` | - | ✅ Implemented | +| GET | `/rule-engine/info` | Get rule engine capabilities info | `RuleEngineService.getInfo()` | - | ✅ Implemented | +| POST | `/rule-engine/evaluate` | Evaluate rules against context | `RuleEngineService.evaluate()` | - | ✅ Implemented | +| POST | `/rule-engine/validate` | Validate rule conditions | `RuleEngineService.validate()` | - | ✅ Implemented | + +**Location in backend:** `core/edge_mining/adapters/infrastructure/rule_engine/fast_api/router.py` + +--- + +## 12. Optimization Units API (`/optimization-units`) + +### Endpoints: + +| Method | Endpoint | Description | Frontend Service | Frontend Page | Status | +|--------|----------|-------------|------------------|---------------|---------| +| GET | `/optimization-units` | Get list of all optimization units | `OptimizationUnitService.getOptimizationUnits()` | `OptimizationUnitsSettingsView.vue` | ✅ Implemented | +| POST | `/optimization-units` | Create a new optimization unit | `OptimizationUnitService.addOptimizationUnit()` | `OptimizationUnitsSettingsView.vue` | ✅ Implemented | +| GET | `/optimization-units/{unit_id}` | Get specific optimization unit | `OptimizationUnitService.getOptimizationUnit()` | - | ✅ Implemented | +| PUT | `/optimization-units/{unit_id}` | Update an optimization unit | `OptimizationUnitService.updateOptimizationUnit()` | `OptimizationUnitsSettingsView.vue` | ✅ Implemented | +| DELETE | `/optimization-units/{unit_id}` | Delete an optimization unit | `OptimizationUnitService.deleteOptimizationUnit()` | `OptimizationUnitsSettingsView.vue` | ✅ Implemented | +| POST | `/optimization-units/{unit_id}/enable` | Enable an optimization unit | `OptimizationUnitService.enableOptimizationUnit()` | `OptimizationUnitsSettingsView.vue` | ✅ Implemented | +| POST | `/optimization-units/{unit_id}/disable` | Disable an optimization unit | `OptimizationUnitService.disableOptimizationUnit()` | `OptimizationUnitsSettingsView.vue` | ✅ Implemented | +| POST | `/optimization-units/{unit_id}/energy-source` | Assign energy source to unit | `OptimizationUnitService.assignEnergySource()` | - | ✅ Implemented | +| POST | `/optimization-units/{unit_id}/policy` | Assign policy to unit | `OptimizationUnitService.assignPolicy()` | - | ✅ Implemented | +| POST | `/optimization-units/{unit_id}/miners` | Assign miners to unit | `OptimizationUnitService.assignMiners()` | - | ✅ Implemented | +| POST | `/optimization-units/{unit_id}/miners/single` | Add single miner to unit | `OptimizationUnitService.addMiner()` | - | ✅ Implemented | +| DELETE | `/optimization-units/{unit_id}/miners/{miner_id}` | Remove miner from unit | `OptimizationUnitService.removeMiner()` | - | ✅ Implemented | +| POST | `/optimization-units/{unit_id}/notifiers` | Assign notifiers to unit | `OptimizationUnitService.assignNotifiers()` | - | ✅ Implemented | +| POST | `/optimization-units/{unit_id}/notifiers/single` | Add single notifier to unit | `OptimizationUnitService.addNotifier()` | - | ✅ Implemented | +| DELETE | `/optimization-units/{unit_id}/notifiers/{notifier_id}` | Remove notifier from unit | `OptimizationUnitService.removeNotifier()` | - | ✅ Implemented | + +**Location in backend:** `core/edge_mining/adapters/domain/optimization_unit/fast_api/router.py` + +--- + +## Summary Statistics + +### By Domain: + +- **Energy Sources**: 6/6 endpoints implemented (100%) ✅ **COMPLETE** +- **Energy Monitors**: 8/8 endpoints implemented (100%) ✅ **COMPLETE** +- **Miners**: 11/11 endpoints implemented (100%) ✅ **COMPLETE** +- **Miner Controllers**: 7/7 endpoints implemented (100%) ✅ **COMPLETE** +- **Policies**: 6/6 endpoints implemented (100%) ✅ **COMPLETE** +- **Policy Rules**: 7/7 endpoints implemented (100%) ✅ **COMPLETE** +- **Decisional Context**: 1/1 endpoints implemented (100%) ✅ **COMPLETE** +- **Forecast Providers**: 8/8 endpoints implemented (100%) ✅ **COMPLETE** +- **Notifiers**: 9/9 endpoints implemented (100%) ✅ **COMPLETE** +- **External Services**: 9/9 endpoints implemented (100%) ✅ **COMPLETE** +- **Rule Engine**: 4/4 endpoints implemented (100%) ✅ **COMPLETE** +- **Optimization Units**: 15/15 endpoints implemented (100%) ✅ **COMPLETE** + +### Overall Progress: +- **Total Endpoints**: 91 +- **Implemented**: 91 (100%) +- **To Implement**: 0 (0%) + +--- + +## Work To Do + +### Phase 8: External Services - New Endpoints ✅ COMPLETED + +Both endpoints have been fully implemented: + +1. ✅ **GET `/external-services/{service_id}/status`** - Get connection status of a specific external service + - Added `ExternalServiceStatus` and `ExternalServiceStatusType` models to `externalService.ts` + - Added `getExternalServiceStatus()` to `ExternalServiceService.ts` + - Added `getServiceStatus()` action and `serviceStatuses` state to `externalServiceStore.ts` + - Added status badge with refresh button to `ExternalServiceRow.vue` + +2. ✅ **GET `/external-services/{service_id}/linked-entities`** - Get all entities linked to a specific external service + - Added `ExternalServiceLinkedEntities` model to `externalService.ts` + - Added `getLinkedEntities()` to `ExternalServiceService.ts` + - Added `getLinkedEntities()` action and `serviceLinkedEntities` state to `externalServiceStore.ts` + - Added linked entities summary text to `ExternalServiceRow.vue` + +### UI Enhancement: Optimization Units - performance_tracker_id + +The `performance_tracker_id` field exists in the backend OptimizationUnit model and frontend TypeScript model, but is **not yet exposed** in the `OptimizationUnitsSettingsView.vue` form UI. This should be added when the performance tracker feature is ready. + +--- + +## Model Fixes Applied + +The following model mismatches between backend and frontend were identified and **fixed**: + +### Fixed: ID type mismatches (5 models) +Backend returns ALL IDs as `str` (UUID strings). These 5 frontend models incorrectly had `id?: number`: + +| Model | File | Fix | +|---|---|---| +| `EnergySource.id` | `energySource.ts` | `number` -> `string` | +| `EnergyMonitor.id` | `energyMonitor.ts` | `number` -> `string` | +| `ForecastProvider.id` | `forecastProvider.ts` | `number` -> `string` | +| `Notifier.id` | `notifier.ts` | `number` -> `string` | +| `ExternalService.id` | `externalService.ts` | `number` -> `string` | + +### Fixed: MinerStatus enum mismatch +- **Backend** (`miner/common.py`): `unknown`, `off`, `on`, `starting`, `stopping`, `error` +- **Frontend** was: `string` with comment `// 'active', 'inactive', 'error'` +- **Fix**: Added proper `MinerStatus` type union: `'unknown' | 'off' | 'on' | 'starting' | 'stopping' | 'error'` +- Also fixed `MinerRowEdit.vue` which compared `status === 'active'` (now compares to `'on'`) + +### Fixed: RuleEngineService.evaluate() - CRITICAL +- **Return type**: Backend returns `bool`, frontend expected `EvaluationResult` object -> Fixed to `Promise` +- **Request type**: Backend expects `{ rules, context, optimization_unit }`, frontend sent generic dict -> Fixed to `RuleEvaluationRequest` +- Removed unused `EvaluationResult` and `EvaluationContext` interfaces +- Updated `ruleEngineStore.ts` accordingly + +### Fixed: AutomationRule.description nullable +- Backend: `Optional[str]` (can be null) +- Frontend was: `description: string` (required) +- Fix: `description?: string` (optional) + +### Fixed: RuleEngineConfig type +- Backend returns: `{ engine_type: RuleEngineType }` +- Frontend was: `{ [key: string]: any }` (generic) +- Fix: `{ engine_type: string }` + +### Fixed: getExternalServices() nullable return +Three services return `Optional[ExternalServiceAdapter]` (can be null): +- `EnergyMonitorService.getExternalServices()` -> return type now `ExternalServiceAdapter | null` +- `ForecastProviderService.getExternalServices()` -> return type now `ExternalServiceAdapter | null` +- (`NotifierService.getExternalServices()` was already correct with `string | null`) + +--- + +## Existing Frontend Implementation + +### Services (in `src/core/services/`): +1. ✅ `MinerService.ts` - **COMPLETE** - Full CRUD + control operations (11/11 endpoints) +2. ✅ `EnergySourceService.ts` - **COMPLETE** - Full CRUD + types (6/6 endpoints) +3. ✅ `EnergyMonitorService.ts` - **COMPLETE** - Full CRUD + types + config schema + external services (8/8 endpoints) +4. ✅ `MinerControllerService.ts` - **COMPLETE** - Full CRUD + types + config schema (7/7 endpoints) +5. ✅ `ForecastProviderService.ts` - **COMPLETE** - Full CRUD + types + config schema + external services (8/8 endpoints) +6. ✅ `PolicyService.ts` - **COMPLETE** - Full CRUD + rules management + check + decisional context (14/14 endpoints) +7. ✅ `NotifierService.ts` - **COMPLETE** - Full CRUD + types + config schema + test + external services (9/9 endpoints) +8. ✅ `ExternalServiceService.ts` - **COMPLETE** - Full CRUD + types + config schema + status + linked-entities (9/9 endpoints) +9. ✅ `RuleEngineService.ts` - **COMPLETE** - Config, info, evaluate, validate (4/4 endpoints) +10. ✅ `OptimizationUnitService.ts` - **COMPLETE** - Full CRUD + enable/disable + assignments (15/15 endpoints) +11. ✅ `BaseService.ts` - Base HTTP service class with GET, POST, PUT, DELETE methods + +### Views/Pages (in `src/views/`): +1. ✅ `DashboardView.vue` - Main dashboard +2. ✅ `settings/MinersSettingsView.vue` - Full CRUD + control operations (start/stop/activate/deactivate) +3. ✅ `settings/EnergySourcesSettingsView.vue` - Full CRUD operations (create, read, update, delete) +4. ✅ `settings/EnergyMonitorSettingsView.vue` - Full CRUD operations with modal edit support +5. ✅ `settings/MinerControllersSettingsView.vue` - Full CRUD operations with modal edit support +6. ✅ `settings/ForecastProvidersSettingsView.vue` - Full CRUD operations with modal edit support +7. ✅ `settings/PoliciesSettingsView.vue` - Full CRUD operations with rules management modal +8. ✅ `settings/NotifiersSettingsView.vue` - Full CRUD operations with test notification feature +9. ✅ `settings/ExternalServicesSettingsView.vue` - Full CRUD operations with status indicator + linked entities display +10. ✅ `settings/OptimizationUnitsSettingsView.vue` - Full CRUD + enable/disable + multi-select assignments + +### Components (in `src/components/`): +1. ✅ `miners/MinerRow.vue` - Display miner row with edit/delete/control buttons +2. ✅ `miners/MinerRowEdit.vue` - Edit miner row inline +3. ✅ `energySources/EnergySourceRow.vue` - Display energy source row with edit/delete buttons +4. ✅ `energySources/EnergySourceRowEdit.vue` - Edit energy source row inline +5. ✅ `energyMonitors/EnergyMonitorRow.vue` - Display energy monitor row with edit/delete buttons +6. ✅ `energyMonitors/EnergyMonitorRowEdit.vue` - Edit energy monitor row (used in modal) +7. ✅ `energyMonitors/EnergyMonitorConfigForm.vue` - Energy monitor config form +8. ✅ `minerControllers/MinerControllerRow.vue` - Display miner controller row with edit/delete buttons +9. ✅ `minerControllers/MinerControllerConfigForm.vue` - Miner controller config form +10. ✅ `forecastProviders/ForecastProviderRow.vue` - Display forecast provider row with edit/delete buttons +11. ✅ `forecastProviders/ForecastProviderConfigForm.vue` - Forecast provider config form +12. ✅ `policies/PolicyRow.vue` - Display policy row with edit/delete/rules/check buttons +13. ✅ `policies/PolicyRuleRow.vue` - Display policy rule row with edit/delete/toggle enabled +14. ✅ `notifiers/NotifierRow.vue` - Display notifier row with edit/delete/test buttons +15. ✅ `notifiers/NotifierConfigForm.vue` - Notifier config form with dynamic schema +16. ✅ `externalServices/ExternalServiceRow.vue` - Display external service row with edit/delete buttons +17. ✅ `externalServices/ExternalServiceConfigForm.vue` - External service config form with dynamic schema +18. ✅ `optimizationUnits/OptimizationUnitRow.vue` - Display optimization unit row with edit/delete/toggle enabled + +--- + +## Notes + +- All services should extend `BaseService` class +- Follow the existing pattern for service methods +- Use TypeScript interfaces/types from `src/core/models/` directory +- Ensure proper error handling and loading states +- Add proper TypeScript types for all API responses +- Consider adding Vuex/Pinia stores for state management where appropriate diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..33895ab --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,5 @@ +# Vue 3 + TypeScript + Vite + +This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..1f6535e --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2848 @@ +{ + "name": "frontend", + "version": "0.1.0-rev3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.1.0-rev3", + "dependencies": { + "@fontsource/plus-jakarta-sans": "^5.2.8", + "@guolao/vue-monaco-editor": "^1.6.0", + "@phosphor-icons/vue": "^2.2.1", + "@tailwindcss/vite": "^4.1.12", + "@vue-flow/background": "^1.3.2", + "@vue-flow/core": "^1.48.1", + "apexcharts": "^5.10.4", + "axios": "^1.11.0", + "pinia": "^3.0.3", + "tailwindcss": "^4.1.12", + "vue": "^3.5.18", + "vue-router": "^4.5.1", + "vue3-apexcharts": "^1.11.1" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.7.0", + "daisyui": "^5.0.50", + "typescript": "~5.8.3", + "vite": "^7.1.2", + "vue-tsc": "^3.0.5" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", + "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fontsource/plus-jakarta-sans": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/plus-jakarta-sans/-/plus-jakarta-sans-5.2.8.tgz", + "integrity": "sha512-P5qE49fqdeD+7DXH1KBxmMPlB17LTz1zvBhFH0tFzfnYTKVJVyb0pR6plh0ZGXxcB+Oayb54FZZw3V42/DawTw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@guolao/vue-monaco-editor": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@guolao/vue-monaco-editor/-/vue-monaco-editor-1.6.0.tgz", + "integrity": "sha512-w2IiJ6eJGGeuIgCK6EKZOAfhHTTUB5aZwslzwGbZ5e89Hb4avx6++GkLTW8p84Sng/arFMjLPPxSBI56cFudyQ==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.6.1", + "vue-demi": "latest" + }, + "peerDependencies": { + "@vue/composition-api": "^1.7.2", + "monaco-editor": ">=0.43.0", + "vue": "^2.6.14 || >=3.0.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@guolao/vue-monaco-editor/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@phosphor-icons/vue": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@phosphor-icons/vue/-/vue-2.2.1.tgz", + "integrity": "sha512-3RNg1utc2Z5RwPKWFkW3eXI/0BfQAwXgtFxPUPeSzi55jGYUq16b+UqcgbKLazWFlwg5R92OCLKjDiJjeiJcnA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "vue": ">=3.2.39" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.29", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz", + "integrity": "sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.48.1.tgz", + "integrity": "sha512-rGmb8qoG/zdmKoYELCBwu7vt+9HxZ7Koos3pD0+sH5fR3u3Wb/jGcpnqxcnWsPEKDUyzeLSqksN8LJtgXjqBYw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.48.1.tgz", + "integrity": "sha512-4e9WtTxrk3gu1DFE+imNJr4WsL13nWbD/Y6wQcyku5qadlKHY3OQ3LJ/INrrjngv2BJIHnIzbqMk1GTAC2P8yQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.48.1.tgz", + "integrity": "sha512-+XjmyChHfc4TSs6WUQGmVf7Hkg8ferMAE2aNYYWjiLzAS/T62uOsdfnqv+GHRjq7rKRnYh4mwWb4Hz7h/alp8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.48.1.tgz", + "integrity": "sha512-upGEY7Ftw8M6BAJyGwnwMw91rSqXTcOKZnnveKrVWsMTF8/k5mleKSuh7D4v4IV1pLxKAk3Tbs0Lo9qYmii5mQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.48.1.tgz", + "integrity": "sha512-P9ViWakdoynYFUOZhqq97vBrhuvRLAbN/p2tAVJvhLb8SvN7rbBnJQcBu8e/rQts42pXGLVhfsAP0k9KXWa3nQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.48.1.tgz", + "integrity": "sha512-VLKIwIpnBya5/saccM8JshpbxfyJt0Dsli0PjXozHwbSVaHTvWXJH1bbCwPXxnMzU4zVEfgD1HpW3VQHomi2AQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.48.1.tgz", + "integrity": "sha512-3zEuZsXfKaw8n/yF7t8N6NNdhyFw3s8xJTqjbTDXlipwrEHo4GtIKcMJr5Ed29leLpB9AugtAQpAHW0jvtKKaQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.48.1.tgz", + "integrity": "sha512-leo9tOIlKrcBmmEypzunV/2w946JeLbTdDlwEZ7OnnsUyelZ72NMnT4B2vsikSgwQifjnJUbdXzuW4ToN1wV+Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.48.1.tgz", + "integrity": "sha512-Vy/WS4z4jEyvnJm+CnPfExIv5sSKqZrUr98h03hpAMbE2aI0aD2wvK6GiSe8Gx2wGp3eD81cYDpLLBqNb2ydwQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.48.1.tgz", + "integrity": "sha512-x5Kzn7XTwIssU9UYqWDB9VpLpfHYuXw5c6bJr4Mzv9kIv242vmJHbI5PJJEnmBYitUIfoMCODDhR7KoZLot2VQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.48.1.tgz", + "integrity": "sha512-yzCaBbwkkWt/EcgJOKDUdUpMHjhiZT/eDktOPWvSRpqrVE04p0Nd6EGV4/g7MARXXeOqstflqsKuXVM3H9wOIQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.48.1.tgz", + "integrity": "sha512-UK0WzWUjMAJccHIeOpPhPcKBqax7QFg47hwZTp6kiMhQHeOYJeaMwzeRZe1q5IiTKsaLnHu9s6toSYVUlZ2QtQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.48.1.tgz", + "integrity": "sha512-3NADEIlt+aCdCbWVZ7D3tBjBX1lHpXxcvrLt/kdXTiBrOds8APTdtk2yRL2GgmnSVeX4YS1JIf0imFujg78vpw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.48.1.tgz", + "integrity": "sha512-euuwm/QTXAMOcyiFCcrx0/S2jGvFlKJ2Iro8rsmYL53dlblp3LkUQVFzEidHhvIPPvcIsxDhl2wkBE+I6YVGzA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.48.1.tgz", + "integrity": "sha512-w8mULUjmPdWLJgmTYJx/W6Qhln1a+yqvgwmGXcQl2vFBkWsKGUBRbtLRuKJUln8Uaimf07zgJNxOhHOvjSQmBQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.48.1.tgz", + "integrity": "sha512-90taWXCWxTbClWuMZD0DKYohY1EovA+W5iytpE89oUPmT5O1HFdf8cuuVIylE6vCbrGdIGv85lVRzTcpTRZ+kA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.48.1.tgz", + "integrity": "sha512-2Gu29SkFh1FfTRuN1GR1afMuND2GKzlORQUP3mNMJbqdndOg7gNsa81JnORctazHRokiDzQ5+MLE5XYmZW5VWg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.48.1.tgz", + "integrity": "sha512-6kQFR1WuAO50bxkIlAVeIYsz3RUx+xymwhTo9j94dJ+kmHe9ly7muH23sdfWduD0BA8pD9/yhonUvAjxGh34jQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.48.1.tgz", + "integrity": "sha512-RUyZZ/mga88lMI3RlXFs4WQ7n3VyU07sPXmMG7/C1NOi8qisUg57Y7LRarqoGoAiopmGmChUhSwfpvQ3H5iGSQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.48.1.tgz", + "integrity": "sha512-8a/caCUN4vkTChxkaIJcMtwIVcBhi4X2PQRoT+yCK3qRYaZ7cURrmJFL5Ux9H9RaMIXj9RuihckdmkBX3zZsgg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz", + "integrity": "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.5.1", + "lightningcss": "1.30.1", + "magic-string": "^0.30.17", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.12" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz", + "integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-x64": "4.1.12", + "@tailwindcss/oxide-freebsd-x64": "4.1.12", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.12", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-x64-musl": "4.1.12", + "@tailwindcss/oxide-wasm32-wasi": "4.1.12", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.12", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.12" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.12.tgz", + "integrity": "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.12.tgz", + "integrity": "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz", + "integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.12.tgz", + "integrity": "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.12.tgz", + "integrity": "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.12.tgz", + "integrity": "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.12.tgz", + "integrity": "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.12.tgz", + "integrity": "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.12.tgz", + "integrity": "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.12.tgz", + "integrity": "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.5", + "@emnapi/runtime": "^1.4.5", + "@emnapi/wasi-threads": "^1.0.4", + "@napi-rs/wasm-runtime": "^0.2.12", + "@tybys/wasm-util": "^0.10.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz", + "integrity": "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.12.tgz", + "integrity": "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.12.tgz", + "integrity": "sha512-4pt0AMFDx7gzIrAOIYgYP0KCBuKWqyW8ayrdiLEjoJTT4pKTjrzG/e4uzWtTLDziC+66R9wbUqZBccJalSE5vQ==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.12", + "@tailwindcss/oxide": "4.1.12", + "tailwindcss": "4.1.12" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz", + "integrity": "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.29" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.23", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.23.tgz", + "integrity": "sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.23" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.23", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.23.tgz", + "integrity": "sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.23", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.23.tgz", + "integrity": "sha512-lAB5zJghWxVPqfcStmAP1ZqQacMpe90UrP5RJ3arDyrhy4aCUQqmxPPLB2PWDKugvylmO41ljK7vZ+t6INMTag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.23", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue-flow/background": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@vue-flow/background/-/background-1.3.2.tgz", + "integrity": "sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==", + "license": "MIT", + "peerDependencies": { + "@vue-flow/core": "^1.23.0", + "vue": "^3.3.0" + } + }, + "node_modules/@vue-flow/core": { + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.48.1.tgz", + "integrity": "sha512-3IxaMBLvWRbznZ4CuK0kVhp4Y4lCDQx9nhi48Swp6PwPw29KNhmiKd2kaBogYeWjGLb/tLjlE9V0s3jEmKCYWw==", + "license": "MIT", + "dependencies": { + "@vueuse/core": "^10.5.0", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.20", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.20.tgz", + "integrity": "sha512-8TWXUyiqFd3GmP4JTX9hbiTFRwYHgVL/vr3cqhr4YQ258+9FADwvj7golk2sWNGHR67QgmCZ8gz80nQcMokhwg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@vue/shared": "3.5.20", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.20", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.20.tgz", + "integrity": "sha512-whB44M59XKjqUEYOMPYU0ijUV0G+4fdrHVKDe32abNdX/kJe1NUEMqsi4cwzXa9kyM9w5S8WqFsrfo1ogtBZGQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.20", + "@vue/shared": "3.5.20" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.20", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.20.tgz", + "integrity": "sha512-SFcxapQc0/feWiSBfkGsa1v4DOrnMAQSYuvDMpEaxbpH5dKbnEM5KobSNSgU+1MbHCl+9ftm7oQWxvwDB6iBfw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@vue/compiler-core": "3.5.20", + "@vue/compiler-dom": "3.5.20", + "@vue/compiler-ssr": "3.5.20", + "@vue/shared": "3.5.20", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.17", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.20", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.20.tgz", + "integrity": "sha512-RSl5XAMc5YFUXpDQi+UQDdVjH9FnEpLDHIALg5J0ITHxkEzJ8uQLlo7CIbjPYqmZtt6w0TsIPbo1izYXwDG7JA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.20", + "@vue/shared": "3.5.20" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.7.tgz", + "integrity": "sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.7", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.7.tgz", + "integrity": "sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.0.6.tgz", + "integrity": "sha512-e2RRzYWm+qGm8apUHW1wA5RQxzNhkqbbKdbKhiDUcmMrNAZGyM8aTiL3UrTqkaFI5s7wJRGGrp4u3jgusuBp2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.23", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^2.0.5", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.20", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.20.tgz", + "integrity": "sha512-hS8l8x4cl1fmZpSQX/NXlqWKARqEsNmfkwOIYqtR2F616NGfsLUm0G6FQBK6uDKUCVyi1YOL8Xmt/RkZcd/jYQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.20" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.20", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.20.tgz", + "integrity": "sha512-vyQRiH5uSZlOa+4I/t4Qw/SsD/gbth0SW2J7oMeVlMFMAmsG1rwDD6ok0VMmjXY3eI0iHNSSOBilEDW98PLRKw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.20", + "@vue/shared": "3.5.20" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.20", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.20.tgz", + "integrity": "sha512-KBHzPld/Djw3im0CQ7tGCpgRedryIn4CcAl047EhFTCCPT2xFf4e8j6WeKLgEEoqPSl9TYqShc3Q6tpWpz/Xgw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.20", + "@vue/runtime-core": "3.5.20", + "@vue/shared": "3.5.20", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.20", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.20.tgz", + "integrity": "sha512-HthAS0lZJDH21HFJBVNTtx+ULcIbJQRpjSVomVjfyPkFSpCwvsPTA+jIzOaUm3Hrqx36ozBHePztQFg6pj5aKg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.20", + "@vue/shared": "3.5.20" + }, + "peerDependencies": { + "vue": "3.5.20" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.20", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.20.tgz", + "integrity": "sha512-SoRGP596KU/ig6TfgkCMbXkr4YJ91n/QSdMuqeP5r3hVIYA3CPHUBCc7Skak0EAKV+5lL4KyIh61VA/pK1CIAA==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.7.0.tgz", + "integrity": "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vueuse/core": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz", + "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz", + "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz", + "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/alien-signals": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-2.0.7.tgz", + "integrity": "sha512-wE7y3jmYeb0+h6mr5BOovuqhFv22O/MV9j5p0ndJsa7z1zJNPGQ4ph5pQk/kTTCWRC3xsA4SmtwmkzQO+7NCNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/apexcharts": { + "version": "5.10.4", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.4.tgz", + "integrity": "sha512-gt0VUqZ2+mr25ScbUcKZgJr96jKYm4vjOcxEWCEh/E5F4dWqhyo3dBhPRvNNnkKiWxkMd2cBwj3ZYH3rK39fkA==", + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", + "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/birpc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.5.0.tgz", + "integrity": "sha512-VSWO/W6nNQdyP520F1mhf+Lc2f8pjGQOtoHHm7Ze8Go1kX7akpVIrtTa0fn+HB0QJEDVacl6aO08YE0PgXfdnQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "license": "MIT", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/daisyui": { + "version": "5.0.50", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.0.50.tgz", + "integrity": "sha512-c1PweK5RI1C76q58FKvbS4jzgyNJSP6CGTQ+KkZYzADdJoERnOxFoeLfDHmQgxLpjEzlYhFMXCeodQNLCC9bow==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/saadeghi/daisyui?sponsor=1" + } + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-what": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/jiti": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.18", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz", + "integrity": "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.3.tgz", + "integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/@vue/devtools-api": { + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.7.tgz", + "integrity": "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.7" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.48.1.tgz", + "integrity": "sha512-jVG20NvbhTYDkGAty2/Yh7HK6/q3DGSRH4o8ALKGArmMuaauM9kLfoMZ+WliPwA5+JHr2lTn3g557FxBV87ifg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.48.1", + "@rollup/rollup-android-arm64": "4.48.1", + "@rollup/rollup-darwin-arm64": "4.48.1", + "@rollup/rollup-darwin-x64": "4.48.1", + "@rollup/rollup-freebsd-arm64": "4.48.1", + "@rollup/rollup-freebsd-x64": "4.48.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.48.1", + "@rollup/rollup-linux-arm-musleabihf": "4.48.1", + "@rollup/rollup-linux-arm64-gnu": "4.48.1", + "@rollup/rollup-linux-arm64-musl": "4.48.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.48.1", + "@rollup/rollup-linux-ppc64-gnu": "4.48.1", + "@rollup/rollup-linux-riscv64-gnu": "4.48.1", + "@rollup/rollup-linux-riscv64-musl": "4.48.1", + "@rollup/rollup-linux-s390x-gnu": "4.48.1", + "@rollup/rollup-linux-x64-gnu": "4.48.1", + "@rollup/rollup-linux-x64-musl": "4.48.1", + "@rollup/rollup-win32-arm64-msvc": "4.48.1", + "@rollup/rollup-win32-ia32-msvc": "4.48.1", + "@rollup/rollup-win32-x64-msvc": "4.48.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, + "node_modules/superjson": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz", + "integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==", + "license": "MIT", + "dependencies": { + "copy-anything": "^3.0.2" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz", + "integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.6.tgz", + "integrity": "sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", + "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.20", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.20.tgz", + "integrity": "sha512-2sBz0x/wis5TkF1XZ2vH25zWq3G1bFEPOfkBcx2ikowmphoQsPH6X0V3mmPCXA2K1N/XGTnifVyDQP4GfDDeQw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.20", + "@vue/compiler-sfc": "3.5.20", + "@vue/runtime-dom": "3.5.20", + "@vue/server-renderer": "3.5.20", + "@vue/shared": "3.5.20" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz", + "integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/vue-tsc": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.0.6.tgz", + "integrity": "sha512-Tbs8Whd43R2e2nxez4WXPvvdjGbW24rOSgRhLOHXzWiT4pcP4G7KeWh0YCn18rF4bVwv7tggLLZ6MJnO6jXPBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.23", + "@vue/language-core": "3.0.6" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vue3-apexcharts": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/vue3-apexcharts/-/vue3-apexcharts-1.11.1.tgz", + "integrity": "sha512-MbN3vg8bMG19wc0Lm1HkeQvODgLm56DgpIxtNUO0xpf/JCzYWVGE4jzXp2JISzy2s3Kul1yOxNQUYsLvKQ5L9g==", + "license": "see LICENSE in LICENSE", + "peerDependencies": { + "apexcharts": ">=5.10.0", + "vue": ">=3.0.0" + }, + "peerDependenciesMeta": { + "apexcharts": { + "optional": false + } + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5eae83d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,34 @@ +{ + "name": "frontend", + "private": true, + "version": "0.1.0-rev3", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@fontsource/plus-jakarta-sans": "^5.2.8", + "@guolao/vue-monaco-editor": "^1.6.0", + "@phosphor-icons/vue": "^2.2.1", + "@tailwindcss/vite": "^4.1.12", + "@vue-flow/background": "^1.3.2", + "@vue-flow/core": "^1.48.1", + "apexcharts": "^5.10.4", + "axios": "^1.11.0", + "pinia": "^3.0.3", + "tailwindcss": "^4.1.12", + "vue": "^3.5.18", + "vue-router": "^4.5.1", + "vue3-apexcharts": "^1.11.1" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.7.0", + "daisyui": "^5.0.50", + "typescript": "~5.8.3", + "vite": "^7.1.2", + "vue-tsc": "^3.0.5" + } +} diff --git a/frontend/public/assets/icons/Vector.svg b/frontend/public/assets/icons/Vector.svg new file mode 100644 index 0000000..fc01ba3 --- /dev/null +++ b/frontend/public/assets/icons/Vector.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 0000000..86fff7a --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1,10 @@ + + + diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..cc53490 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/frontend/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/BottomBar.vue b/frontend/src/components/BottomBar.vue new file mode 100644 index 0000000..e4dde18 --- /dev/null +++ b/frontend/src/components/BottomBar.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/frontend/src/components/ConfigSchemaForm.vue b/frontend/src/components/ConfigSchemaForm.vue new file mode 100644 index 0000000..3e5c55b --- /dev/null +++ b/frontend/src/components/ConfigSchemaForm.vue @@ -0,0 +1,393 @@ + + + diff --git a/frontend/src/components/ConfirmDialog.vue b/frontend/src/components/ConfirmDialog.vue new file mode 100644 index 0000000..bbabba2 --- /dev/null +++ b/frontend/src/components/ConfirmDialog.vue @@ -0,0 +1,60 @@ + + + diff --git a/frontend/src/components/EdgeMiningCard.vue b/frontend/src/components/EdgeMiningCard.vue new file mode 100644 index 0000000..c4e3813 --- /dev/null +++ b/frontend/src/components/EdgeMiningCard.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue new file mode 100644 index 0000000..b58e52b --- /dev/null +++ b/frontend/src/components/HelloWorld.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/frontend/src/components/ResourceId.vue b/frontend/src/components/ResourceId.vue new file mode 100644 index 0000000..32133bd --- /dev/null +++ b/frontend/src/components/ResourceId.vue @@ -0,0 +1,44 @@ + + + diff --git a/frontend/src/components/SidebarMenu.vue b/frontend/src/components/SidebarMenu.vue new file mode 100644 index 0000000..bab1aaa --- /dev/null +++ b/frontend/src/components/SidebarMenu.vue @@ -0,0 +1,343 @@ + + + + diff --git a/frontend/src/components/TopBar.vue b/frontend/src/components/TopBar.vue new file mode 100644 index 0000000..3f32488 --- /dev/null +++ b/frontend/src/components/TopBar.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/frontend/src/components/VectorIcon.vue b/frontend/src/components/VectorIcon.vue new file mode 100644 index 0000000..969675b --- /dev/null +++ b/frontend/src/components/VectorIcon.vue @@ -0,0 +1,38 @@ + + + diff --git a/frontend/src/components/dashboard/ActivityFeed.vue b/frontend/src/components/dashboard/ActivityFeed.vue new file mode 100644 index 0000000..dd4c67b --- /dev/null +++ b/frontend/src/components/dashboard/ActivityFeed.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/frontend/src/components/dashboard/ChartPanel.vue b/frontend/src/components/dashboard/ChartPanel.vue new file mode 100644 index 0000000..4f9a30d --- /dev/null +++ b/frontend/src/components/dashboard/ChartPanel.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/frontend/src/components/dashboard/ForecastChart.vue b/frontend/src/components/dashboard/ForecastChart.vue new file mode 100644 index 0000000..041e230 --- /dev/null +++ b/frontend/src/components/dashboard/ForecastChart.vue @@ -0,0 +1,178 @@ + + + diff --git a/frontend/src/components/dashboard/ForecastSummaryCard.vue b/frontend/src/components/dashboard/ForecastSummaryCard.vue new file mode 100644 index 0000000..f1e49f3 --- /dev/null +++ b/frontend/src/components/dashboard/ForecastSummaryCard.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/frontend/src/components/dashboard/KpiCard.vue b/frontend/src/components/dashboard/KpiCard.vue new file mode 100644 index 0000000..6d3994b --- /dev/null +++ b/frontend/src/components/dashboard/KpiCard.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/frontend/src/components/dashboard/MinerTile.vue b/frontend/src/components/dashboard/MinerTile.vue new file mode 100644 index 0000000..cbb0e79 --- /dev/null +++ b/frontend/src/components/dashboard/MinerTile.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/frontend/src/components/dashboard/RealtimeChart.vue b/frontend/src/components/dashboard/RealtimeChart.vue new file mode 100644 index 0000000..d8681dd --- /dev/null +++ b/frontend/src/components/dashboard/RealtimeChart.vue @@ -0,0 +1,220 @@ + + + diff --git a/frontend/src/components/dashboard/SunCard.vue b/frontend/src/components/dashboard/SunCard.vue new file mode 100644 index 0000000..2015a2e --- /dev/null +++ b/frontend/src/components/dashboard/SunCard.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/frontend/src/components/dashboard/TempFanChart.vue b/frontend/src/components/dashboard/TempFanChart.vue new file mode 100644 index 0000000..8d54760 --- /dev/null +++ b/frontend/src/components/dashboard/TempFanChart.vue @@ -0,0 +1,248 @@ + + + diff --git a/frontend/src/components/energyMonitors/EnergyMonitorCard.vue b/frontend/src/components/energyMonitors/EnergyMonitorCard.vue new file mode 100644 index 0000000..15973ad --- /dev/null +++ b/frontend/src/components/energyMonitors/EnergyMonitorCard.vue @@ -0,0 +1,241 @@ + + + diff --git a/frontend/src/components/energyMonitors/EnergyMonitorConfigForm.vue b/frontend/src/components/energyMonitors/EnergyMonitorConfigForm.vue new file mode 100644 index 0000000..d8aa72c --- /dev/null +++ b/frontend/src/components/energyMonitors/EnergyMonitorConfigForm.vue @@ -0,0 +1,27 @@ + + + + diff --git a/frontend/src/components/energyMonitors/EnergyMonitorFormModal.vue b/frontend/src/components/energyMonitors/EnergyMonitorFormModal.vue new file mode 100644 index 0000000..a620836 --- /dev/null +++ b/frontend/src/components/energyMonitors/EnergyMonitorFormModal.vue @@ -0,0 +1,322 @@ + + + + + diff --git a/frontend/src/components/energySources/EnergySourceCard.vue b/frontend/src/components/energySources/EnergySourceCard.vue new file mode 100644 index 0000000..8283760 --- /dev/null +++ b/frontend/src/components/energySources/EnergySourceCard.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/frontend/src/components/energySources/EnergySourceFormModal.vue b/frontend/src/components/energySources/EnergySourceFormModal.vue new file mode 100644 index 0000000..7b6ac02 --- /dev/null +++ b/frontend/src/components/energySources/EnergySourceFormModal.vue @@ -0,0 +1,397 @@ + + + + + diff --git a/frontend/src/components/externalServices/ExternalServiceCard.vue b/frontend/src/components/externalServices/ExternalServiceCard.vue new file mode 100644 index 0000000..2aca67d --- /dev/null +++ b/frontend/src/components/externalServices/ExternalServiceCard.vue @@ -0,0 +1,368 @@ + + + diff --git a/frontend/src/components/externalServices/ExternalServiceConfigForm.vue b/frontend/src/components/externalServices/ExternalServiceConfigForm.vue new file mode 100644 index 0000000..fdb98cf --- /dev/null +++ b/frontend/src/components/externalServices/ExternalServiceConfigForm.vue @@ -0,0 +1,315 @@ + + + diff --git a/frontend/src/components/externalServices/ExternalServiceFormModal.vue b/frontend/src/components/externalServices/ExternalServiceFormModal.vue new file mode 100644 index 0000000..c8c8a8d --- /dev/null +++ b/frontend/src/components/externalServices/ExternalServiceFormModal.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/frontend/src/components/forecastProviders/ForecastProviderCard.vue b/frontend/src/components/forecastProviders/ForecastProviderCard.vue new file mode 100644 index 0000000..28fb30a --- /dev/null +++ b/frontend/src/components/forecastProviders/ForecastProviderCard.vue @@ -0,0 +1,230 @@ + + + diff --git a/frontend/src/components/forecastProviders/ForecastProviderConfigForm.vue b/frontend/src/components/forecastProviders/ForecastProviderConfigForm.vue new file mode 100644 index 0000000..f414042 --- /dev/null +++ b/frontend/src/components/forecastProviders/ForecastProviderConfigForm.vue @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/components/forecastProviders/ForecastProviderFormModal.vue b/frontend/src/components/forecastProviders/ForecastProviderFormModal.vue new file mode 100644 index 0000000..23cfaab --- /dev/null +++ b/frontend/src/components/forecastProviders/ForecastProviderFormModal.vue @@ -0,0 +1,317 @@ + + + + + diff --git a/frontend/src/components/homeLoads/EnergyLoadForecastProviderCard.vue b/frontend/src/components/homeLoads/EnergyLoadForecastProviderCard.vue new file mode 100644 index 0000000..1a3e781 --- /dev/null +++ b/frontend/src/components/homeLoads/EnergyLoadForecastProviderCard.vue @@ -0,0 +1,237 @@ + + + diff --git a/frontend/src/components/homeLoads/EnergyLoadForecastProviderFormModal.vue b/frontend/src/components/homeLoads/EnergyLoadForecastProviderFormModal.vue new file mode 100644 index 0000000..5d65257 --- /dev/null +++ b/frontend/src/components/homeLoads/EnergyLoadForecastProviderFormModal.vue @@ -0,0 +1,264 @@ + + + diff --git a/frontend/src/components/homeLoads/EnergyLoadHistoryProviderCard.vue b/frontend/src/components/homeLoads/EnergyLoadHistoryProviderCard.vue new file mode 100644 index 0000000..aad9329 --- /dev/null +++ b/frontend/src/components/homeLoads/EnergyLoadHistoryProviderCard.vue @@ -0,0 +1,202 @@ + + + diff --git a/frontend/src/components/homeLoads/EnergyLoadHistoryProviderFormModal.vue b/frontend/src/components/homeLoads/EnergyLoadHistoryProviderFormModal.vue new file mode 100644 index 0000000..848ba9a --- /dev/null +++ b/frontend/src/components/homeLoads/EnergyLoadHistoryProviderFormModal.vue @@ -0,0 +1,265 @@ + + + diff --git a/frontend/src/components/homeLoads/ForecastProviderInfo.vue b/frontend/src/components/homeLoads/ForecastProviderInfo.vue new file mode 100644 index 0000000..f90b41a --- /dev/null +++ b/frontend/src/components/homeLoads/ForecastProviderInfo.vue @@ -0,0 +1,298 @@ + + + diff --git a/frontend/src/components/homeLoads/LoadDeviceCard.vue b/frontend/src/components/homeLoads/LoadDeviceCard.vue new file mode 100644 index 0000000..dcc6168 --- /dev/null +++ b/frontend/src/components/homeLoads/LoadDeviceCard.vue @@ -0,0 +1,243 @@ + + + diff --git a/frontend/src/components/homeLoads/LoadDeviceFormModal.vue b/frontend/src/components/homeLoads/LoadDeviceFormModal.vue new file mode 100644 index 0000000..bf39c65 --- /dev/null +++ b/frontend/src/components/homeLoads/LoadDeviceFormModal.vue @@ -0,0 +1,648 @@ + + + diff --git a/frontend/src/components/homeLoads/LoadDeviceHistoryModal.vue b/frontend/src/components/homeLoads/LoadDeviceHistoryModal.vue new file mode 100644 index 0000000..d700c20 --- /dev/null +++ b/frontend/src/components/homeLoads/LoadDeviceHistoryModal.vue @@ -0,0 +1,600 @@ + + + diff --git a/frontend/src/components/homeLoads/LoadDeviceTable.vue b/frontend/src/components/homeLoads/LoadDeviceTable.vue new file mode 100644 index 0000000..3d7a436 --- /dev/null +++ b/frontend/src/components/homeLoads/LoadDeviceTable.vue @@ -0,0 +1,222 @@ + + + diff --git a/frontend/src/components/homeLoads/LoadDeviceWizardModal.vue b/frontend/src/components/homeLoads/LoadDeviceWizardModal.vue new file mode 100644 index 0000000..37bd64f --- /dev/null +++ b/frontend/src/components/homeLoads/LoadDeviceWizardModal.vue @@ -0,0 +1,568 @@ + + + diff --git a/frontend/src/components/homeLoads/TrainingPanel.vue b/frontend/src/components/homeLoads/TrainingPanel.vue new file mode 100644 index 0000000..b519bd9 --- /dev/null +++ b/frontend/src/components/homeLoads/TrainingPanel.vue @@ -0,0 +1,232 @@ + + + diff --git a/frontend/src/components/icons/DummyMinerIcon.vue b/frontend/src/components/icons/DummyMinerIcon.vue new file mode 100644 index 0000000..fd55895 --- /dev/null +++ b/frontend/src/components/icons/DummyMinerIcon.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/icons/DummySolarIcon.vue b/frontend/src/components/icons/DummySolarIcon.vue new file mode 100644 index 0000000..c2e3f07 --- /dev/null +++ b/frontend/src/components/icons/DummySolarIcon.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/minerControllers/MinerControllerCard.vue b/frontend/src/components/minerControllers/MinerControllerCard.vue new file mode 100644 index 0000000..cf25213 --- /dev/null +++ b/frontend/src/components/minerControllers/MinerControllerCard.vue @@ -0,0 +1,265 @@ + + + + diff --git a/frontend/src/components/minerControllers/MinerControllerConfigForm.vue b/frontend/src/components/minerControllers/MinerControllerConfigForm.vue new file mode 100644 index 0000000..ca3f4ef --- /dev/null +++ b/frontend/src/components/minerControllers/MinerControllerConfigForm.vue @@ -0,0 +1,331 @@ + + + diff --git a/frontend/src/components/minerControllers/MinerControllerFormModal.vue b/frontend/src/components/minerControllers/MinerControllerFormModal.vue new file mode 100644 index 0000000..8594883 --- /dev/null +++ b/frontend/src/components/minerControllers/MinerControllerFormModal.vue @@ -0,0 +1,310 @@ + + + + + diff --git a/frontend/src/components/miners/MinerCard.vue b/frontend/src/components/miners/MinerCard.vue new file mode 100644 index 0000000..1122339 --- /dev/null +++ b/frontend/src/components/miners/MinerCard.vue @@ -0,0 +1,385 @@ + + + + + diff --git a/frontend/src/components/miners/MinerFormModal.vue b/frontend/src/components/miners/MinerFormModal.vue new file mode 100644 index 0000000..fe18c1b --- /dev/null +++ b/frontend/src/components/miners/MinerFormModal.vue @@ -0,0 +1,819 @@ + + + + + diff --git a/frontend/src/components/mining/MinerStateCard.vue b/frontend/src/components/mining/MinerStateCard.vue new file mode 100644 index 0000000..d61f633 --- /dev/null +++ b/frontend/src/components/mining/MinerStateCard.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/frontend/src/components/mining/PayoutCard.vue b/frontend/src/components/mining/PayoutCard.vue new file mode 100644 index 0000000..bf689ce --- /dev/null +++ b/frontend/src/components/mining/PayoutCard.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/frontend/src/components/mining/PoolStatsCard.vue b/frontend/src/components/mining/PoolStatsCard.vue new file mode 100644 index 0000000..e43fc84 --- /dev/null +++ b/frontend/src/components/mining/PoolStatsCard.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/frontend/src/components/notifiers/NotifierCard.vue b/frontend/src/components/notifiers/NotifierCard.vue new file mode 100644 index 0000000..7704f2b --- /dev/null +++ b/frontend/src/components/notifiers/NotifierCard.vue @@ -0,0 +1,226 @@ + + + diff --git a/frontend/src/components/notifiers/NotifierConfigForm.vue b/frontend/src/components/notifiers/NotifierConfigForm.vue new file mode 100644 index 0000000..5a61248 --- /dev/null +++ b/frontend/src/components/notifiers/NotifierConfigForm.vue @@ -0,0 +1,114 @@ + + + diff --git a/frontend/src/components/notifiers/NotifierFormModal.vue b/frontend/src/components/notifiers/NotifierFormModal.vue new file mode 100644 index 0000000..1f10c75 --- /dev/null +++ b/frontend/src/components/notifiers/NotifierFormModal.vue @@ -0,0 +1,317 @@ + + + + + diff --git a/frontend/src/components/optimizationUnits/OptimizationUnitCard.vue b/frontend/src/components/optimizationUnits/OptimizationUnitCard.vue new file mode 100644 index 0000000..158fb8c --- /dev/null +++ b/frontend/src/components/optimizationUnits/OptimizationUnitCard.vue @@ -0,0 +1,349 @@ + + + + + diff --git a/frontend/src/components/optimizationUnits/OptimizationUnitFormModal.vue b/frontend/src/components/optimizationUnits/OptimizationUnitFormModal.vue new file mode 100644 index 0000000..dbe61c6 --- /dev/null +++ b/frontend/src/components/optimizationUnits/OptimizationUnitFormModal.vue @@ -0,0 +1,357 @@ + + + diff --git a/frontend/src/components/performanceTrackers/PerformanceTrackerCard.vue b/frontend/src/components/performanceTrackers/PerformanceTrackerCard.vue new file mode 100644 index 0000000..e8378c2 --- /dev/null +++ b/frontend/src/components/performanceTrackers/PerformanceTrackerCard.vue @@ -0,0 +1,255 @@ + + + diff --git a/frontend/src/components/performanceTrackers/PerformanceTrackerConfigForm.vue b/frontend/src/components/performanceTrackers/PerformanceTrackerConfigForm.vue new file mode 100644 index 0000000..4a2cc95 --- /dev/null +++ b/frontend/src/components/performanceTrackers/PerformanceTrackerConfigForm.vue @@ -0,0 +1,255 @@ + + + diff --git a/frontend/src/components/performanceTrackers/PerformanceTrackerFormModal.vue b/frontend/src/components/performanceTrackers/PerformanceTrackerFormModal.vue new file mode 100644 index 0000000..ef238e8 --- /dev/null +++ b/frontend/src/components/performanceTrackers/PerformanceTrackerFormModal.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/frontend/src/components/policies/JsonEditor.vue b/frontend/src/components/policies/JsonEditor.vue new file mode 100644 index 0000000..8d048f4 --- /dev/null +++ b/frontend/src/components/policies/JsonEditor.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/frontend/src/components/policies/PolicyCard.vue b/frontend/src/components/policies/PolicyCard.vue new file mode 100644 index 0000000..f3399d3 --- /dev/null +++ b/frontend/src/components/policies/PolicyCard.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/frontend/src/components/policies/PolicyFormModal.vue b/frontend/src/components/policies/PolicyFormModal.vue new file mode 100644 index 0000000..aa53079 --- /dev/null +++ b/frontend/src/components/policies/PolicyFormModal.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/frontend/src/components/policies/RuleConditionBuilder.vue b/frontend/src/components/policies/RuleConditionBuilder.vue new file mode 100644 index 0000000..e7baadb --- /dev/null +++ b/frontend/src/components/policies/RuleConditionBuilder.vue @@ -0,0 +1,1159 @@ + + + + + diff --git a/frontend/src/components/policies/RuleConditionGraph.vue b/frontend/src/components/policies/RuleConditionGraph.vue new file mode 100644 index 0000000..b32d863 --- /dev/null +++ b/frontend/src/components/policies/RuleConditionGraph.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/frontend/src/core/composables/useDashboardPolling.ts b/frontend/src/core/composables/useDashboardPolling.ts new file mode 100644 index 0000000..da2b5e1 --- /dev/null +++ b/frontend/src/core/composables/useDashboardPolling.ts @@ -0,0 +1,318 @@ +import { ref, computed, onMounted, onUnmounted } from "vue"; +import { useMinerStore } from "../stores/minerStore"; +import { useOptimizationUnitStore } from "../stores/optimizationUnitStore"; +import { useEnergySourceStore } from "../stores/energySourceStore"; +import { usePolicyStore } from "../stores/policyStore"; +import { useDashboardStore } from "../stores/dashboardStore"; +import { normalizeHashRate } from "../utils/index"; +import { OptimizationUnitService } from "../services/optimizationUnitService"; +import type { DecisionalContext } from "../models/policy"; +import type { ForecastPowerPoint } from "../models/forecast"; + +// Re-export types from the store for backward compatibility +export type { DashboardEvent, TimeSeriesPoint, MinerOnOffEvent } from "../stores/dashboardStore"; + +export function useDashboardPolling(intervalMs = 5000) { + const minerStore = useMinerStore(); + const optimizationUnitStore = useOptimizationUnitStore(); + const energySourceStore = useEnergySourceStore(); + const policyStore = usePolicyStore(); + const dashboardStore = useDashboardStore(); + const ouService = new OptimizationUnitService(); + + const lastUpdated = ref(new Date()); + const isPolling = ref(false); + + // Computed refs that stay reactive to store changes + const hashRateSeries = computed(() => dashboardStore.hashRateSeries); + const powerSeries = computed(() => dashboardStore.powerSeries); + const energyProductionSeries = computed(() => dashboardStore.energyProductionSeries); + const batterySOCSeries = computed(() => dashboardStore.batterySOCSeries); + const batteryPowerSeries = computed(() => dashboardStore.batteryPowerSeries); + const gridPowerSeries = computed(() => dashboardStore.gridPowerSeries); + const consumptionSeries = computed(() => dashboardStore.consumptionSeries); + const maxChipTempSeries = computed(() => dashboardStore.maxChipTempSeries); + const maxBoardTempSeries = computed(() => dashboardStore.maxBoardTempSeries); + const internalFanSpeedSeries = computed(() => dashboardStore.internalFanSpeedSeries); + const externalFanSpeedSeries = computed(() => dashboardStore.externalFanSpeedSeries); + const events = computed(() => dashboardStore.events); + const minerOnOffEvents = computed(() => dashboardStore.minerOnOffEvents); + const latestDecisionalContexts = computed(() => dashboardStore.latestDecisionalContexts); + const forecastPowerPoints = computed(() => dashboardStore.forecastPowerPoints); + + let pollTimer: number | undefined; + let pollInProgress = false; + + function detectMinerChanges() { + for (const miner of minerStore.miners) { + if (!miner.id) continue; + const state = minerStore.minerStates.get(miner.id); + const currentStatus = state?.status ?? 'unknown'; + const prevStatus = dashboardStore.previousMinerStatuses.get(miner.id); + if (prevStatus && prevStatus !== currentStatus) { + const now = Math.floor(Date.now() / 1000); + if (currentStatus === "on" && prevStatus !== "on") { + dashboardStore.addEvent({ + type: "miner_start", + message: `${miner.name} started`, + timestamp: new Date(), + }); + dashboardStore.addMinerOnOffEvent({ + time: now, + minerName: miner.name, + action: "on", + }); + } else if (currentStatus === "off" && prevStatus !== "off") { + dashboardStore.addEvent({ + type: "miner_stop", + message: `${miner.name} stopped`, + timestamp: new Date(), + }); + dashboardStore.addMinerOnOffEvent({ + time: now, + minerName: miner.name, + action: "off", + }); + } else { + dashboardStore.addEvent({ + type: "status_change", + message: `${miner.name}: ${prevStatus} → ${currentStatus}`, + timestamp: new Date(), + }); + } + } + dashboardStore.previousMinerStatuses.set(miner.id, currentStatus); + } + } + + async function refreshMinerStatuses() { + const pollableMiners = minerStore.miners.filter( + (m) => m.id != null && m.active && m.controller_ids?.length + ); + if (pollableMiners.length > 0) { + await Promise.all( + pollableMiners.map((m) => minerStore.getMinerStatus(m.id!.toString())) + ); + } + } + + function recordSnapshot() { + const now = Math.floor(Date.now() / 1000); + + let totalHash = 0; + let totalPower = 0; + let maxChipTemp: number | null = null; + let maxBoardTemp: number | null = null; + let maxInternalFanSpeed: number | null = null; + let latestExternalFanSpeed: number | null = null; + + for (const m of minerStore.miners) { + const state = m.id ? minerStore.minerStates.get(m.id) : undefined; + if (state?.status === "on") { + if (state.hash_rate?.value) { + totalHash += normalizeHashRate(state.hash_rate.value, state.hash_rate.unit || "TH/s"); + } + totalPower += state.power_consumption || 0; + + // Aggregate max chip temperature across all miners + if (state.max_chip_temperature?.value != null) { + maxChipTemp = Math.max(maxChipTemp ?? -Infinity, state.max_chip_temperature.value); + } + // Aggregate max board temperature across all miners + if (state.max_board_temperature?.value != null) { + maxBoardTemp = Math.max(maxBoardTemp ?? -Infinity, state.max_board_temperature.value); + } + // Aggregate max internal fan speed across all miners + if (state.internal_fan_speed?.length) { + for (const fs of state.internal_fan_speed) { + if (fs.value != null) { + maxInternalFanSpeed = Math.max(maxInternalFanSpeed ?? -Infinity, fs.value); + } + } + } + // Latest external fan speed (max across miners) + if (state.external_fan_speed?.value != null) { + latestExternalFanSpeed = Math.max(latestExternalFanSpeed ?? -Infinity, state.external_fan_speed.value); + } + } + } + + dashboardStore.addSeriesPoint("hashRate", { time: now, value: totalHash }); + dashboardStore.addSeriesPoint("power", { time: now, value: totalPower }); + + if (maxChipTemp !== null) { + dashboardStore.addSeriesPoint("maxChipTemp", { time: now, value: maxChipTemp }); + } + if (maxBoardTemp !== null) { + dashboardStore.addSeriesPoint("maxBoardTemp", { time: now, value: maxBoardTemp }); + } + if (maxInternalFanSpeed !== null) { + dashboardStore.addSeriesPoint("internalFanSpeed", { time: now, value: maxInternalFanSpeed }); + } + if (latestExternalFanSpeed !== null) { + dashboardStore.addSeriesPoint("externalFanSpeed", { time: now, value: latestExternalFanSpeed }); + } + } + + async function fetchDecisionalContexts() { + const units = optimizationUnitStore.optimizationUnits.filter((u) => u.id); + if (units.length === 0) return; + + const results = await Promise.allSettled( + units.map((u) => ouService.getDecisionalContext(u.id!)) + ); + + const now = Math.floor(Date.now() / 1000); + let totalProduction = 0; + let totalConsumption = 0; + let batterySOCSum = 0; + let batteryPowerSum = 0; + let batteryCount = 0; + let totalGridPower = 0; + let hasEnergyData = false; + const allForecastPoints: ForecastPowerPoint[] = []; + + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (result.status !== "fulfilled") { + console.warn( + `[Dashboard] Failed to fetch decisional context for unit ${units[i].id}:`, + result.reason + ); + continue; + } + const ctx: DecisionalContext = result.value; + const unitId = units[i].id!; + dashboardStore.latestDecisionalContexts.set(unitId, ctx); + + if (ctx.energy_state) { + hasEnergyData = true; + totalProduction += ctx.energy_state.production ?? 0; + + if (ctx.energy_state.consumption) { + totalConsumption += ctx.energy_state.consumption.current_power ?? 0; + } + + if (ctx.energy_state.battery != null) { + batterySOCSum += ctx.energy_state.battery.state_of_charge ?? 0; + batteryPowerSum += ctx.energy_state.battery.current_power ?? 0; + batteryCount++; + } + + if (ctx.energy_state.grid != null) { + totalGridPower += ctx.energy_state.grid.current_power ?? 0; + } + } + + if (ctx.forecast?.intervals) { + for (const interval of ctx.forecast.intervals) { + if (interval.power_points?.length) { + allForecastPoints.push(...interval.power_points); + } + } + } + } + + if (hasEnergyData) { + dashboardStore.addSeriesPoint("energyProduction", { time: now, value: totalProduction }); + dashboardStore.addSeriesPoint("consumption", { time: now, value: totalConsumption }); + dashboardStore.addSeriesPoint("gridPower", { time: now, value: totalGridPower }); + if (batteryCount > 0) { + dashboardStore.addSeriesPoint("batterySOC", { + time: now, + value: batterySOCSum / batteryCount, + }); + dashboardStore.addSeriesPoint("batteryPower", { + time: now, + value: batteryPowerSum / batteryCount, + }); + } + } + + // Deduplicate and sort forecast points by timestamp + if (allForecastPoints.length > 0) { + const seen = new Set(); + const unique = allForecastPoints.filter((p) => { + if (seen.has(p.timestamp)) return false; + seen.add(p.timestamp); + return true; + }); + unique.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + dashboardStore.forecastPowerPoints = unique; + } + } + + async function poll() { + if (pollInProgress) return; // Prevent overlapping polls + pollInProgress = true; + isPolling.value = true; + try { + await Promise.all([ + refreshMinerStatuses(), + fetchDecisionalContexts(), + ]); + detectMinerChanges(); + recordSnapshot(); + lastUpdated.value = new Date(); + } catch (err) { + console.warn("[Dashboard] polling error:", err); + } finally { + isPolling.value = false; + pollInProgress = false; + } + } + + async function initLoad() { + await Promise.all([ + minerStore.loadMiners(), + optimizationUnitStore.loadOptimizationUnits(), + energySourceStore.loadEnergySources(), + policyStore.loadPolicies(), + ]); + // Initialize previous statuses only if empty (first visit) + if (dashboardStore.previousMinerStatuses.size === 0) { + for (const miner of minerStore.miners) { + if (miner.id) { + const state = minerStore.minerStates.get(miner.id); + dashboardStore.previousMinerStatuses.set(miner.id, state?.status ?? 'unknown'); + } + } + } + await poll(); + } + + onMounted(async () => { + try { + await initLoad(); + } catch (err) { + console.warn("[Dashboard] initial load error:", err); + } + pollTimer = window.setInterval(poll, intervalMs); + }); + + onUnmounted(() => { + if (pollTimer !== undefined) { + clearInterval(pollTimer); + } + }); + + return { + lastUpdated, + isPolling, + events, + hashRateSeries, + powerSeries, + energyProductionSeries, + batterySOCSeries, + batteryPowerSeries, + gridPowerSeries, + consumptionSeries, + maxChipTempSeries, + maxBoardTempSeries, + internalFanSpeedSeries, + externalFanSpeedSeries, + minerOnOffEvents, + latestDecisionalContexts, + forecastPowerPoints, + }; +} diff --git a/frontend/src/core/composables/useLoader.ts b/frontend/src/core/composables/useLoader.ts new file mode 100644 index 0000000..55272f7 --- /dev/null +++ b/frontend/src/core/composables/useLoader.ts @@ -0,0 +1,23 @@ +import { computed, ref } from 'vue' + +export const useLoader = () => { + const loaderSemaphore = ref(0) + + const isLoading = computed(() => loaderSemaphore.value != 0) + + function show() { + loaderSemaphore.value++ + } + + function hide() { + if (loaderSemaphore.value > 0) { + loaderSemaphore.value-- + } + } + + return { + isLoading, + show, + hide + } +} diff --git a/frontend/src/core/composables/useWindowSize.ts b/frontend/src/core/composables/useWindowSize.ts new file mode 100644 index 0000000..18fa0e8 --- /dev/null +++ b/frontend/src/core/composables/useWindowSize.ts @@ -0,0 +1,16 @@ +import { ref, onMounted, onUnmounted } from "vue"; + +export function useWindowSize() { + const width = ref(window.innerWidth); + const height = ref(window.innerHeight); + + function update() { + width.value = window.innerWidth; + height.value = window.innerHeight; + } + + onMounted(() => window.addEventListener("resize", update)); + onUnmounted(() => window.removeEventListener("resize", update)); + + return { width, height }; +} diff --git a/frontend/src/core/extensions/promise.ts b/frontend/src/core/extensions/promise.ts new file mode 100644 index 0000000..f2ffdb5 --- /dev/null +++ b/frontend/src/core/extensions/promise.ts @@ -0,0 +1,78 @@ +import { type AxiosResponse } from "axios"; +import { useAppStore } from "../stores/appStore"; + +/** + * The following lines extend the browser native Promise class to enhance the handling of the + * spinloader AKA busy indicator or the display of error messages + */ +declare global { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface Promise { + addLoadSpinner(this: Promise): Promise; + getData(this: Promise>): Promise; + catchErrorWithToast(this: Promise): Promise; + showToasts( + this: Promise, + successMsg: string, + errorMsg: string + ): Promise; + } +} + +/** + * As soon as this method is invoked, it retrieves the appStore to show the spinloader and + * hides it when the promise is completed + */ +Promise.prototype.addLoadSpinner = function (this: Promise): Promise { + const appStore = useAppStore(); + const promise = this; + appStore.loader.show(); + return promise.finally(() => appStore.loader.hide()); +}; + +/** + * Extrapolates the data property out of the axios calls to make them less redundant + */ +Promise.prototype.getData = function ( + this: Promise> +): Promise { + return this.then((res) => res.data); +}; + +/** + * Called on an Promise, registers a catch callback that tries to get infos from the + * given parameter and displays it as a toast notification + */ +Promise.prototype.catchErrorWithToast = function ( + this: Promise +): Promise { + const appStore = useAppStore(); + return this.catch((err) => { + let message = "Unknown error"; + if (err?.response?.data) { + message = err.response.data; + } else if (err?.message) { + message = err.message; + } + appStore.showErrorToast(message); + }); +}; + +/** + * Called on an Promise, registers a then and catch callback. + * The first one will show a success Toast Notification upon completion. + * The latter will show an error Toast Notification upon failure. + */ +Promise.prototype.showToasts = function ( + this: Promise, + successMsg: string, + errorMsg: string +): Promise { + const appStore = useAppStore(); + + return this.then(() => { + appStore.showSuccessToast(successMsg); + }).catch((err) => { + appStore.showErrorToast(errorMsg, err); + }); +}; diff --git a/frontend/src/core/models/energyLoadForecastProvider.ts b/frontend/src/core/models/energyLoadForecastProvider.ts new file mode 100644 index 0000000..122be88 --- /dev/null +++ b/frontend/src/core/models/energyLoadForecastProvider.ts @@ -0,0 +1,53 @@ +export const EnergyLoadForecastProviderAdapter = { + DUMMY: "dummy", + NAIVE_LAST_HOUR: "naive_last_hour", + NAIVE_PERSISTENCE: "naive_persistence", + SEASONAL_BASELINE: "seasonal_baseline", + SKFORECAST: "skforecast", + STATSMODELS: "statsmodels", + TYPICAL_PROFILE: "typical_profile", + XGBOOST: "xgboost", +} as const; + +export type EnergyLoadForecastProviderAdapter = typeof EnergyLoadForecastProviderAdapter[keyof typeof EnergyLoadForecastProviderAdapter]; + +export interface EnergyLoadForecastProviderConfig { + [key: string]: any; +} + +export interface EnergyLoadForecastProvider { + id?: string; + name: string; + adapter_type: EnergyLoadForecastProviderAdapter; + config?: EnergyLoadForecastProviderConfig; + external_service_id?: string; + min_required_history_hours?: number; +} + +export interface ConfigSchemaProperty { + type?: string; + title?: string; + description?: string; + default?: any; + $ref?: string; + enum?: any[]; + properties?: { + [key: string]: ConfigSchemaProperty; + }; + minimum?: number; + maximum?: number; + required?: string[]; +} + +export interface ConfigSchema { + title: string; + description: string; + type: string; + properties: { + [key: string]: ConfigSchemaProperty; + }; + required?: string[]; + $defs?: { + [key: string]: ConfigSchemaProperty; + }; +} diff --git a/frontend/src/core/models/energyLoadHistoryProvider.ts b/frontend/src/core/models/energyLoadHistoryProvider.ts new file mode 100644 index 0000000..e831c26 --- /dev/null +++ b/frontend/src/core/models/energyLoadHistoryProvider.ts @@ -0,0 +1,18 @@ +export const EnergyLoadHistoryProviderAdapter = { + DUMMY: "dummy", + HOME_ASSISTANT_API: "home_assistant_api", +} as const; + +export type EnergyLoadHistoryProviderAdapter = typeof EnergyLoadHistoryProviderAdapter[keyof typeof EnergyLoadHistoryProviderAdapter]; + +export interface EnergyLoadHistoryProviderConfig { + [key: string]: any; +} + +export interface EnergyLoadHistoryProvider { + id?: string; + name: string; + adapter_type: EnergyLoadHistoryProviderAdapter; + config?: EnergyLoadHistoryProviderConfig; + external_service_id?: string; +} diff --git a/frontend/src/core/models/energyMonitor.ts b/frontend/src/core/models/energyMonitor.ts new file mode 100644 index 0000000..24221f3 --- /dev/null +++ b/frontend/src/core/models/energyMonitor.ts @@ -0,0 +1,39 @@ +export const EnergyMonitorAdapter = { + DUMMY_SOLAR: "dummy_solar", + HOME_ASSISTANT_API: "home_assistant_api", + HOME_ASSISTANT_MQTT: "home_assistant_mqtt" +} as const; + +export type EnergyMonitorAdapter = typeof EnergyMonitorAdapter[keyof typeof EnergyMonitorAdapter]; + +export interface EnergyMonitorConfig { + [key: string]: any; +} + +export interface EnergyMonitor { + id?: string; + name: string; + adapter_type: EnergyMonitorAdapter; + config?: EnergyMonitorConfig; + external_service_id?: string; +} + +export interface ConfigSchemaProperty { + type: string; + title: string; + description?: string; + default?: any; +} + +export interface ConfigSchema { + title: string; + description: string; + type: string; + properties: { + [key: string]: ConfigSchemaProperty; + }; + required?: string[]; + $defs?: { + [key: string]: ConfigSchemaProperty; + }; +} diff --git a/frontend/src/core/models/energySource.ts b/frontend/src/core/models/energySource.ts new file mode 100644 index 0000000..fc9745e --- /dev/null +++ b/frontend/src/core/models/energySource.ts @@ -0,0 +1,88 @@ +export const EnergySourceType = { + SOLAR: "solar", + WIND: "wind", + GRID: "grid", + HYDROELECTRIC: "hydroelectric", + OTHER: "other", +} as const; + +export type EnergySourceType = typeof EnergySourceType[keyof typeof EnergySourceType]; + +export interface StorageSchema { + nominal_capacity: number; +} + +export interface GridSchema { + contracted_power: number; +} + +export interface EnergySource { + id?: string; + name: string; + type: EnergySourceType; + nominal_power_max?: number; + storage?: StorageSchema; + grid?: GridSchema; + external_source?: number; + energy_monitor_id?: string; + forecast_provider_id?: string; +} + +// Battery State with computed fields +export interface BatteryState { + state_of_charge: number; // 0-100 + remaining_capacity?: number; // WattHours + current_power: number; // Watts (positive=charging, negative=discharging) + timestamp: string; // ISO datetime + readonly charging_power?: number; // Computed: max(current_power, 0) + readonly discharging_power?: number; // Computed: max(-current_power, 0) +} + +// Grid State with computed fields +export interface GridState { + current_power: number; // Watts (positive=importing, negative=exporting) + timestamp: string; // ISO datetime + readonly importing_power?: number; // Computed: max(current_power, 0) + readonly exporting_power?: number; // Computed: max(-current_power, 0) +} + +// Load State +export interface LoadState { + current_power: number; // Watts + timestamp: string; // ISO datetime +} + +// Energy State Snapshot +export interface EnergyStateSnapshot { + production: number; // Watts + consumption: LoadState; + battery?: BatteryState; + grid?: GridState; + external_source?: number; // Watts + timestamp: string; // ISO datetime +} + +// Factory functions for computed fields +export function createBatteryState(data: Omit): BatteryState { + return { + ...data, + get charging_power() { + return Math.max(data.current_power, 0); + }, + get discharging_power() { + return Math.abs(Math.min(data.current_power, 0)); + }, + }; +} + +export function createGridState(data: Omit): GridState { + return { + ...data, + get importing_power() { + return Math.max(data.current_power, 0); + }, + get exporting_power() { + return Math.abs(Math.min(data.current_power, 0)); + }, + }; +} diff --git a/frontend/src/core/models/externalService.ts b/frontend/src/core/models/externalService.ts new file mode 100644 index 0000000..307bc66 --- /dev/null +++ b/frontend/src/core/models/externalService.ts @@ -0,0 +1,55 @@ +import type { EnergyMonitor } from "./energyMonitor"; +import type { ForecastProvider } from "./forecastProvider"; +import type { MinerController } from "./minerController"; +import type { Notifier } from "./notifier"; + +export type ExternalServiceAdapter = string; + +export interface ExternalServiceConfig { + [key: string]: any; +} + +export interface ExternalService { + id?: string; + name: string; + adapter_type: ExternalServiceAdapter; + config?: ExternalServiceConfig; +} + +export type ExternalServiceStatusType = 'connected' | 'disconnected' | 'unauthorized'; + +export interface ExternalServiceStatus { + name: string; + status: ExternalServiceStatusType; + last_check: string; // ISO 8601 datetime string + error_message?: string; +} + +export interface ExternalServiceLinkedEntities { + miner_controllers: MinerController[]; + energy_monitors: EnergyMonitor[]; + forecast_providers: ForecastProvider[]; + home_forecast_providers: ForecastProvider[]; + notifiers: Notifier[]; +} + +export interface ConfigSchemaProperty { + type: string; + title: string; + description?: string; + default?: any; +} + +export interface ConfigSchema { + title: string; + description: string; + type: string; + properties: { + [key: string]: ConfigSchemaProperty; + }; + required?: string[]; + $defs?: { + [key: string]: ConfigSchemaProperty; + }; +} + diff --git a/frontend/src/core/models/forecast.ts b/frontend/src/core/models/forecast.ts new file mode 100644 index 0000000..031216e --- /dev/null +++ b/frontend/src/core/models/forecast.ts @@ -0,0 +1,165 @@ +// Forecast Power Point +export interface ForecastPowerPoint { + timestamp: string; // ISO datetime + power: number; // Watts +} + +// Forecast Interval with computed fields +export interface ForecastInterval { + start: string; // ISO datetime + end: string; // ISO datetime + energy?: number; // WattHours + energy_remaining?: number; // WattHours + power_points: ForecastPowerPoint[]; + readonly duration?: number; // Computed: duration in seconds + readonly avg_power?: number; // Computed: average power in Watts +} + +// Sun information with computed fields +export interface Sun { + dawn: string; // ISO datetime + sunrise: string; // ISO datetime + noon: string; // ISO datetime + midnight: string; // ISO datetime + sunset: string; // ISO datetime + dusk: string; // ISO datetime + daylight: number; // seconds + night: number; // seconds + twilight: number; // seconds + azimuth?: number; // degrees + zenith?: number; // degrees + elevation?: number; // degrees + readonly time_before_sunrise?: number; // Computed: seconds until sunrise + readonly time_after_sunrise?: number; // Computed: seconds since sunrise + readonly time_before_sunset?: number; // Computed: seconds until sunset + readonly time_after_sunset?: number; // Computed: seconds since sunset +} + +// Forecast with computed fields +export interface Forecast { + id: string; // UUID + timestamp: string; // ISO datetime + intervals: ForecastInterval[]; + readonly next_hour_power?: number; // Computed: power for next hour (Watts) + readonly avg_next_4_hours_power?: number; // Computed: average power for next 4 hours (Watts) + readonly next_hour_energy?: number; // Computed: energy for next hour (WattHours) +} + +// Factory functions for computed fields +export function createForecastInterval(data: Omit): ForecastInterval { + return { + ...data, + get duration() { + const start = new Date(data.start).getTime(); + const end = new Date(data.end).getTime(); + return (end - start) / 1000; // seconds + }, + get avg_power() { + if (data.power_points.length === 0) return 0; + const totalPower = data.power_points.reduce((sum, point) => sum + point.power, 0); + return totalPower / data.power_points.length; + }, + }; +} + +export function createSun(data: Omit): Sun { + return { + ...data, + get time_before_sunrise() { + const now = Date.now(); + const sunrise = new Date(data.sunrise).getTime(); + const diff = (sunrise - now) / 1000; + return diff > 0 ? diff : undefined; + }, + get time_after_sunrise() { + const now = Date.now(); + const sunrise = new Date(data.sunrise).getTime(); + return Math.max(0, (now - sunrise) / 1000); + }, + get time_before_sunset() { + const now = Date.now(); + const sunset = new Date(data.sunset).getTime(); + const diff = (sunset - now) / 1000; + return diff > 0 ? diff : undefined; + }, + get time_after_sunset() { + const now = Date.now(); + const sunset = new Date(data.sunset).getTime(); + return Math.max(0, (now - sunset) / 1000); + }, + }; +} + +export function createForecast(data: Omit): Forecast { + return { + ...data, + get next_hour_power() { + if (data.intervals.length === 0) return undefined; + const now = Date.now(); + const oneHourLater = now + 3600000; // 1 hour in milliseconds + + // Find intervals within the next hour + const relevantIntervals = data.intervals.filter(interval => { + const start = new Date(interval.start).getTime(); + const end = new Date(interval.end).getTime(); + return start < oneHourLater && end > now; + }); + + if (relevantIntervals.length === 0) return undefined; + + // Calculate weighted average + let totalPower = 0; + let totalDuration = 0; + + for (const interval of relevantIntervals) { + const start = Math.max(now, new Date(interval.start).getTime()); + const end = Math.min(oneHourLater, new Date(interval.end).getTime()); + const duration = (end - start) / 1000; // seconds + + if (interval.power_points.length > 0) { + const avgPower = interval.power_points.reduce((sum, p) => sum + p.power, 0) / interval.power_points.length; + totalPower += avgPower * duration; + totalDuration += duration; + } + } + + return totalDuration > 0 ? totalPower / totalDuration : undefined; + }, + get avg_next_4_hours_power() { + if (data.intervals.length === 0) return 0; + const now = Date.now(); + const fourHoursLater = now + 14400000; // 4 hours in milliseconds + + // Find intervals within the next 4 hours + const relevantIntervals = data.intervals.filter(interval => { + const start = new Date(interval.start).getTime(); + const end = new Date(interval.end).getTime(); + return start < fourHoursLater && end > now; + }); + + if (relevantIntervals.length === 0) return 0; + + // Calculate weighted average + let totalPower = 0; + let totalDuration = 0; + + for (const interval of relevantIntervals) { + const start = Math.max(now, new Date(interval.start).getTime()); + const end = Math.min(fourHoursLater, new Date(interval.end).getTime()); + const duration = (end - start) / 1000; // seconds + + if (interval.power_points.length > 0) { + const avgPower = interval.power_points.reduce((sum, p) => sum + p.power, 0) / interval.power_points.length; + totalPower += avgPower * duration; + totalDuration += duration; + } + } + + return totalDuration > 0 ? totalPower / totalDuration : 0; + }, + get next_hour_energy() { + const nextHourPower = this.next_hour_power; + return nextHourPower !== undefined ? nextHourPower : 0; // Wh = W (for 1 hour) + }, + }; +} diff --git a/frontend/src/core/models/forecastProvider.ts b/frontend/src/core/models/forecastProvider.ts new file mode 100644 index 0000000..e3214d1 --- /dev/null +++ b/frontend/src/core/models/forecastProvider.ts @@ -0,0 +1,46 @@ +export const ForecastProviderAdapter = { + DUMMY_SOLAR: "dummy_solar", + HOME_ASSISTANT_API: "home_assistant_api" +} as const; + +export type ForecastProviderAdapter = typeof ForecastProviderAdapter[keyof typeof ForecastProviderAdapter]; + +export interface ForecastProviderConfig { + [key: string]: any; +} + +export interface ForecastProvider { + id?: string; + name: string; + adapter_type: ForecastProviderAdapter; + config?: ForecastProviderConfig; + external_service_id?: string; +} + +export interface ConfigSchemaProperty { + type?: string; + title?: string; + description?: string; + default?: any; + $ref?: string; + enum?: any[]; + properties?: { + [key: string]: ConfigSchemaProperty; + }; + minimum?: number; + maximum?: number; + required?: string[]; +} + +export interface ConfigSchema { + title: string; + description: string; + type: string; + properties: { + [key: string]: ConfigSchemaProperty; + }; + required?: string[]; + $defs?: { + [key: string]: ConfigSchemaProperty; + }; +} diff --git a/frontend/src/core/models/homeLoad.ts b/frontend/src/core/models/homeLoad.ts new file mode 100644 index 0000000..fcde20e --- /dev/null +++ b/frontend/src/core/models/homeLoad.ts @@ -0,0 +1,139 @@ +// Consumption Interval (similar to ForecastInterval but for home load) +export interface ConsumptionInterval { + start: string; // ISO datetime + end: string; // ISO datetime + energy?: number; // WattHours + energy_remaining?: number; // WattHours + power_points: ConsumptionPowerPoint[]; + readonly duration?: number; // Computed: duration in seconds + readonly avg_power?: number; // Computed: average power in Watts +} + +// Consumption Power Point +export interface ConsumptionPowerPoint { + timestamp: string; // ISO datetime + power: number; // Watts +} + +// Consumption Forecast +export interface ConsumptionForecast { + id: string; // UUID + timestamp: string; // ISO datetime + intervals: ConsumptionInterval[]; + readonly next_hour_consumption?: number; // Computed: consumption for next hour (Watts) + readonly avg_next_4_hours_consumption?: number; // Computed: average consumption for next 4 hours (Watts) + readonly next_hour_energy?: number; // Computed: energy for next hour (WattHours) +} + +// ---------- Backend-aligned types (from DecisionalContext.home_load) ---------- + +// Re-export from loadTraining (same structure used by both) +export type { HomeLoadEnergyInterval, LoadEnergyConsumption } from "./loadTraining"; +import type { LoadEnergyConsumption } from "./loadTraining"; + +/** Per-device consumption breakdown */ +export interface LoadDeviceConsumption { + device_id: string; + device_name: string; + device_category: string; + history: LoadEnergyConsumption; + forecast: LoadEnergyConsumption; +} + +/** Top-level home_load field in DecisionalContext */ +export interface HomeLoadsConsumption { + per_device: LoadDeviceConsumption[]; + total_history: LoadEnergyConsumption; + total_forecast: LoadEnergyConsumption; +} + +// Factory functions for computed fields +export function createConsumptionInterval(data: Omit): ConsumptionInterval { + return { + ...data, + get duration() { + const start = new Date(data.start).getTime(); + const end = new Date(data.end).getTime(); + return (end - start) / 1000; // seconds + }, + get avg_power() { + if (data.power_points.length === 0) return 0; + const totalPower = data.power_points.reduce((sum, point) => sum + point.power, 0); + return totalPower / data.power_points.length; + }, + }; +} + +export function createConsumptionForecast(data: Omit): ConsumptionForecast { + return { + ...data, + get next_hour_consumption() { + if (data.intervals.length === 0) return undefined; + const now = Date.now(); + const oneHourLater = now + 3600000; // 1 hour in milliseconds + + // Find intervals within the next hour + const relevantIntervals = data.intervals.filter(interval => { + const start = new Date(interval.start).getTime(); + const end = new Date(interval.end).getTime(); + return start < oneHourLater && end > now; + }); + + if (relevantIntervals.length === 0) return undefined; + + // Calculate weighted average + let totalPower = 0; + let totalDuration = 0; + + for (const interval of relevantIntervals) { + const start = Math.max(now, new Date(interval.start).getTime()); + const end = Math.min(oneHourLater, new Date(interval.end).getTime()); + const duration = (end - start) / 1000; // seconds + + if (interval.power_points.length > 0) { + const avgPower = interval.power_points.reduce((sum, p) => sum + p.power, 0) / interval.power_points.length; + totalPower += avgPower * duration; + totalDuration += duration; + } + } + + return totalDuration > 0 ? totalPower / totalDuration : undefined; + }, + get avg_next_4_hours_consumption() { + if (data.intervals.length === 0) return 0; + const now = Date.now(); + const fourHoursLater = now + 14400000; // 4 hours in milliseconds + + // Find intervals within the next 4 hours + const relevantIntervals = data.intervals.filter(interval => { + const start = new Date(interval.start).getTime(); + const end = new Date(interval.end).getTime(); + return start < fourHoursLater && end > now; + }); + + if (relevantIntervals.length === 0) return 0; + + // Calculate weighted average + let totalPower = 0; + let totalDuration = 0; + + for (const interval of relevantIntervals) { + const start = Math.max(now, new Date(interval.start).getTime()); + const end = Math.min(fourHoursLater, new Date(interval.end).getTime()); + const duration = (end - start) / 1000; // seconds + + if (interval.power_points.length > 0) { + const avgPower = interval.power_points.reduce((sum, p) => sum + p.power, 0) / interval.power_points.length; + totalPower += avgPower * duration; + totalDuration += duration; + } + } + + return totalDuration > 0 ? totalPower / totalDuration : 0; + }, + get next_hour_energy() { + const nextHourConsumption = this.next_hour_consumption; + return nextHourConsumption !== undefined ? nextHourConsumption : 0; // Wh = W (for 1 hour) + }, + }; +} diff --git a/frontend/src/core/models/homeLoadsProfile.ts b/frontend/src/core/models/homeLoadsProfile.ts new file mode 100644 index 0000000..006ce49 --- /dev/null +++ b/frontend/src/core/models/homeLoadsProfile.ts @@ -0,0 +1,39 @@ +export const LoadDeviceCategory = { + CONTROLLABLE: "controllable", + CONTINUOUS: "continuous", + SEASONAL: "seasonal", + OCCASIONAL: "occasional", +} as const; + +export type LoadDeviceCategory = typeof LoadDeviceCategory[keyof typeof LoadDeviceCategory]; + +export interface LoadDevice { + id?: string; + name: string; + category: LoadDeviceCategory; + enabled: boolean; + energy_load_forecast_provider_id?: string; + energy_load_history_provider_id?: string; +} + +export interface LoadDeviceCreate { + name: string; + category: LoadDeviceCategory; + enabled: boolean; + energy_load_forecast_provider_id?: string; + energy_load_history_provider_id?: string; +} + +export interface LoadDeviceUpdate { + name: string; + category: LoadDeviceCategory; + enabled: boolean; + energy_load_forecast_provider_id?: string; + energy_load_history_provider_id?: string; +} + +export interface HomeLoadsProfile { + id?: string; + name: string; + devices: LoadDevice[]; +} diff --git a/frontend/src/core/models/loadTraining.ts b/frontend/src/core/models/loadTraining.ts new file mode 100644 index 0000000..b32f209 --- /dev/null +++ b/frontend/src/core/models/loadTraining.ts @@ -0,0 +1,31 @@ +export interface HomeLoadPowerPoint { + timestamp: string; + power: number; +} + +export interface HomeLoadEnergyInterval { + start: string; + end: string; + energy: number | null; + avg_power: number | null; +} + +export interface LoadEnergyConsumption { + timestamp: string; + intervals: HomeLoadEnergyInterval[]; +} + +export interface LoadConsumptionModel { + id: string; + device_id?: string; + adapter_type: string; + trained_at?: string; + mae?: number; + rmse?: number; + samples_used: number; + is_active: boolean; + tuning_params?: Record; + backtest_mae?: number; + backtest_rmse?: number; + backtest_folds: number; +} diff --git a/frontend/src/core/models/miner.ts b/frontend/src/core/models/miner.ts new file mode 100644 index 0000000..2a0b39c --- /dev/null +++ b/frontend/src/core/models/miner.ts @@ -0,0 +1,123 @@ +// Hash Rate +export interface HashRate { + value: number; + unit: string; // e.g., 'TH/s', 'GH/s' +} + +export type MinerStatus = 'unknown' | 'off' | 'on' | 'starting' | 'stopping' | 'error'; + +// Feature types matching backend MinerFeatureType enum +export const MinerFeatureType = { + // Monitoring (read-only) + HASHRATE_MONITORING: 'hashrate_monitoring', + POWER_MONITORING: 'power_monitoring', + STATUS_MONITORING: 'status_monitoring', + HASHBOARD_MONITORING: 'hashboard_monitoring', + INLET_TEMPERATURE_MONITORING: 'inlet_temperature_monitoring', + OUTLET_TEMPERATURE_MONITORING: 'outlet_temperature_monitoring', + FAN_SPEED_INTERNAL_MONITORING: 'fan_speed_internal_monitoring', + FAN_SPEED_EXTERNAL_MONITORING: 'fan_speed_external_monitoring', + OPERATIONAL_MONITORING: 'operational_monitoring', + // Control (write) + MINING_CONTROL: 'mining_control', + POWER_CONTROL: 'power_control', + INTERNAL_FAN_CONTROL: 'internal_fan_control', + EXTERNAL_FAN_CONTROL: 'external_fan_control', + // Info + MAX_POWER_DETECTION: 'max_power_detection', + MAX_HASHRATE_DETECTION: 'max_hashrate_detection', + DEVICE_INFO_DETECTION: 'device_info_detection', +} as const; + +export type MinerFeatureType = typeof MinerFeatureType[keyof typeof MinerFeatureType]; + +export interface MinerFeature { + feature_type: MinerFeatureType; + controller_id: string; + priority: number; + enabled: boolean; +} + +export interface Miner { + id?: string; + name: string; + model?: string; + hash_rate_max?: HashRate; + power_consumption_max?: number; + active: boolean; + features?: MinerFeature[]; + controller_ids?: string[]; +} + +// Temperature value object +export interface Temperature { + value: number; + unit: string; // e.g., '°C' +} + +// Fan speed value object +export interface FanSpeed { + value: number; + unit: string; // e.g., 'RPM' +} + +// Voltage value object +export interface Voltage { + value: number; + unit: string; // e.g., 'V' +} + +// Frequency value object +export interface Frequency { + value: number; + unit: string; // e.g., 'MHz' +} + +// Hashboard snapshot +export interface HashboardSnapshot { + index: number; + chip_temperature?: Temperature; + board_temperature?: Temperature; + voltage?: Voltage; + frequency?: Frequency; + hash_rate?: HashRate; + nominal_hash_rate?: HashRate; + hash_rate_error?: HashRate; +} + +// Miner device information (from DeviceInfoPort) +export interface MinerInfo { + model?: string; + serial_number?: string; + firmware_type?: string; + firmware_version?: string; + mac_address?: string; + hostname?: string; + hashboard_count?: number; + chip_count?: number; + fan_count?: number; +} + +// Miner limits (max power and hash rate from detection ports) +export interface MinerLimit { + max_power?: number; + max_hash_rate?: HashRate; +} + +// Runtime operational state +export interface MinerStateSnapshot { + status: MinerStatus; + hash_rate?: HashRate; + power_consumption?: number; + inlet_temperature?: Temperature; + outlet_temperature?: Temperature; + internal_fan_speed: FanSpeed[]; + external_fan_speed?: FanSpeed; + hashboards: HashboardSnapshot[]; + blocks_found?: number; + system_uptime?: number; + max_chip_temperature?: Temperature; + max_board_temperature?: Temperature; + avg_chip_temperature?: Temperature; + avg_board_temperature?: Temperature; +} diff --git a/frontend/src/core/models/minerController.ts b/frontend/src/core/models/minerController.ts new file mode 100644 index 0000000..d967699 --- /dev/null +++ b/frontend/src/core/models/minerController.ts @@ -0,0 +1,47 @@ +export const MinerControllerAdapter = { + DUMMY: "dummy", + GENERIC_SOCKET_HOME_ASSISTANT_API: "generic_socket_home_assistant_api", + PYASIC: "pyasic" +} as const; + +export type MinerControllerAdapter = typeof MinerControllerAdapter[keyof typeof MinerControllerAdapter]; + +export interface MinerControllerConfig { + [key: string]: any; +} + +export interface MinerController { + id?: string; + name: string; + adapter_type: MinerControllerAdapter; + config?: MinerControllerConfig; + external_service_id?: string; +} + +export interface ConfigSchemaProperty { + type?: string; + title?: string; + description?: string; + default?: any; + $ref?: string; + enum?: any[]; + properties?: { + [key: string]: ConfigSchemaProperty; + }; + minimum?: number; + maximum?: number; + required?: string[]; +} + +export interface ConfigSchema { + title: string; + description: string; + type: string; + properties: { + [key: string]: ConfigSchemaProperty; + }; + required?: string[]; + $defs?: { + [key: string]: ConfigSchemaProperty; + }; +} diff --git a/frontend/src/core/models/notifier.ts b/frontend/src/core/models/notifier.ts new file mode 100644 index 0000000..30c860e --- /dev/null +++ b/frontend/src/core/models/notifier.ts @@ -0,0 +1,35 @@ +export type NotifierAdapter = string; + +export interface NotifierConfig { + [key: string]: any; +} + +export interface Notifier { + id?: string; + name: string; + adapter_type: NotifierAdapter; + config?: NotifierConfig; + external_service_id?: string; +} + +export interface ConfigSchemaProperty { + type: string; + title: string; + description?: string; + default?: any; +} + +export interface ConfigSchema { + title: string; + description: string; + type: string; + properties: { + [key: string]: ConfigSchemaProperty; + }; + required?: string[]; +} + +export interface TestNotifierResult { + status: string; + message?: string; +} diff --git a/frontend/src/core/models/optimizationUnit.ts b/frontend/src/core/models/optimizationUnit.ts new file mode 100644 index 0000000..2f635bc --- /dev/null +++ b/frontend/src/core/models/optimizationUnit.ts @@ -0,0 +1,34 @@ +export interface OptimizationUnit { + id?: string; + name: string; + description?: string; + is_enabled: boolean; + policy_id?: string; + target_miner_ids: string[]; + energy_source_id?: string; + home_loads_profile_id?: string; + performance_tracker_id?: string; + notifier_ids: string[]; +} + +export interface OptimizationUnitCreate { + name: string; + description?: string; + policy_id?: string; + target_miner_ids?: string[]; + energy_source_id?: string; + home_loads_profile_id?: string; + performance_tracker_id?: string; + notifier_ids?: string[]; +} + +export interface OptimizationUnitUpdate { + name?: string; + description?: string; + policy_id?: string; + target_miner_ids?: string[]; + energy_source_id?: string; + home_loads_profile_id?: string; + performance_tracker_id?: string; + notifier_ids?: string[]; +} diff --git a/frontend/src/core/models/performanceTracker.ts b/frontend/src/core/models/performanceTracker.ts new file mode 100644 index 0000000..e9f48a2 --- /dev/null +++ b/frontend/src/core/models/performanceTracker.ts @@ -0,0 +1,74 @@ +import type { ConfigSchema } from "./minerController"; + +export const MiningPerformanceTrackerAdapter = { + DUMMY: "dummy", + OCEAN: "ocean", + BRAIINS_POOL: "braiins_pool", +} as const; + +export type MiningPerformanceTrackerAdapter = + typeof MiningPerformanceTrackerAdapter[keyof typeof MiningPerformanceTrackerAdapter]; + +export interface PerformanceTrackerConfig { + [key: string]: any; +} + +export interface PerformanceTracker { + id?: string; + name: string; + adapter_type: MiningPerformanceTrackerAdapter; + config?: PerformanceTrackerConfig; + external_service_id?: string; +} + +export interface HashRate { + value: number; + unit: string; +} + +export interface PoolWorkerStats { + worker_name: string; + hashrate?: HashRate | null; + last_share_at?: string | null; + valid_shares?: number | null; + stale_shares?: number | null; + rejected_shares?: number | null; +} + +export interface PoolStats { + current_hashrate?: HashRate | null; + average_hashrate_24h?: HashRate | null; + average_hashrate_7d?: HashRate | null; + unpaid_balance?: number | null; + estimated_next_payout?: number | null; + workers: PoolWorkerStats[]; + timestamp: string; +} + +export interface MiningReward { + amount: number; + timestamp: string; +} + +export const PayoutFrequency = { + DAILY: "daily", + WEEKLY: "weekly", + MONTHLY: "monthly", + THRESHOLD: "threshold", + UNKNOWN: "unknown", +} as const; + +export type PayoutFrequency = typeof PayoutFrequency[keyof typeof PayoutFrequency]; + +export interface PayoutSchedule { + frequency: PayoutFrequency; + threshold?: number | null; + next_payout_at?: string | null; +} + +export interface TrackerTestResult { + status: string; + message: string; +} + +export type { ConfigSchema }; diff --git a/frontend/src/core/models/policy.ts b/frontend/src/core/models/policy.ts new file mode 100644 index 0000000..7b7ffff --- /dev/null +++ b/frontend/src/core/models/policy.ts @@ -0,0 +1,78 @@ +import type { EnergySource, EnergyStateSnapshot } from "./energySource"; +import type { Forecast, Sun } from "./forecast"; +import type { HomeLoadsConsumption } from "./homeLoad"; +import type { Miner, HashRate, MinerStateSnapshot } from "./miner"; +import type { PayoutSchedule, PoolStats } from "./performanceTracker"; + +export interface MiningPerformanceSnapshot { + current_hashrate?: HashRate | null; + pool_stats?: PoolStats | null; + payout_schedule?: PayoutSchedule | null; + timestamp: string; // ISO datetime +} + +export type RuleType = "start" | "stop"; + +export interface AutomationRule { + id: string; + name: string; + description?: string; + priority: number; + enabled: boolean; + conditions: Record; +} + +export interface Metadata { + author?: string; + version?: number; + created?: string; + last_modified?: string; +} + +export interface OptimizationPolicy { + id: string; + name: string; + description?: string; + start_rules: AutomationRule[]; + stop_rules: AutomationRule[]; + metadata?: Metadata; +} + +export interface PolicyCheckResult { + valid: boolean; + policy_id: string; + policy_name?: string; + errors: string[]; + warnings: string[]; + start_rules_count: number; + stop_rules_count: number; + enabled_start_rules_count: number; + enabled_stop_rules_count: number; +} + +export interface DecisionalContextField { + path: string; + type: string; + description: string; + is_optional: boolean; + values: string[] | null; + children: DecisionalContextField[] | null; +} + +export interface DecisionalContextStructure { + fields: DecisionalContextField[]; + total_fields: number; +} + +// Decisional Context - aggregates all domain data for decision making +export interface DecisionalContext { + energy_source?: EnergySource; + energy_state?: EnergyStateSnapshot; + forecast?: Forecast; + home_load?: HomeLoadsConsumption; + mining_performance?: MiningPerformanceSnapshot; + sun?: Sun; + miner?: Miner; + miner_state?: MinerStateSnapshot; + timestamp: string; // ISO datetime +} diff --git a/frontend/src/core/models/ruleEngine.ts b/frontend/src/core/models/ruleEngine.ts new file mode 100644 index 0000000..5cc90e1 --- /dev/null +++ b/frontend/src/core/models/ruleEngine.ts @@ -0,0 +1,94 @@ +export interface RuleEngineConfig { + engine_type: string; +} + +export interface RuleEvaluationRequest { + rules: any[]; + context: Record; + optimization_unit: string; +} + +export interface ValidationResult { + is_valid: boolean; + validation_errors: string[]; + syntax_errors: string[]; + field_errors: string[]; +} + +// Operator Types +export type OperatorType = + | 'eq' // equal + | 'ne' // not equal + | 'gt' // greater than + | 'gte' // greater than or equal + | 'lt' // less than + | 'lte' // less than or equal + | 'in' // in list/array + | 'not_in' // not in list/array + | 'contains' // string contains + | 'starts_with' // string starts with + | 'ends_with' // string ends with + | 'regex'; // regex match + +// Mapping of operators to their symbolic representation +export const OPERATOR_SYMBOLS: Record = { + 'eq': '==', + 'ne': '!=', + 'gt': '>', + 'gte': '>=', + 'lt': '<', + 'lte': '<=', + 'in': '∈', + 'not_in': '∉', + 'contains': '⊃', + 'starts_with': '^', + 'ends_with': '$', + 'regex': '~' +}; + +// Rule Condition +export interface RuleCondition { + field: string; + operator: OperatorType; + value: number | string | boolean | Array; +} + +// Logical Group +export interface LogicalGroup { + all_of?: Array | null; + any_of?: Array | null; + not_?: RuleCondition | LogicalGroup | null; +} + +// Rule Conditions +export type RuleConditions = LogicalGroup | RuleCondition; + +// Rule Validation Request +export interface RuleValidationRequest { + conditions: RuleConditions; +} + +// Rule Validation Result +export interface RuleValidationResult { + is_valid: boolean; + validation_errors: string[]; + syntax_errors: string[]; + field_errors: string[]; +} + +// Operator Info +export interface OperatorInfo { + operator: OperatorType; + symbol: string; + description: string; + example_usage: string; + supported_types: string[]; +} + +// Rule Engine Info +export interface RuleEngineInfo { + supported_engines: string[]; + supported_operators: OperatorInfo[]; + max_nesting_level: number; + supported_field_types: string[]; +} diff --git a/frontend/src/core/models/userNotification.ts b/frontend/src/core/models/userNotification.ts new file mode 100644 index 0000000..55a62fb --- /dev/null +++ b/frontend/src/core/models/userNotification.ts @@ -0,0 +1,4 @@ +export interface UserNotification { + status: "success" | "info" | "warning" | "error"; + message: string; +} diff --git a/frontend/src/core/services/baseService.ts b/frontend/src/core/services/baseService.ts new file mode 100644 index 0000000..edcb1d9 --- /dev/null +++ b/frontend/src/core/services/baseService.ts @@ -0,0 +1,39 @@ +import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios"; +import { useAppStore } from "../stores/appStore"; +// @ts-ignore // this line is needed to import the PromiseExtensions +import * as PromiseExtensions from "../extensions/promise"; + +/** + * Base service class, wraps common http methods for all services. + * The inheriting services should not use axios call directly. + */ +export class BaseService { + appStore = useAppStore(); + + get(url: string, request?: AxiosRequestConfig): Promise> { + return axios.get(this.appStore.apiUrl + url, request); + } + + post( + url: string, + data: any, + request?: AxiosRequestConfig + ): Promise> { + return axios.post(this.appStore.apiUrl + url, data, request); + } + + put( + url: string, + data: any, + request?: AxiosRequestConfig + ): Promise> { + return axios.put(this.appStore.apiUrl + url, data, request); + } + + delete( + url: string, + request?: AxiosRequestConfig + ): Promise> { + return axios.delete(this.appStore.apiUrl + url, request); + } +} diff --git a/frontend/src/core/services/energyLoadForecastProviderService.ts b/frontend/src/core/services/energyLoadForecastProviderService.ts new file mode 100644 index 0000000..8e9bc59 --- /dev/null +++ b/frontend/src/core/services/energyLoadForecastProviderService.ts @@ -0,0 +1,60 @@ +import { BaseService } from "./baseService"; +import type { + EnergyLoadForecastProvider, + EnergyLoadForecastProviderAdapter, + ConfigSchema, +} from "../models/energyLoadForecastProvider"; +import type { ExternalServiceAdapter } from "../models/externalService"; + +export class EnergyLoadForecastProviderService extends BaseService { + getProviders(): Promise { + return this.get("/energy-load-forecast-providers").getData(); + } + + getProvider(providerId: string): Promise { + return this.get( + `/energy-load-forecast-providers/${providerId}` + ).getData(); + } + + addProvider(provider: EnergyLoadForecastProvider): Promise { + return this.post( + "/energy-load-forecast-providers", + provider + ).getData(); + } + + updateProvider( + providerId: string, + provider: Partial + ): Promise { + return this.put( + `/energy-load-forecast-providers/${providerId}`, + provider + ).getData(); + } + + deleteProvider(providerId: string): Promise { + return this.delete( + `/energy-load-forecast-providers/${providerId}` + ).getData(); + } + + getAdapterTypes(): Promise { + return this.get( + "/energy-load-forecast-providers/types" + ).getData(); + } + + getConfigSchema(adapterType: string): Promise { + return this.get( + `/energy-load-forecast-providers/types/${adapterType}/config-schema` + ).getData(); + } + + getExternalServices(adapterType: string): Promise { + return this.get( + `/energy-load-forecast-providers/types/${adapterType}/external-services` + ).getData(); + } +} diff --git a/frontend/src/core/services/energyLoadHistoryProviderService.ts b/frontend/src/core/services/energyLoadHistoryProviderService.ts new file mode 100644 index 0000000..c816ffc --- /dev/null +++ b/frontend/src/core/services/energyLoadHistoryProviderService.ts @@ -0,0 +1,60 @@ +import { BaseService } from "./baseService"; +import type { + EnergyLoadHistoryProvider, + EnergyLoadHistoryProviderAdapter, +} from "../models/energyLoadHistoryProvider"; +import type { ConfigSchema } from "../models/energyLoadForecastProvider"; +import type { ExternalServiceAdapter } from "../models/externalService"; + +export class EnergyLoadHistoryProviderService extends BaseService { + getProviders(): Promise { + return this.get("/energy-load-history-providers").getData(); + } + + getProvider(providerId: string): Promise { + return this.get( + `/energy-load-history-providers/${providerId}` + ).getData(); + } + + addProvider(provider: EnergyLoadHistoryProvider): Promise { + return this.post( + "/energy-load-history-providers", + provider + ).getData(); + } + + updateProvider( + providerId: string, + provider: Partial + ): Promise { + return this.put( + `/energy-load-history-providers/${providerId}`, + provider + ).getData(); + } + + deleteProvider(providerId: string): Promise { + return this.delete( + `/energy-load-history-providers/${providerId}` + ).getData(); + } + + getAdapterTypes(): Promise { + return this.get( + "/energy-load-history-providers/types" + ).getData(); + } + + getConfigSchema(adapterType: string): Promise { + return this.get( + `/energy-load-history-providers/types/${adapterType}/config-schema` + ).getData(); + } + + getExternalServices(adapterType: string): Promise { + return this.get( + `/energy-load-history-providers/types/${adapterType}/external-services` + ).getData(); + } +} diff --git a/frontend/src/core/services/energyMonitorService.ts b/frontend/src/core/services/energyMonitorService.ts new file mode 100644 index 0000000..364bf06 --- /dev/null +++ b/frontend/src/core/services/energyMonitorService.ts @@ -0,0 +1,37 @@ +import { BaseService } from "./baseService"; +import type { EnergyMonitor, EnergyMonitorAdapter, ConfigSchema } from "../models/energyMonitor"; +import type { ExternalServiceAdapter } from "../models/externalService"; + +export class EnergyMonitorService extends BaseService { + getEnergyMonitors(): Promise { + return this.get("/energy-monitors").getData(); + } + + getEnergyMonitor(monitorId: string): Promise { + return this.get(`/energy-monitors/${monitorId}`).getData(); + } + + addEnergyMonitor(energyMonitor: EnergyMonitor): Promise { + return this.post("/energy-monitors", energyMonitor).getData(); + } + + updateEnergyMonitor(monitorId: string, energyMonitor: Partial): Promise { + return this.put(`/energy-monitors/${monitorId}`, energyMonitor).getData(); + } + + deleteEnergyMonitor(monitorId: string): Promise { + return this.delete(`/energy-monitors/${monitorId}`).getData(); + } + + getAdapterTypes(): Promise { + return this.get("/energy-monitors/types").getData(); + } + + getConfigSchema(adapterType: string): Promise { + return this.get(`/energy-monitors/types/${adapterType}/config-schema`).getData(); + } + + getExternalServices(adapterType: string): Promise { + return this.get(`/energy-monitors/types/${adapterType}/external-services`).getData(); + } +} diff --git a/frontend/src/core/services/energySourceService.ts b/frontend/src/core/services/energySourceService.ts new file mode 100644 index 0000000..1b3233e --- /dev/null +++ b/frontend/src/core/services/energySourceService.ts @@ -0,0 +1,28 @@ +import { BaseService } from "./baseService"; +import type { EnergySource } from "../models/energySource"; + +export class EnergySourceService extends BaseService { + getEnergySources(): Promise { + return this.get("/energy-sources").getData(); + } + + getEnergySource(sourceId: string): Promise { + return this.get(`/energy-sources/${sourceId}`).getData(); + } + + addEnergySource(energySource: EnergySource): Promise { + return this.post("/energy-sources", energySource).getData(); + } + + updateEnergySource(sourceId: string, energySource: Partial): Promise { + return this.put(`/energy-sources/${sourceId}`, energySource).getData(); + } + + deleteEnergySource(sourceId: string): Promise { + return this.delete(`/energy-sources/${sourceId}`).getData(); + } + + getEnergySourceTypes(): Promise { + return this.get("/energy-sources/types").getData(); + } +} diff --git a/frontend/src/core/services/externalServiceService.ts b/frontend/src/core/services/externalServiceService.ts new file mode 100644 index 0000000..f657ed5 --- /dev/null +++ b/frontend/src/core/services/externalServiceService.ts @@ -0,0 +1,49 @@ +import { BaseService } from "./baseService"; +import type { + ExternalService, + ExternalServiceStatus, + ExternalServiceLinkedEntities, + ConfigSchema, +} from "../models/externalService"; + +export class ExternalServiceService extends BaseService { + getExternalServices(): Promise { + return this.get("/external-services").getData(); + } + + getExternalService(serviceId: string): Promise { + return this.get(`/external-services/${serviceId}`).getData(); + } + + getServiceStatus(serviceId: string): Promise { + return this.get(`/external-services/${serviceId}/status`).getData(); + } + + addExternalService(externalService: ExternalService): Promise { + return this.post("/external-services", externalService).getData(); + } + + updateExternalService(serviceId: string, externalService: Partial): Promise { + return this.put(`/external-services/${serviceId}`, externalService).getData(); + } + + deleteExternalService(serviceId: string): Promise { + return this.delete(`/external-services/${serviceId}`).getData(); + } + + getAdapterTypes(): Promise { + return this.get("/external-services/types").getData(); + } + + getConfigSchema(adapterType: string): Promise { + return this.get(`/external-services/types/${adapterType}/config-schema`).getData(); + } + + getExternalServiceStatus(serviceId: string): Promise { + return this.get(`/external-services/${serviceId}/status`).getData(); + } + + getLinkedEntities(serviceId: string): Promise { + return this.get(`/external-services/${serviceId}/linked-entities`).getData(); + } +} diff --git a/frontend/src/core/services/forecastProviderService.ts b/frontend/src/core/services/forecastProviderService.ts new file mode 100644 index 0000000..dca476b --- /dev/null +++ b/frontend/src/core/services/forecastProviderService.ts @@ -0,0 +1,37 @@ +import { BaseService } from "./baseService"; +import type { ForecastProvider, ForecastProviderAdapter, ConfigSchema } from "../models/forecastProvider"; +import type { ExternalServiceAdapter } from "../models/externalService"; + +export class ForecastProviderService extends BaseService { + getForecastProviders(): Promise { + return this.get("/forecast-providers").getData(); + } + + getForecastProvider(providerId: string): Promise { + return this.get(`/forecast-providers/${providerId}`).getData(); + } + + addForecastProvider(forecastProvider: ForecastProvider): Promise { + return this.post("/forecast-providers", forecastProvider).getData(); + } + + updateForecastProvider(providerId: string, forecastProvider: Partial): Promise { + return this.put(`/forecast-providers/${providerId}`, forecastProvider).getData(); + } + + deleteForecastProvider(providerId: string): Promise { + return this.delete(`/forecast-providers/${providerId}`).getData(); + } + + getAdapterTypes(): Promise { + return this.get("/forecast-providers/types").getData(); + } + + getConfigSchema(adapterType: string): Promise { + return this.get(`/forecast-providers/types/${adapterType}/config-schema`).getData(); + } + + getExternalServices(adapterType: string): Promise { + return this.get(`/forecast-providers/types/${adapterType}/external-services`).getData(); + } +} diff --git a/frontend/src/core/services/homeLoadsProfileService.ts b/frontend/src/core/services/homeLoadsProfileService.ts new file mode 100644 index 0000000..8252ea4 --- /dev/null +++ b/frontend/src/core/services/homeLoadsProfileService.ts @@ -0,0 +1,117 @@ +import { BaseService } from "./baseService"; +import type { HomeLoadsProfile, LoadDevice, LoadDeviceCreate, LoadDeviceUpdate } from "../models/homeLoadsProfile"; +import type { HomeLoadPowerPoint, LoadEnergyConsumption } from "../models/loadTraining"; + +export class HomeLoadsProfileService extends BaseService { + getProfiles(): Promise { + return this.get("/home-loads-profiles").getData(); + } + + getProfile(profileId: string): Promise { + return this.get(`/home-loads-profiles/${profileId}`).getData(); + } + + addProfile(name: string): Promise { + return this.post( + `/home-loads-profiles?profile_name=${encodeURIComponent(name)}`, + {} + ).getData(); + } + + updateProfile(profileId: string, name: string): Promise { + return this.put( + `/home-loads-profiles/${profileId}?profile_new_name=${encodeURIComponent(name)}`, + {} + ).getData(); + } + + deleteProfile(profileId: string): Promise { + return this.delete(`/home-loads-profiles/${profileId}`).getData(); + } + + getDevices(profileId: string): Promise { + return this.get(`/home-loads-profiles/${profileId}/devices`).getData(); + } + + getDevice(profileId: string, deviceId: string): Promise { + return this.get(`/home-loads-profiles/${profileId}/devices/${deviceId}`).getData(); + } + + addDevice(profileId: string, device: LoadDeviceCreate): Promise { + return this.post(`/home-loads-profiles/${profileId}/devices`, device).getData(); + } + + updateDevice(profileId: string, deviceId: string, device: LoadDeviceUpdate): Promise { + return this.put( + `/home-loads-profiles/${profileId}/devices/${deviceId}`, + device + ).getData(); + } + + deleteDevice(profileId: string, deviceId: string): Promise { + return this.delete( + `/home-loads-profiles/${profileId}/devices/${deviceId}` + ).getData(); + } + + getDeviceHistory( + profileId: string, + deviceId: string, + start: string, + end: string + ): Promise { + return this.get( + `/home-loads-profiles/${profileId}/devices/${deviceId}/history?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}` + ).getData(); + } + + collectDeviceHistory( + profileId: string, + deviceId: string, + lookbackHours: number = 24 + ): Promise> { + return this.post>( + `/home-loads-profiles/${profileId}/devices/${deviceId}/history/collect?lookback_hours=${lookbackHours}`, + {} + ).getData(); + } + + clearDeviceHistory( + profileId: string, + deviceId: string + ): Promise> { + return this.delete>( + `/home-loads-profiles/${profileId}/devices/${deviceId}/history` + ).getData(); + } + + getDeviceForecast( + profileId: string, + deviceId: string, + hoursAhead: number = 3, + historyHours?: number + ): Promise { + const params = new URLSearchParams({ hours_ahead: String(hoursAhead) }); + if (historyHours != null) params.set("history_hours", String(historyHours)); + return this.get( + `/home-loads-profiles/${profileId}/devices/${deviceId}/forecast?${params}` + ).getData(); + } + + collectHistoryGlobal(lookbackHours: number = 24): Promise> { + return this.post>( + `/history/collect?lookback_hours=${lookbackHours}`, + {} + ).getData(); + } + + collectHistoryForDevices( + deviceIds: string[], + lookbackHours: number = 24 + ): Promise> { + return this.post>( + `/history/collect/devices?lookback_hours=${lookbackHours}`, + deviceIds + ).getData(); + } +} diff --git a/frontend/src/core/services/loadTrainingService.ts b/frontend/src/core/services/loadTrainingService.ts new file mode 100644 index 0000000..91b7d1d --- /dev/null +++ b/frontend/src/core/services/loadTrainingService.ts @@ -0,0 +1,33 @@ +import { BaseService } from "./baseService"; +import type { LoadConsumptionModel } from "../models/loadTraining"; + +export class LoadTrainingService extends BaseService { + triggerTrainingAll( + weeksLookback: number = 8 + ): Promise<{ status: string; detail: string }> { + return this.post<{ status: string; detail: string }>( + `/training/trigger?weeks_lookback=${weeksLookback}`, + {} + ).getData(); + } + + triggerTrainingDevice( + profileId: string, + deviceId: string, + weeksLookback: number = 8 + ): Promise<{ status: string; detail: string }> { + return this.post<{ status: string; detail: string }>( + `/home-loads-profiles/${profileId}/devices/${deviceId}/training/trigger?weeks_lookback=${weeksLookback}`, + {} + ).getData(); + } + + getModels(deviceId?: string): Promise { + const params = deviceId ? `?device_id=${deviceId}` : ""; + return this.get(`/training/models${params}`).getData(); + } + + deleteModel(modelId: string): Promise { + return this.delete(`/training/models/${modelId}`).getData(); + } +} diff --git a/frontend/src/core/services/minerControllerService.ts b/frontend/src/core/services/minerControllerService.ts new file mode 100644 index 0000000..4144800 --- /dev/null +++ b/frontend/src/core/services/minerControllerService.ts @@ -0,0 +1,42 @@ +import { BaseService } from "./baseService"; +import type { MinerController, MinerControllerAdapter, ConfigSchema } from "../models/minerController"; +import type { MinerStateSnapshot } from "../models/miner"; + +export class MinerControllerService extends BaseService { + getMinerControllers(): Promise { + return this.get("/miner-controllers").getData(); + } + + getMinerController(controllerId: string): Promise { + return this.get(`/miner-controllers/${controllerId}`).getData(); + } + + addMinerController(minerController: MinerController): Promise { + return this.post("/miner-controllers", minerController).getData(); + } + + updateMinerController(controllerId: string, minerController: Partial): Promise { + return this.put(`/miner-controllers/${controllerId}`, minerController).getData(); + } + + deleteMinerController(controllerId: string): Promise { + return this.delete(`/miner-controllers/${controllerId}`).getData(); + } + + getAdapterTypes(): Promise { + return this.get("/miner-controllers/types").getData(); + } + + getConfigSchema(adapterType: string): Promise { + return this.get(`/miner-controllers/types/${adapterType}/config-schema`).getData(); + } + + getExternalServiceType(adapterType: string): Promise { + return this.get(`/miner-controllers/types/${adapterType}/external-services`).getData() + .then((result) => (result === "null" ? null : result)); + } + + getMinerDetails(controllerId: string): Promise { + return this.get(`/miner-controllers/${controllerId}/miner-details`).getData(); + } +} diff --git a/frontend/src/core/services/minerService.ts b/frontend/src/core/services/minerService.ts new file mode 100644 index 0000000..c374e18 --- /dev/null +++ b/frontend/src/core/services/minerService.ts @@ -0,0 +1,72 @@ +import { BaseService } from "./baseService"; +import type { Miner, MinerInfo, MinerLimit, MinerStateSnapshot } from "../models/miner"; + +export class MinerService extends BaseService { + getMiners(): Promise { + return this.get("/miners").getData(); + } + + getMiner(minerId: string): Promise { + return this.get(`/miners/${minerId}`).getData(); + } + + addMiner(miner: Miner): Promise { + return this.post("/miners", miner).getData(); + } + + updateMiner(minerId: string, miner: Partial): Promise { + return this.put(`/miners/${minerId}`, miner).getData(); + } + + deleteMiner(minerId: string): Promise { + return this.delete(`/miners/${minerId}`).getData(); + } + + startMiner(minerId: string): Promise { + return this.post(`/miners/${minerId}/start`, {}).getData(); + } + + stopMiner(minerId: string): Promise { + return this.post(`/miners/${minerId}/stop`, {}).getData(); + } + + getMinerStatus(minerId: string): Promise { + return this.get(`/miners/${minerId}/status`).getData(); + } + + activateMiner(minerId: string): Promise { + return this.post(`/miners/${minerId}/activate`, {}).getData(); + } + + deactivateMiner(minerId: string): Promise { + return this.post(`/miners/${minerId}/deactivate`, {}).getData(); + } + + setMinerController(minerId: string, controllerId: string): Promise { + return this.post(`/miners/${minerId}/set-controller`, {}, { params: { controller_id: controllerId } }).getData(); + } + + unlinkMinerController(minerId: string, controllerId: string): Promise { + return this.post(`/miners/${minerId}/unlink-controller`, {}, { params: { controller_id: controllerId } }).getData(); + } + + enableFeature(minerId: string, controllerId: string, featureType: string): Promise { + return this.post(`/miners/${minerId}/features/${controllerId}/${featureType}/enable`, {}).getData(); + } + + disableFeature(minerId: string, controllerId: string, featureType: string): Promise { + return this.post(`/miners/${minerId}/features/${controllerId}/${featureType}/disable`, {}).getData(); + } + + setFeaturePriority(minerId: string, controllerId: string, featureType: string, priority: number): Promise { + return this.put(`/miners/${minerId}/features/${controllerId}/${featureType}/priority`, { priority }).getData(); + } + + getMinerLimits(minerId: string): Promise { + return this.get(`/miners/${minerId}/limits`).getData(); + } + + getMinerInfo(minerId: string): Promise { + return this.get(`/miners/${minerId}/info`).getData(); + } +} diff --git a/frontend/src/core/services/notifierService.ts b/frontend/src/core/services/notifierService.ts new file mode 100644 index 0000000..cf2f643 --- /dev/null +++ b/frontend/src/core/services/notifierService.ts @@ -0,0 +1,40 @@ +import { BaseService } from "./baseService"; +import type { Notifier, ConfigSchema, TestNotifierResult } from "../models/notifier"; + +export class NotifierService extends BaseService { + getNotifiers(): Promise { + return this.get("/notifiers").getData(); + } + + getNotifier(notifierId: string): Promise { + return this.get(`/notifiers/${notifierId}`).getData(); + } + + addNotifier(notifier: Notifier): Promise { + return this.post("/notifiers", notifier).getData(); + } + + updateNotifier(notifierId: string, notifier: Partial): Promise { + return this.put(`/notifiers/${notifierId}`, notifier).getData(); + } + + deleteNotifier(notifierId: string): Promise { + return this.delete(`/notifiers/${notifierId}`).getData(); + } + + getAdapterTypes(): Promise { + return this.get("/notifiers/types").getData(); + } + + getConfigSchema(adapterType: string): Promise { + return this.get(`/notifiers/types/${adapterType}/config-schema`).getData(); + } + + testNotifier(notifierId: string): Promise { + return this.post(`/notifiers/${notifierId}/test`, {}).getData(); + } + + getExternalServices(adapterType: string): Promise { + return this.get(`/notifiers/types/${adapterType}/external-services`).getData(); + } +} diff --git a/frontend/src/core/services/optimizationUnitService.ts b/frontend/src/core/services/optimizationUnitService.ts new file mode 100644 index 0000000..18bfaf0 --- /dev/null +++ b/frontend/src/core/services/optimizationUnitService.ts @@ -0,0 +1,75 @@ +import { BaseService } from "./baseService"; +import type { OptimizationUnit, OptimizationUnitCreate, OptimizationUnitUpdate } from "../models/optimizationUnit"; +import type { DecisionalContext } from "../models/policy"; + +export class OptimizationUnitService extends BaseService { + // Basic CRUD operations + getOptimizationUnits(): Promise { + return this.get("/optimization-units").getData(); + } + + getOptimizationUnit(unitId: string): Promise { + return this.get(`/optimization-units/${unitId}`).getData(); + } + + addOptimizationUnit(unit: OptimizationUnitCreate): Promise { + return this.post("/optimization-units", unit).getData(); + } + + updateOptimizationUnit(unitId: string, unit: OptimizationUnitUpdate): Promise { + return this.put(`/optimization-units/${unitId}`, unit).getData(); + } + + deleteOptimizationUnit(unitId: string): Promise { + return this.delete(`/optimization-units/${unitId}`).getData(); + } + + // Enable/Disable operations + enableOptimizationUnit(unitId: string): Promise { + return this.post(`/optimization-units/${unitId}/enable`, {}).getData(); + } + + disableOptimizationUnit(unitId: string): Promise { + return this.post(`/optimization-units/${unitId}/disable`, {}).getData(); + } + + // Assignment operations + assignEnergySource(unitId: string, energySourceId: string): Promise { + return this.post(`/optimization-units/${unitId}/energy-source?energy_source_id=${energySourceId}`, {}).getData(); + } + + assignPolicy(unitId: string, policyId: string): Promise { + return this.post(`/optimization-units/${unitId}/policy?policy_id=${policyId}`, {}).getData(); + } + + // Miner operations + assignMiners(unitId: string, minerIds: string[]): Promise { + return this.post(`/optimization-units/${unitId}/miners`, minerIds).getData(); + } + + addMiner(unitId: string, minerId: string): Promise { + return this.post(`/optimization-units/${unitId}/miners/single?miner_id=${minerId}`, {}).getData(); + } + + removeMiner(unitId: string, minerId: string): Promise { + return this.delete(`/optimization-units/${unitId}/miners/${minerId}`).getData(); + } + + // Notifier operations + assignNotifiers(unitId: string, notifierIds: string[]): Promise { + return this.post(`/optimization-units/${unitId}/notifiers`, notifierIds).getData(); + } + + addNotifier(unitId: string, notifierId: string): Promise { + return this.post(`/optimization-units/${unitId}/notifiers/single?notifier_id=${notifierId}`, {}).getData(); + } + + removeNotifier(unitId: string, notifierId: string): Promise { + return this.delete(`/optimization-units/${unitId}/notifiers/${notifierId}`).getData(); + } + + // Decisional context + getDecisionalContext(unitId: string): Promise { + return this.get(`/optimization-units/${unitId}/decisional-context`).getData(); + } +} diff --git a/frontend/src/core/services/performanceTrackerService.ts b/frontend/src/core/services/performanceTrackerService.ts new file mode 100644 index 0000000..f3aed62 --- /dev/null +++ b/frontend/src/core/services/performanceTrackerService.ts @@ -0,0 +1,92 @@ +import { BaseService } from "./baseService"; +import type { + PerformanceTracker, + MiningPerformanceTrackerAdapter, + ConfigSchema, + PoolStats, + PoolWorkerStats, + MiningReward, + PayoutSchedule, + TrackerTestResult, +} from "../models/performanceTracker"; + +export class PerformanceTrackerService extends BaseService { + getPerformanceTrackers(): Promise { + return this.get("/mining-performance-trackers").getData(); + } + + getPerformanceTracker(trackerId: string): Promise { + return this.get(`/mining-performance-trackers/${trackerId}`).getData(); + } + + addPerformanceTracker(tracker: PerformanceTracker): Promise { + return this.post("/mining-performance-trackers", tracker).getData(); + } + + updatePerformanceTracker( + trackerId: string, + tracker: Partial + ): Promise { + return this.put( + `/mining-performance-trackers/${trackerId}`, + tracker + ).getData(); + } + + deletePerformanceTracker(trackerId: string): Promise { + return this.delete( + `/mining-performance-trackers/${trackerId}` + ).getData(); + } + + getAdapterTypes(): Promise { + return this.get( + "/mining-performance-trackers/types" + ).getData(); + } + + getConfigSchema(adapterType: string): Promise { + return this.get( + `/mining-performance-trackers/types/${adapterType}/config-schema` + ).getData(); + } + + getExternalServiceType(adapterType: string): Promise { + return this.get( + `/mining-performance-trackers/types/${adapterType}/external-services` + ) + .getData() + .then((result) => (result === "null" || result === null ? null : result)); + } + + testPerformanceTracker(trackerId: string): Promise { + return this.post( + `/mining-performance-trackers/${trackerId}/test`, + {} + ).getData(); + } + + getPoolStats(trackerId: string): Promise { + return this.get( + `/mining-performance-trackers/${trackerId}/stats` + ).getData(); + } + + getWorkers(trackerId: string): Promise { + return this.get( + `/mining-performance-trackers/${trackerId}/workers` + ).getData(); + } + + getRewards(trackerId: string, limit = 10): Promise { + return this.get( + `/mining-performance-trackers/${trackerId}/rewards?limit=${limit}` + ).getData(); + } + + getPayoutSchedule(trackerId: string): Promise { + return this.get( + `/mining-performance-trackers/${trackerId}/payout-schedule` + ).getData(); + } +} diff --git a/frontend/src/core/services/policyService.ts b/frontend/src/core/services/policyService.ts new file mode 100644 index 0000000..e43a90a --- /dev/null +++ b/frontend/src/core/services/policyService.ts @@ -0,0 +1,62 @@ +import { BaseService } from "./baseService"; +import type { OptimizationPolicy, AutomationRule, PolicyCheckResult, RuleType, DecisionalContextStructure } from "../models/policy"; + +export class PolicyService extends BaseService { + // Policy CRUD operations + getPolicies(): Promise { + return this.get("/policies").getData(); + } + + getPolicy(policyId: string): Promise { + return this.get(`/policies/${policyId}`).getData(); + } + + addPolicy(policy: OptimizationPolicy): Promise { + return this.post("/policies", policy).getData(); + } + + updatePolicy(policyId: string, policy: Partial): Promise { + return this.put(`/policies/${policyId}`, policy).getData(); + } + + deletePolicy(policyId: string): Promise { + return this.delete(`/policies/${policyId}`).getData(); + } + + checkPolicy(policyId: string): Promise { + return this.get(`/policies/${policyId}/check`).getData(); + } + + // Policy Rules CRUD operations + addRule(policyId: string, ruleType: RuleType, rule: AutomationRule): Promise { + return this.post(`/policies/${policyId}/rules?rule_type=${ruleType}`, rule).getData(); + } + + getRulesByType(policyId: string, ruleType: string): Promise { + return this.get(`/policies/${policyId}/types/${ruleType}`).getData(); + } + + getRule(policyId: string, ruleId: string): Promise { + return this.get(`/policies/${policyId}/rules/${ruleId}`).getData(); + } + + updateRule(policyId: string, ruleId: string, rule: Partial): Promise { + return this.put(`/policies/${policyId}/rules/${ruleId}`, rule).getData(); + } + + deleteRule(policyId: string, ruleId: string): Promise { + return this.delete(`/policies/${policyId}/rules/${ruleId}`).getData(); + } + + enableRule(policyId: string, ruleId: string): Promise { + return this.get(`/policies/${policyId}/rules/${ruleId}/enable`).getData(); + } + + disableRule(policyId: string, ruleId: string): Promise { + return this.get(`/policies/${policyId}/rules/${ruleId}/disable`).getData(); + } + + getDecisionalContextStructure(): Promise { + return this.get("/decisional-context/structure").getData(); + } +} diff --git a/frontend/src/core/services/ruleEngineService.ts b/frontend/src/core/services/ruleEngineService.ts new file mode 100644 index 0000000..62ca933 --- /dev/null +++ b/frontend/src/core/services/ruleEngineService.ts @@ -0,0 +1,26 @@ +import { BaseService } from "./baseService"; +import type { + RuleEngineConfig, + RuleEngineInfo, + RuleEvaluationRequest, + RuleValidationRequest, + RuleValidationResult, +} from "../models/ruleEngine"; + +export class RuleEngineService extends BaseService { + getConfig(): Promise { + return this.get("/rule-engine/config").getData(); + } + + getInfo(): Promise { + return this.get("/rule-engine/info").getData(); + } + + evaluate(request: RuleEvaluationRequest): Promise { + return this.post("/rule-engine/evaluate", request).getData(); + } + + validate(request: RuleValidationRequest): Promise { + return this.post("/rule-engine/validate", request).getData(); + } +} diff --git a/frontend/src/core/stores/appStore.ts b/frontend/src/core/stores/appStore.ts new file mode 100644 index 0000000..19013c0 --- /dev/null +++ b/frontend/src/core/stores/appStore.ts @@ -0,0 +1,98 @@ +import { defineStore } from "pinia"; +import { computed, ref, watch } from "vue"; +import router from "../../router"; +import { useLoader } from "../composables/useLoader"; +import type { UserNotification } from "../models/userNotification"; + +export const useAppStore = defineStore("app", () => { + // STATE + const userNotification = ref(); + const version = ref(""); + const versionLoading = ref(true); + // The state bound to the loader shown in the App.vue component. Can be used by any component or service that needs to put + // the user on hold. + const loader = useLoader(); + + // GETTERS + const rootUrl = computed( + () => import.meta.env.VITE_API_BASE_URL || "" // Use .env variable or default to relative path + ); + const apiUrl = computed(() => rootUrl.value + "/api/v1"); + + // ACTIONS + function showToast(notification: UserNotification, extra?: string) { + console.info( + `Firing ${notification.status} Toast:`, + notification.message, + extra + ); + userNotification.value = notification; + setTimeout(() => { + userNotification.value = undefined; + }, 4000); + } + + function showSuccessToast(message: string) { + showToast({ status: "success", message }); + } + + function showWarningToast(message: string) { + showToast({ status: "warning", message }); + } + + function showInfoToast(message: string) { + showToast({ status: "info", message }); + } + + function showErrorToast(message: string, reason?: any) { + showToast({ status: "error", message }, reason); + } + + async function fetchVersion() { + versionLoading.value = true; + try { + const response = await fetch(`${rootUrl.value}/api/version`); + if (response.ok) { + const data = await response.json(); + version.value = data.version; + } + } catch (error) { + console.error("Failed to fetch version:", error); + } finally { + versionLoading.value = false; + } + } + + function updateDocumentTitle() { + let title = `Edge Mining`; + const routeTitle = router.currentRoute.value.meta?.title; + if (routeTitle) { + title += ` - ${routeTitle}`; + } + document.title = title; + } + + // WATCHERS + watch(router.currentRoute, () => { + updateDocumentTitle(); + }); + + return { + // STATE + userNotification, + loader, + version, + versionLoading, + + // GETTERS + // rootUrl, + apiUrl, + + // ACTIONS + fetchVersion, + showSuccessToast, + showWarningToast, + showInfoToast, + showErrorToast, + }; +}); diff --git a/frontend/src/core/stores/dashboardStore.ts b/frontend/src/core/stores/dashboardStore.ts new file mode 100644 index 0000000..b5a1215 --- /dev/null +++ b/frontend/src/core/stores/dashboardStore.ts @@ -0,0 +1,111 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { DecisionalContext } from "../models/policy"; +import type { ForecastPowerPoint } from "../models/forecast"; + +export interface DashboardEvent { + id: string; + type: "miner_start" | "miner_stop" | "status_change" | "rule_triggered"; + message: string; + timestamp: Date; + icon?: string; +} + +export interface TimeSeriesPoint { + time: number; // unix seconds + value: number; +} + +export interface MinerOnOffEvent { + time: number; // unix seconds + minerName: string; + action: "on" | "off"; +} + +const MAX_POINTS = 360; // ~30 min at 5s intervals +const MAX_EVENTS = 50; +const MAX_ONOFF_EVENTS = 100; + +export const useDashboardStore = defineStore("dashboard", () => { + const hashRateSeries = ref([]); + const powerSeries = ref([]); + const energyProductionSeries = ref([]); + const batterySOCSeries = ref([]); + const batteryPowerSeries = ref([]); + const gridPowerSeries = ref([]); + const consumptionSeries = ref([]); + const maxChipTempSeries = ref([]); + const maxBoardTempSeries = ref([]); + const internalFanSpeedSeries = ref([]); + const externalFanSpeedSeries = ref([]); + const events = ref([]); + const minerOnOffEvents = ref([]); + const previousMinerStatuses = ref>(new Map()); + const latestDecisionalContexts = ref>(new Map()); + const forecastPowerPoints = ref([]); + let eventCounter = 0; + + type SeriesName = "hashRate" | "power" | "energyProduction" | "batterySOC" | "batteryPower" | "gridPower" | "consumption" | "maxChipTemp" | "maxBoardTemp" | "internalFanSpeed" | "externalFanSpeed"; + + const seriesMap: Record = { + hashRate: hashRateSeries, + power: powerSeries, + energyProduction: energyProductionSeries, + batterySOC: batterySOCSeries, + batteryPower: batteryPowerSeries, + gridPower: gridPowerSeries, + consumption: consumptionSeries, + maxChipTemp: maxChipTempSeries, + maxBoardTemp: maxBoardTempSeries, + internalFanSpeed: internalFanSpeedSeries, + externalFanSpeed: externalFanSpeedSeries, + }; + + function addSeriesPoint( + series: SeriesName, + point: TimeSeriesPoint + ) { + const target = seriesMap[series]; + const existing = target.value; + // Skip if any existing point already has this timestamp + if (existing.some((p) => p.time === point.time)) return; + target.value = [...existing, point].slice(-MAX_POINTS); + } + + function addEvent(event: Omit) { + eventCounter++; + events.value.unshift({ ...event, id: `evt-${eventCounter}` }); + if (events.value.length > MAX_EVENTS) { + events.value = events.value.slice(0, MAX_EVENTS); + } + } + + function addMinerOnOffEvent(event: MinerOnOffEvent) { + minerOnOffEvents.value = [ + ...minerOnOffEvents.value, + event, + ].slice(-MAX_ONOFF_EVENTS); + } + + return { + hashRateSeries, + powerSeries, + energyProductionSeries, + batterySOCSeries, + batteryPowerSeries, + gridPowerSeries, + consumptionSeries, + maxChipTempSeries, + maxBoardTempSeries, + internalFanSpeedSeries, + externalFanSpeedSeries, + events, + minerOnOffEvents, + previousMinerStatuses, + latestDecisionalContexts, + forecastPowerPoints, + addSeriesPoint, + addEvent, + addMinerOnOffEvent, + }; +}); diff --git a/frontend/src/core/stores/energyLoadForecastProviderStore.ts b/frontend/src/core/stores/energyLoadForecastProviderStore.ts new file mode 100644 index 0000000..84b8551 --- /dev/null +++ b/frontend/src/core/stores/energyLoadForecastProviderStore.ts @@ -0,0 +1,63 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { + EnergyLoadForecastProvider, + EnergyLoadForecastProviderAdapter, +} from "../models/energyLoadForecastProvider"; +import { EnergyLoadForecastProviderService } from "../services/energyLoadForecastProviderService"; + +export const useEnergyLoadForecastProviderStore = defineStore( + "energyLoadForecastProvider", + () => { + const service = new EnergyLoadForecastProviderService(); + + // State + const providers = ref([]); + const adapterTypes = ref([]); + + // Actions + function loadProviders() { + return service.getProviders().then((response) => { + providers.value = response; + }); + } + + function loadAdapterTypes() { + return service.getAdapterTypes().then((response) => { + adapterTypes.value = response; + }); + } + + function addProvider(provider: EnergyLoadForecastProvider) { + return service.addProvider(provider); + } + + function updateProvider( + providerId: string, + provider: Partial + ) { + return service.updateProvider(providerId, provider); + } + + function deleteProvider(providerId: string) { + return service.deleteProvider(providerId); + } + + function externalServices(adapterType: string) { + return service.getExternalServices(adapterType); + } + + return { + // STATE + providers, + adapterTypes, + // ACTIONS + loadProviders, + loadAdapterTypes, + addProvider, + updateProvider, + deleteProvider, + externalServices, + }; + } +); diff --git a/frontend/src/core/stores/energyLoadHistoryProviderStore.ts b/frontend/src/core/stores/energyLoadHistoryProviderStore.ts new file mode 100644 index 0000000..d6764ea --- /dev/null +++ b/frontend/src/core/stores/energyLoadHistoryProviderStore.ts @@ -0,0 +1,63 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { + EnergyLoadHistoryProvider, + EnergyLoadHistoryProviderAdapter, +} from "../models/energyLoadHistoryProvider"; +import { EnergyLoadHistoryProviderService } from "../services/energyLoadHistoryProviderService"; + +export const useEnergyLoadHistoryProviderStore = defineStore( + "energyLoadHistoryProvider", + () => { + const service = new EnergyLoadHistoryProviderService(); + + // State + const providers = ref([]); + const adapterTypes = ref([]); + + // Actions + function loadProviders() { + return service.getProviders().then((response) => { + providers.value = response; + }); + } + + function loadAdapterTypes() { + return service.getAdapterTypes().then((response) => { + adapterTypes.value = response; + }); + } + + function addProvider(provider: EnergyLoadHistoryProvider) { + return service.addProvider(provider); + } + + function updateProvider( + providerId: string, + provider: Partial + ) { + return service.updateProvider(providerId, provider); + } + + function deleteProvider(providerId: string) { + return service.deleteProvider(providerId); + } + + function externalServices(adapterType: string) { + return service.getExternalServices(adapterType); + } + + return { + // STATE + providers, + adapterTypes, + // ACTIONS + loadProviders, + loadAdapterTypes, + addProvider, + updateProvider, + deleteProvider, + externalServices, + }; + } +); diff --git a/frontend/src/core/stores/energyMonitorStore.ts b/frontend/src/core/stores/energyMonitorStore.ts new file mode 100644 index 0000000..4109dfc --- /dev/null +++ b/frontend/src/core/stores/energyMonitorStore.ts @@ -0,0 +1,54 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { EnergyMonitor, EnergyMonitorAdapter } from "../models/energyMonitor"; +import { EnergyMonitorService } from "../services/energyMonitorService"; + +export const useEnergyMonitorStore = defineStore("energyMonitor", () => { + const service = new EnergyMonitorService(); + + // State + const energyMonitors = ref([]); + const adapterTypes = ref([]); + + // Actions + function loadEnergyMonitors() { + return service.getEnergyMonitors().then((response) => { + energyMonitors.value = response; + }); + } + + function loadAdapterTypes() { + return service.getAdapterTypes().then((response) => { + adapterTypes.value = response; + }); + } + + function addEnergyMonitor(energyMonitor: EnergyMonitor) { + return service.addEnergyMonitor(energyMonitor); + } + + function updateEnergyMonitor(monitorId: string, energyMonitor: Partial) { + return service.updateEnergyMonitor(monitorId, energyMonitor); + } + + function deleteEnergyMonitor(monitorId: string) { + return service.deleteEnergyMonitor(monitorId); + } + + function externalServices(adapterType: string) { + return service.getExternalServices(adapterType); + } + + return { + //STATE + energyMonitors, + adapterTypes, + // ACTIONS + loadEnergyMonitors, + loadAdapterTypes, + addEnergyMonitor, + updateEnergyMonitor, + deleteEnergyMonitor, + externalServices, + }; +}); diff --git a/frontend/src/core/stores/energySourceStore.ts b/frontend/src/core/stores/energySourceStore.ts new file mode 100644 index 0000000..a7145e2 --- /dev/null +++ b/frontend/src/core/stores/energySourceStore.ts @@ -0,0 +1,40 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { EnergySource } from "../models/energySource"; +import { EnergySourceService } from "../services/energySourceService"; + +export const useEnergySourceStore = defineStore("energySource", () => { + const service = new EnergySourceService(); + + // State + const energySources = ref([]); + + // Actions + function loadEnergySources() { + return service.getEnergySources().then((response) => { + energySources.value = response; + }); + } + + function addEnergySource(energySource: EnergySource) { + return service.addEnergySource(energySource); + } + + function updateEnergySource(sourceId: string, energySource: Partial) { + return service.updateEnergySource(sourceId, energySource); + } + + function deleteEnergySource(sourceId: string) { + return service.deleteEnergySource(sourceId); + } + + return { + //STATE + energySources, + // ACTIONS + loadEnergySources, + addEnergySource, + updateEnergySource, + deleteEnergySource, + }; +}); diff --git a/frontend/src/core/stores/externalServiceStore.ts b/frontend/src/core/stores/externalServiceStore.ts new file mode 100644 index 0000000..dcdc471 --- /dev/null +++ b/frontend/src/core/stores/externalServiceStore.ts @@ -0,0 +1,95 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { + ExternalService, + ExternalServiceStatus, + ExternalServiceLinkedEntities, + ConfigSchema, +} from "../models/externalService"; +import { ExternalServiceService } from "../services/externalServiceService"; + +export const useExternalServiceStore = defineStore("externalService", () => { + const service = new ExternalServiceService(); + + // State + const externalServices = ref([]); + const adapterTypes = ref([]); + const configSchemas = ref>(new Map()); + const serviceStatuses = ref>(new Map()); + const serviceLinkedEntities = ref>(new Map()); + + // Actions + function loadExternalServices() { + return service.getExternalServices().then((response) => { + externalServices.value = response; + }); + } + + async function loadServicesStatus() { + const statusPromises = externalServices.value.map((svc) => + service.getServiceStatus(String(svc.id)).then((status) => ({ id: String(svc.id), status })) + ); + const results = await Promise.all(statusPromises); + results.forEach(({ id, status }) => { + serviceStatuses.value.set(id, status); + }); + } + + function loadAdapterTypes() { + return service.getAdapterTypes().then((response) => { + adapterTypes.value = response; + }); + } + + function loadConfigSchema(adapterType: string) { + return service.getConfigSchema(adapterType).then((response) => { + configSchemas.value.set(adapterType, response); + return response; + }); + } + + function addExternalService(externalService: ExternalService) { + return service.addExternalService(externalService); + } + + function updateExternalService(serviceId: string, externalService: Partial) { + return service.updateExternalService(serviceId, externalService); + } + + function deleteExternalService(serviceId: string) { + return service.deleteExternalService(serviceId); + } + + function getServiceStatus(serviceId: string) { + return service.getExternalServiceStatus(serviceId).then((response) => { + serviceStatuses.value.set(serviceId, response); + return response; + }); + } + + function getLinkedEntities(serviceId: string) { + return service.getLinkedEntities(serviceId).then((response) => { + serviceLinkedEntities.value.set(serviceId, response); + return response; + }); + } + + return { + // STATE + externalServices, + adapterTypes, + configSchemas, + serviceStatuses, + serviceLinkedEntities, + // ACTIONS + loadExternalServices, + loadServicesStatus, + loadAdapterTypes, + loadConfigSchema, + addExternalService, + updateExternalService, + deleteExternalService, + getServiceStatus, + getLinkedEntities, + }; +}); diff --git a/frontend/src/core/stores/forecastProviderStore.ts b/frontend/src/core/stores/forecastProviderStore.ts new file mode 100644 index 0000000..aa4cc87 --- /dev/null +++ b/frontend/src/core/stores/forecastProviderStore.ts @@ -0,0 +1,54 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { ForecastProvider, ForecastProviderAdapter } from "../models/forecastProvider"; +import { ForecastProviderService } from "../services/forecastProviderService"; + +export const useForecastProviderStore = defineStore("forecastProvider", () => { + const service = new ForecastProviderService(); + + // State + const forecastProviders = ref([]); + const adapterTypes = ref([]); + + // Actions + function loadForecastProviders() { + return service.getForecastProviders().then((response) => { + forecastProviders.value = response; + }); + } + + function loadAdapterTypes() { + return service.getAdapterTypes().then((response) => { + adapterTypes.value = response; + }); + } + + function addForecastProvider(forecastProvider: ForecastProvider) { + return service.addForecastProvider(forecastProvider); + } + + function updateForecastProvider(providerId: string, forecastProvider: Partial) { + return service.updateForecastProvider(providerId, forecastProvider); + } + + function deleteForecastProvider(providerId: string) { + return service.deleteForecastProvider(providerId); + } + + function externalServices(adapterType: string) { + return service.getExternalServices(adapterType); + } + + return { + //STATE + forecastProviders, + adapterTypes, + // ACTIONS + loadForecastProviders, + loadAdapterTypes, + addForecastProvider, + updateForecastProvider, + deleteForecastProvider, + externalServices + }; +}); diff --git a/frontend/src/core/stores/homeLoadsProfileStore.ts b/frontend/src/core/stores/homeLoadsProfileStore.ts new file mode 100644 index 0000000..b6d30b6 --- /dev/null +++ b/frontend/src/core/stores/homeLoadsProfileStore.ts @@ -0,0 +1,99 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { HomeLoadsProfile, LoadDeviceCreate, LoadDeviceUpdate } from "../models/homeLoadsProfile"; +import { HomeLoadsProfileService } from "../services/homeLoadsProfileService"; +import type { HomeLoadPowerPoint, LoadEnergyConsumption } from "../models/loadTraining"; + +export const useHomeLoadsProfileStore = defineStore("homeLoadsProfile", () => { + const service = new HomeLoadsProfileService(); + + // State + const profiles = ref([]); + const selectedProfileId = ref(null); + + // Actions + function loadProfiles() { + return service.getProfiles().then((response) => { + profiles.value = response; + // Auto-select first profile if none selected + if (!selectedProfileId.value && response.length > 0) { + selectedProfileId.value = response[0].id ?? null; + } + }); + } + + function addProfile(name: string) { + return service.addProfile(name); + } + + function updateProfile(profileId: string, name: string) { + return service.updateProfile(profileId, name); + } + + function deleteProfile(profileId: string) { + return service.deleteProfile(profileId); + } + + function addDevice(profileId: string, device: LoadDeviceCreate) { + return service.addDevice(profileId, device); + } + + function updateDevice(profileId: string, deviceId: string, device: LoadDeviceUpdate) { + return service.updateDevice(profileId, deviceId, device); + } + + function deleteDevice(profileId: string, deviceId: string) { + return service.deleteDevice(profileId, deviceId); + } + + function getDeviceHistory( + profileId: string, + deviceId: string, + start: string, + end: string + ): Promise { + return service.getDeviceHistory(profileId, deviceId, start, end); + } + + function collectDeviceHistory( + profileId: string, + deviceId: string, + lookbackHours: number = 24 + ): Promise> { + return service.collectDeviceHistory(profileId, deviceId, lookbackHours); + } + + function clearDeviceHistory( + profileId: string, + deviceId: string + ): Promise> { + return service.clearDeviceHistory(profileId, deviceId); + } + + function getDeviceForecast( + profileId: string, + deviceId: string, + hoursAhead: number = 3, + historyHours?: number + ): Promise { + return service.getDeviceForecast(profileId, deviceId, hoursAhead, historyHours); + } + + return { + // STATE + profiles, + selectedProfileId, + // ACTIONS + loadProfiles, + addProfile, + updateProfile, + deleteProfile, + addDevice, + updateDevice, + deleteDevice, + getDeviceHistory, + collectDeviceHistory, + clearDeviceHistory, + getDeviceForecast, + }; +}); diff --git a/frontend/src/core/stores/loadTrainingStore.ts b/frontend/src/core/stores/loadTrainingStore.ts new file mode 100644 index 0000000..cbc7b24 --- /dev/null +++ b/frontend/src/core/stores/loadTrainingStore.ts @@ -0,0 +1,58 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { LoadConsumptionModel } from "../models/loadTraining"; +import { LoadTrainingService } from "../services/loadTrainingService"; + +export const useLoadTrainingStore = defineStore("loadTraining", () => { + const service = new LoadTrainingService(); + + // State + const models = ref([]); + const trainingInProgress = ref(false); + + // Actions + function loadModels(deviceId?: string) { + return service.getModels(deviceId).then((response) => { + models.value = response; + }); + } + + function triggerTrainingAll(weeksLookback: number = 8) { + trainingInProgress.value = true; + return service + .triggerTrainingAll(weeksLookback) + .finally(() => { + trainingInProgress.value = false; + }); + } + + function triggerTrainingDevice( + profileId: string, + deviceId: string, + weeksLookback: number = 8 + ) { + trainingInProgress.value = true; + return service + .triggerTrainingDevice(profileId, deviceId, weeksLookback) + .finally(() => { + trainingInProgress.value = false; + }); + } + + function deleteModel(modelId: string) { + return service.deleteModel(modelId).then(() => { + models.value = models.value.filter((m) => m.id !== modelId); + }); + } + + return { + // STATE + models, + trainingInProgress, + // ACTIONS + loadModels, + triggerTrainingAll, + triggerTrainingDevice, + deleteModel, + }; +}); diff --git a/frontend/src/core/stores/minerControllerStore.ts b/frontend/src/core/stores/minerControllerStore.ts new file mode 100644 index 0000000..1765892 --- /dev/null +++ b/frontend/src/core/stores/minerControllerStore.ts @@ -0,0 +1,49 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { MinerController, MinerControllerAdapter } from "../models/minerController"; +import { MinerControllerService } from "../services/minerControllerService"; + +export const useMinerControllerStore = defineStore("minerController", () => { + const service = new MinerControllerService(); + + // State + const minerControllers = ref([]); + const adapterTypes = ref([]); + + // Actions + function loadMinerControllers() { + return service.getMinerControllers().then((response) => { + minerControllers.value = response; + }); + } + + function loadAdapterTypes() { + return service.getAdapterTypes().then((response) => { + adapterTypes.value = response; + }); + } + + function addMinerController(minerController: MinerController) { + return service.addMinerController(minerController); + } + + function updateMinerController(controllerId: string, minerController: Partial) { + return service.updateMinerController(controllerId, minerController); + } + + function deleteMinerController(controllerId: string) { + return service.deleteMinerController(controllerId); + } + + return { + //STATE + minerControllers, + adapterTypes, + // ACTIONS + loadMinerControllers, + loadAdapterTypes, + addMinerController, + updateMinerController, + deleteMinerController, + }; +}); diff --git a/frontend/src/core/stores/minerStore.ts b/frontend/src/core/stores/minerStore.ts new file mode 100644 index 0000000..b764453 --- /dev/null +++ b/frontend/src/core/stores/minerStore.ts @@ -0,0 +1,103 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { Miner, MinerStateSnapshot } from "../models/miner"; +import { MinerService } from "../services/minerService"; + +export const useMinerStore = defineStore("miner", () => { + const service = new MinerService(); + + // State + const miners = ref([]); + const minerStates = ref>(new Map()); + + // Actions + function loadMiners() { + return service.getMiners().then((response) => { + miners.value = response; + }); + } + + function addMiner(miner: Miner) { + return service.addMiner(miner); + } + + function updateMiner(minerId: string, miner: Partial) { + return service.updateMiner(minerId, miner); + } + + function deleteMiner(minerId: string) { + return service.deleteMiner(minerId); + } + + function startMiner(minerId: string) { + return service.startMiner(minerId); + } + + function stopMiner(minerId: string) { + return service.stopMiner(minerId); + } + + function activateMiner(minerId: string) { + return service.activateMiner(minerId); + } + + function deactivateMiner(minerId: string) { + return service.deactivateMiner(minerId); + } + + function getMinerStatus(minerId: string) { + return service.getMinerStatus(minerId).then((snapshot) => { + // Store the runtime state snapshot in the map + const newMap = new Map(minerStates.value); + newMap.set(minerId, snapshot); + minerStates.value = newMap; + return snapshot; + }); + } + + function getMinerState(minerId: string): MinerStateSnapshot | undefined { + return minerStates.value.get(minerId); + } + + function setMinerController(minerId: string, controllerId: string) { + return service.setMinerController(minerId, controllerId); + } + + function unlinkMinerController(minerId: string, controllerId: string) { + return service.unlinkMinerController(minerId, controllerId); + } + + function enableFeature(minerId: string, controllerId: string, featureType: string) { + return service.enableFeature(minerId, controllerId, featureType); + } + + function disableFeature(minerId: string, controllerId: string, featureType: string) { + return service.disableFeature(minerId, controllerId, featureType); + } + + function setFeaturePriority(minerId: string, controllerId: string, featureType: string, priority: number) { + return service.setFeaturePriority(minerId, controllerId, featureType, priority); + } + + return { + //STATE + miners, + minerStates, + // ACTIONS + loadMiners, + addMiner, + updateMiner, + deleteMiner, + startMiner, + stopMiner, + activateMiner, + deactivateMiner, + getMinerStatus, + getMinerState, + setMinerController, + unlinkMinerController, + enableFeature, + disableFeature, + setFeaturePriority, + }; +}); diff --git a/frontend/src/core/stores/notifierStore.ts b/frontend/src/core/stores/notifierStore.ts new file mode 100644 index 0000000..1cd91b8 --- /dev/null +++ b/frontend/src/core/stores/notifierStore.ts @@ -0,0 +1,69 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { Notifier, ConfigSchema } from "../models/notifier"; +import { NotifierService } from "../services/notifierService"; + +export const useNotifierStore = defineStore("notifier", () => { + const service = new NotifierService(); + + // State + const notifiers = ref([]); + const adapterTypes = ref([]); + const configSchemas = ref>(new Map()); + + // Actions + function loadNotifiers() { + return service.getNotifiers().then((response) => { + notifiers.value = response; + }); + } + + function loadAdapterTypes() { + return service.getAdapterTypes().then((response) => { + adapterTypes.value = response; + }); + } + + function loadConfigSchema(adapterType: string) { + return service.getConfigSchema(adapterType).then((response) => { + configSchemas.value.set(adapterType, response); + return response; + }); + } + + function addNotifier(notifier: Notifier) { + return service.addNotifier(notifier); + } + + function updateNotifier(notifierId: string, notifier: Partial) { + return service.updateNotifier(notifierId, notifier); + } + + function deleteNotifier(notifierId: string) { + return service.deleteNotifier(notifierId); + } + + function testNotifier(notifierId: string) { + return service.testNotifier(notifierId); + } + + function externalServices(adapterType: string) { + return service.getExternalServices(adapterType); + } + + return { + // STATE + notifiers, + adapterTypes, + configSchemas, + // ACTIONS + loadNotifiers, + loadAdapterTypes, + loadConfigSchema, + addNotifier, + updateNotifier, + deleteNotifier, + testNotifier, + externalServices, + }; +}); diff --git a/frontend/src/core/stores/optimizationUnitStore.ts b/frontend/src/core/stores/optimizationUnitStore.ts new file mode 100644 index 0000000..c36dde8 --- /dev/null +++ b/frontend/src/core/stores/optimizationUnitStore.ts @@ -0,0 +1,103 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { OptimizationUnit, OptimizationUnitCreate, OptimizationUnitUpdate } from "../models/optimizationUnit"; +import { OptimizationUnitService } from "../services/optimizationUnitService"; + +export const useOptimizationUnitStore = defineStore("optimizationUnit", () => { + const service = new OptimizationUnitService(); + + // State + const optimizationUnits = ref([]); + + // Basic CRUD Actions + function loadOptimizationUnits() { + return service.getOptimizationUnits().then((response) => { + optimizationUnits.value = response; + }); + } + + function getOptimizationUnit(unitId: string) { + return service.getOptimizationUnit(unitId); + } + + function addOptimizationUnit(unit: OptimizationUnitCreate) { + return service.addOptimizationUnit(unit); + } + + function updateOptimizationUnit(unitId: string, unit: OptimizationUnitUpdate) { + return service.updateOptimizationUnit(unitId, unit); + } + + function deleteOptimizationUnit(unitId: string) { + return service.deleteOptimizationUnit(unitId); + } + + // Enable/Disable Actions + function enableOptimizationUnit(unitId: string) { + return service.enableOptimizationUnit(unitId); + } + + function disableOptimizationUnit(unitId: string) { + return service.disableOptimizationUnit(unitId); + } + + // Assignment Actions + function assignEnergySource(unitId: string, energySourceId: string) { + return service.assignEnergySource(unitId, energySourceId); + } + + function assignPolicy(unitId: string, policyId: string) { + return service.assignPolicy(unitId, policyId); + } + + // Miner Actions + function assignMiners(unitId: string, minerIds: string[]) { + return service.assignMiners(unitId, minerIds); + } + + function addMiner(unitId: string, minerId: string) { + return service.addMiner(unitId, minerId); + } + + function removeMiner(unitId: string, minerId: string) { + return service.removeMiner(unitId, minerId); + } + + // Notifier Actions + function assignNotifiers(unitId: string, notifierIds: string[]) { + return service.assignNotifiers(unitId, notifierIds); + } + + function addNotifier(unitId: string, notifierId: string) { + return service.addNotifier(unitId, notifierId); + } + + function removeNotifier(unitId: string, notifierId: string) { + return service.removeNotifier(unitId, notifierId); + } + + return { + // STATE + optimizationUnits, + // CRUD ACTIONS + loadOptimizationUnits, + getOptimizationUnit, + addOptimizationUnit, + updateOptimizationUnit, + deleteOptimizationUnit, + // ENABLE/DISABLE ACTIONS + enableOptimizationUnit, + disableOptimizationUnit, + // ASSIGNMENT ACTIONS + assignEnergySource, + assignPolicy, + // MINER ACTIONS + assignMiners, + addMiner, + removeMiner, + // NOTIFIER ACTIONS + assignNotifiers, + addNotifier, + removeNotifier, + }; +}); diff --git a/frontend/src/core/stores/performanceTrackerStore.ts b/frontend/src/core/stores/performanceTrackerStore.ts new file mode 100644 index 0000000..a8ada12 --- /dev/null +++ b/frontend/src/core/stores/performanceTrackerStore.ts @@ -0,0 +1,60 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { + PerformanceTracker, + MiningPerformanceTrackerAdapter, +} from "../models/performanceTracker"; +import { PerformanceTrackerService } from "../services/performanceTrackerService"; + +export const usePerformanceTrackerStore = defineStore("performanceTracker", () => { + const service = new PerformanceTrackerService(); + + // State + const performanceTrackers = ref([]); + const adapterTypes = ref([]); + + // Actions + function loadPerformanceTrackers() { + return service.getPerformanceTrackers().then((response) => { + performanceTrackers.value = response; + }); + } + + function loadAdapterTypes() { + return service.getAdapterTypes().then((response) => { + adapterTypes.value = response; + }); + } + + function addPerformanceTracker(tracker: PerformanceTracker) { + return service.addPerformanceTracker(tracker); + } + + function updatePerformanceTracker( + trackerId: string, + tracker: Partial + ) { + return service.updatePerformanceTracker(trackerId, tracker); + } + + function deletePerformanceTracker(trackerId: string) { + return service.deletePerformanceTracker(trackerId); + } + + function testPerformanceTracker(trackerId: string) { + return service.testPerformanceTracker(trackerId); + } + + return { + // STATE + performanceTrackers, + adapterTypes, + // ACTIONS + loadPerformanceTrackers, + loadAdapterTypes, + addPerformanceTracker, + updatePerformanceTracker, + deletePerformanceTracker, + testPerformanceTracker, + }; +}); diff --git a/frontend/src/core/stores/policyStore.ts b/frontend/src/core/stores/policyStore.ts new file mode 100644 index 0000000..2a6940d --- /dev/null +++ b/frontend/src/core/stores/policyStore.ts @@ -0,0 +1,108 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { OptimizationPolicy, AutomationRule, PolicyCheckResult, RuleType, DecisionalContextStructure } from "../models/policy"; +import { PolicyService } from "../services/policyService"; + +export const usePolicyStore = defineStore("policy", () => { + const service = new PolicyService(); + + // State + const policies = ref([]); + const selectedPolicy = ref(null); + const checkResult = ref(null); + const decisionalContextStructure = ref(null); + + // Policy Actions + function loadPolicies() { + return service.getPolicies().then((response) => { + policies.value = response; + }); + } + + function loadPolicy(policyId: string) { + return service.getPolicy(policyId).then((response) => { + selectedPolicy.value = response; + return response; + }); + } + + function addPolicy(policy: OptimizationPolicy) { + return service.addPolicy(policy); + } + + function updatePolicy(policyId: string, policy: Partial) { + return service.updatePolicy(policyId, policy); + } + + function deletePolicy(policyId: string) { + return service.deletePolicy(policyId); + } + + function checkPolicy(policyId: string) { + return service.checkPolicy(policyId).then((response) => { + checkResult.value = response; + return response; + }); + } + + // Rule Actions + function addRule(policyId: string, ruleType: RuleType, rule: AutomationRule) { + return service.addRule(policyId, ruleType, rule); + } + + function getRulesByType(policyId: string, ruleType: string) { + return service.getRulesByType(policyId, ruleType); + } + + function getRule(policyId: string, ruleId: string) { + return service.getRule(policyId, ruleId); + } + + function updateRule(policyId: string, ruleId: string, rule: Partial) { + return service.updateRule(policyId, ruleId, rule); + } + + function deleteRule(policyId: string, ruleId: string) { + return service.deleteRule(policyId, ruleId); + } + + function enableRule(policyId: string, ruleId: string) { + return service.enableRule(policyId, ruleId); + } + + function disableRule(policyId: string, ruleId: string) { + return service.disableRule(policyId, ruleId); + } + + function loadDecisionalContextStructure() { + return service.getDecisionalContextStructure().then((response) => { + decisionalContextStructure.value = response; + return response; + }); + } + + return { + // STATE + policies, + selectedPolicy, + checkResult, + decisionalContextStructure, + // POLICY ACTIONS + loadPolicies, + loadPolicy, + addPolicy, + updatePolicy, + deletePolicy, + checkPolicy, + // RULE ACTIONS + addRule, + getRulesByType, + getRule, + updateRule, + deleteRule, + enableRule, + disableRule, + // DECISIONAL CONTEXT ACTIONS + loadDecisionalContextStructure, + }; +}); diff --git a/frontend/src/core/stores/ruleEngineStore.ts b/frontend/src/core/stores/ruleEngineStore.ts new file mode 100644 index 0000000..a79a36d --- /dev/null +++ b/frontend/src/core/stores/ruleEngineStore.ts @@ -0,0 +1,52 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { + RuleEngineConfig, + RuleEngineInfo, + RuleEvaluationRequest, + RuleValidationRequest, + RuleValidationResult, +} from "../models/ruleEngine"; +import { RuleEngineService } from "../services/ruleEngineService"; + +export const useRuleEngineStore = defineStore("ruleEngine", () => { + const service = new RuleEngineService(); + + // State + const config = ref(null); + const info = ref(null); + + // Actions + function loadConfig() { + return service.getConfig().then((response) => { + config.value = response; + return response; + }); + } + + function loadInfo() { + return service.getInfo().then((response) => { + info.value = response; + return response; + }); + } + + function evaluate(request: RuleEvaluationRequest): Promise { + return service.evaluate(request); + } + + function validate(request: RuleValidationRequest): Promise { + return service.validate(request); + } + + return { + // STATE + config, + info, + // ACTIONS + loadConfig, + loadInfo, + evaluate, + validate, + }; +}); diff --git a/frontend/src/core/utils/formatters.ts b/frontend/src/core/utils/formatters.ts new file mode 100644 index 0000000..e79d42c --- /dev/null +++ b/frontend/src/core/utils/formatters.ts @@ -0,0 +1,78 @@ +export function formatTimeAgo(date: string | Date, compact = true): string { + const diffMs = Date.now() - new Date(date).getTime(); + if (diffMs < 0) return 'just now'; + const seconds = Math.floor(diffMs / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + if (compact) { + if (hours > 24) { + const days = Math.floor(hours / 24); + return `~${days}d ago`; + } + if (hours > 0) return `~${hours}h ago`; + if (minutes > 0) return `~${minutes}min ago`; + return `${seconds}s ago`; + } + if (hours > 0) { + const remMin = minutes % 60; + return remMin > 0 ? `${hours}h and ${remMin}min ago` : `${hours}h ago`; + } + if (minutes > 0) { + const remSec = seconds % 60; + return remSec > 0 ? `${minutes}min and ${remSec}s ago` : `${minutes}min ago`; + } + return `${seconds}s ago`; +} + +export function formatType(type: string): string { + return type + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +export function formatPower(watts: number | undefined): string { + if (watts === undefined || watts === null) return "-"; + if (watts >= 1000000) return `${(watts / 1000000).toFixed(1)} MW`; + if (watts >= 1000) return `${(watts / 1000).toFixed(1)} kW`; + return `${watts} W`; +} + +export function formatCapacity(wh: number | undefined): string { + if (wh === undefined || wh === null) return "-"; + if (wh >= 1000000) return `${(wh / 1000000).toFixed(1)} MWh`; + if (wh >= 1000) return `${(wh / 1000).toFixed(1)} kWh`; + return `${wh} Wh`; +} + +export function formatHashRate(value?: number, unit?: string): string { + if (!value) return "-"; + return `${value} ${unit || ""}`; +} + +export function normalizeHashRate(value: number, unit: string): number { + const multipliers: Record = { + "H/s": 1e-12, + "KH/s": 1e-9, + "MH/s": 1e-6, + "GH/s": 1e-3, + "TH/s": 1, + "PH/s": 1e3, + "EH/s": 1e6, + }; + return value * (multipliers[unit] || 1); +} + +export function formatDate(date?: string): string { + if (!date) return ""; + try { + return new Date(date).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); + } catch { + return date; + } +} + diff --git a/frontend/src/core/utils/index.ts b/frontend/src/core/utils/index.ts new file mode 100644 index 0000000..96552da --- /dev/null +++ b/frontend/src/core/utils/index.ts @@ -0,0 +1 @@ +export * from "./formatters"; diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..c01bdd7 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,14 @@ +import { createApp } from "vue"; +import "@fontsource/plus-jakarta-sans/400.css"; +import "@fontsource/plus-jakarta-sans/500.css"; +import "@fontsource/plus-jakarta-sans/600.css"; +import "@fontsource/plus-jakarta-sans/700.css"; +import "./style.css"; +import App from "./App.vue"; +import router from "./router"; +import { useAppStore } from "./core/stores/appStore"; +import { createPinia } from "pinia"; + +createApp(App).use(router).use(createPinia()).mount("#app"); + +useAppStore(); // Initialize the AppStore diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..cf7b0d9 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,107 @@ +import { createRouter, createWebHistory } from "vue-router"; +import DashboardView from "../views/DashboardView.vue"; +import HomeLoadsDashboardView from "../views/dashboard/HomeLoadsDashboardView.vue"; +import MiningDashboardView from "../views/dashboard/MiningDashboardView.vue"; +import MinersSettingsView from "../views/settings/MinersSettingsView.vue"; +import EnergySourcesSettingsView from "../views/settings/EnergySourcesSettingsView.vue"; +import EnergyMonitorSettingsView from "../views/settings/EnergyMonitorSettingsView.vue"; +import MinerControllersSettingsView from "../views/settings/MinerControllersSettingsView.vue"; +import PerformanceTrackersSettingsView from "../views/settings/PerformanceTrackersSettingsView.vue"; +import ForecastProvidersSettingsView from "../views/settings/ForecastProvidersSettingsView.vue"; +import PoliciesSettingsView from "../views/settings/PoliciesSettingsView.vue"; +import NotifiersSettingsView from "../views/settings/NotifiersSettingsView.vue"; +import ExternalServicesSettingsView from "../views/settings/ExternalServicesSettingsView.vue"; +import OptimizationUnitsSettingsView from "../views/settings/OptimizationUnitsSettingsView.vue"; +import HomeLoadsSettingsView from "../views/settings/HomeLoadsSettingsView.vue"; +import HomeLoadsTrainingView from "../views/settings/HomeLoadsTrainingView.vue"; + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: "/", + name: "dashboard", + // This is the default route, it will be replaced by the setHomeRoute function + component: DashboardView, + }, + { + path: "/dashboard/mining", + name: "dashboard.mining", + component: MiningDashboardView, + }, + { + path: "/dashboard/home-loads", + name: "dashboard.homeLoads", + component: HomeLoadsDashboardView, + }, + { + path: "/settings/", + name: "settings", + redirect: "/settings/miners", + children: [ + { + path: "miners", + name: "settings.miners", + component: MinersSettingsView, + }, + { + path: "energy-sources", + name: "settings.energySources", + component: EnergySourcesSettingsView, + }, + { + path: "energy-monitors", + name: "settings.energyMonitors", + component: EnergyMonitorSettingsView, + }, + { + path: "miner-controllers", + name: "settings.minerControllers", + component: MinerControllersSettingsView, + }, + { + path: "performance-trackers", + name: "settings.performanceTrackers", + component: PerformanceTrackersSettingsView, + }, + { + path: "forecast-providers", + name: "settings.forecastProviders", + component: ForecastProvidersSettingsView, + }, + { + path: "policies", + name: "settings.policies", + component: PoliciesSettingsView, + }, + { + path: "optimization-units", + name: "settings.optimizationUnits", + component: OptimizationUnitsSettingsView, + }, + { + path: "notifiers", + name: "settings.notifiers", + component: NotifiersSettingsView, + }, + { + path: "external-services", + name: "settings.externalServices", + component: ExternalServicesSettingsView, + }, + { + path: "home-loads", + name: "settings.homeLoads", + component: HomeLoadsSettingsView, + }, + { + path: "home-loads-training", + name: "settings.homeLoadsTraining", + component: HomeLoadsTrainingView, + }, + ], + }, + ], +}); + +export default router; diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..92d3b70 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,109 @@ +@import "tailwindcss"; +@plugin "daisyui"; + +@theme { + --font-sans: "Plus Jakarta Sans", sans-serif; +} + +@plugin "daisyui/theme" { + name: "edge"; + default: true; + prefersdark: true; + color-scheme: "dark"; + --color-base-100: oklch(25% 0 0); + --color-base-200: oklch(35% 0 0); + --color-base-300: oklch(50% 0 0); + --color-base-content: #e8e8e8; + --color-primary: #beffa3; + --color-primary-content: #0c0c0c; + --color-secondary: #f4b860; + --color-secondary-content: oklch(13.955% 0.027 168.327); + --color-accent: #fe5f55; + --color-accent-content: #0c0c0c; + --color-neutral: oklch(43% 0 0); + --color-neutral-content: #fffaff; + --color-info: #336699; + --color-info-content: #e8e8e8; + --color-success: #4caf50; + --color-success-content: #0c0c0c; + --color-warning: #f4b860; + --color-warning-content: #0c0c0c; + --color-error: #fe5f55; + --color-error-content: #0c0c0c; + --radius-selector: 2rem; + --radius-field: 0.5rem; + --radius-box: 0.5rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 0; + --noise: 0; +} + +.flenter { + display: flex; + justify-content: center; + align-items: center; +} + +/* Keep DaisyUI tooltip content on a single line (IDs, tokens, etc.) */ +.id-tooltip::before, +.id-tooltip::after { + white-space: nowrap; + overflow-wrap: normal; + word-break: normal; + max-width: min(70vw, 36rem); + overflow: hidden; + text-overflow: ellipsis; +} + +/* Card styling - refined dark theme */ +.card { + @apply bg-base-100 border border-base-300/40 rounded-box; +} + +.card-header { + @apply px-5 pt-5 pb-3; +} + +.card-header h2, +.card-header .card-title { + @apply text-lg font-semibold tracking-tight text-base-content m-0; +} + +.card-body { + @apply px-5 pb-5 pt-0; +} + +/* Modal styling - enhanced visibility */ +.modal-backdrop { + background-color: oklch(0% 0 0 / 0.7); + backdrop-filter: blur(4px); +} + +.modal-box { + @apply bg-base-100 border border-base-300/60; + box-shadow: + 0 0 0 1px oklch(100% 0 0 / 0.05), + 0 4px 6px -1px oklch(0% 0 0 / 0.3), + 0 10px 15px -3px oklch(0% 0 0 / 0.4), + 0 20px 25px -5px oklch(0% 0 0 / 0.3); +} + +.marquee-on-overflow { + display: inline-block; + white-space: nowrap; +} + +.badge:has(.marquee-on-overflow) { + justify-content: flex-start; +} + +.overflow-hidden:hover .marquee-on-overflow { + animation: marquee-bounce 3s ease-in-out infinite alternate; +} + +@keyframes marquee-bounce { + 0%, 20% { transform: translateX(0); } + 80%, 100% { transform: translateX(min(0px, calc(-100% + 9rem))); } +} diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue new file mode 100644 index 0000000..8c6b930 --- /dev/null +++ b/frontend/src/views/DashboardView.vue @@ -0,0 +1,597 @@ + + + + + diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue new file mode 100644 index 0000000..8aabe55 --- /dev/null +++ b/frontend/src/views/SettingsView.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/src/views/dashboard/HomeLoadsDashboardView.vue b/frontend/src/views/dashboard/HomeLoadsDashboardView.vue new file mode 100644 index 0000000..d52331c --- /dev/null +++ b/frontend/src/views/dashboard/HomeLoadsDashboardView.vue @@ -0,0 +1,712 @@ + + + diff --git a/frontend/src/views/dashboard/MiningDashboardView.vue b/frontend/src/views/dashboard/MiningDashboardView.vue new file mode 100644 index 0000000..c929f8f --- /dev/null +++ b/frontend/src/views/dashboard/MiningDashboardView.vue @@ -0,0 +1,337 @@ + + + + + diff --git a/frontend/src/views/settings/EnergyMonitorSettingsView.vue b/frontend/src/views/settings/EnergyMonitorSettingsView.vue new file mode 100644 index 0000000..ed1a88d --- /dev/null +++ b/frontend/src/views/settings/EnergyMonitorSettingsView.vue @@ -0,0 +1,343 @@ + + + + + diff --git a/frontend/src/views/settings/EnergySourcesSettingsView.vue b/frontend/src/views/settings/EnergySourcesSettingsView.vue new file mode 100644 index 0000000..a71c904 --- /dev/null +++ b/frontend/src/views/settings/EnergySourcesSettingsView.vue @@ -0,0 +1,340 @@ + + + + + diff --git a/frontend/src/views/settings/ExternalServicesSettingsView.vue b/frontend/src/views/settings/ExternalServicesSettingsView.vue new file mode 100644 index 0000000..a7ecfd1 --- /dev/null +++ b/frontend/src/views/settings/ExternalServicesSettingsView.vue @@ -0,0 +1,355 @@ + + + + + diff --git a/frontend/src/views/settings/ForecastProvidersSettingsView.vue b/frontend/src/views/settings/ForecastProvidersSettingsView.vue new file mode 100644 index 0000000..13d60bf --- /dev/null +++ b/frontend/src/views/settings/ForecastProvidersSettingsView.vue @@ -0,0 +1,340 @@ + + + + + diff --git a/frontend/src/views/settings/HomeLoadsSettingsView.vue b/frontend/src/views/settings/HomeLoadsSettingsView.vue new file mode 100644 index 0000000..3977abd --- /dev/null +++ b/frontend/src/views/settings/HomeLoadsSettingsView.vue @@ -0,0 +1,547 @@ + + + + + diff --git a/frontend/src/views/settings/HomeLoadsTrainingView.vue b/frontend/src/views/settings/HomeLoadsTrainingView.vue new file mode 100644 index 0000000..9de4493 --- /dev/null +++ b/frontend/src/views/settings/HomeLoadsTrainingView.vue @@ -0,0 +1,62 @@ + + + diff --git a/frontend/src/views/settings/MinerControllersSettingsView.vue b/frontend/src/views/settings/MinerControllersSettingsView.vue new file mode 100644 index 0000000..118662b --- /dev/null +++ b/frontend/src/views/settings/MinerControllersSettingsView.vue @@ -0,0 +1,333 @@ + + + + + diff --git a/frontend/src/views/settings/MinersSettingsView.vue b/frontend/src/views/settings/MinersSettingsView.vue new file mode 100644 index 0000000..3e50c30 --- /dev/null +++ b/frontend/src/views/settings/MinersSettingsView.vue @@ -0,0 +1,437 @@ + + + + + diff --git a/frontend/src/views/settings/NotifiersSettingsView.vue b/frontend/src/views/settings/NotifiersSettingsView.vue new file mode 100644 index 0000000..1b2f4a7 --- /dev/null +++ b/frontend/src/views/settings/NotifiersSettingsView.vue @@ -0,0 +1,413 @@ + + + + + diff --git a/frontend/src/views/settings/OptimizationUnitsSettingsView.vue b/frontend/src/views/settings/OptimizationUnitsSettingsView.vue new file mode 100644 index 0000000..af0ae66 --- /dev/null +++ b/frontend/src/views/settings/OptimizationUnitsSettingsView.vue @@ -0,0 +1,298 @@ + + + + + diff --git a/frontend/src/views/settings/PerformanceTrackersSettingsView.vue b/frontend/src/views/settings/PerformanceTrackersSettingsView.vue new file mode 100644 index 0000000..63ef350 --- /dev/null +++ b/frontend/src/views/settings/PerformanceTrackersSettingsView.vue @@ -0,0 +1,361 @@ + + + + + diff --git a/frontend/src/views/settings/PoliciesSettingsView.vue b/frontend/src/views/settings/PoliciesSettingsView.vue new file mode 100644 index 0000000..2b36baa --- /dev/null +++ b/frontend/src/views/settings/PoliciesSettingsView.vue @@ -0,0 +1,1490 @@ + + + + + diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..3dbbc45 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,15 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..d47632b --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "resolveJsonModule": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..977d653 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import tailwindcss from "@tailwindcss/vite"; +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue(), tailwindcss()], + server: { + proxy: { + "/api": { + target: "http://localhost:8001", + changeOrigin: true, + }, + "/docs": { + target: "http://localhost:8001", + changeOrigin: true, + }, + "/openapi.json": { + target: "http://localhost:8001", + changeOrigin: true, + }, + }, + }, +}); diff --git a/nginx.conf b/nginx.conf index 9a68a52..3d98517 100644 --- a/nginx.conf +++ b/nginx.conf @@ -63,21 +63,5 @@ http { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } - - # Proxy core version endpoint to backend - location = /version/core { - proxy_pass http://backend_service/version/core; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location = /version/app { - alias /usr/share/nginx/html/version.json; - default_type application/json; - add_header Cache-Control "no-cache, no-store, must-revalidate"; - } } } diff --git a/first_start.sh b/scripts/first_start.sh similarity index 90% rename from first_start.sh rename to scripts/first_start.sh index 845a36d..0655430 100755 --- a/first_start.sh +++ b/scripts/first_start.sh @@ -9,8 +9,9 @@ YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' # No Color -# Move to repo root (directory of this script) -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Move to repo root (parent of scripts/) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$ROOT_DIR" echo -e "${CYAN}${BOLD}============================================${NC}" @@ -20,7 +21,7 @@ echo "" # Initialize user_data structure and default files echo -e "${YELLOW}>> Initializing user data...${NC}" -./init_user_data.sh +"$SCRIPT_DIR/init_user_data.sh" echo "" echo -e "${BOLD}This will build the Docker images and start the application.${NC}" diff --git a/init_user_data.sh b/scripts/init_user_data.sh similarity index 97% rename from init_user_data.sh rename to scripts/init_user_data.sh index 44e985f..c2b74e7 100755 --- a/init_user_data.sh +++ b/scripts/init_user_data.sh @@ -8,7 +8,7 @@ YELLOW='\033[1;33m' DIM='\033[2m' NC='\033[0m' # No Color -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" APP_CORE_DIR="$ROOT_DIR/core" APP_CORE_DATA_DIR="$APP_CORE_DIR/data" APP_USER_DATA_DIR="$ROOT_DIR/user_data" diff --git a/switch_branch.sh b/scripts/switch_branch.sh similarity index 94% rename from switch_branch.sh rename to scripts/switch_branch.sh index 2d3039f..1fb41cc 100755 --- a/switch_branch.sh +++ b/scripts/switch_branch.sh @@ -76,11 +76,8 @@ fi echo "" echo -e "${YELLOW}>> Switching to branch '$TARGET_BRANCH'...${NC}" -# Switch branch with submodule update -git switch "$TARGET_BRANCH" --recurse-submodules - -# Ensure submodules are fully in sync -git submodule update --init --recursive +# Switch branch +git switch "$TARGET_BRANCH" # Initialize user_data structure and default files echo -e "${YELLOW}>> Initializing user data...${NC}" diff --git a/update.sh b/scripts/update.sh similarity index 92% rename from update.sh rename to scripts/update.sh index 79646fc..7918421 100755 --- a/update.sh +++ b/scripts/update.sh @@ -35,11 +35,9 @@ if [ -n "$RUNNING_CONTAINERS" ]; then echo "" fi -# Pull latest changes and update submodules (--init handles newly added submodules) +# Pull latest changes echo -e "${YELLOW}>> Pulling latest changes...${NC}" git pull -echo -e "${YELLOW}>> Updating submodules...${NC}" -git submodule update --init --recursive # Initialize user_data structure and default files echo -e "${YELLOW}>> Initializing user data...${NC}"