feat: 推广 V3.5 实时氢耗并完善导出
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
// mileage-correction writes a confirmed daily terminal odometer correction.
|
||||
// It is intentionally explicit: callers must provide the VIN, natural day and
|
||||
// confirmed cumulative mileage; it never derives values from a guess.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
|
||||
)
|
||||
|
||||
func main() {
|
||||
vin := flag.String("vin", "", "VIN")
|
||||
date := flag.String("date", "", "natural day, yyyy-MM-dd")
|
||||
total := flag.Float64("total-km", 0, "confirmed cumulative mileage, km")
|
||||
flag.Parse()
|
||||
if strings.TrimSpace(*vin) == "" || strings.TrimSpace(*date) == "" || *total <= 0 {
|
||||
log.Fatal("vin, date and positive total-km are required")
|
||||
}
|
||||
dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN"))
|
||||
if dsn == "" {
|
||||
log.Fatal("MYSQL_DSN is required")
|
||||
}
|
||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
eventTime, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(*date)+" 23:59:59", loc)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
ctx := context.Background()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
candidate := stats.SourceMileageSample{
|
||||
VIN: strings.TrimSpace(*vin),
|
||||
StatDate: strings.TrimSpace(*date),
|
||||
Protocol: envelope.ProtocolGB32960,
|
||||
SourceKey: "GB32960:manual-correction@manual",
|
||||
SourceIP: "manual",
|
||||
SourceEndpoint: "manual-correction",
|
||||
FirstTotalKM: *total,
|
||||
LatestTotalKM: *total,
|
||||
DailyKM: 0,
|
||||
SampleCount: 1,
|
||||
FirstEventTime: eventTime,
|
||||
LatestEventTime: eventTime,
|
||||
QualityStatus: stats.QualityOK,
|
||||
QualityReason: "manual_correction_confirmed_total",
|
||||
}
|
||||
if err := stats.UpsertSourceMileage(ctx, db, candidate); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := stats.ProjectDailyMileage(ctx, db, candidate.VIN, candidate.StatDate, candidate.Protocol); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// Keep both cumulative fields aligned. day_end_total_mileage_km is the API
|
||||
// field of record, while latest_total_mileage_km remains useful to internal
|
||||
// readers and must not retain the rejected reset value.
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
UPDATE vehicle_daily_mileage
|
||||
SET latest_total_mileage_km=?, day_end_total_mileage_km=?
|
||||
WHERE vin=? AND stat_date=? AND protocol=?`,
|
||||
*total, *total, candidate.VIN, candidate.StatDate, string(candidate.Protocol)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
var daily, latest, dayEnd float64
|
||||
if err := db.QueryRowContext(ctx, `SELECT daily_mileage_km,latest_total_mileage_km,day_end_total_mileage_km FROM vehicle_daily_mileage WHERE vin=? AND stat_date=? AND protocol=?`, candidate.VIN, candidate.StatDate, string(candidate.Protocol)).Scan(&daily, &latest, &dayEnd); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("corrected vin=%s date=%s protocol=%s daily_km=%.3f latest_total_km=%.3f day_end_total_km=%.3f\n", candidate.VIN, candidate.StatDate, candidate.Protocol, daily, latest, dayEnd)
|
||||
}
|
||||
@@ -82,6 +82,11 @@ func main() {
|
||||
} else {
|
||||
registry.SetGauge("vehicle_stat_hydrogen_capacity_cache_entries", nil, float64(loaded))
|
||||
}
|
||||
if loaded, err := writer.ReloadHydrogenRealtimeParameters(ctx); err != nil {
|
||||
logger.Warn("hydrogen V3.5 parameter cache load failed", "error", err)
|
||||
} else {
|
||||
registry.SetGauge("vehicle_stat_hydrogen_v35_parameter_cache_entries", nil, float64(loaded))
|
||||
}
|
||||
if cfg.NormalizePlatformSourcesOnStart {
|
||||
statDate := time.Now().In(cfg.Location).Format("2006-01-02")
|
||||
normalizeCtx, cancel := context.WithTimeout(ctx, cfg.NormalizePlatformSourcesTimeout)
|
||||
@@ -191,6 +196,13 @@ func syncHydrogenCapacities(ctx context.Context, logger interface {
|
||||
}
|
||||
registry.IncCounter("vehicle_stat_hydrogen_capacity_sync_total", metrics.Labels{"status": "ok"})
|
||||
registry.SetGauge("vehicle_stat_hydrogen_capacity_cache_entries", nil, float64(loaded))
|
||||
parameterCount, parameterErr := writer.ReloadHydrogenRealtimeParameters(syncCtx)
|
||||
if parameterErr != nil {
|
||||
registry.IncCounter("vehicle_stat_hydrogen_energy_parameter_sync_total", metrics.Labels{"status": "cache_error"})
|
||||
logger.Warn("hydrogen V3.5 parameter cache reload failed", "error", parameterErr)
|
||||
} else {
|
||||
registry.SetGauge("vehicle_stat_hydrogen_v35_parameter_cache_entries", nil, float64(parameterCount))
|
||||
}
|
||||
metrics.RecordLastActivity(registry, "vehicle_stat_hydrogen_capacity_last_sync_unix_seconds", nil)
|
||||
logger.Info("hydrogen tank capacities synchronized", "source_schema", cfg.HydrogenCapacitySourceSchema, "read", result.Read, "written", result.Written, "deactivated", result.Deactivated, "cache_entries", loaded)
|
||||
}
|
||||
|
||||
@@ -30,31 +30,32 @@ type Execer interface {
|
||||
}
|
||||
|
||||
type Writer struct {
|
||||
exec Execer
|
||||
query Queryer
|
||||
loc *time.Location
|
||||
sourceTouchInterval time.Duration
|
||||
projectionInterval time.Duration
|
||||
cacheRetention time.Duration
|
||||
cacheCleanupInterval time.Duration
|
||||
baselineMissTTL time.Duration
|
||||
baselineHitTTL time.Duration
|
||||
maxCacheEntries int
|
||||
lastCacheCleanup time.Time
|
||||
lastCacheCleanupStats cacheCleanupStats
|
||||
cacheEvictions cacheCleanupStats
|
||||
mu sync.Mutex
|
||||
lastTotalMileage map[string]float64
|
||||
lastSourceSeen map[string]time.Time
|
||||
lastProjection map[string]projectionCacheEntry
|
||||
pendingProjection map[string]pendingProjectionEntry
|
||||
baselineCache map[string]sourceBaselineCacheEntry
|
||||
mileageKeysByPrefix map[string]map[string]struct{}
|
||||
projectionKeysByPrefix map[string]map[string]struct{}
|
||||
baselineKeysByPrefix map[string]map[string]struct{}
|
||||
lastGPSAccumulation map[string]time.Time
|
||||
hydrogenTankCapacities map[string]float64
|
||||
gpsAccumulationInterval time.Duration
|
||||
exec Execer
|
||||
query Queryer
|
||||
loc *time.Location
|
||||
sourceTouchInterval time.Duration
|
||||
projectionInterval time.Duration
|
||||
cacheRetention time.Duration
|
||||
cacheCleanupInterval time.Duration
|
||||
baselineMissTTL time.Duration
|
||||
baselineHitTTL time.Duration
|
||||
maxCacheEntries int
|
||||
lastCacheCleanup time.Time
|
||||
lastCacheCleanupStats cacheCleanupStats
|
||||
cacheEvictions cacheCleanupStats
|
||||
mu sync.Mutex
|
||||
lastTotalMileage map[string]float64
|
||||
lastSourceSeen map[string]time.Time
|
||||
lastProjection map[string]projectionCacheEntry
|
||||
pendingProjection map[string]pendingProjectionEntry
|
||||
baselineCache map[string]sourceBaselineCacheEntry
|
||||
mileageKeysByPrefix map[string]map[string]struct{}
|
||||
projectionKeysByPrefix map[string]map[string]struct{}
|
||||
baselineKeysByPrefix map[string]map[string]struct{}
|
||||
lastGPSAccumulation map[string]time.Time
|
||||
hydrogenTankCapacities map[string]float64
|
||||
hydrogenEnergyParameters map[string]HydrogenRealtimeParameters
|
||||
gpsAccumulationInterval time.Duration
|
||||
}
|
||||
|
||||
type MetricSample struct {
|
||||
@@ -151,26 +152,27 @@ func NewWriter(exec Execer, loc *time.Location) *Writer {
|
||||
loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
}
|
||||
writer := &Writer{
|
||||
exec: exec,
|
||||
loc: loc,
|
||||
sourceTouchInterval: time.Minute,
|
||||
projectionInterval: 15 * time.Second,
|
||||
cacheRetention: defaultCacheRetention,
|
||||
cacheCleanupInterval: defaultCacheCleanupInterval,
|
||||
baselineMissTTL: defaultBaselineMissTTL,
|
||||
baselineHitTTL: defaultBaselineHitTTL,
|
||||
maxCacheEntries: defaultMaxCacheEntries,
|
||||
lastTotalMileage: map[string]float64{},
|
||||
lastSourceSeen: map[string]time.Time{},
|
||||
lastProjection: map[string]projectionCacheEntry{},
|
||||
pendingProjection: map[string]pendingProjectionEntry{},
|
||||
baselineCache: map[string]sourceBaselineCacheEntry{},
|
||||
mileageKeysByPrefix: map[string]map[string]struct{}{},
|
||||
projectionKeysByPrefix: map[string]map[string]struct{}{},
|
||||
baselineKeysByPrefix: map[string]map[string]struct{}{},
|
||||
lastGPSAccumulation: map[string]time.Time{},
|
||||
hydrogenTankCapacities: map[string]float64{},
|
||||
gpsAccumulationInterval: defaultGPSAccumulationInterval,
|
||||
exec: exec,
|
||||
loc: loc,
|
||||
sourceTouchInterval: time.Minute,
|
||||
projectionInterval: 15 * time.Second,
|
||||
cacheRetention: defaultCacheRetention,
|
||||
cacheCleanupInterval: defaultCacheCleanupInterval,
|
||||
baselineMissTTL: defaultBaselineMissTTL,
|
||||
baselineHitTTL: defaultBaselineHitTTL,
|
||||
maxCacheEntries: defaultMaxCacheEntries,
|
||||
lastTotalMileage: map[string]float64{},
|
||||
lastSourceSeen: map[string]time.Time{},
|
||||
lastProjection: map[string]projectionCacheEntry{},
|
||||
pendingProjection: map[string]pendingProjectionEntry{},
|
||||
baselineCache: map[string]sourceBaselineCacheEntry{},
|
||||
mileageKeysByPrefix: map[string]map[string]struct{}{},
|
||||
projectionKeysByPrefix: map[string]map[string]struct{}{},
|
||||
baselineKeysByPrefix: map[string]map[string]struct{}{},
|
||||
lastGPSAccumulation: map[string]time.Time{},
|
||||
hydrogenTankCapacities: map[string]float64{},
|
||||
hydrogenEnergyParameters: map[string]HydrogenRealtimeParameters{},
|
||||
gpsAccumulationInterval: defaultGPSAccumulationInterval,
|
||||
}
|
||||
if query, ok := exec.(Queryer); ok {
|
||||
writer.query = query
|
||||
@@ -197,6 +199,25 @@ func (w *Writer) HydrogenTankCapacityLiters(vin string) (float64, bool) {
|
||||
return capacity, ok
|
||||
}
|
||||
|
||||
func (w *Writer) ReloadHydrogenRealtimeParameters(ctx context.Context) (int, error) {
|
||||
date := time.Now().In(w.loc).Format("2006-01-02")
|
||||
parameters, err := LoadHydrogenRealtimeParameters(ctx, w.query, date)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.hydrogenEnergyParameters = parameters
|
||||
w.mu.Unlock()
|
||||
return len(parameters), nil
|
||||
}
|
||||
|
||||
func (w *Writer) HydrogenRealtimeParameters(vin string) HydrogenRealtimeParameters {
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return normalizeHydrogenRealtimeParameters(w.hydrogenEnergyParameters[vin])
|
||||
}
|
||||
|
||||
func (w *Writer) SetSourceTouchInterval(interval time.Duration) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -315,6 +336,11 @@ func (w *Writer) EnsureSchema(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, statement := range HydrogenRealtimeAlterSQL {
|
||||
if _, err := w.exec.ExecContext(ctx, statement); err != nil && !isIgnorableSchemaChangeError(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -333,7 +359,8 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
|
||||
w.maybeCleanupCaches(seenAt)
|
||||
identity, hasSource := NewSourceIdentityFromEnvelope(env)
|
||||
capacityLiters, _ := w.HydrogenTankCapacityLiters(env.VIN)
|
||||
hydrogen, err := AppendHydrogenStream(ctx, w.exec, env, w.loc, time.Now(), capacityLiters)
|
||||
hydrogenParams := w.HydrogenRealtimeParameters(env.VIN)
|
||||
hydrogen, err := AppendHydrogenV35Realtime(ctx, w.exec, env, w.loc, time.Now(), capacityLiters, hydrogenParams)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
@@ -1950,7 +1950,7 @@ func TestWriterEnsuresSchemaAndUpsertsDailyMileage(t *testing.T) {
|
||||
t.Fatalf("Append() error = %v", err)
|
||||
}
|
||||
|
||||
schemaCalls := 8 + len(DailyMileageAlterSQL)
|
||||
schemaCalls := 8 + len(DailyMileageAlterSQL) + len(HydrogenRealtimeAlterSQL)
|
||||
if len(exec.calls) != schemaCalls+5 {
|
||||
t.Fatalf("exec calls = %d", len(exec.calls))
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ const HydrogenSegmentStreamStateTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_o
|
||||
last_event_time DATETIME(3) NOT NULL,
|
||||
last_event_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
state_json JSON NOT NULL,
|
||||
calculation_method VARCHAR(48) NOT NULL DEFAULT 'PRESSURE_NIST_SEGMENT_MEDIAN_5',
|
||||
calculation_method VARCHAR(80) NOT NULL DEFAULT 'PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5',
|
||||
quality_status VARCHAR(24) NOT NULL DEFAULT 'NO_DATA',
|
||||
quality_reason VARCHAR(255) NOT NULL DEFAULT '有效工作段关键帧不足10条',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
@@ -116,6 +116,18 @@ type HydrogenStreamSample struct {
|
||||
FuelCellActive bool
|
||||
FuelCellStateKnown bool
|
||||
ConsumptionEligible bool
|
||||
MileageKM float64
|
||||
MileageKnown bool
|
||||
SOCPercent float64
|
||||
SOCKnown bool
|
||||
VehicleState int
|
||||
VehicleStateKnown bool
|
||||
ChargeState int
|
||||
ChargeStateKnown bool
|
||||
RunningMode int
|
||||
RunningModeKnown bool
|
||||
FuelCellPowerKW float64
|
||||
FuelCellPowerKnown bool
|
||||
}
|
||||
|
||||
type HydrogenStreamResult struct {
|
||||
@@ -453,6 +465,26 @@ func HydrogenStreamSampleFromEnvelope(env envelope.FrameEnvelope, loc *time.Loca
|
||||
fuelCellStateKnown = true
|
||||
}
|
||||
}
|
||||
mileageKM, mileageKnown := firstHydrogenNumber(env.Fields, []string{
|
||||
envelope.FieldTotalMileageKM, "gb32960.vehicle.total_mileage_km",
|
||||
})
|
||||
socPercent, socKnown := firstHydrogenNumber(env.Fields, []string{
|
||||
envelope.FieldSOCPercent, "gb32960.vehicle.soc_percent",
|
||||
})
|
||||
vehicleStateValue, vehicleStateKnown := firstHydrogenNumber(env.Fields, []string{
|
||||
"vehicle_status", "gb32960.vehicle.vehicle_status",
|
||||
})
|
||||
chargeStateValue, chargeStateKnown := firstHydrogenNumber(env.Fields, []string{
|
||||
"charge_status", "gb32960.vehicle.charge_status",
|
||||
})
|
||||
runningModeValue, runningModeKnown := firstHydrogenNumber(env.Fields, []string{
|
||||
envelope.FieldVehicleRunningMode, "gb32960.vehicle.running_mode",
|
||||
})
|
||||
fuelCellVoltage, voltageKnown := firstHydrogenNumber(env.Fields, []string{
|
||||
"fuel_cell_voltage_v", "gb32960.fuel_cell.fuel_cell_voltage_v",
|
||||
})
|
||||
fuelCellCurrent, currentKnown := firstHydrogenNumber(env.Fields, hydrogenCurrentFieldKeys)
|
||||
fuelCellPowerKnown := voltageKnown && currentKnown && fuelCellVoltage >= 0 && fuelCellCurrent >= 0
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
}
|
||||
@@ -475,6 +507,12 @@ func HydrogenStreamSampleFromEnvelope(env envelope.FrameEnvelope, loc *time.Loca
|
||||
NoiseKG: noiseKG, RefuelThresholdKG: refuelThresholdKG,
|
||||
FuelCellActive: fuelCellActive, FuelCellStateKnown: fuelCellStateKnown,
|
||||
ConsumptionEligible: !fuelCellStateKnown || fuelCellActive,
|
||||
MileageKM: mileageKM, MileageKnown: mileageKnown,
|
||||
SOCPercent: socPercent, SOCKnown: socKnown && socPercent >= 0 && socPercent <= 100,
|
||||
VehicleState: int(vehicleStateValue), VehicleStateKnown: vehicleStateKnown,
|
||||
ChargeState: int(chargeStateValue), ChargeStateKnown: chargeStateKnown,
|
||||
RunningMode: int(runningModeValue), RunningModeKnown: runningModeKnown,
|
||||
FuelCellPowerKW: fuelCellVoltage * fuelCellCurrent / 1000, FuelCellPowerKnown: fuelCellPowerKnown,
|
||||
}, "", true
|
||||
}
|
||||
|
||||
@@ -647,13 +685,13 @@ const insertHydrogenSegmentStreamStateSQL = `INSERT INTO vehicle_open_hydrogen_s
|
||||
vin,stat_date,source_endpoint,finalized_consumption_kg,projected_consumption_kg,
|
||||
sample_count,refuel_count,abnormal_drop_count,eligible_interval_count,qualified_segment_count,
|
||||
last_mass_kg,last_event_time,last_event_id,state_json,calculation_method,quality_status,quality_reason
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'PRESSURE_NIST_SEGMENT_MEDIAN_5',?,?)`
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5',?,?)`
|
||||
|
||||
const updateHydrogenSegmentStreamStateSQL = `UPDATE vehicle_open_hydrogen_segment_stream_state
|
||||
SET source_endpoint=?,finalized_consumption_kg=?,projected_consumption_kg=?,sample_count=?,
|
||||
refuel_count=?,abnormal_drop_count=?,eligible_interval_count=?,qualified_segment_count=?,
|
||||
last_mass_kg=?,last_event_time=?,last_event_id=?,state_json=?,
|
||||
calculation_method='PRESSURE_NIST_SEGMENT_MEDIAN_5',quality_status=?,quality_reason=?
|
||||
calculation_method='PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5',quality_status=?,quality_reason=?
|
||||
WHERE vin=? AND stat_date=?`
|
||||
|
||||
const projectHydrogenSegmentStreamDailySQL = `INSERT INTO vehicle_open_daily_energy(
|
||||
|
||||
@@ -49,6 +49,29 @@ func TestHydrogenStreamSampleUsesPressureTemperatureAndCapacity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenStreamSampleExtractsV35Inputs(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
eventTime := time.Date(2026, 9, 4, 10, 30, 0, 0, loc)
|
||||
env := pressureEnvelope(eventTime)
|
||||
env.Fields[envelope.FieldTotalMileageKM] = 12005.6
|
||||
env.Fields[envelope.FieldSOCPercent] = 72.5
|
||||
env.Fields[envelope.FieldVehicleRunningMode] = 2
|
||||
env.Fields[envelope.FieldFuelCellWorkMode] = 2
|
||||
env.Fields["vehicle_status"] = 1
|
||||
env.Fields["charge_status"] = 3
|
||||
env.Fields["fuel_cell_voltage_v"] = 480.0
|
||||
env.Fields["fuel_cell_current_a"] = 100.0
|
||||
sample, reason, ok := HydrogenStreamSampleFromEnvelope(env, loc, eventTime, 520)
|
||||
if !ok || reason != "" {
|
||||
t.Fatalf("sample rejected: reason=%q", reason)
|
||||
}
|
||||
if !sample.MileageKnown || sample.MileageKM != 12005.6 || !sample.SOCKnown || sample.SOCPercent != 72.5 ||
|
||||
!sample.VehicleStateKnown || sample.VehicleState != 1 || !sample.ChargeStateKnown || sample.ChargeState != 3 ||
|
||||
!sample.RunningModeKnown || sample.RunningMode != 2 || !sample.FuelCellPowerKnown || sample.FuelCellPowerKW != 48 {
|
||||
t.Fatalf("V3.5 fields=%#v", sample)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenStreamMarksInactiveFuelCellIneligible(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
eventTime := time.Date(2026, 8, 15, 13, 31, 7, 0, loc)
|
||||
@@ -288,13 +311,13 @@ func TestWriterAppendWithResultUsesInMemoryCapacity(t *testing.T) {
|
||||
eventTime := time.Now().In(loc).Truncate(time.Millisecond)
|
||||
mock.ExpectBegin()
|
||||
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()
|
||||
|
||||
writer := NewWriter(db, loc)
|
||||
writer.hydrogenTankCapacities["LA9GG64L0NBAF4175"] = 2050
|
||||
writer.hydrogenEnergyParameters["LA9GG64L0NBAF4175"] = HydrogenRealtimeParameters{BatteryCapacityKWh: 80, HydrogenEnergyKWhKG: 16}
|
||||
result, err := writer.AppendWithResult(context.Background(), pressureEnvelope(eventTime))
|
||||
if err != nil {
|
||||
t.Fatalf("AppendWithResult() error = %v", err)
|
||||
@@ -311,7 +334,7 @@ func TestHydrogenStreamSQLUsesVINDateStateAndSegmentMethod(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"PRIMARY KEY (vin, stat_date)",
|
||||
"state_json JSON NOT NULL",
|
||||
"PRESSURE_NIST_SEGMENT_MEDIAN_5",
|
||||
HydrogenV35RealtimeAlgorithmVersion,
|
||||
} {
|
||||
if !strings.Contains(HydrogenSegmentStreamStateTableSQL, want) {
|
||||
t.Fatalf("segment stream schema missing %q", want)
|
||||
|
||||
@@ -0,0 +1,780 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
)
|
||||
|
||||
const (
|
||||
HydrogenV35RealtimeAlgorithmVersion = "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5"
|
||||
hydrogenV35EnergyKWhPerKG = 16.0
|
||||
hydrogenV35MixedMinimumKM = 10.0
|
||||
hydrogenV35MixedConfirmSamples = 3
|
||||
hydrogenV35MixedConfirmWindow = 30 * time.Second
|
||||
hydrogenV35ContinuityGap = 5 * time.Minute
|
||||
hydrogenV35PowerIntegrationGap = 2 * time.Minute
|
||||
hydrogenV35RefuelObservationWindow = 10 * time.Minute
|
||||
hydrogenV35RefuelThermalWindow = 30 * time.Minute
|
||||
hydrogenV35RefuelSustain = 60 * time.Second
|
||||
hydrogenV35RefuelRiseMPa = 3.0
|
||||
hydrogenV35RefuelTemperatureRiseC = 3.0
|
||||
)
|
||||
|
||||
// HydrogenRealtimeParameters are the effective-dated vehicle parameters used by
|
||||
// the V3.5 preview. The physical pressure/temperature result remains available
|
||||
// without a battery capacity, but SOC-balanced results are then marked suspect.
|
||||
type HydrogenRealtimeParameters struct {
|
||||
BatteryCapacityKWh float64 `json:"batteryCapacityKWh"`
|
||||
HydrogenEnergyKWhKG float64 `json:"hydrogenEnergyKWhPerKg"`
|
||||
}
|
||||
|
||||
func normalizeHydrogenRealtimeParameters(value HydrogenRealtimeParameters) HydrogenRealtimeParameters {
|
||||
if value.HydrogenEnergyKWhKG <= 0 {
|
||||
value.HydrogenEnergyKWhKG = hydrogenV35EnergyKWhPerKG
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// LoadHydrogenRealtimeParameters loads the currently effective parameter row
|
||||
// for every VIN. Rows are ordered newest-first so a business override wins over
|
||||
// the oldest automatically synchronized model value.
|
||||
func LoadHydrogenRealtimeParameters(ctx context.Context, query Queryer, date string) (map[string]HydrogenRealtimeParameters, error) {
|
||||
result := map[string]HydrogenRealtimeParameters{}
|
||||
if query == nil {
|
||||
return result, errors.New("hydrogen parameter query is unavailable")
|
||||
}
|
||||
rows, err := query.QueryContext(ctx, `SELECT UPPER(TRIM(vin)),battery_capacity_kwh,hydrogen_energy_kwh_per_kg
|
||||
FROM vehicle_hydrogen_energy_parameter
|
||||
WHERE active=1 AND effective_from<=? AND (effective_to IS NULL OR effective_to>=?)
|
||||
ORDER BY vin,effective_from DESC`, date, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
var value HydrogenRealtimeParameters
|
||||
if err := rows.Scan(&vin, &value.BatteryCapacityKWh, &value.HydrogenEnergyKWhKG); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
if len(vin) != 17 {
|
||||
continue
|
||||
}
|
||||
if _, exists := result[vin]; !exists {
|
||||
result[vin] = normalizeHydrogenRealtimeParameters(value)
|
||||
}
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
type hydrogenV35Point struct {
|
||||
EventID string `json:"eventId"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
MassKG float64 `json:"massKg"`
|
||||
PressureMPa float64 `json:"pressureMpa"`
|
||||
TemperatureC float64 `json:"temperatureC"`
|
||||
NoiseKG float64 `json:"noiseKg"`
|
||||
MileageKM float64 `json:"mileageKm"`
|
||||
MileageKnown bool `json:"mileageKnown"`
|
||||
SOCPercent float64 `json:"socPercent"`
|
||||
SOCKnown bool `json:"socKnown"`
|
||||
RunningMode int `json:"runningMode"`
|
||||
FuelCellActive bool `json:"fuelCellActive"`
|
||||
FuelCellStateKnown bool `json:"fuelCellStateKnown"`
|
||||
FuelCellPowerKW float64 `json:"fuelCellPowerKw"`
|
||||
FuelCellPowerKnown bool `json:"fuelCellPowerKnown"`
|
||||
}
|
||||
|
||||
func hydrogenV35PointFromSample(sample HydrogenStreamSample) hydrogenV35Point {
|
||||
return hydrogenV35Point{
|
||||
EventID: sample.EventID, ObservedAt: sample.EventTime, MassKG: sample.MassKG,
|
||||
PressureMPa: sample.PressureMPa, TemperatureC: sample.TemperatureC, NoiseKG: sample.NoiseKG,
|
||||
MileageKM: sample.MileageKM, MileageKnown: sample.MileageKnown,
|
||||
SOCPercent: sample.SOCPercent, SOCKnown: sample.SOCKnown, RunningMode: sample.RunningMode,
|
||||
FuelCellActive: sample.FuelCellActive, FuelCellStateKnown: sample.FuelCellStateKnown,
|
||||
FuelCellPowerKW: sample.FuelCellPowerKW, FuelCellPowerKnown: sample.FuelCellPowerKnown,
|
||||
}
|
||||
}
|
||||
|
||||
type hydrogenV35CycleState struct {
|
||||
First hydrogenV35Point `json:"first"`
|
||||
Last hydrogenV35Point `json:"last"`
|
||||
HasRun bool `json:"hasRun"`
|
||||
StartsPure bool `json:"startsPure"`
|
||||
MixedLocked bool `json:"mixedLocked"`
|
||||
MixedConfirmed bool `json:"mixedConfirmed"`
|
||||
MixedStart hydrogenV35Point `json:"mixedStart"`
|
||||
MixedCandidate hydrogenV35Point `json:"mixedCandidate"`
|
||||
MixedCandidateSet bool `json:"mixedCandidateSet"`
|
||||
MixedCandidateN int `json:"mixedCandidateCount"`
|
||||
MixedCandidateAt time.Time `json:"mixedCandidateLastAt"`
|
||||
MixedEnergyKWh float64 `json:"mixedEnergyKWh"`
|
||||
CandidateEnergyKWh float64 `json:"candidateEnergyKWh"`
|
||||
}
|
||||
|
||||
type hydrogenV35RefuelCandidate struct {
|
||||
Set bool `json:"set"`
|
||||
Boundary hydrogenV35Point `json:"boundary"`
|
||||
BaselinePressureMPa float64 `json:"baselinePressureMpa"`
|
||||
BaselineMassKG float64 `json:"baselineMassKg"`
|
||||
HighSamples int `json:"highSamples"`
|
||||
PreviousHydrogen hydrogenV35Point `json:"previousHydrogen"`
|
||||
PreviousHydrogenExists bool `json:"previousHydrogenExists"`
|
||||
FirstHydrogenAfter hydrogenV35Point `json:"firstHydrogenAfter"`
|
||||
HydrogenRunsAfter int64 `json:"hydrogenRunsAfter"`
|
||||
}
|
||||
|
||||
type hydrogenV35RealtimeState struct {
|
||||
AlgorithmVersion string `json:"algorithmVersion"`
|
||||
SourceEndpoint string `json:"sourceEndpoint"`
|
||||
SampleCount int64 `json:"sampleCount"`
|
||||
LastEventID string `json:"lastEventId"`
|
||||
LastEventTime time.Time `json:"lastEventTime"`
|
||||
|
||||
Parameters HydrogenRealtimeParameters `json:"parameters"`
|
||||
FirstMassKG float64 `json:"firstMassKg"`
|
||||
LastMassKG float64 `json:"lastMassKg"`
|
||||
|
||||
FirstEffective hydrogenV35Point `json:"firstEffective"`
|
||||
LastEffective hydrogenV35Point `json:"lastEffective"`
|
||||
HasEffective bool `json:"hasEffective"`
|
||||
EffectiveRunCount int64 `json:"effectiveRunCount"`
|
||||
LastBoundaryWasEffective bool `json:"lastBoundaryWasEffective"`
|
||||
|
||||
Cycle hydrogenV35CycleState `json:"cycle"`
|
||||
InitialStateUsed bool `json:"initialStateUsed"`
|
||||
Charging bool `json:"charging"`
|
||||
ChargeCount int64 `json:"chargeCount"`
|
||||
ChargeStartSOC float64 `json:"chargeStartSoc"`
|
||||
ChargeLastSOC float64 `json:"chargeLastSoc"`
|
||||
ChargeSOCKnown bool `json:"chargeSocKnown"`
|
||||
ChargeEnergyKWh float64 `json:"chargeEnergyKWh"`
|
||||
|
||||
CompletedPureKM float64 `json:"completedPureKm"`
|
||||
CompletedMixedKM float64 `json:"completedMixedKm"`
|
||||
CompletedSOCDelta float64 `json:"completedSocDelta"`
|
||||
CompletedMixedEnergyKWh float64 `json:"completedMixedEnergyKWh"`
|
||||
CompletedMixedCycles int64 `json:"completedMixedCycles"`
|
||||
|
||||
HydrogenSegmentStart hydrogenV35Point `json:"hydrogenSegmentStart"`
|
||||
LastHydrogenRun hydrogenV35Point `json:"lastHydrogenRun"`
|
||||
HasHydrogenSegment bool `json:"hasHydrogenSegment"`
|
||||
HydrogenSegmentRuns int64 `json:"hydrogenSegmentRuns"`
|
||||
FinalizedHydrogenKG float64 `json:"finalizedHydrogenKg"`
|
||||
HydrogenSegments int64 `json:"hydrogenSegments"`
|
||||
RefuelCount int64 `json:"refuelCount"`
|
||||
RefuelAmountKG float64 `json:"refuelAmountKg"`
|
||||
RefuelCandidate hydrogenV35RefuelCandidate `json:"refuelCandidate"`
|
||||
MinimumPressureMPa float64 `json:"minimumPressureMpa"`
|
||||
MinimumMassKG float64 `json:"minimumMassKg"`
|
||||
MinimumObservedAt time.Time `json:"minimumObservedAt"`
|
||||
|
||||
PreviousSample hydrogenV35Point `json:"previousSample"`
|
||||
HasPreviousSample bool `json:"hasPreviousSample"`
|
||||
AbnormalDropCount int64 `json:"abnormalDropCount"`
|
||||
InvalidSampleCount int64 `json:"invalidSampleCount"`
|
||||
|
||||
PurePressurePeakMPa float64 `json:"purePressurePeakMpa"`
|
||||
PurePressurePeakAt time.Time `json:"purePressurePeakAt"`
|
||||
HasPurePressurePeak bool `json:"hasPurePressurePeak"`
|
||||
|
||||
InactiveStart hydrogenV35Point `json:"inactiveStart"`
|
||||
InactiveLast hydrogenV35Point `json:"inactiveLast"`
|
||||
InactiveCount int `json:"inactiveCount"`
|
||||
SuspectedLeakCount int64 `json:"suspectedLeakCount"`
|
||||
SuspectedLeakMaxMPa float64 `json:"suspectedLeakMaxMpa"`
|
||||
}
|
||||
|
||||
type hydrogenV35Projection struct {
|
||||
ConsumptionKG float64
|
||||
SOCDeltaPercent *float64
|
||||
BatteryEnergyChangeKWh *float64
|
||||
ElectricEquivalentKG *float64
|
||||
CorrectedConsumptionKG *float64
|
||||
PureMileageKM float64
|
||||
MixedMileageKM float64
|
||||
ConsumptionPer100KM *float64
|
||||
CorrectedPer100KM *float64
|
||||
QualityStatus string
|
||||
QualityReason string
|
||||
FuelCellEnergyKG float64
|
||||
ValidSegmentCount int64
|
||||
}
|
||||
|
||||
func newHydrogenV35RealtimeState(sample HydrogenStreamSample, params HydrogenRealtimeParameters) hydrogenV35RealtimeState {
|
||||
params = normalizeHydrogenRealtimeParameters(params)
|
||||
state := hydrogenV35RealtimeState{
|
||||
AlgorithmVersion: HydrogenV35RealtimeAlgorithmVersion,
|
||||
SourceEndpoint: sample.SourceEndpoint, Parameters: params,
|
||||
FirstMassKG: sample.MassKG, LastMassKG: sample.MassKG,
|
||||
}
|
||||
state.add(sample)
|
||||
return state
|
||||
}
|
||||
|
||||
func (state *hydrogenV35RealtimeState) add(sample HydrogenStreamSample) bool {
|
||||
if !state.LastEventTime.IsZero() && !sample.EventTime.After(state.LastEventTime) {
|
||||
return false
|
||||
}
|
||||
state.AlgorithmVersion = HydrogenV35RealtimeAlgorithmVersion
|
||||
state.Parameters = normalizeHydrogenRealtimeParameters(state.Parameters)
|
||||
state.SourceEndpoint = firstNonEmptyHydrogen(sample.SourceEndpoint, state.SourceEndpoint)
|
||||
state.SampleCount++
|
||||
state.LastEventID, state.LastEventTime = sample.EventID, sample.EventTime
|
||||
point := hydrogenV35PointFromSample(sample)
|
||||
|
||||
pressureInvalid := state.updatePurePressureValidity(sample)
|
||||
if pressureInvalid {
|
||||
state.InvalidSampleCount++
|
||||
}
|
||||
state.updateLeak(point)
|
||||
state.updateRefuel(sample, point)
|
||||
|
||||
effective := sample.VehicleStateKnown && sample.VehicleState == 1 &&
|
||||
!(sample.ChargeStateKnown && sample.ChargeState == 1) && !pressureInvalid &&
|
||||
sample.MileageKnown && sample.SOCKnown && sample.RunningModeKnown
|
||||
externalCharging := sample.ChargeStateKnown && sample.ChargeState == 1 && sample.VehicleStateKnown &&
|
||||
(sample.VehicleState == 1 || sample.VehicleState == 2)
|
||||
if externalCharging {
|
||||
state.startOrContinueCharge(sample)
|
||||
state.LastBoundaryWasEffective = false
|
||||
state.PreviousSample, state.HasPreviousSample = point, true
|
||||
return true
|
||||
}
|
||||
if state.Charging {
|
||||
state.finishCharge()
|
||||
state.finishCycle()
|
||||
state.Cycle = hydrogenV35CycleState{}
|
||||
}
|
||||
if !effective {
|
||||
state.LastBoundaryWasEffective = false
|
||||
state.PreviousSample, state.HasPreviousSample = point, true
|
||||
return true
|
||||
}
|
||||
previousEffective, compareAbnormalDrop := state.LastEffective, state.LastBoundaryWasEffective
|
||||
if state.HasEffective && sample.EventTime.Sub(state.LastEffective.ObservedAt) > hydrogenV35ContinuityGap {
|
||||
state.finishCycle()
|
||||
state.Cycle = hydrogenV35CycleState{}
|
||||
}
|
||||
if !state.HasEffective {
|
||||
state.FirstEffective = point
|
||||
state.FirstMassKG = point.MassKG
|
||||
state.HasEffective = true
|
||||
}
|
||||
state.LastEffective = point
|
||||
state.LastMassKG = point.MassKG
|
||||
state.EffectiveRunCount++
|
||||
state.addCycleRun(point)
|
||||
if !sample.FuelCellStateKnown || sample.FuelCellActive {
|
||||
state.addHydrogenRun(point)
|
||||
}
|
||||
if compareAbnormalDrop && previousEffective.MassKG-point.MassKG > defaultHydrogenMaxDropKG {
|
||||
state.AbnormalDropCount++
|
||||
}
|
||||
state.LastBoundaryWasEffective = true
|
||||
state.PreviousSample, state.HasPreviousSample = point, true
|
||||
return true
|
||||
}
|
||||
|
||||
func (state *hydrogenV35RealtimeState) updatePurePressureValidity(sample HydrogenStreamSample) bool {
|
||||
if !sample.RunningModeKnown || sample.RunningMode != 1 {
|
||||
state.HasPurePressurePeak = false
|
||||
return false
|
||||
}
|
||||
if !state.HasPurePressurePeak || sample.EventTime.Sub(state.PurePressurePeakAt) > 30*time.Minute {
|
||||
state.PurePressurePeakMPa, state.PurePressurePeakAt, state.HasPurePressurePeak = sample.PressureMPa, sample.EventTime, true
|
||||
return false
|
||||
}
|
||||
invalid := state.PurePressurePeakMPa-sample.PressureMPa > 5
|
||||
if sample.PressureMPa > state.PurePressurePeakMPa {
|
||||
state.PurePressurePeakMPa, state.PurePressurePeakAt = sample.PressureMPa, sample.EventTime
|
||||
}
|
||||
return invalid
|
||||
}
|
||||
|
||||
func (state *hydrogenV35RealtimeState) startOrContinueCharge(sample HydrogenStreamSample) {
|
||||
if !state.Charging {
|
||||
state.finishCycle()
|
||||
state.Cycle = hydrogenV35CycleState{}
|
||||
state.Charging = true
|
||||
state.ChargeCount++
|
||||
state.ChargeSOCKnown = sample.SOCKnown
|
||||
state.ChargeStartSOC, state.ChargeLastSOC = sample.SOCPercent, sample.SOCPercent
|
||||
return
|
||||
}
|
||||
if sample.SOCKnown {
|
||||
if !state.ChargeSOCKnown {
|
||||
state.ChargeStartSOC = sample.SOCPercent
|
||||
state.ChargeSOCKnown = true
|
||||
}
|
||||
state.ChargeLastSOC = sample.SOCPercent
|
||||
}
|
||||
}
|
||||
|
||||
func (state *hydrogenV35RealtimeState) finishCharge() {
|
||||
if state.ChargeSOCKnown && state.Parameters.BatteryCapacityKWh > 0 && state.ChargeLastSOC > state.ChargeStartSOC {
|
||||
state.ChargeEnergyKWh += state.Parameters.BatteryCapacityKWh * (state.ChargeLastSOC - state.ChargeStartSOC) / 100
|
||||
}
|
||||
state.Charging, state.ChargeSOCKnown = false, false
|
||||
state.InitialStateUsed = true
|
||||
}
|
||||
|
||||
func (state *hydrogenV35RealtimeState) addCycleRun(point hydrogenV35Point) {
|
||||
cycle := &state.Cycle
|
||||
if !cycle.HasRun {
|
||||
cycle.HasRun, cycle.First, cycle.Last = true, point, point
|
||||
cycle.MixedLocked = !state.InitialStateUsed
|
||||
cycle.StartsPure = point.RunningMode == 1 && !cycle.MixedLocked
|
||||
if cycle.MixedLocked || !cycle.StartsPure {
|
||||
cycle.MixedConfirmed, cycle.MixedStart = true, point
|
||||
}
|
||||
state.InitialStateUsed = true
|
||||
return
|
||||
}
|
||||
previous := cycle.Last
|
||||
cycle.Last = point
|
||||
if cycle.MixedConfirmed {
|
||||
cycle.MixedEnergyKWh += hydrogenV35PowerBetween(previous, point)
|
||||
return
|
||||
}
|
||||
if point.RunningMode != 2 || (cycle.MixedCandidateSet && point.ObservedAt.Sub(cycle.MixedCandidateAt) > hydrogenV35MixedConfirmWindow) {
|
||||
cycle.MixedCandidateSet, cycle.MixedCandidateN, cycle.CandidateEnergyKWh = false, 0, 0
|
||||
}
|
||||
if point.RunningMode != 2 {
|
||||
return
|
||||
}
|
||||
if !cycle.MixedCandidateSet {
|
||||
cycle.MixedCandidate, cycle.MixedCandidateSet = point, true
|
||||
cycle.MixedCandidateN = 1
|
||||
cycle.CandidateEnergyKWh = 0
|
||||
} else {
|
||||
cycle.MixedCandidateN++
|
||||
cycle.CandidateEnergyKWh += hydrogenV35PowerBetween(previous, point)
|
||||
}
|
||||
cycle.MixedCandidateAt = point.ObservedAt
|
||||
if cycle.MixedCandidateN >= hydrogenV35MixedConfirmSamples {
|
||||
cycle.MixedConfirmed = true
|
||||
cycle.MixedStart = cycle.MixedCandidate
|
||||
cycle.MixedEnergyKWh = cycle.CandidateEnergyKWh
|
||||
cycle.MixedCandidateSet, cycle.MixedCandidateN, cycle.CandidateEnergyKWh = false, 0, 0
|
||||
}
|
||||
}
|
||||
|
||||
func hydrogenV35PowerBetween(previous, current hydrogenV35Point) float64 {
|
||||
gap := current.ObservedAt.Sub(previous.ObservedAt)
|
||||
if gap <= 0 || gap > hydrogenV35PowerIntegrationGap || !previous.FuelCellPowerKnown || !current.FuelCellPowerKnown {
|
||||
return 0
|
||||
}
|
||||
return (math.Max(0, previous.FuelCellPowerKW) + math.Max(0, current.FuelCellPowerKW)) / 2 * gap.Hours()
|
||||
}
|
||||
|
||||
func hydrogenV35CycleTotals(cycle hydrogenV35CycleState) (pureKM, mixedKM, socDelta, energyKWh float64, mixed bool) {
|
||||
if !cycle.HasRun {
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
if !cycle.MixedConfirmed {
|
||||
if cycle.StartsPure {
|
||||
return math.Max(0, cycle.Last.MileageKM-cycle.First.MileageKM), 0, 0, 0, false
|
||||
}
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
if cycle.StartsPure {
|
||||
pureKM = math.Max(0, cycle.MixedStart.MileageKM-cycle.First.MileageKM)
|
||||
}
|
||||
mixedKM = math.Max(0, cycle.Last.MileageKM-cycle.MixedStart.MileageKM)
|
||||
socDelta = cycle.Last.SOCPercent - cycle.MixedStart.SOCPercent
|
||||
return pureKM, mixedKM, socDelta, cycle.MixedEnergyKWh, true
|
||||
}
|
||||
|
||||
func (state *hydrogenV35RealtimeState) finishCycle() {
|
||||
pureKM, mixedKM, socDelta, energyKWh, mixed := hydrogenV35CycleTotals(state.Cycle)
|
||||
state.CompletedPureKM += pureKM
|
||||
state.CompletedMixedKM += mixedKM
|
||||
state.CompletedSOCDelta += socDelta
|
||||
state.CompletedMixedEnergyKWh += energyKWh
|
||||
if mixed {
|
||||
state.CompletedMixedCycles++
|
||||
}
|
||||
}
|
||||
|
||||
func (state *hydrogenV35RealtimeState) addHydrogenRun(point hydrogenV35Point) {
|
||||
if state.RefuelCandidate.Set && !point.ObservedAt.Before(state.RefuelCandidate.Boundary.ObservedAt) {
|
||||
if state.RefuelCandidate.HydrogenRunsAfter == 0 {
|
||||
state.RefuelCandidate.FirstHydrogenAfter = point
|
||||
}
|
||||
state.RefuelCandidate.HydrogenRunsAfter++
|
||||
}
|
||||
if !state.HasHydrogenSegment {
|
||||
state.HydrogenSegmentStart, state.LastHydrogenRun, state.HasHydrogenSegment = point, point, true
|
||||
state.HydrogenSegmentRuns = 1
|
||||
return
|
||||
}
|
||||
state.LastHydrogenRun = point
|
||||
state.HydrogenSegmentRuns++
|
||||
}
|
||||
|
||||
func (state *hydrogenV35RealtimeState) updateRefuel(sample HydrogenStreamSample, point hydrogenV35Point) {
|
||||
if state.MinimumObservedAt.IsZero() || sample.EventTime.Sub(state.MinimumObservedAt) > hydrogenV35RefuelObservationWindow {
|
||||
state.MinimumPressureMPa, state.MinimumMassKG, state.MinimumObservedAt = sample.PressureMPa, sample.MassKG, sample.EventTime
|
||||
}
|
||||
if state.RefuelCandidate.Set {
|
||||
age := sample.EventTime.Sub(state.RefuelCandidate.Boundary.ObservedAt)
|
||||
if age > hydrogenV35RefuelObservationWindow || sample.PressureMPa-state.RefuelCandidate.BaselinePressureMPa < hydrogenV35RefuelRiseMPa {
|
||||
state.RefuelCandidate = hydrogenV35RefuelCandidate{}
|
||||
} else {
|
||||
state.RefuelCandidate.HighSamples++
|
||||
if age >= hydrogenV35RefuelSustain || state.RefuelCandidate.HighSamples >= hydrogenV35MixedConfirmSamples {
|
||||
state.confirmRefuel(point)
|
||||
}
|
||||
}
|
||||
}
|
||||
if sample.PressureMPa < state.MinimumPressureMPa {
|
||||
state.MinimumPressureMPa, state.MinimumMassKG, state.MinimumObservedAt = sample.PressureMPa, sample.MassKG, sample.EventTime
|
||||
}
|
||||
thermalRefuel := state.thermalRefuelContext(sample)
|
||||
ordinaryRefuel := sample.PressureMPa-state.MinimumPressureMPa >= hydrogenV35RefuelRiseMPa && state.refuelContext(sample)
|
||||
if state.RefuelCandidate.Set || (!ordinaryRefuel && !thermalRefuel) {
|
||||
return
|
||||
}
|
||||
baselinePressure, baselineMass := state.MinimumPressureMPa, state.MinimumMassKG
|
||||
if thermalRefuel {
|
||||
baselinePressure, baselineMass = state.PreviousSample.PressureMPa, state.PreviousSample.MassKG
|
||||
}
|
||||
candidate := hydrogenV35RefuelCandidate{
|
||||
Set: true, Boundary: point, BaselinePressureMPa: baselinePressure,
|
||||
BaselineMassKG: baselineMass, HighSamples: 1,
|
||||
}
|
||||
if state.HasHydrogenSegment {
|
||||
candidate.PreviousHydrogen, candidate.PreviousHydrogenExists = state.LastHydrogenRun, true
|
||||
}
|
||||
state.RefuelCandidate = candidate
|
||||
}
|
||||
|
||||
func (state *hydrogenV35RealtimeState) thermalRefuelContext(sample HydrogenStreamSample) bool {
|
||||
if !state.HasPreviousSample || !sample.MileageKnown || !state.PreviousSample.MileageKnown {
|
||||
return false
|
||||
}
|
||||
gap := sample.EventTime.Sub(state.PreviousSample.ObservedAt)
|
||||
return gap > 0 && gap <= hydrogenV35RefuelThermalWindow &&
|
||||
sample.PressureMPa-state.PreviousSample.PressureMPa >= hydrogenV35RefuelRiseMPa &&
|
||||
sample.TemperatureC-state.PreviousSample.TemperatureC >= hydrogenV35RefuelTemperatureRiseC &&
|
||||
math.Abs(sample.MileageKM-state.PreviousSample.MileageKM) <= 5
|
||||
}
|
||||
|
||||
func (state *hydrogenV35RealtimeState) refuelContext(sample HydrogenStreamSample) bool {
|
||||
if !state.HasPreviousSample {
|
||||
return false
|
||||
}
|
||||
if sample.MileageKnown && state.PreviousSample.MileageKnown {
|
||||
return math.Abs(sample.MileageKM-state.PreviousSample.MileageKM) <= 0.2
|
||||
}
|
||||
return (sample.VehicleStateKnown && sample.VehicleState == 2) || (sample.FuelCellStateKnown && !sample.FuelCellActive)
|
||||
}
|
||||
|
||||
func (state *hydrogenV35RealtimeState) confirmRefuel(current hydrogenV35Point) {
|
||||
candidate := state.RefuelCandidate
|
||||
if candidate.PreviousHydrogenExists && state.HasHydrogenSegment && state.HydrogenSegmentRuns >= 2 {
|
||||
drop := state.HydrogenSegmentStart.MassKG - candidate.PreviousHydrogen.MassKG
|
||||
if drop < 0 && math.Abs(drop) <= math.Max(state.HydrogenSegmentStart.NoiseKG, candidate.PreviousHydrogen.NoiseKG) {
|
||||
drop = 0
|
||||
}
|
||||
state.FinalizedHydrogenKG += drop
|
||||
state.HydrogenSegments++
|
||||
}
|
||||
state.RefuelCount++
|
||||
state.RefuelAmountKG += math.Max(0, current.MassKG-candidate.BaselineMassKG)
|
||||
if candidate.HydrogenRunsAfter > 0 {
|
||||
state.HydrogenSegmentStart = candidate.FirstHydrogenAfter
|
||||
state.HasHydrogenSegment = true
|
||||
state.HydrogenSegmentRuns = candidate.HydrogenRunsAfter
|
||||
} else {
|
||||
state.HasHydrogenSegment = false
|
||||
state.HydrogenSegmentRuns = 0
|
||||
}
|
||||
state.MinimumPressureMPa, state.MinimumMassKG, state.MinimumObservedAt = current.PressureMPa, current.MassKG, current.ObservedAt
|
||||
state.RefuelCandidate = hydrogenV35RefuelCandidate{}
|
||||
}
|
||||
|
||||
func (state *hydrogenV35RealtimeState) updateLeak(point hydrogenV35Point) {
|
||||
inactive := point.FuelCellStateKnown && !point.FuelCellActive
|
||||
if !inactive {
|
||||
state.finishInactiveLeak()
|
||||
return
|
||||
}
|
||||
if state.InactiveCount > 0 && point.ObservedAt.Sub(state.InactiveLast.ObservedAt) > hydrogenV35ContinuityGap {
|
||||
state.finishInactiveLeak()
|
||||
}
|
||||
if state.InactiveCount == 0 {
|
||||
state.InactiveStart = point
|
||||
}
|
||||
state.InactiveLast = point
|
||||
state.InactiveCount++
|
||||
}
|
||||
|
||||
func (state *hydrogenV35RealtimeState) finishInactiveLeak() {
|
||||
if state.InactiveCount >= 3 && state.InactiveLast.ObservedAt.Sub(state.InactiveStart.ObservedAt) >= 5*time.Minute {
|
||||
pressureDrop := state.InactiveStart.PressureMPa - state.InactiveLast.PressureMPa
|
||||
massDrop := state.InactiveStart.MassKG - state.InactiveLast.MassKG
|
||||
if pressureDrop >= 0.5 && massDrop >= math.Max(0.05, math.Max(state.InactiveStart.NoiseKG, state.InactiveLast.NoiseKG)) {
|
||||
state.SuspectedLeakCount++
|
||||
state.SuspectedLeakMaxMPa = math.Max(state.SuspectedLeakMaxMPa, pressureDrop)
|
||||
}
|
||||
}
|
||||
state.InactiveCount = 0
|
||||
}
|
||||
|
||||
func (state hydrogenV35RealtimeState) projection() hydrogenV35Projection {
|
||||
// Evaluate an open inactive segment at the current watermark on this copy.
|
||||
// Persisted state remains open so later frames can continue the same segment.
|
||||
state.finishInactiveLeak()
|
||||
pureKM, mixedKM, socDelta, mixedEnergyKWh, currentMixed := hydrogenV35CycleTotals(state.Cycle)
|
||||
pureKM += state.CompletedPureKM
|
||||
mixedKM += state.CompletedMixedKM
|
||||
socDelta += state.CompletedSOCDelta
|
||||
mixedEnergyKWh += state.CompletedMixedEnergyKWh
|
||||
mixedCycles := state.CompletedMixedCycles
|
||||
if currentMixed {
|
||||
mixedCycles++
|
||||
}
|
||||
physical := state.FinalizedHydrogenKG
|
||||
hydrogenSegments := state.HydrogenSegments
|
||||
if state.HasHydrogenSegment && state.HydrogenSegmentRuns >= 2 {
|
||||
drop := state.HydrogenSegmentStart.MassKG - state.LastHydrogenRun.MassKG
|
||||
if drop < 0 && math.Abs(drop) <= math.Max(state.HydrogenSegmentStart.NoiseKG, state.LastHydrogenRun.NoiseKG) {
|
||||
drop = 0
|
||||
}
|
||||
physical += drop
|
||||
hydrogenSegments++
|
||||
}
|
||||
params := normalizeHydrogenRealtimeParameters(state.Parameters)
|
||||
fuelCellEnergyKG := mixedEnergyKWh / params.HydrogenEnergyKWhKG
|
||||
electricEquivalentKG := 0.0
|
||||
if params.BatteryCapacityKWh > 0 {
|
||||
electricEquivalentKG = params.BatteryCapacityKWh * socDelta / 100 / params.HydrogenEnergyKWhKG
|
||||
}
|
||||
usedEnergyFallback := mixedKM >= hydrogenV35MixedMinimumKM && fuelCellEnergyKG > 0 && (physical <= 0 || physical-electricEquivalentKG <= 0)
|
||||
if usedEnergyFallback {
|
||||
physical = math.Max(physical, fuelCellEnergyKG)
|
||||
}
|
||||
corrected := math.Max(0, physical-electricEquivalentKG)
|
||||
result := hydrogenV35Projection{
|
||||
ConsumptionKG: roundHydrogenKG(physical), PureMileageKM: roundHydrogenKG(pureKM), MixedMileageKM: roundHydrogenKG(mixedKM),
|
||||
FuelCellEnergyKG: roundHydrogenKG(fuelCellEnergyKG), ValidSegmentCount: hydrogenSegments, QualityStatus: "OK",
|
||||
}
|
||||
if params.BatteryCapacityKWh > 0 {
|
||||
soc, battery, equivalent, balanced := roundHydrogenKG(socDelta), roundHydrogenKG(params.BatteryCapacityKWh*socDelta/100), roundHydrogenKG(electricEquivalentKG), roundHydrogenKG(corrected)
|
||||
result.SOCDeltaPercent, result.BatteryEnergyChangeKWh = &soc, &battery
|
||||
result.ElectricEquivalentKG, result.CorrectedConsumptionKG = &equivalent, &balanced
|
||||
}
|
||||
if mixedKM > 0 {
|
||||
physicalRate := roundHydrogenKG(physical * 100 / mixedKM)
|
||||
result.ConsumptionPer100KM = &physicalRate
|
||||
if result.CorrectedConsumptionKG != nil {
|
||||
correctedRate := roundHydrogenKG(corrected * 100 / mixedKM)
|
||||
result.CorrectedPer100KM = &correctedRate
|
||||
}
|
||||
}
|
||||
reasons := make([]string, 0, 5)
|
||||
totalMileage := 0.0
|
||||
if state.HasEffective {
|
||||
totalMileage = state.LastEffective.MileageKM - state.FirstEffective.MileageKM
|
||||
}
|
||||
switch {
|
||||
case state.EffectiveRunCount < 2:
|
||||
result.QualityStatus, reasons = "NO_DATA", append(reasons, "有效运行分界点不足2条")
|
||||
case totalMileage < 1:
|
||||
result.QualityStatus, reasons = "NO_DATA", append(reasons, "当前有效总里程不足1km")
|
||||
case mixedCycles == 0:
|
||||
result.QualityStatus, reasons = "NO_DATA", append(reasons, "尚未形成确认的用氢混动周期")
|
||||
case mixedKM < hydrogenV35MixedMinimumKM:
|
||||
result.QualityStatus, reasons = "NO_DATA", append(reasons, "当前混动里程不足10km")
|
||||
case hydrogenSegments == 0:
|
||||
result.QualityStatus, reasons = "NO_DATA", append(reasons, "燃料电池有效工作氢量边界不足")
|
||||
case corrected <= 0:
|
||||
result.QualityStatus, reasons = "NO_DATA", append(reasons, "现有报文不足以形成可信的正耗氢结果")
|
||||
}
|
||||
if result.QualityStatus != "NO_DATA" && params.BatteryCapacityKWh <= 0 {
|
||||
result.QualityStatus, reasons = "SUSPECT", append(reasons, "车型动力电池容量未确认")
|
||||
}
|
||||
if state.AbnormalDropCount > 0 {
|
||||
if result.QualityStatus == "OK" {
|
||||
result.QualityStatus = "SUSPECT"
|
||||
}
|
||||
reasons = append(reasons, "检测到氢量异常大幅下降")
|
||||
}
|
||||
if state.SuspectedLeakCount > 0 {
|
||||
if result.QualityStatus == "OK" {
|
||||
result.QualityStatus = "SUSPECT"
|
||||
}
|
||||
reasons = append(reasons, "检测到燃料电池未工作期间持续压降")
|
||||
}
|
||||
if usedEnergyFallback {
|
||||
if result.QualityStatus == "OK" {
|
||||
result.QualityStatus = "SUSPECT"
|
||||
}
|
||||
reasons = append(reasons, "压力边界不足,暂用燃料电池功率积分折算")
|
||||
}
|
||||
if result.QualityStatus == "NO_DATA" {
|
||||
result.ConsumptionPer100KM = nil
|
||||
result.CorrectedPer100KM = nil
|
||||
}
|
||||
result.QualityReason = strings.Join(reasons, ";")
|
||||
return result
|
||||
}
|
||||
|
||||
func AppendHydrogenV35Realtime(ctx context.Context, exec Execer, env envelope.FrameEnvelope, loc *time.Location, now time.Time, tankCapacityLiters float64, params HydrogenRealtimeParameters) (HydrogenStreamResult, error) {
|
||||
sample, reason, ok := HydrogenStreamSampleFromEnvelope(env, loc, now, tankCapacityLiters)
|
||||
if !ok {
|
||||
result := HydrogenStreamResult{}
|
||||
switch reason {
|
||||
case "unsupported_protocol", "missing_pressure", "missing_temperature":
|
||||
return result, nil
|
||||
case "not_current_date":
|
||||
result.NotCurrent = 1
|
||||
default:
|
||||
result.Invalid = 1
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
result := HydrogenStreamResult{Found: 1}
|
||||
beginner, ok := exec.(txBeginner)
|
||||
if !ok {
|
||||
return result, sql.ErrTxDone
|
||||
}
|
||||
tx, err := beginner.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
state, found, err := selectHydrogenV35RealtimeState(ctx, tx, sample.VIN, sample.Date)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if !found || state.AlgorithmVersion != HydrogenV35RealtimeAlgorithmVersion {
|
||||
state = newHydrogenV35RealtimeState(sample, params)
|
||||
if err := insertHydrogenV35RealtimeState(ctx, tx, sample.VIN, sample.Date, state); err != nil {
|
||||
return result, err
|
||||
}
|
||||
} else {
|
||||
if params.BatteryCapacityKWh > 0 {
|
||||
state.Parameters = normalizeHydrogenRealtimeParameters(params)
|
||||
} else {
|
||||
state.Parameters = normalizeHydrogenRealtimeParameters(state.Parameters)
|
||||
}
|
||||
if !state.add(sample) {
|
||||
result.Duplicate = 1
|
||||
return result, tx.Commit()
|
||||
}
|
||||
if err := updateHydrogenV35RealtimeState(ctx, tx, sample.VIN, sample.Date, state); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
if err := projectHydrogenV35Realtime(ctx, tx, sample.VIN, sample.Date, state); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Written = 1
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func selectHydrogenV35RealtimeState(ctx context.Context, tx *sql.Tx, vin, date string) (hydrogenV35RealtimeState, bool, error) {
|
||||
var encoded []byte
|
||||
err := tx.QueryRowContext(ctx, selectHydrogenSegmentStreamStateSQL, vin, date).Scan(&encoded)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return hydrogenV35RealtimeState{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return hydrogenV35RealtimeState{}, false, err
|
||||
}
|
||||
var state hydrogenV35RealtimeState
|
||||
if err := json.Unmarshal(encoded, &state); err != nil {
|
||||
return hydrogenV35RealtimeState{}, false, nil
|
||||
}
|
||||
return state, true, nil
|
||||
}
|
||||
|
||||
func insertHydrogenV35RealtimeState(ctx context.Context, tx *sql.Tx, vin, date string, state hydrogenV35RealtimeState) error {
|
||||
encoded, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
projection := state.projection()
|
||||
_, err = tx.ExecContext(ctx, insertHydrogenSegmentStreamStateSQL,
|
||||
vin, date, state.SourceEndpoint, projection.ConsumptionKG, projection.ConsumptionKG,
|
||||
state.SampleCount, state.RefuelCount, state.AbnormalDropCount, state.EffectiveRunCount, projection.ValidSegmentCount,
|
||||
state.LastMassKG, state.LastEventTime, state.LastEventID, encoded, projection.QualityStatus, projection.QualityReason)
|
||||
return err
|
||||
}
|
||||
|
||||
func updateHydrogenV35RealtimeState(ctx context.Context, tx *sql.Tx, vin, date string, state hydrogenV35RealtimeState) error {
|
||||
encoded, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
projection := state.projection()
|
||||
_, err = tx.ExecContext(ctx, updateHydrogenSegmentStreamStateSQL,
|
||||
state.SourceEndpoint, projection.ConsumptionKG, projection.ConsumptionKG,
|
||||
state.SampleCount, state.RefuelCount, state.AbnormalDropCount, state.EffectiveRunCount, projection.ValidSegmentCount,
|
||||
state.LastMassKG, state.LastEventTime, state.LastEventID, encoded,
|
||||
projection.QualityStatus, projection.QualityReason, vin, date)
|
||||
return err
|
||||
}
|
||||
|
||||
func projectHydrogenV35Realtime(ctx context.Context, tx *sql.Tx, vin, date string, state hydrogenV35RealtimeState) error {
|
||||
projection := state.projection()
|
||||
parameters, err := json.Marshal(map[string]any{
|
||||
"batteryCapacityKWh": state.Parameters.BatteryCapacityKWh,
|
||||
"hydrogenEnergyKWhPerKg": normalizeHydrogenRealtimeParameters(state.Parameters).HydrogenEnergyKWhKG,
|
||||
"algorithmVersion": HydrogenV35RealtimeAlgorithmVersion,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
evidence, err := json.Marshal(map[string]any{
|
||||
"calculationPhase": "PRELIMINARY", "lastEventId": state.LastEventID,
|
||||
"lastEventTime": state.LastEventTime, "refuelAmountKg": roundHydrogenKG(state.RefuelAmountKG),
|
||||
"suspectedLeakCount": state.SuspectedLeakCount, "fuelCellEnergyHydrogenKg": projection.FuelCellEnergyKG,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, projectHydrogenV35RealtimeSQL,
|
||||
vin, date, state.SourceEndpoint, projection.ConsumptionKG, projection.ConsumptionKG,
|
||||
projection.SOCDeltaPercent, projection.BatteryEnergyChangeKWh, projection.ElectricEquivalentKG,
|
||||
projection.CorrectedConsumptionKG, projection.MixedMileageKM, projection.PureMileageKM,
|
||||
projection.ConsumptionPer100KM, projection.CorrectedPer100KM,
|
||||
state.FirstMassKG, state.LastMassKG, state.SampleCount, state.RefuelCount, state.ChargeCount,
|
||||
projection.ValidSegmentCount, state.InvalidSampleCount, HydrogenV35RealtimeAlgorithmVersion,
|
||||
parameters, evidence, projection.QualityStatus, projection.QualityReason)
|
||||
return err
|
||||
}
|
||||
|
||||
const projectHydrogenV35RealtimeSQL = `INSERT INTO vehicle_open_daily_energy(
|
||||
vin,stat_date,energy_type,source_endpoint,consumption_kg,raw_consumption_kg,
|
||||
battery_soc_delta_pct,battery_discharge_kwh,battery_equivalent_kg,soc_balanced_consumption_kg,
|
||||
mixed_mileage_km,pure_electric_mileage_km,consumption_kg_per_100km,soc_balanced_kg_per_100km,
|
||||
unit,first_mass_kg,last_mass_kg,sample_count,refuel_count,charge_count,valid_segment_count,
|
||||
invalid_segment_count,algorithm_version,calculation_phase,parameter_json,evidence_json,
|
||||
quality_status,quality_reason,calculated_at
|
||||
) VALUES(?,?,'HYDROGEN',?,?,?,?,?,?,?,?,?,?,?,'kg',?,?,?,?,?,?,?,?,'PRELIMINARY',?,?,?,?,NOW(3))
|
||||
ON DUPLICATE KEY UPDATE
|
||||
source_endpoint=VALUES(source_endpoint),consumption_kg=VALUES(consumption_kg),raw_consumption_kg=VALUES(raw_consumption_kg),
|
||||
battery_soc_delta_pct=VALUES(battery_soc_delta_pct),battery_discharge_kwh=VALUES(battery_discharge_kwh),
|
||||
battery_equivalent_kg=VALUES(battery_equivalent_kg),soc_balanced_consumption_kg=VALUES(soc_balanced_consumption_kg),
|
||||
mixed_mileage_km=VALUES(mixed_mileage_km),pure_electric_mileage_km=VALUES(pure_electric_mileage_km),
|
||||
consumption_kg_per_100km=VALUES(consumption_kg_per_100km),soc_balanced_kg_per_100km=VALUES(soc_balanced_kg_per_100km),
|
||||
first_mass_kg=VALUES(first_mass_kg),last_mass_kg=VALUES(last_mass_kg),sample_count=VALUES(sample_count),
|
||||
refuel_count=VALUES(refuel_count),charge_count=VALUES(charge_count),valid_segment_count=VALUES(valid_segment_count),
|
||||
invalid_segment_count=VALUES(invalid_segment_count),algorithm_version=VALUES(algorithm_version),
|
||||
calculation_phase='PRELIMINARY',parameter_json=VALUES(parameter_json),evidence_json=VALUES(evidence_json),
|
||||
quality_status=VALUES(quality_status),quality_reason=VALUES(quality_reason),calculated_at=VALUES(calculated_at)`
|
||||
@@ -0,0 +1,157 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,11 @@ var DailyMileageAlterSQL = []string{
|
||||
"ALTER TABLE vehicle_open_hydrogen_stream_state ADD COLUMN last_fuel_cell_state_known TINYINT(1) NOT NULL DEFAULT 0 AFTER last_fuel_cell_active",
|
||||
}
|
||||
|
||||
var HydrogenRealtimeAlterSQL = []string{
|
||||
"ALTER TABLE vehicle_open_hydrogen_segment_stream_state MODIFY COLUMN calculation_method VARCHAR(80) NOT NULL DEFAULT 'PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5'",
|
||||
"ALTER TABLE vehicle_open_daily_energy ADD COLUMN calculation_phase VARCHAR(16) NOT NULL DEFAULT 'FINAL' AFTER algorithm_version",
|
||||
}
|
||||
|
||||
const DataSourceTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_data_source (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
protocol VARCHAR(32) NOT NULL,
|
||||
|
||||
Reference in New Issue
Block a user