新威直连补上工步层与循环汇总(downloadStepLayer 实机验证后落地)
ADR-0070 未决 1 说"downloadStepLayer 未验证,验证通过后本形态可交出工步层与循环汇总"。
2026-10-11 在四台机器上量过:C/E/F 可用(21 / 15 / 4 步),D 那台缓冲里没有测试、如实为空。
- 新增 downloadStepLayer 请求构造与解析;工步记录按既有契约交出。
- 循环号减一:这条命令的 cycleid 是 1 起,而逐点与厂商库是 0 起,实测同一测试两处对照确认。
- 开始时间 = endatime − steptime(应答只给结束时间,steptime 是工步运行时长),
两个独立样本与该测试首行 atime 核对一致。
- 工步身份用 stepindex:stepid 在循环里会重复(实测同一 stepid 在相邻两个循环各出现一次)。
- 未结束的那一步连它所在的循环一起按住:这条命令会把正在跑的那一步交出来,且它的 endseqid
恰好等于 inquiredf 的已落盘行数(四个测试全部相等),行数比不出结束没有,判据是通道工作状态。
交出未结束的段会让结束时间每轮都变,同一条记录反复以不同内容到达。
判断工作状态的读取失败不吞,让它传出去:调用方本来就有"来源读不到就本轮放弃、下一轮重试"
的约定,这样一次传输抖动不会变成"把一个还在跑的工步交出去",也不会被当成结论缓存。
- 循环汇总为聚合值(sourceKind=AGGREGATED),口径与库路线一致:方向未确认的容量能量不进任何一侧。
工步与循环来自同一次读取(5 秒窗口内复用),避免工步边界落在两次读取之间时两者对不上。
- 接口这一侧交不出的只剩辅助测量与计划方案两类,如实为空。
battery-device-bts 70 项通过;后端全量通过、BUILD SUCCESS。
| | |
| | | import org.xml.sax.helpers.DefaultHandler; |
| | | import com.battery.device.api.StepDirectionResolver; |
| | | import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime; |
| | | import com.battery.device.api.TestDataObservationAdapter.StepRecord; |
| | | import com.battery.device.api.model.ChannelIdentity; |
| | | import com.battery.device.api.model.DeviceConnectionStatus; |
| | | import com.battery.device.api.model.DetectedChannel; |
| | |
| | | /** 该通道的数据文件状态:测试号、已落盘行数、厂商自报的数据是否完整。 */ |
| | | public record DataFileState(long testId, long count, boolean complete) { } |
| | | |
| | | /** |
| | | * 构造工步层下载的请求报文({@code downloadStepLayer})。 |
| | | * |
| | | * <p>现场实测:只发命令与 {@code downloadStepLayer} 元素即可,文档里的 {@code V1I1}/{@code V2I2} |
| | | * 不填时按文档所说"用默认参数";{@code dcir="0"}"不计算 DCIR",我们只要工步事实、不做派生计算。 |
| | | * 与 {@code download} 一样,{@code testid="0"} 表示"该通道最近一次测试",应答会回显真实测试号。 |
| | | */ |
| | | public String downloadStepLayerRequest(ChannelIdentity channel) |
| | | { |
| | | requireOwned(channel); |
| | | return envelope("<cmd>downloadStepLayer</cmd><downloadStepLayer devtype=\"" + channel.devtype() |
| | | + "\" devid=\"" + channel.devid() + "\" subdevid=\"" + channel.subdevid() |
| | | + "\" chlid=\"" + channel.chlid() + "\" testid=\"0\" dcir=\"0\"/>"); |
| | | } |
| | | |
| | | /** |
| | | * 解析 {@code downloadStepLayer_resp} 为工步记录。 |
| | | * |
| | | * <p>每条 {@code <data/>} 是一个工步:{@code startseqid}/{@code endseqid}(该工步的首末数据序号)、 |
| | | * {@code stepindex}(测试内的工步序号)、{@code stepid}(原始工步号,循环里会重复)、 |
| | | * {@code cycleid}、{@code steptype}、{@code steptime}(工步运行时长,**毫秒**)、{@code endatime} |
| | | * (工步绝对结束时间)、{@code startvolt}/{@code endvolt}、{@code startcurr}/{@code endcurr}、 |
| | | * {@code cap}/{@code eng}、{@code dcir}(mΩ)。 |
| | | * |
| | | * <p>三处按实测钉死的口径: |
| | | * |
| | | * <ul> |
| | | * <li><b>循环号要减一。</b>这条命令的 {@code cycleid} 是 **1 起**(第一步 {@code cycleid="1"}), |
| | | * 而 {@code download} 的 {@code cycleid} 与厂商库的 {@code cycle} 都是 **0 起**。 |
| | | * 实测同一测试两处对照(工步层 {@code cycleid="1"}/逐点 {@code cycleid="0"})确认, |
| | | * 所以这里统一成 0 起,与库路线、逐点路线同口径。</li> |
| | | * <li><b>开始时间 = 结束时间 − 工步时长。</b>应答只给 {@code endatime}。按两个独立样本核对过 |
| | | * ({@code endatime=09:09:14, steptime=23100 ms} → 09:08:50.9,该测试首行 {@code atime}=09:08:51), |
| | | * 所以这个减法还原的是设备事实,不是估算。缓冲里缺行时(实测有测试只覆盖约 1 天) |
| | | * 相邻两工步的首末不会接上,那是来源的数据缺口,不是这里算错。</li> |
| | | * <li><b>{@code stepindex} 才是工步身份。</b>{@code stepid} 在循环里会重复(同一步在每轮循环再跑一遍), |
| | | * 用它做身份会撞键。</li> |
| | | * </ul> |
| | | * |
| | | * <p>方向的判定沿用既有解析器;{@code cap}/{@code eng} 在接口上是**单一值**(不分充放), |
| | | * 只在方向已确认时给出,方向未知时留空——不按数值大小猜它属于哪一侧(ADR-0069 §3)。 |
| | | * |
| | | * @param xml 应答原文 |
| | | * @param receivedAt 服务器接收时间 |
| | | * @return 按应答顺序的工步记录;该通道缓冲里没有测试时为空列表 |
| | | * @throws BtsProtocolException 应答不是 {@code downloadStepLayer_resp} |
| | | */ |
| | | public List<StepRecord> parseStepLayer(String xml, Instant receivedAt) |
| | | { |
| | | Document document = parse(xml, "downloadStepLayer_resp"); |
| | | NodeList nodes = document.getElementsByTagName("data"); |
| | | if (nodes.getLength() == 0) return List.of(); |
| | | Element header = optionalElement(document, "downloadStepLayer"); |
| | | String testId = header == null ? "" : optionalAttribute(header, "testid"); |
| | | String zoneId = deviceZone.getId(); |
| | | List<StepRecord> steps = new ArrayList<>(nodes.getLength()); |
| | | for (int i = 0; i < nodes.getLength(); i++) |
| | | { |
| | | Element data = (Element) nodes.item(i); |
| | | String stepType = optionalAttribute(data, "steptype"); |
| | | StepDirectionResolver.Direction direction = directions.resolve(stepType); |
| | | String rawTime = requiredAttribute(data, "endatime"); |
| | | Instant endedAt = parseDataTime(rawTime); |
| | | Long durationMillis = optionalDecimal(data, "steptime") == null ? null |
| | | : optionalDecimal(data, "steptime").longValue(); |
| | | int stepIndex = integer(requiredAttribute(data, "stepindex"), "stepindex"); |
| | | // 应答里的 cycleid 是 1 起,逐点与厂商库都是 0 起;统一成 0 起。 |
| | | int cycleNo = requiredInteger(data, "cycleid") - 1; |
| | | BigDecimal capacity = optionalDecimal(data, "cap"); |
| | | BigDecimal energy = optionalDecimal(data, "eng"); |
| | | boolean directionKnown = direction != StepDirectionResolver.Direction.UNKNOWN; |
| | | steps.add(new StepRecord(testId.isBlank() ? null : "neware:" + testId + ":step:" + stepIndex, cycleNo, |
| | | requiredInteger(data, "stepid"), direction.displayName(), stepType, |
| | | durationMillis == null ? endedAt : endedAt.minusMillis(durationMillis), |
| | | endedAt, doubleValue(data, "endvolt"), doubleValue(data, "endcurr"), |
| | | directionKnown && capacity != null ? capacity.doubleValue() : null, |
| | | directionKnown && energy != null ? energy.doubleValue() : null, |
| | | rawTime, zoneId, receivedAt, stepMeasurements(data, durationMillis))); |
| | | } |
| | | return List.copyOf(steps); |
| | | } |
| | | |
| | | /** 工步的设备事实明细;映射表按已有键名口径命名,值全部来自应答原文。 */ |
| | | private static Map<String, BigDecimal> stepMeasurements(Element data, Long durationMillis) |
| | | { |
| | | Map<String, BigDecimal> measurements = new LinkedHashMap<>(); |
| | | if (durationMillis != null) measurements.put("stepDuration", BigDecimal.valueOf(durationMillis).movePointLeft(3)); |
| | | put(measurements, data, "startSeqId", "startseqid"); |
| | | put(measurements, data, "endSeqId", "endseqid"); |
| | | put(measurements, data, "startVoltage", "startvolt"); |
| | | put(measurements, data, "startCurrent", "startcurr"); |
| | | put(measurements, data, "dcir", "dcir"); |
| | | return Map.copyOf(measurements); |
| | | } |
| | | |
| | | private static void put(Map<String, BigDecimal> target, Element element, String key, String attribute) |
| | | { |
| | | BigDecimal value = optionalDecimal(element, attribute); |
| | | if (value != null) target.put(key, value); |
| | | } |
| | | |
| | | private static int requiredInteger(Element element, String name) |
| | | { |
| | | return integer(requiredAttribute(element, name), name); |
| | | } |
| | | |
| | | |
| | | |
| | | /** 解析 {@code getdevinfo_resp},按本实例的适配器编码生成检测柜通道清单。 */ |
| | |
| | | package com.battery.device.bts; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.math.RoundingMode; |
| | | import java.time.Instant; |
| | | import java.util.ArrayList; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Set; |
| | | import java.util.concurrent.ConcurrentHashMap; |
| | | import com.battery.device.api.DeviceAdapter; |
| | | import com.battery.device.api.StepDirectionResolver; |
| | | import com.battery.device.api.TestDataObservationAdapter; |
| | | import com.battery.device.api.model.AdapterCapabilities; |
| | | import com.battery.device.api.model.ChannelIdentity; |
| | | import com.battery.device.api.model.DetectedChannel; |
| | | import com.battery.device.api.model.DeviceWorkStatus; |
| | | import com.battery.device.api.model.RealtimeSnapshot; |
| | | |
| | | /** |
| | |
| | | * <li>{@code inquiredf} → 当前测试号与已落盘行数,后者是逐点接收的增量水位;</li> |
| | | * <li>{@code download} → 逐点行。它给全精度测量值**和绝对时间**,所以测量值与时间轴取它, |
| | | * 而不取 {@code inquire}(后者是 4 位小数、且没有绝对时间)。</li> |
| | | * <li>{@code downloadStepLayer} → 工步层;循环汇总由这些工步按循环聚合(见 {@link #cycleRecords})。</li> |
| | | * </ul> |
| | | * |
| | | * <p><b>不交出的三类事实</b>(都不猜、不用空值冒充已知): |
| | | * <p><b>不交出的两类事实</b>(都不猜、不用空值冒充已知): |
| | | * |
| | | * <ul> |
| | | * <li><b>工步层与循环汇总</b>:协议里读工步层要 {@code downloadStepLayer},该命令**未验证**, |
| | | * 所以这边为空;它们属于该机厂商库那条路(ADR-0068 §5、未决 1)。</li> |
| | | * <li><b>辅助通道 / 采集器测量</b>:ADR-0068 已复核不可得(该机 {@code aux_data_*} 全空、 |
| | | * API 的 {@code auxtemp}/{@code auxvol} 为 0),所以 {@link #latestCollector} 返回空值。</li> |
| | | * <li><b>计划方案</b>:协议里没有读方案的命令(现场实测 {@code inquire_resp} 不带计划), |
| | |
| | | public static final String ADAPTER_TYPE = "NEWARE_BTS"; |
| | | /** 厂商采样周期实测为 60 秒;实时持久化槽位按它对齐,取数不必更快。 */ |
| | | public static final long SAMPLE_INTERVAL_MS = 60_000L; |
| | | /** 工步层读取结果的复用窗口:只为让工步与循环来自同一次读取,不做缓存加速。 */ |
| | | private static final long STEP_LAYER_CACHE_NANOS = 5_000_000_000L; |
| | | |
| | | private final String adapterCode; |
| | | private final BtsTransport transport; |
| | | private final BtsXmlCodec codec; |
| | | private final StepDirectionResolver directions = new BtsStepDirectionResolver(); |
| | | private final AdapterCapabilities capabilities; |
| | | /** 每通道最近一次工步层读取;见 {@link #stepLayer}。 */ |
| | | private final Map<String, StepLayer> stepLayers = new ConcurrentHashMap<>(); |
| | | |
| | | public NewareBtsAdapter(String adapterCode, BtsTransport transport, BtsXmlCodec codec) |
| | | { |
| | |
| | | return null; |
| | | } |
| | | |
| | | /** 本路线的工步层需要未验证的 {@code downloadStepLayer},因此不交出工步统计。 */ |
| | | /** |
| | | * 返回该通道**已结束**的工步。来源是 {@code downloadStepLayer}(现场实测可用)。 |
| | | * |
| | | * <p>这条命令**会把正在进行的那一步也交出来**,而且它的 {@code endseqid} 恰好等于 |
| | | * {@code inquiredf} 报的已落盘行数(实测四个测试都是相等)——所以**行数比不出结束没有**, |
| | | * 判据是通道的工作状态:仍在运行、暂停或保护中的测试,最后一步没有结束。 |
| | | * 交出未结束的段会让它的结束时间每轮都在变,同一条记录反复以不同内容到达,既留下一条错的结束时间, |
| | | * 又不断触发"内容冲突"告警,所以这里把它连同它所在的循环一起按住,等它结束再交出。 |
| | | */ |
| | | @Override |
| | | public List<StepRecord> stepRecords(ChannelIdentity channel) |
| | | { |
| | | return List.of(); |
| | | return stepLayer(channel).steps(); |
| | | } |
| | | |
| | | /** 本路线的循环汇总同上:不交出,也不用实时行推导来源没有的汇总。 */ |
| | | /** |
| | | * 返回该通道的循环汇总,由已结束的工步按循环聚合({@link CycleRecord#SOURCE_AGGREGATED})。 |
| | | * |
| | | * <p>接口没有循环汇总行,聚合口径与库路线一致:只把方向已确认的工步容量能量加进对应一侧, |
| | | * 搁置与未知方向不进任何一侧(按数值猜方向就是编事实);效率 = 放电容量 / 充电容量 × 100, |
| | | * 没有可用充电容量时留空。含未结束工步的那个循环整轮不交出——那几项合计还在长。 |
| | | */ |
| | | @Override |
| | | public List<CycleRecord> cycleRecords(ChannelIdentity channel) |
| | | { |
| | | return List.of(); |
| | | StepLayer layer = stepLayer(channel); |
| | | return aggregateCycles(layer); |
| | | } |
| | | |
| | | /** 返回厂商采样周期作为实时持久化槽位间隔。 */ |
| | |
| | | transport.close(); |
| | | } |
| | | |
| | | /** |
| | | * 取该通道的工步层,并按"最后一步行不行"切出已结束的部分。 |
| | | * |
| | | * <p>结果在 {@link #STEP_LAYER_CACHE_NANOS} 内复用:工步与循环必须来自**同一次读取**, |
| | | * 否则一个工步边界落在两次读取之间时,循环的合计会包含工步列表里没有的那一步, |
| | | * 使循环记录的内容每轮都不同(同一条循环反复以不同内容到达)。 |
| | | */ |
| | | private StepLayer stepLayer(ChannelIdentity channel) |
| | | { |
| | | String key = channel.vendorKey(); |
| | | StepLayer cached = stepLayers.get(key); |
| | | if (cached != null && System.nanoTime() - cached.fetchedAtNanos() < STEP_LAYER_CACHE_NANOS) return cached; |
| | | StepLayer fetched = fetchStepLayer(channel); |
| | | stepLayers.put(key, fetched); |
| | | return fetched; |
| | | } |
| | | |
| | | private StepLayer fetchStepLayer(ChannelIdentity channel) |
| | | { |
| | | long fetchedAtNanos = System.nanoTime(); |
| | | Instant receivedAt = Instant.now(); |
| | | List<StepRecord> layers = codec.parseStepLayer( |
| | | transport.exchange(codec.downloadStepLayerRequest(channel)), receivedAt); |
| | | if (layers.isEmpty()) return new StepLayer(List.of(), null, fetchedAtNanos); |
| | | // 通道仍在动(运行、暂停、保护中)时最后一步没结束:它和它所在的循环都先按住。 |
| | | if (!isStillRunning(channel)) return new StepLayer(layers, null, fetchedAtNanos); |
| | | StepRecord running = layers.get(layers.size() - 1); |
| | | return new StepLayer(layers.subList(0, layers.size() - 1), running.cycleNo(), fetchedAtNanos); |
| | | } |
| | | |
| | | /** |
| | | * 通道工作状态是否说明测试仍在进行。 |
| | | * |
| | | * <p>只有读到"测试进行中"的状态才按住最后一步;其余状态(含读不到该通道)按已结束处理。 |
| | | * **读取失败不吞**:{@code inquire} 抛错时让它传出去,调用方本来就有"来源读不到就本轮放弃、 |
| | | * 下一轮重试"的约定,这样一次传输抖动不会变成"把一个还在跑的工步交出去"。 |
| | | */ |
| | | private boolean isStillRunning(ChannelIdentity channel) |
| | | { |
| | | RealtimeSnapshot snapshot = inquire(Set.of(channel)).get(channel); |
| | | if (snapshot == null) return false; |
| | | DeviceWorkStatus status = snapshot.workStatus(); |
| | | return status == DeviceWorkStatus.WORKING || status == DeviceWorkStatus.PAUSED |
| | | || status == DeviceWorkStatus.PROTECTED || status == DeviceWorkStatus.SYNCHRONIZING; |
| | | } |
| | | |
| | | /** 把已结束的工步按循环聚合;含未结束工步的那个循环不交出。 */ |
| | | private List<CycleRecord> aggregateCycles(StepLayer layer) |
| | | { |
| | | Map<Integer, List<StepRecord>> byCycle = new LinkedHashMap<>(); |
| | | for (StepRecord step : layer.steps()) |
| | | { |
| | | if (layer.unfinishedCycleNo() != null && layer.unfinishedCycleNo() == step.cycleNo()) continue; |
| | | byCycle.computeIfAbsent(step.cycleNo(), ignored -> new ArrayList<>()).add(step); |
| | | } |
| | | List<CycleRecord> cycles = new ArrayList<>(byCycle.size()); |
| | | byCycle.forEach((cycleNo, group) -> { |
| | | BigDecimal chargeCapacity = BigDecimal.ZERO, dischargeCapacity = BigDecimal.ZERO; |
| | | BigDecimal chargeEnergy = BigDecimal.ZERO, dischargeEnergy = BigDecimal.ZERO; |
| | | for (StepRecord step : group) |
| | | { |
| | | if (step.capacityAh() == null || step.energyWh() == null) continue; |
| | | BigDecimal capacity = BigDecimal.valueOf(step.capacityAh()); |
| | | BigDecimal energy = BigDecimal.valueOf(step.energyWh()); |
| | | switch (directions.resolve(step.stepType())) |
| | | { |
| | | case CHARGE -> |
| | | { |
| | | chargeCapacity = chargeCapacity.add(capacity); |
| | | chargeEnergy = chargeEnergy.add(energy); |
| | | } |
| | | case DISCHARGE -> |
| | | { |
| | | dischargeCapacity = dischargeCapacity.add(capacity); |
| | | dischargeEnergy = dischargeEnergy.add(energy); |
| | | } |
| | | // 搁置与未知方向不进任何一侧:把容量算进某一侧就是凭数值猜方向。 |
| | | default -> { } |
| | | } |
| | | } |
| | | StepRecord first = group.get(0), last = group.get(group.size() - 1); |
| | | cycles.add(new CycleRecord("neware:" + testIdOf(first) + ":cycle:" + cycleNo, cycleNo, |
| | | first.startedAt(), last.endedAt(), chargeCapacity.doubleValue(), dischargeCapacity.doubleValue(), |
| | | chargeEnergy.doubleValue(), dischargeEnergy.doubleValue(), |
| | | chargeCapacity.signum() == 0 ? null : dischargeCapacity.multiply(BigDecimal.valueOf(100)) |
| | | .divide(chargeCapacity, 4, RoundingMode.HALF_UP).doubleValue(), |
| | | last.deviceTimeRaw(), codec.timezoneId(), last.serverReceivedAt(), CycleRecord.SOURCE_AGGREGATED)); |
| | | }); |
| | | return List.copyOf(cycles); |
| | | } |
| | | |
| | | /** 从工步的来源行身份里取回测试号(形如 {@code neware:<测试号>:step:<序号>})。 */ |
| | | private static String testIdOf(StepRecord step) |
| | | { |
| | | String identity = step.sourceRecordId(); |
| | | if (identity == null) return "unknown"; |
| | | String[] parts = identity.split(":"); |
| | | return parts.length >= 2 ? parts[1] : "unknown"; |
| | | } |
| | | |
| | | /** |
| | | * 一次工步层读取的结果:已结束的工步、被按住的未结束工步所属循环(没有则为空)。 |
| | | */ |
| | | private record StepLayer(List<StepRecord> steps, Integer unfinishedCycleNo, long fetchedAtNanos) { } |
| | | |
| | | private BtsXmlCodec.DataFileState dataFileState(ChannelIdentity channel) |
| | | { |
| | | return codec.parseDataFileState(transport.exchange(codec.inquiredfRequest(channel))); |
| | |
| | | import org.junit.jupiter.api.Test; |
| | | import com.battery.device.api.DeviceAdapter; |
| | | import com.battery.device.api.TestDataObservationAdapter; |
| | | import com.battery.device.api.TestDataObservationAdapter.CycleRecord; |
| | | import com.battery.device.api.TestDataObservationAdapter.SampleWatermark; |
| | | import com.battery.device.api.model.ChannelIdentity; |
| | | import com.battery.device.api.model.DeviceControlCommand; |
| | |
| | | } |
| | | |
| | | @Test |
| | | void doesNotInventStepCyclesCollectorOrPlanFacts() |
| | | void doesNotInventCollectorOrPlanFacts() |
| | | { |
| | | ScriptedPipe pipe = new ScriptedPipe(); |
| | | try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe)) |
| | | { |
| | | // 工步层要未验证的 downloadStepLayer;采集器测量 ADR-0068 已复核不可得;协议里没有读计划的命令。 |
| | | assertTrue(adapter.stepRecords(CHANNEL).isEmpty()); |
| | | assertTrue(adapter.cycleRecords(CHANNEL).isEmpty()); |
| | | // 采集器测量 ADR-0068 已复核不可得;协议里没有读计划的命令。 |
| | | assertNull(adapter.latestCollector(ADAPTER, "collector-1")); |
| | | assertNull(adapter.testPlan(CHANNEL)); |
| | | assertEquals(NewareBtsAdapter.SAMPLE_INTERVAL_MS, adapter.persistenceIntervalMillis(CHANNEL)); |
| | |
| | | assertThrows(UnsupportedOperationException.class, () -> adapter.control(CHANNEL, command)); |
| | | assertTrue(pipe.commands().isEmpty()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void handsOutEveryStepAndCycleWhenTheChannelHasEnded() |
| | | { |
| | | ScriptedPipe pipe = finishedTestPipe(DeviceWorkStatus.FINISHED); |
| | | try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe)) |
| | | { |
| | | var steps = adapter.stepRecords(CHANNEL); |
| | | var cycles = adapter.cycleRecords(CHANNEL); |
| | | |
| | | assertEquals(4, steps.size()); |
| | | assertEquals("充电", steps.get(1).stepName()); |
| | | // 循环号已按 0 起归一到与厂商库、逐点同口径(应答里的 cycleid 是 1 起)。 |
| | | assertEquals(0, steps.get(0).cycleNo()); |
| | | |
| | | assertEquals(1, cycles.size()); |
| | | var cycle = cycles.get(0); |
| | | assertEquals(0, cycle.cycleNo()); |
| | | assertEquals(2.81630277777778, cycle.chargeCapacityAh(), 1e-9); |
| | | assertEquals(0.00431017035590278, cycle.dischargeCapacityAh(), 1e-12); |
| | | assertEquals(5.58447055555556, cycle.chargeEnergyWh(), 1e-9); |
| | | assertEquals(0.00426197048611111, cycle.dischargeEnergyWh(), 1e-12); |
| | | // 效率 = 放电容量 / 充电容量 × 100;搁置工步不进任何一侧。 |
| | | assertEquals(0.153, cycle.efficiency(), 0.0005); |
| | | assertEquals("neware:316:cycle:0", cycle.sourceRecordId()); |
| | | assertEquals(CycleRecord.SOURCE_AGGREGATED, cycle.sourceKind()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void holdsBackTheRunningStepAndItsCycleWhileTheTestIsStillRunning() |
| | | { |
| | | // 实测这条命令会把正在进行的那一步也交出来,而它的 endseqid 恰好等于已落盘行数 |
| | | // (四个测试都相等),所以只能靠通道工作状态判断它有没有结束。 |
| | | ScriptedPipe pipe = finishedTestPipe(DeviceWorkStatus.WORKING); |
| | | try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe)) |
| | | { |
| | | var steps = adapter.stepRecords(CHANNEL); |
| | | var cycles = adapter.cycleRecords(CHANNEL); |
| | | |
| | | // 最后一步(stepindex=4)与它所在的循环都不交出,等它结束再交。 |
| | | assertEquals(3, steps.size()); |
| | | assertEquals("neware:316:step:3", steps.get(2).sourceRecordId()); |
| | | assertTrue(cycles.isEmpty()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void readsTheStepLayerOnceForBothStepsAndCycles() |
| | | { |
| | | // 工步与循环必须来自同一次读取:否则一个工步边界落在两次读取之间时, |
| | | // 循环合计会包含工步列表里没有的那一步,使循环记录每轮内容都不同。 |
| | | ScriptedPipe pipe = finishedTestPipe(DeviceWorkStatus.FINISHED); |
| | | try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe)) |
| | | { |
| | | adapter.stepRecords(CHANNEL); |
| | | adapter.cycleRecords(CHANNEL); |
| | | |
| | | assertEquals(1, pipe.commands().stream().filter("downloadsteplayer"::equals).count()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void aChannelWithNoTestInTheBufferHasNoStepsOrCycles() |
| | | { |
| | | ScriptedPipe pipe = new ScriptedPipe() |
| | | .on("downloadStepLayer", "<bts><cmd>downloadStepLayer_resp</cmd><list count=\"0\" /></bts>"); |
| | | try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe)) |
| | | { |
| | | assertTrue(adapter.stepRecords(CHANNEL).isEmpty()); |
| | | assertTrue(adapter.cycleRecords(CHANNEL).isEmpty()); |
| | | // 缓冲里没有测试就直接是空,不必再去问工作状态。 |
| | | assertEquals(List.of("downloadsteplayer"), pipe.commands()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void aFailedStateReadSkipsTheRoundInsteadOfHandingOutTheRunningStep() |
| | | { |
| | | // 读取失败不吞:调用方本来就有"来源读不到就本轮放弃、下一轮重试"的约定, |
| | | // 所以一次传输抖动不会变成"把一个还在跑的工步交出去",也不会被当成结论缓存下来。 |
| | | ScriptedPipe pipe = finishedTestPipe(DeviceWorkStatus.WORKING).failing("inquire"); |
| | | try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe)) |
| | | { |
| | | assertThrows(RuntimeException.class, () -> adapter.stepRecords(CHANNEL)); |
| | | |
| | | pipe.recover(); |
| | | // 恢复后重新读,拿到的是完整结论(在跑 → 交出 3 步、按住最后一步),不是上一次的空结果。 |
| | | assertEquals(3, adapter.stepRecords(CHANNEL).size()); |
| | | } |
| | | } |
| | | |
| | | /** 一个已结束测试的工步层(现场实测原文,测试 316):4 步都在循环 1(归一后为 0)。 */ |
| | | private static ScriptedPipe finishedTestPipe(DeviceWorkStatus workStatus) |
| | | { |
| | | return new ScriptedPipe() |
| | | .on("inquire", "<bts><cmd>inquire_resp</cmd><list count=\"1\"><inquire dev=\"25-81-1-2-0\"" |
| | | + " cycle_id=\"1\" step_id=\"4\" step_type=\"dc\" workstatus=\"" |
| | | + workStatus.name().toLowerCase(java.util.Locale.ROOT) |
| | | + "\" current=\"0\" voltage=\"1.2\" /></list></bts>") |
| | | .on("downloadStepLayer", "<bts><cmd>downloadStepLayer_resp</cmd>" |
| | | + "<downloadStepLayer devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\" testid=\"316\" />" |
| | | + "<list count=\"4\">" |
| | | + "<data startseqid=\"1\" endseqid=\"2\" stepindex=\"1\" stepid=\"1\" cycleid=\"1\"" |
| | | + " steptype=\"rest\" steptime=\"23100\" endatime=\"2026-10-10 09:09:14\"" |
| | | + " startvolt=\"0.806611083984375\" endvolt=\"0.80638037109375\" startcurr=\"0\"" |
| | | + " endcurr=\"0\" cap=\"0\" eng=\"0\" dcir=\"0\" />" |
| | | + "<data startseqid=\"3\" endseqid=\"304\" stepindex=\"2\" stepid=\"2\" cycleid=\"1\"" |
| | | + " steptype=\"cc\" steptime=\"18000000\" endatime=\"2026-10-10 14:09:14\"" |
| | | + " startvolt=\"1.07542861328125\" endvolt=\"1.972108203125\"" |
| | | + " startcurr=\"0.563903076171875\" endcurr=\"0.563332702636719\"" |
| | | + " cap=\"2.81630277777778\" eng=\"5.58447055555556\" dcir=\"0\" />" |
| | | + "<data startseqid=\"305\" endseqid=\"307\" stepindex=\"3\" stepid=\"3\" cycleid=\"1\"" |
| | | + " steptype=\"rest\" steptime=\"83600\" endatime=\"2026-10-10 14:10:38\"" |
| | | + " startvolt=\"1.7850955078125\" endvolt=\"1.42794287109375\" startcurr=\"0\"" |
| | | + " endcurr=\"0\" cap=\"0\" eng=\"0\" dcir=\"0\" />" |
| | | + "<data startseqid=\"308\" endseqid=\"309\" stepindex=\"4\" stepid=\"4\" cycleid=\"1\"" |
| | | + " steptype=\"dc\" steptime=\"27600\" endatime=\"2026-10-10 14:11:05\"" |
| | | + " startvolt=\"1.27452001953125\" endvolt=\"0.49604326171875\"" |
| | | + " startcurr=\"-0.562971618652344\" endcurr=\"-0.5622275390625\"" |
| | | + " cap=\"0.00431017035590278\" eng=\"0.00426197048611111\" dcir=\"0\" /></list></bts>"); |
| | | } |
| | | |
| | | private static DeviceAdapter adapter(ScriptedPipe pipe) |
| | |
| | | |
| | | private final Map<String, String> responses = new java.util.LinkedHashMap<>(); |
| | | private final List<String> commands = new ArrayList<>(); |
| | | /** 让指定命令暂时失败,用来验"读取失败不吞、不当结论缓存"。 */ |
| | | private final java.util.Set<String> failing = new java.util.LinkedHashSet<>(); |
| | | private int firstRow = 0; |
| | | private int lastRow = 0; |
| | | |
| | | ScriptedPipe on(String command, String response) |
| | | { |
| | | responses.put(command, response); |
| | | // 请求里的命令名按小写匹配(与生产传输的比对口径一致)。 |
| | | responses.put(command.toLowerCase(java.util.Locale.ROOT), response); |
| | | return this; |
| | | } |
| | | |
| | | ScriptedPipe failing(String command) |
| | | { |
| | | failing.add(command.toLowerCase(java.util.Locale.ROOT)); |
| | | return this; |
| | | } |
| | | |
| | | void recover() |
| | | { |
| | | failing.clear(); |
| | | } |
| | | |
| | | /** 生成 {@code [first, last]} 的逐点行集,供 download/inquiredf 按窗口回答。 */ |
| | |
| | | Matcher matcher = COMMAND.matcher(request); |
| | | if (!matcher.find()) throw new IllegalStateException("请求缺少命令:" + request); |
| | | String command = matcher.group(1).toLowerCase(java.util.Locale.ROOT); |
| | | if (failing.contains(command)) |
| | | throw new BtsTransportException(BtsTransportException.Reason.TIMEOUT, "测试脚本让 " + command + " 失败", null); |
| | | if ("inquiredf".equals(command)) |
| | | { |
| | | commands.add("inquiredf"); |
| | |
| | | import java.util.Set; |
| | | import org.junit.jupiter.api.Test; |
| | | import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime; |
| | | import com.battery.device.api.TestDataObservationAdapter.StepRecord; |
| | | import com.battery.device.api.model.ChannelIdentity; |
| | | import com.battery.device.api.model.DeviceWorkStatus; |
| | | import com.battery.device.api.model.RealtimeSnapshot; |
| | |
| | | } |
| | | |
| | | @Test |
| | | void buildsTheStepLayerRequestWithoutTheOptionalElements() |
| | | { |
| | | String request = codec.downloadStepLayerRequest(CHANNEL); |
| | | |
| | | assertTrue(request.contains("<cmd>downloadStepLayer</cmd>")); |
| | | assertTrue(request.contains("devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\"")); |
| | | assertTrue(request.contains("testid=\"0\"")); |
| | | assertTrue(request.contains("dcir=\"0\"")); |
| | | } |
| | | |
| | | @Test |
| | | void parsesRealStepLayerWithNormalisedCycleAndDerivedStartTime() |
| | | { |
| | | // 现场实测原文(`26.117.165.166` 的 `25-81-1-1`,测试 316)。三处要点都在这个夹具里: |
| | | // cycleid 是 1 起要减一;只有 endatime,开始时间由 endatime − steptime 还原; |
| | | // stepindex 才是身份(stepid 在循环里会重复)。 |
| | | String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\">" |
| | | + "<cmd>downloadStepLayer_resp</cmd>" |
| | | + "<downloadStepLayer devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"1\" testid=\"316\" />" |
| | | + "<list count=\"4\">" |
| | | + "<data startseqid=\"1\" endseqid=\"2\" stepindex=\"1\" stepid=\"1\" cycleid=\"1\" steptype=\"rest\"" |
| | | + " steptime=\"23100\" endatime=\"2026-10-10 09:09:14\" startvolt=\"0.806611083984375\"" |
| | | + " endvolt=\"0.80638037109375\" startcurr=\"0\" endcurr=\"0\" cap=\"0\" eng=\"0\" dcir=\"0\" />" |
| | | + "<data startseqid=\"3\" endseqid=\"304\" stepindex=\"2\" stepid=\"2\" cycleid=\"1\" steptype=\"cc\"" |
| | | + " steptime=\"18000000\" endatime=\"2026-10-10 14:09:14\" startvolt=\"1.07542861328125\"" |
| | | + " endvolt=\"1.972108203125\" startcurr=\"0.563903076171875\" endcurr=\"0.563332702636719\"" |
| | | + " cap=\"2.81630277777778\" eng=\"5.58447055555556\" dcir=\"0\" />" |
| | | + "<data startseqid=\"305\" endseqid=\"307\" stepindex=\"3\" stepid=\"3\" cycleid=\"1\" steptype=\"rest\"" |
| | | + " steptime=\"83600\" endatime=\"2026-10-10 14:10:38\" startvolt=\"1.7850955078125\"" |
| | | + " endvolt=\"1.42794287109375\" startcurr=\"0\" endcurr=\"0\" cap=\"0\" eng=\"0\" dcir=\"0\" />" |
| | | + "<data startseqid=\"308\" endseqid=\"309\" stepindex=\"4\" stepid=\"4\" cycleid=\"1\" steptype=\"dc\"" |
| | | + " steptime=\"27600\" endatime=\"2026-10-10 14:11:05\" startvolt=\"1.27452001953125\"" |
| | | + " endvolt=\"0.49604326171875\" startcurr=\"-0.562971618652344\" endcurr=\"-0.5622275390625\"" |
| | | + " cap=\"0.00431017035590278\" eng=\"0.00426197048611111\" dcir=\"0\" /></list></bts>"; |
| | | |
| | | List<StepRecord> steps = codec.parseStepLayer(xml, Instant.parse("2026-10-10T14:12:00Z")); |
| | | |
| | | assertEquals(4, steps.size()); |
| | | StepRecord first = steps.get(0); |
| | | assertEquals(0, first.cycleNo()); |
| | | assertEquals(1, first.stepNo()); |
| | | assertEquals("rest", first.stepType()); |
| | | assertEquals("搁置", first.stepName()); |
| | | assertEquals("2026-10-10T01:09:14Z", first.endedAt().toString()); |
| | | // 开始时间 = 结束时间 − 23100 ms;与该测试首行 atime(09:08:51 设备本地)对得上。 |
| | | assertEquals("2026-10-10T01:08:50.900Z", first.startedAt().toString()); |
| | | assertEquals("neware:316:step:1", first.sourceRecordId()); |
| | | assertEquals("Asia/Hong_Kong", first.timezoneId()); |
| | | assertEquals("2026-10-10 09:09:14", first.deviceTimeRaw()); |
| | | |
| | | StepRecord charge = steps.get(1); |
| | | assertEquals("充电", charge.stepName()); |
| | | assertEquals(2.81630277777778, charge.capacityAh(), 1e-12); |
| | | assertEquals(5.58447055555556, charge.energyWh(), 1e-12); |
| | | assertEquals(1.972108203125, charge.endVoltageV(), 1e-12); |
| | | assertEquals(0.563332702636719, charge.endCurrentA(), 1e-12); |
| | | assertEquals(3, charge.measurements().get("startSeqId").intValue()); |
| | | assertEquals(304, charge.measurements().get("endSeqId").intValue()); |
| | | |
| | | StepRecord discharge = steps.get(3); |
| | | assertEquals("放电", discharge.stepName()); |
| | | assertEquals(0.00431017035590278, discharge.capacityAh(), 1e-15); |
| | | } |
| | | |
| | | @Test |
| | | void leavesStepCapacityEmptyWhenTheDirectionIsUnknown() |
| | | { |
| | | // 方向未知时不给单项容量能量:cap 是单一值,方向没确认就不知道它属于哪一侧(ADR-0069 §3)。 |
| | | String xml = "<bts><cmd>downloadStepLayer_resp</cmd>" |
| | | + "<downloadStepLayer devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"1\" testid=\"9\" />" |
| | | + "<list count=\"1\"><data startseqid=\"1\" endseqid=\"9\" stepindex=\"1\" stepid=\"1\" cycleid=\"2\"" |
| | | + " steptype=\"--\" steptime=\"60000\" endatime=\"2026-10-10 09:09:14\" startvolt=\"1\" endvolt=\"1\"" |
| | | + " startcurr=\"0\" endcurr=\"0\" cap=\"1.5\" eng=\"2.5\" dcir=\"0\" /></list></bts>"; |
| | | |
| | | StepRecord step = codec.parseStepLayer(xml, Instant.EPOCH).get(0); |
| | | |
| | | assertNull(step.stepName()); |
| | | assertNull(step.capacityAh()); |
| | | assertNull(step.energyWh()); |
| | | assertEquals(1, step.cycleNo()); |
| | | // 明细里保留的是设备原文事实(起始电压、首末序号),没有按方向分派的单项值。 |
| | | assertEquals(1.0, step.measurements().get("startVoltage").doubleValue(), 1e-9); |
| | | assertEquals(9, step.measurements().get("endSeqId").intValue()); |
| | | } |
| | | |
| | | @Test |
| | | void reportsAnEmptyStepLayerInsteadOfFailing() |
| | | { |
| | | // 该通道的缓冲里没有测试时(实测 D 那台就是),应答是空列表而不是错误。 |
| | | List<StepRecord> steps = codec.parseStepLayer( |
| | | "<bts><cmd>downloadStepLayer_resp</cmd><list count=\"0\" /></bts>", Instant.EPOCH); |
| | | |
| | | assertTrue(steps.isEmpty()); |
| | | } |
| | | |
| | | @Test |
| | | void rejectsAStepLayerResponseToAnotherCommand() |
| | | { |
| | | BtsProtocolException exception = assertThrows(BtsProtocolException.class, |
| | | () -> codec.parseStepLayer("<bts><cmd>inquire_resp</cmd><list count=\"0\" /></bts>", Instant.EPOCH)); |
| | | |
| | | assertEquals(BtsProtocolException.Reason.UNEXPECTED_COMMAND, exception.reason()); |
| | | } |
| | | |
| | | @Test |
| | | void keepsOnlyAttributesOutsideTheKnownFieldSetAsDynamicMeasurements() |
| | | { |
| | | // 字段集随客户端构建不同(8.0.0.484 有 barcode/auxtemp,8.0.0.530 没有,实测三种构建互不相同)。 |