Files
SystemSimulationApp/tools/audit_test_mql8_model.py
T

348 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.
Time settings are reported separately; --require-same-time-settings also
makes their equality a --check gate. Parameter/port mismatches always fail.
"""
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/.')
parser.add_argument('--require-same-time-settings', action='store_true',
help='With --check, also require identical start/stop/sample/max-step settings; otherwise report these separately from model semantics.')
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.'
if arguments.require_same_time_settings:
assert original['simulation'] == corrected['simulation'], 'Current JSON time settings differ from AME.'
if __name__ == '__main__':
main()