diff --git a/.github/workflows/Documentation.yml b/.github/workflows/Documentation.yml
index 02320032a..4e8ec56c9 100644
--- a/.github/workflows/Documentation.yml
+++ b/.github/workflows/Documentation.yml
@@ -4,7 +4,8 @@ on:
push:
branches:
- main
- tags: '*'
+ tags:
+ - 'v[0-9]+\.[0-9]+\.[0-9]+'
pull_request:
types: [labeled, opened, synchronize, reopened]
diff --git a/.gitignore b/.gitignore
index 7e7dd9a01..80dd31e0a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@
*.json
!.zenodo.json
!.markdownlint.json
+!docs/package.json
# System-specific files and directories generated by the BinaryProvider and BinDeps packages
# They contain absolute paths specific to the host computer, and so should not be committed
diff --git a/docs/.gitignore b/docs/.gitignore
new file mode 100644
index 000000000..0587d7400
--- /dev/null
+++ b/docs/.gitignore
@@ -0,0 +1,4 @@
+build/
+node_modules/
+package-lock.json
+Manifest.toml
\ No newline at end of file
diff --git a/docs/Project.toml b/docs/Project.toml
index e534f88e2..7bd92d6e5 100644
--- a/docs/Project.toml
+++ b/docs/Project.toml
@@ -12,7 +12,8 @@ CommonSolve = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
DocumenterInterLinks = "d12716ef-a0f6-4df4-a9f1-a5a34e75c656"
-DocumenterMermaid = "a078cd44-4d9c-4618-b545-3ab9d77f9177"
+DocumenterVitepress = "4710194d-e776-4893-9690-8d956a29c365"
+LiveServer = "16fef848-5104-11e9-1b77-fb7a48bbb589"
ExaModels = "1037b233-b668-4ce9-9b63-f9f681f55dd2"
JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819"
JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
@@ -42,7 +43,8 @@ CommonSolve = "0.2"
DataFrames = "1"
Documenter = "1"
DocumenterInterLinks = "1"
-DocumenterMermaid = "0.2"
+DocumenterVitepress = "0.3"
+LiveServer = "1"
ExaModels = "0.9"
JLD2 = "0.6"
JSON3 = "1"
diff --git a/docs/make.jl b/docs/make.jl
index 96ce0a10d..c8a45afd7 100644
--- a/docs/make.jl
+++ b/docs/make.jl
@@ -27,7 +27,7 @@ using OrdinaryDiffEq
# documentation
using DocumenterInterLinks
using Documenter
-using DocumenterMermaid
+using DocumenterVitepress
using Markdown
using MarkdownAST: MarkdownAST
@@ -174,33 +174,6 @@ ext_dir = abspath(joinpath(@__DIR__, "..", "ext"))
# Include the API reference manager
include("api_reference.jl")
-# ═══════════════════════════════════════════════════════════════════════════════
-# Logger: silence the informational warning about @example blocks whose HTML
-# representation exceeds `example_size_threshold`. The SVG fallback is used
-# automatically (which is what we want for plots), so this warning is harmless.
-# ═══════════════════════════════════════════════════════════════════════════════
-using Logging
-struct ExampleSizeThresholdFilter <: AbstractLogger
- inner::AbstractLogger
-end
-function Logging.min_enabled_level(l::ExampleSizeThresholdFilter)
- return Logging.min_enabled_level(l.inner)
-end
-function Logging.shouldlog(l::ExampleSizeThresholdFilter, level, _module, group, id)
- return Logging.shouldlog(l.inner, level, _module, group, id)
-end
-Logging.catch_exceptions(l::ExampleSizeThresholdFilter) = Logging.catch_exceptions(l.inner)
-function Logging.handle_message(
- l::ExampleSizeThresholdFilter, level, message, args...; kwargs...
-)
- msg = string(message)
- if level == Logging.Warn && occursin("example_size_threshold", msg)
- return nothing
- end
- return Logging.handle_message(l.inner, level, message, args...; kwargs...)
-end
-global_logger(ExampleSizeThresholdFilter(global_logger()))
-
# ═══════════════════════════════════════════════════════════════════════════════
# Literate: generate tutorial.md, tutorial.ipynb, tutorial.jl
# ═══════════════════════════════════════════════════════════════════════════════
@@ -217,7 +190,8 @@ for file in ["tutorial.jl"]
INPUT = joinpath(LITERATE_DIR, file)
# Inject @meta Draft=false so the tutorial executes even with global draft=true
function tutorial_postprocess(content)
- return "```@meta\nDraft = false\n```\n\n" * content
+ # return "```@meta\nDraft = false\n```\n\n" * content
+ return content
end
Literate.markdown(INPUT, MD_OUTPUT; documenter=true, postprocess=tutorial_postprocess)
Literate.notebook(INPUT, NB_OUTPUT; execute=false)
@@ -240,23 +214,11 @@ with_api_reference(src_dir, ext_dir) do api_pages
remotes=nothing, # Disable remote links. Needed for DocumenterReference
warnonly=true,
sitename="OptimalControl.jl",
- format=Documenter.HTML(;
- repolink="https://" * repo_url,
- prettyurls=false,
- assets=[
- asset("https://control-toolbox.org/assets/css/documentation.css"),
- asset("https://control-toolbox.org/assets/js/documentation.js"),
- "assets/custom.css",
- ],
- size_threshold_ignore=[
- joinpath("api", "private.md"),
- joinpath("api", "public.md"),
- "manual-macro-free.md",
- "tutorial.md",
- ],
+ format=DocumenterVitepress.MarkdownVitepress(;
+ repo=repo_url, devbranch="main", devurl="dev", sidebar_drawer=true
),
pages=[
- "Introduction" => "index.md",
+ # index.md is the VitePress root — not listed here
"Guided tour" => "tutorial.md",
"Examples" => [
"Energy minimisation" => "example-double-integrator-energy.md",
@@ -295,4 +257,13 @@ with_api_reference(src_dir, ext_dir) do api_pages
end
# ═══════════════════════════════════════════════════════════════════════════════
-deploydocs(; repo=repo_url * ".git", devbranch="main", push_preview=true)
+# Deploy documentation to GitHub Pages
+# ═══════════════════════════════════════════════════════════════════════════════
+bases_file = joinpath(@__DIR__, "build", "bases.txt")
+if isfile(bases_file)
+ DocumenterVitepress.deploydocs(;
+ repo=repo_url * ".git", devbranch="main", push_preview=true
+ )
+else
+ @info "Skipping deployment: no bases were built (prerelease with existing higher stable release)."
+end
diff --git a/docs/package.json b/docs/package.json
new file mode 100644
index 000000000..823cba814
--- /dev/null
+++ b/docs/package.json
@@ -0,0 +1,21 @@
+{
+ "devDependencies": {
+ "@types/node": "^25.3.5",
+ "@types/markdown-it-footnote": "^3.0.4"
+ },
+ "scripts": {
+ "docs:dev": "vitepress dev build/.documenter",
+ "docs:build": "vitepress build build/.documenter",
+ "docs:preview": "vitepress preview build/.documenter"
+ },
+ "dependencies": {
+ "@nolebase/vitepress-plugin-enhanced-readabilities": "^2.18.2",
+ "@mathjax/src": "^4.1.2",
+ "@mdit/plugin-mathjax": "^0.26.1",
+ "@mdit/plugin-tex": "^0.24.1",
+ "markdown-it-footnote": "^4.0.0",
+ "markdown-it": "^14.1.0",
+ "vitepress": "^1.6.4",
+ "vitepress-plugin-tabs": "^0.8.0"
+ }
+}
diff --git a/docs/src/.vitepress/config.mts b/docs/src/.vitepress/config.mts
new file mode 100644
index 000000000..c86c642b1
--- /dev/null
+++ b/docs/src/.vitepress/config.mts
@@ -0,0 +1,117 @@
+import { defineConfig } from 'vitepress'
+import { tabsMarkdownPlugin } from 'vitepress-plugin-tabs'
+import { mathjaxPlugin } from './mathjax-plugin'
+import { juliaReplTransformer } from './julia-repl-transformer'
+import footnote from "markdown-it-footnote";
+import path from 'path'
+
+const mathjax = mathjaxPlugin()
+
+function getBaseRepository(base: string): string {
+ if (!base || base === '/') return '/';
+ const parts = base.split('/').filter(Boolean);
+ return parts.length > 0 ? `/${parts[0]}/` : '/';
+}
+
+const baseTemp = {
+ base: 'REPLACE_ME_DOCUMENTER_VITEPRESS',// TODO: replace this in makedocs!
+}
+
+const nav = [
+ { text: 'Home', link: '/index' },
+ { component: 'VersionPicker' }
+]
+
+// https://vitepress.dev/reference/site-config
+export default defineConfig({
+ base: 'REPLACE_ME_DOCUMENTER_VITEPRESS',// TODO: replace this in makedocs!
+ title: 'REPLACE_ME_DOCUMENTER_VITEPRESS',
+ description: 'REPLACE_ME_DOCUMENTER_VITEPRESS',
+ lastUpdated: true,
+ cleanUrls: true,
+ ignoreDeadLinks: true,
+ outDir: 'REPLACE_ME_DOCUMENTER_VITEPRESS', // This is required for MarkdownVitepress to work correctly...
+ head: [
+ ['link', { rel: 'icon', href: 'REPLACE_ME_DOCUMENTER_VITEPRESS_FAVICON' }],
+ ['link', { rel: 'stylesheet', href: 'https://control-toolbox.org/assets/css/vitepress-documentation.css' }],
+ ['script', {src: `${getBaseRepository(baseTemp.base)}versions.js`}],
+ ['script', {src: 'https://control-toolbox.org/assets/js/vitepress-documentation.js'}],
+ ['script', {src: `${baseTemp.base}siteinfo.js`}],
+ // REPLACE_ME_DOCUMENTER_VITEPRESS_NOINDEX
+ ],
+
+ markdown: {
+ codeTransformers: [juliaReplTransformer()],
+ config(md) {
+ md.use(tabsMarkdownPlugin);
+ md.use(footnote);
+ mathjax.markdownConfig(md);
+ },
+ theme: {
+ light: "github-light",
+ dark: "github-dark"
+ },
+ },
+ vite: {
+ plugins: [
+ mathjax.vitePlugin,
+ // Escape {{ }} inside backtick inline code in .md files before VitePress compiles
+ // them as Vue SFCs. Without this, Julia syntax like `@Lie {{H, K}, L}` (Poisson
+ // bracket notation) would be parsed as a Vue template expression and break the build.
+ {
+ name: 'escape-double-braces-in-inline-code',
+ enforce: 'pre' as const,
+ transform(code: string, id: string) {
+ if (!id.endsWith('.md')) return
+ return code.replace(/`([^`\n]*)`/g, (match: string, content: string) => {
+ if (!content.includes('{{') && !content.includes('}}')) return match
+ return '`' + content.replace(/\{\{/g, '{{').replace(/\}\}/g, '}}') + '`'
+ })
+ },
+ },
+ ],
+ define: {
+ __DEPLOY_ABSPATH__: JSON.stringify('REPLACE_ME_DOCUMENTER_VITEPRESS_DEPLOY_ABSPATH'),
+ },
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, '../components')
+ }
+ },
+ optimizeDeps: {
+ exclude: [
+ '@nolebase/vitepress-plugin-enhanced-readabilities/client',
+ 'vitepress',
+ '@nolebase/ui',
+ ],
+ },
+ ssr: {
+ noExternal: [
+ // If there are other packages that need to be processed by Vite, you can add them here.
+ '@nolebase/vitepress-plugin-enhanced-readabilities',
+ '@nolebase/ui',
+ ],
+ },
+ },
+ themeConfig: {
+ outline: 'deep',
+ logo: 'REPLACE_ME_DOCUMENTER_VITEPRESS',
+ search: {
+ provider: 'local',
+ options: {
+ detailedView: true
+ }
+ },
+ nav,
+ sidebar: 'REPLACE_ME_DOCUMENTER_VITEPRESS',
+ sidebarDrawer: 'REPLACE_ME_DOCUMENTER_VITEPRESS_SIDEBAR_DRAWER',
+ editLink: 'REPLACE_ME_DOCUMENTER_VITEPRESS',
+ socialLinks: [
+ { icon: 'github', link: 'REPLACE_ME_DOCUMENTER_VITEPRESS' }
+ ],
+ footer: {
+ message: 'Made with DocumenterVitepress.jl
',
+ copyright: `© Copyright ${new Date().getUTCFullYear()}.`
+ }
+ }
+})
diff --git a/docs/src/.vitepress/julia-repl-transformer.ts b/docs/src/.vitepress/julia-repl-transformer.ts
new file mode 100644
index 000000000..72f9bb969
--- /dev/null
+++ b/docs/src/.vitepress/julia-repl-transformer.ts
@@ -0,0 +1,98 @@
+import type { ShikiTransformer } from "shiki"
+import type { Element, ElementContent } from "hast"
+
+type PromptKind = "julia" | "pkg" | null
+
+export function juliaReplTransformer(): ShikiTransformer {
+ let promptInfoByLine: Array<{ len: number; kind: PromptKind }> = []
+ let isJuliaBlock = false
+ const rules: Array<{ kind: PromptKind; re: RegExp }> = [
+ { kind: "julia", re: /^julia>/ },
+ { kind: "pkg", re: /^(\([^)]*\)\s*)?pkg>/ }, // handles (@v1.9) pkg>
+ ]
+
+ function classify(line: string): { len: number; kind: PromptKind } {
+ for (const r of rules) {
+ const m = line.match(r.re)
+ if (m) return { len: m[0].length, kind: r.kind }
+ }
+
+ return { len: 0, kind: null }
+ }
+
+ // `self` lets the `code` hook pass this transformer to its recursive calls.
+ const self: ShikiTransformer = {
+ name: "julia-repl-prompts",
+
+ preprocess(code, options) {
+ isJuliaBlock = options.lang === "julia"
+ return code
+ },
+
+ tokens(tokens) {
+ if (!isJuliaBlock) {
+ promptInfoByLine = []
+ return
+ }
+
+ promptInfoByLine = tokens.map((lineTokens) => {
+ const line = lineTokens.map((t) => t.content).join("")
+ return classify(line)
+ })
+ },
+
+ span(node, line, col) {
+ if (!isJuliaBlock) return
+
+ const info = promptInfoByLine[line - 1]
+ if (!info || !info.kind || info.len <= 0) return
+
+ if (col < info.len) {
+ this.addClassToHast(node, "repl-prompt")
+ this.addClassToHast(node, `repl-prompt-${info.kind}`)
+ }
+ },
+
+ // `julia-repl-runs=...` fences (writer.jl): re-highlight each run with its
+ // own grammar, stitch into one
. Julia runs reuse this transformer
+ // for prompt styling; their empty meta no-ops this hook.
+ code(node) {
+ const raw = (this.options.meta as { __raw?: string } | undefined)?.__raw ?? ""
+ const match = raw.match(/julia-repl-runs=(\S+)/)
+ if (!match) return
+
+ const srcLines = this.source.replace(/\n$/, "").split("\n")
+ const children: ElementContent[] = []
+ let cursor = 0
+ for (const spec of match[1].split(",")) {
+ const [lang, countStr] = spec.split(":")
+ const count = Number(countStr)
+ const text = srcLines.slice(cursor, cursor + count).join("\n")
+ cursor += count
+
+ const hast = this.codeToHast(text, {
+ ...this.options,
+ lang,
+ meta: {},
+ // julia input reuses prompt styling; ansi needs none.
+ transformers: lang === "julia" ? [self] : [],
+ })
+ const pre = hast.children[0] as Element
+ const codeEl = pre.children.find(
+ (c): c is Element => c.type === "element" && c.tagName === "code",
+ )
+ if (!codeEl) continue
+ for (const lineEl of codeEl.children) {
+ if (lineEl.type === "element" && lineEl.tagName === "span") {
+ children.push(lineEl)
+ children.push({ type: "text", value: "\n" })
+ }
+ }
+ }
+ if (children.length > 0) children.pop() // drop the trailing newline
+ node.children = children
+ },
+ }
+
+ return self
+}
diff --git a/docs/src/.vitepress/mathjax-plugin.ts b/docs/src/.vitepress/mathjax-plugin.ts
new file mode 100644
index 000000000..4307d32d7
--- /dev/null
+++ b/docs/src/.vitepress/mathjax-plugin.ts
@@ -0,0 +1,142 @@
+// adapter from https://github.com/orgs/vuepress-theme-hope/discussions/5178#discussioncomment-15642629
+// mathjax-plugin.ts
+// @ts-ignore
+import MathJax from '@mathjax/src'
+import type { Plugin as VitePlugin } from 'vite'
+import type MarkdownIt from 'markdown-it'
+import { tex as mdTex } from '@mdit/plugin-tex'
+
+const mathjaxStyleModuleID = 'virtual:mathjax-styles.css'
+
+interface MathJaxOptions {
+ font?: string
+}
+
+async function initializeMathJax(options: MathJaxOptions = {}) {
+ const font = options.font || 'mathjax-newcm'
+
+ const config: any = {
+ loader: {
+ load: [
+ 'input/tex',
+ 'output/svg',
+ '[tex]/boldsymbol',
+ '[tex]/braket',
+ '[tex]/mathtools',
+ ],
+ paths: { mathjax: '@mathjax/src/bundle' },
+ },
+ tex: {
+ tags: 'ams',
+ packages: {
+ '[+]': ['boldsymbol', 'braket', 'mathtools'],
+ },
+ },
+ output: {
+ font,
+ displayOverflow: 'linebreak',
+ mtextInheritFont: true,
+ },
+ svg: {
+ fontCache: 'none', // critical: avoids async font loading
+ },
+ }
+
+ await MathJax.init(config)
+ await MathJax.startup.document.outputJax.font.loadDynamicFiles()
+}
+
+export function mathjaxPlugin(options: MathJaxOptions = {}) {
+ let adaptor: any
+ let initialized = false
+
+ async function ensureInitialized() {
+ if (!initialized) {
+ await initializeMathJax(options)
+ adaptor = MathJax.startup.adaptor
+ initialized = true
+ }
+ }
+
+ function renderMath(content: string, displayMode: boolean): string {
+ if (!initialized) {
+ throw new Error('MathJax not initialized')
+ }
+
+ const node = MathJax.tex2svg(content, { display: displayMode })
+
+ // Prevent Vue from touching MathJax output
+ adaptor.setAttribute(node, 'v-pre', '')
+
+ let html = adaptor.outerHTML(node)
+
+ // Preserve spaces inside mjx-break (SVG only)
+ html = html.replace(
+ /(.*?)<\/mjx-break>/g,
+ (_: string, attr: string, inner: string) =>
+ `${inner.replace(/ /g, ' ')} `,
+ )
+
+ // Wrap only display equations (not inline math)
+ html = html.replace(
+ /(]*display="true"[^>]*>)([\s\S]*?)(<\/mjx-container>)/,
+ '$1$2$3'
+ )
+
+ return html
+ }
+
+ function getMathJaxStyles(): string {
+ return initialized
+ ? adaptor.textContent(MathJax.svgStylesheet()) || ''
+ : ''
+ }
+
+ function resetMathJax(): void {
+ if (!initialized) return
+ MathJax.texReset()
+ MathJax.typesetClear()
+ }
+
+ function viteMathJax(): VitePlugin {
+ const virtualModuleID = '\0' + mathjaxStyleModuleID
+
+ return {
+ name: 'mathjax-styles',
+
+ resolveId(id) {
+ if (id === mathjaxStyleModuleID) {
+ return virtualModuleID
+ }
+ },
+
+ async load(id) {
+ if (id === virtualModuleID) {
+ await ensureInitialized()
+ return getMathJaxStyles()
+ }
+ },
+ }
+ }
+
+ function mdMathJax(md: MarkdownIt): void {
+ mdTex(md, {
+ render: renderMath,
+ })
+
+ const orig = md.render
+ md.render = function (...args) {
+ resetMathJax()
+ return orig.apply(this, args)
+ }
+ }
+
+ const init = ensureInitialized()
+
+ return {
+ vitePlugin: viteMathJax(),
+ markdownConfig: mdMathJax,
+ styleModuleID: mathjaxStyleModuleID,
+ init,
+ }
+}
\ No newline at end of file
diff --git a/docs/src/.vitepress/theme/docstrings.css b/docs/src/.vitepress/theme/docstrings.css
new file mode 100644
index 000000000..4d992460d
--- /dev/null
+++ b/docs/src/.vitepress/theme/docstrings.css
@@ -0,0 +1,51 @@
+.jldocstring.custom-block {
+ border: 1px solid var(--vp-c-gray-2);
+ color: var(--vp-c-text-1);
+ overflow: hidden;
+}
+
+.jldocstring.custom-block summary {
+ font-weight: 700;
+ cursor: pointer;
+ user-select: none;
+ margin: 0 0 8px;
+}
+.jldocstring.custom-block summary a {
+ pointer-events: none;
+ text-decoration: none;
+}
+
+.jldocstring.custom-block .source-link {
+ border: 1px solid var(--vp-c-gray-2);
+ border-radius: 4px;
+ text-decoration: none;
+ background-color: #414040;
+ float: right;
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(-5px);
+ transition: all 0.5s cubic-bezier(0.25, 0.1, 0.25, 1);
+}
+
+.jldocstring.custom-block .source-link a {
+ text-decoration: none;
+ color: #e5e5e5;
+}
+
+.jldocstring.custom-block .source-link a:hover {
+ text-decoration: underline;
+}
+
+.jldocstring.custom-block:hover .source-link {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+}
+
+@media (max-width: 768px) {
+ .jldocstring.custom-block .source-link {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+ }
+}
diff --git a/docs/src/.vitepress/theme/index.ts b/docs/src/.vitepress/theme/index.ts
new file mode 100644
index 000000000..010b304e7
--- /dev/null
+++ b/docs/src/.vitepress/theme/index.ts
@@ -0,0 +1,77 @@
+// .vitepress/theme/index.ts
+import { h } from 'vue'
+import DefaultTheme from 'vitepress/theme'
+import type { Theme as ThemeConfig } from 'vitepress'
+import 'virtual:mathjax-styles.css';
+
+import {
+ NolebaseEnhancedReadabilitiesMenu,
+ NolebaseEnhancedReadabilitiesScreenMenu,
+} from '@nolebase/vitepress-plugin-enhanced-readabilities/client'
+
+import VersionPicker from "@/VersionPicker.vue"
+import AuthorBadge from '@/AuthorBadge.vue'
+import Authors from '@/Authors.vue'
+import SidebarDrawerToggle from '@/SidebarDrawerToggle.vue'
+// __DV_PLUGIN_COMPONENT_IMPORTS__
+
+import { enhanceAppWithTabs } from 'vitepress-plugin-tabs/client'
+
+import '@nolebase/vitepress-plugin-enhanced-readabilities/client/style.css'
+import './style.css' // You could setup your own, or else a default will be copied.
+import './docstrings.css' // You could setup your own, or else a default will be copied.
+import './overrides.css' // You could setup your own, or else a default will be copied.
+
+// `v-exec-scripts` runs the
+
+
\ No newline at end of file
diff --git a/docs/src/components/Authors.vue b/docs/src/components/Authors.vue
new file mode 100644
index 000000000..ee7920b42
--- /dev/null
+++ b/docs/src/components/Authors.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/src/components/SidebarDrawerToggle.vue b/docs/src/components/SidebarDrawerToggle.vue
new file mode 100644
index 000000000..96a10b0b0
--- /dev/null
+++ b/docs/src/components/SidebarDrawerToggle.vue
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
diff --git a/docs/src/components/VersionPicker.vue b/docs/src/components/VersionPicker.vue
new file mode 100644
index 000000000..d03b2e84e
--- /dev/null
+++ b/docs/src/components/VersionPicker.vue
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/src/notebooks/tutorial.ipynb b/docs/src/notebooks/tutorial.ipynb
index 652eba195..464dc3e9b 100644
--- a/docs/src/notebooks/tutorial.ipynb
+++ b/docs/src/notebooks/tutorial.ipynb
@@ -82,7 +82,10 @@
"metadata": {},
"outputs": [],
"source": [
- "t0 = 0; tf = 1; x0 = [-1, 0]; xf = [0, 0];"
+ "t0 = 0;\n",
+ "tf = 1;\n",
+ "x0 = [-1, 0];\n",
+ "xf = [0, 0];"
]
},
{
@@ -112,7 +115,7 @@
"\n",
" ẋ(t) == [v(t), u(t)]\n",
"\n",
- " 0.5∫( u(t)^2 ) → min\n",
+ " 0.5∫(u(t)^2) → min\n",
"end"
]
},
@@ -378,10 +381,10 @@
"source": [
"using MadNLP\n",
"\n",
- "sol_ipopt = solve(goddard; grid_size=250, display=false)\n",
+ "sol_ipopt = solve(goddard; grid_size=250, display=false)\n",
"sol_madnlp = solve(goddard, :madnlp; grid_size=250, display=false)\n",
"\n",
- "println(\"Ipopt : r(tf) = \", objective(sol_ipopt), \", \", iterations(sol_ipopt), \" iters\")\n",
+ "println(\"Ipopt : r(tf) = \", objective(sol_ipopt), \", \", iterations(sol_ipopt), \" iters\")\n",
"println(\"MadNLP : r(tf) = \", objective(sol_madnlp), \", \", iterations(sol_madnlp), \" iters\")"
]
},
@@ -414,12 +417,12 @@
"sol_cold = solve(goddard; grid_size=1000, display=false)\n",
"\n",
"# warm cascade: grid 50 first, then grid 1000 initialised from it\n",
- "s50 = solve(goddard; grid_size=50, display=false)\n",
+ "s50 = solve(goddard; grid_size=50, display=false)\n",
"s1000 = solve(goddard; grid_size=1000, init=s50, display=false)\n",
"\n",
"println(\"cold grid 1000 : \", iterations(sol_cold), \" iters\")\n",
- "println(\"cascade grid 50 (warm-up): \", iterations(s50), \" iters\")\n",
- "println(\"cascade grid 1000 (warm) : \", iterations(s1000), \" iters\")"
+ "println(\"cascade grid 50 (warm-up): \", iterations(s50), \" iters\")\n",
+ "println(\"cascade grid 1000 (warm) : \", iterations(s1000), \" iters\")"
]
},
{
@@ -435,7 +438,7 @@
"metadata": {},
"outputs": [],
"source": [
- "plt = plot(s50; label=\"50\", size=(800, 800))\n",
+ "plt = plot(s50; label=\"50\", size=(800, 800))\n",
"plot!(plt, s1000; label=\"1000\")"
]
},
@@ -471,17 +474,43 @@
"# Phase 1: u = 1, stop when m = mf (fuel depleted)\n",
"bang1!(dx, x, p, t) = (dx[:] = F0(x) + F1(x))\n",
"cb_fuel = ContinuousCallback((u, t, int) -> u[3] - mf, terminate!)\n",
- "sol_bang1 = solve(ODEProblem(bang1!, [r0, v0, m0], (t0, 100.0)), Tsit5(); callback=cb_fuel, reltol=1e-8, abstol=1e-8)\n",
+ "sol_bang1 = solve(\n",
+ " ODEProblem(bang1!, [r0, v0, m0], (t0, 100.0)),\n",
+ " Tsit5();\n",
+ " callback=cb_fuel,\n",
+ " reltol=1e-8,\n",
+ " abstol=1e-8,\n",
+ ")\n",
"t1_bang, x1_bang = sol_bang1.t[end], sol_bang1[:, end]\n",
"\n",
"# Phase 2: u = 0, stop when v = 0 (apogee)\n",
"bang2!(dx, x, p, t) = (dx[:] = F0(x))\n",
"cb_apogee = ContinuousCallback((u, t, int) -> u[2], terminate!)\n",
- "sol_bang2 = solve(ODEProblem(bang2!, x1_bang, (t1_bang, 1000.0)), Tsit5(); callback=cb_apogee, reltol=1e-8, abstol=1e-8)\n",
+ "sol_bang2 = solve(\n",
+ " ODEProblem(bang2!, x1_bang, (t1_bang, 1000.0)),\n",
+ " Tsit5();\n",
+ " callback=cb_apogee,\n",
+ " reltol=1e-8,\n",
+ " abstol=1e-8,\n",
+ ")\n",
"tf_bang, rf_bang = sol_bang2.t[end], sol_bang2[1, end]\n",
"\n",
- "println(\"Bang-bang: r(tf) = \", round(rf_bang, digits=6), \" (t1=\", round(t1_bang, digits=4), \", tf=\", round(tf_bang, digits=4), \")\")\n",
- "println(\"Optimal: r(tf) = \", round(objective(sol_cold), digits=6), \" ( tf=\", round(variable(sol_cold), digits=4), \")\")"
+ "println(\n",
+ " \"Bang-bang: r(tf) = \",\n",
+ " round(rf_bang; digits=6),\n",
+ " \" (t1=\",\n",
+ " round(t1_bang; digits=4),\n",
+ " \", tf=\",\n",
+ " round(tf_bang; digits=4),\n",
+ " \")\",\n",
+ ")\n",
+ "println(\n",
+ " \"Optimal: r(tf) = \",\n",
+ " round(objective(sol_cold); digits=6),\n",
+ " \" ( tf=\",\n",
+ " round(variable(sol_cold); digits=4),\n",
+ " \")\",\n",
+ ")"
]
},
{
@@ -633,7 +662,7 @@
"source": [
"nle!(s, p0, _) = (s[:] = S(p0))\n",
"\n",
- "p_of_t = costate(direct_sol) # costate as a function of time\n",
+ "p_of_t = costate(direct_sol) # costate as a function of time\n",
"p0_guess = p_of_t(t0) # initial costate from the direct method\n",
"\n",
"prob = NonlinearProblem(nle!, p0_guess)\n",
diff --git a/docs/src/tutorial.md b/docs/src/tutorial.md
index e204eb2fd..ce9a22d74 100644
--- a/docs/src/tutorial.md
+++ b/docs/src/tutorial.md
@@ -1,7 +1,3 @@
-```@meta
-Draft = false
-```
-
```@meta
EditURL = "../src-literate/tutorial.jl"
```
@@ -68,7 +64,10 @@ subject to the dynamics
```
````@example tutorial
-t0 = 0; tf = 1; x0 = [-1, 0]; xf = [0, 0];
+t0 = 0;
+tf = 1;
+x0 = [-1, 0];
+xf = [0, 0];
nothing #hide
````
@@ -89,7 +88,7 @@ ocp = @def begin
ẋ(t) == [v(t), u(t)]
- 0.5∫( u(t)^2 ) → min
+ 0.5∫(u(t)^2) → min
end
nothing # hide
````
@@ -267,10 +266,10 @@ nothing # hide
````@example tutorial
using MadNLP
-sol_ipopt = solve(goddard; grid_size=250, display=false)
+sol_ipopt = solve(goddard; grid_size=250, display=false)
sol_madnlp = solve(goddard, :madnlp; grid_size=250, display=false)
-println("Ipopt : r(tf) = ", objective(sol_ipopt), ", ", iterations(sol_ipopt), " iters")
+println("Ipopt : r(tf) = ", objective(sol_ipopt), ", ", iterations(sol_ipopt), " iters")
println("MadNLP : r(tf) = ", objective(sol_madnlp), ", ", iterations(sol_madnlp), " iters")
````
@@ -288,18 +287,18 @@ A solution can be passed **directly** as the initial guess of another solve —
sol_cold = solve(goddard; grid_size=1000, display=false)
# warm cascade: grid 50 first, then grid 1000 initialised from it
-s50 = solve(goddard; grid_size=50, display=false)
+s50 = solve(goddard; grid_size=50, display=false)
s1000 = solve(goddard; grid_size=1000, init=s50, display=false)
println("cold grid 1000 : ", iterations(sol_cold), " iters")
-println("cascade grid 50 (warm-up): ", iterations(s50), " iters")
-println("cascade grid 1000 (warm) : ", iterations(s1000), " iters")
+println("cascade grid 50 (warm-up): ", iterations(s50), " iters")
+println("cascade grid 1000 (warm) : ", iterations(s1000), " iters")
````
**Message:** what matters is the iteration count *at the expensive grid* — the warm-started `iterations(s1000)` is well below the cold `iterations(sol_cold)`, even though the cheap `grid_size=50` warm-up adds iterations of its own to the running total; since a grid-50 iteration is far cheaper than a grid-1000 iteration, the cascade still wins on wall-clock time. Overlay the successive solutions to watch convergence:
````@example tutorial
-plt = plot(s50; label="50", size=(800, 800))
+plt = plot(s50; label="50", size=(800, 800))
plot!(plt, s1000; label="1000")
````
@@ -315,17 +314,43 @@ using OrdinaryDiffEq # ODE solver (callbacks for the bang-bang simulation)
# Phase 1: u = 1, stop when m = mf (fuel depleted)
bang1!(dx, x, p, t) = (dx[:] = F0(x) + F1(x))
cb_fuel = ContinuousCallback((u, t, int) -> u[3] - mf, terminate!)
-sol_bang1 = solve(ODEProblem(bang1!, [r0, v0, m0], (t0, 100.0)), Tsit5(); callback=cb_fuel, reltol=1e-8, abstol=1e-8)
+sol_bang1 = solve(
+ ODEProblem(bang1!, [r0, v0, m0], (t0, 100.0)),
+ Tsit5();
+ callback=cb_fuel,
+ reltol=1e-8,
+ abstol=1e-8,
+)
t1_bang, x1_bang = sol_bang1.t[end], sol_bang1[:, end]
# Phase 2: u = 0, stop when v = 0 (apogee)
bang2!(dx, x, p, t) = (dx[:] = F0(x))
cb_apogee = ContinuousCallback((u, t, int) -> u[2], terminate!)
-sol_bang2 = solve(ODEProblem(bang2!, x1_bang, (t1_bang, 1000.0)), Tsit5(); callback=cb_apogee, reltol=1e-8, abstol=1e-8)
+sol_bang2 = solve(
+ ODEProblem(bang2!, x1_bang, (t1_bang, 1000.0)),
+ Tsit5();
+ callback=cb_apogee,
+ reltol=1e-8,
+ abstol=1e-8,
+)
tf_bang, rf_bang = sol_bang2.t[end], sol_bang2[1, end]
-println("Bang-bang: r(tf) = ", round(rf_bang, digits=6), " (t1=", round(t1_bang, digits=4), ", tf=", round(tf_bang, digits=4), ")")
-println("Optimal: r(tf) = ", round(objective(sol_cold), digits=6), " ( tf=", round(variable(sol_cold), digits=4), ")")
+println(
+ "Bang-bang: r(tf) = ",
+ round(rf_bang; digits=6),
+ " (t1=",
+ round(t1_bang; digits=4),
+ ", tf=",
+ round(tf_bang; digits=4),
+ ")",
+)
+println(
+ "Optimal: r(tf) = ",
+ round(objective(sol_cold); digits=6),
+ " ( tf=",
+ round(variable(sol_cold); digits=4),
+ ")",
+)
````
The optimal thrust profile uses a **singular arc** — it does not simply push at the maximum. Overlaying the two trajectories on the altitude–velocity plane makes the difference visible:
@@ -433,7 +458,7 @@ nothing # hide
````@example tutorial
nle!(s, p0, _) = (s[:] = S(p0))
-p_of_t = costate(direct_sol) # costate as a function of time
+p_of_t = costate(direct_sol) # costate as a function of time
p0_guess = p_of_t(t0) # initial costate from the direct method
prob = NonlinearProblem(nle!, p0_guess)