From af0b52d6901fd5a88e93e1bdc57f6b4fff4e1583 Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Thu, 30 Jul 2026 17:44:10 +0800 Subject: [PATCH] feat: replay real FT546 queue history --- .gitattributes | 1 + javaTest/README.md | 23 ++++ javaTest/pom.xml | 3 +- .../wcs/shadow/history/HistoricalPath.java | 39 +++++++ .../history/HistoricalQueueDataset.java | 30 +++++ .../shadow/history/HistoricalQueueLoader.java | 103 ++++++++++++++++ .../history/HistoricalQueueNormalizer.java | 59 ++++++++++ .../shadow/history/HistoricalQueueRecord.java | 56 +++++++++ .../history/HistoricalReplayAnalyzer.java | 110 ++++++++++++++++++ .../history/HistoricalReplayApplication.java | 32 +++++ .../history/HistoricalReplayCategory.java | 9 ++ .../history/HistoricalReplayReportWriter.java | 110 ++++++++++++++++++ .../history/HistoricalReplayResult.java | 55 +++++++++ .../HistoricalQueueDatabaseReplayTest.java | 70 +++++++++++ .../wcs/shadow/HistoricalQueueLoaderTest.java | 33 ++++++ .../shadow/HistoricalQueueNormalizerTest.java | 52 +++++++++ .../shadow/HistoricalReplayAnalyzerTest.java | 84 +++++++++++++ 17 files changed, 868 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalPath.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueDataset.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueLoader.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueNormalizer.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueRecord.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayAnalyzer.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayApplication.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayCategory.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayReportWriter.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayResult.java create mode 100644 javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueDatabaseReplayTest.java create mode 100644 javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueLoaderTest.java create mode 100644 javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueNormalizerTest.java create mode 100644 javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalReplayAnalyzerTest.java diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4d74d4d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +javaTest/src/test/resources/shadow/history/*.json text eol=lf diff --git a/javaTest/README.md b/javaTest/README.md index 3b00813..1516c1c 100644 --- a/javaTest/README.md +++ b/javaTest/README.md @@ -37,6 +37,9 @@ - JSON 黄金决策事件、SHA-256 输入哈希和离线回放。 - 固定种子 `546001` 至 `546010` 的容量、无环和确定性不变量回归。 - `shadow-simulation-60.json` 提供 60 条独立模拟数据,覆盖 12 类资源、决策、快照和反馈场景;每条数据由参数化测试独立计数。 +- `shadow/history` 保存从 `FT546-wcs.dbo.wcs_srgv_queue_finish` 只读抽取的 240 条真实队列历史样本,保留原始任务号、车辆号、站点号和路径。 +- 真实历史数据覆盖车辆 `1/2/3` 和任务类型 `1/2/3`;数据库全量扫描口径为 17,136 条,其中 11,943 条带有非空路径。 +- 历史回放使用 `[create_date, finish_date)` 推导潜在并发窗口;数据库“已完成”事实与当前 SHADOW 的 `GRANT/HOLD/INSUFFICIENT_DATA` 推演结果分栏记录。 `com.ft546.wcs.demo` 是 LEGACY 演示;新增能力位于 `com.ft546.wcs.shadow`,交通区段状态不会写入 LEGACY 业务队列。 @@ -48,6 +51,26 @@ mvn clean test 测试必须由 Maven Surefire 实际发现并执行;验收标准为测试数大于 0、Failures 为 0、Errors 为 0。 +真实历史专项测试: + +```bash +mvn -Dtest=HistoricalQueueDatabaseReplayTest test +``` + +生成真实历史离线回放证据: + +```bash +mvn exec:java \ + -Dexec.mainClass=com.ft546.wcs.shadow.history.HistoricalReplayApplication \ + -Dexec.args="outputs/wcs-shadow-history-replay-local" +``` + +输出包括 `summary.json`、`replay-results.csv`、`data-quality-issues.csv` 和 +`manifest.sha256`。报告保留数据库原始业务编号,方便回查。 + +> 这些结果是“真实历史队列离线回放”,不是现场 SHADOW 验收。历史队列表没有保存 +> 当时每一轮 PLC 快照、资源账本或 LEGACY 决策,因此不能把最终完成状态当作算法正确率。 + ## 运行控制台模拟 ```bash diff --git a/javaTest/pom.xml b/javaTest/pom.xml index d4b9b92..6d01dc7 100644 --- a/javaTest/pom.xml +++ b/javaTest/pom.xml @@ -15,6 +15,7 @@ 1.8 5.10.2 2.17.2 + com.ft546.wcs.demo.DemoApplication @@ -51,7 +52,7 @@ exec-maven-plugin 3.1.0 - com.ft546.wcs.demo.DemoApplication + ${exec.mainClass} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalPath.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalPath.java new file mode 100644 index 0000000..a54f954 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalPath.java @@ -0,0 +1,39 @@ +package com.ft546.wcs.shadow.history; + +import com.ft546.wcs.shadow.resource.ResourceKey; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +public final class HistoricalPath { + private final HistoricalQueueRecord record; + private final LocalDateTime createdAt; + private final LocalDateTime finishedAt; + private final List nodes; + private final Set resources; + private final List issues; + + HistoricalPath(HistoricalQueueRecord record, LocalDateTime createdAt, LocalDateTime finishedAt, + List nodes, Set resources, List issues) { + this.record = record; + this.createdAt = createdAt; + this.finishedAt = finishedAt; + this.nodes = Collections.unmodifiableList(new ArrayList(nodes)); + this.resources = Collections.unmodifiableSet(new LinkedHashSet(resources)); + this.issues = Collections.unmodifiableList(new ArrayList(issues)); + } + + public HistoricalQueueRecord getRecord() { return record; } + public LocalDateTime getCreatedAt() { return createdAt; } + public LocalDateTime getFinishedAt() { return finishedAt; } + public List getNodes() { return nodes; } + public Set getResources() { return resources; } + public List getIssues() { return issues; } + public boolean isReplayable() { + return createdAt != null && finishedAt != null && !nodes.isEmpty() && issues.isEmpty(); + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueDataset.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueDataset.java new file mode 100644 index 0000000..4b9c56f --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueDataset.java @@ -0,0 +1,30 @@ +package com.ft546.wcs.shadow.history; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +public final class HistoricalQueueDataset { + private final List records; + private final Set mapSites; + private final int manifestSampleRows; + private final int fullScanTotalRows; + private final String extractedAt; + + public HistoricalQueueDataset(List records, Set mapSites, + int manifestSampleRows, int fullScanTotalRows, String extractedAt) { + this.records = Collections.unmodifiableList(new ArrayList(records)); + this.mapSites = Collections.unmodifiableSet(new LinkedHashSet(mapSites)); + this.manifestSampleRows = manifestSampleRows; + this.fullScanTotalRows = fullScanTotalRows; + this.extractedAt = extractedAt; + } + + public List getRecords() { return records; } + public Set getMapSites() { return mapSites; } + public int getManifestSampleRows() { return manifestSampleRows; } + public int getFullScanTotalRows() { return fullScanTotalRows; } + public String getExtractedAt() { return extractedAt; } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueLoader.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueLoader.java new file mode 100644 index 0000000..8e77699 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueLoader.java @@ -0,0 +1,103 @@ +package com.ft546.wcs.shadow.history; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.type.CollectionType; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +public final class HistoricalQueueLoader { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + public HistoricalQueueDataset load(ClassLoader loader, String basePath) { + try { + byte[] manifestBytes = read(loader, basePath + "/manifest.json"); + JsonNode manifest = MAPPER.readTree(manifestBytes); + byte[] queueBytes = read(loader, basePath + "/queue-history-200.json"); + byte[] mapBytes = read(loader, basePath + "/map-sites.json"); + byte[] summaryBytes = read(loader, basePath + "/full-scan-summary.json"); + verifySha256(manifest.path("sha256").path("queue-history-200.json").asText(), queueBytes); + verifySha256(manifest.path("sha256").path("map-sites.json").asText(), mapBytes); + verifySha256(manifest.path("sha256").path("full-scan-summary.json").asText(), summaryBytes); + CollectionType rowsType = MAPPER.getTypeFactory() + .constructCollectionType(List.class, HistoricalQueueRecord.class); + List rows = MAPPER.readValue(queueBytes, rowsType); + Set sites = new LinkedHashSet(); + for (JsonNode node : MAPPER.readTree(mapBytes)) { + String site = node.path("site_id").asText(); + if (!site.isEmpty()) sites.add(site); + } + int sampleRows = manifest.path("sampleRows").asInt(); + if (sampleRows != rows.size()) { + throw new IllegalArgumentException("history sample row count mismatch"); + } + if (rows.size() < 200) { + throw new IllegalArgumentException("history sample must contain at least 200 rows"); + } + int totalRows = MAPPER.readTree(summaryBytes).path("total_rows").asInt(); + if (totalRows != manifest.path("totalRows").asInt()) { + throw new IllegalArgumentException("history full scan row count mismatch"); + } + return new HistoricalQueueDataset(rows, sites, sampleRows, totalRows, + manifest.path("extractedAt").asText()); + } catch (IOException ex) { + throw new IllegalArgumentException("cannot load history snapshot", ex); + } + } + + public HistoricalQueueDataset load(Path basePath) { + if (basePath == null || basePath.getParent() == null || basePath.getParent().getParent() == null) { + throw new IllegalArgumentException("invalid history snapshot path"); + } + try { + URLClassLoader loader = new URLClassLoader( + new java.net.URL[]{basePath.getParent().getParent().toUri().toURL()}); + try { + return load(loader, "shadow/history"); + } finally { + loader.close(); + } + } catch (IOException ex) { + throw new IllegalArgumentException("cannot load filesystem history snapshot", ex); + } + } + + public static void verifySha256(String expected, byte[] bytes) { + String actual = sha256(bytes); + if (expected == null || !expected.equalsIgnoreCase(actual)) { + throw new IllegalArgumentException("history snapshot sha256 mismatch"); + } + } + + private static 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(ex); + } + } + + private static byte[] read(ClassLoader loader, String name) throws IOException { + InputStream input = loader.getResourceAsStream(name); + if (input == null) throw new IllegalArgumentException("missing classpath resource " + name); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) >= 0) output.write(buffer, 0, read); + input.close(); + return output.toByteArray(); + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueNormalizer.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueNormalizer.java new file mode 100644 index 0000000..7836063 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueNormalizer.java @@ -0,0 +1,59 @@ +package com.ft546.wcs.shadow.history; + +import com.ft546.wcs.shadow.resource.ResourceKey; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +public final class HistoricalQueueNormalizer { + private static final DateTimeFormatter TIME = DateTimeFormatter + .ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT); + + public HistoricalPath normalize(HistoricalQueueRecord record, Set mapSites) { + List issues = new ArrayList(); + LocalDateTime created = parse(record.getCreateDate(), "INVALID_CREATE_DATE", issues); + LocalDateTime finished = parse(record.getFinishDate(), "INVALID_FINISH_DATE", issues); + if (created != null && finished != null && finished.isBefore(created)) { + issues.add("REVERSED_INTERVAL"); + } + List nodes = new ArrayList(); + if (record.getPathInfo() != null) { + for (String raw : record.getPathInfo().split(",")) { + String node = raw.trim(); + if (!node.isEmpty()) nodes.add(node); + } + } + if (nodes.isEmpty()) issues.add("EMPTY_PATH"); + Set resources = new LinkedHashSet(); + for (int i = 0; i < nodes.size(); i++) { + String node = nodes.get(i); + if (!mapSites.contains(node)) issues.add("UNKNOWN_MAP_SITE:" + node); + resources.add(ResourceKey.node(node)); + if (i > 0) { + String previous = nodes.get(i - 1); + if (previous.equals(node)) issues.add("ADJACENT_DUPLICATE_NODE:" + node); + else resources.add(ResourceKey.edge(previous, node)); + } + } + return new HistoricalPath(record, created, finished, nodes, resources, issues); + } + + private LocalDateTime parse(String value, String issue, List issues) { + if (value == null || value.trim().isEmpty()) { + issues.add(issue); + return null; + } + try { + return LocalDateTime.parse(value.trim(), TIME); + } catch (DateTimeParseException ex) { + issues.add(issue); + return null; + } + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueRecord.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueRecord.java new file mode 100644 index 0000000..f7a8670 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueRecord.java @@ -0,0 +1,56 @@ +package com.ft546.wcs.shadow.history; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public final class HistoricalQueueRecord { + @JsonProperty("guid") private String guid; + @JsonProperty("task_no") private String taskNo; + @JsonProperty("task_type") private String taskType; + @JsonProperty("priority") private Integer priority; + @JsonProperty("cargo") private String cargo; + @JsonProperty("issued") private String issued; + @JsonProperty("execution_status") private String executionStatus; + @JsonProperty("start_site") private String startSite; + @JsonProperty("end_site") private String endSite; + @JsonProperty("the_floor") private String floor; + @JsonProperty("path_info") private String pathInfo; + @JsonProperty("create_date") private String createDate; + @JsonProperty("start_date") private String startDate; + @JsonProperty("finish_date") private String finishDate; + @JsonProperty("agv_id") private String agvId; + + public HistoricalQueueRecord() { } + + public HistoricalQueueRecord(String guid, String taskNo, String taskType, Integer priority, + String startSite, String endSite, String pathInfo, + String createDate, String finishDate, String agvId) { + this.guid = guid; + this.taskNo = taskNo; + this.taskType = taskType; + this.priority = priority; + this.startSite = startSite; + this.endSite = endSite; + this.pathInfo = pathInfo; + this.createDate = createDate; + this.finishDate = finishDate; + this.agvId = agvId; + } + + public String getGuid() { return guid; } + public String getTaskNo() { return taskNo; } + public String getTaskType() { return taskType; } + public Integer getPriority() { return priority; } + public String getCargo() { return cargo; } + public String getIssued() { return issued; } + public String getExecutionStatus() { return executionStatus; } + public String getStartSite() { return startSite; } + public String getEndSite() { return endSite; } + public String getFloor() { return floor; } + public String getPathInfo() { return pathInfo; } + public String getCreateDate() { return createDate; } + public String getStartDate() { return startDate; } + public String getFinishDate() { return finishDate; } + public String getAgvId() { return agvId; } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayAnalyzer.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayAnalyzer.java new file mode 100644 index 0000000..a1c7e36 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayAnalyzer.java @@ -0,0 +1,110 @@ +package com.ft546.wcs.shadow.history; + +import com.ft546.wcs.shadow.resource.ResourceKey; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +public final class HistoricalReplayAnalyzer { + public boolean overlaps(HistoricalPath left, HistoricalPath right) { + if (left.getCreatedAt() == null || left.getFinishedAt() == null + || right.getCreatedAt() == null || right.getFinishedAt() == null) return false; + return left.getCreatedAt().isBefore(right.getFinishedAt()) + && right.getCreatedAt().isBefore(left.getFinishedAt()); + } + + public List analyze(List input) { + List ordered = new ArrayList(input); + Collections.sort(ordered, stableOrder()); + List results = new ArrayList(); + List accepted = new ArrayList(); + for (HistoricalPath path : ordered) { + if (!path.isReplayable()) { + results.add(result(path, HistoricalReplayCategory.INSUFFICIENT_DATA, "INSUFFICIENT_DATA", + null, Collections.emptyList(), "路径或时间数据不足,按失效关闭处理")); + continue; + } + HistoricalReplayResult conflict = null; + for (HistoricalPath prior : accepted) { + if (!overlaps(prior, path)) continue; + if (same(path.getRecord().getAgvId(), prior.getRecord().getAgvId())) { + conflict = result(path, HistoricalReplayCategory.SAME_VEHICLE_OVERLAP, "HOLD", prior, + Collections.emptyList(), "同一车辆的历史生命周期发生重叠"); + break; + } + List opposite = oppositeEdges(prior, path); + if (!opposite.isEmpty()) { + conflict = result(path, HistoricalReplayCategory.POTENTIAL_OPPOSITE_EDGE, "HOLD", + prior, opposite, "潜在并发窗口内存在对向边资源"); + break; + } + List nodes = sharedNodes(prior, path); + if (!nodes.isEmpty()) { + conflict = result(path, HistoricalReplayCategory.POTENTIAL_NODE_CONFLICT, "HOLD", + prior, nodes, "潜在并发窗口内存在共享节点"); + break; + } + } + if (conflict == null) { + accepted.add(path); + results.add(result(path, HistoricalReplayCategory.NO_CONFLICT, "GRANT", null, + Collections.emptyList(), "按历史生命周期推导窗口未发现资源冲突")); + } else { + results.add(conflict); + } + } + return Collections.unmodifiableList(results); + } + + private Comparator stableOrder() { + return Comparator + .comparing((HistoricalPath p) -> p.getRecord().getPriority() == null + ? Integer.MAX_VALUE : p.getRecord().getPriority()) + .thenComparing(HistoricalPath::getCreatedAt, + Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(p -> text(p.getRecord().getTaskNo())) + .thenComparing(p -> text(p.getRecord().getAgvId())) + .thenComparing(p -> text(p.getRecord().getGuid())); + } + + private List sharedNodes(HistoricalPath left, HistoricalPath right) { + Set values = new LinkedHashSet(); + Set rightNodes = new LinkedHashSet(right.getNodes()); + for (String node : left.getNodes()) if (rightNodes.contains(node)) values.add("NODE:" + node); + return new ArrayList(values); + } + + private List oppositeEdges(HistoricalPath left, HistoricalPath right) { + Set rightDirections = new LinkedHashSet(); + for (int i = 1; i < right.getNodes().size(); i++) { + rightDirections.add(right.getNodes().get(i - 1) + "\u0000" + right.getNodes().get(i)); + } + Set result = new LinkedHashSet(); + for (int i = 1; i < left.getNodes().size(); i++) { + String from = left.getNodes().get(i - 1); + String to = left.getNodes().get(i); + if (rightDirections.contains(to + "\u0000" + from)) { + result.add(ResourceKey.edge(from, to).value()); + } + } + return new ArrayList(result); + } + + private HistoricalReplayResult result(HistoricalPath path, HistoricalReplayCategory category, + String decision, HistoricalPath related, + List resources, String reason) { + return new HistoricalReplayResult(path, category, decision, + related == null ? null : related.getRecord().getAgvId(), + related == null ? null : related.getRecord().getTaskNo(), resources, reason); + } + + private boolean same(String left, String right) { + return left != null && left.equals(right); + } + + private String text(String value) { return value == null ? "" : value; } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayApplication.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayApplication.java new file mode 100644 index 0000000..489d48d --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayApplication.java @@ -0,0 +1,32 @@ +package com.ft546.wcs.shadow.history; + +import java.nio.file.Paths; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +public final class HistoricalReplayApplication { + private HistoricalReplayApplication() { } + + public static void main(String[] args) throws Exception { + if (args.length != 1) { + throw new IllegalArgumentException("usage: HistoricalReplayApplication "); + } + Path snapshot = Paths.get("javaTest/src/test/resources/shadow/history"); + if (!Files.isDirectory(snapshot)) snapshot = Paths.get("src/test/resources/shadow/history"); + HistoricalQueueDataset dataset = new HistoricalQueueLoader().load(snapshot); + HistoricalQueueNormalizer normalizer = new HistoricalQueueNormalizer(); + List paths = new ArrayList(); + for (HistoricalQueueRecord record : dataset.getRecords()) { + paths.add(normalizer.normalize(record, dataset.getMapSites())); + } + HistoricalReplayAnalyzer analyzer = new HistoricalReplayAnalyzer(); + List first = analyzer.analyze(paths); + List second = analyzer.analyze(paths); + if (!first.equals(second)) throw new IllegalStateException("history replay is not deterministic"); + new HistoricalReplayReportWriter().write(Paths.get(args[0]), dataset, paths, first); + System.out.println("HISTORY-REPLAY source=" + dataset.getFullScanTotalRows() + + " sample=" + dataset.getRecords().size() + " output=" + args[0]); + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayCategory.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayCategory.java new file mode 100644 index 0000000..a9d4a64 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayCategory.java @@ -0,0 +1,9 @@ +package com.ft546.wcs.shadow.history; + +public enum HistoricalReplayCategory { + NO_CONFLICT, + POTENTIAL_NODE_CONFLICT, + POTENTIAL_OPPOSITE_EDGE, + SAME_VEHICLE_OVERLAP, + INSUFFICIENT_DATA +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayReportWriter.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayReportWriter.java new file mode 100644 index 0000000..11a4a53 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayReportWriter.java @@ -0,0 +1,110 @@ +package com.ft546.wcs.shadow.history; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +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.util.ArrayList; +import java.util.Arrays; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class HistoricalReplayReportWriter { + private static final ObjectMapper JSON = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + + public void write(Path outputDir, HistoricalQueueDataset dataset, + List paths, List results) throws IOException { + Files.createDirectories(outputDir); + writeCsv(outputDir.resolve("replay-results.csv"), replayRows(results)); + writeCsv(outputDir.resolve("data-quality-issues.csv"), issueRows(paths)); + Map summary = new LinkedHashMap(); + summary.put("mode", "REAL_HISTORY_OFFLINE_REPLAY"); + summary.put("title", "真实历史队列离线回放"); + summary.put("boundary", "不是现场 SHADOW 验收"); + summary.put("sourceRowCount", dataset.getFullScanTotalRows()); + summary.put("sampleRowCount", dataset.getRecords().size()); + summary.put("mapSiteCount", dataset.getMapSites().size()); + summary.put("replayableRows", paths.stream().filter(HistoricalPath::isReplayable).count()); + summary.put("qualityIssueRows", paths.stream().filter(p -> !p.getIssues().isEmpty()).count()); + Map counts = new LinkedHashMap(); + for (HistoricalReplayCategory category : HistoricalReplayCategory.values()) counts.put(category.name(), 0); + for (HistoricalReplayResult result : results) { + counts.put(result.getCategory().name(), counts.get(result.getCategory().name()) + 1); + } + summary.put("categoryCounts", counts); + summary.put("deterministic", true); + JSON.writeValue(outputDir.resolve("summary.json").toFile(), summary); + writeManifest(outputDir); + } + + private List> replayRows(List results) { + List> rows = new ArrayList>(); + rows.add(Arrays.asList("创建时间", "完成时间", "车辆", "任务号", "起点", "终点", + "历史状态", "路径", "同期车辆", "同期任务", "共享资源", "SHADOW推演", + "分类", "中文原因", "数据口径")); + for (HistoricalReplayResult result : results) { + HistoricalQueueRecord record = result.getPath().getRecord(); + rows.add(Arrays.asList(value(record.getCreateDate()), value(record.getFinishDate()), + value(record.getAgvId()), value(record.getTaskNo()), value(record.getStartSite()), + value(record.getEndSite()), value(record.getExecutionStatus()), + value(record.getPathInfo()), value(result.getRelatedAgvId()), + value(result.getRelatedTaskNo()), String.join("|", result.getSharedResources()), + result.getDecision(), result.getCategory().name(), result.getReason(), + "生命周期按[create_date,finish_date)推导")); + } + return rows; + } + + private List> issueRows(List paths) { + List> rows = new ArrayList>(); + rows.add(Arrays.asList("GUID", "车辆", "任务号", "问题类型", "原始路径", "是否影响回放", "处理方式")); + for (HistoricalPath path : paths) { + for (String issue : path.getIssues()) { + HistoricalQueueRecord record = path.getRecord(); + rows.add(Arrays.asList(value(record.getGuid()), value(record.getAgvId()), + value(record.getTaskNo()), issue, value(record.getPathInfo()), "是", "INSUFFICIENT_DATA")); + } + } + return rows; + } + + private void writeCsv(Path file, List> rows) throws IOException { + StringBuilder csv = new StringBuilder("\uFEFF"); + for (List row : rows) { + for (int i = 0; i < row.size(); i++) { + if (i > 0) csv.append(','); + csv.append('"').append(value(row.get(i)).replace("\"", "\"\"")).append('"'); + } + csv.append("\r\n"); + } + Files.write(file, csv.toString().getBytes(StandardCharsets.UTF_8)); + } + + private void writeManifest(Path outputDir) throws IOException { + List names = Arrays.asList("data-quality-issues.csv", "replay-results.csv", "summary.json"); + StringBuilder manifest = new StringBuilder(); + for (String name : names) { + 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[] hash = MessageDigest.getInstance("SHA-256").digest(bytes); + StringBuilder text = new StringBuilder(); + for (byte item : hash) text.append(String.format("%02x", item & 0xff)); + return text.toString(); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + private String value(String value) { return value == null ? "" : value; } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayResult.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayResult.java new file mode 100644 index 0000000..5ad5aaf --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayResult.java @@ -0,0 +1,55 @@ +package com.ft546.wcs.shadow.history; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +public final class HistoricalReplayResult { + private final HistoricalPath path; + private final HistoricalReplayCategory category; + private final String decision; + private final String relatedAgvId; + private final String relatedTaskNo; + private final List sharedResources; + private final String reason; + + HistoricalReplayResult(HistoricalPath path, HistoricalReplayCategory category, String decision, + String relatedAgvId, String relatedTaskNo, + List sharedResources, String reason) { + this.path = path; + this.category = category; + this.decision = decision; + this.relatedAgvId = relatedAgvId; + this.relatedTaskNo = relatedTaskNo; + this.sharedResources = Collections.unmodifiableList(new ArrayList(sharedResources)); + this.reason = reason; + } + + public HistoricalPath getPath() { return path; } + public HistoricalReplayCategory getCategory() { return category; } + public String getDecision() { return decision; } + public String getRelatedAgvId() { return relatedAgvId; } + public String getRelatedTaskNo() { return relatedTaskNo; } + public List getSharedResources() { return sharedResources; } + public String getReason() { return reason; } + + @Override + public boolean equals(Object other) { + if (!(other instanceof HistoricalReplayResult)) return false; + HistoricalReplayResult that = (HistoricalReplayResult) other; + return Objects.equals(path.getRecord().getGuid(), that.path.getRecord().getGuid()) + && category == that.category + && Objects.equals(decision, that.decision) + && Objects.equals(relatedAgvId, that.relatedAgvId) + && Objects.equals(relatedTaskNo, that.relatedTaskNo) + && Objects.equals(sharedResources, that.sharedResources) + && Objects.equals(reason, that.reason); + } + + @Override + public int hashCode() { + return Objects.hash(path.getRecord().getGuid(), category, decision, + relatedAgvId, relatedTaskNo, sharedResources, reason); + } +} diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueDatabaseReplayTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueDatabaseReplayTest.java new file mode 100644 index 0000000..d3f7179 --- /dev/null +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueDatabaseReplayTest.java @@ -0,0 +1,70 @@ +package com.ft546.wcs.shadow; + +import com.ft546.wcs.shadow.history.HistoricalPath; +import com.ft546.wcs.shadow.history.HistoricalQueueDataset; +import com.ft546.wcs.shadow.history.HistoricalQueueLoader; +import com.ft546.wcs.shadow.history.HistoricalQueueNormalizer; +import com.ft546.wcs.shadow.history.HistoricalQueueRecord; +import com.ft546.wcs.shadow.history.HistoricalReplayAnalyzer; +import com.ft546.wcs.shadow.history.HistoricalReplayResult; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HistoricalQueueDatabaseReplayTest { + private static final HistoricalQueueDataset DATA = new HistoricalQueueLoader().load( + HistoricalQueueDatabaseReplayTest.class.getClassLoader(), "shadow/history"); + + @ParameterizedTest(name = "{index} task={0} agv={1} {2}->{3}") + @MethodSource("realHistoryCases") + void replaysRealHistoryRecord(String taskNo, String agvId, + String startSite, String endSite, HistoricalPath path) { + assertFalse(taskNo.trim().isEmpty()); + assertFalse(agvId.trim().isEmpty()); + assertNotNull(startSite); + assertNotNull(endSite); + assertNotNull(path); + if (path.isReplayable()) { + assertFalse(path.getNodes().isEmpty()); + assertFalse(path.getResources().isEmpty()); + } else { + assertFalse(path.getIssues().isEmpty()); + } + } + + static Stream realHistoryCases() { + HistoricalQueueNormalizer normalizer = new HistoricalQueueNormalizer(); + return DATA.getRecords().stream().map(record -> Arguments.of( + record.getTaskNo(), record.getAgvId(), record.getStartSite(), record.getEndSite(), + normalizer.normalize(record, DATA.getMapSites()))); + } + + @Test + void snapshotCoverageAndReplayAreDeterministic() { + assertTrue(DATA.getRecords().size() >= 200); + assertEquals(3, DATA.getRecords().stream() + .map(HistoricalQueueRecord::getAgvId).distinct().count()); + assertEquals(3, DATA.getRecords().stream() + .map(HistoricalQueueRecord::getTaskType).distinct().count()); + assertEquals(17136, DATA.getFullScanTotalRows()); + HistoricalQueueNormalizer normalizer = new HistoricalQueueNormalizer(); + List paths = new ArrayList(); + for (HistoricalQueueRecord record : DATA.getRecords()) { + paths.add(normalizer.normalize(record, DATA.getMapSites())); + } + HistoricalReplayAnalyzer analyzer = new HistoricalReplayAnalyzer(); + List first = analyzer.analyze(paths); + assertEquals(first, analyzer.analyze(paths)); + assertEquals(DATA.getRecords().size(), first.size()); + } +} diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueLoaderTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueLoaderTest.java new file mode 100644 index 0000000..f9d7cfa --- /dev/null +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueLoaderTest.java @@ -0,0 +1,33 @@ +package com.ft546.wcs.shadow; + +import com.ft546.wcs.shadow.history.HistoricalQueueDataset; +import com.ft546.wcs.shadow.history.HistoricalQueueLoader; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HistoricalQueueLoaderTest { + @Test + void loadsVersionedDatabaseSnapshotAndPreservesOriginalIds() { + HistoricalQueueDataset data = new HistoricalQueueLoader().load( + getClass().getClassLoader(), "shadow/history"); + assertEquals(240, data.getRecords().size()); + assertEquals(17136, data.getFullScanTotalRows()); + assertEquals(data.getManifestSampleRows(), data.getRecords().size()); + assertFalse(data.getRecords().get(0).getTaskNo().isEmpty()); + assertFalse(data.getRecords().get(0).getAgvId().isEmpty()); + assertTrue(data.getMapSites().size() > 1000); + } + + @Test + void rejectsHashMismatch() { + assertThrows(IllegalArgumentException.class, () -> + HistoricalQueueLoader.verifySha256( + "wrong", "abc".getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueNormalizerTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueNormalizerTest.java new file mode 100644 index 0000000..57e9ff8 --- /dev/null +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueNormalizerTest.java @@ -0,0 +1,52 @@ +package com.ft546.wcs.shadow; + +import com.ft546.wcs.shadow.history.HistoricalPath; +import com.ft546.wcs.shadow.history.HistoricalQueueNormalizer; +import com.ft546.wcs.shadow.history.HistoricalQueueRecord; +import com.ft546.wcs.shadow.resource.ResourceKey; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.LinkedHashSet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HistoricalQueueNormalizerTest { + private final HistoricalQueueNormalizer normalizer = new HistoricalQueueNormalizer(); + + @Test + void parsesTrailingCommaAndProjectsUndirectedEdges() { + HistoricalPath path = normalizer.normalize(record("A,B,C,"), + new LinkedHashSet(Arrays.asList("A", "B", "C"))); + assertEquals(Arrays.asList("A", "B", "C"), path.getNodes()); + assertTrue(path.getResources().contains(ResourceKey.node("B"))); + assertTrue(path.getResources().contains(ResourceKey.edge("B", "A"))); + assertTrue(path.isReplayable()); + } + + @Test + void unknownMapSiteIsNotReplayable() { + HistoricalPath path = normalizer.normalize(record("A,UNKNOWN,"), + new LinkedHashSet(Arrays.asList("A"))); + assertFalse(path.isReplayable()); + assertTrue(path.getIssues().contains("UNKNOWN_MAP_SITE:UNKNOWN")); + } + + @Test + void reversedIntervalIsNotReplayable() { + HistoricalQueueRecord record = new HistoricalQueueRecord( + "G", "T", "1", 2, "A", "B", "A,B,", + "2026-07-21 14:05:00", "2026-07-21 14:04:59", "1"); + HistoricalPath path = normalizer.normalize(record, + new LinkedHashSet(Arrays.asList("A", "B"))); + assertFalse(path.isReplayable()); + assertTrue(path.getIssues().contains("REVERSED_INTERVAL")); + } + + private HistoricalQueueRecord record(String path) { + return new HistoricalQueueRecord("G", "T", "1", 2, "A", "C", path, + "2026-07-21 14:00:00", "2026-07-21 14:05:00", "1"); + } +} diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalReplayAnalyzerTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalReplayAnalyzerTest.java new file mode 100644 index 0000000..239f7dc --- /dev/null +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalReplayAnalyzerTest.java @@ -0,0 +1,84 @@ +package com.ft546.wcs.shadow; + +import com.ft546.wcs.shadow.history.HistoricalPath; +import com.ft546.wcs.shadow.history.HistoricalQueueDataset; +import com.ft546.wcs.shadow.history.HistoricalQueueLoader; +import com.ft546.wcs.shadow.history.HistoricalQueueNormalizer; +import com.ft546.wcs.shadow.history.HistoricalQueueRecord; +import com.ft546.wcs.shadow.history.HistoricalReplayAnalyzer; +import com.ft546.wcs.shadow.history.HistoricalReplayCategory; +import com.ft546.wcs.shadow.history.HistoricalReplayReportWriter; +import com.ft546.wcs.shadow.history.HistoricalReplayResult; +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.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HistoricalReplayAnalyzerTest { + private final HistoricalQueueNormalizer normalizer = new HistoricalQueueNormalizer(); + private final HistoricalReplayAnalyzer analyzer = new HistoricalReplayAnalyzer(); + private final Set sites = new LinkedHashSet(Arrays.asList("A", "B", "C", "D")); + + @Test + void touchingIntervalsDoNotOverlap() { + HistoricalPath left = path("G1", "T1", "V1", "A,B,", "10:00:00", "10:01:00"); + HistoricalPath right = path("G2", "T2", "V2", "C,D,", "10:01:00", "10:02:00"); + assertFalse(analyzer.overlaps(left, right)); + } + + @Test + void detectsOppositeUseOfSameUndirectedEdge() { + List results = analyzer.analyze(Arrays.asList( + path("G1", "T1", "V1", "A,B,", "10:00:00", "10:02:00"), + path("G2", "T2", "V2", "B,A,", "10:00:30", "10:03:00"))); + assertTrue(results.stream().anyMatch(result -> + result.getCategory() == HistoricalReplayCategory.POTENTIAL_OPPOSITE_EDGE)); + } + + @Test + void identicalInputProducesIdenticalResults() { + List sample = Arrays.asList( + path("G1", "T1", "V1", "A,B,", "10:00:00", "10:02:00"), + path("G2", "T2", "V2", "C,D,", "10:00:30", "10:03:00")); + assertEquals(analyzer.analyze(sample), analyzer.analyze(sample)); + } + + @Test + void writesReadableEvidenceWithOriginalIdsAndBoundaryText(@TempDir Path temp) throws Exception { + HistoricalQueueDataset dataset = new HistoricalQueueLoader().load( + getClass().getClassLoader(), "shadow/history"); + List paths = new ArrayList(); + for (HistoricalQueueRecord record : dataset.getRecords()) { + paths.add(normalizer.normalize(record, dataset.getMapSites())); + } + new HistoricalReplayReportWriter().write(temp, dataset, paths, analyzer.analyze(paths)); + String csv = new String(Files.readAllBytes(temp.resolve("replay-results.csv")), + StandardCharsets.UTF_8); + String summary = new String(Files.readAllBytes(temp.resolve("summary.json")), + StandardCharsets.UTF_8); + assertTrue(csv.contains(dataset.getRecords().get(0).getTaskNo())); + assertTrue(csv.contains(dataset.getRecords().get(0).getAgvId())); + assertTrue(summary.contains("真实历史队列离线回放")); + assertTrue(summary.contains("不是现场 SHADOW 验收")); + assertTrue(Files.size(temp.resolve("manifest.sha256")) > 0); + } + + private HistoricalPath path(String guid, String task, String agv, String nodes, + String start, String finish) { + HistoricalQueueRecord record = new HistoricalQueueRecord(guid, task, "1", 2, + nodes.substring(0, 1), nodes.substring(2, 3), nodes, + "2026-07-21 " + start, "2026-07-21 " + finish, agv); + return normalizer.normalize(record, sites); + } +}