- 按文件哈希区分当前八路工程和历史 Amesim 基准,修复测试与清单中的旧路径。 - 四路审计从 JSON 按需生成 XML,八路新增只核对模式,并显式使用 UTF-8 与 LF。 - 将缺失高刚度夹具的测试替换为当前八路完整 BDF 运行,明确未恢复原 RK45 专项覆盖。 - 更新前端大型工程测试、历史活动回放入口、数据说明、现行规范和相关报告。 验证:全量后台 374 项通过、1 项条件跳过;前端 11 项通过、历史独立服务用例 1 项跳过;四路八路 AME 审计通过。 环境、缓存和运行产物保持忽略;本次未实施工程 JSON 字段精简。
343 lines
18 KiB
Python
343 lines
18 KiB
Python
"""Reproduce the eight-branch AME drawing -> editor model audit.
|
|
|
|
Run from the repository root with .venv/bin/python and --check to preserve
|
|
the current project; omit --check to regenerate it. Audit evidence goes to
|
|
the ignored test/model-audit/mql8 directory. Mapping uses typed graph
|
|
isomorphism and endpoint edit cost, never component display names; names
|
|
are emitted only as evidence for review.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from collections import Counter, defaultdict
|
|
from copy import deepcopy
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import re
|
|
from pathlib import Path
|
|
import sys
|
|
import tarfile
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT))
|
|
from tests.test_test_mql_ame_contract import (
|
|
_blocks, _element_text as element, _expected_parameters,
|
|
_public_model_type, _resolve_globals, _value_fields,
|
|
_SOURCE_FIELD_BY_PARAMETER, _numeric_project_value,
|
|
)
|
|
from app.simulation.registry import get_component_model_spec
|
|
from app.simulation.paths import CURRENT_MQL8_AME_PATH, CURRENT_MQL8_PROJECT_PATH
|
|
|
|
HERE = ROOT / 'test/model-audit/mql8'
|
|
AME = CURRENT_MQL8_AME_PATH
|
|
INPUT = CURRENT_MQL8_PROJECT_PATH
|
|
OUTPUT = CURRENT_MQL8_PROJECT_PATH
|
|
|
|
|
|
def read_drawing():
|
|
with tarfile.open(AME) as archive:
|
|
cir = archive.extractfile('test_mql_.cir').read().decode('latin1')
|
|
sim = archive.extractfile('test_mql_.sim').read().decode('ascii')
|
|
model_info = archive.extractfile('test_mql_.modelinfo').read().decode('ascii')
|
|
globals_ = _resolve_globals(cir)
|
|
components, lines = _blocks(cir, 'COMP'), _blocks(cir, 'LINE')
|
|
entities, edges = {}, []
|
|
|
|
def add(key, body):
|
|
kind = _public_model_type(element(body, 'SUB_NAME'))
|
|
entities[key] = {
|
|
'modelType': kind, 'alias': element(body, 'ALIAS'),
|
|
'parameters': _expected_parameters(body, kind, globals_),
|
|
'fields': {k: vars(v) for k, v in _value_fields(body).items()},
|
|
'geometry': element(body, 'COMP_GEOMETRY'),
|
|
'outputType': element(body, 'OUTPUT_TYPE'),
|
|
'scopeId': element(body, 'CIRCUIT_SCOPE_ID'),
|
|
'drawingPorts': [{'position': element(p, 'PORT_POS'), 'face': element(p, 'PORT_FACE')}
|
|
for p in _blocks(body, 'COMP_PORT')],
|
|
}
|
|
|
|
def port(component, index):
|
|
kind = entities[component]['modelType']
|
|
if index == 0 and kind in ('amesim_forc', 'amesim_pnvo001'):
|
|
return 'res'
|
|
if index == 0 and kind in ('amesim_step0', 'amesim_ud00'):
|
|
return 'out'
|
|
return f'port_{index + 1}'
|
|
|
|
for index, body in enumerate(components):
|
|
add(f'component:{index}', body)
|
|
contacts = set()
|
|
for index, body in enumerate(components):
|
|
for pindex, pbody in enumerate(_blocks(body, 'COMP_PORT')):
|
|
if element(pbody, 'PORT_CONNECT') != '1':
|
|
continue
|
|
for conn in _blocks(pbody, 'CONNECT'):
|
|
other = int(element(conn, 'CONNECT_ENTITY_NUM'))
|
|
op = int(element(conn, 'CONNECT_ENTITY_PORT'))
|
|
contacts.add(((index, pindex), (other, op)))
|
|
for left, right in sorted(contacts):
|
|
assert (right, left) in contacts, 'Non-reciprocal component contact'
|
|
if left > right:
|
|
continue
|
|
a, b = f'component:{left[0]}', f'component:{right[0]}'
|
|
edges.append(((a, port(a, left[1])), (b, port(b, right[1])), 'contact'))
|
|
for index, body in enumerate(lines):
|
|
assert element(body, 'LINE_START_TYPE') == element(body, 'LINE_END_TYPE') == '0'
|
|
a, b = ('component:' + element(body, 'LINE_START_ENTITY'),
|
|
'component:' + element(body, 'LINE_END_ENTITY'))
|
|
left = a, port(a, int(element(body, 'LINE_START_PORT')))
|
|
right = b, port(b, int(element(body, 'LINE_END_PORT')))
|
|
if element(body, 'SUB_NAME') == 'DIRECT':
|
|
edges.append((left, right, f'direct-line:{index}'))
|
|
continue
|
|
key = f'line:{index}'
|
|
add(key, body)
|
|
output_type = element(body, 'OUTPUT_TYPE')
|
|
assert output_type in ('1', '2'), (key, output_type)
|
|
# A reversed two-port line's first physical endpoint is submodel port 2.
|
|
lp, rp = ('port_1', 'port_2') if output_type == '1' else ('port_2', 'port_1')
|
|
edges.extend(((left, (key, lp), key), ((key, rp), right, key)))
|
|
return entities, edges, globals_, sim, model_info
|
|
|
|
|
|
def match_graph(entities, ame_edges, project):
|
|
nodes = {n['id']: n for n in project['nodes']}
|
|
types = {**{k: v['modelType'] for k, v in entities.items()},
|
|
**{k: v['data']['modelType'] for k, v in nodes.items()}}
|
|
adjacency = {k: {} for k in types}
|
|
for (a, ap), (b, bp), _ in ame_edges:
|
|
assert b not in adjacency[a], 'Parallel entity edges need a multigraph matcher'
|
|
adjacency[a][b], adjacency[b][a] = (ap, bp), (bp, ap)
|
|
for edge in project['edges']:
|
|
a, b = edge['source'], edge['target']
|
|
assert b not in adjacency[a], 'Parallel editor edges need a multigraph matcher'
|
|
ap, bp = edge['sourceHandle'], edge['targetHandle']
|
|
adjacency[a][b], adjacency[b][a] = (ap, bp), (bp, ap)
|
|
colors = types.copy()
|
|
for _ in range(len(entities)):
|
|
signatures = {k: (types[k], colors[k], tuple(sorted(colors[n] for n in adjacency[k]))) for k in types}
|
|
vocabulary = {v: i for i, v in enumerate(sorted(set(signatures.values())))}
|
|
updated = {k: vocabulary[v] for k, v in signatures.items()}
|
|
if len(set(updated.values())) == len(set(colors.values())):
|
|
colors = updated
|
|
break
|
|
colors = updated
|
|
groups = defaultdict(lambda: [[], []])
|
|
for key in entities:
|
|
groups[colors[key]][0].append(key)
|
|
for key in nodes:
|
|
groups[colors[key]][1].append(key)
|
|
for ames, editors in groups.values():
|
|
assert len(ames) == len(editors), 'Typed topology differs'
|
|
# All remaining structural symmetries in this exact input preserve
|
|
# parameters, so selection within them does not hide parameter errors.
|
|
assert len({json.dumps(entities[k]['parameters'], sort_keys=True) for k in ames}) == 1
|
|
candidates = {a: tuple(editors) for ames, editors in groups.values() for a in ames}
|
|
best, best_cost, examined = None, math.inf, 0
|
|
mapping, used = {}, set()
|
|
|
|
def choices(a):
|
|
result = []
|
|
for b in candidates[a]:
|
|
if b in used:
|
|
continue
|
|
cost = 0
|
|
for previous, assigned in mapping.items():
|
|
ae, be = adjacency[a].get(previous), adjacency[b].get(assigned)
|
|
if bool(ae) != bool(be):
|
|
break
|
|
if ae:
|
|
cost += sum(x != y for x, y in zip(ae, be))
|
|
else:
|
|
result.append((cost, b))
|
|
return sorted(result)
|
|
|
|
def visit(cost):
|
|
nonlocal best, best_cost, examined
|
|
if cost >= best_cost:
|
|
return
|
|
if len(mapping) == len(entities):
|
|
examined += 1
|
|
best, best_cost = mapping.copy(), cost
|
|
return
|
|
remaining = [(a, choices(a)) for a in entities if a not in mapping]
|
|
if any(not options for _, options in remaining):
|
|
return
|
|
# Each current candidate cost is charged only for edges to assigned
|
|
# nodes, so this lower bound does not double count any edge.
|
|
if cost + sum(options[0][0] for _, options in remaining) >= best_cost:
|
|
return
|
|
a, options = min(remaining, key=lambda item: (len(item[1]),
|
|
-sum(n in mapping for n in adjacency[item[0]]), -len(adjacency[item[0]]), item[0]))
|
|
for extra, b in options:
|
|
mapping[a] = b
|
|
used.add(b)
|
|
visit(cost + extra)
|
|
used.remove(b)
|
|
del mapping[a]
|
|
visit(0)
|
|
assert best is not None, 'No typed topology isomorphism'
|
|
return best, {'minimumEndpointEdits': best_cost, 'improvingSolutions': examined,
|
|
'typedRefinementGroups': len(groups), 'parameterPreservingSymmetries': True}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--check', action='store_true',
|
|
help='Audit current JSON against AME without changing the project; evidence goes to test/model-audit/mql8/.')
|
|
arguments = parser.parse_args()
|
|
HERE.mkdir(parents=True, exist_ok=True)
|
|
sources = {str(p.relative_to(ROOT)): {'sha256': hashlib.sha256(p.read_bytes()).hexdigest(),
|
|
'bytes': p.stat().st_size} for p in (AME, INPUT)}
|
|
original = json.loads(INPUT.read_text(encoding='utf-8'))
|
|
entities, edges, globals_, sim, model_info = read_drawing()
|
|
mapping, matching = match_graph(entities, edges, original)
|
|
corrected = deepcopy(original)
|
|
corrected['name'] = 'test-mql-8-corrected'
|
|
nodes = {n['id']: n for n in corrected['nodes']}
|
|
parameter_audit, parameter_changes = [], []
|
|
for source, target in mapping.items():
|
|
entity, node = entities[source], nodes[target]
|
|
spec = get_component_model_spec(entity['modelType'])
|
|
assert node['data']['modelVersion'] == spec.model_version
|
|
source_fields = _SOURCE_FIELD_BY_PARAMETER.get(entity['modelType'], {})
|
|
assert set(node['data']['parameters']) == set(entity['parameters'])
|
|
for parameter in spec.parameters:
|
|
name = parameter.name
|
|
expected = entity['parameters'][name]
|
|
actual = _numeric_project_value(node['data']['parameters'][name])
|
|
row = {'component': target, 'ameEntity': source, 'ameAlias': entity['alias'],
|
|
'parameter': name, 'unit': parameter.unit, 'old': actual, 'expected': expected,
|
|
'source': entity['fields'].get(source_fields.get(name, name)),
|
|
'matches': math.isclose(actual, expected, rel_tol=2e-14, abs_tol=1e-15)}
|
|
parameter_audit.append(row)
|
|
if not row['matches']:
|
|
parameter_changes.append(row)
|
|
node['data']['parameters'][name] = expected
|
|
desired = {}
|
|
for (a, ap), (b, bp), source in edges:
|
|
ja, jb = mapping[a], mapping[b]
|
|
key = frozenset((ja, jb))
|
|
assert key not in desired
|
|
desired[key] = ({ja: ap, jb: bp}, source)
|
|
connection_audit, connection_changes = [], []
|
|
cleared_contact_flags = []
|
|
for edge in corrected['edges']:
|
|
ends, source = desired.pop(frozenset((edge['source'], edge['target'])))
|
|
before = [(edge['source'], edge['sourceHandle']), (edge['target'], edge['targetHandle'])]
|
|
after = [(edge['source'], ends[edge['source']]), (edge['target'], ends[edge['target']])]
|
|
row = {'edgeId': edge['id'], 'ameSource': source, 'before': before, 'after': after, 'matches': before == after}
|
|
connection_audit.append(row)
|
|
if not row['matches']:
|
|
connection_changes.append(row)
|
|
edge['sourceHandle'], edge['targetHandle'] = after[0][1], after[1][1]
|
|
# A moved endpoint no longer certifies the old geometric contact.
|
|
# Keep its explicit connection visible; physical adjacency is intact.
|
|
if edge.get('data', {}).get('isContactEdge'):
|
|
edge['data']['isContactEdge'] = False
|
|
cleared_contact_flags.append(edge['id'])
|
|
assert not desired
|
|
# The corrected shared P4 symbol uses p1 up, p2 left, p3 down, p4 right.
|
|
# Read every drawing port to ensure the saved geometry needs exactly 180°.
|
|
display_audit, p4_nodes = [], set()
|
|
for source, target in mapping.items():
|
|
entity = entities[source]
|
|
if entity['modelType'] != 'amesim_p4node2':
|
|
continue
|
|
expected = [('10 19', '1'), ('19 10', '3'), ('10 1', '0'), ('1 10', '2')]
|
|
assert entity['geometry'] == '1'
|
|
assert [(p['position'], p['face']) for p in entity['drawingPorts']] == expected
|
|
data = nodes[target]['data']
|
|
display_audit.append({'component': target, 'ameEntity': source, 'ameAlias': entity['alias'],
|
|
'ameGeometry': entity['geometry'], 'drawingPorts': entity['drawingPorts'],
|
|
'previousRotation': data.get('rotation'), 'previousMirrored': data.get('mirrored'),
|
|
'rotation': 180, 'mirrored': False,
|
|
'portSides': {'port_1': 'bottom', 'port_2': 'right', 'port_3': 'top', 'port_4': 'left'}})
|
|
data['rotation'], data['mirrored'] = 180, False
|
|
p4_nodes.add(target)
|
|
cleared_route_points = []
|
|
for edge in corrected['edges']:
|
|
if edge['source'] in p4_nodes or edge['target'] in p4_nodes:
|
|
if 'routePoints' in edge.get('data', {}):
|
|
del edge['data']['routePoints']
|
|
cleared_route_points.append(edge['id'])
|
|
# The first five .sim values are recorded directly; no interpretation of
|
|
# undocumented solver-enumeration slots is needed for this correction.
|
|
sim_values = [float(x) for x in sim.splitlines()[0].split()]
|
|
corrected['simulation'].update(t_start=sim_values[0], t_stop=sim_values[1], step=sim_values[2], max_step=sim_values[3])
|
|
from app.main import ReactFlowProjectPayload, build_reactflow_system_xml, compile_reactflow_network
|
|
from app.simulation.native_codegen.compiler import compile_native_program
|
|
# Browser export resolves arithmetic editor expressions on a separate copy.
|
|
executable = deepcopy(corrected)
|
|
for node in executable['nodes']:
|
|
node['data']['parameters'] = {k: _numeric_project_value(v)
|
|
for k, v in node['data']['parameters'].items()}
|
|
payload = ReactFlowProjectPayload.model_validate(executable)
|
|
network = compile_reactflow_network(payload)
|
|
program = compile_native_program(network)
|
|
if not arguments.check:
|
|
OUTPUT.write_text(json.dumps(corrected, ensure_ascii=False, indent=2) + '\n', encoding='utf-8', newline='\n')
|
|
xml = build_reactflow_system_xml(payload)
|
|
(HERE / 'corrected.xml').write_bytes(xml if isinstance(xml, bytes) else xml.encode())
|
|
# The archive's parameter table and run input have matching row order.
|
|
with tarfile.open(AME) as archive:
|
|
param_lines = archive.extractfile('test_mql_.param').read().decode('latin1').splitlines()
|
|
data_lines = archive.extractfile('test_mql_.data').read().decode('latin1').splitlines()
|
|
assert len(param_lines) == len(data_lines)
|
|
run_parameters = {re.search(r'Data_Path=(\S+)', text).group(1): (i+1, data_lines[i])
|
|
for i,text in enumerate(param_lines) if 'Data_Path=' in text}
|
|
cache_checks, structural_fields = [], []
|
|
reverse_mapping = {v:k for k,v in mapping.items()}
|
|
from tests.test_test_mql_ame_contract import _evaluate_expression
|
|
for row in parameter_audit:
|
|
if row['source'] is None:
|
|
continue
|
|
entity = entities[reverse_mapping[row['component']]]
|
|
field = _SOURCE_FIELD_BY_PARAMETER.get(entity['modelType'], {}).get(row['parameter'], row['parameter'])
|
|
path = field + '@' + row['ameAlias']
|
|
if path not in run_parameters:
|
|
assert entity['modelType'] == 'amesim_lmechn1' and field == 'v1'
|
|
structural_fields.append(path)
|
|
continue
|
|
line, expression = run_parameters[path]
|
|
drawing_value = _evaluate_expression(row['source']['expression'], globals_)
|
|
run_value = _evaluate_expression(expression, globals_)
|
|
assert math.isclose(drawing_value, run_value, rel_tol=2e-14, abs_tol=1e-15), path
|
|
cache_checks.append({'dataPath': path, 'parameterDataLine': line,
|
|
'drawingValue': drawing_value, 'runValue': run_value})
|
|
audit = {
|
|
'sources': sources,
|
|
'checkOnly': arguments.check,
|
|
'output': str(OUTPUT.relative_to(ROOT)), 'globals': globals_, 'matching': matching,
|
|
'counts': {'components': len(entities), 'connections': len(edges), 'parameters': len(parameter_audit),
|
|
'changedParameters': len(parameter_changes), 'changedConnections': len(connection_changes),
|
|
'states': len(program.state_keys), 'outputs': len(program.variables)},
|
|
'simulation': {'old': original['simulation'], 'corrected': corrected['simulation'], 'ameRaw': sim,
|
|
'ameModelInfo': model_info, 'ameRtol': sim_values[4],
|
|
'note': 'BDF retained; AME solver enumeration is not equated with CVODE BDF. API rtol comes from the current backend default; this editor JSON does not carry rtol.'},
|
|
'mapping': [{'ameEntity': a, 'ameAlias': entities[a]['alias'], 'component': b,
|
|
'type': entities[a]['modelType'], 'lineOutputType': entities[a]['outputType']} for a,b in sorted(mapping.items())],
|
|
'parameterChanges': parameter_changes, 'connectionChanges': connection_changes,
|
|
'clearedGeometricContactFlags': cleared_contact_flags,
|
|
'p4DisplayAlignment': display_audit,
|
|
'clearedP4RoutePoints': cleared_route_points,
|
|
'archiveParameterCrossCheck': {'tableRows': len(param_lines), 'checked': len(cache_checks),
|
|
'drawingOnlyStructuralFields': structural_fields, 'checks': cache_checks},
|
|
'parameters': parameter_audit, 'connections': connection_audit,
|
|
'evaluationSchedule': program.evaluation_schedule,
|
|
}
|
|
(HERE / 'audit.json').write_text(json.dumps(audit, ensure_ascii=False, indent=2) + '\n', encoding='utf-8', newline='\n')
|
|
print(json.dumps({'counts': audit['counts'], 'matching': matching,
|
|
'parameters': parameter_changes, 'connections': connection_changes,
|
|
'simulation': audit['simulation']}, ensure_ascii=False, indent=2))
|
|
|
|
if arguments.check:
|
|
assert not parameter_changes, 'Current JSON parameters differ from AME; see audit.json.'
|
|
assert not connection_changes, 'Current JSON connection ports differ from AME; see audit.json.'
|
|
assert original['simulation'] == corrected['simulation'], 'Current JSON time settings differ from AME.'
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|