☰
WITSML代码包实战:XSD校验、SOAP接口与增量同步
2026/10/24 4:21:33 网站建设 项目流程

简介:面向C#开发者与石油天然气行业数据集成人员的WITSML实践代码包,围绕井数据交换标准展开,可用于理解井筒数据模型、简单对象访问协议及可扩展标记语言序列化在项目中的落地方式。压缩包共52个文件、约74KB,以9个C#源码为核心,配合6个配置、2个解决方案、3个可执行程序与3个动态库,以及服务描述和接口文件,覆盖客户端调用、网络服务端和序列化类库等模块,便于梳理服务代理、端点绑定与数据传输流程。已有855人学习下载,说明该主题在井筒数据共享与系统对接场景中具有稳定需求。代码中可参考井、钻柱等实体的类表示,可扩展标记语言序列化与反序列化示例,以及简单对象访问协议服务代理的创建方式,适合需要快速建立该标准集成认知、对照调试接口与补充项目结构思路的中级C#开发者。

1. 拿到 WITSML 代码包先别急着找 API:它其实是三件套拼起来的

上周有个做井场数据采集的朋友,把一份日报 XML 拖进编辑器,标签一眼都认识,改完往服务端一提交,返回里suppMsgOut写着 success,结果库里一条没进。这类翻车在 WITSML 相关代码包里几乎天天发生:它看着像一套 XML 模板,实际是三件东西叠在一起——一组用 XSD 钉死的对象模型、一套 SOAP 风格的服务接口、外加一门“填了值就当过滤条件”的查询模板语法。资源里的 schema 目录、示例报文和调用样例正好对应这三层。做钻井日报入库、测井曲线对接、井眼轨迹同步的工程师拿到它,最大的价值不是多一份样例,而是能把校验、构造、调用这条链在本机跑通一遍,再去连真实服务端。适合已经会读写 XML、但还没被uidWell链和单位属性折磨过的人。

2. 数据对象与 XSD 校验:把 well/wellbore/log 的父子关系钉死

WITSML 的数据不是平铺的表格,而是一棵树。理解这棵树的引用方式,比背字段名重要得多。1.4.1 这一代用的是“逐级 uid 引用”:顶层对象只有自己的uid,往下每挂一层就多带一个祖先的 uid,比如 log 必须同时写uidWell、uidWellbore、uid。2.0 那一代换成了全局uuid加链接关系,对象名首字母大写,还多了 JSON 表达。代码包里如果两种版本的样例混在一起,第一件事是按目录分开,别在同一个校验脚本里跑。

2.1 对象模型:四个层级与引用链

先把常见对象和它们的挂载位置列清楚,后面写查询模板时就是照着这张表填 uid。

对象挂载层级关键字段典型用途
well顶层name、country、timeZone井主数据,uid 全局唯一
wellborewell 下uidWell、name、statusWellbore井筒、侧钻分支
logwellbore 下indexType、indexCurve、logCurveInfo、logData测井、随钻、录井曲线
trajectorywellbore 下trajectoryStation(md/incl/azi/tvd)井眼轨迹
tubularwellbore 下tubularComponent管柱结构、钻具组合
messagewell 或 wellbore 下messageText、dTim井场事件、日报条目
reportwell 或 wellbore 下日报表单字段日报数据

注意uid是服务端内唯一的字符串,不是自增数字。很多团队图省事用1、2当 uid,第一次合并两家服务商的数据就撞车,血泪经验:uid 里带上来源前缀,比如SRC-W-0001,后期做数据融合会轻松很多。

2.2 用 XSD 先把样例报文验一遍

schema 目录是这份资源里最不该被跳过的东西。它决定了你拼出来的报文到底合不合法,而服务端的报错往往只说“invalid query”,不会告诉你哪一行错了。本机先验一遍,能省掉大半天来回。常见做法是用 lxml 把每个 xsd 编译成XMLSchema对象,再对样例逐个校验。

from pathlib import Path from lxml import etree SCHEMA_DIR = Path("witsml/schemas") # schema 目录,1.4.1 与 2.0 建议分开放 DOC_DIR = Path("witsml/samples") # 待校验的样例报文目录 # 1) 预先编译所有 schema,避免每个文件重复解析 schemas = {} for xsd in SCHEMA_DIR.glob("*.xsd"): try: schemas[xsd.name] = etree.XMLSchema(etree.parse(str(xsd))) except etree.XMLSchemaParseError as e: print(f"[SCHEMA-ERR] {xsd.name}: {e}") # 2) 逐个校验样例,按根节点名匹配对象类型 for doc in sorted(DOC_DIR.glob("*.xml")): tree = etree.parse(str(doc)) root = tree.getroot() obj = root.tag.split("}")[-1] # 命名空间被剥掉,得到 logs / wells 这类复数根 if not obj.endswith("s"): print(f"[SKIP] {doc.name}: 根节点 {obj} 不是集合类型") continue cand = [n for n in schemas if obj[:-1] in n] # obj_log.xsd 这类命名 if not cand: print(f"[SKIP] {doc.name}: 未找到对应 schema") continue schema = schemas[cand[0]] if schema.validate(tree): print(f"[OK] {doc.name}") else: print(f"[FAIL] {doc.name}") for err in schema.error_log: print(f" line {err.line}: {err.message}")

代码逻辑:先用XMLSchema(etree.parse(...))预编译,因为 xsd 之间会互相 import,编译一次复用比每次现编快一个量级;obj[:-1]把logs削成log再去匹配文件名;error_log里的line是相对样例文件的行号,直接跳过去看就行。

参数说明:SCHEMA_DIR要放原始 xsd 全集,缺一个 import 就会在编译阶段抛XMLSchemaParseError,这时候看报错里的文件名就知道缺哪个;如果你的代码包把 1.3.1 和 1.4.1 混在同一目录,cand可能匹配到错版本,建议按版本号建子目录。校验通过不代表服务端一定接受,但校验不通过的一定会被拒。

2.3 校验失败时先看哪三行

报错信息量经常很大,按优先级看三处就够了。第一看行号附近的元素名,是不是多了一层不该有的包裹节点;第二看uom属性,1.4.1 里数值型元素基本都要求带单位,缺了直接判不合法;第三看日期格式,dTim这类字段要求带时区的 ISO8601,写成2024-05-01 08:00:00一定过不去,得写成2024-05-01T08:00:00Z或带+08:00偏移。

还有一种不报错但很坑的情况:样例文件里带了xsi:schemaLocation指向某个在线地址,lxml 离线时会尝试拉取然后失败。解决办法是校验前把schemaLocation属性删掉,或者用etree.XMLParser(no_network=True)强制离线。我一般会写个小工具函数,pop掉这个属性再编译,省得每次都手动改。

3. 报文构造与解析:用 Python 拼一份能过校验的 log 对象

校验脚本跑通之后,第二步是反过来——从数据库里取数,拼成合法报文。这一步最容易出问题的地方不在数值,而在结构顺序:logCurveInfo的排列顺序和logData/data里每一列的排列顺序必须严格一致,错一列,整条曲线就串了,而且服务端不会报错。

3.1 indexType 决定 log 对象的骨架

log 的索引类型只有两类思路:按深度(measured depth、true vertical depth)和按时间(date time)。索引类型一换,startIndex/endIndex的语义、单位、以及下游怎么画图全变。

按深度索引的曲线,startIndex和endIndex是深度值,单位通常是m或ft;按时间索引的曲线,这两个字段是时间戳,单位留空或写date time。indexCurve字段填索引曲线自己的助记符,比如DEPTH,这个助记符必须同时出现在logCurveInfo的第一条里。曾经见过有人把indexCurve写成MD、把logCurveInfo里写成DEPTH,校验通过、入库成功,但下游软件一律读不出来——典型的黑匣子问题。

3.2 用 ElementTree 拼一份能过校验的 log

下面的写法用 lxml 的nsmap把默认命名空间挂在根节点上,子元素统一走q()拼标签,避免手写命名空间前缀写到崩溃。

from pathlib import Path from lxml import etree NS = "http://www.witsml.org/schemas/1series" VERSION = "1.4.1.1" # 与 schema 版本对应,别乱填 def q(tag: str) -> str: return f"{{{NS}}}{tag}" # 根节点挂默认命名空间,子元素会自动继承 logs = etree.Element(q("logs"), nsmap={None: NS}, version=VERSION) log = etree.SubElement(logs, q("log")) log.set("uidWell", "SRC-W-0001") log.set("uidWellbore", "SRC-WB-0001") log.set("uid", "SRC-LOG-0001") def sub(parent, tag, text=None, **attrs): el = etree.SubElement(parent, q(tag)) for k, v in attrs.items(): el.set(k, v) if text is not None: el.text = str(text) return el sub(log, "nameWell", "示例井-1") sub(log, "nameWellbore", "示例井筒-1") sub(log, "name", "随钻曲线-深度索引") sub(log, "indexType", "measured depth") sub(log, "startIndex", "1000.0", uom="m") sub(log, "endIndex", "1002.0", uom="m") sub(log, "indexCurve", "DEPTH") # 必须与第一条 logCurveInfo 的助记符一致 CURVES = [("DEPTH", "m"), ("GR", "gAPI"), ("RHOB", "g/cm3")] for mnem, unit in CURVES: lci = etree.SubElement(log, q("logCurveInfo"), uid=f"LCI-{mnem}") sub(lci, "mnemonic", mnem) sub(lci, "unit", unit) rows = [(1000.0, 45.2, 2.31), (1001.0, 46.7, 2.29), (1002.0, 47.1, 2.28)] data = etree.SubElement(etree.SubElement(log, q("logData")), q("data")) data.text = "\n".join(",".join(f"{v:.3f}" for v in r) for r in rows) Path("out").mkdir(exist_ok=True) Path("out/log_001.xml").write_bytes( etree.tostring(logs, pretty_print=True, xml_declaration=True, encoding="UTF-8") )

逻辑说明:nsmap={None: NS}让根节点用默认命名空间,序列化出来就是干净的<logs xmlns="...">,不会带ns0:前缀。log元素的三个 uid 属性是引用链,缺一个服务端就无法定位。曲线元数据先写,logData最后写,顺序和 schema 里的元素顺序一致。

参数说明:version属性填的是完整的 schema 版本号,1.4.1 系列常见写法是1.4.1.1,和你SCHEMA_DIR里的 xsd 对上就行;uom用行业通用单位符号,gAPI、g/cm3、m这些都别自己造;data.text里逗号分隔,不要空格,不要结尾换行,某些服务端解析结尾空行会多出一列 NaN。

拼完之后务必回到第 2 章的脚本再跑一次校验,把“构造”和“校验”串成一个流程,比事后到服务端排错快得多。

3.3 把返回的 Result 解析成行列表

WMLS_GetFromStore的返回里,真正的数据在Result元素里,而且是一段被转义过的 XML 字符串,需要二次解析。直接拿正则抠标签是最容易埋雷的做法。

from lxml import etree def parse_logs_result(result_xml: str): """把 WMLS_GetFromStore 的 Result 字段解析成结构化行数据""" root = etree.fromstring(result_xml.encode("utf-8")) ns = {"w": "http://www.witsml.org/schemas/1series"} out = [] for log in root.findall(".//w:log", ns): mnems = [e.text for e in log.findall(".//w:logCurveInfo/w:mnemonic", ns)] units = [e.text for e in log.findall(".//w:logCurveInfo/w:unit", ns)] rows = [] for block in log.findall(".//w:logData/w:data", ns): for line in (block.text or "").strip().splitlines(): vals = [v.strip() for v in line.split(",")] if len(vals) != len(mnems): # 列数对不上就是丢列了,宁可抛错也别静默塞 None raise ValueError(f"列数不匹配: {len(vals)} vs {len(mnems)}") rows.append(dict(zip(mnems, vals))) out.append({"uid": log.get("uid"), "units": dict(zip(mnems, units)), "mnemonics": mnems, "rows": rows}) return out

逻辑说明:findall(".//w:log")能一次拿到多个对象,因为一个返回包里可能有多口井的曲线;先取助记符列表再逐行zip,保证字典的键和单位一一对应。列数校验是关键,服务端返回带缺失值的行时容易出现空列,静默处理会让后续的单位换算全部错位。

参数说明:命名空间必须和请求的版本一致,查 1.4.1 就用1series,查 2.0 要换成对应的 2.0 命名空间,w:前缀只是本地别名,写什么都行但xmlns必须对。

4. SOAP 接口对接:WMLS_GetFromStore 的请求模板与 optionsIn 调参

接口这一层,WITSML 1.4.1 走的是 SOAP over HTTP,所有操作都是 POST 到同一个端点,靠SOAPAction头区分动作。认证常见做法是 HTTP Basic Auth,少数服务端支持把凭证放进专门的头部元素,先问清楚再动手。

4.1 SOAP 信封与 SOAPAction 的对应关系

请求体结构很固定:一个信封包一个 Body,Body 里是WMLS_GetFromStore这类动作元素,里面几个必填子元素——WMLtypeIn填对象类型(log、well、trajectory),QueryIn放模板 XML 字符串,OptionsIn放逗号分隔的选项,CapabilitiesIn一般留空。

SOAPAction和命名空间里的版本号要一致。1.4.1 系常见是http://www.witsml.org/action/140/Store.WMLS_GetFromStore,1.3.1 系是120。这两个数字对不上,服务端会回一个看起来像 500 但其实什么都没干的响应,非常容易误判成数据不存在。

4.2 一个能直接跑通的调用

import requests from lxml import etree from requests.auth import HTTPBasicAuth ENDPOINT = "https://witsml.example.com/witsml/store" # 以服务端 GetCap 给出的地址为准 AUTH = HTTPBasicAuth("user", "pass") SOAP_ACTION = "http://www.witsml.org/action/140/Store.WMLS_GetFromStore" def build_envelope(query_xml: str, options: str, wml_type: str = "log") -> str: # QueryIn 是字符串类型,把模板 XML 转义后放进去,别直接嵌原始标签 esc = (query_xml.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")) return f"""<?xml version="1.0" encoding="UTF-8"?> <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"> <soapenv:Body> <WMLS_GetFromStore xmlns="http://www.witsml.org/wsdl/140/witsml"> <WMLtypeIn>{wml_type}</WMLtypeIn> <QueryIn>{esc}</QueryIn> <OptionsIn>{options}</OptionsIn> <CapabilitiesIn/> </WMLS_GetFromStore> </soapenv:Body> </soapenv:Envelope>""" QUERY = """<logs xmlns="http://www.witsml.org/schemas/1series" version="1.4.1.1"> <log uidWell="SRC-W-0001" uidWellbore="SRC-WB-0001" uid="SRC-LOG-0001"/> </logs>""" body = build_envelope(QUERY, "returnElements=all") resp = requests.post(ENDPOINT, data=body.encode("utf-8"), headers={"Content-Type": "text/xml;charset=UTF-8", "SOAPAction": SOAP_ACTION}, auth=AUTH, timeout=(5, 120)) resp.raise_for_status() env = etree.fromstring(resp.content) ns = {"s": "http://schemas.xmlsoap.org/soap/envelope/", "w": "http://www.witsml.org/wsdl/140/witsml"} body_el = env.find(".//s:Body", ns) fault = body_el.find("s:Fault", ns) if fault is not None: raise RuntimeError(f"服务端 Fault: {fault.findtext('faultstring')}") node = body_el.find(".//w:WMLS_GetFromStoreResponse", ns) result = node.findtext("Result", default="", namespaces=ns) supp = node.findtext("SuppMsgOut", default="", namespaces=ns) print("补充信息:", supp) # 1 表示有数据,0 或负数通常意味着没命中

逻辑说明:先手工做 XML 转义再塞进QueryIn,比用 CDATA 稳妥,因为部分服务端的解析器对 CDATA 处理不一致。响应要先查Fault,有些实现会用 HTTP 200 返回一个 Fault 体,raise_for_status()拦不住。Result拿到字符串后再交给第 3 章的解析函数。

参数说明:timeout拆成连接和读取两段,井数多的时候读取超时给到 120 秒不算多;WMLtypeIn必须和模板根节点对应,查logs就填单数log;SuppMsgOut是服务端的补充返回码,出现异常值时优先看它而不是看数据量。

4.3 optionsIn 四个常用参数的取舍

OptionsIn是调优的主战场,全部塞returnElements=all在对象少的时候没事,井一多就等着超时。

参数常用取值实际影响
returnElementsall / id-only / requested决定返回全部字段、仅标识、还是模板里显式声明的字段
maxReturnNodes整数,如 200单次返回对象数上限,超了会截断,必须配合分页
requestObjectSelectionCapabilitytrue探测服务端支持哪些对象,对接新服务端时先跑一次
returnHeadertrue / false是否需要返回对象头信息,做增量同步时有用

实践里的组合是:第一次接触某台服务端,先用returnElements=id-only拉一遍 uid 列表,确认连通性和数据规模;写业务逻辑时改成requested,只在模板里声明真正要的字段,能显著压缩响应体;批量导出才用all,并且务必带上maxReturnNodes。id-only返回的字段很少,别指望它带出曲线数据,这是新手最容易误解的一档。

5. 避坑与排查:五个让报文静默失败的坑

这些坑的共同点是服务端不报错、返回码正常、数据却没有。排查顺序建议从报文层到接口层,逐层缩小。

5.1 报文层面的三个静默失败

现象一:入库成功但下游读不出曲线。原因:indexCurve的值和第一条logCurveInfo的mnemonic不一致。解决:写一个断言,构造完报文后立刻校验二者相等,把检查卡在发送之前,别等下游反馈。

现象二:校验通过,服务端回 invalid query。原因:命名空间版本和version属性不匹配,比如模板里写 1.4.1 的命名空间却标了version="1.3.1"。解决:把命名空间、version属性、SOAPAction里的版本号三者做成同一个常量,从配置读,不要三处手写。

现象三:时间字段全被拒。原因:dTim不带时区。解决:统一用 UTC 的Z后缀输出,服务端再按timeZone字段做本地化展示。我一般会在数据出口加一个格式化函数,任何时间字段都强制走它,避免有人顺手str(datetime.now())。

5.2 接口层面的两个超时与认证坑

现象四:响应 200、body 里是 Fault,日志里只记了状态码。原因:把 HTTP 状态码当成业务结果。解决:封装一个统一的响应处理函数,先解析 Fault,再解析SuppMsgOut,最后才看Result,三层都过了才算成功。

现象五:单井数据能拉,整批就超时或截断。原因:没设maxReturnNodes,一次请求把整库对象都往回调。解决:按maxReturnNodes加 uid 游标分页,每页拉完立即落盘再做下一批;同时把读取超时调大,别用默认的几秒。

还有一条容易被忽略:不同服务端对QueryIn里“填了值就当过滤条件”的支持程度不一样。同一个模板,A 服务端返回精确命中的一条,B 服务端返回全表。对接新服务端时先跑requestObjectSelectionCapability=true摸清能力,再决定是客户端过滤还是服务端过滤。

6. 增量同步与曲线对齐:把 dTimLastChange 用起来的两个技巧

全量拉取在开发阶段够用,上了生产就得换增量。WITSML 的多数可更新对象都带一个由服务端维护的dTimLastChange,它是做增量最省事的抓手。

第一种做法是服务端过滤:在查询模板的对象里把commonData/dTimLastChange填上一个起始时间,语义就是“只要这个时间之后变更过的”。注意这条依赖服务端实现,先在测试环境用已知的修改记录验证一次,别直接上线。

QUERY_INCR = """<logs xmlns="http://www.witsml.org/schemas/1series" version="1.4.1.1"> <log uidWell="SRC-W-0001" uidWellbore="SRC-WB-0001"> <commonData><dTimLastChange>2024-05-01T00:00:00Z</dTimLastChange></commonData> </log> </logs>"""

第二种做法是客户端比对:先用returnElements=id-only拉全量 uid 和变更时间,本地做 diff,只对变化的 uid 发详细查询。这种方式对服务端实现依赖小,代价是每次多一次轻量请求。井数上千的场景,我一般选第二种,把 diff 结果落一张本地表,失败重试也好做。

曲线对齐是另一个高频问题。两条曲线来自不同服务商,索引单位一个是m一个是ft,或者采样点密度的差异导致同一深度上有多个值。落地检查就两条:一是统一索引单位后再合并,别在原始单位上做插值;二是同一索引值出现多条记录时,按dTimLastChange取最新,而不是简单去重取第一条。单位换算写成显式函数并记录换算系数到日志里,出问题时能一眼看出是哪一步引入的偏差。

还有个实用技巧:把每次请求的WMLtypeIn、OptionsIn、SuppMsgOut、返回对象数打成一行结构化日志,出问题时不用复现,翻日志就知道当时发了什么。从那以后我每次接新服务端,第一件事都是先跑一遍版本查询和能力查询,把版本号和对象支持清单打进日志,再动手写业务查询——这条习惯帮我省掉过好几次“明明昨天还能跑”的深夜排查。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询