Statistik-Icon-Groesse, Batteriediagramm-Ueberarbeitung, Temperatur-Kopplung mit 300s-Fenster, letzte gueltige Spannung als Fallback

- Statistik-Tab-Icon per zentrierter Stauchung an die Fuellflaeche der
  anderen Tab-Icons angeglichen (Panel + companion-app), auf Nutzerwunsch
  anschliessend 10% groesser.
- Batteriediagramm neu gezeichnet: dynamische viewBox (1 Einheit = 1 Pixel,
  kein preserveAspectRatio-Verzerren mehr), ein Punkt je Ruhespannungsmessung
  statt Datumsbeschriftung, Tageshoechstwert nur noch im Tooltip. Generator-
  spannungen (> AGM_RUHE_MAX_V = 13,0 V, vom Nutzer festgelegt) bleiben aus
  dem Diagramm aussen vor; der SOC-Kurve-Eckwert "100% / voll" bleibt bei den
  ursprünglich recherchierten 12,8 V. companion-app auf dieselbe lineare
  SOC-Interpolation umgestellt (vorher eine abweichende Stufenkurve).
- Außentemperatur wird jetzt auch beim Batterieverlauf-Import nachgetragen
  (vorher nur live) - Kopplung an die naechstgelegene Messung innerhalb
  NEBENWERT_MAX_ABSTAND_S (verlauf.py), vom Nutzer nach Live-Daten-Analyse
  auf 300s festgelegt. Merge-Logik in ablage.py ergaenzt: ein erneuter Import
  kann eine zuvor fehlende Temperatur jetzt tatsaechlich nachtragen, auch
  wenn sich der Tagesminimalwert selbst nicht aendert.
- "Mein Audi"/Zustand zeigt bei unplausibler Live-Messung (z. B. Dongle
  offline) die zuletzt gemessene Spannung statt "unbekannt".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 23:23:12 +02:00
parent 80d73e8afd
commit 172db54e7a
12 changed files with 626 additions and 85 deletions
+116 -40
View File
@@ -7,11 +7,11 @@
*
* Wichtig für die Einordnung: Der Ladezustand wird aus der **Tagesminimum**-
* Spannung geschätzt (Ruhespannung kurz nach dem Start, bevor die Lichtmaschine
* anhebt); Werte über 12,8 V gelten als „während der Fahrt gemessen" und
* anhebt); Werte über 13,0 V gelten als „während der Fahrt gemessen" und
* taugen nicht als Ruhewert.
*/
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { IconButton, Tile } from "@audi-dash/ui"
@@ -28,29 +28,46 @@ interface Tageswert {
max: number | null
min_ts?: string
max_ts?: string
min_temp_c?: number | null
}
/** Ab hier gilt eine Messung als nicht mehr in Ruhe aufgenommen - alles
darüber ist schon Lichtmaschinenspannung, nicht mehr Ruhespannung. Auch
von BatterieListe.tsx verwendet, für die "Generatorspannung"-Kennzeichnung
dort - eine einzige Schwelle für beide Ansichten. */
export const AGM_RUHE_MAX_V = 12.8
/** Ab hier gilt eine Messung nicht mehr als Batterie(ruhe)spannung, sondern
schon als Generatorspannung - vom Nutzer direkt festgelegt ("max voltage
for AGM Battery is 13V, everything above is the generator"), identisch
mit AGM_RUHE_MAX_V im Panel (audi-dashboard-app.js). Getrennt von
LADEKURVE unten: eine Messung zwischen 12,8 und 13,0 V zählt hier noch als
gültige Batteriemessung (Diagrammpunkt, fließt in den Trend ein), zeigt
aber weiterhin 100 % Ladezustand ("12,8v voll was already right" - der
Nutzer bestätigte den ursprünglichen 12,8-V-Eckwert ausdrücklich). Auch
von BatterieListe.tsx verwendet, für die "Generatorspannung"-
Kennzeichnung dort - eine einzige Schwelle für beide Ansichten. */
export const AGM_RUHE_MAX_V = 13.0
/** Spannung → Ladezustand für eine AGM-Batterie in Ruhe. */
/** Spannung → Ladezustand für eine AGM-Batterie in Ruhe, linear zwischen den
Stützstellen interpoliert - identische Tabelle und Rechenweg wie im Panel
(`bvSocProzent()`/`BV_SOC_TABELLE`, `audi-dashboard-app.js`), auf
Nutzerwunsch angeglichen: die vorherige Stufenkurve hier lieferte bei
derselben Spannung teils deutlich andere Prozentwerte als das Panel (z. B.
12,1 V: 40 % im Panel, 25 % hier) - beide Ansichten sollen bei derselben
Messung dasselbe zeigen. Letzte Stützstelle bleibt bei 12,8 V/100 % -
unabhängig von der oben auf 13,0 V angehobenen AGM_RUHE_MAX_V, siehe
deren eigenen Kommentar. */
const LADEKURVE: [number, number][] = [
[12.8, 100],
[12.6, 90],
[12.4, 75],
[12.2, 50],
[12.0, 25],
[11.8, 10],
[10.5, 0], [11.8, 10], [11.9, 20], [12.0, 30], [12.1, 40],
[12.2, 50], [12.4, 60], [12.5, 70], [12.6, 80], [12.7, 90], [12.8, 100],
]
function ladezustand(spannung: number): number | null {
if (spannung > AGM_RUHE_MAX_V) return null
const ersterTreffer = LADEKURVE.find(([v]) => spannung >= v)
if (ersterTreffer) return ersterTreffer[1]
return 0
const t = LADEKURVE
if (spannung <= t[0]![0]) return 0
if (spannung >= t[t.length - 1]![0]) return 100
for (let i = 0; i < t.length - 1; i++) {
const [v0, s0] = t[i]!
const [v1, s1] = t[i + 1]!
if (spannung >= v0 && spannung <= v1) return Math.round(s0 + ((s1 - s0) * (spannung - v0)) / (v1 - v0))
}
return 100
}
export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) {
@@ -148,11 +165,34 @@ export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) {
)
}
/** Von Hand gezeichnete Verlaufskurve aus Tagesminimum und -maximum. */
/** Von Hand gezeichnete Verlaufskurve aus dem Tagesminimum, mit einem Punkt je
Messung. Der Tageshöchstwert bekommt bewusst KEINE eigene Linie mehr
(Nutzerwunsch, Parität zum Panel) - er erscheint als Text, sobald ein Punkt
angetippt wird, zusammen mit Datum und Uhrzeit.
Die viewBox folgt der tatsächlich gemessenen Pixelbreite, statt eine feste
320er-Fläche mit preserveAspectRatio="none" breitzuziehen: sonst würden die
neuen Punkte auf breiten Anzeigen zu Ellipsen verzerrt (im Panel war
dasselbe Problem an den auseinandergezogenen Achsenzahlen sichtbar). */
function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
const breite = 320
const hoehe = 120
const rand = 4
const rand = 8
const svgRef = useRef<SVGSVGElement | null>(null)
const [breite, setzeBreite] = useState(320)
const [gewaehlt, setzeGewaehlt] = useState<number | null>(null)
useEffect(() => {
const el = svgRef.current
if (!el || typeof ResizeObserver === "undefined") return
const messen = () => {
const w = el.getBoundingClientRect().width
if (w > 0) setzeBreite(w)
}
messen()
const beobachter = new ResizeObserver(messen)
beobachter.observe(el)
return () => beobachter.disconnect()
}, [])
// Feste Achse 10-15V (Nutzerwunsch): deckt den relevanten Bereich ab
// (12V-Bleibatterie, Ladespannung des Alternators mit Reserve).
@@ -160,8 +200,7 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
const groesster = 15
const spanne = groesster - kleinster
const x = (i: number) =>
rand + (i / Math.max(1, werte.length - 1)) * (breite - 2 * rand)
const x = (i: number) => rand + (i / Math.max(1, werte.length - 1)) * (breite - 2 * rand)
// Anteil auf [0,1] geklemmt statt roh extrapoliert - ein Ausreißer außerhalb
// von kleinster/groesster zeichnet sich sonst weit außerhalb der Fläche.
const y = (v: number) => {
@@ -169,25 +208,62 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
return hoehe - rand - anteil * (hoehe - 2 * rand)
}
const linie = (auswahl: (t: Tageswert) => number | null) =>
werte
.map((t, i) => {
const v = auswahl(t)
return v == null ? null : `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(v).toFixed(1)}`
})
.filter(Boolean)
.join(" ")
const linie = werte
.map((t, i) => (t.min == null ? null : `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(t.min).toFixed(1)}`))
.filter(Boolean)
.join(" ")
const punktWaehlen = (ereignis: { clientX: number }) => {
const el = svgRef.current
if (!el || werte.length === 0) return
const kasten = el.getBoundingClientRect()
const relativ = ereignis.clientX - kasten.left - rand
const schritt = (breite - 2 * rand) / Math.max(1, werte.length - 1)
const index = Math.max(0, Math.min(werte.length - 1, Math.round(relativ / schritt)))
setzeGewaehlt((vorher) => (vorher === index ? null : index))
}
const aktiv = gewaehlt != null ? werte[gewaehlt] : undefined
const zeitpunkt = aktiv?.min_ts ? new Date(aktiv.min_ts) : null
return (
<svg
className="dm-diagramm"
viewBox={`0 0 ${breite} ${hoehe}`}
preserveAspectRatio="none"
role="img"
aria-label={`Spannungsverlauf über ${werte.length} Tage`}
>
<path d={linie((t) => t.max)} fill="none" stroke="var(--fg3)" strokeWidth="1" />
<path d={linie((t) => t.min)} fill="none" stroke="var(--red)" strokeWidth="1.5" />
</svg>
<>
<svg
ref={svgRef}
className="dm-diagramm"
viewBox={`0 0 ${breite.toFixed(1)} ${hoehe}`}
role="img"
aria-label={`Spannungsverlauf über ${werte.length} Messungen`}
onClick={punktWaehlen}
>
<path d={linie} fill="none" stroke="var(--red)" strokeWidth="1.5" strokeLinejoin="round" />
{werte.map((t, i) =>
t.min == null ? null : (
<circle
key={t.datum}
cx={x(i)}
cy={y(t.min)}
r={gewaehlt === i ? 4.5 : 2.4}
fill="var(--red)"
{...(gewaehlt === i ? { stroke: "var(--fg)", strokeWidth: 1 } : {})}
/>
),
)}
</svg>
{aktiv && (
<p className="dm-fussnote">
{[
zeitpunkt
? `${datum(aktiv.datum)} · ${zeitpunkt.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr`
: datum(aktiv.datum),
aktiv.min != null ? `Min ${de(aktiv.min, 2)} V` : null,
aktiv.max != null ? `Max ${de(aktiv.max, 2)} V` : null,
aktiv.min_temp_c != null ? `${de(aktiv.min_temp_c, 1)} °C` : null,
]
.filter(Boolean)
.join(" · ")}
</p>
)}
</>
)
}
+9 -2
View File
@@ -79,14 +79,21 @@ export function SymbolFahrten({ groesse }: SymbolProps) {
}
// CI-Symbol polls-s statt der vorherigen handgezeichneten Balken (Parität
// zum Panel, siehe ICONS.stat dort).
// zum Panel, siehe ICONS.stat dort). Anders als die handgezeichneten
// Symbole (die im 24er-Raster deutlich Rand lassen) ist das CI-Symbol
// randlos gezeichnet und wirkte dadurch in der Tableiste sichtbar größer
// als "Fahrten"/"Tanken" - dieselbe zentrierte Stauchung (Faktor 0.77, um
// (12,12), auf Nutzerwunsch 10% größer als die erste Fassung) wie im Panel
// gleicht das aus, siehe ICONS.stat dort.
export function SymbolStatistik({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
voll
kinder={
<path d="M9 0h6v21H9zm1 1v19h4V1zm7 4h6v16h-6zm1 1v14h4V6zM1 9h6v12H1zm1 1v10h4V10zM1 23h22v1H1z" />
<g transform="translate(2.76,2.76) scale(0.77)">
<path d="M9 0h6v21H9zm1 1v19h4V1zm7 4h6v16h-6zm1 1v14h4V6zM1 9h6v12H1zm1 1v10h4V10zM1 23h22v1H1z" />
</g>
}
/>
)