车辆进入隧道后信号断开,座舱旅程怎样补回事件

车主反馈的原话是"这段路白跑了"。他从滨海大道出发,中间穿过云钟隧道,出了一段七百多米的隧道之后继续开到目的地。行程记录里,这段隧道对应的里程是空白的,能耗曲线也断开了一截。

隧道里没有信号是常态,问题不在这里。问题是车机当时其实什么都知道------车速、电耗、位置一直在采,只是发不出去。等信号恢复,那些数据已经丢了。

这一版把旅程事件先在本地攒起来,等链路恢复再补报。

事件要带单调序号,不能只靠时间戳

最先要解决的是事件的排序依据。车机时钟会因为校时而跳动,进隧道前是 09:42:10,出隧道后可能显示 09:41:58。服务端如果按时间戳排序,补报上来的事件会被插到更早的位置,整段行程的顺序就乱了。

所以每个事件除了发生时刻,还带一个本地单调序号:

ts 复制代码
export type TripEventKind = 'segment' | 'energy' | 'charging' | 'parking'

export interface TripEvent {
  eventId: string          // 全局唯一,用于服务端去重
  kind: TripEventKind
  occurredAt: number       // 事件发生的墙上时间,仅作展示
  monotonicSeq: number     // 本地单调递增,排序依据
  payload: string          // 事件正文
  synced: boolean          // 是否已被服务端确认
}

export interface TripState {
  events: TripEvent[]
  nextSeq: number
  lastAckedSeq: number
  tunnelEntryAt: number | null
}

monotonicSeq 不能只放在内存里。车机在熄火重启后会从零开始,如果新产生的事件序号比之前小,服务端会拒绝接收。实际做法是把 nextSeq 一起持久化,重启后接着往下走。

tunnelEntryAt 记录断链的起点时刻,它有两个用途:判断断链持续了多久,以及在补报时给位置缺口做插值。

队列满了要丢已同步的,不能丢没发出去的

隧道里可能一待就是几分钟,事件不停产生。队列必须设上限,否则内存会一直涨。

上限设成 500 条。满了之后怎么处理,这里有过一次返工。第一版直接丢最旧的,结果长隧道里排在最前面的恰好是进隧道那一刻产生的、还没发出去的事件,一丢就把断点切在了最不合适的位置。

ts 复制代码
const MAX_QUEUE_SIZE: number = 500

export function enqueue(state: TripState, event: TripEvent): TripState {
  const appended: TripEvent[] = state.events.concat([event])

  if (appended.length <= MAX_QUEUE_SIZE) {
    return {
      ...state,
      events: appended,
      nextSeq: state.nextSeq + 1
    }
  }

  // 优先丢弃已经被确认的事件,未同步的必须保住
  const overflow: number = appended.length - MAX_QUEUE_SIZE
  let removed: number = 0
  const kept: TripEvent[] = []

  appended.forEach((item: TripEvent) => {
    if (removed < overflow && item.synced) {
      removed += 1
      return
    }
    kept.push(item)
  })

  return {
    ...state,
    events: kept,
    nextSeq: state.nextSeq + 1
  }
}

如果已同步的事件全部丢完还是超限,说明确实堆得太多。这种情况下宁可让最旧的一条未同步事件也走掉,也不能让队列无限增长把车机拖垮。日志里会记一条丢事件的告警,方便后续排查。

补报要能接上,也要能跳过重复

链路恢复之后,补报的起点由 lastAckedSeq 决定,而不是"队列里所有 synced 为 false 的事件"。这两个条件看起来差不多,实际差很多:服务端可能已经收到了某条事件并写了库,但确认回执在断链时丢了,客户端这边还标着未同步。

ts 复制代码
export interface SyncBatch {
  fromSeq: number
  events: TripEvent[]
  keepLocal: boolean
}

export function planResync(state: TripState): SyncBatch {
  const pending: TripEvent[] = state.events
    .filter((item: TripEvent) => item.monotonicSeq > state.lastAckedSeq)
    .sort((a: TripEvent, b: TripEvent) => a.monotonicSeq - b.monotonicSeq)

  return {
    fromSeq: state.lastAckedSeq + 1,
    events: pending,
    keepLocal: pending.length > 0
  }
}

排序再用一次 monotonicSeq。事件在队列里的物理顺序通常是对的,但补报前如果发生过队列裁剪,顺序不一定还可靠。

服务端按 eventId 去重,重复的事件返回 duplicate 而不是 error。客户端拿到回执后,把对应事件的 synced 置为 true,并把 lastAckedSeq 推进到本批最大的序号------只推进到最大值,不能只推进到"最后一条被接受的事件",因为中间可能有被跳过的。

ts 复制代码
export interface AckReceipt {
  eventId: string
  status: 'accepted' | 'duplicate' | 'rejected'
}

export function applyAck(state: TripState, receipts: AckReceipt[]): TripState {
  const statusMap: Map<string, string> = new Map()
  receipts.forEach((receipt: AckReceipt) => {
    statusMap.set(receipt.eventId, receipt.status)
  })

  let maxSeq: number = state.lastAckedSeq
  const updated: TripEvent[] = state.events.map((item: TripEvent) => {
    const status: string | undefined = statusMap.get(item.eventId)
    // 被拒绝的事件不再重试,但仍然读取它的序号用于推进位点
    if (status === 'accepted' || status === 'duplicate') {
      if (item.monotonicSeq > maxSeq) {
        maxSeq = item.monotonicSeq
      }
      return { ...item, synced: true }
    }
    return item
  })

  return { ...state, events: updated, lastAckedSeq: maxSeq }
}

duplicate 和 accepted 在推进位点这件事上待遇相同。区别只在于日志记什么,业务上都是"这条已经落实了"。

位置缺口不能直连

补报解决了数据丢失,但地图上的轨迹还有一处别扭:隧道两端的位置点之间是一条直线。车在隧道里其实是跟着路走的,直线切过去会穿过山体。

断链时间超过阈值时,按平均速度补几个中间点:

ts 复制代码
const GAP_THRESHOLD_MS: number = 8000
const INTERPOLATE_STEP_MS: number = 2000

export interface TrackPoint {
  at: number
  x: number
  y: number
  interpolated: boolean
}

export function fillGap(before: TrackPoint, after: TrackPoint, averageSpeed: number): TrackPoint[] {
  const gap: number = after.at - before.at

  if (gap < GAP_THRESHOLD_MS || averageSpeed <= 0) {
    return []
  }

  const count: number = Math.min(12, Math.floor(gap / INTERPOLATE_STEP_MS))
  if (count <= 0) {
    return []
  }

  const points: TrackPoint[] = []
  for (let i: number = 1; i <= count; i++) {
    const ratio: number = i / (count + 1)
    points.push({
      at: before.at + gap * ratio,
      x: before.x + (after.x - before.x) * ratio,
      y: before.y + (after.y - before.y) * ratio,
      interpolated: true
    })
  }

  return points
}

interpolated 这个标记会传到界面上,补出来的点用虚线画,实线部分才是真实采到的位置。用户能看出这一段是估算的,不会以为定位精度突然变差了。

最大补 12 个点是个上限。七百米的隧道按每小时 60 公里开过去大约 42 秒,会补出 12 个点,间隔 3 秒多一点,视觉上够用了。

能耗也要补齐

行程结束后的"本次能耗 14.8 kWh"如果只统计有信号的那部分,会明显偏低。隧道段的能耗用断链前后的平均功率估算:

ts 复制代码
export function estimateEnergyGap(beforePowerKw: number, afterPowerKw: number, gapMs: number): number {
  if (gapMs <= 0) {
    return 0
  }

  const averageKw: number = (beforePowerKw + afterPowerKw) / 2
  const hours: number = gapMs / 3600000

  return averageKw * hours
}

用两端平均而不是取一个固定值,是因为隧道里通常还在爬坡或者下坡,功率前后差别不小。这个估算值在界面上不单独标注,只在数据导出时附一个 estimated: true 的标记,让后续分析知道这一段的来源。

目录结构

text 复制代码
EnergyCockpit/
├─ entry/src/main/ets/
│  ├─ pages/Index.ets                     # 旅程导航与能耗面板
│  ├─ features/
│  │  ├─ TripModel.ets                    # 事件、队列与位点结构
│  │  ├─ EventQueue.ets                   # 入队、裁剪与持久化
│  │  ├─ ResyncPlanner.ets                # 补报批次与回执处理
│  │  └─ TrackFiller.ets                  # 位置与能耗缺口估算
│  ├─ entryability/EntryAbility.ets
│  └─ adapters/
│     ├─ VehicleBusAdapter.ets             # 车速、电耗与位置读取
│     └─ UplinkAdapter.ets                 # 链路状态与补报通道
├─ entry/src/main/resources/
└─ build-profile.json5

EventQueue 的持久化用文件而不是偏好存储。事件量在长途行程里可能上千条,偏好存储适合放配置,不适合放这种会持续增长的列表。

真机上车机可能整晚不上电,队列要在熄火前落盘。这一步放在能力生命周期里做,不用定时器,避免熄火流程被打断导致最后几条事件丢失。

相关推荐
砚底藏山河1 小时前
数据新鲜度 SLA 监控
java·大数据·python·maven
红海云1 小时前
Agent 工程化的智能面积
数据库
Wzx1980121 小时前
Python Agent 短期记忆设计:从对话历史到 LangGraph Checkpointer
开发语言·python
定栓1 小时前
Webpack迁到Vite第六节:构建配置详解(vite.config.js)
前端·javascript·webpack
风骏时光牛马1 小时前
从零到精通AI全栈学习路线
前端
March.s1 小时前
Redis 核心知识与高可用架构实践
数据库·redis·架构
gopher95111 小时前
RUST:错误处理 thiserror 详解
开发语言·后端·rust
杨杨杨大侠9 小时前
都能接模型、RAG 和 MCP,Java Agent 框架到底差在哪?
java
破茧蝴蝶10 小时前
Python分布式爬虫完整教程
开发语言·python