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:
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
280
backend/tests/test_engine.py
Normal file
280
backend/tests/test_engine.py
Normal 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
|
||||
Reference in New Issue
Block a user