diff --git a/javaTest/README.md b/javaTest/README.md index 1516c1c..23dad8e 100644 --- a/javaTest/README.md +++ b/javaTest/README.md @@ -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 diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationApplication.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationApplication.java new file mode 100644 index 0000000..2fe8fa7 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationApplication.java @@ -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 [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()); + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationReportWriter.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationReportWriter.java new file mode 100644 index 0000000..03cec47 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationReportWriter.java @@ -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 summary(HistoricalQueueDataset dataset, + HistoricalDispatchSimulationResult result) { + Map value = new LinkedHashMap(); + 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 timeStats(List samples) { + SimulationMetrics metrics = new SimulationMetrics(); + Map stats = new LinkedHashMap(); + 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 waitingSamples(HistoricalDispatchSimulationResult result) { + List values = new ArrayList(); + for (SimulationTaskResult task : result.getTaskResults()) { + if (task.getWaitingMillis() != null) values.add(task.getWaitingMillis()); + } + return values; + } + + private List completionSamples(HistoricalDispatchSimulationResult result) { + List values = new ArrayList(); + for (SimulationTaskResult task : result.getTaskResults()) { + if (task.getCompletionMillis() != null) values.add(task.getCompletionMillis()); + } + return values; + } + + private List historicalSamples(HistoricalDispatchSimulationResult result) { + List values = new ArrayList(); + for (SimulationTaskResult task : result.getTaskResults()) { + Long duration = historicalDuration(task); + if (duration != null) values.add(duration); + } + return values; + } + + private List durationDifferenceSamples(HistoricalDispatchSimulationResult result) { + List values = new ArrayList(); + 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 aggregateReasons(HistoricalDispatchSimulationResult result, + boolean hold) { + Map values = new LinkedHashMap(); + for (SimulationTaskResult task : result.getTaskResults()) { + Map source = hold ? task.getHoldReasons() : task.getRetryReasons(); + for (Map.Entry entry : source.entrySet()) { + values.put(entry.getKey(), values.containsKey(entry.getKey()) + ? values.get(entry.getKey()) + entry.getValue() : entry.getValue()); + } + } + return values; + } + + private Map failureReasons(HistoricalDispatchSimulationResult result) { + Map values = new LinkedHashMap(); + 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 invariantSummary(HistoricalDispatchSimulationResult result) { + SimulationInvariantSummary source = result.getInvariants(); + Map values = new LinkedHashMap(); + 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 names) throws IOException { + List ordered = new ArrayList(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; } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationMetrics.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationMetrics.java new file mode 100644 index 0000000..03d3fa6 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationMetrics.java @@ -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 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 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 ordered = new ArrayList(samples); + Collections.sort(ordered); + int index = (int) Math.ceil(percentile * ordered.size()) - 1; + return ordered.get(index); + } + + private void validate(List samples) { + if (samples == null || samples.contains(null)) { + throw new IllegalArgumentException("non-null samples are required"); + } + } +} diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationReportWriterTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationReportWriterTest.java new file mode 100644 index 0000000..e5c2a35 --- /dev/null +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulationReportWriterTest.java @@ -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 manifest = Files.readAllLines(output.resolve("manifest.sha256"), + StandardCharsets.UTF_8); + assertEquals(4, manifest.size()); + } +} diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/SimulationMetricsTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/SimulationMetricsTest.java new file mode 100644 index 0000000..c59ba89 --- /dev/null +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/SimulationMetricsTest.java @@ -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)); + } +}