From df92a10208c0d5ecfe0f03c59e658ce09d415432 Mon Sep 17 00:00:00 2001 From: Matheus Santana <130666046+mathsant-js@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:07:55 -0300 Subject: [PATCH 1/5] feat(ml): add temporal dataset generation and demand baseline --- backend/app/ml/baseline.py | 94 +++++++++++++++ backend/app/ml/dataset.py | 190 +++++++++++++++++++++++++++++++ backend/app/ml/pipeline.py | 79 +++++++++++++ backend/tests/test_ml_dataset.py | 136 ++++++++++++++++++++++ docs/PHASE_7.md | 64 +++++++++++ scripts/README.md | 18 ++- 6 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 backend/app/ml/baseline.py create mode 100644 backend/app/ml/dataset.py create mode 100644 backend/app/ml/pipeline.py create mode 100644 backend/tests/test_ml_dataset.py create mode 100644 docs/PHASE_7.md diff --git a/backend/app/ml/baseline.py b/backend/app/ml/baseline.py new file mode 100644 index 0000000..1835237 --- /dev/null +++ b/backend/app/ml/baseline.py @@ -0,0 +1,94 @@ +"""Chronological evaluation of the hour/day-of-week demand baseline.""" + +import math +from collections import defaultdict +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime + +from app.ml.dataset import DemandDatasetRow + + +@dataclass(frozen=True, slots=True) +class RegressionMetrics: + mae: float + rmse: float + r2: float + + +@dataclass(frozen=True, slots=True) +class BaselineEvaluation: + strategy: str + train_rows: int + test_rows: int + train_start: datetime + train_end: datetime + test_start: datetime + test_end: datetime + metrics: RegressionMetrics + + +def chronological_split( + rows: Sequence[DemandDatasetRow], test_fraction: float = 0.2 +) -> tuple[Sequence[DemandDatasetRow], Sequence[DemandDatasetRow]]: + if not 0 < test_fraction < 1: + raise ValueError("test_fraction must be between zero and one") + if len(rows) < 2: + raise ValueError("at least two rows are required") + if any( + current.timestamp <= previous.timestamp + for previous, current in zip(rows, rows[1:], strict=False) + ): + raise ValueError("rows must be strictly ordered by timestamp") + split_index = int(len(rows) * (1 - test_fraction)) + if split_index == 0 or split_index == len(rows): + raise ValueError("split must leave rows in train and test") + return rows[:split_index], rows[split_index:] + + +def regression_metrics(actual: Sequence[float], predicted: Sequence[float]) -> RegressionMetrics: + if len(actual) != len(predicted) or not actual: + raise ValueError("actual and predicted must have the same nonzero length") + errors = [ + actual_value - predicted_value + for actual_value, predicted_value in zip(actual, predicted, strict=True) + ] + mae = sum(abs(error) for error in errors) / len(errors) + mse = sum(error**2 for error in errors) / len(errors) + actual_mean = sum(actual) / len(actual) + total_variance = sum((value - actual_mean) ** 2 for value in actual) + residual_variance = sum(error**2 for error in errors) + r2 = 1 - residual_variance / total_variance if total_variance else float(residual_variance == 0) + return RegressionMetrics(mae=mae, rmse=math.sqrt(mse), r2=r2) + + +def evaluate_hour_weekday_baseline( + rows: Sequence[DemandDatasetRow], test_fraction: float = 0.2 +) -> BaselineEvaluation: + train, test = chronological_split(rows, test_fraction) + totals: defaultdict[tuple[int, int], float] = defaultdict(float) + counts: defaultdict[tuple[int, int], int] = defaultdict(int) + for row in train: + bucket = (row.day_of_week, row.hour) + totals[bucket] += row.demand_kw_next_60_minutes + counts[bucket] += 1 + global_average = sum(row.demand_kw_next_60_minutes for row in train) / len(train) + predictions = [ + totals[(row.day_of_week, row.hour)] / counts[(row.day_of_week, row.hour)] + if counts[(row.day_of_week, row.hour)] + else global_average + for row in test + ] + metrics = regression_metrics( + [row.demand_kw_next_60_minutes for row in test], predictions + ) + return BaselineEvaluation( + strategy="historical_mean_by_hour_and_day_of_week", + train_rows=len(train), + test_rows=len(test), + train_start=train[0].timestamp, + train_end=train[-1].timestamp, + test_start=test[0].timestamp, + test_end=test[-1].timestamp, + metrics=metrics, + ) diff --git a/backend/app/ml/dataset.py b/backend/app/ml/dataset.py new file mode 100644 index 0000000..91db0d4 --- /dev/null +++ b/backend/app/ml/dataset.py @@ -0,0 +1,190 @@ +"""Deterministic temporal dataset generation for demand forecasting. + +The generator deliberately produces station-level observations without calling +the production power allocator. It is training-data scaffolding, not a second +implementation of the energy-management business rules. +""" + +import csv +import math +import random +from collections import defaultdict +from collections.abc import Sequence +from dataclasses import asdict, dataclass, fields +from datetime import UTC, datetime, timedelta +from pathlib import Path + +INTERVAL_MINUTES = 5 +FORECAST_HORIZON_MINUTES = 60 +HORIZON_STEPS = FORECAST_HORIZON_MINUTES // INTERVAL_MINUTES + + +@dataclass(frozen=True, slots=True) +class DatasetConfig: + days: int = 90 + seed: int = 42 + start: datetime = datetime(2026, 1, 1, tzinfo=UTC) + grid_limit_kw: float = 180.0 + solar_peak_kw: float = 90.0 + + +@dataclass(frozen=True, slots=True) +class DemandObservation: + timestamp: datetime + active_sessions: int + requested_power_kw: float + allocated_power_kw: float + solar_available_kw: float + solar_used_kw: float + grid_power_kw: float + total_demand_kw: float + + +@dataclass(frozen=True, slots=True) +class DemandDatasetRow: + timestamp: datetime + hour: int + day_of_week: int + is_weekend: bool + active_sessions: int + requested_power_kw: float + allocated_power_kw: float + solar_available_kw: float + solar_used_kw: float + grid_power_kw: float + total_demand_kw: float + current_demand_kw: float + historical_avg_demand_kw: float + demand_kw_next_60_minutes: float + + +DATASET_COLUMNS = tuple(field.name for field in fields(DemandDatasetRow)) +FEATURE_COLUMNS = tuple( + column for column in DATASET_COLUMNS if column != "demand_kw_next_60_minutes" +) + + +def _validate_config(config: DatasetConfig) -> None: + if config.days <= 0: + raise ValueError("days must be positive") + if config.start.tzinfo is None or config.start.utcoffset() is None: + raise ValueError("start must be timezone-aware") + if config.grid_limit_kw <= 0 or config.solar_peak_kw < 0: + raise ValueError("power limits must be valid") + + +def generate_observations(config: DatasetConfig) -> list[DemandObservation]: + """Generate 90 days plus the look-ahead needed to label the final row.""" + + _validate_config(config) + rng = random.Random(config.seed) + row_count = config.days * 24 * 60 // INTERVAL_MINUTES + HORIZON_STEPS + observations: list[DemandObservation] = [] + + for step in range(row_count): + timestamp = config.start + timedelta(minutes=step * INTERVAL_MINUTES) + fractional_hour = timestamp.hour + timestamp.minute / 60 + weekday_factor = 0.78 if timestamp.weekday() >= 5 else 1.0 + morning = math.exp(-((fractional_hour - 8.0) / 2.8) ** 2) + evening = math.exp(-((fractional_hour - 18.0) / 3.4) ** 2) + activity_level = weekday_factor * (1.6 + 8.2 * morning + 11.5 * evening) + active_sessions = max(0, round(activity_level + rng.gauss(0, 1.15))) + + average_request_kw = max(3.6, 9.2 + rng.gauss(0, 1.1)) + requested_power_kw = active_sessions * average_request_kw + + daylight = 0.0 + if 6 < fractional_hour < 18: + daylight = math.sin(math.pi * (fractional_hour - 6) / 12) + seasonal_factor = 0.82 + 0.18 * math.sin(2 * math.pi * timestamp.timetuple().tm_yday / 365) + cloud_factor = min(1.0, max(0.35, 0.84 + rng.gauss(0, 0.07))) + solar_available_kw = config.solar_peak_kw * daylight * seasonal_factor * cloud_factor + + # Synthetic capacity is intentionally local to the data generator. It + # preserves physical consistency without owning production allocation. + allocated_power_kw = min(requested_power_kw, config.grid_limit_kw + solar_available_kw) + solar_used_kw = min(allocated_power_kw, solar_available_kw) + grid_power_kw = allocated_power_kw - solar_used_kw + observations.append( + DemandObservation( + timestamp=timestamp, + active_sessions=active_sessions, + requested_power_kw=round(requested_power_kw, 6), + allocated_power_kw=round(allocated_power_kw, 6), + solar_available_kw=round(solar_available_kw, 6), + solar_used_kw=round(solar_used_kw, 6), + grid_power_kw=round(grid_power_kw, 6), + total_demand_kw=round(allocated_power_kw, 6), + ) + ) + return observations + + +def build_supervised_dataset( + observations: Sequence[DemandObservation], + *, + output_rows: int | None = None, +) -> list[DemandDatasetRow]: + """Build causal features and a label exactly 60 minutes in the future. + + Historical averages are calculated from earlier observations only. The + current observation is added to the accumulator after its feature value is + read, which makes the no-look-ahead property explicit. + """ + + if len(observations) <= HORIZON_STEPS: + raise ValueError("observations must cover more than the forecast horizon") + for previous, current in zip(observations, observations[1:], strict=False): + if current.timestamp - previous.timestamp != timedelta(minutes=INTERVAL_MINUTES): + raise ValueError("observations must be unique, ordered 5-minute intervals") + + available_rows = len(observations) - HORIZON_STEPS + row_count = available_rows if output_rows is None else output_rows + if not 0 < row_count <= available_rows: + raise ValueError("output_rows exceeds observations with known targets") + + totals: defaultdict[tuple[int, int], float] = defaultdict(float) + counts: defaultdict[tuple[int, int], int] = defaultdict(int) + rows: list[DemandDatasetRow] = [] + for index, observation in enumerate(observations[:row_count]): + bucket = (observation.timestamp.weekday(), observation.timestamp.hour) + historical_average = totals[bucket] / counts[bucket] if counts[bucket] else 0.0 + target = observations[index + HORIZON_STEPS].total_demand_kw + rows.append( + DemandDatasetRow( + timestamp=observation.timestamp, + hour=observation.timestamp.hour, + day_of_week=observation.timestamp.weekday(), + is_weekend=observation.timestamp.weekday() >= 5, + active_sessions=observation.active_sessions, + requested_power_kw=observation.requested_power_kw, + allocated_power_kw=observation.allocated_power_kw, + solar_available_kw=observation.solar_available_kw, + solar_used_kw=observation.solar_used_kw, + grid_power_kw=observation.grid_power_kw, + total_demand_kw=observation.total_demand_kw, + current_demand_kw=observation.total_demand_kw, + historical_avg_demand_kw=round(historical_average, 6), + demand_kw_next_60_minutes=target, + ) + ) + totals[bucket] += observation.total_demand_kw + counts[bucket] += 1 + return rows + + +def generate_dataset(config: DatasetConfig | None = None) -> list[DemandDatasetRow]: + config = config or DatasetConfig() + expected_rows = config.days * 24 * 60 // INTERVAL_MINUTES + return build_supervised_dataset(generate_observations(config), output_rows=expected_rows) + + +def write_dataset_csv(rows: Sequence[DemandDatasetRow], output_path: Path) -> None: + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", encoding="utf-8", newline="") as output_file: + writer = csv.DictWriter(output_file, fieldnames=DATASET_COLUMNS) + writer.writeheader() + for row in rows: + values = asdict(row) + values["timestamp"] = row.timestamp.isoformat() + writer.writerow(values) diff --git a/backend/app/ml/pipeline.py b/backend/app/ml/pipeline.py new file mode 100644 index 0000000..a73e4d9 --- /dev/null +++ b/backend/app/ml/pipeline.py @@ -0,0 +1,79 @@ +"""Command-line entry point for Phase 7 dataset and baseline generation.""" + +import argparse +import json +from dataclasses import asdict +from datetime import datetime +from pathlib import Path +from typing import Any + +from app.ml.baseline import evaluate_hour_weekday_baseline +from app.ml.dataset import ( + FORECAST_HORIZON_MINUTES, + INTERVAL_MINUTES, + DatasetConfig, + generate_dataset, + write_dataset_csv, +) + + +def _isoformat_datetimes(value: Any) -> Any: + if isinstance(value, datetime): + return value.isoformat() + raise TypeError(f"cannot serialize {type(value).__name__}") + + +def run_pipeline( + *, + dataset_path: Path, + metadata_path: Path, + config: DatasetConfig, + test_fraction: float, +) -> dict[str, Any]: + rows = generate_dataset(config) + evaluation = evaluate_hour_weekday_baseline(rows, test_fraction) + write_dataset_csv(rows, dataset_path) + metadata: dict[str, Any] = { + "dataset": { + "rows": len(rows), + "seed": config.seed, + "interval_minutes": INTERVAL_MINUTES, + "forecast_horizon_minutes": FORECAST_HORIZON_MINUTES, + "period_start": rows[0].timestamp, + "period_end": rows[-1].timestamp, + }, + "baseline": asdict(evaluation), + } + metadata_path.parent.mkdir(parents=True, exist_ok=True) + metadata_path.write_text( + json.dumps(metadata, default=_isoformat_datetimes, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return metadata + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dataset", type=Path, default=Path("../data/processed/demand_90d.csv")) + parser.add_argument( + "--metadata", type=Path, default=Path("../data/processed/baseline_metrics.json") + ) + parser.add_argument("--days", type=int, default=90) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--test-fraction", type=float, default=0.2) + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + metadata = run_pipeline( + dataset_path=args.dataset, + metadata_path=args.metadata, + config=DatasetConfig(days=args.days, seed=args.seed), + test_fraction=args.test_fraction, + ) + print(json.dumps(metadata, default=_isoformat_datetimes, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_ml_dataset.py b/backend/tests/test_ml_dataset.py new file mode 100644 index 0000000..e599706 --- /dev/null +++ b/backend/tests/test_ml_dataset.py @@ -0,0 +1,136 @@ +from dataclasses import fields, replace +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest + +from app.ml.baseline import ( + chronological_split, + evaluate_hour_weekday_baseline, + regression_metrics, +) +from app.ml.dataset import ( + DATASET_COLUMNS, + FEATURE_COLUMNS, + HORIZON_STEPS, + DatasetConfig, + DemandDatasetRow, + build_supervised_dataset, + generate_dataset, + generate_observations, +) +from app.ml.pipeline import run_pipeline + + +@pytest.fixture(scope="module") +def dataset() -> list[DemandDatasetRow]: + return generate_dataset(DatasetConfig(days=2, seed=17)) + + +def test_dataset_schema_contains_required_and_causal_columns( + dataset: list[DemandDatasetRow], +) -> None: + required = { + "timestamp", + "hour", + "day_of_week", + "is_weekend", + "active_sessions", + "requested_power_kw", + "allocated_power_kw", + "solar_available_kw", + "solar_used_kw", + "grid_power_kw", + "total_demand_kw", + "demand_kw_next_60_minutes", + } + assert required <= set(DATASET_COLUMNS) + assert "demand_kw_next_60_minutes" not in FEATURE_COLUMNS + assert tuple(field.name for field in fields(dataset[0])) == DATASET_COLUMNS + + +def test_dataset_is_strictly_ordered_in_five_minute_intervals( + dataset: list[DemandDatasetRow], +) -> None: + assert len(dataset) == 2 * 24 * 12 + assert all( + current.timestamp - previous.timestamp == timedelta(minutes=5) + for previous, current in zip(dataset, dataset[1:], strict=False) + ) + + +def test_target_is_demand_exactly_sixty_minutes_ahead() -> None: + observations = generate_observations(DatasetConfig(days=1, seed=3)) + rows = build_supervised_dataset(observations) + for index, row in enumerate(rows): + assert row.demand_kw_next_60_minutes == observations[index + HORIZON_STEPS].total_demand_kw + + +def test_future_changes_do_not_change_past_features() -> None: + observations = generate_observations(DatasetConfig(days=1, seed=5)) + cutoff = 80 + changed = list(observations) + changed[cutoff] = replace( + changed[cutoff], total_demand_kw=changed[cutoff].total_demand_kw + 999 + ) + original_rows = build_supervised_dataset(observations) + changed_rows = build_supervised_dataset(changed) + for original, modified in zip(original_rows[:cutoff], changed_rows[:cutoff], strict=True): + assert tuple(getattr(original, name) for name in FEATURE_COLUMNS) == tuple( + getattr(modified, name) for name in FEATURE_COLUMNS + ) + + +def test_generation_is_reproducible_and_seed_configurable() -> None: + config = DatasetConfig(days=1, seed=101, start=datetime(2026, 2, 1, tzinfo=UTC)) + assert generate_dataset(config) == generate_dataset(config) + assert generate_dataset(config) != generate_dataset(replace(config, seed=102)) + + +def test_chronological_split_has_no_overlap(dataset: list[DemandDatasetRow]) -> None: + train, test = chronological_split(dataset, test_fraction=0.25) + assert train[-1].timestamp < test[0].timestamp + assert len(train) + len(test) == len(dataset) + + +def test_baseline_metrics_are_calculated_correctly() -> None: + metrics = regression_metrics([1.0, 2.0, 3.0], [1.0, 2.0, 4.0]) + assert metrics.mae == pytest.approx(1 / 3) + assert metrics.rmse == pytest.approx((1 / 3) ** 0.5) + assert metrics.r2 == pytest.approx(0.5) + + +def test_hour_weekday_baseline_reports_finite_metrics_and_periods( + dataset: list[DemandDatasetRow], +) -> None: + evaluation = evaluate_hour_weekday_baseline(dataset) + assert evaluation.strategy == "historical_mean_by_hour_and_day_of_week" + assert evaluation.train_end < evaluation.test_start + assert evaluation.train_rows + evaluation.test_rows == len(dataset) + assert evaluation.metrics.mae >= 0 + assert evaluation.metrics.rmse >= 0 + assert all( + value == pytest.approx(value) + for value in ( + evaluation.metrics.mae, + evaluation.metrics.rmse, + evaluation.metrics.r2, + ) + ) + + +def test_pipeline_writes_dataset_and_training_metadata(tmp_path: Path) -> None: + dataset_path = tmp_path / "dataset.csv" + metadata_path = tmp_path / "metadata.json" + metadata = run_pipeline( + dataset_path=dataset_path, + metadata_path=metadata_path, + config=DatasetConfig(days=1, seed=9), + test_fraction=0.2, + ) + assert dataset_path.read_text(encoding="utf-8").splitlines()[0] == ",".join( + DATASET_COLUMNS + ) + assert metadata_path.is_file() + assert metadata["dataset"]["rows"] == 24 * 12 + assert metadata["baseline"]["train_end"] < metadata["baseline"]["test_start"] diff --git a/docs/PHASE_7.md b/docs/PHASE_7.md new file mode 100644 index 0000000..1764371 --- /dev/null +++ b/docs/PHASE_7.md @@ -0,0 +1,64 @@ +# Fase 7 — Dataset temporal e baseline + +Esta primeira fatia implementa a geração reproduzível do dataset temporal e a +avaliação do baseline. Treinamento e persistência do modelo, inferência e +classificação de risco permanecem para as próximas fatias da Fase 7. + +## Decisões + +- 90 dias em UTC, com intervalos de 5 minutos e 25.920 linhas rotuladas; +- target `demand_kw_next_60_minutes` igual à demanda observada exatamente 12 + intervalos à frente; +- `historical_avg_demand_kw` usa apenas observações anteriores do mesmo par + hora/dia da semana; +- split cronológico de 80% para treino e 20% para teste, sem embaralhamento; +- baseline pela média do target no treino para hora + dia da semana; +- gerador sintético isolado do alocador de produção; +- implementação somente com a biblioteca padrão do Python. NumPy, Pandas e + scikit-learn não estavam declarados nem instalados, e não foram necessários + nesta fatia. + +## Execução + +A partir da raiz do repositório: + +```bash +cd backend +.venv/bin/python -m app.ml.pipeline --seed 42 +``` + +Os parâmetros `--days`, `--seed`, `--test-fraction`, `--dataset` e `--metadata` +podem ser configurados. Os CSVs e JSONs gerados sob `data/` são ignorados pelo +Git; apenas `.gitkeep` é versionado. + +## Resultado reproduzido + +Execução realizada em 23/09/2026 com seed 42 e split 80/20: + +| Item | Resultado | +|---|---:| +| Linhas | 25.920 | +| Período do dataset | 2026-01-01 00:00 UTC a 2026-03-31 23:55 UTC | +| Treino | 20.736 linhas; até 2026-03-13 23:55 UTC | +| Teste | 5.184 linhas; desde 2026-03-14 00:00 UTC | +| MAE | 11,096690 kW | +| RMSE | 14,122756 kW | +| R² | 0,825907 | + +Comando de validação focada executado: + +```bash +backend/.venv/bin/pytest -q backend/tests/test_ml_dataset.py +``` + +Resultado: `9 passed`. + +Validação completa executada: + +```bash +backend/.venv/bin/ruff check backend/app backend/tests +backend/.venv/bin/mypy backend/app +backend/.venv/bin/pytest -q backend/tests +``` + +Resultados: Ruff sem erros, mypy sem erros em 72 arquivos e `194 passed`. diff --git a/scripts/README.md b/scripts/README.md index 0041990..80ea309 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1 +1,17 @@ -Scripts reproduzíveis de seed, simulação e ML serão adicionados nas fases correspondentes. +## Dataset e baseline da Fase 7 + +Execute a partir de `backend/`: + +```bash +.venv/bin/python -m app.ml.pipeline --seed 42 +``` + +O comando gera localmente `data/processed/demand_90d.csv` e +`data/processed/baseline_metrics.json`. Esses artefatos são ignorados pelo Git; +somente os arquivos `.gitkeep` permanecem versionados. + +Parâmetros úteis: + +```bash +.venv/bin/python -m app.ml.pipeline --seed 7 --days 90 --test-fraction 0.2 +``` From d3d4fb60be715c94fc2da5a2c37c0c625e4433bd Mon Sep 17 00:00:00 2001 From: Matheus Santana <130666046+mathsant-js@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:24:38 -0300 Subject: [PATCH 2/5] feat(ml): add Phase 7 model training and evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - train a RandomForestRegressor using the chronological dataset split - calculate MAE, RMSE, and R² for the model and baseline - persist the model and training metadata with Joblib - validate artifact format and feature compatibility on load - add explicit errors for missing or incompatible artifacts - add reproducible training command and document actual metrics - cover training, persistence, temporal split, and baseline comparison --- backend/app/ml/pipeline.py | 21 ++- backend/app/ml/training.py | 213 ++++++++++++++++++++++++++++++ backend/pyproject.toml | 5 +- backend/tests/test_ml_dataset.py | 1 + backend/tests/test_ml_training.py | 103 +++++++++++++++ docs/PHASE_7.md | 64 +++++---- 6 files changed, 375 insertions(+), 32 deletions(-) create mode 100644 backend/app/ml/training.py create mode 100644 backend/tests/test_ml_training.py diff --git a/backend/app/ml/pipeline.py b/backend/app/ml/pipeline.py index a73e4d9..3390b9b 100644 --- a/backend/app/ml/pipeline.py +++ b/backend/app/ml/pipeline.py @@ -1,4 +1,4 @@ -"""Command-line entry point for Phase 7 dataset and baseline generation.""" +"""Command-line entry point for Phase 7 training and evaluation.""" import argparse import json @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any +from app.core.logging import configure_logging from app.ml.baseline import evaluate_hour_weekday_baseline from app.ml.dataset import ( FORECAST_HORIZON_MINUTES, @@ -15,6 +16,7 @@ generate_dataset, write_dataset_csv, ) +from app.ml.training import train_and_evaluate def _isoformat_datetimes(value: Any) -> Any: @@ -27,11 +29,19 @@ def run_pipeline( *, dataset_path: Path, metadata_path: Path, + artifact_path: Path, config: DatasetConfig, test_fraction: float, ) -> dict[str, Any]: rows = generate_dataset(config) evaluation = evaluate_hour_weekday_baseline(rows, test_fraction) + training = train_and_evaluate( + rows, + baseline_metrics=evaluation.metrics, + artifact_path=artifact_path, + test_fraction=test_fraction, + random_state=config.seed, + ) write_dataset_csv(rows, dataset_path) metadata: dict[str, Any] = { "dataset": { @@ -43,6 +53,8 @@ def run_pipeline( "period_end": rows[-1].timestamp, }, "baseline": asdict(evaluation), + "model": asdict(training.metadata), + "comparison": asdict(training.comparison), } metadata_path.parent.mkdir(parents=True, exist_ok=True) metadata_path.write_text( @@ -56,7 +68,10 @@ def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--dataset", type=Path, default=Path("../data/processed/demand_90d.csv")) parser.add_argument( - "--metadata", type=Path, default=Path("../data/processed/baseline_metrics.json") + "--metadata", type=Path, default=Path("../data/processed/training_metrics.json") + ) + parser.add_argument( + "--artifact", type=Path, default=Path("../data/models/demand_forecast.joblib") ) parser.add_argument("--days", type=int, default=90) parser.add_argument("--seed", type=int, default=42) @@ -65,10 +80,12 @@ def _parse_args() -> argparse.Namespace: def main() -> None: + configure_logging("INFO") args = _parse_args() metadata = run_pipeline( dataset_path=args.dataset, metadata_path=args.metadata, + artifact_path=args.artifact, config=DatasetConfig(days=args.days, seed=args.seed), test_fraction=args.test_fraction, ) diff --git a/backend/app/ml/training.py b/backend/app/ml/training.py new file mode 100644 index 0000000..b521712 --- /dev/null +++ b/backend/app/ml/training.py @@ -0,0 +1,213 @@ +"""Train, evaluate, persist, and load the Phase 7 demand model.""" + +import logging +from collections.abc import Sequence +from dataclasses import asdict, dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, cast + +import joblib +import pandas as pd +from sklearn.ensemble import RandomForestRegressor + +from app.ml.baseline import RegressionMetrics, chronological_split, regression_metrics +from app.ml.dataset import DemandDatasetRow + +logger = logging.getLogger(__name__) + +MODEL_VERSION = "1.0.0" +MODEL_FEATURES = ( + "hour", + "day_of_week", + "is_weekend", + "active_sessions", + "current_demand_kw", + "historical_avg_demand_kw", + "solar_available_kw", +) +TARGET_COLUMN = "demand_kw_next_60_minutes" +ARTIFACT_FORMAT_VERSION = 1 + + +class ModelArtifactError(RuntimeError): + """Base error for model artifact failures.""" + + +class ModelArtifactNotFoundError(ModelArtifactError): + """Raised when the configured model artifact does not exist.""" + + +class IncompatibleModelArtifactError(ModelArtifactError): + """Raised when artifact features or structure are incompatible.""" + + +@dataclass(frozen=True, slots=True) +class TrainingPeriod: + start: datetime + end: datetime + rows: int + + +@dataclass(frozen=True, slots=True) +class ModelMetadata: + artifact_format_version: int + model_version: str + algorithm: str + features: tuple[str, ...] + target: str + training_period: TrainingPeriod + test_period: TrainingPeriod + metrics: RegressionMetrics + + +@dataclass(frozen=True, slots=True) +class ModelComparison: + model: RegressionMetrics + baseline: RegressionMetrics + mae_improvement: float + rmse_improvement: float + r2_improvement: float + + +@dataclass(frozen=True, slots=True) +class TrainingResult: + metadata: ModelMetadata + comparison: ModelComparison + + +@dataclass(frozen=True, slots=True) +class LoadedDemandModel: + model: RandomForestRegressor + metadata: ModelMetadata + + def predict(self, rows: Sequence[DemandDatasetRow]) -> list[float]: + frame = _feature_frame(rows, self.metadata.features) + return [float(value) for value in self.model.predict(frame)] + + +def _feature_frame( + rows: Sequence[DemandDatasetRow], features: Sequence[str] = MODEL_FEATURES +) -> pd.DataFrame: + return pd.DataFrame( + [{feature: getattr(row, feature) for feature in features} for row in rows], + columns=list(features), + ) + + +def _period(rows: Sequence[DemandDatasetRow]) -> TrainingPeriod: + return TrainingPeriod(start=rows[0].timestamp, end=rows[-1].timestamp, rows=len(rows)) + + +def train_and_evaluate( + rows: Sequence[DemandDatasetRow], + *, + baseline_metrics: RegressionMetrics, + artifact_path: Path, + test_fraction: float = 0.2, + random_state: int = 42, + n_estimators: int = 200, +) -> TrainingResult: + """Train only on the chronological training window and persist a Joblib bundle.""" + + train, test = chronological_split(rows, test_fraction) + logger.info( + "Starting demand model training: version=%s algorithm=RandomForestRegressor " + "train_rows=%d test_rows=%d train_start=%s train_end=%s", + MODEL_VERSION, + len(train), + len(test), + train[0].timestamp.isoformat(), + train[-1].timestamp.isoformat(), + ) + model = RandomForestRegressor( + n_estimators=n_estimators, + random_state=random_state, + n_jobs=-1, + ) + model.fit( + _feature_frame(train), + [row.demand_kw_next_60_minutes for row in train], + ) + predictions = model.predict(_feature_frame(test)) + metrics = regression_metrics( + [row.demand_kw_next_60_minutes for row in test], + [float(value) for value in predictions], + ) + metadata = ModelMetadata( + artifact_format_version=ARTIFACT_FORMAT_VERSION, + model_version=MODEL_VERSION, + algorithm="RandomForestRegressor", + features=MODEL_FEATURES, + target=TARGET_COLUMN, + training_period=_period(train), + test_period=_period(test), + metrics=metrics, + ) + comparison = ModelComparison( + model=metrics, + baseline=baseline_metrics, + mae_improvement=baseline_metrics.mae - metrics.mae, + rmse_improvement=baseline_metrics.rmse - metrics.rmse, + r2_improvement=metrics.r2 - baseline_metrics.r2, + ) + artifact_path.parent.mkdir(parents=True, exist_ok=True) + joblib.dump({"model": model, "metadata": asdict(metadata)}, artifact_path) + logger.info( + "Demand model training completed: version=%s mae=%.6f rmse=%.6f r2=%.6f", + MODEL_VERSION, + metrics.mae, + metrics.rmse, + metrics.r2, + ) + return TrainingResult(metadata=metadata, comparison=comparison) + + +def _metadata_from_dict(value: object) -> ModelMetadata: + if not isinstance(value, dict): + raise IncompatibleModelArtifactError("artifact metadata is missing or invalid") + try: + data = cast(dict[str, Any], value) + return ModelMetadata( + artifact_format_version=int(data["artifact_format_version"]), + model_version=str(data["model_version"]), + algorithm=str(data["algorithm"]), + features=tuple(data["features"]), + target=str(data["target"]), + training_period=TrainingPeriod(**data["training_period"]), + test_period=TrainingPeriod(**data["test_period"]), + metrics=RegressionMetrics(**data["metrics"]), + ) + except (KeyError, TypeError, ValueError) as error: + raise IncompatibleModelArtifactError("artifact metadata is incomplete") from error + + +def load_model_artifact( + artifact_path: Path, + *, + expected_features: Sequence[str] = MODEL_FEATURES, +) -> LoadedDemandModel: + """Load a model only when its format and ordered feature contract match.""" + + if not artifact_path.is_file(): + raise ModelArtifactNotFoundError(f"model artifact not found: {artifact_path}") + try: + bundle = joblib.load(artifact_path) + except Exception as error: + raise IncompatibleModelArtifactError("model artifact could not be loaded") from error + if not isinstance(bundle, dict) or "model" not in bundle: + raise IncompatibleModelArtifactError("artifact model is missing or invalid") + metadata = _metadata_from_dict(bundle.get("metadata")) + expected = tuple(expected_features) + if metadata.artifact_format_version != ARTIFACT_FORMAT_VERSION: + raise IncompatibleModelArtifactError("artifact format version is incompatible") + if metadata.features != expected: + raise IncompatibleModelArtifactError( + f"artifact features are incompatible: expected {expected}, got {metadata.features}" + ) + model = bundle["model"] + if not isinstance(model, RandomForestRegressor): + raise IncompatibleModelArtifactError("artifact model type is incompatible") + if getattr(model, "n_features_in_", None) != len(expected): + raise IncompatibleModelArtifactError("artifact model feature count is incompatible") + return LoadedDemandModel(model=model, metadata=metadata) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f70467c..23460f5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -10,10 +10,13 @@ requires-python = ">=3.12" dependencies = [ "alembic>=1.14,<2", "fastapi>=0.115,<0.116", + "joblib>=1.4,<2", + "pandas>=2.2,<3", "psycopg[binary]>=3.2,<4", "pydantic-settings>=2.7,<3", "PyJWT>=2.10,<3", "sqlalchemy>=2.0,<3", + "scikit-learn>=1.6,<2", "uvicorn[standard]>=0.34,<1", ] @@ -46,5 +49,5 @@ strict = true plugins = ["pydantic.mypy"] [[tool.mypy.overrides]] -module = ["alembic.*"] +module = ["alembic.*", "joblib", "pandas", "sklearn.*"] ignore_missing_imports = true diff --git a/backend/tests/test_ml_dataset.py b/backend/tests/test_ml_dataset.py index e599706..836594c 100644 --- a/backend/tests/test_ml_dataset.py +++ b/backend/tests/test_ml_dataset.py @@ -125,6 +125,7 @@ def test_pipeline_writes_dataset_and_training_metadata(tmp_path: Path) -> None: metadata = run_pipeline( dataset_path=dataset_path, metadata_path=metadata_path, + artifact_path=tmp_path / "model.joblib", config=DatasetConfig(days=1, seed=9), test_fraction=0.2, ) diff --git a/backend/tests/test_ml_training.py b/backend/tests/test_ml_training.py new file mode 100644 index 0000000..8364a3b --- /dev/null +++ b/backend/tests/test_ml_training.py @@ -0,0 +1,103 @@ +from pathlib import Path + +import joblib +import pytest + +from app.ml.baseline import evaluate_hour_weekday_baseline +from app.ml.dataset import DatasetConfig, DemandDatasetRow, generate_dataset +from app.ml.training import ( + MODEL_FEATURES, + IncompatibleModelArtifactError, + ModelArtifactNotFoundError, + load_model_artifact, + train_and_evaluate, +) + + +@pytest.fixture(scope="module") +def dataset() -> list[DemandDatasetRow]: + return generate_dataset(DatasetConfig(days=10, seed=21)) + + +def _train(dataset: list[DemandDatasetRow], artifact_path: Path): + baseline = evaluate_hour_weekday_baseline(dataset, test_fraction=0.2) + result = train_and_evaluate( + dataset, + baseline_metrics=baseline.metrics, + artifact_path=artifact_path, + test_fraction=0.2, + random_state=21, + n_estimators=20, + ) + return baseline, result + + +def test_training_uses_same_strict_temporal_split_as_baseline( + dataset: list[DemandDatasetRow], tmp_path: Path +) -> None: + baseline, result = _train(dataset, tmp_path / "model.joblib") + + assert result.metadata.training_period.start == baseline.train_start + assert result.metadata.training_period.end == baseline.train_end + assert result.metadata.test_period.start == baseline.test_start + assert result.metadata.test_period.end == baseline.test_end + assert result.metadata.training_period.end < result.metadata.test_period.start + + +def test_training_reports_required_metrics_and_baseline_comparison( + dataset: list[DemandDatasetRow], tmp_path: Path +) -> None: + baseline, result = _train(dataset, tmp_path / "model.joblib") + + assert result.metadata.algorithm == "RandomForestRegressor" + assert result.metadata.metrics.mae >= 0 + assert result.metadata.metrics.rmse >= 0 + assert result.metadata.metrics.r2 == pytest.approx(result.metadata.metrics.r2) + assert result.comparison.baseline == baseline.metrics + assert result.comparison.model == result.metadata.metrics + assert result.comparison.mae_improvement == pytest.approx( + baseline.metrics.mae - result.metadata.metrics.mae + ) + + +def test_model_is_persisted_reloaded_and_predicts( + dataset: list[DemandDatasetRow], tmp_path: Path +) -> None: + artifact_path = tmp_path / "model.joblib" + _, result = _train(dataset, artifact_path) + + loaded = load_model_artifact(artifact_path) + predictions = loaded.predict(dataset[-3:]) + + assert artifact_path.is_file() + assert loaded.metadata == result.metadata + assert len(predictions) == 3 + assert all(prediction >= 0 for prediction in predictions) + + +def test_loading_missing_artifact_fails_explicitly(tmp_path: Path) -> None: + with pytest.raises(ModelArtifactNotFoundError, match="not found"): + load_model_artifact(tmp_path / "missing.joblib") + + +def test_loading_rejects_incompatible_expected_features( + dataset: list[DemandDatasetRow], tmp_path: Path +) -> None: + artifact_path = tmp_path / "model.joblib" + _train(dataset, artifact_path) + + with pytest.raises(IncompatibleModelArtifactError, match="features are incompatible"): + load_model_artifact(artifact_path, expected_features=(*MODEL_FEATURES, "grid_power_kw")) + + +def test_loading_rejects_tampered_feature_metadata( + dataset: list[DemandDatasetRow], tmp_path: Path +) -> None: + artifact_path = tmp_path / "model.joblib" + _train(dataset, artifact_path) + bundle = joblib.load(artifact_path) + bundle["metadata"]["features"] = (*MODEL_FEATURES[:-1], "grid_power_kw") + joblib.dump(bundle, artifact_path) + + with pytest.raises(IncompatibleModelArtifactError, match="features are incompatible"): + load_model_artifact(artifact_path) diff --git a/docs/PHASE_7.md b/docs/PHASE_7.md index 1764371..b3fae03 100644 --- a/docs/PHASE_7.md +++ b/docs/PHASE_7.md @@ -1,8 +1,8 @@ -# Fase 7 — Dataset temporal e baseline +# Fase 7 — Dataset, baseline e treinamento -Esta primeira fatia implementa a geração reproduzível do dataset temporal e a -avaliação do baseline. Treinamento e persistência do modelo, inferência e -classificação de risco permanecem para as próximas fatias da Fase 7. +Esta fatia implementa a geração reproduzível do dataset temporal, avaliação do +baseline, treinamento do modelo e persistência do artefato. A classificação de +risco e sua integração com a aplicação permanecem para as próximas fatias. ## Decisões @@ -14,9 +14,13 @@ classificação de risco permanecem para as próximas fatias da Fase 7. - split cronológico de 80% para treino e 20% para teste, sem embaralhamento; - baseline pela média do target no treino para hora + dia da semana; - gerador sintético isolado do alocador de produção; -- implementação somente com a biblioteca padrão do Python. NumPy, Pandas e - scikit-learn não estavam declarados nem instalados, e não foram necessários - nesta fatia. +- `RandomForestRegressor` treinado apenas na janela cronológica de treino; +- features do modelo restritas às sete features mínimas da SPEC; +- artefato Joblib contém modelo, versão, contrato ordenado de features, target, + períodos de treino/teste e métricas; +- carregamento falha explicitamente para artefato ausente, inválido ou com + features incompatíveis; +- previsões são consultivas e não são conectadas ao alocador energético. ## Execução @@ -24,10 +28,14 @@ A partir da raiz do repositório: ```bash cd backend -.venv/bin/python -m app.ml.pipeline --seed 42 +.venv/bin/python -m app.ml.pipeline \ + --seed 42 \ + --dataset ../data/processed/demand_90d.csv \ + --metadata ../data/processed/training_metrics.json \ + --artifact ../data/models/demand_forecast.joblib ``` -Os parâmetros `--days`, `--seed`, `--test-fraction`, `--dataset` e `--metadata` +Os parâmetros `--days`, `--seed`, `--test-fraction`, `--dataset`, `--metadata` e `--artifact` podem ser configurados. Os CSVs e JSONs gerados sob `data/` são ignorados pelo Git; apenas `.gitkeep` é versionado. @@ -35,30 +43,28 @@ Git; apenas `.gitkeep` é versionado. Execução realizada em 23/09/2026 com seed 42 e split 80/20: -| Item | Resultado | -|---|---:| -| Linhas | 25.920 | -| Período do dataset | 2026-01-01 00:00 UTC a 2026-03-31 23:55 UTC | -| Treino | 20.736 linhas; até 2026-03-13 23:55 UTC | -| Teste | 5.184 linhas; desde 2026-03-14 00:00 UTC | -| MAE | 11,096690 kW | -| RMSE | 14,122756 kW | -| R² | 0,825907 | +| Item | Random Forest | Baseline | Diferença (modelo vs baseline) | +|---|---:|---:|---:| +| MAE | 11,542932 kW | 11,096690 kW | +0,446242 kW | +| RMSE | 14,714592 kW | 14,122756 kW | +0,591836 kW | +| R² | 0,811010 | 0,825907 | -0,014897 | -Comando de validação focada executado: +Foram usadas 25.920 linhas. O treino contém 20.736 linhas entre +2026-01-01 00:00 UTC e 2026-03-13 23:55 UTC; o teste contém 5.184 linhas entre +2026-03-14 00:00 UTC e 2026-03-31 23:55 UTC. -```bash -backend/.venv/bin/pytest -q backend/tests/test_ml_dataset.py -``` +O Random Forest inicial **não superou o baseline** nesta execução. O resultado +é preservado como evidência experimental; o modelo não deve substituir o +baseline nem influenciar diretamente a alocação energética com base nestas +métricas. -Resultado: `9 passed`. - -Validação completa executada: +Validação executada: ```bash -backend/.venv/bin/ruff check backend/app backend/tests -backend/.venv/bin/mypy backend/app -backend/.venv/bin/pytest -q backend/tests +cd backend +.venv/bin/ruff check . +.venv/bin/mypy app +.venv/bin/pytest -q ``` -Resultados: Ruff sem erros, mypy sem erros em 72 arquivos e `194 passed`. +Resultados: Ruff sem erros, mypy sem erros em 73 arquivos e `200 passed`. From 2c6edf78e9c5dcfd1872922f3bebc30ce27d0c55 Mon Sep 17 00:00:00 2001 From: Matheus Santana <130666046+mathsant-js@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:37:16 -0300 Subject: [PATCH 3/5] feat(ml): integrate persisted demand inference and peak-risk alerts - add admin endpoint for explicit demand prediction execution - build causal inference features from available station data - persist 60-minute forecasts with effective capacity and risk levels - generate deterministic recommendations and deduplicated PEAK_RISK alerts - keep ML advisory without changing energy allocations - add authorization, boundary, persistence, failure, and safety tests - update OpenAPI, Docker configuration, and Phase 7 documentation --- .env.example | 1 + README.md | 13 +- backend/app/api/routes/predictions.py | 43 ++++ backend/app/core/config.py | 2 + backend/app/ml/training.py | 29 ++- backend/app/models/prediction.py | 7 + backend/app/schemas/prediction.py | 5 + backend/app/services/demand_predictions.py | 176 ++++++++++++++ backend/tests/test_demand_inference.py | 227 ++++++++++++++++++ backend/tests/test_openapi.py | 3 + .../tests/test_predictions_configuration.py | 3 + docker-compose.yml | 2 + docs/PHASE_7.md | 43 +++- 13 files changed, 543 insertions(+), 11 deletions(-) create mode 100644 backend/app/services/demand_predictions.py create mode 100644 backend/tests/test_demand_inference.py diff --git a/.env.example b/.env.example index 688b4c6..7862c09 100644 --- a/.env.example +++ b/.env.example @@ -12,5 +12,6 @@ POSTGRES_DB=chargegrid POSTGRES_USER=chargegrid POSTGRES_PASSWORD=chargegrid DATABASE_URL=postgresql+psycopg://chargegrid:chargegrid@localhost:5432/chargegrid +DEMAND_MODEL_PATH=../data/models/demand_forecast.joblib VITE_API_URL=http://localhost:8000/api/v1 diff --git a/README.md b/README.md index c72ae12..68564ec 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ python3 scripts/sprint3_demo.py - [Fase 5 — Billing e histórico de invoices (concluída)](docs/PHASE_5.md) - [Fase 6 — Contratos da API de analytics](docs/PHASE_6_ANALYTICS.md) - [Fase 6 — Integração e limites](docs/PHASE_6.md) +- [Fase 7 — ML, treinamento e inferência](docs/PHASE_7.md) ## Estado atual @@ -157,7 +158,11 @@ atualização das respostas dos dashboards. Os gráficos do gestor somam leitura simultâneas para mostrar a demanda total de cada tick. Veja os resultados e limites em [docs/PHASE_6.md](docs/PHASE_6.md). -A Fase 6 ainda não está concluída: o KPI obrigatório de risco de pico depende -de uma previsão futura válida, e o fluxo automático de previsão/classificação -da Fase 7 ainda não existe. Sem esses dados, a tela mostra um estado -informativo. O simulador também depende de ticks manuais pela API; a Sprint 3 fornece seed e roteiro reproduzíveis para a demonstração completa. +A Fase 7 disponibiliza inferência administrativa explícita em +`POST /api/v1/predictions/demand/run`. Ela carrega o artefato configurado em +`DEMAND_MODEL_PATH`, monta features apenas com leituras já disponíveis, +persiste a previsão de 60 minutos, classifica o risco com os thresholds de +`SystemConfiguration` e cria `PEAK_RISK` uma vez por episódio `HIGH`. A +previsão é estritamente consultiva e não altera a alocação energética. Consulte +[docs/PHASE_7.md](docs/PHASE_7.md) para preparo do modelo, pré-condições e +respostas de erro. O simulador continua dependendo de ticks manuais pela API. diff --git a/backend/app/api/routes/predictions.py b/backend/app/api/routes/predictions.py index c4c8a4a..62767e1 100644 --- a/backend/app/api/routes/predictions.py +++ b/backend/app/api/routes/predictions.py @@ -14,16 +14,21 @@ commit_or_conflict, get_or_404, ) +from app.core.config import get_settings +from app.ml.training import ModelArtifactError from app.models.infrastructure import ChargingStation from app.models.prediction import DemandPrediction, SystemConfiguration +from app.schemas.common import ErrorResponse from app.schemas.prediction import ( DemandPredictionCreate, DemandPredictionResponse, + DemandPredictionRun, SystemConfigurationCreate, SystemConfigurationResponse, SystemConfigurationUpdate, SystemConfigurationValues, ) +from app.services.demand_predictions import PredictionDataError, run_demand_prediction predictions_router = APIRouter(prefix="/predictions", tags=["predictions"]) configuration_router = APIRouter(prefix="/system-configuration", tags=["configuration"]) @@ -67,6 +72,44 @@ async def create_demand_prediction( return prediction +@predictions_router.post( + "/demand/run", + response_model=DemandPredictionResponse, + status_code=status.HTTP_201_CREATED, + responses=UNAUTHORIZED_RESPONSE | FORBIDDEN_RESPONSE | NOT_FOUND_RESPONSE | { + 422: {"model": ErrorResponse, "description": "Inference features are insufficient"}, + 503: {"model": ErrorResponse, "description": "Demand model is unavailable"}, + }, +) +async def execute_demand_prediction( + payload: DemandPredictionRun, db: DbSession, _: AdminUser +) -> DemandPrediction: + station = db.scalar( + select(ChargingStation) + .where(ChargingStation.id == payload.station_id) + .with_for_update() + ) + if station is None: + raise HTTPException(status_code=404, detail="Resource not found") + configuration = db.scalar(select(SystemConfiguration).limit(1)) + if configuration is None: + raise HTTPException(status_code=404, detail="Configuration not found") + try: + prediction = run_demand_prediction( + db, + station=station, + configuration=configuration, + artifact_path=get_settings().demand_model_path, + ) + except PredictionDataError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except ModelArtifactError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + commit_or_conflict(db) + db.refresh(prediction) + return prediction + + @configuration_router.get( "", response_model=SystemConfigurationResponse, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 77c40e0..ca0194b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,5 +1,6 @@ from datetime import UTC, datetime from functools import lru_cache +from pathlib import Path from typing import Literal from pydantic import Field, field_validator, model_validator @@ -22,6 +23,7 @@ class Settings(BaseSettings): app_cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:5173"]) api_v1_prefix: str = "/api/v1" grid_emission_factor_kg_per_kwh: float = Field(default=0.0, ge=0) + demand_model_path: Path = Path("../data/models/demand_forecast.joblib") demo_simulation_start_utc: datetime | None = None database_url: str = "postgresql+psycopg://chargegrid:chargegrid@localhost:5432/chargegrid" jwt_secret_key: str = Field(default=DEFAULT_JWT_SECRET, min_length=32) diff --git a/backend/app/ml/training.py b/backend/app/ml/training.py index b521712..0082b01 100644 --- a/backend/app/ml/training.py +++ b/backend/app/ml/training.py @@ -5,7 +5,7 @@ from dataclasses import asdict, dataclass from datetime import datetime from pathlib import Path -from typing import Any, cast +from typing import Any, Protocol, cast import joblib import pandas as pd @@ -76,18 +76,41 @@ class TrainingResult: comparison: ModelComparison +class DemandFeatureRow(Protocol): + @property + def hour(self) -> int: ... + + @property + def day_of_week(self) -> int: ... + + @property + def is_weekend(self) -> bool: ... + + @property + def active_sessions(self) -> int: ... + + @property + def current_demand_kw(self) -> float: ... + + @property + def historical_avg_demand_kw(self) -> float: ... + + @property + def solar_available_kw(self) -> float: ... + + @dataclass(frozen=True, slots=True) class LoadedDemandModel: model: RandomForestRegressor metadata: ModelMetadata - def predict(self, rows: Sequence[DemandDatasetRow]) -> list[float]: + def predict(self, rows: Sequence[DemandFeatureRow]) -> list[float]: frame = _feature_frame(rows, self.metadata.features) return [float(value) for value in self.model.predict(frame)] def _feature_frame( - rows: Sequence[DemandDatasetRow], features: Sequence[str] = MODEL_FEATURES + rows: Sequence[DemandFeatureRow], features: Sequence[str] = MODEL_FEATURES ) -> pd.DataFrame: return pd.DataFrame( [{feature: getattr(row, feature) for feature in features} for row in rows], diff --git a/backend/app/models/prediction.py b/backend/app/models/prediction.py index a455955..1a20135 100644 --- a/backend/app/models/prediction.py +++ b/backend/app/models/prediction.py @@ -48,6 +48,13 @@ def prediction_horizon_minutes(self) -> int: prediction_for = prediction_for.replace(tzinfo=UTC) return round((prediction_for - generated_at).total_seconds() / 60) + @property + def recommendation(self) -> str: + # Imported lazily to keep the ORM model free from service initialization. + from app.services.demand_predictions import recommendation_for + + return recommendation_for(self.risk_level) + class SystemConfiguration(TimestampMixin, Base): __tablename__ = "system_configurations" diff --git a/backend/app/schemas/prediction.py b/backend/app/schemas/prediction.py index 49da380..5959364 100644 --- a/backend/app/schemas/prediction.py +++ b/backend/app/schemas/prediction.py @@ -29,6 +29,11 @@ class DemandPredictionResponse(BaseModel): risk_level: DemandRiskLevel model_version: str prediction_horizon_minutes: int + recommendation: str + + +class DemandPredictionRun(BaseModel): + station_id: UUID class SystemConfigurationValues(BaseModel): diff --git a/backend/app/services/demand_predictions.py b/backend/app/services/demand_predictions.py new file mode 100644 index 0000000..c49f9a0 --- /dev/null +++ b/backend/app/services/demand_predictions.py @@ -0,0 +1,176 @@ +"""Advisory demand inference; this module never writes charging allocations.""" + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.ml.training import LoadedDemandModel, load_model_artifact +from app.models.alert import Alert, AlertSeverity, AlertType +from app.models.energy import ChargingSession, ChargingSessionStatus, EnergyReading, SolarReading +from app.models.infrastructure import Charger, ChargingStation +from app.models.prediction import DemandPrediction, DemandRiskLevel, SystemConfiguration + +FORECAST_HORIZON_MINUTES = 60 + +RECOMMENDATIONS = { + DemandRiskLevel.LOW: "Demanda prevista dentro da faixa normal. Mantenha o monitoramento.", + DemandRiskLevel.MEDIUM: ( + "Demanda prevista elevada. Monitore novas sessões e a capacidade disponível." + ), + DemandRiskLevel.HIGH: ( + "Alta probabilidade de utilização próxima ao limite. " + "Considere restringir a potência de novas sessões." + ), +} + + +class PredictionDataError(RuntimeError): + """Raised when causal inference features cannot be assembled.""" + + +@dataclass(frozen=True, slots=True) +class InferenceFeatures: + hour: int + day_of_week: int + is_weekend: bool + active_sessions: int + current_demand_kw: float + historical_avg_demand_kw: float + solar_available_kw: float + + +def classify_risk( + predicted_demand_kw: float, + capacity_kw: float, + *, + medium_threshold: float, + high_threshold: float, +) -> DemandRiskLevel: + ratio = predicted_demand_kw / capacity_kw + if ratio >= high_threshold: + return DemandRiskLevel.HIGH + if ratio >= medium_threshold: + return DemandRiskLevel.MEDIUM + return DemandRiskLevel.LOW + + +def recommendation_for(risk_level: DemandRiskLevel) -> str: + return RECOMMENDATIONS[risk_level] + + +def _as_utc(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + + +def build_inference_features( + db: Session, station_id: UUID, generated_at: datetime +) -> tuple[InferenceFeatures, float]: + """Build only from observations whose timestamps are no later than prediction time.""" + + solar = db.scalar( + select(SolarReading) + .where(SolarReading.station_id == station_id, SolarReading.timestamp <= generated_at) + .order_by(SolarReading.timestamp.desc(), SolarReading.id.desc()) + .limit(1) + ) + if solar is None: + raise PredictionDataError("insufficient features: no solar observation is available") + + aggregates = db.execute( + select(EnergyReading.timestamp, func.sum(EnergyReading.allocated_power_kw)) + .join(ChargingSession, EnergyReading.session_id == ChargingSession.id) + .join(Charger, ChargingSession.charger_id == Charger.id) + .where(Charger.station_id == station_id, EnergyReading.timestamp <= generated_at) + .group_by(EnergyReading.timestamp) + .order_by(EnergyReading.timestamp) + ).all() + if not aggregates: + raise PredictionDataError("insufficient features: no demand observation is available") + + _, current_demand = aggregates[-1] + historical = [ + float(demand) + for timestamp, demand in aggregates[:-1] + if _as_utc(timestamp).weekday() == generated_at.weekday() + and _as_utc(timestamp).hour == generated_at.hour + ] + if not historical: + raise PredictionDataError( + "insufficient features: no prior observation exists for this weekday and hour" + ) + active_sessions = db.scalar( + select(func.count(ChargingSession.id)) + .join(Charger, ChargingSession.charger_id == Charger.id) + .where( + Charger.station_id == station_id, + ChargingSession.status == ChargingSessionStatus.CHARGING, + ) + ) or 0 + solar_available = float(solar.available_power_kw) + return ( + InferenceFeatures( + hour=generated_at.hour, + day_of_week=generated_at.weekday(), + is_weekend=generated_at.weekday() >= 5, + active_sessions=int(active_sessions), + current_demand_kw=float(current_demand), + historical_avg_demand_kw=sum(historical) / len(historical), + solar_available_kw=solar_available, + ), + solar_available, + ) + + +def run_demand_prediction( + db: Session, + *, + station: ChargingStation, + configuration: SystemConfiguration, + artifact_path: Path, + generated_at: datetime | None = None, +) -> DemandPrediction: + """Run and persist advisory inference without invoking the power allocator.""" + + instant = _as_utc(generated_at or datetime.now(UTC)) + features, solar_available = build_inference_features(db, station.id, instant) + loaded: LoadedDemandModel = load_model_artifact(artifact_path) + predicted_demand_kw = max(0.0, loaded.predict([features])[0]) + capacity_kw = station.grid_limit_kw + solar_available + risk_level = classify_risk( + predicted_demand_kw, + capacity_kw, + medium_threshold=configuration.medium_peak_threshold, + high_threshold=configuration.high_peak_threshold, + ) + previous_risk = db.scalar( + select(DemandPrediction.risk_level) + .where(DemandPrediction.station_id == station.id) + .order_by(DemandPrediction.generated_at.desc(), DemandPrediction.id.desc()) + .limit(1) + ) + prediction = DemandPrediction( + station_id=station.id, + generated_at=instant, + prediction_for=instant + timedelta(minutes=FORECAST_HORIZON_MINUTES), + predicted_demand_kw=predicted_demand_kw, + capacity_kw=capacity_kw, + risk_level=risk_level, + model_version=loaded.metadata.model_version, + ) + db.add(prediction) + if risk_level == DemandRiskLevel.HIGH and previous_risk != DemandRiskLevel.HIGH: + db.add( + Alert( + station_id=station.id, + type=AlertType.PEAK_RISK, + severity=AlertSeverity.CRITICAL, + title="Peak demand risk", + message=recommendation_for(risk_level), + created_at=instant, + ) + ) + return prediction diff --git a/backend/tests/test_demand_inference.py b/backend/tests/test_demand_inference.py new file mode 100644 index 0000000..4042404 --- /dev/null +++ b/backend/tests/test_demand_inference.py @@ -0,0 +1,227 @@ +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from pathlib import Path +from types import SimpleNamespace + +import pytest +from httpx import AsyncClient +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.core.security import hash_password +from app.models.alert import Alert, AlertType +from app.models.energy import ChargingSession, ChargingSessionStatus, EnergyReading, SolarReading +from app.models.infrastructure import Charger, ChargerStatus, ChargingStation +from app.models.prediction import DemandPrediction, DemandRiskLevel, SystemConfiguration +from app.models.user import User, UserRole +from app.models.vehicle import Vehicle +from app.services.demand_predictions import classify_risk, run_demand_prediction + + +class FixedModel: + def __init__(self, prediction: float) -> None: + self.prediction = prediction + self.metadata = SimpleNamespace(model_version="test-model") + + def predict(self, rows: object) -> list[float]: + return [self.prediction] + + +def seed_inference_context(db: Session) -> tuple[ChargingStation, ChargingSession, datetime]: + instant = datetime.now(UTC).replace(microsecond=0) + admin = db.scalar(select(User).limit(1)) + if admin is None: + admin = User( + name="Inference admin", + email="inference-admin@example.com", + password_hash=hash_password("password-123"), + role=UserRole.ADMIN, + is_active=True, + ) + db.add(admin) + db.flush() + station = ChargingStation( + name="Inference station", grid_limit_kw=60, station_peak_solar_kw=20, is_active=True + ) + db.add(station) + db.flush() + charger = Charger( + station_id=station.id, + name="Inference charger", + code=f"INF-{station.id}", + max_power_kw=22, + status=ChargerStatus.CHARGING, + is_active=True, + ) + vehicle = Vehicle( + user_id=admin.id, + name="Inference EV", + brand="Test", + model="EV", + license_plate=f"INF-{str(station.id)[:8]}", + max_charge_power_kw=20, + ) + db.add_all([charger, vehicle]) + db.flush() + session = ChargingSession( + user_id=admin.id, + vehicle_id=vehicle.id, + charger_id=charger.id, + status=ChargingSessionStatus.CHARGING, + started_at=instant - timedelta(days=8), + requested_power_kw=20, + allocated_power_kw=12, + tariff_per_kwh=Decimal("0.9200"), + ) + db.add(session) + db.flush() + for timestamp, demand in ( + (instant - timedelta(days=7), 10.0), + (instant - timedelta(minutes=5), 12.0), + ): + db.add( + EnergyReading( + session_id=session.id, + timestamp=timestamp, + requested_power_kw=20, + allocated_power_kw=demand, + solar_power_kw=2, + grid_power_kw=demand - 2, + interval_energy_kwh=1, + solar_energy_kwh=0.2, + grid_energy_kwh=0.8, + ) + ) + db.add( + SolarReading( + station_id=station.id, + timestamp=instant - timedelta(minutes=1), + available_power_kw=20, + ) + ) + db.add( + SystemConfiguration( + simulation_speed=60, + grid_emission_factor_kg_per_kwh=0.084, + high_demand_threshold=0.85, + medium_peak_threshold=0.7, + high_peak_threshold=0.9, + ) + ) + db.commit() + return station, session, instant + + +@pytest.mark.parametrize( + ("demand", "expected"), + [ + (69.999, DemandRiskLevel.LOW), + (70, DemandRiskLevel.MEDIUM), + (89.999, DemandRiskLevel.MEDIUM), + (90, DemandRiskLevel.HIGH), + ], +) +def test_risk_classification_exact_boundaries( + demand: float, expected: DemandRiskLevel +) -> None: + assert ( + classify_risk(demand, 100, medium_threshold=0.7, high_threshold=0.9) == expected + ) + + +def test_inference_persists_sixty_minute_forecast_and_does_not_change_allocation( + db_session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + station, session, instant = seed_inference_context(db_session) + monkeypatch.setattr( + "app.services.demand_predictions.load_model_artifact", lambda _: FixedModel(72) + ) + configuration = db_session.scalar(select(SystemConfiguration)) + assert configuration is not None + + prediction = run_demand_prediction( + db_session, + station=station, + configuration=configuration, + artifact_path=Path("unused.joblib"), + generated_at=instant, + ) + db_session.commit() + + persisted = db_session.get(DemandPrediction, prediction.id) + assert persisted is not None + assert persisted.prediction_horizon_minutes == 60 + assert persisted.capacity_kw == 80 + assert persisted.risk_level == DemandRiskLevel.HIGH + assert persisted.model_version == "test-model" + assert db_session.get(ChargingSession, session.id).allocated_power_kw == 12 # type: ignore[union-attr] + + +def test_peak_risk_alert_is_deduplicated_within_same_episode( + db_session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + station, _, instant = seed_inference_context(db_session) + monkeypatch.setattr( + "app.services.demand_predictions.load_model_artifact", lambda _: FixedModel(72) + ) + configuration = db_session.scalar(select(SystemConfiguration)) + assert configuration is not None + for offset in (0, 1): + run_demand_prediction( + db_session, + station=station, + configuration=configuration, + artifact_path=Path("unused.joblib"), + generated_at=instant + timedelta(minutes=offset), + ) + db_session.commit() + + count = db_session.scalar( + select(func.count(Alert.id)).where( + Alert.station_id == station.id, Alert.type == AlertType.PEAK_RISK + ) + ) + assert count == 1 + + +@pytest.mark.anyio +async def test_run_endpoint_reports_missing_model( + client: AsyncClient, db_session: Session, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + station, _, _ = seed_inference_context(db_session) + monkeypatch.setattr(get_settings(), "demand_model_path", tmp_path / "missing.joblib") + + response = await client.post( + "/api/v1/predictions/demand/run", json={"station_id": str(station.id)} + ) + + assert response.status_code == 503 + assert "not found" in response.json()["detail"] + + +@pytest.mark.anyio +async def test_run_endpoint_rejects_insufficient_features( + client: AsyncClient, +) -> None: + station = await client.post( + "/api/v1/stations", json={"name": "Empty prediction station", "grid_limit_kw": 60} + ) + configuration = { + "simulation_speed": 60, + "grid_emission_factor_kg_per_kwh": 0.084, + "high_demand_threshold": 0.85, + "medium_peak_threshold": 0.7, + "high_peak_threshold": 0.9, + } + created_configuration = await client.post( + "/api/v1/system-configuration", json=configuration + ) + assert created_configuration.status_code == 201 + + response = await client.post( + "/api/v1/predictions/demand/run", json={"station_id": station.json()["id"]} + ) + + assert response.status_code == 422 + assert "insufficient features" in response.json()["detail"] diff --git a/backend/tests/test_openapi.py b/backend/tests/test_openapi.py index dc96b50..5906e0b 100644 --- a/backend/tests/test_openapi.py +++ b/backend/tests/test_openapi.py @@ -25,6 +25,7 @@ async def test_openapi_is_available() -> None: assert "/api/v1/solar/current" in paths assert "/api/v1/solar/history" in paths assert "/api/v1/predictions/demand" in paths + assert "/api/v1/predictions/demand/run" in paths assert "/api/v1/system-configuration" in paths assert "/api/v1/billing/invoices" in paths assert "/api/v1/billing/invoices/{invoice_id}" in paths @@ -61,6 +62,7 @@ async def test_openapi_is_available() -> None: ("/api/v1/solar/history", "get"), ("/api/v1/predictions/demand", "get"), ("/api/v1/predictions/demand", "post"), + ("/api/v1/predictions/demand/run", "post"), ("/api/v1/system-configuration", "get"), ("/api/v1/system-configuration", "post"), ("/api/v1/system-configuration", "patch"), @@ -103,6 +105,7 @@ async def test_openapi_is_available() -> None: ("/api/v1/solar/history", "get"): {"401"}, ("/api/v1/predictions/demand", "get"): {"401", "404"}, ("/api/v1/predictions/demand", "post"): {"401", "403", "404"}, + ("/api/v1/predictions/demand/run", "post"): {"401", "403", "404", "422", "503"}, ("/api/v1/system-configuration", "get"): {"401", "403", "404"}, ("/api/v1/system-configuration", "post"): {"401", "403", "409"}, ("/api/v1/system-configuration", "patch"): {"401", "403", "404"}, diff --git a/backend/tests/test_predictions_configuration.py b/backend/tests/test_predictions_configuration.py index 8b67726..4c1718c 100644 --- a/backend/tests/test_predictions_configuration.py +++ b/backend/tests/test_predictions_configuration.py @@ -78,6 +78,9 @@ async def test_system_configuration_and_prediction_creation_require_admin( assert ( await client.post("/api/v1/predictions/demand", headers=headers, json={}) ).status_code == 403 + assert ( + await client.post("/api/v1/predictions/demand/run", headers=headers, json={}) + ).status_code == 403 @pytest.mark.anyio diff --git a/docker-compose.yml b/docker-compose.yml index f67059a..5bbd55a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,7 @@ services: JWT_EXPIRATION_MINUTES: ${JWT_EXPIRATION_MINUTES:-60} JWT_ALGORITHM: ${JWT_ALGORITHM:-HS256} GRID_EMISSION_FACTOR_KG_PER_KWH: ${GRID_EMISSION_FACTOR_KG_PER_KWH:-0.0} + DEMAND_MODEL_PATH: ${DEMAND_MODEL_PATH:-../data/models/demand_forecast.joblib} DEMO_SIMULATION_START_UTC: ${DEMO_SIMULATION_START_UTC:-} command: sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload" healthcheck: @@ -42,6 +43,7 @@ services: - "8000:8000" volumes: - ./backend:/app + - ./data:/data frontend: build: diff --git a/docs/PHASE_7.md b/docs/PHASE_7.md index b3fae03..2989044 100644 --- a/docs/PHASE_7.md +++ b/docs/PHASE_7.md @@ -1,8 +1,8 @@ # Fase 7 — Dataset, baseline e treinamento -Esta fatia implementa a geração reproduzível do dataset temporal, avaliação do -baseline, treinamento do modelo e persistência do artefato. A classificação de -risco e sua integração com a aplicação permanecem para as próximas fatias. +Esta fase implementa a geração reproduzível do dataset temporal, avaliação do +baseline, treinamento e persistência do artefato, além da inferência consultiva +integrada à aplicação. ## Decisões @@ -20,7 +20,15 @@ risco e sua integração com a aplicação permanecem para as próximas fatias. períodos de treino/teste e métricas; - carregamento falha explicitamente para artefato ausente, inválido ou com features incompatíveis; -- previsões são consultivas e não são conectadas ao alocador energético. +- previsões são consultivas e não são conectadas ao alocador energético; +- a inferência usa somente leituras com timestamp menor ou igual ao instante da + previsão e exige histórico anterior do mesmo par hora/dia da semana; +- a capacidade efetiva é `grid_limit_kw + solar_available_kw`; +- os limites `medium_peak_threshold` e `high_peak_threshold` vêm de + `SystemConfiguration`, com igualdade pertencendo ao nível superior; +- recomendações são determinísticas para `LOW`, `MEDIUM` e `HIGH`; +- um alerta `PEAK_RISK` crítico é criado apenas na entrada em `HIGH`; novas + execuções em `HIGH` no mesmo episódio não duplicam o alerta. ## Execução @@ -39,6 +47,33 @@ Os parâmetros `--days`, `--seed`, `--test-fraction`, `--dataset`, `--metadata` podem ser configurados. Os CSVs e JSONs gerados sob `data/` são ignorados pelo Git; apenas `.gitkeep` é versionado. +## Inferência administrativa + +Configure `DEMAND_MODEL_PATH` (padrão +`../data/models/demand_forecast.joblib`) e execute, autenticado como `ADMIN`: + +```http +POST /api/v1/predictions/demand/run +Content-Type: application/json + +{"station_id": ""} +``` + +A execução produz uma previsão exatamente 60 minutos à frente e persiste +`station_id`, `generated_at`, `prediction_for`, `predicted_demand_kw`, +`capacity_kw`, `risk_level` e `model_version`. O retorno também contém a +recomendação determinística e mantém o contrato consumido pelo dashboard em +`GET /api/v1/predictions/demand?station_id=`. + +Respostas operacionais relevantes: + +- `422`: não há leituras causais suficientes para montar todas as features; +- `503`: o artefato está ausente, inválido ou incompatível; +- `401`/`403`: autenticação ausente ou usuário sem papel administrativo. + +A inferência não chama o alocador, não atualiza `allocated_power_kw` e não pode +substituir as invariantes determinísticas de energia. + ## Resultado reproduzido Execução realizada em 23/09/2026 com seed 42 e split 80/20: From 24619dc19fe7c5628fcf12989609b3df4e11331b Mon Sep 17 00:00:00 2001 From: Matheus Santana <130666046+mathsant-js@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:49:20 -0300 Subject: [PATCH 4/5] feat(frontend): integrate Phase 7 demand forecasting into admin dashboard - display forecast demand, target time, model version, and recommendation - translate peak risk levels and add accessible visual indicators - distinguish expired, missing, insufficient-data, unavailable-model, and communication-error states - prevent forecasts from leaking across station filters - add responsive styling and comprehensive dashboard tests --- frontend/src/api/client.ts | 3 +- .../src/pages/AdminDashboardPage.test.tsx | 106 ++++++++++++------ frontend/src/pages/AdminDashboardPage.tsx | 57 ++++++++-- frontend/src/styles.css | 19 +++- 4 files changed, 140 insertions(+), 45 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 38da442..24a506e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -15,7 +15,7 @@ export interface Filters { station_id?: string; user_id?: string; from?: string; export interface Station { id: string; name: string; grid_limit_kw: number; station_peak_solar_kw: number; is_active: boolean } export interface Charger { id: string; station_id: string; name: string; status: 'AVAILABLE' | 'CHARGING' | 'UNAVAILABLE'; is_active: boolean } export interface SolarReading { id: string; station_id: string; timestamp: string; available_power_kw: number } -export interface DemandPrediction { id: string; station_id: string; generated_at: string; prediction_for: string; predicted_demand_kw: number; capacity_kw: number; risk_level: 'LOW' | 'MEDIUM' | 'HIGH'; prediction_horizon_minutes: number; model_version: string } +export interface DemandPrediction { id: string; station_id: string; generated_at: string; prediction_for: string; predicted_demand_kw: number; capacity_kw: number; risk_level: 'LOW' | 'MEDIUM' | 'HIGH'; prediction_horizon_minutes: number; model_version: string; recommendation: string } export interface Alert { id: string; station_id: string; type: string; severity: 'INFO' | 'WARNING' | 'CRITICAL'; title: string; message: string; created_at: string; acknowledged_at: string | null } export class ApiError extends Error { @@ -69,6 +69,7 @@ export const api = { chargers: () => request('/chargers'), solarHistory: (filters?: Filters) => request(`/solar/history${query(filters)}`), prediction: (station_id: string) => request(`/predictions/demand${query({ station_id })}`), + runPrediction: (station_id: string) => request('/predictions/demand/run', { method: 'POST', body: JSON.stringify({ station_id }) }), alerts: (station_id?: string) => request(`/alerts${query({ station_id })}`), acknowledgeAlert: (id: string) => request(`/alerts/${encodeURIComponent(id)}/acknowledge`, { method: 'PATCH' }), } diff --git a/frontend/src/pages/AdminDashboardPage.test.tsx b/frontend/src/pages/AdminDashboardPage.test.tsx index 31eb739..22a0dbd 100644 --- a/frontend/src/pages/AdminDashboardPage.test.tsx +++ b/frontend/src/pages/AdminDashboardPage.test.tsx @@ -2,23 +2,25 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re import { MemoryRouter } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { App } from '../App' -import { tokenStore } from '../api/client' +import { tokenStore, type DemandPrediction } from '../api/client' const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }) const admin = { id: 'u1', name: 'Ana', email: 'ana@example.com', role: 'ADMIN', is_active: true, created_at: '', updated_at: '' } -const station = { id: 's1', name: 'Estação Central', grid_limit_kw: 60, station_peak_solar_kw: 20, is_active: true } +const stations = [{ id: 's1', name: 'Estação Central', grid_limit_kw: 60, station_peak_solar_kw: 20, is_active: true }] const session = { id: 'se1', user_id: 'u1', charger_id: 'c1', status: 'CHARGING', allocated_power_kw: 20, energy_consumed_kwh: 5, total_cost: '0', created_at: '', updated_at: '' } const reading = { id: 'r1', session_id: 'se1', timestamp: '2026-09-17T12:00:00Z', allocated_power_kw: 20, solar_power_kw: 8, grid_power_kw: 12 } const alert = { id: 'a1', station_id: 's1', title: 'Demanda elevada', message: 'Verifique a rede', severity: 'WARNING', type: 'HIGH_DEMAND', created_at: '2026-09-17T12:00:00Z', acknowledged_at: null } const summary = { session_count: 1, completed_session_count: 0, energy_consumed_kwh: 5, billed_total: '0.00' } const sustainability = { solar_energy_kwh: 2, avoided_co2_kg: 0.8 } -const prediction = { id: 'p1', station_id: 's1', generated_at: new Date().toISOString(), prediction_for: new Date(Date.now() + 3600000).toISOString(), predicted_demand_kw: 54, capacity_kw: 60, risk_level: 'HIGH', prediction_horizon_minutes: 60, model_version: 'baseline' } +const prediction: DemandPrediction = { id: 'p1', station_id: 's1', generated_at: new Date().toISOString(), prediction_for: new Date(Date.now() + 3600000).toISOString(), predicted_demand_kw: 54, capacity_kw: 60, risk_level: 'HIGH', prediction_horizon_minutes: 60, model_version: 'rf-2026.09', recommendation: 'Monitore novas sessões.' } -function mockApi(withPrediction = false) { +type MockOptions = { prediction?: DemandPrediction | null; predictionFailure?: number | 'network'; runResult?: DemandPrediction; runFailure?: number | 'network'; stationList?: typeof stations } + +function mockApi(options: MockOptions = {}) { return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { const url = new URL(String(input)) if (url.pathname.endsWith('/auth/me')) return json(admin) - if (url.pathname.endsWith('/stations')) return json([station]) + if (url.pathname.endsWith('/stations')) return json(options.stationList ?? stations) if (url.pathname.endsWith('/chargers')) return json([{ id: 'c1', station_id: 's1', name: 'C1', status: 'CHARGING', is_active: true }]) if (url.pathname.endsWith('/sessions')) return json([session]) if (url.pathname.endsWith('/billing/invoices')) return json([]) @@ -27,7 +29,16 @@ function mockApi(withPrediction = false) { if (url.pathname.endsWith('/analytics/dashboard')) return json(summary) if (url.pathname.endsWith('/analytics/sustainability')) return json(sustainability) if (url.pathname.endsWith('/alerts')) return json([alert]) - if (url.pathname.endsWith('/predictions/demand')) return withPrediction ? json(prediction) : json({ detail: 'Prediction not found' }, 404) + if (url.pathname.endsWith('/predictions/demand/run') && init?.method === 'POST') { + if (options.runFailure === 'network') throw new TypeError('offline') + if (options.runFailure) return json({ detail: 'prediction failure' }, options.runFailure) + return json(options.runResult ?? prediction, 201) + } + if (url.pathname.endsWith('/predictions/demand')) { + if (options.predictionFailure === 'network') throw new TypeError('offline') + if (options.predictionFailure) return json({ detail: 'prediction failure' }, options.predictionFailure) + return options.prediction ? json(options.prediction) : json({ detail: 'Prediction not found' }, 404) + } if (url.pathname.endsWith('/alerts/a1/acknowledge') && init?.method === 'PATCH') return json({ ...alert, acknowledged_at: new Date().toISOString() }) throw Error(String(input)) }) @@ -37,41 +48,72 @@ function show() { tokenStore.set('token'); render( localStorage.clear()) afterEach(() => { cleanup(); vi.restoreAllMocks() }) -describe('admin dashboard', () => { - it('renders live indicators, history and a clear missing ML state', async () => { - mockApi() - show() - expect(await screen.findByRole('heading', { name: 'Dashboard do gestor' })).toBeInTheDocument() - expect(await screen.findByText('Ainda não há dados válidos de ML para esta estação.')).toBeInTheDocument() - expect(screen.getAllByText('20 kW').length).toBeGreaterThan(0) - expect(screen.getByText('60 kW')).toBeInTheDocument() +describe('admin dashboard prediction', () => { + it('preserves the informative state when there is no prediction yet', async () => { + mockApi(); show() + expect(await screen.findByText('Ainda não há previsão para esta estação.')).toBeInTheDocument() + expect(screen.queryByText('54 kW')).not.toBeInTheDocument() + }) + + it.each([['LOW', 'Baixo'], ['MEDIUM', 'Médio'], ['HIGH', 'Alto']] as const)('shows a valid %s risk in Portuguese with a non-color indicator', async (level, label) => { + mockApi({ prediction: { ...prediction, risk_level: level } }); show() + expect(await screen.findByText('54 kW')).toBeInTheDocument() + const badge = screen.getByLabelText(`Risco ${label}`) + expect(badge).toHaveTextContent(label) + expect(badge.querySelector('[aria-hidden="true"]')).not.toHaveTextContent('') + expect(screen.getByText('Horário-alvo')).toBeInTheDocument() + expect(screen.getByText('rf-2026.09')).toBeInTheDocument() + expect(screen.getByText('Monitore novas sessões.')).toBeInTheDocument() + expect(screen.getByText('A previsão apoia decisões operacionais e não executa controle energético.')).toBeInTheDocument() + }) + + it('identifies an expired prediction without presenting its values as current', async () => { + mockApi({ prediction: { ...prediction, prediction_for: new Date(Date.now() - 60000).toISOString() } }); show() + expect(await screen.findByText(/A previsão desta estação expirou/)).toBeInTheDocument() + expect(screen.queryByText('54 kW')).not.toBeInTheDocument() + }) + + it.each([[422, 'Dados insuficientes para gerar uma previsão desta estação.'], [503, 'Modelo de previsão indisponível no momento.'], [500, 'Não foi possível comunicar com o serviço de previsão.']] as const)('reports run failure %s with its specific state', async (status, message) => { + mockApi({ runFailure: status }); show() + await screen.findByText('Ainda não há previsão para esta estação.') + fireEvent.click(screen.getByRole('button', { name: 'Gerar nova previsão' })) + expect(await screen.findByText(message)).toBeInTheDocument() + }) + + it('reports a network communication error while preserving the rest of the dashboard', async () => { + mockApi({ predictionFailure: 'network' }); show() + expect(await screen.findByText('Não foi possível comunicar com o serviço de previsão.')).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Histórico recente' })).toBeInTheDocument() - expect(screen.queryByText('Risco de pico')).not.toBeInTheDocument() }) - it('shows a valid prediction and acknowledges alerts through the API', async () => { - const fetch = mockApi(true) - show() - expect(await screen.findByText('Risco de pico')).toBeInTheDocument() - expect(screen.getByText('54 kW')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'Reconhecer' })) - expect(await screen.findByText('Reconhecido')).toBeInTheDocument() - expect(fetch.mock.calls.some(([url, init]) => String(url).endsWith('/alerts/a1/acknowledge') && init?.method === 'PATCH')).toBe(true) + it('does not show a prediction returned for another station', async () => { + mockApi({ prediction: { ...prediction, station_id: 's2' } }); show() + expect(await screen.findByText('Ainda não há previsão para esta estação.')).toBeInTheDocument() + expect(screen.queryByText('54 kW')).not.toBeInTheDocument() }) - it('applies station and period filters', async () => { - const fetch = mockApi() - show() - await screen.findByText('Ainda não há dados válidos de ML para esta estação.') + it('requires a station filter when multiple stations exist and requests only the selected station', async () => { + const fetch = mockApi({ stationList: [...stations, { ...stations[0], id: 's2', name: 'Estação Norte' }] }); show() + expect(await screen.findByText('Selecione uma estação para consultar a previsão de demanda.')).toBeInTheDocument() + expect(fetch.mock.calls.some(([url]) => String(url).includes('/predictions/demand'))).toBe(false) + fireEvent.change(screen.getByLabelText('Estação'), { target: { value: 's2' } }) + await screen.findByText('Ainda não há previsão para esta estação.') + expect(fetch.mock.calls.some(([url]) => String(url).includes('/predictions/demand?station_id=s2'))).toBe(true) + }) +}) + +describe('admin dashboard operations', () => { + it('applies period filters and acknowledges alerts through the API', async () => { + const fetch = mockApi({ prediction }); show() + await screen.findByText('54 kW') fireEvent.change(screen.getByLabelText('Período'), { target: { value: '24h' } }) await waitFor(() => expect(fetch.mock.calls.some(([url]) => String(url).includes('/analytics/dashboard?') && String(url).includes('from='))).toBe(true)) - fireEvent.change(screen.getByLabelText('Estação'), { target: { value: 's1' } }) - await waitFor(() => expect(fetch.mock.calls.some(([url]) => String(url).includes('/analytics/dashboard?') && String(url).includes('station_id=s1'))).toBe(true)) + fireEvent.click(await screen.findByRole('button', { name: 'Reconhecer' })) + expect(await screen.findByText('Reconhecido')).toBeInTheDocument() }) - it('offers retry on load failure and preserves alert on acknowledgement failure', async () => { - const fetch = mockApi() - let failed = false + it('offers retry on dashboard load failure and preserves an alert on acknowledgement failure', async () => { + const fetch = mockApi(); let failed = false const original = fetch.getMockImplementation()! fetch.mockImplementation(async (input, init) => { if (String(input).endsWith('/analytics/dashboard') && !failed) { failed = true; return json({}, 500) } diff --git a/frontend/src/pages/AdminDashboardPage.tsx b/frontend/src/pages/AdminDashboardPage.tsx index 2457e59..4849745 100644 --- a/frontend/src/pages/AdminDashboardPage.tsx +++ b/frontend/src/pages/AdminDashboardPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Area, AreaChart, Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' import { api, ApiError, type Alert, type Charger, type ChargingSession, type Dashboard, type DemandPrediction, type EnergyReading, type Invoice, type SolarReading, type Station, type Sustainability } from '../api/client' import { useAuth } from '../auth/context' @@ -6,12 +6,14 @@ import { DashboardCard } from '../components/DashboardCard' import { AppShell } from '../layouts/AppShell' import { aggregateEnergyHistory } from './energyHistory' -type Data = { stations: Station[]; chargers: Charger[]; sessions: ChargingSession[]; invoices: Invoice[]; readings: EnergyReading[]; currentReadings: EnergyReading[]; solar: SolarReading[]; summary: Dashboard; sustainability: Sustainability; alerts: Alert[]; prediction: DemandPrediction | null; predictionError: boolean } +type PredictionState = 'valid' | 'expired' | 'missing' | 'insufficient' | 'model-unavailable' | 'communication-error' | 'select-station' +type Data = { stations: Station[]; chargers: Charger[]; sessions: ChargingSession[]; invoices: Invoice[]; readings: EnergyReading[]; currentReadings: EnergyReading[]; solar: SolarReading[]; summary: Dashboard; sustainability: Sustainability; alerts: Alert[]; prediction: DemandPrediction | null; predictionState: PredictionState } const number = (value: number) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(value) const money = (value: string) => new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(value)) const time = (value: string) => new Date(value).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' }) const active = (session: ChargingSession) => session.status === 'CHARGING' || session.status === 'PAUSED' -const validPrediction = (prediction: DemandPrediction | null, stationId: string) => prediction && prediction.station_id === stationId && Date.parse(prediction.prediction_for) > Date.now() && Number.isFinite(prediction.predicted_demand_kw) && prediction.predicted_demand_kw >= 0 && Number.isFinite(prediction.capacity_kw) && prediction.capacity_kw > 0 && ['LOW', 'MEDIUM', 'HIGH'].includes(prediction.risk_level) +const validPrediction = (prediction: DemandPrediction, stationId: string) => prediction.station_id === stationId && Number.isFinite(prediction.predicted_demand_kw) && prediction.predicted_demand_kw >= 0 && Number.isFinite(prediction.capacity_kw) && prediction.capacity_kw > 0 && ['LOW', 'MEDIUM', 'HIGH'].includes(prediction.risk_level) +const risk = { LOW: { label: 'Baixo', symbol: '●' }, MEDIUM: { label: 'Médio', symbol: '▲' }, HIGH: { label: 'Alto', symbol: '!' } } as const export function AdminDashboardPage() { const { state, logout } = useAuth() @@ -22,6 +24,9 @@ export function AdminDashboardPage() { const [error, setError] = useState('') const [actionError, setActionError] = useState('') const [pendingAlert, setPendingAlert] = useState(null) + const [runningPrediction, setRunningPrediction] = useState(false) + const stationIdRef = useRef(stationId) + stationIdRef.current = stationId useEffect(() => { let live = true @@ -31,12 +36,17 @@ export function AdminDashboardPage() { Promise.all([api.stations(), api.chargers(), api.sessions(), api.invoices({ status: 'CLOSED' }), readingsPromise, from ? api.energyHistory({ station_id: stationId || undefined }) : readingsPromise, api.solarHistory({ station_id: stationId || undefined }), api.dashboard(filters), api.sustainability(filters), api.alerts(stationId || undefined)]).then(async ([stations, chargers, sessions, invoices, readings, currentReadings, solar, summary, sustainability, alerts]) => { const predictionStation = stationId || (stations.length === 1 ? stations[0].id : '') let prediction: DemandPrediction | null = null - let predictionError = false + let predictionState: PredictionState = predictionStation ? 'missing' : 'select-station' if (predictionStation) { - try { prediction = await api.prediction(predictionStation) } - catch (cause) { if (!(cause instanceof ApiError && cause.status === 404)) predictionError = true } + try { + const received = await api.prediction(predictionStation) + if (validPrediction(received, predictionStation)) { + prediction = received + predictionState = Date.parse(received.prediction_for) > Date.now() ? 'valid' : 'expired' + } + } catch (cause) { if (!(cause instanceof ApiError && cause.status === 404)) predictionState = 'communication-error' } } - if (live) setData({ stations, chargers, sessions, invoices, readings, currentReadings, solar, summary, sustainability, alerts, prediction, predictionError }) + if (live) setData({ stations, chargers, sessions, invoices, readings, currentReadings, solar, summary, sustainability, alerts, prediction, predictionState }) }).catch(() => { if (live) setError('Não foi possível carregar o dashboard.') }) return () => { live = false } }, [stationId, period, attempt]) @@ -51,6 +61,23 @@ export function AdminDashboardPage() { finally { setPendingAlert(null) } } + async function runPrediction() { + if (!data) return + const selectedStation = stationId || (data.stations.length === 1 ? data.stations[0].id : '') + if (!selectedStation) return + setRunningPrediction(true) + try { + const prediction = await api.runPrediction(selectedStation) + if (stationIdRef.current !== stationId) return + if (validPrediction(prediction, selectedStation)) setData(previous => previous && { ...previous, prediction, predictionState: Date.parse(prediction.prediction_for) > Date.now() ? 'valid' : 'expired' }) + else setData(previous => previous && { ...previous, prediction: null, predictionState: 'missing' }) + } catch (cause) { + if (stationIdRef.current !== stationId) return + const predictionState: PredictionState = cause instanceof ApiError && cause.status === 422 ? 'insufficient' : cause instanceof ApiError && cause.status === 503 ? 'model-unavailable' : 'communication-error' + setData(previous => previous && { ...previous, prediction: null, predictionState }) + } finally { setRunningPrediction(false) } + } + const chargerMap = new Map(data?.chargers.map(charger => [charger.id, charger]) ?? []) const shownSessions = data?.sessions.filter(session => !stationId || chargerMap.get(session.charger_id)?.station_id === stationId) ?? [] const currentSessions = shownSessions.filter(active) @@ -66,6 +93,14 @@ export function AdminDashboardPage() { const solarAvailable = [...currentSolar.values()].reduce((total, reading) => total + reading.available_power_kw, 0) const predictionStation = stationId || (data?.stations.length === 1 ? data.stations[0].id : '') const prediction = data?.prediction && validPrediction(data.prediction, predictionStation) ? data.prediction : null + const predictionMessages: Record, string> = { + expired: 'A previsão desta estação expirou. Gere uma nova previsão para consultar o próximo horário-alvo.', + missing: 'Ainda não há previsão para esta estação.', + insufficient: 'Dados insuficientes para gerar uma previsão desta estação.', + 'model-unavailable': 'Modelo de previsão indisponível no momento.', + 'communication-error': 'Não foi possível comunicar com o serviço de previsão.', + 'select-station': 'Selecione uma estação para consultar a previsão de demanda.', + } const history = aggregateEnergyHistory(data?.readings ?? []).map(point => ({ ...point, label: time(point.timestamp) })) const periodStart = period === '24h' ? Date.now() - 86400000 : period === '7d' ? Date.now() - 7 * 86400000 : 0 const shownSessionIds = new Set(shownSessions.map(session => session.id)) @@ -75,8 +110,12 @@ export function AdminDashboardPage() {

Administração

Dashboard do gestor

Olá, {state.status === 'authenticated' ? state.user.name : ''}

{error ?

{error}

: !data ?

Carregando dashboard...

: <> -
session.charger_id)).size} />{prediction && <>}
- {!prediction &&

{data.predictionError ? 'Previsão indisponível no momento.' : !predictionStation ? 'Selecione uma estação para consultar a previsão de demanda.' : 'Ainda não há dados válidos de ML para esta estação.'}

} +
session.charger_id)).size} />
+
+

Previsão consultiva

Demanda prevista

{predictionStation && }
+ {data.predictionState === 'valid' && prediction ?
Demanda{number(prediction.predicted_demand_kw)} kW
Risco de pico {risk[prediction.risk_level].label}
Horário-alvo{time(prediction.prediction_for)}
Versão do modelo{prediction.model_version}
Recomendação{prediction.recommendation}
:

{predictionMessages[data.predictionState as Exclude]}

} +

A previsão apoia decisões operacionais e não executa controle energético.

+

Demanda ao longo do tempo

{history.length ? :

Sem histórico de demanda no período.

}

Solar vs rede

{history.length ? :

Sem dados de energia no período.

}

Sessões

{data.summary.session_count} iniciadas · {data.summary.completed_session_count} concluídas no período

    {shownSessions.slice(-8).reverse().map(session =>
  • {session.status}{number(session.energy_consumed_kwh)} kWh
  • )}
{!shownSessions.length &&

Nenhuma sessão encontrada.

}

Faturamento

{invoiceHistory.length ? money(String(value))} /> :

Sem faturamento no período.

}

Histórico recente

{history.length ?
{(data.readings ?? []).slice(-10).reverse().map(reading => )}
DataDemandaSolarRede
{time(reading.timestamp)}{number(reading.allocated_power_kw)} kW{number(reading.solar_power_kw)} kW{number(reading.grid_power_kw)} kW
:

Nenhum registro no período.

}

Alertas recentes

{actionError &&

{actionError}

}{data.alerts.length ?
    {data.alerts.slice(0, 10).map(alert =>
  • {alert.title}

    {alert.message}

    {time(alert.created_at)} · {alert.severity}
    {alert.acknowledged_at ? Reconhecido : }
  • )}
:

Nenhum alerta recente.

}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 71acb70..9ffb567 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -89,11 +89,24 @@ a { color: inherit; text-decoration: none; } .dashboard button { cursor: pointer; background: #43e7a3; color: #062019; font-weight: 700; } .dashboard button:disabled { opacity: .6; cursor: wait; } .kpi-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1rem; } -.dashboard-card, .chart-panel, .empty-note, .dashboard-error { border: 1px solid #275343; border-radius: 1rem; background: #0c271e; padding: 1.35rem; } +.dashboard-card, .chart-panel, .empty-note, .dashboard-error, .prediction-panel { border: 1px solid #275343; border-radius: 1rem; background: #0c271e; padding: 1.35rem; } .dashboard-card h3 { margin: 0 0 .8rem; color: #a9c7ba; font-size: .9rem; font-weight: 500; } .dashboard-card strong { font-size: 1.7rem; } .dashboard-card p { margin: .5rem 0 0; color: #a9c7ba; font-size: .8rem; } .empty-note { margin: 1rem 0; } +.prediction-panel { margin-top: 1rem; } +.prediction-panel__heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } +.prediction-panel__heading h2 { margin: .35rem 0 0; } +.prediction-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1rem; margin-top: 1.25rem; } +.prediction-grid > div { display: grid; align-content: start; gap: .45rem; padding: 1rem; border-radius: .75rem; background: #071f18; } +.prediction-grid span, .advisory-note { color: #a9c7ba; font-size: .85rem; } +.prediction-grid strong { line-height: 1.35; overflow-wrap: anywhere; } +.prediction-recommendation { grid-column: 1 / -1; } +.risk-badge { display: inline-flex; width: fit-content; align-items: center; gap: .45rem; padding: .3rem .55rem; border: 1px solid currentColor; border-radius: 999px; } +.risk-badge--low { color: #74eab8; } +.risk-badge--medium { color: #f4c95d; } +.risk-badge--high { color: #ff9f96; } +.advisory-note { margin: 1rem 0 0; } .chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; margin-top: 1rem; } .chart-panel { min-width: 0; margin-top: 1rem; } .chart-grid .chart-panel { margin-top: 0; } @@ -107,5 +120,5 @@ a { color: inherit; text-decoration: none; } .dashboard table { width: 100%; border-collapse: collapse; text-align: left; } .dashboard th, .dashboard td { padding: .7rem; border-bottom: 1px solid #275343; white-space: nowrap; } .dashboard [role="alert"] { color: #ffb2a9; } -@media (max-width: 900px) { .kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@media (max-width: 650px) { .dashboard { padding: 1rem; } .chart-grid, .kpi-grid { grid-template-columns: 1fr; } } +@media (max-width: 900px) { .kpi-grid, .prediction-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 650px) { .dashboard { padding: 1rem; } .chart-grid, .kpi-grid, .prediction-grid { grid-template-columns: 1fr; } .prediction-recommendation { grid-column: auto; } } From b80214b87941a1ab2e0e0bbd357d9cfa55d7c1cc Mon Sep 17 00:00:00 2001 From: Matheus Santana <130666046+mathsant-js@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:59:54 -0300 Subject: [PATCH 5/5] feat(frontend): add driver vehicle and charging session workflow --- frontend/src/App.test.tsx | 10 +- frontend/src/api/client.ts | 9 +- frontend/src/pages/UserDashboardPage.test.tsx | 66 ++++++++++++- frontend/src/pages/UserDashboardPage.tsx | 93 +++++++++++++++++-- frontend/src/styles.css | 22 ++++- 5 files changed, 179 insertions(+), 21 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index d37180b..05a489f 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -18,6 +18,7 @@ describe('authentication flow', () => { if (path.endsWith('/auth/login')) return json({ access_token: 'token', token_type: 'bearer' }) if (path.endsWith('/auth/me')) return json(user) if (path.endsWith('/user/dashboard')) return json({ current_session: null, session_history: [], invoices: [] }) + if (path.endsWith('/vehicles') || path.endsWith('/stations') || path.endsWith('/chargers')) return json([]) throw Error(path) }) show('/login') @@ -58,12 +59,13 @@ describe('authentication flow', () => { tokenStore.set('saved') let attempts = 0 vi.spyOn(globalThis, 'fetch').mockImplementation(async input => { - if (String(input).endsWith('/auth/me')) return json(user) - attempts += 1 - return attempts === 1 ? json({ detail: 'error' }, 500) : json({ current_session: null, session_history: [], invoices: [] }) + const path = String(input) + if (path.endsWith('/auth/me')) return json(user) + if (path.endsWith('/user/dashboard')) { attempts += 1; return attempts === 1 ? json({ detail: 'error' }, 500) : json({ current_session: null, session_history: [], invoices: [] }) } + return json([]) }) show('/user') - expect(await screen.findByRole('alert')).toHaveTextContent('Não foi possível carregar o dashboard.') + expect(await screen.findByRole('alert')).toHaveTextContent('Não foi possível carregar os dados operacionais.') fireEvent.click(screen.getByRole('button', { name: 'Tentar novamente' })) await waitFor(() => expect(screen.getByText('Nenhuma recarga em andamento.')).toBeInTheDocument()) }) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 24a506e..369f7a6 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -3,6 +3,8 @@ const TOKEN_KEY = 'chargegrid.access_token' export type Role = 'ADMIN' | 'USER' export interface User { id: string; name: string; email: string; role: Role; is_active: boolean; created_at: string; updated_at: string } +export interface Vehicle { id: string; user_id: string; name: string; brand: string; model: string; license_plate: string; max_charge_power_kw: number; created_at: string; updated_at: string } +export interface VehicleInput { name: string; brand: string; model: string; license_plate: string; max_charge_power_kw: number } export interface TokenResponse { access_token: string; token_type: 'bearer' } export interface ChargingSession { id: string; user_id: string; vehicle_id: string; charger_id: string; status: 'CREATED' | 'CHARGING' | 'PAUSED' | 'COMPLETED' | 'CANCELLED'; started_at: string | null; ended_at: string | null; requested_power_kw: number; allocated_power_kw: number; energy_consumed_kwh: number; solar_energy_kwh: number; grid_energy_kwh: number; tariff_per_kwh: string; total_cost: string; created_at: string; updated_at: string } export interface EnergyReading { id: string; session_id: string; timestamp: string; requested_power_kw: number; allocated_power_kw: number; solar_power_kw: number; grid_power_kw: number; interval_energy_kwh: number; solar_energy_kwh: number; grid_energy_kwh: number } @@ -13,7 +15,7 @@ export interface Dashboard { station_id: string | null; user_id: string | null; export interface Sustainability { station_id: string | null; user_id: string | null; energy_consumed_kwh: number; solar_energy_kwh: number; grid_energy_kwh: number; solar_percentage: number; avoided_co2_kg: number; grid_emission_factor_kg_per_kwh: number; estimated_solar_savings: string; currency: string } export interface Filters { station_id?: string; user_id?: string; from?: string; to?: string } export interface Station { id: string; name: string; grid_limit_kw: number; station_peak_solar_kw: number; is_active: boolean } -export interface Charger { id: string; station_id: string; name: string; status: 'AVAILABLE' | 'CHARGING' | 'UNAVAILABLE'; is_active: boolean } +export interface Charger { id: string; station_id: string; name: string; code: string; max_power_kw: number; status: 'AVAILABLE' | 'CHARGING' | 'UNAVAILABLE'; is_active: boolean } export interface SolarReading { id: string; station_id: string; timestamp: string; available_power_kw: number } export interface DemandPrediction { id: string; station_id: string; generated_at: string; prediction_for: string; predicted_demand_kw: number; capacity_kw: number; risk_level: 'LOW' | 'MEDIUM' | 'HIGH'; prediction_horizon_minutes: number; model_version: string; recommendation: string } export interface Alert { id: string; station_id: string; type: string; severity: 'INFO' | 'WARNING' | 'CRITICAL'; title: string; message: string; created_at: string; acknowledged_at: string | null } @@ -48,6 +50,7 @@ async function request(path: string, options: RequestInit = {}, authenticated const detail = typeof body === 'object' && body !== null && 'detail' in body && typeof body.detail === 'string' ? body.detail : undefined throw new ApiError(response.status, response.status === 403 ? 'Acesso não permitido.' : detail ?? `Erro HTTP ${response.status}`) } + if (response.status === 204) return undefined as T return response.json() as Promise } @@ -56,6 +59,10 @@ export const api = { me: () => request('/auth/me'), sessions: () => request('/sessions'), userDashboard: () => request('/user/dashboard'), + vehicles: () => request('/vehicles'), + createVehicle: (vehicle: VehicleInput) => request('/vehicles', { method: 'POST', body: JSON.stringify(vehicle) }), + updateVehicle: (id: string, vehicle: VehicleInput) => request(`/vehicles/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(vehicle) }), + deleteVehicle: (id: string) => request(`/vehicles/${encodeURIComponent(id)}`, { method: 'DELETE' }), session: (id: string) => request(`/sessions/${encodeURIComponent(id)}`), startSession: (vehicle_id: string, charger_id: string) => request('/sessions/start', { method: 'POST', body: JSON.stringify({ vehicle_id, charger_id }) }), stopSession: (id: string) => request(`/sessions/${encodeURIComponent(id)}/stop`, { method: 'POST' }), diff --git a/frontend/src/pages/UserDashboardPage.test.tsx b/frontend/src/pages/UserDashboardPage.test.tsx index 02af465..85541e0 100644 --- a/frontend/src/pages/UserDashboardPage.test.tsx +++ b/frontend/src/pages/UserDashboardPage.test.tsx @@ -1,4 +1,4 @@ -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { App } from '../App' @@ -7,13 +7,28 @@ import { tokenStore } from '../api/client' const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }) const user = { id: 'u1', name: 'Bia', email: 'bia@example.com', role: 'USER', is_active: true, created_at: '', updated_at: '' } const current = { id: 's1', status: 'CHARGING', vehicle_name: 'Meu EV', charger_name: 'CH-01', started_at: '2026-09-17T12:00:00Z', ended_at: null, duration_seconds: 3660, allocated_power_kw: 11, energy_consumed_kwh: 10, solar_percentage: 40, estimated_cost: '9.20', invoice_total: null } +const dashboard = { current_session: null, session_history: [], invoices: [] } +const vehicle = { id: 'v1', user_id: 'u1', name: 'Meu EV', brand: 'Good', model: 'One', license_plate: 'EV-2026', max_charge_power_kw: 11, created_at: '', updated_at: '' } +const station = { id: 'st1', name: 'Estação Centro', grid_limit_kw: 50, station_peak_solar_kw: 20, is_active: true } +const charger = { id: 'c1', station_id: 'st1', name: 'CH-01', code: 'CH-01', max_power_kw: 22, status: 'AVAILABLE', is_active: true } + +function apiResponse(input: RequestInfo | URL, dashboardData: unknown = dashboard) { + const url = String(input) + if (url.endsWith('/auth/me')) return json(user) + if (url.endsWith('/user/dashboard')) return json(dashboardData) + if (url.endsWith('/vehicles')) return json([vehicle]) + if (url.endsWith('/stations')) return json([station]) + if (url.endsWith('/chargers')) return json([charger]) + throw new Error(`Unexpected request: ${url}`) +} function show() { tokenStore.set('token'); render() } beforeEach(() => localStorage.clear()) afterEach(() => { cleanup(); vi.restoreAllMocks() }) it('shows the active estimate, completed invoice amount and histories', async () => { - vi.spyOn(globalThis, 'fetch').mockImplementation(async input => String(input).endsWith('/auth/me') ? json(user) : json({ current_session: current, session_history: [{ ...current, id: 's0', status: 'COMPLETED', estimated_cost: null, invoice_total: '8.75' }], invoices: [{ id: 'i0', session_id: 's0', status: 'CLOSED', closed_at: '2026-09-17T13:00:00Z', energy_kwh: '10', total: '8.75' }] })) + const populated = { current_session: current, session_history: [{ ...current, id: 's0', status: 'COMPLETED', estimated_cost: null, invoice_total: '8.75' }], invoices: [{ id: 'i0', session_id: 's0', status: 'CLOSED', closed_at: '2026-09-17T13:00:00Z', energy_kwh: '10', total: '8.75' }] } + vi.spyOn(globalThis, 'fetch').mockImplementation(async input => apiResponse(input, populated)) show() expect(await screen.findByText('Meu EV · CH-01 · Carregando')).toBeInTheDocument() expect(screen.getByText('Custo estimado')).toBeInTheDocument() @@ -28,15 +43,56 @@ it('shows loading, empty and retry states', async () => { const firstDashboard = new Promise(resolve => { resolveFirstDashboard = resolve }) vi.spyOn(globalThis, 'fetch').mockImplementation(async input => { if (String(input).endsWith('/auth/me')) return json(user) - calls++ - return calls === 1 ? firstDashboard : json({ current_session: null, session_history: [], invoices: [] }) + if (String(input).endsWith('/user/dashboard')) { calls++; return calls === 1 ? firstDashboard : json(dashboard) } + return apiResponse(input) }) show() expect(await screen.findByText('Carregando dashboard...')).toBeInTheDocument() await act(async () => { resolveFirstDashboard(json({}, 500)) }) - expect(await screen.findByRole('alert')).toHaveTextContent('Não foi possível carregar o dashboard.') + expect(await screen.findByRole('alert')).toHaveTextContent('Não foi possível carregar os dados operacionais.') fireEvent.click(screen.getByRole('button', { name: 'Tentar novamente' })) expect(await screen.findByText('Nenhuma recarga em andamento.')).toBeInTheDocument() expect(screen.getByText('Nenhuma sessão anterior.')).toBeInTheDocument() expect(screen.getByText('Nenhuma invoice disponível.')).toBeInTheDocument() }) + +it('starts and stops a session using backend responses and prevents duplicate submissions', async () => { + let active = false + let startCalls = 0 + let stopCalls = 0 + vi.spyOn(globalThis, 'fetch').mockImplementation(async input => { + const url = String(input) + if (url.endsWith('/sessions/start')) { startCalls++; active = true; return json({ id: 's1' }, 201) } + if (url.endsWith('/sessions/s1/stop')) { stopCalls++; active = false; return json({ id: 's1' }) } + if (url.endsWith('/user/dashboard')) return json(active ? { ...dashboard, current_session: current } : dashboard) + return apiResponse(input) + }) + show() + const start = await screen.findByRole('button', { name: 'Iniciar sessão' }) + await waitFor(() => expect(start).toBeEnabled()) + fireEvent.click(start); fireEvent.click(start) + expect(await screen.findByText('Sessão iniciada com sucesso.')).toBeInTheDocument() + expect(startCalls).toBe(1) + fireEvent.click(screen.getByRole('button', { name: 'Encerrar sessão atual' })) + expect(screen.getByRole('alertdialog')).toHaveTextContent('Confirma o encerramento') + const confirm = screen.getByRole('button', { name: 'Sim, encerrar sessão' }) + fireEvent.click(confirm); fireEvent.click(confirm) + expect(await screen.findByText('Sessão encerrada e dashboard atualizado.')).toBeInTheDocument() + expect(stopCalls).toBe(1) +}) + +it('shows expected conflict and validation messages', async () => { + let status = 409 + vi.spyOn(globalThis, 'fetch').mockImplementation(async input => { + if (String(input).endsWith('/sessions/start')) return json({ detail: 'conflict' }, status) + return apiResponse(input) + }) + show() + const start = await screen.findByRole('button', { name: 'Iniciar sessão' }) + await waitFor(() => expect(start).toBeEnabled()) + fireEvent.click(start) + expect(await screen.findByRole('alert')).toHaveTextContent('conflito com o estado atual') + status = 422 + fireEvent.click(start) + expect(await screen.findByRole('alert')).toHaveTextContent('Revise os campos informados') +}) diff --git a/frontend/src/pages/UserDashboardPage.tsx b/frontend/src/pages/UserDashboardPage.tsx index d498476..e16cb91 100644 --- a/frontend/src/pages/UserDashboardPage.tsx +++ b/frontend/src/pages/UserDashboardPage.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react' -import { api, type UserDashboard, type UserSessionSummary } from '../api/client' +import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react' +import { ApiError, api, type Charger, type Station, type UserDashboard, type UserSessionSummary, type Vehicle, type VehicleInput } from '../api/client' import { useAuth } from '../auth/context' import { DashboardCard } from '../components/DashboardCard' import { AppShell } from '../layouts/AppShell' @@ -9,25 +9,98 @@ const money = (value: string) => new Intl.NumberFormat('pt-BR', { style: 'curren const date = (value: string | null) => value ? new Date(value).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' }) : '—' const duration = (seconds: number) => `${Math.floor(seconds / 3600)}h ${String(Math.floor(seconds % 3600 / 60)).padStart(2, '0')}min` const statusLabel: Record = { CREATED: 'Criada', CHARGING: 'Carregando', PAUSED: 'Pausada', COMPLETED: 'Concluída', CANCELLED: 'Cancelada' } +const emptyVehicle: VehicleInput = { name: '', brand: '', model: '', license_plate: '', max_charge_power_kw: 0 } + +function errorMessage(error: unknown) { + if (!(error instanceof ApiError)) return 'Não foi possível concluir a operação. Tente novamente.' + const messages: Record = { + 0: 'Não foi possível conectar à API.', + 401: 'Sua sessão expirou. Entre novamente.', + 403: 'Você não tem permissão para realizar esta operação.', + 404: 'O recurso selecionado não foi encontrado. Atualize os dados e tente novamente.', + 409: 'A operação entra em conflito com o estado atual. O veículo ou carregador pode já estar em uso.', + 422: 'Revise os campos informados e tente novamente.', + } + return messages[error.status] ?? error.message +} export function UserDashboardPage() { const { state, logout } = useAuth() const [data, setData] = useState(null) + const [vehicles, setVehicles] = useState([]) + const [stations, setStations] = useState([]) + const [chargers, setChargers] = useState([]) const [error, setError] = useState(false) - const [attempt, setAttempt] = useState(0) + const [notice, setNotice] = useState<{ kind: 'success' | 'error'; text: string } | null>(null) + const [busy, setBusy] = useState(null) + const [editing, setEditing] = useState(null) + const [vehicleForm, setVehicleForm] = useState(emptyVehicle) + const [selectedVehicle, setSelectedVehicle] = useState('') + const [selectedStation, setSelectedStation] = useState('') + const [selectedCharger, setSelectedCharger] = useState('') + const [confirmStop, setConfirmStop] = useState(false) + + const load = useCallback(async () => { + setError(false) + try { + const [dashboard, ownVehicles, allStations, allChargers] = await Promise.all([api.userDashboard(), api.vehicles(), api.stations(), api.chargers()]) + setData(dashboard); setVehicles(ownVehicles); setStations(allStations); setChargers(allChargers) + setSelectedVehicle(value => ownVehicles.some(item => item.id === value) ? value : ownVehicles[0]?.id ?? '') + setSelectedStation(value => allStations.some(item => item.id === value && item.is_active) ? value : allStations.find(item => item.is_active)?.id ?? '') + } catch { setError(true) } + }, []) + + useEffect(() => { void load() }, [load]) + + const availableChargers = useMemo(() => chargers.filter(charger => charger.station_id === selectedStation && charger.is_active && charger.status === 'AVAILABLE'), [chargers, selectedStation]) + useEffect(() => { setSelectedCharger(value => availableChargers.some(item => item.id === value) ? value : availableChargers[0]?.id ?? '') }, [availableChargers]) + + async function run(key: string, action: () => Promise, success: string) { + if (busy) return false + setBusy(key); setNotice(null) + try { await action(); setNotice({ kind: 'success', text: success }); await load(); return true } + catch (cause) { setNotice({ kind: 'error', text: errorMessage(cause) }); return false } + finally { setBusy(null) } + } + + function submitVehicle(event: FormEvent) { + event.preventDefault() + const action = editing ? api.updateVehicle(editing, vehicleForm) : api.createVehicle(vehicleForm) + void run('vehicle', () => action, editing ? 'Veículo atualizado com sucesso.' : 'Veículo cadastrado com sucesso.').then(success => { + if (success) { setEditing(null); setVehicleForm(emptyVehicle) } + }) + } + + function editVehicle(vehicle: Vehicle) { + setEditing(vehicle.id) + setVehicleForm({ name: vehicle.name, brand: vehicle.brand, model: vehicle.model, license_plate: vehicle.license_plate, max_charge_power_kw: vehicle.max_charge_power_kw }) + } - useEffect(() => { - let live = true - api.userDashboard().then(result => { if (live) setData(result) }).catch(() => { if (live) setError(true) }) - return () => { live = false } - }, [attempt]) + function reload() { setData(null); setNotice(null); void load() } - function reload() { setData(null); setError(false); setAttempt(value => value + 1) } const current = data?.current_session return

Área do usuário

Minha recarga

Olá, {state.status === 'authenticated' ? state.user.name : ''}

- {error ?

Não foi possível carregar o dashboard.

: !data ?

Carregando dashboard...

: <> + {notice &&

{notice.text}

} + {error ?

Não foi possível carregar os dados operacionais.

: !data ?

Carregando dashboard...

: <> +

Garagem

Meus veículos

+ {vehicles.length ?
    {vehicles.map(vehicle =>
  • {vehicle.name}{vehicle.brand} {vehicle.model} · {vehicle.license_plate} · {number(vehicle.max_charge_power_kw)} kW
  • )}
:

Nenhum veículo cadastrado.

} +

{editing ? 'Editar veículo' : 'Cadastrar veículo'}

+ + + + + +
{editing && }
+
+

Operação

Iniciar recarga

{current ?

Encerre a sessão atual antes de iniciar outra.

:
{ event.preventDefault(); void run('start', () => api.startSession(selectedVehicle, selectedCharger), 'Sessão iniciada com sucesso.') }}> + + + + +
}

Sessão atual

{current ? <>

{current.vehicle_name} · {current.charger_name} · {statusLabel[current.status]}

:

Nenhuma recarga em andamento.

}
+ {current &&

Encerrar recarga

{confirmStop ?

Confirma o encerramento da sessão atual?

: }
}

Histórico de sessões

{data.session_history.length ?
{data.session_history.map(session => )}
InícioVeículoCarregadorStatusTempo realEnergiaSolarCusto final
{date(session.started_at)}{session.vehicle_name}{session.charger_name}{statusLabel[session.status]}{duration(session.duration_seconds)}{number(session.energy_consumed_kwh)} kWh{number(session.solar_percentage)}%{session.invoice_total === null ? '—' : money(session.invoice_total)}
:

Nenhuma sessão anterior.

}

Invoices

{data.invoices.length ?
{data.invoices.map(invoice => )}
DataSessãoStatusEnergiaValor final
{date(invoice.closed_at ?? invoice.created_at)}{invoice.session_id}{invoice.status === 'CLOSED' ? 'Fechada' : invoice.status === 'OPEN' ? 'Aberta' : 'Cancelada'}{number(Number(invoice.energy_kwh))} kWh{invoice.status === 'CLOSED' ? money(invoice.total) : '—'}
:

Nenhuma invoice disponível.

}
} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9ffb567..95c28e6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -120,5 +120,25 @@ a { color: inherit; text-decoration: none; } .dashboard table { width: 100%; border-collapse: collapse; text-align: left; } .dashboard th, .dashboard td { padding: .7rem; border-bottom: 1px solid #275343; white-space: nowrap; } .dashboard [role="alert"] { color: #ffb2a9; } +.section-heading, .vehicle-list li, .row-actions { display: flex; align-items: center; justify-content: space-between; gap: .75rem; flex-wrap: wrap; } +.section-heading h2 { margin-bottom: 0; } +.vehicle-list { list-style: none; padding: 0; margin: 0 0 1.5rem; } +.vehicle-list li { padding: 1rem 0; border-bottom: 1px solid #275343; } +.vehicle-list li > div:first-child { display: grid; gap: .3rem; } +.vehicle-list span, .field-help { color: #a9c7ba; font-size: .88rem; } +.vehicle-form, .session-form { display: grid; gap: 1rem; margin-top: 1.25rem; padding-top: 1.25rem; border-top: 1px solid #275343; } +.vehicle-form h3 { margin: 0; } +.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } +.vehicle-form label, .session-form label { display: grid; gap: .4rem; color: #cce4da; } +.dashboard input { width: 100%; padding: .7rem 1rem; border-radius: .55rem; border: 1px solid #47705d; color: #e8f5ef; background: #071713; } +.session-form { grid-template-columns: repeat(3, minmax(0, 1fr)) auto; align-items: end; } +.row-actions { justify-content: flex-start; } +.dashboard .secondary-button { background: transparent; color: #e8f5ef; } +.dashboard .danger-button { background: #ff9f96; color: #32100d; } +.operation-notice { padding: 1rem; border: 1px solid #275343; border-radius: .75rem; background: #0c271e; } +.operation-notice--success { color: #74eab8; } +.operation-notice--error { color: #ffb2a9; } +.stop-panel { border-color: #78443f; } @media (max-width: 900px) { .kpi-grid, .prediction-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@media (max-width: 650px) { .dashboard { padding: 1rem; } .chart-grid, .kpi-grid, .prediction-grid { grid-template-columns: 1fr; } .prediction-recommendation { grid-column: auto; } } +@media (max-width: 900px) { .session-form { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 650px) { .dashboard { padding: 1rem; } .chart-grid, .kpi-grid, .prediction-grid, .form-grid, .session-form { grid-template-columns: 1fr; } .prediction-recommendation { grid-column: auto; } .vehicle-list li { align-items: flex-start; } }