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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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
# For a fresh demo database only: DEMO_SIMULATION_START_UTC=2026-09-18T11:58:00Z

POSTGRES_DB=chargegrid
POSTGRES_USER=chargegrid
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ activemq-data/
!.env.example
.envrc
.venv
.video-edit-venv/
env/
venv/
ENV/
Expand Down Expand Up @@ -236,5 +237,8 @@ data/models/*
!data/processed/.gitkeep
!data/models/.gitkeep

# Generated temporary artifacts
tmp/

# Streamlit
.streamlit/secrets.toml
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,34 @@ make check

O alvo executa testes, lint e verificação de tipos no backend e no frontend, além do build web. Consulte [CONTRIBUTING.md](docs/CONTRIBUTING.md) para o fluxo detalhado.

## Demo da Sprint 3

Execute o [roteiro reproduzível da Sprint 3](docs/sprint-3/README.md) em um banco isolado. Ele inclui comandos de seed, configuração UTC, cenário por endpoints públicos e resultados esperados.

### Documento da Sprint 3 em PDF

Acesse o [documento da Sprint 3 em PDF](output/pdf/chargegrid_sprint_3.pdf).

### Documento da Sprint 3 em Markdown

Acesse o [documento da Sprint 3 em Markdown](docs/sprint-3/chargegrid_sprint_3.md).

### Vídeo pitch da Sprint 3 em MP4

Acesse o [arquivo MP4 do vídeo pitch da Sprint 3](video_editado/Pitch_challenge_sprint3.mp4).

### Vídeo pitch da Sprint 3 no YouTube

Assista ao [vídeo pitch da Sprint 3 no YouTube](https://youtu.be/caGX1bfDN7s).

Após configurar `.env` e exportar `DEMO_ADMIN_PASSWORD` e `DEMO_USER_PASSWORD` conforme o roteiro:

```bash
docker compose up --build -d
docker compose exec -e DEMO_ADMIN_PASSWORD -e DEMO_USER_PASSWORD backend python -m app.demo_seed
python3 scripts/sprint3_demo.py
```

## Documentação

- [Briefing](BRIEFING.md)
Expand Down Expand Up @@ -132,5 +160,4 @@ 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; não há
seed reproduzível oficial para a demonstração completa.
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.
18 changes: 17 additions & 1 deletion backend/app/core/config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from datetime import UTC, datetime
from functools import lru_cache
from typing import Literal

from pydantic import Field, model_validator
from pydantic import Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

DEFAULT_JWT_SECRET = "development-only-change-me-minimum-32-bytes"
Expand All @@ -21,13 +22,28 @@ 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)
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)
jwt_expiration_minutes: int = Field(default=60, gt=0)
jwt_algorithm: Literal["HS256"] = "HS256"

@field_validator("demo_simulation_start_utc", mode="before")
@classmethod
def empty_demo_start_is_unset(cls, value: object) -> object:
return None if value == "" else value

@model_validator(mode="after")
def reject_default_jwt_secret_outside_local_environments(self) -> "Settings":
if self.demo_simulation_start_utc is not None:
instant = self.demo_simulation_start_utc
if self.app_env.strip().lower() not in {"development", "demo", "test"}:
raise ValueError(
"DEMO_SIMULATION_START_UTC is allowed only in local demo environments"
)
if instant.tzinfo is None or instant.utcoffset() != UTC.utcoffset(instant):
raise ValueError("DEMO_SIMULATION_START_UTC must be an explicit UTC instant")
self.demo_simulation_start_utc = instant.astimezone(UTC)
if (
self.app_env.strip().lower() in NON_LOCAL_ENVIRONMENTS
and self.jwt_secret_key == DEFAULT_JWT_SECRET
Expand Down
100 changes: 100 additions & 0 deletions backend/app/demo_seed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Idempotent, explicitly named seed for the isolated Sprint 3 demo database."""

import os
from datetime import UTC, datetime
from decimal import Decimal

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.core.security import hash_password, verify_password
from app.db.session import SessionLocal
from app.models.billing import Tariff
from app.models.infrastructure import Charger, ChargerStatus, ChargingStation
from app.models.prediction import SystemConfiguration
from app.models.user import User, UserRole
from app.models.vehicle import Vehicle

PREFIX = "SPRINT3-DEMO"
EMAILS = ["sprint3-admin@demo.invalid"] + [f"sprint3-user-{i}@demo.invalid" for i in range(1, 5)]
TARIFF = Decimal("0.8000")
EMISSION_FACTOR = 0.4


def seed(db: Session, admin_password: str, user_password: str) -> None:
if len(admin_password) < 8 or len(user_password) < 8:
raise ValueError("Demo passwords must each have at least 8 characters")
for index, email in enumerate(EMAILS):
role = UserRole.ADMIN if index == 0 else UserRole.USER
user = db.scalar(select(User).where(User.email == email))
if user is None:
user = User(name=f"Sprint 3 Demo {'Admin' if index == 0 else f'User {index}'}",
email=email,
password_hash=hash_password(
admin_password if index == 0 else user_password
),
role=role, is_active=True)
db.add(user)
db.flush()
elif (user.role != role or not user.is_active or not verify_password(
admin_password if index == 0 else user_password, user.password_hash
)):
raise ValueError(f"Demo account conflicts with existing user: {email}")
if index:
plate = f"S3D-{index:04d}"
vehicle = db.scalar(select(Vehicle).where(Vehicle.license_plate == plate))
if vehicle is None:
db.add(Vehicle(user_id=user.id, name=f"Demo EV {index}", brand="Demo",
model="EV", license_plate=plate, max_charge_power_kw=20))
elif vehicle.user_id != user.id or vehicle.max_charge_power_kw != 20:
raise ValueError(f"Demo vehicle conflicts: {plate}")

station = db.scalar(select(ChargingStation).where(ChargingStation.name == PREFIX))
if station is None:
station = ChargingStation(name=PREFIX, description="Sprint 3 simulated station",
grid_limit_kw=60, station_peak_solar_kw=0, is_active=True)
db.add(station)
db.flush()
elif (station.grid_limit_kw != 60 or station.station_peak_solar_kw not in (0, 20)
or not station.is_active):
raise ValueError("Demo station has conflicting configuration")
for index in range(1, 5):
code = f"{PREFIX}-CH-{index:02d}"
charger = db.scalar(select(Charger).where(Charger.code == code))
if charger is None:
db.add(Charger(station_id=station.id, name=f"CH-{index:02d}", code=code,
max_power_kw=22, status=ChargerStatus.AVAILABLE, is_active=True))
elif charger.station_id != station.id or charger.max_power_kw != 22:
raise ValueError(f"Demo charger conflicts: {code}")

tariff = db.scalar(select(Tariff).where(Tariff.name == PREFIX))
if tariff is None:
if db.scalar(select(Tariff).where(Tariff.is_active.is_(True))) is not None:
raise ValueError("Another active tariff exists; use an isolated demo database")
db.add(Tariff(name=PREFIX, price_per_kwh=TARIFF, currency="BRL", is_active=True,
valid_from=datetime(2020, 1, 1, tzinfo=UTC)))
elif tariff.price_per_kwh != TARIFF or not tariff.is_active:
raise ValueError("Demo tariff has conflicting configuration")
config = db.scalar(select(SystemConfiguration).limit(1))
if config is None:
db.add(SystemConfiguration(simulation_speed=60,
grid_emission_factor_kg_per_kwh=EMISSION_FACTOR,
high_demand_threshold=0.85,
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")


def main() -> None:
admin_password = os.environ.get("DEMO_ADMIN_PASSWORD")
user_password = os.environ.get("DEMO_USER_PASSWORD")
if not admin_password or not user_password:
raise SystemExit("Set DEMO_ADMIN_PASSWORD and DEMO_USER_PASSWORD in the environment")
with SessionLocal() as db, db.begin():
seed(db, admin_password, user_password)
print("Sprint 3 demo seed ready: 1 admin, 4 users, 4 vehicles, "
"1 station, 4 chargers, 1 tariff, ESG config")


if __name__ == "__main__":
main()
5 changes: 4 additions & 1 deletion backend/app/simulation/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from sqlalchemy import func, select
from sqlalchemy.orm import Session

from app.core.config import get_settings
from app.models.energy import EnergyReading, SolarReading
from app.models.prediction import SystemConfiguration
from app.services.energy_allocation import EqualSharePowerResolver
Expand All @@ -19,7 +20,9 @@

class SimulationController:
def __init__(self, clock: SimulationClock | None = None) -> None:
self.clock = clock or SimulationClock(initial_instant=datetime.now(UTC))
self.clock = clock or SimulationClock(
initial_instant=get_settings().demo_simulation_start_utc or datetime.now(UTC)
)
self.last_tick: datetime | None = None
self.lock = RLock()

Expand Down
51 changes: 51 additions & 0 deletions backend/tests/test_demo_seed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from datetime import UTC, datetime

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

from app.core.config import Settings, get_settings
from app.demo_seed import seed
from app.models.billing import Tariff
from app.models.infrastructure import Charger, ChargingStation
from app.models.prediction import SystemConfiguration
from app.models.user import User
from app.models.vehicle import Vehicle
from app.simulation.control import SimulationController


def test_demo_seed_is_idempotent(db_session: Session) -> None:
seed(db_session, "admin-secret", "user-secret")
db_session.commit()
seed(db_session, "admin-secret", "user-secret")
db_session.commit()
for model, expected in ((User, 5), (Vehicle, 4), (ChargingStation, 1),
(Charger, 4), (Tariff, 1), (SystemConfiguration, 1)):
assert db_session.scalar(select(func.count()).select_from(model)) == expected
assert db_session.scalar(select(ChargingStation)).grid_limit_kw == 60
assert db_session.scalar(select(Tariff)).is_active


def test_demo_seed_requires_passwords(db_session: Session) -> None:
with pytest.raises(ValueError, match="at least 8"):
seed(db_session, "short", "user-secret")


def test_demo_clock_is_opt_in_and_utc(monkeypatch: pytest.MonkeyPatch) -> None:
with monkeypatch.context() as env:
env.setenv("DEMO_SIMULATION_START_UTC", "2026-09-18T11:58:00Z")
get_settings.cache_clear()
assert SimulationController().clock.current_instant == datetime(
2026, 9, 18, 11, 58, tzinfo=UTC
)
env.setenv("APP_ENV", "production")
env.setenv("JWT_SECRET_KEY", "a" * 32)
with pytest.raises(ValueError, match="local demo"):
Settings()
env.setenv("APP_ENV", "development")
env.setenv("DEMO_SIMULATION_START_UTC", "2026-09-18T11:58:00")
with pytest.raises(ValueError, match="explicit UTC"):
Settings()
get_settings.cache_clear()
elapsed = SimulationController().clock.current_instant - datetime.now(UTC)
assert abs(elapsed.total_seconds()) < 5
59 changes: 59 additions & 0 deletions backend/tests/test_sprint3_demo_script.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import importlib.util
from pathlib import Path
from types import ModuleType
from urllib.error import URLError

import pytest


def load_demo_script() -> ModuleType:
path = Path(__file__).parents[2] / "scripts" / "sprint3_demo.py"
spec = importlib.util.spec_from_file_location("sprint3_demo", path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


class HealthyResponse:
status = 200

def __enter__(self) -> "HealthyResponse":
return self

def __exit__(self, *args: object) -> None:
return None


def test_wait_for_api_retries_transient_startup_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
demo = load_demo_script()
attempts = iter([URLError("starting"), HealthyResponse()])
monotonic_values = iter([0.0, 0.0, 0.1])

def open_api(*args: object, **kwargs: object) -> HealthyResponse:
result = next(attempts)
if isinstance(result, Exception):
raise result
return result

monkeypatch.setattr(demo, "urlopen", open_api)
monkeypatch.setattr(demo.time, "monotonic", lambda: next(monotonic_values))
monkeypatch.setattr(demo.time, "sleep", lambda _: None)

demo.wait_for_api(timeout_seconds=1)


def test_wait_for_api_reports_backend_diagnostics(monkeypatch: pytest.MonkeyPatch) -> None:
demo = load_demo_script()
monotonic_values = iter([0.0, 0.0, 1.0])

monkeypatch.setattr(demo, "urlopen", lambda *args, **kwargs: (_ for _ in ()).throw(
URLError("unavailable")
))
monkeypatch.setattr(demo.time, "monotonic", lambda: next(monotonic_values))
monkeypatch.setattr(demo.time, "sleep", lambda _: None)

with pytest.raises(RuntimeError, match=r"docker compose logs backend"):
demo.wait_for_api(timeout_seconds=0.5)
11 changes: 10 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,15 @@ services:
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-development-only-change-me-minimum-32-bytes}
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}
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:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health', timeout=2)"]
interval: 2s
timeout: 3s
retries: 15
start_period: 5s
depends_on:
db:
condition: service_healthy
Expand All @@ -41,7 +49,8 @@ services:
environment:
VITE_API_URL: ${VITE_API_URL:-http://localhost:8000/api/v1}
depends_on:
- backend
backend:
condition: service_healthy
ports:
- "5173:5173"
volumes:
Expand Down
Loading
Loading