1
0
forked from EHV/sejeteralo

Compare commits

13 Commits
main ... main

Author SHA1 Message Date
syoul
b24f226b35 fix prod : Eau2018.xls hors du volume /app + pin Trivy
Le volume backend-data monté sur /app masquait Eau2018.xls copié dans
l'image à /app/Eau2018.xls — d'où le FileNotFoundError au step seed CI.

- Dockerfile : copie Eau2018.xls dans /opt/ (hors mount)
- seed.py : résolution multi-chemin avec /opt en priorité (Docker)
- .woodpecker.yml : trivy:latest -> trivy:0.70.0 (pin reproductibilité)

Note : si le seed replante avec la même erreur après ce commit, c'est
que le volume backend-data en prod a aussi un seed.py figé (shadowing
de /app entier). Fix de fond à venir : déplacer le mount sur /app/data.
2026-04-26 22:57:39 +02:00
Yvv
65c148142c docs : page équations mathématiques du modèle Bézier (KaTeX)
Toutes les formules du moteur de tarification : courbes paramétriques
palier 1/2, décomposition intégrale (α₁, α₂, β₂), calcul de p₀,
résolution cubique Cardano + Newton-Raphson.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 20:50:56 +02:00
Yvv
92fb60c114 ci : ajoute Eau2018.xls au dépôt (requis pour la seed Docker)
- .gitignore : exception !Eau2018.xls dans la règle *.xls
- Eau2018.xls : données tarifaires Saoû 2018, nécessaires à seed.py
  pour créer les 363 foyers et la courbe de référence

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 04:51:51 +01:00
Yvv
532cc1a0e3 ci: déclenche pipeline seed prod 2026-03-24 04:39:50 +01:00
Yvv
c1a9548bd7 ci : step seed après deploy (idempotent)
Lance python seed.py dans le conteneur backend après chaque deploy.
Idempotent : crée Saoû + votes si absent, ajoute les fixtures dev sinon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 04:37:34 +01:00
Yvv
8341a050d3 fix prod : Eau2018.xls dans l'image Docker + build context racine
- backend.Dockerfile : COPY Eau2018.xls /app/ (contexte = racine projet)
- docker-compose.yml : section build: pour backend et frontend (context: ..)
- seed.py : XLS_PATH cherche d'abord /app/Eau2018.xls, fallback ../

Sans ça python seed.py échoue dans le conteneur (fichier absent)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 04:15:06 +01:00
Yvv
90b069cb88 fix seed : fixtures dev idempotentes, indépendantes de l'early-return
- DEV_FIXTURES défini au niveau module (constante partagée)
- seed principal (commune + 363 foyers + votes) sous `if commune is None`
- fixtures toujours vérifiées/insérées après, quel que soit l'état de la DB
- résout le cas prod avec DB déjà seedée

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 03:58:31 +01:00
Yvv
19ac64c856 Seed : foyers fixture dev + codes stables dans dev hints
- seed.py : 3 foyers avec codes fixes (DEVTEST2/3/4, RS/RP/PRO)
  insérés avant les 363 réels ; existing_codes pré-chargé → zéro collision
- page citizen : dev hint mis à jour avec les 3 mêmes codes + profils
- CLAUDE.md : reformaté en guide de session

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 03:45:12 +01:00
syoul
017806025c fix: redirect_slashes=False pour éviter les 307 avec IPs Docker internes
FastAPI redirige /communes → /communes/ avec l'IP container dans le
Location header, inaccessible depuis le navigateur via Fabio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 12:41:10 +01:00
syoul
c9bb437695 fix: régénérer package-lock.json (désynchronisé avec package.json)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 12:19:59 +01:00
syoul
f314998ca5 fix: npm ci --legacy-peer-deps pour pinia 3 / @pinia-nuxt 0.9 (peer dep conflict)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 12:17:39 +01:00
syoul
e05d081cac ci: exclure tests dépendant de Eau2018.xls (données dev, absent en CI)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 12:14:12 +01:00
syoul
4951e20099 ci: réécriture pipeline Woodpecker next + migration Fabio
- Format liste steps (Woodpecker next)
- Séparation from_secret / volumes (bug Woodpecker next)
- Suppression $\{VAR\} → $VAR dans les commands
- Ajout security-check, validate, test-backend
- Ajout SBOM : syft + trivy + dependency-track
- Ajout write-env / test-env / test-deploy / healthcheck
- Remplacement SSH+registry → build local + deploy via Docker socket
- docker-compose : Traefik → Fabio/Registrator (labels SERVICE_*)
- docker-compose : build: → image: pré-construites

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 12:09:40 +01:00
12 changed files with 827 additions and 306 deletions

1
.gitignore vendored
View File

@@ -56,6 +56,7 @@ data/DEV-CREDENTIALS.md
# Data files (research, not part of the app) # Data files (research, not part of the app)
*.pdf *.pdf
*.xls *.xls
!Eau2018.xls
*.xlsx *.xlsx
*.ipynb *.ipynb
eau.py eau.py

View File

@@ -1,63 +1,217 @@
when: when:
branch: main - branch: main
event: push event: push
steps: steps:
build-frontend:
image: node:20-slim
commands:
- cd frontend && npm ci && npm run build
build-backend: - name: security-check
image: alpine:3.20
commands:
- |
if [ -f .env ]; then
echo "ERREUR: .env ne doit pas etre commite dans le depot"
exit 1
fi
- 'grep -q "^\.env$" .gitignore || (echo "ERREUR: .env manquant dans .gitignore" && exit 1)'
- echo "Security check OK"
- name: validate
image: docker:27-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
APP_DOMAIN: validate.example.com
SECRET_KEY: placeholder
commands:
- |
export COMPOSE_PROJECT_NAME=$(printf '%s-%s-%s' "$CI_REPO_OWNER" "$CI_REPO_NAME" "$CI_COMMIT_BRANCH" | tr 'A-Z/' 'a-z-')
docker compose -f docker/docker-compose.yml config --quiet
- echo "docker-compose.yml valide"
- name: test-backend
image: python:3.11-slim image: python:3.11-slim
commands: commands:
- pip install -r backend/requirements.txt - pip install -r backend/requirements.txt
- cd backend && python -m pytest tests/ -v --tb=short || true - cd backend && python -m pytest tests/ -v --tb=short -k "not (test_saou_data_loaded or test_p0 or test_full_tariff or test_linear_p0)"
docker-backend: # NOTE: volumes + pas de from_secret : compatible
image: woodpeckerci/plugin-docker-buildx - name: build-backend
settings: image: docker:27-cli
repo: volumes:
from_secret: registry_repo_backend - /var/run/docker.sock:/var/run/docker.sock
registry: commands:
from_secret: registry_host - docker build -t sejeteralo-backend:latest -f docker/backend.Dockerfile --target production .
username: - echo "Image backend construite"
from_secret: registry_user
password:
from_secret: registry_password
dockerfile: docker/backend.Dockerfile
target: production
tags: latest
when:
status: success
docker-frontend: # NOTE: volumes + pas de from_secret : compatible
image: woodpeckerci/plugin-docker-buildx - name: build-frontend
settings: image: docker:27-cli
repo: volumes:
from_secret: registry_repo_frontend - /var/run/docker.sock:/var/run/docker.sock
registry: commands:
from_secret: registry_host - docker build -t sejeteralo-frontend:latest -f docker/frontend.Dockerfile --target production .
username: - echo "Image frontend construite"
from_secret: registry_user
password:
from_secret: registry_password
dockerfile: docker/frontend.Dockerfile
target: production
tags: latest
when:
status: success
deploy: # NOTE: volumes + pas de from_secret : compatible
image: appleboy/drone-ssh - name: sbom-generate
settings: image: alpine:3.20
host: volumes:
from_secret: deploy_host - /var/run/docker.sock:/var/run/docker.sock
username: commands:
from_secret: deploy_user - apk add --no-cache curl
key: - curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
from_secret: deploy_key - mkdir -p .reports
script: - syft sejeteralo-backend:latest -o cyclonedx-json --file .reports/sbom-backend.cyclonedx.json
- cd /opt/sejeteralo && docker compose -f docker/docker-compose.yml pull && docker compose -f docker/docker-compose.yml up -d - syft sejeteralo-frontend:latest -o cyclonedx-json --file .reports/sbom-frontend.cyclonedx.json
- echo "SBOM generes"
# NOTE: volumes + pas de from_secret : compatible
- name: sbom-scan
image: aquasec/trivy:0.70.0
volumes:
- /home/syoul/trivy-cache:/root/.cache/trivy
commands:
- trivy sbom --format json --output .reports/trivy-backend.json .reports/sbom-backend.cyclonedx.json
- trivy sbom --format json --output .reports/trivy-frontend.json .reports/sbom-frontend.cyclonedx.json
- echo "Scan CVE termine"
# NOTE: from_secret + pas de volumes : compatible
- name: sbom-publish
image: alpine/curl:latest
environment:
DTRACK_TOKEN:
from_secret: dependency_track_token
DTRACK_DOMAIN:
from_secret: dtrack_domain
commands:
- |
VERSION=$(date +%Y-%m-%d)-$(echo "$CI_COMMIT_SHA" | cut -c1-8)
for COMPONENT in backend frontend; do
HTTP=$(curl -s -o /tmp/dtrack-resp.txt -w "%{http_code}" -X POST "https://$DTRACK_DOMAIN/api/v1/bom" \
-H "X-Api-Key: $DTRACK_TOKEN" \
-F "autoCreate=true" \
-F "projectName=sejeteralo-$COMPONENT" \
-F "projectVersion=$VERSION" \
-F "bom=@.reports/sbom-$COMPONENT.cyclonedx.json")
echo "HTTP $HTTP sejeteralo-$COMPONENT : $(cat /tmp/dtrack-resp.txt)"
[ "$HTTP" -ge 200 ] && [ "$HTTP" -lt 300 ] || exit 1
done
# NOTE: from_secret + pas de volumes : compatible
- name: write-env
image: alpine:3.20
environment:
APP_DOMAIN:
from_secret: app_domain
SECRET_KEY:
from_secret: secret_key
commands:
- env | grep -E "^(APP_DOMAIN|SECRET_KEY)=" > .env.deploy
- OWNER=$(echo "$CI_REPO_OWNER" | tr 'A-Z' 'a-z') && REPO=$(echo "$CI_REPO_NAME" | tr 'A-Z' 'a-z') && BRANCH=$(echo "$CI_COMMIT_BRANCH" | tr 'A-Z/' 'a-z-') && echo "COMPOSE_PROJECT_NAME=$OWNER-$REPO-$BRANCH" >> .env.deploy
- echo ".env.deploy cree ($(wc -c < .env.deploy) octets)"
- name: test-env
image: alpine:3.20
commands:
- |
[ -f .env.deploy ] || { echo "FAIL: .env.deploy introuvable"; exit 1; }
echo "PASS: .env.deploy present"
- |
VAL=$(grep '^COMPOSE_PROJECT_NAME=' .env.deploy | cut -d= -f2)
[ -z "$VAL" ] && echo "FAIL: COMPOSE_PROJECT_NAME vide" && exit 1
echo "PASS: COMPOSE_PROJECT_NAME = $VAL"
- |
VAL=$(grep '^APP_DOMAIN=' .env.deploy | cut -d= -f2)
[ -z "$VAL" ] && echo "FAIL: APP_DOMAIN vide" && exit 1
echo "PASS: APP_DOMAIN = $VAL"
# NOTE: volumes + pas de from_secret : compatible
# Routing Fabio gere automatiquement par Registrator via labels SERVICE_* du compose
- name: deploy
image: docker:27-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/sejeteralo:/opt/sejeteralo
commands:
- cp .env.deploy /opt/sejeteralo/.env
- chmod 600 /opt/sejeteralo/.env
- cp docker/docker-compose.yml /opt/sejeteralo/docker-compose.yml
# Arreter avant le challenge ACME : libere le webroot pour sonic-acme-1
- cd /opt/sejeteralo && docker compose stop
- |
DOMAIN=$(grep '^APP_DOMAIN=' /opt/sejeteralo/.env | cut -d= -f2)
ACME_EXIT=0
docker exec sonic-acme-1 /app/acme.sh \
--home /etc/acme.sh \
--issue -d "$DOMAIN" \
--webroot /usr/share/nginx/html \
--server letsencrypt \
--accountemail support+acme@asycn.io || ACME_EXIT=$?
if [ "$ACME_EXIT" -ne 0 ] && [ "$ACME_EXIT" -ne 2 ]; then
echo "ERREUR: acme.sh a echoue (exit $ACME_EXIT)"
exit 1
fi
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/fullchain.cer /host/certs/$DOMAIN-cert.pem
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/$DOMAIN.key /host/certs/$DOMAIN-key.pem
echo "TLS OK (acme exit $ACME_EXIT)"
# Images construites localement dans la pipeline : pas de docker compose pull
- cd /opt/sejeteralo && docker compose up -d --remove-orphans
- cd /opt/sejeteralo && docker compose ps
- name: seed
image: docker:27-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/sejeteralo:/opt/sejeteralo
commands:
- |
PROJECT=$(grep '^COMPOSE_PROJECT_NAME=' /opt/sejeteralo/.env | cut -d= -f2)
BACKEND="$PROJECT-backend"
echo "Seed sur $BACKEND..."
docker exec "$BACKEND" python seed.py
# NOTE: volumes + pas de from_secret : compatible
- name: test-deploy
image: docker:27-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/sejeteralo:/opt/sejeteralo
commands:
- |
PROJECT=$(grep '^COMPOSE_PROJECT_NAME=' /opt/sejeteralo/.env | cut -d= -f2)
for SVC in backend frontend; do
STATUS=$(docker inspect --format '{{.State.Status}}' "$PROJECT-$SVC" 2>/dev/null || echo "absent")
echo "$PROJECT-$SVC : $STATUS"
[ "$STATUS" = "running" ] || { echo "FAIL: $PROJECT-$SVC non running"; exit 1; }
done
echo "PASS: tous les containers running"
- name: healthcheck
image: alpine:3.20
commands:
- apk add --no-cache --quiet curl
- |
SITE=$(grep '^APP_DOMAIN=' .env.deploy | cut -d= -f2)
TARGET="https://$SITE"
echo "Healthcheck $TARGET..."
MAX=60
i=0
until [ $i -ge $MAX ]; do
CODE=$(curl -sSo /dev/null -w "%{http_code}" "$TARGET" 2>/dev/null)
echo "Tentative $((i+1))/$MAX - HTTP $CODE"
if [ "$CODE" = "200" ] || [ "$CODE" = "301" ] || [ "$CODE" = "302" ]; then
echo "PASS: app repond sur $TARGET"
exit 0
fi
i=$((i+1))
sleep 10
done
echo "FAIL: app ne repond pas apres 10 minutes"
exit 1
- name: notify-failure
image: alpine:3.20
commands:
- 'echo "ECHEC pipeline #$CI_BUILD_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
when: when:
status: success - status: failure

166
CLAUDE.md
View File

@@ -1,104 +1,100 @@
# CLAUDE.md # SejeteralO
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Plateforme de tarification participative de l'eau pour communes françaises.
Les citoyens votent sur la forme de la courbe tarifaire via des éditeurs Bézier interactifs ;
le système calcule un tarif à équilibre de recettes en temps réel.
## Project Overview ## Protocole de début de session
SejeteralO is a participatory water tarification platform for French communes. Citizens vote on tariff curve shapes via interactive Bézier editors; the system computes revenue-balanced pricing in real-time. Built as a **FastAPI + Nuxt 4** monorepo. 1. `git pull --rebase origin main`
2. Vérifier que `data/` existe à la racine — si absent, signaler avant toute opération
3. Si l'objectif de la session n'est pas précisé, le demander
## Development Commands ## Stack
```bash - **Frontend** : Nuxt 4 (Vue 3, TypeScript) + Pinia ; package manager : npm ; port dev : 3009
# Install - **Backend** : Python FastAPI + SQLAlchemy 2.0 async + SQLite (aiosqlite) ; port dev : 8000
cd backend && python3 -m venv venv && . venv/bin/activate && pip install -r requirements.txt - Déploiement : Docker multi-stage + Traefik (backend + frontend) ; CI Woodpecker
cd frontend && npm install - Pas d'UnoCSS — CSS vanilla avec variables CSS palettes dans `main.css`
# Dev servers (run separately) ## Structure
cd backend && . venv/bin/activate && uvicorn app.main:app --reload --port 8000
cd frontend && npm run dev # port 3009
# Seed demo data (Saoû commune, 363 households, admin accounts) ```
cd backend && . venv/bin/activate && python seed.py frontend/
app/
# Tests components/ # composants Vue (dont DisplaySettings.vue — 6 palettes)
cd backend && . venv/bin/activate && python -m pytest tests/ -v layouts/ # layouts Nuxt
pages/
# Build frontend commune/[slug]/index.vue # page principale citoyenne (~1900 lignes)
cd frontend && npm run build composables/
useApi.ts # wraps fetch avec Bearer token ; usage : api.get<Type>('/path')
# Docker middleware/ # route middleware (auth)
make docker-up # production (docker/docker-compose.yml) plugins/
make docker-dev # dev with hot-reload (+ docker-compose.dev.yml) auth-restore.client.ts # restaure token depuis localStorage au démarrage
stores/
auth.ts # token, role, communeSlug
commune.ts
utils/
bezier-math.ts # formule Bézier (Cardano + Newton-Raphson) — miroir du backend
nuxt.config.ts # port 3009, apiBase via NUXT_PUBLIC_API_BASE (défaut :8000)
backend/
app/
routers/ # 6 routers : auth, communes, tariff, votes, households, content
engine/
pricing.py # compute_p0(), compute_tariff(), compute_impacts()
integrals.py # coefficients α₁, α₂, β₂ (intégrales Bézier)
median.py # médiane élément par élément des votes
current_model.py # tarif linéaire de référence
models/ # Commune, TariffParams, Household, Vote, AdminUser, CommuneContent
alembic/versions/ # migrations
tests/
seed.py # Commune Saoû, 363 foyers, codes auth 8 chars, comptes admin
data/ # runtime — JAMAIS dans git (voir Données runtime)
docker/
docker-compose.yml # backend + frontend (réseau traefik externe)
backend.Dockerfile
frontend.Dockerfile
docker-compose.dev.yml
Makefile # docker-up, docker-dev
``` ```
## Architecture ## Données runtime (CRITIQUE)
### Backend (FastAPI + SQLAlchemy async + SQLite) - `data/` à la racine : contenu non géré par git, **jamais écrasé par les commits**
- Volume Docker `backend-data` monté sur `/app` — contient `sejeteralo.db` (SQLite)
- **Avant toute migration de chemin ou écriture sur data/ ou le volume : demander confirmation**
- En dev local : la DB SQLite est dans `backend/` (chemin relatif `./sejeteralo.db`)
- Seed requis après chaque reset DB : `cd backend && python seed.py`
**API prefix:** `/api/v1/` — 6 routers: auth, communes, tariff, votes, households, content. ## Commandes
**Auth:** JWT tokens (python-jose). Admin tokens = 24h, citizen tokens = 4h. Dependencies: `get_current_admin`, `get_current_citizen`, `require_super_admin`. ```bash
# Backend
cd backend && . venv/bin/activate
uvicorn app.main:app --reload --port 8000 --host 0.0.0.0
python -m pytest tests/ -v
python seed.py # Saoû, 363 foyers, admin accounts
**Database:** SQLAlchemy 2.0 async with aiosqlite. Migrations via Alembic (`backend/alembic/versions/`). Key models: Commune, TariffParams, Household (with 8-char auth_code), Vote, AdminUser, CommuneContent. # Frontend
cd frontend && npm run dev # :3009
npm run build
**Engine** (`backend/app/engine/`): The core tariff math. # Docker
- `pricing.py``compute_p0()`, `compute_tariff()`, `compute_impacts()` make docker-up # production
- `integrals.py` — Bézier curve integral computation (α₁, α₂, β₂ coefficients) make docker-dev # dev avec hot-reload
- `median.py` — Element-wise median of vote parameters ```
- `current_model.py` — Baseline linear tariff for comparison
### Frontend (Nuxt 4 + Vue 3 + Pinia) ## Conventions / pièges
**Config:** `nuxt.config.ts`, dev port 3009, API base via `NUXT_PUBLIC_API_BASE`. - **UI français, code anglais** — "foyer" = household (facturation), "électeur" = voter (vote)
- **Modèle Bézier deux niveaux** — 6 paramètres citoyens (vinf, a, b, c, d, e) + p0 auto-calculé :
**State:** Pinia stores in `app/stores/``auth.ts` (token, role, communeSlug) and `commune.ts`. Auth restored from localStorage on page load via `plugins/auth-restore.client.ts`.
**API calls:** `app/composables/useApi.ts` — wraps fetch with Bearer token injection. Usage: `const api = useApi(); const data = await api.get<Type>('/path')`.
**Key page:** `app/pages/commune/[slug]/index.vue` (~1900 lines) — the public citizen-facing page with interactive Bézier chart, sidebar auth/vote, histogram, baseline charts, marginal price chart, key metrics banner. All SVG charts use reactive theme bindings.
## The Bézier Tariff Model
Two-tier cubic Bézier with **6 citizen-adjustable parameters** + computed p0:
- **vinf** — inflection volume (tier 1 ↔ tier 2 boundary)
- **a, b, c, d, e** — shape parameters, each ∈ [0, 1]
- **p0** — price at inflection, **auto-computed** to balance revenue:
``` ```
p0 = (Recettes Σabo Σβ₂) / Σ(α₁ + α₂) p0 = (Recettes Σabo Σβ₂) / Σ(α₁ + α₂)
``` ```
Implémenté deux fois : backend Python (`engine/pricing.py`) et frontend TS (`utils/bezier-math.ts`) — garder synchronisés
Tier 1 (0→vinf): population pricing. Tier 2 (vinf→vmax): exceptional consumption pricing. - **Agrégation votes** : médiane élément par élément des votes actifs (pas moyenne)
- **Graphiques SVG** : axe X inversé (volumes élevés à gauche) — utiliser bindings réactifs `t.*` ou `var(--svg-*)`, jamais de couleurs hex codées en dur dans les SVG
**Implemented twice** — backend (`engine/pricing.py` with numpy) and frontend (`utils/bezier-math.ts` with Cardano's formula + Newton-Raphson). Frontend computes locally for instant UI feedback; server is authoritative. - **Thème** : 6 palettes via `useState('theme-dark')` ; CSS vars : `--color-primary`, `--color-bg`, `--color-surface`, `--color-text`, `--color-border`, `--svg-plot-bg`, `--svg-grid`, `--svg-text`, `--svg-text-light` ; `.palette-dark` sur `<html>`
- **Dev hints** : classe `.dev-hint` + `v-if="isDev"` ; les codes auth doivent exister dans la DB seedée
## Vote Flow - **Port backend** : 8000 en local (nuxt.config + uvicorn) — la table globale CLAUDE.md indique 8009 par erreur ; les fichiers de config font foi
- **Pas de `ssr: true`** — CSR uniquement
1. Citizen authenticates with commune slug + 8-char auth_code → JWT citizen token
2. Frontend loads tariff params, household stats, published/median curve
3. Citizen drags Bézier control points → local p0 recomputation in real-time
4. Submit: POST `/communes/{slug}/votes` with 6 params → backend computes p0, deactivates old votes
5. Aggregation: element-wise median of all active votes produces the collective curve
## Theme / Dark Mode
- `DisplaySettings.vue`: 6 palettes (4 light: eau, terre, foret, ardoise; 2 dark: nuit, ocean)
- Global state via `useState('theme-dark')` — shared across components
- CSS variables: `--color-primary`, `--color-bg`, `--color-surface`, `--color-text`, `--color-border`
- SVG-specific: `--svg-plot-bg`, `--svg-grid`, `--svg-text`, `--svg-text-light`
- SVG elements use reactive `t.*` bindings (computed from `isDark`), never hardcoded colors
- `.palette-dark` class on `<html>` for CSS overrides in `main.css`
- Settings persisted in localStorage: `sej-palette`, `sej-fontSize`, `sej-density`
## Conventions
- **Language:** All UI labels and messages are in French (no i18n layer)
- **Terminology:** "foyer" = household (bills/consumption context), "électeur" = voter (vote context)
- **SVG charts:** Inverted X axis (high volumes left, 0 right) on main/baseline charts. All colors must be theme-reactive — use `t.*` bindings or `var(--svg-*)` CSS variables, never hardcoded hex in SVG
- **CSS:** Mobile-first with `clamp()` for fluid sizing. Breakpoints: 480 / 768 / 1024px
- **Dev hint boxes:** Use class `.dev-hint` (has dark mode override in main.css), guarded by `v-if="isDev"`
- **Auth codes in dev hints:** Must actually exist in the seeded database
## CI/CD
Woodpecker CI (`.woodpecker.yml`): build frontend → build backend → docker push (2 images) → SSH deploy. Docker files in `docker/` with multi-stage builds (base → build → production/development). Production uses Traefik labels for routing.

BIN
Eau2018.xls Normal file

Binary file not shown.

View File

@@ -19,6 +19,7 @@ app = FastAPI(
description="Outil de démocratie participative pour la tarification de l'eau", description="Outil de démocratie participative pour la tarification de l'eau",
version="0.1.0", version="0.1.0",
lifespan=lifespan, lifespan=lifespan,
redirect_slashes=False,
) )
app.add_middleware( app.add_middleware(

View File

@@ -17,167 +17,189 @@ from app.services.auth_service import hash_password
from app.services.import_service import generate_auth_code from app.services.import_service import generate_auth_code
from app.engine.pricing import HouseholdData, compute_p0 from app.engine.pricing import HouseholdData, compute_p0
XLS_PATH = os.path.join(os.path.dirname(__file__), "..", "Eau2018.xls") _XLS_CANDIDATES = [
"/opt/Eau2018.xls", # image Docker (hors volume monté sur /app)
os.path.join(os.path.dirname(__file__), "Eau2018.xls"),
os.path.join(os.path.dirname(__file__), "..", "Eau2018.xls"), # dev local depuis backend/
]
XLS_PATH = next((p for p in _XLS_CANDIDATES if os.path.exists(p)), _XLS_CANDIDATES[-1])
# Codes fixes — identiques dans le dev hint frontend
DEV_FIXTURES = [
{"identifier": "[DEV] Foyer RS 60m³", "status": "RS", "volume_m3": 60.0, "price_paid_eur": 140.0, "auth_code": "DEVTEST2"},
{"identifier": "[DEV] Foyer RP 120m³", "status": "RP", "volume_m3": 120.0, "price_paid_eur": 265.0, "auth_code": "DEVTEST3"},
{"identifier": "[DEV] Foyer PRO 350m³", "status": "PRO", "volume_m3": 350.0, "price_paid_eur": 680.0, "auth_code": "DEVTEST4"},
]
async def seed(): async def seed():
await init_db() await init_db()
async with async_session() as db: async with async_session() as db:
# Check if already seeded # ── Commune Saoû (idempotent) ──────────────────────────────────────────
result = await db.execute(select(Commune).where(Commune.slug == "saou")) result = await db.execute(select(Commune).where(Commune.slug == "saou"))
if result.scalar_one_or_none(): commune = result.scalar_one_or_none()
print("Saoû already seeded.")
return
# Create commune if commune is None:
commune = Commune( commune = Commune(
name="Saoû", name="Saoû",
slug="saou", slug="saou",
description="Commune de Saoû - Tarification progressive de l'eau", description="Commune de Saoû - Tarification progressive de l'eau",
)
db.add(commune)
await db.flush()
# Create tariff params
params = TariffParams(
commune_id=commune.id,
abop=100,
abos=100,
recettes=75000,
pmax=20,
vmax=2100,
differentiated_tariff=False,
data_year=2018,
data_imported_at=datetime.utcnow(),
)
db.add(params)
# Create super admin (manages all communes)
super_admin = AdminUser(
email="superadmin@sejeteralo.fr",
hashed_password=hash_password("superadmin"),
full_name="Super Admin",
role="super_admin",
)
db.add(super_admin)
# Create commune admin for Saoû (manages only this commune)
commune_admin = AdminUser(
email="saou@sejeteralo.fr",
hashed_password=hash_password("saou2024"),
full_name="Admin Saoû",
role="commune_admin",
)
commune_admin.communes.append(commune)
db.add(commune_admin)
# Import households from Eau2018.xls
book = xlrd.open_workbook(XLS_PATH)
sheet = book.sheet_by_name("CALCULS")
nb_hab = 363
existing_codes = set()
for r in range(1, nb_hab + 1):
name = sheet.cell_value(r, 0)
status = sheet.cell_value(r, 3)
volume = sheet.cell_value(r, 4)
price = sheet.cell_value(r, 33)
code = generate_auth_code()
while code in existing_codes:
code = generate_auth_code()
existing_codes.add(code)
household = Household(
commune_id=commune.id,
identifier=str(name).strip(),
status=str(status).strip().upper(),
volume_m3=float(volume),
price_paid_eur=float(price) if price else 0.0,
auth_code=code,
) )
db.add(household) db.add(commune)
await db.flush()
await db.flush() # Create tariff params
params = TariffParams(
commune_id=commune.id,
abop=100,
abos=100,
recettes=75000,
pmax=20,
vmax=2100,
differentiated_tariff=False,
data_year=2018,
data_imported_at=datetime.utcnow(),
)
db.add(params)
# ── Publish a reference curve ── # Create super admin (manages all communes)
# Reference: vinf=400, all params=0.5 super_admin = AdminUser(
ref_vinf, ref_a, ref_b, ref_c, ref_d, ref_e = 400, 0.5, 0.5, 0.5, 0.5, 0.5 email="superadmin@sejeteralo.fr",
hashed_password=hash_password("superadmin"),
full_name="Super Admin",
role="super_admin",
)
db.add(super_admin)
hh_result = await db.execute( # Create commune admin for Saoû (manages only this commune)
select(Household).where(Household.commune_id == commune.id) commune_admin = AdminUser(
) email="saou@sejeteralo.fr",
all_households = hh_result.scalars().all() hashed_password=hash_password("saou2024"),
hh_data = [ full_name="Admin Saoû",
HouseholdData(volume_m3=h.volume_m3, status=h.status, price_paid_eur=h.price_paid_eur) role="commune_admin",
for h in all_households )
] commune_admin.communes.append(commune)
db.add(commune_admin)
ref_p0 = compute_p0( # Import households from Eau2018.xls
hh_data, book = xlrd.open_workbook(XLS_PATH)
recettes=params.recettes, abop=params.abop, abos=params.abos, sheet = book.sheet_by_name("CALCULS")
vinf=ref_vinf, vmax=params.vmax, pmax=params.pmax, nb_hab = 363
a=ref_a, b=ref_b, c=ref_c, d=ref_d, e=ref_e,
)
params.published_vinf = ref_vinf
params.published_a = ref_a
params.published_b = ref_b
params.published_c = ref_c
params.published_d = ref_d
params.published_e = ref_e
params.published_p0 = ref_p0
params.published_at = datetime.utcnow()
# ── Generate 10 votes, small variations around the reference ── existing_codes = {f["auth_code"] for f in DEV_FIXTURES}
random.seed(42) for r in range(1, nb_hab + 1):
name = sheet.cell_value(r, 0)
status = sheet.cell_value(r, 3)
volume = sheet.cell_value(r, 4)
price = sheet.cell_value(r, 33)
vote_profiles = [ code = generate_auth_code()
# 5 votes slightly below reference (eco-leaning) while code in existing_codes:
{"vinf": 350, "a": 0.45, "b": 0.48, "c": 0.40, "d": 0.52, "e": 0.55}, code = generate_auth_code()
{"vinf": 370, "a": 0.42, "b": 0.50, "c": 0.45, "d": 0.48, "e": 0.52}, existing_codes.add(code)
{"vinf": 380, "a": 0.48, "b": 0.45, "c": 0.42, "d": 0.50, "e": 0.58},
{"vinf": 360, "a": 0.50, "b": 0.52, "c": 0.38, "d": 0.55, "e": 0.50},
{"vinf": 390, "a": 0.47, "b": 0.47, "c": 0.48, "d": 0.46, "e": 0.53},
# 5 votes slightly above reference (lax-leaning)
{"vinf": 420, "a": 0.52, "b": 0.50, "c": 0.55, "d": 0.48, "e": 0.45},
{"vinf": 440, "a": 0.55, "b": 0.53, "c": 0.52, "d": 0.50, "e": 0.42},
{"vinf": 430, "a": 0.50, "b": 0.55, "c": 0.58, "d": 0.45, "e": 0.48},
{"vinf": 410, "a": 0.53, "b": 0.48, "c": 0.50, "d": 0.52, "e": 0.47},
{"vinf": 450, "a": 0.48, "b": 0.52, "c": 0.60, "d": 0.42, "e": 0.40},
]
used_households = set() db.add(Household(
vote_count = 0 commune_id=commune.id,
for prof in vote_profiles: identifier=str(name).strip(),
# Pick a unique household status=str(status).strip().upper(),
hh_pick = random.choice(all_households) volume_m3=float(volume),
while hh_pick.id in used_households: price_paid_eur=float(price) if price else 0.0,
hh_pick = random.choice(all_households) auth_code=code,
used_households.add(hh_pick.id) ))
vp0 = compute_p0( await db.flush()
# ── Publish a reference curve ──
ref_vinf, ref_a, ref_b, ref_c, ref_d, ref_e = 400, 0.5, 0.5, 0.5, 0.5, 0.5
hh_result = await db.execute(
select(Household).where(Household.commune_id == commune.id)
)
all_households = hh_result.scalars().all()
hh_data = [
HouseholdData(volume_m3=h.volume_m3, status=h.status, price_paid_eur=h.price_paid_eur)
for h in all_households
]
ref_p0 = compute_p0(
hh_data, hh_data,
recettes=params.recettes, abop=params.abop, abos=params.abos, recettes=params.recettes, abop=params.abop, abos=params.abos,
vinf=prof["vinf"], vmax=params.vmax, pmax=params.pmax, vinf=ref_vinf, vmax=params.vmax, pmax=params.pmax,
a=prof["a"], b=prof["b"], c=prof["c"], d=prof["d"], e=prof["e"], a=ref_a, b=ref_b, c=ref_c, d=ref_d, e=ref_e,
) )
vote = Vote( params.published_vinf = ref_vinf
commune_id=commune.id, params.published_a = ref_a
household_id=hh_pick.id, params.published_b = ref_b
vinf=prof["vinf"], params.published_c = ref_c
a=prof["a"], b=prof["b"], c=prof["c"], d=prof["d"], e=prof["e"], params.published_d = ref_d
computed_p0=vp0, params.published_e = ref_e
) params.published_p0 = ref_p0
db.add(vote) params.published_at = datetime.utcnow()
hh_pick.has_voted = True
vote_count += 1
await db.commit() # ── Generate 10 votes, small variations around the reference ──
print(f"Seeded: commune 'saou', {nb_hab} households, {vote_count} votes") random.seed(42)
print(f" Published curve: vinf={ref_vinf}, p0={ref_p0:.3f}")
print(f" Super admin: superadmin@sejeteralo.fr / superadmin") vote_profiles = [
print(f" Commune admin Saou: saou@sejeteralo.fr / saou2024") # 5 votes slightly below reference (eco-leaning)
{"vinf": 350, "a": 0.45, "b": 0.48, "c": 0.40, "d": 0.52, "e": 0.55},
{"vinf": 370, "a": 0.42, "b": 0.50, "c": 0.45, "d": 0.48, "e": 0.52},
{"vinf": 380, "a": 0.48, "b": 0.45, "c": 0.42, "d": 0.50, "e": 0.58},
{"vinf": 360, "a": 0.50, "b": 0.52, "c": 0.38, "d": 0.55, "e": 0.50},
{"vinf": 390, "a": 0.47, "b": 0.47, "c": 0.48, "d": 0.46, "e": 0.53},
# 5 votes slightly above reference (lax-leaning)
{"vinf": 420, "a": 0.52, "b": 0.50, "c": 0.55, "d": 0.48, "e": 0.45},
{"vinf": 440, "a": 0.55, "b": 0.53, "c": 0.52, "d": 0.50, "e": 0.42},
{"vinf": 430, "a": 0.50, "b": 0.55, "c": 0.58, "d": 0.45, "e": 0.48},
{"vinf": 410, "a": 0.53, "b": 0.48, "c": 0.50, "d": 0.52, "e": 0.47},
{"vinf": 450, "a": 0.48, "b": 0.52, "c": 0.60, "d": 0.42, "e": 0.40},
]
used_households = set()
vote_count = 0
for prof in vote_profiles:
hh_pick = random.choice(all_households)
while hh_pick.id in used_households:
hh_pick = random.choice(all_households)
used_households.add(hh_pick.id)
vp0 = compute_p0(
hh_data,
recettes=params.recettes, abop=params.abop, abos=params.abos,
vinf=prof["vinf"], vmax=params.vmax, pmax=params.pmax,
a=prof["a"], b=prof["b"], c=prof["c"], d=prof["d"], e=prof["e"],
)
db.add(Vote(
commune_id=commune.id,
household_id=hh_pick.id,
vinf=prof["vinf"],
a=prof["a"], b=prof["b"], c=prof["c"], d=prof["d"], e=prof["e"],
computed_p0=vp0,
))
hh_pick.has_voted = True
vote_count += 1
await db.commit()
print(f"Seeded: commune 'saou', {nb_hab} households, {vote_count} votes")
print(f" Published curve: vinf={ref_vinf}, p0={ref_p0:.3f}")
print(f" Super admin: superadmin@sejeteralo.fr / superadmin")
print(f" Commune admin Saou: saou@sejeteralo.fr / saou2024")
else:
print("Saoû already seeded.")
# ── Dev fixtures (idempotent — insérés à chaque run si absents) ────────
fixture_added = 0
for fixture in DEV_FIXTURES:
res = await db.execute(
select(Household).where(Household.auth_code == fixture["auth_code"])
)
if res.scalar_one_or_none() is None:
db.add(Household(commune_id=commune.id, **fixture))
fixture_added += 1
if fixture_added:
await db.commit()
print(f" Dev fixtures: {fixture_added} ajoutés — DEVTEST2 (RS 60m³) · DEVTEST3 (RP 120m³) · DEVTEST4 (PRO 350m³)")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -11,6 +11,7 @@ COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ . COPY backend/ .
COPY Eau2018.xls /opt/Eau2018.xls
# Production # Production
FROM base AS production FROM base AS production
@@ -21,6 +22,7 @@ COPY --from=build /usr/local/lib/python3.11/site-packages /usr/local/lib/python3
COPY --from=build /usr/local/bin/uvicorn /usr/local/bin/uvicorn COPY --from=build /usr/local/bin/uvicorn /usr/local/bin/uvicorn
COPY --from=build /usr/local/bin/alembic /usr/local/bin/alembic COPY --from=build /usr/local/bin/alembic /usr/local/bin/alembic
COPY --from=build /app /app COPY --from=build /app /app
COPY --from=build /opt/Eau2018.xls /opt/Eau2018.xls
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/docs')" || exit 1 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/docs')" || exit 1

View File

@@ -1,43 +1,51 @@
name: sejeteralo name: ${COMPOSE_PROJECT_NAME:-syoul-sejeteralo-main}
services: services:
backend: backend:
build: build:
context: ../ context: ..
dockerfile: docker/backend.Dockerfile dockerfile: docker/backend.Dockerfile
target: production target: production
image: sejeteralo-backend:latest
container_name: ${COMPOSE_PROJECT_NAME:-syoul-sejeteralo-main}-backend
restart: always
environment: environment:
DATABASE_URL: sqlite+aiosqlite:///./sejeteralo.db DATABASE_URL: sqlite+aiosqlite:///./sejeteralo.db
SECRET_KEY: ${SECRET_KEY} SECRET_KEY: ${SECRET_KEY}
DEBUG: "false" DEBUG: "false"
CORS_ORIGINS: '["https://${DOMAIN:-sejeteralo.org}"]' CORS_ORIGINS: '["https://${APP_DOMAIN:-sejeteralo.fr}"]'
volumes: volumes:
- backend-data:/app - backend-data:/app
restart: always
labels: labels:
- "traefik.enable=true" - SERVICE_8000_NAME=${COMPOSE_PROJECT_NAME:-syoul-sejeteralo-main}-backend-8000
- "traefik.http.routers.sejeteralo-api.rule=Host(`${DOMAIN:-sejeteralo.org}`) && PathPrefix(`/api`)" - SERVICE_8000_TAGS=urlprefix-${APP_DOMAIN:-sejeteralo.fr}/api/*
- "traefik.http.routers.sejeteralo-api.entrypoints=websecure" - SERVICE_8000_CHECK_TCP=true
- "traefik.http.routers.sejeteralo-api.tls.certresolver=letsencrypt" networks:
- "traefik.http.services.sejeteralo-api.loadbalancer.server.port=8000" - sonic
frontend: frontend:
build: build:
context: ../ context: ..
dockerfile: docker/frontend.Dockerfile dockerfile: docker/frontend.Dockerfile
target: production target: production
image: sejeteralo-frontend:latest
container_name: ${COMPOSE_PROJECT_NAME:-syoul-sejeteralo-main}-frontend
restart: always
environment: environment:
NODE_ENV: production NODE_ENV: production
NUXT_PUBLIC_API_BASE: http://backend:8000/api/v1 NUXT_PUBLIC_API_BASE: https://${APP_DOMAIN:-sejeteralo.fr}/api/v1
depends_on: depends_on:
- backend - backend
restart: always
labels: labels:
- "traefik.enable=true" - SERVICE_3000_NAME=${COMPOSE_PROJECT_NAME:-syoul-sejeteralo-main}-frontend-3000
- "traefik.http.routers.sejeteralo.rule=Host(`${DOMAIN:-sejeteralo.org}`)" - SERVICE_3000_TAGS=urlprefix-${APP_DOMAIN:-sejeteralo.fr}/*
- "traefik.http.routers.sejeteralo.entrypoints=websecure" - SERVICE_3000_CHECK_TCP=true
- "traefik.http.routers.sejeteralo.tls.certresolver=letsencrypt" networks:
- "traefik.http.services.sejeteralo.loadbalancer.server.port=3000" - sonic
volumes: volumes:
backend-data: backend-data:
networks:
sonic:
external: true

View File

@@ -10,7 +10,7 @@ WORKDIR /src
FROM base AS build FROM base AS build
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci RUN npm ci --legacy-peer-deps
COPY frontend/ . COPY frontend/ .
RUN npm run build RUN npm run build

195
docs/equations.html Normal file
View File

@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>SejeteralO — Modèle mathématique</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"
onload="renderMathInElement(document.body, {delimiters:[
{left:'$$',right:'$$',display:true},
{left:'$',right:'$',display:false}
]})"></script>
<style>
body { font-family: 'Georgia', serif; max-width: 860px; margin: 3rem auto; padding: 0 2rem; color: #1a1a2e; background: #fafaf8; line-height: 1.7; }
h1 { font-size: 1.6rem; border-bottom: 2px solid #3a5a8a; padding-bottom: .5rem; color: #1a2e5a; }
h2 { font-size: 1.2rem; color: #2a4a7a; margin-top: 2.5rem; border-left: 4px solid #7aacdc; padding-left: .8rem; }
h3 { font-size: 1rem; color: #3a5a8a; margin-top: 1.8rem; }
.block { background: #f0f4fb; border-radius: 10px; padding: 1.2rem 1.5rem; margin: 1rem 0; }
.cp-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .3rem 2rem; font-size: .92rem; }
table { border-collapse: collapse; width: 100%; font-size: .92rem; margin: 1rem 0; }
th { background: #3a5a8a; color: #fff; padding: .4rem .8rem; text-align: left; }
td { padding: .35rem .8rem; border-bottom: 1px solid #dde; }
tr:nth-child(even) td { background: #f4f7fc; }
.note { font-size: .88rem; color: #556; font-style: italic; margin-top: .5rem; }
code { background: #e8ecf5; padding: .1rem .35rem; border-radius: 4px; font-size: .88rem; font-family: monospace; }
.sep { border: none; border-top: 1px dashed #ccd; margin: 2.5rem 0; }
</style>
</head>
<body>
<h1>SejeteralO — Modèle tarifaire Bézier</h1>
<p>Tarification participative de l'eau : les citoyens façonnent une courbe prix/volume via des points de contrôle Bézier ; le prix d'inflexion $p_0$ est calculé automatiquement pour équilibrer les recettes de la commune.</p>
<h2>1. Paramètres</h2>
<h3>Paramètres structurels (fixés par la commune)</h3>
<table>
<tr><th>Paramètre</th><th>Signification</th></tr>
<tr><td>$v_\text{inf}$</td><td>Volume d'inflexion — frontière entre les deux paliers (m³/an)</td></tr>
<tr><td>$v_\text{max}$</td><td>Volume maximum de la courbe (m³/an)</td></tr>
<tr><td>$p_\text{max}$</td><td>Prix au m³ en $v_\text{max}$ (€)</td></tr>
<tr><td>$R$</td><td>Recettes cibles totales (€/an)</td></tr>
<tr><td>$\text{abo}_P, \text{abo}_S$</td><td>Abonnements résidence principale / secondaire (€/an)</td></tr>
</table>
<h3>Paramètres de forme (votés par les citoyens, 6 valeurs dans $[0,1]$)</h3>
<table>
<tr><th>Param.</th><th>Rôle géométrique</th></tr>
<tr><td>$a$</td><td>Hauteur du 2ᵉ point de contrôle du palier 1 (courbure initiale)</td></tr>
<tr><td>$b$</td><td>Position horizontale du 3ᵉ point de contrôle du palier 1</td></tr>
<tr><td>$c$</td><td>Position horizontale du 1ᵉʳ point de contrôle du palier 2</td></tr>
<tr><td>$d$</td><td>Position horizontale du 2ᵉ point de contrôle du palier 2</td></tr>
<tr><td>$e$</td><td>Hauteur du 2ᵉ point de contrôle du palier 2</td></tr>
</table>
<h3>Variable calculée</h3>
<p>$p_0$ — prix au m³ à l'inflexion — est toujours déduit de l'équilibre de recettes (§4).</p>
<hr class="sep">
<h2>2. Courbe Bézier cubique par paliers</h2>
<p>La courbe prix/volume est définie de façon paramétrique : pour $t \in [0,1]$, on obtient un point $(v(t),\, p(t))$.</p>
<h3>Palier 1 — $v \in [0,\, v_\text{inf}]$</h3>
<div class="block">
$$v_1(t) = v_\text{inf}\,\bigl[(1-3b)\,t^3 + 3b\,t^2\bigr]$$
$$p_1(t) = p_0\,\bigl[(3a-2)\,t^3 + (3-6a)\,t^2 + 3a\,t\bigr]$$
</div>
<p>Points de contrôle :</p>
<div class="cp-grid">
<span>$P_1 = (0,\; 0)$</span>
<span>$P_2 = (0,\; a\,p_0)$</span>
<span>$P_3 = (b\,v_\text{inf},\; p_0)$</span>
<span>$P_4 = (v_\text{inf},\; p_0)$</span>
</div>
<h3>Palier 2 — $v \in [v_\text{inf},\, v_\text{max}]$</h3>
<p>Posons $w_\text{max} = v_\text{max} - v_\text{inf}$.</p>
<div class="block">
$$v_2(t) = v_\text{inf} + w_\text{max}\,\Bigl[\bigl(3(c+d-cd)-2\bigr)\,t^3 + 3(1-2c-d+cd)\,t^2 + 3c\,t\Bigr]$$
$$p_2(t) = p_0 + (p_\text{max}-p_0)\,\bigl[(1-3e)\,t^3 + 3e\,t^2\bigr]$$
</div>
<p>Points de contrôle (partagent $P_4$ avec le palier 1) :</p>
<div class="cp-grid">
<span>$P_4 = (v_\text{inf},\; p_0)$</span>
<span>$P_5 = (v_\text{inf}+c\,w_\text{max},\; p_0)$</span>
<span>$P_6 = \bigl(v_\text{inf}+w_\text{max}(1-d+cd),\; p_0+e(p_\text{max}-p_0)\bigr)$</span>
<span>$P_7 = (v_\text{max},\; p_\text{max})$</span>
</div>
<hr class="sep">
<h2>3. Facture d'un foyer</h2>
<p>La facture annuelle d'un foyer de consommation $v$ est :</p>
<div class="block">
$$\text{Bill}(v) = \text{abo} + \int_0^v p(u)\,\mathrm{d}u$$
</div>
<p>L'intégrale est calculée analytiquement par décomposition :</p>
<div class="block">
$$\int_0^v p(u)\,\mathrm{d}u = (\alpha_1 + \alpha_2)\,p_0 + \beta_2$$
</div>
<p>où les trois coefficients dépendent uniquement de la forme de la courbe (paramètres $a$$e$, $v_\text{inf}$, $v_\text{max}$, $p_\text{max}$) et de la consommation $v$, mais <em>pas</em> de $p_0$. Cela rend le calcul de $p_0$ linéaire.</p>
<h3>Résolution cubique — inversion de la courbe</h3>
<p>Pour évaluer les coefficients en un volume $v$ donné, on cherche $T$ tel que $v_i(T) = v$.</p>
<p>Palier 1 ($v \leq v_\text{inf}$) — résoudre en $T_1 \in [0,1]$ :</p>
<div class="block">
$$(1-3b)\,T_1^3 + 3b\,T_1^2 - \frac{v}{v_\text{inf}} = 0$$
</div>
<p>Palier 2 ($v > v_\text{inf}$) — posons $w = v - v_\text{inf}$, résoudre en $T_2 \in [0,1]$ :</p>
<div class="block">
$$\bigl(3(c+d-cd)-2\bigr)\,T_2^3 + 3(1-2c-d+cd)\,T_2^2 + 3c\,T_2 - \frac{w}{w_\text{max}} = 0$$
</div>
<p class="note">Ces cubiques sont résolues par la formule de Cardano avec affinement Newton-Raphson.</p>
<h3>Coefficient $\alpha_1$ — intégrale du palier 1</h3>
<div class="block">
$$\alpha_1(T_1) = 3\,v_\text{inf}\left[
\frac{-9ab+3a+6b-2}{6}\,T_1^6
+\frac{24ab-6a-13b+3}{5}\,T_1^5
+\frac{3(-7ab+a+2b)}{4}\,T_1^4
+2ab\,T_1^3
\right]$$
</div>
<p class="note">Si $v \leq v_\text{inf}$ : on utilise le $T_1$ résolu ci-dessus. Si $v > v_\text{inf}$ : $\alpha_1 = \alpha_1(1)$ (palier 1 entier).</p>
<h3>Coefficients $\alpha_2$ et $\beta_2$ — intégrale du palier 2</h3>
<p>Posons l'auxiliaire :</p>
<div class="block">
$$u(T_2) =
\frac{-3cd+9ecd+3c-9ec+3d-9ed+6e-2}{6}\,T_2^6 \\[4pt]
+\frac{2cd-15ecd-4c+21ec-2d+15ed-12e+2}{5}\,T_2^5 \\[4pt]
+\frac{6ecd+c-15ec-6ed+6e}{4}\,T_2^4
+ec\,T_2^3$$
</div>
<p>Alors :</p>
<div class="block">
$$\alpha_2 = (v - v_\text{inf}) - 3\,u(T_2)\,w_\text{max}$$
$$\beta_2 = 3\,p_\text{max}\,w_\text{max}\,u(T_2)$$
</div>
<p class="note">Si $v \leq v_\text{inf}$ : $\alpha_2 = \beta_2 = 0$.</p>
<hr class="sep">
<h2>4. Équilibre de recettes — calcul de $p_0$</h2>
<p>En substituant la décomposition $(\alpha_1+\alpha_2)\,p_0+\beta_2$ dans la somme des factures :</p>
<div class="block">
$$R = \sum_{i} \text{abo}_i + \sum_{i}\bigl[(\alpha_{1,i}+\alpha_{2,i})\,p_0 + \beta_{2,i}\bigr]$$
</div>
<p>Comme les $\alpha$ et $\beta$ ne dépendent pas de $p_0$, on obtient directement :</p>
<div class="block">
$$\boxed{p_0 = \frac{R - \displaystyle\sum_i \text{abo}_i - \displaystyle\sum_i \beta_{2,i}}{\displaystyle\sum_i (\alpha_{1,i} + \alpha_{2,i})}}$$
</div>
<p class="note">$p_0$ est calculé une fois par évaluation de la courbe. Si $\sum\alpha = 0$ (tous les foyers consomment 0), on pose $p_0 = 0$.</p>
<hr class="sep">
<h2>5. Résumé du pipeline de calcul</h2>
<ol>
<li>Les citoyens placent des points de contrôle → paramètres $(a,b,c,d,e,v_\text{inf})$.</li>
<li>Pour chaque foyer $i$, calculer $\alpha_{1,i}$, $\alpha_{2,i}$, $\beta_{2,i}$ par inversion cubique.</li>
<li>Calculer $p_0$ par la formule d'équilibre.</li>
<li>Facture de chaque foyer : $\text{Bill}_i = \text{abo}_i + (\alpha_{1,i}+\alpha_{2,i})\,p_0 + \beta_{2,i}$.</li>
<li>Vérification : $\sum_i \text{Bill}_i = R$ (à précision numérique).</li>
</ol>
<p class="note">Sources : <code>backend/app/engine/pricing.py</code> · <code>backend/app/engine/integrals.py</code> · <code>frontend/app/utils/bezier-math.ts</code></p>
</body>
</html>

View File

@@ -269,7 +269,7 @@
<button type="submit" class="btn btn-primary" :disabled="authLoading" style="padding: 0.35rem 0.75rem;">OK</button> <button type="submit" class="btn btn-primary" :disabled="authLoading" style="padding: 0.35rem 0.75rem;">OK</button>
</form> </form>
<p v-if="isDev" :style="{ marginTop: '0.4rem', padding: '0.3rem 0.5rem', background: t.devBg, border: '1px solid ' + t.devBorder, borderRadius: '5px', fontSize: '0.7rem', color: isDark ? '#fcd34d' : 'inherit' }"> <p v-if="isDev" :style="{ marginTop: '0.4rem', padding: '0.3rem 0.5rem', background: t.devBg, border: '1px solid ' + t.devBorder, borderRadius: '5px', fontSize: '0.7rem', color: isDark ? '#fcd34d' : 'inherit' }">
<strong>Dev:</strong> QPF5L9ZK (60) <strong>Dev:</strong> DEVTEST2 (RS 60) · DEVTEST3 (RP 120) · DEVTEST4 (PRO 350)
</p> </p>
</div> </div>
<div class="sidebar-auth-block" style="opacity: 0.5; border-top: 1px solid var(--color-border); padding-top: 0.75rem; margin-top: 0.75rem;"> <div class="sidebar-auth-block" style="opacity: 0.5; border-top: 1px solid var(--color-border); padding-top: 0.75rem; margin-top: 0.75rem;">

View File

@@ -1,17 +1,21 @@
{ {
"name": "frontend", "name": "sejeteralo-frontend",
"version": "1.0.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "sejeteralo-frontend",
"version": "1.0.0", "version": "0.1.0",
"license": "ISC",
"dependencies": { "dependencies": {
"nuxt": "^4.3.1", "nuxt": "^4.3.1",
"vue": "^3.5.28", "vue": "^3.5.28",
"vue-router": "^5.0.3" "vue-router": "^5.0.3"
},
"devDependencies": {
"@pinia/nuxt": "^0.9.0",
"pinia": "^3.0.2",
"typescript": "^5.8.2"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -2805,6 +2809,55 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/@pinia/nuxt": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@pinia/nuxt/-/nuxt-0.9.0.tgz",
"integrity": "sha512-2yeRo7LeyCF68AbNeL3xu2h6uw0617RkcsYxmA8DJM0R0PMdz5wQHnc44KeENQxR/Mrq8T910XVT6buosqsjBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nuxt/kit": "^3.9.0"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"pinia": "^2.3.0"
}
},
"node_modules/@pinia/nuxt/node_modules/@nuxt/kit": {
"version": "3.21.2",
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-3.21.2.tgz",
"integrity": "sha512-Bd6m6mrDrqpBEbX+g0rc66/ALd1sxlgdx5nfK9MAYO0yKLTOSK7McSYz1KcOYn3LQFCXOWfvXwaqih/b+REI1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"c12": "^3.3.3",
"consola": "^3.4.2",
"defu": "^6.1.4",
"destr": "^2.0.5",
"errx": "^0.1.0",
"exsolve": "^1.0.8",
"ignore": "^7.0.5",
"jiti": "^2.6.1",
"klona": "^2.0.6",
"knitwork": "^1.3.0",
"mlly": "^1.8.1",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"pkg-types": "^2.3.0",
"rc9": "^3.0.0",
"scule": "^1.3.0",
"semver": "^7.7.4",
"tinyglobby": "^0.2.15",
"ufo": "^1.6.3",
"unctx": "^2.5.0",
"untyped": "^2.0.0"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -6462,15 +6515,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/mlly": { "node_modules/mlly": {
"version": "1.8.0", "version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
"integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"acorn": "^8.15.0", "acorn": "^8.16.0",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"pkg-types": "^1.3.1", "pkg-types": "^1.3.1",
"ufo": "^1.6.1" "ufo": "^1.6.3"
} }
}, },
"node_modules/mlly/node_modules/confbox": { "node_modules/mlly/node_modules/confbox": {
@@ -7288,6 +7341,81 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pinia": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.5.0",
"vue": "^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/@vue/devtools-api": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/pinia/node_modules/@vue/devtools-kit": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.9",
"birpc": "^2.3.0",
"hookable": "^5.5.3",
"mitt": "^3.0.1",
"perfect-debounce": "^1.0.0",
"speakingurl": "^14.0.1",
"superjson": "^2.2.2"
}
},
"node_modules/pinia/node_modules/@vue/devtools-shared": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
"dev": true,
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
}
},
"node_modules/pinia/node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/pinia/node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"dev": true,
"license": "MIT"
},
"node_modules/pkg-types": { "node_modules/pkg-types": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
@@ -8835,6 +8963,20 @@
"integrity": "sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==", "integrity": "sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/ufo": { "node_modules/ufo": {
"version": "1.6.3", "version": "1.6.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",