Guidance for working in the Planktos repository. Keep this file current; it is loaded into every session.
Commits and pushes require explicit, per-action authorization in the user's most recent message. Authorization NEVER carries forward.
- Never
git commitorgit pushautomatically or on your own initiative. - Each commit needs its own fresh green light. A commit request authorizes exactly ONE commit, right then. The next commit requires a new, explicit request. Past authorizations do not propagate forward — not across turns, and not within a single multi-step task.
- "Do X, then commit, then do Y" authorizes committing X only. It does NOT authorize committing Y or any later/again work. Map each commit authorization to the one specific step it was attached to, and nothing else.
- Do not move toward a commit without authorization. Running
git add/staging as a prelude to an unauthorized commit counts as moving toward it — don't do it. (Read-only git —status,diff,log— is always fine.) - A request to commit is NOT a request to push. Push only when the user explicitly and separately asks to push, each time.
- When in doubt, do not commit. Show the diff, summarize, and ask. Leaving changes uncommitted in the working tree is always the safe default.
The user has explicitly asked for help remembering to maintain the version number and the changelog — these are easy to forget. Be proactive about it:
- The version lives in
planktos/__init__.py(__version__);setup.cfgreads it viaattr: planktos.__version__, anddocs/conf.pyimports it — so the one string in__init__.pyis the single source of truth.__version__is1.0.2, which is in development and not yet tagged, accumulating bug fixes under its ownchangelog.txtsection.v1.0.1(documentation-only) andv1.0.0are released and tagged. changelog.txtis hand-maintained, terse, and grouped by version. When a change is user-facing, prompt to add an entry under the appropriate version.- When work looks release-worthy (or a user-facing change lands) but the version or changelog hasn't been touched, say so and confirm the right action.
- Do NOT bump the version or rewrite the changelog silently — surface the need and let the user decide (a version bump is a semver judgment call).
Changelog style (strict):
- Length: each entry ≤ 180 characters, and ≤ 100 in most cases.
- Say what changed, not the story. No details about what happened and no how the bug was found. State the user-facing effect only.
- Skip regression fixes entirely. If it worked on
master, we broke it in dev, and we fixed it back, it does NOT go in the changelog. - Lump minor edge-case fixes. Small bug fixes for edge cases we only found by
running a test get collapsed into a single line:
- other minor bug fixes. Do not give them individual entries.
Planktos is an agent-based modeling framework for simulating the movement and dispersal of small organisms (plankton, tiny insects, etc.) in 2D or 3D fluid environments. The defining assumption is that agents are small enough that their effect on the surrounding fluid is negligible — fluid drives agents, agents do not drive fluid. It is an active research project (NSF DMS-2410988, 2024–2027).
Primary uses: studying collective/emergent behavior, dispersal, and interaction with immersed structures (e.g. flow around a cylinder, a jellyfish, a seafan).
Cite: Strickland, Battista, Hamlet, Miller (2022), Bulletin of Mathematical Biology 84(72). Docs: https://planktos.readthedocs.io
dyload— current development branch. The main feature work: dynamic loading of fluid data (streaming/loading fluid time steps on demand rather than all at once). This matters because time-dependent 3D fluid data is often ~100 GB raw and significantly larger once splined, so it cannot be held in memory all at once. You are usually here.master— stable/published; carries the released1.0.xline. Small, self-contained changes (docs, typos, packaging) are sometimes made here directly and then merged intodyload.mvbnd— gone. This was the 2D moving immersed boundaries work; it was merged intomaster, released as tagv1.0.0, and the branch has since been deleted locally and on origin. Older notes and commit messages still mention it; that history is preserved inmaster.
3D moving boundaries are planned but not started. They are blocked on dyload
(3D dynamic fluid loading) working first, because of the data-size problem above.
Moving boundaries are currently 2D only.
When making cross-cutting changes (like this CLAUDE.md), expect them to be
merged from master into dyload later.
The installable package is planktos/. Public API is intentionally tiny;
internal modules carry a leading underscore and are not part of the public
surface (a deliberate convention — see changelog.txt).
| File | Public? | Purpose |
|---|---|---|
planktos/__init__.py |
yes | Exports Environment, Swarm. motion is reachable as planktos.motion. |
planktos/_environment.py (~4250 ln) |
Environment class |
The fluid domain: holds flow field, immersed boundary mesh, swarms, time. Loads fluid/mesh data, generates analytical flows, plots, computes vorticity/FTLE. |
planktos/_swarm.py (~3160 ln) |
Swarm class |
A group of agents: positions/velocities/props, the move loop, boundary-condition application, plotting, data saving. |
planktos/motion.py (~550 ln) |
yes (planktos.motion) |
Equation-of-motion generators & solvers: Euler_brownian_motion (default SDE), inertial_particles, highRe_massive_drift, tracer_particles, RK45. |
planktos/fluid.py (~380 ln) |
mostly internal | Fluid data loading helpers + fCubicSpline (subclass of scipy.interpolate.CubicSpline) and create_temporal_interpolations. Newer module (2025); relevant to dyload. |
planktos/_geom.py (~840 ln) |
internal | Pure geometry workhorses: segment/line/triangle intersections, closest distances, multilinear-polynomial intersection (for moving meshes). Formerly static methods of Swarm. |
planktos/_ibc.py (~1170 ln) |
internal | Immersed-boundary collision handling: apply_internal_static_BC, apply_internal_moving_BC, and the project-and-slide routines for static and moving meshes. |
planktos/_dataio.py (~680 ln) |
internal | Low-level read/write of vtk, vtu, .vertex, stl, NetCDF. Use Environment loader methods instead of calling these directly. |
Environmentis the world: domain sizeL, boundary conditionsbndry, a fluid velocity fieldflow, an immersed boundary meshibmesh, and a list ofswarms. It owns the simulationtimeandtime_history.Swarmis a vectorized group of agents (NOT individual objects — agents are rows in numpy arrays for speed). It belongs to oneEnvironment.positions(andvelocities,accelerations) are masked arrays of shapeNx2/Nx3. A masked row = that agent has left the domain and is no longer updated. Respect/preserve the mask.- The fluid
flowis a list of ndarrays (one per spatial dim). For time-dependent flow the first axis is time. On first temporal interpolation these arrays are replaced in place byfCubicSplineobjects; the raw data can be recovered withEnvironment.regenerate_flow_data(). Interpolation is cubic spline in time, linear in space.
import planktos
envir = planktos.Environment() # define the world
envir.set_brinkman_flow(...) # or read_IB2d_fluid_data / load_NetCDF / etc.
envir.read_IB2d_mesh_data(...) # optional immersed boundaries
swrm = planktos.Swarm(swarm_size=100, envir=envir) # add agents
for _ in range(steps):
swrm.move(dt) # advance one step
swrm.plot_all(movie_filename='out.mp4') # visualizeSee examples/ for runnable scripts (start with basic_ex_2d.py,
basic_ex_3d.py). ex_ib2d_mvbnd_sticky.py is the 2D moving-boundary
showcase (needs external data — see the file header for the download link).
To change how agents move, subclass Swarm and override apply_agent_model(self, dt).
It must return (not assign) the new NxD positions array. Do not override
move() — move() is the harness that records history, applies boundary
conditions, recomputes velocity/acceleration by finite difference, and advances
time. Optionally override after_move(self, dt) to act on final positions/props
(e.g. marking stuck agents).
Inside apply_agent_model, typically call a planktos.motion generator, e.g.
planktos.motion.Euler_brownian_motion(self, dt). Default behavior is a random
walk: drift = local fluid velocity + shared_props['mu'], diffusion =
shared_props['cov'].
Helper accessors for use inside behavior code: get_fluid_drift(), get_dudt(),
get_fluid_mag_gradient(), get_prop(name), add_prop(...). Per-agent variation
lives in the pandas DataFrame Swarm.props; shared values in Swarm.shared_props.
- Agents treat the
ibmeshas solid. Collision behavior is set per-Swarmviaib_condition(and per-move viamove(..., ib_collisions=...)):'sliding'(default): no flux normal to the boundary; remaining movement is projected onto the boundary (recursive vector projection).'sticky': agent stops at the point of intersection for that step.None: ignore immersed boundaries entirely.
- After each move,
Swarm.ib_collision_idxis a length-N int array:-1if no collision that step, else the index of the first mesh element struck. (This replaced the old booleanib_collision— seechangelog.txt.) - Mesh assumption: segments must not cross except at shared vertices. Verify
imported meshes with
Environment.plot_envir().add_vertices_to_static_2D_ibmeshexists to repair crossings but is discouraged. - Static 3D meshes load from STL; 2D meshes (static or moving) load from
IB2d data via
read_IB2d_mesh_data(directory oflagsPts.####.vtk→ moving; single.vtk/.vertex→ static).
This code prioritizes scientific accuracy and robustness above all — "nothing breaks" is a hard requirement, not an aspiration. Treat the following as load-bearing:
- The workhorses are the agent–boundary intersection routines (
_geom.py) and the collision/interaction handlers (_ibc.py). These are the riskiest, most subtle code in the project. Change them with extreme care. - Hard invariant: no agent may ever end up on the wrong side of a boundary (penetration). This must hold for arbitrary geometry and movement, including the hard cases: where two or more mesh elements join (concave/convex joints), and under moving boundaries. Roundoff error is the enemy — penetration caused by floating-point error at joints or near-tangent hits is a real bug, not noise. Preserve the careful epsilon/tolerance handling already in place.
- Sliding collisions are the most delicate path. They handle many distinct geometric situations and are potentially recursive (project onto a boundary, which may push the agent into another boundary, repeat until the move vector is exhausted). Reason through all cases before touching this.
- When in doubt about a change to intersection/collision code, prefer to add a test that pins the current (trusted) behavior before refactoring.
Algorithm/derivation notes are in docs/notes/ (Markdown with LaTeX):
project_and_slide_moving.md— the moving-boundary project-and-slide math (the core of the 2D moving-boundary work). Implemented in_ibc._project_and_slide_moving.Equations_of_motion.md,Intersection_w_multilinear_polynomial.md,Lines_closest_points.md— supporting derivations.
- Source of truth for behavior is the docstrings in the source (NumPy style),
which Sphinx autodoc renders.
docs/builds the readthedocs site (docs/index.rst,docs/quickstart.rst,docs/api/,docs/examples/). README.mdis a landing page, not a reference. Its hand-maintained API listing was removed in1.0.1(it had drifted) and replaced with links to the generated docs. Do not reintroduce a duplicated API listing there — if something is undocumented, fix the docstring in the source.- Run
codespell README.md docs/ planktos/ examples/after documentation work; the tree was made clean in1.0.1. Note it has ambiguous cases it will not auto-fix (-w), so read its output rather than trusting a zero exit alone.
- GitHub Actions (
.github/workflows/tests.yml) runs the test suite and codespell on every push and pull request. It is the authority: it cannot be bypassed and it runs on Linux, which has already caught a failure that did not reproduce on the user's Windows machine (numpy 2.5 removingnp.crossfor 2-vectors). If CI fails and you cannot reproduce locally, check the dependency versions first — the runner installs the newest of everything. .pre-commit-config.yamlmirrors the codespell check locally. It is inert untilpre-commit installis run once per clone, which is easy to forget on a new machine — if the user is setting up a fresh clone, remind them.git commit --no-verifybypasses it. Documented under "Development" in the README.- Keep the pre-commit codespell skip list in sync with the workflow's, so the two cannot disagree about what is checked.
The suite is organized into focused, deterministic, fast modules (overhauled
2026-06). Run pytest from the repository root. ~390 tests; the default run is
~3s, and --runslow brings it to ~16s by adding the full-simulation
parallelization tests (~12s of that) and the plotting smokes.
- Run the whole thing with
pytest; a specific area with e.g.pytest tests/test_collisions_static.py. - Modules (all self-contained / analytic-answer unless noted):
test_geom.py—_geomintersection & closest-distance functions.test_collisions_static.py/test_collisions_moving.py/test_collisions_static_3d.py— call_ibc.apply_internal_static_BC/apply_internal_moving_BCdirectly across a geometry × movement matrix (2D segments, 3D triangle meshes; convex/concave joints, grazing, deep recursive multi-element slides); assert no-penetration and exact post-collision positions.test_collisions_moving.pyalso pins a deterministic multi-stepSwarm.move()trajectory (golden drift detector), the moving slider's rotate-away release branch (needs an element pivoting about an interior point — see_ib_harness.pivoting_segment), and frame-independence of the slide.test_collisions_junctions.py— the cases the chain-shaped builders above structurally cannot reach: vertices of degree > 2 and non-manifold edges, where a slide running off an element has several candidates to continue onto. Asserts invariants (finite, motion not amplified, stays outside a closed obstacle, rigid-motion equivariance) rather than exact positions.test_collisions_invariants.py— the checks that are not tied to any geometry: the answer must be finite, must not amplify the motion, must not depend on where the problem sits or which way the axes point, and must behave the same in any units. Also holds the stack-exhaustion cases, since recursion depth is set by step length against mesh spacing rather than by shape. Deliberately uses plain geometries, so a failure is attributable to the property and not to an exotic mesh.test_ibc_helpers.py— the small helpers and guard rails inside_ibc:_boundary_eps,_point_in_triangle,make_ib_workerunpacking, and the 2D-only guard on moving meshes.test_collisions_stl_3d.py— end-to-end 3D: load a generated STL viaEnvironment.read_stl_mesh_dataand drive agents into it withSwarm.move()(needs the optional numpy-stl; module skips otherwise).test_flow_generation.py— brinkman/channel/canopy,tile_flow,extend,flow_pointsaxis order.test_temporal_interp.py—fluid.fCubicSpline/create_temporal_interpolations.test_agent_models.py—apply_agent_model/after_moveoverrides, themotiongenerators, and the publicmotion.RK45solver contract.test_material_derivative.py—Swarm.get_DuDt/get_dudt(closed-form).test_swarm_lifecycle.py—move()bookkeeping, mask contract, and domain BCs (zero/noflux/periodic) in 2D, 3D, and mixed-per-dimension combinations.test_periodic_ib.py— periodic domain boundary × immersed boundary (an agent wraps across the domain and immediately meets a wall on the far side).test_swarm_save.py— round-trips forsave_pos_to_csv/save_data/save_pos_to_vtk.test_analysis.py—get_2D_vorticity, forward & backward FTLE (closed-form).test_io_loaders.py— IB2d moving/static mesh import (committed fixtures), IBAMR vtk (@vtk), COMSOL vtu (@vtu).test_parallel_ib.py— serial == threads == processes (@slow).test_plotting_smoke.py—plot_*methods run without error on the Agg backend (@slow; the movie test also needs ffmpeg).
- Helpers / fixtures:
tests/_ib_harness.py(mesh builders + invariant assertions; also drives the parallel scenarios and the golden moving-boundary trajectory);tests/fixtures/holds tiny committed IB2d fixtures, regenerable viatests/fixtures/_gen_fixtures.py. - Markers (registered in
pytest.ini):slow(only with--runslow),vtk(skipped if vtk data absent),vtu(skipped if COMSOL data absent). - Non-automated visual/exploratory scripts live in
tests/manual/— excluded from collection viacollect_ignoreinconftest.py.
An xfail in this suite marks a bug serious enough to stop the development
cycle for. It is not a way to defer something inconvenient. The convention:
xfail⇒ drop other work and fix it. If a defect does not warrant that, it does not get anxfail— it goes in the issue tracker with reproduction notes instead.- Always
strict=True, always with areasonnaming the defect. Strict means the suite fails the moment the test starts passing, so a marker cannot outlive its bug. - No issue-tracker entry is needed for an
xfailed bug. The test already catalogues it, with an executable reproduction — that is strictly better than prose. Duplicating it in the tracker just creates two things to keep in sync. - Delete the marker in the same commit as the fix.
- The steady state is still zero
xfails. A non-empty list means work is in flight right now.
Check with pytest -rX (lists xfails) or pytest -rxX (xfails and xpasses).
The overhaul and its follow-ups uncovered and fixed a series of latent bugs, each
with a regression test; see changelog.txt for the list. Two FTLE specifics
worth knowing (calculate_FTLE):
FTLE_smallestis the smallest-eigenvalue (contraction) exponent, not backward-time FTLE (the old "negate it" guidance was wrong). For attracting LCS, callcalculate_FTLE(..., backward=True)— it integrates the reversed flow and stores the backward field inFTLE_largest. Backward is tracer-only (reverse- time inertial/custom dynamics are dissipative/ill-posed). Forward works for tracer,ode_gen(inertial/custom), and user-swrmmodels.- FTLE respects static immersed boundaries but not moving ones (it doesn't
advance
envir.time, so a moving mesh would be frozen) — a moving mesh now raisesNotImplementedError.
- Favor small, exact analytic setups with known answers over large simulations; keep the default run fast and deterministic.
- The key property is the no-penetration invariant (agents end on the correct
side of every boundary) plus correctness of the resulting position. Extend the
geometry × movement matrix (convex/concave joints, grazing, multi-element,
moving vs static, sliding vs sticky) in
test_collisions_*. - Pin trusted moving-boundary behavior with regression locks before refactors.
- Underscored modules are internal. Add new public surface only to
__init__.pyexports; keep helpers in underscored modules. - Classes
EnvironmentandSwarmare capitalized (a deliberate1.0.0rename). - Masked arrays everywhere for agent state — use
.copy()before mutatingself.positions/velocities/accelerations; direct assignment is by reference and the auto-update inmove()will overwrite velocity/acceleration anyway. - Multiple swarms in one environment: advance them with
Environment.move_swarms()(or call eachSwarm.move(update_time=False)then bump time), not a bare per-swarmmove()loop, which warns about un-advanced swarms. - FFmpeg must be on
$PATHto save animation videos. - Data files are gitignored (
*.vtk,*.vtu,*.vertex,*.stl,*.mp4,*.npz,data/, etc.). Large example/test datasets are downloaded separately. proj_dev/is the gitignored scratch/dev folder convention for work-in-progress with data.past_projects/holds prior research code (e.g.brine_shrimp/) kept for reference; not part of the package.- Build: setuptools via
setup.cfg(deps: numpy, scipy>=1.10.1, matplotlib>=3, pandas, vtk>=9.2, pyvista>=0.44; optional extras: STL, netCDF, test). Editable install withpip install -e .. changelog.txtis hand-maintained — update it for user-facing changes.