refactor(layout): src/ 收成顶层包 arm/,入口统一 python -m;提升承担构建的 scratch 脚本
单文件约束取消后,Python 侧没必要再靠 16 处 sys.path.insert 维持扁平模块互认。
现在按职责分包:
arm/paths.py 路径唯一来源(含新增 CACHE、TPL)
arm/constants.py 实测接口数值(原 armcore 的 M/C/CLR/FIT/MOTOR_TOL 段)
arm/cad/{helpers,arm_build,part_tools,parts/} 几何与装配
arm/kinematics.py(原 human_layout.py,唯一运动学事实源)
arm/analysis/{sweep_v2,strength_check,torque_balance,finalize_travel,ws_sweep,gen_env_mesh}
arm/dataio/{screw_mesh,draw_sheet} arm/viewer/{build_viewer,shot}
arm/build_model.py(原 arm_model.py)
顺带修掉的既有缺陷(都是实测撞出来的,不是预防性改动):
- arm_model 第二次 sys.path 跳到 gitignore 的 scratch/ 去 import 构建必需的包络脚本:
ws_sweep/gen_env_mesh 提升进 arm/analysis/,缓存挪到 data/cache/(gitignore,可重生成)。
- finalize_travel / draw_sheet 顶层就是"算完写文件",一次 importlib 冒烟测试就把
docs/travel.json 重写了一遍;现在被 import 直接抛错。
- strength_check 用 OUT.replace("/out","/web") 靠字符串形状推路径,改成直接用 WEB。
- build_viewer 里 WORKSPACE 使用点漏改导致 NameError —— 已由构建关卡抓到并修掉。
os._exit 的处置与计划里的假设相反:实测光是 `python -c "import arm.cad.helpers"`
就会在解释器退出时段错误(exit 139,OCC 静态析构),全流程跑完也一样。所以入口
脚本尾部保留 flush + os._exit,并把原因写进注释;被 import 时不生效。
关卡(tests/):
- verify.py 一条命令跑三层:产物 md5、IK 数值 exact 段、截图。
- 截图基线重做并分级:同一构建连跑两轮实测 102 张逐字节可复现(要求 md5 相同),
3 张线束 x-ray 不可复现(mean<=0.0017、无像素差>8)改为与参考像素比容差;
这 3 张不再入库,免得每次跑关卡都脏 git。
- 新增 ?view=sheet / ?sheet=N / ?theme / ?guide=N 四个 URL 参数:工程图与装配向导
占了约一半 CSS 和 700 行 JS,原先没有任何截图覆盖,拆它们等于没有关卡。
- 关卡灵敏度已验:改坏 IK 基线一个数 -> FAIL;换旧版求解器 -> 5 处 exact 差异。
实测:python -m arm.build_model 退出码 0,全部产物 md5 与基线一致,
105 视图截图全过,IK exact 段逐字节一致。
This commit is contained in:
+172
@@ -0,0 +1,172 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""重构期的不变量关卡。
|
||||
|
||||
python -m tests.verify # 数据哈希 + IK 数值(秒级,每步都跑)
|
||||
python -m tests.verify --shots # 再加全套截图比对(约 3 分钟,前端改动后跑)
|
||||
|
||||
基线在 tests/baselines/ 下,随仓库走。产物按**文件名**匹配,不写死目录:
|
||||
mesh 从 web/ 搬到 webapp/data/ 这类移动不该让关卡失效,也不该让我以为它通过了。
|
||||
任何一项对不上就退出码 1。
|
||||
"""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.dirname(HERE)
|
||||
BASE = os.path.join(HERE, "baselines")
|
||||
|
||||
# 每个产物允许出现的目录,按优先级;找到第一个即取。
|
||||
CANDIDATES = {
|
||||
"mesh_arm.json": ["webapp/data", "web"],
|
||||
"sheets.jsdata": ["webapp/data", "web"],
|
||||
"travel.json": ["docs", "webapp/data"],
|
||||
"travel_detail.json": ["docs", "webapp/data"],
|
||||
"collide.json": ["docs", "webapp/data"],
|
||||
"screws.json": ["docs"],
|
||||
}
|
||||
|
||||
|
||||
def md5(path):
|
||||
h = hashlib.md5()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def read_list(name):
|
||||
"""基线文件是 'hash filename';md5sum 的二进制模式会在文件名前加 '*',剥掉。"""
|
||||
out = {}
|
||||
for ln in io.open(os.path.join(BASE, name), encoding="utf-8"):
|
||||
ln = ln.strip()
|
||||
if ln:
|
||||
h, f = ln.split()
|
||||
out[f.lstrip("*")] = h
|
||||
return out
|
||||
|
||||
|
||||
def locate(fname, dirs):
|
||||
for d in dirs:
|
||||
p = os.path.join(REPO, d, fname)
|
||||
if os.path.exists(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def check_data():
|
||||
base = read_list("data_by_name.md5")
|
||||
bad, rows = 0, []
|
||||
for fname, dirs in CANDIDATES.items():
|
||||
want = base.get(fname)
|
||||
if want is None:
|
||||
rows.append(("SKIP", fname, "基线里没有这项"))
|
||||
continue
|
||||
p = locate(fname, dirs)
|
||||
if p is None:
|
||||
bad += 1
|
||||
rows.append(("MISS", fname, "找不到文件(试过的目录: %s)" % ", ".join(dirs)))
|
||||
continue
|
||||
got = md5(p)
|
||||
ok = got == want
|
||||
if not ok:
|
||||
bad += 1
|
||||
rows.append(("OK " if ok else "DIFF", fname,
|
||||
os.path.relpath(p, REPO).replace("\\", "/") + " " + got[:12] +
|
||||
("" if ok else " 基线 " + want[:12])))
|
||||
for st, f, msg in rows:
|
||||
print(" [%s] %-20s %s" % (st, f, msg))
|
||||
return bad
|
||||
|
||||
|
||||
def check_ik():
|
||||
r = subprocess.run(["node", os.path.join(HERE, "ik_baseline.mjs"),
|
||||
"--compare", os.path.join(BASE, "ik_exact.json")],
|
||||
capture_output=True, text=True, cwd=REPO,
|
||||
encoding="utf-8", errors="replace")
|
||||
out = r.stdout or ""
|
||||
for ln in out.splitlines():
|
||||
print(" " + ln)
|
||||
if r.returncode != 0:
|
||||
print(" node 退出码 %d: %s" % (r.returncode, (r.stderr or "").strip()[:300]))
|
||||
# 没有 "== exact" 这一行就说明探针根本没跑出比对结果(编码炸了、node 不在、路径错…),
|
||||
# 绝不能因为"没报差异"就算通过。
|
||||
if "== exact" not in out:
|
||||
print(" [MISS] ik_baseline 没有输出比对结果,判 FAIL")
|
||||
return 1
|
||||
return 0 if r.returncode == 0 else 1
|
||||
|
||||
|
||||
def check_shots():
|
||||
"""两层判定,依据是"同一构建连跑两轮"的实测(scratch/_gate/flaky_scan.py):
|
||||
104 张逐字节可复现 -> md5 必须相同;
|
||||
3 张线束 x-ray 不可复现(mean<=0.0017、无像素差>8,纯抗锯齿噪声)
|
||||
-> 与 tests/baselines/flaky/ 里的参考像素比,超阈值才算回归。
|
||||
把不可复现的也要求 md5 相同,等于给每一步埋假 FAIL,最后只会被人肉忽略。"""
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
base = read_list("shots_stable.md5")
|
||||
th = json.load(io.open(os.path.join(BASE, "flaky", "thresholds.json"), encoding="utf-8"))
|
||||
r = subprocess.run([sys.executable, "-m", "arm.viewer.shot"],
|
||||
capture_output=True, text=True, cwd=REPO,
|
||||
encoding="utf-8", errors="replace")
|
||||
out = r.stdout or ""
|
||||
if "all shots ok" not in out and "OK " not in out:
|
||||
print(" [MISS] shot.py 没有正常输出(退出码 %s),判 FAIL" % r.returncode)
|
||||
return 999
|
||||
bad = [l for l in out.splitlines() if "FAIL" in l]
|
||||
if bad:
|
||||
print(" [MISS] 截图失败 %d 个: %s" % (len(bad), bad[:3]))
|
||||
|
||||
fails = len(bad)
|
||||
for fname, want in sorted(base.items()):
|
||||
p = os.path.join(REPO, fname.replace("/", os.sep))
|
||||
if not os.path.exists(p):
|
||||
print(" [MISS] %s" % fname)
|
||||
fails += 1
|
||||
continue
|
||||
if md5(p) != want:
|
||||
print(" [DIFF] %-40s" % fname)
|
||||
fails += 1
|
||||
print(" 逐字节比对: %d 张稳定视图,%d 张不一致" % (len(base), fails - len(bad)))
|
||||
|
||||
tol_fails = 0
|
||||
for name, ref in sorted(th["views"].items()):
|
||||
p = os.path.join(REPO, "docs", "shots", name + ".png")
|
||||
q = os.path.join(BASE, "flaky", name + ".png")
|
||||
if not (os.path.exists(p) and os.path.exists(q)):
|
||||
print(" [MISS] 容差比对缺文件: %s" % name)
|
||||
tol_fails += 1
|
||||
continue
|
||||
d = np.abs(np.asarray(Image.open(p).convert("RGB"), dtype=np.int16)
|
||||
- np.asarray(Image.open(q).convert("RGB"), dtype=np.int16))
|
||||
mean, mx = float(d.mean()), int(d.max())
|
||||
over = mean > th["mean_max"] or mx > th["pixel_max"]
|
||||
tol_fails += 1 if over else 0
|
||||
print(" [%s] %-34s mean=%.5f (<= %.2f) 最大单像素差=%d (<= %d)"
|
||||
% ("DIFF" if over else "OK ", name, mean, th["mean_max"], mx, th["pixel_max"]))
|
||||
print(" 容差比对: %d 张噪声视图,%d 张超阈值" % (len(th["views"]), tol_fails))
|
||||
return fails + tol_fails
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[1:]
|
||||
fails = 0
|
||||
print("== 数据产物哈希 ==")
|
||||
fails += check_data()
|
||||
print("== IK 数值基线 ==")
|
||||
fails += check_ik()
|
||||
if "--shots" in argv:
|
||||
print("== 截图逐字节比对 ==")
|
||||
fails += check_shots()
|
||||
print("RESULT:", "PASS" if fails == 0 else "FAIL(%d 项不一致)" % fails)
|
||||
return 1 if fails else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user