From 0d80321b53a60b5cc8256b4aa9a410f022132f4e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 26 Sep 2026 00:18:05 +0000 Subject: [PATCH 1/2] feat: add a browser field sheet for EPEA scores Score six EPI-style tiers, binding energy and cost in the browser with the same formulas as epea.core. Records stay on the device. Co-authored-by: Diaiti Zure --- .github/workflows/ci.yml | 29 +- CHANGELOG.md | 9 + README.md | 17 + field-sheet/app.mjs | 602 +++++++++++++++++++++++ field-sheet/calc.mjs | 402 +++++++++++++++ field-sheet/calc.test.mjs | 213 ++++++++ field-sheet/examples.mjs | 83 ++++ field-sheet/favicon.svg | 5 + field-sheet/index.html | 301 ++++++++++++ field-sheet/manifest.webmanifest | 19 + field-sheet/serve.py | 55 +++ field-sheet/sheet.css | 592 ++++++++++++++++++++++ field-sheet/sw.js | 49 ++ package.json | 9 + tests/test_field_sheet_matches_python.py | 123 +++++ tests/test_field_sheet_server.py | 38 ++ 16 files changed, 2544 insertions(+), 2 deletions(-) create mode 100644 field-sheet/app.mjs create mode 100644 field-sheet/calc.mjs create mode 100644 field-sheet/calc.test.mjs create mode 100644 field-sheet/examples.mjs create mode 100644 field-sheet/favicon.svg create mode 100644 field-sheet/index.html create mode 100644 field-sheet/manifest.webmanifest create mode 100644 field-sheet/serve.py create mode 100644 field-sheet/sheet.css create mode 100644 field-sheet/sw.js create mode 100644 package.json create mode 100644 tests/test_field_sheet_matches_python.py create mode 100644 tests/test_field_sheet_server.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbe1c9b..cba4a6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,32 @@ jobs: run: pip install ruff - name: Check formatting - run: ruff format --check src tests examples + run: ruff format --check src tests examples field-sheet - name: Lint - run: ruff check src tests examples + run: ruff check src tests examples field-sheet + + field-sheet: + name: "field sheet calculator" + runs-on: ubuntu-latest + env: + FIELD_SHEET_JS: "1" + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install package + dev dependencies + run: pip install -e ".[dev]" + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Browser calculator + run: node --test field-sheet/calc.test.mjs + + - name: Match Python scores and serve the sheet + run: pytest --tb=short -q tests/test_field_sheet_matches_python.py tests/test_field_sheet_server.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e74d84..6c9cc43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## [Unreleased] + +### Added +- `field-sheet/`: browser field sheet for the same WSM and MCDA scores as `epea.core`. + Enter six EPI-style tiers, docking LBE and cost per kg. The sheet ranks candidates, + stores records in the browser and imports or exports the tier CSV. +- `python field-sheet/serve.py` serves the sheet at . +- Tests that the browser scores match the Python module, plus a server smoke test. + ## [1.0.0] — 2024-05-30 ### Added diff --git a/README.md b/README.md index 3116712..762b20a 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,22 @@ python -m venv .venv pip install -e ".[dev]" ``` +## Open the calculation sheet + +Open [http://127.0.0.1:43119](http://127.0.0.1:43119). + +From the repository root, start the sheet and then open that address in a browser. + +```bash +python field-sheet/serve.py +``` + +`npm run dev` and `npm start` start the same server. + +The worked example loads the three illustrative rows from `examples/run_demo.py`. Records stay in the browser. No data are uploaded. A blank candidate row is left out of the score. Import a CSV that uses the columns in `examples/sample_chemicals.template.csv`, or export one and pass it to `run_mcda_from_tier_table`. + +This sheet scores candidates. It does not close a pre-harvest mass balance. That calculation lives in [antiviral-mass-balance](https://github.com/planttalks/antiviral-mass-balance). + ## Quick use ```python @@ -74,6 +90,7 @@ python examples/run_demo.py ```bash pytest +node --test field-sheet/calc.test.mjs ``` ## Citation diff --git a/field-sheet/app.mjs b/field-sheet/app.mjs new file mode 100644 index 0000000..28bc517 --- /dev/null +++ b/field-sheet/app.mjs @@ -0,0 +1,602 @@ +import { PROFILE_NAMES, candidatesFromTierCsv, computeSheet, sheetToCsv } from "./calc.mjs"; +import { clearRanking, workedExample } from "./examples.mjs"; + +const STORAGE_KEY = "epea-field-sheet-v1"; +const TIER_OPTIONS = ["safe", "mild", "danger"]; + +const SCALAR_FIELDS = [ + ["scenario", "scenario"], + ["operator", "operator"], + ["date", "date"], + ["notes", "notes"], + ["wE", "wE"], + ["wS", "wS"], + ["wC", "wC"], +]; + +let currentId = null; +let latestInput = null; +let latestResult = null; +let lastErrorSignature = ""; +let clearArmed = false; + +function $(id) { + return document.getElementById(id); +} + +function today() { + return new Date().toISOString().slice(0, 10); +} + +function emptyCandidate(name = "") { + return { + name, + logOH: "safe", + logKoc: "safe", + HLN: "safe", + logBCF: "safe", + logBAF: "safe", + DT50: "safe", + lbe: "", + cost: "", + }; +} + +function emptyInput() { + return { + scenario: "", + operator: "", + date: today(), + notes: "", + weightMode: "equal", + wE: "", + wS: "", + wC: "", + candidates: [emptyCandidate(), emptyCandidate()], + }; +} + +function inputNumber(value) { + if (value === null || value === undefined || value === "") return ""; + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(parsed)) return String(value); + return String(parsed); +} + +function formatScore(value) { + if (!Number.isFinite(value)) return "n/a"; + return value.toFixed(2); +} + +function setStatus(message) { + $("status").textContent = message; +} + +function fillTierSelect(select, value) { + select.replaceChildren(); + for (const tier of TIER_OPTIONS) { + const option = document.createElement("option"); + option.value = tier; + option.textContent = tier; + select.append(option); + } + select.value = TIER_OPTIONS.includes(value) ? value : "safe"; +} + +function applyWeightMode() { + const custom = document.querySelector('input[name="weightMode"]:checked')?.value === "custom"; + $("custom-weights").hidden = !custom; +} + +function syncLegend(node) { + const name = node.querySelector('[data-field="name"]').value.trim(); + node.querySelector(".legend-name").textContent = name || "Candidate"; +} + +function renumberCandidates() { + const nodes = document.querySelectorAll("[data-candidate]"); + nodes.forEach((node, index) => { + node.dataset.index = String(index); + node.querySelectorAll("[data-error-key]").forEach((field) => { + field.dataset.errorId = `cand-${index}-${field.dataset.errorKey}`; + }); + node.querySelector("[data-remove]").disabled = nodes.length === 1; + syncLegend(node); + }); +} + +function createCandidate(values = {}) { + const node = $("candidate-template").content.firstElementChild.cloneNode(true); + const name = node.querySelector('[data-field="name"]'); + name.value = values.name == null ? "" : String(values.name); + for (const key of PROFILE_NAMES) { + fillTierSelect(node.querySelector(`[data-field="${key}"]`), values[key] || "safe"); + } + node.querySelector('[data-field="lbe"]').value = inputNumber(values.lbe); + node.querySelector('[data-field="cost"]').value = inputNumber(values.cost); + syncLegend(node); + return node; +} + +function setCandidates(candidates) { + const host = $("candidates"); + host.replaceChildren(); + const rows = candidates && candidates.length ? candidates : [emptyCandidate()]; + for (const candidate of rows) host.append(createCandidate(candidate)); + renumberCandidates(); +} + +function readCandidate(node) { + const value = (field) => node.querySelector(`[data-field="${field}"]`).value; + const candidate = { name: value("name"), lbe: value("lbe"), cost: value("cost") }; + for (const key of PROFILE_NAMES) candidate[key] = value(key); + return candidate; +} + +function readForm() { + const input = { candidates: [] }; + for (const [id, key] of SCALAR_FIELDS) input[key] = $(id).value; + input.weightMode = document.querySelector('input[name="weightMode"]:checked')?.value || "equal"; + input.candidates = [...document.querySelectorAll("[data-candidate]")].map(readCandidate); + return input; +} + +function fillForm(input) { + for (const [id, key] of SCALAR_FIELDS) { + if (!(key in input)) continue; + const value = input[key]; + $(id).value = typeof value === "number" ? inputNumber(value) : value == null ? "" : String(value); + } + const mode = input.weightMode === "custom" ? "custom" : "equal"; + const radio = document.querySelector(`input[name="weightMode"][value="${mode}"]`); + if (radio) radio.checked = true; + applyWeightMode(); + setCandidates(input.candidates || [emptyCandidate()]); + recompute(); +} + +function markErrors(result) { + document.querySelectorAll("[data-error-id]").forEach((field) => field.removeAttribute("aria-invalid")); + const ids = new Set(result.errors.map((error) => error.id)); + document.querySelectorAll("[data-error-id]").forEach((field) => { + if (ids.has(field.dataset.errorId)) field.setAttribute("aria-invalid", "true"); + }); + const signature = result.errors.map((error) => `${error.id}:${error.message}`).join("|"); + const list = $("result-errors"); + if (signature === lastErrorSignature) return; + lastErrorSignature = signature; + list.replaceChildren(); + for (const error of result.errors) { + const item = document.createElement("li"); + item.textContent = error.message; + list.append(item); + } +} + +function joinWords(items) { + if (items.length <= 1) return items[0] || ""; + if (items.length === 2) return `${items[0]} and ${items[1]}`; + return `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`; +} + +function sheetTitle(input) { + const scenario = String(input.scenario || "").trim(); + if (scenario) return scenario; + const names = (input.candidates || []) + .map((candidate) => String(candidate.name || "").trim()) + .filter(Boolean); + if (names.length === 0) return "Untitled comparison"; + if (names.length <= 3) return names.join(" / "); + return `${names.slice(0, 2).join(" / ")} and ${names.length - 2} more`; +} + +function resultLead(result) { + if (result.errors.length && !result.ranked.length) { + return "Correct the marked fields. Impact totals that can be calculated are still shown."; + } + if (!result.ranked.length) { + return "Enter a binding energy and a cost for at least one candidate."; + } + const top = result.ranked[0].oPercent; + const leaders = result.ranked.filter((row) => row.oPercent === top).map((row) => row.name); + if (result.readyCount === 1) { + return `${leaders[0]} is the only candidate, so each normalized score is 50. Add another candidate to rank them.`; + } + if (leaders.length > 1) { + return `${joinWords(leaders)} share the highest overall score.`; + } + return `${leaders[0]} has the highest overall score.`; +} + +function weightLine(result) { + if (!result.weights) return "Enter three weights that sum to 1."; + const [wE, wS, wC] = result.weights; + if (Math.abs(wE - 1 / 3) < 1e-9 && Math.abs(wS - 1 / 3) < 1e-9) { + return "O = (1/3) Enorm + (1/3) Snorm + (1/3) Cnorm"; + } + return `O = ${formatScore(wE)} Enorm + ${formatScore(wS)} Snorm + ${formatScore(wC)} Cnorm`; +} + +function classLabel(value) { + const span = document.createElement("span"); + span.className = `tier tier-${value || "mild"}`; + span.textContent = value || "n/a"; + return span; +} + +function renderRanking(result) { + const body = $("rank-body"); + body.replaceChildren(); + if (!result.ranked.length) { + const row = document.createElement("tr"); + const cell = document.createElement("td"); + cell.colSpan = 8; + cell.textContent = "No ranking yet."; + row.append(cell); + body.append(row); + return; + } + for (const score of result.ranked) { + const row = document.createElement("tr"); + if (score.rank === 1) row.className = "is-best"; + const cells = [ + String(score.rank), + score.name, + formatScore(score.eiPercent), + null, + formatScore(score.enorm), + formatScore(score.snorm), + formatScore(score.cnorm), + formatScore(score.oPercent), + ]; + cells.forEach((value, index) => { + const cell = document.createElement("td"); + if (index === 3) cell.append(classLabel(score.eiClass)); + else cell.textContent = value; + row.append(cell); + }); + body.append(row); + } +} + +function renderCandidateNotes(result) { + const host = $("result-candidates"); + host.replaceChildren(); + for (const candidate of result.candidates.filter((row) => row.used && row.total !== null)) { + const block = document.createElement("div"); + block.className = "part-result"; + const title = document.createElement("p"); + title.className = "sheet-kicker"; + title.textContent = candidate.name; + const line = document.createElement("p"); + line.textContent = `Total ${candidate.total} of 18. EI ${formatScore(candidate.eiPercent)}%.`; + const detail = document.createElement("p"); + const lbe = candidate.lbe === null ? "binding energy missing" : `LBE ${candidate.lbe}`; + const cost = candidate.cost === null ? "cost missing" : `USD/kg ${candidate.cost}`; + detail.textContent = `${lbe}. ${cost}.`; + block.append(title, line, classLabel(candidate.eiClass), detail); + host.append(block); + } +} + +function notebookText(input, result) { + const lines = [sheetTitle(input)]; + if (input.date) lines.push(input.date); + if (input.operator) lines.push(input.operator); + lines.push(weightLine(result)); + for (const score of result.ranked) { + lines.push( + `${score.rank}. ${score.name}: O ${formatScore(score.oPercent)}%, EI ${formatScore(score.eiPercent)}% (${score.eiClass}), Enorm ${formatScore(score.enorm)}, Snorm ${formatScore(score.snorm)}, Cnorm ${formatScore(score.cnorm)}`, + ); + } + for (const candidate of result.candidates.filter((row) => row.used && row.total !== null && !result.ranked.length)) { + lines.push(`${candidate.name}: total ${candidate.total} of 18, EI ${formatScore(candidate.eiPercent)}% (${candidate.eiClass})`); + } + if (input.notes) lines.push(input.notes); + return lines.join("\n"); +} + +function renderResults(input, result) { + markErrors(result); + $("result-lead").textContent = resultLead(result); + $("weight-hint").textContent = + input.weightMode === "custom" + ? "The three weights must sum to 1. Efficacy, impact and cost use the weights you enter." + : "Efficacy, environmental impact and cost each take one third."; + $("weight-line").textContent = weightLine(result); + renderRanking(result); + renderCandidateNotes(result); + $("notebook").textContent = notebookText(input, result); + const leader = result.ranked[0]; + $("bar-best").textContent = leader ? leader.name : "n/a"; + $("bar-o").textContent = leader ? `${formatScore(leader.oPercent)}%` : "n/a"; + $("bar-class").textContent = leader ? leader.eiClass : "n/a"; +} + +function recompute() { + renumberCandidates(); + latestInput = readForm(); + latestResult = computeSheet(latestInput); + renderResults(latestInput, latestResult); +} + +function loadJournal() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? parsed : []; + } catch { + return null; + } +} + +function writeJournal(records) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(records)); +} + +function refreshCounts() { + const records = loadJournal(); + const text = + records === null + ? "Local storage is blocked in this browser." + : records.length === 0 + ? "No records are stored in this browser." + : records.length === 1 + ? "1 record is stored in this browser." + : `${records.length} records are stored in this browser.`; + $("browser-count").textContent = text; + if ($("journal-count")) $("journal-count").textContent = text; +} + +function renderJournal() { + const records = loadJournal(); + const list = $("journal-list"); + const empty = $("journal-empty"); + list.replaceChildren(); + refreshCounts(); + if (records === null) { + empty.hidden = false; + empty.textContent = "Local storage is blocked in this browser. The calculation still runs."; + return; + } + empty.hidden = records.length > 0; + empty.textContent = "No records are stored in this browser. Save a record from the calculation sheet."; + for (const record of records) { + const item = document.createElement("li"); + const open = document.createElement("button"); + open.type = "button"; + open.className = "journal-open"; + open.textContent = record.title || "Untitled comparison"; + open.addEventListener("click", () => openRecord(record)); + const when = document.createElement("p"); + when.className = "journal-when"; + const parsed = new Date(record.savedAt); + when.textContent = Number.isNaN(parsed.getTime()) + ? "" + : parsed.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "secondary"; + remove.textContent = "Delete record"; + remove.addEventListener("click", () => deleteRecord(record.id, remove)); + item.append(open, when, remove); + list.append(item); + } +} + +function openRecord(record) { + currentId = record.id; + fillForm(record.input); + setStatus("Record opened."); + location.hash = "#sheet"; +} + +function deleteRecord(id, button) { + if (button.dataset.armed !== "yes") { + button.dataset.armed = "yes"; + button.textContent = "Confirm deletion"; + window.setTimeout(() => { + if (!button.isConnected) return; + button.dataset.armed = "no"; + button.textContent = "Delete record"; + }, 4000); + return; + } + const records = loadJournal(); + if (records === null) return; + writeJournal(records.filter((record) => record.id !== id)); + if (currentId === id) currentId = null; + renderJournal(); +} + +function saveSheet() { + const records = loadJournal(); + if (records === null) { + setStatus("Local storage is blocked. The calculation still runs. Export the record to keep a copy."); + return; + } + const input = readForm(); + const id = currentId || crypto.randomUUID(); + currentId = id; + const record = { + id, + savedAt: new Date().toISOString(), + title: sheetTitle(input), + input, + }; + writeJournal([record, ...records.filter((item) => item.id !== id)]); + refreshCounts(); + setStatus(`Record saved in this browser. ${sheetTitle(input)}.`); +} + +function plainResult(result) { + if (!result) return null; + return { + weights: result.weights, + ranked: result.ranked, + candidates: result.candidates.filter((row) => row.used), + errors: result.errors, + }; +} + +function fileStamp() { + return ($("date").value || today()).replaceAll("-", ""); +} + +function download(filename, text, type) { + const blob = new Blob([text], { type }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1500); +} + +function exportJson() { + recompute(); + download( + `epea-${fileStamp()}.json`, + JSON.stringify({ input: latestInput, result: plainResult(latestResult) }, null, 2), + "application/json", + ); +} + +function exportCsv() { + recompute(); + const used = latestResult.candidates.filter((row) => row.used); + download(`epea-${fileStamp()}.csv`, sheetToCsv(used, latestResult.ranked), "text/csv"); +} + +async function copyNotebook() { + const text = $("notebook").textContent; + try { + await navigator.clipboard.writeText(text); + setStatus("Results copied."); + } catch { + setStatus("Select the result text and copy it."); + } +} + +function loadExample(example) { + currentId = null; + clearArmed = false; + $("clear-sheet").textContent = "Clear form"; + fillForm({ ...emptyInput(), ...example.input, date: today() }); + setStatus(example.note); +} + +function clearSheet() { + const button = $("clear-sheet"); + if (!clearArmed) { + clearArmed = true; + button.textContent = "Confirm clear form"; + window.setTimeout(() => { + clearArmed = false; + button.textContent = "Clear form"; + }, 4000); + return; + } + clearArmed = false; + button.textContent = "Clear form"; + currentId = null; + fillForm(emptyInput()); + setStatus("Form cleared."); +} + +async function importCsv(file) { + const text = await file.text(); + const parsed = candidatesFromTierCsv(text); + if (parsed.errors.length) { + setStatus(parsed.errors[0]); + return; + } + currentId = null; + const input = readForm(); + input.candidates = parsed.candidates; + fillForm(input); + const count = parsed.candidates.length; + setStatus(count === 1 ? "Imported 1 candidate." : `Imported ${count} candidates.`); +} + +function showView(name) { + for (const view of ["sheet", "journal", "limits"]) { + $(`view-${view}`).hidden = view !== name; + } + document.querySelectorAll("nav a").forEach((link) => { + link.setAttribute("aria-current", link.dataset.view === name ? "page" : "false"); + }); + const bar = $("live-bar"); + if (name === "sheet") bar.removeAttribute("hidden"); + else bar.setAttribute("hidden", ""); + if (name === "journal") renderJournal(); +} + +function route(moveFocus) { + const name = location.hash.replace("#", ""); + const view = name === "journal" || name === "limits" ? name : "sheet"; + showView(view); + if (moveFocus) document.querySelector(`#view-${view} h1`)?.focus(); +} + +function renderNet() { + $("net-status").textContent = navigator.onLine + ? "Scores are calculated in this browser. A network connection is not required after the first visit." + : "The browser is offline. The calculation sheet and stored records remain available."; +} + +function bind() { + const form = $("sheet-form"); + form.addEventListener("submit", (event) => event.preventDefault()); + form.addEventListener("input", (event) => { + const node = event.target.closest?.("[data-candidate]"); + if (node && event.target.matches?.('[data-field="name"]')) syncLegend(node); + recompute(); + }); + form.addEventListener("change", (event) => { + if (event.target.name === "weightMode") applyWeightMode(); + recompute(); + }); + $("add-candidate").addEventListener("click", () => { + $("candidates").append(createCandidate(emptyCandidate())); + renumberCandidates(); + recompute(); + }); + $("candidates").addEventListener("click", (event) => { + const button = event.target.closest?.("[data-remove]"); + if (!button || button.disabled) return; + button.closest("[data-candidate]")?.remove(); + renumberCandidates(); + recompute(); + }); + $("load-demo").addEventListener("click", () => loadExample(workedExample())); + $("load-clear").addEventListener("click", () => loadExample(clearRanking())); + $("clear-sheet").addEventListener("click", clearSheet); + $("save-sheet").addEventListener("click", saveSheet); + $("export-json").addEventListener("click", exportJson); + $("export-csv").addEventListener("click", exportCsv); + $("copy-notebook").addEventListener("click", () => { + void copyNotebook(); + }); + $("print-sheet").addEventListener("click", () => window.print()); + $("import-csv").addEventListener("change", (event) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) void importCsv(file); + }); + window.addEventListener("hashchange", () => route(true)); + window.addEventListener("online", renderNet); + window.addEventListener("offline", renderNet); +} + +bind(); +fillForm(emptyInput()); +refreshCounts(); +renderNet(); +route(false); + +if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("./sw.js").catch(() => {}); +} diff --git a/field-sheet/calc.mjs b/field-sheet/calc.mjs new file mode 100644 index 0000000..adfe189 --- /dev/null +++ b/field-sheet/calc.mjs @@ -0,0 +1,402 @@ +/** + * EPEA scores for the browser sheet. + * Keep this file in step with src/epea/core.py. + * + * Zure, Sung, Rahim and Kuo, Int. J. Mol. Sci. 2024, 25, 6009. + * https://doi.org/10.3390/ijms25116009 + */ + +export const N_PROFILES = 6; +export const MAX_TIER_SCORE = 3; +export const MAX_TOTAL_PROFILE_SCORE = N_PROFILES * MAX_TIER_SCORE; + +export const PROFILE_NAMES = ["logOH", "logKoc", "HLN", "logBCF", "logBAF", "DT50"]; + +export const PROFILES = [ + { key: "logOH", label: "Atmospheric hydroxylation", short: "log OH" }, + { key: "logKoc", label: "Soil adsorption", short: "log Koc" }, + { key: "HLN", label: "Fish biotransformation half-life", short: "HLN" }, + { key: "logBCF", label: "Bioconcentration factor", short: "log BCF" }, + { key: "logBAF", label: "Bioaccumulation factor", short: "log BAF" }, + { key: "DT50", label: "Biodegradation half-life", short: "DT50" }, +]; + +const TIERS = new Set(["safe", "mild", "danger"]); + +export function tierToScore(tier) { + if (tier === "safe") return 1; + if (tier === "mild") return 2; + if (tier === "danger") return 3; + throw new Error(`Unknown tier: ${tier}`); +} + +export function profileScoreFromTiers(tiers) { + let values; + if (Array.isArray(tiers)) { + if (tiers.length !== N_PROFILES) { + throw new Error(`Expected ${N_PROFILES} tier values, got ${tiers.length}`); + } + values = tiers; + } else { + values = PROFILE_NAMES.map((key) => tiers[key]); + } + return values.reduce((sum, tier) => sum + tierToScore(tier), 0); +} + +export function environmentalImpactFraction(totalProfileScore) { + return totalProfileScore / MAX_TOTAL_PROFILE_SCORE; +} + +export function classifyEnvironmentalImpactTotalScore(totalProfileScore) { + if (totalProfileScore < 6 || totalProfileScore > MAX_TOTAL_PROFILE_SCORE) { + throw new Error(`total_profile_score must be in 6..${MAX_TOTAL_PROFILE_SCORE}`); + } + if (totalProfileScore <= 6) return "safe"; + if (totalProfileScore <= 11) return "mild"; + return "danger"; +} + +export function classifyEnvironmentalImpactPct(eiPercent) { + let total = Math.round((eiPercent / 100) * MAX_TOTAL_PROFILE_SCORE); + total = Math.max(6, Math.min(MAX_TOTAL_PROFILE_SCORE, total)); + return classifyEnvironmentalImpactTotalScore(total); +} + +function assertFiniteVector(values, label) { + if (!Array.isArray(values) || values.length === 0) { + throw new Error(`${label} must not be empty`); + } + if (values.some((value) => typeof value !== "number" || !Number.isFinite(value))) { + throw new Error(`${label} must be finite numbers`); + } +} + +function minmax01(values) { + const lo = Math.min(...values); + const hi = Math.max(...values); + if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi === lo) { + return values.map(() => 0.5); + } + return values.map((value) => (value - lo) / (hi - lo)); +} + +export function enormFromLbe(raw) { + const values = Array.from(raw, Number); + assertFiniteVector(values, "LBE"); + const lo = Math.min(...values); + const hi = Math.max(...values); + if (hi === lo) return values.map(() => 50); + return values.map((value) => ((hi - value) / (hi - lo)) * 100); +} + +export function snormFromImpactPct(raw) { + const values = Array.from(raw, Number); + assertFiniteVector(values, "EI percent"); + return minmax01(values).map((value) => 100 - value * 100); +} + +export function cnormFromCost(raw) { + const values = Array.from(raw, Number); + assertFiniteVector(values, "cost"); + return minmax01(values).map((value) => 100 - value * 100); +} + +export function mcdaOverallScores(lbe, eiPercent, costPerKg, weights = [1 / 3, 1 / 3, 1 / 3], names = null) { + const E = Array.from(lbe, Number); + const S = Array.from(eiPercent, Number); + const C = Array.from(costPerKg, Number); + if (S.length !== E.length || C.length !== E.length) { + throw new Error("E, S, and C must have the same length"); + } + const [wE, wS, wC] = weights; + if (![wE, wS, wC].every((value) => typeof value === "number" && Number.isFinite(value))) { + throw new Error("weights must be finite numbers"); + } + if (Math.abs(wE + wS + wC - 1) > 1e-6) { + throw new Error("weights must sum to 1"); + } + const en = enormFromLbe(E); + const sn = snormFromImpactPct(S); + const cn = cnormFromCost(C); + return E.map((value, index) => ({ + name: names ? String(names[index]) : `candidate_${index}`, + lbe: value, + eiPercent: S[index], + costPerKg: C[index], + enorm: en[index], + snorm: sn[index], + cnorm: cn[index], + oPercent: wE * en[index] + wS * sn[index] + wC * cn[index], + })); +} + +export function readNumber(value) { + if (value === null || value === undefined) return null; + if (typeof value === "number") return Number.isFinite(value) ? value : Number.NaN; + const text = String(value).trim(); + if (text === "") return null; + const parsed = Number(text); + return Number.isFinite(parsed) ? parsed : Number.NaN; +} + +function invalid(errors, id, message) { + errors.push({ id, message }); +} + +function requireFinite(errors, id, value, label) { + const parsed = readNumber(value); + if (parsed === null) return null; + if (Number.isNaN(parsed)) { + invalid(errors, id, `${label} must be a number.`); + return null; + } + return parsed; +} + +function normalizeTier(value) { + return String(value ?? "") + .trim() + .toLowerCase(); +} + +function candidateTouched(candidate) { + const name = String(candidate.name || "").trim(); + return name !== "" || readNumber(candidate.lbe) !== null || readNumber(candidate.cost) !== null; +} + +function resolveCandidate(candidate, index, errors) { + const id = `cand-${index}`; + const name = String(candidate.name || "").trim() || `Candidate ${index + 1}`; + const tiers = {}; + for (const key of PROFILE_NAMES) { + tiers[key] = normalizeTier(candidate[key] || "safe"); + } + const row = { + index, + name, + used: false, + tiers, + total: null, + eiPercent: null, + eiClass: null, + lbe: null, + cost: null, + }; + if (!candidateTouched(candidate)) return row; + + row.used = true; + for (const key of PROFILE_NAMES) { + if (!TIERS.has(tiers[key])) { + invalid(errors, `${id}-${key}`, `${name} has an unknown tier for ${key}.`); + } + } + const tiersOk = PROFILE_NAMES.every((key) => TIERS.has(tiers[key])); + if (tiersOk) { + row.total = profileScoreFromTiers(tiers); + row.eiPercent = environmentalImpactFraction(row.total) * 100; + row.eiClass = classifyEnvironmentalImpactTotalScore(row.total); + } + + row.lbe = requireFinite(errors, `${id}-lbe`, candidate.lbe, `${name} binding energy`); + row.cost = requireFinite(errors, `${id}-cost`, candidate.cost, `${name} cost`); + if (row.lbe === null && readNumber(candidate.lbe) === null) { + invalid(errors, `${id}-lbe`, `${name} needs a binding energy.`); + } + if (row.cost === null && readNumber(candidate.cost) === null) { + invalid(errors, `${id}-cost`, `${name} needs a cost per kg.`); + } + return row; +} + +function requirePresentNumber(errors, id, value, label) { + const parsed = requireFinite(errors, id, value, label); + if (parsed === null && readNumber(value) === null) { + invalid(errors, id, `${label} is required.`); + } + return parsed; +} + +function resolveWeights(input, errors) { + if (input.weightMode !== "custom") { + return [1 / 3, 1 / 3, 1 / 3]; + } + const wE = requirePresentNumber(errors, "wE", input.wE, "Efficacy weight"); + const wS = requirePresentNumber(errors, "wS", input.wS, "Environmental weight"); + const wC = requirePresentNumber(errors, "wC", input.wC, "Cost weight"); + if (wE === null || wS === null || wC === null) return null; + const sum = wE + wS + wC; + if (Math.abs(sum - 1) > 1e-6) { + invalid(errors, "wE", `Weights sum to ${sum}. They must sum to 1.`); + return null; + } + return [wE, wS, wC]; +} + +export function computeSheet(input) { + const errors = []; + const weights = resolveWeights(input, errors); + const candidates = (input.candidates || []).map((candidate, index) => + resolveCandidate(candidate, index, errors), + ); + const used = candidates.filter((candidate) => candidate.used); + const complete = used.filter( + (candidate) => + candidate.total !== null && candidate.lbe !== null && candidate.cost !== null, + ); + const blocked = errors.some((error) => error.id.startsWith("cand-") || error.id.startsWith("w")); + let scores = null; + if (weights && complete.length > 0 && complete.length === used.length && !blocked) { + scores = mcdaOverallScores( + complete.map((candidate) => candidate.lbe), + complete.map((candidate) => candidate.eiPercent), + complete.map((candidate) => candidate.cost), + weights, + complete.map((candidate) => candidate.name), + ).map((row, index) => ({ + ...row, + index: complete[index].index, + total: complete[index].total, + eiClass: complete[index].eiClass, + })); + } + + const ranked = scores + ? [...scores].sort((a, b) => b.oPercent - a.oPercent || a.index - b.index) + : []; + ranked.forEach((row, index) => { + row.rank = index + 1; + }); + + return { + errors, + weights, + candidates, + scores, + ranked, + readyCount: complete.length, + }; +} + +export function parseCsv(text) { + const rows = []; + let row = []; + let cell = ""; + let quoted = false; + const src = String(text).replace(/^\uFEFF/, ""); + for (let i = 0; i < src.length; i += 1) { + const ch = src[i]; + if (quoted) { + if (ch === '"') { + if (src[i + 1] === '"') { + cell += '"'; + i += 1; + } else { + quoted = false; + } + } else { + cell += ch; + } + continue; + } + if (ch === '"') { + quoted = true; + } else if (ch === ",") { + row.push(cell); + cell = ""; + } else if (ch === "\n" || ch === "\r") { + if (ch === "\r" && src[i + 1] === "\n") i += 1; + row.push(cell); + rows.push(row); + row = []; + cell = ""; + } else { + cell += ch; + } + } + if (cell.length > 0 || row.length > 0) { + row.push(cell); + rows.push(row); + } + return rows; +} + +const CSV_COLUMNS = ["name", ...PROFILE_NAMES, "LBE", "USD_per_kg"]; + +export function candidatesFromTierCsv(text) { + const errors = []; + const table = parseCsv(text).filter((row) => { + const first = String(row[0] ?? "").trim(); + return row.some((cell) => String(cell).trim() !== "") && !first.startsWith("#"); + }); + if (table.length === 0) { + return { candidates: [], errors: ["The CSV has no rows."] }; + } + const header = table[0].map((cell) => String(cell).trim()); + const missing = CSV_COLUMNS.filter((column) => !header.includes(column)); + if (missing.length) { + return { candidates: [], errors: [`Missing columns: ${missing.join(", ")}`] }; + } + const index = Object.fromEntries(header.map((column, position) => [column, position])); + const candidates = []; + for (const raw of table.slice(1)) { + const cell = (column) => String(raw[index[column]] ?? "").trim(); + const name = cell("name"); + if (!name) { + errors.push("A row is missing a name."); + continue; + } + const candidate = { name, lbe: cell("LBE"), cost: cell("USD_per_kg") }; + for (const key of PROFILE_NAMES) { + const tier = cell(key).toLowerCase(); + candidate[key] = tier; + if (!TIERS.has(tier)) { + errors.push(`Invalid tier '${tier}' in row '${name}'.`); + } + } + candidates.push(candidate); + } + if (errors.length) return { candidates: [], errors }; + if (candidates.length === 0) return { candidates: [], errors: ["The CSV has no candidates."] }; + return { candidates, errors }; +} + +export function sheetToCsv(candidates, scores) { + const byIndex = new Map((scores || []).map((row) => [row.index, row])); + const header = [ + ...CSV_COLUMNS, + "total_score", + "EI_percent", + "EI_class", + "Enorm", + "Snorm", + "Cnorm", + "O_percent", + "rank", + ]; + const lines = [header.join(",")]; + for (const candidate of candidates) { + const score = byIndex.get(candidate.index); + const cells = [ + candidate.name, + ...PROFILE_NAMES.map((key) => candidate.tiers[key]), + candidate.lbe ?? "", + candidate.cost ?? "", + candidate.total ?? "", + candidate.eiPercent ?? "", + candidate.eiClass ?? "", + score ? score.enorm : "", + score ? score.snorm : "", + score ? score.cnorm : "", + score ? score.oPercent : "", + score ? score.rank : "", + ]; + lines.push(cells.map(csvCell).join(",")); + } + return `${lines.join("\n")}\n`; +} + +function csvCell(value) { + const text = value == null ? "" : String(value); + if (/[",\n]/.test(text)) return `"${text.replaceAll('"', '""')}"`; + return text; +} diff --git a/field-sheet/calc.test.mjs b/field-sheet/calc.test.mjs new file mode 100644 index 0000000..292e4ea --- /dev/null +++ b/field-sheet/calc.test.mjs @@ -0,0 +1,213 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import * as epea from "./calc.mjs"; +import { clearRanking, workedExample } from "./examples.mjs"; + +function close(actual, expected) { + assert.ok(Math.abs(actual - expected) < 1e-9, `${actual} vs ${expected}`); +} + +test("tier scores", () => { + assert.equal(epea.tierToScore("safe"), 1); + assert.equal(epea.tierToScore("mild"), 2); + assert.equal(epea.tierToScore("danger"), 3); + assert.throws(() => epea.tierToScore("unknown"), /Unknown tier/); +}); + +test("profile totals", () => { + assert.equal(epea.profileScoreFromTiers(Array(6).fill("safe")), 6); + assert.equal(epea.profileScoreFromTiers(Array(6).fill("danger")), 18); + assert.equal( + epea.profileScoreFromTiers(["safe", "mild", "safe", "mild", "safe", "mild"]), + 9, + ); + assert.equal( + epea.profileScoreFromTiers({ + logOH: "safe", + logKoc: "mild", + HLN: "danger", + logBCF: "safe", + logBAF: "safe", + DT50: "mild", + }), + 10, + ); + assert.throws(() => epea.profileScoreFromTiers(["safe", "mild"]), /Expected 6/); +}); + +test("environmental impact bands", () => { + assert.ok(Math.abs(epea.environmentalImpactFraction(6) - 6 / 18) < 1e-12); + assert.equal(epea.environmentalImpactFraction(18), 1); + assert.equal(epea.classifyEnvironmentalImpactTotalScore(6), "safe"); + assert.equal(epea.classifyEnvironmentalImpactTotalScore(7), "mild"); + assert.equal(epea.classifyEnvironmentalImpactTotalScore(11), "mild"); + assert.equal(epea.classifyEnvironmentalImpactTotalScore(12), "danger"); + assert.equal(epea.classifyEnvironmentalImpactTotalScore(18), "danger"); + assert.equal(epea.classifyEnvironmentalImpactPct((6 / 18) * 100), "safe"); + assert.equal(epea.classifyEnvironmentalImpactPct(100), "danger"); + assert.throws(() => epea.classifyEnvironmentalImpactTotalScore(5), /6\.\.18/); +}); + +test("normalization gives the better value 100", () => { + const enorm = epea.enormFromLbe([-10, -8, -6]); + assert.deepEqual(enorm, [100, 50, 0]); + assert.deepEqual(epea.enormFromLbe([-7, -7, -7]), [50, 50, 50]); + assert.deepEqual(epea.snormFromImpactPct([0, 50, 100]), [100, 50, 0]); + assert.deepEqual(epea.cnormFromCost([0, 50, 100]), [100, 50, 0]); +}); + +test("overall score rejects bad weights and unequal lengths", () => { + assert.throws( + () => epea.mcdaOverallScores([-7], [50], [20], [0.5, 0.5, 0.5]), + /weights must sum to 1/, + ); + assert.throws(() => epea.mcdaOverallScores([-7, -8], [50], [20]), /same length/); +}); + +test("custom efficacy weight prefers the more negative binding energy", () => { + const rows = epea.mcdaOverallScores([-10, -6], [50, 50], [50, 50], [0.8, 0.1, 0.1], [ + "best_efficacy", + "worst_efficacy", + ]); + const best = rows.find((row) => row.name === "best_efficacy"); + const worst = rows.find((row) => row.name === "worst_efficacy"); + assert.ok(best.oPercent > worst.oPercent); + close(best.oPercent, 90); + close(worst.oPercent, 10); +}); + +test("a perfect candidate scores 100 and the opposite scores 0", () => { + const rows = epea.mcdaOverallScores([-10, -5], [0, 100], [1, 1000], undefined, [ + "perfect", + "worst", + ]); + close(rows[0].oPercent, 100); + close(rows[1].oPercent, 0); +}); + +test("worked example ranks Example_A first", () => { + const sheet = epea.computeSheet(workedExample().input); + assert.equal(sheet.errors.length, 0); + assert.equal(sheet.candidates[0].total, 9); + assert.equal(sheet.candidates[0].eiClass, "mild"); + assert.equal(sheet.candidates[1].total, 10); + assert.equal(sheet.candidates[2].total, 12); + assert.equal(sheet.candidates[2].eiClass, "danger"); + assert.equal(sheet.ranked[0].name, "Example_A"); + assert.equal(sheet.ranked[1].name, "Example_B"); + assert.equal(sheet.ranked[2].name, "Example_C"); + close(sheet.ranked[0].enorm, 43.75); + close(sheet.ranked[1].enorm, 100); + close(sheet.ranked[2].enorm, 0); + close(sheet.ranked[0].snorm, 100); + close(sheet.ranked[2].cnorm, 100); + assert.ok(sheet.ranked[0].oPercent > sheet.ranked[1].oPercent); +}); + +test("clear ranking puts the low-impact candidate at 100", () => { + const sheet = epea.computeSheet(clearRanking().input); + assert.equal(sheet.errors.length, 0); + assert.equal(sheet.ranked[0].name, "Low_impact"); + close(sheet.ranked[0].oPercent, 100); + assert.equal(sheet.ranked[0].eiClass, "safe"); + close(sheet.ranked[1].oPercent, 0); + assert.equal(sheet.ranked[1].eiClass, "danger"); +}); + +test("one candidate still reports impact and a tied score of 50", () => { + const sheet = epea.computeSheet({ + weightMode: "equal", + candidates: [ + { + name: "Only", + logOH: "safe", + logKoc: "safe", + HLN: "safe", + logBCF: "safe", + logBAF: "safe", + DT50: "safe", + lbe: -8, + cost: 10, + }, + ], + }); + assert.equal(sheet.errors.length, 0); + assert.equal(sheet.candidates[0].total, 6); + assert.equal(sheet.candidates[0].eiClass, "safe"); + close(sheet.ranked[0].enorm, 50); + close(sheet.ranked[0].snorm, 50); + close(sheet.ranked[0].cnorm, 50); + close(sheet.ranked[0].oPercent, 50); +}); + +test("a blank row is ignored and a half-filled row blocks the ranking", () => { + const sheet = epea.computeSheet({ + weightMode: "equal", + candidates: [ + { + name: "Ready", + logOH: "safe", + logKoc: "mild", + HLN: "safe", + logBCF: "mild", + logBAF: "safe", + DT50: "mild", + lbe: -8, + cost: 12, + }, + { name: "", logOH: "safe", logKoc: "safe", HLN: "safe", logBCF: "safe", logBAF: "safe", DT50: "safe" }, + { name: "Missing cost", lbe: -7 }, + ], + }); + assert.equal(sheet.scores, null); + assert.equal(sheet.candidates[0].used, true); + assert.equal(sheet.candidates[1].used, false); + assert.ok(sheet.errors.some((error) => error.id === "cand-2-cost")); + assert.equal(sheet.candidates[0].eiPercent, 50); +}); + +test("custom weights must sum to 1", () => { + const sheet = epea.computeSheet({ + weightMode: "custom", + wE: 0.5, + wS: 0.5, + wC: 0.5, + candidates: [{ name: "A", lbe: -8, cost: 10 }], + }); + assert.equal(sheet.scores, null); + assert.ok(sheet.errors.some((error) => error.id === "wE")); +}); + +test("tier csv round trip keeps the worked example", () => { + const sheet = epea.computeSheet(workedExample().input); + const csv = epea.sheetToCsv( + sheet.candidates.filter((row) => row.used), + sheet.ranked, + ); + const parsed = epea.candidatesFromTierCsv(csv); + assert.deepEqual(parsed.errors, []); + assert.equal(parsed.candidates.length, 3); + assert.equal(parsed.candidates[0].name, "Example_A"); + assert.equal(parsed.candidates[0].logOH, "safe"); + assert.equal(Number(parsed.candidates[1].lbe), -8.1); + assert.equal(Number(parsed.candidates[2].cost), 8); +}); + +test("tier csv reports a bad tier and a missing column", () => { + const bad = epea.candidatesFromTierCsv( + "name,logOH,logKoc,HLN,logBCF,logBAF,DT50,LBE,USD_per_kg\nX,safe,mild,safe,mild,safe,unknown,-7,10\n", + ); + assert.equal(bad.candidates.length, 0); + assert.match(bad.errors[0], /Invalid tier/); + const missing = epea.candidatesFromTierCsv("name,logOH\nX,safe\n"); + assert.match(missing.errors[0], /Missing columns/); +}); + +test("comment lines and quoted names are accepted", () => { + const parsed = epea.candidatesFromTierCsv( + '# note\nname,logOH,logKoc,HLN,logBCF,logBAF,DT50,LBE,USD_per_kg\n"Compound, A",safe,safe,safe,safe,safe,safe,-8,12\n', + ); + assert.deepEqual(parsed.errors, []); + assert.equal(parsed.candidates[0].name, "Compound, A"); +}); diff --git a/field-sheet/examples.mjs b/field-sheet/examples.mjs new file mode 100644 index 0000000..8c64458 --- /dev/null +++ b/field-sheet/examples.mjs @@ -0,0 +1,83 @@ +/** Illustrative rows. They follow examples/run_demo.py and are not published table values. */ + +export function workedExample() { + return { + note: "Illustrative comparison from the demo script. Replace the tiers, binding energies and prices with your own.", + input: { + scenario: "Demo comparison", + notes: + "Illustrative rows from examples/run_demo.py. These numbers are not a published table.", + weightMode: "equal", + candidates: [ + { + name: "Example_A", + logOH: "safe", + logKoc: "mild", + HLN: "safe", + logBCF: "mild", + logBAF: "safe", + DT50: "mild", + lbe: -7.2, + cost: 12, + }, + { + name: "Example_B", + logOH: "mild", + logKoc: "mild", + HLN: "mild", + logBCF: "safe", + logBAF: "mild", + DT50: "safe", + lbe: -8.1, + cost: 45, + }, + { + name: "Example_C", + logOH: "danger", + logKoc: "mild", + HLN: "mild", + logBCF: "mild", + logBAF: "mild", + DT50: "safe", + lbe: -6.5, + cost: 8, + }, + ], + }, + }; +} + +export function clearRanking() { + return { + note: "Two candidates at the ends of the scale. The low-impact candidate scores 100. The high-impact candidate scores 0.", + input: { + scenario: "Clear ranking", + notes: "Constructed pair. All-safe and cheap against all-danger and costly.", + weightMode: "equal", + candidates: [ + { + name: "Low_impact", + logOH: "safe", + logKoc: "safe", + HLN: "safe", + logBCF: "safe", + logBAF: "safe", + DT50: "safe", + lbe: -10, + cost: 5, + }, + { + name: "High_impact", + logOH: "danger", + logKoc: "danger", + HLN: "danger", + logBCF: "danger", + logBAF: "danger", + DT50: "danger", + lbe: -5, + cost: 100, + }, + ], + }, + }; +} diff --git a/field-sheet/favicon.svg b/field-sheet/favicon.svg new file mode 100644 index 0000000..e206738 --- /dev/null +++ b/field-sheet/favicon.svg @@ -0,0 +1,5 @@ + + + + E + diff --git a/field-sheet/index.html b/field-sheet/index.html new file mode 100644 index 0000000..079d340 --- /dev/null +++ b/field-sheet/index.html @@ -0,0 +1,301 @@ + + + + + + EPEA field sheet + + + + + + + + + + + + + + + +
+
+
+
+

Tiers, binding energy and cost

+

Eco-pharmaco-economic analysis

+

+ Enter each disinfectant (or similar candidate) with six EPI-style tiers, a docking + binding energy and a price. This sheet calculates environmental impact and the + overall score. +

+
+ + + +
+

+
+ +
+ +
+
+
+

Record

+
+ + + +
+ +
+ +
+

Weights

+
+ + +
+ +

Efficacy, environmental impact and cost each take one third.

+
+ +
+
+

Candidates

+ +
+

+ A row with an empty name, binding energy and cost is skipped. The overall score + compares the candidates in this record. One candidate still gets an impact class. + Its normalized scores sit at 50 because there is nothing to rank against. +

+
+
+ +
+ + + + + + +
+
+ + +
+
+ + + + +
+ +
+ Best n/a + O n/a + Class n/a +
+ + + + + + diff --git a/field-sheet/manifest.webmanifest b/field-sheet/manifest.webmanifest new file mode 100644 index 0000000..dd5cdb9 --- /dev/null +++ b/field-sheet/manifest.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "EPEA field sheet", + "short_name": "EPEA", + "description": "Field sheet for eco-pharmaco-economic analysis. Six EPI-style tiers, binding energy and cost are scored in the browser.", + "start_url": "./", + "scope": "./", + "display": "standalone", + "background_color": "#efe6d2", + "theme_color": "#efe6d2", + "lang": "en", + "icons": [ + { + "src": "./favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/field-sheet/serve.py b/field-sheet/serve.py new file mode 100644 index 0000000..e4c659f --- /dev/null +++ b/field-sheet/serve.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Serve the EPEA field sheet. + +From the repository root: + + python field-sheet/serve.py + +Then open http://127.0.0.1:43119 +""" + +from __future__ import annotations + +import functools +import http.server +import os +import socketserver +from pathlib import Path + +HOST = "0.0.0.0" +PORT = 43119 +DIRECTORY = Path(__file__).resolve().parent + + +class SheetServer(socketserver.TCPServer): + allow_reuse_address = True + + +def make_server(host: str = HOST, port: int = PORT) -> SheetServer: + handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(DIRECTORY)) + return SheetServer((host, port), handler) + + +def main() -> None: + port = int(os.environ.get("EPEA_SHEET_PORT", PORT)) + try: + server = make_server(HOST, port) + except OSError as exc: + message = ( + f"Port {port} is already in use. " + f"Open http://127.0.0.1:{port} if the sheet is already running." + ) + raise SystemExit(message) from exc + url = f"http://127.0.0.1:{server.server_address[1]}" + print("EPEA field sheet") + print(f"Open {url}") + try: + server.serve_forever() + except KeyboardInterrupt: + print() + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/field-sheet/sheet.css b/field-sheet/sheet.css new file mode 100644 index 0000000..057e3a3 --- /dev/null +++ b/field-sheet/sheet.css @@ -0,0 +1,592 @@ +:root { + --background: oklch(0.945 0.02 92); + --foreground: oklch(0.19 0.022 70); + --card: oklch(0.975 0.012 95); + --primary: oklch(0.27 0.04 250); + --primary-foreground: oklch(0.97 0.012 95); + --muted: oklch(0.32 0.025 70); + --destructive: oklch(0.4 0.15 27); + --font-sans: "Atkinson Hyperlegible", "Segoe UI", sans-serif; + --font-serif: "Source Serif 4", Georgia, "Iowan Old Style", serif; + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: oklch(0.18 0.015 75); + --foreground: oklch(0.94 0.015 92); + --card: oklch(0.22 0.015 75); + --primary: oklch(0.9 0.02 92); + --primary-foreground: oklch(0.2 0.02 75); + --muted: oklch(0.78 0.015 90); + --destructive: oklch(0.72 0.14 27); + color-scheme: dark; + } +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans); + font-size: 1.0625rem; + line-height: 1.5; +} + +a { + color: inherit; +} + +h1, +h2, +.wordmark { + font-family: var(--font-serif); + font-weight: 560; + letter-spacing: -0.02em; +} + +h1 { + margin: 0.2rem 0 0.6rem; + font-size: clamp(2rem, 5vw, 2.7rem); + line-height: 1.08; +} + +h2 { + margin: 0 0 0.8rem; + font-size: 1.45rem; +} + +h3 { + margin: 1rem 0 0.4rem; +} + +p { + margin: 0.4rem 0; +} + +.skip { + position: absolute; + left: 0.5rem; + top: -3rem; + background: var(--card); + padding: 0.4rem 0.6rem; +} + +.skip:focus { + top: 0.5rem; + z-index: 6; +} + +.site-header { + position: sticky; + top: 0; + z-index: 4; + background: color-mix(in oklch, var(--background) 92%, transparent); + border-bottom: 1px solid color-mix(in oklch, var(--foreground) 35%, transparent); + backdrop-filter: blur(8px); +} + +.header-inner, +.wrap { + width: min(64rem, calc(100% - 2rem)); + margin-inline: auto; +} + +.header-inner { + display: flex; + flex-wrap: wrap; + gap: 0.6rem 1.2rem; + align-items: baseline; + justify-content: space-between; + padding: 0.75rem 0; +} + +.wordmark { + color: inherit; + text-decoration: none; + font-size: 1.35rem; +} + +nav { + display: flex; + gap: 0.9rem; +} + +nav a { + text-decoration: none; + font-weight: 700; +} + +nav a[aria-current="page"] { + text-decoration: underline; + text-underline-offset: 0.25rem; +} + +.wrap { + position: relative; + padding-top: 1.4rem; + padding-bottom: 6.5rem; +} + +.wrap::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: -0.35rem; + width: 1px; + background: color-mix(in oklch, var(--foreground) 25%, transparent); +} + +.sheet-kicker { + margin: 0; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.16em; + line-height: 1.35; + text-transform: uppercase; +} + +.section-rule { + border-bottom: 1px solid color-mix(in oklch, var(--foreground) 50%, transparent); + padding-bottom: 0.3rem; +} + +.lede { + max-width: 38rem; +} + +.intro { + display: grid; + gap: 1.2rem; + margin-bottom: 1.6rem; +} + +@media (min-width: 800px) { + .intro { + grid-template-columns: minmax(0, 1.2fr) minmax(14rem, 0.8fr); + align-items: start; + } +} + +.label-sheet { + background: var(--card); + box-shadow: + inset 0 0 0 3px var(--card), + inset 0 0 0 4px var(--foreground); + padding: 0.95rem 1rem 1rem; +} + +.sheet-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1.4rem; + align-items: start; +} + +.sheet-grid > * { + min-width: 0; +} + +@media (min-width: 960px) { + .sheet-grid { + grid-template-columns: minmax(0, 1.25fr) minmax(16rem, 0.75fr); + } + + .results { + position: sticky; + top: 4.6rem; + } +} + +.block { + margin: 0 0 1.3rem; +} + +.section-head { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + align-items: end; + justify-content: space-between; +} + +.section-head h2 { + flex: 1 1 12rem; +} + +.fields { + display: grid; + gap: 0.75rem; + margin: 0.7rem 0; +} + +@media (min-width: 640px) { + .fields.two { + grid-template-columns: 1fr 1fr; + } + + .fields.three { + grid-template-columns: 1fr 1fr 1fr; + } +} + +.field { + display: flex; + flex-direction: column; + gap: 0.28rem; + min-width: 0; +} + +.field > span, +legend { + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +input, +select, +textarea, +button { + font: inherit; + color: inherit; +} + +input, +select, +textarea { + width: 100%; + min-height: 2.75rem; + padding: 0.45rem 0.7rem; + border: 1px solid color-mix(in oklch, var(--foreground) 45%, transparent); + border-radius: 2px; + background: var(--card); +} + +textarea { + min-height: 5.5rem; + resize: vertical; +} + +input[aria-invalid="true"], +select[aria-invalid="true"], +textarea[aria-invalid="true"] { + border-color: var(--destructive); +} + +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +button:focus-visible, +a:focus-visible, +summary:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +.hint { + color: var(--muted); + font-size: 0.95rem; +} + +.choice-row { + display: flex; + flex-wrap: wrap; + gap: 0.8rem 1.2rem; + margin: 0.6rem 0; +} + +.check { + display: flex; + gap: 0.6rem; + align-items: flex-start; +} + +.check input { + width: 1.2rem; + height: 1.2rem; + min-height: 1.2rem; + margin-top: 0.2rem; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0.8rem 0; +} + +button { + min-height: 2.75rem; + padding: 0.4rem 0.9rem; + border: 1px solid var(--foreground); + border-radius: 2px; + background: var(--primary); + color: var(--primary-foreground); + font-weight: 700; + cursor: pointer; +} + +button.secondary { + background: transparent; + color: var(--foreground); +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.ink-frame { + border: 1px solid color-mix(in oklch, var(--foreground) 55%, transparent); + background: var(--card); + padding: 0.85rem 0.9rem 1rem; + margin: 0 0 0.75rem; +} + +.ink-empty { + border: 1px dashed color-mix(in oklch, var(--foreground) 60%, transparent); + padding: 1rem; +} + +fieldset { + min-inline-size: 0; +} + +.part-toolbar { + display: flex; + justify-content: flex-end; + margin-top: -0.2rem; +} + +.results dl { + margin: 0.4rem 0 0; +} + +.results dl div { + display: grid; + grid-template-columns: minmax(7rem, 0.9fr) minmax(0, 1.1fr); + gap: 0.4rem 0.8rem; + padding: 0.28rem 0; + border-bottom: 1px solid color-mix(in oklch, var(--foreground) 18%, transparent); +} + +.results dt { + margin: 0; + color: var(--muted); +} + +.results dd { + margin: 0; + font-variant-numeric: tabular-nums; +} + +.part-result { + margin-top: 0.8rem; +} + +.part-result p { + margin: 0.15rem 0; +} + +#error-list, +#result-errors { + margin: 0.4rem 0; + padding-left: 1.1rem; + color: var(--destructive); +} + +#result-errors:empty { + display: none; +} + +.notebook { + white-space: pre-wrap; + overflow-wrap: anywhere; + font-family: ui-monospace, monospace; + font-size: 0.82rem; + line-height: 1.45; + margin: 0.3rem 0 0; +} + +.prose { + max-width: 40rem; +} + +.prose p { + margin: 0 0 0.9rem; +} + +.journal-list { + list-style: none; + margin: 1rem 0 0; + padding: 0; +} + +.journal-list li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.2rem 0.8rem; + align-items: center; + padding: 0.75rem 0; + border-bottom: 1px solid color-mix(in oklch, var(--foreground) 25%, transparent); +} + +.journal-open { + justify-self: start; + text-align: left; + background: transparent; + color: var(--foreground); + border: 0; + padding: 0; + min-height: 2.4rem; + text-decoration: underline; + text-underline-offset: 0.2rem; +} + +.journal-when { + grid-column: 1; + color: var(--muted); + font-size: 0.92rem; +} + +details summary { + cursor: pointer; + font-family: var(--font-serif); + font-size: 1.25rem; + margin-bottom: 0.6rem; +} + +.footer { + border-top: 1px solid color-mix(in oklch, var(--foreground) 40%, transparent); + padding-top: 0.8rem; + padding-bottom: 6rem; +} + +.live-bar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 5; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.6rem; + padding: 0.7rem 0.9rem calc(0.7rem + env(safe-area-inset-bottom)); + background: var(--card); + border-top: 1px solid var(--foreground); + font-variant-numeric: tabular-nums; +} + +.live-bar[hidden] { + display: none; +} + +.file-button { + display: inline-flex; + align-items: center; + min-height: 2.75rem; + padding: 0.4rem 0.9rem; + border: 1px solid var(--foreground); + border-radius: 2px; + background: transparent; + color: var(--foreground); + font-weight: 700; + cursor: pointer; +} + +.table-scroll { + overflow-x: auto; + max-width: 100%; + margin-top: 0.6rem; +} + +.rank-table { + width: 100%; + border-collapse: collapse; + font-variant-numeric: tabular-nums; + font-size: 0.92rem; +} + +.rank-table th, +.rank-table td { + text-align: left; + padding: 0.35rem 0.35rem; + border-bottom: 1px solid color-mix(in oklch, var(--foreground) 18%, transparent); + white-space: nowrap; +} + +.rank-table th { + font-size: 0.68rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.rank-table tr.is-best td { + font-weight: 700; +} + +.tier { + display: inline-block; + min-width: 4.4rem; + padding: 0.05rem 0.35rem; + text-align: center; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.tier-safe { + background: #0072b2; + color: #fff; +} + +.tier-mild { + background: #e69f00; + color: #1a1408; +} + +.tier-danger { + background: #d55e00; + color: #fff; +} + +@media (min-width: 960px) { + .live-bar { + display: none; + } +} + +@media print { + .site-header, + .live-bar, + .no-print, + .footer { + display: none !important; + } + + body { + background: white; + } + + .wrap { + width: auto; + padding-bottom: 0; + } + + .results { + position: static; + } +} diff --git a/field-sheet/sw.js b/field-sheet/sw.js new file mode 100644 index 0000000..21fbec5 --- /dev/null +++ b/field-sheet/sw.js @@ -0,0 +1,49 @@ +const CACHE = "epea-field-sheet-v1"; +const FILES = [ + "./", + "./index.html", + "./sheet.css", + "./app.mjs", + "./calc.mjs", + "./examples.mjs", + "./manifest.webmanifest", + "./favicon.svg", +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(CACHE) + .then((cache) => cache.addAll(FILES)) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)))) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") return; + const url = new URL(event.request.url); + if (url.origin !== self.location.origin) return; + event.respondWith( + caches.match(event.request).then((cached) => { + const network = fetch(event.request) + .then((response) => { + if (response.ok) { + const copy = response.clone(); + caches.open(CACHE).then((cache) => cache.put(event.request, copy)); + } + return response; + }) + .catch(() => cached); + return cached || network; + }), + ); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..d0f4a89 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "epea-framework", + "private": true, + "scripts": { + "dev": "python3 field-sheet/serve.py || python field-sheet/serve.py", + "start": "python3 field-sheet/serve.py || python field-sheet/serve.py", + "test:sheet": "node --test field-sheet/calc.test.mjs" + } +} diff --git a/tests/test_field_sheet_matches_python.py b/tests/test_field_sheet_matches_python.py new file mode 100644 index 0000000..262959b --- /dev/null +++ b/tests/test_field_sheet_matches_python.py @@ -0,0 +1,123 @@ +"""The browser sheet must stay on the same scores as the Python module.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from epea.core import ( + classify_environmental_impact_pct, + classify_environmental_impact_total_score, + cnorm_from_cost, + enorm_from_lbe, + environmental_impact_fraction, + mcda_overall_scores, + profile_score_from_tiers, + snorm_from_impact_pct, + tier_to_score, +) + +ROOT = Path(__file__).resolve().parents[1] + + +def test_field_sheet_js_matches_python() -> None: + node = shutil.which("node") + if node is None: + if os.environ.get("CI") == "true" and os.environ.get("FIELD_SHEET_JS") == "1": + pytest.fail("node is missing in CI") + pytest.skip("node is required to compare the field sheet") + + script = """ +import * as epea from "./field-sheet/calc.mjs"; +import { workedExample } from "./field-sheet/examples.mjs"; + +const tiers = ["safe", "mild", "safe", "mild", "safe", "mild"]; +const total = epea.profileScoreFromTiers(tiers); +const ei = epea.environmentalImpactFraction(total) * 100; +const sheet = epea.computeSheet(workedExample().input); +const custom = epea.mcdaOverallScores( + [-10, -6], + [50, 50], + [50, 50], + [0.8, 0.1, 0.1], + ["best_efficacy", "worst_efficacy"], +); +console.log(JSON.stringify({ + mild: epea.tierToScore("mild"), + total, + ei, + cls: [ + epea.classifyEnvironmentalImpactTotalScore(6), + epea.classifyEnvironmentalImpactTotalScore(7), + epea.classifyEnvironmentalImpactTotalScore(11), + epea.classifyEnvironmentalImpactTotalScore(12), + ], + clsPct: epea.classifyEnvironmentalImpactPct(ei), + enorm: epea.enormFromLbe([-10, -8, -6]), + snorm: epea.snormFromImpactPct([0, 50, 100]), + cnorm: epea.cnormFromCost([0, 50, 100]), + tied: epea.enormFromLbe([-7, -7, -7]), + custom: custom.map((row) => [row.name, row.oPercent]), + ranked: sheet.ranked.map((row) => [row.name, row.oPercent, row.enorm, row.snorm, row.cnorm, row.eiPercent]), +})); +""" + completed = subprocess.run( + [node, "--input-type=module", "-e", script], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ) + got = json.loads(completed.stdout) + + assert got["mild"] == tier_to_score("mild") + expected_total = profile_score_from_tiers(("safe", "mild", "safe", "mild", "safe", "mild")) + assert got["total"] == expected_total + assert got["ei"] == pytest.approx(environmental_impact_fraction(expected_total) * 100) + assert got["cls"] == [ + classify_environmental_impact_total_score(6), + classify_environmental_impact_total_score(7), + classify_environmental_impact_total_score(11), + classify_environmental_impact_total_score(12), + ] + assert got["clsPct"] == classify_environmental_impact_pct(got["ei"]) + assert got["enorm"] == pytest.approx(enorm_from_lbe([-10.0, -8.0, -6.0]).tolist()) + assert got["snorm"] == pytest.approx(snorm_from_impact_pct([0.0, 50.0, 100.0]).tolist()) + assert got["cnorm"] == pytest.approx(cnorm_from_cost([0.0, 50.0, 100.0]).tolist()) + assert got["tied"] == pytest.approx(enorm_from_lbe([-7.0, -7.0, -7.0]).tolist()) + + custom = mcda_overall_scores( + [-10.0, -6.0], + [50.0, 50.0], + [50.0, 50.0], + weights=(0.8, 0.1, 0.1), + names=["best_efficacy", "worst_efficacy"], + ) + assert [row[0] for row in got["custom"]] == ["best_efficacy", "worst_efficacy"] + for name, score in got["custom"]: + expected = custom.loc[custom["name"] == name, "O_percent"].iloc[0] + assert score == pytest.approx(expected) + + names = ["Example_A", "Example_B", "Example_C"] + tier_rows = [ + ("safe", "mild", "safe", "mild", "safe", "mild"), + ("mild", "mild", "mild", "safe", "mild", "safe"), + ("danger", "mild", "mild", "mild", "mild", "safe"), + ] + ei = [environmental_impact_fraction(profile_score_from_tiers(row)) * 100 for row in tier_rows] + frame = mcda_overall_scores([-7.2, -8.1, -6.5], ei, [12.0, 45.0, 8.0], names=names) + by_name = {row[0]: row for row in got["ranked"]} + for name in names: + py = frame.loc[frame["name"] == name].iloc[0] + js_name, o_percent, enorm, snorm, cnorm, ei_percent = by_name[name] + assert js_name == name + assert o_percent == pytest.approx(py["O_percent"]) + assert enorm == pytest.approx(py["Enorm"]) + assert snorm == pytest.approx(py["Snorm"]) + assert cnorm == pytest.approx(py["Cnorm"]) + assert ei_percent == pytest.approx(py["EI_percent"]) diff --git a/tests/test_field_sheet_server.py b/tests/test_field_sheet_server.py new file mode 100644 index 0000000..0a468f6 --- /dev/null +++ b/tests/test_field_sheet_server.py @@ -0,0 +1,38 @@ +"""The field sheet server must answer on its own address.""" + +from __future__ import annotations + +import importlib.util +import threading +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def _load_server(): + path = ROOT / "field-sheet" / "serve.py" + spec = importlib.util.spec_from_file_location("epea_sheet_server", path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_field_sheet_server_serves_index() -> None: + server_module = _load_server() + httpd = server_module.make_server("127.0.0.1", 0) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + port = httpd.server_address[1] + with urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=5) as response: + body = response.read().decode("utf-8") + assert response.status == 200 + assert "EPEA field sheet" in body + assert "Eco-pharmaco-economic analysis" in body + finally: + httpd.shutdown() + httpd.server_close() + thread.join(timeout=5) From 4e05bf7572a09366c86d37161d663d045c750c65 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 26 Sep 2026 00:31:34 +0000 Subject: [PATCH 2/2] docs: put the field sheet address at the front of the repo Users open http://127.0.0.1:43119 after python field-sheet/serve.py. The README, package metadata, demo script and sheet footer now say so. Co-authored-by: Diaiti Zure --- CHANGELOG.md | 1 + README.md | 30 +++++++++++--------------- examples/run_demo.py | 2 ++ examples/sample_chemicals.template.csv | 1 + field-sheet/index.html | 5 ++++- package.json | 2 ++ pyproject.toml | 3 ++- tests/test_field_sheet_server.py | 1 + 8 files changed, 26 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c9cc43..be7e67b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Enter six EPI-style tiers, docking LBE and cost per kg. The sheet ranks candidates, stores records in the browser and imports or exports the tier CSV. - `python field-sheet/serve.py` serves the sheet at . + That address is the field sheet link in the README, package metadata and the page footer. - Tests that the browser scores match the Python module, plus a server smoke test. ## [1.0.0] — 2024-05-30 diff --git a/README.md b/README.md index 762b20a..758b334 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,19 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![DOI](https://img.shields.io/badge/DOI-10.3390%2Fijms25116009-blue)](https://doi.org/10.3390/ijms25116009) -Python reference implementation of the **weighted score (WSM) environmental impact** and **multicriteria decision analysis (MCDA)** workflow described in *International Journal of Molecular Sciences* (2024), for comparing disinfectant (or similar) candidates on efficacy, environmental profiles, and cost. +Python reference implementation and browser field sheet for the **weighted score (WSM) environmental impact** and **multicriteria decision analysis (MCDA)** workflow in *International Journal of Molecular Sciences* (2024). Compare disinfectant (or similar) candidates on efficacy, environmental profiles and cost. + +**Field sheet:** [http://127.0.0.1:43119](http://127.0.0.1:43119) + +Start the sheet from this repository, then open that address in a browser: + +```bash +python field-sheet/serve.py +``` + +`npm run dev` and `npm start` start the same server. The worked example loads the three illustrative rows from `examples/run_demo.py`. Records stay in the browser. No data are uploaded. A blank candidate row is left out of the score. Import a CSV that uses the columns in `examples/sample_chemicals.template.csv`, or export one and pass it to `run_mcda_from_tier_table`. + +This sheet scores candidates. It does not close a pre-harvest mass balance. That calculation lives in [antiviral-mass-balance](https://github.com/planttalks/antiviral-mass-balance). **Paper:** Zure, D.; Sung, M.-H.; Rahim, A.; Kuo, H.-W. “In Silico Assessment of Chemical Disinfectants on Surface Proteins Unveiled Dissimilarity in Antiviral Efficacy and Suitability towards Pathogenic Viruses.” *Int. J. Mol. Sci.* **2024**, *25*, 6009. [https://doi.org/10.3390/ijms25116009](https://doi.org/10.3390/ijms25116009) · [MDPI article page](https://www.mdpi.com/1422-0067/25/11/6009) @@ -37,22 +49,6 @@ python -m venv .venv pip install -e ".[dev]" ``` -## Open the calculation sheet - -Open [http://127.0.0.1:43119](http://127.0.0.1:43119). - -From the repository root, start the sheet and then open that address in a browser. - -```bash -python field-sheet/serve.py -``` - -`npm run dev` and `npm start` start the same server. - -The worked example loads the three illustrative rows from `examples/run_demo.py`. Records stay in the browser. No data are uploaded. A blank candidate row is left out of the score. Import a CSV that uses the columns in `examples/sample_chemicals.template.csv`, or export one and pass it to `run_mcda_from_tier_table`. - -This sheet scores candidates. It does not close a pre-harvest mass balance. That calculation lives in [antiviral-mass-balance](https://github.com/planttalks/antiviral-mass-balance). - ## Quick use ```python diff --git a/examples/run_demo.py b/examples/run_demo.py index 44ed360..5000cbf 100644 --- a/examples/run_demo.py +++ b/examples/run_demo.py @@ -65,6 +65,8 @@ def main() -> None: df = df.sort_values("O_percent", ascending=False) print("\nMCDA ranking (higher O_percent = better overall):") print(df.to_string(index=False)) + print("\nThe same scores are on the field sheet: http://127.0.0.1:43119") + print("Start it with: python field-sheet/serve.py") if __name__ == "__main__": diff --git a/examples/sample_chemicals.template.csv b/examples/sample_chemicals.template.csv index 0410230..f3f8bc2 100644 --- a/examples/sample_chemicals.template.csv +++ b/examples/sample_chemicals.template.csv @@ -1,3 +1,4 @@ # Zure et al. Int. J. Mol. Sci. 2024 25 6009 https://doi.org/10.3390/ijms25116009 +# Import this file on the field sheet at http://127.0.0.1:43119 (python field-sheet/serve.py) name,logOH,logKoc,HLN,logBCF,logBAF,DT50,LBE,USD_per_kg,notes Example_1,safe,mild,safe,mild,safe,mild,-7.2,12.0,template row — edit or replace diff --git a/field-sheet/index.html b/field-sheet/index.html index 079d340..54b8677 100644 --- a/field-sheet/index.html +++ b/field-sheet/index.html @@ -235,8 +235,11 @@

Limits

diff --git a/package.json b/package.json index d0f4a89..bfcca37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,8 @@ { "name": "epea-framework", "private": true, + "homepage": "http://127.0.0.1:43119", + "description": "EPEA field sheet. Start with npm start, then open http://127.0.0.1:43119.", "scripts": { "dev": "python3 field-sheet/serve.py || python field-sheet/serve.py", "start": "python3 field-sheet/serve.py || python field-sheet/serve.py", diff --git a/pyproject.toml b/pyproject.toml index 94432be..2f93baf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "epea" version = "1.0.0" -description = "Eco-pharmaco-economic analysis (EPEA): WSM environmental impact + MCDA scoring" +description = "Eco-pharmaco-economic analysis (EPEA): field sheet at http://127.0.0.1:43119, plus WSM environmental impact and MCDA scoring" readme = "README.md" requires-python = ">=3.10" license = { text = "MIT" } @@ -34,6 +34,7 @@ dev = ["pytest>=7.0", "ruff>=0.4"] [project.urls] Repository = "https://github.com/planttalks/epea-framework" +"Field sheet" = "http://127.0.0.1:43119" "Paper (DOI)" = "https://doi.org/10.3390/ijms25116009" "Paper (MDPI)" = "https://www.mdpi.com/1422-0067/25/11/6009" diff --git a/tests/test_field_sheet_server.py b/tests/test_field_sheet_server.py index 0a468f6..318f041 100644 --- a/tests/test_field_sheet_server.py +++ b/tests/test_field_sheet_server.py @@ -32,6 +32,7 @@ def test_field_sheet_server_serves_index() -> None: assert response.status == 200 assert "EPEA field sheet" in body assert "Eco-pharmaco-economic analysis" in body + assert "http://127.0.0.1:43119" in body finally: httpd.shutdown() httpd.server_close()