Skip to main content

Overview

The Veydra Model Standard (VMS) is a set of conventions for building system dynamics models that are both executable and analyzable. VMS-compliant models enable automatic generation of stock-and-flow diagrams, causal loop diagrams, and other analytical visualizations through AST (Abstract Syntax Tree) parsing.

Executable

Models run in Python with numerical simulation via SciPy

Analyzable

AST parsing extracts structure for automatic diagram generation

Modular

Independent submodels can be composed into larger systems

Traceable

Explicit dependencies enable causal loop analysis

Core Architecture

VMS models consist of:
  • Submodels: Independent modules that inherit from Submodel base class
  • VARIABLES Dictionary: Module-level declaration of all model variables
  • SimulationContext: Runtime context for accessing stocks, parameters, and intervention gating
  • Calculation Functions: Module-level functions for complex computations
  • Multi-Scenario Execution: run_multi_scenario() runs baseline + N scenarios in a single call
  • Output Helpers: Summary tables, stacked tables, and time-window filtering
from veydra_model_standard import (
    Submodel,
    VeydraModelStandard,
    SimulationContext,
    META_KEYS,
    compute_summary_stats,
    build_summary_table,
    build_stacked_table,
    apply_time_window,
)
import numpy as np

Submodel Structure

Every submodel follows this structure:
"""Module docstring describing the submodel's purpose."""

from veydra_model_standard import Submodel
import numpy as np

# 1. VMS Calculation Functions (optional, for complex logic)
def calc_something(input1, input2):
    """Docstring describing the calculation."""
    return input1 * input2

# 2. VARIABLES dictionary (REQUIRED - module level)
VARIABLES = {
    'namespace.s_stock_name': {...},
    'namespace.parameter_name': {...},
}

# 3. Submodel class
class MySubmodel(Submodel):
    """Class docstring."""
    
    def calculate_derivatives_and_flows(self, sim_context):
        # Implementation
        return derivatives, flows
Required Rules:
  • Do NOT override __init__ method
  • Do NOT override get_variables() method
  • VARIABLES dict MUST be at module level
  • Only implement calculate_derivatives_and_flows()

VARIABLES Dictionary

The VARIABLES dictionary declares all model variables at module level.

Stock Definition

'budget.s_government_budget': {
    'name': 'Government Budget',           # Display name
    'units': 'dollars',                    # Unit of measurement
    'description': 'Accumulated fund',     # Plain text description
    'default': 100000.0,                   # Initial value (THIS IS THE INITIAL VALUE)
    'min': -1000000.0,                     # Minimum allowed
    'max': 10000000.0,                     # Maximum allowed
    'type': 'slider',                      # UI control type
    'category': 'stock'                    # MUST be 'stock'
}

Parameter Definition

'budget.tax_rate': {
    'name': 'Tax Rate',
    'units': 'dimensionless',
    'description': 'Fraction collected as tax',
    'default': 0.02,
    'min': 0.0,
    'max': 0.15,
    'step': 0.005,                         # Step size for UI
    'type': 'slider',
    'category': 'parameter'                # MUST be 'parameter'
}

Naming Conventions

TypePrefix/SuffixExample
Stocks_ prefixbudget.s_government_budget
Parameternonebudget.tax_rate
Flow_flow suffixbudget.tax_revenue_flow
Critical Rule: The default field in a stock definition IS the initial value. Never create separate initial_* parameters.
# ❌ WRONG - Don't do this
'population.initial_population': {'default': 1000000, ...}  # FORBIDDEN

# ✅ CORRECT - Stock default IS the initial value
'population.s_population': {'default': 1000000, ...}  # default = initial value

Stock-and-Flow Modeling

Separate Flows by Direction

Each parameter affecting a stock must have its own distinct flow. Never collapse flows.
# Separate flows preserve directionality
deposit_flow = periodic_deposit_amount
withdrawal_flow = periodic_withdrawal_amount
interest_flow = balance * interest_rate

d_balance_dt = deposit_flow - withdrawal_flow + interest_flow

flows = {
    'account.deposit_flow': deposit_flow,
    'account.withdrawal_flow': withdrawal_flow,
    'account.interest_flow': interest_flow
}

Why This Matters

  • Individual flows enable proper stock-and-flow diagram generation
  • Collapsed flows obscure causal relationships
  • AST parser cannot trace through intermediate “net” variables

Direct Flow Reference Rule

Derivatives MUST directly reference the same variable names that appear in the flows dictionary. This enables the AST parser to trace stock-flow relationships for automatic diagram generation.
# Step 1: Define flow variables
government_tax_revenue_flow = tax_revenue
government_spending_flow = spending

# Step 2: Derivative DIRECTLY references flow variable names
d_government_budget_dt = government_tax_revenue_flow - government_spending_flow

# Step 3: flows dict uses the SAME variable names
flows = {
    'budget.government_tax_revenue_flow': government_tax_revenue_flow,  # ✅ Same name
    'budget.government_spending_flow': government_spending_flow         # ✅ Same name
}
Common Mistakes:
  • Using different variable names in derivative vs flows dict
  • Using intermediate “net” variables that hide individual flows

Calculation Function Convention

For complex intermediate calculations (conditionals, aggregations, multi-step computations), define module-level calculation functions where ALL dependencies are explicit in the function signature.

Why Use Calculation Functions

The AST parser extracts all function call arguments as dependencies. This makes complex logic fully traceable.
# AST parser sees: pool_frac_ill depends on all 5 arguments
pool_frac_ill = calc_pool_frac_ill(insured_pool, insured_healthy, frac_ill_healthy, insured_sickly, frac_ill_sickly)

Pattern

# Module-level calculation function with explicit dependencies
def calc_pool_frac_ill(insured_pool, insured_healthy, frac_ill_healthy, insured_sickly, frac_ill_sickly):
    """Calculate weighted average fraction ill across insured pool."""
    if insured_pool > 1.0:
        return (insured_healthy * frac_ill_healthy + insured_sickly * frac_ill_sickly) / insured_pool
    return frac_ill_healthy

# In calculate_derivatives_and_flows:
pool_frac_ill = calc_pool_frac_ill(insured_pool, insured_healthy, frac_ill_healthy, 
                                    insured_sickly, frac_ill_sickly)

Function Naming Convention

  • Use calc_ prefix: calc_pool_frac_ill, calc_total_income, calc_tax_revenue
  • Name should describe what is being calculated
  • Keep signatures to 6 or fewer parameters when possible

When to Use Calculation Functions

Use CaseExample
Conditional logicif pool > 0: ... else: ...
Weighted averages(a * weight_a + b * weight_b) / total
Multi-step computationsIntermediate results needed
Complex aggregationsSummations with conditions

Derivative Patterns

Dictionary Format (Required)

Always return derivatives as a dictionary mapping stock names to their rates of change:
derivatives = {
    'budget.s_government_budget': d_government_budget_dt,
    'budget.s_private_budget': d_private_budget_dt
}
Never use array format - it’s unclear which stock is which:
# ❌ WRONG
derivatives = [d_government_budget_dt, d_private_budget_dt]

Derivative Expression Pattern

Build derivatives from individual flows with explicit addition/subtraction:
# ✅ CORRECT - Explicit inflow/outflow pattern
d_stock_dt = inflow1 + inflow2 - outflow1 - outflow2

Accessing Stocks and Parameters

# ✅ CORRECT - Use getter methods
stock_value = sim_context.get_stock('namespace.s_stock_name', default_value)
param_value = sim_context.get_param('namespace.param_name', default_value)

# ❌ WRONG - Direct attribute access
stock_value = sim_context.stocks['namespace.s_stock_name']  # Don't do this
param_value = sim_context.params['namespace.param_name']    # Don't do this

AST Traceability

The VMS conventions enable automatic AST parsing for:
1

Stock-Flow Diagram Generation

Automatically creates visual diagrams showing stocks, flows, and their connections
2

Causal Loop Analysis

Traces parameter influences through the model to identify feedback loops
3

Dependency Extraction

Identifies all variable dependencies for each calculation

How AST Parsing Works

  1. Shallow Extraction: Parser extracts direct variable references from expressions
  2. Function Arguments: All arguments to calc_* functions are extracted as dependencies
  3. Flow Dictionary Keys: Flow names from the flows dict are matched to derivative expressions
ConventionEnables
Direct Flow ReferenceStock-to-flow connections in diagrams
Calculation FunctionsComplete dependency graphs
Separate FlowsIndividual causal links
VARIABLES dictParameter metadata extraction

Complete Example

Here’s a complete VMS-compliant submodel:
"""Budget submodel: government and private sector funding dynamics."""

from veydra_model_standard import Submodel
import numpy as np


# ═══════════════════════════════════════════════════════════════════════════════
# VMS CALCULATION FUNCTIONS - explicit dependency signatures for AST traceability
# ═══════════════════════════════════════════════════════════════════════════════

def calc_tax_revenue(total_income, tax_rate):
    """Calculate government tax revenue from total income."""
    return total_income * tax_rate


def calc_spending(investment_spending, split_fraction):
    """Calculate spending portion based on split."""
    return investment_spending * split_fraction


# ═══════════════════════════════════════════════════════════════════════════════
# VARIABLES - module-level declaration (stock defaults ARE initial values)
# ═══════════════════════════════════════════════════════════════════════════════

VARIABLES = {
    # Stocks
    'budget.s_government_budget': {
        'name': 'Government Budget',
        'units': 'dollars',
        'description': 'Accumulated government fund',
        'default': 100000.0,  # THIS IS THE INITIAL VALUE
        'min': -1000000.0,
        'max': 10000000.0,
        'type': 'slider',
        'category': 'stock'
    },
    
    # Parameters
    'budget.tax_rate': {
        'name': 'Tax Rate',
        'units': 'dimensionless',
        'description': 'Fraction collected as healthcare tax',
        'default': 0.02,
        'min': 0.0,
        'max': 0.15,
        'step': 0.005,
        'type': 'slider',
        'category': 'parameter'
    }
}


class BudgetSubmodel(Submodel):
    """Government budget dynamics with tax revenue and spending."""

    def calculate_derivatives_and_flows(self, sim_context):
        # ── Read stocks ───────────────────────────────────────────────────
        government_budget = sim_context.get_stock('budget.s_government_budget', 100000.0)

        # ── Read parameters ───────────────────────────────────────────────
        tax_rate = sim_context.get_param('budget.tax_rate', 0.02)
        
        # ── Read cross-submodel values ────────────────────────────────────
        total_income = sim_context.get_param('economy.total_income', 1000000.0)
        investment_spending = sim_context.get_param('capacity.investment_spending', 50000.0)

        # ── VMS Calculation Functions ─────────────────────────────────────
        tax_revenue = calc_tax_revenue(total_income, tax_rate)
        government_spending = calc_spending(investment_spending, 0.5)

        # ── Define flow variables (for direct reference rule) ─────────────
        tax_revenue_flow = tax_revenue
        spending_flow = government_spending

        # ── Derivative (directly references flow variable names) ──────────
        d_government_budget_dt = tax_revenue_flow - spending_flow

        # ── Return dictionaries ───────────────────────────────────────────
        derivatives = {
            'budget.s_government_budget': d_government_budget_dt
        }

        flows = {
            'budget.tax_revenue_flow': tax_revenue_flow,    # Inflow
            'budget.spending_flow': spending_flow           # Outflow
        }

        return derivatives, flows

Multi-Scenario Execution

VeydraModelStandard.run_multi_scenario() runs one or many scenarios in a single call with shared simulation parameters and calibration.

Input Config Shape

config = {
    # ── Control ──────────────────────────────────────────────
    "include_baseline": True,           # Run a pure-defaults baseline (default: False)

    # ── Shared simulation params ─────────────────────────────
    "simulation.duration": 104,
    "simulation.intervention_start_time": 52,

    # ── Meta-keys (stripped before parameter resolution) ─────
    "__calibration_params__": {          # Pre-intervention overrides
        "retail.order_quantity": 600
    },
    "__initial_state__": {               # Stock overrides at t=0 (game-mode warm-start)
        "retail.s_inventory_on_hand": 500
    },
    "__time_window__": {                 # Filter output to a time range
        "start": 10, "end": 80
    },
    "__output_format__": "full",         # "full" | "summary" | "stacked" | "all"
    "__summary_stats__": ["final_value", "peak", "mean"],
    "__summary_variable__": "retail.s_inventory_on_hand",

    # ── Scenario list ────────────────────────────────────────
    "scenarios": [
        {"id": "high_qty", "params": {"retail.order_quantity": 1000}},
        {"id": "low_qty",  "params": {"retail.order_quantity": 200}},
    ]
}

result = model.run_multi_scenario(config)

How It Works

1

Extract meta-keys

include_baseline, __calibration_params__, __initial_state__, __time_window__, __output_format__, __summary_stats__, __summary_variable__, and scenarios are pulled out of the config before parameter resolution. They never reach the ODE solver.
2

Run baseline (if requested)

When include_baseline is true, a run is executed with only the shared simulation params plus calibration values. No intervention gating is applied (there are no scenario overrides to gate).
3

Run each scenario

For every entry in scenarios, the simulation params are merged with that scenario’s params. If simulation.intervention_start_time is set, the scenario override keys are gated so they only take effect after that time (see Intervention Gating below).
4

Format output

Results are assembled in the requested __output_format__ and returned.

Implicit Current Scenario

If no scenarios list is provided but there are non-simulation override keys in the config, they are automatically bundled into a single scenario with id "current":
# These two calls are equivalent:
model.run_multi_scenario({"retail.order_quantity": 800})
model.run_multi_scenario({"scenarios": [{"id": "current", "params": {"retail.order_quantity": 800}}]})

Auto-Detection in run_interactive_simulation

run_interactive_simulation() automatically routes to run_multi_scenario() when any of these keys are present: scenarios, include_baseline, or __output_format__. Otherwise it falls through to a plain run_simulation() call for full backward compatibility.
# Single run (backward compatible)
run_interactive_simulation(model, {"retail.order_quantity": 800})

# Multi-scenario (auto-detected)
run_interactive_simulation(model, {
    "include_baseline": True,
    "scenarios": [{"id": "s1", "params": {"retail.order_quantity": 800}}]
})

Intervention Gating

SimulationContext.get_param() natively supports intervention gating. When a scenario is run with simulation.intervention_start_time set, scenario override parameters return their default (or calibration) value before the intervention time, and the scenario value after it. This eliminates the need for any external monkey-patching or runtime code injection.

Priority Order

get_param(key) resolves values in this order:
  1. Intervention gating - if current_time < intervention_start_time and key is a scenario override, return calibration value (or model default)
  2. Dynamic parameters - if the value is a __dynamic__ spec, resolve it to a scalar at current_time
  3. Plain scalar - return the value from all_params directly
# SimulationContext is constructed with gating context automatically
# by the orchestrator during run_multi_scenario(). Submodels don't need
# to do anything special — sim_context.get_param() handles it.

# Before intervention_start_time:
sim_context.get_param('retail.order_quantity')  # → calibration value or default

# After intervention_start_time:
sim_context.get_param('retail.order_quantity')  # → scenario override value

SimulationContext Constructor

SimulationContext(
    current_time=t,
    current_datetime=current_datetime,
    all_params=params,
    all_stocks=stocks,
    intervention_active=False,
    # Optional gating fields (set automatically by run_multi_scenario):
    intervention_start_time=52.0,
    scenario_param_keys={'retail.order_quantity'},
    calibration_params={'retail.order_quantity': 600},
    param_defaults={'retail.order_quantity': 500},
)
Submodel code does not change at all. The gating is handled entirely inside get_param(), so existing sim_context.get_param('retail.order_quantity') calls work with or without gating enabled.

Dynamic (Time-Varying) Parameters

Parameters can be time-varying instead of scalar. Dynamic parameters are dicts with __dynamic__: true and are resolved to a scalar at current_time by SimulationContext.get_param().

Modes

ModeDescriptionRequired Fields
functionEvaluates a math expression at each time stepexpression (string)
arrayLinearly interpolates a lookup tablepoints (list of [time, value])
drawnInterpolates user-drawn curve pointspoints (list of [time, value])

Example

params = {
    # Static parameter
    'retail.order_quantity': 500,

    # Dynamic parameter — function mode
    'retail.demand_rate': {
        '__dynamic__': True,
        'mode': 'function',
        'expression': 'sin(t * 0.1) * 50 + 200',
        'defaultValue': 200.0
    },

    # Dynamic parameter — array mode
    'retail.seasonal_factor': {
        '__dynamic__': True,
        'mode': 'array',
        'points': [[0, 1.0], [26, 1.5], [52, 1.0], [78, 0.8], [104, 1.0]],
        'defaultValue': 1.0
    }
}
Available math functions in expressions: sin, cos, tan, exp, log, sqrt, abs, min, max, pow, floor, ceil, pi, e. The variable t represents current_time.
Dynamic parameter specs are preserved through _clean_params() and _resolve_parameters() — they bypass scalar validation (min/max clamping) since their value changes over time.

Output Formats

The __output_format__ meta-key controls how run_multi_scenario() returns results.

"full" (default)

Returns the complete time-series for every scenario:
{
    "success": True,
    "scenarios": {
        "baseline": {"success": True, "time": [...], "stocks": {...}, "flows": {...}},
        "high_qty": {"success": True, "time": [...], "stocks": {...}, "flows": {...}}
    }
}

"summary"

Returns a compact table with one row per scenario and configurable summary statistics:
{
    "success": True,
    "summary": {
        "columns": ["scenario_id", "final_value", "peak", "min", "mean", "growth_pct"],
        "rows": [
            ["baseline", 450.2, 520.1, 380.0, 455.3, 12.5],
            ["high_qty", 680.9, 710.0, 400.0, 590.1, 36.2]
        ],
        "primary_stock": "retail.s_inventory_on_hand"
    }
}
Customize with __summary_stats__ (list of stat names) and __summary_variable__ (which stock to summarize). Available stats: final_value, peak, min, mean, growth_pct, variance, time_to_peak

"stacked"

Returns a long-format table suitable for charting in Google Sheets:
{
    "success": True,
    "stacked": {
        "columns": ["scenario_id", "time", "variable", "value"],
        "rows": [
            ["baseline", 0, "s_inventory_on_hand", 500.0],
            ["baseline", 0, "s_inventory_on_order", 0.0],
            ["baseline", 1, "s_inventory_on_hand", 485.3],
            ...
        ]
    }
}
Variable names use shorthand (last segment after .) for readability.

"all"

Returns all three formats in a single response: scenarios, summary, and stacked.

Meta-Keys Reference

Meta-keys are special config keys that control execution behavior. They are stripped from the parameter dict before the ODE solver sees them.
KeyTypeDefaultDescription
include_baselineboolfalseRun a pure-defaults baseline alongside scenarios
scenarioslist[]List of {id, params} scenario definitions
__calibration_params__dict{}Parameter overrides applied to baseline and used as pre-intervention defaults
__initial_state__dict{}Stock value overrides at t=0 (warm-start / game-mode)
__time_window__dictnull{start, end} to filter output time range
__output_format__string"full""full", "summary", "stacked", or "all"
__summary_stats__listdefault setWhich statistics to compute in summary mode
__summary_variable__stringfirst stockWhich stock to summarize
Meta-keys must never collide with actual model parameter names. They are defined in META_KEYS frozenset and automatically excluded from _clean_params().

Initial State and Game-Mode Stepping

The __initial_state__ meta-key overrides stock values at t=0, enabling two use cases:

Warm-Start

Resume a simulation from a previously saved state:
result = model.run_simulation({
    'simulation.duration': 52,
    '__initial_state__': {
        'retail.s_inventory_on_hand': 450.0,
        'retail.s_inventory_on_order': 120.0,
    }
})

Game-Mode Stepping

Run the simulation one step at a time (e.g., one week per turn). Each step feeds the final state of the previous step as __initial_state__ for the next:
state = {}  # Start with defaults
step_size = 1  # 1 week per turn

for week in range(52):
    result = model.run_simulation({
        'simulation.duration': step_size,
        '__initial_state__': state,
        # Player can change params each turn
        'retail.order_quantity': player_decision,
    })

    # Capture final stock values as next initial state
    state = {
        stock: values[-1]
        for stock, values in result['stocks'].items()
    }
In the frontend, game-mode stepping runs client-side via Pyodide (not server-side). The __initial_state__ dict is passed in each step call.

Calibration Parameters

__calibration_params__ are shared across all scenarios in a run_multi_scenario() call. They serve two purposes:
  1. Baseline modification: Applied directly to the baseline run as regular parameter overrides
  2. Pre-intervention defaults: For scenario runs with intervention gating, calibration values are used as the pre-intervention value (taking priority over model defaults)
result = model.run_multi_scenario({
    'include_baseline': True,
    'simulation.duration': 104,
    'simulation.intervention_start_time': 52,
    '__calibration_params__': {
        'retail.order_quantity': 600,  # Calibrated to real-world data
    },
    'scenarios': [
        {'id': 'high', 'params': {'retail.order_quantity': 1000}},
    ]
})

# baseline: order_quantity = 600 for the entire run
# high scenario:
#   t < 52: order_quantity = 600 (calibration value, via intervention gating)
#   t >= 52: order_quantity = 1000 (scenario override kicks in)

Helper Functions

compute_summary_stats

Compute named statistics for a single time-series:
from veydra_model_standard import compute_summary_stats

stats = compute_summary_stats([10, 20, 30, 40, 50], ['final_value', 'mean', 'growth_pct'])
# {'final_value': 50.0, 'mean': 30.0, 'growth_pct': 400.0}

build_summary_table

One row per scenario with configurable stats:
from veydra_model_standard import build_summary_table

table = build_summary_table(scenario_results, 'retail.s_inventory_on_hand')
# {'columns': ['scenario_id', 'final_value', ...], 'rows': [...], 'primary_stock': '...'}

build_stacked_table

Long-format table for all scenarios (suitable for Google Sheets charting):
from veydra_model_standard import build_stacked_table

table = build_stacked_table(scenario_results)
# {'columns': ['scenario_id', 'time', 'variable', 'value'], 'rows': [...]}

apply_time_window

Filter a single-run result dict to a time range:
from veydra_model_standard import apply_time_window

filtered = apply_time_window(result, {'start': 10, 'end': 80})

Quick Reference Checklist

When writing VMS-compliant models, verify: