""" 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