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:
368
frontend/app/utils/bezier-math.ts
Normal file
368
frontend/app/utils/bezier-math.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* TypeScript port of the Bézier tariff math engine.
|
||||
*
|
||||
* Mirrors backend/app/engine/integrals.py and pricing.py.
|
||||
* Uses Cardano's formula + Newton-Raphson polish for cubic solving.
|
||||
*/
|
||||
|
||||
// ── Cubic solver ──
|
||||
|
||||
/**
|
||||
* Solve ax³ + bx² + cx + d = 0 for real roots in [0, 1].
|
||||
* Uses Cardano's method with Newton-Raphson refinement.
|
||||
*/
|
||||
function solveCubicInUnit(a: number, b: number, c: number, d: number): number | null {
|
||||
if (Math.abs(a) < 1e-12) {
|
||||
// Degenerate: quadratic
|
||||
if (Math.abs(b) < 1e-12) {
|
||||
// Linear
|
||||
if (Math.abs(c) < 1e-12) return null
|
||||
const t = -d / c
|
||||
return t >= -1e-10 && t <= 1 + 1e-10 ? clamp01(t) : null
|
||||
}
|
||||
const disc = c * c - 4 * b * d
|
||||
if (disc < 0) return null
|
||||
const sqrtDisc = Math.sqrt(disc)
|
||||
const t1 = (-c + sqrtDisc) / (2 * b)
|
||||
const t2 = (-c - sqrtDisc) / (2 * b)
|
||||
if (t1 >= -1e-10 && t1 <= 1 + 1e-10) return clamp01(t1)
|
||||
if (t2 >= -1e-10 && t2 <= 1 + 1e-10) return clamp01(t2)
|
||||
return null
|
||||
}
|
||||
|
||||
// Normalize: t³ + pt² + qt + r = 0
|
||||
const p = b / a
|
||||
const q = c / a
|
||||
const r = d / a
|
||||
|
||||
// Depressed cubic: u³ + pu + q = 0 via substitution t = u - p/3
|
||||
const p1 = q - p * p / 3
|
||||
const q1 = r - p * q / 3 + 2 * p * p * p / 27
|
||||
|
||||
const discriminant = q1 * q1 / 4 + p1 * p1 * p1 / 27
|
||||
|
||||
const roots: number[] = []
|
||||
|
||||
if (discriminant > 1e-12) {
|
||||
// One real root
|
||||
const sqrtD = Math.sqrt(discriminant)
|
||||
const u = cbrt(-q1 / 2 + sqrtD)
|
||||
const v = cbrt(-q1 / 2 - sqrtD)
|
||||
roots.push(u + v - p / 3)
|
||||
} else if (discriminant < -1e-12) {
|
||||
// Three real roots (casus irreducibilis)
|
||||
const m = Math.sqrt(-p1 / 3)
|
||||
const theta = Math.acos((-q1 / 2) / (m * m * m)) / 3
|
||||
roots.push(
|
||||
2 * m * Math.cos(theta) - p / 3,
|
||||
2 * m * Math.cos(theta - 2 * Math.PI / 3) - p / 3,
|
||||
2 * m * Math.cos(theta - 4 * Math.PI / 3) - p / 3,
|
||||
)
|
||||
} else {
|
||||
// Double or triple root
|
||||
const u = cbrt(-q1 / 2)
|
||||
roots.push(2 * u - p / 3, -u - p / 3)
|
||||
}
|
||||
|
||||
// Find root in [0,1] and refine with Newton-Raphson
|
||||
for (const root of roots) {
|
||||
if (root >= -0.1 && root <= 1.1) {
|
||||
let t = clamp01(root)
|
||||
// Newton-Raphson polish (3 iterations)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const f = ((a * t + b) * t + c) * t + d
|
||||
const fp = (3 * a * t + 2 * b) * t + c
|
||||
if (Math.abs(fp) < 1e-14) break
|
||||
t = clamp01(t - f / fp)
|
||||
}
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function cbrt(x: number): number {
|
||||
return x < 0 ? -Math.pow(-x, 1 / 3) : Math.pow(x, 1 / 3)
|
||||
}
|
||||
|
||||
function clamp01(t: number): number {
|
||||
return Math.max(0, Math.min(1, t))
|
||||
}
|
||||
|
||||
// ── Integral computation ──
|
||||
|
||||
export interface IntegralResult {
|
||||
alpha1: number
|
||||
alpha2: number
|
||||
beta2: number
|
||||
}
|
||||
|
||||
export function computeIntegrals(
|
||||
volume: number,
|
||||
vinf: number,
|
||||
vmax: number,
|
||||
pmax: number,
|
||||
a: number,
|
||||
b: number,
|
||||
c: number,
|
||||
d: number,
|
||||
e: number,
|
||||
): IntegralResult {
|
||||
if (volume <= vinf) {
|
||||
const T = solveTier1T(volume, vinf, b)
|
||||
const alpha1 = computeAlpha1(T, vinf, a, b)
|
||||
return { alpha1, alpha2: 0, beta2: 0 }
|
||||
} else {
|
||||
const alpha1 = computeAlpha1(1.0, vinf, a, b)
|
||||
const wmax = vmax - vinf
|
||||
const T = solveTier2T(volume - vinf, wmax, c, d)
|
||||
const uu = computeUU(T, c, d, e)
|
||||
const alpha2 = (volume - vinf) - 3 * uu * wmax
|
||||
const beta2 = 3 * pmax * wmax * uu
|
||||
return { alpha1, alpha2, beta2 }
|
||||
}
|
||||
}
|
||||
|
||||
function solveTier1T(volume: number, vinf: number, b: number): number {
|
||||
if (volume <= 0) return 0
|
||||
if (volume >= vinf) return 1
|
||||
const ratio = volume / vinf
|
||||
const t = solveCubicInUnit(1 - 3 * b, 3 * b, 0, -ratio)
|
||||
return t ?? 0
|
||||
}
|
||||
|
||||
function solveTier2T(w: number, wmax: number, c: number, d: number): number {
|
||||
if (w <= 0) return 0
|
||||
if (w >= wmax) return 1
|
||||
const ratio = w / wmax
|
||||
const t = solveCubicInUnit(
|
||||
3 * (c + d - c * d) - 2,
|
||||
3 * (1 - 2 * c - d + c * d),
|
||||
3 * c,
|
||||
-ratio,
|
||||
)
|
||||
return t ?? 0
|
||||
}
|
||||
|
||||
function computeAlpha1(T: number, vinf: number, a: number, b: number): number {
|
||||
return 3 * vinf * (
|
||||
Math.pow(T, 6) / 6 * (-9 * a * b + 3 * a + 6 * b - 2) +
|
||||
Math.pow(T, 5) / 5 * (24 * a * b - 6 * a - 13 * b + 3) +
|
||||
3 * Math.pow(T, 4) / 4 * (-7 * a * b + a + 2 * b) +
|
||||
Math.pow(T, 3) / 3 * 6 * a * b
|
||||
)
|
||||
}
|
||||
|
||||
function computeUU(T: number, c: number, d: number, e: number): number {
|
||||
return (
|
||||
(-3 * c * d + 9 * e * c * d + 3 * c - 9 * e * c + 3 * d - 9 * e * d + 6 * e - 2) * Math.pow(T, 6) / 6 +
|
||||
(2 * c * d - 15 * e * c * d - 4 * c + 21 * e * c - 2 * d + 15 * e * d - 12 * e + 2) * Math.pow(T, 5) / 5 +
|
||||
(6 * e * c * d + c - 15 * e * c - 6 * e * d + 6 * e) * Math.pow(T, 4) / 4 +
|
||||
(3 * e * c) * Math.pow(T, 3) / 3
|
||||
)
|
||||
}
|
||||
|
||||
// ── Pricing computation ──
|
||||
|
||||
export interface HouseholdData {
|
||||
volume_m3: number
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface PricingResult {
|
||||
p0: number
|
||||
curveVolumes: number[]
|
||||
curvePricesM3: number[]
|
||||
}
|
||||
|
||||
export interface ImpactRow {
|
||||
volume: number
|
||||
oldPrice: number
|
||||
newPriceRP: number
|
||||
newPriceRS: number
|
||||
}
|
||||
|
||||
export function computeP0(
|
||||
households: HouseholdData[],
|
||||
recettes: number,
|
||||
abop: number,
|
||||
abos: number,
|
||||
vinf: number,
|
||||
vmax: number,
|
||||
pmax: number,
|
||||
a: number,
|
||||
b: number,
|
||||
c: number,
|
||||
d: number,
|
||||
e: number,
|
||||
): number {
|
||||
let totalAbo = 0
|
||||
let totalAlpha = 0
|
||||
let totalBeta = 0
|
||||
|
||||
for (const h of households) {
|
||||
const abo = h.status === 'RS' ? abos : abop
|
||||
totalAbo += abo
|
||||
const vol = Math.max(h.volume_m3, 1e-5)
|
||||
const { alpha1, alpha2, beta2 } = computeIntegrals(vol, vinf, vmax, pmax, a, b, c, d, e)
|
||||
totalAlpha += alpha1 + alpha2
|
||||
totalBeta += beta2
|
||||
}
|
||||
|
||||
if (totalAbo >= recettes) return 0
|
||||
if (totalAlpha === 0) return 0
|
||||
|
||||
return (recettes - totalAbo - totalBeta) / totalAlpha
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate price curve points (price per m³ vs volume).
|
||||
*/
|
||||
export function generateCurve(
|
||||
vinf: number,
|
||||
vmax: number,
|
||||
pmax: number,
|
||||
p0: number,
|
||||
a: number,
|
||||
b: number,
|
||||
c: number,
|
||||
d: number,
|
||||
e: number,
|
||||
nbpts: number = 200,
|
||||
): PricingResult {
|
||||
const curveVolumes: number[] = []
|
||||
const curvePricesM3: number[] = []
|
||||
|
||||
const dt = 1 / (nbpts - 1)
|
||||
|
||||
// Tier 1
|
||||
for (let i = 0; i < nbpts; i++) {
|
||||
const t = Math.min(i * dt, 1 - 1e-6)
|
||||
const v = vinf * ((1 - 3 * b) * t * t * t + 3 * b * t * t)
|
||||
const p = p0 * ((3 * a - 2) * t * t * t + (-6 * a + 3) * t * t + 3 * a * t)
|
||||
curveVolumes.push(v)
|
||||
curvePricesM3.push(p)
|
||||
}
|
||||
|
||||
// Tier 2
|
||||
for (let i = 0; i < nbpts; i++) {
|
||||
const t = Math.min(i * dt, 1 - 1e-6)
|
||||
const v = vinf + (vmax - vinf) * (
|
||||
(3 * (c + d - c * d) - 2) * t * t * t +
|
||||
3 * (1 - 2 * c - d + c * d) * t * t +
|
||||
3 * c * t
|
||||
)
|
||||
const p = p0 + (pmax - p0) * ((1 - 3 * e) * t * t * t + 3 * e * t * t)
|
||||
curveVolumes.push(v)
|
||||
curvePricesM3.push(p)
|
||||
}
|
||||
|
||||
return { p0, curveVolumes, curvePricesM3 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute price impacts at reference volume levels.
|
||||
*/
|
||||
export function computeImpacts(
|
||||
households: HouseholdData[],
|
||||
recettes: number,
|
||||
abop: number,
|
||||
abos: number,
|
||||
vinf: number,
|
||||
vmax: number,
|
||||
pmax: number,
|
||||
a: number,
|
||||
b: number,
|
||||
c: number,
|
||||
d: number,
|
||||
e: number,
|
||||
referenceVolumes: number[] = [30, 60, 90, 150, 300],
|
||||
): { p0: number; impacts: ImpactRow[] } {
|
||||
const p0 = computeP0(households, recettes, abop, abos, vinf, vmax, pmax, a, b, c, d, e)
|
||||
|
||||
// Linear baseline
|
||||
const totalVol = households.reduce((s, h) => s + Math.max(h.volume_m3, 1e-5), 0)
|
||||
const totalAbo = households.reduce((s, h) => s + (h.status === 'RS' ? abos : abop), 0)
|
||||
const oldPM3 = totalVol > 0 ? (recettes - totalAbo) / totalVol : 0
|
||||
|
||||
const impacts: ImpactRow[] = referenceVolumes.map((vol) => {
|
||||
const { alpha1, alpha2, beta2 } = computeIntegrals(vol, vinf, vmax, pmax, a, b, c, d, e)
|
||||
return {
|
||||
volume: vol,
|
||||
oldPrice: abop + oldPM3 * vol,
|
||||
newPriceRP: abop + (alpha1 + alpha2) * p0 + beta2,
|
||||
newPriceRS: abos + (alpha1 + alpha2) * p0 + beta2,
|
||||
}
|
||||
})
|
||||
|
||||
return { p0, impacts }
|
||||
}
|
||||
|
||||
// ── Control point mapping ──
|
||||
|
||||
export interface ControlPoints {
|
||||
// Tier 1: P1(fixed), P2, P3, P4
|
||||
p1: { x: number; y: number }
|
||||
p2: { x: number; y: number }
|
||||
p3: { x: number; y: number }
|
||||
p4: { x: number; y: number }
|
||||
// Tier 2: P4(shared), P5, P6, P7(fixed)
|
||||
p5: { x: number; y: number }
|
||||
p6: { x: number; y: number }
|
||||
p7: { x: number; y: number }
|
||||
}
|
||||
|
||||
export function paramsToControlPoints(
|
||||
vinf: number,
|
||||
vmax: number,
|
||||
pmax: number,
|
||||
p0: number,
|
||||
a: number,
|
||||
b: number,
|
||||
c: number,
|
||||
d: number,
|
||||
e: number,
|
||||
): ControlPoints {
|
||||
return {
|
||||
p1: { x: 0, y: 0 },
|
||||
p2: { x: 0, y: a * p0 },
|
||||
p3: { x: b * vinf, y: p0 },
|
||||
p4: { x: vinf, y: p0 },
|
||||
p5: { x: vinf + c * (vmax - vinf), y: p0 },
|
||||
p6: {
|
||||
x: vinf + (vmax - vinf) * (1 - d + c * d),
|
||||
y: p0 + e * (pmax - p0),
|
||||
},
|
||||
p7: { x: vmax, y: pmax },
|
||||
}
|
||||
}
|
||||
|
||||
export function controlPointsToParams(
|
||||
cp: ControlPoints,
|
||||
vmax: number,
|
||||
pmax: number,
|
||||
p0: number,
|
||||
): { vinf: number; a: number; b: number; c: number; d: number; e: number } {
|
||||
const vinf = cp.p4.x
|
||||
|
||||
const a = p0 > 0 ? clamp01(cp.p2.y / p0) : 0.5
|
||||
const b = vinf > 0 ? clamp01(cp.p3.x / vinf) : 0.5
|
||||
|
||||
const wmax = vmax - vinf
|
||||
const c = wmax > 0 ? clamp01((cp.p5.x - vinf) / wmax) : 0.5
|
||||
|
||||
const qmax = pmax - p0
|
||||
const e = qmax > 0 ? clamp01((cp.p6.y - p0) / qmax) : 0.5
|
||||
|
||||
// d from p6.x: x6 = vinf + wmax * (1 - d + c*d) => d = (1 - (x6-vinf)/wmax) / (1-c)
|
||||
let d_val = 0.5
|
||||
if (wmax > 0) {
|
||||
const ratio = (cp.p6.x - vinf) / wmax
|
||||
if (Math.abs(1 - c) > 1e-10) {
|
||||
d_val = clamp01((1 - ratio) / (1 - c))
|
||||
}
|
||||
}
|
||||
|
||||
return { vinf, a, b, c, d: d_val, e }
|
||||
}
|
||||
Reference in New Issue
Block a user