| | |
| | | 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.credentials.LocalCredentials; |
| | | 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() |
| | |
| | | { |
| | | // 文件、环境变量、启动参数都在时取文件。 |
| | | java.nio.file.Files.writeString(file, key + "=from-file\n"); |
| | | assertEquals("from-file", NewareMysqlAdapterFactory.credential(key, file, |
| | | assertEquals("from-file", LocalCredentials.resolve(key, file, |
| | | Map.of(key, "from-env"), name -> "from-parameter")); |
| | | |
| | | // 没有文件时退到环境变量。 |
| | | java.nio.file.Files.delete(file); |
| | | assertEquals("from-env", NewareMysqlAdapterFactory.credential(key, file, |
| | | assertEquals("from-env", LocalCredentials.resolve(key, file, |
| | | Map.of(key, "from-env"), name -> "from-parameter")); |
| | | |
| | | // 前两者都没有时退到启动参数。 |
| | | assertEquals("from-parameter", NewareMysqlAdapterFactory.credential(key, file, |
| | | assertEquals("from-parameter", LocalCredentials.resolve(key, file, |
| | | Map.of(), name -> "from-parameter")); |
| | | |
| | | // 三者都没有:失败信息里同时出现文件路径与键名。 |
| | | var failure = org.junit.jupiter.api.Assertions.assertThrows( |
| | | com.battery.device.api.DeviceAdapterException.class, |
| | | () -> NewareMysqlAdapterFactory.credential(key, file, Map.of(), name -> null)); |
| | | () -> LocalCredentials.resolve(key, file, Map.of(), name -> null)); |
| | | assertTrue(failure.getMessage().contains(file.toString())); |
| | | assertTrue(failure.getMessage().contains(key)); |
| | | } |
| | |
| | | try |
| | | { |
| | | java.nio.file.Files.writeString(file, key + "=\n"); |
| | | assertEquals("from-env", NewareMysqlAdapterFactory.credential(key, file, Map.of(key, "from-env"), |
| | | assertEquals("from-env", LocalCredentials.resolve(key, file, Map.of(key, "from-env"), |
| | | name -> null)); |
| | | } |
| | | finally |