-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathnormalize.go
More file actions
317 lines (295 loc) Β· 12.8 KB
/
Copy pathnormalize.go
File metadata and controls
317 lines (295 loc) Β· 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
package normalize
import (
"context"
"errors"
"fmt"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/ev-dev-labs/teslasync/internal/tesla/codec"
"github.com/ev-dev-labs/teslasync/internal/tesla/protomodel"
unithistory "github.com/ev-dev-labs/teslasync/internal/tesla/unit_history"
"github.com/ev-dev-labs/teslasync/internal/tesla/units"
)
// ErrNoUnitContext is the normalize-package sentinel returned by
// toSI when the Repo.At lookup yields unithistory.ErrNotFound. It
// is the LOUD form of "we have no unit history for this
// (vehicle, EmittedAt)" β the caller drops the atomic and bumps
// UnitContextMissing rather than guessing a default unit, because
// guessing "km" would silently corrupt a US car the moment we
// assumed it.
//
// We define a normalize-local sentinel (rather than re-exporting
// units.ErrNoUnitContext) so callers can distinguish the no-history
// case (a data-quality problem the bootstrap layer is responsible
// for resolving) from the units.ErrNoUnitContext case (a programmer
// bug β a caller invoking units.ToSI with active="").
var ErrNoUnitContext = errors.New("normalize: no unit history for vehicle/kind at emitted_at")
// outcome label constants for the values_processed metric. The set
// is closed and matches ADR-004 #8's contract; adding a new outcome
// requires updating the dashboards built against that contract.
const (
outcomeOK = "ok"
outcomeDroppedNoUnit = "dropped_no_unit"
outcomeDroppedInvalid = "dropped_invalid"
outcomeDroppedNoRoute = "dropped_no_route"
outcomeError = "error"
)
// Metrics bundles the two CounterVecs the Pipeline emits. The struct
// shape is part of the package's public surface so a future caller
// (out-of-process renderer, alternate registry) can substitute its
// own backend without rewriting Pipeline. Production wiring uses
// defaultMetrics, which is registered against
// prometheus.DefaultRegisterer at package init via promauto.
//
// The label sets are LOCKED by ADR-004 #8:
//
// ValuesProcessed: field, outcome (outcome β {ok,
// dropped_no_unit, dropped_invalid,
// dropped_no_route, error})
// UnitContextMissing: field
//
// Cardinality is bounded: `field` is the closed set of
// protomodel.Signals.Field strings (~250 entries) and `outcome` is
// the 5-element set above, so the total label-pair count is at most
// ~1250 per Prometheus instance.
type Metrics struct {
// ValuesProcessed counts every atomic the dispatch loop touches,
// labelled by the canonical Field name and the per-atomic
// outcome bucket. Setting*Unit atomics increment outcomeOK on
// successful Record; the value-bearing dispatch path increments
// the appropriate bucket on success or the corresponding drop
// reason on failure.
ValuesProcessed *prometheus.CounterVec
// UnitContextMissing counts the subset of dropped_no_unit
// outcomes by Field. It is a separate metric (rather than a
// label-derived view of ValuesProcessed) so the alert rule β
// "any vehicle has been emitting unit-bearing values for >5min
// without a unit-history row" β can be expressed as a simple
// rate() on a single series rather than a filter expression.
UnitContextMissing *prometheus.CounterVec
}
// defaultMetrics is the package-level singleton used by New. It is
// initialised via promauto so registration happens exactly once at
// import time against prometheus.DefaultRegisterer; double-Register
// panics are impossible because there is no other call site.
//
// The fully-qualified Prometheus names are
// tesla_normalize_values_processed_total and
// tesla_normalize_unit_context_missing_total. The grep-friendly
// substring `tesla_normalize_` is used by dashboards and alerts.
var defaultMetrics = &Metrics{
ValuesProcessed: promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "tesla",
Subsystem: "normalize",
Name: "values_processed_total",
Help: "Atomic values traversed by normalize.Pipeline.processOne, " +
"labelled by canonical proto field name and per-atomic outcome " +
"bucket {ok, dropped_no_unit, dropped_invalid, dropped_no_route, error}. " +
"Public metric: tesla_normalize_values_processed_total.",
}, []string{"field", "outcome"}),
UnitContextMissing: promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "tesla",
Subsystem: "normalize",
Name: "unit_context_missing_total",
Help: "Atomic values dropped because vehicle_unit_history had no row " +
"for the field's UnitKind at the atomic's EmittedAt. A non-zero " +
"rate indicates the bootstrap layer (or live SettingUnit emission) " +
"has not yet seeded unit context for the vehicle. Public metric: " +
"tesla_normalize_unit_context_missing_total.",
}, []string{"field"}),
}
// toSI converts a unit-bearing atomic to canonical SI given the active unit
// at the atomic's EmittedAt. Fixed-wire charging fields bypass unit history
// and convert kWh/kW to Wh/W directly. For atomics whose Field is dimensionless
// (UnitKindNone and not on an override list) or whose Field is UnitKindCharge
// (SoC scalars are always %), the function returns the atomic unchanged.
//
// Errors:
//
// - ErrNoUnitContext the Repo had no unit-history row for the
// vehicle/kind at the atomic's EmittedAt. The caller drops the
// atomic and bumps UnitContextMissing + ValuesProcessed{outcome=
// "dropped_no_unit"}.
//
// - units.ErrUnsupportedField / units.ErrUnsupportedUnit the
// active unit returned by the Repo does not match a conversion
// entry. This is a deployment drift between the proto, the
// unit-history layer, and the units conversion table; the
// caller drops the atomic and bumps ValuesProcessed{outcome=
// "dropped_invalid"}.
//
// - any other error unrecoverable infrastructure failure
// (Repo.At returned a wrapped pgx error). The caller drops +
// bumps outcome="error" and continues with the next atomic.
//
// On the happy path the returned codec.Atomic has the same Field /
// EmittedAt / VehicleID as the input and Value replaced with the
// SI scalar (float64).
func (p *Pipeline) toSI(ctx context.Context, atomic codec.Atomic, vehicleIntID int64) (codec.Atomic, error) {
meta := protomodel.SignalsByName[atomic.Field]
// Pass-through cases: dimensionless field with no speed-override
// (e.g. Gear, BatteryHeaterOn), and the UnitKindCharge family
// (Soc, BatteryLevel) whose values are always %.
if !needsConversion(atomic.Field, meta) {
return atomic, nil
}
// Fixed-wire-unit fields bypass unit history entirely. Distance/range
// fields are always miles; charging energy/power fields are always
// kWh/kW. Neither family may be dropped because a vehicle has no
// unit_history row.
if units.IsFixedMileDistanceField(atomic.Field) ||
units.IsFixedKiloToBaseField(atomic.Field) ||
units.IsFixedMphSpeedField(atomic.Field) {
raw, ok := coerceFloat(atomic.Value)
if !ok {
return codec.Atomic{}, fmt.Errorf("%w: %s value of type %T not coercible to float64", units.ErrUnsupportedField, atomic.Field, atomic.Value)
}
siValue, err := units.ToSI(atomic.Field, raw, "")
if err != nil {
return codec.Atomic{}, fmt.Errorf("normalize: units.ToSI(%s, %v, fixed-wire): %w", atomic.Field, raw, err)
}
atomic.Value = siValue
return atomic, nil
}
kind, ok := kindFromMeta(atomic.Field, meta)
if !ok {
// Defensive: needsConversion returned true so kindFromMeta
// MUST have a Kind for this Field. Reaching here is a
// generator-drift bug.
return codec.Atomic{}, fmt.Errorf("normalize: no unit_history.Kind for field %q (UnitKind=%s)", atomic.Field, unitKindString(meta))
}
active, err := p.histRepo.At(ctx, vehicleIntID, kind, atomic.EmittedAt)
if errors.Is(err, unithistory.ErrNotFound) {
p.metrics.UnitContextMissing.WithLabelValues(atomic.Field).Inc()
return codec.Atomic{}, ErrNoUnitContext
}
if err != nil {
return codec.Atomic{}, fmt.Errorf("normalize: histRepo.At(%d, %s, %s): %w", vehicleIntID, kind, atomic.EmittedAt.Format("2006-01-02T15:04:05Z07:00"), err)
}
raw, ok := coerceFloat(atomic.Value)
if !ok {
// A unit-bearing field whose Value is not a numeric scalar
// is a producer/codec contract violation. Tagged
// outcome="dropped_invalid" via units.ErrUnsupportedField so
// the closed outcome set holds without inventing a new bucket.
return codec.Atomic{}, fmt.Errorf("%w: %s value of type %T not coercible to float64", units.ErrUnsupportedField, atomic.Field, atomic.Value)
}
siValue, err := units.ToSI(atomic.Field, raw, active)
if err != nil {
return codec.Atomic{}, fmt.Errorf("normalize: units.ToSI(%s, %v, %s): %w", atomic.Field, raw, active, err)
}
atomic.Value = siValue
return atomic, nil
}
// needsConversion reports whether toSI should perform a conversion for the
// field. The cases that DO need conversion:
//
// - UnitKindDistance / UnitKindTemperature / UnitKindPressure: the
// value is in the wire-format unit and must be converted to SI
// (meters / Celsius / Pascals).
//
// - the speed-override list (VehicleSpeed, CruiseSetSpeed): the
// SignalMeta UnitKind is None (because their canonical SI form
// is m/s, which UnitKindDistance cannot express without
// overloading), but units.ToSI handles them via an internal
// speed-conversions table given the active distance unit.
//
// - fixed kWh/kW charging fields: UnitKindNone metadata, but Tesla's
// documented wire unit must be scaled to Wh/W without unit history.
//
// UnitKindCharge is intentionally a pass-through: SoC scalars are
// always emitted in % and units.ToSI returns ErrUnsupportedUnit for
// them. The SettingChargeUnit signal is recorded for UI display
// preference only.
func needsConversion(field string, meta *protomodel.SignalMeta) bool {
if isSpeedField(field) || units.IsFixedKiloToBaseField(field) || units.IsFixedMphSpeedField(field) {
return true
}
if meta == nil {
return false
}
switch meta.UnitKind {
case protomodel.UnitKindDistance, protomodel.UnitKindTemperature, protomodel.UnitKindPressure:
return true
default:
return false
}
}
// kindFromMeta maps a (Field, SignalMeta) to the unithistory.Kind
// that the unit-history layer indexes against. The speed-override
// list short-circuits to KindDistance because the speed fields'
// active unit comes from SettingDistanceUnit even though their
// SignalMeta.UnitKind is None. For UnitKindCharge the function
// returns KindCharge for completeness (callers may use it to
// observe SettingChargeUnit history) even though needsConversion
// returns false for charge fields.
//
// Returns (kind, false) when the field has no mapping β either
// SignalMeta is nil or its UnitKind is None and the field is not
// on the speed-override list.
func kindFromMeta(field string, meta *protomodel.SignalMeta) (unithistory.Kind, bool) {
if isSpeedField(field) {
return unithistory.KindDistance, true
}
if meta == nil {
return "", false
}
switch meta.UnitKind {
case protomodel.UnitKindDistance:
return unithistory.KindDistance, true
case protomodel.UnitKindTemperature:
return unithistory.KindTemperature, true
case protomodel.UnitKindPressure:
return unithistory.KindPressure, true
case protomodel.UnitKindCharge:
return unithistory.KindCharge, true
}
return "", false
}
// speedFields mirrors units.speedFields (which is package-private to
// the units package). The list is short and stable; if a third
// speed field is added the two lists get bumped together. Keeping a
// local copy avoids a cyclic exposure of an internal symbol from
// units just to satisfy normalize, and the package doc comment in
// units/conversions.go names the fields explicitly so the bump is
// hard to miss in code review.
var speedFields = map[string]bool{
"VehicleSpeed": true,
"CruiseSetSpeed": true,
}
// isSpeedField reports whether the field is on the speed-override
// list. Exposed as a separate helper so call sites read top-to-
// bottom without an inline map literal.
func isSpeedField(field string) bool {
return speedFields[field]
}
// coerceFloat widens the protomodel.DecodeValue numeric variants
// (int32 / int64 / float32 / float64) to a float64 for units.ToSI.
// Returns (0, false) for any non-numeric type so the caller can
// classify the drop as outcome="dropped_invalid" rather than
// silently substituting NaN.
func coerceFloat(v any) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case float32:
return float64(x), true
case int64:
return float64(x), true
case int32:
return float64(x), true
case int:
return float64(x), true
default:
return 0, false
}
}
// unitKindString renders meta's UnitKind for diagnostic logging.
// Defensive against a nil meta so the format-string call site
// doesn't need a separate guard.
func unitKindString(meta *protomodel.SignalMeta) string {
if meta == nil {
return "<nil-meta>"
}
return meta.UnitKind.String()
}