Skip to main content

A symbolic perturbation theory toolkit built on SymPy

Project description

asymptotics

A symbolic perturbation theory toolkit for Python

Python SymPy Tests License

Write your perturbation problem as a string. Get symbolic order-by-order results,
LaTeX display, numerical verification, and direct evaluation — automatically.


What is asymptotics?

asymptotics is a Python library that automates classical perturbation methods for algebraic equations and ordinary differential equations (ODEs). Instead of manually deriving order-by-order equations, substituting ansätze, and solving each level — you write your problem as a plain string and call a method:

from asymptotics import ODE

eq  = ODE("u'' + u + eps*u**3", small_param="eps",
          conditions=["u(0) = 1", "u'(0) = 0"])

sol = eq.expand_lindstedt(order=2)
sol.show()       # beautiful LaTeX in Jupyter; clean text in terminal

The library handles the rest: symbolic expansion, secular term elimination, condition application, and optional numerical comparison against SciPy.

Unlike black-box tools (Mathematica's AsymptoticDSolveValue), asymptotics exposes every intermediate step — each order's ODE, its general and particular solution, secular terms, frequency corrections — as live SymPy expressions you can inspect and manipulate.


Installation

pip install asymptotics

For local development with example notebooks:

git clone https://github.com/saadgroup/asymptotics
cd asymptotics
pip install -e ".[dev,notebook]"

Requirements: Python ≥ 3.10 · SymPy ≥ 1.12 · NumPy ≥ 1.24 · SciPy · Matplotlib


Methods at a glance

Class Method Use when…
AlgebraicEquation .expand_regular(order) Nonlinear algebraic equation $f(x,\varepsilon)=0$
AlgebraicSystem .expand_regular(order) Coupled algebraic system
ODE .expand_regular(order) ODE with small nonlinear/forcing term (IVP or BVP)
ODE .expand_lindstedt(order) Nonlinear oscillator — strains time to remove secular terms
ODE .expand_multiple_scales(order) Oscillator with slow amplitude/phase modulation
ODE .expand_boundary_layer() Singular BVP — $\varepsilon$ multiplies highest derivative
ODE .begin_expansion(order) Step-by-step control when SymPy cannot solve a leading order
ODESystem .expand_regular(order) Coupled system of ODEs

Every result supports a consistent four-method API:

Method What it does
sol.show() LaTeX display in Jupyter; plain text in terminal
sol.eval(eps, at=...) Evaluate composite as a NumPy array
sol.compare_numeric(eps) Numerical verification plot via SciPy
sol.to_latex(...) Export ready-to-paste LaTeX source

Quick examples

Algebraic equation

from asymptotics import AlgebraicEquation

eq  = AlgebraicEquation("x**3 + eps*x - 1", dependent="x", small_param="eps")
sol = eq.expand_regular(order=3)
sol.show()
# x(ε) = 1 - ε/3 - ε³/81 + O(ε⁴)

sol[0].solution    # x₀ = 1
sol[1].solution    # x₁ = -1/3
sol.composite      # full SymPy expression

sol.eval(eps=0.1)                          # float
sol.eval(eps=[0.1, 0.2, 0.3])             # ndarray
sol.compare_numeric(eps=0.3)              # plot vs scipy.fsolve

Coupled algebraic system

from asymptotics import AlgebraicSystem

sys = AlgebraicSystem(
    equations   = ["x**2 + eps*sin(y) - 1", "y - eps*cos(x)"],
    dependents  = ["x", "y"],
    small_param = "eps",
)
sol = sys.expand_regular(order=3)
sol.show()

sol["x"].composite   # expansion for x(ε)
sol["y"].composite   # expansion for y(ε)

Regular perturbation — IVP

from asymptotics import ODE

# dependent='u' and independent='t' inferred automatically from conditions
eq = ODE(
    "u'' + u + eps*u**3",
    small_param = "eps",
    conditions  = ["u(0) = 1", "u'(0) = 0"],
)
sol = eq.expand_regular(order=2)
sol.show()

sol[1].secular   # True — secular terms detected; use Lindstedt or multiple scales

Regular perturbation — BVP

# BVP detected automatically from two distinct boundary points
# independent='x' inferred
eq = ODE(
    "u'' + eps*u",
    small_param = "eps",
    conditions  = ["u(0) = 0", "u(1) = 1"],
)
sol = eq.expand_regular(order=3)
sol.show()

Lindstedt–Poincaré

Fix secular terms in nonlinear oscillators by straining the time coordinate $\tau = \omega(\varepsilon),t$.

eq = ODE(
    "u'' + u + eps*u**3",      # Duffing oscillator
    small_param = "eps",
    conditions  = ["u(0) = 1", "u'(0) = 0"],
)
sol = eq.expand_lindstedt(order=2)
sol.show()

sol.omega_0           # ω₀ = 1   (auto-detected from unperturbed equation)
sol.omega_expansion   # ω(ε) = 1 + 3ε/8 - 21ε²/256
sol[1].omega_k_val    # ω₁ = 3/8  (classical result)
sol.composite_t       # u(t, ε) — uniformly valid in physical time t

import numpy as np
t_vals = np.linspace(0, 40, 500)
sol.eval(eps=0.1, at=t_vals)        # ndarray
sol.compare_numeric(eps=0.1)        # plot vs scipy.solve_ivp

Works for any natural frequency — detected automatically from the unperturbed equation:

eq = ODE("u'' + 4*u + eps*u**3", small_param="eps",
         conditions=["u(0) = 1", "u'(0) = 0"])
sol = eq.expand_lindstedt(order=2)
sol.omega_0   # 2 — detected from u'' + 4u = 0

Multiple scales

For problems where amplitude or phase evolve slowly.

# Weakly damped oscillator
eq = ODE(
    "u'' + u + eps*u'",
    small_param = "eps",
    conditions  = ["u(0) = 1", "u'(0) = 0"],
)
sol = eq.expand_multiple_scales(order=1)
sol.show()

sol.amplitude_A   # A(T₁) = e^{-T₁/2}  — exact Bernoulli solution
sol.composite_t   # e^{-εt/2} · cos(t)  — matches exact result at O(1)
# Van der Pol oscillator — limit cycle
eq = ODE(
    "u'' + u + eps*(u**2 - 1)*u'",
    small_param = "eps",
    conditions  = ["u(0) = 1", "u'(0) = 0"],
)
sol = eq.expand_multiple_scales(order=1)
sol.amplitude_A   # 2√(eᵀ¹/(eᵀ¹+3))  →  2  as T₁→∞  (limit cycle)

Boundary layers

For singular BVPs where $\varepsilon$ multiplies the highest derivative. The layer location is detected automatically from the sign of $p(x)$.

eq = ODE(
    "eps*u'' + u' + u",   # p(0) = 1 > 0  →  layer at x = 0
    small_param = "eps",
    conditions  = ["u(0) = 0", "u(1) = 1"],
)
sol = eq.expand_boundary_layer()
sol.show()

sol.layer_location   # 'x = 0'
sol.outer            # outer solution (away from layer)
sol.inner            # inner solution U(ξ) in stretched coord ξ = x/ε
sol.composite        # u_out + u_in − u_match  (Van Dyke rule)

sol.compare_numeric(eps=0.05)

Variable coefficients are fully supported:

eq = ODE(
    "eps*u'' + (1 + x)*u' - u",
    small_param = "eps",
    conditions  = ["u(0) = 1", "u(1) = 2"],
)

Coupled ODE system

from asymptotics import ODESystem

sys = ODESystem(
    equations   = ["u' + u + eps*v", "v' + 2*v + eps*u**2"],
    dependents  = ["u", "v"],
    small_param = "eps",
    independent = "t",
    conditions  = ["u(0) = 1", "v(0) = 1"],
)
sol = sys.expand_regular(order=2)
sol.show()

sol["u"].composite              # full expansion for u(t, ε)
sol["v"].composite              # full expansion for v(t, ε)
sol["u"][1].particular_solution # u₁(t)

import numpy as np
t_vals = np.linspace(0, 10, 200)
sol.eval(eps=0.1, at=t_vals)    # {'u': ndarray, 'v': ndarray}
sol.compare_numeric(eps=0.1)

Works for any number of coupled equations.


Step-by-step API

When SymPy cannot solve a leading-order equation (e.g. a nonlinear ODE at O(1)), begin_expansion() gives you full manual control while letting the library handle all linear higher-order equations automatically.

sol = eq.begin_expansion(order=2)

# Inspect all equations immediately — nothing is solved yet
sol.show()

# Examine order-k ODE
sol[0].ode    # O(1) equation — purely symbolic
sol[1].ode    # O(ε) equation — symbolic if O(1) unsolved;
              #                  fully substituted if O(1) is solved

# Solve or provide solutions
sol[0].solve()                      # try SymPy — fails gracefully with clear message
sol[0].set_solution("4*eta*(1-eta)") # provide expression as string or SymPy expr
sol[1].solve()                      # SymPy handles linear higher orders
sol.solve_all()                     # attempt all remaining

# Inspect state
sol[k].is_solved                    # bool
sol[k].particular_solution          # SymPy expression (if solved)
sol[k].general_solution             # SymPy expression (if solved)
sol.n_solved                        # number of solved orders
sol.n_pending                       # number of unsolved orders

# Full standard API once all orders are solved
sol.composite
sol.show()
sol.to_latex()
sol.eval(eps=0.1, at=t_vals)
sol.compare_numeric(eps=0.1)

If composite, eval, compare_numeric, or to_latex is called before all orders are solved, a clear NotReadyError lists exactly which orders are pending.


Non-standard gauge sequences

By default the ansatz uses the standard power sequence ${1, \varepsilon, \varepsilon^2, \ldots}$. You can supply a non-standard gauge when the problem calls for it:

# Half-power gauge: {1, √ε, ε, ε^(3/2), ...}
sol = eq.expand_regular(order=3, gauge="sqrt(eps)")

# Logarithmic gauge: {1, log(ε), log²(ε), ...}
sol = eq.expand_regular(order=2, gauge=["1", "log(eps)", "log(eps)**2"])

# Inspect the gauge used
sol.gauge   # list of SymPy gauge functions

Symbolic parameters

Parameters other than the dependent variable, independent variable, and small parameter are detected automatically and a warning is issued:

eq = ODE("u'' + a*u + eps*u**3", small_param="eps",
         conditions=["u(0) = 1", "u'(0) = 0"])
# ⚠️  symbolic parameters detected: {'a'}

# Parameters also work in conditions
eq = ODE("u'' + u + eps*u**3", small_param="eps",
         conditions=["u(0) = A", "u'(0) = 0"])
# ⚠️  symbolic parameters detected: {'A'}

# show() and to_latex() always work — results stay symbolic
sol.show()

# Provide values at eval/compare time
sol.eval(eps=0.1, at=t_vals, params={"a": 2.0, "A": 1.5})
sol.compare_numeric(eps=0.1, params={"a": 2.0, "A": 1.5})
# Missing params → clear error listing all required names

The four core methods

sol.show()

LaTeX rendering in Jupyter; clean plain text in the terminal. The small parameter is always displayed as $\varepsilon$, regardless of the name you chose (eps, mu, delta, …).

sol.show()                   # full hierarchy
sol.show(orders=[0, 1])      # selected orders only
sol.show(mode='text')        # force plain text (useful in scripts)

sol.eval(eps, at=None, params=None)

Evaluate the composite expansion and return a NumPy array.

import numpy as np
t_vals = np.linspace(0, 20, 400)

# ODE — single eps
u = sol.eval(eps=0.1, at=t_vals)             # ndarray

# ODE — multiple eps values
u = sol.eval(eps=[0.1, 0.2], at=t_vals)      # dict {0.1: ndarray, 0.2: ndarray}

# Algebraic
x = sol.eval(eps=0.1)                        # float
x = sol.eval(eps=[0.1, 0.2, 0.3])           # ndarray

# ODESystem
r = sol.eval(eps=0.1, at=t_vals)            # {'u': ndarray, 'v': ndarray}

# With symbolic parameters
sol.eval(eps=0.1, at=t_vals, params={"a": 2.0})

For Lindstedt and multiple scales, composite_t (solution in physical time $t$) is used automatically — no need to handle the strained-time symbol yourself.

sol.compare_numeric(eps, params=None, plot_range=None, filename=None)

Generate a comparison plot between the perturbation expansion and a SciPy numerical reference solution.

sol.compare_numeric(eps=0.1)
sol.compare_numeric(eps=[0.1, 0.2, 0.3])           # overlay multiple eps
sol.compare_numeric(eps=0.1, params={"a": 2.0})
sol.compare_numeric(eps=0.1, plot_range=[0, 40])    # override default range
sol.compare_numeric(eps=0.1, filename="fig.pdf")    # save to file

The plot range is inferred automatically from the problem's conditions. No problem= argument is required — the problem is stored on the hierarchy.

Returns a dict with keys that depend on problem type:

Problem type Keys
All ODE methods 't', 'u_pert', 'u_numerical', 'fig'
Boundary layer additionally 'u_outer', 'u_inner', 'u_composite'
ODE system 'u_pert' and 'u_numerical' are dicts keyed by variable name
Algebraic 'eps', 'perturbation', 'numerical', 'fig'

SciPy solvers used: solve_ivp (IVP, Lindstedt, multiple scales), solve_bvp (BVP, boundary layer), fsolve (algebraic).

sol.to_latex(environment='align', show_orders=False, filename=None)

Export results as ready-to-paste LaTeX source. The small parameter is always rendered as \varepsilon.

sol.to_latex()                              # print to console
sol.to_latex(filename="result.tex")        # save to file
sol.to_latex(show_orders=True)             # include u₀, u₁, u₂, … before composite
sol.to_latex(environment='equation')       # default: 'align'
sol.to_latex(environment='gather')

Lindstedt output includes the frequency expansion $\omega(\varepsilon)$, the composite in strained time $\tau$, and the composite in physical time $t$. Boundary layer output includes outer, inner, and composite separately.


Inspecting intermediate steps

Every hierarchy exposes the full symbolic work at each order:

sol = eq.expand_regular(order=3)

# Per-order entries
sol[k].ode                   # the ODE/equation at order k
sol[k].general_solution      # with free integration constants
sol[k].particular_solution   # constants fixed by ICs/BCs
sol[k].secular               # True if secular terms detected (ODE only)

# Assembly
sol.composite                # assembled expansion u(t, ε) as SymPy expression
sol.small_param              # the ε symbol

Lindstedt-specific attributes:

sol.omega_0                  # unperturbed frequency (auto-detected)
sol.omega_expansion          # ω(ε) as a SymPy series
sol[k].omega_k_val           # frequency correction ωₖ at order k
sol[k].secularity_condition  # the equation that determined ωₖ
sol.composite_t              # u(t, ε) with τ = ω(ε)·t substituted

Multiple scales-specific attributes:

sol.T0, sol.T1               # fast and slow time symbols
sol.amplitude_A              # A(T₁) — solved if B = 0 and Bernoulli ODE applies
sol.amplitude_B              # B(T₁)
sol[k].solvability_A         # amplitude ODE for A

Condition syntax

Conditions are plain strings — the same notation you'd write on paper:

conditions = ["u(0) = 1"]                          # 1st-order IVP
conditions = ["u(0) = 1", "u'(0) = 0"]             # 2nd-order IVP
conditions = ["u(0) = 0", "u(1) = 1"]              # BVP
conditions = ["u(pi) = 0", "u'(0) = sqrt(2)"]      # symbolic points/values
conditions = ["u(0) = 1/2", "u'(0) = 0"]           # rational values
conditions = ["u(0) = 0.9", "u'(0) = 0"]           # floats auto-converted

asymptotics automatically:

  • Detects IVP vs BVP from the number of distinct boundary points
  • Infers the dependent variable name from condition strings
  • Infers the independent variable (t for IVPs, x for BVPs) from the equation
  • Reports exactly what was inferred (with override syntax) at construction time
  • Raises clear ConditionError for wrong count, conflicts, or inconsistencies

Limit conditions for regularity at singular points:

conditions = [
    "F(0) = 0",
    "F(1) = 1",
    "lim(sqrt(2*eta)*F'', eta, 0) = 0",   # lim(expr, var, point) = value
]

At each order the library substitutes the general solution, identifies which free constants cause divergence, and sets them to zero automatically.


Auto-inference of variables

For ODE, dependent and independent are both optional:

# Fully minimal — both inferred
eq = ODE("u'' + u + eps*u**3", small_param="eps",
         conditions=["u(0) = 1", "u'(0) = 0"])
# ℹ️  dependent = 'u' (inferred from conditions)
# ℹ️  independent = 't' (IVP default)
# To override: ODE(..., dependent='u', independent='t')

# Override when the equation contains a non-standard independent variable
eq = ODE("u'' + sin(tau)*u + eps*u**3", small_param="eps",
         conditions=["u(0) = 1", "u'(0) = 0"],
         independent="tau")

Independent variable candidates inferred from the equation: {x, y, z, t}. All other symbols are treated as parameters. If a candidate symbol is ambiguous (could be independent variable or parameter), a hard error asks you to specify independent explicitly.


Higher-order ODEs

ODEs up to 6th order are supported using prime notation:

# 4th-order ODE
eq = ODE(
    "eta*F'''' + alpha*(eta*F''' + 2*F'') + eps/2*(F*F''' - F'*F'') + 2*F'''",
    small_param = "eps",
    independent = "eta",
    conditions  = ["F(0) = 0", "F(1/2) = 1", "F'(1/2) = 0",
                   "lim(sqrt(2*eta)*F'', eta, 0) = 0"],
)
sol = eq.begin_expansion(order=1)

Prime notation: u' (1st), u'' (2nd), u''' (3rd), u'''' (4th), u''''' (5th), u'''''' (6th).

Lindstedt and multiple scales are limited to 2nd-order oscillators. Regular perturbation and begin_expansion work for any supported order.


Error messages

asymptotics raises clear, actionable errors:

ConditionError: 3 conditions provided but the ODE is order 2 — need exactly 2.
  IVP: ["u(0) = 1", "u'(0) = 0"]
  BVP: ["u(0) = 0", "u(1) = 1"]

ConditionError: Conflicting conditions at t=0:
  u(0) = 1
  u(0) = 2

ValueError: Could not parse equation: 'u^2 + eps*u - 1'
  Use ** for powers:  u**2  not  u^2

NotReadyError: Cannot access 'composite' — 1 order(s) not yet solved: [0]
  Call sol[0].solve() or sol[0].set_solution(expr) first.

Project layout

asymptotics/
├── __init__.py                      ← public API
├── numerics.py                      ← compare_numeric() dispatcher + SciPy solvers
├── eval.py                          ← eval() for all hierarchy types
├── latex_export.py                  ← to_latex() for all hierarchy types
├── gauge.py                         ← non-standard gauge sequence support
├── core/
│   ├── problem.py                   ← AlgebraicEquation, AlgebraicSystem, ODE
│   ├── ode_system.py                ← ODESystem
│   ├── hierarchy.py                 ← OrderHierarchy, OrderEntry
│   ├── system_hierarchy.py          ← SystemHierarchy (coupled algebraic)
│   ├── conditions.py                ← parser: ParsedCondition, LimitCondition
│   └── exceptions.py               ← custom exceptions
├── methods/
│   ├── regular_algebraic.py         ← AlgebraicEquation solver
│   ├── regular_algebraic_system.py  ← AlgebraicSystem solver
│   ├── regular_ode.py               ← ODE regular perturbation
│   ├── regular_ode_system.py        ← ODESystem solver
│   ├── lindstedt.py                 ← Lindstedt–Poincaré
│   ├── multiple_scales.py           ← Method of multiple scales
│   ├── boundary_layer.py            ← Matched asymptotic expansions
│   └── stepwise.py                  ← StepwiseHierarchy, begin_expansion
├── display/
│   ├── jupyter.py                   ← algebraic LaTeX display
│   ├── ode_display.py
│   ├── ode_system_display.py
│   ├── lindstedt_display.py
│   ├── multiple_scales_display.py
│   └── boundary_layer_display.py
└── tests/                           ← 195 tests, all passing
    ├── test_regular_algebraic.py
    ├── test_algebraic_system.py
    ├── test_errors.py
    ├── test_gauge.py
    ├── test_ode.py
    ├── test_lindstedt.py
    ├── test_multiple_scales.py
    ├── test_boundary_layer.py
    └── test_ode_system.py

Example notebooks

Ten Jupyter notebooks covering every feature with worked examples:

Notebook Topic
01_introduction.ipynb Algebraic equations — root selection, symbolic parameters
02_transcendental.ipynb Transcendental equations and coupled algebraic systems
03_ode.ipynb Regular perturbation for ODEs — IVP and BVP, secular detection
04_lindstedt.ipynb Lindstedt–Poincaré — Duffing oscillator, amplitude dependence
05_multiple_scales.ipynb Multiple scales — damped oscillator, Van der Pol limit cycle
06_boundary_layers.ipynb Matched asymptotic expansions — left/right layers, variable coefficients
07_ode_system.ipynb Coupled ODE systems, predator-prey
08_advanced.ipynb Advanced features and edge cases
08_stepwise.ipynb Step-by-step API, higher-order ODEs, limit BCs
09_gauge.ipynb Non-standard gauge sequences

Running the tests

pytest                      # all 195 tests
pytest -v                   # verbose output
pytest --cov=asymptotics    # with coverage report

Design philosophy

  • String-based input — write "u'' + u + eps*u**3", not u.diff(t, 2) + u + eps*u**3
  • Transparent by default — every intermediate step is a live SymPy expression you can inspect and reuse
  • Consistent APIexpand_*, show(), eval(), compare_numeric(), to_latex() work identically across all problem types
  • Self-contained results — the problem is stored on the hierarchy; no need to pass it again to compare_numeric or eval
  • Fail clearly — errors name the problem, quote your input, and suggest a fix
  • No SymPy wrestling — the library manages all symbolic machinery internally; users stay at the mathematical level

Citation

If you use asymptotics in published work, please cite:

Tony Saad, asymptotics: A symbolic perturbation theory toolkit for Python, University of Utah, 2025. https://github.com/saadgroup/asymptotics


License

MIT © 2026 Tony Saad, University of Utah

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

asymptotics-0.2.0.tar.gz (85.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

asymptotics-0.2.0-py3-none-any.whl (103.4 kB view details)

Uploaded Python 3

File details

Details for the file asymptotics-0.2.0.tar.gz.

File metadata

  • Download URL: asymptotics-0.2.0.tar.gz
  • Upload date:
  • Size: 85.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.2

File hashes

Hashes for asymptotics-0.2.0.tar.gz
Algorithm Hash digest
SHA256 5083b1732fb8ac992fef201072d1ebfaf02df23e0c5f6b6dd3a68d16c2453a18
MD5 10aff9e6c79d1a16b42b13f165b6acf3
BLAKE2b-256 7612acb0ff31b5579766bbe67deedd47f6711a28296d0f646c58e0709603b73c

See more details on using hashes here.

File details

Details for the file asymptotics-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: asymptotics-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 103.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.2

File hashes

Hashes for asymptotics-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 6b2ff1882d0a1c0b2fed13ebddb7868485541d2385d7b29df4ef9c2f289fd1c1
MD5 3e7114db9ded1a0085e01a34fd1f9c32
BLAKE2b-256 6bc7855cad6f28c0af4eb2dd1fba8e518d4b18a3a3160e3248eb5e6737feb5d1

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page