【零基础学智能仿真-57】OpenFOAM工作流自动化

课程摘要

本节将OpenFOAM网格生成、网格检查、求解与后处理组织为有检查点的自动化流程,学习命令参数、工作目录、超时、返回码、日志与证据归档。通过实际运行的Python子进程和明确标注的教学日志,演练正常、失败、缺失指标及超时等情形,理解进程成功、记录通过与物理正确之间的区别,为后续Foam-Agent协作提供可审查的执行基础。


一、为什么"依次运行命令"还不够?

上一节,我们讨论了怎样检查网格、选择边界条件和估算时间步。

现在假设有人写了一个自动化脚本,依次执行:

复制代码
生成网格
检查网格
启动求解
提取结果
生成报告

看起来已经实现了自动化。

但如果网格检查失败,脚本仍然启动求解,随后后处理又读到了上一次遗留的结果文件,会怎样?

最终报告可能仍然写着:

"计算完成。"

问题不在于命令不够多,而在于缺少三个环节:

  1. 前一步是否真正通过检查?
  2. 后一步是否允许继续?
  3. 当前结果是否确实来自本次运行?

因此,本节的核心不是让命令尽可能连续地执行,而是让执行过程能够被检查。

正确的逻辑是:

复制代码
案例与版本预检查
        ↓
网格生成 → 检查
        ↓
网格检查 → 检查
        ↓
求解执行 → 检查
        ↓
结果提取 → 检查
        ↓
等待物理复核

任一步不通过 → 停止后续步骤,并保留证据

"停止并留档"也是自动化能力的一部分。

本节练习的范围

本节程序会真实启动本地Python子进程,演示等待、返回码、日志、超时和失败停止。

但网格记录、求解日志和CFD指标是明确标注的教学构造,没有执行OpenFOAM,也没有求解Navier--Stokes方程。

这是对自动化执行层的练习,不是完整CFD求解案例。

二、执行前,先锁定案例与环境

自动化之前,应先回答以下问题:

需要明确的内容 目的
OpenFOAM发行分支和版本 避免混用命令与字典
求解器或模块 明确实际执行入口
案例来源和输入副本 能回查,不破坏参考案例
物性、压力定义和单位 防止物理量混淆
网格与字段边界的对应 避免名字存在但配置不一致
目标时刻或稳态停止准则 知道什么时候算完成
结果提取位置和单位 防止读错截面、字段或时间
计算预算与重试次数 防止无限重提任务

例如,Foundation v13采用foamRun加载相应模块等方式,不能将其他分支或旧版本的命令直接当作已经验证的执行方案。Foundation运行说明、求解器说明

上一节提供的mesh_demo只有网格工具练习所需文件,不能不补齐速度、压力、物性和数值设置,就直接启动流动求解。

文件夹存在,不代表案例完整;案例完整,也不代表物理设置正确。

三、一份执行计划应该记录什么?

执行计划不能只有一句:

"运行求解器。"

每一步至少需要:

字段 含义
stage 当前步骤
argv 可执行程序和独立参数列表
cwd 工作目录
timeout_s 等待上限
log 日志保存位置
artifact 预期产物

其中,argv应当是清楚的参数列表,而不是未经审核的任意命令字符串。

Python的subprocess.run可以指定工作目录、超时、标准输出和错误输出,并取得返回码。Python subprocess说明

本节程序使用:

  • 参数列表;
  • 明确的工作目录;
  • shell=False;
  • 每步独立日志;
  • 固定范围内的工作进程;
  • 不覆盖旧记录的新输出目录。

但需要注意,shell=False不是安全沙箱。真实系统仍然需要工具白名单、目录权限和操作审批。

不要把模型生成的任意命令直接交给系统执行。

四、返回码、记录检查与物理验收,是三层判断

一个进程返回0,通常表示它按照自身约定结束了。

但它不能直接证明:

  • 网格满足要求;
  • 求解达到目标;
  • 结果字段完整;
  • 流量与解析解一致;
  • 当前模型符合工程需求。

可以分成三层:

层次 核心问题
执行层 程序是否结束?是否超时?返回码是什么?
记录层 日志、字段和指标是否存在且符合约定?
物理层 模型与结果是否符合守恒、基准和工程假设?

本节程序主要训练前两层。

因此,即使所有步骤通过,最终状态也是:

复制代码
READY_FOR_PHYSICS_REVIEW

含义是:

"流程记录检查通过,等待物理复核。"

不是:

"工程验证通过。"

本节采用的步骤状态

状态 含义
PASS 当前教学记录通过配置的检查
REJECTED 进程可能结束,但证据缺失或不合格
FAILED 进程以非零返回码结束
TIMEOUT 超过等待上限
SKIPPED 上游未通过,因此没有执行

计划中列出了某一步,不代表那一步已经执行。

五、日志缺少指标时,为什么不能填0?

本节使用固定的教学格式:

复制代码
SOURCE=TEACHING_FIXTURE
FORMAT=LESSON57_V1
FINAL_RESIDUAL p=1e-7
FINAL_RESIDUAL Ux=2e-8
MASS_IMBALANCE=0.0001

这不是通用OpenFOAM日志格式。

特别是MASS_IMBALANCE,不是所有OpenFOAM版本都会原生打印的字段。

本节解析器要求:

  • 两个残差记录存在;
  • 一个守恒指标存在;
  • 不允许重复记录;
  • 数值必须有限;
  • 数值满足课堂设定的范围。

教学阈值为:

\ r_{\\mathrm{final}}\\le10\^{-6}, \\\ e_Q\\le10\^{-3}. \\

这些只是课堂规则,不是所有CFD任务的通用标准。

本格式只演示p与Ux。真实问题需要依据模型检查完整变量、分量及其时序,不能照搬这两个名称作为全部验收项。

缺失不是零

如果日志中没有守恒指标,不能自动填入:

复制代码
MASS_IMBALANCE=0

因为零表示一个明确的平衡结果,而缺失表示没有足够证据。

NaN、负残差、重复记录,也不能当作正常数据接受。

守恒指标需要先定义

对于无源、稳态、恒密度、固定计算域,若入口和出口流量采用正的大小,可以定义:

\ e_Q= \\frac{\|Q_{\\mathrm{out}}-Q_{\\mathrm{in}}\|} {\|Q_{\\mathrm{in}}\|}, \\qquad Q_{\\mathrm{in}}\\ne0. \\

例如:

\ e_Q=0.0001 \\

对应:

\ 0.01\\%. \\

本节日志中的这个数字是教学构造,不是实际边界面通量积分的结果。

可压缩、多相、带源项、动网格或涉及储存项的问题,需要采用相应的守恒关系。

小残差也不能替代物理检查

某次线性方程求解的最终残差很小,不代表外层迭代充分,更不代表边界、参数和流量都正确。残差含义应结合求解算法理解。Foundation方程求解与算法控制说明

六、运行完整练习

完整文件:

复制代码
"""Real Python subprocess control with synthetic CFD evidence; never launches OpenFOAM."""
import argparse
import hashlib
import json
import math
import re
import subprocess
import sys
import tempfile
import time
from pathlib import Path


STAGES = ('mesh', 'mesh_check', 'solve', 'postprocess')
SCENARIOS = ('normal', 'mesh_bad', 'fatal', 'missing', 'high_residual', 'mass_bad', 'timeout', 'wrong_velocity')
ARTIFACTS = dict(zip(STAGES, ('mesh_record.json', 'mesh_check.json', 'solve_record.json', 'metrics.json')))
SOURCE = 'TEACHING_FIXTURE'


def save_json(path, data):
    path.write_text(json.dumps(data, indent=2, allow_nan=False), encoding='utf-8')


def fixture_worker(stage, scenario):
    print('SOURCE=TEACHING_FIXTURE', flush=True)
    print('FORMAT=LESSON57_V1', flush=True)
    data = {'source': SOURCE}
    if stage == 'mesh':
        print('Teaching mesh metadata only; no polyMesh is generated.', flush=True)
        data['cells'] = 20000
    elif stage == 'mesh_check':
        data['mesh_ok'] = scenario != 'mesh_bad'
    elif stage == 'solve':
        if scenario == 'timeout':
            print('Injected wait for timeout exercise.', flush=True)
            time.sleep(2)
        if scenario == 'fatal':
            print('FOAM FATAL ERROR: injected fixture failure, not a real solver error.', flush=True)
            return 1
        print('FINAL_RESIDUAL p=' + ('1e-3' if scenario == 'high_residual' else '1e-7'), flush=True)
        print('FINAL_RESIDUAL Ux=2e-8', flush=True)
        if scenario != 'missing':
            print('MASS_IMBALANCE=' + ('0.01' if scenario == 'mass_bad' else '0.0001'), flush=True)
        data.update(target_time=1.0, reached_time=1.0, fields=['U', 'p'])
    else:
        factor = 1.15 if scenario == 'wrong_velocity' else 1.0
        data.update(units='SI', U_mean_m_per_s=factor/60,
                    q_per_width_m2_per_s=factor/6000, fields=['U', 'p'])
    path = Path(ARTIFACTS[stage])
    if path.exists():
        raise ValueError('Refusing to replace fixture evidence')
    save_json(path, data)
    return 0


def finite_number(value):
    return type(value) in (int, float) and math.isfinite(value)


def parse_solver_log(text):
    # shortcut: LESSON57_V1 teaching format only; use versioned adapters for real OpenFOAM logs.
    if 'SOURCE=TEACHING_FIXTURE' not in text or 'FORMAT=LESSON57_V1' not in text:
        raise ValueError('unsupported_log_format')
    if re.search(r'FOAM FATAL|Floating point exception', text, re.I):
        raise ValueError('fatal_marker')
    residuals, mass = {}, []
    for line in text.splitlines():
        if line.startswith('FINAL_RESIDUAL '):
            match = re.fullmatch(r'FINAL_RESIDUAL (p|Ux)=(\S+)', line)
            if not match or match[1] in residuals:
                raise ValueError('malformed_or_duplicate_residual')
            value = float(match[2])
            if not math.isfinite(value) or not 0 <= value <= 1e-6:
                raise ValueError('residual_out_of_range')
            residuals[match[1]] = value
        elif line.startswith('MASS_IMBALANCE='):
            mass.append(float(line.split('=', 1)[1]))
    if set(residuals) != {'p', 'Ux'}:
        raise ValueError('missing_residual')
    if len(mass) != 1:
        raise ValueError('missing_or_duplicate_mass_imbalance')
    if not math.isfinite(mass[0]) or not 0 <= mass[0] <= 1e-3:
        raise ValueError('mass_imbalance_out_of_range')
    return {'final_residuals': residuals, 'mass_imbalance': mass[0]}


def inspect_evidence(stage, text, path):
    if 'SOURCE=TEACHING_FIXTURE' not in text or 'FORMAT=LESSON57_V1' not in text:
        raise ValueError('unsupported_log_format')
    if re.search(r'FOAM FATAL|Floating point exception', text, re.I):
        raise ValueError('fatal_marker')
    data = json.loads(path.read_text(encoding='utf-8'))
    if not isinstance(data, dict) or data.get('source') != SOURCE:
        raise ValueError('unsupported_evidence_source')
    if stage == 'mesh':
        if type(data.get('cells')) is not int or data['cells'] <= 0:
            raise ValueError('invalid_mesh_metadata')
    elif stage == 'mesh_check':
        if data.get('mesh_ok') is not True:
            raise ValueError('mesh_not_approved')
    elif stage == 'solve':
        diagnostics = parse_solver_log(text)
        if not all(finite_number(data.get(k)) for k in ('target_time', 'reached_time')):
            raise ValueError('missing_target_evidence')
        if data['target_time'] != 1.0 or data['reached_time'] != 1.0 or data.get('fields') != ['U', 'p']:
            raise ValueError('target_or_fields_not_confirmed')
        return diagnostics
    else:
        if data.get('units') != 'SI' or data.get('fields') != ['U', 'p']:
            raise ValueError('invalid_metric_context')
        for key in ('U_mean_m_per_s', 'q_per_width_m2_per_s'):
            if not finite_number(data.get(key)) or data[key] <= 0:
                raise ValueError('missing_or_invalid_metric')
    return data


def run_fixture(out, scenario):
    out = out.resolve()
    out.mkdir(parents=True, exist_ok=False)
    script = Path(__file__).resolve()
    plan = [{'stage': stage, 'argv': [sys.executable, str(script), '--worker', stage, '--scenario', scenario],
             'cwd': str(out), 'timeout_s': .5 if stage == 'solve' and scenario == 'timeout' else 5,
             'log': stage+'.log', 'artifact': ARTIFACTS[stage]} for stage in STAGES]
    save_json(out/'plan.json', {'source': SOURCE, 'OpenFOAM_run': False, 'steps': plan,
                               'script_sha256': hashlib.sha256(script.read_bytes()).hexdigest()})
    records, stopped = [], False
    for step in plan:
        record = {'stage': step['stage'], 'status': 'SKIPPED', 'returncode': None, 'reason': ''}
        if not stopped:
            record['log'] = step['log']
            try:
                with (out/step['log']).open('x', encoding='utf-8') as log:
                    completed = subprocess.run(step['argv'], cwd=out, stdout=log, stderr=subprocess.STDOUT,
                                               stdin=subprocess.DEVNULL, shell=False,
                                               timeout=step['timeout_s'], check=False)
                record['returncode'] = completed.returncode
                if completed.returncode != 0:
                    record.update(status='FAILED', reason='nonzero_exit')
                else:
                    text = (out/step['log']).read_text(encoding='utf-8', errors='replace')
                    record['evidence'] = inspect_evidence(step['stage'], text, out/step['artifact'])
                    record['status'] = 'PASS'
            except subprocess.TimeoutExpired:
                record.update(status='TIMEOUT', reason='stage_timeout')
            except (OSError, ValueError, KeyError, TypeError) as exc:
                record.update(status='REJECTED', reason=str(exc))
            stopped = record['status'] != 'PASS'
        else:
            record['reason'] = 'upstream_not_passed'
        records.append(record)
    audit = {'source': SOURCE, 'OpenFOAM_run': False, 'scenario': scenario, 'steps': records,
             'physical_verified': False,
             'status': 'STOPPED' if stopped else 'READY_FOR_PHYSICS_REVIEW'}
    save_json(out/'audit.json', audit)
    lines = ['# Lesson 57 teaching workflow', '', 'Source: TEACHING_FIXTURE. OpenFOAM was not run.',
             'Python subprocesses were executed. Physical verification is NOT complete.',
             '', 'Final: '+audit['status'], '', '| Stage | Status | Reason |', '|---|---|---|']
    lines += [f'| {r["stage"]} | {r["status"]} | {r["reason"]} |' for r in records]
    (out/'report.md').write_text('\n'.join(lines)+'\n', encoding='utf-8')
    return audit


def self_test():
    good = 'SOURCE=TEACHING_FIXTURE\nFORMAT=LESSON57_V1\nFINAL_RESIDUAL p=1e-7\nFINAL_RESIDUAL Ux=2e-8\nMASS_IMBALANCE=0.0001\n'
    assert parse_solver_log(good)['mass_imbalance'] == .0001
    for bad in ('', good.replace('1e-7', 'nan'), good.replace('1e-7', '-1e-7'),
                good.replace('1e-7', '1e-3'), good+'FOAM FATAL ERROR',
                good.replace('MASS_IMBALANCE=0.0001', ''), good+'FINAL_RESIDUAL p=1e-7\n'):
        try:
            parse_solver_log(bad)
        except ValueError:
            pass
        else:
            raise AssertionError('bad log accepted')
    with tempfile.TemporaryDirectory(prefix='lesson57_test_', dir=Path(__file__).resolve().parent) as folder:
        for scenario in SCENARIOS:
            state = run_fixture(Path(folder)/scenario, scenario)
            expected = 'READY_FOR_PHYSICS_REVIEW' if scenario in ('normal','wrong_velocity') else 'STOPPED'
            assert state['status'] == expected
            if scenario == 'mesh_bad':
                assert state['steps'][2]['status'] == 'SKIPPED'
                assert not (Path(folder)/scenario/'solve.log').exists()
            if scenario == 'timeout':
                assert state['steps'][2]['status'] == 'TIMEOUT'
            assert state['physical_verified'] is False
    print('SELF_TEST_OK')


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--scenario', choices=SCENARIOS, default='normal')
    parser.add_argument('--out', type=Path, default=Path('run57'))
    parser.add_argument('--self-test', action='store_true')
    parser.add_argument('--worker', choices=STAGES, help=argparse.SUPPRESS)
    args = parser.parse_args()
    if args.worker:
        return fixture_worker(args.worker, args.scenario)
    if args.self_test:
        self_test()
        return 0
    try:
        audit = run_fixture(args.out, args.scenario)
        for row in audit['steps']:
            print(row['stage']+': '+row['status']+(' ['+row['reason']+']' if row['reason'] else ''))
        print('FINAL='+audit['status'])
        print('MODE=REAL_PYTHON_SUBPROCESSES; CFD_EVIDENCE=TEACHING_FIXTURE; OpenFOAM_NOT_RUN')
        return 0 if audit['status'] == 'READY_FOR_PHYSICS_REVIEW' else 1
    except (OSError, ValueError) as exc:
        print('OUTPUT_OR_INPUT_ERROR: '+str(exc))
        return 2


if __name__ == '__main__':
    raise SystemExit(main())

程序只使用Python标准库,不需要OpenFOAM,也不依赖前几节脚本。

6.1 先执行自检

在文件所在目录打开终端:

复制代码
python lesson57_workflow.py --self-test

预期输出:

复制代码
SELF_TEST_OK

自检覆盖8种进程场景,以及空日志、NaN、负残差、指标超标、重复记录和失败停止等分支。

自检会在课程文件所在目录中新建专用临时文件夹并清理,需要该目录具有写权限。

6.2 运行正常场景

复制代码
python lesson57_workflow.py --scenario normal --out run57_normal_01

实测输出:

复制代码
mesh: PASS
mesh_check: PASS
solve: PASS
postprocess: PASS
FINAL=READY_FOR_PHYSICS_REVIEW
MODE=REAL_PYTHON_SUBPROCESSES; CFD_EVIDENCE=TEACHING_FIXTURE; OpenFOAM_NOT_RUN

这表示本地进程和教学记录检查通过,没有完成真实CFD物理验收。

6.3 查看证据目录

复制代码
run57_normal_01/
    plan.json
    audit.json
    report.md
    mesh.log
    mesh_record.json
    mesh_check.log
    mesh_check.json
    solve.log
    solve_record.json
    postprocess.log
    metrics.json

其中:

  • plan.json保存命令、目录、超时、产物名称和脚本哈希;
  • audit.json保存各步状态、返回码和检查证据;
  • report.md提供便于阅读的摘要;
  • 各个.log保留工作进程输出。

程序拒绝已有输出目录,避免旧结果冒充本次产物。

课程包已经附带reference_*实测记录,重新运行请使用新的run57_*目录。

七、实验一:网格检查不通过,后续必须跳过

运行:

复制代码
python lesson57_workflow.py --scenario mesh_bad --out run57_meshbad_01

实测输出:

复制代码
mesh: PASS
mesh_check: REJECTED [mesh_not_approved]
solve: SKIPPED [upstream_not_passed]
postprocess: SKIPPED [upstream_not_passed]
FINAL=STOPPED
MODE=REAL_PYTHON_SUBPROCESSES; CFD_EVIDENCE=TEACHING_FIXTURE; OpenFOAM_NOT_RUN

这个场景中,网格检查工作进程的返回码仍然是0,但教学证据中的:

复制代码
mesh_ok = false

因此,流程停止。

进一步检查目录,会发现没有生成solve.log,因为求解步骤根本没有执行。

这个实验说明:

执行成功与业务证据通过,是不同的判断。

这里检验的是停止逻辑,没有执行真实网格检查。

八、实验二:指标缺失,不能默认通过

运行:

复制代码
python lesson57_workflow.py --scenario missing --out run57_missing_01

关键输出:

复制代码
solve: REJECTED [missing_or_duplicate_mass_imbalance]
postprocess: SKIPPED [upstream_not_passed]
FINAL=STOPPED

查看solve.log,可以发现守恒指标没有写出。

正确处理是报告证据不足,而不是:

"其他项目都正常,这一项先填0。"

同样,下面这个场景将残差记录改成\(10^{-3}\):

复制代码
python lesson57_workflow.py --scenario high_residual --out run57_residual_01

关键输出:

复制代码
solve: REJECTED [residual_out_of_range]
postprocess: SKIPPED [upstream_not_passed]
FINAL=STOPPED

拒绝结果时,要保留原因和原始日志,而不是直接删除失败记录。

九、实验三:超时以后,不能继续读取结果

运行:

复制代码
python lesson57_workflow.py --scenario timeout --out run57_timeout_01

关键输出:

复制代码
solve: TIMEOUT [stage_timeout]
postprocess: SKIPPED [upstream_not_passed]
FINAL=STOPPED

该场景让单个Python工作进程等待2秒,而调度端对这一测试步骤施加0.5秒超时。

这些数字只用于快速课堂演练,不是实际CAE运行预算。

Python的subprocess.run超时会处理其直接子进程,并抛出TimeoutExpired。Python超时行为说明

但本例工作进程没有再派生后代,也没有提交远程作业。

真实系统涉及MPI、容器、调度队列或其他后代进程时,还需要进程组和作业级管理,不能把这个例子当成完整清理方案。

超时后应先确认旧任务是否结束,再决定是否重试。盲目重复提交可能产生:

  • 重复计算成本;
  • 多个进程同时写结果;
  • 旧任务与新任务的数据混淆。

十、查看8种场景的实际控制流

图中状态来自实际运行的Python调度。

每一行是一个教学注入场景,不代表真实OpenFOAM任务的成功率。

程序支持:

场景参数 教学内容
normal 正常记录
mesh_bad 网格检查未通过
fatal 致命错误、非零退出
missing 缺失守恒指标
high_residual 残差记录超标
mass_bad 相对不平衡超标
timeout 步骤超时
wrong_velocity 错误指标仍需要物理复核

需要复现图片时,复制plot_lesson57.py,保留课程包中的8组reference_*记录,在安装了Matplotlib的环境运行:

复制代码
"""Plot the measured control-flow states of local Python teaching subprocesses."""
import argparse
import json
from pathlib import Path

import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap
from matplotlib.patches import Patch


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--data-root',type=Path,default=Path(__file__).resolve().parent)
    parser.add_argument('--out',type=Path,default=Path(__file__).with_name('figures'))
    args = parser.parse_args()
    scenarios = [('normal','正常记录'),('mesh_bad','网格检查不通过'),('fatal','求解进程失败'),
                 ('missing','缺失守恒指标'),('high_residual','残差指标超标'),('mass_bad','守恒指标超标'),
                 ('timeout','求解步骤超时'),('wrong_velocity','错误速度仍须物理复核')]
    statuses = ['PASS','REJECTED','FAILED','TIMEOUT','SKIPPED']
    colors = ['#d3eee8','#ffe3ae','#f2b7b4','#d8c8ed','#e6e9ed']
    labels = ['通过','拒绝','失败','超时','跳过']
    states = []
    for name,_ in scenarios:
        state = json.loads((args.data_root/('reference_'+name)/'audit.json').read_text(encoding='utf-8'))
        assert state['source']=='TEACHING_FIXTURE' and state['OpenFOAM_run'] is False
        assert state['physical_verified'] is False and len(state['steps'])==4
        assert all(r['status'] in statuses for r in state['steps'])
        states.append(state)
    args.out.mkdir(parents=True,exist_ok=False)
    plt.rcParams['font.sans-serif']=['Microsoft YaHei','SimHei','Noto Sans CJK SC','DejaVu Sans']
    plt.rcParams['axes.unicode_minus']=False
    matrix = [[statuses.index(r['status']) for r in s['steps']] for s in states]
    fig,ax=plt.subplots(figsize=(12,6.6))
    ax.imshow(matrix,cmap=ListedColormap(colors),vmin=-.5,vmax=4.5,aspect='auto')
    ax.set_xticks(range(4),['网格步骤','网格检查','求解步骤','后处理'])
    ax.set_yticks(range(len(scenarios)),[label for _,label in scenarios])
    ax.tick_params(axis='both',length=0,pad=10,labelsize=11)
    for i,state in enumerate(states):
        for j,row in enumerate(state['steps']):
            idx=statuses.index(row['status'])
            ax.text(j,i,labels[idx],ha='center',va='center',fontsize=12,color='#1d3548')
        ax.text(3.65,i,'等待物理复核' if state['status']=='READY_FOR_PHYSICS_REVIEW' else '已停止',ha='left',va='center',fontsize=11,color='#526375')
    ax.set_xlim(-.5,4.65)
    ax.set_xticks([-.5,.5,1.5,2.5,3.5],minor=True)
    ax.set_yticks([i-.5 for i in range(9)],minor=True)
    ax.grid(which='minor',color='white',lw=3)
    ax.tick_params(which='minor',bottom=False,left=False)
    for spine in ax.spines.values():
        spine.set_visible(False)
    ax.set_title('先检查,再继续:8种教学场景的实际控制流',fontsize=15,pad=20)
    ax.legend(handles=[Patch(facecolor=c,label=l) for c,l in zip(colors,labels)],loc='upper center',bbox_to_anchor=(.45,-.06),ncol=5,frameon=False)
    fig.text(.5,.012,'实际运行的是本地Python子进程;CFD证据为教学构造,未运行OpenFOAM。全流程通过仍不代表物理结果合格。',ha='center',fontsize=9,color='#526375')
    fig.tight_layout(rect=(0,.05,1,1))
    fig.savefig(args.out/'lesson57_workflow_status.png',dpi=180)
    plt.close(fig)
    print('FIGURE_OK: lesson57_workflow_status.png')


if __name__ == '__main__':
    main()

python plot_lesson57.py --out figures57_01

预期输出:

复制代码
FIGURE_OK: lesson57_workflow_status.png

没有Matplotlib也不影响主程序练习,可以直接阅读提供的图片。

十一、最重要的反例:全流程通过,速度仍然可能错误

运行:

复制代码
python lesson57_workflow.py --scenario wrong_velocity --out run57_wrong_01

输出仍然为:

复制代码
mesh: PASS
mesh_check: PASS
solve: PASS
postprocess: PASS
FINAL=READY_FOR_PHYSICS_REVIEW
MODE=REAL_PYTHON_SUBPROCESSES; CFD_EVIDENCE=TEACHING_FIXTURE; OpenFOAM_NOT_RUN

但metrics.json中的均速和单位宽度流量,被同时放大了15%。

指标 基准值 教学错误值
平均速度/m·s⁻¹ 0.016667 0.019167
单位宽度流量/m²·s⁻¹ 0.000166667 0.000191667

为什么前面的检查没有发现?

因为:

  • 数据格式正确;
  • 数值有限且为正;
  • 教学残差记录合格;
  • 教学守恒指标合格;
  • 速度和流量同步变化,还能保持简单比例关系。

但是,在相同物理规格下,与第55节解析基准比较:

\ e_{\\bar u} = \\left\| \\frac{\\bar u_{\\mathrm{reported}}} {\\bar u_{\\mathrm{ref}}} -1 \\right\| = 15\\%. \\

这就是为什么最终状态必须是:

复制代码
READY_FOR_PHYSICS_REVIEW

而不是:

复制代码
PHYSICALLY_VERIFIED

这个反例不是说错误结果应该接受,而是在说明每一层检查都有能力边界。

第59节会进一步组织基准对照、误差分析和物理验收。

十二、怎样迁移到真实OpenFOAM?

不能只把某个Python命令替换成foamRun,就宣布已经接入。

需要逐项替换和验证:

本节教学实现 真实接入时需要做的事
Python工作进程 已验证版本的实际工具
mesh_record.json 真实网格及生成证据
mesh_ok布尔量 对实际网格报告的解析
固定教学日志 与版本、求解模式匹配的解析器
JSON中的时间和场名 查询实际目标时间及场文件
构造的指标 按固定位置、单位和时间提取真实结果
等待物理复核 执行独立基准与工程审核

本节JSON中的U、p和目标时间,只是教学记录,不代表实际CFD产物存在。

真实接入应先选择一个完整、可以单独运行的参考案例,逐步验证命令,再封装为自动化工具。

输入副本、软件版本、脚本版本、日志和提取口径,都应与结果一起保存。

十三、给智能体划清执行权限

下一节将讨论多角色协作,但智能体不能绕过执行层的规则。

它可以提出计划、解释错误、建议修改;执行层仍应限制:

  • 哪些工具允许调用;
  • 哪些目录允许访问;
  • 哪些物性或边界需要人工确认;
  • 最多重试多少次;
  • 可以使用多少计算资源;
  • 哪些验收阈值不能自行放宽。

失败后应先分类:

缺参数,就澄清参数;环境不可用,就检查环境;超时,就查询旧任务;数值异常,才考虑经过授权的设置调整。

不能为了输出"成功",而改变物理问题、放宽验收条件或者删除失败记录。

十四、课后练习与本节验收

练习一:把残差改成NaN

解析器应该怎样处理?

参考答案:拒绝,不能将NaN当作0或正常小残差。

练习二:为什么返回码0仍然可能停止?

参考答案:返回码说明进程的执行结果,不能代替对业务证据的检查。mesh_bad就是本节反例。

练习三:为什么超时后不能立即重提?

参考答案:真实后代进程或远程作业可能仍在运行,需要先查询状态,避免重复成本和结果竞争。

练习四:全部步骤通过,是否意味着解析器没有问题?

参考答案:通过只能说明配置范围内的检查通过。还需要核对检查范围是否完整,以及物理结果是否符合基准。

本节验收要求

能够复现正常流程和至少三种故障,并说明:

复制代码
计划列出 ≠ 实际执行
进程结束 ≠ 记录通过
记录通过 ≠ 物理正确

同时能够回查原始日志、停止原因和被跳过的步骤。

本节小结

可信自动化不是让命令不停地运行,而是让每一步都有明确的输入、产物、检查和停止条件。

记录不足时承认不足,失败时保存证据,流程通过后仍接受物理复核。

下一节将按照课程目录,进入"Foam-Agent协作与知识检索",在受控执行基础上组织需求、检索、配置、执行与审核。

相关推荐
WikiLeake1 小时前
一个健壮且灵活的大语言模型(LLM)API 配置加载器
数据库·人工智能
龙亘川1 小时前
电子印章安全审计管理业务建模:从证书密钥、安全策略到审计预警的全链路闭环
运维·人工智能·安全·智慧城市·开源软件·数据可视化
饼饼学习空间智能1 小时前
物流仓储机器人训练数据从哪来?真机、仿真与数据回流技术解析
人工智能
2501_938140073 小时前
做内容很省心,2026好用AI笔记,支持抖音小红书视频无水印保存
人工智能
sell-cloud6 小时前
Prompt Engineering要凉了?Fable 5.5能“猜“意图,但银行正在封杀AI Agent——企业AI的真正护城河是什么
人工智能·prompt
Navicat中国7 小时前
Navicat 18 内置 AI 助手让数据库开发从繁琐操作迈向智能协作
大数据·人工智能·数据库开发
4SAPI8 小时前
多模型请求路由怎么设计?成本、速度和能力如何平衡
人工智能·python·gateway·php
魔乐社区8 小时前
翼芯・智算测试与适配优化专区,让国产算力“选得准、跑得通、用得好“
人工智能·开源·大模型
XCDoris10 小时前
郴州logo设计机构
大数据·人工智能·python