table_parser.py 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985
  1. # Copyright (c) Opendatalab. All rights reserved.
  2. """
  3. 表格解析模块 v2 - 独立版本,不依赖v1
  4. """
  5. from typing import List
  6. import re
  7. from ..utils.logging_config import get_logger
  8. from ..models.data_models import OperationalCondition, OperationalConditionV2
  9. logger = get_logger("pdf_converter_v2.parser.table")
  10. def normalize_text(text: str) -> str:
  11. """将常见全角符号、大小写等统一,便于关键词匹配"""
  12. if not text:
  13. return ""
  14. text = text.lower()
  15. replacements = {
  16. "(": "(",
  17. ")": ")",
  18. ":": ":",
  19. "-": "-",
  20. "—": "-",
  21. "〜": "~",
  22. "~": "~",
  23. "/": "/",
  24. " ": " ",
  25. }
  26. for old, new in replacements.items():
  27. text = text.replace(old, new)
  28. return text
  29. def parse_table_cell(cell_content: str) -> str:
  30. """解析表格单元格内容"""
  31. if not cell_content:
  32. return ""
  33. cell_content = re.sub(r'<[^>]+>', '', cell_content)
  34. cell_content = re.sub(r'\s+', ' ', cell_content).strip()
  35. return cell_content
  36. def extract_table_data(markdown_content: str) -> List[List[List[str]]]:
  37. """从Markdown内容中提取表格数据"""
  38. tables: List[List[List[str]]] = []
  39. # 匹配带属性的table标签,如 <table border=1 style='...'>
  40. table_matches = re.findall(r'<table[^>]*>(.*?)</table>', markdown_content, re.DOTALL)
  41. logger.debug(f"[extract_table_data] 共找到 {len(table_matches)} 个表格")
  42. for table_idx, table_content in enumerate(table_matches):
  43. table_rows: List[List[str]] = []
  44. tr_matches = re.findall(r'<tr[^>]*>(.*?)</tr>', table_content, re.DOTALL)
  45. logger.debug(f"[extract_table_data] 表格{table_idx}, 行数: {len(tr_matches)}")
  46. for row_idx, tr_content in enumerate(tr_matches):
  47. td_matches = re.findall(r'<td[^>]*>(.*?)</td>', tr_content)
  48. row: List[str] = [parse_table_cell(td) for td in td_matches]
  49. if row:
  50. table_rows.append(row)
  51. if table_rows:
  52. tables.append(table_rows)
  53. logger.debug(f"[extract_table_data] 总表格: {len(tables)}")
  54. return tables
  55. def extract_table_with_rowspan_colspan(markdown_content: str) -> List[List[List[str]]]:
  56. """提取表格数据,处理rowspan和colspan属性"""
  57. tables: List[List[List[str]]] = []
  58. # 匹配带属性的table标签,如 <table border=1 style='...'>
  59. table_matches = re.findall(r'<table[^>]*>(.*?)</table>', markdown_content, re.DOTALL)
  60. logger.debug(f"[extract_table_with_rowspan_colspan] 共找到 {len(table_matches)} 个表格")
  61. for table_idx, table_content in enumerate(table_matches):
  62. tr_matches = re.findall(r'<tr[^>]*>(.*?)</tr>', table_content, re.DOTALL)
  63. logger.debug(f"[extract_table_with_rowspan_colspan] 表格{table_idx}, 行数: {len(tr_matches)}")
  64. if not tr_matches:
  65. continue
  66. # 用于存储rowspan的值(跨行的单元格值)
  67. rowspan_values = {} # {(row_idx, col_idx): (value, remaining_rows)}
  68. # 先构建一个矩阵来存储所有单元格
  69. max_cols = 0
  70. table_matrix = []
  71. for row_idx, tr_content in enumerate(tr_matches):
  72. # 找到所有td标签,包括属性
  73. td_pattern = r'<td[^>]*>(.*?)</td>'
  74. td_matches_with_attrs = re.finditer(td_pattern, tr_content, re.DOTALL)
  75. row = []
  76. col_idx = 0
  77. for td_match in td_matches_with_attrs:
  78. full_td = td_match.group(0)
  79. cell_content = td_match.group(1)
  80. # 提取rowspan和colspan属性
  81. rowspan_match = re.search(r'rowspan=["\']?(\d+)["\']?', full_td)
  82. colspan_match = re.search(r'colspan=["\']?(\d+)["\']?', full_td)
  83. rowspan = int(rowspan_match.group(1)) if rowspan_match else 1
  84. colspan = int(colspan_match.group(1)) if colspan_match else 1
  85. # 解析单元格内容
  86. cell_text = parse_table_cell(cell_content)
  87. # 跳过被rowspan占用的列
  88. while (row_idx, col_idx) in rowspan_values:
  89. row.append(rowspan_values[(row_idx, col_idx)][0]) # 使用rowspan的值
  90. remaining = rowspan_values[(row_idx, col_idx)][1] - 1
  91. if remaining > 0:
  92. rowspan_values[(row_idx + 1, col_idx)] = (rowspan_values[(row_idx, col_idx)][0], remaining)
  93. del rowspan_values[(row_idx, col_idx)]
  94. col_idx += 1
  95. # 添加单元格内容
  96. for c in range(colspan):
  97. row.append(cell_text if c == 0 else "")
  98. # 如果有rowspan,记录到后续行
  99. if rowspan > 1 and c == 0:
  100. rowspan_values[(row_idx + 1, col_idx)] = (cell_text, rowspan - 1)
  101. col_idx += 1
  102. # 处理剩余的被rowspan占用的列
  103. while (row_idx, col_idx) in rowspan_values:
  104. row.append(rowspan_values[(row_idx, col_idx)][0])
  105. remaining = rowspan_values[(row_idx, col_idx)][1] - 1
  106. if remaining > 0:
  107. rowspan_values[(row_idx + 1, col_idx)] = (rowspan_values[(row_idx, col_idx)][0], remaining)
  108. del rowspan_values[(row_idx, col_idx)]
  109. col_idx += 1
  110. if row:
  111. table_matrix.append(row)
  112. max_cols = max(max_cols, len(row))
  113. logger.debug(f"[extract_table_with_rowspan_colspan] 表格{table_idx} 第{row_idx}行, 内容: {row}")
  114. # 统一列数(可选,确保每行列数一致)
  115. for row in table_matrix:
  116. while len(row) < max_cols:
  117. row.append("")
  118. if table_matrix:
  119. tables.append(table_matrix)
  120. logger.debug(f"[extract_table_with_rowspan_colspan] 总表格: {len(tables)}")
  121. return tables
  122. def parse_operational_conditions(markdown_content: str, require_title: bool = True) -> List[OperationalCondition]:
  123. """解析工况信息表格
  124. Args:
  125. markdown_content: Markdown内容
  126. require_title: 是否要求必须有标题标识(如"附件2 工况信息"),默认为True
  127. 如果为False,则仅根据表格结构判断是否为工况信息表格
  128. """
  129. conditions: List[OperationalCondition] = []
  130. # 查找工况信息相关的表格
  131. if require_title:
  132. if "附件2 工况信息" not in markdown_content and "工况信息" not in markdown_content:
  133. logger.debug("[工况信息] 未找到工况信息标识")
  134. return conditions
  135. else:
  136. logger.debug("[工况信息] 无标题模式:仅根据表格结构判断")
  137. # 提取表格数据(支持rowspan和colspan)
  138. tables = extract_table_with_rowspan_colspan(markdown_content)
  139. if not tables:
  140. logger.warning("[工况信息] 未能提取出任何表格内容")
  141. return conditions
  142. # 查找工况信息表格(通常包含"检测时间"/"时间"、"电压"/"U"、"电流"/"I"等关键词;支持两行表头)
  143. for table in tables:
  144. if not table or len(table) < 2:
  145. continue
  146. header_row = table[0]
  147. header_text = " ".join(header_row)
  148. # 第一行表头:检测时间/电压/电流/项目 或 名称/时间/运行工况
  149. has_row0 = any(
  150. k in header_text for k in ["检测时间", "电压", "电流", "有功功率", "无功功率", "项目", "时间", "名称", "运行工况"]
  151. )
  152. # 两行表头时第二行常有 U(kV)、I(A)、P(MW)、Q(Mvar)
  153. header_row2 = table[1] if len(table) > 1 else []
  154. header2_text = " ".join(header_row2).lower()
  155. has_row1 = any(
  156. k in header2_text for k in ["u(", "i(", "p(", "q(", "电压", "电流", "有功", "无功", "kv", "mw", "mvar"]
  157. )
  158. has_operational_keywords = has_row0 or (len(header_row2) >= 4 and has_row1)
  159. if not has_operational_keywords:
  160. continue
  161. logger.info(f"[工况信息] 找到工况信息表格,行数: {len(table)}")
  162. # 列索引:优先用第二行表头(U/I/P/Q),否则用第一行
  163. monitor_at_idx = -1
  164. project_idx = -1
  165. name_idx = -1
  166. voltage_idx = -1
  167. current_idx = -1
  168. active_power_idx = -1
  169. reactive_power_idx = -1
  170. # 若第二行表头存在且含 U/I/P/Q,用其确定电压/电流/功率列
  171. if len(table) > 1 and has_row1:
  172. row2 = table[1]
  173. for idx, cell in enumerate(row2):
  174. cell_n = normalize_text(cell)
  175. if "u" in cell_n and ("kv" in cell_n or "k v" in cell_n or "电压" in cell):
  176. voltage_idx = idx
  177. elif "i" in cell_n and ("a)" in cell_n or "a )" in cell_n or "电流" in cell):
  178. current_idx = idx
  179. elif "p" in cell_n and ("mw" in cell_n or "m w" in cell_n or "有功" in cell):
  180. active_power_idx = idx
  181. elif "q" in cell_n and ("mvar" in cell_n or "无功" in cell):
  182. reactive_power_idx = idx
  183. elif ("时间" in cell or "检测时间" in cell or "监测时间" in cell) and monitor_at_idx == -1:
  184. monitor_at_idx = idx
  185. elif ("名称" in cell or "主变" in cell) and name_idx == -1:
  186. name_idx = idx
  187. # 用第一行表头补全未识别的列(名称、时间、项目等)
  188. for idx, cell in enumerate(header_row):
  189. cell_lower = cell.lower()
  190. if ("检测时间" in cell or "监测时间" in cell or "时间" in cell) and monitor_at_idx == -1:
  191. monitor_at_idx = idx
  192. elif "项目" in cell:
  193. if project_idx == -1:
  194. project_idx = idx
  195. if idx + 1 < len(header_row) and name_idx == -1:
  196. next_cell = header_row[idx + 1]
  197. if not any(k in next_cell.lower() for k in ["电压", "电流", "有功", "无功", "检测"]):
  198. name_idx = idx + 1
  199. elif "电压" in cell or "电压(kv)" in cell_lower and voltage_idx == -1:
  200. voltage_idx = idx
  201. elif "电流" in cell or "电流(a)" in cell_lower and current_idx == -1:
  202. current_idx = idx
  203. elif ("有功功率" in cell or ("有功" in cell and "功率" in cell)) and active_power_idx == -1:
  204. active_power_idx = idx
  205. elif ("无功功率" in cell or ("无功" in cell and "功率" in cell)) and reactive_power_idx == -1:
  206. reactive_power_idx = idx
  207. elif ("名称" in cell or "主变" in cell) and name_idx == -1:
  208. name_idx = idx
  209. # 默认名称列0、时间列1(常见两行表头:名称、时间、运行工况 | 名称、时间、U、I、P、Q)
  210. if name_idx == -1 and len(header_row) > 0 and ("名称" in header_row[0] or not any("名称" in c for c in header_row2)):
  211. name_idx = 0
  212. if monitor_at_idx == -1 and len(header_row) > 1 and ("时间" in header_row[1] or (len(header_row2) > 1 and "时间" in header_row2[1])):
  213. monitor_at_idx = 1
  214. logger.debug(f"[工况信息] 列索引: 检测时间={monitor_at_idx}, 项目={project_idx}, 名称={name_idx}, "
  215. f"电压={voltage_idx}, 电流={current_idx}, 有功功率={active_power_idx}, 无功功率={reactive_power_idx}")
  216. # 数据行:两行表头时从第3行(索引2)开始,否则从第2行(索引1)开始
  217. data_start = 2 if (len(table) > 1 and has_row1) else 1
  218. current_monitor_at = ""
  219. current_project = ""
  220. for row_idx in range(data_start, len(table)):
  221. row = table[row_idx]
  222. if len(row) < 4: # 至少需要检测时间、项目、名称等基本字段
  223. continue
  224. # 检测时间
  225. if monitor_at_idx >= 0 and monitor_at_idx < len(row) and row[monitor_at_idx].strip():
  226. current_monitor_at = row[monitor_at_idx].strip()
  227. # 项目名称
  228. if project_idx >= 0 and project_idx < len(row) and row[project_idx].strip():
  229. current_project = row[project_idx].strip()
  230. # 名称(如1#主变)
  231. name_value = ""
  232. if name_idx >= 0 and name_idx < len(row):
  233. name_value = row[name_idx].strip()
  234. elif project_idx >= 0 and project_idx + 1 < len(row):
  235. # 如果名称列在项目列后面
  236. name_value = row[project_idx + 1].strip()
  237. # 只有当名称存在时才创建工况信息记录(因为有rowspan的情况)
  238. if name_value and any(k in name_value for k in ["主变", "#"]):
  239. oc = OperationalCondition()
  240. oc.monitorAt = current_monitor_at
  241. oc.project = current_project
  242. oc.name = name_value
  243. # 电压
  244. if voltage_idx >= 0 and voltage_idx < len(row):
  245. oc.voltage = row[voltage_idx].strip()
  246. # 电流
  247. if current_idx >= 0 and current_idx < len(row):
  248. oc.current = row[current_idx].strip()
  249. # 有功功率
  250. if active_power_idx >= 0 and active_power_idx < len(row):
  251. oc.activePower = row[active_power_idx].strip()
  252. # 无功功率
  253. if reactive_power_idx >= 0 and reactive_power_idx < len(row):
  254. oc.reactivePower = row[reactive_power_idx].strip()
  255. conditions.append(oc)
  256. logger.debug(f"[工况信息] 解析到: {oc.to_dict()}")
  257. # 只处理第一个匹配的表格
  258. if conditions:
  259. break
  260. logger.info(f"[工况信息] 共解析到 {len(conditions)} 条工况信息")
  261. return conditions
  262. def parse_operational_conditions_v2(markdown_content: str) -> List[OperationalConditionV2]:
  263. """解析工况信息表格(新格式:表1检测工况)
  264. 表格结构:
  265. - 第一行:名称、时间,电压(kV)(colspan=2),电流(A)(colspan=2),有功(MW)(colspan=2),无功(Mvar)(colspan=2)
  266. - 第二行:最大值、最小值(重复4次)
  267. - 数据行:名称、时间、电压最大值、电压最小值、电流最大值、电流最小值、有功最大值、有功最小值、无功最大值、无功最小值
  268. """
  269. conditions: List[OperationalConditionV2] = []
  270. # 检查是否包含"表1检测工况"标识(使用正则表达式,允许中间有空格)
  271. # 支持:表1检测工况、表 1 检测工况、表 1检测工况、表1 检测工况 等变体
  272. pattern = r'表\s*1\s*检测工况'
  273. if not re.search(pattern, markdown_content):
  274. logger.debug("[工况信息V2] 未找到'表1检测工况'标识(包括空格变体)")
  275. return conditions
  276. logger.debug("[工况信息V2] 检测到'表1检测工况'格式(包括空格变体),第一列将映射到name字段,project字段保持为空")
  277. # 提取表格数据(支持rowspan和colspan)
  278. tables = extract_table_with_rowspan_colspan(markdown_content)
  279. if not tables:
  280. logger.warning("[工况信息V2] 未能提取出任何表格内容")
  281. return conditions
  282. # 查找包含"表1检测工况"的表格
  283. # 表格结构:第一行是名称、时间,然后是电压、电流、有功、无功(各占2列)
  284. for table in tables:
  285. if not table or len(table) < 3: # 至少需要表头2行和数据1行
  286. continue
  287. # 检查第一行表头是否包含"名称"、"时间"、"电压"等关键词
  288. first_row = table[0]
  289. first_row_text = " ".join(first_row).lower()
  290. has_keywords = any(k in first_row_text for k in ["名称", "时间", "电压", "电流", "有功", "无功"])
  291. if not has_keywords:
  292. continue
  293. logger.info(f"[工况信息V2] 找到工况信息表格,行数: {len(table)}")
  294. # 列索引映射(根据表格结构)
  295. # 列0: 项目名称(映射到name字段)
  296. # 列1: 时间
  297. # 列2: 电压最大值
  298. # 列3: 电压最小值
  299. # 列4: 电流最大值
  300. # 列5: 电流最小值
  301. # 列6: 有功最大值
  302. # 列7: 有功最小值
  303. # 列8: 无功最大值
  304. # 列9: 无功最小值
  305. # 注意:对于"表1检测工况"格式,第一列映射到name字段,project字段保持为空
  306. name_idx = 0 # 第一列是项目名称(如"500kV 江黄Ⅰ线")
  307. time_idx = 1
  308. voltage_max_idx = 2
  309. voltage_min_idx = 3
  310. current_max_idx = 4
  311. current_min_idx = 5
  312. active_power_max_idx = 6
  313. active_power_min_idx = 7
  314. reactive_power_max_idx = 8
  315. reactive_power_min_idx = 9
  316. # 从第三行开始解析数据(前两行是表头)
  317. logger.debug(f"[工况信息V2] 表格总行数: {len(table)}, 开始从第3行(索引2)解析数据行")
  318. for row_idx in range(2, len(table)):
  319. row = table[row_idx]
  320. logger.debug(f"[工况信息V2] 处理第{row_idx}行(索引{row_idx}): 列数={len(row)}, 内容={row[:5]}...") # 只打印前5列用于调试
  321. # 至少需要10列(名称、时间、电压max/min、电流max/min、有功max/min、无功max/min)
  322. if len(row) < 10:
  323. logger.warning(f"[工况信息V2] 第{row_idx}行列数不足10列,跳过: {len(row)}列")
  324. continue
  325. # 检查是否是数据行(第一列应该有项目名称,且不是"名称"、"最大值"、"最小值"等表头关键词)
  326. first_cell = row[name_idx].strip() if name_idx < len(row) else ""
  327. if not first_cell or first_cell in ["名称", "最大值", "最小值", "时间"]:
  328. logger.debug(f"[工况信息V2] 第{row_idx}行第一列为表头关键词或为空,跳过: '{first_cell}'")
  329. continue
  330. # 跳过完全空的行(但允许某些字段为空)
  331. if not any(cell.strip() for cell in row[:2]): # 至少名称或时间应该有值
  332. logger.debug(f"[工况信息V2] 第{row_idx}行前两列为空,跳过")
  333. continue
  334. oc = OperationalConditionV2()
  335. # 名称(列0,映射到name字段)
  336. if name_idx < len(row):
  337. oc.name = row[name_idx].strip()
  338. # project字段保持为空(仅在检测工况之外的场景使用)
  339. oc.project = ""
  340. # 时间(列1)
  341. if time_idx < len(row):
  342. oc.monitorAt = row[time_idx].strip()
  343. # 电压最大值(列2)
  344. if voltage_max_idx < len(row):
  345. oc.maxVoltage = row[voltage_max_idx].strip()
  346. # 电压最小值(列3)
  347. if voltage_min_idx < len(row):
  348. oc.minVoltage = row[voltage_min_idx].strip()
  349. # 电流最大值(列4)
  350. if current_max_idx < len(row):
  351. oc.maxCurrent = row[current_max_idx].strip()
  352. # 电流最小值(列5)
  353. if current_min_idx < len(row):
  354. oc.minCurrent = row[current_min_idx].strip()
  355. # 有功功率最大值(列6)
  356. if active_power_max_idx < len(row):
  357. oc.maxActivePower = row[active_power_max_idx].strip()
  358. # 有功功率最小值(列7)
  359. if active_power_min_idx < len(row):
  360. oc.minActivePower = row[active_power_min_idx].strip()
  361. # 无功功率最大值(列8)
  362. if reactive_power_max_idx < len(row):
  363. oc.maxReactivePower = row[reactive_power_max_idx].strip()
  364. # 无功功率最小值(列9)
  365. if reactive_power_min_idx < len(row):
  366. oc.minReactivePower = row[reactive_power_min_idx].strip()
  367. # 添加记录(只要名称不为空)
  368. if oc.name:
  369. conditions.append(oc)
  370. logger.info(f"[工况信息V2] 解析到第{len(conditions)}条记录: name='{oc.name}', 时间='{oc.monitorAt}'")
  371. else:
  372. logger.warning(f"[工况信息V2] 第{row_idx}行名称为空,跳过该行: {row[:3]}")
  373. # 只处理第一个匹配的表格
  374. if conditions:
  375. break
  376. logger.info(f"[工况信息V2] 共解析到 {len(conditions)} 条工况信息")
  377. return conditions
  378. def parse_operational_conditions_opstatus(markdown_content: str) -> List[OperationalCondition]:
  379. """解析工况信息表格(opStatus格式:附件 工况及工程信息)
  380. 表格结构:
  381. - 第一行:名称(rowspan=2)、时间(rowspan=2)、运行工况(colspan=4)
  382. - 第二行:U (kV)、I (A)、P (MW)、Q (Mvar)
  383. - 数据行:名称、时间(可能有rowspan)、U范围、I范围、P范围、Q范围
  384. 输出格式:
  385. [
  386. {
  387. "monitorAt": "", // 检测时间
  388. "project": "", // 项目名称(从"项目编号"提取,如果存在)
  389. "name": "", // 名称,如#1主变
  390. "voltage": "", // 电压范围
  391. "current": "", // 电流范围
  392. "activePower": "", // 有功功率范围
  393. "reactivePower": "", // 无功功率范围
  394. }
  395. ]
  396. """
  397. conditions: List[OperationalCondition] = []
  398. # 检查是否包含"附件 工况及工程信息"标识
  399. if "附件" not in markdown_content or "工况" not in markdown_content:
  400. logger.debug("[工况信息opStatus] 未找到'附件 工况及工程信息'标识")
  401. return conditions
  402. # 提取项目编号(如果存在)
  403. # 需要排除表格内容,只匹配表格之前的内容
  404. project_number = ""
  405. # 先找到表格开始位置
  406. table_start = markdown_content.find('<table>')
  407. if table_start > 0:
  408. # 只在表格之前的内容中查找项目编号
  409. content_before_table = markdown_content[:table_start]
  410. project_match = re.search(r'项目编号[::]\s*([^\n<]+)', content_before_table)
  411. if project_match:
  412. project_number = project_match.group(1).strip()
  413. logger.debug(f"[工况信息opStatus] 提取到项目编号: {project_number}")
  414. else:
  415. # 如果没有表格,在整个内容中查找
  416. project_match = re.search(r'项目编号[::]\s*([^\n<]+)', markdown_content)
  417. if project_match:
  418. project_number = project_match.group(1).strip()
  419. logger.debug(f"[工况信息opStatus] 提取到项目编号: {project_number}")
  420. # 提取表格数据(支持rowspan和colspan)
  421. tables = extract_table_with_rowspan_colspan(markdown_content)
  422. if not tables:
  423. logger.warning("[工况信息opStatus] 未能提取出任何表格内容")
  424. return conditions
  425. # 查找工况信息表格(包含"名称"、"时间"、"U"、"I"、"P"、"Q"等关键词)
  426. for table in tables:
  427. if not table or len(table) < 3: # 至少需要表头2行和数据1行
  428. continue
  429. # 检查表头是否包含工况信息的关键词
  430. first_row = table[0]
  431. second_row = table[1] if len(table) > 1 else []
  432. header_text = normalize_text(" ".join(first_row + second_row))
  433. has_keywords = any(
  434. keyword in header_text
  435. for keyword in ["名称", "时间", "运行工况", "u", "i", "p", "q", "kv", "mw", "mvar"]
  436. )
  437. if not has_keywords:
  438. continue
  439. logger.info(f"[工况信息opStatus] 找到工况信息表格,行数: {len(table)}")
  440. # 检测是否有两行表头(第二行包含"U (kV)"、"I (A)"等)
  441. has_two_row_header = False
  442. if len(table) > 1:
  443. second_row_text = normalize_text(" ".join(table[1]))
  444. # 兼容带空格和不带空格的写法,例如 "U (kV)" / "U(kV)"
  445. if (
  446. any(k in second_row_text for k in ["u(kv)", "i(a)", "p(mw)", "q(mvar)"])
  447. or ("u" in second_row_text and "kv" in second_row_text)
  448. or ("i" in second_row_text and "a" in second_row_text)
  449. or ("p" in second_row_text and "mw" in second_row_text)
  450. or ("q" in second_row_text and "mvar" in second_row_text)
  451. ):
  452. has_two_row_header = True
  453. logger.debug("[工况信息opStatus] 检测到两行表头格式")
  454. # 根据表头动态确定列索引
  455. # 如果有两行表头,从第二行检测;否则从第一行检测
  456. header_row = table[1] if has_two_row_header else table[0]
  457. time_idx = -1
  458. project_idx = -1
  459. name_idx = -1
  460. voltage_idx = -1
  461. current_idx = -1
  462. active_power_idx = -1
  463. reactive_power_idx = -1
  464. for idx, cell in enumerate(header_row):
  465. cell_lower = cell.lower()
  466. cell_normalized = normalize_text(cell)
  467. if "检测时间" in cell or "监测时间" in cell or "时间" in cell:
  468. time_idx = idx
  469. elif "项目" in cell:
  470. project_idx = idx
  471. elif "名称" in cell:
  472. name_idx = idx
  473. elif "电压" in cell or ("u" in cell_normalized and "kv" in cell_normalized) or ("u (kv)" in cell_normalized):
  474. voltage_idx = idx
  475. elif "电流" in cell or ("i" in cell_normalized and "a" in cell_normalized) or ("i (a)" in cell_normalized):
  476. current_idx = idx
  477. elif "有功功率" in cell or ("有功" in cell and "功率" in cell) or ("p" in cell_normalized and "mw" in cell_normalized) or ("p (mw)" in cell_normalized):
  478. active_power_idx = idx
  479. elif "无功功率" in cell or ("无功" in cell and "功率" in cell) or ("q" in cell_normalized and "mvar" in cell_normalized) or ("q (mvar)" in cell_normalized):
  480. reactive_power_idx = idx
  481. # 如果第一行表头有"名称",也检查第一行
  482. if has_two_row_header and name_idx == -1:
  483. first_row = table[0]
  484. for idx, cell in enumerate(first_row):
  485. if "名称" in cell:
  486. name_idx = idx
  487. break
  488. # 如果表头中没有找到名称列,尝试在数据行中查找
  489. if name_idx == -1:
  490. # 确定第一行数据的位置(如果有两行表头,从第2行开始;否则从第1行开始)
  491. first_data_row_idx = 2 if has_two_row_header else 1
  492. if len(table) > first_data_row_idx:
  493. first_data_row = table[first_data_row_idx]
  494. for idx, cell in enumerate(first_data_row):
  495. # 跳过已知的列(时间列和项目列)
  496. if idx != time_idx and idx != project_idx and cell.strip():
  497. # 支持主变格式(包含"主变"或"#")和输电线路格式(包含"kV"和"线")
  498. if (any(k in cell for k in ["主变", "#"]) or
  499. ("kV" in cell and "线" in cell) or
  500. ("kV" in cell)):
  501. name_idx = idx
  502. logger.debug(f"[工况信息opStatus] 从数据行推断名称列索引: {name_idx}, 内容='{cell}'")
  503. break
  504. # 如果仍然没有找到名称列,但找到了项目列,则名称列通常在项目列之后
  505. # 如果项目列有colspan,名称列可能在空列之后
  506. if name_idx == -1 and project_idx >= 0:
  507. # 检查项目列之后是否有空列,如果有,名称列在空列位置(因为colspan会创建空列)
  508. if project_idx + 1 < len(header_row):
  509. if not header_row[project_idx + 1].strip():
  510. # 项目列有colspan,名称列在空列位置(project_idx + 1)
  511. name_idx = project_idx + 1
  512. else:
  513. # 名称列紧跟在项目列之后
  514. name_idx = project_idx + 1
  515. else:
  516. # 如果项目列是最后一列,名称列可能在项目列之后
  517. name_idx = project_idx + 1 if project_idx + 1 < len(header_row) else -1
  518. logger.debug(f"[工况信息opStatus] 列索引: 检测时间={time_idx}, 项目={project_idx}, 名称={name_idx}, "
  519. f"电压={voltage_idx}, 电流={current_idx}, 有功功率={active_power_idx}, 无功功率={reactive_power_idx}")
  520. # 确定数据行起始位置(如果有两行表头,从第2行开始;否则从第1行开始)
  521. data_start_row = 2 if has_two_row_header else 1
  522. current_monitor_at = ""
  523. current_project = ""
  524. for row_idx in range(data_start_row, len(table)):
  525. row = table[row_idx]
  526. logger.debug(f"[工况信息opStatus] 处理第{row_idx}行: 列数={len(row)}, 内容={row}")
  527. # 至少需要3列
  528. if len(row) < 3:
  529. logger.warning(f"[工况信息opStatus] 第{row_idx}行列数不足,跳过: {len(row)}列")
  530. continue
  531. # 更新检测时间(如果有值且列索引有效)
  532. if time_idx >= 0 and time_idx < len(row) and row[time_idx].strip():
  533. current_monitor_at = row[time_idx].strip()
  534. # 更新项目/位置(如果有值且列索引有效)
  535. if project_idx >= 0 and project_idx < len(row) and row[project_idx].strip():
  536. current_project = row[project_idx].strip()
  537. # 获取名称(变压器名称,如"1#主变")
  538. name_value = ""
  539. if name_idx >= 0 and name_idx < len(row):
  540. name_value = row[name_idx].strip()
  541. elif project_idx >= 0 and project_idx + 1 < len(row):
  542. # 如果名称列索引未找到,尝试项目列之后的第一列
  543. name_value = row[project_idx + 1].strip()
  544. # 检查是否是噪声数据行(包含测点编号如N1、N2等,且没有电压/电流/功率列)
  545. is_noise_row = False
  546. # 检查名称列或第一列是否是测点编号
  547. check_cell = name_value if name_value else (row[0].strip() if len(row) > 0 else "")
  548. if check_cell:
  549. # 如果名称列或第一列包含测点编号格式(N1、N2等)且没有找到电压/电流/功率列,可能是噪声数据行
  550. if re.match(r'^N\d+', check_cell) and voltage_idx == -1 and current_idx == -1:
  551. is_noise_row = True
  552. logger.debug(f"[工况信息opStatus] 第{row_idx}行疑似噪声数据行,跳过: 第一列或名称列='{check_cell}'")
  553. # 只有当名称存在且不是噪声数据行时才创建工况信息记录
  554. # 放宽名称验证:支持主变(包含"主变"或"#")和输电线路(包含"kV"和"线")
  555. is_valid_name = False
  556. if name_value:
  557. # 主变格式:包含"主变"或"#"
  558. if any(k in name_value for k in ["主变", "#"]):
  559. is_valid_name = True
  560. # 输电线路格式:包含"kV"和"线"
  561. elif "kV" in name_value and "线" in name_value:
  562. is_valid_name = True
  563. # 其他可能的格式:包含"kV"(可能是其他设备)
  564. elif "kV" in name_value:
  565. is_valid_name = True
  566. if is_valid_name and not is_noise_row:
  567. # 进一步验证:必须有电压或电流或功率列,否则可能是噪声数据行
  568. if voltage_idx == -1 and current_idx == -1 and active_power_idx == -1 and reactive_power_idx == -1:
  569. logger.debug(f"[工况信息opStatus] 第{row_idx}行没有找到电压/电流/功率列,跳过: name='{name_value}'")
  570. continue
  571. # 创建工况信息记录
  572. oc = OperationalCondition()
  573. oc.monitorAt = current_monitor_at
  574. oc.project = current_project # project字段使用表格中"项目"列的值
  575. oc.name = name_value
  576. # 电压范围
  577. if voltage_idx >= 0 and voltage_idx < len(row):
  578. oc.voltage = row[voltage_idx].strip()
  579. # 电流范围
  580. if current_idx >= 0 and current_idx < len(row):
  581. oc.current = row[current_idx].strip()
  582. # 有功功率范围
  583. if active_power_idx >= 0 and active_power_idx < len(row):
  584. oc.activePower = row[active_power_idx].strip()
  585. # 无功功率范围
  586. if reactive_power_idx >= 0 and reactive_power_idx < len(row):
  587. oc.reactivePower = row[reactive_power_idx].strip()
  588. # 添加记录
  589. conditions.append(oc)
  590. logger.info(f"[工况信息opStatus] 解析到第{len(conditions)}条记录: name='{oc.name}', 时间='{oc.monitorAt}', 项目='{oc.project}'")
  591. else:
  592. if is_noise_row:
  593. logger.debug(f"[工况信息opStatus] 第{row_idx}行是噪声数据行,跳过: name='{name_value}'")
  594. else:
  595. logger.debug(f"[工况信息opStatus] 第{row_idx}行名称无效或为空,跳过: name='{name_value}'")
  596. # 只处理第一个匹配的表格
  597. if conditions:
  598. break
  599. logger.info(f"[工况信息opStatus] 共解析到 {len(conditions)} 条工况信息")
  600. return conditions
  601. def parse_operational_conditions_format3_5(markdown_content: str) -> List[OperationalConditionV2]:
  602. """解析工况信息表格(格式3和格式5:附件 2 工况信息,电压列第一列存储时间段)
  603. 表格结构:
  604. - 第一行:名称(rowspan=2)、时间(rowspan=2或colspan=2)、电压(kV)(colspan=2)、电流(A)(colspan=2)、有功(MW)(colspan=2)、无功(Mvar)(colspan=2)
  605. - 第二行:最大值、最小值(重复4次)
  606. - 数据行特点:
  607. - 电压列的第一列存储时间段(如"昼间9:00~11:00"),第二列是电压最大值
  608. - 电流列的第一列是电流最大值,第二列是电流最小值
  609. - 有功和无功类似
  610. 输出格式(使用OperationalConditionV2):
  611. [
  612. {
  613. "monitorAt": "", // 检测时间(从时间列和电压时间段列组合)
  614. "project": "", // 项目名称
  615. "name": "", // 名称,如#3主变
  616. "maxVoltage": "", // 电压最大值
  617. "minVoltage": "", // 电压最小值
  618. "maxCurrent": "", // 电流最大值
  619. "minCurrent": "", // 电流最小值
  620. "maxActivePower": "", // 有功功率最大值
  621. "minActivePower": "", // 有功功率最小值
  622. "maxReactivePower": "", // 无功功率最大值
  623. "minReactivePower": "", // 无功功率最小值
  624. }
  625. ]
  626. """
  627. conditions: List[OperationalConditionV2] = []
  628. # 检查是否包含"附件 2 工况信息"或"附件 2 工况及工程信息"标识
  629. if "附件" not in markdown_content or ("工况信息" not in markdown_content and "工况及工程信息" not in markdown_content):
  630. logger.debug("[工况信息格式3/5] 未找到'附件 2 工况信息'或'附件 2 工况及工程信息'标识")
  631. return conditions
  632. # 提取表格数据(支持rowspan和colspan)
  633. tables = extract_table_with_rowspan_colspan(markdown_content)
  634. if not tables:
  635. logger.warning("[工况信息格式3/5] 未能提取出任何表格内容")
  636. return conditions
  637. # 查找工况信息表格
  638. for table in tables:
  639. if not table or len(table) < 3: # 至少需要表头2行和数据1行
  640. continue
  641. # 检查表头是否包含工况信息的关键词
  642. first_row = table[0]
  643. second_row = table[1] if len(table) > 1 else []
  644. header_text = normalize_text(" ".join(first_row + second_row))
  645. has_keywords = any(
  646. keyword in header_text
  647. for keyword in ["名称", "时间", "电压", "电流", "有功", "无功", "kv", "mw", "mvar"]
  648. )
  649. if not has_keywords:
  650. continue
  651. logger.info(f"[工况信息格式3/5] 找到工况信息表格,行数: {len(table)}")
  652. # 检查是否是格式3/5(电压列第一列存储时间段)
  653. # 从数据行检查:如果电压列第一列包含"昼间"、"夜间"等时间段关键词,则是格式3/5
  654. is_format3_5 = False
  655. if len(table) > 2:
  656. logger.debug(f"[工况信息格式3/5] 开始检查格式3/5特征,表格行数: {len(table)}")
  657. for row_idx in range(2, min(5, len(table))): # 检查前几行数据
  658. row = table[row_idx]
  659. logger.debug(f"[工况信息格式3/5] 检查第{row_idx}行,列数: {len(row)}, 内容: {row[:6]}")
  660. if len(row) >= 4: # 至少需要名称、时间、电压列
  661. # 电压列可能在索引2或3(格式3在列2,格式5在列3,因为时间列有colspan=2)
  662. # 扩大检查范围到列2-6,以覆盖格式3和格式5
  663. for col_idx in range(2, min(7, len(row))):
  664. cell = row[col_idx].strip()
  665. # 处理可能的换行符(如"昼间\n10:00~17:00")
  666. cell_normalized = cell.replace("\n", " ").replace("\r", " ").strip()
  667. if cell_normalized and any(k in cell_normalized for k in ["昼间", "夜间", "次日", "~", ":"]) and not re.match(r'^[\d.\-]+$', cell_normalized):
  668. is_format3_5 = True
  669. logger.info(f"[工况信息格式3/5] 检测到格式3/5特征:第{row_idx}行列{col_idx}包含时间段 '{cell_normalized}'")
  670. break
  671. if is_format3_5:
  672. break
  673. if not is_format3_5:
  674. logger.warning("[工况信息格式3/5] 未检测到格式3/5特征(电压列包含时间段),跳过该表格")
  675. continue
  676. # 确定列索引
  677. # 根据表格结构:名称、时间(可能有colspan=2)、电压(kV)[colspan=2]、电流(A)[colspan=2]、有功(MW)[colspan=2]、无功(Mvar)[colspan=2]
  678. # 第二行表头:最大值、最小值(重复4次)
  679. # 格式3:名称、时间、电压时间段、电压最大值、电压最小值、电流最大值、电流最小值...
  680. # 格式5:名称、时间(colspan=2,占用2列)、电压时间段、电压最大值、电压最小值、电流最大值、电流最小值...
  681. # 动态检测时间列是否有colspan=2(通过检查表头和数据行的列数)
  682. # 格式3和格式5的时间列都有colspan=2,但实际数据行可能都是11列
  683. # 检查表头第一行,看时间列是否有colspan=2
  684. has_time_colspan2 = False
  685. if len(table) > 0:
  686. first_row = table[0]
  687. # 检查表头中是否有"时间"列,并且该列有colspan=2
  688. # 如果第一行包含"时间"且后续列数较多,可能是colspan=2
  689. header_text = " ".join(first_row).lower()
  690. if "时间" in header_text:
  691. # 检查表头结构:如果时间列后面直接是电压列,可能是colspan=2
  692. # 或者检查数据行的列数
  693. if len(table) > 2:
  694. for row_idx in range(2, min(5, len(table))):
  695. row = table[row_idx]
  696. logger.debug(f"[工况信息格式3/5] 检查第{row_idx}行,列数: {len(row)}")
  697. if len(row) >= 11: # 格式3/5:名称(1) + 时间(2) + 电压时间段(1) + 电压max(1) + 电压min(1) + 电流max(1) + 电流min(1) + 有功max(1) + 有功min(1) + 无功max(1) + 无功min(1) = 11列
  698. has_time_colspan2 = True
  699. logger.info(f"[工况信息格式3/5] 检测到时间列有colspan=2,数据行有{len(row)}列")
  700. break
  701. elif len(row) >= 10:
  702. # 可能是格式3,但时间列也可能有colspan=2(只是数据值只占1列)
  703. # 检查列2是否是时间段(包含"昼间"、"夜间"等)
  704. if len(row) > 2 and any(k in row[2] for k in ["昼间", "夜间", "次日", "~", ":"]):
  705. has_time_colspan2 = True
  706. logger.info(f"[工况信息格式3/5] 检测到时间列有colspan=2(格式3),数据行有{len(row)}列,列2是时间段")
  707. break
  708. logger.debug(f"[工况信息格式3/5] 检测到格式3,数据行有{len(row)}列")
  709. break
  710. name_idx = 0
  711. # 无论格式3还是格式5,时间段都在列2(索引2),因为时间列虽然有colspan=2,
  712. # 但实际数据中时间段是电压列的第一列,在索引2
  713. time_idx = 1
  714. voltage_time_idx = 2 # 电压时间段列(格式3和格式5都在索引2)
  715. voltage_max_idx = 3 # 电压最大值列
  716. voltage_min_idx = 4 # 电压最小值列
  717. current_max_idx = 5 # 电流最大值列
  718. current_min_idx = 6 # 电流最小值列
  719. active_power_max_idx = 7 # 有功最大值列
  720. active_power_min_idx = 8 # 有功最小值列
  721. reactive_power_max_idx = 9 # 无功最大值列
  722. reactive_power_min_idx = 10 # 无功最小值列
  723. # 从第三行开始解析数据(前两行是表头)
  724. current_name = ""
  725. current_time = ""
  726. logger.info(f"[工况信息格式3/5] 开始解析数据行,表格总行数: {len(table)}, 从第3行(索引2)开始")
  727. for row_idx in range(2, len(table)):
  728. row = table[row_idx]
  729. logger.debug(f"[工况信息格式3/5] 处理第{row_idx}行,列数: {len(row)}, 前5列: {row[:5]}")
  730. # 至少需要4列
  731. if len(row) < 4:
  732. logger.debug(f"[工况信息格式3/5] 第{row_idx}行列数不足4列,跳过")
  733. continue
  734. # 检查是否是表头行(包含"名称"、"时间"、"最大值"、"最小值"等关键词)
  735. if any(keyword in " ".join(row[:5]).lower() for keyword in ["名称", "时间", "最大值", "最小值", "电压", "电流", "有功", "无功"]):
  736. logger.debug(f"[工况信息格式3/5] 跳过表头行: {row[:3]}")
  737. continue
  738. # 检查是否是项目名称行(整行合并,如"蕲昌220kV变电站"、"输电线路")
  739. # 项目名称行通常只有第0列有值,其他列都为空(因为colspan)
  740. non_empty_cols = [i for i, cell in enumerate(row) if cell.strip()]
  741. if len(non_empty_cols) == 1 and non_empty_cols[0] == 0:
  742. # 检查内容是否是项目名称(不包含"主变"、"#"、"线"等设备名称关键词)
  743. cell_value = row[0].strip()
  744. if not any(k in cell_value for k in ["主变", "#", "线"]):
  745. # 可能是项目名称行,跳过
  746. logger.debug(f"[工况信息格式3/5] 跳过项目名称行: {cell_value}")
  747. continue
  748. # 更新名称(如果有值)
  749. if name_idx < len(row) and row[name_idx].strip():
  750. name_value = row[name_idx].strip()
  751. # 检查是否是有效名称(包含"主变"、"#"等,但排除"输电线路"这样的项目名称)
  752. if name_value in ["输电线路", "变电站"]:
  753. # 这是项目名称行,跳过
  754. logger.debug(f"[工况信息格式3/5] 跳过项目名称行: {name_value}")
  755. continue
  756. elif any(k in name_value for k in ["主变", "#"]) or ("kV" in name_value and "线" in name_value):
  757. current_name = name_value
  758. logger.debug(f"[工况信息格式3/5] 更新名称: {current_name}")
  759. # 更新时间(如果有值)
  760. # 时间列可能有colspan=2,所以检查列1和列2
  761. time_value = ""
  762. if time_idx < len(row) and row[time_idx].strip():
  763. time_value = row[time_idx].strip()
  764. elif time_idx + 1 < len(row) and row[time_idx + 1].strip():
  765. time_value = row[time_idx + 1].strip()
  766. # 检查是否是日期格式(支持"2025.03.28"和"2025.08.29-08.30")
  767. if time_value and (re.match(r'^\d{4}\.\d{1,2}\.\d{1,2}', time_value) or re.match(r'^\d{4}\.\d{1,2}\.\d{1,2}-\d{1,2}\.\d{1,2}', time_value)):
  768. current_time = time_value
  769. logger.debug(f"[工况信息格式3/5] 更新时间: {current_time}")
  770. # 检查是否有电压时间段(格式3/5的特征)
  771. if voltage_time_idx < len(row) and row[voltage_time_idx].strip():
  772. voltage_time = row[voltage_time_idx].strip()
  773. # 检查是否是时间段(包含"昼间"、"夜间"等)
  774. if any(k in voltage_time for k in ["昼间", "夜间", "次日", "~", ":"]):
  775. # 创建工况信息记录(使用OperationalConditionV2格式)
  776. oc = OperationalConditionV2()
  777. oc.project = ""
  778. oc.name = current_name
  779. # 组合monitorAt:时间 + 时间段(保持原始格式,如"2025.03.28 昼间9:00~11:00")
  780. if current_time:
  781. oc.monitorAt = f"{current_time} {voltage_time}".strip()
  782. else:
  783. oc.monitorAt = voltage_time
  784. # 电压最大值
  785. if voltage_max_idx < len(row) and row[voltage_max_idx].strip():
  786. oc.maxVoltage = row[voltage_max_idx].strip()
  787. # 电压最小值
  788. if voltage_min_idx < len(row) and row[voltage_min_idx].strip():
  789. oc.minVoltage = row[voltage_min_idx].strip()
  790. # 电流最大值
  791. if current_max_idx < len(row) and row[current_max_idx].strip():
  792. oc.maxCurrent = row[current_max_idx].strip()
  793. # 电流最小值
  794. if current_min_idx < len(row) and row[current_min_idx].strip():
  795. oc.minCurrent = row[current_min_idx].strip()
  796. # 有功功率最大值
  797. if active_power_max_idx < len(row) and row[active_power_max_idx].strip():
  798. oc.maxActivePower = row[active_power_max_idx].strip()
  799. # 有功功率最小值
  800. if active_power_min_idx < len(row) and row[active_power_min_idx].strip():
  801. oc.minActivePower = row[active_power_min_idx].strip()
  802. # 无功功率最大值
  803. if reactive_power_max_idx < len(row) and row[reactive_power_max_idx].strip():
  804. oc.maxReactivePower = row[reactive_power_max_idx].strip()
  805. # 无功功率最小值
  806. if reactive_power_min_idx < len(row) and row[reactive_power_min_idx].strip():
  807. oc.minReactivePower = row[reactive_power_min_idx].strip()
  808. # 添加记录(只要名称不为空)
  809. if oc.name:
  810. conditions.append(oc)
  811. logger.info(f"[工况信息格式3/5] 解析到第{len(conditions)}条记录: name='{oc.name}', monitorAt='{oc.monitorAt}'")
  812. # 只处理第一个匹配的表格
  813. if conditions:
  814. break
  815. logger.info(f"[工况信息格式3/5] 共解析到 {len(conditions)} 条工况信息")
  816. return conditions