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>
369 lines
9.3 KiB
TypeScript
369 lines
9.3 KiB
TypeScript
/**
|
|
* 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 }
|
|
}
|