Skip to content

Multipart - #26

Open
rafaqz wants to merge 37 commits into
mainfrom
multipart
Open

Multipart#26
rafaqz wants to merge 37 commits into
mainfrom
multipart

Conversation

@rafaqz

@rafaqz rafaqz commented Jul 25, 2026

Copy link
Copy Markdown
Member

@mrke this is a plan to implement multi-part geometries

@mrke mrke left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great - main suggestions are to remove the sphere and the axis-ratio as an effector (and shifting instead to a discrete or continuous alteration of limb exposure)

Comment thread multipart-plan.md
(radiation, convection, evaporation) happens per part.

The existing single-body, dorsal/ventral-split model becomes a special case:
one part with `Naked`, or two `HalfCylinder` parts with a `SharedCore` join.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this also be two parts? Because the point is to capture the distinct dorso/ventral environments. Historically in the NicheMapR ectotherm model we never did this at all.

@rafaqz rafaqz Jul 25, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm. Wouldnt we just do that generically with a view factors list for sky, ground and between components? I think thats what the plan has.

So every geometry has that and its orthoginal to other reasons to split parts?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was interpreting this as if it was saying that the two half cylinder approach would be unavailable for naked objects but perhaps I'm not reading it right

Comment thread multipart-plan.md

abstract type AbstractShape end
abstract type AbstractCylindrical <: AbstractShape end
abstract type AbstractSpherical <: AbstractShape end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps we don't need this and we make a sphere a special case of ellipsoid

@rafaqz rafaqz Jul 25, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it allows performance optimisations

Comment thread multipart-plan.md

abstract type AbstractInsulation end
abstract type AbstractFibrous <: AbstractInsulation end # fur, feathers, hair
abstract type AbstractLayer <: AbstractInsulation end # fat, muscle, skin

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could distinguish between metabolically active (flesh, fat) and metabolically inactive (e.g. bone)

Comment thread multipart-plan.md
invalidated by `Piloerect`, `Vasodilate`, `Hyperthermia`, `Pant`, `Sweat` —
the vast majority of thermoregulation-loop iterations.

Posture-change behaviors (`Roll`, `Curl`) are out of scope for this refactor.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curl was in part a hack for capturing things like ears popping up or wings being outstretched. Curling in a ball/rolling can end up being a sudden change in shape to be implemented via BiophsicalBehaviour.jl and we don't need a gradual curling.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets just delete

Comment thread multipart-plan.md
`::Effector` no-op and the compiler inlines them away at the call site.
Only `Piloerect`, `Uncurl`, and `Vasodilate` do real work.

### 3.6 HeatExchange as pure physics + `CommonSolve` interface

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes!

Comment thread multipart-plan.md Outdated
`+ 2 · NumCompartments` (per-compartment `core_temperature` and
`flesh_conductivity`)
`+ 3 · NumParts` (per-part `insulation_depth`, `skin_wetness`,
`aspect_ratio`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as i've been suggesting - maybe we abandon this and instead have an appearance / disappearance of parts

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok lets delete aspect_ratio

Suggested change
`aspect_ratio`)
)

Comment thread multipart-plan.md Outdated
| Insulation properties, piloerection range | `part.physiology.insulation`, `.insulation_limits` |
| Skin wetness range, flesh conductivity range | `part.physiology.skin_wetness_range`, `.flesh_conductivity_limits` |
| Panting capacity | field on the `LungPart` wrapper around the lung part's physiology (§3.9); not present on other parts |
| Aspect-ratio bounds | `aspect_ratio_bounds(part.shape, limits)` dispatch (`::Sphere` returns `(1,1)`); no user input |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove?

Comment thread multipart-plan.md
Add `OrganismTraits.lung_part::Symbol` as an O(1) index into
`body.parts` (e.g. `:torso`, or `:body` for single-part organisms).
Semantics: `lung_temperature` derives from `lung_part`'s
`core_temperature`; `Pant`'s default selector is

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice

Comment thread multipart-plan.md
### 3.10 Effector + selector interface

Selectors let a behavior target the whole body, a subset, or a single
part. `WholeBody()` degenerates to the current single-body API.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's great to have this flexibilty

Comment thread multipart-plan.md
be added.
- **8.3 Local convection per part** (leeward parts see less wind).
Requires: Tier 2 cache accepts an extra per-part `flow_factor` scalar
(default 1.0). No structural change.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting - and have wind direction from some weather data sets - animal can orient according to both wind and solar

Comment thread multipart-plan.md
`SharedCore`) but do not provide shims.
- The four extensions in section 8 — see below.

## 8. Future extensions (design must not preclude)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one more thing I had forgotten about - we need this setup to extend to an animal in a shelter, e.g. possum in a nestbox, where the heat exchange is being calculated for both the shelter and the animal and their interaction. We have some old code that at one point was integrated with the endotherm model when it was a big monolith but I never hooked it back into the NicheMapR setup

rafaqz added 25 commits August 4, 2026 14:56
Phase 2 — src/parts.jl: PartSelector (WholeBody/ByName/Compartment) and
type-stable traversal (map_parts/foldl_parts/set_part); a plain Body is
treated as a one-part composite named :body. test/parts.jl covers
round-trips and inference of the lightweight primitives.

Phase 3 — effector tags (Piloerect/Uncurl/Vasodilate/Hyperthermia/Pant/
Sweat) + effect() entry point. Rule-based effectors are now reached via
effect(op, WholeBody(), ...) with the old scalar names forwarding;
numerics are unchanged (thermoregulate loop untouched). The genuine
per-part/per-compartment apply split is deferred to Phase 4 with per-part
physiology.

plan — add 8.5 animal-in-shelter as a future extension.

Note: Project.toml [sources] is repointed to local dev checkouts of
BiophysicalGeometry/HeatExchange for cross-package work; left uncommitted
to avoid absolute local paths in the repo.
Introduces the per-part physiology foundation (§3.9, §4):

- LungPart{Physiology,PantingCapacity} wraps the lung-hosting part's physiology,
  carrying panting_capacity that lives ONLY on this wrapper — non-lung parts have
  plain physiology and no panting field. All HeatExchange physiology accessors
  forward transparently through the wrapper.
- physiology(organism) is a per-part NamedTuple keyed by part name, with the lung
  part wrapped in LungPart. broadcast_physiology builds it: a single lumped
  HeatExchangeTraits broadcasts to every part (a plain Body -> (; body = LungPart)),
  or a per-part NamedTuple is used as given with only the lung entry wrapped.
- OrganismTraits gains lung_part::Symbol. The heat_exchange field now holds either
  a single lumped physiology (single-body) or a per-part NamedTuple (multi-part);
  the 3-arg constructor is unchanged and defaults lung_part to :body. Whole-organism
  forwarding accessors (metabolism, respiration, options, ...) resolve through the
  lung part when per-part physiology is stored, and pass a lumped physiology
  straight through — so the single-body path is byte-identical and fully
  type-stable (endotherm 328 / ectotherm 20 unchanged).
- pant_selector(organism) = ByName{(lung_part,)} routes Pant to the lung part;
  degenerates to the whole body for a single Body.
- lung_physiology(organism) is the unwrapped source for whole-organism respiration/
  metabolism and lung_temperature.

34 new tests: LungPart forwarding, broadcasting (single + distinct per-part), and
organism-level accessors for both single-body and a 2-part lung-in-torso dog.
Build the per-part solve_part_surface setups directly from a multi-part
Organism (CompositeBody parts + per-part physiology NamedTuple) and route
respiration, lung mass, and metabolism through lung_part, then close the
whole-organism balance via HeatExchange.solve_coupled_metabolic_rate.

solve_multipart_metabolic_rate / part_surface_setups generalise HeatExchange's
_pack_sides from a dorsal/ventral pseudo-split to genuine anatomical parts:
each part sees the full (un-doubled) sky/ground/bush/vegetation hemisphere
through its own radiation_pars, insulation through its own insulation_pars, and
ground contact through its own conduction_pars_external. All parts share one
regulated core (the all-SharedCore compartment); floating compartments layer
on top later.

A single Body collapses to one part reproducing the dorsal/ventral baseline in
the symmetric case (Phase 7 equivalence gate, now driven from the organism
API). Tests: single-body regression vs solve_metabolic_rate + two-part solve
with lung routed to the torso. Full BB suite green.
Route the endotherm effector ladder through per-part physiology for
CompositeBody organisms, driving solve_multipart_metabolic_rate:

- map_part_physiology(f, selector, organism): substitute f(physiology) for the
  selected parts' stored HeatExchangeTraits (type-stable ntuple/Val).
- Per-part effectors: vasodilation and sweating span every part;
  hyperthermia (core) and panting land on the lung_part only via pant_selector,
  so panting affects only the lung part's respiration and energy balance
  (§3.9 Phase 4 exit criterion). Metabolic heat flow is produced at the lung.
- thermoregulate(::Endotherm, ::RuleBasedSequentialControl, ::Organism{<:CompositeBody})
  runs the vasodilate -> hyperthermia -> pant -> sweat ladder against the
  multipart solve. Single-Body organisms keep the exact dorsal/ventral path
  (dispatch on Organism{<:CompositeBody} leaves it byte-identical). Piloerection
  and uncurl (geometry reshaping) deferred to the Phase 5 cache layer.

Also add the plan's whole-organism scalars to ThermoregulationLimits: the four
magic-number replacement fields (skin_temperature_undershoot / _core_overshoot,
metabolic_heat_flow_max_multiplier, minimum_normalisation_range) and
weight_for(weight, part) resolving scalar-broadcast vs NamedTuple per-part
penalty coefficients (consumed by the Phase 7.5 NLP builder).

Tests: panting-lung-only, per-part vasodilate/sweat spanning all parts,
multi-part ladder runs, weight_for + magic-number defaults. Full BB suite green
(endotherm 328 unchanged, multipart_solve 29).
- examples/dog_thermoregulation.jl: six-part dog (torso lung + head + 4 legs),
  per-part physiology, run through the rule-based controller end-to-end.
- Tests: map_part_physiology ByName selector touches only its target part;
  six-part dog direct solve (one surface result per part, lung mass from torso)
  and full rule-based ladder. Full BB suite green.
Precompute each part's total_area, silhouette_area, and characteristic_dim once
(pure functions of shape + insulation + constant solar orientation) instead of
recomputing them every outer-loop iteration inside part_surface_setups.

- ShapeCache{Parts}: immutable per-part NamedTuple geometry cache.
- precompute_shape_cache(organism) builds it; refresh(cache, organism, effector)
  is a no-op for the physiological effectors (Vasodilate/Hyperthermia/Pant/Sweat,
  elided at the call site) and rebuilds for Piloerect/Uncurl (§3.5 invalidation
  by direct effector dispatch).
- solve_multipart_metabolic_rate / part_surface_setups take an optional cache;
  _part_geometry reads cached values or computes them on the fly (identical
  results). characteristic_dim now flows through the setup to solve_part_surface.
- The multi-part rule-based loop precomputes the cache once and refreshes after
  each effector; the physiological ladder keeps it valid throughout.

Tests: cached solve is bitwise-identical to uncached; refresh no-ops for
physiological effectors (=== cache) and rebuilds for geometry effectors. Full BB
suite green (endotherm 328 unchanged, multipart_solve 52).
Generalise the IPOPT thermoregulation solver from the fixed dorsal/ventral
WeightedMeanNLP / MultiSidedNLP layouts to a genuine multi-part organism.

New MultipartNLP strategy (a HeatExchange.NLPStrategy) with a MultipartNLPPacked
problem carrying the per-part solve_part_surface setups plus the whole-organism
respiration inputs. Decision variables are a regular per-part stride — core /
log-metabolic / pant whole-organism, then skin / insulation / flesh_conductivity
/ skin_wetness per part (3 + 4N variables). Constraints are per-part surface
balance + skin temperature, one whole-organism respiration balance, and the Q10
inequality (2N + 2). Physics is HeatExchange.part_surface_residuals per part, so
nothing is duplicated; geometry effectors (insulation_depth / axis_ratio) are
deferred exactly as the multi-part rule-based ladder defers Piloerect / Uncurl,
so the per-part setups stay fixed across the solve and no body is rebuilt under AD.

The multipart methods slot into the existing direct-Ipopt + Enzyme callback
machinery (IPOPTSolverCache, _ipopt_solve!, the five Evaluate* functors,
_lagrangian) unchanged — they dispatch physics on the packed type and operate on
a flat Float64 vector — by adding MultipartNLPPacked methods for _heat_balance_
residuals!, _objective_value, _inputs!, _problem_size, _scaling, _assemble, and a
nlp_pack method.

Also repairs the shared Ipopt/Enzyme callbacks so they run under the current
Enzyme: set_runtime_activity on the reverse/forward passes, mark the captured
LagrangianGradient functor Const in the Hessian pass, and replace the BLAS dot in
_lagrangian (which segfaulted under nested AD via Enzyme's fallback BLAS) with a
plain loop. The single-body path had no tests and did not run in this
environment; the 328-case endotherm regression is behaviourally untouched.

Tests (test/multipart_nlp.jl, 16): residuals vanish at the iterative coupled
solution; 1-part and two-part organisms solve via IPOPT to heat-balanced,
Q10-satisfying, physically sane states.
Validates the reusable cache path (cold-in-cache then primal+dual
warm-started) converges to the same multi-part solution as a fresh
per-call solve.
The plan is a forward-looking roadmap; progress belongs in git history, not
the plan document.
Replace the 3+4N index-stride decoding of Ipopt's decision-variable vector with
a nested NamedTuple template flattened/reconstructed by Flatten. The variable
structure is defined once (_variable_structure); the variable template, bounds,
scaling, and initial-value templates are all that same shape flattened in the
same order, so a bound can never drift out of alignment with its variable, and
adding a part or a per-part effector is a change to the structure, not to a pile
of x[3+4i] offsets. Residual/objective/assemble read named fields off the
reconstructed template.

The flat optimiser vector and the reconstructed template are Float64: Ipopt's
space is unitless, so keeping the reconstructed leaves homogeneous Float64 makes
Flatten.reconstruct type-stable (a heterogeneous data[n] infers to a Union and
breaks Enzyme's AD). Units are attached where each named field enters the physics
(* u"K", exp(_) * u"W", …). Enzyme differentiates straight through reconstruct.

Per-part accumulation in the residual uses a plain for-loop with loop-carried SSA
scalars, not a map/ntuple closure that reassigns a captured accumulator (which
boxes it — non-isbits — and crashes the AD compiler). Constraint counts are
derived by flattening the residual template, never hand-counted.

Full BB suite green (multipart_nlp 19, endotherm 328 unchanged).
The multi-part NLP template now holds Unitful leaves. Targeting Real,
Flatten.flatten rips out only each Quantity's inner Float64 magnitude (the
optimiser vector stays homogeneous Float64, so reconstruct is type-stable and
Enzyme-safe), and reconstruct puts a Float64 back into the typed slot — units
round-trip through the Quantity type. The residual/objective/assemble read
Unitful named fields straight off reconstruct; no field is hand-attached with
* u"K" and nothing is hand-stripped.

Removes the last targetless ustrip (the test's pack helper now flattens to Real
magnitudes). The only remaining ustrips are targeted (ustrip(u"K"/u"W", _)) at
genuine boundaries: the objective's dimensionless penalty space, the
log-transform variable, and residual outputs. All 19 multipart_nlp tests pass
(incl. the two-part case through Enzyme); full BB suite green, endotherm 328
unchanged.
Stop stripping quantities to a target unit (which bakes a unit choice into the
code and blocks later generalisation). The objective and _inputs! now carry
Unitful quantities throughout — bounds/initials are built Unitful and
flatten(Real) rips the magnitudes at the one Flatten boundary; the objective's
penalty terms are ratios of like-dimensioned quantities (dimensionless without
ustrip); the Q10 slack subtracts Unitful quantities and strips only the
temperature difference the empirical correlation needs.

ustrip now appears only where a bare number is genuinely required: the external
solver's Float64 vectors (residual writes, bounds), the Q10 formula, and the
log-transform variable.

Fix two parameter-structure smells surfaced by this: ThermoregulationLimits
target_core_skin_gradient is now a Quantity (2.0u"K") via the existing Tu type
param, not a bare Float64 that consumers slapped u"K" onto; and the objective/nlp
heat-flow parameters are Unitful W, dropping the heat_flow_min_W /
setpoint_temperature_K unit-suffixed names (the unit lives in the type).

Full BB suite green, endotherm 328 unchanged.
…, type-stable lung routing

Objective / NLP:
- Replace the hardcoded five-term objective with a tuple of RegulationTarget
  recipes folded by the objective; rename the *_penalty weight fields to *_weight
  and drop the dead weight_for helper.
- Delete the single-body IPOPT path (WeightedMeanNLP/MultiSidedNLP); MultipartNLP is
  the sole strategy and the IPOPTControl default.
- Remove every hardcoded unit literal between flatten/reconstruct: the variable
  template derives its units from the model, residuals are dimensionless-ised by
  their own oneunit, Q10 and the log-metabolic transform use model-derived units.
- Wire respire into the whole-organism balance so the NLP and the coupled solver
  constrain the same thing.

Physiology:
- Store the lung part as Val{name} in OrganismTraits so the name lives in the type;
  physiology(o) is now type-stable (guarded by an @inferred test) instead of
  inferring Union{LungPart,_} per part.
- map_part_physiology works on a single-Body organism; merge the duplicate
  _organism_physiology methods; drop dead _set_lung_metabolic_heat_flow.
flesh_conductivity had a bounds envelope but no objective term, so it was
unobservable whenever core ~ skin (the core->skin conduction flux is then
insensitive to it) and the solver parked it at an arbitrary bound.

Add a RegulationTarget pulling it toward its vasoconstricted reference, matching
the other effectors, plus a flesh_conductivity_weight limit (default 0.0, so no
existing behaviour changes). The budgerigar example sets it to 0.05, which
removes the vasodilation flip-flop at the hot end.
The unified Newton surface solve conserves surface energy where NicheMapR's
linearised thermal-node update does not, so the furred cylinder (mildly) and
furred plate/near-cube (substantially) no longer match the NicheMapR reference.
Mark exactly the diverging assertions broken via a @check helper rather than
loosening tolerances, keeping every other comparison tight and flagging us if
the divergence ever disappears.
OrganismTraits gains a `couplings` field — a tuple parallel to `body.joins`
(values <: HeatCoupling), defaulting to `()` which resolves to all-SharedCore
(every part shares one regulated core, preserving today's single-body /
dorsal-ventral behaviour). `organism_compartment_graph(o)` builds the
HeatExchange.CompartmentGraph from the body topology and couplings: SharedCore
joins contract into one compartment, ConductiveCoupling joins split parts into
separate floating compartments. Behaviour-preserving — the solve path is
unchanged this increment. Re-exports HeatCoupling/SharedCore/ConductiveCoupling.
…partments

solve_multipart_metabolic_rate now dispatches on the couplings type: the default
empty tuple (every organism that doesn't opt in) keeps the exact single-compartment
path and return type — no graph, no regression. Organisms with explicit couplings
build the compartment graph; all-SharedCore still collapses to the single-compartment
path, while ConductiveCoupling joins trigger the multi-compartment solve.

The multi-compartment path pins the regulated (lung) compartment at setpoint and
runs a fixed point over the floating cores: per-part surface solves at each
compartment's current core -> per-compartment aggregates -> solve_regulated_core_
temperatures -> repeat to convergence. The regulated compartment's metabolic heat is
then closed by solve_coupled_metabolic_rate, charged the conductive loss to its
floating neighbours via extra_net_metabolic. Inter-compartment conductances derive
from join_area + internal_distance + per-part flesh conductivity.

Tests: floating-compartment behaviour with environment-independent limits (tight
interface -> head core to setpoint; loose -> floats furthest away).
Adds the plan's Phase 7 exit-gate test: two equal-mass HalfCylinders joined
flat-to-flat with a FullCover join and the default SharedCore coupling reproduce
the full-Cylinder dorsal/ventral baseline within ~0.5% on metabolic heat and
~0.04 K / ~0.15 K on skin / insulation temperatures. The two halves reconstruct
the full cylinder's exposed surface exactly; the only residual is the half-cylinder
characteristic dimension (2^(1/3) smaller) feeding convection. Together with the
existing single-Body identity and the ConductiveCoupling floating-compartment test
(Phase 7b), this completes the Phase 7 equivalence gates.
thermoregulate(::Endotherm, ::RuleBasedSequentialControl, ...) gains an
inner_solve keyword (default _multisided_inner — the legacy dorsal/ventral path,
behaviour unchanged). _multipart_inner routes the same ladder through the genuine
per-part solve_multipart_metabolic_rate. This lets both paths be driven for
validation before the dorsal/ventral machinery is deleted.

Validation (full converged ladder, 8 endoR cases, old vs new): the genuine
per-part path reproduces the dorsal/ventral converged results to <0.7% on
metabolic heat and <0.6 K on skin across all shapes/fur. The earlier large
input-state divergences were artifacts of comparing the unregulated state; at
convergence the paths agree to sub-percent. Confirms the Phase 8 deletion is
safe and preserves the NicheMapR validation.
_assemble_endotherm_output builds the full ThermoregulationOutput (thermoregulation/
morphology/energy_flows/mass_flows) from a single-part multipart solve, so the genuine
per-part path (_multipart_inner) can return the rich output downstream consumers read.
Morphology is body-only (same expressions as the legacy assembler → matches to
tolerance); energy/mass flows come from the one part's flows + respiration. Gross
longwave is recomputed as ε·σ·A·T⁴ (the part sees the full hemisphere, view factors
sum to one) with incoming = outgoing − net.

Default inner_solve stays _multisided_inner (legacy) so the suite is unchanged; the
genuine path is exercised via inner_solve=_multipart_inner. Switching the default
surfaces the expected off-assumption endoR divergences (gross-flux decomposition,
per-side dorsal/ventral assertions), which is a validation-restructuring decision,
not a code bug — the physical net agrees (<0.7% metabolic).
The single-part endotherm output assembler reconstructed gross longwave
out as ε·σ·total_area·T⁴, but the internal surface physics radiates only
from area_convection = total_area·(1 − conduction_fraction) (heat_balance
line 489). The ground-contact patch's exchange with the substrate is
bundled into the conduction term, so including it in the environmental
longwave over-stated gross in/out by 1/(1 − conduction_fraction) (43% at
conduction_fraction = 0.3). The net longwave (and metabolic balance) was
never affected, since longwave_flow_in = out − flows.longwave.
rafaqz added 9 commits August 9, 2026 11:32
Locks in the one operating point where the genuine per-part model and the
legacy dorsal/ventral model must coincide: an animal standing upright at
midday in symmetric surroundings — sun overhead, uniform radiant
environment, symmetric insulation/view factors, and no ground contact
(conduction_fraction = 0). There the two ladders agree on the regulated
quantities (metabolic heat, core, skin, insulation) to ~1e-4 and both
conserve energy.

Ground contact is deliberately excluded: it is where the old model's
conflation bites (ventral solar carries a (1 − 2·conduction_fraction)
factor; ground conduction is weighted by the ground view factor), so the
models diverge there by design — the reason dorsal/ventral is retired,
not a regression.
Adds the init! step for multi-part surface radiation and threads its output
through the solve as an opt-in per-part view:

- precompute_view_partition(organism, environment_vars): for a CompositeBody,
  bakes each part's occlusion-aware (sky, ground, neighbours) fractions from
  view_partition plus its direct-beam lit silhouette from silhouette_area_per_part
  (sun direction derived from the zenith angle, azimuth 0). A single Body has no
  parts to occlude, so it returns nothing and the solve keeps its naive path.

- part_surface_setups / _part_surface_setup / solve_multipart_metabolic_rate take
  an optional �[?1049h�[>4;2m�[?1h�=�[?2004h�[?1004h�[1;24r�[?12h�[?12l�[22;2t�[22;1t�[27m�[23m�[29m�[m�[H�[2J�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�]11;?��]10;?��[?25l�[2;1H�[94m~                                                                               �[3;1H~                                                                               �[4;1H~                                                                               �[5;1H~                                                                               �[6;1H~                                                                               �[7;1H~                                                                               �[8;1H~                                                                               �[9;1H~                                                                               �[10;1H~                                                                               �[11;1H~                                                                               �[12;1H~                                                                               �[13;1H~                                                                               �[14;1H~                                                                               �[15;1H~                                                                               �[16;1H~                                                                               �[17;1H~                                                                               �[18;1H~                                                                               �[19;1H~                                                                               �[20;1H~                                                                               �[21;1H~                                                                               �[22;1H~                                                                               �[23;1H~                                                                               �[m�[7;32HVIM - Vi IMproved�[9;33Hversion 9.1.1752�[10;29Hby Bram Moolenaar et al.�[11;19HVim is open source and freely distributable�[13;26HHelp poor children in Uganda!�[14;18Htype  :help iccf�[34m<Enter>�[m       for information �[16;18Htype  :q�[34m<Enter>�[m               to exit         �[17;18Htype  :help�[34m<Enter>�[m  or  �[34m<F1>�[m  for on-line help�[18;18Htype  :help version9�[34m<Enter>�[m   for version info�[1;1H�[?25h�[?4m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[?2004l�[>4;m�[?2004h�[>4;2m�[24;1H�[?2004l�[>4;m�[?1004l�[?2004l�[?1l�>�[?1049l�[>4;mVim: Error reading input, exiting...
Vim: Finished.
�[24;1H�[23;2t�[23;1t�[J. When supplied, a part's sky/ground view factors and solar
  silhouette come from the precomputed partition instead of its own naive full-
  hemisphere values, so a shadowed part gets no phantom direct beam. Default
  (view = nothing) is the existing behaviour, so no current path is disturbed.

The modeller poses the body biologically (dorsal up); init! reads that pose.
Validated: dorsal-up two halves under an overhead sun — the ventral half's lit
silhouette is zero and its solar drops to the diffuse component only.

Remaining: the inter-part surface exchange term (neighbour fractions), the
convection shared-boundary-layer fix, and the two-half-under-load gate.
Build the per-part neighbour topology (_neighbour_topology: sibling index +
view fraction, from the precomputed view partition) and pass it to
solve_coupled_metabolic_rate so the occluded solid angle of each part exchanges
longwave with its siblings' surfaces instead of the sky/ground behind them.

The occlusion testset now also asserts the coupling: the view partitions sum to
one (energy-conserving), and against an identical solve with the neighbour term
stripped, coupling shrinks the warm-dorsal/cool-ventral surface-temperature gap
(warm half radiates more, cool half less).
Splitting a body into parts must not shrink the convective length scale: the two
halves of a cylinder sit in one boundary layer, so each should convect at the
reconstructed whole's V^(1/3) (+ fur), not its own (V/2)^(1/3) — a factor ~2^(1/3)
too small. part_surface_setups now computes the composite's characteristic
dimension (_parent_characteristic_dim: cbrt of the summed part volumes plus the
shared fur thickness) and hands it to every part's setup; a single Body keeps its
own (nothing to share).

This removes the last standing residual between the two-half model and the old
dorsal/ventral one: at thermoneutral, two HalfCylinders + SharedCore now reproduce
the full-cylinder dorsal/ventral solve to ~5e-5 in metabolic rate and ~1e-6 in
surface temperatures (was ~0.5%, entirely the half char dim). Gate tightened
accordingly; a direct test asserts each half's setup uses the whole-body scale.
The floating-core fixed point (_solve_multicompartment) now rides the inter-part
surface exchange: _bake_neighbours injects each part's neighbours — view fraction
plus the sibling's current outer surface temperature — into its packed
environment_vars each outer iteration, so the compartment cores and the neighbour
surface temperatures co-converge. The converged temperatures are baked once more
for the regulated solve_coupled_metabolic_rate close (a fixed exchange there, no
extra iteration). A nothing topology (single Body, or no precomputed view) passes
the setups through untouched, so the pre-coupling multicompartment path stays
bit-identical.

Test asserts the baking deterministically (no-op without topology; the right
fraction + sibling temperature with one), since the term itself is already
validated at the HeatExchange level and end-to-end in the single-compartment path.
Half shapes have no mass field; read mass(body.shape) so half-body parts
report their own (halved) mass.
Replace view_partition → silhouette_factors and silhouette_area_per_part →
silhouette(body, Point(sun)) at the multipart radiation precompute.
Migrate cache / heatexchange / multipart / ectotherm call sites; rename a
shadowing local (silhouette → area_silhouette) exposed by the function rename.
`precompute_view_partition` now calls `silhouette_factors(body, Sky(0.5))`
(explicit flat-ground hemisphere, byte-identical to the old default) and
`silhouette(body, Beam(sun))`, matching BiophysicalGeometry's beam/diffuse region
API. A sloped or occluded site would pass its own `Sky`/`Ground`/`Horizon` here.
@rafaqz rafaqz changed the title Multipart plan Multipart Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants