| import gradio as gr |
| import json |
| from lxml import etree |
|
|
| |
|
|
| def get_ied_ip(root, ns, ied_name): |
| """Extrae la IP del IED""" |
| for cap in root.findall('.//scl:ConnectedAP', ns): |
| if cap.get('iedName') == ied_name: |
| address = cap.find('.//scl:Address', ns) |
| if address is not None: |
| for p in address.findall('.//scl:P', ns): |
| if p.get('type') == 'IP': |
| return p.text |
| return None |
|
|
| def process_da_type(children_dict, da_type_id, datype_cache, ns): |
| """Procesa recursivamente DAType para extraer todos DA anidados""" |
| da_type_elem = datype_cache.get(da_type_id) |
| if da_type_elem is None: |
| return |
|
|
| |
| for bda in da_type_elem.findall('scl:BDA', ns): |
| bda_name = bda.get('name') |
| bda_type = bda.get('type', '') |
| bda_btype = bda.get('bType', '') |
| |
| bda_data = { |
| '_type': 'Data Attribute', |
| '_type_short': 'DA', |
| '_display': bda_name, |
| '_children': {} |
| } |
|
|
| |
| if bda_type and bda_type in datype_cache: |
| process_da_type(bda_data['_children'], bda_type, datype_cache, ns) |
|
|
| children_dict[bda_name] = bda_data |
|
|
| def process_do_type(children_dict, do_type_id, dotype_cache, datype_cache, ns): |
| """Procesa recursivamente DOType para extraer todos DA""" |
| do_type_elem = dotype_cache.get(do_type_id) |
| if do_type_elem is None: |
| return |
|
|
| |
| for da in do_type_elem.findall('scl:DA', ns): |
| da_name = da.get('name') |
| da_fc = da.get('fc', '') |
| da_type = da.get('type', '') |
| da_btype = da.get('bType', '') |
| |
| display = f"{da_name} [{da_fc}]" if da_fc else da_name |
|
|
| da_data = { |
| '_type': 'Data Attribute', |
| '_type_short': 'DA', |
| '_display': display, |
| '_children': {} |
| } |
|
|
| |
| if da_type and da_type in datype_cache: |
| process_da_type(da_data['_children'], da_type, datype_cache, ns) |
|
|
| children_dict[da_name] = da_data |
|
|
| |
| for sdo in do_type_elem.findall('scl:SDO', ns): |
| sdo_name = sdo.get('name') |
| sdo_type = sdo.get('type') |
|
|
| sdo_data = { |
| '_type': 'Data Attribute', |
| '_type_short': 'DA', |
| '_display': sdo_name, |
| '_children': {} |
| } |
|
|
| |
| process_do_type(sdo_data['_children'], sdo_type, dotype_cache, datype_cache, ns) |
|
|
| children_dict[sdo_name] = sdo_data |
|
|
| def get_data_objects_clean_cached(lntype_cache, dotype_cache, datype_cache, ns, ln_type): |
| """Extrae Data Objects del LNodeType usando cache""" |
| data_objects = {} |
|
|
| lntype = lntype_cache.get(ln_type) |
| if lntype is not None: |
| for do in lntype.findall('scl:DO', ns): |
| do_name = do.get('name') |
| do_type = do.get('type') |
|
|
| do_data = { |
| '_type': 'Data Object', |
| '_type_short': 'DO', |
| '_display': do_name, |
| '_children': {} |
| } |
|
|
| |
| process_do_type(do_data['_children'], do_type, dotype_cache, datype_cache, ns) |
|
|
| data_objects[do_name] = do_data |
|
|
| return data_objects |
|
|
| def get_data_objects_clean(root, ns, ln_type): |
| """Extrae Data Objects del LNodeType""" |
| data_objects = {} |
|
|
| lntype = root.find(f".//scl:LNodeType[@id='{ln_type}']", ns) |
| if lntype is not None: |
| for do in lntype.findall('.//scl:DO', ns): |
| do_name = do.get('name') |
| do_type = do.get('type') |
|
|
| do_data = { |
| '_type': 'Data Object', |
| '_type_short': 'DO', |
| '_display': do_name, |
| '_children': {} |
| } |
|
|
| |
| do_type_elem = root.find(f".//scl:DOType[@id='{do_type}']", ns) |
| if do_type_elem is not None: |
| for da in do_type_elem.findall('.//scl:DA', ns): |
| da_name = da.get('name') |
| da_fc = da.get('fc', '') |
|
|
| display = f"{da_name} [{da_fc}]" if da_fc else da_name |
|
|
| do_data['_children'][da_name] = { |
| '_type': 'Data Attribute', |
| '_type_short': 'DA', |
| '_display': display |
| } |
|
|
| data_objects[do_name] = do_data |
|
|
| return data_objects |
|
|
| def resolve_da_structure(root, ns, lntype_id, do_name, da_name, dotype_cache, datype_cache): |
| """Resuelve la estructura completa de un DA específico incluyendo sus BDA""" |
| |
| lntype = root.find(f".//scl:LNodeType[@id='{lntype_id}']", ns) |
| if lntype is None: |
| return {} |
| |
| |
| do_elem = lntype.find(f".//scl:DO[@name='{do_name}']", ns) |
| if do_elem is None: |
| return {} |
| |
| do_type = do_elem.get('type') |
| if not do_type: |
| return {} |
| |
| |
| do_type_elem = dotype_cache.get(do_type) |
| if do_type_elem is None: |
| return {} |
| |
| |
| da_elem = do_type_elem.find(f".//scl:DA[@name='{da_name}']", ns) |
| if da_elem is None: |
| return {} |
| |
| da_type = da_elem.get('type', '') |
| if not da_type or da_type not in datype_cache: |
| return {} |
| |
| |
| da_structure = {} |
| process_da_type(da_structure, da_type, datype_cache, ns) |
| |
| return da_structure |
|
|
| def add_fcda_to_hierarchy(hierarchy, fcda, root, ns, lntype_cache, dotype_cache, datype_cache): |
| """Desglosa un FCDA en la jerarquía: LD -> LN -> DO -> DA -> BDA""" |
| ld_inst = fcda.get('ldInst', '') |
| ln_prefix = fcda.get('prefix', '') |
| ln_class = fcda.get('lnClass', '') |
| ln_inst = fcda.get('lnInst', '') |
| do_name = fcda.get('doName', '') |
| da_name = fcda.get('daName', '') |
| fc = fcda.get('fc', '') |
|
|
| |
| if ld_inst and ld_inst not in hierarchy: |
| hierarchy[ld_inst] = { |
| '_type': 'Logical Device', |
| '_type_short': 'LD', |
| '_display': ld_inst, |
| '_children': {} |
| } |
|
|
| current = hierarchy.get(ld_inst, hierarchy) if ld_inst else hierarchy |
| if '_children' not in current: |
| current = hierarchy |
| else: |
| current = current['_children'] |
|
|
| |
| ln_key = f"{ln_prefix}{ln_class}{ln_inst}" |
| if ln_key not in current: |
| current[ln_key] = { |
| '_type': 'Logical Node', |
| '_type_short': 'LN', |
| '_display': ln_key, |
| '_children': {} |
| } |
|
|
| current = current[ln_key]['_children'] |
|
|
| |
| if do_name and do_name not in current: |
| current[do_name] = { |
| '_type': 'Data Object', |
| '_type_short': 'DO', |
| '_display': do_name, |
| '_children': {} |
| } |
|
|
| if do_name: |
| current = current[do_name]['_children'] |
|
|
| |
| if da_name: |
| da_display = f"{da_name} [{fc}]" if fc else da_name |
| if da_name not in current: |
| current[da_name] = { |
| '_type': 'Data Attribute', |
| '_type_short': 'DA', |
| '_display': da_display, |
| '_children': {} |
| } |
| |
| |
| |
| lntype_id = None |
| for ld in root.findall('.//scl:LDevice', ns): |
| if ld.get('inst') == ld_inst: |
| if ln_class == 'LLN0': |
| ln_elem = ld.find('.//scl:LN0', ns) |
| else: |
| ln_elem = ld.find(f".//scl:LN[@lnClass='{ln_class}'][@inst='{ln_inst}']", ns) |
| if ln_elem is None and ln_prefix: |
| ln_elem = ld.find(f".//scl:LN[@prefix='{ln_prefix}'][@lnClass='{ln_class}'][@inst='{ln_inst}']", ns) |
| |
| if ln_elem is not None: |
| lntype_id = ln_elem.get('lnType') |
| break |
| |
| if lntype_id: |
| da_structure = resolve_da_structure(root, ns, lntype_id, do_name, da_name, dotype_cache, datype_cache) |
| if da_structure: |
| current[da_name]['_children'] = da_structure |
| else: |
| |
| if fc and ld_inst in hierarchy: |
| do_obj = hierarchy[ld_inst]['_children'][ln_key]['_children'][do_name] |
| do_obj['_display'] = f"{do_name} [{fc}]" |
|
|
| def resolve_dataset_structure(root, ns, ld_inst, dataset_name, lntype_cache, dotype_cache, datype_cache): |
| """Resuelve y retorna la estructura completa de un Dataset""" |
| structure = {} |
|
|
| |
| for ld in root.findall('.//scl:LDevice', ns): |
| if ld.get('inst') == ld_inst: |
| ln0 = ld.find('.//scl:LN0', ns) |
| if ln0 is not None: |
| for ds in ln0.findall('.//scl:DataSet', ns): |
| if ds.get('name') == dataset_name: |
| |
| for fcda in ds.findall('.//scl:FCDA', ns): |
| add_fcda_to_hierarchy(structure, fcda, root, ns, lntype_cache, dotype_cache, datype_cache) |
| return structure |
| return structure |
|
|
| def parse_ied_clean(cid_file): |
| """Parsea el archivo CID con optimizaciones""" |
| tree = etree.parse(cid_file) |
| root = tree.getroot() |
| ns = {'scl': 'http://www.iec.ch/61850/2003/SCL'} |
|
|
| |
| lntype_cache = {lnt.get('id'): lnt for lnt in root.findall('.//scl:LNodeType', ns)} |
| dotype_cache = {dot.get('id'): dot for dot in root.findall('.//scl:DOType', ns)} |
| datype_cache = {dat.get('id'): dat for dat in root.findall('.//scl:DAType', ns)} |
|
|
| ieds_structure = {} |
|
|
| for ied in root.findall('.//scl:IED', ns): |
| ied_name = ied.get('name') |
| ip_address = get_ied_ip(root, ns, ied_name) |
| display_name = f"{ied_name} ({ip_address})" if ip_address else ied_name |
|
|
| ieds_structure[ied_name] = { |
| '_type': 'Physical Device', |
| '_type_short': 'PD', |
| '_display': display_name, |
| '_children': { |
| 'GOOSE': {'_type': 'Section', '_type_short': 'Section', '_display': 'GOOSE', '_children': {}}, |
| 'Reports': {'_type': 'Section', '_type_short': 'Section', '_display': 'Reports', '_children': {}}, |
| 'Dataset': {'_type': 'Section', '_type_short': 'Section', '_display': 'Dataset', '_children': {}}, |
| 'Data Model': {'_type': 'Section', '_type_short': 'Section', '_display': 'Data Model', '_children': {}} |
| } |
| } |
|
|
| goose_section = ieds_structure[ied_name]['_children']['GOOSE']['_children'] |
| reports_section = ieds_structure[ied_name]['_children']['Reports']['_children'] |
| dataset_section = ieds_structure[ied_name]['_children']['Dataset']['_children'] |
| data_model_section = ieds_structure[ied_name]['_children']['Data Model']['_children'] |
|
|
| for ld in ied.findall('.//scl:LDevice', ns): |
| ld_inst = ld.get('inst') |
| data_model_section[ld_inst] = { |
| '_type': 'Logical Device', |
| '_type_short': 'LD', |
| '_display': ld_inst, |
| '_children': {} |
| } |
| ld_node = data_model_section[ld_inst] |
|
|
| ln0 = ld.find('.//scl:LN0', ns) |
| if ln0 is not None: |
| ln0_data = {'_type': 'Logical Node', '_type_short': 'LN', '_display': 'LLN0', '_children': {}} |
|
|
| |
| for ds in ln0.findall('.//scl:DataSet', ns): |
| ds_name = ds.get('name') |
|
|
| |
| ds_key = f"{ld_inst}.LLN0.{ds_name}" |
|
|
| |
| ds_structure = { |
| '_type': 'Dataset', |
| '_type_short': 'Dataset', |
| '_display': f"LLN0.{ds_name}", |
| '_children': {} |
| } |
|
|
| |
| if ld_inst not in dataset_section: |
| dataset_section[ld_inst] = { |
| '_type': 'Logical Device', |
| '_type_short': 'LD', |
| '_display': ld_inst, |
| '_children': {} |
| } |
|
|
| |
| ds_content = {} |
| for fcda in ds.findall('.//scl:FCDA', ns): |
| add_fcda_to_hierarchy(ds_content, fcda, root, ns, lntype_cache, dotype_cache, datype_cache) |
| ds_structure['_children'] = ds_content |
|
|
| |
| dataset_section[ld_inst]['_children'][ds_key] = ds_structure |
|
|
| |
| for rpt in ln0.findall('.//scl:ReportControl', ns): |
| rpt_name = rpt.get('name') |
| rpt_datset = rpt.get('datSet', '') |
|
|
| rpt_key = f"{ld_inst}.LLN0.{rpt_name}" |
|
|
| |
| rpt_display = f"LLN0.{rpt_name}" |
|
|
| rpt_node = { |
| '_type': 'ReportControl', |
| '_type_short': 'ReportControl', |
| '_display': rpt_display, |
| '_datSet': f"LLN0.{rpt_datset}" if rpt_datset else '', |
| '_buffered': rpt.get('buffered', 'false'), |
| '_children': {} |
| } |
|
|
| |
| if ld_inst not in reports_section: |
| reports_section[ld_inst] = { |
| '_type': 'Logical Device', |
| '_type_short': 'LD', |
| '_display': ld_inst, |
| '_children': {} |
| } |
|
|
| |
| if rpt_datset: |
| dataset_structure = resolve_dataset_structure(root, ns, ld_inst, rpt_datset, lntype_cache, dotype_cache, datype_cache) |
| rpt_node['_children'] = dataset_structure |
|
|
| |
| reports_section[ld_inst]['_children'][rpt_key] = rpt_node |
|
|
| |
| for gse in ln0.findall('.//scl:GSEControl', ns): |
| gse_name = gse.get('name') |
| gse_datset = gse.get('datSet', '') |
|
|
| gse_key = f"{ld_inst}.LLN0.{gse_name}" |
|
|
| |
| gse_display = f"LLN0.{gse_name}" |
|
|
| gse_node = { |
| '_type': 'GOOSEControl', |
| '_type_short': 'GOOSEControl', |
| '_display': gse_display, |
| '_datSet': f"LLN0.{gse_datset}" if gse_datset else '', |
| '_children': {} |
| } |
|
|
| |
| if ld_inst not in goose_section: |
| goose_section[ld_inst] = { |
| '_type': 'Logical Device', |
| '_type_short': 'LD', |
| '_display': ld_inst, |
| '_children': {} |
| } |
|
|
| |
| if gse_datset: |
| dataset_structure = resolve_dataset_structure(root, ns, ld_inst, gse_datset, lntype_cache, dotype_cache, datype_cache) |
| gse_node['_children'] = dataset_structure |
|
|
| |
| goose_section[ld_inst]['_children'][gse_key] = gse_node |
|
|
| |
| for smv in ln0.findall('.//scl:SampledValueControl', ns): |
| smv_name = smv.get('name') |
| smv_datset = smv.get('datSet', '') |
|
|
| smv_key = f"{ld_inst}.LLN0.{smv_name}" |
|
|
| |
| smv_display = f"LLN0.{smv_name}" |
|
|
| smv_node = { |
| '_type': 'SVControl', |
| '_type_short': 'SVControl', |
| '_display': smv_display, |
| '_datSet': f"LLN0.{smv_datset}" if smv_datset else '', |
| '_children': {} |
| } |
|
|
| |
| if ld_inst not in goose_section: |
| goose_section[ld_inst] = { |
| '_type': 'Logical Device', |
| '_type_short': 'LD', |
| '_display': ld_inst, |
| '_children': {} |
| } |
|
|
| |
| if smv_datset: |
| dataset_structure = resolve_dataset_structure(root, ns, ld_inst, smv_datset, lntype_cache, dotype_cache, datype_cache) |
| smv_node['_children'] = dataset_structure |
|
|
| |
| goose_section[ld_inst]['_children'][smv_key] = smv_node |
|
|
| |
| ln0_type = ln0.get('lnType') |
| if ln0_type: |
| data_objects = get_data_objects_clean_cached(lntype_cache, dotype_cache, datype_cache, ns, ln0_type) |
| ln0_data['_children'].update(data_objects) |
|
|
| ld_node['_children']['LLN0'] = ln0_data |
|
|
| |
| for ln in ld.findall('.//scl:LN', ns): |
| ln_class = ln.get('lnClass') |
| ln_inst = ln.get('inst', '') |
| ln_prefix = ln.get('prefix', '') |
| ln_key = f"{ln_prefix}{ln_class}{ln_inst}" |
| ln_data = {'_type': 'Logical Node', '_type_short': 'LN', '_display': ln_key, '_children': {}} |
| ln_type = ln.get('lnType') |
| if ln_type: |
| data_objects = get_data_objects_clean_cached(lntype_cache, dotype_cache, datype_cache, ns, ln_type) |
| ln_data['_children'] = data_objects |
| ld_node['_children'][ln_key] = ln_data |
|
|
| return ieds_structure |
|
|
| |
|
|
| def generate_collapsible_tree_js(data, path="root", parent_section=None, level=0, max_initial_depth=0): |
| """Genera árbol HTML colapsable - SIEMPRE renderiza hijos pero los oculta""" |
| html = "" |
| |
| |
| should_expand_initially = level < max_initial_depth |
|
|
| if isinstance(data, dict): |
| for idx, (key, value) in enumerate(data.items()): |
| if key.startswith('_'): |
| continue |
|
|
| if isinstance(value, dict): |
| obj_type_short = value.get('_type_short', value.get('_type', '')) |
| display = value.get('_display', key) |
|
|
| is_section = obj_type_short == 'Section' |
| skip_type = parent_section in ['GOOSE', 'Reports', 'Dataset'] and obj_type_short in ['GOOSEControl', 'SVControl', 'ReportControl', 'Dataset'] |
|
|
| node_id = f"node_{path}_{idx}".replace(" ", "_").replace("[", "").replace("]", "").replace(".", "_").replace("/", "_").replace("(", "").replace(")", "") |
| has_children = '_children' in value and value['_children'] and any(not k.startswith('_') for k in value['_children'].keys()) |
|
|
| extra_info = "" |
| if obj_type_short == 'ReportControl': |
| datset = value.get('_datSet', '') |
| buffered = value.get('_buffered', '') |
| if datset: |
| extra_info = f"<span class='extra-info'>datSet: {datset}</span>" |
| if buffered: |
| extra_info += f" <span class='extra-info'>| buffered: {buffered}</span>" |
| elif buffered: |
| extra_info = f"<span class='extra-info'>buffered: {buffered}</span>" |
|
|
| if obj_type_short in ['GOOSEControl', 'SVControl']: |
| datset = value.get('_datSet', '') |
| if datset: |
| extra_info = f"<span class='extra-info'>datSet: {datset}</span>" |
| |
| if obj_type_short == 'Dataset': |
| |
| pass |
|
|
| indent = level * 8 |
|
|
| if has_children: |
| |
| initial_display = 'block' if should_expand_initially else 'none' |
| icon_class = 'expanded' if should_expand_initially else '' |
| |
| html += f""" |
| <div class="tree-node" style="margin-left: {indent}px;"> |
| <div class="tree-item tree-toggle" data-target="{node_id}"> |
| <span class="toggle-icon {icon_class}">›</span> |
| """ |
| else: |
| html += f""" |
| <div class="tree-node" style="margin-left: {indent}px;"> |
| <div class="tree-item"> |
| <span class="no-icon">·</span> |
| """ |
|
|
| if is_section: |
| html += f"<strong class='section-title'>{display}</strong>" |
| current_section = display |
| elif skip_type: |
| html += f"<span class='node-text'>{display}</span>{extra_info}" |
| current_section = parent_section |
| else: |
| if '[' in display and ']' in display and obj_type_short == 'DA': |
| parts = display.rsplit('[', 1) |
| name_part = parts[0].strip() |
| fc_part = '[' + parts[1] |
| html += f"<span class='node-type node-type-{obj_type_short.lower()}'>{obj_type_short}</span> <span class='node-text'>{name_part}</span> <span class='fc-text'>{fc_part}</span>" |
| elif obj_type_short in ['GOOSEControl', 'SVControl', 'ReportControl', 'Dataset']: |
| |
| html += f"<strong class='control-name'>{display}</strong>{extra_info}" |
| else: |
| html += f"<span class='node-type node-type-{obj_type_short.lower()}'>{obj_type_short}</span> <span class='node-text'>{display}</span>{extra_info}" |
| current_section = parent_section |
|
|
| html += "</div>" |
|
|
| if has_children: |
| |
| html += f"<div id='{node_id}' class='tree-children' style='display: {initial_display};'>" |
| |
| |
| html += generate_collapsible_tree_js( |
| value['_children'], |
| f"{path}_{idx}", |
| current_section, |
| level + 1, |
| max_initial_depth |
| ) |
| |
| html += "</div>" |
|
|
| html += "</div>" |
|
|
| return html |
|
|
| def process_cid_file(file): |
| """Procesa el archivo CID y genera la visualización""" |
| if file is None: |
| return """ |
| <div class='empty-state'> |
| <div style='font-size: 48px; margin-bottom: 12px;'>📄</div> |
| <div style='font-size: 13px; font-weight: 600; margin-bottom: 4px;'>No file loaded</div> |
| <div style='font-size: 11px;'>Select a CID file from the sidebar</div> |
| </div> |
| """, "" |
|
|
| try: |
| ieds = parse_ied_clean(file.name) |
| stats = count_elements(ieds) |
|
|
| ied_name = list(ieds.keys())[0] if ieds else "Unknown" |
| ied_info = ieds[ied_name] if ied_name in ieds else {} |
| ied_display = ied_info.get('_display', ied_name) |
|
|
| stats_html = f""" |
| <div class='stats-card'> |
| <div class='stats-title'>Estadísticas</div> |
| <table class='stats-table'> |
| <tr><td> Physical Devices:</td><td>{stats['ieds']}</td></tr> |
| <tr><td> Logical Devices:</td><td>{stats['lds']}</td></tr> |
| <tr><td> Logical Nodes:</td><td>{stats['lns']}</td></tr> |
| <tr><td> Data Objects:</td><td>{stats['dos']}</td></tr> |
| <tr><td> Data Attributes:</td><td>{stats['das']}</td></tr> |
| <tr class='divider'><td> Datasets:</td><td>{stats['datasets']}</td></tr> |
| <tr><td> Reports:</td><td>{stats['reports']}</td></tr> |
| <tr><td> GOOSE/SV:</td><td>{stats['goose']}</td></tr> |
| </table> |
| </div> |
| """ |
|
|
| tree_content = generate_collapsible_tree_js(ieds) |
|
|
| import time |
| tree_id = f"tree_{int(time.time() * 1000)}" |
|
|
| html_tree = f""" |
| <div id="{tree_id}" class="cid-tree-root"> |
| <style> |
| #{tree_id} {{ |
| --tree-bg: #FFFFFF; |
| --tree-border: #EDEBE9; |
| --tree-hover: #F3F2F1; |
| --tree-text: #323130; |
| --tree-secondary: #605E5C; |
| --tree-tertiary: #8A8886; |
| --tree-heading: #201F1E; |
| --tree-icon: #605E5C; |
| --tree-code-bg: #F3F2F1; |
| --header-bg: #F3F2F1; |
| |
| --color-pd: #0078D4; |
| --color-ld: #107C10; |
| --color-ln: #FF8C00; |
| --color-do: #E81123; |
| --color-da: #881798; |
| }} |
| |
| #{tree_id} .tree-container {{ |
| font-family: 'Segoe UI', 'Segoe UI Web', Tahoma, Arial, sans-serif; |
| font-size: 11px; |
| line-height: 1.4; |
| background: var(--tree-bg); |
| padding: 12px; |
| border: 1px solid var(--tree-border); |
| border-radius: 2px; |
| max-height: calc(100vh - 143px); |
| overflow-y: auto; |
| overflow-x: auto; |
| color: var(--tree-text); |
| }} |
| |
| #{tree_id} .tree-node {{ |
| user-select: none; |
| }} |
| |
| #{tree_id} .tree-item {{ |
| padding: 3px 6px; |
| border-radius: 2px; |
| transition: background-color 0.1s ease; |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| white-space: nowrap; |
| min-height: 20px; /* <-- AGREGA ESTO para altura mínima consistente */ |
| }} |
| |
| #{tree_id} .tree-item.tree-toggle {{ |
| cursor: pointer; |
| }} |
| |
| #{tree_id} .tree-item.tree-toggle:hover {{ |
| background-color: var(--tree-hover); |
| }} |
| |
| #{tree_id} .toggle-icon {{ |
| display: inline-flex; |
| align-items: center; |
| justify-content: center; |
| width: 12px; |
| height: 12px; |
| font-size: 12px; /* <-- Cambia de 14px a 12px */ |
| color: var(--tree-icon); |
| transition: transform 0.1s ease; |
| flex-shrink: 0; |
| line-height: 1; /* <-- AGREGA ESTO */ |
| }} |
| |
| #{tree_id} .toggle-icon.expanded {{ |
| transform: rotate(90deg); |
| }} |
| |
| #{tree_id} .no-icon {{ |
| display: inline-flex; /* <-- Cambia de inline-block a inline-flex */ |
| align-items: center; /* <-- AGREGA ESTO */ |
| justify-content: center; /* <-- AGREGA ESTO */ |
| width: 12px; |
| height: 12px; |
| font-size: 12px; |
| color: var(--tree-tertiary); |
| flex-shrink: 0; |
| }} |
| |
| #{tree_id} .node-type {{ |
| font-weight: 600; |
| font-family: 'Consolas', 'Courier New', monospace; |
| padding: 1px 4px; |
| background: var(--tree-code-bg); |
| border-radius: 2px; |
| font-size: 10px; |
| line-height: 1; /* <-- AGREGA ESTO */ |
| display: inline-flex; /* <-- AGREGA ESTO */ |
| align-items: center; /* <-- AGREGA ESTO */ |
| }} |
| |
| #{tree_id} .node-type-pd {{ color: var(--color-pd); }} |
| #{tree_id} .node-type-ld {{ color: var(--color-ld); }} |
| #{tree_id} .node-type-ln {{ color: var(--color-ln); }} |
| #{tree_id} .node-type-do {{ color: var(--color-do); }} |
| #{tree_id} .node-type-da {{ color: var(--color-da); }} |
| |
| #{tree_id} .node-text {{ |
| color: var(--tree-text); |
| font-size: 11px; |
| line-height: 1; /* <-- AGREGA ESTO */ |
| display: inline-flex; /* <-- AGREGA ESTO */ |
| align-items: center; /* <-- AGREGA ESTO */ |
| }} |
| |
| #{tree_id} .section-title {{ |
| color: var(--tree-heading); |
| font-size: 12px; |
| font-weight: 600; |
| line-height: 1; /* <-- AGREGA ESTO */ |
| display: inline-flex; /* <-- AGREGA ESTO */ |
| align-items: center; /* <-- AGREGA ESTO */ |
| }} |
| |
| #{tree_id} .fc-text {{ |
| color: var(--tree-tertiary); |
| font-size: 10px; |
| line-height: 1; /* <-- AGREGA ESTO */ |
| display: inline-flex; /* <-- AGREGA ESTO */ |
| align-items: center; /* <-- AGREGA ESTO */ |
| }} |
| |
| #{tree_id} .control-name {{ |
| color: var(--tree-text); |
| font-size: 11px; |
| font-weight: 600; |
| line-height: 1; |
| display: inline-flex; |
| align-items: center; |
| }} |
| |
| #{tree_id} .extra-info {{ |
| color: #A19F9D; /* Gris claro */ |
| font-size: 10px; |
| margin-left: 8px; |
| font-weight: 400; |
| }} |
| |
| #{tree_id} .tree-children {{ |
| overflow: hidden; |
| }} |
| |
| #{tree_id} .header {{ |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| margin-bottom: 8px; |
| padding: 8px 12px; |
| background: var(--header-bg); |
| border: 1px solid var(--tree-border); |
| border-radius: 2px; |
| }} |
| |
| #{tree_id} .ied-title {{ |
| font-size: 12px; |
| font-weight: 600; |
| color: var(--tree-heading); |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| }} |
| |
| #{tree_id} .control-buttons {{ |
| display: flex; |
| gap: 4px; |
| }} |
| |
| #{tree_id} .btn {{ |
| padding: 4px 12px; |
| border: 1px solid var(--tree-border); |
| background: var(--tree-bg); |
| border-radius: 2px; |
| font-weight: 400; |
| cursor: pointer; |
| transition: all 0.1s ease; |
| font-size: 11px; |
| color: var(--tree-text); |
| font-family: 'Segoe UI', 'Segoe UI Web', Tahoma, Arial, sans-serif; |
| }} |
| |
| #{tree_id} .btn:hover {{ |
| background: var(--tree-hover); |
| }} |
| |
| #{tree_id} .btn-primary {{ |
| background: #0078D4; |
| color: white; |
| border-color: #0078D4; |
| }} |
| |
| #{tree_id} .btn-primary:hover {{ |
| background: #106EBE; |
| border-color: #106EBE; |
| }} |
| |
| #{tree_id} .tree-container::-webkit-scrollbar {{ |
| width: 12px; |
| height: 12px; |
| }} |
| |
| #{tree_id} .tree-container::-webkit-scrollbar-track {{ |
| background: var(--tree-hover); |
| }} |
| |
| #{tree_id} .tree-container::-webkit-scrollbar-thumb {{ |
| background: var(--tree-secondary); |
| border-radius: 2px; |
| }} |
| |
| #{tree_id} .tree-container::-webkit-scrollbar-thumb:hover {{ |
| background: var(--tree-icon); |
| }} |
| </style> |
| |
| <div class="header"> |
| <div class="ied-title"> |
| <span>🔌</span> |
| <span>IEDs</span> |
| </div> |
| <div class="control-buttons"> |
| <button class="btn btn-primary btn-expand-all">Expandir Todo</button> |
| <button class="btn btn-collapse-all">Colapsar Todo</button> |
| </div> |
| </div> |
| |
| <div class="tree-container"> |
| {tree_content} |
| </div> |
| </div> |
| """ |
|
|
| return html_tree, stats_html |
|
|
| except Exception as e: |
| import traceback |
| error_detail = traceback.format_exc() |
| print(f"Error processing CID: {error_detail}") |
| |
| error_msg = f""" |
| <div class='error-state'> |
| <div style='font-size: 48px; margin-bottom: 12px;'>⚠️</div> |
| <div style='font-size: 13px; font-weight: 600; margin-bottom: 4px;'>Error processing file</div> |
| <div style='font-size: 11px;'>{str(e)}</div> |
| </div> |
| """ |
| return error_msg, "" |
|
|
| def count_elements(data): |
| """Cuenta elementos en la estructura""" |
| stats = { |
| 'ieds': 0, |
| 'lds': 0, |
| 'lns': 0, |
| 'dos': 0, |
| 'das': 0, |
| 'datasets': 0, |
| 'reports': 0, |
| 'goose': 0 |
| } |
|
|
| def count_recursive(d): |
| if isinstance(d, dict): |
| for key, value in d.items(): |
| if key.startswith('_'): |
| continue |
| if isinstance(value, dict): |
| obj_type = value.get('_type_short', '') |
| if obj_type == 'PD': |
| stats['ieds'] += 1 |
| elif obj_type == 'LD': |
| stats['lds'] += 1 |
| elif obj_type == 'LN': |
| stats['lns'] += 1 |
| elif obj_type == 'DO': |
| stats['dos'] += 1 |
| elif obj_type == 'DA': |
| stats['das'] += 1 |
| elif obj_type == 'Dataset': |
| stats['datasets'] += 1 |
| elif obj_type == 'ReportControl': |
| stats['reports'] += 1 |
| elif obj_type in ['GOOSEControl', 'SVControl']: |
| stats['goose'] += 1 |
|
|
| if '_children' in value: |
| count_recursive(value['_children']) |
|
|
| count_recursive(data) |
| return stats |
|
|
| GLOBAL_JS = """ |
| function() { |
| // Solo verificar tema UNA VEZ al cargar la página |
| if (!window.themeChecked) { |
| window.themeChecked = true; |
| const url = new URL(window.location); |
| if (url.searchParams.get('__theme') !== 'light') { |
| url.searchParams.set('__theme', 'light'); |
| window.location.href = url.href; |
| return; // Detener ejecución |
| } |
| } |
| |
| // Evitar múltiples inicializaciones |
| if (window.treeHandlersInitialized) return; |
| window.treeHandlersInitialized = true; |
| |
| // Delegación de eventos ÚNICA |
| document.addEventListener('click', function(e) { |
| // Manejo de toggles |
| const toggle = e.target.closest('.tree-toggle'); |
| if (toggle) { |
| const root = toggle.closest('.cid-tree-root'); |
| if (!root) return; |
| |
| const targetId = toggle.getAttribute('data-target'); |
| if (!targetId) return; |
| |
| const children = root.querySelector('#' + targetId); |
| const icon = toggle.querySelector('.toggle-icon'); |
| |
| if (children && icon) { |
| const isHidden = children.style.display === 'none' || children.style.display === ''; |
| children.style.display = isHidden ? 'block' : 'none'; |
| icon.classList.toggle('expanded', isHidden); |
| } |
| e.stopPropagation(); |
| return; |
| } |
| |
| // Expand all |
| if (e.target.closest('.btn-expand-all')) { |
| const root = e.target.closest('.cid-tree-root'); |
| if (root) { |
| root.querySelectorAll('.tree-children').forEach(el => el.style.display = 'block'); |
| root.querySelectorAll('.toggle-icon').forEach(el => el.classList.add('expanded')); |
| } |
| e.stopPropagation(); |
| return; |
| } |
| |
| // Collapse all |
| if (e.target.closest('.btn-collapse-all')) { |
| const root = e.target.closest('.cid-tree-root'); |
| if (root) { |
| root.querySelectorAll('.tree-children').forEach(el => el.style.display = 'none'); |
| root.querySelectorAll('.toggle-icon').forEach(el => el.classList.remove('expanded')); |
| } |
| e.stopPropagation(); |
| return; |
| } |
| }, { passive: true }); // Mejorar rendimiento |
| } |
| """ |
|
|
| custom_css = """ |
| /* Windows style theme - Solo tema claro */ |
| :root { |
| --primary-color: #0078D4; |
| --primary-hover: #106EBE; |
| --background-color: #FAF9F8; |
| --sidebar-bg: #FFFFFF; |
| --border-color: #EDEBE9; |
| --text-primary: #201F1E; |
| --text-secondary: #323130; |
| --text-tertiary: #605E5C; |
| --text-quaternary: #8A8886; |
| --hover-bg: #F3F2F1; |
| --surface-bg: #FFFFFF; |
| } |
| |
| body { |
| font-family: 'Segoe UI', 'Segoe UI Web', Tahoma, Arial, sans-serif !important; |
| background: var(--background-color) !important; |
| } |
| |
| .gradio-container { |
| max-width: 100% !important; |
| padding: 0 !important; |
| background: var(--background-color) !important; |
| } |
| |
| #component-0 { |
| background: var(--sidebar-bg) !important; |
| border-right: 1px solid var(--border-color) !important; |
| padding: 12px !important; |
| display: block !important; |
| } |
| |
| /* Reducir espaciado entre componentes del sidebar */ |
| #component-0 .gr-block { |
| margin-bottom: 0.3rem !important; |
| gap: 0.2rem !important; |
| } |
| |
| #component-0 .gr-form { |
| gap: 0.3rem !important; |
| } |
| |
| #component-0 .gr-box { |
| gap: 0.3rem !important; |
| } |
| |
| /* Markdown compacto en sidebar */ |
| #component-0 .prose { |
| margin-top: 0 !important; |
| margin-bottom: 0.3rem !important; |
| } |
| |
| #component-0 .prose h3 { |
| margin-top: 0.3rem !important; |
| margin-bottom: 0.2rem !important; |
| } |
| |
| #component-0 .prose p { |
| margin-top: 0 !important; |
| margin-bottom: 0.2rem !important; |
| } |
| |
| /* File input y botón compactos */ |
| #component-0 .gr-file, |
| #component-0 .gr-button { |
| margin-bottom: 0.3rem !important; |
| } |
| |
| .gr-button { |
| border-radius: 2px !important; |
| font-size: 11px !important; |
| padding: 6px 16px !important; |
| font-weight: 400 !important; |
| border: 1px solid var(--border-color) !important; |
| transition: all 0.1s ease !important; |
| background: var(--surface-bg) !important; |
| color: var(--text-secondary) !important; |
| } |
| |
| .gr-button:hover { |
| background: var(--hover-bg) !important; |
| } |
| |
| .gr-button-primary { |
| background: var(--primary-color) !important; |
| border-color: var(--primary-color) !important; |
| color: white !important; |
| } |
| |
| .gr-button-primary:hover { |
| background: var(--primary-hover) !important; |
| } |
| |
| .gr-file { |
| border: 1px solid var(--border-color) !important; |
| border-radius: 2px !important; |
| background: var(--surface-bg) !important; |
| } |
| |
| h1, h2, h3 { |
| color: var(--text-primary) !important; |
| font-weight: 600 !important; |
| } |
| |
| h1 { |
| font-size: 18px !important; |
| margin-bottom: 4px !important; |
| margin-top: 0 !important; |
| } |
| |
| h2 { |
| font-size: 14px !important; |
| margin-top: 8px !important; |
| margin-bottom: 4px !important; |
| } |
| |
| h3 { |
| font-size: 12px !important; |
| margin-top: 8px !important; |
| margin-bottom: 4px !important; |
| } |
| |
| p, li { |
| font-size: 11px !important; |
| line-height: 1.4 !important; |
| color: var(--text-secondary) !important; |
| margin-top: 2px !important; |
| margin-bottom: 2px !important; |
| } |
| |
| strong { |
| color: var(--text-primary) !important; |
| font-weight: 600 !important; |
| } |
| |
| .prose { |
| font-size: 11px !important; |
| color: var(--text-secondary) !important; |
| } |
| |
| .prose p { |
| margin-top: 4px !important; |
| margin-bottom: 4px !important; |
| } |
| |
| .prose ul, .prose ol { |
| margin-top: 4px !important; |
| margin-bottom: 4px !important; |
| padding-left: 16px !important; |
| } |
| |
| .prose li { |
| margin-top: 2px !important; |
| margin-bottom: 2px !important; |
| } |
| |
| .prose hr { |
| margin-top: 8px !important; |
| margin-bottom: 8px !important; |
| border-color: var(--border-color) !important; |
| } |
| |
| .gr-box, .gr-form, .gr-panel { |
| gap: 6px !important; |
| } |
| |
| .gr-padded { |
| padding: 8px !important; |
| } |
| |
| .gr-label { |
| margin-bottom: 4px !important; |
| font-size: 11px !important; |
| color: var(--text-secondary) !important; |
| font-weight: 600 !important; |
| } |
| |
| .gr-input-container { |
| margin-bottom: 6px !important; |
| } |
| |
| /* Estilos personalizados para componentes HTML */ |
| .stats-card { |
| background: var(--surface-bg); |
| border: 1px solid var(--border-color); |
| padding: 12px; |
| font-size: 11px; |
| line-height: 1.6; |
| border-radius: 2px; |
| } |
| |
| .stats-title { |
| font-weight: 600; |
| margin-bottom: 8px; |
| color: var(--text-primary); |
| font-size: 11px; |
| } |
| |
| .stats-table { |
| width: 100%; |
| border-collapse: collapse; |
| } |
| |
| .stats-table td { |
| padding: 2px 0; |
| color: var(--text-tertiary); |
| vertical-align: middle; /* <-- AGREGA ESTO */ |
| } |
| |
| .stats-table td:last-child { |
| text-align: center; |
| font-weight: 600; |
| color: var(--text-primary); |
| vertical-align: middle; /* <-- AGREGA ESTO */ |
| } |
| |
| .stats-table tr.divider { |
| border-top: 1px solid var(--border-color); |
| } |
| |
| .stats-table tr.divider td { |
| padding-top: 4px; |
| } |
| |
| .empty-state, .error-state { |
| text-align: center; |
| padding: 30px; |
| background: var(--surface-bg); |
| border: 1px solid var(--border-color); |
| border-radius: 2px; |
| } |
| |
| .empty-state { |
| color: var(--text-quaternary); |
| } |
| |
| .error-state { |
| color: var(--text-tertiary); |
| } |
| |
| #panel-lateral { |
| gap: 0 !important; |
| } |
| |
| /* Full height layout estable sin vh */ |
| .full-height-column { |
| height: 100%; |
| display: flex; |
| flex-direction: column; |
| } |
| |
| .full-height-html { |
| flex: 1; /* Ocupa todo el espacio disponible */ |
| height: 100%; /* Hereda del padre */ |
| } |
| |
| .empty-state { |
| height: 100%; |
| display: flex; |
| flex-direction: column; |
| justify-content: center; |
| align-items: center; |
| background: var(--surface-bg); |
| border: 1px solid var(--border-color); |
| border-radius: 2px; |
| padding: 20px; |
| box-sizing: border-box; |
| } |
| |
| .empty-content { |
| text-align: center; |
| max-width: 500px; |
| } |
| |
| /* Agregar después de .extra-info */ |
| .ref-text { |
| color: var(--text-tertiary); |
| font-size: 10px; |
| font-style: italic; |
| margin-left: 4px; |
| } |
| """ |
|
|
| |
| with gr.Blocks(title="IEC 61850 CID Viewer", css=custom_css, js=GLOBAL_JS, fill_height=True) as app: |
|
|
| with gr.Row(equal_height=False): |
| with gr.Column(scale=1, min_width=280, elem_id="panel-lateral"): |
| gr.Markdown(""" |
| # IEC 61850 Viewer |
| |
| Herramienta profesional para analizar archivos SCL de subestaciones eléctricas IEC 61850. |
| """) |
|
|
| file_input = gr.File( |
| label="Subir el Archivo IID, ICD, CID o SCD", |
| file_types=[".cid", ".xml", ".scd"], |
| type="filepath", |
| height=150 |
| ) |
|
|
| process_btn = gr.Button( |
| "Procesar Archivo", |
| variant="primary", |
| size="sm" |
| ) |
|
|
| gr.Markdown("---") |
|
|
| stats_output = gr.HTML(label="Statistics", show_label=False) |
|
|
| gr.Markdown("---") |
| |
| gr.Markdown(""" |
| ### Leyendas |
| - **PD** Physical Device |
| - **LD** Logical Device |
| - **LN** Logical Node |
| - **DO** Data Object |
| - **DA** Data Attribute |
| - **FC** Functional Constraint |
| """) |
|
|
| gr.Markdown("---") |
| |
| gr.Markdown(""" |
| ### Secciones |
| - **GOOSE** — GOOSE y SV Messages |
| - **Reports** — Report Control Blocks |
| - **Dataset** — Datasets |
| - **Data Model** — IED Data Model |
| """) |
|
|
|
|
| with gr.Column(scale=3, elem_classes="full-height-column"): |
| html_output = gr.HTML( |
| value=""" |
| <div class="empty-state"> |
| <div class="empty-content"> |
| <div style="font-size: 72px; margin-bottom: 16px;">📄</div> |
| <div style="font-size: 16px; font-weight: 600; margin-bottom: 8px;">IEC 61850 Viewer</div> |
| <div style="font-size: 12px; color: var(--text-tertiary);">Cargue un archivo IID, ICD, CID o SCD desde la barra lateral para comenzar el análisis.</div> |
| </div> |
| </div> |
| """, |
| show_label=False, |
| elem_classes="full-height-html" |
| ) |
|
|
| process_btn.click( |
| fn=process_cid_file, |
| inputs=[file_input], |
| outputs=[html_output, stats_output] |
| ).then( |
| fn=lambda: None, |
| inputs=None, |
| outputs=[file_input] |
| ) |
|
|
| if __name__ == "__main__": |
| app.launch(share=True, server_port=7860) |