From 99935242ecccd94e76978e2b71cbc46b87d31dc6 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期五, 09 十月 2026 11:17:44 +0800
Subject: [PATCH] 推式来源的工步与循环改为从采样段推导
---
battery-business/src/main/java/com/battery/business/service/impl/PushIngestAdapter.java | 264 +++++++++++++++++++++++++++++++++++++++++++++++++++-
1 files changed, 257 insertions(+), 7 deletions(-)
diff --git a/battery-business/src/main/java/com/battery/business/service/impl/PushIngestAdapter.java b/battery-business/src/main/java/com/battery/business/service/impl/PushIngestAdapter.java
index 4dfdc59..30ff7d0 100644
--- a/battery-business/src/main/java/com/battery/business/service/impl/PushIngestAdapter.java
+++ b/battery-business/src/main/java/com/battery/business/service/impl/PushIngestAdapter.java
@@ -1,11 +1,13 @@
package com.battery.business.service.impl;
import java.math.BigDecimal;
+import java.math.RoundingMode;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -37,13 +39,15 @@
* 不开入站端口、不在网络上传递厂商库凭据;解释(身份映射、时间换算、幂等键取值)全部在后端完成,
* 所以这里只做投影:落地行的字段由接收端点解析好,本类不出现任何厂商列名。
*
- * <p>三条本形态特有的事实:
+ * <p>四条本形态特有的事实:
* <ul>
* <li><b>来源是否在线由心跳判定</b>:落地表在本地随时可读,因此"能否读到表"不能表示来源可达。
* 取数程序每轮推送一次心跳,心跳缺失即认为来源不可达——发现路径明确失败,
* 已登记设备随之离线、旧快照被撤下(既有路径),页面不会显示伪造的实时值。</li>
* <li><b>最近数据时间只看采样行</b>:没有新采样行就是没有,不按拓扑上报或心跳时间代替(ADR-0063)。</li>
* <li><b>通道工作状态不可观测</b>:落地表里没有设备侧状态,报"无法观测"而不由数据行推断。</li>
+ * <li><b>工步与循环从采样段推导</b>:这类来源的厂商库里没有工步层也没有循环汇总表,工步号与循环号
+ * 只在采样行上,所以按「来源库 + 循环号 + 工步号」分段推导(ADR-0067),推导是无状态重算。</li>
* </ul>
*/
public final class PushIngestAdapter implements DeviceAdapter, TestDataObservationAdapter
@@ -68,6 +72,8 @@
* 只取水位之后的新行,取数更勤不会产生重复记录。厂商写库节奏仍是 60 秒,值不会来得更快。
*/
private static final long PERSISTENCE_INTERVAL_MS = 5000L;
+ /** 厂商写库节奏:工步与循环的重算对齐它,来源的汇总产出不可能比这更快。 */
+ private static final long AGGREGATE_INTERVAL_MS = 60000L;
private static final int SAMPLE_BATCH_LIMIT = 500;
/** 心跳容忍窗口的下限:写库与推送都是 60 秒一轮,窗口至少覆盖三轮。 */
private static final Duration MINIMUM_STALE_AFTER = Duration.ofSeconds(90);
@@ -254,20 +260,37 @@
String unit) { }
/**
- * 22 所没有独立的工步层:工步与循环号只在采样行的 {@code Step_Index} / {@code Cycle_Index} 里,
- * 从采样推导工步是另一项决策(与"工步来自设备工步层"的既有口径冲突),不在本单范围,因此返回空。
+ * 交出该通道已结束的工步。来源没有设备侧工步层,所以按本来源自己的采样行分段推导(ADR-0067):
+ * 按「来源库 + 循环号 + 工步号」分段,只有最新一段视为进行中、不交出,其余各段各一条工步记录。
*/
@Override
public List<StepRecord> stepRecords(ChannelIdentity channel)
{
- return List.of();
+ Derivation derivation = derive(channel);
+ List<StepRecord> records = new ArrayList<>();
+ for (StepGroup group : derivation.closedSteps()) records.add(stepRecord(derivation.sourceRef(), group));
+ return List.copyOf(records);
}
- /** 同上:循环统计不从采样推导,本单不交出循环记录。 */
+ /**
+ * 交出该通道已结束的循环:来源没有循环汇总行,循环由已结束的工步按方向求和得出(ADR-0066 同一配方)。
+ * 最新一段所属的循环仍在进行,整体不交出——半个循环不是事实。
+ */
@Override
public List<CycleRecord> cycleRecords(ChannelIdentity channel)
{
- return List.of();
+ Derivation derivation = derive(channel);
+ Map<Integer, List<StepGroup>> byCycle = new LinkedHashMap<>();
+ for (StepGroup group : derivation.closedSteps())
+ {
+ // 仍在进行的那一段所属的循环整体排除:它自己的工步行还没写完。
+ if (group.key().cycleNo() == derivation.openCycleNo()) continue;
+ byCycle.computeIfAbsent(group.key().cycleNo(), ignored -> new ArrayList<>()).add(group);
+ }
+ List<CycleRecord> records = new ArrayList<>();
+ for (Map.Entry<Integer, List<StepGroup>> entry : byCycle.entrySet())
+ records.add(cycleRecord(derivation.sourceRef(), entry.getKey(), entry.getValue()));
+ return List.copyOf(records);
}
/** 返回推式来源固定的持久化槽位间隔;采样行按来源自己的落库节奏到达。 */
@@ -275,6 +298,16 @@
public long persistenceIntervalMillis(ChannelIdentity channel)
{
return PERSISTENCE_INTERVAL_MS;
+ }
+
+ /**
+ * 工步与循环的重算跟着来源的写库节奏,不跟实时行的展示节奏:来源最快每 60 秒才产出一行新采样,
+ * 而工步要等下一段出现才算结束,因此重算更勤只会空跑落地表(ADR-0067)。
+ */
+ @Override
+ public long aggregateIntervalMillis(ChannelIdentity channel)
+ {
+ return AGGREGATE_INTERVAL_MS;
}
/** 水位是落地表里该通道已存在的最新一行:设备绝对时间与协议序号。 */
@@ -350,7 +383,8 @@
private CabinetRealtime cabinetRealtime(TDeviceIngestRecord row)
{
- // 充放电容量按电流方向取对应的一组:与直连来源同一口径(22 所的电流符号约定待现场对着上位机确认)。
+ // 充放电容量按电流方向取对应的一组:与直连来源同一口径。电流符号的含义已用该来源的真实推送
+ // 数据核对(恒正即充电、恒负即放电,ADR-0067),不再是待确认的约定。
BigDecimal current = row.getCurrentA() == null ? BigDecimal.ZERO : row.getCurrentA();
boolean charging = current.signum() >= 0;
BigDecimal capacity = charging ? row.getChargeCapacityAh() : row.getDischargeCapacityAh();
@@ -365,6 +399,222 @@
"push:" + row.getRecordKey());
}
+ // ------------------------------------------------------------------ 工步与循环的推导(ADR-0067)
+
+ /** 分段推导的结果:来源库、已结束的各段,以及仍在进行的那一段所属的循环号。 */
+ private record Derivation(String sourceRef, List<StepGroup> closedSteps, int openCycleNo) { }
+
+ /** 一段采样行:同一个来源库里同一个「循环号 + 工步号」的全部行,按 (设备时间, 协议序号) 升序。 */
+ private record StepGroup(StepKey key, List<TDeviceIngestRecord> rows)
+ {
+ TDeviceIngestRecord first() { return rows.get(0); }
+
+ TDeviceIngestRecord last() { return rows.get(rows.size() - 1); }
+ }
+
+ private record StepKey(int cycleNo, int stepNo) { }
+
+ /**
+ * 该段本身的方向。来源没有设备侧工步类型列,方向由该段自己的电流符号判定——依据是用该来源真实推送的
+ * 采样数据核对过的列语义(588 段零反例,见 ADR-0067),不是按数值大小猜方向。
+ *
+ * <p>段内电流正负混用、电流缺失,或恒为零却有容量增长时,方向不唯一:保持未知,明细照原样保留。
+ */
+ private enum StepDirection
+ {
+ CHARGE("charge"), DISCHARGE("discharge"), REST("rest"), UNKNOWN(null);
+
+ private final String label;
+
+ StepDirection(String label) { this.label = label; }
+
+ /** 写进工步记录的类型:来源没给类型时交出的是判定出的方向名称,未知方向留空。 */
+ String label() { return label; }
+
+ /** 该工步自身累计的单项值。搁置段实测两向都不增长,所以它的累计是 0 而不是缺失。 */
+ BigDecimal own(BigDecimal charge, BigDecimal discharge)
+ {
+ return switch (this)
+ {
+ case CHARGE -> charge;
+ case DISCHARGE -> discharge;
+ case REST -> BigDecimal.ZERO;
+ case UNKNOWN -> null;
+ };
+ }
+ }
+
+ /**
+ * 从该通道的采样行推导「已结束的段」。只看该通道**最新的来源库**:来源库就是厂商侧一次测试
+ * (22 所每次测试新建一个库),读取量因此限于一次厂商测试,分段也不会跨测试。
+ *
+ * <p>段是否结束只按一条依据判断:设备是否已经写下**后续工步**的行(与 ADR-0066 同一判据)。
+ * 所以只有最新一段视为进行中;它所属的循环整体不交——半个循环不是事实。
+ */
+ private Derivation derive(ChannelIdentity channel)
+ {
+ TDeviceIngestRecord latest = latestSample(channel);
+ if (latest == null || latest.getSourceRef() == null) return new Derivation(null, List.of(), Integer.MIN_VALUE);
+ List<TDeviceIngestRecord> rows = records.selectSamplesOfSource(config.adapterCode(), latest.getDevtype(),
+ latest.getDevid(), latest.getSubdevid(), latest.getChlid(), latest.getSourceRef());
+ Map<StepKey, List<TDeviceIngestRecord>> grouped = new LinkedHashMap<>();
+ for (TDeviceIngestRecord row : rows == null ? List.<TDeviceIngestRecord>of() : rows)
+ {
+ // 分段依据是设备给出的循环号与工步号;缺任何一个都无法归属到某一段,跳过并留痕。
+ if (row.getCycleNo() == null || row.getStepNo() == null)
+ {
+ log.warn("落地行缺少循环号或工步号,不参与工步推导 ingestId={}", row.getIngestId());
+ continue;
+ }
+ grouped.computeIfAbsent(new StepKey(row.getCycleNo(), row.getStepNo()), ignored -> new ArrayList<>())
+ .add(row);
+ }
+ List<StepGroup> closed = new ArrayList<>();
+ int openCycleNo = Integer.MIN_VALUE;
+ int index = 0;
+ for (Map.Entry<StepKey, List<TDeviceIngestRecord>> entry : grouped.entrySet())
+ {
+ if (++index == grouped.size())
+ {
+ openCycleNo = entry.getKey().cycleNo();
+ break;
+ }
+ closed.add(new StepGroup(entry.getKey(), entry.getValue()));
+ }
+ return new Derivation(latest.getSourceRef(), List.copyOf(closed), openCycleNo);
+ }
+
+ /**
+ * 一条工步记录:容量与能量是**工步累计**(实测段内从未下降,末行即最大值),所以取该段末行的值;
+ * 工步时间取末行的工步内相对时间(秒)。设备没给的项不写零、不伪造中值电压。
+ */
+ private StepRecord stepRecord(String sourceRef, StepGroup group)
+ {
+ TDeviceIngestRecord first = group.first();
+ TDeviceIngestRecord last = group.last();
+ StepDirection direction = direction(group);
+ Map<String, BigDecimal> measurements = new LinkedHashMap<>();
+ putIfPresent(measurements, "stepDuration", last.getRelativeTimeSeconds());
+ putIfPresent(measurements, "startVoltage", first.getVoltageV());
+ // 四组容量能量原样保留设备事实:方向未判定的段也留着明细供核对。
+ putIfPresent(measurements, "chargeCapacity", last.getChargeCapacityAh());
+ putIfPresent(measurements, "dischargeCapacity", last.getDischargeCapacityAh());
+ putIfPresent(measurements, "chargeEnergy", last.getChargeEnergyWh());
+ putIfPresent(measurements, "dischargeEnergy", last.getDischargeEnergyWh());
+ BigDecimal capacity = direction.own(last.getChargeCapacityAh(), last.getDischargeCapacityAh());
+ BigDecimal energy = direction.own(last.getChargeEnergyWh(), last.getDischargeEnergyWh());
+ return new StepRecord(sourceRecordId(sourceRef, "step", group.key().cycleNo() + ":" + group.key().stepNo()),
+ group.key().cycleNo(), group.key().stepNo(), null, direction.label(), deviceTime(first),
+ deviceTime(last), decimal(last.getVoltageV()), decimal(last.getCurrentA()),
+ capacity == null ? null : capacity.doubleValue(), energy == null ? null : energy.doubleValue(),
+ last.getDeviceTimeRaw(), last.getTimezoneId(), receivedAt(last), measurements);
+ }
+
+ /**
+ * 一条循环记录:该循环各已结束工步取自己的累计值一次,按方向归入充电或放电一侧并**求和**
+ * (同一循环可能含多个同方向工步)。效率 = 放电容量 ÷ 充电容量,没有可用充电容量时留空,不写零。
+ * 搁置段两向都不增长、未知方向不按哪一列有值去猜,两者都不进任何一侧。
+ */
+ private CycleRecord cycleRecord(String sourceRef, int cycleNo, List<StepGroup> groups)
+ {
+ BigDecimal chargeCapacity = BigDecimal.ZERO, dischargeCapacity = BigDecimal.ZERO;
+ BigDecimal chargeEnergy = BigDecimal.ZERO, dischargeEnergy = BigDecimal.ZERO;
+ for (StepGroup group : groups)
+ {
+ TDeviceIngestRecord last = group.last();
+ switch (direction(group))
+ {
+ case CHARGE ->
+ {
+ chargeCapacity = chargeCapacity.add(orZero(last.getChargeCapacityAh()));
+ chargeEnergy = chargeEnergy.add(orZero(last.getChargeEnergyWh()));
+ }
+ case DISCHARGE ->
+ {
+ dischargeCapacity = dischargeCapacity.add(orZero(last.getDischargeCapacityAh()));
+ dischargeEnergy = dischargeEnergy.add(orZero(last.getDischargeEnergyWh()));
+ }
+ default -> { }
+ }
+ }
+ TDeviceIngestRecord first = groups.get(0).first();
+ TDeviceIngestRecord last = groups.get(groups.size() - 1).last();
+ return new CycleRecord(sourceRecordId(sourceRef, "cycle", String.valueOf(cycleNo)), cycleNo,
+ deviceTime(first), deviceTime(last), 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.getDeviceTimeRaw(), last.getTimezoneId(), receivedAt(last), CycleRecord.SOURCE_AGGREGATED);
+ }
+
+ /** 段内电流符号一致时给出该方向;正负混用或电流缺失保持未知。 */
+ private static StepDirection direction(StepGroup group)
+ {
+ BigDecimal maximumCharge = group.first().getChargeCapacityAh();
+ BigDecimal maximumDischarge = group.first().getDischargeCapacityAh();
+ boolean positive = false, negative = false;
+ for (TDeviceIngestRecord row : group.rows())
+ {
+ BigDecimal current = row.getCurrentA();
+ // 电流缺失的段判不出方向:不拿同段其它行的符号代替。
+ if (current == null) return StepDirection.UNKNOWN;
+ positive |= current.signum() > 0;
+ negative |= current.signum() < 0;
+ maximumCharge = maximum(maximumCharge, row.getChargeCapacityAh());
+ maximumDischarge = maximum(maximumDischarge, row.getDischargeCapacityAh());
+ }
+ if (positive && negative) return StepDirection.UNKNOWN;
+ if (positive) return StepDirection.CHARGE;
+ if (negative) return StepDirection.DISCHARGE;
+ // 恒为零:搁置。但恒零却出现容量增长时方向未能判定——不按哪一列在涨去猜。
+ boolean accumulated = raised(group.first().getChargeCapacityAh(), maximumCharge)
+ || raised(group.first().getDischargeCapacityAh(), maximumDischarge);
+ return accumulated ? StepDirection.UNKNOWN : StepDirection.REST;
+ }
+
+ /** 来源行身份:来源库 + 工步或循环标识。同一次测试重算多少次都得到同一个身份,重复交出因此幂等。 */
+ private static String sourceRecordId(String sourceRef, String kind, String suffix)
+ {
+ return "push:" + sourceRef + ":" + kind + ":" + suffix;
+ }
+
+ /** 只放设备给了的项:缺列就是没有这一项,不写零代替缺失。 */
+ private static void putIfPresent(Map<String, BigDecimal> target, String key, BigDecimal value)
+ {
+ if (value != null) target.put(key, value);
+ }
+
+ /** 求和时把缺列当没有:缺少的项不参与求和,也不写零占位。 */
+ private static BigDecimal orZero(BigDecimal value)
+ {
+ return value == null ? BigDecimal.ZERO : value;
+ }
+
+ private static BigDecimal maximum(BigDecimal current, BigDecimal candidate)
+ {
+ if (candidate == null) return current;
+ return current == null ? candidate : current.max(candidate);
+ }
+
+ private static boolean raised(BigDecimal first, BigDecimal maximum)
+ {
+ return first != null && maximum != null && maximum.compareTo(first) > 0;
+ }
+
+ private static Instant deviceTime(TDeviceIngestRecord row)
+ {
+ return row.getDeviceTimeUtc() == null ? null : row.getDeviceTimeUtc().toInstant();
+ }
+
+ /**
+ * 接收时间取**我们收到这一行**的时刻,与推式来源的实时路径同一口径,也是接收边界判定的依据;
+ * 直连来源没有这个独立取值,它沿用设备记录时刻(见 KNOWN-GAPS)。
+ */
+ private static Instant receivedAt(TDeviceIngestRecord row)
+ {
+ return row.getReceivedAtUtc() == null ? null : row.getReceivedAtUtc().toInstant();
+ }
+
private RealtimeSnapshot snapshot(ChannelIdentity channel, CabinetRealtime latest)
{
// 读落地表路线下通道工作状态不可观测(ADR-0063):不按数据行推断,也不沿用上一次状态。
--
Gitblit v1.7.1