chore: snapshot production code before Apple Design UI refinement
This commit is contained in:
@@ -0,0 +1,681 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "http://115.29.187.205:20200"
|
||||
hydrogenKWh = 16.0
|
||||
)
|
||||
|
||||
type candidate struct {
|
||||
VIN string `json:"vin"`
|
||||
StatDate string `json:"stat_date"`
|
||||
PlatformName string `json:"platform_name"`
|
||||
DailyMileageKm float64 `json:"daily_mileage_km"`
|
||||
RawTotal int `json:"raw_total"`
|
||||
Plate string `json:"plate,omitempty"`
|
||||
SampleNo int `json:"sample_no,omitempty"`
|
||||
MileageBin int `json:"mileage_bin,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
TankCapacityL float64 `json:"tank_capacity_l,omitempty"`
|
||||
BatteryKWh float64 `json:"battery_capacity_kwh,omitempty"`
|
||||
}
|
||||
|
||||
type capacityRecord struct {
|
||||
VIN, Plate, Model string
|
||||
TankCapacityL float64
|
||||
Active int
|
||||
}
|
||||
|
||||
type rawFrame struct {
|
||||
TS string `json:"ts"`
|
||||
FrameID string `json:"frame_id"`
|
||||
EventID string `json:"event_id"`
|
||||
MessageID int `json:"message_id"`
|
||||
MessageIDHex string `json:"message_id_hex"`
|
||||
EventTime string `json:"event_time"`
|
||||
ReceivedAt string `json:"received_at"`
|
||||
RawSizeBytes int `json:"raw_size_bytes"`
|
||||
RawHex string `json:"raw_hex"`
|
||||
ParsedFields map[string]any `json:"parsed_fields"`
|
||||
ParseStatus string `json:"parse_status"`
|
||||
SourceEndpoint string `json:"source_endpoint"`
|
||||
Protocol string `json:"protocol"`
|
||||
VIN string `json:"vin"`
|
||||
}
|
||||
|
||||
type rawResponse struct {
|
||||
Items []rawFrame `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type dayOutput struct {
|
||||
Candidate candidate `json:"candidate"`
|
||||
APIRawFrameCount int `json:"apiRawFrameCount"`
|
||||
UniqueFrameCount int `json:"uniqueFrameCount"`
|
||||
DuplicateFrameCount int `json:"duplicateFrameCount"`
|
||||
AlgorithmSamples int `json:"algorithmSamples"`
|
||||
CriticalFieldRows int `json:"criticalFieldRows"`
|
||||
EarliestEventTime string `json:"earliestEventTime"`
|
||||
LatestEventTime string `json:"latestEventTime"`
|
||||
RawArchiveFile string `json:"rawArchiveFile"`
|
||||
RawArchiveSHA256 string `json:"rawArchiveSha256"`
|
||||
RawArchiveBytes int64 `json:"rawArchiveBytes"`
|
||||
Stat openplatform.HydrogenDailyStat `json:"stat"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 5 && len(os.Args) != 6 {
|
||||
panic("usage: audit <vehicle-metadata.json> <start-date> <end-date> <output-dir> [source-archive-dir]")
|
||||
}
|
||||
metadataPath, startDate, endDate, outputDir := os.Args[1], os.Args[2], os.Args[3], os.Args[4]
|
||||
sourceArchiveDir := ""
|
||||
if len(os.Args) == 6 {
|
||||
sourceArchiveDir = os.Args[5]
|
||||
}
|
||||
start, err := time.Parse("2006-01-02", startDate)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
end, err := time.Parse("2006-01-02", endDate)
|
||||
if err != nil || end.Before(start) {
|
||||
panic("invalid date range")
|
||||
}
|
||||
var metadata []struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
Model string `json:"model"`
|
||||
TankCapacityL float64 `json:"tank_capacity_l"`
|
||||
BatteryCapacityKWh float64 `json:"battery_capacity_kwh"`
|
||||
}
|
||||
mustReadJSON(metadataPath, &metadata)
|
||||
if len(metadata) == 0 {
|
||||
panic("vehicle metadata is empty")
|
||||
}
|
||||
selected := make([]candidate, 0, len(metadata)*int(end.Sub(start).Hours()/24+1))
|
||||
for day := start; !day.After(end); day = day.AddDate(0, 0, 1) {
|
||||
statDate := day.Format("2006-01-02")
|
||||
for _, item := range metadata {
|
||||
if len(strings.TrimSpace(item.VIN)) != 17 || item.TankCapacityL <= 0 || item.BatteryCapacityKWh <= 0 {
|
||||
continue
|
||||
}
|
||||
selected = append(selected, candidate{
|
||||
VIN: strings.ToUpper(strings.TrimSpace(item.VIN)), StatDate: statDate,
|
||||
Plate: item.Plate, Model: item.Model, TankCapacityL: item.TankCapacityL,
|
||||
BatteryKWh: item.BatteryCapacityKWh,
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.Slice(selected, func(i, j int) bool {
|
||||
if selected[i].StatDate != selected[j].StatDate {
|
||||
return selected[i].StatDate < selected[j].StatDate
|
||||
}
|
||||
if selected[i].Plate != selected[j].Plate {
|
||||
return selected[i].Plate < selected[j].Plate
|
||||
}
|
||||
return selected[i].VIN < selected[j].VIN
|
||||
})
|
||||
for index := range selected {
|
||||
selected[index].SampleNo = index + 1
|
||||
}
|
||||
if sourceArchiveDir == "" {
|
||||
for day := start; !day.After(end); day = day.AddDate(0, 0, 1) {
|
||||
if err := os.MkdirAll(filepath.Join(outputDir, "raw", day.Format("2006-01-02")), 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(filepath.Join(outputDir, "selected_vehicles.json"), selected)
|
||||
|
||||
jobs := make(chan candidate)
|
||||
results := make(chan dayOutput)
|
||||
errs := make(chan error, len(selected))
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 12; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for c := range jobs {
|
||||
out, err := processDay(c, outputDir, sourceArchiveDir)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
continue
|
||||
}
|
||||
results <- out
|
||||
}
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
for _, c := range selected {
|
||||
jobs <- c
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(errs)
|
||||
}()
|
||||
outputs := make([]dayOutput, 0, len(selected))
|
||||
for out := range results {
|
||||
if out.APIRawFrameCount == 0 {
|
||||
continue
|
||||
}
|
||||
outputs = append(outputs, out)
|
||||
fmt.Fprintf(os.Stderr, "completed %03d %s %s raw=%d samples=%d quality=%s\n", out.Candidate.SampleNo, out.Candidate.Plate, out.Candidate.VIN, out.APIRawFrameCount, out.AlgorithmSamples, out.Stat.QualityStatus)
|
||||
}
|
||||
var allErrs []string
|
||||
for err := range errs {
|
||||
allErrs = append(allErrs, err.Error())
|
||||
}
|
||||
if len(allErrs) > 0 {
|
||||
panic(strings.Join(allErrs, "\n"))
|
||||
}
|
||||
sort.Slice(outputs, func(i, j int) bool { return outputs[i].Candidate.SampleNo < outputs[j].Candidate.SampleNo })
|
||||
writeJSON(filepath.Join(outputDir, "daily_results.json"), outputs)
|
||||
writeManifest(filepath.Join(outputDir, "raw_manifest.csv"), outputs)
|
||||
fmt.Printf("processed=%d raw_frames=%d algorithm_samples=%d intervals=%d\n", len(outputs), sumRaw(outputs), sumSamples(outputs), sumIntervals(outputs))
|
||||
}
|
||||
|
||||
func stratifiedSelect(all []candidate, plateByVIN map[string]string, capacityByVIN map[string]capacityRecord) []candidate {
|
||||
general, cold := make([]candidate, 0), make([]candidate, 0)
|
||||
for _, c := range all {
|
||||
cap, ok := capacityByVIN[strings.ToUpper(c.VIN)]
|
||||
if !ok || cap.Active != 1 || c.DailyMileageKm < 10 || c.DailyMileageKm > 600 || c.RawTotal < 300 {
|
||||
continue
|
||||
}
|
||||
if cap.Model != "4.5吨货车" && cap.Model != "帕力安牌4.5吨冷链车" {
|
||||
continue
|
||||
}
|
||||
c.Plate = cap.Plate
|
||||
if c.Plate == "" {
|
||||
c.Plate = plateByVIN[strings.ToUpper(c.VIN)]
|
||||
}
|
||||
c.Model = cap.Model
|
||||
c.TankCapacityL = cap.TankCapacityL
|
||||
if cap.Model == "4.5吨货车" {
|
||||
general = append(general, c)
|
||||
} else {
|
||||
cold = append(cold, c)
|
||||
}
|
||||
}
|
||||
if len(general) < 41 || len(cold) < 59 {
|
||||
panic(fmt.Sprintf("insufficient pools general=%d cold=%d", len(general), len(cold)))
|
||||
}
|
||||
selected := append(selectEvenly(general, 41), selectEvenly(cold, 59)...)
|
||||
sort.Slice(selected, func(i, j int) bool {
|
||||
if selected[i].DailyMileageKm != selected[j].DailyMileageKm {
|
||||
return selected[i].DailyMileageKm < selected[j].DailyMileageKm
|
||||
}
|
||||
return selected[i].VIN < selected[j].VIN
|
||||
})
|
||||
for i := range selected {
|
||||
selected[i].SampleNo = i + 1
|
||||
selected[i].MileageBin = i/10 + 1
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
func selectEvenly(values []candidate, count int) []candidate {
|
||||
sort.Slice(values, func(i, j int) bool {
|
||||
if values[i].DailyMileageKm != values[j].DailyMileageKm {
|
||||
return values[i].DailyMileageKm < values[j].DailyMileageKm
|
||||
}
|
||||
return values[i].VIN < values[j].VIN
|
||||
})
|
||||
out := make([]candidate, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
idx := 0
|
||||
if count > 1 {
|
||||
idx = int(math.Round(float64(i) * float64(len(values)-1) / float64(count-1)))
|
||||
}
|
||||
out = append(out, values[idx])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func processDay(c candidate, outputDir, sourceArchiveDir string) (dayOutput, error) {
|
||||
archiveName := rawArchiveName(c)
|
||||
archivePath := filepath.Join(outputDir, "raw", c.StatDate, archiveName)
|
||||
readArchivePath := archivePath
|
||||
if sourceArchiveDir != "" {
|
||||
readArchivePath = filepath.Join(sourceArchiveDir, "raw", c.StatDate, archiveName)
|
||||
if _, statErr := os.Stat(readArchivePath); os.IsNotExist(statErr) {
|
||||
// Sample numbers depend on the selected vehicle/date set. Reuse an
|
||||
// existing archive by its stable plate/VIN/date suffix when rerunning
|
||||
// only a small subset for validation.
|
||||
pattern := filepath.Join(sourceArchiveDir, "raw", c.StatDate, "*_"+c.Plate+"_"+c.VIN+"_"+c.StatDate+".csv.gz")
|
||||
if matches, _ := filepath.Glob(pattern); len(matches) == 1 {
|
||||
readArchivePath = matches[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
frames := []rawFrame(nil)
|
||||
total := 0
|
||||
var err error
|
||||
if _, statErr := os.Stat(readArchivePath); statErr == nil {
|
||||
frames, err = readRawArchive(readArchivePath)
|
||||
total = len(frames)
|
||||
} else if sourceArchiveDir != "" && os.IsNotExist(statErr) {
|
||||
return dayOutput{Candidate: c}, nil
|
||||
} else {
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
frames, total, err = fetchFrames(c.VIN, c.StatDate)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if attempt < 3 {
|
||||
time.Sleep(time.Duration(attempt) * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return dayOutput{}, fmt.Errorf("%s %s: %w", c.StatDate, c.VIN, err)
|
||||
}
|
||||
if total == 0 {
|
||||
return dayOutput{Candidate: c}, nil
|
||||
}
|
||||
sort.SliceStable(frames, func(i, j int) bool {
|
||||
if frames[i].EventTime != frames[j].EventTime {
|
||||
return frames[i].EventTime < frames[j].EventTime
|
||||
}
|
||||
if frames[i].SourceEndpoint != frames[j].SourceEndpoint {
|
||||
return frames[i].SourceEndpoint < frames[j].SourceEndpoint
|
||||
}
|
||||
return frames[i].TS < frames[j].TS
|
||||
})
|
||||
seen := map[string]bool{}
|
||||
duplicateFrames := 0
|
||||
observations := make([]openplatform.HydrogenObservation, 0, len(frames))
|
||||
criticalRows := 0
|
||||
for _, f := range frames {
|
||||
if f.FrameID != "" {
|
||||
if seen[f.FrameID] {
|
||||
duplicateFrames++
|
||||
}
|
||||
seen[f.FrameID] = true
|
||||
}
|
||||
obs, ok, critical := observationFromFrame(f, c.TankCapacityL, c.StatDate)
|
||||
if critical {
|
||||
criticalRows++
|
||||
}
|
||||
if ok {
|
||||
observations = append(observations, obs)
|
||||
}
|
||||
}
|
||||
params := map[string]openplatform.HydrogenCalculationParameters{c.VIN: {BatteryCapacityKWh: c.BatteryKWh, HydrogenEnergyKWhKg: hydrogenKWh}}
|
||||
stats := openplatform.BuildHydrogenDailyStatsOrderedWithParameters(observations, c.StatDate, 0.05, 20, params)
|
||||
var stat openplatform.HydrogenDailyStat
|
||||
if len(stats) == 1 {
|
||||
stat = stats[0]
|
||||
} else {
|
||||
stat = openplatform.HydrogenDailyStat{VIN: c.VIN, Date: c.StatDate, SampleCount: len(observations), QualityStatus: "NO_DATA", QualityReason: "无有效压力温度样本"}
|
||||
}
|
||||
roles := map[string]string{}
|
||||
for _, interval := range stat.Intervals {
|
||||
appendRole(roles, interval.StartEventID, fmt.Sprintf("运行区间%d(%s)起点", interval.Index, interval.Type))
|
||||
appendRole(roles, interval.EndEventID, fmt.Sprintf("运行区间%d(%s)终点", interval.Index, interval.Type))
|
||||
}
|
||||
for _, interval := range stat.HydrogenIntervals {
|
||||
appendRole(roles, interval.StartEventID, fmt.Sprintf("氢量分段%d起点", interval.Index))
|
||||
appendRole(roles, interval.EndEventID, fmt.Sprintf("氢量分段%d终点", interval.Index))
|
||||
}
|
||||
// Even when the original frames are reused, rewrite the audit CSV so its
|
||||
// “计算角色/排除原因” column matches the current algorithm version and
|
||||
// interval boundaries. The original HEX and parsed fields remain unchanged.
|
||||
if sourceArchiveDir == "" {
|
||||
if err := writeRawCSV(archivePath, c, frames, roles); err != nil {
|
||||
return dayOutput{}, err
|
||||
}
|
||||
}
|
||||
checksum, size, err := fileSHA256(readArchivePath)
|
||||
if err != nil {
|
||||
return dayOutput{}, err
|
||||
}
|
||||
out := dayOutput{Candidate: c, APIRawFrameCount: total, UniqueFrameCount: len(seen), DuplicateFrameCount: duplicateFrames, AlgorithmSamples: len(observations), CriticalFieldRows: criticalRows, RawArchiveFile: filepath.ToSlash(filepath.Join("raw", c.StatDate, archiveName)), RawArchiveSHA256: checksum, RawArchiveBytes: size, Stat: stat}
|
||||
if len(frames) > 0 {
|
||||
out.EarliestEventTime = frames[0].EventTime
|
||||
out.LatestEventTime = frames[len(frames)-1].EventTime
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func rawArchiveName(c candidate) string {
|
||||
return fmt.Sprintf("%04d_%s_%s_%s.csv.gz", c.SampleNo, safeName(c.Plate), c.VIN, c.StatDate)
|
||||
}
|
||||
|
||||
func appendRole(roles map[string]string, eventID, role string) {
|
||||
if eventID == "" {
|
||||
return
|
||||
}
|
||||
if roles[eventID] == "" {
|
||||
roles[eventID] = role
|
||||
return
|
||||
}
|
||||
roles[eventID] += ";" + role
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, int64, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer file.Close()
|
||||
hash := sha256.New()
|
||||
size, err := io.Copy(hash, file)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return fmt.Sprintf("%x", hash.Sum(nil)), size, nil
|
||||
}
|
||||
|
||||
func readRawArchive(path string) ([]rawFrame, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
gz, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer gz.Close()
|
||||
reader := csv.NewReader(gz)
|
||||
rows, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("empty raw archive %s", path)
|
||||
}
|
||||
columns := map[string]int{}
|
||||
for index, name := range rows[0] {
|
||||
columns[name] = index
|
||||
}
|
||||
cell := func(row []string, name string) string {
|
||||
index, ok := columns[name]
|
||||
if !ok || index >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return row[index]
|
||||
}
|
||||
frames := make([]rawFrame, 0, len(rows)-1)
|
||||
for _, row := range rows[1:] {
|
||||
fields := map[string]any{}
|
||||
if rawFields := cell(row, "完整解析字段JSON"); rawFields != "" {
|
||||
_ = json.Unmarshal([]byte(rawFields), &fields)
|
||||
}
|
||||
messageID, _ := strconv.Atoi(cell(row, "消息ID"))
|
||||
rawSize, _ := strconv.Atoi(cell(row, "原始字节数"))
|
||||
frames = append(frames, rawFrame{
|
||||
FrameID: cell(row, "帧ID"), EventID: cell(row, "事件ID"), MessageID: messageID,
|
||||
MessageIDHex: cell(row, "消息ID_HEX"), EventTime: cell(row, "事件时间"), ReceivedAt: cell(row, "接收时间"),
|
||||
RawSizeBytes: rawSize, RawHex: cell(row, "原始报文HEX"), ParsedFields: fields,
|
||||
ParseStatus: cell(row, "解析状态"), SourceEndpoint: cell(row, "源端点"), Protocol: "GB32960", VIN: cell(row, "VIN"),
|
||||
})
|
||||
}
|
||||
return frames, nil
|
||||
}
|
||||
|
||||
func fetchFrames(vin, statDate string) ([]rawFrame, int, error) {
|
||||
const limit = 500
|
||||
first, err := fetchPage(vin, statDate, 0, limit, true)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
frames := append([]rawFrame(nil), first.Items...)
|
||||
for offset := limit; offset < first.Total; offset += limit {
|
||||
page, err := fetchPage(vin, statDate, offset, limit, false)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
frames = append(frames, page.Items...)
|
||||
}
|
||||
if len(frames) != first.Total {
|
||||
return nil, first.Total, fmt.Errorf("API total=%d fetched=%d", first.Total, len(frames))
|
||||
}
|
||||
return frames, first.Total, nil
|
||||
}
|
||||
|
||||
func fetchPage(vin, statDate string, offset, limit int, includeTotal bool) (rawResponse, error) {
|
||||
q := url.Values{"protocol": {"GB32960"}, "vin": {vin}, "dateFrom": {statDate + " 00:00:00"}, "dateTo": {statDate + " 23:59:59"}, "orderBy": {"eventTime"}, "limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(offset)}, "includeFields": {"true"}, "includePayload": {"true"}, "includeTotal": {strconv.FormatBool(includeTotal)}}
|
||||
req, _ := http.NewRequest(http.MethodGet, baseURL+"/api/history/raw-frames?"+q.Encode(), nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
client := &http.Client{Timeout: 90 * time.Second}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return rawResponse{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if res.StatusCode == http.StatusInternalServerError && bytes.Contains(body, []byte("Table does not exist")) {
|
||||
return rawResponse{}, nil
|
||||
}
|
||||
return rawResponse{}, fmt.Errorf("HTTP %d: %s", res.StatusCode, body)
|
||||
}
|
||||
var reader io.Reader = res.Body
|
||||
if res.Header.Get("Content-Encoding") == "gzip" {
|
||||
gz, err := gzip.NewReader(res.Body)
|
||||
if err != nil {
|
||||
return rawResponse{}, err
|
||||
}
|
||||
defer gz.Close()
|
||||
reader = gz
|
||||
}
|
||||
var out rawResponse
|
||||
if err := json.NewDecoder(reader).Decode(&out); err != nil {
|
||||
return rawResponse{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func observationFromFrame(f rawFrame, tankCapacity float64, statDate string) (openplatform.HydrogenObservation, bool, bool) {
|
||||
if f.MessageID != 2 || f.ParseStatus != "OK" {
|
||||
return openplatform.HydrogenObservation{}, false, false
|
||||
}
|
||||
pressure, pok := num(f.ParsedFields["gb32960.fuel_cell.max_hydrogen_pressure_mpa"])
|
||||
temp, tok := num(f.ParsedFields["gb32960.fuel_cell.max_hydrogen_temperature_c"])
|
||||
critical := pok && tok
|
||||
if !pok || !tok || pressure <= 0 || pressure > 70 || temp <= -40 || temp > 726.85 {
|
||||
return openplatform.HydrogenObservation{}, false, critical
|
||||
}
|
||||
mass, ok := openplatform.PressureHydrogenMassKg(pressure, temp, tankCapacity)
|
||||
if !ok {
|
||||
return openplatform.HydrogenObservation{}, false, critical
|
||||
}
|
||||
step, _ := openplatform.PressureHydrogenMassKg(math.Max(0, pressure-0.2), temp, tankCapacity)
|
||||
parsed, _ := json.Marshal(f.ParsedFields)
|
||||
_, _, active, known, _ := openplatform.ExtractHydrogenTelemetry(string(parsed))
|
||||
t, err := parseEventTime(f.EventTime)
|
||||
if err != nil || t.Format("2006-01-02") != statDate {
|
||||
return openplatform.HydrogenObservation{}, false, critical
|
||||
}
|
||||
o := openplatform.HydrogenObservation{VIN: f.VIN, Source: f.SourceEndpoint, EventID: f.EventID, ObservedAt: t, MassKg: mass, TankCapacityLiter: tankCapacity, PressureMPa: pressure, TemperatureC: temp, NoiseKg: math.Min(1, math.Max(0.05, mass-step)), RefuelThresholdKg: math.Max(1, mass*0.05), FuelCellActive: active, FuelCellStateKnown: known}
|
||||
if voltage, vok := num(f.ParsedFields["gb32960.fuel_cell.fuel_cell_voltage_v"]); vok && voltage > 0 && voltage <= 1000 {
|
||||
if current, cok := num(f.ParsedFields["gb32960.fuel_cell.fuel_cell_current_a"]); cok && current >= 0 && current <= 2000 {
|
||||
o.FuelCellVoltageV = voltage
|
||||
o.FuelCellCurrentA = current
|
||||
o.FuelCellPowerKnown = true
|
||||
}
|
||||
}
|
||||
if v, ok := num(f.ParsedFields["gb32960.vehicle.soc_percent"]); ok && v >= 0 && v <= 100 {
|
||||
o.SOCPercent = v
|
||||
o.SOCKnown = true
|
||||
}
|
||||
if v, ok := num(f.ParsedFields["gb32960.vehicle.total_mileage_km"]); ok && v >= 0 {
|
||||
o.MileageKm = v
|
||||
o.MileageKnown = true
|
||||
}
|
||||
if v, ok := num(f.ParsedFields["gb32960.vehicle.vehicle_status"]); ok {
|
||||
o.VehicleState = int(v)
|
||||
o.VehicleStateKnown = v >= 0 && v <= 255
|
||||
}
|
||||
if v, ok := num(f.ParsedFields["gb32960.vehicle.charge_status"]); ok {
|
||||
o.ChargeState = int(v)
|
||||
o.ChargeStateKnown = v >= 0 && v <= 255
|
||||
}
|
||||
if v, ok := num(f.ParsedFields["gb32960.vehicle.running_mode"]); ok {
|
||||
o.RunningMode = int(v)
|
||||
o.RunningModeKnown = v >= 0 && v <= 255
|
||||
}
|
||||
return o, true, critical
|
||||
}
|
||||
|
||||
var rawHeaders = []string{"序号", "车牌", "VIN", "统计日期", "车型", "储氢总容积(L)", "额定电量(kWh)", "事件时间", "接收时间", "帧ID", "事件ID", "消息ID", "消息ID_HEX", "源端点", "解析状态", "原始字节数", "原始报文HEX", "完整解析字段JSON", "最高氢压(MPa)", "最高氢温(℃)", "电池SOC(%)", "仪表总里程(km)", "车辆状态", "充电状态", "运行模式", "燃料电池工作状态", "燃料电池电流(A)", "车端氢气质量(kg)", "系统压力换算剩余氢量(kg)", "噪声阈值(kg)", "是否算法有效样本", "计算角色/排除原因"}
|
||||
|
||||
func writeRawCSV(path string, c candidate, frames []rawFrame, roles map[string]string) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
gz := gzip.NewWriter(f)
|
||||
defer gz.Close()
|
||||
w := csv.NewWriter(gz)
|
||||
defer w.Flush()
|
||||
if err := w.Write(rawHeaders); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rf := range frames {
|
||||
o, valid, _ := observationFromFrame(rf, c.TankCapacityL, c.StatDate)
|
||||
role := roles[rf.EventID]
|
||||
if !valid {
|
||||
if t, err := parseEventTime(rf.EventTime); err == nil && t.Format("2006-01-02") != c.StatDate {
|
||||
role = "事件时间不在统计日,不进入计算"
|
||||
} else if rf.MessageID != 2 {
|
||||
role = "非实时信息上报帧,不进入计算"
|
||||
} else if rf.ParseStatus != "OK" {
|
||||
role = "解析状态非OK,不进入计算"
|
||||
} else {
|
||||
role = "压力/温度无效或缺失,不进入计算"
|
||||
}
|
||||
} else if role == "" {
|
||||
role = "有效候选帧(由分段规则判定)"
|
||||
}
|
||||
parsedJSON, _ := json.Marshal(rf.ParsedFields)
|
||||
row := []string{fmt.Sprintf("%04d", c.SampleNo), c.Plate, c.VIN, c.StatDate, c.Model, strconv.FormatFloat(c.TankCapacityL, 'f', 2, 64), strconv.FormatFloat(c.BatteryKWh, 'f', 2, 64), rf.EventTime, rf.ReceivedAt, rf.FrameID, rf.EventID, strconv.Itoa(rf.MessageID), rf.MessageIDHex, rf.SourceEndpoint, rf.ParseStatus, strconv.Itoa(rf.RawSizeBytes), rf.RawHex, string(parsedJSON), val(rf.ParsedFields, "gb32960.fuel_cell.max_hydrogen_pressure_mpa"), val(rf.ParsedFields, "gb32960.fuel_cell.max_hydrogen_temperature_c"), val(rf.ParsedFields, "gb32960.vehicle.soc_percent"), val(rf.ParsedFields, "gb32960.vehicle.total_mileage_km"), val(rf.ParsedFields, "gb32960.vehicle.vehicle_status"), val(rf.ParsedFields, "gb32960.vehicle.charge_status"), val(rf.ParsedFields, "gb32960.vehicle.running_mode"), val(rf.ParsedFields, "gb32960.gd_fc_stack.engine_work_state"), val(rf.ParsedFields, "gb32960.fuel_cell.fuel_cell_current_a"), val(rf.ParsedFields, "gb32960.gd_fc_vehicle_info.hydrogen_mass_kg"), blankFloat(valid, o.MassKg), blankFloat(valid, o.NoiseKg), yesNo(valid), role}
|
||||
if err := w.Write(row); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return w.Error()
|
||||
}
|
||||
|
||||
func writeManifest(path string, outputs []dayOutput) {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer f.Close()
|
||||
w := csv.NewWriter(f)
|
||||
defer w.Flush()
|
||||
_ = w.Write([]string{"序号", "车牌", "VIN", "日期", "车型", "储氢总容积(L)", "额定电量(kWh)", "API原始帧数", "唯一帧数", "重复帧数", "算法样本数", "关键字段帧数", "最早事件时间", "最晚事件时间", "原始文件", "压缩包SHA256", "压缩字节数", "质量状态", "质量原因"})
|
||||
for _, o := range outputs {
|
||||
_ = w.Write([]string{fmt.Sprintf("%04d", o.Candidate.SampleNo), o.Candidate.Plate, o.Candidate.VIN, o.Candidate.StatDate, o.Candidate.Model, strconv.FormatFloat(o.Candidate.TankCapacityL, 'f', 2, 64), strconv.FormatFloat(o.Candidate.BatteryKWh, 'f', 2, 64), strconv.Itoa(o.APIRawFrameCount), strconv.Itoa(o.UniqueFrameCount), strconv.Itoa(o.DuplicateFrameCount), strconv.Itoa(o.AlgorithmSamples), strconv.Itoa(o.CriticalFieldRows), o.EarliestEventTime, o.LatestEventTime, o.RawArchiveFile, o.RawArchiveSHA256, strconv.FormatInt(o.RawArchiveBytes, 10), o.Stat.QualityStatus, o.Stat.QualityReason})
|
||||
}
|
||||
}
|
||||
|
||||
func num(v any) (float64, bool) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x, true
|
||||
case string:
|
||||
n, e := strconv.ParseFloat(strings.TrimSpace(x), 64)
|
||||
return n, e == nil
|
||||
case json.Number:
|
||||
n, e := x.Float64()
|
||||
return n, e == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
func val(m map[string]any, k string) string {
|
||||
if v, ok := m[k]; ok {
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func blankFloat(ok bool, v float64) string {
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatFloat(v, 'f', 6, 64)
|
||||
}
|
||||
func yesNo(v bool) string {
|
||||
if v {
|
||||
return "是"
|
||||
}
|
||||
return "否"
|
||||
}
|
||||
func safeName(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return "无车牌"
|
||||
}
|
||||
return strings.NewReplacer("/", "_", "\\", "_", " ", "_").Replace(v)
|
||||
}
|
||||
func parseEventTime(value string) (time.Time, error) {
|
||||
loc := time.FixedZone("CST", 8*3600)
|
||||
if t, e := time.ParseInLocation("2006-01-02 15:04:05.000", value, loc); e == nil {
|
||||
return t, nil
|
||||
}
|
||||
return time.ParseInLocation("2006-01-02 15:04:05", value, loc)
|
||||
}
|
||||
func mustReadJSON(path string, v any) {
|
||||
b, e := os.ReadFile(path)
|
||||
if e != nil {
|
||||
panic(e)
|
||||
}
|
||||
if e = json.Unmarshal(b, v); e != nil {
|
||||
panic(e)
|
||||
}
|
||||
}
|
||||
func writeJSON(path string, v any) {
|
||||
b, e := json.MarshalIndent(v, "", " ")
|
||||
if e != nil {
|
||||
panic(e)
|
||||
}
|
||||
if e = os.WriteFile(path, b, 0o644); e != nil {
|
||||
panic(e)
|
||||
}
|
||||
}
|
||||
func sumRaw(v []dayOutput) int {
|
||||
n := 0
|
||||
for _, x := range v {
|
||||
n += x.APIRawFrameCount
|
||||
}
|
||||
return n
|
||||
}
|
||||
func sumSamples(v []dayOutput) int {
|
||||
n := 0
|
||||
for _, x := range v {
|
||||
n += x.AlgorithmSamples
|
||||
}
|
||||
return n
|
||||
}
|
||||
func sumIntervals(v []dayOutput) int {
|
||||
n := 0
|
||||
for _, x := range v {
|
||||
n += len(x.Stat.Intervals)
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args)!=3 { panic("usage: query_capacity <selected.json> <output.json>") }
|
||||
var selected []struct{ VIN string `json:"vin"` }
|
||||
b,err:=os.ReadFile(os.Args[1]);if err!=nil{panic(err)};if err=json.Unmarshal(b,&selected);err!=nil{panic(err)}
|
||||
prefix := "HYDROGEN_DB_"
|
||||
host := strings.TrimSpace(os.Getenv(prefix + "HOST"))
|
||||
port := strings.TrimSpace(os.Getenv(prefix + "PORT")); if port == "" { port = "3306" }
|
||||
name := strings.TrimSpace(os.Getenv(prefix + "NAME"))
|
||||
user := os.Getenv(prefix + "USER"); pass := os.Getenv(prefix + "PASSWORD")
|
||||
dsn := user+":"+pass+"@tcp("+net.JoinHostPort(host,port)+")/"+name+"?parseTime=true&loc="+url.QueryEscape("Asia/Shanghai")+"&timeout=5s&readTimeout=15s"
|
||||
db,err:=sql.Open("mysql",dsn); if err!=nil{panic(err)}; defer db.Close(); db.SetConnMaxLifetime(time.Minute); if err=db.Ping();err!=nil{panic(err)}
|
||||
vins:=make([]string,0,len(selected));args:=make([]any,0,len(selected));for _,s:=range selected{v:=strings.ToUpper(strings.TrimSpace(s.VIN));vins=append(vins,v);args=append(args,v)}
|
||||
placeholders:=strings.TrimRight(strings.Repeat("?,",len(vins)),",")
|
||||
rows,err:=db.Query(`SELECT UPPER(TRIM(vin)),plate_number,brand_name,model_name,tank_capacity_l,active,source_model_id,source_updated_at,synced_at FROM lingniu_vehicle_data.vehicle_hydrogen_tank_capacity WHERE UPPER(TRIM(vin)) IN (`+placeholders+`) ORDER BY vin`,args...);if err!=nil{panic(err)};defer rows.Close()
|
||||
type row struct{VIN,Plate,Brand,Model string;TankCapacityL float64;Active int;SourceModelID *int64;SourceUpdatedAt, SyncedAt *time.Time}
|
||||
out:=make([]row,0,len(vins));for rows.Next(){var r row;if err=rows.Scan(&r.VIN,&r.Plate,&r.Brand,&r.Model,&r.TankCapacityL,&r.Active,&r.SourceModelID,&r.SourceUpdatedAt,&r.SyncedAt);err!=nil{panic(err)};out=append(out,r)};if err=rows.Err();err!=nil{panic(err)}
|
||||
sort.Slice(out,func(i,j int)bool{return out[i].VIN<out[j].VIN});data,err:=json.MarshalIndent(out,""," ");if err!=nil{panic(err)};if err=os.WriteFile(os.Args[2],data,0o644);err!=nil{panic(err)}
|
||||
counts:=map[float64]int{};models:=map[string]int{};for _,r:=range out{counts[r.TankCapacityL]++;models[r.Model]++}
|
||||
fmt.Printf("requested=%d found=%d capacities=%v models=%v\n",len(vins),len(out),counts,models)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
type metadataRecord struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
Brand string `json:"brand"`
|
||||
Model string `json:"model"`
|
||||
VehicleModelID int64 `json:"vehicle_model_id"`
|
||||
BatteryCapacityKWh float64 `json:"battery_capacity_kwh"`
|
||||
TankCapacityL float64 `json:"tank_capacity_l"`
|
||||
SourceUpdatedAt *time.Time `json:"source_updated_at,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 2 {
|
||||
panic("usage: query_metadata <output.json>")
|
||||
}
|
||||
dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN"))
|
||||
if dsn == "" {
|
||||
panic("MYSQL_DSN is required")
|
||||
}
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer db.Close()
|
||||
rows, err := db.Query(`SELECT UPPER(TRIM(vi.vin)),COALESCE(vi.plate_number,''),
|
||||
COALESCE(vm.brand,''),COALESCE(vm.model,''),vm.id,vm.battery_capacity,vm.tank_capacity,
|
||||
CASE
|
||||
WHEN vi.update_time IS NULL THEN vm.update_time
|
||||
WHEN vm.update_time IS NULL THEN vi.update_time
|
||||
ELSE GREATEST(vi.update_time,vm.update_time)
|
||||
END
|
||||
FROM ln_asset_management.vehicle_info vi
|
||||
JOIN ln_asset_management.vehicle_model vm ON vm.id=vi.vehicle_model_id
|
||||
WHERE COALESCE(vi.del_flag,'0')='0' AND COALESCE(vm.del_flag,'0')='0'
|
||||
AND LOWER(TRIM(vm.brand))='hyundai'
|
||||
AND LENGTH(TRIM(vi.vin))=17
|
||||
AND vm.tank_capacity>0
|
||||
AND vm.battery_capacity>0
|
||||
ORDER BY vi.vin,vi.update_time DESC,vi.id DESC`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
byVIN := map[string]metadataRecord{}
|
||||
for rows.Next() {
|
||||
var row metadataRecord
|
||||
var updated sql.NullTime
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.Brand, &row.Model, &row.VehicleModelID, &row.BatteryCapacityKWh, &row.TankCapacityL, &updated); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if _, exists := byVIN[row.VIN]; exists {
|
||||
continue
|
||||
}
|
||||
if updated.Valid {
|
||||
value := updated.Time
|
||||
row.SourceUpdatedAt = &value
|
||||
}
|
||||
byVIN[row.VIN] = row
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
vins := make([]string, 0, len(byVIN))
|
||||
for vin := range byVIN {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
sort.Strings(vins)
|
||||
out := make([]metadataRecord, 0, len(vins))
|
||||
for _, vin := range vins {
|
||||
out = append(out, byVIN[vin])
|
||||
}
|
||||
output, err := os.Create(os.Args[1])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer output.Close()
|
||||
encoder := json.NewEncoder(output)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(out); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "http://115.29.187.205:20200"
|
||||
hydrogenEnergyKWh = 16.0
|
||||
maxIntegrationGap = 30 * time.Second
|
||||
)
|
||||
|
||||
type vehicleMetadata struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
Model string `json:"model"`
|
||||
BatteryCapacityKWh float64 `json:"battery_capacity_kwh"`
|
||||
TankCapacityL float64 `json:"tank_capacity_l"`
|
||||
}
|
||||
|
||||
type rawFrame struct {
|
||||
TS string `json:"ts"`
|
||||
FrameID string `json:"frame_id"`
|
||||
EventID string `json:"event_id"`
|
||||
MessageID int `json:"message_id"`
|
||||
EventTime string `json:"event_time"`
|
||||
ParseStatus string `json:"parse_status"`
|
||||
SourceEndpoint string `json:"source_endpoint"`
|
||||
VIN string `json:"vin"`
|
||||
ParsedFields map[string]any `json:"parsed_fields"`
|
||||
}
|
||||
|
||||
type rawResponse struct {
|
||||
Items []rawFrame `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type energySample struct {
|
||||
At time.Time
|
||||
VehicleStatus int
|
||||
ChargeStatus int
|
||||
SOC float64
|
||||
Mileage float64
|
||||
BatteryVoltageV float64
|
||||
BatteryCurrentA float64
|
||||
FuelCellVoltageV float64
|
||||
FuelCellCurrentA float64
|
||||
HasSOC bool
|
||||
HasMileage bool
|
||||
HasBatteryPower bool
|
||||
HasFuelCellPower bool
|
||||
HasVehicleStatus bool
|
||||
HasChargeStatus bool
|
||||
}
|
||||
|
||||
type energyResult struct {
|
||||
FirstTime string `json:"firstTime"`
|
||||
LastTime string `json:"lastTime"`
|
||||
StartSOC float64 `json:"startSoc"`
|
||||
EndSOC float64 `json:"endSoc"`
|
||||
SOCChangePctEndMinusStart float64 `json:"socChangePctEndMinusStart"`
|
||||
StoredEnergyChangeKWhSOC float64 `json:"storedEnergyChangeKWhSoc"`
|
||||
BatteryNetOutputKWhSOC float64 `json:"batteryNetOutputKWhSoc"`
|
||||
BatteryNetOutputKWhIntegrated float64 `json:"batteryNetOutputKWhIntegrated"`
|
||||
FuelCellOutputKWhIntegrated float64 `json:"fuelCellOutputKWhIntegrated"`
|
||||
BatteryCoverageSeconds float64 `json:"batteryCoverageSeconds"`
|
||||
FuelCellCoverageSeconds float64 `json:"fuelCellCoverageSeconds"`
|
||||
OperatingSpanSeconds float64 `json:"operatingSpanSeconds"`
|
||||
BatteryCoverageRatio float64 `json:"batteryCoverageRatio"`
|
||||
FuelCellCoverageRatio float64 `json:"fuelCellCoverageRatio"`
|
||||
ExternalChargeFrameCount int `json:"externalChargeFrameCount"`
|
||||
EnergySampleCount int `json:"energySampleCount"`
|
||||
}
|
||||
|
||||
type row struct {
|
||||
Date string `json:"date"`
|
||||
Plate string `json:"plate"`
|
||||
VIN string `json:"vin"`
|
||||
Model string `json:"model"`
|
||||
RawFrameCount int `json:"rawFrameCount"`
|
||||
CurrentQuality string `json:"currentQuality"`
|
||||
CurrentReason string `json:"currentReason"`
|
||||
RefuelCount int `json:"refuelCount"`
|
||||
ChargeCount int `json:"chargeCount"`
|
||||
TotalMileageKm float64 `json:"totalMileageKm"`
|
||||
PureElectricMileageKm float64 `json:"pureElectricMileageKm"`
|
||||
CurrentMixedMileageKm float64 `json:"currentMixedMileageKm"`
|
||||
PhysicalHydrogenKg float64 `json:"physicalHydrogenKg"`
|
||||
CurrentSOCBalancedKg float64 `json:"currentSocBalancedKg"`
|
||||
CurrentRatePerMixedKm float64 `json:"currentRatePerMixedKm"`
|
||||
PhysicalRatePerTotalKm float64 `json:"physicalRatePerTotalKm"`
|
||||
FullDayStartSOC float64 `json:"fullDayStartSoc"`
|
||||
FullDayEndSOC float64 `json:"fullDayEndSoc"`
|
||||
FullDaySOCChangePct float64 `json:"fullDaySocChangePct"`
|
||||
StoredEnergyChangeKWhSOC float64 `json:"storedEnergyChangeKWhSoc"`
|
||||
BatteryEquivalentKgFixed16 float64 `json:"batteryEquivalentKgFixed16"`
|
||||
StandardLikeBalancedKg float64 `json:"standardLikeBalancedKg"`
|
||||
StandardLikeRatePerTotalKm float64 `json:"standardLikeRatePerTotalKm"`
|
||||
StandardLikeApplicable bool `json:"standardLikeApplicable"`
|
||||
StandardLikeReason string `json:"standardLikeReason"`
|
||||
BatteryNetOutputKWhIntegrated float64 `json:"batteryNetOutputKWhIntegrated"`
|
||||
FuelCellOutputKWhIntegrated float64 `json:"fuelCellOutputKWhIntegrated"`
|
||||
BatteryEnergyShare float64 `json:"batteryEnergyShare"`
|
||||
FuelCellEnergyShare float64 `json:"fuelCellEnergyShare"`
|
||||
BatteryContributionKm float64 `json:"batteryContributionKm"`
|
||||
FuelCellContributionKm float64 `json:"fuelCellContributionKm"`
|
||||
HydrogenRatePerFCContributionKm float64 `json:"hydrogenRatePerFcContributionKm"`
|
||||
BatteryCoverageRatio float64 `json:"batteryCoverageRatio"`
|
||||
FuelCellCoverageRatio float64 `json:"fuelCellCoverageRatio"`
|
||||
ExternalChargeFrameCount int `json:"externalChargeFrameCount"`
|
||||
PowerIntegrationUsable bool `json:"powerIntegrationUsable"`
|
||||
}
|
||||
|
||||
type job struct {
|
||||
Vehicle vehicleMetadata
|
||||
Date string
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 5 {
|
||||
panic("usage: standard-model-trial <metadata.json> <start-date> <end-date> <output-dir>")
|
||||
}
|
||||
metadataPath, startDate, endDate, outputDir := os.Args[1], os.Args[2], os.Args[3], os.Args[4]
|
||||
start := mustDate(startDate)
|
||||
end := mustDate(endDate)
|
||||
if end.Before(start) {
|
||||
panic("end date is before start date")
|
||||
}
|
||||
var vehicles []vehicleMetadata
|
||||
mustReadJSON(metadataPath, &vehicles)
|
||||
if len(vehicles) == 0 {
|
||||
panic("metadata is empty")
|
||||
}
|
||||
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
jobs := make(chan job)
|
||||
results := make(chan row)
|
||||
errs := make(chan error, 128)
|
||||
var wg sync.WaitGroup
|
||||
for worker := 0; worker < 16; worker++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for item := range jobs {
|
||||
result, err := process(item)
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("%s %s %s: %w", item.Date, item.Vehicle.Plate, item.Vehicle.VIN, err)
|
||||
continue
|
||||
}
|
||||
if result.RawFrameCount > 0 {
|
||||
results <- result
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
for day := start; !day.After(end); day = day.AddDate(0, 0, 1) {
|
||||
for _, vehicle := range vehicles {
|
||||
if len(strings.TrimSpace(vehicle.VIN)) != 17 || vehicle.TankCapacityL <= 0 || vehicle.BatteryCapacityKWh <= 0 {
|
||||
continue
|
||||
}
|
||||
jobs <- job{Vehicle: vehicle, Date: day.Format("2006-01-02")}
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(errs)
|
||||
}()
|
||||
|
||||
var rows []row
|
||||
completed := 0
|
||||
for result := range results {
|
||||
rows = append(rows, result)
|
||||
completed++
|
||||
if completed%25 == 0 {
|
||||
fmt.Fprintf(os.Stderr, "completed=%d latest=%s %s quality=%s raw=%d\n", completed, result.Date, result.Plate, result.CurrentQuality, result.RawFrameCount)
|
||||
}
|
||||
}
|
||||
var errorMessages []string
|
||||
for err := range errs {
|
||||
errorMessages = append(errorMessages, err.Error())
|
||||
}
|
||||
if len(errorMessages) > 0 {
|
||||
panic(strings.Join(errorMessages, "\n"))
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].Date != rows[j].Date {
|
||||
return rows[i].Date < rows[j].Date
|
||||
}
|
||||
if rows[i].Plate != rows[j].Plate {
|
||||
return rows[i].Plate < rows[j].Plate
|
||||
}
|
||||
return rows[i].VIN < rows[j].VIN
|
||||
})
|
||||
writeJSON(filepath.Join(outputDir, "results.json"), rows)
|
||||
writeCSV(filepath.Join(outputDir, "results.csv"), rows)
|
||||
writeJSON(filepath.Join(outputDir, "summary.json"), summarize(rows))
|
||||
fmt.Printf("vehicle_days=%d output=%s\n", len(rows), outputDir)
|
||||
}
|
||||
|
||||
func process(item job) (row, error) {
|
||||
frames, total, err := fetchFrames(item.Vehicle.VIN, item.Date)
|
||||
if err != nil || total == 0 {
|
||||
return row{Date: item.Date, Plate: item.Vehicle.Plate, VIN: item.Vehicle.VIN, Model: item.Vehicle.Model, RawFrameCount: total}, err
|
||||
}
|
||||
sort.SliceStable(frames, func(i, j int) bool {
|
||||
if frames[i].EventTime != frames[j].EventTime {
|
||||
return frames[i].EventTime < frames[j].EventTime
|
||||
}
|
||||
return frames[i].EventID < frames[j].EventID
|
||||
})
|
||||
observations := make([]openplatform.HydrogenObservation, 0, len(frames))
|
||||
energySamples := make([]energySample, 0, len(frames))
|
||||
for _, frame := range frames {
|
||||
if observation, ok := observationFromFrame(frame, item.Vehicle.TankCapacityL, item.Date); ok {
|
||||
observations = append(observations, observation)
|
||||
}
|
||||
if sample, ok := energySampleFromFrame(frame, item.Date); ok {
|
||||
energySamples = append(energySamples, sample)
|
||||
}
|
||||
}
|
||||
params := map[string]openplatform.HydrogenCalculationParameters{
|
||||
item.Vehicle.VIN: {BatteryCapacityKWh: item.Vehicle.BatteryCapacityKWh, HydrogenEnergyKWhKg: hydrogenEnergyKWh},
|
||||
}
|
||||
stats := openplatform.BuildHydrogenDailyStatsOrderedWithParameters(observations, item.Date, 0.05, 20, params)
|
||||
stat := openplatform.HydrogenDailyStat{VIN: item.Vehicle.VIN, Date: item.Date, QualityStatus: "NO_DATA", QualityReason: "无有效压力温度样本"}
|
||||
if len(stats) == 1 {
|
||||
stat = stats[0]
|
||||
}
|
||||
energy := calculateEnergy(energySamples, item.Vehicle.BatteryCapacityKWh)
|
||||
totalMileage := stat.MixedMileageKm + stat.PureElectricMileageKm
|
||||
result := row{
|
||||
Date: item.Date, Plate: item.Vehicle.Plate, VIN: item.Vehicle.VIN, Model: item.Vehicle.Model,
|
||||
RawFrameCount: total, CurrentQuality: stat.QualityStatus, CurrentReason: stat.QualityReason,
|
||||
RefuelCount: stat.RefuelCount, ChargeCount: stat.ChargeCount,
|
||||
TotalMileageKm: totalMileage, PureElectricMileageKm: stat.PureElectricMileageKm,
|
||||
CurrentMixedMileageKm: stat.MixedMileageKm, PhysicalHydrogenKg: stat.ConsumptionKg,
|
||||
FullDayStartSOC: energy.StartSOC, FullDayEndSOC: energy.EndSOC,
|
||||
FullDaySOCChangePct: energy.SOCChangePctEndMinusStart,
|
||||
StoredEnergyChangeKWhSOC: energy.StoredEnergyChangeKWhSOC,
|
||||
BatteryNetOutputKWhIntegrated: energy.BatteryNetOutputKWhIntegrated,
|
||||
FuelCellOutputKWhIntegrated: energy.FuelCellOutputKWhIntegrated,
|
||||
BatteryCoverageRatio: energy.BatteryCoverageRatio,
|
||||
FuelCellCoverageRatio: energy.FuelCellCoverageRatio,
|
||||
ExternalChargeFrameCount: energy.ExternalChargeFrameCount,
|
||||
}
|
||||
if stat.SOCBalancedConsumptionKg != nil {
|
||||
result.CurrentSOCBalancedKg = *stat.SOCBalancedConsumptionKg
|
||||
}
|
||||
if stat.SOCBalancedKgPer100Km != nil {
|
||||
result.CurrentRatePerMixedKm = *stat.SOCBalancedKgPer100Km
|
||||
}
|
||||
if totalMileage > 0 {
|
||||
result.PhysicalRatePerTotalKm = round(stat.ConsumptionKg * 100 / totalMileage)
|
||||
result.BatteryEquivalentKgFixed16 = round(-energy.StoredEnergyChangeKWhSOC / hydrogenEnergyKWh)
|
||||
result.StandardLikeBalancedKg = round(stat.ConsumptionKg + result.BatteryEquivalentKgFixed16)
|
||||
result.StandardLikeRatePerTotalKm = round(result.StandardLikeBalancedKg * 100 / totalMileage)
|
||||
switch {
|
||||
case energy.ExternalChargeFrameCount > 0:
|
||||
result.StandardLikeReason = "检测到外部充电,整日SOC平衡修正不适用,应按CD/CS分段"
|
||||
case result.StandardLikeBalancedKg < 0:
|
||||
result.StandardLikeReason = "SOC平衡氢量为负,端点、SOC或换算系数需复核"
|
||||
default:
|
||||
result.StandardLikeApplicable = true
|
||||
}
|
||||
} else {
|
||||
result.StandardLikeReason = "无有效总里程"
|
||||
}
|
||||
// The GB/T 43252 contribution trial uses positive battery net output and
|
||||
// fuel-cell output measured over non-external-charge vehicle-on intervals.
|
||||
// Coverage gates prevent sparse power fields from being treated as precise.
|
||||
if totalMileage >= 10 && stat.ConsumptionKg > 0 && energy.ExternalChargeFrameCount == 0 && energy.BatteryCoverageRatio >= 0.98 && energy.FuelCellCoverageRatio >= 0.98 && energy.FuelCellOutputKWhIntegrated > 0 {
|
||||
batteryOutput := math.Max(0, energy.BatteryNetOutputKWhIntegrated)
|
||||
totalOutput := batteryOutput + energy.FuelCellOutputKWhIntegrated
|
||||
if totalOutput > 0 {
|
||||
result.PowerIntegrationUsable = true
|
||||
result.BatteryEnergyShare = round(batteryOutput / totalOutput)
|
||||
result.FuelCellEnergyShare = round(energy.FuelCellOutputKWhIntegrated / totalOutput)
|
||||
result.BatteryContributionKm = round(totalMileage * result.BatteryEnergyShare)
|
||||
result.FuelCellContributionKm = round(totalMileage * result.FuelCellEnergyShare)
|
||||
if result.FuelCellContributionKm > 0 {
|
||||
result.HydrogenRatePerFCContributionKm = round(stat.ConsumptionKg * 100 / result.FuelCellContributionKm)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func calculateEnergy(samples []energySample, batteryCapacityKWh float64) energyResult {
|
||||
result := energyResult{EnergySampleCount: len(samples)}
|
||||
if len(samples) == 0 {
|
||||
return result
|
||||
}
|
||||
for _, sample := range samples {
|
||||
if sample.HasChargeStatus && sample.ChargeStatus == 1 {
|
||||
result.ExternalChargeFrameCount++
|
||||
}
|
||||
}
|
||||
operating := make([]energySample, 0, len(samples))
|
||||
for _, sample := range samples {
|
||||
if sample.HasVehicleStatus && sample.VehicleStatus == 1 && (!sample.HasChargeStatus || sample.ChargeStatus != 1) {
|
||||
operating = append(operating, sample)
|
||||
}
|
||||
}
|
||||
if len(operating) == 0 {
|
||||
return result
|
||||
}
|
||||
result.FirstTime = operating[0].At.Format(time.RFC3339)
|
||||
result.LastTime = operating[len(operating)-1].At.Format(time.RFC3339)
|
||||
result.OperatingSpanSeconds = operating[len(operating)-1].At.Sub(operating[0].At).Seconds()
|
||||
for _, sample := range operating {
|
||||
if sample.HasSOC {
|
||||
result.StartSOC = sample.SOC
|
||||
break
|
||||
}
|
||||
}
|
||||
for index := len(operating) - 1; index >= 0; index-- {
|
||||
if operating[index].HasSOC {
|
||||
result.EndSOC = operating[index].SOC
|
||||
break
|
||||
}
|
||||
}
|
||||
result.SOCChangePctEndMinusStart = round(result.EndSOC - result.StartSOC)
|
||||
result.StoredEnergyChangeKWhSOC = round(batteryCapacityKWh * result.SOCChangePctEndMinusStart / 100)
|
||||
result.BatteryNetOutputKWhSOC = round(-result.StoredEnergyChangeKWhSOC)
|
||||
for index := 1; index < len(operating); index++ {
|
||||
previous, current := operating[index-1], operating[index]
|
||||
delta := current.At.Sub(previous.At)
|
||||
if delta <= 0 || delta > maxIntegrationGap {
|
||||
continue
|
||||
}
|
||||
hours := delta.Hours()
|
||||
if previous.HasBatteryPower && current.HasBatteryPower {
|
||||
power0 := previous.BatteryVoltageV * previous.BatteryCurrentA / 1000
|
||||
power1 := current.BatteryVoltageV * current.BatteryCurrentA / 1000
|
||||
result.BatteryNetOutputKWhIntegrated += (power0 + power1) * 0.5 * hours
|
||||
result.BatteryCoverageSeconds += delta.Seconds()
|
||||
}
|
||||
if previous.HasFuelCellPower && current.HasFuelCellPower {
|
||||
power0 := previous.FuelCellVoltageV * previous.FuelCellCurrentA / 1000
|
||||
power1 := current.FuelCellVoltageV * current.FuelCellCurrentA / 1000
|
||||
result.FuelCellOutputKWhIntegrated += (power0 + power1) * 0.5 * hours
|
||||
result.FuelCellCoverageSeconds += delta.Seconds()
|
||||
}
|
||||
}
|
||||
result.BatteryNetOutputKWhIntegrated = round(result.BatteryNetOutputKWhIntegrated)
|
||||
result.FuelCellOutputKWhIntegrated = round(result.FuelCellOutputKWhIntegrated)
|
||||
if result.OperatingSpanSeconds > 0 {
|
||||
result.BatteryCoverageRatio = round(result.BatteryCoverageSeconds / result.OperatingSpanSeconds)
|
||||
result.FuelCellCoverageRatio = round(result.FuelCellCoverageSeconds / result.OperatingSpanSeconds)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func observationFromFrame(frame rawFrame, tankCapacity float64, statDate string) (openplatform.HydrogenObservation, bool) {
|
||||
if frame.MessageID != 2 || !strings.EqualFold(frame.ParseStatus, "OK") {
|
||||
return openplatform.HydrogenObservation{}, false
|
||||
}
|
||||
pressure, pressureOK := number(frame.ParsedFields["gb32960.fuel_cell.max_hydrogen_pressure_mpa"])
|
||||
temperature, temperatureOK := number(frame.ParsedFields["gb32960.fuel_cell.max_hydrogen_temperature_c"])
|
||||
if !pressureOK || !temperatureOK || pressure <= 0 || pressure > 70 || temperature <= -40 || temperature > 726.85 {
|
||||
return openplatform.HydrogenObservation{}, false
|
||||
}
|
||||
mass, ok := openplatform.PressureHydrogenMassKg(pressure, temperature, tankCapacity)
|
||||
if !ok {
|
||||
return openplatform.HydrogenObservation{}, false
|
||||
}
|
||||
step, _ := openplatform.PressureHydrogenMassKg(math.Max(0, pressure-0.2), temperature, tankCapacity)
|
||||
at, err := parseEventTime(frame.EventTime)
|
||||
if err != nil || at.Format("2006-01-02") != statDate {
|
||||
return openplatform.HydrogenObservation{}, false
|
||||
}
|
||||
parsed, _ := json.Marshal(frame.ParsedFields)
|
||||
_, _, active, known, _ := openplatform.ExtractHydrogenTelemetry(string(parsed))
|
||||
observation := openplatform.HydrogenObservation{
|
||||
VIN: frame.VIN, Source: frame.SourceEndpoint, EventID: frame.EventID, ObservedAt: at,
|
||||
MassKg: mass, TankCapacityLiter: tankCapacity, PressureMPa: pressure, TemperatureC: temperature,
|
||||
NoiseKg: math.Min(1, math.Max(0.05, mass-step)), RefuelThresholdKg: math.Max(1, mass*0.05),
|
||||
FuelCellActive: active, FuelCellStateKnown: known,
|
||||
}
|
||||
if value, ok := number(frame.ParsedFields["gb32960.vehicle.soc_percent"]); ok && value >= 0 && value <= 100 {
|
||||
observation.SOCPercent, observation.SOCKnown = value, true
|
||||
}
|
||||
if value, ok := number(frame.ParsedFields["gb32960.vehicle.total_mileage_km"]); ok && value >= 0 {
|
||||
observation.MileageKm, observation.MileageKnown = value, true
|
||||
}
|
||||
if value, ok := number(frame.ParsedFields["gb32960.vehicle.vehicle_status"]); ok {
|
||||
observation.VehicleState, observation.VehicleStateKnown = int(value), value >= 0 && value <= 255
|
||||
}
|
||||
if value, ok := number(frame.ParsedFields["gb32960.vehicle.charge_status"]); ok {
|
||||
observation.ChargeState, observation.ChargeStateKnown = int(value), value >= 0 && value <= 255
|
||||
}
|
||||
if value, ok := number(frame.ParsedFields["gb32960.vehicle.running_mode"]); ok {
|
||||
observation.RunningMode, observation.RunningModeKnown = int(value), value >= 0 && value <= 255
|
||||
}
|
||||
return observation, true
|
||||
}
|
||||
|
||||
func energySampleFromFrame(frame rawFrame, statDate string) (energySample, bool) {
|
||||
if frame.MessageID != 2 || !strings.EqualFold(frame.ParseStatus, "OK") {
|
||||
return energySample{}, false
|
||||
}
|
||||
at, err := parseEventTime(frame.EventTime)
|
||||
if err != nil || at.Format("2006-01-02") != statDate {
|
||||
return energySample{}, false
|
||||
}
|
||||
sample := energySample{At: at}
|
||||
if value, ok := number(frame.ParsedFields["gb32960.vehicle.vehicle_status"]); ok {
|
||||
sample.VehicleStatus, sample.HasVehicleStatus = int(value), true
|
||||
}
|
||||
if value, ok := number(frame.ParsedFields["gb32960.vehicle.charge_status"]); ok {
|
||||
sample.ChargeStatus, sample.HasChargeStatus = int(value), true
|
||||
}
|
||||
if value, ok := number(frame.ParsedFields["gb32960.vehicle.soc_percent"]); ok && value >= 0 && value <= 100 {
|
||||
sample.SOC, sample.HasSOC = value, true
|
||||
}
|
||||
if value, ok := number(frame.ParsedFields["gb32960.vehicle.total_mileage_km"]); ok && value >= 0 {
|
||||
sample.Mileage, sample.HasMileage = value, true
|
||||
}
|
||||
batteryVoltage, batteryVoltageOK := number(frame.ParsedFields["gb32960.vehicle.total_voltage_v"])
|
||||
batteryCurrent, batteryCurrentOK := number(frame.ParsedFields["gb32960.vehicle.total_current_a"])
|
||||
if batteryVoltageOK && batteryCurrentOK && batteryVoltage > 0 && batteryVoltage <= 1000 && batteryCurrent >= -1000 && batteryCurrent <= 1000 {
|
||||
sample.BatteryVoltageV, sample.BatteryCurrentA, sample.HasBatteryPower = batteryVoltage, batteryCurrent, true
|
||||
}
|
||||
fuelCellVoltage, fuelCellVoltageOK := number(frame.ParsedFields["gb32960.fuel_cell.fuel_cell_voltage_v"])
|
||||
fuelCellCurrent, fuelCellCurrentOK := number(frame.ParsedFields["gb32960.fuel_cell.fuel_cell_current_a"])
|
||||
if fuelCellVoltageOK && fuelCellCurrentOK && fuelCellVoltage >= 0 && fuelCellVoltage <= 2000 && fuelCellCurrent >= 0 && fuelCellCurrent <= 2000 {
|
||||
sample.FuelCellVoltageV, sample.FuelCellCurrentA, sample.HasFuelCellPower = fuelCellVoltage, fuelCellCurrent, true
|
||||
}
|
||||
return sample, sample.HasVehicleStatus || sample.HasSOC || sample.HasBatteryPower || sample.HasFuelCellPower
|
||||
}
|
||||
|
||||
func fetchFrames(vin, date string) ([]rawFrame, int, error) {
|
||||
const limit = 500
|
||||
first, err := fetchPage(vin, date, 0, limit, true)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
frames := append([]rawFrame(nil), first.Items...)
|
||||
for offset := limit; offset < first.Total; offset += limit {
|
||||
page, err := fetchPage(vin, date, offset, limit, false)
|
||||
if err != nil {
|
||||
return nil, first.Total, err
|
||||
}
|
||||
frames = append(frames, page.Items...)
|
||||
}
|
||||
if len(frames) != first.Total {
|
||||
return nil, first.Total, fmt.Errorf("API total=%d fetched=%d", first.Total, len(frames))
|
||||
}
|
||||
return frames, first.Total, nil
|
||||
}
|
||||
|
||||
func fetchPage(vin, date string, offset, limit int, includeTotal bool) (rawResponse, error) {
|
||||
query := url.Values{
|
||||
"protocol": {"GB32960"}, "vin": {vin},
|
||||
"dateFrom": {date + " 00:00:00"}, "dateTo": {date + " 23:59:59"},
|
||||
"orderBy": {"eventTime"}, "limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(offset)},
|
||||
"includeFields": {"true"}, "includePayload": {"false"}, "includeTotal": {strconv.FormatBool(includeTotal)},
|
||||
}
|
||||
var response *http.Response
|
||||
var err error
|
||||
for attempt := 1; attempt <= 4; attempt++ {
|
||||
request, _ := http.NewRequest(http.MethodGet, baseURL+"/api/history/raw-frames?"+query.Encode(), nil)
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
response, err = (&http.Client{Timeout: 90 * time.Second}).Do(request)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if attempt < 4 {
|
||||
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return rawResponse{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
if response.StatusCode == http.StatusInternalServerError && bytes.Contains(body, []byte("Table does not exist")) {
|
||||
return rawResponse{}, nil
|
||||
}
|
||||
return rawResponse{}, fmt.Errorf("HTTP %d: %s", response.StatusCode, body)
|
||||
}
|
||||
var reader io.Reader = response.Body
|
||||
if response.Header.Get("Content-Encoding") == "gzip" {
|
||||
gzipReader, err := gzip.NewReader(response.Body)
|
||||
if err != nil {
|
||||
return rawResponse{}, err
|
||||
}
|
||||
defer gzipReader.Close()
|
||||
reader = gzipReader
|
||||
}
|
||||
var result rawResponse
|
||||
if err := json.NewDecoder(reader).Decode(&result); err != nil {
|
||||
return rawResponse{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func summarize(rows []row) map[string]any {
|
||||
byDate := map[string]map[string]any{}
|
||||
for _, date := range uniqueDates(rows) {
|
||||
var dateRows []row
|
||||
for _, value := range rows {
|
||||
if value.Date == date {
|
||||
dateRows = append(dateRows, value)
|
||||
}
|
||||
}
|
||||
quality := map[string]int{}
|
||||
valid := 0
|
||||
standardApplicable := 0
|
||||
powerUsable := 0
|
||||
physicalTotal, balancedTotal, mileageTotal, balancedMileageTotal := 0.0, 0.0, 0.0, 0.0
|
||||
for _, value := range dateRows {
|
||||
quality[value.CurrentQuality]++
|
||||
if value.CurrentQuality != "NO_DATA" && value.TotalMileageKm > 0 {
|
||||
valid++
|
||||
physicalTotal += value.PhysicalHydrogenKg
|
||||
mileageTotal += value.TotalMileageKm
|
||||
}
|
||||
if value.StandardLikeApplicable {
|
||||
standardApplicable++
|
||||
balancedTotal += value.StandardLikeBalancedKg
|
||||
balancedMileageTotal += value.TotalMileageKm
|
||||
}
|
||||
if value.PowerIntegrationUsable {
|
||||
powerUsable++
|
||||
}
|
||||
}
|
||||
physicalRate, balancedRate := 0.0, 0.0
|
||||
if mileageTotal > 0 {
|
||||
physicalRate = round(physicalTotal * 100 / mileageTotal)
|
||||
}
|
||||
if balancedMileageTotal > 0 {
|
||||
balancedRate = round(balancedTotal * 100 / balancedMileageTotal)
|
||||
}
|
||||
byDate[date] = map[string]any{
|
||||
"vehicleDaysWithFrames": len(dateRows), "validVehicleDays": valid, "standardLikeApplicableVehicleDays": standardApplicable, "qualityCounts": quality,
|
||||
"powerIntegrationUsableVehicleDays": powerUsable, "totalMileageKm": round(mileageTotal),
|
||||
"physicalHydrogenKg": round(physicalTotal), "standardLikeBalancedHydrogenKg": round(balancedTotal),
|
||||
"standardLikeMileageKm": round(balancedMileageTotal), "fleetPhysicalRateKgPer100Km": physicalRate, "fleetStandardLikeRateKgPer100Km": balancedRate,
|
||||
}
|
||||
}
|
||||
return map[string]any{"vehicleDays": len(rows), "byDate": byDate}
|
||||
}
|
||||
|
||||
func writeCSV(path string, rows []row) {
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
writer := csv.NewWriter(file)
|
||||
defer writer.Flush()
|
||||
headers := []string{"日期", "车牌", "VIN", "车型", "原始帧数", "当前质量状态", "当前质量原因", "加氢次数", "充电次数", "总里程km", "当前纯电里程km", "当前混动里程km", "物理耗氢kg", "当前SOC平衡氢量kg", "当前修正氢耗_按混动里程", "物理氢耗_按总里程", "全日起始SOC", "全日终止SOC", "全日SOC变化_末减初", "全日电池储能变化kWh", "电池等效氢量_16kWhkg", "标准式平衡氢量kg", "标准式平衡氢耗_按总里程", "标准式修正可用", "标准式修正说明", "电流积分电池净输出kWh", "燃料电池输出积分kWh", "电池能量贡献占比", "燃料电池能量贡献占比", "电池贡献里程km", "燃料电池贡献里程km", "按燃料电池贡献里程氢耗", "电池功率覆盖率", "燃料电池功率覆盖率", "外充状态帧数", "功率积分可用"}
|
||||
_ = writer.Write(headers)
|
||||
for _, value := range rows {
|
||||
_ = writer.Write([]string{
|
||||
value.Date, value.Plate, value.VIN, value.Model, integer(value.RawFrameCount), value.CurrentQuality, value.CurrentReason,
|
||||
integer(value.RefuelCount), integer(value.ChargeCount), decimal(value.TotalMileageKm), decimal(value.PureElectricMileageKm),
|
||||
decimal(value.CurrentMixedMileageKm), decimal(value.PhysicalHydrogenKg), decimal(value.CurrentSOCBalancedKg),
|
||||
decimal(value.CurrentRatePerMixedKm), decimal(value.PhysicalRatePerTotalKm), decimal(value.FullDayStartSOC),
|
||||
decimal(value.FullDayEndSOC), decimal(value.FullDaySOCChangePct), decimal(value.StoredEnergyChangeKWhSOC),
|
||||
decimal(value.BatteryEquivalentKgFixed16), decimal(value.StandardLikeBalancedKg), decimal(value.StandardLikeRatePerTotalKm),
|
||||
strconv.FormatBool(value.StandardLikeApplicable), value.StandardLikeReason,
|
||||
decimal(value.BatteryNetOutputKWhIntegrated), decimal(value.FuelCellOutputKWhIntegrated), decimal(value.BatteryEnergyShare),
|
||||
decimal(value.FuelCellEnergyShare), decimal(value.BatteryContributionKm), decimal(value.FuelCellContributionKm),
|
||||
decimal(value.HydrogenRatePerFCContributionKm), decimal(value.BatteryCoverageRatio), decimal(value.FuelCellCoverageRatio),
|
||||
integer(value.ExternalChargeFrameCount), strconv.FormatBool(value.PowerIntegrationUsable),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueDates(rows []row) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, value := range rows {
|
||||
seen[value.Date] = true
|
||||
}
|
||||
dates := make([]string, 0, len(seen))
|
||||
for date := range seen {
|
||||
dates = append(dates, date)
|
||||
}
|
||||
sort.Strings(dates)
|
||||
return dates
|
||||
}
|
||||
|
||||
func number(value any) (float64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return typed, true
|
||||
case json.Number:
|
||||
parsed, err := typed.Float64()
|
||||
return parsed, err == nil
|
||||
case string:
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
|
||||
return parsed, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func parseEventTime(value string) (time.Time, error) {
|
||||
location := time.FixedZone("CST", 8*3600)
|
||||
if parsed, err := time.ParseInLocation("2006-01-02 15:04:05.000", value, location); err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
return time.ParseInLocation("2006-01-02 15:04:05", value, location)
|
||||
}
|
||||
|
||||
func mustDate(value string) time.Time {
|
||||
parsed, err := time.Parse("2006-01-02", value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func mustReadJSON(path string, target any) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(path string, value any) {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func round(value float64) float64 { return math.Round(value*1000) / 1000 }
|
||||
func decimal(value float64) string { return strconv.FormatFloat(value, 'f', 3, 64) }
|
||||
func integer(value int) string { return strconv.Itoa(value) }
|
||||
Reference in New Issue
Block a user