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.
- symtest/__init__.py +45 -0
- symtest/cli.py +549 -0
- symtest/commands/__init__.py +9 -0
- symtest/commands/compare.py +221 -0
- symtest/config/__init__.py +7 -0
- symtest/config/config_io.py +346 -0
- symtest/config/config_schema.py +330 -0
- symtest/config/import_expander.py +149 -0
- symtest/config/inheritance_expander.py +197 -0
- symtest/core/__init__.py +15 -0
- symtest/core/assertions.py +253 -0
- symtest/core/base_runner.py +299 -0
- symtest/core/config_loader.py +536 -0
- symtest/core/execution.py +498 -0
- symtest/core/history_store.py +96 -0
- symtest/core/last_run_store.py +109 -0
- symtest/core/parallel_runner.py +251 -0
- symtest/core/process_worker.py +93 -0
- symtest/core/sequence_state.py +143 -0
- symtest/core/setup.py +137 -0
- symtest/core/test_case.py +76 -0
- symtest/core/types.py +92 -0
- symtest/file_comparator/__init__.py +10 -0
- symtest/file_comparator/base_comparator.py +109 -0
- symtest/file_comparator/binary_comparator.py +399 -0
- symtest/file_comparator/csv_comparator.py +241 -0
- symtest/file_comparator/factory.py +191 -0
- symtest/file_comparator/h5_comparator.py +777 -0
- symtest/file_comparator/json_comparator.py +323 -0
- symtest/file_comparator/result.py +213 -0
- symtest/file_comparator/script_comparator.py +182 -0
- symtest/file_comparator/text_comparator.py +182 -0
- symtest/file_comparator/xml_comparator.py +150 -0
- symtest/logging_config.py +66 -0
- symtest/runners/__init__.py +15 -0
- symtest/runners/config_runner.py +96 -0
- symtest/runners/json_runner.py +21 -0
- symtest/runners/parallel_config_runner.py +278 -0
- symtest/runners/parallel_json_runner.py +26 -0
- symtest/runners/parallel_yaml_runner.py +31 -0
- symtest/runners/yaml_runner.py +26 -0
- symtest/tui/__init__.py +11 -0
- symtest/tui/app.py +90 -0
- symtest/tui/controllers/__init__.py +0 -0
- symtest/tui/controllers/case_controller.py +322 -0
- symtest/tui/screens/__init__.py +0 -0
- symtest/tui/screens/case_editor.py +244 -0
- symtest/tui/screens/case_list.py +255 -0
- symtest/tui/widgets/__init__.py +0 -0
- symtest/tui/widgets/case_table.py +113 -0
- symtest/tui/widgets/expected_editor.py +159 -0
- symtest/tui/widgets/search_bar.py +160 -0
- symtest/tui/widgets/steps_editor.py +243 -0
- symtest/utils/__init__.py +21 -0
- symtest/utils/junit_xml_writer.py +137 -0
- symtest/utils/path_resolver.py +124 -0
- symtest/utils/report_generator.py +208 -0
- symtest_cli-1.3.0.dist-info/METADATA +316 -0
- symtest_cli-1.3.0.dist-info/RECORD +63 -0
- symtest_cli-1.3.0.dist-info/WHEEL +5 -0
- symtest_cli-1.3.0.dist-info/entry_points.txt +4 -0
- symtest_cli-1.3.0.dist-info/licenses/LICENSE +21 -0
- 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
|