Initial commit: HC900 Crawler

Honeywell HC900을 Modbus TCP로 직접 폴링 → gRPC → C# 크롤러 → PostgreSQL.
기존 Experion OPC UA 데이터 경로를 HC900 직접 통신으로 대체.

- industrial-comm/cpp: C++ Modbus 게이트웨이 (gRPC 서버)
- src: C# .NET 8 ASP.NET Core 크롤러 + 웹 UI (3-Layer)
- mcp-server: Python FastMCP (RAG/NL2SQL/P&ID)
- 다중 컨트롤러(N-Controller) 지원

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-06-03 20:28:14 +09:00
commit 16fc7a2598
325 changed files with 126583 additions and 0 deletions

271
scripts/build_c4_mapping.py Normal file
View File

@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""
C4 컨트롤러 Experion 태그 → HC900 Modbus 레지스터 매핑 CSV 생성
입력:
docs/C4-All-Modbus-Map.csv - HC900 C4 Modbus 레지스터 전체 맵
docs/Sinam_Tag_all.xlsx - Experion 태그 목록 (SourceAddressPV/OP/MD 컬럼)
출력:
docs/c4_tag_mapping.csv - hc900_map_master 적재용 매핑 테이블
전략:
AnalogPoint (PID Loop) → 루프 번호로 표준 파라미터 세트 생성
(PV / SP / OP / RSP / MODE / STATUS)
StatusPoint (Signal Tag) → C4 TAG N → Signal Tag 주소 직접 매핑
MATH_VAR (Variable) → C4 MATH_VAR N → Variable 주소 직접 매핑
"""
import csv, re, openpyxl, argparse
from pathlib import Path
from collections import OrderedDict
# ── 경로 설정 ─────────────────────────────────────────────────────────────
BASE = Path(__file__).parent.parent / 'docs'
# ── AnalogPoint 표준 파라미터 세트 ──────────────────────────────────────────
# (tagname_suffix, hc900_suffix, offset_hex, dtype, access, param_type, description)
ANALOG_PARAMS = [
('pv', 'PV', 0x0000, 'float32', 'R', 'PV', 'Process Variable'),
('sp', 'WSP', 0x0004, 'float32', 'R/W', 'SP', 'Working Set Point'),
('op', 'Output', 0x0006, 'float32', 'R/W', 'OP', 'Output'),
('rsp', 'RSP_SP2', 0x0002, 'float32', 'R/W', 'RSP', 'Remote Set Point (SP2)'),
('lsp1', 'LSP1', 0x002A, 'float32', 'R/W', 'LSP1', 'Local SP 1'),
('lsp2', 'LSP2', 0x002C, 'float32', 'R/W', 'LSP2', 'Local SP 2'),
('dev', 'Deviation', 0x004A, 'float32', 'R', 'DEV', 'Deviation (SP-PV)'),
('pv_lo', 'PV_low_range', 0x0016, 'float32', 'R', 'PV_LO', 'PV Low Range'),
('pv_hi', 'PV_high_range', 0x0018, 'float32', 'R', 'PV_HI', 'PV High Range'),
('sp_lo', 'SP_low_limit', 0x0034, 'float32', 'R/W', 'SP_LO', 'SP Low Limit'),
('sp_hi', 'SP_high_limit', 0x0036, 'float32', 'R/W', 'SP_HI', 'SP High Limit'),
('op_lo', 'Output_Low_Limit', 0x003A, 'float32', 'R/W', 'OP_LO', 'Output Low Limit'),
('op_hi', 'Output_High_Limit', 0x003C, 'float32', 'R/W', 'OP_HI', 'Output High Limit'),
('alm1', 'Alarm_1_SP1', 0x001A, 'float32', 'R/W', 'ALM1', 'Alarm 1 SP1'),
('alm2', 'Alarm_2_SP1', 0x002E, 'float32', 'R/W', 'ALM2', 'Alarm 2 SP1'),
('mode', 'Auto_Man_State', 0x00BA, 'uint16', 'R/W', 'MODE', 'Auto/Manual State (0=Man,1=Auto)'),
('status', 'Loop_Status_Register', 0x00BE, 'uint16', 'R', 'STATUS', 'Loop Status Register'),
]
# Experion 소스 파라미터명 → HC900 오프셋 (SourceAddressMD 처리용)
EXP_PARAM_MAP = {
'RESET1': (0x0010, 'Reset_1', 'float32', 'R/W', 'RESET1', 'Reset 1'),
'RATE1': (0x0012, 'Rate_1', 'float32', 'R/W', 'RATE1', 'Rate 1'),
'AMSTAT': (0x00BA, 'Auto_Man_State', 'uint16', 'R/W', 'MODE', 'Auto/Manual State'),
'LOOPSTAT': (0x00BE, 'Loop_Status_Register', 'uint16', 'R', 'STATUS', 'Loop Status Register'),
'PV': (0x0000, 'PV', 'float32', 'R', 'PV', 'Process Variable'),
'WSP': (0x0004, 'WSP', 'float32', 'R/W', 'SP', 'Working Set Point'),
'OP': (0x0006, 'Output', 'float32', 'R/W', 'OP', 'Output'),
}
def load_modbus_map(csv_path: Path):
"""C4-All-Modbus-Map.csv → loop_map, sig_map, var_map"""
with open(csv_path, encoding='utf-8-sig') as f:
rows = list(csv.reader(f))
header_idx = next(i for i,r in enumerate(rows) if r and r[0]=='Hex Addr')
data = rows[header_idx+1:]
loop_map = {} # loop_num → {'tag': str, 'base': int, 'desc': str}
sig_map = {} # tag_num → {'tag': str, 'addr': int, 'dtype': str, 'desc': str}
var_map = {} # var_num → {'tag': str, 'addr': int, 'desc': str}
for r in data:
if len(r) < 9: continue
partition = r[2].strip()
tag_name = r[3].strip()
if not tag_name: continue
addr_hex = r[0].strip()
try:
addr = int(addr_hex, 16)
except ValueError:
continue
desc = r[4].strip()
dtype = r[7].strip()
access = r[8].strip()
# PID Loop 1-24
if partition == 'Loops 1-24' and tag_name.endswith('.PV'):
n = (addr - 0x0040) // 0x0100 + 1
base_tag = tag_name.replace('.PV', '')
loop_map[n] = {'tag': base_tag, 'base': addr, 'desc': desc}
# PID Loop 25-32
elif partition == 'Loops 25-32' and tag_name.endswith('.PV'):
n = (addr - 0x7840) // 0x0100 + 25
base_tag = tag_name.replace('.PV', '')
loop_map[n] = {'tag': base_tag, 'base': addr, 'desc': desc}
# Signal Tags 1-1000
elif partition == 'Signal Tags 1-1000':
n = (addr - 0x2000) // 2 + 1
dt = 'float32' if 'float' in dtype.lower() else 'uint16'
sig_map[n] = {'tag': tag_name, 'addr': addr, 'dtype': dt,
'access': access, 'desc': desc}
# Variables 1-600
elif partition == 'Variables 1-600':
n = (addr - 0x18C0) // 2 + 1
var_map[n] = {'tag': tag_name, 'addr': addr, 'desc': desc}
return loop_map, sig_map, var_map
def parse_indexed(src: str):
"""Indexed Address → (partition, n, param)"""
m = re.match(r'\S+\s+(LOOP|LOOPX)\s+(\d+)\s+(\w+)', src.strip())
if m: return m.group(1), int(m.group(2)), m.group(3)
m = re.match(r'\S+\s+TAG\s+(\d+)\s+VALUE', src.strip())
if m: return 'TAG', int(m.group(1)), 'VALUE'
m = re.match(r'\S+\s+MATH_VAR\s+(\d+)\s+VALUE', src.strip())
if m: return 'MATH_VAR', int(m.group(1)), 'VALUE'
return None, None, None
def get_loop_num(spv: str, smd: str) -> tuple[str|None, int|None]:
"""SourceAddressPV/MD 에서 루프 번호 추출 → (partition, n)"""
for src in [spv, smd]:
if not src: continue
part, n, _ = parse_indexed(src)
if part in ('LOOP', 'LOOPX'):
return part, n
return None, None
def build_mapping(modbus_csv: Path, sinam_xlsx: Path) -> list[dict]:
loop_map, sig_map, var_map = load_modbus_map(modbus_csv)
wb = openpyxl.load_workbook(sinam_xlsx, read_only=True, data_only=True)
ws = wb.active
xlsx_rows = list(ws.iter_rows(values_only=True))
headers = xlsx_rows[1]
col = {h: i for i, h in enumerate(headers) if h}
rows_out = []
seen_tagnames = set()
def emit(tagname: str, hc900_tag: str, modbus_addr: int,
dtype: str, access: str, loop_no, param_type: str,
description: str, experion_src: str):
if tagname in seen_tagnames:
return
seen_tagnames.add(tagname)
rows_out.append(OrderedDict([
('tagname', tagname),
('hc900_tag', hc900_tag),
('modbus_addr', modbus_addr),
('modbus_addr_hex', f'0x{modbus_addr:04X}'),
('data_type', dtype),
('access', access),
('loop_no', loop_no if loop_no else ''),
('param_type', param_type),
('description', description),
('experion_src', experion_src),
('is_active', 'TRUE'),
]))
for row in xlsx_rows[2:]:
item_name = row[col['ItemName']]
cls = row[col['Class']]
spv = str(row[col['SourceAddressPV']] or '').strip()
sop = str(row[col['SourceAddressOP']] or '').strip()
smd = str(row[col['SourceAddressMD']] or '').strip()
if not item_name or not cls: continue
# C4 참조만 처리
if not any('C4 ' in s for s in [spv, sop, smd]):
continue
base_name = str(item_name).strip().lower()
# ── AnalogPoint (PID Loop) ────────────────────────────────────────
if cls == 'AnalogPoint':
loop_part, loop_n = get_loop_num(spv, smd)
if loop_n is None:
continue
linfo = loop_map.get(loop_n)
if linfo is None:
continue
base_tag = linfo['tag']
base_addr = linfo['base']
# 표준 파라미터 세트 생성
for suffix, hc_suffix, offset, dtype, access, ptype, pdesc in ANALOG_PARAMS:
tagname = f'{base_name}.{suffix}'
hc900_tag = f'{base_tag}.{hc_suffix}'
addr = base_addr + offset
src_str = f'{loop_part} {loop_n}{base_tag}'
emit(tagname, hc900_tag, addr, dtype, access, loop_n, ptype, pdesc, src_str)
# ── StatusPoint / 기타 ────────────────────────────────────────────
else:
processed_srcs = set()
for src in [spv, sop, smd]:
if not src or 'C4 ' not in src or src in processed_srcs:
continue
processed_srcs.add(src)
part, n, param = parse_indexed(src)
if part is None:
continue
if part == 'TAG':
info = sig_map.get(n)
hc900_tag = info['tag'] if info else f'SIG_TAG_{n}'
addr = 0x2000 + (n-1)*2
dtype = info['dtype'] if info else 'float32'
access = 'R'
pdesc = info['desc'] if info else ''
emit(base_name, hc900_tag, addr, dtype, access, None, 'SIG', pdesc, src)
elif part == 'MATH_VAR':
info = var_map.get(n)
hc900_tag = info['tag'] if info else f'VAR_{n}'
addr = 0x18C0 + (n-1)*2
pdesc = info['desc'] if info else ''
emit(base_name, hc900_tag, addr, 'float32', 'R/W', None, 'VAR', pdesc, src)
elif part in ('LOOP', 'LOOPX'):
# AnalogPoint가 아닌데 LOOP 소스 참조 (드물지만 처리)
linfo = loop_map.get(n)
if linfo is None: continue
pinfo = EXP_PARAM_MAP.get(param)
if pinfo is None: continue
off, hc_suffix, dtype, access, ptype, pdesc = pinfo
addr = linfo['base'] + off
tagname = f'{base_name}' if src == spv else f'{base_name}.{param.lower()}'
hc900_tag = f"{linfo['tag']}.{hc_suffix}"
emit(tagname, hc900_tag, addr, dtype, access, n, ptype, pdesc, src)
return rows_out
def main():
parser = argparse.ArgumentParser(description='C4 태그 매핑 CSV 생성')
parser.add_argument('--modbus-csv', default=str(BASE/'C4-All-Modbus-Map.csv'))
parser.add_argument('--sinam-xlsx', default=str(BASE/'Sinam_Tag_all.xlsx'))
parser.add_argument('-o', '--output', default=str(BASE/'c4_tag_mapping.csv'))
args = parser.parse_args()
rows = build_mapping(Path(args.modbus_csv), Path(args.sinam_xlsx))
with open(args.output, 'w', newline='', encoding='utf-8') as f:
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
w.writeheader()
w.writerows(rows)
# 통계
from collections import Counter
ptypes = Counter(r['param_type'] for r in rows)
loop_rows = sum(1 for r in rows if r['loop_no'])
sig_rows = sum(1 for r in rows if r['param_type'] == 'SIG')
var_rows = sum(1 for r in rows if r['param_type'] == 'VAR')
print(f'총 매핑: {len(rows)}')
print(f' Loop 파라미터: {loop_rows}개 ({len(set(r["loop_no"] for r in rows if r["loop_no"]))} loops × 최대 {len(ANALOG_PARAMS)} params)')
print(f' Signal Tag: {sig_rows}')
print(f' Variable: {var_rows}')
print(f' param_type 분포: {dict(ptypes.most_common())}')
print(f'저장: {args.output}')
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""
HC900 Register Map Builder
Converts HC Designer CSV exports (SummaryFunctionBlockReport, SignalTags, Variables)
into a unified JSON register map for the Modbus TCP gateway.
Usage:
python3 build_register_map.py \
--loop-csv ../docs/SummaryFucntionBlockReport.csv \
--signal-csv ../docs/SignalTags.csv \
--variable-csv ../docs/Variables.csv \
-o ../docs/register-map.json
"""
import csv
import json
import argparse
from pathlib import Path
# ─── PID loop parameter offset map (based on manual Table 6-3) ───
LOOP_PARAM_OFFSETS = [
# (name, offset_hex, type, access, description)
# PV / SP / OP
("PV", 0x00, "float32", "R", "Process Variable"),
("RSP", 0x02, "float32", "R", "Remote Set Point (SP2)"),
("SP", 0x04, "float32", "RW", "Working Set Point"),
("OP", 0x06, "float32", "RW", "Output"),
# Gains / tuning
("GAIN1", 0x0C, "float32", "RW", "Gain #1 / Prop Band #1"),
("DIR", 0x0E, "float32", "R", "Direction (0=Direct, 1=Reverse)"),
("RESET1", 0x10, "float32", "RW", "Reset #1"),
("RATE1", 0x12, "float32", "RW", "Rate #1"),
# Ranges
("PV_LO", 0x16, "float32", "R", "PV Low Range"),
("PV_HI", 0x18, "float32", "R", "PV High Range"),
# Alarms
("ALM1_SP1", 0x1A, "float32", "RW", "Alarm #1 SP #1"),
("ALM1_SP2", 0x1C, "float32", "RW", "Alarm #1 SP #2"),
# Local SPs
("LSP1", 0x2A, "float32", "RW", "Local SP #1"),
("LSP2", 0x2C, "float32", "RW", "Local SP #2"),
# Limits
("SP_LO", 0x34, "float32", "RW", "SP Low Limit"),
("SP_HI", 0x36, "float32", "RW", "SP High Limit"),
("OP_LO", 0x3A, "float32", "RW", "Output Low Limit"),
("OP_HI", 0x3C, "float32", "RW", "Output High Limit"),
("DEV", 0x4A, "float32", "R", "Deviation (SP-PV)"),
# Bit-packed status / control
("FUZZY_EN", 0xB7, "uint16", "RW", "Fuzzy Enable"),
("ATUNE_REQ", 0xB8, "uint16", "RW", "Autotune Request"),
("MODE", 0xBA, "uint16", "RW", "Auto/Manual (0=Man, 1=Auto)"),
("SP_STATE", 0xBB, "uint16", "RW", "Set Point State (0=SP1, 1=SP2)"),
("RSP_STATE", 0xBC, "uint16", "RW", "Remote/Local SP (0=LSP, 1=RSP)"),
("LOOP_STATUS", 0xBE, "uint16", "R", "Loop Status (bit packed)"),
]
# ─── helpers ───
def hex_addr(s: str) -> int:
return int(s.strip(), 16)
def strip_header(rows, header_marker: str):
"""Skip rows until we find the actual data header."""
for i, row in enumerate(rows):
if row and row[0] == header_marker:
return rows[i + 1:]
return rows
# ─── parsers ───
def parse_loop_csv(path: Path) -> list[dict]:
"""Parse SummaryFunctionBlockReport.csv → list of loop dicts."""
loops = []
with open(path, "r", encoding="utf-8-sig") as f:
reader = csv.reader(f)
rows = list(reader)
for row in rows:
if len(row) < 6:
continue
hex_addr_raw = row[0].strip()
if not hex_addr_raw.startswith("0x"):
continue
tag = row[2].strip()
desc = row[3].strip()
typ = row[4].strip()
num = row[5].strip()
if typ != "PID":
continue
addr = hex_addr(hex_addr_raw)
loops.append({
"tag": tag,
"description": desc,
"start_addr": addr,
"loop_number": int(num.replace("#", "")),
})
return loops
def parse_signal_or_variable_csv(path: Path, access: str) -> list[dict]:
"""Parse SignalTags.csv or Variables.csv → list of tag dicts."""
tags = []
with open(path, "r", encoding="utf-8-sig") as f:
reader = csv.reader(f)
rows = list(reader)
for row in rows:
if len(row) < 8:
continue
hex_addr_raw = row[0].strip()
if not hex_addr_raw.startswith("0x"):
continue
tag = row[2].strip()
desc = row[3].strip()
typ = row[6].strip() if len(row) > 6 else "float 32"
eu = row[7].strip() if len(row) > 7 else ""
# normalise type
if "float" in typ.lower():
data_type = "float32"
register_count = 2
elif "signed 16" in typ.lower() or "unsigned 16" in typ.lower() or "integer" in typ.lower():
data_type = "uint16"
register_count = 1
else:
data_type = "float32"
register_count = 2
addr = hex_addr(hex_addr_raw)
tags.append({
"tag": tag,
"description": desc,
"addr": addr,
"count": register_count,
"type": data_type,
"access": access,
"eu": eu,
})
return tags
# ─── main ───
def build(loop_path, signal_path, variable_path, output_path):
loops = parse_loop_csv(loop_path)
signals = parse_signal_or_variable_csv(signal_path, "R")
variables = parse_signal_or_variable_csv(variable_path, "RW")
registers = []
# 1. Expand PID loops into individual parameters
for loop in loops:
base = loop["start_addr"]
for param in LOOP_PARAM_OFFSETS:
name, off, dtype, access, desc_template = param
reg_count = 2 if dtype == "float32" else 1
addr = base + off
tag_name = f"{loop['tag']}.{name}"
description = f"{loop['description']} / {desc_template}" if loop['description'] else desc_template
registers.append({
"tag": tag_name,
"addr": addr,
"count": reg_count,
"type": dtype,
"access": access,
"description": description,
})
# 2. Signal Tags (read-only)
for sig in signals:
registers.append({
"tag": sig["tag"],
"addr": sig["addr"],
"count": sig["count"],
"type": sig["type"],
"access": sig["access"],
"description": sig["description"],
"eu": sig["eu"],
})
# 3. Variables (R/W)
for var in variables:
registers.append({
"tag": var["tag"],
"addr": var["addr"],
"count": var["count"],
"type": var["type"],
"access": var["access"],
"description": var["description"],
"eu": var["eu"],
})
# sort by address
registers.sort(key=lambda r: r["addr"])
output = {
"controller": "HC900-C70 Rev 4.4x",
"report_generated": "2026-06-01",
"float_format": "FP_B",
"notes": "FP_B = IEEE 754 Big Endian (byte order 4,3,2,1). "
"Address is the first register (0-based). "
"float32 uses 2 consecutive registers.",
"register_count": len(registers),
"registers": registers,
}
with open(output_path, "w", encoding="utf-8") as f:
json.dump(output, f, indent=2, ensure_ascii=False)
print(f"✓ Register map written to {output_path}")
print(f" Loops: {len(loops)}")
print(f" Signals: {len(signals)}")
print(f" Variables: {len(variables)}")
print(f" Total registers in map: {len(registers)}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Build HC900 register map JSON from CSV exports")
parser.add_argument("--loop-csv", required=True, help="SummaryFunctionBlockReport.csv")
parser.add_argument("--signal-csv", required=True, help="SignalTags.csv")
parser.add_argument("--variable-csv", required=True, help="Variables.csv")
parser.add_argument("-o", "--output", default="register-map.json", help="Output JSON path")
args = parser.parse_args()
build(args.loop_csv, args.signal_csv, args.variable_csv, args.output)

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""
HC900 Register Map Builder — C3/C4 Full Modbus Map CSV → register-map.json
Reads the "Modbus Full Address Report" CSV exported from HC Designer
(C3-All-Modbus-Map.csv / C4-All-Modbus-Map.csv) and produces the
JSON register map consumed by the hc900_gateway.
"""
import csv
import json
import argparse
from pathlib import Path
def parse_full_map(path: Path) -> dict:
registers = []
with open(path, "r", encoding="utf-8-sig") as f:
reader = csv.reader(f)
rows = list(reader)
# Determine which section we're in
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()
desc = row[3].strip()
typ = row[4].strip()
num = row[5].strip()
if typ == "PID":
dtype_raw = row[6].strip()
access_raw = row[7].strip() if len(row) > 7 else "R"
if "unsigned 16" in dtype_raw.lower() or "integer" in dtype_raw.lower() or "signed 16" in dtype_raw.lower():
dtype = "uint16"
count = 1
else:
dtype = "float32"
count = 2
access = "RW" if "w" in access_raw.lower() else "R"
registers.append({
"tag": tag, "addr": addr, "count": count,
"type": dtype, "access": access, "description": desc,
})
elif typ in ("Signal Tag", "Signal"):
dtype_raw = row[6].strip()
if "unsigned 16" in dtype_raw.lower() or "signed 16" in dtype_raw.lower():
dtype = "uint16"
count = 1
else:
dtype = "float32"
count = 2
registers.append({
"tag": tag, "addr": addr, "count": count,
"type": dtype, "access": "R", "description": desc,
})
elif typ in ("Variable", "Math Variable"):
dtype_raw = row[6].strip()
if "unsigned 16" in dtype_raw.lower() or "signed 16" in dtype_raw.lower():
dtype = "uint16"
count = 1
else:
dtype = "float32"
count = 2
registers.append({
"tag": tag, "addr": addr, "count": count,
"type": dtype, "access": "RW", "description": desc,
})
registers.sort(key=lambda r: r["addr"])
# Detect controller name from header
controller = "HC900-C70"
with open(path, "r", encoding="utf-8-sig") as f:
for line in f:
if line.startswith("File Name:"):
controller = line.split(",", 1)[1].strip()
break
return {
"controller": controller,
"float_format": "FP_B",
"notes": "FP_B = IEEE 754 Big Endian (byte order 4,3,2,1). "
"Address is the first register (0-based). "
"float32 uses 2 consecutive registers.",
"register_count": len(registers),
"registers": registers,
}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Build register-map.json from HC Designer Full Modbus Map CSV")
parser.add_argument("csv", help="Path to the *-All-Modbus-Map.csv")
parser.add_argument("-o", "--output", default="register-map.json", help="Output JSON path")
args = parser.parse_args()
data = parse_full_map(Path(args.csv))
with open(args.output, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
pid_count = sum(1 for r in data["registers"] if r["tag"] and any(c in r["tag"] for c in ".-_"))
print(f"✓ Register map written to {args.output}")
print(f" Controller: {data['controller']}")
print(f" Total registers: {data['register_count']}")

View File

@@ -0,0 +1,322 @@
#!/usr/bin/env python3
"""
Build register-map.json from Sinam_Tag_all.xlsx SourceAddress columns.
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.
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
"""
import re
import json
import argparse
import csv
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'),
}
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.
csv_lookup: {(partition, index): {'addr': int, 'dtype': str, 'tag': str}}
"""
addr_str = addr_str.strip()
# 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}',
}
# 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')
return None
# ─── CSV lookup ───
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}}
"""
lookup = {}
with open(csv_path, 'r', encoding='utf-8-sig') as f:
reader = csv.reader(f)
rows = list(reader)
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'
# Normalize dtype
if 'unsigned 16' in dtype_raw.lower() or 'signed 16' in dtype_raw.lower() or 'integer' in dtype_raw.lower():
dtype = 'uint16'
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
# ─── 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.
"""
wb = openpyxl.load_workbook(sinam_path, read_only=True, data_only=True)
ws = wb['Sheet1']
registers = []
seen_global = set() # {(tag_name, addr)} — global dedup
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()
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:
continue
# Per-row dedup: track addresses to avoid OP/MD pointing to same reg
row_addrs = set()
# 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'
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:
continue
addr = result['modbus_addr']
# Per-row address dedup (OP/MD often point to same register)
if addr in row_addrs:
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)
wb.close()
return registers
# ─── main ───
def build(sinam_path: Path, csv_path: Path | None, output_path: Path):
csv_lookup = build_csv_lookup(csv_path) if csv_path else {}
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")}')
print(f'Parsing C3 entries from {sinam_path}...')
registers = iter_sinam_c3_entries(sinam_path, csv_lookup)
registers.sort(key=lambda r: r['addr'])
output = {
'controller': 'C3',
'report_generated': '2026-06-03',
'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.',
'register_count': len(registers),
'registers': registers,
}
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 = {}
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}')
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()
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))

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""
xlsx의 StatusPoint DescriptorState0~7 → hc900.tag_metadata 로드
실행: python3 scripts/load_state_labels.py
"""
import openpyxl
import psycopg2
from pathlib import Path
XLSX_PATH = Path(__file__).parent.parent / "docs" / "Sinam_Tag_all.xlsx"
DB_DSN = "host=localhost port=5432 dbname=iiot_platform user=postgres password=postgres"
wb = openpyxl.load_workbook(XLSX_PATH, read_only=True, data_only=True)
ws = wb['Sheet1']
rows = list(ws.iter_rows(values_only=True))
headers = rows[1]
col = {h: i for i, h in enumerate(headers) if h}
conn = psycopg2.connect(DB_DSN)
cur = conn.cursor()
inserted = 0
for row in rows[2:]:
name = str(row[col['ItemName']] or '').strip()
cls = str(row[col['Class']] or '').strip()
if cls != 'StatusPoint' or not name:
continue
base_tag = name.lower()
for i in range(8):
key = f'DescriptorState{i}'
if key not in col:
continue
val = row[col[key]]
if val is None or val == '':
continue
cur.execute("""
INSERT INTO hc900.tag_metadata (base_tag, attribute, value)
VALUES (%s, %s, %s)
ON CONFLICT (base_tag, attribute) DO UPDATE SET value = EXCLUDED.value
""", (base_tag, f'state{i}', str(val)))
inserted += 1
conn.commit()
cur.close()
conn.close()
print(f"완료: {inserted}개 상태 레이블 저장")

View File

@@ -0,0 +1,121 @@
-- ============================================================================
-- Migration: Add controller_id columns for multi-controller support
-- Run this once against the existing HC900 database.
-- ============================================================================
BEGIN;
-- 1. realtime_table: add controller_id + UNIQUE(controller_id, tagname)
ALTER TABLE realtime_table
ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1';
-- Drop any existing unique constraint on tagname alone (idempotent wrapper)
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'realtime_table'
AND indexname = 'idx_realtime_table_tagname_unique'
) THEN
DROP INDEX IF EXISTS idx_realtime_table_tagname_unique;
END IF;
IF EXISTS (
SELECT 1 FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
WHERE t.relname = 'realtime_table' AND c.contype = 'u'
) THEN
-- There's a unique constraint; we can't easily drop it by name unless we know it.
-- Use a safe approach: drop any unique constraint on tagname
END IF;
END $$;
-- Add the new composite UNIQUE (required for ON CONFLICT (controller_id, tagname) DO UPDATE)
-- Skip if already exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'realtime_table'
AND indexname = 'idx_realtime_table_ctrl_tag_unique'
) THEN
CREATE UNIQUE INDEX idx_realtime_table_ctrl_tag_unique
ON realtime_table(controller_id, tagname);
END IF;
END $$;
-- 2. history_table: add controller_id with default 'HC1'
ALTER TABLE history_table
ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1';
-- 3. event_history_table: add controller_id
ALTER TABLE event_history_table
ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1';
-- 4. tag_metadata: add controller_id
ALTER TABLE tag_metadata
ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1';
-- 5. hc900_map_master: add controller_id
ALTER TABLE hc900_map_master
ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1';
-- 6. v_tag_summary: recreate with controller_id
DROP VIEW IF EXISTS v_tag_summary CASCADE;
CREATE VIEW v_tag_summary AS
SELECT
rt_base.base_tag,
pv_rt.livevalue AS pv,
sp_rt.livevalue AS sp,
op_rt.livevalue AS op,
instate0_rt.livevalue AS instate0,
instate1_rt.livevalue AS instate1,
instate2_rt.livevalue AS instate2,
desc_md.value AS description,
area_md.value AS area,
sub_area_md.value AS sub_area,
rt_base.controller_id
FROM (SELECT DISTINCT split_part(tagname, '.', 1) AS base_tag, controller_id FROM realtime_table) rt_base
LEFT JOIN realtime_table pv_rt ON pv_rt.tagname = rt_base.base_tag || '.pv' AND pv_rt.controller_id = rt_base.controller_id
LEFT JOIN realtime_table sp_rt ON sp_rt.tagname = rt_base.base_tag || '.sp' AND sp_rt.controller_id = rt_base.controller_id
LEFT JOIN realtime_table op_rt ON op_rt.tagname = rt_base.base_tag || '.op' AND op_rt.controller_id = rt_base.controller_id
LEFT JOIN realtime_table instate0_rt ON instate0_rt.tagname = rt_base.base_tag || '.instate0' AND instate0_rt.controller_id = rt_base.controller_id
LEFT JOIN realtime_table instate1_rt ON instate1_rt.tagname = rt_base.base_tag || '.instate1' AND instate1_rt.controller_id = rt_base.controller_id
LEFT JOIN realtime_table instate2_rt ON instate2_rt.tagname = rt_base.base_tag || '.instate2' AND instate2_rt.controller_id = rt_base.controller_id
LEFT JOIN tag_metadata desc_md ON desc_md.base_tag = rt_base.base_tag AND desc_md.attribute = 'desc'
LEFT JOIN tag_metadata area_md ON area_md.base_tag = rt_base.base_tag AND area_md.attribute = 'area'
LEFT JOIN tag_metadata sub_area_md ON sub_area_md.base_tag = rt_base.base_tag AND sub_area_md.attribute = 'sub_area';
-- 7. v_plant_running_state: recreate (depends on v_tag_summary)
DROP VIEW IF EXISTS v_plant_running_state;
CREATE VIEW v_plant_running_state AS
WITH pump_state AS (
SELECT
trim(split_part(area, '|', 2)) AS area_code,
area AS area_raw,
base_tag,
pv,
controller_id
FROM v_tag_summary
WHERE area IS NOT NULL
AND (base_tag LIKE 'p-%' OR base_tag LIKE 'vp-%')
AND pv ~ '\|\s*(L-RUN|R-RUN|L-STOP|R-STOP|L-TRIP|R-TRIP)\s*\|'
)
SELECT
area_code,
MAX(area_raw) AS area_raw,
COUNT(*) AS total_pumps,
COUNT(*) FILTER (WHERE pv ~ '\|\s*[LR]-RUN\s*\|') AS running_pumps,
COUNT(*) FILTER (WHERE pv ~ '\|\s*[LR]-TRIP\s*\|') AS tripped_pumps,
COUNT(*) FILTER (WHERE pv ~ '\|\s*(L-STOP|R-STOP)\s*\|') AS stopped_pumps,
CASE
WHEN COUNT(*) FILTER (WHERE pv ~ '\|\s*[LR]-RUN\s*\|') > 0 THEN 'RUNNING'
WHEN COUNT(*) FILTER (WHERE pv ~ '\|\s*[LR]-TRIP\s*\|') > 0 THEN 'TRIPPED'
ELSE 'STOPPED'
END AS status,
array_agg(base_tag) FILTER (WHERE pv ~ '\|\s*[LR]-RUN\s*\|') AS running_pump_tags
FROM pump_state
WHERE area_code IS NOT NULL AND area_code <> ''
GROUP BY area_code
ORDER BY area_code;
COMMIT;