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() }