diff --git a/internal/api/batterypassport/doc.go b/internal/api/batterypassport/doc.go new file mode 100644 index 0000000000..f71e547abe --- /dev/null +++ b/internal/api/batterypassport/doc.go @@ -0,0 +1,14 @@ +// Package batterypassport serves the Battery Passport — a verifiable, +// tamper-evident State-of-Health provenance certificate for a vehicle's +// high-voltage battery, aligned with the EU Battery Passport regulation +// (mandatory 2027). +// +// The passport is a certificate-style provenance artifact distinct from the +// batterydegradation analytics page: it condenses the battery's health +// history and usage into a signed, exportable snapshot whose core immutable +// facts are bound by a SHA-256 provenance hash. A companion verify endpoint +// recomputes that hash so a prospective buyer can detect tampering or +// staleness. +// +// Layer: handler +package batterypassport diff --git a/internal/api/batterypassport/dtos.go b/internal/api/batterypassport/dtos.go new file mode 100644 index 0000000000..104bec4732 --- /dev/null +++ b/internal/api/batterypassport/dtos.go @@ -0,0 +1,54 @@ +package batterypassport + +// DTOs for the Battery Passport endpoints. All JSON tags are snake_case to +// match the frontend wire contract; numeric fields are rounded at the +// handler boundary before both the JSON body and the provenance hash are +// derived from them, so the hash reproduces the displayed facts exactly. + +// ThermalExposure is the share of drives whose ambient temperature fell in +// each band, expressed as a percentage (0..100). The three bands sum to 100 +// when at least one drive carried an ambient reading; otherwise all are 0. +type ThermalExposure struct { + ColdPct float64 `json:"cold_pct"` + NominalPct float64 `json:"nominal_pct"` + HotPct float64 `json:"hot_pct"` +} + +// TrendPoint is one day of the State-of-Health degradation trend. Date is a +// YYYY-MM-DD calendar day (UTC); SohPct is the estimated pack SoH for that +// day (0..100). +type TrendPoint struct { + Date string `json:"date"` + SohPct float64 `json:"soh_pct"` +} + +// Passport is the complete Battery Passport certificate payload. +// +// FirstObservedAt is a nullable RFC 3339 timestamp — a vehicle with no +// drives and no charging sessions yet has no first-observed instant, so the +// frontend must render its "issued/observed" header null-safely. +type Passport struct { + VehicleID int64 `json:"vehicle_id"` + VinMasked string `json:"vin_masked"` + IssuedAt string `json:"issued_at"` + FirstObservedAt *string `json:"first_observed_at"` + SohPct float64 `json:"soh_pct"` + CapacityKwh float64 `json:"capacity_kwh"` + OriginalCapacityKwh float64 `json:"original_capacity_kwh"` + EquivalentFullCycles float64 `json:"equivalent_full_cycles"` + FastChargeRatio float64 `json:"fast_charge_ratio"` + AvgChargeLimitPct float64 `json:"avg_charge_limit_pct"` + ThermalExposure ThermalExposure `json:"thermal_exposure"` + HealthGrade string `json:"health_grade"` + DegradationTrend []TrendPoint `json:"degradation_trend"` + Recommendations []string `json:"recommendations"` + ProvenanceHash string `json:"provenance_hash"` +} + +// VerifyResponse is the tamper-evidence result: Valid is true when the +// caller-supplied hash matches the freshly recomputed ExpectedHash. +type VerifyResponse struct { + Valid bool `json:"valid"` + ExpectedHash string `json:"expected_hash"` + ProvidedHash string `json:"provided_hash"` +} diff --git a/internal/api/batterypassport/handler.go b/internal/api/batterypassport/handler.go new file mode 100644 index 0000000000..d5b3e353f4 --- /dev/null +++ b/internal/api/batterypassport/handler.go @@ -0,0 +1,401 @@ +package batterypassport + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/ev-dev-labs/teslasync/internal/api/apiparams" + "github.com/ev-dev-labs/teslasync/internal/api/httpx" + "github.com/ev-dev-labs/teslasync/internal/database" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/rs/zerolog/log" +) + +// bpDataTimeout bounds each analytics read so a stalled connection cannot pin +// the request goroutine longer than the boundary rule allows. The pool's +// server-side statement_timeout is the backstop; this is the client-side +// deadline. A var (not const) so tests can shorten it. +var bpDataTimeout = 15 * time.Second + +// Trend / band tuning. Kept as vars so a test can pin the exact SQL args. +var ( + // trendMinSocSwingPct is the minimum daily SoC swing a day must show to + // yield a usable capacity estimate — small swings amplify sensor noise + // in the energy/SoC ratio. + trendMinSocSwingPct = 20.0 + // trendMaxDays caps the degradation trend so the payload stays bounded + // even for a pack with years of daily history. + trendMaxDays = 180 + // recentCapacitySamples is how many of the most recent daily capacity + // estimates feed the robust (median) headline SoH. + recentCapacitySamples = 8 +) + +// errVehicleNotFound is returned by buildPassport when the vehicle row is +// absent, so the handlers can map it to a 404 rather than a 500. +var errVehicleNotFound = errors.New("vehicle not found") + +// passportLedgerWriteFailuresTotal counts best-effort ledger snapshot writes +// that failed. A ledger-write failure never fails the read — the passport is +// still served — but it is counted here and logged so operators can alert on +// a persistently broken provenance ledger. +var passportLedgerWriteFailuresTotal = promauto.NewCounter(prometheus.CounterOpts{ + Namespace: "teslasync", + Name: "battery_passport_ledger_write_failures_total", + Help: "Battery-passport ledger snapshot writes that failed (the passport read still succeeded).", +}) + +// passportQuerier is the minimal pgx surface the handler needs. Declared +// locally so tests can drive every branch with scripted row/rows sources and +// an Exec seam without a live database or a vendored pgxmock (mirrors +// timemachine.tmQuerier / routeeff.routeQuerier). *pgxpool.Pool satisfies it. +type passportQuerier interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) +} + +// Handler serves the Battery Passport certificate and its verify endpoint. +type Handler struct { + db passportQuerier + now func() time.Time +} + +// NewBatteryPassportHandler wires the handler to the pgx pool. Panics on a nil +// pool — a nil pool is a wiring bug, not a runtime condition, so it surfaces +// at construction rather than as a nil-deref on the first request (mirrors +// routeeff.NewRouteEfficiencyHandler / timemachine.NewTimeMachineHandler). +func NewBatteryPassportHandler(db *database.DB) *Handler { + if db == nil || db.Pool == nil { + panic("batterypassport.NewBatteryPassportHandler: db pool must not be nil") + } + return &Handler{db: db.Pool, now: time.Now} +} + +// --- SQL. Package-level constants so tests can pin the critical clauses +// without a live database. --- + +// vehicleQuery fetches the identity + capacity inputs. ErrNoRows ⇒ 404. +const vehicleQuery = `SELECT vin, model FROM vehicles WHERE id = $1` + +// trendQuery estimates usable pack capacity per day from cagg_battery_daily: +// the day's charged energy (AC + DC cumulative-counter deltas) divided by the +// day's SoC swing. Only days with a meaningful swing and positive charged +// energy qualify. Newest-first with a cap, re-sorted ascending by the caller. +const trendQuery = ` +SELECT day, cap_est_wh FROM ( + SELECT + bucket::date AS day, + (COALESCE(ac_energy_added_wh, 0) + COALESCE(dc_energy_added_wh, 0)) + / NULLIF((max_soc - min_soc) / 100.0, 0) AS cap_est_wh + FROM cagg_battery_daily + WHERE vehicle_id = $1 + AND max_soc IS NOT NULL + AND min_soc IS NOT NULL + AND (max_soc - min_soc) >= $2 + AND (COALESCE(ac_energy_added_wh, 0) + COALESCE(dc_energy_added_wh, 0)) > 0 + ORDER BY bucket DESC + LIMIT $3 +) t +ORDER BY day ASC` + +// chargingQuery rolls up fast-charge share, total charged energy (for +// equivalent full cycles), the average charge ceiling, and the first charge. +const chargingQuery = ` +SELECT + COUNT(*) FILTER (WHERE peak_power_w > $2) AS fast_count, + COUNT(*) AS total_count, + COALESCE(SUM(total_energy_added_wh), 0) AS total_energy_wh, + AVG(end_soc_pct) FILTER (WHERE end_soc_pct IS NOT NULL) AS avg_end_soc, + MIN(started_at) AS first_charge_at +FROM charging_sessions +WHERE vehicle_id = $1` + +// drivesQuery buckets drives by average ambient temperature into thermal +// bands and reports the first observed drive. +const drivesQuery = ` +SELECT + COUNT(*) FILTER (WHERE ambient_temp_c_avg < $2) AS cold_count, + COUNT(*) FILTER (WHERE ambient_temp_c_avg >= $2 AND ambient_temp_c_avg <= $3) AS nominal_count, + COUNT(*) FILTER (WHERE ambient_temp_c_avg > $3) AS hot_count, + MIN(started_at) AS first_drive_at +FROM drives +WHERE vehicle_id = $1` + +// ledgerInsert appends an issued-snapshot row. Best-effort: a failure here is +// logged + counted, never surfaced to the caller. +const ledgerInsert = ` +INSERT INTO tesla_battery_passport_ledger + (vehicle_id, issued_at, soh_pct, equivalent_full_cycles, provenance_hash, payload) +VALUES ($1, $2, $3, $4, $5, $6)` + +// Get serves GET /api/v1/vehicles/{vehicleID}/battery-passport. It builds the +// passport, best-effort appends a provenance-ledger snapshot, and returns the +// certificate JSON. A ledger-write failure never fails the read. +func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { + vehicleID, err := apiparams.URLParamInt64(r, "vehicleID") + if err != nil || vehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "invalid vehicle ID") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), bpDataTimeout) + defer cancel() + + passport, err := h.buildPassport(ctx, vehicleID) + if err != nil { + if errors.Is(err, errVehicleNotFound) { + httpx.WriteError(w, http.StatusNotFound, "vehicle not found") + return + } + log.Error().Err(err).Int64("vehicleID", vehicleID).Msg("battery passport: failed to build passport") + httpx.WriteError(w, http.StatusInternalServerError, "failed to build battery passport") + return + } + + h.recordLedgerSnapshot(ctx, passport) + + httpx.WriteJSON(w, http.StatusOK, passport) +} + +// Verify serves GET /api/v1/vehicles/{vehicleID}/battery-passport/verify. It +// recomputes the current passport hash and reports whether the caller-supplied +// hash still matches (tamper / staleness evidence). Read-only — it never +// writes a ledger snapshot. +func (h *Handler) Verify(w http.ResponseWriter, r *http.Request) { + vehicleID, err := apiparams.URLParamInt64(r, "vehicleID") + if err != nil || vehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "invalid vehicle ID") + return + } + + provided := r.URL.Query().Get("hash") + if provided == "" { + httpx.WriteError(w, http.StatusBadRequest, "hash query parameter required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), bpDataTimeout) + defer cancel() + + passport, err := h.buildPassport(ctx, vehicleID) + if err != nil { + if errors.Is(err, errVehicleNotFound) { + httpx.WriteError(w, http.StatusNotFound, "vehicle not found") + return + } + log.Error().Err(err).Int64("vehicleID", vehicleID).Msg("battery passport: failed to verify passport") + httpx.WriteError(w, http.StatusInternalServerError, "failed to verify battery passport") + return + } + + httpx.WriteJSON(w, http.StatusOK, VerifyResponse{ + Valid: passport.ProvenanceHash == provided, + ExpectedHash: passport.ProvenanceHash, + ProvidedHash: provided, + }) +} + +// buildPassport runs the four reads, folds them through the pure scoring + +// hashing core, and returns the certificate. It never writes. A missing +// vehicle is reported via errVehicleNotFound; any other read failure is +// wrapped for the caller to log + 500. +func (h *Handler) buildPassport(ctx context.Context, vehicleID int64) (*Passport, error) { + // 1. Identity + nameplate capacity. + var vin string + var model *string + if err := h.db.QueryRow(ctx, vehicleQuery, vehicleID).Scan(&vin, &model); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errVehicleNotFound + } + return nil, err + } + modelStr := "" + if model != nil { + modelStr = *model + } + originalWh := EstimateOriginalCapacityWh(vin, modelStr) + + // 2. Degradation trend + robust current capacity/SoH from cagg_battery_daily. + trend, currentCapacityWh, err := h.readTrend(ctx, vehicleID, originalWh) + if err != nil { + return nil, err + } + sohPct := 0.0 + if originalWh > 0 && currentCapacityWh > 0 { + sohPct = clamp(currentCapacityWh/originalWh*100, 0, 100) + } + + // 3. Charging roll-up: fast-charge share, cycles, avg charge ceiling. + var fastCount, totalCount int64 + var totalEnergyWh float64 + var avgEndSoc *float64 + var firstChargeAt *time.Time + if err := h.db.QueryRow(ctx, chargingQuery, vehicleID, dcFastChargeThresholdW). + Scan(&fastCount, &totalCount, &totalEnergyWh, &avgEndSoc, &firstChargeAt); err != nil { + if !errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + } + fastChargeRatio := 0.0 + if totalCount > 0 { + fastChargeRatio = clamp(float64(fastCount)/float64(totalCount), 0, 1) + } + avgChargeLimit := 0.0 + if avgEndSoc != nil { + avgChargeLimit = clamp(*avgEndSoc, 0, 100) + } + equivalentFullCycles := 0.0 + if originalWh > 0 { + equivalentFullCycles = totalEnergyWh / originalWh + } + + // 4. Drives roll-up: thermal exposure bands + first observed drive. + var coldCount, nominalCount, hotCount int64 + var firstDriveAt *time.Time + if err := h.db.QueryRow(ctx, drivesQuery, vehicleID, thermalColdMaxC, thermalHotMinC). + Scan(&coldCount, &nominalCount, &hotCount, &firstDriveAt); err != nil { + if !errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + } + thermal := ThermalExposureFrom(coldCount, nominalCount, hotCount) + + // Fold + round to the canonical numeric form used by BOTH the JSON body + // and the hashed facts. + firstObserved := earliest(firstChargeAt, firstDriveAt) + sohR := round1(sohPct) + capacityKwhR := round2(currentCapacityWh / 1000.0) + originalKwhR := round1(originalWh / 1000.0) + cyclesR := round1(equivalentFullCycles) + fastRatioR := round4(fastChargeRatio) + avgLimitR := round1(avgChargeLimit) + + issuedAt := h.now().UTC() + facts := PassportCoreFacts{ + VehicleID: vehicleID, + FirstObservedAt: firstObserved, + SohPct: sohR, + CapacityKwh: capacityKwhR, + EquivalentFullCycles: cyclesR, + FastChargeRatio: fastRatioR, + IssuedAt: issuedAt, + } + + grade := gradeUnknown + if sohR > 0 { + grade = Grade(sohR, fastRatioR, cyclesR) + } + + return &Passport{ + VehicleID: vehicleID, + VinMasked: MaskVIN(vin), + IssuedAt: issuedAt.Format(time.RFC3339), + FirstObservedAt: formatTimePtr(firstObserved), + SohPct: sohR, + CapacityKwh: capacityKwhR, + OriginalCapacityKwh: originalKwhR, + EquivalentFullCycles: cyclesR, + FastChargeRatio: fastRatioR, + AvgChargeLimitPct: avgLimitR, + ThermalExposure: thermal, + HealthGrade: grade, + DegradationTrend: trend, + Recommendations: Recommendations(sohR, fastRatioR, avgLimitR, thermal.HotPct, cyclesR), + ProvenanceHash: ProvenanceHash(facts), + }, nil +} + +// readTrend reads the daily capacity estimates, returns the ascending +// SoH-per-day trend plus the robust (median-of-recent) current capacity in Wh. +func (h *Handler) readTrend(ctx context.Context, vehicleID int64, originalWh float64) ([]TrendPoint, float64, error) { + rows, err := h.db.Query(ctx, trendQuery, vehicleID, trendMinSocSwingPct, trendMaxDays) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + trend := make([]TrendPoint, 0, 32) + caps := make([]float64, 0, 32) + for rows.Next() { + var day time.Time + var capWh *float64 + if err := rows.Scan(&day, &capWh); err != nil { + return nil, 0, err + } + if capWh == nil || *capWh <= 0 { + continue + } + caps = append(caps, *capWh) + soh := 0.0 + if originalWh > 0 { + soh = clamp(*capWh/originalWh*100, 0, 100) + } + trend = append(trend, TrendPoint{ + Date: day.UTC().Format("2006-01-02"), + SohPct: round1(soh), + }) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + + // Robust current capacity: median of the most recent samples so a single + // noisy day cannot move the headline number. + recent := caps + if len(recent) > recentCapacitySamples { + recent = recent[len(recent)-recentCapacitySamples:] + } + return trend, medianWh(recent), nil +} + +// recordLedgerSnapshot best-effort appends the issued passport to the +// provenance ledger. Any failure — marshal error or DB error — is logged and +// counted but never propagated: the read has already succeeded. +func (h *Handler) recordLedgerSnapshot(ctx context.Context, p *Passport) { + payload, err := json.Marshal(p) + if err != nil { + passportLedgerWriteFailuresTotal.Inc() + log.Error().Err(err).Int64("vehicleID", p.VehicleID).Msg("battery passport: failed to marshal ledger payload") + return + } + if _, err := h.db.Exec(ctx, ledgerInsert, + p.VehicleID, h.now().UTC(), p.SohPct, p.EquivalentFullCycles, p.ProvenanceHash, payload); err != nil { + passportLedgerWriteFailuresTotal.Inc() + log.Warn().Err(err).Int64("vehicleID", p.VehicleID).Msg("battery passport: failed to write ledger snapshot (passport still served)") + } +} + +// earliest returns the earlier of two nullable timestamps, or the zero time +// when both are nil. +func earliest(a, b *time.Time) time.Time { + switch { + case a == nil && b == nil: + return time.Time{} + case a == nil: + return *b + case b == nil: + return *a + case a.Before(*b): + return *a + default: + return *b + } +} + +// formatTimePtr renders a nullable/zero instant as a nullable RFC 3339 UTC +// string so the JSON carries null (not a zero-time string) when the vehicle +// has no observed history yet. +func formatTimePtr(t time.Time) *string { + if t.IsZero() { + return nil + } + s := t.UTC().Format(time.RFC3339) + return &s +} diff --git a/internal/api/batterypassport/handler_test.go b/internal/api/batterypassport/handler_test.go new file mode 100644 index 0000000000..d809974a63 --- /dev/null +++ b/internal/api/batterypassport/handler_test.go @@ -0,0 +1,661 @@ +package batterypassport + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "regexp" + "testing" + "time" + + "github.com/ev-dev-labs/teslasync/internal/database" + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + dto "github.com/prometheus/client_model/go" + "github.com/rs/zerolog" +) + +// TestMain silences the global zerolog logger so the intentional error-path +// logs don't clutter test output. Set once before any test runs. +func TestMain(m *testing.M) { + zerolog.SetGlobalLevel(zerolog.Disabled) + m.Run() +} + +// --------------------------------------------------------------------------- +// Fake pgx plumbing. The module vendors no pgxmock (see routeeff / timemachine +// / adapter-postgres for the same precedent); the handler talks to a local +// passportQuerier seam so tests supply scripted row/rows/exec sources without +// a live database. +// --------------------------------------------------------------------------- + +// assignScan copies scripted column values into Scan destinations, mirroring +// pgx's per-type scanning generically via reflection. Unlike the routeeff +// helper it also allocates when the destination is a nullable pointer field +// (a non-nil scalar scanned into a *T), which the passport handler relies on +// for nullable aggregates (avg_end_soc, first_charge_at, …). +func assignScan(dest, vals []any) error { + if len(dest) != len(vals) { + return fmt.Errorf("scan: %d destinations but row has %d values", len(dest), len(vals)) + } + for i := range dest { + dv := reflect.ValueOf(dest[i]) + if dv.Kind() != reflect.Pointer || dv.IsNil() { + return fmt.Errorf("scan: destination %d is not a non-nil pointer (%T)", i, dest[i]) + } + target := dv.Elem() + if !target.CanSet() { + return fmt.Errorf("scan: destination %d (%s) is not settable", i, target.Type()) + } + v := vals[i] + if v == nil { + target.Set(reflect.Zero(target.Type())) // nil pointer / zero value + continue + } + rv := reflect.ValueOf(v) + if target.Kind() == reflect.Pointer { + // Nullable field: allocate a *T and store the scalar. + et := target.Type().Elem() + switch { + case rv.Type().AssignableTo(et): + p := reflect.New(et) + p.Elem().Set(rv) + target.Set(p) + case rv.Type().ConvertibleTo(et): + p := reflect.New(et) + p.Elem().Set(rv.Convert(et)) + target.Set(p) + default: + return fmt.Errorf("scan: cannot assign %T into nullable destination %d (%s)", v, i, target.Type()) + } + continue + } + switch { + case rv.Type().AssignableTo(target.Type()): + target.Set(rv) + case rv.Type().ConvertibleTo(target.Type()): + target.Set(rv.Convert(target.Type())) + default: + return fmt.Errorf("scan: cannot assign %T into destination %d (%s)", v, i, target.Type()) + } + } + return nil +} + +// fakeRow is a scripted pgx.Row. vals populate the Scan destinations, or err +// exercises the not-found / scan-failure branches. +type fakeRow struct { + vals []any + err error +} + +func (r fakeRow) Scan(dest ...any) error { + if r.err != nil { + return r.err + } + return assignScan(dest, r.vals) +} + +var _ pgx.Row = fakeRow{} + +// fakeRows is a scripted pgx.Rows for the degradation-trend Query. +type fakeRows struct { + data [][]any + idx int + scanErr error + scanErrAt int + iterErr error + closed bool +} + +func (r *fakeRows) Next() bool { + if r.idx >= len(r.data) { + return false + } + r.idx++ + return true +} + +func (r *fakeRows) Scan(dest ...any) error { + if r.scanErr != nil && r.idx == r.scanErrAt { + return r.scanErr + } + return assignScan(dest, r.data[r.idx-1]) +} + +func (r *fakeRows) Close() { r.closed = true } +func (r *fakeRows) Err() error { return r.iterErr } +func (r *fakeRows) CommandTag() pgconn.CommandTag { return pgconn.CommandTag{} } +func (r *fakeRows) FieldDescriptions() []pgconn.FieldDescription { return nil } +func (r *fakeRows) Values() ([]any, error) { return nil, nil } +func (r *fakeRows) RawValues() [][]byte { return nil } +func (r *fakeRows) Conn() *pgx.Conn { return nil } + +var _ pgx.Rows = (*fakeRows)(nil) + +// fakePool returns scripted QueryRow results in call order (the handler issues +// vehicle → charging → drives), scripted Query rows for the trend, and a +// scripted Exec result for the ledger write. It records invocations. +type fakePool struct { + queryRowResults []pgx.Row + queryRowIdx int + + rows pgx.Rows + queryErr error + + tag pgconn.CommandTag + execErr error + + queryRowSQLs [][]any // captured args per QueryRow call + querySQL string + queryArgs []any + execArgs []any + execN int +} + +func (p *fakePool) Query(_ context.Context, sql string, args ...any) (pgx.Rows, error) { + p.querySQL = sql + p.queryArgs = args + if p.queryErr != nil { + return nil, p.queryErr + } + return p.rows, nil +} + +func (p *fakePool) QueryRow(_ context.Context, _ string, args ...any) pgx.Row { + p.queryRowSQLs = append(p.queryRowSQLs, args) + if p.queryRowIdx >= len(p.queryRowResults) { + return fakeRow{err: errors.New("fakePool: unexpected QueryRow call")} + } + row := p.queryRowResults[p.queryRowIdx] + p.queryRowIdx++ + return row +} + +func (p *fakePool) Exec(_ context.Context, _ string, args ...any) (pgconn.CommandTag, error) { + p.execN++ + p.execArgs = args + if p.execErr != nil { + return pgconn.CommandTag{}, p.execErr + } + return p.tag, nil +} + +var _ passportQuerier = (*fakePool)(nil) + +// --------------------------------------------------------------------------- +// Fixtures + helpers +// --------------------------------------------------------------------------- + +var fixedNow = time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC) + +const ( + testVIN = "5YJ3E1EK7KF123456" // 8th char 'K' → 75 kWh nameplate + testModelNm = "Model 3" +) + +func testHandler(pool passportQuerier) *Handler { + return &Handler{db: pool, now: func() time.Time { return fixedNow }} +} + +// happyPool scripts a fully-populated, deterministic vehicle: three trend days +// around ~70 kWh usable on a 75 kWh pack, a 25%-fast-charge history, and a +// mixed thermal profile. Returns a FRESH pool each call so it can back two +// independent requests (Get then Verify). +func happyPool() *fakePool { + model := testModelNm + return &fakePool{ + queryRowResults: []pgx.Row{ + // vehicle: vin, model + fakeRow{vals: []any{testVIN, model}}, + // charging: fast_count, total_count, total_energy_wh, avg_end_soc, first_charge_at + fakeRow{vals: []any{int64(10), int64(40), 1_500_000.0, 82.0, mustTime("2023-02-01T00:00:00Z")}}, + // drives: cold, nominal, hot, first_drive_at + fakeRow{vals: []any{int64(5), int64(30), int64(5), mustTime("2023-01-15T00:00:00Z")}}, + }, + rows: &fakeRows{data: [][]any{ + {mustTime("2023-03-01T00:00:00Z"), 70000.0}, + {mustTime("2023-04-01T00:00:00Z"), 69000.0}, + {mustTime("2023-05-01T00:00:00Z"), 68000.0}, + }}, + tag: pgconn.NewCommandTag("INSERT 0 1"), + } +} + +func mustTime(s string) time.Time { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + panic(err) + } + return t +} + +func getReq(id string) *http.Request { + return reqWithParam(httptest.NewRequest(http.MethodGet, "/vehicles/"+id+"/battery-passport", nil), id) +} + +func verifyReq(id, query string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/vehicles/"+id+"/battery-passport/verify?"+query, nil) + return reqWithParam(r, id) +} + +func reqWithParam(r *http.Request, id string) *http.Request { + rctx := chi.NewRouteContext() + rctx.URLParams.Add("vehicleID", id) + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) +} + +func decodeErr(t *testing.T, rec *httptest.ResponseRecorder) map[string]string { + t.Helper() + var m map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { + t.Fatalf("decode error body: %v (body=%q)", err, rec.Body.String()) + } + return m +} + +func decodePassport(t *testing.T, rec *httptest.ResponseRecorder) Passport { + t.Helper() + var p Passport + if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil { + t.Fatalf("decode passport: %v (body=%q)", err, rec.Body.String()) + } + return p +} + +var hexRe = regexp.MustCompile(`^[0-9a-f]{64}$`) + +// ledgerFailureCount reads the current value of the best-effort ledger-write +// failure counter without pulling in the prometheus testutil module (which +// would add a dependency); client_model is already a direct require. +func ledgerFailureCount(t *testing.T) float64 { + t.Helper() + var m dto.Metric + if err := passportLedgerWriteFailuresTotal.Write(&m); err != nil { + t.Fatalf("read ledger failure counter: %v", err) + } + return m.GetCounter().GetValue() +} + +// --------------------------------------------------------------------------- +// Constructor +// --------------------------------------------------------------------------- + +func TestNewBatteryPassportHandler_NilDBPanics(t *testing.T) { + t.Parallel() + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic constructing handler with a nil *database.DB") + } + }() + _ = NewBatteryPassportHandler(nil) +} + +func TestNewBatteryPassportHandler_NilPoolPanics(t *testing.T) { + t.Parallel() + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic constructing handler with a nil pool") + } + }() + _ = NewBatteryPassportHandler(&database.DB{}) +} + +// --------------------------------------------------------------------------- +// Get — validation (no data layer reached) +// --------------------------------------------------------------------------- + +func TestGet_InvalidVehicleID(t *testing.T) { + t.Parallel() + for _, id := range []string{"", "abc", "0", "-5", "99999999999999999999"} { + id := id + t.Run("id="+id, func(t *testing.T) { + t.Parallel() + pool := happyPool() + rec := httptest.NewRecorder() + testHandler(pool).Get(rec, getReq(id)) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body=%q)", rec.Code, rec.Body.String()) + } + if len(pool.queryRowSQLs) != 0 { + t.Errorf("data layer reached (%d QueryRow calls) on invalid input", len(pool.queryRowSQLs)) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Get — data-layer error paths +// --------------------------------------------------------------------------- + +func TestGet_VehicleNotFound(t *testing.T) { + t.Parallel() + pool := &fakePool{queryRowResults: []pgx.Row{fakeRow{err: pgx.ErrNoRows}}} + rec := httptest.NewRecorder() + testHandler(pool).Get(rec, getReq("42")) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (body=%q)", rec.Code, rec.Body.String()) + } + if got := decodeErr(t, rec)["error"]; got != "vehicle not found" { + t.Errorf("error = %q, want %q", got, "vehicle not found") + } + if pool.execN != 0 { + t.Errorf("ledger written (%d Exec) despite 404", pool.execN) + } +} + +func TestGet_ErrorPaths(t *testing.T) { + t.Parallel() + boom := errors.New("boom") + model := testModelNm + vehicle := fakeRow{vals: []any{testVIN, model}} + + tests := []struct { + name string + pool *fakePool + }{ + { + name: "vehicle query error", + pool: &fakePool{queryRowResults: []pgx.Row{fakeRow{err: boom}}}, + }, + { + name: "trend query error", + pool: &fakePool{queryRowResults: []pgx.Row{vehicle}, queryErr: boom}, + }, + { + name: "trend scan error", + pool: &fakePool{ + queryRowResults: []pgx.Row{vehicle}, + rows: &fakeRows{data: [][]any{{mustTime("2023-03-01T00:00:00Z"), 70000.0}}, scanErr: boom, scanErrAt: 1}, + }, + }, + { + name: "trend iteration error", + pool: &fakePool{ + queryRowResults: []pgx.Row{vehicle}, + rows: &fakeRows{data: [][]any{{mustTime("2023-03-01T00:00:00Z"), 70000.0}}, iterErr: boom}, + }, + }, + { + name: "charging query error", + pool: &fakePool{ + queryRowResults: []pgx.Row{vehicle, fakeRow{err: boom}}, + rows: &fakeRows{}, + }, + }, + { + name: "drives query error", + pool: &fakePool{ + queryRowResults: []pgx.Row{ + vehicle, + fakeRow{vals: []any{int64(1), int64(4), 100000.0, 80.0, nil}}, + fakeRow{err: boom}, + }, + rows: &fakeRows{}, + }, + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + testHandler(tc.pool).Get(rec, getReq("42")) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 (body=%q)", rec.Code, rec.Body.String()) + } + if got := decodeErr(t, rec)["error"]; got != "failed to build battery passport" { + t.Errorf("error = %q, want build-failure message", got) + } + if tc.pool.execN != 0 { + t.Errorf("ledger written (%d Exec) despite 500", tc.pool.execN) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Get — happy path +// --------------------------------------------------------------------------- + +func TestGet_HappyPath(t *testing.T) { + t.Parallel() + pool := happyPool() + rec := httptest.NewRecorder() + testHandler(pool).Get(rec, getReq("42")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%q)", rec.Code, rec.Body.String()) + } + p := decodePassport(t, rec) + + if p.VehicleID != 42 { + t.Errorf("vehicle_id = %d, want 42", p.VehicleID) + } + if p.VinMasked != MaskVIN(testVIN) { + t.Errorf("vin_masked = %q, want %q", p.VinMasked, MaskVIN(testVIN)) + } + if p.OriginalCapacityKwh != 75 { + t.Errorf("original_capacity_kwh = %v, want 75", p.OriginalCapacityKwh) + } + // median of recent [70000,69000,68000] = 69000 Wh → 69 kWh, SoH 92%. + if p.CapacityKwh != 69 { + t.Errorf("capacity_kwh = %v, want 69", p.CapacityKwh) + } + if p.SohPct != 92 { + t.Errorf("soh_pct = %v, want 92", p.SohPct) + } + // fast_charge_ratio = 10/40 = 0.25. + if p.FastChargeRatio != 0.25 { + t.Errorf("fast_charge_ratio = %v, want 0.25", p.FastChargeRatio) + } + if p.AvgChargeLimitPct != 82 { + t.Errorf("avg_charge_limit_pct = %v, want 82", p.AvgChargeLimitPct) + } + // equivalent_full_cycles = 1_500_000 / 75_000 = 20. + if p.EquivalentFullCycles != 20 { + t.Errorf("equivalent_full_cycles = %v, want 20", p.EquivalentFullCycles) + } + // thermal: 5/40, 30/40, 5/40. + if p.ThermalExposure.NominalPct != 75 || p.ThermalExposure.ColdPct != 12.5 || p.ThermalExposure.HotPct != 12.5 { + t.Errorf("thermal_exposure = %+v, want {12.5 75 12.5}", p.ThermalExposure) + } + // Grade(92, 0.25, 20) = 92 - 2 - 0.16 = ~89.84 → "B". + if p.HealthGrade != "B" { + t.Errorf("health_grade = %q, want B", p.HealthGrade) + } + if len(p.DegradationTrend) != 3 { + t.Fatalf("degradation_trend len = %d, want 3", len(p.DegradationTrend)) + } + if p.DegradationTrend[0].Date != "2023-03-01" { + t.Errorf("trend[0].date = %q, want 2023-03-01", p.DegradationTrend[0].Date) + } + if len(p.Recommendations) == 0 { + t.Error("recommendations empty") + } + if !hexRe.MatchString(p.ProvenanceHash) { + t.Errorf("provenance_hash = %q, want 64 hex chars", p.ProvenanceHash) + } + if p.FirstObservedAt == nil || *p.FirstObservedAt != "2023-01-15T00:00:00Z" { + t.Errorf("first_observed_at = %v, want earliest drive 2023-01-15", p.FirstObservedAt) + } + if p.IssuedAt != fixedNow.Format(time.RFC3339) { + t.Errorf("issued_at = %q, want %q", p.IssuedAt, fixedNow.Format(time.RFC3339)) + } + + // Ledger snapshot appended once, keyed by vehicle_id. + if pool.execN != 1 { + t.Errorf("Exec calls = %d, want 1 (ledger snapshot)", pool.execN) + } + if len(pool.execArgs) < 1 || pool.execArgs[0] != int64(42) { + t.Errorf("ledger vehicle_id arg = %v, want 42", pool.execArgs) + } + // Trend cursor closed (no leak). + if fr, ok := pool.rows.(*fakeRows); ok && !fr.closed { + t.Error("trend rows.Close() not called") + } +} + +func TestGet_EmptyHistory(t *testing.T) { + t.Parallel() + // A vehicle with no cagg/charging/drive history: every section still + // renders, SoH unknown ⇒ grade N/A, trend empty, hash still stable. + pool := &fakePool{ + queryRowResults: []pgx.Row{ + fakeRow{vals: []any{testVIN, testModelNm}}, + fakeRow{vals: []any{int64(0), int64(0), 0.0, nil, nil}}, + fakeRow{vals: []any{int64(0), int64(0), int64(0), nil}}, + }, + rows: &fakeRows{}, + tag: pgconn.NewCommandTag("INSERT 0 1"), + } + rec := httptest.NewRecorder() + testHandler(pool).Get(rec, getReq("7")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%q)", rec.Code, rec.Body.String()) + } + p := decodePassport(t, rec) + if p.SohPct != 0 { + t.Errorf("soh_pct = %v, want 0 (no data)", p.SohPct) + } + if p.HealthGrade != gradeUnknown { + t.Errorf("health_grade = %q, want %q", p.HealthGrade, gradeUnknown) + } + if p.DegradationTrend == nil { + t.Error("degradation_trend is nil, want empty slice") + } + if len(p.DegradationTrend) != 0 { + t.Errorf("degradation_trend len = %d, want 0", len(p.DegradationTrend)) + } + if p.FirstObservedAt != nil { + t.Errorf("first_observed_at = %v, want null", *p.FirstObservedAt) + } + if !hexRe.MatchString(p.ProvenanceHash) { + t.Errorf("provenance_hash = %q, want 64 hex chars", p.ProvenanceHash) + } + if len(p.Recommendations) == 0 { + t.Error("recommendations empty; want the healthy fallback note") + } +} + +// --------------------------------------------------------------------------- +// Get — ledger write is best-effort (never fails the read) +// --------------------------------------------------------------------------- + +func TestGet_LedgerWriteFailureStillServes(t *testing.T) { + // Not parallel: asserts a delta on the global failure counter. + before := ledgerFailureCount(t) + + pool := happyPool() + pool.execErr = errors.New("ledger unavailable") + rec := httptest.NewRecorder() + testHandler(pool).Get(rec, getReq("42")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 despite ledger failure (body=%q)", rec.Code, rec.Body.String()) + } + if p := decodePassport(t, rec); !hexRe.MatchString(p.ProvenanceHash) { + t.Errorf("passport not served on ledger failure: %q", p.ProvenanceHash) + } + if pool.execN != 1 { + t.Errorf("Exec calls = %d, want 1 (attempted)", pool.execN) + } + after := ledgerFailureCount(t) + if after-before != 1 { + t.Errorf("ledger failure counter delta = %v, want 1", after-before) + } +} + +// --------------------------------------------------------------------------- +// Verify +// --------------------------------------------------------------------------- + +func TestVerify_InvalidVehicleID(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + testHandler(happyPool()).Verify(rec, verifyReq("abc", "hash=deadbeef")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body=%q)", rec.Code, rec.Body.String()) + } +} + +func TestVerify_MissingHash(t *testing.T) { + t.Parallel() + pool := happyPool() + rec := httptest.NewRecorder() + testHandler(pool).Verify(rec, verifyReq("42", "")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body=%q)", rec.Code, rec.Body.String()) + } + if got := decodeErr(t, rec)["error"]; got != "hash query parameter required" { + t.Errorf("error = %q, want hash-required message", got) + } + if len(pool.queryRowSQLs) != 0 { + t.Error("data layer reached before hash validation") + } +} + +func TestVerify_VehicleNotFound(t *testing.T) { + t.Parallel() + pool := &fakePool{queryRowResults: []pgx.Row{fakeRow{err: pgx.ErrNoRows}}} + rec := httptest.NewRecorder() + testHandler(pool).Verify(rec, verifyReq("42", "hash=abc")) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (body=%q)", rec.Code, rec.Body.String()) + } +} + +func TestVerify_RoundTrip(t *testing.T) { + t.Parallel() + // First issue a passport to obtain the current provenance hash… + getRec := httptest.NewRecorder() + testHandler(happyPool()).Get(getRec, getReq("42")) + issued := decodePassport(t, getRec) + + // …then a matching hash verifies valid, and never writes a ledger row. + verifyPool := happyPool() + okRec := httptest.NewRecorder() + testHandler(verifyPool).Verify(okRec, verifyReq("42", "hash="+issued.ProvenanceHash)) + if okRec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%q)", okRec.Code, okRec.Body.String()) + } + var okBody VerifyResponse + if err := json.Unmarshal(okRec.Body.Bytes(), &okBody); err != nil { + t.Fatalf("decode verify: %v", err) + } + if !okBody.Valid { + t.Errorf("valid = false for matching hash; expected=%q provided=%q", okBody.ExpectedHash, okBody.ProvidedHash) + } + if okBody.ExpectedHash != issued.ProvenanceHash { + t.Errorf("expected_hash = %q, want %q", okBody.ExpectedHash, issued.ProvenanceHash) + } + if verifyPool.execN != 0 { + t.Errorf("verify wrote a ledger row (%d Exec); it must be read-only", verifyPool.execN) + } + + // A tampered hash is reported invalid (but expected is still returned). + badRec := httptest.NewRecorder() + testHandler(happyPool()).Verify(badRec, verifyReq("42", "hash=deadbeefdeadbeef")) + var badBody VerifyResponse + if err := json.Unmarshal(badRec.Body.Bytes(), &badBody); err != nil { + t.Fatalf("decode verify: %v", err) + } + if badBody.Valid { + t.Error("valid = true for a tampered hash") + } + if badBody.ProvidedHash != "deadbeefdeadbeef" { + t.Errorf("provided_hash = %q, want the tampered value echoed back", badBody.ProvidedHash) + } + if badBody.ExpectedHash != issued.ProvenanceHash { + t.Errorf("expected_hash = %q, want %q", badBody.ExpectedHash, issued.ProvenanceHash) + } +} diff --git a/internal/api/batterypassport/scoring.go b/internal/api/batterypassport/scoring.go new file mode 100644 index 0000000000..7750a42049 --- /dev/null +++ b/internal/api/batterypassport/scoring.go @@ -0,0 +1,258 @@ +package batterypassport + +import ( + "crypto/sha256" + "encoding/hex" + "math" + "sort" + "strconv" + "strings" + "time" +) + +// --------------------------------------------------------------------------- +// Pure, unit-testable core. Nothing in this file touches the database, the +// clock, or the network — every function is a deterministic transform of its +// inputs so the provenance hash, the health grade, and the recommendations +// are reproducible and independently testable. +// --------------------------------------------------------------------------- + +// Thresholds and weights. Kept as named constants so the scoring is +// documented and a test can pin the exact boundaries. +const ( + // dcFastChargeThresholdW: charging above this instantaneous power is + // treated as DC fast-charging (Superchargers / DC rapids). Mirrors the + // established batterydegradation heuristic (peak_power_w > 50 kW). + dcFastChargeThresholdW = 50000.0 + + // Thermal bands (deg C) applied to a drive's average ambient temperature. + thermalColdMaxC = 10.0 + thermalHotMinC = 30.0 + + // ratedFullCycles is the nominal cycle life a modern EV pack is designed + // for; cycle wear penalty saturates here. + ratedFullCycles = 1500.0 + + // Grade-score penalty weights (points subtracted from SoH). + fastChargePenaltyPts = 8.0 + cyclePenaltyPts = 12.0 + + // canonicalVersion namespaces the hash so a future change to the + // serialization cannot silently collide with an old digest. + canonicalVersion = "tsbp-v1" + + // gradeUnknown is returned when there is not enough SoH data to grade. + gradeUnknown = "N/A" +) + +// clamp bounds v to [lo, hi]. +func clamp(v, lo, hi float64) float64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// round1/round2/round4 round to a fixed number of decimals so the JSON body +// and the hashed facts share one canonical numeric form. +func round1(v float64) float64 { return math.Round(v*10) / 10 } +func round2(v float64) float64 { return math.Round(v*100) / 100 } +func round4(v float64) float64 { return math.Round(v*10000) / 10000 } + +// GradeScore reduces the three durability signals to a single 0..100 score. +// +// SoH is the dominant term. Two documented penalties are subtracted: +// - fast-charge penalty: up to fastChargePenaltyPts, scaling linearly with +// the DC-fast-charge ratio (0..1). Sustained DC fast-charging accelerates +// capacity fade, so a pack that has only ever fast-charged loses the full +// penalty. +// - cycle penalty: up to cyclePenaltyPts, scaling linearly with equivalent +// full cycles up to ratedFullCycles, then saturating. A pack near its +// rated cycle life carries the full penalty. +// +// The result is clamped to [0, 100]. Pure and side-effect free. +func GradeScore(sohPct, fastChargeRatio, equivalentFullCycles float64) float64 { + soh := clamp(sohPct, 0, 100) + fastPenalty := clamp(fastChargeRatio, 0, 1) * fastChargePenaltyPts + cyclePenalty := clamp(equivalentFullCycles/ratedFullCycles, 0, 1) * cyclePenaltyPts + return clamp(soh-fastPenalty-cyclePenalty, 0, 100) +} + +// Grade maps GradeScore to a letter grade A..F using fixed 10-point bands. +// A pure function so the boundaries are testable in isolation. +func Grade(sohPct, fastChargeRatio, equivalentFullCycles float64) string { + score := GradeScore(sohPct, fastChargeRatio, equivalentFullCycles) + switch { + case score >= 90: + return "A" + case score >= 80: + return "B" + case score >= 70: + return "C" + case score >= 60: + return "D" + case score >= 50: + return "E" + default: + return "F" + } +} + +// MaskVIN reveals the manufacturer WMI (first three characters) and the last +// four characters of the VIN, masking the unique serial in between. A resale +// buyer can cross-reference the plant/region and the tail without the full +// identifier being exposed on a shareable certificate. VINs too short to +// safely reveal are fully masked. +func MaskVIN(vin string) string { + v := strings.ToUpper(strings.TrimSpace(vin)) + n := len(v) + if n == 0 { + return "" + } + if n <= 7 { + return strings.Repeat("*", n) + } + return v[:3] + strings.Repeat("*", n-7) + v[n-4:] +} + +// ThermalExposureFrom converts drive counts per temperature band into +// percentages that sum to 100 (within rounding) when there is at least one +// temperature-carrying drive. With no data every band is 0. +func ThermalExposureFrom(cold, nominal, hot int64) ThermalExposure { + total := cold + nominal + hot + if total <= 0 { + return ThermalExposure{} + } + tf := float64(total) + return ThermalExposure{ + ColdPct: round1(float64(cold) / tf * 100), + NominalPct: round1(float64(nominal) / tf * 100), + HotPct: round1(float64(hot) / tf * 100), + } +} + +// EstimateOriginalCapacityWh returns the pack's nameplate energy in Wh from +// the VIN's model-year/trim code, falling back to model name then a 75 kWh +// default. A local copy of the batterydegradation helper (the carve playbook +// duplicates small stranded helpers rather than importing another handler). +func EstimateOriginalCapacityWh(vin, model string) float64 { + if len(vin) >= 8 { + switch vin[7] { + case 'E', 'F': + return 60000.0 + case 'K', 'L', 'M': + return 75000.0 + case 'S', 'A', 'P': + return 100000.0 + } + } + m := strings.ToLower(model) + if strings.Contains(m, "model s") || strings.Contains(m, "model x") { + return 100000.0 + } + return 75000.0 +} + +// medianWh returns the median of a capacity-estimate sample. The median is +// used (rather than the mean or the single latest reading) so one noisy +// day — a partial charge, a mixed drive-and-charge day — cannot swing the +// headline SoH. Returns 0 for an empty sample. +func medianWh(samples []float64) float64 { + if len(samples) == 0 { + return 0 + } + cp := make([]float64, len(samples)) + copy(cp, samples) + sort.Float64s(cp) + mid := len(cp) / 2 + if len(cp)%2 == 1 { + return cp[mid] + } + return (cp[mid-1] + cp[mid]) / 2 +} + +// Recommendations derives buyer-facing guidance from the passport metrics. +// Deterministic and ordered by severity so the certificate reads +// consistently. Always returns a non-nil slice. +func Recommendations(sohPct, fastChargeRatio, avgChargeLimitPct, thermalHotPct, equivalentFullCycles float64) []string { + recs := make([]string, 0, 5) + + if sohPct > 0 && sohPct < 80 { + recs = append(recs, "State-of-Health has crossed the 80% warranty threshold; commission an independent battery inspection before resale.") + } + if fastChargeRatio > 0.5 { + recs = append(recs, "DC fast-charging dominates this pack's history; favour AC charging where possible to slow capacity fade.") + } + if avgChargeLimitPct > 90 { + recs = append(recs, "Average charge limit is high; capping daily charges near 80% reduces calendar aging.") + } + if thermalHotPct > 30 { + recs = append(recs, "Frequent high-temperature operation detected; precondition and park in shade to limit thermal stress.") + } + if equivalentFullCycles > ratedFullCycles { + recs = append(recs, "Equivalent full cycles exceed the pack's rated cycle life; factor expected end-of-life into valuation.") + } + if len(recs) == 0 { + recs = append(recs, "Battery health and usage are within healthy bounds; maintain current charging habits.") + } + return recs +} + +// PassportCoreFacts is the immutable subset of the passport that the +// provenance hash binds. IssuedAt is rounded to the day by the canonicalizer +// so repeated reads on the same day (with unchanged history) reproduce the +// same digest. +type PassportCoreFacts struct { + VehicleID int64 + FirstObservedAt time.Time + SohPct float64 + CapacityKwh float64 + EquivalentFullCycles float64 + FastChargeRatio float64 + IssuedAt time.Time +} + +// dayUTC formats an instant as its UTC calendar day. A zero time yields the +// Go zero day ("0001-01-01"), which is still deterministic — a vehicle with +// no observed history hashes to a stable value. +func dayUTC(t time.Time) string { + return t.UTC().Format("2006-01-02") +} + +// f4 renders a float with fixed 4-decimal precision, normalizing -0 to 0 so +// the canonical form never depends on the sign bit of a zero. +func f4(v float64) string { + if v == 0 { + v = 0 + } + return strconv.FormatFloat(v, 'f', 4, 64) +} + +// CanonicalString serializes the core facts into a single deterministic, +// order-fixed line. Field order, separators, day-granularity timestamps, and +// fixed numeric precision are all pinned here so the same facts always +// produce the same bytes across processes and runs. Pure. +func CanonicalString(f PassportCoreFacts) string { + parts := []string{ + canonicalVersion, + "vehicle_id=" + strconv.FormatInt(f.VehicleID, 10), + "first_observed_at=" + dayUTC(f.FirstObservedAt), + "soh_pct=" + f4(f.SohPct), + "capacity_kwh=" + f4(f.CapacityKwh), + "equivalent_full_cycles=" + f4(f.EquivalentFullCycles), + "fast_charge_ratio=" + f4(f.FastChargeRatio), + "issued_at=" + dayUTC(f.IssuedAt), + } + return strings.Join(parts, "|") +} + +// ProvenanceHash is the lowercase hex SHA-256 of the canonical serialization. +// Stable across runs for identical inputs; the sole binding between the +// human-readable certificate and its tamper-evidence check. Pure. +func ProvenanceHash(f PassportCoreFacts) string { + sum := sha256.Sum256([]byte(CanonicalString(f))) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/api/batterypassport/scoring_test.go b/internal/api/batterypassport/scoring_test.go new file mode 100644 index 0000000000..c2b49d5cb1 --- /dev/null +++ b/internal/api/batterypassport/scoring_test.go @@ -0,0 +1,359 @@ +package batterypassport + +import ( + "fmt" + "regexp" + "strings" + "testing" + "time" +) + +// --------------------------------------------------------------------------- +// GradeScore + Grade — the pure durability scoring. +// --------------------------------------------------------------------------- + +func TestGradeScore(t *testing.T) { + t.Parallel() + tests := []struct { + name string + soh float64 + fastRatio float64 + cycles float64 + want float64 + }{ + {"pristine, no penalties", 100, 0, 0, 100}, + {"soh clamps above 100", 200, 0, 0, 100}, + {"soh clamps below 0", -50, 0, 0, 0}, + {"full fast + full cycle penalty", 100, 1, 1500, 80}, // 100 - 8 - 12 + {"half fast + half cycle penalty", 100, 0.5, 750, 90}, // 100 - 4 - 6 + {"cycle penalty saturates past rated", 100, 0, 3000, 88}, // 100 - 12 + {"fast ratio clamps to 1", 100, 5, 0, 92}, // 100 - 8 + {"combined drags to 30", 50, 1, 3000, 30}, // 50 - 8 - 12 + {"already zero stays zero", 0, 1, 1500, 0}, // clamps at 0 + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := GradeScore(tc.soh, tc.fastRatio, tc.cycles); got != tc.want { + t.Errorf("GradeScore(%v,%v,%v) = %v, want %v", tc.soh, tc.fastRatio, tc.cycles, got, tc.want) + } + }) + } +} + +func TestGrade_Bands(t *testing.T) { + t.Parallel() + // With no fast-charge/cycle penalty the score equals the clamped SoH, so + // SoH alone pins every letter-grade boundary. + tests := []struct { + soh float64 + want string + }{ + {100, "A"}, {90, "A"}, + {89.999, "B"}, {80, "B"}, + {79.5, "C"}, {70, "C"}, + {69.9, "D"}, {60, "D"}, + {59.9, "E"}, {50, "E"}, + {49.9, "F"}, {0, "F"}, + } + for _, tc := range tests { + tc := tc + t.Run(fmt.Sprintf("%s@soh=%g", tc.want, tc.soh), func(t *testing.T) { + t.Parallel() + if got := Grade(tc.soh, 0, 0); got != tc.want { + t.Errorf("Grade(%v,0,0) = %q, want %q", tc.soh, got, tc.want) + } + }) + } +} + +func TestGrade_PenaltiesDropLetter(t *testing.T) { + t.Parallel() + // A pack at 92% SoH is an A with no penalties, but sustained DC + // fast-charging + heavy cycling drags the score to 92-8-12=72 → C. + if got := Grade(92, 0, 0); got != "A" { + t.Fatalf("Grade(92,0,0) = %q, want A", got) + } + if got := Grade(92, 1, 1500); got != "C" { + t.Fatalf("Grade(92,1,1500) = %q, want C (92-8-12=72)", got) + } +} + +// --------------------------------------------------------------------------- +// MaskVIN. +// --------------------------------------------------------------------------- + +func TestMaskVIN(t *testing.T) { + t.Parallel() + tests := []struct { + name string + in string + want string + }{ + {"full 17-char VIN", "5YJ3E1EA7KF123456", "5YJ**********3456"}, + {"lowercased is upper-normalised", "5yj3e1ea7kf123456", "5YJ**********3456"}, + {"surrounding whitespace trimmed", " 5YJ3E1EA7KF123456 ", "5YJ**********3456"}, + {"eight chars reveals wmi + last4", "ABCDEFGH", "ABC*EFGH"}, + {"exactly seven fully masked", "ABCDEFG", "*******"}, + {"short fully masked", "SHORT", "*****"}, + {"empty stays empty", "", ""}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := MaskVIN(tc.in); got != tc.want { + t.Errorf("MaskVIN(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// ThermalExposureFrom. +// --------------------------------------------------------------------------- + +func TestThermalExposureFrom(t *testing.T) { + t.Parallel() + tests := []struct { + name string + cold, nominal, hot int64 + wantCold, wantNominal float64 + wantHot float64 + }{ + {"no data is all zero", 0, 0, 0, 0, 0, 0}, + {"even split", 1, 2, 1, 25, 50, 25}, + {"all nominal", 0, 10, 0, 0, 100, 0}, + {"all hot", 0, 0, 3, 0, 0, 100}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := ThermalExposureFrom(tc.cold, tc.nominal, tc.hot) + if got.ColdPct != tc.wantCold || got.NominalPct != tc.wantNominal || got.HotPct != tc.wantHot { + t.Errorf("ThermalExposureFrom(%d,%d,%d) = %+v, want {%v %v %v}", + tc.cold, tc.nominal, tc.hot, got, tc.wantCold, tc.wantNominal, tc.wantHot) + } + }) + } +} + +// --------------------------------------------------------------------------- +// EstimateOriginalCapacityWh + medianWh. +// --------------------------------------------------------------------------- + +func TestEstimateOriginalCapacityWh(t *testing.T) { + t.Parallel() + tests := []struct { + name string + vin string + model string + want float64 + }{ + {"vin code E → 60kWh", "1234567E", "", 60000}, + {"vin code F → 60kWh", "1234567F", "", 60000}, + {"vin code K → 75kWh", "1234567K", "", 75000}, + {"vin code S → 100kWh", "1234567S", "", 100000}, + {"vin code P → 100kWh", "1234567P", "", 100000}, + {"short vin, model X → 100kWh", "SHORT", "Model X", 100000}, + {"short vin, model S → 100kWh", "SHORT", "Model S", 100000}, + {"short vin, model 3 → default", "SHORT", "Model 3", 75000}, + {"unknown → default", "", "", 75000}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := EstimateOriginalCapacityWh(tc.vin, tc.model); got != tc.want { + t.Errorf("EstimateOriginalCapacityWh(%q,%q) = %v, want %v", tc.vin, tc.model, got, tc.want) + } + }) + } +} + +func TestMedianWh(t *testing.T) { + t.Parallel() + tests := []struct { + name string + in []float64 + want float64 + }{ + {"empty is zero", nil, 0}, + {"single", []float64{100}, 100}, + {"odd count", []float64{100, 200, 300}, 200}, + {"even count averages middle two", []float64{100, 200, 300, 400}, 250}, + {"unsorted input is sorted", []float64{300, 100, 200}, 200}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := medianWh(tc.in); got != tc.want { + t.Errorf("medianWh(%v) = %v, want %v", tc.in, got, tc.want) + } + }) + } +} + +func TestMedianWh_DoesNotMutateInput(t *testing.T) { + t.Parallel() + in := []float64{300, 100, 200} + _ = medianWh(in) + if in[0] != 300 || in[1] != 100 || in[2] != 200 { + t.Errorf("medianWh mutated its input: %v", in) + } +} + +// --------------------------------------------------------------------------- +// Recommendations. +// --------------------------------------------------------------------------- + +func TestRecommendations(t *testing.T) { + t.Parallel() + + t.Run("healthy pack gets a single positive note", func(t *testing.T) { + t.Parallel() + recs := Recommendations(95, 0.1, 80, 5, 100) + if len(recs) != 1 { + t.Fatalf("len = %d, want 1 (%v)", len(recs), recs) + } + if !strings.Contains(recs[0], "within healthy bounds") { + t.Errorf("unexpected healthy rec: %q", recs[0]) + } + }) + + t.Run("each risk raises its own recommendation", func(t *testing.T) { + t.Parallel() + recs := Recommendations(75, 0.6, 95, 40, 2000) + joined := strings.Join(recs, " || ") + for _, want := range []string{ + "80% warranty threshold", + "DC fast-charging dominates", + "charge limit is high", + "high-temperature operation", + "rated cycle life", + } { + if !strings.Contains(joined, want) { + t.Errorf("recommendations missing %q; got %v", want, recs) + } + } + }) + + t.Run("unknown soh does not trigger the warranty note", func(t *testing.T) { + t.Parallel() + recs := Recommendations(0, 0.1, 80, 5, 100) + for _, r := range recs { + if strings.Contains(r, "warranty threshold") { + t.Errorf("warranty note raised for soh=0: %q", r) + } + } + }) + + t.Run("never returns nil", func(t *testing.T) { + t.Parallel() + if Recommendations(0, 0, 0, 0, 0) == nil { + t.Error("Recommendations returned nil") + } + }) +} + +// --------------------------------------------------------------------------- +// CanonicalString + ProvenanceHash — determinism, format, tamper sensitivity. +// --------------------------------------------------------------------------- + +// goldenFacts is a fixed input whose canonical form + digest are pinned below. +var goldenFacts = PassportCoreFacts{ + VehicleID: 42, + FirstObservedAt: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC), + SohPct: 93.2, + CapacityKwh: 69.9, + EquivalentFullCycles: 210.0, + FastChargeRatio: 0.35, + IssuedAt: time.Date(2026, 7, 6, 11, 3, 21, 0, time.UTC), +} + +const ( + goldenCanonical = "tsbp-v1|vehicle_id=42|first_observed_at=2023-01-15|soh_pct=93.2000|capacity_kwh=69.9000|equivalent_full_cycles=210.0000|fast_charge_ratio=0.3500|issued_at=2026-07-06" + goldenHash = "ef1f4c87480f80848800fcb5937c3cad2aa5ab41195876ab7d4a20357c29196f" +) + +func TestCanonicalString_PinnedFormat(t *testing.T) { + t.Parallel() + if got := CanonicalString(goldenFacts); got != goldenCanonical { + t.Errorf("CanonicalString mismatch:\n got %q\nwant %q", got, goldenCanonical) + } +} + +func TestCanonicalString_IssuedAtRoundedToDay(t *testing.T) { + t.Parallel() + // Two issuance instants on the same UTC day must canonicalize identically. + a := goldenFacts + a.IssuedAt = time.Date(2026, 7, 6, 0, 0, 1, 0, time.UTC) + b := goldenFacts + b.IssuedAt = time.Date(2026, 7, 6, 23, 59, 59, 0, time.UTC) + if CanonicalString(a) != CanonicalString(b) { + t.Error("same-day issuance produced different canonical strings") + } +} + +func TestProvenanceHash_GoldenAndDeterministic(t *testing.T) { + t.Parallel() + + if got := ProvenanceHash(goldenFacts); got != goldenHash { + t.Errorf("ProvenanceHash = %q, want golden %q", got, goldenHash) + } + + hexRe := regexp.MustCompile(`^[0-9a-f]{64}$`) + if !hexRe.MatchString(ProvenanceHash(goldenFacts)) { + t.Error("hash is not 64 lowercase hex chars") + } + + // Stable across many runs for identical input. + first := ProvenanceHash(goldenFacts) + for i := 0; i < 1000; i++ { + if got := ProvenanceHash(goldenFacts); got != first { + t.Fatalf("hash unstable on run %d: %q != %q", i, got, first) + } + } +} + +func TestProvenanceHash_TamperSensitivity(t *testing.T) { + t.Parallel() + base := ProvenanceHash(goldenFacts) + + mutations := map[string]func(f *PassportCoreFacts){ + "vehicle_id": func(f *PassportCoreFacts) { f.VehicleID = 43 }, + "first_observed_at day": func(f *PassportCoreFacts) { f.FirstObservedAt = f.FirstObservedAt.AddDate(0, 0, 1) }, + "soh_pct": func(f *PassportCoreFacts) { f.SohPct = 93.3 }, + "capacity_kwh": func(f *PassportCoreFacts) { f.CapacityKwh = 70.0 }, + "equivalent_full_cycles": func(f *PassportCoreFacts) { f.EquivalentFullCycles = 211 }, + "fast_charge_ratio": func(f *PassportCoreFacts) { f.FastChargeRatio = 0.36 }, + "issued_at day": func(f *PassportCoreFacts) { f.IssuedAt = f.IssuedAt.AddDate(0, 0, 1) }, + } + for name, mutate := range mutations { + name, mutate := name, mutate + t.Run(name, func(t *testing.T) { + t.Parallel() + f := goldenFacts + mutate(&f) + if got := ProvenanceHash(f); got == base { + t.Errorf("mutating %s did not change the hash", name) + } + }) + } +} + +func TestProvenanceHash_SubSecondIssuedIrrelevant(t *testing.T) { + t.Parallel() + // Because issued_at is day-rounded, sub-second jitter within a day must + // not perturb the digest — the reproducibility guarantee the verify + // endpoint relies on. + a := goldenFacts + b := goldenFacts + b.IssuedAt = b.IssuedAt.Add(37 * time.Second) + if ProvenanceHash(a) != ProvenanceHash(b) { + t.Error("sub-day issuance jitter changed the provenance hash") + } +} diff --git a/internal/api/carbon/carbon.go b/internal/api/carbon/carbon.go new file mode 100644 index 0000000000..08987698a2 --- /dev/null +++ b/internal/api/carbon/carbon.go @@ -0,0 +1,243 @@ +package carbon + +import ( + "math" + "sort" +) + +// --------------------------------------------------------------------------- +// Pure, unit-testable core. Nothing in this file touches the database, the +// clock, or the network — every function is a deterministic transform of its +// inputs, so the per-session CO2, the green-score, and the greenest-window +// selection are reproducible and independently testable (carbon_test.go). +// --------------------------------------------------------------------------- + +const ( + // GasBaselineKgCO2PerKm is the distance-based CO2 footprint attributed to + // an equivalent internal-combustion (gasoline) car, in kilograms of CO2 + // per kilometre driven. 0.192 kg/km (~192 g/km) is a deliberately + // conservative real-world average for a mid-size petrol car including + // well-to-wheel (fuel production + tailpipe) emissions — comparable to the + // EPA average passenger-vehicle figure of ~404 g CO2/mile (≈251 g/km + // tailpipe) net of the upstream/efficiency spread. It is the single, + // documented constant behind the "CO2 saved vs a gas car" headline; a + // future Settings key could make it user-editable per their prior vehicle. + GasBaselineKgCO2PerKm = 0.192 + + // hoursPerDay is the fixed span of the diurnal grid model (0..23). + hoursPerDay = 24 + + // GreenestWindowHours is the length of the recommended contiguous charging + // window. Three hours comfortably covers a typical overnight/solar top-up + // while staying inside a single low-intensity trough of the diurnal curve. + GreenestWindowHours = 3 + + // flatCurveGreenScore is returned by GreenScore when the curve is flat + // (max == min): no hour is dirtier than any other, so any timing is + // maximally green. + flatCurveGreenScore = 100.0 +) + +// HourIntensity is one hour of the diurnal grid carbon-intensity curve. +// GCO2PerKWh is grams of CO2 attributed per kWh drawn during HourOfDay. +type HourIntensity struct { + HourOfDay int `json:"hour_of_day"` + GCO2PerKWh float64 `json:"g_co2_per_kwh"` +} + +// clamp bounds v to [lo, hi]. +func clamp(v, lo, hi float64) float64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// round1/round2 round to a fixed number of decimals so the JSON body carries a +// stable, display-ready numeric form (mirrors the tco / batterypassport +// rounding boundary). +func round1(v float64) float64 { return math.Round(v*10) / 10 } +func round2(v float64) float64 { return math.Round(v*100) / 100 } + +// safeF guards against NaN/Inf which silently break json.Encode. +func safeF(v float64) float64 { + if math.IsNaN(v) || math.IsInf(v, 0) { + return 0 + } + return v +} + +// SessionCO2Kg attributes a CO2 footprint to a single charging session: +// energy drawn (kWh) times the grid intensity at the session's hour (gCO2/kWh), +// converted from grams to kilograms. Non-positive inputs yield 0 (no negative +// or phantom emissions). Pure. +func SessionCO2Kg(energyKwh, gCO2PerKWh float64) float64 { + if energyKwh <= 0 || gCO2PerKWh <= 0 { + return 0 + } + return energyKwh * gCO2PerKWh / 1000.0 +} + +// GasEquivCO2Kg is the CO2 an equivalent gasoline car would have emitted over +// the same distance (km), using the documented GasBaselineKgCO2PerKm baseline. +// Non-positive distance yields 0. Pure. +func GasEquivCO2Kg(distanceKm float64) float64 { + if distanceKm <= 0 { + return 0 + } + return distanceKm * GasBaselineKgCO2PerKm +} + +// GreenScore maps a realized (energy-weighted) charging intensity onto a 0..100 +// timing score relative to the theoretical greenest (minIntensity) and dirtiest +// (maxIntensity) hours of the curve: +// +// score = (max - realized) / (max - min) * 100 +// +// - 100 ⇒ every kWh was drawn at the greenest hour (realized == min). +// - 0 ⇒ every kWh was drawn at the dirtiest hour (realized == max). +// - a flat curve (max == min) can't be gamed, so it scores 100. +// +// The result is clamped to [0, 100]. Pure and side-effect free — this is the +// documented scoring contract pinned by carbon_test.go. +func GreenScore(realizedIntensity, minIntensity, maxIntensity float64) float64 { + span := maxIntensity - minIntensity + if span <= 0 { + return flatCurveGreenScore + } + return clamp((maxIntensity-realizedIntensity)/span*100, 0, 100) +} + +// CurveStats reduces a curve to its min/max intensity and the (sorted) sets of +// hours that achieve each. Greenest hours == every hour at the minimum +// intensity; dirtiest == every hour at the maximum. An empty curve yields +// (0, 0) and non-nil empty hour slices so the JSON never carries null arrays. +// Pure. +func CurveStats(curve []HourIntensity) (minV, maxV float64, greenest, dirtiest []int) { + greenest, dirtiest = []int{}, []int{} + if len(curve) == 0 { + return 0, 0, greenest, dirtiest + } + minV, maxV = curve[0].GCO2PerKWh, curve[0].GCO2PerKWh + for _, h := range curve { + if h.GCO2PerKWh < minV { + minV = h.GCO2PerKWh + } + if h.GCO2PerKWh > maxV { + maxV = h.GCO2PerKWh + } + } + for _, h := range curve { + if h.GCO2PerKWh == minV { + greenest = append(greenest, h.HourOfDay) + } + if h.GCO2PerKWh == maxV { + dirtiest = append(dirtiest, h.HourOfDay) + } + } + sort.Ints(greenest) + sort.Ints(dirtiest) + return minV, maxV, greenest, dirtiest +} + +// intensityLookup indexes a curve by hour for O(1) intensity lookups. Hours +// outside 0..23 are ignored. Pure. +func intensityLookup(curve []HourIntensity) map[int]float64 { + m := make(map[int]float64, len(curve)) + for _, h := range curve { + if h.HourOfDay >= 0 && h.HourOfDay < hoursPerDay { + m[h.HourOfDay] = h.GCO2PerKWh + } + } + return m +} + +// EnergyWeightedIntensity returns the realized grid intensity a driver's +// charging actually incurred: sum(energy_h * intensity_h) / sum(energy_h) over +// the hours present in the intensity lookup. Hours with energy but no matching +// intensity are skipped (they can't be scored). Returns 0 when the total +// weighted energy is 0. Pure. +func EnergyWeightedIntensity(energyKwhByHour, lookup map[int]float64) float64 { + var num, den float64 + for hour, energy := range energyKwhByHour { + gi, ok := lookup[hour] + if !ok || energy <= 0 { + continue + } + num += energy * gi + den += energy + } + if den <= 0 { + return 0 + } + return num / den +} + +// GreenestWindow scans all 24 wrap-around start positions and returns the +// contiguous `length`-hour clock window with the lowest MEAN grid intensity. +// The window wraps past midnight (e.g. 23:00 → 02:00). Ties break toward the +// earliest start hour. It returns the start hour (inclusive, 0..23), the end +// hour (EXCLUSIVE, 0..23, wrapping), and the window's mean intensity. +// +// A window is only considered when every one of its hours is present in the +// curve, so a partially-populated curve never yields a spurious "greenest" +// window over missing hours. An empty curve or non-positive length yields +// (0, 0, 0). Pure. +func GreenestWindow(curve []HourIntensity, length int) (startHour, endHour int, avgIntensity float64) { + lookup := intensityLookup(curve) + if length <= 0 || len(lookup) == 0 { + return 0, 0, 0 + } + if length > hoursPerDay { + length = hoursPerDay + } + best := math.Inf(1) + bestStart := -1 + for start := 0; start < hoursPerDay; start++ { + var sum float64 + complete := true + for i := 0; i < length; i++ { + gi, ok := lookup[(start+i)%hoursPerDay] + if !ok { + complete = false + break + } + sum += gi + } + if !complete { + continue + } + if avg := sum / float64(length); avg < best { + best = avg + bestStart = start + } + } + if bestStart < 0 { + return 0, 0, 0 + } + return bestStart, (bestStart + length) % hoursPerDay, best +} + +// PotentialSaving quantifies shifting ALL charging from the realized average +// intensity into the greenest window's average intensity, over the observed +// total energy: +// +// saving_kg = total_energy_kwh * (current_avg - greenest_avg) / 1000 +// saving_pct = (current_avg - greenest_avg) / current_avg * 100 +// +// Both are clamped at 0 — if the driver already charges greener than the window +// average (or there is no energy), there is nothing to save. Pure. +func PotentialSaving(totalEnergyKwh, currentAvgIntensity, greenestAvgIntensity float64) (savingKg, savingPct float64) { + delta := currentAvgIntensity - greenestAvgIntensity + if delta <= 0 || totalEnergyKwh <= 0 { + return 0, 0 + } + savingKg = totalEnergyKwh * delta / 1000.0 + if currentAvgIntensity > 0 { + savingPct = delta / currentAvgIntensity * 100 + } + return savingKg, savingPct +} diff --git a/internal/api/carbon/carbon_test.go b/internal/api/carbon/carbon_test.go new file mode 100644 index 0000000000..bb3d47c88b --- /dev/null +++ b/internal/api/carbon/carbon_test.go @@ -0,0 +1,343 @@ +package carbon + +import ( + "math" + "reflect" + "testing" +) + +// --------------------------------------------------------------------------- +// Shared fixture: the built-in diurnal curve seeded by +// migrations/000217_grid_carbon_intensity.up.sql. Kept in lock-step with the +// migration so a drift between the SQL seed and the Go tests fails loudly here. +// Reused by the handler tests (same package) to script the intensity read. +// --------------------------------------------------------------------------- + +func builtInCurveValues() [hoursPerDay]float64 { + return [hoursPerDay]float64{ + 260, 250, 245, 245, 250, 265, 300, 340, 330, 280, 240, 215, + 200, 200, 205, 225, 270, 340, 430, 500, 490, 450, 370, 300, + } +} + +func builtInCurve() []HourIntensity { + vals := builtInCurveValues() + curve := make([]HourIntensity, 0, hoursPerDay) + for h, v := range vals { + curve = append(curve, HourIntensity{HourOfDay: h, GCO2PerKWh: v}) + } + return curve +} + +const eps = 1e-9 + +func approx(a, b float64) bool { return math.Abs(a-b) <= eps } + +// --------------------------------------------------------------------------- +// SessionCO2Kg — energy(kWh) × intensity(gCO2/kWh) ÷ 1000 = kg CO2. +// --------------------------------------------------------------------------- + +func TestSessionCO2Kg(t *testing.T) { + t.Parallel() + tests := []struct { + name string + energy float64 + gPerKWh float64 + want float64 + }{ + {"clean midday kWh", 10, 200, 2.0}, + {"dirty evening kWh", 10, 500, 5.0}, + {"partial energy", 2.5, 400, 1.0}, + {"zero energy", 0, 500, 0}, + {"zero intensity", 10, 0, 0}, + {"negative energy guarded", -5, 200, 0}, + {"negative intensity guarded", 10, -200, 0}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := SessionCO2Kg(tc.energy, tc.gPerKWh); !approx(got, tc.want) { + t.Errorf("SessionCO2Kg(%v,%v) = %v, want %v", tc.energy, tc.gPerKWh, got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// GasEquivCO2Kg — distance(km) × 0.192 kg/km. +// --------------------------------------------------------------------------- + +func TestGasEquivCO2Kg(t *testing.T) { + t.Parallel() + tests := []struct { + name string + km float64 + want float64 + }{ + {"100 km", 100, 19.2}, + {"1000 km", 1000, 192.0}, + {"zero", 0, 0}, + {"negative guarded", -50, 0}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := GasEquivCO2Kg(tc.km); !approx(got, tc.want) { + t.Errorf("GasEquivCO2Kg(%v) = %v, want %v", tc.km, got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// GreenScore — how close the realized intensity is to the greenest hour. +// --------------------------------------------------------------------------- + +func TestGreenScore(t *testing.T) { + t.Parallel() + tests := []struct { + name string + realized, lo, hi float64 + want float64 + }{ + {"always greenest ⇒ 100", 200, 200, 500, 100}, + {"always dirtiest ⇒ 0", 500, 200, 500, 0}, + {"exact midpoint ⇒ 50", 350, 200, 500, 50}, + {"one third up ⇒ ~66.67", 300, 200, 500, (500.0 - 300.0) / 300.0 * 100}, + {"flat curve ⇒ 100", 250, 250, 250, 100}, + {"cleaner than min clamps to 100", 100, 200, 500, 100}, + {"dirtier than max clamps to 0", 600, 200, 500, 0}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := GreenScore(tc.realized, tc.lo, tc.hi); !approx(got, tc.want) { + t.Errorf("GreenScore(%v,%v,%v) = %v, want %v", tc.realized, tc.lo, tc.hi, got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// CurveStats — min/max + the sets of hours achieving each. +// --------------------------------------------------------------------------- + +func TestCurveStats(t *testing.T) { + t.Parallel() + + t.Run("built-in curve", func(t *testing.T) { + t.Parallel() + minV, maxV, greenest, dirtiest := CurveStats(builtInCurve()) + if !approx(minV, 200) || !approx(maxV, 500) { + t.Fatalf("min/max = %v/%v, want 200/500", minV, maxV) + } + if !reflect.DeepEqual(greenest, []int{12, 13}) { + t.Errorf("greenest = %v, want [12 13]", greenest) + } + if !reflect.DeepEqual(dirtiest, []int{19}) { + t.Errorf("dirtiest = %v, want [19]", dirtiest) + } + }) + + t.Run("ties collect every matching hour, sorted", func(t *testing.T) { + t.Parallel() + curve := []HourIntensity{ + {HourOfDay: 3, GCO2PerKWh: 500}, + {HourOfDay: 1, GCO2PerKWh: 200}, + {HourOfDay: 4, GCO2PerKWh: 500}, + {HourOfDay: 2, GCO2PerKWh: 200}, + } + minV, maxV, greenest, dirtiest := CurveStats(curve) + if !approx(minV, 200) || !approx(maxV, 500) { + t.Fatalf("min/max = %v/%v, want 200/500", minV, maxV) + } + if !reflect.DeepEqual(greenest, []int{1, 2}) { + t.Errorf("greenest = %v, want [1 2]", greenest) + } + if !reflect.DeepEqual(dirtiest, []int{3, 4}) { + t.Errorf("dirtiest = %v, want [3 4]", dirtiest) + } + }) + + t.Run("empty curve yields non-nil empty slices", func(t *testing.T) { + t.Parallel() + minV, maxV, greenest, dirtiest := CurveStats(nil) + if minV != 0 || maxV != 0 { + t.Errorf("min/max = %v/%v, want 0/0", minV, maxV) + } + if greenest == nil || dirtiest == nil { + t.Fatal("greenest/dirtiest must be non-nil (JSON must not carry null arrays)") + } + if len(greenest) != 0 || len(dirtiest) != 0 { + t.Errorf("greenest/dirtiest = %v/%v, want empty", greenest, dirtiest) + } + }) +} + +// --------------------------------------------------------------------------- +// EnergyWeightedIntensity — realized intensity a driver actually incurred. +// --------------------------------------------------------------------------- + +func TestEnergyWeightedIntensity(t *testing.T) { + t.Parallel() + lookup := map[int]float64{0: 300, 1: 200, 2: 500} + tests := []struct { + name string + energy map[int]float64 + want float64 + }{ + {"single hour", map[int]float64{2: 5}, 500}, + {"even split", map[int]float64{0: 10, 1: 10}, 250}, + {"weighted split", map[int]float64{0: 30, 2: 10}, (30*300 + 10*500) / 40.0}, + {"hour missing from lookup skipped", map[int]float64{5: 100}, 0}, + {"zero energy skipped", map[int]float64{0: 0}, 0}, + {"empty", map[int]float64{}, 0}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := EnergyWeightedIntensity(tc.energy, lookup); !approx(got, tc.want) { + t.Errorf("EnergyWeightedIntensity(%v) = %v, want %v", tc.energy, got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// GreenestWindow — lowest-mean contiguous window, wrapping past midnight. +// --------------------------------------------------------------------------- + +func TestGreenestWindow(t *testing.T) { + t.Parallel() + + t.Run("built-in curve picks midday solar trough", func(t *testing.T) { + t.Parallel() + start, end, avg := GreenestWindow(builtInCurve(), 3) + if start != 12 || end != 15 { + t.Fatalf("window = [%d,%d), want [12,15)", start, end) + } + if !approx(avg, (200+200+205)/3.0) { + t.Errorf("avg = %v, want %v", avg, (200+200+205)/3.0) + } + }) + + t.Run("window wraps across midnight", func(t *testing.T) { + t.Parallel() + // All 500 except a clean trough at 23,0,1 = 100 each. + curve := make([]HourIntensity, hoursPerDay) + for h := range curve { + curve[h] = HourIntensity{HourOfDay: h, GCO2PerKWh: 500} + } + curve[23].GCO2PerKWh = 100 + curve[0].GCO2PerKWh = 100 + curve[1].GCO2PerKWh = 100 + start, end, avg := GreenestWindow(curve, 3) + if start != 23 || end != 2 { + t.Fatalf("window = [%d,%d), want [23,2)", start, end) + } + if !approx(avg, 100) { + t.Errorf("avg = %v, want 100", avg) + } + }) + + t.Run("ties break toward the earliest start hour", func(t *testing.T) { + t.Parallel() + curve := make([]HourIntensity, hoursPerDay) + for h := range curve { + curve[h] = HourIntensity{HourOfDay: h, GCO2PerKWh: 500} + } + // Two equal-minimum troughs; earliest (start=2) must win. + for _, h := range []int{2, 3, 4, 14, 15, 16} { + curve[h].GCO2PerKWh = 100 + } + start, _, avg := GreenestWindow(curve, 3) + if start != 2 { + t.Fatalf("start = %d, want 2 (earliest of the tied troughs)", start) + } + if !approx(avg, 100) { + t.Errorf("avg = %v, want 100", avg) + } + }) + + t.Run("length over 24 clamps to the whole day", func(t *testing.T) { + t.Parallel() + start, end, avg := GreenestWindow(builtInCurve(), 48) + if start != 0 || end != 0 { + t.Fatalf("window = [%d,%d), want [0,0) (whole-day wrap)", start, end) + } + var sum float64 + for _, v := range builtInCurveValues() { + sum += v + } + if !approx(avg, sum/hoursPerDay) { + t.Errorf("avg = %v, want %v", avg, sum/hoursPerDay) + } + }) + + t.Run("degenerate inputs yield zeros", func(t *testing.T) { + t.Parallel() + if s, e, a := GreenestWindow(nil, 3); s != 0 || e != 0 || a != 0 { + t.Errorf("empty curve = (%d,%d,%v), want (0,0,0)", s, e, a) + } + if s, e, a := GreenestWindow(builtInCurve(), 0); s != 0 || e != 0 || a != 0 { + t.Errorf("zero length = (%d,%d,%v), want (0,0,0)", s, e, a) + } + }) + + t.Run("incomplete window is never chosen", func(t *testing.T) { + t.Parallel() + // Only hours 10,11 present (a low pair) plus a full high triple at + // 20,21,22. A 3-hour window needs three present hours, so the low + // pair cannot form a window and 20-23 must win. + curve := []HourIntensity{ + {HourOfDay: 10, GCO2PerKWh: 50}, + {HourOfDay: 11, GCO2PerKWh: 50}, + {HourOfDay: 20, GCO2PerKWh: 300}, + {HourOfDay: 21, GCO2PerKWh: 300}, + {HourOfDay: 22, GCO2PerKWh: 300}, + } + start, end, avg := GreenestWindow(curve, 3) + if start != 20 || end != 23 { + t.Fatalf("window = [%d,%d), want [20,23)", start, end) + } + if !approx(avg, 300) { + t.Errorf("avg = %v, want 300", avg) + } + }) +} + +// --------------------------------------------------------------------------- +// PotentialSaving — shifting all charging into the greenest window. +// --------------------------------------------------------------------------- + +func TestPotentialSaving(t *testing.T) { + t.Parallel() + tests := []struct { + name string + energy, current, window float64 + wantKg, wantPct float64 + }{ + {"clear saving", 100, 300, 200, 10, (100.0 / 300.0) * 100}, + {"large saving", 50, 500, 200, 15, 60}, + {"already at window ⇒ none", 100, 200, 200, 0, 0}, + {"already cleaner ⇒ none", 100, 150, 200, 0, 0}, + {"no energy ⇒ none", 0, 300, 200, 0, 0}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + gotKg, gotPct := PotentialSaving(tc.energy, tc.current, tc.window) + if !approx(gotKg, tc.wantKg) { + t.Errorf("savingKg = %v, want %v", gotKg, tc.wantKg) + } + if !approx(gotPct, tc.wantPct) { + t.Errorf("savingPct = %v, want %v", gotPct, tc.wantPct) + } + }) + } +} diff --git a/internal/api/carbon/doc.go b/internal/api/carbon/doc.go new file mode 100644 index 0000000000..058fac567a --- /dev/null +++ b/internal/api/carbon/doc.go @@ -0,0 +1,15 @@ +// Package carbon serves the Carbon Intelligence endpoints: grid-aware CO2 +// accounting for charging. It attributes a real CO2 footprint to each charging +// session from the hour it charged (grid carbon intensity varies across the +// day), tracks lifetime CO2 saved versus an equivalent gasoline car, scores how +// green the charging timing is, and recommends the greenest charging window. +// +// The CO2 arithmetic, green-score, and greenest-window selection are PURE, +// deterministic functions in carbon.go with table-driven tests; the handler +// only parses/validates requests, reads the pgx pool, folds rows through the +// pure core, and writes snake_case JSON. The diurnal grid model is a seeded, +// admin-editable table (migrations/000217_grid_carbon_intensity) so the feature +// works self-hosted with no external grid-intensity API. +// +// Layer: handler +package carbon diff --git a/internal/api/carbon/dtos.go b/internal/api/carbon/dtos.go new file mode 100644 index 0000000000..c0fe00a91d --- /dev/null +++ b/internal/api/carbon/dtos.go @@ -0,0 +1,60 @@ +package carbon + +// DTOs for the Carbon Intelligence endpoints. All JSON tags are snake_case to +// match the frontend wire contract; numeric fields are rounded at the handler +// boundary. SI-canonical: energy in kWh, mass in kg, intensity in gCO2/kWh. + +// IntensityCurveResponse is the body of GET /api/v1/carbon/intensity: the full +// 24-hour diurnal grid model plus its derived extremes. GreenestHours and +// DirtiestHours are always non-nil (possibly empty) slices so the frontend can +// highlight the cleanest/dirtiest bars without a null check. +type IntensityCurveResponse struct { + Curve []HourIntensity `json:"curve"` + Min float64 `json:"min"` + Max float64 `json:"max"` + GreenestHours []int `json:"greenest_hours"` + DirtiestHours []int `json:"dirtiest_hours"` +} + +// MonthlyCO2 is one YYYY-MM row of the CO2 trend: the attributed CO2 (kg) and +// the energy (kWh) charged that month. +type MonthlyCO2 struct { + Month string `json:"month"` + CO2Kg float64 `json:"co2_kg"` + EnergyKwh float64 `json:"energy_kwh"` +} + +// SummaryResponse is the body of GET /api/v1/vehicles/{vehicleID}/carbon/summary. +// +// - TotalCO2Kg — sum over sessions of energy_kwh * intensity(hour) / 1000. +// - GasEquivCO2Kg — distance-based ICE baseline (GasBaselineKgCO2PerKm). +// - CO2SavedKg — GasEquivCO2Kg - TotalCO2Kg (can be negative on a dirty grid). +// - GreenScore — 0..100 timing score (see GreenScore); 0 when nothing scored. +// - Monthly — always a non-nil slice, ascending by month. +type SummaryResponse struct { + TotalEnergyKwh float64 `json:"total_energy_kwh"` + TotalCO2Kg float64 `json:"total_co2_kg"` + GasEquivCO2Kg float64 `json:"gas_equiv_co2_kg"` + CO2SavedKg float64 `json:"co2_saved_kg"` + GreenScore float64 `json:"green_score"` + SessionsScored int `json:"sessions_scored"` + Monthly []MonthlyCO2 `json:"monthly"` +} + +// GreenestWindowDTO describes the recommended charging window. StartHour is +// inclusive, EndHour is exclusive (both 0..23, wrapping past midnight). +type GreenestWindowDTO struct { + StartHour int `json:"start_hour"` + EndHour int `json:"end_hour"` + AvgIntensity float64 `json:"avg_intensity"` +} + +// RecommendationResponse is the body of +// GET /api/v1/vehicles/{vehicleID}/carbon/recommendation: how the driver's +// realized charging intensity compares to shifting into the greenest window. +type RecommendationResponse struct { + CurrentAvgIntensity float64 `json:"current_avg_intensity"` + GreenestWindow GreenestWindowDTO `json:"greenest_window"` + PotentialCO2SavingKg float64 `json:"potential_co2_saving_kg"` + PotentialSavingPct float64 `json:"potential_saving_pct"` +} diff --git a/internal/api/carbon/handler.go b/internal/api/carbon/handler.go new file mode 100644 index 0000000000..a6ae310255 --- /dev/null +++ b/internal/api/carbon/handler.go @@ -0,0 +1,352 @@ +package carbon + +import ( + "context" + "errors" + "net/http" + "sort" + "time" + + "github.com/ev-dev-labs/teslasync/internal/api/apiparams" + "github.com/ev-dev-labs/teslasync/internal/api/httpx" + "github.com/ev-dev-labs/teslasync/internal/database" + "github.com/jackc/pgx/v5" + "github.com/rs/zerolog/log" +) + +// carbonDataTimeout bounds each analytics read so a stalled connection cannot +// pin the request goroutine longer than the boundary rule allows. The pool's +// server-side statement_timeout is the backstop; this is the client-side +// deadline. A var (not const) so tests can shorten it (mirrors +// routeeff.routeEffDataTimeout / batterypassport.bpDataTimeout). +var carbonDataTimeout = 15 * time.Second + +// carbonQuerier is the minimal pgx surface the handler needs. Declared locally +// so tests can drive every branch with scripted row/rows sources without a live +// database or a vendored pgxmock (mirrors routeeff.routeQuerier / +// batterypassport.passportQuerier). *pgxpool.Pool satisfies it. +type carbonQuerier interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + +// Handler serves the Carbon Intelligence endpoints. +type Handler struct { + db carbonQuerier +} + +// NewCarbonHandler wires the handler to the pgx pool. Panics on a nil pool — a +// nil pool is a wiring bug, not a runtime condition, so it surfaces at +// construction rather than as a nil-deref on the first request (mirrors +// routeeff.NewRouteEfficiencyHandler / batterypassport.NewBatteryPassportHandler). +func NewCarbonHandler(db *database.DB) *Handler { + if db == nil || db.Pool == nil { + panic("carbon.NewCarbonHandler: db pool must not be nil") + } + return &Handler{db: db.Pool} +} + +// --- SQL. Package-level constants so tests can pin the critical clauses +// without a live database. --- + +// intensityQuery reads the full 24-row diurnal grid model, ascending by hour. +const intensityQuery = ` +SELECT hour_of_day, g_co2_per_kwh +FROM grid_carbon_intensity +ORDER BY hour_of_day` + +// summaryChargingQuery rolls charged energy up by (month, hour) so a single +// scan drives BOTH the totals and the monthly trend. EXTRACT(HOUR ...) uses the +// session's clock (the intensity model is local-hour). The optional +// [start, end] window is expressed via a NULL-guarded BETWEEN so one prepared +// statement covers "scoped" and "full-history". +const summaryChargingQuery = ` +SELECT TO_CHAR(started_at, 'YYYY-MM') AS month, + EXTRACT(HOUR FROM started_at)::int AS hour, + COALESCE(SUM(total_energy_added_wh), 0) AS energy_wh, + COUNT(*) AS session_count +FROM charging_sessions +WHERE vehicle_id = $1 + AND total_energy_added_wh > 0 + AND ($2::timestamptz IS NULL OR started_at BETWEEN $2 AND $3) +GROUP BY 1, 2 +ORDER BY 1, 2` + +// summaryDistanceQuery totals drive distance (SI metres → km) for the ICE +// gas-equivalent baseline, over the same optional window. +const summaryDistanceQuery = ` +SELECT COALESCE(SUM(distance_m), 0) / 1000.0 AS distance_km +FROM drives +WHERE vehicle_id = $1 + AND distance_m > 0 + AND ($2::timestamptz IS NULL OR started_at BETWEEN $2 AND $3)` + +// recommendationChargingQuery rolls charged energy up by hour over the vehicle's +// full charging history — the recommendation compares the driver's realized +// hour distribution to the greenest window, independent of any date window. +const recommendationChargingQuery = ` +SELECT EXTRACT(HOUR FROM started_at)::int AS hour, + COALESCE(SUM(total_energy_added_wh), 0) AS energy_wh +FROM charging_sessions +WHERE vehicle_id = $1 + AND total_energy_added_wh > 0 +GROUP BY 1` + +// Intensity serves GET /api/v1/carbon/intensity: the seeded diurnal grid model +// and its derived greenest/dirtiest hours. Read-only, vehicle-independent. +func (h *Handler) Intensity(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), carbonDataTimeout) + defer cancel() + + curve, err := h.loadCurve(ctx) + if err != nil { + log.Error().Err(err).Msg("carbon: failed to load intensity curve") + httpx.WriteError(w, http.StatusInternalServerError, "failed to load grid carbon intensity") + return + } + + minV, maxV, greenest, dirtiest := CurveStats(curve) + + // Round the wire values without mutating the loaded curve. + out := make([]HourIntensity, len(curve)) + for i, hh := range curve { + out[i] = HourIntensity{HourOfDay: hh.HourOfDay, GCO2PerKWh: round1(hh.GCO2PerKWh)} + } + + httpx.WriteJSON(w, http.StatusOK, IntensityCurveResponse{ + Curve: out, + Min: round1(minV), + Max: round1(maxV), + GreenestHours: greenest, + DirtiestHours: dirtiest, + }) +} + +// Summary serves GET /api/v1/vehicles/{vehicleID}/carbon/summary?from=&to=. +// It attributes CO2 to each session by its charging hour, compares the lifetime +// total to a distance-based gas-car baseline, and scores the charging timing. +func (h *Handler) Summary(w http.ResponseWriter, r *http.Request) { + vehicleID, err := apiparams.URLParamInt64(r, "vehicleID") + if err != nil || vehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "invalid vehicle ID") + return + } + + startTime, endTime := parseFromTo(r) + hasRange := !startTime.IsZero() && !endTime.IsZero() + + ctx, cancel := context.WithTimeout(r.Context(), carbonDataTimeout) + defer cancel() + + curve, err := h.loadCurve(ctx) + if err != nil { + log.Error().Err(err).Int64("vehicleID", vehicleID).Msg("carbon: summary: failed to load intensity curve") + httpx.WriteError(w, http.StatusInternalServerError, "failed to load grid carbon intensity") + return + } + lookup := intensityLookup(curve) + minV, maxV, _, _ := CurveStats(curve) + + rows, err := h.db.Query(ctx, summaryChargingQuery, vehicleID, + apiparams.NullableTime(hasRange, startTime), apiparams.NullableTime(hasRange, endTime)) + if err != nil { + log.Error().Err(err).Int64("vehicleID", vehicleID).Msg("carbon: summary: failed to query charging rollup") + httpx.WriteError(w, http.StatusInternalServerError, "failed to compute carbon summary") + return + } + defer rows.Close() + + monthEnergy := make(map[string]float64) + monthCO2 := make(map[string]float64) + var totalEnergyKwh, totalCO2Kg, weightedNum float64 + var sessionsScored int + for rows.Next() { + var month string + var hour int + var energyWh float64 + var sessionCount int64 + if err := rows.Scan(&month, &hour, &energyWh, &sessionCount); err != nil { + log.Error().Err(err).Int64("vehicleID", vehicleID).Msg("carbon: summary: scan charging row") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read carbon summary") + return + } + energyKwh := energyWh / 1000.0 + gi := lookup[hour] // seeded 0..23 ⇒ always present; missing ⇒ 0 (unscored) + co2 := SessionCO2Kg(energyKwh, gi) + + totalEnergyKwh += energyKwh + totalCO2Kg += co2 + weightedNum += energyKwh * gi + sessionsScored += int(sessionCount) + monthEnergy[month] += energyKwh + monthCO2[month] += co2 + } + if err := rows.Err(); err != nil { + log.Error().Err(err).Int64("vehicleID", vehicleID).Msg("carbon: summary: charging rows iteration") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read carbon summary") + return + } + + realizedAvg := 0.0 + if totalEnergyKwh > 0 { + realizedAvg = weightedNum / totalEnergyKwh + } + greenScore := 0.0 + if sessionsScored > 0 { + greenScore = GreenScore(realizedAvg, minV, maxV) + } + + // Distance → ICE gas-equivalent CO2. A missing drives row (no drives yet) + // scans as 0 via COALESCE, so ErrNoRows is not expected, but guard anyway. + var totalKm float64 + if err := h.db.QueryRow(ctx, summaryDistanceQuery, vehicleID, + apiparams.NullableTime(hasRange, startTime), apiparams.NullableTime(hasRange, endTime)). + Scan(&totalKm); err != nil { + if !errors.Is(err, pgx.ErrNoRows) { + log.Error().Err(err).Int64("vehicleID", vehicleID).Msg("carbon: summary: failed to query distance") + httpx.WriteError(w, http.StatusInternalServerError, "failed to compute carbon summary") + return + } + } + gasEquiv := GasEquivCO2Kg(totalKm) + + // Monthly trend, ascending by month (map iteration order is randomised). + months := make([]string, 0, len(monthEnergy)) + for m := range monthEnergy { + months = append(months, m) + } + sort.Strings(months) + monthly := make([]MonthlyCO2, 0, len(months)) + for _, m := range months { + monthly = append(monthly, MonthlyCO2{ + Month: m, + CO2Kg: safeF(round2(monthCO2[m])), + EnergyKwh: safeF(round2(monthEnergy[m])), + }) + } + + httpx.WriteJSON(w, http.StatusOK, SummaryResponse{ + TotalEnergyKwh: safeF(round2(totalEnergyKwh)), + TotalCO2Kg: safeF(round2(totalCO2Kg)), + GasEquivCO2Kg: safeF(round2(gasEquiv)), + CO2SavedKg: safeF(round2(gasEquiv - totalCO2Kg)), + GreenScore: safeF(round1(greenScore)), + SessionsScored: sessionsScored, + Monthly: monthly, + }) +} + +// Recommendation serves +// GET /api/v1/vehicles/{vehicleID}/carbon/recommendation: the driver's realized +// average charging intensity, the greenest contiguous window, and the CO2 that +// shifting into it would save over their observed charging. +func (h *Handler) Recommendation(w http.ResponseWriter, r *http.Request) { + vehicleID, err := apiparams.URLParamInt64(r, "vehicleID") + if err != nil || vehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "invalid vehicle ID") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), carbonDataTimeout) + defer cancel() + + curve, err := h.loadCurve(ctx) + if err != nil { + log.Error().Err(err).Int64("vehicleID", vehicleID).Msg("carbon: recommendation: failed to load intensity curve") + httpx.WriteError(w, http.StatusInternalServerError, "failed to load grid carbon intensity") + return + } + lookup := intensityLookup(curve) + + rows, err := h.db.Query(ctx, recommendationChargingQuery, vehicleID) + if err != nil { + log.Error().Err(err).Int64("vehicleID", vehicleID).Msg("carbon: recommendation: failed to query charging rollup") + httpx.WriteError(w, http.StatusInternalServerError, "failed to compute carbon recommendation") + return + } + defer rows.Close() + + energyByHour := make(map[int]float64) + var totalEnergyKwh float64 + for rows.Next() { + var hour int + var energyWh float64 + if err := rows.Scan(&hour, &energyWh); err != nil { + log.Error().Err(err).Int64("vehicleID", vehicleID).Msg("carbon: recommendation: scan charging row") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read carbon recommendation") + return + } + energyKwh := energyWh / 1000.0 + energyByHour[hour] += energyKwh + totalEnergyKwh += energyKwh + } + if err := rows.Err(); err != nil { + log.Error().Err(err).Int64("vehicleID", vehicleID).Msg("carbon: recommendation: charging rows iteration") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read carbon recommendation") + return + } + + currentAvg := EnergyWeightedIntensity(energyByHour, lookup) + startH, endH, windowAvg := GreenestWindow(curve, GreenestWindowHours) + savingKg, savingPct := PotentialSaving(totalEnergyKwh, currentAvg, windowAvg) + + httpx.WriteJSON(w, http.StatusOK, RecommendationResponse{ + CurrentAvgIntensity: safeF(round1(currentAvg)), + GreenestWindow: GreenestWindowDTO{ + StartHour: startH, + EndHour: endH, + AvgIntensity: safeF(round1(windowAvg)), + }, + PotentialCO2SavingKg: safeF(round2(savingKg)), + PotentialSavingPct: safeF(round1(savingPct)), + }) +} + +// parseFromTo reads the optional [from, to] window this endpoint documents, +// mirroring apiparams.ParseDateRange's two-format handling (RFC 3339 instants +// or YYYY-MM-DD calendar days) but on the `from`/`to` keys. An RFC 3339 `to` +// is treated as an EXCLUSIVE upper bound and nudged back one microsecond so it +// composes with the handler's inclusive BETWEEN. Missing/unparseable values +// yield zero times; the caller detects "unspecified" via IsZero(). +func parseFromTo(r *http.Request) (from, to time.Time) { + if s := r.URL.Query().Get("from"); s != "" { + if t, err := time.Parse(time.RFC3339, s); err == nil { + from = t + } else if t, err := time.Parse("2006-01-02", s); err == nil { + from = t + } + } + if s := r.URL.Query().Get("to"); s != "" { + if t, err := time.Parse(time.RFC3339, s); err == nil { + to = t.Add(-time.Microsecond) // exclusive → inclusive for BETWEEN + } else if t, err := time.Parse("2006-01-02", s); err == nil { + to = t.Add(24*time.Hour - time.Second) // end of day (UTC) + } + } + return from, to +} + +// loadCurve reads the seeded diurnal grid model. Returned rows are ordered by +// hour; the pure core tolerates a partial curve, but the migration guarantees +// all 24. Any read/scan failure is wrapped for the caller to log + 500. +func (h *Handler) loadCurve(ctx context.Context) ([]HourIntensity, error) { + rows, err := h.db.Query(ctx, intensityQuery) + if err != nil { + return nil, err + } + defer rows.Close() + + curve := make([]HourIntensity, 0, hoursPerDay) + for rows.Next() { + var hour int + var g float64 + if err := rows.Scan(&hour, &g); err != nil { + return nil, err + } + curve = append(curve, HourIntensity{HourOfDay: hour, GCO2PerKWh: g}) + } + if err := rows.Err(); err != nil { + return nil, err + } + return curve, nil +} diff --git a/internal/api/carbon/handler_test.go b/internal/api/carbon/handler_test.go new file mode 100644 index 0000000000..083938d54d --- /dev/null +++ b/internal/api/carbon/handler_test.go @@ -0,0 +1,683 @@ +package carbon + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/ev-dev-labs/teslasync/internal/database" + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/rs/zerolog" +) + +// TestMain silences the global zerolog logger so the intentional error-path +// logs (query failures, scan failures) don't clutter test output. +func TestMain(m *testing.M) { + zerolog.SetGlobalLevel(zerolog.Disabled) + m.Run() +} + +// --------------------------------------------------------------------------- +// Fake pgx plumbing. The module vendors no pgxmock (see routeeff / +// batterypassport / timemachine for the same precedent); the handler talks to a +// local carbonQuerier seam so tests supply scripted rows/row sources in call +// order without a live database. +// --------------------------------------------------------------------------- + +// assignScan copies scripted column values into Scan destinations, mirroring +// pgx's per-type scanning generically via reflection (allocating for nullable +// pointer fields). Same helper shape as the batterypassport tests. +func assignScan(dest, vals []any) error { + if len(dest) != len(vals) { + return fmt.Errorf("scan: %d destinations but row has %d values", len(dest), len(vals)) + } + for i := range dest { + dv := reflect.ValueOf(dest[i]) + if dv.Kind() != reflect.Pointer || dv.IsNil() { + return fmt.Errorf("scan: destination %d is not a non-nil pointer (%T)", i, dest[i]) + } + target := dv.Elem() + if !target.CanSet() { + return fmt.Errorf("scan: destination %d (%s) is not settable", i, target.Type()) + } + v := vals[i] + if v == nil { + target.Set(reflect.Zero(target.Type())) + continue + } + rv := reflect.ValueOf(v) + if target.Kind() == reflect.Pointer { + et := target.Type().Elem() + switch { + case rv.Type().AssignableTo(et): + p := reflect.New(et) + p.Elem().Set(rv) + target.Set(p) + case rv.Type().ConvertibleTo(et): + p := reflect.New(et) + p.Elem().Set(rv.Convert(et)) + target.Set(p) + default: + return fmt.Errorf("scan: cannot assign %T into nullable destination %d (%s)", v, i, target.Type()) + } + continue + } + switch { + case rv.Type().AssignableTo(target.Type()): + target.Set(rv) + case rv.Type().ConvertibleTo(target.Type()): + target.Set(rv.Convert(target.Type())) + default: + return fmt.Errorf("scan: cannot assign %T into destination %d (%s)", v, i, target.Type()) + } + } + return nil +} + +// fakeRow is a scripted pgx.Row for the distance QueryRow. +type fakeRow struct { + vals []any + err error +} + +func (r fakeRow) Scan(dest ...any) error { + if r.err != nil { + return r.err + } + return assignScan(dest, r.vals) +} + +var _ pgx.Row = fakeRow{} + +// fakeRows is a scripted pgx.Rows. Each element of data is one row's column +// values, positionally matching the handler's Scan destinations. +type fakeRows struct { + data [][]any + idx int + scanErr error // returned by Scan when idx == scanErrAt + scanErrAt int // 1-based row at which Scan fails; 0 = never + iterErr error // returned by Err() to simulate mid-stream iteration failure + closed bool +} + +func (r *fakeRows) Next() bool { + if r.idx >= len(r.data) { + return false + } + r.idx++ + return true +} + +func (r *fakeRows) Scan(dest ...any) error { + if r.scanErr != nil && r.idx == r.scanErrAt { + return r.scanErr + } + return assignScan(dest, r.data[r.idx-1]) +} + +func (r *fakeRows) Close() { r.closed = true } +func (r *fakeRows) Err() error { return r.iterErr } +func (r *fakeRows) CommandTag() pgconn.CommandTag { return pgconn.CommandTag{} } +func (r *fakeRows) FieldDescriptions() []pgconn.FieldDescription { return nil } +func (r *fakeRows) Values() ([]any, error) { return nil, nil } +func (r *fakeRows) RawValues() [][]byte { return nil } +func (r *fakeRows) Conn() *pgx.Conn { return nil } + +var _ pgx.Rows = (*fakeRows)(nil) + +// queryResult is one scripted Query outcome (rows OR an error). +type queryResult struct { + rows pgx.Rows + err error +} + +// fakePool returns scripted Query results in call order (each handler issues +// the intensity read first, then its rollup), scripted QueryRow results in call +// order (the summary distance read), and records the SQL/args it saw. +type fakePool struct { + queryResults []queryResult + queryIdx int + + queryRowResults []pgx.Row + queryRowIdx int + + querySQLs []string + queryArgs [][]any + queryRowSQLs []string + queryRowArgs [][]any +} + +func (p *fakePool) Query(_ context.Context, sql string, args ...any) (pgx.Rows, error) { + p.querySQLs = append(p.querySQLs, sql) + p.queryArgs = append(p.queryArgs, args) + if p.queryIdx >= len(p.queryResults) { + return nil, fmt.Errorf("fakePool: unexpected Query call #%d: %q", p.queryIdx+1, sql) + } + qr := p.queryResults[p.queryIdx] + p.queryIdx++ + if qr.err != nil { + return nil, qr.err + } + return qr.rows, nil +} + +func (p *fakePool) QueryRow(_ context.Context, sql string, args ...any) pgx.Row { + p.queryRowSQLs = append(p.queryRowSQLs, sql) + p.queryRowArgs = append(p.queryRowArgs, args) + if p.queryRowIdx >= len(p.queryRowResults) { + return fakeRow{err: errors.New("fakePool: unexpected QueryRow call")} + } + row := p.queryRowResults[p.queryRowIdx] + p.queryRowIdx++ + return row +} + +var _ carbonQuerier = (*fakePool)(nil) + +// --------------------------------------------------------------------------- +// Fixtures + helpers +// --------------------------------------------------------------------------- + +func testHandler(pool carbonQuerier) *Handler { return &Handler{db: pool} } + +// curveRows scripts the 24-row intensity read from the shared built-in curve. +func curveRows() *fakeRows { + c := builtInCurve() + data := make([][]any, 0, len(c)) + for _, h := range c { + data = append(data, []any{h.HourOfDay, h.GCO2PerKWh}) + } + return &fakeRows{data: data} +} + +func intensityReq() *http.Request { + return httptest.NewRequest(http.MethodGet, "/carbon/intensity", nil) +} + +func summaryReq(id, query string) *http.Request { + url := "/vehicles/" + id + "/carbon/summary" + if query != "" { + url += "?" + query + } + return reqWithParam(httptest.NewRequest(http.MethodGet, url, nil), id) +} + +func recommendationReq(id string) *http.Request { + return reqWithParam(httptest.NewRequest(http.MethodGet, "/vehicles/"+id+"/carbon/recommendation", nil), id) +} + +func reqWithParam(r *http.Request, id string) *http.Request { + rctx := chi.NewRouteContext() + rctx.URLParams.Add("vehicleID", id) + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) +} + +func decodeErr(t *testing.T, rec *httptest.ResponseRecorder) map[string]string { + t.Helper() + var m map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { + t.Fatalf("decode error body: %v (body=%q)", err, rec.Body.String()) + } + return m +} + +func decodeInto[T any](t *testing.T, rec *httptest.ResponseRecorder) T { + t.Helper() + var v T + if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil { + t.Fatalf("decode body: %v (body=%q)", err, rec.Body.String()) + } + return v +} + +// --------------------------------------------------------------------------- +// Constructor +// --------------------------------------------------------------------------- + +func TestNewCarbonHandler_NilDBPanics(t *testing.T) { + t.Parallel() + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic constructing handler with a nil *database.DB") + } + }() + _ = NewCarbonHandler(nil) +} + +func TestNewCarbonHandler_NilPoolPanics(t *testing.T) { + t.Parallel() + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic constructing handler with a nil pool") + } + }() + _ = NewCarbonHandler(&database.DB{}) +} + +// --------------------------------------------------------------------------- +// Intensity +// --------------------------------------------------------------------------- + +func TestIntensity_HappyPath(t *testing.T) { + t.Parallel() + pool := &fakePool{queryResults: []queryResult{{rows: curveRows()}}} + rec := httptest.NewRecorder() + testHandler(pool).Intensity(rec, intensityReq()) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%q)", rec.Code, rec.Body.String()) + } + got := decodeInto[IntensityCurveResponse](t, rec) + if len(got.Curve) != hoursPerDay { + t.Fatalf("curve length = %d, want 24", len(got.Curve)) + } + if got.Min != 200 || got.Max != 500 { + t.Errorf("min/max = %v/%v, want 200/500", got.Min, got.Max) + } + if !reflect.DeepEqual(got.GreenestHours, []int{12, 13}) { + t.Errorf("greenest_hours = %v, want [12 13]", got.GreenestHours) + } + if !reflect.DeepEqual(got.DirtiestHours, []int{19}) { + t.Errorf("dirtiest_hours = %v, want [19]", got.DirtiestHours) + } + if got.Curve[0].HourOfDay != 0 || got.Curve[0].GCO2PerKWh != 260 { + t.Errorf("curve[0] = %+v, want {0 260}", got.Curve[0]) + } +} + +func TestIntensity_QueryError(t *testing.T) { + t.Parallel() + pool := &fakePool{queryResults: []queryResult{{err: errors.New("boom")}}} + rec := httptest.NewRecorder() + testHandler(pool).Intensity(rec, intensityReq()) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + if msg := decodeErr(t, rec)["error"]; msg != "failed to load grid carbon intensity" { + t.Errorf("error = %q", msg) + } +} + +func TestIntensity_ScanError(t *testing.T) { + t.Parallel() + rows := &fakeRows{data: [][]any{{0, 260.0}}, scanErr: errors.New("bad scan"), scanErrAt: 1} + pool := &fakePool{queryResults: []queryResult{{rows: rows}}} + rec := httptest.NewRecorder() + testHandler(pool).Intensity(rec, intensityReq()) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } +} + +// --------------------------------------------------------------------------- +// Summary — validation +// --------------------------------------------------------------------------- + +func TestSummary_InvalidVehicleID(t *testing.T) { + t.Parallel() + for _, id := range []string{"", "abc", "0", "-5", "99999999999999999999"} { + id := id + t.Run("id="+id, func(t *testing.T) { + t.Parallel() + pool := &fakePool{queryResults: []queryResult{{rows: curveRows()}}} + rec := httptest.NewRecorder() + testHandler(pool).Summary(rec, summaryReq(id, "")) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body=%q)", rec.Code, rec.Body.String()) + } + if body := decodeErr(t, rec); body["error"] != "invalid vehicle ID" { + t.Errorf("error = %q, want 'invalid vehicle ID'", body["error"]) + } + if pool.queryIdx != 0 { + t.Errorf("data layer reached (%d Query calls) on invalid input", pool.queryIdx) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Summary — happy path +// --------------------------------------------------------------------------- + +func TestSummary_HappyPath(t *testing.T) { + t.Parallel() + // Two clusters: 10 kWh @ hour 12 (200 g) and 5 kWh @ hour 19 (500 g). + charging := &fakeRows{data: [][]any{ + {"2025-01", 12, 10000.0, int64(2)}, + {"2025-01", 19, 5000.0, int64(1)}, + }} + pool := &fakePool{ + queryResults: []queryResult{ + {rows: curveRows()}, // loadCurve + {rows: charging}, // summary charging rollup + }, + queryRowResults: []pgx.Row{ + fakeRow{vals: []any{500.0}}, // distance km + }, + } + rec := httptest.NewRecorder() + testHandler(pool).Summary(rec, summaryReq("42", "")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%q)", rec.Code, rec.Body.String()) + } + got := decodeInto[SummaryResponse](t, rec) + + // energy = 15 kWh; co2 = 10*200/1000 + 5*500/1000 = 2.0 + 2.5 = 4.5 kg + if got.TotalEnergyKwh != 15 { + t.Errorf("total_energy_kwh = %v, want 15", got.TotalEnergyKwh) + } + if got.TotalCO2Kg != 4.5 { + t.Errorf("total_co2_kg = %v, want 4.5", got.TotalCO2Kg) + } + // gas equiv = 500 km * 0.192 = 96.0; saved = 96 - 4.5 = 91.5 + if got.GasEquivCO2Kg != 96 { + t.Errorf("gas_equiv_co2_kg = %v, want 96", got.GasEquivCO2Kg) + } + if got.CO2SavedKg != 91.5 { + t.Errorf("co2_saved_kg = %v, want 91.5", got.CO2SavedKg) + } + // realized avg = 4500/15 = 300 ⇒ score = (500-300)/300*100 = 66.7 (round1) + if got.GreenScore != 66.7 { + t.Errorf("green_score = %v, want 66.7", got.GreenScore) + } + if got.SessionsScored != 3 { + t.Errorf("sessions_scored = %v, want 3", got.SessionsScored) + } + if len(got.Monthly) != 1 || got.Monthly[0].Month != "2025-01" || + got.Monthly[0].CO2Kg != 4.5 || got.Monthly[0].EnergyKwh != 15 { + t.Errorf("monthly = %+v, want [{2025-01 4.5 15}]", got.Monthly) + } +} + +func TestSummary_NoChargingData(t *testing.T) { + t.Parallel() + pool := &fakePool{ + queryResults: []queryResult{ + {rows: curveRows()}, + {rows: &fakeRows{}}, // no charging rows + }, + queryRowResults: []pgx.Row{ + fakeRow{vals: []any{0.0}}, // no distance + }, + } + rec := httptest.NewRecorder() + testHandler(pool).Summary(rec, summaryReq("42", "")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%q)", rec.Code, rec.Body.String()) + } + got := decodeInto[SummaryResponse](t, rec) + if got.TotalCO2Kg != 0 || got.SessionsScored != 0 || got.GreenScore != 0 { + t.Errorf("empty summary = %+v, want zeroed totals + score", got) + } + if got.Monthly == nil { + t.Error("monthly must be a non-nil empty slice, not null") + } + if len(got.Monthly) != 0 { + t.Errorf("monthly = %v, want empty", got.Monthly) + } +} + +// TestSummary_RangeParamsForwarded pins the [from,to] window onto BOTH the +// charging rollup and the distance read (the NULL-guarded BETWEEN contract). +func TestSummary_RangeParamsForwarded(t *testing.T) { + t.Parallel() + pool := &fakePool{ + queryResults: []queryResult{ + {rows: curveRows()}, + {rows: &fakeRows{}}, + }, + queryRowResults: []pgx.Row{fakeRow{vals: []any{0.0}}}, + } + rec := httptest.NewRecorder() + testHandler(pool).Summary(rec, summaryReq("42", "from=2025-01-01&to=2025-02-01")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%q)", rec.Code, rec.Body.String()) + } + // Query #2 is the charging rollup: args = (vehicleID, start, end). + if len(pool.queryArgs) < 2 { + t.Fatalf("expected >=2 Query calls, got %d", len(pool.queryArgs)) + } + chargingArgs := pool.queryArgs[1] + if len(chargingArgs) != 3 || chargingArgs[1] == nil || chargingArgs[2] == nil { + t.Errorf("charging rollup args = %v, want (id, non-nil start, non-nil end)", chargingArgs) + } + // Distance QueryRow also receives the window. + if len(pool.queryRowArgs) != 1 || len(pool.queryRowArgs[0]) != 3 || + pool.queryRowArgs[0][1] == nil || pool.queryRowArgs[0][2] == nil { + t.Errorf("distance args = %v, want (id, non-nil start, non-nil end)", pool.queryRowArgs) + } +} + +// --------------------------------------------------------------------------- +// Summary — error paths +// --------------------------------------------------------------------------- + +func TestSummary_ErrorPaths(t *testing.T) { + t.Parallel() + sentinel := errors.New("boom") + tests := []struct { + name string + pool *fakePool + wantMsg string + }{ + { + name: "curve query fails", + pool: &fakePool{queryResults: []queryResult{{err: sentinel}}}, + wantMsg: "failed to load grid carbon intensity", + }, + { + name: "charging query fails", + pool: &fakePool{queryResults: []queryResult{ + {rows: curveRows()}, {err: sentinel}, + }}, + wantMsg: "failed to compute carbon summary", + }, + { + name: "charging scan fails", + pool: &fakePool{queryResults: []queryResult{ + {rows: curveRows()}, + {rows: &fakeRows{data: [][]any{{"2025-01", 12, 10000.0, int64(1)}}, scanErr: sentinel, scanErrAt: 1}}, + }}, + wantMsg: "failed to read carbon summary", + }, + { + name: "charging iteration fails", + pool: &fakePool{queryResults: []queryResult{ + {rows: curveRows()}, + {rows: &fakeRows{iterErr: sentinel}}, + }}, + wantMsg: "failed to read carbon summary", + }, + { + name: "distance query fails", + pool: &fakePool{ + queryResults: []queryResult{ + {rows: curveRows()}, + {rows: &fakeRows{}}, + }, + queryRowResults: []pgx.Row{fakeRow{err: sentinel}}, + }, + wantMsg: "failed to compute carbon summary", + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + testHandler(tc.pool).Summary(rec, summaryReq("42", "")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 (body=%q)", rec.Code, rec.Body.String()) + } + if msg := decodeErr(t, rec)["error"]; msg != tc.wantMsg { + t.Errorf("error = %q, want %q", msg, tc.wantMsg) + } + }) + } +} + +// A NULL distance (ErrNoRows) must NOT fail the summary — it degrades to 0 km. +func TestSummary_DistanceNoRowsTolerated(t *testing.T) { + t.Parallel() + pool := &fakePool{ + queryResults: []queryResult{ + {rows: curveRows()}, + {rows: &fakeRows{}}, + }, + queryRowResults: []pgx.Row{fakeRow{err: pgx.ErrNoRows}}, + } + rec := httptest.NewRecorder() + testHandler(pool).Summary(rec, summaryReq("42", "")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%q)", rec.Code, rec.Body.String()) + } + got := decodeInto[SummaryResponse](t, rec) + if got.GasEquivCO2Kg != 0 { + t.Errorf("gas_equiv_co2_kg = %v, want 0 on ErrNoRows", got.GasEquivCO2Kg) + } +} + +// --------------------------------------------------------------------------- +// Recommendation +// --------------------------------------------------------------------------- + +func TestRecommendation_InvalidVehicleID(t *testing.T) { + t.Parallel() + for _, id := range []string{"", "abc", "0", "-5"} { + id := id + t.Run("id="+id, func(t *testing.T) { + t.Parallel() + pool := &fakePool{queryResults: []queryResult{{rows: curveRows()}}} + rec := httptest.NewRecorder() + testHandler(pool).Recommendation(rec, recommendationReq(id)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if pool.queryIdx != 0 { + t.Errorf("data layer reached (%d Query calls) on invalid input", pool.queryIdx) + } + }) + } +} + +func TestRecommendation_HappyPath(t *testing.T) { + t.Parallel() + // 20 kWh @ hour 1 (250 g) + 10 kWh @ hour 19 (500 g). + charging := &fakeRows{data: [][]any{ + {1, 20000.0}, + {19, 10000.0}, + }} + pool := &fakePool{queryResults: []queryResult{ + {rows: curveRows()}, + {rows: charging}, + }} + rec := httptest.NewRecorder() + testHandler(pool).Recommendation(rec, recommendationReq("42")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%q)", rec.Code, rec.Body.String()) + } + got := decodeInto[RecommendationResponse](t, rec) + + // current avg = (20*250 + 10*500)/30 = 10000/30 = 333.33 ⇒ round1 333.3 + if got.CurrentAvgIntensity != 333.3 { + t.Errorf("current_avg_intensity = %v, want 333.3", got.CurrentAvgIntensity) + } + // greenest window = [12,15) avg (200+200+205)/3 = 201.67 ⇒ round1 201.7 + if got.GreenestWindow.StartHour != 12 || got.GreenestWindow.EndHour != 15 { + t.Errorf("greenest_window = [%d,%d), want [12,15)", got.GreenestWindow.StartHour, got.GreenestWindow.EndHour) + } + if got.GreenestWindow.AvgIntensity != 201.7 { + t.Errorf("greenest_window.avg_intensity = %v, want 201.7", got.GreenestWindow.AvgIntensity) + } + // saving = 30 * (333.33-201.67)/1000 = 3.95 kg; pct = 131.67/333.33*100 = 39.5 + if got.PotentialCO2SavingKg != 3.95 { + t.Errorf("potential_co2_saving_kg = %v, want 3.95", got.PotentialCO2SavingKg) + } + if got.PotentialSavingPct != 39.5 { + t.Errorf("potential_saving_pct = %v, want 39.5", got.PotentialSavingPct) + } +} + +func TestRecommendation_NoChargingData(t *testing.T) { + t.Parallel() + pool := &fakePool{queryResults: []queryResult{ + {rows: curveRows()}, + {rows: &fakeRows{}}, + }} + rec := httptest.NewRecorder() + testHandler(pool).Recommendation(rec, recommendationReq("42")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%q)", rec.Code, rec.Body.String()) + } + got := decodeInto[RecommendationResponse](t, rec) + // No charging ⇒ realized avg 0, nothing to save, but the greenest window is + // still surfaced from the curve so the UI can always advise a window. + if got.CurrentAvgIntensity != 0 || got.PotentialCO2SavingKg != 0 || got.PotentialSavingPct != 0 { + t.Errorf("no-data recommendation = %+v, want zeroed current/savings", got) + } + if got.GreenestWindow.StartHour != 12 || got.GreenestWindow.EndHour != 15 { + t.Errorf("greenest_window = [%d,%d), want [12,15) even with no charging", + got.GreenestWindow.StartHour, got.GreenestWindow.EndHour) + } +} + +func TestRecommendation_ErrorPaths(t *testing.T) { + t.Parallel() + sentinel := errors.New("boom") + tests := []struct { + name string + pool *fakePool + wantMsg string + }{ + { + name: "curve query fails", + pool: &fakePool{queryResults: []queryResult{{err: sentinel}}}, + wantMsg: "failed to load grid carbon intensity", + }, + { + name: "charging query fails", + pool: &fakePool{queryResults: []queryResult{ + {rows: curveRows()}, {err: sentinel}, + }}, + wantMsg: "failed to compute carbon recommendation", + }, + { + name: "charging scan fails", + pool: &fakePool{queryResults: []queryResult{ + {rows: curveRows()}, + {rows: &fakeRows{data: [][]any{{1, 20000.0}}, scanErr: sentinel, scanErrAt: 1}}, + }}, + wantMsg: "failed to read carbon recommendation", + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + testHandler(tc.pool).Recommendation(rec, recommendationReq("42")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 (body=%q)", rec.Code, rec.Body.String()) + } + if msg := decodeErr(t, rec)["error"]; msg != tc.wantMsg { + t.Errorf("error = %q, want %q", msg, tc.wantMsg) + } + }) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 294c5cc5a5..00e0807dae 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -90,6 +90,8 @@ import ( apibattery "github.com/ev-dev-labs/teslasync/internal/api/battery" "github.com/ev-dev-labs/teslasync/internal/api/batterycells" "github.com/ev-dev-labs/teslasync/internal/api/batterydegradation" + "github.com/ev-dev-labs/teslasync/internal/api/batterypassport" + apicarbon "github.com/ev-dev-labs/teslasync/internal/api/carbon" apichargeheatmap "github.com/ev-dev-labs/teslasync/internal/api/chargeheatmap" apichargeopt "github.com/ev-dev-labs/teslasync/internal/api/chargeopt" "github.com/ev-dev-labs/teslasync/internal/api/chargeplanner" @@ -145,11 +147,13 @@ import ( apirbac "github.com/ev-dev-labs/teslasync/internal/api/rbac" apiregen "github.com/ev-dev-labs/teslasync/internal/api/regen" apirouteeff "github.com/ev-dev-labs/teslasync/internal/api/routeeff" + apirul "github.com/ev-dev-labs/teslasync/internal/api/rul" apisafety "github.com/ev-dev-labs/teslasync/internal/api/safety" apisaved "github.com/ev-dev-labs/teslasync/internal/api/savedviews" apischedexp "github.com/ev-dev-labs/teslasync/internal/api/scheduledexports" apisearch "github.com/ev-dev-labs/teslasync/internal/api/search" apisecurity "github.com/ev-dev-labs/teslasync/internal/api/security" + apisegments "github.com/ev-dev-labs/teslasync/internal/api/segments" apisess "github.com/ev-dev-labs/teslasync/internal/api/session" apisettings "github.com/ev-dev-labs/teslasync/internal/api/settings" apisetreset "github.com/ev-dev-labs/teslasync/internal/api/settingsreset" @@ -175,6 +179,7 @@ import ( apituc "github.com/ev-dev-labs/teslasync/internal/api/teslauserconfig" apituo "github.com/ev-dev-labs/teslasync/internal/api/teslauserorder" apitup "github.com/ev-dev-labs/teslasync/internal/api/teslauserprofile" + apitimemachine "github.com/ev-dev-labs/teslasync/internal/api/timemachine" apitirepressure "github.com/ev-dev-labs/teslasync/internal/api/tirepressure" apitotp "github.com/ev-dev-labs/teslasync/internal/api/totp" apitrip "github.com/ev-dev-labs/teslasync/internal/api/trip" @@ -817,6 +822,9 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie backupRestoreHandler := apibackup.NewRestoreHandler(db) regenHandler := apiregen.NewRegenHandler(db) batteryDegradationHandler := batterydegradation.NewHandler(db, stateReader, signalLogReader) + batteryPassportHandler := batterypassport.NewBatteryPassportHandler(db) + carbonHandler := apicarbon.NewCarbonHandler(db) + rulHandler := apirul.NewRULHandler(db) auditHandler := apiaudit.NewAuditHandler(db, cfg.Auth.ForwardAuthHeader) maskedRevealHandler := apiaudit.NewMaskedRevealHandler(auditRepo, cfg.Auth.ForwardAuthHeader) apiCallLogHandler := apicalllog.NewHandler(db) @@ -833,6 +841,8 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie dataRepairHandler := apidatarepair.NewDataRepairHandler(db) tempImpactHandler := tempimpact.NewHandler(db) routeEfficiencyHandler := apirouteeff.NewRouteEfficiencyHandler(db) + timeMachineHandler := apitimemachine.NewTimeMachineHandler(db) + segmentsHandler := apisegments.NewSegmentsHandler(db) batteryCellsHandler := batterycells.NewHandler(db, alertLiveSignalStore, stateReader, signalLogReader) rangeProjectionHandler := apirangeproj.NewRangeProjectionHandler(db, stateReader) drivetrainHealthHandler := apidrivetrain.NewDrivetrainHealthHandler(db, stateReader) @@ -2904,8 +2914,59 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie r.Get("/battery", batteryHandler.Report) r.Get("/battery/cells", batteryCellsHandler.GetByVehicle) r.Get("/battery/projected-range", rangeProjectionHandler.GetByVehicle) + + // Battery Passport — a verifiable, tamper-evident SoH + // provenance certificate. Both routes are read-only: the + // GET builds the certificate (and best-effort appends an + // audit-ledger snapshot), and /verify recomputes the + // provenance hash so a buyer can detect tampering. No rate + // limit — the SPA reads them on page load and the ledger + // write is off the read's critical path. + r.Get("/battery-passport", batteryPassportHandler.Get) + r.Get("/battery-passport/verify", batteryPassportHandler.Verify) r.Get("/weekly-digest", weeklyDigestHandler.Get) + // Vehicle Time Machine — reconstruct the complete signal + // state at any past instant from the signal_log cold path. + // Both routes are read-only and rate-limit-free: the SPA + // polls them as the user drags the timeline scrubber, and + // the point-in-time query is index-served + field-capped. + r.Get("/time-machine", timeMachineHandler.State) + r.Get("/time-machine/range", timeMachineHandler.Range) + + // Carbon Intelligence — grid-aware CO2 accounting for + // charging. /summary attributes CO2 to each session by its + // charging hour and scores the timing vs a gas-car baseline; + // /recommendation surfaces the greenest charging window. Both + // are read-only and rate-limit-free (the SPA reads them on page + // load; the diurnal grid model is a tiny 24-row table). The + // vehicle-independent curve is served at the top-level + // GET /api/v1/carbon/intensity route below. + r.Get("/carbon/summary", carbonHandler.Summary) + r.Get("/carbon/recommendation", carbonHandler.Recommendation) + + // Remaining Useful Life — predictive component prognostics. + // /rul returns the whole health board (per-component remaining + // days/km, projected replace-by date, confidence, status) plus + // the nearest upcoming service; /rul/{component} adds the + // configured reference figures and a forecast series for the + // end-of-life chart. Both are read-only and rate-limit-free (the + // SPA reads them on page load; the prognosis is computed from + // cached daily roll-ups + a tiny config table). + r.Get("/rul", rulHandler.RUL) + r.Get("/rul/{component}", rulHandler.Component) + + // Ghost Racing / EV Segments — Strava-style route segments. + // /segments detects the vehicle's repeated start→end routes + // from its drive history, best-effort persists each (so it + // earns a stable id), and returns a personal-best-by-time / + // -by-efficiency summary per segment. Read-only and + // rate-limit-free: the SPA reads it on page load and the + // clustering is computed from the bounded drives table. The + // segment-scoped leaderboard + ghost race live at the + // top-level /api/v1/segments/{segmentID}/* routes below. + r.Get("/segments", segmentsHandler.List) + // Vehicle access: drivers & share invitations r.Route("/drivers", func(r chi.Router) { r.Get("/", vehicleAccessHandler.ListDrivers) @@ -3297,6 +3358,23 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie // Analytics r.Get("/analytics/fleet", analyticsHandler.Fleet) r.Get("/analytics/tco", tcoHandler.GetTCO) + + // Carbon Intelligence — the vehicle-independent diurnal grid + // carbon-intensity model (seeded, admin-editable). Mounted as a + // top-level /api/v1 route because the curve is shared by every + // vehicle; the per-vehicle carbon summary/recommendation live under + // /vehicles/{vehicleID}/carbon/* above. + r.Get("/carbon/intensity", carbonHandler.Intensity) + + // Ghost Racing / EV Segments — segment-scoped reads addressed by the + // stable route_segments id (the per-vehicle list at + // /vehicles/{vehicleID}/segments above persists and hands out the id). + // /leaderboard ranks every attempt on the segment by time AND by energy + // efficiency; /ghost aligns two attempts (a=&b=) onto a shared + // distance-fraction axis for a head-to-head ghost playback. Both are + // read-only and rate-limit-free. + r.Get("/segments/{segmentID}/leaderboard", segmentsHandler.Leaderboard) + r.Get("/segments/{segmentID}/ghost", segmentsHandler.Ghost) r.Get("/analytics/sleep", sleepHandler.GetSleepAnalytics) r.Get("/analytics/regen", regenHandler.Stats) r.Get("/analytics/battery-degradation", batteryDegradationHandler.Predict) diff --git a/internal/api/rul/doc.go b/internal/api/rul/doc.go new file mode 100644 index 0000000000..fcfae26706 --- /dev/null +++ b/internal/api/rul/doc.go @@ -0,0 +1,31 @@ +// Package rul serves the Remaining Useful Life (RUL) endpoints: predictive +// component prognostics. For each wear component it estimates the remaining +// useful life (in days and km), a projected "replace-by" date, a confidence +// band, and a health status — FORECASTING end-of-life, going beyond the +// AnomalyDashboard / DrivetrainHealth surfaces which only detect issues that +// are already present. +// +// Data sources (all read-only): +// +// - HV battery — a daily State-of-Health series reconstructed from signal_log +// EnergyRemaining + BatteryLevel (the same SoH/capacity approach as +// internal/api/batterydegradation; cagg_battery_daily is the sibling daily +// roll-up but materialises State-of-Charge, not usable capacity, so the +// SoH trend is rebuilt from the raw energy/SoC pair). A linear fit gives +// the degradation rate; the fit's R² + history length give the confidence. +// - Tires / brakes — distance wear from the drives odometer: km/day from +// recent accumulation, km-since-reference from the lifetime odometer +// (a whole-life proxy — there is no per-service reset feed). +// - 12V battery / cabin filter — age-based from the vehicle's enrollment date +// versus a nominal calendar life. +// +// The regression (slope + R²), rate math, EOL projection, status +// classification, and forecast-series generation are PURE, deterministic, +// table-tested functions in prognostics.go. The handler only parses/validates +// requests, reads the pgx pool, folds rows through the pure core, and writes +// snake_case JSON. The per-component service-life model is a seeded, +// admin-editable table (migrations/000218_component_lifespans) so the feature +// works self-hosted with no external service-schedule API. +// +// Layer: handler +package rul diff --git a/internal/api/rul/dtos.go b/internal/api/rul/dtos.go new file mode 100644 index 0000000000..ecd08511c8 --- /dev/null +++ b/internal/api/rul/dtos.go @@ -0,0 +1,78 @@ +package rul + +// DTOs and configuration types for the Remaining Useful Life endpoints. All +// JSON tags are snake_case to match the frontend wire contract; nullable Go +// pointers map to `T | null` in TypeScript. Numeric fields are rounded at the +// handler/pure-core boundary. SI-canonical: distance in km, time in days. + +// ComponentConfig is one row of the seeded, admin-editable component_lifespans +// table (migration 000218). Nullable columns are pointers so an absent km life +// (calendar-wear part) or days life (distance-wear part) is distinguishable +// from a zero. +type ComponentConfig struct { + Component string + NominalLifeKm *float64 + NominalLifeDays *int + EOLThreshold *float64 + Notes string +} + +// ComponentRUL is the per-component prognosis returned by both endpoints. +// +// - HealthPct — the display health metric: % State of Health for the +// HV battery, % of nominal life remaining for wear/age parts. +// - WearRatePerDay — decline in HealthPct per day (SoH %/day for the +// battery, life %/day for wear/age parts). +// - RemainingDays — projected days until the EOL threshold. 0 when overdue; +// a large sentinel when no measurable wear yet (see ProjectedEOLDate). +// - RemainingKm — projected km until EOL for distance-wear parts; null +// for the battery and calendar-wear parts. +// - ProjectedEOLDate — YYYY-MM-DD "replace-by" date; null when the rate is +// indeterminate (sparse/flat data) so the UI shows "—" rather than a +// fabricated date. +// - Confidence — 0..1 trust in the estimate. +// - Status — healthy | watch | replace_soon | overdue. +// - Basis — human-readable sentence explaining how it was derived. +type ComponentRUL struct { + Component string `json:"component"` + Label string `json:"label"` + HealthPct float64 `json:"health_pct"` + WearRatePerDay float64 `json:"wear_rate_per_day"` + RemainingDays float64 `json:"remaining_days"` + RemainingKm *float64 `json:"remaining_km"` + ProjectedEOLDate *string `json:"projected_eol_date"` + Confidence float64 `json:"confidence"` + Status string `json:"status"` + Basis string `json:"basis"` +} + +// NextService is the single most-urgent upcoming replacement — the component +// with the nearest projected EOL date. Date is null when nothing is +// projectable. +type NextService struct { + Component string `json:"component"` + Date *string `json:"date"` +} + +// RULResponse is the body of GET /api/v1/vehicles/{vehicleID}/rul. Components is +// always a non-nil slice (possibly empty) so the frontend never guards a null +// array; NextService is null when no component has a projectable EOL date. +type RULResponse struct { + VehicleID int64 `json:"vehicle_id"` + Components []ComponentRUL `json:"components"` + NextService *NextService `json:"next_service"` +} + +// ComponentDetailResponse is the body of +// GET /api/v1/vehicles/{vehicleID}/rul/{component}: the component's prognosis +// (embedded, so its fields inline into the JSON object) plus the configured +// reference figures and a forecast series from today to the projected EOL for +// the chart. +type ComponentDetailResponse struct { + ComponentRUL + EOLThreshold *float64 `json:"eol_threshold"` + NominalLifeKm *float64 `json:"nominal_life_km"` + NominalLifeDays *int `json:"nominal_life_days"` + Notes string `json:"notes"` + Projection []ProjectionPoint `json:"projection"` +} diff --git a/internal/api/rul/handler.go b/internal/api/rul/handler.go new file mode 100644 index 0000000000..a4b55b30a7 --- /dev/null +++ b/internal/api/rul/handler.go @@ -0,0 +1,427 @@ +package rul + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "github.com/ev-dev-labs/teslasync/internal/api/apiparams" + "github.com/ev-dev-labs/teslasync/internal/api/httpx" + "github.com/ev-dev-labs/teslasync/internal/database" + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" + "github.com/rs/zerolog/log" +) + +// rulDataTimeout bounds each analytics read so a stalled connection cannot pin +// the request goroutine longer than the boundary rule allows. The pool's +// server-side statement_timeout is the backstop; this is the client-side +// deadline. A var (not const) so tests can shorten it (mirrors +// carbon.carbonDataTimeout / routeeff.routeEffDataTimeout). +var rulDataTimeout = 15 * time.Second + +// rulQuerier is the minimal pgx surface the handler needs. Declared locally so +// tests can drive every branch with scripted row/rows sources without a live +// database or a vendored pgxmock (mirrors carbon.carbonQuerier / +// routeeff.routeQuerier). *pgxpool.Pool satisfies it. +type rulQuerier interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + +// Handler serves the Remaining Useful Life endpoints. +type Handler struct { + db rulQuerier +} + +// NewRULHandler wires the handler to the pgx pool. Panics on a nil pool — a nil +// pool is a wiring bug, not a runtime condition, so it surfaces at construction +// rather than as a nil-deref on the first request (mirrors +// carbon.NewCarbonHandler / routeeff.NewRouteEfficiencyHandler). +func NewRULHandler(db *database.DB) *Handler { + if db == nil || db.Pool == nil { + panic("rul.NewRULHandler: db pool must not be nil") + } + return &Handler{db: db.Pool} +} + +// componentSpec is the static description of a tracked component: its stable +// machine key, human label, and which prognosis model applies. The slice is +// ordered so both endpoints and the UI present a stable, deliberate sequence +// (battery first, then the fast-moving wear parts, then calendar parts). +type componentSpec struct { + Component string + Label string + Kind componentKind +} + +type componentKind int + +const ( + kindBattery componentKind = iota // SoH regression on the reconstructed series + kindWear // distance wear from the odometer + kindAge // calendar wear from the enrollment date +) + +var componentSpecs = []componentSpec{ + {Component: "hv_battery", Label: "High-Voltage Battery", Kind: kindBattery}, + {Component: "lv_battery", Label: "12V Battery", Kind: kindAge}, + {Component: "tires", Label: "Tires", Kind: kindWear}, + {Component: "brakes", Label: "Brakes", Kind: kindWear}, + {Component: "cabin_filter", Label: "Cabin Air Filter", Kind: kindAge}, +} + +// specByComponent indexes componentSpecs for O(1) lookup / validation. +var specByComponent = func() map[string]componentSpec { + m := make(map[string]componentSpec, len(componentSpecs)) + for _, s := range componentSpecs { + m[s.Component] = s + } + return m +}() + +// Windowing constants. The SQL below hard-codes these same intervals (pgx +// interval literals cannot be a bound $-param cleanly); keep the two in sync. +const ( + // sohWindowDays bounds the SoH reconstruction lookback — long enough to see + // a degradation trend without scanning the whole hypertable. + sohWindowDays = 400 + // recentWindowDays bounds the odometer accumulation window used for km/day. + recentWindowDays = 90 + // defaultCapacityWh mirrors batterydegradation's fallback pack size. + defaultCapacityWh = 75000.0 +) + +// --- SQL. Package-level constants so tests can pin the critical clauses. --- + +// configsQuery reads the seeded per-component service-life model (migration +// 000218), ordered for stable output. +const configsQuery = ` +SELECT component, nominal_life_km, nominal_life_days, eol_threshold, notes +FROM component_lifespans +ORDER BY component` + +// vehicleQuery reads the fields needed to size the pack (VIN/model → nominal +// capacity) and to age the calendar-wear parts (enrolled_at). +const vehicleQuery = ` +SELECT vin, model, enrolled_at +FROM vehicles +WHERE id = $1` + +// sohSeriesQuery reconstructs a daily State-of-Health series: per day, the peak +// EnergyRemaining (Wh) and peak BatteryLevel (% SoC). cagg_battery_daily is the +// sibling daily roll-up but materialises SoC, not usable capacity, so the SoH +// trend is rebuilt here from the raw energy/SoC pair (batterydegradation's +// approach). The HAVING guarantees both signals are present on a retained day. +const sohSeriesQuery = ` +SELECT date_trunc('day', ts) AS day, + MAX(float_value) FILTER (WHERE field = 'EnergyRemaining') AS max_energy_wh, + MAX(float_value) FILTER (WHERE field = 'BatteryLevel') AS max_soc_pct +FROM signal_log +WHERE vehicle_id = $1 + AND field IN ('EnergyRemaining', 'BatteryLevel') + AND ts > NOW() - INTERVAL '400 days' + AND float_value IS NOT NULL +GROUP BY 1 +HAVING MAX(float_value) FILTER (WHERE field = 'EnergyRemaining') IS NOT NULL + AND MAX(float_value) FILTER (WHERE field = 'BatteryLevel') IS NOT NULL +ORDER BY 1` + +// odometerQuery derives the whole-life distance proxy in one aggregate row: the +// lifetime odometer (MAX end_odometer_m), the distance driven in the recent +// window, the drive count backing that rate, and the active span between the +// first and last drive in the window (so km/day reflects real driving, not idle +// calendar days). positions has no odometer column, so drives is the source. +const odometerQuery = ` +SELECT COALESCE(MAX(end_odometer_m), 0) AS total_odometer_m, + COALESCE(SUM(distance_m) FILTER (WHERE started_at > NOW() - INTERVAL '90 days'), 0) AS recent_distance_m, + COUNT(*) FILTER (WHERE started_at > NOW() - INTERVAL '90 days' AND distance_m IS NOT NULL) AS recent_samples, + COALESCE(EXTRACT(EPOCH FROM ( + MAX(started_at) FILTER (WHERE started_at > NOW() - INTERVAL '90 days') + - MIN(started_at) FILTER (WHERE started_at > NOW() - INTERVAL '90 days') + )) / 86400.0, 0) AS recent_span_days +FROM drives +WHERE vehicle_id = $1` + +// gathered is the raw material the pure core folds into prognoses. +type gathered struct { + configs map[string]ComponentConfig + enrolledAt time.Time + capacityWh float64 + sohSeries []Point + totalKm float64 + kmPerDay float64 + driveCount int +} + +// loadConfigs reads the component service-life model. Returned map is keyed by +// component; a missing component (admin deleted a row) is simply absent. +func (h *Handler) loadConfigs(ctx context.Context) (map[string]ComponentConfig, error) { + rows, err := h.db.Query(ctx, configsQuery) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make(map[string]ComponentConfig, len(componentSpecs)) + for rows.Next() { + var c ComponentConfig + var notes *string + if err := rows.Scan(&c.Component, &c.NominalLifeKm, &c.NominalLifeDays, &c.EOLThreshold, ¬es); err != nil { + return nil, err + } + if notes != nil { + c.Notes = *notes + } + out[c.Component] = c + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +// loadVehicle reads the enrollment date and derives the nominal pack capacity +// from VIN/model. A missing vehicle is not an error here (mirrors carbon's +// graceful zero-data handling): it degrades to "enrolled today" + default +// capacity so the board renders an all-healthy, low-confidence result rather +// than a 404 for a vehicle that simply has no telemetry yet. +func (h *Handler) loadVehicle(ctx context.Context, vehicleID int64, now time.Time) (enrolledAt time.Time, capacityWh float64, err error) { + var vin string + var model *string + var enrolled time.Time + scanErr := h.db.QueryRow(ctx, vehicleQuery, vehicleID).Scan(&vin, &model, &enrolled) + if scanErr != nil { + if errors.Is(scanErr, pgx.ErrNoRows) { + return now, defaultCapacityWh, nil + } + return time.Time{}, 0, scanErr + } + m := "" + if model != nil { + m = *model + } + cap, _ := estimateBatteryCapacityWh(vin, m) + return enrolled, cap, nil +} + +// loadSoHSeries reads the daily (max energy, max SoC) pairs and normalises them +// into an SoH series via the pure BuildSoHSeries. Each retained day becomes one +// regression point. +func (h *Handler) loadSoHSeries(ctx context.Context, vehicleID int64, capacityWh float64) ([]Point, error) { + rows, err := h.db.Query(ctx, sohSeriesQuery, vehicleID) + if err != nil { + return nil, err + } + defer rows.Close() + + var daily []DailyBattery + for rows.Next() { + var day time.Time + var energy, soc *float64 + if err := rows.Scan(&day, &energy, &soc); err != nil { + return nil, err + } + if energy == nil || soc == nil { + continue + } + daily = append(daily, DailyBattery{Day: day, MaxEnergyWh: *energy, MaxSocPct: *soc}) + } + if err := rows.Err(); err != nil { + return nil, err + } + return BuildSoHSeries(daily, capacityWh), nil +} + +// loadOdometer reads the single aggregate row and folds it into the whole-life +// distance figures. A vehicle with no drives scans as all-zero via COALESCE. +func (h *Handler) loadOdometer(ctx context.Context, vehicleID int64) (totalKm, kmPerDay float64, driveCount int, err error) { + var totalM, recentM, spanDays float64 + var samples int64 + scanErr := h.db.QueryRow(ctx, odometerQuery, vehicleID).Scan(&totalM, &recentM, &samples, &spanDays) + if scanErr != nil { + if errors.Is(scanErr, pgx.ErrNoRows) { + return 0, 0, 0, nil + } + return 0, 0, 0, scanErr + } + totalKm = totalM / 1000.0 + kmPerDay = KmPerDay(recentM/1000.0, spanDays) + return totalKm, kmPerDay, int(samples), nil +} + +// gather runs the four reads in a FIXED order — Query: [configs, soh]; +// QueryRow: [vehicle, odometer] — so a fake pool can script results by call +// order. Any read error propagates for the caller to log + 500. Each helper +// closes its own rows (no defer-in-loop). +func (h *Handler) gather(ctx context.Context, vehicleID int64, now time.Time) (*gathered, error) { + configs, err := h.loadConfigs(ctx) + if err != nil { + return nil, err + } + enrolledAt, capacityWh, err := h.loadVehicle(ctx, vehicleID, now) + if err != nil { + return nil, err + } + sohSeries, err := h.loadSoHSeries(ctx, vehicleID, capacityWh) + if err != nil { + return nil, err + } + totalKm, kmPerDay, driveCount, err := h.loadOdometer(ctx, vehicleID) + if err != nil { + return nil, err + } + return &gathered{ + configs: configs, + enrolledAt: enrolledAt, + capacityWh: capacityWh, + sohSeries: sohSeries, + totalKm: totalKm, + kmPerDay: kmPerDay, + driveCount: driveCount, + }, nil +} + +// buildComponents folds the gathered data through the pure per-component +// builders, in componentSpecs order. It returns the prognoses AND their +// projection inputs (keyed by component) so the detail endpoint can render a +// forecast without recomputing. Deterministic given `now`. +func (h *Handler) buildComponents(g *gathered, now time.Time) ([]ComponentRUL, map[string]projInputs) { + comps := make([]ComponentRUL, 0, len(componentSpecs)) + proj := make(map[string]projInputs, len(componentSpecs)) + ageDays := now.Sub(g.enrolledAt).Hours() / 24.0 + + for _, spec := range componentSpecs { + cfg := g.configs[spec.Component] // zero value if unconfigured + cfg.Component = spec.Component + var c ComponentRUL + var pi projInputs + switch spec.Kind { + case kindBattery: + c, pi = BatteryRUL(cfg, spec.Label, g.sohSeries, now) + case kindWear: + c, pi = WearRUL(cfg, spec.Label, g.totalKm, g.kmPerDay, g.driveCount, now) + case kindAge: + c, pi = AgeRUL(cfg, spec.Label, ageDays, now) + } + comps = append(comps, c) + proj[spec.Component] = pi + } + return comps, proj +} + +// RUL serves GET /api/v1/vehicles/{vehicleID}/rul: the full component health +// board plus the single most-urgent upcoming service. +func (h *Handler) RUL(w http.ResponseWriter, r *http.Request) { + vehicleID, err := apiparams.URLParamInt64(r, "vehicleID") + if err != nil || vehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "invalid vehicle ID") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), rulDataTimeout) + defer cancel() + + now := time.Now().UTC() + g, err := h.gather(ctx, vehicleID, now) + if err != nil { + log.Error().Err(err).Int64("vehicleID", vehicleID).Msg("rul: failed to gather prognostics inputs") + httpx.WriteError(w, http.StatusInternalServerError, "failed to compute remaining useful life") + return + } + + comps, _ := h.buildComponents(g, now) + httpx.WriteJSON(w, http.StatusOK, RULResponse{ + VehicleID: vehicleID, + Components: comps, + NextService: NextServiceDue(comps), + }) +} + +// Component serves GET /api/v1/vehicles/{vehicleID}/rul/{component}: one +// component's prognosis plus its configured reference figures and a forecast +// series from today to the projected end-of-life for the chart. Unknown +// component keys are a 400; a known component with no seeded config row is a +// 404. +func (h *Handler) Component(w http.ResponseWriter, r *http.Request) { + vehicleID, err := apiparams.URLParamInt64(r, "vehicleID") + if err != nil || vehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "invalid vehicle ID") + return + } + component := strings.TrimSpace(chi.URLParam(r, "component")) + spec, known := specByComponent[component] + if !known { + httpx.WriteError(w, http.StatusBadRequest, "unknown component") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), rulDataTimeout) + defer cancel() + + now := time.Now().UTC() + g, err := h.gather(ctx, vehicleID, now) + if err != nil { + log.Error().Err(err).Int64("vehicleID", vehicleID).Str("component", component).Msg("rul: failed to gather component prognostics") + httpx.WriteError(w, http.StatusInternalServerError, "failed to compute remaining useful life") + return + } + + cfg, configured := g.configs[component] + if !configured { + httpx.WriteError(w, http.StatusNotFound, "component not configured") + return + } + + comps, proj := h.buildComponents(g, now) + var target *ComponentRUL + for i := range comps { + if comps[i].Component == component { + target = &comps[i] + break + } + } + if target == nil { // unreachable: every spec is built, but stay null-safe + httpx.WriteError(w, http.StatusNotFound, "component not available") + return + } + + pi := proj[component] + series := ProjectHealthSeries(now, pi.currentHealth, pi.wearRatePerDay, pi.eolHealth, pi.horizonDays, pi.confidence, projectionSteps) + _ = spec // label already carried on the ComponentRUL + + httpx.WriteJSON(w, http.StatusOK, ComponentDetailResponse{ + ComponentRUL: *target, + EOLThreshold: cfg.EOLThreshold, + NominalLifeKm: cfg.NominalLifeKm, + NominalLifeDays: cfg.NominalLifeDays, + Notes: cfg.Notes, + Projection: series, + }) +} + +// estimateBatteryCapacityWh returns the best-effort nominal pack capacity in Wh +// and a source string. Local copy of the battery-degradation helper (the carve +// playbook duplicates small stranded helpers rather than importing across +// handler packages); keep the two in sync. +func estimateBatteryCapacityWh(vin string, model string) (float64, string) { + if len(vin) >= 8 { + switch vin[7] { + case 'E', 'F': + return 60000.0, "vin_estimate" + case 'K', 'L', 'M': + return 75000.0, "vin_estimate" + case 'S', 'A': + return 100000.0, "vin_estimate" + case 'P': + return 100000.0, "vin_estimate" + } + } + m := strings.ToLower(model) + if strings.Contains(m, "model s") || strings.Contains(m, "model x") { + return 100000.0, "model_estimate" + } + return defaultCapacityWh, "default" +} diff --git a/internal/api/rul/handler_test.go b/internal/api/rul/handler_test.go new file mode 100644 index 0000000000..840bfb1cc7 --- /dev/null +++ b/internal/api/rul/handler_test.go @@ -0,0 +1,638 @@ +package rul + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + + "github.com/ev-dev-labs/teslasync/internal/database" + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/rs/zerolog" +) + +// TestMain silences the global zerolog logger so the intentional error-path logs +// (query / scan failures) don't clutter test output. +func TestMain(m *testing.M) { + zerolog.SetGlobalLevel(zerolog.Disabled) + m.Run() +} + +// --------------------------------------------------------------------------- +// Fake pgx plumbing. The module vendors no pgxmock (see carbon / routeeff / +// timemachine for the same precedent); the handler talks to a local rulQuerier +// seam so tests supply scripted rows/row sources in call order without a live +// database. +// --------------------------------------------------------------------------- + +// assignScan copies scripted column values into Scan destinations via reflection +// (allocating for nullable pointer fields). Same helper shape as the carbon / +// batterypassport tests. +func assignScan(dest, vals []any) error { + if len(dest) != len(vals) { + return fmt.Errorf("scan: %d destinations but row has %d values", len(dest), len(vals)) + } + for i := range dest { + dv := reflect.ValueOf(dest[i]) + if dv.Kind() != reflect.Pointer || dv.IsNil() { + return fmt.Errorf("scan: destination %d is not a non-nil pointer (%T)", i, dest[i]) + } + target := dv.Elem() + if !target.CanSet() { + return fmt.Errorf("scan: destination %d (%s) is not settable", i, target.Type()) + } + v := vals[i] + if v == nil { + target.Set(reflect.Zero(target.Type())) + continue + } + rv := reflect.ValueOf(v) + if target.Kind() == reflect.Pointer { + et := target.Type().Elem() + switch { + case rv.Type().AssignableTo(et): + p := reflect.New(et) + p.Elem().Set(rv) + target.Set(p) + case rv.Type().ConvertibleTo(et): + p := reflect.New(et) + p.Elem().Set(rv.Convert(et)) + target.Set(p) + default: + return fmt.Errorf("scan: cannot assign %T into nullable destination %d (%s)", v, i, target.Type()) + } + continue + } + switch { + case rv.Type().AssignableTo(target.Type()): + target.Set(rv) + case rv.Type().ConvertibleTo(target.Type()): + target.Set(rv.Convert(target.Type())) + default: + return fmt.Errorf("scan: cannot assign %T into destination %d (%s)", v, i, target.Type()) + } + } + return nil +} + +// fakeRow is a scripted pgx.Row for the vehicle / odometer QueryRow calls. +type fakeRow struct { + vals []any + err error +} + +func (r fakeRow) Scan(dest ...any) error { + if r.err != nil { + return r.err + } + return assignScan(dest, r.vals) +} + +var _ pgx.Row = fakeRow{} + +// fakeRows is a scripted pgx.Rows. Each element of data is one row's column +// values, positionally matching the handler's Scan destinations. +type fakeRows struct { + data [][]any + idx int + scanErr error + scanErrAt int + iterErr error + closed bool +} + +func (r *fakeRows) Next() bool { + if r.idx >= len(r.data) { + return false + } + r.idx++ + return true +} + +func (r *fakeRows) Scan(dest ...any) error { + if r.scanErr != nil && r.idx == r.scanErrAt { + return r.scanErr + } + return assignScan(dest, r.data[r.idx-1]) +} + +func (r *fakeRows) Close() { r.closed = true } +func (r *fakeRows) Err() error { return r.iterErr } +func (r *fakeRows) CommandTag() pgconn.CommandTag { return pgconn.CommandTag{} } +func (r *fakeRows) FieldDescriptions() []pgconn.FieldDescription { return nil } +func (r *fakeRows) Values() ([]any, error) { return nil, nil } +func (r *fakeRows) RawValues() [][]byte { return nil } +func (r *fakeRows) Conn() *pgx.Conn { return nil } + +var _ pgx.Rows = (*fakeRows)(nil) + +// queryResult is one scripted Query outcome (rows OR an error). +type queryResult struct { + rows pgx.Rows + err error +} + +// fakePool returns scripted Query results (call order: [configs, soh]) and +// scripted QueryRow results (call order: [vehicle, odometer]) and records the +// SQL/args it saw. +type fakePool struct { + queryResults []queryResult + queryIdx int + + queryRowResults []pgx.Row + queryRowIdx int + + querySQLs []string + queryArgs [][]any + queryRowSQLs []string + queryRowArgs [][]any +} + +func (p *fakePool) Query(_ context.Context, sql string, args ...any) (pgx.Rows, error) { + p.querySQLs = append(p.querySQLs, sql) + p.queryArgs = append(p.queryArgs, args) + if p.queryIdx >= len(p.queryResults) { + return nil, fmt.Errorf("fakePool: unexpected Query call #%d: %q", p.queryIdx+1, sql) + } + qr := p.queryResults[p.queryIdx] + p.queryIdx++ + if qr.err != nil { + return nil, qr.err + } + return qr.rows, nil +} + +func (p *fakePool) QueryRow(_ context.Context, sql string, args ...any) pgx.Row { + p.queryRowSQLs = append(p.queryRowSQLs, sql) + p.queryRowArgs = append(p.queryRowArgs, args) + if p.queryRowIdx >= len(p.queryRowResults) { + return fakeRow{err: errors.New("fakePool: unexpected QueryRow call")} + } + row := p.queryRowResults[p.queryRowIdx] + p.queryRowIdx++ + return row +} + +var _ rulQuerier = (*fakePool)(nil) + +// --------------------------------------------------------------------------- +// Fixtures + helpers +// --------------------------------------------------------------------------- + +func testHandler(pool rulQuerier) *Handler { return &Handler{db: pool} } + +// configRows scripts the seeded component_lifespans table. Values are the raw +// column scalars (nil for NULL); assignScan allocates the nullable pointers the +// handler scans into. `exclude` drops named components to exercise the "not +// configured" 404 branch. +func configRows(exclude ...string) *fakeRows { + skip := make(map[string]bool, len(exclude)) + for _, e := range exclude { + skip[e] = true + } + all := [][]any{ + {"brakes", 150000.0, nil, 0.0, "EV brakes last long via regen"}, + {"cabin_filter", nil, 365, 0.0, "Yearly cabin filter"}, + {"hv_battery", 300000.0, nil, 70.0, "EOL at 70% SoH"}, + {"lv_battery", nil, 1460, 0.0, "~4 year 12V"}, + {"tires", 50000.0, nil, 0.0, "Tread life"}, + } + data := make([][]any, 0, len(all)) + for _, row := range all { + if skip[row[0].(string)] { + continue + } + data = append(data, row) + } + return &fakeRows{data: data} +} + +// sohRows scripts a gently declining daily SoH source (energy/soc pairs). With +// the default 75000 Wh pack, SoH runs 100 -> ~98.8 over 300 days. +func sohRows(now time.Time) *fakeRows { + base := now.AddDate(0, 0, -300) + var data [][]any + for i := 0; i <= 30; i++ { + day := base.AddDate(0, 0, i*10) + energy := 67500.0 * (1 - 0.0004*float64(i)) // usable = energy/0.9 + data = append(data, []any{day, energy, 90.0}) + } + return &fakeRows{data: data} +} + +// vehicleRow scripts the vehicle read: a short VIN + nil model → default 75000 Wh +// capacity; enrolled `ageDays` before now. +func vehicleRow(now time.Time, ageDays int) fakeRow { + return fakeRow{vals: []any{"TEST", nil, now.AddDate(0, 0, -ageDays)}} +} + +// odometerRow scripts the aggregate drives read: total km, recent km, drive +// count, active span days. +func odometerRow(totalKm, recentKm float64, samples int64, spanDays float64) fakeRow { + return fakeRow{vals: []any{totalKm * 1000.0, recentKm * 1000.0, samples, spanDays}} +} + +// happyPool wires a full, realistic four-read script: 300-day-old vehicle, +// declining SoH, 46,000 km on a ~50 km/day cadence. +func happyPool(now time.Time) *fakePool { + return &fakePool{ + queryResults: []queryResult{{rows: configRows()}, {rows: sohRows(now)}}, + queryRowResults: []pgx.Row{vehicleRow(now, 300), odometerRow(46000, 3000, 45, 60)}, + } +} + +func rulReq(id string) *http.Request { + return reqWithParams(httptest.NewRequest(http.MethodGet, "/vehicles/"+id+"/rul", nil), id, "") +} + +func componentReq(id, component string) *http.Request { + return reqWithParams(httptest.NewRequest(http.MethodGet, "/vehicles/"+id+"/rul/"+component, nil), id, component) +} + +func reqWithParams(r *http.Request, id, component string) *http.Request { + rctx := chi.NewRouteContext() + rctx.URLParams.Add("vehicleID", id) + if component != "" { + rctx.URLParams.Add("component", component) + } + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) +} + +func decodeErr(t *testing.T, rec *httptest.ResponseRecorder) map[string]string { + t.Helper() + var m map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { + t.Fatalf("decode error body: %v (body=%q)", err, rec.Body.String()) + } + return m +} + +func decodeInto[T any](t *testing.T, rec *httptest.ResponseRecorder) T { + t.Helper() + var v T + if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil { + t.Fatalf("decode body: %v (body=%q)", err, rec.Body.String()) + } + return v +} + +func byComponent(comps []ComponentRUL) map[string]ComponentRUL { + m := make(map[string]ComponentRUL, len(comps)) + for _, c := range comps { + m[c.Component] = c + } + return m +} + +// --------------------------------------------------------------------------- +// Constructor +// --------------------------------------------------------------------------- + +func TestNewRULHandler_NilDBPanics(t *testing.T) { + t.Parallel() + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic constructing handler with a nil *database.DB") + } + }() + _ = NewRULHandler(nil) +} + +func TestNewRULHandler_NilPoolPanics(t *testing.T) { + t.Parallel() + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic constructing handler with a nil pool") + } + }() + _ = NewRULHandler(&database.DB{}) +} + +// --------------------------------------------------------------------------- +// RUL (board) +// --------------------------------------------------------------------------- + +func TestRUL_HappyPath(t *testing.T) { + t.Parallel() + now := time.Now().UTC() + pool := happyPool(now) + rec := httptest.NewRecorder() + testHandler(pool).RUL(rec, rulReq("7")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%q)", rec.Code, rec.Body.String()) + } + got := decodeInto[RULResponse](t, rec) + if got.VehicleID != 7 { + t.Errorf("vehicle_id = %d, want 7", got.VehicleID) + } + if len(got.Components) != len(componentSpecs) { + t.Fatalf("components = %d, want %d", len(got.Components), len(componentSpecs)) + } + // Emitted in the deliberate componentSpecs order. + if got.Components[0].Component != "hv_battery" || got.Components[2].Component != "tires" { + t.Errorf("unexpected component order: %s ... %s", got.Components[0].Component, got.Components[2].Component) + } + + m := byComponent(got.Components) + if b := m["hv_battery"]; b.Status != "healthy" || b.HealthPct <= 90 || b.HealthPct > 100 { + t.Errorf("hv_battery = %+v, want healthy with 90 1e308 || f < -1e308 +} + +func TestRUL_VehicleNotFoundIsGraceful(t *testing.T) { + t.Parallel() + pool := &fakePool{ + queryResults: []queryResult{{rows: configRows()}, {rows: &fakeRows{}}}, + queryRowResults: []pgx.Row{fakeRow{err: pgx.ErrNoRows}, odometerRow(0, 0, 0, 0)}, + } + rec := httptest.NewRecorder() + testHandler(pool).RUL(rec, rulReq("7")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (graceful no-vehicle); body=%q", rec.Code, rec.Body.String()) + } + got := decodeInto[RULResponse](t, rec) + if len(got.Components) != len(componentSpecs) { + t.Fatalf("components = %d, want %d", len(got.Components), len(componentSpecs)) + } + // Enrolled "today", no drives, no SoH → all-healthy, low/zero confidence. + for _, c := range got.Components { + if c.Status != "healthy" { + t.Errorf("%s status = %q, want healthy for a data-less vehicle", c.Component, c.Status) + } + } +} + +func TestRUL_BadVehicleID(t *testing.T) { + t.Parallel() + for _, id := range []string{"0", "-3", "abc"} { + rec := httptest.NewRecorder() + testHandler(&fakePool{}).RUL(rec, rulReq(id)) + if rec.Code != http.StatusBadRequest { + t.Errorf("id=%q status = %d, want 400", id, rec.Code) + } + if msg := decodeErr(t, rec)["error"]; msg != "invalid vehicle ID" { + t.Errorf("id=%q error = %q", id, msg) + } + } +} + +func TestRUL_ConfigsQueryError(t *testing.T) { + t.Parallel() + pool := &fakePool{queryResults: []queryResult{{err: errors.New("boom")}}} + rec := httptest.NewRecorder() + testHandler(pool).RUL(rec, rulReq("7")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + if msg := decodeErr(t, rec)["error"]; msg != "failed to compute remaining useful life" { + t.Errorf("error = %q", msg) + } +} + +func TestRUL_ConfigsScanError(t *testing.T) { + t.Parallel() + rows := &fakeRows{data: [][]any{{"hv_battery", 300000.0, nil, 70.0, "x"}}, scanErr: errors.New("bad scan"), scanErrAt: 1} + pool := &fakePool{queryResults: []queryResult{{rows: rows}}} + rec := httptest.NewRecorder() + testHandler(pool).RUL(rec, rulReq("7")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } +} + +func TestRUL_VehicleReadError(t *testing.T) { + t.Parallel() + // A non-ErrNoRows failure on the vehicle QueryRow is a real 500 (configs + // Query #1 succeeds first; SoH Query #2 is never reached). + pool := &fakePool{ + queryResults: []queryResult{{rows: configRows()}}, + queryRowResults: []pgx.Row{fakeRow{err: errors.New("connection reset")}}, + } + rec := httptest.NewRecorder() + testHandler(pool).RUL(rec, rulReq("7")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 (body=%q)", rec.Code, rec.Body.String()) + } +} + +func TestRUL_SoHQueryError(t *testing.T) { + t.Parallel() + now := time.Now().UTC() + pool := &fakePool{ + queryResults: []queryResult{{rows: configRows()}, {err: errors.New("timeout")}}, + queryRowResults: []pgx.Row{vehicleRow(now, 100)}, + } + rec := httptest.NewRecorder() + testHandler(pool).RUL(rec, rulReq("7")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } +} + +func TestRUL_OdometerReadError(t *testing.T) { + t.Parallel() + now := time.Now().UTC() + pool := &fakePool{ + queryResults: []queryResult{{rows: configRows()}, {rows: &fakeRows{}}}, + queryRowResults: []pgx.Row{vehicleRow(now, 100), fakeRow{err: errors.New("boom")}}, + } + rec := httptest.NewRecorder() + testHandler(pool).RUL(rec, rulReq("7")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } +} + +// verifies the SQL args carry the vehicle id, and the read order is the one the +// fake pool relies on. +func TestRUL_QueryArgsAndOrder(t *testing.T) { + t.Parallel() + now := time.Now().UTC() + pool := happyPool(now) + rec := httptest.NewRecorder() + testHandler(pool).RUL(rec, rulReq("42")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if len(pool.querySQLs) != 2 { + t.Fatalf("Query calls = %d, want 2 [configs, soh]", len(pool.querySQLs)) + } + if len(pool.queryRowSQLs) != 2 { + t.Fatalf("QueryRow calls = %d, want 2 [vehicle, odometer]", len(pool.queryRowSQLs)) + } + // configs takes no args; the other three are scoped to the vehicle. + if len(pool.queryArgs[0]) != 0 { + t.Errorf("configs query args = %v, want none", pool.queryArgs[0]) + } + for _, args := range [][]any{pool.queryArgs[1], pool.queryRowArgs[0], pool.queryRowArgs[1]} { + if len(args) != 1 || args[0] != int64(42) { + t.Errorf("scoped query args = %v, want [42]", args) + } + } +} + +// --------------------------------------------------------------------------- +// Component (detail) +// --------------------------------------------------------------------------- + +func TestComponent_HappyPath_Battery(t *testing.T) { + t.Parallel() + now := time.Now().UTC() + pool := happyPool(now) + rec := httptest.NewRecorder() + testHandler(pool).Component(rec, componentReq("7", "hv_battery")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%q)", rec.Code, rec.Body.String()) + } + got := decodeInto[ComponentDetailResponse](t, rec) + if got.Component != "hv_battery" || got.Label != "High-Voltage Battery" { + t.Errorf("component/label = %q/%q", got.Component, got.Label) + } + if got.EOLThreshold == nil || *got.EOLThreshold != 70 { + t.Errorf("eol_threshold = %v, want 70", got.EOLThreshold) + } + if got.NominalLifeKm == nil || *got.NominalLifeKm != 300000 { + t.Errorf("nominal_life_km = %v, want 300000", got.NominalLifeKm) + } + if got.Notes == "" { + t.Error("notes should be echoed") + } + if len(got.Projection) != projectionSteps+1 { + t.Fatalf("projection points = %d, want %d", len(got.Projection), projectionSteps+1) + } + // Forecast starts at current health and decays toward — never below — the EOL. + first, last := got.Projection[0], got.Projection[len(got.Projection)-1] + if first.ProjectedHealth < last.ProjectedHealth { + t.Errorf("projection should decay: first %v < last %v", first.ProjectedHealth, last.ProjectedHealth) + } + if last.ProjectedHealth < *got.EOLThreshold-0.5 { + t.Errorf("projection floor %v below EOL %v", last.ProjectedHealth, *got.EOLThreshold) + } + for _, p := range got.Projection { + if p.ConfidenceLow > p.ProjectedHealth+1e-6 || p.ConfidenceHigh < p.ProjectedHealth-1e-6 { + t.Errorf("band does not straddle projection: %+v", p) + } + } +} + +func TestComponent_HappyPath_Wear(t *testing.T) { + t.Parallel() + now := time.Now().UTC() + pool := happyPool(now) + rec := httptest.NewRecorder() + testHandler(pool).Component(rec, componentReq("7", "tires")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%q)", rec.Code, rec.Body.String()) + } + got := decodeInto[ComponentDetailResponse](t, rec) + if got.NominalLifeKm == nil || *got.NominalLifeKm != 50000 { + t.Errorf("nominal_life_km = %v, want 50000", got.NominalLifeKm) + } + if got.RemainingKm == nil || !approx(*got.RemainingKm, 4000, 1) { + t.Errorf("remaining_km = %v, want ~4000", got.RemainingKm) + } + if len(got.Projection) == 0 { + t.Error("expected a projection series") + } +} + +func TestComponent_UnknownComponent(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + // 400 is decided before any DB read, so an empty pool is fine. + testHandler(&fakePool{}).Component(rec, componentReq("7", "flux_capacitor")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body=%q)", rec.Code, rec.Body.String()) + } + if msg := decodeErr(t, rec)["error"]; msg != "unknown component" { + t.Errorf("error = %q", msg) + } +} + +func TestComponent_BadVehicleID(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + testHandler(&fakePool{}).Component(rec, componentReq("0", "hv_battery")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if msg := decodeErr(t, rec)["error"]; msg != "invalid vehicle ID" { + t.Errorf("error = %q", msg) + } +} + +func TestComponent_KnownButNotConfigured(t *testing.T) { + t.Parallel() + now := time.Now().UTC() + // hv_battery is a known spec but its config row was (admin-)deleted → 404. + pool := &fakePool{ + queryResults: []queryResult{{rows: configRows("hv_battery")}, {rows: sohRows(now)}}, + queryRowResults: []pgx.Row{vehicleRow(now, 100), odometerRow(46000, 3000, 45, 60)}, + } + rec := httptest.NewRecorder() + testHandler(pool).Component(rec, componentReq("7", "hv_battery")) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (body=%q)", rec.Code, rec.Body.String()) + } + if msg := decodeErr(t, rec)["error"]; msg != "component not configured" { + t.Errorf("error = %q", msg) + } +} + +func TestComponent_GatherError(t *testing.T) { + t.Parallel() + pool := &fakePool{queryResults: []queryResult{{err: errors.New("boom")}}} + rec := httptest.NewRecorder() + testHandler(pool).Component(rec, componentReq("7", "hv_battery")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } +} diff --git a/internal/api/rul/prognostics.go b/internal/api/rul/prognostics.go new file mode 100644 index 0000000000..561eb2dcb7 --- /dev/null +++ b/internal/api/rul/prognostics.go @@ -0,0 +1,543 @@ +package rul + +import ( + "fmt" + "math" + "time" +) + +// --------------------------------------------------------------------------- +// Pure, unit-testable core. Nothing in this file touches the database or the +// network. The only ambient dependency is the current time, which is INJECTED +// (`now time.Time`) into the two functions that emit dates, so every function +// here is a deterministic transform of its inputs — the linear regression, +// rate math, EOL projection, status classification, and forecast-series +// generation are reproducible and independently testable (prognostics_test.go). +// --------------------------------------------------------------------------- + +// Status is the health classification of a component. +type Status string + +const ( + StatusHealthy Status = "healthy" + StatusWatch Status = "watch" + StatusReplaceSoon Status = "replace_soon" + StatusOverdue Status = "overdue" +) + +// Tunable, documented thresholds and constants. Named so a test can pin them +// and an admin-facing tuning surface could later expose them. +const ( + // replaceSoonDays — a valid projection with fewer than this many days left + // is "replace soon". + replaceSoonDays = 30.0 + // replaceSoonLifePct — under this % of usable life remaining is + // "replace soon" regardless of the day count. + replaceSoonLifePct = 10.0 + // watchLifePct — under this % of usable life remaining is "watch". + watchLifePct = 25.0 + + // adequateRegressionPoints — daily SoH samples at/above which the + // data-adequacy weight on the battery confidence saturates to 1.0. + adequateRegressionPoints = 30.0 + // adequateDriveSamples — drive rows at/above which the km-wear confidence + // saturates to 1.0. + adequateDriveSamples = 40.0 + // ageConfidenceCap — age-based estimates (12V, cabin filter) are exact on + // elapsed time but blind to replacement history, so their confidence is + // pinned here rather than derived from a fit. + ageConfidenceCap = 0.6 + + // maxProjectionDays caps a "remaining_days" sentinel so a near-zero wear + // rate never yields +Inf on the wire (100 years). + maxProjectionDays = 36500.0 + + // minSoCForSoH — daily battery samples whose peak State-of-Charge is below + // this are dropped from the SoH series: capacity extrapolated from a low + // SoC is too noisy to trust. + minSoCForSoH = 50.0 + + // projectionSteps — number of segments (steps+1 points) in a forecast + // series. + projectionSteps = 24 + // defaultHorizonDays — forecast horizon used when the wear rate is flat / + // indeterminate, so the chart still renders a short flat curve instead of + // nothing. + defaultHorizonDays = 180.0 + // maxBandFraction — the confidence band's half-width at the horizon under + // ZERO confidence, as a fraction of the current→EOL health span. + maxBandFraction = 0.35 +) + +// clamp bounds v to [lo, hi]. +func clamp(v, lo, hi float64) float64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// round1/round2/round4 round to a fixed number of decimals so the JSON body +// carries a stable, display-ready numeric form (mirrors the carbon / tco +// rounding boundary). round4 keeps enough precision for the small per-day SoH +// decline of a healthy battery. +func round1(v float64) float64 { return math.Round(v*10) / 10 } +func round2(v float64) float64 { return math.Round(v*100) / 100 } +func round4(v float64) float64 { return math.Round(v*10000) / 10000 } + +// safeF guards against NaN/Inf which silently break json.Encode. +func safeF(v float64) float64 { + if math.IsNaN(v) || math.IsInf(v, 0) { + return 0 + } + return v +} + +// Point is one (x, y) observation for the linear fit. X is typically days since +// the first sample; Y is the health metric (e.g. SoH %). +type Point struct { + X float64 + Y float64 +} + +// Fit is the result of an ordinary-least-squares line fit. +type Fit struct { + Slope float64 // dY/dX + Intercept float64 // Y at X=0 + R2 float64 // coefficient of determination, 0..1 + N int // sample count +} + +// LinearFit computes the ordinary-least-squares line Y = Slope*X + Intercept +// plus R² over the points. Pure and deterministic. Degenerate inputs never +// produce NaN: +// +// - N < 2 ⇒ zero slope, R²=0 (a single point's Y becomes the +// intercept so callers can still read a "current" value). +// - zero variance in X ⇒ zero slope, intercept = mean(Y), R²=0. +// - zero variance in Y ⇒ zero slope, R²=0 (a flat metric carries no trend +// information, so confidence stays low rather than a spurious R²=1). +func LinearFit(pts []Point) Fit { + n := len(pts) + if n < 2 { + f := Fit{N: n} + if n == 1 { + f.Intercept = pts[0].Y + } + return f + } + var sumX, sumY, sumXY, sumX2 float64 + for _, p := range pts { + sumX += p.X + sumY += p.Y + sumXY += p.X * p.Y + sumX2 += p.X * p.X + } + fn := float64(n) + meanX := sumX / fn + meanY := sumY / fn + ssX := sumX2 - fn*meanX*meanX + if ssX <= 0 { + // All X identical ⇒ no slope determinable. Report the mean as a flat + // line so downstream rate math sees zero wear. + return Fit{Slope: 0, Intercept: meanY, R2: 0, N: n} + } + slope := (sumXY - fn*meanX*meanY) / ssX + intercept := meanY - slope*meanX + + var ssRes, ssTot float64 + for _, p := range pts { + pred := intercept + slope*p.X + ssRes += (p.Y - pred) * (p.Y - pred) + ssTot += (p.Y - meanY) * (p.Y - meanY) + } + r2 := 0.0 + if ssTot > 0 { + r2 = clamp(1-ssRes/ssTot, 0, 1) + } + return Fit{Slope: slope, Intercept: intercept, R2: r2, N: n} +} + +// RegressionConfidence scores how trustworthy a battery degradation projection +// is: the fit quality (R²) scaled by a data-adequacy ramp on the sample count. +// Sparse history caps confidence low even for a tight fit. Clamped to [0,1]. +// Pure. +func RegressionConfidence(r2 float64, n int) float64 { + adequacy := clamp(float64(n)/adequateRegressionPoints, 0, 1) + return clamp(clamp(r2, 0, 1)*adequacy, 0, 1) +} + +// SampleConfidence scores an estimate that rests on a simple sample count (e.g. +// the drives backing a km/day rate): it ramps 0→1 as the count reaches +// `adequate`. Clamped to [0,1]. Pure. +func SampleConfidence(samples int, adequate float64) float64 { + if adequate <= 0 { + return 0 + } + return clamp(float64(samples)/adequate, 0, 1) +} + +// RemainingDays projects how many days until a linearly-declining health metric +// reaches its end-of-life threshold, from the current value and a positive +// per-day wear rate. A non-positive/NaN rate is "no measurable wear" ⇒ +// (0, false): the caller MUST treat remaining life as indeterminate, never as +// zero. When current is already at/below eol the result is <= 0 (overdue) with +// ok=true. The positive result is capped at maxProjectionDays. Pure. +func RemainingDays(current, eol, ratePerDay float64) (days float64, ok bool) { + if ratePerDay <= 0 || math.IsNaN(ratePerDay) || math.IsInf(ratePerDay, 0) { + return 0, false + } + days = (current - eol) / ratePerDay + if days > maxProjectionDays { + days = maxProjectionDays + } + return days, true +} + +// LifeRemainingPct maps a health value onto a 0..100 "fraction of usable life +// left", where 100 = fresh and 0 = at the end-of-life threshold. For the HV +// battery, health is SoH and eol is the SoH floor (e.g. 70); for wear/age parts +// eol is 0 so life-remaining equals health. Clamped to [0,100]. Pure. +func LifeRemainingPct(health, eol float64) float64 { + span := 100 - eol + if span <= 0 { + return clamp(health, 0, 100) + } + return clamp((health-eol)/span*100, 0, 100) +} + +// ClassifyStatus applies the documented, deterministic thresholds: +// +// - overdue — health already at/below EOL, OR a valid projection shows +// remaining_days <= 0. +// - replace_soon — < replaceSoonLifePct % life left, OR a valid projection +// with < replaceSoonDays days left. +// - watch — < watchLifePct % of usable life left. +// - healthy — everything else, INCLUDING the sparse-data case (no +// projection + ample life) so missing history never masquerades as a +// failure. +// +// hasProjection guards the time-based branches so an indeterminate rate can +// never fabricate an "overdue" / "replace_soon" from a zero remaining_days. +// Pure. +func ClassifyStatus(healthBelowEOL, hasProjection bool, remainingDays, lifeRemainingPct float64) Status { + if healthBelowEOL { + return StatusOverdue + } + if hasProjection && remainingDays <= 0 { + return StatusOverdue + } + if lifeRemainingPct < replaceSoonLifePct { + return StatusReplaceSoon + } + if hasProjection && remainingDays < replaceSoonDays { + return StatusReplaceSoon + } + if lifeRemainingPct < watchLifePct { + return StatusWatch + } + return StatusHealthy +} + +// KmPerDay is the distance-accumulation rate over an active window: km driven +// divided by the window span in days (span is floored at 1 day so a single busy +// day never divides by ~0). Non-positive km yields 0. Pure. +func KmPerDay(recentKm, spanDays float64) float64 { + if recentKm <= 0 { + return 0 + } + if spanDays < 1 { + spanDays = 1 + } + return recentKm / spanDays +} + +// DailyBattery is one day's peak battery observation: the highest EnergyRemaining +// (Wh) and highest State-of-Charge (%) seen that day. +type DailyBattery struct { + Day time.Time + MaxEnergyWh float64 + MaxSocPct float64 +} + +// BuildSoHSeries converts daily (max EnergyRemaining, max SoC) observations into +// a State-of-Health series suitable for LinearFit. For each qualifying day it +// extrapolates the observed pack energy up to a full 100% SoC — +// usable_capacity ≈ max_energy / (max_soc/100) — then SoH = usable_capacity / +// nominal * 100 (clamped to 100). Days whose peak SoC is below minSoCForSoH, or +// with non-positive energy, are dropped (their capacity estimate is too noisy). +// X is days since the first RETAINED day so the fit's slope is per-day. Pure. +func BuildSoHSeries(rows []DailyBattery, nominalWh float64) []Point { + if nominalWh <= 0 || len(rows) == 0 { + return nil + } + var pts []Point + var first time.Time + for _, r := range rows { + if r.MaxSocPct < minSoCForSoH || r.MaxEnergyWh <= 0 { + continue + } + usable := r.MaxEnergyWh / (r.MaxSocPct / 100.0) + soh := usable / nominalWh * 100.0 + if soh <= 0 { + continue + } + if soh > 100 { + soh = 100 + } + if first.IsZero() { + first = r.Day + } + pts = append(pts, Point{X: r.Day.Sub(first).Hours() / 24.0, Y: soh}) + } + return pts +} + +// projInputs carries the numbers ProjectHealthSeries needs, produced alongside +// each ComponentRUL so the detail endpoint can render a forecast without +// recomputing the prognosis. +type projInputs struct { + currentHealth float64 // forecast start (SoH % for battery, life % otherwise) + wearRatePerDay float64 // health %/day decline + eolHealth float64 // forecast floor (EOL threshold for battery, 0 otherwise) + horizonDays float64 // days to reach eol (<= 0 / indeterminate ⇒ flat default) + confidence float64 +} + +// BatteryRUL derives the HV-battery prognosis from a daily SoH series. It fits a +// line, reads the current SoH off the fit at the latest sample (smoothing +// outliers vs. the raw last point), projects the days until SoH crosses the +// eolThreshold, and classifies. Confidence follows the fit quality + history +// length. An empty series degrades to healthy / zero-confidence rather than +// NaN. Pure/deterministic given `now`. +func BatteryRUL(cfg ComponentConfig, label string, series []Point, now time.Time) (ComponentRUL, projInputs) { + eol := 70.0 + if cfg.EOLThreshold != nil { + eol = *cfg.EOLThreshold + } + + c := ComponentRUL{Component: cfg.Component, Label: label, Status: string(StatusHealthy)} + + if len(series) == 0 { + c.HealthPct = 0 + c.Confidence = 0 + c.RemainingDays = maxProjectionDays + c.Basis = "No battery state-of-health history yet; awaiting charge/energy telemetry." + return c, projInputs{currentHealth: 100, eolHealth: eol, horizonDays: 0, confidence: 0} + } + + fit := LinearFit(series) + current := series[len(series)-1].Y + if fit.N >= 2 { + current = fit.Intercept + fit.Slope*series[len(series)-1].X + } + current = clamp(current, 0, 100) + + rate := 0.0 + if fit.Slope < 0 { + rate = -fit.Slope + } + conf := RegressionConfidence(fit.R2, fit.N) + remaining, ok := RemainingDays(current, eol, rate) + life := LifeRemainingPct(current, eol) + status := ClassifyStatus(current <= eol, ok, remaining, life) + + c.HealthPct = round1(current) + c.WearRatePerDay = round4(rate) + c.Confidence = round2(conf) + c.Status = string(status) + if ok { + c.RemainingDays = round1(clamp(remaining, 0, maxProjectionDays)) + if remaining > 0 { + c.ProjectedEOLDate = eolDatePtr(now, remaining) + } + c.Basis = fmt.Sprintf( + "Linear SoH trend over %d daily samples (%.3f%%/day decline, R²=%.2f); end-of-life at %.0f%% SoH.", + fit.N, rate, fit.R2, eol) + } else { + c.RemainingDays = maxProjectionDays + c.Basis = fmt.Sprintf( + "SoH ~%.1f%% over %d samples shows no measurable decline yet; projection deferred until a trend emerges.", + current, fit.N) + } + return c, projInputs{currentHealth: current, wearRatePerDay: rate, eolHealth: eol, horizonDays: remaining, confidence: conf} +} + +// WearRUL derives a distance-wear prognosis (tires, brakes) from the lifetime +// odometer (kmSinceRef — a whole-life proxy; there is no per-service reset +// feed, documented in doc.go) and a recent km/day accumulation rate. +// remaining_km = nominal - kmSinceRef; remaining_days = remaining_km / kmPerDay. +// Health is the % of nominal life left. Confidence ramps with the drive count +// backing the rate. Pure/deterministic given `now`. +func WearRUL(cfg ComponentConfig, label string, kmSinceRef, kmPerDay float64, driveSamples int, now time.Time) (ComponentRUL, projInputs) { + c := ComponentRUL{Component: cfg.Component, Label: label, Status: string(StatusHealthy)} + + nominalKm := 0.0 + if cfg.NominalLifeKm != nil { + nominalKm = *cfg.NominalLifeKm + } + if nominalKm <= 0 { + c.HealthPct = 100 + c.Basis = "No nominal distance life configured for this component." + return c, projInputs{currentHealth: 100, eolHealth: 0, horizonDays: 0, confidence: 0} + } + + remainingKm := nominalKm - kmSinceRef + health := clamp(remainingKm/nominalKm*100, 0, 100) + rate := 0.0 // health %/day + if kmPerDay > 0 { + rate = kmPerDay / nominalKm * 100 + } + conf := SampleConfidence(driveSamples, adequateDriveSamples) + remaining, ok := RemainingDays(health, 0, rate) + status := ClassifyStatus(remainingKm <= 0, ok, remaining, health) + + c.HealthPct = round1(health) + c.WearRatePerDay = round4(rate) + c.Confidence = round2(conf) + c.Status = string(status) + rkm := round1(math.Max(remainingKm, 0)) + c.RemainingKm = &rkm + if ok { + c.RemainingDays = round1(clamp(remaining, 0, maxProjectionDays)) + if remaining > 0 { + c.ProjectedEOLDate = eolDatePtr(now, remaining) + } + } else { + c.RemainingDays = maxProjectionDays + } + c.Basis = fmt.Sprintf( + "Whole-life estimate: %.0f of %.0f km used at %.1f km/day (odometer proxy; no per-service reset data).", + safeF(kmSinceRef), nominalKm, safeF(kmPerDay)) + return c, projInputs{currentHealth: health, wearRatePerDay: rate, eolHealth: 0, horizonDays: remaining, confidence: conf} +} + +// AgeRUL derives an age-based prognosis (12V battery, cabin filter) from elapsed +// service days versus a nominal calendar life. remaining_days = nominal - age. +// Health is the % of calendar life left. Confidence is fixed at ageConfidenceCap +// — the elapsed time is exact, but replacement history is unknown, so it is +// capped rather than derived. Pure/deterministic given `now`. +func AgeRUL(cfg ComponentConfig, label string, ageDays float64, now time.Time) (ComponentRUL, projInputs) { + c := ComponentRUL{Component: cfg.Component, Label: label, Status: string(StatusHealthy)} + + nominalDays := 0 + if cfg.NominalLifeDays != nil { + nominalDays = *cfg.NominalLifeDays + } + if nominalDays <= 0 { + c.HealthPct = 100 + c.Basis = "No nominal calendar life configured for this component." + return c, projInputs{currentHealth: 100, eolHealth: 0, horizonDays: 0, confidence: 0} + } + if ageDays < 0 { + ageDays = 0 + } + + nd := float64(nominalDays) + remaining := nd - ageDays + health := clamp(remaining/nd*100, 0, 100) + rate := 100.0 / nd // health %/day + conf := ageConfidenceCap + + status := ClassifyStatus(remaining <= 0, true, remaining, health) + + c.HealthPct = round1(health) + c.WearRatePerDay = round4(rate) + c.Confidence = round2(conf) + c.Status = string(status) + if remaining > 0 { + c.RemainingDays = round1(clamp(remaining, 0, maxProjectionDays)) + c.ProjectedEOLDate = eolDatePtr(now, remaining) + } else { + c.RemainingDays = 0 + } + c.Basis = fmt.Sprintf( + "Age-based: %.0f of %d days of nominal calendar life elapsed since enrollment (no replacement record).", + ageDays, nominalDays) + return c, projInputs{currentHealth: health, wearRatePerDay: rate, eolHealth: 0, horizonDays: remaining, confidence: conf} +} + +// NextServiceDue picks the component that needs attention soonest — the one with +// the nearest projected EOL date. Components with no projectable date (flat / +// indeterminate rate) are skipped. Returns nil when nothing is projectable. +// Because the dates are YYYY-MM-DD, a lexical compare is a chronological one. +// Pure. +func NextServiceDue(components []ComponentRUL) *NextService { + var best *ComponentRUL + for i := range components { + c := &components[i] + if c.ProjectedEOLDate == nil { + continue + } + if best == nil || *c.ProjectedEOLDate < *best.ProjectedEOLDate { + best = c + } + } + if best == nil { + return nil + } + return &NextService{Component: best.Component, Date: best.ProjectedEOLDate} +} + +// ProjectionPoint is one sample of a forecast curve: the projected health at a +// date, plus a confidence band around it. +type ProjectionPoint struct { + Date string `json:"date"` + ProjectedHealth float64 `json:"projected_health"` + ConfidenceLow float64 `json:"confidence_low"` + ConfidenceHigh float64 `json:"confidence_high"` +} + +// ProjectHealthSeries builds a forecast of a health metric decaying linearly at +// wearRatePerDay from `current` toward `eol`, sampled steps+1 times between +// `now` and the projected EOL (horizonDays out). The confidence band widens +// with time AND with lower confidence: its half-width grows from 0 today to +// maxBandFraction*(current-eol)*(1-confidence) at the horizon. A flat / +// indeterminate horizon falls back to defaultHorizonDays so the chart is never +// empty. Everything is clamped so the wire never carries NaN/Inf or a health +// outside [eol, current]. Deterministic given `now`; pure. +func ProjectHealthSeries(now time.Time, current, wearRatePerDay, eol, horizonDays, confidence float64, steps int) []ProjectionPoint { + if steps < 1 { + steps = 1 + } + h := horizonDays + if h <= 0 || math.IsNaN(h) || math.IsInf(h, 0) { + h = defaultHorizonDays + } + if h > maxProjectionDays { + h = maxProjectionDays + } + conf := clamp(confidence, 0, 1) + floor := math.Min(current, eol) + ceil := math.Max(current, eol) + bandMax := (current - eol) * maxBandFraction + if bandMax < 0 { + bandMax = 0 + } + + out := make([]ProjectionPoint, 0, steps+1) + for i := 0; i <= steps; i++ { + frac := float64(i) / float64(steps) + dayOffset := frac * h + health := clamp(current-wearRatePerDay*dayOffset, floor, ceil) + band := bandMax * (1 - conf) * frac + out = append(out, ProjectionPoint{ + Date: now.AddDate(0, 0, int(math.Round(dayOffset))).Format("2006-01-02"), + ProjectedHealth: round1(safeF(health)), + ConfidenceLow: round1(clamp(health-band, 0, 100)), + ConfidenceHigh: round1(clamp(health+band, 0, 100)), + }) + } + return out +} + +// eolDatePtr renders `now + days` as a *YYYY-MM-DD string for the JSON body. +// Caller guarantees days > 0. +func eolDatePtr(now time.Time, days float64) *string { + d := clamp(days, 0, maxProjectionDays) + s := now.AddDate(0, 0, int(math.Round(d))).Format("2006-01-02") + return &s +} diff --git a/internal/api/rul/prognostics_test.go b/internal/api/rul/prognostics_test.go new file mode 100644 index 0000000000..1e7ee32541 --- /dev/null +++ b/internal/api/rul/prognostics_test.go @@ -0,0 +1,631 @@ +package rul + +import ( + "math" + "testing" + "time" +) + +// A fixed clock so every date-emitting function is deterministic. +var testNow = time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC) + +func approx(a, b, tol float64) bool { return math.Abs(a-b) <= tol } + +// --------------------------------------------------------------------------- +// LinearFit +// --------------------------------------------------------------------------- + +func TestLinearFit(t *testing.T) { + t.Parallel() + tests := []struct { + name string + pts []Point + wantSlope float64 + wantR2 float64 + wantN int + wantInt float64 + }{ + { + name: "perfect declining line", + pts: []Point{{0, 100}, {10, 90}, {20, 80}, {30, 70}}, + wantSlope: -1, wantR2: 1, wantN: 4, wantInt: 100, + }, + { + name: "empty", + pts: nil, + wantSlope: 0, wantR2: 0, wantN: 0, wantInt: 0, + }, + { + name: "single point carries Y as intercept", + pts: []Point{{5, 88}}, + wantSlope: 0, wantR2: 0, wantN: 1, wantInt: 88, + }, + { + name: "zero X variance -> flat, mean intercept", + pts: []Point{{7, 90}, {7, 80}, {7, 100}}, + wantSlope: 0, wantR2: 0, wantN: 3, wantInt: 90, + }, + { + name: "zero Y variance -> flat, R2 zero", + pts: []Point{{0, 95}, {10, 95}, {20, 95}}, + wantSlope: 0, wantR2: 0, wantN: 3, wantInt: 95, + }, + { + name: "noisy positive trend has 0 negative, ok", 68, 70, 0.02, -100, true}, + {"zero rate -> indeterminate", 90, 70, 0, 0, false}, + {"negative rate -> indeterminate", 90, 70, -0.01, 0, false}, + {"NaN rate -> indeterminate", 90, 70, math.NaN(), 0, false}, + {"tiny rate capped at horizon", 100, 0, 1e-9, maxProjectionDays, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + gotDays, gotOK := RemainingDays(tc.current, tc.eol, tc.rate) + if gotOK != tc.wantOK { + t.Fatalf("ok = %v, want %v", gotOK, tc.wantOK) + } + if !approx(gotDays, tc.wantDays, 1e-6) { + t.Errorf("days = %v, want %v", gotDays, tc.wantDays) + } + if math.IsNaN(gotDays) || math.IsInf(gotDays, 0) { + t.Errorf("non-finite days: %v", gotDays) + } + }) + } +} + +// --------------------------------------------------------------------------- +// LifeRemainingPct +// --------------------------------------------------------------------------- + +func TestLifeRemainingPct(t *testing.T) { + t.Parallel() + tests := []struct { + name string + health float64 + eol float64 + want float64 + }{ + {"fresh battery at 100 vs eol 70", 100, 70, 100}, + {"battery at eol", 70, 70, 0}, + {"battery midway", 85, 70, 50}, + {"below eol clamps to 0", 60, 70, 0}, + {"wear part eol 0 == health", 42, 0, 42}, + {"degenerate span guarded", 90, 100, 90}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := LifeRemainingPct(tc.health, tc.eol); !approx(got, tc.want, 1e-6) { + t.Errorf("= %v, want %v", got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// ClassifyStatus — every documented branch +// --------------------------------------------------------------------------- + +func TestClassifyStatus(t *testing.T) { + t.Parallel() + tests := []struct { + name string + belowEOL bool + hasProjection bool + remainingDays float64 + lifePct float64 + want Status + }{ + {"health at/below eol -> overdue", true, true, 500, 0, StatusOverdue}, + {"projection past due -> overdue", false, true, -3, 40, StatusOverdue}, + {"low life -> replace_soon", false, false, 0, 5, StatusReplaceSoon}, + {"near-term projection -> replace_soon", false, true, 12, 40, StatusReplaceSoon}, + {"quarter life -> watch", false, true, 200, 20, StatusWatch}, + {"ample life -> healthy", false, true, 900, 80, StatusHealthy}, + {"sparse data (no projection, ample) -> healthy", false, false, 0, 90, StatusHealthy}, + {"missing data cannot fake overdue", false, false, 0, 100, StatusHealthy}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := ClassifyStatus(tc.belowEOL, tc.hasProjection, tc.remainingDays, tc.lifePct); got != tc.want { + t.Errorf("= %v, want %v", got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// KmPerDay +// --------------------------------------------------------------------------- + +func TestKmPerDay(t *testing.T) { + t.Parallel() + tests := []struct { + name string + km float64 + span float64 + want float64 + }{ + {"normal", 900, 30, 30}, + {"span floored at 1 day", 50, 0.2, 50}, + {"zero km", 0, 30, 0}, + {"negative km guarded", -5, 30, 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := KmPerDay(tc.km, tc.span); !approx(got, tc.want, 1e-9) { + t.Errorf("= %v, want %v", got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// BuildSoHSeries +// --------------------------------------------------------------------------- + +func TestBuildSoHSeries(t *testing.T) { + t.Parallel() + base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + rows := []DailyBattery{ + {Day: base, MaxEnergyWh: 67500, MaxSocPct: 90}, // usable 75000 -> SoH 100 + {Day: base.AddDate(0, 0, 10), MaxEnergyWh: 20000, MaxSocPct: 40}, // low SoC -> dropped + {Day: base.AddDate(0, 0, 20), MaxEnergyWh: 66000, MaxSocPct: 90}, // usable 73333 -> SoH ~97.8 + {Day: base.AddDate(0, 0, 30), MaxEnergyWh: 0, MaxSocPct: 95}, // no energy -> dropped + } + got := BuildSoHSeries(rows, 75000) + if len(got) != 2 { + t.Fatalf("len = %d, want 2 (low-SoC and zero-energy rows dropped)", len(got)) + } + if !approx(got[0].X, 0, 1e-9) || !approx(got[1].X, 20, 1e-9) { + t.Errorf("X = [%v %v], want [0 20] (days since first retained)", got[0].X, got[1].X) + } + if !approx(got[0].Y, 100, 1e-6) { + t.Errorf("Y[0] = %v, want 100 (capped)", got[0].Y) + } + if !approx(got[1].Y, 97.78, 1e-2) { + t.Errorf("Y[1] = %v, want ~97.78", got[1].Y) + } + + if BuildSoHSeries(rows, 0) != nil { + t.Error("non-positive nominal capacity must yield nil series") + } + if BuildSoHSeries(nil, 75000) != nil { + t.Error("empty input must yield nil series") + } +} + +// --------------------------------------------------------------------------- +// BatteryRUL +// --------------------------------------------------------------------------- + +func batteryCfg() ComponentConfig { + eol := 70.0 + km := 300000.0 + return ComponentConfig{Component: "hv_battery", NominalLifeKm: &km, EOLThreshold: &eol} +} + +func TestBatteryRUL_DecliningSeries(t *testing.T) { + t.Parallel() + // SoH from 100 to ~94 over 300 days: slope -0.02 %/day, tight fit. + var series []Point + for i := 0; i <= 300; i += 10 { + series = append(series, Point{X: float64(i), Y: 100 - 0.02*float64(i)}) + } + c, pi := BatteryRUL(batteryCfg(), "High-Voltage Battery", series, testNow) + + if c.Status != string(StatusHealthy) { + t.Errorf("status = %q, want healthy", c.Status) + } + if !approx(c.HealthPct, 94, 0.1) { + t.Errorf("health = %v, want ~94", c.HealthPct) + } + if c.WearRatePerDay <= 0 { + t.Errorf("wear rate = %v, want > 0", c.WearRatePerDay) + } + if c.Confidence <= 0.9 { + t.Errorf("confidence = %v, want high (>0.9)", c.Confidence) + } + if c.ProjectedEOLDate == nil { + t.Fatal("projected EOL date must be set for a declining battery") + } + // (94 - 70) / 0.02 = 1200 days from current. + if !approx(c.RemainingDays, 1200, 30) { + t.Errorf("remaining_days = %v, want ~1200", c.RemainingDays) + } + if pi.eolHealth != 70 { + t.Errorf("proj eol health = %v, want 70", pi.eolHealth) + } +} + +func TestBatteryRUL_EmptySeries(t *testing.T) { + t.Parallel() + c, pi := BatteryRUL(batteryCfg(), "High-Voltage Battery", nil, testNow) + if c.Status != string(StatusHealthy) { + t.Errorf("status = %q, want healthy (sparse data never fails)", c.Status) + } + if c.Confidence != 0 { + t.Errorf("confidence = %v, want 0", c.Confidence) + } + if c.ProjectedEOLDate != nil { + t.Errorf("projected date = %v, want nil", *c.ProjectedEOLDate) + } + if c.RemainingDays != maxProjectionDays { + t.Errorf("remaining_days = %v, want sentinel %v", c.RemainingDays, maxProjectionDays) + } + if pi.confidence != 0 { + t.Errorf("proj confidence = %v, want 0", pi.confidence) + } +} + +func TestBatteryRUL_FlatSeriesNoProjection(t *testing.T) { + t.Parallel() + series := []Point{{0, 96}, {30, 96}, {60, 96}, {90, 96}} + c, _ := BatteryRUL(batteryCfg(), "High-Voltage Battery", series, testNow) + if c.Status != string(StatusHealthy) { + t.Errorf("status = %q, want healthy", c.Status) + } + if c.ProjectedEOLDate != nil { + t.Errorf("flat trend must not project a date, got %v", *c.ProjectedEOLDate) + } + if c.WearRatePerDay != 0 { + t.Errorf("wear rate = %v, want 0 for a flat series", c.WearRatePerDay) + } +} + +// --------------------------------------------------------------------------- +// WearRUL +// --------------------------------------------------------------------------- + +func wearCfg(nominalKm float64) ComponentConfig { + return ComponentConfig{Component: "tires", NominalLifeKm: &nominalKm} +} + +func TestWearRUL(t *testing.T) { + t.Parallel() + + t.Run("replace_soon near end of tread life", func(t *testing.T) { + t.Parallel() + c, pi := WearRUL(wearCfg(50000), "Tires", 46000, 50, 45, testNow) + if c.Status != string(StatusReplaceSoon) { + t.Errorf("status = %q, want replace_soon (8%% life left)", c.Status) + } + if c.RemainingKm == nil || !approx(*c.RemainingKm, 4000, 1e-6) { + t.Errorf("remaining_km = %v, want 4000", c.RemainingKm) + } + if !approx(c.RemainingDays, 80, 1) { // 4000 km / 50 km/day + t.Errorf("remaining_days = %v, want ~80", c.RemainingDays) + } + if c.Confidence != 1 { + t.Errorf("confidence = %v, want 1 (45 >= adequate drives)", c.Confidence) + } + if pi.eolHealth != 0 { + t.Errorf("proj eol health = %v, want 0", pi.eolHealth) + } + }) + + t.Run("overdue past nominal km", func(t *testing.T) { + t.Parallel() + c, _ := WearRUL(wearCfg(50000), "Tires", 55000, 40, 50, testNow) + if c.Status != string(StatusOverdue) { + t.Errorf("status = %q, want overdue", c.Status) + } + if c.RemainingKm == nil || *c.RemainingKm != 0 { + t.Errorf("remaining_km = %v, want 0 (clamped)", c.RemainingKm) + } + if c.ProjectedEOLDate != nil { + t.Errorf("overdue part must not project a future date, got %v", *c.ProjectedEOLDate) + } + }) + + t.Run("healthy with plenty of tread", func(t *testing.T) { + t.Parallel() + c, _ := WearRUL(wearCfg(150000), "Brakes", 40000, 50, 45, testNow) + if c.Status != string(StatusHealthy) { + t.Errorf("status = %q, want healthy", c.Status) + } + }) + + t.Run("no nominal configured -> healthy zero-confidence", func(t *testing.T) { + t.Parallel() + c, _ := WearRUL(ComponentConfig{Component: "tires"}, "Tires", 40000, 50, 45, testNow) + if c.Status != string(StatusHealthy) || c.Confidence != 0 { + t.Errorf("got status=%q confidence=%v, want healthy/0", c.Status, c.Confidence) + } + if c.HealthPct != 100 { + t.Errorf("health = %v, want 100", c.HealthPct) + } + }) + + t.Run("no measurable driving -> no projection, never NaN", func(t *testing.T) { + t.Parallel() + c, _ := WearRUL(wearCfg(50000), "Tires", 10000, 0, 0, testNow) + if c.ProjectedEOLDate != nil { + t.Errorf("zero km/day must not project, got %v", *c.ProjectedEOLDate) + } + if math.IsNaN(c.RemainingDays) || math.IsInf(c.RemainingDays, 0) { + t.Errorf("remaining_days not finite: %v", c.RemainingDays) + } + }) +} + +// --------------------------------------------------------------------------- +// AgeRUL +// --------------------------------------------------------------------------- + +func ageCfg(days int) ComponentConfig { + return ComponentConfig{Component: "cabin_filter", NominalLifeDays: &days} +} + +func TestAgeRUL(t *testing.T) { + t.Parallel() + + t.Run("watch near end of calendar life", func(t *testing.T) { + t.Parallel() + c, pi := AgeRUL(ageCfg(365), "Cabin Air Filter", 300, testNow) + if c.Status != string(StatusWatch) { + t.Errorf("status = %q, want watch (~17.8%% life)", c.Status) + } + if !approx(c.RemainingDays, 65, 1) { + t.Errorf("remaining_days = %v, want ~65", c.RemainingDays) + } + if c.Confidence != round2(ageConfidenceCap) { + t.Errorf("confidence = %v, want capped %v", c.Confidence, ageConfidenceCap) + } + if c.ProjectedEOLDate == nil { + t.Error("expected a projected date") + } + if pi.wearRatePerDay <= 0 { + t.Errorf("proj wear rate = %v, want > 0", pi.wearRatePerDay) + } + }) + + t.Run("overdue past calendar life", func(t *testing.T) { + t.Parallel() + c, _ := AgeRUL(ageCfg(365), "Cabin Air Filter", 400, testNow) + if c.Status != string(StatusOverdue) { + t.Errorf("status = %q, want overdue", c.Status) + } + if c.RemainingDays != 0 { + t.Errorf("remaining_days = %v, want 0", c.RemainingDays) + } + if c.ProjectedEOLDate != nil { + t.Errorf("overdue must not project a date, got %v", *c.ProjectedEOLDate) + } + }) + + t.Run("fresh part is healthy", func(t *testing.T) { + t.Parallel() + c, _ := AgeRUL(ageCfg(1460), "12V Battery", 100, testNow) + if c.Status != string(StatusHealthy) { + t.Errorf("status = %q, want healthy", c.Status) + } + }) + + t.Run("no nominal configured -> healthy zero-confidence", func(t *testing.T) { + t.Parallel() + c, _ := AgeRUL(ComponentConfig{Component: "cabin_filter"}, "Cabin Air Filter", 100, testNow) + if c.Status != string(StatusHealthy) || c.Confidence != 0 || c.HealthPct != 100 { + t.Errorf("got status=%q confidence=%v health=%v, want healthy/0/100", c.Status, c.Confidence, c.HealthPct) + } + }) + + t.Run("negative age clamped", func(t *testing.T) { + t.Parallel() + c, _ := AgeRUL(ageCfg(365), "Cabin Air Filter", -20, testNow) + if c.HealthPct != 100 { + t.Errorf("health = %v, want 100 for a not-yet-aged part", c.HealthPct) + } + }) +} + +// --------------------------------------------------------------------------- +// NextServiceDue +// --------------------------------------------------------------------------- + +func TestNextServiceDue(t *testing.T) { + t.Parallel() + mk := func(name, date string) ComponentRUL { + d := date + return ComponentRUL{Component: name, ProjectedEOLDate: &d} + } + + t.Run("picks earliest date", func(t *testing.T) { + t.Parallel() + comps := []ComponentRUL{ + mk("brakes", "2030-01-01"), + mk("cabin_filter", "2024-08-01"), + mk("hv_battery", "2026-05-05"), + } + got := NextServiceDue(comps) + if got == nil || got.Component != "cabin_filter" { + t.Fatalf("= %+v, want cabin_filter", got) + } + if got.Date == nil || *got.Date != "2024-08-01" { + t.Errorf("date = %v, want 2024-08-01", got.Date) + } + }) + + t.Run("skips components without a date", func(t *testing.T) { + t.Parallel() + comps := []ComponentRUL{ + {Component: "hv_battery"}, // no date (overdue/flat) + mk("tires", "2025-03-03"), + } + got := NextServiceDue(comps) + if got == nil || got.Component != "tires" { + t.Fatalf("= %+v, want tires", got) + } + }) + + t.Run("nil when nothing projectable", func(t *testing.T) { + t.Parallel() + if got := NextServiceDue([]ComponentRUL{{Component: "a"}, {Component: "b"}}); got != nil { + t.Errorf("= %+v, want nil", got) + } + }) +} + +// --------------------------------------------------------------------------- +// ProjectHealthSeries +// --------------------------------------------------------------------------- + +func TestProjectHealthSeries_DecayAndBand(t *testing.T) { + t.Parallel() + // current 90, 0.1 %/day toward eol 70, horizon 200 days, confidence 0.8. + got := ProjectHealthSeries(testNow, 90, 0.1, 70, 200, 0.8, 4) + if len(got) != 5 { + t.Fatalf("len = %d, want steps+1 = 5", len(got)) + } + if got[0].Date != "2024-06-01" { + t.Errorf("first date = %q, want today", got[0].Date) + } + if !approx(got[0].ProjectedHealth, 90, 1e-6) { + t.Errorf("first health = %v, want 90", got[0].ProjectedHealth) + } + if !approx(got[4].ProjectedHealth, 70, 1e-6) { + t.Errorf("last health = %v, want 70 (eol)", got[4].ProjectedHealth) + } + // Band is zero at t0 and widens; at horizon = (90-70)*0.35*(1-0.8) = 1.4. + if got[0].ConfidenceLow != got[0].ProjectedHealth || got[0].ConfidenceHigh != got[0].ProjectedHealth { + t.Errorf("band at t0 must be zero-width: %+v", got[0]) + } + if !approx(got[4].ConfidenceLow, 68.6, 1e-1) || !approx(got[4].ConfidenceHigh, 71.4, 1e-1) { + t.Errorf("horizon band = [%v %v], want ~[68.6 71.4]", got[4].ConfidenceLow, got[4].ConfidenceHigh) + } + // Monotonic non-increasing health, all finite. + for i := 1; i < len(got); i++ { + if got[i].ProjectedHealth > got[i-1].ProjectedHealth+1e-9 { + t.Errorf("health not monotonic at %d: %v > %v", i, got[i].ProjectedHealth, got[i-1].ProjectedHealth) + } + if math.IsNaN(got[i].ProjectedHealth) || math.IsInf(got[i].ProjectedHealth, 0) { + t.Errorf("non-finite health at %d", i) + } + } +} + +func TestProjectHealthSeries_FlatHorizonFallback(t *testing.T) { + t.Parallel() + // Indeterminate horizon (<=0) falls back to a flat default-horizon curve. + got := ProjectHealthSeries(testNow, 95, 0, 70, 0, 0.0, 4) + if len(got) != 5 { + t.Fatalf("len = %d, want 5", len(got)) + } + for i, p := range got { + if !approx(p.ProjectedHealth, 95, 1e-6) { + t.Errorf("point %d health = %v, want flat 95", i, p.ProjectedHealth) + } + } + // Under zero confidence the band widens to its max fraction at the horizon. + last := got[len(got)-1] + if last.ConfidenceHigh <= last.ProjectedHealth { + t.Errorf("expected a widening band under low confidence, got %+v", last) + } +} + +func TestProjectHealthSeries_StepGuard(t *testing.T) { + t.Parallel() + if got := ProjectHealthSeries(testNow, 90, 0.1, 70, 100, 0.5, 0); len(got) != 2 { + t.Errorf("steps<1 must floor to 1 segment (2 points), got %d", len(got)) + } +} diff --git a/internal/api/segments/doc.go b/internal/api/segments/doc.go new file mode 100644 index 0000000000..005b4ec57a --- /dev/null +++ b/internal/api/segments/doc.go @@ -0,0 +1,11 @@ +// Package segments serves the Ghost Racing / EV Segments endpoints. +// +// It clusters a vehicle's drives into Strava-style route "segments" (drives +// sharing an approximate start AND end point), then serves a personal-best +// leaderboard (by time and by energy efficiency) and a head-to-head "ghost" +// race between two attempts on the same segment. The haversine clustering and +// the ghost alignment/interpolation are kept in a pure, table-tested core +// (segments.go) with no database, clock, or network dependency. +// +// Layer: handler +package segments diff --git a/internal/api/segments/dtos.go b/internal/api/segments/dtos.go new file mode 100644 index 0000000000..375646c4f9 --- /dev/null +++ b/internal/api/segments/dtos.go @@ -0,0 +1,114 @@ +package segments + +// DTOs for the Ghost Racing / EV Segments endpoints. All JSON tags are +// snake_case to match the frontend wire contract; nullable Go pointers map to +// `T | null` in TypeScript. SI-canonical: distance in metres, duration in +// seconds, efficiency in watt-hours per kilometre. Numeric fields are rounded +// at the handler boundary. + +// SegmentBest is a personal-best-by-time (or the latest) attempt reference. +type SegmentBest struct { + DriveID int64 `json:"drive_id"` + DurationS float64 `json:"duration_s"` + StartedAt string `json:"started_at"` +} + +// SegmentBestEff is a personal-best-by-efficiency attempt reference. +type SegmentBestEff struct { + DriveID int64 `json:"drive_id"` + WhPerKm float64 `json:"wh_per_km"` + StartedAt string `json:"started_at"` +} + +// SegmentSummary is one detected segment in the list response. BestTime and +// Latest are always present (a segment has >= 2 attempts); BestEfficiency is +// null when no attempt has a measured energy reading. Id is 0 when the +// best-effort persist failed (the segment is still returned, but cannot be +// drilled into). +type SegmentSummary struct { + ID int64 `json:"id"` + Name string `json:"name"` + StartAddress string `json:"start_address"` + EndAddress string `json:"end_address"` + DistanceM float64 `json:"distance_m"` + AttemptCount int `json:"attempt_count"` + BestTime *SegmentBest `json:"best_time"` + BestEfficiency *SegmentBestEff `json:"best_efficiency"` + Latest *SegmentBest `json:"latest"` +} + +// SegmentsResponse is the body of GET /vehicles/{vehicleID}/segments. Segments +// is always a non-nil (possibly empty) slice so the frontend never guards a +// null array. +type SegmentsResponse struct { + Segments []SegmentSummary `json:"segments"` +} + +// SegmentInfo is the segment header echoed by the leaderboard and ghost +// responses. Addresses/distance/attempt_count are recomputed from the matched +// drives at read time (route_segments persists only the anchor + name). +type SegmentInfo struct { + ID int64 `json:"id"` + Name string `json:"name"` + StartAddress string `json:"start_address"` + EndAddress string `json:"end_address"` + DistanceM float64 `json:"distance_m"` + AttemptCount int `json:"attempt_count"` +} + +// LeaderboardRow is one ranked attempt. WhPerKm is null when the attempt has no +// energy reading. DeltaToBestS is the time gap to the fastest run (the +// by-time PR), in both orderings. IsPR flags the rank-1 row of its own ordering. +type LeaderboardRow struct { + Rank int `json:"rank"` + DriveID int64 `json:"drive_id"` + StartedAt string `json:"started_at"` + DurationS float64 `json:"duration_s"` + DistanceM float64 `json:"distance_m"` + WhPerKm *float64 `json:"wh_per_km"` + DeltaToBestS float64 `json:"delta_to_best_s"` + IsPR bool `json:"is_pr"` +} + +// LeaderboardResponse is the body of GET /segments/{segmentID}/leaderboard. It +// carries both a by-time and a by-efficiency ordering; each is a non-nil +// (possibly empty) slice. +type LeaderboardResponse struct { + Segment SegmentInfo `json:"segment"` + ByTime []LeaderboardRow `json:"by_time"` + ByEfficiency []LeaderboardRow `json:"by_efficiency"` +} + +// GhostSeriesPoint is one point of a drive's normalized progress series. +type GhostSeriesPoint struct { + FractionOfDistance float64 `json:"fraction_of_distance"` + ElapsedS float64 `json:"elapsed_s"` + SpeedMps float64 `json:"speed_mps"` +} + +// GhostDrive is one racer in the ghost response: its id, recorded duration, and +// its progress series. Series is always a non-nil (possibly empty) slice. +type GhostDrive struct { + DriveID int64 `json:"drive_id"` + DurationS float64 `json:"duration_s"` + Series []GhostSeriesPoint `json:"series"` +} + +// GhostSplitDelta is the A-vs-B time gap at a shared distance fraction. +type GhostSplitDelta struct { + Fraction float64 `json:"fraction"` + DeltaS float64 `json:"delta_s"` +} + +// GhostResponse is the body of GET /segments/{segmentID}/ghost?a=&b=. A and B +// are the two aligned drives; SplitDeltas shows where A gained/lost against B; +// WinnerDriveID is the faster drive (null on a tie) and MarginS the gap in +// seconds between the two recorded durations. +type GhostResponse struct { + Segment SegmentInfo `json:"segment"` + A GhostDrive `json:"a"` + B GhostDrive `json:"b"` + SplitDeltas []GhostSplitDelta `json:"split_deltas"` + WinnerDriveID *int64 `json:"winner_drive_id"` + MarginS float64 `json:"margin_s"` +} diff --git a/internal/api/segments/handler.go b/internal/api/segments/handler.go new file mode 100644 index 0000000000..6fbdee1d7e --- /dev/null +++ b/internal/api/segments/handler.go @@ -0,0 +1,610 @@ +package segments + +import ( + "context" + "errors" + "net/http" + "sort" + "strconv" + "sync/atomic" + "time" + + "github.com/ev-dev-labs/teslasync/internal/api/apiparams" + "github.com/ev-dev-labs/teslasync/internal/api/httpx" + "github.com/ev-dev-labs/teslasync/internal/database" + "github.com/jackc/pgx/v5" + "github.com/rs/zerolog/log" +) + +// segDataTimeout bounds each analytics read so a stalled connection cannot pin +// the request goroutine longer than the boundary rule allows. The pool's +// server-side statement_timeout is the backstop; this is the client-side +// deadline. A var (not const) so tests can shorten it (mirrors +// routeeff.routeEffDataTimeout / carbon.carbonDataTimeout). +var segDataTimeout = 15 * time.Second + +// segMinDistanceM is the minimum drive distance (SI metres) that qualifies as a +// segment attempt. It filters out GPS-noise "micro-drives" (a car nudged in a +// driveway) that would otherwise cluster into a spurious segment. A var so +// tests can pin it. +var segMinDistanceM = 300.0 + +// segmentPersistFailures counts best-effort UPSERT failures over the process +// lifetime. The list read logs each failure AND increments this counter, but +// never fails the read — the computed segments are still returned. Exposed via +// SegmentPersistFailures for observability / tests. +var segmentPersistFailures atomic.Int64 + +// SegmentPersistFailures returns the number of best-effort segment persist +// failures observed since process start. +func SegmentPersistFailures() int64 { return segmentPersistFailures.Load() } + +// segQuerier is the minimal pgx surface the handler needs. Declared locally so +// tests can drive every branch with scripted row/rows sources without a live +// database or a vendored pgxmock (mirrors routeeff.routeQuerier / +// carbon.carbonQuerier). *pgxpool.Pool satisfies it. +type segQuerier interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + +// Handler serves the Ghost Racing / EV Segments endpoints. +type Handler struct { + db segQuerier +} + +// NewSegmentsHandler wires the handler to the pgx pool. Panics on a nil pool — +// a nil pool is a wiring bug, not a runtime condition, so it surfaces at +// construction rather than as a nil-deref on the first request (mirrors +// routeeff.NewRouteEfficiencyHandler / carbon.NewCarbonHandler). +func NewSegmentsHandler(db *database.DB) *Handler { + if db == nil || db.Pool == nil { + panic("segments.NewSegmentsHandler: db pool must not be nil") + } + return &Handler{db: db.Pool} +} + +// --- SQL. Package-level constants so tests can pin the critical clauses +// without a live database. --- + +// candidateDrivesSQL reads the completed, geolocated, non-trivial drives that +// can be clustered into segments. NOTE the source column suffix is `lng` +// (drives) — the handler maps it onto DrivePoint.*Lon. Ordered earliest-first +// so ClusterDrives seeds each cluster on its earliest drive (stable anchor). +const candidateDrivesSQL = ` +SELECT id, started_at, start_lat, start_lng, end_lat, end_lng, + start_place, end_place, distance_m, duration_s, energy_used_wh +FROM drives +WHERE vehicle_id = $1 + AND ended_at IS NOT NULL + AND start_lat IS NOT NULL AND start_lng IS NOT NULL + AND end_lat IS NOT NULL AND end_lng IS NOT NULL + AND distance_m > $2 + AND duration_s > 0 +ORDER BY started_at ASC` + +// upsertSegmentSQL persists a detected segment idempotently per (vehicle, +// anchor endpoints). RETURNING id gives the stable identity the leaderboard / +// ghost routes are addressed by. +const upsertSegmentSQL = ` +INSERT INTO route_segments + (vehicle_id, name, start_lat, start_lon, end_lat, end_lon, radius_m, distance_m, attempt_count, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW()) +ON CONFLICT (vehicle_id, start_lat, start_lon, end_lat, end_lon) +DO UPDATE SET + name = EXCLUDED.name, + radius_m = EXCLUDED.radius_m, + distance_m = EXCLUDED.distance_m, + attempt_count = EXCLUDED.attempt_count, + updated_at = NOW() +RETURNING id` + +// loadSegmentSQL fetches a persisted segment's anchor for the leaderboard / +// ghost reads. +const loadSegmentSQL = ` +SELECT id, vehicle_id, name, start_lat, start_lon, end_lat, end_lon, radius_m +FROM route_segments +WHERE id = $1` + +// loadDriveSQL fetches a single drive header scoped to a vehicle (so a caller +// cannot ghost-race a drive from another vehicle onto this segment). +const loadDriveSQL = ` +SELECT started_at, duration_s, distance_m, energy_used_wh +FROM drives +WHERE id = $1 AND vehicle_id = $2` + +// telemetrySQL reads a drive's per-tick speed track for the ghost progress +// series, oldest-first. drive_id is the indexed access path on drive_telemetry. +const telemetrySQL = ` +SELECT ts, speed_mps +FROM drive_telemetry +WHERE drive_id = $1 AND speed_mps IS NOT NULL +ORDER BY ts ASC` + +// List serves GET /vehicles/{vehicleID}/segments: it detects segments from the +// vehicle's drive history, best-effort persists each (so it earns a stable id), +// and returns them with their personal-best-by-time, best-by-efficiency, and +// latest attempt. +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + vehicleID, err := apiparams.URLParamInt64(r, "vehicleID") + if err != nil || vehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "invalid vehicle ID") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), segDataTimeout) + defer cancel() + + drives, err := h.loadCandidateDrives(ctx, vehicleID) + if err != nil { + log.Error().Err(err).Int64("vehicleID", vehicleID).Msg("segments: failed to load drives") + httpx.WriteError(w, http.StatusInternalServerError, "failed to load segments") + return + } + + clusters := ClusterDrives(drives, DefaultRadiusM) + summaries := InterestingSegments(clusters, MinAttempts) + // Most-attempted first; deterministic tie-break by name. + sort.SliceStable(summaries, func(i, j int) bool { + if summaries[i].AttemptCount != summaries[j].AttemptCount { + return summaries[i].AttemptCount > summaries[j].AttemptCount + } + return summaries[i].Name < summaries[j].Name + }) + + out := make([]SegmentSummary, 0, len(summaries)) + var failures int + for _, s := range summaries { + id, perr := h.upsertSegment(ctx, vehicleID, s) + if perr != nil { + // Best-effort: a persist failure is logged + counted, never fatal. + // The computed segment is still returned (with id 0). + failures++ + segmentPersistFailures.Add(1) + log.Warn().Err(perr).Int64("vehicleID", vehicleID).Str("segment", s.Name). + Msg("segments: best-effort persist failed; returning computed segment without id") + } + out = append(out, toSegmentSummaryDTO(id, s)) + } + if failures > 0 { + log.Warn().Int64("vehicleID", vehicleID).Int("failures", failures).Int("segments", len(summaries)). + Msg("segments: some segments could not be persisted") + } + + httpx.WriteJSON(w, http.StatusOK, SegmentsResponse{Segments: out}) +} + +// Leaderboard serves GET /segments/{segmentID}/leaderboard: the ranked attempts +// on a segment, ordered both by time and by energy efficiency, each flagging +// which run is the personal record. +func (h *Handler) Leaderboard(w http.ResponseWriter, r *http.Request) { + segmentID, err := apiparams.URLParamInt64(r, "segmentID") + if err != nil || segmentID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "invalid segment ID") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), segDataTimeout) + defer cancel() + + seg, err := h.loadSegment(ctx, segmentID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + httpx.WriteError(w, http.StatusNotFound, "segment not found") + return + } + log.Error().Err(err).Int64("segmentID", segmentID).Msg("segments: failed to load segment") + httpx.WriteError(w, http.StatusInternalServerError, "failed to load segment") + return + } + + matched, err := h.matchingDrives(ctx, seg) + if err != nil { + log.Error().Err(err).Int64("segmentID", segmentID).Msg("segments: failed to load leaderboard drives") + httpx.WriteError(w, http.StatusInternalServerError, "failed to load leaderboard") + return + } + + byTime := toLeaderboardRows(RankByTime(matched)) + byEff := toLeaderboardRows(RankByEfficiency(matched)) + + httpx.WriteJSON(w, http.StatusOK, LeaderboardResponse{ + Segment: seg.info(matched), + ByTime: byTime, + ByEfficiency: byEff, + }) +} + +// Ghost serves GET /segments/{segmentID}/ghost?a=&b=: two +// drives on the same segment aligned onto a shared distance-fraction axis, with +// the per-fraction time split between them and the head-to-head result. +func (h *Handler) Ghost(w http.ResponseWriter, r *http.Request) { + segmentID, err := apiparams.URLParamInt64(r, "segmentID") + if err != nil || segmentID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "invalid segment ID") + return + } + aID, aErr := parseInt64Query(r, "a") + bID, bErr := parseInt64Query(r, "b") + if aErr != nil || bErr != nil || aID <= 0 || bID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "query parameters a and b must be positive drive IDs") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), segDataTimeout) + defer cancel() + + seg, err := h.loadSegment(ctx, segmentID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + httpx.WriteError(w, http.StatusNotFound, "segment not found") + return + } + log.Error().Err(err).Int64("segmentID", segmentID).Msg("segments: ghost: failed to load segment") + httpx.WriteError(w, http.StatusInternalServerError, "failed to load segment") + return + } + + ghostA, err := h.loadGhostDrive(ctx, aID, seg.VehicleID) + if err != nil { + h.writeGhostDriveErr(w, err, segmentID, aID) + return + } + ghostB, err := h.loadGhostDrive(ctx, bID, seg.VehicleID) + if err != nil { + h.writeGhostDriveErr(w, err, segmentID, bID) + return + } + + // Split deltas from the FULL series (accuracy), then downsample the series + // carried in the payload (bounded size). + splits := SplitDeltas(ghostA.series, ghostB.series, SplitSamples) + winner, margin := raceResult(aID, ghostA.durationS, bID, ghostB.durationS) + + httpx.WriteJSON(w, http.StatusOK, GhostResponse{ + Segment: seg.info(nil), + A: ghostA.toDTO(), + B: ghostB.toDTO(), + SplitDeltas: toSplitDeltaDTO(splits), + WinnerDriveID: winner, + MarginS: safeF(round2(margin)), + }) +} + +// --- data access helpers --- + +// loadCandidateDrives reads and projects the clusterable drives for a vehicle. +func (h *Handler) loadCandidateDrives(ctx context.Context, vehicleID int64) ([]DrivePoint, error) { + rows, err := h.db.Query(ctx, candidateDrivesSQL, vehicleID, segMinDistanceM) + if err != nil { + return nil, err + } + defer rows.Close() + return scanDrivePoints(rows) +} + +// scanDrivePoints projects candidate-drive rows into DrivePoints, mapping the +// nullable place/energy columns to their zero + a HasEnergy flag. +func scanDrivePoints(rows pgx.Rows) ([]DrivePoint, error) { + out := make([]DrivePoint, 0, 64) + for rows.Next() { + var ( + d DrivePoint + startedAt time.Time + durationS int64 + startPlace *string + endPlace *string + energyWh *float64 + ) + if err := rows.Scan(&d.DriveID, &startedAt, &d.StartLat, &d.StartLon, + &d.EndLat, &d.EndLon, &startPlace, &endPlace, &d.DistanceM, &durationS, &energyWh); err != nil { + return nil, err + } + d.StartedAt = startedAt + d.DurationS = float64(durationS) + if startPlace != nil { + d.StartPlace = *startPlace + } + if endPlace != nil { + d.EndPlace = *endPlace + } + if energyWh != nil && *energyWh > 0 { + d.EnergyWh = *energyWh + d.HasEnergy = true + } + out = append(out, d) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +// upsertSegment persists one detected segment and returns its stable id. +func (h *Handler) upsertSegment(ctx context.Context, vehicleID int64, s Summary) (int64, error) { + var id int64 + err := h.db.QueryRow(ctx, upsertSegmentSQL, + vehicleID, s.Name, + s.Seed.StartLat, s.Seed.StartLon, s.Seed.EndLat, s.Seed.EndLon, + DefaultRadiusM, s.DistanceM, s.AttemptCount, + ).Scan(&id) + if err != nil { + return 0, err + } + return id, nil +} + +// segmentRow is a loaded route_segments anchor. +type segmentRow struct { + ID int64 + VehicleID int64 + Name string + StartLat float64 + StartLon float64 + EndLat float64 + EndLon float64 + RadiusM float64 +} + +// loadSegment reads a persisted segment by id. +func (h *Handler) loadSegment(ctx context.Context, segmentID int64) (segmentRow, error) { + var ( + seg segmentRow + startLat, startLon, endLat, endLon *float64 + radiusM *float64 + ) + err := h.db.QueryRow(ctx, loadSegmentSQL, segmentID).Scan( + &seg.ID, &seg.VehicleID, &seg.Name, &startLat, &startLon, &endLat, &endLon, &radiusM) + if err != nil { + return segmentRow{}, err + } + if startLat != nil { + seg.StartLat = *startLat + } + if startLon != nil { + seg.StartLon = *startLon + } + if endLat != nil { + seg.EndLat = *endLat + } + if endLon != nil { + seg.EndLon = *endLon + } + if radiusM != nil && *radiusM > 0 { + seg.RadiusM = *radiusM + } else { + seg.RadiusM = DefaultRadiusM + } + return seg, nil +} + +// matchingDrives returns the vehicle's drives that fall inside the segment +// anchor's start AND end radius — the same pure predicate ClusterDrives used to +// detect membership, so the leaderboard re-finds exactly the cluster members. +func (h *Handler) matchingDrives(ctx context.Context, seg segmentRow) ([]DrivePoint, error) { + all, err := h.loadCandidateDrives(ctx, seg.VehicleID) + if err != nil { + return nil, err + } + matched := make([]DrivePoint, 0, len(all)) + for _, d := range all { + if WithinRadius(d.StartLat, d.StartLon, seg.StartLat, seg.StartLon, seg.RadiusM) && + WithinRadius(d.EndLat, d.EndLon, seg.EndLat, seg.EndLon, seg.RadiusM) { + matched = append(matched, d) + } + } + return matched, nil +} + +// info builds the segment header echoed by the leaderboard / ghost responses, +// recomputing addresses/distance/attempt_count from the matched drives when +// available (nil ⇒ header without those derived fields, e.g. the ghost read). +func (s segmentRow) info(matched []DrivePoint) SegmentInfo { + info := SegmentInfo{ID: s.ID, Name: s.Name} + if len(matched) == 0 { + return info + } + starts := make([]string, 0, len(matched)) + ends := make([]string, 0, len(matched)) + for _, d := range matched { + starts = append(starts, d.StartPlace) + ends = append(ends, d.EndPlace) + } + info.StartAddress = MostCommon(starts) + info.EndAddress = MostCommon(ends) + info.DistanceM = safeF(round1(MedianDistanceM(matched))) + info.AttemptCount = len(matched) + return info +} + +// ghostDrive is a loaded, aligned ghost racer. +type ghostDrive struct { + driveID int64 + durationS float64 + series []ProgressPoint +} + +// loadGhostDrive loads a drive header (scoped to the segment's vehicle) and its +// telemetry, and builds the normalized progress series. A missing drive returns +// pgx.ErrNoRows for the caller to translate into a 404. +func (h *Handler) loadGhostDrive(ctx context.Context, driveID, vehicleID int64) (ghostDrive, error) { + var ( + startedAt time.Time + durationS *int64 + distanceM *float64 + energyWh *float64 + ) + if err := h.db.QueryRow(ctx, loadDriveSQL, driveID, vehicleID). + Scan(&startedAt, &durationS, &distanceM, &energyWh); err != nil { + return ghostDrive{}, err + } + + samples, err := h.loadTelemetry(ctx, driveID, startedAt) + if err != nil { + return ghostDrive{}, err + } + series := BuildProgressSeries(samples) + + dur := 0.0 + if durationS != nil { + dur = float64(*durationS) + } + if dur <= 0 && len(series) > 0 { + dur = series[len(series)-1].ElapsedS + } + + return ghostDrive{driveID: driveID, durationS: dur, series: series}, nil +} + +// loadTelemetry reads a drive's speed track and turns each tick into a +// TelemetrySample whose offset is seconds since the drive started. +func (h *Handler) loadTelemetry(ctx context.Context, driveID int64, startedAt time.Time) ([]TelemetrySample, error) { + rows, err := h.db.Query(ctx, telemetrySQL, driveID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]TelemetrySample, 0, 256) + for rows.Next() { + var ( + ts time.Time + speed float64 + ) + if err := rows.Scan(&ts, &speed); err != nil { + return nil, err + } + out = append(out, TelemetrySample{ + OffsetS: ts.Sub(startedAt).Seconds(), + SpeedMps: speed, + }) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +// writeGhostDriveErr maps a ghost-drive load failure to the right envelope: a +// missing drive is a 404, anything else a logged 500. +func (h *Handler) writeGhostDriveErr(w http.ResponseWriter, err error, segmentID, driveID int64) { + if errors.Is(err, pgx.ErrNoRows) { + httpx.WriteError(w, http.StatusNotFound, "drive not found on this segment's vehicle") + return + } + log.Error().Err(err).Int64("segmentID", segmentID).Int64("driveID", driveID). + Msg("segments: ghost: failed to load drive") + httpx.WriteError(w, http.StatusInternalServerError, "failed to load ghost race") +} + +// --- pure DTO mapping --- + +// toSegmentSummaryDTO maps a computed Summary + persisted id onto the wire DTO. +func toSegmentSummaryDTO(id int64, s Summary) SegmentSummary { + dto := SegmentSummary{ + ID: id, + Name: s.Name, + StartAddress: s.StartAddress, + EndAddress: s.EndAddress, + DistanceM: safeF(round1(s.DistanceM)), + AttemptCount: s.AttemptCount, + BestTime: &SegmentBest{ + DriveID: s.BestTime.DriveID, + DurationS: safeF(round1(s.BestTime.DurationS)), + StartedAt: s.BestTime.StartedAt.UTC().Format(time.RFC3339), + }, + Latest: &SegmentBest{ + DriveID: s.Latest.DriveID, + DurationS: safeF(round1(s.Latest.DurationS)), + StartedAt: s.Latest.StartedAt.UTC().Format(time.RFC3339), + }, + } + if s.HasBestEff { + dto.BestEfficiency = &SegmentBestEff{ + DriveID: s.BestEff.DriveID, + WhPerKm: safeF(round1(s.BestEffWhPerKm)), + StartedAt: s.BestEff.StartedAt.UTC().Format(time.RFC3339), + } + } + return dto +} + +// toLeaderboardRows maps ranked attempts onto wire rows. Always non-nil. +func toLeaderboardRows(ranked []Ranked) []LeaderboardRow { + out := make([]LeaderboardRow, 0, len(ranked)) + for _, r := range ranked { + row := LeaderboardRow{ + Rank: r.Rank, + DriveID: r.Drive.DriveID, + StartedAt: r.Drive.StartedAt.UTC().Format(time.RFC3339), + DurationS: safeF(round1(r.Drive.DurationS)), + DistanceM: safeF(round1(r.Drive.DistanceM)), + DeltaToBestS: safeF(round1(r.DeltaToBestS)), + IsPR: r.IsPR, + } + if r.HasWhPerKm { + v := safeF(round1(r.WhPerKm)) + row.WhPerKm = &v + } + out = append(out, row) + } + return out +} + +// toDTO maps a loaded ghost racer onto the wire DTO, downsampling and rounding +// its series at the boundary. +func (g ghostDrive) toDTO() GhostDrive { + pts := DownsampleSeries(g.series, MaxSeriesPoints) + series := make([]GhostSeriesPoint, 0, len(pts)) + for _, p := range pts { + series = append(series, GhostSeriesPoint{ + FractionOfDistance: safeF(round4(p.FractionOfDistance)), + ElapsedS: safeF(round2(p.ElapsedS)), + SpeedMps: safeF(round2(p.SpeedMps)), + }) + } + return GhostDrive{ + DriveID: g.driveID, + DurationS: safeF(round1(g.durationS)), + Series: series, + } +} + +// toSplitDeltaDTO maps pure split deltas onto the wire DTO. +func toSplitDeltaDTO(splits []SplitDeltaPoint) []GhostSplitDelta { + out := make([]GhostSplitDelta, 0, len(splits)) + for _, s := range splits { + out = append(out, GhostSplitDelta{ + Fraction: safeF(round4(s.Fraction)), + DeltaS: safeF(round2(s.DeltaS)), + }) + } + return out +} + +// raceResult decides the head-to-head winner by recorded duration (lower wins) +// and the margin between them. A non-positive duration for a drive means it has +// no usable finish time, so it cannot win. Equal (or indeterminate) times yield +// a nil winner (a tie). +func raceResult(aID int64, aDur float64, bID int64, bDur float64) (*int64, float64) { + margin := aDur - bDur + if margin < 0 { + margin = -margin + } + switch { + case aDur > 0 && (bDur <= 0 || aDur < bDur): + id := aID + return &id, margin + case bDur > 0 && (aDur <= 0 || bDur < aDur): + id := bID + return &id, margin + default: + return nil, margin + } +} + +// parseInt64Query parses a required int64 query parameter. An empty or +// malformed value is an error; the caller additionally rejects non-positive IDs. +func parseInt64Query(r *http.Request, key string) (int64, error) { + return strconv.ParseInt(r.URL.Query().Get(key), 10, 64) +} diff --git a/internal/api/segments/handler_test.go b/internal/api/segments/handler_test.go new file mode 100644 index 0000000000..c1f5e35317 --- /dev/null +++ b/internal/api/segments/handler_test.go @@ -0,0 +1,650 @@ +package segments + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + "github.com/ev-dev-labs/teslasync/internal/database" + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/rs/zerolog" +) + +// TestMain silences the global zerolog logger so the intentional error-path +// logs (query failures, best-effort persist failures) don't clutter output. +func TestMain(m *testing.M) { + zerolog.SetGlobalLevel(zerolog.Disabled) + m.Run() +} + +// --------------------------------------------------------------------------- +// Fake pgx plumbing. The module vendors no pgxmock (see carbon / routeeff / +// timemachine for the same precedent); the handler talks to a local segQuerier +// seam so tests supply scripted rows/row sources in call order without a live +// database. +// --------------------------------------------------------------------------- + +// assignScan copies scripted column values into Scan destinations, mirroring +// pgx's per-type scanning generically via reflection (allocating for nullable +// pointer fields). Same helper shape as the carbon / batterypassport tests. +func assignScan(dest, vals []any) error { + if len(dest) != len(vals) { + return fmt.Errorf("scan: %d destinations but row has %d values", len(dest), len(vals)) + } + for i := range dest { + dv := reflect.ValueOf(dest[i]) + if dv.Kind() != reflect.Pointer || dv.IsNil() { + return fmt.Errorf("scan: destination %d is not a non-nil pointer (%T)", i, dest[i]) + } + target := dv.Elem() + if !target.CanSet() { + return fmt.Errorf("scan: destination %d (%s) is not settable", i, target.Type()) + } + v := vals[i] + if v == nil { + target.Set(reflect.Zero(target.Type())) + continue + } + rv := reflect.ValueOf(v) + if target.Kind() == reflect.Pointer { + et := target.Type().Elem() + switch { + case rv.Type().AssignableTo(et): + p := reflect.New(et) + p.Elem().Set(rv) + target.Set(p) + case rv.Type().ConvertibleTo(et): + p := reflect.New(et) + p.Elem().Set(rv.Convert(et)) + target.Set(p) + default: + return fmt.Errorf("scan: cannot assign %T into nullable destination %d (%s)", v, i, target.Type()) + } + continue + } + switch { + case rv.Type().AssignableTo(target.Type()): + target.Set(rv) + case rv.Type().ConvertibleTo(target.Type()): + target.Set(rv.Convert(target.Type())) + default: + return fmt.Errorf("scan: cannot assign %T into destination %d (%s)", v, i, target.Type()) + } + } + return nil +} + +// fakeRow is a scripted pgx.Row for QueryRow calls (segment / drive header / +// upsert RETURNING id). +type fakeRow struct { + vals []any + err error +} + +func (r fakeRow) Scan(dest ...any) error { + if r.err != nil { + return r.err + } + return assignScan(dest, r.vals) +} + +var _ pgx.Row = fakeRow{} + +// fakeRows is a scripted pgx.Rows. Each element of data is one row's column +// values, positionally matching the handler's Scan destinations. +type fakeRows struct { + data [][]any + idx int + scanErr error // returned by Scan when idx == scanErrAt + scanErrAt int // 1-based row at which Scan fails; 0 = never + iterErr error // returned by Err() to simulate mid-stream iteration failure + closed bool +} + +func (r *fakeRows) Next() bool { + if r.idx >= len(r.data) { + return false + } + r.idx++ + return true +} + +func (r *fakeRows) Scan(dest ...any) error { + if r.scanErr != nil && r.idx == r.scanErrAt { + return r.scanErr + } + return assignScan(dest, r.data[r.idx-1]) +} + +func (r *fakeRows) Close() { r.closed = true } +func (r *fakeRows) Err() error { return r.iterErr } +func (r *fakeRows) CommandTag() pgconn.CommandTag { return pgconn.CommandTag{} } +func (r *fakeRows) FieldDescriptions() []pgconn.FieldDescription { return nil } +func (r *fakeRows) Values() ([]any, error) { return nil, nil } +func (r *fakeRows) RawValues() [][]byte { return nil } +func (r *fakeRows) Conn() *pgx.Conn { return nil } + +var _ pgx.Rows = (*fakeRows)(nil) + +// queryResult is one scripted Query outcome (rows OR an error). +type queryResult struct { + rows pgx.Rows + err error +} + +// fakePool returns scripted Query results in call order and scripted QueryRow +// results in call order, and records the SQL/args it saw so tests can pin the +// critical clauses. Query and QueryRow advance independent cursors, which is +// exactly how the handlers interleave them (e.g. ghost: QueryRow segment, +// QueryRow drive, Query telemetry, ...). +type fakePool struct { + queryResults []queryResult + queryIdx int + + queryRowResults []pgx.Row + queryRowIdx int + + querySQLs []string + queryArgs [][]any + queryRowSQLs []string + queryRowArgs [][]any +} + +func (p *fakePool) Query(_ context.Context, sql string, args ...any) (pgx.Rows, error) { + p.querySQLs = append(p.querySQLs, sql) + p.queryArgs = append(p.queryArgs, args) + if p.queryIdx >= len(p.queryResults) { + return nil, fmt.Errorf("fakePool: unexpected Query call #%d: %q", p.queryIdx+1, sql) + } + qr := p.queryResults[p.queryIdx] + p.queryIdx++ + if qr.err != nil { + return nil, qr.err + } + return qr.rows, nil +} + +func (p *fakePool) QueryRow(_ context.Context, sql string, args ...any) pgx.Row { + p.queryRowSQLs = append(p.queryRowSQLs, sql) + p.queryRowArgs = append(p.queryRowArgs, args) + if p.queryRowIdx >= len(p.queryRowResults) { + return fakeRow{err: errors.New("fakePool: unexpected QueryRow call")} + } + row := p.queryRowResults[p.queryRowIdx] + p.queryRowIdx++ + return row +} + +var _ segQuerier = (*fakePool)(nil) + +// --------------------------------------------------------------------------- +// Fixtures + helpers +// --------------------------------------------------------------------------- + +func testHandler(pool segQuerier) *Handler { return &Handler{db: pool} } + +var tBase = time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC) + +// driveVals scripts one candidate-drives row in the handler's exact Scan order: +// id, started_at, start_lat, start_lng, end_lat, end_lng, start_place, +// end_place, distance_m, duration_s (int64), energy_used_wh (nullable). +func driveVals(id int64, offsetH int, sLat, sLon, eLat, eLon, distM float64, durS int64, place string, energy any) []any { + return []any{ + id, + tBase.Add(time.Duration(offsetH) * time.Hour), + sLat, sLon, eLat, eLon, + place, place, + distM, durS, energy, + } +} + +// segVals scripts a loadSegment row: id, vehicle_id, name, start_lat, start_lon, +// end_lat, end_lon, radius_m. +func segVals(id, veh int64, name string, sLat, sLon, eLat, eLon, radius float64) []any { + return []any{id, veh, name, sLat, sLon, eLat, eLon, radius} +} + +// driveHeaderVals scripts a loadDrive row: started_at, duration_s (nullable +// int64), distance_m (nullable), energy_used_wh (nullable). +func driveHeaderVals(started time.Time, durS int64, distM, energy float64) []any { + return []any{started, durS, distM, energy} +} + +// telemetryRows scripts a drive_telemetry track (ts, speed_mps) for the ghost +// series, evenly spaced from the drive start. +func telemetryRows(started time.Time, speeds ...float64) *fakeRows { + data := make([][]any, 0, len(speeds)) + for i, s := range speeds { + data = append(data, []any{started.Add(time.Duration(i*10) * time.Second), s}) + } + return &fakeRows{data: data} +} + +func segmentsReq(vehicleID string) *http.Request { + return reqWithParams( + httptest.NewRequest(http.MethodGet, "/vehicles/"+vehicleID+"/segments", nil), + map[string]string{"vehicleID": vehicleID}, + ) +} + +func leaderboardReq(segmentID string) *http.Request { + return reqWithParams( + httptest.NewRequest(http.MethodGet, "/segments/"+segmentID+"/leaderboard", nil), + map[string]string{"segmentID": segmentID}, + ) +} + +func ghostReq(segmentID, query string) *http.Request { + url := "/segments/" + segmentID + "/ghost" + if query != "" { + url += "?" + query + } + return reqWithParams( + httptest.NewRequest(http.MethodGet, url, nil), + map[string]string{"segmentID": segmentID}, + ) +} + +func reqWithParams(r *http.Request, kv map[string]string) *http.Request { + rctx := chi.NewRouteContext() + for k, v := range kv { + rctx.URLParams.Add(k, v) + } + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) +} + +func decodeErr(t *testing.T, rec *httptest.ResponseRecorder) map[string]string { + t.Helper() + var m map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { + t.Fatalf("decode error body: %v (body=%q)", err, rec.Body.String()) + } + return m +} + +func decodeInto[T any](t *testing.T, rec *httptest.ResponseRecorder) T { + t.Helper() + var v T + if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil { + t.Fatalf("decode body: %v (body=%q)", err, rec.Body.String()) + } + return v +} + +// --------------------------------------------------------------------------- +// Constructor +// --------------------------------------------------------------------------- + +func TestNewSegmentsHandler_NilDBPanics(t *testing.T) { + t.Parallel() + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic constructing handler with a nil *database.DB") + } + }() + _ = NewSegmentsHandler(nil) +} + +func TestNewSegmentsHandler_NilPoolPanics(t *testing.T) { + t.Parallel() + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic constructing handler with a nil pool") + } + }() + _ = NewSegmentsHandler(&database.DB{}) +} + +// --------------------------------------------------------------------------- +// List +// --------------------------------------------------------------------------- + +func TestList_HappyPath(t *testing.T) { + t.Parallel() + // Two drives sharing a Home -> Work segment (~11 m apart at both ends). + drives := &fakeRows{data: [][]any{ + driveVals(1, 0, 0, 0.0000, 0, 0.0100, 1000, 300, "Home", 2000.0), + driveVals(2, 1, 0, 0.0001, 0, 0.0101, 1000, 200, "Home", 1500.0), + }} + pool := &fakePool{ + queryResults: []queryResult{{rows: drives}}, + queryRowResults: []pgx.Row{fakeRow{vals: []any{int64(77)}}}, // upsert RETURNING id + } + + rec := httptest.NewRecorder() + testHandler(pool).List(rec, segmentsReq("42")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%q)", rec.Code, rec.Body.String()) + } + got := decodeInto[SegmentsResponse](t, rec) + if len(got.Segments) != 1 { + t.Fatalf("segments = %d, want 1", len(got.Segments)) + } + s := got.Segments[0] + if s.ID != 77 { + t.Fatalf("segment id = %d, want 77 (from upsert RETURNING)", s.ID) + } + if s.AttemptCount != 2 { + t.Fatalf("attempt_count = %d, want 2", s.AttemptCount) + } + if s.BestTime == nil || s.BestTime.DriveID != 2 { + t.Fatalf("best_time = %+v, want fastest drive 2", s.BestTime) + } + if s.Latest == nil || s.Latest.DriveID != 2 { + t.Fatalf("latest = %+v, want most-recent drive 2", s.Latest) + } + if s.BestEfficiency == nil || s.BestEfficiency.DriveID != 2 { + t.Fatalf("best_efficiency = %+v, want most-efficient drive 2", s.BestEfficiency) + } + // The candidate-drive read is parameterised by vehicle + min distance. + if len(pool.queryArgs) == 0 || pool.queryArgs[0][0].(int64) != 42 { + t.Fatalf("candidate query vehicle arg = %v, want 42", pool.queryArgs) + } + // The persist is an idempotent UPSERT on the endpoint anchor. + if len(pool.queryRowSQLs) != 1 || !strings.Contains(pool.queryRowSQLs[0], "ON CONFLICT") { + t.Fatalf("expected one ON CONFLICT upsert, got %v", pool.queryRowSQLs) + } +} + +func TestList_EmptyWhenNoRepeat(t *testing.T) { + t.Parallel() + // A single drive never reaches the >=2-attempt threshold. + drives := &fakeRows{data: [][]any{ + driveVals(1, 0, 0, 0, 0, 0.01, 1000, 300, "Home", 2000.0), + }} + pool := &fakePool{queryResults: []queryResult{{rows: drives}}} + + rec := httptest.NewRecorder() + testHandler(pool).List(rec, segmentsReq("42")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%q)", rec.Code, rec.Body.String()) + } + got := decodeInto[SegmentsResponse](t, rec) + if got.Segments == nil { + t.Fatal("segments is null, want [] (non-nil empty array)") + } + if len(got.Segments) != 0 { + t.Fatalf("segments = %d, want 0", len(got.Segments)) + } + if pool.queryRowIdx != 0 { + t.Fatalf("upsert calls = %d, want 0 (nothing to persist)", pool.queryRowIdx) + } +} + +func TestList_InvalidVehicleID(t *testing.T) { + t.Parallel() + pool := &fakePool{} + rec := httptest.NewRecorder() + testHandler(pool).List(rec, segmentsReq("0")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if got := decodeErr(t, rec)["error"]; got == "" { + t.Fatal("expected a non-empty error message") + } + if pool.queryIdx != 0 { + t.Fatal("expected no DB access on a rejected vehicle ID") + } +} + +func TestList_QueryError(t *testing.T) { + t.Parallel() + pool := &fakePool{queryResults: []queryResult{{err: errors.New("connection reset")}}} + rec := httptest.NewRecorder() + testHandler(pool).List(rec, segmentsReq("42")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 (body=%q)", rec.Code, rec.Body.String()) + } +} + +func TestList_ScanError(t *testing.T) { + t.Parallel() + drives := &fakeRows{ + data: [][]any{driveVals(1, 0, 0, 0, 0, 0.01, 1000, 300, "Home", 2000.0)}, + scanErr: errors.New("bad column type"), + scanErrAt: 1, + } + pool := &fakePool{queryResults: []queryResult{{rows: drives}}} + rec := httptest.NewRecorder() + testHandler(pool).List(rec, segmentsReq("42")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 (body=%q)", rec.Code, rec.Body.String()) + } +} + +func TestList_PersistFailureIsBestEffort(t *testing.T) { + t.Parallel() + before := SegmentPersistFailures() + + drives := &fakeRows{data: [][]any{ + driveVals(1, 0, 0, 0.0000, 0, 0.0100, 1000, 300, "Home", 2000.0), + driveVals(2, 1, 0, 0.0001, 0, 0.0101, 1000, 200, "Home", 1500.0), + }} + pool := &fakePool{ + queryResults: []queryResult{{rows: drives}}, + queryRowResults: []pgx.Row{fakeRow{err: errors.New("unique violation")}}, // upsert fails + } + + rec := httptest.NewRecorder() + testHandler(pool).List(rec, segmentsReq("42")) + + // The read still succeeds and returns the computed segment (with id 0). + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 despite persist failure (body=%q)", rec.Code, rec.Body.String()) + } + got := decodeInto[SegmentsResponse](t, rec) + if len(got.Segments) != 1 { + t.Fatalf("segments = %d, want 1 (still returned)", len(got.Segments)) + } + if got.Segments[0].ID != 0 { + t.Fatalf("segment id = %d, want 0 when persist failed", got.Segments[0].ID) + } + // ...and the failure is counted for observability. + if after := SegmentPersistFailures(); after <= before { + t.Fatalf("persist-failure counter = %d, want > %d", after, before) + } +} + +// --------------------------------------------------------------------------- +// Leaderboard +// --------------------------------------------------------------------------- + +func TestLeaderboard_HappyPath(t *testing.T) { + t.Parallel() + seg := fakeRow{vals: segVals(5, 42, "Home → Work", 0, 0, 0, 0.01, 250)} + // Three attempts inside the anchor radius; d3 carries no energy reading. + drives := &fakeRows{data: [][]any{ + driveVals(1, 0, 0, 0.0000, 0, 0.0100, 10000, 300, "Home", 2000.0), // 200 Wh/km + driveVals(2, 1, 0, 0.0001, 0, 0.0101, 10000, 200, "Home", 1500.0), // 150 Wh/km, fastest + driveVals(3, 2, 0, 0.0000, 0, 0.0100, 10000, 250, "Home", nil), // no energy + }} + pool := &fakePool{ + queryRowResults: []pgx.Row{seg}, + queryResults: []queryResult{{rows: drives}}, + } + + rec := httptest.NewRecorder() + testHandler(pool).Leaderboard(rec, leaderboardReq("5")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%q)", rec.Code, rec.Body.String()) + } + got := decodeInto[LeaderboardResponse](t, rec) + if got.Segment.ID != 5 || got.Segment.AttemptCount != 3 { + t.Fatalf("segment header = %+v, want id 5 / 3 attempts", got.Segment) + } + if len(got.ByTime) != 3 { + t.Fatalf("by_time rows = %d, want 3", len(got.ByTime)) + } + if got.ByTime[0].DriveID != 2 || !got.ByTime[0].IsPR { + t.Fatalf("by_time PR = drive %d (pr=%v), want drive 2 PR", got.ByTime[0].DriveID, got.ByTime[0].IsPR) + } + // The energy-less attempt (drive 3) has a null wh_per_km in the by-time order. + var row3 *LeaderboardRow + for i := range got.ByTime { + if got.ByTime[i].DriveID == 3 { + row3 = &got.ByTime[i] + } + } + if row3 == nil { + t.Fatal("drive 3 missing from by_time") + } + if row3.WhPerKm != nil { + t.Fatalf("energy-less drive should have null wh_per_km, got %v", *row3.WhPerKm) + } + // by_efficiency omits the energy-less attempt. + if len(got.ByEfficiency) != 2 { + t.Fatalf("by_efficiency rows = %d, want 2 (energy-less omitted)", len(got.ByEfficiency)) + } + if got.ByEfficiency[0].DriveID != 2 || !got.ByEfficiency[0].IsPR { + t.Fatalf("by_efficiency PR = drive %d, want drive 2", got.ByEfficiency[0].DriveID) + } +} + +func TestLeaderboard_NotFound(t *testing.T) { + t.Parallel() + pool := &fakePool{queryRowResults: []pgx.Row{fakeRow{err: pgx.ErrNoRows}}} + rec := httptest.NewRecorder() + testHandler(pool).Leaderboard(rec, leaderboardReq("5")) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (body=%q)", rec.Code, rec.Body.String()) + } +} + +func TestLeaderboard_InvalidID(t *testing.T) { + t.Parallel() + pool := &fakePool{} + rec := httptest.NewRecorder() + testHandler(pool).Leaderboard(rec, leaderboardReq("abc")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if pool.queryRowIdx != 0 { + t.Fatal("expected no DB access on a malformed segment ID") + } +} + +func TestLeaderboard_DrivesQueryError(t *testing.T) { + t.Parallel() + seg := fakeRow{vals: segVals(5, 42, "Home → Work", 0, 0, 0, 0.01, 250)} + pool := &fakePool{ + queryRowResults: []pgx.Row{seg}, + queryResults: []queryResult{{err: errors.New("read timeout")}}, + } + rec := httptest.NewRecorder() + testHandler(pool).Leaderboard(rec, leaderboardReq("5")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 (body=%q)", rec.Code, rec.Body.String()) + } +} + +// --------------------------------------------------------------------------- +// Ghost +// --------------------------------------------------------------------------- + +func TestGhost_HappyPath(t *testing.T) { + t.Parallel() + seg := fakeRow{vals: segVals(5, 42, "Home → Work", 0, 0, 0, 0.01, 250)} + driveA := fakeRow{vals: driveHeaderVals(tBase, 200, 10000, 1500)} + driveB := fakeRow{vals: driveHeaderVals(tBase, 300, 10000, 2000)} + telemA := telemetryRows(tBase, 0, 10, 0) // finishes sooner + telemB := telemetryRows(tBase, 0, 5, 5, 0) // slower + + pool := &fakePool{ + queryRowResults: []pgx.Row{seg, driveA, driveB}, + queryResults: []queryResult{{rows: telemA}, {rows: telemB}}, + } + + rec := httptest.NewRecorder() + testHandler(pool).Ghost(rec, ghostReq("5", "a=1&b=2")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%q)", rec.Code, rec.Body.String()) + } + got := decodeInto[GhostResponse](t, rec) + if got.A.DriveID != 1 || got.B.DriveID != 2 { + t.Fatalf("racers = a:%d b:%d, want a:1 b:2", got.A.DriveID, got.B.DriveID) + } + if len(got.A.Series) == 0 || len(got.B.Series) == 0 { + t.Fatal("expected non-empty progress series for both racers") + } + if len(got.SplitDeltas) != SplitSamples+1 { + t.Fatalf("split_deltas = %d, want %d", len(got.SplitDeltas), SplitSamples+1) + } + if got.WinnerDriveID == nil || *got.WinnerDriveID != 1 { + t.Fatalf("winner = %v, want drive 1 (200s < 300s)", got.WinnerDriveID) + } + if got.MarginS != 100 { + t.Fatalf("margin = %v, want 100", got.MarginS) + } + // Fractions run 0..1 across the split series. + if got.SplitDeltas[0].Fraction != 0 || got.SplitDeltas[len(got.SplitDeltas)-1].Fraction != 1 { + t.Fatalf("split fraction span = [%v..%v], want [0..1]", + got.SplitDeltas[0].Fraction, got.SplitDeltas[len(got.SplitDeltas)-1].Fraction) + } +} + +func TestGhost_SegmentNotFound(t *testing.T) { + t.Parallel() + pool := &fakePool{queryRowResults: []pgx.Row{fakeRow{err: pgx.ErrNoRows}}} + rec := httptest.NewRecorder() + testHandler(pool).Ghost(rec, ghostReq("5", "a=1&b=2")) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (body=%q)", rec.Code, rec.Body.String()) + } +} + +func TestGhost_DriveNotFound(t *testing.T) { + t.Parallel() + seg := fakeRow{vals: segVals(5, 42, "Home → Work", 0, 0, 0, 0.01, 250)} + pool := &fakePool{queryRowResults: []pgx.Row{seg, fakeRow{err: pgx.ErrNoRows}}} + rec := httptest.NewRecorder() + testHandler(pool).Ghost(rec, ghostReq("5", "a=1&b=2")) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (body=%q)", rec.Code, rec.Body.String()) + } +} + +func TestGhost_MissingParams(t *testing.T) { + t.Parallel() + pool := &fakePool{} + rec := httptest.NewRecorder() + testHandler(pool).Ghost(rec, ghostReq("5", "")) // no a / b + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body=%q)", rec.Code, rec.Body.String()) + } + if pool.queryRowIdx != 0 { + t.Fatal("expected no DB access when a/b are missing") + } +} + +func TestGhost_NonPositiveDriveID(t *testing.T) { + t.Parallel() + pool := &fakePool{} + rec := httptest.NewRecorder() + testHandler(pool).Ghost(rec, ghostReq("5", "a=0&b=2")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 for a non-positive drive id (body=%q)", rec.Code, rec.Body.String()) + } +} + +func TestGhost_InvalidSegmentID(t *testing.T) { + t.Parallel() + pool := &fakePool{} + rec := httptest.NewRecorder() + testHandler(pool).Ghost(rec, ghostReq("-3", "a=1&b=2")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body=%q)", rec.Code, rec.Body.String()) + } +} diff --git a/internal/api/segments/segments.go b/internal/api/segments/segments.go new file mode 100644 index 0000000000..2e74290a5e --- /dev/null +++ b/internal/api/segments/segments.go @@ -0,0 +1,513 @@ +package segments + +import ( + "fmt" + "math" + "sort" + "strings" + "time" +) + +// --------------------------------------------------------------------------- +// Pure, unit-testable core. Nothing in this file touches the database, the +// clock, or the network — every function is a deterministic transform of its +// inputs, so the haversine distance, the greedy clustering, the leaderboard +// ranking, and the ghost alignment/interpolation are reproducible and +// independently testable (segments_test.go). +// --------------------------------------------------------------------------- + +const ( + // earthRadiusM is the mean Earth radius in metres used by the haversine + // great-circle distance. A sphere is more than accurate enough at the + // sub-kilometre scale a segment radius operates on. + earthRadiusM = 6371000.0 + + // DefaultRadiusM is the default segment match radius: two drives share a + // segment when their start points are within this many metres of each other + // AND their end points are within this many metres. 250 m comfortably + // absorbs parking-spot / driveway / GPS-fix variation without merging + // genuinely distinct routes. + DefaultRadiusM = 250.0 + + // MinAttempts is how many member drives a cluster needs before it is an + // "interesting" segment worth surfacing. A single drive is just a drive; a + // segment implies a repeat you can race against. + MinAttempts = 2 + + // SplitSamples is the number of intervals the ghost split-delta series is + // sampled at (yielding SplitSamples+1 fraction points from 0.0 to 1.0). + SplitSamples = 20 + + // MaxSeriesPoints caps the per-drive ghost progress series so a long, + // high-rate drive cannot fan the response into an unbounded payload. The + // series is uniformly downsampled (first + last preserved) past this. + MaxSeriesPoints = 300 +) + +// DrivePoint is the pure projection of one completed drive used by clustering +// and ranking. Coordinates are WGS84 decimal degrees; DistanceM/EnergyWh are +// SI (metres / watt-hours); DurationS is seconds. HasEnergy distinguishes a +// genuine zero from an absent energy reading so efficiency ranking can skip +// unmeasured drives rather than treat them as infinitely efficient. +type DrivePoint struct { + DriveID int64 + StartedAt time.Time + StartLat float64 + StartLon float64 + EndLat float64 + EndLon float64 + StartPlace string + EndPlace string + DistanceM float64 + DurationS float64 + EnergyWh float64 + HasEnergy bool +} + +// Cluster is a set of drives detected as the same segment. Seed is the first +// drive assigned to the cluster (the earliest, since detection runs over +// chronologically-sorted input); its start/end coordinates are the stable +// anchor persisted for the segment and re-used to match future drives. +type Cluster struct { + Seed DrivePoint + Drives []DrivePoint +} + +// Summary is the pure, computed shape of a detected segment (no JSON, no id). +// The handler maps it onto the wire DTO and fills the persisted id. +type Summary struct { + Seed DrivePoint + Name string + StartAddress string + EndAddress string + DistanceM float64 + AttemptCount int + BestTime DrivePoint + BestEff DrivePoint + HasBestEff bool + BestEffWhPerKm float64 + Latest DrivePoint +} + +// Ranked is one leaderboard row in a pure form. Drive carries the underlying +// attempt; WhPerKm is only meaningful when HasWhPerKm is true. +type Ranked struct { + Rank int + Drive DrivePoint + WhPerKm float64 + HasWhPerKm bool + DeltaToBestS float64 + IsPR bool +} + +// TelemetrySample is one drive-telemetry tick projected for the ghost series: +// OffsetS is seconds since the drive started; SpeedMps is instantaneous speed. +type TelemetrySample struct { + OffsetS float64 + SpeedMps float64 +} + +// ProgressPoint is one point of a normalized ghost progress series: how far +// along the route (0..1 by integrated distance), how long into the drive +// (seconds), and how fast (m/s) at that point. +type ProgressPoint struct { + FractionOfDistance float64 + ElapsedS float64 + SpeedMps float64 +} + +// SplitDeltaPoint is the time gap between two ghost drives at a shared distance +// fraction. DeltaS = elapsed(A) - elapsed(B): negative means A reached that +// fraction sooner (A ahead), positive means A is behind. +type SplitDeltaPoint struct { + Fraction float64 + DeltaS float64 +} + +// HaversineMeters returns the great-circle distance in metres between two +// WGS84 points. Pure. +func HaversineMeters(lat1, lon1, lat2, lon2 float64) float64 { + rLat1 := lat1 * math.Pi / 180 + rLat2 := lat2 * math.Pi / 180 + dLat := (lat2 - lat1) * math.Pi / 180 + dLon := (lon2 - lon1) * math.Pi / 180 + a := math.Sin(dLat/2)*math.Sin(dLat/2) + + math.Cos(rLat1)*math.Cos(rLat2)*math.Sin(dLon/2)*math.Sin(dLon/2) + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + return earthRadiusM * c +} + +// WithinRadius reports whether two points are within radiusM metres. Pure. +func WithinRadius(lat1, lon1, lat2, lon2, radiusM float64) bool { + return HaversineMeters(lat1, lon1, lat2, lon2) <= radiusM +} + +// SameSegment reports whether drives a and b belong to the same segment: their +// start points are within radiusM AND their end points are within radiusM. +// Pure. +func SameSegment(a, b DrivePoint, radiusM float64) bool { + return WithinRadius(a.StartLat, a.StartLon, b.StartLat, b.StartLon, radiusM) && + WithinRadius(a.EndLat, a.EndLon, b.EndLat, b.EndLon, radiusM) +} + +// ClusterDrives greedily groups drives into segments: each drive joins the +// FIRST existing cluster whose seed it matches (SameSegment), otherwise it +// seeds a new cluster. Anchoring membership to the seed (rather than a drifting +// centroid) keeps detection deterministic and makes the persisted anchor the +// exact predicate the leaderboard re-uses to re-find members. Input order is +// respected, so callers pass drives chronologically (earliest first) to get +// stable, earliest-drive seeds. Pure. +func ClusterDrives(drives []DrivePoint, radiusM float64) []Cluster { + clusters := make([]Cluster, 0, 8) + for _, d := range drives { + placed := false + for i := range clusters { + if SameSegment(clusters[i].Seed, d, radiusM) { + clusters[i].Drives = append(clusters[i].Drives, d) + placed = true + break + } + } + if !placed { + clusters = append(clusters, Cluster{Seed: d, Drives: []DrivePoint{d}}) + } + } + return clusters +} + +// MostCommon returns the most frequent non-blank string, breaking ties toward +// the lexicographically smallest value so the result is deterministic. Returns +// "" when every value is blank. Pure. +func MostCommon(vals []string) string { + counts := make(map[string]int, len(vals)) + for _, v := range vals { + if strings.TrimSpace(v) == "" { + continue + } + counts[v]++ + } + keys := make([]string, 0, len(counts)) + for k := range counts { + keys = append(keys, k) + } + sort.Strings(keys) + best := "" + bestN := 0 + for _, k := range keys { + if counts[k] > bestN { + bestN = counts[k] + best = k + } + } + return best +} + +// WhPerKm is a drive's energy efficiency in watt-hours per kilometre. Returns +// 0 for a non-positive distance (undefined efficiency). Pure. +func WhPerKm(energyWh, distanceM float64) float64 { + if distanceM <= 0 { + return 0 + } + return energyWh / (distanceM / 1000.0) +} + +// MedianDistanceM is the median member distance (SI metres) — a representative +// segment length that resists an outlier detour. Returns 0 for no drives. Pure. +func MedianDistanceM(drives []DrivePoint) float64 { + if len(drives) == 0 { + return 0 + } + ds := make([]float64, len(drives)) + for i, d := range drives { + ds[i] = d.DistanceM + } + sort.Float64s(ds) + n := len(ds) + if n%2 == 1 { + return ds[n/2] + } + return (ds[n/2-1] + ds[n/2]) / 2 +} + +// segmentName builds "start → end" from the most-common place labels, falling +// back to the seed's rounded coordinates when a label is missing so the name is +// always meaningful. Pure. +func segmentName(start, end string, seed DrivePoint) string { + s := start + if strings.TrimSpace(s) == "" { + s = fmt.Sprintf("%.4f, %.4f", seed.StartLat, seed.StartLon) + } + e := end + if strings.TrimSpace(e) == "" { + e = fmt.Sprintf("%.4f, %.4f", seed.EndLat, seed.EndLon) + } + return s + " → " + e +} + +// Summarize reduces a cluster to its display + personal-best summary: a name +// and start/end addresses from the most common member labels, a representative +// (median) distance, and the best-by-time, best-by-efficiency, and latest +// member drives. A cluster is assumed non-empty (the caller filters by +// MinAttempts). Pure. +func Summarize(c Cluster) Summary { + s := Summary{Seed: c.Seed, AttemptCount: len(c.Drives)} + starts := make([]string, 0, len(c.Drives)) + ends := make([]string, 0, len(c.Drives)) + for _, d := range c.Drives { + starts = append(starts, d.StartPlace) + ends = append(ends, d.EndPlace) + } + s.StartAddress = MostCommon(starts) + s.EndAddress = MostCommon(ends) + s.Name = segmentName(s.StartAddress, s.EndAddress, c.Seed) + s.DistanceM = MedianDistanceM(c.Drives) + + best := c.Drives[0] + latest := c.Drives[0] + var bestEff DrivePoint + bestEffVal := math.Inf(1) + for _, d := range c.Drives { + if d.DurationS > 0 && (best.DurationS <= 0 || d.DurationS < best.DurationS) { + best = d + } + if d.StartedAt.After(latest.StartedAt) { + latest = d + } + if d.HasEnergy && d.DistanceM > 0 { + if e := WhPerKm(d.EnergyWh, d.DistanceM); e < bestEffVal { + bestEffVal = e + bestEff = d + s.HasBestEff = true + } + } + } + s.BestTime = best + s.Latest = latest + if s.HasBestEff { + s.BestEff = bestEff + s.BestEffWhPerKm = bestEffVal + } + return s +} + +// InterestingSegments summarizes only the clusters with at least minAttempts +// members. Always returns a non-nil (possibly empty) slice. Pure. +func InterestingSegments(clusters []Cluster, minAttempts int) []Summary { + out := make([]Summary, 0, len(clusters)) + for _, c := range clusters { + if len(c.Drives) >= minAttempts { + out = append(out, Summarize(c)) + } + } + return out +} + +// bestDuration is the minimum positive duration across drives (0 when none). +func bestDuration(drives []DrivePoint) float64 { + best := math.Inf(1) + found := false + for _, d := range drives { + if d.DurationS > 0 && d.DurationS < best { + best = d.DurationS + found = true + } + } + if !found { + return 0 + } + return best +} + +// RankByTime ranks every attempt fastest-first. DeltaToBestS is each attempt's +// time gap to the fastest run; the rank-1 row is flagged IsPR. Ties break +// toward the earlier drive so ordering is deterministic. Pure. +func RankByTime(drives []DrivePoint) []Ranked { + best := bestDuration(drives) + sorted := append([]DrivePoint(nil), drives...) + sort.SliceStable(sorted, func(i, j int) bool { + if sorted[i].DurationS != sorted[j].DurationS { + return sorted[i].DurationS < sorted[j].DurationS + } + return sorted[i].StartedAt.Before(sorted[j].StartedAt) + }) + out := make([]Ranked, 0, len(sorted)) + for i, d := range sorted { + r := Ranked{ + Rank: i + 1, + Drive: d, + DeltaToBestS: d.DurationS - best, + IsPR: i == 0, + } + if d.HasEnergy && d.DistanceM > 0 { + r.WhPerKm = WhPerKm(d.EnergyWh, d.DistanceM) + r.HasWhPerKm = true + } + out = append(out, r) + } + return out +} + +// RankByEfficiency ranks the attempts that HAVE a measured efficiency, +// most-efficient (lowest Wh/km) first. Attempts without an energy reading are +// omitted (efficiency is undefined). DeltaToBestS is still the time gap to the +// fastest run over ALL attempts, so a maximally-efficient but slow run reads as +// "+Ns" against the outright PR. Ties break toward the earlier drive. Pure. +func RankByEfficiency(drives []DrivePoint) []Ranked { + best := bestDuration(drives) + withEnergy := make([]DrivePoint, 0, len(drives)) + for _, d := range drives { + if d.HasEnergy && d.DistanceM > 0 { + withEnergy = append(withEnergy, d) + } + } + sort.SliceStable(withEnergy, func(i, j int) bool { + ei := WhPerKm(withEnergy[i].EnergyWh, withEnergy[i].DistanceM) + ej := WhPerKm(withEnergy[j].EnergyWh, withEnergy[j].DistanceM) + if ei != ej { + return ei < ej + } + return withEnergy[i].StartedAt.Before(withEnergy[j].StartedAt) + }) + out := make([]Ranked, 0, len(withEnergy)) + for i, d := range withEnergy { + out = append(out, Ranked{ + Rank: i + 1, + Drive: d, + WhPerKm: WhPerKm(d.EnergyWh, d.DistanceM), + HasWhPerKm: true, + DeltaToBestS: d.DurationS - best, + IsPR: i == 0, + }) + } + return out +} + +// BuildProgressSeries turns per-tick (offset, speed) samples into a normalized +// progress series. Cumulative distance is the trapezoidal integral of +// non-negative speed over time (negative speeds and non-forward time steps +// contribute nothing, keeping the distance monotonic so the fraction is +// well-ordered); the fraction is that cumulative distance divided by the total, +// so both a fast and a slow run map onto the same 0..1 axis for head-to-head +// alignment. Returns a non-nil (possibly empty) slice. Pure. +func BuildProgressSeries(samples []TelemetrySample) []ProgressPoint { + if len(samples) == 0 { + return []ProgressPoint{} + } + cum := make([]float64, len(samples)) + for i := 1; i < len(samples); i++ { + dt := samples[i].OffsetS - samples[i-1].OffsetS + if dt <= 0 { + cum[i] = cum[i-1] + continue + } + v0 := math.Max(0, samples[i-1].SpeedMps) + v1 := math.Max(0, samples[i].SpeedMps) + cum[i] = cum[i-1] + (v0+v1)/2*dt + } + total := cum[len(cum)-1] + out := make([]ProgressPoint, len(samples)) + for i, s := range samples { + frac := 0.0 + if total > 0 { + frac = cum[i] / total + } + out[i] = ProgressPoint{ + FractionOfDistance: frac, + ElapsedS: s.OffsetS, + SpeedMps: math.Max(0, s.SpeedMps), + } + } + return out +} + +// ElapsedAtFraction linearly interpolates the elapsed time at a distance +// fraction. Fractions before the first / after the last sample clamp to the +// endpoints (never extrapolate). Assumes a fraction-monotonic series as +// produced by BuildProgressSeries. Returns 0 for an empty series. Pure. +func ElapsedAtFraction(series []ProgressPoint, fraction float64) float64 { + if len(series) == 0 { + return 0 + } + if fraction <= series[0].FractionOfDistance { + return series[0].ElapsedS + } + last := series[len(series)-1] + if fraction >= last.FractionOfDistance { + return last.ElapsedS + } + for i := 1; i < len(series); i++ { + p0, p1 := series[i-1], series[i] + if fraction <= p1.FractionOfDistance { + span := p1.FractionOfDistance - p0.FractionOfDistance + if span <= 0 { + return p1.ElapsedS + } + t := (fraction - p0.FractionOfDistance) / span + return p0.ElapsedS + t*(p1.ElapsedS-p0.ElapsedS) + } + } + return last.ElapsedS +} + +// SplitDeltas samples the time gap between two ghost drives at n+1 evenly +// spaced distance fractions from 0.0 to 1.0. DeltaS = elapsed(a) - elapsed(b): +// negative where a is ahead, positive where a is behind. n is clamped to at +// least 1. Pure. +func SplitDeltas(a, b []ProgressPoint, n int) []SplitDeltaPoint { + if n < 1 { + n = 1 + } + out := make([]SplitDeltaPoint, 0, n+1) + for i := 0; i <= n; i++ { + f := float64(i) / float64(n) + out = append(out, SplitDeltaPoint{ + Fraction: f, + DeltaS: ElapsedAtFraction(a, f) - ElapsedAtFraction(b, f), + }) + } + return out +} + +// DownsampleSeries uniformly thins a progress series to at most max points, +// always preserving the first and last. A series already within the cap is +// returned unchanged. max <= 0 disables downsampling. Pure. +func DownsampleSeries(series []ProgressPoint, max int) []ProgressPoint { + if max <= 0 || len(series) <= max { + return series + } + if max == 1 { + return []ProgressPoint{series[len(series)-1]} + } + out := make([]ProgressPoint, 0, max) + stride := float64(len(series)-1) / float64(max-1) + lastIdx := -1 + for i := 0; i < max; i++ { + idx := int(math.Round(float64(i) * stride)) + if idx >= len(series) { + idx = len(series) - 1 + } + if idx == lastIdx { + continue + } + out = append(out, series[idx]) + lastIdx = idx + } + if lastIdx != len(series)-1 { + out = append(out, series[len(series)-1]) + } + return out +} + +// round1/round2/round4 fix a value to a stable, display-ready number of +// decimals at the JSON boundary (mirrors the carbon / tco rounding convention). +func round1(v float64) float64 { return math.Round(v*10) / 10 } +func round2(v float64) float64 { return math.Round(v*100) / 100 } +func round4(v float64) float64 { return math.Round(v*10000) / 10000 } + +// safeF guards against NaN/Inf which silently break json.Encode. +func safeF(v float64) float64 { + if math.IsNaN(v) || math.IsInf(v, 0) { + return 0 + } + return v +} diff --git a/internal/api/segments/segments_test.go b/internal/api/segments/segments_test.go new file mode 100644 index 0000000000..430ed339be --- /dev/null +++ b/internal/api/segments/segments_test.go @@ -0,0 +1,680 @@ +package segments + +import ( + "math" + "testing" + "time" +) + +// --------------------------------------------------------------------------- +// Pure-core table tests. Everything here is a deterministic transform of its +// inputs (no DB, no clock, no network), so the haversine distance, the greedy +// seed-anchored clustering, the leaderboard ranking, and the ghost +// alignment/interpolation are locked down independently of the handler. +// --------------------------------------------------------------------------- + +const eps = 1e-9 + +func approx(a, b float64) bool { return math.Abs(a-b) <= eps } + +// approxT compares with an explicit tolerance (used for the haversine, whose +// spherical model is only accurate to well under a metre at this scale). +func approxT(a, b, tol float64) bool { return math.Abs(a-b) <= tol } + +var baseTime = time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) + +// mkDrive is a terse DrivePoint builder for the table tests. hasEnergy is +// implied by energyWh > 0 (mirrors how the handler sets the flag). +func mkDrive(id int64, offsetH int, sLat, sLon, eLat, eLon, distM, durS, energyWh float64) DrivePoint { + return DrivePoint{ + DriveID: id, + StartedAt: baseTime.Add(time.Duration(offsetH) * time.Hour), + StartLat: sLat, + StartLon: sLon, + EndLat: eLat, + EndLon: eLon, + DistanceM: distM, + DurationS: durS, + EnergyWh: energyWh, + HasEnergy: energyWh > 0, + } +} + +// --------------------------------------------------------------------------- +// HaversineMeters — great-circle distance. +// --------------------------------------------------------------------------- + +func TestHaversineMeters(t *testing.T) { + t.Parallel() + tests := []struct { + name string + lat1, lon1, lat2, lon2 float64 + want float64 + tol float64 + }{ + {"identical point is zero", 51.5, -0.12, 51.5, -0.12, 0, eps}, + {"one degree of longitude at equator", 0, 0, 0, 1, 111194.93, 1.0}, + {"one degree of latitude", 0, 0, 1, 0, 111194.93, 1.0}, + {"one milli-degree of longitude at equator", 0, 0, 0, 0.001, 111.19, 0.1}, + {"symmetric (order independent)", 0, 0.001, 0, 0, 111.19, 0.1}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := HaversineMeters(tc.lat1, tc.lon1, tc.lat2, tc.lon2) + if !approxT(got, tc.want, tc.tol) { + t.Fatalf("HaversineMeters = %.4f, want %.4f (±%.4f)", got, tc.want, tc.tol) + } + }) + } +} + +// --------------------------------------------------------------------------- +// WithinRadius / SameSegment — the segment membership predicate. +// --------------------------------------------------------------------------- + +func TestWithinRadius(t *testing.T) { + t.Parallel() + // ~222 m apart (0.002° of longitude at the equator). + if !WithinRadius(0, 0, 0, 0.002, 250) { + t.Fatal("expected 222 m to be within 250 m") + } + if WithinRadius(0, 0, 0, 0.002, 100) { + t.Fatal("expected 222 m NOT to be within 100 m") + } + if !WithinRadius(1.2345, 6.789, 1.2345, 6.789, 0) { + t.Fatal("expected an identical point to be within a zero radius") + } +} + +func TestSameSegment(t *testing.T) { + t.Parallel() + // Start and end both close. + a := mkDrive(1, 0, 0, 0, 0, 1, 1000, 100, 0) + near := mkDrive(2, 1, 0, 0.001, 0, 1.001, 1000, 100, 0) // ~111 m at both ends + if !SameSegment(a, near, DefaultRadiusM) { + t.Fatal("expected drives with close start AND end to share a segment") + } + // Same start, far end. + farEnd := mkDrive(3, 2, 0, 0.001, 5, 5, 1000, 100, 0) + if SameSegment(a, farEnd, DefaultRadiusM) { + t.Fatal("expected a far END to break segment membership") + } + // Far start, same end. + farStart := mkDrive(4, 3, 5, 5, 0, 1.001, 1000, 100, 0) + if SameSegment(a, farStart, DefaultRadiusM) { + t.Fatal("expected a far START to break segment membership") + } +} + +// --------------------------------------------------------------------------- +// ClusterDrives — greedy, seed-anchored, NON-transitive. +// --------------------------------------------------------------------------- + +func TestClusterDrives_GroupsAndSplits(t *testing.T) { + t.Parallel() + // A and B share a segment; C is far away. + a := mkDrive(1, 0, 0, 0, 0, 1, 1000, 300, 0) + b := mkDrive(2, 1, 0.0001, 0.0001, 0, 1.0001, 1000, 200, 0) + c := mkDrive(3, 2, 10, 10, 11, 11, 5000, 900, 0) + + clusters := ClusterDrives([]DrivePoint{a, b, c}, DefaultRadiusM) + if len(clusters) != 2 { + t.Fatalf("clusters = %d, want 2", len(clusters)) + } + if len(clusters[0].Drives) != 2 || clusters[0].Seed.DriveID != 1 { + t.Fatalf("first cluster: seed=%d size=%d, want seed=1 size=2", + clusters[0].Seed.DriveID, len(clusters[0].Drives)) + } + if len(clusters[1].Drives) != 1 || clusters[1].Seed.DriveID != 3 { + t.Fatalf("second cluster: seed=%d size=%d, want seed=3 size=1", + clusters[1].Seed.DriveID, len(clusters[1].Drives)) + } +} + +func TestClusterDrives_SeedAnchoredNotTransitive(t *testing.T) { + t.Parallel() + // Ends are identical for all three; only the START varies along a line: + // A start lon 0.000, B start lon 0.002 (~222 m from A, within 250), + // C start lon 0.004 (~444 m from A -> NOT within 250, though ~222 m from B). + // Because membership is anchored to the SEED (A), C does not chain onto B; + // it seeds its own cluster. This locks the non-transitive behaviour. + a := mkDrive(1, 0, 0, 0.000, 0, 1, 1000, 300, 0) + b := mkDrive(2, 1, 0, 0.002, 0, 1, 1000, 200, 0) + c := mkDrive(3, 2, 0, 0.004, 0, 1, 1000, 250, 0) + + clusters := ClusterDrives([]DrivePoint{a, b, c}, DefaultRadiusM) + if len(clusters) != 2 { + t.Fatalf("clusters = %d, want 2 (non-transitive seed anchoring)", len(clusters)) + } + if len(clusters[0].Drives) != 2 { + t.Fatalf("cluster A size = %d, want 2 (A,B)", len(clusters[0].Drives)) + } + if len(clusters[1].Drives) != 1 || clusters[1].Seed.DriveID != 3 { + t.Fatalf("cluster C = seed %d size %d, want seed 3 size 1", + clusters[1].Seed.DriveID, len(clusters[1].Drives)) + } +} + +func TestClusterDrives_Empty(t *testing.T) { + t.Parallel() + if got := ClusterDrives(nil, DefaultRadiusM); len(got) != 0 { + t.Fatalf("clusters = %d, want 0", len(got)) + } +} + +// --------------------------------------------------------------------------- +// MostCommon — deterministic mode with blank filtering + lexicographic tie-break. +// --------------------------------------------------------------------------- + +func TestMostCommon(t *testing.T) { + t.Parallel() + tests := []struct { + name string + vals []string + want string + }{ + {"clear winner", []string{"x", "x", "y"}, "x"}, + {"blanks ignored", []string{"", " ", "home", "home"}, "home"}, + {"all blank", []string{"", " ", "\t"}, ""}, + {"tie breaks lexicographically", []string{"b", "a"}, "a"}, + {"empty", nil, ""}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := MostCommon(tc.vals); got != tc.want { + t.Fatalf("MostCommon(%v) = %q, want %q", tc.vals, got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// WhPerKm — SI energy efficiency, guarded division. +// --------------------------------------------------------------------------- + +func TestWhPerKm(t *testing.T) { + t.Parallel() + tests := []struct { + name string + energyWh, distM float64 + want float64 + }{ + {"10 km at 2000 Wh", 2000, 10000, 200}, + {"1 km at 150 Wh", 150, 1000, 150}, + {"zero distance is undefined", 500, 0, 0}, + {"negative distance guarded", 500, -100, 0}, + {"zero energy", 0, 10000, 0}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := WhPerKm(tc.energyWh, tc.distM); !approx(got, tc.want) { + t.Fatalf("WhPerKm(%v,%v) = %v, want %v", tc.energyWh, tc.distM, got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// MedianDistanceM — odd/even/empty. +// --------------------------------------------------------------------------- + +func TestMedianDistanceM(t *testing.T) { + t.Parallel() + mk := func(dists ...float64) []DrivePoint { + out := make([]DrivePoint, len(dists)) + for i, d := range dists { + out[i] = DrivePoint{DistanceM: d} + } + return out + } + tests := []struct { + name string + drives []DrivePoint + want float64 + }{ + {"odd count", mk(30, 10, 20), 20}, + {"even count averages the middle two", mk(10, 20, 30, 40), 25}, + {"unsorted input", mk(40, 10, 30, 20), 25}, + {"single", mk(1234), 1234}, + {"empty", nil, 0}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := MedianDistanceM(tc.drives); !approx(got, tc.want) { + t.Fatalf("MedianDistanceM = %v, want %v", got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Summarize / InterestingSegments — the per-segment display + PB reduction. +// --------------------------------------------------------------------------- + +func TestSummarize(t *testing.T) { + t.Parallel() + // Three attempts of Home -> Work; one alternate end label; one drive with no + // energy reading. d2 is both the fastest AND the most efficient; d3 is latest. + d1 := mkDrive(1, 0, 0, 0, 0, 1, 10000, 300, 2000) // 200 Wh/km + d1.StartPlace, d1.EndPlace = "Home", "Work" + d2 := mkDrive(2, 1, 0, 0, 0, 1, 10000, 200, 1500) // 150 Wh/km (best) + d2.StartPlace, d2.EndPlace = "Home", "Work" + d3 := mkDrive(3, 2, 0, 0, 0, 1, 12000, 250, 0) // no energy + d3.StartPlace, d3.EndPlace = "Home", "Office" + + got := Summarize(Cluster{Seed: d1, Drives: []DrivePoint{d1, d2, d3}}) + + if got.AttemptCount != 3 { + t.Fatalf("AttemptCount = %d, want 3", got.AttemptCount) + } + if got.StartAddress != "Home" || got.EndAddress != "Work" { + t.Fatalf("addresses = %q -> %q, want Home -> Work", got.StartAddress, got.EndAddress) + } + if got.Name != "Home → Work" { + t.Fatalf("Name = %q, want %q", got.Name, "Home → Work") + } + if !approx(got.DistanceM, 10000) { + t.Fatalf("DistanceM (median) = %v, want 10000", got.DistanceM) + } + if got.BestTime.DriveID != 2 { + t.Fatalf("BestTime drive = %d, want 2", got.BestTime.DriveID) + } + if got.Latest.DriveID != 3 { + t.Fatalf("Latest drive = %d, want 3", got.Latest.DriveID) + } + if !got.HasBestEff || got.BestEff.DriveID != 2 || !approx(got.BestEffWhPerKm, 150) { + t.Fatalf("BestEff = drive %d @ %v Wh/km (has=%v), want drive 2 @ 150", + got.BestEff.DriveID, got.BestEffWhPerKm, got.HasBestEff) + } +} + +func TestSummarize_NoEnergyMeansNoBestEff(t *testing.T) { + t.Parallel() + d1 := mkDrive(1, 0, 0, 0, 0, 1, 10000, 300, 0) + d2 := mkDrive(2, 1, 0, 0, 0, 1, 10000, 200, 0) + got := Summarize(Cluster{Seed: d1, Drives: []DrivePoint{d1, d2}}) + if got.HasBestEff { + t.Fatalf("HasBestEff = true, want false when no attempt has energy") + } + if got.BestTime.DriveID != 2 { + t.Fatalf("BestTime = %d, want 2 (fastest still resolved)", got.BestTime.DriveID) + } +} + +func TestSummarize_FallbackNameFromCoords(t *testing.T) { + t.Parallel() + d1 := mkDrive(1, 0, 12.34567, 1.0, 76.54321, 2.0, 1000, 100, 0) + d2 := mkDrive(2, 1, 12.34567, 1.0, 76.54321, 2.0, 1000, 90, 0) + got := Summarize(Cluster{Seed: d1, Drives: []DrivePoint{d1, d2}}) + want := "12.3457, 1.0000 → 76.5432, 2.0000" + if got.Name != want { + t.Fatalf("Name = %q, want %q", got.Name, want) + } +} + +func TestInterestingSegments_FiltersByMinAttempts(t *testing.T) { + t.Parallel() + two := Cluster{Seed: mkDrive(1, 0, 0, 0, 0, 1, 1000, 100, 0), + Drives: []DrivePoint{mkDrive(1, 0, 0, 0, 0, 1, 1000, 100, 0), mkDrive(2, 1, 0, 0, 0, 1, 1000, 90, 0)}} + one := Cluster{Seed: mkDrive(3, 2, 5, 5, 6, 6, 1000, 100, 0), + Drives: []DrivePoint{mkDrive(3, 2, 5, 5, 6, 6, 1000, 100, 0)}} + three := Cluster{Seed: mkDrive(4, 3, 9, 9, 8, 8, 1000, 100, 0), + Drives: []DrivePoint{ + mkDrive(4, 3, 9, 9, 8, 8, 1000, 100, 0), + mkDrive(5, 4, 9, 9, 8, 8, 1000, 95, 0), + mkDrive(6, 5, 9, 9, 8, 8, 1000, 92, 0), + }} + + got := InterestingSegments([]Cluster{two, one, three}, MinAttempts) + if len(got) != 2 { + t.Fatalf("interesting segments = %d, want 2 (the >=2-attempt clusters)", len(got)) + } + // Always non-nil even when nothing qualifies. + if InterestingSegments([]Cluster{one}, MinAttempts) == nil { + t.Fatal("expected a non-nil empty slice, got nil") + } +} + +// --------------------------------------------------------------------------- +// RankByTime — fastest first, delta-to-best, PR flag, deterministic ties. +// --------------------------------------------------------------------------- + +func TestRankByTime(t *testing.T) { + t.Parallel() + d1 := mkDrive(1, 0, 0, 0, 0, 1, 10000, 300, 2000) // 200 Wh/km + d2 := mkDrive(2, 1, 0, 0, 0, 1, 10000, 200, 1500) // 150 Wh/km, fastest + d3 := mkDrive(3, 2, 0, 0, 0, 1, 10000, 250, 0) // no energy + + got := RankByTime([]DrivePoint{d1, d2, d3}) + if len(got) != 3 { + t.Fatalf("rows = %d, want 3", len(got)) + } + // Order: d2 (200s), d3 (250s), d1 (300s). + wantOrder := []int64{2, 3, 1} + for i, w := range wantOrder { + if got[i].Drive.DriveID != w || got[i].Rank != i+1 { + t.Fatalf("row %d = drive %d rank %d, want drive %d rank %d", + i, got[i].Drive.DriveID, got[i].Rank, w, i+1) + } + } + if !got[0].IsPR || got[1].IsPR || got[2].IsPR { + t.Fatal("expected only the rank-1 row to be flagged IsPR") + } + // Deltas relative to the 200 s best. + if !approx(got[0].DeltaToBestS, 0) || !approx(got[1].DeltaToBestS, 50) || !approx(got[2].DeltaToBestS, 100) { + t.Fatalf("deltas = %v/%v/%v, want 0/50/100", + got[0].DeltaToBestS, got[1].DeltaToBestS, got[2].DeltaToBestS) + } + // Efficiency carried where present, absent for the energy-less drive. + if !got[0].HasWhPerKm || !approx(got[0].WhPerKm, 150) { + t.Fatalf("row0 Wh/km = %v (has=%v), want 150", got[0].WhPerKm, got[0].HasWhPerKm) + } + if got[1].HasWhPerKm { + t.Fatal("expected the energy-less drive to have no Wh/km") + } +} + +func TestRankByTime_TieBreaksByEarlierStart(t *testing.T) { + t.Parallel() + early := mkDrive(1, 0, 0, 0, 0, 1, 10000, 200, 0) + late := mkDrive(2, 5, 0, 0, 0, 1, 10000, 200, 0) + got := RankByTime([]DrivePoint{late, early}) + if got[0].Drive.DriveID != 1 || got[1].Drive.DriveID != 2 { + t.Fatalf("tie order = %d,%d, want 1,2 (earlier start first)", + got[0].Drive.DriveID, got[1].Drive.DriveID) + } +} + +func TestRankByTime_Empty(t *testing.T) { + t.Parallel() + if got := RankByTime(nil); got == nil || len(got) != 0 { + t.Fatalf("RankByTime(nil) = %v, want non-nil empty", got) + } +} + +// --------------------------------------------------------------------------- +// RankByEfficiency — most efficient first, omits energy-less attempts. +// --------------------------------------------------------------------------- + +func TestRankByEfficiency(t *testing.T) { + t.Parallel() + d1 := mkDrive(1, 0, 0, 0, 0, 1, 10000, 300, 2000) // 200 Wh/km + d2 := mkDrive(2, 1, 0, 0, 0, 1, 10000, 200, 1500) // 150 Wh/km (best eff) + d3 := mkDrive(3, 2, 0, 0, 0, 1, 10000, 250, 0) // no energy -> omitted + + got := RankByEfficiency([]DrivePoint{d1, d2, d3}) + if len(got) != 2 { + t.Fatalf("rows = %d, want 2 (energy-less drive omitted)", len(got)) + } + if got[0].Drive.DriveID != 2 || !got[0].IsPR { + t.Fatalf("rank1 = drive %d (PR=%v), want drive 2 PR", got[0].Drive.DriveID, got[0].IsPR) + } + if got[1].Drive.DriveID != 1 || got[1].IsPR { + t.Fatalf("rank2 = drive %d (PR=%v), want drive 1 non-PR", got[1].Drive.DriveID, got[1].IsPR) + } + // DeltaToBestS remains the gap to the fastest run overall (200 s): + // d2=200s -> 0, d1=300s -> 100. + if !approx(got[0].DeltaToBestS, 0) || !approx(got[1].DeltaToBestS, 100) { + t.Fatalf("deltas = %v/%v, want 0/100", got[0].DeltaToBestS, got[1].DeltaToBestS) + } +} + +func TestRankByEfficiency_TieBreaksByEarlierStart(t *testing.T) { + t.Parallel() + early := mkDrive(1, 0, 0, 0, 0, 1, 10000, 200, 1500) // 150 Wh/km + late := mkDrive(2, 5, 0, 0, 0, 1, 10000, 210, 1500) // 150 Wh/km + got := RankByEfficiency([]DrivePoint{late, early}) + if got[0].Drive.DriveID != 1 || got[1].Drive.DriveID != 2 { + t.Fatalf("tie order = %d,%d, want 1,2 (earlier start first)", + got[0].Drive.DriveID, got[1].Drive.DriveID) + } +} + +// --------------------------------------------------------------------------- +// BuildProgressSeries — trapezoidal speed integration -> monotonic fraction. +// --------------------------------------------------------------------------- + +func TestBuildProgressSeries(t *testing.T) { + t.Parallel() + // Accelerate 0->10 m/s over 10 s, decelerate 10->0 over the next 10 s. + // Distance: 50 m + 50 m = 100 m total -> fractions 0, 0.5, 1.0. + samples := []TelemetrySample{ + {OffsetS: 0, SpeedMps: 0}, + {OffsetS: 10, SpeedMps: 10}, + {OffsetS: 20, SpeedMps: 0}, + } + got := BuildProgressSeries(samples) + if len(got) != 3 { + t.Fatalf("points = %d, want 3", len(got)) + } + wantFrac := []float64{0, 0.5, 1.0} + wantElapsed := []float64{0, 10, 20} + for i := range got { + if !approx(got[i].FractionOfDistance, wantFrac[i]) { + t.Fatalf("point %d fraction = %v, want %v", i, got[i].FractionOfDistance, wantFrac[i]) + } + if !approx(got[i].ElapsedS, wantElapsed[i]) { + t.Fatalf("point %d elapsed = %v, want %v", i, got[i].ElapsedS, wantElapsed[i]) + } + } +} + +func TestBuildProgressSeries_GuardsNegativeSpeedAndNonForwardTime(t *testing.T) { + t.Parallel() + samples := []TelemetrySample{ + {OffsetS: 0, SpeedMps: -5}, // clamped to 0 + {OffsetS: 10, SpeedMps: 10}, // seg1: (0+10)/2*10 = +50 m + {OffsetS: 10, SpeedMps: 10}, // dt=0 -> no distance added + {OffsetS: 20, SpeedMps: 0}, // seg3: (10+0)/2*10 = +50 m + } + got := BuildProgressSeries(samples) + if !approx(got[0].SpeedMps, 0) { + t.Fatalf("negative speed not clamped: %v", got[0].SpeedMps) + } + // cumulative: 0, 50, 50, 100 -> fractions 0, .5, .5, 1.0 + wantFrac := []float64{0, 0.5, 0.5, 1.0} + for i, w := range wantFrac { + if !approx(got[i].FractionOfDistance, w) { + t.Fatalf("point %d fraction = %v, want %v", i, got[i].FractionOfDistance, w) + } + } +} + +func TestBuildProgressSeries_EmptyAndAllZero(t *testing.T) { + t.Parallel() + if got := BuildProgressSeries(nil); got == nil || len(got) != 0 { + t.Fatalf("empty input -> %v, want non-nil empty", got) + } + // All-zero speed -> zero total distance -> every fraction pinned to 0. + got := BuildProgressSeries([]TelemetrySample{{OffsetS: 0}, {OffsetS: 5}, {OffsetS: 10}}) + for i, p := range got { + if !approx(p.FractionOfDistance, 0) { + t.Fatalf("point %d fraction = %v, want 0 (no distance travelled)", i, p.FractionOfDistance) + } + } +} + +// --------------------------------------------------------------------------- +// ElapsedAtFraction — clamp at the ends, linear interpolation between. +// --------------------------------------------------------------------------- + +func TestElapsedAtFraction(t *testing.T) { + t.Parallel() + series := []ProgressPoint{ + {FractionOfDistance: 0, ElapsedS: 0}, + {FractionOfDistance: 0.5, ElapsedS: 10}, + {FractionOfDistance: 1.0, ElapsedS: 20}, + } + tests := []struct { + frac, want float64 + }{ + {-0.5, 0}, // clamp low + {0, 0}, // exact endpoint + {0.25, 5}, // interpolate first half + {0.5, 10}, // exact mid + {0.75, 15}, // interpolate second half + {1.0, 20}, // exact endpoint + {1.5, 20}, // clamp high + } + for _, tc := range tests { + if got := ElapsedAtFraction(series, tc.frac); !approx(got, tc.want) { + t.Fatalf("ElapsedAtFraction(%v) = %v, want %v", tc.frac, got, tc.want) + } + } + if got := ElapsedAtFraction(nil, 0.5); !approx(got, 0) { + t.Fatalf("empty series -> %v, want 0", got) + } +} + +// --------------------------------------------------------------------------- +// SplitDeltas — A-vs-B time gap sampled across the shared fraction axis. +// --------------------------------------------------------------------------- + +func TestSplitDeltas(t *testing.T) { + t.Parallel() + // A is uniformly twice as fast as B. + a := []ProgressPoint{{FractionOfDistance: 0, ElapsedS: 0}, {FractionOfDistance: 1, ElapsedS: 10}} + b := []ProgressPoint{{FractionOfDistance: 0, ElapsedS: 0}, {FractionOfDistance: 1, ElapsedS: 20}} + + got := SplitDeltas(a, b, 2) + if len(got) != 3 { + t.Fatalf("points = %d, want 3 (n+1)", len(got)) + } + wantFrac := []float64{0, 0.5, 1.0} + wantDelta := []float64{0, -5, -10} // A ahead -> negative + for i := range got { + if !approx(got[i].Fraction, wantFrac[i]) || !approx(got[i].DeltaS, wantDelta[i]) { + t.Fatalf("point %d = {frac %v, delta %v}, want {frac %v, delta %v}", + i, got[i].Fraction, got[i].DeltaS, wantFrac[i], wantDelta[i]) + } + } +} + +func TestSplitDeltas_ClampsN(t *testing.T) { + t.Parallel() + a := []ProgressPoint{{FractionOfDistance: 0, ElapsedS: 0}, {FractionOfDistance: 1, ElapsedS: 10}} + b := []ProgressPoint{{FractionOfDistance: 0, ElapsedS: 0}, {FractionOfDistance: 1, ElapsedS: 10}} + if got := SplitDeltas(a, b, 0); len(got) != 2 { + t.Fatalf("n=0 clamped -> %d points, want 2", len(got)) + } +} + +// --------------------------------------------------------------------------- +// DownsampleSeries — cap size, preserve first + last. +// --------------------------------------------------------------------------- + +func TestDownsampleSeries(t *testing.T) { + t.Parallel() + mk := func(n int) []ProgressPoint { + out := make([]ProgressPoint, n) + for i := range out { + out[i] = ProgressPoint{FractionOfDistance: float64(i) / float64(n-1), ElapsedS: float64(i)} + } + return out + } + five := mk(5) + + got := DownsampleSeries(five, 3) + if len(got) != 3 { + t.Fatalf("downsampled length = %d, want 3", len(got)) + } + if !approx(got[0].FractionOfDistance, 0) || !approx(got[len(got)-1].FractionOfDistance, 1) { + t.Fatalf("endpoints not preserved: first=%v last=%v", + got[0].FractionOfDistance, got[len(got)-1].FractionOfDistance) + } + + // Already within cap -> returned unchanged. + if got := DownsampleSeries(five, 10); len(got) != 5 { + t.Fatalf("under-cap length = %d, want 5 (unchanged)", len(got)) + } + // max == 1 keeps only the last point. + if got := DownsampleSeries(five, 1); len(got) != 1 || !approx(got[0].FractionOfDistance, 1) { + t.Fatalf("max=1 -> %v, want single last point", got) + } + // max <= 0 disables downsampling. + if got := DownsampleSeries(five, 0); len(got) != 5 { + t.Fatalf("max=0 length = %d, want 5 (disabled)", len(got)) + } +} + +// --------------------------------------------------------------------------- +// raceResult — head-to-head winner + margin. +// --------------------------------------------------------------------------- + +func TestRaceResult(t *testing.T) { + t.Parallel() + tests := []struct { + name string + aDur, bDur float64 + wantWinner *int64 + wantMargin float64 + }{ + {"A faster", 200, 300, i64(10), 100}, + {"B faster", 300, 200, i64(20), 100}, + {"tie", 200, 200, nil, 0}, + {"B has no finish -> A wins", 200, 0, i64(10), 200}, + {"A has no finish -> B wins", 0, 200, i64(20), 200}, + {"neither finished", 0, 0, nil, 0}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + gotWinner, gotMargin := raceResult(10, tc.aDur, 20, tc.bDur) + if !approx(gotMargin, tc.wantMargin) { + t.Fatalf("margin = %v, want %v", gotMargin, tc.wantMargin) + } + switch { + case tc.wantWinner == nil && gotWinner != nil: + t.Fatalf("winner = %d, want nil (tie)", *gotWinner) + case tc.wantWinner != nil && gotWinner == nil: + t.Fatalf("winner = nil, want %d", *tc.wantWinner) + case tc.wantWinner != nil && *gotWinner != *tc.wantWinner: + t.Fatalf("winner = %d, want %d", *gotWinner, *tc.wantWinner) + } + }) + } +} + +func i64(v int64) *int64 { return &v } + +// --------------------------------------------------------------------------- +// Rounding + NaN/Inf guards at the JSON boundary. +// --------------------------------------------------------------------------- + +func TestRoundingHelpers(t *testing.T) { + t.Parallel() + if !approx(round1(1.24), 1.2) || !approx(round1(1.25), 1.3) { + t.Fatalf("round1 mismatch: %v %v", round1(1.24), round1(1.25)) + } + if !approx(round2(1.005), 1.0) && !approx(round2(1.005), 1.01) { + t.Fatalf("round2 unexpected: %v", round2(1.005)) + } + if !approx(round4(0.12345), 0.1235) && !approx(round4(0.12345), 0.1234) { + t.Fatalf("round4 unexpected: %v", round4(0.12345)) + } +} + +func TestSafeF(t *testing.T) { + t.Parallel() + if got := safeF(math.NaN()); got != 0 { + t.Fatalf("safeF(NaN) = %v, want 0", got) + } + if got := safeF(math.Inf(1)); got != 0 { + t.Fatalf("safeF(+Inf) = %v, want 0", got) + } + if got := safeF(math.Inf(-1)); got != 0 { + t.Fatalf("safeF(-Inf) = %v, want 0", got) + } + if got := safeF(3.14); !approx(got, 3.14) { + t.Fatalf("safeF(3.14) = %v, want 3.14", got) + } +} diff --git a/internal/api/timemachine/doc.go b/internal/api/timemachine/doc.go new file mode 100644 index 0000000000..523a4e6c42 --- /dev/null +++ b/internal/api/timemachine/doc.go @@ -0,0 +1,14 @@ +// Package timemachine reconstructs the complete signal state of a vehicle +// at an arbitrary past instant from the signal_log cold-path hypertable +// (ADR-004). It powers the Vehicle Time Machine: a DVR-style scrubber over +// the full field state of a car's "mind" at any moment in its history. +// +// The reconstruction is the classic "last row at-or-before an instant" +// query — DISTINCT ON (field) ... WHERE vehicle_id = $1 AND ts <= $2 +// ORDER BY field, ts DESC — served by the (vehicle_id, field, ts DESC) +// covering index. Every value returned is SI-canonical (normalize.toSI ran +// before the cold-path writer), so display-unit conversion is a frontend +// render-boundary concern. +// +// Layer: handler +package timemachine diff --git a/internal/api/timemachine/handler.go b/internal/api/timemachine/handler.go new file mode 100644 index 0000000000..a14fb349a6 --- /dev/null +++ b/internal/api/timemachine/handler.go @@ -0,0 +1,341 @@ +package timemachine + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/ev-dev-labs/teslasync/internal/api/apiparams" + "github.com/ev-dev-labs/teslasync/internal/api/httpx" + "github.com/ev-dev-labs/teslasync/internal/database" + "github.com/jackc/pgx/v5" + "github.com/rs/zerolog/log" +) + +// tmDataTimeout bounds each cold-path read so a stalled connection cannot +// pin the request goroutine longer than the boundary rule allows. The +// pool's server-side statement_timeout is the backstop; this is the +// client-side deadline. A var (not const) so tests can shorten it. +var tmDataTimeout = 15 * time.Second + +// maxFields caps the number of distinct fields returned by a single +// point-in-time reconstruction. signal_log routinely carries a few hundred +// distinct fields per vehicle; the cap keeps a single response bounded and +// prevents a mis-seeded / adversarial table from fanning the DISTINCT ON +// scan into an unbounded payload. A var (not const) so tests can shrink it. +var maxFields = 512 + +// protomodel.ValueKind values as stored in signal_log.value_kind. Exactly +// one typed column is non-null per row, dictated by this kind (see the +// column-mapping table in 000186_signal_log.up.sql). ValueKindCompound (8) +// is never logged (flattened by the codec) and ValueKindUnknown (0) / +// ValueKindInvalid (10) are dropped before the writer, so they collapse to +// the "unknown" label + a nil value here. +const ( + valueKindString = 1 + valueKindBool = 2 + valueKindInt32 = 3 + valueKindInt64 = 4 + valueKindFloat = 5 + valueKindDouble = 6 + valueKindEnum = 7 + valueKindTime = 9 +) + +// stateQuery reconstructs the last-known value of every distinct field for +// a vehicle at-or-before the requested instant. The inner DISTINCT ON walks +// each field's leading edge on the (vehicle_id, field, ts DESC) index and +// stops at the first row with ts <= $2; the outer wrapper orders the result +// alphabetically and caps it at $3 fields. Kept as a package-level constant +// so a test can pin the critical clauses without a live database. +const stateQuery = ` +SELECT field, value_kind, str_value, bool_value, int_value, float_value, time_value, ts +FROM ( + SELECT DISTINCT ON (field) + field, value_kind, str_value, bool_value, int_value, float_value, time_value, ts + FROM signal_log + WHERE vehicle_id = $1 AND ts <= $2 + ORDER BY field, ts DESC +) latest +ORDER BY field +LIMIT $3` + +// rangeQuery bounds the scrubber: the oldest and newest observation for a +// vehicle plus how many distinct fields exist. MIN/MAX/COUNT over an empty +// set return (NULL, NULL, 0) in a single row — never zero rows — so callers +// scan into nullable pointers and a plain int. +const rangeQuery = ` +SELECT MIN(ts) AS earliest, MAX(ts) AS latest, COUNT(DISTINCT field) AS field_count +FROM signal_log +WHERE vehicle_id = $1` + +// Sentinel errors returned by parseAtParam. The handler surfaces their +// message verbatim in a 400 envelope; tests match with errors.Is. +var ( + // errAtMalformed is returned when `at` is present but not RFC 3339. + errAtMalformed = errors.New("at: must be an RFC 3339 timestamp") + // errAtFuture is returned when `at` lies after the now-anchor — a + // Time Machine only reconstructs the past. + errAtFuture = errors.New("at: must not be in the future") +) + +// tmQuerier is the minimal pgx surface the handler needs. Declared locally +// so tests can drive every branch with a scripted row/row source without a +// live database or a vendored pgxmock (mirrors routeeff.routeQuerier). +// *pgxpool.Pool satisfies it. +type tmQuerier interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + +// TimeMachineHandler serves point-in-time vehicle-state reconstruction. +type TimeMachineHandler struct { + db tmQuerier +} + +// NewTimeMachineHandler wires the handler to the pgx pool. Panics on a nil +// pool — a nil pool is a wiring bug, not a runtime condition, so it +// surfaces at construction rather than as a nil-deref on the first request +// (mirrors routeeff.NewRouteEfficiencyHandler). +func NewTimeMachineHandler(db *database.DB) *TimeMachineHandler { + if db == nil || db.Pool == nil { + panic("timemachine.NewTimeMachineHandler: db pool must not be nil") + } + return &TimeMachineHandler{db: db.Pool} +} + +// fieldState is one reconstructed field at the requested instant. Value is +// the single typed column dictated by ValueKind (SI-canonical for floats); +// the frontend converts to display units at the render boundary. +type fieldState struct { + Field string `json:"field"` + Value any `json:"value"` + ValueKind string `json:"value_kind"` + Ts string `json:"ts"` + AgeSeconds float64 `json:"age_seconds"` +} + +// stateResponse is the point-in-time reconstruction envelope. +type stateResponse struct { + At string `json:"at"` + Fields []fieldState `json:"fields"` + Count int `json:"count"` +} + +// rangeResponse bounds the scrubber. Earliest/Latest are nil when the +// vehicle has no signal_log history yet (fresh install / never ingested). +type rangeResponse struct { + Earliest *string `json:"earliest"` + Latest *string `json:"latest"` + FieldCount int `json:"field_count"` +} + +// scannedSignal is the raw signal_log row shape. Exactly one typed column +// is non-nil per row, dictated by ValueKind (see 000186_signal_log). +type scannedSignal struct { + Field string + ValueKind int16 + Str *string + Bool *bool + Int *int64 + Float *float64 + Time *time.Time + Ts time.Time +} + +// parseAtParam resolves the `at` query parameter into the instant at which +// state is reconstructed. Empty ⇒ the now-anchor (live state). A non-empty +// value must be RFC 3339 and must not lie in the future. The result is +// normalized to UTC. Pure + now-injected so handler tests can pin the +// future check without monkey-patching the clock. +func parseAtParam(raw string, now time.Time) (time.Time, error) { + if raw == "" { + return now.UTC(), nil + } + t, err := time.Parse(time.RFC3339, raw) + if err != nil { + return time.Time{}, errAtMalformed + } + t = t.UTC() + if t.After(now) { + return time.Time{}, errAtFuture + } + return t, nil +} + +// valueKindLabel maps a protomodel.ValueKind to a stable, frontend-facing +// label. Int32/Int64 collapse to "int" (both land in int_value) and +// Float/Double collapse to "float" (both land in float_value); Enum keeps +// its own label so the UI can render it distinctly from a plain integer. +func valueKindLabel(kind int16) string { + switch kind { + case valueKindString: + return "string" + case valueKindBool: + return "bool" + case valueKindInt32, valueKindInt64: + return "int" + case valueKindEnum: + return "enum" + case valueKindFloat, valueKindDouble: + return "float" + case valueKindTime: + return "time" + default: + return "unknown" + } +} + +// typedValue resolves the single typed column dictated by ValueKind into a +// JSON-ready value, plus the stable string label for the kind. A NULL in +// the dictated column yields (nil, label) so the field still renders with a +// null value rather than being silently dropped. Time values are rendered +// as RFC 3339 UTC strings so the wire shape is a string, not a nested +// timestamp object. +func typedValue(s scannedSignal) (any, string) { + label := valueKindLabel(s.ValueKind) + switch s.ValueKind { + case valueKindString: + if s.Str == nil { + return nil, label + } + return *s.Str, label + case valueKindBool: + if s.Bool == nil { + return nil, label + } + return *s.Bool, label + case valueKindInt32, valueKindInt64, valueKindEnum: + if s.Int == nil { + return nil, label + } + return *s.Int, label + case valueKindFloat, valueKindDouble: + if s.Float == nil { + return nil, label + } + return *s.Float, label + case valueKindTime: + if s.Time == nil { + return nil, label + } + return s.Time.UTC().Format(time.RFC3339), label + default: + return nil, label + } +} + +// signalAge is how long before the reconstruction instant `at` the field +// last changed, in whole+fractional seconds. Never negative: stateQuery +// only returns rows with ts <= at, but a defensive clamp keeps a +// clock-skewed row from surfacing a negative age on the wire. +func signalAge(at, ts time.Time) float64 { + age := at.Sub(ts).Seconds() + if age < 0 { + return 0 + } + return age +} + +// formatTimePtr renders a nullable timestamp as a nullable RFC 3339 UTC +// string so the JSON envelope carries `null` (not a zero-time string) when +// the vehicle has no history. +func formatTimePtr(t *time.Time) *string { + if t == nil { + return nil + } + s := t.UTC().Format(time.RFC3339) + return &s +} + +// State reconstructs the complete signal state of a vehicle at the instant +// given by `at` (RFC 3339; defaults to now). GET +// /api/v1/vehicles/{vehicleID}/time-machine?at=. +func (h *TimeMachineHandler) State(w http.ResponseWriter, r *http.Request) { + vehicleID, err := apiparams.URLParamInt64(r, "vehicleID") + if err != nil || vehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "invalid vehicle ID") + return + } + + at, err := parseAtParam(r.URL.Query().Get("at"), time.Now()) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), tmDataTimeout) + defer cancel() + + rows, err := h.db.Query(ctx, stateQuery, vehicleID, at, maxFields) + if err != nil { + log.Error().Err(err).Int64("vehicleID", vehicleID).Msg("time machine: failed to query state") + httpx.WriteError(w, http.StatusInternalServerError, "failed to reconstruct vehicle state") + return + } + defer rows.Close() + + fields := make([]fieldState, 0, 64) + for rows.Next() { + var s scannedSignal + if err := rows.Scan(&s.Field, &s.ValueKind, &s.Str, &s.Bool, &s.Int, &s.Float, &s.Time, &s.Ts); err != nil { + log.Error().Err(err).Int64("vehicleID", vehicleID).Msg("time machine: scan state row") + httpx.WriteError(w, http.StatusInternalServerError, "failed to scan vehicle state") + return + } + value, label := typedValue(s) + fields = append(fields, fieldState{ + Field: s.Field, + Value: value, + ValueKind: label, + Ts: s.Ts.UTC().Format(time.RFC3339), + AgeSeconds: signalAge(at, s.Ts), + }) + } + if err := rows.Err(); err != nil { + log.Error().Err(err).Int64("vehicleID", vehicleID).Msg("time machine: state rows iteration") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read vehicle state") + return + } + + httpx.WriteJSON(w, http.StatusOK, stateResponse{ + At: at.Format(time.RFC3339), + Fields: fields, + Count: len(fields), + }) +} + +// Range bounds the scrubber for a vehicle: the earliest and latest +// observation plus the distinct field count. GET +// /api/v1/vehicles/{vehicleID}/time-machine/range. +func (h *TimeMachineHandler) Range(w http.ResponseWriter, r *http.Request) { + vehicleID, err := apiparams.URLParamInt64(r, "vehicleID") + if err != nil || vehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "invalid vehicle ID") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), tmDataTimeout) + defer cancel() + + var earliest, latest *time.Time + var fieldCount int + if err := h.db.QueryRow(ctx, rangeQuery, vehicleID).Scan(&earliest, &latest, &fieldCount); err != nil { + // The aggregate always returns exactly one row, so ErrNoRows is + // defensive; treat it as "no history" rather than a 500. + if errors.Is(err, pgx.ErrNoRows) { + httpx.WriteJSON(w, http.StatusOK, rangeResponse{FieldCount: 0}) + return + } + log.Error().Err(err).Int64("vehicleID", vehicleID).Msg("time machine: failed to query range") + httpx.WriteError(w, http.StatusInternalServerError, "failed to query time machine range") + return + } + + httpx.WriteJSON(w, http.StatusOK, rangeResponse{ + Earliest: formatTimePtr(earliest), + Latest: formatTimePtr(latest), + FieldCount: fieldCount, + }) +} diff --git a/internal/api/timemachine/handler_test.go b/internal/api/timemachine/handler_test.go new file mode 100644 index 0000000000..18c8e1d6e3 --- /dev/null +++ b/internal/api/timemachine/handler_test.go @@ -0,0 +1,721 @@ +package timemachine + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/rs/zerolog" +) + +// TestMain silences the global zerolog logger so the intentional error-path +// logs (query failures, scan failures) don't clutter test output. +func TestMain(m *testing.M) { + zerolog.SetGlobalLevel(zerolog.Disabled) + m.Run() +} + +// --------------------------------------------------------------------------- +// Pure helpers: parseAtParam +// --------------------------------------------------------------------------- + +func TestParseAtParam(t *testing.T) { + t.Parallel() + now := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + raw string + want time.Time + wantErr error + }{ + { + name: "empty defaults to now (UTC)", + raw: "", + want: now, + }, + { + name: "valid RFC3339 in the past", + raw: "2026-01-02T03:04:05Z", + want: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), + }, + { + name: "offset timestamp normalized to UTC", + raw: "2026-01-02T05:04:05+02:00", + want: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), + }, + { + name: "exactly now is allowed (not future)", + raw: "2026-03-04T12:00:00Z", + want: now, + }, + { + name: "malformed value rejected", + raw: "not-a-timestamp", + wantErr: errAtMalformed, + }, + { + name: "date-only (non RFC3339) rejected", + raw: "2026-01-02", + wantErr: errAtMalformed, + }, + { + name: "future timestamp rejected", + raw: "2026-03-04T12:00:01Z", + wantErr: errAtFuture, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := parseAtParam(tt.raw, now) + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Fatalf("parseAtParam(%q) err = %v, want %v", tt.raw, err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("parseAtParam(%q) unexpected err = %v", tt.raw, err) + } + if !got.Equal(tt.want) { + t.Fatalf("parseAtParam(%q) = %v, want %v", tt.raw, got, tt.want) + } + if got.Location() != time.UTC { + t.Fatalf("parseAtParam(%q) location = %v, want UTC", tt.raw, got.Location()) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Pure helpers: valueKindLabel +// --------------------------------------------------------------------------- + +func TestValueKindLabel(t *testing.T) { + t.Parallel() + tests := []struct { + kind int16 + want string + }{ + {valueKindString, "string"}, + {valueKindBool, "bool"}, + {valueKindInt32, "int"}, + {valueKindInt64, "int"}, + {valueKindEnum, "enum"}, + {valueKindFloat, "float"}, + {valueKindDouble, "float"}, + {valueKindTime, "time"}, + {0, "unknown"}, // ValueKindUnknown + {8, "unknown"}, // ValueKindCompound (never logged) + {10, "unknown"}, // ValueKindInvalid + {99, "unknown"}, // out of range + } + for _, tt := range tests { + if got := valueKindLabel(tt.kind); got != tt.want { + t.Errorf("valueKindLabel(%d) = %q, want %q", tt.kind, got, tt.want) + } + } +} + +// --------------------------------------------------------------------------- +// Pure helpers: typedValue +// --------------------------------------------------------------------------- + +func strp(s string) *string { return &s } +func boolp(b bool) *bool { return &b } +func i64p(i int64) *int64 { return &i } +func f64p(f float64) *float64 { return &f } +func timep(t time.Time) *time.Time { return &t } + +func TestTypedValue(t *testing.T) { + t.Parallel() + ts := time.Date(2025, 6, 1, 8, 30, 0, 0, time.UTC) + + tests := []struct { + name string + in scannedSignal + wantValue any + wantKind string + }{ + { + name: "string kind reads str_value", + in: scannedSignal{ValueKind: valueKindString, Str: strp("P")}, + wantValue: "P", + wantKind: "string", + }, + { + name: "bool kind reads bool_value", + in: scannedSignal{ValueKind: valueKindBool, Bool: boolp(true)}, + wantValue: true, + wantKind: "bool", + }, + { + name: "int32 kind reads int_value", + in: scannedSignal{ValueKind: valueKindInt32, Int: i64p(42)}, + wantValue: int64(42), + wantKind: "int", + }, + { + name: "int64 kind reads int_value", + in: scannedSignal{ValueKind: valueKindInt64, Int: i64p(9000000000)}, + wantValue: int64(9000000000), + wantKind: "int", + }, + { + name: "enum kind reads int_value with enum label", + in: scannedSignal{ValueKind: valueKindEnum, Int: i64p(3)}, + wantValue: int64(3), + wantKind: "enum", + }, + { + name: "float kind reads float_value", + in: scannedSignal{ValueKind: valueKindFloat, Float: f64p(3.5)}, + wantValue: 3.5, + wantKind: "float", + }, + { + name: "double kind reads float_value", + in: scannedSignal{ValueKind: valueKindDouble, Float: f64p(75000.0)}, + wantValue: 75000.0, + wantKind: "float", + }, + { + name: "time kind renders RFC3339 string", + in: scannedSignal{ValueKind: valueKindTime, Time: timep(ts)}, + wantValue: "2025-06-01T08:30:00Z", + wantKind: "time", + }, + { + name: "string kind with NULL column yields nil value", + in: scannedSignal{ValueKind: valueKindString, Str: nil}, + wantValue: nil, + wantKind: "string", + }, + { + name: "float kind with NULL column yields nil value", + in: scannedSignal{ValueKind: valueKindFloat, Float: nil}, + wantValue: nil, + wantKind: "float", + }, + { + name: "unknown kind yields nil value + unknown label", + in: scannedSignal{ValueKind: 0}, + wantValue: nil, + wantKind: "unknown", + }, + { + name: "mismatched column ignored (only kind-dictated column read)", + in: scannedSignal{ValueKind: valueKindBool, Str: strp("ignore-me")}, + wantValue: nil, + wantKind: "bool", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + gotValue, gotKind := typedValue(tt.in) + if gotValue != tt.wantValue { + t.Errorf("typedValue value = %#v (%T), want %#v (%T)", gotValue, gotValue, tt.wantValue, tt.wantValue) + } + if gotKind != tt.wantKind { + t.Errorf("typedValue kind = %q, want %q", gotKind, tt.wantKind) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Pure helpers: signalAge +// --------------------------------------------------------------------------- + +func TestSignalAge(t *testing.T) { + t.Parallel() + at := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) + tests := []struct { + name string + ts time.Time + want float64 + }{ + {"same instant", at, 0}, + {"two minutes old", at.Add(-2 * time.Minute), 120}, + {"one day old", at.Add(-24 * time.Hour), 86400}, + {"future ts clamped to zero", at.Add(1 * time.Second), 0}, + } + for _, tt := range tests { + if got := signalAge(at, tt.ts); got != tt.want { + t.Errorf("%s: signalAge = %v, want %v", tt.name, got, tt.want) + } + } +} + +// --------------------------------------------------------------------------- +// Query-building: assert the critical clauses are present so a refactor that +// drops the point-in-time semantics is caught without a live database. +// --------------------------------------------------------------------------- + +func TestStateQuery_Shape(t *testing.T) { + t.Parallel() + needles := []string{ + "DISTINCT ON (field)", // one row per field + "WHERE vehicle_id = $1", // vehicle scope + "ts <= $2", // at-or-before the instant + "ORDER BY field, ts DESC", // leading-edge per field + "LIMIT $3", // field cap + } + for _, n := range needles { + if !strings.Contains(stateQuery, n) { + t.Errorf("stateQuery missing clause %q\nquery=%s", n, stateQuery) + } + } +} + +func TestRangeQuery_Shape(t *testing.T) { + t.Parallel() + needles := []string{ + "MIN(ts)", + "MAX(ts)", + "COUNT(DISTINCT field)", + "WHERE vehicle_id = $1", + } + for _, n := range needles { + if !strings.Contains(rangeQuery, n) { + t.Errorf("rangeQuery missing clause %q\nquery=%s", n, rangeQuery) + } + } +} + +// --------------------------------------------------------------------------- +// Constructor guards +// --------------------------------------------------------------------------- + +func TestNewTimeMachineHandler_NilDBPanics(t *testing.T) { + t.Parallel() + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic constructing handler with a nil *database.DB") + } + }() + _ = NewTimeMachineHandler(nil) +} + +// --------------------------------------------------------------------------- +// Fake pgx plumbing. The codebase does not vendor pgxmock (see routeeff / +// chargeopt for the same precedent); the handler talks to a local tmQuerier +// interface so tests can supply a scripted row source without a database. +// --------------------------------------------------------------------------- + +type fakeRows struct { + data [][]any + idx int + scanErr error + errVal error + closed bool +} + +func (r *fakeRows) Next() bool { + if r.idx >= len(r.data) { + return false + } + r.idx++ + return true +} + +func (r *fakeRows) Scan(dest ...any) error { + if r.scanErr != nil { + return r.scanErr + } + return assignScan(dest, r.data[r.idx-1]) +} + +func (r *fakeRows) Close() { r.closed = true } +func (r *fakeRows) Err() error { return r.errVal } +func (r *fakeRows) CommandTag() pgconn.CommandTag { return pgconn.CommandTag{} } +func (r *fakeRows) FieldDescriptions() []pgconn.FieldDescription { return nil } +func (r *fakeRows) Values() ([]any, error) { return nil, nil } +func (r *fakeRows) RawValues() [][]byte { return nil } +func (r *fakeRows) Conn() *pgx.Conn { return nil } + +var _ pgx.Rows = (*fakeRows)(nil) + +type fakeRow struct { + vals []any + err error +} + +func (r *fakeRow) Scan(dest ...any) error { + if r.err != nil { + return r.err + } + return assignScan(dest, r.vals) +} + +var _ pgx.Row = (*fakeRow)(nil) + +// assignScan copies scripted values into the handler's Scan destinations, +// mimicking pgx's per-type scanning (including NULL → nil pointer for the +// nullable typed columns). A nil scripted entry for a **T destination is a +// SQL NULL. +func assignScan(dest []any, vals []any) error { + if len(dest) != len(vals) { + return fmt.Errorf("scan: %d destinations but row has %d values", len(dest), len(vals)) + } + for i, d := range dest { + v := vals[i] + switch p := d.(type) { + case *string: + s, ok := v.(string) + if !ok { + return fmt.Errorf("col %d: cannot scan %T into *string", i, v) + } + *p = s + case *int: + n, ok := v.(int) + if !ok { + return fmt.Errorf("col %d: cannot scan %T into *int", i, v) + } + *p = n + case *int16: + n, ok := v.(int16) + if !ok { + return fmt.Errorf("col %d: cannot scan %T into *int16", i, v) + } + *p = n + case *time.Time: + tv, ok := v.(time.Time) + if !ok { + return fmt.Errorf("col %d: cannot scan %T into *time.Time", i, v) + } + *p = tv + case **string: + if v == nil { + *p = nil + continue + } + s, ok := v.(string) + if !ok { + return fmt.Errorf("col %d: cannot scan %T into **string", i, v) + } + cp := s + *p = &cp + case **bool: + if v == nil { + *p = nil + continue + } + b, ok := v.(bool) + if !ok { + return fmt.Errorf("col %d: cannot scan %T into **bool", i, v) + } + cp := b + *p = &cp + case **int64: + if v == nil { + *p = nil + continue + } + n, ok := v.(int64) + if !ok { + return fmt.Errorf("col %d: cannot scan %T into **int64", i, v) + } + cp := n + *p = &cp + case **float64: + if v == nil { + *p = nil + continue + } + f, ok := v.(float64) + if !ok { + return fmt.Errorf("col %d: cannot scan %T into **float64", i, v) + } + cp := f + *p = &cp + case **time.Time: + if v == nil { + *p = nil + continue + } + tv, ok := v.(time.Time) + if !ok { + return fmt.Errorf("col %d: cannot scan %T into **time.Time", i, v) + } + cp := tv + *p = &cp + default: + return fmt.Errorf("col %d: unsupported destination type %T", i, d) + } + } + return nil +} + +// fakePool records the SQL + args and returns scripted rows / a scripted row. +type fakePool struct { + rows pgx.Rows + queryErr error + row pgx.Row + gotSQL string + gotArgs []any +} + +func (p *fakePool) Query(_ context.Context, sql string, args ...any) (pgx.Rows, error) { + p.gotSQL = sql + p.gotArgs = args + if p.queryErr != nil { + return nil, p.queryErr + } + return p.rows, nil +} + +func (p *fakePool) QueryRow(_ context.Context, sql string, args ...any) pgx.Row { + p.gotSQL = sql + p.gotArgs = args + return p.row +} + +var _ tmQuerier = (*fakePool)(nil) + +// stateRequest builds a request whose chi route context resolves vehicleID +// and carries the given raw query string (e.g. "at=2025-06-01T08:30:00Z"). +func stateRequest(vehicleID, rawQuery string) *http.Request { + req := httptest.NewRequest(http.MethodGet, "/vehicles/"+vehicleID+"/time-machine?"+rawQuery, nil) + rc := chi.NewRouteContext() + rc.URLParams.Add("vehicleID", vehicleID) + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rc)) +} + +// --------------------------------------------------------------------------- +// Handler: State +// --------------------------------------------------------------------------- + +func TestState_ReconstructsTypedFields(t *testing.T) { + at := time.Date(2025, 6, 1, 8, 30, 0, 0, time.UTC) + // ts values are all at-or-before `at`; ages are deterministic. + socTs := at.Add(-30 * time.Second) // fresh + gearTs := at.Add(-2 * time.Hour) // stale + odoTs := at.Add(-90 * 24 * time.Hour) // very stale + + rows := &fakeRows{data: [][]any{ + // field, value_kind, str, bool, int, float, time, ts + {"BatteryLevel", int16(valueKindDouble), nil, nil, nil, 72.5, nil, socTs}, + {"Gear", int16(valueKindString), "D", nil, nil, nil, nil, gearTs}, + {"Odometer", int16(valueKindInt64), nil, nil, int64(123456), nil, nil, odoTs}, + {"Locked", int16(valueKindBool), nil, true, nil, nil, nil, socTs}, + }} + pool := &fakePool{rows: rows} + h := &TimeMachineHandler{db: pool} + + rec := httptest.NewRecorder() + h.State(rec, stateRequest("42", "at=2025-06-01T08:30:00Z")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + var body struct { + At string `json:"at"` + Count int `json:"count"` + Fields []struct { + Field string `json:"field"` + Value any `json:"value"` + ValueKind string `json:"value_kind"` + Ts string `json:"ts"` + AgeSeconds float64 `json:"age_seconds"` + } `json:"fields"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v; body=%s", err, rec.Body.String()) + } + + if body.At != "2025-06-01T08:30:00Z" { + t.Errorf("at = %q, want reconstructed instant", body.At) + } + if body.Count != 4 || len(body.Fields) != 4 { + t.Fatalf("count = %d / fields = %d, want 4", body.Count, len(body.Fields)) + } + + byField := map[string]struct { + Value any + Kind string + AgeSeconds float64 + }{} + for _, f := range body.Fields { + byField[f.Field] = struct { + Value any + Kind string + AgeSeconds float64 + }{f.Value, f.ValueKind, f.AgeSeconds} + } + + if got := byField["BatteryLevel"]; got.Kind != "float" || got.Value.(float64) != 72.5 || got.AgeSeconds != 30 { + t.Errorf("BatteryLevel = %#v, want float 72.5 age 30", got) + } + if got := byField["Gear"]; got.Kind != "string" || got.Value.(string) != "D" || got.AgeSeconds != 7200 { + t.Errorf("Gear = %#v, want string D age 7200", got) + } + if got := byField["Odometer"]; got.Kind != "int" || got.Value.(float64) != 123456 { + t.Errorf("Odometer = %#v, want int 123456", got) + } + if got := byField["Locked"]; got.Kind != "bool" || got.Value.(bool) != true { + t.Errorf("Locked = %#v, want bool true", got) + } + + // The point-in-time args must be forwarded verbatim. + if len(pool.gotArgs) != 3 { + t.Fatalf("query args = %v, want [vehicleID at maxFields]", pool.gotArgs) + } + if vid, ok := pool.gotArgs[0].(int64); !ok || vid != 42 { + t.Errorf("arg0 vehicleID = %#v, want int64(42)", pool.gotArgs[0]) + } + if atArg, ok := pool.gotArgs[1].(time.Time); !ok || !atArg.Equal(at) { + t.Errorf("arg1 at = %#v, want %v", pool.gotArgs[1], at) + } + if !rows.closed { + t.Error("rows.Close() was not called") + } +} + +func TestState_EmptyHistoryReturnsEmptyFields(t *testing.T) { + pool := &fakePool{rows: &fakeRows{data: nil}} + h := &TimeMachineHandler{db: pool} + + rec := httptest.NewRecorder() + h.State(rec, stateRequest("7", "")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + // fields must serialize as [] (never null) so the frontend can map safely. + if !strings.Contains(rec.Body.String(), `"fields":[]`) { + t.Errorf("empty history body should carry an empty fields array; got %s", rec.Body.String()) + } +} + +func TestState_InvalidVehicleID(t *testing.T) { + h := &TimeMachineHandler{db: &fakePool{}} + rec := httptest.NewRecorder() + h.State(rec, stateRequest("0", "")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestState_MalformedAtParam(t *testing.T) { + h := &TimeMachineHandler{db: &fakePool{}} + rec := httptest.NewRecorder() + h.State(rec, stateRequest("42", "at=nonsense")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestState_QueryErrorIs500(t *testing.T) { + pool := &fakePool{queryErr: errors.New("connection reset")} + h := &TimeMachineHandler{db: pool} + rec := httptest.NewRecorder() + h.State(rec, stateRequest("42", "at=2025-06-01T08:30:00Z")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500; body=%s", rec.Code, rec.Body.String()) + } +} + +// --------------------------------------------------------------------------- +// Handler: Range +// --------------------------------------------------------------------------- + +func rangeRequest(vehicleID string) *http.Request { + req := httptest.NewRequest(http.MethodGet, "/vehicles/"+vehicleID+"/time-machine/range", nil) + rc := chi.NewRouteContext() + rc.URLParams.Add("vehicleID", vehicleID) + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rc)) +} + +func TestRange_ReturnsBounds(t *testing.T) { + earliest := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + latest := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) + pool := &fakePool{row: &fakeRow{vals: []any{earliest, latest, 137}}} + h := &TimeMachineHandler{db: pool} + + rec := httptest.NewRecorder() + h.Range(rec, rangeRequest("42")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Earliest *string `json:"earliest"` + Latest *string `json:"latest"` + FieldCount int `json:"field_count"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v; body=%s", err, rec.Body.String()) + } + if body.Earliest == nil || *body.Earliest != "2025-01-01T00:00:00Z" { + t.Errorf("earliest = %v, want 2025-01-01T00:00:00Z", body.Earliest) + } + if body.Latest == nil || *body.Latest != "2026-03-04T12:00:00Z" { + t.Errorf("latest = %v, want 2026-03-04T12:00:00Z", body.Latest) + } + if body.FieldCount != 137 { + t.Errorf("field_count = %d, want 137", body.FieldCount) + } +} + +func TestRange_NoHistoryReturnsNulls(t *testing.T) { + // MIN/MAX over an empty set are NULL; COUNT is 0. + pool := &fakePool{row: &fakeRow{vals: []any{nil, nil, 0}}} + h := &TimeMachineHandler{db: pool} + + rec := httptest.NewRecorder() + h.Range(rec, rangeRequest("42")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Earliest *string `json:"earliest"` + Latest *string `json:"latest"` + FieldCount int `json:"field_count"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v; body=%s", err, rec.Body.String()) + } + if body.Earliest != nil || body.Latest != nil { + t.Errorf("earliest/latest = %v/%v, want null/null", body.Earliest, body.Latest) + } + if body.FieldCount != 0 { + t.Errorf("field_count = %d, want 0", body.FieldCount) + } +} + +func TestRange_InvalidVehicleID(t *testing.T) { + h := &TimeMachineHandler{db: &fakePool{}} + rec := httptest.NewRecorder() + h.Range(rec, rangeRequest("-1")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestRange_ScanErrorIs500(t *testing.T) { + pool := &fakePool{row: &fakeRow{err: errors.New("scan boom")}} + h := &TimeMachineHandler{db: pool} + rec := httptest.NewRecorder() + h.Range(rec, rangeRequest("42")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500; body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/migrations/000215_signal_log_pit_index.down.sql b/migrations/000215_signal_log_pit_index.down.sql new file mode 100644 index 0000000000..5c745e9cb0 --- /dev/null +++ b/migrations/000215_signal_log_pit_index.down.sql @@ -0,0 +1,10 @@ +-- Migration 215: Point-in-time reconstruction index for signal_log (rollback). +-- +-- Drops ONLY the index this migration may have created. The pre-existing +-- signal_log_vehicle_field_ts (owned by 000186_signal_log) is intentionally +-- left untouched — its lifecycle belongs to 000186's own down migration. +-- IF EXISTS keeps the rollback a safe no-op on a database where the up +-- migration short-circuited (because the equivalent 000186 index was +-- already present) and therefore never created idx_signal_log_vehicle_field_ts. + +DROP INDEX IF EXISTS idx_signal_log_vehicle_field_ts; diff --git a/migrations/000215_signal_log_pit_index.up.sql b/migrations/000215_signal_log_pit_index.up.sql new file mode 100644 index 0000000000..5bff85cdab --- /dev/null +++ b/migrations/000215_signal_log_pit_index.up.sql @@ -0,0 +1,45 @@ +-- Migration 215: Point-in-time reconstruction index for signal_log. +-- +-- Goal: keep the Vehicle Time Machine's point-in-time state query fast. +-- The reconstruction endpoint runs, for every distinct field of a vehicle, +-- a `DISTINCT ON (field) ... WHERE vehicle_id = $1 AND ts <= $2 +-- ORDER BY field, ts DESC` scan (the "last row at-or-before an instant" +-- pattern). That access path is served ideally by a composite btree on +-- (vehicle_id, field, ts DESC): Postgres walks each field's leading edge +-- and stops at the first row whose ts <= the requested instant. +-- +-- Equivalence check (this migration is intentionally a safe no-op on an +-- up-to-date schema): +-- 000186_signal_log.up.sql already created +-- CREATE INDEX signal_log_vehicle_field_ts +-- ON signal_log (vehicle_id, field, ts DESC); +-- which is column-for-column identical to the index this migration +-- would otherwise add. Creating a second, differently-named index on +-- the same tuple would only duplicate write amplification and disk with +-- zero read benefit. So the guarded DO block below detects ANY existing +-- index on signal_log that leads with (vehicle_id, field, ts DESC) and +-- skips creation; on a database that somehow lacks it (e.g. a hand-rolled +-- schema, or a future migration that drops 000186's index) it falls +-- through to the canonical CREATE INDEX IF NOT EXISTS. Either way the +-- migration is idempotent and leaves the fast path in place. +-- +-- Reversible by the matching .down.sql, which drops ONLY the index this +-- migration may have created (idx_signal_log_vehicle_field_ts) and never +-- the 000186-owned signal_log_vehicle_field_ts. + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_indexes + WHERE schemaname = current_schema() + AND tablename = 'signal_log' + AND indexdef ILIKE '%(vehicle_id, field, ts DESC)%' + ) THEN + RAISE NOTICE + 'idx_signal_log_vehicle_field_ts: an equivalent (vehicle_id, field, ts DESC) index already exists on signal_log; skipping (no-op).'; + ELSE + CREATE INDEX IF NOT EXISTS idx_signal_log_vehicle_field_ts + ON signal_log (vehicle_id, field, ts DESC); + END IF; +END$$; diff --git a/migrations/000216_battery_passport_ledger.down.sql b/migrations/000216_battery_passport_ledger.down.sql new file mode 100644 index 0000000000..f01f09e51e --- /dev/null +++ b/migrations/000216_battery_passport_ledger.down.sql @@ -0,0 +1,8 @@ +-- Migration 216: Battery Passport provenance ledger (rollback). +-- +-- Drops the index first (defensive; DROP TABLE would cascade it anyway) and +-- then the table. IF EXISTS keeps the rollback a safe no-op on a database +-- where the up migration never ran. + +DROP INDEX IF EXISTS tesla_battery_passport_ledger_vehicle_issued_idx; +DROP TABLE IF EXISTS tesla_battery_passport_ledger; diff --git a/migrations/000216_battery_passport_ledger.up.sql b/migrations/000216_battery_passport_ledger.up.sql new file mode 100644 index 0000000000..5067d33c82 --- /dev/null +++ b/migrations/000216_battery_passport_ledger.up.sql @@ -0,0 +1,59 @@ +-- Migration 216: Battery Passport provenance ledger. +-- +-- Why this table exists +-- ───────────────────── +-- The Battery Passport endpoint (internal/api/batterypassport) issues a +-- certificate-style State-of-Health provenance artifact whose core immutable +-- facts are bound by a SHA-256 provenance hash. Each time the passport is +-- read the handler appends an "issued snapshot" here: the point-in-time +-- SoH, the equivalent-full-cycle count, the provenance hash, and the full +-- passport payload. That gives an append-only audit trail a prospective +-- buyer (or a regulator, per the EU Battery Passport regime) can inspect to +-- confirm the certificate was not fabricated after the fact. +-- +-- Best-effort write, never a read blocker +-- ─────────────────────────────────────── +-- The GET passport handler treats the INSERT as best-effort: a ledger-write +-- failure is logged + counted (battery_passport_ledger_write_failures_total) +-- but NEVER fails the read — the passport is still returned. So this table is +-- an audit convenience, not on the critical path. +-- +-- Why not reuse an existing table +-- ──────────────────────────────── +-- signal_log is per-field vehicle telemetry on a TimescaleDB hypertable; +-- events_outbox (000213) is a transactional outbox for domain events. Neither +-- carries a self-contained, hash-anchored certificate payload, and overloading +-- either would conflate very different semantic streams. This is a small, +-- purpose-built regular table (row count bounded by passport read frequency). +-- +-- Schema notes +-- ──────────── +-- * payload is jsonb so the certificate shape can evolve without a +-- migration; readers project the fields they need. +-- * provenance_hash is the lowercase hex SHA-256 that binds the snapshot; +-- it is NOT unique (the same facts on the same day reproduce the same +-- hash, and a genuine re-issue should append a fresh audit row). +-- * the (vehicle_id, issued_at DESC) index serves the only hot query: +-- "the most recent snapshots for this vehicle". + +CREATE TABLE IF NOT EXISTS tesla_battery_passport_ledger ( + id BIGSERIAL PRIMARY KEY, + vehicle_id BIGINT NOT NULL, + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + soh_pct DOUBLE PRECISION, + equivalent_full_cycles DOUBLE PRECISION, + provenance_hash TEXT NOT NULL, + payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Hot path: "most recent issued snapshots for a vehicle". +CREATE INDEX IF NOT EXISTS tesla_battery_passport_ledger_vehicle_issued_idx + ON tesla_battery_passport_ledger (vehicle_id, issued_at DESC); + +COMMENT ON TABLE tesla_battery_passport_ledger IS + 'Append-only issued-snapshot ledger for the Battery Passport provenance certificate. Written best-effort by the GET passport handler; a write failure never fails the read.'; +COMMENT ON COLUMN tesla_battery_passport_ledger.provenance_hash IS + 'Lowercase hex SHA-256 over the canonical core facts (see batterypassport.CanonicalString). Not unique — same facts on the same day reproduce the same digest.'; +COMMENT ON COLUMN tesla_battery_passport_ledger.payload IS + 'Full Battery Passport certificate JSON as issued (snake_case), preserved for audit.'; diff --git a/migrations/000217_grid_carbon_intensity.down.sql b/migrations/000217_grid_carbon_intensity.down.sql new file mode 100644 index 0000000000..aeac1c18a7 --- /dev/null +++ b/migrations/000217_grid_carbon_intensity.down.sql @@ -0,0 +1,9 @@ +-- Migration 217: Grid carbon-intensity diurnal model (rollback). +-- +-- Drops the whole grid model. IF EXISTS keeps the rollback a safe no-op on a +-- database where the up migration never ran. Dropping the table also discards +-- the seeded curve and any admin edits — the Carbon Intelligence endpoints +-- degrade to a 500 until the up migration is re-applied, which is the intended +-- "feature removed" state for a down migration. + +DROP TABLE IF EXISTS grid_carbon_intensity; diff --git a/migrations/000217_grid_carbon_intensity.up.sql b/migrations/000217_grid_carbon_intensity.up.sql new file mode 100644 index 0000000000..1faf6d7c1c --- /dev/null +++ b/migrations/000217_grid_carbon_intensity.up.sql @@ -0,0 +1,73 @@ +-- Migration 217: Grid carbon-intensity diurnal model (Carbon Intelligence). +-- +-- Why this table exists +-- ───────────────────── +-- The Carbon Intelligence surface (internal/api/carbon) attributes a real CO2 +-- footprint to EACH charging session based on WHEN it charged: grid carbon +-- intensity swings across the day (dirty evening peak, clean midday solar, +-- moderate overnight baseload), so a kWh drawn at 19:00 carries far more CO2 +-- than the same kWh drawn at 13:00. This table is the grid model the handler +-- reads to turn "energy at hour H" into "grams CO2 per kWh at hour H". +-- +-- Self-hosted-safe by design +-- ────────────────────────── +-- TeslaSync ships self-hosted with no guaranteed internet egress, so the +-- feature must NOT depend on a live grid-intensity API (WattTime, Electricity +-- Maps, National Grid ESO, …). Instead we SEED a sensible, realistic built-in +-- diurnal curve here. A future admin screen can UPDATE these 24 rows to match +-- a user's own utility / region without any code change — the table IS the +-- configurable model. The seed is a generic Northern-Hemisphere shape: +-- * overnight baseload (00:00–05:00) ~245–265 gCO2/kWh (nuclear + wind), +-- * midday solar trough (11:00–15:00) ~200–225 gCO2/kWh (cheapest, greenest), +-- * evening peak ramp (18:00–21:00) ~430–500 gCO2/kWh (gas peakers). +-- Values are gCO2 per kWh (well-to-wheel-ish, order-of-magnitude realistic). +-- +-- Schema notes +-- ──────────── +-- * hour_of_day is the PRIMARY KEY (0..23) with a CHECK so a bad row can +-- never be inserted; there is exactly one intensity per clock hour. +-- * g_co2_per_kwh is DOUBLE PRECISION so an admin can store a fractional, +-- region-specific figure without a migration. +-- * The INSERT ... ON CONFLICT DO NOTHING makes re-running the migration a +-- safe no-op and, crucially, never clobbers an admin's hand-edited rows. + +CREATE TABLE IF NOT EXISTS grid_carbon_intensity ( + hour_of_day SMALLINT PRIMARY KEY CHECK (hour_of_day BETWEEN 0 AND 23), + g_co2_per_kwh DOUBLE PRECISION NOT NULL +); + +-- Seed the 24-hour built-in diurnal curve. ON CONFLICT DO NOTHING keeps the +-- migration idempotent and preserves any admin edits on re-run. +INSERT INTO grid_carbon_intensity (hour_of_day, g_co2_per_kwh) VALUES + (0, 260), + (1, 250), + (2, 245), + (3, 245), + (4, 250), + (5, 265), + (6, 300), + (7, 340), + (8, 330), + (9, 280), + (10, 240), + (11, 215), + (12, 200), + (13, 200), + (14, 205), + (15, 225), + (16, 270), + (17, 340), + (18, 430), + (19, 500), + (20, 490), + (21, 450), + (22, 370), + (23, 300) +ON CONFLICT (hour_of_day) DO NOTHING; + +COMMENT ON TABLE grid_carbon_intensity IS + 'Configurable diurnal grid carbon-intensity model (24 rows, one per clock hour). Read by internal/api/carbon to attribute per-session CO2 by charging hour. Seeded with a realistic built-in curve so the feature works offline; a future admin screen edits these rows.'; +COMMENT ON COLUMN grid_carbon_intensity.hour_of_day IS + 'Local clock hour 0..23 (primary key). Intensity is looked up by EXTRACT(HOUR FROM charging_sessions.started_at).'; +COMMENT ON COLUMN grid_carbon_intensity.g_co2_per_kwh IS + 'Grams of CO2 attributed per kWh of energy drawn during this hour. Higher = dirtier grid (evening peak); lower = greener (midday solar / overnight baseload).'; diff --git a/migrations/000218_component_lifespans.down.sql b/migrations/000218_component_lifespans.down.sql new file mode 100644 index 0000000000..aaef1c5b23 --- /dev/null +++ b/migrations/000218_component_lifespans.down.sql @@ -0,0 +1,9 @@ +-- Migration 218: Component lifespans reference table (rollback). +-- +-- Drops the whole component model. IF EXISTS keeps the rollback a safe no-op on +-- a database where the up migration never ran. Dropping the table also discards +-- the seeded figures and any admin edits — the Remaining Useful Life endpoints +-- degrade to a 500 until the up migration is re-applied, which is the intended +-- "feature removed" state for a down migration. + +DROP TABLE IF EXISTS component_lifespans; diff --git a/migrations/000218_component_lifespans.up.sql b/migrations/000218_component_lifespans.up.sql new file mode 100644 index 0000000000..2da84a273c --- /dev/null +++ b/migrations/000218_component_lifespans.up.sql @@ -0,0 +1,72 @@ +-- Migration 218: Component lifespans reference table (Remaining Useful Life / RUL). +-- +-- Why this table exists +-- ───────────────────── +-- The Remaining Useful Life surface (internal/api/rul) FORECASTS the end-of-life +-- of each wear component — going beyond the AnomalyDashboard / DrivetrainHealth +-- surfaces which only detect issues that are ALREADY present. To turn observed +-- wear rates into a "replace-by" date it needs a per-component nominal service +-- life and an end-of-life threshold. This table is that reference model: one row +-- per component, read by the handler to bound each prognosis. +-- +-- Self-hosted-safe by design +-- ────────────────────────── +-- TeslaSync ships self-hosted with no guaranteed internet egress, so the feature +-- must NOT depend on a live manufacturer service-schedule API. Instead we SEED +-- realistic, generic figures here. A future admin screen can UPDATE these rows +-- to match a specific model / driving profile without any code change — the +-- table IS the configurable model. The INSERT ... ON CONFLICT DO NOTHING makes +-- re-running the migration a safe no-op and never clobbers an admin's edits. +-- +-- Schema notes +-- ──────────── +-- * component is the PRIMARY KEY — a stable machine key (e.g. 'hv_battery') +-- the handler switches on; there is exactly one row per component. +-- * nominal_life_km / nominal_life_days are NULLABLE: distance-wear parts +-- (tires, brakes) carry a km life; calendar-wear parts (12V battery, cabin +-- filter) carry a days life. A component populates whichever applies. +-- * eol_threshold is the health value (in the component's own health unit) at +-- which it is considered end-of-life. For the HV battery this is a % State +-- of Health (70); wear/age parts decay toward a 0% remaining-life floor. +-- * notes is a free-text rationale the detail endpoint echoes to the UI. + +CREATE TABLE IF NOT EXISTS component_lifespans ( + component TEXT PRIMARY KEY, + nominal_life_km DOUBLE PRECISION, + nominal_life_days INTEGER, + eol_threshold DOUBLE PRECISION, + notes TEXT +); + +-- Seed the built-in component model. ON CONFLICT DO NOTHING keeps the migration +-- idempotent and preserves any admin-edited rows on re-run. +-- +-- * hv_battery — HV traction pack. EOL at 70% State of Health (a common +-- warranty / usability floor); a Tesla pack is generally +-- rated well past 300,000 km, hence the km reference. +-- * lv_battery — 12V low-voltage battery. Calendar-wear part; ~4 years +-- (1460 days) is a realistic service life. +-- * tires — Whole-set tread life ~50,000 km for a performance EV. +-- * brakes — ~150,000 km: EVs spare their friction brakes via regen, so +-- pads/rotors last far longer than on an ICE car. +-- * cabin_filter — Cabin HVAC filter, replaced roughly yearly (365 days). +INSERT INTO component_lifespans (component, nominal_life_km, nominal_life_days, eol_threshold, notes) VALUES + ('hv_battery', 300000, NULL, 70, 'High-voltage traction pack. Remaining life fitted from the daily State-of-Health trend; end-of-life at 70% SoH.'), + ('lv_battery', NULL, 1460, 0, '12V low-voltage battery. Age-based estimate from vehicle enrollment vs a ~4-year nominal calendar life.'), + ('tires', 50000, NULL, 0, 'Tire tread. Distance-wear estimate from odometer accumulation; whole-life proxy (no per-set reset data).'), + ('brakes', 150000, NULL, 0, 'Brake pads/rotors. Distance-wear estimate; EVs spare the friction brakes via regenerative braking, so life is long.'), + ('cabin_filter', NULL, 365, 0, 'Cabin air filter. Age-based estimate from vehicle enrollment vs a ~1-year nominal calendar life.') +ON CONFLICT (component) DO NOTHING; + +COMMENT ON TABLE component_lifespans IS + 'Configurable per-component service-life model read by internal/api/rul to forecast Remaining Useful Life. Seeded with realistic built-in figures so the feature works offline; a future admin screen edits these rows.'; +COMMENT ON COLUMN component_lifespans.component IS + 'Stable machine key for the component (primary key). The RUL handler switches on this value: hv_battery, lv_battery, tires, brakes, cabin_filter.'; +COMMENT ON COLUMN component_lifespans.nominal_life_km IS + 'Nominal service life in kilometres for distance-wear parts (tires, brakes). NULL for calendar-wear parts.'; +COMMENT ON COLUMN component_lifespans.nominal_life_days IS + 'Nominal service life in days for calendar-wear parts (12V battery, cabin filter). NULL for distance-wear parts.'; +COMMENT ON COLUMN component_lifespans.eol_threshold IS + 'Health value at which the component is end-of-life, in its own health unit. HV battery: percent State of Health (70). Wear/age parts decay toward a 0% remaining-life floor.'; +COMMENT ON COLUMN component_lifespans.notes IS + 'Free-text rationale for the figures, echoed to the UI by the per-component detail endpoint.'; diff --git a/migrations/000219_route_segments.down.sql b/migrations/000219_route_segments.down.sql new file mode 100644 index 0000000000..98c9e96175 --- /dev/null +++ b/migrations/000219_route_segments.down.sql @@ -0,0 +1,11 @@ +-- Migration 219: Route segments (rollback). +-- +-- Drops the Ghost Racing / EV Segments identity table (and its two indexes, +-- which fall with the table). IF EXISTS keeps the rollback a safe no-op on a +-- database where the up migration never ran. Segments are DERIVED from the +-- drives table, so dropping this cache loses only the stable segment ids and the +-- best-effort persisted rows — the Ghost Racing endpoints degrade to a 500 (the +-- list can no longer UPSERT / address segments) until the up migration is +-- re-applied, which is the intended "feature removed" state for a down migration. + +DROP TABLE IF EXISTS route_segments; diff --git a/migrations/000219_route_segments.up.sql b/migrations/000219_route_segments.up.sql new file mode 100644 index 0000000000..c160bdbd45 --- /dev/null +++ b/migrations/000219_route_segments.up.sql @@ -0,0 +1,79 @@ +-- Migration 219: Route segments (Ghost Racing / EV Segments). +-- +-- Why this table exists +-- ───────────────────── +-- The Ghost Racing surface (internal/api/segments) turns a driver's own +-- history into Strava-style route "segments": clusters of drives that share +-- (approximately) the SAME start point AND the SAME end point. Once a route has +-- been driven twice or more it becomes a segment you can race against your own +-- personal best — a head-to-head ghost of your fastest / most-efficient run. +-- +-- Detection is DERIVED, persistence is a CACHE +-- ──────────────────────────────────────────── +-- Segments are DETECTED on the fly from the `drives` table by a pure, +-- table-tested clustering function (haversine start-radius + end-radius). This +-- table is the persistent identity for a detected segment so the leaderboard and +-- ghost endpoints can be addressed by a stable `id` in the URL. The list handler +-- UPSERTs each detected segment best-effort: a write failure is logged + counted +-- but never fails the read (the computed segments are still returned). Because +-- the anchor endpoint tuple is the cluster SEED (the earliest drive's start/end +-- coordinates), re-running detection over the same history is idempotent and +-- lands on the same row via the unique endpoint index. +-- +-- Schema notes +-- ──────────── +-- * id — stable surrogate key addressed by /segments/{id}/... . +-- * vehicle_id — owning vehicle (no FK; the writer stays lock-free, mirroring +-- the phase-42 drives table convention). +-- * name — human label, "start_place → end_place" (coord fallback). +-- * start/end lat+lon — the segment anchor (WGS84 decimal degrees). NOTE the +-- column suffix is `lon` here (route_segments) whereas the +-- source `drives` table uses `lng`; the handler maps between +-- them explicitly. +-- * radius_m — the match radius used to detect membership (default 250 m). +-- * distance_m — representative (median) segment distance, SI metres. +-- * attempt_count — number of drives detected as members at last UPSERT. +-- +-- The UNIQUE index on (vehicle_id, start_lat, start_lon, end_lat, end_lon) is the +-- ON CONFLICT target that makes the UPSERT idempotent per (vehicle, anchor). A +-- second plain index on (vehicle_id) serves the per-vehicle segment list. + +CREATE TABLE IF NOT EXISTS route_segments ( + id BIGSERIAL PRIMARY KEY, + vehicle_id BIGINT NOT NULL, + name TEXT NOT NULL, + start_lat DOUBLE PRECISION, + start_lon DOUBLE PRECISION, + end_lat DOUBLE PRECISION, + end_lon DOUBLE PRECISION, + radius_m DOUBLE PRECISION NOT NULL DEFAULT 250, + distance_m DOUBLE PRECISION, + attempt_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Unique per (vehicle, anchor endpoints): the ON CONFLICT target for the +-- best-effort UPSERT so re-detecting the same segment updates in place instead +-- of duplicating. +CREATE UNIQUE INDEX IF NOT EXISTS route_segments_vehicle_endpoints + ON route_segments (vehicle_id, start_lat, start_lon, end_lat, end_lon); + +-- Serves the per-vehicle segment list (GET /vehicles/{id}/segments). +CREATE INDEX IF NOT EXISTS route_segments_vehicle + ON route_segments (vehicle_id); + +COMMENT ON TABLE route_segments IS + 'Ghost Racing / EV Segments identity table. One row per detected route segment (a cluster of drives sharing an approximate start AND end point). Detected on the fly from drives by internal/api/segments and UPSERTed best-effort so the leaderboard/ghost endpoints can be addressed by a stable id.'; +COMMENT ON COLUMN route_segments.vehicle_id IS + 'Owning vehicle (no FK constraint to keep the writer hot path lock-free, mirroring the phase-42 drives table).'; +COMMENT ON COLUMN route_segments.name IS + 'Human label for the segment, "start_place → end_place" with a lat,lon fallback when reverse geocoding is missing.'; +COMMENT ON COLUMN route_segments.start_lon IS + 'WGS84 longitude at the segment anchor start (decimal degrees). Note: the source drives table names the same quantity start_lng; the handler maps between them.'; +COMMENT ON COLUMN route_segments.radius_m IS + 'Match radius in metres used to detect segment membership (a drive belongs when its start AND end are each within this radius of the anchor). Defaults to 250 m.'; +COMMENT ON COLUMN route_segments.distance_m IS + 'Representative (median) segment distance in SI metres across detected member drives.'; +COMMENT ON COLUMN route_segments.attempt_count IS + 'Number of member drives detected at the last UPSERT. Recomputed on every list read.'; diff --git a/web/src/App.tsx b/web/src/App.tsx index cb29e560fd..f366901fb4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -24,6 +24,7 @@ const Vehicles = lazy(() => import('./features/vehicles/pages/VehicleListPage')) const VehicleDetail = lazy(() => import('./features/vehicles/pages/VehicleDetailPage')) const VehicleAccess = lazy(() => import('./features/vehicles/pages/VehicleAccessPage')) const DigitalTwin = lazy(() => import('./features/vehicles/pages/DigitalTwinPage')) +const TimeMachine = lazy(() => import('./features/vehicles/pages/TimeMachinePage')) // Charging const Charging = lazy(() => import('./features/charging/pages/ChargingListPage')) @@ -51,6 +52,7 @@ const EnergyProducts = lazy(() => import('./features/battery/pages/EnergyProduct const VampireDrain = lazy(() => import('./features/battery/pages/VampireDrainPage')) const ProjectedRange = lazy(() => import('./features/battery/pages/ProjectedRangePage')) const SleepEfficiency = lazy(() => import('./features/battery/pages/SleepEfficiencyPage')) +const BatteryPassport = lazy(() => import('./features/battery/pages/BatteryPassportPage')) // Driving & Performance const Drives = lazy(() => import('./features/driving/pages/DrivesListPage')) @@ -64,6 +66,9 @@ const SpeedProfile = lazy(() => import('./features/driving/pages/SpeedProfilePag const RegenEfficiency = lazy(() => import('./features/driving/pages/RegenEfficiencyPage')) const RouteEfficiency = lazy(() => import('./features/driving/pages/RouteEfficiencyPage')) const TripPlanner = lazy(() => import('./features/driving/pages/TripPlannerPage')) +const DriveDNA = lazy(() => import('./features/driving/pages/DriveDNAPage')) +const WhatIf = lazy(() => import('./features/driving/pages/WhatIfPage')) +const Segments = lazy(() => import('./features/driving/pages/SegmentsPage')) // Analytics & Statistics const Analytics = lazy(() => import('./features/analytics/pages/AnalyticsPage')) @@ -71,6 +76,7 @@ const Statistics = lazy(() => import('./features/analytics/pages/StatisticsPage' const PeriodCompare = lazy(() => import('./features/analytics/pages/PeriodComparePage')) const Mileage = lazy(() => import('./features/analytics/pages/MileagePage')) const TrueCostOwnership = lazy(() => import('./features/analytics/pages/TrueCostPage')) +const CarbonIntelligence = lazy(() => import('./features/analytics/pages/CarbonIntelligencePage')) const WeeklyDigest = lazy(() => import('./features/analytics/pages/WeeklyDigestPage')) const Timeline = lazy(() => import('./features/analytics/pages/TimelinePage')) const FleetCompare = lazy(() => import('./features/analytics/pages/FleetComparePage')) @@ -124,6 +130,7 @@ const MQTTInspector = lazy(() => import('./features/telemetry/pages/MQTTInspecto // Diagnostics const AnomalyDashboard = lazy(() => import('./features/diagnostics/pages/AnomalyDashboardPage')) +const RemainingUsefulLife = lazy(() => import('./features/diagnostics/pages/RemainingUsefulLifePage')) // Admin & DevTools const NotificationsAudit = lazy(() => import('./features/notifications/pages/AuditLogPage')) @@ -380,6 +387,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> @@ -514,6 +522,7 @@ export default function App() { the opt-in AILearnedAnomalyBaselines section when AI mode is on and the toggle is enabled). */} } /> + } /> } /> } /> {/* Phase-50 / 0031 alias: the slice prompt registered the AI feature @@ -562,7 +571,11 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> } /> + } /> } /> {/* Phase-50 / 0050 alias: the slice prompt registered the AI feature against frontend route `/analytics/tco`; the canonical app path @@ -571,6 +584,8 @@ export default function App() { RouteSet.Frontend entry land users on the deterministic baseline without surprising 404s. */} } /> + {/* Carbon Intelligence — grid-aware CO₂ accounting; sibling of /analytics/tco (money) */} + } /> } /> } /> } /> diff --git a/web/src/api/hooks/useBatteryPassport.ts b/web/src/api/hooks/useBatteryPassport.ts new file mode 100644 index 0000000000..2c68c242ca --- /dev/null +++ b/web/src/api/hooks/useBatteryPassport.ts @@ -0,0 +1,96 @@ +import { useQuery } from '@tanstack/react-query'; +import { request } from '../client'; +import { STALE_TIMES } from '@/lib/constants'; + +/** + * Battery Passport — a verifiable, tamper-evident State-of-Health provenance + * certificate for a vehicle's battery (aligned with the EU Battery Passport + * regulation). These hooks read the two backend routes registered in + * internal/api/router.go (mounted under the versioned API group): + * + * GET /vehicles/{vehicleID}/battery-passport + * GET /vehicles/{vehicleID}/battery-passport/verify?hash= + * + * `request()` prepends the version prefix automatically, so the paths below + * must NOT include it. All field names are snake_case to mirror the Go JSON + * tags. + */ + +/** Share of drives whose ambient temperature fell in each band (0..100). */ +export interface BatteryPassportThermalExposure { + cold_pct: number; + nominal_pct: number; + hot_pct: number; +} + +/** One day of the State-of-Health degradation trend. */ +export interface BatteryPassportTrendPoint { + /** YYYY-MM-DD calendar day (UTC). */ + date: string; + /** Estimated pack SoH for that day (0..100). */ + soh_pct: number; +} + +/** Complete Battery Passport certificate payload. */ +export interface BatteryPassport { + vehicle_id: number; + vin_masked: string; + /** RFC 3339 issue instant. */ + issued_at: string; + /** + * RFC 3339 first-observed instant, or `null` for a vehicle with no drives + * and no charging sessions yet — render the header null-safely. + */ + first_observed_at: string | null; + soh_pct: number; + capacity_kwh: number; + original_capacity_kwh: number; + equivalent_full_cycles: number; + /** Fraction (0..1) of charging sessions that were DC fast-charges. */ + fast_charge_ratio: number; + avg_charge_limit_pct: number; + thermal_exposure: BatteryPassportThermalExposure; + /** Overall health grade A..F, or "N/A" when SoH cannot be estimated. */ + health_grade: string; + degradation_trend: BatteryPassportTrendPoint[]; + recommendations: string[]; + /** Lowercase hex SHA-256 binding the certificate's immutable core facts. */ + provenance_hash: string; +} + +/** Tamper-evidence result: `valid` is true when the supplied hash matches. */ +export interface BatteryPassportVerifyResponse { + valid: boolean; + expected_hash: string; + provided_hash: string; +} + +/** Fetch the current Battery Passport certificate for a vehicle. */ +export function useBatteryPassport(vehicleId: string | null) { + return useQuery({ + queryKey: ['battery-passport', vehicleId], + queryFn: ({ signal }) => + request(`/vehicles/${vehicleId}/battery-passport`, { signal }), + enabled: vehicleId !== null, + staleTime: STALE_TIMES.ANALYTICS, + }); +} + +/** + * Recompute the current passport hash server-side and compare it to `hash` + * (tamper-evidence). Disabled until both the vehicle and a non-empty hash are + * available, so the certificate's "Verified ✓" badge only fires once the + * passport has loaded and yielded its `provenance_hash`. + */ +export function useVerifyPassport(vehicleId: string | null, hash: string | null) { + return useQuery({ + queryKey: ['battery-passport-verify', vehicleId, hash], + queryFn: ({ signal }) => + request( + `/vehicles/${vehicleId}/battery-passport/verify?hash=${encodeURIComponent(hash ?? '')}`, + { signal }, + ), + enabled: vehicleId !== null && hash !== null && hash !== '', + staleTime: STALE_TIMES.ANALYTICS, + }); +} diff --git a/web/src/api/hooks/useCarbon.ts b/web/src/api/hooks/useCarbon.ts new file mode 100644 index 0000000000..3dff01b4e2 --- /dev/null +++ b/web/src/api/hooks/useCarbon.ts @@ -0,0 +1,126 @@ +import { useQuery } from '@tanstack/react-query'; +import { request } from '../client'; +import { STALE_TIMES } from '@/lib/constants'; + +/** + * Carbon Intelligence — grid-aware CO2 accounting for charging. These hooks + * read the three backend routes registered in internal/api/router.go (under the + * versioned API group): + * + * GET /carbon/intensity (shared grid model) + * GET /vehicles/{vehicleID}/carbon/summary?from=&to= (per-vehicle CO2) + * GET /vehicles/{vehicleID}/carbon/recommendation (greenest window) + * + * `request()` prepends `/api/v1` automatically, so the paths below MUST NOT + * include it. Query params are snake_case (`from`, `to`) to match the Go + * handler, and every field name mirrors the Go JSON tags. + */ + +/** One hour of the diurnal grid carbon-intensity curve. */ +export interface CarbonHourIntensity { + /** Local clock hour 0..23. */ + hour_of_day: number; + /** Grams of CO2 attributed per kWh drawn during this hour. */ + g_co2_per_kwh: number; +} + +/** The full 24-hour grid model plus its derived extremes. */ +export interface CarbonIntensityCurve { + curve: CarbonHourIntensity[]; + /** Cleanest hourly intensity across the day (gCO2/kWh). */ + min: number; + /** Dirtiest hourly intensity across the day (gCO2/kWh). */ + max: number; + /** Hours (0..23) achieving the minimum intensity. Always present, maybe empty. */ + greenest_hours: number[]; + /** Hours (0..23) achieving the maximum intensity. Always present, maybe empty. */ + dirtiest_hours: number[]; +} + +/** One YYYY-MM row of the CO2 trend. */ +export interface CarbonMonthly { + /** YYYY-MM calendar month. */ + month: string; + co2_kg: number; + energy_kwh: number; +} + +/** Per-vehicle carbon summary over the optional [from,to] window. */ +export interface CarbonSummary { + total_energy_kwh: number; + total_co2_kg: number; + /** Distance-based ICE baseline (0.192 kg CO2/km). */ + gas_equiv_co2_kg: number; + /** gas_equiv_co2_kg - total_co2_kg (can be negative on a very dirty grid). */ + co2_saved_kg: number; + /** 0..100 timing score — 100 = always charged at the greenest hour. */ + green_score: number; + sessions_scored: number; + monthly: CarbonMonthly[]; +} + +/** The recommended contiguous charging window (end hour is exclusive). */ +export interface CarbonGreenestWindow { + start_hour: number; + end_hour: number; + avg_intensity: number; +} + +/** How the driver's realized charging compares to the greenest window. */ +export interface CarbonRecommendation { + current_avg_intensity: number; + greenest_window: CarbonGreenestWindow; + potential_co2_saving_kg: number; + potential_saving_pct: number; +} + +/** + * The shared, vehicle-independent diurnal grid model. It is a seeded, + * admin-editable table, so it changes rarely — cache it as effectively static. + */ +export function useCarbonIntensity() { + return useQuery({ + queryKey: ['carbon-intensity'], + queryFn: ({ signal }) => request('/carbon/intensity', { signal }), + staleTime: STALE_TIMES.STATIC, + }); +} + +/** + * Per-vehicle CO2 summary. Optional `from`/`to` (YYYY-MM-DD or RFC 3339) scope + * the window; omit both for full history. Disabled until a vehicle is known so + * an empty selection never fires a request. + */ +export function useCarbonSummary(vehicleId: number | null, from?: string, to?: string) { + return useQuery({ + queryKey: ['carbon-summary', vehicleId, from ?? null, to ?? null], + queryFn: ({ signal }) => { + const params = new URLSearchParams(); + if (from) params.set('from', from); + if (to) params.set('to', to); + const qs = params.toString(); + return request( + qs + ? `/vehicles/${vehicleId}/carbon/summary?${qs}` + : `/vehicles/${vehicleId}/carbon/summary`, + { signal }, + ); + }, + enabled: vehicleId !== null && vehicleId > 0, + staleTime: STALE_TIMES.ANALYTICS, + }); +} + +/** + * The greenest charging window for a vehicle and the CO2 shifting into it would + * save. Disabled until a vehicle is known. + */ +export function useCarbonRecommendation(vehicleId: number | null) { + return useQuery({ + queryKey: ['carbon-recommendation', vehicleId], + queryFn: ({ signal }) => + request(`/vehicles/${vehicleId}/carbon/recommendation`, { signal }), + enabled: vehicleId !== null && vehicleId > 0, + staleTime: STALE_TIMES.ANALYTICS, + }); +} diff --git a/web/src/api/hooks/useRUL.ts b/web/src/api/hooks/useRUL.ts new file mode 100644 index 0000000000..88a1227113 --- /dev/null +++ b/web/src/api/hooks/useRUL.ts @@ -0,0 +1,112 @@ +import { useQuery } from '@tanstack/react-query'; +import { request } from '../client'; +import { STALE_TIMES } from '@/lib/constants'; + +/** + * Remaining Useful Life (RUL) — predictive component prognostics. These hooks + * read the two backend routes registered in internal/api/router.go (under the + * versioned API group, in the `/vehicles/{vehicleID}` sub-router): + * + * GET /vehicles/{vehicleID}/rul (whole component health board) + * GET /vehicles/{vehicleID}/rul/{component} (one component + forecast series) + * + * `request()` prepends `/api/v1` automatically, so the paths below MUST NOT + * include it. Every field name is snake_case to mirror the Go JSON tags, and + * nullable Go pointers surface as `T | null`. + */ + +/** Health classification of a component. Matches the Go `status` enum. */ +export type RULStatus = 'healthy' | 'watch' | 'replace_soon' | 'overdue'; + +/** Stable machine keys for the tracked components (Go componentSpecs order). */ +export type RULComponent = 'hv_battery' | 'lv_battery' | 'tires' | 'brakes' | 'cabin_filter'; + +/** One component's prognosis, returned by both endpoints. */ +export interface ComponentRUL { + /** Stable machine key (e.g. `hv_battery`). */ + component: string; + /** Human label (e.g. `High-Voltage Battery`). */ + label: string; + /** Display health: % State of Health (battery) or % of nominal life left. */ + health_pct: number; + /** Decline in health_pct per day (SoH %/day or life %/day). */ + wear_rate_per_day: number; + /** Projected days until the end-of-life threshold. 0 when overdue. */ + remaining_days: number; + /** Projected km until EOL for distance-wear parts; null otherwise. */ + remaining_km: number | null; + /** YYYY-MM-DD "replace-by" date; null when the rate is indeterminate. */ + projected_eol_date: string | null; + /** 0..1 trust in the estimate. */ + confidence: number; + /** healthy | watch | replace_soon | overdue. */ + status: RULStatus; + /** Human-readable sentence explaining how the estimate was derived. */ + basis: string; +} + +/** The single most-urgent upcoming replacement. */ +export interface NextService { + component: string; + /** YYYY-MM-DD; null when nothing is projectable. */ + date: string | null; +} + +/** Body of GET /vehicles/{vehicleID}/rul. */ +export interface RULResponse { + vehicle_id: number; + /** Always present (possibly empty), in the deliberate componentSpecs order. */ + components: ComponentRUL[]; + /** Null when no component has a projectable EOL date. */ + next_service: NextService | null; +} + +/** One sample of a forecast curve, with a confidence band. */ +export interface ProjectionPoint { + /** YYYY-MM-DD. */ + date: string; + projected_health: number; + confidence_low: number; + confidence_high: number; +} + +/** Body of GET /vehicles/{vehicleID}/rul/{component}. */ +export interface ComponentDetailResponse extends ComponentRUL { + /** EOL threshold in the component's own health unit; null when unset. */ + eol_threshold: number | null; + /** Configured nominal distance life (km); null for calendar-wear parts. */ + nominal_life_km: number | null; + /** Configured nominal calendar life (days); null for distance-wear parts. */ + nominal_life_days: number | null; + /** Free-text rationale echoed from the config table. */ + notes: string; + /** Forecast from today to the projected EOL for the chart. */ + projection: ProjectionPoint[]; +} + +/** + * The whole component health board for a vehicle. Disabled until a vehicle is + * known so an empty selection never fires a request. + */ +export function useRUL(vehicleId: number | null) { + return useQuery({ + queryKey: ['rul', vehicleId], + queryFn: ({ signal }) => request(`/vehicles/${vehicleId}/rul`, { signal }), + enabled: vehicleId !== null && vehicleId > 0, + staleTime: STALE_TIMES.ANALYTICS, + }); +} + +/** + * One component's detailed prognosis plus its forecast series. Disabled until + * both a vehicle and a component are selected. + */ +export function useComponentRUL(vehicleId: number | null, component: string | null) { + return useQuery({ + queryKey: ['rul-component', vehicleId, component], + queryFn: ({ signal }) => + request(`/vehicles/${vehicleId}/rul/${component}`, { signal }), + enabled: vehicleId !== null && vehicleId > 0 && !!component, + staleTime: STALE_TIMES.ANALYTICS, + }); +} diff --git a/web/src/api/hooks/useSegments.ts b/web/src/api/hooks/useSegments.ts new file mode 100644 index 0000000000..ab23eb49d0 --- /dev/null +++ b/web/src/api/hooks/useSegments.ts @@ -0,0 +1,184 @@ +import { useQuery } from '@tanstack/react-query'; +import { request } from '../client'; +import { STALE_TIMES } from '@/lib/constants'; + +/** + * Ghost Racing / EV Segments — Strava-style route segments raced against your + * own personal best. These hooks read the three backend routes registered in + * internal/api/router.go (under the versioned API group): + * + * GET /vehicles/{vehicleID}/segments (detected segments) + * GET /segments/{segmentID}/leaderboard (ranked attempts) + * GET /segments/{segmentID}/ghost?a=&b= (head-to-head race) + * + * `request()` prepends `/api/v1` automatically, so the paths below MUST NOT + * include it. Query params are snake_case (`a`, `b`) to match the Go handler, + * and every field name mirrors the Go JSON tags (snake_case) exactly. Go + * pointer fields map to `T | null` here. + */ + +/** A personal-best-by-time (or the latest) attempt reference. */ +export interface SegmentBest { + drive_id: number; + /** Recorded drive duration, SI seconds. */ + duration_s: number; + /** RFC 3339 UTC timestamp of when the drive started. */ + started_at: string; +} + +/** A personal-best-by-efficiency attempt reference. */ +export interface SegmentBestEff { + drive_id: number; + /** Energy efficiency, watt-hours per kilometre. */ + wh_per_km: number; + started_at: string; +} + +/** + * One detected segment in the list response. `best_time` and `latest` are + * present whenever the segment has attempts; `best_efficiency` is null when no + * attempt has a measured energy reading. `id` is 0 when the best-effort persist + * failed (the segment is still returned but cannot be drilled into). + */ +export interface SegmentSummary { + id: number; + name: string; + start_address: string; + end_address: string; + /** Representative (median) segment distance, SI metres. */ + distance_m: number; + attempt_count: number; + best_time: SegmentBest | null; + best_efficiency: SegmentBestEff | null; + latest: SegmentBest | null; +} + +/** Body of GET /vehicles/{vehicleID}/segments. `segments` is always present. */ +export interface SegmentsResponse { + segments: SegmentSummary[]; +} + +/** The segment header echoed by the leaderboard and ghost responses. */ +export interface SegmentInfo { + id: number; + name: string; + start_address: string; + end_address: string; + distance_m: number; + attempt_count: number; +} + +/** + * One ranked attempt. `wh_per_km` is null when the attempt has no energy + * reading. `delta_to_best_s` is the time gap to the fastest run (the by-time + * PR) in BOTH orderings. `is_pr` flags the rank-1 row of its own ordering. + */ +export interface LeaderboardRow { + rank: number; + drive_id: number; + started_at: string; + duration_s: number; + distance_m: number; + wh_per_km: number | null; + delta_to_best_s: number; + is_pr: boolean; +} + +/** Body of GET /segments/{segmentID}/leaderboard. */ +export interface LeaderboardResponse { + segment: SegmentInfo; + by_time: LeaderboardRow[]; + by_efficiency: LeaderboardRow[]; +} + +/** One point of a drive's normalized progress series. */ +export interface GhostSeriesPoint { + /** How far along the route, 0..1 by integrated distance. */ + fraction_of_distance: number; + /** Seconds since the drive started. */ + elapsed_s: number; + /** Instantaneous speed at this point, SI metres per second. */ + speed_mps: number; +} + +/** One racer in the ghost response. */ +export interface GhostDrive { + drive_id: number; + duration_s: number; + series: GhostSeriesPoint[]; +} + +/** The A-vs-B time gap at a shared distance fraction (delta_s < 0 -> A ahead). */ +export interface GhostSplitDelta { + fraction: number; + delta_s: number; +} + +/** + * Body of GET /segments/{segmentID}/ghost?a=&b=. `winner_drive_id` is the + * faster drive (null on a tie) and `margin_s` the gap in seconds between the two + * recorded durations. + */ +export interface GhostResponse { + segment: SegmentInfo; + a: GhostDrive; + b: GhostDrive; + split_deltas: GhostSplitDelta[]; + winner_drive_id: number | null; + margin_s: number; +} + +/** + * Detected segments for a vehicle, with each segment's personal-best-by-time, + * best-by-efficiency, and latest attempt. Disabled until a vehicle is known so + * an empty selection never fires a request. + */ +export function useSegments(vehicleId: number | null) { + return useQuery({ + queryKey: ['segments', vehicleId], + queryFn: ({ signal }) => + request(`/vehicles/${vehicleId}/segments`, { signal }), + enabled: vehicleId !== null && vehicleId > 0, + staleTime: STALE_TIMES.ANALYTICS, + }); +} + +/** + * The ranked attempts on a segment, ordered both by time and by energy + * efficiency. Disabled until a segment is selected. + */ +export function useSegmentLeaderboard(segmentId: number | null) { + return useQuery({ + queryKey: ['segment-leaderboard', segmentId], + queryFn: ({ signal }) => + request(`/segments/${segmentId}/leaderboard`, { signal }), + enabled: segmentId !== null && segmentId > 0, + staleTime: STALE_TIMES.ANALYTICS, + }); +} + +/** + * Two attempts on a segment aligned onto a shared distance-fraction axis for a + * head-to-head ghost race, with the per-fraction time split between them. + * Disabled until a segment and both positive drive IDs are known. + */ +export function useSegmentGhost( + segmentId: number | null, + a: number | null, + b: number | null, +) { + return useQuery({ + queryKey: ['segment-ghost', segmentId, a, b], + queryFn: ({ signal }) => + request(`/segments/${segmentId}/ghost?a=${a}&b=${b}`, { signal }), + enabled: + segmentId !== null && + segmentId > 0 && + a !== null && + a > 0 && + b !== null && + b > 0 && + a !== b, + staleTime: STALE_TIMES.ANALYTICS, + }); +} diff --git a/web/src/api/hooks/useTimeMachine.ts b/web/src/api/hooks/useTimeMachine.ts new file mode 100644 index 0000000000..2a715818be --- /dev/null +++ b/web/src/api/hooks/useTimeMachine.ts @@ -0,0 +1,88 @@ +import { useQuery } from '@tanstack/react-query'; +import { request } from '../client'; +import { STALE_TIMES } from '@/lib/constants'; + +/** + * Vehicle Time Machine hooks. + * + * Reconstruct the COMPLETE signal state of a vehicle at any past instant + * from the `signal_log` cold-path hypertable. Backed by: + * GET /vehicles/{id}/time-machine?at= (point-in-time state) + * GET /vehicles/{id}/time-machine/range (scrubber bounds) + * + * `request()` prepends `/api/v1` — paths here MUST NOT include it. Query + * params are snake_case (`at`) to match the Go handler. + */ + +/** protomodel.ValueKind collapsed to a stable, frontend-facing label. */ +export type TimeMachineValueKind = + | 'string' + | 'bool' + | 'int' + | 'enum' + | 'float' + | 'time' + | 'unknown'; + +/** A JSON-representable signal value. `time` kinds arrive as RFC 3339 strings. */ +export type TimeMachineValue = string | number | boolean | null; + +/** One reconstructed field at the requested instant (snake_case ↔ Go tags). */ +export interface TimeMachineField { + field: string; + value: TimeMachineValue; + value_kind: TimeMachineValueKind; + /** RFC 3339 timestamp of the last change at-or-before the instant. */ + ts: string; + /** Seconds between the field's last change and the reconstruction instant. */ + age_seconds: number; +} + +/** Point-in-time reconstruction envelope. */ +export interface TimeMachineState { + /** Echoed RFC 3339 instant the state was reconstructed at. */ + at: string; + fields: TimeMachineField[]; + count: number; +} + +/** Scrubber bounds. earliest/latest are null when the vehicle has no history. */ +export interface TimeMachineRange { + earliest: string | null; + latest: string | null; + field_count: number; +} + +/** + * Bounds for the timeline scrubber (oldest + newest observation, distinct + * field count). Near-static per vehicle, so it caches until invalidated. + */ +export function useTimeMachineRange(vehicleId: number | null) { + return useQuery({ + queryKey: ['time-machine-range', vehicleId], + queryFn: ({ signal }) => + request(`/vehicles/${vehicleId}/time-machine/range`, { signal }), + enabled: vehicleId !== null && vehicleId > 0, + staleTime: STALE_TIMES.SLOW, + }); +} + +/** + * Full reconstructed field state at `atISO` (RFC 3339). Disabled until both + * a vehicle and an instant are known, so scrubbing to a fresh position is a + * cache miss that fetches, while returning to a visited position is instant. + */ +export function useTimeMachineState(vehicleId: number | null, atISO: string | null) { + return useQuery({ + queryKey: ['time-machine-state', vehicleId, atISO], + queryFn: ({ signal }) => + request( + `/vehicles/${vehicleId}/time-machine?at=${encodeURIComponent(atISO ?? '')}`, + { signal }, + ), + enabled: vehicleId !== null && vehicleId > 0 && !!atISO, + // Historical reconstructions are immutable — the past does not change — + // so a visited instant never needs a background refetch. + staleTime: STALE_TIMES.STATIC, + }); +} diff --git a/web/src/components/layout/Layout.tsx b/web/src/components/layout/Layout.tsx index 40efa7d713..96074c326e 100644 --- a/web/src/components/layout/Layout.tsx +++ b/web/src/components/layout/Layout.tsx @@ -259,6 +259,7 @@ export const navSections = [ { to: '/digital-twin', icon: Icons.monitor, label: 'Vehicle Live View', color: 'text-cyan-400' }, { to: '/vehicle-comparison', icon: Icons.arrowLeftRight, label: 'Compare Vehicles', color: 'text-orange-400', minVehicles: 2 }, { to: '/locations', icon: Icons.location, label: 'Saved Locations', color: 'text-emerald-400' }, + { to: '/time-machine', icon: Icons.history, label: 'Time Machine', color: 'text-indigo-400' }, ], }, { @@ -276,6 +277,9 @@ export const navSections = [ { to: '/driving-dynamics', icon: Icons.efficiency, label: 'Driving Dynamics', color: 'text-red-400' }, { to: '/regen-efficiency', icon: Icons.recycle, label: 'Regen Braking', color: 'text-green-400' }, { to: '/route-efficiency', icon: Icons.navigationAlt, label: 'Route Efficiency', color: 'text-emerald-400' }, + { to: '/drive-dna', icon: Icons.palette, label: 'Drive DNA', color: 'text-fuchsia-400' }, + { to: '/what-if', icon: Icons.preferences, label: 'What-If Simulator', color: 'text-cyan-400' }, + { to: '/segments', icon: Icons.flag, label: 'Ghost Racing', color: 'text-cyan-400' }, ], }, { @@ -298,6 +302,7 @@ export const navSections = [ { to: '/projected-range', icon: Icons.target, label: 'Projected Range', color: 'text-pink-400' }, { to: '/vampire-drain', icon: Icons.moon, label: 'Vampire Drain', color: 'text-indigo-400' }, { to: '/sleep-efficiency', icon: Icons.bedDouble, label: 'Sleep Efficiency', color: 'text-purple-400' }, + { to: '/battery-passport', icon: Icons.securityCheck, label: 'Battery Passport', color: 'text-emerald-400' }, ], }, { @@ -335,6 +340,7 @@ export const navSections = [ { to: '/temperature-impact', icon: Icons.climateHot, label: 'Temperature Impact', color: 'text-blue-400' }, { to: '/cost-analysis', icon: Icons.dollarSign, label: 'Cost Analysis', color: 'text-emerald-400' }, { to: '/tco', icon: Icons.wallet, label: 'Cost of Ownership', color: 'text-green-400' }, + { to: '/analytics/carbon', icon: Icons.leaf, label: 'Carbon Intelligence', color: 'text-green-400' }, ], }, { @@ -415,6 +421,7 @@ export const navSections = [ { to: '/system-status', icon: Icons.efficiency, label: 'System Status', color: 'text-emerald-400' }, { to: '/db-health', icon: Icons.hardDrive, label: 'Database Health', color: 'text-emerald-400' }, { to: '/anomaly-detection', icon: Icons.scanSearch, label: 'Anomaly Detection', color: 'text-red-400' }, + { to: '/diagnostics/rul', icon: Icons.timer, label: 'Remaining Useful Life', color: 'text-amber-400' }, { to: '/signals', icon: Icons.activity, label: 'Live Signals', color: 'text-neon-cyan', dataTour: 'live-signals-section' }, { to: '/admin/live-signals', icon: Icons.radioTower, label: 'Live Signal Inspector', color: 'text-cyan-400' }, { to: '/admin/ingest-xray', icon: Icons.scanSearch, label: 'Ingest X-Ray', color: 'text-sky-400' }, diff --git a/web/src/features/analytics/pages/CarbonIntelligencePage.tsx b/web/src/features/analytics/pages/CarbonIntelligencePage.tsx new file mode 100644 index 0000000000..ca776f27b5 --- /dev/null +++ b/web/src/features/analytics/pages/CarbonIntelligencePage.tsx @@ -0,0 +1,471 @@ +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Leaf, Zap, Fuel, Gauge, CalendarRange, Clock, TrendingDown, Sparkles, Sun, Factory, +} from 'lucide-react'; +import { PageContainer } from '@/components/layout'; +import { GlassPanel, PanelTitle, Text } from '@/components/ui'; +import { MetricCard, DataFreshnessAuto } from '@/components/data-display'; +import { EmptyState, QueryError, Skeleton } from '@/components/feedback'; +import { FadeIn } from '@/components/motion'; +import { VehicleSelect, RangePicker } from '@/components/forms'; +import { + AreaChart, Area, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, + ReferenceLine, RadialGauge, ChartContainer, ChartTooltip, ChartGradient, + chartGrid, axisTick, +} from '@/components/charts'; +import { usePageTitle } from '@/hooks/usePageTitle'; +import { useSelectedVehicle } from '@/hooks/useSelectedVehicle'; +import { useRangeState } from '@/hooks/useRangeState'; +import { + useCarbonIntensity, useCarbonSummary, useCarbonRecommendation, +} from '@/api/hooks/useCarbon'; +import { fmtNumber, fmtInt } from '@/lib/numberFormat'; + +/* Chart series colours — dynamic hex handed to Recharts. Emerald/teal palette: + teal for the neutral grid curve, emerald for "green" (cleanest / savings), + rose for the dirty peak, amber for the range-scoped "this period" metric. */ +const COLOR_TEAL = '#2dd4bf'; +const COLOR_GREEN = '#10b981'; +const COLOR_DIRTY = '#f43f5e'; +const COLOR_AMBER = '#f59e0b'; + +/* ── Formatting helpers (null-safe; the API models every figure as a plain + number, but a disabled/in-flight query yields `undefined`). ── */ +const pad2 = (n: number) => String(n).padStart(2, '0'); +const hourLabel = (h: number) => `${pad2(h)}:00`; + +function kg(v: number | null | undefined): string { + return v == null || !Number.isFinite(v) ? '—' : `${fmtNumber(v, 1)} kg`; +} +function gPerKwh(v: number | null | undefined): string { + return v == null || !Number.isFinite(v) ? '—' : `${fmtInt(v)} g/kWh`; +} + +/** Gauge colour ramp: emerald → teal → amber → rose as timing worsens. */ +function scoreColor(score: number): string { + if (score >= 75) return COLOR_GREEN; + if (score >= 50) return COLOR_TEAL; + if (score >= 25) return COLOR_AMBER; + return COLOR_DIRTY; +} + +/* ── Component ── */ + +export default function CarbonIntelligencePage() { + const { t } = useTranslation(); + usePageTitle(t('carbon.title', 'Carbon Intelligence')); + + const { vehicleId } = useSelectedVehicle(); + const { start, end, setRange } = useRangeState({ + persistKey: 'carbon.range', + defaultPresetId: '90d', + }); + + // The shared 24-hour grid model (vehicle-independent). + const intensityQuery = useCarbonIntensity(); + const { + data: intensity, isLoading: intensityLoading, isError: intensityIsError, + error: intensityErr, refetch: refetchIntensity, + } = intensityQuery; + + // Lifetime footprint (no window) drives the score, hero totals and trend. + const lifetimeQuery = useCarbonSummary(vehicleId); + const { + data: lifetime, isLoading: lifetimeLoading, isError: lifetimeIsError, + error: lifetimeErr, refetch: refetchLifetime, + } = lifetimeQuery; + + // Range-scoped footprint powers only the "this period" hero card. + const { data: period } = useCarbonSummary(vehicleId, start, end); + + // Greenest-window recommendation. + const recQuery = useCarbonRecommendation(vehicleId); + const { + data: rec, isLoading: recLoading, isError: recIsError, + error: recErr, refetch: refetchRec, + } = recQuery; + + const onRetryIntensity = useCallback(() => { void refetchIntensity(); }, [refetchIntensity]); + const onRetryLifetime = useCallback(() => { void refetchLifetime(); }, [refetchLifetime]); + const onRetryRec = useCallback(() => { void refetchRec(); }, [refetchRec]); + + // Sorted { hour, intensity } points for the area curve (numeric x-axis so the + // greenest/dirtiest ReferenceLines land exactly on their hour). + const intensityData = useMemo( + () => [...(intensity?.curve ?? [])] + .sort((a, b) => a.hour_of_day - b.hour_of_day) + .map((h) => ({ hour: h.hour_of_day, intensity: h.g_co2_per_kwh })), + [intensity], + ); + const greenestHours = intensity?.greenest_hours ?? []; + const dirtiestHours = intensity?.dirtiest_hours ?? []; + + const monthly = lifetime?.monthly ?? []; + const noVehicle = vehicleId == null; + const emptyMsg = noVehicle + ? t('carbon.selectVehicle', 'Select a vehicle to see its carbon footprint.') + : t('carbon.noData', 'No charging data yet — charge a session to see your CO₂ footprint.'); + + const score = lifetime?.green_score ?? 0; + const windowLabel = rec + ? `${hourLabel(rec.greenest_window.start_hour)} – ${hourLabel(rec.greenest_window.end_hour)}` + : '—'; + const hasSaving = (rec?.potential_saving_pct ?? 0) > 0.1; + + return ( + + + + {/* Cagg-driven; force amber after 6h to surface stale aggregates. */} + + + } + > + {/* 1 — KPI band: lifetime saved / total CO₂ / this-period CO₂ / gas-equiv */} + +
+ {lifetimeIsError ? ( + + + + ) : lifetimeLoading ? ( + Array.from({ length: 4 }).map((_, i) => ( + + )) + ) : lifetime ? ( + <> + } + color="green" + subtitle={t('carbon.savedSubtitle', 'Lifetime, vs an equivalent gas car')} + /> + } + color="cyan" + subtitle={t('carbon.totalCo2Subtitle', '{{kwh}} kWh over {{n}} sessions', { + kwh: fmtNumber(lifetime.total_energy_kwh ?? 0, 0), + n: fmtInt(lifetime.sessions_scored ?? 0), + })} + /> + } + color="amber" + subtitle={`${start} → ${end}`} + /> + } + color="red" + subtitle={t('carbon.gasEquivSubtitle', 'ICE baseline @ 0.192 kg CO₂/km')} + /> + + ) : ( + + } + message={emptyMsg} + /> + + )} +
+
+ + {/* 2 — Hero bento: green-timing score gauge (1) + 24h grid curve (2) */} + +
+ + + + {lifetimeIsError ? ( + + ) : lifetimeLoading ? ( + + ) : lifetime && (lifetime.sessions_scored ?? 0) > 0 ? ( +
+ +
+ + {score >= 75 + ? t('carbon.score.excellent', 'Excellent timing') + : score >= 50 + ? t('carbon.score.good', 'Good timing') + : score >= 25 + ? t('carbon.score.fair', 'Room to improve') + : t('carbon.score.poor', 'Mostly peak-hour charging')} + + + {t('carbon.scoreHelp', '100 = you always charge at the greenest hour; 0 = the dirtiest.')} + +
+
+ ) : ( + } + message={emptyMsg} + /> + )} +
+ + {/* chart-a11y:no-table diurnal grid model; greenest/dirtiest hours are + restated as chips + the recommendation card below */} + + {intensityIsError ? ( + + ) : intensityLoading ? ( + + ) : intensityData.length > 0 ? ( + + + + + + {chartGrid} + hourLabel(h)} + tick={axisTick} + tickLine={false} + axisLine={false} + /> + fmtInt(v)} + /> + hourLabel(Number(l))} + valueFormatter={(v) => gPerKwh(Number(v))} + /> + } /> + {greenestHours.map((h, i) => ( + + ))} + {dirtiestHours.map((h, i) => ( + + ))} + + + + ) : ( + + )} + +
+
+ + {/* 3 — Secondary bento: monthly CO₂ trend (2) + greenest-window card (1) */} + +
+ {/* chart-a11y:no-table month-by-month rollup; totals are restated in the + hero KPI band above */} + + {lifetimeIsError ? ( + + ) : lifetimeLoading ? ( + + ) : monthly.length > 0 ? ( + + + {chartGrid} + + fmtInt(v)} + /> + kg(Number(v))} /> + } /> + + + + ) : ( + + )} + + + {/* Greenest charging window recommendation */} + + + + {recIsError ? ( + + ) : recLoading ? ( + + ) : rec && (rec.current_avg_intensity ?? 0) > 0 ? ( +
+
+ + + + {windowLabel} + + + {hasSaving + ? t('carbon.rec.cut', 'Cut ~{{pct}}% ({{kg}}) vs your current timing', { + pct: fmtNumber(rec.potential_saving_pct, 0), + kg: kg(rec.potential_co2_saving_kg), + }) + : t('carbon.rec.already', "You're already charging in the greenest window — nice!")} + +
+
+
+ + {gPerKwh(rec.current_avg_intensity)} + + {t('carbon.rec.current', 'Your avg intensity')} +
+
+ + {gPerKwh(rec.greenest_window.avg_intensity)} + + {t('carbon.rec.window', 'Window avg intensity')} +
+
+
+
+
+ ) : ( + } + message={emptyMsg} + /> + )} +
+
+
+ + {/* 4 — Legend footnote: what the markers on the curve mean */} + + +
+ + + + + + +
+
+
+
+ ); +} diff --git a/web/src/features/battery/pages/BatteryPassportPage.tsx b/web/src/features/battery/pages/BatteryPassportPage.tsx new file mode 100644 index 0000000000..2da59d8c83 --- /dev/null +++ b/web/src/features/battery/pages/BatteryPassportPage.tsx @@ -0,0 +1,619 @@ +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ShieldCheck, ShieldAlert, ShieldQuestion, BatteryCharging, Zap, Gauge, + Thermometer, Download, Fingerprint, Activity, Award, AlertTriangle, + CheckCircle2, Snowflake, Flame, Sun, +} from 'lucide-react'; + +import { PageContainer } from '@/components/layout'; +import { VehicleSelect } from '@/components/forms'; +import { GlassPanel, Badge, PanelTitle, Text, Caption, Button, CopyButton } from '@/components/ui'; +import { MetricCard, MetricBar } from '@/components/data-display'; +import { RadialGauge, AreaChartWrapper } from '@/components/charts'; +import { Skeleton, EmptyState, QueryError } from '@/components/feedback'; +import { FadeIn } from '@/components/motion'; + +import { + useBatteryPassport, + useVerifyPassport, + type BatteryPassport, +} from '@/api/hooks/useBatteryPassport'; +import { usePageTitle } from '@/hooks/usePageTitle'; +import { useSelectedVehicle } from '@/hooks/useSelectedVehicle'; +import { fmtNumber, fmtInt, fmtPercent } from '@/lib/numberFormat'; +import { formatDate, formatDateTime } from '@/lib/dateFormat'; +import { cn } from '@/lib/cn'; +import type { NeonColor } from '@/lib/tokens'; + +/* ── Types ─────────────────────────────────────────────── */ + +type BadgeVariant = 'info' | 'success' | 'warning' | 'danger' | 'neutral'; + +/* ── Grade / SoH visual helpers (pure) ─────────────────── */ + +/** Hex accent for the SoH gauge — green ≥90, amber ≥80, red below. */ +function sohHex(soh: number): string { + if (soh >= 90) return '#10b981'; + if (soh >= 80) return '#f59e0b'; + return '#ef4444'; +} + +/** Toned 300-level text class for the big grade glyph. */ +function gradeTextClass(grade: string): string { + switch (grade.toUpperCase()) { + case 'A': return 'text-emerald-300'; + case 'B': return 'text-cyan-300'; + case 'C': return 'text-blue-300'; + case 'D': return 'text-amber-300'; + case 'E': return 'text-orange-300'; + case 'F': return 'text-rose-300'; + default: return 'text-[var(--text-muted)]'; + } +} + +function gradeNeon(grade: string): NeonColor { + switch (grade.toUpperCase()) { + case 'A': return 'green'; + case 'B': return 'cyan'; + case 'C': return 'blue'; + case 'D': + case 'E': return 'amber'; + case 'F': return 'red'; + default: return 'cyan'; + } +} + +/* ── Certificate export (clean snake_case, no camel duplicates) ── */ + +/** + * The `request()` client mirrors every key into camelCase, so the live + * passport object carries both `soh_pct` and `sohPct`. Rebuild a clean, + * canonical snake_case artifact before download so the exported certificate + * matches the wire contract (and the fields the provenance hash was computed + * over) exactly — not a doubled convenience blob. + */ +function toCertificate(p: BatteryPassport): Record { + return { + vehicle_id: p.vehicle_id, + vin_masked: p.vin_masked, + issued_at: p.issued_at, + first_observed_at: p.first_observed_at, + soh_pct: p.soh_pct, + capacity_kwh: p.capacity_kwh, + original_capacity_kwh: p.original_capacity_kwh, + equivalent_full_cycles: p.equivalent_full_cycles, + fast_charge_ratio: p.fast_charge_ratio, + avg_charge_limit_pct: p.avg_charge_limit_pct, + thermal_exposure: { + cold_pct: p.thermal_exposure.cold_pct, + nominal_pct: p.thermal_exposure.nominal_pct, + hot_pct: p.thermal_exposure.hot_pct, + }, + health_grade: p.health_grade, + degradation_trend: (p.degradation_trend ?? []).map((d) => ({ + date: d.date, + soh_pct: d.soh_pct, + })), + recommendations: p.recommendations ?? [], + provenance_hash: p.provenance_hash, + }; +} + +function downloadCertificate(p: BatteryPassport): void { + const blob = new Blob([JSON.stringify(toCertificate(p), null, 2)], { + type: 'application/json', + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `battery-passport-${p.vehicle_id}-${p.provenance_hash.slice(0, 12)}.json`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} + +/* ── Page ──────────────────────────────────────────────── */ + +export default function BatteryPassportPage() { + const { t } = useTranslation(); + usePageTitle(t('batteryPassport.title', 'Battery Passport')); + + /* Vehicle selector: header picker is the source of truth. */ + const { vehicleId: activeId } = useSelectedVehicle(); + const activeIdStr = activeId != null ? String(activeId) : null; + + const passportQuery = useBatteryPassport(activeIdStr); + const passport = passportQuery.data ?? null; + + /* Tamper-evidence: recompute the current hash server-side and compare it to + the one the certificate just returned. Only fires once the passport has + loaded and yielded a provenance hash. */ + const verifyQuery = useVerifyPassport(activeIdStr, passport?.provenance_hash ?? null); + + const trendData = useMemo( + () => + (passport?.degradation_trend ?? []).map((d) => ({ + date: formatDate(d.date), + soh_pct: d.soh_pct, + })), + [passport], + ); + + const onExport = useCallback(() => { + if (passport) downloadCertificate(passport); + }, [passport]); + + const { isLoading, error } = passportQuery; + const noVehicle = activeIdStr === null; + const recommendations = passport?.recommendations ?? []; + const thermal = passport?.thermal_exposure ?? null; + + /* Verified badge state derived from the verify query. */ + const verifyState: { variant: BadgeVariant; label: string; Icon: typeof ShieldCheck } = + (() => { + if (verifyQuery.isLoading) { + return { + variant: 'neutral', + label: t('batteryPassport.verifying', 'Verifying…'), + Icon: ShieldQuestion, + }; + } + if (verifyQuery.error) { + return { + variant: 'warning', + label: t('batteryPassport.verifyUnavailable', 'Unverified'), + Icon: ShieldQuestion, + }; + } + if (verifyQuery.data?.valid) { + return { + variant: 'success', + label: t('batteryPassport.verified', 'Verified'), + Icon: ShieldCheck, + }; + } + if (verifyQuery.data && !verifyQuery.data.valid) { + return { + variant: 'danger', + label: t('batteryPassport.tampered', 'Tampered'), + Icon: ShieldAlert, + }; + } + return { + variant: 'neutral', + label: t('batteryPassport.unverified', 'Unverified'), + Icon: ShieldQuestion, + }; + })(); + + /* ── Render ──────────────────────────────────────────── */ + + return ( + + + + + } + > + {noVehicle ? ( + + } + title={t('batteryPassport.noVehicleTitle', 'No vehicle selected')} + message={t( + 'batteryPassport.noVehicle', + 'Choose a vehicle to issue its Battery Passport certificate.', + )} + /> + + ) : ( + <> + {/* ── 1 · Certificate masthead ───────────────────── */} + +
+ +
+ {error ? ( + passportQuery.refetch()} /> + ) : isLoading ? ( + + ) : passport ? ( +
+
+
+
+ + {passport.vin_masked || t('batteryPassport.unknownVin', 'VIN unavailable')} + +
+
+ {t('batteryPassport.issued', 'Issued')} + + {formatDateTime(passport.issued_at)} + +
+
+ + {t('batteryPassport.firstObserved', 'First observed')} + + + {passport.first_observed_at + ? formatDate(passport.first_observed_at) + : '—'} + +
+
+
+
+ + +
+ + + {t('batteryPassport.grade', 'Health grade')} + +
+
+
+ ) : ( + + )} + +
+
+ + {/* ── 2 · Hero: SoH gauge + key metrics ──────────── */} + +
+ {/* SoH gauge */} + + + +
+ {error ? ( + + ) : isLoading ? ( + + ) : passport && passport.soh_pct > 0 ? ( + <> + + + {t('batteryPassport.capacityLine', '{{cap}} kWh of {{orig}} kWh original', { + cap: fmtNumber(passport.capacity_kwh, 1), + orig: fmtNumber(passport.original_capacity_kwh, 0), + })} + + + ) : ( + + )} +
+
+ + {/* Key metric cards */} +
+ {error ? ( +
+ + + +
+ ) : isLoading ? ( + Array.from({ length: 4 }).map((_, i) => ) + ) : passport ? ( + <> + } + color="purple" + help={{ + i18nKey: 'batteryPassport.help.cycles', + defaultValue: + 'Total energy throughput expressed as full 0→100% charge-discharge cycles. A proxy for cycle-fade wear that is independent of how the pack was topped up.', + }} + /> + } + color="amber" + help={{ + i18nKey: 'batteryPassport.help.fastCharge', + defaultValue: + 'Share of charging sessions that were DC fast-charges. Frequent fast-charging accelerates degradation, so a lower ratio is healthier.', + }} + /> + } + color="cyan" + help={{ + i18nKey: 'batteryPassport.help.avgLimit', + defaultValue: + 'Average state of charge the pack was charged up to. Habitually charging to 100% stresses the cells; ~80% is gentler for daily use.', + }} + /> + } + color={gradeNeon(passport.health_grade)} + subtitle={t('batteryPassport.gradeScale', 'A (best) → F')} + /> + + ) : ( +
+ +
+ )} +
+
+
+ + {/* ── 3 · Degradation trend ──────────────────────── */} + +
+ + + + {error ? ( + + ) : isLoading ? ( + + ) : trendData.length > 0 ? ( + `${fmtNumber(v, 0)}%`} + series={[ + { + key: 'soh_pct', + label: t('batteryPassport.soh', 'State of Health'), + color: '#22d3ee', + }, + ]} + /> + ) : ( + } + message={t( + 'batteryPassport.noTrend', + 'No day-over-day SoH history is available for this vehicle yet.', + )} + /> + )} + +
+
+ + {/* ── 4 · Thermal exposure + Recommendations ─────── */} + +
+ {/* Thermal exposure */} + + + + {error ? ( + + ) : isLoading ? ( + + ) : thermal ? ( +
+ + + 30°C)')} + value={thermal.hot_pct} + max={100} + color="#ef4444" + sublabel={fmtPercent(thermal.hot_pct, 1)} + /> +
+ + + + + + +
+
+ ) : ( + } + message={t( + 'batteryPassport.noThermal', + 'No ambient-temperature readings recorded for this vehicle yet.', + )} + /> + )} +
+ + {/* Recommendations */} + + + + {error ? ( + + ) : isLoading ? ( + + ) : recommendations.length > 0 ? ( +
    + {recommendations.map((rec, i) => ( +
  • +
  • + ))} +
+ ) : ( + } + message={t( + 'batteryPassport.noRecommendations', + 'No recommendations — this pack is being treated well.', + )} + /> + )} +
+
+
+ + {/* ── 5 · Provenance ─────────────────────────────── */} + +
+ + + + + {t( + 'batteryPassport.provenanceBlurb', + 'A SHA-256 fingerprint over the certificate’s immutable core facts. Any change to the underlying data yields a different hash — that is what makes this passport tamper-evident.', + )} + + {error ? ( + + ) : isLoading ? ( + + ) : passport ? ( +
+
+ + {passport.provenance_hash} + + +
+
+ + + {verifyQuery.data && ( + + {verifyQuery.data.valid + ? t('batteryPassport.hashMatches', 'Recomputed hash matches the certificate.') + : t('batteryPassport.hashMismatch', 'Recomputed hash does not match.')} + + )} +
+
+ +
+
+ ) : ( + + )} +
+
+
+ + )} +
+ ); +} diff --git a/web/src/features/diagnostics/pages/RemainingUsefulLifePage.tsx b/web/src/features/diagnostics/pages/RemainingUsefulLifePage.tsx new file mode 100644 index 0000000000..9db673b1c8 --- /dev/null +++ b/web/src/features/diagnostics/pages/RemainingUsefulLifePage.tsx @@ -0,0 +1,390 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + HeartPulse, CalendarClock, TrendingDown, BatteryCharging, Battery, + Circle, Disc, Wind, Gauge, type LucideIcon, +} from 'lucide-react'; + +import { PageContainer } from '@/components/layout'; +import { GlassPanel, PanelTitle, Text, StatusPill, SelectableCard } from '@/components/ui'; +import { VehicleSelect } from '@/components/forms'; +import { MetricBar } from '@/components/data-display'; +import { + RadialGauge, ChartGradient, ChartTooltip, chartGrid, axisTick, + ComposedChart, Area, Line, XAxis, YAxis, Tooltip, ReferenceLine, ResponsiveContainer, +} from '@/components/charts'; +import { Skeleton, EmptyState, QueryError } from '@/components/feedback'; +import { FadeIn } from '@/components/motion'; + +import { useRUL, useComponentRUL, type ComponentRUL, type RULStatus } from '@/api/hooks/useRUL'; +import { useSelectedVehicle } from '@/hooks/useSelectedVehicle'; +import { usePageTitle } from '@/hooks/usePageTitle'; +import { fmtNumber, fmtInt } from '@/lib/numberFormat'; +import { cn } from '@/lib/cn'; + +const DASH = '—'; + +/* Chart colours — dynamic hex handed to Recharts (graphics, not text, so the + brighter -400 shades are fine here; body text stays on toned -300 shades). */ +const COLOR_CONFIDENCE = '#22d3ee'; // cyan-400 +const COLOR_EOL = '#f43f5e'; // rose-500 — the end-of-life threshold marker + +/* Per-status presentation. `dot` is a background class for the StatusPill's + indicator; `text` is a toned-neon (-300) accent for the icon/label; `gauge` + is the RadialGauge arc colour. */ +interface StatusMeta { + dot: string; + text: string; + gauge: string; + key: string; + fallback: string; +} +const STATUS_META: Record = { + healthy: { dot: 'bg-emerald-400', text: 'text-emerald-300', gauge: '#34d399', key: 'rul.status.healthy', fallback: 'Healthy' }, + watch: { dot: 'bg-amber-400', text: 'text-amber-300', gauge: '#fbbf24', key: 'rul.status.watch', fallback: 'Watch' }, + replace_soon: { dot: 'bg-rose-400', text: 'text-rose-300', gauge: '#fb7185', key: 'rul.status.replace_soon', fallback: 'Replace soon' }, + overdue: { dot: 'bg-red-500', text: 'text-red-300', gauge: '#f87171', key: 'rul.status.overdue', fallback: 'Overdue' }, +}; +function statusMeta(status: string): StatusMeta { + return STATUS_META[status as RULStatus] ?? STATUS_META.healthy; +} + +/* Component → icon. Falls back to a generic gauge for an unrecognised key. */ +const COMPONENT_ICON: Record = { + hv_battery: BatteryCharging, + lv_battery: Battery, + tires: Circle, + brakes: Disc, + cabin_filter: Wind, +}; + +export default function RemainingUsefulLifePage() { + const { t } = useTranslation(); + usePageTitle(t('rul.title', 'Remaining Useful Life')); + + const { vehicleId } = useSelectedVehicle(); + const noVehicle = vehicleId === null; + + const boardQuery = useRUL(vehicleId); + const { data: board, isLoading: boardLoading, error: boardError, refetch: refetchBoard } = boardQuery; + + const components = board?.components ?? []; + const nextService = board?.next_service ?? null; + + /* Selected component drives the forecast panel. When the user hasn't picked + one yet we default to the next-service component (most actionable), then + the first card, so a forecast always renders once the board loads. */ + const [selected, setSelected] = useState(null); + const activeComponent = useMemo( + () => selected ?? nextService?.component ?? components[0]?.component ?? null, + [selected, nextService, components], + ); + + const detailQuery = useComponentRUL(vehicleId, activeComponent); + const { data: detail, isLoading: detailLoading, error: detailError, refetch: refetchDetail } = detailQuery; + + const handleSelect = useCallback((component: string) => setSelected(component), []); + const onRetryBoard = useCallback(() => { refetchBoard(); }, [refetchBoard]); + const onRetryDetail = useCallback(() => { refetchDetail(); }, [refetchDetail]); + + /* ── null-safe display helpers ── */ + const remainingText = useCallback((c: ComponentRUL): string => { + if (c.projected_eol_date != null) { + if (c.remaining_days >= 365) { + return `${fmtNumber(c.remaining_days / 365, 1)} ${t('rul.units.years', 'yr')}`; + } + return `${fmtInt(c.remaining_days)} ${t('rul.units.days', 'days')}`; + } + if (c.status === 'overdue') return t('rul.card.overdueNow', 'Overdue'); + return DASH; // indeterminate — not enough trend to project + }, [t]); + + const kmText = useCallback((c: ComponentRUL): string => ( + c.remaining_km == null ? DASH : `${fmtInt(c.remaining_km)} ${t('rul.units.km', 'km')}` + ), [t]); + + const confLabel = useCallback((conf: number): string => { + if (conf >= 0.66) return t('rul.confidence.high', 'High'); + if (conf >= 0.33) return t('rul.confidence.medium', 'Medium'); + return t('rul.confidence.low', 'Low'); + }, [t]); + + const nextServiceLabel = useMemo(() => { + if (!nextService) return null; + const match = components.find((c) => c.component === nextService.component); + return match?.label ?? nextService.component; + }, [nextService, components]); + + /* ── forecast chart data: fold the projection band into a [low, high] tuple + so a single Recharts range-Area shades the confidence interval. ── */ + const chartData = useMemo( + () => (detail?.projection ?? []).map((p) => ({ + date: p.date, + projected_health: p.projected_health, + band: [p.confidence_low, p.confidence_high] as [number, number], + })), + [detail], + ); + const eol = detail?.eol_threshold ?? null; + const yMin = eol != null && eol > 0 ? Math.max(0, Math.floor(eol) - 10) : 0; + const activeMeta = detail ? statusMeta(detail.status) : STATUS_META.healthy; + + const selectVehicleMsg = t('rul.selectVehicle', 'Select a vehicle to view its component prognostics.'); + + return ( + } + query={boardQuery} + > + {/* ── 1. Next-service banner ─────────────────────────────────────── */} + + +
+
+
+
+ + {/* ── 2. Component health board ──────────────────────────────────── */} + +
+ + + + {noVehicle ? ( + + } + message={selectVehicleMsg} + /> + + ) : boardLoading && !board ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : boardError ? ( + + + + ) : components.length === 0 ? ( + + } + message={t('rul.board.empty', 'Component health will appear once telemetry is available.')} + /> + + ) : ( +
+ {components.map((c) => { + const meta = statusMeta(c.status); + const Icon = COMPONENT_ICON[c.component] ?? Gauge; + const confPct = Math.round(c.confidence * 100); + return ( + handleSelect(c.component)} + aria-label={t('rul.card.aria', 'Show forecast for {{label}}', { label: c.label })} + className="flex flex-col gap-3" + > +
+
+
+ + {t(meta.key, meta.fallback)} + +
+ +
+ +
+
+ {t('rul.card.remaining', 'Remaining')} + + {remainingText(c)} + +
+
+ {t('rul.card.distanceLeft', 'Distance left')} + {kmText(c)} +
+
+ {t('rul.card.replaceBy', 'Replace by')} + + {c.projected_eol_date ?? DASH} + +
+
+
+ + +
+ ); + })} +
+ )} +
+
+ + {/* ── 3. Forecast — selected component's decline to EOL ───────────── */} + + + + + + {t('rul.forecast.subtitle', 'Projected health decaying to the end-of-life threshold, with a confidence band.')} + + + {noVehicle ? ( + } + message={selectVehicleMsg} + /> + ) : !activeComponent ? ( + } + message={t('rul.forecast.select', 'Select a component above to see its forecast.')} + /> + ) : detailLoading && !detail ? ( + + ) : detailError ? ( + + ) : chartData.length === 0 ? ( + } + message={t('rul.forecast.empty', 'No forecast available for this component yet.')} + /> + ) : ( + <> +
+ + + + + + {chartGrid} + (typeof d === 'string' ? d.slice(0, 7) : String(d))} + /> + fmtInt(v)} + /> + ( + Array.isArray(v) + ? `${fmtInt(v[0])}–${fmtInt(v[1])}%` + : `${fmtNumber(v as number, 1)}%` + )} + /> + } /> + {eol != null ? ( + + ) : null} + + + + +
+ {detail?.basis ? ( + + {t('rul.forecast.basis', 'Basis')}: {detail.basis} + + ) : null} + + )} +
+
+
+ ); +} diff --git a/web/src/features/driving/lib/driveDNA.test.ts b/web/src/features/driving/lib/driveDNA.test.ts new file mode 100644 index 0000000000..4fd3caf045 --- /dev/null +++ b/web/src/features/driving/lib/driveDNA.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { generateDriveDNA, petalLine, DNA_CENTER } from './driveDNA'; +import type { DriveTelemetryPoint } from '@/types/driving'; + +function pt(over: Partial): DriveTelemetryPoint { + return { + timestamp: '2025-01-01T00:00:00Z', + speed: 20, + power: 10000, + batteryLevel: 70, + outsideTemp: 18, + insideTemp: 21, + driverTemp: 21, + passengerTemp: 21, + elevation: 100, + idealRange: null, + ratedRange: null, + estRange: null, + odometer: null, + soc: 70, + usableSoc: 70, + tirePressureFl: null, + tirePressureFr: null, + tirePressureRl: null, + tirePressureRr: null, + isClimateOn: true, + fanStatus: null, + latitude: null, + longitude: null, + ...over, + } as DriveTelemetryPoint; +} + +const spiritedDrive: DriveTelemetryPoint[] = Array.from({ length: 40 }, (_, i) => + pt({ speed: 10 + (i % 8) * 5, power: i % 3 === 0 ? -6000 : 40000, soc: 80 - i, elevation: 100 + i * 6 }), +); + +describe('generateDriveDNA', () => { + it('is deterministic — identical telemetry yields the same signature and petal count', () => { + const a = generateDriveDNA(spiritedDrive); + const b = generateDriveDNA(spiritedDrive.map((p) => ({ ...p }))); // structural clone + expect(a.signature).toBe(b.signature); + expect(a.petals.length).toBe(b.petals.length); + expect(a.petals.length).toBe(spiritedDrive.length); + expect(a.signature).toMatch(/^[0-9A-Z]{7}$/); + }); + + it('returns coherent empty art for empty / single-point input without throwing', () => { + const empty = generateDriveDNA([]); + expect(empty.petals).toHaveLength(0); + expect(empty.rings).toHaveLength(0); + expect(empty.signature).toBe('0000000'); + expect(generateDriveDNA(undefined).stats.points).toBe(0); + expect(generateDriveDNA([pt({})]).petals).toHaveLength(0); // needs >= 2 points + }); + + it('is null-safe — missing channels collapse to neutral geometry, never NaN', () => { + const sparse = [pt({ speed: null, power: null, soc: null, elevation: null }), pt({ speed: null, power: null })]; + const g = generateDriveDNA(sparse); + expect(g.petals.length).toBe(2); + for (const p of g.petals) { + expect(Number.isFinite(p.r1)).toBe(true); + expect(Number.isFinite(p.width)).toBe(true); + expect(p.color).toMatch(/^hsl\(/); + } + }); + + it('derives traits from how the car was driven', () => { + const g = generateDriveDNA(spiritedDrive); + // maxSpeed 45 m/s (>33) => Spirited; big climb => Mountainous + expect(g.traits).toContain('Spirited'); + expect(g.traits).toContain('Mountainous'); + expect(g.stats.topSpeedKph).toBeGreaterThan(120); + + const gentle = generateDriveDNA(Array.from({ length: 20 }, () => pt({ speed: 8, power: 3000, elevation: 100 }))); + expect(gentle.traits).toContain('Gentle'); + }); + + it('petalLine maps a petal to finite coordinates radiating from centre', () => { + const [p] = generateDriveDNA(spiritedDrive).petals; + const line = petalLine(p); + for (const v of Object.values(line)) expect(Number.isFinite(v)).toBe(true); + // outer point is further from centre than inner point + const d0 = Math.hypot(line.x1 - DNA_CENTER, line.y1 - DNA_CENTER); + const d1 = Math.hypot(line.x2 - DNA_CENTER, line.y2 - DNA_CENTER); + expect(d1).toBeGreaterThanOrEqual(d0); + }); +}); diff --git a/web/src/features/driving/lib/driveDNA.ts b/web/src/features/driving/lib/driveDNA.ts new file mode 100644 index 0000000000..8d29f9673f --- /dev/null +++ b/web/src/features/driving/lib/driveDNA.ts @@ -0,0 +1,223 @@ +/** + * Drive DNA — deterministic generative-art engine. + * + * Turns a single drive's telemetry into a unique, reproducible visual + * "fingerprint": a radial genome bloom where every petal is one telemetry + * sample and its geometry/colour encode how the car was actually driven. + * + * Encoding (all derived purely from the data, so the same drive always + * renders the same art — no randomness): + * - angle : progress through the drive (0..2π) + * - radius : instantaneous speed (faster => reaches further out) + * - hue : power flow — regen (negative kW) is cool/emerald, + * hard draw (positive kW) is warm/rose + * - saturation : |power| magnitude (effort) + * - lightness : battery state of charge at that moment + * - ringRadius : elevation band (concentric terrain rings) + * + * Everything is null-safe: missing signals collapse to neutral values so a + * sparse drive still produces coherent art instead of NaN geometry. + */ +import type { DriveTelemetryPoint } from '@/types/driving'; + +export interface DNAPetal { + /** Polar angle in radians (0 = 3 o'clock, grows clockwise). */ + angle: number; + /** Inner radius (viewBox units) — where the petal starts. */ + r0: number; + /** Outer radius (viewBox units) — driven by speed. */ + r1: number; + /** HSL colour string encoding power / SoC. */ + color: string; + /** Stroke width — effort (|power|). */ + width: number; + /** 0..1 opacity — recency-weighted so the drive reads as a journey. */ + opacity: number; +} + +export interface DNARing { + /** Radius of the concentric terrain ring. */ + r: number; + /** Faint colour keyed to the elevation band. */ + color: string; +} + +export interface DriveGenome { + petals: DNAPetal[]; + rings: DNARing[]; + /** Dominant background halo hue (average efficiency mood). */ + haloColor: string; + /** Short human traits, e.g. ["Spirited", "Mountainous", "Cold-start"]. */ + traits: string[]; + /** Deterministic 12-char signature (a shareable "gene sequence"). */ + signature: string; + /** Summary stats surfaced next to the art. */ + stats: { + points: number; + topSpeedKph: number | null; + climbM: number | null; + regenShare: number | null; // 0..1 fraction of samples in regen + coldStart: boolean; + }; +} + +const VIEWBOX = 100; // square viewBox side; art is centred at (50,50) +const CENTER = VIEWBOX / 2; + +function num(v: number | null | undefined, fallback = 0): number { + return typeof v === 'number' && Number.isFinite(v) ? v : fallback; +} + +function clamp(v: number, lo: number, hi: number): number { + return v < lo ? lo : v > hi ? hi : v; +} + +/** FNV-1a 32-bit hash → stable base36 signature from the drive's shape. */ +function signatureOf(points: DriveTelemetryPoint[]): string { + let h = 0x811c9dc5; + for (const p of points) { + // Quantise the meaningful channels so tiny float noise stays stable. + const parts = [ + Math.round(num(p.speed)), + Math.round(num(p.power) / 5), + Math.round(num(p.soc)), + Math.round(num(p.elevation) / 10), + ]; + for (const part of parts) { + h ^= part & 0xffff; + h = Math.imul(h, 0x01000193) >>> 0; + } + } + return (h >>> 0).toString(36).padStart(7, '0').slice(0, 7).toUpperCase(); +} + +/** + * Build a full genome from a drive's telemetry. Returns coherent (empty-art) + * output for an empty/degenerate series rather than throwing. + */ +export function generateDriveDNA(raw: readonly DriveTelemetryPoint[] | undefined): DriveGenome { + const points = (raw ?? []).filter(Boolean); + const n = points.length; + + const empty: DriveGenome = { + petals: [], + rings: [], + haloColor: 'hsl(210, 30%, 20%)', + traits: [], + signature: '0000000', + stats: { points: 0, topSpeedKph: null, climbM: null, regenShare: null, coldStart: false }, + }; + if (n < 2) return empty; + + // ---- Pass 1: ranges for normalisation -------------------------------- + let maxSpeed = 0; + let minElev = Infinity; + let maxElev = -Infinity; + let regenSamples = 0; + let sumEffort = 0; + let firstOutside: number | null = null; + let climb = 0; + let prevElev: number | null = null; + + for (const p of points) { + const s = num(p.speed); + if (s > maxSpeed) maxSpeed = s; + const e = p.elevation; + if (typeof e === 'number' && Number.isFinite(e)) { + if (e < minElev) minElev = e; + if (e > maxElev) maxElev = e; + if (prevElev != null && e > prevElev) climb += e - prevElev; + prevElev = e; + } + const pw = num(p.power); + if (pw < -1) regenSamples += 1; + sumEffort += Math.abs(pw); + if (firstOutside == null && typeof p.outsideTemp === 'number') firstOutside = p.outsideTemp; + } + if (!Number.isFinite(minElev)) { + minElev = 0; + maxElev = 0; + } + const elevSpan = Math.max(1, maxElev - minElev); + const speedSpan = Math.max(1, maxSpeed); + const avgEffort = sumEffort / n; + + // ---- Pass 2: petals --------------------------------------------------- + const petals: DNAPetal[] = points.map((p, i) => { + const t = i / (n - 1); // 0..1 journey progress + const angle = t * Math.PI * 2 - Math.PI / 2; // start at 12 o'clock + const speedNorm = clamp(num(p.speed) / speedSpan, 0, 1); + const r0 = 10; + const r1 = r0 + speedNorm * (CENTER - 14); + + const power = num(p.power); + // Hue: regen (power<0) → emerald ~150°, coasting → cyan ~190°, + // hard draw (power>0) → rose ~350°. Map power [-avg..+2avg] onto hue. + const powerNorm = clamp((power + avgEffort) / (3 * avgEffort + 1), 0, 1); + const hue = 150 + powerNorm * (350 - 150); + const sat = 55 + clamp(Math.abs(power) / (avgEffort * 2 + 1), 0, 1) * 40; + const light = 40 + clamp(num(p.soc, 50) / 100, 0, 1) * 35; + + return { + angle, + r0, + r1, + color: `hsl(${Math.round(hue)}, ${Math.round(sat)}%, ${Math.round(light)}%)`, + width: 0.4 + clamp(Math.abs(power) / (avgEffort * 2 + 1), 0, 1) * 1.4, + opacity: 0.35 + t * 0.55, // brighten toward the end of the drive + }; + }); + + // ---- Terrain rings (elevation bands) --------------------------------- + const ringCount = clamp(Math.round((maxElev - minElev) / 60), 0, 5); + const rings: DNARing[] = Array.from({ length: ringCount }, (_, k) => { + const frac = (k + 1) / (ringCount + 1); + return { + r: 12 + frac * (CENTER - 14), + color: `hsla(${Math.round(200 - frac * 60)}, 45%, 55%, 0.12)`, + }; + }); + + // ---- Traits + halo ---------------------------------------------------- + const regenShare = regenSamples / n; + const coldStart = firstOutside != null && firstOutside < 5; + const traits: string[] = []; + if (maxSpeed > 33) traits.push('Spirited'); // >~120 km/h + else if (maxSpeed < 14) traits.push('Gentle'); + if (climb > 150) traits.push('Mountainous'); + if (regenShare > 0.35) traits.push('Regen-rich'); + if (coldStart) traits.push('Cold-start'); + if (avgEffort < 8000) traits.push('Efficient'); + if (traits.length === 0) traits.push('Balanced'); + + const haloHue = 150 + clamp(1 - regenShare, 0, 1) * 60; // greener when regen-heavy + const haloColor = `hsl(${Math.round(haloHue)}, 40%, 16%)`; + + return { + petals, + rings, + haloColor, + traits, + signature: signatureOf(points), + stats: { + points: n, + topSpeedKph: maxSpeed > 0 ? Math.round(maxSpeed * 3.6) : null, + climbM: elevSpan > 1 ? Math.round(climb) : null, + regenShare, + coldStart, + }, + }; +} + +/** Convert a petal to an SVG line segment from centre outward. */ +export function petalLine(p: DNAPetal): { x1: number; y1: number; x2: number; y2: number } { + return { + x1: CENTER + Math.cos(p.angle) * p.r0, + y1: CENTER + Math.sin(p.angle) * p.r0, + x2: CENTER + Math.cos(p.angle) * p.r1, + y2: CENTER + Math.sin(p.angle) * p.r1, + }; +} + +export const DNA_VIEWBOX = VIEWBOX; +export const DNA_CENTER = CENTER; diff --git a/web/src/features/driving/lib/whatIfModel.test.ts b/web/src/features/driving/lib/whatIfModel.test.ts new file mode 100644 index 0000000000..71bc96c085 --- /dev/null +++ b/web/src/features/driving/lib/whatIfModel.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { simulateWhatIf, DEFAULT_KNOBS } from './whatIfModel'; +import type { Drive, DriveTelemetryPoint } from '@/types/driving'; + +const drive: Drive = { + id: 1, + vehicleId: 1, + startTs: '2025-01-01T08:00:00Z', + endTs: '2025-01-01T08:30:00Z', + durationS: 1800, + distanceM: 30000, // 30 km + startAddress: null, + endAddress: null, + startLat: null, + startLon: null, + endLat: null, + endLon: null, + startBatteryPct: 80, + endBatteryPct: 68, + energyUsedWh: 6000, // 6 kWh + regenEnergyWh: 800, + avgSpeedMps: 30000 / 1800, // ~16.7 m/s + maxSpeedMps: 25, +} as Drive; + +const telemetry: DriveTelemetryPoint[] = Array.from({ length: 20 }, (_, i) => + ({ timestamp: '2025-01-01T08:00:00Z', elevation: 100 + i * 4, isClimateOn: true, outsideTemp: 4, speed: 16, power: 12000 } as unknown as DriveTelemetryPoint), +); + +describe('simulateWhatIf', () => { + it('flags drives without energy data instead of returning NaN', () => { + expect(simulateWhatIf(undefined, telemetry, DEFAULT_KNOBS).ok).toBe(false); + const noEnergy = { ...drive, energyUsedWh: 0 } as Drive; + const r = simulateWhatIf(noEnergy, telemetry, DEFAULT_KNOBS); + expect(r.ok).toBe(false); + expect(Number.isFinite(r.scenario.total)).toBe(true); + }); + + it('baseline reconciles to the actual observed energy', () => { + const r = simulateWhatIf(drive, telemetry, DEFAULT_KNOBS); + expect(r.ok).toBe(true); + // "other" absorbs the residual so the decomposed total equals reality. + expect(r.baseline.total).toBeCloseTo(drive.energyUsedWh as number, 0); + expect(r.baseline.aero).toBeGreaterThan(0); + expect(r.baseline.rolling).toBeGreaterThan(0); + }); + + it('raising speed increases aero drag quadratically and shortens the drive', () => { + const base = simulateWhatIf(drive, telemetry, DEFAULT_KNOBS); + const fast = simulateWhatIf(drive, telemetry, { ...DEFAULT_KNOBS, speedFactor: 1.2 }); + expect(fast.scenario.aero).toBeGreaterThan(base.scenario.aero); + // aero ∝ v²: +20% speed ⇒ ~+44% aero + expect(fast.scenario.aero / base.scenario.aero).toBeCloseTo(1.44, 1); + expect(fast.scenarioDurationS).toBeLessThan(base.scenarioDurationS); + }); + + it('turning HVAC off removes climate load; under-inflation raises rolling', () => { + const hvacOff = simulateWhatIf(drive, telemetry, { ...DEFAULT_KNOBS, hvac: false }); + expect(hvacOff.scenario.climate).toBe(0); + expect(hvacOff.scenario.total).toBeLessThan(hvacOff.baseline.total); + + const lowTires = simulateWhatIf(drive, telemetry, { ...DEFAULT_KNOBS, tires: 'low' }); + const nominal = simulateWhatIf(drive, telemetry, DEFAULT_KNOBS); + expect(lowTires.scenario.rolling).toBeGreaterThan(nominal.scenario.rolling); + }); + + it('infers pack size from SoC drop and computes arrival battery', () => { + const r = simulateWhatIf(drive, telemetry, DEFAULT_KNOBS); + // 6 kWh for 12% ⇒ ~50 kWh pack + expect(r.packWh).toBeGreaterThan(40000); + expect(r.packWh).toBeLessThan(60000); + expect(r.baselineArrivalSoc).toBeGreaterThan(0); + expect(r.baselineArrivalSoc).toBeLessThanOrEqual(80); + }); +}); diff --git a/web/src/features/driving/lib/whatIfModel.ts b/web/src/features/driving/lib/whatIfModel.ts new file mode 100644 index 0000000000..1646d40497 --- /dev/null +++ b/web/src/features/driving/lib/whatIfModel.ts @@ -0,0 +1,207 @@ +/** + * What-If — a physics-lite counterfactual engine for a single real drive. + * + * It decomposes the drive's ACTUAL observed energy into first-principles + * components (aerodynamic drag, rolling resistance, elevation work, climate + * load) plus a measured residual ("other": electronics, battery conditioning, + * drivetrain losses). Anchoring to the real energy means the baseline always + * reconciles with what the car actually used; only the knobs move things. + * + * Then it recomputes those components under user "what-if" knobs: + * - speedFactor : scale average speed (aero ∝ v², climate time ∝ 1/v) + * - tires : rolling-resistance multiplier (under/over-inflation) + * - hvac : climate load on/off + * - ambientC : ambient temperature (colder ⇒ more heating + less regen) + * + * All outputs are null-safe and clamped; a drive missing energy just yields a + * neutral, clearly-flagged result rather than NaN. + */ +import type { Drive } from '@/types/driving'; +import type { DriveTelemetryPoint } from '@/types/driving'; + +// Generic mid-size EV constants (Model 3-class); good enough for relative +// what-if deltas, which is what this tool communicates. +const MASS_KG = 1800; +const CDA = 0.58; // drag coefficient × frontal area (m²) +const RHO = 1.225; // air density (kg/m³) +const G = 9.81; +const DRIVETRAIN_EFF = 0.9; +const CLIMATE_BASE_W = 1500; // steady HVAC draw when on at ~20°C +const J_TO_WH = 1 / 3600; +const DEFAULT_PACK_WH = 75000; + +export type TirePreset = 'low' | 'nominal' | 'high'; + +export interface WhatIfKnobs { + speedFactor: number; // 0.8 .. 1.2 + tires: TirePreset; + hvac: boolean; + ambientC: number; // effective ambient temperature °C +} + +export const DEFAULT_KNOBS: WhatIfKnobs = { speedFactor: 1, tires: 'nominal', hvac: true, ambientC: 18 }; + +export interface EnergyBreakdown { + aero: number; + rolling: number; + elevation: number; + climate: number; + other: number; + total: number; +} + +export interface WhatIfResult { + ok: boolean; + baseline: EnergyBreakdown; + scenario: EnergyBreakdown; + packWh: number; + startSocPct: number | null; + baselineArrivalSoc: number | null; + scenarioArrivalSoc: number | null; + /** scenario − baseline energy, Wh (negative = saved). */ + energyDeltaWh: number; + /** duration under the scenario (s). */ + scenarioDurationS: number; + ambientNativeC: number; +} + +const TIRE_CRR: Record = { low: 0.0138, nominal: 0.011, high: 0.0099 }; + +function num(v: number | null | undefined, fallback = 0): number { + return typeof v === 'number' && Number.isFinite(v) ? v : fallback; +} +function clamp(v: number, lo: number, hi: number): number { + return v < lo ? lo : v > hi ? hi : v; +} + +/** Net positive elevation gain (m) across the telemetry, climate flag, ambient. */ +function analyzeTelemetry(points: readonly DriveTelemetryPoint[]): { + climb: number; + climateOn: boolean; + ambientC: number | null; +} { + let climb = 0; + let prev: number | null = null; + let climateHits = 0; + let climateSeen = 0; + let ambientSum = 0; + let ambientN = 0; + for (const p of points) { + const e = p.elevation; + if (typeof e === 'number' && Number.isFinite(e)) { + if (prev != null && e > prev) climb += e - prev; + prev = e; + } + if (typeof p.isClimateOn === 'boolean') { + climateSeen += 1; + if (p.isClimateOn) climateHits += 1; + } + if (typeof p.outsideTemp === 'number' && Number.isFinite(p.outsideTemp)) { + ambientSum += p.outsideTemp; + ambientN += 1; + } + } + return { + climb, + climateOn: climateSeen === 0 ? true : climateHits / climateSeen > 0.3, + ambientC: ambientN > 0 ? ambientSum / ambientN : null, + }; +} + +/** Heating power multiplier as a function of ambient temp (colder ⇒ more). */ +function climatePowerFor(ambientC: number, on: boolean): number { + if (!on) return 0; + const belowComfort = Math.max(0, 20 - ambientC); + const aboveComfort = Math.max(0, ambientC - 26); + return CLIMATE_BASE_W * (1 + belowComfort * 0.06 + aboveComfort * 0.04); +} + +export function simulateWhatIf( + drive: Drive | undefined, + telemetry: readonly DriveTelemetryPoint[] | undefined, + knobs: WhatIfKnobs, +): WhatIfResult { + const points = (telemetry ?? []).filter(Boolean); + const distance = num(drive?.distanceM); + const duration = Math.max(1, num(drive?.durationS, 1)); + const observed = num(drive?.energyUsedWh); + const avgSpeed = num(drive?.avgSpeedMps, distance / duration); + + const neutral: WhatIfResult = { + ok: false, + baseline: { aero: 0, rolling: 0, elevation: 0, climate: 0, other: 0, total: 0 }, + scenario: { aero: 0, rolling: 0, elevation: 0, climate: 0, other: 0, total: 0 }, + packWh: DEFAULT_PACK_WH, + startSocPct: drive?.startBatteryPct ?? null, + baselineArrivalSoc: null, + scenarioArrivalSoc: null, + energyDeltaWh: 0, + scenarioDurationS: duration, + ambientNativeC: knobs.ambientC, + }; + if (!drive || distance < 10 || observed <= 0 || avgSpeed <= 0) return neutral; + + const { climb, climateOn, ambientC } = analyzeTelemetry(points); + const nativeAmbient = ambientC ?? knobs.ambientC; + + // ---- Baseline decomposition (anchored to observed energy) ------------- + const aero0 = (0.5 * RHO * CDA * avgSpeed * avgSpeed * distance) * J_TO_WH / DRIVETRAIN_EFF; + const roll0 = (TIRE_CRR.nominal * MASS_KG * G * distance) * J_TO_WH / DRIVETRAIN_EFF; + const elev0 = (MASS_KG * G * climb) * J_TO_WH / DRIVETRAIN_EFF; + const climate0 = (climatePowerFor(nativeAmbient, climateOn) * duration) * J_TO_WH * 3600 / 3600; // W·s→Wh + const modeled0 = aero0 + roll0 + elev0 + climate0; + // Residual attributed to "other" (auxiliaries, conditioning); never negative. + const other = Math.max(0, observed - modeled0); + const baseline: EnergyBreakdown = { + aero: aero0, + rolling: roll0, + elevation: elev0, + climate: climate0, + other, + total: aero0 + roll0 + elev0 + climate0 + other, + }; + + // ---- Scenario recompute ----------------------------------------------- + const k = clamp(knobs.speedFactor, 0.5, 1.6); + const scenSpeed = avgSpeed * k; + const scenDuration = distance / scenSpeed; + const aero1 = aero0 * k * k; // ∝ v² + const roll1 = roll0 * (TIRE_CRR[knobs.tires] / TIRE_CRR.nominal); + const elev1 = elev0; // gravity work is route-fixed + const climate1 = (climatePowerFor(knobs.ambientC, knobs.hvac) * scenDuration) * J_TO_WH; + // Cold ambient also reduces battery/regen efficiency a touch. + const coldPenalty = 1 + Math.max(0, 10 - knobs.ambientC) * 0.008; + const scenario: EnergyBreakdown = { + aero: aero1 * coldPenalty, + rolling: roll1 * coldPenalty, + elevation: elev1, + climate: climate1, + other: other * coldPenalty, + total: 0, + }; + scenario.total = scenario.aero + scenario.rolling + scenario.elevation + scenario.climate + scenario.other; + + // ---- SoC / pack ------------------------------------------------------- + const startSoc = drive.startBatteryPct; + const endSoc = drive.endBatteryPct; + let packWh = DEFAULT_PACK_WH; + if (startSoc != null && endSoc != null && startSoc > endSoc) { + const inferred = observed / ((startSoc - endSoc) / 100); + if (Number.isFinite(inferred) && inferred > 30000 && inferred < 130000) packWh = inferred; + } + const baselineArrival = startSoc != null ? clamp(startSoc - (baseline.total / packWh) * 100, 0, 100) : null; + const scenarioArrival = startSoc != null ? clamp(startSoc - (scenario.total / packWh) * 100, 0, 100) : null; + + return { + ok: true, + baseline, + scenario, + packWh, + startSocPct: startSoc, + baselineArrivalSoc: baselineArrival, + scenarioArrivalSoc: scenarioArrival, + energyDeltaWh: scenario.total - baseline.total, + scenarioDurationS: scenDuration, + ambientNativeC: nativeAmbient, + }; +} diff --git a/web/src/features/driving/pages/DriveDNAPage.tsx b/web/src/features/driving/pages/DriveDNAPage.tsx new file mode 100644 index 0000000000..9db1003747 --- /dev/null +++ b/web/src/features/driving/pages/DriveDNAPage.tsx @@ -0,0 +1,229 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Sparkles, Download, Gauge, Mountain, Snowflake, Leaf, Fingerprint } from 'lucide-react'; + +import { PageContainer } from '@/components/layout'; +import { GlassPanel, PanelTitle, Text, Button, Badge, Select } from '@/components/ui'; +import { VehicleSelect } from '@/components/forms'; +import { Skeleton, EmptyState, QueryError } from '@/components/feedback'; +import { FadeIn } from '@/components/motion'; + +import { useDrives, useDriveTelemetry } from '@/api/hooks/useDriving'; +import { useSelectedVehicle } from '@/hooks/useSelectedVehicle'; +import { usePageTitle } from '@/hooks/usePageTitle'; +import { formatDateShort } from '@/lib/dateFormat'; +import type { Drive } from '@/types/driving'; + +import { + generateDriveDNA, + petalLine, + DNA_VIEWBOX, + DNA_CENTER, + type DriveGenome, +} from '../lib/driveDNA'; + +const TRAIT_ICON: Record = { + Spirited: Gauge, + Gentle: Leaf, + Mountainous: Mountain, + 'Regen-rich': Leaf, + 'Cold-start': Snowflake, + Efficient: Leaf, + Balanced: Sparkles, +}; + +/** Build a standalone, downloadable SVG document string from a genome. */ +function genomeToSvg(g: DriveGenome, label: string): string { + const rings = g.rings + .map((r) => ``) + .join(''); + const petals = g.petals + .map((p) => { + const l = petalLine(p); + return ``; + }) + .join(''); + return `` + + `` + + `` + + rings + petals + + `${label} · ${g.signature}` + + ``; +} + +export default function DriveDNAPage() { + const { t } = useTranslation(); + usePageTitle(t('driveDna.title', 'Drive DNA')); + + const { vehicleId } = useSelectedVehicle(); + const vehicleIdStr = vehicleId != null ? String(vehicleId) : undefined; + + const drivesQuery = useDrives(vehicleIdStr); + const drives = useMemo(() => drivesQuery.data ?? [], [drivesQuery.data]); + + const [selectedId, setSelectedId] = useState(''); + const activeId = selectedId || (drives[0] ? String(drives[0].id) : ''); + + const telemetryQuery = useDriveTelemetry(activeId); + const genome = useMemo(() => generateDriveDNA(telemetryQuery.data), [telemetryQuery.data]); + + const driveOptions = useMemo( + () => + drives.map((d) => ({ + value: String(d.id), + label: `${formatDateShort(d.startTs)} · ${(d.distanceM / 1000).toFixed(1)} km`, + })), + [drives], + ); + + const activeDrive = drives.find((d) => String(d.id) === activeId); + const label = activeDrive ? formatDateShort(activeDrive.startTs) : t('driveDna.title', 'Drive DNA'); + + function handleDownload() { + if (!genome.petals.length) return; + const svg = genomeToSvg(genome, label); + const blob = new Blob([svg], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `drive-dna-${genome.signature}.svg`; + a.click(); + URL.revokeObjectURL(url); + } + + return ( + + + +
+
+ {t('driveDna.heading', 'Generative telemetry art')} + + {t( + 'driveDna.blurb', + 'Every drive has a unique signature. This bloom encodes speed, power flow, elevation and battery into a reproducible fingerprint.', + )} + +
+
+ + {driveOptions.length > 0 && ( + setSelectedId(e.target.value)} options={driveOptions} /> + )} +
+
+
+
+ + {drivesQuery.isError ? ( + drivesQuery.refetch()} /> + ) : loading ? ( +
+ + +
+ ) : !activeId ? ( + + ) : !result.ok ? ( + + ) : ( +
+ {/* Controls */} + + +
+ {t('whatIf.knobs', 'Knobs')} + +
+ + setKnobs((k) => ({ ...k, speedFactor: v }))} + /> + +
+ {t('whatIf.tires', 'Tire pressure')} +