diff --git a/README.md b/README.md index 31fce72..a964176 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/backend/app/api/routes/simulation.py b/backend/app/api/routes/simulation.py index 40151b5..397b469 100644 --- a/backend/app/api/routes/simulation.py +++ b/backend/app/api/routes/simulation.py @@ -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") diff --git a/backend/app/services/energy_allocation.py b/backend/app/services/energy_allocation.py new file mode 100644 index 0000000..cb740f8 --- /dev/null +++ b/backend/app/services/energy_allocation.py @@ -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() + } diff --git a/backend/app/simulation/control.py b/backend/app/simulation/control.py index 281d4cb..d36f3bc 100644 --- a/backend/app/simulation/control.py +++ b/backend/app/simulation/control.py @@ -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)) @@ -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") diff --git a/backend/app/simulation/tick.py b/backend/app/simulation/tick.py index 64094b8..629ccf0 100644 --- a/backend/app/simulation/tick.py +++ b/backend/app/simulation/tick.py @@ -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. """ @@ -19,6 +19,7 @@ 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 @@ -26,23 +27,8 @@ 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, diff --git a/backend/tests/test_energy_allocation.py b/backend/tests/test_energy_allocation.py new file mode 100644 index 0000000..59b352d --- /dev/null +++ b/backend/tests/test_energy_allocation.py @@ -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) diff --git a/backend/tests/test_simulation_api.py b/backend/tests/test_simulation_api.py index 21cc85f..c8d4db9 100644 --- a/backend/tests/test_simulation_api.py +++ b/backend/tests/test_simulation_api.py @@ -1,5 +1,6 @@ """Administrative simulation lifecycle and read API integration.""" +from collections.abc import Sequence from datetime import UTC, datetime, timedelta from decimal import Decimal from uuid import UUID, uuid4 @@ -16,6 +17,7 @@ from app.models.prediction import SystemConfiguration from app.models.user import User, UserRole from app.models.vehicle import Vehicle +from app.services.energy_allocation import PowerBreakdown, SessionPowerRequest from app.simulation.clock import SimulationClock from app.simulation.control import SimulationController, get_simulation_controller @@ -34,19 +36,30 @@ async def override() -> SimulationController: app.dependency_overrides.pop(get_simulation_controller, None) -def seed_charging_session(db: Session) -> tuple[UUID, UUID]: - user = User(name="Driver", email="driver-simulation@example.com", password_hash="hash") - station = ChargingStation(name="Solar station", grid_limit_kw=20, station_peak_solar_kw=10) +def seed_charging_session( + db: Session, + *, + station: ChargingStation | None = None, + grid_limit_kw: float = 20, + solar_peak_kw: float = 10, + requested_power_kw: float = 11, + max_power_kw: float = 11, +) -> tuple[UUID, UUID]: + marker = str(uuid4()) + user = User(name="Driver", email=f"driver-{marker}@example.com", password_hash="hash") + station = station or ChargingStation( + name="Solar station", grid_limit_kw=grid_limit_kw, station_peak_solar_kw=solar_peak_kw + ) db.add_all([user, station]) db.flush() - charger = Charger(station_id=station.id, name="C1", code="SIM-1", max_power_kw=11) + charger = Charger(station_id=station.id, name="C1", code=marker, max_power_kw=max_power_kw) vehicle = Vehicle( user_id=user.id, name="EV", brand="B", model="M", - license_plate="SIM-EV", - max_charge_power_kw=11, + license_plate=marker[:8], + max_charge_power_kw=max_power_kw, ) db.add_all([charger, vehicle]) db.flush() @@ -55,7 +68,7 @@ def seed_charging_session(db: Session) -> tuple[UUID, UUID]: vehicle_id=vehicle.id, charger_id=charger.id, status=ChargingSessionStatus.CHARGING, - requested_power_kw=11, + requested_power_kw=requested_power_kw, allocated_power_kw=0, energy_consumed_kwh=0, solar_energy_kwh=0, @@ -167,7 +180,7 @@ async def test_simulation_lifecycle_readings_filters_and_reset( assert db_session.scalar(select(func.count()).select_from(SolarReading)) == 1 session = db_session.get(ChargingSession, session_id) assert session is not None - assert session.energy_consumed_kwh == 0 + assert session.energy_consumed_kwh == pytest.approx(11 * 2 / 60) for resource in ("energy", "solar"): current_response = await client.get( f"/api/v1/{resource}/current", params={"station_id": str(station_id)} @@ -198,13 +211,137 @@ async def test_simulation_lifecycle_readings_filters_and_reset( params={"from": (INSTANT + timedelta(seconds=1)).isoformat()}, ) assert after.json() == [] - assert (await client.get("/api/v1/energy/current")).json()["allocated_power_kw"] == 0 + energy = (await client.get("/api/v1/energy/current")).json() + assert energy["allocated_power_kw"] == 11 + assert energy["solar_power_kw"] == 10 + assert energy["grid_power_kw"] == 1 assert (await client.get("/api/v1/solar/current")).json()["available_power_kw"] == 10 +@pytest.mark.anyio +async def test_tick_allocates_each_station_and_records_energy_parts( + client: AsyncClient, db_session: Session, control: SimulationController +) -> None: + station_a_id, first_id = seed_charging_session( + db_session, + grid_limit_kw=10, + solar_peak_kw=6, + requested_power_kw=12, + max_power_kw=12, + ) + station_a = db_session.get(ChargingStation, station_a_id) + assert station_a is not None + _, second_id = seed_charging_session( + db_session, station=station_a, requested_power_kw=12, max_power_kw=12 + ) + station_b_id, third_id = seed_charging_session( + db_session, + grid_limit_kw=5, + solar_peak_kw=0, + requested_power_kw=9, + max_power_kw=9, + ) + + assert (await client.post("/api/v1/simulation/start")).status_code == 200 + response = await client.post("/api/v1/simulation/ticks") + assert response.status_code == 200 + assert response.json()["stations_processed"] == 2 + assert response.json()["energy_readings_created"] == 3 + assert control.clock.current_instant == INSTANT + timedelta(minutes=1) + + db_session.rollback() + readings = { + reading.session_id: reading for reading in db_session.scalars(select(EnergyReading)).all() + } + assert set(readings) == {first_id, second_id, third_id} + expected = { + first_id: (12, 8, 3, 5), + second_id: (12, 8, 3, 5), + third_id: (9, 5, 0, 5), + } + for session_id, (requested, allocated, solar, grid) in expected.items(): + reading = readings[session_id] + session = db_session.get(ChargingSession, session_id) + assert session is not None + assert reading.requested_power_kw == requested + assert reading.allocated_power_kw == pytest.approx(allocated) + assert reading.solar_power_kw == pytest.approx(solar) + assert reading.grid_power_kw == pytest.approx(grid) + assert reading.interval_energy_kwh == pytest.approx(allocated / 60) + assert reading.solar_energy_kwh == pytest.approx(solar / 60) + assert reading.grid_energy_kwh == pytest.approx(grid / 60) + assert reading.interval_energy_kwh == pytest.approx( + reading.solar_energy_kwh + reading.grid_energy_kwh + ) + assert session.allocated_power_kw == pytest.approx(allocated) + assert session.energy_consumed_kwh == pytest.approx(reading.interval_energy_kwh) + assert session.solar_energy_kwh == pytest.approx(reading.solar_energy_kwh) + assert session.grid_energy_kwh == pytest.approx(reading.grid_energy_kwh) + + solar_readings = db_session.scalars(select(SolarReading)).all() + assert {row.station_id: row.available_power_kw for row in solar_readings} == { + station_a_id: 6, + station_b_id: 0, + } + assert sum(readings[id].grid_power_kw for id in (first_id, second_id)) == pytest.approx(10) + assert readings[third_id].grid_power_kw == pytest.approx(5) + + +@pytest.mark.anyio +async def test_tick_rolls_back_all_stations_for_invalid_injected_resolver( + client: AsyncClient, + db_session: Session, + control: SimulationController, + monkeypatch: pytest.MonkeyPatch, +) -> None: + first_station, first_session = seed_charging_session(db_session) + second_station, second_session = seed_charging_session(db_session) + failing_station = max(first_station, second_station) + + class InvalidResolver: + def resolve( + self, + *, + station_id: UUID, + grid_limit_kw: float, + solar_available_kw: float, + sessions: Sequence[SessionPowerRequest], + ) -> dict[UUID, PowerBreakdown]: + if station_id == failing_station: + return { + item.session_id: PowerBreakdown(11, 0, grid_limit_kw + 1) for item in sessions + } + return {item.session_id: PowerBreakdown(11, 10, 1) for item in sessions} + + monkeypatch.setattr("app.simulation.control.EqualSharePowerResolver", InvalidResolver) + assert (await client.post("/api/v1/simulation/start")).status_code == 200 + transport = ASGITransport(app=app, raise_app_exceptions=False) + async with AsyncClient( + transport=transport, base_url="http://test", headers=dict(client.headers) + ) as safe_client: + response = await safe_client.post("/api/v1/simulation/ticks") + assert response.status_code == 500 + assert response.json() == {"detail": "Internal server error"} + assert control.clock.current_instant == INSTANT + assert control.last_tick is None + db_session.rollback() + assert db_session.scalar(select(func.count()).select_from(EnergyReading)) == 0 + assert db_session.scalar(select(func.count()).select_from(SolarReading)) == 0 + for session_id in (first_session, second_session): + session = db_session.get(ChargingSession, session_id) + assert session is not None + db_session.refresh(session) + assert session.allocated_power_kw == 0 + assert session.energy_consumed_kwh == 0 + assert session.solar_energy_kwh == 0 + assert session.grid_energy_kwh == 0 + + @pytest.mark.anyio async def test_failed_tick_keeps_clock_and_hides_internal_error( - client: AsyncClient, control: SimulationController, monkeypatch: pytest.MonkeyPatch, + client: AsyncClient, + control: SimulationController, + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: def fail_tick(*args: object, **kwargs: object) -> None: diff --git a/docs/PHASE_3.md b/docs/PHASE_3.md index b635c7b..2094477 100644 --- a/docs/PHASE_3.md +++ b/docs/PHASE_3.md @@ -6,7 +6,7 @@ O backend possui relógio simulado determinístico, curva solar por estação, s `status` informa estado, instante UTC atual, duração simulada do tick em segundos, `simulation_speed` e timestamp do último tick concluído. `start` e `stop` são idempotentes e retornam 200 com o mesmo estado quando repetidos. `start` carrega `simulation_speed` da `SystemConfiguration`, quando presente. Sem configuração, o padrão é 60 segundos simulados por segundo real; o tick manual representa um segundo real. -`POST /ticks` exige estado RUNNING, executa uma vez `execute_tick` e retorna 409 se o simulador estiver parado. O serviço de tick abre uma transação para as leituras e acumuladores, confirma antes de avançar o relógio e registra sucesso ou falha. Sem sessões CHARGING, o relógio ainda avança. A resolução de potência provisória da Fase 3 aloca **zero kW** para cada sessão ativa. Isso gera leituras de energia com zero consumo e mantém os limites físicos, enquanto a curva solar gera `SolarReading` para cada estação com sessão ativa. O resolvedor é injetável; os testes do serviço exercitam leituras com potência não nula por um resolvedor de teste. +`POST /ticks` exige estado RUNNING, executa uma vez `execute_tick` e retorna 409 se o simulador estiver parado. O serviço de tick abre uma transação para as leituras e acumuladores, confirma antes de avançar o relógio e registra sucesso ou falha. Sem sessões CHARGING, o relógio ainda avança. Na entrega original da Fase 3, a resolução provisória alocava **zero kW**; a Fase 4 a substituiu por Equal Share Allocation V1. Consulte [Fase 4](PHASE_4.md) para o comportamento atual do tick. O resolvedor permanece injetável para testes do serviço. `reset` exige STOPPED e retorna 409 durante RUNNING. Ele limpa somente o estado do relógio e o marcador do último tick. O novo instante é o maior entre o horário UTC atual e o último timestamp persistido de energia ou solar mais uma duração de tick. Assim a próxima execução não reutiliza timestamps. **Nenhuma `EnergyReading` ou `SolarReading` é apagada**, nem são zerados acumuladores de sessões. Leituras continuam disponíveis nos endpoints `/energy/current`, `/energy/history`, `/solar/current` e `/solar/history`, inclusive com filtros `station_id`, `from` e `to` nos históricos. @@ -24,8 +24,8 @@ Na validação desta entrega, `alembic heads` encontrou somente `20260916_0010 ( Ruff e mypy passaram; pytest concluiu 144 testes backend. Vitest concluiu 2 testes frontend; ESLint, typecheck TypeScript e build Vite passaram. -## Próxima fase +## Evolução posterior -**Alocação de potência, limite de rede na distribuição e prioridade solar permanecem para a Fase 4.** O serviço de tick já valida os limites de uma resolução injetada; o resolvedor de produção ainda precisa implementar essas regras. +Alocação de potência, limite de rede na distribuição e prioridade solar foram entregues na Fase 4. Alertas e analytics derivados por tick previstos no fluxo completo do SPEC ainda dependem das fases posteriores desses módulos. diff --git a/docs/PHASE_4.md b/docs/PHASE_4.md new file mode 100644 index 0000000..7c44d45 --- /dev/null +++ b/docs/PHASE_4.md @@ -0,0 +1,66 @@ +# Fase 4 — Gestão energética + +## Comportamento entregue + +O tick manual `POST /api/v1/simulation/ticks`, disponível a ADMIN com o relógio +RUNNING, busca as sessões `CHARGING` e agrupa por estação. Para cada estação com +sessão ativa, consulta a curva solar determinística (06:00–18:00 UTC, pico às +12:00 UTC), soma a capacidade solar instantânea ao limite de importação da rede +e aplica Equal Share Allocation V1. O rateio igualitário é limitado pelo pedido, +pela potência máxima do carregador e pela potência máxima do veículo; sobras +de participantes limitados são redistribuídas iterativamente. + +O resolvedor usa `min(potência alocada total, solar disponível)` como potência +solar e distribui essa parcela proporcionalmente entre as sessões. O restante +vem da rede. O tick valida as potências e os limites antes de persistir uma +`SolarReading` por estação e uma `EnergyReading` por sessão ativa. Energia total, +solar e da rede são potência multiplicada pela duração simulada em horas. +Os acumuladores da sessão somam as parcelas de cada tick e a potência alocada +passa a refletir o tick mais recente. Estações sem sessão `CHARGING` não geram +leituras; o relógio avança mesmo quando não há sessões ativas. + +Leituras e acumuladores de todas as estações são confirmados numa transação. +Falha em qualquer estação desfaz o tick inteiro e preserva o instante do relógio. +Uma estação já concluída no mesmo instante é ignorada em nova execução; as +constraints de unicidade impedem leituras duplicadas. O controlador usa um +relógio e lock por processo e não executa ticks automaticamente. + +## Cenários demonstráveis + +| Critério | Configuração | Resultado verificado | +| --- | --- | --- | +| 56 | Rede 60 kW, solar 0 kW, quatro pedidos de 20 kW | 15 kW por sessão; 60 kW da rede | +| 57 | Carregador 22 kW, veículo 11 kW, pedido de 20 kW | Alocação limitada a 11 kW; a criação normal da sessão já limita o pedido a 11 kW | +| 58 | Rede 15 kW, solar 25 kW, dois pedidos de 20 kW | 40 kW alocados; 25 kW solares e 15 kW da rede | +| Tick integrado | Estação A: rede 10 kW, solar 6 kW, dois pedidos de 12 kW; estação B: rede 5 kW, solar 0 kW, pedido de 9 kW | A: 8 kW por sessão, sendo 3 solares e 5 da rede; B: 5 kW da rede. Leituras e acumuladores usam kWh do intervalo. | + +O cenário integrado usa 12:00 UTC para obter o pico solar. O cenário 58 é +testado no resolvedor com solar fornecido explicitamente. O cenário 57 também +é coberto pelo fluxo de criação de sessão nos testes de domínio. + +## Invariantes e limites + +O resolvedor rejeita entradas negativas ou não finitas, sessões duplicadas e +capacidade total que exceda a faixa finita de `float`. Em cada sessão, +`0 ≤ alocada ≤ pedido`, `alocada ≤ carregador` e `alocada ≤ veículo`. +No agregado, potência da rede não ultrapassa `grid_limit_kw`; solar não +ultrapassa a disponibilidade. O serviço de tick valida resultados recebidos +do resolvedor injetado, inclusive conservação entre potência alocada, solar e +rede e entre suas energias. Pequenas diferenças de arredondamento binário são +aceitas nas somas. + +Alertas e analytics derivados citados no fluxo geral do `SPEC.md` ainda não são +produzidos pelo tick; pertencem a fases posteriores. A Fase 4 não altera o +schema nem requer migration adicional. + +## Validação desta revisão + +Em 16/09/2026, a suíte backend completa executada via `.venv/bin/pytest -q +--cov-report=term` passou com **156 testes** e cobertura total de **97%**. +`.venv/bin/ruff check .` passou sem achados; `.venv/bin/mypy app` passou sem +problemas em **61 arquivos de origem**. Os testes direcionados de alocação, +tick, API de simulação e leituras passaram com **40 testes** antes da correção +do caso de overflow; a suíte completa acima inclui os novos testes. Não houve +alteração frontend nesta revisão, portanto os checks frontend não foram +executados. Os testes usam SQLite em memória; esta revisão não repetiu uma +validação online das migrations em PostgreSQL.