Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ workflow smoke test. Set `HORTICALC_BROWSER_PATH` when the browser is outside a
standard location. The repository does not declare a separate Node version;
use a maintained Node.js release with npm.

Tests use temporary application data rather than the checkout's `user/`
directory. Browser subprocesses also receive an isolated application root;
they do not load your local preferences, recipes, or catalogue overrides.

The entrypoint runs Ruff format and lint checks, ESLint, Stylelint, frontend
unit tests, the browser smoke test, and the complete Pytest suite. For a focused
Pytest run:
Expand Down
3 changes: 3 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ in its standard installation directory. Build behavior is owned by
`scripts/packaging/horticalc.spec`, the platform build scripts, and
`scripts/packaging/horticalc.iss`.

Windows scripts check native command exit codes and stop on failed version
discovery, version-resource generation, packaging, or installer compilation.

The Linux application is written to `dist/horticalc/`; the Windows application
is written to `dist/Horticalc/`; and the installer is written to the repository
root. Launch `dist/horticalc/horticalc` or
Expand Down
40 changes: 20 additions & 20 deletions api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
import math
import os
import threading
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
Expand Down Expand Up @@ -234,15 +235,16 @@ async def security_headers(request: Request, call_next):


def _validation_details(errors: list[dict]) -> list[dict]:
for error in errors:
invalid_input = error.get("input")
if isinstance(invalid_input, float) and not math.isfinite(invalid_input):
error["input"] = str(invalid_input)
if "ctx" in error:
error["ctx"] = {
key: str(value) if isinstance(value, ValueError) else value for key, value in error["ctx"].items()
}
return errors
def safe_value(value: Any) -> Any:
if isinstance(value, ValueError) or isinstance(value, float) and not math.isfinite(value):
return str(value)
if isinstance(value, dict):
return {key: safe_value(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [safe_value(item) for item in value]
return value

return [safe_value(error) for error in errors]


@app.exception_handler(RequestValidationError)
Expand All @@ -253,6 +255,7 @@ async def request_validation_error(_request: Request, exc: RequestValidationErro
FERTILIZERS: Dict[str, Fertilizer] = {}
MOLAR_MASSES: Dict[str, float] = {}
PORTABLE_LAYOUT: PortableLayout | None = None
_preferences_lock = threading.RLock()
FRONTEND_DIR = app_root() / "frontend"


Expand Down Expand Up @@ -432,10 +435,6 @@ def _validated_request_model(model_type: type[BaseModel], payload: dict) -> Base
raise HTTPException(status_code=422, detail=_validation_details(exc.errors(include_url=False))) from exc


def _model_dump(model: BaseModel) -> dict:
return model.model_dump()


def _safe_filename(name: str) -> str:
return "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in name).strip("_")

Expand Down Expand Up @@ -725,11 +724,12 @@ def put_preferences(payload: PreferencesPayload) -> dict[str, Any]:
updates[field_name],
field_name=field_name,
)
preferences = load_user_preferences()
preferences.update(updates)
save_user_preferences(preferences)
if "solver_history_limit" in updates:
trim_solver_history(_solver_history_path(), _effective_solver_history_limit(preferences))
with _preferences_lock:
preferences = load_user_preferences()
preferences.update(updates)
save_user_preferences(preferences)
if "solver_history_limit" in updates:
trim_solver_history(_solver_history_path(), _effective_solver_history_limit(preferences))
return preferences


Expand Down Expand Up @@ -1028,7 +1028,7 @@ async def save_recipe_profile(request: Request) -> dict:
payload_out = {
"name": name,
"liters": recipe.liters,
"fertilizers": [_model_dump(entry) for entry in recipe.fertilizers],
"fertilizers": [entry.model_dump() for entry in recipe.fertilizers],
"fertilizers_allowed": fertilizers_allowed,
"urea_as_nh4": recipe.urea_as_nh4,
}
Expand Down Expand Up @@ -1070,7 +1070,7 @@ def _calculation_inputs(payload: RecipeRequest) -> tuple[dict, dict[str, float],

recipe = {
"liters": payload.liters,
"fertilizers": [_model_dump(entry) for entry in payload.fertilizers],
"fertilizers": [entry.model_dump() for entry in payload.fertilizers],
"urea_as_nh4": payload.urea_as_nh4,
}
return recipe, water_mg_l, osmosis_percent
Expand Down
7 changes: 7 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ shipped resources; fertilizer edits are stored as catalogue deltas. WebView
state lives in `user/webview/`, separate from Solver history and structured
profiles.

Preference updates serialize their read/merge/write operation within the API
process and replace the file atomically. A write failure preserves the previous
file. Changing history retention persists preferences before trimming history;
a trim failure is reported without rolling back the saved preference. The
frontend queues writes and reports failed operations without blocking later
updates.

The API owns validation and persistence. The core owns chemistry and
optimization inputs but not filesystem or HTTP presentation. The UI owns
display units, locale, navigation, and transient interaction state.
Expand Down
16 changes: 13 additions & 3 deletions docs/solver.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ the reported solution.
affected a solve.

- Numeric zero targets are normally report-only.
- In `n_forms_only` mode, explicit zero nitrogen-form targets remain objectives.
- `Na` and `Cl` are always report-only.
- `nitrogen_objective_mode` selects provided nitrogen targets, total nitrogen
only, or individual nitrogen forms only. The default is `n_total_only`.
Expand All @@ -42,9 +43,14 @@ affected a solve.
elemental `S` target.
- Hierarchical target directions at priority `0` are report-only.

`ignored_elements` remains part of accepted persisted/API data. The
hierarchical model interprets each listed element as priority `0` in both
directions; the GUI persists the canonical `target_priorities` representation.
Urea mode applies to both optimization coefficients and reported results. With
`urea_as_nh4=true`, fertilizer urea contributes to `N_NH4` instead of `N_UREA`;
its contribution to total nitrogen is unchanged.

`ignored_elements` remains part of accepted persisted/API data and survives GUI
load/save cycles for every model. The hierarchical model displays each listed
element at priority `0` in both directions. Editing either priority replaces
that element's ignore entry with the selected directional priorities.

## NNLS + tuning

Expand All @@ -61,6 +67,10 @@ The standard path can apply relative weighting, iterative reweighted least
squares (IRLS) overshoot penalties, an optional nitrogen-total governor, and
dominant-supplier correction passes. These controls affect only `nnls_tuning`.

Candidate comparisons include explicit zero nitrogen-form objectives. Their
errors are measured against `scale_eps_mg_per_l`; non-zero targets use their
percentage error. Zero targets outside the selected objectives stay report-only.

## Mass NNLS (experimental)

`mass_nnls` minimizes unweighted squared residuals in elemental `mg/L`:
Expand Down
26 changes: 24 additions & 2 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ display unit converts the visible quantity without changing the stored batch or
dose. The sidebar shows nitrate, ammonium and urea shares as **NO3:NH4:UR
(%)**, calculated from their nitrogen contributions.

Batch-volume changes scale doses without rounding the stored quantities.
Display formatting does not limit the precision of small trace doses.

Saved calculator recipes contain their batch, fertilizer doses, water profile,
RO-water proportion, urea setting, optional solver configuration, and any stock
preparation settings. The star
Expand Down Expand Up @@ -95,15 +98,23 @@ The feature plans preparation; it does not track bottles already mixed.
Use **Water analysis** to load, edit, and save the water used by Calculator and
Solver. Values describe the source water before reverse-osmosis mixing.

Loaded profiles show their combined phosphorus as P and set PO4 to zero, so
loading or saving a profile does not count the same phosphorus twice. Separately
entered P and PO4 values add together.

A pending load is ignored if a different profile is selected before it finishes.
This also applies to recipe and Solver target-profile loading.

The **RO-water proportion** is the percentage of the final batch modelled as
pure RO water with `0 mg/L` dissolved input. At 25%, Horticalc uses 75% of every
entered source-water concentration.

## Fertilizer editor

Use **Fertilizer editor** to search and edit the effective catalogue. Composition
values are mass fractions: `0.14` means 14%. A liquid product uses its weight
factor as density when converting the displayed volume dose.
values are entered as percentages: enter `14` for 14%. CSV and API composition
values remain mass fractions (`0.14` for the same analysis). A liquid product uses
its weight factor as density when converting the displayed volume dose.

Select an editor row and use minus to delete it from the editable list.
The table fills the remaining workspace height at the selected interface scale;
Expand Down Expand Up @@ -135,6 +146,9 @@ allowed fertilizers, fixed doses, urea mode, and solver settings. Loading with
the option disabled changes only the targets. Horticalc checks referenced water
and fertilizer names before replacing the current setup.

Changing or scaling targets clears the previous result. Calculate again before
copying, saving, applying, or preparing stocks from the new targets.

The target-profile star is independent of recipe favorites. Delete is available
only for user-saved recipes and target profiles. Deleting a user override of a
shipped profile reveals the shipped profile again.
Expand All @@ -147,6 +161,9 @@ a run above normal history and protects it from retention and the regular clear
action. The history limit counts unpinned runs; setting it to `0` retains pins
but stops normal history recording.

Closing a history dialog or opening another run discards pending responses for
the old dialog. Copy and Restore use the run currently displayed.

## Preferences and languages

Configuration controls volume, solid-dose and liquid-dose display units, theme,
Expand All @@ -156,6 +173,11 @@ wheel uses the same scale steps. Horticalc provides German, English, Dutch,
Spanish, and Simplified Chinese UI text. Data keys, element symbols, file
formats, and API fields remain unchanged when the language changes.

Changing language preserves the selected water profile and its analysis.
Preference save failures are reported. Appearance and unit changes remain active
for the session; failed history-limit and favorite changes return to their last
confirmed values.

## Files and backups

Horticalc keeps shipped defaults in `data/` and `recipes/`. Your edits,
Expand Down
8 changes: 4 additions & 4 deletions frontend/app/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,16 @@ export function loadPreferences() {
}

export function persistPreferences(updates) {
preferences = { ...preferences, ...updates };
preferenceWritePromise = preferenceWritePromise.then(() =>
const write = preferenceWritePromise.then(() =>
putJson("/preferences", updates, "Unable to save preferences", { keepalive: true })
.then((data) => {
preferences = data && typeof data === "object" ? data : preferences;
return preferences;
})
.catch(() => preferences)
);
return preferenceWritePromise;
// Recover the queue while leaving the operation's rejection with its caller.
preferenceWritePromise = write.catch(() => {});
return write;
}

export const fetchFertilizers = (message) => getJson("/fertilizers", message);
Expand Down
14 changes: 6 additions & 8 deletions frontend/app/calculator.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
qsa,
renderTableRows,
} from "./dom.js";
import { buildAlignedRows, formatNumber, roundScaledValue } from "./formatting.js";
import { buildAlignedRows, formatNumber } from "./formatting.js";
import { applyScaledValues, bindScaleButtons } from "./scaling.js";
import { createLatestRequestGate } from "../request_gate.js";
import { storageSet } from "./storage.js";
Expand Down Expand Up @@ -62,7 +62,7 @@ function createCalculatorRow(name = "", grams = 0) {
return {
name,
grams: normalizedGrams,
baseGrams: roundScaledValue(normalizedGrams),
baseGrams: normalizedGrams,
};
}

Expand Down Expand Up @@ -320,7 +320,7 @@ function renderCalculatorTable() {
if (calculatorRow.baseGrams === undefined) {
const currentAmount = Math.max(0, Number(calculatorRow.grams) || 0);
calculatorRow.baseGrams =
calculatorScaleFactor > 0 ? roundScaledValue(currentAmount / calculatorScaleFactor) : 0;
calculatorScaleFactor > 0 ? currentAmount / calculatorScaleFactor : 0;
}

const indexCell = document.createElement("td");
Expand All @@ -344,7 +344,7 @@ function renderCalculatorTable() {
}
calculatorRow.grams = canonicalValue;
calculatorRow.baseGrams =
calculatorScaleFactor > 0 ? roundScaledValue(canonicalValue / calculatorScaleFactor) : 0;
calculatorScaleFactor > 0 ? canonicalValue / calculatorScaleFactor : 0;
scheduleRecalculate();
});
input.addEventListener("change", () => {
Expand Down Expand Up @@ -531,10 +531,10 @@ function initializeTables() {
function scaleBatch(previousLiters, nextLiters) {
const factor = nextLiters / previousLiters;
calculatorRows.forEach((row) => {
const scaled = roundScaledValue((Number(row.grams) || 0) * factor);
const scaled = (Number(row.grams) || 0) * factor;
row.grams = scaled;
row.baseGrams = calculatorScaleFactor > 0
? roundScaledValue(scaled / calculatorScaleFactor)
? scaled / calculatorScaleFactor
: scaled;
});
renderCalculatorTable();
Expand Down Expand Up @@ -596,11 +596,9 @@ return {
collectSelectedFertilizerNames,
get lastCalculation() { return lastCalculation; },
get currentRequest() { return calculatorResultCurrent ? JSON.parse(JSON.stringify(lastCalculationRequest)) : null; },
get resultCurrent() { return calculatorResultCurrent; },
mount,
refreshDoseUnits() { renderSelectionTable(); renderCalculatorTable(); },
refreshLocalized,
render: renderCalculation,
scaleBatch,
scheduleRecalculate,
setFertilizers,
Expand Down
4 changes: 0 additions & 4 deletions frontend/app/formatting.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,6 @@ export function formatNumber(value, formatter = defaultNumberFormatter) {
return Number.isFinite(value) ? formatter.format(value) : "-";
}

export function roundScaledValue(value) {
return Math.round(value * 1000) / 1000;
}

export function buildAlignedRows(headers, rows, numericColumns = []) {
const allRows = headers ? [headers, ...rows] : rows;
const widths = allRows.reduce((current, row) => {
Expand Down
9 changes: 9 additions & 0 deletions frontend/app/history.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { qs } from "./dom.js";
import { buildSolverPrintableText } from "./solver_printable.js";
import { createLatestRequestGate } from "../request_gate.js";

const HISTORY_DEFAULT_LIMIT = 1000;
const HISTORY_PREVIEW_MAX_LINES = 18;
Expand Down Expand Up @@ -47,6 +48,7 @@ export function createHistoryController({ api, i18n, notifications, units, onRes
const copyButton = qs("#copySolverHistoryEntry");
const restoreButton = qs("#restoreSolverHistoryEntry");
const detailCache = new Map();
const dialogRequests = createLatestRequestGate();
const t = (key, params) => i18n.t(key, params);
let entries = [];
let limit = HISTORY_DEFAULT_LIMIT;
Expand Down Expand Up @@ -126,6 +128,7 @@ export function createHistoryController({ api, i18n, notifications, units, onRes
}

async function openDetail(entry) {
const version = dialogRequests.reserve();
activePreviewId = "";
preview.classList.add("is-hidden");
activeDialogDetail = null;
Expand All @@ -138,11 +141,13 @@ export function createHistoryController({ api, i18n, notifications, units, onRes
else dialog.setAttribute("open", "");
try {
const detail = await fetchDetail(entry.id);
if (!dialogRequests.isCurrent(version) || !dialog.hasAttribute("open")) return;
activeDialogDetail = detail;
dialogOutput.textContent = detailText(detail);
copyButton.disabled = false;
restoreButton.disabled = false;
} catch (error) {
if (!dialogRequests.isCurrent(version) || !dialog.hasAttribute("open")) return;
dialogOutput.textContent = t("history.previewUnavailable");
notifications.reportError(error, t("errors.loadSolverHistoryEntry"));
}
Expand Down Expand Up @@ -235,7 +240,11 @@ export function createHistoryController({ api, i18n, notifications, units, onRes
}
});
dialog.addEventListener("close", () => {
if (dialog.hasAttribute("open")) return;
dialogRequests.invalidate();
activeDialogDetail = null;
copyButton.disabled = true;
restoreButton.disabled = true;
dialogStatus.textContent = "";
});
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ async function init() {
stocks.mount();
document.querySelector("#makeSolverStocks").addEventListener("click", openSolverStocks);
calculator.setFertilizers(resources.fertilizers);
solver.setFertilizers(resources.fertilizers);
solver.setFertilizers(resources.fertilizers, { persist: false });
solver.mount({ configDefinitions: resources.solverConfigDefinitions, config: preferences.solver_config || {} });
editor.setData(resources.fertilizers, resources.fertilizerKeys);
editor.mount();
Expand Down
Loading
Loading