symtest-cli 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. symtest/__init__.py +45 -0
  2. symtest/cli.py +549 -0
  3. symtest/commands/__init__.py +9 -0
  4. symtest/commands/compare.py +221 -0
  5. symtest/config/__init__.py +7 -0
  6. symtest/config/config_io.py +346 -0
  7. symtest/config/config_schema.py +330 -0
  8. symtest/config/import_expander.py +149 -0
  9. symtest/config/inheritance_expander.py +197 -0
  10. symtest/core/__init__.py +15 -0
  11. symtest/core/assertions.py +253 -0
  12. symtest/core/base_runner.py +299 -0
  13. symtest/core/config_loader.py +536 -0
  14. symtest/core/execution.py +498 -0
  15. symtest/core/history_store.py +96 -0
  16. symtest/core/last_run_store.py +109 -0
  17. symtest/core/parallel_runner.py +251 -0
  18. symtest/core/process_worker.py +93 -0
  19. symtest/core/sequence_state.py +143 -0
  20. symtest/core/setup.py +137 -0
  21. symtest/core/test_case.py +76 -0
  22. symtest/core/types.py +92 -0
  23. symtest/file_comparator/__init__.py +10 -0
  24. symtest/file_comparator/base_comparator.py +109 -0
  25. symtest/file_comparator/binary_comparator.py +399 -0
  26. symtest/file_comparator/csv_comparator.py +241 -0
  27. symtest/file_comparator/factory.py +191 -0
  28. symtest/file_comparator/h5_comparator.py +777 -0
  29. symtest/file_comparator/json_comparator.py +323 -0
  30. symtest/file_comparator/result.py +213 -0
  31. symtest/file_comparator/script_comparator.py +182 -0
  32. symtest/file_comparator/text_comparator.py +182 -0
  33. symtest/file_comparator/xml_comparator.py +150 -0
  34. symtest/logging_config.py +66 -0
  35. symtest/runners/__init__.py +15 -0
  36. symtest/runners/config_runner.py +96 -0
  37. symtest/runners/json_runner.py +21 -0
  38. symtest/runners/parallel_config_runner.py +278 -0
  39. symtest/runners/parallel_json_runner.py +26 -0
  40. symtest/runners/parallel_yaml_runner.py +31 -0
  41. symtest/runners/yaml_runner.py +26 -0
  42. symtest/tui/__init__.py +11 -0
  43. symtest/tui/app.py +90 -0
  44. symtest/tui/controllers/__init__.py +0 -0
  45. symtest/tui/controllers/case_controller.py +322 -0
  46. symtest/tui/screens/__init__.py +0 -0
  47. symtest/tui/screens/case_editor.py +244 -0
  48. symtest/tui/screens/case_list.py +255 -0
  49. symtest/tui/widgets/__init__.py +0 -0
  50. symtest/tui/widgets/case_table.py +113 -0
  51. symtest/tui/widgets/expected_editor.py +159 -0
  52. symtest/tui/widgets/search_bar.py +160 -0
  53. symtest/tui/widgets/steps_editor.py +243 -0
  54. symtest/utils/__init__.py +21 -0
  55. symtest/utils/junit_xml_writer.py +137 -0
  56. symtest/utils/path_resolver.py +124 -0
  57. symtest/utils/report_generator.py +208 -0
  58. symtest_cli-1.3.0.dist-info/METADATA +316 -0
  59. symtest_cli-1.3.0.dist-info/RECORD +63 -0
  60. symtest_cli-1.3.0.dist-info/WHEEL +5 -0
  61. symtest_cli-1.3.0.dist-info/entry_points.txt +4 -0
  62. symtest_cli-1.3.0.dist-info/licenses/LICENSE +21 -0
  63. symtest_cli-1.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,777 @@
1
+ from .base_comparator import BaseComparator
2
+ import h5py
3
+ import numpy as np
4
+ import logging
5
+ import re
6
+
7
+ class H5Comparator(BaseComparator):
8
+ def __init__(self, tables=None, table_regex=None, structure_only=False, show_content_diff=False, debug=False, rtol=1e-5, atol=1e-8, expand_path=True, data_filter=None, error_analysis=False, **kwargs):
9
+ """
10
+ Initialize H5 comparator
11
+ :param tables: List of table names to compare. If None, compare all tables
12
+ :param table_regex: Regular expression pattern to match table names
13
+ :param structure_only: If True, only compare file structure without comparing content
14
+ :param show_content_diff: If True, show detailed content differences
15
+ :param debug: If True, enable debug mode
16
+ :param rtol: Relative tolerance for numerical comparison
17
+ :param atol: Absolute tolerance for numerical comparison
18
+ :param expand_path: If True, expand group paths to compare all sub-items. Defaults to True.
19
+ :param data_filter: String filter expression for data comparison (e.g., '>1e-6', 'abs>1e-9')
20
+ :param error_analysis: Enable streaming error statistics over ALL numeric cells
21
+ """
22
+ super().__init__(**kwargs)
23
+ self.tables = tables
24
+ self.table_regex = table_regex
25
+ self.structure_only = structure_only
26
+ self.show_content_diff = show_content_diff
27
+ self.rtol = rtol
28
+ self.atol = atol
29
+ self.expand_path = expand_path
30
+ self.data_filter = data_filter
31
+ self.filter_func = self._parse_filter()
32
+ self.error_analysis = error_analysis
33
+ self._error_stats = None
34
+
35
+ # Set debug level if verbose is enabled
36
+ if kwargs.get('verbose', False) or debug:
37
+ self.logger.setLevel(logging.DEBUG)
38
+
39
+ self.logger.debug(f"Initialized H5Comparator with structure_only={structure_only}, show_content_diff={show_content_diff}, rtol={rtol}, atol={atol}, expand_path={expand_path}, data_filter={data_filter}")
40
+ if table_regex:
41
+ self.logger.debug(f"Using table regex pattern: {table_regex}")
42
+
43
+ def read_content(self, file_path, start_line=0, end_line=None, start_column=0, end_column=None):
44
+ """Read H5 file content"""
45
+ content = {}
46
+ processed_paths = set()
47
+
48
+ # Store file path for later chunked reading if needed
49
+ content['_file_path'] = str(file_path)
50
+ content['_start_line'] = start_line
51
+ content['_end_line'] = end_line
52
+ content['_start_column'] = start_column
53
+ content['_end_column'] = end_column
54
+
55
+ # Log whether we're in structure-only mode
56
+ self.logger.debug(f"Reading file {file_path} in structure-only mode: {self.structure_only}")
57
+ self.logger.debug(f"Tables parameter: {self.tables}")
58
+ self.logger.debug(f"Table regex parameter: {self.table_regex}")
59
+
60
+ with h5py.File(file_path, 'r') as f:
61
+ # Function to collect structure information
62
+ def collect_structure(name, obj):
63
+ if name in processed_paths:
64
+ self.logger.debug(f"Path {name} already processed, skipping.")
65
+ return
66
+
67
+ if isinstance(obj, h5py.Dataset):
68
+ content[name] = {
69
+ 'type': 'dataset',
70
+ 'shape': obj.shape,
71
+ 'dtype': str(obj.dtype),
72
+ 'attrs': dict(obj.attrs)
73
+ }
74
+ self.logger.debug(f"Collected structure for dataset: {name}")
75
+ processed_paths.add(name)
76
+ elif isinstance(obj, h5py.Group) and name: # Skip root group
77
+ content[name] = {
78
+ 'type': 'group',
79
+ 'keys': list(obj.keys()),
80
+ 'attrs': dict(obj.attrs)
81
+ }
82
+ self.logger.debug(f"Collected structure for group: {name}")
83
+ processed_paths.add(name)
84
+
85
+ # Function to collect structure and data
86
+ def collect_structure_and_data(name, obj):
87
+ if name in processed_paths:
88
+ self.logger.debug(f"Path {name} already processed, skipping.")
89
+ return
90
+
91
+ if isinstance(obj, h5py.Dataset):
92
+ dataset_info = {
93
+ 'type': 'dataset',
94
+ 'shape': obj.shape,
95
+ 'dtype': str(obj.dtype),
96
+ 'attrs': dict(obj.attrs)
97
+ }
98
+
99
+ # Read data with range constraints and chunk-based reading for large datasets
100
+ try:
101
+ # Threshold for chunk-based reading (1 million elements = ~8MB for float64)
102
+ # For datasets smaller than this, read entire dataset for efficiency
103
+ if obj.size < 1000000:
104
+ # Small dataset: read entire dataset into memory
105
+ data = obj[:]
106
+ if isinstance(data, np.ndarray):
107
+ if end_line is None:
108
+ end_line_actual = data.shape[0]
109
+ else:
110
+ end_line_actual = min(end_line, data.shape[0])
111
+
112
+ if len(data.shape) == 1:
113
+ data = data[start_line:end_line_actual]
114
+ elif len(data.shape) > 1:
115
+ if end_column is None:
116
+ end_column_actual = data.shape[1]
117
+ else:
118
+ end_column_actual = min(end_column, data.shape[1])
119
+ data = data[start_line:end_line_actual, start_column:end_column_actual]
120
+
121
+ dataset_info['data'] = data
122
+ self.logger.debug(f"Collected full data for small dataset: {name} (size: {obj.size})")
123
+ else:
124
+ # Large dataset: mark for chunked reading during comparison
125
+ # Don't load entire dataset into memory
126
+ dataset_info['data'] = None # Will be read chunk-by-chunk during comparison
127
+ dataset_info['dataset_path'] = name # Store dataset path for later chunked reading
128
+ dataset_info['needs_chunked_reading'] = True
129
+ self.logger.debug(f"Marked large dataset for chunked reading: {name} (size: {obj.size})")
130
+
131
+ except Exception as e:
132
+ self.logger.error(f"Error reading data from {name}: {str(e)}")
133
+
134
+ content[name] = dataset_info
135
+ processed_paths.add(name)
136
+
137
+ elif isinstance(obj, h5py.Group) and name: # Skip root group
138
+ content[name] = {
139
+ 'type': 'group',
140
+ 'keys': list(obj.keys()),
141
+ 'attrs': dict(obj.attrs)
142
+ }
143
+ self.logger.debug(f"Collected structure for group: {name}")
144
+ processed_paths.add(name)
145
+
146
+ if self.tables or self.table_regex:
147
+ # If specific tables or regex pattern is specified
148
+ regex_patterns = []
149
+ if self.table_regex:
150
+ # Split by comma to support multiple regex patterns
151
+ regex_strings = [pattern.strip() for pattern in self.table_regex.split(',')]
152
+ self.logger.debug(f"Parsed regex patterns: {regex_strings}")
153
+
154
+ for regex_str in regex_strings:
155
+ # If the regex looks like a simple path (no regex metacharacters except . and /),
156
+ # escape it to treat it as a literal string
157
+ self.logger.debug(f"Processing regex pattern: {regex_str}")
158
+ # Check if it contains regex metacharacters other than . and /
159
+ import string
160
+ regex_metacharacters = set('[]{}()*+?^$|\\')
161
+ if not any(char in regex_str for char in regex_metacharacters):
162
+ # Escape dots and other special characters for literal matching
163
+ escaped_regex_str = re.escape(regex_str)
164
+ self.logger.debug(f"Treating pattern as literal path, escaped: {escaped_regex_str}")
165
+ regex_patterns.append(re.compile(escaped_regex_str))
166
+ else:
167
+ self.logger.debug(f"Using pattern as regular expression: {regex_str}")
168
+ regex_patterns.append(re.compile(regex_str))
169
+
170
+ def should_process(name):
171
+ if self.tables and name in self.tables:
172
+ self.logger.debug(f"Matched by tables list: {name}")
173
+ return True
174
+ for pattern in regex_patterns:
175
+ if pattern.fullmatch(name):
176
+ self.logger.debug(f"Matched by regex pattern {pattern.pattern}: {name}")
177
+ return True
178
+ return False
179
+
180
+ def process_item(name, item):
181
+ try:
182
+ # Process the current item first
183
+ if self.structure_only:
184
+ collect_structure(name, item)
185
+ else:
186
+ collect_structure_and_data(name, item)
187
+
188
+ # If it's a group and expand_path is enabled, visit all its members
189
+ if self.expand_path and isinstance(item, h5py.Group):
190
+ self.logger.debug(f"Expanding group path: {name}")
191
+
192
+ def visitor(sub_name, sub_obj):
193
+ full_path = f"{name}/{sub_name}"
194
+ self.logger.debug(f"Processing sub-item from expansion: {full_path}")
195
+ if self.structure_only:
196
+ collect_structure(full_path, sub_obj)
197
+ else:
198
+ collect_structure_and_data(full_path, sub_obj)
199
+
200
+ item.visititems(visitor)
201
+
202
+ except Exception as e:
203
+ self.logger.error(f"Error processing {name}: {str(e)}")
204
+
205
+ # First try direct path access for table names
206
+ if self.tables:
207
+ for table_path in self.tables:
208
+ try:
209
+ if table_path in f:
210
+ process_item(table_path, f[table_path])
211
+ else:
212
+ self.logger.warning(f"Table {table_path} not found in {file_path}")
213
+ except Exception as e:
214
+ self.logger.error(f"Error processing {table_path}: {str(e)}")
215
+
216
+ # Then process regex pattern if specified
217
+ if regex_patterns:
218
+ def visit_with_regex(name, obj):
219
+ self.logger.debug(f"Checking path: {name}")
220
+ if should_process(name):
221
+ self.logger.debug(f"Processing matched path: {name}")
222
+ process_item(name, obj)
223
+ else:
224
+ self.logger.debug(f"Skipping path: {name}")
225
+ f.visititems(visit_with_regex)
226
+ else:
227
+ # If no tables specified, read all datasets
228
+ if self.structure_only:
229
+ f.visititems(collect_structure)
230
+ else:
231
+ f.visititems(collect_structure_and_data)
232
+
233
+ self.logger.debug(f"Read {len(content)} items from {file_path}")
234
+ self.logger.debug(f"Items read: {list(content.keys())}")
235
+ return content
236
+
237
+ def compare_content(self, content1, content2):
238
+ """Compare two H5 file contents
239
+ @return tuple: (bool, list, bool) - (identical, differences, truncated)
240
+ """
241
+ self._error_stats = None # Reset per comparison
242
+ identical = True
243
+ differences = []
244
+
245
+ # ── Error analysis accumulators ──
246
+ _ea_total = 0
247
+ _ea_mismatched = 0
248
+ _ea_sum_abs = 0.0
249
+ _ea_sum_sq = 0.0
250
+ _ea_max_abs = None
251
+ _ea_max_abs_at = None
252
+ _ea_max_rel = None
253
+ _ea_max_rel_at = None
254
+
255
+ # Filter out metadata keys (starting with _)
256
+ metadata_keys = {'_file_path', '_start_line', '_end_line', '_start_column', '_end_column'}
257
+
258
+ # Get all unique table names (excluding metadata)
259
+ all_tables = (set(content1.keys()) | set(content2.keys())) - metadata_keys
260
+
261
+ # Debug log
262
+ self.logger.debug(f"Structure-only mode: {self.structure_only}")
263
+ self.logger.debug(f"Number of tables to compare: {len(all_tables)}")
264
+
265
+ for table_name in all_tables:
266
+ # Debug log
267
+ self.logger.debug(f"Comparing table: {table_name}")
268
+ if table_name in content1 and table_name in content2:
269
+ self.logger.debug(f"Table1 keys: {content1[table_name].keys()}")
270
+ self.logger.debug(f"Table2 keys: {content2[table_name].keys()}")
271
+
272
+ # Check if table exists in both files
273
+ if table_name not in content1:
274
+ differences.append(self._create_difference(
275
+ position=table_name,
276
+ expected="Table exists",
277
+ actual="Table missing",
278
+ diff_type="structure"
279
+ ))
280
+ identical = False
281
+ continue
282
+
283
+ if table_name not in content2:
284
+ differences.append(self._create_difference(
285
+ position=table_name,
286
+ expected="Table exists",
287
+ actual="Table missing",
288
+ diff_type="structure"
289
+ ))
290
+ identical = False
291
+ continue
292
+
293
+ table1 = content1[table_name]
294
+ table2 = content2[table_name]
295
+
296
+ # Compare table type
297
+ if table1.get('type') != table2.get('type'):
298
+ differences.append(self._create_difference(
299
+ position=f"{table_name}/type",
300
+ expected=table1.get('type'),
301
+ actual=table2.get('type'),
302
+ diff_type="structure"
303
+ ))
304
+ identical = False
305
+ continue
306
+
307
+ # For datasets, compare shape and dtype
308
+ if table1.get('type') == 'dataset':
309
+ if table1['shape'] != table2['shape']:
310
+ differences.append(self._create_difference(
311
+ position=f"{table_name}/shape",
312
+ expected=str(table1['shape']),
313
+ actual=str(table2['shape']),
314
+ diff_type="structure"
315
+ ))
316
+ identical = False
317
+
318
+ if table1['dtype'] != table2['dtype']:
319
+ differences.append(self._create_difference(
320
+ position=f"{table_name}/dtype",
321
+ expected=str(table1['dtype']),
322
+ actual=str(table2['dtype']),
323
+ diff_type="structure"
324
+ ))
325
+ identical = False
326
+
327
+ # For groups, compare keys
328
+ elif table1.get('type') == 'group':
329
+ keys1 = set(table1['keys'])
330
+ keys2 = set(table2['keys'])
331
+ if keys1 != keys2:
332
+ missing_keys = keys1 - keys2
333
+ extra_keys = keys2 - keys1
334
+ if missing_keys:
335
+ differences.append(self._create_difference(
336
+ position=f"{table_name}/keys",
337
+ expected=str(sorted(missing_keys)),
338
+ actual="Keys missing",
339
+ diff_type="structure"
340
+ ))
341
+ if extra_keys:
342
+ differences.append(self._create_difference(
343
+ position=f"{table_name}/keys",
344
+ expected="No extra keys",
345
+ actual=str(sorted(extra_keys)),
346
+ diff_type="structure"
347
+ ))
348
+ identical = False
349
+
350
+ # Only compare attributes and data if not in structure-only mode
351
+ if not self.structure_only:
352
+ self.logger.debug(f"Comparing attributes and data for {table_name}")
353
+
354
+ # Compare attributes
355
+ attr_diff = self._compare_attributes(table1['attrs'], table2['attrs'], table_name)
356
+ if attr_diff:
357
+ differences.extend(attr_diff)
358
+ identical = False
359
+
360
+ # Compare data content
361
+ if 'data' in table1 and 'data' in table2:
362
+ data1 = table1['data']
363
+ data2 = table2['data']
364
+
365
+ # Check if this is a large dataset that needs chunked comparison
366
+ if table1.get('needs_chunked_reading') or table2.get('needs_chunked_reading'):
367
+ # Large dataset: need to read from files in chunks
368
+ self.logger.debug(f"Comparing large dataset {table_name} using chunked reading")
369
+ # Get file paths from content dictionaries
370
+ file1_path = content1.get('_file_path')
371
+ file2_path = content2.get('_file_path')
372
+
373
+ if not file1_path or not file2_path:
374
+ self.logger.error(f"File paths not available for chunked reading of {table_name}")
375
+ differences.append(self._create_difference(
376
+ position=table_name,
377
+ expected="File path available",
378
+ actual="File path missing",
379
+ diff_type="error"
380
+ ))
381
+ identical = False
382
+ else:
383
+ dataset_diff = self._compare_dataset_chunked(
384
+ table1, table2, table_name, file1_path, file2_path
385
+ )
386
+ if dataset_diff:
387
+ differences.extend(dataset_diff)
388
+ identical = False
389
+ elif isinstance(data1, np.ndarray) and isinstance(data2, np.ndarray):
390
+ # Small dataset: already in memory, compare directly
391
+ try:
392
+ # 应用过滤器
393
+ mask1 = self.filter_func(data1) if self.filter_func else np.ones_like(data1, dtype=bool)
394
+ mask2 = self.filter_func(data2) if self.filter_func else np.ones_like(data2, dtype=bool)
395
+
396
+ # 我们只关心两个文件中都满足条件的位置
397
+ combined_mask = mask1 & mask2
398
+
399
+ # 过滤后的数据
400
+ filtered_data1 = data1[combined_mask]
401
+ filtered_data2 = data2[combined_mask]
402
+
403
+ if self.filter_func:
404
+ self.logger.debug(f"Applied filter to {table_name}: {np.sum(combined_mask)}/{data1.size} elements meet criteria")
405
+
406
+ # 对于数值类型数据使用 isclose
407
+ if np.issubdtype(data1.dtype, np.number) and np.issubdtype(data2.dtype, np.number):
408
+ is_close = np.isclose(filtered_data1, filtered_data2, equal_nan=True, rtol=self.rtol, atol=self.atol)
409
+ all_close = np.all(is_close)
410
+
411
+ # ── Error analysis: streaming stats over ALL numeric cells ──
412
+ if self.error_analysis:
413
+ f1 = np.asarray(filtered_data1, dtype=np.float64)
414
+ f2 = np.asarray(filtered_data2, dtype=np.float64)
415
+ n_cells = int(f1.size)
416
+ _ea_total += n_cells
417
+ if not all_close:
418
+ mismatched = int(np.sum(~is_close))
419
+ _ea_mismatched += mismatched
420
+ diff = np.abs(f2 - f1)
421
+ abs_err_vals = diff[~is_close]
422
+ _ea_sum_abs += float(np.sum(abs_err_vals))
423
+ _ea_sum_sq += float(np.sum(abs_err_vals ** 2))
424
+ table_max_abs = float(np.max(abs_err_vals))
425
+ if _ea_max_abs is None or table_max_abs > _ea_max_abs:
426
+ _ea_max_abs = table_max_abs
427
+ _ea_max_abs_at = table_name
428
+ # Relative error (avoid div by zero)
429
+ d1_vals = np.abs(f1[~is_close])
430
+ safe_div = np.where(d1_vals > 0, d1_vals, 1e-300)
431
+ rel_errs = abs_err_vals / safe_div
432
+ table_max_rel = float(np.max(rel_errs))
433
+ if _ea_max_rel is None or table_max_rel > _ea_max_rel:
434
+ _ea_max_rel = table_max_rel
435
+ _ea_max_rel_at = table_name
436
+
437
+ if not all_close:
438
+ if self.show_content_diff:
439
+ # 如果过滤后数据不相等,需要找到原始数据的索引来报告差异
440
+ # 简化处理:直接报告内容不同
441
+ differences.append(self._create_difference(
442
+ position=table_name,
443
+ expected="Same content (after filtering)",
444
+ actual="Content differs (after filtering)",
445
+ diff_type="content"
446
+ ))
447
+ else:
448
+ # Just report that content differs
449
+ differences.append(self._create_difference(
450
+ position=table_name,
451
+ expected="Same content (after filtering)",
452
+ actual="Content differs (after filtering)",
453
+ diff_type="content"
454
+ ))
455
+ identical = False
456
+ # 对于字符串或其他类型直接比较
457
+ else:
458
+ if not np.array_equal(filtered_data1, filtered_data2):
459
+ if self.show_content_diff:
460
+ # For non-numeric arrays, find the first difference
461
+ diff_indices = np.where(filtered_data1 != filtered_data2)
462
+ for idx in list(zip(*diff_indices))[:10]:
463
+ position = f"{table_name}[{','.join(map(str, idx))}]"
464
+ differences.append(self._create_difference(
465
+ position=position,
466
+ expected=str(filtered_data1[idx]),
467
+ actual=str(filtered_data2[idx]),
468
+ diff_type="content"
469
+ ))
470
+ else:
471
+ differences.append(self._create_difference(
472
+ position=table_name,
473
+ expected="Same content (after filtering)",
474
+ actual="Content differs (after filtering)",
475
+ diff_type="content"
476
+ ))
477
+ identical = False
478
+ except Exception as e:
479
+ self.logger.error(f"Error comparing data in table {table_name}: {str(e)}")
480
+ differences.append(self._create_difference(
481
+ position=table_name,
482
+ expected=f"Data type: {table1.get('dtype', 'unknown')}",
483
+ actual=f"Data type: {table2.get('dtype', 'unknown')}",
484
+ diff_type="error"
485
+ ))
486
+ identical = False
487
+
488
+ # ── Store error stats ──
489
+ if self.error_analysis and _ea_total > 0:
490
+ self._error_stats = {
491
+ "total_numeric_cells": _ea_total,
492
+ "mismatched_cells": _ea_mismatched,
493
+ "max_abs_error": _ea_max_abs,
494
+ "max_abs_error_at": _ea_max_abs_at,
495
+ "max_rel_error": _ea_max_rel,
496
+ "max_rel_error_at": _ea_max_rel_at,
497
+ "mean_abs_error": _ea_sum_abs / _ea_mismatched if _ea_mismatched > 0 else 0.0,
498
+ "rms_abs_error": (np.sqrt(_ea_sum_sq / _ea_mismatched) if _ea_mismatched > 0 else 0.0),
499
+ }
500
+
501
+ return identical, differences, False
502
+
503
+ def _compare_attributes(self, attrs1, attrs2, table_name):
504
+ """Compare HDF5 attributes"""
505
+ differences = []
506
+
507
+ # Compare attribute keys
508
+ keys1 = set(attrs1.keys())
509
+ keys2 = set(attrs2.keys())
510
+
511
+ # Check for missing attributes
512
+ for key in keys1 - keys2:
513
+ differences.append(self._create_difference(
514
+ position=f"{table_name}/attrs/{key}",
515
+ expected=str(attrs1[key]),
516
+ actual="Attribute missing",
517
+ diff_type="missing_attribute"
518
+ ))
519
+
520
+ for key in keys2 - keys1:
521
+ differences.append(self._create_difference(
522
+ position=f"{table_name}/attrs/{key}",
523
+ expected="Attribute missing",
524
+ actual=str(attrs2[key]),
525
+ diff_type="extra_attribute"
526
+ ))
527
+
528
+ # Compare common attributes
529
+ for key in keys1 & keys2:
530
+ val1 = attrs1[key]
531
+ val2 = attrs2[key]
532
+
533
+ # Handle numpy arrays in attributes
534
+ if isinstance(val1, np.ndarray) and isinstance(val2, np.ndarray):
535
+ try:
536
+ if not np.array_equal(val1, val2):
537
+ differences.append(self._create_difference(
538
+ position=f"{table_name}/attrs/{key}",
539
+ expected=str(val1),
540
+ actual=str(val2),
541
+ diff_type="attribute"
542
+ ))
543
+ except Exception as e:
544
+ self.logger.error(f"Error comparing array attribute {key}: {str(e)}")
545
+ differences.append(self._create_difference(
546
+ position=f"{table_name}/attrs/{key}",
547
+ expected=str(val1),
548
+ actual=str(val2),
549
+ diff_type="attribute"
550
+ ))
551
+ elif isinstance(val1, np.ndarray) or isinstance(val2, np.ndarray):
552
+ # One is array, one is not - they're different
553
+ differences.append(self._create_difference(
554
+ position=f"{table_name}/attrs/{key}",
555
+ expected=str(val1),
556
+ actual=str(val2),
557
+ diff_type="attribute"
558
+ ))
559
+ else:
560
+ # Regular comparison for non-array values
561
+ if val1 != val2:
562
+ differences.append(self._create_difference(
563
+ position=f"{table_name}/attrs/{key}",
564
+ expected=str(val1),
565
+ actual=str(val2),
566
+ diff_type="attribute"
567
+ ))
568
+
569
+ return differences
570
+
571
+ def _create_difference(self, position, expected, actual, diff_type):
572
+ """Create a Difference object"""
573
+ from .result import Difference
574
+ return Difference(position=position, expected=expected, actual=actual, diff_type=diff_type)
575
+
576
+ def _parse_filter(self):
577
+ """Parse data filter string and return a filter function"""
578
+ if not self.data_filter:
579
+ return None
580
+
581
+ self.logger.debug(f"Parsing data filter: {self.data_filter}")
582
+ try:
583
+ # 匹配模式,例如 'abs>0.1', '>=1e-5', '<-10'
584
+ match = re.match(r"^(abs)?([><]=?|==)([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)$", self.data_filter.replace(" ", ""))
585
+ if not match:
586
+ self.logger.warning(f"Invalid data filter format: {self.data_filter}. Ignoring filter.")
587
+ return None
588
+
589
+ use_abs, op, value_str = match.groups()
590
+ value = float(value_str)
591
+
592
+ op_map = {
593
+ '>': np.greater,
594
+ '>=': np.greater_equal,
595
+ '<': np.less,
596
+ '<=': np.less_equal,
597
+ '==': np.equal
598
+ }
599
+
600
+ def filter_func(data):
601
+ if not isinstance(data, np.ndarray) or not np.issubdtype(data.dtype, np.number):
602
+ return np.ones_like(data, dtype=bool) # 对于非数字类型,不过滤
603
+
604
+ target_data = np.abs(data) if use_abs else data
605
+ return op_map[op](target_data, value)
606
+
607
+ self.logger.debug(f"Created filter function for pattern: {use_abs or ''}{op}{value}")
608
+ return filter_func
609
+ except Exception as e:
610
+ self.logger.error(f"Failed to parse data filter '{self.data_filter}': {e}. Ignoring filter.")
611
+ return None
612
+
613
+ def _compare_dataset_chunked(self, table1, table2, table_name, file1_path, file2_path):
614
+ """
615
+ Compare large datasets using chunked reading to avoid loading entire dataset into memory
616
+ @param table1 dict: Dataset info from first file
617
+ @param table2 dict: Dataset info from second file
618
+ @param table_name str: Name/path of the dataset
619
+ @param file1_path str: Path to first HDF5 file
620
+ @param file2_path str: Path to second HDF5 file
621
+ @return list: List of Difference objects, empty if datasets are identical
622
+ """
623
+ differences = []
624
+ dataset_path = table1.get('dataset_path') or table2.get('dataset_path') or table_name
625
+
626
+ try:
627
+ with h5py.File(file1_path, 'r') as f1, h5py.File(file2_path, 'r') as f2:
628
+ ds1 = f1[dataset_path]
629
+ ds2 = f2[dataset_path]
630
+
631
+ # Verify shapes match (should already be checked, but verify again)
632
+ if ds1.shape != ds2.shape:
633
+ differences.append(self._create_difference(
634
+ position=f"{table_name}/shape",
635
+ expected=str(ds1.shape),
636
+ actual=str(ds2.shape),
637
+ diff_type="structure"
638
+ ))
639
+ return differences
640
+
641
+ # Determine chunk size (1000 rows at a time, adjustable based on memory constraints)
642
+ chunk_size = 1000
643
+ total_rows = ds1.shape[0]
644
+
645
+ # Handle different dimensionalities
646
+ if len(ds1.shape) == 1:
647
+ # 1D array: simple chunking
648
+ for start_idx in range(0, total_rows, chunk_size):
649
+ end_idx = min(start_idx + chunk_size, total_rows)
650
+
651
+ # Read only this slice
652
+ slice1 = ds1[start_idx:end_idx]
653
+ slice2 = ds2[start_idx:end_idx]
654
+
655
+ # Apply filter if specified
656
+ if self.filter_func:
657
+ mask1 = self.filter_func(slice1)
658
+ mask2 = self.filter_func(slice2)
659
+ combined_mask = mask1 & mask2
660
+ slice1 = slice1[combined_mask]
661
+ slice2 = slice2[combined_mask]
662
+
663
+ # Compare slices
664
+ if np.issubdtype(ds1.dtype, np.number) and np.issubdtype(ds2.dtype, np.number):
665
+ if not np.all(np.isclose(slice1, slice2, equal_nan=True, rtol=self.rtol, atol=self.atol)):
666
+ differences.append(self._create_difference(
667
+ position=f"{table_name}[{start_idx}:{end_idx}]",
668
+ expected="Content matches",
669
+ actual="Content differs",
670
+ diff_type="content"
671
+ ))
672
+ # Early return on first difference to save time
673
+ return differences
674
+ else:
675
+ if not np.array_equal(slice1, slice2):
676
+ differences.append(self._create_difference(
677
+ position=f"{table_name}[{start_idx}:{end_idx}]",
678
+ expected="Content matches",
679
+ actual="Content differs",
680
+ diff_type="content"
681
+ ))
682
+ return differences
683
+
684
+ elif len(ds1.shape) == 2:
685
+ # 2D array: chunk along first dimension
686
+ for start_idx in range(0, total_rows, chunk_size):
687
+ end_idx = min(start_idx + chunk_size, total_rows)
688
+
689
+ # Read slice along first dimension
690
+ slice1 = ds1[start_idx:end_idx, :]
691
+ slice2 = ds2[start_idx:end_idx, :]
692
+
693
+ # Apply filter if specified
694
+ if self.filter_func:
695
+ mask1 = self.filter_func(slice1)
696
+ mask2 = self.filter_func(slice2)
697
+ combined_mask = mask1 & mask2
698
+ # Flatten for filtering, then reshape
699
+ flat1 = slice1.flatten()
700
+ flat2 = slice2.flatten()
701
+ slice1 = flat1[combined_mask.flatten()]
702
+ slice2 = flat2[combined_mask.flatten()]
703
+
704
+ # Compare slices
705
+ if np.issubdtype(ds1.dtype, np.number) and np.issubdtype(ds2.dtype, np.number):
706
+ if not np.all(np.isclose(slice1, slice2, equal_nan=True, rtol=self.rtol, atol=self.atol)):
707
+ differences.append(self._create_difference(
708
+ position=f"{table_name}[{start_idx}:{end_idx},:]",
709
+ expected="Content matches",
710
+ actual="Content differs",
711
+ diff_type="content"
712
+ ))
713
+ return differences
714
+ else:
715
+ if not np.array_equal(slice1, slice2):
716
+ differences.append(self._create_difference(
717
+ position=f"{table_name}[{start_idx}:{end_idx},:]",
718
+ expected="Content matches",
719
+ actual="Content differs",
720
+ diff_type="content"
721
+ ))
722
+ return differences
723
+
724
+ else:
725
+ # Higher dimensional arrays: chunk along first dimension
726
+ # This is a simplified approach; for very high-dimensional arrays,
727
+ # you might want more sophisticated chunking
728
+ for start_idx in range(0, total_rows, chunk_size):
729
+ end_idx = min(start_idx + chunk_size, total_rows)
730
+
731
+ # Create slice tuple
732
+ slice_tuple1 = (slice(start_idx, end_idx),) + (slice(None),) * (len(ds1.shape) - 1)
733
+ slice_tuple2 = (slice(start_idx, end_idx),) + (slice(None),) * (len(ds2.shape) - 1)
734
+
735
+ slice1 = ds1[slice_tuple1]
736
+ slice2 = ds2[slice_tuple2]
737
+
738
+ # Apply filter if specified (flatten for filtering)
739
+ if self.filter_func:
740
+ flat1 = slice1.flatten()
741
+ flat2 = slice2.flatten()
742
+ mask1 = self.filter_func(flat1)
743
+ mask2 = self.filter_func(flat2)
744
+ combined_mask = mask1 & mask2
745
+ slice1 = flat1[combined_mask]
746
+ slice2 = flat2[combined_mask]
747
+
748
+ # Compare slices
749
+ if np.issubdtype(ds1.dtype, np.number) and np.issubdtype(ds2.dtype, np.number):
750
+ if not np.all(np.isclose(slice1, slice2, equal_nan=True, rtol=self.rtol, atol=self.atol)):
751
+ differences.append(self._create_difference(
752
+ position=f"{table_name}[{start_idx}:{end_idx},...]",
753
+ expected="Content matches",
754
+ actual="Content differs",
755
+ diff_type="content"
756
+ ))
757
+ return differences
758
+ else:
759
+ if not np.array_equal(slice1, slice2):
760
+ differences.append(self._create_difference(
761
+ position=f"{table_name}[{start_idx}:{end_idx},...]",
762
+ expected="Content matches",
763
+ actual="Content differs",
764
+ diff_type="content"
765
+ ))
766
+ return differences
767
+
768
+ except Exception as e:
769
+ self.logger.error(f"Error in chunked comparison of {table_name}: {str(e)}")
770
+ differences.append(self._create_difference(
771
+ position=table_name,
772
+ expected="Successful comparison",
773
+ actual=f"Error: {str(e)}",
774
+ diff_type="error"
775
+ ))
776
+
777
+ return differences