修复机械事件力曲线并对齐气动孔口端口显示

This commit is contained in:
huojiarong committed 2026-08-20 05:59:04 +00:00
1 parent e18399c022
commit 408b4ecb22
11 files changed
+697 -43

No files matched your search

@@ -574,8 +574,9 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
category_id="flow", category_id="flow",
symbol="amesim_pnvo001_fixed", symbol="amesim_pnvo001_fixed",
ports=( ports=(
PortDisplaySpec("port_2", "left", order=10), # AMESim default geometry places port 2 right and port 3 left.
PortDisplaySpec("port_3", "right", order=20), PortDisplaySpec("port_2", "right", order=10),
PortDisplaySpec("port_3", "left", order=20),
), ),
order=30, order=30,
parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,), parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,),
@@ -990,8 +991,9 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
symbol="amesim_pnvo001", symbol="amesim_pnvo001",
ports=( ports=(
PortDisplaySpec("res", "left", order=5), PortDisplaySpec("res", "left", order=5),
PortDisplaySpec("port_2", "left", order=10), # AMESim default geometry places port 2 right and port 3 left.
PortDisplaySpec("port_3", "right", order=20), PortDisplaySpec("port_2", "right", order=10),
PortDisplaySpec("port_3", "left", order=20),
), ),
order=35, order=35,
parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,), parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,),
@@ -8,3 +8,9 @@
- 纳管慢区归因、真实 worker/API/浏览器复验及未启用候选的历史报告;最终默认方案保持 `legacy` 机械容差和 SciPy Jacobian。本地证据中 production worker `0.2 s / 0.001 s` 相对旧批准基线缩短约 `7.50%`,AMESim 物理门保持通过,改变轨迹或收益不足的接触感知容差与半解析 Jacobian 候选未启用。 - 纳管慢区归因、真实 worker/API/浏览器复验及未启用候选的历史报告;最终默认方案保持 `legacy` 机械容差和 SciPy Jacobian。本地证据中 production worker `0.2 s / 0.001 s` 相对旧批准基线缩短约 `7.50%`,AMESim 物理门保持通过,改变轨迹或收益不足的接触感知容差与半解析 Jacobian 候选未启用。
- 合并后的完整后端 900 项测试通过,其中 3 项按条件跳过;前端两套 TypeScript 检查、Vite 生产构建及 12 项活动看门狗/超时回归通过,差异格式检查无异常。 - 合并后的完整后端 900 项测试通过,其中 3 项按条件跳过;前端两套 TypeScript 检查、Vite 生产构建及 12 项活动看门狗/超时回归通过,差异格式检查无异常。
- 仍存在的问题:跨 `maxStep` 的自动分层比较器尚未完成;当前权威 `5 s` 聚合报告和 `10 s / 0.001 s` 基线尚未生成;真正无活动调用的硬终止、客户端断连恢复、并发资源门控及 SciPy 内部有限差分 Jacobian 的实时分类仍待实现。 - 仍存在的问题:跨 `maxStep` 的自动分层比较器尚未完成;当前权威 `5 s` 聚合报告和 `10 s / 0.001 s` 基线尚未生成;真正无活动调用的硬终止、客户端断连恢复、并发资源门控及 SciPy 内部有限差分 Jacobian 的实时分类仍待实现。
## 13:55
- 对齐固定开度与信号控制 PNVO001 的 AMESim 默认端口显示:`port_2` 位于右侧、`port_3` 位于左侧,同时保留目录中的端口编号顺序;旧工程仅在检测到历史端口方位快照时幂等迁移一次镜像状态,不修改连接端口名、压力流量方程或结果键。
- 修复机械状态事件对结果曲线的展示干扰:结果页仅隐藏事件时刻相对两侧连续样本异常放大的孤立力尖峰,并连续绘制其两侧正常样本;规则同时覆盖机械端口力和 LSTP00A、LMECHN1 等机械组件的派生力,不处理外部力信号、持续接触力及非力状态。原始 `series`、`.simresult` 与 CSV 导出保持不变。
- PNVO 目录/组件定向后端测试 20 项、端口符号/旧工程迁移/事件力纯函数与真实浏览器回归 4 项均通过;前端 TypeScript 检查与 Vite 生产构建通过,差异格式检查无异常。
+7 -1
View File
@@ -142,6 +142,7 @@ import {
isParameterExpressionValue, isParameterExpressionValue,
isScientificNotationValue, isScientificNotationValue,
} from "./parameterExpression"; } from "./parameterExpression";
import { migratedPnvo001Mirrored } from "./pnvoPortDisplayMigration";
import { import {
WorkspaceViewTabs, WorkspaceViewTabs,
type WorkspaceView, type WorkspaceView,
@@ -9473,7 +9474,12 @@ function normalizeLoadedNode(
parameterUnits, parameterUnits,
parameterScientificNotation, parameterScientificNotation,
rotation: normalizeNodeRotation(node.data.rotation), rotation: normalizeNodeRotation(node.data.rotation),
mirrored: Boolean(node.data.mirrored), mirrored: migratedPnvo001Mirrored(
definition?.modelType ?? node.data.modelType,
node.data.ports,
definition?.ports,
Boolean(node.data.mirrored),
),
}, },
}; };
} }
+71 -30
View File
@@ -50,6 +50,11 @@ import {
orthogonalEdgePoints, orthogonalEdgePoints,
type EdgeRouteData, type EdgeRouteData,
} from "./edgeRouting"; } from "./edgeRouting";
import {
continuousMechanicalForceChartValues,
resultStateTransitionSampleIndices,
segmentedChartPath,
} from "./resultEventSeries";
type ResultPortDefinition = { type ResultPortDefinition = {
name: string; name: string;
@@ -136,6 +141,11 @@ type ResultPayload = {
final: Record<string, number>; final: Record<string, number>;
series: Record<string, number[]>; series: Record<string, number[]>;
diagnostics: { diagnostics: {
integration?: {
segments?: Array<{
stateTransitionTimes?: number[];
}>;
};
pressureFlow: { pressureFlow: {
solveCount: number; solveCount: number;
maxScaledResidual: number; maxScaledResidual: number;
@@ -383,6 +393,14 @@ export function SimulationResultsView({
}: SimulationResultsViewProps) { }: SimulationResultsViewProps) {
const reactFlow = useReactFlow(); const reactFlow = useReactFlow();
const resultStatus = simulationResultStatus(snapshot.result); const resultStatus = simulationResultStatus(snapshot.result);
const stateTransitionSampleIndices = useMemo(
() =>
resultStateTransitionSampleIndices(
snapshot.result.series.time ?? [],
snapshot.result.diagnostics,
),
[snapshot.result.diagnostics, snapshot.result.series.time],
);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>( const [selectedNodeId, setSelectedNodeId] = useState<string | null>(
snapshot.project.nodes[0]?.id ?? null, snapshot.project.nodes[0]?.id ?? null,
); );
@@ -1613,9 +1631,16 @@ export function SimulationResultsView({
variable, variable,
chartWindow.unit, chartWindow.unit,
); );
const displayValues = ( const chartTime = snapshot.result.series.time ?? [];
snapshot.result.series[chartWindow.variableKey] ?? [] const primaryChartValues = continuousMechanicalForceChartValues(
).map(selectedUnit.fromBase); variable,
component,
snapshot.result.series[chartWindow.variableKey] ?? [],
stateTransitionSampleIndices,
);
const displayValues = primaryChartValues.values.map(
selectedUnit.fromBase,
);
const combinedVariables = windowVariables.filter( const combinedVariables = windowVariables.filter(
(item) => isMixed || item.unit === variable.unit, (item) => isMixed || item.unit === variable.unit,
); );
@@ -1632,6 +1657,13 @@ export function SimulationResultsView({
chartWindow.variableUnits[item.key] ?? item.unit, chartWindow.variableUnits[item.key] ?? item.unit,
) )
: selectedUnit; : selectedUnit;
const itemChartValues =
continuousMechanicalForceChartValues(
item,
itemComponent,
snapshot.result.series[item.key] ?? [],
stateTransitionSampleIndices,
);
return { return {
key: item.key, key: item.key,
label: resultVariableTitle( label: resultVariableTitle(
@@ -1640,12 +1672,18 @@ export function SimulationResultsView({
), ),
color: colorForVariable(item.key), color: colorForVariable(item.key),
unit: itemUnit.label, unit: itemUnit.label,
values: (snapshot.result.series[item.key] ?? []).map( values: itemChartValues.values.map(itemUnit.fromBase),
itemUnit.fromBase, separatedEventSampleCount:
), itemChartValues.separatedEventSampleCount,
}; };
}); });
const chartTime = snapshot.result.series.time ?? []; const separatedEventSampleCount = isCombined
? multiSeries.reduce(
(count, item) =>
count + item.separatedEventSampleCount,
0,
)
: primaryChartValues.separatedEventSampleCount;
const visibilitySeries = isCombined const visibilitySeries = isCombined
? multiSeries ? multiSeries
: [ : [
@@ -1671,6 +1709,7 @@ export function SimulationResultsView({
}`} }`}
data-chart-kind={chartWindow.kind} data-chart-kind={chartWindow.kind}
data-pending="false" data-pending="false"
data-separated-event-samples={separatedEventSampleCount}
key={chartWindow.id} key={chartWindow.id}
onDragLeave={(event) => { onDragLeave={(event) => {
if ( if (
@@ -1727,6 +1766,9 @@ export function SimulationResultsView({
{isCombined {isCombined
? `${visibleCombinedVariables.length}/${combinedVariables.length} 条显示 · ${isMixed ? "上下同步图" : variable.unit || "无量纲"}` ? `${visibleCombinedVariables.length}/${combinedVariables.length} 条显示 · ${isMixed ? "上下同步图" : variable.unit || "无量纲"}`
: componentName} : componentName}
{separatedEventSampleCount > 0
? ` · 已隐藏 ${separatedEventSampleCount} 个孤立事件力尖峰`
: ""}
</span> </span>
</div> </div>
<div className="result-chart-window-actions"> <div className="result-chart-window-actions">
@@ -2026,7 +2068,7 @@ export function SimulationResultsView({
} }
onZoomPrevious={() => undoChartWindowZoom(chartWindow.id)} onZoomPrevious={() => undoChartWindowZoom(chartWindow.id)}
series={multiSeries} series={multiSeries}
time={snapshot.result.series.time ?? []} time={chartTime}
viewport={chartWindow.viewport} viewport={chartWindow.viewport}
zoomEnabled={chartWindow.zoomEnabled} zoomEnabled={chartWindow.zoomEnabled}
/> />
@@ -2038,7 +2080,7 @@ export function SimulationResultsView({
} }
onZoomPrevious={() => undoChartWindowZoom(chartWindow.id)} onZoomPrevious={() => undoChartWindowZoom(chartWindow.id)}
series={multiSeries} series={multiSeries}
time={snapshot.result.series.time ?? []} time={chartTime}
title="多曲线对比" title="多曲线对比"
unit={selectedUnit.label} unit={selectedUnit.label}
viewport={chartWindow.viewport} viewport={chartWindow.viewport}
@@ -2052,7 +2094,7 @@ export function SimulationResultsView({
updateChartWindowViewport(chartWindow.id, viewport) updateChartWindowViewport(chartWindow.id, viewport)
} }
onZoomPrevious={() => undoChartWindowZoom(chartWindow.id)} onZoomPrevious={() => undoChartWindowZoom(chartWindow.id)}
time={snapshot.result.series.time ?? []} time={chartTime}
title={chartTitle} title={chartTitle}
unit={selectedUnit.label} unit={selectedUnit.label}
values={displayValues} values={displayValues}
@@ -2268,6 +2310,7 @@ type MultiCurveSeries = {
color: string; color: string;
unit: string; unit: string;
values: number[]; values: number[];
separatedEventSampleCount: number;
}; };
type ChartVisibilitySeries = { type ChartVisibilitySeries = {
@@ -3598,12 +3641,12 @@ function MultiCurvePlot({
/> />
<g clipPath={`url(#${clipPathId})`} data-chart-clipped-series="true"> <g clipPath={`url(#${clipPathId})`} data-chart-clipped-series="true">
{plottedSeries.map((curve) => { {plottedSeries.map((curve) => {
const path = curve.samples const path = segmentedChartPath(
.map( time,
(sample, index) => curve.values,
`${index === 0 ? "M" : "L"} ${xPosition(sample.x).toFixed(2)} ${yPosition(sample.y).toFixed(2)}`, xPosition,
) yPosition,
.join(" "); );
return ( return (
<path <path
d={path} d={path}
@@ -4157,12 +4200,12 @@ function StackedCurvePlot({
</defs> </defs>
{plotBands.map((band) => { {plotBands.map((band) => {
const isLast = band.index === plotBands.length - 1; const isLast = band.index === plotBands.length - 1;
const path = band.curve.samples const path = segmentedChartPath(
.map( time,
(sample, sampleIndex) => band.curve.values,
`${sampleIndex === 0 ? "M" : "L"} ${xPosition(sample.x).toFixed(2)} ${band.yPosition(sample.y).toFixed(2)}`, xPosition,
) band.yPosition,
.join(" "); );
return ( return (
<g className="result-chart-stack-band" key={band.curve.key}> <g className="result-chart-stack-band" key={band.curve.key}>
{band.index > 0 ? ( {band.index > 0 ? (
@@ -4614,14 +4657,12 @@ function CurvePlot({
margin.top + (1 - (value - yMin) / yRange) * plotHeight; margin.top + (1 - (value - yMin) / yRange) * plotHeight;
const xTicks = chartTicks(xMin, xMax, 5); const xTicks = chartTicks(xMin, xMax, 5);
const yTicks = chartTicks(yMin, yMax, 5); const yTicks = chartTicks(yMin, yMax, 5);
const path = samples const path = segmentedChartPath(
.map( time,
(sample, index) => values,
`${index === 0 ? "M" : "L"} ${xPosition(sample.x).toFixed(2)} ${yPosition( xPosition,
sample.y, yPosition,
).toFixed(2)}`, );
)
.join(" ");
const requestedCursorIndex = const requestedCursorIndex =
cursorIndex === null cursorIndex === null
? Math.floor(samples.length / 2) ? Math.floor(samples.length / 2)
+39
View File
@@ -0,0 +1,39 @@
type PortSideSnapshot = {
name: string;
side: string;
};
const PNVO001_MODEL_TYPES = new Set([
"amesim_pnvo001_fixed",
"amesim_pnvo001",
]);
function portUsesSide(
ports: readonly PortSideSnapshot[],
portName: string,
side: string,
) {
return ports.some((port) => port.name === portName && port.side === side);
}
export function migratedPnvo001Mirrored(
modelType: string,
savedPorts: readonly PortSideSnapshot[],
currentPorts: readonly PortSideSnapshot[] | undefined,
mirrored: boolean,
) {
if (!PNVO001_MODEL_TYPES.has(modelType) || !currentPorts) {
return mirrored;
}
const savedUsesLegacySides =
portUsesSide(savedPorts, "port_2", "left") &&
portUsesSide(savedPorts, "port_3", "right");
const catalogUsesAmesimSides =
portUsesSide(currentPorts, "port_2", "right") &&
portUsesSide(currentPorts, "port_3", "left");
return savedUsesLegacySides && catalogUsesAmesimSides
? !mirrored
: mirrored;
}
+190
View File
@@ -0,0 +1,190 @@
export type StateTransitionDiagnostics = {
integration?: {
segments?: Array<{
stateTransitionTimes?: number[];
}>;
};
};
type EventSeriesVariable = {
scope: "component" | "port";
portName: string | null;
quantity: string;
category: string;
};
type EventSeriesComponent = {
data: {
ports: Array<{
name: string;
domain: string;
}>;
};
};
const ISOLATED_EVENT_FORCE_RATIO = 100;
export function resultStateTransitionSampleIndices(
time: number[],
diagnostics: StateTransitionDiagnostics,
) {
const indices = new Set<number>();
for (const segment of diagnostics.integration?.segments ?? []) {
for (const eventTime of segment.stateTransitionTimes ?? []) {
if (!Number.isFinite(eventTime)) {
continue;
}
let index = time.indexOf(eventTime);
if (index < 0) {
const tolerance = 1e-12 * Math.max(Math.abs(eventTime), 1);
index = time.findIndex(
(sampleTime) => Math.abs(sampleTime - eventTime) <= tolerance,
);
}
if (index >= 0) {
indices.add(index);
}
}
}
return indices;
}
export function continuousMechanicalForceChartValues(
variable: EventSeriesVariable,
component: EventSeriesComponent | undefined,
values: number[],
stateTransitionSampleIndices: ReadonlySet<number>,
) {
const componentHasMechanicalPort = Boolean(
component?.data.ports.some((port) => port.domain === "mechanical"),
);
const forceBelongsToMechanicalDomain =
variable.scope === "component"
? variable.category === "derived" && componentHasMechanicalPort
: variable.portName !== null &&
Boolean(
component?.data.ports.some(
(port) =>
port.name === variable.portName &&
port.domain === "mechanical",
),
);
if (variable.quantity !== "force" || !forceBelongsToMechanicalDomain) {
return { values, separatedEventSampleCount: 0 };
}
const isolatedEventIndices: number[] = [];
for (const index of stateTransitionSampleIndices) {
if (
index < 0 ||
index >= values.length ||
!isolatedEventForceSample(
values,
index,
stateTransitionSampleIndices,
)
) {
continue;
}
isolatedEventIndices.push(index);
}
if (isolatedEventIndices.length === 0) {
return { values, separatedEventSampleCount: 0 };
}
const displayValues = values.slice();
for (const index of isolatedEventIndices) {
displayValues[index] = Number.NaN;
}
return {
values: displayValues,
separatedEventSampleCount: isolatedEventIndices.length,
};
}
function isolatedEventForceSample(
values: number[],
index: number,
stateTransitionSampleIndices: ReadonlySet<number>,
) {
const value = Number(values[index]);
if (!Number.isFinite(value)) {
return false;
}
const previous = finiteNonEventNeighbor(
values,
index - 1,
-1,
stateTransitionSampleIndices,
);
const next = finiteNonEventNeighbor(
values,
index + 1,
1,
stateTransitionSampleIndices,
);
if (previous === null || next === null) {
return false;
}
const localScale = Math.max(
Math.abs(previous),
Math.abs(next),
Math.abs(next - previous),
1e-9,
);
const localEstimate = (previous + next) / 2;
return (
Math.abs(value) > ISOLATED_EVENT_FORCE_RATIO * localScale &&
Math.abs(value - localEstimate) >
ISOLATED_EVENT_FORCE_RATIO * localScale
);
}
function finiteNonEventNeighbor(
values: number[],
startIndex: number,
direction: -1 | 1,
stateTransitionSampleIndices: ReadonlySet<number>,
) {
for (
let index = startIndex;
index >= 0 && index < values.length;
index += direction
) {
if (stateTransitionSampleIndices.has(index)) {
continue;
}
const value = Number(values[index]);
if (Number.isFinite(value)) {
return value;
}
}
return null;
}
export function segmentedChartPath(
time: number[],
values: number[],
xPosition: (value: number) => number,
yPosition: (value: number) => number,
) {
const commands: string[] = [];
let continuesSegment = false;
for (let index = 0; index < time.length; index += 1) {
const x = Number(time[index]);
const y = Number(values[index]);
if (!Number.isFinite(x)) {
continuesSegment = false;
continue;
}
if (!Number.isFinite(y)) {
continue;
}
commands.push(
`${continuesSegment ? "L" : "M"} ${xPosition(x).toFixed(2)} ${yPosition(y).toFixed(2)}`,
);
continuesSegment = true;
}
return commands.join(" ");
}
+8 -8
View File
@@ -107,15 +107,15 @@ function portsForSymbol(modelType: string) {
} }
if (modelType === "amesim_pnvo001_fixed") { if (modelType === "amesim_pnvo001_fixed") {
return [ return [
physicalPort("port_2", "left", 10), physicalPort("port_2", "right", 10),
physicalPort("port_3", "right", 20), physicalPort("port_3", "left", 20),
]; ];
} }
if (modelType === "amesim_pnvo001") { if (modelType === "amesim_pnvo001") {
return [ return [
signalPort("res", "left", "input", 5), signalPort("res", "left", "input", 5),
physicalPort("port_2", "left", 10), physicalPort("port_2", "right", 10),
physicalPort("port_3", "right", 20), physicalPort("port_3", "left", 20),
]; ];
} }
if ( if (
@@ -784,8 +784,8 @@ test("PNOR001 与两种 PNVO001 使用统一孔板风格和 small 画布", async
}, },
{ {
anchors: [ anchors: [
{ portName: "port_2", x: 12.8, y: 24 }, { portName: "port_3", x: 12.8, y: 24 },
{ portName: "port_3", x: 51.2, y: 24 }, { portName: "port_2", x: 51.2, y: 24 },
], ],
control: "fixed", control: "fixed",
modelType: "amesim_pnvo001_fixed", modelType: "amesim_pnvo001_fixed",
@@ -794,8 +794,8 @@ test("PNOR001 与两种 PNVO001 使用统一孔板风格和 small 画布", async
{ {
anchors: [ anchors: [
{ portName: "res", x: 27.35, y: 43.2 }, { portName: "res", x: 27.35, y: 43.2 },
{ portName: "port_2", x: 12.8, y: 24 }, { portName: "port_3", x: 12.8, y: 24 },
{ portName: "port_3", x: 51.2, y: 24 }, { portName: "port_2", x: 51.2, y: 24 },
], ],
control: "signal", control: "signal",
modelType: "amesim_pnvo001", modelType: "amesim_pnvo001",
@@ -0,0 +1,54 @@
import { expect, test } from "@playwright/test";
import { migratedPnvo001Mirrored } from "../../src/pnvoPortDisplayMigration";
const legacyPorts = [
{ name: "res", side: "left" },
{ name: "port_2", side: "left" },
{ name: "port_3", side: "right" },
];
const amesimPorts = [
{ name: "res", side: "left" },
{ name: "port_2", side: "right" },
{ name: "port_3", side: "left" },
];
test("旧 PNVO001 端口快照只迁移一次镜像方向", () => {
for (const modelType of [
"amesim_pnvo001_fixed",
"amesim_pnvo001",
]) {
const migrated = migratedPnvo001Mirrored(
modelType,
legacyPorts,
amesimPorts,
true,
);
expect(migrated).toBe(false);
expect(
migratedPnvo001Mirrored(
modelType,
amesimPorts,
amesimPorts,
migrated,
),
).toBe(false);
expect(
migratedPnvo001Mirrored(
modelType,
legacyPorts,
amesimPorts,
false,
),
).toBe(true);
}
expect(
migratedPnvo001Mirrored(
"amesim_pnor001",
legacyPorts,
amesimPorts,
true,
),
).toBe(true);
});
@@ -0,0 +1,131 @@
import { expect, test } from "@playwright/test";
import {
continuousMechanicalForceChartValues,
resultStateTransitionSampleIndices,
segmentedChartPath,
} from "../../src/resultEventSeries";
const mechanicalComponent = {
data: {
ports: [{ name: "port_2", domain: "mechanical" }],
},
};
const mechanicalPortForce = {
scope: "port" as const,
portName: "port_2",
quantity: "force",
category: "flow",
};
test("只分离机械事件中的孤立力尖峰,同时保留连续曲线和原始值", () => {
const time = [0, 0.5, 0.75, 1, 1.5];
const eventIndices = resultStateTransitionSampleIndices(time, {
integration: {
segments: [
{ stateTransitionTimes: [0.75] },
{ stateTransitionTimes: [0.7500000000000001] },
],
},
});
expect([...eventIndices]).toEqual([2]);
const rawForce = [1, 2, 1e12, 3, 4];
const displayedForce = continuousMechanicalForceChartValues(
mechanicalPortForce,
mechanicalComponent,
rawForce,
eventIndices,
);
expect(displayedForce.separatedEventSampleCount).toBe(1);
expect(Number.isNaN(displayedForce.values[2])).toBe(true);
expect(Math.max(...displayedForce.values.filter(Number.isFinite))).toBe(4);
expect(rawForce[2]).toBe(1e12);
const path = segmentedChartPath(
time,
displayedForce.values,
(value) => value,
(value) => value,
);
expect(path.match(/\bM /g)).toHaveLength(1);
const smoothForce = [1, 2, 2.5, 3, 4];
const displayedSmoothForce = continuousMechanicalForceChartValues(
mechanicalPortForce,
mechanicalComponent,
smoothForce,
eventIndices,
);
expect(displayedSmoothForce.values).toBe(smoothForce);
expect(displayedSmoothForce.separatedEventSampleCount).toBe(0);
const componentForce = continuousMechanicalForceChartValues(
{
scope: "component",
portName: null,
quantity: "force",
category: "derived",
},
mechanicalComponent,
[1, 2, -1e12, 3, 4],
eventIndices,
);
expect(componentForce.separatedEventSampleCount).toBe(1);
expect(Number.isNaN(componentForce.values[2])).toBe(true);
const signalForce = continuousMechanicalForceChartValues(
{
scope: "component",
portName: null,
quantity: "force",
category: "signal",
},
mechanicalComponent,
[1, 2, -1e12, 3, 4],
eventIndices,
);
expect(signalForce.values[2]).toBe(-1e12);
expect(signalForce.separatedEventSampleCount).toBe(0);
const stateValues = [0, 0.5, 1e9, 1, 1.5];
const displayedState = continuousMechanicalForceChartValues(
{ ...mechanicalPortForce, quantity: "velocity" },
mechanicalComponent,
stateValues,
eventIndices,
);
expect(displayedState.values).toBe(stateValues);
expect(displayedState.values[2]).toBe(1e9);
const pneumaticForce = continuousMechanicalForceChartValues(
mechanicalPortForce,
{
data: {
ports: [{ name: "port_2", domain: "pneumatic" }],
},
},
rawForce,
eventIndices,
);
expect(pneumaticForce.values).toBe(rawForce);
const persistentContactForce = [0, 0, 1e6, 1e6, 1e6];
const displayedPersistentContactForce =
continuousMechanicalForceChartValues(
mechanicalPortForce,
mechanicalComponent,
persistentContactForce,
eventIndices,
);
expect(displayedPersistentContactForce.values).toBe(
persistentContactForce,
);
expect(
displayedPersistentContactForce.separatedEventSampleCount,
).toBe(0);
expect(
resultStateTransitionSampleIndices(time, {}),
).toEqual(new Set<number>());
});
@@ -34,6 +34,88 @@ const chartWindowSnapshot = {
}, },
}; };
const eventForceSnapshot = {
...chartWindowSnapshot,
id: "e2e-mechanical-event-force-chart",
project: {
...chartWindowSnapshot.project,
nodes: chartWindowSnapshot.project.nodes.map((node, nodeIndex) =>
nodeIndex === 0
? {
...node,
data: {
...node.data,
ports: node.data.ports.map((port, portIndex) =>
portIndex === 0
? { ...port, domain: "mechanical" }
: port,
),
},
}
: node,
),
simulation: {
...chartWindowSnapshot.project.simulation,
t_stop: 2,
step: 0.5,
},
},
result: {
...chartWindowSnapshot.result,
simulatedUntil: 2,
requestedStopTime: 2,
variables: [
...chartWindowSnapshot.result.variables,
{
key: "generic_sensor_1.port_a.f",
componentId: "generic_sensor_1",
componentType: "generic_sensor",
scope: "port",
portName: "port_a",
name: "f",
label: "力",
quantity: "force",
unit: "N",
category: "flow",
order: 30,
},
{
key: "generic_sensor_1.force",
componentId: "generic_sensor_1",
componentType: "generic_sensor",
scope: "component",
portName: null,
name: "force",
label: "接触力",
quantity: "force",
unit: "N",
category: "derived",
order: 31,
},
],
final: {
...chartWindowSnapshot.result.final,
"generic_sensor_1.value": 2,
"generic_sensor_1.port_a.f": 5,
"generic_sensor_1.force": 5,
},
series: {
...chartWindowSnapshot.result.series,
time: [0, 0.5, 0.75, 1, 1.5, 2],
"generic_sensor_1.value": [0, 0.5, 1e9, 1, 1.5, 2],
"generic_sensor_1.port_a.f": [1, 2, 1e12, 3, 4, 5],
"generic_sensor_1.force": [1, 2, -1e12, 3, 4, 5],
},
diagnostics: {
...chartWindowSnapshot.result.diagnostics,
integration: {
segments: [{ stateTransitionTimes: [0.75] }],
},
sampleCount: 6,
},
},
};
async function openResults(page: Page) { async function openResults(page: Page) {
await page.getByRole("tab", { name: "结果", exact: true }).click(); await page.getByRole("tab", { name: "结果", exact: true }).click();
await expect(page.locator(".results-chart-workspace")).toBeVisible(); await expect(page.locator(".results-chart-workspace")).toBeVisible();
@@ -129,6 +211,95 @@ test.beforeEach(async ({ page }) => {
}, chartWindowSnapshot); }, chartWindowSnapshot);
}); });
test("机械状态事件力点不参与连续曲线纵轴,但原始数据与非力状态保持不变", async ({
page,
}) => {
await page.addInitScript((snapshot) => {
window.sessionStorage.setItem(
"system-simulation-flow:latest-result",
JSON.stringify(snapshot),
);
}, eventForceSnapshot);
await page.goto("/");
await openResults(page);
await page
.locator(".results-variable-list button")
.filter({ hasText: "port_a · 力" })
.first()
.click();
const forceWindow = page
.locator('.result-chart-window[data-chart-kind="single"]')
.filter({ hasText: "port_a.f" });
await expect(forceWindow).toHaveAttribute(
"data-separated-event-samples",
"1",
);
await expect(forceWindow).toContainText("已隐藏 1 个孤立事件力尖峰");
const forceChart = forceWindow.locator('svg[data-result-chart="true"]');
const forceDomain = await readChartDomain(forceChart);
expect(forceDomain.yMax).toBeLessThan(10);
const forcePath = await forceChart
.locator('path[data-chart-clipped-series="true"]')
.getAttribute("d");
expect(forcePath?.match(/\bM /g)).toHaveLength(1);
const storedEventForce = await page.evaluate(() => {
const raw = sessionStorage.getItem(
"system-simulation-flow:latest-result",
);
return raw
? JSON.parse(raw).result.series["generic_sensor_1.port_a.f"][2]
: null;
});
expect(storedEventForce).toBe(1e12);
await page
.locator(".results-variable-list button")
.filter({ hasText: "接触力" })
.click();
const componentForceWindow = page
.locator('.result-chart-window[data-chart-kind="single"]')
.filter({ hasText: "generic_sensor_1.force" });
await expect(componentForceWindow).toHaveAttribute(
"data-separated-event-samples",
"1",
);
const componentForceDomain = await readChartDomain(
componentForceWindow.locator('svg[data-result-chart="true"]'),
);
expect(componentForceDomain.yMin).toBeGreaterThan(-10);
const storedComponentForce = await page.evaluate(() => {
const raw = sessionStorage.getItem(
"system-simulation-flow:latest-result",
);
return raw
? JSON.parse(raw).result.series["generic_sensor_1.force"][2]
: null;
});
expect(storedComponentForce).toBe(-1e12);
await page
.locator(".results-variable-list button")
.filter({ hasText: "数值" })
.first()
.click();
const stateWindow = page
.locator('.result-chart-window[data-chart-kind="single"]')
.filter({ hasText: "数值" });
await expect(stateWindow).toHaveAttribute(
"data-separated-event-samples",
"0",
);
const stateDomain = await readChartDomain(
stateWindow.locator('svg[data-result-chart="true"]'),
);
expect(stateDomain.yMax).toBeGreaterThan(1e8);
});
test("组合曲线窗口先创建为空窗,并在删除最后一条曲线后恢复待定", async ({ test("组合曲线窗口先创建为空窗,并在删除最后一条曲线后恢复待定", async ({
page, page,
}) => { }) => {
+14
View File
@@ -141,6 +141,13 @@ class ComponentCatalogTests(unittest.TestCase):
self.assertEqual(components["amesim_pnvo001_fixed"]["category"]["id"], "flow") self.assertEqual(components["amesim_pnvo001_fixed"]["category"]["id"], "flow")
self.assertEqual(pnvo_parameters["opening"]["maximum"], 1.0) self.assertEqual(pnvo_parameters["opening"]["maximum"], 1.0)
self.assertEqual([port["name"] for port in components["amesim_pnvo001_fixed"]["ports"]], ["port_2", "port_3"]) self.assertEqual([port["name"] for port in components["amesim_pnvo001_fixed"]["ports"]], ["port_2", "port_3"])
self.assertEqual(
{
port["name"]: port["side"]
for port in components["amesim_pnvo001_fixed"]["ports"]
},
{"port_2": "right", "port_3": "left"},
)
self.assertEqual(components["amesim_step0"]["category"]["id"], "signals") self.assertEqual(components["amesim_step0"]["category"]["id"], "signals")
self.assertEqual([port["name"] for port in components["amesim_step0"]["ports"]], ["out"]) self.assertEqual([port["name"] for port in components["amesim_step0"]["ports"]], ["out"])
@@ -383,6 +390,13 @@ class ComponentCatalogTests(unittest.TestCase):
) )
self.assertEqual(components["amesim_pnvo001"]["category"]["id"], "flow") self.assertEqual(components["amesim_pnvo001"]["category"]["id"], "flow")
self.assertEqual([port["name"] for port in components["amesim_pnvo001"]["ports"]], ["res", "port_2", "port_3"]) self.assertEqual([port["name"] for port in components["amesim_pnvo001"]["ports"]], ["res", "port_2", "port_3"])
self.assertEqual(
{
port["name"]: port["side"]
for port in components["amesim_pnvo001"]["ports"]
},
{"res": "left", "port_2": "right", "port_3": "left"},
)
pnl_parameters = { pnl_parameters = {
parameter["name"]: parameter parameter["name"]: parameter