chore: remove retired workflow and historical material
This commit is contained in:
@@ -1,13 +0,0 @@
|
||||
# 개발 실행 진입점
|
||||
|
||||
이 영역은 명시적으로 요청된 검증 실행과 비교 도구를 제공한다. 솔버의 제품 runtime이나
|
||||
자동 작업 승인·commit·push·release 절차를 소유하지 않는다.
|
||||
|
||||
- execute.py는 인자 없는 build/test 진입점이다. 과거 phase 인자, 자동 retry·내부 LLM·
|
||||
파일명 TDD gate·대화 종료 hook을 복원하지 않는다.
|
||||
- 명령 성공의 범위를 실제 실행한 검사로 제한한다. C++ 실행기가 Python 회귀나 별도
|
||||
reference 비교까지 자동 수행한다고 보고하지 않는다.
|
||||
- Config·preset·실행 명령은 신뢰가 필요한 실행 입력이다. shell=False나 timeout을
|
||||
sandbox라고 설명하지 않는다.
|
||||
- 변경 동작은 해당 Python 검사로 검증하고 문서만 수정하면 실행하지 않는다.
|
||||
기능별 source/reference를 실행기 편의를 위해 rename·rewrite하지 않는다.
|
||||
@@ -1,59 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the configured C/C++ build and tests once: python scripts/execute.py."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from msvc_harness.adapters.base import AdapterError
|
||||
from msvc_harness.adapters.cmake import CMakeAdapter
|
||||
from msvc_harness.adapters.msbuild import MSBuildAdapter
|
||||
from msvc_harness.config import ConfigError, load_config
|
||||
from msvc_harness.discovery import DiscoveryError, discover_project
|
||||
from msvc_harness.models import ProjectKind
|
||||
from msvc_harness.process import ValidationFailure, execute_plan
|
||||
from msvc_harness.toolchain import ToolchainError, discover_toolchain
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def run_validations(root: Path) -> int:
|
||||
deadline = time.monotonic() + 1800
|
||||
try:
|
||||
config = load_config(root)
|
||||
selection = discover_project(root, config).selection
|
||||
if selection is None:
|
||||
print("not configured: no CMake or MSBuild project found", file=sys.stderr)
|
||||
return 2
|
||||
tools = discover_toolchain(selection.kind, deadline=deadline)
|
||||
adapter = CMakeAdapter() if selection.kind is ProjectKind.CMAKE else MSBuildAdapter()
|
||||
plan = adapter.create_plan(root, selection, config, tools)
|
||||
results = execute_plan(plan, root, deadline=deadline)
|
||||
except (ConfigError, DiscoveryError, ToolchainError, AdapterError, ValidationFailure, OSError) as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
for result in results:
|
||||
print(f"{result.command.stage}: argv={list(result.command.argv)!r}; "
|
||||
f"cwd={str(result.command.cwd)!r}; exit {result.returncode}")
|
||||
if result.command.stage == "test":
|
||||
output = (result.stdout + "\n" + result.stderr).strip()
|
||||
tail = "\n".join(output.splitlines()[-8:])[-2000:]
|
||||
if tail != output:
|
||||
print("[test output tail]")
|
||||
if tail:
|
||||
print(tail)
|
||||
print(f"Validation passed ({len(results)} commands).")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.parse_args(argv)
|
||||
return run_validations(ROOT)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,15 +0,0 @@
|
||||
# Build와 test 실행기
|
||||
|
||||
이 package는 config·project 감지·toolchain 선택, adapter 명령 계획과 process 실행을 소유한다.
|
||||
수치 acceptance나 Feature 상태, Git와 agent 인계는 소유하지 않는다.
|
||||
|
||||
- Explicit config와 metadata에 따라 CMake/MSBuild를 선택한다. 모호한 project 감지를
|
||||
첫 번째 파일로 해결하거나 C++ source 존재만으로 별도 gate를 만들지 않는다.
|
||||
- CMake 경로는 configure 이후 MSVC metadata와 CTest의 비어 있지 않은 JSON discovery를
|
||||
검사한다. Preset 네 필드와 실제 binaryDir의 일치를 보존한다.
|
||||
- Argv list와 shell=False를 사용하고 cwd를 검증한다. CLI의 전체 deadline과 step timeout
|
||||
중 남은 범위를 적용한다. Backend side effect 전체를 격리한다고 주장하지 않는다.
|
||||
- Nonzero·timeout·결과 검사 실패는 stage/argv/cwd와 출력 tail을 포함해 반환한다.
|
||||
자동 retry·commit·push·phase 결과 파일 생성을 추가하지 않는다.
|
||||
- Config precedence, ambiguous metadata, non-MSVC, empty discovery, timeout 경계,
|
||||
backend exit와 runner exit 구분을 process seam으로 검증한다.
|
||||
@@ -1,3 +0,0 @@
|
||||
from .config import ConfigError, load_config
|
||||
|
||||
__all__ = ["ConfigError", "load_config"]
|
||||
@@ -1,5 +0,0 @@
|
||||
from .base import AdapterError, ValidationAdapter
|
||||
from .cmake import CMakeAdapter
|
||||
from .msbuild import MSBuildAdapter
|
||||
|
||||
__all__ = ["AdapterError", "CMakeAdapter", "MSBuildAdapter", "ValidationAdapter"]
|
||||
@@ -1,19 +0,0 @@
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from ..models import HarnessConfig, ProjectSelection, Toolchain, ValidationPlan
|
||||
|
||||
|
||||
class AdapterError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ValidationAdapter(Protocol):
|
||||
def create_plan(
|
||||
self,
|
||||
root: Path,
|
||||
selection: ProjectSelection,
|
||||
config: HarnessConfig,
|
||||
tools: Toolchain,
|
||||
) -> ValidationPlan:
|
||||
raise NotImplementedError
|
||||
@@ -1,99 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ..models import (
|
||||
CommandSpec,
|
||||
HarnessConfig,
|
||||
ProjectKind,
|
||||
ProjectSelection,
|
||||
ResultCheck,
|
||||
ResultCheckKind,
|
||||
Toolchain,
|
||||
ValidationPlan,
|
||||
ValidationStep,
|
||||
)
|
||||
from .base import AdapterError
|
||||
|
||||
|
||||
class CMakeAdapter:
|
||||
def create_plan(
|
||||
self,
|
||||
root: Path,
|
||||
selection: ProjectSelection,
|
||||
config: HarnessConfig,
|
||||
tools: Toolchain,
|
||||
) -> ValidationPlan:
|
||||
if selection.kind is not ProjectKind.CMAKE:
|
||||
raise AdapterError("CMakeAdapter requires a CMake project selection")
|
||||
if tools.cmake is None:
|
||||
raise AdapterError("CMake tool is required")
|
||||
if tools.ctest is None:
|
||||
raise AdapterError("CTest tool is required")
|
||||
|
||||
if config.cmake.configure_preset is None:
|
||||
command_cwd = root
|
||||
binary = config.cmake.binary_dir or root / ".harness/build"
|
||||
configure = (
|
||||
str(tools.cmake),
|
||||
"-S",
|
||||
str(config.cmake.source_dir),
|
||||
"-B",
|
||||
str(binary),
|
||||
"-A",
|
||||
"x64",
|
||||
)
|
||||
build = (str(tools.cmake), "--build", str(binary), "--config", "Debug")
|
||||
discover = (
|
||||
str(tools.ctest),
|
||||
"--test-dir",
|
||||
str(binary),
|
||||
"-C",
|
||||
"Debug",
|
||||
"--show-only=json-v1",
|
||||
)
|
||||
test = (
|
||||
str(tools.ctest),
|
||||
"--test-dir",
|
||||
str(binary),
|
||||
"-C",
|
||||
"Debug",
|
||||
"--output-on-failure",
|
||||
)
|
||||
else:
|
||||
command_cwd = config.cmake.source_dir
|
||||
binary = config.cmake.binary_dir
|
||||
if (
|
||||
binary is None
|
||||
or config.cmake.build_preset is None
|
||||
or config.cmake.test_preset is None
|
||||
):
|
||||
raise AdapterError("CMake presets require binary, build, and test settings")
|
||||
configure = (str(tools.cmake), "--preset", config.cmake.configure_preset)
|
||||
build = (str(tools.cmake), "--build", "--preset", config.cmake.build_preset)
|
||||
discover = (
|
||||
str(tools.ctest),
|
||||
"--preset",
|
||||
config.cmake.test_preset,
|
||||
"--show-only=json-v1",
|
||||
)
|
||||
test = (
|
||||
str(tools.ctest),
|
||||
"--preset",
|
||||
config.cmake.test_preset,
|
||||
"--output-on-failure",
|
||||
)
|
||||
|
||||
return ValidationPlan(
|
||||
ProjectKind.CMAKE,
|
||||
(
|
||||
ValidationStep(
|
||||
CommandSpec(configure, command_cwd, "configure"),
|
||||
(ResultCheck(ResultCheckKind.CMAKE_COMPILER_IS_MSVC, binary),),
|
||||
),
|
||||
ValidationStep(CommandSpec(build, command_cwd, "build")),
|
||||
ValidationStep(
|
||||
CommandSpec(discover, command_cwd, "test-discovery"),
|
||||
(ResultCheck(ResultCheckKind.CTEST_HAS_TESTS),),
|
||||
),
|
||||
ValidationStep(CommandSpec(test, command_cwd, "test")),
|
||||
),
|
||||
)
|
||||
@@ -1,62 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ..models import (
|
||||
CommandSpec,
|
||||
HarnessConfig,
|
||||
ProjectKind,
|
||||
ProjectSelection,
|
||||
Toolchain,
|
||||
ValidationPlan,
|
||||
ValidationStep,
|
||||
)
|
||||
from .base import AdapterError
|
||||
|
||||
|
||||
def _test_argv(root: Path, command: tuple[str, ...]) -> tuple[str, ...]:
|
||||
first = Path(command[0].replace("\\", "/"))
|
||||
if first.is_absolute():
|
||||
raise AdapterError(
|
||||
"msbuild.testCommand executable must be repository-relative"
|
||||
)
|
||||
executable = (root / first).resolve()
|
||||
try:
|
||||
executable.relative_to(root.resolve())
|
||||
except ValueError as exc:
|
||||
raise AdapterError(
|
||||
"msbuild.testCommand resolves outside the repository"
|
||||
) from exc
|
||||
return (str(executable), *command[1:])
|
||||
|
||||
|
||||
class MSBuildAdapter:
|
||||
def create_plan(
|
||||
self,
|
||||
root: Path,
|
||||
selection: ProjectSelection,
|
||||
config: HarnessConfig,
|
||||
tools: Toolchain,
|
||||
) -> ValidationPlan:
|
||||
if selection.kind is not ProjectKind.MSBUILD:
|
||||
raise AdapterError("MSBuildAdapter received a non-MSBuild project")
|
||||
if not config.msbuild.test_command:
|
||||
raise AdapterError(
|
||||
"msbuild.testCommand is required for .sln/.vcxproj validation"
|
||||
)
|
||||
build = ValidationStep(
|
||||
CommandSpec(
|
||||
(
|
||||
str(tools.msbuild),
|
||||
str(selection.project_file),
|
||||
"/m",
|
||||
"/nologo",
|
||||
f"/p:Configuration={config.msbuild.configuration}",
|
||||
f"/p:Platform={config.msbuild.platform}",
|
||||
),
|
||||
root,
|
||||
"build",
|
||||
)
|
||||
)
|
||||
test = ValidationStep(
|
||||
CommandSpec(_test_argv(root, config.msbuild.test_command), root, "test")
|
||||
)
|
||||
return ValidationPlan(ProjectKind.MSBUILD, (build, test))
|
||||
@@ -1,138 +0,0 @@
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .models import CMakeConfig, HarnessConfig, MSBuildConfig
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _reject_unknown(data: dict[str, Any], allowed: set[str], label: str) -> None:
|
||||
unknown = sorted(set(data) - allowed)
|
||||
if unknown:
|
||||
raise ConfigError(f"{label} contains unexpected field: {unknown[0]}")
|
||||
|
||||
|
||||
def _repo_path(root: Path, raw: str, label: str) -> Path:
|
||||
candidate = Path(raw)
|
||||
if candidate.is_absolute():
|
||||
raise ConfigError(f"{label} must be repository-relative")
|
||||
resolved = (root / candidate).resolve()
|
||||
try:
|
||||
resolved.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise ConfigError(f"{label} resolves outside the repository") from exc
|
||||
return resolved
|
||||
|
||||
|
||||
def _mapping(value: Any, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise ConfigError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _nonempty_string(value: Any, label: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ConfigError(f"{label} must be a non-empty string")
|
||||
if any(ord(character) < 32 or ord(character) == 127 for character in value):
|
||||
raise ConfigError(f"{label} must not contain control characters")
|
||||
return value
|
||||
|
||||
|
||||
def _string_array(value: Any, label: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, list):
|
||||
raise ConfigError(f"{label} must be an array")
|
||||
if not value:
|
||||
raise ConfigError(f"{label} must be a non-empty string array")
|
||||
return tuple(
|
||||
_nonempty_string(item, f"{label}[{index}]")
|
||||
for index, item in enumerate(value)
|
||||
)
|
||||
|
||||
|
||||
def load_config(root: Path) -> HarnessConfig:
|
||||
root = root.resolve()
|
||||
config_path = root / ".harness" / "config.json"
|
||||
if config_path.is_file():
|
||||
try:
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ConfigError(
|
||||
f"{config_path}: configuration must be valid UTF-8: {exc}"
|
||||
) from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ConfigError(f"{config_path}: {exc}") from exc
|
||||
else:
|
||||
data = {"version": 1}
|
||||
|
||||
data = _mapping(data, "config")
|
||||
_reject_unknown(data, {"version", "projectType", "cmake", "msbuild", "tdd"}, "config")
|
||||
|
||||
if data.get("version") != 1 or isinstance(data.get("version"), bool):
|
||||
raise ConfigError("version must be 1")
|
||||
project_type = data.get("projectType", "auto")
|
||||
if project_type not in {"auto", "cmake", "msbuild"}:
|
||||
raise ConfigError("projectType must be auto, cmake, or msbuild")
|
||||
|
||||
cmake_data = _mapping(data.get("cmake", {}), "cmake")
|
||||
_reject_unknown(
|
||||
cmake_data,
|
||||
{"sourceDir", "binaryDir", "configurePreset", "buildPreset", "testPreset"},
|
||||
"cmake",
|
||||
)
|
||||
source_dir = _repo_path(
|
||||
root, _nonempty_string(cmake_data.get("sourceDir", "."), "cmake.sourceDir"), "cmake.sourceDir"
|
||||
)
|
||||
binary_dir = None
|
||||
if "binaryDir" in cmake_data:
|
||||
binary_dir = _repo_path(
|
||||
root, _nonempty_string(cmake_data["binaryDir"], "cmake.binaryDir"), "cmake.binaryDir"
|
||||
)
|
||||
preset_keys = ("configurePreset", "buildPreset", "testPreset")
|
||||
if any(key in cmake_data for key in preset_keys) and not (
|
||||
all(key in cmake_data for key in preset_keys) and binary_dir is not None
|
||||
):
|
||||
raise ConfigError("cmake configurePreset, buildPreset, testPreset, and binaryDir must be specified together")
|
||||
configure_preset = (
|
||||
_nonempty_string(cmake_data["configurePreset"], "cmake.configurePreset")
|
||||
if "configurePreset" in cmake_data
|
||||
else None
|
||||
)
|
||||
build_preset = (
|
||||
_nonempty_string(cmake_data["buildPreset"], "cmake.buildPreset")
|
||||
if "buildPreset" in cmake_data
|
||||
else None
|
||||
)
|
||||
test_preset = (
|
||||
_nonempty_string(cmake_data["testPreset"], "cmake.testPreset")
|
||||
if "testPreset" in cmake_data
|
||||
else None
|
||||
)
|
||||
|
||||
msbuild_data = _mapping(data.get("msbuild", {}), "msbuild")
|
||||
_reject_unknown(msbuild_data, {"solution", "configuration", "platform", "testCommand"}, "msbuild")
|
||||
solution = None
|
||||
if "solution" in msbuild_data:
|
||||
solution = _repo_path(
|
||||
root, _nonempty_string(msbuild_data["solution"], "msbuild.solution"), "msbuild.solution"
|
||||
)
|
||||
configuration = _nonempty_string(msbuild_data.get("configuration", "Debug"), "msbuild.configuration")
|
||||
platform = _nonempty_string(msbuild_data.get("platform", "x64"), "msbuild.platform")
|
||||
test_command = (
|
||||
_string_array(msbuild_data["testCommand"], "msbuild.testCommand")
|
||||
if "testCommand" in msbuild_data
|
||||
else None
|
||||
)
|
||||
|
||||
if "tdd" in data:
|
||||
print("Deprecated config field 'tdd' is ignored; it may be removed.", file=sys.stderr)
|
||||
|
||||
return HarnessConfig(
|
||||
version=1,
|
||||
project_type=project_type,
|
||||
cmake=CMakeConfig(source_dir, binary_dir, configure_preset, build_preset, test_preset),
|
||||
msbuild=MSBuildConfig(solution, configuration, platform, test_command),
|
||||
)
|
||||
@@ -1,70 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from .models import DiscoveryResult, HarnessConfig, ProjectKind, ProjectSelection
|
||||
|
||||
|
||||
class DiscoveryError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _cmake_entry(source: Path) -> Path | None:
|
||||
for name in ("CMakePresets.json", "CMakeUserPresets.json", "CMakeLists.txt"):
|
||||
candidate = source / name
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _single_msbuild(root: Path, explicit: Path | None) -> Path:
|
||||
if explicit is not None:
|
||||
if explicit.is_file() and explicit.suffix.lower() in {".sln", ".vcxproj"}:
|
||||
return explicit
|
||||
raise DiscoveryError(f"configured MSBuild project is invalid: {explicit}")
|
||||
solutions = sorted(root.glob("*.sln"))
|
||||
if len(solutions) > 1:
|
||||
raise DiscoveryError("multiple .sln files found; set msbuild.solution")
|
||||
if solutions:
|
||||
return solutions[0]
|
||||
projects = sorted(root.glob("*.vcxproj"))
|
||||
if len(projects) > 1:
|
||||
raise DiscoveryError("multiple .vcxproj files found; set msbuild.solution")
|
||||
if projects:
|
||||
return projects[0]
|
||||
raise DiscoveryError("no root .sln or .vcxproj was found")
|
||||
|
||||
|
||||
def discover_project(root: Path, config: HarnessConfig) -> DiscoveryResult:
|
||||
root = root.resolve()
|
||||
if config.project_type == "cmake":
|
||||
entry = _cmake_entry(config.cmake.source_dir)
|
||||
if entry is None:
|
||||
raise DiscoveryError(
|
||||
f"no CMake project found in {config.cmake.source_dir}"
|
||||
)
|
||||
return DiscoveryResult(ProjectSelection(ProjectKind.CMAKE, entry))
|
||||
|
||||
if config.project_type == "msbuild":
|
||||
project = _single_msbuild(root, config.msbuild.solution)
|
||||
return DiscoveryResult(ProjectSelection(ProjectKind.MSBUILD, project))
|
||||
|
||||
entry = _cmake_entry(config.cmake.source_dir)
|
||||
if entry is not None:
|
||||
return DiscoveryResult(ProjectSelection(ProjectKind.CMAKE, entry))
|
||||
|
||||
solutions = sorted(root.glob("*.sln"))
|
||||
if len(solutions) > 1:
|
||||
raise DiscoveryError("multiple .sln files found; set msbuild.solution")
|
||||
if solutions:
|
||||
return DiscoveryResult(
|
||||
ProjectSelection(ProjectKind.MSBUILD, solutions[0])
|
||||
)
|
||||
|
||||
projects = sorted(root.glob("*.vcxproj"))
|
||||
if len(projects) > 1:
|
||||
raise DiscoveryError("multiple .vcxproj files found; set msbuild.solution")
|
||||
if projects:
|
||||
return DiscoveryResult(
|
||||
ProjectSelection(ProjectKind.MSBUILD, projects[0])
|
||||
)
|
||||
|
||||
return DiscoveryResult(None)
|
||||
@@ -1,91 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ProjectKind(Enum):
|
||||
CMAKE = "cmake"
|
||||
MSBUILD = "msbuild"
|
||||
|
||||
|
||||
class ResultCheckKind(Enum):
|
||||
CMAKE_COMPILER_IS_MSVC = "cmake_compiler_is_msvc"
|
||||
CTEST_HAS_TESTS = "ctest_has_tests"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CMakeConfig:
|
||||
source_dir: Path
|
||||
binary_dir: Path | None = None
|
||||
configure_preset: str | None = None
|
||||
build_preset: str | None = None
|
||||
test_preset: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MSBuildConfig:
|
||||
solution: Path | None = None
|
||||
configuration: str = "Debug"
|
||||
platform: str = "x64"
|
||||
test_command: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HarnessConfig:
|
||||
version: int
|
||||
project_type: str
|
||||
cmake: CMakeConfig
|
||||
msbuild: MSBuildConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectSelection:
|
||||
kind: ProjectKind
|
||||
project_file: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiscoveryResult:
|
||||
selection: ProjectSelection | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Toolchain:
|
||||
installation: Path
|
||||
msbuild: Path
|
||||
cmake: Path | None = None
|
||||
ctest: Path | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandSpec:
|
||||
argv: tuple[str, ...]
|
||||
cwd: Path
|
||||
stage: str
|
||||
timeout_seconds: int = 7200
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResultCheck:
|
||||
kind: ResultCheckKind
|
||||
path: Path | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidationStep:
|
||||
command: CommandSpec
|
||||
checks: tuple[ResultCheck, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidationPlan:
|
||||
project_kind: ProjectKind
|
||||
steps: tuple[ValidationStep, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandResult:
|
||||
command: CommandSpec
|
||||
returncode: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
@@ -1,140 +0,0 @@
|
||||
import json
|
||||
import locale
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from .models import CommandResult, ResultCheckKind
|
||||
|
||||
DIAGNOSTIC_LIMIT = 8000
|
||||
|
||||
|
||||
class ValidationFailure(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _decode(value):
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
for encoding in ("utf-8", locale.getpreferredencoding(False)):
|
||||
try:
|
||||
return value.decode(encoding)
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
continue
|
||||
return value.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _check_result(check, result):
|
||||
if check.kind is ResultCheckKind.CMAKE_COMPILER_IS_MSVC:
|
||||
if check.path is None:
|
||||
raise ValidationFailure("MSVC check is missing binaryDir")
|
||||
files = sorted(check.path.glob("CMakeFiles/*/CMakeCXXCompiler.cmake"))
|
||||
if not files:
|
||||
raise ValidationFailure("CMake did not generate compiler metadata")
|
||||
text = files[-1].read_text(encoding="utf-8", errors="replace")
|
||||
if not re.search(
|
||||
r'set\s*\(\s*CMAKE_CXX_COMPILER_ID\s+"MSVC"\s*\)', text
|
||||
):
|
||||
raise ValidationFailure("CMake selected a compiler other than MSVC")
|
||||
elif check.kind is ResultCheckKind.CTEST_HAS_TESTS:
|
||||
try:
|
||||
payload = json.loads(result.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValidationFailure("CTest discovery did not return JSON") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise ValidationFailure("CTest discovery JSON must be an object")
|
||||
if not isinstance(payload.get("tests"), list) or not payload["tests"]:
|
||||
raise ValidationFailure("CTest discovered no tests")
|
||||
|
||||
|
||||
def _diagnostic(stdout, stderr):
|
||||
diagnostic = (stdout + "\n" + stderr).rstrip()[-DIAGNOSTIC_LIMIT:]
|
||||
return diagnostic.replace("\r\n", " | ").replace("\n", " | ")
|
||||
|
||||
|
||||
def _command_context(argv, cwd):
|
||||
return f"argv={list(argv)!r}; cwd={str(cwd)!r}"
|
||||
|
||||
|
||||
def execute_plan(
|
||||
plan,
|
||||
root,
|
||||
*,
|
||||
env=None,
|
||||
total_timeout_seconds=7200,
|
||||
deadline=None,
|
||||
run=subprocess.run,
|
||||
clock=time.monotonic,
|
||||
):
|
||||
root = root.resolve()
|
||||
if deadline is None:
|
||||
deadline = clock() + total_timeout_seconds
|
||||
results = []
|
||||
|
||||
for step in plan.steps:
|
||||
cwd = step.command.cwd.resolve()
|
||||
context = _command_context(step.command.argv, cwd)
|
||||
try:
|
||||
cwd.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise ValidationFailure(
|
||||
f"{step.command.stage} working directory is outside the repository; "
|
||||
f"{context}"
|
||||
) from exc
|
||||
|
||||
remaining = deadline - clock()
|
||||
if remaining <= 0:
|
||||
raise ValidationFailure(
|
||||
f"{step.command.stage} timed out before it could start; {context}"
|
||||
)
|
||||
|
||||
timeout = min(step.command.timeout_seconds, remaining)
|
||||
try:
|
||||
completed = run(
|
||||
list(step.command.argv),
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
shell=False,
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
stdout = _decode(exc.output)
|
||||
stderr = _decode(exc.stderr)
|
||||
diagnostic = _diagnostic(stdout, stderr)
|
||||
message = (
|
||||
f"{step.command.stage} timed out after {timeout} seconds; {context}"
|
||||
)
|
||||
if diagnostic:
|
||||
message = f"{message}; output tail: {diagnostic}"
|
||||
raise ValidationFailure(message) from exc
|
||||
|
||||
stdout = _decode(completed.stdout)
|
||||
stderr = _decode(completed.stderr)
|
||||
result = CommandResult(step.command, completed.returncode, stdout, stderr)
|
||||
if completed.returncode != 0:
|
||||
diagnostic = _diagnostic(stdout, stderr)
|
||||
message = (
|
||||
f"{step.command.stage} failed with exit code {completed.returncode}; "
|
||||
f"{context}"
|
||||
)
|
||||
if diagnostic:
|
||||
message = f"{message}; output tail: {diagnostic}"
|
||||
raise ValidationFailure(message)
|
||||
|
||||
try:
|
||||
for check in step.checks:
|
||||
_check_result(check, result)
|
||||
except ValidationFailure as exc:
|
||||
diagnostic = _diagnostic(stdout, stderr)
|
||||
message = (
|
||||
f"{step.command.stage} result check failed: {exc}; {context}"
|
||||
)
|
||||
if diagnostic:
|
||||
message = f"{message}; output tail: {diagnostic}"
|
||||
raise ValidationFailure(message) from exc
|
||||
results.append(result)
|
||||
|
||||
return tuple(results)
|
||||
@@ -1,169 +0,0 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .models import ProjectKind, Toolchain
|
||||
|
||||
VC_WORKLOAD = "Microsoft.VisualStudio.Component.VC.Tools.x86.x64"
|
||||
VS_CMAKE_RELATIVE = Path(
|
||||
"Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin"
|
||||
)
|
||||
|
||||
|
||||
class ToolchainError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _decode(value):
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return (value or b"").decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _first_path(result, label, argv, cwd):
|
||||
text = _decode(result.stdout).strip()
|
||||
if result.returncode != 0:
|
||||
diagnostic = (
|
||||
_decode(result.stdout) + "\n" + _decode(result.stderr)
|
||||
).rstrip()[-8000:]
|
||||
diagnostic = diagnostic.replace("\r\n", " | ").replace("\n", " | ")
|
||||
message = (
|
||||
f"{label} probe failed with exit code {result.returncode}; "
|
||||
f"argv={argv!r}; cwd={str(cwd)!r}"
|
||||
)
|
||||
if diagnostic:
|
||||
message = f"{message}; output tail: {diagnostic}"
|
||||
raise ToolchainError(
|
||||
f"{message}; install Visual Studio Desktop development with C++"
|
||||
)
|
||||
if not text:
|
||||
raise ToolchainError(
|
||||
f"{label} probe returned no path; argv={argv!r}; cwd={str(cwd)!r}; "
|
||||
"install Visual Studio Desktop development with C++"
|
||||
)
|
||||
path = Path(text.splitlines()[0]).resolve()
|
||||
if not path.exists():
|
||||
diagnostic = (
|
||||
_decode(result.stdout) + "\n" + _decode(result.stderr)
|
||||
).rstrip()[-8000:]
|
||||
diagnostic = diagnostic.replace("\r\n", " | ").replace("\n", " | ")
|
||||
message = (
|
||||
f"{label} probe returned nonexistent path with exit code 0; "
|
||||
f"argv={argv!r}; cwd={str(cwd)!r}"
|
||||
)
|
||||
if diagnostic:
|
||||
message = f"{message}; output tail: {diagnostic}"
|
||||
raise ToolchainError(
|
||||
f"{message}; install or repair Visual Studio Desktop development with C++"
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def _require_path(path, label):
|
||||
if path is None or not path.exists():
|
||||
raise ToolchainError(f"{label} not found")
|
||||
return path.resolve()
|
||||
|
||||
|
||||
def _remaining(deadline, clock, label):
|
||||
if deadline is None:
|
||||
return None
|
||||
remaining = deadline - clock()
|
||||
if remaining <= 0:
|
||||
raise ToolchainError(f"{label} probe timed out before it could start")
|
||||
return remaining
|
||||
|
||||
|
||||
def _probe(run, argv, label, *, deadline, clock):
|
||||
cwd = Path.cwd().resolve()
|
||||
kwargs = {
|
||||
"cwd": cwd,
|
||||
"capture_output": True,
|
||||
"check": False,
|
||||
"shell": False,
|
||||
}
|
||||
remaining = _remaining(deadline, clock, label)
|
||||
if remaining is not None:
|
||||
kwargs["timeout"] = remaining
|
||||
try:
|
||||
return run(argv, **kwargs)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise ToolchainError(
|
||||
f"{label} probe timed out; argv={argv!r}; cwd={str(cwd)!r}; "
|
||||
"install or repair Visual Studio Desktop development with C++"
|
||||
) from exc
|
||||
|
||||
|
||||
def discover_toolchain(
|
||||
kind: ProjectKind,
|
||||
*,
|
||||
env=None,
|
||||
which=shutil.which,
|
||||
run=subprocess.run,
|
||||
deadline=None,
|
||||
clock=time.monotonic,
|
||||
) -> Toolchain:
|
||||
environment = os.environ if env is None else env
|
||||
vswhere_path = which("vswhere.exe")
|
||||
if vswhere_path is None:
|
||||
program_files = environment.get("ProgramFiles(x86)")
|
||||
if program_files:
|
||||
candidate = Path(program_files) / "Microsoft Visual Studio/Installer/vswhere.exe"
|
||||
vswhere_path = str(candidate) if candidate.exists() else None
|
||||
vswhere = _require_path(
|
||||
Path(vswhere_path) if vswhere_path else None,
|
||||
"vswhere.exe; install Visual Studio Desktop development with C++",
|
||||
)
|
||||
|
||||
install_argv = [
|
||||
str(vswhere), "-latest", "-products", "*", "-requires", VC_WORKLOAD,
|
||||
"-property", "installationPath",
|
||||
]
|
||||
install_result = _probe(
|
||||
run,
|
||||
install_argv,
|
||||
"Visual Studio installation",
|
||||
deadline=deadline,
|
||||
clock=clock,
|
||||
)
|
||||
probe_cwd = Path.cwd().resolve()
|
||||
installation = _require_path(
|
||||
_first_path(
|
||||
install_result,
|
||||
"Visual Studio installation",
|
||||
install_argv,
|
||||
probe_cwd,
|
||||
),
|
||||
"Visual Studio installation",
|
||||
)
|
||||
msbuild_argv = [
|
||||
str(vswhere), "-latest", "-products", "*", "-requires", VC_WORKLOAD,
|
||||
"-find", r"MSBuild\**\Bin\MSBuild.exe",
|
||||
]
|
||||
msbuild_result = _probe(
|
||||
run,
|
||||
msbuild_argv,
|
||||
"MSBuild",
|
||||
deadline=deadline,
|
||||
clock=clock,
|
||||
)
|
||||
msbuild = _require_path(
|
||||
_first_path(msbuild_result, "MSBuild", msbuild_argv, probe_cwd),
|
||||
"MSBuild.exe",
|
||||
)
|
||||
|
||||
if kind is not ProjectKind.CMAKE:
|
||||
return Toolchain(installation=installation, msbuild=msbuild)
|
||||
|
||||
bundled_cmake_dir = installation / VS_CMAKE_RELATIVE
|
||||
cmake = _require_path(
|
||||
Path(which("cmake.exe")) if which("cmake.exe") else bundled_cmake_dir / "cmake.exe",
|
||||
"cmake.exe",
|
||||
)
|
||||
ctest = _require_path(
|
||||
Path(which("ctest.exe")) if which("ctest.exe") else bundled_cmake_dir / "ctest.exe",
|
||||
"ctest.exe",
|
||||
)
|
||||
return Toolchain(installation=installation, msbuild=msbuild, cmake=cmake, ctest=ctest)
|
||||
@@ -1,306 +0,0 @@
|
||||
"""Measure whole native processes; reference comparison remains a separate step."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CASES = (
|
||||
("hexa_nonlinearstatic", ROOT / "reference/hexa/hexa_nonlinearstatic.inp", 2.0),
|
||||
("hexa_dynnonlinear", ROOT / "reference/hexa/hexa_dynnonlinear.inp", 100.0),
|
||||
("shellNL-dyn-meshfine", ROOT / "reference/shellNL-dyn-meshfine/shellNL-dyn-meshfine.inp", 100.0),
|
||||
)
|
||||
THREAD_KEYS = ("MKL_NUM_THREADS", "MKL_DYNAMIC", "MKL_THREADING_LAYER", "MKL_CBWR",
|
||||
"TBB_NUM_THREADS", "OMP_NUM_THREADS", "OMP_DYNAMIC", "OMP_PROC_BIND",
|
||||
"OMP_PLACES", "KMP_AFFINITY", "KMP_BLOCKTIME", "FESA_NUM_THREADS")
|
||||
|
||||
|
||||
def sha256(path):
|
||||
if not path.is_file():
|
||||
return None
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def publish(path, value):
|
||||
"""Expose only a fully serialized record; retain the last closed report."""
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(json.dumps(value, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def command_output(command):
|
||||
try:
|
||||
result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True,
|
||||
errors="replace", timeout=30, check=False)
|
||||
return {"command": command, "exit": result.returncode,
|
||||
"output": (result.stdout + result.stderr).strip()}
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
return {"command": command, "exit": None, "error": str(error)}
|
||||
|
||||
|
||||
def collect_environment(exe):
|
||||
"""Capture actual build/runtime evidence outside every timed interval."""
|
||||
build = next((p for p in exe.parents if (p / "CMakeCache.txt").is_file()), None)
|
||||
cache = (build / "CMakeCache.txt").read_text(errors="replace") if build else None
|
||||
result = {
|
||||
"source_commit": command_output(["git", "rev-parse", "HEAD"]),
|
||||
"source_status": command_output(["git", "status", "--porcelain"]),
|
||||
"measurement_tool_sha256": sha256(Path(__file__)),
|
||||
"comparator_sha256": {name: sha256(ROOT / "scripts/verification" / name)
|
||||
for name in ("compare_hex8.py", "compare_hht_dynamic.py")},
|
||||
"source_diff_sha256": hashlib.sha256(command_output(["git", "diff", "HEAD", "--", "include", "src", "cmake", "CMakeLists.txt", "CMakePresets.json"])["output"].encode()).hexdigest(),
|
||||
"exe": str(exe), "exe_sha256": sha256(exe),
|
||||
"os": platform.platform(), "cpu": platform.processor(),
|
||||
"logical_processors": os.cpu_count(),
|
||||
"thread_environment": {key: os.environ.get(key) for key in THREAD_KEYS},
|
||||
"related_environment": {key: value for key, value in sorted(os.environ.items())
|
||||
if key.startswith(("MKL_", "TBB_", "OMP_", "KMP_", "FESA_"))},
|
||||
"runtime_path": os.environ.get("PATH"),
|
||||
"thread_settings_note": "Environment requests; TBB uses runtime default (TBB_NUM_THREADS is not an enforced cap). Actual thread use requires profile evidence.",
|
||||
"build_type_flags_dependencies": cache,
|
||||
"build_files": {str(p): sha256(p) for p in ((build / "CMakeCache.txt", build / "build.ninja") if build else ())},
|
||||
"runtime_dlls": {p.name: sha256(p) for p in sorted(exe.parent.glob("*.dll"))},
|
||||
"compiler": command_output(["cl"]),
|
||||
"vtune": command_output([os.environ.get("VTUNE_EXE", "vtune"), "-version"]),
|
||||
}
|
||||
if os.name == "nt":
|
||||
import ctypes
|
||||
class PowerStatus(ctypes.Structure):
|
||||
_fields_ = [("ac_line", ctypes.c_ubyte), ("battery_flag", ctypes.c_ubyte),
|
||||
("battery_percent", ctypes.c_ubyte), ("reserved", ctypes.c_ubyte),
|
||||
("battery_life", ctypes.c_ulong), ("battery_full_life", ctypes.c_ulong)]
|
||||
power = PowerStatus()
|
||||
obtained = bool(ctypes.windll.kernel32.GetSystemPowerStatus(ctypes.byref(power)))
|
||||
result["system_power"] = {"obtained": obtained,
|
||||
"ac_line_status": power.ac_line if obtained else None,
|
||||
"battery_flag": power.battery_flag if obtained else None}
|
||||
result["power_scheme"] = command_output(["powercfg", "/getactivescheme"])
|
||||
machine = command_output([
|
||||
"powershell", "-NoProfile", "-Command",
|
||||
"ConvertTo-Json -Depth 4 @{boot=(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToString('o'); cpu=(Get-CimInstance Win32_Processor | Select-Object Name,NumberOfCores,NumberOfLogicalProcessors); battery=(Get-CimInstance Win32_Battery | Select-Object BatteryStatus); runtime=(Get-ChildItem -LiteralPath '"
|
||||
+ str(exe.parent).replace("'", "''")
|
||||
+ "' -Filter *.dll | ForEach-Object { @{name=$_.Name;version=$_.VersionInfo.FileVersion} })}"])
|
||||
if machine.get("exit") == 0:
|
||||
machine["output"] = json.loads(machine["output"])
|
||||
result["machine_power_runtime_versions"] = machine
|
||||
return result
|
||||
|
||||
|
||||
def validate_result(case, scratch, result):
|
||||
"""Reuse established inventory/finite prechecks without claiming comparison."""
|
||||
name = "compare_hex8" if case.startswith("hexa_") else "compare_hht_dynamic"
|
||||
spec = importlib.util.spec_from_file_location(name, ROOT / f"scripts/verification/{name}.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
if case.startswith("hexa_"):
|
||||
model = module.input_inventory(scratch, case)
|
||||
values = module.read_results(result, model)
|
||||
module.check_result_values(values, model)
|
||||
return {**values["evidence"], "physical_values_checked": True}
|
||||
nodes, identity = module.input_inventory(scratch)
|
||||
_, evidence = module.read_results(result, nodes, identity)
|
||||
import h5py
|
||||
with h5py.File(result, "r") as file:
|
||||
if int(file["metadata"].attrs["schema_version"]) != 2:
|
||||
raise ValueError("Shell performance requires schema v2")
|
||||
source = file["metadata"].attrs["source_input_identity"]
|
||||
if isinstance(source, bytes):
|
||||
source = source.decode()
|
||||
if source != f"path={scratch.absolute().as_posix()};content_identity={identity}":
|
||||
raise ValueError("Shell result scratch path identity mismatch")
|
||||
return evidence
|
||||
|
||||
|
||||
def run_once(exe, original, case, directory, index, timeout=None):
|
||||
directory.mkdir(parents=True, exist_ok=False)
|
||||
scratch = directory / original.name
|
||||
original_before = sha256(original)
|
||||
shutil.copyfile(original, scratch)
|
||||
scratch_before = sha256(scratch)
|
||||
if original_before != scratch_before:
|
||||
raise ValueError("Input copy differs from source bytes")
|
||||
result = scratch.with_name(scratch.stem + "_results.h5")
|
||||
native_path, stdout_path = directory / "native.json", directory / "stdout.txt"
|
||||
command = [str(exe), str(scratch)]
|
||||
state, native_exit, error, process = "completed", None, None, None
|
||||
with stdout_path.open("wb") as output:
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
process = subprocess.Popen(command, cwd=directory, stdout=output, stderr=subprocess.STDOUT)
|
||||
native_exit = process.wait(timeout=timeout)
|
||||
except OSError as exception:
|
||||
state, error = "launch-failed", str(exception)
|
||||
except (KeyboardInterrupt, subprocess.TimeoutExpired) as exception:
|
||||
state = "interrupted" if isinstance(exception, KeyboardInterrupt) else "timed-out"
|
||||
error = str(exception)
|
||||
if process is not None:
|
||||
process.kill()
|
||||
process.communicate()
|
||||
native_exit = process.returncode
|
||||
finally:
|
||||
elapsed = time.perf_counter() - start
|
||||
native = {
|
||||
"case": case, "command": command, "cwd": str(directory), "native_exit": native_exit,
|
||||
"execution_state": state, "original_sha256": original_before,
|
||||
"scratch_sha256": scratch_before, "result_sha256": sha256(result),
|
||||
"source_unchanged": sha256(original) == original_before,
|
||||
"scratch_unchanged": sha256(scratch) == scratch_before, "result_exists": result.is_file(),
|
||||
"original_sha256_after": sha256(original), "scratch_sha256_after": sha256(scratch),
|
||||
"exe_sha256": sha256(exe), "elapsed_seconds": elapsed,
|
||||
}
|
||||
publish(native_path, native)
|
||||
valid = (state == "completed" and native_exit == 0 and native["result_exists"]
|
||||
and native["source_unchanged"] and native["scratch_unchanged"]
|
||||
and math.isfinite(elapsed) and elapsed >= 0)
|
||||
evidence, validation_state = None, "not-run"
|
||||
if valid:
|
||||
try:
|
||||
evidence = validate_result(case, scratch, result)
|
||||
validation_state = "passed"
|
||||
except KeyboardInterrupt:
|
||||
valid, validation_state = False, "interrupted"
|
||||
error = "Result validation interrupted after native completion"
|
||||
except Exception as exception:
|
||||
valid, validation_state, error = False, "failed", str(exception)
|
||||
row = {**native, "kind": "measurement",
|
||||
"measurement_index": index, "original_input": str(original), "scratch_input": str(scratch),
|
||||
"result": str(result), "native_record": str(native_path), "stdout_log": str(stdout_path),
|
||||
"native_record_sha256": sha256(native_path),
|
||||
"solver_log": str(scratch.with_name(scratch.stem + "_log.txt")),
|
||||
"comparison_status": "pending", "comparison_report": None,
|
||||
"execution_valid": valid, "result_precheck": evidence,
|
||||
"result_validation_state": validation_state, "error": error}
|
||||
publish(directory / "run.json", row)
|
||||
return row
|
||||
|
||||
|
||||
def measure(exe, output_dir, cases=CASES):
|
||||
if output_dir.exists() and any(output_dir.iterdir()):
|
||||
raise FileExistsError(f"Nonempty artifact directory: {output_dir}")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
environment = collect_environment(exe)
|
||||
report = {"schema": "fesa-performance-v1", "environment": environment, "cases": [],
|
||||
"protocol": {"warmup_runs": 0, "measurement_runs": 1,
|
||||
"cache_preparation": "no dedicated warmup or cache reset"},
|
||||
"execution_complete": False}
|
||||
path = output_dir / "measurements.json"
|
||||
publish(path, report)
|
||||
for case, original, target in cases:
|
||||
case_report = {"case": case, "target_seconds": target, "runs": [],
|
||||
"all_measured_runs_within_target": False}
|
||||
report["cases"].append(case_report)
|
||||
if collect_environment(exe) != environment:
|
||||
report["error"] = "Source, binary, build or environment changed during measurement"
|
||||
publish(path, report)
|
||||
return 1
|
||||
row = run_once(exe, original, case, output_dir / case / "run-1", 1)
|
||||
native_valid = row["execution_valid"]
|
||||
row["execution_valid"], row["within_target"] = False, False
|
||||
row["environment_validation_state"] = "pending"
|
||||
case_report["runs"].append(row)
|
||||
publish(Path(row["native_record"]).parent / "run.json", row)
|
||||
publish(path, report)
|
||||
try:
|
||||
unchanged = collect_environment(exe) == environment
|
||||
except KeyboardInterrupt:
|
||||
row["environment_validation_state"] = "interrupted"
|
||||
row["error"] = "Environment validation interrupted after native completion"
|
||||
except Exception as error:
|
||||
row["environment_validation_state"] = "failed"
|
||||
row["error"] = f"Environment validation failed after native completion: {error}"
|
||||
else:
|
||||
row["environment_validation_state"] = "passed" if unchanged else "changed"
|
||||
row["execution_valid"] = native_valid and unchanged
|
||||
row["within_target"] = row["execution_valid"] and row["elapsed_seconds"] <= target
|
||||
if not unchanged:
|
||||
row["error"] = "Source, binary, build or environment changed during native execution"
|
||||
publish(Path(row["native_record"]).parent / "run.json", row)
|
||||
case_report["all_measured_runs_within_target"] = row["within_target"]
|
||||
publish(path, report)
|
||||
print(f"{case} measurement 1: {row['elapsed_seconds']:.6f}s exit={row['native_exit']} valid={row['execution_valid']}", flush=True)
|
||||
if not row["execution_valid"]:
|
||||
return 1
|
||||
report["execution_complete"] = True
|
||||
publish(path, report)
|
||||
return 0
|
||||
|
||||
|
||||
def link_comparisons(output_dir):
|
||||
"""Attach closed comparator reports by hashes without changing raw timing."""
|
||||
path = output_dir / "measurements.json"
|
||||
report = json.loads(path.read_text(encoding="utf-8"))
|
||||
for case in report["cases"]:
|
||||
for row in case["runs"]:
|
||||
comparison_path = Path(row["native_record"]).parent / "comparison.json"
|
||||
if not comparison_path.is_file():
|
||||
continue
|
||||
comparison = json.loads(comparison_path.read_text(encoding="utf-8"))
|
||||
identities = comparison.get("identities", {})
|
||||
native_path = Path(row["native_record"])
|
||||
native = json.loads(native_path.read_text(encoding="utf-8"))
|
||||
if (sha256(native_path) != row["native_record_sha256"]
|
||||
or row["elapsed_seconds"] != native["elapsed_seconds"]
|
||||
or sha256(Path(row["scratch_input"])) != row["scratch_sha256"]
|
||||
or sha256(Path(row["result"])) != row["result_sha256"]
|
||||
or identities.get("input", {}).get("sha256") != row["scratch_sha256"]
|
||||
or identities.get("results", {}).get("sha256") != row["result_sha256"]
|
||||
or comparison.get("case") != case["case"]):
|
||||
raise ValueError("Comparison does not identify the recorded native run/result")
|
||||
if case["case"].startswith("hexa_") and comparison.get("blocking_passed") and (
|
||||
comparison.get("execution", {}).get("record_sha256") != row["native_record_sha256"]):
|
||||
raise ValueError("Hex8 comparator did not verify this execution record")
|
||||
row["comparison_status"] = "passed" if (
|
||||
row["execution_valid"] and comparison.get("precheck", {}).get("passed")
|
||||
and comparison.get("blocking_passed") and comparison.get("verdict") == "passed"
|
||||
) else "failed"
|
||||
row["comparison_report"] = str(comparison_path)
|
||||
row["comparison_report_sha256"] = sha256(comparison_path)
|
||||
report["comparisons_complete"] = (report["execution_complete"] and all(
|
||||
row["comparison_status"] != "pending" for case in report["cases"] for row in case["runs"]))
|
||||
report["all_comparisons_passed"] = (report["comparisons_complete"] and all(
|
||||
row["comparison_status"] == "passed" for case in report["cases"] for row in case["runs"]))
|
||||
publish(path, report)
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--exe", type=Path)
|
||||
parser.add_argument("--output-dir", required=True, type=Path)
|
||||
parser.add_argument("--link-comparisons", action="store_true",
|
||||
help="Attach existing per-run comparison.json by native/input/result hashes.")
|
||||
args = parser.parse_args()
|
||||
if args.link_comparisons:
|
||||
try:
|
||||
return link_comparisons(args.output_dir.resolve())
|
||||
except (OSError, ValueError, KeyError) as error:
|
||||
print(f"comparison link failed: {error}", flush=True)
|
||||
return 1
|
||||
if args.exe is None:
|
||||
parser.error("--exe is required for measurement")
|
||||
if not args.exe.is_file():
|
||||
parser.error("--exe must be an existing executable")
|
||||
if args.output_dir.exists() and (not args.output_dir.is_dir() or any(args.output_dir.iterdir())):
|
||||
parser.error("--output-dir must be new or empty")
|
||||
try:
|
||||
return measure(args.exe.resolve(), args.output_dir.resolve())
|
||||
except (OSError, ValueError, KeyboardInterrupt) as error:
|
||||
print(f"measurement failed: {error}", flush=True)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,14 +0,0 @@
|
||||
# 외부 결과 비교 도구
|
||||
|
||||
이 영역은 승인된 reference와 FESA HDF5의 deterministic comparison을 소유한다.
|
||||
솔버 실행, reference 생성·수정과 물리식 채택을 소유하지 않는다.
|
||||
|
||||
- 비교 전 필요한 source instance·label·component·step/frame을 일대일 대응한다.
|
||||
누락·추가·중복·nonfinite와 schema 오류를 tolerance로 무시하지 않는다.
|
||||
- Reference 값만으로 family scale을 만들고 approved near-zero·row·RMS·zero-scale 규칙을
|
||||
적용한다. 임의 absolute floor·값 보정·nearest-time matching을 추가하지 않는다.
|
||||
- 현재 HHT 비교기는 지정된 729-node 모델과 101 frames의 선택 절점 U/UR용이다.
|
||||
다른 모델을 인자로 받는다는 이유로 범용 comparator라 설명하지 않는다.
|
||||
- U blocking과 UR warning-only를 유지하고 warning도 결정적인 순서로 보고한다.
|
||||
- Identity 변형·frame 누락·비유한 값·tolerance 경계·zero scale·report 재현성을 검증한다.
|
||||
변위 비교 성공을 가속도·에너지·시간수렴 검증으로 확대하지 않는다.
|
||||
@@ -1,807 +0,0 @@
|
||||
"""Compare original Hex8 CSV/TXT results with Solid HDF5; never launch a solver.
|
||||
|
||||
This entry point is intentionally restricted to reference/hexa's fixed original
|
||||
models. Identity/schema checks precede finite checks and the common tolerance.
|
||||
Rounded HDF5 observables are compared; internal high+low solver gates are not
|
||||
recomputed from them. Missing original modal Value data remain NOT RUN.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
from decimal import Decimal, InvalidOperation
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import h5py
|
||||
import numpy as np
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
REFERENCE_DIRECTORY = ROOT / "reference/hexa"
|
||||
STEP = "Step-1"
|
||||
INSTANCE = "PART-1-1"
|
||||
NODE_COUNT = 442
|
||||
ELEMENT_COUNT = 300
|
||||
POLICY = {"relative_tolerance": 0.05, "relative_rms_tolerance": 0.01,
|
||||
"near_zero_ratio": 0.01, "zero_scale": "exact-zero"}
|
||||
CASES = {
|
||||
"hexa_linearstatic": "linear-static",
|
||||
"hexa_nonlinearstatic": "nonlinear-static-full-newton",
|
||||
"hexa_dynlinear": "hht-linear-dynamics",
|
||||
"hexa_dynnonlinear": "hht-nonlinear-dynamics",
|
||||
"hexa_freq": "linear-frequency",
|
||||
}
|
||||
# Working-tree bytes (Windows CRLF), not Git's normalized LF blobs.
|
||||
SOURCE_SHA256 = {
|
||||
"hexa_dynlinear.inp": "f8db81b8ea56b689ab0ad892e6a84b7c6a0162c9d3ff95328ea99722f3de078e",
|
||||
"hexa_dynlinear_U.csv": "623c20489e8cc18d23fa1728563ce5a26059f321861514ef8569e3d204ef6cb5",
|
||||
"hexa_dynnonlinear.inp": "1685f86f844e8020bf46e1747b949699ac9aff0d7570bd21ec95109399365ff9",
|
||||
"hexa_dynnonlinear_U.csv": "f5832a24577f54838bb49dec86a0dc9668cf43e78953f718a9d3635e0f574226",
|
||||
"hexa_freq.inp": "42cbcec2a8b738f8b6d2c1e13114b8840dccdde6fc3e6f8a43a3272e85d82347",
|
||||
"hexa_freq.txt": "3d2dacbee13cd2cc8d3f7c1a38210611a72de41d5e31d169e636bf40e00d4370",
|
||||
"hexa_linearstatic.inp": "67183909c5dc962a4d4b65ffb81f7bd65b72e89a5d015c8369a8e0226347a647",
|
||||
"hexa_linearstatic_RF.csv": "3e04e71ec075ffef098cf4870d77f9c400557fccb375369da8bf67cf67c1e36b",
|
||||
"hexa_linearstatic_S.csv": "8840a502b9d28a91e5685349301a56fed37d2ac62327b71304716b714db577e8",
|
||||
"hexa_linearstatic_U.csv": "1ff49aea3ec601cfd796d6a2fb6a4f91be3fc3de9426cdf3744d981216d4bd5f",
|
||||
"hexa_nonlinearstatic.inp": "7858254025b96c6bfe3fb6498605ecf9c473425c2bc3593f5fbdc6902fd7c6cb",
|
||||
"hexa_nonlinearstatic_RF.csv": "3ba67a4fb75b2ce3edee13d35b2ea66ac7d52fc16e612c086ed1122a834fee7b",
|
||||
"hexa_nonlinearstatic_S.csv": "a8866a730f0d65b1e3921d504d255b72158b4a2b9ee30fc691712d94982b07ba",
|
||||
"hexa_nonlinearstatic_U.csv": "bd6c2fe2b2bb2ffb0bc2ea52f67513c3967a396b5707bbebc9533fde7be25ac6",
|
||||
}
|
||||
BASE_COLUMNS = ["ODB Name", "Step", "Frame", "Part Instance Name"]
|
||||
TAIL_COLUMNS = ["X", "Y", "Z", "Section Name", "Material Name", "Section Point"]
|
||||
COMPONENTS = {"U": ["U1", "U2", "U3"], "RF": ["RF1", "RF2", "RF3"],
|
||||
"S": ["S11", "S22", "S33", "S12", "S13", "S23"]}
|
||||
STRESS_DERIVED = ["Mises", "Max. Principal", "Max. Principal (Abs)",
|
||||
"Mid. Principal", "Min. Principal", "Tresca", "Pressure", "Third Invariant"]
|
||||
NATURAL_NODES = np.array([[-1, -1, -1], [1, -1, -1], [1, 1, -1], [-1, 1, -1],
|
||||
[-1, -1, 1], [1, -1, 1], [1, 1, 1], [-1, 1, 1]])
|
||||
NATURAL_POINTS = np.array([[-1, -1, -1], [1, -1, -1], [-1, 1, -1], [1, 1, -1],
|
||||
[-1, -1, 1], [1, -1, 1], [-1, 1, 1], [1, 1, 1]]) / math.sqrt(3.0)
|
||||
FRAME_PATTERN = re.compile(
|
||||
r"Increment\s+(\d+):\s*Step Time\s*=\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)"
|
||||
)
|
||||
|
||||
|
||||
class ComparisonError(ValueError):
|
||||
def __init__(self, code: str, message: str):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def require(condition: bool, code: str, message: str):
|
||||
if not condition:
|
||||
raise ComparisonError(code, message)
|
||||
|
||||
|
||||
def canonical(value: str) -> str:
|
||||
"""Abaqus ASCII case folding only; distinct instances never coalesce."""
|
||||
return value.translate(str.maketrans("abcdefghijklmnopqrstuvwxyz", "ABCDEFGHIJKLMNOPQRSTUVWXYZ"))
|
||||
|
||||
|
||||
def label(value: str) -> int:
|
||||
require(re.fullmatch(r"[0-9]+", value) is not None, "invalid-source-label", value)
|
||||
number = int(value)
|
||||
require(0 < number <= 2**63 - 1, "invalid-source-label", value)
|
||||
return number
|
||||
|
||||
|
||||
def lexical_absolute(path: Path) -> str:
|
||||
"""Match C++ absolute.lexically_normal without resolving physical aliases."""
|
||||
return Path(os.path.abspath(path)).as_posix()
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
with path.open("rb") as stream:
|
||||
return hashlib.file_digest(stream, "sha256").hexdigest()
|
||||
|
||||
|
||||
def fnv1a64(raw: bytes) -> str:
|
||||
value = 14695981039346656037
|
||||
for byte in raw:
|
||||
value = ((value ^ byte) * 1099511628211) & (2**64 - 1)
|
||||
return f"fnv1a64:{value:016x}"
|
||||
|
||||
|
||||
def input_inventory(path: Path, case: str) -> dict[str, Any]:
|
||||
"""Read only the frozen single-part identity-instance original subset."""
|
||||
require(case in CASES, "unknown-source-case", case)
|
||||
require(sha256(path) == SOURCE_SHA256[f"{case}.inp"], "source-input-hash-mismatch", str(path))
|
||||
nodes, elements, sets, boundaries = {}, {}, {}, []
|
||||
keyword, options, in_assembly = "", {}, False
|
||||
model = {"case": case, "nodes": nodes, "elements": elements, "node_sets": sets,
|
||||
"section_line": None, "source_path": lexical_absolute(path),
|
||||
"content_identity": fnv1a64(path.read_bytes())}
|
||||
for line_number, raw in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1):
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("**"):
|
||||
continue
|
||||
if line.startswith("*"):
|
||||
tokens = [token.strip() for token in line[1:].split(",")]
|
||||
keyword = canonical(tokens[0])
|
||||
options = {canonical(token.split("=", 1)[0]): token.split("=", 1)[1].strip()
|
||||
for token in tokens[1:] if "=" in token}
|
||||
if keyword == "ASSEMBLY":
|
||||
in_assembly = True
|
||||
elif keyword == "END ASSEMBLY":
|
||||
in_assembly = False
|
||||
elif keyword == "INSTANCE":
|
||||
require(canonical(options["NAME"]) == INSTANCE and len(tokens) == 3,
|
||||
"unsupported-source-instance", line)
|
||||
elif keyword == "SOLID SECTION":
|
||||
model["section_line"] = line_number
|
||||
model["section_elset"] = options["ELSET"]
|
||||
model["material"] = options["MATERIAL"]
|
||||
elif keyword == "STEP":
|
||||
require(options["NAME"] == STEP, "wrong-source-step", line)
|
||||
continue
|
||||
tokens = [token.strip() for token in line.split(",") if token.strip()]
|
||||
if keyword == "NODE":
|
||||
node = label(tokens[0])
|
||||
require(node not in nodes and len(tokens) == 4, "source-node-inventory", line)
|
||||
nodes[node] = tuple(float(value) for value in tokens[1:])
|
||||
elif keyword == "ELEMENT":
|
||||
element = label(tokens[0])
|
||||
require(element not in elements and len(tokens) == 9 and options["TYPE"] == "C3D8",
|
||||
"source-element-inventory", line)
|
||||
elements[element] = tuple(label(value) for value in tokens[1:])
|
||||
elif keyword == "NSET" and in_assembly:
|
||||
name = canonical(options["NSET"])
|
||||
require(canonical(options["INSTANCE"]) == INSTANCE, "wrong-source-instance", line)
|
||||
sets.setdefault(name, []).extend(label(value) for value in tokens)
|
||||
elif keyword == "BOUNDARY":
|
||||
first, last = int(tokens[1]), int(tokens[2])
|
||||
require(len(tokens) == 3 or float(tokens[3]) == 0.0, "unsupported-source-boundary", line)
|
||||
boundaries.append((canonical(tokens[0]), first, last))
|
||||
elif keyword == "DENSITY":
|
||||
model["density"] = float(tokens[0])
|
||||
elif keyword == "ELASTIC":
|
||||
model["elastic"] = tuple(float(value) for value in tokens)
|
||||
require(set(nodes) == set(range(1, NODE_COUNT + 1))
|
||||
and set(elements) == set(range(1, ELEMENT_COUNT + 1)),
|
||||
"source-model-inventory", "Expected original 442 nodes and 300 elements.")
|
||||
constrained = set()
|
||||
for name, first, last in boundaries:
|
||||
require(name in sets and 1 <= first <= last <= 6, "source-boundary-identity", name)
|
||||
constrained.update((node, component) for node in sets[name]
|
||||
for component in range(first, min(last, 3) + 1))
|
||||
model["constrained"] = constrained
|
||||
return model
|
||||
|
||||
|
||||
def point_coordinates(model, element: int, point: int):
|
||||
xi = NATURAL_POINTS[point - 1]
|
||||
shape = np.prod(1.0 + NATURAL_NODES * xi, axis=1) / 8.0
|
||||
coordinates = np.array([model["nodes"][node] for node in model["elements"][element]])
|
||||
terms = shape[:, None] * coordinates
|
||||
return np.sum(terms, axis=0), np.sum(np.abs(terms), axis=0)
|
||||
|
||||
|
||||
def read_reference(path: Path, case: str, quantity: str, model):
|
||||
"""Validate all row identities before converting any physical values."""
|
||||
require(quantity in COMPONENTS, "unknown-quantity", quantity)
|
||||
dynamic = case.startswith("hexa_dyn")
|
||||
require(not dynamic or quantity == "U", "wrong-reference-quantity", quantity)
|
||||
extra = (["Element Label", "IntPt"] if quantity == "S" else ["Node Label"])
|
||||
suffix = ([f"S-{name}" for name in STRESS_DERIVED] if quantity == "S"
|
||||
else [f"{quantity}-Magnitude"])
|
||||
headers = BASE_COLUMNS + extra + TAIL_COLUMNS + suffix + [f"{quantity}-{c}" for c in COMPONENTS[quantity]]
|
||||
records = {}
|
||||
with path.open(newline="", encoding="utf-8-sig") as stream:
|
||||
reader = csv.reader(stream)
|
||||
fields = [field.strip() for field in next(reader, [])]
|
||||
require(len(fields) == len(set(fields)), "duplicate-reference-header", str(path))
|
||||
require(set(fields) == set(headers), "wrong-reference-columns", str(fields))
|
||||
for row_number, values in enumerate(reader, 2):
|
||||
require(len(values) == len(fields), "wrong-reference-row-width", str(row_number))
|
||||
row = dict(zip(fields, [value.strip() for value in values]))
|
||||
require(row["ODB Name"].replace("\\", "/").rsplit("/", 1)[-1] == f"{case}.odb",
|
||||
"wrong-reference-case", str(row_number))
|
||||
require(row["Step"] == STEP, "wrong-reference-step", str(row_number))
|
||||
require(canonical(row["Part Instance Name"]) == INSTANCE,
|
||||
"wrong-reference-instance", str(row_number))
|
||||
match = FRAME_PATTERN.fullmatch(row["Frame"])
|
||||
require(match is not None, "invalid-reference-frame", str(row_number))
|
||||
increment = int(match[1])
|
||||
time = Decimal(match[2])
|
||||
require(time.is_finite() and increment in (range(101) if dynamic else (10,))
|
||||
and time == (Decimal(increment) / 10 if dynamic else Decimal(1)),
|
||||
"wrong-reference-frame-time", str(row_number))
|
||||
entity = label(row["Element Label" if quantity == "S" else "Node Label"])
|
||||
point = label(row["IntPt"]) if quantity == "S" else 0
|
||||
require(entity in model["elements" if quantity == "S" else "nodes"],
|
||||
"extra-reference-entity", str(row_number))
|
||||
require(quantity != "S" or 1 <= point <= 8, "wrong-reference-point", str(row_number))
|
||||
require(row["Section Point"] == ""
|
||||
and canonical(row["Material Name"]) == ("MATERIAL-1" if quantity == "S" else "NA")
|
||||
and canonical(row["Section Name"]) == ("ASSEMBLY_PART-1-1_SET-1" if quantity == "S" else "NA"),
|
||||
"wrong-reference-section", str(row_number))
|
||||
key = (increment, entity, point)
|
||||
require(key not in records, "duplicate-reference-identity", str(key))
|
||||
records[key] = row
|
||||
entities = model["elements" if quantity == "S" else "nodes"]
|
||||
expected = {(n, entity, p) for n in (range(101) if dynamic else (10,))
|
||||
for entity in entities for p in (range(1, 9) if quantity == "S" else (0,))}
|
||||
require(records.keys() == expected, "reference-inventory", "Missing reference identities.")
|
||||
return records
|
||||
|
||||
|
||||
def reference_values(records, quantity, model):
|
||||
result = {}
|
||||
for key in sorted(records):
|
||||
row = records[key]
|
||||
values = [float(row[f"{quantity}-{component}"]) for component in COMPONENTS[quantity]]
|
||||
coordinates = np.array([float(row[axis]) for axis in ("X", "Y", "Z")])
|
||||
# Every exported numeric column is checked, including unused derived values.
|
||||
derived = STRESS_DERIVED if quantity == "S" else ["Magnitude"]
|
||||
other = [float(row[f"{quantity}-{name}"]) for name in derived]
|
||||
require(all(math.isfinite(value) for value in values + other) and np.isfinite(coordinates).all(),
|
||||
"nonfinite-reference-value", str(key))
|
||||
if quantity == "S":
|
||||
expected, scale = point_coordinates(model, key[1], key[2])
|
||||
else:
|
||||
expected = np.array(model["nodes"][key[1]])
|
||||
scale = np.abs(expected)
|
||||
budget = 16.0 * np.finfo(np.float32).eps * scale
|
||||
require((np.abs(coordinates - expected) <= budget).all(),
|
||||
"reference-coordinate-convention", str(key))
|
||||
result[key] = values
|
||||
return result
|
||||
|
||||
|
||||
def evaluate_family(identity, result, reference):
|
||||
require(len(result) == len(reference) == len(identity["rows"]) and len(result) > 0,
|
||||
"scalar-inventory", identity["quantity"])
|
||||
require(all(math.isfinite(value) for value in (*result, *reference)),
|
||||
"nonfinite-comparison-value", "Comparison values must be finite.")
|
||||
scale = max(abs(value) for value in reference)
|
||||
band = POLICY["near_zero_ratio"] * scale
|
||||
rows, error_norm = [], 0.0
|
||||
for key, actual, expected in zip(identity["rows"], result, reference):
|
||||
error = abs(actual - expected)
|
||||
require(math.isfinite(error), "nonfinite-comparison-value", str(key))
|
||||
error_norm = math.hypot(error_norm, error)
|
||||
require(math.isfinite(error_norm), "nonfinite-comparison-value", str(key))
|
||||
relative = None
|
||||
if scale == 0.0:
|
||||
branch, threshold, passed = "zero-scale-exact", 0.0, error == 0.0
|
||||
elif abs(expected) <= band:
|
||||
branch, threshold, passed = "near-zero", band, error <= band
|
||||
else:
|
||||
branch, threshold = "relative", POLICY["relative_tolerance"] * abs(expected)
|
||||
relative = error / abs(expected)
|
||||
require(math.isfinite(relative), "nonfinite-comparison-value", str(key))
|
||||
passed = relative <= POLICY["relative_tolerance"]
|
||||
rows.append({**key, "fesa_value": actual, "reference_value": expected,
|
||||
"absolute_error": error, "relative_error": relative, "branch": branch,
|
||||
"threshold": threshold, "passed": passed})
|
||||
rows_passed = all(row["passed"] for row in rows)
|
||||
absolute_rms = error_norm / math.sqrt(len(rows))
|
||||
relative_rms = absolute_rms / scale if scale else None
|
||||
require(relative_rms is None or math.isfinite(relative_rms), "nonfinite-comparison-value", "RMS")
|
||||
rms_passed = rows_passed if not scale else relative_rms <= POLICY["relative_rms_tolerance"]
|
||||
return {**{k: v for k, v in identity.items() if k != "rows"}, "impact": "blocking",
|
||||
"row_count": len(rows), "reference_scale": scale, "near_zero_band": band,
|
||||
"maximum_absolute_error": max(row["absolute_error"] for row in rows),
|
||||
"absolute_rms": absolute_rms, "relative_rms": relative_rms,
|
||||
"policy": dict(POLICY), "rows": rows, "rows_passed": rows_passed,
|
||||
"rms_passed": rms_passed, "passed": rows_passed and rms_passed,
|
||||
"diagnostic_code": "zero-reference-scale-nonzero-error" if not scale and not rows_passed else None}
|
||||
|
||||
|
||||
def read_modal_reference(path: Path):
|
||||
"""Read the supplied hexa_freq.txt Value=lambda, Freq=cycles/time format.
|
||||
|
||||
This artifact has no step field: its fixed case is bound to the sole Step-1
|
||||
of the hash-checked hexa_freq.inp. Freq is finite auxiliary data; comparison
|
||||
uses the printed Value directly, without frequency conversion or rounding.
|
||||
"""
|
||||
mapping = {}
|
||||
for line in path.read_text(encoding="utf-8-sig").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
match = re.fullmatch(
|
||||
r"\s*Mode\s+([0-9]+):\s+Value\s*=\s*(\S+)\s+Freq\s*=\s*(\S+)\s+\(cycles/time\)\s*", line)
|
||||
require(match is not None, "wrong-modal-reference-format", line)
|
||||
mode = int(match[1])
|
||||
require(mode in range(1, 11), "wrong-modal-mode", str(mode))
|
||||
require(mode not in mapping, "duplicate-modal-mode", str(mode))
|
||||
mapping[mode] = (match[2], match[3])
|
||||
require(set(mapping) == set(range(1, 11)), "modal-mode-inventory", "Lowest ten modes required.")
|
||||
rows = []
|
||||
for mode in range(1, 11):
|
||||
value, frequency = (float(token) for token in mapping[mode])
|
||||
require(math.isfinite(value) and math.isfinite(frequency), "nonfinite-modal-reference", str(mode))
|
||||
require(value >= 0 and frequency >= 0, "invalid-modal-reference-value", str(mode))
|
||||
rows.append({"step": STEP, "mode": mode, "quantity": "eigenvalue",
|
||||
"unit_dimensions": "1/time^2", "value": value})
|
||||
require(all(a["value"] <= b["value"] for a, b in zip(rows, rows[1:])),
|
||||
"wrong-modal-order", str(path))
|
||||
return rows
|
||||
|
||||
|
||||
def compare_modal(reference, result, quantity: str, unit: str):
|
||||
"""Compare explicitly typed modal rows using the common tolerance.
|
||||
|
||||
Each row explicitly supplies step, mode, quantity, unit_dimensions and value.
|
||||
Callers must obtain those semantics from a genuine supplied artifact first.
|
||||
Eigenvalues and frequencies are never silently converted into one another.
|
||||
"""
|
||||
units = {"eigenvalue": "1/time^2", "frequency": "1/time"}
|
||||
require(quantity in units and unit == units[quantity], "wrong-modal-unit", unit)
|
||||
inventories = []
|
||||
for rows in (reference, result):
|
||||
mapping = {}
|
||||
for row in rows:
|
||||
require(row["quantity"] == quantity, "wrong-modal-quantity", str(row["mode"]))
|
||||
require(row["unit_dimensions"] == unit, "wrong-modal-unit", str(row["mode"]))
|
||||
require(row["step"] == STEP, "wrong-modal-step", str(row["mode"]))
|
||||
mode = row["mode"]
|
||||
require(type(mode) is int and mode in range(1, 11), "wrong-modal-mode", str(mode))
|
||||
require(mode not in mapping, "duplicate-modal-mode", str(mode))
|
||||
mapping[mode] = row["value"]
|
||||
require(set(mapping) == set(range(1, 11)), "modal-mode-inventory", "Lowest ten modes required.")
|
||||
inventories.append(mapping)
|
||||
families = [evaluate_family(
|
||||
{"identity": f"hexa_freq:{STEP}:mode-{mode}:{quantity}",
|
||||
"case": "hexa_freq", "step": STEP, "mode": mode, "quantity": quantity,
|
||||
"unit_dimensions": unit, "rows": [{"mode": mode, "component": quantity}]},
|
||||
[inventories[1][mode]], [inventories[0][mode]]) for mode in range(1, 11)]
|
||||
return {"families": families, "passed": all(family["passed"] for family in families)}
|
||||
|
||||
|
||||
def text(value) -> str:
|
||||
return value.decode("utf-8", errors="strict") if isinstance(value, bytes) else str(value)
|
||||
|
||||
|
||||
def string_attribute(obj, name, expected=None):
|
||||
require(name in obj.attrs, "missing-result-attribute", f"{obj.name}:{name}")
|
||||
attr = obj.attrs.get_id(name)
|
||||
string = h5py.check_string_dtype(attr.dtype)
|
||||
require(attr.shape == () and string is not None and string.length is None
|
||||
and string.encoding == "utf-8", "wrong-result-attribute-type", f"{obj.name}:{name}")
|
||||
value = text(obj.attrs[name])
|
||||
require(bool(value) and (expected is None or value == expected),
|
||||
"wrong-result-attribute", f"{obj.name}:{name}={value}")
|
||||
return value
|
||||
|
||||
|
||||
def numeric_attribute(obj, name, dtype, expected):
|
||||
require(name in obj.attrs, "missing-result-attribute", f"{obj.name}:{name}")
|
||||
attr = obj.attrs.get_id(name)
|
||||
require(attr.shape == () and attr.dtype == np.dtype(dtype),
|
||||
"wrong-result-attribute-type", f"{obj.name}:{name}")
|
||||
require(obj.attrs[name] == expected, "wrong-result-attribute", f"{obj.name}:{name}")
|
||||
|
||||
|
||||
def dataset(file, path, shape):
|
||||
require(path in file and isinstance(file[path], h5py.Dataset), "missing-result-dataset", path)
|
||||
value = file[path]
|
||||
require(value.shape == shape and value.chunks is None and value.compression is None,
|
||||
"wrong-result-shape", path)
|
||||
return value
|
||||
|
||||
|
||||
def table(file, path, count, fields):
|
||||
value = dataset(file, path, (count,))
|
||||
require(value.dtype.names == tuple(name for name, _ in fields), "wrong-result-table-schema", path)
|
||||
for name, dtype in fields:
|
||||
actual = value.dtype[name]
|
||||
if dtype == "string":
|
||||
info = h5py.check_string_dtype(actual)
|
||||
valid = info is not None and info.length is None and info.encoding == "utf-8"
|
||||
else:
|
||||
valid = actual == np.dtype(dtype)
|
||||
require(valid, "wrong-result-table-schema", f"{path}:{name}")
|
||||
return value[:]
|
||||
|
||||
|
||||
def read_results(path: Path, model):
|
||||
"""Read complete official schema/identities; defer physical finite checks."""
|
||||
case = model["case"]
|
||||
dynamic, nonlinear = case.startswith("hexa_dyn"), "nonlinear" in case
|
||||
frequency = case == "hexa_freq"
|
||||
arrays, frames, modal = [], {}, {}
|
||||
with h5py.File(path, "r") as file:
|
||||
require("metadata" in file and isinstance(file["metadata"], h5py.Group),
|
||||
"missing-result-metadata", str(path))
|
||||
metadata = file["metadata"]
|
||||
numeric_attribute(metadata, "schema_version", "<u8", 1)
|
||||
for name, expected in {
|
||||
"feature_id": "hex8-structural-analysis", "analysis_procedure": CASES[case],
|
||||
"element_formulation": "FESA-Hex8", "integration_rule": "gauss-2x2x2",
|
||||
"mass_formulation": "lumped-reference", "source_step_name": STEP,
|
||||
"unit_system_label": "user-consistent-unspecified",
|
||||
"coordinate_convention": "global-cartesian; reference-strain; current-cauchy-stress",
|
||||
"component_order": "node-major; UX,UY,UZ",
|
||||
"source_input_identity": f"path={model['source_path']};content_identity={model['content_identity']}",
|
||||
}.items():
|
||||
string_attribute(metadata, name, expected)
|
||||
string_attribute(metadata, "solver_version")
|
||||
nodes = table(file, "model/nodes", NODE_COUNT, [
|
||||
("internal_node_id", "<u8"), ("instance_name", "string"),
|
||||
("source_label", "string"), ("coordinates", ("<f8", (3,)))])
|
||||
node_map = {}
|
||||
for i, row in enumerate(nodes):
|
||||
node = label(text(row["source_label"]))
|
||||
require(int(row["internal_node_id"]) == i and canonical(text(row["instance_name"])) == INSTANCE,
|
||||
"wrong-result-node-identity", str(i))
|
||||
require(node not in node_map and node in model["nodes"], "result-node-inventory", str(node))
|
||||
node_map[node] = i
|
||||
require(node_map.keys() == model["nodes"].keys(), "result-node-inventory", str(path))
|
||||
elements = table(file, "model/elements", ELEMENT_COUNT, [
|
||||
("internal_element_id", "<u8"), ("instance_name", "string"), ("source_label", "string"),
|
||||
("source_element_type", "string"), ("internal_formulation", "string"),
|
||||
("node_internal_ids", ("<u8", (8,))), ("solid_section_internal_id", "<u8"),
|
||||
("material_internal_id", "<u8")])
|
||||
element_map = {}
|
||||
for i, row in enumerate(elements):
|
||||
element = label(text(row["source_label"]))
|
||||
require(int(row["internal_element_id"]) == i and canonical(text(row["instance_name"])) == INSTANCE,
|
||||
"wrong-result-element-identity", str(i))
|
||||
require(element not in element_map and element in model["elements"],
|
||||
"result-element-inventory", str(element))
|
||||
require(text(row["source_element_type"]) == "C3D8"
|
||||
and text(row["internal_formulation"]) == "FESA-Hex8"
|
||||
and tuple(row["node_internal_ids"]) == tuple(node_map[n] for n in model["elements"][element])
|
||||
and row["solid_section_internal_id"] == 0 and row["material_internal_id"] == 0,
|
||||
"wrong-result-element-connectivity", str(element))
|
||||
element_map[element] = i
|
||||
materials = table(file, "model/solid/materials", 1, [
|
||||
("internal_material_id", "<u8"), ("name", "string"), ("youngs_modulus", "<f8"),
|
||||
("poisson_ratio", "<f8"), ("density_present", "u1"), ("density", "<f8")])
|
||||
material = materials[0]
|
||||
require(material["internal_material_id"] == 0 and canonical(text(material["name"])) == "MATERIAL-1"
|
||||
and material["density_present"] == 1, "wrong-result-material-identity", str(path))
|
||||
sections = table(file, "model/solid/sections", 1, [
|
||||
("internal_section_id", "<u8"), ("source_file", "string"), ("source_line", "<u8"),
|
||||
("source_elset", "string"), ("material_internal_id", "<u8")])
|
||||
section = sections[0]
|
||||
require(section["internal_section_id"] == 0 and section["material_internal_id"] == 0
|
||||
and text(section["source_file"]) == model["source_path"]
|
||||
and section["source_line"] == model["section_line"]
|
||||
and canonical(text(section["source_elset"])) == canonical(model["section_elset"]),
|
||||
"wrong-result-section-identity", str(path))
|
||||
points = table(file, "model/solid_integration_points", ELEMENT_COUNT * 8, [
|
||||
("internal_element_id", "<u8"), ("point_id", "u1"),
|
||||
("natural_coordinates", ("<f8", (3,))), ("reference_coordinates", ("<f8", (3,)))])
|
||||
for i, row in enumerate(points):
|
||||
require(row["internal_element_id"] == i // 8 and row["point_id"] == i % 8 + 1,
|
||||
"wrong-result-point-identity", str(i))
|
||||
mask = dataset(file, "model/nodal_constraint_mask", (NODE_COUNT, 3))
|
||||
require(mask.dtype == np.dtype("u1"), "wrong-result-mask-type", str(path))
|
||||
string_attribute(mask, "component_names", "UX,UY,UZ")
|
||||
mask = mask[:]
|
||||
expected_mask = np.array([[(node, c) in model["constrained"] for c in (1, 2, 3)]
|
||||
for node in node_map], dtype="u1")
|
||||
require(np.array_equal(mask, expected_mask), "wrong-result-constraint-identity", str(path))
|
||||
require("steps" in file and set(file["steps"]) == {STEP}, "wrong-result-step", str(path))
|
||||
step = file[f"steps/{STEP}"]
|
||||
expected_step_groups = {"eigen"} if frequency else ({"frames", "accepted_frames"} if dynamic or nonlinear else {"frames"})
|
||||
require(set(step) == expected_step_groups, "wrong-result-step-inventory", str(path))
|
||||
if frequency:
|
||||
modal = read_modal_tables(file, arrays, int((mask == 0).sum()))
|
||||
else:
|
||||
count = 101 if dynamic else (10 if nonlinear else 1)
|
||||
accepted = None
|
||||
if dynamic or nonlinear:
|
||||
coordinate = "time" if dynamic else "load_factor"
|
||||
flag = "is_initial" if dynamic else "is_factor_one"
|
||||
accepted = table(file, f"steps/{STEP}/accepted_frames", count, [
|
||||
("frame_index", "<u8"), ("increment_index", "<u8"), (coordinate, "<f8"),
|
||||
("iteration_count", "<u8"), (flag, "u1")])
|
||||
for frame, row in enumerate(accepted):
|
||||
increment = frame if dynamic else frame + 1
|
||||
value = increment * 0.1
|
||||
expected_flag = int(frame == (0 if dynamic else count - 1))
|
||||
require(row["frame_index"] == frame and row["increment_index"] == increment
|
||||
and row[coordinate] == value and row[flag] == expected_flag
|
||||
and (row["iteration_count"] == 0 if dynamic and frame == 0
|
||||
else 1 <= row["iteration_count"] <= 100),
|
||||
"wrong-result-frame-schedule", str(frame))
|
||||
frame_root = f"steps/{STEP}/frames"
|
||||
require(set(file[frame_root]) == {str(i) for i in range(count)},
|
||||
"result-frame-inventory", str(path))
|
||||
for frame in range(count):
|
||||
increment = frame if dynamic else (frame + 1 if nonlinear else 10)
|
||||
value = increment * 0.1 if dynamic or nonlinear else 1.0
|
||||
frames[increment] = read_frame(file, frame, increment, value, dynamic, nonlinear, arrays)
|
||||
# Identity and schema checks above deliberately finish before physical checks.
|
||||
arrays.extend([("model/nodes/coordinates", nodes["coordinates"]),
|
||||
("model/points/natural", points["natural_coordinates"]),
|
||||
("model/points/reference", points["reference_coordinates"]),
|
||||
("model/material", np.array([material[n] for n in ("youngs_modulus", "poisson_ratio", "density")]))])
|
||||
evidence = {"source_input_identity": string_attribute(metadata, "source_input_identity"),
|
||||
"source_node_count": NODE_COUNT, "source_element_count": ELEMENT_COUNT,
|
||||
"accepted_frame_count": len(frames), "constrained_scalar_count": int(mask.sum()),
|
||||
"result_node_map": node_map, "result_element_map": element_map}
|
||||
return {"frames": frames, "modal": modal, "nodes": nodes, "points": points,
|
||||
"material": material, "arrays": arrays, "mask": mask, "evidence": evidence}
|
||||
|
||||
|
||||
def physical_dataset(file, path, shape, names, units, location, frame=None,
|
||||
increment=None, coordinate=None, dynamic=False, extra=None):
|
||||
value = dataset(file, path, shape)
|
||||
require(value.dtype == np.dtype("<f8"), "wrong-result-value-type", path)
|
||||
attributes = {"component_names": names, "unit_dimensions": units,
|
||||
"coordinate_system": "global-cartesian", "location": location}
|
||||
if frame is not None:
|
||||
attributes["step_name"] = STEP
|
||||
attributes.update(extra or {})
|
||||
for name, expected in attributes.items():
|
||||
string_attribute(value, name, expected)
|
||||
if frame is not None:
|
||||
numeric_attribute(value, "frame_index", "<u8", frame)
|
||||
numeric_attribute(value, "increment_index", "<u8", increment)
|
||||
numeric_attribute(value, "time" if dynamic else "load_factor", "<f8", coordinate)
|
||||
return value[()]
|
||||
|
||||
|
||||
def read_frame(file, frame, increment, coordinate, dynamic, nonlinear, arrays):
|
||||
root = f"steps/{STEP}/frames/{frame}"
|
||||
require(set(file[root]) == {"nodal", "solid", "global"}, "wrong-result-frame-inventory", root)
|
||||
definitions = {
|
||||
"nodal/displacement": ((NODE_COUNT, 3), "U1,U2,U3", "length", "nodal", {}),
|
||||
"nodal/reaction": ((NODE_COUNT, 3), "RF1,RF2,RF3", "force", "nodal", {}),
|
||||
"nodal/residual": ((NODE_COUNT, 3), "R1,R2,R3", "force", "nodal", {}),
|
||||
"solid/stress": ((ELEMENT_COUNT, 8, 6), "S11,S22,S33,S12,S13,S23", "force/length^2",
|
||||
"integration-point", {"stress_measure": "cauchy"}),
|
||||
"solid/strain": ((ELEMENT_COUNT, 8, 6), "E11,E22,E33,E12,E13,E23", "dimensionless",
|
||||
"integration-point", {"strain_measure": "green-lagrange" if nonlinear else "infinitesimal",
|
||||
"shear_convention": "tensor"}),
|
||||
"global/strain_energy": ((), "scalar", "force*length", "global",
|
||||
{"definition": "sum(W*detJ0)" if nonlinear else "0.5*u^T*K0*u"}),
|
||||
}
|
||||
if dynamic:
|
||||
definitions.update({
|
||||
"nodal/velocity": ((NODE_COUNT, 3), "V1,V2,V3", "length/time", "nodal", {}),
|
||||
"nodal/acceleration": ((NODE_COUNT, 3), "A1,A2,A3", "length/time^2", "nodal", {"acceleration_kind": "physical"}),
|
||||
"nodal/algorithmic_acceleration": ((NODE_COUNT, 3), "A1,A2,A3", "length/time^2", "nodal", {"acceleration_kind": "algorithmic"}),
|
||||
"global/kinetic_energy": ((), "scalar", "force*length", "global", {"definition": "0.5*v^T*M*v"}),
|
||||
"global/external_work": ((), "scalar", "force*length", "global", {"definition": "sum(0.5*(F_old+F_new)*delta_u)"}),
|
||||
"global/energy_balance_error": ((), "scalar", "force*length", "global", {"definition": "T+U-T0-U0-Wext"}),
|
||||
"global/linear_momentum": ((3,), "P1,P2,P3", "mass*length/time", "global", {}),
|
||||
"global/angular_momentum": ((3,), "L1,L2,L3", "mass*length^2/time", "global", {"origin": "global0"}),
|
||||
})
|
||||
for group in ("nodal", "solid", "global"):
|
||||
expected = {key.split("/", 1)[1] for key in definitions if key.startswith(group + "/")}
|
||||
require(set(file[f"{root}/{group}"]) == expected, "wrong-result-quantity-inventory", root + "/" + group)
|
||||
values = {}
|
||||
for quantity, (shape, names, units, location, extra) in definitions.items():
|
||||
path = root + "/" + quantity
|
||||
values[quantity] = physical_dataset(file, path, shape, names, units, location,
|
||||
frame, increment, coordinate, dynamic, extra)
|
||||
arrays.append((path, values[quantity]))
|
||||
return values
|
||||
|
||||
|
||||
def read_modal_tables(file, arrays, free_count):
|
||||
root = f"steps/{STEP}/eigen"
|
||||
require(set(file[root]) == {"modes", "frequencies", "vectors", "verification"},
|
||||
"wrong-modal-result-inventory", root)
|
||||
modes = table(file, root + "/modes", 10, [("mode_id", "<u8"), ("eigenvalue", "<f8"),
|
||||
("classification", "u1"), ("cluster_id", "<u8")])
|
||||
for name, component, units in (("modes", "eigenvalue", "1/time^2"),
|
||||
("frequencies", "frequency", "1/time")):
|
||||
obj = file[root + "/" + name]
|
||||
for attr, expected in {"component_names": component, "unit_dimensions": units,
|
||||
"coordinate_system": "global-cartesian", "location": "global"}.items():
|
||||
string_attribute(obj, attr, expected)
|
||||
frequencies = table(file, root + "/frequencies", sum(row["eigenvalue"] >= 0 for row in modes),
|
||||
[("mode_id", "<u8"), ("frequency", "<f8")])
|
||||
require(list(modes["mode_id"]) == list(range(1, 11)) and (modes["classification"] <= 1).all()
|
||||
and modes["cluster_id"][0] == 1
|
||||
and all(b in (a, a + 1) for a, b in zip(modes["cluster_id"], modes["cluster_id"][1:])),
|
||||
"wrong-modal-mode", root)
|
||||
require(list(frequencies["mode_id"]) == [row["mode_id"] for row in modes if row["eigenvalue"] >= 0],
|
||||
"wrong-modal-frequency-inventory", root)
|
||||
vectors = physical_dataset(file, root + "/vectors", (10, NODE_COUNT, 3),
|
||||
"UX,UY,UZ", "mass-normalized", "nodal",
|
||||
extra={"normalization": "phi^T*M*phi=1"})
|
||||
verification = root + "/verification"
|
||||
require(set(file[verification]) == {"pairs", "eta_gram", "max_eta_orth"},
|
||||
"wrong-modal-verification-inventory", verification)
|
||||
pair_object = file[verification + "/pairs"]
|
||||
require(isinstance(pair_object, h5py.Dataset) and len(pair_object.shape) == 1,
|
||||
"wrong-modal-pair-shape", verification + "/pairs")
|
||||
pair_count = pair_object.shape[0]
|
||||
require(10 <= pair_count <= free_count, "wrong-modal-pair-count", str(pair_count))
|
||||
pairs = table(file, verification + "/pairs", pair_count, [
|
||||
("sorted_rank", "<u8"), ("solver_column", "<u8"), ("eigenvalue", "<f8"),
|
||||
("modal_mass", "<f8"), ("rayleigh_quotient", "<f8"), ("eta_r", "<f8"),
|
||||
("eta_norm", "<f8"), ("eta_energy", "<f8")])
|
||||
require(list(pairs["sorted_rank"]) == list(range(1, pair_count + 1))
|
||||
and len(set(pairs["solver_column"])) == pair_count
|
||||
and (pairs["solver_column"] < pair_count).all(), "wrong-modal-pair-identity", verification)
|
||||
for name in ("eta_gram", "max_eta_orth"):
|
||||
value = dataset(file, verification + "/" + name, ())
|
||||
require(value.dtype == np.dtype("<f8"), "wrong-result-value-type", name)
|
||||
arrays.append((verification + "/" + name, value[()]))
|
||||
arrays.extend([(root + "/vectors", vectors), (root + "/lambda", modes["eigenvalue"]),
|
||||
(root + "/frequency", frequencies["frequency"])])
|
||||
arrays.extend([(verification + "/" + name, pairs[name]) for name in pairs.dtype.names[2:]])
|
||||
return {"modes": modes, "frequencies": frequencies, "pairs": pairs}
|
||||
|
||||
|
||||
def check_result_values(result, model):
|
||||
for path, values in result["arrays"]:
|
||||
require(np.isfinite(values).all(), "nonfinite-result-value", path)
|
||||
for row in result["nodes"]:
|
||||
node = label(text(row["source_label"]))
|
||||
require(tuple(row["coordinates"]) == model["nodes"][node], "wrong-result-coordinate", str(node))
|
||||
material = result["material"]
|
||||
require(tuple(material[name] for name in ("youngs_modulus", "poisson_ratio")) == model["elastic"]
|
||||
and material["density"] == model["density"], "wrong-result-material-value", model["case"])
|
||||
for i, row in enumerate(result["points"]):
|
||||
element = list(result["evidence"]["result_element_map"])[i // 8]
|
||||
expected, scale = point_coordinates(model, element, i % 8 + 1)
|
||||
require(np.array_equal(row["natural_coordinates"], NATURAL_POINTS[i % 8])
|
||||
and (np.abs(row["reference_coordinates"] - expected) <= 16 * np.finfo(float).eps * scale).all(),
|
||||
"result-coordinate-convention", str(i))
|
||||
for increment, values in result["frames"].items():
|
||||
require((values["nodal/reaction"][result["mask"] == 0] == 0.0).all(),
|
||||
"nonzero-free-result-reaction", str(increment))
|
||||
if model["case"].startswith("hexa_dyn") and increment == 0:
|
||||
require((values["nodal/displacement"] == 0.0).all() and (values["nodal/velocity"] == 0.0).all(),
|
||||
"nonzero-initial-result-state", str(increment))
|
||||
if result["modal"]:
|
||||
modes, frequencies, pairs = (result["modal"][key] for key in ("modes", "frequencies", "pairs"))
|
||||
require((np.diff(modes["eigenvalue"]) >= 0).all() and (np.diff(pairs["eigenvalue"]) >= 0).all(),
|
||||
"wrong-modal-order", model["case"])
|
||||
for row in frequencies:
|
||||
expected = math.sqrt(modes[row["mode_id"] - 1]["eigenvalue"]) / (2 * math.pi)
|
||||
require(row["frequency"] == expected, "wrong-modal-frequency", str(row["mode_id"]))
|
||||
|
||||
|
||||
def compare(case: str, input_path: Path, results_path: Path, reference_directory: Path = REFERENCE_DIRECTORY,
|
||||
execution_record: Path | None = None):
|
||||
report = {"schema": "hex8-reference-comparison-v1", "case": case, "policy": dict(POLICY),
|
||||
"identities": {}, "precheck": {"passed": False}, "families": [],
|
||||
"blocking_failures": [], "blocking_passed": False, "verdict": "failed"}
|
||||
try:
|
||||
require(case in CASES, "unknown-source-case", case)
|
||||
quantities = ["U"] if case.startswith("hexa_dyn") else (["U", "RF", "S"] if case != "hexa_freq" else [])
|
||||
paths = {"input": input_path, "results": results_path}
|
||||
paths.update({quantity: reference_directory / f"{case}_{quantity}.csv" for quantity in quantities})
|
||||
modal_path = reference_directory / "hexa_freq.txt"
|
||||
if case == "hexa_freq" and modal_path.exists():
|
||||
paths["Value"] = modal_path
|
||||
for name, path in paths.items():
|
||||
report["identities"][name] = {"path": lexical_absolute(path), "sha256": sha256(path)}
|
||||
model = input_inventory(input_path, case)
|
||||
report["input_copy_matches_approved_bytes"] = True
|
||||
report["approved_input_sha256"] = SOURCE_SHA256[f"{case}.inp"]
|
||||
report["execution"] = {"verified": False, "scope": "file comparison only; execution record not supplied"}
|
||||
if execution_record is not None:
|
||||
execution = json.loads(execution_record.read_text(encoding="utf-8-sig"))
|
||||
command = execution.get("command")
|
||||
require(execution.get("case") == case and execution.get("native_exit") == 0
|
||||
and type(execution.get("native_exit")) is int
|
||||
and execution.get("original_sha256") == report["identities"]["input"]["sha256"]
|
||||
and execution.get("scratch_sha256") == report["identities"]["input"]["sha256"]
|
||||
and execution.get("result_sha256") == report["identities"]["results"]["sha256"]
|
||||
and execution.get("source_unchanged") is True
|
||||
and execution.get("scratch_unchanged") is True
|
||||
and execution.get("result_exists") is True
|
||||
and isinstance(command, list) and len(command) == 2
|
||||
and all(isinstance(item, str) for item in command)
|
||||
and Path(lexical_absolute(Path(command[1]))) == Path(lexical_absolute(input_path)),
|
||||
"invalid-execution-evidence", str(execution_record))
|
||||
report["execution"] = {"verified": True, "native_exit": 0, "command": command,
|
||||
"record_path": lexical_absolute(execution_record),
|
||||
"record_sha256": sha256(execution_record)}
|
||||
records = {}
|
||||
modal_reference = None
|
||||
if "Value" in paths:
|
||||
require(report["identities"]["Value"]["sha256"] == SOURCE_SHA256["hexa_freq.txt"],
|
||||
"reference-hash-mismatch", str(modal_path))
|
||||
modal_reference = read_modal_reference(modal_path)
|
||||
for quantity in quantities:
|
||||
require(report["identities"][quantity]["sha256"] == SOURCE_SHA256[f"{case}_{quantity}.csv"],
|
||||
"reference-hash-mismatch", str(paths[quantity]))
|
||||
records[quantity] = read_reference(paths[quantity], case, quantity, model)
|
||||
result = read_results(results_path, model)
|
||||
references = {quantity: reference_values(rows, quantity, model) for quantity, rows in records.items()}
|
||||
check_result_values(result, model)
|
||||
report["precheck"] = {"passed": True, **result["evidence"],
|
||||
"reference_row_counts": {q: len(rows) for q, rows in records.items()},
|
||||
"coordinate_check": "auxiliary; fixed identity; 16*float32-eps uncancelled source-coordinate budget"}
|
||||
# Internal maps are useful to validation but unnecessary in the public report.
|
||||
del report["precheck"]["result_node_map"], report["precheck"]["result_element_map"]
|
||||
if case == "hexa_freq":
|
||||
if modal_reference is None:
|
||||
report["verdict"] = "not-run"
|
||||
report["reason"] = "Original modal Value reference is absent; no format/quantity/unit assumption made."
|
||||
return report
|
||||
actual = [{"step": STEP, "mode": int(row["mode_id"]), "quantity": "eigenvalue",
|
||||
"unit_dimensions": "1/time^2", "value": float(row["eigenvalue"])}
|
||||
for row in result["modal"]["modes"]]
|
||||
modal = compare_modal(modal_reference, actual, "eigenvalue", "1/time^2")
|
||||
report["precheck"]["reference_row_counts"]["Value"] = len(modal_reference)
|
||||
report["modal_reference"] = {"format": "Mode n: Value = lambda Freq = f (cycles/time)",
|
||||
"step_binding": "sole Step-1 of approved hexa_freq.inp",
|
||||
"quantity": "eigenvalue", "unit_dimensions": "1/time^2",
|
||||
"frequency_role": "finite auxiliary; no conversion"}
|
||||
report["families"] = modal["families"]
|
||||
for family in report["families"]:
|
||||
family["reference_sha256"] = report["identities"]["Value"]["sha256"]
|
||||
report["blocking_failures"] = [f["identity"] for f in modal["families"] if not f["passed"]]
|
||||
report["blocking_passed"] = modal["passed"]
|
||||
report["verdict"] = "passed" if modal["passed"] else "failed"
|
||||
return report
|
||||
families = []
|
||||
for increment in sorted(result["frames"]):
|
||||
if not case.startswith("hexa_dyn") and increment != 10:
|
||||
continue
|
||||
values = result["frames"][increment]
|
||||
for quantity in quantities:
|
||||
actual, expected, identities = [], [], []
|
||||
for key in sorted(references[quantity]):
|
||||
if key[0] != increment:
|
||||
continue
|
||||
_, entity, point = key
|
||||
index = result["evidence"]["result_element_map" if quantity == "S" else "result_node_map"][entity]
|
||||
row = values[{"U": "nodal/displacement", "RF": "nodal/reaction", "S": "solid/stress"}[quantity]][index]
|
||||
if quantity == "S":
|
||||
row = row[point - 1]
|
||||
for c, component in enumerate(COMPONENTS[quantity]):
|
||||
if quantity == "RF" and (entity, c + 1) not in model["constrained"]:
|
||||
continue
|
||||
identities.append({"instance": INSTANCE, "component": component,
|
||||
"element_label" if quantity == "S" else "node_label": entity,
|
||||
**({"integration_point": point} if quantity == "S" else {})})
|
||||
actual.append(float(row[c]))
|
||||
expected.append(references[quantity][key][c])
|
||||
identity = {"identity": f"{case}:{STEP}:increment-{increment}:{quantity}-global",
|
||||
"case": case, "step": STEP, "increment": increment, "quantity": quantity,
|
||||
"unit_dimensions": {"U": "length", "RF": "force", "S": "force/length^2"}[quantity],
|
||||
"coordinate_system": "global-cartesian", "rows": identities,
|
||||
"reference_sha256": report["identities"][quantity]["sha256"]}
|
||||
if case.startswith("hexa_dyn"):
|
||||
identity.update({"hdf5_time": increment * 0.1,
|
||||
"reference_decimal_time": str(Decimal(increment) / 10)})
|
||||
families.append(evaluate_family(identity, actual, expected))
|
||||
report["families"] = families
|
||||
report["blocking_failures"] = [f["identity"] for f in families if not f["passed"]]
|
||||
report["blocking_passed"] = not report["blocking_failures"]
|
||||
report["verdict"] = "passed" if report["blocking_passed"] else "failed"
|
||||
except (ComparisonError, OSError, ValueError, InvalidOperation, UnicodeError, KeyError, TypeError) as error:
|
||||
report["precheck"] = {"passed": False, "code": getattr(error, "code", "invalid-comparison-input"),
|
||||
"message": str(error)}
|
||||
return report
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--case", choices=CASES, required=True)
|
||||
parser.add_argument("--input", type=Path, required=True, help="Actual byte-identical scratch input read by the solver.")
|
||||
parser.add_argument("--results", type=Path, required=True)
|
||||
parser.add_argument("--references", type=Path, default=REFERENCE_DIRECTORY)
|
||||
parser.add_argument("--execution-record", type=Path, help="Closed native CLI record with input/result hashes; never executed here.")
|
||||
parser.add_argument("--report", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
protected = {args.input.resolve(), args.results.resolve()}
|
||||
if args.execution_record is not None:
|
||||
protected.add(args.execution_record.resolve())
|
||||
protected.update(path.resolve() for path in args.references.glob("*"))
|
||||
protected.update(path.resolve() for path in REFERENCE_DIRECTORY.glob("*"))
|
||||
report_path = args.report.resolve()
|
||||
hardlink_alias = args.report.exists() and any(
|
||||
path.exists() and args.report.samefile(path) for path in protected)
|
||||
if report_path in protected or hardlink_alias:
|
||||
parser.error("The report path must not overwrite an input, reference or result.")
|
||||
report = compare(args.case, args.input, args.results, args.references, args.execution_record)
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(json.dumps(report, sort_keys=True, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
||||
print(json.dumps({"case": args.case, "verdict": report["verdict"], "precheck": report["precheck"],
|
||||
"blocking_failure_count": len(report["blocking_failures"]), "report": str(args.report)}, allow_nan=False))
|
||||
return 2 if not report["precheck"]["passed"] or report["verdict"] == "not-run" else (0 if report["blocking_passed"] else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,436 +0,0 @@
|
||||
"""Compare the approved 101-frame HHT reference without modifying artifacts.
|
||||
|
||||
Run with ``uv run --with h5py python -B scripts/verification/compare_hht_dynamic.py``.
|
||||
Only the documented displacement/rotation reference quantities are compared;
|
||||
this is not an independent validation of inertia, energy, or convergence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
from decimal import Decimal, InvalidOperation
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import h5py
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CASE = "shellNL-dyn-meshfine"
|
||||
REFERENCE_DIRECTORY = ROOT / "reference" / CASE
|
||||
NODE_LABELS = (2, 21, 22, 69, 70, 165, 309, 442, 586)
|
||||
INSTANCE = "PART-1-1"
|
||||
STEP = "Step-1"
|
||||
NODE_COUNT = 729
|
||||
FRAME_COUNT = 101
|
||||
INCREMENT = 0.1
|
||||
COMPONENTS = ("U-U1", "U-U2", "U-U3", "UR-UR1", "UR-UR2", "UR-UR3")
|
||||
CSV_COLUMNS = ("Frame", "Part Instance Name", "Node Label", *COMPONENTS)
|
||||
FRAME_PATTERN = re.compile(
|
||||
r"Increment\s+(\d+):\s*Step Time\s*=\s*"
|
||||
r"([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)"
|
||||
)
|
||||
POLICY = {
|
||||
"policy_id": "common-reference-tolerance",
|
||||
"near_zero_ratio": 0.01,
|
||||
"relative_tolerance": 0.05,
|
||||
"relative_rms_tolerance": 0.01,
|
||||
}
|
||||
|
||||
|
||||
class ComparisonError(ValueError):
|
||||
"""An identity/schema/finite failure that must precede tolerance evaluation."""
|
||||
|
||||
def __init__(self, code: str, message: str):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def require(condition: bool, code: str, message: str) -> None:
|
||||
if not condition:
|
||||
raise ComparisonError(code, message)
|
||||
|
||||
|
||||
def positive_label(text: str) -> int:
|
||||
require(bool(re.fullmatch(r"\d+", text)), "invalid-node-label", repr(text))
|
||||
label = int(text)
|
||||
require(0 < label <= 2**63 - 1, "invalid-node-label", repr(text))
|
||||
return label
|
||||
|
||||
|
||||
def canonical_instance(text: str) -> str:
|
||||
return text.translate(str.maketrans("abcdefghijklmnopqrstuvwxyz", "ABCDEFGHIJKLMNOPQRSTUVWXYZ"))
|
||||
|
||||
|
||||
def input_inventory(path: Path) -> tuple[dict[int, tuple[float, ...]], str]:
|
||||
"""Read identity and schedule authority from this specific approved INP."""
|
||||
raw = path.read_bytes()
|
||||
nodes: dict[int, tuple[float, ...]] = {}
|
||||
parts: list[str] = []
|
||||
instances: list[tuple[str, str]] = []
|
||||
steps: list[dict[str, str]] = []
|
||||
dynamic_rows: list[tuple[float, float]] = []
|
||||
keyword = ""
|
||||
in_part = False
|
||||
for line_number, line in enumerate(raw.decode("utf-8-sig").splitlines(), 1):
|
||||
text = line.strip()
|
||||
if not text or text.startswith("**"):
|
||||
continue
|
||||
fields = [field.strip() for field in text.split(",")]
|
||||
if text.startswith("*"):
|
||||
keyword = fields[0].upper()
|
||||
parameters = dict(
|
||||
(name.upper(), value.strip())
|
||||
for field in fields[1:] if "=" in field
|
||||
for name, value in [field.split("=", 1)]
|
||||
)
|
||||
if keyword == "*PART":
|
||||
in_part = True
|
||||
parts.append(canonical_instance(parameters.get("NAME", "")))
|
||||
elif keyword == "*END PART":
|
||||
in_part = False
|
||||
elif keyword == "*INSTANCE":
|
||||
instances.append((canonical_instance(parameters.get("NAME", "")),
|
||||
canonical_instance(parameters.get("PART", ""))))
|
||||
elif keyword == "*STEP":
|
||||
steps.append(parameters)
|
||||
elif keyword == "*DYNAMIC":
|
||||
require([field.upper() for field in fields] == ["*DYNAMIC", "DIRECT"],
|
||||
"wrong-input-procedure", f"line {line_number}")
|
||||
continue
|
||||
if keyword == "*NODE":
|
||||
require(in_part and len(fields) == 4, "invalid-input-node", f"line {line_number}")
|
||||
label = positive_label(fields[0])
|
||||
require(label not in nodes, "duplicate-input-node", str(label))
|
||||
coordinates = tuple(float(value) for value in fields[1:])
|
||||
require(all(math.isfinite(value) for value in coordinates),
|
||||
"nonfinite-input-node", str(label))
|
||||
nodes[label] = coordinates
|
||||
elif keyword == "*DYNAMIC":
|
||||
if fields[-1] == "":
|
||||
fields.pop()
|
||||
require(len(fields) == 2, "wrong-input-schedule", f"line {line_number}")
|
||||
dynamic_rows.append((float(fields[0]), float(fields[1])))
|
||||
require(parts == ["PART-1"] and instances == [(INSTANCE, "PART-1")],
|
||||
"wrong-input-instance", "Expected the original single identity instance.")
|
||||
require(len(nodes) == NODE_COUNT and set(NODE_LABELS) <= nodes.keys(),
|
||||
"wrong-input-node-inventory", f"Expected {NODE_COUNT} source nodes.")
|
||||
require(len(steps) == 1 and steps[0].get("NAME") == STEP
|
||||
and steps[0].get("NLGEOM", "").upper() == "YES"
|
||||
and steps[0].get("INC") == "1000", "wrong-input-step", str(steps))
|
||||
require(dynamic_rows == [(INCREMENT, 10.0)], "wrong-input-schedule", str(dynamic_rows))
|
||||
value = 14695981039346656037
|
||||
for byte in raw:
|
||||
value = ((value ^ byte) * 1099511628211) & (2**64 - 1)
|
||||
return nodes, f"fnv1a64:{value:016x}"
|
||||
|
||||
|
||||
def read_reference(path: Path) -> dict[tuple[int, int], tuple[float, ...]]:
|
||||
rows: dict[tuple[int, int], tuple[float, ...]] = {}
|
||||
with path.open(encoding="utf-8-sig", newline="") as stream:
|
||||
reader = csv.reader(stream)
|
||||
header = [field.strip() for field in next(reader, [])]
|
||||
require(len(header) == len(set(header)), "duplicate-reference-column", str(header))
|
||||
require(set(CSV_COLUMNS) <= set(header), "missing-reference-column", str(header))
|
||||
for line_number, fields in enumerate(reader, 2):
|
||||
if not fields or all(not field.strip() for field in fields):
|
||||
continue
|
||||
require(len(fields) == len(header), "invalid-reference-row", f"line {line_number}")
|
||||
row = dict(zip(header, (field.strip() for field in fields)))
|
||||
match = FRAME_PATTERN.fullmatch(row["Frame"])
|
||||
require(match is not None, "invalid-reference-frame", f"line {line_number}")
|
||||
increment = int(match.group(1))
|
||||
time = Decimal(match.group(2))
|
||||
require(time.is_finite() and time == Decimal(increment) / Decimal(10),
|
||||
"wrong-reference-time", f"line {line_number}: {row['Frame']}")
|
||||
require(canonical_instance(row["Part Instance Name"]) == INSTANCE,
|
||||
"wrong-reference-instance", f"line {line_number}")
|
||||
label = positive_label(row["Node Label"])
|
||||
key = (increment, label)
|
||||
require(key not in rows, "duplicate-reference-row", str(key))
|
||||
values = tuple(float(row[component]) for component in COMPONENTS)
|
||||
require(all(math.isfinite(value) for value in values),
|
||||
"nonfinite-reference-value", str(key))
|
||||
rows[key] = values
|
||||
expected = {(frame, label) for frame in range(FRAME_COUNT) for label in NODE_LABELS}
|
||||
require(rows.keys() == expected, "reference-row-inventory",
|
||||
f"missing={sorted(expected - rows.keys())}; extra={sorted(rows.keys() - expected)}")
|
||||
return rows
|
||||
|
||||
|
||||
def text_value(value: Any) -> str:
|
||||
return value.decode("utf-8") if isinstance(value, bytes) else str(value)
|
||||
|
||||
|
||||
def string_attribute(obj: Any, name: str, expected: str | None = None) -> str:
|
||||
require(name in obj.attrs, "missing-hdf5-attribute", f"{obj.name}@{name}")
|
||||
attribute = obj.attrs.get_id(name)
|
||||
string = h5py.check_string_dtype(attribute.dtype)
|
||||
require(attribute.shape == () and string is not None and string.length is None
|
||||
and string.encoding == "utf-8", "wrong-hdf5-attribute-type", f"{obj.name}@{name}")
|
||||
value = text_value(obj.attrs[name])
|
||||
require(bool(value) and (expected is None or value == expected),
|
||||
"wrong-hdf5-attribute-value", f"{obj.name}@{name}: {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def numeric_attribute(obj: Any, name: str, dtype: str, expected: int | float) -> None:
|
||||
require(name in obj.attrs, "missing-hdf5-attribute", f"{obj.name}@{name}")
|
||||
attribute = obj.attrs.get_id(name)
|
||||
require(attribute.shape == () and attribute.dtype == np.dtype(dtype),
|
||||
"wrong-hdf5-attribute-type", f"{obj.name}@{name}")
|
||||
value = obj.attrs[name]
|
||||
require(math.isfinite(value) and value == expected, "wrong-hdf5-attribute-value",
|
||||
f"{obj.name}@{name}: {value!r}")
|
||||
|
||||
|
||||
def dataset(file: h5py.File, path: str, shape: tuple[int, ...]) -> h5py.Dataset:
|
||||
require(path in file and isinstance(file[path], h5py.Dataset), "missing-hdf5-dataset", path)
|
||||
result = file[path]
|
||||
require(result.shape == shape and result.chunks is None and result.compression is None,
|
||||
"wrong-hdf5-dataset-layout", path)
|
||||
return result
|
||||
|
||||
|
||||
def read_results(path: Path, nodes: dict[int, tuple[float, ...]],
|
||||
content_identity: str) -> tuple[list[np.ndarray], dict[str, Any]]:
|
||||
"""Precheck every result node/frame/component, then project by source key."""
|
||||
projected: list[np.ndarray] = []
|
||||
with h5py.File(path, "r") as file:
|
||||
require("metadata" in file and isinstance(file["metadata"], h5py.Group),
|
||||
"missing-hdf5-metadata", "/metadata")
|
||||
metadata = file["metadata"]
|
||||
for name, expected in {
|
||||
"feature_id": "hht-implicit-dynamics", "analysis_procedure": "hht-implicit-dynamics",
|
||||
"mass_formulation": "nodal-lumped-kinetic", "internal_formulation": "FESA-MITC4-total-lagrangian",
|
||||
"initial_state": "zero-q-v-step-load-0+",
|
||||
"acceleration_convention": "physical-endpoint-and-auxiliary-separated",
|
||||
"source_step_name": STEP, "step_name": STEP, "unit_system_label": "consistent-input-units",
|
||||
}.items():
|
||||
string_attribute(metadata, name, expected)
|
||||
string_attribute(metadata, "solver_version")
|
||||
source_identity = string_attribute(metadata, "source_input_identity")
|
||||
require(source_identity.endswith(";content_identity=" + content_identity),
|
||||
"wrong-input-content-identity", source_identity)
|
||||
require("schema_version" in metadata.attrs, "missing-hdf5-attribute", "/metadata@schema_version")
|
||||
version_attribute = metadata.attrs.get_id("schema_version")
|
||||
require(version_attribute.shape == () and version_attribute.dtype == np.dtype("<u8"),
|
||||
"wrong-hdf5-attribute-type", "/metadata@schema_version")
|
||||
schema_version = int(metadata.attrs["schema_version"])
|
||||
require(schema_version in (1, 2), "wrong-hdf5-attribute-value", "/metadata@schema_version")
|
||||
if schema_version == 2:
|
||||
string_attribute(metadata, "time_integration_formulation",
|
||||
"reduced-coordinate-HHT-with-physical-rate-reconstruction")
|
||||
elif "time_integration_formulation" in metadata.attrs:
|
||||
string_attribute(metadata, "time_integration_formulation", "full-coordinate-HHT")
|
||||
for name, dtype, value in (
|
||||
("schema_version", "<u8", schema_version), ("increment_count", "<u8", 100),
|
||||
("maximum_increments", "<u8", 1000), ("alpha", "<f8", -0.05),
|
||||
("beta", "<f8", 0.275625), ("gamma", "<f8", 0.55),
|
||||
("initial_increment", "<f8", INCREMENT), ("time_period", "<f8", 10.0),
|
||||
):
|
||||
numeric_attribute(metadata, name, dtype, value)
|
||||
table = dataset(file, "model/nodes", (NODE_COUNT,))
|
||||
require(table.dtype.names == ("internal_node_id", "instance_name", "source_label", "coordinates"),
|
||||
"wrong-node-schema", str(table.dtype))
|
||||
require(table.dtype["internal_node_id"] == np.dtype("<u8")
|
||||
and table.dtype["coordinates"] == np.dtype(("<f8", (3,))),
|
||||
"wrong-node-schema", str(table.dtype))
|
||||
for name in ("instance_name", "source_label"):
|
||||
string = h5py.check_string_dtype(table.dtype[name])
|
||||
require(string is not None and string.length is None and string.encoding == "utf-8",
|
||||
"wrong-node-schema", name)
|
||||
node_indices: dict[int, int] = {}
|
||||
for index, node in enumerate(table[:]):
|
||||
require(int(node["internal_node_id"]) == index, "wrong-internal-node-identity", str(index))
|
||||
instance = canonical_instance(text_value(node["instance_name"]))
|
||||
label = positive_label(text_value(node["source_label"]))
|
||||
require(instance == INSTANCE, "wrong-result-instance", instance)
|
||||
require(label not in node_indices, "duplicate-result-node", str(label))
|
||||
require(label in nodes, "extra-result-node", str(label))
|
||||
require(np.isfinite(node["coordinates"]).all(), "nonfinite-result-node", str(label))
|
||||
require(tuple(node["coordinates"]) == nodes[label], "wrong-result-coordinate", str(label))
|
||||
node_indices[label] = index
|
||||
require(node_indices.keys() == nodes.keys(), "result-node-inventory", "Source node inventories differ.")
|
||||
require("steps" in file and isinstance(file["steps"], h5py.Group)
|
||||
and set(file["steps"]) == {STEP}, "wrong-result-step", "Expected one accepted step.")
|
||||
accepted = dataset(file, f"steps/{STEP}/accepted_frames", (FRAME_COUNT,))
|
||||
expected_fields = (("frame_index", "<u8"), ("increment_index", "<u8"),
|
||||
("time", "<f8"), ("iteration_count", "<u8"), ("is_initial", "u1"))
|
||||
require(accepted.dtype.names == tuple(name for name, _ in expected_fields)
|
||||
and all(accepted.dtype[name] == np.dtype(dtype) for name, dtype in expected_fields),
|
||||
"wrong-frame-table-schema", str(accepted.dtype))
|
||||
frame_path = f"steps/{STEP}/frames"
|
||||
require(frame_path in file and isinstance(file[frame_path], h5py.Group)
|
||||
and set(file[frame_path]) == {str(i) for i in range(FRAME_COUNT)},
|
||||
"result-frame-inventory", "Missing, extra, or noncanonical frame group.")
|
||||
selection = [node_indices[label] for label in NODE_LABELS]
|
||||
for frame, row in enumerate(accepted[:]):
|
||||
scheduled_time = float(frame) * INCREMENT
|
||||
require(int(row["frame_index"]) == frame and int(row["increment_index"]) == frame
|
||||
and math.isfinite(row["time"]) and float(row["time"]) == scheduled_time
|
||||
and int(row["is_initial"]) == int(frame == 0)
|
||||
and 0 <= int(row["iteration_count"]) <= 100
|
||||
and (frame != 0 or (int(row["iteration_count"]) == 0 and not np.signbit(row["time"]))),
|
||||
"wrong-result-frame-time", str(frame))
|
||||
displacement = dataset(file, f"{frame_path}/{frame}/nodal/displacement", (NODE_COUNT, 6))
|
||||
require(displacement.dtype == np.dtype("<f8"), "wrong-displacement-type", str(frame))
|
||||
for name, value in {
|
||||
"component_names": "UX,UY,UZ,URX,URY,URZ",
|
||||
"component_unit_dimensions": "length,length,length,1,1,1",
|
||||
"coordinate_system": "global-cartesian", "location": "nodal",
|
||||
"step_name": STEP, "source_step_name": STEP,
|
||||
"rotation_representation": "total-rotation-vector-chart",
|
||||
"quantity_semantics": "global-translation-and-total-rotation-vector",
|
||||
}.items():
|
||||
string_attribute(displacement, name, value)
|
||||
for name, dtype, value in (
|
||||
("schema_version", "<u8", schema_version), ("frame_index", "<u8", frame),
|
||||
("increment_index", "<u8", frame), ("time", "<f8", scheduled_time),
|
||||
("is_initial", "u1", int(frame == 0)),
|
||||
):
|
||||
numeric_attribute(displacement, name, dtype, value)
|
||||
if schema_version == 2:
|
||||
auxiliary = dataset(file, f"{frame_path}/{frame}/nodal/algorithmic_acceleration", (NODE_COUNT, 6))
|
||||
require(auxiliary.dtype == np.dtype("<f8"), "wrong-auxiliary-type", str(frame))
|
||||
for name, value in {
|
||||
"component_names": "AUX1,AUX2,AUX3,AUX4,AUX5,AUX6",
|
||||
"component_unit_dimensions": "length/time^2,length/time^2,length/time^2,1/time^2,1/time^2,1/time^2",
|
||||
"coordinate_system": "fixed-initial-global-chart", "location": "nodal",
|
||||
"step_name": STEP, "source_step_name": STEP,
|
||||
"rotation_representation": "total-rotation-vector-chart",
|
||||
"quantity_semantics": "full-chart-HHT-filter-combination-not-predictor-driver",
|
||||
}.items():
|
||||
string_attribute(auxiliary, name, value)
|
||||
for name, dtype, value in (
|
||||
("schema_version", "<u8", schema_version), ("frame_index", "<u8", frame),
|
||||
("increment_index", "<u8", frame), ("time", "<f8", scheduled_time),
|
||||
("is_initial", "u1", int(frame == 0)),
|
||||
):
|
||||
numeric_attribute(auxiliary, name, dtype, value)
|
||||
require(np.isfinite(auxiliary[:]).all(), "nonfinite-auxiliary-value", str(frame))
|
||||
values = displacement[:]
|
||||
require(np.isfinite(values).all(), "nonfinite-result-value", str(frame))
|
||||
if frame == 0:
|
||||
require((values == 0.0).all(), "nonzero-initial-displacement", "Frame zero must have q0=0.")
|
||||
projected.append(values[selection])
|
||||
return projected, {"source_input_identity": source_identity, "source_node_count": NODE_COUNT,
|
||||
"accepted_frame_count": FRAME_COUNT, "checked_result_scalars": NODE_COUNT * FRAME_COUNT * 6,
|
||||
"projected_node_labels": list(NODE_LABELS)}
|
||||
|
||||
|
||||
def evaluate_family(increment: int, quantity: str, result: list[float],
|
||||
reference: list[float]) -> dict[str, Any]:
|
||||
"""Apply the common policy exactly, including zero-scale and family RMS."""
|
||||
require(len(result) == len(reference) and len(result) > 0, "scalar-inventory", quantity)
|
||||
require(all(math.isfinite(value) for value in (*result, *reference)),
|
||||
"nonfinite-comparison-value", quantity)
|
||||
scale = max(abs(value) for value in reference)
|
||||
band = POLICY["near_zero_ratio"] * scale
|
||||
rows = []
|
||||
error_norm = 0.0
|
||||
for index, (actual, expected) in enumerate(zip(result, reference)):
|
||||
error = abs(actual - expected)
|
||||
require(math.isfinite(error), "nonfinite-comparison-value", quantity)
|
||||
error_norm = math.hypot(error_norm, error)
|
||||
require(math.isfinite(error_norm), "nonfinite-comparison-value", quantity)
|
||||
relative_error = None
|
||||
if scale == 0.0:
|
||||
branch, threshold, passed = "zero-scale-exact", 0.0, error == 0.0
|
||||
elif abs(expected) <= band:
|
||||
branch, threshold, passed = "near-zero", band, error <= band
|
||||
else:
|
||||
branch = "relative"
|
||||
threshold = POLICY["relative_tolerance"] * abs(expected)
|
||||
relative_error = error / abs(expected)
|
||||
require(math.isfinite(relative_error), "nonfinite-comparison-value", quantity)
|
||||
passed = relative_error <= POLICY["relative_tolerance"]
|
||||
rows.append({"instance": INSTANCE, "node_label": NODE_LABELS[index // 3],
|
||||
"component": f"{quantity}{index % 3 + 1}", "fesa_value": actual,
|
||||
"reference_value": expected, "absolute_error": error,
|
||||
"relative_error": relative_error, "branch": branch,
|
||||
"threshold": threshold, "passed": passed})
|
||||
rows_passed = all(row["passed"] for row in rows)
|
||||
rms = error_norm / math.sqrt(len(rows)) / scale if scale else None
|
||||
require(rms is None or math.isfinite(rms), "nonfinite-comparison-value", quantity)
|
||||
rms_passed = rows_passed if scale == 0.0 else rms <= POLICY["relative_rms_tolerance"]
|
||||
return {"identity": f"{quantity}-global:{STEP}:increment-{increment}", "case": CASE,
|
||||
"step": STEP, "increment": increment, "hdf5_time": float(increment) * INCREMENT,
|
||||
"reference_decimal_time": str(Decimal(increment) / Decimal(10)),
|
||||
"quantity": quantity, "components": [f"{quantity}{i}" for i in (1, 2, 3)],
|
||||
"unit_dimension": "length" if quantity == "U" else "rad",
|
||||
"coordinate_system": "global-cartesian" if quantity == "U" else "fixed-initial-global-total-rotation-vector",
|
||||
"impact": "blocking" if quantity == "U" else "warning-only", "row_count": len(rows),
|
||||
"reference_scale": scale, "near_zero_band": band, "rows": rows,
|
||||
"rows_passed": rows_passed, "relative_rms": rms, "rms_passed": rms_passed,
|
||||
"diagnostic_code": "zero-reference-scale-nonzero-error" if scale == 0.0 and not rows_passed else None,
|
||||
"passed": rows_passed and rms_passed}
|
||||
|
||||
|
||||
def compare(input_path: Path, reference_path: Path, results_path: Path) -> dict[str, Any]:
|
||||
report: dict[str, Any] = {
|
||||
"schema": "hht-reference-comparison-v1", "case": CASE, "policy": dict(POLICY),
|
||||
"identities": {}, "precheck": {"passed": False}, "families": [],
|
||||
"blocking_failures": [], "warnings": [], "blocking_passed": False, "verdict": "failed",
|
||||
}
|
||||
try:
|
||||
for name, path in (("input", input_path), ("reference", reference_path), ("results", results_path)):
|
||||
report["identities"][name] = {"path": str(path.absolute())}
|
||||
with path.open("rb") as stream:
|
||||
report["identities"][name]["sha256"] = hashlib.file_digest(stream, "sha256").hexdigest()
|
||||
nodes, content_identity = input_inventory(input_path)
|
||||
reference = read_reference(reference_path)
|
||||
result, evidence = read_results(results_path, nodes, content_identity)
|
||||
families = []
|
||||
for frame in range(FRAME_COUNT):
|
||||
for quantity, first in (("U", 0), ("UR", 3)):
|
||||
actual = [float(value) for row in result[frame] for value in row[first:first + 3]]
|
||||
expected = [value for label in NODE_LABELS for value in reference[(frame, label)][first:first + 3]]
|
||||
families.append(evaluate_family(frame, quantity, actual, expected))
|
||||
report["precheck"] = {"passed": True, "reference_row_count": len(reference),
|
||||
"reference_frame_count": FRAME_COUNT, **evidence}
|
||||
report["families"] = families
|
||||
for family in families:
|
||||
if family["passed"]:
|
||||
continue
|
||||
if family["impact"] == "blocking":
|
||||
report["blocking_failures"].append(family["identity"])
|
||||
else:
|
||||
if not family["rows_passed"]:
|
||||
report["warnings"].append({"family": family["identity"], "code": "row-tolerance-failure"})
|
||||
if not family["rms_passed"]:
|
||||
report["warnings"].append({"family": family["identity"], "code": "relative-rms-failure"})
|
||||
report["blocking_passed"] = not report["blocking_failures"]
|
||||
report["verdict"] = "passed" if report["blocking_passed"] else "failed"
|
||||
except (ComparisonError, OSError, ValueError, InvalidOperation, UnicodeError) as error:
|
||||
report["precheck"] = {"passed": False, "code": getattr(error, "code", "invalid-comparison-input"),
|
||||
"message": str(error)}
|
||||
return report
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--input", type=Path, default=REFERENCE_DIRECTORY / f"{CASE}.inp")
|
||||
parser.add_argument("--reference", type=Path, default=REFERENCE_DIRECTORY / f"{CASE} displacements.csv")
|
||||
parser.add_argument("--results", type=Path)
|
||||
parser.add_argument("--report", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
results = args.results or args.input.with_name(args.input.stem + "_results.h5")
|
||||
if args.report.resolve() in {args.input.resolve(), args.reference.resolve(), results.resolve()}:
|
||||
parser.error("The report path must not overwrite an input, reference, or solver result.")
|
||||
report = compare(args.input, args.reference, results)
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(json.dumps(report, indent=2, sort_keys=True, allow_nan=False) + "\n", encoding="utf-8")
|
||||
print(json.dumps({"verdict": report["verdict"], "precheck": report["precheck"],
|
||||
"blocking_failure_count": len(report["blocking_failures"]),
|
||||
"warning_count": len(report["warnings"]), "report": str(args.report)}, allow_nan=False))
|
||||
return 2 if not report["precheck"]["passed"] else (0 if report["blocking_passed"] else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user