re-organize project structure a bit + add icons
This commit is contained in:
parent
3cb3ef91d2
commit
5e7b944978
24 changed files with 514 additions and 249 deletions
133
src/statecharts/abstract_syntax.ts
Normal file
133
src/statecharts/abstract_syntax.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { Action, TransitionLabel } from "./label_ast";
|
||||
|
||||
export type AbstractState = {
|
||||
uid: string;
|
||||
parent?: ConcreteState;
|
||||
children: ConcreteState[];
|
||||
comments: [string, string][]; // array of tuple (text-uid, text-text)
|
||||
entryActions: Action[];
|
||||
exitActions: Action[];
|
||||
depth: number;
|
||||
timers: number[]; // list of timeouts (e.g., the state having an outgoing transition with trigger "after 4s" would appear as the number 4000 in this list)
|
||||
}
|
||||
|
||||
export type AndState = {
|
||||
kind: "and";
|
||||
} & AbstractState;
|
||||
|
||||
export type OrState = {
|
||||
kind: "or";
|
||||
// array of tuples: (uid of Arrow indicating initial state, initial state)
|
||||
// in a valid AST, there must be one initial state, but we allow the user to draw crazy shit
|
||||
initial: [string, ConcreteState][];
|
||||
} & AbstractState;
|
||||
|
||||
export type ConcreteState = AndState | OrState;
|
||||
|
||||
export type Transition = {
|
||||
uid: string;
|
||||
src: ConcreteState;
|
||||
tgt: ConcreteState;
|
||||
label: TransitionLabel[];
|
||||
}
|
||||
|
||||
export type Statechart = {
|
||||
root: OrState;
|
||||
transitions: Map<string, Transition[]>; // key: source state uid
|
||||
|
||||
variables: Set<string>;
|
||||
|
||||
inputEvents: Set<string>;
|
||||
internalEvents: Set<string>;
|
||||
outputEvents: Set<string>;
|
||||
|
||||
uid2State: Map<string, ConcreteState>;
|
||||
}
|
||||
|
||||
const emptyRoot: OrState = {
|
||||
uid: "root",
|
||||
kind: "or",
|
||||
depth: 0,
|
||||
initial: [],
|
||||
children:[],
|
||||
comments: [],
|
||||
entryActions: [],
|
||||
exitActions: [],
|
||||
timers: [],
|
||||
};
|
||||
|
||||
export const emptyStatechart: Statechart = {
|
||||
root: emptyRoot,
|
||||
transitions: new Map(),
|
||||
variables: new Set(),
|
||||
inputEvents: new Set(),
|
||||
internalEvents: new Set(),
|
||||
outputEvents: new Set(),
|
||||
uid2State: new Map([["root", emptyRoot]]),
|
||||
};
|
||||
|
||||
// reflexive, transitive relation
|
||||
export function isAncestorOf({ancestor, descendant}: {ancestor: ConcreteState, descendant: ConcreteState}): ConcreteState[] | false {
|
||||
if (ancestor.uid === descendant.uid) {
|
||||
return [descendant];
|
||||
}
|
||||
if (ancestor.depth >= descendant.depth) {
|
||||
return false;
|
||||
}
|
||||
const pathToParent = isAncestorOf({ancestor, descendant: descendant.parent!});
|
||||
return pathToParent && [...pathToParent, descendant];
|
||||
}
|
||||
|
||||
export function isOverlapping(a: ConcreteState, b: ConcreteState): boolean {
|
||||
if (a.depth < b.depth) {
|
||||
return Boolean(isAncestorOf({ancestor: a, descendant: b}));
|
||||
}
|
||||
else {
|
||||
return Boolean(isAncestorOf({ancestor: b, descendant: a}));
|
||||
}
|
||||
}
|
||||
|
||||
// the arena of a transition is the lowest common ancestor state that is an OR-state
|
||||
// see "Deconstructing the Semantics of Big-Step Modelling Languages" by Shahram Esmaeilsabzali, 2009
|
||||
export function computeArena({src, tgt}: {src: ConcreteState, tgt: ConcreteState}): {
|
||||
arena: OrState,
|
||||
srcPath: ConcreteState[],
|
||||
tgtPath: ConcreteState[],
|
||||
} {
|
||||
if (src.depth >= tgt.depth) {
|
||||
const path = isAncestorOf({descendant: src, ancestor: tgt});
|
||||
if (path) {
|
||||
if (tgt.kind === "or") {
|
||||
return {arena: tgt, srcPath: path, tgtPath: [tgt]};
|
||||
}
|
||||
}
|
||||
// keep looking
|
||||
const {arena, srcPath, tgtPath} = computeArena({src, tgt: tgt.parent!});
|
||||
return {arena, srcPath, tgtPath: [...tgtPath, tgt]};
|
||||
}
|
||||
else {
|
||||
// same, but swap src and tgt
|
||||
const {arena, srcPath, tgtPath} = computeArena({src: tgt, tgt: src});
|
||||
return {arena, srcPath: tgtPath, tgtPath: srcPath};
|
||||
}
|
||||
}
|
||||
|
||||
export function getDescendants(state: ConcreteState): Set<string> {
|
||||
const result = new Set([state.uid]);
|
||||
for (const child of state.children) {
|
||||
for (const descendant of getDescendants(child)) {
|
||||
// will include child itself:
|
||||
result.add(descendant);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// the 'description' of a state is a human-readable string that (hopefully) identifies the state.
|
||||
// if the state contains a comment, we take the 'first' (= visually topmost) comment
|
||||
// otherwise we fall back to the state's UID.
|
||||
export function stateDescription(state: ConcreteState) {
|
||||
const description = state.comments.length > 0 ? state.comments[0][1] : state.uid;
|
||||
return description;
|
||||
}
|
||||
|
||||
46
src/statecharts/actionlang_interpreter.ts
Normal file
46
src/statecharts/actionlang_interpreter.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Just a simple recursive interpreter for the action language
|
||||
|
||||
import { Expression } from "./label_ast";
|
||||
import { Environment } from "./runtime_types";
|
||||
|
||||
|
||||
const UNARY_OPERATOR_MAP: Map<string, (x: any) => any> = new Map([
|
||||
["!", x => !x],
|
||||
["-", x => -x as any],
|
||||
]);
|
||||
const BINARY_OPERATOR_MAP: Map<string, (a: any, b: any) => any> = new Map([
|
||||
["+", (a, b) => a + b],
|
||||
["-", (a, b) => a - b],
|
||||
["*", (a, b) => a * b],
|
||||
["/", (a, b) => a / b],
|
||||
["&&", (a, b) => a && b],
|
||||
["||", (a, b) => a || b],
|
||||
["==", (a, b) => a == b],
|
||||
["<=", (a, b) => a <= b],
|
||||
[">=", (a, b) => a >= b],
|
||||
["<", (a, b) => a < b],
|
||||
[">", (a, b) => a > b],
|
||||
]);
|
||||
|
||||
export function evalExpr(expr: Expression, environment: Environment): any {
|
||||
if (expr.kind === "literal") {
|
||||
return expr.value;
|
||||
}
|
||||
else if (expr.kind === "ref") {
|
||||
const found = environment.get(expr.variable);
|
||||
if (found === undefined) {
|
||||
throw new Error(`variable '${expr.variable}' does not exist in environment`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
else if (expr.kind === "unaryExpr") {
|
||||
const arg = evalExpr(expr.expr, environment);
|
||||
return UNARY_OPERATOR_MAP.get(expr.operator)!(arg);
|
||||
}
|
||||
else if (expr.kind === "binaryExpr") {
|
||||
const lhs = evalExpr(expr.lhs, environment);
|
||||
const rhs = evalExpr(expr.rhs, environment);
|
||||
return BINARY_OPERATOR_MAP.get(expr.operator)!(lhs, rhs);
|
||||
}
|
||||
throw new Error("should never reach here");
|
||||
}
|
||||
117
src/statecharts/concrete_syntax.ts
Normal file
117
src/statecharts/concrete_syntax.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { Rect2D, Vec2D, Line2D, euclideanDistance, intersectLines, isWithin, lineBBox, isEntirelyWithin } from "../VisualEditor/geometry";
|
||||
import { ARROW_SNAP_THRESHOLD, TEXT_SNAP_THRESHOLD } from "../VisualEditor/parameters";
|
||||
import { sides } from "../VisualEditor/VisualEditor";
|
||||
|
||||
export type Rountangle = {
|
||||
uid: string;
|
||||
kind: "and" | "or";
|
||||
} & Rect2D;
|
||||
|
||||
export type Text = {
|
||||
uid: string;
|
||||
topLeft: Vec2D;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type Arrow = {
|
||||
uid: string;
|
||||
} & Line2D;
|
||||
|
||||
export type VisualEditorState = {
|
||||
rountangles: Rountangle[];
|
||||
texts: Text[];
|
||||
arrows: Arrow[];
|
||||
nextID: number;
|
||||
};
|
||||
|
||||
// independently moveable parts of our shapes:
|
||||
export type RountanglePart = "left" | "top" | "right" | "bottom";
|
||||
export type ArrowPart = "start" | "end";
|
||||
|
||||
export const emptyState = {
|
||||
rountangles: [], texts: [], arrows: [], nextID: 0,
|
||||
};
|
||||
|
||||
export const onOffStateMachine = {
|
||||
rountangles: [
|
||||
{ uid: "0", topLeft: { x: 100, y: 100 }, size: { x: 100, y: 100 }, kind: "and" },
|
||||
{ uid: "1", topLeft: { x: 100, y: 300 }, size: { x: 100, y: 100 }, kind: "and" },
|
||||
],
|
||||
texts: [],
|
||||
arrows: [
|
||||
{ uid: "2", start: { x: 150, y: 200 }, end: { x: 160, y: 300 } },
|
||||
],
|
||||
nextID: 3,
|
||||
};
|
||||
|
||||
// used to find which rountangle an arrow connects to (src/tgt)
|
||||
export function findNearestRountangleSide(arrow: Line2D, arrowPart: "start" | "end", candidates: Rountangle[]): {uid: string, part: RountanglePart} | undefined {
|
||||
let best = Infinity;
|
||||
let bestSide: undefined | {uid: string, part: RountanglePart};
|
||||
for (const rountangle of candidates) {
|
||||
for (const [side, getSide] of sides) {
|
||||
const asLine = getSide(rountangle);
|
||||
const intersection = intersectLines(arrow, asLine);
|
||||
if (intersection !== null) {
|
||||
const bbox = lineBBox(asLine, ARROW_SNAP_THRESHOLD);
|
||||
const dist = euclideanDistance(arrow[arrowPart], intersection);
|
||||
if (isWithin(arrow[arrowPart], bbox) && dist < best) {
|
||||
best = dist;
|
||||
bestSide = { uid: rountangle.uid, part: side };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestSide;
|
||||
}
|
||||
|
||||
export function point2LineDistance(point: Vec2D, {start, end}: Line2D): number {
|
||||
const A = point.x - start.x;
|
||||
const B = point.y - start.y;
|
||||
const C = end.x - start.x;
|
||||
const D = end.y - start.y;
|
||||
|
||||
const dot = A * C + B * D;
|
||||
const lenSq = C * C + D * D;
|
||||
let t = lenSq ? dot / lenSq : -1;
|
||||
|
||||
if (t < 0) t = 0;
|
||||
else if (t > 1) t = 1;
|
||||
|
||||
const closestX = start.x + t * C;
|
||||
const closestY = start.y + t * D;
|
||||
|
||||
const dx = point.x - closestX;
|
||||
const dy = point.y - closestY;
|
||||
|
||||
const distance = Math.hypot(dx, dy);
|
||||
|
||||
return distance;
|
||||
}
|
||||
|
||||
// used to find which arrow a text label belongs to (if any)
|
||||
// author: ChatGPT
|
||||
export function findNearestArrow(point: Vec2D, candidates: Arrow[]): Arrow | undefined {
|
||||
let best;
|
||||
let bestDistance = Infinity
|
||||
|
||||
for (const arrow of candidates) {
|
||||
const distance = point2LineDistance(point, arrow);
|
||||
|
||||
if (distance < TEXT_SNAP_THRESHOLD && distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = arrow;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
// precondition: candidates are sorted from big to small
|
||||
export function findRountangle(point: Vec2D, candidates: Rountangle[]): Rountangle | undefined {
|
||||
for (let i=candidates.length-1; i>=0; i--) {
|
||||
if (isWithin(point, candidates[i])) {
|
||||
return candidates[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
287
src/statecharts/interpreter.ts
Normal file
287
src/statecharts/interpreter.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import { evalExpr } from "./actionlang_interpreter";
|
||||
import { computeArena, ConcreteState, getDescendants, isOverlapping, OrState, Statechart, stateDescription, Transition } from "./abstract_syntax";
|
||||
import { Action } from "./label_ast";
|
||||
import { Environment, RaisedEvents, Mode, RT_Statechart, initialRaised, BigStepOutput, TimerElapseEvent, Timers } from "./runtime_types";
|
||||
|
||||
export function initialize(ast: Statechart): BigStepOutput {
|
||||
let {enteredStates, environment, ...raised} = enterDefault(0, ast.root, {
|
||||
environment: new Map(),
|
||||
...initialRaised,
|
||||
});
|
||||
return handleInternalEvents(0, ast, {mode: enteredStates, environment, ...raised});
|
||||
}
|
||||
|
||||
type ActionScope = {
|
||||
environment: Environment,
|
||||
} & RaisedEvents;
|
||||
|
||||
type EnteredScope = { enteredStates: Mode } & ActionScope;
|
||||
|
||||
export function entryActions(simtime: number, state: ConcreteState, actionScope: ActionScope): ActionScope {
|
||||
for (const action of state.entryActions) {
|
||||
(actionScope = execAction(action, actionScope));
|
||||
}
|
||||
// schedule timers
|
||||
// we store timers in the environment (dirty!)
|
||||
const environment = new Map(actionScope.environment);
|
||||
const timers: Timers = [...(environment.get("_timers") || [])];
|
||||
for (const timeOffset of state.timers) {
|
||||
const futureSimTime = simtime + timeOffset; // point in simtime when after-trigger becomes enabled
|
||||
timers.push([futureSimTime, {state: state.uid, timeDurMs: timeOffset}]);
|
||||
}
|
||||
timers.sort((a,b) => a[0] - b[0]); // smallest futureSimTime comes first
|
||||
environment.set("_timers", timers);
|
||||
return {...actionScope, environment};
|
||||
}
|
||||
|
||||
export function exitActions(simtime: number, state: ConcreteState, actionScope: ActionScope): ActionScope {
|
||||
for (const action of state.exitActions) {
|
||||
(actionScope = execAction(action, actionScope));
|
||||
}
|
||||
// cancel timers
|
||||
const environment = new Map(actionScope.environment);
|
||||
const timers: Timers = environment.get("_timers") || [];
|
||||
const filtered = timers.filter(([_, {state: s}]) => s !== state.uid);
|
||||
environment.set("_timers", filtered);
|
||||
return {...actionScope, environment};
|
||||
}
|
||||
|
||||
export function enterDefault(simtime: number, state: ConcreteState, rt: ActionScope): EnteredScope {
|
||||
let actionScope = rt;
|
||||
|
||||
// execute entry actions
|
||||
actionScope = entryActions(simtime, state, actionScope);
|
||||
|
||||
// enter children...
|
||||
let enteredStates = new Set([state.uid]);
|
||||
if (state.kind === "and") {
|
||||
// enter every child
|
||||
for (const child of state.children) {
|
||||
let enteredChildren;
|
||||
({enteredStates: enteredChildren, ...actionScope} = enterDefault(simtime, child, actionScope));
|
||||
enteredStates = enteredStates.union(enteredChildren);
|
||||
}
|
||||
}
|
||||
else if (state.kind === "or") {
|
||||
// same as AND-state, but we only enter the initial state(s)
|
||||
if (state.initial.length > 0) {
|
||||
if (state.initial.length > 1) {
|
||||
console.warn(state.uid + ': multiple initial states, only entering one of them');
|
||||
}
|
||||
let enteredChildren;
|
||||
({enteredStates: enteredChildren, ...actionScope} = enterDefault(simtime, state.initial[0][1], actionScope));
|
||||
enteredStates = enteredStates.union(enteredChildren);
|
||||
}
|
||||
console.warn(state.uid + ': no initial state');
|
||||
}
|
||||
|
||||
return {enteredStates, ...actionScope};
|
||||
}
|
||||
|
||||
export function enterPath(simtime: number, path: ConcreteState[], rt: ActionScope): EnteredScope {
|
||||
let actionScope = rt;
|
||||
|
||||
const [state, ...rest] = path;
|
||||
|
||||
// execute entry actions
|
||||
actionScope = entryActions(simtime, state, actionScope);
|
||||
|
||||
// enter children...
|
||||
let enteredStates = new Set([state.uid]);
|
||||
if (state.kind === "and") {
|
||||
// enter every child
|
||||
for (const child of state.children) {
|
||||
let enteredChildren;
|
||||
if (rest.length > 0 && child.uid === rest[0].uid) {
|
||||
({enteredStates: enteredChildren, ...actionScope} = enterPath(simtime, rest, actionScope));
|
||||
}
|
||||
else {
|
||||
({enteredStates: enteredChildren, ...actionScope} = enterDefault(simtime, child, actionScope));
|
||||
}
|
||||
enteredStates = enteredStates.union(enteredChildren);
|
||||
}
|
||||
}
|
||||
else if (state.kind === "or") {
|
||||
if (rest.length > 0) {
|
||||
let enteredChildren;
|
||||
({enteredStates: enteredChildren, ...actionScope} = enterPath(simtime, rest, actionScope));
|
||||
enteredStates = enteredStates.union(enteredChildren);
|
||||
}
|
||||
else {
|
||||
// same as AND-state, but we only enter the initial state(s)
|
||||
for (const [_, child] of state.initial) {
|
||||
let enteredChildren;
|
||||
({enteredStates: enteredChildren, ...actionScope} = enterDefault(simtime, child, actionScope));
|
||||
enteredStates = enteredStates.union(enteredChildren);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { enteredStates, ...actionScope };
|
||||
}
|
||||
|
||||
// exit the given state and all its active descendants
|
||||
export function exitCurrent(simtime: number, state: ConcreteState, rt: EnteredScope): ActionScope {
|
||||
let {enteredStates, ...actionScope} = rt;
|
||||
|
||||
// exit all active children...
|
||||
for (const child of state.children) {
|
||||
if (enteredStates.has(child.uid)) {
|
||||
actionScope = exitCurrent(simtime, child, {enteredStates, ...actionScope});
|
||||
}
|
||||
}
|
||||
|
||||
// execute exit actions
|
||||
actionScope = exitActions(simtime, state, actionScope);
|
||||
|
||||
return actionScope;
|
||||
}
|
||||
|
||||
export function exitPath(simtime: number, path: ConcreteState[], rt: EnteredScope): ActionScope {
|
||||
let {enteredStates, ...actionScope} = rt;
|
||||
|
||||
const toExit = enteredStates.difference(new Set(path));
|
||||
|
||||
const [state, ...rest] = path;
|
||||
|
||||
// exit state and all its children, *except* states along the rest of the path
|
||||
actionScope = exitCurrent(simtime, state, {enteredStates: toExit, ...actionScope});
|
||||
if (rest.length > 0) {
|
||||
actionScope = exitPath(simtime, rest, {enteredStates, ...actionScope});
|
||||
}
|
||||
|
||||
// execute exit actions
|
||||
actionScope = exitActions(simtime, state, actionScope);
|
||||
|
||||
return actionScope;
|
||||
}
|
||||
|
||||
export function execAction(action: Action, rt: ActionScope): ActionScope {
|
||||
if (action.kind === "assignment") {
|
||||
const rhs = evalExpr(action.rhs, rt.environment);
|
||||
const newEnvironment = new Map(rt.environment);
|
||||
newEnvironment.set(action.lhs, rhs);
|
||||
return {
|
||||
...rt,
|
||||
environment: newEnvironment,
|
||||
};
|
||||
}
|
||||
else if (action.kind === "raise") {
|
||||
if (action.event.startsWith('_')) {
|
||||
// append to internal events
|
||||
return {
|
||||
...rt,
|
||||
internalEvents: [...rt.internalEvents, action.event],
|
||||
};
|
||||
}
|
||||
else {
|
||||
// append to output events
|
||||
return {
|
||||
...rt,
|
||||
outputEvents: [...rt.outputEvents, action.event],
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("should never reach here");
|
||||
}
|
||||
|
||||
export function handleEvent(simtime: number, event: string | TimerElapseEvent, statechart: Statechart, activeParent: ConcreteState, {environment, mode, ...raised}: RT_Statechart & RaisedEvents): RT_Statechart & RaisedEvents {
|
||||
const arenasFired = new Set<OrState>();
|
||||
for (const state of activeParent.children) {
|
||||
if (mode.has(state.uid)) {
|
||||
const outgoing = statechart.transitions.get(state.uid) || [];
|
||||
let triggered;
|
||||
if (typeof event === 'string') {
|
||||
triggered = outgoing.filter(transition => {
|
||||
const trigger = transition.label[0].trigger;
|
||||
if (trigger.kind === "event") {
|
||||
return trigger.event === event;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
else {
|
||||
triggered = outgoing.filter(transition => {
|
||||
const trigger = transition.label[0].trigger;
|
||||
if (trigger.kind === "after") {
|
||||
return trigger.durationMs === event.timeDurMs;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
const enabled = triggered.filter(transition =>
|
||||
evalExpr(transition.label[0].guard, environment)
|
||||
);
|
||||
if (enabled.length > 0) {
|
||||
if (enabled.length > 1) {
|
||||
console.warn('nondeterminism!!!!');
|
||||
}
|
||||
const t = enabled[0];
|
||||
console.log('enabled:', transitionDescription(t));
|
||||
const {arena, srcPath, tgtPath} = computeArena(t);
|
||||
let overlapping = false;
|
||||
for (const alreadyFired of arenasFired) {
|
||||
if (isOverlapping(arena, alreadyFired)) {
|
||||
overlapping = true;
|
||||
}
|
||||
}
|
||||
if (!overlapping) {
|
||||
console.log('^ firing');
|
||||
({mode, environment, ...raised} = fireTransition(simtime, t, arena, srcPath, tgtPath, {mode, environment, ...raised}));
|
||||
arenasFired.add(arena);
|
||||
}
|
||||
else {
|
||||
console.log('skip (overlapping arenas)');
|
||||
}
|
||||
}
|
||||
else {
|
||||
// no enabled outgoing transitions, try the children:
|
||||
({environment, mode, ...raised} = handleEvent(simtime, event, statechart, state, {environment, mode, ...raised}));
|
||||
}
|
||||
}
|
||||
}
|
||||
return {environment, mode, ...raised};
|
||||
}
|
||||
|
||||
export function handleInputEvent(simtime: number, event: string, statechart: Statechart, {mode, environment}: {mode: Mode, environment: Environment}): BigStepOutput {
|
||||
let raised = initialRaised;
|
||||
|
||||
({mode, environment, ...raised} = handleEvent(simtime, event, statechart, statechart.root, {mode, environment, ...raised}));
|
||||
|
||||
return handleInternalEvents(simtime, statechart, {mode, environment, ...raised});
|
||||
}
|
||||
|
||||
export function handleInternalEvents(simtime: number, statechart: Statechart, {mode, environment, ...raised}: RT_Statechart & RaisedEvents): BigStepOutput {
|
||||
while (raised.internalEvents.length > 0) {
|
||||
const [internalEvent, ...rest] = raised.internalEvents;
|
||||
({mode, environment, ...raised} = handleEvent(simtime, internalEvent, statechart, statechart.root, {mode, environment, internalEvents: rest, outputEvents: raised.outputEvents}));
|
||||
}
|
||||
return {mode, environment, outputEvents: raised.outputEvents};
|
||||
}
|
||||
|
||||
function transitionDescription(t: Transition) {
|
||||
return stateDescription(t.src) + ' ➔ ' + stateDescription(t.tgt);
|
||||
}
|
||||
|
||||
export function fireTransition(simtime: number, t: Transition, arena: OrState, srcPath: ConcreteState[], tgtPath: ConcreteState[], {mode, environment, ...raised}: RT_Statechart & RaisedEvents): RT_Statechart & RaisedEvents {
|
||||
|
||||
// console.log('fire ', transitionDescription(t), {arena, srcPath, tgtPath});
|
||||
|
||||
// exit src
|
||||
({environment, ...raised} = exitPath(simtime, srcPath.slice(1), {environment, enteredStates: mode, ...raised}));
|
||||
const toExit = getDescendants(arena);
|
||||
toExit.delete(arena.uid); // do not exit the arena itself
|
||||
const exitedMode = mode.difference(toExit);
|
||||
|
||||
// exec transition actions
|
||||
for (const action of t.label[0].actions) {
|
||||
({environment, ...raised} = execAction(action, {environment, ...raised}));
|
||||
}
|
||||
|
||||
// enter tgt
|
||||
let enteredStates;
|
||||
({enteredStates, environment, ...raised} = enterPath(simtime, tgtPath.slice(1), {environment, ...raised}));
|
||||
const enteredMode = exitedMode.union(enteredStates);
|
||||
|
||||
return {mode: enteredMode, environment, ...raised};
|
||||
}
|
||||
74
src/statecharts/label_ast.ts
Normal file
74
src/statecharts/label_ast.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
export type ParsedText = TransitionLabel | Comment;
|
||||
|
||||
export type TransitionLabel = {
|
||||
kind: "transitionLabel";
|
||||
uid: string; // uid of the text node
|
||||
trigger: Trigger;
|
||||
guard: Expression;
|
||||
actions: Action[];
|
||||
}
|
||||
|
||||
export type Comment = {
|
||||
kind: "comment";
|
||||
uid: string; // uid of the text node
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type Trigger = EventTrigger | AfterTrigger | EntryTrigger | ExitTrigger;
|
||||
|
||||
export type EventTrigger = {
|
||||
kind: "event";
|
||||
event: string;
|
||||
}
|
||||
|
||||
export type AfterTrigger = {
|
||||
kind: "after";
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export type EntryTrigger = {
|
||||
kind: "entry";
|
||||
}
|
||||
export type ExitTrigger = {
|
||||
kind: "exit";
|
||||
}
|
||||
|
||||
|
||||
export type Action = Assignment | RaiseEvent;
|
||||
|
||||
export type Assignment = {
|
||||
kind: "assignment";
|
||||
lhs: string;
|
||||
rhs: Expression;
|
||||
}
|
||||
|
||||
export type RaiseEvent = {
|
||||
kind: "raise";
|
||||
event: string;
|
||||
}
|
||||
|
||||
|
||||
export type Expression = BinaryExpression | UnaryExpression | VarRef | Literal;
|
||||
|
||||
export type BinaryExpression = {
|
||||
kind: "binaryExpr";
|
||||
operator: "+" | "-" | "*" | "/" | "&&" | "||";
|
||||
lhs: Expression;
|
||||
rhs: Expression;
|
||||
}
|
||||
|
||||
export type UnaryExpression = {
|
||||
kind: "unaryExpr";
|
||||
operator: "!" | "-";
|
||||
expr: Expression;
|
||||
}
|
||||
|
||||
export type VarRef = {
|
||||
kind: "ref";
|
||||
variable: string;
|
||||
}
|
||||
|
||||
export type Literal = {
|
||||
kind: "literal";
|
||||
value: any;
|
||||
}
|
||||
1420
src/statecharts/label_parser.js
Normal file
1420
src/statecharts/label_parser.js
Normal file
File diff suppressed because it is too large
Load diff
257
src/statecharts/parser.ts
Normal file
257
src/statecharts/parser.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { ConcreteState, OrState, Statechart, Transition } from "./abstract_syntax";
|
||||
import { findNearestArrow, findNearestRountangleSide, findRountangle, Rountangle, VisualEditorState } from "./concrete_syntax";
|
||||
import { isEntirelyWithin } from "../VisualEditor/geometry";
|
||||
import { Action, Expression, ParsedText } from "./label_ast";
|
||||
|
||||
import { parse as parseLabel, SyntaxError } from "./label_parser";
|
||||
|
||||
export function parseStatechart(state: VisualEditorState): [Statechart, [string,string][]] {
|
||||
const errorShapes: [string, string][] = [];
|
||||
|
||||
// implicitly, the root is always an Or-state
|
||||
const root: OrState = {
|
||||
kind: "or",
|
||||
uid: "root",
|
||||
children: [],
|
||||
initial: [],
|
||||
comments: [],
|
||||
entryActions: [],
|
||||
exitActions: [],
|
||||
depth: 0,
|
||||
timers: [],
|
||||
}
|
||||
|
||||
const uid2State = new Map<string, ConcreteState>([["root", root]]);
|
||||
|
||||
// we will always look for the smallest parent rountangle
|
||||
const parentCandidates: Rountangle[] = [{
|
||||
kind: "or",
|
||||
uid: root.uid,
|
||||
topLeft: {x: -Infinity, y: -Infinity},
|
||||
size: {x: Infinity, y: Infinity},
|
||||
}];
|
||||
|
||||
const parentLinks = new Map<string, string>();
|
||||
|
||||
// step 1: figure out state hierarchy
|
||||
|
||||
// we assume that the rountangles are sorted from big to small:
|
||||
for (const rt of state.rountangles) {
|
||||
const state: ConcreteState = {
|
||||
kind: rt.kind,
|
||||
uid: rt.uid,
|
||||
children: [],
|
||||
comments: [],
|
||||
entryActions: [],
|
||||
exitActions: [],
|
||||
timers: [],
|
||||
}
|
||||
if (state.kind === "or") {
|
||||
state.initial = [];
|
||||
}
|
||||
uid2State.set(rt.uid, state);
|
||||
|
||||
// iterate in reverse:
|
||||
for (let i=parentCandidates.length-1; i>=0; i--) {
|
||||
const candidate = parentCandidates[i];
|
||||
if (candidate.uid === "root" || isEntirelyWithin(rt, candidate)) {
|
||||
// found our parent :)
|
||||
const parentState = uid2State.get(candidate.uid)!;
|
||||
parentState.children.push(state);
|
||||
parentCandidates.push(rt);
|
||||
parentLinks.set(rt.uid, candidate.uid);
|
||||
state.parent = parentState;
|
||||
state.depth = parentState.depth+1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// step 2: figure out transitions
|
||||
|
||||
const transitions = new Map<string, Transition[]>();
|
||||
const uid2Transition = new Map<string, Transition>();
|
||||
|
||||
for (const arr of state.arrows) {
|
||||
const srcUID = findNearestRountangleSide(arr, "start", state.rountangles)?.uid;
|
||||
const tgtUID = findNearestRountangleSide(arr, "end", state.rountangles)?.uid;
|
||||
if (!srcUID) {
|
||||
if (!tgtUID) {
|
||||
// dangling edge - todo: display error...
|
||||
errorShapes.push([arr.uid, "dangling"]);
|
||||
}
|
||||
else {
|
||||
// target but no source, so we treat is as an 'initial' marking
|
||||
const initialState = uid2State.get(tgtUID)!;
|
||||
const ofState = uid2State.get(parentLinks.get(tgtUID)!)!;
|
||||
if (ofState.kind === "or") {
|
||||
ofState.initial.push([arr.uid, initialState]);
|
||||
}
|
||||
else {
|
||||
// and states do not have an 'initial' state - todo: display error...
|
||||
errorShapes.push([arr.uid, "AND-state cannot have an initial state"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (!tgtUID) {
|
||||
errorShapes.push([arr.uid, "no target"]);
|
||||
}
|
||||
else {
|
||||
// add transition
|
||||
const transition: Transition = {
|
||||
uid: arr.uid,
|
||||
src: uid2State.get(srcUID)!,
|
||||
tgt: uid2State.get(tgtUID)!,
|
||||
label: [],
|
||||
};
|
||||
const existingTransitions = transitions.get(srcUID) || [];
|
||||
existingTransitions.push(transition);
|
||||
transitions.set(srcUID, existingTransitions);
|
||||
uid2Transition.set(arr.uid, transition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const state of uid2State.values()) {
|
||||
if (state.kind === "or") {
|
||||
if (state.initial.length > 1) {
|
||||
errorShapes.push(...state.initial.map(([uid,childState])=>[uid,"multiple initial states"] as [string, string]));
|
||||
}
|
||||
else if (state.initial.length === 0) {
|
||||
errorShapes.push([state.uid, "no initial state"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let variables = new Set<string>();
|
||||
const inputEvents = new Set<string>();
|
||||
const outputEvents = new Set<string>();
|
||||
const internalEvents = new Set<string>();
|
||||
|
||||
// step 3: figure out labels
|
||||
|
||||
const textsSorted = state.texts.toSorted((a,b) => a.topLeft.y - b.topLeft.y);
|
||||
for (const text of textsSorted) {
|
||||
let parsed: ParsedText;
|
||||
try {
|
||||
parsed = parseLabel(text.text); // may throw
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
errorShapes.push([text.uid, e]);
|
||||
continue;
|
||||
}
|
||||
else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
parsed.uid = text.uid;
|
||||
if (parsed.kind === "transitionLabel") {
|
||||
const belongsToArrow = findNearestArrow(text.topLeft, state.arrows);
|
||||
if (belongsToArrow) {
|
||||
const belongsToTransition = uid2Transition.get(belongsToArrow.uid);
|
||||
if (belongsToTransition) {
|
||||
// parse as transition label
|
||||
belongsToTransition.label.push(parsed);
|
||||
// collect events
|
||||
if (parsed.trigger.kind === "event") {
|
||||
const {event} = parsed.trigger;
|
||||
if (event.startsWith("_")) {
|
||||
internalEvents.add(event);
|
||||
}
|
||||
else {
|
||||
inputEvents.add(event);
|
||||
}
|
||||
}
|
||||
else if (parsed.trigger.kind === "after") {
|
||||
belongsToTransition.src.timers.push(parsed.trigger.durationMs);
|
||||
belongsToTransition.src.timers.sort();
|
||||
}
|
||||
for (const action of parsed.actions) {
|
||||
if (action.kind === "raise") {
|
||||
const {event} = action;
|
||||
if (event.startsWith("_")) {
|
||||
internalEvents.add(event);
|
||||
}
|
||||
else {
|
||||
outputEvents.add(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
// collect variables
|
||||
variables = variables
|
||||
.union(findVariables(parsed.guard));
|
||||
for (const action of parsed.actions) {
|
||||
variables = variables.union(findVariablesAction(action));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// text does not belong to transition...
|
||||
// so it belongs to a rountangle (a state)
|
||||
const rountangle = findRountangle(text.topLeft, state.rountangles);
|
||||
const belongsToState = rountangle ? uid2State.get(rountangle.uid)! : root;
|
||||
if (parsed.kind === "transitionLabel") {
|
||||
// labels belonging to a rountangle (= a state) must by entry/exit actions
|
||||
// if we cannot find a containing state, then it belong to the root
|
||||
if (parsed.trigger.kind === "entry") {
|
||||
belongsToState.entryActions.push(...parsed.actions);
|
||||
}
|
||||
else if(parsed.trigger.kind === "exit") {
|
||||
belongsToState.exitActions.push(...parsed.actions);
|
||||
}
|
||||
else {
|
||||
errorShapes.push([text.uid, {
|
||||
message: "states can only have entry/exit triggers",
|
||||
location: {start: {offset: 0}, end: {offset: text.text.length}},
|
||||
}]);
|
||||
}
|
||||
|
||||
}
|
||||
else if (parsed.kind === "comment") {
|
||||
// just append comments to their respective states
|
||||
belongsToState.comments.push([text.uid, parsed.text]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const transition of uid2Transition.values()) {
|
||||
if (transition.label.length === 0) {
|
||||
errorShapes.push([transition.uid, "no label"]);
|
||||
}
|
||||
else if (transition.label.length > 1) {
|
||||
errorShapes.push([transition.uid, "multiple labels"]);
|
||||
}
|
||||
}
|
||||
|
||||
return [{
|
||||
root,
|
||||
transitions,
|
||||
variables,
|
||||
inputEvents,
|
||||
internalEvents,
|
||||
outputEvents,
|
||||
uid2State,
|
||||
}, errorShapes];
|
||||
}
|
||||
|
||||
function findVariables(expr: Expression): Set<string> {
|
||||
if (expr.kind === "ref") {
|
||||
return new Set([expr.variable]);
|
||||
}
|
||||
else if (expr.kind === "unaryExpr") {
|
||||
return findVariables(expr.expr);
|
||||
}
|
||||
else if (expr.kind === "binaryExpr") {
|
||||
return findVariables(expr.lhs).union(findVariables(expr.rhs));
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function findVariablesAction(action: Action): Set<string> {
|
||||
if (action.kind === "assignment") {
|
||||
return new Set([action.lhs, ...findVariables(action.rhs)]);
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
39
src/statecharts/runtime_types.ts
Normal file
39
src/statecharts/runtime_types.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
export type Timestamp = number; // milliseconds since begin of simulation
|
||||
export type Event = string;
|
||||
|
||||
export type Mode = Set<string>; // set of active states
|
||||
|
||||
|
||||
export type Environment = ReadonlyMap<string, any>; // variable name -> value
|
||||
|
||||
export type RT_Statechart = {
|
||||
mode: Mode;
|
||||
environment: Environment;
|
||||
// history: // TODO
|
||||
}
|
||||
|
||||
export type BigStepOutput = RT_Statechart & {
|
||||
outputEvents: string[],
|
||||
};
|
||||
|
||||
export type BigStep = {
|
||||
inputEvent: string | null, // null if initialization
|
||||
simtime: number,
|
||||
} & BigStepOutput;
|
||||
|
||||
|
||||
export type RaisedEvents = {
|
||||
internalEvents: string[];
|
||||
outputEvents: string[];
|
||||
};
|
||||
|
||||
// export type Timers = Map<string, number>; // transition uid -> timestamp
|
||||
|
||||
export const initialRaised: RaisedEvents = {
|
||||
internalEvents: [],
|
||||
outputEvents: [],
|
||||
};
|
||||
|
||||
export type TimerElapseEvent = { state: string; timeDurMs: number; };
|
||||
export type Timers = [number, TimerElapseEvent][];
|
||||
|
||||
77
src/statecharts/time.ts
Normal file
77
src/statecharts/time.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
|
||||
export type TimeMode = TimePaused | TimeRealTime;
|
||||
|
||||
// When the simulation is paused, we only need to know the current simulated time.
|
||||
export type TimePaused = {
|
||||
kind: "paused",
|
||||
simtime: number, // the current simulated time
|
||||
}
|
||||
|
||||
// When the simulation is running in real time, we need to know the time when the simulation was set to real time (both in simulated and wall-clock time), and the time scale. This allows us to compute the simulated time of every future event.
|
||||
// Such a 'future event' may be:
|
||||
// - raising an input event
|
||||
// - changing of the time scale parameter
|
||||
// - pausing the simulation
|
||||
export type TimeRealTime = {
|
||||
kind: "realtime",
|
||||
since: {
|
||||
simtime: number, // the simulated time at which the time was set to realtime
|
||||
wallclktime: number, // the wall-clock time at which the time was set to realtime
|
||||
}
|
||||
scale: number, // time scale relative to wall-clock time
|
||||
}
|
||||
|
||||
// given a wall-clock time, how does it translate to simtime?
|
||||
export function getSimTime(currentMode: TimeMode, wallclktime: number): number {
|
||||
if (currentMode.kind === "paused") {
|
||||
return currentMode.simtime;
|
||||
}
|
||||
else {
|
||||
const elapsedWallclk = wallclktime - currentMode.since.wallclktime;
|
||||
return currentMode.since.simtime + currentMode.scale * elapsedWallclk;
|
||||
}
|
||||
}
|
||||
|
||||
// given a simulated real time clock, how long will it take in wall-clock time duration until 'simtime' is the current time?
|
||||
export function getWallClkDelay(realtime: TimeRealTime, simtime: number, wallclktime: number): number {
|
||||
const currentSimTime = getSimTime(realtime, wallclktime);
|
||||
const simtimeDelay = simtime - currentSimTime;
|
||||
return Math.max(0, simtimeDelay / realtime.scale);
|
||||
}
|
||||
|
||||
// given a current simulated clock (paused or real time), switch to real time with given time scale
|
||||
export function setRealtime(currentMode: TimeMode, scale: number, wallclktime: number): TimeRealTime {
|
||||
if (currentMode.kind === "paused") {
|
||||
return {
|
||||
kind: "realtime",
|
||||
scale,
|
||||
since: {
|
||||
simtime: currentMode.simtime,
|
||||
wallclktime,
|
||||
},
|
||||
};
|
||||
}
|
||||
else {
|
||||
return {
|
||||
kind: "realtime",
|
||||
scale,
|
||||
since: {
|
||||
simtime: getSimTime(currentMode, wallclktime),
|
||||
wallclktime,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// given a current simulated clock (paused or real time), switch to paused
|
||||
export function setPaused(currentMode: TimeMode, wallclktime: number): TimePaused {
|
||||
if (currentMode.kind === "paused") {
|
||||
return currentMode; // no change
|
||||
}
|
||||
else {
|
||||
return {
|
||||
kind: "paused",
|
||||
simtime: getSimTime(currentMode, wallclktime),
|
||||
};
|
||||
}
|
||||
}
|
||||
128
src/statecharts/transition_label.grammar
Normal file
128
src/statecharts/transition_label.grammar
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
start = tlabel / comment
|
||||
|
||||
tlabel = _ trigger:trigger _ guard:("[" _ guard _ "]")? _ actions:("/" _ actions )? _ {
|
||||
return {
|
||||
kind: "transitionLabel",
|
||||
trigger,
|
||||
guard: guard ? guard[2] : {kind: "literal", value: true},
|
||||
actions: actions ? actions[2] : [],
|
||||
};
|
||||
}
|
||||
|
||||
trigger = afterTrigger / entryTrigger / exitTrigger / eventTrigger
|
||||
|
||||
eventTrigger = event:identifier {
|
||||
return {kind: "event", event};
|
||||
}
|
||||
|
||||
afterTrigger = "after" _ dur:durationMs {
|
||||
return {kind: "after", durationMs: dur};
|
||||
}
|
||||
|
||||
entryTrigger = "entry" {
|
||||
return {kind: "entry"};
|
||||
}
|
||||
|
||||
exitTrigger = "exit" {
|
||||
return {kind: "exit"};
|
||||
}
|
||||
|
||||
|
||||
durationMs = num:number _ u:timeUnit {
|
||||
return num * (u === "s" ? 1000 : 1);
|
||||
}
|
||||
|
||||
timeUnit = "ms" / "s" {
|
||||
return text();
|
||||
}
|
||||
|
||||
guard = expr
|
||||
|
||||
actions = head:action tail:(_ ";" _ action)* _ ";"? {
|
||||
return [head, ...tail.map(t => t[3])];
|
||||
}
|
||||
action = assignment / raise
|
||||
|
||||
assignment = lhs:identifier _ "=" _ rhs:expr {
|
||||
return {kind: "assignment", lhs, rhs};
|
||||
}
|
||||
|
||||
identifier = ("_" / [a-zA-Z0-9])+ {
|
||||
return text();
|
||||
}
|
||||
|
||||
number = [0-9]+ {
|
||||
return parseInt(text());
|
||||
}
|
||||
|
||||
expr = compare
|
||||
|
||||
compare = sum:sum rest:((_ ("==" / "!=" / "<" / ">" / "<=" / ">=") _) compare)? {
|
||||
if (rest === null) {
|
||||
return sum;
|
||||
}
|
||||
return {
|
||||
kind: "binaryExpr",
|
||||
operator: rest[0][1],
|
||||
lhs: sum,
|
||||
rhs: rest[1],
|
||||
};
|
||||
}
|
||||
|
||||
sum = prod:product rest:((_ ("+" / "-") _) sum)? {
|
||||
if (rest === null) {
|
||||
return prod;
|
||||
}
|
||||
return {
|
||||
kind: "binaryExpr",
|
||||
operator: rest[0][1],
|
||||
lhs: prod,
|
||||
rhs: rest[1],
|
||||
};
|
||||
}
|
||||
|
||||
product = atom:atom rest:((_ ("*" / "/") _) product)? {
|
||||
if (rest === null) {
|
||||
return atom;
|
||||
}
|
||||
return {
|
||||
kind: "binaryExpr",
|
||||
operator: rest[0][1],
|
||||
lhs: atom,
|
||||
rhs: rest[1],
|
||||
};
|
||||
}
|
||||
|
||||
atom = nested / literal / ref
|
||||
|
||||
nested = "(" _ expr:expr _ ")" {
|
||||
return expr;
|
||||
}
|
||||
|
||||
literal = value:(number / boolean) {
|
||||
return {kind: "literal", value}
|
||||
}
|
||||
|
||||
ref = variable:identifier {
|
||||
return {kind: "ref", variable}
|
||||
}
|
||||
|
||||
boolean = ("true" / "false") {
|
||||
return text() === "true";
|
||||
}
|
||||
|
||||
raise = "^" _ event:identifier {
|
||||
return {kind: "raise", event};
|
||||
}
|
||||
|
||||
_ "whitespace"
|
||||
= (comment / [ \t\n\r])*
|
||||
{ return null; }
|
||||
|
||||
comment = "//" _ text:.* _ ('\n' / !.) {
|
||||
return {
|
||||
kind: "comment",
|
||||
text: text.join(''),
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue