Skip to content
Merged
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
28 changes: 21 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,29 @@ O alvo executa testes, lint e verificação de tipos no backend e no frontend, a
- [Arquitetura](docs/ARCHITECTURE.md)
- [Fase 1 — Fundação (concluída)](docs/PHASE_1.md)
- [Fase 2 — Domínio (concluída)](docs/PHASE_2.md)
- [Fase 3 — Simulação e leituras (concluída)](docs/PHASE_3.md)

## Estado atual

As Fases 1 (fundação) e 2 (domínio) estão concluídas, com critérios de saída e
evidências documentados. O backend entrega Users/Auth, Vehicles, Stations, Chargers
e Sessions sob `/api/v1`, com JWT, autorização por papel e propriedade, persistência
As Fases 1 (fundação), 2 (domínio) e 3 (simulação e leituras) estão concluídas,
com critérios de saída e evidências documentados. As migrations da Fase 3 foram
validadas online em PostgreSQL. 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.

O próximo incremento funcional é a Fase 3. Relógio/ticks do simulador, gestão e
alocação energética, dashboards, ESG e treinamento/inferência de ML ainda não estão
implementados. Estruturas preparatórias de dados de fases futuras existentes no
backend não devem ser confundidas com esses fluxos completos.
A Fase 3 contém relógio determinístico, provedor solar e o serviço
`app.simulation.tick.execute_tick`. O serviço recebe uma resolução de potência
injetável por estação, valida os limites físicos e persiste as leituras e os
acumuladores em uma transação. O controle ADMIN em `/api/v1/simulation` expõe
status, start, stop, reset e um tick manual (`POST /ticks`), sem loop automático.
Até a Fase 4, o resolvedor HTTP aloca zero kW. Alocação, limite de rede na
distribuição e prioridade solar pertencem à Fase 4.

Cada estação processada recebe uma `SolarReading` única por timestamp simulado;
cada sessão recebe uma `EnergyReading` única por timestamp. Uma reexecução no
mesmo instante ignora estações já concluídas. Constraints no banco impedem
duplicatas mesmo em escrita concorrente, e uma falha desfaz todo o tick. O
relógio avança apenas depois do commit. O chamador deve passar uma sessão de
banco sem transação ativa e um relógio iniciado; em caso de erro pode repetir
o mesmo tick após corrigir a causa.
2 changes: 2 additions & 0 deletions backend/app/api/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from app.api.routes.health import router as health_router
from app.api.routes.predictions import configuration_router, predictions_router
from app.api.routes.sessions import router as sessions_router
from app.api.routes.simulation import router as simulation_router
from app.api.routes.solar import router as solar_router
from app.api.routes.stations import router as stations_router
from app.api.routes.users import router as users_router
Expand All @@ -21,6 +22,7 @@
api_router.include_router(stations_router)
api_router.include_router(chargers_router)
api_router.include_router(sessions_router)
api_router.include_router(simulation_router)
api_router.include_router(energy_router)
api_router.include_router(solar_router)
api_router.include_router(billing_router)
Expand Down
89 changes: 89 additions & 0 deletions backend/app/api/routes/simulation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Administrative control of the explicitly advanced Phase 3 simulator."""

from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, status

from app.api.dependencies import AdminUser
from app.api.routes.common import (
CONFLICT_RESPONSE,
FORBIDDEN_RESPONSE,
UNAUTHORIZED_RESPONSE,
DbSession,
)
from app.schemas.simulation import SimulationStatusResponse, SimulationTickResponse
from app.simulation.control import SimulationController, get_simulation_controller

router = APIRouter(prefix="/simulation", tags=["simulation"])
Controller = Annotated[SimulationController, Depends(get_simulation_controller)]
AUTH_RESPONSES = UNAUTHORIZED_RESPONSE | FORBIDDEN_RESPONSE


def _status(control: SimulationController) -> SimulationStatusResponse:
with control.lock:
return SimulationStatusResponse(
state=control.clock.state,
current_instant=control.clock.current_instant,
tick_duration_seconds=control.clock.tick_duration.total_seconds(),
simulation_speed=control.clock.simulation_speed,
last_tick=control.last_tick,
)


@router.get("/status", response_model=SimulationStatusResponse, responses=AUTH_RESPONSES)
async def simulation_status(_: AdminUser, control: Controller) -> SimulationStatusResponse:
"""Return this API process's clock state and most recent successful tick."""
return _status(control)


@router.post("/start", response_model=SimulationStatusResponse, responses=AUTH_RESPONSES)
async def start_simulation(
db: DbSession, _: AdminUser, control: Controller
) -> SimulationStatusResponse:
"""Idempotently start manual control; load the configured simulation speed."""
control.start(db)
return _status(control)


@router.post("/stop", response_model=SimulationStatusResponse, responses=AUTH_RESPONSES)
async def stop_simulation(_: AdminUser, control: Controller) -> SimulationStatusResponse:
"""Idempotently stop manual ticks; no worker or timer is started."""
control.stop()
return _status(control)


@router.post(
"/reset",
response_model=SimulationStatusResponse,
responses=AUTH_RESPONSES | CONFLICT_RESPONSE,
)
async def reset_simulation(
db: DbSession, _: AdminUser, control: Controller
) -> SimulationStatusResponse:
"""While stopped, clear clock metadata and move beyond saved readings without deleting data."""
if not control.reset(db):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Stop simulation before reset"
)
return _status(control)


@router.post(
"/ticks",
response_model=SimulationTickResponse,
responses=AUTH_RESPONSES | CONFLICT_RESPONSE,
)
async def run_simulation_tick(
db: DbSession, _: AdminUser, control: Controller
) -> SimulationTickResponse:
"""Run exactly one transactional tick while RUNNING. Phase 3 allocates zero power."""
result = control.tick(db)
if result is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Simulation is stopped")
return SimulationTickResponse(
timestamp=result.timestamp,
stations_processed=result.stations_processed,
energy_readings_created=result.energy_readings_created,
stations_already_processed=result.stations_already_processed,
current_instant=control.clock.current_instant,
)
20 changes: 18 additions & 2 deletions backend/app/models/energy.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,17 @@
from decimal import Decimal
from enum import StrEnum

from sqlalchemy import CheckConstraint, DateTime, Enum, Float, ForeignKey, Index, Numeric, text
from sqlalchemy import (
CheckConstraint,
DateTime,
Enum,
Float,
ForeignKey,
Index,
Numeric,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.db.base import Base
Expand Down Expand Up @@ -31,6 +41,10 @@ class ChargingSession(TimestampMixin, Base):
CheckConstraint("grid_energy_kwh >= 0", name="ck_sessions_grid_energy_nonnegative"),
CheckConstraint("tariff_per_kwh >= 0", name="ck_sessions_tariff_nonnegative"),
CheckConstraint("total_cost >= 0", name="ck_sessions_total_cost_nonnegative"),
CheckConstraint(
"status IN ('CREATED', 'CHARGING', 'PAUSED', 'COMPLETED', 'CANCELLED')",
name="charging_session_status",
),
Index(
"uq_charging_sessions_active_charger",
"charger_id",
Expand Down Expand Up @@ -62,7 +76,7 @@ class ChargingSession(TimestampMixin, Base):
ChargingSessionStatus,
name="charging_session_status",
native_enum=False,
create_constraint=True,
create_constraint=False,
validate_strings=True,
),
default=ChargingSessionStatus.CREATED,
Expand All @@ -87,6 +101,7 @@ class ChargingSession(TimestampMixin, Base):
class EnergyReading(Base):
__tablename__ = "energy_readings"
__table_args__ = (
UniqueConstraint("session_id", "timestamp", name="uq_energy_readings_session_tick"),
CheckConstraint("requested_power_kw >= 0", name="ck_energy_readings_requested_nonnegative"),
CheckConstraint("allocated_power_kw >= 0", name="ck_energy_readings_allocated_nonnegative"),
CheckConstraint(
Expand Down Expand Up @@ -119,6 +134,7 @@ class EnergyReading(Base):
class SolarReading(Base):
__tablename__ = "solar_readings"
__table_args__ = (
UniqueConstraint("station_id", "timestamp", name="uq_solar_readings_station_tick"),
CheckConstraint("available_power_kw >= 0", name="ck_solar_readings_power_nonnegative"),
)

Expand Down
17 changes: 14 additions & 3 deletions backend/app/models/infrastructure.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,31 @@ class ChargerStatus(StrEnum):

class ChargingStation(TimestampMixin, Base):
__tablename__ = "charging_stations"
__table_args__ = (CheckConstraint("grid_limit_kw > 0", name="ck_stations_grid_limit_positive"),)
__table_args__ = (
CheckConstraint("grid_limit_kw > 0", name="ck_stations_grid_limit_positive"),
CheckConstraint(
"station_peak_solar_kw >= 0", name="ck_stations_peak_solar_nonnegative"
),
)

id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(120), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
grid_limit_kw: Mapped[float] = mapped_column(Float, nullable=False)
station_peak_solar_kw: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)

chargers: Mapped[list["Charger"]] = relationship(back_populates="station")


class Charger(TimestampMixin, Base):
__tablename__ = "chargers"
__table_args__ = (CheckConstraint("max_power_kw > 0", name="ck_chargers_max_power_positive"),)
__table_args__ = (
CheckConstraint("max_power_kw > 0", name="ck_chargers_max_power_positive"),
CheckConstraint(
"status IN ('AVAILABLE', 'CHARGING', 'UNAVAILABLE')", name="charger_status"
),
)

id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
station_id: Mapped[uuid.UUID] = mapped_column(
Expand All @@ -43,7 +54,7 @@ class Charger(TimestampMixin, Base):
ChargerStatus,
name="charger_status",
native_enum=False,
create_constraint=True,
create_constraint=False,
validate_strings=True,
),
default=ChargerStatus.AVAILABLE,
Expand Down
5 changes: 3 additions & 2 deletions backend/app/models/user.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import uuid
from enum import StrEnum

from sqlalchemy import Boolean, Enum, String
from sqlalchemy import Boolean, CheckConstraint, Enum, String
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.db.base import Base
Expand All @@ -15,6 +15,7 @@ class UserRole(StrEnum):

class User(TimestampMixin, Base):
__tablename__ = "users"
__table_args__ = (CheckConstraint("role IN ('ADMIN', 'USER')", name="user_role"),)

id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(120), nullable=False)
Expand All @@ -25,7 +26,7 @@ class User(TimestampMixin, Base):
UserRole,
name="user_role",
native_enum=False,
create_constraint=True,
create_constraint=False,
validate_strings=True,
),
default=UserRole.USER,
Expand Down
7 changes: 7 additions & 0 deletions backend/app/schemas/energy.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ class ReadingResponse(BaseModel):
id: UUID
timestamp: datetime

@field_validator("timestamp")
@classmethod
def normalize_timestamp_utc(cls, value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)


class EnergyReadingResponse(ReadingResponse):
session_id: UUID
Expand Down
21 changes: 21 additions & 0 deletions backend/app/schemas/simulation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from datetime import datetime

from pydantic import BaseModel

from app.simulation.clock import SimulationClockState


class SimulationStatusResponse(BaseModel):
state: SimulationClockState
current_instant: datetime
tick_duration_seconds: float
simulation_speed: int
last_tick: datetime | None


class SimulationTickResponse(BaseModel):
timestamp: datetime
stations_processed: int
energy_readings_created: int
stations_already_processed: int
current_instant: datetime
8 changes: 6 additions & 2 deletions backend/app/schemas/station.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,29 @@
from typing import Annotated

from pydantic import BaseModel, StringConstraints, field_validator
from pydantic import BaseModel, Field, StringConstraints, field_validator

from app.schemas.common import Name, ORMResponse, PositivePower

Description = Annotated[str, StringConstraints(strip_whitespace=True, max_length=2000)]
NonNegativePower = Annotated[float, Field(ge=0, allow_inf_nan=False)]


class StationCreate(BaseModel):
name: Name
description: Description | None = None
grid_limit_kw: PositivePower
station_peak_solar_kw: NonNegativePower = 0.0
is_active: bool = True


class StationUpdate(BaseModel):
name: Name | None = None
description: Description | None = None
grid_limit_kw: PositivePower | None = None
station_peak_solar_kw: NonNegativePower | None = None
is_active: bool | None = None

@field_validator("name", "grid_limit_kw", "is_active")
@field_validator("name", "grid_limit_kw", "station_peak_solar_kw", "is_active")
@classmethod
def reject_null(cls, value: object) -> object:
if value is None:
Expand All @@ -32,4 +35,5 @@ class StationResponse(ORMResponse):
name: str
description: str | None
grid_limit_kw: float
station_peak_solar_kw: float
is_active: bool
Loading
Loading