功能:完善里程边界与氢耗流式统计
This commit is contained in:
@@ -2,8 +2,10 @@ package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -47,6 +49,29 @@ func TestHydrogenStreamSampleUsesPressureTemperatureAndCapacity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenStreamMarksInactiveFuelCellIneligible(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
eventTime := time.Date(2026, 8, 15, 13, 31, 7, 0, loc)
|
||||
env := pressureEnvelope(eventTime)
|
||||
env.Fields[envelope.FieldFuelCellWorkMode] = 0
|
||||
sample, reason, ok := HydrogenStreamSampleFromEnvelope(env, loc, eventTime, 520)
|
||||
if !ok || reason != "" || !sample.FuelCellStateKnown || sample.FuelCellActive || sample.ConsumptionEligible {
|
||||
t.Fatalf("inactive sample classification=%#v reason=%q ok=%v", sample, reason, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenStreamFallsBackToFuelCellCurrent(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
eventTime := time.Date(2026, 8, 15, 13, 31, 7, 0, loc)
|
||||
env := pressureEnvelope(eventTime)
|
||||
delete(env.Fields, envelope.FieldFuelCellWorkMode)
|
||||
env.Fields["fuel_cell_current_a"] = 0.2
|
||||
sample, reason, ok := HydrogenStreamSampleFromEnvelope(env, loc, eventTime, 520)
|
||||
if !ok || reason != "" || !sample.FuelCellStateKnown || sample.FuelCellActive || sample.ConsumptionEligible {
|
||||
t.Fatalf("current fallback classification=%#v reason=%q ok=%v", sample, reason, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenStreamRejectsMissingCapacity(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
eventTime := time.Date(2026, 7, 21, 10, 30, 0, 0, loc)
|
||||
@@ -56,6 +81,20 @@ func TestHydrogenStreamRejectsMissingCapacity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenStreamRejectsInvalidPressureTemperaturePlaceholders(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
eventTime := time.Date(2026, 8, 12, 8, 11, 16, 0, loc)
|
||||
for _, fields := range []struct{ pressure, temperature float64 }{{0, 30}, {22.8, -40}} {
|
||||
env := pressureEnvelope(eventTime)
|
||||
env.Fields["gb32960.fuel_cell.max_hydrogen_pressure_mpa"] = fields.pressure
|
||||
env.Fields["gb32960.fuel_cell.max_hydrogen_temperature_c"] = fields.temperature
|
||||
_, reason, ok := HydrogenStreamSampleFromEnvelope(env, loc, eventTime, 520)
|
||||
if ok || reason != "invalid_pressure_temperature" {
|
||||
t.Fatalf("placeholder pressure=%v temperature=%v accepted: ok=%v reason=%q", fields.pressure, fields.temperature, ok, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNISTHydrogenDensityValidationPoint(t *testing.T) {
|
||||
density, ok := HydrogenDensityKGPerM3(10, 26.85)
|
||||
if !ok || math.Abs(density-7.625) > 0.01 {
|
||||
@@ -63,6 +102,144 @@ func TestNISTHydrogenDensityValidationPoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func hydrogenSegmentTestSample(at time.Time, mass float64, source string, active bool) HydrogenStreamSample {
|
||||
return HydrogenStreamSample{
|
||||
VIN: "LA9GG64L0NBAF4175", Date: at.Format("2006-01-02"), SourceEndpoint: source,
|
||||
EventID: at.Format(time.RFC3339Nano), EventTime: at, MassKG: mass,
|
||||
TankCapacityLiters: 520, PressureMPa: mass, TemperatureC: 30,
|
||||
NoiseKG: 0.05, RefuelThresholdKG: 1,
|
||||
FuelCellActive: active, FuelCellStateKnown: true, ConsumptionEligible: active,
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenSegmentStreamUsesEndpointMediansAcrossSources(t *testing.T) {
|
||||
base := time.Date(2026, 8, 19, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
state := newHydrogenSegmentStreamState(hydrogenSegmentTestSample(base, 10, "source-a", true), nil)
|
||||
for index := 1; index < 10; index++ {
|
||||
mass := 10.0
|
||||
if index >= 5 {
|
||||
mass = 9.6
|
||||
}
|
||||
source := "source-a"
|
||||
if index >= 6 {
|
||||
source = "source-b"
|
||||
}
|
||||
if !state.add(hydrogenSegmentTestSample(base.Add(time.Duration(index)*time.Second), mass, source, true)) {
|
||||
t.Fatalf("sample %d rejected", index)
|
||||
}
|
||||
}
|
||||
quality, reason := state.quality()
|
||||
if state.projectedConsumptionKG() != 0.4 || quality != "OK" || reason != "" || state.SampleCount != 10 {
|
||||
t.Fatalf("state=%#v consumption=%.3f quality=%s reason=%q", state, state.projectedConsumptionKG(), quality, reason)
|
||||
}
|
||||
if state.SourceEndpoint != "source-b" || len(state.Segment.First) != 5 || len(state.Segment.Tail) != 5 {
|
||||
t.Fatalf("endpoint/window state=%#v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenSegmentStreamFinalizesOnInactiveFrame(t *testing.T) {
|
||||
base := time.Now()
|
||||
state := newHydrogenSegmentStreamState(hydrogenSegmentTestSample(base, 10, "source-a", true), nil)
|
||||
for index := 1; index < 10; index++ {
|
||||
mass := 10.0
|
||||
if index >= 5 {
|
||||
mass = 9.6
|
||||
}
|
||||
state.add(hydrogenSegmentTestSample(base.Add(time.Duration(index)*time.Second), mass, "source-a", true))
|
||||
}
|
||||
state.add(hydrogenSegmentTestSample(base.Add(10*time.Second), 9.5, "source-a", false))
|
||||
if math.Abs(state.FinalizedConsumptionKG-0.4) > 0.0001 || state.Segment.Count != 0 || state.QualifiedSegmentCount != 1 {
|
||||
t.Fatalf("state=%#v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenSegmentStreamIgnoresOutOfOrderAndKeepsBoundedWindow(t *testing.T) {
|
||||
base := time.Now()
|
||||
state := newHydrogenSegmentStreamState(hydrogenSegmentTestSample(base, 20, "source-a", true), nil)
|
||||
if state.add(hydrogenSegmentTestSample(base, 19, "source-b", true)) {
|
||||
t.Fatal("same-time duplicate was accepted")
|
||||
}
|
||||
for index := 1; index <= 10000; index++ {
|
||||
state.add(hydrogenSegmentTestSample(base.Add(time.Duration(index)*time.Second), 20-float64(index)/20000, "source-b", true))
|
||||
}
|
||||
if len(state.Segment.First) != 5 || len(state.Segment.Tail) != 5 || state.SampleCount != 10001 {
|
||||
t.Fatalf("first=%d tail=%d samples=%d", len(state.Segment.First), len(state.Segment.Tail), state.SampleCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenSegmentStreamInactiveDropDoesNotConsume(t *testing.T) {
|
||||
base := time.Now()
|
||||
state := newHydrogenSegmentStreamState(hydrogenSegmentTestSample(base, 10, "source-a", false), nil)
|
||||
for index := 1; index < 20; index++ {
|
||||
state.add(hydrogenSegmentTestSample(base.Add(time.Duration(index)*time.Second), 10-float64(index)/10, "source-a", false))
|
||||
}
|
||||
quality, _ := state.quality()
|
||||
if state.projectedConsumptionKG() != 0 || state.RemainingMassKG != 10 || quality != "NO_DATA" {
|
||||
t.Fatalf("state=%#v quality=%s", state, quality)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenSegmentStreamSplitsAndAccumulatesRefuelCycles(t *testing.T) {
|
||||
base := time.Now()
|
||||
state := newHydrogenSegmentStreamState(hydrogenSegmentTestSample(base, 10, "source-a", true), nil)
|
||||
for index := 1; index < 10; index++ {
|
||||
mass := 10.0
|
||||
if index >= 5 {
|
||||
mass = 9.6
|
||||
}
|
||||
state.add(hydrogenSegmentTestSample(base.Add(time.Duration(index)*time.Second), mass, "source-a", true))
|
||||
}
|
||||
state.add(hydrogenSegmentTestSample(base.Add(10*time.Second), 12, "source-b", true))
|
||||
for index := 11; index < 20; index++ {
|
||||
mass := 12.0
|
||||
if index >= 15 {
|
||||
mass = 11.7
|
||||
}
|
||||
state.add(hydrogenSegmentTestSample(base.Add(time.Duration(index)*time.Second), mass, "source-b", true))
|
||||
}
|
||||
if got := state.projectedConsumptionKG(); math.Abs(got-0.7) > 0.0001 {
|
||||
t.Fatalf("consumption=%.3f state=%#v", got, state)
|
||||
}
|
||||
if state.QualifiedSegmentCount != 1 || state.RefuelCount != 1 || state.Segment.Count != 10 {
|
||||
t.Fatalf("state=%#v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenSegmentStreamFiltersAbnormalDrop(t *testing.T) {
|
||||
base := time.Now()
|
||||
state := newHydrogenSegmentStreamState(hydrogenSegmentTestSample(base, 30, "source-a", true), nil)
|
||||
state.add(hydrogenSegmentTestSample(base.Add(time.Second), 5, "source-a", true))
|
||||
for index := 2; index < 12; index++ {
|
||||
state.add(hydrogenSegmentTestSample(base.Add(time.Duration(index)*time.Second), 5, "source-a", true))
|
||||
}
|
||||
quality, _ := state.quality()
|
||||
if state.AbnormalDropCount != 1 || state.projectedConsumptionKG() != 0 || quality != "SUSPECT" {
|
||||
t.Fatalf("state=%#v quality=%s", state, quality)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenSegmentStreamSeedsDayEndBaselineWithoutRecounting(t *testing.T) {
|
||||
base := time.Now()
|
||||
baseline := &hydrogenStreamBaseline{
|
||||
SourceEndpoint: "source-a", ConsumptionKG: 3.2, FirstMassKG: 14,
|
||||
LastMassKG: 10, SampleCount: 100, RefuelCount: 1, QualityStatus: "OK",
|
||||
}
|
||||
state := newHydrogenSegmentStreamState(hydrogenSegmentTestSample(base, 10, "source-b", true), baseline)
|
||||
for index := 1; index < 10; index++ {
|
||||
mass := 10.0
|
||||
if index >= 5 {
|
||||
mass = 9.8
|
||||
}
|
||||
state.add(hydrogenSegmentTestSample(base.Add(time.Duration(index)*time.Second), mass, "source-b", true))
|
||||
}
|
||||
if got := state.projectedConsumptionKG(); math.Abs(got-3.4) > 0.0001 {
|
||||
t.Fatalf("consumption=%.3f state=%#v", got, state)
|
||||
}
|
||||
if state.FirstMassKG != 14 || state.SampleCount != 110 || state.RefuelCount != 1 {
|
||||
t.Fatalf("state=%#v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendHydrogenStreamPersistsPressureEvidenceAtomically(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
@@ -72,14 +249,20 @@ func TestAppendHydrogenStreamPersistsPressureEvidenceAtomically(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
eventTime := time.Date(2026, 7, 21, 10, 30, 0, 0, loc)
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_hydrogen_stream_state")).
|
||||
WithArgs("LA9GG64L0NBAF4175", "2026-07-21", "10.0.0.1:9000", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
|
||||
2050.0, 6.0, 6.0, 44.0, 44.0, eventTime, eventTime, "event-1",
|
||||
sqlmock.AnyArg(), 20.0, sqlmock.AnyArg(), 20.0, 20.0, 20.0,
|
||||
sqlmock.AnyArg(), sqlmock.AnyArg(), 20.0).
|
||||
mock.ExpectQuery(regexp.QuoteMeta("SELECT state_json")).
|
||||
WithArgs("LA9GG64L0NBAF4175", "2026-07-21").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectQuery(regexp.QuoteMeta("SELECT source_endpoint,consumption_kg,first_mass_kg,last_mass_kg,")).
|
||||
WithArgs("LA9GG64L0NBAF4175", "2026-07-21").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_hydrogen_segment_stream_state")).
|
||||
WithArgs("LA9GG64L0NBAF4175", "2026-07-21", "10.0.0.1:9000",
|
||||
sqlmock.AnyArg(), sqlmock.AnyArg(), int64(1), int64(0), int64(0), int64(0), int64(0),
|
||||
sqlmock.AnyArg(), eventTime, "event-1", sqlmock.AnyArg(), "NO_DATA", "有效车载氢量样本不足2条").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy")).
|
||||
WithArgs("LA9GG64L0NBAF4175", "2026-07-21").
|
||||
WithArgs("LA9GG64L0NBAF4175", "2026-07-21", "10.0.0.1:9000", 0.0,
|
||||
sqlmock.AnyArg(), sqlmock.AnyArg(), int64(1), int64(0), "NO_DATA", "有效车载氢量样本不足2条").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
@@ -104,7 +287,9 @@ func TestWriterAppendWithResultUsesInMemoryCapacity(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
eventTime := time.Now().In(loc).Truncate(time.Millisecond)
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_hydrogen_stream_state")).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectQuery(regexp.QuoteMeta("SELECT state_json")).WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectQuery(regexp.QuoteMeta("SELECT source_endpoint,consumption_kg,first_mass_kg,last_mass_kg,")).WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_hydrogen_segment_stream_state")).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy")).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
@@ -122,17 +307,17 @@ func TestWriterAppendWithResultUsesInMemoryCapacity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenStreamSQLGuardsDuplicatesAndRecordsPressure(t *testing.T) {
|
||||
func TestHydrogenStreamSQLUsesVINDateStateAndSegmentMethod(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"VALUES(last_event_time)>last_event_time",
|
||||
"cycle_min_mass_kg-VALUES(last_mass_kg)>?",
|
||||
"tank_capacity_l",
|
||||
"last_pressure_mpa",
|
||||
"last_temperature_c",
|
||||
"PRESSURE_NIST",
|
||||
"PRIMARY KEY (vin, stat_date)",
|
||||
"state_json JSON NOT NULL",
|
||||
"PRESSURE_NIST_SEGMENT_MEDIAN_5",
|
||||
} {
|
||||
if !regexp.MustCompile(regexp.QuoteMeta(want)).MatchString(upsertHydrogenStreamStateSQL) {
|
||||
t.Fatalf("stream upsert missing %q", want)
|
||||
if !strings.Contains(HydrogenSegmentStreamStateTableSQL, want) {
|
||||
t.Fatalf("segment stream schema missing %q", want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(selectHydrogenSegmentStreamStateSQL, "FOR UPDATE") {
|
||||
t.Fatal("segment state must be locked before update")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user