旧版前端工程文件导入时版本对比查验、审阅与仿真时部分阻挡功能实现;前端参数输入格式统一规范

This commit is contained in:
lujingze committed 2026-09-12 14:15:40 +00:00
1 parent 22579e51c9
commit 44b6ea74ab
32 files changed
+2087 -322

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
"""Measure JSON validation, input normalization and strict XML construction separately.
Run from the repository root with its Python environment; artifacts are ignored.
"""
import json
from pathlib import Path
from statistics import median
from time import perf_counter_ns
from app.main import ReactFlowProjectPayload, build_reactflow_system_xml
from app.project_parameters import prepare_project
def measure(fn, repeat=200):
for _ in range(10):
fn()
samples = []
for _ in range(repeat):
start = perf_counter_ns()
fn()
samples.append((perf_counter_ns() - start) / 1e6)
samples.sort()
return {"medianMs": median(samples), "p95Ms": samples[int(.95 * len(samples))], "samples": repeat}
def main():
source = Path("tests/data/test-mql-8-corrected.json")
raw = json.loads(source.read_text(encoding="utf-8"))
current = ReactFlowProjectPayload.model_validate(raw)
old = current.model_copy(deep=True)
for node in old.nodes:
node.data.modelVersion = "0.0.1"
normalized, _ = prepare_project(current)
metrics = {
"source": str(source), "nodes": len(current.nodes),
"parameters": sum(len(n.data.parameters) for n in current.nodes),
"pydanticInput": measure(lambda: ReactFlowProjectPayload.model_validate(raw)),
"prepareCurrent": measure(lambda: prepare_project(current)),
"prepareAllOld": measure(lambda: prepare_project(old)),
"strictXmlAfterPreparation": measure(lambda: build_reactflow_system_xml(normalized)),
"notes": "prepare includes a defensive copy, parameter/expression/unit conversion, version comparison and simulation time normalization; not version checking alone.",
}
destination = Path("test/project-contract-20260912/backend-input-timing.json")
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(json.dumps(metrics, indent=2), encoding="utf-8")
print(json.dumps(metrics, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1,125 @@
// Real catalog, project UI, HTTP and native execution in the rehearsal sandbox.
import { chromium } from '../../frontend/node_modules/playwright/index.mjs';
import fs from 'node:fs/promises';
import path from 'node:path';
import assert from 'node:assert/strict';
const [input, output, url = 'http://127.0.0.1:8036'] = process.argv.slice(2);
if (!input || !output) throw new Error('Usage: node browser_component_registration.mjs PROJECT.json OUTPUT_DIR [URL]');
await fs.mkdir(output, { recursive: true });
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 }, acceptDownloads: true });
page.setDefaultTimeout(15000);
const errors = [];
page.on('pageerror', e => errors.push(String(e)));
async function download(label, file) {
const promise = page.waitForEvent('download');
await page.getByRole('button', { name: label, exact: true }).click();
await (await promise).saveAs(path.join(output, file));
return path.join(output, file);
}
try {
await page.goto(url);
const catalog = await (await page.request.get(`${url}/api/components/catalog`)).json();
assert(catalog.libraries.some(x => x.id === 'registration_demo'));
const palette = page.getByRole('button', { name: '注册演练斜坡信号', exact: true });
await palette.waitFor();
await palette.dragTo(page.locator('.react-flow__pane').first(), { targetPosition: { x: 500, y: 300 } });
await page.locator('.react-flow__node').first().waitFor();
const paletteProject = JSON.parse(await fs.readFile(await download('导出工程 JSON', 'palette-created.json'), 'utf8'));
assert.equal(paletteProject.nodes.length, 1);
assert.equal(paletteProject.nodes[0].data.modelType, 'registration_demo_ramp');
assert.deepEqual(paletteProject.nodes[0].data.parameters, { offset: 2, amplitude: 3, duration: 1 });
const validProject = JSON.parse(await fs.readFile(input, 'utf8'));
const invalidProject = structuredClone(validProject);
invalidProject.nodes[0].data.ports[0].positiveFlowDirection = null;
await page.locator('input[type="file"][accept*=".json"]').setInputFiles({
name: 'invalid-catalog-port.json', mimeType: 'application/json',
buffer: Buffer.from(JSON.stringify(invalidProject)),
});
await page.getByText('导入失败:文件不是有效的 ReactFlow 工程 JSON', { exact: true }).waitFor();
assert.equal(await page.locator('.react-flow__node').count(), 1);
await page.locator('input[type="file"][accept*=".json"]').setInputFiles(path.resolve(input));
await page.locator('.react-flow__node[data-id="ramp_1"]').click();
const timeUnits = await page.getByRole('combobox', { name: '时长单位', exact: true }).locator('option').evaluateAll(options => options.map(o => o.value));
assert.deepEqual(timeUnits, ['s']);
const duration = page.getByRole('textbox', { name: '时长', exact: true });
assert.equal(await duration.inputValue(), '1');
await duration.fill('2');
await duration.press('Tab');
const projectPath = await download('导出工程 JSON', 'edited-project.json');
const project = JSON.parse(await fs.readFile(projectPath, 'utf8'));
assert.equal(project.nodes[0].data.parameters.duration, 2);
assert.equal(project.nodes[0].data.parameterUnits.duration, 's');
assert.equal(project.nodes[0].data.ports[0].name, 'out');
assert.equal(project.nodes.length, 4);
assert.equal(project.edges.length, 3);
await page.getByRole('button', { name: '生成系统 XML', exact: true }).click();
const xml = await fs.readFile(await download('下载系统 XML', 'browser-input.xml'), 'utf8');
assert.match(xml, /name="duration" value="2"/);
assert.match(xml, /type="registration_demo_ramp"/);
await page.screenshot({ path: path.join(output, 'model.png'), fullPage: true });
const rows = [];
let lastResult;
for (let index = 0; index < 2; index++) {
const marker = await page.evaluate(() => sessionStorage.getItem('system-simulation-flow:latest-result'));
const responsePromise = page.waitForResponse(r => r.url().endsWith('/api/system-xml/simulate-stream'));
await page.getByRole('button', { name: '运行仿真', exact: true }).click();
const response = await responsePromise;
await response.finished();
assert(response.ok());
await page.waitForFunction(old => {
const value = sessionStorage.getItem('system-simulation-flow:latest-result');
return value && value !== old && JSON.parse(value).storage === 'indexeddb';
}, marker);
await page.getByRole('tab', { name: /^结果/ }).click();
const data = JSON.parse(await fs.readFile(await download('下载结果文件', `run-${index}.simresult`), 'utf8'));
const result = data.snapshot.result;
assert(result.success);
assert.equal(result.simulatedUntil, 1);
assert.equal(result.series.time.length, 11);
assert(result.series['force_1.force']);
result.series.time.forEach((t, i) => assert(Math.abs(result.series['force_1.force'][i] - (2 + 1.5*t)) < 1e-12));
result.series.time.forEach((t, i) => {
assert(Math.abs(result.series['ramp_1.y'][i] - (2 + 1.5*t)) < 1e-12);
assert.equal(result.series['ramp_1.y'][i], result.series['ramp_1.out.signal'][i]);
});
const massErrors = { x: 0, v: 0 };
result.series.time.forEach((t, i) => {
// m=10, F=2+1.5t, x0=v0=0; analytic free-mass motion.
massErrors.x = Math.max(massErrors.x, Math.abs(result.series['mass_1.x'][i] - (.1*t*t + .025*t*t*t)));
massErrors.v = Math.max(massErrors.v, Math.abs(result.series['mass_1.v'][i] - (.2*t + .075*t*t)));
});
assert(massErrors.x < 1e-7 && massErrors.v < 1e-7);
if (index === 1) assert(result.diagnostics.native.cacheHit);
rows.push({ run: index, samples: 11, cacheHit: result.diagnostics.native.cacheHit, analyticPassed: true, massErrors });
lastResult = result;
if (!index) await page.getByRole('tab', { name: '建模', exact: true }).click();
}
await page.getByRole('button', { name: '适应系统图窗口', exact: true }).click();
await page.locator('.results-system-panel .react-flow__node[data-id="ramp_1"]').click();
await page.locator('.results-variable-list button').first().click();
await page.locator('.results-chart-panel .result-chart-window svg').first().waitFor();
const csv = await fs.readFile(await download('下载结果 CSV', 'result.csv'), 'utf8');
const records = csv.replace(/^\uFEFF/, '').trim().split(/\r?\n/).map(s => s.split(','));
assert.equal(records.length, 12);
const signalColumns = records[0].flatMap((key, i) => /ramp_1[.](y|out[.]signal)/.test(key) ? [i] : []);
assert.equal(signalColumns.length, 2);
for (let i = 1; i < records.length; i++) {
const values = records[i].map(Number);
for (const j of signalColumns) assert(Math.abs(values[j] - (2 + 1.5*values[0])) < 1e-12);
}
await page.screenshot({ path: path.join(output, 'result.png'), fullPage: true });
await page.reload();
await page.getByRole('tab', { name: /^结果/ }).click();
const restored = JSON.parse(await fs.readFile(await download('下载结果文件', 'restored.simresult'), 'utf8'));
assert.deepEqual(restored.snapshot.result, lastResult);
assert.deepEqual(errors, []);
await fs.writeFile(path.join(output, 'summary.json'), JSON.stringify({ url, browser: browser.version(),
mocked: false, paletteCreation: true, catalogDrivenParameters: true, timeUnits, siParameterEdit: true,
invalidCatalogSnapshotRejected: true, projectExport: true, xmlExport: true, chartVisible: true, rows, csvAnalyticPassed: true, restoredIdentical: true, errors }, null, 2));
console.log('Browser registration rehearsal passed.');
} catch (error) {
await page.screenshot({ path: path.join(output, 'failure.png'), fullPage: true });
await fs.writeFile(path.join(output, 'failure.txt'), `${error.stack}\n${await page.locator('body').innerText()}`);
throw error;
} finally { await browser.close(); }
+122
View File
@@ -0,0 +1,122 @@
// Acceptance against the real built frontend, HTTP adapter, and native eight-branch solver.
import { chromium } from '../../frontend/node_modules/playwright/index.mjs';
import { stripTypeScriptTypes } from 'node:module';
import fs from 'node:fs/promises';
import path from 'node:path';
import assert from 'node:assert/strict';
const [baseURL='http://127.0.0.1:8036', output='test/project-contract-20260912/browser'] = process.argv.slice(2);
await fs.mkdir(output,{recursive:true});
const original=JSON.parse(await fs.readFile('tests/data/test-mql-8-corrected.json','utf8'));
const catalog=await (await fetch(`${baseURL}/api/components/catalog`)).json();
const definitions=catalog.libraries.flatMap(l=>l.components).map(d=>({...d,parameters:Object.fromEntries(d.parameters.map(p=>[p.name,p]))}));
const index=new Map(definitions.map(d=>[d.type,d]));
const browser=await chromium.launch({headless:true});
const page=await browser.newPage({viewport:{width:1600,height:1000},acceptDownloads:true});
page.setDefaultTimeout(30000);
const errors=[];page.on('pageerror',e=>errors.push(String(e)));
const unitsOnly=process.argv.includes('--units-only');
const summary=unitsOnly ? JSON.parse(await fs.readFile(path.join(output,'partial-summary.json'),'utf8')) :
{rows:[],unitCases:[],browser:browser.version(),nodeCount:original.nodes.length};
async function expand(){const button=page.getByRole('button',{name:'展开仿真控制台',exact:true});if(await button.count())await button.click();}
async function importProject(project,name){
await page.locator('input[type="file"][accept*=".json"]').setInputFiles({name:`${name}.json`,mimeType:'application/json',buffer:Buffer.from(JSON.stringify(project))});
await page.waitForFunction(name=>[...document.querySelectorAll('input')].some(input=>input.value===name),name);
}
async function exportJson(filename){
const waiting=page.waitForEvent('download');
await page.getByRole('button',{name:'导出工程 JSON',exact:true}).click();
await(await waiting).saveAs(path.join(output,filename));
return JSON.parse(await fs.readFile(path.join(output,filename),'utf8'));
}
try{
await page.goto(baseURL);
await page.getByRole('button',{name:'运行仿真',exact:true}).waitFor();
if(!unitsOnly){
// Run the exact source function in Chromium, separately from React rendering and XML validation.
let source=await fs.readFile('frontend/src/projectCompatibility.ts','utf8');
source=source.replace(/^import unitTable[^\n]+/m,`const unitTable=${await fs.readFile('schemas/parameter-units.json','utf8')};`);
const js=stripTypeScriptTypes(source);
summary.versionCheck=await page.evaluate(async({js,definitions,original})=>{
const api=await import(`data:text/javascript;base64,${btoa(unescape(encodeURIComponent(js)))}`);
const stats=(fn)=>{const samples=[];for(let i=0;i<100;i++)fn();for(let i=0;i<200;i++){const start=performance.now();for(let j=0;j<10;j++)fn();samples.push((performance.now()-start)/10);}samples.sort((a,b)=>a-b);return{medianMs:samples[100],p95Ms:samples[190]};};
const start=performance.now();api.componentIndex(definitions);const coldIndexMs=performance.now()-start;
return{coldIndexMs,cases:[157,1000,10000].map(count=>{
const current=Array.from({length:count},(_,i)=>({...original.nodes[i%original.nodes.length],id:`node-${i}`}));
const old=current.map(n=>({...n,data:{...n.data,modelVersion:'0.0.1'}}));
return{count,current:stats(()=>api.componentVersionNotices(current,definitions)),old:stats(()=>api.componentVersionNotices(old,definitions)),oldWithMessage:stats(()=>api.componentVersionWarning(old,definitions,true))};
})};
},{js,definitions,original});
await fs.writeFile(path.join(output,'version-check.json'),JSON.stringify(summary.versionCheck,null,2));
// Full eight-branch run and result persistence; repeat with old model versions, then exported v2.
let baseline=null;
const stale=structuredClone(original);stale.nodes.forEach(n=>n.data.modelVersion='0.0.1');
const projects=[['current',original],['old',stale]];
for(let i=0;i<3;i++){
const [name,project]=projects[i];
await page.getByRole('tab',{name:'建模',exact:true}).click();
const importedAt=performance.now();await importProject(project,name);const importMs=performance.now()-importedAt;
await expand();
const consolePanel=page.getByRole('complementary',{name:'仿真控制台',exact:true});
if(name==='old')assert((await consolePanel.innerText()).includes('工程包含旧版或版本未知的组件'));
const previous=await page.evaluate(()=>sessionStorage.getItem('system-simulation-flow:latest-result'));
let xml;
const requestPromise=page.waitForRequest(r=>r.url().endsWith('/api/system-xml/simulate-stream'));
const responsePromise=page.waitForResponse(r=>r.url().endsWith('/api/system-xml/simulate-stream'),{timeout:180000});
const started=performance.now();await page.getByRole('button',{name:'运行仿真',exact:true}).click();
xml=(await requestPromise).postData();
const response=await responsePromise;await response.finished();assert(response.ok());
const receiveMs=performance.now()-started;
await page.waitForFunction(previous=>{const current=sessionStorage.getItem('system-simulation-flow:latest-result');return current&&current!==previous&&JSON.parse(current).storage==='indexeddb';},previous,{timeout:180000});
const savedMs=performance.now()-started;
await page.getByRole('tab',{name:/^结果/}).click();
const resultDownload=page.waitForEvent('download');await page.getByRole('button',{name:'下载结果文件',exact:true}).click();
const resultFile=path.join(output,`${name}.simresult`);await(await resultDownload).saveAs(resultFile);
const result=JSON.parse(await fs.readFile(resultFile,'utf8')).snapshot.result;
assert(result.success,result.message);assert.equal(result.simulatedUntil,original.simulation.t_stop);
if(!baseline)baseline=result;
else {
let differences=0;
assert.equal(Object.keys(result.series).length,Object.keys(baseline.series).length);
for(const [key,values] of Object.entries(result.series)) {
assert.equal(values.length,baseline.series[key].length,key);
for(let j=0;j<values.length;j++) if(values[j]!==baseline.series[key][j]) differences++;
}
assert.equal(differences,0,`${name}: ${differences} result values differ`);
}
await fs.writeFile(path.join(output,`${name}.xml`),xml);
summary.rows.push({name,importMs,receiveMs,savedMs,sampleCount:result.series.time.length,identicalSeries:true,native:result.diagnostics.native});
await page.getByRole('tab',{name:'建模',exact:true}).click();await expand();
if(name==='old'){
assert((await consolePanel.innerText()).includes('可能出现仿真失败或仿真结果与实际不符'));
const exported=await exportJson('upgraded-eight.json');assert.equal(exported.projectSchemaVersion,2);
exported.nodes.forEach(n=>assert.equal(n.data.modelVersion,index.get(n.data.componentType).modelVersion));
projects.push(['exported-v2',exported]);
}
}
await fs.writeFile(path.join(output,'partial-summary.json'),JSON.stringify(summary,null,2));
}
// Lightweight real browser + HTTP checks isolate p0 numbers, strings, expression, and degC.
const chamber=definitions.find(d=>d.modelType==='amesim_pnch012');
assert(chamber);
for(const value of [2.5,'2.5','=2.5','2+0.5','sqrt(6.25)']){
const project={projectSchemaVersion:2,name:'units',simulation:{t_start:0,t_stop:0.002,step:0.001,max_step:0.001,method:'BDF'},edges:[],nodes:[{id:'chamber_1',type:'simulationComponent',position:{x:0,y:0},data:{label:'chamber_1',rotation:0,mirrored:false,componentType:chamber.type,modelType:chamber.modelType,modelVersion:chamber.modelVersion,ports:chamber.ports,parameters:{p0:value,T0:'=10+10'},parameterUnits:{p0:'bar',T0:'degC'}}}]};
await importProject(project,`units-${summary.unitCases.length}`);
await page.getByRole('button',{name:'生成系统 XML',exact:true}).click();await expand();
const blocks=page.getByLabel('生成的系统 XML');
const browserXml=await blocks.last().innerText();
const response=await fetch(`${baseURL}/api/reactflow/system-xml`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(project)});
assert(response.ok,await response.clone().text());const httpXml=await response.text();
const read=xml=>[...xml.matchAll(/<Parameter name="([^"]+)" value="([^"]+)"/g)].map(m=>[m[1],Number(m[2])]);
const webParams=Object.fromEntries(read(browserXml));const httpParams=Object.fromEntries(read(httpXml));
assert.equal(webParams.p0,250000);assert.equal(webParams.T0,293.15);assert.deepEqual(webParams,httpParams);
const exported=await exportJson(`units-${summary.unitCases.length}.json`);assert.equal(exported.projectSchemaVersion,2);
summary.unitCases.push({value,siPressure:webParams.p0,siTemperature:webParams.T0,exportedPressure:exported.nodes[0].data.parameters.p0});
}
summary.errors=errors;assert.deepEqual(errors,[]);
await page.screenshot({path:path.join(output,'acceptance.png'),fullPage:true});
await fs.writeFile(path.join(output,'summary.json'),JSON.stringify(summary,null,2));
await fs.rm(path.join(output,'failure.txt'),{force:true});
await fs.rm(path.join(output,'failure.png'),{force:true});
console.log(JSON.stringify(summary,null,2));
}catch(error){await fs.writeFile(path.join(output,'failure.txt'),`${error.stack}\n${await page.locator('body').innerText()}`);await page.screenshot({path:path.join(output,'failure.png'),fullPage:true});throw error;}
finally{await browser.close();}
@@ -0,0 +1,271 @@
"""Rehearse registration in an isolated source tree; never register the demo in production.
Run with the repository Python environment and a fresh --output-dir. The final
sandbox can be served with uvicorn for a real browser check. No API is mocked.
"""
from __future__ import annotations
import argparse
from dataclasses import replace
from hashlib import sha256
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[2]
MODEL = '''from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition
from app.simulation.core.ports import PortDefinition
class DemoRamp(AlgebraicComponent):
MODEL_TYPE = 'registration_demo_ramp'
MODEL_VERSION = '1.0.0'
PORTS = (PortDefinition.signal('out', nominal_role='output'),)
PARAMETERS = (
ParameterDefinition('offset', 2.0, label='初始值'),
ParameterDefinition('amplitude', 3.0, label='幅值'),
ParameterDefinition('duration', 1.0, label='时长', quantity='time', unit='s',
minimum=0.0, minimum_exclusive=True),
)
RESULT_VARIABLES = (ResultVariableDefinition('y', '输出', 'dimensionless', '', 'signal', 10),)
DISPLAY = ComponentDisplaySpec(label='注册演练斜坡信号', library_id='registration_demo',
category_id='signals', symbol='amesim_step0',
ports=(PortDisplaySpec('out', 'right', order=10),), order=10)
EQUATIONS = ()
def __init__(self, name, medium, *, offset, amplitude, duration):
super().__init__(name)
self.set_parameter_values(dict(offset=offset, amplitude=amplitude, duration=duration))
self.offset, self.amplitude, self.duration = offset, amplitude, duration
self.out = self.register_declared_port('out')
@classmethod
def create(cls, *, name, medium, parameters):
return cls(name, medium, **parameters)
'''
LIBRARY = '''from app.simulation.core.catalog import ComponentLibrarySpec, ComponentCategorySpec
LIBRARY = ComponentLibrarySpec(id='registration_demo', label='组件注册演练库',
version='1.0.0', source_package='app.simulation.components.registration_demo',
categories=(ComponentCategorySpec(id='signals', label='信号元件', order=10),),
models=('app.simulation.components.registration_demo.signals.ramp:DemoRamp',), order=300)
'''
STAGES = ['unlisted', 'catalog_only', 'native_discovery_only', 'contract_only',
'lowered_without_module_export', 'complete']
def write(root, name, value):
path = root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(value, encoding='utf-8', newline='\n')
def edit(root, name, before, after):
path = root / name
value = path.read_text(encoding='utf-8')
if value.count(before) != 1:
raise RuntimeError(f'Rehearsal source anchor changed: {name}: {before!r}')
write(root, name, value.replace(before, after))
def worker(sandbox, output, stage):
sys.path.insert(0, str(sandbox))
from app.simulation.registry import COMPONENT_MODEL_REGISTRY, build_component_catalog
from app.simulation.native_codegen.compiler import compile_native_program
from app.simulation.native_codegen.build import build_native
from app.simulation.native_codegen.input import project_xml
from app.simulation.native_codegen.runner import execute_native
from app.simulation.backends import simulation_config
from app.main import compile_system_xml_network
from app.system_xml import validate_system_xml_document
record = {'stage': stage, 'registered': 'registration_demo_ramp' in COMPONENT_MODEL_REGISTRY}
if stage == 'unlisted':
assert not record['registered']
return record
catalog = build_component_catalog()
model = next(c for lib in catalog['libraries'] for c in lib['components']
if c['modelType'] == 'registration_demo_ramp')
record['catalog'] = model
project = {'projectSchemaVersion': 1, 'name': 'registration-demo',
'simulation': {'t_start': 0, 't_stop': 1, 'step': .1, 'max_step': .05, 'method': 'BDF'},
'edges': [], 'nodes': [{'id': 'ramp_1', 'type': 'simulationComponent',
'position': {'x': 200, 'y': 200}, 'data': {'label': '注册演练斜坡信号',
'componentType': model['type'], 'modelType': model['modelType'],
'modelVersion': model['modelVersion'], 'symbol': model['symbol'],
# Project snapshots use the browser's normalized port shape, not raw catalog objects.
'ports': [{k: v for k, v in port.items()
if k in ('name', 'kind', 'domain', 'nominalRole', 'side')
or (k == 'positiveFlowDirection' and v == 'intoComponent')}
for port in model['ports']],
'parameters': {p['name']: p['default'] for p in model['parameters']},
'parameterUnits': {'duration': 's'}, 'rotation': 0, 'mirrored': False}}]}
xml = project_xml(project)
report = validate_system_xml_document(xml)
assert report.valid, report.as_dict()
write(output, 'project.json', json.dumps(project, ensure_ascii=False, indent=2))
write(output, 'catalog.json', json.dumps(catalog, ensure_ascii=False, indent=2))
(output / 'input.xml').write_bytes(xml)
# The browser requires all displayed ports to be connected, unlike the
# backend's warning-only standalone signal fixture.
import copy
browser_project = copy.deepcopy(project)
for index, (kind, name) in enumerate((('amesim_forc', 'force_1'),
('amesim_mecmas21', 'mass_1'),
('amesim_f000', 'zero_1')), 1):
item = next(c for lib in catalog['libraries'] for c in lib['components'] if c['modelType'] == kind)
node = copy.deepcopy(project['nodes'][0])
node.update(id=name, position={'x': 200 + index * 220, 'y': 200})
node['data'].update(label=name, componentType=kind, modelType=kind,
modelVersion=item['modelVersion'], symbol=item['symbol'],
ports=[{k: v for k, v in port.items()
if k in ('name', 'kind', 'domain', 'nominalRole', 'side')
or (k == 'positiveFlowDirection' and v == 'intoComponent')}
for port in item['ports']],
parameters={param['name']: param['default'] for param in item['parameters']},
parameterUnits={})
if kind == 'amesim_mecmas21':
node['data']['parameters'].update(stoptype=4, useFriction=1, mass=10, x0=0, v0=0)
browser_project['nodes'].append(node)
for index, (a, pa, b, pb) in enumerate((('ramp_1', 'out', 'force_1', 'res'),
('force_1', 'port_2', 'mass_1', 'port_2'), ('mass_1', 'port_1', 'zero_1', 'port_1'))):
browser_project['edges'].append(dict(id=f'edge_{index}', source=a, sourceHandle=pa,
target=b, targetHandle=pb, data={'isContactEdge': False}))
browser_report = validate_system_xml_document(project_xml(browser_project))
assert browser_report.valid, browser_report.as_dict()
write(output, 'browser-project.json', json.dumps(browser_project, ensure_ascii=False, indent=2))
network = compile_system_xml_network(report.document)
try:
program = compile_native_program(network)
record['generated'] = True
build = build_native(program, cache_dir=output / 'cache')
except (ValueError, RuntimeError) as exc:
record['error'] = str(exc)
if stage == 'complete':
raise
expected = {'catalog_only': 'no native contract',
'native_discovery_only': 'version does not match',
'contract_only': 'output mapping incomplete',
'lowered_without_module_export': 'undefined reference'}[stage]
assert expected.lower() in str(exc).lower(), str(exc)
return record
try:
assert stage == 'complete'
assert not build.cache_hit
assert 'demo_signal' in build.details['selectedModules']
record['firstCompleteBuild'] = build.details # Earlier failed link may warm shared objects.
record['runs'] = []
for method in ('RK45', 'BDF'):
config = replace(simulation_config(report.document.simulation), method=method)
result = execute_native(build, config, .1, run_dir=output / method)
assert result['success'] and result['simulatedUntil'] == 1
errors = [abs(y - (2 + 3*t)) for t, y in zip(result['series']['time'], result['series']['ramp_1.y'])]
assert max(errors) < 1e-12
assert result['series']['ramp_1.y'] == result['series']['ramp_1.out.signal']
record['runs'].append({'method': method, 'samples': len(errors), 'maxAnalyticError': max(errors)})
cached = build_native(program, cache_dir=output / 'cache')
try:
assert cached.cache_hit and cached.details['objectCompilations'] == 0
record['warmBuild'] = cached.details
finally:
cached.close()
project['nodes'][0]['data']['parameters']['duration'] = 2
changed_report = validate_system_xml_document(project_xml(project))
changed = compile_native_program(compile_system_xml_network(changed_report.document))
new_build = build_native(changed, cache_dir=output / 'cache')
try:
assert new_build.details['objectCompilations'] == 1
assert new_build.details['objectCacheHits'] > 0
result = execute_native(new_build, config, .1, run_dir=output / 'parameter-change')
assert result['success'] and abs(result['final']['ramp_1.y'] - 3.5) < 1e-12
record['parameterBuild'] = new_build.details
finally:
new_build.close()
invalid = {'wrong_version': xml.replace(b'modelVersion="1.0.0"', b'modelVersion="9.0.0"')}
# Build semantic mutations from the parsed tree; serializer whitespace
# and float spellings are not an input contract.
import xml.etree.ElementTree as ET
tree = ET.fromstring(xml)
comp = tree.find('./Components/Component')
duration = comp.find("Parameter[@name='duration']")
duration.set('value', '0')
invalid['zero_duration'] = ET.tostring(tree)
comp.remove(duration)
invalid['missing_parameter'] = ET.tostring(tree)
record['rejections'] = {}
for name, payload in invalid.items():
invalid_report = validate_system_xml_document(payload)
assert not invalid_report.valid, name
record['rejections'][name] = invalid_report.as_dict()
record['sourceSha256'] = sha256(program.source.encode()).hexdigest()
return record
finally:
build.close()
def prepare(output):
output.mkdir(parents=True, exist_ok=False)
sandbox = output / 'sandbox'
for name in ('app', 'native', 'schemas'):
shutil.copytree(ROOT / name, sandbox / name,
ignore=shutil.ignore_patterns('__pycache__', '*.pyc', 'data'))
if (ROOT / 'frontend/dist').exists():
shutil.copytree(ROOT / 'frontend/dist', sandbox / 'frontend/dist')
write(sandbox, 'app/simulation/components/registration_demo/__init__.py', '')
write(sandbox, 'app/simulation/components/registration_demo/signals/__init__.py', '')
write(sandbox, 'app/simulation/components/registration_demo/signals/ramp.py', MODEL)
write(sandbox, 'app/simulation/components/registration_demo/library.py', LIBRARY)
rows = []
for stage in STAGES:
if stage == 'catalog_only':
edit(sandbox, 'app/simulation/registry.py', 'ENABLED_COMPONENT_LIBRARIES = (',
'ENABLED_COMPONENT_LIBRARIES = (\n "app.simulation.components.registration_demo.library:LIBRARY",')
elif stage == 'native_discovery_only':
edit(sandbox, 'app/simulation/native_codegen/extended.py', 'for entry in (*a.models, *e.models):',
'from app.simulation.components.registration_demo.library import LIBRARY as demo\n for entry in (*a.models, *e.models, *demo.models):')
elif stage == 'contract_only':
edit(sandbox, 'app/simulation/native_codegen/contracts.py', 'SUPPORTED_VERSIONS = {',
"SUPPORTED_VERSIONS = {\n 'registration_demo_ramp': '1.0.0',")
elif stage == 'lowered_without_module_export':
edit(sandbox, 'app/simulation/native_codegen/extended.py', " elif c.model_type == 'amesim_ud00':",
" elif c.model_type == 'registration_demo_ramp':\n put(c, 'y', f'native_demo_ramp(t,{num(c.offset)},{num(c.amplitude)},{num(c.duration)})')\n elif c.model_type == 'amesim_ud00':")
edit(sandbox, 'native/include/kernels.h', '#include <stddef.h>',
'#include <stddef.h>\ndouble native_demo_ramp(double t, double offset, double amplitude, double duration);')
write(sandbox, 'native/components/modules/demo_signal.c',
'#include "kernels.h"\ndouble native_demo_ramp(double t, double offset, double amplitude, double duration) { return offset + amplitude * t / duration; }\n')
elif stage == 'complete':
edit(sandbox, 'app/simulation/native_codegen/modules.py', 'EXPORTS = {',
"EXPORTS = {\n 'demo_signal': frozenset({'native_demo_ramp'}),")
edit(sandbox, 'native/components/kernels.c', '#define NATIVE_COMPONENT_AMALGAMATION 1',
'#define NATIVE_COMPONENT_AMALGAMATION 1\n#include "../components/modules/demo_signal.c"')
edit(sandbox, 'app/simulation/native_codegen/jacobian.py', '_FUNCTIONS = frozenset({',
"_FUNCTIONS = frozenset({\n 'native_demo_ramp',")
record_path = output / f'{stage}.json'
proc = subprocess.run([sys.executable, '-B', str(Path(__file__).resolve()),
'--worker', stage, '--sandbox', str(sandbox), '--output-dir', str(output)],
capture_output=True, text=True, encoding='utf-8', timeout=180)
write(output, f'{stage}.log', proc.stdout + proc.stderr)
if proc.returncode:
raise RuntimeError(f'{stage} failed; see {output / (stage + ".log")}')
rows.append(json.loads(record_path.read_text(encoding='utf-8')))
summary = {'baselineHead': subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip(),
'platform': sys.platform, 'windowsExecuted': os.name == 'nt', 'stages': rows,
'exampleScope': 'Stateless signal source, third library, new C module, existing symbol and parameter controls. No new physical domain, dynamic ports or nontrivial Jacobian coloring.',
'sandbox': str(sandbox), 'productionModelRegistered': False}
write(output, 'summary.json', json.dumps(summary, ensure_ascii=False, indent=2))
print(json.dumps({'output': str(output), 'stages': len(rows), 'complete': True}))
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--output-dir', type=Path, required=True)
parser.add_argument('--worker', choices=STAGES)
parser.add_argument('--sandbox', type=Path)
args = parser.parse_args()
out = args.output_dir.resolve()
if args.worker:
result = worker(args.sandbox.resolve(), out, args.worker)
write(out, f'{args.worker}.json', json.dumps(result, ensure_ascii=False, indent=2))
else:
prepare(out)