import os
import re
import json
import subprocess
import gradio as gr
try:
import spaces
except ImportError:
class spaces:
@staticmethod
def GPU(f=None, duration=None):
if f is not None:
return f
def decorator(fn):
return fn
return decorator
# ==========================================
# CONSTANTES Y BASE DE DATOS DEL REGLAMENTO ELÉCTRICO ARGENTINO
# ==========================================
# Tabla 771.7.I - Resumen de tipos de circuitos
CIRCUIT_RULES = {
"IUG": {
"name": "Iluminación de Uso General",
"min_section": 1.5,
"max_bocas": 15,
"max_protection": 16,
"desc": "Circuitos destinados a alimentar puntos de iluminación y tomacorrientes asociados si los hubiera."
},
"TUG": {
"name": "Tomacorrientes de Uso General",
"min_section": 2.5,
"max_bocas": 15,
"max_protection": 20,
"desc": "Circuitos destinados a alimentar tomas comunes para electrodomésticos estándar de hasta 10A."
},
"IUE": {
"name": "Iluminación de Uso Especial",
"min_section": 2.5,
"max_bocas": 12,
"max_protection": 32,
"desc": "Circuitos de iluminación a la intemperie o con condiciones ambientales específicas."
},
"TUE": {
"name": "Tomacorrientes de Uso Especial",
"min_section": 2.5,
"max_bocas": 12,
"max_protection": 32,
"desc": "Circuitos para consumos pesados (aires acondicionados, estufas, tomas de más de 10A)."
}
}
# Tabla 771.16.I - Corrientes admisibles [A] para conductores de cobre termoplásticos (PVC) en cañerías a 40 °C
# Formato: seccion: { 2x: corriente_bipolar, 3x: corriente_tripolar }
CABLE_CURRENT_CAPACITY = {
1.5: {"2x": 15, "3x": 14},
2.5: {"2x": 21, "3x": 18},
4.0: {"2x": 28, "3x": 25},
6.0: {"2x": 36, "3x": 32},
10.0: {"2x": 50, "3x": 44},
16.0: {"2x": 66, "3x": 59},
25.0: {"2x": 88, "3x": 77},
35.0: {"2x": 109, "3x": 96}
}
# Tabla 771.16.II.b - Factor de corrección por agrupamiento en un mismo caño
GROUPING_FACTORS = {
1: 1.0,
2: 0.8,
3: 0.7,
4: 0.65,
5: 0.60,
6: 0.60,
7: 0.50,
8: 0.50,
9: 0.50
}
# ==========================================
# CÓDIGO DE RETRIEVAL (RAG LOCAL)
# ==========================================
MD_771_PATH = "Reglamento_Electrico_Argentino_771_Viviendas.md"
MD_701_PATH = "Reglamento_Electrico_Argentino_701.md"
def get_markdown_pages(md_path):
"""Lee un archivo Markdown y extrae las páginas delimitadas por marcadores --- PAGE X ---."""
if not os.path.exists(md_path):
print(f"Error: No existe el archivo Markdown en {md_path}")
return []
print(f"Cargando páginas desde Markdown: {md_path}...")
try:
with open(md_path, 'r', encoding='utf-8') as f:
content = f.read()
raw_parts = content.split("--- PAGE ")
pages = []
for part in raw_parts:
if not part.strip():
continue
# Separar el número de página del texto
lines = part.split("\n", 1)
if len(lines) < 2:
continue
page_num_str = lines[0].replace("---", "").strip()
page_text = lines[1].strip()
try:
page_num = int(page_num_str)
except ValueError:
page_num = len(pages) + 1
if page_text:
pages.append({
"page": page_num,
"text": page_text
})
print(f"Cargadas {len(pages)} páginas desde {md_path}.")
return pages
except Exception as e:
print(f"Error al leer archivo Markdown {md_path}: {e}")
return []
# Inicializar bases de datos de texto
pages_771 = get_markdown_pages(MD_771_PATH)
pages_701 = get_markdown_pages(MD_701_PATH)
import torch
import numpy as np
# Determinar dispositivo para la GPU
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Dispositivo detectado para ejecución local: {device}")
# Cargar modelo de embeddings local para búsqueda semántica
embedding_model = None
EMBEDDINGS_771_JSON = "reglamento_771_embeddings.json"
EMBEDDINGS_701_JSON = "reglamento_701_embeddings.json"
try:
from sentence_transformers import SentenceTransformer
print("Cargando modelo de embeddings sentence-transformers/all-MiniLM-L6-v2...")
embedding_model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2", device=device)
print("Modelo de embeddings cargado correctamente.")
except Exception as e:
print(f"Error al cargar el modelo de embeddings: {e}")
def get_embeddings_for_pages(pages, cache_embeddings_path, emb_model):
"""Carga los embeddings desde el caché o los genera si no existen."""
if not emb_model:
return pages
if os.path.exists(cache_embeddings_path):
try:
with open(cache_embeddings_path, 'r', encoding='utf-8') as f:
cached_data = json.load(f)
for p in pages:
p_str = str(p['page'])
if p_str in cached_data:
p['embedding'] = cached_data[p_str]
print(f"Cargados embeddings desde caché {cache_embeddings_path}")
return pages
except Exception as e:
print(f"Error al leer caché de embeddings {cache_embeddings_path}: {e}")
print(f"Generando embeddings y guardando en {cache_embeddings_path} (esto puede tardar un momento)...")
texts = [p['text'] for p in pages if p.get('text')]
if texts:
embeddings = emb_model.encode(texts, show_progress_bar=True)
# Convertir a listas de flotantes para JSON
embeddings_list = [emb.tolist() for emb in embeddings]
cached_data = {}
idx = 0
for p in pages:
if p.get('text'):
emb = embeddings_list[idx]
p['embedding'] = emb
cached_data[str(p['page'])] = emb
idx += 1
try:
with open(cache_embeddings_path, 'w', encoding='utf-8') as f:
json.dump(cached_data, f)
print(f"Embeddings guardados exitosamente en {cache_embeddings_path}")
except Exception as e:
print(f"Error al escribir caché de embeddings {cache_embeddings_path}: {e}")
return pages
# Inicializar y cachear embeddings
if embedding_model:
pages_771 = get_embeddings_for_pages(pages_771, EMBEDDINGS_771_JSON, embedding_model)
pages_701 = get_embeddings_for_pages(pages_701, EMBEDDINGS_701_JSON, embedding_model)
# Cargar modelo LLM local
llm_tokenizer = None
llm_model = None
try:
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
import torch
print("Iniciando descarga/carga del modelo Qwen/Qwen3.5-9B...")
llm_tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen3.5-9B")
# Configurar cuantización de 4 bits para reducir el uso de RAM en CPU a ~4.5 GB
quant_cfg = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16 if device == "cpu" else torch.bfloat16,
)
# Cargar siempre en CPU al inicio para cumplir con la política de ZeroGPU
llm_model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3.5-9B",
quantization_config=quant_cfg,
device_map="cpu" if "SPACE_ID" in os.environ else "auto",
torch_dtype=torch.float16
)
print("Modelo Qwen3.5-9B cargado correctamente.")
except Exception as e:
print(f"No se pudo cargar el LLM local ({e}). El consultor usará el motor de búsqueda directa.")
def normalize_text(text):
if not text:
return ""
text = text.lower()
replacements = {
'á': 'a', 'é': 'e', 'í': 'i', 'ó': 'o', 'ú': 'u', 'ü': 'u',
'ñ': 'ñ'
}
for k, v in replacements.items():
text = text.replace(k, v)
return text
def local_search(query, num_results=3):
"""Busca en el texto de los reglamentos usando coincidencia híbrida (léxica + semántica)."""
norm_query = normalize_text(query)
raw_words = re.findall(r'\w+', norm_query)
# Palabras vacías en español que no aportan significado semántico
stopwords = {
"que", "es", "el", "la", "los", "las", "un", "una", "unos", "unas",
"de", "del", "al", "en", "para", "por", "con", "sin", "sobre", "entre",
"este", "esta", "estos", "estas", "eso", "esa", "esos", "esas", "como",
"cual", "cuales", "como", "donde", "cuando", "quien", "quienes", "que",
"y", "o", "u", "e", "mas", "pero", "sino", "aunque", "hacer", "hace",
"ser", "sido", "estar", "tiene", "tienen", "debe", "deben", "se"
}
query_words = [w for w in raw_words if len(w) > 2 and w not in stopwords]
if not query_words:
query_words = [w for w in raw_words if len(w) > 2]
scored_results = []
# Extraer bigramas y trigramas contiguos de la consulta original para buscar frases
query_phrases = []
if query_words:
for length in [3, 2]:
for i in range(len(raw_words) - length + 1):
phrase = " ".join(raw_words[i:i+length])
if any(w in query_words for w in raw_words[i:i+length]):
query_phrases.append((phrase, length))
# Obtener embedding de la consulta si el modelo está disponible
query_emb = None
if embedding_model is not None:
try:
query_emb = embedding_model.encode(query, convert_to_numpy=True)
except Exception as e:
print(f"Error al codificar la consulta '{query}': {e}")
def score_page(p):
page_text = p['text']
norm_text = normalize_text(page_text)
lexical_score = 0
# 1. Coincidencia de palabras clave individuales
for word in query_words:
if word in norm_text:
count = norm_text.count(word)
lexical_score += 3 + min(count, 5) * 0.5
# 2. Coincidencia de frases contiguas
for phrase, length in query_phrases:
if phrase in norm_text:
lexical_score += 15 * length
# 3. Coincidencia exacta de la consulta completa (limpiando signos)
clean_query = re.sub(r'[¿?¡!()]', '', norm_query).strip()
if len(clean_query) > 5 and clean_query in norm_text:
lexical_score += 50
# 4. Similitud semántica
semantic_score = 0
if query_emb is not None and 'embedding' in p:
page_emb = np.array(p['embedding'])
dot_product = np.dot(query_emb, page_emb)
norm_q = np.linalg.norm(query_emb)
norm_p = np.linalg.norm(page_emb)
if norm_q * norm_p > 0:
cosine_sim = dot_product / (norm_q * norm_p)
# Escalar por 100
semantic_score = float(cosine_sim) * 100.0
total_score = lexical_score + semantic_score
# Umbral mínimo de relevancia para evitar spam
if total_score < 15:
return 0
return round(total_score, 2)
# Buscar en Reglamento General 771
for p in pages_771:
score = score_page(p)
if score > 0:
scored_results.append((score, 771, p['page'], p['text']))
# Buscar en Reglamento Baños 701
for p in pages_701:
score = score_page(p)
if score > 0:
scored_results.append((score, 701, p['page'], p['text']))
scored_results.sort(key=lambda x: x[0], reverse=True)
return scored_results[:num_results]
# ==========================================
# FUNCIONES DE LÓGICA Y CÁLCULOS
# ==========================================
def audit_circuit(circuit_type, section, protection, bocas, grouped_circuits):
# Validaciones básicas
rules = CIRCUIT_RULES.get(circuit_type)
if not rules:
return "Error: Tipo de circuito no válido."
section = float(section)
protection = int(protection)
bocas = int(bocas)
grouped_circuits = int(grouped_circuits)
# 1. Validación de sección de cable mínima
min_sec = rules["min_section"]
sec_ok = section >= min_sec
sec_msg = f"✅ Sección de {section} mm² cumple con la sección mínima de {min_sec} mm²." if sec_ok else f"❌ La sección mínima obligatoria para circuitos {circuit_type} es de {min_sec} mm² (elegiste {section} mm²)."
# 2. Validación de cantidad máxima de bocas
max_b = rules["max_bocas"]
bocas_ok = bocas <= max_b
bocas_msg = f"✅ Cantidad de bocas ({bocas}) dentro del límite reglamentario (máx {max_b})." if bocas_ok else f"❌ Exceso de bocas en el circuito. La norma especifica un máximo de {max_b} bocas por circuito {circuit_type} (elegiste {bocas})."
# 3. Validación de la protección (Térmica Máxima del circuito)
max_p = rules["max_protection"]
prot_rule_ok = protection <= max_p
prot_rule_msg = f"✅ Calibre de la térmica ({protection}A) cumple con el límite máximo de {max_p}A para {circuit_type}." if prot_rule_ok else f"❌ La protección máxima para circuitos {circuit_type} es de {max_p}A (elegiste {protection}A)."
# 4. Coordinación Cable - Térmica (Cálculo de corriente admisible)
# Buscamos en la tabla
capacity = CABLE_CURRENT_CAPACITY.get(section, {"2x": 0, "3x": 0})
# Asumimos monofásico (2 conductores cargados: 2x)
base_current = capacity["2x"]
# Aplicar factor de agrupamiento
group_factor = GROUPING_FACTORS.get(grouped_circuits, 0.50)
allowed_current_corrected = round(base_current * group_factor, 2)
# Chequear condición fundamental: In <= Iz (Calibre de térmica <= Corriente admisible corregida del cable)
coordination_ok = protection <= allowed_current_corrected
if coordination_ok:
coord_msg = f"✅ **COORDINACIÓN DE PROTECCIÓN CORRECTA:** La corriente admisible del cable ({allowed_current_corrected}A, corregida por {grouped_circuits} circuitos agrupados) es mayor o igual al calibre de la térmica ({protection}A). El cable está protegido contra sobrecargas."
status_box = "APROBADO - CUMPLE LA REGLAMENTACIÓN"
status_color = "green"
else:
coord_msg = f"⚠️ **¡PELIGRO DE INCENDIO!:** La térmica de {protection}A es mayor que la corriente admisible máxima que soporta el cable ({allowed_current_corrected}A) con {grouped_circuits} circuitos en la misma cañería. **El cable podría derretirse antes de que salte la térmica.** Se debe reducir la térmica a máximo {int(allowed_current_corrected)}A o aumentar la sección del conductor."
status_box = "RECHAZADO - RIESGO DE SOBRECARGA"
status_color = "red"
# Texto detallado explicativo
detail_html = f"""
{status_box}
{sec_msg}
{bocas_msg}
{prot_rule_msg}
{coord_msg}
Cálculo Técnico (Tabla 771.16.I y II.b):
- Corriente admisible base del cable de {section} mm²: {base_current}A.
- Factor de reducción por agrupamiento ({grouped_circuits} circuito/s en caño): x{group_factor}.
- Corriente máxima admitida corregida ($I_z$): {allowed_current_corrected}A.
- Llave térmica elegida ($I_n$): {protection}A.
"""
return detail_html
def audit_bathroom_zones(dist_horizontal, height, elem_type):
dist_horizontal = float(dist_horizontal)
height = float(height)
# 1. Determinar Zona
zone = "Zona 3"
# Zona 0
if dist_horizontal == 0 and height == 0:
zone = "Zona 0"
# Zona 1
elif dist_horizontal == 0 and height <= 225:
zone = "Zona 1"
# Zona 2
elif dist_horizontal <= 60 and height <= 225:
zone = "Zona 2"
# Zona 3
elif dist_horizontal > 60 and dist_horizontal <= 240 and height <= 225:
zone = "Zona 3"
else:
# Fuera de las zonas del baño
zone = "Fuera de volumen de peligro"
# 2. Verificar permitidos por elemento
permitted = False
req_ip = "IPX0"
safety_rule = ""
if zone == "Zona 0":
req_ip = "IPX7"
if elem_type == "Termotanque":
safety_rule = "Está terminantemente prohibido colocar calefones o termotanques dentro de la bañera o espacio de ducha (Zona 0)."
else:
safety_rule = "Sólo se permiten equipos de Muy Baja Tensión de Seguridad (MBTS) de hasta 12V alternos, con fuente de seguridad fuera del baño."
elif zone == "Zona 1":
req_ip = "IPX5"
if elem_type == "Termotanque":
permitted = True
safety_rule = "Se permite únicamente si es un calefón eléctrico o termotanque de fijación fija con alimentación dedicada y protección de disyuntor de 30mA."
elif elem_type == "Luminaria común":
safety_rule = "Solo se permiten luminarias fijas protegidas por MBTS (hasta 12V) con fuente instalada en Zona 3 o fuera del baño."
else:
safety_rule = "Prohibido instalar interruptores o tomacorrientes en Zona 1. Tampoco se permite el paso de cañerías ajenas a este volumen."
elif zone == "Zona 2":
req_ip = "IPX4"
if elem_type == "Tomacorriente":
safety_rule = "No se permiten tomacorrientes comunes. Solo se permite el tomacorriente para máquinas de afeitar alimentado por transformador de aislación (según IRAM 2445)."
elif elem_type == "Interruptor":
safety_rule = "No se permiten llaves de luz comunes. Deben estar fuera de esta zona o activarse por piolín/remoto de material aislante."
elif elem_type == "Luminaria común":
permitted = True
safety_rule = "Permitido luminarias clase II con grado de protección IPX4 o mayor."
else:
permitted = True
safety_rule = "Permitido termotanques y calentadores con IPX4 fijos."
elif zone == "Zona 3":
req_ip = "IPX1"
permitted = True
safety_rule = "Se permiten tomacorrientes, interruptores y luminarias comunes, SIEMPRE que cuenten con protección diferencial (disyuntor de hasta 30mA) y puesta a tierra obligatoria."
else:
permitted = True
safety_rule = "Fuera de la zonificación restrictiva del baño. Se aplican las reglas comunes de interiores de viviendas."
status_str = "CUMPLE REGLAMENTO" if permitted else "NO CUMPLE REGLAMENTO"
color = "#2e7d32" if permitted else "#c62828"
html_res = f"""
Ubicación: {zone}
Estado:{status_str}
Índice de protección mínimo requerido:{req_ip}
Explicación del Reglamento Eléctrico Argentino (Sección 701): {safety_rule}
"""
return html_res
def calc_electrification(covered_m2, semi_covered_m2):
covered = float(covered_m2)
semi = float(semi_covered_m2)
# Límite de aplicación (LA)
la = covered + (semi * 0.5)
# Grado de electrificación (Tabla 771.8.I)
grade = ""
circuits_num = 0
circuits_desc = ""
points_of_utilization = ""
if la <= 60:
grade = "MÍNIMO"
circuits_num = 2
circuits_desc = "Mínimo 2 circuitos: 1 de Iluminación de Uso General (IUG) y 1 de Tomacorrientes de Uso General (TUG)."
points_of_utilization = """
Estar / Comedor: 1 boca de iluminación (IUG) y 1 boca de tomacorriente (TUG) cada 6m² (mínimo 2).
Dormitorio (<10m²): 1 boca de IUG y 2 bocas de TUG.
Cocina: 1 boca de IUG y 3 bocas de TUG + tomas para extractor y heladera.
Baño: 1 boca de IUG y 1 boca de TUG (fuera de Zona 2).
Pasillo / Vestíbulo: 1 boca de IUG y 1 de TUG cada 5m de longitud.
"""
elif la <= 130:
grade = "MEDIO"
circuits_num = 3
circuits_desc = "Mínimo 3 circuitos: Varias combinaciones permitidas. Ejemplo recomendado: 1 IUG + 1 TUG + 1 circuito libre (puede ser IUG, TUG, IUE o TUE)."
points_of_utilization = """
Estar / Comedor: 1 boca de IUG y 1 boca de TUG cada 6m² (mínimo 3).
Dormitorio (>=10m²): 1 boca de IUG y 3 bocas de TUG.
Cocina: 2 bocas de IUG y 3 bocas de TUG + tomas específicos (mínimo 3 tomas independientes).
Baño: 1 boca de IUG y 1 de TUG.
Pasillo / Vestíbulo: 1 boca de IUG y 1 de TUG cada 5m.
"""
elif la <= 200:
grade = "ELEVADO"
circuits_num = 5
circuits_desc = "Mínimo 5 circuitos: 2 IUG + 2 TUG + 1 circuito de Uso Especial (IUE o TUE)."
points_of_utilization = """
Estar / Comedor: 1 boca de IUG cada 18m² y 1 boca de TUG cada 6m² (mínimo 4).
Dormitorios: 1 boca de IUG y 3 bocas de TUG.
Cocina: 2 bocas de IUG y 4 bocas de TUG + tomas especiales.
Baño: 1 boca de IUG y 2 de TUG.
Pasillos amplios y lavadero: bocas dedicadas de TUG e IUG.
Estar / Comedor: Bocas abundantes. Mínimo 1 boca de IUG cada 18m² y 1 boca de TUG cada 6m² (mínimo 5).
Dormitorios: 1 boca de IUG y 4 bocas de TUG.
Cocina: Bocas de IUG y TUG abundantes (mínimo 4 bocas de tomacorriente generales).
Baño: 1 boca de IUG y 2 de TUG.
"""
html_res = f"""
Grado de Electrificación: {grade}
Límite de Aplicación calculado (LA): {la} m² (Cubierta: {covered} m², Semicubierta al 50%: {semi*0.5} m²)
Número mínimo de circuitos obligatorios:{circuits_num}
Configuración requerida: {circuits_desc}
Puntos Mínimos de Utilización (Bocas mínimas por ambiente):
{points_of_utilization}
"""
return html_res
@spaces.GPU
def rag_query_response(message, history):
# Buscar pasajes relevantes
matches = local_search(message, num_results=3)
if not matches:
return "No encontré pasajes relevantes en el Reglamento Eléctrico Argentino sobre ese tema. Probá usando palabras clave como 'cables', 'baño', 'bocas', 'toma', 'disyuntor' o 'térmica'."
# Construir el contexto
context_text = ""
for score, doc_id, page, text in matches:
doc_name = "Sección 771 (Viviendas)" if doc_id == 771 else "Sección 701 (Baños)"
context_text += f"\n--- {doc_name} - Página {page} (Relevancia: {score}) ---\n{text}\n"
# Responder usando el modelo si está cargado
if llm_model is not None and llm_tokenizer is not None:
try:
# Si corre en ZeroGPU, movemos el modelo a CUDA dentro de la función decorada
if torch.cuda.is_available():
llm_model.to("cuda")
# Formatear el prompt en base al contexto
prompt = f"<|im_start|>system\nEres un asistente de ingeniería eléctrica experto en el Reglamento Eléctrico Argentino (Secciones 771 y 701). Responde a la pregunta del usuario utilizando únicamente el contexto provisto del reglamento. Si el contexto no contiene la información para responder, indícalo claramente. Menciona siempre el documento y la página de donde obtienes la respuesta.\n<|im_end|>\n<|im_start|>user\nContexto normativo extraído:\n{context_text}\n\nPregunta: {message}\n<|im_end|>\n<|im_start|>assistant\n"
inputs = llm_tokenizer(prompt, return_tensors="pt")
# Mover inputs a GPU
if torch.cuda.is_available():
inputs = {k: v.to("cuda") for k, v in inputs.items()}
elif hasattr(llm_model, "device"):
inputs = {k: v.to(llm_model.device) for k, v in inputs.items()}
else:
inputs = {k: v.to(device) for k, v in inputs.items()}
# Generación con max_new_tokens de 350
outputs = llm_model.generate(
**inputs,
max_new_tokens=350,
temperature=0.2,
do_sample=False,
pad_token_id=llm_tokenizer.eos_token_id
)
response_text = llm_tokenizer.decode(outputs[0][inputs['input_ids'].shape[1]:], skip_special_tokens=True)
return response_text.strip()
except Exception as e:
print("Error en inferencia de modelo local:", e)
# Fallback si no está el LLM cargado: mostrar el pasaje directamente de forma prolija
response = "🤖 *Motor de consulta RAG local (Modo búsqueda directa):*\n\n"
response += "Encontré los siguientes artículos y páginas relevantes en el reglamento:\n"
for score, doc_id, page, text in matches:
doc_name = "Reglamento Eléctrico Argentino (Sección 771 - Viviendas)" if doc_id == 771 else "Reglamento Eléctrico Argentino (Sección 701 - Baños)"
# Extraer las primeras 3 líneas o 200 caracteres para mostrar un extracto limpio
clean_text = "\n".join(text.split("\n")[:8])
response += f"\n📖 **{doc_name} — Página {page}** (Relevancia: {score}):\n"
response += f"```text\n{clean_text}...\n```\n"
response += "\n*Nota: Podés ver el documento oficial PDF completo en tu carpeta de proyecto para leer el contexto extendido.*"
return response
# ==========================================
# NUEVAS HERRAMIENTAS DE LA SUITE ELÉCTRICA
# ==========================================
import math
def get_standard_in(ib, max_in=100):
breaks = [10, 16, 20, 25, 32, 40, 50, 63, 80, 100, 125, 160, 200, 250]
for b in breaks:
if b >= ib:
return max_in if b > max_in else b
return max_in
def get_standard_diff(in_val):
diffs = [25, 40, 63, 80, 100, 125, 160, 200, 250]
for d in diffs:
if d >= in_val:
return d
return in_val
def get_cable(in_val, is_iug=False):
cables = [
{"s": "1.5", "iz": 15}, {"s": "2.5", "iz": 21}, {"s": "4.0", "iz": 28}, {"s": "6.0", "iz": 36},
{"s": "10.0", "iz": 50}, {"s": "16.0", "iz": 66}, {"s": "25.0", "iz": 88}, {"s": "35.0", "iz": 109},
{"s": "50.0", "iz": 131}, {"s": "70.0", "iz": 167}, {"s": "95.0", "iz": 202}, {"s": "120.0", "iz": 234}
]
start_idx = 0 if is_iug else 1
for i in range(start_idx, len(cables)):
if cables[i]["iz"] >= in_val:
return cables[i]
return cables[-1]
def draw_power_triangle_svg(P, cos_obs, cos_target, S, qc):
phi_obs = math.acos(cos_obs)
phi_target = math.acos(cos_target)
width = 500
height = 300
padding_left = 65
padding_bottom = 50
max_width = width - padding_left - 50
max_height = height - padding_bottom - 40
q_original = math.sqrt(max(0.0, S**2 - P**2))
q_compensated = max(0.0, q_original - qc)
s_compensated = P / cos_target
max_power = max(P, q_original)
scale = min(max_width / P, max_height / max_power) if max_power > 0 else 1.0
ox = padding_left
oy = height - padding_bottom
px = ox + (P * scale)
py = oy
qy_original = oy - (q_original * scale)
qy_compensated = oy - (q_compensated * scale)
svg = f""""
return svg
def calc_power_factor(P, I, V, target_cos, freq):
try:
P = float(P)
I = float(I)
V = float(V)
target_cos = float(target_cos)
freq = int(freq)
S = V * I
if S == 0:
return "
Error: Tensión y corriente deben ser mayores a 0.
", "", "", ""
cos_observed = P / S
if cos_observed > 1.0:
err_msg = f"""
⚠️ Inconsistencia Física Detectada
El cos(φ) observado resultante es {cos_observed:.3f}, el cual supera el límite teórico de 1.0.
Esto se debe a que la potencia activa ($P$) no puede ser mayor que la potencia aparente ($S = V \times I$). Sugerencia: Aumente la corriente medida o disminuya la potencia activa ingresada.
"""
return err_msg, "", "", ""
phi_observed = math.acos(cos_observed)
phi_target = math.acos(target_cos)
k = math.tan(phi_observed) - math.tan(phi_target)
qc = P * k
if qc < 0:
qc = 0.0
k = 0.0
omega = 314 if freq == 50 else 377
if qc > 0:
Xc = (V ** 2) / qc
C = 1000000.0 / (omega * Xc)
else:
Xc = 0.0
C = 0.0
s_compensated = P / target_cos
i_new = P / (V * target_cos)
i_old = I
diff_amps = i_old - i_new
percent_saved = (diff_amps / i_old) * 100 if i_old > 0 else 0.0
kva_saved = (S - s_compensated) / 1000.0
tbody_html = f"""
Potencia Activa (P)
{P:,.1f} W
Intensidad de Corriente (I)
{I:,.1f} A
Tensión (V)
{V:,.1f} V
Potencia Aparente Original (S)
{S:,.1f} VA
cos(φ) Observado
{cos_observed:.3f}
cos(φ) Objetivo
{target_cos:.2f}
Multiplicador k
{k:.4f}
Reactiva de Compensación (qc)
{qc:,.1f} VAR
Reactancia Capacitiva (Xc)
{Xc:,.2f} Ω
Capacitancia (C)
{C:,.2f} μF
"""
beneficios_html = f"""
Corriente Reducida
{diff_amps:.2f} A (-{percent_saved:.1f}%)
Corriente bajará de {i_old:.1f}A a {i_new:.1f}A, reduciendo pérdidas por efecto Joule.
Capacidad de Red Liberada
{kva_saved:.2f} kVA
Disminuye la potencia de transporte aparente de la red, liberando carga en transformadores.
💡 Evita Penalizaciones de Reactiva
Las distribuidoras penalizan severamente cos(φ) inferiores a 0.85 o 0.90.
Instalando un capacitor calibrado de {C:.1f} μF, garantizas operar bajo la meta reglamentaria de {target_cos:.2f}.
", ""
R = 0.0
formula_desc = ""
aprox_text = "-"
relacion_ld_text = "-"
warning_text = ""
if tipo == "Jabalina enterrada verticalmente (771-C.10.1)":
L = float(L_jab)
d = float(d_jab)
if L <= 0 or d <= 0:
return "
Error: Longitud y diámetro deben ser mayores a 0.
", ""
R = (rho / (2.0 * math.pi * L)) * (math.log((8.0 * L) / d) - 1.0)
formula_desc = "R = (ρ / (2·π·L)) · [ ln(8·L / d) - 1 ]"
relacion_ld = L / d
relacion_ld_text = f"{relacion_ld:.2f}"
if 25 <= relacion_ld <= 100:
aprox_text = f"{(0.75 * rho / L):.2f} Ω"
elif 100 < relacion_ld <= 600:
aprox_text = f"{(rho / L):.2f} Ω"
elif 600 < relacion_ld <= 3000:
aprox_text = f"{(1.2 * rho / L):.2f} Ω"
else:
aprox_text = "Fuera de rango aproximado normativo (25 ≤ L/d ≤ 3000)"
elif tipo == "Conductor desnudo enterrado horizontalmente (771-C.10.2)":
L = float(L_hor)
h = float(h_hor)
d = float(d_hor)
if L <= 0 or h <= 0 or d <= 0:
return "
Error: Dimensiones y profundidad deben ser mayores a 0.
", ""
t1 = math.log((4.0 * L) / d)
t2 = math.log(L / h)
t3 = - 2.0
t4 = (2.0 * h) / L
t5 = - (h ** 2) / L
t6 = (h ** 4) / (2.0 * L)
R = (rho / (2.0 * math.pi * L)) * (t1 + t2 + t3 + t4 + t5 + t6)
formula_desc = "R = (ρ / (2·π·L)) · [ ln(4L/d) + ln(L/h) - 2 + 2h/L - h²/L + h⁴/2L ]"
elif tipo == "Placa circular desnuda enterrada verticalmente (771-C.10.3)":
D = float(D_pla)
h = float(h_pla)
if D <= 0 or h <= 0:
return "
Error: Diámetro y profundidad deben ser mayores a 0.
", ""
if h < (D / 2.0):
warning_text = f"""
⚠️ Aviso de instalación: La profundidad del centro de la placa ({h}m) debería ser mayor o igual al radio ({D/2.0}m) para asegurar una dispersión de corriente adecuada.