diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 8448da7..3be58a9 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -65,6 +65,8 @@ jobs: - name: Install dependencies run: | uv sync --all-extras --dev + source .venv/bin/activate + python -m ipykernel install --user --name lynx - name: Build HTML documentation run: | @@ -84,13 +86,8 @@ jobs: with: path: docs/_build/html - # DEPLOYMENT DISABLED: Repository is currently private - # To enable deployment when ready: - # 1. Make repository public (Settings → Danger Zone → Change visibility) - # 2. Enable GitHub Pages (Settings → Pages → Source: "GitHub Actions") - # 3. Change the condition below to: if: github.ref == 'refs/heads/main' && github.event_name == 'push' deploy: - if: false # DISABLED - Remove this line to enable deployment + if: github.ref == 'refs/heads/main' && github.event_name == 'push' needs: build runs-on: ubuntu-latest environment: diff --git a/.gitignore b/.gitignore index b8fd023..5e9ca03 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,20 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +# Frontend build output +js/dist/ +js/build/ +src/lynx/static/ + +# Sphinx documentation +docs/_build/ +docs/source/api/generated/ +docs/source/.jupyter_cache/ +docs/source/**/.ipynb_checkpoints/ +docs/source/**/_plots/ +*.doctree + + # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] @@ -72,13 +86,6 @@ instance/ # Scrapy stuff: .scrapy -# Sphinx documentation -docs/_build/ -docs/source/api/generated/ -docs/source/.jupyter_cache/ -docs/source/**/.ipynb_checkpoints/ -*.doctree - # PyBuilder .pybuilder/ target/ @@ -231,11 +238,6 @@ pnpm-debug.log* .pnpm-store/ *.tsbuildinfo -# Frontend build output -js/dist/ -js/build/ -src/lynx/static/ - # IDE .vscode/ .idea/ diff --git a/CLAUDE.md b/CLAUDE.md index 45e99cd..1f109e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -225,6 +225,7 @@ When tests are included, they follow the same user story organization. - TypeScript 5.9 (frontend), Python 3.11+ (backend) + React 19.2.3, React Flow 11.11.4, anywidget, KaTeX 0.16.27, Pydantic (014-iomarker-latex-rendering) - JSON diagram files (existing persistence via Pydantic schemas) (014-iomarker-latex-rendering) - TypeScript 5.9 (frontend), Python 3.11+ (backend) + React 19.2.3, React Flow 11.11.4, anywidget (Jupyter widget framework), Pydantic (schema validation) (015-block-drag-detection) +- Python 3.11+ + Pydantic 2.12+ (existing schema validation), python-control 0.10+ (existing) (017-diagram-label-indexing) ## Key Components @@ -605,6 +606,7 @@ blocks/ - `js/src/test/` - Test configuration and setup files ## Recent Changes +- 017-diagram-label-indexing: Added Python 3.11+ + Pydantic 2.12+ (existing schema validation), python-control 0.10+ (existing) - **015-block-drag-detection**: Intelligent drag detection with 5-pixel movement threshold - Click-to-select (< 5px movement) vs drag-to-move (≥ 5px movement) behavior - Uses React Flow 11.11.4's `nodeDragThreshold={5}` prop for automatic click/drag separation @@ -628,7 +630,6 @@ blocks/ - 14 backend tests (5 auto-indexing + 6 renumbering + 3 integration), 13 frontend tests (6 parameter editor + 7 block including performance) - 95% coverage for io_marker.py, renumbering methods fully covered in diagram.py - TDD approach (RED-GREEN-REFACTOR) throughout implementation with strict test-first discipline -- 013-editable-block-labels: Added TypeScript 5.9 (frontend), Python 3.11+ (backend) + React 19.2.3, React Flow 11.11.4, anywidget (Jupyter widget framework), Pydantic (schema validation) - Added `diagram.get_ss(from_signal, to_signal)` and `diagram.get_tf(from_signal, to_signal)` API - 3-tier signal reference system (IOMarker labels → connection labels → block_label.output_port) - Break-and-inject architecture for subsystem extraction preserving diagram immutability diff --git a/dev/DEV.md b/dev/DEV.md index 349a9d1..98ae934 100644 --- a/dev/DEV.md +++ b/dev/DEV.md @@ -83,7 +83,9 @@ Configuration is in `pyproject.toml` under `[tool.ruff]`. ### Frontend formatting ```bash -cd js && npx prettier --write . +cd js +npx prettier --write . +npm run lint ``` ## Type Checking diff --git a/docs/source/_static/android-chrome-192x192.png b/docs/source/_static/android-chrome-192x192.png new file mode 100644 index 0000000..f44a4eb --- /dev/null +++ b/docs/source/_static/android-chrome-192x192.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3f585b77931e6b1c95980599e951e5925f9c53268aefcd42d48a2eba14a73a9 +size 45799 diff --git a/docs/source/_static/android-chrome-512x512.png b/docs/source/_static/android-chrome-512x512.png new file mode 100644 index 0000000..846de7a --- /dev/null +++ b/docs/source/_static/android-chrome-512x512.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:edad8ff5fd7c1451bc18f5e9952e746d2f41616aa4af48783e1694c20ff36ee0 +size 240324 diff --git a/docs/source/_static/apple-touch-icon.png b/docs/source/_static/apple-touch-icon.png new file mode 100644 index 0000000..00828b7 --- /dev/null +++ b/docs/source/_static/apple-touch-icon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8edcd3962f5b020394a5a0a24b5234ab0fc7bc563befc137451a151e24880c00 +size 41003 diff --git a/docs/source/_static/custom.css b/docs/source/_static/custom.css index a9a3aa6..a8aea78 100644 --- a/docs/source/_static/custom.css +++ b/docs/source/_static/custom.css @@ -38,6 +38,24 @@ --color-admonition-background: rgba(130, 151, 248, 0.15) !important; } +/* Fix for auto theme mode when system prefers dark */ +@media (prefers-color-scheme: dark) { + body:not([data-theme="light"]) { + --color-background-primary: #1f2937 !important; + --color-background-secondary: #111827 !important; + --color-background: #1a1f2e !important; + --color-foreground-primary: #f9fafb !important; + --color-foreground-secondary: #d1d5db !important; + + /* Code blocks in dark mode */ + --color-code-background: #2d3748 !important; + --color-code-foreground: #f9fafb !important; + + /* Admonitions in dark mode */ + --color-admonition-background: rgba(130, 151, 248, 0.15) !important; + } +} + /* Font family overrides */ .sidebar-brand-text, h1, h2, h3, h4, h5, h6 { font-family: 'Roboto Slab', Georgia, serif !important; @@ -73,6 +91,17 @@ body { background-attachment: fixed; } +/* Auto theme mode - dark grid background */ +@media (prefers-color-scheme: dark) { + body:not([data-theme="light"]) { + background-image: + linear-gradient(rgba(129, 140, 248, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(129, 140, 248, 0.05) 1px, transparent 1px); + background-size: 20px 20px; + background-attachment: fixed; + } +} + /* Logo styling in sidebar */ .sidebar-brand-container { display: flex; @@ -86,6 +115,29 @@ body { margin-right: 16px; } +/* Remove the "documentation" text */ +.sidebar-brand-text::after { + content: "" !important; +} + +/* Hide specific titles */ +.hidden-title { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0,0,0,0); + border: 0; +} + +/* Reduce spacing for h1 elements that only contain hidden titles */ +h1:has(.hidden-title) { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + /* Terminal-style code blocks with enhanced styling */ .highlight { position: relative; @@ -99,11 +151,12 @@ body { [data-theme="dark"] .highlight { border-color: var(--color-border-tech); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + background-color: var(--color-bg-code); } /* Terminal header bar - dynamic based on language */ .highlight::before { - content: '▸ code'; + /* content: '▸ code'; */ display: block; padding: 0.5rem 1rem; background: linear-gradient(180deg, #f9fafb 0%, #f3f4f6 100%); @@ -239,6 +292,18 @@ body { display: none; } +/* Fix: Ensure code blocks in notebook cells have dark background in dark mode */ +[data-theme="dark"] .cell_input .highlight-ipython3 pre, +[data-theme="dark"] .cell_input .highlight pre, +[data-theme="dark"] .cell_input .highlight { + background-color: var(--color-bg-code) !important; +} + +[data-theme="dark"] .cell_input .highlight-ipython3, +[data-theme="dark"] .highlight-ipython3 .highlight { + background-color: var(--color-bg-code) !important; +} + /* Cell output styling */ .cell_output { margin-top: 0.5rem; @@ -436,3 +501,162 @@ a.reference.external::after { border-radius: 6px; } } + +/* ============================================ + Auto Theme Mode Dark Styles + When body[data-theme="auto"] and system prefers dark + ============================================ */ +@media (prefers-color-scheme: dark) { + /* Code blocks and highlights */ + body:not([data-theme="light"]) .highlight { + border-color: var(--color-border-tech); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + background-color: var(--color-bg-code); + } + + body:not([data-theme="light"]) .highlight::before { + background: linear-gradient(180deg, #1f2937 0%, #111827 100%); + border-bottom-color: var(--color-border-tech); + color: #9ca3af; + } + + body:not([data-theme="light"]) .highlight pre { + background-color: var(--color-bg-code) !important; + } + + body:not([data-theme="light"]) pre { + background-color: var(--color-bg-code) !important; + border: 1px solid var(--color-border-tech); + } + + /* Syntax highlighting colors */ + body:not([data-theme="light"]) .highlight .s1 { color: #fca5a5; } + body:not([data-theme="light"]) .highlight .nn { color: #86efac; } + body:not([data-theme="light"]) .highlight .nb { color: #93c5fd; } + body:not([data-theme="light"]) .highlight .k { color: #a5b4fc; } + body:not([data-theme="light"]) .highlight .n { color: #f9fafb; } + body:not([data-theme="light"]) .highlight .mi { color: #fde047; } + body:not([data-theme="light"]) .highlight .kc { color: #f0abfc; } + + /* Function signatures */ + body:not([data-theme="light"]) .sig { + background-color: #2d3748 !important; + } + + /* Tables */ + body:not([data-theme="light"]) table.docutils { + border-color: #374151; + } + + body:not([data-theme="light"]) table.docutils thead { + background-color: #2d3748; + } + + body:not([data-theme="light"]) table.docutils tbody tr:nth-child(odd) { + background-color: #1f2937; + } + + body:not([data-theme="light"]) table.docutils tbody tr:nth-child(even) { + background-color: #2d3748; + } + + /* Theme images */ + body:not([data-theme="light"]) .only-light { + display: none; + } + + body:not([data-theme="light"]) .only-dark { + display: block; + } + + /* Notebook cells */ + body:not([data-theme="light"]) .cell_input { + border-left-color: var(--color-signal-input); + background-color: rgba(16, 185, 129, 0.05); + } + + body:not([data-theme="light"]) .cell_input .highlight-ipython3 pre, + body:not([data-theme="light"]) .cell_input .highlight pre, + body:not([data-theme="light"]) .cell_input .highlight { + background-color: var(--color-bg-code) !important; + } + + body:not([data-theme="light"]) .cell_input .highlight-ipython3, + body:not([data-theme="light"]) .highlight-ipython3 .highlight { + background-color: var(--color-bg-code) !important; + } + + body:not([data-theme="light"]) .cell_output { + background-color: rgba(245, 158, 11, 0.05); + } + + /* Inline code */ + body:not([data-theme="light"]) code.literal { + background-color: rgba(129, 140, 248, 0.15); + color: var(--lynx-indigo-bright); + border-color: rgba(129, 140, 248, 0.25); + } + + /* Function signatures */ + body:not([data-theme="light"]) .sig { + background: linear-gradient(135deg, #1f2937 0%, #111827 100%); + border-color: var(--color-border-tech); + border-left-color: var(--lynx-indigo-bright); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + } + + /* Admonitions */ + body:not([data-theme="light"]) .admonition.note, + body:not([data-theme="light"]) .admonition.seealso { + background-color: rgba(16, 185, 129, 0.1); + } + + body:not([data-theme="light"]) .admonition.warning, + body:not([data-theme="light"]) .admonition.attention, + body:not([data-theme="light"]) .admonition.caution { + background-color: rgba(245, 158, 11, 0.1); + } + + body:not([data-theme="light"]) .admonition.danger, + body:not([data-theme="light"]) .admonition.error { + background-color: rgba(239, 68, 68, 0.1); + } + + body:not([data-theme="light"]) .admonition.tip, + body:not([data-theme="light"]) .admonition.hint, + body:not([data-theme="light"]) .admonition.important { + background-color: rgba(129, 140, 248, 0.1); + } + + /* Cards */ + body:not([data-theme="light"]) .sd-card:hover { + box-shadow: 0 4px 16px rgba(130, 151, 248, 0.2); + } + + /* Scrollbars */ + body:not([data-theme="light"]) ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + body:not([data-theme="light"]) ::-webkit-scrollbar-track { + background: #1f2937; + } + + body:not([data-theme="light"]) ::-webkit-scrollbar-thumb { + background: #374151; + border-radius: 5px; + } + + body:not([data-theme="light"]) ::-webkit-scrollbar-thumb:hover { + background: #4b5563; + } + + /* Hero video */ + body:not([data-theme="light"]) .hero-video { + border-color: var(--color-border-tech); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + background-color: #1a1f2e; + } +} + diff --git a/docs/source/_static/favicon-16x16.png b/docs/source/_static/favicon-16x16.png new file mode 100644 index 0000000..c7b0145 --- /dev/null +++ b/docs/source/_static/favicon-16x16.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:19509b97d12547a592165d82d736f3cd0ccf09507352ee132cdc9a964cfc0f9f +size 776 diff --git a/docs/source/_static/favicon-32x32.png b/docs/source/_static/favicon-32x32.png new file mode 100644 index 0000000..2c4bdae --- /dev/null +++ b/docs/source/_static/favicon-32x32.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ed0dc0c40d4c92b93895943f1dcb39993cb28b66100634c3d745215865cb561 +size 2237 diff --git a/docs/source/_static/favicon.ico b/docs/source/_static/favicon.ico new file mode 100644 index 0000000..349e273 Binary files /dev/null and b/docs/source/_static/favicon.ico differ diff --git a/docs/source/_static/favicon.svg b/docs/source/_static/favicon.svg deleted file mode 100644 index d63a12d..0000000 --- a/docs/source/_static/favicon.svg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d25c9e3b71e1ec0fb23e5ff6fc7a262df7af6bcb87056bf887c2749ddd2671d6 -size 251 diff --git a/docs/source/_static/logo-dark.png b/docs/source/_static/logo-dark.png deleted file mode 100644 index a77d371..0000000 --- a/docs/source/_static/logo-dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e1fa3962ee3fd17d4d1b2f62154c397dc7658344c9ec2d06e8148c62aba6e253 -size 93848 diff --git a/docs/source/_static/logo-light.png b/docs/source/_static/logo-light.png deleted file mode 100644 index a77d371..0000000 --- a/docs/source/_static/logo-light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e1fa3962ee3fd17d4d1b2f62154c397dc7658344c9ec2d06e8148c62aba6e253 -size 93848 diff --git a/docs/source/_static/logo.png b/docs/source/_static/logo.png new file mode 100644 index 0000000..57eaad5 --- /dev/null +++ b/docs/source/_static/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7dcf54090377231509bec6b7b9d2317861b34a90c368c22ad72105f006686cc8 +size 490252 diff --git a/docs/source/_templates/autosummary/class.rst b/docs/source/_templates/autosummary/class.rst new file mode 100644 index 0000000..697411f --- /dev/null +++ b/docs/source/_templates/autosummary/class.rst @@ -0,0 +1,29 @@ +{{ fullname | escape | underline}} + +.. currentmodule:: {{ module }} + +.. autoclass:: {{ objname }} + :members: + :show-inheritance: + + {% block methods %} + {% if methods %} + .. rubric:: {{ _('Methods') }} + + .. autosummary:: + {% for item in methods %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block attributes %} + {% if attributes %} + .. rubric:: {{ _('Attributes') }} + + .. autosummary:: + {% for item in attributes %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} diff --git a/docs/source/_templates/layout.html b/docs/source/_templates/layout.html new file mode 100644 index 0000000..71f150b --- /dev/null +++ b/docs/source/_templates/layout.html @@ -0,0 +1,12 @@ +{# Extend Furo's base layout to add custom favicon links #} +{% extends "!layout.html" %} + +{% block extrahead %} + {{ super() }} + {# Favicons for various platforms #} + + + + + +{% endblock %} diff --git a/docs/source/_templates/sidebar/brand.html b/docs/source/_templates/sidebar/brand.html new file mode 100644 index 0000000..8780b11 --- /dev/null +++ b/docs/source/_templates/sidebar/brand.html @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/docs/source/api/blocks.md b/docs/source/api/blocks.md index 7a7e780..711ba25 100644 --- a/docs/source/api/blocks.md +++ b/docs/source/api/blocks.md @@ -4,206 +4,15 @@ Lynx provides five block types for building control system diagrams. Each block ## Block Comparison -| Block Type | Use Case | Key Parameters | Ports | -|------------|----------|----------------|-------| -| **Gain** | Scalar multiplication, controller gains | `K` (float) | 1 input, 1 output | -| **TransferFunction** | LTI systems in s-domain, plant models | `numerator`, `denominator` (arrays) | 1 input, 1 output | -| **StateSpace** | MIMO systems, state feedback | `A`, `B`, `C`, `D` (matrices) | 1+ inputs, 1+ outputs | -| **Sum** | Adding/subtracting signals, error calculation | `signs` (list: +/-/\|) | 3 inputs max, 1 output | -| **IOMarker** | System boundaries, signal labels | `marker_type`, `label` | 1 input OR 1 output | +| Block Type | Key Parameters | Ports | +|------------|----------------|-------| +| **Gain** | `K` (float) | 1 input, 1 output | +| **TransferFunction** | `num`, `den` (arrays) | 1 input, 1 output | +| **StateSpace** | `A`, `B`, `C`, `D` (matrices) | 1+ inputs, 1+ outputs | +| **Sum** | `signs` (list: +/-/\|) | 3 inputs max, 1 output | +| **InputMarker** | `label`, `index` | 1 output | +| **OutputMarker** | `label`, `index` | 1 input | -## Gain Block - -Multiplies input by constant gain K. - -### Parameters - -- **K** (`float`): Gain value (default: 1.0) -- **label** (`str`, optional): Block label for display -- **custom_latex** (`str`, optional): Custom LaTeX for rendering -- **position** (`dict`, optional): `{'x': float, 'y': float}` - -### Example - -```python -# Proportional controller with K=5 -diagram.add_block('gain', 'controller', K=5.0, label='P Controller') - -# Negative gain -diagram.add_block('gain', 'inverter', K=-1.0) - -# Custom LaTeX -diagram.add_block('gain', 'alpha', K=0.5, custom_latex=r'\alpha') -``` - -### Transfer Function - -Mathematical representation: $G(s) = K$ - -## TransferFunction Block - -Represents LTI systems in Laplace domain: $G(s) = \frac{N(s)}{D(s)}$ - -### Parameters - -- **numerator** (`list[float]`): Numerator coefficients (descending powers of s) -- **denominator** (`list[float]`): Denominator coefficients (descending powers of s) -- **label** (`str`, optional): Block label -- **custom_latex** (`str`, optional): Custom LaTeX for rendering -- **position** (`dict`, optional): Position coordinates - -### Example - -```python -# First-order system: G(s) = 2/(s+3) -diagram.add_block('transfer_function', 'plant', - numerator=[2.0], - denominator=[1.0, 3.0]) - -# Second-order system: G(s) = (s+1)/(s^2 + 2s + 1) -diagram.add_block('transfer_function', 'filter', - numerator=[1.0, 1.0], - denominator=[1.0, 2.0, 1.0]) - -# Pure integrator: G(s) = 1/s -diagram.add_block('transfer_function', 'integrator', - numerator=[1.0], - denominator=[1.0, 0.0]) -``` - -### LaTeX Rendering - -- Numerator and denominator automatically render as polynomial fractions -- Coefficients shown with 3 significant figures -- Exponential notation for very small/large values - -## StateSpace Block - -Represents systems in state-space form: - -$$ -\begin{align} -\dot{x} &= Ax + Bu \\\\ -y &= Cx + Du -\end{align} -$$ - -### Parameters - -- **A** (`np.ndarray`): State matrix (n×n) -- **B** (`np.ndarray`): Input matrix (n×m) -- **C** (`np.ndarray`): Output matrix (p×n) -- **D** (`np.ndarray`): Feedthrough matrix (p×m) -- **label** (`str`, optional): Block label -- **custom_latex** (`str`, optional): Custom LaTeX for rendering -- **position** (`dict`, optional): Position coordinates - -### Example - -```python -import numpy as np - -# 2-state system -A = np.array([[-1, 0], [0, -2]]) -B = np.array([[1], [1]]) -C = np.array([[1, 0]]) -D = np.array([[0]]) - -diagram.add_block('state_space', 'ss_plant', - A=A, B=B, C=C, D=D, - label='State Space Plant') - -# MIMO system (2 inputs, 2 outputs) -A = np.array([[-1, 1], [-1, -1]]) -B = np.array([[1, 0], [0, 1]]) -C = np.array([[1, 0], [0, 1]]) -D = np.zeros((2, 2)) - -diagram.add_block('state_space', 'mimo_system', - A=A, B=B, C=C, D=D) -``` - -### Port Naming - -For MIMO systems: -- Input ports: `in1`, `in2`, ..., `inM` -- Output ports: `out1`, `out2`, ..., `outP` - -## Sum Block - -Adds or subtracts up to 3 signals based on configured signs. - -### Parameters - -- **signs** (`list[str]`): Port signs for [top, left, bottom] positions - - `"+"`: Addition - - `"-"`: Subtraction - - `"|"`: Port disabled (no port at this position) -- **label** (`str`, optional): Block label -- **position** (`dict`, optional): Position coordinates - -### Port Configuration - -Ports are created based on `signs` parameter: -- Index 0 (top): `in1` -- Index 1 (left): `in2` -- Index 2 (bottom): `in3` -- Output: always `out` - -### Example - -```python -# Error calculation: e = r - y -diagram.add_block('sum', 'error', - signs=['+', '-', '|']) # Top: +r, Left: -y, Bottom: disabled - -# Three-input sum: out = a + b - c -diagram.add_block('sum', 'three_way', - signs=['+', '+', '-']) - -# Two-input addition -diagram.add_block('sum', 'adder', - signs=['+', '+', '|']) -``` - -### Interactive Configuration - -In the Jupyter widget, click quadrants to cycle signs: + → - → | → + - -## IOMarker Block - -Marks system input/output boundaries and provides signal labels for export. - -### Parameters - -- **marker_type** (`str`): `'input'` or `'output'` -- **label** (`str`): Signal name for export (e.g., `'r'`, `'y'`) -- **index** (`int`, optional): Index for automatic LaTeX numbering -- **custom_latex** (`str`, optional): Custom LaTeX override -- **position** (`dict`, optional): Position coordinates - -### Example - -```python -# System inputs -diagram.add_block('io_marker', 'ref', marker_type='input', label='r') -diagram.add_block('io_marker', 'dist', marker_type='input', label='d') - -# System outputs -diagram.add_block('io_marker', 'output', marker_type='output', label='y') -diagram.add_block('io_marker', 'error_out', marker_type='output', label='e') -``` - -### Export Usage - -IOMarker labels are the **highest priority** signal references: - -```python -# Export from 'r' to 'y' using IOMarker labels -sys = diagram.get_tf('r', 'y') -``` - -See {doc}`export` for signal reference priority rules. ## API Reference @@ -226,4 +35,3 @@ See {doc}`export` for signal reference priority rules. - {doc}`diagram` - Adding blocks to diagrams - {doc}`export` - Using IOMarker labels for export -- {doc}`../examples/index` - Block usage examples diff --git a/docs/source/api/diagram.md b/docs/source/api/diagram.md index 49dfa6a..790ace1 100644 --- a/docs/source/api/diagram.md +++ b/docs/source/api/diagram.md @@ -20,7 +20,10 @@ import lynx diagram = lynx.Diagram() # Load existing diagram -diagram = lynx.Diagram.load('my_diagram.json') +diagram = lynx.Diagram.load("diagram.json") + +# Create new diagram from template +diagram = lynx.Diagram.from_template("feedforward_tf") ``` ## Adding Blocks @@ -35,8 +38,8 @@ diagram.add_block('gain', 'controller', K=5.0, position={'x': 100, 'y': 50}) # Transfer function: G(s) = 2/(s+3) diagram.add_block('transfer_function', 'plant', - numerator=[2.0], - denominator=[1.0, 3.0], + num=[2.0], + den=[1.0, 3.0], position={'x': 200, 'y': 50}) # State-space block with A, B, C, D matrices @@ -125,40 +128,6 @@ This opens a visual editor where you can: Changes in the widget sync back to the Python object. -## Complete Example - -```python -import lynx -import control as ct -import numpy as np - -# Create PID feedback control system -diagram = lynx.Diagram() - -# Add blocks -diagram.add_block('io_marker', 'r', marker_type='input', label='r', position={'x': 0, 'y': 0}) -diagram.add_block('sum', 'error', signs=['+', '-', '|'], position={'x': 80, 'y': 0}) -diagram.add_block('gain', 'Kp', K=10.0, label='Proportional', position={'x': 150, 'y': 0}) -diagram.add_block('transfer_function', 'plant', - numerator=[1.0], denominator=[1.0, 2.0, 1.0], - position={'x': 250, 'y': 0}) -diagram.add_block('io_marker', 'y', marker_type='output', label='y', position={'x': 350, 'y': 0}) - -# Connect blocks -diagram.add_connection('c1', 'r', 'out', 'error', 'in1') -diagram.add_connection('c2', 'error', 'out', 'Kp', 'in') -diagram.add_connection('c3', 'Kp', 'out', 'plant', 'in') -diagram.add_connection('c4', 'plant', 'out', 'y', 'in') -diagram.add_connection('c5', 'plant', 'out', 'error', 'in2') # Feedback - -# Save -diagram.save('pid_system.json') - -# Export and analyze -sys = diagram.get_tf('r', 'y') -t, y = ct.step_response(sys, np.linspace(0, 10, 1000)) -print(f"Settling time: {ct.step_info(sys)['SettlingTime']:.2f}s") -``` ## API Reference diff --git a/docs/source/api/export.md b/docs/source/api/export.md index b0c0f98..0a57b46 100644 --- a/docs/source/api/export.md +++ b/docs/source/api/export.md @@ -19,231 +19,11 @@ Both methods: - Return python-control objects (`TransferFunction` or `StateSpace`) - Perform validation before export (raises `ValidationError` if invalid) -## Signal Reference System - -Lynx uses a 3-tier priority system for signal references: - -### Priority 1: IOMarker Labels (Highest) - -The recommended approach. Use the `label` parameter from InputMarker/OutputMarker blocks: - -```python -# Define IOMarkers with labels -diagram.add_block('io_marker', 'ref', marker_type='input', label='r') -diagram.add_block('io_marker', 'output', marker_type='output', label='y') - -# Export using IOMarker labels -sys = diagram.get_tf('r', 'y') # ← Uses IOMarker labels -``` - -**Benefits**: -- Clear system boundaries -- Unambiguous references -- Required for validation - -### Priority 2: Connection Labels - -Reference labeled connections between blocks: - -```python -# Add connection with label -diagram.add_connection('c1', 'plant', 'out', 'sensor', 'in', label='measurement') - -# Export using connection label -sys = diagram.get_ss('r', 'measurement') # ← Uses connection label -``` - -**Use case**: Extracting subsystems at internal signals - -### Priority 3: Block.Port Notation (Lowest) - -Explicit reference using `block_label.output_port`: - -```python -# Reference block output directly -sys = diagram.get_tf('controller.out', 'plant.out') -``` - -**Notes**: -- Must use block **label** (not ID) if label is set -- Must use **output** ports only (signals are outputs, not inputs) -- Use dot notation: `block_label.port_id` - -## Complete Examples - -### Basic Closed-Loop Transfer Function - -```python -import lynx -import control as ct - -# Create feedback control loop -diagram = lynx.Diagram() - -# Add blocks with IOMarkers -diagram.add_block('io_marker', 'r', marker_type='input', label='r') -diagram.add_block('sum', 'error', signs=['+', '-', '|']) -diagram.add_block('gain', 'controller', K=5.0) -diagram.add_block('transfer_function', 'plant', - numerator=[2.0], denominator=[1.0, 3.0]) -diagram.add_block('io_marker', 'y', marker_type='output', label='y') - -# Connect -diagram.add_connection('c1', 'r', 'out', 'error', 'in1') -diagram.add_connection('c2', 'error', 'out', 'controller', 'in') -diagram.add_connection('c3', 'controller', 'out', 'plant', 'in') -diagram.add_connection('c4', 'plant', 'out', 'y', 'in') -diagram.add_connection('c5', 'plant', 'out', 'error', 'in2') # Feedback - -# Export closed-loop transfer function -sys = diagram.get_tf('r', 'y') - -# Analyze -print(f"DC Gain: {ct.dcgain(sys):.3f}") -ct.step_response(sys) -``` - -### Subsystem Extraction - -Extract a portion of the diagram between arbitrary signals: - -```python -# Extract subsystem from controller output to sensor output -sys_subsystem = diagram.get_ss('controller.out', 'sensor.out') - -# Or using connection labels -diagram.add_connection('c_control', 'controller', 'out', 'plant', 'in', label='control_signal') -diagram.add_connection('c_measure', 'sensor', 'out', 'filter', 'in', label='measurement') - -sys_subsystem = diagram.get_ss('control_signal', 'measurement') -``` - -### Sum Block Sign Handling - -Sum blocks correctly handle negative feedback: - -```python -# Create feedback loop with subtraction -diagram.add_block('sum', 'error', signs=['+', '-', '|']) # Top: +, Left: - - -# Connections -diagram.add_connection('c1', 'r', 'out', 'error', 'in1') # Positive -diagram.add_connection('c2', 'plant', 'out', 'error', 'in2') # Negative (feedback) - -# Export: Lynx automatically applies negative sign -sys = diagram.get_tf('r', 'y') -``` - -The exported system correctly represents: -$$e = r - y$$ - -No manual negation needed! - -## Internal Implementation - -The export methods use python-control's interconnection capabilities internally to build the full system model before extracting the desired subsystem. Most users should use `get_tf()` and `get_ss()` for their export needs. - -## Validation Before Export - -All export methods validate the diagram first. See {doc}`validation` for details on: - -- Required IOMarkers (at least 1 input and 1 output) -- Port connectivity requirements -- Label uniqueness warnings -- Error handling and recovery - -## Integration with Python-Control - -Once exported, use the full python-control API: - -```python -import control as ct -import numpy as np - -# Export system -sys = diagram.get_tf('r', 'y') - -# Time-domain analysis -t = np.linspace(0, 10, 1000) -t_out, y_out = ct.step_response(sys, t) -impulse_out = ct.impulse_response(sys, t) - -# Frequency-domain analysis -ct.bode_plot(sys, dB=True) -ct.nyquist_plot(sys) -ct.nichols_plot(sys) - -# Stability analysis -poles = ct.poles(sys) -zeros = ct.zeros(sys) -margins = ct.stability_margins(sys) - -# Controller design -K, S, E = ct.lqr(sys.A, sys.B, Q, R) # For state-space systems -``` - -## Signal Reference Resolution Examples - -```python -# Priority demonstration -diagram.add_block('io_marker', 'input', marker_type='input', label='r') -diagram.add_block('gain', 'K1', K=5.0, label='controller') -diagram.add_connection('c1', 'K1', 'out', 'plant', 'in', label='control_signal') - -# All three references: -sys1 = diagram.get_tf('r', 'y') # Priority 1: IOMarker label -sys2 = diagram.get_tf('control_signal', 'y') # Priority 2: Connection label -sys3 = diagram.get_tf('controller.out', 'y') # Priority 3: Block.port notation - -# sys2 and sys3 extract different subsystems than sys1 -``` - -## Common Patterns - -### Pattern 1: Full Closed-Loop Analysis - -```python -# Use IOMarker labels for full system -sys = diagram.get_tf('r', 'y') -``` - -### Pattern 2: Open-Loop Analysis - -```python -# Break loop, measure plant only -sys_plant = diagram.get_tf('controller.out', 'sensor.in') -``` - -### Pattern 3: Sensitivity Function - -```python -# S = e/r (error sensitivity) -sys_S = diagram.get_tf('r', 'error.out') # If error sum has label='error' -``` - -### Pattern 4: Complementary Sensitivity - -```python -# T = y/r (closed-loop transfer function) -sys_T = diagram.get_tf('r', 'y') -``` - -## API Reference - -```{eval-rst} -.. currentmodule:: lynx - -.. autosummary:: - :toctree: generated/ - :nosignatures: - - Diagram.get_tf - Diagram.get_ss -``` +For more information see {doc}`../concepts/export`. ## See Also - {doc}`diagram` - Building diagrams - {doc}`blocks` - IOMarker block details - {doc}`validation` - Error handling and validation -- {doc}`../examples/index` - Export examples +- {doc}`../examples/cruise-control` - Workflow including export diff --git a/docs/source/api/index.md b/docs/source/api/index.md index feb1c99..f64db30 100644 --- a/docs/source/api/index.md +++ b/docs/source/api/index.md @@ -2,10 +2,6 @@ Complete reference documentation for Lynx's Python API. All public methods, parameters, and examples are documented here. -## Overview - -Lynx provides a simple, intuitive API for creating and analyzing control system diagrams: - ::::{grid} 1 2 2 2 :gutter: 3 @@ -20,14 +16,14 @@ Create, save, and load diagrams. The central class for all Lynx operations. :link: blocks :link-type: doc -Five block types for building control systems: Gain, TransferFunction, StateSpace, Sum, IOMarker. +Basic control systems blocks: gain, transfer function, state space, sum ::: :::{grid-item-card} Validation :link: validation :link-type: doc -Pre-export validation to catch errors: algebraic loops, connectivity, label uniqueness. +Diagram correctness: algebraic loops, connectivity, label uniqueness. ::: :::{grid-item-card} Python-Control Export @@ -43,11 +39,12 @@ Export diagrams to python-control for analysis and simulation. Common tasks and their corresponding API methods: -| Task | API Method | Example | -|------|------------|---------| +| Task | API Method | Example | +|----------------|------------|---------| | Create diagram | `lynx.Diagram()` | `diagram = lynx.Diagram()` | | Add block | `diagram.add_block()` | `diagram.add_block('gain', 'K1', K=5.0)` | | Add connection | `diagram.add_connection()` | `diagram.add_connection('c1', 'K1', 'out', 'plant', 'in')` | +| Update parameter | `block.set_parameter()` | `diagram["block_label"].set_parameter("parameter_name", new_value)` | | Save diagram | `diagram.save()` | `diagram.save('my_diagram.json')` | | Load diagram | `lynx.Diagram.load()` | `diagram = lynx.Diagram.load('my_diagram.json')` | | Export transfer function | `diagram.get_tf()` | `sys = diagram.get_tf('r', 'y')` | diff --git a/docs/source/api/validation.md b/docs/source/api/validation.md index f72da03..78ad17d 100644 --- a/docs/source/api/validation.md +++ b/docs/source/api/validation.md @@ -56,12 +56,12 @@ except ValidationError as e: ```python # Before (invalid) diagram.add_block('gain', 'K1', K=5.0) -diagram.add_block('transfer_function', 'plant', numerator=[1.0], denominator=[1.0, 1.0]) +diagram.add_block('transfer_function', 'plant', num=[1.0], den=[1.0, 1.0]) # After (valid) diagram.add_block('io_marker', 'r', marker_type='input', label='r') diagram.add_block('gain', 'K1', K=5.0) -diagram.add_block('transfer_function', 'plant', numerator=[1.0], denominator=[1.0, 1.0]) +diagram.add_block('transfer_function', 'plant', num=[1.0], den=[1.0, 1.0]) diagram.add_block('io_marker', 'y', marker_type='output', label='y') ``` @@ -76,12 +76,12 @@ diagram.add_block('io_marker', 'y', marker_type='output', label='y') ```python # Before (invalid) - plant has no input connection diagram.add_block('io_marker', 'r', marker_type='input', label='r') -diagram.add_block('transfer_function', 'plant', numerator=[1.0], denominator=[1.0, 1.0]) +diagram.add_block('transfer_function', 'plant', num=[1.0], den=[1.0, 1.0]) # Missing connection! # After (valid) diagram.add_block('io_marker', 'r', marker_type='input', label='r') -diagram.add_block('transfer_function', 'plant', numerator=[1.0], denominator=[1.0, 1.0]) +diagram.add_block('transfer_function', 'plant', num=[1.0], den=[1.0, 1.0]) diagram.add_connection('c1', 'r', 'out', 'plant', 'in') # Connect input ``` @@ -106,7 +106,7 @@ diagram.add_connection('c3', 'K2', 'out', 'error', 'in2') # Direct feedback - a diagram.add_block('sum', 'error', signs=['+', '-', '|']) diagram.add_block('gain', 'K1', K=5.0) diagram.add_block('transfer_function', 'plant', # Add dynamics here - numerator=[1.0], denominator=[1.0, 1.0]) + num=[1.0], den=[1.0, 1.0]) diagram.add_connection('c1', 'error', 'out', 'K1', 'in') diagram.add_connection('c2', 'K1', 'out', 'plant', 'in') diagram.add_connection('c3', 'plant', 'out', 'error', 'in2') # Now valid @@ -163,17 +163,6 @@ except ValidationError as e: print(f" Check connections for block '{e.block_id}' port '{e.port_id}'") ``` -## Validation Checklist - -Before exporting, ensure: - -- [ ] At least one InputMarker (`marker_type='input'`) -- [ ] At least one OutputMarker (`marker_type='output'`) -- [ ] All non-InputMarker input ports have connections -- [ ] All connections reference valid blocks and ports -- [ ] No algebraic loops (feedback paths have dynamics) -- [ ] Unique labels for all blocks and connections (optional, but recommended) - ## SignalNotFoundError Separate from `ValidationError`, this exception occurs when the specified signal references don't exist: @@ -197,4 +186,4 @@ except SignalNotFoundError as e: - {doc}`export` - Signal reference patterns and subsystem extraction - {doc}`diagram` - Building valid diagrams -- {doc}`../getting-started/quickstart` - Quick examples +- {doc}`../quickstart` - Quick examples diff --git a/docs/source/concepts.md b/docs/source/concepts.md deleted file mode 100644 index 0c5efa2..0000000 --- a/docs/source/concepts.md +++ /dev/null @@ -1,327 +0,0 @@ -# Core Concepts - -This guide explains the fundamental concepts behind Lynx's design: diagrams, blocks, connections, and how they work together to model control systems. - -## Diagram - -A **Diagram** is the top-level container for your control system. It holds all blocks and connections, and provides methods for: - -- Adding/removing blocks and connections -- Exporting to python-control systems -- Saving/loading to JSON files -- Validation before export - -```python -import lynx - -# Create an empty diagram -diagram = lynx.Diagram() - -# Diagrams are serializable -diagram.save('my_system.json') -diagram_loaded = lynx.Diagram.load('my_system.json') -``` - -### Diagram as Data - -Lynx diagrams are **pure data structures** - they can be: -- Created programmatically in Python -- Saved to/loaded from JSON files -- Edited interactively with `lynx.edit(diagram)` -- Exported to python-control for analysis - -## Block - -A **Block** represents a computational unit in your control system. Each block has: - -- **Type**: Defines behavior (Gain, TransferFunction, StateSpace, Sum, IOMarker) -- **Parameters**: Configuration specific to the block type -- **Ports**: Input and output connection points -- **Label**: Optional human-readable identifier (not the same as block ID) - -### Block Types Overview - -| Block Type | Use Case | Parameters | Ports | -|------------|----------|------------|-------| -| **Gain** | Scalar multiplication, controller gains | `K` (gain value) | `in` → `out` | -| **TransferFunction** | LTI systems in s-domain | `numerator`, `denominator` (coefficient arrays) | `in` → `out` | -| **StateSpace** | MIMO systems, state feedback | `A`, `B`, `C`, `D` (matrices) | `in` → `out` | -| **Sum** | Adding/subtracting signals | `signs` (list: `"+"`, `"-"`, `"|"` for each quadrant) | `in1`, `in2`, `in3` → `out` | -| **IOMarker** | System boundaries for export | `marker_type` (`'input'` or `'output'`), `label` | `out` (InputMarker) or `in` (OutputMarker) | - -### When to Use Each Block - -**Gain** blocks are ideal for: -- Simple controller gains (P, I, D components) -- Unit conversions or scaling factors -- Quick prototyping before implementing full controllers - -**TransferFunction** blocks are best for: -- Plant models from system identification -- Classical control design (lead/lag compensators, PID) -- Single-input single-output (SISO) systems in frequency domain - -**StateSpace** blocks excel at: -- Multi-input multi-output (MIMO) systems -- State feedback control -- Systems derived from physical models (Newton's laws, circuit equations) -- Observer design - -**Sum** blocks are used for: -- Error calculation (reference - output) -- Combining multiple signals (feedforward + feedback) -- Weighted sums with different signs per input - -**IOMarker** blocks define: -- System boundaries for subsystem extraction -- Named signals for `diagram.get_ss()` and `diagram.get_tf()` calls -- Documentation of system inputs and outputs - -### Creating Blocks - -```python -# Gain block: K = 5 -diagram.add_block('gain', 'controller', K=5.0) - -# Transfer function: G(s) = 2/(s+3) -diagram.add_block('transfer_function', 'plant', - numerator=[2.0], - denominator=[1.0, 3.0]) - -# State-space: x_dot = Ax + Bu, y = Cx + Du -import numpy as np -A = np.array([[0, 1], [0, 0]]) -B = np.array([[0], [1]]) -C = np.array([[1, 0]]) -D = np.array([[0]]) -diagram.add_block('state_space', 'plant', A=A, B=B, C=C, D=D) - -# Sum block with 2 inputs (top: +, left: -) -diagram.add_block('sum', 'error', signs=['+', '-', '|']) - -# Input/Output markers -diagram.add_block('io_marker', 'r', marker_type='input', label='r') -diagram.add_block('io_marker', 'y', marker_type='output', label='y') -``` - -## Connection - -A **Connection** represents a directed signal flow from one block's output port to another block's input port. - -### Connection Anatomy - -```python -diagram.add_connection( - 'connection_id', # Unique identifier - 'source_block', # Source block ID - 'source_port', # Output port ID (e.g., 'out') - 'target_block', # Target block ID - 'target_port' # Input port ID (e.g., 'in', 'in1', 'in2') -) -``` - -### Connection Rules - -1. **One output to many inputs** is allowed (signal fanout) -2. **Many outputs to one input** is NOT allowed (use Sum block to combine) -3. **All input ports must be connected** before export (except InputMarker blocks) -4. **Output ports can remain unconnected** (signals computed but not used) - -### Example: Feedback Loop - -```python -# Forward path: r -> error -> controller -> plant -> y -diagram.add_connection('c1', 'r', 'out', 'error', 'in1') -diagram.add_connection('c2', 'error', 'out', 'controller', 'in') -diagram.add_connection('c3', 'controller', 'out', 'plant', 'in') -diagram.add_connection('c4', 'plant', 'out', 'y', 'in') - -# Feedback path: plant output -> error input (negative feedback) -diagram.add_connection('c5', 'plant', 'out', 'error', 'in2') -``` - -## Port - -A **Port** is a typed connection point on a block. Every port has: - -- **Direction**: Input or output -- **Port ID**: Identifier like `'in'`, `'out'`, `'in1'`, `'in2'` -- **Block**: The block it belongs to - -### Port Conventions - -- **Single-input blocks** (Gain, TransferFunction, StateSpace): Use `'in'` and `'out'` -- **Multi-input blocks** (Sum): Use `'in1'`, `'in2'`, `'in3'` for top, left, bottom quadrants -- **IOMarkers**: InputMarker has `'out'` only, OutputMarker has `'in'` only - -## Signal References for Export - -When you export a subsystem with `diagram.get_ss(from_signal, to_signal)` or `diagram.get_tf(from_signal, to_signal)`, Lynx needs to identify which signals to use. Signal references follow a **3-tier priority system**: - -### 1. IOMarker Labels (Highest Priority) - -Use the `label` parameter from InputMarker or OutputMarker blocks: - -```python -diagram.add_block('io_marker', 'ref_marker', marker_type='input', label='r') -diagram.add_block('io_marker', 'out_marker', marker_type='output', label='y') - -# Export using IOMarker labels (recommended) -sys = diagram.get_tf('r', 'y') -``` - -**Best practice**: Use IOMarker labels for all system boundaries and subsystem extraction. - -### 2. Connection Labels (Medium Priority) - -Reference labeled connections between blocks: - -```python -diagram.add_connection('error_conn', 'sum', 'out', 'controller', 'in', - label='error') - -# Export using connection label -sys = diagram.get_ss('r', 'error') -``` - -**Use case**: Extracting internal signals without adding extra IOMarker blocks. - -### 3. Block.Port Notation (Lowest Priority) - -Explicit reference using `block_label.output_port` format: - -```python -# Export using block label + port -sys = diagram.get_ss('controller.out', 'plant.out') -``` - -**Important**: -- Must use block **label** (not block ID) -- Must reference **output** ports only (signals are outputs, not inputs) -- Requires explicit `.out` suffix (bare block labels no longer supported) - -### Signal Resolution Example - -```python -# All three signals are valid for export: -# - 'r' (IOMarker label - highest priority) -# - 'error' (connection label) -# - 'controller.out' (block.port notation) - -# Get transfer function from reference to error -sys_re = diagram.get_tf('r', 'error') - -# Get transfer function from error to plant output -sys_ey = diagram.get_tf('error', 'plant.out') - -# Full closed-loop transfer function -sys_ry = diagram.get_tf('r', 'y') -``` - -## Validation - -Before exporting to python-control, Lynx performs **three layers of validation**: - -### Layer 1: System Boundaries - -Every diagram must have: -- **At least one InputMarker** (system input) -- **At least one OutputMarker** (system output) - -Without these, there's no well-defined system to export. - -### Layer 2: Label Uniqueness - -Lynx checks for duplicate labels and issues warnings: -- Duplicate block labels -- Duplicate connection labels - -**Important**: These are warnings, not errors. The export will proceed, but ambiguous references may cause unexpected behavior. - -### Layer 3: Port Connectivity - -All input ports must be connected, except: -- **InputMarker blocks** (they define system inputs) -- Ports on blocks that are not part of the signal path being extracted - -### Example ValidationError - -```python -# Forgot to connect controller input -diagram.add_block('gain', 'controller', K=5.0) -diagram.add_block('transfer_function', 'plant', - numerator=[2.0], denominator=[1.0, 3.0]) -diagram.add_connection('c1', 'controller', 'out', 'plant', 'in') - -try: - sys = diagram.get_tf('r', 'y') -except lynx.ValidationError as e: - print(e) - # "Validation failed: input port 'in' on block 'controller' is not connected" -``` - -### Fixing Validation Errors - -The error message includes: -- **Block ID**: Which block has the issue -- **Port ID**: Which port is problematic -- **Guidance**: What needs to be fixed - -Common fixes: -1. **Missing input connection**: Add connection from upstream block -2. **Missing IOMarker**: Add InputMarker or OutputMarker to define system boundary -3. **Duplicate labels**: Rename blocks/connections to ensure uniqueness - -## Interactive Widget - -The relationship between Python code and the interactive widget: - -```python -# 1. Create diagram programmatically -diagram = lynx.Diagram() -diagram.add_block('gain', 'K', K=5.0) -diagram.add_block('transfer_function', 'G', - numerator=[2.0], denominator=[1.0, 3.0]) -diagram.add_connection('c1', 'K', 'out', 'G', 'in') - -# 2. Launch interactive widget -lynx.edit(diagram) - -# 3. User makes changes in UI: -# - Drag blocks to new positions -# - Edit parameters in property panel -# - Add/remove connections -# - Adjust routing waypoints - -# 4. Changes are bidirectionally synced -# The diagram object is updated automatically! -print(diagram.blocks['K'].parameters['K']) # May have changed if user edited it -``` - -### Use Cases - -**Visual verification**: -- Check that programmatic diagram construction is correct -- Verify signal routing and connection topology - -**Manual layout adjustments**: -- Position blocks for clear visualization -- Adjust connection routing for readability - -**Exploratory design**: -- Quickly try different topologies -- Add/remove blocks interactively -- Experiment with parameter values - -**Documentation**: -- Generate clean block diagrams for papers/presentations -- Export screenshots with `lynx.edit(diagram).export_png('diagram.png')` - -## Next Steps - -Now that you understand Lynx's core concepts: - -- {doc}`quickstart` - Quick reference for creating diagrams -- {doc}`../api/index` - Full API documentation -- {doc}`../examples/index` - Learn through executable examples -- Try designing your own control system! diff --git a/docs/source/concepts/_static/cascaded-dark.png b/docs/source/concepts/_static/cascaded-dark.png new file mode 100644 index 0000000..7a7f872 --- /dev/null +++ b/docs/source/concepts/_static/cascaded-dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f8aad13a14bb8809d77ced670edb3c09e12d951f09c92832e4aa1aa159e6e358 +size 160051 diff --git a/docs/source/concepts/_static/cascaded-light.png b/docs/source/concepts/_static/cascaded-light.png new file mode 100644 index 0000000..2e61103 --- /dev/null +++ b/docs/source/concepts/_static/cascaded-light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7e17231968cf7d42de7d4f881b40de8a648f3ad45aaee0fa576acd87675326a6 +size 185990 diff --git a/docs/source/concepts/_static/feedback-ss-dark.png b/docs/source/concepts/_static/feedback-ss-dark.png new file mode 100644 index 0000000..86843b7 --- /dev/null +++ b/docs/source/concepts/_static/feedback-ss-dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:21e2595d20ecfa7fe7557a1fb45d329b9c2a56afaa60e4c2e36e21b8c655d868 +size 92033 diff --git a/docs/source/concepts/_static/feedback-ss-light.png b/docs/source/concepts/_static/feedback-ss-light.png new file mode 100644 index 0000000..6aa2202 --- /dev/null +++ b/docs/source/concepts/_static/feedback-ss-light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7cf5a916defa04e37ed7dad0921418797e396055fe133dfc41818e88dc73da1b +size 105538 diff --git a/docs/source/concepts/_static/feedback-tf-dark.png b/docs/source/concepts/_static/feedback-tf-dark.png new file mode 100644 index 0000000..2928bf2 --- /dev/null +++ b/docs/source/concepts/_static/feedback-tf-dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b30a564b407e84be696d23150534f5226cec20c4f62f7e93673c04f544fc51c7 +size 84108 diff --git a/docs/source/concepts/_static/feedback-tf-light.png b/docs/source/concepts/_static/feedback-tf-light.png new file mode 100644 index 0000000..696f612 --- /dev/null +++ b/docs/source/concepts/_static/feedback-tf-light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:10e0b0ca01608851113ef5e6bf8e13e621de74544e5861b5ba1bf8e62a13a4ca +size 96046 diff --git a/docs/source/concepts/_static/feedforward-ss-dark.png b/docs/source/concepts/_static/feedforward-ss-dark.png new file mode 100644 index 0000000..1efd518 --- /dev/null +++ b/docs/source/concepts/_static/feedforward-ss-dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:beea9b392c3ad7f48122fdfb2771a39f2bb12751c3297f5e498d6be2f44ea4bd +size 122974 diff --git a/docs/source/concepts/_static/feedforward-ss-light.png b/docs/source/concepts/_static/feedforward-ss-light.png new file mode 100644 index 0000000..4fbf858 --- /dev/null +++ b/docs/source/concepts/_static/feedforward-ss-light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d9f4f9972e43e839a701c66faa604b51e57ad6d74bb5367c8432cb1f2b51cacd +size 140424 diff --git a/docs/source/concepts/_static/feedforward-tf-dark.png b/docs/source/concepts/_static/feedforward-tf-dark.png new file mode 100644 index 0000000..01a6714 --- /dev/null +++ b/docs/source/concepts/_static/feedforward-tf-dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f671028e127222d3e70cfdce608fc1686075956758a79f379ffea34a8d8335b +size 116719 diff --git a/docs/source/concepts/_static/feedforward-tf-light.png b/docs/source/concepts/_static/feedforward-tf-light.png new file mode 100644 index 0000000..3b73753 --- /dev/null +++ b/docs/source/concepts/_static/feedforward-tf-light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8fd36cfb4071a9aa3f195d3d0c1e635144d33b47114cf2343f2981ecc6650084 +size 134890 diff --git a/docs/source/concepts/_static/filtered-dark.png b/docs/source/concepts/_static/filtered-dark.png new file mode 100644 index 0000000..3bb3fa5 --- /dev/null +++ b/docs/source/concepts/_static/filtered-dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe45b4bd8f97169f3bc95ee6227fb147657f6dde368911560137a0c82aa23e7d +size 147209 diff --git a/docs/source/concepts/_static/filtered-light.png b/docs/source/concepts/_static/filtered-light.png new file mode 100644 index 0000000..d32c28c --- /dev/null +++ b/docs/source/concepts/_static/filtered-light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e21f0f94ec91036dbb78b49065b3668bc2c758c17add231b1947c2297b507c75 +size 169178 diff --git a/docs/source/concepts/_static/open-loop-ss-dark.png b/docs/source/concepts/_static/open-loop-ss-dark.png new file mode 100644 index 0000000..fb1082f --- /dev/null +++ b/docs/source/concepts/_static/open-loop-ss-dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c01930c123fa15305920c0f6f106c9fc963c9320d2c1acd2b8766d003f01a096 +size 27495 diff --git a/docs/source/concepts/_static/open-loop-ss-light.png b/docs/source/concepts/_static/open-loop-ss-light.png new file mode 100644 index 0000000..db25549 --- /dev/null +++ b/docs/source/concepts/_static/open-loop-ss-light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b8cbb2e7ce62c5bee4e128dd85a5c96c206e9e46a88b6da50969c90a6337f298 +size 32974 diff --git a/docs/source/concepts/_static/open-loop-tf-dark.png b/docs/source/concepts/_static/open-loop-tf-dark.png new file mode 100644 index 0000000..7d28571 --- /dev/null +++ b/docs/source/concepts/_static/open-loop-tf-dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:30881cd5073432c5d2f53af146db2b7f4b6d52b50d8ee3d92ec2ed006673a155 +size 23001 diff --git a/docs/source/concepts/_static/open-loop-tf-light.png b/docs/source/concepts/_static/open-loop-tf-light.png new file mode 100644 index 0000000..32626e0 --- /dev/null +++ b/docs/source/concepts/_static/open-loop-tf-light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e9881b628b3220e297c5706d3de2eb9553cd5db34130613e560bd159138bbc0 +size 28358 diff --git a/docs/source/concepts/editor.md b/docs/source/concepts/editor.md new file mode 100644 index 0000000..c007301 --- /dev/null +++ b/docs/source/concepts/editor.md @@ -0,0 +1,52 @@ +# Graphical Editing + +The main interface for editing block diagrams in Lynx is a Jupyter widget, which allows interactive editing inline in Jupyter notebooks. + +
+ +

Basic widget functionality

+
+ +This is strongly recommended over programmatic diagram construction - put simply, it is very difficult to design an inutitive API for what is fundamentally a graphical "language". +A convenient workflow is to: + +1. Create a diagram in the interactive widget or [initialize from a template](./templates.md) +2. Save the diagram to JSON (can also check into git) +3. Edit parameters and [extract subsystems](./export.md) using Python +4. Use the widget for visualization or further structural changes or visualization, saving changes to the JSON file + + + +## State Synchronization + +The Python code and the interactive widget have bidirectional syncing: + +```python +# 1. Create diagram programmatically +diagram = lynx.Diagram() +diagram.add_block('gain', 'K', K=5.0) +diagram.add_block('transfer_function', 'G', + num=[2.0], den=[1.0, 3.0]) +diagram.add_connection('c1', 'K', 'out', 'G', 'in') + +# 2. Launch interactive widget +lynx.edit(diagram) + +# 3. Makes changes in UI: +# - Drag blocks to new positions +# - Edit parameters in property panel +# - Add/remove connections +# - Adjust routing waypoints + +# 4. The diagram object is updated automatically +print(diagram["gain"].get_parameter("K")) +``` + +This allows you to update Python variables used in expressions in the diagram and have the changes automatically propagate to the diagram, or to edit the diagram and have the changes automatically sync to the Python `Diagram` object. + +## Static Rendering + +For documentation/publication/presentations, you can also create static renderings with `lynx.render(diagram, 'diagram.png')`, which supports both PNG and SVG exports. \ No newline at end of file diff --git a/docs/source/concepts/export.md b/docs/source/concepts/export.md new file mode 100644 index 0000000..5659b89 --- /dev/null +++ b/docs/source/concepts/export.md @@ -0,0 +1,107 @@ +# Subsystem Export + +A key feature of Lynx is interoperability with the [Python Control Systems Library](https://python-control.readthedocs.io/), referred to here as python-control or `control`. + +Python-control stores system parameters as NumPy arrays, so they can easily be translated to block parameters by directly referencing the variables: + +```python +import control +import lynx + +# Create a system in python-control +s = control.tf('s') +sys = control.ss((s + 1) / s^2) + +# Use the parameters for the Lynx block +diagram = lynx.Diagram() +diagram.add_block('state_space', 'plant', A=sys.A, B=sys.B, C=sys.C, D=sys.D) +``` + +Perhaps a more powerful feature is the capability to go the other direction and export python-control objects from Lynx diagrams. +This enables all of the simulation, analysis, and design tools in python-control without complex block diagram algebra. + +For instance, the `"cascaded"` template provides a pre-built diagram structure with 16 blocks including plant models, inner and outer control loops, and noise and disturbance inputs. + +```{image} _static/cascaded-light.png +:class: only-light +``` + +```{image} _static/cascaded-dark.png +:class: only-dark +``` + +Since the important signals have all been labeled, it's trivial to extract any internal subsystem in either a state-space or transfer function representation: + +```python +diagram = lynx.Diagram.from_template("cascaded") + +# Transfer function from inner loop disturbance (d2) to outer loop output (y1) +subsys_tf = diagram.get_tf("d2", "y1") + +# Same subsystem in state-space form +subsys_ss = diagram.get_tf("d2", "y1") +``` + +## Signal References for Export + +When you export a subsystem with `diagram.get_ss(from_signal, to_signal)` or `diagram.get_tf(from_signal, to_signal)`, Lynx needs to identify which signals to use. Signal references follow a **3-tier priority system**: + +### 1. IOMarker Labels (Highest Priority) + +Use the `label` parameter from InputMarker or OutputMarker blocks: + +```python +diagram.add_block('io_marker', 'ref_marker', marker_type='input', label='r') +diagram.add_block('io_marker', 'out_marker', marker_type='output', label='y') + +# Export using IOMarker labels (recommended) +sys = diagram.get_tf('r', 'y') +``` + +**Best practice**: Use IOMarker labels for all system boundaries and subsystem extraction. + +### 2. Connection Labels (Medium Priority) + +Reference labeled connections between blocks: + +```python +diagram.add_connection('error_conn', 'sum', 'out', 'controller', 'in', + label='error') + +# Export using connection label +sys = diagram.get_ss('r', 'error') +``` + +**Use case**: Extracting internal signals without adding extra IOMarker blocks. + +### 3. Block.Port Notation (Lowest Priority) + +Explicit reference using `block_label.output_port` format: + +```python +# Export using block label + port +sys = diagram.get_ss('controller.out', 'plant.out') +``` + +**Important**: +- Must use block **label** (not internal block ID) +- Must reference **output** ports only (signals are outputs, not inputs) +- Requires explicit `.out` suffix + +### Signal Resolution Example + +```python +# All three signals are valid for export: +# - 'ref' (IOMarker label - highest priority) +# - 'e' (connection label) +# - 'controller.out' (block.port notation) + +# Get transfer function from reference to error +sys_re = diagram.get_tf('ref', 'e') + +# Get transfer function from error to plant output +sys_ey = diagram.get_tf('e', 'plant.out') + +# Full closed-loop transfer function +sys_ry = diagram.get_tf('ref', 'output') +``` diff --git a/docs/source/concepts/index.md b/docs/source/concepts/index.md new file mode 100644 index 0000000..c71faa7 --- /dev/null +++ b/docs/source/concepts/index.md @@ -0,0 +1,149 @@ +# Core Concepts + +This guide explains the fundamental concepts behind Lynx's design and how they work together to model control systems. + +The basic objects in a block diagram are the **diagram**, which contains **blocks** representing computational units or subsystems, and **connections** between the blocks representing signal flows. + +## Diagram + +A **Diagram** is the top-level container for your control system. It holds all blocks and connections, and provides methods for: + +- Adding/removing blocks and connections +- Validating diagram structure +- Editing parameters +- Exporting to `python-control` system objects (state-space/transfer function) +- Saving/loading to JSON files + +```python +import lynx + +# Create an empty diagram +diagram = lynx.Diagram() + +# Load from a pre-made template +diagram = lynx.Diagram.from_template("feedback_tf") + +# Diagrams are serializable +diagram.save('my_system.json') +diagram_loaded = lynx.Diagram.load('my_system.json') +``` + +Lynx diagrams are **pure Python data structures** - they can be created programmatically in Python (not recommended), saved to/loaded from JSON, or edited interactively in Jupyter notebooks (recommended) with: + +```python +lynx.edit(diagram) +``` + +## Block + +A **Block** has the usual control system diagram semantics. Each block has: + +- **Type**: Defines behavior (Gain, TransferFunction, StateSpace, Sum, IOMarker) +- **Parameters**: Configuration specific to the block type +- **Ports**: Input and output connection points +- **Label**: Optional human-readable identifier + +### Ports + +A **Port** is a typed connection point on a block. Every port has: + +- **Direction**: Input or output +- **Port ID**: Identifier like `'in'`, `'out'`, `'in1'`, `'in2'` +- **Block**: The block it belongs to + +Single-input/output blocks (Gain, TransferFunction, StateSpace) have `'in'` and `'out'` ports, while multi-input blocks (Sum) use `'in1'`, `'in2'`, etc. IOMarker blocks have one port, either `'out'` or `'in'` for input and output markers, respectively. + +### Block Types Overview + +| Block Type | Parameters | Ports | +|------------|------------|-------| +| **Gain** | `K` (gain value) | `in` → `out` | +| **TransferFunction** | `num`, `den` (coefficient arrays) | `in` → `out` | +| **StateSpace** | `A`, `B`, `C`, `D` (matrices) | `in` → `out` | +| **Sum** | `signs` (list: `"+"`, `"-"`, `"\|"` for each quadrant) | `in1`, `in2`, `in3` → `out` | +| **IOMarker** | `marker_type` (`'input'` or `'output'`), `label` | `out` (InputMarker) or `in` (OutputMarker) | + +### Creating Blocks + +```python +# Gain block: K = 5 +diagram.add_block('gain', 'controller', K=5.0) + +# Transfer function: G(s) = 2/(s+3) +diagram.add_block('transfer_function', 'plant', + num=[2.0], + den=[1.0, 3.0]) + +# State-space: x_dot = Ax + Bu, y = Cx + Du +import numpy as np +A = np.array([[0, 1], [0, 0]]) +B = np.array([[0], [1]]) +C = np.array([[1, 0]]) +D = np.array([[0]]) +diagram.add_block('state_space', 'plant', A=A, B=B, C=C, D=D) + +# Sum block with 2 inputs (top: +, left: -) +diagram.add_block('sum', 'error', signs=['+', '-', '|']) + +# Input/Output markers +diagram.add_block('io_marker', 'r', marker_type='input', label='r') +diagram.add_block('io_marker', 'y', marker_type='output', label='y') +``` + +## Connection + +A **Connection** represents a directed signal flow from one block's output port to another block's input port. + +```python +diagram.add_connection( + 'connection_id', # Unique identifier + 'source_block', # Source block ID + 'source_port', # Output port ID (e.g., 'out') + 'target_block', # Target block ID + 'target_port', # Input port ID (e.g., 'in', 'in1', 'in2') + label="signal", # Optional signal name +) +``` + +### Connection Rules + +1. **One output to many inputs** is allowed (signal fanout) +2. **Many outputs to one input** is NOT allowed (use Sum block to combine) +3. **All input ports must be connected** before export +4. **Output ports can remain unconnected** (signals computed but not used) + +### Example: Feedback Loop + +```python +# Forward path: r -> error -> controller -> plant -> y +diagram.add_connection('c1', 'r', 'out', 'error', 'in1') +diagram.add_connection('c2', 'error', 'out', 'controller', 'in') +diagram.add_connection('c3', 'controller', 'out', 'plant', 'in') +diagram.add_connection('c4', 'plant', 'out', 'y', 'in') + +# Feedback path: plant output -> error input (negative feedback) +diagram.add_connection('c5', 'plant', 'out', 'error', 'in2') +``` + + +## Next Steps + +Now that you understand the basic block diagram components, continue on with: + + +- {doc}`editor` - Quick intro to the graphical editor +- {doc}`templates` - Pre-built control system architectures +- {doc}`export` - Interoperability with the Python control systems library +- {doc}`validation` - Checks for diagram consistency + + +```{toctree} +:maxdepth: 2 +:caption: Contents +:hidden: + +editor +templates +export +validation +``` diff --git a/docs/source/concepts/templates.md b/docs/source/concepts/templates.md new file mode 100644 index 0000000..f859752 --- /dev/null +++ b/docs/source/concepts/templates.md @@ -0,0 +1,90 @@ +# Diagram Templates + +While every control system is unique, there are several common architectures that many systems will either share or be minor variations of. + +To simplify construction of these diagrams, Lynx provides pre-built "template" systems that you can instantiate and edit. +In many cases, you may only need to edit the block parameters, block/signal labels, LaTeX content to make the diagram consistent with your own system. + +For an example using templates to quickly construct a system, see {doc}`../examples/cruise-control`. + +## Available templates + +**Open-loop (transfer function plant)** (`"open_loop_tf"`) + +```{image} _static/open-loop-tf-light.png +:class: only-light +``` + +```{image} _static/open-loop-tf-dark.png +:class: only-dark +``` + +**Open-loop (state-space plant)** (`"open_loop_ss"`) + +```{image} _static/open-loop-ss-light.png +:class: only-light +``` + +```{image} _static/open-loop-ss-dark.png +:class: only-dark +``` + +**Feedback (transfer function plant)** (`"feedback_tf"`) + +```{image} _static/feedback-tf-light.png +:class: only-light +``` + +```{image} _static/feedback-tf-dark.png +:class: only-dark +``` + +**Feedback (state space plant)** (`"feedback_ss"`) + +```{image} _static/feedback-ss-light.png +:class: only-light +``` + +```{image} _static/feedback-ss-dark.png +:class: only-dark +``` + +**Feedforward (transfer function plant)** (`"feedforward_tf"`) + +```{image} _static/feedforward-tf-light.png +:class: only-light +``` + +```{image} _static/feedforward-tf-dark.png +:class: only-dark +``` + +**Feedforward (state space plant)** (`"feedforward_ss"`) + +```{image} _static/feedforward-ss-light.png +:class: only-light +``` + +```{image} _static/feedforward-ss-dark.png +:class: only-dark +``` + +**Feedback + filtering** (`"filtered"`) + +```{image} _static/filtered-light.png +:class: only-light +``` + +```{image} _static/filtered-dark.png +:class: only-dark +``` + +**Cascaded control** (`"cascaded"`) + +```{image} _static/cascaded-light.png +:class: only-light +``` + +```{image} _static/cascaded-dark.png +:class: only-dark +``` \ No newline at end of file diff --git a/docs/source/concepts/validation.md b/docs/source/concepts/validation.md new file mode 100644 index 0000000..ed2c5e3 --- /dev/null +++ b/docs/source/concepts/validation.md @@ -0,0 +1,36 @@ +# Diagram Validation + +Lynx validates diagrams both in the editor widget and before exporting to python-control. +In the editor the validation status is displayed in the lower right corner with a green ✅ for all checks passing, yellow ⚠️ for warnings, and red ❌ for errors. + +1. **System Boundaries**: Every diagram must have at least one input marker and one output marker; without these, there's no well-defined system to export. +2. **Label Uniqueness**: Duplicate labels raise errors, either on blocks or connections. +3. **Port Connectivity**: All input ports must be connected (outputs are optional). +4. **Algebraic Loops**: Signal loops must be broken by an integrator in order to maintain causality in the diagram. + +The error message includes: +- **Block ID**: Which block has the issue +- **Port ID**: Which port is problematic +- **Guidance**: What needs to be fixed + +Common fixes: +1. **Missing input connection**: Add connection from upstream block +2. **Missing IOMarker**: Add InputMarker or OutputMarker to define system boundary +3. **Duplicate labels**: Rename blocks/connections to ensure uniqueness + + +## Example ValidationError + +```python +# Forgot to connect controller input +diagram.add_block('gain', 'controller', K=5.0) +diagram.add_block('transfer_function', 'plant', + num=[2.0], den=[1.0, 3.0]) +diagram.add_connection('c1', 'controller', 'out', 'plant', 'in') + +try: + sys = diagram.get_tf('r', 'y') +except lynx.ValidationError as e: + print(e) + # Block 'controller' input port 'in' is not connected +``` diff --git a/docs/source/conf.py b/docs/source/conf.py index 9d0dae0..ed44bfe 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -15,6 +15,9 @@ copyright = "2026, Jared Callaham" author = "Jared Callaham" +# # Override default Sphinx title to just show project name +# html_title = "Lynx" + # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration @@ -42,11 +45,12 @@ # Furo theme options html_theme_options = { # Logos - "light_logo": "logo-light.png", - "dark_logo": "logo-dark.png", + # "light_logo": "logo-light.png", + # "dark_logo": "logo-dark.png", "sidebar_hide_name": False, # Navigation "navigation_with_keys": True, + "top_of_page_buttons": [], "source_repository": "https://github.com/pinetreelabs/lynx", "source_branch": "main", "source_directory": "docs/source/", @@ -114,7 +118,7 @@ ], } -html_favicon = "_static/favicon.svg" +html_favicon = "_static/favicon.ico" # -- Extension configuration ------------------------------------------------- diff --git a/docs/source/examples/_static/edited-template-dark.png b/docs/source/examples/_static/edited-template-dark.png new file mode 100644 index 0000000..98a8f17 --- /dev/null +++ b/docs/source/examples/_static/edited-template-dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84556161f75d6b600bbdad81a1fcd46c6ef74acdb730f944114ce64f095f3969 +size 88513 diff --git a/docs/source/examples/_static/edited-template-light.png b/docs/source/examples/_static/edited-template-light.png new file mode 100644 index 0000000..5708bfc --- /dev/null +++ b/docs/source/examples/_static/edited-template-light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:036e436f4e3c2ba9c22510c2eb8e16767daa0f93eb35a4dedee2d25657637b0a +size 100761 diff --git a/docs/source/examples/basic-feedback.md b/docs/source/examples/basic-feedback.md deleted file mode 100644 index fcaa6a2..0000000 --- a/docs/source/examples/basic-feedback.md +++ /dev/null @@ -1,126 +0,0 @@ ---- -jupytext: - text_representation: - extension: .md - format_name: myst - format_version: 0.13 - jupytext_version: 1.16.1 -kernelspec: - display_name: Python 3 - language: python - name: python3 ---- - -# Basic Feedback Control - -This example demonstrates a simple feedback control system with a proportional controller and first-order plant. - -## System Overview - -We'll create a unity feedback system with: -- **Controller**: Proportional gain K = 5 -- **Plant**: First-order system $G(s) = \frac{2}{s+3}$ -- **Feedback**: Unity negative feedback - -## Setup - -```{code-cell} ipython3 -import lynx -import control as ct -import numpy as np -import matplotlib.pyplot as plt -``` - -## Create Diagram - -Build the feedback loop using Lynx's block diagram API: - -```{code-cell} ipython3 -# Create empty diagram -diagram = lynx.Diagram() - -# Add blocks -diagram.add_block('io_marker', 'r', marker_type='input', label='r', position={'x': 0, 'y': 0}) -diagram.add_block('sum', 'error', signs=['+', '-', '|'], position={'x': 80, 'y': 0}) -diagram.add_block('gain', 'controller', K=5.0, position={'x': 180, 'y': 0}) -diagram.add_block('transfer_function', 'plant', - numerator=[2.0], denominator=[1.0, 3.0], - position={'x': 300, 'y': 0}) -diagram.add_block('io_marker', 'y', marker_type='output', label='y', position={'x': 420, 'y': 0}) - -# Add connections -diagram.add_connection('c1', 'r', 'out', 'error', 'in1') -diagram.add_connection('c2', 'error', 'out', 'controller', 'in') -diagram.add_connection('c3', 'controller', 'out', 'plant', 'in') -diagram.add_connection('c4', 'plant', 'out', 'y', 'in') -diagram.add_connection('c5', 'plant', 'out', 'error', 'in2') # Feedback - -print("✓ Diagram created successfully") -``` - -## Export to Python-Control - -Extract the closed-loop transfer function from r to y: - -```{code-cell} ipython3 -# Export closed-loop transfer function -sys = diagram.get_tf('r', 'y') - -print(f"Closed-loop transfer function:") -print(sys) -print(f"\nDC Gain: {ct.dcgain(sys):.4f}") -``` - -## Step Response - -Analyze the system's step response: - -```{code-cell} ipython3 -# Compute step response -t = np.linspace(0, 3, 500) -t_out, y_out = ct.step_response(sys, t) - -# Plot -plt.figure(figsize=(10, 5)) -plt.plot(t_out, y_out, linewidth=2) -plt.axhline(y=ct.dcgain(sys), color='r', linestyle='--', alpha=0.5, label=f'DC Gain = {ct.dcgain(sys):.3f}') -plt.grid(True, alpha=0.3) -plt.xlabel('Time (s)') -plt.ylabel('Output') -plt.title('Closed-Loop Step Response') -plt.legend() -plt.tight_layout() -plt.show() - -print(f"Final value: {y_out[-1]:.4f}") -print(f"Settling time (2%): ~{t_out[np.where(np.abs(y_out - y_out[-1]) < 0.02 * y_out[-1])[0][0]]:.2f}s") -``` - -## Frequency Response - -Plot the Bode diagram to understand frequency domain behavior: - -```{code-cell} ipython3 -# Bode plot -plt.figure(figsize=(10, 8)) -ct.bode_plot(sys, dB=True, Hz=False) -plt.tight_layout() -plt.show() -``` - -## Analysis - -The closed-loop transfer function is: - -$$T(s) = \frac{KG(s)}{1 + KG(s)} = \frac{10}{s + 13}$$ - -Key observations: -- **DC Gain**: 10/13 ≈ 0.769 (steady-state error of ~23%) -- **Bandwidth**: ~13 rad/s (first-order system) -- **Settling time**: ~0.3s (4 time constants of 1/13) - -## Next Steps - -- Try increasing the controller gain K to reduce steady-state error -- Add an integrator to eliminate steady-state error (see PID example) -- Experiment with different plant dynamics diff --git a/docs/source/examples/cruise-control.md b/docs/source/examples/cruise-control.md new file mode 100644 index 0000000..79ac1e0 --- /dev/null +++ b/docs/source/examples/cruise-control.md @@ -0,0 +1,190 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.16.1 +kernelspec: + display_name: Python 3 + language: python + name: lynx +--- + +# Cruise Control Example + +This example demonstrates a simple feedback control system with a proportional-integral controller and first-order plant. +It is borrowed from the "cruise control" example in [Åström & Murray](https://people.duke.edu/~hpgavin/SystemID/References/Astrom-Feedback-2006.pdf), Chapter 3. + +## System Overview + +The plant models a transfer function from engine throttle to speed and is derived from linearizing a 1D nonlinear model about a particular engine gear and vehicle speed. + +The linearized plant model is: + +$$ +G(s) = \frac{b}{s - a}, \qquad b=1.32, ~~ a=-0.0101 +$$ + +and this can be controlled with simple proportional-integral (PI) feedback: + +$$ +C(s) = k_p + \frac{k_i}{s}, \qquad k_p = 0.5, ~~ k_i = 0.1 +$$ + +## Setup + +```{code-cell} python +:tags: [hide-cell] +# ruff: noqa: N802, N803, N806, N815, N816 + +import matplotlib.pyplot as plt +import numpy as np + +import control + +import lynx +``` + +```{code-cell} python +:tags: [remove-cell] +from pathlib import Path + +plot_dir = Path.cwd() / "_plots" +plot_dir.mkdir(exist_ok=True) +``` + +## Create Diagram + +It is possible to create this diagram programmatically using the block diagram API; however, you can expect a generally poor experience programmatically constructing block diagrams. + +Instead, either use the interactive widget to construct the diagram yourself, or load one of the pre-built [templates](../concepts/templates) and modify the block parameters. + +For a simple feedback controller with a transfer function plant model we can load the `"feedback_tf"` template: + +```{code-cell} python +# Construct a new diagram from scratch: +# diagram = lynx.Diagram() +# lynx.edit(diagram) + +# Load the diagram architecture from a template +diagram = lynx.Diagram.from_template("feedback_tf") +``` + +```{image} ../concepts/_static/feedback-tf-light.png +:class: only-light +``` + +```{image} ../concepts/_static/feedback-tf-dark.png +:class: only-dark +``` + +Update the transfer functions and turn off custom LaTeX rendering to see the numerical values: + +```{code-cell} python +# Linearized vehicle model +b = 1.32 +a = -0.0101 +diagram["plant"].set_parameter("num", [b]) +diagram["plant"].set_parameter("den", [1, -a]) +diagram["plant"].custom_latex = None + +# PI controller +kp = 0.5 +ki = 0.1 +diagram["controller"].set_parameter("num", [kp, ki]) +diagram["controller"].set_parameter("den", [1, 0]) +diagram["controller"].custom_latex = None +``` + +```{image} _static/edited-template-light.png +:class: only-light +``` + +```{image} _static/edited-template-dark.png +:class: only-dark +``` + +## Export to Python-Control + +Extract the closed-loop transfer functions from `r` to `u` and `y` as a python-control `TransferFunction` object: + +```{code-cell} python +# Export closed-loop transfer functions +G_yr = diagram.get_tf('r', 'y') +G_ur = diagram.get_tf('r', 'u') + +print(f"Closed-loop transfer function:") +print(G_yr) +``` + +Then these subsystems can be further analyzed using any of the python-control tools. + +For instance, to evaluate the step response: + +```{code-cell} python +# DC gains +yr_dcgain = control.dcgain(G_yr) +ur_dcgain = control.dcgain(G_ur) + +# Compute step responses +t = np.linspace(0, 30, 500) +_, y = control.step_response(G_yr, t) +_, u = control.step_response(G_ur, t) +``` + +```{code-cell} python +:tags: [hide-cell, remove-output] +fig, ax = plt.subplots(2, 1, figsize=(7, 4), sharex=True) + +ax[0].plot(t, y, linewidth=2) +ax[0].axhline(y=yr_dcgain, linestyle='--', alpha=0.5, label=f'DC Gain = {yr_dcgain:.3f}') +ax[0].grid(True, alpha=0.3) +ax[0].set_ylabel('Speed [m/s]') +ax[0].set_title('Closed-Loop Step Response') +ax[0].legend() + +ax[1].plot(t, u, linewidth=2) +ax[1].axhline(y=ur_dcgain, linestyle='--', alpha=0.5, label=f'DC Gain = {ur_dcgain:.3f}') +ax[1].grid(True, alpha=0.3) +ax[1].set_ylabel('Throttle [-]') +ax[1].legend() + +ax[-1].set_xlabel('Time [s]') +plt.show() +``` + +```{code-cell} python +:tags: [remove-cell] + +for theme in {"light", "dark"}: + lynx.utils.set_theme(theme) + + fig, ax = plt.subplots(2, 1, figsize=(7, 4), sharex=True) + + ax[0].plot(t, y, linewidth=2) + ax[0].axhline(y=yr_dcgain, linestyle='--', alpha=0.5, label=f'DC Gain = {yr_dcgain:.3f}') + ax[0].grid(True) + ax[0].set_ylabel('Speed [m/s]') + ax[0].set_title('Closed-Loop Step Response') + ax[0].legend() + + ax[1].plot(t, u, linewidth=2) + ax[1].axhline(y=ur_dcgain, linestyle='--', alpha=0.5, label=f'DC Gain = {ur_dcgain:.3f}') + ax[1].grid(True) + ax[1].set_ylabel('Throttle [-]') + ax[1].legend() + + ax[-1].set_xlabel('Time [s]') + + plt.savefig(plot_dir / f"cruise_control_0_{theme}.png") + plt.close() +``` + +```{image} _plots/cruise_control_0_light.png +:class: only-light +``` + +```{image} _plots/cruise_control_0_dark.png +:class: only-dark +``` diff --git a/docs/source/examples/index.md b/docs/source/examples/index.md deleted file mode 100644 index 6b6abca..0000000 --- a/docs/source/examples/index.md +++ /dev/null @@ -1,109 +0,0 @@ -# Examples Gallery - -Learn Lynx through executable Jupyter notebook examples. Each example demonstrates key concepts and can be downloaded and run locally. - -## Prerequisites - -To run these examples, you'll need: - -```bash -pip install lynx-nb numpy scipy matplotlib python-control jupyter -``` - -Basic understanding of: -- Control theory fundamentals (transfer functions, feedback, stability) -- Python and Jupyter notebooks -- Laplace transforms (helpful but not required) - -## Example Notebooks - -::::{grid} 1 2 2 3 -:gutter: 3 - -:::{grid-item-card} Basic Feedback Control -:link: basic-feedback -:link-type: doc - -Simple proportional feedback system with first-order plant. Learn diagram creation, export, and analysis. - -**Topics**: Feedback loops, transfer functions, step response, Bode plots - -**Level**: Beginner -::: - -:::{grid-item-card} PI Controller Design -:link: pid-controller -:link-type: doc - -PI controller tuning for a second-order system. Explore the effects of proportional and integral gains. - -**Topics**: PI control, integral action, overshoot, steady-state error - -**Level**: Intermediate -::: - -:::{grid-item-card} State-Space Control -:link: state-feedback -:link-type: doc - -State feedback design using pole placement for a double integrator. - -**Topics**: State-space models, pole placement, MIMO systems - -**Level**: Advanced -::: - -:::: - -## Running Examples Locally - -### Option 1: Download Individual Notebooks - -Click the download button (↓) at the top of each example page to get the `.ipynb` file. - -### Option 2: Clone Repository - -```bash -git clone https://github.com/pinetreelabs/lynx.git -cd lynx/docs/source/examples -jupyter notebook -``` - -### Option 3: Run in Colab - -Each notebook can be opened directly in Google Colab (no installation required). - -## Learning Path - -Recommended order for new users: - -1. **Start here**: {doc}`basic-feedback` - Understand the basics -2. {doc}`pid-controller` - Learn PI controller design -3. {doc}`state-feedback` - Explore advanced state-space methods - -## What's Next? - -After working through the examples: - -- {doc}`../api/index` - Explore the full API reference -- {doc}`../getting-started/quickstart` - Quick reference for creating diagrams -- Try modifying the examples to solve your own control problems! - -## Example Code Structure - -All examples follow this pattern: - -1. **Import libraries**: lynx, python-control, numpy, matplotlib -2. **Create diagram**: Add blocks and connections -3. **Export system**: Convert to python-control objects -4. **Analyze**: Step response, Bode plots, stability analysis -5. **Visualize**: Plot results with matplotlib - -```{toctree} -:maxdepth: 1 -:hidden: - -basic-feedback -pid-controller -state-feedback -``` diff --git a/docs/source/examples/pid-controller.md b/docs/source/examples/pid-controller.md deleted file mode 100644 index e4656cd..0000000 --- a/docs/source/examples/pid-controller.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -jupytext: - text_representation: - extension: .md - format_name: myst - format_version: 0.13 - jupytext_version: 1.16.1 -kernelspec: - display_name: Python 3 - language: python - name: python3 ---- - -# PI Controller Design - -This example demonstrates PI controller tuning for a second-order plant. - -## System Overview - -Plant: $G(s) = \frac{1}{s^2 + 2s + 1}$ (underdamped second-order system) - -```{code-cell} ipython3 -import lynx -import control as ct -import numpy as np -import matplotlib.pyplot as plt -``` - -## Create PI Feedback System - -```{code-cell} ipython3 -diagram = lynx.Diagram() - -# System boundaries -diagram.add_block('io_marker', 'r', marker_type='input', label='r', position={'x': 0, 'y': 0}) -diagram.add_block('io_marker', 'y', marker_type='output', label='y', position={'x': 500, 'y': 0}) - -# Error calculation -diagram.add_block('sum', 'error', signs=['+', '-', '|'], position={'x': 60, 'y': 0}) - -# PI controller: C(s) = Kp + Ki/s (simpler than full PID) -# Implementing as: C(s) = (Kp*s + Ki) / s -Kp, Ki = 10.0, 5.0 -diagram.add_block('transfer_function', 'pid', - numerator=[Kp, Ki], - denominator=[1.0, 0.0], - position={'x': 180, 'y': 0}) - -# Plant: G(s) = 1/(s^2 + 2s + 1) -diagram.add_block('transfer_function', 'plant', - numerator=[1.0], - denominator=[1.0, 2.0, 1.0], - position={'x': 320, 'y': 0}) - -# Connections -diagram.add_connection('c1', 'r', 'out', 'error', 'in1') -diagram.add_connection('c2', 'error', 'out', 'pid', 'in') -diagram.add_connection('c3', 'pid', 'out', 'plant', 'in') -diagram.add_connection('c4', 'plant', 'out', 'y', 'in') -diagram.add_connection('c5', 'plant', 'out', 'error', 'in2') - -print(f"PI gains: Kp={Kp}, Ki={Ki}") -``` - -## Analyze Closed-Loop Response - -```{code-cell} ipython3 -sys = diagram.get_tf('r', 'y') - -t = np.linspace(0, 5, 1000) -t_out, y_out = ct.step_response(sys, t) - -plt.figure(figsize=(10, 5)) -plt.plot(t_out, y_out, linewidth=2, label='PI Response') -plt.axhline(y=1, color='r', linestyle='--', alpha=0.5, label='Setpoint') -plt.grid(True, alpha=0.3) -plt.xlabel('Time (s)') -plt.ylabel('Output') -plt.title('PI Closed-Loop Step Response') -plt.legend() -plt.tight_layout() -plt.show() - -print(f"DC Gain: {ct.dcgain(sys):.4f}") -print(f"Overshoot: {(np.max(y_out) - 1.0) * 100:.1f}%") -``` - -## Key Insights - -- **Integral action** (Ki) eliminates steady-state error -- **Proportional gain** (Kp) affects speed of response -- **Note**: For derivative action (Kd), use a filtered derivative to keep the controller proper - -Try adjusting the gains to explore the tradeoffs! diff --git a/docs/source/examples/state-feedback.md b/docs/source/examples/state-feedback.md deleted file mode 100644 index 288a5cd..0000000 --- a/docs/source/examples/state-feedback.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -jupytext: - text_representation: - extension: .md - format_name: myst - format_version: 0.13 - jupytext_version: 1.16.1 -kernelspec: - display_name: Python 3 - language: python - name: python3 ---- - -# State-Space Control Design - -This example demonstrates state feedback control using pole placement. - -## System Model - -Consider a double integrator plant: -$$\dot{x} = \begin{bmatrix} 0 & 1 \\ 0 & 0 \end{bmatrix} x + \begin{bmatrix} 0 \\ 1 \end{bmatrix} u$$ -$$y = \begin{bmatrix} 1 & 0 \end{bmatrix} x$$ - -```{code-cell} ipython3 -import lynx -import control as ct -import numpy as np -import matplotlib.pyplot as plt -``` - -## Design State Feedback Gain - -```{code-cell} ipython3 -# Plant matrices -A = np.array([[0, 1], [0, 0]]) -B = np.array([[0], [1]]) -C = np.array([[1, 0]]) -D = np.array([[0]]) - -# Design state feedback for desired poles at -2 ± 2j -desired_poles = [-2+2j, -2-2j] -K = ct.place(A, B, desired_poles) - -print(f"State feedback gain K = {K}") -print(f"Closed-loop poles: {np.linalg.eigvals(A - B @ K)}") -``` - -## Create Closed-Loop System in Lynx - -```{code-cell} ipython3 -diagram = lynx.Diagram() - -# Reference input -diagram.add_block('io_marker', 'r', marker_type='input', label='r', position={'x': 0, 'y': 0}) - -# State-space plant -diagram.add_block('state_space', 'plant', A=A, B=B, C=C, D=D, position={'x': 200, 'y': 0}) - -# State feedback gain (Note: In practice, you'd implement this with gain blocks for each state) -# For simplicity, we use a gain block representing the feedback -diagram.add_block('gain', 'feedback', K=-K[0,0], position={'x': 100, 'y': 0}) - -# Output -diagram.add_block('io_marker', 'y', marker_type='output', label='y', position={'x': 320, 'y': 0}) - -# Connections -diagram.add_connection('c1', 'r', 'out', 'feedback', 'in') -diagram.add_connection('c2', 'feedback', 'out', 'plant', 'in') -diagram.add_connection('c3', 'plant', 'out', 'y', 'in') - -print("✓ State feedback system created") -``` - -## Simulate Step Response - -```{code-cell} ipython3 -# Note: For full state feedback, you would typically use python-control directly -# This simplified example shows the concept - -# Closed-loop system -A_cl = A - B @ K -sys_cl = ct.ss(A_cl, B, C, D) - -t = np.linspace(0, 5, 500) -t_out, y_out = ct.step_response(sys_cl, t) - -plt.figure(figsize=(10, 5)) -plt.plot(t_out, y_out, linewidth=2) -plt.grid(True, alpha=0.3) -plt.xlabel('Time (s)') -plt.ylabel('Output') -plt.title('State Feedback Closed-Loop Response') -plt.tight_layout() -plt.show() - -print(f"Damping ratio: ~0.7 (from pole location)") -print(f"Natural frequency: ~2.8 rad/s") -``` - -## Key Advantages - -- **Arbitrary pole placement** for stable, controllable systems -- **Multi-variable control** (MIMO systems) -- **Optimal control** possible with LQR design - -State-space methods are powerful for complex control problems! diff --git a/docs/source/index.md b/docs/source/index.md index 5a1f0e1..6a6e0c3 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -1,4 +1,10 @@ -# Lynx +# [Lynx]{.hidden-title} + + **Lynx** is a minimal, lightweight Jupyter widget for editing block diagrams. Design, visualize, and analyze linear SISO control systems using an interactive Jupyter workflow and seamless python-control integration. @@ -33,10 +39,10 @@ Get started in under 5 minutes. Install Lynx, create your first diagram, and run ::: :::{grid-item-card} 📚 Examples -:link: examples/index +:link: examples/cruise-control :link-type: doc -Learn through working Jupyter notebooks covering feedback control, PID tuning, and state-space design. +Basic example of custom diagram creation and analysis with python-control interoperability ::: :::{grid-item-card} 📖 API Reference @@ -71,7 +77,7 @@ pip install jupyter :hidden: quickstart -concepts -examples/index +concepts/index +examples/cruise-control api/index ``` diff --git a/docs/source/quickstart.md b/docs/source/quickstart.md index adb659c..7c4b106 100644 --- a/docs/source/quickstart.md +++ b/docs/source/quickstart.md @@ -68,8 +68,8 @@ diagram.add_block('gain', 'controller', K=5.0, # Add plant: 2/(s+3) diagram.add_block('transfer_function', 'plant', - numerator=[2.0], - denominator=[1.0, 3.0], + num=[2.0], + den=[1.0, 3.0], position={'x': 300, 'y': 0}) # Add summing junction for error calculation @@ -122,5 +122,5 @@ print(f"Final value: {y_out[-1]}") ## Next Steps -- {doc}`../examples/index`: Learn through more advanced examples +- {doc}`../examples/cruise-control`: Complete example of a control system workflow - {doc}`../api/index`: Explore all available blocks and methods diff --git a/js/src/DiagramCanvas.tsx b/js/src/DiagramCanvas.tsx index 7d14707..63a36e8 100644 --- a/js/src/DiagramCanvas.tsx +++ b/js/src/DiagramCanvas.tsx @@ -34,11 +34,16 @@ import { getDiagramState, onDiagramStateChange, sendAction } from "./utils/trait import { findCollinearSnap } from "./utils/collinearSnapping"; import { INTERACTION } from "./config/constants"; import lynxLogo from "./assets/lynx-logo.png"; -import type { - DiagramState, - Block as DiagramBlock, - Connection as DiagramConnection, -} from "./utils/traitletSync"; +import { + DEFAULT_VIEWPORT, + MIN_ZOOM, + MAX_ZOOM, + FIT_VIEW_OPTIONS, + getDefaultEdgeOptions, +} from "./utils/reactFlowConfig"; +import { blockToNode, connectionToEdge } from "./utils/nodeConversion"; +import { getContentBounds, calculateFitViewport } from "./utils/edgeAwareFitView"; +import type { DiagramState, Connection as DiagramConnection } from "./utils/traitletSync"; import { nodeTypes } from "./blocks"; import BlockPalette from "./palette/BlockPalette"; import ParameterPanel from "./components/ParameterPanel"; @@ -56,26 +61,7 @@ const edgeTypes: EdgeTypes = { orthogonal: OrthogonalEditableEdge, }; -/** - * Convert backend block to React Flow node - */ -function blockToNode(block: DiagramBlock): Node { - return { - id: block.id, - type: block.type, - position: block.position, - data: { - parameters: block.parameters, - ports: block.ports, - label: block.label, - flipped: block.flipped || false, - custom_latex: block.custom_latex, - label_visible: block.label_visible || false, - width: block.width, - height: block.height, - }, - }; -} +// Block-to-node conversion now imported from shared utils /** * Calculate squared distance between two points (avoids sqrt overhead) @@ -126,10 +112,14 @@ export default function DiagramCanvas() { // Track ReactFlow instance for programmatic control const reactFlowInstance = useRef(null); + const [isReactFlowReady, setIsReactFlowReady] = useState(false); // Track drag start positions for distance calculation (drag detection) const dragStartPos = useRef>({}); + // Track if we've done the initial fitView (prevent re-running on every node add) + const hasInitialFitView = useRef(false); + // Subscribe to theme changes from Python useEffect(() => { if (!model) return; @@ -196,28 +186,7 @@ export default function DiagramCanvas() { // Memoized edge converter that uses current marker color const connectionToEdgeWithColor = useCallback( - (conn: DiagramConnection): Edge => { - return { - id: conn.id, - source: conn.source_block_id, - sourceHandle: conn.source_port_id, - target: conn.target_block_id, - targetHandle: conn.target_port_id, - type: "orthogonal", - data: { - waypoints: conn.waypoints || [], - label: conn.label, - label_visible: conn.label_visible || false, - }, - style: { stroke: markerColor, strokeWidth: 2.5 }, - markerEnd: { - type: "arrowclosed", - width: 14, - height: 14, - color: markerColor, - }, - }; - }, + (conn: DiagramConnection): Edge => connectionToEdge(conn, markerColor), [markerColor] ); @@ -227,6 +196,7 @@ export default function DiagramCanvas() { // Initial load const initialState = getDiagramState(model); + setDiagramState(initialState); // Store initial state for parameter panel setNodes(initialState.blocks.map(blockToNode)); setEdges(initialState.connections.map(connectionToEdgeWithColor)); @@ -405,6 +375,36 @@ export default function DiagramCanvas() { [model, edges, nodes] ); + // Edge-aware fitView callback (defined before keyboard shortcuts that use it) + const edgeAwareFitView = useCallback(() => { + if (!reactFlowInstance.current) return; + + const containerElement = document.querySelector(".react-flow") as HTMLElement; + if (!containerElement) return; + + const contentBounds = getContentBounds(nodes, edges); + const viewport = calculateFitViewport( + contentBounds, + containerElement.offsetWidth, + containerElement.offsetHeight, + FIT_VIEW_OPTIONS + ); + reactFlowInstance.current.setViewport(viewport); + }, [nodes, edges]); + + // Initial fitView when diagram first loads (ONE TIME ONLY) + useEffect(() => { + if (!isReactFlowReady || nodes.length === 0 || hasInitialFitView.current) return; + + // Wait for React Flow to render nodes, then fit view + const timer = setTimeout(() => { + edgeAwareFitView(); + hasInitialFitView.current = true; // Mark as completed + }, 100); + + return () => clearTimeout(timer); + }, [isReactFlowReady, nodes.length, edgeAwareFitView]); + // Keyboard shortcuts useEffect(() => { if (!model) return; @@ -464,7 +464,7 @@ export default function DiagramCanvas() { // Spacebar: Zoom to fit if (event.key === " " && !isInputField) { event.preventDefault(); - reactFlowInstance.current?.fitView({ padding: 0.4, minZoom: 0.3, maxZoom: 1 }); + edgeAwareFitView(); return; } @@ -526,7 +526,7 @@ export default function DiagramCanvas() { return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, [model, onNodesChange]); + }, [model, onNodesChange, edgeAwareFitView]); // Handle drag start - clear waypoints immediately for WYSIWYG preview const onNodeDragStart = useCallback((_event: React.MouseEvent, node: Node) => { @@ -645,7 +645,10 @@ export default function DiagramCanvas() { // Handle block double-click (opens parameter panel) const onNodeDoubleClick = useCallback( - (_event: React.MouseEvent, node: Node) => { + (event: React.MouseEvent, node: Node) => { + event.stopPropagation(); // Prevent zoom behavior + event.preventDefault(); // Prevent any default browser behavior + // Update our custom selectedBlockId for ParameterPanel setSelectedBlockId(node.id); if (model) { @@ -855,27 +858,18 @@ export default function DiagramCanvas() { onPaneClick={onPaneClick} onInit={(instance) => { reactFlowInstance.current = instance; + setIsReactFlowReady(true); }} nodeTypes={nodeTypes} edgeTypes={edgeTypes} nodeDragThreshold={5} - fitView - fitViewOptions={{ padding: 0.4, minZoom: 0.3, maxZoom: 1 }} - defaultViewport={{ x: 0, y: 0, zoom: 0.5 }} - minZoom={0.3} - maxZoom={2} + zoomOnDoubleClick={false} + defaultViewport={DEFAULT_VIEWPORT} + minZoom={MIN_ZOOM} + maxZoom={MAX_ZOOM} connectionMode="loose" isValidConnection={() => true} - defaultEdgeOptions={{ - style: { stroke: markerColor, strokeWidth: 2.5 }, - type: "orthogonal", - markerEnd: { - type: "arrowclosed", - width: 16, - height: 16, - color: markerColor, - }, - }} + defaultEdgeOptions={getDefaultEdgeOptions(markerColor)} style={{ backgroundColor: "var(--color-slate-50)" }} defaultMarkerColor={markerColor} proOptions={{ hideAttribution: true }} @@ -888,13 +882,8 @@ export default function DiagramCanvas() { style={{ opacity: 0.1 }} /> - {/* Custom zoom-to-fit button with padding: 0.4 */} - - reactFlowInstance.current?.fitView({ padding: 0.4, minZoom: 0.3, maxZoom: 1 }) - } - title="Zoom to Fit (Spacebar)" - > + {/* Custom zoom-to-fit button with edge-aware bounds */} + ) { // Get numerator and denominator parameters - const numerator = data.parameters?.find((p) => p.name === "numerator")?.value ?? [1]; - const denominator = data.parameters?.find((p) => p.name === "denominator")?.value ?? [1, 1]; + const numerator = data.parameters?.find((p) => p.name === "num")?.value ?? [1]; + const denominator = data.parameters?.find((p) => p.name === "den")?.value ?? [1, 1]; const customLatex = data.custom_latex; const isFlipped = data.flipped || false; diff --git a/js/src/blocks/transfer_function/TransferFunctionParameterEditor.test.tsx b/js/src/blocks/transfer_function/TransferFunctionParameterEditor.test.tsx index f87baf6..9fb9675 100644 --- a/js/src/blocks/transfer_function/TransferFunctionParameterEditor.test.tsx +++ b/js/src/blocks/transfer_function/TransferFunctionParameterEditor.test.tsx @@ -26,8 +26,8 @@ const createTransferFunctionBlock = ( type: "transfer_function", position: { x: 0, y: 0 }, parameters: [ - { name: "numerator", value: numerator }, - { name: "denominator", value: denominator }, + { name: "num", value: numerator }, + { name: "den", value: denominator }, ], ports: [ { id: "in", type: "input" }, diff --git a/js/src/blocks/transfer_function/TransferFunctionParameterEditor.tsx b/js/src/blocks/transfer_function/TransferFunctionParameterEditor.tsx index 97cfee6..0bd1231 100644 --- a/js/src/blocks/transfer_function/TransferFunctionParameterEditor.tsx +++ b/js/src/blocks/transfer_function/TransferFunctionParameterEditor.tsx @@ -19,8 +19,8 @@ export interface ParameterEditorProps { export default function TransferFunctionParameterEditor({ block, onUpdate }: ParameterEditorProps) { // Get parameter objects - const numParam = block.parameters?.find((p) => p.name === "numerator"); - const denParam = block.parameters?.find((p) => p.name === "denominator"); + const numParam = block.parameters?.find((p) => p.name === "num"); + const denParam = block.parameters?.find((p) => p.name === "den"); // Extract expressions (fallback to stringified values for old diagrams) const numExpression = @@ -42,7 +42,7 @@ export default function TransferFunctionParameterEditor({ block, onUpdate }: Par // Initialize expressions when block changes useEffect(() => { - const param = block.parameters?.find((p) => p.name === "numerator"); + const param = block.parameters?.find((p) => p.name === "num"); const expr = param?.expression ?? (Array.isArray(param?.value) ? param.value.join(",") : String(param?.value ?? "[1]")); @@ -50,7 +50,7 @@ export default function TransferFunctionParameterEditor({ block, onUpdate }: Par }, [block.parameters]); useEffect(() => { - const param = block.parameters?.find((p) => p.name === "denominator"); + const param = block.parameters?.find((p) => p.name === "den"); const expr = param?.expression ?? (Array.isArray(param?.value) ? param.value.join(",") : String(param?.value ?? "[1,1]")); @@ -74,7 +74,7 @@ export default function TransferFunctionParameterEditor({ block, onUpdate }: Par // Apply numerator expression const handleNumApply = () => { - onUpdate(block.id, "numerator", numExpressionInput); + onUpdate(block.id, "num", numExpressionInput); }; // Handle denominator expression change @@ -84,7 +84,7 @@ export default function TransferFunctionParameterEditor({ block, onUpdate }: Par // Apply denominator expression const handleDenApply = () => { - onUpdate(block.id, "denominator", denExpressionInput); + onUpdate(block.id, "den", denExpressionInput); }; return ( diff --git a/js/src/capture/CaptureCanvas.tsx b/js/src/capture/CaptureCanvas.tsx index 089da76..b9e2b9d 100644 --- a/js/src/capture/CaptureCanvas.tsx +++ b/js/src/capture/CaptureCanvas.tsx @@ -9,21 +9,26 @@ * Used by the capture system to generate PNG/SVG exports. */ -import React, { useCallback, useMemo, useState, useEffect, useContext, useRef } from "react"; +import React, { useCallback, useState, useEffect, useContext, useRef } from "react"; import { AnyWidgetModelContext } from "../index"; import ReactFlow, { Node, Edge, EdgeTypes, ReactFlowInstance, ReactFlowProvider } from "reactflow"; import "reactflow/dist/style.css"; import OrthogonalEditableEdge from "../connections/OrthogonalEditableEdge"; import { getDiagramState, onDiagramStateChange } from "../utils/traitletSync"; -import type { - DiagramState, - Block as DiagramBlock, - Connection as DiagramConnection, -} from "../utils/traitletSync"; +import type { DiagramState } from "../utils/traitletSync"; import { nodeTypes } from "../blocks"; import type { CaptureRequest, CaptureResult } from "./types"; -import { captureToPng, captureToSvg, calculateContentBounds } from "./captureUtils"; +import { captureToPng, captureToSvg } from "./captureUtils"; +import { + DEFAULT_VIEWPORT, + MIN_ZOOM, + MAX_ZOOM, + FIT_VIEW_OPTIONS, + getDefaultEdgeOptions, +} from "../utils/reactFlowConfig"; +import { blockToNode, connectionToEdge } from "../utils/nodeConversion"; +import { getContentBounds, calculateFitViewport } from "../utils/edgeAwareFitView"; /** * Map edge types to custom edge components @@ -32,69 +37,7 @@ const edgeTypes: EdgeTypes = { orthogonal: OrthogonalEditableEdge, }; -/** - * Default dimensions for each block type (must match BLOCK_DEFAULTS in blockDefaults.ts) - */ -const BLOCK_DEFAULTS: Record = { - gain: { width: 120, height: 80 }, - sum: { width: 56, height: 56 }, - transfer_function: { width: 100, height: 50 }, - state_space: { width: 100, height: 60 }, - io_marker: { width: 60, height: 48 }, -}; - -/** - * Convert backend block to React Flow node - * Important: width/height must be on the node itself for getNodesBounds to work - */ -function blockToNode(block: DiagramBlock): Node { - const defaults = BLOCK_DEFAULTS[block.type] || { width: 100, height: 60 }; - const width = block.width ?? defaults.width; - const height = block.height ?? defaults.height; - - return { - id: block.id, - type: block.type, - position: block.position, - // Width/height on node for getNodesBounds calculation - width, - height, - data: { - parameters: block.parameters, - ports: block.ports, - label: block.label, - flipped: block.flipped || false, - custom_latex: block.custom_latex, - label_visible: block.label_visible || false, - width, - height, - }, - }; -} - -/** - * Convert backend connection to React Flow edge - */ -function connectionToEdge(conn: DiagramConnection): Edge { - return { - id: conn.id, - source: conn.source_block_id, - sourceHandle: conn.source_port_id, - target: conn.target_block_id, - targetHandle: conn.target_port_id, - type: "orthogonal", - data: { - waypoints: conn.waypoints || [], - label: conn.label, - label_visible: conn.label_visible || false, - }, - markerEnd: { - type: "arrowclosed", - width: 14, - height: 14, - }, - }; -} +// Block/edge conversion now imported from shared utils interface CaptureCanvasInnerProps { nodes: Node[]; @@ -118,44 +61,37 @@ function CaptureCanvasInner({ // Perform capture when request changes and canvas is ready useEffect(() => { - console.log("[CaptureCanvasInner] Capture effect check:", { - hasCaptureRequest: !!captureRequest, - isReady, - hasContainer: !!containerRef.current, - hasInstance: !!reactFlowInstance.current, - nodeCount: nodes.length, - }); - if (!captureRequest || !isReady || !containerRef.current || !reactFlowInstance.current) { return; } // Wait for nodes to be available if (nodes.length === 0) { - console.log("[CaptureCanvasInner] Waiting for nodes..."); return; } const performCapture = async () => { try { - console.log("[CaptureCanvasInner] Starting capture with", nodes.length, "nodes"); + // Calculate content bounds including edge waypoints (not just nodes) + const contentBounds = getContentBounds(nodes, edges); - // Calculate natural content bounds (without target dimensions) - const contentBounds = calculateContentBounds(nodes, null, null); - console.log("[CaptureCanvasInner] Content bounds:", contentBounds); + // Use percentage-based padding for consistency with DiagramCanvas + const CAPTURE_PADDING = 0.15; // 15% padding on each side - // Determine output dimensions - const outputWidth = Math.ceil(captureRequest.width ?? contentBounds.width); - const outputHeight = Math.ceil(captureRequest.height ?? contentBounds.height); + // Determine output dimensions (use content with padding if not specified) + let outputWidth: number; + let outputHeight: number; - // Calculate zoom to fit content within output dimensions - let zoom = 1; if (captureRequest.width !== null || captureRequest.height !== null) { - const scaleX = outputWidth / contentBounds.width; - const scaleY = outputHeight / contentBounds.height; - zoom = Math.min(scaleX, scaleY); // Fit while preserving aspect ratio + // User specified dimensions + outputWidth = Math.ceil(captureRequest.width ?? contentBounds.width * 1.2); + outputHeight = Math.ceil(captureRequest.height ?? contentBounds.height * 1.2); + } else { + // Auto-size to content with padding + const paddingMultiplier = 1 / (1 - CAPTURE_PADDING * 2); // Inverse of available space + outputWidth = Math.ceil(contentBounds.width * paddingMultiplier); + outputHeight = Math.ceil(contentBounds.height * paddingMultiplier); } - console.log("[CaptureCanvasInner] Calculated zoom:", zoom); // Resize container to match output dimensions if (containerRef.current) { @@ -166,20 +102,13 @@ function CaptureCanvasInner({ // Wait for resize to take effect await new Promise((resolve) => setTimeout(resolve, 100)); - // Calculate viewport position to center content within output - // Content center in canvas coordinates - const contentCenterX = contentBounds.x + contentBounds.width / 2; - const contentCenterY = contentBounds.y + contentBounds.height / 2; - - // Viewport position to center scaled content - const viewportX = outputWidth / 2 - contentCenterX * zoom; - const viewportY = outputHeight / 2 - contentCenterY * zoom; - - reactFlowInstance.current?.setViewport({ - x: viewportX, - y: viewportY, - zoom, + // Calculate viewport to fit content bounds with padding + const viewport = calculateFitViewport(contentBounds, outputWidth, outputHeight, { + padding: CAPTURE_PADDING, + minZoom: MIN_ZOOM, + maxZoom: MAX_ZOOM, }); + reactFlowInstance.current?.setViewport(viewport); // Wait for viewport adjustment and rendering to complete await new Promise((resolve) => setTimeout(resolve, 200)); @@ -193,14 +122,10 @@ function CaptureCanvasInner({ throw new Error("Could not find React Flow viewport element"); } - console.log( - "[CaptureCanvasInner] Capturing", - captureRequest.format, - "at", - outputWidth, - "x", - outputHeight - ); + // Compute background color from theme (html-to-image can't resolve CSS variables) + const computedStyle = getComputedStyle(containerRef.current); + const backgroundColor = + computedStyle.getPropertyValue("--color-slate-50").trim() || "#fafbfc"; let data: string; if (captureRequest.format === "png") { @@ -208,14 +133,13 @@ function CaptureCanvasInner({ viewportElement, outputWidth, outputHeight, - captureRequest.transparent + captureRequest.transparent, + backgroundColor ); } else { data = await captureToSvg(viewportElement, outputWidth, outputHeight); } - console.log("[CaptureCanvasInner] Capture successful, data length:", data.length); - onCaptureComplete({ success: true, data, @@ -239,16 +163,9 @@ function CaptureCanvasInner({ }; performCapture(); - }, [captureRequest, isReady, nodes, onCaptureComplete]); + }, [captureRequest, isReady, nodes, edges, onCaptureComplete]); - // Calculate initial viewport to fit all nodes - const defaultViewport = useMemo(() => { - if (nodes.length === 0) { - return { x: 0, y: 0, zoom: 1 }; - } - // Will be set by fitView - return { x: 0, y: 0, zoom: 1 }; - }, [nodes]); + // Note: defaultViewport is set by shared config, fitView will override it anyway return (
{ - console.log("[CaptureCanvasInner] ReactFlow initialized"); reactFlowInstance.current = instance; - // Fit view after init - instance.fitView({ padding: 0.1 }); + + // Use edge-aware fitView on init + const contentBounds = getContentBounds(nodes, edges); + const container = containerRef.current; + if (container) { + const viewport = calculateFitViewport( + contentBounds, + container.offsetWidth, + container.offsetHeight, + { padding: 0.1, minZoom: MIN_ZOOM, maxZoom: MAX_ZOOM } + ); + instance.setViewport(viewport); + } + // Mark as ready after a short delay to ensure render is complete setTimeout(() => { - console.log("[CaptureCanvasInner] Setting isReady=true"); setIsReady(true); }, 100); }} fitView - fitViewOptions={{ padding: 0.1, minZoom: 0.1, maxZoom: 4 }} - defaultViewport={defaultViewport} - minZoom={0.1} - maxZoom={4} + fitViewOptions={FIT_VIEW_OPTIONS} + defaultViewport={DEFAULT_VIEWPORT} + minZoom={MIN_ZOOM} + maxZoom={MAX_ZOOM} nodesDraggable={false} nodesConnectable={false} elementsSelectable={false} @@ -288,13 +215,7 @@ function CaptureCanvasInner({ zoomOnPinch={false} zoomOnDoubleClick={false} preventScrolling={false} - defaultEdgeOptions={{ - style: { stroke: "var(--color-primary-600)", strokeWidth: 2 }, - type: "orthogonal", - markerEnd: { - type: "arrowclosed", - }, - }} + defaultEdgeOptions={getDefaultEdgeOptions("var(--color-primary-600)")} style={{ backgroundColor: "transparent" }} defaultMarkerColor="var(--color-primary-600)" proOptions={{ hideAttribution: true }} @@ -315,23 +236,45 @@ export default function CaptureCanvas() { const [nodes, setNodes] = useState([]); const [edges, setEdges] = useState([]); const [captureRequest, setCaptureRequest] = useState(null); + const [theme, setTheme] = useState("light"); const lastTimestamp = useRef(0); + // Subscribe to theme changes from Python + useEffect(() => { + if (!model) return; + + // Read initial theme + const initialTheme = model.get("theme") || "light"; + setTheme(initialTheme); + + // Listen for theme changes + const handleThemeChange = () => { + const newTheme = model.get("theme") || "light"; + setTheme(newTheme); + }; + + model.on("change:theme", handleThemeChange); + + return () => { + model.off("change:theme", handleThemeChange); + }; + }, [model]); + // Subscribe to diagram state from Python (like DiagramCanvas does) useEffect(() => { if (!model) return; // Initial load const initialState = getDiagramState(model); - console.log("[CaptureCanvas] Initial state:", initialState.blocks.length, "blocks"); setNodes(initialState.blocks.map(blockToNode)); - setEdges(initialState.connections.map(connectionToEdge)); + setEdges( + initialState.connections.map((conn) => connectionToEdge(conn, "var(--color-primary-600)")) + ); // Subscribe to changes (in case state updates after mount) const unsubscribe = onDiagramStateChange(model, (state: DiagramState) => { - console.log("[CaptureCanvas] State updated:", state.blocks.length, "blocks"); setNodes(state.blocks.map(blockToNode)); - setEdges(state.connections.map(connectionToEdge)); + setEdges(state.connections.map((conn) => connectionToEdge(conn, "var(--color-primary-600)"))); }); return unsubscribe; @@ -349,7 +292,6 @@ export default function CaptureCanvas() { if (request.timestamp <= lastTimestamp.current) return; lastTimestamp.current = request.timestamp; - console.log("[CaptureCanvas] Received capture request:", request); setCaptureRequest(request); }; @@ -372,21 +314,14 @@ export default function CaptureCanvas() { (result: CaptureResult) => { if (!model) return; - console.log("[CaptureCanvas] Capture complete:", result.success ? "success" : result.error); - if (result.success && result.data) { const displayInline = captureRequest?.displayInline ?? true; const mimeType = result.format === "png" ? "image/png" : "image/svg+xml"; if (displayInline) { // Display inline - inject into the widget container - console.log("[CaptureCanvas] Displaying inline"); - console.log("[CaptureCanvas] outerRef.current:", outerRef.current); - - // Find the anywidget container (parent of our React root) // Navigate up from our ref to find the .lynx-widget container const lynxWidget = outerRef.current?.closest(".lynx-widget"); - console.log("[CaptureCanvas] Found .lynx-widget:", lynxWidget); if (lynxWidget) { // Create image element @@ -418,17 +353,10 @@ export default function CaptureCanvas() { } parent = parent.parentElement; } - - console.log("[CaptureCanvas] Image injected successfully"); } else { - console.error("[CaptureCanvas] Could not find .lynx-widget container"); - // Fallback: try to find any parent and log the DOM structure - let parent = outerRef.current?.parentElement; - console.log("[CaptureCanvas] Parent chain:"); - while (parent) { - console.log(" -", parent.tagName, parent.className); - parent = parent.parentElement; - } + console.error( + "[CaptureCanvas] Could not find .lynx-widget container for inline display" + ); } } } @@ -446,6 +374,7 @@ export default function CaptureCanvas() { return (
{ + const waypoints = edge.data?.waypoints; + if (waypoints && Array.isArray(waypoints)) { + waypoints.forEach((waypoint: { x: number; y: number }) => { + minX = Math.min(minX, waypoint.x); + minY = Math.min(minY, waypoint.y); + maxX = Math.max(maxX, waypoint.x); + maxY = Math.max(maxY, waypoint.y); + }); + } + }); + + // Recalculate bounds including edges + const combinedBounds = { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; // Add padding const paddedBounds: ContentBounds = { - x: bounds.x - DEFAULT_PADDING, - y: bounds.y - DEFAULT_PADDING, - width: bounds.width + DEFAULT_PADDING * 2, - height: bounds.height + DEFAULT_PADDING * 2, + x: combinedBounds.x - DEFAULT_PADDING, + y: combinedBounds.y - DEFAULT_PADDING, + width: combinedBounds.width + DEFAULT_PADDING * 2, + height: combinedBounds.height + DEFAULT_PADDING * 2, }; // If both dimensions specified, use them directly @@ -112,23 +141,25 @@ function createCaptureFilter() { * @param width - Output width in pixels * @param height - Output height in pixels * @param transparent - If true, background is transparent + * @param backgroundColor - Background color (actual hex/rgb value, not CSS variable) * @returns Base64-encoded PNG data (without data URL prefix) */ export async function captureToPng( element: HTMLElement, width: number, height: number, - transparent: boolean + transparent: boolean, + backgroundColor: string ): Promise { const dataUrl = await toPng(element, { width, height, - backgroundColor: transparent ? undefined : "var(--color-slate-200)", + backgroundColor: transparent ? undefined : backgroundColor, pixelRatio: 2, // 2x resolution for crisp output filter: createCaptureFilter(), // Force the captured element itself to have the background style: { - background: transparent ? "transparent" : "var(--color-slate-200)", + background: transparent ? "transparent" : backgroundColor, }, }); diff --git a/js/src/palette/BlockPalette.tsx b/js/src/palette/BlockPalette.tsx index b833dc1..99ca43c 100644 --- a/js/src/palette/BlockPalette.tsx +++ b/js/src/palette/BlockPalette.tsx @@ -131,9 +131,7 @@ export default function BlockPalette() { {/* Transfer Function Block */}