/** Helper functions
* ABE Library
The library provides a number of helper functions.
#### Building STPs
The library provides functions to simplify the construction of Stand treatment programs.
+ `lib.createSTP`: takes one or several activites and creates a STP with a given name
#### Introspection
+ use `formattedLog()` and `formattedSTP()` for a detailed look into past and plant activitites
#### Miscallaneous
+ Logging: use `log()` and `dbg()` functions and `lib.logevel` to control the amount of log information
+ Activity log: use `activityLog()` (internally) to add to the stand-level log data
#### Internals
+ `lib.mergeOptions`: help with global / local settings
+ `lib.selectOptimalPatches`: compare patches and select the best based on a criterion
#### Useful activites
+ `changeSTP`: set follow-up STP when the current STP ends
+ `repeater`: simple activity to repeatedly run a single JS function / activity
@class helper
*/
/**
* Initializes the `stand.obj` Javascript object for the current stand (`stand.id`).
*
* @method initStandObj
*/
lib.initStandObj = function() {
if (typeof stand.obj === 'object' && typeof stand.obj.lib === 'object') return;
if (stand.obj === undefined)
stand.obj = {}; // set an empty object
if (stand.obj.lib === undefined)
stand.obj.lib = {}; // object for library-internal communication
}
/**
* Initializes all stands of the current simulation (`initStandObj()`).
*
* @method initAllStands
*/
lib.initAllStands = function() {
for (const id of fmengine.standIds) {
// set the focus of ABE to this stand:
fmengine.standId = id;
// access data for the stand
lib.initStandObj();
}
}
/**
* Helper function to get a list of all unique stand ids from the standGrid.
*
* @method getAllStandGridIds
*/
function getAllStandGridIds() {
// Get the stand grid ScriptGrid object
// Globals.grid("standgrid") provides access to the spatial stand grid definition [4].
let standGrid = Globals.grid("standgrid");
if (!standGrid) {
console.log("Error: Stand grid not available.");
return [];
}
// Get all unique stand ids from standGrid
const uniqueStandIds = Array.from(
new Set(
standGrid.values().filter(
v => Number.isInteger(v) && v > -1
)
)
);
return uniqueStandIds;
}
/**
* Sanity check for all stands, if they have a STP.
*
* @method CheckManagementOfStands
*/
lib.CheckManagementOfStands = function() {
var allGood = true;
var standGridIDs = getAllStandGridIds();
let nonExistingStands = [];
for (const id of standGridIDs) {
// check if stand id is a valid id within the FM engine
if (fmengine.isValidStand(id) === false) {
nonExistingStands.push(id);
allGood = false;
console.log(`CheckManagementOfStands: stand ${id} has no stp set.`)
}
};
console.log(`CheckManagementOfStands: All stands have stp set: ${allGood}`);
if (allGood === false) {
throw new Error(`CheckManagementOfStands: stands ${nonExistingStands} have no stp set.`);
}
}
lib.loglevel = 0; // 0: none, 1: normal, 2: debug
/**
* Internal function to log a string `str` to the iLand logfile.
* You can control the amount of log messages by setting `lib.loglevel` to the following values:
* * `0`: None - no messages from abe-library
* * `1`: Normal - limited amount of messages from the library, usually only high level
* * `2`: Debug - high number of log messages. Use for debugging and not in productive model applications.
*
* See also: lib.dbg()
*
* @example
* // set log level (e.g. in iLand Javascript console or in your code) after loading the library
* lib.loglevel = 2; //debug!
* ...
* // somewhere in library code: running the function now produces log messages
* lib.dbg(`selectiveThinning: repeat ${stand.obj.lib.selective_thinning_counter}, removed ${harvested} trees.`);
*
*
* @param str {String} string to log
* @method log
*/
lib.log = function(str) {
if (lib.loglevel > 0)
fmengine.log(str);
}
/**
* Internal debug function to log a string `str` to the iLand logfile.
* You can control the amount of log messages by setting `lib.loglevel` to the following values:
* * `0`: None - no messages from abe-library
* * `1`: Normal - limited amount of messages from the library, usually only high level
* * `2`: Debug - high number of log messages. Use for debugging and not in productive model applications.
*
* See also: lib.dbg()
*
* @param str {String} string to log
* @method dbg
*/
lib.dbg = function(str) {
if (lib.loglevel > 1)
fmengine.log(str);
}
/* helper functions within the library */
lib.mergeOptions2 = function(defaults, options) {
const merged = {};
for (const key in defaults) {
merged[key] = options[key] !== undefined ? options[key] : defaults[key];
}
return merged;
}
/**
* Helper function to combine deafult options and user-provided options for activities.
*
* The function throws an error when `options` include values not defined in `defaults`.
* You should therefore define all potential keys that can be used by the user with value `undefined`!
*
* @example
* // pattern
* const default_options = { schedule: undefined, intensity: 10 };
* // create an object with combined options (even if not provided)
* const opts = lib.mergeOptions(defaultOptions, options || {});
* // ...
*
*
* @param defaults object containing default values
* @param options object user-defined options
* @return object that contains default values updated with user-provided options
* @method mergeOptions
*/
lib.mergeOptions = function(defaults, options) {
const merged = {};
for (const key in defaults) {
if (options && options.hasOwnProperty(key)) {
merged[key] = options[key];
} else {
merged[key] = defaults[key];
}
}
// Check for invalid options
const validOptions = Object.keys(defaults);
for (const key in options) {
if (!defaults.hasOwnProperty(key)) {
throw new Error(`Invalid option: "${key}". \nValid options are: ${validOptions.join(', ')}`);
}
}
return merged;
}
/**
* Library function to build a full iLand STP from a collection of elements.
*
* The function takes one or multiple elements that are typically Javascript objects to define iLand activities.
* These Javascript objects typically are returnd by library functions; for example, the library function `lib.thinning.tending()`
* returns (one or more) *definitions* of iLand activities. `buildProgram` combines multiple elements to a single Javascript
* object. Note that this function does not intitalize iLand activities - it merely operates on Javascript objects. Use `createSTP`
* to actually create a stand treatment program in iLand!
*
* @see
*
* @example
* //
* const StructureThinning = lib.thinning.selectiveThinning({mode = 'dynamic'});
* const StructureHarvest = lib.harvest.targetDBH({dbhList = {"fasy":65, //source: 'Waldbau auf ökologischer Grundlage', p.452
* "frex":60, "piab":45, "quro":75, "pisy":45, "lade":65,
* "qupe":75, "psme":65, "abal":45, "acps":60, "pini":45}});
*
* const stp = lib.buildProgram(StructureThinning, StructureHarvest);
* // you can still modify the program, e.g, by adding activities!
* stp['a_new_activity'] = { type: 'general', schedule: .... };
*
*
*
* @param concepts one or multiple concepts, typically the result of calls to library function
* @return object a definitions of multiple activities combined in a single object
* @method buildProgram
*/
lib.buildProgram = function (...concepts) {
const program = {};
for (const conceptResult of concepts) {
if (Array.isArray(conceptResult)) { // Multiple results
conceptResult.forEach((activity, index) => {
const key = `${conceptResult[0].type}${index + 1}`; // Or your naming logic
program[key] = activity;
});
} else { // Single result
program[conceptResult.type] = conceptResult;
}
}
return program;
}
/**
* Main library function to create stand treatment programs in iLand.
*
* The function takes one or multiple elements that are typically Javascript objects to define iLand activities, combines them,
* and creates a STP in iLand. If the STP already exists, it updates the existing STP (using `fmengine.updateManagement`),
* and creates a new stp otherwise (using `fmengine.addManagement`).
*
*
* See also: {{#crossLink "lib.helper/buildProgram:method"}}{{/crossLink}},{{#crossLink "FMEngine/updateManagement:method"}}{{/crossLink}}
*
* @example
* //
* const StructureThinning = lib.thinning.selectiveThinning({mode = 'dynamic'});
* const StructureHarvest = lib.harvest.targetDBH({dbhList = {"fasy":65, //source: 'Waldbau auf ökologischer Grundlage', p.452
* "frex":60, "piab":45, "quro":75, "pisy":45, "lade":65,
* "qupe":75, "psme":65, "abal":45, "acps":60, "pini":45}});
*
* lib.createSTP('my_structure_stp',StructureThinning, StructureHarvest);
*
* @param stp_name {string} name of the stand treatment program
* @param concepts one or multiple concepts, typically the result of calls to library function
* @method createSTP
*/
lib.createSTP = function(stp_name, ...concepts) {
const program = {};
let activityCounter = 1;
for (const conceptResult of concepts) {
if (Array.isArray(conceptResult)) {
for (const activity of conceptResult) {
program[`activity${activityCounter}`] = activity;
activityCounter++;
}
} else {
program[`activity${activityCounter}`] = conceptResult;
activityCounter++;
}
}
if (fmengine.isValidStp(stp_name)) {
// the STP already exists, so update the program
fmengine.updateManagement(program, stp_name);
} else {
// the STP does not yet exist, add to fmengine
fmengine.addManagement(program, stp_name);
}
}
/** Management history
Stands store a activity log, also used for DNN training.
`activityLog` is the internal function to populate the management history. The `extraValues` is
any value that can be defined by the activity itself.
See also: TODO: write management history, activityLog()
@param actName {String} the activity name to log
@param extraValues {String} extra values (activity specific)
@method activityLog
*/
lib.activityLog = function(actName, extraValues) {
if (typeof stand.obj !== 'object' || typeof stand.obj.lib !== 'object')
throw new Error(`activityLog() for stand ${stand.id}: stand.obj not available. Call lib.initStandObj()!`);
if (typeof stand.obj.history !== 'object')
stand.obj.history = [];
const log_item = { year: Globals.year,
standId: stand.id,
stp: stand.stp.name,
activity: actName,
details: extraValues};
stand.obj.history.push(log_item);
}
/**
* returns the activity log for the currently active stand as formatted HTML.
*
*
* @example
* // show the log for stand 13 in a popup window
* fmengine.standId = 13;
* Globals.alert( lib.formattedLog() );
*
* @method formattedLog
*/
lib.formattedLog = function(StandId) {
// set the focus of ABE to the input StandID if provided
if (StandId !== undefined) {
fmengine.standId = StandId;
}
// also add that only existing standIDs can be provided
if (typeof stand.obj !== 'object' || !Array.isArray(stand.obj.history)) {
return "No activity log available.";
}
let htmlLog = `<h1>Activity Log - ${stand.id}</h1>`;
stand.obj.history.forEach(item => {
const year = item.year;
htmlLog += `<div>
<h2>${year} - ${item.activity}</h2>
<ul>
<li>Stand ID: ${item.standId}</li>
<li>STP: ${item.stp}</li>`;
if (item.details) {
htmlLog += `<li>Details: <pre>${JSON.stringify(item.details, null, 2)}</pre></li>`;
}
htmlLog += `</ul>
</div>`;
});
return htmlLog;
}
/**
* returns a readable description of the current STP as formatted HTML.
*
* The description is a list of activites of the STP that is assigned the currently active stand.
* Items are greyed out if they have already been run in the current rotation, provide the planned year
* and a detailed descriptions (as provdied by the library functions)
*
* @example
* // show the log for stand 13 in a popup window
* fmengine.standId = 13;
* Globals.alert( lib.formattedSTP() );
*
* @method formattedSTP
*/
lib.formattedSTP = function(StandId) {
// set the focus of ABE to the input StandID if provided
if (StandId !== undefined) {
fmengine.standId = StandId;
}
// also add that only existing standIDs can be provided
let htmlLog = `<h1>Planned Activities</h1><h2>Stand: ${stand.id} STP: ${stand.stp.name}</h2>`;
for (name of stand.stp.activityNames) {
let act = stand.activityByName(name);
let col = act.active ? 'black' : 'gray';
let year = act.optimalTime > 10000 ? '(signal)' : act.optimalTime;
htmlLog += `<div>
<h3 style="color: ${col};">${year} - ${act.name}</h3>
<ul><li>Active (in this rotation): <b>${act.active}</b></li>
<li>Enabled (at all): <b>${act.enabled}</b></li>
<li>Description: ${act.description}</li>
</ul>
</div> `;
}
return htmlLog;
}
/**
* Selects optimal patches based on a given criterion.
*
* This function helps with selecting the best patches in a stand based on a specified criterion
* (e.g., light availability, basal area) or a custom function. It's useful for setting up
* spatially explicit management activities like creating gaps or targeting specific areas
* within a stand for treatments.
*
* @method selectOptimalPatches
* @param {object} options Options for configuring the patch selection.
* @param {string} options.id A unique identifier for the activity (default: 'selectOptimalPatches').
* @param {number} options.N Number of patches to select per hectare (default: 4).
* @param {number} options.patchsize Size of the patches (in cells, assuming a square shape, e.g., 2 for 2x2 cells, which is 20x20m or 400m2) (default: 2).
* @param {number} options.spacing Space (in 10m cells) between candidate patches (default: 0).
* @param {string} options.criterium Criterion for selecting patches ('max_light', 'min_light', or 'min_basalarea') (default: 'max_light').
* @param {function|undefined} options.customFun Custom function for evaluating patches. This function should take a patch object as input and return a score (default: undefined).
* @param {number} options.patchId ID to assign to the selected patches (default: 1).
* @param {string} options.sendSignal Optional: Signal that should be send after selecting patches (default: undefined).
* @param {object} options.schedule Schedule object for triggering the patch selection (default: { signal: 'start' }).
* @return {object} act - An object describing the patch selection activity.
* @example
* // Select 5 patches per hectare based on maximum light availability, using 3x3 patches.
* lib.selectOptimalPatches({
* N: 5,
* patchsize: 3,
* criterium: 'max_light'
* });
*
* // Select 2 patches per hectare based on a custom evaluation function.
* lib.selectOptimalPatches({
* N: 2,
* customFun: function(patch) {
* // Example: Score based on proximity to a specific location
* const targetX = 50;
* const targetY = 50;
* const distance = Math.sqrt(Math.pow(patch.x - targetX, 2) + Math.pow(patch.y - targetY, 2));
* return 1 / distance; // Higher score for closer patches
* }
* });
*/
lib.selectOptimalPatches = function(options) {
// 1. Default Options
const defaultOptions = {
id: 'selectOptimalPatches',
N: 4, // select N patches per ha
patchsize: 2, // 2x2 = 20x20 = 400m2
spacing: 0, // space (in 10m cells) between candidate patches
criterium: 'max_light', // fixed options
customPrefFunc: undefined, // custom preference function
patchId: 1, // id of selected patches
sendSignal: undefined, // optional: emit signal after patches have been selected
schedule: { signal: 'start' }, // default behavior: trigger on 'start'
// ... add other default parameters
};
const opts = lib.mergeOptions(defaultOptions, options || {});
// code for patches
function createPatches(opts) {
// overwrite patches with a regular pattern (with some at least patchsize spacing)
stand.patches.list = stand.patches.createRegular(opts.patchsize,opts.spacing);
}
// select the N patches with top scores
function topN(n) {
// sorting *directly* within stand.patches.list does not work.
// instead:
var slist = stand.patches.list;
const pcount = stand.patches.list.length;
// sort score (descending)
slist.sort( (a,b) => b.score - a.score);
// reduce to the N patches with top score
slist = slist.slice(0, n);
lib.dbg(`topN: before: ${pcount} patches, after: ${slist.length} patches.`);
return slist;
}
function patchEvaluation(patch, opts) {
var score = 0;
// pre-defined variables
switch (opts.criterium) {
case 'max_light':
score = stand.patches.lif(patch);
break; // get LIF on the cells
case 'min_light':
score = - stand.patches.lif(patch);
break;
case 'min_basalarea':
// evaluate the basal area
stand.trees.load('patch = ' + patch.id);
var basal_area = stand.trees.sum('basalarea') / patch.area; // basal area / ha
score = -basal_area; // top down
break;
case 'max_basalarea':
// evaluate the basal area
stand.trees.load('patch = ' + patch.id);
var basal_area = stand.trees.sum('basalarea') / patch.area; // basal area / ha
score = basal_area;
break;
case 'custom':
if (opts.customPrefFunc === undefined) {
throw new Error(`selectOptimalPatches: the custom preference function 'customPrefFunc' is not defined!`);
}
// evaluate the custom function
stand.trees.load('patch = ' + patch.id);
var score = stand.trees.sum(opts.customPrefFunc) / patch.area; // calculate score based on custom function / ha
break;
default:
throw new Error(`selectOptimalPatches: invalid criterion "${opts.criterium}"!`);
}
patch.score = score;
}
return {
id: opts.id,
type: 'general',
schedule: opts.schedule,
action: function() {
// (1) init
stand.patches.clear();
const n_ha = opts.N * stand.area;
lib.log(`selectOptimalPatches: ${n_ha} / ha, based on ${opts.criterium}.`);
// (2) create candidate patches
createPatches(opts);
// (3) Evaluate patches
stand.patches.list.forEach((p) => patchEvaluation(p, opts));
// (4) select patches based on the score provided in the evaluation function
stand.patches.list = topN(n_ha);
// (5) set all patches to a single ID
stand.patches.list.forEach((p) => p.id = opts.patchId);
stand.patches.updateGrid(); // to make changes visible
},
onExit: function() {
if (opts.sendSignal !== undefined) {
lib.dbg(`Signal: ${opts.sendSignal} emitted.`);
stand.stp.signal(opts.sendSignal);
}
},
}
}
/**
* Switches the active Stand Treatment Program (STP) for a stand.
*
* This function allows you to dynamically change the STP that is being applied to a stand during a simulation.
* It's particularly useful for implementing adaptive management strategies or scenarios where different
* management regimes should be applied based on specific conditions or triggers.
*
* @method changeSTP
* @param {object} options Options for configuring the STP change.
* @param {string} options.STP The name of the STP to switch to. This STP must already be defined in the iLand project.
* @param {object} options.schedule Schedule object for triggering the STP change (default: { signal: 'end' }).
* @param {string} options.id A unique identifier for the activity (default: 'change_stp').
* @return {object} act - An object describing the STP change activity.
* @example
* // Switch to the 'harvest_STP' when the 'start' signal is received.
* lib.changeSTP({
* STP: 'harvest_STP',
* schedule: { signal: 'start' }
* });
*
* // Switch to 'passive_STP' after 100 years.
* lib.changeSTP({
* STP: 'passive_STP',
* schedule: { absolute: true, opt: 100}
* });
*/
lib.changeSTP = function(options) {
// 1. Default Options
const defaultOptions = {
STP: undefined, // the STP that should follow after the end of the currently running STP
schedule: { signal: 'end' }, // default behavior: trigger on end signal
id: 'change_stp'
// ... add other default parameters
};
const opts = lib.mergeOptions(defaultOptions, options || {});
if (!fmengine.isValidStp(opts.STP)) {
throw new Error(`lib.changeSTP: the target STP "${opts.STP}" is not available!`);
}
return {
type: 'general', schedule: opts.schedule,
id: opts.id,
action: function() {
fmengine.log("The next STP will be: " + opts.STP);
// TODO: find a way to actually do that :=)
stand.setSTP(opts.STP);
}
};
}
/**
* Creates a repeater activity that repeatedly triggers a specified signal.
*
* This function is useful for creating activities that need to be executed multiple times
* at regular intervals, such as repeated thinnings or harvests. The repeater can be
* configured to trigger based on a schedule or a signal, and it can optionally block
* other activities until it has finished.
*
* @method repeater
* @param {object} options Options for configuring the repeater.
* @param {object} options.schedule Schedule object for starting the repeater.
* @param {string} options.id A unique identifier for the repeater activity (default: 'repeater').
* @param {number} options.count Number of times to repeat the signal (default: undefined).
* @param {number} options.interval Interval (in years) between repetitions (default: 1).
* @param {string} options.signal Name of the signal to emit at each repetition.
* @param {boolean} options.block Whether the repeater should block other activities until it finishes (default: true).
* @param {function|undefined} options.parameter Function to provide the signal parameter when the signal is emitted (default: undefined).
* @return {object} act - An object describing the repeater activity.
* @example
* // Repeat the 'thinning' signal 5 times every 10 years, starting in year 50.
* lib.repeater({
* schedule: { start: 50 },
* count: 5,
* interval: 10,
* signal: 'thinning'
* });
*
* // Repeat the 'harvest' signal 3 times every 2 years, triggered by the 'ready_for_harvest' signal,
* // and provide a custom parameter to the signal.
* lib.repeater({
* schedule: { signal: 'ready_for_harvest' },
* count: 3,
* interval: 2,
* signal: 'harvest',
* parameter: function() {
* // Example: Return the current stand's basal area as the parameter.
* return stand.basalArea();
* }
* });
*/
lib.repeater = function(options) {
// 1. Default Options
const defaultOptions = {
schedule: undefined, ///< when to start the repeater
id: 'repeater',
count: undefined, ///< number of repetitions
interval: 1, ///< interval between repetitions
signal: undefined, ///< signal of the activity to be executed
block: true, ///< all other activities only resume after the repeater ends
parameter: undefined ///< function that provides the signal parameter
};
const opts = lib.mergeOptions(defaultOptions, options || {});
if (opts.signal === undefined)
throw new Error("Repeater: signal is required!");
return {
type: 'general', schedule: opts.schedule,
id: opts.id,
action: function() {
const repeat_interval = opts.interval;
const repeat_count = opts.count;
const signal = opts.signal;
stand.repeat(this,
function() {
lib.dbg(`repeater: emit signal "${opts.signal}"`);
let param = opts.parameter;
if (typeof opts.parameter === 'function')
param = opts.parameter.call(opts);
stand.stp.signal(opts.signal, param);
},
opts.interval,
opts.count);
// make sure that only the repeater runs
if (opts.block)
stand.sleep(opts.interval * opts.count);
},
}
}
/**
* Constructs a iLand expression for filtering trees based on the proportion of target species in the stand.
* When the relative basal area of all target species combined is below `threshold`, then
* trees of these species are filtered out. The chance of being filtered out declines linearly up to
* 2x threshold, above no trees are filtered.
* The species list can be provided in multiple formats.
*
* @param {string | string[] | object} speciesList The list of species. Can be:
* - A single species ID string (e.g., "quro").
* - An array of species ID strings (e.g., ["quro", "qupe"]).
* - An object where keys are species IDs (e.g., 'quro') and values are ignored.
* - A function
* @param {number} threshold The relative basal area threshold (0..1) for filtering.
* @returns {string} The filter string.
* @method buildRareSpeciesFilter
*/
lib.buildRareSpeciesFilter = function(speciesList, threshold) {
let speciesIds;
// 1. Normalize the speciesList input to an array of species IDs.
if (typeof speciesList === 'string') {
speciesIds = [speciesList]; // Single species ID
lib.dbg(`speciesIds: ${speciesIds}`)
} else if (Array.isArray(speciesList)) {
speciesIds = speciesList; // Array of species IDs
lib.dbg(`speciesIds: ${speciesIds}`)
} else if (typeof speciesList === 'object' && speciesList !== null) {
speciesIds = Object.keys(speciesList); // Object: extract keys
lib.dbg(`speciesIds: ${speciesIds}`)
} else if (typeof speciesList === 'function' && speciesList !== null) {
speciesIds = Object.keys(speciesList.call()); // Object: extract keys
lib.dbg(`speciesIds: ${speciesIds}`)
} else {
// Handle invalid input (optional, but good practice)
throw new Error(`Invalid speciesList format! ${speciesList}, Type: ${typeof speciesList}`);
}
// Loop over each species, if there is a list of unique thresholds for each species in speciesList
if (typeof threshold !== 'number') {
let thresholds;
if (typeof threshold === 'object' && threshold !== null) {
thresholds = Object.values(threshold) // Object: extract values
lib.dbg(`thresholds: ${thresholds}`)
} else if (typeof threshold === 'function' && threshold !== null) {
thresholds = Object.values(threshold.call()); // Object: extract keys
lib.dbg(`thresholds: ${thresholds}`)
} else {
// Handle invalid input (optional, but good practice)
throw new Error(`Invalid threshold format! ${threshold}, Type: ${typeof threshold}`);
}
// check if length of speciesIds and thresholds is equal
if (speciesIds.length !== thresholds.length) {
throw new Error(`buildRareSpeciesFilter: Length of speciesIds and thresholds not equal! speciesIds: ${speciesIds}, thresholds: ${thresholds}`);
}
// save one filter string for each species
const filterParts = [];
// loop over speciesIds
for (let i = 0; i < speciesIds.length; i++) {
// set speciesId and species specific threshold
var speciesId = speciesIds[i];
var threshold = thresholds[i];
const relBA = stand.relSpeciesBasalAreaOf(speciesId);
const threshold2x = 2 * threshold;
let pfilter = 0;
if (relBA <= threshold) {
pfilter = 0;
} else if (relBA >= threshold2x) {
pfilter = 1;
} else {
pfilter = 1 - (threshold2x - relBA) / threshold;
pfilter = Math.max(0, Math.min(1, pfilter));
}
lib.dbg(`buildRareSpeciesFilter: Species: ${speciesId}, rel basal area: ${relBA}, threshold: ${threshold}, pFilter: ${pfilter}`);
// Build per-species condition
const condition = `if(species = ${speciesId}, rnd(0,1) < ${pfilter.toFixed(3)}, true)`;
filterParts.push(condition);
}
// Combine all species conditions
const combinedFilter = filterParts.join(' and ');
lib.log(combinedFilter);
return combinedFilter || 'true'; // if empty, return always true
} else {
// 2. Calculate the total relative basal area.
let totalBasalArea = 0;
for (const speciesId of speciesIds) {
totalBasalArea += stand.relSpeciesBasalAreaOf(speciesId);
}
// 3. Determine the filtering probability (pfilter).
const threshold2x = 2 * threshold;
let pfilter = 0;
if (totalBasalArea <= threshold) {
pfilter = 0; // filter out all trees
} else if (totalBasalArea >= threshold2x) {
pfilter = 1; // keep the trees
} else {
// a linear function between threshold and 2xthreshold
pfilter = 1 - (threshold2x - totalBasalArea) / threshold;
pfilter = Math.max(0, Math.min(1, pfilter));
}
lib.dbg(`buildRareSpeciesFilter: rel basal area of targets: ${totalBasalArea}. pFilter: ${pfilter}`);
// 4. Construct the filter string.
if (pfilter == 1) {
return 'true'; // the filter is inactive
} else {
const speciesCodes = speciesIds.join(', '); // Use the normalized speciesIds array
const filterString = `if( in(species, ${speciesCodes}), rnd(0,1)<${pfilter.toFixed(3)}, true)`;
return filterString;
}
}
}