From 9c93934b70ccfca081102944a47f3baafad3c218 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期一, 28 九月 2026 16:43:57 +0800
Subject: [PATCH] 实现新威 7.6 的工步与循环数据取数

---
 battery-device-neware/src/test/java/com/battery/device/neware/NewareMysqlAdapterContractTest.java |  194 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 194 insertions(+), 0 deletions(-)

diff --git a/battery-device-neware/src/test/java/com/battery/device/neware/NewareMysqlAdapterContractTest.java b/battery-device-neware/src/test/java/com/battery/device/neware/NewareMysqlAdapterContractTest.java
index 14673a6..529c1ae 100644
--- a/battery-device-neware/src/test/java/com/battery/device/neware/NewareMysqlAdapterContractTest.java
+++ b/battery-device-neware/src/test/java/com/battery/device/neware/NewareMysqlAdapterContractTest.java
@@ -13,7 +13,9 @@
 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;
@@ -205,6 +207,198 @@
                 .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()

--
Gitblit v1.7.1