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
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,13 @@ O alvo executa testes, lint e verificação de tipos no backend e no frontend, a
- [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)
- [Fase 4 — Gestão energética (concluída)](docs/PHASE_4.md)

## Estado atual

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
As Fases 1 (fundação), 2 (domínio), 3 (simulação e leituras) e 4 (gestão energética)
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.
Expand All @@ -83,8 +84,14 @@ A Fase 3 contém relógio determinístico, provedor solar e o serviço
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.
O controle de simulação usa a política Equal Share Allocation V1 da Fase 4:
divide a capacidade da rede mais a geração solar entre sessões `CHARGING` de
cada estação, respeita pedido, carregador e veículo, e redistribui sobras.
A potência solar disponível cobre primeiro a demanda alocada e é rateada
proporcionalmente; a parcela restante vem da rede, limitada por estação.
Cada tick corresponde à duração configurada do relógio (60 segundos simulados
por padrão), calcula energia em kWh e atualiza os acumuladores das sessões.
Esta etapa ainda não gera alertas nem atualiza analytics derivados por tick.

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
2 changes: 1 addition & 1 deletion backend/app/api/routes/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ async def reset_simulation(
async def run_simulation_tick(
db: DbSession, _: AdminUser, control: Controller
) -> SimulationTickResponse:
"""Run exactly one transactional tick while RUNNING. Phase 3 allocates zero power."""
"""Run exactly one transactional tick while RUNNING."""
result = control.tick(db)
if result is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Simulation is stopped")
Expand Down
81 changes: 81 additions & 0 deletions backend/app/services/energy_allocation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Equal share power allocation for charging sessions at one station."""

import math
from collections.abc import Sequence
from dataclasses import dataclass
from uuid import UUID


@dataclass(frozen=True, slots=True)
class SessionPowerRequest:
session_id: UUID
requested_power_kw: float
charger_max_power_kw: float
vehicle_max_charge_power_kw: float


@dataclass(frozen=True, slots=True)
class PowerBreakdown:
allocated_power_kw: float
solar_power_kw: float
grid_power_kw: float


class EqualSharePowerResolver:
"""Cap equal shares by each request, then use solar before grid power."""

def resolve(
self,
*,
station_id: UUID,
grid_limit_kw: float,
solar_available_kw: float,
sessions: Sequence[SessionPowerRequest],
) -> dict[UUID, PowerBreakdown]:
for value in (grid_limit_kw, solar_available_kw):
if not math.isfinite(value) or value < 0:
raise ValueError("station power must be finite and nonnegative")

limits: dict[UUID, float] = {}
for session in sessions:
values = (
session.requested_power_kw,
session.charger_max_power_kw,
session.vehicle_max_charge_power_kw,
)
if any(not math.isfinite(value) or value < 0 for value in values):
raise ValueError("session power must be finite and nonnegative")
if session.session_id in limits:
raise ValueError("duplicate charging session")
limits[session.session_id] = min(values)

available = grid_limit_kw + solar_available_kw
if not math.isfinite(available):
raise ValueError("total station power must be finite")

allocated = {session_id: 0.0 for session_id in limits}
active = {session_id for session_id, limit in limits.items() if limit > 0}
remaining = available
while active and remaining > 0:
share = remaining / len(active)
capped = {session_id for session_id in active if limits[session_id] <= share}
if not capped:
for session_id in active:
allocated[session_id] = share
break
for session_id in capped:
allocated[session_id] = limits[session_id]
remaining -= limits[session_id]
active -= capped

total_allocated = sum(allocated.values())
solar_used = min(total_allocated, solar_available_kw)
solar_fraction = solar_used / total_allocated if total_allocated else 0.0
return {
session_id: PowerBreakdown(
allocated_power_kw=power,
solar_power_kw=power * solar_fraction,
grid_power_kw=power - power * solar_fraction,
)
for session_id, power in allocated.items()
}
23 changes: 4 additions & 19 deletions backend/app/simulation/control.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,22 @@
"""Single-process, manually driven simulator control for the Phase 3 MVP."""
"""Single-process, manually driven simulator control."""

import logging
from collections.abc import Sequence
from datetime import UTC, datetime
from threading import RLock
from uuid import UUID

from sqlalchemy import func, select
from sqlalchemy.orm import Session

from app.models.energy import EnergyReading, SolarReading
from app.models.prediction import SystemConfiguration
from app.services.energy_allocation import EqualSharePowerResolver
from app.simulation.clock import SimulationClock, SimulationClockState
from app.simulation.energy_data import SimulationEnergyDataProvider
from app.simulation.tick import PowerBreakdown, SessionPowerRequest, TickResult, execute_tick
from app.simulation.tick import TickResult, execute_tick

logger = logging.getLogger(__name__)


class ZeroPowerResolver:
"""Safe Phase 3 placeholder until Phase 4 supplies power allocation."""

def resolve(
self,
*,
station_id: UUID,
grid_limit_kw: float,
solar_available_kw: float,
sessions: Sequence[SessionPowerRequest],
) -> dict[UUID, PowerBreakdown]:
return {item.session_id: PowerBreakdown(0, 0, 0) for item in sessions}


class SimulationController:
def __init__(self, clock: SimulationClock | None = None) -> None:
self.clock = clock or SimulationClock(initial_instant=datetime.now(UTC))
Expand Down Expand Up @@ -96,7 +81,7 @@ def tick(self, db: Session) -> TickResult | None:
db,
clock=self.clock,
solar_provider=SimulationEnergyDataProvider(),
power_resolver=ZeroPowerResolver(),
power_resolver=EqualSharePowerResolver(),
)
except Exception:
logger.exception("simulation_control_tick_failed")
Expand Down
20 changes: 3 additions & 17 deletions backend/app/simulation/tick.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Persist one deterministic simulator tick using an injected power resolver.

The resolver owns allocation and solar distribution (Phase 4). This service
The resolver owns allocation and solar distribution. This service
only checks its safety invariants and records the resulting interval energy.
"""

Expand All @@ -19,30 +19,16 @@
from app.models.energy import ChargingSession, ChargingSessionStatus, EnergyReading, SolarReading
from app.models.infrastructure import Charger, ChargingStation
from app.models.vehicle import Vehicle
from app.services.energy_allocation import PowerBreakdown, SessionPowerRequest
from app.services.energy_readings import ABSOLUTE_TOLERANCE, build_energy_reading_data
from app.simulation.clock import SimulationClock, SimulationClockState
from app.simulation.energy_data import EnergyDataProvider

logger = logging.getLogger(__name__)


@dataclass(frozen=True, slots=True)
class SessionPowerRequest:
session_id: UUID
requested_power_kw: float
charger_max_power_kw: float
vehicle_max_charge_power_kw: float


@dataclass(frozen=True, slots=True)
class PowerBreakdown:
allocated_power_kw: float
solar_power_kw: float
grid_power_kw: float


class PowerResolver(Protocol):
"""Phase 4 supplies one breakdown for each charging session at a station."""
"""Supply one breakdown for each charging session at a station."""

def resolve(
self,
Expand Down
72 changes: 72 additions & 0 deletions backend/tests/test_energy_allocation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Equal share allocation and solar priority for one station."""

from uuid import uuid4

import pytest

from app.services.energy_allocation import EqualSharePowerResolver, SessionPowerRequest


def resolve(
requests: list[float], *, grid: float, solar: float = 0,
charger: float = 100, vehicle: float = 100,
) -> list[tuple[float, float, float]]:
sessions = [SessionPowerRequest(uuid4(), power, charger, vehicle) for power in requests]
result = EqualSharePowerResolver().resolve(
station_id=uuid4(), grid_limit_kw=grid,
solar_available_kw=solar, sessions=sessions,
)
return [
(result[item.session_id].allocated_power_kw,
result[item.session_id].solar_power_kw,
result[item.session_id].grid_power_kw)
for item in sessions
]


def test_four_equal_requests_share_grid_limit() -> None:
assert resolve([20] * 4, grid=60) == [(15, 0, 15)] * 4


def test_solar_adds_to_capacity_and_is_shared_proportionally() -> None:
assert resolve([20] * 4, grid=60, solar=20) == [(20, 5, 15)] * 4
assert resolve([10, 30], grid=60, solar=25) == [
(10, 6.25, 3.75), (30, 18.75, 11.25)
]


def test_solar_acceptance_uses_25_kw_before_15_kw_grid() -> None:
result = resolve([20, 20], grid=15, solar=25)
assert sum(row[0] for row in result) == 40
assert sum(row[1] for row in result) == 25
assert sum(row[2] for row in result) == 15


def test_unequal_requests_redistribute_unused_share() -> None:
result = resolve([5, 20, 20, 20], grid=45)
assert [row[0] for row in result] == [5, 40 / 3, 40 / 3, 40 / 3]
assert sum(row[2] for row in result) == pytest.approx(45)


def test_demand_below_capacity_and_individual_limits() -> None:
assert resolve([5, 10], grid=60) == [(5, 0, 5), (10, 0, 10)]
assert resolve([20], grid=60, charger=22, vehicle=11) == [(11, 0, 11)]


def test_zero_power_and_empty_sessions() -> None:
assert resolve([0, 0], grid=0, solar=0) == [(0, 0, 0), (0, 0, 0)]
assert resolve([20, 0], grid=0, solar=5) == [(5, 5, 0), (0, 0, 0)]
assert resolve([], grid=60, solar=20) == []


@pytest.mark.parametrize("power", [-1, float("nan"), float("inf")])
def test_invalid_power_is_rejected(power: float) -> None:
with pytest.raises(ValueError):
resolve([power], grid=60)
with pytest.raises(ValueError):
resolve([20], grid=power)


def test_finite_inputs_with_overflowing_total_capacity_are_rejected() -> None:
with pytest.raises(ValueError, match="total station power must be finite"):
resolve([20], grid=1e308, solar=1e308)
Loading
Loading