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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ APP_CORS_ORIGINS=["http://localhost:5173"]
JWT_SECRET_KEY=development-only-change-me-minimum-32-bytes
JWT_EXPIRATION_MINUTES=60
JWT_ALGORITHM=HS256
GRID_EMISSION_FACTOR_KG_PER_KWH=0.0
# Deterministic clock for a fresh demo database only; also set APP_ENV=demo:
# DEMO_SIMULATION_START_UTC=2026-09-18T11:58:00Z

Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,14 @@ python3 scripts/sprint3_demo.py
- [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)
- [Fase 8 — ESG e alertas](docs/PHASE_8.md)
- [Fase 9 — Auditoria final do MVP](docs/PHASE_9.md)

## Estado atual

As Fases 1 (fundação), 2 (domínio), 3 (simulação e leituras), 4 (gestão
energética) e 5 (billing e histórico de invoices) estão concluídas, com
critérios de saída e evidências documentados. As migrations das Fases 3 e 5
foram validadas online em PostgreSQL. O backend entrega
As Fases 1 a 9 do MVP foram implementadas e auditadas, com limitações e
resultados de comandos registrados em [docs/PHASE_9.md](docs/PHASE_9.md).
As migrations foram validadas em PostgreSQL 16. O backend entrega
Users/Auth, Vehicles, Stations, Chargers e Sessions sob `/api/v1`, com JWT,
autorização por papel e propriedade, persistência
via Alembic e regras de início/encerramento de sessão na camada de serviço.
Expand All @@ -132,7 +133,7 @@ por padrão), calcula energia em kWh e atualiza os acumuladores das sessões.
O tick também cria `HIGH_DEMAND` quando a importação da rede atinge o limiar
configurado em `SystemConfiguration` (0,85 na ausência de configuração). O
alerta é emitido uma vez por episódio de alta demanda e participa da mesma
transação das leituras. O tick ainda não atualiza analytics derivados.
transação das leituras. Analytics e dashboards consultam as leituras persistidas.

Cada estação processada recebe uma `SolarReading` única por timestamp simulado;
cada sessão recebe uma `EnergyReading` única por timestamp. Uma reexecução no
Expand Down
8 changes: 6 additions & 2 deletions backend/app/api/routes/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
from uuid import UUID

from fastapi import APIRouter, HTTPException, Query, status
from sqlalchemy import select

from app.api.dependencies import CurrentUser
from app.api.routes.common import NOT_FOUND_RESPONSE, UNAUTHORIZED_RESPONSE, DbSession
from app.core.config import get_settings
from app.models.prediction import SystemConfiguration
from app.models.user import UserRole
from app.schemas.analytics import DashboardResponse, SustainabilityResponse
from app.services.analytics import AnalyticsFilters, dashboard, sustainability
Expand Down Expand Up @@ -75,10 +76,13 @@ async def get_sustainability(
station_id: UUID | None = None,
user_id: UUID | None = None,
) -> SustainabilityResponse:
configuration = db.scalar(select(SystemConfiguration).limit(1))
if configuration is None:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "ESG configuration unavailable")
return sustainability(
db,
filters_for_user(
current_user.id, current_user.role, station_id, user_id, date_from, date_to
),
get_settings().grid_emission_factor_kg_per_kwh,
configuration.grid_emission_factor_kg_per_kwh,
)
3 changes: 3 additions & 0 deletions backend/app/api/routes/predictions.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ async def update_system_configuration(
"simulation_speed": configuration.simulation_speed,
"grid_emission_factor_kg_per_kwh": configuration.grid_emission_factor_kg_per_kwh,
"high_demand_threshold": configuration.high_demand_threshold,
"high_solar_availability_threshold": (
configuration.high_solar_availability_threshold
),
"medium_peak_threshold": configuration.medium_peak_threshold,
"high_peak_threshold": configuration.high_peak_threshold,
}
Expand Down
1 change: 0 additions & 1 deletion backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ class Settings(BaseSettings):
app_log_level: str = "INFO"
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"
Expand Down
1 change: 1 addition & 0 deletions backend/app/demo_seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ def seed(db: Session, admin_password: str, user_password: str) -> None:
db.add(SystemConfiguration(simulation_speed=60,
grid_emission_factor_kg_per_kwh=EMISSION_FACTOR,
high_demand_threshold=0.85,
high_solar_availability_threshold=0.8,
medium_peak_threshold=0.7, high_peak_threshold=0.9))
elif config.grid_emission_factor_kg_per_kwh != EMISSION_FACTOR or config.simulation_speed != 60:
raise ValueError("Existing system configuration conflicts with demo")
Expand Down
8 changes: 8 additions & 0 deletions backend/app/models/prediction.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ class SystemConfiguration(TimestampMixin, Base):
"high_demand_threshold > 0 AND high_demand_threshold <= 1",
name="ck_config_high_demand_range",
),
CheckConstraint(
"high_solar_availability_threshold > 0 "
"AND high_solar_availability_threshold <= 1",
name="ck_config_high_solar_availability_range",
),
CheckConstraint(
"medium_peak_threshold > 0 AND medium_peak_threshold < high_peak_threshold "
"AND high_peak_threshold <= 1",
Expand All @@ -79,5 +84,8 @@ class SystemConfiguration(TimestampMixin, Base):
simulation_speed: Mapped[int] = mapped_column(Integer, nullable=False)
grid_emission_factor_kg_per_kwh: Mapped[float] = mapped_column(Float, nullable=False)
high_demand_threshold: Mapped[float] = mapped_column(Float, nullable=False)
high_solar_availability_threshold: Mapped[float] = mapped_column(
Float, default=0.8, nullable=False
)
medium_peak_threshold: Mapped[float] = mapped_column(Float, nullable=False)
high_peak_threshold: Mapped[float] = mapped_column(Float, nullable=False)
6 changes: 6 additions & 0 deletions backend/app/schemas/prediction.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ class SystemConfigurationValues(BaseModel):
simulation_speed: int = Field(gt=0)
grid_emission_factor_kg_per_kwh: float = Field(ge=0, allow_inf_nan=False)
high_demand_threshold: float = Field(gt=0, le=1, allow_inf_nan=False)
high_solar_availability_threshold: float = Field(
default=0.8, gt=0, le=1, allow_inf_nan=False
)
medium_peak_threshold: float = Field(gt=0, lt=1, allow_inf_nan=False)
high_peak_threshold: float = Field(gt=0, le=1, allow_inf_nan=False)

Expand All @@ -60,6 +63,9 @@ class SystemConfigurationUpdate(BaseModel):
default=None, ge=0, allow_inf_nan=False
)
high_demand_threshold: float | None = Field(default=None, gt=0, le=1, allow_inf_nan=False)
high_solar_availability_threshold: float | None = Field(
default=None, gt=0, le=1, allow_inf_nan=False
)
medium_peak_threshold: float | None = Field(default=None, gt=0, lt=1, allow_inf_nan=False)
high_peak_threshold: float | None = Field(default=None, gt=0, le=1, allow_inf_nan=False)

Expand Down
37 changes: 37 additions & 0 deletions backend/app/simulation/tick.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ def execute_tick(
high_demand_threshold = (
configuration.high_demand_threshold if configuration is not None else 0.85
)
high_solar_threshold = (
configuration.high_solar_availability_threshold
if configuration is not None
else None
)
rows = db.execute(
select(ChargingSession, Charger, Vehicle, ChargingStation)
.join(Charger, ChargingSession.charger_id == Charger.id)
Expand Down Expand Up @@ -168,6 +173,17 @@ def execute_tick(
)
or 0.0
)
previous_solar_available = 0.0
if previous_station_tick is not None:
previous_solar_available = (
db.scalar(
select(SolarReading.available_power_kw).where(
SolarReading.station_id == station_id,
SolarReading.timestamp == previous_timestamp,
)
)
or 0.0
)
if (
grid_total / station.grid_limit_kw >= high_demand_threshold
and previous_grid_power / station.grid_limit_kw < high_demand_threshold
Expand All @@ -185,6 +201,27 @@ def execute_tick(
created_at=timestamp,
)
)
if (
high_solar_threshold is not None
and station.station_peak_solar_kw > 0
and solar_available / station.station_peak_solar_kw >= high_solar_threshold
and previous_solar_available / station.station_peak_solar_kw
< high_solar_threshold
):
db.add(
Alert(
station_id=station_id,
type=AlertType.HIGH_SOLAR_AVAILABILITY,
severity=AlertSeverity.INFO,
title="High solar availability",
message=(
f"Solar generation reached {solar_available:.2f} kW "
f"({solar_available / station.station_peak_solar_kw:.0%} "
"of configured station peak)."
),
created_at=timestamp,
)
)

db.add(
SolarReading(
Expand Down
43 changes: 43 additions & 0 deletions backend/migrations/versions/20260924_0012_high_solar_threshold.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Add the configurable high-solar alert threshold.

Revision ID: 20260924_0012
Revises: 20260916_0011
Create Date: 2026-09-24
"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

revision: str = "20260924_0012"
down_revision: str | None = "20260916_0011"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.add_column(
"system_configurations",
sa.Column(
"high_solar_availability_threshold",
sa.Float(),
server_default="0.8",
nullable=False,
),
)
op.create_check_constraint(
"ck_config_high_solar_availability_range",
"system_configurations",
"high_solar_availability_threshold > 0 "
"AND high_solar_availability_threshold <= 1",
)


def downgrade() -> None:
op.drop_constraint(
"ck_config_high_solar_availability_range",
"system_configurations",
type_="check",
)
op.drop_column("system_configurations", "high_solar_availability_threshold")
27 changes: 20 additions & 7 deletions backend/tests/test_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
from httpx import AsyncClient
from sqlalchemy.orm import Session

from app.core.config import get_settings
from app.core.security import hash_password
from app.models.billing import Invoice, InvoiceStatus
from app.models.energy import ChargingSession, ChargingSessionStatus, EnergyReading
from app.models.infrastructure import Charger, ChargingStation
from app.models.prediction import SystemConfiguration
from app.models.user import User, UserRole
from app.models.vehicle import Vehicle
from app.services.analytics import AnalyticsFilters, dashboard, sustainability
Expand All @@ -29,7 +29,14 @@ def seed(db: Session) -> tuple[User, User, ChargingStation, ChargingSession, Cha
role=UserRole.USER,
)
station = ChargingStation(name="One", grid_limit_kw=60)
db.add_all([first, second, station])
db.add_all([first, second, station, SystemConfiguration(
simulation_speed=60,
grid_emission_factor_kg_per_kwh=0.4,
high_demand_threshold=0.85,
high_solar_availability_threshold=0.8,
medium_peak_threshold=0.7,
high_peak_threshold=0.9,
)])
db.flush()
charger_a = Charger(station_id=station.id, name="A", code="A", max_power_kw=22)
charger_b = Charger(station_id=station.id, name="B", code="B", max_power_kw=22)
Expand Down Expand Up @@ -181,6 +188,10 @@ async def test_analytics_api_scope_filters_and_validation(
assert (await client.get(f"{base}/sustainability", headers=headers)).json()[
"solar_percentage"
] == 25
report = (await client.get(f"{base}/sustainability", headers=headers)).json()
assert report["grid_emission_factor_kg_per_kwh"] == 0.4
assert report["avoided_co2_kg"] == 2
assert report["estimated_solar_savings"] == "4.60"
for endpoint in ("dashboard", "sustainability"):
assert (
await client.get(
Expand Down Expand Up @@ -215,8 +226,10 @@ async def test_analytics_api_scope_filters_and_validation(
assert (await client.get(f"{base}/dashboard")).status_code == 401


def test_emission_factor_is_configurable(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GRID_EMISSION_FACTOR_KG_PER_KWH", "0.45")
get_settings.cache_clear()
assert get_settings().grid_emission_factor_kg_per_kwh == 0.45
get_settings.cache_clear()
@pytest.mark.anyio
async def test_sustainability_requires_identified_esg_configuration(
client: AsyncClient,
) -> None:
response = await client.get("/api/v1/analytics/sustainability")
assert response.status_code == 503
assert response.json()["detail"] == "ESG configuration unavailable"
5 changes: 0 additions & 5 deletions backend/tests/test_phase_6_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from httpx import AsyncClient
from sqlalchemy.orm import Session

from app.core.config import get_settings
from app.main import app
from app.models.infrastructure import ChargingStation
from app.models.prediction import SystemConfiguration
Expand Down Expand Up @@ -36,10 +35,7 @@ async def override() -> SimulationController:
@pytest.mark.anyio
async def test_phase_6_golden_path(
client: AsyncClient, db_session: Session, control: SimulationController,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("GRID_EMISSION_FACTOR_KG_PER_KWH", "0.4")
get_settings.cache_clear()
station = (await client.post(
"/api/v1/stations",
json={"name": "Golden Path", "grid_limit_kw": 60, "station_peak_solar_kw": 0},
Expand Down Expand Up @@ -144,4 +140,3 @@ async def test_phase_6_golden_path(
assert len((await client.get("/api/v1/alerts", params={
"station_id": station["id"],
})).json()) >= 2
get_settings.cache_clear()
42 changes: 42 additions & 0 deletions backend/tests/test_simulation_tick.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ def resolve(
return {request.session_id: self.powers[request.session_id] for request in sessions}


class FixedSolarProvider:
def __init__(self, value: float) -> None:
self.value = value

def solar_available_kw(self, timestamp: datetime, station_peak_solar_kw: float) -> float:
return self.value


def clock() -> SimulationClock:
value = SimulationClock(initial_instant=INSTANT)
value.start()
Expand Down Expand Up @@ -105,13 +113,47 @@ def configured_threshold(db: Session, threshold: float) -> None:
simulation_speed=60,
grid_emission_factor_kg_per_kwh=0.1,
high_demand_threshold=threshold,
high_solar_availability_threshold=0.8,
medium_peak_threshold=0.7,
high_peak_threshold=0.9,
)
)
db.commit()


def alerts_of_type(db: Session, alert_type: AlertType) -> list[Alert]:
return list(db.scalars(select(Alert).where(Alert.type == alert_type)))


def test_high_solar_alert_is_configurable_and_deduplicated_by_episode(
db_session: Session,
) -> None:
station, session = add_session(db_session)
configured_threshold(db_session, 0.9)
configuration = db_session.scalar(select(SystemConfiguration))
assert configuration is not None
configuration.high_solar_availability_threshold = 0.8
db_session.commit()
resolver = FixedResolver({session.id: PowerBreakdown(7, 7, 0)})
solar = FixedSolarProvider(8)
tick_clock = clock()

execute_tick(db_session, clock=tick_clock, solar_provider=solar, power_resolver=resolver)
assert len(alerts_of_type(db_session, AlertType.HIGH_SOLAR_AVAILABILITY)) == 1
db_session.rollback()
execute_tick(db_session, clock=tick_clock, solar_provider=solar, power_resolver=resolver)
assert len(alerts_of_type(db_session, AlertType.HIGH_SOLAR_AVAILABILITY)) == 1

db_session.rollback()
solar.value = 7
execute_tick(db_session, clock=tick_clock, solar_provider=solar, power_resolver=resolver)
assert len(alerts_of_type(db_session, AlertType.HIGH_SOLAR_AVAILABILITY)) == 1
db_session.rollback()
solar.value = 8
execute_tick(db_session, clock=tick_clock, solar_provider=solar, power_resolver=resolver)
assert len(alerts_of_type(db_session, AlertType.HIGH_SOLAR_AVAILABILITY)) == 2


def test_high_demand_threshold_and_new_episode(db_session: Session) -> None:
station, session = add_session(db_session)
session_id = session.id
Expand Down
Loading
Loading