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 报告。
二、总体架构:五层分层与核心模块
整体技术方案给出五层架构:
- 应用层:实验目标输入、报告输出、评测展示;
- Agent 层:规划器、编排器、规律发现,决策引擎可为规则引擎或 LLM Function Calling;
- 工具层:仪器工具封装,包括 OPL、拉伸机、EBSD、XRD、制样;
- 仪器层:模拟器或真实仪器控制接口;
- 数据层:统一 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"。每步只做两件事:
- 从工具注册表计算当前样品状态下可执行工具集;
- 由决策引擎按实验目标,即目标响应缺口与依赖解锁关系,自主选择下一步工具。
代码中 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。
发现模块据此生成多条规律:
- 力学性能趋势:随轧制压下率增大,屈服强度与抗拉强度上升,延伸率下降,强度-塑性此消彼长,符合加工硬化主导的强化规律。
- 织构演变:Brass 织构组分上升、晶粒细化,说明形变织构增强、微观组织细化。
- 跨仪器关联:屈服强度与 Brass 织构组分正相关,与晶粒尺寸负相关,表明强度提升同时受织构强化与细晶强化贡献。
- XRD 佐证:主峰半高宽随压下率增大而展宽,物相组成不变,佐证微观应变与位错密度积累。
每条规律都包含五要素:statement、evidence、mechanism_hypothesis、confidence、direction,并可附带 correlation。这使输出不是数据清单,而是可验证、可证伪、带机理假设的科学结论。
六、规律发现 Prompt 设计与防复述机制
《规律发现 Agent Prompt 设计》进一步把"自主发现"协议化。System Prompt 固化四条约束:
- 输出规律/结论,而不是数据复述,仅仅罗列数值不算完成;
- 每条规律必须包含规律陈述、支撑证据、机理假设、置信度;
- 优先寻找跨变量趋势与跨仪器关联;
- 没有显著规律时如实说明,不得编造。
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 在材料实验中的两个核心能力:多仪器工具调用链自主编排,以及检测完成直接输出科学规律。
它的关键设计可以概括为三点:
- 工具封装统一 Schema:每台仪器变成可调用函数,输入输出结构化,依赖显式化;
- 编排目标驱动:依赖层固化物理约束,决策层按目标响应缺口与解锁关系自主选工具,因此换目标即换调用链;
- 规律输出协议化:规律必须包含陈述、证据、机理、置信度、方向,避免数据复述,使自主发现可评测、可校验、可扩展。
从"人操作仪器、人分析数据"到"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 代码文件。