#!/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)