table_parser.py 47 KB

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