实现新威 7.6 的工步与循环数据取数
数据列表「工步」页改由设备工步层 cap_step 取数,「循环」页由工步层按循环聚合。
聚合配方用现场真实数据核对后定稿:同一工步的行数不是常量(多数 2 行,另有 1 行与
3–10 行的少数协议),工步的累计容量与能量只出现在结束行上,且它是工步累计而不是测试
累计;因此每个已结束的工步取结束行一次,按方向归入本循环的充电或放电一侧并求和,效率
为放电容量除以充电容量,没有可用充电容量时留空。
循环统计的来源类别随记录落库(DEVICE / AGGREGATED / IMPORTED),推导值在数据里即可
与设备事实区分,界面与导出据此标注「工步层聚合」。工步方向按设备类型数字映射
(1 充电、2 放电、4 搁置),未确认取值保持未知且不参与聚合,四组容量能量原样保留在扩展
测量里供核对;中值电压改由设备工步层的 middle_vol 承载,不用峰值或谷值替代。仍在进行
的工步与循环不落库,暂停与继续不产生补算。
工步层读取按来源写库节奏限流,一次接收里工步与循环共用同一次读取,不跟着每秒的调度
节奏重查厂商库。
| | |
| | | import java.math.BigDecimal; |
| | | import java.util.Date; |
| | | |
| | | /** 检测柜直接返回的一条实际循环统计记录。 */ |
| | | /** 一条循环统计记录;设备没有循环汇总行时由工步层按循环聚合,来源类别随之保存。 */ |
| | | public class TTestCycleRecord implements Serializable |
| | | { |
| | | private Long cycleRecordId, testId, testBatteryId, bindingId; |
| | | private String adapterCode, sourceIdentity, sourceRecordId, adapterTimezoneId, dedupeKey, extensionMeasurementsJson; |
| | | private String adapterCode, sourceIdentity, sourceRecordId, sourceKind, adapterTimezoneId, dedupeKey, extensionMeasurementsJson; |
| | | private Date deviceTimeUtc, serverReceivedAtUtc; |
| | | private String deviceTimeRaw; |
| | | private Integer cycleNo; |
| | | private BigDecimal chargeCapacityAh, dischargeCapacityAh, chargeEnergyWh, dischargeEnergyWh, efficiency; |
| | | private byte[] contentSha256; |
| | | public Long getCycleRecordId(){return cycleRecordId;} public void setCycleRecordId(Long v){cycleRecordId=v;} public Long getTestId(){return testId;} public void setTestId(Long v){testId=v;} |
| | | public Long getCycleRecordId(){return cycleRecordId;} public void setCycleRecordId(Long v){cycleRecordId=v;} |
| | | public String getSourceKind(){return sourceKind;} public void setSourceKind(String v){sourceKind=v;} public Long getTestId(){return testId;} public void setTestId(Long v){testId=v;} |
| | | public Long getTestBatteryId(){return testBatteryId;} public void setTestBatteryId(Long v){testBatteryId=v;} public Long getBindingId(){return bindingId;} public void setBindingId(Long v){bindingId=v;} |
| | | public String getAdapterCode(){return adapterCode;} public void setAdapterCode(String v){adapterCode=v;} public String getSourceIdentity(){return sourceIdentity;} public void setSourceIdentity(String v){sourceIdentity=v;} |
| | | public String getSourceRecordId(){return sourceRecordId;} public void setSourceRecordId(String v){sourceRecordId=v;} public String getAdapterTimezoneId(){return adapterTimezoneId;} public void setAdapterTimezoneId(String v){adapterTimezoneId=v;} |
| | |
| | | import com.battery.business.domain.TTestDataRevisionAction; |
| | | import com.battery.business.mapper.TTestMapper; |
| | | import com.battery.business.service.ITestConsoleDataService; |
| | | import com.battery.device.api.TestDataObservationAdapter.CycleRecord; |
| | | import com.ruoyi.common.exception.ServiceException; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | |
| | | values.put("source_identity", String.join(":", List.of("adapter_code", "devtype", "devid", "subdevid", "chlid") |
| | | .stream().map(key -> String.valueOf(binding.get(key))).toList())); |
| | | values.put("source_record_id", importText(row, "recordNo", "记录编号", 128)); |
| | | // 导入的循环统计来自授权文件,不是设备提供的循环汇总行,也不是本系统按工步层聚合的值。 |
| | | if ("CYCLE".equals(type)) values.put("source_kind", CycleRecord.SOURCE_IMPORTED); |
| | | Date time; |
| | | try { |
| | | Object raw = row.get("recordedAt"); |
| | |
| | | if (extension == null) continue; |
| | | Map<String, Object> values = com.alibaba.fastjson2.JSON.parseObject(extension.toString()); |
| | | // 中值与历史峰谷值是不同测量,不将旧扩展字段回退映射为中值。 |
| | | for (String field : List.of("stepDuration", "startVoltage", "chargeMedianVoltage", "dischargeMedianVoltage")) |
| | | // 四组容量能量是设备工步层事实:单项列无法表达未确认方向时,调用方读这四组核对。 |
| | | for (String field : List.of("stepDuration", "startVoltage", "chargeMedianVoltage", "dischargeMedianVoltage", |
| | | "chargeCapacity", "dischargeCapacity", "chargeEnergy", "dischargeEnergy")) |
| | | if (values.containsKey(field)) row.put(field, values.get(field)); |
| | | } |
| | | return rows; |
| | | } |
| | | |
| | | /** 查询尚存循环记录,包含设备统计与授权历史导入。 */ |
| | | /** 查询尚存循环记录,包含设备统计、工步层聚合与授权历史导入;携带来源类别供界面标注聚合来源。 */ |
| | | @Override public List<Map<String, Object>> cycles(Long testId) |
| | | { |
| | | tests.selectTestById(testId); |
| | |
| | | private final Clock clock; |
| | | private final Map<String, Instant> lastSnapshotAt = new java.util.concurrent.ConcurrentHashMap<>(); |
| | | private final Map<Long, Instant> lastRealtimeCheckAt = new java.util.concurrent.ConcurrentHashMap<>(); |
| | | /** 每个测试电池上次读取工步与循环的时刻;读取节奏对齐来源写库周期,不跟着调度节奏。 */ |
| | | private final Map<String, Instant> lastAggregateAt = new java.util.concurrent.ConcurrentHashMap<>(); |
| | | /** 每个测试电池各自的接收水位;只存在内存中,重启后按来源里的最新一行重新取水位。 */ |
| | | private final Map<String, SampleCursor> sampleCursors = new java.util.concurrent.ConcurrentHashMap<>(); |
| | | |
| | |
| | | |
| | | private void saveDeviceAggregates(TTest test, TTestBattery battery) |
| | | { |
| | | // 工步、循环只接受设备直接返回的统计记录,并以来源编号或明确的时间组合执行幂等。 |
| | | // 工步、循环只接受来源返回的统计记录,并以来源编号或明确的时间组合执行幂等。 |
| | | if (adapter == null) return; TTestChannelBinding binding = battery.getBinding(); if (binding == null || binding.getDevid() == null || binding.getChlid() == null) return; |
| | | String key = test.getTestId() + ":" + battery.getTestBatteryId(); |
| | | // 接收调度每秒跑一次,而来源按自己的写库节奏产出工步:不按来源节奏限流会每秒重查厂商库。 |
| | | Instant now = clock.instant(); Instant previous = lastAggregateAt.get(key); |
| | | if (previous != null && now.isBefore(previous.plusMillis(persistenceInterval(binding)))) return; |
| | | lastAggregateAt.put(key, now); |
| | | ChannelIdentity channel = new ChannelIdentity(binding.getAdapterCode(), binding.getDevtype(), binding.getDevid(), binding.getSubdevid(), binding.getChlid()); |
| | | List<StepRecord> steps; List<CycleRecord> cycles; try { steps = adapter.stepRecords(channel); cycles = adapter.cycleRecords(channel); } catch (RuntimeException ex) { return; } |
| | | Date receivedAt = Date.from(clock.instant()); String identity = sourceIdentity(binding); |
| | | String identity = sourceIdentity(binding); |
| | | for (StepRecord source : steps == null ? List.<StepRecord>of() : steps) |
| | | { |
| | | if (!accepted(test, source.endedAt(), source.serverReceivedAt())) continue; |
| | | TTestStepRecord row = new TTestStepRecord(); row.setTestId(test.getTestId()); row.setTestBatteryId(battery.getTestBatteryId()); row.setBindingId(binding.getBindingId()); row.setAdapterCode(binding.getAdapterCode()); row.setSourceIdentity(identity); row.setSourceRecordId(blankToNull(source.sourceRecordId())); row.setAdapterTimezoneId(source.timezoneId()); row.setCycleNo(source.cycleNo()); row.setStepNo(source.stepNo()); row.setStepType(source.stepType()); row.setStepName(source.stepName()); row.setDeviceTimeUtc(Date.from(source.endedAt())); row.setDeviceTimeRaw(source.deviceTimeRaw()); row.setServerReceivedAtUtc(Date.from(source.serverReceivedAt())); row.setVoltageV(BigDecimal.valueOf(source.endVoltageV())); row.setCapacityAh(BigDecimal.valueOf(source.capacityAh())); row.setEnergyWh(BigDecimal.valueOf(source.energyWh())); row.setDedupeKey(row.getSourceRecordId() == null ? "fallback:" + source.endedAt() + ":" + source.cycleNo() + ":" + source.stepNo() : "record:" + row.getSourceRecordId()); row.setContentSha256(hash(row)); |
| | | TTestStepRecord row = new TTestStepRecord(); row.setTestId(test.getTestId()); row.setTestBatteryId(battery.getTestBatteryId()); row.setBindingId(binding.getBindingId()); row.setAdapterCode(binding.getAdapterCode()); row.setSourceIdentity(identity); row.setSourceRecordId(blankToNull(source.sourceRecordId())); row.setAdapterTimezoneId(source.timezoneId()); row.setCycleNo(source.cycleNo()); row.setStepNo(source.stepNo()); row.setStepType(source.stepType()); row.setStepName(source.stepName()); row.setDeviceTimeUtc(Date.from(source.endedAt())); row.setDeviceTimeRaw(source.deviceTimeRaw()); row.setServerReceivedAtUtc(Date.from(source.serverReceivedAt())); row.setVoltageV(BigDecimal.valueOf(source.endVoltageV())); row.setCurrentA(BigDecimal.valueOf(source.endCurrentA())); row.setCapacityAh(decimal(source.capacityAh())); row.setEnergyWh(decimal(source.energyWh())); row.setDedupeKey(row.getSourceRecordId() == null ? "fallback:" + source.endedAt() + ":" + source.cycleNo() + ":" + source.stepNo() : "record:" + row.getSourceRecordId()); row.setContentSha256(hash(row)); |
| | | // 物理删除依靠已有修订追溯识别来源,重复观测不能把已删除汇总重新写回。 |
| | | row.setExtensionMeasurementsJson(JSON.toJSONString(source.measurements())); |
| | | row.setContentSha256(hash(row)); |
| | |
| | | for (CycleRecord source : cycles == null ? List.<CycleRecord>of() : cycles) |
| | | { |
| | | if (!accepted(test, source.endedAt(), source.serverReceivedAt())) continue; |
| | | TTestCycleRecord row = new TTestCycleRecord(); row.setTestId(test.getTestId()); row.setTestBatteryId(battery.getTestBatteryId()); row.setBindingId(binding.getBindingId()); row.setAdapterCode(binding.getAdapterCode()); row.setSourceIdentity(identity); row.setAdapterTimezoneId(source.timezoneId()); row.setSourceRecordId(blankToNull(source.sourceRecordId())); row.setCycleNo(source.cycleNo()); row.setDeviceTimeUtc(Date.from(source.endedAt())); row.setDeviceTimeRaw(source.deviceTimeRaw()); row.setServerReceivedAtUtc(Date.from(source.serverReceivedAt())); row.setChargeCapacityAh(BigDecimal.valueOf(source.chargeCapacityAh())); row.setDischargeCapacityAh(BigDecimal.valueOf(source.dischargeCapacityAh())); row.setChargeEnergyWh(BigDecimal.valueOf(source.chargeEnergyWh())); row.setDischargeEnergyWh(BigDecimal.valueOf(source.dischargeEnergyWh())); row.setEfficiency(BigDecimal.valueOf(source.efficiency())); row.setDedupeKey(row.getSourceRecordId() == null ? "fallback:" + source.endedAt() + ":" + source.cycleNo() : "record:" + row.getSourceRecordId()); row.setContentSha256(hash(row)); |
| | | TTestCycleRecord row = new TTestCycleRecord(); row.setTestId(test.getTestId()); row.setTestBatteryId(battery.getTestBatteryId()); row.setBindingId(binding.getBindingId()); row.setAdapterCode(binding.getAdapterCode()); row.setSourceIdentity(identity); row.setAdapterTimezoneId(source.timezoneId()); row.setSourceRecordId(blankToNull(source.sourceRecordId())); row.setSourceKind(source.sourceKind()); row.setCycleNo(source.cycleNo()); row.setDeviceTimeUtc(Date.from(source.endedAt())); row.setDeviceTimeRaw(source.deviceTimeRaw()); row.setServerReceivedAtUtc(Date.from(source.serverReceivedAt())); row.setChargeCapacityAh(BigDecimal.valueOf(source.chargeCapacityAh())); row.setDischargeCapacityAh(BigDecimal.valueOf(source.dischargeCapacityAh())); row.setChargeEnergyWh(BigDecimal.valueOf(source.chargeEnergyWh())); row.setDischargeEnergyWh(BigDecimal.valueOf(source.dischargeEnergyWh())); row.setEfficiency(decimal(source.efficiency())); row.setDedupeKey(row.getSourceRecordId() == null ? "fallback:" + source.endedAt() + ":" + source.cycleNo() : "record:" + row.getSourceRecordId()); row.setContentSha256(hash(row)); |
| | | if (mapper.wasAggregateDeleted(test.getTestId(), "CYCLE", battery.getTestBatteryId(), identity, row.getDedupeKey())) continue; |
| | | if (mapper.insertCycleRecord(row) == 0) |
| | | { |
| | |
| | | private static String sourceIdentity(TTestChannelBinding b) { return String.join(":", String.valueOf(b.getAdapterCode()), String.valueOf(b.getDevtype()), String.valueOf(b.getDevid()), String.valueOf(b.getSubdevid()), String.valueOf(b.getChlid())); } |
| | | private static String timezone(TTestChannelBinding b) { return b.getAdapterTimezoneId() == null || b.getAdapterTimezoneId().isBlank() ? "Asia/Hong_Kong" : b.getAdapterTimezoneId(); } |
| | | private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value; } |
| | | /** 设备未提供的单项值保持为空:零是设备事实,缺失不能写成零。 */ |
| | | private static BigDecimal decimal(Double value) { return value == null ? null : BigDecimal.valueOf(value); } |
| | | // 实时提醒与持久化共用归属边界,避免同一旧观测只在首页触发异常。 |
| | | static boolean accepted(TTest test, Instant deviceTime, Instant serverReceivedAt) |
| | | { |
| | |
| | | </insert> |
| | | <insert id="insertCycleRecord" parameterType="com.battery.business.domain.TTestCycleRecord" useGeneratedKeys="true" keyProperty="cycleRecordId"> |
| | | insert ignore into t_test_cycle_record |
| | | (test_id, test_battery_id, binding_id, adapter_code, source_identity, source_record_id, dedupe_key, adapter_timezone_id, |
| | | (test_id, test_battery_id, binding_id, adapter_code, source_identity, source_record_id, source_kind, dedupe_key, adapter_timezone_id, |
| | | device_time_utc, device_time_raw, server_received_at_utc, cycle_no, charge_capacity_ah, discharge_capacity_ah, |
| | | charge_energy_wh, discharge_energy_wh, efficiency, extension_measurements_json, content_sha256) |
| | | values (#{testId}, #{testBatteryId}, #{bindingId}, #{adapterCode}, #{sourceIdentity}, #{sourceRecordId}, #{dedupeKey}, #{adapterTimezoneId}, |
| | | values (#{testId}, #{testBatteryId}, #{bindingId}, #{adapterCode}, #{sourceIdentity}, #{sourceRecordId}, #{sourceKind}, #{dedupeKey}, #{adapterTimezoneId}, |
| | | #{deviceTimeUtc}, #{deviceTimeRaw}, #{serverReceivedAtUtc}, #{cycleNo}, #{chargeCapacityAh}, #{dischargeCapacityAh}, |
| | | #{chargeEnergyWh}, #{dischargeEnergyWh}, #{efficiency}, cast(#{extensionMeasurementsJson} as json), #{contentSha256}) |
| | | </insert> |
| | |
| | | r.device_time_utc absoluteTime, r.server_received_at_utc serverReceivedAt, |
| | | r.charge_capacity_ah chargeCapacity, r.discharge_capacity_ah dischargeCapacity, |
| | | r.charge_energy_wh chargeEnergy, r.discharge_energy_wh dischargeEnergy, |
| | | r.efficiency efficiency, r.adapter_code adapterCode, r.source_identity sourceIdentity, |
| | | r.source_record_id sourceRecordId |
| | | r.efficiency efficiency, r.source_kind sourceKind, r.adapter_code adapterCode, |
| | | r.source_identity sourceIdentity, r.source_record_id sourceRecordId |
| | | from t_test_cycle_record r join t_test_battery b on b.test_battery_id = r.test_battery_id |
| | | where r.test_id = #{testId} and b.del_flag = 0 |
| | | order by r.cycle_no, b.battery_no, r.cycle_record_id |
| | |
| | | /** 返回设备直接提供的工步统计,不从实时采样推导。 */ |
| | | List<StepRecord> stepRecords(ChannelIdentity channel); |
| | | |
| | | /** 返回设备直接提供的循环统计,不补齐设备未返回的记录。 */ |
| | | /** |
| | | * 返回该来源的循环统计。设备直接提供循环汇总行时原样返回;设备只有工步层时由适配器按循环聚合, |
| | | * 并用 {@link CycleRecord#sourceKind()} 标注为聚合来源,使调用方与界面能区分推导值与设备事实。 |
| | | * 不补齐来源未提供的循环,也不跨循环借值。 |
| | | */ |
| | | List<CycleRecord> cycleRecords(ChannelIdentity channel); |
| | | |
| | | /** 返回适配器定义的实时持久化槽位间隔。 */ |
| | |
| | | record CollectorMeasurement(String channelCode, long sequenceId, Instant deviceTime, String deviceTimeRaw, |
| | | Instant serverReceivedAt, String metricCode, BigDecimal value, String unit) { } |
| | | |
| | | /** |
| | | * 一条设备工步层记录。{@code stepType} 保留设备给出的原始类型(新威为数字枚举), |
| | | * 方向由调用方按该类型解释,不由适配器按电流符号推断。 |
| | | * |
| | | * <p>{@code endVoltageV} 与 {@code endCurrentA} 是该工步结束时的设备读数。 |
| | | * {@code capacityAh} 与 {@code energyWh} 是该工步自身累计的单项值,只在方向已确认时给出; |
| | | * 方向未知时保持为空,四个方向的容量能量仍在 {@code measurements} 中作为设备事实保留。 |
| | | */ |
| | | record StepRecord(String sourceRecordId, int cycleNo, int stepNo, String stepName, String stepType, |
| | | Instant startedAt, Instant endedAt, double endVoltageV, double capacityAh, double energyWh, |
| | | String deviceTimeRaw, String timezoneId, Instant serverReceivedAt, |
| | | Instant startedAt, Instant endedAt, double endVoltageV, double endCurrentA, Double capacityAh, |
| | | Double energyWh, String deviceTimeRaw, String timezoneId, Instant serverReceivedAt, |
| | | java.util.Map<String, BigDecimal> measurements) { } |
| | | |
| | | /** |
| | | * 一条循环统计。{@code sourceRecordId} 是来源行的稳定身份,供幂等与冲突判定使用。 |
| | | * |
| | | * <p>设备没有循环汇总行时,循环统计由工步层按循环聚合得出;{@code sourceKind} 记录这个来源事实, |
| | | * 使界面与导出能明确标注"这个数是聚合的还是设备给的",取值只能是下面三个常量之一。 |
| | | * |
| | | * <p>{@code efficiency} 为单位为百分比的充放电效率。该循环没有可用的充电容量时保持为空: |
| | | * 未知比率不用零代替,零值只表示设备确实给出零。 |
| | | */ |
| | | record CycleRecord(String sourceRecordId, int cycleNo, Instant startedAt, Instant endedAt, |
| | | double chargeCapacityAh, double dischargeCapacityAh, double chargeEnergyWh, |
| | | double dischargeEnergyWh, double efficiency, String deviceTimeRaw, String timezoneId, |
| | | Instant serverReceivedAt) { } |
| | | double dischargeEnergyWh, Double efficiency, String deviceTimeRaw, String timezoneId, |
| | | Instant serverReceivedAt, String sourceKind) |
| | | { |
| | | /** 来源类别:设备直接提供。 */ |
| | | public static final String SOURCE_DEVICE = "DEVICE"; |
| | | /** 来源类别:来源没有循环汇总行,由工步层按循环聚合得出。 */ |
| | | public static final String SOURCE_AGGREGATED = "AGGREGATED"; |
| | | /** 来源类别:授权文件导入。 */ |
| | | public static final String SOURCE_IMPORTED = "IMPORTED"; |
| | | |
| | | public CycleRecord |
| | | { |
| | | // 未标注一律按设备事实:引入聚合之前写进库的循环记录都来自设备或文件。 |
| | | sourceKind = sourceKind == null || sourceKind.isBlank() ? SOURCE_DEVICE : sourceKind; |
| | | } |
| | | } |
| | | } |
| | |
| | | import javax.sql.DataSource; |
| | | import com.battery.device.api.DeviceAdapter; |
| | | import com.battery.device.api.DeviceAdapterException; |
| | | 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; |
| | |
| | | public static final String DEFAULT_PASSWORD_ENV = "BATTERY_VENDOR_DB_PASSWORD"; |
| | | /** 厂商实测写库周期;它同时是持久化周期,展示节奏不随之加快。 */ |
| | | public static final long SAMPLE_INTERVAL_MS = 60000L; |
| | | /** 工步层的共用读取窗口:足以覆盖一轮接收里工步与循环的连续调用,又不会压住新出现的工步行。 */ |
| | | private static final long CAP_STEP_CACHE_MS = 5000L; |
| | | private static final int SAMPLE_BATCH_LIMIT = 500; |
| | | private static final Pattern SAFE_TABLE_NAME = Pattern.compile("[A-Za-z0-9_]{1,64}"); |
| | | /** 设备时间按适配器时区解释;来源列是秒精度 datetime,小数部分按需接受。 */ |
| | |
| | | private final Runnable connectionReset; |
| | | private final Clock clock; |
| | | private final ZoneId deviceZone; |
| | | /** 工步类型到方向的映射:正向聚合循环容量时需要它区分充、放、搁置与未知。 */ |
| | | private final StepDirectionResolver directions = new NewareStepDirectionResolver(); |
| | | /** 最近数据时间最快也要一分钟才可能变化,缓存使发现清单不必每轮重读全部通道。 */ |
| | | private final long lastDataCacheMillis; |
| | | private final Map<String, CachedLastData> lastDataCache = new ConcurrentHashMap<>(); |
| | | private final Map<String, CachedCapSteps> capStepCache = new ConcurrentHashMap<>(); |
| | | |
| | | public NewareMysqlAdapter(AdapterInstanceConfig config, DataSource dataSource) |
| | | { |
| | |
| | | return null; |
| | | } |
| | | |
| | | /** 工步层记录由本单之后的一张单实现,这里不返回从采样推导出来的工步。 */ |
| | | /** 工步层记录与循环统计都读设备的 {@code cap_step} 表;该表不随采样分表滚动。 */ |
| | | @Override |
| | | public List<StepRecord> stepRecords(ChannelIdentity channel) |
| | | { |
| | | return List.of(); |
| | | CapStepSource source = capStepSource(channel); |
| | | if (source == null) return List.of(); |
| | | List<StepRecord> records = new ArrayList<>(); |
| | | for (StepGroup group : completedStepGroups(source.rows())) records.add(stepRecord(source.testId(), group)); |
| | | return List.copyOf(records); |
| | | } |
| | | |
| | | /** 循环汇总表不存在,聚合配方须先用真实数据核对,这里不返回推导值。 */ |
| | | /** |
| | | * 循环统计由工步层按循环聚合:设备没有循环汇总行(`cap_plat` 的充放电容量列实测全为 0, |
| | | * 库内也没有任何按循环汇总的表)。 |
| | | * |
| | | * <p>配方(行语义见 {@link #completedStepGroups}): |
| | | * <ul> |
| | | * <li>每个已结束工步的累计容量与能量各计一次,重复接收不重复累计。</li> |
| | | * <li>按方向归入本循环的充电或放电一侧并<b>求和</b>:同一循环可能含多个同方向工步 |
| | | * (该化成协议的前两个循环各有两个充电工步和两个放电工步),求和才是该循环充入/放出的总量。</li> |
| | | * <li>效率是放电容量除以充电容量;没有可用充电容量时留空,未知比率不写零。</li> |
| | | * <li>搁置与未知方向的工步不进任何一侧:容量无法归属到方向时,明细留在工步记录里供核对。</li> |
| | | * </ul> |
| | | * |
| | | * <p>不插值、不补齐、不跨循环借值:聚合只用该循环自己的工步行,仍在进行的循环整体不出现。 |
| | | */ |
| | | @Override |
| | | public List<CycleRecord> cycleRecords(ChannelIdentity channel) |
| | | { |
| | | return List.of(); |
| | | CapStepSource source = capStepSource(channel); |
| | | if (source == null) return List.of(); |
| | | List<CapStepRow> rows = source.rows(); |
| | | Map<Integer, List<StepGroup>> byCycle = new LinkedHashMap<>(); |
| | | for (StepGroup group : completedStepGroups(rows)) |
| | | byCycle.computeIfAbsent(group.key().cycle(), ignored -> new ArrayList<>()).add(group); |
| | | // 最后一行所属的循环仍在进行:它的工步行还没写完,聚合出来的是半个循环。 |
| | | int openCycle = rows.isEmpty() ? Integer.MIN_VALUE : rows.get(rows.size() - 1).cycle(); |
| | | List<CycleRecord> records = new ArrayList<>(); |
| | | for (Map.Entry<Integer, List<StepGroup>> entry : byCycle.entrySet()) |
| | | if (entry.getKey() != openCycle) records.add(cycleRecord(source.testId(), entry.getKey(), entry.getValue())); |
| | | return List.copyOf(records); |
| | | } |
| | | |
| | | /** 厂商固定 60 秒写库;持久化周期与之对齐,不做倍速。 */ |
| | |
| | | return result; |
| | | } |
| | | |
| | | // ------------------------------------------------------------------ 工步层与循环聚合 |
| | | |
| | | /** 读该通道最新测试在工步层的全部行;工步号与循环号都取自设备,不按采样分表推算。 */ |
| | | private CapStepSource capStepSource(ChannelIdentity channel) |
| | | { |
| | | ChannelKey key = channelKey(channel); |
| | | String cacheKey = key.devUid() + ":" + key.unitId() + ":" + key.chlId(); |
| | | Instant now = clock.instant(); |
| | | CachedCapSteps cached = capStepCache.get(cacheKey); |
| | | // 工步与循环在一次接收里被连续请求,同一次读取供两条路径共用,不重复查厂商库。 |
| | | if (cached != null && now.isBefore(cached.expiresAt())) return cached.source(); |
| | | VendorTest latest = latestTest(key); |
| | | if (latest == null) return null; |
| | | List<CapStepRow> rows = query("select seq_id, cycle, step_id, step_type, test_time, test_vol, test_cur, " |
| | | + "test_capchg, test_engchg, test_capdchg, test_engdchg, middle_vol, test_atime from cap_step " |
| | | + "where test_id = ? and dev_uid = ? and unit_id = ? and chl_id = ? order by seq_id", |
| | | this::readCapStepRows, latest.testId(), key.devUid(), key.unitId(), key.chlId()); |
| | | CapStepSource source = new CapStepSource(latest.testId(), rows); |
| | | capStepCache.put(cacheKey, new CachedCapSteps(source, now.plusMillis(CAP_STEP_CACHE_MS))); |
| | | return source; |
| | | } |
| | | |
| | | private List<CapStepRow> readCapStepRows(ResultSet rows) throws SQLException |
| | | { |
| | | List<CapStepRow> result = new ArrayList<>(); |
| | | while (rows.next()) |
| | | { |
| | | String raw = rows.getString("test_atime"); |
| | | LocalDateTime deviceTime = parseDeviceTime(raw); |
| | | result.add(new CapStepRow(rows.getLong("seq_id"), rows.getInt("cycle"), rows.getInt("step_id"), |
| | | rows.getString("step_type"), rows.getLong("test_time"), rows.getBigDecimal("test_vol"), |
| | | rows.getBigDecimal("test_cur"), rows.getBigDecimal("test_capchg"), |
| | | rows.getBigDecimal("test_engchg"), rows.getBigDecimal("test_capdchg"), |
| | | rows.getBigDecimal("test_engdchg"), rows.getBigDecimal("middle_vol"), raw, |
| | | deviceTime.atZone(deviceZone).toInstant())); |
| | | } |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 已结束的工步:按「循环号 + 工步号」分组,每组取设备写的最后一行作为该工步的累计事实。 |
| | | * |
| | | * <p>实测行语义:同一工步的行数不是常量(多数为进入行加结束行的 2 行,另有 1 行与 3–10 行的少数协议), |
| | | * 工步的累计容量与能量只出现在结束行上(进入行全为 0,结束行等于该工步的最大值), |
| | | * 且它是**工步累计**而不是测试累计——同一工步在不同循环的值基本相同,不随测试推进累加。 |
| | | * 因此取最后一行,不按行求和、不取平均。 |
| | | * |
| | | * <p>工步是否结束只按一条依据判断:设备是否已经写了<b>后续工步</b>的行。只有进入行的工步仍在进行中, |
| | | * 不落库——否则它结束后的结束行会与已保存的行冲突。代价是设备停止后最后一个未闭合的工步不出现, |
| | | * 这是"不把未确认的事实当事实"的落地形态,也保证暂停或继续不产生补算。 |
| | | */ |
| | | private List<StepGroup> completedStepGroups(List<CapStepRow> rows) |
| | | { |
| | | Map<StepKey, List<CapStepRow>> groups = new LinkedHashMap<>(); |
| | | for (CapStepRow row : rows) |
| | | groups.computeIfAbsent(new StepKey(row.cycle(), row.stepId()), ignored -> new ArrayList<>()).add(row); |
| | | List<StepGroup> completed = new ArrayList<>(); |
| | | int last = groups.size() - 1; |
| | | int index = 0; |
| | | for (Map.Entry<StepKey, List<CapStepRow>> entry : groups.entrySet()) |
| | | if (index++ < last) completed.add(new StepGroup(entry.getKey(), entry.getValue())); |
| | | return completed; |
| | | } |
| | | |
| | | /** |
| | | * 一条工步记录。工步时间取设备给的 {@code test_time}(毫秒 → 秒),四组容量能量与中值电压 |
| | | * 放进扩展测量;单项容量只在方向已确认时给出。 |
| | | */ |
| | | private StepRecord stepRecord(long testId, StepGroup group) |
| | | { |
| | | CapStepRow first = group.rows().get(0); |
| | | CapStepRow last = group.last(); |
| | | StepDirectionResolver.Direction direction = directions.resolve(last.stepType()); |
| | | Map<String, BigDecimal> measurements = new LinkedHashMap<>(); |
| | | measurements.put("stepDuration", BigDecimal.valueOf(last.testTimeMillis()).movePointLeft(3)); |
| | | measurements.put("startVoltage", scaledValue(first.voltage(), VOLTAGE_CURRENT_DIVISOR)); |
| | | // 中值电压按 ADR-0054 的口径改由设备工步层字段承载,不用峰值或谷值替代;设备给零就保留零。 |
| | | BigDecimal medianVoltage = scaledValue(last.middleVoltage(), VOLTAGE_CURRENT_DIVISOR); |
| | | if (direction == StepDirectionResolver.Direction.CHARGE) measurements.put("chargeMedianVoltage", medianVoltage); |
| | | if (direction == StepDirectionResolver.Direction.DISCHARGE) measurements.put("dischargeMedianVoltage", medianVoltage); |
| | | // 四组容量能量原样保留设备事实:未知方向的工步也留着明细供核对。 |
| | | measurements.put("chargeCapacity", scaledValue(last.capacityCharge(), CAPACITY_ENERGY_DIVISOR)); |
| | | measurements.put("dischargeCapacity", scaledValue(last.capacityDischarge(), CAPACITY_ENERGY_DIVISOR)); |
| | | measurements.put("chargeEnergy", scaledValue(last.energyCharge(), CAPACITY_ENERGY_DIVISOR)); |
| | | measurements.put("dischargeEnergy", scaledValue(last.energyDischarge(), CAPACITY_ENERGY_DIVISOR)); |
| | | // 单项容量与能量只在方向已确认时给值:未知方向留空,不按数值大小猜它属于哪一组。 |
| | | BigDecimal capacity = null, energy = null; |
| | | if (direction == StepDirectionResolver.Direction.CHARGE) |
| | | { |
| | | capacity = last.capacityCharge(); energy = last.energyCharge(); |
| | | } |
| | | else if (direction == StepDirectionResolver.Direction.DISCHARGE) |
| | | { |
| | | capacity = last.capacityDischarge(); energy = last.energyDischarge(); |
| | | } |
| | | else if (direction == StepDirectionResolver.Direction.REST) |
| | | { |
| | | // 搁置工步设备两向都为 0,取充电侧的 0 承载,使单项列与充放电工步同列可比。 |
| | | capacity = last.capacityCharge(); energy = last.energyCharge(); |
| | | } |
| | | // 服务器接收时间取设备写结束行的时刻:接收边界据它在暂停与继续之间留白,不把暂停期的工步补回来。 |
| | | Double capacityAh = capacity == null ? null : scaled(capacity, CAPACITY_ENERGY_DIVISOR); |
| | | Double energyWh = energy == null ? null : scaled(energy, CAPACITY_ENERGY_DIVISOR); |
| | | return new StepRecord(sourceRecordId(testId, "step", group.key().cycle() + ":" + group.key().stepId()), |
| | | group.key().cycle(), group.key().stepId(), null, last.stepType(), first.deviceTime(), |
| | | last.deviceTime(), scaled(last.voltage(), VOLTAGE_CURRENT_DIVISOR), |
| | | scaled(last.current(), VOLTAGE_CURRENT_DIVISOR), capacityAh, energyWh, |
| | | last.deviceTimeRaw(), deviceZone.getId(), last.deviceTime(), measurements); |
| | | } |
| | | |
| | | private CycleRecord cycleRecord(long testId, int cycleNo, List<StepGroup> groups) |
| | | { |
| | | BigDecimal chargeCapacity = BigDecimal.ZERO, dischargeCapacity = BigDecimal.ZERO; |
| | | BigDecimal chargeEnergy = BigDecimal.ZERO, dischargeEnergy = BigDecimal.ZERO; |
| | | for (StepGroup group : groups) |
| | | { |
| | | CapStepRow last = group.last(); |
| | | switch (directions.resolve(last.stepType())) |
| | | { |
| | | case CHARGE -> |
| | | { |
| | | chargeCapacity = chargeCapacity.add(scaledValue(last.capacityCharge(), CAPACITY_ENERGY_DIVISOR)); |
| | | chargeEnergy = chargeEnergy.add(scaledValue(last.energyCharge(), CAPACITY_ENERGY_DIVISOR)); |
| | | } |
| | | case DISCHARGE -> |
| | | { |
| | | dischargeCapacity = dischargeCapacity.add(scaledValue(last.capacityDischarge(), CAPACITY_ENERGY_DIVISOR)); |
| | | dischargeEnergy = dischargeEnergy.add(scaledValue(last.energyDischarge(), CAPACITY_ENERGY_DIVISOR)); |
| | | } |
| | | // 搁置与未知方向不进任何一侧:方向未确认时把容量算进某一侧就是凭数值猜方向。 |
| | | default -> { } |
| | | } |
| | | } |
| | | CapStepRow last = groups.get(groups.size() - 1).last(); |
| | | return new CycleRecord(sourceRecordId(testId, "cycle", String.valueOf(cycleNo)), cycleNo, |
| | | groups.get(0).rows().get(0).deviceTime(), last.deviceTime(), 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(), deviceZone.getId(), last.deviceTime(), CycleRecord.SOURCE_AGGREGATED); |
| | | } |
| | | |
| | | /** 来源行身份:厂商测试编号 + 工步或循环标识;重复接收据此幂等。 */ |
| | | private static String sourceRecordId(long testId, String kind, String suffix) |
| | | { |
| | | return "neware:" + testId + ":" + kind + ":" + suffix; |
| | | } |
| | | |
| | | private CabinetRealtime cabinetRealtime(SampleRow row) |
| | | { |
| | | // 充放电容量按电流方向取对应的一组:设备界面对正在放电的通道显示放电容量。 |
| | |
| | | |
| | | private static double scaled(BigDecimal value, BigDecimal divisor) |
| | | { |
| | | if (value == null) return 0d; |
| | | // 保留 12 位小数与业务列精度一致,避免在换算环节提前丢失设备原始分辨率。 |
| | | return value.divide(divisor, 12, RoundingMode.HALF_UP).doubleValue(); |
| | | return scaledValue(value, divisor).doubleValue(); |
| | | } |
| | | |
| | | /** 保留 12 位小数与业务列精度一致,避免在换算环节提前丢失设备原始分辨率。 */ |
| | | private static BigDecimal scaledValue(BigDecimal value, BigDecimal divisor) |
| | | { |
| | | if (value == null) return BigDecimal.ZERO; |
| | | return value.divide(divisor, 12, RoundingMode.HALF_UP); |
| | | } |
| | | |
| | | private ChannelIdentity identity(ChannelKey key) |
| | |
| | | |
| | | private record CachedLastData(Instant lastDataAt, Instant expiresAt) { } |
| | | |
| | | private record CachedCapSteps(CapStepSource source, Instant expiresAt) { } |
| | | |
| | | /** 该通道最新厂商测试在工步层的全部行;{@code testId} 同时用于来源行身份。 */ |
| | | private record CapStepSource(long testId, List<CapStepRow> rows) { } |
| | | |
| | | /** 工步号在同一个循环内唯一,所以「循环号 + 工步号」就是一条工步事实的身份。 */ |
| | | private record StepKey(int cycle, int stepId) { } |
| | | |
| | | private record StepGroup(StepKey key, List<CapStepRow> rows) |
| | | { |
| | | CapStepRow last() { return rows.get(rows.size() - 1); } |
| | | } |
| | | |
| | | private record CapStepRow(long sequenceId, int cycle, int stepId, String stepType, long testTimeMillis, |
| | | BigDecimal voltage, BigDecimal current, BigDecimal capacityCharge, BigDecimal energyCharge, |
| | | BigDecimal capacityDischarge, BigDecimal energyDischarge, BigDecimal middleVoltage, |
| | | String deviceTimeRaw, Instant deviceTime) { } |
| | | |
| | | private record SampleRow(long testId, long sequenceId, int cycle, int stepId, String stepType, |
| | | Long testTimeMillis, BigDecimal voltage, BigDecimal current, BigDecimal capacityCharge, |
| | | BigDecimal energyCharge, BigDecimal capacityDischarge, BigDecimal energyDischarge, |
| New file |
| | |
| | | package com.battery.device.neware; |
| | | |
| | | import com.battery.device.api.StepDirectionResolver; |
| | | |
| | | /** |
| | | * 新威工步层 {@code cap_step.step_type} 的数值枚举解释:{@code 1} 充电、{@code 2} 放电、{@code 4} 搁置。 |
| | | * |
| | | * <p>其余取值(实测出现过 {@code 3}、{@code 5}、{@code 7}、{@code 13})语义未确认,保持未知: |
| | | * 不按电流符号、也不按容量出现在哪一组去猜方向,明细照原样保存供核对。 |
| | | */ |
| | | public final class NewareStepDirectionResolver implements StepDirectionResolver |
| | | { |
| | | @Override |
| | | public Direction resolve(String stepType) |
| | | { |
| | | if (stepType == null) return Direction.UNKNOWN; |
| | | return switch (stepType.trim()) |
| | | { |
| | | case "1" -> Direction.CHARGE; |
| | | case "2" -> Direction.DISCHARGE; |
| | | case "4" -> Direction.REST; |
| | | default -> Direction.UNKNOWN; |
| | | }; |
| | | } |
| | | } |
| | |
| | | String stepType, long testTimeMillis, int voltage, int current, int capacityCharge, int energyCharge, |
| | | int capacityDischarge, int energyDischarge, String testAtime) { } |
| | | |
| | | /** 工步层一行:工步层不随采样分表滚动,所以没有表名。 */ |
| | | record CapStepRow(long testId, long devUid, int unitId, int chlId, long seqId, int cycle, int stepId, |
| | | String stepType, long testTimeMillis, int voltage, int current, int capacityCharge, int energyCharge, |
| | | int capacityDischarge, int energyDischarge, int middleVoltage, String testAtime) { } |
| | | |
| | | private final List<String> statements = new java.util.concurrent.CopyOnWriteArrayList<>(); |
| | | private final List<ChannelRow> channels = new ArrayList<>(); |
| | | private final List<TestRow> tests = new ArrayList<>(); |
| | | private final List<SampleRow> samples = new ArrayList<>(); |
| | | private final List<CapStepRow> capSteps = new ArrayList<>(); |
| | | |
| | | FakeVendorDatabase registeredChannel(long devUid, int unitId, int chlId) |
| | | { |
| | |
| | | FakeVendorDatabase sample(SampleRow row) |
| | | { |
| | | samples.add(row); |
| | | return this; |
| | | } |
| | | |
| | | FakeVendorDatabase capStep(CapStepRow row) |
| | | { |
| | | capSteps.add(row); |
| | | return this; |
| | | } |
| | | |
| | |
| | | return latestTests(longValue(parameters, 0)); |
| | | if (normalized.contains("from test")) |
| | | return latestTestForChannel(longValue(parameters, 0), intValue(parameters, 1), intValue(parameters, 2)); |
| | | if (normalized.contains("from cap_step")) |
| | | return capStepRows(longValue(parameters, 0), longValue(parameters, 1), intValue(parameters, 2), |
| | | intValue(parameters, 3)); |
| | | if (normalized.contains("order by test_atime, seq_id limit")) |
| | | return samplesAfter(tableOf(sql), parameters); |
| | | return lastSampleOfTest(tableOf(sql), intValue(parameters, 0), intValue(parameters, 1), |
| | |
| | | .map(this::sampleColumns).toList(); |
| | | } |
| | | |
| | | private List<Map<String, Object>> capStepRows(long testId, long devUid, int unitId, int chlId) |
| | | { |
| | | return capSteps.stream().filter(row -> row.testId() == testId && row.devUid() == devUid |
| | | && row.unitId() == unitId && row.chlId() == chlId) |
| | | .sorted(Comparator.comparingLong(CapStepRow::seqId)).map(this::capStepColumns).toList(); |
| | | } |
| | | |
| | | private Map<String, Object> capStepColumns(CapStepRow row) |
| | | { |
| | | Map<String, Object> value = new LinkedHashMap<>(); |
| | | value.put("seq_id", row.seqId()); |
| | | value.put("cycle", row.cycle()); |
| | | value.put("step_id", row.stepId()); |
| | | value.put("step_type", row.stepType()); |
| | | value.put("test_time", row.testTimeMillis()); |
| | | value.put("test_vol", row.voltage()); |
| | | value.put("test_cur", row.current()); |
| | | value.put("test_capchg", row.capacityCharge()); |
| | | value.put("test_engchg", row.energyCharge()); |
| | | value.put("test_capdchg", row.capacityDischarge()); |
| | | value.put("test_engdchg", row.energyDischarge()); |
| | | value.put("middle_vol", row.middleVoltage()); |
| | | value.put("test_atime", row.testAtime()); |
| | | return value; |
| | | } |
| | | |
| | | private static boolean after(String atime, long seqId, String watermark, long watermarkSeq) |
| | | { |
| | | int time = atime.compareTo(watermark); |
| | |
| | | import java.util.Set; |
| | | import org.junit.jupiter.api.Test; |
| | | import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime; |
| | | import com.battery.device.api.TestDataObservationAdapter.CycleRecord; |
| | | import com.battery.device.api.TestDataObservationAdapter.SampleWatermark; |
| | | import com.battery.device.api.TestDataObservationAdapter.StepRecord; |
| | | import com.battery.device.api.model.AdapterCapabilities; |
| | | import com.battery.device.api.model.ChannelIdentity; |
| | | import com.battery.device.api.model.DetectedChannel; |
| | |
| | | .allMatch(item -> ADAPTER_CODE.equals(item.identity().adapterCode()))); |
| | | } |
| | | |
| | | // ---------------------------------------------------------------- 工步层与循环聚合 |
| | | |
| | | /** |
| | | * 工步记录来自设备工步层:每工步一条,取该工步的结束行;四组容量能量与中值电压放在扩展测量里, |
| | | * 中值电压由设备字段承载,不用峰值或谷值替代。 |
| | | */ |
| | | @Test |
| | | void stepRecordsComeFromTheDeviceStepLayerAndCarryAllFourCapacityGroups() |
| | | { |
| | | List<StepRecord> steps = adapter(stepLayerDatabase()).stepRecords(CHANNEL); |
| | | // 只有进入行的工步仍在进行中,不出现:设备还没写后续工步的行。 |
| | | assertEquals(9, steps.size()); |
| | | assertTrue(steps.stream().noneMatch(step -> step.cycleNo() == 2)); |
| | | |
| | | StepRecord rest = step(steps, 0, 1); |
| | | assertEquals("4", rest.stepType()); |
| | | assertNull(rest.stepName()); |
| | | assertEquals(Instant.parse("2026-09-28T01:00:00Z"), rest.startedAt()); |
| | | assertEquals(Instant.parse("2026-09-28T01:00:38Z"), rest.endedAt()); |
| | | assertEquals(1.3588, rest.endVoltageV(), 1e-9); |
| | | // 工步记录同时带结束时的电流读数。 |
| | | assertEquals(0d, rest.endCurrentA(), 1e-9); |
| | | assertEquals(-1.1889, step(steps, 1, 11).endCurrentA(), 1e-9); |
| | | // 工步时间是设备给的 test_time(毫秒 → 秒),不是按采样时间推算的。 |
| | | assertEquals(0, new java.math.BigDecimal("38.800").compareTo(rest.measurements().get("stepDuration"))); |
| | | assertEquals(0, new java.math.BigDecimal("1.358800000000").compareTo(rest.measurements().get("startVoltage"))); |
| | | |
| | | StepRecord charge = step(steps, 0, 4); |
| | | assertEquals("1", charge.stepType()); |
| | | // 单项容量只在方向已确认时给出:充电工步取充电侧。 |
| | | assertEquals(119277040d / 36000000d, charge.capacityAh(), 1e-9); |
| | | assertEquals(195220576d / 36000000d, charge.energyWh(), 1e-9); |
| | | // 充电中值电压直接取设备工步层的 middle_vol,设备给零就保留零。 |
| | | assertEquals(0, java.math.BigDecimal.ZERO.compareTo(charge.measurements().get("chargeMedianVoltage"))); |
| | | assertNull(charge.measurements().get("dischargeMedianVoltage")); |
| | | |
| | | StepRecord discharge = step(steps, 0, 6); |
| | | assertEquals("2", discharge.stepType()); |
| | | assertEquals(68462032d / 36000000d, discharge.capacityAh(), 1e-9); |
| | | assertTrue(discharge.measurements().containsKey("dischargeMedianVoltage")); |
| | | // 四组容量能量都是设备事实:每一组都保留,不只保留方向匹配的那一组。 |
| | | for (String field : List.of("chargeCapacity", "dischargeCapacity", "chargeEnergy", "dischargeEnergy")) |
| | | assertTrue(charge.measurements().containsKey(field) && discharge.measurements().containsKey(field)); |
| | | assertEquals("neware:440:step:0:4", charge.sourceRecordId()); |
| | | assertEquals("Asia/Hong_Kong", charge.timezoneId()); |
| | | assertEquals(charge.endedAt(), charge.serverReceivedAt()); |
| | | } |
| | | |
| | | /** 未知工步类型保留明细并保持未知:不按电流符号,也不按容量出现在哪一组去猜方向。 */ |
| | | @Test |
| | | void unknownStepTypeKeepsItsDetailAndStaysOutOfTheCycleAggregate() |
| | | { |
| | | List<StepRecord> steps = adapter(stepLayerDatabase()).stepRecords(CHANNEL); |
| | | StepRecord unknown = step(steps, 1, 3); |
| | | assertEquals("7", unknown.stepType()); |
| | | // 单项容量留空:方向未确认时不给出可归属的单值,但四组明细照原样保留供核对。 |
| | | assertNull(unknown.capacityAh()); |
| | | assertNull(unknown.energyWh()); |
| | | assertEquals(500000000d / 36000000d, unknown.measurements().get("chargeCapacity").doubleValue(), 1e-9); |
| | | |
| | | CycleRecord cycle = cycle(adapter(stepLayerDatabase()).cycleRecords(CHANNEL), 1); |
| | | // 未知方向的那一步容量没有进任何一侧:本循环的充放电仍只由已知方向的工步构成。 |
| | | assertEquals((119267664d + 85510512d) / 36000000d, cycle.chargeCapacityAh(), 1e-9); |
| | | assertEquals((67587520d + 47559464d) / 36000000d, cycle.dischargeCapacityAh(), 1e-9); |
| | | } |
| | | |
| | | /** |
| | | * 循环统计由工步层按循环聚合并标注为聚合来源:同一循环内同方向的工步容量求和 |
| | | * (实测该化成协议 cycle 1 有两个充电工步与两个放电工步),效率是放电容量除以充电容量。 |
| | | */ |
| | | @Test |
| | | void cycleRecordsAreAggregatedFromTheStepLayerAndMarkedAsAggregated() |
| | | { |
| | | List<CycleRecord> cycles = adapter(stepLayerDatabase()).cycleRecords(CHANNEL); |
| | | // 仍在进行的循环整体不出现:工步行还没写完,聚合出来的是半个循环。 |
| | | assertEquals(2, cycles.size()); |
| | | |
| | | CycleRecord first = cycle(cycles, 0); |
| | | assertEquals(119277040d / 36000000d, first.chargeCapacityAh(), 1e-9); |
| | | // 两个放电工步求和:16219380 + 68462032。 |
| | | assertEquals(84681412d / 36000000d, first.dischargeCapacityAh(), 1e-9); |
| | | assertEquals(195220576d / 36000000d, first.chargeEnergyWh(), 1e-9); |
| | | assertEquals(40000000d / 36000000d, first.dischargeEnergyWh(), 1e-9); |
| | | assertEquals(70.9956, first.efficiency(), 1e-3); |
| | | // 来源类别是聚合:界面与导出据此标注"这个数是推导出来的"。 |
| | | assertEquals(CycleRecord.SOURCE_AGGREGATED, first.sourceKind()); |
| | | assertEquals("neware:440:cycle:0", first.sourceRecordId()); |
| | | // 循环的起止取该循环首末工步的设备时间,服务器接收时间同样取设备时刻,暂停与继续据此留白。 |
| | | assertEquals(Instant.parse("2026-09-28T01:00:00Z"), first.startedAt()); |
| | | assertEquals(Instant.parse("2026-09-28T14:04:47Z"), first.endedAt()); |
| | | assertEquals(first.endedAt(), first.serverReceivedAt()); |
| | | |
| | | CycleRecord second = cycle(cycles, 1); |
| | | assertEquals((119267664d + 85510512d) / 36000000d, second.chargeCapacityAh(), 1e-9); |
| | | assertEquals((67587520d + 47559464d) / 36000000d, second.dischargeCapacityAh(), 1e-9); |
| | | assertEquals(56.2301, second.efficiency(), 1e-3); |
| | | } |
| | | |
| | | /** 循环没有可用充电容量时效率留空:未知比率不写成零。 */ |
| | | @Test |
| | | void cycleEfficiencyStaysEmptyWhenThereIsNoChargeCapacity() |
| | | { |
| | | FakeVendorDatabase database = new FakeVendorDatabase() |
| | | .registeredChannel(DEV_UID, 1, 5) |
| | | .test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 5, 440L, "2026-09-28 09:00:00", ACTIVE_TABLE, null)) |
| | | .capStep(new FakeVendorDatabase.CapStepRow(440L, DEV_UID, 1, 5, 1L, 9, 1, "2", 3600000L, |
| | | 13000, -5000, 0, 0, 50000000, 9000000, 0, "2026-09-28 12:00:00")) |
| | | .capStep(new FakeVendorDatabase.CapStepRow(440L, DEV_UID, 1, 5, 2L, 10, 1, "4", 60000L, |
| | | 13000, 0, 0, 0, 0, 0, 0, "2026-09-28 12:01:00")); |
| | | List<CycleRecord> cycles = adapter(database) |
| | | .cycleRecords(new ChannelIdentity(ADAPTER_CODE, 21, 36, 1, 5)); |
| | | assertEquals(1, cycles.size()); |
| | | assertEquals(0d, cycles.get(0).chargeCapacityAh(), 1e-9); |
| | | assertNull(cycles.get(0).efficiency()); |
| | | } |
| | | |
| | | /** 没有测试记录、没有工步行或通道属于别实例时,工步与循环都保持为空而不报错。 */ |
| | | @Test |
| | | void stepAndCycleRecordsStayEmptyWithoutAStepLayerOrTest() |
| | | { |
| | | NewareMysqlAdapter withoutTest = adapter(new FakeVendorDatabase().registeredChannel(DEV_UID, 1, 2)); |
| | | assertTrue(withoutTest.stepRecords(CHANNEL).isEmpty()); |
| | | assertTrue(withoutTest.cycleRecords(CHANNEL).isEmpty()); |
| | | assertTrue(adapter(activeDeviceDatabase()).stepRecords(CHANNEL).isEmpty()); |
| | | org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, |
| | | () -> adapter(activeDeviceDatabase()).cycleRecords(new ChannelIdentity("other-source", 21, 36, 1, 2))); |
| | | } |
| | | |
| | | private static StepRecord step(List<StepRecord> steps, int cycleNo, int stepNo) |
| | | { |
| | | return steps.stream().filter(item -> item.cycleNo() == cycleNo && item.stepNo() == stepNo) |
| | | .findFirst().orElseThrow(); |
| | | } |
| | | |
| | | private static CycleRecord cycle(List<CycleRecord> cycles, int cycleNo) |
| | | { |
| | | return cycles.stream().filter(item -> item.cycleNo() == cycleNo).findFirst().orElseThrow(); |
| | | } |
| | | |
| | | /** |
| | | * 工步层假厂商库:cycle 0 走完搁置、放电、充电、放电,cycle 1 有两个充电工步、两个放电工步 |
| | | * 与一个未知类型工步;末组(cycle 2 的 step 9)只有进入行——它仍在进行中。 |
| | | */ |
| | | private FakeVendorDatabase stepLayerDatabase() |
| | | { |
| | | return new FakeVendorDatabase() |
| | | .registeredChannel(DEV_UID, 1, 2) |
| | | .test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 2, 440L, "2026-09-28 09:00:00", |
| | | ACTIVE_TABLE, null)) |
| | | // cycle 0:搁置、放电、充电、放电;结束行承载该工步累计容量与能量。 |
| | | .capStep(step(1L, 0, 1, "4", 0L, 13588, 0, 0, 0, 0, 0, "2026-09-28 09:00:00")) |
| | | .capStep(step(2L, 0, 1, "4", 38800L, 13588, 0, 0, 0, 0, 0, "2026-09-28 09:00:38")) |
| | | .capStep(step(3L, 0, 2, "2", 0L, 13222, -4735, 0, 0, 0, 0, "2026-09-28 09:00:38")) |
| | | .capStep(step(4L, 0, 2, "2", 3425000L, 9998, -4736, 0, 0, 16219380, 10000000, |
| | | "2026-09-28 10:04:37")) |
| | | .capStep(step(5L, 0, 4, "1", 0L, 13507, 4732, 0, 0, 0, 0, "2026-09-28 11:03:51")) |
| | | .capStep(step(6L, 0, 4, "1", 25200000L, 17085, 4733, 119277040, 195220576, 0, 0, |
| | | "2026-09-28 18:03:51")) |
| | | .capStep(step(7L, 0, 6, "2", 0L, 14574, -4735, 0, 0, 0, 0, "2026-09-28 18:03:51")) |
| | | .capStep(step(8L, 0, 6, "2", 14455700L, 9998, -4736, 0, 0, 68462032, 30000000, |
| | | "2026-09-28 22:04:47")) |
| | | // cycle 1:两个充电工步 + 两个放电工步 + 一个未知类型工步。 |
| | | .capStep(step(9L, 1, 1, "1", 0L, 13507, 4732, 0, 0, 0, 0, "2026-09-28 22:04:47")) |
| | | .capStep(step(10L, 1, 1, "1", 25200000L, 17085, 4733, 119267664, 196020320, 0, 0, |
| | | "2026-09-29 05:04:47")) |
| | | .capStep(step(11L, 1, 2, "2", 0L, 14574, -4735, 0, 0, 0, 0, "2026-09-29 05:04:47")) |
| | | .capStep(step(12L, 1, 2, "2", 14271500L, 9998, -4736, 0, 0, 67587520, 86258072, |
| | | "2026-09-29 09:02:38")) |
| | | // 未知类型 7:充电组有值,但方向未确认,所以不进聚合、单项容量留空。 |
| | | .capStep(step(13L, 1, 3, "7", 0L, 13000, 500, 500000000, 900000000, 0, 0, "2026-09-29 09:02:39")) |
| | | .capStep(step(14L, 1, 3, "7", 600000L, 13200, 600, 500000000, 900000000, 0, 0, |
| | | "2026-09-29 09:12:39")) |
| | | .capStep(step(15L, 1, 9, "1", 0L, 13092, 11860, 0, 0, 0, 0, "2026-09-29 09:12:40")) |
| | | .capStep(step(16L, 1, 9, "1", 7200000L, 18176, 11875, 85510512, 145614320, 0, 0, |
| | | "2026-09-29 11:12:40")) |
| | | .capStep(step(17L, 1, 11, "2", 0L, 14313, -11878, 0, 0, 0, 0, "2026-09-29 11:12:41")) |
| | | .capStep(step(18L, 1, 11, "2", 4001000L, 9998, -11889, 0, 0, 47559464, 58816460, |
| | | "2026-09-29 12:19:21")) |
| | | // 仍在进行的工步:只有进入行。 |
| | | .capStep(step(19L, 2, 9, "1", 0L, 13774, 11871, 0, 0, 0, 0, "2026-09-29 12:19:22")); |
| | | } |
| | | |
| | | /** 工步层一行的简写:本用例只用本通道的数据,中值电压按现场实测给出零。 */ |
| | | private static FakeVendorDatabase.CapStepRow step(long seqId, int cycle, int stepId, String stepType, |
| | | long testTimeMillis, int voltage, int current, int capacityCharge, int energyCharge, |
| | | int capacityDischarge, int energyDischarge, String testAtime) |
| | | { |
| | | return new FakeVendorDatabase.CapStepRow(440L, DEV_UID, 1, 2, seqId, cycle, stepId, stepType, |
| | | testTimeMillis, voltage, current, capacityCharge, energyCharge, capacityDischarge, energyDischarge, |
| | | 0, testAtime); |
| | | } |
| | | |
| | | /** 凭据只用现场取得的服务端只读账号;已知的全权限账号不进入配置。 */ |
| | | @Test |
| | | void credentialsFallBackToTheReadOnlyAccountAndRefuseFullPrivilegeAccounts() |
| New file |
| | |
| | | package com.battery.device.neware; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | |
| | | import org.junit.jupiter.api.Test; |
| | | import com.battery.device.api.StepDirectionResolver.Direction; |
| | | |
| | | /** 工步类型数字到方向的映射:已知取值照映射,未确认取值保持未知,不按电流符号猜。 */ |
| | | class NewareStepDirectionResolverTest |
| | | { |
| | | private final NewareStepDirectionResolver resolver = new NewareStepDirectionResolver(); |
| | | |
| | | @Test |
| | | void mapsTheConfirmedNumericStepTypes() |
| | | { |
| | | // 现场实测:1 充电、2 放电、4 搁置;空白按未知处理。 |
| | | assertEquals(Direction.CHARGE, resolver.resolve("1")); |
| | | assertEquals(Direction.DISCHARGE, resolver.resolve("2")); |
| | | assertEquals(Direction.REST, resolver.resolve(" 4 ")); |
| | | } |
| | | |
| | | @Test |
| | | void keepsUnconfirmedNumericTypesUnknown() |
| | | { |
| | | // cap_step 里还出现过 3、5、7、13,语义未确认;未知就是未知,不能按数值或电流方向推断。 |
| | | for (String type : new String[] { "3", "5", "7", "13", "0", "99", "abc", "cc", "rest", " " }) |
| | | assertEquals(Direction.UNKNOWN, resolver.resolve(type), type); |
| | | assertEquals(Direction.UNKNOWN, resolver.resolve(null)); |
| | | } |
| | | } |
| | |
| | | package com.battery.business.runtime; |
| | | |
| | | import com.battery.device.api.StepDirectionResolver; |
| | | import com.battery.device.api.StepDirectionResolver.Direction; |
| | | import com.battery.device.bts.BtsStepDirectionResolver; |
| | | import com.battery.device.neware.NewareStepDirectionResolver; |
| | | import org.springframework.context.annotation.Bean; |
| | | import org.springframework.context.annotation.Configuration; |
| | | |
| | | /** 装配已保存 BTS 工步的方向解释,不触发任何设备请求。 */ |
| | | /** 装配已保存工步的方向解释,不触发任何设备请求。 */ |
| | | @Configuration |
| | | public class ReportStepDirectionConfig |
| | | { |
| | | /** |
| | | * 已保存工步的类型有两种形态:导入或协议路径给的是名称(`cc`、`charge`), |
| | | * 直连新威取来的是设备数字枚举(`1`/`2`/`4`)。两种都解释,未确认取值保持未知。 |
| | | */ |
| | | @Bean |
| | | public StepDirectionResolver reportStepDirectionResolver() { return new BtsStepDirectionResolver(); } |
| | | public StepDirectionResolver reportStepDirectionResolver() |
| | | { |
| | | StepDirectionResolver deviceTypes = new NewareStepDirectionResolver(); |
| | | StepDirectionResolver names = new BtsStepDirectionResolver(); |
| | | return stepType -> { |
| | | Direction direction = deviceTypes.resolve(stepType); |
| | | return direction == Direction.UNKNOWN ? names.resolve(stepType) : direction; |
| | | }; |
| | | } |
| | | } |
| New file |
| | |
| | | -- 循环统计的来源类别:设备直接提供、由工步层按循环聚合、授权文件导入。 |
| | | -- 设备没有循环汇总行时循环统计是推导值,必须与设备事实可区分,界面与导出据此标注"聚合来源"。 |
| | | -- 历史行按设备事实处理:本轮之前写入的循环记录没有聚合来源,语言上的默认值与存量一致。 |
| | | ALTER TABLE t_test_cycle_record |
| | | ADD COLUMN source_kind VARCHAR(16) NOT NULL DEFAULT 'DEVICE' AFTER source_record_id; |
| New file |
| | |
| | | package com.battery.business.runtime; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertFalse; |
| | | import static org.junit.jupiter.api.Assertions.assertTrue; |
| | | |
| | | import java.io.InputStream; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.util.UUID; |
| | | import org.apache.ibatis.datasource.unpooled.UnpooledDataSource; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.springframework.core.io.ClassPathResource; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.jdbc.datasource.init.ScriptUtils; |
| | | |
| | | /** |
| | | * 循环记录来源类别的迁移契约:设备没有循环汇总行时循环统计由工步层聚合得出, |
| | | * 该来源事实必须落在库里,界面与导出才能明确标注"这个数是推导的"。 |
| | | */ |
| | | class CycleRecordSourceKindMigrationContractTest |
| | | { |
| | | @Test |
| | | void migrationAddsSourceKindColumnWithDeviceAsHistoricalDefault() throws Exception |
| | | { |
| | | String path = "db/migration-final/V23__cycle_record_source_kind.sql"; |
| | | String migration = resource(path); |
| | | assertTrue(migration.contains("ADD COLUMN source_kind VARCHAR(16) NOT NULL DEFAULT 'DEVICE'")); |
| | | assertTrue(migration.contains("AFTER source_record_id")); |
| | | // 迁移只加列:不改写既有循环事实,也不写入任何数据。 |
| | | assertFalse(migration.matches("(?s).*\\b(INSERT|UPDATE|DELETE|DROP)\\b.*")); |
| | | |
| | | var source = new UnpooledDataSource("org.h2.Driver", |
| | | "jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE", "sa", ""); |
| | | try (var connection = source.getConnection()) |
| | | { |
| | | JdbcTemplate jdbc = new JdbcTemplate(source); |
| | | jdbc.execute("create table t_test_cycle_record(cycle_record_id bigint auto_increment primary key," |
| | | + "source_record_id varchar(128))"); |
| | | // 迁移之前写入的循环记录按设备事实处理:语言上的默认值与存量一致。 |
| | | jdbc.update("insert into t_test_cycle_record(source_record_id) values(?)", "CYCLE-1"); |
| | | ScriptUtils.executeSqlScript(connection, new ClassPathResource(path)); |
| | | |
| | | assertEquals("DEVICE", jdbc.queryForObject( |
| | | "select source_kind from t_test_cycle_record where source_record_id = 'CYCLE-1'", String.class)); |
| | | // 聚合来源是显式写入的事实,不是从别处推断出来的。 |
| | | jdbc.update("insert into t_test_cycle_record(source_record_id, source_kind) values(?,?)", |
| | | "neware:442:cycle:3", "AGGREGATED"); |
| | | assertEquals(1, jdbc.queryForObject( |
| | | "select count(*) from t_test_cycle_record where source_kind = 'AGGREGATED'", Integer.class)); |
| | | } |
| | | } |
| | | |
| | | private static String resource(String path) |
| | | { |
| | | try (InputStream stream = CycleRecordSourceKindMigrationContractTest.class.getClassLoader() |
| | | .getResourceAsStream(path)) |
| | | { |
| | | if (stream == null) throw new AssertionError("缺少资源 " + path); |
| | | return new String(stream.readAllBytes(), StandardCharsets.UTF_8); |
| | | } |
| | | catch (Exception exception) |
| | | { |
| | | throw new AssertionError("无法读取资源 " + path, exception); |
| | | } |
| | | } |
| | | } |
| | |
| | | Instant started = Instant.parse("2026-09-02T04:00:10Z"); |
| | | Instant ended = Instant.parse("2026-09-02T04:00:50Z"); |
| | | when(fixture.simulator.stepRecords(any(ChannelIdentity.class))).thenReturn(List.of(new StepRecord( |
| | | "STEP-1", 1, 2, "恒流充电", "cc", started, ended, 3.4, 2.5, 8.4, ended.toString(), "UTC", ended, |
| | | "STEP-1", 1, 2, "恒流充电", "cc", started, ended, 3.4, 2.6, 2.5, 8.4, ended.toString(), "UTC", ended, |
| | | java.util.Map.of("stepDuration", new BigDecimal("40.125"), "startVoltage", new BigDecimal("3.1"), |
| | | "chargeMedianVoltage", new BigDecimal("3.25"), "dischargeMedianVoltage", BigDecimal.ZERO)))); |
| | | when(fixture.simulator.cycleRecords(any(ChannelIdentity.class))).thenReturn(List.of(new CycleRecord( |
| | | "CYCLE-1-1-1", 1, started, ended, 2.5, 2.4, 8.4, 8.0, 96, ended.toString(), "UTC", ended))); |
| | | "CYCLE-1-1-1", 1, started, ended, 2.5, 2.4, 8.4, 8.0, 96d, ended.toString(), "UTC", ended, |
| | | CycleRecord.SOURCE_DEVICE))); |
| | | when(fixture.mapper.insertStepRecord(any())).thenReturn(1); |
| | | when(fixture.mapper.insertCycleRecord(any())).thenReturn(1); |
| | | |
| | |
| | | assertEquals(BigDecimal.ZERO, metrics.getBigDecimal("dischargeMedianVoltage")); |
| | | assertEquals("CYCLE-1-1-1", cycle.getValue().getSourceRecordId()); |
| | | assertEquals("record:CYCLE-1-1-1", cycle.getValue().getDedupeKey()); |
| | | // 设备或文件直接提供的循环统计保留原有来源类别。 |
| | | assertEquals(CycleRecord.SOURCE_DEVICE, cycle.getValue().getSourceKind()); |
| | | // 工步结束时的设备读数:电压与电流一起落库。 |
| | | assertEquals(new BigDecimal("3.4"), step.getValue().getVoltageV()); |
| | | assertEquals(new BigDecimal("2.6"), step.getValue().getCurrentA()); |
| | | assertEquals(Date.from(ended), step.getValue().getServerReceivedAtUtc()); |
| | | assertEquals(Date.from(ended), cycle.getValue().getServerReceivedAtUtc()); |
| | | } |
| | | |
| | | /** 接收调度每秒运行,但来源按自己的写库节奏产出工步:不按来源节奏限流会每秒重查厂商库。 */ |
| | | @Test void readsTheStepLayerOncePerSourceCadenceInsteadOfEverySchedulerTick() |
| | | { |
| | | Fixture fixture = fixture(); |
| | | when(fixture.simulator.stepRecords(any(ChannelIdentity.class))).thenReturn(List.of()); |
| | | when(fixture.simulator.cycleRecords(any(ChannelIdentity.class))).thenReturn(List.of()); |
| | | |
| | | TestDataReceptionServiceImpl service = fixture.service(); |
| | | service.captureReceivedData(); |
| | | service.captureReceivedData(); |
| | | service.captureReceivedData(); |
| | | |
| | | verify(fixture.simulator, times(1)).stepRecords(any(ChannelIdentity.class)); |
| | | verify(fixture.simulator, times(1)).cycleRecords(any(ChannelIdentity.class)); |
| | | } |
| | | |
| | | @Test |
| | |
| | | fixture.tests.selectTestById(1L).setReceptionBoundaryAtUtc(Date.from(CAPTURED_AT)); |
| | | Instant pausedAt = CAPTURED_AT.minusSeconds(10); |
| | | when(fixture.simulator.stepRecords(any())).thenReturn(List.of(new StepRecord("paused", 1, 1, "工步", "cc", |
| | | pausedAt.minusSeconds(20), pausedAt, 3, 1, 2, pausedAt.toString(), "UTC", pausedAt, java.util.Map.of()))); |
| | | pausedAt.minusSeconds(20), pausedAt, 3d, 1.5d, 1d, 2d, pausedAt.toString(), "UTC", pausedAt, |
| | | java.util.Map.of()))); |
| | | when(fixture.simulator.cycleRecords(any())).thenReturn(List.of(new CycleRecord("paused", 1, |
| | | pausedAt.minusSeconds(20), pausedAt, 1, 1, 2, 2, 100, pausedAt.toString(), "UTC", pausedAt))); |
| | | pausedAt.minusSeconds(20), pausedAt, 1, 1, 2, 2, 100d, pausedAt.toString(), "UTC", pausedAt, |
| | | CycleRecord.SOURCE_DEVICE))); |
| | | fixture.service().captureReceivedData(); |
| | | verify(fixture.mapper, never()).insertStepRecord(any()); |
| | | verify(fixture.mapper, never()).insertCycleRecord(any()); |
| | | } |
| | | |
| | | /** 循环统计的来源类别随记录落库:聚合值必须能与设备或文件提供的值区分。 */ |
| | | @Test void persistsCycleSourceKindAndKeepsMissingMetricsEmpty() |
| | | { |
| | | Fixture fixture = fixture(); |
| | | when(fixture.simulator.cycleRecords(any(ChannelIdentity.class))).thenReturn(List.of( |
| | | new CycleRecord("neware:442:cycle:3", 3, CAPTURED_AT.minusSeconds(600), CAPTURED_AT, 2.375, 1.383, |
| | | 4.059, 1.715, null, CAPTURED_AT.toString(), "Asia/Hong_Kong", CAPTURED_AT, |
| | | CycleRecord.SOURCE_AGGREGATED))); |
| | | when(fixture.simulator.stepRecords(any(ChannelIdentity.class))).thenReturn(List.of(new StepRecord( |
| | | "neware:442:step:3:7", 3, 7, null, "7", CAPTURED_AT.minusSeconds(60), CAPTURED_AT, 1.3, 0.4, null, null, |
| | | CAPTURED_AT.toString(), "Asia/Hong_Kong", CAPTURED_AT, |
| | | java.util.Map.of("chargeCapacity", new BigDecimal("13.888888888889"))))); |
| | | when(fixture.mapper.insertCycleRecord(any())).thenReturn(1); |
| | | when(fixture.mapper.insertStepRecord(any())).thenReturn(1); |
| | | |
| | | fixture.service().captureReceivedData(); |
| | | |
| | | ArgumentCaptor<TTestCycleRecord> cycle = ArgumentCaptor.forClass(TTestCycleRecord.class); |
| | | verify(fixture.mapper).insertCycleRecord(cycle.capture()); |
| | | assertEquals(CycleRecord.SOURCE_AGGREGATED, cycle.getValue().getSourceKind()); |
| | | // 没有可用充电容量就没有效率:空值不是零。 |
| | | assertNull(cycle.getValue().getEfficiency()); |
| | | assertEquals(new BigDecimal("2.375"), cycle.getValue().getChargeCapacityAh()); |
| | | |
| | | ArgumentCaptor<TTestStepRecord> step = ArgumentCaptor.forClass(TTestStepRecord.class); |
| | | verify(fixture.mapper).insertStepRecord(step.capture()); |
| | | // 方向未确认的工步没有单项容量与能量:落库保持空值,四组明细仍在扩展里。 |
| | | assertNull(step.getValue().getCapacityAh()); |
| | | assertNull(step.getValue().getEnergyWh()); |
| | | assertTrue(step.getValue().getExtensionMeasurementsJson().contains("chargeCapacity")); |
| | | } |
| | | |
| | | @Test void deletedAggregateDoesNotReturnOnRepeatedObservation() |
| | | { |
| | | Fixture fixture = fixture(); |
| | | when(fixture.simulator.stepRecords(any())).thenReturn(List.of(new StepRecord("deleted", 1, 1, "工步", "cc", |
| | | CAPTURED_AT.minusSeconds(10), CAPTURED_AT, 3, 1, 2, CAPTURED_AT.toString(), "UTC", CAPTURED_AT, java.util.Map.of()))); |
| | | CAPTURED_AT.minusSeconds(10), CAPTURED_AT, 3d, 1.5d, 1d, 2d, CAPTURED_AT.toString(), "UTC", CAPTURED_AT, |
| | | java.util.Map.of()))); |
| | | when(fixture.mapper.wasAggregateDeleted(1L, "STEP", 4L, "dev-sim:22:1:1:1", "record:deleted")).thenReturn(true); |
| | | fixture.service().captureReceivedData(); |
| | | verify(fixture.mapper, never()).insertStepRecord(any()); |
| | |
| | | new ClassPathResource("db/migration-final/V8__import_records_without_polling_slots.sql")); |
| | | String deviceTime = "dedupe_key varchar(512) not null, device_time_utc timestamp(3) not null, device_time_raw varchar(64) not null, adapter_timezone_id varchar(64) not null, unique(test_battery_id,source_identity,dedupe_key)"; |
| | | jdbc.execute("create table t_test_step_record(step_record_id bigint auto_increment primary key," + common + "," + metrics + "," + deviceTime + ")"); |
| | | jdbc.execute("create table t_test_cycle_record(cycle_record_id bigint auto_increment primary key," + common + ",charge_capacity_ah decimal(30,12),discharge_capacity_ah decimal(30,12),charge_energy_wh decimal(30,12),discharge_energy_wh decimal(30,12),efficiency decimal(30,12)," + deviceTime + ")"); |
| | | jdbc.execute("create table t_test_cycle_record(cycle_record_id bigint auto_increment primary key," + common + ",source_kind varchar(16) default 'DEVICE',charge_capacity_ah decimal(30,12),discharge_capacity_ah decimal(30,12),charge_energy_wh decimal(30,12),discharge_energy_wh decimal(30,12),efficiency decimal(30,12)," + deviceTime + ")"); |
| | | var factory = new SqlSessionFactoryBean(); factory.setDataSource(source); |
| | | factory.setTypeAliasesPackage("com.battery.business.domain"); |
| | | factory.setMapperLocations(new ClassPathResource("mapper/battery/TTestMapper.xml")); |
| | |
| | | } |
| | | assertEquals("17", mapper.selectPersistedFinalRealtimeRecords(1L).get(0).get("recordno")); |
| | | assertEquals(3, service.selectActions(new TTestDataRevisionAction()).size()); |
| | | } |
| | | |
| | | /** |
| | | * 循环记录的来源类别随记录落库并经查询交回:导入的循环来自授权文件, |
| | | * 由工步层聚合的循环标注为聚合,使界面与导出能区分"推导值"与"设备或文件给的值"。 |
| | | */ |
| | | @Test void cycleSourceKindDistinguishesImportedAndAggregatedRecords() |
| | | { |
| | | assertEquals(1, submit("CYCLE", List.of(row())).get("importedCount")); |
| | | assertEquals("IMPORTED", mapper.selectPersistedFinalCycleRecords(1L).get(0).get("sourcekind")); |
| | | |
| | | var aggregated = new com.battery.business.domain.TTestCycleRecord(); |
| | | aggregated.setTestId(1L); aggregated.setTestBatteryId(4L); aggregated.setBindingId(5L); |
| | | aggregated.setAdapterCode("adapter-a"); aggregated.setSourceIdentity("adapter-a:27:1:1:2"); |
| | | aggregated.setSourceRecordId("neware:442:cycle:3"); |
| | | aggregated.setSourceKind(com.battery.device.api.TestDataObservationAdapter.CycleRecord.SOURCE_AGGREGATED); |
| | | aggregated.setDedupeKey("record:neware:442:cycle:3"); |
| | | aggregated.setAdapterTimezoneId("Asia/Hong_Kong"); |
| | | aggregated.setDeviceTimeUtc(new java.util.Date(1683517331000L)); |
| | | aggregated.setDeviceTimeRaw("2023-05-08 11:42:11"); |
| | | aggregated.setServerReceivedAtUtc(new java.util.Date(1683517331000L)); |
| | | aggregated.setCycleNo(3); |
| | | aggregated.setChargeCapacityAh(new java.math.BigDecimal("2.375")); |
| | | aggregated.setDischargeCapacityAh(new java.math.BigDecimal("1.383")); |
| | | aggregated.setEfficiency(null); |
| | | aggregated.setContentSha256(new byte[32]); |
| | | assertEquals(1, mapper.insertCycleRecord(aggregated)); |
| | | |
| | | var rows = mapper.selectPersistedFinalCycleRecords(1L); |
| | | assertEquals(2, rows.size()); |
| | | var stored = rows.stream().filter(item -> "3".equals(String.valueOf(item.get("cycleno")))).findFirst().orElseThrow(); |
| | | assertEquals("AGGREGATED", stored.get("sourcekind")); |
| | | // 没有可用充电容量就没有效率:空值不是零。 |
| | | assertNull(stored.get("efficiency")); |
| | | } |
| | | |
| | | @Test void databaseFailureRollsBackEarlierRowsAndTheRevision() |
| | |
| | | org.mockito.Mockito.mock(com.battery.business.service.ITTestService.class)); |
| | | var current = new LinkedHashMap<String, Object>(Map.of("extensionMeasurementsJson", extension.toJSONString())); |
| | | var legacy = new LinkedHashMap<String, Object>(Map.of("extensionMeasurementsJson", "{\"chargePeakVoltage\":1.8,\"dischargeValleyVoltage\":0.8}")); |
| | | org.mockito.Mockito.when(queryMapper.selectPersistedFinalStepRecords(1L)).thenReturn(List.of(current, legacy)); |
| | | // 设备工步层的四组容量能量是独立事实:查询要交出它们,供方向未确认的工步核对。 |
| | | var deviceStep = new LinkedHashMap<String, Object>(Map.of("extensionMeasurementsJson", |
| | | "{\"chargeCapacity\":13.888888888889,\"dischargeCapacity\":0,\"chargeEnergy\":25,\"dischargeEnergy\":0}")); |
| | | org.mockito.Mockito.when(queryMapper.selectPersistedFinalStepRecords(1L)).thenReturn(List.of(current, legacy, deviceStep)); |
| | | var result = query.steps(1L); |
| | | assertEquals(new java.math.BigDecimal("1.1709"), result.get(0).get("dischargeMedianVoltage")); |
| | | assertEquals(0.0, ((Number) result.get(0).get("chargeMedianVoltage")).doubleValue()); |
| | | assertNull(result.get(1).get("chargeMedianVoltage")); |
| | | assertNull(result.get(1).get("dischargeMedianVoltage")); |
| | | assertEquals(0, new java.math.BigDecimal("13.888888888889").compareTo( |
| | | new java.math.BigDecimal(String.valueOf(result.get(2).get("chargeCapacity"))))); |
| | | assertEquals(0, java.math.BigDecimal.ZERO.compareTo( |
| | | new java.math.BigDecimal(String.valueOf(result.get(2).get("dischargeCapacity"))))); |
| | | assertEquals(0, new java.math.BigDecimal("25.0").compareTo( |
| | | new java.math.BigDecimal(String.valueOf(result.get(2).get("chargeEnergy"))))); |
| | | assertEquals(0, java.math.BigDecimal.ZERO.compareTo( |
| | | new java.math.BigDecimal(String.valueOf(result.get(2).get("dischargeEnergy"))))); |
| | | assertNull(result.get(1).get("chargeCapacity")); |
| | | |
| | | imported.put("dischargeMedianVoltage", 1.2); |
| | | assertTrue(assertThrows(RuntimeException.class, () -> submit("STEP", List.of(imported))).getMessage().contains("冲突拒绝")); |