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)