Files
lingniu-vehicle-ingest/go/vehicle-gateway/cmd/stats-backfill/customer_hydrogen.go
T
lingniu d6b957dfc8 feat: sync vehicle monitoring and accumulated platform updates
Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates.

Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
2026-10-26 18:05:59 +08:00

186 lines
6.8 KiB
Go

package main
import (
"context"
"database/sql"
"fmt"
"log/slog"
"math"
"sort"
"strings"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
)
const customerHydrogenAlgorithm = "MODE_INTERVAL_V1"
type customerHydrogenDay struct {
VIN, Date, Protocol, SourceKey string
HydrogenKM, ObservedKM, ClassifiedKM float64
Samples, KnownSamples, Intervals, Rejected int64
First, Last time.Time
PreviousKM float64
}
func addCustomerHydrogenSample(days map[string]*customerHydrogenDay, s stats.MetricSample) {
if s.Protocol != envelope.ProtocolGB32960 && s.Protocol != envelope.ProtocolYutongMQTT {
return
}
if math.IsNaN(s.TotalMileageKM) || math.IsInf(s.TotalMileageKM, 0) || s.TotalMileageKM < 0 {
return
}
key := s.VIN + "/" + s.StatDate + "/" + string(s.Protocol) + "/" + s.SourceKey
d := days[key]
if d == nil {
d = &customerHydrogenDay{VIN: s.VIN, Date: s.StatDate, Protocol: string(s.Protocol), SourceKey: s.SourceKey, First: s.EventTime, Last: s.EventTime, PreviousKM: s.TotalMileageKM, Samples: 1}
if s.PureHydrogenModeKnown {
d.KnownSamples = 1
}
days[key] = d
return
}
// Do not count duplicate or out-of-order reports as extra evidence.
if !s.EventTime.After(d.Last) {
return
}
elapsed := s.EventTime.Sub(d.Last)
delta := s.TotalMileageKM - d.PreviousKM
d.Samples++
if s.PureHydrogenModeKnown {
d.KnownSamples++
}
if delta < -0.2 || delta > math.Max(0.5, elapsed.Hours()*130) || elapsed > 15*time.Minute {
d.Rejected++
} else {
delta = math.Max(0, delta)
d.ObservedKM += delta
d.Intervals++
if s.PureHydrogenModeKnown {
d.ClassifiedKM += delta
if s.PureHydrogenActive {
d.HydrogenKM += delta
}
}
}
d.Last = s.EventTime
d.PreviousKM = s.TotalMileageKM
}
// This path reads raw work-mode evidence and only writes the dedicated customer
// monitor projection. It never resets/reprojects the existing GPS/daily tables.
func runCustomerHydrogenBackfill(ctx context.Context, mysqlDB, td *sql.DB, cfg config) error {
return runCustomerHydrogenBackfillWithLease(ctx, mysqlDB, td, cfg, "")
}
func runCustomerHydrogenBackfillWithLease(ctx context.Context, mysqlDB, td *sql.DB, cfg config, owner string) error {
if cfg.Reset {
return fmt.Errorf("hydrogen-only backfill does not allow reset")
}
if cfg.Limit > 0 && !cfg.DryRun {
return fmt.Errorf("limited scan cannot publish a daily result")
}
for _, p := range cfg.Protocols {
if p != envelope.ProtocolGB32960 && p != envelope.ProtocolYutongMQTT {
return fmt.Errorf("hydrogen-only backfill accepts GB32960,YUTONG_MQTT only")
}
}
rows, err := queryRawFrames(ctx, td, cfg)
if err != nil {
return err
}
defer rows.Close()
days := map[string]*customerHydrogenDay{}
var scanned, skipped int64
for rows.Next() {
scanned++
row, err := scanRawFrame(rows)
if err != nil {
return err
}
text := strings.TrimSpace(row.ParsedJSON)
if isChunkManifest(text) {
text, err = readChunkedPayload(ctx, td, cfg.TDengineDatabase, row.EventID, "parsed_fields")
if err != nil {
return fmt.Errorf("read complete mode evidence: %w", err)
}
}
fields := fieldsForStats(row.Protocol, row.VIN, text)
if len(fields) == 0 && row.Protocol == envelope.ProtocolYutongMQTT {
fields = fieldsForStats(row.Protocol, row.VIN, row.RawText)
}
samples, err := stats.SamplesFromEnvelope(envelope.FrameEnvelope{EventID: row.EventID, Protocol: row.Protocol, MessageID: row.MessageID, VIN: row.VIN, Phone: row.Phone, DeviceID: row.DeviceID, SourceEndpoint: row.SourceEndpoint, EventTimeMS: row.EventTimeMS, ReceivedAtMS: row.ReceivedAtMS, Fields: fields, ParsedFields: fields, ParseStatus: envelope.ParseOK}, cfg.Location)
if err != nil {
return err
}
if len(samples) == 0 {
skipped++
}
for _, s := range samples {
if s.StatDate >= cfg.DateFrom && s.StatDate <= cfg.DateTo {
addCustomerHydrogenSample(days, s)
}
}
if cfg.ProgressEvery > 0 && scanned%cfg.ProgressEvery == 0 {
slog.Info("customer hydrogen scan", "scanned", scanned, "source_days", len(days), "skipped", skipped)
}
}
if err = rows.Err(); err != nil {
return err
}
var known, samples int64
var hydrogen float64
for _, d := range days {
known += d.KnownSamples
samples += d.Samples
hydrogen += d.HydrogenKM
}
slog.Info("customer hydrogen evidence", "scanned", scanned, "samples", samples, "known_mode_samples", known, "source_days", len(days), "hydrogen_km_across_sources", hydrogen, "dry_run", cfg.DryRun)
if cfg.DryRun {
return nil
}
return publishCustomerHydrogenDays(ctx, mysqlDB, days, cfg.DateFrom, owner)
}
func publishCustomerHydrogenDays(ctx context.Context, mysqlDB *sql.DB, days map[string]*customerHydrogenDay, date, owner string) error {
// Publish only after a complete successful scan. Upserts are deterministic;
// failures roll back the entire projection update.
tx, err := mysqlDB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if owner != "" {
// Fence publication with the same row lock used to claim jobs. A stale
// worker must not overwrite results even before its next heartbeat.
var valid int
if err = tx.QueryRowContext(ctx, `SELECT 1 FROM platform_customer_hydrogen_job WHERE stat_date=? AND lease_owner=? AND status='running' AND lease_until>NOW(3) FOR UPDATE`, date, owner).Scan(&valid); err != nil {
return fmt.Errorf("customer hydrogen publication lease lost: %w", err)
}
}
keys := make([]string, 0, len(days))
for k := range days {
keys = append(keys, k)
}
sort.Strings(keys)
for start := 0; start < len(keys); start += 200 {
end := start + 200
if end > len(keys) {
end = len(keys)
}
values := []string{}
args := []any{}
for _, key := range keys[start:end] {
d := days[key]
values = append(values, "(?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(3))")
args = append(args, d.VIN, d.Date, d.Protocol, d.SourceKey, d.HydrogenKM, d.ObservedKM, d.ClassifiedKM, d.Samples, d.KnownSamples, d.Intervals, d.Rejected, d.First, d.Last, customerHydrogenAlgorithm)
}
_, err = tx.ExecContext(ctx, `INSERT INTO platform_customer_hydrogen_daily(vin,stat_date,protocol,source_key,hydrogen_km,observed_km,classified_km,sample_count,known_sample_count,interval_count,rejected_intervals,first_event_time,last_event_time,algorithm_version,calculated_at) VALUES `+strings.Join(values, ",")+` ON DUPLICATE KEY UPDATE hydrogen_km=VALUES(hydrogen_km),observed_km=VALUES(observed_km),classified_km=VALUES(classified_km),sample_count=VALUES(sample_count),known_sample_count=VALUES(known_sample_count),interval_count=VALUES(interval_count),rejected_intervals=VALUES(rejected_intervals),first_event_time=VALUES(first_event_time),last_event_time=VALUES(last_event_time),algorithm_version=VALUES(algorithm_version),calculated_at=VALUES(calculated_at)`, args...)
if err != nil {
return err
}
}
return tx.Commit()
}