Documentation
¶
Overview ¶
Package packhealth derives an "effective pack capacity" per qualifying charge session. The result is a per-vehicle time series the UI plots to surface battery degradation — Rivian itself doesn't expose a SoH field (verified against the 3.12 Android app), so we have to compute it from inputs we already record on the `charges` table.
The fit ¶
pack_kwh_effective = energy_added_kwh / (soc_delta / 100)
where soc_delta = end_soc_pct - start_soc_pct.
Qualification ¶
Small SoC deltas amplify any measurement noise on energy_added_kwh (a 5%-window session gives a 20x noise multiplier on the divisor). We discard sessions with delta < MinSoCDeltaPct. Energy must be positive and below SanityMaxEnergyKWh — values above that almost always indicate a multi-day session that wrapped around midnight in the importer.
What's NOT yet captured ¶
The recorder doesn't currently persist whether chargerDerateStatus was "active" during a session; until it does, we always store DerateActive=false. The trend-line UI should still treat the column as authoritative once the recorder hook starts populating it.
Index ¶
- Constants
- type ChargeInput
- type Sample
- type Store
- func (s *Store) BackfillAll(ctx context.Context) (int, error)
- func (s *Store) BackfillVehicle(ctx context.Context, vehicleID uuid.UUID) (int, error)
- func (s *Store) ListByVehicle(ctx context.Context, vehicleID uuid.UUID, limit int) ([]Sample, error)
- func (s *Store) Upsert(ctx context.Context, sample Sample) error
Constants ¶
const MinPeakKWForMeasured = 20.0
MinPeakKWForMeasured is the lower bound on max_power_kw that signals Rivian reported a real totalChargedEnergy for the session (vs the recorder back-filling energy from SoC delta). 20 kW excludes AC home charging (typical home L2 caps at ~11.5 kW for R1) but includes any DCFC stop — including derated sessions. Sessions with no measured energy yield circular fits that just echo the stored nameplate.
const MinSoCDeltaPct = 30.0
MinSoCDeltaPct is the smallest SoC delta we trust to produce a meaningful pack-capacity estimate. Below this the energy-vs-delta ratio is dominated by measurement noise (the SoC field is quantised to whole percent on Rivian's side; a 5% session has ~10% noise on the divisor). 30% is a good compromise — it admits most real DC fast stops and rejects topping-off home sessions.
const SanityMaxEnergyKWh = 200.0
SanityMaxEnergyKWh rejects obvious bad data (multi-day sessions that the importer collapsed into a single row, etc.). Standard+ R1T usable is ~131 kWh; 200 is a comfortable upper bound.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type ChargeInput ¶
type ChargeInput struct {
VehicleID uuid.UUID
ChargeID uuid.UUID
EndedAt time.Time
StartSoCPct float64
EndSoCPct float64
EnergyAddedKWh float64
DerateObserved bool
}
ChargeInput is the subset of a charge row Derive needs. Keeping it a small struct (instead of importing db.Charge) avoids an import cycle and makes the math testable without a DB.
type Sample ¶
type Sample struct {
VehicleID uuid.UUID `json:"vehicle_id"`
ChargeID uuid.UUID `json:"charge_id"`
At time.Time `json:"at"`
PackKWhEffective float64 `json:"pack_kwh_effective"`
SoCDeltaPct float64 `json:"soc_delta_pct"`
EnergyDeliveredKWh float64 `json:"energy_delivered_kwh"`
DerateActive bool `json:"derate_active"`
}
Sample is a single derived data point for a charge session.
func Derive ¶
func Derive(in ChargeInput) (Sample, bool)
Derive computes a Sample for a charge. Returns ok=false when the input doesn't qualify (small SoC delta, missing energy, sanity reject). Callers should silently skip — non-qualifying sessions aren't an error condition, just data that can't be fit cleanly.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store wraps the vehicle_pack_health_samples table.
func NewStore ¶
NewStore returns a store over the shared pool. Multi-tenant scoping is enforced by the caller passing vehicleID — Postgres RLS on the charges table guards against cross-tenant reads at the source-of-truth level.
func (*Store) BackfillAll ¶
BackfillAll iterates every vehicle and runs BackfillVehicle. Idempotent (Upsert replaces on conflict). Designed to be called from a startup hydrate sweep AND a recurring ticker so newly recorded charges become samples without a per-charge recorder hook. Returns the total count of inserted/updated rows across all vehicles.
One replica at a time: a call that finds the pass already running on another replica (the advisory lock is held) does nothing and returns 0, nil; that replica's pass covers the hour.
func (*Store) BackfillVehicle ¶
BackfillVehicle re-derives samples for every existing charge on a vehicle. Idempotent (Upsert replaces on conflict) so safe to re-run any time. Skips charges where Derive returns ok=false.
Filters to charges with MEASURED energy: DC fast sessions (session_type='dc') OR any session with a credible peak power (max_power_kw > MinPeakKWForMeasured). Lower-power AC home sessions usually don't get a real totalChargedEnergy push from Rivian — the recorder back-fills energy_added_kwh from (SoC delta × pack_kwh), which would make this derivation circular and produce nothing but the stored nameplate back.
Reads start_soc_pct, end_soc_pct, energy_added_kwh, ended_at directly from the charges table. Bypasses RLS — pass a trusted vehicleID that the caller has already authorized.
func (*Store) ListByVehicle ¶
func (s *Store) ListByVehicle(ctx context.Context, vehicleID uuid.UUID, limit int) ([]Sample, error)
ListByVehicle returns samples for a vehicle, oldest first so the UI can plot a left-to-right trend directly. limit caps the row count (0 = unlimited; callers usually pass 365 for a year view).