1
0
forked from EHV/sejeteralo

Compare commits

...

3 Commits

Author SHA1 Message Date
nicoboy daad6bb1d5 modif position legende 2026-05-01 20:37:23 +02:00
nicoboy 129c9cfc7b modification calcul mediane 2026-05-01 20:22:52 +02:00
syoul b24f226b35 fix prod : Eau2018.xls hors du volume /app + pin Trivy
Le volume backend-data monté sur /app masquait Eau2018.xls copié dans
l'image à /app/Eau2018.xls — d'où le FileNotFoundError au step seed CI.

- Dockerfile : copie Eau2018.xls dans /opt/ (hors mount)
- seed.py : résolution multi-chemin avec /opt en priorité (Docker)
- .woodpecker.yml : trivy:latest -> trivy:0.70.0 (pin reproductibilité)

Note : si le seed replante avec la même erreur après ce commit, c'est
que le volume backend-data en prod a aussi un seed.py figé (shadowing
de /app entier). Fix de fond à venir : déplacer le mount sur /app/data.
2026-04-26 22:57:39 +02:00
6 changed files with 101 additions and 12 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ steps:
# NOTE: volumes + pas de from_secret : compatible # NOTE: volumes + pas de from_secret : compatible
- name: sbom-scan - name: sbom-scan
image: aquasec/trivy:latest image: aquasec/trivy:0.70.0
volumes: volumes:
- /home/syoul/trivy-cache:/root/.cache/trivy - /home/syoul/trivy-cache:/root/.cache/trivy
commands: commands:
+39 -1
View File
@@ -5,12 +5,22 @@ Computes the element-wise median of (vinf, a, b, c, d, e) across all active vote
This parametric median is chosen over geometric median because: This parametric median is chosen over geometric median because:
- It's transparent and politically explainable - It's transparent and politically explainable
- The result is itself a valid set of Bézier parameters - The result is itself a valid set of Bézier parameters
Post-processing: the median is sanitized to guarantee monotonicity of tier 2.
The condition for tier 2 to be non-decreasing is e <= 1/3.
If the raw median violates this, e is clamped to E_MAX_MONOTONE.
""" """
import numpy as np import numpy as np
from dataclasses import dataclass from dataclasses import dataclass
# Maximum value of e that guarantees tier-2 price monotonicity.
# prix_m3_2 = p0 + (pmax-p0) * ((1-3e)t³ + 3e t²)
# The cubic is monotone non-decreasing on [0,1] iff (1-3e) >= 0, i.e. e <= 1/3.
E_MAX_MONOTONE = 1.0 / 3.0
@dataclass @dataclass
class VoteParams: class VoteParams:
"""The 6 citizen-adjustable parameters.""" """The 6 citizen-adjustable parameters."""
@@ -22,11 +32,37 @@ class VoteParams:
e: float e: float
def _is_tier2_monotone(e: float) -> bool:
"""Check if tier-2 price curve is monotone non-decreasing."""
return e <= E_MAX_MONOTONE
def sanitize_median(params: "VoteParams") -> "VoteParams":
"""
Ensure the median curve is physically valid:
- Tier 2 must be monotone non-decreasing (penalizes high consumption)
- If e violates monotonicity, clamp it to E_MAX_MONOTONE
"""
e = params.e
if not _is_tier2_monotone(e):
e = E_MAX_MONOTONE
return VoteParams(
vinf=params.vinf,
a=params.a,
b=params.b,
c=params.c,
d=params.d,
e=e,
)
def compute_median(votes: list[VoteParams]) -> VoteParams | None: def compute_median(votes: list[VoteParams]) -> VoteParams | None:
""" """
Compute element-wise median of vote parameters. Compute element-wise median of vote parameters.
Returns None if no votes provided. Returns None if no votes provided.
The result is sanitized for tier-2 monotonicity.
""" """
if not votes: if not votes:
return None return None
@@ -38,7 +74,7 @@ def compute_median(votes: list[VoteParams]) -> VoteParams | None:
d_s = [v.d for v in votes] d_s = [v.d for v in votes]
e_s = [v.e for v in votes] e_s = [v.e for v in votes]
return VoteParams( raw = VoteParams(
vinf=float(np.median(vinfs)), vinf=float(np.median(vinfs)),
a=float(np.median(a_s)), a=float(np.median(a_s)),
b=float(np.median(b_s)), b=float(np.median(b_s)),
@@ -46,3 +82,5 @@ def compute_median(votes: list[VoteParams]) -> VoteParams | None:
d=float(np.median(d_s)), d=float(np.median(d_s)),
e=float(np.median(e_s)), e=float(np.median(e_s)),
) )
return sanitize_median(raw)
+6 -3
View File
@@ -17,9 +17,12 @@ from app.services.auth_service import hash_password
from app.services.import_service import generate_auth_code from app.services.import_service import generate_auth_code
from app.engine.pricing import HouseholdData, compute_p0 from app.engine.pricing import HouseholdData, compute_p0
XLS_PATH = os.path.join(os.path.dirname(__file__), "Eau2018.xls") _XLS_CANDIDATES = [
if not os.path.exists(XLS_PATH): "/opt/Eau2018.xls", # image Docker (hors volume monté sur /app)
XLS_PATH = os.path.join(os.path.dirname(__file__), "..", "Eau2018.xls") os.path.join(os.path.dirname(__file__), "Eau2018.xls"),
os.path.join(os.path.dirname(__file__), "..", "Eau2018.xls"), # dev local depuis backend/
]
XLS_PATH = next((p for p in _XLS_CANDIDATES if os.path.exists(p)), _XLS_CANDIDATES[-1])
# Codes fixes — identiques dans le dev hint frontend # Codes fixes — identiques dans le dev hint frontend
+47
View File
@@ -0,0 +1,47 @@
# docker-compose.jetson.yml
# Ports décalés (8001/3001) pour ne pas entrer en conflit
# avec les services existants sur le Jetson.
# Nginx fait le reverse proxy depuis sejeteraleau.nicolasboyer.com
services:
backend:
build:
context: .
dockerfile: docker/backend.Dockerfile
target: production
environment:
DATABASE_URL: sqlite+aiosqlite:////data/sejeteralo.db
SECRET_KEY: CHANGEZ-MOI-cle-longue-et-aleatoire-32-chars-min
DEBUG: "false"
CORS_ORIGINS: '["https://sejeteraleau.nicolasboyer.com"]'
ports:
- "127.0.0.1:8010:8000"
volumes:
- backend-data:/data
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
interval: 30s
timeout: 5s
retries: 3
frontend:
build:
context: .
dockerfile: docker/frontend.Dockerfile
target: production
args:
# URL vue depuis le navigateur du visiteur
NUXT_PUBLIC_API_BASE: https://sejeteraleau.nicolasboyer.com/api/v1
environment:
NUXT_PUBLIC_API_BASE: https://sejeteraleau.nicolasboyer.com/api/v1
PORT: "3000"
ports:
- "127.0.0.1:3010:3000"
depends_on:
- backend
restart: unless-stopped
volumes:
backend-data:
+2 -1
View File
@@ -11,7 +11,7 @@ COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ . COPY backend/ .
COPY Eau2018.xls /app/Eau2018.xls COPY Eau2018.xls /opt/Eau2018.xls
# Production # Production
FROM base AS production FROM base AS production
@@ -22,6 +22,7 @@ COPY --from=build /usr/local/lib/python3.11/site-packages /usr/local/lib/python3
COPY --from=build /usr/local/bin/uvicorn /usr/local/bin/uvicorn COPY --from=build /usr/local/bin/uvicorn /usr/local/bin/uvicorn
COPY --from=build /usr/local/bin/alembic /usr/local/bin/alembic COPY --from=build /usr/local/bin/alembic /usr/local/bin/alembic
COPY --from=build /app /app COPY --from=build /app /app
COPY --from=build /opt/Eau2018.xls /opt/Eau2018.xls
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/docs')" || exit 1 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/docs')" || exit 1
+1 -1
View File
@@ -231,7 +231,7 @@
</text> </text>
<!-- Legend box (top-right) --> <!-- Legend box (top-right) -->
<g :transform="`translate(${margin.left + plotW - 232}, ${margin.top + 8})`"> <g :transform="`translate(${margin.left + 8}, ${margin.top + plotH - 90})`">
<rect x="0" y="0" width="220" :height="citizenAbo > 0 ? 80 : 62" rx="6" :fill="t.legendBg" fill-opacity="0.92" :stroke="t.legendBorder" /> <rect x="0" y="0" width="220" :height="citizenAbo > 0 ? 80 : 62" rx="6" :fill="t.legendBg" fill-opacity="0.92" :stroke="t.legendBorder" />
<line x1="10" y1="14" x2="28" y2="14" stroke="#2563eb" stroke-width="3" stroke-linecap="round" /> <line x1="10" y1="14" x2="28" y2="14" stroke="#2563eb" stroke-width="3" stroke-linecap="round" />
<text x="34" y="18" font-size="11" :fill="t.text">Consommations foyers</text> <text x="34" y="18" font-size="11" :fill="t.text">Consommations foyers</text>