Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats/hydrogen_v35_realtime_test.go
T

158 lines
7.1 KiB
Go

package stats
import (
"context"
"math"
"regexp"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
func hydrogenV35TestSample(at time.Time, mass, pressure, mileage, soc float64, runningMode int) HydrogenStreamSample {
return HydrogenStreamSample{
VIN: "LA9GG64L0NBAF4175", Date: at.Format("2006-01-02"), SourceEndpoint: "source-a",
EventID: at.Format(time.RFC3339Nano), EventTime: at,
MassKG: mass, PressureMPa: pressure, TemperatureC: 30, NoiseKG: 0.05, RefuelThresholdKG: 1,
MileageKM: mileage, MileageKnown: true, SOCPercent: soc, SOCKnown: true,
VehicleState: 1, VehicleStateKnown: true, ChargeState: 3, ChargeStateKnown: true,
RunningMode: runningMode, RunningModeKnown: true,
FuelCellActive: runningMode == 2, FuelCellStateKnown: true,
FuelCellPowerKW: 40, FuelCellPowerKnown: true, ConsumptionEligible: true,
TankCapacityLiters: 520,
}
}
func TestHydrogenV35RealtimeConfirmsMixedAndBalancesSOC(t *testing.T) {
base := time.Date(2026, 9, 4, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
charging := hydrogenV35TestSample(base, 10.1, 30, 100, 90, 1)
charging.ChargeState = 1
state := newHydrogenV35RealtimeState(charging, HydrogenRealtimeParameters{BatteryCapacityKWh: 80, HydrogenEnergyKWhKG: 16})
values := []HydrogenStreamSample{
hydrogenV35TestSample(base.Add(time.Minute), 10.0, 29.5, 100, 90, 1),
hydrogenV35TestSample(base.Add(2*time.Minute), 9.8, 29, 105, 88, 1),
hydrogenV35TestSample(base.Add(3*time.Minute), 9.7, 28.5, 106, 87, 2),
hydrogenV35TestSample(base.Add(3*time.Minute+10*time.Second), 9.2, 27, 112, 84, 2),
hydrogenV35TestSample(base.Add(3*time.Minute+20*time.Second), 8.5, 25, 120, 80, 2),
}
for _, value := range values {
if !state.add(value) {
t.Fatalf("sample %s rejected", value.EventID)
}
}
projection := state.projection()
if projection.QualityStatus != "OK" {
t.Fatalf("quality=%s reason=%s state=%#v", projection.QualityStatus, projection.QualityReason, state)
}
if math.Abs(projection.ConsumptionKG-1.2) > 0.001 || math.Abs(projection.PureMileageKM-6) > 0.001 || math.Abs(projection.MixedMileageKM-14) > 0.001 {
t.Fatalf("projection=%#v", projection)
}
if projection.SOCDeltaPercent == nil || math.Abs(*projection.SOCDeltaPercent-(-7)) > 0.001 {
t.Fatalf("SOC projection=%#v", projection)
}
if projection.CorrectedConsumptionKG == nil || math.Abs(*projection.CorrectedConsumptionKG-1.55) > 0.001 {
t.Fatalf("balanced projection=%#v", projection)
}
}
func TestHydrogenV35RealtimeWaitsForRefuelConfirmation(t *testing.T) {
base := time.Date(2026, 9, 4, 9, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
state := newHydrogenV35RealtimeState(hydrogenV35TestSample(base, 10, 20, 100, 80, 2), HydrogenRealtimeParameters{BatteryCapacityKWh: 80})
state.add(hydrogenV35TestSample(base.Add(time.Minute), 9, 19, 101, 79, 2))
state.add(hydrogenV35TestSample(base.Add(2*time.Minute), 12.5, 22.5, 101, 79, 2))
if state.RefuelCount != 0 || !state.RefuelCandidate.Set {
t.Fatalf("refuel confirmed too early: %#v", state.RefuelCandidate)
}
state.add(hydrogenV35TestSample(base.Add(2*time.Minute+10*time.Second), 12.4, 22.4, 101, 79, 2))
state.add(hydrogenV35TestSample(base.Add(2*time.Minute+20*time.Second), 12.3, 22.3, 101, 79, 2))
if state.RefuelCount != 1 || state.RefuelCandidate.Set || state.HydrogenSegments != 1 {
t.Fatalf("refuel not confirmed: count=%d segments=%d candidate=%#v", state.RefuelCount, state.HydrogenSegments, state.RefuelCandidate)
}
state.add(hydrogenV35TestSample(base.Add(3*time.Minute), 11.5, 21, 112, 75, 2))
if got := state.projection().ConsumptionKG; math.Abs(got-2.0) > 0.001 {
t.Fatalf("consumption=%.3f, want 2.000; state=%#v", got, state)
}
}
func TestHydrogenV35RealtimeDetectsThermalRefuelAcrossReportingGap(t *testing.T) {
base := time.Date(2026, 9, 4, 9, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
first := hydrogenV35TestSample(base, 5, 10, 100, 80, 2)
first.TemperatureC = 20
state := newHydrogenV35RealtimeState(first, HydrogenRealtimeParameters{BatteryCapacityKWh: 80})
for index, offset := range []time.Duration{21*time.Minute + 50*time.Second, 22 * time.Minute, 22*time.Minute + 10*time.Second} {
value := hydrogenV35TestSample(base.Add(offset), 9-float64(index)/10, 14-float64(index)/10, 100.3, 80, 2)
value.TemperatureC = 24
state.add(value)
}
if state.RefuelCount != 1 {
t.Fatalf("thermal refuel count=%d candidate=%#v", state.RefuelCount, state.RefuelCandidate)
}
}
func TestHydrogenV35RealtimeRejectsLateFrameWithoutChangingState(t *testing.T) {
base := time.Date(2026, 9, 4, 10, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
state := newHydrogenV35RealtimeState(hydrogenV35TestSample(base, 10, 20, 100, 80, 2), HydrogenRealtimeParameters{})
before := state.SampleCount
if state.add(hydrogenV35TestSample(base, 9, 19, 101, 79, 2)) {
t.Fatal("same-time frame was accepted")
}
if state.SampleCount != before || state.LastMassKG != 10 {
t.Fatalf("late frame changed state: %#v", state)
}
}
func TestHydrogenV35RealtimeNoDataDoesNotExposePer100KMRates(t *testing.T) {
base := time.Date(2026, 9, 4, 10, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
state := newHydrogenV35RealtimeState(hydrogenV35TestSample(base, 10, 20, 100, 80, 2), HydrogenRealtimeParameters{BatteryCapacityKWh: 80})
state.add(hydrogenV35TestSample(base.Add(time.Minute), 9.5, 19, 105, 78, 2))
state.add(hydrogenV35TestSample(base.Add(2*time.Minute), 9, 18, 109, 76, 2))
projection := state.projection()
if projection.QualityStatus != "NO_DATA" {
t.Fatalf("quality=%s, want NO_DATA", projection.QualityStatus)
}
if projection.ConsumptionPer100KM != nil || projection.CorrectedPer100KM != nil {
t.Fatalf("NO_DATA projection exposes per-100km rates: %#v", projection)
}
}
func TestLoadHydrogenRealtimeParametersKeepsNewestEffectiveRow(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectQuery(regexp.QuoteMeta("SELECT UPPER(TRIM(vin)),battery_capacity_kwh,hydrogen_energy_kwh_per_kg")).
WithArgs("2026-09-04", "2026-09-04").
WillReturnRows(sqlmock.NewRows([]string{"vin", "battery_capacity_kwh", "hydrogen_energy_kwh_per_kg"}).
AddRow("LA9GG64L0NBAF4175", 80.0, 15.8).
AddRow("LA9GG64L0NBAF4175", 75.0, 16.0))
values, err := LoadHydrogenRealtimeParameters(context.Background(), db, "2026-09-04")
if err != nil {
t.Fatal(err)
}
if got := values["LA9GG64L0NBAF4175"]; got.BatteryCapacityKWh != 80 || got.HydrogenEnergyKWhKG != 15.8 {
t.Fatalf("parameters=%#v", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestHydrogenV35RealtimeSQLMarksPreviewWithoutOverwritingFinalSemantics(t *testing.T) {
for _, want := range []string{
"calculation_phase", "'PRELIMINARY'", "algorithm_version=VALUES(algorithm_version)",
"soc_balanced_consumption_kg", "consumption_kg_per_100km",
} {
if !regexp.MustCompile(regexp.QuoteMeta(want)).MatchString(projectHydrogenV35RealtimeSQL) {
t.Fatalf("realtime projection SQL missing %q", want)
}
}
if got := strings.Count(strings.Split(projectHydrogenV35RealtimeSQL, "ON DUPLICATE KEY UPDATE")[0], "?"); got != 25 {
t.Fatalf("realtime projection placeholders=%d, want 25", got)
}
}