forked from EHV/sejeteralo
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b24f226b35 | ||
|
|
65c148142c | ||
|
|
92fb60c114 | ||
|
|
532cc1a0e3 | ||
|
|
c1a9548bd7 | ||
|
|
8341a050d3 | ||
|
|
90b069cb88 | ||
|
|
19ac64c856 | ||
|
|
017806025c | ||
|
|
c9bb437695 | ||
|
|
f314998ca5 | ||
|
|
e05d081cac | ||
|
|
4951e20099 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -56,9 +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
|
||||||
|
|
||||||
# User Syl
|
|
||||||
/docs-syoul
|
|
||||||
227
.woodpecker.yml
227
.woodpecker.yml
@@ -1,22 +1,217 @@
|
|||||||
when:
|
when:
|
||||||
branch: main
|
- branch: main
|
||||||
event: push
|
event: push
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: build
|
|
||||||
image: docker:dind
|
- name: security-check
|
||||||
environment:
|
image: alpine:3.20
|
||||||
COMPOSE_PROJECT_NAME: ${CI_REPO_OWNER,,}-${CI_REPO_NAME,,}-${CI_COMMIT_BRANCH//\//-}
|
|
||||||
DOMAIN: sejeteralo.org
|
|
||||||
LETSENCRYPT_HOST: sejeteralo.org
|
|
||||||
SERVICE_8000_TAGS: urlprefix-sejeteralo.org:443/api
|
|
||||||
SERVICE_3000_TAGS: urlprefix-sejeteralo.org:443/*
|
|
||||||
SECRET_KEY:
|
|
||||||
from_secret: SECRET_KEY
|
|
||||||
commands:
|
commands:
|
||||||
- docker compose -f docker/docker-compose.yml -f docker/docker-compose.fabio.yml up --build -d
|
- |
|
||||||
|
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:
|
volumes:
|
||||||
- ${DOCKER_SOCKET_LOCATION:-/var/run/docker.sock}:/var/run/docker.sock
|
- /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
|
||||||
|
commands:
|
||||||
|
- pip install -r backend/requirements.txt
|
||||||
|
- 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)"
|
||||||
|
|
||||||
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
|
- name: build-backend
|
||||||
|
image: docker:27-cli
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- docker build -t sejeteralo-backend:latest -f docker/backend.Dockerfile --target production .
|
||||||
|
- echo "Image backend construite"
|
||||||
|
|
||||||
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
|
- name: build-frontend
|
||||||
|
image: docker:27-cli
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- docker build -t sejeteralo-frontend:latest -f docker/frontend.Dockerfile --target production .
|
||||||
|
- echo "Image frontend construite"
|
||||||
|
|
||||||
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
|
- name: sbom-generate
|
||||||
|
image: alpine:3.20
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache curl
|
||||||
|
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
|
||||||
|
- mkdir -p .reports
|
||||||
|
- syft sejeteralo-backend:latest -o cyclonedx-json --file .reports/sbom-backend.cyclonedx.json
|
||||||
|
- 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:
|
||||||
- branch: main
|
- status: failure
|
||||||
event: push
|
|
||||||
|
|||||||
166
CLAUDE.md
166
CLAUDE.md
@@ -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
BIN
Eau2018.xls
Normal file
Binary file not shown.
16
Makefile
16
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: install dev dev-backend dev-frontend test seed docker-up docker-down docker-dev docker-fabio docker-fabio-down consul-services fabio-routes
|
.PHONY: install dev dev-backend dev-frontend test seed docker-up docker-down docker-dev
|
||||||
|
|
||||||
# ── Development (local) ──
|
# ── Development (local) ──
|
||||||
|
|
||||||
@@ -32,17 +32,3 @@ docker-down:
|
|||||||
|
|
||||||
docker-dev:
|
docker-dev:
|
||||||
docker compose -f docker/docker-compose.yml -f docker/docker-compose.dev.yml up --build
|
docker compose -f docker/docker-compose.yml -f docker/docker-compose.dev.yml up --build
|
||||||
|
|
||||||
# ── Docker (Fabio/Consul) ──
|
|
||||||
|
|
||||||
docker-fabio:
|
|
||||||
docker compose -f docker/docker-compose.yml -f docker/docker-compose.fabio.yml up --build -d
|
|
||||||
|
|
||||||
docker-fabio-down:
|
|
||||||
docker compose -f docker/docker-compose.yml -f docker/docker-compose.fabio.yml down
|
|
||||||
|
|
||||||
consul-services:
|
|
||||||
@curl -s http://localhost:8500/v1/catalog/services | python3 -m json.tool
|
|
||||||
|
|
||||||
fabio-routes:
|
|
||||||
@curl -s http://localhost:9998/routes
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
302
backend/seed.py
302
backend/seed.py
@@ -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__":
|
||||||
|
|||||||
@@ -11,22 +11,21 @@ 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
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY --from=build /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
COPY --from=build /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
||||||
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 curl -f http://localhost:8000/api/health || exit 1
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/docs')" || exit 1
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ services:
|
|||||||
ports: !override
|
ports: !override
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- backend-sources:/app
|
- ../backend:/app
|
||||||
|
labels: []
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@@ -21,18 +22,5 @@ services:
|
|||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
- "24678:24678"
|
- "24678:24678"
|
||||||
volumes:
|
volumes:
|
||||||
- frontend-sources:/app
|
- ../frontend:/app
|
||||||
|
labels: []
|
||||||
volumes:
|
|
||||||
backend-sources:
|
|
||||||
driver: local
|
|
||||||
driver_opts:
|
|
||||||
type: none
|
|
||||||
o: bind
|
|
||||||
device: ../backend
|
|
||||||
frontend-sources:
|
|
||||||
driver: local
|
|
||||||
driver_opts:
|
|
||||||
type: none
|
|
||||||
o: bind
|
|
||||||
device: ../frontend
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
# Overlay Fabio — combine avec : docker compose -f docker-compose.yml -f docker-compose.fabio.yml
|
|
||||||
|
|
||||||
services:
|
|
||||||
backend:
|
|
||||||
labels:
|
|
||||||
- SERVICE_8000_CHECK_HTTP=${SERVICE_8000_CHECK_HTTP:-/api/health}
|
|
||||||
- SERVICE_8000_NAME=${SERVICE_8000_NAME:-${COMPOSE_PROJECT_NAME:-sejeteralo}-backend-8000}
|
|
||||||
- SERVICE_8000_TAGS=${SERVICE_8000_TAGS:-urlprefix-sejeteralo.org:443/api}
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
labels:
|
|
||||||
- SERVICE_3000_CHECK_HTTP=${SERVICE_3000_CHECK_HTTP:-/}
|
|
||||||
- SERVICE_3000_NAME=${SERVICE_3000_NAME:-${COMPOSE_PROJECT_NAME:-sejeteralo}-frontend-3000}
|
|
||||||
- SERVICE_3000_TAGS=${SERVICE_3000_TAGS:-urlprefix-sejeteralo.org:443/*}
|
|
||||||
@@ -1,47 +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}"]'
|
||||||
ports:
|
|
||||||
- 8000
|
|
||||||
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
|
||||||
ports:
|
|
||||||
- 3000
|
|
||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -21,9 +21,6 @@ FROM base AS production
|
|||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY --from=build /src/.output /src/.output
|
COPY --from=build /src/.output /src/.output
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
|||||||
195
docs/equations.html
Normal file
195
docs/equations.html
Normal 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>
|
||||||
@@ -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 (60m³)
|
<strong>Dev:</strong> DEVTEST2 (RS 60m³) · DEVTEST3 (RP 120m³) · DEVTEST4 (PRO 350m³)
|
||||||
</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;">
|
||||||
|
|||||||
162
frontend/package-lock.json
generated
162
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user