food_chain/ecology/feeding.js
2026-09-29 23:45:12 +09:00

80 lines
6.5 KiB
JavaScript

import {clamp,rateToProbability} from './units.js?v=45';
export const TROPHIC_ROLES = Object.freeze(['primary-consumer','secondary-consumer','tertiary-consumer','detritivore']);
export const RESOURCE_ROLES = Object.freeze(['producer','zooplankton','primary-consumer','secondary-consumer','tertiary-consumer','detritus']);
export const roleCode = role => Math.max(0,TROPHIC_ROLES.indexOf(role));
export const roleLabel = role => ({'primary-consumer':'一次消費者','secondary-consumer':'二次消費者','tertiary-consumer':'高次消費者','detritivore':'腐食者'}[role]||role);
const DEFAULT_EDGES = Object.freeze({
'primary-consumer': Object.freeze({
producer:Object.freeze({preferenceWeight:1,assimilationEfficiency:.45,functionalResponse:2,attackRate:2.2,handlingTimeDays:.05}),
zooplankton:Object.freeze({preferenceWeight:.42,assimilationEfficiency:.85,functionalResponse:2,attackRate:1.25,handlingTimeDays:.08}),
}),
'secondary-consumer': Object.freeze({
producer:Object.freeze({preferenceWeight:.12,assimilationEfficiency:.45,functionalResponse:2,attackRate:.55,handlingTimeDays:.08}),
zooplankton:Object.freeze({preferenceWeight:.32,assimilationEfficiency:.85,functionalResponse:2,attackRate:1.05,handlingTimeDays:.08}),
'primary-consumer':Object.freeze({preferenceWeight:1,assimilationEfficiency:.85,functionalResponse:2,attackRate:1.25,handlingTimeDays:.12}),
detritus:Object.freeze({preferenceWeight:.3,assimilationEfficiency:.85,functionalResponse:2,attackRate:.8,handlingTimeDays:.1}),
}),
'tertiary-consumer': Object.freeze({
'primary-consumer':Object.freeze({preferenceWeight:.45,assimilationEfficiency:.85,functionalResponse:2,attackRate:.8,handlingTimeDays:.16}),
'secondary-consumer':Object.freeze({preferenceWeight:1,assimilationEfficiency:.85,functionalResponse:2,attackRate:1.05,handlingTimeDays:.18}),
detritus:Object.freeze({preferenceWeight:.2,assimilationEfficiency:.85,functionalResponse:2,attackRate:.55,handlingTimeDays:.12}),
}),
detritivore: Object.freeze({
detritus:Object.freeze({preferenceWeight:1,assimilationEfficiency:.85,functionalResponse:2,attackRate:1.4,handlingTimeDays:.08}),
producer:Object.freeze({preferenceWeight:.08,assimilationEfficiency:.45,functionalResponse:2,attackRate:.45,handlingTimeDays:.1}),
}),
});
export function interactionEdge(consumer,resource){return DEFAULT_EDGES[consumer]?.[resource] || null}
export function bodyMassPreference(predatorMassKg,preyMassKg,profile,edge=null){
const preferred=edge?.preferredPredatorPreyMassRatio??profile.feeding.preferredPredatorPreyMassRatio,sigma=edge?.massRatioSigma??profile.feeding.massRatioSigma,ratio=Math.max(1e-8,predatorMassKg/Math.max(1e-8,preyMassKg)),mu=Math.log(preferred);
return Math.exp(-((Math.log(ratio)-mu)**2)/(2*sigma*sigma));
}
// Rall et al. (2012), all-data 'only slopes' model:
// attack: Ea=+0.44 eV, consumer exponent +0.47, resource exponent +0.15
// handling: Ea=-0.27 eV, consumer exponent -0.48, resource exponent +0.34.
// Their local residual analysis adds the nonlinear ln(predator:prey ratio) terms below
// and a quadratic temperature residual for handling time. Edge values remain transparent
// baseline calibrations at 20 C, 1 kg consumer, and the profile's preferred mass ratio.
const BOLTZMANN_EV_K=8.617333262e-5,REFERENCE_T_K=293.15;
const arrheniusFactor=(activationEnergyEv,temperatureC)=>{const t=Math.max(180,temperatureC+273.15);return Math.exp(clamp(activationEnergyEv*(t-REFERENCE_T_K)/(BOLTZMANN_EV_K*t*REFERENCE_T_K),-5,5))};
export function rallFeedingParameters(edge,consumerMassKg,resourceMassKg,temperatureC,profile){
if(!edge)return {attackRate:0,handlingTimeDays:Infinity,preference:0};
const mc=Math.max(consumerMassKg,1e-8),mr=Math.max(resourceMassKg,1e-10),preferredRatio=Math.max(1e-8,edge.preferredPredatorPreyMassRatio??profile.feeding.preferredPredatorPreyMassRatio),ratio=mc/mr,lr=Math.log(ratio),lr0=Math.log(preferredRatio),referenceConsumerKg=1,referenceResourceKg=1/preferredRatio;
const attackResidual=x=>-1.81+.37*x-.017*x*x;
const handlingMassResidual=x=>1.93-.48*x+.026*x*x;
const handlingTempResidual=t=>.51-.055*t+.0013*t*t;
const globalAttackMass=Math.pow(mc/referenceConsumerKg,.47)*Math.pow(mr/referenceResourceKg,.15),globalHandlingMass=Math.pow(mc/referenceConsumerKg,-.48)*Math.pow(mr/referenceResourceKg,.34);
const attackRatioResidual=Math.exp(clamp(attackResidual(lr)-attackResidual(lr0),-3,3)),handlingRatioResidual=Math.exp(clamp(handlingMassResidual(lr)-handlingMassResidual(lr0),-3,3));
const attackTemp=arrheniusFactor(.44,temperatureC),handlingTemp=arrheniusFactor(-.27,temperatureC),handlingTempResidualFactor=Math.exp(clamp(handlingTempResidual(temperatureC)-handlingTempResidual(20),-2,2));
const attackScale=clamp(globalAttackMass*attackRatioResidual*attackTemp,1e-3,1e3),handlingScale=clamp(globalHandlingMass*handlingRatioResidual*handlingTemp*handlingTempResidualFactor,1e-3,1e3),preference=bodyMassPreference(mc,mr,profile,edge);
return {attackRate:edge.attackRate*attackScale,handlingTimeDays:Math.max(1e-5,edge.handlingTimeDays*handlingScale),preference};
}
export function functionalResponseRatePerDay(params,resourceDensity,denominatorTerms=[],q=1){
const n=Math.max(0,resourceDensity),power=Math.pow(n,q),numerator=params.attackRate*params.preference*power;
let denominator=1;
for(const term of denominatorTerms){const tq=term.q??1,tn=Math.max(0,term.resourceDensity);denominator+=term.attackRate*term.preference*term.handlingTimeDays*Math.pow(tn,tq)}
return numerator/Math.max(1e-12,denominator);
}
export function fieldResourceIntakeKgPerDay(massKg,resourceDensityKgPerM2,edge,resourceRole='producer'){
if(!edge)return 0;
const mass=Math.max(massKg,.001),density=Math.max(0,resourceDensityKgPerM2),q=edge.functionalResponse===3?2:1;
// A biomass field has no single prey body mass. Do not feed field density into
// predator:prey body-mass scaling. Use a density-based Holling saturation instead.
const halfSaturation=resourceRole==='zooplankton'?.012:.08;
const x=Math.pow(density,q),half=Math.pow(halfSaturation,q),saturation=x/Math.max(1e-12,half+x);
const coefficient=resourceRole==='zooplankton'?.12:.16;
const maxIntakeKgPerDay=Math.max(.002,coefficient*Math.pow(mass,.78))*edge.preferenceWeight;
return maxIntakeKgPerDay*saturation;
}
export function producerIntakeKgPerDay(massKg,producerKgPerM2,edge){
return fieldResourceIntakeKgPerDay(massKg,producerKgPerM2,edge,'producer');
}
export function attackProbabilityPerStep(ratePerDay,dtDays){return rateToProbability(ratePerDay,dtDays)}