| "use strict"; |
|
|
| (function exposePredictor(root, factory) { |
| const api = factory(); |
| if (typeof module === "object" && module.exports) module.exports = api; |
| else root.PHBVPredictor = api; |
| })(typeof globalThis !== "undefined" ? globalThis : this, function buildPredictor() { |
| const clip = value => Math.max(0, Math.min(100, value)); |
|
|
| function validateRelationships(record) { |
| const percentages = [1, 2, 3].map(index => { |
| const raw = record[`additive${index}_percentage_wt`]; |
| return raw === null || raw === undefined || raw === "" ? 0 : Number(raw); |
| }); |
| const types = [1, 2, 3].map(index => String(record[`additive_type_${index}`] ?? "not_applicable")); |
| if (String(record.additives ?? "no") === "no" && percentages.some(value => value > 0)) { |
| throw new Error("Additives are marked absent, but an additive percentage is nonzero."); |
| } |
| percentages.forEach((value, index) => { |
| if (value > 0 && types[index] === "not_applicable") { |
| throw new Error(`Additive ${index + 1} has a nonzero percentage but its type is not applicable.`); |
| } |
| }); |
| } |
|
|
| function transform(model, record) { |
| const vector = []; |
| model.numeric_features.forEach((feature, index) => { |
| const raw = record[feature]; |
| const parsed = raw === null || raw === undefined || raw === "" ? NaN : Number(raw); |
| const value = Number.isFinite(parsed) ? parsed : model.numeric_imputer_statistics[index]; |
| const mean = model.numeric_scaler_with_mean ? model.numeric_scaler_mean[index] : 0; |
| vector.push((value - mean) / model.numeric_scaler_scale[index]); |
| }); |
| model.categorical_features.forEach((feature, index) => { |
| const raw = record[feature]; |
| const selected = raw === null || raw === undefined || raw === "" |
| ? String(model.categorical_imputer_statistics[index]) |
| : String(raw); |
| model.encoder_categories[index].forEach(category => { |
| vector.push(selected === String(category) ? 1 : 0); |
| }); |
| }); |
| if (vector.length !== model.num_transformed_features) { |
| throw new Error("The browser preprocessing map is inconsistent with the model."); |
| } |
| return vector; |
| } |
|
|
| function estimate(model, record) { |
| validateRelationships(record); |
| const vector = transform(model, record); |
| let total = 0; |
| model.trees.forEach(tree => { |
| let node = 0; |
| while (tree.left[node] !== -1) { |
| node = vector[tree.feature[node]] <= tree.threshold[node] |
| ? tree.left[node] |
| : tree.right[node]; |
| } |
| total += tree.value[node]; |
| }); |
| return clip(total / model.n_estimators); |
| } |
|
|
| function globalBaseline(model, timeDays) { |
| const parameters = model.global_first_order_reference; |
| if (!parameters) return null; |
| return clip(parameters.asymptote * (1 - Math.exp(-parameters.rate_per_day * Number(timeDays)))); |
| } |
|
|
| function intervalResult(model, prediction) { |
| const represented = Number(model.uncertainty.represented_study_curve_radius); |
| const crossStudy = Number(model.uncertainty.cross_study_stress_radius); |
| return { |
| represented_lower: clip(prediction - represented), |
| represented_upper: clip(prediction + represented), |
| cross_study_lower: clip(prediction - crossStudy), |
| cross_study_upper: clip(prediction + crossStudy), |
| }; |
| } |
|
|
| function predictRecord(model, record) { |
| const prediction = estimate(model, record); |
| return { |
| estimate: prediction, |
| ...intervalResult(model, prediction), |
| global_baseline: globalBaseline(model, record.biodegradation_time_days), |
| }; |
| } |
|
|
| function predictCurve(model, record, options = {}) { |
| const startDay = Number(options.startDay); |
| const endDay = Number(options.endDay); |
| const points = Number(options.points ?? 100); |
| const monotone = options.monotone !== false; |
| if (!Number.isFinite(startDay) || !Number.isFinite(endDay) || endDay <= startDay) { |
| throw new Error("End day must exceed start day."); |
| } |
| if (!Number.isInteger(points) || points < 2 || points > 500) { |
| throw new Error("Curve points must be an integer from 2 to 500."); |
| } |
| const output = { |
| time_days: [], estimate: [], represented_lower: [], represented_upper: [], |
| cross_study_lower: [], cross_study_upper: [], global_baseline: [], |
| }; |
| let runningMaximum = 0; |
| for (let index = 0; index < points; index += 1) { |
| const day = startDay + ((endDay - startDay) * index) / (points - 1); |
| const curveRecord = {...record, biodegradation_time_days: day}; |
| let prediction = estimate(model, curveRecord); |
| if (monotone) { |
| prediction = Math.max(runningMaximum, prediction); |
| runningMaximum = prediction; |
| } |
| const intervals = intervalResult(model, prediction); |
| output.time_days.push(day); |
| output.estimate.push(prediction); |
| output.represented_lower.push(intervals.represented_lower); |
| output.represented_upper.push(intervals.represented_upper); |
| output.cross_study_lower.push(intervals.cross_study_lower); |
| output.cross_study_upper.push(intervals.cross_study_upper); |
| output.global_baseline.push(globalBaseline(model, day)); |
| } |
| return output; |
| } |
|
|
| return {transform, estimate, predictRecord, predictCurve, validateRelationships}; |
| }); |
|
|