packhealth

package
v0.36.21 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 18, 2026 License: MIT Imports: 6 Imported by: 0

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

View Source
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.

View Source
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.

View Source
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

func NewStore(d *sql.DB) *Store

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

func (s *Store) BackfillAll(ctx context.Context) (int, error)

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.

func (*Store) BackfillVehicle

func (s *Store) BackfillVehicle(ctx context.Context, vehicleID uuid.UUID) (int, error)

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).

func (*Store) Upsert

func (s *Store) Upsert(ctx context.Context, sample Sample) error

Upsert inserts a sample, replacing on conflict so re-running Derive on a charge whose energy was later corrected refreshes the row instead of duplicating it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL