717 lines
29 KiB
Python
717 lines
29 KiB
Python
"""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"
|
||
|
||
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": [3],
|
||
"minimum_worksheets": 2,
|
||
},
|
||
"oracle": {"stacked_totals": [sum(row[1:]) for row in stacked_rows]},
|
||
},
|
||
"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) -> dict[str, Any]:
|
||
labels = list(sheet.get_labels("L"))
|
||
rows = [list(row) for row in sheet.to_list2(c1=0, c2=max(0, len(labels) - 1))]
|
||
while rows and all(value in (None, "") for value in rows[-1]):
|
||
rows.pop()
|
||
return {
|
||
"name": str(sheet.lname),
|
||
"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 = [_worksheet_snapshot(sheet) for book in workbooks for sheet in book]
|
||
by_name = {item["name"]: item for item in sheets}
|
||
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]
|
||
if actual["labels"][: len(headers)] != headers:
|
||
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 "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())
|