从“人操作仪器”到“Agent 自主发现”:OPL 金属材料自驱动实验室 MVP 技术论述

OPL 金属材料自驱动实验室试图完成一次科研范式的转变:从传统的"人设计实验 → 人操作仪器 → 人读取数据 → 人分析归纳",转向"Agent 设计实验 → Agent 编排仪器 → 结构化数据 → Agent 发现规律"。其核心考察点有两个:一是多仪器工具调用链的自主编排,即 OPL 原位加载、拉伸机、EBSD、XRD 等仪器不是按写死脚本执行,而是由 Agent 根据实验目标和样品状态动态选择;二是自主发现,即检测完成后直接输出科学规律,而不是复述数据。

结合项目 README、整体技术方案、工具调用链 Schema 设计、规律发现 Agent Prompt 设计以及 Python 代码骨架,可以看到该项目已经实现了一个可运行、可测试、可扩展的最小闭环 MVP。它用统一 Tool Schema 封装仪器,用 valid_states 和 requires/provides 表达物理依赖,用规则引擎或 LLM function calling 做决策,用模拟器内置可验证物理规律,用确定性统计加协议化输出生成科学规律。本文将从架构、工具封装、自主编排、规律发现、Prompt 设计、代码实现、运行验证、技术对标和迭代方向等方面展开论述。


一、项目定位与需求

传统材料力学性能表征流程存在三个瓶颈:实验设计依赖专家经验,跨仪器组合难以穷举;仪器操作串行耗时,数据格式异构,人工解析效率低;规律归纳主观性强,难以在检测完成后即时输出结构化结论。OPL 金属材料自驱动实验室的目标,是让 Agent 自主设计实验、自主操作仪器、自主发现规律。

项目交付形态是一个可运行的最小闭环 MVP,配套三份技术文档:《工具调用链 Schema 设计》《规律发现 Agent Prompt 设计》《整体技术方案》。技术对标为 Agentic AI 工具调用与自主发现。前者对应 Function Calling、ReAct、工具编排框架;后者对应 Agent 驱动的数据综合与假设生成。

MVP 默认演示"轧制压下率对铝合金力学性能、织构与物相的影响"。输入自然语言目标和工艺水平,系统规划样品批次,自主编排仪器链,采集结构化结果,并输出科学规律与 JSON 报告。


二、总体架构:五层分层与核心模块

整体技术方案给出五层架构:

  1. 应用层:实验目标输入、报告输出、评测展示;
  2. Agent 层:规划器、编排器、规律发现,决策引擎可为规则引擎或 LLM Function Calling;
  3. 工具层:仪器工具封装,包括 OPL、拉伸机、EBSD、XRD、制样;
  4. 仪器层:模拟器或真实仪器控制接口;
  5. 数据层:统一 JSON Schema、执行轨迹、报告。

代码骨架中,main.py 是入口,负责参数解析、调用规划器、编排器、发现模块并输出报告。planner.py 把自然语言目标解析为实验方案;orchestrator.py 是 Agent 核心,负责逐样品选择工具、执行并更新状态;registry.py 维护工具注册表并计算当前可执行工具集;tools/*.py 封装各仪器;simulators.py 提供物理模拟;discovery.py 做跨仪器联合分析;context.py 维护样品状态、工艺信息、结果与 trace;llm_backend.py 提供可选 LLM 决策;discovery_prompt.py 提供规律发现 Prompt 模板。

这种分层的关键价值在于:仪器差异被工具层吸收,Agent 层只面对统一 Schema;物理依赖被依赖层固化为硬约束,决策层则在合法可执行集中自主选择。这正是"自主编排"与"写死流程"的本质区别。


三、工具封装与 Schema 设计

《工具调用链 Schema 设计》强调,多仪器工具调用链的前提是统一封装。每台仪器封装为 tool,包含 name、description、input_schema、output_schema 和 dependencies。工具即函数,输入输出均为结构化 JSON,兼容主流大模型 function calling 协议。

代码中 base.py 定义了 Tool 抽象基类和 ToolResult。Tool 的核心字段包括:

  • name:工具名;
  • description:自然语言说明;
  • input_schema / output_schema:参数与输出结构;
  • required_sample_state:语义化依赖描述;
  • provided_sample_state:执行后样品进入的状态;
  • valid_states:可执行状态白名单;
  • execute_fn:执行器。

ToolResult 统一成功与失败结构。失败时返回 code、message、retryable、suggestion,为编排器提供恢复依据。

具体工具的状态依赖如下:

  • sample_prep:valid_states=["raw"],执行后 prepared;
  • opl_in_situ_loading:valid_states=["prepared"],执行后 loaded;
  • tensile_test:valid_states=["prepared", "loaded"],执行后 characterized;
  • ebsd_scan:valid_states=["characterized"],不改变状态;
  • xrd_measurement:valid_states=["characterized"],不改变状态。

这形成了一条物理依赖链:raw → prepared → loaded / characterized。EBSD 和 XRD 必须在拉伸后样品进入 characterized 状态后才能执行。文档中更完整的 Schema 还定义了 categories、artifacts、specimen_state、material_state 等字段,MVP 代码做了简化,但保留了最核心的依赖图语义。


四、自主编排:依赖层固化,决策层自主

自主编排是项目最核心的考察点。README 明确指出,编排器不硬编码"OPL → 拉伸 → EBSD → XRD"。每步只做两件事:

  1. 从工具注册表计算当前样品状态下可执行工具集;
  2. 由决策引擎按实验目标,即目标响应缺口与依赖解锁关系,自主选择下一步工具。

代码中 orchestrator.py 实现了这一循环。available_tools_for(state) 根据 valid_states 返回可执行工具。RuleBasedDecision.choose 对可用工具打分:

  • 若工具能直接提供目标响应,加 100 分;
  • 若工具执行后可解锁某个目标响应工具的可用状态,加 50 分;
  • 若尚未获得任何响应且工具是 sample_prep,加 20 分;
  • 再加微弱的信息量偏好。

同分时按响应优先级排序:mechanical > texture > phase。这个评分函数体现了"目标响应缺口 + 依赖解锁关系"的自主选择逻辑。

以一个典型场景为例。默认目标"研究轧制压下率对铝合金力学性能、织构与物相的影响"解析出的目标响应为 mechanical、texture、phase,不包含 in_situ。样品初始为 raw。第一步只有 sample_prep 可执行,执行后进入 prepared。此时可用工具包括 OPL 和拉伸机。OPL 能提供 in_situ,但目标不需要;它可以把样品变成 loaded,从而解锁拉伸机,因此可得 50 分。拉伸机直接提供 mechanical,得 100 分,因此被优先选择。拉伸后样品进入 characterized,随后 EBSD 和 XRD 均可用,按 texture > phase 的优先级,EBSD 先执行,XRD 后执行。因此每样品链条为:sample_prep → tensile_test → ebsd_scan → xrd_measurement,三个样品共 12 步。这正好对应技术方案中"默认演示输出 12 步工具调用链"的描述。

如果实验目标加入"原位""损伤演化"等关键词,planner 会解析出 in_situ 响应。此时 OPL 直接命中目标响应,得 100 分,同时可解锁拉伸机,再加 50 分,因此会优先于拉伸机执行。链条变为 sample_prep → opl_in_situ_loading → tensile_test → ebsd_scan → xrd_measurement。如果目标只测物相,链条则变为 sample_prep → tensile_test → xrd_measurement,自动跳过 OPL 和 EBSD。测试 test_goal_targets_change_chain 正是验证这一点:只测物相时,ebsd_scan 不出现在调用链中,xrd_measurement 出现。

这说明调用链不是写死的,而是由目标响应、样品状态和工具依赖共同决定的。依赖层保证每一步物理合法,决策层保证在多个合法选项中按目标自主选择。


五、自主发现:从结构化结果到科学规律

自主发现是项目第二个核心考察点。discovery.py 的设计目标是:不是复述"样品 A 强度 450 MPa、样品 B 强度 520 MPa",而是输出"随工艺参数变化,性能指标呈现怎样的趋势 + 可能机理 + 支撑证据"。

发现模块首先按压下率升序收集各样品的关键指标,包括拉伸 summary、EBSD summary、XRD summary。然后用标准库实现皮尔逊相关系数和趋势判定。趋势方向分为 increasing、decreasing、flat、weak_trend、insufficient。

在默认数据下,模拟器内置了可验证物理规律:

  • 屈服强度约为 300 + 400 * r,随压下率上升;
  • 延伸率约为 32 - 22 * r,随压下率下降;
  • Brass 织构组分约为 0.05 + 0.50 * r,随压下率上升;
  • 平均晶粒尺寸约为 28 - 28 * r,随压下率细化;
  • KAM 随压下率上升;
  • XRD 主峰半高宽约为 0.10 + 0.08 * r,随变形展宽;
  • 物相保持 alpha-Al + 微量 AlFeSi。

发现模块据此生成多条规律:

  1. 力学性能趋势:随轧制压下率增大,屈服强度与抗拉强度上升,延伸率下降,强度-塑性此消彼长,符合加工硬化主导的强化规律。
  2. 织构演变:Brass 织构组分上升、晶粒细化,说明形变织构增强、微观组织细化。
  3. 跨仪器关联:屈服强度与 Brass 织构组分正相关,与晶粒尺寸负相关,表明强度提升同时受织构强化与细晶强化贡献。
  4. XRD 佐证:主峰半高宽随压下率增大而展宽,物相组成不变,佐证微观应变与位错密度积累。

每条规律都包含五要素:statement、evidence、mechanism_hypothesis、confidence、direction,并可附带 correlation。这使输出不是数据清单,而是可验证、可证伪、带机理假设的科学结论。


六、规律发现 Prompt 设计与防复述机制

《规律发现 Agent Prompt 设计》进一步把"自主发现"协议化。System Prompt 固化四条约束:

  1. 输出规律/结论,而不是数据复述,仅仅罗列数值不算完成;
  2. 每条规律必须包含规律陈述、支撑证据、机理假设、置信度;
  3. 优先寻找跨变量趋势与跨仪器关联;
  4. 没有显著规律时如实说明,不得编造。

User Prompt 注入实验目标、材料体系和结构化结果,并要求按 JSON schema 输出。代码中 discovery_prompt.py 落地了这一设计:SYSTEM_PROMPT 固化四约束,USER_TEMPLATE 注入目标、材料与结构化结果,build_discovery_prompt() 完成组装。

文档还提出三层防复述机制:

  • 结构性防复述:JSON schema 中不存在原始数据回填字段,evidence 限长,direction 必填,Agent 只能引用提炼后的证据;
  • 语义性防复述:用 few-shot 对比"错误示例:数据复述"与"正确示例:科学规律";
  • 评测性防复述:计算 statement 与输入数据文本的重合度,校验方向性与证据存在性。

在 MVP 中,即使没有配置 LLM,discovery.py 的确定性规则引擎也会直接产出同 schema 的规律。配置 OPENAI_API_KEY 后,可将结构化结果送入 Prompt 模板获取 LLM 版规律。两条路径输出 schema 一致,可互相校验。


七、代码实现走读

main.py 是入口。它调用 init_registry() 初始化工具,调用 plan_experiment() 生成计划,创建 Orchestrator 并运行,最后调用 discover() 生成规律。报告包含 goal、material、plan、orchestration_decisions、experiment_chain、results、discovery,并写入 output/experiment_report.json。

planner.py 通过关键词映射解析目标响应。例如"力学""强度""拉伸"映射为 mechanical,"织构""晶粒""EBSD"映射为 texture,"物相""XRD""衍射"映射为 phase,"原位""OPL""损伤"映射为 in_situ。若未识别出任何目标,则默认全响应覆盖。每个压下率水平生成一个样品。

orchestrator.py 是 Agent 核心。run() 遍历样品,初始化状态和元数据,然后调用 _orchestrate_sample()。后者维护 executed 和 obtained,循环计算可用工具、调用决策引擎、执行工具、更新状态,直到目标响应全部获得、无可执行工具或达到最大步数。失败时重试一次,仍失败则终止该样品。每次执行都写入 trace,并记录结果。

registry.py 维护工具注册表,available_tools_for() 根据当前样品状态计算可执行工具集。context.py 维护样品状态、工艺信息、结果和 trace,是工具与编排器共享的上下文。

llm_backend.py 提供可选 LLM 决策。它用标准库 urllib 调用 OpenAI 兼容 Chat Completions,构建 tools schema,设置 tool_choice="auto"、temperature=0,解析返回的 tool_calls。若未配置 OPENAI_API_KEY 或请求失败,LLMDecision 自动回退规则引擎。这保证系统默认可运行,LLM 只是增强项。


八、运行演示与测试验证

默认运行:

bash 复制代码
python main.py

终端打印自主编排的工具调用链和自主发现的科学规律,同时输出 JSON 报告。默认演示研究轧制压下率 20%、40%、60% 对铝合金力学性能、织构与物相的影响,输出 12 步工具调用链与多条科学规律。

自定义实验目标:

bash 复制代码
python main.py --goal "研究轧制压下率对铝合金物相的影响" --levels 0.1,0.3,0.5

这会改变目标响应集合,从而改变调用链,验证目标驱动编排的自主性。

测试方面,test_pipeline.py 包含五项测试:

  • test_tool_registry:验证注册表包含五类工具;
  • test_parse_targets:验证目标关键词解析;
  • test_end_to_end_chain_and_discovery:验证端到端调用链包含制样、拉伸、EBSD、XRD,样品最终进入 characterized 状态,发现模块至少输出 3 条规律;
  • test_goal_targets_change_chain:验证只测物相时调用链不包含 EBSD;
  • test_stats:验证皮尔逊相关与趋势判定。

这些测试覆盖了"自主编排随目标变化"和"规律发现非复述"两个核心考察点。


九、与 Agentic AI 技术对标

项目在工具调用层面对标 ReAct / Tool Calling 模式:决策引擎从可执行工具集中选工具、执行、观察结果、再决策,形成"思考-行动-观察"循环。工具描述遵循 OpenAI 兼容 Function Calling Schema,可直接对接主流大模型。

在自主发现层面对标科研自动化的"数据到假设"环节:用统计方法替代人工横比,用协议化输出替代直觉归纳,用可证伪性约束替代主观断言,使"检测完成直接输出科学规律"成为系统默认行为。

项目的差异点与增量在于:

  • 工具调用链新增物理依赖约束 valid_states,避免 Agent 在数据依赖上犯错;
  • 规律输出新增科学严谨性约束,包括证据绑定、机理假设与观察分离、置信度分级;
  • 模拟器内置可验证规律,使自主发现可被自动化评测,例如趋势方向、相关系数、规律数量。

十、局限、风险与迭代方向

当前 MVP 的局限也很明显。首先,仪器层使用模拟器,真实仪器接入尚未完成;但代码已把替换点放在 _xxx_impl 中,接入真实 API 时 schema 与编排逻辑不变。其次,失败恢复只实现了一次重试,文档中提到的指数退避、降级路径、备用试样、数据质量触发重扫尚未完全实现。再次,并发调度尚未实现,当前 Orchestrator.run 是样品串行执行,而技术方案提出同一样品串行、不同样品可并行。最后,规律发现规则是确定性统计,LLM 版规律生成需要配置 API Key 后才能启用。

风险方面,真实仪器接口异构会延长落地周期;数据质量差会导致规律失真;LLM 决策可能不稳定;规律发现可能编造证据;实验设计不可穷举。对策分别是:统一 Schema、模拟器先行;统一错误结构与质量字段;规则引擎默认、LLM 增强并失败回退;推理协议加证据存在性校验;后续引入正交实验、响应面等统计实验设计。

技术方案给出的里程碑也清晰:M1 基础闭环,M2 目标驱动验证与失败恢复,M3 真实仪器接入,M4 LLM Function Calling 与规律生成,M5 多因子实验设计、并发样品流与批量报告。


十一、总结

OPL 金属材料自驱动实验室 MVP 用一套简洁但完整的代码骨架,验证了 Agentic AI 在材料实验中的两个核心能力:多仪器工具调用链自主编排,以及检测完成直接输出科学规律。

它的关键设计可以概括为三点:

  1. 工具封装统一 Schema:每台仪器变成可调用函数,输入输出结构化,依赖显式化;
  2. 编排目标驱动:依赖层固化物理约束,决策层按目标响应缺口与解锁关系自主选工具,因此换目标即换调用链;
  3. 规律输出协议化:规律必须包含陈述、证据、机理、置信度、方向,避免数据复述,使自主发现可评测、可校验、可扩展。

从"人操作仪器、人分析数据"到"Agent 自主设计实验、操作仪器、发现规律",该项目已经迈出了可运行的最小闭环一步。后续接入真实仪器与 LLM 后,它有望成为材料自驱动实验室的通用编排与发现底座。

以下按文件逐个打印项目中所有 Python 代码。文件路径按项目结构标注。


main.py

python 复制代码
# -*- coding: utf-8 -*-
"""OPL 金属材料自驱动实验室 --- MVP 入口。

运行示例:
    python main.py
    python main.py --goal "研究轧制压下率对铝合金力学性能与织构的影响" --levels 0.2,0.4,0.6
"""
from __future__ import annotations

import argparse
import json
import os
import sys
from typing import Any, Dict, List

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from opdlab import config  # noqa: E402
from opdlab.agent.discovery import discover  # noqa: E402
from opdlab.agent.orchestrator import Orchestrator  # noqa: E402
from opdlab.agent.planner import plan_experiment  # noqa: E402
from opdlab.tools.registry import init_registry  # noqa: E402

def build_report(goal_text: str, material: str, levels: List[float]) -> Dict[str, Any]:
    init_registry()
    plan = plan_experiment(goal_text, material, levels, seed=config.SEED)
    orchestrator = Orchestrator()
    ctx = orchestrator.run(plan, goal_text)
    discovery = discover(ctx)
    return {
        "goal": goal_text,
        "material": material,
        "plan": plan.to_dict(),
        "orchestration_decisions": orchestrator.decision.name,
        "experiment_chain": [
            {
                "step": i + 1,
                "sample_id": t["sample_id"],
                "tool": t["tool"],
                "rationale": t["rationale"],
                "ok": t["ok"],
            }
            for i, t in enumerate(ctx.trace)
        ],
        "results": ctx.results,
        "discovery": discovery,
    }

def main() -> None:
    parser = argparse.ArgumentParser(description="OPL 金属材料自驱动实验室 MVP")
    parser.add_argument("--goal", default="研究轧制压下率对铝合金力学性能、织构与物相的影响",
                        help="实验目标(自然语言)")
    parser.add_argument("--material", default=config.DEFAULT_MATERIAL, help="材料体系")
    parser.add_argument("--levels", default=",".join(map(str, config.DEFAULT_LEVELS)),
                        help="工艺变量水平(轧制压下率,逗号分隔,如 0.2,0.4,0.6)")
    parser.add_argument("--output", default="", help="输出 JSON 路径(默认写入 output/ 目录)")
    args = parser.parse_args()

    levels = [float(x) for x in args.levels.split(",") if x.strip()]
    report = build_report(args.goal, args.material, levels)

    print("=" * 78)
    print("OPL 金属材料自驱动实验室 --- 最小闭环演示")
    print("=" * 78)
    print(f"实验目标: {report['goal']}")
    print(f"材料体系: {report['material']}   决策引擎: {report['orchestration_decisions']}")
    print(f"计划响应: {report['plan']['target_responses']}")
    print(f"样品批次: {len(report['plan']['samples'])} 个(压下率 "
          f"{[s['rolling_reduction'] for s in report['plan']['samples']]})")
    print("-" * 78)
    print("自主编排的工具调用链:")
    for e in report["experiment_chain"]:
        mark = "OK " if e["ok"] else "ERR"
        print(f"  [{e['step']:02d}] [{mark}] {e['sample_id']} -> {e['tool']}")
        print(f"       原因: {e['rationale']}")
    print("-" * 78)
    print("自主发现的科学规律:")
    if report["discovery"]["laws"]:
        for i, law in enumerate(report["discovery"]["laws"], 1):
            print(f"  规律 {i}(置信度 {law['confidence']},方向 {law['direction']}):")
            print(f"    {law['statement']}")
            print(f"    证据: {law['evidence']}")
            print(f"    机理: {law['mechanism_hypothesis']}")
    else:
        print("   (未发现显著规律)")
    print("=" * 78)

    out_path = args.output or os.path.join(config.OUTPUT_DIR, "experiment_report.json")
    os.makedirs(os.path.dirname(out_path), exist_ok=True)
    with open(out_path, "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(f"报告已输出: {out_path}")

if __name__ == "__main__":
    main()

opdlab/config.py

python 复制代码
# -*- coding: utf-8 -*-
"""全局配置:材料体系、工艺参数、仪器参数。"""
import os

# 随机种子(保证模拟可复现)
SEED = int(os.environ.get("OPD_SEED", "42"))

# 默认材料体系
DEFAULT_MATERIAL = "AA6061"

# 默认工艺变量及其水平(轧制压下率,0.2 = 20%)
DEFAULT_FACTOR = "rolling_reduction"
DEFAULT_LEVELS = [0.2, 0.4, 0.6]

# 输出目录
OUTPUT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "output")
os.makedirs(OUTPUT_DIR, exist_ok=True)

# LLM 决策后端配置(可选;未配置时使用内置规则引擎)
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini")

opdlab/agent/context.py

python 复制代码
# -*- coding: utf-8 -*-
"""实验上下文:维护样品状态、工艺信息与各仪器产出,供工具与编排器共享。"""
from __future__ import annotations

from typing import Any, Dict, List

class ExperimentContext:
    def __init__(self) -> None:
        self._states: Dict[str, str] = {}
        self._meta: Dict[str, Dict[str, Any]] = {}
        self.results: Dict[str, Dict[str, Any]] = {}
        self.trace: List[Dict[str, Any]] = []

    # ---- 样品状态 ----
    def init_sample(self, sample_id: str, state: str = "raw") -> None:
        self._states[sample_id] = state

    def get_sample_state(self, sample_id: str) -> str:
        return self._states.get(sample_id, "raw")

    def set_sample_state(self, sample_id: str, state: str) -> None:
        self._states[sample_id] = state

    # ---- 样品工艺信息 ----
    def set_sample_meta(self, sample_id: str, **kwargs: Any) -> None:
        self._meta.setdefault(sample_id, {}).update(kwargs)

    def get_sample_meta(self, sample_id: str) -> Dict[str, Any]:
        return self._meta.get(sample_id, {})

    # ---- 仪器产出记录 ----
    def record_result(self, sample_id: str, tool_name: str, data: Dict[str, Any]) -> None:
        self.results.setdefault(sample_id, {})[tool_name] = data

    def append_trace(self, entry: Dict[str, Any]) -> None:
        self.trace.append(entry)

    def summary(self) -> Dict[str, Any]:
        return {
            "samples": {
                sid: {
                    "state": self._states.get(sid),
                    "meta": self._meta.get(sid, {}),
                    "results": self.results.get(sid, {}),
                }
                for sid in self._states
            },
            "trace": self.trace,
        }

opdlab/agent/discovery.py

python 复制代码
# -*- coding: utf-8 -*-
"""
规律发现模块:把跨仪器结构化结果转化为科学规律。

核心思想:不是复述"样品A强度450MPa、样品B强度520MPa"这类数字,
而是输出"随工艺参数变化,性能指标呈现怎样的趋势 + 可能的机理 + 支撑证据"。
本实现使用确定性规则 + 简单统计(相关系数、单调趋势),可扩展为 LLM 推理。
"""
from __future__ import annotations

import math
from typing import Any, Dict, List, Optional, Tuple

from .context import ExperimentContext

# ---------------------------------------------------------------- 统计工具
def pearson(a: List[float], b: List[float]) -> float:
    """计算两组数据的皮尔逊相关系数(标准库实现)。"""
    n = len(a)
    if n != len(b) or n < 2:
        return 0.0
    ma, mb = sum(a) / n, sum(b) / n
    num = sum((x - ma) * (y - mb) for x, y in zip(a, b))
    da = math.sqrt(sum((x - ma) ** 2 for x in a)) or 1e-9
    db = math.sqrt(sum((y - mb) ** 2 for y in b)) or 1e-9
    return num / (da * db)

def trend_direction(xs: List[float], ys: List[float]) -> Tuple[str, float]:
    """判定 ys 随 xs 的整体趋势方向与确定性。"""
    if len(xs) < 2:
        return "insufficient", 0.0
    r = pearson(xs, ys)
    if r >= 0.8:
        return "increasing", r
    if r <= -0.8:
        return "decreasing", abs(r)
    if abs(r) < 0.3:
        return "flat", abs(r)
    return "weak_trend", abs(r)

# ---------------------------------------------------------------- 规律生成
def _collect_series(ctx: ExperimentContext):
    """按压下率升序收集各样品的关键指标。"""
    items = []
    for sample_id, meta in ctx._meta.items():
        if "rolling_reduction" not in meta:
            continue
        results = ctx.results.get(sample_id, {})
        item = {
            "sample_id": sample_id,
            "reduction": meta["rolling_reduction"],
            "tensile": results.get("tensile_test", {}).get("summary", {}),
            "ebsd": results.get("ebsd_scan", {}).get("summary", {}),
            "xrd": results.get("xrd_measurement", {}).get("summary", {}),
        }
        if item["reduction"] is not None:
            items.append(item)
    items.sort(key=lambda x: x["reduction"])
    return items

def _law(statement: str, evidence: List[str], mechanism: str, confidence: str,
         direction: str = "", correlation: float | None = None) -> Dict[str, Any]:
    law: Dict[str, Any] = {
        "statement": statement,
        "evidence": evidence,
        "mechanism_hypothesis": mechanism,
        "confidence": confidence,
        "direction": direction,
    }
    if correlation is not None:
        law["correlation"] = round(correlation, 3)
    return law

def discover(ctx: ExperimentContext) -> Dict[str, Any]:
    """主入口:从实验上下文生成科学规律。"""
    items = _collect_series(ctx)
    if len(items) < 2:
        return {
            "laws": [],
            "data_summary": {"samples": len(items)},
            "note": "样本不足,无法开展跨条件趋势分析",
        }

    reductions = [i["reduction"] for i in items]
    laws: List[Dict[str, Any]] = []

    # ---- 力学性能趋势 ----
    if all(i["tensile"] for i in items):
        ys = [i["tensile"]["yield_strength_mpa"] for i in items]
        uts = [i["tensile"]["ultimate_tensile_strength_mpa"] for i in items]
        els = [i["tensile"]["elongation_pct"] for i in items]

        d_ys, r_ys = trend_direction(reductions, ys)
        d_el, r_el = trend_direction(reductions, els)
        span_ys = ys[-1] - ys[0]
        span_el = els[0] - els[-1]

        evidence = [
            f"屈服强度: {[round(y, 0) for y in ys]} MPa(压下率 {[int(r*100) for r in reductions]}%)",
            f"延伸率: {[round(e, 1) for e in els]}%",
        ]
        if d_ys == "increasing" and d_el == "decreasing":
            laws.append(_law(
                "随轧制压下率增大,屈服强度(与抗拉强度)单调上升、延伸率单调下降,"
                "强度-塑性呈现典型的此消彼长关系,符合加工硬化主导的强化规律。",
                evidence,
                "冷轧引入高密度位错与亚结构,阻碍位错滑移从而提升强度,同时消耗变形能力使塑性下降。",
                "high", direction="strength_up_ductility_down",
                correlation=min(r_ys, r_el),
            ))

        # ---- 织构演变(EBSD)----
    if all(i["ebsd"] for i in items):
        brass = [i["ebsd"]["texture_components"]["Brass"] for i in items]
        copper = [i["ebsd"]["texture_components"]["Copper"] for i in items]
        grain = [i["ebsd"]["average_grain_size_um"] for i in items]
        kam = [i["ebsd"]["kam_mean_deg"] for i in items]

        d_br, r_br = trend_direction(reductions, brass)
        d_gr, r_gr = trend_direction(reductions, grain)

        evidence = [
            f"Brass 织构组分: {[round(b, 1) for b in brass]}%",
            f"Copper 织构组分: {[round(c, 1) for c in copper]}%",
            f"平均晶粒尺寸: {[round(g, 1) for g in grain]} um",
        ]
        if d_br == "increasing":
            laws.append(_law(
                "随轧制压下率增大,Brass 织构组分单调上升、晶粒尺寸细化,"
                "说明形变织构(Brass 型)随变形量增加而增强,微观组织持续细化。",
                evidence,
                "冷轧变形沿特定晶面滑移使取向向 Brass 组分汇聚(织构择优),"
                "同时动态回复与晶粒破碎导致晶粒细化。",
                "high", direction="brass_up_grain_finer",
                correlation=min(r_br, r_gr),
            ))

        # ---- 跨仪器关联:织构强化 vs 强度 ----
        if all(i["tensile"] for i in items):
            strength = [i["tensile"]["yield_strength_mpa"] for i in items]
            r_br_str = pearson(strength, brass)
            r_gr_str = pearson(strength, grain)
            laws.append(_law(
                "跨仪器联合分析:屈服强度与 Brass 织构组分正相关(r=%.2f)、"
                "与晶粒尺寸负相关(r=%.2f),表明强度提升同时受织构强化与细晶强化贡献。"
                % (r_br_str, r_gr_str),
                [
                    f"强度-Brass 相关系数: {round(r_br_str, 2)}",
                    f"强度-晶粒尺寸相关系数: {round(r_gr_str, 2)}",
                ],
                "Zener 织构强化理论 + Hall-Petch 细晶强化:二者叠加形成宏观强度增益。",
                "medium",
                correlation=abs(r_br_str),
            ))

    # ---- XRD 物相与峰宽佐证 ----
    if all(i["xrd"] for i in items):
        fwhms = [i["xrd"]["peaks"][0]["fwhm_deg"] for i in items]
        phases0 = [p["name"] for p in items[0]["xrd"]["phases"]]
        phases1 = [p["name"] for p in items[-1]["xrd"]["phases"]]
        d_fw, r_fw = trend_direction(reductions, fwhms)
        evd = [f"XRD 主峰半高宽: {[round(f, 3) for f in fwhms]}°",
               f"物相组成: {phases0} -> {phases1}"]
        if d_fw == "increasing":
            laws.append(_law(
                "XRD 主峰半高宽随压下率增大而展宽,物相组成保持不变,"
                "佐证变形引入的微观应变/位错密度积累,与力学与 EBSD 观察一致。",
                evd,
                "峰展宽源于晶粒细化(Scherrer 效应)与微观应力(位错)的叠加。",
                "medium", direction="fwhm_up", correlation=r_fw,
            ))

    # ---- 只保留与最优先规律一致的结论,标注数据摘要供核查 ----
    return {
        "laws": laws,
        "data_summary": {
            "samples": len(items),
            "reductions": reductions,
            "obtained_responses": sorted({
                r for sid in ctx._meta for r in ctx._meta[sid].get("obtained_responses", [])
            }),
        },
        "note": "规律基于模拟数据生成,趋势与相关系数由标准库统计得出;接入真实仪器后替换模拟器即可。",
    }

opdlab/agent/llm_backend.py

python 复制代码
# -*- coding: utf-8 -*-
"""可选 LLM 决策后端:通过 OpenAI 兼容 Chat Completions 的 function calling 让大模型选下一步工具。

依赖标准库 urllib,无第三方包要求;未配置 OPENAI_API_KEY 或请求失败时,
编排器会自动回退到规则引擎(见 orchestrator.LLMDecision)。
"""
from __future__ import annotations

import json
import urllib.request
from typing import Any, Dict, List, Optional, Set

from ..config import OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL
from ..tools.base import Tool

def _build_tools_schema(available: List[Tool]) -> List[Dict[str, Any]]:
    return [
        {
            "type": "function",
            "function": {
                "name": t.name,
                "description": t.description,
                "parameters": t.input_schema,
            },
        }
        for t in available
    ]

def llm_choose_tool(
    sample_id: str,
    available: List[Tool],
    target_responses: Set[str],
    obtained: Set[str],
) -> Optional[str]:
    """调用 LLM,返回选中的工具名;任何异常返回 None(由调用方回退)。"""
    if not OPENAI_API_KEY or not available:
        return None

    payload: Dict[str, Any] = {
        "model": OPENAI_MODEL,
        "messages": [
            {"role": "system", "content": (
                "你是材料自驱动实验室的仪器编排 Agent。根据实验目标、当前已获得的数据"
                "与可用仪器工具,选择下一步该调用哪台仪器。只输出工具名。"
            )},
            {"role": "user", "content": (
                f"样品 {sample_id}:目标响应类型 {sorted(target_responses)},"
                f"已获得 {sorted(obtained)}。当前可用工具:"
                f"{[t.name for t in available]}。请选择下一个调用的工具。"
            )},
        ],
        "tools": _build_tools_schema(available),
        "tool_choice": "auto",
        "temperature": 0.0,
    }

    req = urllib.request.Request(
        f"{OPENAI_BASE_URL}/chat/completions",
        data=json.dumps(payload).encode("utf-8"),
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {OPENAI_API_KEY}",
        },
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=30) as resp:  # noqa: S310 - 用户显式配置的 LLM 端点
        body = json.loads(resp.read().decode("utf-8"))

    msg = body["choices"][0]["message"]
    tool_calls = msg.get("tool_calls") or []
    if tool_calls:
        fn = tool_calls[0]["function"]
        try:
            name = json.loads(fn.get("arguments", "{}")).get("tool")
            if name:
                return str(name)
        except json.JSONDecodeError:
            pass
        # function 名即工具名
        return fn.get("name")
    return None

opdlab/agent/planner.py

python 复制代码
# -*- coding: utf-8 -*-
"""实验规划器:把自然语言目标解析为实验方案(样品批 + 目标响应)。"""
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Dict, List, Set

# 响应类型 -> 可满足该响应的工具
RESPONSE_TOOLS = {
    "mechanical": {"tensile_test"},
    "texture": {"ebsd_scan"},
    "phase": {"xrd_measurement"},
    "in_situ": {"opl_in_situ_loading"},
}

KEYWORD_MAP = {
    "mechanical": ["力学", "强度", "屈服", "抗拉", "拉伸", "塑性", "延伸",
                   "mechanical", "strength", "tensile", "yield", "ductility"],
    "texture": ["织构", "取向", "晶粒", "ebsd", "微观组织", "texture", "grain"],
    "phase": ["物相", "xrd", "衍射", "相组成", "phase", "diffraction"],
    "in_situ": ["原位", "opl", "加载演化", "损伤", "滑移带", "in-situ", "crack"],
}

@dataclass
class ExperimentSample:
    sample_id: str
    material: str
    rolling_reduction: float

@dataclass
class ExperimentPlan:
    goal_text: str
    target_responses: Set[str]
    samples: List[ExperimentSample] = field(default_factory=list)

    def to_dict(self) -> Dict[str, Any]:
        return {
            "goal_text": self.goal_text,
            "target_responses": sorted(self.target_responses),
            "samples": [
                {"sample_id": s.sample_id, "material": s.material,
                 "rolling_reduction": s.rolling_reduction}
                for s in self.samples
            ],
        }

def parse_target_responses(goal_text: str) -> Set[str]:
    """从目标文本中解析需要的响应类型(多关键词匹配)。"""
    text = goal_text.lower()
    hits: Set[str] = set()
    for resp, keywords in KEYWORD_MAP.items():
        if any(k.lower() in text for k in keywords):
            hits.add(resp)
    return hits

def plan_experiment(goal_text: str, material: str, levels: List[float], seed: int = 42) -> ExperimentPlan:
    """生成实验计划:默认全响应覆盖 + 每组压下率一个样品。"""
    targets = parse_target_responses(goal_text)
    if not targets:
        targets = set(RESPONSE_TOOLS.keys())  # 未识别 → 全响应

    plan = ExperimentPlan(goal_text=goal_text, target_responses=targets)
    for i, level in enumerate(levels, start=1):
        plan.samples.append(ExperimentSample(
            sample_id=f"smp-{i:02d}",
            material=material,
            rolling_reduction=round(float(level), 4),
        ))
    return plan

opdlab/agent/orchestrator.py

python 复制代码
# -*- coding: utf-8 -*-
"""
工具调用链编排器(Agent 核心)。

编排逻辑不是硬编码 "OPL -> 拉伸 -> EBSD -> XRD" 的顺序,而是:
  1. 从工具注册表计算当前样品状态下"可执行工具集"(依赖满足且未执行);
  2. 由决策引擎(规则引擎或 LLM)依据实验目标从可执行集中自主选择下一步;
  3. 执行并更新样品状态,循环直至目标响应全部获得或无可执行工具。

因此改变实验目标(如只做物相、或加入原位观察)会自动得到不同的调用链。
"""
from __future__ import annotations

import time
from typing import Any, Dict, List, Optional, Set

from ..config import OPENAI_API_KEY
from ..tools.base import Tool, ToolResult
from ..tools.registry import available_tools_for, init_registry, list_tools
from .context import ExperimentContext
from .planner import RESPONSE_TOOLS, ExperimentPlan

def _tool_responses(tool: Tool) -> Set[str]:
    """返回工具能提供的响应类型集合。"""
    hits = set()
    for resp, tool_names in RESPONSE_TOOLS.items():
        if tool.name in tool_names:
            hits.add(resp)
    return hits

def _can_unlock(tool: Tool, wanted_tools: List[Tool]) -> bool:
    """判断 tool 执行后产出的样品状态,是否能解锁某个目标工具的可用状态。"""
    provided = tool.provided_sample_state
    if not provided:
        return False
    for wt in wanted_tools:
        if wt.valid_states is None:
            continue
        if provided in wt.valid_states:
            return True
    return False

class RuleBasedDecision:
    """规则决策引擎:依据目标响应缺口 + 依赖解锁关系打分选工具。"""

    name = "rule"

    def choose(
        self,
        sample_id: str,
        available: List[Tool],
        target_responses: Set[str],
        obtained: Set[str],
    ) -> Optional[Tool]:
        if not available:
            return None
        wanted = target_responses - obtained
        # 能提供目标响应的全部工具(含当前不可用的),用于判定解锁路径
        wanted_tools = [t for t in list_tools() if _tool_responses(t) & wanted]

        def score(t: Tool) -> float:
            s = 0.0
            provided = _tool_responses(t)
            if provided & wanted:
                s += 100.0  # 直接命中目标响应
            if _can_unlock(t, wanted_tools):
                s += 50.0  # 执行后可解锁目标响应工具的可用状态
            if not obtained and t.name == "sample_prep":
                s += 20.0  # 初始制样
            if t.name == "opl_in_situ_loading" and "in_situ" not in wanted and "in_situ" in target_responses:
                s += 0  # 目标要求原位数据时它本身会命中 +100,无需特判
            s += len(provided) * 0.5  # 微弱偏向信息量
            return s

        # 同分时按目标响应优先级稳定排序(依赖路径优先)
        def prio(t: Tool) -> int:
            if "in_situ" in target_responses and "in_situ" not in wanted:
                return 2  # 原位数据已满足时,把机会留给后续工具
            if "mechanical" in wanted:
                return 0
            if "texture" in wanted:
                return 1
            if "phase" in wanted:
                return 2
            return 9

        return sorted(available, key=lambda t: (-score(t), prio(t)))[0]

    def rationale(self, tool: Tool, target_responses: Set[str], obtained: Set[str]) -> str:
        provided = _tool_responses(tool)
        if provided & (target_responses - obtained):
            resp = sorted(provided & (target_responses - obtained))[0]
            return f"目标需要响应类型 [{resp}],选择 {tool.name} 直接获取该数据"
        if tool.name == "sample_prep":
            return "样品尚未制备,先执行 sample_prep 建立基线状态"
        return f"当前依赖条件下选择 {tool.name}({tool.description[:40]}...)"

class LLMDecision:
    """可选 LLM 决策后端(OpenAI 兼容 function calling)。未配置密钥时回退规则引擎。"""

    name = "llm"

    def __init__(self) -> None:
        if not OPENAI_API_KEY:
            self._fallback = RuleBasedDecision()
            self.enabled = False
        else:
            self.enabled = True

    def choose(
        self,
        sample_id: str,
        available: List[Tool],
        target_responses: Set[str],
        obtained: Set[str],
    ) -> Optional[Tool]:
        if not self.enabled or not available:
            return self._fallback.choose(sample_id, available, target_responses, obtained)
        try:
            from .llm_backend import llm_choose_tool
            choice = llm_choose_tool(sample_id, available, target_responses, obtained)
            if choice is not None and any(t.name == choice for t in available):
                return next(t for t in available if t.name == choice)
            return self._fallback.choose(sample_id, available, target_responses, obtained)
        except Exception:  # 网络/解析失败 → 规则兜底
            return self._fallback.choose(sample_id, available, target_responses, obtained)

    def rationale(self, tool: Tool, target_responses: Set[str], obtained: Set[str]) -> str:
        if self.enabled:
            return f"LLM 决策:选择 {tool.name}"
        return self._fallback.rationale(tool, target_responses, obtained)

class Orchestrator:
    """指挥 Agent 完成一轮实验:规划 -> 逐样品编排工具链 -> 汇总产出。"""

    def __init__(self, decision: Any = None) -> None:
        init_registry()
        self.decision = decision or (LLMDecision() if OPENAI_API_KEY else RuleBasedDecision())

    def run(self, plan: ExperimentPlan, goal_text: str) -> ExperimentContext:
        ctx = ExperimentContext()
        for sample in plan.samples:
            ctx.init_sample(sample.sample_id, state="raw")
            ctx.set_sample_meta(
                sample.sample_id,
                material=sample.material,
                rolling_reduction=sample.rolling_reduction,
                goal=goal_text,
            )
            self._orchestrate_sample(ctx, sample.sample_id, plan.target_responses)
        return ctx

    def _orchestrate_sample(self, ctx: ExperimentContext, sample_id: str, target_responses: Set[str]) -> None:
        executed: Set[str] = set()
        obtained: Set[str] = set()
        visited_guard = 0
        max_steps = 20

        while visited_guard < max_steps:
            visited_guard += 1
            # 目标响应已全部获得 -> 停止该样品(确保不被"随手补做"其它工具)
            if not (target_responses - obtained):
                break
            state = ctx.get_sample_state(sample_id)
            available = [
                t for t in available_tools_for(state)
                if t.name not in executed
            ]
            if not available:
                break

            choice = self.decision.choose(sample_id, available, target_responses, obtained)
            if choice is None:
                break

            rationale = self.decision.rationale(choice, target_responses, obtained)
            result = self._execute_tool(ctx, sample_id, choice, rationale)
            executed.add(choice.name)
            obtained |= _tool_responses(choice)

            if not result.ok:
                # 可重试一次(换策略:简单重试),仍失败则终止该样品
                result2 = self._execute_tool(ctx, sample_id, choice, rationale + "(重试)")
                if not result2.ok:
                    break
                result = result2
            if choice.provided_sample_state:
                ctx.set_sample_state(sample_id, choice.provided_sample_state)

        # 记录未能满足的响应缺口
        ctx.set_sample_meta(sample_id, obtained_responses=sorted(obtained),
                            missing_responses=sorted(target_responses - obtained))

    def _execute_tool(self, ctx: ExperimentContext, sample_id: str, tool: Tool, rationale: str) -> ToolResult:
        started = time.time()
        result = tool.execute(sample_id, {}, ctx)
        elapsed = round(time.time() - started, 3)
        ctx.append_trace({
            "sample_id": sample_id,
            "tool": tool.name,
            "rationale": rationale,
            "ok": result.ok,
            "elapsed_s": elapsed,
            "data": result.data if result.ok else result.error,
        })
        if result.ok:
            ctx.record_result(sample_id, tool.name, result.data)
        return result

opdlab/instruments/simulators.py

python 复制代码
# -*- coding: utf-8 -*-
"""
四台仪器的物理模拟器。

内部内置了可验证的物理规律(确定性趋势 + 可控噪声),
供 "Agent 自主编排 -> 多仪器数据联合 -> 规律发现" 整条链路端到端演示:
  - 轧制压下率 r 增大 -> 屈服强度/抗拉强度上升
  - 轧制压下率 r 增大 -> 延伸率下降(塑性牺牲)
  - 轧制压下率 r 增大 -> Brass 织构组分上升、晶粒细化(织构强化机制)
  - XRD 物相始终为 alpha-Al + 微量析出相,半高宽随变形略增
"""
from __future__ import annotations

import hashlib
import math
import random

def _rng_for(sample_id: str, salt: str) -> random.Random:
    """按 样品+用途 生成可复现的随机数生成器。"""
    seed = int(hashlib.sha256(f"{sample_id}:{salt}".encode("utf-8")).hexdigest()[:8], 16)
    return random.Random(seed)

# ---------------------------------------------------------------- 拉伸机
def simulate_tensile(sample_id: str, material: str, rolling_reduction: float, strain_rate: float = 0.001) -> dict:
    """单轴拉伸模拟:返回原始曲线点列 + 结构化力学指标。"""
    rng = _rng_for(sample_id, "tensile")
    r = rolling_reduction  # 0.2 ~ 0.6

    # 确定性物理趋势 + 少量噪声
    yield_strength = 300.0 + 400.0 * r + rng.uniform(-8, 8)
    tensile_strength = yield_strength * (1.15 + rng.uniform(-0.01, 0.01))
    elongation = 32.0 - 22.0 * r + rng.uniform(-1.5, 1.5)
    uniform_elongation = 0.62 * elongation + rng.uniform(-1, 1)
    elastic_modulus = 70.0  # GPa

    # 生成工程应力-应变曲线(弹性段 + 幂硬化段 + 颈缩段)
    curve = []
    e_elastic_max = yield_strength / (elastic_modulus * 1000.0)  # E 单位 GPa -> MPa 转换
    i = 0
    while True:
        strain = i * 0.001
        if strain <= e_elastic_max:
            stress = elastic_modulus * 1000.0 * strain
        elif strain <= uniform_elongation / 100.0:
            stress = tensile_strength * (1 - 0.35 * ((strain - e_elastic_max) / max(uniform_elongation / 100.0 - e_elastic_max + 1e-9, 1e-9)) ** 0.8)
        else:
            stress = tensile_strength * (1 - 0.85 * ((strain - uniform_elongation / 100.0) / max(elongation / 100.0 - uniform_elongation / 100.0 + 1e-9, 1e-9)) ** 0.5)
        stress = max(0.0, stress)
        curve.append({"strain": round(strain, 4), "stress_mpa": round(stress + rng.uniform(-2, 2), 2)})
        if strain >= elongation / 100.0:
            break
        i += 1

    return {
        "sample_id": sample_id,
        "material": material,
        "rolling_reduction": rolling_reduction,
        "strain_rate": strain_rate,
        "curve": curve,
        "summary": {
            "yield_strength_mpa": round(yield_strength, 1),
            "ultimate_tensile_strength_mpa": round(tensile_strength, 1),
            "elongation_pct": round(elongation, 2),
            "uniform_elongation_pct": round(uniform_elongation, 2),
            "elastic_modulus_gpa": elastic_modulus,
            "curve_quality": "good",
        },
    }

# ---------------------------------------------------------------- EBSD
def simulate_ebsd(sample_id: str, material: str, rolling_reduction: float) -> dict:
    """EBSD 取向扫描模拟:织构组分、晶粒尺寸、KAM。"""
    rng = _rng_for(sample_id, "ebsd")
    r = rolling_reduction
    n = 3  # 组分数目近似值(Brass / Copper / S / Goss / Cube)

    brass = min(0.60, 0.05 + 0.50 * r + rng.uniform(-0.02, 0.02))
    copper = max(0.02, 0.38 - 0.35 * r + rng.uniform(-0.02, 0.02))
    s_comp = min(0.45, 0.12 + 0.20 * r + rng.uniform(-0.02, 0.02))
    goss = max(0.005, 0.05 - 0.04 * r + rng.uniform(-0.005, 0.005))
    cube = max(0.002, 0.18 - 0.22 * r + rng.uniform(-0.01, 0.01))

    # 归一化
    total = brass + copper + s_comp + goss + cube
    brass, copper, s_comp, goss, cube = (v / total for v in (brass, copper, s_comp, goss, cube))

    avg_grain_um = max(3.0, 28.0 - 28.0 * r + rng.uniform(-1, 1))
    kam_mean = 0.40 + 1.6 * r + rng.uniform(-0.05, 0.05)
    indexing_rate = min(0.98, 0.90 + rng.uniform(0, 0.06))

    return {
        "sample_id": sample_id,
        "material": material,
        "rolling_reduction": rolling_reduction,
        "summary": {
            "indexing_rate": round(indexing_rate, 3),
            "texture_components": {
                "Brass": round(brass * 100, 1),
                "Copper": round(copper * 100, 1),
                "S": round(s_comp * 100, 1),
                "Goss": round(goss * 100, 1),
                "Cube": round(cube * 100, 1),
            },
            "average_grain_size_um": round(avg_grain_um, 2),
            "kam_mean_deg": round(kam_mean, 3),
            "grain_boundary_stats": {
                "low_angle_pct": round(min(85.0, 30.0 + 55.0 * r + rng.uniform(-3, 3)), 1),
                "high_angle_pct": None,  # 由 low_angle 补齐
            },
        },
    }

# ---------------------------------------------------------------- XRD
def simulate_xrd(sample_id: str, material: str, rolling_reduction: float) -> dict:
    """X 射线衍射模拟:物相、峰位、半高宽。"""
    rng = _rng_for(sample_id, "xrd")
    r = rolling_reduction

    # alpha-Al 特征峰 (Cu K-alpha)
    base_peaks = [
        {"hkl": "(111)", "two_theta": 38.47, "intensity": 100.0},
        {"hkl": "(200)", "two_theta": 44.74, "intensity": 46.0},
        {"hkl": "(220)", "two_theta": 65.13, "intensity": 24.0},
        {"hkl": "(311)", "two_theta": 78.23, "intensity": 20.0},
    ]
    peaks = []
    for p in base_peaks:
        fwhm = 0.10 + 0.08 * r + rng.uniform(-0.005, 0.005)  # 变形使峰展宽
        peaks.append({
            "hkl": p["hkl"],
            "two_theta_deg": round(p["two_theta"] + rng.uniform(-0.02, 0.02), 2),
            "intensity": round(p["intensity"] * rng.uniform(0.95, 1.05), 1),
            "fwhm_deg": round(fwhm, 3),
        })

    phases = [
        {"name": "alpha-Al", "fraction": round(96.5 + rng.uniform(-1, 1), 1), "confidence": 0.97},
        {"name": "AlFeSi (beta)", "fraction": round(3.5 - rng.uniform(0, 1), 1), "confidence": 0.72},
    ]

    preferred = "weak (111) fiber" if r < 0.35 else "strong (220) rolling texture"

    return {
        "sample_id": sample_id,
        "material": material,
        "rolling_reduction": rolling_reduction,
        "summary": {
            "phases": phases,
            "peaks": peaks,
            "preferred_orientation": preferred,
        },
    }

# ---------------------------------------------------------------- OPL 原位加载
def simulate_opl(sample_id: str, material: str, rolling_reduction: float, target_load_n: float = 3000.0) -> dict:
    """原位加载模拟:载荷-位移曲线 + 显微观察事件。"""
    rng = _rng_for(sample_id, "opl")
    r = rolling_reduction

    # 峰值载荷与屈服强度正相关
    peak_load = (800.0 + 900.0 * r) * (target_load_n / 3000.0)
    events = []
    if r >= 0.3:
        events.append({"strain_pct": round(3.5 + 3.0 * r, 2), "event": "slip_band_formation"})
    if r >= 0.45:
        events.append({"strain_pct": round(8.0 + 4.0 * r, 2), "event": "micro_crack_initiation"})

    curve = []
    n_points = 30
    for i in range(n_points):
        disp = (i + 1) * 0.05
        load = peak_load * (disp / (0.05 * n_points)) ** 0.85 + rng.uniform(-5, 5)
        curve.append({"displacement_mm": round(disp, 2), "load_n": round(max(0, load), 1)})

    return {
        "sample_id": sample_id,
        "material": material,
        "rolling_reduction": rolling_reduction,
        "summary": {
            "max_load_n": round(max(c["load_n"] for c in curve), 1),
            "max_displacement_mm": round(curve[-1]["displacement_mm"], 2),
            "events": events,
            "specimen_state": "fractured" if r >= 0.45 else "loaded",
        },
        "curve": curve,
    }

opdlab/tools/base.py

python 复制代码
# -*- coding: utf-8 -*-
"""Tool 抽象基类:定义仪器工具的通用契约(schema、依赖、执行)。"""
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional

TOOL_OK = "ok"
TOOL_ERROR = "error"

@dataclass
class ToolResult:
    """工具执行结果(统一错误结构)。"""
    ok: bool
    data: Dict[str, Any] = field(default_factory=dict)
    error: Optional[Dict[str, Any]] = None
    rationale: str = ""

    @classmethod
    def success(cls, data: Dict[str, Any], rationale: str = "") -> "ToolResult":
        return cls(ok=True, data=data, rationale=rationale)

    @classmethod
    def fail(cls, code: str, message: str, retryable: bool = True, suggestion: str = "") -> "ToolResult":
        return cls(ok=False, error={
            "code": code,
            "message": message,
            "retryable": retryable,
            "suggestion": suggestion,
        })

class Tool:
    """仪器工具基类。每台仪器一个子类,声明自己的 schema 与依赖。"""

    name: str = ""
    description: str = ""
    input_schema: Dict[str, Any] = {}
    output_schema: Dict[str, Any] = {}

    # 依赖:requires 指样品必须处于的状态;provides 指执行后样品进入的状态
    required_sample_state: Optional[str] = None   # 语义化依赖描述
    provided_sample_state: Optional[str] = None   # None = 不改变样品状态
    # 可执行状态白名单:None 表示任意状态可执行;否则仅当样品处于列表内状态时才进入可执行集
    valid_states: Optional[List[str]] = None

    # 调用处理器:execute(sample_id, args, context) -> ToolResult
    execute_fn: Optional[Callable[[str, Dict[str, Any], Any], "ToolResult"]] = None

    def execute(self, sample_id: str, args: Dict[str, Any], context: Any) -> ToolResult:
        if self.execute_fn is None:
            return ToolResult.fail("NOT_IMPLEMENTED", f"{self.name} 未实现执行逻辑", retryable=False)
        return self.execute_fn(sample_id, args, context)

    @property
    def schema(self) -> Dict[str, Any]:
        return {
            "name": self.name,
            "description": self.description,
            "input_schema": self.input_schema,
            "output_schema": self.output_schema,
            "dependencies": {
                "requires": self.required_sample_state,
                "provides": self.provided_sample_state,
            },
        }

opdlab/prompts/discovery_prompt.py

python 复制代码
# -*- coding: utf-8 -*-
"""
规律发现 Agent 的 Prompt 模板(与《规律发现 Agent Prompt 设计》文档配套)。

设计目标:让 Agent 在检测完成后直接输出科学规律,而不是复述数据。
关键机制:
  1. 显式禁止"数据复述"(只列数字不算发现);
  2. 规定推理协议:现象 -> 趋势/关联 -> 机理假设 -> 置信度;
  3. 输出 JSON schema 约束,保证可程序化消费。
"""
from __future__ import annotations

SYSTEM_PROMPT = (
    "你是材料科研实验室的规律发现 Agent。你负责把多台仪器"
    "(OPL 原位加载、拉伸机、EBSD、XRD)检测产生的结构化结果综合成科学规律。\n"
    "你的产出必须满足:\n"
    "1. 输出的是规律/结论,而不是数据复述------仅仅罗列数值不算完成;\n"
    "2. 每条规律必须包含:规律陈述 + 支撑证据(引用具体数据)+ 机理假设 + 置信度;\n"
    "3. 优先寻找跨变量的趋势(随某工艺参数单调变化)与跨仪器的关联(如强度与织构组分的相关性);\n"
    "4. 没有显著规律时如实说明,不得编造。\n"
    "只输出 JSON,不要输出其它解释。"
)

USER_TEMPLATE = (
    "实验目标:{goal}\n"
    "材料体系:{material}\n"
    "数据分析输入(跨样品、跨仪器的结构化结果):\n"
    "{structured_results}\n\n"
    "请按以下 JSON schema 输出:\n"
    '{{"laws": [{{"statement": "规律陈述", "evidence": ["证据1", "证据2"], '
    '"mechanism_hypothesis": "机理假设", "confidence": "high|medium|low", '
    '"direction": "increasing|decreasing|flat|n/a"}}], '
    '"data_summary": {{"samples": 数量, "note": "备注"}}}}'
)

def build_discovery_prompt(goal: str, material: str, structured_results: str) -> tuple[str, str]:
    """组装规律发现的 system/user prompt。"""
    return SYSTEM_PROMPT, USER_TEMPLATE.format(
        goal=goal, material=material, structured_results=structured_results
    )

opdlab/tools/tensile.py

python 复制代码
# -*- coding: utf-8 -*-
"""拉伸机工具封装。"""
from __future__ import annotations

from typing import Any, Dict

from ..instruments import simulators
from .base import Tool, ToolResult

class TensileTestTool(Tool):
    name = "tensile_test"
    description = "对 prepared 或 loaded 状态的试样执行单轴拉伸试验,返回应力-应变曲线与力学性能摘要(屈服强度、抗拉强度、延伸率、弹性模量)。拉伸后试样进入 characterized 可用状态,供 EBSD/XRD 使用。"
    input_schema = {
        "type": "object",
        "properties": {
            "strain_rate_1_per_s": {"type": "number", "default": 0.001},
            "stop_mode": {"type": "string", "enum": ["fracture", "target_strain"], "default": "fracture"},
        },
        "required": [],
    }
    output_schema = {
        "type": "object",
        "properties": {
            "run_id": {"type": "string"},
            "summary": {"type": "object"},
        },
    }
    required_sample_state = None  # prepared 或 loaded 均可接受
    provided_sample_state = "characterized"
    valid_states = ["prepared", "loaded"]

    def __init__(self):
        self.execute_fn = _tensile_impl

    def execute(self, sample_id: str, args: Dict[str, Any], context: Any) -> ToolResult:
        # 依赖:prepared 或 loaded 均可
        state = context.get_sample_state(sample_id)
        if state not in ("prepared", "loaded"):
            return ToolResult.fail(
                "SPECIMEN_INVALID",
                f"拉伸试验要求 prepared/loaded 状态,当前为 {state}",
                retryable=False,
                suggestion="先执行 sample_prep 或 opl_in_situ_loading",
            )
        return super().execute(sample_id, args, context)

def _tensile_impl(sample_id: str, args: Dict[str, Any], context: Any) -> ToolResult:
    meta = context.get_sample_meta(sample_id)
    strain_rate = float(args.get("strain_rate_1_per_s", 0.001))
    data = simulators.simulate_tensile(
        sample_id,
        material=meta["material"],
        rolling_reduction=meta["rolling_reduction"],
        strain_rate=strain_rate,
    )
    s = data["summary"]
    rationale = (f"拉伸完成:屈服强度 {s['yield_strength_mpa']} MPa,抗拉强度 "
                 f"{s['ultimate_tensile_strength_mpa']} MPa,延伸率 {s['elongation_pct']}%")
    return ToolResult.success(
        {"run_id": f"{sample_id}-tensile", "summary": s, "curve_points": len(data["curve"])},
        rationale,
    )

opdlab/tools/ebsd.py

python 复制代码
# -*- coding: utf-8 -*-
"""EBSD 工具封装。"""
from __future__ import annotations

from typing import Any, Dict

from ..instruments import simulators
from .base import Tool, ToolResult

class EbsdScanTool(Tool):
    name = "ebsd_scan"
    description = "对拉伸后的试样执行 EBSD 取向扫描,输出织构组分(Brass/Copper/S/Goss/Cube)、平均晶粒尺寸、KAM 与晶界统计,用于微观组织与织构分析。"
    input_schema = {
        "type": "object",
        "properties": {
            "region": {"type": "string", "enum": ["fracture_near", "gauge_center", "grip_end", "predefined"]},
            "step_size_um": {"type": "number", "default": 0.5},
        },
        "required": [],
    }
    output_schema = {
        "type": "object",
        "properties": {
            "run_id": {"type": "string"},
            "summary": {"type": "object"},
        },
    }
    required_sample_state = "characterized"
    provided_sample_state = None  # 不改变样品状态
    valid_states = ["characterized"]

    def __init__(self):
        self.execute_fn = _ebsd_impl

def _ebsd_impl(sample_id: str, args: Dict[str, Any], context: Any) -> ToolResult:
    meta = context.get_sample_meta(sample_id)
    data = simulators.simulate_ebsd(
        sample_id,
        material=meta["material"],
        rolling_reduction=meta["rolling_reduction"],
    )
    s = data["summary"]
    tex = s["texture_components"]
    rationale = (f"EBSD 扫描完成:标定率 {s['indexing_rate']:.0%},平均晶粒 {s['average_grain_size_um']} um,"
                 f"主织构 Brass {tex['Brass']}% / Copper {tex['Copper']}%")
    return ToolResult.success({"run_id": f"{sample_id}-ebsd", "summary": s}, rationale)

opdlab/tools/opl.py

python 复制代码
# -*- coding: utf-8 -*-
"""OPL 原位加载工具封装。"""
from __future__ import annotations

from typing import Any, Dict

from ..instruments import simulators
from .base import Tool, ToolResult

class OplInSituLoadingTool(Tool):
    name = "opl_in_situ_loading"
    description = "对 prepared 状态的试样执行原位加载试验,同步采集载荷-位移曲线与表面显微观察事件(滑移带、微裂纹等),用于研究变形与损伤演化。"
    input_schema = {
        "type": "object",
        "properties": {
            "loading_mode": {"type": "string", "enum": ["tensile", "cyclic", "constant_load"]},
            "target_load_n": {"type": "number", "description": "目标载荷(牛顿)"},
        },
        "required": ["loading_mode"],
    }
    output_schema = {
        "type": "object",
        "properties": {
            "run_id": {"type": "string"},
            "summary": {"type": "object"},
        },
    }
    required_sample_state = "prepared"
    provided_sample_state = "loaded"
    valid_states = ["prepared"]

    def __init__(self):
        self.execute_fn = _opl_impl

def _opl_impl(sample_id: str, args: Dict[str, Any], context: Any) -> ToolResult:
    meta = context.get_sample_meta(sample_id)
    loading_mode = args.get("loading_mode", "tensile")
    target_load_n = float(args.get("target_load_n", 3000.0))
    data = simulators.simulate_opl(
        sample_id,
        material=meta["material"],
        rolling_reduction=meta["rolling_reduction"],
        target_load_n=target_load_n,
    )
    summary = data["summary"]
    rationale = (f"OPL 原位加载({loading_mode})至 {summary['max_load_n']:.0f} N,"
                 f"记录 {len(summary['events'])} 个变形事件")
    return ToolResult.success({"run_id": f"{sample_id}-opl", "summary": summary}, rationale)

opdlab/tools/registry.py

python 复制代码
# -*- coding: utf-8 -*-
"""工具注册表:集中管理四台仪器 + 制样工具,供编排器查询。"""
from __future__ import annotations

from typing import Dict, List

from .base import Tool
from .opl import OplInSituLoadingTool
from .tensile import TensileTestTool
from .ebsd import EbsdScanTool
from .xrd import XrdMeasurementTool
from .sample_prep import SamplePrepTool

_REGISTRY: Dict[str, Tool] = {}

def register(tool: Tool) -> None:
    _REGISTRY[tool.name] = tool

def get_tool(name: str) -> Tool:
    if name not in _REGISTRY:
        raise KeyError(f"未知工具: {name}")
    return _REGISTRY[name]

def list_tools() -> List[Tool]:
    return list(_REGISTRY.values())

def available_tools_for(sample_state: str) -> List[Tool]:
    """返回在当前样品状态下可执行的工具集合(valid_states 满足)。"""
    return [
        t for t in _REGISTRY.values()
        if t.valid_states is None or sample_state in t.valid_states
    ]

def init_registry() -> None:
    if _REGISTRY:
        return
    for tool in [
        SamplePrepTool(),
        OplInSituLoadingTool(),
        TensileTestTool(),
        EbsdScanTool(),
        XrdMeasurementTool(),
    ]:
        register(tool)

opdlab/tools/sample_prep.py

python 复制代码
# -*- coding: utf-8 -*-
"""制样工具:把母试样加工成可用的试样/子样品(配套工具)。"""
from __future__ import annotations

from typing import Any, Dict

from ..instruments import simulators
from .base import Tool, ToolResult

class SamplePrepTool(Tool):
    name = "sample_prep"
    description = "将母材加工为拉伸/表征用试样,记录材料体系与工艺参数(如轧制压下率),返回样品标识与初始状态 prepared。"
    input_schema = {
        "type": "object",
        "properties": {
            "material": {"type": "string", "description": "材料体系,如 AA6061"},
            "rolling_reduction": {"type": "number", "description": "轧制压下率(0-1)"},
        },
        "required": ["material", "rolling_reduction"],
    }
    output_schema = {
        "type": "object",
        "properties": {
            "sample_id": {"type": "string"},
            "state": {"type": "string", "enum": ["prepared"]},
        },
    }
    required_sample_state = None
    provided_sample_state = "prepared"
    valid_states = ["raw"]

    def __init__(self):
        self.execute_fn = _sample_prep_impl

def _sample_prep_impl(sample_id: str, args: Dict[str, Any], context: Any) -> ToolResult:
    existing = context.get_sample_meta(sample_id)
    material = existing.get("material") or args.get("material", "AA6061")
    rolling_reduction = existing.get("rolling_reduction")
    if rolling_reduction is None:
        rolling_reduction = float(args.get("rolling_reduction", 0.2))
    context.set_sample_meta(sample_id, material=material, rolling_reduction=float(rolling_reduction))
    return ToolResult.success(
        {"sample_id": sample_id, "state": "prepared", "material": material, "rolling_reduction": float(rolling_reduction)},
        rationale=f"制样完成:{material},轧制压下率 {float(rolling_reduction):.0%}",
    )

opdlab/tools/xrd.py

python 复制代码
# -*- coding: utf-8 -*-
"""XRD 工具封装。"""
from __future__ import annotations

from typing import Any, Dict

from ..instruments import simulators
from .base import Tool, ToolResult

class XrdMeasurementTool(Tool):
    name = "xrd_measurement"
    description = "对试样执行 X 射线衍射测量,输出物相鉴定(alpha-Al、析出相)、衍射峰位与半高宽、择优取向,用于物相与织构佐证分析。"
    input_schema = {
        "type": "object",
        "properties": {
            "scan_mode": {"type": "string", "enum": ["theta_2theta", "pole_figure", "omega_scan"], "default": "theta_2theta"},
            "two_theta_start_deg": {"type": "number", "default": 30},
            "two_theta_end_deg": {"type": "number", "default": 110},
        },
        "required": [],
    }
    output_schema = {
        "type": "object",
        "properties": {
            "run_id": {"type": "string"},
            "summary": {"type": "object"},
        },
    }
    required_sample_state = "characterized"
    provided_sample_state = None  # 不改变样品状态
    valid_states = ["characterized"]

    def __init__(self):
        self.execute_fn = _xrd_impl

def _xrd_impl(sample_id: str, args: Dict[str, Any], context: Any) -> ToolResult:
    meta = context.get_sample_meta(sample_id)
    data = simulators.simulate_xrd(
        sample_id,
        material=meta["material"],
        rolling_reduction=meta["rolling_reduction"],
    )
    s = data["summary"]
    phases = ", ".join(f"{p['name']} {p['fraction']}%" for p in s["phases"])
    rationale = f"XRD 完成:物相 [{phases}],择优取向:{s['preferred_orientation']}"
    return ToolResult.success({"run_id": f"{sample_id}-xrd", "summary": s}, rationale)

tests/test_pipeline.py

python 复制代码
# -*- coding: utf-8 -*-
"""端到端冒烟测试:验证编排、调用链与规律发现闭环。"""
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from opdlab.agent.discovery import discover, pearson, trend_direction  # noqa: E402
from opdlab.agent.orchestrator import Orchestrator  # noqa: E402
from opdlab.agent.planner import plan_experiment, parse_target_responses  # noqa: E402
from opdlab.tools.registry import init_registry, list_tools  # noqa: E402

def test_tool_registry():
    init_registry()
    names = {t.name for t in list_tools()}
    assert {"sample_prep", "opl_in_situ_loading", "tensile_test", "ebsd_scan",
            "xrd_measurement"} <= names, names

def test_parse_targets():
    assert "mechanical" in parse_target_responses("研究力学性能和强度")
    assert {"texture", "phase"} <= parse_target_responses("研究织构与物相影响")

def test_end_to_end_chain_and_discovery():
    plan = plan_experiment("研究轧制压下率对铝合金力学性能、织构与物相的影响",
                           "AA6061", [0.2, 0.4, 0.6])
    ctx = Orchestrator(decision=None).run(plan, plan.goal_text)

    tools_used = {t["tool"] for t in ctx.trace}
    assert "sample_prep" in tools_used
    assert "tensile_test" in tools_used
    assert "ebsd_scan" in tools_used
    assert "xrd_measurement" in tools_used

    # 每个样品都应执行拉伸并更新状态
    for sid in ctx._states:
        assert ctx.get_sample_state(sid) == "characterized", sid

    # 规律发现:应当输出至少 3 条规律且不包含"数据复述"
    laws = discover(ctx)
    assert len(laws["laws"]) >= 3, laws
    statements = " ".join(l["statement"] for l in laws["laws"])
    assert "趋势" in statements or "上升" in statements or "正相关" in statements

def test_goal_targets_change_chain():
    """只测物相:调用链不应包含 EBSD(自主编排随目标变化)。"""
    plan = plan_experiment("研究物相组成", "AA6061", [0.2, 0.5])
    ctx = Orchestrator(decision=None).run(plan, plan.goal_text)
    tools_used = {t["tool"] for t in ctx.trace}
    assert "ebsd_scan" not in tools_used
    assert "xrd_measurement" in tools_used

def test_stats():
    assert abs(pearson([1, 2, 3], [2, 4, 6]) - 1.0) < 1e-9
    d, r = trend_direction([1, 2, 3], [6, 4, 2])
    assert d == "decreasing" and r > 0.8

以上为项目中全部 Python 代码文件。

相关推荐
xx_xxxxx_1 小时前
论文阅读-PASLE
人工智能·深度学习·机器学习
对讲机数码科普1 小时前
危化厂区防爆专网通信建设实践:从合规框架到验收清单
运维·网络·架构
释厄6231 小时前
01AB 基本元理——任何智能体的三元法理·0=1→0≠1法理跃迁为天理
人工智能·windows·算法·microsoft·机器学习
szxinmai主板定制专家1 小时前
工业视觉新思路:FPGA图像预处理+RK NPU推理,实现高速缺陷检测
人工智能·嵌入式硬件·fpga开发·rk3576+codesys
能源革命1 小时前
AI 日报 · 2026-10-02
人工智能
枯木◊靠推文躺平版1 小时前
2026 企业 AI 办公工具选型指南:如何匹配团队业务场景
人工智能
Web3&Basketball1 小时前
LlamaIndex+BGE 重排:RAG 从噪声到可信
人工智能·大模型·rag
MiYi124061 小时前
Vlog人像美颜工具怎么选
大数据·人工智能
硅基手札1 小时前
ROS2 全面解析 - 从架构到源码
单片机·架构·机器人