Files
SystemSimulationApp/tests/model_audit/audit_mql4_model.py
lujingze b6c22a54f8 修复测试模型路径并完善四路八路回归与文档
- 按文件哈希区分当前八路工程和历史 Amesim 基准,修复测试与清单中的旧路径。
- 四路审计从 JSON 按需生成 XML,八路新增只核对模式,并显式使用 UTF-8 与 LF。
- 将缺失高刚度夹具的测试替换为当前八路完整 BDF 运行,明确未恢复原 RK45 专项覆盖。
- 更新前端大型工程测试、历史活动回放入口、数据说明、现行规范和相关报告。

验证:全量后台 374 项通过、1 项条件跳过;前端 11 项通过、历史独立服务用例 1 项跳过;四路八路 AME 审计通过。
环境、缓存和运行产物保持忽略;本次未实施工程 JSON 字段精简。
2026-09-12 15:49:09 +00:00

423 lines
23 KiB
Python

"""Audit the AME diagram against the current browser project; generate XML on demand.
Run from the repository root with .venv/bin/python. Does not run Amesim or use
the stale eight-branch compiled/result cache inside the four-branch archive.
"""
from __future__ import annotations
import argparse
from collections import Counter, defaultdict
from copy import deepcopy
import hashlib
import json
from pathlib import Path
import re
import sys
if '--check' in sys.argv:
sys.dont_write_bytecode = True
import struct
import tarfile
import xml.etree.ElementTree as ET
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from app.simulation.registry import get_component_model_spec
from tests.test_test_mql_ame_contract import (
_blocks, _required_element_text, _element_text, _resolve_globals,
_public_model_type, _expected_parameters, _value_fields,
)
OUT = ROOT / 'test/model-audit/mql4'
AME = ROOT / 'tests/data/test_mql_4.ame'
XML = OUT / 'aligned-input.xml'
REFERENCE = ROOT / 'tests/baselines/simulation/test_mql_4/test-mql-4-amesim-reference.json'
PROJECT = ROOT / 'tests/data/test-mql-4-corrected.json'
def sha(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def public_port(model, index):
if index == 0 and model in {'amesim_pnvo001', 'amesim_forc'}:
return 'res'
if index == 0 and model in {'amesim_step0', 'amesim_ud00'}:
return 'out'
return f'port_{index + 1}'
def parameter_key(values):
return tuple(sorted((key, format(float(value), '.12g')) for key, value in values.items()))
def audit_source():
with tarfile.open(AME) as archive:
def read(suffix):
names = [name for name in archive.getnames() if name.endswith(suffix)]
if len(names) != 1:
raise ValueError((suffix, names))
return archive.extractfile(names[0]).read().decode('latin1')
cir = read('.cir')
sim = read('.sim')
modelinfo = read('.modelinfo')
variables = read('.var')
perf = read('.ameperf')
result_name = next(name for name in archive.getnames() if name.endswith('.results'))
result_data = archive.extractfile(result_name).read()
result_points, result_columns = struct.unpack('<2i', result_data[:8])
member_hashes = {}
for suffix in ['.cir', '.sim', '.modelinfo', '.var', '.results']:
name = next(name for name in archive.getnames() if name.endswith(suffix))
data = archive.extractfile(name).read()
member_hashes[name] = {'bytes': len(data), 'sha256': hashlib.sha256(data).hexdigest()}
globals_ = _resolve_globals(cir)
components = _blocks(cir, 'COMP')
lines = _blocks(cir, 'LINE')
nodes = {}
for kind, entities in [('component', components), ('line', lines)]:
for index, body in enumerate(entities):
submodel = _required_element_text(body, 'SUB_NAME')
if submodel == 'DIRECT':
continue
model = _public_model_type(submodel)
nodes[f'{kind}:{index}'] = {
'modelType': model,
'alias': _required_element_text(body, 'ALIAS'),
'parameters': _expected_parameters(body, model, globals_),
'sourceValues': {name: {'expression': value.expression, 'unit': value.unit}
for name, value in _value_fields(body).items()},
'position': _element_text(body, 'COMP_POS') or _element_text(body, 'LINE_POINTS'),
'geometry': _element_text(body, 'COMP_GEOMETRY'),
'outputType': _element_text(body, 'OUTPUT_TYPE'),
}
edges = set()
contacts = set()
for index, body in enumerate(components):
for port_index, port_body in enumerate(_blocks(body, 'COMP_PORT')):
if _required_element_text(port_body, 'PORT_CONNECT') != '1':
continue
for connection in _blocks(port_body, 'CONNECT'):
other = int(_required_element_text(connection, 'CONNECT_ENTITY_NUM'))
other_port = int(_required_element_text(connection, 'CONNECT_ENTITY_PORT'))
contacts.add(((index, port_index), (other, other_port)))
assert all((b, a) in contacts for a, b in contacts)
def endpoint(index, port):
key = f'component:{index}'
return key, public_port(nodes[key]['modelType'], port)
for a, b in contacts:
edges.add(tuple(sorted((endpoint(*a), endpoint(*b)))))
for index, body in enumerate(lines):
assert _required_element_text(body, 'LINE_START_TYPE') == '0'
assert _required_element_text(body, 'LINE_END_TYPE') == '0'
start = endpoint(int(_required_element_text(body, 'LINE_START_ENTITY')),
int(_required_element_text(body, 'LINE_START_PORT')))
end = endpoint(int(_required_element_text(body, 'LINE_END_ENTITY')),
int(_required_element_text(body, 'LINE_END_PORT')))
if _required_element_text(body, 'SUB_NAME') == 'DIRECT':
edges.add(tuple(sorted((start, end))))
else:
key = f'line:{index}'
output_type = _required_element_text(body, 'OUTPUT_TYPE')
assert output_type in {'1', '2'}
first, second = ('port_1', 'port_2') if output_type == '1' else ('port_2', 'port_1')
edges.add(tuple(sorted((start, (key, first)))))
edges.add(tuple(sorted((end, (key, second)))))
count = Counter(node['modelType'] for node in nodes.values())
states = sum(count[model] * size for model, size in {
'amesim_pnch012': 2, 'amesim_pnch023': 2, 'amesim_pnl0001': 2,
'amesim_pnl0002': 2, 'amesim_pnl0003': 4, 'amesim_mecmas21': 2,
}.items())
return nodes, sorted(edges), {
'archiveSha256': sha(AME), 'archiveBytes': AME.stat().st_size,
'members': member_hashes, 'globals': globals_,
'componentCount': len(components), 'modeledLineCount': len(nodes) - len(components),
'directLineCount': len(lines) - (len(nodes) - len(components)),
'contactCount': len(contacts) // 2, 'publicNodeCount': len(nodes),
'publicParameterCount': sum(len(node['parameters']) for node in nodes.values()),
'connectionCount': len(edges), 'typeCounts': dict(count),
'diagramDynamicStateCount': states,
'simulationFileRaw': sim, 'savedSimulationNumericRows': [[float(x) for x in row.split()] for row in sim.splitlines()],
'cachedModelInfo': modelinfo,
'cachedResultsSuitableForFourBranchComparison': False,
'cacheRejectionReason': 'Diagram has 64 states / four pistons; cached modelinfo has 132 states, .ameperf contains eight LSTP00A instances, and .results declares 1116 saved variables while the current .var has only 640 rows.',
'cachedResultHeader': {'pointCount': result_points, 'savedVariableCount': abs(result_columns)},
'variableRowCount': len(variables.splitlines()),
'cachedPerformanceContactInstances': sorted(set(re.findall(r'LSTP00A-([0-9]+)', perf))),
'cachedPistonAliases': sorted(set(re.findall(r'Data_Path=\S+@(pn_brp2[^\s]*)', variables))),
}
def project_xml():
# The separately maintained XML fixture was deleted. Use the same input
# conversion as the native CLI; the AME remains the independent authority.
from app.simulation.native_codegen.input import load_input
xml, _ = load_input(PROJECT)
return xml
def xml_model(root=None):
if root is None:
root = ET.fromstring(project_xml())
nodes = {item.attrib['id']: {
'modelType': item.attrib['type'],
'modelVersion': item.attrib['modelVersion'],
'parameters': {p.attrib['name']: float(p.attrib['value']) for p in item.findall('Parameter')},
} for item in root.findall('./Components/Component')}
edges = [tuple(sorted((ep.attrib['component'], ep.attrib['port']) for ep in item.findall('Endpoint')))
for item in root.findall('./Connections/Connection')]
return nodes, edges, root
def find_mapping(first, first_edges, second, second_edges, *, ports=False, parameters=True):
"""Find an actual graph bijection, preserving node types and all parameters."""
def edge_label(node, port):
if not ports:
return ''
return port
def graph(nodes, edges):
adjacency = {key: defaultdict(Counter) for key in nodes}
for (a, ap), (b, bp) in edges:
adjacency[a][b][(edge_label(a, ap), edge_label(b, bp))] += 1
adjacency[b][a][(edge_label(b, bp), edge_label(a, ap))] += 1
return adjacency
a, b = graph(first, first_edges), graph(second, second_edges)
def signature(nodes, adj, key):
return (nodes[key]['modelType'], parameter_key(nodes[key]['parameters']) if parameters else (),
sum(sum(labels.values()) for labels in adj[key].values()))
candidates = {key: [other for other in second if signature(first, a, key) == signature(second, b, other)]
for key in first}
if any(not options for options in candidates.values()):
return None
mapping = {}
used = set()
def search():
if len(mapping) == len(first):
return dict(mapping)
key = min((key for key in first if key not in mapping),
key=lambda key: (-sum(neighbor in mapping for neighbor in a[key]),
len([other for other in candidates[key] if other not in used]), -len(a[key])))
for other in candidates[key]:
if other in used:
continue
if any(a[key].get(existing, Counter()) != b[other].get(target, Counter())
for existing, target in mapping.items()):
continue
mapping[key] = other
used.add(other)
result = search()
if result is not None:
return result
used.remove(other)
del mapping[key]
return None
return search()
def check_project_contract(project, xml_root):
"""Check generated port metadata and exact JSON/XML execution data, without writes.
This covers the observed nullable optional-field failure and shared physical
data, not a replacement for the complete frontend project parser.
"""
xml_nodes, xml_edges, _ = xml_model(xml_root)
json_nodes = {node['id']: node['data'] for node in project['nodes']}
if len(json_nodes) != len(project['nodes']) or json_nodes.keys() != xml_nodes.keys():
raise ValueError('JSON/XML component IDs differ or JSON contains duplicate IDs.')
for node_id, data in json_nodes.items():
expected = xml_nodes[node_id]
if (data['componentType'] != expected['modelType'] or
data['modelType'] != expected['modelType'] or
data['modelVersion'] != expected['modelVersion']):
raise ValueError(f'{node_id}: JSON/XML model type or version differs.')
if data['parameters'] != expected['parameters']:
raise ValueError(f'{node_id}: JSON/XML parameters differ.')
for port in data['ports']:
# frontend/src/App.tsx:isProjectPortDefinitionValue accepts omission,
# or the literal intoComponent. An explicit null rejects the import.
if ('positiveFlowDirection' in port and
port['positiveFlowDirection'] != 'intoComponent'):
raise ValueError(f"{node_id}.{port['name']}: omit undefined positiveFlowDirection instead of null.")
json_edges = []
for edge in project['edges']:
endpoints = []
for side in ['source', 'target']:
component, port = edge[side], edge[side + 'Handle']
if component not in json_nodes:
raise ValueError(f'Unknown connection component {component}.')
data = json_nodes[component]
active_ports = get_component_model_spec(data['modelType']).active_ports(data['parameters'])
if port not in {item.name for item in active_ports}:
raise ValueError(f'{component}.{port}: connection port is inactive or unknown.')
endpoints.append((component, port))
json_edges.append(tuple(sorted(endpoints)))
if Counter(json_edges) != Counter(xml_edges):
raise ValueError('JSON/XML connection endpoints differ.')
simulation = xml_root.find('Simulation').attrib
for key, xml_key in [('t_start', 'tStart'), ('t_stop', 'tStop'),
('step', 'sampleStep'), ('max_step', 'maxStep')]:
if project['simulation'][key] != float(simulation[xml_key]):
raise ValueError(f'JSON/XML simulation {key} differs.')
if project['simulation']['method'] != simulation['method']:
raise ValueError('JSON/XML integration methods differ.')
return {'valid': True, 'componentCount': len(json_nodes), 'connectionCount': len(json_edges),
'parameterCount': sum(len(data['parameters']) for data in json_nodes.values()),
'optionalPortFieldsValid': True, 'jsonXmlExecutionDataEqual': True}
def generate_project(source, source_edges, target, target_edges, xml_root, mapping):
layout_source = PROJECT
layout = json.loads(layout_source.read_text(encoding='utf-8'))
layout_sha = sha(layout_source)
layout_nodes = {node['id']: node for node in layout['nodes']}
reverse_mapping = {value: key for key, value in mapping.items()}
nodes = []
for node_id, model in target.items():
source_node = source[reverse_mapping[node_id]]
template = layout_nodes.get(node_id)
if template is None:
template = next(node for node in layout['nodes'] if node['data']['modelType'] == model['modelType'])
node = deepcopy(template)
node['id'] = node_id
node['selected'] = False
node['data']['label'] = source_node['alias']
node['data']['parameters'] = source_node['parameters']
node['data']['modelVersion'] = model['modelVersion']
node['data']['ports'] = get_component_model_spec(model['modelType']).as_catalog_dict()['ports']
for port in node['data']['ports']:
if port.get('positiveFlowDirection') is None:
port.pop('positiveFlowDirection', None)
if model['modelType'] == 'amesim_p4node2':
node['data']['rotation'] = 180
node['data']['mirrored'] = False
if node_id not in layout_nodes:
# The extra closing pipe sits below the four retained branches.
node['position'] = {'x': 80.0, 'y': 900.0}
node['data']['rotation'] = 0
nodes.append(node)
original_by_pair = {}
for edge in layout['edges']:
pair = tuple(sorted((edge['source'], edge['target'])))
original_by_pair[pair] = edge
xml_by_pair = {}
for connection in xml_root.findall('./Connections/Connection'):
pair = tuple(sorted(ep.attrib['component'] for ep in connection.findall('Endpoint')))
xml_by_pair[pair] = connection
edges = []
changed_connections = []
for ((a, ap), (b, bp)) in source_edges:
a, b = mapping[a], mapping[b]
pair = tuple(sorted((a, b)))
existing = original_by_pair.get(pair)
edge = deepcopy(existing) if existing else {'id': 'ame-' + a + '-' + ap + '-' + b + '-' + bp}
if existing and existing['source'] == b:
a, ap, b, bp = b, bp, a, ap
old_connection = xml_by_pair[pair]
old_endpoints = {ep.attrib['component']: ep.attrib['port'] for ep in old_connection.findall('Endpoint')}
changed = old_endpoints != {a: ap, b: bp}
if changed:
changed_connections.append({'components': pair, 'before': old_endpoints, 'after': {a: ap, b: bp}})
edge.update({'source': a, 'target': b, 'sourceHandle': ap, 'targetHandle': bp})
edge.pop('selected', None)
if any(target[node_id]['modelType'] == 'amesim_p4node2' for node_id in [a, b]):
edge.setdefault('data', {}).pop('routePoints', None)
if changed or existing is None:
edge['data'] = {'isContactEdge': False}
edges.append(edge)
for endpoint in old_connection.findall('Endpoint'):
endpoint.attrib['port'] = {a: ap, b: bp}[endpoint.attrib['component']]
parameter_changes = []
for component in xml_root.findall('./Components/Component'):
node_id = component.attrib['id']
parameters = source[reverse_mapping[node_id]]['parameters']
for parameter in component.findall('Parameter'):
key = parameter.attrib['name']
before = float(parameter.attrib['value'])
after = parameters[key]
if abs(before - after) > max(1e-18, abs(after) * 1e-12):
parameter_changes.append({'component': node_id, 'parameter': key, 'before': before, 'after': after})
parameter.attrib['value'] = repr(after)
simulation = {'t_start': 0.0, 't_stop': 10.0, 'step': 0.01, 'max_step': 1e30, 'method': 'BDF'}
project = {'projectSchemaVersion': 1, 'name': 'test-mql-4-AME-aligned', 'nodes': nodes,
'edges': edges, 'simulation': simulation}
check_project_contract(project, xml_root)
PROJECT.write_text(json.dumps(project, indent=2, ensure_ascii=False) + '\n', encoding='utf-8', newline='\n')
generated_xml = OUT / 'aligned-input.xml'
xml_root.attrib['name'] = project['name']
ET.indent(xml_root)
ET.ElementTree(xml_root).write(generated_xml, encoding='utf-8', xml_declaration=True)
XML.write_bytes(generated_xml.read_bytes())
# Check the delivered JSON itself, including every exact endpoint and number.
json_nodes = {node['id']: node['data'] for node in json.loads(PROJECT.read_text(encoding='utf-8'))['nodes']}
for source_id, project_id in mapping.items():
assert json_nodes[project_id]['parameters'] == source[source_id]['parameters']
expected_edges = {tuple(sorted(((mapping[a], ap), (mapping[b], bp)))) for ((a, ap), (b, bp)) in source_edges}
actual_edges = {tuple(sorted(((edge['source'], edge['sourceHandle']), (edge['target'], edge['targetHandle'])))) for edge in edges}
assert expected_edges == actual_edges
for edge in edges:
for side in ['source', 'target']:
data = json_nodes[edge[side]]
active_ports = get_component_model_spec(data['modelType']).active_ports(data['parameters'])
assert edge[side + 'Handle'] in {port.name for port in active_ports}
return {'path': str(PROJECT.relative_to(ROOT)), 'sha256': sha(PROJECT),
'xmlPath': str(generated_xml.relative_to(ROOT)), 'xmlSha256': sha(generated_xml),
'nodeCount': len(nodes), 'edgeCount': len(edges), 'parameterCount': sum(len(n['data']['parameters']) for n in nodes),
'allMappedParametersExactlyMatchAme': True, 'allMappedConnectionPortsExactlyMatchAme': True,
'parameterChangesFromLegacyXml': parameter_changes, 'connectionPortChangesFromLegacyXml': changed_connections,
'layoutMetadataSource': {'path': str(layout_source.relative_to(ROOT)), 'sha256': layout_sha},
'simulation': simulation,
'simulationSettingsNote': 'AME archive stores historical fixed-step settings. This JSON uses native BDF; rtol is supplied by the backend and is not stored in the project. Initial validation used 1e-7; the backend default was subsequently changed to 1e-8. The earlier official Standard variable-step reference remains tolerance 1e-7. Inspect actual run diagnostics; solver algorithms are not identical.'}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check', action='store_true', help='Check current JSON parameters and exact connection ports against AME; generate XML in memory, without file writes.')
arguments = parser.parse_args()
if arguments.check:
target, target_edges, xml_root = xml_model()
result = check_project_contract(json.loads(PROJECT.read_text(encoding='utf-8')), xml_root)
source, source_edges, _ = audit_source()
mapping = find_mapping(source, source_edges, target, target_edges, ports=True)
if mapping is None or len(source) != len(target) or len(source_edges) != len(target_edges):
raise ValueError('Current JSON differs from AME in components, parameters or connection ports.')
for source_id, target_id in mapping.items():
if source[source_id]['parameters'] != target[target_id]['parameters']:
raise ValueError(f'{target_id}: parameters do not exactly match AME.')
result['ameParametersAndConnectionPortsMatch'] = True
result['executionXmlSource'] = str(PROJECT.relative_to(ROOT))
print(json.dumps(result, indent=2))
return
OUT.mkdir(parents=True, exist_ok=True)
source, source_edges, audit = audit_source()
XML.write_bytes(project_xml())
(OUT / 'input-before-audit.xml').write_bytes(XML.read_bytes())
target, target_edges, xml_root = xml_model()
mapping = find_mapping(source, source_edges, target, target_edges)
audit['correctedXml'] = {'path': str(XML.relative_to(ROOT)), 'sha256': sha(XML),
'parameterAndComponentTopologyIsomorphic': mapping is not None}
ref = json.loads(REFERENCE.read_text(encoding='utf-8'))
audit['reference'] = {'path': str(REFERENCE.relative_to(ROOT)), 'sha256': sha(REFERENCE),
'source': ref['source'], 'timeCount': len(ref['times']), 'seriesCount': len(ref['series']),
'ameHashMatches': ref['originalAmeSha256'] == sha(AME),
'xmlHashMatches': ref['xmlSha256'] == sha(XML),
'newAmesimRunPerformed': False}
(OUT / 'source-model.json').write_text(json.dumps({'nodes': source, 'edges': source_edges}, indent=2, ensure_ascii=False) + '\n', encoding='utf-8', newline='\n')
if not mapping:
mapping = find_mapping(source, source_edges, target, target_edges, parameters=False)
if mapping:
audit['componentMapping'] = {key: {'alias': source[key]['alias'], 'projectId': value} for key, value in mapping.items()}
transformed = {tuple(sorted(((mapping[a], ap), (mapping[b], bp)))) for ((a, ap), (b, bp)) in source_edges}
target_set = set(target_edges)
audit['correctedXml']['exactPortDifferencesForTopologyMapping'] = {
'ameOnly': sorted(transformed - target_set), 'xmlOnly': sorted(target_set - transformed)}
if mapping:
audit['generatedProject'] = generate_project(source, source_edges, target, target_edges, xml_root, mapping)
audit['correctedXml']['scope'] = 'Input XML snapshot before this generation; generatedProject describes the delivered corrected files.'
audit_text = json.dumps(audit, indent=2, ensure_ascii=False) + '\n'
(OUT / 'model-audit.json').write_text(audit_text, encoding='utf-8', newline='\n')
(ROOT / 'tests/model_audit/test-mql-4-audit.json').write_text(audit_text, encoding='utf-8', newline='\n')
print(json.dumps({key: value for key, value in audit.items() if key not in {'componentMapping', 'members', 'globals'}}, indent=2))
if __name__ == '__main__':
main()