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()