feat(javaTest): publish historical simulation evidence

This commit is contained in:
zgw
2026-07-31 12:44:59 +08:00
parent e5b988aec8
commit a060c6255a
6 changed files with 458 additions and 1 deletions

View File

@@ -60,7 +60,7 @@ mvn -Dtest=HistoricalQueueDatabaseReplayTest test
生成真实历史离线回放证据:
```bash
mvn exec:java \
mvn compile exec:java \
-Dexec.mainClass=com.ft546.wcs.shadow.history.HistoricalReplayApplication \
-Dexec.args="outputs/wcs-shadow-history-replay-local"
```
@@ -71,6 +71,27 @@ mvn exec:java \
> 这些结果是“真实历史队列离线回放”,不是现场 SHADOW 验收。历史队列表没有保存
> 当时每一轮 PLC 快照、资源账本或 LEGACY 决策,因此不能把最终完成状态当作算法正确率。
## 运行真实历史负载全链路调度仿真
240 条历史记录按 `create_date` 生成到达事件,默认按 `100x` 加速。调度中心重新选车和
规划路径,经过 SHADOW 交通裁决、模拟 PLC/ACK、逐节点移动、资源释放、完成归档和
幂等 WMS 回调。历史 `agv_id/path_info/finish_date/完成状态` 仅用于对照。
```bash
mvn compile exec:java \
-Dexec.mainClass=com.ft546.wcs.shadow.history.simulation.HistoricalDispatchSimulationApplication \
-Dexec.args="outputs/wcs-history-driven-dispatch-simulation-local 100"
```
第二个参数只允许 `10` 或 `100`。输出包括 `summary.json`、`task-results.csv`、
`tick-events.csv`、`invariant-results.json` 和 `manifest.sha256`,回答完成/等待/失败、
每车完成数、车辆和路径差异、资源安全、平均/P95/P99、HOLD/重试原因、历史耗时差异、
归档和 WMS 幂等情况。
> 这是“真实历史负载驱动纯内存仿真”,不是现场 SHADOW、HIL 或 ACTIVE 验收。
> 历史表不含独立的仿真起始时刻车辆位置/状态快照,因此每辆车以其最早历史任务的
> 起点初始化;选车距离、冲突与吞吐指标用于算法仿真对比,不能解释为现场轨迹复现。
## 运行控制台模拟
```bash

View File

@@ -0,0 +1,44 @@
package com.ft546.wcs.shadow.history.simulation;
import com.ft546.wcs.shadow.history.HistoricalQueueDataset;
import com.ft546.wcs.shadow.history.HistoricalQueueLoader;
import java.nio.file.Path;
import java.nio.file.Paths;
public final class HistoricalDispatchSimulationApplication {
private HistoricalDispatchSimulationApplication() { }
public static void main(String[] args) throws Exception {
if (args.length < 1 || args.length > 2) {
throw new IllegalArgumentException(
"usage: HistoricalDispatchSimulationApplication <outputDir> [10|100]");
}
int acceleration = args.length == 2 ? Integer.parseInt(args[1]) : 100;
SimulationConfig defaults = SimulationConfig.defaults();
SimulationConfig config = new SimulationConfig(acceleration,
defaults.getAgingIntervalMillis(), defaults.getRetryLimit(),
defaults.getNoProgressLimit(), defaults.getTickLimit());
ClassLoader classLoader = HistoricalDispatchSimulationApplication.class.getClassLoader();
HistoricalQueueLoader loader = new HistoricalQueueLoader();
HistoricalQueueDataset dataset = classLoader.getResource("shadow/history/manifest.json") != null
? loader.load(classLoader, "shadow/history")
: loader.load(Paths.get("src/test/resources/shadow/history"));
HistoricalSimulationInput input = new HistoricalSimulationInputFactory().create(dataset);
DeterministicFaultPlan.Builder faults = DeterministicFaultPlan.builder();
for (int i = 0; i < input.getTasks().size(); i += 37) {
faults.plcFailure(input.getTasks().get(i).getSimulationTaskId(), 1,
SimulatedPlcGateway.PlcReason.SEND_FAILURE);
}
HistoricalDispatchSimulationResult result = new HistoricalDispatchSimulator().simulate(
input, config, faults.build());
Path output = Paths.get(args[0]);
new HistoricalDispatchSimulationReportWriter().write(output, dataset, result);
System.out.println("HISTORY-DISPATCH-SIMULATION input=" + result.getTaskResults().size()
+ " completed=" + result.getCompletedCount()
+ " waiting=" + result.getWaitingCount()
+ " failed=" + result.getFailedCount()
+ " deadlocks=" + result.getInvariants().getDeadlocks()
+ " output=" + output.toAbsolutePath());
}
}

View File

@@ -0,0 +1,266 @@
package com.ft546.wcs.shadow.history.simulation;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.ft546.wcs.shadow.history.HistoricalQueueDataset;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class HistoricalDispatchSimulationReportWriter {
private static final DateTimeFormatter TIME =
DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss");
private static final ObjectMapper MAPPER = new ObjectMapper()
.enable(SerializationFeature.INDENT_OUTPUT);
public void write(Path outputDir, HistoricalQueueDataset dataset,
HistoricalDispatchSimulationResult result) throws IOException {
if (outputDir == null || dataset == null || result == null) {
throw new IllegalArgumentException("output, dataset and result are required");
}
Files.createDirectories(outputDir);
MAPPER.writeValue(outputDir.resolve("summary.json").toFile(), summary(dataset, result));
writeTaskCsv(outputDir.resolve("task-results.csv"), result);
writeEventCsv(outputDir.resolve("tick-events.csv"), result);
MAPPER.writeValue(outputDir.resolve("invariant-results.json").toFile(),
invariantSummary(result));
writeManifest(outputDir, Arrays.asList("invariant-results.json", "summary.json",
"task-results.csv", "tick-events.csv"));
}
private Map<String, Object> summary(HistoricalQueueDataset dataset,
HistoricalDispatchSimulationResult result) {
Map<String, Object> value = new LinkedHashMap<String, Object>();
value.put("mode", "REAL_HISTORY_DRIVEN_DISPATCH_SIMULATION");
value.put("boundary", "真实历史负载驱动纯内存仿真,不是现场 SHADOW/HIL/ACTIVE 验收");
value.put("database", dataset.getDatabase());
value.put("sourceTable", dataset.getSourceTable());
value.put("sourceRowCount", dataset.getFullScanTotalRows());
value.put("inputCount", result.getTaskResults().size());
value.put("acceleration", result.getAcceleration());
value.put("completedCount", result.getCompletedCount());
value.put("waitingCount", result.getWaitingCount());
value.put("failedCount", result.getFailedCount());
value.put("completedByVehicle", result.completedByVehicle());
value.put("reassignmentCount", result.getReassignmentCount());
value.put("replannedPathDifferenceCount", result.getReplannedCount());
value.put("tickCount", result.getTickCount());
value.put("waitingTime", timeStats(waitingSamples(result)));
value.put("completionTime", timeStats(completionSamples(result)));
value.put("historicalDuration", timeStats(historicalSamples(result)));
value.put("simulationMinusHistoricalDuration",
timeStats(durationDifferenceSamples(result)));
value.put("holdReasons", aggregateReasons(result, true));
value.put("retryReasons", aggregateReasons(result, false));
value.put("failureReasons", failureReasons(result));
value.put("archiveCount", result.getArchiveCount());
value.put("wmsDeliveryAttempts", result.getWmsDeliveryAttempts());
value.put("wmsBusinessEffects", result.getWmsBusinessEffects());
value.put("archiveComplete", result.getArchiveCount() == result.getCompletedCount());
value.put("wmsIdempotent", result.getWmsBusinessEffects() == result.getCompletedCount()
&& result.getWmsDeliveryAttempts() == result.getCompletedCount() * 2);
value.put("invariants", invariantSummary(result));
return value;
}
private Map<String, Object> timeStats(List<Long> samples) {
SimulationMetrics metrics = new SimulationMetrics();
Map<String, Object> stats = new LinkedHashMap<String, Object>();
stats.put("sampleCount", samples.size());
stats.put("averageMillis", metrics.average(samples));
stats.put("p95Millis", metrics.percentile(samples, 0.95));
stats.put("p99Millis", metrics.percentile(samples, 0.99));
return stats;
}
private List<Long> waitingSamples(HistoricalDispatchSimulationResult result) {
List<Long> values = new ArrayList<Long>();
for (SimulationTaskResult task : result.getTaskResults()) {
if (task.getWaitingMillis() != null) values.add(task.getWaitingMillis());
}
return values;
}
private List<Long> completionSamples(HistoricalDispatchSimulationResult result) {
List<Long> values = new ArrayList<Long>();
for (SimulationTaskResult task : result.getTaskResults()) {
if (task.getCompletionMillis() != null) values.add(task.getCompletionMillis());
}
return values;
}
private List<Long> historicalSamples(HistoricalDispatchSimulationResult result) {
List<Long> values = new ArrayList<Long>();
for (SimulationTaskResult task : result.getTaskResults()) {
Long duration = historicalDuration(task);
if (duration != null) values.add(duration);
}
return values;
}
private List<Long> durationDifferenceSamples(HistoricalDispatchSimulationResult result) {
List<Long> values = new ArrayList<Long>();
for (SimulationTaskResult task : result.getTaskResults()) {
Long historical = historicalDuration(task);
Long simulated = task.getCompletionMillis();
if (historical != null && simulated != null) {
values.add(simulated * result.getAcceleration() - historical);
}
}
return values;
}
private Map<String, Integer> aggregateReasons(HistoricalDispatchSimulationResult result,
boolean hold) {
Map<String, Integer> values = new LinkedHashMap<String, Integer>();
for (SimulationTaskResult task : result.getTaskResults()) {
Map<String, Integer> source = hold ? task.getHoldReasons() : task.getRetryReasons();
for (Map.Entry<String, Integer> entry : source.entrySet()) {
values.put(entry.getKey(), values.containsKey(entry.getKey())
? values.get(entry.getKey()) + entry.getValue() : entry.getValue());
}
}
return values;
}
private Map<String, Integer> failureReasons(HistoricalDispatchSimulationResult result) {
Map<String, Integer> values = new LinkedHashMap<String, Integer>();
for (SimulationTaskResult task : result.getTaskResults()) {
if (task.getStatus() != SimulationTaskStatus.FAILED) continue;
String reason = task.getReason();
values.put(reason, values.containsKey(reason) ? values.get(reason) + 1 : 1);
}
return values;
}
private Map<String, Object> invariantSummary(HistoricalDispatchSimulationResult result) {
SimulationInvariantSummary source = result.getInvariants();
Map<String, Object> values = new LinkedHashMap<String, Object>();
values.put("capacityViolations", source.getCapacityViolations());
values.put("nodeDoubleOccupancyViolations", source.getNodeDoubleOccupancyViolations());
values.put("oppositeEdgeViolations", source.getOppositeEdgeViolations());
values.put("duplicateArchiveAttempts", source.getDuplicateArchiveAttempts());
values.put("duplicateWmsBusinessEffects", source.getDuplicateWmsBusinessEffects());
values.put("deadlocks", source.getDeadlocks());
values.put("totalSafetyViolations", source.totalSafetyViolations());
return values;
}
private void writeTaskCsv(Path path, HistoricalDispatchSimulationResult result)
throws IOException {
StringBuilder csv = new StringBuilder("\ufeff");
row(csv, "simulation_task_id", "task_no", "guid", "task_type", "priority",
"original_start_site", "original_end_site", "historical_agv", "simulated_agv",
"historical_path", "planned_path", "assigned_vehicles", "status", "reason",
"arrival_ms", "first_assigned_ms", "completed_ms", "waiting_ms",
"historical_duration_ms", "simulation_duration_ms", "scaled_source_duration_ms",
"duration_difference_ms", "hold_count", "protocol_attempts", "retry_count",
"hold_reasons", "retry_reasons", "vehicle_changed", "path_changed");
for (SimulationTaskResult task : result.getTaskResults()) {
Long historical = historicalDuration(task);
Long simulated = task.getCompletionMillis();
Long scaled = simulated == null ? null : simulated * result.getAcceleration();
Long difference = historical == null || scaled == null ? null : scaled - historical;
String historicalPath = trimTrailingCommas(text(task.getRecord().getPathInfo()));
String plannedPath = String.join(",", task.getPlannedPath());
row(csv, task.getSimulationTaskId(), task.getRecord().getTaskNo(),
task.getRecord().getGuid(), task.getRecord().getTaskType(),
task.getRecord().getPriority(), task.getRecord().getStartSite(),
task.getRecord().getEndSite(), task.getRecord().getAgvId(),
task.getSimulatedVehicleId(), historicalPath, plannedPath,
String.join("|", task.getAssignedVehicles()), task.getStatus(), task.getReason(),
task.getArrivalMillis(), task.getFirstAssignedMillis(), task.getCompletedMillis(),
task.getWaitingMillis(), historical, simulated, scaled, difference,
task.getHoldCount(), task.getProtocolAttempts(), task.getRetryCount(),
task.getHoldReasons(), task.getRetryReasons(),
task.getSimulatedVehicleId() != null
&& !task.getSimulatedVehicleId().equals(task.getRecord().getAgvId()),
!historicalPath.equals(plannedPath));
}
Files.write(path, csv.toString().getBytes(StandardCharsets.UTF_8));
}
private void writeEventCsv(Path path, HistoricalDispatchSimulationResult result)
throws IOException {
StringBuilder csv = new StringBuilder("\ufeff");
row(csv, "tick", "simulation_ms", "event_type", "task_id", "vehicle_id",
"reason", "from_site", "to_site");
for (SimulationTickEvent event : result.getEvents()) {
row(csv, event.getTick(), event.getSimulationMillis(), event.getType(),
event.getTaskId(), event.getVehicleId(), event.getReason(),
event.getFromSite(), event.getToSite());
}
Files.write(path, csv.toString().getBytes(StandardCharsets.UTF_8));
}
private void row(StringBuilder target, Object... values) {
for (int i = 0; i < values.length; i++) {
if (i > 0) target.append(',');
target.append(csv(values[i]));
}
target.append("\r\n");
}
private String csv(Object value) {
if (value == null) return "";
String text = String.valueOf(value);
if (text.contains(",") || text.contains("\"") || text.contains("\r") || text.contains("\n")) {
return "\"" + text.replace("\"", "\"\"") + "\"";
}
return text;
}
private Long historicalDuration(SimulationTaskResult task) {
try {
LocalDateTime created = LocalDateTime.parse(task.getRecord().getCreateDate(), TIME);
LocalDateTime finished = LocalDateTime.parse(task.getRecord().getFinishDate(), TIME);
return Duration.between(created, finished).toMillis();
} catch (DateTimeParseException | NullPointerException ex) {
return null;
}
}
private void writeManifest(Path outputDir, List<String> names) throws IOException {
List<String> ordered = new ArrayList<String>(names);
Collections.sort(ordered);
StringBuilder manifest = new StringBuilder();
for (String name : ordered) {
manifest.append(sha256(Files.readAllBytes(outputDir.resolve(name))))
.append(" ").append(name).append('\n');
}
Files.write(outputDir.resolve("manifest.sha256"),
manifest.toString().getBytes(StandardCharsets.UTF_8));
}
private String sha256(byte[] bytes) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(bytes);
StringBuilder value = new StringBuilder();
for (byte item : digest) value.append(String.format("%02x", item & 0xff));
return value.toString();
} catch (Exception ex) {
throw new IllegalStateException("SHA-256 unavailable", ex);
}
}
private String trimTrailingCommas(String value) {
int end = value.length();
while (end > 0 && value.charAt(end - 1) == ',') end--;
return value.substring(0, end);
}
private String text(String value) { return value == null ? "" : value; }
}

View File

@@ -0,0 +1,33 @@
package com.ft546.wcs.shadow.history.simulation;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class SimulationMetrics {
public Double average(List<Long> samples) {
validate(samples);
if (samples.isEmpty()) return null;
double total = 0.0;
for (Long sample : samples) total += sample;
return total / samples.size();
}
public Long percentile(List<Long> samples, double percentile) {
validate(samples);
if (percentile <= 0.0 || percentile > 1.0) {
throw new IllegalArgumentException("percentile must be in (0, 1]");
}
if (samples.isEmpty()) return null;
List<Long> ordered = new ArrayList<Long>(samples);
Collections.sort(ordered);
int index = (int) Math.ceil(percentile * ordered.size()) - 1;
return ordered.get(index);
}
private void validate(List<Long> samples) {
if (samples == null || samples.contains(null)) {
throw new IllegalArgumentException("non-null samples are required");
}
}
}

View File

@@ -0,0 +1,64 @@
package com.ft546.wcs.shadow.history.simulation;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ft546.wcs.shadow.history.HistoricalQueueDataset;
import com.ft546.wcs.shadow.history.HistoricalQueueLoader;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static com.ft546.wcs.shadow.history.simulation.SimulatedPlcGateway.PlcReason.SEND_FAILURE;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class HistoricalDispatchSimulationReportWriterTest {
@TempDir Path output;
@Test
void writesAuditableMetricsTaskEventsInvariantsAndHashes() throws Exception {
HistoricalQueueDataset dataset = new HistoricalQueueLoader().load(
getClass().getClassLoader(), "shadow/history");
HistoricalSimulationInput input = new HistoricalSimulationInputFactory().create(dataset);
DeterministicFaultPlan.Builder faults = DeterministicFaultPlan.builder();
for (int i = 0; i < input.getTasks().size(); i += 37) {
faults.plcFailure(input.getTasks().get(i).getSimulationTaskId(), 1, SEND_FAILURE);
}
HistoricalDispatchSimulationResult result = new HistoricalDispatchSimulator().simulate(
input, SimulationConfig.defaults(), faults.build());
new HistoricalDispatchSimulationReportWriter().write(output, dataset, result);
for (String name : new String[]{"summary.json", "task-results.csv",
"tick-events.csv", "invariant-results.json", "manifest.sha256"}) {
assertTrue(Files.isRegularFile(output.resolve(name)), name);
}
JsonNode summary = new ObjectMapper().readTree(output.resolve("summary.json").toFile());
assertEquals("REAL_HISTORY_DRIVEN_DISPATCH_SIMULATION", summary.path("mode").asText());
assertEquals(240, summary.path("inputCount").asInt());
assertEquals(240, summary.path("completedCount").asInt()
+ summary.path("waitingCount").asInt() + summary.path("failedCount").asInt());
assertEquals(result.getArchiveCount(), summary.path("archiveCount").asInt());
assertEquals(result.getWmsBusinessEffects(), summary.path("wmsBusinessEffects").asInt());
assertTrue(summary.has("waitingTime"));
assertTrue(summary.has("completionTime"));
assertTrue(summary.has("historicalDuration"));
assertTrue(summary.has("holdReasons"));
assertTrue(summary.has("retryReasons"));
String taskCsv = new String(Files.readAllBytes(output.resolve("task-results.csv")),
StandardCharsets.UTF_8);
assertTrue(taskCsv.startsWith("\ufeffsimulation_task_id,task_no,guid"));
assertTrue(taskCsv.contains("historical_agv,simulated_agv"));
assertTrue(taskCsv.contains("historical_path,planned_path"));
assertTrue(taskCsv.contains("historical_duration_ms,simulation_duration_ms"));
assertTrue(taskCsv.contains("hold_reasons,retry_reasons"));
List<String> manifest = Files.readAllLines(output.resolve("manifest.sha256"),
StandardCharsets.UTF_8);
assertEquals(4, manifest.size());
}
}

View File

@@ -0,0 +1,29 @@
package com.ft546.wcs.shadow.history.simulation;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class SimulationMetricsTest {
@Test
void calculatesAverageAndNearestRankPercentilesFromLiteralSamples() {
SimulationMetrics metrics = new SimulationMetrics();
assertEquals(3.0, metrics.average(Arrays.asList(1L, 3L, 5L)));
assertEquals(5L, metrics.percentile(Arrays.asList(1L, 3L, 5L), 0.95));
assertEquals(5L, metrics.percentile(Arrays.asList(1L, 3L, 5L), 0.99));
assertEquals(3L, metrics.percentile(Arrays.asList(1L, 3L, 5L), 0.50));
}
@Test
void returnsNullForEmptySamplesInsteadOfInventingZero() {
SimulationMetrics metrics = new SimulationMetrics();
assertNull(metrics.average(Collections.emptyList()));
assertNull(metrics.percentile(Collections.emptyList(), 0.95));
}
}