- 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 컨트롤러 설정 추가
488 lines
22 KiB
Python
488 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Build register-map.json from the Experion point export (Sinam_Tag_all.xlsx) ALONE.
|
|
|
|
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 --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 datetime
|
|
from pathlib import Path
|
|
|
|
import openpyxl
|
|
|
|
# ─────────────────────────── 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
|
|
}
|
|
|
|
# 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
|
|
}
|
|
|
|
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.
|
|
"""
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
|
|
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.
|
|
|
|
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.
|
|
"""
|
|
base = loop_base(n)
|
|
|
|
# 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
|
|
|
|
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:
|
|
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
|
|
|
|
|
|
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: 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]
|
|
if not item_name:
|
|
continue
|
|
item_name = str(item_name).strip()
|
|
cells = [str(v) for v in row if v is not None]
|
|
|
|
# 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
|
|
|
|
desc = scan_point(cells, controller_num)
|
|
if desc is None:
|
|
continue
|
|
|
|
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:
|
|
if item_name in seen_tags:
|
|
continue
|
|
one = build_point_entry(item_name, desc)
|
|
new = [one] if one else []
|
|
|
|
for e in new:
|
|
if e['tag'] in seen_tags:
|
|
continue
|
|
seen_tags.add(e['tag'])
|
|
registers.append(e)
|
|
|
|
wb.close()
|
|
registers.sort(key=lambda r: r['addr'])
|
|
return registers
|
|
|
|
|
|
# ─────────────────────────── optional CSV cross-check ───────────────────────────
|
|
|
|
def validate_against_csv(csv_path: Path) -> None:
|
|
"""Warn if the embedded LOOP_LAYOUT disagrees with an HC Designer export.
|
|
|
|
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
|
|
|
|
def norm(s):
|
|
return re.sub(r'[^a-z0-9]', '', s.lower())
|
|
|
|
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': controller,
|
|
'report_generated': datetime.date.today().isoformat(),
|
|
'float_format': 'FP_B',
|
|
'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')
|
|
|
|
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:
|
|
by_access[r['access']] = by_access.get(r['access'], 0) + 1
|
|
print(f' by access: {by_access}')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
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()
|
|
|
|
build(Path(args.sinam), args.controller.upper(), Path(args.output),
|
|
Path(args.validate_csv) if args.validate_csv else None)
|