"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}; });