From f3a387e5fbad24b6b76e095a12c0b4c1b08c79de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:20:26 +0000 Subject: [PATCH 1/5] Initial plan From 2d5eb3e76652905fdd718cb057baf48d71461155 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:26:31 +0000 Subject: [PATCH 2/5] Add trigger_forecast, get_forecast, and trigger_and_get_forecast methods Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> --- src/flexmeasures_client/client.py | 206 ++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index c28ca854..d2e8cfc0 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -1265,6 +1265,212 @@ async def trigger_schedule( self.logger.info(f"Schedule triggered successfully. Schedule ID: {schedule_id}") return schedule_id + async def trigger_forecast( + self, + sensor_id: int, + start: str | datetime | None = None, + end: str | datetime | None = None, + duration: str | timedelta | None = None, + train_start: str | datetime | None = None, + train_period: str | timedelta | None = None, + max_training_period: str | timedelta | None = None, + retrain_frequency: str | timedelta | None = None, + future_regressors: list[int] | None = None, + past_regressors: list[int] | None = None, + regressors: list[int] | None = None, + max_forecast_horizon: str | timedelta | None = None, + forecast_frequency: str | timedelta | None = None, + probabilistic: bool | None = None, + ) -> str: + """Trigger a forecasting job for the given sensor. + + :param sensor_id: ID of the sensor to forecast. + :param start: Start date for predictions (ISO 8601 datetime string or datetime). + :param end: End date of the forecast period (ISO 8601 datetime string or datetime). + :param duration: Duration of the forecast period (ISO 8601 duration or timedelta). + Provide at most two of start, end, and duration. + :param train_start: Start date of historical training data. + :param train_period: Duration of the training period (min 2 days). + :param max_training_period: Maximum duration of the training period. Defaults to 1 year. + :param retrain_frequency: How often to retrain (ISO 8601 duration or timedelta). + :param future_regressors: List of sensor IDs used only as future regressors. + :param past_regressors: List of sensor IDs used only as past regressors. + :param regressors: List of sensor IDs used as both past and future regressors. + :param max_forecast_horizon: Maximum forecast horizon (ISO 8601 duration or timedelta). + :param forecast_frequency: How often to recompute forecasts (ISO 8601 duration or timedelta). + :param probabilistic: If True, enable probabilistic forecasting. + + :returns: Forecast job UUID (string). + + This function raises a ValueError when an unhandled status code is returned. + """ + json_payload: dict[str, Any] = {} + + if start is not None: + json_payload["start"] = pd.Timestamp(start).isoformat() + if end is not None: + json_payload["end"] = pd.Timestamp(end).isoformat() + if duration is not None: + json_payload["duration"] = pd.Timedelta(duration).isoformat() + if max_forecast_horizon is not None: + json_payload["max-forecast-horizon"] = pd.Timedelta( + max_forecast_horizon + ).isoformat() + if forecast_frequency is not None: + json_payload["forecast-frequency"] = pd.Timedelta( + forecast_frequency + ).isoformat() + if probabilistic is not None: + json_payload["probabilistic"] = probabilistic + + # Build config sub-dict for training and regressor parameters + config: dict[str, Any] = {} + if train_start is not None: + config["train-start"] = pd.Timestamp(train_start).isoformat() + if train_period is not None: + config["train-period"] = pd.Timedelta(train_period).isoformat() + if max_training_period is not None: + config["max-training-period"] = pd.Timedelta( + max_training_period + ).isoformat() + if retrain_frequency is not None: + config["retrain-frequency"] = pd.Timedelta(retrain_frequency).isoformat() + if future_regressors is not None: + config["future-regressors"] = future_regressors + if past_regressors is not None: + config["past-regressors"] = past_regressors + if regressors is not None: + config["regressors"] = regressors + if config: + json_payload["config"] = config + + response, status = await self.request( + uri=f"sensors/{sensor_id}/forecasts/trigger", + json_payload=json_payload, + method="POST", + minimum_server_version="0.31.0", + ) + check_for_status(status, 200) + + if not isinstance(response, dict): + raise ContentTypeError( + f"Expected a dictionary, but got {type(response)}", + ) + + if not isinstance(response.get("forecast"), str): + raise ContentTypeError( + f"Expected a forecast ID, but got {type(response.get('forecast'))}", + ) + forecast_id = response["forecast"] + self.logger.info( + f"Forecast triggered successfully. Forecast ID: {forecast_id}" + ) + return forecast_id + + async def get_forecast( + self, + sensor_id: int, + forecast_id: str, + ) -> dict: + """Get forecast with given ID. + + Polls the server until the forecasting job is complete and returns the result. + + :returns: forecast as dictionary, for example: + { + 'values': [1.2, 1.5, 1.4, 0.8], + 'start': '2025-10-15T00:00:00+01:00', + 'duration': 'PT4H', + 'unit': 'kW' + } + + This function raises a ValueError when an unhandled status code is returned. + """ + polling_step = 0 + try: + async with async_timeout.timeout(self.polling_timeout): + while polling_step < self.max_polling_steps: + forecast, status = await self.request( + uri=f"sensors/{sensor_id}/forecasts/{forecast_id}", + method="GET", + minimum_server_version="0.31.0", + ) + if status == 200: + if not isinstance(forecast, dict): + raise ContentTypeError( + f"Expected a forecast dictionary, but got {type(forecast)}", + ) + return forecast + elif status == 202: + job_status = ( + forecast.get("status", "unknown") + if isinstance(forecast, dict) + else "unknown" + ) + message = f"Forecast job status: {job_status}. Polling step: {polling_step}. Retrying in {self.polling_interval} seconds..." + self.logger.debug(message) + polling_step += 1 + await asyncio.sleep(self.polling_interval) + else: + check_for_status(status, 200) + except asyncio.TimeoutError as exception: + raise ConnectionError( + "Client polling timeout while waiting for forecast job to complete." + ) from exception + raise ConnectionError( + "Max polling steps reached while waiting for forecast job to complete." + ) + + async def trigger_and_get_forecast( + self, + sensor_id: int, + start: str | datetime | None = None, + end: str | datetime | None = None, + duration: str | timedelta | None = None, + train_start: str | datetime | None = None, + train_period: str | timedelta | None = None, + max_training_period: str | timedelta | None = None, + retrain_frequency: str | timedelta | None = None, + future_regressors: list[int] | None = None, + past_regressors: list[int] | None = None, + regressors: list[int] | None = None, + max_forecast_horizon: str | timedelta | None = None, + forecast_frequency: str | timedelta | None = None, + probabilistic: bool | None = None, + ) -> dict: + """Trigger a forecasting job and then fetch the result. + + :returns: forecast as dictionary, for example: + { + 'values': [1.2, 1.5, 1.4, 0.8], + 'start': '2025-10-15T00:00:00+01:00', + 'duration': 'PT4H', + 'unit': 'kW' + } + + This function raises a ValueError when an unhandled status code is returned. + """ + forecast_id = await self.trigger_forecast( + sensor_id=sensor_id, + start=start, + end=end, + duration=duration, + train_start=train_start, + train_period=train_period, + max_training_period=max_training_period, + retrain_frequency=retrain_frequency, + future_regressors=future_regressors, + past_regressors=past_regressors, + regressors=regressors, + max_forecast_horizon=max_forecast_horizon, + forecast_frequency=forecast_frequency, + probabilistic=probabilistic, + ) + return await self.get_forecast( + sensor_id=sensor_id, + forecast_id=forecast_id, + ) + @staticmethod def create_storage_flex_model( soc_unit: str, From 0a7f2d9619fa042c426b552444f961bc89473c43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:33:22 +0000 Subject: [PATCH 3/5] Add tests for trigger_forecast, get_forecast, and trigger_and_get_forecast Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> --- tests/test_client.py | 189 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index adcea5e8..8fdf292f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1016,3 +1016,192 @@ async def test_trigger_schedule_with_custom_scheduler() -> None: ) await flexmeasures_client.close() + + +@pytest.mark.asyncio +async def test_trigger_forecast() -> None: + """Test triggering a forecast with basic parameters.""" + with aioresponses() as m: + flexmeasures_client = FlexMeasuresClient( + email="test@test.test", password="test" + ) + flexmeasures_client.access_token = "test-token" + + sensor_id = 1 + m.post( + f"http://localhost:5000/api/v3_0/sensors/{sensor_id}/forecasts/trigger", + status=200, + payload={"forecast": "test-forecast-uuid"}, + ) + + forecast_id = await flexmeasures_client.trigger_forecast( + sensor_id=sensor_id, + start="2025-01-05T00:00:00+00:00", + end="2025-01-07T00:00:00+00:00", + ) + + assert forecast_id == "test-forecast-uuid" + + m.assert_called_once_with( + f"http://localhost:5000/api/v3_0/sensors/{sensor_id}/forecasts/trigger", + method="POST", + headers={"Content-Type": "application/json", "Authorization": "test-token"}, + json={ + "start": "2025-01-05T00:00:00+00:00", + "end": "2025-01-07T00:00:00+00:00", + }, + params=None, + ssl=False, + allow_redirects=False, + ) + + await flexmeasures_client.close() + + +@pytest.mark.asyncio +async def test_trigger_forecast_with_config() -> None: + """Test triggering a forecast with training config parameters.""" + with aioresponses() as m: + flexmeasures_client = FlexMeasuresClient( + email="test@test.test", password="test" + ) + flexmeasures_client.access_token = "test-token" + + sensor_id = 1 + m.post( + f"http://localhost:5000/api/v3_0/sensors/{sensor_id}/forecasts/trigger", + status=200, + payload={"forecast": "test-forecast-uuid"}, + ) + + forecast_id = await flexmeasures_client.trigger_forecast( + sensor_id=sensor_id, + start="2025-01-05T00:00:00+00:00", + end="2025-01-07T00:00:00+00:00", + train_start="2025-01-01T00:00:00+00:00", + retrain_frequency="PT24H", + future_regressors=[2, 3], + ) + + assert forecast_id == "test-forecast-uuid" + + m.assert_called_once_with( + f"http://localhost:5000/api/v3_0/sensors/{sensor_id}/forecasts/trigger", + method="POST", + headers={"Content-Type": "application/json", "Authorization": "test-token"}, + json={ + "start": "2025-01-05T00:00:00+00:00", + "end": "2025-01-07T00:00:00+00:00", + "config": { + "train-start": "2025-01-01T00:00:00+00:00", + "retrain-frequency": "P1DT0H0M0S", + "future-regressors": [2, 3], + }, + }, + params=None, + ssl=False, + allow_redirects=False, + ) + + await flexmeasures_client.close() + + +@pytest.mark.asyncio +async def test_get_forecast_polling() -> None: + """Test getting a forecast with polling (202 -> 202 -> 200).""" + sensor_id = 1 + forecast_id = "test-uuid" + url = f"http://localhost:5000/api/v3_0/sensors/{sensor_id}/forecasts/{forecast_id}" + + with aioresponses() as m: + # First call returns 202 (QUEUED) + m.get( + url=url, + status=202, + payload={"status": "QUEUED"}, + ) + # Second call returns 202 (STARTED) + m.get( + url=url, + status=202, + payload={"status": "STARTED"}, + ) + # Third call returns 200 (completed) + m.get( + url=url, + status=200, + payload={ + "values": [1.2, 1.5], + "start": "2025-01-05T00:00:00+00:00", + "duration": "PT2H", + "unit": "kW", + }, + ) + + flexmeasures_client = FlexMeasuresClient( + email="test@test.test", + password="test", + request_timeout=2, + polling_interval=0.2, + access_token="skip-auth", + ) + + forecast = await flexmeasures_client.get_forecast( + sensor_id=sensor_id, forecast_id=forecast_id + ) + + assert forecast["values"] == [1.2, 1.5] + assert forecast["start"] == "2025-01-05T00:00:00+00:00" + assert forecast["duration"] == "PT2H" + assert forecast["unit"] == "kW" + + await flexmeasures_client.close() + + +@pytest.mark.asyncio +async def test_trigger_and_get_forecast() -> None: + """Test triggering and getting a forecast in one call.""" + with aioresponses() as m: + flexmeasures_client = FlexMeasuresClient( + email="test@test.test", + password="test", + request_timeout=2, + polling_interval=0.2, + ) + flexmeasures_client.access_token = "test-token" + + sensor_id = 1 + forecast_uuid = "test-forecast-uuid" + + # Mock the trigger request + m.post( + f"http://localhost:5000/api/v3_0/sensors/{sensor_id}/forecasts/trigger", + status=200, + payload={"forecast": forecast_uuid}, + ) + + # Mock the get forecast request (with immediate 200 response) + m.get( + f"http://localhost:5000/api/v3_0/sensors/{sensor_id}/forecasts/{forecast_uuid}", + status=200, + payload={ + "values": [1.2, 1.5, 1.8], + "start": "2025-01-05T00:00:00+00:00", + "duration": "PT3H", + "unit": "kW", + }, + ) + + forecast = await flexmeasures_client.trigger_and_get_forecast( + sensor_id=sensor_id, + start="2025-01-05T00:00:00+00:00", + end="2025-01-07T00:00:00+00:00", + ) + + assert "values" in forecast + assert forecast["values"] == [1.2, 1.5, 1.8] + assert forecast["start"] == "2025-01-05T00:00:00+00:00" + assert forecast["duration"] == "PT3H" + assert forecast["unit"] == "kW" + + await flexmeasures_client.close() From 0fea65b912051516cdbe8d4d4b9bcbb499678398 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:43:54 +0000 Subject: [PATCH 4/5] Fix black linting issue and add copilot agent instructions Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> --- .github/agents/copilot.md | 106 ++++++++++++++++++++++++++++++ src/flexmeasures_client/client.py | 4 +- 2 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 .github/agents/copilot.md diff --git a/.github/agents/copilot.md b/.github/agents/copilot.md new file mode 100644 index 00000000..007d3032 --- /dev/null +++ b/.github/agents/copilot.md @@ -0,0 +1,106 @@ +--- +name: copilot +description: Agent instructions for working on flexmeasures-client +--- + +# flexmeasures-client Agent Instructions + +## Project Overview + +`flexmeasures-client` is an async Python client library for connecting to the FlexMeasures API. The main client class is `FlexMeasuresClient` in `src/flexmeasures_client/client.py`. + +## Repository Layout + +- `src/flexmeasures_client/` — main package + - `client.py` — main `FlexMeasuresClient` class (all API methods live here) + - `response_handling.py` — HTTP response checks, polling logic + - `constants.py` — API version, content-type headers + - `exceptions.py` — custom exception classes +- `tests/` — async tests using `pytest-asyncio` + `aioresponses` + +## Running Tests + +```bash +pip install -e ".[testing]" +python3 -m pytest tests/test_client.py -q +``` + +All tests must pass (`35+` depending on features added). Never run only the full suite; use targeted file-level tests first. + +## Linting and Code Quality + +The project enforces linting via `.pre-commit-config.yaml`. **Always run these checks and fix any issues before committing or requesting review.** + +### Linting tools (run individually when pre-commit is unavailable) + +```bash +pip install black isort flake8 + +# Format (black) +black src/flexmeasures_client/ tests/ + +# Sort imports (isort) +isort src/flexmeasures_client/ tests/ + +# Style check (flake8) +flake8 src/flexmeasures_client/ tests/ +``` + +### Running via pre-commit + +```bash +pip install pre-commit +pre-commit run --all-files +``` + +**Fix all linting issues before pushing. Do not leave black reformatting errors.** + +## Coding Patterns + +### Adding a new API method to `FlexMeasuresClient` + +1. Add the async method to `client.py` following the existing style. +2. Use `await self.request(uri=..., method="GET"/"POST", ...)` for all HTTP calls. +3. Call `check_for_status(status, expected)` to raise on unexpected status codes. +4. Use `pd.Timestamp(x).isoformat()` for datetime params and `pd.Timedelta(x).isoformat()` for duration params. +5. Pass `minimum_server_version="x.y.z"` when a feature requires a specific server version. + +### Polling endpoints (202 → 200) + +When an endpoint returns 202 while a job is running and 200 when complete, implement polling directly using `asyncio.sleep` and `async_timeout.timeout`, similar to `get_forecast()`. + +### Key response-handling rules (in `response_handling.py`) + +- `<300` → pass +- `303` → redirect +- `400` with "Scheduling job waiting/in progress" → poll +- `401` → re-authenticate once +- `503` + `Retry-After` → poll + +The `request()` method in `FlexMeasuresClient` already handles standard retries; only implement custom polling for endpoints that return `202`. + +## Test Patterns + +See `.github/agents/test-specialist.md` for full test-writing guidelines. + +Key points: +- Use `@pytest.mark.asyncio` + `async def test_*() -> None:` +- Use `aioresponses` to mock HTTP calls +- Set `flexmeasures_client.access_token = "test-token"` to skip auth +- Always `await flexmeasures_client.close()` at the end of each test +- Use short `request_timeout=2, polling_interval=0.2` for polling tests + +## API Version and Server Compatibility + +- Client uses API version `v3_0` by default (`constants.py`) +- New endpoints requiring FlexMeasures ≥ 0.31.0 must pass `minimum_server_version="0.31.0"` to `request()` + +## Forecasting Endpoints (added in v0.31.0) + +- `POST /sensors/{id}/forecasts/trigger` → `trigger_forecast(sensor_id, start, end, duration, ...)` + - Top-level keys: `start`, `end`, `duration`, `max-forecast-horizon`, `forecast-frequency`, `probabilistic` + - Nested `config` dict keys: `train-start`, `train-period`, `max-training-period`, `retrain-frequency`, `future-regressors`, `past-regressors`, `regressors` + - Returns forecast job UUID from `response["forecast"]` +- `GET /sensors/{id}/forecasts/{uuid}` → `get_forecast(sensor_id, forecast_id)` + - Returns 202 while job is running; 200 with `{values, start, duration, unit}` when done +- `trigger_and_get_forecast(sensor_id, ...)` combines both diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index d2e8cfc0..adb0282b 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -1362,9 +1362,7 @@ async def trigger_forecast( f"Expected a forecast ID, but got {type(response.get('forecast'))}", ) forecast_id = response["forecast"] - self.logger.info( - f"Forecast triggered successfully. Forecast ID: {forecast_id}" - ) + self.logger.info(f"Forecast triggered successfully. Forecast ID: {forecast_id}") return forecast_id async def get_forecast( From 9f0766bad59baf4cc907ebd6cf68a44485968973 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:06:35 +0000 Subject: [PATCH 5/5] Add forecasting examples to README and docs/forecasting.rst; simplify copilot agent instructions Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> --- .github/agents/copilot.md | 91 ++++++----------------- README.rst | 17 +++++ docs/forecasting.rst | 147 ++++++++++++++++++++++++++++++++++++++ docs/index.rst | 1 + 4 files changed, 188 insertions(+), 68 deletions(-) create mode 100644 docs/forecasting.rst diff --git a/.github/agents/copilot.md b/.github/agents/copilot.md index 007d3032..340270c9 100644 --- a/.github/agents/copilot.md +++ b/.github/agents/copilot.md @@ -7,16 +7,14 @@ description: Agent instructions for working on flexmeasures-client ## Project Overview -`flexmeasures-client` is an async Python client library for connecting to the FlexMeasures API. The main client class is `FlexMeasuresClient` in `src/flexmeasures_client/client.py`. +`flexmeasures-client` is an async Python client library for connecting to the FlexMeasures API. +The main client class is `FlexMeasuresClient` in `src/flexmeasures_client/client.py`. ## Repository Layout -- `src/flexmeasures_client/` — main package - - `client.py` — main `FlexMeasuresClient` class (all API methods live here) - - `response_handling.py` — HTTP response checks, polling logic - - `constants.py` — API version, content-type headers - - `exceptions.py` — custom exception classes +- `src/flexmeasures_client/` — main package (`client.py` is where all API methods live) - `tests/` — async tests using `pytest-asyncio` + `aioresponses` +- `docs/` — RST documentation (add new `.rst` files and link them in `docs/index.rst`) ## Running Tests @@ -25,82 +23,39 @@ pip install -e ".[testing]" python3 -m pytest tests/test_client.py -q ``` -All tests must pass (`35+` depending on features added). Never run only the full suite; use targeted file-level tests first. +## Linting -## Linting and Code Quality - -The project enforces linting via `.pre-commit-config.yaml`. **Always run these checks and fix any issues before committing or requesting review.** - -### Linting tools (run individually when pre-commit is unavailable) +Always fix linting before pushing. Run individually if `pre-commit` is unavailable: ```bash pip install black isort flake8 - -# Format (black) -black src/flexmeasures_client/ tests/ - -# Sort imports (isort) -isort src/flexmeasures_client/ tests/ - -# Style check (flake8) -flake8 src/flexmeasures_client/ tests/ +black src/ tests/ +isort src/ tests/ +flake8 src/ tests/ ``` -### Running via pre-commit +Or via pre-commit (preferred): ```bash -pip install pre-commit -pre-commit run --all-files +pip install pre-commit && pre-commit run --all-files ``` -**Fix all linting issues before pushing. Do not leave black reformatting errors.** - ## Coding Patterns -### Adding a new API method to `FlexMeasuresClient` - -1. Add the async method to `client.py` following the existing style. -2. Use `await self.request(uri=..., method="GET"/"POST", ...)` for all HTTP calls. -3. Call `check_for_status(status, expected)` to raise on unexpected status codes. -4. Use `pd.Timestamp(x).isoformat()` for datetime params and `pd.Timedelta(x).isoformat()` for duration params. -5. Pass `minimum_server_version="x.y.z"` when a feature requires a specific server version. - -### Polling endpoints (202 → 200) - -When an endpoint returns 202 while a job is running and 200 when complete, implement polling directly using `asyncio.sleep` and `async_timeout.timeout`, similar to `get_forecast()`. - -### Key response-handling rules (in `response_handling.py`) - -- `<300` → pass -- `303` → redirect -- `400` with "Scheduling job waiting/in progress" → poll -- `401` → re-authenticate once -- `503` + `Retry-After` → poll - -The `request()` method in `FlexMeasuresClient` already handles standard retries; only implement custom polling for endpoints that return `202`. - -## Test Patterns - -See `.github/agents/test-specialist.md` for full test-writing guidelines. +- Add async methods to `client.py` following the existing style. +- Use `await self.request(uri=..., method="GET"/"POST", ...)` for HTTP calls. +- Call `check_for_status(status, expected)` to raise on unexpected status codes. +- Use `pd.Timestamp(x).isoformat()` for datetimes and `pd.Timedelta(x).isoformat()` for durations. +- Pass `minimum_server_version="x.y.z"` when a feature needs a specific server version. +- For endpoints that return 202 (job in progress) → 200 (done), implement polling using `asyncio.sleep` + `async_timeout.timeout` (see `get_forecast()` as a reference). -Key points: -- Use `@pytest.mark.asyncio` + `async def test_*() -> None:` -- Use `aioresponses` to mock HTTP calls -- Set `flexmeasures_client.access_token = "test-token"` to skip auth -- Always `await flexmeasures_client.close()` at the end of each test -- Use short `request_timeout=2, polling_interval=0.2` for polling tests +## Writing Tests -## API Version and Server Compatibility +Delegate test writing to the **test-specialist** sub-agent (see `.github/agents/test-specialist.md`). +After the sub-agent completes, **verify yourself** that: -- Client uses API version `v3_0` by default (`constants.py`) -- New endpoints requiring FlexMeasures ≥ 0.31.0 must pass `minimum_server_version="0.31.0"` to `request()` +1. All new tests pass: `python3 -m pytest tests/test_client.py -q` +2. Linting passes: `black --check src/ tests/ && flake8 src/ tests/` -## Forecasting Endpoints (added in v0.31.0) +Do not accept the sub-agent's output at face value — run both checks yourself and iterate if needed. -- `POST /sensors/{id}/forecasts/trigger` → `trigger_forecast(sensor_id, start, end, duration, ...)` - - Top-level keys: `start`, `end`, `duration`, `max-forecast-horizon`, `forecast-frequency`, `probabilistic` - - Nested `config` dict keys: `train-start`, `train-period`, `max-training-period`, `retrain-frequency`, `future-regressors`, `past-regressors`, `regressors` - - Returns forecast job UUID from `response["forecast"]` -- `GET /sensors/{id}/forecasts/{uuid}` → `get_forecast(sensor_id, forecast_id)` - - Returns 202 while job is running; 200 with `{values, start, duration, unit}` when done -- `trigger_and_get_forecast(sensor_id, ...)` combines both diff --git a/README.rst b/README.rst index 0be4e10c..e6cdfa30 100644 --- a/README.rst +++ b/README.rst @@ -244,6 +244,23 @@ This can be used to retrieve the schedule, using: The client will re-try until the schedule is available or the ``MAX_POLLING_STEPS`` of ``10`` is reached. +Forecasting +=========== + +Trigger a forecast for a sensor and wait for the result: + +.. code-block:: python + + forecast = await client.trigger_and_get_forecast( + sensor_id=, # int + duration="PT24H", # ISO duration – how far ahead to forecast + ) + # Returns e.g. {"values": [1.2, 1.5, ...], "start": "...", "duration": "PT24H", "unit": "kW"} + +The client polls until the forecasting job is complete. For more advanced options +(training window, regressors, forecast frequency, etc.) see :doc:`forecasting`. + + Development ============== diff --git a/docs/forecasting.rst b/docs/forecasting.rst new file mode 100644 index 00000000..5bc3dacd --- /dev/null +++ b/docs/forecasting.rst @@ -0,0 +1,147 @@ +.. _forecasting: + +Forecasting +=========== + +The FlexMeasures Client supports the forecasting API endpoints introduced in +FlexMeasures v0.31.0: + +- ``POST /sensors//forecasts/trigger`` — queue a forecasting job +- ``GET /sensors//forecasts/`` — poll for results + +These are exposed through three client methods: + +- :meth:`trigger_forecast` — trigger and return the job UUID +- :meth:`get_forecast` — poll until results are ready +- :meth:`trigger_and_get_forecast` — convenience wrapper for both + +.. note:: + + These endpoints require a FlexMeasures server of version **0.31.0** or above. + + +Basic example +------------- + +Forecast the next 24 hours for a sensor, using server-side defaults for the +training window: + +.. code-block:: python + + import asyncio + from flexmeasures_client import FlexMeasuresClient + + async def main(): + client = FlexMeasuresClient( + host="localhost:5000", + ssl=False, + email="user@example.com", + password="password", + ) + + forecast = await client.trigger_and_get_forecast( + sensor_id=1, + duration="PT24H", + ) + print(forecast) + # e.g. {"values": [1.2, 1.5, 1.8, ...], "start": "...", "duration": "PT24H", "unit": "kW"} + + await client.close() + + asyncio.run(main()) + + +Specifying a forecast window +----------------------------- + +Use ``start`` and ``end`` (or ``start`` and ``duration``) to define the exact +period to forecast: + +.. code-block:: python + + forecast = await client.trigger_and_get_forecast( + sensor_id=1, + start="2025-01-15T00:00:00+01:00", + end="2025-01-17T00:00:00+01:00", + ) + + +Controlling the training window +--------------------------------- + +Pass training parameters inside a nested structure via the ``train_start``, +``train_period``, and ``retrain_frequency`` keyword arguments: + +.. code-block:: python + + forecast = await client.trigger_and_get_forecast( + sensor_id=1, + start="2025-01-15T00:00:00+01:00", + duration="PT48H", + # Training configuration + train_start="2025-01-01T00:00:00+01:00", # historical data start + train_period="P14D", # use 14 days of history + retrain_frequency="PT24H", # retrain every 24 h + ) + + +Using regressors +---------------- + +You can improve forecast accuracy by supplying regressor sensor IDs: + +.. code-block:: python + + forecast = await client.trigger_and_get_forecast( + sensor_id=1, + duration="PT24H", + # Sensors whose *forecasts* matter (e.g. weather forecasts) + future_regressors=[10, 11], + # Sensors whose *measurements* matter (e.g. price history) + past_regressors=[20], + ) + + +Step-by-step usage +------------------- + +Trigger and retrieve separately to handle the job UUID yourself: + +.. code-block:: python + + # Step 1 – enqueue the forecasting job + forecast_id = await client.trigger_forecast( + sensor_id=1, + start="2025-01-15T00:00:00+01:00", + end="2025-01-17T00:00:00+01:00", + ) + print(f"Job queued: {forecast_id}") + + # Step 2 – poll until the job finishes + forecast = await client.get_forecast( + sensor_id=1, + forecast_id=forecast_id, + ) + print(forecast) + + +Polling behaviour +----------------- + +``get_forecast`` polls the server with a ``GET`` request and returns when the +server responds with HTTP 200. The polling respects the same client-level +settings as scheduling: + +- ``polling_interval`` (default 10 s) — time between retries +- ``polling_timeout`` (default 200 s) — maximum total wait time +- ``max_polling_steps`` (default 10) — maximum number of poll attempts + +Override them at client construction time: + +.. code-block:: python + + client = FlexMeasuresClient( + ..., + polling_interval=5.0, # check every 5 seconds + polling_timeout=300.0, # wait up to 5 minutes + ) diff --git a/docs/index.rst b/docs/index.rst index 980910dd..05174429 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -30,6 +30,7 @@ Contents :maxdepth: 2 Overview + Forecasting Contributions & Help License Authors