Initial commit: SejeteralO water tarification platform

Full-stack app for participatory water pricing using Bezier curves.
- Backend: FastAPI + SQLAlchemy + SQLite with JWT auth
- Frontend: Nuxt 4 + TypeScript with interactive SVG editor
- Math engine: cubic Bezier tarification with Cardano solver
- Admin: commune management, household import, vote monitoring, CMS
- Citizen: interactive curve editor, vote submission
- Docker-compose deployment ready

Includes fixes for:
- Impact table snake_case/camelCase property mismatch
- CMS content backend API + frontend editor (was stub)
- Admin route protection middleware
- Public content display on commune page
- Vote confirmation page link fix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-21 15:26:02 +01:00
commit b30e54a8f7
67 changed files with 16723 additions and 0 deletions

View File

View File

@@ -0,0 +1,280 @@
"""
Tests for the extracted math engine.
Validates that the engine produces identical results to the original eau.py
using the Saoû data (Eau2018.xls).
"""
import sys
import os
import numpy as np
import pytest
import xlrd
# Add backend to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from app.engine.integrals import compute_integrals
from app.engine.pricing import HouseholdData, compute_p0, compute_tariff
from app.engine.current_model import compute_linear_tariff
from app.engine.median import VoteParams, compute_median
# Path to the Excel file
DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "data")
XLS_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "Eau2018.xls")
def load_saou_households() -> list[HouseholdData]:
"""Load household data from Eau2018.xls exactly as eau.py does."""
book = xlrd.open_workbook(XLS_PATH)
sheet = book.sheet_by_name("CALCULS")
nb_hab = 363
households = []
for r in range(1, nb_hab + 1):
vol = sheet.cell_value(r, 4)
status = sheet.cell_value(r, 3)
prix = sheet.cell_value(r, 33)
households.append(HouseholdData(
volume_m3=vol,
status=status,
price_paid_eur=prix,
))
return households
# Reference original eau.py computeIntegrals for comparison
def original_compute_integrals(vv, vinf, vmax, pmax, a, b, c, d, e):
"""Direct port of eau.py computeIntegrals for validation."""
if vv <= vinf:
if vv == 0:
T = 0.0
elif vv == vinf:
T = 1.0
else:
p = [1 - 3 * b, 3 * b, 0, -vv / vinf]
roots = np.roots(p)
roots = np.unique(roots)
roots2 = np.real(roots[np.isreal(roots)])
mask = (roots2 <= 1.0) & (roots2 >= 0.0)
T = float(roots2[mask])
alpha1 = 3 * vinf * (
T**6 / 6 * (-9 * a * b + 3 * a + 6 * b - 2)
+ T**5 / 5 * (24 * a * b - 6 * a - 13 * b + 3)
+ 3 * T**4 / 4 * (-7 * a * b + a + 2 * b)
+ T**3 / 3 * 6 * a * b
)
return alpha1, 0, 0
else:
alpha1 = 3 * vinf * (
1 / 6 * (-9 * a * b + 3 * a + 6 * b - 2)
+ 1 / 5 * (24 * a * b - 6 * a - 13 * b + 3)
+ 3 / 4 * (-7 * a * b + a + 2 * b)
+ 1 / 3 * 6 * a * b
)
wmax = vmax - vinf
if vv == vinf:
T = 0.0
elif vv == vmax:
T = 1.0
else:
p = [3 * (c + d - c * d) - 2, 3 * (1 - 2 * c - d + c * d), 3 * c, -(vv - vinf) / wmax]
roots = np.roots(p)
roots = np.unique(roots)
roots2 = np.real(roots[np.isreal(roots)])
mask = (roots2 <= 1.0) & (roots2 >= 0.0)
T = float(np.real(roots2[mask]))
uu = (
(-3 * c * d + 9 * e * c * d + 3 * c - 9 * e * c + 3 * d - 9 * e * d + 6 * e - 2) * T**6 / 6
+ (2 * c * d - 15 * e * c * d - 4 * c + 21 * e * c - 2 * d + 15 * e * d - 12 * e + 2) * T**5 / 5
+ (6 * e * c * d + c - 15 * e * c - 6 * e * d + 6 * e) * T**4 / 4
+ (3 * e * c) * T**3 / 3
)
alpha2 = vv - vinf - 3 * uu * wmax
beta2 = 3 * pmax * wmax * uu
return alpha1, alpha2, beta2
class TestIntegrals:
"""Test the integral computation against the original."""
def test_tier1_zero_volume(self):
a1, a2, b2 = compute_integrals(0, 1050, 2100, 20, 0.5, 0.5, 0.5, 0.5, 0.5)
assert a1 == 0.0
assert a2 == 0.0
assert b2 == 0.0
def test_tier1_at_vinf(self):
a1, a2, b2 = compute_integrals(1050, 1050, 2100, 20, 0.5, 0.5, 0.5, 0.5, 0.5)
oa1, oa2, ob2 = original_compute_integrals(1050, 1050, 2100, 20, 0.5, 0.5, 0.5, 0.5, 0.5)
assert abs(a1 - oa1) < 1e-10
assert a2 == 0.0
assert b2 == 0.0
def test_tier2_at_vmax(self):
a1, a2, b2 = compute_integrals(2100, 1050, 2100, 20, 0.5, 0.5, 0.5, 0.5, 0.5)
oa1, oa2, ob2 = original_compute_integrals(2100, 1050, 2100, 20, 0.5, 0.5, 0.5, 0.5, 0.5)
assert abs(a1 - oa1) < 1e-10
assert abs(a2 - oa2) < 1e-6
assert abs(b2 - ob2) < 1e-6
def test_various_volumes_match_original(self):
"""Test multiple volumes with various parameter sets."""
params_sets = [
(0.5, 0.5, 0.5, 0.5, 0.5),
(0.25, 0.75, 0.3, 0.6, 0.8),
(0.1, 0.1, 0.9, 0.9, 0.1),
(0.9, 0.9, 0.1, 0.1, 0.9),
]
volumes = [0, 10, 50, 100, 300, 500, 1000, 1050, 1051, 1500, 2000, 2100]
for a, b, c, d, e in params_sets:
for vol in volumes:
vinf, vmax, pmax = 1050, 2100, 20
result = compute_integrals(vol, vinf, vmax, pmax, a, b, c, d, e)
expected = original_compute_integrals(vol, vinf, vmax, pmax, a, b, c, d, e)
for i in range(3):
assert abs(result[i] - expected[i]) < 1e-6, (
f"Mismatch at vol={vol}, params=({a},{b},{c},{d},{e}), "
f"component={i}: got {result[i]}, expected {expected[i]}"
)
class TestPricing:
"""Test the pricing computation with Saoû data."""
@pytest.fixture
def saou_households(self):
return load_saou_households()
def test_saou_data_loaded(self, saou_households):
assert len(saou_households) == 363
def test_p0_default_params(self, saou_households):
"""Test p0 with default slider values from eau.py mainFunction."""
# Default values from eau.py lines 54-62
p0 = compute_p0(
saou_households,
recettes=75000, # recettesArray[25]
abop=100, # abopArray[100]
abos=100, # abosArray[100]
vinf=1050, # vinfArray[vmax/2]
vmax=2100,
pmax=20,
a=0.5, # aArray[25]
b=0.5, # bArray[25]
c=0.5, # cArray[25]
d=0.5, # dArray[25]
e=0.5, # eArray[25]
)
# Compute the same p0 using original algorithm
volumes = np.array([max(h.volume_m3, 1e-5) for h in saou_households])
statuses = np.array([h.status for h in saou_households])
abo = 100 * np.ones(363)
abo[statuses == "RS"] = 100
alpha1_arr = np.zeros(363)
alpha2_arr = np.zeros(363)
beta2_arr = np.zeros(363)
for ih in range(363):
alpha1_arr[ih], alpha2_arr[ih], beta2_arr[ih] = original_compute_integrals(
volumes[ih], 1050, 2100, 20, 0.5, 0.5, 0.5, 0.5, 0.5
)
expected_p0 = (75000 - np.sum(beta2_arr + abo)) / np.sum(alpha1_arr + alpha2_arr)
assert abs(p0 - expected_p0) < 1e-6, f"p0={p0}, expected={expected_p0}"
assert p0 > 0, "p0 should be positive"
def test_p0_various_params(self, saou_households):
"""Test p0 with various parameter sets."""
param_sets = [
(75000, 100, 100, 1050, 0.5, 0.5, 0.5, 0.5, 0.5),
(60000, 80, 80, 800, 0.3, 0.7, 0.4, 0.6, 0.2),
(90000, 120, 90, 1200, 0.8, 0.2, 0.6, 0.3, 0.7),
]
for recettes, abop, abos, vinf, a, b, c, d, e in param_sets:
p0 = compute_p0(
saou_households, recettes, abop, abos, vinf, 2100, 20, a, b, c, d, e
)
# Verify: total bills should equal recettes
total = 0
for h in saou_households:
vol = max(h.volume_m3, 1e-5)
abo_val = abos if h.status == "RS" else abop
a1, a2, b2 = compute_integrals(vol, vinf, 2100, 20, a, b, c, d, e)
total += abo_val + (a1 + a2) * p0 + b2
assert abs(total - recettes) < 0.01, (
f"Revenue mismatch: got {total:.2f}, expected {recettes}. "
f"Params: recettes={recettes}, abop={abop}, abos={abos}, vinf={vinf}, "
f"a={a}, b={b}, c={c}, d={d}, e={e}"
)
def test_full_tariff_computation(self, saou_households):
result = compute_tariff(
saou_households,
recettes=75000,
abop=100,
abos=100,
vinf=1050,
vmax=2100,
pmax=20,
a=0.5,
b=0.5,
c=0.5,
d=0.5,
e=0.5,
)
assert result.p0 > 0
assert len(result.curve_volumes) == 400 # 200 * 2 tiers
assert len(result.household_bills) == 363
class TestLinearModel:
"""Test the linear (current) pricing model."""
@pytest.fixture
def saou_households(self):
return load_saou_households()
def test_linear_p0(self, saou_households):
result = compute_linear_tariff(saou_households, recettes=75000, abop=100, abos=100)
# p0 = (recettes - sum_abo) / sum_volume
volumes = [max(h.volume_m3, 1e-5) for h in saou_households]
total_vol = sum(volumes)
expected_p0 = (75000 - 363 * 100) / total_vol # all RS have same abo in this case
assert abs(result.p0 - expected_p0) < 1e-6
class TestMedian:
"""Test the median computation."""
def test_single_vote(self):
votes = [VoteParams(vinf=1050, a=0.5, b=0.5, c=0.5, d=0.5, e=0.5)]
m = compute_median(votes)
assert m.vinf == 1050
assert m.a == 0.5
def test_odd_votes(self):
votes = [
VoteParams(vinf=800, a=0.3, b=0.2, c=0.4, d=0.5, e=0.6),
VoteParams(vinf=1000, a=0.5, b=0.5, c=0.5, d=0.5, e=0.5),
VoteParams(vinf=1200, a=0.7, b=0.8, c=0.6, d=0.5, e=0.4),
]
m = compute_median(votes)
assert m.vinf == 1000
assert m.a == 0.5
def test_even_votes(self):
votes = [
VoteParams(vinf=800, a=0.3, b=0.2, c=0.4, d=0.5, e=0.6),
VoteParams(vinf=1200, a=0.7, b=0.8, c=0.6, d=0.5, e=0.4),
]
m = compute_median(votes)
assert m.vinf == 1000 # average of 800, 1200
assert abs(m.a - 0.5) < 1e-10
def test_empty_votes(self):
assert compute_median([]) is None