feat: Sinam_xlsx 기반 register-map 재생성 + C1-C4 컨트롤러 추가
- register-map.json 재생성 (C3: 328→1974 registers) - .PV/.SP/.OP 등 suffix 추가, write_addr 필드 도입 - LOOP_LAYOUT 기반 고정 레이아웃 전개 + 명명되지 않은 레지스터 보존 - FC16 쓰기를 위한 write_addr 분리 (.MD는 LOOPSTAT 읽기/MODEIN 쓰기) - build_register_map_from_sinam.py 리팩터 - --controller 인자 추가 (C3/C4/C5 복수 컨트롤러 지원) - --validate-csv 옵션 추가 (HC Designer CSV 교차검증) - tag명 대문자 유지 (ToLower 금지) - 출력 경로 Path 객체 사용 - gateway-config.json: C1-C4 컨트롤러 설정 추가
This commit is contained in:
@@ -6,14 +6,44 @@
|
||||
},
|
||||
"controllers": [
|
||||
{
|
||||
"id": "C3",
|
||||
"name": "HC900 C3 Controller",
|
||||
"controllerIp": "192.168.0.230",
|
||||
"id": "C1",
|
||||
"name": "HC900 C1 Controller",
|
||||
"controllerIp": "192.168.0.250",
|
||||
"controllerPort": 502,
|
||||
"grpcPort": 50051,
|
||||
"pollIntervalMs": 1000,
|
||||
"registerMapPath": "/home/windpacer/projects/hc900_ax/docs/register-map.json",
|
||||
"pollIntervalMs": 500,
|
||||
"registerMapPath": "/home/windpacer/projects/hc900_ax/docs/register-map-c1.json",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"id": "C2",
|
||||
"name": "HC900 C2 Controller",
|
||||
"controllerIp": "192.168.0.230",
|
||||
"controllerPort": 502,
|
||||
"grpcPort": 50052,
|
||||
"pollIntervalMs": 500,
|
||||
"registerMapPath": "/home/windpacer/projects/hc900_ax/docs/register-map-c2.json",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"id": "C3",
|
||||
"name": "HC900 C3 Controller",
|
||||
"controllerIp": "192.168.0.240",
|
||||
"controllerPort": 502,
|
||||
"grpcPort": 50053,
|
||||
"pollIntervalMs": 500,
|
||||
"registerMapPath": "/home/windpacer/projects/hc900_ax/docs/register-map-c3.json",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"id": "C4",
|
||||
"name": "HC900 C4 Controller",
|
||||
"controllerIp": "192.168.0.220",
|
||||
"controllerPort": 502,
|
||||
"grpcPort": 50054,
|
||||
"pollIntervalMs": 500,
|
||||
"registerMapPath": "/home/windpacer/projects/hc900_ax/docs/register-map-c4.json",
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
16798
docs/register-map.json
16798
docs/register-map.json
File diff suppressed because it is too large
Load Diff
@@ -1,322 +1,487 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build register-map.json from Sinam_Tag_all.xlsx SourceAddress columns.
|
||||
Build register-map.json from the Experion point export (Sinam_Tag_all.xlsx) ALONE.
|
||||
|
||||
Reads Sinam_Tag_all.xlsx (Experion tag→HC900 address mapping), filters
|
||||
for C3 controller entries, converts Experion indexed addresses to
|
||||
fixed Modbus addresses, and writes register-map.json.
|
||||
The Experion export carries, for every point, its HC900 source/destination
|
||||
addresses written as Honeywell "named" indexed addresses (e.g. ``C3 LOOP 1 PV``,
|
||||
``C3 TAG 542 VALUE``, ``C3 MATH_VAR 22 VALUE``) or, for custom-mapped registers,
|
||||
as raw "non-named" addresses (e.g. ``C3 4:0x789e UINT2``).
|
||||
|
||||
Combined with the HC900 *fixed* loop register layout (a controller-firmware
|
||||
constant taken from the HC900 Communications manual, Table 6-3, and the Experion
|
||||
"PID loop" named-address reference) this is everything needed to produce the
|
||||
Modbus register map. No HC Designer CSV export is required at run time; pass a
|
||||
CSV with ``--validate-csv`` only if you want the embedded layout cross-checked.
|
||||
|
||||
Design (see docs/컨트롤러별-태그매핑-규칙.md):
|
||||
* The map is keyed on the **Experion point name** (ItemName) — every downstream
|
||||
consumer (crawler, DB, web UI) is Experion-based.
|
||||
* For a loop point we register **all** parameters of that loop (one contiguous
|
||||
Modbus read covers them anyway), naming the parameters Experion exposes with
|
||||
Experion attribute names (.PV/.SP/.OP/.MD/.GAIN/.RESET/.RATE) and the rest
|
||||
with their HC900 names.
|
||||
* ``.MD`` (Mode) reads from Loop Status (LOOPSTAT) but writes to Auto/Manual
|
||||
State (the MODEIN destination) — so it carries a separate ``write_addr``.
|
||||
|
||||
Usage:
|
||||
python3 build_register_map_from_sinam.py \
|
||||
--sinam docs/Sinam_Tag_all.xlsx \
|
||||
--csv docs/C3-All-Modbus-Map.csv \
|
||||
-o docs/register-map.json
|
||||
python3 build_register_map_from_sinam.py --controller C3 \
|
||||
--sinam docs/Sinam_Tag_all.xlsx -o docs/register-map.json
|
||||
# optional cross-check against an HC Designer export:
|
||||
python3 build_register_map_from_sinam.py --controller C4 \
|
||||
--sinam docs/Sinam_Tag_all.xlsx --validate-csv docs/C4-All-Modbus-Map.csv \
|
||||
-o docs/register-map-c4.json
|
||||
"""
|
||||
|
||||
import re
|
||||
import csv
|
||||
import json
|
||||
import argparse
|
||||
import csv
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
|
||||
# ─── Experion indexed address → Modbus address ───
|
||||
|
||||
PARAM_MAP = {
|
||||
'PV': (0x00, 2, 'float32'),
|
||||
'RSP': (0x02, 2, 'float32'),
|
||||
'WSP': (0x04, 2, 'float32'),
|
||||
'SP': (0x04, 2, 'float32'),
|
||||
'OP': (0x06, 2, 'float32'),
|
||||
'OPWORK': (0x06, 2, 'float32'),
|
||||
'LSP1': (0x2A, 2, 'float32'),
|
||||
'LSP2': (0x2C, 2, 'float32'),
|
||||
'AMSTAT': (0xBA, 1, 'uint16'),
|
||||
'LOOPSTAT': (0xBE, 1, 'uint16'),
|
||||
# ─────────────────────────── HC900 fixed loop layout ───────────────────────────
|
||||
# Offset within a loop block (loop base = 0x40 + (n-1)*0x100 for loops 1-24,
|
||||
# 0x7840 + (n-25)*0x100 for loops 25-32). Validated against C3/C4 HC Designer
|
||||
# exports and the HC900 Communications manual Table 6-3.
|
||||
#
|
||||
# offset : (default_suffix, count, type, access)
|
||||
# count 2 = float32 (IEEEFP), count 1 = uint16 (bit-packed / unscaled int).
|
||||
LOOP_LAYOUT = {
|
||||
0x00: ('PV', 2, 'float32', 'R'),
|
||||
0x02: ('RSP_SP2', 2, 'float32', 'RW'),
|
||||
0x04: ('WSP', 2, 'float32', 'RW'),
|
||||
0x06: ('Output', 2, 'float32', 'RW'),
|
||||
0x08: ('PV_B', 2, 'float32', 'R'),
|
||||
0x0A: ('CarbonPotTemp', 2, 'float32', 'R'),
|
||||
0x0C: ('Gain1', 2, 'float32', 'RW'),
|
||||
0x0E: ('Direction', 2, 'float32', 'R'),
|
||||
0x10: ('Reset1', 2, 'float32', 'RW'),
|
||||
0x12: ('Rate1', 2, 'float32', 'RW'),
|
||||
0x14: ('CycleTime1', 2, 'float32', 'R'),
|
||||
0x16: ('PV_LowRange', 2, 'float32', 'R'),
|
||||
0x18: ('PV_HighRange', 2, 'float32', 'R'),
|
||||
0x1A: ('Alarm1SP1', 2, 'float32', 'RW'),
|
||||
0x1C: ('Alarm1SP2', 2, 'float32', 'RW'),
|
||||
0x20: ('Gain2', 2, 'float32', 'RW'),
|
||||
0x22: ('StepDeadband', 2, 'float32', 'RW'),
|
||||
0x24: ('Reset2', 2, 'float32', 'RW'),
|
||||
0x26: ('Rate2', 2, 'float32', 'RW'),
|
||||
0x28: ('CycleTime2', 2, 'float32', 'R'),
|
||||
0x2A: ('LSP1', 2, 'float32', 'RW'),
|
||||
0x2C: ('LSP2', 2, 'float32', 'RW'),
|
||||
0x2E: ('Alarm2SP1', 2, 'float32', 'RW'),
|
||||
0x30: ('Alarm2SP2', 2, 'float32', 'RW'),
|
||||
0x34: ('SP_LowLimit', 2, 'float32', 'RW'),
|
||||
0x36: ('SP_HighLimit', 2, 'float32', 'RW'),
|
||||
0x38: ('WSP_B', 2, 'float32', 'RW'),
|
||||
0x3A: ('Output_LowLimit', 2, 'float32', 'RW'),
|
||||
0x3C: ('Output_HighLimit', 2, 'float32', 'RW'),
|
||||
0x3E: ('OPWORK', 2, 'float32', 'RW'),
|
||||
0x46: ('Ratio', 2, 'float32', 'RW'),
|
||||
0x48: ('Bias', 2, 'float32', 'RW'),
|
||||
0x4A: ('Deviation', 2, 'float32', 'R'),
|
||||
0x4E: ('ManualReset', 2, 'float32', 'RW'),
|
||||
0x50: ('FeedforwardGain', 2, 'float32', 'RW'),
|
||||
0x52: ('LocalPctCO', 2, 'float32', 'RW'),
|
||||
0x54: ('FurnaceFactor', 2, 'float32', 'RW'),
|
||||
0x56: ('PercentHydrogen', 2, 'float32', 'RW'),
|
||||
0x58: ('OnOffHysteresis', 2, 'float32', 'RW'),
|
||||
0x5A: ('CarbPotDewpt', 2, 'float32', 'RW'),
|
||||
0x5C: ('StepMotorTime', 2, 'float32', 'RW'),
|
||||
0xB7: ('FuzzyEnable', 1, 'uint16', 'RW'),
|
||||
0xB8: ('DemandTune', 1, 'uint16', 'RW'),
|
||||
0xB9: ('AntiSootEnable', 1, 'uint16', 'RW'),
|
||||
0xBA: ('AutoManState', 1, 'uint16', 'RW'),
|
||||
0xBB: ('SP_SelectState', 1, 'uint16', 'RW'),
|
||||
0xBC: ('RemLocSPState', 1, 'uint16', 'RW'),
|
||||
0xBD: ('TuneSetState', 1, 'uint16', 'RW'),
|
||||
0xBE: ('LoopStatus', 1, 'uint16', 'R'),
|
||||
}
|
||||
|
||||
# Honeywell named-address mnemonic → offset within the loop block.
|
||||
MNEMONIC_OFFSET = {
|
||||
'PV': 0x00, 'RSP': 0x02, 'SP2': 0x02, 'WSP': 0x04, 'OP': 0x06, 'PVB': 0x08,
|
||||
'TEMP': 0x0A, 'GAIN1': 0x0C, 'PROP1': 0x0C, 'DIR': 0x0E, 'RESET1': 0x10,
|
||||
'RATE1': 0x12, 'CYCLE1': 0x14, 'PVLOW': 0x16, 'PVHIGH': 0x18, 'AL1SP1': 0x1A,
|
||||
'AL1SP2': 0x1C, 'GAIN2': 0x20, 'PROP2': 0x20, 'DB': 0x22, 'RESET2': 0x24,
|
||||
'RATE2': 0x26, 'CYCLE2': 0x28, 'LSP1': 0x2A, 'LSP2': 0x2C, 'AL2SP1': 0x2E,
|
||||
'AL2SP2': 0x30, 'SPLOW': 0x34, 'SPHIGH': 0x36, 'SPWORK': 0x38, 'OPLOW': 0x3A,
|
||||
'OPHIGH': 0x3C, 'OPWORK': 0x3E, 'RATIO': 0x46, 'BIAS': 0x48, 'DEV': 0x4A,
|
||||
'MAN_RESET': 0x4E, 'FF': 0x50, 'PCTCO': 0x52, 'FFCTR': 0x54, 'H2': 0x56,
|
||||
'OUT_HYST': 0x58, 'CPD': 0x5A, 'MOTOR': 0x5C, 'AMSTAT': 0xBA, 'LOOPSTAT': 0xBE,
|
||||
'MODEIN': 0xBA, # mode write destination = Auto/Manual State register
|
||||
}
|
||||
|
||||
def parse_indexed_addr(addr_str: str, csv_lookup: dict) -> dict | None:
|
||||
"""Convert an Experion indexed address (e.g. 'C3 LOOP 1 OP')
|
||||
to a dict with modbus_addr, count, type, and optional description.
|
||||
# Mnemonic → Experion point-parameter (attribute) name. Only these get renamed;
|
||||
# every other loop register keeps its HC900 name from LOOP_LAYOUT.
|
||||
EXPERION_ATTR = {
|
||||
'PV': 'PV',
|
||||
'WSP': 'SP', 'SPWORK': 'SP',
|
||||
'OP': 'OP', 'OPWORK': 'OP',
|
||||
'GAIN1': 'GAIN',
|
||||
'RESET1': 'RESET',
|
||||
'RATE1': 'RATE',
|
||||
'LOOPSTAT': 'MD', # mode read source
|
||||
}
|
||||
|
||||
csv_lookup: {(partition, index): {'addr': int, 'dtype': str, 'tag': str}}
|
||||
MD_READ_OFFSET = 0xBE # Loop Status (read)
|
||||
MD_WRITE_OFFSET = 0xBA # Auto/Manual State (MODEIN destination, write)
|
||||
|
||||
# Fixed bases for the other named-address spaces.
|
||||
SIGNAL_TAG_BASE = 0x2000 # TAG n → base + (n-1)*2 (read-only float32)
|
||||
MATH_VAR_BASE = 0x18C0 # MATH_VAR n → base + (n-1)*2 (read/write float32)
|
||||
|
||||
|
||||
def loop_base(n: int) -> int:
|
||||
"""Base holding-register address of loop n (1-32)."""
|
||||
if 1 <= n <= 24:
|
||||
return 0x0040 + (n - 1) * 0x0100
|
||||
if 25 <= n <= 32:
|
||||
return 0x7840 + (n - 25) * 0x0100
|
||||
raise ValueError(f'loop number {n} out of range 1-32')
|
||||
|
||||
|
||||
# ─────────────────────────── Sinam (Experion export) parsing ───────────────────────────
|
||||
|
||||
# Named addresses: C3 LOOP 1 PV / C3 LOOPX 25 OPWORK / C3 TAG 542 VALUE
|
||||
# C3 MATH_VAR 22 VALUE
|
||||
# Non-named address: C3 4:0x789e / C3 4:0x78aa UINT2
|
||||
_RE_LOOP = re.compile(r'^C(\d+)\s+(LOOPX?)\s+(\d+)\s+(\w+)\s*$', re.I)
|
||||
_RE_TAG = re.compile(r'^C(\d+)\s+TAG\s+(\d+)\s+VALUE\s*$', re.I)
|
||||
_RE_VAR = re.compile(r'^C(\d+)\s+MATH_VAR\s+(\d+)\s+VALUE\s*$', re.I)
|
||||
_RE_RAW = re.compile(r'^C(\d+)\s+(\d+):0x([0-9a-fA-F]+)(?:\s+(\w+))?\s*$', re.I)
|
||||
|
||||
|
||||
def _raw_type(fmt: str | None) -> tuple[int, str]:
|
||||
"""Map a non-named address format suffix to (count, type)."""
|
||||
f = (fmt or '').upper()
|
||||
if f in ('', 'IEEEFP'):
|
||||
return 2, 'float32'
|
||||
# UINT2 / MODE / bit-field (numeric) are all single 16-bit registers.
|
||||
return 1, 'uint16'
|
||||
|
||||
|
||||
def scan_point(cells: list[str], controller_num: str) -> dict | None:
|
||||
"""Classify an Experion point from all of its non-empty cells.
|
||||
|
||||
Returns a descriptor dict identifying the point's HC900 binding for the
|
||||
requested controller, or None if the point does not reference it.
|
||||
Priority: LOOP/LOOPX > TAG > MATH_VAR > non-named raw address.
|
||||
"""
|
||||
addr_str = addr_str.strip()
|
||||
loop_refs: list[tuple[int, str]] = [] # (loop_no, mnemonic) — a point may reference
|
||||
# several loops (e.g. tuning from another loop)
|
||||
tag_n = var_n = None
|
||||
raw = None
|
||||
|
||||
# LOOP / LOOPX
|
||||
m = re.match(r'C3 (LOOP|LOOPX) (\d+) (\w+)', addr_str)
|
||||
if m:
|
||||
loop_type, n_str, param = m.group(1), int(m.group(2)), m.group(3)
|
||||
if loop_type == 'LOOP':
|
||||
assert 1 <= n_str <= 24, f'LOOP number {n_str} out of range 1-24'
|
||||
base = 0x0040 + (n_str - 1) * 0x0100
|
||||
else:
|
||||
assert 25 <= n_str <= 32, f'LOOPX number {n_str} out of range 25-32'
|
||||
base = 0x7840 + (n_str - 25) * 0x0100
|
||||
param_info = PARAM_MAP.get(param)
|
||||
if param_info is None:
|
||||
print(f' ⚠ Unknown param "{param}" in "{addr_str}", skipping')
|
||||
return None
|
||||
off, count, dtype = param_info
|
||||
addr = base + off
|
||||
return {
|
||||
'modbus_addr': addr,
|
||||
'count': count,
|
||||
'type': dtype,
|
||||
'access': 'R',
|
||||
'description': f'{loop_type} #{n_str} {param}',
|
||||
}
|
||||
for cell in cells:
|
||||
s = cell.strip()
|
||||
m = _RE_LOOP.match(s)
|
||||
if m and m.group(1) == controller_num:
|
||||
loop_refs.append((int(m.group(3)), m.group(4).upper()))
|
||||
continue
|
||||
m = _RE_TAG.match(s)
|
||||
if m and m.group(1) == controller_num:
|
||||
tag_n = int(m.group(2)); continue
|
||||
m = _RE_VAR.match(s)
|
||||
if m and m.group(1) == controller_num:
|
||||
var_n = int(m.group(2)); continue
|
||||
m = _RE_RAW.match(s)
|
||||
if m and m.group(1) == controller_num:
|
||||
count, dtype = _raw_type(m.group(4))
|
||||
raw = {'addr': int(m.group(3), 16), 'table': int(m.group(2)),
|
||||
'count': count, 'type': dtype}
|
||||
continue
|
||||
|
||||
# TAG (Signal Tag)
|
||||
m = re.match(r'C3 TAG (\d+) VALUE', addr_str)
|
||||
if m:
|
||||
n = int(m.group(1))
|
||||
addr = 0x2000 + (n - 1) * 2
|
||||
info = csv_lookup.get(('TAG', n), {})
|
||||
dtype = info.get('dtype', 'float32')
|
||||
count = 2 if dtype == 'float32' else 1
|
||||
tag_name = info.get('tag', f'SIG_TAG_{n}')
|
||||
return {
|
||||
'modbus_addr': addr,
|
||||
'count': count,
|
||||
'type': dtype,
|
||||
'access': 'R',
|
||||
'description': f'Signal Tag #{n} ({tag_name})',
|
||||
}
|
||||
|
||||
# MATH_VAR (Variable)
|
||||
m = re.match(r'C3 MATH_VAR (\d+) VALUE', addr_str)
|
||||
if m:
|
||||
n = int(m.group(1))
|
||||
addr = 0x18C0 + (n - 1) * 2
|
||||
info = csv_lookup.get(('MATH_VAR', n), {})
|
||||
dtype = info.get('dtype', 'float32')
|
||||
count = 2 if dtype == 'float32' else 1
|
||||
tag_name = info.get('tag', f'VAR_{n}')
|
||||
return {
|
||||
'modbus_addr': addr,
|
||||
'count': count,
|
||||
'type': dtype,
|
||||
'access': 'RW',
|
||||
'description': f'Variable #{n} ({tag_name})',
|
||||
}
|
||||
|
||||
# Raw address format: C3 4:0x789e
|
||||
m = re.match(r'C3 (\d+):0x([0-9a-fA-F]+)', addr_str)
|
||||
if m:
|
||||
addr = int(m.group(2), 16)
|
||||
dtype_str = addr_str.split()[-1] if 'UINT' in addr_str.upper() else 'uint16'
|
||||
return {
|
||||
'modbus_addr': addr,
|
||||
'count': 1,
|
||||
'type': 'uint16',
|
||||
'access': 'R',
|
||||
'description': f'Raw addr 0x{addr:04X}',
|
||||
}
|
||||
|
||||
print(f' ⚠ Could not parse indexed address: "{addr_str}", skipping')
|
||||
if loop_refs:
|
||||
# Primary loop = the loop whose PV this point reads (its own loop). If no PV
|
||||
# ref, fall back to the most-referenced loop. (A point can cross-reference
|
||||
# another loop's tuning constants, so "last match wins" is wrong.)
|
||||
from collections import Counter
|
||||
counts = Counter(n for n, _ in loop_refs)
|
||||
pv_loops = [n for n, mn in loop_refs if mn == 'PV']
|
||||
primary = (max(pv_loops, key=lambda n: counts[n]) if pv_loops
|
||||
else counts.most_common(1)[0][0])
|
||||
mnems = {mn for n, mn in loop_refs if n == primary}
|
||||
return {'kind': 'loop', 'n': primary, 'mnemonics': mnems}
|
||||
if tag_n is not None:
|
||||
return {'kind': 'tag', 'n': tag_n}
|
||||
if var_n is not None:
|
||||
return {'kind': 'var', 'n': var_n}
|
||||
if raw is not None:
|
||||
return {'kind': 'raw', **raw}
|
||||
return None
|
||||
|
||||
|
||||
# ─── CSV lookup ───
|
||||
def build_loop_entries(item_name: str, n: int, mnemonics: set[str]) -> list[dict]:
|
||||
"""Register every parameter of loop n, keyed on the Experion ItemName.
|
||||
|
||||
def build_csv_lookup(csv_path: Path) -> dict:
|
||||
"""Build a lookup dict from C3-All-Modbus-Map.csv.
|
||||
|
||||
Returns: {(partition, index): {'tag': str, 'dtype': str, 'addr': int}}
|
||||
The parameters Experion actually exposes (per the mnemonics referenced in the
|
||||
export) are renamed to Experion attribute names; the rest keep their HC900
|
||||
names so the whole loop block is available.
|
||||
"""
|
||||
lookup = {}
|
||||
with open(csv_path, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.reader(f)
|
||||
rows = list(reader)
|
||||
base = loop_base(n)
|
||||
|
||||
for row in rows:
|
||||
if len(row) < 6:
|
||||
continue
|
||||
hex_addr_raw = row[0].strip()
|
||||
if not hex_addr_raw.startswith('0x'):
|
||||
continue
|
||||
addr = int(hex_addr_raw, 16)
|
||||
tag = row[2].strip()
|
||||
typ = row[4].strip()
|
||||
num_str = row[5].strip()
|
||||
dtype_raw = row[6].strip() if len(row) > 6 else 'float 32'
|
||||
# offset → Experion attribute, derived from the mnemonics this point uses.
|
||||
offset_attr: dict[int, str] = {}
|
||||
for mn in mnemonics:
|
||||
attr = EXPERION_ATTR.get(mn)
|
||||
if attr and mn in MNEMONIC_OFFSET:
|
||||
offset_attr[MNEMONIC_OFFSET[mn]] = attr
|
||||
has_mode = 'LOOPSTAT' in mnemonics or 'MODEIN' in mnemonics
|
||||
|
||||
# Normalize dtype
|
||||
if 'unsigned 16' in dtype_raw.lower() or 'signed 16' in dtype_raw.lower() or 'integer' in dtype_raw.lower():
|
||||
dtype = 'uint16'
|
||||
entries = []
|
||||
for off, (suffix, count, dtype, access) in sorted(LOOP_LAYOUT.items()):
|
||||
if off == MD_READ_OFFSET and has_mode:
|
||||
continue # emitted below as ".MD" with a distinct write_addr
|
||||
name = offset_attr.get(off, suffix)
|
||||
entries.append({
|
||||
'tag': f'{item_name}.{name}',
|
||||
'addr': base + off,
|
||||
'write_addr': base + off,
|
||||
'count': count, 'type': dtype, 'access': access,
|
||||
'description': f'LOOP #{n} {suffix}',
|
||||
})
|
||||
|
||||
if has_mode:
|
||||
# MODESTAT / Loop Status (0xBE) is read-only and encodes the combined mode
|
||||
# the way Experion reads it: 0=RSP AUTO, 1=RSP MAN, 4=LSP AUTO, 5=LSP MAN
|
||||
# (bit0 = Auto/Man, bit2 = LSP/RSP). The mode is *changed* by writing the
|
||||
# dedicated RW registers below: .AutoManState (0xBA) and .RemLocSPState (0xBC).
|
||||
entries.append({
|
||||
'tag': f'{item_name}.MD',
|
||||
'addr': base + MD_READ_OFFSET,
|
||||
'write_addr': base + MD_READ_OFFSET,
|
||||
'count': 1, 'type': 'uint16', 'access': 'R',
|
||||
'description': f'LOOP #{n} Mode status (MODESTAT 0=RSP AUTO,1=RSP MAN,'
|
||||
f'4=LSP AUTO,5=LSP MAN; write via .AutoManState / .RemLocSPState)',
|
||||
})
|
||||
return entries
|
||||
|
||||
|
||||
def build_point_entry(item_name: str, desc: dict) -> dict | None:
|
||||
"""Build the register entry for a signal-tag / variable / raw point."""
|
||||
kind = desc['kind']
|
||||
if kind == 'tag':
|
||||
addr = SIGNAL_TAG_BASE + (desc['n'] - 1) * 2
|
||||
return {'tag': item_name, 'addr': addr, 'write_addr': addr,
|
||||
'count': 2, 'type': 'float32', 'access': 'R',
|
||||
'description': f'Signal Tag #{desc["n"]}'}
|
||||
if kind == 'var':
|
||||
addr = MATH_VAR_BASE + (desc['n'] - 1) * 2
|
||||
return {'tag': item_name, 'addr': addr, 'write_addr': addr,
|
||||
'count': 2, 'type': 'float32', 'access': 'RW',
|
||||
'description': f'Variable (MATH_VAR) #{desc["n"]}'}
|
||||
if kind == 'raw':
|
||||
if desc['table'] != 4:
|
||||
print(f' ⚠ {item_name}: non-named table {desc["table"]} (not holding '
|
||||
f'registers) — skipping')
|
||||
return None
|
||||
return {'tag': item_name, 'addr': desc['addr'], 'write_addr': desc['addr'],
|
||||
'count': desc['count'], 'type': desc['type'], 'access': 'R',
|
||||
'description': f'Custom address 0x{desc["addr"]:04X}'}
|
||||
return None
|
||||
|
||||
|
||||
def resolve_one(s: str, controller_num: str) -> dict | None:
|
||||
"""Resolve a single source-address string to {addr,count,type,access}, or None.
|
||||
|
||||
Used for FlexibleParameters, whose SourceAddress points at one specific
|
||||
register (a loop parameter, a signal tag, a variable, or a raw address).
|
||||
Types come from the physical HC900 layout, not the Experion-declared type.
|
||||
"""
|
||||
s = s.strip()
|
||||
m = _RE_LOOP.match(s)
|
||||
if m and m.group(1) == controller_num:
|
||||
n, mn = int(m.group(3)), m.group(4).upper()
|
||||
off = MNEMONIC_OFFSET.get(mn)
|
||||
if off is None:
|
||||
return None
|
||||
if off in LOOP_LAYOUT:
|
||||
_suf, count, dtype, access = LOOP_LAYOUT[off]
|
||||
else:
|
||||
dtype = 'float32'
|
||||
|
||||
# PID loop → map loop number
|
||||
if typ == 'PID' and num_str.startswith('#'):
|
||||
n = int(num_str[1:])
|
||||
lookup[('LOOP', n)] = {'tag': tag, 'dtype': dtype, 'addr': addr}
|
||||
|
||||
# Signal Tag
|
||||
if typ == 'Signal Tag' and num_str.isdigit():
|
||||
n = int(num_str)
|
||||
lookup[('TAG', n)] = {'tag': tag, 'dtype': dtype, 'addr': addr}
|
||||
|
||||
# Variable / Math Variable
|
||||
if typ in ('Variable', 'Math Variable') and num_str.isdigit():
|
||||
n = int(num_str)
|
||||
lookup[('MATH_VAR', n)] = {'tag': tag, 'dtype': dtype, 'addr': addr}
|
||||
|
||||
return lookup
|
||||
count, dtype, access = 2, 'float32', 'RW'
|
||||
return {'addr': loop_base(n) + off, 'count': count, 'type': dtype, 'access': access}
|
||||
m = _RE_TAG.match(s)
|
||||
if m and m.group(1) == controller_num:
|
||||
return {'addr': SIGNAL_TAG_BASE + (int(m.group(2)) - 1) * 2,
|
||||
'count': 2, 'type': 'float32', 'access': 'R'}
|
||||
m = _RE_VAR.match(s)
|
||||
if m and m.group(1) == controller_num:
|
||||
return {'addr': MATH_VAR_BASE + (int(m.group(2)) - 1) * 2,
|
||||
'count': 2, 'type': 'float32', 'access': 'RW'}
|
||||
m = _RE_RAW.match(s)
|
||||
if m and m.group(1) == controller_num and int(m.group(2)) == 4:
|
||||
count, dtype = _raw_type(m.group(4))
|
||||
return {'addr': int(m.group(3), 16), 'count': count, 'type': dtype, 'access': 'R'}
|
||||
return None
|
||||
|
||||
|
||||
# ─── Sinam parsing ───
|
||||
|
||||
def iter_sinam_c3_entries(sinam_path: Path, csv_lookup: dict) -> list[dict]:
|
||||
"""Iterate over Sinam_Tag_all.xlsx rows with C3 source addresses.
|
||||
|
||||
Yields register entries dicts ready for JSON output.
|
||||
"""
|
||||
def build_registers(sinam_path: Path, controller: str) -> list[dict]:
|
||||
controller_num = controller.lstrip('Cc') # "C3" → "3"
|
||||
wb = openpyxl.load_workbook(sinam_path, read_only=True, data_only=True)
|
||||
ws = wb['Sheet1']
|
||||
|
||||
registers = []
|
||||
seen_global = set() # {(tag_name, addr)} — global dedup
|
||||
registers: list[dict] = []
|
||||
seen_tags: set[str] = set()
|
||||
seen_loops: set[int] = set()
|
||||
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
item_name = row[0]
|
||||
cls = row[1]
|
||||
if not item_name:
|
||||
continue
|
||||
item_name = str(item_name).strip()
|
||||
if not cls:
|
||||
continue
|
||||
cls = str(cls).strip()
|
||||
cells = [str(v) for v in row if v is not None]
|
||||
|
||||
pv = str(row[28]).strip() if row[28] else ''
|
||||
op = str(row[29]).strip() if row[29] else ''
|
||||
md = str(row[30]).strip() if row[30] else ''
|
||||
|
||||
# Collect C3 source addresses
|
||||
sources = []
|
||||
for field, val in [('PV', pv), ('OP', op), ('MD', md)]:
|
||||
if val and val.upper().startswith('C3'):
|
||||
sources.append((field, val))
|
||||
|
||||
if not sources:
|
||||
# FlexibleParameters: one extra named parameter per row, keyed on the parent
|
||||
# point. col1=Class, col2=ParamName, source address sits among the cells
|
||||
# (e.g. "C4 LOOP 22 AL1SP1", "C4 TAG 359 VALUE", "C4 MATH_VAR 114 VALUE").
|
||||
cls = str(row[1]).strip() if len(row) > 1 and row[1] else ''
|
||||
if cls == 'FlexibleParameters':
|
||||
param_name = str(row[2]).strip() if len(row) > 2 and row[2] else ''
|
||||
if param_name:
|
||||
res = next((r for r in (resolve_one(c, controller_num) for c in cells) if r), None)
|
||||
if res:
|
||||
tag = f'{item_name}.{param_name}'
|
||||
if tag not in seen_tags:
|
||||
seen_tags.add(tag)
|
||||
registers.append({
|
||||
'tag': tag, 'addr': res['addr'], 'write_addr': res['addr'],
|
||||
'count': res['count'], 'type': res['type'], 'access': res['access'],
|
||||
'description': f'FlexibleParameter {param_name}',
|
||||
})
|
||||
continue
|
||||
|
||||
# Per-row dedup: track addresses to avoid OP/MD pointing to same reg
|
||||
row_addrs = set()
|
||||
desc = scan_point(cells, controller_num)
|
||||
if desc is None:
|
||||
continue
|
||||
|
||||
# Determine primary field: the main source that drops the suffix
|
||||
# AnalogPoint: PV = main analog value, OP = loop status (supplement)
|
||||
# StatusPoint: OP = digital state (main), MD = supplement (if different)
|
||||
if cls == 'AnalogPoint':
|
||||
primary_field = 'PV'
|
||||
elif cls == 'StatusPoint':
|
||||
primary_field = 'OP'
|
||||
if desc['kind'] == 'loop':
|
||||
if desc['n'] in seen_loops:
|
||||
continue # this loop already registered by another point
|
||||
seen_loops.add(desc['n'])
|
||||
new = build_loop_entries(item_name, desc['n'], desc['mnemonics'])
|
||||
else:
|
||||
primary_field = sources[0][0]
|
||||
|
||||
# For each source address, create register entry
|
||||
for field, addr_str in sources:
|
||||
result = parse_indexed_addr(addr_str, csv_lookup)
|
||||
if result is None:
|
||||
if item_name in seen_tags:
|
||||
continue
|
||||
one = build_point_entry(item_name, desc)
|
||||
new = [one] if one else []
|
||||
|
||||
addr = result['modbus_addr']
|
||||
|
||||
# Per-row address dedup (OP/MD often point to same register)
|
||||
if addr in row_addrs:
|
||||
for e in new:
|
||||
if e['tag'] in seen_tags:
|
||||
continue
|
||||
row_addrs.add(addr)
|
||||
|
||||
# Tag naming:
|
||||
# single source → ItemName
|
||||
# multi-source, primary field → ItemName (no suffix)
|
||||
# multi-source, other field → ItemName.field
|
||||
if len(sources) == 1 or field == primary_field:
|
||||
tag_name = item_name
|
||||
else:
|
||||
tag_name = f'{item_name}.{field}'
|
||||
|
||||
# Global dedup by (tag_name, addr)
|
||||
key = (tag_name, addr)
|
||||
if key in seen_global:
|
||||
continue
|
||||
seen_global.add(key)
|
||||
|
||||
entry = {
|
||||
'tag': tag_name,
|
||||
'addr': result['modbus_addr'],
|
||||
'count': result['count'],
|
||||
'type': result['type'],
|
||||
'access': result['access'],
|
||||
'description': result['description'],
|
||||
}
|
||||
registers.append(entry)
|
||||
seen_tags.add(e['tag'])
|
||||
registers.append(e)
|
||||
|
||||
wb.close()
|
||||
registers.sort(key=lambda r: r['addr'])
|
||||
return registers
|
||||
|
||||
|
||||
# ─── main ───
|
||||
# ─────────────────────────── optional CSV cross-check ───────────────────────────
|
||||
|
||||
def build(sinam_path: Path, csv_path: Path | None, output_path: Path):
|
||||
csv_lookup = build_csv_lookup(csv_path) if csv_path else {}
|
||||
def validate_against_csv(csv_path: Path) -> None:
|
||||
"""Warn if the embedded LOOP_LAYOUT disagrees with an HC Designer export.
|
||||
|
||||
print(f'Building CSV lookup from {csv_path}...')
|
||||
print(f' LOOP entries: {sum(1 for k in csv_lookup if k[0] == "LOOP")}')
|
||||
print(f' TAG entries: {sum(1 for k in csv_lookup if k[0] == "TAG")}')
|
||||
print(f' MATH_VAR entries: {sum(1 for k in csv_lookup if k[0] == "MATH_VAR")}')
|
||||
Handles both report formats: the 8-column "Modbus Full Address Report"
|
||||
(Fixed map, C3) and the 15-column "Modbus All Partitions Report" (Custom
|
||||
map, C4) which inserts a Partition Name column.
|
||||
"""
|
||||
rows = list(csv.reader(open(csv_path, encoding='utf-8-sig')))
|
||||
custom = any(len(r) > 1 and r[0].strip() == 'Hex Addr' and 'Partition Name' in r
|
||||
for r in rows)
|
||||
tag_col = 3 if custom else 2
|
||||
type_col = 5 if custom else 4
|
||||
|
||||
print(f'Parsing C3 entries from {sinam_path}...')
|
||||
registers = iter_sinam_c3_entries(sinam_path, csv_lookup)
|
||||
def norm(s):
|
||||
return re.sub(r'[^a-z0-9]', '', s.lower())
|
||||
|
||||
registers.sort(key=lambda r: r['addr'])
|
||||
expected = {off: norm(suf) for off, (suf, *_ ) in LOOP_LAYOUT.items()}
|
||||
# a few HC900-name aliases between the manual layout and HC Designer text
|
||||
aliases = {0x3e: {'opwork', 'outputb'}, 0x38: {'wspb', 'spwork'},
|
||||
0x0a: {'carbonpottemp', 'temp'}, 0x14: {'cycletime1', 'scancycletime'},
|
||||
0x28: {'cycletime2', 'cycletimescan', 'scancycletimeb'},
|
||||
0x52: {'localpctco', 'localpercentcarbmonoxide'},
|
||||
0x58: {'onoffhysteresis', 'onoffouthysterisis'},
|
||||
0x5a: {'carbpotdewpt', 'carbpotdewpt'}, 0x5c: {'stepmotortime', '3posstepmotortime'},
|
||||
0x22: {'stepdeadband', '3posstepdeadband'},
|
||||
0xbb: {'spselectstate', 'lspselectstate'},
|
||||
0xbe: {'loopstatus', 'loopstatusregister'},
|
||||
0xb9: {'antisootenable', 'antisootsplimenable'},
|
||||
0xb7: {'fuzzyenable', 'enabledisablefuzzy'},
|
||||
0xb8: {'demandtune', 'demandtunereq'}, 0x4e: {'manualreset'},
|
||||
0x50: {'feedforwardgain'}, 0x08: {'pvb', 'pv'}}
|
||||
mism = 0
|
||||
for r in rows:
|
||||
if len(r) <= type_col or not r[0].startswith('0x'):
|
||||
continue
|
||||
if r[type_col].strip() != 'PID':
|
||||
continue
|
||||
addr = int(r[0], 16)
|
||||
off = (addr - 0x40) % 0x100 # offset within the loop block
|
||||
if off not in LOOP_LAYOUT:
|
||||
continue
|
||||
suff = r[tag_col].split('.', 1)[1] if '.' in r[tag_col] else r[tag_col]
|
||||
got = norm(suff)
|
||||
ok = got == expected[off] or got in aliases.get(off, set()) \
|
||||
or expected[off] in got or got in expected[off]
|
||||
if not ok:
|
||||
mism += 1
|
||||
if mism <= 12:
|
||||
print(f' ⚠ offset 0x{off:02X}: layout={expected[off]!r} csv={got!r} '
|
||||
f'({r[0]})')
|
||||
print(f' CSV cross-check: {"OK" if mism == 0 else f"{mism} mismatch(es)"} '
|
||||
f'({"Custom" if custom else "Fixed"} map)')
|
||||
|
||||
|
||||
# ─────────────────────────── main ───────────────────────────
|
||||
|
||||
def build(sinam_path: Path, controller: str, output_path: Path,
|
||||
validate_csv: Path | None) -> None:
|
||||
if validate_csv:
|
||||
print(f'Validating embedded loop layout against {validate_csv}...')
|
||||
validate_against_csv(validate_csv)
|
||||
|
||||
print(f'Parsing {controller} points from {sinam_path}...')
|
||||
registers = build_registers(sinam_path, controller)
|
||||
|
||||
output = {
|
||||
'controller': 'C3',
|
||||
'report_generated': '2026-06-03',
|
||||
'controller': controller,
|
||||
'report_generated': datetime.date.today().isoformat(),
|
||||
'float_format': 'FP_B',
|
||||
'notes': 'Register map built from Sinam_Tag_all.xlsx SourceAddress columns (C3 only). '
|
||||
'Tags are mapped from Experion indexed addresses to fixed Modbus addresses.',
|
||||
'notes': f'Register map built from {sinam_path.name} ({controller} only). '
|
||||
'Experion point names are the keys; addresses are resolved from the '
|
||||
'HC900 fixed loop layout (named addresses) and from explicit '
|
||||
'non-named addresses in the export. .MD reads LoopStatus and writes '
|
||||
'Auto/Manual State (write_addr).',
|
||||
'register_count': len(registers),
|
||||
'registers': registers,
|
||||
}
|
||||
output_path.write_text(json.dumps(output, indent=2, ensure_ascii=False),
|
||||
encoding='utf-8')
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(output, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f'\n✓ Register map written to {output_path}')
|
||||
print(f' Total C3 registers from Sinam: {len(registers)}')
|
||||
|
||||
# Summary by type
|
||||
by_field = {}
|
||||
n_loops = len({r['description'].split()[1] for r in registers
|
||||
if r['description'].startswith('LOOP')})
|
||||
print(f'\n✓ Wrote {output_path}')
|
||||
print(f' {len(registers)} registers ({n_loops} loops expanded)')
|
||||
by_access = {}
|
||||
for r in registers:
|
||||
suffix = r['tag'].split('.')[-1] if '.' in r['tag'] else '(main)'
|
||||
by_field[suffix] = by_field.get(suffix, 0) + 1
|
||||
print(' By suffix:')
|
||||
for s, c in sorted(by_field.items(), key=lambda x: -x[1]):
|
||||
print(f' {c:4d} {s}')
|
||||
by_access[r['access']] = by_access.get(r['access'], 0) + 1
|
||||
print(f' by access: {by_access}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Build register-map.json from Sinam_Tag_all.xlsx (C3 controller)')
|
||||
parser.add_argument('--sinam', required=True,
|
||||
help='Path to Sinam_Tag_all.xlsx')
|
||||
parser.add_argument('--csv',
|
||||
default='docs/C3-All-Modbus-Map.csv',
|
||||
help='Path to C3-All-Modbus-Map.csv for type/tag lookup (optional)')
|
||||
parser.add_argument('-o', '--output', default='docs/register-map.json',
|
||||
help='Output JSON path')
|
||||
parser.add_argument('--no-csv', action='store_true',
|
||||
help='Skip CSV lookup (use defaults for type)')
|
||||
args = parser.parse_args()
|
||||
p = argparse.ArgumentParser(
|
||||
description='Build register-map.json from the Experion export (xlsx) alone')
|
||||
p.add_argument('--controller', required=True,
|
||||
help='Experion controller name / indexed-address prefix, e.g. C3')
|
||||
p.add_argument('--sinam', required=True, help='Path to Sinam_Tag_all.xlsx')
|
||||
p.add_argument('-o', '--output', default='docs/register-map.json',
|
||||
help='Output JSON path')
|
||||
p.add_argument('--validate-csv', default=None,
|
||||
help='Optional HC Designer CSV export to cross-check the embedded layout')
|
||||
args = p.parse_args()
|
||||
|
||||
csv_path = None if args.no_csv else Path(args.csv)
|
||||
if csv_path and not csv_path.exists():
|
||||
print(f'Warning: CSV path {csv_path} not found, continuing without lookup')
|
||||
csv_path = None
|
||||
|
||||
build(Path(args.sinam), csv_path, Path(args.output))
|
||||
build(Path(args.sinam), args.controller.upper(), Path(args.output),
|
||||
Path(args.validate_csv) if args.validate_csv else None)
|
||||
|
||||
Reference in New Issue
Block a user