DLMS/COSEM 蓝皮书解读(十一):Special days table 类(class_id = 11)—— 节假日如何覆盖常规日程:费率日历的"补丁表"
系列说明:本系列基于 DLMS UA《Blue Book(蓝皮书)第 16 版 · 第 2 部分 —— COSEM interface classes》,一个接口类一篇。第 10 篇讲了 Schedule(class_id = 10),它解决的是"脚本在什么时候、按什么周期执行"。本篇的 Special days table 解决的是紧随其后的那个洞:节假日 / 例外日如何覆盖常规日程 —— 春节、国庆这类日子不能按普通周末 / 工作日的规则走,需要一张"特殊日表"来给日历打补丁。
上篇回顾:Schedule 是一张表内的 cron 表,靠 switch_time 通配符 + exec_weekdays + begin_date/end_date 表达了"每周一到周五 22:00 切谷"这类周期性规则。但它有一个结构性盲区:它只认"星期几",不认"几月几日"。2026 年春节是 2 月 17 日,星期二 —— 按 exec_weekdays 它是一个普通工作日,可业务上它必须按假日费率走。Schedule 自己在 exec_specdays 里留了一个 bit-string 接口,但那个接口指向的规则表,正是本篇的 Special days table。
(版本说明:本卷 raw 原文中 Special days table 只有 version = 0 一个版本,故本篇不设版本对比章节。)
0. 为什么必须要有 Special days table
先算一笔账。某地峰谷电价:峰(08:00–11:00、18:00–21:00)、平、谷(22:00–次日 06:00)。表内靠 Activity calendar 的 week_profile 表达:周一至周五用 “工作日 day_profile”,周六周日用 “假日 day_profile”。
现在把 2026 年的日历摊开看:
| 2026-01-01(元旦) | 星期四 | 假日费率 | ❌ 按工作日计费 |
| 2026-02-17(春节) | 星期二 | 假日费率 | ❌ 按工作日计费 |
| 2026-05-01(劳动节) | 星期五 | 假日费率 | ❌ 按工作日计费 |
| 2026-10-01(国庆) | 星期四 | 假日费率 | ❌ 按工作日计费 |
四个法定节假日,没有一个落在周末。 这不是巧合 —— 全世界的法定假日绝大多数是"某月某日",而不是"某星期几",两套体系天生对不上。少了 Special days table,会出现三件很难收场的事:
Special days table 的思路与前两篇一脉相承:把"例外"也外置成数据。一张 entries 表放在 attribute 里,表内固件只有一个通用引擎(今天是不是特殊日?是 → 取那个 day_id → 用它覆盖常规 day profile),改节假日 = 一次 SET / 一次 insert,不动固件,也不依赖主站在线。
蓝皮书原文(Special days table, Overview):
“This IC allows defining special dates. On such dates, a special switching behaviour overrides the normal one. The IC works in conjunction with the class ‘Schedule’ or ‘Activity calendar’. The linking data item is day_id.”
三句话,把这个类的全部定位说完了:① 它定义特殊日期;② 特殊日上,“特殊切换行为覆盖(overrides)常规行为”;③ 它与 Schedule 或 Activity calendar 协同工作,链接两者的那个数据项叫 day_id。
一句话定位:
Special days table = 日历的补丁表。它自己不执行任何动作,只回答一个问题:“今天是不是特殊日?如果是,用几号 day profile / 置位哪个 specday bit?”
三个"日历类"怎么分工
| Schedule | 10 | 什么时刻、星期几、哪段日期区间,去执行哪个脚本 | entries(switch_time / exec_weekdays / exec_specdays / begin_date…) |
| Special days table | 11 | 哪一天是例外?例外日用哪个 day_id? | entries(index / specialday_date / day_id) |
| Activity calendar | 20 | 季节 → 周 → 日,层层嵌套的完整费率日历 | season_profile / week_profile_table / day_profile_table |
注意 Special days table 与 Activity calendar 是两条不同的覆盖链路(见示例 3 与示例 4),很多"配了特殊日却不生效"的工单,根源就是没分清自己在配哪一条。
1. 类蓝图
Special days table 0…n class_id = 11, version = 0
基数 0…n:一台设备可以有多个 Special days table 实例(例如"法定节假日一张"“检修日一张”),也可以一个都没有 —— 没有实例时,日历就完全按常规 week_profile / exec_weekdays 走,这本身是合法配置。
| logical_name | (static) | octet-string | x | |||
| entries | (static) | array | x + 0x08 |
| insert (data) | o | x + 0x10 |
| delete (data) | o | x + 0x18 |
注 1:原文未给 Min / Max / Def 三列任何取值,故留空。
注 2:两个属性全是 (static) —— static 表示"值不会由设备自发改变",不等于不可写;entries 恰恰是主站改写最频繁的属性之一(每年都要更新)。
注 3:两个方法都是 o(optional)。表计可以完全不实现任何方法,只把 entries 当成一个可整块 SET 的配置数组。调用 insert / delete 前必须先确认设备是否支持。
注 4:本类的方法 Short name 从 x + 0x10 起步,紧贴 entries 的 x + 0x08;对比第 10 篇的 Schedule(属性到 x + 0x08,方法却从 x + 0x20 起步,中间 0x10/0x18 是空的)—— Short name 偏移不能跨类按固定步长推算,必须查表。
只有 2 个属性、2 个方法,是全集里最"轻"的类之一。全部复杂度都藏在 entries 这一个 array 里。
2. 属性逐条解读
2.1 logical_name(static, octet-string)
“Identifies the ‘Special days table’ object instance.”
6 字节 OBIS 编码。工程上常见取值形如 0-0:11.0.0.255(示例值,非蓝皮书原文,实际以设备对象列表为准):A 组 = 0 属于"抽象 / 公用"对象,与相邻几个日历类连号排布(Script table 0-0:10.0.0.255、Schedule 0-0:12.0.0.255、Special days table 0-0:11.0.0.255、Activity calendar 0-0:13.0.0.255)。
关键提醒:本对象的 logical_name 不会被任何类的属性引用。第 10 篇的 Schedule 是通过 exec_specdays 的 bit 位间接关联到 day_id 的,Activity calendar 也是通过 day_id 关联 —— 两边都只认 day_id,不认这张表是谁。这是典型的"软引用",改错 day_id 不会触发任何一致性检查(详见第 5 节坑 3)。
2.2 entries(static, array)—— 本篇的全部内容
蓝皮书原文(Special days table, entries):
“Specifies a special day identifier for a given date. The date may have wildcards for repeating special days like Christmas.”
“array spec_day_entry”
两句话两层意思:entries 的每个条目 = “给某个日期指定一个特殊日标识”;日期字段允许通配符,用来表达"每年圣诞"这类重复节日 —— 这一句直接决定了本类的使用边界(见示例 2)。
原文给出的 structure 定义,三个字段,一个都不少:
spec_day_entry ::= structure
{
index: long-unsigned,
specialday_date: octet-string,
day_id: unsigned
}
注:部分资料把这个字段写作 special_day_date;本卷 raw 原文的拼写是 specialday_date(无第二个下划线),本文统一按原文。
| index | long-unsigned | 条目主键(不是数组下标!),delete 与"覆盖判定"都按它 |
| specialday_date | octet-string | 特殊日的日期,5 字节,按 4.6.1 的 date 规则,允许通配符 |
| day_id | unsigned | 链接数据项:对应 Activity calendar 的 day_profile,或 Schedule 的 exec_specdays bit 位 |
specialday_date:5 字节 date 与通配符
“specialday_date formatting follows the rules set in 4.6.1 for date;”
按第 8 篇讲过的 date 编码(5 字节 octet-string):year_hi | year_lo | month | day_of_month | day_of_week。年份写 0xFFFF = not specified = 每年。
| FF FF 01 01 FF | 每年 1 月 1 日(元旦) | 每年,永久 |
| FF FF 0A 01 FF | 每年 10 月 1 日(国庆) | 每年,永久 |
| FF FF 0C 19 FF | 每年 12 月 25 日(圣诞) | 每年,永久 |
| 07 EA 02 11 02 | 2026-02-17(周二,春节) | 仅 2026 年一次 |
| 07 EA 0A 01 04 | 2026-10-01(周四) | 仅 2026 年一次 |
年份 0x07EA = 2026;月 0x02 = 2 月;日 0x11 = 17 日;星期 0x02 = 周二(4.6.1 规定 day_of_week 为 1…7,1 = Monday,0xFF = not specified)。
"每年重复"与"一次性"的分水岭就是年份那两个字节 —— 这一点与第 10 篇 Schedule 的 begin_date / end_date 完全同源:写 FF FF 是周期性规则,写具体年份是"到点就过期"。而 Special days table 的残酷之处在于:农历节日(春节、中秋、清明)每年日期都不同,通配符表达不了,只能逐年下发具体日期(详见示例 2 与坑 1)。
day_id:整个类的"链接数据项"
蓝皮书原文(Special days table, entries, Where):
“the range of the day_id shall match the length of the bit-string exec_specdays in the related object of IC ‘Schedule’.”
这是本类唯一一条硬性约束,也是最常见的故障点:day_id 的取值范围必须与相关 Schedule 对象里 exec_specdays 这个 bit-string 的长度一致。
工程上怎么落地(示例,非原文):若 Schedule 的 exec_specdays 是 9 bit(对应 S1…S9),那么 day_id 只能取 1…9 —— 你写一个 day_id = 12,那个 bit 根本不存在,特殊日静默失效,没有任何报错。
原文未明确规定的两点(不要当成规格):① day_id 的下界(是否从 1 开始、0 是否保留);② 第 n 个 bit 与 day_id = n 的位序映射(S1 占字节最高位还是最低位)。本系列第 10 篇 exec_weekdays 采用"第 1 个 bit = 最高位"(Mo = 0x40),exec_specdays 按同例类推 —— 实际请以设备对象列表与厂商文档为准,并在实验表上验证。
day_id 的另一半语义在 Activity calendar 那边(见示例 3),本卷 Special days table 原文只给了 day_id: unsigned 和上面那条约束,没有展开它在 Activity calendar 侧的用法。
3. 方法详解(两个,全是 o)
3.1 insert (data)
“Inserts a new entry in the table.”
“entry ::= spec_day_entry”
“If a special day with the same index or with the same date as an already defined day is inserted, the old entry will be overwritten.”
data 就是一个完整的 spec_day_entry。重点在第三句:覆盖条件是"二选一" —— index 相同或**specialday_date 相同**,旧条目都会被覆盖。**
这与第 10 篇 Schedule.insert 的语义不一样:Schedule 只说 “If the index of the entry exists already, the existing entry is overwritten” —— 只按 index 判定。而本类多了一个"日期相同也算撞车"的条件,后果是:
已存在:{ index = 5, specialday_date = 2026-10-01, day_id = 9 } # 国庆 → 假日
新下发:{ index = 6, specialday_date = 2026-10-01, day_id = 1 } # 手滑写错 day_id
结果 :index = 5 的条目被静默覆盖(删除),表里只剩 index = 6 这一条
也就是说 insert 实际上是 upsert,而且 upsert 的键有两个。批量下发节假日表时,"同一天被写了两次"不会报错,只会留下最后一条(见示例 6)。
3.2 delete (data)
“Deletes an entry in the table.”
“index Index of the entry that shall be deleted.”
“data ::= long-unsigned”
三点:
- data 是单个 long-unsigned(一个 index 值),不是区间。对比 Schedule.delete(data ::= structure { firstIndex, lastIndex },按区间删)—— 这两个同名方法参数完全不同,别把 Schedule 的写法套过来。
- 按 index 匹配,不是数组下标。删掉中间一条后其余条目的 index 不变。
- 原文没有给出"删除不存在的 index 时是否报错"的规定 —— 原文未提供该项。
3.3 没有 enable/disable:与相邻类的能力对比
| Schedule(10) | enable/disable(按区间关 A 开 B)、insert、delete(按区间) | 专为季节切换设计 |
| Special days table(11) | insert(单条,双条件覆盖)、delete(单个 index) | 无 enable/disable |
| Activity calendar(20) | activate_passive_calendar(passive → active 一次性整体切换) | 靠"双份日历 + 定时激活"实现无缝换季 |
工程含义很直接:想临时停用一个特殊日,只能 delete 掉它,或者把整张 entries 读回来改完再整块 SET 回去(后者在两个方法都不支持时是唯一退路)。另外,原文未说明 insert / delete 之后是否立即生效、是否需要重启 —— 原文未提供该项,实践中建议 insert 后立刻 GET entries 读回校验。
4. 【实战举例】
以下示例中的 OBIS、配置取值与报文字节均为帮助理解而构造,非蓝皮书原文,实际以设备对象列表与厂商文档为准。编码按 4.6.1 的 date 规则与 A-XDR 规则(structure = 0x02、array = 0x01、octet-string = 0x09、long-unsigned = 0x12、unsigned = 0x11、bit-string = 0x04)构造。
示例 1:一条 entry 的完整构造与 A-XDR —— 2026 年春节
需求:2026-02-17(周二,春节)不走工作日费率,改用 day_id = 9 的假日 day profile。
Special days table logical_name = 0-0:11.0.0.255 (示例)
entries[0] = {
index = 1, # 主键(不是数组下标)
specialday_date = 07 EA 02 11 02, # 2026-02-17,周二
day_id = 9 # → 假日 day_profile / S9
}
SET 该 entries(单条)的 A-XDR(示例):
01 01 array(1) entries
02 03 structure(3) spec_day_entry
12 00 01 long-unsigned 1 index
09 05 07 EA 02 11 02 octet-string(5) 2026-02-17 specialday_date
11 09 unsigned 9 day_id
等价的增量写法(若设备实现了 insert):
ACTION (class_id = 11, logical_name = 0-0:11.0.0.255, method_index = 1) # insert
data ::= spec_day_entry = { index = 1, specialday_date = 07 EA 02 11 02, day_id = 9 }
对比第 10 篇 Schedule 的 10 字段 entry(20 多个字节),本类一个条目只有 12 个字节 —— 一张 30 天的节假日表也就 360 字节左右,完全可以整块 SET,这是它设计成这么轻的原因。
示例 2:国庆能用通配符,春节不能 —— 农历节日的硬约束
同一个"法定节假日"需求,两种日期写法,语义天差地别(示例):
# A. 公历固定日 → 通配符,一次下发永久有效
{ index = 10, specialday_date = FF FF 0A 01 FF, day_id = 9 } # 每年 10-01 国庆
{ index = 11, specialday_date = FF FF 01 01 FF, day_id = 9 } # 每年 01-01 元旦
{ index = 12, specialday_date = FF FF 05 01 FF, day_id = 9 } # 每年 05-01 劳动节
# B. 农历节日 → 每年日期不同,只能逐年写具体日期
{ index = 20, specialday_date = 07 EA 02 11 02, day_id = 9 } # 2026-02-17 春节(周二)
# 2027 年春节是 2 月 6 日(周六),必须再下发一条:
{ index = 21, specialday_date = 07 EB 02 06 06, day_id = 9 } # 2027-02-06(0x07EB = 2027)
day_of_month 通配符(0xFD = 倒数第二天、0xFE = 最后一天)能表达"每月最后一天",但表达不了"农历正月初一" —— 4.6.1 的 date 是公历结构,没有农历字段。
公历节日(元旦 / 劳动节 / 国庆 / 圣诞)→ 通配符,一次配置多年;农历与浮动节日(春节 / 中秋 / 清明 / 复活节 / 感恩节)→ 必须逐年下发具体日期。 这条结论直接决定了跨年维护的工作量(见示例 6)。
示例 3:day_id → Activity calendar 的 day_profile(覆盖链路之一)
这是 Special days table 最正统的用法。原文对优先级的描述写在 Activity calendar 那一章:
蓝皮书原文(Activity calendar, Overview):
“If an instance of the IC ‘Special days table’ is available, relevant entries there take precedence over the ‘Activity calendar’ object driven selection of a day profile. The day profile referenced in the ‘Special days table’ activates the day_schedule of the day_profile_table in the ‘Activity calendar’ object by referencing through the day_id.”
翻成工程语言:如果设备里有 Special days table 实例,且今天命中了其中一条,那么它的选择优先于 Activity calendar 自己按 week_profile 选出来的 day profile;而它引用的那个 day profile,正是通过 day_id 在 Activity calendar 的 day_profile_table 里查到的。
一组完整配置(示例):
Activity calendar (0-0:13.0.0.255) (示例 OBIS)
day_profile_table_active:
day_id = 8 → day_schedule: # 工作日
06:00 → Script(0-0:10.0.100.255, #1) # 切峰
22:00 → Script(0-0:10.0.100.255, #2) # 切谷
day_id = 9 → day_schedule: # 假日(全天谷)
00:00 → Script(0-0:10.0.100.255, #2) # 切谷
week_profile_table_active:
"WORK-WEEK" → { mon=8, tue=8, wed=8, thu=8, fri=8, sat=9, sun=9 }
Special days table (0-0:11.0.0.255)
{ index = 1, specialday_date = 07 EA 02 11 02, day_id = 9 } # 2026-02-17 春节 → 假日
{ index = 10, specialday_date = FF FF 0A 01 FF, day_id = 9 } # 每年国庆 → 假日
2026-02-17 那天:week_profile 说"周二 → day_id = 8",但 Special days table 命中并覆盖为 day_id = 9 → 走假日 day_schedule → 全天谷费率。注意这里 Schedule 的 exec_specdays 完全不参与 —— 这是 Activity calendar 侧的覆盖。
示例 4:day_id ↔ Schedule.exec_specdays(覆盖链路之二)
另一条链路是给 Schedule 条目用的。第 10 篇留的接口在这里接上:
Special days table (0-0:11.0.0.255)
{ index = 1, specialday_date = 07 EA 02 11 02, day_id = 9 } # 春节 → S9
{ index = 2, specialday_date = FF FF 0C 19 FF, day_id = 1 } # 每年圣诞 → S1
… day_id 取值 1…9,共 9 个
Schedule (0-0:12.0.0.255)
entry { index = 300,
switch_time = 12:00,
exec_weekdays = 04 07 01, # 7 bit,仅周日(0x01 = Su)
exec_specdays = 04 09 00 80 } # 9 bit,S9 置位(第 9 位)
exec_specdays 的 bit 数必须等于 day_id 的取值范围长度:表里有 S1…S9 → 9 bit → 2 字节(04 09 xx xx);若表里只有 S1…S4 → 4 bit → 1 字节(04 04 x0)。长度写错(写成 7 bit)时,S8 / S9 永远命中不了,现象是"特殊日不生效"且极难排查。
另一条来自 Activity calendar 原文的排序规则值得记住:
“If actions in a ‘Schedule’ object are scheduled for the same activation time as in an ‘Activity calendar’ object, the actions triggered by the ‘Schedule’ object are executed first.”
即同一时刻 Schedule 的动作先于 Activity calendar 的动作执行。所以如果"特殊日切换"和"常规整点切换"撞在同一分钟,顺序是固定的,别指望靠配两次来互相纠正。
示例 5:完整协作时序 —— 从 Clock 到费率寄存器
把第 6 篇(费率切换链路)、第 9 篇(Script table)、第 10 篇(Schedule)、本篇串起来,2026-02-17 春节这一天表内发生了什么(示例):
[0] 表内时钟 (Clock, 8):2026-02-16 23:59:59 → 跨日
[1] Activity calendar (20):按 season_profile → week_profile 取 "周二 → day_id = 8"
[2] Special days table (11):查 specialday_date == 2026-02-17 → 命中 index=1 → day_id = 9
└─ 覆盖(override)上一步的 day_id = 8
[3] 选中 day_profile 9 的 day_schedule(假日:00:00 切谷)
[4] 到达激活时刻 → Script table (9).execute(#2)
└─ 脚本内容:SET Register activation (6).active_mask = "holiday"
[5] Register activation (6).active_mask = "holiday" → 谷寄存器 1-0:1.8.3.255 开始累加
→ 结果:2026-02-17 全天电量计入谷费率寄存器,而不是峰/平寄存器
同一天里,Schedule 若也有一条 exec_specdays 命中 S9 的条目(示例 4 的 index = 300),它会在 12:00 再触发一次自己的脚本 —— 两条链路各走各的,day_id 是它们共同的锚点。工程上务必只选一条链路配置同一个动作,否则就是"费率被切两次"。
示例 6:跨年下发 —— 时序、条数与那个"同 date 覆盖"陷阱
真实运维节奏(示例):每年 12 月中旬,主站要把次年的特殊日表下发到全网表计。
T0 2026-12-10 主站生成 2027 年表:12 条(元旦/春节/清明/劳动/端午/中秋/国庆…)
T1 2026-12-15 批量 ACTION insert ×12(或整块 SET entries)
⚠ 每条下发后 GET entries 读回校验条数与 day_id
T2 2026-12-31 抽查:随机抽 1% 表计,确认 entries 里 2027 年条目齐全
T3 2027-01-01 表计自动切换(无需主站在线)
三个必须记住的点:
5. 工程上容易踩的坑
6. 小结 & 下期预告
本篇要点:
下一篇(第 12 篇):Association SN(class_id = 12)—— 前 11 篇讲的都是"数据对象",从下一篇起换一个维度:在 SN(short name)寻址方式下,"会话"本身如何建模。一台设备可以为它能支持的每一个 AA(application association)各建一个 Association SN 实例,object_list / access_rights_list / security_setup_reference / user_list / current_user 等属性定义了"这个客户端能看到什么、能做什么"。这个类有 4 个 version(0 / 1 / 2 / 4),我们会按版本分节,逐版说明新增了哪些属性与方法(read_by_logicalname、change_secret、reply_to_HLS_authentication、add_user、remove_user 等),并解释为什么中间跳过了 version = 3。
参考资料:DLMS UA《Blue Book Ed.16 Part 2 – COSEM interface classes》,Special days table (class_id = 11, version = 0) 章节(含 Overview、Attribute description 与 spec_day_entry 定义、Method description),以及 Activity calendar (class_id = 20, version = 0) 章节 Overview 中关于 Special days table 优先级与 day_id 引用关系的描述、Schedule (class_id = 10) 章节中 exec_specdays 的相关描述。文中 2 个属性的名称、static 标记、数据类型、Short name 偏移、2 个方法的名称与 m/o、Short name 偏移、以及全部英文引文均与原文一致;原文未提供的项(Min/Max/Def 取值、insert/delete 后的生效时机、删除不存在 index 的行为、day_id 下界与 bit 位序映射、条目自动过期机制)已如实标注。示例中的 OBIS(0-0:12.0.0.255 / 0-0:11.0.0.255 / 0-0:10.0.0.255 / 0-0:13.0.0.255)、day_id 取值、日期字节与报文字节为帮助理解而构造(实际以设备对象列表与厂商文档为准)。




