yeg
2026-09-05 7259e265b9237286996b09da03038ea5a133e1e0
battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
@@ -11,31 +11,33 @@
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamConstants;
import javax.xml.stream.XMLStreamReader;
import com.battery.device.model.DeviceWorkStatus;
import com.battery.device.api.model.DeviceWorkStatus;
import com.battery.device.api.model.DeviceConnectionStatus;
import com.battery.device.api.model.DeviceIdentity;
import com.battery.device.api.model.DeviceObservation;
import com.battery.device.api.model.DetectedChannel;
import com.battery.device.api.model.DetectedDeviceMetadata;
import com.battery.device.api.model.ChannelIdentity;
import com.battery.device.api.model.AdapterCapabilities;
import com.battery.device.api.DeviceObservationAdapter;
import com.battery.device.api.TestDataObservationAdapter;
import com.battery.device.simulator.BtsSimulatorStateStore;
import com.battery.device.simulator.BtsSimulatorStateStore.ChannelState;
import com.battery.device.simulator.BtsSimulatorStateStore.ChannelTombstone;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorChannelState;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryAppend;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryFaults;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorMetricState;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorRealtimeMeasurement;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorState;
import com.battery.device.simulator.BtsSimulatorStateStore.DeviceState;
import com.battery.device.simulator.BtsSimulatorStateStore.BtsDeviceProfile;
import com.battery.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord;
import com.battery.device.simulator.BtsSimulatorStateStore.BtsPlaybackState;
import com.battery.device.simulator.BtsSimulatorStateStore.RecentRequest;
import com.battery.device.simulator.BtsSimulatorStateStore.SimulatorState;
import com.battery.device.simulator.InMemoryBtsSimulatorStateStore;
import com.battery.device.simulator.DevelopmentCollectorSimulation;
import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorAdvance;
import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorHistoryPage;
import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot;
/** 提供由开发后端托管的临时只读 BTS 模拟端点。 */
public final class BtsDevelopmentSimulator implements AutoCloseable
public final class BtsDevelopmentSimulator implements TestDataObservationAdapter
{
    public static final String HOST = "127.0.0.1";
    public static final int DEFAULT_PORT = 15020;
@@ -53,11 +55,69 @@
    private final BtsSimulatorStateStore stateStore;
    private SimulatorState state;
    private boolean running = true;
    private long observationSequence;
    private final java.util.Map<String, java.util.List<BtsHistoryRecord>> btsHistory = new java.util.LinkedHashMap<>();
    private static final long SIMULATED_RECORD_INTERVAL_SECONDS = 60L;
    private static final DateTimeFormatter BTS_HISTORY_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH.mm.ss")
            .withZone(ZoneId.of("Asia/Hong_Kong"));
    private static final ZoneId DEVICE_ZONE = ZoneId.of("Asia/Hong_Kong");
    private static final DateTimeFormatter DEVICE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
    @Override
    public String adapterCode()
    {
        return ADAPTER_CODE;
    }
    @Override
    public AdapterCapabilities capabilities()
    {
        return AdapterCapabilities.observeOnly();
    }
    /** 直接提供模拟器当前拓扑,供不需要 TCP/XML 的契约测试使用。 */
    @Override
    public synchronized List<DetectedChannel> discoverChannels()
    {
        List<DetectedChannel> result = new ArrayList<>();
        for (DeviceState device : state.devices())
        {
            BtsDeviceProfile profile = profileFor(device);
            for (ChannelState channel : device.channels())
            {
                ChannelIdentity identity = new ChannelIdentity(ADAPTER_CODE, profile.devtype(), device.deviceId(),
                        profile.subDeviceId(), channel.channelId());
                result.add(new DetectedChannel(identity, device.ip(), state.softwareAccessible() && device.online() && channel.online(),
                        new DetectedDeviceMetadata(profile.name(), profile.manufacturer(), profile.model(),
                                simulatorMac(profile.devtype(), device.deviceId()))));
            }
        }
        recordRequest("discover", ADAPTER_CODE, result.toString());
        return List.copyOf(result);
    }
    /** 直接提供模拟器观测;离线通道不保留上一次测量或工步事实。 */
    @Override
    public synchronized java.util.Map<ChannelIdentity, DeviceObservation> observe(
            java.util.Set<ChannelIdentity> channels)
    {
        if (channels == null || channels.isEmpty()) return java.util.Map.of();
        java.util.Map<ChannelIdentity, DeviceObservation> result = new java.util.LinkedHashMap<>();
        for (ChannelIdentity requested : channels) result.put(requested, observation(requested));
        recordRequest("observe", channels.toString(), result.toString());
        return java.util.Map.copyOf(result);
    }
    private DeviceObservation observation(ChannelIdentity requested)
    {
        DeviceState device = state.devices().stream().filter(item -> item.deviceId() == requested.devid())
                .findFirst().orElse(null);
        ChannelState channel = device == null ? null : device.channels().stream()
                .filter(item -> item.channelId() == requested.chlid()).findFirst().orElse(null);
        if (!state.softwareAccessible() || device == null || channel == null
                || !ADAPTER_CODE.equals(requested.adapterCode()) || !device.online() || !channel.online()
                || requested.devtype() != profileFor(device).devtype()
                || requested.subdevid() != profileFor(device).subDeviceId())
            return DeviceObservation.offline(requested, failureReason());
        return new DeviceObservation(new DeviceIdentity(ADAPTER_CODE, requested.devtype(), requested.devid(),
                requested.subdevid()), requested, DeviceConnectionStatus.ONLINE, channel.workStatus(),
                List.of(), null, java.util.Map.of(), null);
    }
    /** 启动仅监听 IPv4 本机回环地址的开发模拟器;传入 0 时由系统分配测试端口。 */
    public BtsDevelopmentSimulator(int port) throws IOException
@@ -70,11 +130,7 @@
    {
        this.stateStore = stateStore;
        state = stateStore.loadOrInitialize(defaultState());
        // 仅清理 Ticket13 之前嵌在主状态中的旧演示历史;独立历史由模拟器存储保留。
        state = stateStore.resetHistoryForDemoStartup(state);
        restoreBtsHistory();
        validateRestoredState();
        stateStore.completeValidatedRestore(state);
        simulator = ScriptedBtsTcpSimulator.responding(port, this::respond);
    }
@@ -145,15 +201,9 @@
        List<DeviceState> devices = new ArrayList<>(state.devices());
        devices.removeIf(item -> item.deviceId() == deviceId);
        List<ChannelTombstone> tombstones = new ArrayList<>(state.tombstones());
        BtsDeviceProfile removedProfile = profileFor(removed);
        for (ChannelState channel : removed.channels())
        {
            upsertTombstone(tombstones, removed.deviceId(), removed.ip(), channel.channelId());
            String key = historyKey(removedProfile.devtype(), removed.deviceId(), removedProfile.subDeviceId(),
                    channel.channelId());
            stateStore.deleteBtsHistory(key);
            stateStore.deleteBtsPlaybackState(key);
            btsHistory.remove(key);
        }
        persist(new SimulatorState(state.version(), devices, state.collectors(), state.nextDeviceId(),
                state.nextCollectorId(), tombstones));
@@ -207,16 +257,7 @@
        collectors.removeIf(item -> item.collectorId() == collectorId);
        SimulatorState next = new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
                state.nextCollectorId(), state.tombstones());
        stateStore.saveAndDeleteCollectorHistory(next, collector.deviceCode());
        this.state = next;
    }
    /** 配置采集器独立采样频率和历史下载故障场景。 */
    public synchronized void configureCollectorSampling(int collectorId, int sampleIntervalMs,
            CollectorHistoryFaults historyFaults)
    {
        CollectorState current = requireCollector(collectorId);
        replaceCollector(DevelopmentCollectorSimulation.configureSampling(current, sampleIntervalMs, historyFaults));
        persist(next);
    }
    /** 配置一个稳定 S 通道的固定或动态演示指标。 */
@@ -227,295 +268,135 @@
        replaceCollector(DevelopmentCollectorSimulation.configureChannel(current, channelCode, metrics));
    }
    /**
     * 模拟现场人工统一开关机:关机停止采样,开机重启检测柜夹具生命周期并保持通道待机。
     * 采集器历史继续保留,检测柜夹具历史在关机后的开机上升沿清理。
     * 该操作只改变模拟设备事实,不代表系统具备真实设备控制能力。
     */
    /** 修改软件可访问性,不改变设备身份、在线配置或通道工作状态。 */
    public synchronized void setSoftwareAccessible(boolean accessible)
    {
        SimulatorState next = new SimulatorState(state.version(), state.devices(), state.collectors(),
                state.nextDeviceId(), state.nextCollectorId(), state.tombstones(), accessible, state.observationSequence());
        stateStore.save(next);
        state = next;
    }
    /** 批量修改设备在线事实,保留各通道工作状态。 */
    public synchronized void setAllDevicesPoweredOn(boolean poweredOn)
    {
        boolean wasPoweredOff = (!state.devices().isEmpty() || !state.collectors().isEmpty())
                && state.devices().stream().noneMatch(DeviceState::online)
                && state.collectors().stream().noneMatch(CollectorState::online);
        Instant changedAt = Instant.now();
        if (poweredOn && wasPoweredOff)
        {
            // 一键开机代表新一轮演示;清理检测柜夹具,避免序号从 1 重放并与旧生命周期冲突。
            for (DeviceState device : state.devices())
                for (ChannelState channel : device.channels())
                    resetBtsFixtureLifecycle(btsKey(device, channel.channelId()), changedAt);
        }
        List<DeviceState> devices = state.devices().stream()
                .map(device -> new DeviceState(device.deviceId(), device.ip(), poweredOn,
                        device.channels().stream()
                                .map(channel -> new ChannelState(channel.channelId(), poweredOn,
                                        DeviceWorkStatus.STOPPED))
                                .toList(), device.profile()))
                .map(device -> new DeviceState(device.deviceId(), device.ip(), poweredOn, device.channels(), device.profile()))
                .toList();
        List<CollectorState> collectors = state.collectors().stream()
                .map(collector -> DevelopmentCollectorSimulation.resize(collector, collector.ip(),
                        collector.channelCount(), poweredOn, changedAt))
                .toList();
                        collector.channelCount(), poweredOn, Instant.now())).toList();
        persist(new SimulatorState(state.version(), devices, collectors, state.nextDeviceId(),
                state.nextCollectorId(), state.tombstones()));
    }
    /** 清理检测柜夹具的历史和播放指针;采集器历史由总电源开关独立保留。 */
    private void resetBtsFixtureLifecycle(String key, Instant now)
    /** 修改采集器单个通道工作状态;不代表真实采集器控制能力。 */
    public synchronized void updateCollectorChannelStatus(int collectorId, String channelCode, DeviceWorkStatus status)
    {
        btsHistory.remove(key);
        stateStore.deleteBtsHistory(key);
        stateStore.deleteBtsPlaybackState(key);
        savePlayback(key, defaultPlayback(now));
        requireControllableStatus(status);
        replaceCollector(DevelopmentCollectorSimulation.configureStatus(requireCollector(collectorId), channelCode, status));
    }
    /** 按每台采集器自己的频率推进实时和历史采样。 */
    public synchronized void advanceCollectors(Instant now)
    /** 返回固定的开发计划,不推进工步或生成历史。 */
    @Override
    public synchronized TestPlan testPlan(ChannelIdentity channel)
    {
        boolean hasOnlineCollector = false;
        for (CollectorState collector : state.collectors())
        {
            if (!collector.online()) continue;
            hasOnlineCollector = true;
            break;
        }
        if (!hasOnlineCollector) return;
        List<CollectorState> collectors = new ArrayList<>(state.collectors().size());
        List<CollectorHistoryAppend> historyAppends = new ArrayList<>();
        for (CollectorState collector : state.collectors())
        {
            CollectorAdvance advance = DevelopmentCollectorSimulation.advance(collector, now);
            collectors.add(advance.state());
            if (!advance.appendedRecords().isEmpty())
                historyAppends.add(new CollectorHistoryAppend(collector.deviceCode(), advance.appendedRecords()));
        }
        if (!historyAppends.isEmpty())
            persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
                    state.nextCollectorId(), state.tombstones()), historyAppends);
        if (!canObserve(channel)) return null;
        return new TestPlan(1, 1, 1, List.of(new PlanRow("STEP", 1, 1, "恒流充电", 60d, 3.7d,
                requireDevice(channel.devid()).channels().get(channel.chlid() - 1).workStatus() == DeviceWorkStatus.FINISHED
                        ? "已完成" : "待执行", null, null)));
    }
    /** 按墙上时间推进检测柜模拟播放;设备状态和测试接收状态互不影响。 */
    public synchronized void advanceBts(Instant now)
    /** 请求当前检测柜测量;离线或无法观测时不返回缓存值。 */
    @Override
    public synchronized CabinetRealtime latestCabinet(ChannelIdentity channel)
    {
        for (DeviceState device : state.devices())
        {
            if (!device.online()) continue;
            BtsDeviceProfile profile = profileFor(device);
            for (ChannelState channel : device.channels())
            {
                if (!channel.online() || channel.workStatus() != DeviceWorkStatus.WORKING) continue;
                String key = historyKey(profile.devtype(), device.deviceId(), profile.subDeviceId(), channel.channelId());
                BtsPlaybackState playback = playbackState(key, now);
                advanceBtsPlayback(key, profile, playback,
                        profile.currentMaxA().doubleValue() * 0.2d, 3.7d, now);
            }
        }
        CabinetRealtime result = canObserve(channel) ? cabinetMeasurement(channel) : null;
        recordRequest("cabinet-realtime", String.valueOf(channel),
                result == null ? failureReason() : result.toString());
        return result;
    }
    /** 设置检测柜通道演示倍速,只接受 1x、60x 和 600x。 */
    public synchronized void setBtsSpeed(int deviceId, int channelId, int speedMultiplier)
    private CabinetRealtime cabinetMeasurement(ChannelIdentity identity)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        String key = btsKey(device, channelId);
        BtsPlaybackState current = playbackState(key, Instant.now());
        stateStore.saveBtsPlaybackState(key, new BtsPlaybackState(current.playing(), speedMultiplier,
                current.cycleNo(), current.stepNo(), current.stepName(), current.stepType(), current.simulatedAt(),
                Instant.now(), current.nextSequence(), current.naturallyFinished()));
        DeviceState device = requireDevice(identity.devid());
        ChannelState channel = device.channels().get(identity.chlid() - 1);
        if (channel.workStatus() == DeviceWorkStatus.UNOBSERVABLE) return null;
        Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS);
        long sequence = nextObservationSequence();
        double wave = Math.sin((sequence + channel.channelId()) * 0.7d);
        BtsDeviceProfile profile = profileFor(device);
        double current = channel.workStatus() == DeviceWorkStatus.WORKING
                ? clamp(profile.currentMaxA().doubleValue() * (0.18d + wave * 0.02d),
                        profile.currentMinA().doubleValue(), profile.currentMaxA().doubleValue()) : 0d;
        double voltage = clamp(3.7d + wave * 0.01d, profile.voltageMinV().doubleValue(), profile.voltageMaxV().doubleValue());
        CabinetRealtime result = new CabinetRealtime(sequence, now, current, voltage, 1.2d, 4.44d,
                1, 1, "恒流充电", "cc", now, DEVICE_TIME.format(now.atZone(DEVICE_ZONE)), DEVICE_ZONE.getId());
        return result;
    }
    /** 开始播放指定检测柜通道的演示时间线。 */
    public synchronized void playBts(int deviceId, int channelId) { setBtsPlaying(deviceId, channelId, true); }
    /** 暂停模拟器时间线,不改变检测柜通道工作事实。 */
    public synchronized void pauseBts(int deviceId, int channelId) { setBtsPlaying(deviceId, channelId, false); }
    /** 从暂停状态恢复模拟器时间线。 */
    public synchronized void resumeBts(int deviceId, int channelId) { setBtsPlaying(deviceId, channelId, true); }
    private void setBtsPlaying(int deviceId, int channelId, boolean playing)
    /** 请求采集器当前配置值;软件不可访问或设备离线时返回空测量。 */
    @Override
    public synchronized CollectorRealtime latestCollector(String collectorCode)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        String key = btsKey(device, channelId);
        Instant now = Instant.now();
        BtsPlaybackState current = playbackState(key, now);
        if (current.naturallyFinished() && playing) return;
        stateStore.saveBtsPlaybackState(key, new BtsPlaybackState(playing, current.speedMultiplier(), current.cycleNo(),
                current.stepNo(), current.stepName(), current.stepType(), current.simulatedAt(), now,
                current.nextSequence(), current.naturallyFinished()));
        CollectorState collector = state.collectors().stream().filter(item -> item.deviceCode().equals(collectorCode))
                .findFirst().orElse(null);
        if (collector == null) return null;
        var snapshot = collectorRealtime(collector.collectorId());
        return new CollectorRealtime(snapshot.collectorCode(), snapshot.online(), snapshot.sampleIntervalMs(),
                snapshot.measurements().stream().map(item -> new CollectorMeasurement(item.channelCode(), item.sequenceId(),
                        item.deviceTime(), item.deviceTimeRaw(), item.serverReceivedAt(), item.metricCode(), item.value(),
                        item.unit())).toList(), DEVICE_ZONE.getId());
    }
    /** 跳转到下一实际工步;不向设备发送控制报文。 */
    public synchronized void nextBtsStep(int deviceId, int channelId)
    /** 模拟器不提供历史工步统计。 */
    @Override
    public List<StepRecord> stepRecords(ChannelIdentity channel) { return List.of(); }
    /** 模拟器不提供历史循环统计。 */
    @Override
    public List<CycleRecord> cycleRecords(ChannelIdentity channel) { return List.of(); }
    /** 使用后端正常持久化周期,不提供开发倍速。 */
    @Override
    public long persistenceIntervalMillis(ChannelIdentity channel) { return 60000L; }
    private boolean canObserve(ChannelIdentity channel)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        String key = btsKey(device, channelId);
        BtsPlaybackState current = playbackState(key, Instant.now());
        BtsExcelFixture.FixtureRecord next = nextFixtureStep(current.cycleNo(), current.stepNo());
        if (next == null)
        {
            BtsExcelFixture.FixtureRecord last = BtsExcelFixture.record(BtsExcelFixture.recordCount());
            savePlayback(key, new BtsPlaybackState(false, current.speedMultiplier(), last.cycleNo(), last.stepNo(),
                    last.stepName(), last.stepType(), current.simulatedAt(), Instant.now(),
                    BtsExcelFixture.recordCount() + 1L, true));
            return;
        }
        savePlayback(key, new BtsPlaybackState(current.playing(), current.speedMultiplier(), next.cycleNo(), next.stepNo(),
                next.stepName(), next.stepType(), current.simulatedAt(), Instant.now(), next.sequenceId(), false));
        // 完整来源身份限定数据归属;不可访问和离线只改变观测结果。
        return channel != null && observation(channel).connectionStatus() == DeviceConnectionStatus.ONLINE
                && observation(channel).workStatus() != DeviceWorkStatus.UNOBSERVABLE;
    }
    /** 跳转到下一循环的首工步;不补齐当前循环缺失的首尾工步。 */
    public synchronized void nextBtsCycle(int deviceId, int channelId)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        String key = btsKey(device, channelId);
        BtsPlaybackState current = playbackState(key, Instant.now());
        BtsExcelFixture.FixtureRecord next = BtsExcelFixture.records().stream()
                .filter(item -> item.cycleNo() > current.cycleNo()).findFirst().orElse(null);
        if (next == null)
        {
            BtsExcelFixture.FixtureRecord last = BtsExcelFixture.record(BtsExcelFixture.recordCount());
            savePlayback(key, new BtsPlaybackState(false, current.speedMultiplier(), last.cycleNo(), last.stepNo(),
                    last.stepName(), last.stepType(), current.simulatedAt(), Instant.now(),
                    BtsExcelFixture.recordCount() + 1L, true));
            return;
        }
        savePlayback(key, new BtsPlaybackState(current.playing(), current.speedMultiplier(), next.cycleNo(), next.stepNo(),
                next.stepName(), next.stepType(), current.simulatedAt(), Instant.now(), next.sequenceId(), false));
    }
    /** 清空单通道播放和原始数据,恢复到首循环首工步。 */
    public synchronized void resetBts(int deviceId, int channelId)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        String key = btsKey(device, channelId);
        resetBtsFixtureLifecycle(key, Instant.now());
    }
    /** 返回播放状态及当前通道的 record、step、cycle 数据。 */
    public synchronized PlaybackSnapshot btsPlayback(int deviceId, int channelId)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        String key = btsKey(device, channelId);
        BtsPlaybackState state = playbackState(key, Instant.now());
        return new PlaybackSnapshot(deviceId, channelId, state, btsRecords(deviceId, channelId),
                btsSteps(deviceId, channelId), btsCycles(deviceId, channelId));
    }
    /** 返回只追加的检测柜原始 record。 */
    public synchronized List<BtsHistoryRecord> btsRecords(int deviceId, int channelId)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        return List.copyOf(btsHistoryFor(btsKey(device, channelId)));
    }
    /** 返回开发模拟器当前通道的持久化槽位间隔;仅供开发调度层使用。 */
    public synchronized long persistenceIntervalMillis(int deviceId, int channelId)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        BtsPlaybackState playback = playbackState(btsKey(device, channelId), Instant.now());
        return Math.max(100L, 60000L / Math.max(1, playback.speedMultiplier()));
    }
    /** 按实际出现的循环和工步汇总 record,不补齐缺失行。 */
    public synchronized List<BtsStepRecord> btsSteps(int deviceId, int channelId)
    {
        java.util.Map<String, BtsStepAccumulator> grouped = new java.util.LinkedHashMap<>();
        for (BtsHistoryRecord item : btsRecords(deviceId, channelId))
        {
            String key = item.cycleNo() + ":" + item.stepNo();
            int position = BtsExcelFixture.records().stream()
                    .filter(record -> record.cycleNo() == item.cycleNo())
                    .map(BtsExcelFixture.FixtureRecord::stepNo).distinct().toList().indexOf(item.stepNo()) + 1;
            grouped.computeIfAbsent(key, ignored -> new BtsStepAccumulator(item, position)).add(item);
        }
        return grouped.values().stream().map(BtsStepAccumulator::finish).toList();
    }
    /** 按实际出现的循环汇总 record,不增加测试结束统计行。 */
    public synchronized List<BtsCycleRecord> btsCycles(int deviceId, int channelId)
    {
        java.util.Map<Integer, BtsCycleAccumulator> grouped = new java.util.LinkedHashMap<>();
        for (BtsHistoryRecord item : btsRecords(deviceId, channelId))
            grouped.computeIfAbsent(item.cycleNo(), ignored -> new BtsCycleAccumulator(item)).add(item);
        return grouped.entrySet().stream().map(entry -> {
            BtsCycleRecord calculated = entry.getValue().finish();
            BtsExcelFixture.CycleSummary expected = BtsExcelFixture.cycle(entry.getKey());
            String recordNo = "CYCLE-" + deviceId + "-" + channelId + "-" + entry.getKey();
            if (expected == null || entry.getValue().count < expected.recordCount())
                return new BtsCycleRecord(recordNo, calculated.cycleNo(), calculated.recordCount(), calculated.startedAt(), calculated.endedAt(),
                        calculated.chargeCapacityAh(), calculated.dischargeCapacityAh(), calculated.chargeEnergyWh(), calculated.dischargeEnergyWh(), calculated.efficiency());
            return new BtsCycleRecord(recordNo, calculated.cycleNo(), expected.recordCount(), calculated.startedAt(),
                    calculated.endedAt(), expected.chargeCapacityAh(), expected.dischargeCapacityAh(),
                    expected.chargeEnergyWh(), expected.dischargeEnergyWh(),
                    efficiency(expected.chargeCapacityAh(), expected.dischargeCapacityAh()));
        }).toList();
    }
    /** 返回检测柜测试方案事实;方案行来自 Excel 夹具,当前状态来自该通道播放状态。 */
    public synchronized BtsTestSnapshot btsTest(int deviceId, int channelId)
    { return btsTest(deviceId, channelId, null); }
    /** 返回指定循环的检测柜测试方案;未开始接收的测试固定使用第一个循环作为核对上下文。 */
    public synchronized BtsTestSnapshot btsTest(int deviceId, int channelId, Integer forcedCycleNo)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        BtsPlaybackState playback = playbackState(btsKey(device, channelId), Instant.now());
        int requestedCycleNo = forcedCycleNo == null ? playback.cycleNo() : forcedCycleNo;
        int currentCycleNo = requestedCycleNo;
        List<BtsExcelFixture.FixtureRecord> currentCycleRecords = BtsExcelFixture.records().stream()
                .filter(item -> item.cycleNo() == requestedCycleNo).toList();
        if (currentCycleRecords.isEmpty())
        {
            int fallbackCycleNo = BtsExcelFixture.records().get(0).cycleNo();
            currentCycleNo = fallbackCycleNo;
            currentCycleRecords = BtsExcelFixture.records().stream()
                    .filter(item -> item.cycleNo() == fallbackCycleNo).toList();
        }
        java.util.Map<Integer, BtsExcelFixture.FixtureRecord> firstByStep = new java.util.LinkedHashMap<>();
        for (BtsExcelFixture.FixtureRecord item : currentCycleRecords)
            firstByStep.putIfAbsent(item.stepNo(), item);
        List<BtsTestPlanRow> rows = new ArrayList<>();
        int cyclePosition = 1;
        for (BtsExcelFixture.FixtureRecord first : firstByStep.values())
        {
            List<BtsExcelFixture.FixtureRecord> sameCycle = currentCycleRecords.stream()
                    .filter(item -> item.stepNo() == first.stepNo()).toList();
            BtsExcelFixture.FixtureRecord last = sameCycle.get(sameCycle.size() - 1);
            // step 号在每个循环内重复,跨循环时必须按当前循环的工步位置判断,不能把上一循环的同号工步当成当前状态。
            String status = forcedCycleNo != null ? "待执行" : playback.naturallyFinished() ? "已完成"
                    : playback.stepNo() > first.stepNo() ? "已完成"
                            : playback.stepNo() == first.stepNo() ? "进行中" : "待执行";
            rows.add(new BtsTestPlanRow("STEP", first.stepNo(), cyclePosition++, first.stepName(),
                    Math.max(0d, last.offsetSeconds() - first.offsetSeconds()), first.voltageV(), status,
                    null, null));
        }
        rows.add(new BtsTestPlanRow("CYCLE", BtsExcelFixture.startStepNo(), null, "循环", 0d, null,
                forcedCycleNo != null ? "待执行" : playback.naturallyFinished() ? "已完成" : "进行中", currentCycleNo,
                (long) currentCycleRecords.size()));
        rows.add(new BtsTestPlanRow("END", 0, null, "结束", 0d, null,
                forcedCycleNo != null ? "待执行" : playback.naturallyFinished() ? "已完成" : "待执行", null, (long) BtsExcelFixture.recordCount()));
        return new BtsTestSnapshot(currentCycleNo, playback.stepNo(), BtsExcelFixture.cycleCount(), rows);
    }
    /** 返回采集器最新实时快照;快照与分页历史保持不同读取路径。 */
    /** 请求采集器当前观测,读取状态页面本身不会触发此请求。 */
    public synchronized CollectorRealtimeSnapshot collectorRealtime(int collectorId)
    {
        return DevelopmentCollectorSimulation.realtime(requireCollector(collectorId));
        CollectorState collector = requireCollector(collectorId);
        boolean available = state.softwareAccessible() && collector.online();
        if (available)
        {
            collector = DevelopmentCollectorSimulation.sample(collector, Instant.now(), nextObservationSequence());
            replaceCollector(collector);
        }
        var result = new CollectorRealtimeSnapshot(collector.deviceCode(), available, collector.sampleIntervalMs(),
                available ? collector.latestMeasurements() : List.of());
        recordRequest("collector-realtime", collector.deviceCode(), available ? result.toString() : failureReason());
        return result;
    }
    /** 分页下载采集器模拟历史,并应用已配置的重复、冲突、乱序或缺失场景。 */
    public synchronized CollectorHistoryPage collectorHistory(int collectorId, long offset, int limit)
    private String failureReason()
    {
        CollectorState collector = requireCollector(collectorId);
        return DevelopmentCollectorSimulation.history(collector, stateStore, offset, limit);
        return state.softwareAccessible() ? "UNOBSERVABLE" : "SOFTWARE_UNAVAILABLE";
    }
    private long nextObservationSequence()
    {
        long sequence = Math.max(state.observationSequence() + 1, Instant.now().toEpochMilli());
        SimulatorState next = new SimulatorState(state.version(), state.devices(), state.collectors(),
                state.nextDeviceId(), state.nextCollectorId(), state.tombstones(), state.softwareAccessible(), sequence);
        stateStore.save(next);
        state = next;
        return sequence;
    }
    /** 返回控制页需要的运行状态快照。 */
@@ -532,10 +413,9 @@
                .map(collector -> new CollectorSnapshot(collector.collectorId(), collector.deviceCode(),
                        collector.ip(), collector.mac(), collector.name(), collector.manufacturer(),
                        collector.model(), collector.channelCount(), collector.online(), collector.sampleIntervalMs(),
                        collector.channels(), collector.historyFaults(), collector.historyRecordCount(),
                        DevelopmentCollectorSimulation.realtime(collector).measurements()))
                        collector.channels(), state.softwareAccessible() && collector.online() ? collector.latestMeasurements() : List.of()))
                .toList();
        return new StateSnapshot(running, HOST + ":" + port(), deviceSnapshots, collectorSnapshots,
        return new StateSnapshot(running, state.softwareAccessible(), HOST + ":" + port(), deviceSnapshots, collectorSnapshots,
                stateStore.loadRecentRequests());
    }
@@ -544,7 +424,6 @@
    {
        SimulatorState defaultState = defaultState();
        stateStore.reset(defaultState);
        btsHistory.clear();
        state = defaultState;
    }
@@ -583,24 +462,23 @@
                state.nextCollectorId(), state.tombstones()));
    }
    private String respond(String request)
    private synchronized String respond(String request)
    {
        String command = readCommand(request);
        recordRequest(command, request);
        return switch (command)
        String response = state.softwareAccessible() ? switch (command)
        {
            case "getdevinfo" -> deviceInfoResponse();
            case "inquire" -> observationResponse(request);
            case "inquiredf" -> historyAvailabilityResponse(request);
            case "download" -> historyResponse(request);
            default -> "<bts version=\"1.0\"><cmd>error</cmd></bts>";
        };
        } : "<bts version=\"1.0\"><cmd>error</cmd></bts>";
        recordRequest(command, request, state.softwareAccessible() ? response : "SOFTWARE_UNAVAILABLE");
        return response;
    }
    private synchronized void recordRequest(String command, String request)
    private void recordRequest(String command, String request, String response)
    {
        List<RecentRequest> recentRequests = new ArrayList<>(stateStore.loadRecentRequests());
        recentRequests.add(0, new RecentRequest(Instant.now(), command, request));
        recentRequests.add(0, new RecentRequest(Instant.now(), command, request, response));
        if (recentRequests.size() > MAX_RECENT_REQUESTS)
            recentRequests = new ArrayList<>(recentRequests.subList(0, MAX_RECENT_REQUESTS));
        stateStore.saveRecentRequests(recentRequests);
@@ -673,13 +551,13 @@
    private synchronized String observationResponse(String request)
    {
        long tick = ++observationSequence;
        java.util.Set<String> requested = requestedChannels(request);
        List<ObservedChannel> available = new ArrayList<>();
        for (DeviceState device : state.devices())
            for (ChannelState channel : device.channels())
                if (device.online() && channel.online()
                        && (requested.isEmpty() || requested.contains(device.deviceId() + ":" + channel.channelId())))
                if (device.online() && channel.online() && channel.workStatus() != DeviceWorkStatus.UNOBSERVABLE
                        && requested.contains(profileFor(device).devtype() + ":" + device.deviceId()
                                + ":" + profileFor(device).subDeviceId() + ":" + channel.channelId()))
                    available.add(new ObservedChannel(device.deviceId(), channel));
        StringBuilder response = new StringBuilder("<bts version=\"1.0\"><cmd>inquire_resp</cmd><list count=\"")
@@ -689,29 +567,12 @@
            ChannelState channel = observed.channel();
            DeviceState device = requireDevice(observed.deviceId());
            BtsDeviceProfile profile = profileFor(device);
            boolean working = channel.workStatus() == DeviceWorkStatus.WORKING;
            double wave = Math.sin((tick + channel.channelId()) * 0.7d);
            double current = working ? clamp(profile.currentMaxA().doubleValue() * (0.18d + wave * 0.02d),
                    profile.currentMinA().doubleValue(), profile.currentMaxA().doubleValue()) : 0d;
            double voltage = clamp(3.7d + wave * 0.01d, profile.voltageMinV().doubleValue(),
                    profile.voltageMaxV().doubleValue());
            String key = historyKey(profile.devtype(), observed.deviceId(), profile.subDeviceId(), channel.channelId());
            BtsPlaybackState playback = playbackState(key, Instant.now());
            double capacity = working ? latestCapacity(key) : 0d;
            double energy = capacity * voltage;
            if (working)
            {
                playback = advanceBtsPlayback(key, profile, playback, current, voltage);
                BtsHistoryRecord latest = latestRecord(key);
                if (latest != null)
                {
                    capacity = latest.capacityAh();
                    energy = latest.energyWh();
                }
            }
            int cycleNo = playback.cycleNo();
            int stepNo = playback.stepNo();
            String stepType = playback.stepType();
            CabinetRealtime measurement = cabinetMeasurement(new ChannelIdentity(ADAPTER_CODE, profile.devtype(),
                    device.deviceId(), profile.subDeviceId(), channel.channelId()));
            double current = measurement.currentA(), voltage = measurement.voltageV();
            double capacity = measurement.capacityAh(), energy = measurement.energyWh();
            int cycleNo = measurement.cycleNo(), stepNo = measurement.stepNo();
            String stepType = measurement.stepType();
            response.append("<inquire dev=\"").append(profile.devtype()).append('-').append(observed.deviceId())
                    .append('-').append(profile.subDeviceId()).append('-').append(channel.channelId())
                    .append("-0\" cycle_id=\"").append(cycleNo).append("\" step_id=\"").append(stepNo)
@@ -720,251 +581,13 @@
                    .append("\" current=\"").append(decimal(current)).append("\" voltage=\"")
                    .append(decimal(voltage)).append("\" capacity=\"").append(decimal(capacity))
                    .append("\" energy=\"").append(decimal(energy)).append("\" totaltime=\"")
                    .append(working ? tick : 0).append("\" relativetime=\"").append(working ? tick : 0)
                    .append("\" open_or_close=\"0\" log_code=\"0\"/>");
                    .append(0).append("\" relativetime=\"").append(0)
                    .append("\" open_or_close=\"0\" log_code=\"0\">")
                    // 模拟器返回当前设备方案,供统一观测模型展示;不代表真实 BTS 的完整方案字段。
                    .append("<plan><step step_no=\"").append(stepNo).append("\" step_type=\"")
                    .append(stepType).append("\" name=\"").append(stepType).append("\"/></plan></inquire>");
        }
        return response.append("</list></bts>").toString();
    }
    private synchronized String historyAvailabilityResponse(String request)
    {
        java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("<chl\\s+([^>]*)/?>").matcher(request);
        StringBuilder response = new StringBuilder("<bts version=\"1.0\"><cmd>inquiredf_resp</cmd><list>");
        while (matcher.find())
        {
            String attrs = matcher.group(1);
            int devtype = Integer.parseInt(attribute(attrs, "devtype"));
            int devid = Integer.parseInt(attribute(attrs, "devid"));
            int subdevid = Integer.parseInt(attribute(attrs, "subdevid"));
            int chlid = Integer.parseInt(attribute(attrs, "chlid"));
            int count = btsHistoryFor(historyKey(devtype, devid, subdevid, chlid)).size();
            response.append("<chl devtype=\"").append(devtype).append("\" devid=\"").append(devid)
                    .append("\" subdevid=\"").append(subdevid).append("\" chlid=\"").append(chlid)
                    .append("\" testid=\"SIM-").append(devid).append('-').append(chlid).append("\" count=\"")
                    .append(count).append("\">true</chl>");
        }
        return response.append("</list></bts>").toString();
    }
    private synchronized String historyResponse(String request)
    {
        int devtype = Integer.parseInt(attribute(request, "devtype"));
        int devid = Integer.parseInt(attribute(request, "devid"));
        int subdevid = Integer.parseInt(attribute(request, "subdevid"));
        int chlid = Integer.parseInt(attribute(request, "chlid"));
        String testId = attribute(request, "testid");
        long start = Long.parseLong(attribute(request, "startpos"));
        int count = Integer.parseInt(attribute(request, "count"));
        List<BtsHistoryRecord> history = btsHistoryFor(historyKey(devtype, devid, subdevid, chlid));
        int from = (int) Math.min(Math.max(0L, start - 1L), history.size());
        int to = Math.min(history.size(), from + Math.max(1, count));
        StringBuilder response = new StringBuilder("<bts version=\"1.0\"><cmd>download_resp</cmd><download devtype=\"")
                .append(devtype).append("\" devid=\"").append(devid).append("\" subdevid=\"").append(subdevid)
                .append("\" chlid=\"").append(chlid).append("\" testid=\"").append(testId)
                .append("\"><list>");
        for (BtsHistoryRecord item : history.subList(from, to))
            response.append("<data seqid=\"").append(item.sequenceId()).append("\" stepid=\"").append(item.stepNo())
                    .append("\" cycleid=\"").append(item.cycleNo()).append("\" steptype=\"").append(item.stepType()).append("\" atime=\"")
                    .append(BTS_HISTORY_TIME.format(item.deviceTime())).append("\" testtime=\"60000\" volt=\"")
                    .append(decimal(item.voltageV())).append("\" curr=\"").append(decimal(item.currentA()))
                    .append("\" cap=\"").append(decimal(item.capacityAh())).append("\" eng=\"").append(decimal(item.energyWh())).append("\"/>");
        return response.append("</list></download></bts>").toString();
    }
    private String attribute(String xml, String name)
    {
        java.util.regex.Matcher matcher = java.util.regex.Pattern.compile(name + "=\\\"([^\\\"]+)\\\"").matcher(xml);
        if (!matcher.find()) throw new IllegalArgumentException("Missing simulator attribute: " + name);
        return matcher.group(1);
    }
    private String historyKey(int devtype, int devid, int subdevid, int chlid)
    {
        return devtype + ":" + devid + ":" + subdevid + ":" + chlid;
    }
    private void restoreBtsHistory()
    {
        for (DeviceState device : state.devices())
        {
            BtsDeviceProfile profile = profileFor(device);
            for (ChannelState channel : device.channels())
            {
                String key = historyKey(profile.devtype(), device.deviceId(), profile.subDeviceId(), channel.channelId());
                ArrayList<BtsHistoryRecord> restored = new ArrayList<>();
                long offset = 0;
                while (true)
                {
                    var page = stateStore.loadBtsHistoryPage(key, offset, BtsSimulatorStateStore.MAX_HISTORY_BATCH_SIZE);
                    restored.addAll(page.records().stream().map(this::normalizeHistoryRecord).toList());
                    if (!page.hasMore()) break;
                    if (page.nextOffset() <= offset) throw new IllegalStateException("Invalid BTS history page");
                    offset = page.nextOffset();
                }
                if (!restored.isEmpty()) btsHistory.put(key, restored);
            }
        }
    }
    private List<BtsHistoryRecord> btsHistoryFor(String key)
    {
        return btsHistory.getOrDefault(key, List.of());
    }
    private BtsHistoryRecord normalizeHistoryRecord(BtsHistoryRecord item)
    {
        return item.stepName() == null || item.stepName().isBlank()
                ? new BtsHistoryRecord(item.sequenceId(), item.deviceTime(), item.currentA(), item.voltageV(),
                        item.capacityAh(), item.energyWh()) : item;
    }
    private String btsKey(DeviceState device, int channelId)
    {
        BtsDeviceProfile profile = profileFor(device);
        return historyKey(profile.devtype(), device.deviceId(), profile.subDeviceId(), channelId);
    }
    private void requireChannel(DeviceState device, int channelId)
    {
        if (channelId < 1 || channelId > device.channels().size())
            throw new IllegalArgumentException("channelId must be between 1 and " + device.channels().size());
    }
    private BtsPlaybackState playbackState(String key, Instant now)
    {
        BtsPlaybackState stored = stateStore.loadBtsPlaybackState(key);
        if (stored != null) return stored;
        List<BtsHistoryRecord> records = btsHistoryFor(key);
        long next = records.stream().mapToLong(BtsHistoryRecord::sequenceId).max().orElse(0L) + 1L;
        BtsExcelFixture.FixtureRecord first = BtsExcelFixture.record(1);
        BtsPlaybackState created = new BtsPlaybackState(true, 1, first.cycleNo(), first.stepNo(), first.stepName(), first.stepType(),
                now.minusSeconds(SIMULATED_RECORD_INTERVAL_SECONDS), now, next, false);
        stateStore.saveBtsPlaybackState(key, created);
        return created;
    }
    private BtsHistoryRecord latestRecord(String key)
    {
        List<BtsHistoryRecord> records = btsHistoryFor(key);
        return records.isEmpty() ? null : records.get(records.size() - 1);
    }
    private double latestCapacity(String key)
    {
        BtsHistoryRecord latest = latestRecord(key);
        return latest == null ? 0d : latest.capacityAh();
    }
    private BtsPlaybackState advanceBtsPlayback(String key, BtsDeviceProfile profile, BtsPlaybackState current,
            double currentA, double voltageV)
    {
        return advanceBtsPlayback(key, profile, current, currentA, voltageV, Instant.now());
    }
    private BtsPlaybackState advanceBtsPlayback(String key, BtsDeviceProfile profile, BtsPlaybackState current,
            double currentA, double voltageV, Instant now)
    {
        if (!current.playing() || current.naturallyFinished()) return current;
        long elapsedMillis = Math.max(0L, java.time.Duration.between(current.wallClockAt(), now).toMillis())
                * current.speedMultiplier();
        List<BtsHistoryRecord> history = new ArrayList<>(btsHistoryFor(key));
        List<BtsHistoryRecord> appended = new ArrayList<>();
        Instant simulated = current.simulatedAt();
        long sequence = current.nextSequence();
        if (sequence < 1) sequence = 1;
        if (sequence > BtsExcelFixture.recordCount() + 1L)
            sequence = BtsExcelFixture.recordCount() + 1L;
        long lastOffset = sequence == 1 ? -SIMULATED_RECORD_INTERVAL_SECONDS
                : BtsExcelFixture.record(sequence - 1).offsetSeconds();
        long budgetMillis = elapsedMillis;
        while (sequence <= BtsExcelFixture.recordCount())
        {
            BtsExcelFixture.FixtureRecord fixture = BtsExcelFixture.record(sequence);
            long deltaSeconds = fixture.offsetSeconds() - lastOffset;
            long deltaMillis = Math.max(1L, deltaSeconds * 1000L);
            boolean firstRecord = history.isEmpty() && sequence == 1L;
            if (!firstRecord && budgetMillis < deltaMillis) break;
            budgetMillis = Math.max(0L, budgetMillis - deltaMillis);
            simulated = simulated.plusSeconds(deltaSeconds);
            BtsHistoryRecord record = new BtsHistoryRecord(fixture.sequenceId(), simulated, fixture.currentA(), fixture.voltageV(),
                    fixture.capacityAh(), fixture.energyWh(), fixture.cycleNo(), fixture.stepNo(), fixture.stepName(), fixture.stepType());
            history.add(record);
            appended.add(record);
            lastOffset = fixture.offsetSeconds();
            sequence++;
        }
        if (appended.isEmpty()) return current;
        if (history.size() > BtsSimulatorStateStore.MAX_HISTORY_RECORDS)
            history = new ArrayList<>(history.subList(history.size() - (int) BtsSimulatorStateStore.MAX_HISTORY_RECORDS,
                    history.size()));
        BtsExcelFixture.FixtureRecord latest = BtsExcelFixture.record(sequence - 1L);
        boolean finished = sequence > BtsExcelFixture.recordCount();
        BtsPlaybackState next = new BtsPlaybackState(!finished && current.playing(), current.speedMultiplier(),
                latest.cycleNo(), latest.stepNo(), latest.stepName(), latest.stepType(), simulated, now, sequence, finished);
        stateStore.saveBtsPlaybackAndAppendHistory(key, next, appended);
        btsHistory.put(key, history);
        return next;
    }
    private BtsPlaybackState defaultPlayback(Instant now)
    {
        BtsExcelFixture.FixtureRecord first = BtsExcelFixture.record(1);
        return new BtsPlaybackState(true, 1, first.cycleNo(), first.stepNo(), first.stepName(), first.stepType(),
                now.minusSeconds(SIMULATED_RECORD_INTERVAL_SECONDS), now, 1, false);
    }
    private BtsExcelFixture.FixtureRecord nextFixtureStep(int cycleNo, int stepNo)
    {
        return BtsExcelFixture.records().stream()
                .filter(item -> item.cycleNo() > cycleNo || (item.cycleNo() == cycleNo && item.stepNo() > stepNo))
                .findFirst().orElse(null);
    }
    private void savePlayback(String key, BtsPlaybackState playback)
    {
        stateStore.saveBtsPlaybackState(key, playback);
    }
    public record BtsStepRecord(int cycleNo, int stepNo, Integer cyclePosition, String stepName, String stepType, long recordCount,
            Instant startedAt, Instant endedAt, double durationSeconds, double capacityAh, double energyWh,
            double startVoltageV, double endVoltageV, double chargePeakVoltageV, double dischargeValleyVoltageV) { }
    public record BtsCycleRecord(String recordNo, int cycleNo, long recordCount, Instant startedAt, Instant endedAt,
            double chargeCapacityAh, double dischargeCapacityAh, double chargeEnergyWh, double dischargeEnergyWh,
            double efficiency) { }
    public record BtsTestPlanRow(String rowType, int stepNo, Integer cyclePosition, String stepName, double durationSeconds,
            Double voltageV, String executionStatus, Integer cycleCount, Long recordCount) { }
    public record BtsTestSnapshot(int currentCycleNo, int currentStepNo, int plannedCycleCount,
            List<BtsTestPlanRow> rows) { }
    private static double efficiency(double chargeCapacityAh, double dischargeCapacityAh)
    {
        return chargeCapacityAh > 0d ? dischargeCapacityAh / chargeCapacityAh * 100d : 0d;
    }
    public record PlaybackSnapshot(int deviceId, int channelId, BtsPlaybackState playback,
            List<BtsHistoryRecord> records, List<BtsStepRecord> steps, List<BtsCycleRecord> cycles) { }
    private static final class BtsStepAccumulator
    {
        private final int cycle; private final int step; private final int position; private final String name; private final String type;
        private long count; private Instant first; private Instant last; private double duration; private double capacity; private double energy;
        private double startVoltage; private double endVoltage; private double chargePeak = Double.NEGATIVE_INFINITY; private double dischargeValley = Double.POSITIVE_INFINITY;
        BtsStepAccumulator(BtsHistoryRecord first, int position) { cycle = first.cycleNo(); step = first.stepNo(); this.position = position; name = first.stepName(); type = first.stepType(); }
        void add(BtsHistoryRecord item) { count++; if (first == null) { first = item.deviceTime(); startVoltage = item.voltageV(); } last = item.deviceTime(); endVoltage = item.voltageV(); duration = first == null ? 0 : Math.max(0, java.time.Duration.between(first, last).toMillis() / 1000d); capacity = item.capacityAh(); energy = item.energyWh(); if (item.currentA() >= 0) chargePeak = Math.max(chargePeak, item.voltageV()); else dischargeValley = Math.min(dischargeValley, item.voltageV()); }
        BtsStepRecord finish() { return new BtsStepRecord(cycle, step, position, name, type, count, first, last, duration, capacity, energy, startVoltage, endVoltage, chargePeak == Double.NEGATIVE_INFINITY ? 0 : chargePeak, dischargeValley == Double.POSITIVE_INFINITY ? 0 : dischargeValley); }
    }
    private static final class BtsCycleAccumulator
    {
        private final int cycle; private long count; private Instant first; private Instant last;
        private double maximumCapacity = Double.NEGATIVE_INFINITY; private double minimumCapacity = Double.POSITIVE_INFINITY;
        private double chargeEnergy; private double dischargeEnergy;
        BtsCycleAccumulator(BtsHistoryRecord first) { cycle = first.cycleNo(); }
        void add(BtsHistoryRecord item) { count++; if (first == null) first = item.deviceTime(); last = item.deviceTime(); maximumCapacity = Math.max(maximumCapacity, item.capacityAh()); minimumCapacity = Math.min(minimumCapacity, item.capacityAh()); if (item.currentA() >= 0) chargeEnergy = Math.max(chargeEnergy, item.energyWh()); else dischargeEnergy = Math.max(dischargeEnergy, Math.abs(item.energyWh())); }
        BtsCycleRecord finish() { double charge = Math.max(0d, maximumCapacity); double discharge = Math.max(0d, maximumCapacity - minimumCapacity); return new BtsCycleRecord("", cycle, count, first, last, charge, discharge, chargeEnergy, dischargeEnergy, efficiency(charge, discharge)); }
    }
    private java.util.Set<String> requestedChannels(String request)
@@ -982,7 +605,10 @@
                {
                    String devid = reader.getAttributeValue(null, "devid");
                    String chlid = reader.getAttributeValue(null, "chlid");
                    if (devid != null && chlid != null) channels.add(devid + ":" + chlid);
                    String devtype = reader.getAttributeValue(null, "devtype");
                    String subdevid = reader.getAttributeValue(null, "subdevid");
                    if (devtype != null && devid != null && subdevid != null && chlid != null)
                        channels.add(devtype + ":" + devid + ":" + subdevid + ":" + chlid);
                }
            }
            return channels;
@@ -1054,6 +680,7 @@
            case WORKING -> "working";
            case PAUSED -> "pause";
            case FINISHED -> "finish";
            case PROTECTED -> "protect";
            default -> throw new IllegalArgumentException("Unsupported simulator work status: " + status);
        };
    }
@@ -1130,15 +757,11 @@
    private void persist(SimulatorState nextState)
    {
        // 写穿失败时保留当前内存副本,避免控制页显示未持久化状态。
        stateStore.save(nextState);
        state = nextState;
    }
    private void persist(SimulatorState nextState, List<CollectorHistoryAppend> historyAppends)
    {
        // 主状态与原始历史批次必须一起成功,内存序号才能向前推进。
        stateStore.save(nextState, historyAppends);
        state = nextState;
        SimulatorState configured = new SimulatorState(nextState.version(), nextState.devices(), nextState.collectors(),
                nextState.nextDeviceId(), nextState.nextCollectorId(), nextState.tombstones(),
                state.softwareAccessible(), state.observationSequence());
        stateStore.save(configured);
        state = configured;
    }
    private String simulatorMac(int devtype, int devid)
@@ -1155,8 +778,9 @@
    private void requireControllableStatus(DeviceWorkStatus status)
    {
        if (status == null || (status != DeviceWorkStatus.STOPPED && status != DeviceWorkStatus.WORKING
                && status != DeviceWorkStatus.PAUSED && status != DeviceWorkStatus.FINISHED))
            throw new IllegalArgumentException("Simulator work status must be STOPPED, WORKING, PAUSED or FINISHED");
                && status != DeviceWorkStatus.PAUSED && status != DeviceWorkStatus.FINISHED
                && status != DeviceWorkStatus.PROTECTED && status != DeviceWorkStatus.UNOBSERVABLE))
            throw new IllegalArgumentException("Unsupported simulator work status");
    }
    private void validateRestoredState()
@@ -1189,7 +813,7 @@
                    || !isIpv4(collector.ip()) || !ips.add(collector.ip())
                    || collector.channelCount() < 1 || collector.channelCount() > MAX_CHANNEL_COUNT)
                throw new IllegalStateException("Invalid BTS simulator collector state in Redis");
            DevelopmentCollectorSimulation.validate(collector, stateStore);
            DevelopmentCollectorSimulation.validate(collector);
            maximumCollectorId = Math.max(maximumCollectorId, collector.collectorId());
        }
        java.util.Set<String> tombstoneIds = new java.util.HashSet<>();
@@ -1203,15 +827,22 @@
    }
    @Override
    public synchronized void close() throws Exception
    public synchronized void close()
    {
        running = false;
        simulator.close();
        try
        {
            simulator.close();
        }
        catch (Exception exception)
        {
            throw new IllegalStateException("关闭开发模拟器失败", exception);
        }
    }
    private record ObservedChannel(int deviceId, ChannelState channel) { }
    public record StateSnapshot(boolean running, String listenAddress, List<DeviceSnapshot> devices,
    public record StateSnapshot(boolean running, boolean softwareAccessible, String listenAddress, List<DeviceSnapshot> devices,
            List<CollectorSnapshot> collectors, List<RecentRequest> recentRequests) { }
    public record DeviceSnapshot(int deviceId, String ip, boolean online, BtsDeviceProfile profile,
@@ -1221,8 +852,7 @@
    public record CollectorSnapshot(int collectorId, String deviceCode, String ip, String mac, String name,
            String manufacturer, String model, int channelCount, boolean online, int sampleIntervalMs,
            List<CollectorChannelState> channels, CollectorHistoryFaults historyFaults, long historyRecordCount,
            List<CollectorRealtimeMeasurement> latestMeasurements)
            List<CollectorChannelState> channels, List<CollectorRealtimeMeasurement> latestMeasurements)
    {
        public CollectorSnapshot
        {