feat(platform): harden telemetry pipeline and unify Semi UI workspaces
This commit is contained in:
@@ -608,9 +608,13 @@ func buildMonitorMapResponse(vehicles Page[VehicleRealtimeRow], query url.Values
|
||||
zoom = 20
|
||||
}
|
||||
provinceMode := zoom <= monitorProvinceMaxZoom
|
||||
bounds, hasBounds, err := parseMonitorBounds(query.Get("bounds"))
|
||||
if err != nil {
|
||||
return MonitorMapResponse{}, err
|
||||
bounds, hasBounds, boundsErr := parseMonitorBounds(query.Get("bounds"))
|
||||
if boundsErr != nil {
|
||||
// The viewport is an optional performance hint. Wide, wrapped or
|
||||
// intermediate map frames can briefly exceed WGS-84 bounds; falling
|
||||
// back to the bounded unfiltered response keeps the map usable.
|
||||
bounds = monitorBounds{}
|
||||
hasBounds = false
|
||||
}
|
||||
result := MonitorMapResponse{
|
||||
Mode: "clusters",
|
||||
@@ -1602,6 +1606,63 @@ func (s *Service) HistoryLocations(ctx context.Context, query url.Values) (Page[
|
||||
return s.store.HistoryLocationsFromTDengine(ctx, resolvedQuery)
|
||||
}
|
||||
|
||||
type trackSourceCandidate struct {
|
||||
Protocol string
|
||||
Page Page[HistoryLocationRow]
|
||||
Raw []HistoryLocationRow
|
||||
Clean []HistoryLocationRow
|
||||
Quality TrackQuality
|
||||
}
|
||||
|
||||
var trackCandidateProtocols = []string{"GB32960", "JT808", "YUTONG_MQTT"}
|
||||
|
||||
func cloneURLValues(values url.Values) url.Values {
|
||||
next := make(url.Values, len(values))
|
||||
for key, items := range values {
|
||||
next[key] = append([]string(nil), items...)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func sortedHistoryLocations(items []HistoryLocationRow) []HistoryLocationRow {
|
||||
points := append([]HistoryLocationRow(nil), items...)
|
||||
sort.SliceStable(points, func(i, j int) bool {
|
||||
left, leftOK := parseVehicleServiceTime(points[i].DeviceTime)
|
||||
right, rightOK := parseVehicleServiceTime(points[j].DeviceTime)
|
||||
if leftOK && rightOK && !left.Equal(right) {
|
||||
return left.Before(right)
|
||||
}
|
||||
return points[i].DeviceTime < points[j].DeviceTime
|
||||
})
|
||||
return points
|
||||
}
|
||||
|
||||
func (s *Service) trackSourceCandidates(ctx context.Context, query url.Values, requestedProtocol string) ([]trackSourceCandidate, error) {
|
||||
protocols := trackCandidateProtocols
|
||||
if requestedProtocol != "" {
|
||||
protocols = []string{requestedProtocol}
|
||||
}
|
||||
candidates := make([]trackSourceCandidate, 0, len(protocols))
|
||||
for _, protocol := range protocols {
|
||||
sourceQuery := cloneURLValues(query)
|
||||
sourceQuery.Set("protocol", protocol)
|
||||
page, err := s.store.HistoryLocationsFromTDengine(ctx, sourceQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := sortedHistoryLocations(page.Items)
|
||||
clean, quality := analyzeTrackPoints(raw)
|
||||
candidates = append(candidates, trackSourceCandidate{
|
||||
Protocol: protocol,
|
||||
Page: page,
|
||||
Raw: raw,
|
||||
Clean: clean,
|
||||
Quality: quality,
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (s *Service) TrackPlayback(ctx context.Context, query url.Values) (TrackPlaybackResponse, error) {
|
||||
keyword := strings.TrimSpace(firstNonEmpty(query.Get("keyword"), query.Get("vin")))
|
||||
if keyword == "" {
|
||||
@@ -1627,37 +1688,41 @@ func (s *Service) TrackPlayback(ctx context.Context, query url.Values) (TrackPla
|
||||
}
|
||||
resolvedQuery.Set("limit", "5000")
|
||||
resolvedQuery.Set("offset", "0")
|
||||
page, err := s.store.HistoryLocationsFromTDengine(ctx, resolvedQuery)
|
||||
requestedProtocol := strings.ToUpper(strings.TrimSpace(query.Get("protocol")))
|
||||
candidates, err := s.trackSourceCandidates(ctx, resolvedQuery, requestedProtocol)
|
||||
if err != nil {
|
||||
return TrackPlaybackResponse{}, err
|
||||
}
|
||||
rawPoints := append([]HistoryLocationRow(nil), page.Items...)
|
||||
sort.SliceStable(rawPoints, func(i, j int) bool {
|
||||
left, leftOK := parseVehicleServiceTime(rawPoints[i].DeviceTime)
|
||||
right, rightOK := parseVehicleServiceTime(rawPoints[j].DeviceTime)
|
||||
if leftOK && rightOK && !left.Equal(right) {
|
||||
return left.Before(right)
|
||||
allCleanPoints := make([]HistoryLocationRow, 0)
|
||||
for _, candidate := range candidates {
|
||||
allCleanPoints = append(allCleanPoints, candidate.Clean...)
|
||||
}
|
||||
points, selectedProtocol, alternateSourcePoints := selectTrackPrimarySource(allCleanPoints, requestedProtocol)
|
||||
selected := trackSourceCandidate{Protocol: selectedProtocol, Clean: []HistoryLocationRow{}}
|
||||
for _, candidate := range candidates {
|
||||
if strings.EqualFold(candidate.Protocol, selectedProtocol) {
|
||||
selected = candidate
|
||||
break
|
||||
}
|
||||
return rawPoints[i].DeviceTime < rawPoints[j].DeviceTime
|
||||
})
|
||||
cleanPoints, quality := analyzeTrackPoints(rawPoints)
|
||||
points, selectedProtocol, alternateSourcePoints := selectTrackPrimarySource(cleanPoints, query.Get("protocol"))
|
||||
}
|
||||
quality := selected.Quality
|
||||
quality.SelectedProtocol = selectedProtocol
|
||||
quality.AlternateSourcePoints = alternateSourcePoints
|
||||
quality.ValidPoints = len(points)
|
||||
applyTrackSequenceQuality(points, &quality)
|
||||
truncated := selected.Page.Total > len(selected.Raw)
|
||||
result := TrackPlaybackResponse{
|
||||
Points: []HistoryLocationRow{},
|
||||
Events: []TrackPlaybackEvent{},
|
||||
Sources: summarizeTrackSources(cleanPoints),
|
||||
Sources: summarizeTrackSources(allCleanPoints),
|
||||
Segments: []TrackSegment{},
|
||||
Stops: []TrackStop{},
|
||||
Total: page.Total,
|
||||
Truncated: page.Total > len(rawPoints),
|
||||
Total: selected.Page.Total,
|
||||
Truncated: truncated,
|
||||
Quality: quality,
|
||||
AsOf: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
result.Coverage = trackCoverage(resolvedQuery, page.Total, len(rawPoints), len(points), 0, result.Truncated, false)
|
||||
result.Coverage = trackCoverage(resolvedQuery, selected.Page.Total, len(selected.Raw), len(points), 0, result.Truncated, false)
|
||||
applyTrackPrimarySourceCoverage(&result.Coverage, selectedProtocol, alternateSourcePoints)
|
||||
if len(points) == 0 {
|
||||
return result, nil
|
||||
@@ -1696,7 +1761,7 @@ func (s *Service) TrackPlayback(ctx context.Context, query url.Values) (TrackPla
|
||||
for index := range result.Stops {
|
||||
result.Stops[index].SampledIndex = nearestSampledIndex((result.Stops[index].startIndex+result.Stops[index].endIndex)/2, sampledIndexes)
|
||||
}
|
||||
result.Coverage = trackCoverage(resolvedQuery, page.Total, len(rawPoints), len(points), len(result.Points), result.Truncated, result.Sampled)
|
||||
result.Coverage = trackCoverage(resolvedQuery, selected.Page.Total, len(selected.Raw), len(points), len(result.Points), result.Truncated, result.Sampled)
|
||||
applyTrackPrimarySourceCoverage(&result.Coverage, selectedProtocol, alternateSourcePoints)
|
||||
result.Coverage.ActualStart = result.Summary.StartTime
|
||||
result.Coverage.ActualEnd = result.Summary.EndTime
|
||||
@@ -1755,9 +1820,13 @@ type metricCatalogStore interface {
|
||||
|
||||
func (s *Service) metricDefinitions(ctx context.Context) ([]MetricDefinition, error) {
|
||||
if store, ok := s.store.(metricCatalogStore); ok {
|
||||
return store.MetricDefinitions(ctx)
|
||||
definitions, err := store.MetricDefinitions(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return enrichMetricDefinitions(definitions), nil
|
||||
}
|
||||
return metricDefinitions(), nil
|
||||
return enrichMetricDefinitions(metricDefinitions()), nil
|
||||
}
|
||||
|
||||
func (s *Service) MetricCatalog(ctx context.Context) (MetricCatalog, error) {
|
||||
@@ -1865,7 +1934,8 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin
|
||||
if unified {
|
||||
key = definition.Key
|
||||
}
|
||||
if previous, exists := candidates[key]; exists && (!observedOK || (previous.timeValid && !observed.After(previous.sortTime))) {
|
||||
candidateKey := normalizedProtocol + "\x00" + key
|
||||
if previous, exists := candidates[candidateKey]; exists && (!observedOK || (previous.timeValid && !observed.After(previous.sortTime))) {
|
||||
continue
|
||||
}
|
||||
label, unit := latestTelemetryRawMetadata(sourceField)
|
||||
@@ -1873,6 +1943,11 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin
|
||||
valueType := "dynamic"
|
||||
description := ""
|
||||
value := rawValue
|
||||
if reference, ok := gb32960ReferenceForSource(sourceField); ok {
|
||||
label = reference.Label
|
||||
unit = reference.Unit
|
||||
description = reference.Description
|
||||
}
|
||||
if unified {
|
||||
label = definition.Label
|
||||
unit = definition.Unit
|
||||
@@ -1880,11 +1955,18 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin
|
||||
valueType = definition.ValueType
|
||||
description = definition.Description
|
||||
value = normalizeTelemetryValue(rawValue, definition.ValueType)
|
||||
if reference, ok := gb32960ReferenceForSource(sourceField); ok {
|
||||
label = reference.Label
|
||||
unit = reference.Unit
|
||||
description = reference.Description
|
||||
}
|
||||
label, description = protocolTelemetryMetadata(normalizedProtocol, key, label, description)
|
||||
}
|
||||
candidates[key] = latestTelemetryCandidate{
|
||||
displayValue := protocolDisplayValue(sourceField, value)
|
||||
candidates[candidateKey] = latestTelemetryCandidate{
|
||||
value: LatestTelemetryValue{
|
||||
Key: key, SourceField: sourceField, Label: label, Description: description, Unit: unit,
|
||||
Category: category, ValueType: valueType, Value: value, Protocol: normalizedProtocol,
|
||||
Category: category, ValueType: valueType, Value: value, DisplayValue: displayValue, Protocol: normalizedProtocol,
|
||||
SourceEndpoint: frame.SourceEndpoint, FrameID: frame.ID, DeviceTime: frame.DeviceTime, ServerTime: frame.ServerTime,
|
||||
Quality: quality, QualityReason: qualityReason, FreshnessSeconds: freshness, DataDelaySeconds: delay,
|
||||
},
|
||||
@@ -1896,6 +1978,9 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin
|
||||
response.Values = append(response.Values, candidate.value)
|
||||
}
|
||||
sort.Slice(response.Values, func(i, j int) bool {
|
||||
if left, right := telemetryProtocolRank(response.Values[i].Protocol), telemetryProtocolRank(response.Values[j].Protocol); left != right {
|
||||
return left < right
|
||||
}
|
||||
left, right := telemetryCategoryRank(response.Values[i].Category), telemetryCategoryRank(response.Values[j].Category)
|
||||
if left != right {
|
||||
return left < right
|
||||
@@ -1915,10 +2000,24 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin
|
||||
}
|
||||
response.Categories[index].Count++
|
||||
}
|
||||
response.Evidence = fmt.Sprintf("scanned %d newest RAW frames; selected %d newest scalar values by unified metric/source field", len(frames), len(response.Values))
|
||||
response.Evidence = fmt.Sprintf("scanned %d newest RAW frames; retained %d newest scalar values independently by protocol and metric/source field", len(frames), len(response.Values))
|
||||
return response
|
||||
}
|
||||
|
||||
func protocolTelemetryMetadata(protocol, key, label, description string) (string, string) {
|
||||
if key != "total_mileage_km" {
|
||||
return label, description
|
||||
}
|
||||
switch protocol {
|
||||
case "JT808":
|
||||
return "GPS 总里程", "JT/T 808 定位终端累计的 GPS 里程,不等同于车辆仪表盘里程"
|
||||
case "GB32960", "YUTONG_MQTT":
|
||||
return "仪表盘总里程", "车辆总线或车厂平台上报的仪表盘累计里程"
|
||||
default:
|
||||
return label, description
|
||||
}
|
||||
}
|
||||
|
||||
func telemetrySourceLookupKey(protocol, sourceField string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(protocol)) + "\x00" + strings.TrimSpace(sourceField)
|
||||
}
|
||||
@@ -2036,6 +2135,10 @@ func normalizeTelemetryCategory(category string) string {
|
||||
return "location"
|
||||
case "quality":
|
||||
return "quality"
|
||||
case "fuel-cell", "fuel_cell", "fuelcell", "hydrogen":
|
||||
return "fuel-cell"
|
||||
case "motor", "engine":
|
||||
return "motor"
|
||||
default:
|
||||
return "extension"
|
||||
}
|
||||
@@ -2046,7 +2149,7 @@ func telemetrySourceCategory(sourceField string) string {
|
||||
switch {
|
||||
case strings.Contains(normalized, "alarm") || strings.Contains(normalized, "fault") || strings.Contains(normalized, "warning"):
|
||||
return "alarm"
|
||||
case strings.Contains(normalized, "fuel_cell") || strings.Contains(normalized, "fuelcell") || strings.Contains(normalized, "fc_stack"):
|
||||
case strings.Contains(normalized, "fuel_cell") || strings.Contains(normalized, "fuelcell") || strings.Contains(normalized, "fc_stack") || strings.Contains(normalized, "hydrogen"):
|
||||
return "fuel-cell"
|
||||
case strings.Contains(normalized, "motor") || strings.Contains(normalized, "engine") || strings.Contains(normalized, "rpm"):
|
||||
return "motor"
|
||||
@@ -2070,6 +2173,15 @@ func telemetryCategoryRank(category string) int {
|
||||
return 99
|
||||
}
|
||||
|
||||
func telemetryProtocolRank(protocol string) int {
|
||||
for index, candidate := range canonicalVehicleProtocols {
|
||||
if protocol == candidate {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return len(canonicalVehicleProtocols)
|
||||
}
|
||||
|
||||
func telemetryCategoryLabel(category string) string {
|
||||
labels := map[string]string{"vehicle": "整车数据", "motor": "驱动电机", "fuel-cell": "燃料电池", "battery": "电池", "location": "定位", "alarm": "报警", "quality": "数据质量", "extension": "扩展字段"}
|
||||
if label := labels[category]; label != "" {
|
||||
@@ -2086,9 +2198,28 @@ func metricDefinitions() []MetricDefinition {
|
||||
{Key: "alarm_active", Label: "协议告警位", Description: "协议报文是否携带活动告警", Unit: "", Category: "safety", ValueType: "boolean", Protocols: []string{"GB32960", "JT808"}, SourceFields: map[string]string{"GB32960": "gb32960.alarm.general_alarm_flag", "JT808": "jt808.location.alarm_flag"}, Searchable: true, Chartable: false, Alertable: true},
|
||||
{Key: "freshness_sec", Label: "离线时长", Description: "最新数据距当前时间的秒数", Unit: "s", Category: "quality", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"PLATFORM": "vehicle_realtime_location.updated_at"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "data_delay_sec", Label: "数据延迟", Description: "服务接收时间与设备事件时间的差值", Unit: "s", Category: "quality", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"PLATFORM": "received_at-event_time"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "total_mileage_km", Label: "总里程", Description: "车辆累计行驶里程", Unit: "km", Category: "driving", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_mileage_km", "JT808": "jt808.location.total_mileage_km", "YUTONG_MQTT": "yutong.vehicle.total_mileage_km"}, Searchable: true, Chartable: true, Alertable: false},
|
||||
{Key: "total_mileage_km", Label: "总里程", Description: "车辆累计行驶里程;JT808 为 GPS 里程,其余为仪表盘里程", Unit: "km", Category: "driving", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_mileage_km", "JT808": "jt808.location.total_mileage_km", "YUTONG_MQTT": "yutong_mqtt.data.total_mileage_km"}, Searchable: true, Chartable: true, Alertable: false},
|
||||
{Key: "longitude", Label: "经度", Description: "WGS84/协议归一化经度", Unit: "°", Category: "location", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"PLATFORM": "vehicle_realtime_location.longitude"}, Searchable: true, Chartable: false, Alertable: false},
|
||||
{Key: "latitude", Label: "纬度", Description: "WGS84/协议归一化纬度", Unit: "°", Category: "location", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"PLATFORM": "vehicle_realtime_location.latitude"}, Searchable: true, Chartable: false, Alertable: false},
|
||||
{Key: "vehicle_status", Label: "车辆状态", Description: "GB/T 32960 整车状态码", Unit: "", Category: "vehicle", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.vehicle_status"}, Searchable: true, Chartable: false, Alertable: true},
|
||||
{Key: "charge_status", Label: "充电状态", Description: "GB/T 32960 充电状态码", Unit: "", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.charge_status"}, Searchable: true, Chartable: false, Alertable: true},
|
||||
{Key: "total_voltage_v", Label: "总电压", Description: "动力电池总电压", Unit: "V", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_voltage_v"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "total_current_a", Label: "总电流", Description: "动力电池总电流", Unit: "A", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_current_a"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "insulation_kohm", Label: "绝缘电阻", Description: "整车绝缘电阻", Unit: "kΩ", Category: "safety", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.insulation_kohm"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "fuel_cell_voltage_v", Label: "燃料电池电压", Description: "燃料电池系统输出电压", Unit: "V", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.fuel_cell_voltage_v"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "fuel_cell_current_a", Label: "燃料电池电流", Description: "燃料电池系统输出电流", Unit: "A", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.fuel_cell_current_a"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "hydrogen_consumption_kg_per_100km", Label: "百公里氢耗", Description: "燃料电池系统百公里氢气消耗量", Unit: "kg/100km", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.hydrogen_consumption_kg_per_100km"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "hydrogen_concentration_percent", Label: "最高氢浓度", Description: "氢气浓度最高值", Unit: "%", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.max_hydrogen_concentration_percent"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "hydrogen_pressure_mpa", Label: "最高氢气压力", Description: "燃料电池系统氢气压力探针的最高读数", Unit: "MPa", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.max_hydrogen_pressure_mpa"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "hydrogen_temperature_c", Label: "最高氢气温度", Description: "燃料电池系统氢气温度探针的最高读数", Unit: "℃", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.max_hydrogen_temperature_c"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "engine_speed_rpm", Label: "发动机转速", Description: "发动机曲轴转速", Unit: "rpm", Category: "vehicle", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.engine.crank_speed_rpm"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "max_cell_voltage_v", Label: "单体最高电压", Description: "动力电池单体电压最高值", Unit: "V", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.extreme.max_voltage_v"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "min_cell_voltage_v", Label: "单体最低电压", Description: "动力电池单体电压最低值", Unit: "V", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.extreme.min_voltage_v"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "max_battery_temperature_c", Label: "电池最高温度", Description: "动力电池温度探针最高值", Unit: "℃", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.extreme.max_temp_c"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "min_battery_temperature_c", Label: "电池最低温度", Description: "动力电池温度探针最低值", Unit: "℃", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.extreme.min_temp_c"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "gnss_satellite_count", Label: "GNSS 卫星数", Description: "JT/T 808 定位终端参与定位的卫星数量", Unit: "颗", Category: "location", ValueType: "numeric", Protocols: []string{"JT808"}, SourceFields: map[string]string{"JT808": "jt808.location.additional.gnss_satellite_count"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "network_signal_strength", Label: "通信信号强度", Description: "JT/T 808 终端无线通信信号强度", Unit: "", Category: "quality", ValueType: "numeric", Protocols: []string{"JT808"}, SourceFields: map[string]string{"JT808": "jt808.location.additional.network_signal_strength"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "fuel_l", Label: "油量", Description: "JT/T 808 终端上报的车辆油量", Unit: "L", Category: "vehicle", ValueType: "numeric", Protocols: []string{"JT808"}, SourceFields: map[string]string{"JT808": "jt808.location.additional.fuel_l"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2593,19 +2724,51 @@ func mergeDiscoveredRawMetrics(base []HistoryMetricDefinition, rows []HistoryDat
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
label, unit := rawMetricMetadata(key)
|
||||
result = append(result, HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: "raw", ValueType: "dynamic", DefaultVisible: len(result) < 7})
|
||||
metric := HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: "raw", ValueType: "dynamic", DefaultVisible: len(result) < 7}
|
||||
if reference, ok := gb32960ReferenceForSource(key); ok {
|
||||
metric.Label = reference.Label + " · GB32960"
|
||||
metric.Description = reference.Description
|
||||
metric.Unit = reference.Unit
|
||||
metric.ValueMappings = reference.ValueMappings
|
||||
}
|
||||
result = append(result, metric)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func rawMetricMetadata(key string) (string, string) {
|
||||
if reference, ok := gb32960ReferenceForSource(key); ok {
|
||||
return reference.Label + " · GB32960", reference.Unit
|
||||
}
|
||||
parts := strings.Split(key, ".")
|
||||
tail := parts[len(parts)-1]
|
||||
protocol := strings.ToUpper(parts[0])
|
||||
labels := map[string]string{"speed_kmh": "速度", "total_mileage_km": "总里程", "avg_voltage_v": "平均电压", "soc_percent": "SOC", "longitude": "经度", "latitude": "纬度", "direction": "方向", "current_a": "电流", "temperature_c": "温度"}
|
||||
labels := map[string]string{
|
||||
"speed_kmh": "速度", "recorder_speed_kmh": "行驶记录仪速度", "total_mileage_km": "总里程", "avg_voltage_v": "平均电压",
|
||||
"soc_percent": "SOC", "longitude": "经度", "latitude": "纬度", "direction": "方向", "direction_deg": "方向角", "altitude_m": "海拔",
|
||||
"current_a": "电流", "temperature_c": "温度", "vehicle_status": "车辆状态", "charge_status": "充电状态", "running_mode": "运行模式",
|
||||
"total_voltage_v": "总电压", "total_current_a": "总电流", "dc_dc_status": "DC/DC 状态", "gear": "挡位", "insulation_kohm": "绝缘电阻",
|
||||
"accelerator_pct": "加速踏板开度", "brake_pct": "制动踏板状态", "state": "工作状态", "controller_temperature_c": "控制器温度",
|
||||
"speed_rpm": "转速", "torque_nm": "转矩", "motor_temperature_c": "驱动电机温度", "controller_voltage_v": "控制器输入电压",
|
||||
"controller_current_a": "控制器母线电流", "fuel_cell_voltage_v": "燃料电池电压", "fuel_cell_current_a": "燃料电池电流",
|
||||
"hydrogen_consumption_kg_per_100km": "百公里氢耗", "temperature_probe_count": "温度探针数量", "max_hydrogen_temperature_c": "最高氢气温度",
|
||||
"max_hydrogen_temperature_probe_id": "最高氢温探针编号", "max_hydrogen_concentration_fraction": "最高氢气浓度占比",
|
||||
"max_hydrogen_concentration_percent": "最高氢浓度", "max_hydrogen_concentration_ppm": "最高氢浓度原始值", "max_hydrogen_concentration_probe_id": "最高氢浓度探针编号",
|
||||
"max_hydrogen_pressure_mpa": "最高氢气压力", "max_hydrogen_pressure_probe_id": "最高氢压探针编号",
|
||||
"engine_status": "发动机状态", "crank_speed_rpm": "发动机转速", "fuel_rate": "燃料消耗率",
|
||||
"max_voltage_subsystem_no": "最高电压子系统编号", "max_voltage_cell_no": "最高电压单体编号", "max_voltage_v": "单体最高电压",
|
||||
"min_voltage_subsystem_no": "最低电压子系统编号", "min_voltage_cell_no": "最低电压单体编号", "min_voltage_v": "单体最低电压",
|
||||
"max_temp_subsystem_no": "最高温度子系统编号", "max_temp_probe_no": "最高温度探针编号", "max_temp_c": "电池最高温度",
|
||||
"min_temp_subsystem_no": "最低温度子系统编号", "min_temp_probe_no": "最低温度探针编号", "min_temp_c": "电池最低温度",
|
||||
"max_alarm_level": "最高告警等级", "general_alarm_flag": "通用告警标志", "alarm_flag": "告警标志", "status_flag": "状态标志",
|
||||
"fuel_l": "油量", "manual_alarm_event_id": "人工确认告警事件编号", "carriage_temperature_c": "车厢温度",
|
||||
"network_signal_strength": "通信信号强度", "gnss_satellite_count": "GNSS 卫星数", "device_time": "设备时间",
|
||||
"hydrogen_mass_kg": "储氢质量", "gd_fc_vehicle_hydrogen_mass_kg": "储氢质量", "stack_temp_c": "电堆温度",
|
||||
"gd_fc_demo_stack_temp_c": "电堆温度", "controller_temp_c": "控制器温度", "gd_fc_dcdc_controller_temp_c": "燃料电池 DC/DC 控制器温度",
|
||||
}
|
||||
label := labels[tail]
|
||||
if label == "" {
|
||||
label = strings.ReplaceAll(tail, "_", " ")
|
||||
label = humanizeRawMetricTail(tail)
|
||||
}
|
||||
if protocol != "" {
|
||||
label += " · " + protocol
|
||||
@@ -2614,6 +2777,8 @@ func rawMetricMetadata(key string) (string, string) {
|
||||
switch {
|
||||
case strings.HasSuffix(tail, "_kmh"):
|
||||
unit = "km/h"
|
||||
case strings.HasSuffix(tail, "_kg_per_100km"):
|
||||
unit = "kg/100km"
|
||||
case strings.HasSuffix(tail, "_km"):
|
||||
unit = "km"
|
||||
case strings.HasSuffix(tail, "_percent"):
|
||||
@@ -2624,10 +2789,63 @@ func rawMetricMetadata(key string) (string, string) {
|
||||
unit = "A"
|
||||
case strings.HasSuffix(tail, "_temperature_c") || strings.HasSuffix(tail, "_c"):
|
||||
unit = "℃"
|
||||
case strings.HasSuffix(tail, "_rpm"):
|
||||
unit = "rpm"
|
||||
case strings.HasSuffix(tail, "_nm"):
|
||||
unit = "N·m"
|
||||
case strings.HasSuffix(tail, "_mpa"):
|
||||
unit = "MPa"
|
||||
case strings.HasSuffix(tail, "_kohm"):
|
||||
unit = "kΩ"
|
||||
case strings.HasSuffix(tail, "_ppm"):
|
||||
unit = "ppm"
|
||||
case strings.HasSuffix(tail, "_kg"):
|
||||
unit = "kg"
|
||||
case strings.HasSuffix(tail, "_deg"):
|
||||
unit = "°"
|
||||
case strings.HasSuffix(tail, "_m"):
|
||||
unit = "m"
|
||||
case strings.HasSuffix(tail, "_l"):
|
||||
unit = "L"
|
||||
}
|
||||
return label, unit
|
||||
}
|
||||
|
||||
func humanizeRawMetricTail(tail string) string {
|
||||
tokenLabels := map[string]string{
|
||||
"max": "最高", "min": "最低", "avg": "平均", "total": "总", "vehicle": "车辆", "battery": "电池",
|
||||
"fuel": "燃料", "cell": "电池", "hydrogen": "氢气", "motor": "电机", "engine": "发动机", "controller": "控制器",
|
||||
"stack": "电堆", "inlet": "入口", "outlet": "出口", "pressure": "压力", "temperature": "温度", "temp": "温度",
|
||||
"voltage": "电压", "current": "电流", "speed": "速度", "mileage": "里程", "status": "状态", "count": "数量",
|
||||
"probe": "探针", "serial": "序号", "subsystem": "子系统", "signal": "信号", "strength": "强度", "alarm": "告警",
|
||||
"level": "等级", "rate": "速率", "mass": "质量", "consumption": "消耗量", "capacity": "容量", "soc": "SOC",
|
||||
}
|
||||
var builder strings.Builder
|
||||
for _, token := range strings.Split(tail, "_") {
|
||||
if label := tokenLabels[token]; label != "" {
|
||||
builder.WriteString(label)
|
||||
} else if token != "" && !isRawMetricUnitToken(token) {
|
||||
if builder.Len() > 0 {
|
||||
builder.WriteByte(' ')
|
||||
}
|
||||
builder.WriteString(strings.ToUpper(token))
|
||||
}
|
||||
}
|
||||
if builder.Len() == 0 {
|
||||
return strings.ReplaceAll(tail, "_", " ")
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func isRawMetricUnitToken(token string) bool {
|
||||
switch token {
|
||||
case "kmh", "km", "v", "a", "c", "rpm", "nm", "mpa", "kohm", "ppm", "kg", "pct", "percent", "deg", "m", "l":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) CreateHistoryExport(ctx context.Context, request HistoryExportRequest) (HistoryExportJob, error) {
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok {
|
||||
@@ -3170,7 +3388,12 @@ func historyExportColumns(category string, requested []string) []HistoryMetricDe
|
||||
continue
|
||||
}
|
||||
label, unit := rawMetricMetadata(key)
|
||||
selected = append(selected, HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: "raw", ValueType: "dynamic", DefaultVisible: true})
|
||||
metric := HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: "raw", ValueType: "dynamic", DefaultVisible: true}
|
||||
if reference, ok := gb32960ReferenceForSource(key); ok {
|
||||
metric.Description = reference.Description
|
||||
metric.ValueMappings = reference.ValueMappings
|
||||
}
|
||||
selected = append(selected, metric)
|
||||
known[key] = true
|
||||
}
|
||||
return selected
|
||||
@@ -3200,7 +3423,11 @@ func historyExportRecord(row HistoryDataRow, columns []HistoryMetricDefinition)
|
||||
record = append(record, "")
|
||||
continue
|
||||
}
|
||||
record = append(record, fmt.Sprint(value))
|
||||
if display := protocolDisplayValue(column.Key, value); display != "" {
|
||||
record = append(record, display)
|
||||
} else {
|
||||
record = append(record, fmt.Sprint(value))
|
||||
}
|
||||
}
|
||||
return record
|
||||
}
|
||||
@@ -3368,27 +3595,37 @@ func analyzeTrackPoints(raw []HistoryLocationRow) ([]HistoryLocationRow, TrackQu
|
||||
|
||||
func selectTrackPrimarySource(points []HistoryLocationRow, requestedProtocol string) ([]HistoryLocationRow, string, int) {
|
||||
requestedProtocol = strings.ToUpper(strings.TrimSpace(requestedProtocol))
|
||||
counts := map[string]int{}
|
||||
latest := map[string]time.Time{}
|
||||
grouped := map[string][]HistoryLocationRow{}
|
||||
for _, point := range points {
|
||||
protocol := strings.ToUpper(firstNonEmpty(strings.TrimSpace(point.Protocol), "UNKNOWN"))
|
||||
counts[protocol]++
|
||||
if observedAt, ok := parseVehicleServiceTime(point.DeviceTime); ok && observedAt.After(latest[protocol]) {
|
||||
latest[protocol] = observedAt
|
||||
}
|
||||
grouped[protocol] = append(grouped[protocol], point)
|
||||
}
|
||||
selected := requestedProtocol
|
||||
if selected == "" {
|
||||
protocols := make([]string, 0, len(counts))
|
||||
for protocol := range counts {
|
||||
protocols := make([]string, 0, len(grouped))
|
||||
for protocol := range grouped {
|
||||
protocols = append(protocols, protocol)
|
||||
}
|
||||
sort.Slice(protocols, func(i, j int) bool {
|
||||
if counts[protocols[i]] != counts[protocols[j]] {
|
||||
return counts[protocols[i]] > counts[protocols[j]]
|
||||
left := trackSourceSelectionScore(grouped[protocols[i]])
|
||||
right := trackSourceSelectionScore(grouped[protocols[j]])
|
||||
if left.HasMovement != right.HasMovement {
|
||||
return left.HasMovement
|
||||
}
|
||||
if !latest[protocols[i]].Equal(latest[protocols[j]]) {
|
||||
return latest[protocols[i]].After(latest[protocols[j]])
|
||||
if left.DurationSeconds != right.DurationSeconds {
|
||||
return left.DurationSeconds > right.DurationSeconds
|
||||
}
|
||||
if left.CoordinateChanges != right.CoordinateChanges {
|
||||
return left.CoordinateChanges > right.CoordinateChanges
|
||||
}
|
||||
if math.Abs(left.DistanceKm-right.DistanceKm) > 0.001 {
|
||||
return left.DistanceKm > right.DistanceKm
|
||||
}
|
||||
if left.MovingPoints != right.MovingPoints {
|
||||
return left.MovingPoints > right.MovingPoints
|
||||
}
|
||||
if len(grouped[protocols[i]]) != len(grouped[protocols[j]]) {
|
||||
return len(grouped[protocols[i]]) > len(grouped[protocols[j]])
|
||||
}
|
||||
return protocols[i] < protocols[j]
|
||||
})
|
||||
@@ -3396,13 +3633,48 @@ func selectTrackPrimarySource(points []HistoryLocationRow, requestedProtocol str
|
||||
selected = protocols[0]
|
||||
}
|
||||
}
|
||||
result := make([]HistoryLocationRow, 0, counts[selected])
|
||||
for _, point := range points {
|
||||
if strings.EqualFold(firstNonEmpty(strings.TrimSpace(point.Protocol), "UNKNOWN"), selected) {
|
||||
result = append(result, point)
|
||||
result := grouped[selected]
|
||||
return result, selected, len(points) - len(result)
|
||||
}
|
||||
|
||||
type trackSourceScore struct {
|
||||
HasMovement bool
|
||||
DurationSeconds int64
|
||||
CoordinateChanges int
|
||||
MovingPoints int
|
||||
DistanceKm float64
|
||||
}
|
||||
|
||||
func trackSourceSelectionScore(points []HistoryLocationRow) trackSourceScore {
|
||||
score := trackSourceScore{}
|
||||
if len(points) == 0 {
|
||||
return score
|
||||
}
|
||||
if start, startOK := parseVehicleServiceTime(points[0].DeviceTime); startOK {
|
||||
if end, endOK := parseVehicleServiceTime(points[len(points)-1].DeviceTime); endOK && end.After(start) {
|
||||
score.DurationSeconds = int64(end.Sub(start).Seconds())
|
||||
}
|
||||
}
|
||||
return result, selected, len(points) - len(result)
|
||||
for index, point := range points {
|
||||
if point.SpeedKmh > trackStopSpeedKmh {
|
||||
score.MovingPoints++
|
||||
}
|
||||
if index == 0 || !validTrackCoordinate(points[index-1]) || !validTrackCoordinate(point) {
|
||||
continue
|
||||
}
|
||||
previousTime, previousOK := parseVehicleServiceTime(points[index-1].DeviceTime)
|
||||
currentTime, currentOK := parseVehicleServiceTime(point.DeviceTime)
|
||||
if !previousOK || !currentOK || !currentTime.After(previousTime) || currentTime.Sub(previousTime) > time.Duration(trackGapThresholdSeconds)*time.Second {
|
||||
continue
|
||||
}
|
||||
distance := haversineKm(points[index-1].Latitude, points[index-1].Longitude, point.Latitude, point.Longitude)
|
||||
score.DistanceKm += distance
|
||||
if distance >= 0.03 {
|
||||
score.CoordinateChanges++
|
||||
}
|
||||
}
|
||||
score.HasMovement = score.CoordinateChanges >= 2 || score.DistanceKm >= 0.5 || score.MovingPoints >= 3
|
||||
return score
|
||||
}
|
||||
|
||||
func applyTrackSequenceQuality(points []HistoryLocationRow, quality *TrackQuality) {
|
||||
|
||||
Reference in New Issue
Block a user