zcbot/windows-node/adapters/origin.plot@v2/acceptance.py

840 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Run the fixed Origin plot acceptance suite on a dedicated Windows node."""
from __future__ import annotations
import argparse
import csv
import hashlib
import importlib.util
import json
import locale
import os
import platform
import struct
import subprocess
import sys
import time
from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from typing import Any
from uuid import NAMESPACE_URL, uuid5
ADAPTER_DIR = Path(__file__).resolve().parent
WORKER_PATH = ADAPTER_DIR / "worker.py"
_WORKER_SPEC = importlib.util.spec_from_file_location(
"zcbot_origin_acceptance_worker", WORKER_PATH
)
if _WORKER_SPEC is None or _WORKER_SPEC.loader is None:
raise RuntimeError("ORIGIN_WORKER_IMPORT_FAILED")
worker = importlib.util.module_from_spec(_WORKER_SPEC)
_WORKER_SPEC.loader.exec_module(worker)
REPORT_SCHEMA_VERSION = 1
OUTPUTS = [
{"key": "project", "type": "project", "format": "opju"},
{"key": "figure_png", "type": "figure", "format": "png", "options": {"dpi": 300}},
{"key": "figure_svg", "type": "figure", "format": "svg"},
{"key": "figure_pdf", "type": "figure", "format": "pdf"},
]
EXPECTED_ARTIFACTS = {
"project",
"figure_png",
"figure_svg",
"figure_pdf",
"plot_spec",
"provenance",
}
ORIGIN_PROCESS_PATTERN = r"^origin(?:\d+)?(?:_?\d+)?(?:64)?\.exe$"
def _artifact_id(case_name: str, input_key: str) -> str:
return str(uuid5(NAMESPACE_URL, f"zcbot-origin-acceptance:{case_name}:{input_key}"))
def _input(case_name: str, key: str) -> dict[str, str]:
return {"key": key, "artifact_id": _artifact_id(case_name, key)}
def _cases() -> dict[str, dict[str, Any]]:
annotations_csv = "x,y\n" + "\n".join(
f"{x},{10 + 3 * x + (x % 3) * 2}" for x in range(11)
) + "\n"
recipe_csv = "age,strength,modulus,porosity\n" + "\n".join(
f"{age},{strength},{modulus},{porosity}"
for age, strength, modulus, porosity in (
(1, 12, 18, 27), (3, 24, 23, 22), (7, 38, 28, 18), (28, 55, 33, 13)
)
) + "\n"
grid_rows = [
(x, y, round(20 + 1.5 * x - 0.8 * y + 0.12 * x * y, 4))
for y in range(5)
for x in range(7)
]
grid_csv = "temperature,time,value\n" + "\n".join(
f"{x},{y},{z}" for x, y, z in reversed(grid_rows)
) + "\n"
stacked_rows = [(1, 3, 5, 7), (3, 4, 6, 8), (7, 5, 5, 7), (28, 8, 6, 8)]
stacked_csv = "age,phase_a,phase_b,phase_c\n" + "\n".join(
",".join(str(value) for value in row) for row in stacked_rows
) + "\n"
band_rows = [
(x, 80 - 4 * x, 76 - 4 * x, 84 - 4 * x) for x in range(11)
]
band_csv = "time,center,lower,upper\n" + "\n".join(
",".join(str(value) for value in row) for row in band_rows
) + "\n"
spectrum_rows = [
(20, 12, 30, 8), (25, 40, 15, 18), (30, 20, 55, 25),
(35, 65, 20, 48), (40, 18, 35, 15),
]
spectrum_csv = "two_theta,sample_a,sample_b,sample_c\n" + "\n".join(
",".join(str(value) for value in row) for row in spectrum_rows
) + "\n"
violin_rows = [
(42, 45, 50), (45, 49, 54), (47, 51, 59), (49, 53, 62), (50, 56, 65),
(51, 58, 67), (53, 61, 70), (56, 64, 74), (60, 69, 79),
]
violin_csv = "reference,dosage_5,dosage_10\n" + "\n".join(
",".join(str(value) for value in row) for row in violin_rows
) + "\n"
return {
"annotations": {
"files": {"sample": annotations_csv},
"request": {
"schema_version": 2,
"inputs": [_input("annotations", "sample")],
"operation": {"plot": {
"type": "line",
"title": "中文标注与参考区域",
"canvas": {"width_mm": 160, "height_mm": 100},
"series": [{
"input": "sample", "x": "x", "y": "y", "label": "试样 A",
"style": {"color": "#3366CC", "line_width": 1.5},
}],
"x_axis": {
"title": "龄期", "unit": "d", "minor_ticks": 4,
"reverse": True, "grid": "major_minor",
},
"y_axis": {"title": "抗压强度", "unit": "MPa"},
"legend": {"enabled": True, "position": "top_left"},
"annotations": [
{
"kind": "reference_line", "axis": "x", "value": 7,
"color": "#CC0000", "line_style": "dash", "label": "关键龄期",
},
{
"kind": "reference_band", "axis": "y", "from": 25, "to": 35,
"color": "#F4A261", "transparency": 80, "label": "目标区间",
},
{
"kind": "text", "text": "受控文字标注", "x": 2, "y": 42,
"color": "#222222", "font_size": 10, "background": "white",
},
],
}},
"outputs": OUTPUTS,
},
"expect": {
"minimum_graph_layers": 1,
"minimum_plot_counts": [1],
"minimum_worksheets": 1,
"annotation_names": ["ZCBOT_ANN_001", "ZCBOT_ANN_002", "ZCBOT_ANN_003"],
},
"oracle": {"reference_line_x": 7, "reference_band_y": [25, 35]},
},
"recipe_2x2": {
"files": {"sample": recipe_csv},
"request": {
"schema_version": 2,
"inputs": [_input("recipe_2x2", "sample")],
"operation": {"plot": {
"type": "recipe", "recipe_version": 1,
"title": "材料性能 2×2 组合图",
"canvas": {"width_mm": 180, "height_mm": 150},
"layout": {"rows": 2, "columns": 2, "share_x": True},
"x_axis": {"title": "龄期", "unit": "d"},
"panels": [
{
"key": "strength", "panel_label": "(a)",
"title": "抗压强度", "y_axis": {"title": "强度", "unit": "MPa"},
"series": [{
"input": "sample", "x": "age", "y": "strength",
"kind": "line_scatter", "label": "强度",
}],
},
{
"key": "modulus", "panel_label": "(b)",
"title": "弹性模量", "y_axis": {"title": "模量", "unit": "GPa"},
"series": [{
"input": "sample", "x": "age", "y": "modulus",
"kind": "line_scatter", "label": "模量",
}],
},
{
"key": "porosity", "panel_label": "(c)",
"title": "显气孔率", "y_axis": {"title": "气孔率", "unit": "%"},
"series": [{
"input": "sample", "x": "age", "y": "porosity",
"kind": "column", "label": "气孔率",
}],
},
{
"key": "comparison", "panel_label": "(d)",
"title": "归一化趋势", "y_axis": {"title": "指标值"},
"series": [
{
"input": "sample", "x": "age", "y": "strength",
"kind": "line", "label": "强度",
},
{
"input": "sample", "x": "age", "y": "modulus",
"kind": "line", "label": "模量",
},
],
},
],
}},
"outputs": OUTPUTS,
},
"expect": {
"minimum_graph_layers": 4,
"minimum_plot_counts": [1, 1, 1, 2],
"minimum_worksheets": 1,
},
"oracle": {"panel_count": 4, "series_count": 5},
},
"heatmap": {
"files": {"grid": grid_csv},
"request": {
"schema_version": 2,
"inputs": [_input("heatmap", "grid")],
"operation": {"plot": {
"type": "heatmap", "title": "烧结工艺窗口热图",
"canvas": {"width_mm": 150, "height_mm": 110},
"series": [{
"input": "grid", "x": "temperature", "y": "time", "z": "value",
}],
"x_axis": {"title": "温度", "unit": "°C"},
"y_axis": {"title": "保温时间", "unit": "h"},
"z_axis": {"title": "抗压强度", "unit": "MPa"},
}},
"outputs": OUTPUTS,
},
"expect": {
"minimum_graph_layers": 1,
"minimum_plot_counts": [1],
"minimum_worksheets": 1,
"minimum_matrices": 1,
},
"oracle": {"grid_shape": [5, 7], "first_cell": grid_rows[0][2], "last_cell": grid_rows[-1][2]},
},
"surface_3d": {
"files": {"grid": grid_csv},
"request": {
"schema_version": 2,
"inputs": [_input("surface_3d", "grid")],
"operation": {"plot": {
"type": "surface_3d", "title": "三维工艺响应面",
"canvas": {"width_mm": 160, "height_mm": 120},
"series": [{
"input": "grid", "x": "temperature", "y": "time", "z": "value",
}],
"x_axis": {"title": "温度", "unit": "°C"},
"y_axis": {"title": "保温时间", "unit": "h"},
"z_axis": {"title": "性能", "unit": "MPa"},
"legend": {"enabled": True},
}},
"outputs": OUTPUTS,
},
"expect": {
"minimum_graph_layers": 1,
"minimum_plot_counts": [1],
"minimum_worksheets": 1,
},
"oracle": {"point_count": len(grid_rows)},
},
"stacked": {
"files": {"sample": stacked_csv},
"request": {
"schema_version": 2,
"inputs": [_input("stacked", "sample")],
"operation": {"plot": {
"type": "stacked_column", "title": "物相组成",
"canvas": {"width_mm": 150, "height_mm": 100},
"series": [
{"input": "sample", "x": "age", "y": "phase_a", "label": "相 A"},
{"input": "sample", "x": "age", "y": "phase_b", "label": "相 B"},
{"input": "sample", "x": "age", "y": "phase_c", "label": "相 C"},
],
"x_axis": {"title": "龄期", "unit": "d"},
"y_axis": {"title": "相含量", "unit": "%"},
}},
"outputs": OUTPUTS,
},
"expect": {
"minimum_graph_layers": 1,
"minimum_plot_counts": [1],
"minimum_worksheets": 2,
"annotation_names": [
"ZCBOT_STACK_LABEL_001",
"ZCBOT_STACK_LABEL_002",
"ZCBOT_STACK_LABEL_003",
],
},
"oracle": {"stacked_totals": [sum(row[1:]) for row in stacked_rows]},
},
"stacked_line": {
"files": {"spectrum": spectrum_csv},
"request": {
"schema_version": 2,
"inputs": [_input("stacked_line", "spectrum")],
"operation": {"plot": {
"type": "stacked_line", "title": "XRD 谱图错位叠加",
"canvas": {"width_mm": 160, "height_mm": 110},
"stack_gap_percent": 10,
"series": [
{"input": "spectrum", "x": "two_theta", "y": "sample_a", "label": "试样 A"},
{"input": "spectrum", "x": "two_theta", "y": "sample_b", "label": "试样 B"},
{"input": "spectrum", "x": "two_theta", "y": "sample_c", "label": "试样 C"},
],
"x_axis": {"title": "", "unit": "°"},
"y_axis": {"title": "错位强度", "unit": "a.u."},
}},
"outputs": OUTPUTS,
},
"expect": {
"minimum_graph_layers": 1,
"minimum_plot_counts": [3],
"minimum_worksheets": 2,
},
"oracle": {"stacked_line_baselines": [0, 58.3, 116.6]},
},
"violin": {
"files": {"strength": violin_csv},
"request": {
"schema_version": 2,
"inputs": [_input("violin", "strength")],
"operation": {"plot": {
"type": "violin", "title": "不同配方抗压强度分布",
"canvas": {"width_mm": 150, "height_mm": 105},
"series": [
{"input": "strength", "y": "reference", "label": "基准组"},
{"input": "strength", "y": "dosage_5", "label": "掺量 5%"},
{"input": "strength", "y": "dosage_10", "label": "掺量 10%"},
],
"x_axis": {"title": "配方组别"},
"y_axis": {"title": "抗压强度", "unit": "MPa"},
"legend": {"enabled": False},
}},
"outputs": OUTPUTS,
},
"expect": {
"minimum_graph_layers": 1,
"minimum_plot_counts": [3],
"minimum_worksheets": 2,
},
"oracle": {"violin_medians": [50, 56, 65]},
},
"band": {
"files": {"sample": band_csv},
"request": {
"schema_version": 2,
"inputs": [_input("band", "sample")],
"operation": {"plot": {
"type": "band", "title": "耐久性能衰减区间",
"canvas": {"width_mm": 150, "height_mm": 100},
"series": [{
"input": "sample", "x": "time", "y": "center",
"lower": "lower", "upper": "upper", "label": "均值与区间",
"style": {"color": "#2A9D8F", "transparency": 60},
}],
"x_axis": {"title": "暴露时间", "unit": ""},
"y_axis": {"title": "相对动弹模", "unit": "%"},
}},
"outputs": OUTPUTS,
},
"expect": {
"minimum_graph_layers": 1,
"minimum_plot_counts": [3],
"minimum_worksheets": 1,
},
"oracle": {
"lower_not_above_center": all(row[2] <= row[1] for row in band_rows),
"center_not_above_upper": all(row[1] <= row[3] for row in band_rows),
},
},
}
def _canonical_request(request: dict[str, Any]) -> tuple[str, str]:
encoded = json.dumps(request, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return encoded, hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _stage(root: Path, case_name: str, case: dict[str, Any]) -> Path:
job_dir = root / case_name
request_dir = job_dir / "request"
request_dir.mkdir(parents=True)
for input_key, content in case["files"].items():
input_dir = job_dir / "input" / input_key
input_dir.mkdir(parents=True)
(input_dir / f"{input_key}.csv").write_text(content, encoding="utf-8", newline="")
_, digest = _canonical_request(case["request"])
record = {
"job_id": str(uuid5(NAMESPACE_URL, f"zcbot-origin-acceptance-job:{case_name}")),
"lease_id": str(uuid5(NAMESPACE_URL, f"zcbot-origin-acceptance-lease:{case_name}")),
"request_digest": digest,
"request": case["request"],
}
worker._atomic_json(request_dir / "request.json", record)
return job_dir
def _png_dimensions(path: Path) -> tuple[int, int]:
data = path.read_bytes()[:24]
if len(data) != 24 or not data.startswith(b"\x89PNG\r\n\x1a\n"):
raise RuntimeError("PNG_EXPORT_INVALID")
return struct.unpack(">II", data[16:24])
def _normalized_cell(value: Any) -> Any:
if value is None or value == "":
return None
if isinstance(value, bool):
return value
try:
number = float(value)
except (TypeError, ValueError):
return str(value)
return int(number) if number.is_integer() else round(number, 12)
def _csv_fixture(content: str) -> tuple[list[str], list[list[Any]]]:
rows = list(csv.reader(content.splitlines()))
return rows[0], [[_normalized_cell(value) for value in row] for row in rows[1:]]
def _worksheet_snapshot(sheet: Any, fallback_name: str = "") -> dict[str, Any]:
labels = list(sheet.get_labels("L"))
columns = sheet.to_list2(c1=0, c2=max(0, len(labels) - 1)) or []
rows = [list(row) for row in zip(*columns)]
while rows and all(value in (None, "") for value in rows[-1]):
rows.pop()
return {
"name": str(sheet.lname or fallback_name),
"labels": labels,
"rows": [[_normalized_cell(value) for value in row[: len(labels)]] for row in rows],
}
def _validate_project_data(
workbooks: list[Any], matrices: list[Any], case: dict[str, Any]
) -> dict[str, Any]:
sheets = []
for book in workbooks:
fallback_name = str(
getattr(book, "lname", "") or getattr(book, "name", "") or ""
)
for sheet in book:
snapshot = _worksheet_snapshot(sheet, fallback_name)
if any(snapshot["labels"]) or snapshot["rows"]:
sheets.append(snapshot)
by_name = {item["name"]: item for item in sheets}
plot = case["request"]["operation"]["plot"]
series_specs = (
[item for panel in plot["panels"] for item in panel["series"]]
if plot["type"] in worker.COMPOSITION_PLOT_TYPES
else plot["series"]
)
for input_key, content in case["files"].items():
if input_key not in by_name:
raise RuntimeError(f"OPJU_INPUT_SHEET_MISSING:{input_key}")
headers, rows = _csv_fixture(content)
actual = by_name[input_key]
expected_labels = list(headers)
for series in series_specs:
if series["input"] == input_key and "y" in series:
expected_labels[headers.index(series["y"])] = str(
series.get("label") or series["y"]
)
if actual["labels"][: len(headers)] != expected_labels:
raise RuntimeError(f"OPJU_INPUT_LABELS_MISMATCH:{input_key}")
actual_rows = [row[: len(headers)] for row in actual["rows"][: len(rows)]]
if actual_rows != rows:
raise RuntimeError(f"OPJU_INPUT_VALUES_MISMATCH:{input_key}")
oracle_validation: dict[str, Any] = {"input_sheets_match": True}
if "stacked_totals" in case["oracle"]:
staging = by_name.get("stacked_plot_data")
if staging is None:
raise RuntimeError("OPJU_STACKED_STAGING_MISSING")
totals = [sum(float(value) for value in row[1:4]) for row in staging["rows"]]
expected = [float(value) for value in case["oracle"]["stacked_totals"]]
if totals != expected:
raise RuntimeError("OPJU_STACKED_TOTALS_MISMATCH")
oracle_validation["stacked_totals"] = totals
if "stacked_line_baselines" in case["oracle"]:
staging = by_name.get("stacked_line_plot_data")
if staging is None:
raise RuntimeError("OPJU_STACKED_LINE_STAGING_MISSING")
baselines = [
min(float(row[index]) for row in staging["rows"])
for index in (1, 3, 5)
]
expected = [float(value) for value in case["oracle"]["stacked_line_baselines"]]
if any(
abs(actual - target) > 1e-9
for actual, target in zip(baselines, expected, strict=True)
):
raise RuntimeError("OPJU_STACKED_LINE_BASELINES_MISMATCH")
oracle_validation["stacked_line_baselines"] = baselines
if "violin_medians" in case["oracle"]:
input_key = next(iter(case["files"]))
source = by_name[input_key]
medians = []
for column_index in range(len(case["oracle"]["violin_medians"])):
values = sorted(float(row[column_index]) for row in source["rows"])
middle = len(values) // 2
medians.append(
values[middle]
if len(values) % 2
else (values[middle - 1] + values[middle]) / 2
)
expected = [float(value) for value in case["oracle"]["violin_medians"]]
if medians != expected:
raise RuntimeError("OPJU_VIOLIN_MEDIANS_MISMATCH")
oracle_validation["violin_medians"] = medians
if "grid_shape" in case["oracle"]:
matrix_sheets = [sheet for book in matrices for sheet in book]
if not matrix_sheets:
raise RuntimeError("OPJU_HEATMAP_MATRIX_MISSING")
matrix = matrix_sheets[-1].to_np2d()
shape = [int(value) for value in matrix.shape]
if shape != case["oracle"]["grid_shape"]:
raise RuntimeError(f"OPJU_HEATMAP_MATRIX_SHAPE_INVALID:{shape}")
first = _normalized_cell(matrix[0, 0])
last = _normalized_cell(matrix[-1, -1])
if first != case["oracle"]["first_cell"] or last != case["oracle"]["last_cell"]:
raise RuntimeError("OPJU_HEATMAP_MATRIX_VALUES_MISMATCH")
oracle_validation.update({"matrix_shape": shape, "matrix_corners": [first, last]})
return {
"worksheet_count": len(sheets),
"worksheets": [
{"name": item["name"], "columns": len(item["labels"]), "rows": len(item["rows"])}
for item in sheets
],
"oracle_validation": oracle_validation,
}
def _validate_manifest(job_dir: Path, request: dict[str, Any], terminal: dict[str, Any]) -> dict[str, Any]:
if terminal.get("status") != "succeeded":
raise RuntimeError(f"WORKER_FAILED:{terminal.get('error')}")
manifest = terminal.get("artifact_manifest") or []
by_id = {item["artifact_id"]: item for item in manifest}
if set(by_id) != EXPECTED_ARTIFACTS:
raise RuntimeError(f"ARTIFACT_MANIFEST_MISMATCH:{sorted(by_id)}")
output = job_dir / "output"
paths = {
"project": output / "project.opju",
"figure_png": output / "figure.png",
"figure_svg": output / "figure.svg",
"figure_pdf": output / "figure.pdf",
"plot_spec": output / "plot-spec.json",
"provenance": output / "provenance.json",
}
extensions = {
"project": "opju", "figure_png": "png", "figure_svg": "svg", "figure_pdf": "pdf"
}
for artifact_id, path in paths.items():
if not path.is_file():
raise RuntimeError(f"ARTIFACT_MISSING:{artifact_id}")
if artifact_id in extensions:
worker._validate_artifact(path, extensions[artifact_id])
item = by_id[artifact_id]
if item["sha256"] != worker._file_sha256(path) or item["size_bytes"] != path.stat().st_size:
raise RuntimeError(f"ARTIFACT_DIGEST_MISMATCH:{artifact_id}")
if json.loads(paths["plot_spec"].read_text(encoding="utf-8")) != request:
raise RuntimeError("PLOT_SPEC_MISMATCH")
provenance = json.loads(paths["provenance"].read_text(encoding="utf-8"))
if provenance.get("adapter_version") != worker.ADAPTER_VERSION:
raise RuntimeError("PROVENANCE_ADAPTER_VERSION_MISMATCH")
width, height = _png_dimensions(paths["figure_png"])
canvas = request["operation"]["plot"].get("canvas") or {"width_mm": 160}
expected_width = round(300 * canvas["width_mm"] / 25.4)
if abs(width - expected_width) > 1 or height <= 0:
raise RuntimeError("PNG_DIMENSIONS_INVALID")
return {
"png_pixels": [width, height],
"artifacts": {
artifact_id: {
"size_bytes": paths[artifact_id].stat().st_size,
"sha256": worker._file_sha256(paths[artifact_id]),
}
for artifact_id in sorted(paths)
},
}
def _reopen_and_export(job_dir: Path, case: dict[str, Any]) -> dict[str, Any]:
import originpro as op
project = job_dir / "output" / "project.opju"
reopen_dir = job_dir / "reopen"
reopen_dir.mkdir()
op.set_show(False)
try:
if not op.open(str(project), readonly=True):
raise RuntimeError("OPJU_REOPEN_FAILED")
graphs = list(op.pages("g"))
workbooks = list(op.pages("w"))
matrices = list(op.pages("m"))
if not graphs:
raise RuntimeError("OPJU_GRAPH_MISSING")
graph = graphs[-1]
layer_count = len(graph)
plot_counts = [len(layer.plot_list()) for layer in graph]
expected = case["expect"]
if layer_count < expected["minimum_graph_layers"]:
raise RuntimeError("OPJU_LAYER_COUNT_INVALID")
for index, minimum in enumerate(expected.get("minimum_plot_counts") or []):
if index >= len(plot_counts) or plot_counts[index] < minimum:
raise RuntimeError(f"OPJU_PLOT_COUNT_INVALID:{index}")
worksheet_count = sum(len(book) for book in workbooks)
if worksheet_count < expected.get("minimum_worksheets", 0):
raise RuntimeError("OPJU_WORKSHEET_COUNT_INVALID")
matrix_count = sum(len(book) for book in matrices)
if matrix_count < expected.get("minimum_matrices", 0):
raise RuntimeError("OPJU_MATRIX_COUNT_INVALID")
for name in expected.get("annotation_names") or []:
if graph[0].label(name) is None:
raise RuntimeError(f"OPJU_ANNOTATION_MISSING:{name}")
project_data = _validate_project_data(workbooks, matrices, case)
graph.activate()
worker._configure_origin_session(op)
svg = reopen_dir / "figure.svg"
pdf = reopen_dir / "figure.pdf"
exported_svg = Path(graph.save_fig(str(svg), type="svg", width=0, ratio=100)).resolve()
if exported_svg != svg.resolve() or not svg.is_file():
raise RuntimeError("OPJU_REOPEN_SVG_EXPORT_FAILED")
normalized_svg, _ = worker._strip_origin_svg_text_baselines(svg.read_bytes())
svg.write_bytes(normalized_svg)
worker._validate_artifact(svg, "svg")
exported_pdf = Path(graph.save_fig(str(pdf), type="pdf", width=0, ratio=100)).resolve()
if exported_pdf != pdf.resolve() or not pdf.is_file():
raise RuntimeError("OPJU_REOPEN_PDF_EXPORT_FAILED")
worker._strip_origin_pdf_text_baselines(pdf)
worker._validate_artifact(pdf, "pdf")
canvas = case["request"]["operation"]["plot"].get("canvas") or {"width_mm": 160}
pixel_width = round(300 * canvas["width_mm"] / 25.4)
png = reopen_dir / "figure.png"
rendered_width, _ = worker._render_pdf_to_png(pdf, png, pixel_width)
if abs(rendered_width - pixel_width) > 1:
raise RuntimeError("OPJU_REOPEN_PNG_WIDTH_INVALID")
worker._validate_artifact(png, "png")
return {
"graph_count": len(graphs),
"workbook_count": len(workbooks),
"worksheet_count": worksheet_count,
"matrix_book_count": len(matrices),
"matrix_count": matrix_count,
"layer_count": layer_count,
"plot_counts": plot_counts,
"project_data": project_data,
"exports": {
path.name: {"size_bytes": path.stat().st_size, "sha256": worker._file_sha256(path)}
for path in (png, svg, pdf)
},
}
finally:
if op.oext:
op.exit()
def _environment_fingerprint() -> dict[str, Any]:
try:
originpro_version = version("originpro")
except PackageNotFoundError:
originpro_version = "unknown"
origin_version = None
try:
import winreg
origin_version = worker._registered_origin_version(winreg)
except (ImportError, OSError):
pass
display: dict[str, int] = {}
if sys.platform == "win32":
try:
import ctypes
user32 = ctypes.windll.user32
display = {
"width_pixels": int(user32.GetSystemMetrics(0)),
"height_pixels": int(user32.GetSystemMetrics(1)),
"system_dpi": int(user32.GetDpiForSystem()),
}
except (AttributeError, OSError):
display = {}
return {
"adapter_version": worker.ADAPTER_VERSION,
"origin_version": origin_version,
"originpro_version": originpro_version,
"python_version": platform.python_version(),
"platform": platform.platform(),
"locale": locale.getlocale(),
"display": display,
"execution_mode": "hidden",
"configured_origin_executable": bool(os.environ.get("ZCBOT_ORIGIN_EXE")),
}
def _origin_processes() -> dict[int, str]:
if sys.platform != "win32":
return {}
completed = subprocess.run(
["tasklist.exe", "/fo", "csv", "/nh"],
check=True,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
processes: dict[int, str] = {}
import re
for row in csv.reader(completed.stdout.splitlines()):
name = row[0].casefold() if row else ""
if len(row) >= 2 and re.fullmatch(ORIGIN_PROCESS_PATTERN, name):
processes[int(row[1])] = row[0]
return processes
def _wait_for_origin_release(
baseline: dict[int, str], timeout_seconds: int
) -> dict[int, str]:
deadline = time.monotonic() + timeout_seconds
while True:
remaining = {
pid: name for pid, name in _origin_processes().items() if pid not in baseline
}
if not remaining:
return {}
if time.monotonic() >= deadline:
return remaining
time.sleep(2)
def _run_case(
root: Path,
case_name: str,
case: dict[str, Any],
baseline_processes: dict[int, str],
release_wait: int,
) -> dict[str, Any]:
job_dir = _stage(root, case_name, case)
started = time.monotonic()
completed = subprocess.run(
[sys.executable, str(WORKER_PATH), str(job_dir)],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
elapsed = time.monotonic() - started
terminal_path = job_dir / "terminal.json"
if not terminal_path.is_file():
raise RuntimeError(f"WORKER_TERMINAL_MISSING:{completed.stderr[-500:]}")
terminal = json.loads(terminal_path.read_text(encoding="utf-8"))
if completed.returncode != 0:
raise RuntimeError(f"WORKER_PROCESS_FAILED:{completed.stderr[-500:]}")
validation = _validate_manifest(job_dir, case["request"], terminal)
reopened = _reopen_and_export(job_dir, case)
remaining = _wait_for_origin_release(baseline_processes, release_wait)
if remaining:
raise RuntimeError(f"ORIGIN_PROCESS_REMAINS:{remaining}")
_, digest = _canonical_request(case["request"])
return {
"case": case_name,
"elapsed_seconds": round(elapsed, 3),
"request_digest": digest,
"oracle": case["oracle"],
"validation": validation,
"reopen": reopened,
"origin_processes_released": True,
}
def run_suite(
root: Path,
selected: list[str] | None = None,
*,
release_wait: int = 60,
) -> dict[str, Any]:
cases = _cases()
names = selected or list(cases)
unknown = sorted(set(names) - cases.keys())
if unknown:
raise ValueError(f"UNKNOWN_ACCEPTANCE_CASES:{','.join(unknown)}")
root.mkdir(parents=True, exist_ok=False)
baseline_processes = _origin_processes()
report = {
"schema_version": REPORT_SCHEMA_VERSION,
"started_at": datetime.now(timezone.utc).isoformat(),
"environment": _environment_fingerprint(),
"baseline_origin_processes": baseline_processes,
"selected_cases": names,
"cases": [],
"passed": False,
}
report_path = root / "acceptance-report.json"
try:
for index, name in enumerate(names, start=1):
report["cases"].append(
_run_case(root, name, cases[name], baseline_processes, release_wait)
)
worker._atomic_json(report_path, report)
print(f"[OK] Origin acceptance {index}/{len(names)}: {name}")
report["passed"] = True
return report
except Exception as exc:
report["failure"] = {
"type": type(exc).__name__,
"detail": str(exc)[:1000],
"completed_cases": len(report["cases"]),
}
raise
finally:
report["completed_at"] = datetime.now(timezone.utc).isoformat()
worker._atomic_json(report_path, report)
def main() -> int:
parser = argparse.ArgumentParser(description="Run fixed Origin plot acceptance cases.")
parser.add_argument("--work-root", type=Path, required=True)
parser.add_argument("--release-wait", type=int, default=60)
parser.add_argument(
"--case",
action="append",
choices=tuple(_cases()),
dest="cases",
help="Run only the named case; repeat the option to select multiple cases.",
)
args = parser.parse_args()
if sys.platform != "win32":
raise RuntimeError("Origin acceptance requires Windows")
if args.release_wait < 1:
raise ValueError("release-wait must be positive")
root = args.work_root.resolve()
report = run_suite(root, args.cases, release_wait=args.release_wait)
print(f"[OK] Origin acceptance passed. Report: {root / 'acceptance-report.json'}")
print(f"[INFO] Cases: {len(report['cases'])}")
return 0
if __name__ == "__main__":
raise SystemExit(main())