DLMS/COSEM 蓝皮书解读(七):Profile generic 类(class_id = 7)—— 表计里的"一张表"
系列说明:本系列基于 DLMS UA《Blue Book(蓝皮书)第 16 版 · 第 2 部分》,一个接口类一篇。前面六篇讲的都是"存一个数"的类。本篇的
Profile generic存的是一整张表—— 负荷曲线、事件日志、冻结数据全靠它。它是整套 COSEM 里最实用、也最容易用错的类之一。
上篇回顾:
Register activation管"哪些寄存器当前生效"。而表计每隔 15 分钟把当时的电量存一条、存上几千条 —— 这种"历史序列"的存储与读取,就是Profile generic。
版本说明:这个类在蓝皮书里有version 1(现行)与version 0(Ed.1 遗留,自 Edition 2 起被替换)两个版本。按本系列"一个 class 一篇"的原则,两者合并在本篇,第 7 节专门对比。
0. 从"一个数"到"一张表"
前面所有类,读出来都是一个值:
Register → 1234,5 Wh(现在是多少) Extended register → 1234,5 Wh(某时刻是多少) Demand register → 6 kW(过去 15 分钟平均是多少)但真实业务要的是序列:
- 负荷曲线(load profile):过去 90 天,每 15 分钟一条"时刻 + 电量";
- 事件日志:过去 200 条"什么时候、发生了什么";
- 日冻结:每天 0 点冻结一次,存 365 条。
如果每条曲线都建几千个Register对象,对象模型会爆炸。所以 COSEM 设计了Profile generic:
蓝皮书原文(Profile generic, Overview):
“This IC provides a generalized concept allowing to store, sort and access data groups or data series, calledcapture objects. Capture objects are appropriate attributes or elements of (an) attribute(s) of COSEM objects. The capture objects are collected periodically or occasionally.”
它的思路是**“先定义列,再逐行采集”**:
capture_objects—— 定义"这张表有哪些列"(每列指向某个对象的某个属性);buffer—— 存放采集到的行;capture_period—— 多久自动采一行(0 = 不自动,靠外部触发);sort_method/sort_object—— 行按什么顺序排(时间?数值大小?)。
一句话定位:
Profile generic=表计内部的一张二维表:列由capture_objects定义,行由capture()或自动采集产生,读取时用"选择性访问"切一块下来。
1. 类蓝图(version 1,现行)
Profile generic 0...n class_id = 7, version = 1| 属性 | 静态/动态 | 数据类型 | Min | Max | Def | Short name |
|---|---|---|---|---|---|---|
logical_name | (static) | octet-string | x | |||
buffer | (dyn.) | compact-array or array | x + 0x08 | |||
capture_objects | (static) | array | x + 0x10 | |||
capture_period | (static) | double-long-unsigned | x + 0x18 | |||
sort_method | (static) | enum | 1 | x + 0x20 | ||
sort_object | (static) | capture_object_definition | x + 0x28 | |||
entries_in_use | (dyn.) | double-long-unsigned | 0 | 0 | x + 0x30 | |
profile_entries | (static) | double-long-unsigned | 1 | 1 | x + 0x38 |
| 方法 | 必选/可选(m/o) | Short name |
|---|---|---|
reset (data) | o | x + 0x58 |
capture (data) | o | x + 0x60 |
| (保留给前一版本的方法,×2) | o | — |
2. 属性逐条解读(version 1)
2.1capture_objects(static, array)—— 定义"列"
这是整个类最关键的配置。每个元素是一个capture_object_definition:
capture_object_definition ::= structure { class_id: long-unsigned, logical_name: octet-string, attribute_index: integer, data_index: long-unsigned }四个字段的含义(原文逐条说明):
class_id+logical_name:定位到哪个对象;attribute_index:指向该对象的第几个属性。“attribute_index 1 refers to the 1st attribute (i.e. the logical_name), attribute_index 2 to the 2nd, etc.;attribute_index 0 refers to all public attributes.”
data_index:指向该属性内部的第几个元素(仅当属性是 structure 时有意义)。“data_index is a pointer selecting a specific element of the attribute. The first element in the attribute structure is identified by data_index 1.If the attribute is not a structure, then the data_index has no meaning.If the capture object is the buffer of a profile, then the data_index identifies the captured object of the buffer (i.e. the column) of the inner profile;data_index 0: references the whole attribute.”
要点小结:
| 字段 | 取值 | 含义 |
|---|---|---|
attribute_index | 1 | logical_name |
| 2 | 第 2 个属性(如Register的value) | |
| 0 | 所有公开属性 | |
data_index | 0 | 整个属性 |
| 1, 2, … | structure 内的第 n 个元素 | |
| — | 属性非 structure 时无意义 |
⚠️
data_index有一条特殊用法:当捕获对象是另一个 Profile 的 buffer 时,data_index表示内层 Profile 的"列号"—— 这就是 Profile 嵌套(Profile of Profile)的基础。
为什么列定义是 static?原文解释:
“The list is defined statically to ensurehomogenous buffer entries (all entries have the same size and structure).”
即:所有行必须长得一模一样,这样 buffer 才能紧凑存储、才能用偏移量随机访问。代价就是 —— 一旦改列定义,buffer 必须清空(见第 5 节)。
2.2buffer(dyn., compact-array or array)—— 存"行"
entry ::= structure { CHOICE { ... } }buffer是一个 array(或 compact-array),每个元素(行)是一个 structure,structure 里的元素个数与顺序与capture_objects完全一致。
它的 CHOICE 类型表比Register的更丰富(多出布尔、BCD、日期时间、以及差值类型 delta-*):
-- simple data types null-data [0], boolean [3], bit-string [4], double-long [5], double-long-unsigned [6], octet-string [9], visible-string [10], utf8-string [12], bcd [13], integer [15], long [16], unsigned [17], long-unsigned [18], long64 [20], long64-unsigned [21], enum [22], float32 [23], float64 [24], date-time [25], date [26], time [27], delta-integer [28], delta-long [29], delta-double-long [30], delta-unsigned [31], delta-long-unsigned [32], delta-double-long-unsigned [33], -- complex data types array [1], structure [2], compact-array [19]两条必须知道的 REMARK:
REMARK 1:“Reading the entire buffer delivers only those entries, which are ‘in use’.”
→ 读整个 buffer 只返回正在使用的行(不会把预分配的空行返回给你)。
REMARK 2:“The value of a captured object may be replaced bynull-dataif it can be unambiguously recovered from the previous value (for example for time: if it can be calculated from the previous value and capture_period; or for a value: if it is equal to the previous value).”
→数据压缩:如果某列的值能从上一行推出来(时间 = 上一行时间 + capture_period)或等于上一行,表计可以存null-data省空间。解析端必须做"沿用上一行"的处理,否则会读到空值。
2.3capture_period(static, double-long-unsigned)—— 采集周期
">= 1: Automatic capturing assumed. Specifies the capturing period in seconds.
0: No automatic capturing; capturing is triggered externally or capture events occur asynchronously."
| 取值 | 行为 |
|---|---|
| ≥ 1 | 自动采集,单位秒(900 = 15 分钟) |
| 0 | 不自动,由外部调用capture()或异步事件触发 |
典型值:900(15 分钟负荷曲线)、300(5 分钟)、3600(1 小时冻结)、0(事件日志)。
2.4sort_method(static, enum)与sort_object—— 怎么排
enum: (1) fifo (first in first out), (2) lifo (last in first out), (3) largest, (4) smallest, (5) nearest_to_zero, (6) farest_from_zero Def. fifo未排序(fifo)时的行为:
“If the profile is unsorted, it works as a ‘first in first out’ buffer … If the buffer is full, the next call to capture () willpush out the first (oldest) entryof the buffer to make space for the new entry.”
已排序时的行为:
“If the profile is sorted, a call to capture () will store the new entry at the appropriate position in the buffer, moving all following entries andprobably losing the least interesting entry. If the new entry would enter the buffer after the last entry and if the buffer is already full,the new entry will not be retained at all.”
sort_object指定"按哪一列排":
“If the profile is sorted, this attribute specifies the register or clock that the ordering is based upon. Def. no object to sort by (only possible with sort_method fifo or lifo).”
“NOTE: If the sort_method is FIFO or LIFO, then all elements of the capture_object_definition specifying the sort object can be zero.”
要点:
sort_method = fifo / lifo时不需要sort_object(各字段可填 0);sort_method = largest / smallest / nearest_to_zero / farest_from_zero时必须指定sort_object(通常是 Clock 的时间列,或某个 Register/Demand register 的数值列)。
2.5entries_in_use与profile_entries
| 属性 | 类型 | Min | Def | 含义 |
|---|---|---|---|---|
entries_in_use | (dyn.) double-long-unsigned | 0 | 0 | 当前已存了多少行(0 …profile_entries) |
profile_entries | (static) double-long-unsigned | 1 | 1 | 最多保留多少行(物理上限内) |
原文对
profile_entries的说明:“Specifies how many entries shall be retained in the buffer.”且“the physical limit for the buffer … The object will reject an attempt of setting the maximum number of entries that is larger than physically possible.”
读 buffer 之前,先读entries_in_use可以知道有多少行有效 —— 这是很多主站"先探长度再拉数据"的标准做法。
3. 选择性访问:怎么只"切一块"回来
这是Profile generic最实用、也最能体现 DLMS 设计功力的部分。
问题:一条 90 天的负荷曲线有 8640 行,全量读回来既慢又占带宽。只想读"5 月 1 日这一天"怎么办?
答案:对buffer属性使用选择性访问(selective access)。蓝皮书定义了两个 access selector:
| Access selector | Access parameter | 作用 |
|---|---|---|
| 1 | range_descriptor | 按值范围取(如某段时间、某个数值区间) |
| 2 | entry_descriptor | 按行列下标取(如第 87–96 行、第 1–2 列) |
3.1 Access selector 1:range_descriptor(按值范围)
range_descriptor ::= structure { restricting_object: capture_object_definition, -- 用哪一列来限定范围(只允许简单类型) from_value: CHOICE { ... }, -- 起始值(最老/最小) to_value: CHOICE { ... }, -- 结束值(最新/最大) selected_values: array capture_object_definition -- 要返回哪些列;空数组 = 全部列 }from_value/to_value的 CHOICE 支持:double-long[5]、double-long-unsigned[6]、octet-string[9]、visible-string[10]、utf8-string[12]、integer[15]、long[16]、unsigned[17]、long-unsigned[18]、long64[20]、long64-unsigned[21]、float32[23]、float64[24]、date-time[25]、date[26]、time[27]。
date-time [25]在列里 —— 所以按时间范围取就是用这个 selector。
selected_values是关键优化:只返回你关心的列。比如曲线有 8 列,但你只要"时间 + 电量"两列,就把这两列填进去,其余列不传。
3.2 Access selector 2:entry_descriptor(按行列下标)
entry_descriptor ::= structure { from_entry: double-long-unsigned, -- 起始行 to_entry: double-long-unsigned, -- 结束行(0 = 最高可能行) from_selected_value: long-unsigned, -- 起始列 to_selected_value: long-unsigned -- 结束列(0 = 最高可能列) }两条 NOTE:
“NOTE 1: from_entry and to_entry identify thelines, from_selected_value to_selected_value identify thecolumnsof the buffer to be retrieved.”
“NOTE 2:Numbering of entries and selected values starts from 1.”
再次强调:下标从 1 开始;to_entry = 0表示"到最后一行",to_selected_value = 0表示"到最后一列"。
4. 方法:reset与capture
reset (data)
“Clears the buffer. It has no valid entries afterwards; entries_in_use is zero after this call.This call does not trigger any additional operations on the capture objects. Specifically, it does not reset any attributes captured.”
data ::= integer (0)要点:清空 buffer,但不会去复位被采集的那些对象(不会把 Register 的值清零)—— 很多人误以为 reset profile 会清电量,其实不会。
capture (data)
“Copies the values of the objects to capture into the buffer by reading each capture object. Depending on the sort_method and the actual state of the buffer this produces a new entry or a replacement for the less significant entry. As long as not all entries are already used, the entries_in_use attribute will be incremented.”
“This call does not trigger any additional operations within the capture objects such as capture () or reset ().”
data ::= integer (0)两条实操注意:
“Note that if more than one attribute of an object need to be captured, they have to be definedone by oneon the list of capture_objects. If the attribute_index = 0, all attributes are captured.”
即:要采一个对象的多个属性,得逐条列出来(不能用一行"打包");或者用attribute_index = 0采全部公开属性(但会让列变得很宽,慎用)。
5. 修改属性的连锁反应 & 限制
> "Any modification of one of the capture_objects describing the static structure of the buffer will automatically call a reset () and this call will propagate to all other profiles capturing this profile."改列定义 = 清表,而且会传播:如果 Profile B 采集了 Profile A 的 buffer(嵌套 Profile),改 A 的列定义会连带清空 B。
另外:
- 写
profile_entries超过物理上限 →v1 会拒绝(reject); - 限制:“When defining the capture objects,circular reference to the profile shall be avoided.”—— 不能自己采自己(循环引用)。
6. 【实战举例】
示例 1:15 分钟负荷曲线(最典型的配置)
目标:存"时刻 + 正向有功总电能",每 15 分钟一条,保留一天(96 条) capture_objects = [ { class_id = 8, logical_name = 0-0:1.0.0, attribute_index = 2, data_index = 0 }, # ↑ Clock(8) 的 time 属性(第 2 个属性),整个属性 → 第 1 列:时间 { class_id = 3, logical_name = 1-0:1.8.0, attribute_index = 2, data_index = 0 } # ↑ Register(3) 的 value 属性(第 2 个属性)→ 第 2 列:电量 ] capture_period = 900 # 每 900 秒(15 分钟)自动采一条 sort_method = 1 # fifo(按采集顺序,最老的在最前面) sort_object = { 0, 0, 0, 0 } # fifo 时可为 0(NOTE 明确允许) profile_entries = 96 # 最多 96 条(正好一天)采集结果(buffer逻辑形态):
| 行号 | 第 1 列(时间) | 第 2 列(电量) |
|---|---|---|
| 1 | 2023-05-01 00:00:00 | 12 345,6 |
| 2 | 2023-05-01 00:15:00 | 12 348,1 |
| 3 | 2023-05-01 00:30:00 | 12 351,9 |
| … | … | … |
| 96 | 2023-05-01 23:45:00 | 12 890,3 |
主站读取流程:
# 1. 先看有多少条 GET (7, 1-0:99.1.0, 7) # entries_in_use(attribute 7) → 96 # 2. 用选择性访问读取(见示例 2 / 3) GET (7, 1-0:99.1.0, 2) with access_selector = 2, entry_descriptor{...}
1-0:99.x.0是负荷曲线常用的 OBIS 段(示例)。实际logical_name以设备对象列表为准。
示例 2:只读"最近 10 条"(用 entry_descriptor)
场景:只想看最新 10 个点,不要 96 条全拉。
GET (class_id = 7, logical_name = 1-0:99.1.0, attribute_index = 2) access_selector = 2 access_parameters = entry_descriptor { from_entry = 87, # 第 87 行 to_entry = 96, # 到第 96 行 from_selected_value = 1, # 第 1 列 to_selected_value = 2 # 到第 2 列 } → 返回 10 行 × 2 列如果想读"全部列",to_selected_value可以填0(= 到最后一列);想读"到最后一行",to_entry = 0。
示例 3:只读"5 月 1 日这一天"(用 range_descriptor)
GET (class_id = 7, logical_name = 1-0:99.1.0, attribute_index = 2) access_selector = 1 access_parameters = range_descriptor { restricting_object = { class_id = 8, logical_name = 0-0:1.0.0, attribute_index = 2, data_index = 0 }, # 用"时间列"限定范围 from_value = 2023-05-01 00:00:00, # date-time to_value = 2023-05-01 23:59:59, selected_values = [ ] # 空数组 = 返回所有列 } → 返回 2023-05-01 当天的所有行如果只要"时间 + 电量"两列(曲线实际有 8 列时),把selected_values填上:
selected_values = [ { 8, 0-0:1.0.0, 2, 0 }, # 时间列 { 3, 1-0:1.8.0, 2, 0 } # 电量列 ] → 只返回这两列,带宽大幅下降示例 4:用"1 条深的 Profile"造一个最大需量寄存器 ⭐
这是蓝皮书 Overview 里亲口给的例子,非常精妙:
“For example, it is very easy to build a‘maximum demand register’with a one entry deep sorted profile capturing and sorted by a ‘Demand register’last_average_valueattribute. It is just as simple to define a profile retaining thethree largest valuesof some period.”
配置:
capture_objects = [ { 5, 1-0:1.4.0, 3, 0 }, # Demand register(5) 的 last_average_value(第 3 个属性) { 8, 0-0:1.0.0, 2, 0 } # Clock(8) 的 time(记录这个最大值发生在什么时候) ] sort_method = 3 # largest(按最大值排) sort_object = { 5, 1-0:1.4.0, 3, 0 } # 按第 1 列(需量值)排序 profile_entries = 1 # 只保留 1 行!效果:每次capture()新值进来,如果比 buffer 里现有的那一行大,就替换它;否则按原文"新条目排在最后且 buffer 已满 → 不保留"直接丢弃。
结果:buffer 里永远是"历史最大需量 + 它发生的时刻" —— 这就是一个最大需量寄存器,而且自带时间戳!
改成profile_entries = 3就变成"保留最大的三个值"。
这个例子很好地展示了 COSEM 的设计哲学:用通用构件组合出专用功能,而不是为每个需求定义一个新类。
示例 5:首选读数集(preferred readout)
“By setting profile_entries to 1, a ‘Profile generic’ object can be used to define a set of preferred readout values. See also … Setting capture_period to 1 ensures that the values are updated every second.”
capture_objects = [ {3, 1-0:1.8.0, 2, 0}, # 总电能 {3, 1-0:15.7.0, 2, 0}, # 总有功功率 {8, 0-0:1.0.0, 2, 0} ] # 时间 capture_period = 1 # 每秒更新 profile_entries = 1 # 只保留最新 1 行这就是"标准读数(standard readout)":主站不需要分别发 3 次GET,只要读这 1 行就拿到一组常用值 ——一次通信拿全量,对抄表效率是巨大优化。
示例 6:null-data压缩与解析陷阱 ⚠️
按 REMARK 2,表计可能这样存:
| 行 | 时间列 | 电量列 |
|---|---|---|
| 1 | 2023-05-01 00:00:00 | 12 345,6 |
| 2 | null-data | 12 348,1 |
| 3 | null-data | 12 351,9 |
第 2、3 行的时间是null-data,因为它可以由"上一行时间 + capture_period(900 s)"推出来。
解析端必须:
prev_time = 2023-05-01 00:00:00 第 2 行时间 = prev_time + 900 s = 00:15:00 第 3 行时间 = 第 2 行时间 + 900 s = 00:30:00同理,如果电量值与上一行相同,也可能被存成null-data,解析时要沿用上一行的值。
这是主站解析负荷曲线时最常见的 bug 来源:把
null-data当成 0 或当成缺失值丢弃,导致曲线出现断点或归零。
7. version 0(Ed.1 遗留)与 version 1 对比
原文明确说明:
“The version listed here was valid inEdition 1and replaced with effect fromEdition 2.”
version 0 的类蓝图:
| 属性 | 数据类型 | Short name |
|---|---|---|
logical_name | octet-string | x |
buffer | array(无 compact-array) | x + 0x08 |
capture_objects | array | x + 0x10 |
capture_period | double-long-unsigned | x + 0x18 |
sort_method | enum | x + 0x20 |
sort_object | ObjectDefinition | x + 0x28 |
entries_in_use | double-long-unsigned | x + 0x30 |
profile_entries | double-long-unsigned | x + 0x38 |
| 方法 | Short name |
|---|---|
reset (data) | x + 0x58 |
capture (data) | x + 0x60 |
get_buffer_by_range (data) | x + 0x68 |
get_buffer_by_index (data) | x + 0x70 |
核心差异一览:
| 对比项 | version 0(Ed.1) | version 1(现行) |
|---|---|---|
buffer类型 | array | compact-arrayor array |
| 列定义类型 | ObjectDefinition {logical_name, class_id, attribute_index} | capture_object_definition {class_id, logical_name, attribute_index,data_index} |
| 字段顺序 | logical_name在前 | class_id在前 |
是否有data_index | ❌ 无 | ✅ 有(可选属性内元素 / 内层 Profile 的列) |
| 部分读取方式 | 两个专用方法:get_buffer_by_range/get_buffer_by_index | 选择性访问:buffer属性 + access selector 1/2 |
profile_entries写入过大 | 自动设为物理最大值 | 拒绝(reject) |
| 状态 | Ed.1 遗留,Ed.2 起被替换 | 现行 |
关于 v0 的两个读取方法:
get_buffer_by_range (data) data ::= structure { restricting_object; from_value; to_value; selected_values } 响应 data 是 array of instance_specific_value get_buffer_by_index (data) 参数含 from_index / to_index / from_selected_value / to_selected_value (原文 data 定义写作 structure {from_index; to_index; selected_values}) 响应 data 是 array of instance_specific_value迁移要点:从 Ed.1 设备迁移到 Ed.2+ 时,最需要注意的是 ——“读部分曲线"从"调方法"变成了"读属性 + 选择性访问”。报文结构完全不同,主站代码必须改。
8. 工程上容易踩的坑
- 改
capture_objects会清空 buffer,并传播到嵌套 Profile—— 现场改列定义前一定要先备份曲线数据。 null-data必须前向填充解析(REMARK 2),否则曲线出现假零点。- 读整个 buffer 只返回 in use 的行(REMARK 1),别按
profile_entries去截断解析。 - 下标从 1 开始:
entry_descriptor的from_entry/from_selected_value都是 1-based。 profile_entries超限:v1拒绝写、v0静默截断为物理最大值 —— 跨版本行为不一致,写入后要回读确认。- 禁止循环引用:
capture_objects不能引用自己(直接或间接)。 reset不会清被采集对象的值:清的是 profile 的 buffer,不是 Register 的电量。sort_method = largest/smallest必须配sort_object;fifo/lifo时sort_object各字段可填 0。- 要采多属性必须逐条列出;
attribute_index = 0会采全部公开属性,通常太宽。 capture_period = 0表示外部触发 —— 事件日志类 Profile 通常这样配,别以为配错了。
9. 小结 & 下期预告
本篇要点:
Profile generic(class_id = 7)是表计里的二维表:列 =capture_objects,行 =buffer,采集节奏 =capture_period,排序 =sort_method+sort_object;- 选择性访问是它的精髓:access selector1 = range_descriptor(按值/时间范围)、2 = entry_descriptor(按行列下标,1-based,
0表示"到最后"); selected_values可以只取需要的列,大幅省带宽;- 1 条深 +
sort_method = largest就能做出"最大需量寄存器(带时间戳)“—— 蓝皮书原文的例子;profile_entries = 1 + capture_period = 1可做"首选读数集”; - 改列定义会清空 buffer 并传播;
null-data是合法压缩,解析端必须前向填充; - v0 用两个方法(
get_buffer_by_range/get_buffer_by_index)读部分曲线,v1 改用选择性访问;且 v0 的ObjectDefinition字段顺序与 v1 不同、没有data_index。
下一篇(第 8 篇):Clock(class_id = 8)。前面几乎每个例子里都有它的身影 —— 负荷曲线要采它的时间、冻结值要记录时刻、Activity calendar要靠它判断现在处于哪个费率时段。可以说,表计里没有比 Clock 更基础、也更容易出问题的对象了(时钟跑偏 = 全表数据错时段)。我们会讲time/time_zone/status/daylight_savings这几个属性,以及"夏令时切换"这个让无数主站翻车的细节。
参考资料:DLMS UA《Blue Book Ed.16 Part 2 – COSEM interface classes》,Profile generic (class_id = 7, version = 1) 与 (class_id = 7, version = 0) 章节,含 buffer CHOICE 类型表、capture_object_definition / ObjectDefinition 定义、Parameters for selective access to the buffer attribute 表。文中属性、Min/Max/Def、Short name 偏移、方法定义、access selector 与引文均与原文一致;示例中的 OBIS 值与配置为帮助理解而构造。