This commit is contained in:
33333-33333 2026-09-29 19:10:03 +09:00
commit 01204dfa2f
34 changed files with 1498 additions and 0 deletions

13
ecology/climate.js Normal file
View file

@ -0,0 +1,13 @@
import {clamp} from './units.js';
export class ClimateProvider{
constructor({meanTemperatureC=15,amplitudeC=10,phaseDay=205}={}){this.meanTemperatureC=meanTemperatureC;this.amplitudeC=amplitudeC;this.phaseDay=phaseDay;this.series=new Map}
setSeries(variable,values){const clean=Array.from(values??[],Number);if(!clean.length||clean.some(v=>!Number.isFinite(v)))throw new TypeError(`${variable} values must contain finite numbers`);this.series.set(variable,clean)}
hasSeries(variable){return this.series.has(variable)}
setDailyTemperature(values){this.setSeries('temperatureC',values)}
clearObserved(variable=null){if(variable)this.series.delete(variable);else this.series.clear()}
value(variable,day,fallback=null){const values=this.series.get(variable);if(values){const i=clamp(Math.floor(day),0,values.length-1);return values[i]}return typeof fallback==='function'?fallback(day):fallback}
temperatureC(day,{aquatic=false}={}){if(aquatic&&this.series.has('surfaceTemperatureC'))return this.value('surfaceTemperatureC',day);return this.value('temperatureC',day,()=>this.meanTemperatureC+this.amplitudeC*Math.cos(2*Math.PI*(day-this.phaseDay)/365))}
precipitationMm(day){return this.value('precipitationMm',day,0)}
soilMoisture(day,fallback){return this.value('soilMoisture',day,fallback)}
}

40
ecology/demography.js Normal file
View file

@ -0,0 +1,40 @@
import {annualSurvivalToDailyHazard,rateToProbability,clamp} from './units.js';
// Life-history fallbacks are simulator calibration values, not universal biological constants.
export const ROLE_DEMOGRAPHY = Object.freeze({
'primary-consumer': {annualSurvival:.38,offspringPerEvent:1,reproductionIntervalDays:180},
'secondary-consumer': {annualSurvival:.58,offspringPerEvent:1,reproductionIntervalDays:240},
'tertiary-consumer': {annualSurvival:.70,offspringPerEvent:1,reproductionIntervalDays:365},
detritivore: {annualSurvival:.45,offspringPerEvent:1,reproductionIntervalDays:210},
});
export function backgroundHazardPerDay(role){return annualSurvivalToDailyHazard((ROLE_DEMOGRAPHY[role]||ROLE_DEMOGRAPHY['primary-consumer']).annualSurvival)}
export function mortalityOccurs(hazardPerDay,dtDays,random){return random()<rateToProbability(hazardPerDay,dtDays)}
export function reproductionConfig(role){return ROLE_DEMOGRAPHY[role]||ROLE_DEMOGRAPHY['primary-consumer']}
export function inBreedingSeason(day,role){
const doy=((day%365)+365)%365;
if(role==='tertiary-consumer')return doy>=60&&doy<=180;
if(role==='secondary-consumer')return (doy>=60&&doy<=165)||(doy>=245&&doy<=300);
if(role==='detritivore')return (doy>=35&&doy<=175)||(doy>=220&&doy<=315);
return (doy>=45&&doy<=170)||(doy>=225&&doy<=305);
}
export function offspringEnergyShare(role){return role==='tertiary-consumer'?.18:.13}
export function reproductionThreshold(role){return role==='tertiary-consumer'?.82:role==='secondary-consumer'?.8:.78}
export const energyFraction = (energy,maxEnergy)=>clamp(maxEnergy>0?energy/maxEnergy:0,0,1);
// Structural growth is paid from assimilated energy. These are simulator
// calibration values rather than universal life-history constants.
export const GROWTH_ENERGY_COST_KJ_PER_KG = 6000;
export const RESERVE_FLOOR_FRACTION_FOR_GROWTH = 0.50;
export function initialStructuralMassKg(adultMassKg,offspringSize,ageDays,maturityAgeDays){
const adult=Math.max(1e-6,adultMassKg),birth=adult*clamp(offspringSize,0.01,1),progress=clamp(ageDays/Math.max(1e-6,maturityAgeDays),0,1);
return birth+(adult-birth)*progress;
}
export function energyLimitedGrowth({structuralMassKg,adultMassKg,energyKJ,reserveCapacityKJPerKg=1200,ageDays,maturityAgeDays,dtDays}){
const mass=Math.max(1e-6,structuralMassKg),adult=Math.max(mass,adultMassKg),remaining=Math.max(0,adult-mass);
if(!remaining)return {structuralMassKg:mass,energyKJ,gainKg:0};
const reserveFloor=mass*reserveCapacityKJPerKg*RESERVE_FLOOR_FRACTION_FOR_GROWTH,available=Math.max(0,energyKJ-reserveFloor);
if(!available)return {structuralMassKg:mass,energyKJ,gainKg:0};
const daysToMaturity=Math.max(1,maturityAgeDays-ageDays),potential=remaining*Math.min(1,dtDays/daysToMaturity),affordable=available/GROWTH_ENERGY_COST_KJ_PER_KG,gain=Math.min(remaining,potential,affordable);
return {structuralMassKg:mass+gain,energyKJ:energyKJ-gain*GROWTH_ENERGY_COST_KJ_PER_KG,gainKg:gain};
}

73
ecology/feeding.js Normal file
View file

@ -0,0 +1,73 @@
import {clamp,rateToProbability} from './units.js';
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 producerIntakeKgPerDay(massKg,producerKgPerM2,edge,temperatureC=20,profile={feeding:{preferredPredatorPreyMassRatio:35,massRatioSigma:1.05}}){
if(!edge)return 0;
const resourceMassEquivalent=Math.max(1e-5,producerKgPerM2*.01),params=rallFeedingParameters(edge,massKg,resourceMassEquivalent,temperatureC,profile),q=edge.functionalResponse===3?2:1;
const raw=functionalResponseRatePerDay(params,producerKgPerM2,[{...params,resourceDensity:producerKgPerM2,q}],q);
const maxIntake=Math.max(.002,.16*Math.pow(Math.max(massKg,.001),.78))*edge.preferenceWeight;
return Math.min(maxIntake,raw*Math.max(.03,Math.pow(massKg,.58)));
}
export function attackProbabilityPerStep(ratePerDay,dtDays){return rateToProbability(ratePerDay,dtDays)}

23
ecology/metabolism.js Normal file
View file

@ -0,0 +1,23 @@
import {KELVIN_OFFSET, clamp} from './units.js';
const B0 = Object.freeze({ectotherm: 42, endotherm: 310}); // kJ d^-1 at 1 kg, simulator calibration
const ACTIVATION_EV = 0.65;
const BOLTZMANN_EV = 8.617333262e-5;
export function ectothermTemperatureMultiplier(temperatureC,referenceC=20){
const t=clamp(temperatureC+KELVIN_OFFSET,180,350),ref=referenceC+KELVIN_OFFSET;
return Math.exp((-ACTIVATION_EV/BOLTZMANN_EV)*(1/t-1/ref));
}
export function basalMetabolismKJPerDay(massKg,temperatureC,physiology='ectotherm'){
const mass=Math.max(1e-4,massKg),temp=physiology==='ectotherm'?ectothermTemperatureMultiplier(temperatureC):1;
return B0[physiology] * Math.pow(mass,.75) * temp;
}
export function starvationHazardPerDay(energyFraction){
if(energyFraction>=.18)return 0;
return 0.018 + 0.34*Math.pow((.18-Math.max(0,energyFraction))/.18,2);
}
export function thermalHazardPerDay(temperatureC,preferredC,toleranceC,physiology='ectotherm'){
const excess=Math.max(0,Math.abs(temperatureC-preferredC)-Math.max(1,toleranceC));
if(!excess)return 0;
return (physiology==='endotherm'?.00025:.0008)*excess*excess;
}

37
ecology/movement.js Normal file
View file

@ -0,0 +1,37 @@
const MODE = Object.freeze({
// Hirt et al. time-dependent maximum-speed form:
// v = a * M^b * (1 - exp(-h * M^i)), v in km/h, M in kg.
// Flying, running and swimming coefficients are the published Supplementary
// Table 4 fits for the time-dependent maximum-speed model.
running:{a:25.5,b:.26,h:22,i:-.60,routineFraction:.34,homeRangeFactor:1,calibration:'Hirt-2017'},
swimming:{a:11.2,b:.36,h:19.5,i:-.56,routineFraction:.30,homeRangeFactor:1.25,calibration:'Hirt-2017'},
flying:{a:142.8,b:.24,h:2.4,i:-.72,routineFraction:.42,homeRangeFactor:1.8,calibration:'Hirt-2017'},
});
export const locomotionModes=Object.freeze(Object.keys(MODE));
export const normalizeLocomotionMode=mode=>MODE[mode]?mode:'running';
export const locomotionCalibration=mode=>({...MODE[normalizeLocomotionMode(mode)]});
export function maximumSpeedMPerDay(massKg,mode='running'){
const c=MODE[normalizeLocomotionMode(mode)],m=Math.max(massKg,1e-6),kmh=c.a*Math.pow(m,c.b)*(1-Math.exp(-c.h*Math.pow(m,c.i)));
return Math.max(1,kmh*24000); // km/h -> m/day
}
export const maximumSpeedMultiplier=massKg=>maximumSpeedMPerDay(massKg,'running')/maximumSpeedMPerDay(3,'running');
export function routineTravelSpeedMPerDay(massKg,dailyMovementBudgetM,mode='running'){
const c=MODE[normalizeLocomotionMode(mode)],budgetSpeed=Math.max(0,dailyMovementBudgetM)*c.routineFraction;
return Math.min(maximumSpeedMPerDay(massKg,mode)*.22,Math.max(1,budgetSpeed));
}
export function foragingSpeedMPerDay(massKg,dailyMovementBudgetM,mode='running'){
return Math.min(maximumSpeedMPerDay(massKg,mode)*.42,routineTravelSpeedMPerDay(massKg,dailyMovementBudgetM,mode)*1.35);
}
export function escapeSpeedMPerDay(massKg,dailyMovementBudgetM,mode='running'){
return Math.min(maximumSpeedMPerDay(massKg,mode),routineTravelSpeedMPerDay(massKg,dailyMovementBudgetM,mode)*2.4);
}
export function dailyMovementBudgetM(massKg,geneBudgetM,mode='running'){
const c=MODE[normalizeLocomotionMode(mode)];
return Math.max(25,geneBudgetM)*(0.75+0.25*Math.pow(Math.max(massKg,.01)/3,.08))*(.9+.1*c.homeRangeFactor);
}
export function homeRangeRadiusM(massKg,mode='running'){
const c=MODE[normalizeLocomotionMode(mode)];return Math.min(1500,Math.max(90,170*Math.pow(Math.max(massKg,.01),.22)*c.homeRangeFactor));
}
export function sampleDispersalDistanceM(random,massKg,mode='running'){
const scale=homeRangeRadiusM(massKg,mode)*.55;return Math.min(scale*3,-Math.log(Math.max(1e-9,1-random()))*scale);
}

11
ecology/profiles.js Normal file
View file

@ -0,0 +1,11 @@
export const TERRESTRIAL = Object.freeze({
id:'terrestrial',type:'terrestrial',producer:{rMaxPerDay:.03,carryingCapacityKgPerM2:.5},feeding:{preferredPredatorPreyMassRatio:35,massRatioSigma:1.05},environment:{light:.82,nutrientN:.78,nutrientP:.78,dissolvedOxygen:1},movement:{homeRangeMultiplier:1}
});
export const FRESHWATER = Object.freeze({
id:'freshwater',type:'freshwater',producer:{carryingCapacityKgPerM2:.18},feeding:{preferredPredatorPreyMassRatio:80,massRatioSigma:1.12},environment:{light:.72,nutrientN:.58,nutrientP:.52,dissolvedOxygen:.82},movement:{homeRangeMultiplier:1.15}
});
export const MARINE = Object.freeze({
id:'marine',type:'marine',producer:{carryingCapacityKgPerM2:.14},feeding:{preferredPredatorPreyMassRatio:55,massRatioSigma:1.08},environment:{light:.76,nutrientN:.54,nutrientP:.48,dissolvedOxygen:.9},movement:{homeRangeMultiplier:1.35}
});
export const PROFILES=Object.freeze({terrestrial:TERRESTRIAL,freshwater:FRESHWATER,marine:MARINE});
export function getProfile(id){return PROFILES[id]||null}

32
ecology/resources.js Normal file
View file

@ -0,0 +1,32 @@
import {clamp} from './units.js';
export class ResourceField{
constructor({id,role,cellCount,energyDensityKJPerKg,representation='field'}){this.id=id;this.role=role;this.energyDensityKJPerKg=energyDensityKJPerKg;this.representation=representation;this.biomass=new Float32Array(cellCount)}
consume(index,amountKg,cellAreaM2){const available=this.biomass[index]*cellAreaM2,taken=Math.min(Math.max(0,amountKg),available);this.biomass[index]=Math.max(0,this.biomass[index]-taken/cellAreaM2);return taken}
}
export const phytoplanktonMuMax = temperatureC => 0.81 * Math.exp(0.0631 * temperatureC);
export function producerCapacity(profile,{moisture=1,shade=0,blocked=false}={}){
if(blocked)return 0;
if(profile.type==='terrestrial')return profile.producer.carryingCapacityKgPerM2 * clamp(0.35 + 0.65*moisture,0,1) * (1 - 0.18*clamp(shade,0,1));
return profile.producer.carryingCapacityKgPerM2;
}
export function producerGrowthPerDay(profile,temperatureC,{moisture=1,light,nutrientN,nutrientP}={}){
if(profile.type==='terrestrial'){
const fT=Math.exp(-0.5*((temperatureC-20)/13)**2),fW=clamp(moisture,0,1),fL=clamp(light ?? profile.environment.light,0,1);
return profile.producer.rMaxPerDay * fT * fW * fL;
}
const fI=clamp(light ?? profile.environment.light,0,1),fN=clamp(nutrientN ?? profile.environment.nutrientN,0,1),fP=clamp(nutrientP ?? profile.environment.nutrientP,0,1);
return phytoplanktonMuMax(temperatureC) * Math.min(fN,fP) * fI;
}
export function updateProducerBiomass(biomass,capacity,growthPerDay,consumedKgPerM2,dtDays){
if(capacity<=0)return 0;const growth=growthPerDay*biomass*(1-biomass/capacity);return clamp(biomass + growth*dtDays - Math.max(0,consumedKgPerM2),0,capacity*1.05);
}
export function updateZooplanktonCohort(producerKgPerM2,zooplanktonKgPerM2,dtDays=1){
const z=Math.max(0,zooplanktonKgPerM2),p=Math.max(0,producerKgPerM2),ingestion=Math.min(p,.9*z*p/(.035+p))*dtDays,assimilated=ingestion*.45,mortality=.10*z*dtDays;
return {producerConsumedKgPerM2:ingestion,nextZooplanktonKgPerM2:Math.max(0,z+assimilated-mortality),productionKgPerM2:Math.max(0,assimilated-mortality)};
}

3
ecology/schema.js Normal file
View file

@ -0,0 +1,3 @@
import {PROFILES} from './profiles.js';
export function validateProfileId(id){if(!PROFILES[id])throw new RangeError(`unknown environment profile: ${id}`);return id}

7
ecology/units.js Normal file
View file

@ -0,0 +1,7 @@
export const HOURS_PER_DAY = 24;
export const KELVIN_OFFSET = 273.15;
export const DEFAULT_TIME_STEP_DAYS = 1 / HOURS_PER_DAY;
export const CELL_AREA_M2 = cellSizeM => cellSizeM * cellSizeM;
export const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
export const rateToProbability = (ratePerDay, dtDays) => 1 - Math.exp(-Math.max(0, ratePerDay) * dtDays);
export const annualSurvivalToDailyHazard = annualSurvival => -Math.log(clamp(annualSurvival, 1e-6, 1)) / 365;