feat: replay real FT546 queue history

This commit is contained in:
zgw
2026-07-30 17:44:10 +08:00
parent ba3e84a3f5
commit af0b52d690
17 changed files with 868 additions and 1 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
javaTest/src/test/resources/shadow/history/*.json text eol=lf

View File

@@ -37,6 +37,9 @@
- JSON 黄金决策事件、SHA-256 输入哈希和离线回放。 - JSON 黄金决策事件、SHA-256 输入哈希和离线回放。
- 固定种子 `546001` 至 `546010` 的容量、无环和确定性不变量回归。 - 固定种子 `546001` 至 `546010` 的容量、无环和确定性不变量回归。
- `shadow-simulation-60.json` 提供 60 条独立模拟数据,覆盖 12 类资源、决策、快照和反馈场景;每条数据由参数化测试独立计数。 - `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 业务队列。 `com.ft546.wcs.demo` 是 LEGACY 演示;新增能力位于 `com.ft546.wcs.shadow`,交通区段状态不会写入 LEGACY 业务队列。
@@ -48,6 +51,26 @@ mvn clean test
测试必须由 Maven Surefire 实际发现并执行;验收标准为测试数大于 0、Failures 为 0、Errors 为 0。 测试必须由 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 ```bash

View File

@@ -15,6 +15,7 @@
<maven.compiler.target>1.8</maven.compiler.target> <maven.compiler.target>1.8</maven.compiler.target>
<junit.version>5.10.2</junit.version> <junit.version>5.10.2</junit.version>
<jackson.version>2.17.2</jackson.version> <jackson.version>2.17.2</jackson.version>
<exec.mainClass>com.ft546.wcs.demo.DemoApplication</exec.mainClass>
</properties> </properties>
<dependencies> <dependencies>
@@ -51,7 +52,7 @@
<artifactId>exec-maven-plugin</artifactId> <artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version> <version>3.1.0</version>
<configuration> <configuration>
<mainClass>com.ft546.wcs.demo.DemoApplication</mainClass> <mainClass>${exec.mainClass}</mainClass>
</configuration> </configuration>
</plugin> </plugin>
</plugins> </plugins>

View File

@@ -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<String> nodes;
private final Set<ResourceKey> resources;
private final List<String> issues;
HistoricalPath(HistoricalQueueRecord record, LocalDateTime createdAt, LocalDateTime finishedAt,
List<String> nodes, Set<ResourceKey> resources, List<String> issues) {
this.record = record;
this.createdAt = createdAt;
this.finishedAt = finishedAt;
this.nodes = Collections.unmodifiableList(new ArrayList<String>(nodes));
this.resources = Collections.unmodifiableSet(new LinkedHashSet<ResourceKey>(resources));
this.issues = Collections.unmodifiableList(new ArrayList<String>(issues));
}
public HistoricalQueueRecord getRecord() { return record; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getFinishedAt() { return finishedAt; }
public List<String> getNodes() { return nodes; }
public Set<ResourceKey> getResources() { return resources; }
public List<String> getIssues() { return issues; }
public boolean isReplayable() {
return createdAt != null && finishedAt != null && !nodes.isEmpty() && issues.isEmpty();
}
}

View File

@@ -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<HistoricalQueueRecord> records;
private final Set<String> mapSites;
private final int manifestSampleRows;
private final int fullScanTotalRows;
private final String extractedAt;
public HistoricalQueueDataset(List<HistoricalQueueRecord> records, Set<String> mapSites,
int manifestSampleRows, int fullScanTotalRows, String extractedAt) {
this.records = Collections.unmodifiableList(new ArrayList<HistoricalQueueRecord>(records));
this.mapSites = Collections.unmodifiableSet(new LinkedHashSet<String>(mapSites));
this.manifestSampleRows = manifestSampleRows;
this.fullScanTotalRows = fullScanTotalRows;
this.extractedAt = extractedAt;
}
public List<HistoricalQueueRecord> getRecords() { return records; }
public Set<String> getMapSites() { return mapSites; }
public int getManifestSampleRows() { return manifestSampleRows; }
public int getFullScanTotalRows() { return fullScanTotalRows; }
public String getExtractedAt() { return extractedAt; }
}

View File

@@ -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<HistoricalQueueRecord> rows = MAPPER.readValue(queueBytes, rowsType);
Set<String> sites = new LinkedHashSet<String>();
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();
}
}

View File

@@ -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<String> mapSites) {
List<String> issues = new ArrayList<String>();
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<String> nodes = new ArrayList<String>();
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<ResourceKey> resources = new LinkedHashSet<ResourceKey>();
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<String> 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;
}
}
}

View File

@@ -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; }
}

View File

@@ -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<HistoricalReplayResult> analyze(List<HistoricalPath> input) {
List<HistoricalPath> ordered = new ArrayList<HistoricalPath>(input);
Collections.sort(ordered, stableOrder());
List<HistoricalReplayResult> results = new ArrayList<HistoricalReplayResult>();
List<HistoricalPath> accepted = new ArrayList<HistoricalPath>();
for (HistoricalPath path : ordered) {
if (!path.isReplayable()) {
results.add(result(path, HistoricalReplayCategory.INSUFFICIENT_DATA, "INSUFFICIENT_DATA",
null, Collections.<String>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.<String>emptyList(), "同一车辆的历史生命周期发生重叠");
break;
}
List<String> opposite = oppositeEdges(prior, path);
if (!opposite.isEmpty()) {
conflict = result(path, HistoricalReplayCategory.POTENTIAL_OPPOSITE_EDGE, "HOLD",
prior, opposite, "潜在并发窗口内存在对向边资源");
break;
}
List<String> 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.<String>emptyList(), "按历史生命周期推导窗口未发现资源冲突"));
} else {
results.add(conflict);
}
}
return Collections.unmodifiableList(results);
}
private Comparator<HistoricalPath> 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<String> sharedNodes(HistoricalPath left, HistoricalPath right) {
Set<String> values = new LinkedHashSet<String>();
Set<String> rightNodes = new LinkedHashSet<String>(right.getNodes());
for (String node : left.getNodes()) if (rightNodes.contains(node)) values.add("NODE:" + node);
return new ArrayList<String>(values);
}
private List<String> oppositeEdges(HistoricalPath left, HistoricalPath right) {
Set<String> rightDirections = new LinkedHashSet<String>();
for (int i = 1; i < right.getNodes().size(); i++) {
rightDirections.add(right.getNodes().get(i - 1) + "\u0000" + right.getNodes().get(i));
}
Set<String> result = new LinkedHashSet<String>();
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<String>(result);
}
private HistoricalReplayResult result(HistoricalPath path, HistoricalReplayCategory category,
String decision, HistoricalPath related,
List<String> 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; }
}

View File

@@ -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 <outputDir>");
}
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<HistoricalPath> paths = new ArrayList<HistoricalPath>();
for (HistoricalQueueRecord record : dataset.getRecords()) {
paths.add(normalizer.normalize(record, dataset.getMapSites()));
}
HistoricalReplayAnalyzer analyzer = new HistoricalReplayAnalyzer();
List<HistoricalReplayResult> first = analyzer.analyze(paths);
List<HistoricalReplayResult> 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]);
}
}

View File

@@ -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
}

View File

@@ -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<HistoricalPath> paths, List<HistoricalReplayResult> results) throws IOException {
Files.createDirectories(outputDir);
writeCsv(outputDir.resolve("replay-results.csv"), replayRows(results));
writeCsv(outputDir.resolve("data-quality-issues.csv"), issueRows(paths));
Map<String, Object> summary = new LinkedHashMap<String, Object>();
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<String, Integer> counts = new LinkedHashMap<String, Integer>();
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<List<String>> replayRows(List<HistoricalReplayResult> results) {
List<List<String>> rows = new ArrayList<List<String>>();
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<List<String>> issueRows(List<HistoricalPath> paths) {
List<List<String>> rows = new ArrayList<List<String>>();
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<List<String>> rows) throws IOException {
StringBuilder csv = new StringBuilder("\uFEFF");
for (List<String> 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<String> 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; }
}

View File

@@ -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<String> sharedResources;
private final String reason;
HistoricalReplayResult(HistoricalPath path, HistoricalReplayCategory category, String decision,
String relatedAgvId, String relatedTaskNo,
List<String> sharedResources, String reason) {
this.path = path;
this.category = category;
this.decision = decision;
this.relatedAgvId = relatedAgvId;
this.relatedTaskNo = relatedTaskNo;
this.sharedResources = Collections.unmodifiableList(new ArrayList<String>(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<String> 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);
}
}

View File

@@ -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<Arguments> 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<HistoricalPath> paths = new ArrayList<HistoricalPath>();
for (HistoricalQueueRecord record : DATA.getRecords()) {
paths.add(normalizer.normalize(record, DATA.getMapSites()));
}
HistoricalReplayAnalyzer analyzer = new HistoricalReplayAnalyzer();
List<HistoricalReplayResult> first = analyzer.analyze(paths);
assertEquals(first, analyzer.analyze(paths));
assertEquals(DATA.getRecords().size(), first.size());
}
}

View File

@@ -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)));
}
}

View File

@@ -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<String>(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<String>(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<String>(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");
}
}

View File

@@ -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<String> sites = new LinkedHashSet<String>(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<HistoricalReplayResult> 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<HistoricalPath> 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<HistoricalPath> paths = new ArrayList<HistoricalPath>();
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);
}
}