· compliance· 约 23 分钟精读
端侧离线检测记录的仪器溯源工程:四个可选字段、三种日期写法与校准状态的能力边界
校准日期是现场台账里最容易被当成既定事实的一项注记。本文逐行核对 TagWorks 的四个溯源字段在录入、落盘、应用内回显与三条导出路径中的落点,说明为什么"记录了校准日期"并不等于"校准状态可判定",并与 SlingGuard 的检验依据字段做对照。
一、复审时最先被翻到的不是读数
一次事故调查或一次甲方复审,翻开的往往不是那份印着 PASS 的报告首页,而是某一台设备的记录明细。审查者要回答三个问题:这台设备当时是谁测的、用什么测的、那台仪器在当时是否处于校准有效期内。前两个问题在台账里都有字段,第三个问题通常只有一个日期。
端侧离线工具把这三个问题压进了同一条记录。记录里既有技术员姓名,也有仪器型号、序列号、校准日期与程序引用。字段都在,看起来证据链完整。但字段”在”与字段”能回答问题”是两件事:一条记录可以填了校准日期却无法判断该日期是否仍然有效,也可以完全不填而照样保存成合格。
本文以 TagWorks 的记录模型为样本,逐段核对这四个字段的落点与边界,并在最后给出五条不改变业务定位的收敛线。文中的字段名、函数名与判定顺序都可以在源码里对上。
二、四个字段在数据模型里的位置
TestRecord 里与仪器有关的字段有四个:
var testerModel: String? = nil
var testerSerial: String? = nil
var calibrationDate: Date? = nil
var procedureReference: String? = nil四个都是可选的,解码时使用 decodeIfPresent。数据模型的注释说明了原因:历史记录与纯目视流程可能没有仪器元数据,缺失必须被如实读出,而不是补一个默认值。这条注释是对的——把一个没填的字段解码成一个看似合理的默认值,等于在数据层伪造证据。
四个字段挂载的层级也值得注意:它们在记录上,不在资产上,也不在一台独立的仪器实体上。同一台仪器在半年内测了三十台设备,就会有三十份互不引用的副本。这带来一个直接后果:仪器层面的属性,例如校准周期、校准机构与证书编号,没有地方存放,只能在每条记录里重复填写,或者干脆不填。
三、录入面:默认值是设备级的,快照是记录级的
TestEntryView 在 onAppear 里做了一件事:从 UserDefaults 读回上一次保存的型号、序列号、程序引用与校准日期,把前三个直接填进输入框,第四个在存在时把开关置为打开。
testerModel = UserDefaults.standard.string(forKey: "testerModel") ?? ""
...
if let timestamp = UserDefaults.standard.object(forKey: "calibrationDate") as? Date {
calibrationDate = timestamp
hasCalibrationDate = true
}保存时再写回:
UserDefaults.standard.set(testerModel, forKey: "testerModel")
UserDefaults.standard.set(testerSerial, forKey: "testerSerial")
UserDefaults.standard.set(procedureReference, forKey: "procedureReference")
if hasCalibrationDate { UserDefaults.standard.set(calibrationDate, forKey: "calibrationDate") }这段代码有一个不对称,值得单独说:三个文本字段是无条件写回的,校准日期是有条件写回的。后果是,操作者把型号输入框清空并保存,设备级记忆会被清空;但把 Calibration date recorded 开关关掉并保存,存储里的旧日期仍然在,下一次进入录入页时,object(forKey:) 取到它,开关又被自动打开,旧日期回到界面上。
也就是说:关闭开关只影响当次保存的那条记录,不影响下一次的默认值。一条记录的 calibrationDate 是 nil,看起来是”本次没有记录校准日期”,但下一次的默认值仍然是上一次的日期。这个行为不是数据错误,是”关闭”这个动作在语义上只作用于当前会话,而使用者很可能把它理解为”清除”。
四、存在性判据:非空字符串不等于有内容
三个文本字段在保存时的处理是:
testerModel: testerModel.isEmpty ? nil : testerModel判据是 isEmpty,没有去空白。输入一个空格,字符串非空,字段被写进记录,PDF 证据行会把它画成一个空格,CSV 会把它导出成一个空格。这条记录在”字段是否存在”的意义上是完整的,在”字段是否有内容”的意义上是空的。
同仓库的起重侧工具在这一点上采用了另一种写法:InspectionRecord 对可选证据字段统一走 normalized(_:),先 trimmingCharacters(in: .whitespacesAndNewlines),再去掉空串。两种写法都合理,差别在于判据放在哪一层:放在保存入口,脏值进不了台账;放在读取或渲染出口,脏值留在台账里,每个读者都要自己再洗一遍。
| 维度 | TagWorks 的仪器字段 | SlingGuard 的证据字段 |
|---|---|---|
| 归一位置 | 保存入口只判 isEmpty | 初始化即 trimmingCharacters |
| 空格串 | 作为有效值写入 | 归一为空后置 nil |
| 影响面 | 导出物里出现看不见的字段 | 台账内不存在空字段 |
五、校准日期不是校准有效期
字段名是 calibrationDate,语义是”校准发生的日期”。审查者要判断的是”检测发生时,这台仪器是否仍在校准有效期内”。两者之间差一个量:校准周期。
当前的数据模型里没有这个量。全仓库没有校准周期的常量,没有校准到期日字段,也没有把 calibrationDate 与 record.date 放在一起比较的函数。Calibration date 在界面上是一个日期选择器,displayedComponents 只有 .date,没有上界,操作者可以选择一个未来的日期,也可以选择一个早于设备出厂日的日期,保存路径不会提出任何异议。
这就产生了一个语义空洞:字段名承诺的是”校准日期”,使用者读到的往往是”校准状态”。同一个日期,在两种读法下结论相反——按”上次校准于某日”读,它是一条历史事实;按”校准有效至某日”读,它是一条期限声明。报告的证据行只写 Cal 2 Sep 2026,没有说明是哪一种。
六、标准怎么说:周期是仪器与机构的属性
AS/NZS 3760:2022 对试验设备提出的要求是例行核验:用于试验的设备应按固定间隔接受核查,以保证工作正常、准确度得以维持[5]。这句话约束的是设备管理,并没有给出一个统一的月数。
再校准间隔怎么定,计量领域有专门的方法学。ILAC-G24:2022 与 OIML D 10 把间隔确定方法分成固定间隔法、阶梯法、控制图法、日历时间法与使用时间法,并列出了影响初始间隔的风险因素:所需测量不确定度、设备类型与漂移倾向、磨损与使用强度、环境条件、运输风险、人员训练程度与法定要求等[2]。结论很明确:不存在适用于所有仪器的标准周期,间隔由机构根据风险与历史数据确定,并定期复审。
ISO/IEC 17025:2017 把这件事放在设备与计量溯源两节:实验室应确保用于测量的设备满足所需准确度,其校准状态应可识别,测量结果应可溯源到国家或国际计量基准[1]。
三份文件指向同一个工程结论:如果应用要判断一条记录所用的仪器在当时是否处于校准有效期内,它至少需要三个量——校准实施日、校准周期或到期日、检测时刻。当前的数据模型只有第一个,而且它是操作者手填的。后两个量的缺失,使得在期与过期这个判断在端侧无法成立。
七、判定链为什么不读它
把三个判定入口的签名摆在一起,结论很直接:
| 判定入口 | 入参 | 是否读取仪器字段 |
|---|---|---|
TestThresholds.evaluate | 接地毫欧、绝缘兆欧、是否需要接地 | 否 |
TestRecord.evidenceState | 记录类型、落盘结论、目视结论、读数判定 | 否 |
TestIntervalPolicy.status | 设备属性、最近结论、证据完整性、当前时刻 | 否 |
三个入口没有一个接收仪器参数。这意味着一条记录可以在型号、序列号、校准日期全部为空的情况下走到 PASS;也可以带着一个五年前的校准日期走到 PASS。判定链只看读数与目视结论,这是产品定位决定的——应用不执行测量,也不验证仪器——但它同时决定了:校准状态在这套系统里是一个注记,不是一个判据。
这不是缺陷,是边界。缺陷在于边界没有在界面上被说清楚:录入页把四个字段放在 Tester provenance 这个标题下,视觉权重与 Measurements 同级,操作者很容易认为填了它结论会更可靠;实际上填与不填,判定结果一样。
八、保护带:读数落在限值附近时,结论依赖什么
TestThresholds 的判定是简单比较:接地读数大于 100 mΩ 判不合格,绝缘读数小于 1 MΩ 判不合格。这是简单接受,不做保护带,也不考虑测量不确定度。
计量领域对这件事有成熟的写法。ILAC-G8 把符合性判定分为二元判定与带保护带的判定,保护带宽度通常取扩展不确定度的倍数;JCGM 106 进一步给出基于不确定度的判定规则与风险定义[3][4]。若把仪器准确度量级取为 ±5%,接地限值 100 mΩ 对应 5 mΩ 的保护带:读数 96 mΩ 在简单接受下判合格,在带保护带的判定下落入不确定区,需要复测或降级结论。绝缘的 1 MΩ 限值同理,5% 对应 0.05 MΩ。
当前代码没有这个区间。它没有错——现场工具的定位是给出一个可复核的初判,而不是出具校准实验室的符合性声明——但它说明了一件事:当读数靠近限值时,结论的可靠性取决于仪器状态,而仪器状态恰好不在判定输入里。两个缺口叠在一起,就是本文要指出的边界。
九、同一条日期,三种写法
calibrationDate 是 Date,一个绝对时刻。它在三个出口被格式化成三种样子:
| 出口 | 编码器 | 形式 | 示例 |
|---|---|---|---|
| register.json | JSONEncoder() 默认策略 | 自 2001-01-01 起的秒数 | 809964000 |
| 登记册 CSV | yyyy-MM-dd,设备本地时区 | 日历日 | 2026-09-02 |
| 记录历史 CSV | ISO 8601,UTC,含毫秒 | 绝对时刻 | 2026-09-01T14:00:00.000Z |
示例取的是同一个时刻:设备在 UTC+10 时区选择 2026 年 9 月 2 日。登记册 CSV 写 2026-09-02,记录历史 CSV 写 2026-09-01T14:00:00.000Z。两个文件描述同一件事,日历日相差一天。审计方把两份 CSV 并排打开,会看到两个不同的日期,而没有任何一列说明它们来自同一个字段。
ISO 8601 对日期与时刻的表示本来就分开定义[6],代码把它们合并成一个类型,问题就转移到了格式化环节。这不是格式偏好问题。日期型事实,也就是”哪一天校准的”,被存成绝对时刻,也就是”哪一毫秒”,再在不同时区、不同精度下还原,必然出现这类偏差。工程上的解法是把日期与时刻分成两种类型:日期只存年月日与历法,时刻才存绝对瞬间;导出时按同一时区、同一精度渲染。
十、报告里那一行
四个字段里,只有两个能在应用内回看。资产详情的历史行会拼一行 Tester: 型号 · 序列号,而校准日期与程序引用在应用界面上没有任何位置:保存之后,操作者想在手机上核对自己刚才填的日期是否正确,做不到,只能导出一份报告。
PDF 的证据行则是另一套拼法:
record.testerModel.map { "Tester \($0)" },
record.testerSerial.map { "Serial \($0)" },
record.calibrationDate.map { "Cal \(fmtDate($0))" },
record.procedureReference.map { "Ref \($0)" },四个 map 加 compactMap,缺失的字段直接不出现,不留下占位符。于是一行证据可能包含型号、序列号、校准日期与引用,也可能只剩下读数与目视结论。读者无法从”没有 Cal 字段”区分三种情况:这台仪器不需要校准、操作者没填、这项数据在导出时被裁掉了。
缺失即沉默,是这一行最需要被指出的设计。它和照片计数那一项形成了对照:照片为空时仍然写 Photos: 0,因为”零张”是一个有意义的事实;校准日期为空时什么都不写,因为代码把它当成”没有这个属性”。
十一、吊装侧的对照
SlingGuard 的检验记录里没有仪器型号、序列号与校准日期。它有两个相邻的字段:certificationNumber,也就是胜任人员证书或执照编号,来自设备级 profile;以及 examinationSchemeReference,也就是检验依据或程序引用。前者是人的资质,后者是判定所依据的方案。
这个差异来自场景:吊装彻底检验的核心证据是目视清单与检验依据,仪器只在涉及载荷测量时出现;电气检测的核心证据是读数,读数必须来自一台可溯源的仪器。两个产品把溯源落在了不同的对象上——一个落在人,一个落在仪器。
但它们有一个共同点:溯源字段都不进入合格判定。SlingGuard 的 reportingGaps 会检查检验依据是否缺失,缺失则证据不完整;它不检查检验人证书是否在有效期内。TagWorks 的 calibrationDate 连”是否缺失”都不进入判定。两者都把记录完整性与结论正确性分开了,这个分离是对的,只是分离之后,校准状态该由谁来判断,产品没有回答。
十二、五条收敛线
第一条,字段语义分离。把 calibrationDate 拆成 calibratedOn 与 recalibrationDue,或者保留前者并增加 calibrationIntervalMonths。只有日期没有周期,判断无法闭合;只有周期没有日期,无法定位当前状态。两者都有,才能把在期与过期做成一个纯函数。
第二条,写入语义对齐。文本字段与日期开关应采用同一种清除语义:要么都无条件写回,空值即清除;要么都只在有值时写回,并在界面上提供显式的清除动作。当前的不对称会让”关掉开关”看起来像清除,实际只是本条记录不携带。
第三条,存在性判据前移。在保存入口统一做去空白与空串归一,让 isEmpty 的判断对象是”有内容的字符串”,而不是”长度大于零的字符串”。这条改动的成本很低,收益是导出的每一行都不会出现看不见的空格字段。
第四条,日期与时刻分型。校准日期用日历日类型,只保留年月日;检测时刻用绝对时刻类型;导出时统一时区与精度,并在列名上写清楚是日期还是时刻。当前三种写法并存,至少有一处在跨时区时会给出不同的日历日。
第五条,把校准状态接进证据层,但不接进读数判定。新增一个独立的状态维度,取值为完整、不完整、超出校准期,让它出现在报告证据行与 CSV 列里,而不改变 TestThresholds 的合格判定。这样既不越权替计量实验室下结论,也不让读者误以为一个日期就是一份校准状态。
十三、能力边界
应用无法验证校准证书的真伪,无法离线确认出具证书的实验室是否具备资质,无法知道仪器在校准之后是否被摔过、是否换了探头,也无法阻止操作者把上一次的日期原样保留。这些属于计量体系与现场管理,不属于一部手机。
还有一层是时间。校准日期由操作者选择,检测时刻取自设备墙钟;设备时间被调整过,两者的先后关系就跟着变。端侧离线工具没有可信外部授时,这一点在软件层没有解法,只能在使用流程里约定。
最后是解释权。报告封面已经写明:应用不执行检测、不验证仪器、不判定合规。这句话不是免责套话,而是对数据模型的准确描述——四个字段承载的是操作者的陈述,判定链从头到尾没有读过它们。把这条边界写进产品,比在字段名上暗示一个它并不具备的能力要诚实。
参考文献
[1] ISO/IEC 17025:2017. General requirements for the competence of testing and calibration laboratories. https://www.iso.org/standard/66912.html [2] ILAC. ILAC-G24:2022 / OIML D 10:2022 — Guidelines for the determination of recalibration intervals of measuring equipment. https://ilac.org/publications-and-resources/ilac-guidance-series/ [3] ILAC. ILAC-G8:09/2019 — Guidelines on decision rules and statements of conformity. https://ilac.org/publications-and-resources/ilac-guidance-series/ [4] JCGM. JCGM 106:2012 — The role of measurement uncertainty in conformity assessment. https://www.bipm.org/en/committees/jc/jcgm/publications [5] Standards Australia. AS/NZS 3760:2022 — In-service safety inspection and testing of electrical equipment and RCDs. https://store.standards.org.au/product/as-nzs-3760-2022 [6] ISO. ISO 8601-1:2019 — Date and time — Representations for information interchange — Part 1: Basic rules. https://www.iso.org/standard/70907.html [7] Apple Developer Documentation. JSONEncoder.DateEncodingStrategy. https://developer.apple.com/documentation/foundation/jsonencoder/dateencodingstrategy [8] Apple Developer Documentation. DatePicker. https://developer.apple.com/documentation/swiftui/datepicker