Ed5 commited on
Commit
5379578
·
verified ·
1 Parent(s): 3749910

Update 1_📊_Analysis.py

Browse files
Files changed (1) hide show
  1. 1_📊_Analysis.py +862 -836
1_📊_Analysis.py CHANGED
@@ -1,837 +1,863 @@
1
- import streamlit as st
2
- import pandas as pd
3
- import pdfplumber
4
- import re
5
- from io import BytesIO
6
- from typing import List, Tuple
7
- from pydantic import BaseModel
8
- from openpyxl import Workbook
9
-
10
-
11
- # ==================== МОДЕЛИ ====================
12
-
13
- class Counts(BaseModel):
14
- RS485: int = 0
15
- ETH: int = 0
16
- TI: int = 0 # AI
17
- TS: int = 0 # DI
18
- TU: int = 0 # DO
19
- AO: int = 0 # AO
20
-
21
- def add(self, other: "Counts") -> None:
22
- self.RS485 += other.RS485
23
- self.ETH += other.ETH
24
- self.TI += other.TI
25
- self.TS += other.TS
26
- self.TU += other.TU
27
- self.AO += other.AO
28
-
29
- def total(self) -> int:
30
- return self.RS485 + self.ETH + self.TI + self.TS + self.TU + self.AO
31
-
32
-
33
- class PageResult(BaseModel):
34
- page: int
35
- is_scan: bool = False
36
- has_hidden_signals: bool = False
37
- has_undefined_tables: bool = False
38
- mode_info: str = ""
39
- counts: Counts = Counts()
40
- debug_log: List[str] = []
41
-
42
-
43
- # ==================== УТИЛИТЫ (ОБЩИЕ) ====================
44
-
45
- def clean_str(s):
46
- if s is None: return ""
47
- return str(s).strip().replace('\n', ' ')
48
-
49
-
50
- def normalize_signal_type(text: str) -> str:
51
- if not text: return ""
52
- replacements = {
53
- 'а': 'a', 'А': 'a', 'о': 'o', 'О': 'o',
54
- 'с': 'c', 'С': 'c', 'е': 'e', 'Е': 'e',
55
- 'х': 'x', 'Х': 'x', '0': 'o'
56
- }
57
- t = str(text).lower().strip()
58
- t = t.replace(" ", "").replace(".", "")
59
- res = []
60
- for char in t:
61
- res.append(replacements.get(char, char))
62
- return "".join(res)
63
-
64
-
65
- def is_garbage_row(row_str: str) -> bool:
66
- s = row_str.lower()
67
- if "изм." in s and "лист" in s: return True
68
- if "подп." in s and "дата" in s: return True
69
- if "инв. №" in s or "взам. инв" in s: return True
70
- if len(s) < 20 and re.search(r"лист\s*\d+", s): return True
71
- return False
72
-
73
-
74
- def is_4_20_ma(text: str) -> bool:
75
- if not text: return False
76
- if "4...20" in text or "4..20" in text or "0...20" in text: return True
77
- if "4-20" in text or "4 - 20" in text: return True
78
- pattern = r"(\d\s*м[аa])|(\bм[аa]\b)"
79
- if re.search(pattern, text, re.IGNORECASE): return True
80
- return False
81
-
82
-
83
- # === ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ (ДЛЯ СПЕЦ. ТАБЛИЦ) ===
84
-
85
- def find_no_column(df: pd.DataFrame) -> int:
86
- """Ищет колонку с номером (№, No, Pos) в шапке."""
87
- for r in range(min(5, len(df))):
88
- for c in range(len(df.columns)):
89
- val = clean_str(df.iloc[r, c]).lower()
90
- if "№" in val or "п/п" in val or val == "no" or "поз" in val:
91
- return c
92
- return 0 # По умолчанию 1-я колонка
93
-
94
-
95
- def is_valid_number(val: str) -> bool:
96
- """Проверяет, является ли значение номером (1, 2, 23, 1.1)."""
97
- v = val.replace(".", "").strip()
98
- return v.isdigit() and len(v) < 6
99
-
100
-
101
- # ==================== БЛОК (GENERIC) ====================
102
-
103
- def analyze_headers_deep(df: pd.DataFrame) -> dict:
104
- cols_map = {"type": -1, "cabinet": -1, "func": -1, "in": -1, "out": -1, "force_ignore": False}
105
- rows_to_scan = min(15, len(df))
106
- col_texts = []
107
- all_header_text = ""
108
- for c_idx in range(df.shape[1]):
109
- txt_parts = []
110
- for r_idx in range(rows_to_scan):
111
- val = clean_str(df.iloc[r_idx, c_idx]).lower()
112
- if "перечень" in val: continue
113
- if val: txt_parts.append(val)
114
- col_full = " ".join(txt_parts)
115
- col_texts.append(col_full)
116
- all_header_text += " " + col_full
117
-
118
- strict_type_2 = "входной сигнал на" in all_header_text and "выходной сигнал с" in all_header_text
119
- strict_type_1 = "тип сигнала" in all_header_text
120
-
121
- if not strict_type_2 and not strict_type_1:
122
- bad_keywords = ["наименование работ", "проверка", "монтаж", "содержание", "спецификация", "кол-во",
123
- "примечание"]
124
- if any(bk in all_header_text for bk in bad_keywords):
125
- cols_map["force_ignore"] = True
126
- return cols_map, col_texts
127
-
128
- for c_idx, full_text in enumerate(col_texts):
129
- if strict_type_1:
130
- if "тип" in full_text and "сигнал" in full_text:
131
- cols_map["type"] = c_idx
132
- elif "определение" in full_text and "функц" in full_text:
133
- cols_map["func"] = c_idx
134
- elif "место" in full_text and "устан" in full_text:
135
- cols_map["cabinet"] = c_idx
136
- if strict_type_2:
137
- if "входной сигнал на" in full_text:
138
- cols_map["in"] = c_idx
139
- elif "выходной сигнал с" in full_text:
140
- cols_map["out"] = c_idx
141
- if not strict_type_1 and not strict_type_2:
142
- if "тип" in full_text and "сигнал" in full_text:
143
- cols_map["type"] = c_idx
144
- elif "вход" in full_text and "сигнал" in full_text:
145
- cols_map["in"] = c_idx
146
- elif "вых" in full_text and "сигнал" in full_text:
147
- cols_map["out"] = c_idx
148
-
149
- if strict_type_2: cols_map["type"] = -1
150
- if strict_type_1: cols_map["in"] = -1; cols_map["out"] = -1
151
- return cols_map, col_texts
152
-
153
-
154
- def process_page_data_pdf(df: pd.DataFrame, cols: dict, cabinet_filter: str, debug_mode: bool) -> Tuple[
155
- Counts, List[str]]:
156
- c = Counts()
157
- logs = []
158
- if cols["in"] != -1 or cols["out"] != -1:
159
- table_type = 2
160
- elif cols["type"] != -1:
161
- table_type = 1
162
- else:
163
- return c, logs
164
-
165
- start_row = 0
166
- for r in range(min(15, len(df))):
167
- row_txt = " ".join([clean_str(x) for x in df.iloc[r]]).lower()
168
- is_header = False
169
- if table_type == 2:
170
- if "вход" in row_txt and "выход" in row_txt: is_header = True
171
- elif table_type == 1:
172
- if "тип" in row_txt and "сигнал" in row_txt: is_header = True
173
- if is_header: start_row = r + 1
174
-
175
- BAN_WORDS = ["проверка", "монтаж", "демонтаж", "подключение", "блок питания", "шина", "клеммн", "узип",
176
- "автоматическ", "кабель", "жгут", "труба", "коробка", "модуль"]
177
-
178
- for i in range(start_row, len(df)):
179
- row = df.iloc[i]
180
- row_full_text = " ".join([clean_str(x) for x in row]).lower()
181
- if not row_full_text.replace(" ", ""): continue
182
- if is_garbage_row(row_full_text): continue
183
- if cabinet_filter and cabinet_filter.lower() not in row_full_text: continue
184
-
185
- is_data_row = False
186
- sig_raw_t1 = ""
187
- if table_type == 1:
188
- sig_raw_t1 = clean_str(row.iloc[cols["type"]]) if cols["type"] != -1 else ""
189
- sig_norm = normalize_signal_type(sig_raw_t1)
190
- if sig_norm in ["ai", "di", "do", "ao", "rtd",
191
- "tc"] or "rs" in sig_norm or "eth" in sig_norm: is_data_row = True
192
- if not is_data_row and ("rs485" in row_full_text or "ethernet" in row_full_text): is_data_row = True
193
- elif table_type == 2:
194
- val_in = clean_str(row.iloc[cols["in"]]) if cols["in"] != -1 else ""
195
- val_out = clean_str(row.iloc[cols["out"]]) if cols["out"] != -1 else ""
196
- if (val_in or val_out) and "сигнал на" not in val_in:
197
- if not any(w in row_full_text for w in BAN_WORDS) or any(
198
- x in row_full_text for x in ["4..20", "24", "rs", "eth"]): is_data_row = True
199
-
200
- found = False
201
- if table_type == 1:
202
- sig_norm = normalize_signal_type(sig_raw_t1)
203
- func_val = clean_str(row.iloc[cols["func"]]).lower() if cols["func"] != -1 else ""
204
- full_ctx = row_full_text
205
- if sig_norm in ["ai", "rtd", "tc"]:
206
- c.TI += 1;
207
- found = "TI (AI)"
208
- elif sig_norm in ["di", "d1"]:
209
- c.TS += 1;
210
- found = "TS (DI)"
211
- elif sig_norm in ["do", "d0", "dq"]:
212
- c.TU += 1;
213
- found = "TU (DO)"
214
- elif sig_norm in ["ao", "aq"]:
215
- c.AO += 1;
216
- found = "TR (AO)"
217
- elif "rs485" in full_ctx or "modbus" in full_ctx:
218
- c.RS485 += 1;
219
- found = "RS485"
220
- elif "ethernet" in full_ctx:
221
- c.ETH += 1;
222
- found = "ETH"
223
- elif not found and is_data_row:
224
- if is_4_20_ma(func_val): c.TI += 1; found = "TI (Func 4-20)"
225
- elif table_type == 2:
226
- val_in = clean_str(row.iloc[cols["in"]]).lower() if cols["in"] != -1 else ""
227
- val_out = clean_str(row.iloc[cols["out"]]).lower() if cols["out"] != -1 else ""
228
- if val_in == "сигнал на" or val_out == "сигнал с": continue
229
- row_context = val_in + " " + val_out
230
- if "rs" in row_context and "485" in row_context:
231
- c.RS485 += 1;
232
- found = "RS485"
233
- elif "eth" in row_context or "modbus" in row_context:
234
- if not found: c.ETH += 1; found = "ETH"
235
- if not found:
236
- if val_in:
237
- if is_4_20_ma(val_in):
238
- c.TI += 1;
239
- found = "TI (AI 4-20)"
240
- elif "24" in val_in:
241
- c.TS += 1;
242
- found = "TS (DI 24V)"
243
- elif any(x in val_in for x in ["сух", "контакт", "no", "nc"]):
244
- c.TS += 1;
245
- found = "TS (DI)"
246
- elif "pt100" in val_in:
247
- c.TI += 1;
248
- found = "TI (RTD)"
249
- if val_out:
250
- if is_4_20_ma(val_out):
251
- c.AO += 1;
252
- found = "AO"
253
- elif "24" in val_out:
254
- c.TU += 1;
255
- found = "TU (DO 24V)"
256
- elif any(x in val_out for x in ["реле", "ламп", "звук"]):
257
- c.TU += 1;
258
- found = "TU (DO)"
259
-
260
- if debug_mode and found: logs.append(f"Стр {i} [Generic]: {found}")
261
- return c, logs
262
-
263
-
264
- # ==================== ЛОГИКА ДЛЯ СПЕЦ. ТАБЛИЦ (КРАНЫ И Т.Д.) ====================
265
-
266
- def detect_spec_header(text_context: str) -> str:
267
- """Расширенный поиск заголовков во всем тексте."""
268
- t = text_context.lower().replace("\n", " ").replace(" ", " ")
269
-
270
- if "таблица" in t:
271
- if "краны" in t: return "CRANES"
272
- if "телеизмерение" in t: return "TI"
273
- if "телесигнализация" in t: return "TS"
274
- if "телеуправление" in t: return "TU"
275
- if "телерегулирование" in t: return "AO"
276
-
277
- if "внешние цифровые" in t or ("интерфейс" in t and "протокол" in t and "таблица" in t):
278
- return "DIGITAL"
279
-
280
- return ""
281
-
282
-
283
- def process_spec_by_number(df: pd.DataFrame, mode: str, debug_mode: bool) -> Tuple[Counts, List[str]]:
284
- """Считаем сигналы по наличию номера в колонке №."""
285
- c = Counts()
286
- logs = []
287
- no_col = find_no_column(df)
288
-
289
- start_row = 0
290
- for r in range(min(5, len(df))):
291
- val = clean_str(df.iloc[r, no_col])
292
- if "№" in val or "п/п" in val or "no" in val.lower():
293
- start_row = r + 1
294
- break
295
-
296
- for i in range(start_row, len(df)):
297
- row_full = " ".join([clean_str(x) for x in df.iloc[i]]).lower()
298
- if is_garbage_row(row_full): continue
299
- if "примечание" in row_full: continue
300
-
301
- val_no = clean_str(df.iloc[i, no_col])
302
- if is_valid_number(val_no):
303
- if mode == "CRANES":
304
- c.TS += 4
305
- c.TU += 2
306
- if debug_mode: logs.append(f"Стр {i} [№{val_no}]: Кран -> +4 TS, +2 TU")
307
- elif mode == "TI":
308
- c.TI += 1
309
- if debug_mode: logs.append(f"Стр {i} [№{val_no}]: TI")
310
- elif mode == "TS":
311
- c.TS += 1
312
- if debug_mode: logs.append(f"Стр {i} [№{val_no}]: TS")
313
- elif mode == "TU":
314
- c.TU += 1
315
- if debug_mode: logs.append(f"Стр {i} [№{val_no}]: TU")
316
- elif mode == "AO":
317
- c.AO += 1
318
- if debug_mode: logs.append(f"Стр {i} [№{val_no}]: AO")
319
-
320
- return c, logs
321
-
322
-
323
- def process_spec_digital(df: pd.DataFrame, debug_mode: bool) -> Tuple[Counts, List[str]]:
324
- c = Counts()
325
- logs = []
326
- int_col = -1
327
- start_row = 0
328
- for r in range(min(5, len(df))):
329
- row_vals = [clean_str(x).lower() for x in df.iloc[r]]
330
- for idx, v in enumerate(row_vals):
331
- if "интерфейс" in v:
332
- int_col = idx
333
- start_row = r + 1
334
- break
335
- if int_col != -1: break
336
-
337
- if start_row == 0: start_row = 1
338
-
339
- for i in range(start_row, len(df)):
340
- row_txt = " ".join([clean_str(x).lower() for x in df.iloc[i]])
341
- if is_garbage_row(row_txt): continue
342
- if not row_txt.strip(): continue
343
-
344
- val = clean_str(df.iloc[i, int_col]).lower() if int_col != -1 else row_txt
345
- if "rs" in val and "485" in val:
346
- c.RS485 += 1
347
- if debug_mode: logs.append(f"Стр {i}: RS-485")
348
- elif "eth" in val or "tcp" in val:
349
- c.ETH += 1
350
- if debug_mode: logs.append(f"Стр {i}: Ethernet")
351
-
352
- return c, logs
353
-
354
-
355
- # ==================== ГЛАВНЫЙ АНАЛИЗАТОР PDF ====================
356
-
357
- def analyze_page_pdf(page, cabinet_filter: str, debug_mode: bool, last_mode: str) -> Tuple[PageResult, str]:
358
- res = PageResult(page=page.page_number)
359
- text = (page.extract_text() or "")
360
-
361
- # --- ДЕТЕКЦИЯ СТРАНИЦ-КАРТИНОК ---
362
- tables = page.extract_tables()
363
-
364
- # Если таблиц нет, НО есть картинки -> Это скорее всего скан таблицы
365
- if not tables:
366
- if page.images:
367
- res.is_scan = True
368
- # Лог только в дебаг, чтобы не пугать раньше времени
369
- if debug_mode: res.debug_log.append("Внимание: Найдена картинка, текстовы�� таблиц нет.")
370
- return res, last_mode
371
-
372
- current_mode_for_next_page = last_mode
373
-
374
- for idx, table in enumerate(tables):
375
- df = pd.DataFrame(table).fillna("")
376
- if df.shape[0] < 2:
377
- continue
378
-
379
- spec_type = detect_spec_header(text)
380
-
381
- if not spec_type and last_mode:
382
- cols_check, _ = analyze_headers_deep(df)
383
- is_generic = any(v != -1 for k, v in cols_check.items() if k != "force_ignore")
384
- if not is_generic:
385
- spec_type = last_mode
386
-
387
- if spec_type:
388
- # Специфическая логика
389
- current_mode_for_next_page = spec_type
390
- sub_c = Counts()
391
- sub_logs = []
392
-
393
- if spec_type in ["CRANES", "TI", "TS", "TU", "AO"]:
394
- sub_c, sub_logs = process_spec_by_number(df, spec_type, debug_mode)
395
- elif spec_type == "DIGITAL":
396
- sub_c, sub_logs = process_spec_digital(df, debug_mode)
397
-
398
- res.counts.add(sub_c)
399
- res.debug_log.extend(sub_logs)
400
- res.mode_info = spec_type
401
- continue
402
-
403
- # Старая логика (Generic)
404
- cols_map, _ = analyze_headers_deep(df)
405
- if cols_map.get("force_ignore"):
406
- continue
407
-
408
- valid_cols = any(v != -1 for k, v in cols_map.items() if k != "force_ignore")
409
- if valid_cols:
410
- current_mode_for_next_page = ""
411
- sub_c, sub_logs = process_page_data_pdf(df, cols_map, cabinet_filter, debug_mode)
412
- res.counts.add(sub_c)
413
- res.debug_log.extend(sub_logs)
414
- res.mode_info = "Generic"
415
-
416
- return res, current_mode_for_next_page
417
-
418
-
419
- # ==================== EXCEL ====================
420
- def find_cabinets_excel(df: pd.DataFrame) -> List[str]:
421
- cabinet_col_idx = -1
422
- for r in range(min(20, len(df))):
423
- row_vals = [clean_str(x).lower() for x in df.iloc[r]]
424
- for c, val in enumerate(row_vals):
425
- if "наименование" in val and "шкаф" in val: cabinet_col_idx = c; break
426
- if cabinet_col_idx != -1: break
427
- if cabinet_col_idx != -1:
428
- raw = df.iloc[:, cabinet_col_idx].dropna().unique()
429
- return sorted([clean_str(x) for x in raw if len(str(x)) > 3])
430
- return []
431
-
432
-
433
- def analyze_excel(df: pd.DataFrame, cabinet_filter: str, debug_mode: bool) -> PageResult:
434
- res = PageResult(page=1)
435
- c = Counts()
436
- col_cabinet = -1;
437
- col_type = -1;
438
- header_row = 0
439
- for r in range(min(20, len(df))):
440
- row_vals = [clean_str(x).lower() for x in df.iloc[r]]
441
- for idx, val in enumerate(row_vals):
442
- if "наименование" in val and "шкаф" in val: col_cabinet = idx
443
- if ("интерфейс" in val or "тип сигнала" in val) and "плк" in val:
444
- col_type = idx
445
- elif ("интерфейс" in val or "тип" in val) and col_type == -1:
446
- col_type = idx
447
- if col_cabinet != -1 and col_type != -1: header_row = r + 1; break
448
-
449
- if col_cabinet == -1 or col_type == -1: res.has_undefined_tables = True; return res
450
- for i in range(header_row, len(df)):
451
- row = df.iloc[i]
452
- cab = clean_str(row.iloc[col_cabinet])
453
- if cabinet_filter and cabinet_filter.lower() not in cab.lower(): continue
454
- typ = normalize_signal_type(clean_str(row.iloc[col_type]))
455
- found = ""
456
- if typ in ["ai", "ti"]:
457
- c.TI += 1;
458
- found = "AI"
459
- elif typ in ["di", "ts"]:
460
- c.TS += 1;
461
- found = "DI"
462
- elif typ in ["do", "tu"]:
463
- c.TU += 1;
464
- found = "DO"
465
- elif typ in ["ao"]:
466
- c.AO += 1;
467
- found = "AO"
468
- elif "rs" in typ:
469
- c.RS485 += 1;
470
- found = "RS"
471
- elif "eth" in typ:
472
- c.ETH += 1;
473
- found = "ETH"
474
- if found and debug_mode and i < 100: res.debug_log.append(f"Row {i}: {found}")
475
- res.counts = c
476
- return res
477
-
478
-
479
- def find_cabinets_pdf(pdf_bytes: bytes) -> List[str]:
480
- cabinets = set()
481
- with pdfplumber.open(BytesIO(pdf_bytes)) as pdf:
482
- for i in range(min(15, len(pdf.pages))):
483
- text = pdf.pages[i].extract_text() or ""
484
- matches = re.findall(r"(?:Шкаф|Щит)\s+([А-ЯA-Z0-9\-\.\(\)\s]+)", text, re.IGNORECASE)
485
- for m in matches: cabinets.add(f"Шкаф {m.split()[0]}")
486
- return sorted(list(cabinets))
487
-
488
-
489
- # ==================== ФУНКЦИИ ПОИСКА ВТОРОГО ВВОДА (ИСПРАВЛЕНО) ====================
490
-
491
- def check_second_input_text(text: str) -> bool:
492
- """
493
- Ищет Ввод 2 или Резерв, учитывая разрыв слов и специфику схем (QS).
494
- """
495
- t = text.lower().replace('\n', ' ')
496
-
497
- # 1. Защита от ложных срабатываний (если это просто "Таблица 2" или "Ввод 2 сигналов")
498
- # Если в строке есть слова "сигнал", "дискрет", "аналог" рядом с цифрой 2 - пропускаем.
499
- if re.search(r"ввод\s*2\s*(?:дискрет|аналог|сигнал)", t):
500
- return False
501
-
502
- patterns = [
503
- # Паттерн для ТАБЛИЦЫ (Скриншот 2): "Ввод 2 от резервного..."
504
- # Ищет "Ввод 2", за которым (через пробел) не идет слово "сигнал"
505
- r"ввод\s*(?:№)?\s*2\b(?!.*сигнал)",
506
-
507
- # Паттерн для СХЕМЫ (Скриншот 1): "Ввод питания ... (рез.)"
508
- # Ищет "Ввод", затем любой текст (до 40 символов), затем "(рез.)" или "резерв"
509
- r"ввод\s*питания.{0,40}?\((?:рез\.|резерв)\)",
510
-
511
- # Паттерн: "Ввод ... от резервного источника"
512
- r"ввод.{0,20}?от\s*резервного",
513
-
514
- # Паттерн для СХЕМЫ (Автоматы): Обычно 1QS - основной, 2QS - резервный
515
- r"\b2\s*qs\b",
516
-
517
- # Стандартные фразы
518
- r"резервн[а-я]*\s*ввод",
519
- r"питание\s*от\s*двух\s*вводов",
520
- r"\bавр\b"
521
- ]
522
-
523
- for p in patterns:
524
- if re.search(p, t):
525
- return True
526
- return False
527
-
528
-
529
- # ==================== УЛУЧШЕННЫЙ ПОИСК ИБП И ВРЕМЕНИ АВТОНОМИИ ====================
530
-
531
- def check_ups_and_time(text: str) -> Tuple[bool, str]:
532
- """
533
- Строгий поиск: ИБП засчитывается ТОЛЬКО если указано конкретное ВРЕМЯ (цифры).
534
- Если стоит прочерк ("-"), время не находится, и галочка не ставится.
535
- """
536
- t = text.lower().replace('\n', ' ').replace(' ', ' ')
537
-
538
- found_ups = False
539
- found_time = ""
540
-
541
- # 1. ПОИСК ВРЕМЕНИ (Главный критерий)
542
- # Ищем: "автономн/бесперебой/резерв" ... (до 100 симв) ... ЦИФРА ... МИН/ЧАС
543
- # \d+ гарантирует, что это цифра, а не прочерк "-".
544
-
545
- strict_time_pat = r"(?:автономн|бесперебой|резерв|ибп|ups).{0,100}?(\d+(?:[\.,]\d+)?|од(?:ин|ного)|двух|пол)\s*(час|мин)"
546
-
547
- match = re.search(strict_time_pat, t)
548
- if match:
549
- val = match.group(1) # "1", "30", "одного"
550
- unit = match.group(2) # "час", "мин"
551
-
552
- # Превращаем слова в цифры для красоты
553
- if "одн" in val:
554
- val = "1"
555
- elif "дву" in val:
556
- val = "2"
557
- elif "пол" in val:
558
- val = "0.5"
559
-
560
- found_time = f"{val} {unit}."
561
- found_ups = True
562
-
563
- # 2. ПОИСК БАЙПАСА
564
- # Если явно требуют "байпас" для ИБП, то ИБП нужен, даже если время не нашли (или оно стандартное)
565
- if "байпас" in t and ("ибп" in t or "ups" in t):
566
- found_ups = True
567
- if not found_time:
568
- found_time = "Стандарт (по байпасу)"
569
-
570
- return found_ups, found_time
571
-
572
-
573
- def scan_ups_full_pdf(pdf_bytes: bytes) -> Tuple[bool, str]:
574
- final_ups = False
575
- final_time = ""
576
- with pdfplumber.open(BytesIO(pdf_bytes)) as pdf:
577
- for page in pdf.pages:
578
- text = page.extract_text() or ""
579
- is_ups, t_str = check_ups_and_time(text)
580
- if is_ups:
581
- final_ups = True
582
- if t_str and "Стандарт" not in t_str: # Приоритет конкретному времени
583
- final_time = t_str
584
- elif t_str and not final_time:
585
- final_time = t_str
586
- return final_ups, final_time
587
-
588
-
589
- def scan_ups_full_excel(df: pd.DataFrame) -> Tuple[bool, str]:
590
- final_ups = False
591
- final_time = ""
592
- for r in range(min(300, len(df))): # Смотрим первые 300 строк
593
- row_txt = " ".join([clean_str(x) for x in df.iloc[r]])
594
- is_ups, t_str = check_ups_and_time(row_txt)
595
- if is_ups:
596
- final_ups = True
597
- if t_str and "Стандарт" not in t_str:
598
- final_time = t_str
599
- break # Нашли точное время - выходим
600
- elif t_str and not final_time:
601
- final_time = t_str
602
- return final_ups, final_time
603
-
604
-
605
- def check_input2_pdf(pdf_bytes: bytes) -> bool:
606
- with pdfplumber.open(BytesIO(pdf_bytes)) as pdf:
607
- # Сканируем первые 20 страниц (увеличили глубину поиска)
608
- for i in range(min(20, len(pdf.pages))):
609
- text = pdf.pages[i].extract_text() or ""
610
- if check_second_input_text(text):
611
- return True
612
- return False
613
-
614
-
615
- def check_input2_excel(df: pd.DataFrame) -> bool:
616
- # Сканируем первые 100 строк Excel (увеличили глубину поиска)
617
- for r in range(min(100, len(df))):
618
- row_txt = " ".join([clean_str(x) for x in df.iloc[r]]).lower()
619
- if check_second_input_text(row_txt):
620
- return True
621
- return False
622
-
623
-
624
- # ==================== UI ====================
625
-
626
- st.set_page_config(page_title="Анализ сигналов", layout="wide")
627
- st.title("Подсчет сигналов ТС, ТИ, ТУ, TR")
628
-
629
- if "has_input2" not in st.session_state: st.session_state.has_input2 = False
630
- if "has_ups" not in st.session_state: st.session_state.has_ups = False
631
- if "ups_time_str" not in st.session_state: st.session_state.ups_time_str = ""
632
- if "detected_cabinets" not in st.session_state: st.session_state.detected_cabinets = []
633
- if "cabinet_final" not in st.session_state: st.session_state.cabinet_final = ""
634
- if "reserve_val" not in st.session_state: st.session_state.reserve_val = 20
635
- if "res_list" not in st.session_state: st.session_state.res_list = []
636
- if "debug_mode" not in st.session_state: st.session_state.debug_mode = True
637
- if "last_filename" not in st.session_state: st.session_state.last_filename = ""
638
-
639
- uploaded_files = st.file_uploader("Загрузите PDF или Excel (можно несколько)", type=["pdf", "xlsx"],
640
- accept_multiple_files=True)
641
-
642
- if uploaded_files:
643
- # Проверка: если состав файлов изменился, сбрасываем результаты
644
- current_filenames = str(sorted([f.name for f in uploaded_files]))
645
- if st.session_state.last_filename != current_filenames:
646
- st.session_state.detected_cabinets = []
647
- st.session_state.res_list = []
648
- st.session_state.has_input2 = False
649
- st.session_state.has_ups = False
650
- st.session_state.ups_time_str = ""
651
- st.session_state.last_filename = current_filenames
652
-
653
- # Если список шкафов пуст, пробегаем по ВСЕМ файлам
654
- if not st.session_state.detected_cabinets:
655
- all_cabinets = set()
656
- has_in2 = False
657
- has_ups_found = False # <--- Новая переменная
658
-
659
- for file_obj in uploaded_files:
660
- fname = file_obj.name.lower()
661
- bytes_data = file_obj.getvalue()
662
-
663
- try:
664
- if fname.endswith(".xlsx"):
665
- df = pd.read_excel(BytesIO(bytes_data), header=None, engine='openpyxl')
666
- cabs = find_cabinets_excel(df)
667
- if check_input2_excel(df): has_in2 = True
668
-
669
- # --- НОВАЯ ПРОВЕРКА UPS ---
670
- is_u, t_s = scan_ups_full_excel(df)
671
- if is_u: has_ups_found = True
672
- if t_s: st.session_state.ups_time_str = t_s
673
- # --------------------------
674
-
675
- for c in cabs: all_cabinets.add(c)
676
- else:
677
- cabs = find_cabinets_pdf(bytes_data)
678
- if check_input2_pdf(bytes_data): has_in2 = True
679
-
680
- # --- НОВАЯ ПРОВЕРКА UPS ---
681
- is_u, t_s = scan_ups_full_pdf(bytes_data)
682
- if is_u: has_ups_found = True
683
- if t_s: st.session_state.ups_time_str = t_s
684
- # --------------------------
685
-
686
- for c in cabs: all_cabinets.add(c)
687
- except Exception as e:
688
- pass
689
-
690
- st.session_state.detected_cabinets = sorted(list(all_cabinets))
691
- st.session_state.has_input2 = has_in2
692
- st.session_state.has_ups = has_ups_found # <--- Сохраняем результат
693
-
694
- c_list = st.session_state.detected_cabinets
695
-
696
- with st.container():
697
- c1, c2 = st.columns(2)
698
- with c1:
699
- if c_list:
700
- sel = st.selectbox("Шкаф:", ["(Все)"] + c_list)
701
- st.session_state.cabinet_final = sel if sel != "(Все)" else ""
702
- st.text_input("Фильтр:", value=st.session_state.cabinet_final, key="manual_filter_input")
703
- if st.session_state.manual_filter_input: st.session_state.cabinet_final = st.session_state.manual_filter_input
704
- with c2:
705
- st.session_state.reserve_val = st.number_input("Резерв %:", value=st.session_state.reserve_val)
706
- st.session_state.debug_mode = st.checkbox("Debug", value=st.session_state.debug_mode)
707
- st.write("---")
708
-
709
- # Чекбокс Ввод 2
710
- st.session_state.has_input2 = st.checkbox("Есть Ввод 2 (Резерв)", value=st.session_state.has_input2)
711
-
712
- # Чекбокс ИБП
713
- st.session_state.has_ups = st.checkbox(
714
- "Требуется ИБП (UPS)",
715
- value=st.session_state.has_ups,
716
- help="Включается автоматически, если в ТЗ найдено время автономной работы или требование байпаса."
717
- )
718
-
719
- # Поле времени (появляется только если нужен ИБП)
720
- if st.session_state.has_ups:
721
- # Если время нашли автоматически, оно подставится. Если нет - можно ввести вручную.
722
- val_time = st.session_state.ups_time_str if st.session_state.ups_time_str else "10 мин."
723
- st.session_state.ups_time_str = st.text_input("Время работы:", value=val_time)
724
-
725
- if st.button("Старт", type="primary") and uploaded_files:
726
- st.session_state.res_list = []
727
-
728
- # Создаем общий прогресс-бар
729
- total_files = len(uploaded_files)
730
- main_bar = st.progress(0)
731
-
732
- for file_idx, file_obj in enumerate(uploaded_files):
733
- fname = file_obj.name.lower()
734
- bytes_data = file_obj.getvalue()
735
-
736
- # Логика обработки конкретного файла
737
- if fname.endswith(".xlsx"):
738
- try:
739
- df = pd.read_excel(BytesIO(bytes_data), header=None, engine='openpyxl')
740
- r = analyze_excel(df, st.session_state.cabinet_final, st.session_state.debug_mode)
741
- # Добавляем имя файла в лог для ясности
742
- r.debug_log.insert(0, f"=== ФАЙЛ: {file_obj.name} ===")
743
- st.session_state.res_list.append(r)
744
- except Exception as e:
745
- st.error(f"Ошибка при чтении {file_obj.name}: {e}")
746
- else:
747
- try:
748
- with pdfplumber.open(BytesIO(bytes_data)) as pdf:
749
- last_mode = ""
750
- for i, p in enumerate(pdf.pages):
751
- r, last_mode = analyze_page_pdf(p, st.session_state.cabinet_final,
752
- st.session_state.debug_mode, last_mode)
753
- # Если это первая страница PDF, добавим метку файла
754
- if i == 0: r.debug_log.insert(0, f"=== ФАЙЛ: {file_obj.name} ===")
755
- st.session_state.res_list.append(r)
756
- except Exception as e:
757
- st.error(f"Ошибка при чтении PDF {file_obj.name}: {e}")
758
-
759
- # Обновляем прогресс
760
- main_bar.progress((file_idx + 1) / total_files)
761
-
762
- if st.session_state.res_list:
763
- st.divider()
764
- total_c = Counts()
765
- scanned_pages = []
766
-
767
- for r in st.session_state.res_list:
768
- total_c.add(r.counts)
769
- if r.is_scan: scanned_pages.append(str(r.page))
770
-
771
- st.subheader("Результаты")
772
-
773
- # --- УМНЫЙ БЛОК ПРЕДУПРЕЖДЕНИЙ ---
774
- # Показываем красный алерт ТОЛЬКО если сигналов МАЛО (< 15) и есть картинки.
775
- # Если сигналов много (100+), мы считаем, что картинки - это штампы, и не пугаем пользователя.
776
-
777
- total_signals_count = total_c.total()
778
-
779
- if total_signals_count < 15 and scanned_pages:
780
- st.error(
781
- f"⚠️ ВНИМАНИЕ: Найдено всего {total_signals_count} сигналов. При этом обнаружены страницы-картинки (текст не распознан): {', '.join(scanned_pages)}. Вероятно, таблица сигналов находится там.")
782
- elif total_signals_count < 15:
783
- st.warning(
784
- f"⚠️ Найдено всего {total_signals_count} сигналов. Проверьте документ, возможно формат таблиц не поддерживается.")
785
-
786
- # ----------------------------------
787
-
788
- if total_signals_count > 0:
789
- c1, c2, c3, c4, c5, c6 = st.columns(6)
790
- c1.metric("RS-485", total_c.RS485)
791
- c2.metric("ETH", total_c.ETH)
792
- c3.metric("AI (TI)", total_c.TI)
793
- c4.metric("DI (TS)", total_c.TS)
794
- c5.metric("DO (TU)", total_c.TU)
795
- c6.metric("TR (AO)", total_c.AO)
796
- else:
797
- st.warning("Сигналы не найдены.")
798
-
799
- if st.session_state.debug_mode:
800
- with st.expander("LOGS"):
801
- for r in st.session_state.res_list:
802
- if r.debug_log:
803
- st.write(f"**Page {r.page}** [{r.mode_info}]")
804
- for l in r.debug_log:
805
- c = "green" if "->" in l else "blue" if "ПРОДОЛЖЕНИЕ" in l else "red" if "Внимание" in l else "black"
806
- st.markdown(f":{c}[{l}]")
807
- st.divider()
808
-
809
- edit_data = pd.DataFrame({
810
- "Тип": ["RS-485", "ETH", "AI (TI)", "DI (TS)", "DO (TU)", "TR (AO)"],
811
- "Авто": [total_c.RS485, total_c.ETH, total_c.TI, total_c.TS, total_c.TU, total_c.AO],
812
- "Коррекция": [0, 0, 0, 0, 0, 0]
813
- })
814
- edited = st.data_editor(edit_data, use_container_width=True, hide_index=True)
815
-
816
- # === НОВЫЙ БЛОК: СОХРАНЕНИЕ РУЧНЫХ ПРАВОК В ПАМЯТЬ ===
817
- saved_signals = {}
818
- for idx, row in edited.iterrows():
819
- # Складываем Авто + Коррекция
820
- total_val = row["Авто"] + row["Коррекция"]
821
- saved_signals[row["Тип"]] = total_val
822
-
823
- # Сохраняем словарь в сессию, чтобы видеть его на других страницах-
824
- st.session_state['manual_signals_counts'] = saved_signals
825
-
826
- final_rows = []
827
- for idx, row in edited.iterrows():
828
- final_rows.append([row["Тип"], row["Авто"], row["Коррекция"], row["Авто"] + row["Коррекция"]])
829
-
830
- df_ex = pd.DataFrame(final_rows, columns=["Тип", "Авто", "Коррекция", "Итого"])
831
- bio = BytesIO()
832
- with pd.ExcelWriter(bio, engine='openpyxl') as writer:
833
- df_ex.to_excel(writer, index=False, sheet_name="Signals")
834
- ws = writer.sheets['Signals']
835
- ws["A8"] = f"Резерв {st.session_state.reserve_val}%"
836
- bio.seek(0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
837
  st.download_button("Скачать отчет", bio, "signals.xlsx")
 
1
+ import streamlit as st
2
+ import pandas as pd
3
+ import pdfplumber
4
+ import re
5
+ from io import BytesIO
6
+ from typing import List, Tuple
7
+ from pydantic import BaseModel
8
+ from openpyxl import Workbook
9
+
10
+
11
+ # ==================== МОДЕЛИ ====================
12
+
13
+ class Counts(BaseModel):
14
+ RS485: int = 0
15
+ ETH: int = 0
16
+ TI: int = 0 # AI
17
+ TS: int = 0 # DI
18
+ TU: int = 0 # DO
19
+ AO: int = 0 # AO
20
+
21
+ def add(self, other: "Counts") -> None:
22
+ self.RS485 += other.RS485
23
+ self.ETH += other.ETH
24
+ self.TI += other.TI
25
+ self.TS += other.TS
26
+ self.TU += other.TU
27
+ self.AO += other.AO
28
+
29
+ def total(self) -> int:
30
+ return self.RS485 + self.ETH + self.TI + self.TS + self.TU + self.AO
31
+
32
+
33
+ class PageResult(BaseModel):
34
+ page: int
35
+ is_scan: bool = False
36
+ has_hidden_signals: bool = False
37
+ has_undefined_tables: bool = False
38
+ mode_info: str = ""
39
+ counts: Counts = Counts()
40
+ debug_log: List[str] = []
41
+
42
+
43
+ # ==================== УТИЛИТЫ (ОБЩИЕ) ====================
44
+
45
+ def clean_str(s):
46
+ if s is None: return ""
47
+ return str(s).strip().replace('\n', ' ')
48
+
49
+
50
+ def normalize_signal_type(text: str) -> str:
51
+ if not text: return ""
52
+ replacements = {
53
+ 'а': 'a', 'А': 'a', 'о': 'o', 'О': 'o',
54
+ 'с': 'c', 'С': 'c', 'е': 'e', 'Е': 'e',
55
+ 'х': 'x', 'Х': 'x', '0': 'o'
56
+ }
57
+ t = str(text).lower().strip()
58
+ t = t.replace(" ", "").replace(".", "")
59
+ res = []
60
+ for char in t:
61
+ res.append(replacements.get(char, char))
62
+ return "".join(res)
63
+
64
+
65
+ def is_garbage_row(row_str: str) -> bool:
66
+ s = row_str.lower()
67
+ if "изм." in s and "лист" in s: return True
68
+ if "подп." in s and "дата" in s: return True
69
+ if "инв. №" in s or "взам. инв" in s: return True
70
+ if len(s) < 20 and re.search(r"лист\s*\d+", s): return True
71
+ return False
72
+
73
+
74
+ def is_4_20_ma(text: str) -> bool:
75
+ if not text: return False
76
+ if "4...20" in text or "4..20" in text or "0...20" in text: return True
77
+ if "4-20" in text or "4 - 20" in text: return True
78
+ pattern = r"(\d\s*м[аa])|(\bм[аa]\b)"
79
+ if re.search(pattern, text, re.IGNORECASE): return True
80
+ return False
81
+
82
+
83
+ # === ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ (ДЛЯ СПЕЦ. ТАБЛИЦ) ===
84
+
85
+ def find_no_column(df: pd.DataFrame) -> int:
86
+ """Ищет колонку с номером (№, No, Pos) в шапке."""
87
+ for r in range(min(5, len(df))):
88
+ for c in range(len(df.columns)):
89
+ val = clean_str(df.iloc[r, c]).lower()
90
+ if "№" in val or "п/п" in val or val == "no" or "поз" in val:
91
+ return c
92
+ return 0 # По умолчанию 1-я колонка
93
+
94
+
95
+ def is_valid_number(val: str) -> bool:
96
+ """Проверяет, является ли значение номером (1, 2, 23, 1.1)."""
97
+ v = val.replace(".", "").strip()
98
+ return v.isdigit() and len(v) < 6
99
+
100
+
101
+ # ==================== БЛОК (GENERIC) ====================
102
+
103
+ def analyze_headers_deep(df: pd.DataFrame) -> dict:
104
+ cols_map = {"type": -1, "cabinet": -1, "func": -1, "in": -1, "out": -1, "force_ignore": False}
105
+ rows_to_scan = min(15, len(df))
106
+ col_texts = []
107
+ all_header_text = ""
108
+ for c_idx in range(df.shape[1]):
109
+ txt_parts = []
110
+ for r_idx in range(rows_to_scan):
111
+ val = clean_str(df.iloc[r_idx, c_idx]).lower()
112
+ if "перечень" in val: continue
113
+ if val: txt_parts.append(val)
114
+ col_full = " ".join(txt_parts)
115
+ col_texts.append(col_full)
116
+ all_header_text += " " + col_full
117
+
118
+ strict_type_2 = "входной сигнал на" in all_header_text and "выходной сигнал с" in all_header_text
119
+ strict_type_1 = "тип сигнала" in all_header_text
120
+
121
+ if not strict_type_2 and not strict_type_1:
122
+ bad_keywords = ["наименование работ", "проверка", "монтаж", "содержание", "спецификация", "кол-во",
123
+ "примечание"]
124
+ if any(bk in all_header_text for bk in bad_keywords):
125
+ cols_map["force_ignore"] = True
126
+ return cols_map, col_texts
127
+
128
+ for c_idx, full_text in enumerate(col_texts):
129
+ if strict_type_1:
130
+ if "тип" in full_text and "сигнал" in full_text:
131
+ cols_map["type"] = c_idx
132
+ elif "определение" in full_text and "функц" in full_text:
133
+ cols_map["func"] = c_idx
134
+ elif "место" in full_text and "устан" in full_text:
135
+ cols_map["cabinet"] = c_idx
136
+ if strict_type_2:
137
+ if "входной сигнал на" in full_text:
138
+ cols_map["in"] = c_idx
139
+ elif "выходной сигнал с" in full_text:
140
+ cols_map["out"] = c_idx
141
+ if not strict_type_1 and not strict_type_2:
142
+ if "тип" in full_text and "сигнал" in full_text:
143
+ cols_map["type"] = c_idx
144
+ elif "вход" in full_text and "сигнал" in full_text:
145
+ cols_map["in"] = c_idx
146
+ elif "вых" in full_text and "сигнал" in full_text:
147
+ cols_map["out"] = c_idx
148
+
149
+ if strict_type_2: cols_map["type"] = -1
150
+ if strict_type_1: cols_map["in"] = -1; cols_map["out"] = -1
151
+ return cols_map, col_texts
152
+
153
+
154
+ def process_page_data_pdf(df: pd.DataFrame, cols: dict, cabinet_filter: str, debug_mode: bool) -> Tuple[
155
+ Counts, List[str]]:
156
+ c = Counts()
157
+ logs = []
158
+ if cols["in"] != -1 or cols["out"] != -1:
159
+ table_type = 2
160
+ elif cols["type"] != -1:
161
+ table_type = 1
162
+ else:
163
+ return c, logs
164
+
165
+ start_row = 0
166
+ for r in range(min(15, len(df))):
167
+ row_txt = " ".join([clean_str(x) for x in df.iloc[r]]).lower()
168
+ is_header = False
169
+ if table_type == 2:
170
+ if "вход" in row_txt and "выход" in row_txt: is_header = True
171
+ elif table_type == 1:
172
+ if "тип" in row_txt and "сигнал" in row_txt: is_header = True
173
+ if is_header: start_row = r + 1
174
+
175
+ BAN_WORDS = ["проверка", "монтаж", "демонтаж", "подключение", "блок питания", "шина", "клеммн", "узип",
176
+ "автоматическ", "кабель", "жгут", "труба", "коробка", "модуль"]
177
+
178
+ for i in range(start_row, len(df)):
179
+ row = df.iloc[i]
180
+ row_full_text = " ".join([clean_str(x) for x in row]).lower()
181
+ if not row_full_text.replace(" ", ""): continue
182
+ if is_garbage_row(row_full_text): continue
183
+ if cabinet_filter and cabinet_filter.lower() not in row_full_text: continue
184
+
185
+ is_data_row = False
186
+ sig_raw_t1 = ""
187
+ if table_type == 1:
188
+ sig_raw_t1 = clean_str(row.iloc[cols["type"]]) if cols["type"] != -1 else ""
189
+ sig_norm = normalize_signal_type(sig_raw_t1)
190
+ if sig_norm in ["ai", "di", "do", "ao", "rtd",
191
+ "tc"] or "rs" in sig_norm or "eth" in sig_norm: is_data_row = True
192
+ if not is_data_row and ("rs485" in row_full_text or "ethernet" in row_full_text): is_data_row = True
193
+ elif table_type == 2:
194
+ val_in = clean_str(row.iloc[cols["in"]]) if cols["in"] != -1 else ""
195
+ val_out = clean_str(row.iloc[cols["out"]]) if cols["out"] != -1 else ""
196
+ if (val_in or val_out) and "сигнал на" not in val_in:
197
+ if not any(w in row_full_text for w in BAN_WORDS) or any(
198
+ x in row_full_text for x in ["4..20", "24", "rs", "eth"]): is_data_row = True
199
+
200
+ found = False
201
+ if table_type == 1:
202
+ sig_norm = normalize_signal_type(sig_raw_t1)
203
+ func_val = clean_str(row.iloc[cols["func"]]).lower() if cols["func"] != -1 else ""
204
+ full_ctx = row_full_text
205
+ if sig_norm in ["ai", "rtd", "tc"]:
206
+ c.TI += 1;
207
+ found = "TI (AI)"
208
+ elif sig_norm in ["di", "d1"]:
209
+ c.TS += 1;
210
+ found = "TS (DI)"
211
+ elif sig_norm in ["do", "d0", "dq"]:
212
+ c.TU += 1;
213
+ found = "TU (DO)"
214
+ elif sig_norm in ["ao", "aq"]:
215
+ c.AO += 1;
216
+ found = "TR (AO)"
217
+ elif "rs485" in full_ctx or "modbus" in full_ctx:
218
+ c.RS485 += 1;
219
+ found = "RS485"
220
+ elif "ethernet" in full_ctx:
221
+ c.ETH += 1;
222
+ found = "ETH"
223
+ elif not found and is_data_row:
224
+ if is_4_20_ma(func_val): c.TI += 1; found = "TI (Func 4-20)"
225
+ elif table_type == 2:
226
+ val_in = clean_str(row.iloc[cols["in"]]).lower() if cols["in"] != -1 else ""
227
+ val_out = clean_str(row.iloc[cols["out"]]).lower() if cols["out"] != -1 else ""
228
+ if val_in == "сигнал на" or val_out == "сигнал с": continue
229
+ row_context = val_in + " " + val_out
230
+ if "rs" in row_context and "485" in row_context:
231
+ c.RS485 += 1;
232
+ found = "RS485"
233
+ elif "eth" in row_context or "modbus" in row_context:
234
+ if not found: c.ETH += 1; found = "ETH"
235
+ if not found:
236
+ if val_in:
237
+ if is_4_20_ma(val_in):
238
+ c.TI += 1;
239
+ found = "TI (AI 4-20)"
240
+ elif "24" in val_in:
241
+ c.TS += 1;
242
+ found = "TS (DI 24V)"
243
+ elif any(x in val_in for x in ["сух", "контакт", "no", "nc"]):
244
+ c.TS += 1;
245
+ found = "TS (DI)"
246
+ elif "pt100" in val_in:
247
+ c.TI += 1;
248
+ found = "TI (RTD)"
249
+ if val_out:
250
+ if is_4_20_ma(val_out):
251
+ c.AO += 1;
252
+ found = "AO"
253
+ elif "24" in val_out:
254
+ c.TU += 1;
255
+ found = "TU (DO 24V)"
256
+ elif any(x in val_out for x in ["реле", "ламп", "звук"]):
257
+ c.TU += 1;
258
+ found = "TU (DO)"
259
+
260
+ if debug_mode and found: logs.append(f"Стр {i} [Generic]: {found}")
261
+ return c, logs
262
+
263
+
264
+ # ==================== ЛОГИКА ДЛЯ СПЕЦ. ТАБЛИЦ (КРАНЫ И Т.Д.) ====================
265
+
266
+ def detect_spec_header(text_context: str) -> str:
267
+ """Расширенный поиск заголовков во всем тексте."""
268
+ t = text_context.lower().replace("\n", " ").replace(" ", " ")
269
+
270
+ if "таблица" in t:
271
+ if "краны" in t: return "CRANES"
272
+ if "телеизмерение" in t: return "TI"
273
+ if "телесигнализация" in t: return "TS"
274
+ if "телеуправление" in t: return "TU"
275
+ if "телерегулирование" in t: return "AO"
276
+
277
+ if "внешние цифровые" in t or ("интерфейс" in t and "протокол" in t and "таблица" in t):
278
+ return "DIGITAL"
279
+
280
+ return ""
281
+
282
+
283
+ def process_spec_by_number(df: pd.DataFrame, mode: str, debug_mode: bool) -> Tuple[Counts, List[str]]:
284
+ """Считаем сигналы по наличию номера в колонке №."""
285
+ c = Counts()
286
+ logs = []
287
+ no_col = find_no_column(df)
288
+
289
+ start_row = 0
290
+ for r in range(min(5, len(df))):
291
+ val = clean_str(df.iloc[r, no_col])
292
+ if "№" in val or "п/п" in val or "no" in val.lower():
293
+ start_row = r + 1
294
+ break
295
+
296
+ for i in range(start_row, len(df)):
297
+ row_full = " ".join([clean_str(x) for x in df.iloc[i]]).lower()
298
+ if is_garbage_row(row_full): continue
299
+ if "примечание" in row_full: continue
300
+
301
+ val_no = clean_str(df.iloc[i, no_col])
302
+ if is_valid_number(val_no):
303
+ if mode == "CRANES":
304
+ c.TS += 4
305
+ c.TU += 2
306
+ if debug_mode: logs.append(f"Стр {i} [№{val_no}]: Кран -> +4 TS, +2 TU")
307
+ elif mode == "TI":
308
+ c.TI += 1
309
+ if debug_mode: logs.append(f"Стр {i} [№{val_no}]: TI")
310
+ elif mode == "TS":
311
+ c.TS += 1
312
+ if debug_mode: logs.append(f"Стр {i} [№{val_no}]: TS")
313
+ elif mode == "TU":
314
+ c.TU += 1
315
+ if debug_mode: logs.append(f"Стр {i} [№{val_no}]: TU")
316
+ elif mode == "AO":
317
+ c.AO += 1
318
+ if debug_mode: logs.append(f"Стр {i} [№{val_no}]: AO")
319
+
320
+ return c, logs
321
+
322
+
323
+ def process_spec_digital(df: pd.DataFrame, debug_mode: bool) -> Tuple[Counts, List[str]]:
324
+ c = Counts()
325
+ logs = []
326
+ int_col = -1
327
+ start_row = 0
328
+ for r in range(min(5, len(df))):
329
+ row_vals = [clean_str(x).lower() for x in df.iloc[r]]
330
+ for idx, v in enumerate(row_vals):
331
+ if "интерфейс" in v:
332
+ int_col = idx
333
+ start_row = r + 1
334
+ break
335
+ if int_col != -1: break
336
+
337
+ if start_row == 0: start_row = 1
338
+
339
+ for i in range(start_row, len(df)):
340
+ row_txt = " ".join([clean_str(x).lower() for x in df.iloc[i]])
341
+ if is_garbage_row(row_txt): continue
342
+ if not row_txt.strip(): continue
343
+
344
+ val = clean_str(df.iloc[i, int_col]).lower() if int_col != -1 else row_txt
345
+ if "rs" in val and "485" in val:
346
+ c.RS485 += 1
347
+ if debug_mode: logs.append(f"Стр {i}: RS-485")
348
+ elif "eth" in val or "tcp" in val:
349
+ c.ETH += 1
350
+ if debug_mode: logs.append(f"Стр {i}: Ethernet")
351
+
352
+ return c, logs
353
+
354
+
355
+ # ==================== ГЛАВНЫЙ АНАЛИЗАТОР PDF ====================
356
+
357
+ def analyze_page_pdf(page, cabinet_filter: str, debug_mode: bool, last_mode: str) -> Tuple[PageResult, str]:
358
+ res = PageResult(page=page.page_number)
359
+ text = (page.extract_text() or "")
360
+
361
+ # --- ДЕТЕКЦИЯ СТРАНИЦ-КАРТИНОК ---
362
+ tables = page.extract_tables()
363
+
364
+ # Если таблиц нет, НО есть картинки -> Это скорее всего скан таблицы
365
+ if not tables:
366
+ if page.images:
367
+ res.is_scan = True
368
+ # Лог только в дебаг, чтобы не пугать раньше времени
369
+ if debug_mode: res.debug_log.append("Внимание: Найдена картинка, текстовых таблиц нет.")
370
+ return res, last_mode
371
+
372
+ current_mode_for_next_page = last_mode
373
+
374
+ for idx, table in enumerate(tables):
375
+ df = pd.DataFrame(table).fillna("")
376
+ if df.shape[0] < 2:
377
+ continue
378
+
379
+ spec_type = detect_spec_header(text)
380
+
381
+ if not spec_type and last_mode:
382
+ cols_check, _ = analyze_headers_deep(df)
383
+ is_generic = any(v != -1 for k, v in cols_check.items() if k != "force_ignore")
384
+ if not is_generic:
385
+ spec_type = last_mode
386
+
387
+ if spec_type:
388
+ # Специфическая логика
389
+ current_mode_for_next_page = spec_type
390
+ sub_c = Counts()
391
+ sub_logs = []
392
+
393
+ if spec_type in ["CRANES", "TI", "TS", "TU", "AO"]:
394
+ sub_c, sub_logs = process_spec_by_number(df, spec_type, debug_mode)
395
+ elif spec_type == "DIGITAL":
396
+ sub_c, sub_logs = process_spec_digital(df, debug_mode)
397
+
398
+ res.counts.add(sub_c)
399
+ res.debug_log.extend(sub_logs)
400
+ res.mode_info = spec_type
401
+ continue
402
+
403
+ # Старая логика (Generic)
404
+ cols_map, _ = analyze_headers_deep(df)
405
+ if cols_map.get("force_ignore"):
406
+ continue
407
+
408
+ valid_cols = any(v != -1 for k, v in cols_map.items() if k != "force_ignore")
409
+ if valid_cols:
410
+ current_mode_for_next_page = ""
411
+ sub_c, sub_logs = process_page_data_pdf(df, cols_map, cabinet_filter, debug_mode)
412
+ res.counts.add(sub_c)
413
+ res.debug_log.extend(sub_logs)
414
+ res.mode_info = "Generic"
415
+
416
+ return res, current_mode_for_next_page
417
+
418
+
419
+ # ==================== EXCEL ====================
420
+ def find_cabinets_excel(df: pd.DataFrame) -> List[str]:
421
+ cabinet_col_idx = -1
422
+ for r in range(min(20, len(df))):
423
+ row_vals = [clean_str(x).lower() for x in df.iloc[r]]
424
+ for c, val in enumerate(row_vals):
425
+ if "наименование" in val and "шкаф" in val: cabinet_col_idx = c; break
426
+ if cabinet_col_idx != -1: break
427
+ if cabinet_col_idx != -1:
428
+ raw = df.iloc[:, cabinet_col_idx].dropna().unique()
429
+ return sorted([clean_str(x) for x in raw if len(str(x)) > 3])
430
+ return []
431
+
432
+
433
+ def analyze_excel(df: pd.DataFrame, cabinet_filter: str, debug_mode: bool) -> PageResult:
434
+ res = PageResult(page=1)
435
+ c = Counts()
436
+ col_cabinet = -1;
437
+ col_type = -1;
438
+ header_row = 0
439
+ for r in range(min(20, len(df))):
440
+ row_vals = [clean_str(x).lower() for x in df.iloc[r]]
441
+ for idx, val in enumerate(row_vals):
442
+ if "наименование" in val and "шкаф" in val: col_cabinet = idx
443
+ if ("интерфейс" in val or "тип сигнала" in val) and "плк" in val:
444
+ col_type = idx
445
+ elif ("интерфейс" in val or "тип" in val) and col_type == -1:
446
+ col_type = idx
447
+ if col_cabinet != -1 and col_type != -1: header_row = r + 1; break
448
+
449
+ if col_cabinet == -1 or col_type == -1: res.has_undefined_tables = True; return res
450
+ for i in range(header_row, len(df)):
451
+ row = df.iloc[i]
452
+ cab = clean_str(row.iloc[col_cabinet])
453
+ if cabinet_filter and cabinet_filter.lower() not in cab.lower(): continue
454
+ typ = normalize_signal_type(clean_str(row.iloc[col_type]))
455
+ found = ""
456
+ if typ in ["ai", "ti"]:
457
+ c.TI += 1;
458
+ found = "AI"
459
+ elif typ in ["di", "ts"]:
460
+ c.TS += 1;
461
+ found = "DI"
462
+ elif typ in ["do", "tu"]:
463
+ c.TU += 1;
464
+ found = "DO"
465
+ elif typ in ["ao"]:
466
+ c.AO += 1;
467
+ found = "AO"
468
+ elif "rs" in typ:
469
+ c.RS485 += 1;
470
+ found = "RS"
471
+ elif "eth" in typ:
472
+ c.ETH += 1;
473
+ found = "ETH"
474
+ if found and debug_mode and i < 100: res.debug_log.append(f"Row {i}: {found}")
475
+ res.counts = c
476
+ return res
477
+
478
+
479
+ def find_cabinets_pdf(pdf_bytes: bytes) -> List[str]:
480
+ cabinets = set()
481
+ with pdfplumber.open(BytesIO(pdf_bytes)) as pdf:
482
+ for i in range(min(15, len(pdf.pages))):
483
+ text = pdf.pages[i].extract_text() or ""
484
+ matches = re.findall(r"(?:Шкаф|Щит)\s+([А-ЯA-Z0-9\-\.\(\)\s]+)", text, re.IGNORECASE)
485
+ for m in matches: cabinets.add(f"Шкаф {m.split()[0]}")
486
+ return sorted(list(cabinets))
487
+
488
+
489
+ # ==================== ФУНКЦИИ ПОИСКА ВТОРОГО ВВОДА (ИСПРАВЛЕНО) ====================
490
+
491
+ def check_second_input_text(text: str) -> bool:
492
+ """
493
+ Ищет Ввод 2 или Резерв, учитывая разрыв слов и специфику схем (QS).
494
+ """
495
+ t = text.lower().replace('\n', ' ')
496
+
497
+ # 1. Защита от ложных срабатываний (если это просто "Таблица 2" или "Ввод 2 сигналов")
498
+ # Если в строке есть слова "сигнал", "дискрет", "аналог" рядом с цифрой 2 - пропускаем.
499
+ if re.search(r"ввод\s*2\s*(?:дискрет|аналог|сигнал)", t):
500
+ return False
501
+
502
+ patterns = [
503
+ # Паттерн для ТАБЛИЦЫ (Скриншот 2): "Ввод 2 от резервного..."
504
+ # Ищет "Ввод 2", за которым (через пробел) не идет слово "сигнал"
505
+ r"ввод\s*(?:№)?\s*2\b(?!.*сигнал)",
506
+
507
+ # Паттерн для СХЕМЫ (Скриншот 1): "Ввод питания ... (рез.)"
508
+ # Ищет "Ввод", затем любой текст (до 40 символов), затем "(рез.)" или "резерв"
509
+ r"ввод\s*питания.{0,40}?\((?:рез\.|резерв)\)",
510
+
511
+ # Паттерн: "Ввод ... от резервного источника"
512
+ r"ввод.{0,20}?от\s*резервного",
513
+
514
+ # Паттерн для СХЕМЫ (Автоматы): Обычно 1QS - основной, 2QS - резервный
515
+ r"\b2\s*qs\b",
516
+
517
+ # Стандартные фразы
518
+ r"резервн[а-я]*\s*ввод",
519
+ r"питание\s*от\s*двух\s*вводов",
520
+ r"\bавр\b"
521
+ ]
522
+
523
+ for p in patterns:
524
+ if re.search(p, t):
525
+ return True
526
+ return False
527
+
528
+
529
+ # ==================== УЛУЧШЕННЫЙ ПОИСК ИБП И ВРЕМЕНИ АВТОНОМИИ ====================
530
+
531
+ def check_ups_and_time(text: str) -> Tuple[bool, str]:
532
+ """
533
+ Строгий поиск: ИБП засчитывается ТОЛЬКО если указано конкретное ВРЕМЯ (цифры).
534
+ Если стоит прочерк ("-"), время не находится, и галочка не ставится.
535
+ """
536
+ t = text.lower().replace('\n', ' ').replace(' ', ' ')
537
+
538
+ found_ups = False
539
+ found_time = ""
540
+
541
+ # 1. ПОИСК ВРЕМЕНИ (Главный критерий)
542
+ # Ищем: "автономн/бесперебой/резерв" ... (до 100 симв) ... ЦИФРА ... МИН/ЧАС
543
+ # \d+ гарантирует, что это цифра, а не прочерк "-".
544
+
545
+ strict_time_pat = r"(?:автономн|бесперебой|резерв|ибп|ups).{0,100}?(\d+(?:[\.,]\d+)?|од(?:ин|ного)|двух|пол)\s*(час|мин)"
546
+
547
+ match = re.search(strict_time_pat, t)
548
+ if match:
549
+ val = match.group(1) # "1", "30", "одного"
550
+ unit = match.group(2) # "час", "мин"
551
+
552
+ # Превращаем слова в цифры для красоты
553
+ if "одн" in val:
554
+ val = "1"
555
+ elif "дву" in val:
556
+ val = "2"
557
+ elif "пол" in val:
558
+ val = "0.5"
559
+
560
+ found_time = f"{val} {unit}."
561
+ found_ups = True
562
+
563
+ # 2. ПОИСК БАЙПАСА
564
+ # Если явно требуют "байпас" для ИБП, то ИБП нужен, даже если время не нашли (или оно стандартное)
565
+ if "байпас" in t and ("ибп" in t or "ups" in t):
566
+ found_ups = True
567
+ if not found_time:
568
+ found_time = "Стандарт (по байпасу)"
569
+
570
+ return found_ups, found_time
571
+
572
+
573
+ def scan_ups_full_pdf(pdf_bytes: bytes) -> Tuple[bool, str]:
574
+ final_ups = False
575
+ final_time = ""
576
+ with pdfplumber.open(BytesIO(pdf_bytes)) as pdf:
577
+ for page in pdf.pages:
578
+ text = page.extract_text() or ""
579
+ is_ups, t_str = check_ups_and_time(text)
580
+ if is_ups:
581
+ final_ups = True
582
+ if t_str and "Станд��рт" not in t_str: # Приоритет конкретному времени
583
+ final_time = t_str
584
+ elif t_str and not final_time:
585
+ final_time = t_str
586
+ return final_ups, final_time
587
+
588
+
589
+ def scan_ups_full_excel(df: pd.DataFrame) -> Tuple[bool, str]:
590
+ final_ups = False
591
+ final_time = ""
592
+ for r in range(min(300, len(df))): # Смотрим первые 300 строк
593
+ row_txt = " ".join([clean_str(x) for x in df.iloc[r]])
594
+ is_ups, t_str = check_ups_and_time(row_txt)
595
+ if is_ups:
596
+ final_ups = True
597
+ if t_str and "Стандарт" not in t_str:
598
+ final_time = t_str
599
+ break # Нашли точное время - выходим
600
+ elif t_str and not final_time:
601
+ final_time = t_str
602
+ return final_ups, final_time
603
+
604
+
605
+ def check_input2_pdf(pdf_bytes: bytes) -> bool:
606
+ with pdfplumber.open(BytesIO(pdf_bytes)) as pdf:
607
+ # Сканируем первые 20 страниц (увеличили глубину поиска)
608
+ for i in range(min(20, len(pdf.pages))):
609
+ text = pdf.pages[i].extract_text() or ""
610
+ if check_second_input_text(text):
611
+ return True
612
+ return False
613
+
614
+
615
+ def check_input2_excel(df: pd.DataFrame) -> bool:
616
+ # Сканируем первые 100 строк Excel (увеличили глубину поиска)
617
+ for r in range(min(100, len(df))):
618
+ row_txt = " ".join([clean_str(x) for x in df.iloc[r]]).lower()
619
+ if check_second_input_text(row_txt):
620
+ return True
621
+ return False
622
+
623
+ # ==================== КЕШИРОВАНИЕ (НОВОЕ) ====================
624
+
625
+ @st.cache_data(show_spinner=False)
626
+ def process_file_cached(filename: str, file_bytes: bytes, cabinet_filter: str, debug_mode: bool) -> List[PageResult]:
627
+ """
628
+ Эта функция выполняет тяжелую работу и ЗАПОМИНАЕТ результат.
629
+ Если подать те же байты файла, она вернет ответ мгновенно.
630
+ """
631
+ results = []
632
+
633
+ if filename.endswith(".xlsx"):
634
+ try:
635
+ df = pd.read_excel(BytesIO(file_bytes), header=None, engine='openpyxl')
636
+ r = analyze_excel(df, cabinet_filter, debug_mode)
637
+ r.debug_log.insert(0, f"=== ФАЙЛ: {filename} ===")
638
+ results.append(r)
639
+ except Exception as e:
640
+ # В кешированной функции нельзя использовать st.error, поэтому вернем пустой результат или обработаем позже
641
+ pass
642
+ else:
643
+ try:
644
+ with pdfplumber.open(BytesIO(file_bytes)) as pdf:
645
+ last_mode = ""
646
+ for i, p in enumerate(pdf.pages):
647
+ r, last_mode = analyze_page_pdf(p, cabinet_filter, debug_mode, last_mode)
648
+ if i == 0: r.debug_log.insert(0, f"=== ФАЙЛ: {filename} ===")
649
+ results.append(r)
650
+ except Exception as e:
651
+ pass
652
+
653
+ return results
654
+
655
+
656
+ # ==================== UI ====================
657
+
658
+ st.set_page_config(page_title="Анализ сигналов", layout="wide")
659
+ st.title("Подсчет сигналов ТС, ТИ, ТУ, TR")
660
+
661
+ if "has_input2" not in st.session_state: st.session_state.has_input2 = False
662
+ if "has_ups" not in st.session_state: st.session_state.has_ups = False
663
+ if "ups_time_str" not in st.session_state: st.session_state.ups_time_str = ""
664
+ if "detected_cabinets" not in st.session_state: st.session_state.detected_cabinets = []
665
+ if "cabinet_final" not in st.session_state: st.session_state.cabinet_final = ""
666
+ if "reserve_val" not in st.session_state: st.session_state.reserve_val = 20
667
+ if "res_list" not in st.session_state: st.session_state.res_list = []
668
+ if "debug_mode" not in st.session_state: st.session_state.debug_mode = True
669
+ if "last_filename" not in st.session_state: st.session_state.last_filename = ""
670
+
671
+ uploaded_files = st.file_uploader("Загрузите PDF или Excel (можно несколько)", type=["pdf", "xlsx"],
672
+ accept_multiple_files=True)
673
+
674
+ if uploaded_files:
675
+ # Проверка: если состав файлов изменился, сбрасываем результаты
676
+ current_filenames = str(sorted([f.name for f in uploaded_files]))
677
+ if st.session_state.last_filename != current_filenames:
678
+ st.session_state.detected_cabinets = []
679
+ st.session_state.res_list = []
680
+ st.session_state.has_input2 = False
681
+ st.session_state.has_ups = False
682
+ st.session_state.ups_time_str = ""
683
+ st.session_state.last_filename = current_filenames
684
+
685
+ # Если список шкафов пуст, пробегаем по ВСЕМ файлам
686
+ if not st.session_state.detected_cabinets:
687
+ all_cabinets = set()
688
+ has_in2 = False
689
+ has_ups_found = False # <--- Нова�� переменная
690
+
691
+ for file_obj in uploaded_files:
692
+ fname = file_obj.name.lower()
693
+ bytes_data = file_obj.getvalue()
694
+
695
+ try:
696
+ if fname.endswith(".xlsx"):
697
+ df = pd.read_excel(BytesIO(bytes_data), header=None, engine='openpyxl')
698
+ cabs = find_cabinets_excel(df)
699
+ if check_input2_excel(df): has_in2 = True
700
+
701
+ # --- НОВАЯ ПРОВЕРКА UPS ---
702
+ is_u, t_s = scan_ups_full_excel(df)
703
+ if is_u: has_ups_found = True
704
+ if t_s: st.session_state.ups_time_str = t_s
705
+ # --------------------------
706
+
707
+ for c in cabs: all_cabinets.add(c)
708
+ else:
709
+ cabs = find_cabinets_pdf(bytes_data)
710
+ if check_input2_pdf(bytes_data): has_in2 = True
711
+
712
+ # --- НОВАЯ ПРОВЕРКА UPS ---
713
+ is_u, t_s = scan_ups_full_pdf(bytes_data)
714
+ if is_u: has_ups_found = True
715
+ if t_s: st.session_state.ups_time_str = t_s
716
+ # --------------------------
717
+
718
+ for c in cabs: all_cabinets.add(c)
719
+ except Exception as e:
720
+ pass
721
+
722
+ st.session_state.detected_cabinets = sorted(list(all_cabinets))
723
+ st.session_state.has_input2 = has_in2
724
+ st.session_state.has_ups = has_ups_found # <--- Сохраняем результат
725
+
726
+ c_list = st.session_state.detected_cabinets
727
+
728
+ with st.container():
729
+ c1, c2 = st.columns(2)
730
+ with c1:
731
+ if c_list:
732
+ sel = st.selectbox("Шкаф:", ["(Все)"] + c_list)
733
+ st.session_state.cabinet_final = sel if sel != "(Все)" else ""
734
+ st.text_input("Фильтр:", value=st.session_state.cabinet_final, key="manual_filter_input")
735
+ if st.session_state.manual_filter_input: st.session_state.cabinet_final = st.session_state.manual_filter_input
736
+ with c2:
737
+ st.session_state.reserve_val = st.number_input("Резерв %:", value=st.session_state.reserve_val)
738
+ st.session_state.debug_mode = st.checkbox("Debug", value=st.session_state.debug_mode)
739
+ st.write("---")
740
+
741
+ # Чекбокс Ввод 2
742
+ st.session_state.has_input2 = st.checkbox("Есть Ввод 2 (Резерв)", value=st.session_state.has_input2)
743
+
744
+ # Чекбокс ИБП
745
+ st.session_state.has_ups = st.checkbox(
746
+ "Требуется ИБП (UPS)",
747
+ value=st.session_state.has_ups,
748
+ help="Включается автоматически, если в ТЗ найдено время автономной работы или требование байпаса."
749
+ )
750
+
751
+ # Поле времени (появляется только если нужен ИБП)
752
+ if st.session_state.has_ups:
753
+ # Если время нашли автоматически, оно подставится. Если нет - можно ввести вручную.
754
+ val_time = st.session_state.ups_time_str if st.session_state.ups_time_str else "10 мин."
755
+ st.session_state.ups_time_str = st.text_input("Время работы:", value=val_time)
756
+
757
+ if st.button("Старт", type="primary") and uploaded_files:
758
+ st.session_state.res_list = []
759
+
760
+ # Создаем общий прогресс-бар
761
+ total_files = len(uploaded_files)
762
+ main_bar = st.progress(0)
763
+
764
+ for file_idx, file_obj in enumerate(uploaded_files):
765
+ fname = file_obj.name.lower()
766
+ # Превращаем файл в байты, чтобы передать в кеш
767
+ bytes_data = file_obj.getvalue()
768
+
769
+ # --- ВЫЗЫВАЕМ КЕШИРОВАННУЮ ФУНКЦИЮ ---
770
+ # При повторном нажатии или добавлении файлов этот шаг выполнится за 0.01 сек
771
+ file_results = process_file_cached(
772
+ file_obj.name,
773
+ bytes_data,
774
+ st.session_state.cabinet_final,
775
+ st.session_state.debug_mode
776
+ )
777
+
778
+ if file_results:
779
+ st.session_state.res_list.extend(file_results)
780
+ else:
781
+ # Если вернулся пустой список, возможно была ошибка, но для кэша мы её подавили
782
+ # Можно вывести предупреждение, если нужно
783
+ pass
784
+
785
+ # Обновляем прогресс
786
+ main_bar.progress((file_idx + 1) / total_files)
787
+
788
+ if st.session_state.res_list:
789
+ st.divider()
790
+ total_c = Counts()
791
+ scanned_pages = []
792
+
793
+ for r in st.session_state.res_list:
794
+ total_c.add(r.counts)
795
+ if r.is_scan: scanned_pages.append(str(r.page))
796
+
797
+ st.subheader("Результаты")
798
+
799
+ # --- УМНЫЙ БЛОК ПРЕДУПРЕЖДЕНИЙ ---
800
+ # Показываем красный алерт ТОЛЬКО если сигналов МАЛО (< 15) и есть картинки.
801
+ # Если сигналов много (100+), мы считаем, что картинки - это штампы, и не пугаем пользователя.
802
+
803
+ total_signals_count = total_c.total()
804
+
805
+ if total_signals_count < 15 and scanned_pages:
806
+ st.error(
807
+ f"⚠️ ВНИМАНИЕ: Найдено всего {total_signals_count} сигналов. При этом обнаружены страницы-картинки (текст не распознан): {', '.join(scanned_pages)}. Вероятно, таблица сигналов находится там.")
808
+ elif total_signals_count < 15:
809
+ st.warning(
810
+ f"⚠️ Найдено всего {total_signals_count} сигналов. Проверьте документ, возможно формат таблиц не поддерживается.")
811
+
812
+ # ----------------------------------
813
+
814
+ if total_signals_count > 0:
815
+ c1, c2, c3, c4, c5, c6 = st.columns(6)
816
+ c1.metric("RS-485", total_c.RS485)
817
+ c2.metric("ETH", total_c.ETH)
818
+ c3.metric("AI (TI)", total_c.TI)
819
+ c4.metric("DI (TS)", total_c.TS)
820
+ c5.metric("DO (TU)", total_c.TU)
821
+ c6.metric("TR (AO)", total_c.AO)
822
+ else:
823
+ st.warning("Сигналы не найдены.")
824
+
825
+ if st.session_state.debug_mode:
826
+ with st.expander("LOGS"):
827
+ for r in st.session_state.res_list:
828
+ if r.debug_log:
829
+ st.write(f"**Page {r.page}** [{r.mode_info}]")
830
+ for l in r.debug_log:
831
+ c = "green" if "->" in l else "blue" if "ПРОДОЛЖЕНИЕ" in l else "red" if "Внимание" in l else "black"
832
+ st.markdown(f":{c}[{l}]")
833
+ st.divider()
834
+
835
+ edit_data = pd.DataFrame({
836
+ "Тип": ["RS-485", "ETH", "AI (TI)", "DI (TS)", "DO (TU)", "TR (AO)"],
837
+ "Авто": [total_c.RS485, total_c.ETH, total_c.TI, total_c.TS, total_c.TU, total_c.AO],
838
+ "Коррекция": [0, 0, 0, 0, 0, 0]
839
+ })
840
+ edited = st.data_editor(edit_data, use_container_width=True, hide_index=True)
841
+
842
+ # === НОВЫЙ БЛОК: СОХРАНЕНИЕ РУЧНЫХ ПРАВОК В ПАМЯТЬ ===
843
+ saved_signals = {}
844
+ for idx, row in edited.iterrows():
845
+ # Складываем Авто + Коррекция
846
+ total_val = row["Авто"] + row["Коррекция"]
847
+ saved_signals[row["Тип"]] = total_val
848
+
849
+ # Сохраняем словарь в сессию, чтобы видеть его на других страницах-
850
+ st.session_state['manual_signals_counts'] = saved_signals
851
+
852
+ final_rows = []
853
+ for idx, row in edited.iterrows():
854
+ final_rows.append([row["Тип"], row["Авто"], row["Коррекция"], row["Авто"] + row["Коррекция"]])
855
+
856
+ df_ex = pd.DataFrame(final_rows, columns=["Тип", "Авто", "Коррекция", "Итого"])
857
+ bio = BytesIO()
858
+ with pd.ExcelWriter(bio, engine='openpyxl') as writer:
859
+ df_ex.to_excel(writer, index=False, sheet_name="Signals")
860
+ ws = writer.sheets['Signals']
861
+ ws["A8"] = f"Резерв {st.session_state.reserve_val}%"
862
+ bio.seek(0)
863
  st.download_button("Скачать отчет", bio, "signals.xlsx")