Skip to content

Commit aeacdca

Browse files
committed
feat(invariant-set-approximation): add invariant set approximation from initial point circle
1 parent 97e118b commit aeacdca

27 files changed

Lines changed: 1084 additions & 24 deletions

frontend/src/Anton.tsx

Lines changed: 153 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,15 @@ import {
7878
import {
7979
CONTINUOUS_BOUNDARY_FLOW_SETTINGS,
8080
DEFAULT_GEOMETRIC_OFFSET_SETTINGS,
81+
DEFAULT_INVARIANT_SET_SETTINGS,
8182
DEFAULT_MANIFOLD_SETTINGS,
8283
DEFAULT_ULAM_SETTINGS,
8384
INVERSE_OFFSET_POSITION_TOLERANCE_RULE,
8485
ULAM_OPERATOR_SETTINGS,
8586
continuousUlamIntegrationTime,
8687
inverseOffsetPositionTolerance
8788
} from './config/numericalSettings';
89+
import { randomHenonInvariantSeed } from './utils/invariantSetSeed';
8890
import {
8991
buildExperimentBundle,
9092
experimentConfigurationToUiState,
@@ -105,6 +107,7 @@ import type {
105107
ExtendedPointTuple,
106108
ExtendedState,
107109
GeometricOffsetState,
110+
InvariantSetState,
108111
Manifold,
109112
ManifoldBranch,
110113
ManifoldState,
@@ -510,6 +513,16 @@ const SetValuedViz = () => {
510513
};
511514
});
512515

516+
const [invariantSetState, setInvariantSetState] = useState<InvariantSetState>({
517+
seedMode: 'manual',
518+
boundaryPointCount: DEFAULT_INVARIANT_SET_SETTINGS.boundaryPointCount,
519+
forwardIterations: DEFAULT_INVARIANT_SET_SETTINGS.forwardIterations,
520+
showResult: true,
521+
isComputing: false,
522+
result: null,
523+
error: null,
524+
});
525+
513526
const boundarySourceManifolds = manifoldState.manifolds;
514527

515528
const verifiedBoundaryCycles = useMemo(
@@ -697,6 +710,7 @@ const SetValuedViz = () => {
697710
const periodicComputationRevisionRef = useRef(0);
698711
const geometricOffsetRequestIdRef = useRef(0);
699712
const inverseOffsetRequestIdRef = useRef(0);
713+
const invariantSetRequestIdRef = useRef(0);
700714
const ulamDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
701715
const ulamSupportRef = useRef<SupportGrid | null>(null);
702716
const ulamTransitionsRequestRef = useRef(0);
@@ -2189,6 +2203,83 @@ const SetValuedViz = () => {
21892203
}
21902204
}, [recordingState.recordingEnabled]);
21912205

2206+
const computeForwardInvariantSet = useCallback(async () => {
2207+
if (dynamicSystem !== 'henon') return;
2208+
let seed: ExtendedState;
2209+
try {
2210+
if (invariantSetState.seedMode === 'manual') {
2211+
seed = { ...manifoldState.startPoint };
2212+
} else {
2213+
const position = randomHenonInvariantSeed(
2214+
viewRange,
2215+
params.a,
2216+
params.b,
2217+
params.epsilon,
2218+
);
2219+
seed = {
2220+
...position,
2221+
nx: manifoldState.startPoint.nx,
2222+
ny: manifoldState.startPoint.ny,
2223+
};
2224+
}
2225+
} catch (error) {
2226+
setInvariantSetState(previous => ({
2227+
...previous,
2228+
error: error instanceof Error ? error.message : String(error),
2229+
}));
2230+
return;
2231+
}
2232+
2233+
const requestId = ++invariantSetRequestIdRef.current;
2234+
setInvariantSetState(previous => ({
2235+
...previous,
2236+
isComputing: true,
2237+
result: null,
2238+
error: null,
2239+
}));
2240+
try {
2241+
const result = await runComputeTask('computeForwardInvariantSet', {
2242+
seed,
2243+
params: { a: params.a, b: params.b, epsilon: params.epsilon },
2244+
domain: viewRange,
2245+
settings: {
2246+
boundaryPointCount: invariantSetState.boundaryPointCount,
2247+
forwardIterations: invariantSetState.forwardIterations,
2248+
},
2249+
});
2250+
if (requestId !== invariantSetRequestIdRef.current) return;
2251+
setInvariantSetState(previous => ({
2252+
...previous,
2253+
isComputing: false,
2254+
showResult: true,
2255+
result,
2256+
error: null,
2257+
}));
2258+
} catch (error) {
2259+
if (requestId !== invariantSetRequestIdRef.current) return;
2260+
setInvariantSetState(previous => ({
2261+
...previous,
2262+
isComputing: false,
2263+
result: null,
2264+
error: error instanceof Error ? error.message : String(error),
2265+
}));
2266+
}
2267+
}, [
2268+
dynamicSystem,
2269+
invariantSetState.boundaryPointCount,
2270+
invariantSetState.forwardIterations,
2271+
invariantSetState.seedMode,
2272+
manifoldState.startPoint.x,
2273+
manifoldState.startPoint.y,
2274+
manifoldState.startPoint.nx,
2275+
manifoldState.startPoint.ny,
2276+
params.a,
2277+
params.b,
2278+
params.epsilon,
2279+
runComputeTask,
2280+
viewRange,
2281+
]);
2282+
21922283
const computeGeometricOffsets = useCallback(async () => {
21932284
if (dynamicSystem !== 'henon' || calculatedBoundaryBranches.length === 0) {
21942285
setGeometricOffsetState(prev => ({
@@ -2420,6 +2511,31 @@ const SetValuedViz = () => {
24202511
transitionViewRange
24212512
]);
24222513

2514+
useEffect(() => {
2515+
invariantSetRequestIdRef.current += 1;
2516+
setInvariantSetState(previous => ({
2517+
...previous,
2518+
isComputing: false,
2519+
result: null,
2520+
error: null,
2521+
}));
2522+
}, [dynamicSystem, params.a, params.b, params.epsilon, viewRange]);
2523+
2524+
useEffect(() => {
2525+
if (invariantSetState.seedMode !== 'manual') return;
2526+
invariantSetRequestIdRef.current += 1;
2527+
setInvariantSetState(previous => ({
2528+
...previous,
2529+
isComputing: false,
2530+
result: null,
2531+
error: null,
2532+
}));
2533+
}, [
2534+
invariantSetState.seedMode,
2535+
manifoldState.startPoint.x,
2536+
manifoldState.startPoint.y,
2537+
]);
2538+
24232539
useEffect(() => {
24242540
geometricOffsetRequestIdRef.current += 1;
24252541
inverseOffsetRequestIdRef.current += 1;
@@ -2445,7 +2561,7 @@ const SetValuedViz = () => {
24452561

24462562
const toRemove: THREE.Object3D[] = [];
24472563
scene.traverse(child => {
2448-
if (child.userData.type === 'trajectory' || child.userData.type === 'manifold' || child.userData.type === 'boundaryLayer' || child.userData.type === 'boundarySamplePoints' || child.userData.type === 'noiseBall' || child.userData.type === 'geometricOffset' || child.userData.type === 'inverseGeometricOffset' || child.userData.type === 'fixedPoint' || child.userData.type === 'bde') {
2564+
if (child.userData.type === 'trajectory' || child.userData.type === 'manifold' || child.userData.type === 'boundaryLayer' || child.userData.type === 'boundarySamplePoints' || child.userData.type === 'noiseBall' || child.userData.type === 'geometricOffset' || child.userData.type === 'inverseGeometricOffset' || child.userData.type === 'forwardInvariantSet' || child.userData.type === 'fixedPoint' || child.userData.type === 'bde') {
24492565
toRemove.push(child);
24502566
}
24512567
});
@@ -2511,6 +2627,37 @@ const SetValuedViz = () => {
25112627
scene.add(samples);
25122628
};
25132629

2630+
if (invariantSetState.showResult && invariantSetState.result?.point_sets.length) {
2631+
// Iteration zero is the construction circle, not a forward-projected result.
2632+
const projectedPointSets = invariantSetState.result.point_sets.filter(pointSet => (
2633+
pointSet.iteration > 0
2634+
));
2635+
const projectedPoints = projectedPointSets.flatMap(pointSet => pointSet.points);
2636+
if (projectedPoints.length > 0) {
2637+
const geometry = new THREE.BufferGeometry().setFromPoints(
2638+
projectedPoints.map(point => new THREE.Vector3(point.x, point.y, 0.31)),
2639+
);
2640+
const material = new THREE.PointsMaterial({
2641+
color: new THREE.Color('#70a7c4'),
2642+
size: 4,
2643+
sizeAttenuation: false,
2644+
transparent: true,
2645+
opacity: 0.9,
2646+
depthTest: false,
2647+
depthWrite: false,
2648+
});
2649+
const samples = new THREE.Points(geometry, material);
2650+
samples.renderOrder = 15;
2651+
samples.userData = {
2652+
type: 'forwardInvariantSet',
2653+
role: 'projected-point-samples',
2654+
pointSetCount: projectedPointSets.length,
2655+
pointCount: projectedPoints.length,
2656+
};
2657+
scene.add(samples);
2658+
}
2659+
}
2660+
25142661
if (manifoldState.showUnstableManifold && hasBoundarySamples) {
25152662
boundaryLayers.invariantBranches.forEach(branch => {
25162663
if (manifoldState.showBoundarySamplePoints) {
@@ -2947,7 +3094,7 @@ const SetValuedViz = () => {
29473094
scene.add(sphere);
29483095
}
29493096

2950-
}, [manifoldState, geometricOffsetState, bdeState, dynamicSystem, type, viewRange, viewportRange, readViewportSize, boundaryLayers, hasBoundarySamples, hasVerifiedBoundaryCycles, params.epsilon, filters, periodicState.isReady, periodicState.orbits]);
3097+
}, [manifoldState, geometricOffsetState, invariantSetState, bdeState, dynamicSystem, type, viewRange, viewportRange, readViewportSize, boundaryLayers, hasBoundarySamples, hasVerifiedBoundaryCycles, params.epsilon, filters, periodicState.isReady, periodicState.orbits]);
29513098

29523099
useEffect(() => {
29533100
if (!sceneRef.current) return;
@@ -3886,6 +4033,9 @@ const SetValuedViz = () => {
38864033
setManifoldState={setManifoldState}
38874034
geometricOffsetState={geometricOffsetState}
38884035
setGeometricOffsetState={setGeometricOffsetState}
4036+
invariantSetState={invariantSetState}
4037+
setInvariantSetState={setInvariantSetState}
4038+
computeForwardInvariantSet={computeForwardInvariantSet}
38894039
hasBoundarySamples={hasBoundarySamples}
38904040
boundaryLayerError={boundaryLayers.error}
38914041
boundarySampling={{
@@ -3931,6 +4081,7 @@ const SetValuedViz = () => {
39314081
tooltip={tooltip}
39324082
manifoldState={manifoldState}
39334083
geometricOffsetState={geometricOffsetState}
4084+
invariantSetState={invariantSetState}
39344085
ulamState={ulamState}
39354086
hasBoundarySamples={hasBoundarySamples}
39364087
displayRange={viewportRange}

frontend/src/components/layout/Sidebar.test.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ vi.mock('../sidebar/EquationDisplay', () => ({ EquationDisplay: () => <div data-
99
vi.mock('../sidebar/ParametersPanel', () => ({ ParametersPanel: () => <div data-testid="parameters-panel" /> }));
1010
vi.mock('../sidebar/ManifoldsPanel', () => ({ ManifoldsPanel: () => <div data-testid="manifolds-panel" /> }));
1111
vi.mock('../sidebar/GeometricOffsetsPanel', () => ({ GeometricOffsetsPanel: () => <div data-testid="geometric-offsets-panel" /> }));
12+
vi.mock('../sidebar/InvariantSetsPanel', () => ({ InvariantSetsPanel: () => <div data-testid="invariant-sets-panel" /> }));
1213
vi.mock('../sidebar/VisualizationPanel', () => ({ VisualizationPanel: () => <div data-testid="visualization-panel" /> }));
1314
vi.mock('../sidebar/StartingPoint', () => ({ StartingPoint: () => <div data-testid="starting-point" /> }));
1415
vi.mock('../sidebar/PeriodicOrbitsPanel', () => ({ PeriodicOrbitsPanel: () => <div data-testid="periodic-orbits" /> }));
@@ -134,6 +135,17 @@ const baseProps = {
134135
canComputeInverseGeometricOffsets: false,
135136
computeInverseGeometricOffsets: vi.fn(),
136137
fitInverseGeometricOffsets: vi.fn(),
138+
invariantSetState: {
139+
seedMode: 'random',
140+
boundaryPointCount: 256,
141+
forwardIterations: 100,
142+
showResult: true,
143+
isComputing: false,
144+
result: null,
145+
error: null,
146+
},
147+
setInvariantSetState: vi.fn(),
148+
computeForwardInvariantSet: vi.fn(),
137149
ORBIT_COLORS: { manifold: '#e67e22', stableManifold: '#3498db' },
138150
filters: {
139151
period1: true,
@@ -231,7 +243,7 @@ describe('Sidebar', () => {
231243
});
232244

233245
it('shows the starting point panel for discrete systems', () => {
234-
render(<Sidebar {...baseProps} type="discrete" dynamicSystem="henon" />);
246+
render(<Sidebar {...baseProps} type="discrete" dynamicSystem="duffing" />);
235247
expect(screen.getByTestId('starting-point')).toBeInTheDocument();
236248
});
237249

@@ -248,6 +260,13 @@ describe('Sidebar', () => {
248260
expect(screen.queryByTestId('geometric-offsets-panel')).toBeNull();
249261
});
250262

263+
it('shows forward invariant-set controls only for the Hénon map', () => {
264+
const { rerender } = render(<Sidebar {...baseProps} type="discrete" dynamicSystem="henon" />);
265+
expect(screen.getByTestId('invariant-sets-panel')).toBeInTheDocument();
266+
rerender(<Sidebar {...baseProps} type="discrete" dynamicSystem="duffing" />);
267+
expect(screen.queryByTestId('invariant-sets-panel')).toBeNull();
268+
});
269+
251270
it('passes recompute controls into the bottom controls bar', () => {
252271
const onRecompute = vi.fn();
253272
render(<Sidebar {...baseProps} applyInputsAndRecompute={onRecompute} hasPendingInputChanges={true} />);

frontend/src/components/layout/Sidebar.tsx

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { AnimationPanel } from '../sidebar/AnimationPanel';
1212
import { ParameterSweepPanel } from '../sidebar/ParameterSweepPanel';
1313
import { GeometricOffsetsPanel } from '../sidebar/GeometricOffsetsPanel';
1414
import { ExperimentPanel } from '../sidebar/ExperimentPanel';
15+
import { InvariantSetsPanel } from '../sidebar/InvariantSetsPanel';
1516
import { InfoStrip } from './InfoStrip';
1617
import { ControlsBar } from './ControlsBar';
1718
import { BIST_VERSION } from '../../config/systems';
@@ -25,6 +26,7 @@ import type {
2526
CustomParameter,
2627
ExperimentStatus,
2728
GeometricOffsetState,
29+
InvariantSetState,
2830
ManifoldState,
2931
OrbitFilters,
3032
PeriodicSearchSettings,
@@ -79,6 +81,9 @@ interface SidebarProps {
7981
canComputeInverseGeometricOffsets: boolean;
8082
computeInverseGeometricOffsets: () => void;
8183
fitInverseGeometricOffsets: () => void;
84+
invariantSetState: InvariantSetState;
85+
setInvariantSetState: StateSetter<InvariantSetState>;
86+
computeForwardInvariantSet: () => void;
8287
ORBIT_COLORS: { manifold: string; stableManifold: string };
8388
filters: OrbitFilters;
8489
setFilters: StateSetter<OrbitFilters>;
@@ -182,15 +187,27 @@ export const Sidebar = (props: SidebarProps) => {
182187
resetViewRange={props.resetViewRange}
183188
/>
184189

185-
<StartingPoint
186-
type={props.type}
187-
startPoint={props.manifoldState.startPoint}
188-
updateStartPoint={props.updateStartPoint}
189-
disabled={props.manifoldState.isRunning || animationLocksConfiguration}
190-
/>
190+
{!(props.type === 'discrete' && props.dynamicSystem === 'henon') && (
191+
<StartingPoint
192+
type={props.type}
193+
startPoint={props.manifoldState.startPoint}
194+
updateStartPoint={props.updateStartPoint}
195+
disabled={props.manifoldState.isRunning || animationLocksConfiguration}
196+
/>
197+
)}
191198

192199
{props.type === 'discrete' && (
193200
<>
201+
{props.dynamicSystem === 'henon' && (
202+
<InvariantSetsPanel
203+
state={props.invariantSetState}
204+
setState={props.setInvariantSetState}
205+
initialState={props.manifoldState.startPoint}
206+
updateInitialState={props.updateStartPoint}
207+
epsilon={(props.appliedParams || props.params).epsilon}
208+
compute={props.computeForwardInvariantSet}
209+
/>
210+
)}
194211
<ManifoldsPanel
195212
manifoldState={props.manifoldState}
196213
setManifoldState={props.setManifoldState}

frontend/src/components/layout/Viewport.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,26 @@ describe('Viewport', () => {
7474
expect(screen.getByTestId('geometric-offset-point-swatch')).not.toHaveClass('lg-line');
7575
});
7676

77+
it('labels forward-projected invariant set samples', () => {
78+
render(<Viewport {...baseProps} type="discrete" invariantSetState={{
79+
showResult: true,
80+
result: {
81+
seed: { x: 0, y: 0, nx: 1, ny: 0 },
82+
deterministic_image: { x: 1, y: 0 },
83+
epsilon: 0.1,
84+
boundary_point_count: 8,
85+
requested_iterations: 1,
86+
completed_iterations: 1,
87+
stop_reason: 'requested_iterations_completed',
88+
point_sets: [
89+
{ iteration: 0, points: [] },
90+
{ iteration: 1, points: [] },
91+
],
92+
},
93+
}} />);
94+
expect(screen.getByText('Forward-projected samples')).toBeInTheDocument();
95+
});
96+
7797
it('labels unstable, deterministic-image, noise-ball, and mapped-point layers', () => {
7898
render(<Viewport {...baseProps} type="discrete" hasBoundarySamples manifoldState={{
7999
...baseProps.manifoldState,

0 commit comments

Comments
 (0)