diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..ed0a467 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,52 @@ +name: Tests + +on: + push: + branches: [ master ] + + pull_request: + branches: [ master ] + + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}/${{ github.ref }} + cancel-in-progress: true + +jobs: + pytest: + name: Pytest (${{ matrix.os }}, ${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest, windows-latest, macos-latest ] + python-version: [ '3.12', '3.13' ] + + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + with: + # hatch-vcs derives the version from tags, so a shallow clone + # without them makes the build fail. + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install project and test dependencies + run: uv sync --group test --group examples + + - name: Run test suite + # The maths and the theme are head-less; only the Qt widget tests + # would need a display, and there are none yet. + run: uv run pytest + + - name: Rebuild the example workbook + run: uv run python examples/build_seal_workbook.py + + - name: Run the worked example + run: uv run python examples/seal_tolerance.py diff --git a/.gitignore b/.gitignore index a004fa1..c12fb8b 100644 --- a/.gitignore +++ b/.gitignore @@ -228,3 +228,6 @@ __marimo__/ *.pdf *.pptx + +# Excel owner/lock files, created whenever a workbook is opened +~$* diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..f5bdd5d --- /dev/null +++ b/examples/README.md @@ -0,0 +1,96 @@ +# vatic examples + +Worked examples you can run, read, and check your own results against. + +## Double-D seal gland tolerance stack-up + +A PSA-backed double-D single-hole gland is specified by eight dimensions, each +with a symmetric tolerance. Following the convention used throughout the +original model, a `±t` tolerance is read as a **three sigma** bound, so each +dimension is modelled as `Normal(nominal, t / 3)`. + +Two characteristics are checked against their requirements: + +| Characteristic | Formula | Lower | Upper | +| --- | --- | --- | --- | +| Gland Fill % | `seal area / groove area` | 0.75 | 1.00 | +| Seal Comp. % | `1 - groove height / seal height` | 0.25 | 0.50 | + +### Run it + +```sh +python examples/seal_tolerance.py +``` + +This runs 10,000 Latin-hypercube trials through `vatic`'s own API — no +spreadsheet involved — and prints the mean, standard deviation, variance, +skewness, kurtosis, percentiles and the full process-capability family for +every characteristic. + +### What the answer should look like + +At nominal dimensions the model is deterministic, so the simulated means have +a closed form to check against: + +| Characteristic | Nominal | +| --- | --- | +| Core hole area | 0.002969 in² | +| Seal area | 0.015400 in² | +| Groove area | 0.015210 in² | +| Gland Fill % | 1.0125 | +| Seal Comp. % | 0.27778 | + +Note that **Gland Fill % overfills at nominal** — its mean sits above the 1.00 +upper limit, so a negative `Cpk` and a defect rate in the hundreds of thousands +of PPM is the correct result, not a bug. Seal Comp. % comfortably passes. That +contrast is the point of the example: one characteristic that fails and one +that does not. + +`tests/test_seal_example.py` asserts all of this. + +## Rebuilding the workbook + +`seal_tolerance.xlsx` holds the same model as a spreadsheet, with the input +dimensions in `C8:C15`, the requirements in `C19:D20`, and the characteristics +in `C24:C26` and `C30:C31`. Formulas are written against workbook names +(`sh`, `sw`, `d`, `cc`, `gh`, `gw`, `flat1`, `flat2`, `ca`, `sa`, `ga`), which +keeps them readable and is what a spreadsheet-driven run will bind to. + +Regenerate it from source with: + +```sh +uv pip install openpyxl +python examples/build_seal_workbook.py +``` + +## Provenance + +The model, its dimensions and its formulas come from the worked example that +ships with the original **vatic** project by Abraham Lee +(), which drove the same calculation through +Microsoft Excel over COM. + +That repository carries no licence, so its workbook file is not redistributed +here. `build_seal_workbook.py` rebuilds an equivalent workbook from the same +published dimensions and formulas instead, which also means the spreadsheet is +reproducible from source control rather than being an opaque binary. + +## Differences from the original + +The statistics deliberately follow the original conventions so results line up: +population variance, skewness as the standardised third moment with no +sample-size correction, and Pearson kurtosis where a normal distribution sits +at 3.0. + +The capability metrics do **not** reproduce the original's arithmetic, which +had defects: + +- it used the normal **density** where the **cumulative** distribution is + required, so every `p(N/C)`, `PPM`, `Zst` and `Zlt` value was wrong; +- it wrote the `Cpm`/`Ppm` exponent with `^`, which is bitwise XOR in Python, + so those two metrics could never be computed at all; +- it accepted a `zshift` argument and then ignored it, leaving `Zst` and `Zlt` + identical. + +All three are fixed here, and `tests/test_analytics.py` pins each fix down +against an independent derivation. diff --git a/examples/build_seal_workbook.py b/examples/build_seal_workbook.py new file mode 100644 index 0000000..09f2d4b --- /dev/null +++ b/examples/build_seal_workbook.py @@ -0,0 +1,176 @@ +# -------------------------------------*- vatic -*---------------------------- +# Open Source Risk Analysis +# +# Copyright (c) 2026, eggzec +# Contact: https://eggzec.github.io/ +# +# License: GNU General Public License +# Version 3, 29 June 2007 +# +# ---------------------------------------------------------------------------- +# +# Author(s) +# Saud Zahir +# +# Date +# 7 May 2026 +# +# Description +# Generate the Double-D seal gland workbook used by the spreadsheet +# examples and tests. +# +# ---------------------------------------------------------------------------- +# +# The original vatic project by Abraham Lee +# (https://github.com/tisimst/vatic) ships an equivalent workbook, but that +# repository carries no licence, so its file is not redistributed here. +# This script rebuilds an equivalent model from the same published +# dimensions and formulas, which keeps the example self-contained and lets +# the workbook be regenerated or edited from source control. +# +# Requires openpyxl: +# +# uv pip install openpyxl +# python examples/build_seal_workbook.py +# +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +from pathlib import Path + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Font, PatternFill +from openpyxl.workbook.defined_name import DefinedName + + +OUTPUT = Path(__file__).resolve().parent / "seal_tolerance.xlsx" + +SHEET = "Seal-Groove Design" + +#: Row, label, nominal, tolerance, units and the workbook name bound to the +#: nominal cell so the formulas below stay readable. +INPUTS: tuple[tuple[int, str, float, float, str, str], ...] = ( + (8, "Seal Height (mean)", 0.162, 0.005, "in", "sh"), + (9, "Seal Width", 0.118, 0.005, "in", "sw"), + (10, "Core Hole Diameter", 0.071, 0.005, "in", "d"), + (11, "Core Hole % Compression", 0.75, 0.10, "", "cc"), + (12, "Groove Height", 0.117, 0.002, "in", "gh"), + (13, "Groove Width", 0.130, 0.002, "in", "gw"), + (14, "Lid Flatness (GD&T)", 0.0, 0.005, "in", "flat1"), + (15, "Box Flatness (GD&T)", 0.0, 0.005, "in", "flat2"), +) + +#: Row, label, formula, units and the name bound to the result cell. +INTERMEDIATES: tuple[tuple[int, str, str, str, str], ...] = ( + (24, "Core Hole Area", "=cc*PI()*(d/2)^2", "in^2", "ca"), + (25, "Seal Area", "=PI()*(sw/2/2)^2+(sw*(sh-sw/4))-ca", "in^2", "sa"), + (26, "Groove Area", "=gw*(gh+flat1+flat2)", "in^2", "ga"), +) + +#: Row, label, formula and the spec limits the characteristic is judged on. +OUTPUTS: tuple[tuple[int, str, str, float, float], ...] = ( + (30, "Gland Fill %", "=sa/ga", 0.75, 1.00), + (31, "Seal Comp. %", "=1-gh/sh", 0.25, 0.50), +) + +_INPUT_FILL = PatternFill("solid", fgColor="FFF2CC") +_HEADING = Font(bold=True, color="2323FF") +_BOLD = Font(bold=True) + + +def build() -> Path: + """Write the workbook to :data:`OUTPUT`. + + Returns: + The path the workbook was written to. + """ + book = Workbook() + sheet = book.active + sheet.title = SHEET + + sheet["B2"] = "PSA Backed Double-D Single Hole Gland Calculator" + sheet["B2"].font = Font(bold=True, size=13, color="2323FF") + sheet["B3"] = "Model after Abraham Lee, IPPD (github.com/tisimst/vatic)" + + sheet["B6"] = "INPUTS" + sheet["B6"].font = _HEADING + sheet["C6"] = "Nominal values in the shaded fields" + for column, title in ( + ("B", "Parameter"), + ("C", "Nominal"), + ("D", "Tolerance (+/-)"), + ("E", "Units"), + ): + cell = sheet[f"{column}7"] + cell.value = title + cell.font = _BOLD + + for row, label, nominal, tolerance, units, name in INPUTS: + sheet[f"B{row}"] = label + sheet[f"C{row}"] = nominal + sheet[f"C{row}"].fill = _INPUT_FILL + sheet[f"D{row}"] = tolerance + sheet[f"E{row}"] = units + book.defined_names.add( + DefinedName(name, attr_text=f"'{SHEET}'!$C${row}") + ) + + sheet["B17"] = "REQUIREMENTS" + sheet["B17"].font = _HEADING + for column, title in ( + ("B", "Parameter"), + ("C", "Lower Limit"), + ("D", "Upper Limit"), + ): + cell = sheet[f"{column}18"] + cell.value = title + cell.font = _BOLD + + for offset, (row, _label, _formula, lower, upper) in enumerate(OUTPUTS): + spec_row = 19 + offset + sheet[f"B{spec_row}"] = f"={'B'}{row}" + sheet[f"C{spec_row}"] = lower + sheet[f"D{spec_row}"] = upper + + sheet["B22"] = "INTERMEDIATE CALCULATIONS (REFERENCE)" + sheet["B22"].font = _HEADING + for column, title in (("B", "Parameter"), ("C", "Nominal"), ("D", "Units")): + cell = sheet[f"{column}23"] + cell.value = title + cell.font = _BOLD + + for row, label, formula, units, name in INTERMEDIATES: + sheet[f"B{row}"] = label + sheet[f"C{row}"] = formula + sheet[f"D{row}"] = units + book.defined_names.add( + DefinedName(name, attr_text=f"'{SHEET}'!$C${row}") + ) + + sheet["B28"] = "OUTPUTS" + sheet["B28"].font = _HEADING + sheet["B29"] = "Parameter" + sheet["B29"].font = _BOLD + sheet["C29"] = "Nominal" + sheet["C29"].font = _BOLD + + for row, label, formula, _lower, _upper in OUTPUTS: + sheet[f"B{row}"] = label + sheet[f"C{row}"] = formula + + sheet.column_dimensions["B"].width = 30 + for column in ("C", "D", "E"): + sheet.column_dimensions[column].width = 16 + for row in sheet.iter_rows(min_row=1, max_row=32, max_col=5): + for cell in row: + if cell.column_letter in {"C", "D"}: + cell.alignment = Alignment(horizontal="right") + + book.save(OUTPUT) + return OUTPUT + + +if __name__ == "__main__": + path = build() + print(f"wrote {path}") diff --git a/examples/seal_tolerance.py b/examples/seal_tolerance.py new file mode 100644 index 0000000..a1f5d76 --- /dev/null +++ b/examples/seal_tolerance.py @@ -0,0 +1,186 @@ +# -------------------------------------*- vatic -*---------------------------- +# Open Source Risk Analysis +# +# Copyright (c) 2026, eggzec +# Contact: https://eggzec.github.io/ +# +# License: GNU General Public License +# Version 3, 29 June 2007 +# +# ---------------------------------------------------------------------------- +# +# Author(s) +# Saud Zahir +# +# Date +# 7 May 2026 +# +# Description +# Double-D seal gland tolerance stack-up, run head-less through the +# vatic API. Reproduces the worked example that ships with the original +# vatic project by Abraham Lee (https://github.com/tisimst/vatic), which +# drove the same model through a Microsoft Excel workbook. +# +# ---------------------------------------------------------------------------- +# +# A PSA-backed double-D single-hole gland is specified by eight dimensions, +# each with a symmetric tolerance. Following the convention used in the +# original workbook, a tolerance is read as a three sigma bound, so a +# dimension with a +/- t tolerance is modelled as Normal(nominal, t / 3). +# +# Two characteristics are then checked against their requirements: +# +# Gland Fill % = seal area / groove area spec 0.75 .. 1.00 +# Seal Comp. % = 1 - groove height / seal height spec 0.25 .. 0.50 +# +# Run it with: +# +# python examples/seal_tolerance.py +# +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +import mcerp +import numpy as np + +from vatic.analytics import compute_capability_metrics, compute_statistics +from vatic.distributions import ( + build_variable, + get_distribution_spec, + seed_sampler, +) +from vatic.formula import evaluate_formula + + +#: Nominal value and symmetric tolerance for each input dimension, in inches +#: except for the dimensionless compression fraction. These are the values +#: from the original worked example. +DIMENSIONS: dict[str, tuple[float, float]] = { + "seal_height": (0.162, 0.005), + "seal_width": (0.118, 0.005), + "core_hole_diameter": (0.071, 0.005), + "core_compression": (0.75, 0.10), + "groove_height": (0.117, 0.002), + "groove_width": (0.130, 0.002), + "lid_flatness": (0.0, 0.005), + "box_flatness": (0.0, 0.005), +} + +#: A tolerance is treated as a three sigma bound. +TOLERANCE_SIGMAS = 3.0 + +#: Intermediate and output characteristics, in dependency order. Each may +#: reference the assumptions and any characteristic defined above it. +FORECASTS: tuple[tuple[str, str, float | None, float | None], ...] = ( + ( + "core_hole_area", + "core_compression * pi * (core_hole_diameter / 2) ** 2", + None, + None, + ), + ( + "seal_area", + "pi * (seal_width / 4) ** 2" + " + seal_width * (seal_height - seal_width / 4)" + " - core_hole_area", + None, + None, + ), + ( + "groove_area", + "groove_width * (groove_height + lid_flatness + box_flatness)", + None, + None, + ), + ("gland_fill", "seal_area / groove_area", 0.75, 1.00), + ("seal_compression", "1 - groove_height / seal_height", 0.25, 0.50), +) + +ITERATIONS = 10_000 + +#: Fixed so the printed numbers and the tests are reproducible. Pass +#: ``seed=None`` to run() for a fresh sample set each time. +SEED = 20260819 + + +def build_assumptions() -> dict[str, object]: + """Create one uncertain variable per toleranced dimension. + + Returns: + Mapping of dimension name to its mcerp random variable. + """ + spec = get_distribution_spec("Normal") + variables: dict[str, object] = {} + for name, (nominal, tolerance) in DIMENSIONS.items(): + variables[name] = build_variable( + name, spec, {"mu": nominal, "sigma": tolerance / TOLERANCE_SIGMAS} + ) + return variables + + +def run(seed: int | None = SEED) -> dict[str, dict[str, float]]: + """Run the stack-up and report statistics for every characteristic. + + Args: + seed: Sampler seed. The default fixes the run so the numbers are + reproducible; pass ``None`` for a fresh sample set. + + Returns: + Mapping of characteristic name to its statistics, with capability + metrics merged in for the two that carry spec limits. + """ + mcerp.npts = ITERATIONS + seed_sampler(seed) + + context: dict[str, object] = dict(build_assumptions()) + results: dict[str, dict[str, float]] = {} + + for name, expression, lsl, usl in FORECASTS: + outcome = evaluate_formula(expression, context) + context[name] = outcome + + samples = getattr(outcome, "_mcpts", None) + if samples is None: + continue + + values = np.asarray(samples, dtype=float) + stats = compute_statistics(values) + if lsl is not None or usl is not None: + stats |= compute_capability_metrics( + values, lsl=lsl, usl=usl, target=None + ) + results[name] = stats + + return results + + +def main() -> None: + """Print the stack-up results in a readable table.""" + results = run() + + print(f"Double-D seal gland tolerance stack-up ({ITERATIONS:,} trials)") + print("=" * 68) + + for name, stats in results.items(): + print(f"\n{name}") + print("-" * len(name)) + print(f" mean {stats['mean']:12.6f}") + print(f" std dev {stats['std']:12.6f}") + print(f" variance {stats['variance']:12.6e}") + print(f" skewness {stats['skewness']:12.6f}") + print(f" kurtosis {stats['kurtosis']:12.6f}") + print(f" min / max {stats['min']:12.6f} / {stats['max']:.6f}") + print(f" P05 / P95 {stats['p05']:12.6f} / {stats['p95']:.6f}") + + if "Cpk" in stats: + print(f" Cp / Cpk {stats['Cp']:12.4f} / {stats['Cpk']:.4f}") + print(f" Pp / Ppk {stats['Pp']:12.4f} / {stats['Ppk']:.4f}") + print( + f" Zst / Zlt {stats['Zst']:12.4f} / {stats['Zlt']:.4f}" + ) + print(f" PPM total {stats['PPM-total']:12.2f}") + + +if __name__ == "__main__": + main() diff --git a/examples/seal_tolerance.xlsx b/examples/seal_tolerance.xlsx new file mode 100644 index 0000000..b237787 Binary files /dev/null and b/examples/seal_tolerance.xlsx differ diff --git a/pyproject.toml b/pyproject.toml index 4e61f9b..44cb529 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,13 @@ classifiers = [ "Operating System :: MacOS", ] +[project.optional-dependencies] +# The Microsoft Excel link is Windows-only; the rest of the app, including +# the in-app formula model, works everywhere without it. +excel = [ + "pywin32>=306; sys_platform == 'win32'", +] + [project.scripts] vatic = "vatic.app:main" @@ -76,6 +83,9 @@ issues = "https://github.com/eggzec/vatic/issues" dev = [ { include-group = "docs" }, { include-group = "lint" }, + { include-group = "test" }, + { include-group = "examples" }, + { include-group = "excel" }, ] docs = [ "zensical>=0.0.23", @@ -83,6 +93,15 @@ docs = [ lint = [ "ruff==0.15.*", ] +test = [ + "pytest>=8.0", +] +examples = [ + "openpyxl>=3.1", +] +excel = [ + "pywin32>=306; sys_platform == 'win32'", +] [tool.uv] package = true @@ -94,6 +113,23 @@ source = "vcs" include = [ "/vatic", ] +# Brand assets are not Python sources, so they must be named explicitly or the +# installed app falls back to a generic icon and a system font. +artifacts = [ + "/vatic/assets/*.png", + "/vatic/assets/*.svg", + "/vatic/assets/*.ico", + "/vatic/assets/fonts/*.ttf", + "/vatic/assets/fonts/OFL.txt", +] + +[tool.hatch.build.targets.sdist] +include = [ + "/vatic", + "/assets", + "/README.md", + "/LICENSE", +] [tool.ruff] # CI workflow definitions and the helper scripts they run are not @@ -140,3 +176,7 @@ ignore = [ [tool.ruff.format] docstring-code-format = true skip-magic-trailing-comma = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" diff --git a/tests/test_analytics.py b/tests/test_analytics.py new file mode 100644 index 0000000..c07da02 --- /dev/null +++ b/tests/test_analytics.py @@ -0,0 +1,184 @@ +# -------------------------------------*- vatic -*---------------------------- +# Open Source Risk Analysis +# +# Copyright (c) 2026, eggzec +# Contact: https://eggzec.github.io/ +# +# License: GNU General Public License +# Version 3, 29 June 2007 +# +# ---------------------------------------------------------------------------- +# +# Description +# Statistics and process-capability maths, checked against closed-form +# values rather than against the implementation itself. +# +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +import math + +import numpy as np +import pytest +from scipy import stats as sps + +from vatic.analytics import compute_capability_metrics, compute_statistics + + +def test_moments_match_closed_form() -> None: + """Mean, variance and extrema agree with hand computation.""" + values = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + result = compute_statistics(values) + + assert result["samples"] == 5.0 + assert result["mean"] == pytest.approx(3.0) + # Population variance, matching the reference implementation. + assert result["variance"] == pytest.approx(2.0) + # Sample standard deviation, ddof=1. + assert result["std"] == pytest.approx(math.sqrt(2.5)) + assert result["min"] == pytest.approx(1.0) + assert result["max"] == pytest.approx(5.0) + assert result["p50"] == pytest.approx(3.0) + + +def test_skewness_and_kurtosis_use_reference_conventions() -> None: + """Skewness is biased g1 and kurtosis is Pearson, not the excess form.""" + generator = np.random.default_rng(20260819) + values = generator.gamma(shape=2.0, scale=1.5, size=50_000) + + result = compute_statistics(values) + + # scipy's defaults are the biased estimators, which is what the reference + # implementation computes by hand. + assert result["skewness"] == pytest.approx(sps.skew(values), rel=1e-9) + assert result["kurtosis"] == pytest.approx( + sps.kurtosis(values, fisher=False), rel=1e-9 + ) + # Pearson and excess kurtosis differ by exactly three. + assert result["kurtosis_excess"] == pytest.approx(result["kurtosis"] - 3.0) + + +def test_symmetric_sample_has_zero_skew_and_normal_kurtosis() -> None: + """A large normal sample lands on skew 0 and Pearson kurtosis 3.""" + generator = np.random.default_rng(7) + values = generator.normal(loc=10.0, scale=2.0, size=200_000) + + result = compute_statistics(values) + + assert result["skewness"] == pytest.approx(0.0, abs=0.02) + assert result["kurtosis"] == pytest.approx(3.0, abs=0.05) + + +def test_constant_sample_reports_zero_shape() -> None: + """A zero-variance sample must not divide by zero.""" + result = compute_statistics(np.full(1000, 4.2)) + + assert result["variance"] == pytest.approx(0.0) + assert result["skewness"] == 0.0 + assert result["kurtosis"] == 0.0 + + +def test_empty_sample_is_rejected() -> None: + """An empty sample is a caller error, not a silent NaN.""" + with pytest.raises(ValueError): + compute_statistics(np.array([])) + + +def _capability_reference( + values: np.ndarray, lsl: float, usl: float, zshift: float = 1.5 +) -> dict[str, float]: + """Compute capability metrics independently for comparison. + + Args: + values: Sample to measure. + lsl: Lower spec limit. + usl: Upper spec limit. + zshift: Long-term shift applied to Zst. + + Returns: + The reference metrics. + """ + mean = float(np.mean(values)) + sigma = float(np.std(values, ddof=1)) + p_below = float(sps.norm.cdf((lsl - mean) / sigma)) + p_above = float(1.0 - sps.norm.cdf((usl - mean) / sigma)) + p_total = p_below + p_above + return { + "Cp": (usl - lsl) / (6.0 * sigma), + "Cpk": min((mean - lsl) / (3.0 * sigma), (usl - mean) / (3.0 * sigma)), + "p(N/C)-total": p_total, + "PPM-total": p_total * 1e6, + "Zst": float(-sps.norm.ppf(p_total)), + "Zlt": float(-sps.norm.ppf(p_total)) - zshift, + } + + +def test_capability_metrics_match_independent_reference() -> None: + """Cp, Cpk, PPM and the Z scores agree with a separate derivation.""" + generator = np.random.default_rng(1234) + values = generator.normal(loc=10.0, scale=1.0, size=100_000) + + metrics = compute_capability_metrics(values, lsl=7.0, usl=13.0, target=10.0) + expected = _capability_reference(values, lsl=7.0, usl=13.0) + + for key, value in expected.items(): + assert metrics[key] == pytest.approx(value, rel=1e-9), key + + +def test_capability_uses_the_cumulative_distribution() -> None: + """A centred 6-sigma process is near zero defects. + + The reference implementation used the probability *density* here, which + put the defect rate off by orders of magnitude, so this pins the + cumulative form down. + """ + generator = np.random.default_rng(99) + values = generator.normal(loc=0.0, scale=1.0, size=200_000) + + metrics = compute_capability_metrics(values, lsl=-6.0, usl=6.0, target=0.0) + + assert metrics["Cp"] == pytest.approx(2.0, rel=0.02) + assert metrics["PPM-total"] < 1.0 + assert metrics["Zst"] > 4.0 + + +def test_long_term_z_is_shifted_from_short_term() -> None: + """Zlt is Zst less the shift; the reference left them identical.""" + generator = np.random.default_rng(5) + values = generator.normal(loc=0.0, scale=1.0, size=50_000) + + metrics = compute_capability_metrics(values, lsl=-3.0, usl=3.0, target=0.0) + + assert metrics["Zst"] - metrics["Zlt"] == pytest.approx(1.5) + + +def test_cpm_is_penalised_by_being_off_target() -> None: + """Cpm falls below Cp once the mean drifts away from target. + + The reference implementation wrote this exponent with ``^``, which is + bitwise XOR in Python, so the metric could never be produced at all. + """ + generator = np.random.default_rng(11) + centred = generator.normal(loc=10.0, scale=1.0, size=50_000) + metrics = compute_capability_metrics( + centred, lsl=7.0, usl=13.0, target=10.0 + ) + assert metrics["Cpm"] == pytest.approx(metrics["Cp"], rel=0.02) + + drifted = centred + 1.5 + off = compute_capability_metrics(drifted, lsl=7.0, usl=13.0, target=10.0) + assert off["Cpm"] < off["Cp"] + + +def test_one_sided_specification_omits_two_sided_metrics() -> None: + """With only an upper limit there is no Cp and no total defect rate.""" + generator = np.random.default_rng(3) + values = generator.normal(loc=0.0, scale=1.0, size=20_000) + + metrics = compute_capability_metrics(values, lsl=None, usl=2.0, target=None) + + assert "Cp" not in metrics + assert "p(N/C)-total" not in metrics + assert "Cpk-upper" in metrics + assert "PPM-above" in metrics diff --git a/tests/test_excel_runner.py b/tests/test_excel_runner.py new file mode 100644 index 0000000..c994b17 --- /dev/null +++ b/tests/test_excel_runner.py @@ -0,0 +1,250 @@ +# -------------------------------------*- vatic -*---------------------------- +# Open Source Risk Analysis +# +# Copyright (c) 2026, eggzec +# Contact: https://eggzec.github.io/ +# +# License: GNU General Public License +# Version 3, 29 June 2007 +# +# ---------------------------------------------------------------------------- +# +# Description +# End-to-end simulation through a live Excel workbook. +# +# ---------------------------------------------------------------------------- +# +# Every test here drives a PRIVATE Excel instance created with DispatchEx and +# quits it afterwards, so a session the user has open is never touched. The +# whole module skips where Excel is unavailable, which is every non-Windows +# CI runner. +# +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +import gc +import math +from collections.abc import Iterator + +import numpy as np +import pytest + +from vatic.excel import excel_available +from vatic.sheetmodel import CellRef, SheetAssumption, SheetForecast, SheetModel + + +pytestmark = pytest.mark.skipif( + not excel_available(), reason="Microsoft Excel is not available here" +) + +MODEL_SHEET = "Model" + + +@pytest.fixture +def session() -> Iterator[object]: + """Open a private Excel instance holding a small live model. + + The model is ``out = a * 10 + b`` with ``a`` also feeding a second + forecast, and ``a`` deliberately holds a FORMULA so the restore path is + exercised on the case the reference implementation destroyed. + + Yields: + A connected :class:`~vatic.excel.session.ExcelSession`. + """ + from vatic.excel import ExcelSession + + link = ExcelSession(visible=False, private=True) + link.connect() + link.new_workbook() + book = link.workbook + sheet = book.Worksheets(1) + sheet.Name = MODEL_SHEET + sheet.Range("A1").Formula = "=0.5+0.5" # an assumption holding a formula + sheet.Range("A2").Value = 2.0 + sheet.Range("A3").Formula = "=A1*10+A2" + sheet.Range("A4").Formula = "=A1+A2" + del sheet, book + try: + yield link + finally: + # Excel proxies must be released before the process it serves exits, + # or their later collection raises RPC_E_DISCONNECTED. + gc.collect() + link.close() + + +@pytest.fixture +def model() -> SheetModel: + """Build the sheet model matching the fixture workbook. + + Returns: + A model with two assumptions and two forecasts. + """ + return SheetModel( + assumptions=[ + SheetAssumption(CellRef("A1", MODEL_SHEET), "a", "Normal", {}), + SheetAssumption(CellRef("A2", MODEL_SHEET), "b", "Normal", {}), + ], + forecasts=[ + SheetForecast(CellRef("A3", MODEL_SHEET), "out"), + SheetForecast(CellRef("A4", MODEL_SHEET), "total"), + ], + ) + + +def test_connects_and_lists_sheets(session) -> None: + """A connected session can see the workbook it attached to.""" + assert MODEL_SHEET in session.sheet_names() + + +def test_run_matches_numpy_exactly(session, model: SheetModel) -> None: + """Every trial Excel computes agrees with the same arithmetic in numpy.""" + from vatic.excel import ExcelRunner + + trials = 500 + samples = np.column_stack([ + np.linspace(1.0, 5.0, trials), + np.linspace(-2.0, 2.0, trials), + ]) + + result = ExcelRunner(session, model).run(samples) + + expected_out = samples[:, 0] * 10 + samples[:, 1] + expected_total = samples[:, 0] + samples[:, 1] + np.testing.assert_allclose(result.forecasts["out"], expected_out, rtol=1e-9) + np.testing.assert_allclose( + result.forecasts["total"], expected_total, rtol=1e-9 + ) + assert result.diagnostics.trials == trials + assert result.diagnostics.error_count == 0 + + +def test_inputs_are_returned_alongside_outputs( + session, model: SheetModel +) -> None: + """The sampled inputs come back so a tornado chart can be drawn.""" + from vatic.excel import ExcelRunner + + samples = np.column_stack([np.arange(50.0), np.arange(50.0) * -1]) + result = ExcelRunner(session, model).run(samples) + + np.testing.assert_allclose(result.inputs["a"], samples[:, 0]) + np.testing.assert_allclose(result.inputs["b"], samples[:, 1]) + + +def test_original_formula_is_restored_not_its_value( + session, model: SheetModel +) -> None: + """A1 holds a formula and must still hold it after a run. + + The reference implementation snapshotted the computed value and wrote + that back, permanently replacing the user's formula with a number. + """ + from vatic.excel import ExcelRunner + + before = session.read_formula(CellRef("A1", MODEL_SHEET)) + assert before.startswith("=") + + ExcelRunner(session, model).run(np.ones((25, 2))) + + after = session.read_formula(CellRef("A1", MODEL_SHEET)) + assert after == before, "the assumption cell's formula was destroyed" + + +def test_scratch_sheets_are_removed(session, model: SheetModel) -> None: + """The hidden helper sheets do not survive the run.""" + from vatic.excel import ExcelRunner + from vatic.excel.runner import TABLE_SHEET, TRIALS_SHEET + + ExcelRunner(session, model).run(np.ones((25, 2))) + + remaining = session.sheet_names() + assert TRIALS_SHEET not in remaining + assert TABLE_SHEET not in remaining + + +def test_application_settings_are_restored(session, model: SheetModel) -> None: + """Excel is handed back in the state it was found in.""" + from vatic.excel import ExcelRunner + + app = session.app + before = ( + bool(app.ScreenUpdating), + int(app.Calculation), + bool(app.EnableEvents), + ) + + ExcelRunner(session, model).run(np.ones((25, 2))) + + after = ( + bool(app.ScreenUpdating), + int(app.Calculation), + bool(app.EnableEvents), + ) + assert after == before + + +def test_worksheet_errors_are_counted_not_silently_zeroed( + session, model: SheetModel +) -> None: + """A #DIV/0! trial is reported as an error and left as NaN.""" + from vatic.excel import ExcelRunner + + # Make the second forecast divide by the first assumption. + session.write_formula(CellRef("A4", MODEL_SHEET), "=1/A1") + samples = np.column_stack([np.array([1.0, 0.0, 2.0, 0.0]), np.zeros(4)]) + + result = ExcelRunner(session, model).run(samples) + + assert result.diagnostics.error_count == 2 + assert result.diagnostics.errors["total"]["#DIV/0!"] == 2 + assert math.isnan(result.forecasts["total"][1]) + assert result.forecasts["total"][0] == pytest.approx(1.0) + + +def test_interior_capture_and_restore_round_trips(session) -> None: + """An unfilled cell is still unfilled after being highlighted.""" + ref = CellRef("D10", MODEL_SHEET) + original = session.capture_interior(ref) + assert original.is_unfilled + + session.highlight(ref, "#24AEFF") + assert not session.capture_interior(ref).is_unfilled + + session.restore_interior(ref, original) + assert session.capture_interior(ref).is_unfilled + + +def test_mismatched_sample_matrix_is_rejected( + session, model: SheetModel +) -> None: + """A matrix whose width does not match the model is a caller error.""" + from vatic.excel import ExcelRunner + + with pytest.raises(ValueError, match="columns"): + ExcelRunner(session, model).run(np.ones((10, 3))) + + +def test_cancellation_still_restores_the_workbook( + session, model: SheetModel +) -> None: + """Stopping mid-run leaves the workbook exactly as it was.""" + from vatic.excel import ExcelRunner + from vatic.excel.errors import SimulationCancelled + from vatic.excel.runner import TRIALS_SHEET + + before = session.read_formula(CellRef("A1", MODEL_SHEET)) + calls = {"n": 0} + + def should_cancel() -> bool: + calls["n"] += 1 + return calls["n"] > 2 + + with pytest.raises(SimulationCancelled): + ExcelRunner(session, model).run( + np.ones((100, 2)), should_cancel=should_cancel + ) + + assert session.read_formula(CellRef("A1", MODEL_SHEET)) == before + assert TRIALS_SHEET not in session.sheet_names() diff --git a/tests/test_formula.py b/tests/test_formula.py new file mode 100644 index 0000000..1567c9d --- /dev/null +++ b/tests/test_formula.py @@ -0,0 +1,81 @@ +# -------------------------------------*- vatic -*---------------------------- +# Open Source Risk Analysis +# +# Copyright (c) 2026, eggzec +# Contact: https://eggzec.github.io/ +# +# License: GNU General Public License +# Version 3, 29 June 2007 +# +# ---------------------------------------------------------------------------- +# +# Description +# The formula evaluator: the expressions the seal model needs, and the +# constructs it must refuse. +# +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +import math + +import pytest + +from vatic.formula import evaluate_formula + + +def test_arithmetic_and_precedence() -> None: + """Ordinary arithmetic evaluates as Python would.""" + assert evaluate_formula("2 + 3 * 4", {}) == 14 + assert evaluate_formula("(2 + 3) * 4", {}) == 20 + assert evaluate_formula("2 ** 3 ** 2", {}) == 512 + + +def test_named_variables_resolve() -> None: + """Assumptions and earlier forecasts are referenced by name.""" + context = {"revenue": 120.0, "cost": 78.0} + assert evaluate_formula("revenue - cost", context) == pytest.approx(42.0) + + +def test_pi_and_power_cover_the_seal_formulas() -> None: + """The Excel model translates directly once ^ becomes **.""" + context = {"cc": 0.75, "d": 0.071} + result = evaluate_formula("cc * pi * (d / 2) ** 2", context) + assert result == pytest.approx(0.75 * math.pi * (0.071 / 2) ** 2) + + +def test_whitelisted_functions_are_available() -> None: + """The keypad's functions all evaluate.""" + assert evaluate_formula("sqrt(16)", {}) == pytest.approx(4.0) + assert evaluate_formula("log(e)", {}) == pytest.approx(1.0) + assert evaluate_formula("abs(-3)", {}) == 3 + assert evaluate_formula("max(1, 7, 3)", {}) == 7 + assert evaluate_formula("min(1, 7, 3)", {}) == 1 + + +def test_unknown_name_is_rejected() -> None: + """A typo in a variable name fails loudly rather than resolving oddly.""" + with pytest.raises(ValueError, match="Unknown name"): + evaluate_formula("revenu - cost", {"revenue": 1.0, "cost": 1.0}) + + +@pytest.mark.parametrize( + "expression", + [ + "__import__('os').system('echo hi')", + "open('secret.txt').read()", + "(1).__class__.__bases__", + "[x for x in range(3)]", + "lambda: 1", + ], +) +def test_dangerous_expressions_are_refused(expression: str) -> None: + """The evaluator is a calculator, not a Python interpreter.""" + with pytest.raises((ValueError, SyntaxError)): + evaluate_formula(expression, {}) + + +def test_keyword_arguments_are_refused() -> None: + """Only positional calls are allowed through the validator.""" + with pytest.raises(ValueError): + evaluate_formula("round(1.234, ndigits=2)", {}) diff --git a/tests/test_seal_example.py b/tests/test_seal_example.py new file mode 100644 index 0000000..3217b79 --- /dev/null +++ b/tests/test_seal_example.py @@ -0,0 +1,155 @@ +# -------------------------------------*- vatic -*---------------------------- +# Open Source Risk Analysis +# +# Copyright (c) 2026, eggzec +# Contact: https://eggzec.github.io/ +# +# License: GNU General Public License +# Version 3, 29 June 2007 +# +# ---------------------------------------------------------------------------- +# +# Description +# End-to-end check of the Double-D seal gland example against the +# closed-form nominal values of the same model. +# +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +import math +import sys +from pathlib import Path + +import pytest + + +EXAMPLES = Path(__file__).resolve().parents[1] / "examples" +sys.path.insert(0, str(EXAMPLES)) + +seal_tolerance = pytest.importorskip("seal_tolerance") + + +def _nominal() -> dict[str, float]: + """Evaluate the seal model at nominal dimensions, by hand. + + Returns: + The nominal value of every characteristic in the model. + """ + dims = {k: v[0] for k, v in seal_tolerance.DIMENSIONS.items()} + core_hole_area = ( + dims["core_compression"] + * math.pi + * (dims["core_hole_diameter"] / 2) ** 2 + ) + seal_area = ( + math.pi * (dims["seal_width"] / 4) ** 2 + + dims["seal_width"] * (dims["seal_height"] - dims["seal_width"] / 4) + - core_hole_area + ) + groove_area = dims["groove_width"] * ( + dims["groove_height"] + dims["lid_flatness"] + dims["box_flatness"] + ) + return { + "core_hole_area": core_hole_area, + "seal_area": seal_area, + "groove_area": groove_area, + "gland_fill": seal_area / groove_area, + "seal_compression": 1 - dims["groove_height"] / dims["seal_height"], + } + + +@pytest.fixture(scope="module") +def results() -> dict[str, dict[str, float]]: + """Run the stack-up once for the whole module. + + Returns: + Statistics keyed by characteristic name. + """ + return seal_tolerance.run() + + +def test_every_characteristic_is_reported(results) -> None: + """All three intermediates and both outputs come back.""" + assert set(results) == { + "core_hole_area", + "seal_area", + "groove_area", + "gland_fill", + "seal_compression", + } + + +@pytest.mark.parametrize( + "name", + [ + "core_hole_area", + "seal_area", + "groove_area", + "gland_fill", + "seal_compression", + ], +) +def test_mean_tracks_the_nominal_value(results, name: str) -> None: + """Each simulated mean sits on the deterministic nominal value. + + Every input is symmetric about its nominal, so the mean of a + near-linear characteristic must land on the nominal result. + """ + expected = _nominal()[name] + assert results[name]["mean"] == pytest.approx(expected, rel=5e-3) + + +def test_groove_area_is_exact_at_nominal(results) -> None: + """Groove area is linear in its inputs, so its mean is exact.""" + assert results["groove_area"]["mean"] == pytest.approx( + _nominal()["groove_area"], rel=1e-3 + ) + + +def test_seal_compression_meets_its_specification(results) -> None: + """Seal compression sits inside 0.25 .. 0.50 with capability to spare.""" + stats = results["seal_compression"] + assert 0.25 < stats["mean"] < 0.50 + assert stats["Cpk"] > 1.0 + assert stats["PPM-total"] < 10_000.0 + + +def test_gland_fill_breaches_its_upper_limit(results) -> None: + """Gland fill overfills at nominal, which the metrics must surface. + + The nominal design sits above the 1.00 upper limit, so a negative Cpk + and a large defect rate are the correct answer, not a bug. + """ + stats = results["gland_fill"] + assert stats["mean"] > 1.0 + assert stats["Cpk"] < 0.0 + assert stats["PPM-total"] > 100_000.0 + + +def test_outputs_are_approximately_normal(results) -> None: + """Near-linear combinations of normals stay near-normal.""" + for name in ("gland_fill", "seal_compression"): + stats = results[name] + assert abs(stats["skewness"]) < 0.3 + assert stats["kurtosis"] == pytest.approx(3.0, abs=0.4) + + +def test_run_is_reproducible_when_seeded() -> None: + """The same seed reproduces the run exactly.""" + first = seal_tolerance.run(seed=4242) + second = seal_tolerance.run(seed=4242) + + for name, stats in first.items(): + for key, value in stats.items(): + assert second[name][key] == pytest.approx(value, rel=1e-12), ( + f"{name}.{key}" + ) + + +def test_different_seeds_give_different_samples() -> None: + """A different seed is a genuinely different sample set.""" + first = seal_tolerance.run(seed=1) + second = seal_tolerance.run(seed=2) + + assert first["gland_fill"]["mean"] != second["gland_fill"]["mean"] diff --git a/tests/test_sheetmodel.py b/tests/test_sheetmodel.py new file mode 100644 index 0000000..c53c82f --- /dev/null +++ b/tests/test_sheetmodel.py @@ -0,0 +1,228 @@ +# -------------------------------------*- vatic -*---------------------------- +# Open Source Risk Analysis +# +# Copyright (c) 2026, eggzec +# Contact: https://eggzec.github.io/ +# +# License: GNU General Public License +# Version 3, 29 June 2007 +# +# ---------------------------------------------------------------------------- +# +# Description +# The spreadsheet data model and the Excel error decoder. Both are pure +# Python, so these run on every platform whether Excel exists or not. +# +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +import pytest + +from vatic.excel import describe_error_value, is_error_value +from vatic.excel.errors import ( + ExcelBusyError, + ExcelLinkError, + ExcelNotAvailableError, + SheetProtectedError, + WorkbookReadOnlyError, + classify, +) +from vatic.sheetmodel import ( + CellRef, + InteriorState, + ReferenceError, + SheetAssumption, + SheetForecast, + SheetModel, +) + + +@pytest.mark.parametrize( + ("text", "cell", "sheet", "workbook"), + [ + ("C8", "C8", None, None), + ("$C$8", "C8", None, None), + ("c8", "C8", None, None), + ("Sheet1!C8", "C8", "Sheet1", None), + ("'Seal-Groove Design'!$C$8", "C8", "Seal-Groove Design", None), + ("[Book1.xlsx]Sheet1!C8", "C8", "Sheet1", "Book1.xlsx"), + (" Sheet1!C8 ", "C8", "Sheet1", None), + ("AA1048576", "AA1048576", None, None), + ], +) +def test_reference_parsing( + text: str, cell: str, sheet: str | None, workbook: str | None +) -> None: + """A1 references parse in every form Excel writes them.""" + ref = CellRef.parse(text) + assert ref.cell == cell + assert ref.sheet == sheet + assert ref.workbook == workbook + + +@pytest.mark.parametrize( + "text", ["", "not a cell", "8C", "Sheet1!", "!C8", "C", "8"] +) +def test_bad_references_are_rejected(text: str) -> None: + """Anything that is not a reference fails loudly.""" + with pytest.raises(ReferenceError): + CellRef.parse(text) + + +def test_reference_column_and_row() -> None: + """Column letters and row numbers come back separately.""" + ref = CellRef.parse("Sheet1!$AB$27") + assert ref.column == "AB" + assert ref.row == 27 + + +def test_qualified_quotes_sheets_that_need_it() -> None: + """A sheet name with punctuation is quoted the way Excel expects.""" + assert CellRef("C8", "Sheet1").qualified() == "Sheet1!C8" + assert ( + CellRef("C8", "Seal-Groove Design").qualified() + == "'Seal-Groove Design'!C8" + ) + assert CellRef("C8").qualified() == "C8" + + +def test_unfilled_interior_is_recognised() -> None: + """An unfilled cell is identified by its pattern, not its colour. + + Excel reports ``Color == 16777215`` for a cell with no fill, so restoring + the colour alone would leave a solid white fill behind. + """ + unfilled = InteriorState(color=16777215, pattern=-4142) + filled = InteriorState(color=16777215, pattern=1) + assert unfilled.is_unfilled + assert not filled.is_unfilled + + +def _model() -> SheetModel: + """Build a small valid model. + + Returns: + A model with one assumption and one forecast. + """ + return SheetModel( + workbook="book.xlsx", + assumptions=[ + SheetAssumption(CellRef("C8", "S"), "height", "Normal", {"mu": 1}) + ], + forecasts=[SheetForecast(CellRef("C30", "S"), "fill", lsl=0.75)], + ) + + +def test_valid_model_passes_validation() -> None: + """A model with inputs and outputs validates.""" + _model().validate() + + +def test_model_needs_assumptions_and_forecasts() -> None: + """Neither half of the model may be empty.""" + with pytest.raises(ValueError, match="assumption"): + SheetModel(forecasts=_model().forecasts).validate() + with pytest.raises(ValueError, match="forecast"): + SheetModel(assumptions=_model().assumptions).validate() + + +def test_duplicate_tags_are_rejected() -> None: + """Two variables cannot share a name.""" + model = _model() + model.forecasts[0].tag = "height" + with pytest.raises(ValueError, match="Duplicate tag"): + model.validate() + + +def test_a_cell_cannot_be_both_input_and_output() -> None: + """Tagging one cell as both would make the run self-referential.""" + model = _model() + model.forecasts[0].ref = CellRef("C8", "S") + with pytest.raises(ValueError, match="both an assumption and a forecast"): + model.validate() + + +def test_the_same_cell_cannot_be_two_assumptions() -> None: + """Two assumptions writing the same cell would fight each other.""" + model = _model() + model.assumptions.append( + SheetAssumption(CellRef("C8", "S"), "other", "Normal", {"mu": 2}) + ) + with pytest.raises(ValueError, match="two assumptions"): + model.validate() + + +def test_model_serialises_to_plain_data() -> None: + """Assumptions and forecasts round-trip through plain dicts.""" + model = _model() + assumption = model.assumptions[0].as_dict() + assert assumption["cell"] == "S!C8" + assert assumption["distribution"] == "Normal" + forecast = model.forecasts[0].as_dict() + assert forecast["cell"] == "S!C30" + assert forecast["lsl"] == 0.75 + + +@pytest.mark.parametrize( + ("value", "name"), + [ + (-2146826281, "#DIV/0!"), + (-2146826246, "#N/A"), + (-2146826259, "#NAME?"), + (-2146826265, "#REF!"), + (-2146826273, "#VALUE!"), + ], +) +def test_worksheet_errors_are_decoded(value: int, name: str) -> None: + """Excel's error sentinels are named rather than treated as numbers.""" + assert describe_error_value(value) == name + assert is_error_value(value) + + +@pytest.mark.parametrize("value", [0, 1, 3.5, -1, "text", None, True, False]) +def test_real_values_are_not_mistaken_for_errors(value: object) -> None: + """Ordinary cell values pass through untouched.""" + assert describe_error_value(value) is None + assert not is_error_value(value) + + +def test_com_failures_are_classified() -> None: + """A COM error becomes a typed failure with advice attached.""" + read_only = classify(Exception("The workbook is read-only")) + assert isinstance(read_only, WorkbookReadOnlyError) + assert read_only.advice + + protected = classify(Exception("The sheet is protected")) + assert isinstance(protected, SheetProtectedError) + + unknown = classify(Exception("something odd")) + assert isinstance(unknown, ExcelLinkError) + + +def test_known_hresults_are_named() -> None: + """A missing registration reads as 'Excel is not installed'.""" + + class FakeComError(Exception): + hresult = -2147221164 + + error = classify(FakeComError("class not registered")) + assert isinstance(error, ExcelNotAvailableError) + + class BusyError(Exception): + hresult = -2147418111 + + assert isinstance(classify(BusyError("rejected")), ExcelBusyError) + + +def test_typed_errors_already_pass_through() -> None: + """Classifying an already-typed error does not re-wrap it.""" + original = SheetProtectedError("protected") + assert classify(original) is original + + +def test_user_message_includes_advice() -> None: + """The dialog text tells the user what to actually do.""" + message = ExcelNotAvailableError("no Excel").user_message() + assert "no Excel" in message + assert "Microsoft Excel" in message diff --git a/tests/test_theme.py b/tests/test_theme.py new file mode 100644 index 0000000..28cd100 --- /dev/null +++ b/tests/test_theme.py @@ -0,0 +1,256 @@ +# -------------------------------------*- vatic -*---------------------------- +# Open Source Risk Analysis +# +# Copyright (c) 2026, eggzec +# Contact: https://eggzec.github.io/ +# +# License: GNU General Public License +# Version 3, 29 June 2007 +# +# ---------------------------------------------------------------------------- +# +# Description +# Guards the brand rules: a closed palette taken from the logo, and +# legible contrast everywhere text meets a surface. +# +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +import colorsys +import re +from pathlib import Path + +import pytest + +from vatic.theme import ( + BRAND_HUES, + TOKENS, + audit_contrast, + build_stylesheet, + contrast, + shade, + tint, +) + + +#: Hue angle of each logo colour, in degrees. +BRAND_HUE_ANGLES = (240.0, 260.0, 279.0, 202.0) +HUE_TOLERANCE = 10.0 + + +def _channel_spread(colour: str) -> int: + """Return how far apart a colour's channels are. + + A near-white such as ``#FAFBFD`` reports a high HLS saturation despite + being visually neutral, so neutrality is judged on the raw spread. + + Args: + colour: Hex colour string. + + Returns: + The difference between the largest and smallest channel, 0..255. + """ + raw = colour.lstrip("#") + channels = [int(raw[i : i + 2], 16) for i in (0, 2, 4)] + return max(channels) - min(channels) + + +def _hue_and_saturation(colour: str) -> tuple[float, float]: + """Return the hue angle and saturation of a hex colour. + + Args: + colour: Hex colour string. + + Returns: + Hue in degrees and saturation from 0.0 to 1.0. + """ + raw = colour.lstrip("#") + red, green, blue = (int(raw[i : i + 2], 16) / 255 for i in (0, 2, 4)) + hue, _lightness, saturation = colorsys.rgb_to_hls(red, green, blue) + return hue * 360.0, saturation + + +#: Tokens that carry brand identity and must therefore sit on a logo hue. +BRAND_TOKENS = ( + "accent", + "accent.violet", + "accent.magenta", + "accent.cyan", + "border.focus", + "ink.brand", + "selection.bg", + "wash.cyan", + "wash.blue", + "wash.violet", + "wash.magenta", +) + + +@pytest.mark.parametrize("name", BRAND_TOKENS) +def test_brand_tokens_sit_on_a_logo_hue(name: str) -> None: + """Anything that carries identity uses a colour from the logo.""" + hue, saturation = _hue_and_saturation(TOKENS[name]) + assert saturation > 0.05, f"{name} is not a colour at all" + assert any( + abs(hue - angle) < HUE_TOLERANCE for angle in BRAND_HUE_ANGLES + ), f"{name}={TOKENS[name]} has hue {hue:.0f}, outside the logo palette" + + +@pytest.mark.parametrize( + "name", + [ + "ink.strong", + "ink.body", + "ink.muted", + "ink.placeholder", + "surface.sunken", + "surface.stripe", + "border.hairline", + "border.subtle", + ], +) +def test_text_and_surfaces_are_neutral(name: str) -> None: + """Ink and paper stay neutral so text is easy to read. + + Saturated blue body text passes the contrast threshold but is tiring + over a long session and competes with the accents, so the ink scale is + a cool near-black rather than the brand blue. + """ + assert _channel_spread(TOKENS[name]) <= 40, ( + f"{name}={TOKENS[name]} is too colourful for text or paper" + ) + + +def test_body_ink_is_high_contrast() -> None: + """Body text is comfortably past the AA threshold, not just over it.""" + assert contrast(TOKENS["ink.body"], TOKENS["surface.panel"]) > 12.0 + + +def test_background_is_white() -> None: + """White is the dominant surface, not a tinted near-white.""" + assert TOKENS["surface.canvas"] == "#FFFFFF" + assert TOKENS["surface.panel"] == "#FFFFFF" + + +def test_every_audited_pairing_meets_wcag_aa() -> None: + """Text clears 4.5:1 and meaningful borders clear 3:1.""" + failures = [ + (foreground, background, ratio) + for foreground, background, ratio in audit_contrast() + if ratio < (3.0 if foreground.startswith("border") else 4.5) + ] + assert not failures, f"contrast failures: {failures}" + + +def test_accent_is_a_logo_hue() -> None: + """The primary action is painted in a colour from the icon.""" + assert TOKENS["accent"] in BRAND_HUES + + +def test_accent_works_as_a_fill_and_as_text() -> None: + """The accent is legible under white text and as text on white. + + Only the brand blue satisfies both: cyan is 2.45:1 on white and cannot + carry a white label, and magenta clears neither threshold for body text. + """ + assert contrast(TOKENS["ink.onAccent"], TOKENS["accent"]) >= 4.5 + assert contrast(TOKENS["accent"], TOKENS["surface.panel"]) >= 4.5 + + +def test_panel_washes_are_light() -> None: + """Every panel wash stays close to white so the app reads bright.""" + for name in ("wash.cyan", "wash.blue", "wash.violet", "wash.magenta"): + assert contrast(TOKENS[name], "#FFFFFF") < 1.35, name + + +def test_tint_and_shade_are_monotonic() -> None: + """Tinting moves toward white and shading moves away from it.""" + base = TOKENS["accent"] + assert contrast(tint(base, 0.2), "#FFFFFF") < contrast(base, "#FFFFFF") + assert contrast(shade(base, 0.5), "#FFFFFF") > contrast(base, "#FFFFFF") + + +def test_stylesheet_has_no_unresolved_placeholders() -> None: + """Every token referenced by the style sheet is defined.""" + sheet = build_stylesheet() + leftovers = re.findall(r"\{[a-zA-Z_]+\}", sheet) + assert not leftovers, f"unsubstituted tokens: {sorted(set(leftovers))}" + + +def test_stylesheet_uses_rgba_not_eight_digit_hex() -> None: + """Qt reads #AARRGGBB, so an eight digit hex is almost always a bug.""" + sheet = build_stylesheet() + assert not re.findall(r"#[0-9A-Fa-f]{8}\b", sheet) + + +def test_stylesheet_icon_assets_exist() -> None: + """Every url() the style sheet points at is actually shipped.""" + sheet = build_stylesheet() + for path in re.findall(r"url\(([^)]+)\)", sheet): + assert Path(path).exists(), path + + +def test_no_rule_paints_light_text_on_a_light_background() -> None: + """No style sheet rule may set an unreadable colour pair. + + The token audit only covers pairs the palette declares. This walks the + generated style sheet itself and checks every rule that sets both a + colour and a background, which is how a white-on-near-white disabled + button slipped through. + """ + sheet = build_stylesheet() + failures = [] + for selector, body in re.findall(r"([^{}]+)\{([^{}]*)\}", sheet): + foreground = re.search( + r"(? None: + """Wherever a selection colour pair is set, it must be legible. + + An item view draws cell text through its delegate, which uses + ``selection-color`` rather than the ``::item:selected`` rule. A rule that + changes the selection background without changing the text colour to + match leaves white text on a pale row. + """ + sheet = build_stylesheet() + failures = [] + for selector, body in re.findall(r"([^{}]+)\{([^{}]*)\}", sheet): + background = re.search( + r"selection-background-color\s*:\s*(#[0-9A-Fa-f]{6})\s*;", body + ) + foreground = re.search( + r"selection-color\s*:\s*(#[0-9A-Fa-f]{6})\s*;", body + ) + if not background: + continue + assert foreground, ( + f"{selector.strip()} sets a selection background but no " + "selection-color, so the text colour is inherited and may not " + "suit it" + ) + ratio = contrast(foreground.group(1), background.group(1)) + if ratio < 4.5: + failures.append(( + selector.strip(), + foreground.group(1), + background.group(1), + round(ratio, 2), + )) + assert not failures, f"unreadable selections: {failures}" diff --git a/uv.lock b/uv.lock index 42aaadd..869576e 100644 --- a/uv.lock +++ b/uv.lock @@ -119,6 +119,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -412,52 +430,16 @@ wheels = [ ] [[package]] -name = "vatic" -source = { editable = "." } +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "kaleido" }, - { name = "mcerp" }, - { name = "numpy" }, - { name = "plotly" }, - { name = "pyside6" }, - { name = "python-pptx" }, - { name = "reportlab" }, - { name = "rich" }, - { name = "scipy" }, -] - -[package.dev-dependencies] -dev = [ - { name = "ruff" }, - { name = "zensical" }, -] -docs = [ - { name = "zensical" }, -] -lint = [ - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "kaleido", specifier = ">=0.2.1" }, - { name = "mcerp", specifier = ">=0.12" }, - { name = "numpy", specifier = ">=2.1" }, - { name = "plotly", specifier = ">=5.24" }, - { name = "pyside6", specifier = ">=6.8,<6.10" }, - { name = "python-pptx", specifier = ">=1.0.2" }, - { name = "reportlab", specifier = ">=4.2" }, - { name = "rich", specifier = ">=13.7" }, - { name = "scipy", specifier = ">=1.14" }, + { name = "et-xmlfile" }, ] - -[package.metadata.requires-dev] -dev = [ - { name = "ruff", specifier = "==0.15.*" }, - { name = "zensical", specifier = ">=0.0.23" }, +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, ] -docs = [{ name = "zensical", specifier = ">=0.0.23" }] -lint = [{ name = "ruff", specifier = "==0.15.*" }] [[package]] name = "orjson" @@ -612,6 +594,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl", hash = "sha256:ac8aca1c25c663a59b5b9140a549264a5badde2e057d79b8c772ae2920e32ff0", size = 9898444, upload-time = "2026-04-09T20:36:39.812Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -682,6 +673,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/22/4ec828f6360e6e9bcd6fde2dec20fa4865fe1a77cb6bac6812f7d0aa55b3/pyside6_essentials-6.9.3-cp39-abi3-win_arm64.whl", hash = "sha256:2e34081933e005686d79265cc04370a28fea3844ab63d432e493adcd4465070c", size = 54301788, upload-time = "2025-09-30T12:07:49.504Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "python-pptx" version = "1.0.2" @@ -697,6 +704,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -974,6 +1000,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "vatic" +source = { editable = "." } +dependencies = [ + { name = "kaleido" }, + { name = "mcerp" }, + { name = "numpy" }, + { name = "plotly" }, + { name = "pyside6" }, + { name = "python-pptx" }, + { name = "reportlab" }, + { name = "rich" }, + { name = "scipy" }, +] + +[package.optional-dependencies] +excel = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "openpyxl" }, + { name = "pytest" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "ruff" }, + { name = "zensical" }, +] +docs = [ + { name = "zensical" }, +] +examples = [ + { name = "openpyxl" }, +] +excel = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +lint = [ + { name = "ruff" }, +] +test = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "kaleido", specifier = ">=0.2.1" }, + { name = "mcerp", specifier = ">=0.12" }, + { name = "numpy", specifier = ">=2.1" }, + { name = "plotly", specifier = ">=5.24" }, + { name = "pyside6", specifier = ">=6.8,<6.10" }, + { name = "python-pptx", specifier = ">=1.0.2" }, + { name = "pywin32", marker = "sys_platform == 'win32' and extra == 'excel'", specifier = ">=306" }, + { name = "reportlab", specifier = ">=4.2" }, + { name = "rich", specifier = ">=13.7" }, + { name = "scipy", specifier = ">=1.14" }, +] +provides-extras = ["excel"] + +[package.metadata.requires-dev] +dev = [ + { name = "openpyxl", specifier = ">=3.1" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=306" }, + { name = "ruff", specifier = "==0.15.*" }, + { name = "zensical", specifier = ">=0.0.23" }, +] +docs = [{ name = "zensical", specifier = ">=0.0.23" }] +examples = [{ name = "openpyxl", specifier = ">=3.1" }] +excel = [{ name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=306" }] +lint = [{ name = "ruff", specifier = "==0.15.*" }] +test = [{ name = "pytest", specifier = ">=8.0" }] + [[package]] name = "xlsxwriter" version = "3.2.9" diff --git a/vatic/__init__.py b/vatic/__init__.py index 2004732..69cd717 100644 --- a/vatic/__init__.py +++ b/vatic/__init__.py @@ -18,6 +18,7 @@ # ---------------------------------------------------------------------------- from importlib.metadata import PackageNotFoundError, version +from typing import TYPE_CHECKING try: # noqa RUF067 @@ -25,7 +26,34 @@ except PackageNotFoundError: __version__ = "unknown" -from vatic.app import main + +if TYPE_CHECKING: # pragma: no cover - import-time only + from vatic.app import main __all__ = ["__version__", "main"] + + +def __getattr__(name: str) -> object: + """Import the Qt entry point only when it is actually asked for. + + Importing it eagerly meant that ``import vatic.analytics`` pulled in the + whole Qt stack, QtWebEngine included, so the numerical and theme modules + could not be imported at all on a head-less machine without the system + libraries Qt needs. They have no Qt dependency of their own, and now + nothing forces one on them. + + Args: + name: Attribute being looked up on the package. + + Returns: + The requested attribute. + + Raises: + AttributeError: If the name is not part of the public surface. + """ + if name == "main": + from vatic.app import main + + return main + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/vatic/analytics.py b/vatic/analytics.py index d40bda5..f260211 100644 --- a/vatic/analytics.py +++ b/vatic/analytics.py @@ -38,10 +38,31 @@ def compute_statistics(values: np.ndarray) -> dict[str, float]: LOGGER.debug("Computing statistics | sample_count=%s", values.size) percentiles = np.percentile(values, [1, 5, 50, 95, 99]) + + # Moments follow the original vatic conventions so results line up with + # the reference implementation: the variance is the population variance + # (ddof=0), skewness is the standardised third moment with no sample-size + # correction, and kurtosis is the Pearson form where a normal + # distribution sits at 3.0 rather than the excess form centred on zero. + mean = float(np.mean(values)) + deviations = values - mean + variance = float(np.mean(deviations**2)) + population_sigma = float(np.sqrt(variance)) + if population_sigma <= 1e-8: + skewness = 0.0 + kurtosis = 0.0 + else: + skewness = float(np.mean(deviations**3) / population_sigma**3) + kurtosis = float(np.mean(deviations**4) / population_sigma**4) + return { "samples": float(values.size), - "mean": float(np.mean(values)), + "mean": mean, "std": float(np.std(values, ddof=1)) if values.size > 1 else 0.0, + "variance": variance, + "skewness": skewness, + "kurtosis": kurtosis, + "kurtosis_excess": kurtosis - 3.0 if population_sigma > 1e-8 else 0.0, "min": float(np.min(values)), "max": float(np.max(values)), "p01": float(percentiles[0]), diff --git a/vatic/app.py b/vatic/app.py index c59291f..c50ce35 100644 --- a/vatic/app.py +++ b/vatic/app.py @@ -27,6 +27,11 @@ from PySide6.QtWidgets import QApplication from vatic.logger import configure_logging, emit_startup_banner, get_logger +from vatic.resources import ( + app_icon, + load_bundled_fonts, + register_app_user_model_id, +) from vatic.window import VaticWindow @@ -38,8 +43,20 @@ def main() -> None: emit_startup_banner() LOGGER.debug("Launching vatic application | argv=%s", sys.argv) + # Must run before the first window exists, otherwise Windows has already + # bound the task bar button to the host interpreter's identity. + register_app_user_model_id() + app = QApplication(sys.argv) app.setApplicationName("vatic") + app.setApplicationDisplayName("vatic") + app.setOrganizationName("eggzec") + app.setWindowIcon(app_icon()) + + # Registered after QApplication exists but before any widget is + # built, so the style sheet's font stack resolves to the bundled + # family rather than a system fallback. + load_bundled_fonts() window = VaticWindow() window.show() diff --git a/vatic/assets/check.svg b/vatic/assets/check.svg new file mode 100644 index 0000000..d7a5d15 --- /dev/null +++ b/vatic/assets/check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vatic/assets/chevron-down-hover.svg b/vatic/assets/chevron-down-hover.svg new file mode 100644 index 0000000..051c5eb --- /dev/null +++ b/vatic/assets/chevron-down-hover.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vatic/assets/chevron-down-muted.svg b/vatic/assets/chevron-down-muted.svg new file mode 100644 index 0000000..92292ae --- /dev/null +++ b/vatic/assets/chevron-down-muted.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vatic/assets/chevron-down.svg b/vatic/assets/chevron-down.svg new file mode 100644 index 0000000..72b915c --- /dev/null +++ b/vatic/assets/chevron-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vatic/assets/chevron-up-hover.svg b/vatic/assets/chevron-up-hover.svg new file mode 100644 index 0000000..db907b0 --- /dev/null +++ b/vatic/assets/chevron-up-hover.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vatic/assets/chevron-up.svg b/vatic/assets/chevron-up.svg new file mode 100644 index 0000000..0f4ad58 --- /dev/null +++ b/vatic/assets/chevron-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vatic/assets/fonts/JetBrainsMono-Bold.ttf b/vatic/assets/fonts/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000..8c93043 Binary files /dev/null and b/vatic/assets/fonts/JetBrainsMono-Bold.ttf differ diff --git a/vatic/assets/fonts/JetBrainsMono-Medium.ttf b/vatic/assets/fonts/JetBrainsMono-Medium.ttf new file mode 100644 index 0000000..9767115 Binary files /dev/null and b/vatic/assets/fonts/JetBrainsMono-Medium.ttf differ diff --git a/vatic/assets/fonts/JetBrainsMono-Regular.ttf b/vatic/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..dff66cc Binary files /dev/null and b/vatic/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/vatic/assets/fonts/JetBrainsMono-SemiBold.ttf b/vatic/assets/fonts/JetBrainsMono-SemiBold.ttf new file mode 100644 index 0000000..a70e69b Binary files /dev/null and b/vatic/assets/fonts/JetBrainsMono-SemiBold.ttf differ diff --git a/vatic/assets/fonts/OFL.txt b/vatic/assets/fonts/OFL.txt new file mode 100644 index 0000000..8bee414 --- /dev/null +++ b/vatic/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/vatic/assets/vatic-banner.png b/vatic/assets/vatic-banner.png new file mode 100644 index 0000000..4090193 Binary files /dev/null and b/vatic/assets/vatic-banner.png differ diff --git a/vatic/assets/vatic-icon-128.png b/vatic/assets/vatic-icon-128.png new file mode 100644 index 0000000..dccc404 Binary files /dev/null and b/vatic/assets/vatic-icon-128.png differ diff --git a/vatic/assets/vatic-icon-16.png b/vatic/assets/vatic-icon-16.png new file mode 100644 index 0000000..0aea40a Binary files /dev/null and b/vatic/assets/vatic-icon-16.png differ diff --git a/vatic/assets/vatic-icon-24.png b/vatic/assets/vatic-icon-24.png new file mode 100644 index 0000000..d45645f Binary files /dev/null and b/vatic/assets/vatic-icon-24.png differ diff --git a/vatic/assets/vatic-icon-256.png b/vatic/assets/vatic-icon-256.png new file mode 100644 index 0000000..6b76856 Binary files /dev/null and b/vatic/assets/vatic-icon-256.png differ diff --git a/vatic/assets/vatic-icon-32.png b/vatic/assets/vatic-icon-32.png new file mode 100644 index 0000000..7fbf648 Binary files /dev/null and b/vatic/assets/vatic-icon-32.png differ diff --git a/vatic/assets/vatic-icon-48.png b/vatic/assets/vatic-icon-48.png new file mode 100644 index 0000000..0054b49 Binary files /dev/null and b/vatic/assets/vatic-icon-48.png differ diff --git a/vatic/assets/vatic-icon-512.png b/vatic/assets/vatic-icon-512.png new file mode 100644 index 0000000..654688a Binary files /dev/null and b/vatic/assets/vatic-icon-512.png differ diff --git a/vatic/assets/vatic-icon-64.png b/vatic/assets/vatic-icon-64.png new file mode 100644 index 0000000..1910e2b Binary files /dev/null and b/vatic/assets/vatic-icon-64.png differ diff --git a/vatic/assets/vatic-icon.png b/vatic/assets/vatic-icon.png new file mode 100644 index 0000000..b6429da Binary files /dev/null and b/vatic/assets/vatic-icon.png differ diff --git a/vatic/assets/vatic-icon.svg b/vatic/assets/vatic-icon.svg new file mode 100644 index 0000000..34fcf39 --- /dev/null +++ b/vatic/assets/vatic-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/vatic/assets/vatic.ico b/vatic/assets/vatic.ico new file mode 100644 index 0000000..6237d9a Binary files /dev/null and b/vatic/assets/vatic.ico differ diff --git a/vatic/chart.py b/vatic/chart.py index b694408..479ccc6 100644 --- a/vatic/chart.py +++ b/vatic/chart.py @@ -22,45 +22,158 @@ from __future__ import annotations +import json +import tempfile +from pathlib import Path + import numpy as np import plotly.graph_objects as go +from plotly.offline import get_plotlyjs +from PySide6.QtCore import QUrl from PySide6.QtWebEngineWidgets import QWebEngineView from scipy import stats from vatic.logger import get_logger +from vatic.theme import CHART_SEQUENCE, TOKENS, WHITE +from vatic.theme import alpha as rgba LOGGER = get_logger(__name__) +CHART_PAPER = WHITE +CHART_INK = TOKENS["ink.body"] +CHART_MUTED = TOKENS["ink.muted"] +CHART_GRID = TOKENS["border.hairline"] +CHART_AXIS = TOKENS["border.subtle"] + +PLOT_CONFIG = {"displaylogo": False, "responsive": True, "scrollZoom": True} + +_SHELL_HTML = """ + + +
+""" + + +def _plotly_asset_dir() -> Path: + """Return a directory holding a local copy of ``plotly.min.js``. + + The bundled copy is written once per machine so charts never depend on a + CDN round-trip and keep working with no network at all. + + Returns: + Directory containing ``plotly.min.js``. + """ + directory = Path(tempfile.gettempdir()) / "vatic-plotly" + directory.mkdir(parents=True, exist_ok=True) + + script = directory / "plotly.min.js" + payload = get_plotlyjs() + if not script.exists() or script.stat().st_size != len(payload.encode()): + script.write_text(payload, encoding="utf-8") + LOGGER.debug("Cached plotly.js | path=%s", script) + return directory + class PlotCanvas(QWebEngineView): + #: Page background, kept in step with the chart paper colour so resizing + #: and re-plotting never flashes a foreign colour. + BACKGROUND = "#FFFFFF" + def __init__(self) -> None: super().__init__() self.setContextMenuPolicy(self.contextMenuPolicy()) + + self._ready = False + self._pending: go.Figure | None = None + + asset_dir = _plotly_asset_dir() + self.loadFinished.connect(self._on_load_finished) + self.setHtml( + _SHELL_HTML.format(background=self.BACKGROUND), + QUrl.fromLocalFile(f"{asset_dir}/"), + ) + self.draw_message("Run a simulation to display charts") + def _on_load_finished(self, ok: bool) -> None: + """Flush any figure that was requested before the page was ready. + + Args: + ok: Whether the shell document loaded successfully. + """ + self._ready = bool(ok) + if not ok: + LOGGER.warning("Chart shell failed to load; charts unavailable") + return + + LOGGER.debug("Chart shell ready") + if self._pending is not None: + figure, self._pending = self._pending, None + self._render(figure) + def _base_layout(self, title: str) -> go.Layout: + """Return the shared brand layout for every figure. + + Args: + title: Chart title. + + Returns: + A layout carrying the vatic palette, so that the charts and the + application chrome read as a single system. + """ return go.Layout( - title=title, + title=dict(text=title, font=dict(color=CHART_INK, size=15)), template="plotly_white", - paper_bgcolor="#ffffff", - plot_bgcolor="#f8fbff", - margin=dict(l=70, r=50, t=70, b=60), - font=dict(family="Segoe UI, sans-serif", size=12, color="#10233f"), + paper_bgcolor=CHART_PAPER, + plot_bgcolor=CHART_PAPER, + colorway=list(CHART_SEQUENCE), + margin=dict(l=70, r=40, t=64, b=56), + font=dict(family="Segoe UI, sans-serif", size=12, color=CHART_INK), hovermode="closest", + legend=dict(font=dict(color=CHART_MUTED, size=11)), + xaxis=dict( + gridcolor=CHART_GRID, + zerolinecolor=CHART_AXIS, + linecolor=CHART_AXIS, + tickfont=dict(color=CHART_MUTED, size=11), + ), + yaxis=dict( + gridcolor=CHART_GRID, + zerolinecolor=CHART_AXIS, + linecolor=CHART_AXIS, + tickfont=dict(color=CHART_MUTED, size=11), + ), ) def _render(self, figure: go.Figure) -> None: - html = figure.to_html( - include_plotlyjs="cdn", - full_html=False, - config={ - "displaylogo": False, - "responsive": True, - "scrollZoom": True, - }, + """Draw ``figure`` into the already-loaded page. + + Uses ``Plotly.react`` against a persistent document rather than + replacing the whole page, so switching chart type re-uses the parsed + plotly.js instead of downloading and re-parsing it every time. + + Args: + figure: The figure to display. + """ + if not self._ready: + self._pending = figure + return + + payload = json.loads(figure.to_json()) + script = ( + "Plotly.react(" + "'vatic-chart'," + f"{json.dumps(payload.get('data', []))}," + f"{json.dumps(payload.get('layout', {}))}," + f"{json.dumps(PLOT_CONFIG)}" + ");" ) - self.setHtml(html) + self.page().runJavaScript(script) def _downsample( self, values: np.ndarray, max_points: int = 6000 @@ -79,7 +192,7 @@ def draw_histogram(self, data: np.ndarray) -> None: x=data, nbinsx=bins, marker=dict( - color="#2A9D8F", line=dict(color="#264653", width=1) + color="#24AEFF", line=dict(color="#2323FF", width=1) ), opacity=0.9, hovertemplate="Outcome=%{x:.4f}
Frequency=%{y}", @@ -105,7 +218,7 @@ def draw_cdf(self, data: np.ndarray) -> None: x=sorted_values, y=cumulative, mode="lines", - line=dict(color="#1D3557", width=2), + line=dict(color="#2323FF", width=2), hovertemplate="Outcome=%{x:.4f}
CDF=%{y:.4f}", name="CDF", ) @@ -128,7 +241,7 @@ def draw_exceedance(self, data: np.ndarray) -> None: x=sorted_values, y=exceedance, mode="lines", - line=dict(color="#E76F51", width=2), + line=dict(color="#C04AFF", width=2), hovertemplate="Threshold=%{x:.4f}
P(X>x)=%{y:.4f}", name="Exceedance", ) @@ -160,7 +273,7 @@ def draw_var_cvar(self, data: np.ndarray, confidence: float = 0.95) -> None: x=data, nbinsx=bins, marker=dict( - color="#8ECAE6", line=dict(color="#1D3557", width=1) + color="#87D2FF", line=dict(color="#2323FF", width=1) ), opacity=0.85, hovertemplate="Outcome=%{x:.4f}
Frequency=%{y}", @@ -172,7 +285,7 @@ def draw_var_cvar(self, data: np.ndarray, confidence: float = 0.95) -> None: x=[var_threshold, var_threshold], y=[0.0, y_max], mode="lines", - line=dict(color="#E76F51", width=2, dash="dash"), + line=dict(color="#C04AFF", width=2, dash="dash"), name=f"VaR {confidence:.0%}", hovertemplate=f"VaR {confidence:.0%}: {var_threshold:,.4f}", ) @@ -182,7 +295,7 @@ def draw_var_cvar(self, data: np.ndarray, confidence: float = 0.95) -> None: x=[cvar_value, cvar_value], y=[0.0, y_max], mode="lines", - line=dict(color="#D00000", width=2, dash="dot"), + line=dict(color="#772E9E", width=2, dash="dot"), name="CVaR", hovertemplate=f"CVaR: {cvar_value:,.4f}", ) @@ -196,7 +309,7 @@ def draw_var_cvar(self, data: np.ndarray, confidence: float = 0.95) -> None: xanchor="left", showarrow=False, text=f"VaR {confidence:.0%}: {var_threshold:,.4f}", - font=dict(color="#9C2D1D", size=12), + font=dict(color="#7E3DFF", size=12), bgcolor="rgba(255,255,255,0.85)", ) fig.add_annotation( @@ -207,7 +320,7 @@ def draw_var_cvar(self, data: np.ndarray, confidence: float = 0.95) -> None: xanchor="right", showarrow=False, text=f"CVaR: {cvar_value:,.4f}", - font=dict(color="#7A0000", size=12), + font=dict(color="#45228C", size=12), bgcolor="rgba(255,255,255,0.85)", ) fig.update_xaxes(title_text="Outcome") @@ -234,9 +347,9 @@ def draw_kde(self, data: np.ndarray) -> None: x=x_grid, y=y_density, mode="lines", - line=dict(color="#2A9D8F", width=2.5), + line=dict(color="#2323FF", width=2.5), fill="tozeroy", - fillcolor="rgba(42,157,143,0.22)", + fillcolor="rgba(35,35,255,0.18)", hovertemplate="Outcome=%{x:.4f}
Density=%{y:.6f}", name="KDE", ) @@ -262,7 +375,7 @@ def draw_qq_normal(self, data: np.ndarray) -> None: x=theoretical, y=observed, mode="markers", - marker=dict(size=6, color="#3A7CA5", opacity=0.6), + marker=dict(size=6, color="#24AEFF", opacity=0.6), hovertemplate="Theoretical=%{x:.4f}
Observed=%{y:.4f}", name="Sample quantiles", ) @@ -272,7 +385,7 @@ def draw_qq_normal(self, data: np.ndarray) -> None: x=theoretical, y=fit_line, mode="lines", - line=dict(color="#E76F51", width=2), + line=dict(color="#C04AFF", width=2), name=f"Reference line (r={corr:.4f})", hoverinfo="skip", ) @@ -295,7 +408,7 @@ def draw_pareto(self, data: np.ndarray) -> None: go.Bar( x=labels, y=counts, - marker=dict(color="#3A7CA5"), + marker=dict(color="#24AEFF"), name="Frequency", hovertemplate="Bucket=%{x}
Frequency=%{y}", ) @@ -305,8 +418,8 @@ def draw_pareto(self, data: np.ndarray) -> None: x=labels, y=cumulative, mode="lines+markers", - marker=dict(color="#E76F51", size=8), - line=dict(color="#E76F51", width=2), + marker=dict(color="#C04AFF", size=8), + line=dict(color="#C04AFF", width=2), name="Cumulative %", yaxis="y2", hovertemplate="Bucket=%{x}
Cumulative=%{y:.2f}%", @@ -338,7 +451,7 @@ def draw_trend(self, data: np.ndarray) -> None: x=x, y=running_mean, mode="lines", - line=dict(color="#1D3557", width=2.2), + line=dict(color="#2323FF", width=2.2), name="Running mean", hovertemplate="Iteration=%{x}
Mean=%{y:.4f}", ) @@ -348,7 +461,7 @@ def draw_trend(self, data: np.ndarray) -> None: x=x, y=sampled, mode="lines", - line=dict(color="#A8DADC", width=1), + line=dict(color="#9C9CFF", width=1), opacity=0.6, name="Sample outcome", hovertemplate="Iteration=%{x}
Value=%{y:.4f}", @@ -375,7 +488,7 @@ def draw_scatter(self, x: np.ndarray, y: np.ndarray, x_label: str) -> None: x=x_sampled[:count], y=y_sampled[:count], mode="markers", - marker=dict(color="#457B9D", size=6, opacity=0.45), + marker=dict(color="#4E269E", size=6, opacity=0.45), hovertemplate=f"{x_label}=%{{x:.4f}}
Forecast=%{{y:.4f}}", name="Samples", ) @@ -392,7 +505,7 @@ def draw_tornado(self, points: list[tuple[str, float]]) -> None: names = [name for name, _ in points] values = [value for _, value in points] - colors = ["#E76F51" if value < 0 else "#2A9D8F" for value in values] + colors = ["#C04AFF" if value < 0 else "#24AEFF" for value in values] fig = go.Figure(layout=self._base_layout("Tornado (Sensitivity)")) fig.add_trace( @@ -419,14 +532,15 @@ def draw_box(self, groups: dict[str, np.ndarray]) -> None: fig = go.Figure( layout=self._base_layout("Box Plot (Inputs + Forecast)") ) - for name, values in groups.items(): + for index, (name, values) in enumerate(groups.items()): + colour = CHART_SEQUENCE[index % len(CHART_SEQUENCE)] fig.add_trace( go.Box( x=values, name=name, boxpoints=False, - marker=dict(color="#3A7CA5"), - line=dict(color="#1D3557"), + marker=dict(color=colour), + line=dict(color=colour), hovertemplate="%{x:.4f}%{fullData.name}", ) ) @@ -438,7 +552,8 @@ def draw_violin(self, groups: dict[str, np.ndarray]) -> None: fig = go.Figure( layout=self._base_layout("Violin Plot (Inputs + Forecast)") ) - for name, values in groups.items(): + for index, (name, values) in enumerate(groups.items()): + colour = CHART_SEQUENCE[index % len(CHART_SEQUENCE)] fig.add_trace( go.Violin( x=values, @@ -446,8 +561,8 @@ def draw_violin(self, groups: dict[str, np.ndarray]) -> None: box_visible=True, meanline_visible=True, points=False, - line_color="#1D3557", - fillcolor="rgba(69,123,157,0.35)", + line_color=colour, + fillcolor=rgba(colour, 0.28), hovertemplate="%{x:.4f}%{fullData.name}", ) ) @@ -483,15 +598,15 @@ def draw_statistics(self, stats: dict[str, float]) -> None: go.Table( header=dict( values=["Metric", "Value"], - fill_color="#1D3557", - font=dict(color="white", size=13), + fill_color="#24AEFF", + font=dict(color="#13138C", size=13), align="left", ), cells=dict( values=[labels, values], - fill_color=["#f8fbff", "#ffffff"], + fill_color=["#FFFFFF", "#FFFFFF"], align="left", - font=dict(color="#10233f", size=12), + font=dict(color="#13138C", size=12), height=30, ), ) @@ -501,8 +616,8 @@ def draw_statistics(self, stats: dict[str, float]) -> None: title="Rich Statistics", template="plotly_white", margin=dict(l=40, r=40, t=70, b=20), - paper_bgcolor="#ffffff", - font=dict(family="Segoe UI, sans-serif", size=12, color="#10233f"), + paper_bgcolor="#FFFFFF", + font=dict(family="Segoe UI, sans-serif", size=12, color="#13138C"), ) self._render(fig) @@ -522,7 +637,7 @@ def draw_message(self, message: str) -> None: xref="paper", yref="paper", showarrow=False, - font=dict(size=16, color="#264653"), + font=dict(size=16, color="#0F0F6B"), ) ], ) diff --git a/vatic/dialogs.py b/vatic/dialogs.py index f0c0897..3a0f295 100644 --- a/vatic/dialogs.py +++ b/vatic/dialogs.py @@ -29,6 +29,7 @@ QLabel, QLineEdit, QMessageBox, + QTextBrowser, QVBoxLayout, QWidget, ) @@ -110,3 +111,251 @@ def _accept(self) -> None: exc, ) QMessageBox.critical(self, "Invalid Parameters", str(exc)) + + +class ForecastDialog(QDialog): + """Edit one forecast formula row away from the table grid. + + The expression is the most awkward field to edit in place: a table cell + editor is a few characters wide, so a long formula scrolls out of sight + while it is being typed. Editing happens here instead. + """ + + def __init__( + self, + name: str = "", + expression: str = "", + lsl: str = "", + usl: str = "", + target: str = "", + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self._values: dict[str, str] = {} + + self.setWindowTitle("Forecast formula") + self.setMinimumWidth(520) + LOGGER.debug("Forecast dialog opened | name=%s", name or "") + + root = QVBoxLayout(self) + hint = QLabel( + "Reference assumptions and earlier forecasts by name. Operators: " + "+ - * / ** and functions such as sqrt, log, exp, min, max, pi." + ) + hint.setWordWrap(True) + root.addWidget(hint) + + form = QFormLayout() + self.name_input = QLineEdit(name) + self.name_input.setPlaceholderText("profit") + self.expression_input = QLineEdit(expression) + self.expression_input.setPlaceholderText("revenue - cost") + self.lsl_input = QLineEdit(lsl) + self.lsl_input.setPlaceholderText("optional") + self.usl_input = QLineEdit(usl) + self.usl_input.setPlaceholderText("optional") + self.target_input = QLineEdit(target) + self.target_input.setPlaceholderText("optional") + + form.addRow("Name", self.name_input) + form.addRow("Expression", self.expression_input) + form.addRow("Lower spec limit", self.lsl_input) + form.addRow("Upper spec limit", self.usl_input) + form.addRow("Target", self.target_input) + root.addLayout(form) + + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.accepted.connect(self._accept) + buttons.rejected.connect(self.reject) + root.addWidget(buttons) + + def values(self) -> dict[str, str]: + """Return the accepted field values. + + Returns: + Mapping of ``name``, ``expression``, ``lsl``, ``usl`` and + ``target`` to their raw text. + """ + return dict(self._values) + + def _accept(self) -> None: + """Validate the fields and close the dialog when they are usable.""" + name = self.name_input.text().strip() + expression = self.expression_input.text().strip() + + if not name: + QMessageBox.critical(self, "Invalid forecast", "Name is required.") + return + if not name.isidentifier(): + QMessageBox.critical( + self, + "Invalid forecast", + f"'{name}' is not a valid name. Use letters, numbers and " + "underscores, and do not start with a number.", + ) + return + if not expression: + QMessageBox.critical( + self, "Invalid forecast", "Expression is required." + ) + return + + limits: dict[str, float] = {} + for label, widget in ( + ("Lower spec limit", self.lsl_input), + ("Upper spec limit", self.usl_input), + ("Target", self.target_input), + ): + text = widget.text().strip() + if not text: + continue + try: + limits[label] = float(text) + except ValueError: + QMessageBox.critical( + self, + "Invalid forecast", + f"{label} must be a number, or left empty.", + ) + return + + lower = limits.get("Lower spec limit") + upper = limits.get("Upper spec limit") + if lower is not None and upper is not None and lower > upper: + QMessageBox.critical( + self, + "Invalid forecast", + "The lower spec limit cannot be greater than the upper one.", + ) + return + + self._values = { + "name": name, + "expression": expression, + "lsl": self.lsl_input.text().strip(), + "usl": self.usl_input.text().strip(), + "target": self.target_input.text().strip(), + } + LOGGER.debug("Forecast dialog accepted | name=%s", name) + self.accept() + + +EXCEL_HELP_HTML = """ +

Using vatic with Microsoft Excel

+ +

vatic can drive a spreadsheet directly. Your workbook stays the model: +vatic samples the inputs you nominate, lets Excel recalculate, and collects +the outputs you care about. Every formula, lookup and add-in behaves exactly +as it does when you use the workbook by hand.

+ +

What you need

+
    +
  • Windows, with Microsoft Excel installed.
  • +
  • The workbook open, and not read-only.
  • +
+

On macOS and Linux the rest of vatic still works; only the spreadsheet +link is Windows-only.

+ +

Step by step

+
    +
  1. Spreadsheet > Connect Workbook. Pick a file, or cancel the file + picker to attach to the workbook already open in Excel.
  2. +
  3. Select an input cell in Excel — a nominal dimension, a cost, a + rate — then choose Spreadsheet > Tag Selected Cell as + Assumption. Give it a name and a distribution. vatic suggests the name + from the label to the left of the cell, and seeds the distribution from + the value already in it.
  4. +
  5. Select an output cell — whatever the workbook calculates that + you want to understand — and choose Tag Selected Cell as + Forecast. Add lower and upper spec limits here if the value has to + stay inside a range; that is what turns on the capability metrics.
  6. +
  7. Set Iterations, and a Seed if you want the run to be exactly + reproducible.
  8. +
  9. Press Run Simulation.
  10. +
+

Tagged cells are tinted in the sheet so you can see what vatic is driving: +inputs in cyan, outputs in magenta. Clear Tagged Cells puts the +original colours back.

+ +

A worked example

+

The examples folder contains a tolerance stack-up: +seal_tolerance.xlsx. Its inputs sit in C8:C15 with +their tolerances beside them, and its two outputs are in C30 and +C31 with limits in C19:D20. A tolerance is +conventionally three sigma, so an input with a ±0.005 tolerance becomes +Normal(nominal, 0.005/3).

+ +

What vatic changes, and what it puts back

+

During a run vatic adds two hidden sheets, points each tagged input at a +column of trial values, and builds an Excel data table so the whole simulation +resolves in a single recalculation. That is why ten thousand trials take about +a second instead of many minutes.

+

When the run finishes — or fails, or you cancel it — the hidden +sheets are deleted, every tagged cell gets its original formula back, +and Excel's calculation mode, screen updating and event settings are restored. +Nothing is saved to disk; if anything still looks wrong, close the workbook +without saving.

+ +

If something goes wrong

+
    +
  • "Excel is not available" — Excel is not installed, or this is + not Windows.
  • +
  • "Open read-only" — the run has to write into the sheet. Reopen + the workbook with write access.
  • +
  • "Excel is busy" — a dialog is open in Excel, or a cell is + still being edited. Finish that and run again.
  • +
  • Errors reported after a run — some trials produced + #DIV/0!, #REF! or similar. Those trials are left + out of the statistics rather than being counted as zero, and the tally + tells you how many.
  • +
  • Volatile functionsRAND, + RANDBETWEEN, NOW and TODAY change on + every recalculation, so a model that uses them cannot give a meaningful + answer.
  • +
+""" + +NO_EXCEL_HTML = """ +

Microsoft Excel is not available here

+

The spreadsheet link needs Windows with Excel installed. vatic could not +find it, so Spreadsheet > Connect Workbook will not work on this +machine.

+

Everything else still works: define your inputs in the +Assumptions table, write expressions in Forecast Formulas, and +press Run Simulation. The statistics, capability metrics, charts and +reports are identical either way.

+

If you do have Excel, install the optional dependency and restart:

+
uv pip install "vatic[excel]"
+""" + + +class ExcelHelpDialog(QDialog): + """Explains how to drive a spreadsheet model from vatic.""" + + def __init__( + self, *, available: bool = True, parent: QWidget | None = None + ) -> None: + """Build the help window. + + Args: + available: Whether the Excel link can be used on this machine. + When False the dialog explains why instead. + parent: Parent widget. + """ + super().__init__(parent) + self.setWindowTitle("Using vatic with Excel") + self.resize(720, 620) + + root = QVBoxLayout(self) + body = QTextBrowser() + body.setOpenExternalLinks(True) + body.setHtml(EXCEL_HELP_HTML if available else NO_EXCEL_HTML) + root.addWidget(body) + + buttons = QDialogButtonBox(QDialogButtonBox.Close) + buttons.rejected.connect(self.reject) + buttons.accepted.connect(self.accept) + root.addWidget(buttons) + LOGGER.debug("Excel help opened | available=%s", available) diff --git a/vatic/distributions.py b/vatic/distributions.py index 112281b..1d4a6b1 100644 --- a/vatic/distributions.py +++ b/vatic/distributions.py @@ -28,6 +28,7 @@ from typing import Any import mcerp +import numpy as np from vatic.logger import get_logger @@ -187,3 +188,32 @@ def build_variable( type(built).__name__, ) return built + + +#: Sentinel meaning "draw a fresh, unpredictable sample set each run". +RANDOM_SEED = 0 + + +def seed_sampler(seed: int | None) -> int | None: + """Make the next sampling run reproducible. + + ``mcerp`` draws its Latin hypercube through numpy's legacy global random + state, so seeding that state is what pins a run down. Without it two runs + of the same model return different capability metrics and a different + tornado ordering, which is untenable when the output is used to sign off + a design or to assert anything in a test. + + Args: + seed: Seed to install, or ``None``/:data:`RANDOM_SEED` to leave the + generator alone so every run differs. + + Returns: + The seed that was installed, or ``None`` when the run is left random. + """ + if seed is None or seed == RANDOM_SEED: + LOGGER.debug("Sampling left unseeded | runs will not be reproducible") + return None + + np.random.seed(int(seed) % (2**32)) + LOGGER.debug("Sampler seeded | seed=%s", seed) + return int(seed) diff --git a/vatic/excel/__init__.py b/vatic/excel/__init__.py new file mode 100644 index 0000000..eea4f90 --- /dev/null +++ b/vatic/excel/__init__.py @@ -0,0 +1,95 @@ +# -------------------------------------*- vatic -*---------------------------- +# Open Source Risk Analysis +# +# Copyright (c) 2026, eggzec +# Contact: https://eggzec.github.io/ +# +# License: GNU General Public License +# Version 3, 29 June 2007 +# +# ---------------------------------------------------------------------------- +# +# Author(s) +# Saud Zahir +# +# Date +# 7 May 2026 +# +# Description +# The Microsoft Excel link. +# +# ---------------------------------------------------------------------------- +# +# Importing this package is safe on every platform: the error types and the +# availability check carry no COM dependency, so the window can ask whether a +# spreadsheet run is possible without pywin32 being installed. The session and +# the runner import COM lazily, and only on Windows. +# +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from vatic.excel.errors import ( + CellResolutionError, + CircularReferenceError, + ExcelBusyError, + ExcelLinkError, + ExcelNotAvailableError, + SheetProtectedError, + SimulationCancelled, + VolatileModelError, + WorkbookNotFoundError, + WorkbookReadOnlyError, + describe_error_value, + is_error_value, +) +from vatic.excel.session import excel_available + + +if TYPE_CHECKING: # pragma: no cover - import-time only + from vatic.excel.runner import ExcelRunner + from vatic.excel.session import ExcelSession + + +__all__ = [ + "CellResolutionError", + "CircularReferenceError", + "ExcelBusyError", + "ExcelLinkError", + "ExcelNotAvailableError", + "ExcelRunner", + "ExcelSession", + "SheetProtectedError", + "SimulationCancelled", + "VolatileModelError", + "WorkbookNotFoundError", + "WorkbookReadOnlyError", + "describe_error_value", + "excel_available", + "is_error_value", +] + + +def __getattr__(name: str) -> object: + """Import the COM-backed classes only when they are actually used. + + Args: + name: Attribute being looked up. + + Returns: + The requested class. + + Raises: + AttributeError: If the name is not part of the public surface. + """ + if name == "ExcelSession": + from vatic.excel.session import ExcelSession + + return ExcelSession + if name == "ExcelRunner": + from vatic.excel.runner import ExcelRunner + + return ExcelRunner + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/vatic/excel/errors.py b/vatic/excel/errors.py new file mode 100644 index 0000000..4d1f0d0 --- /dev/null +++ b/vatic/excel/errors.py @@ -0,0 +1,209 @@ +# -------------------------------------*- vatic -*---------------------------- +# Open Source Risk Analysis +# +# Copyright (c) 2026, eggzec +# Contact: https://eggzec.github.io/ +# +# License: GNU General Public License +# Version 3, 29 June 2007 +# +# ---------------------------------------------------------------------------- +# +# Author(s) +# Saud Zahir +# +# Date +# 7 May 2026 +# +# Description +# Typed failures for the Excel link, and the worksheet error decoder. +# +# ---------------------------------------------------------------------------- +# +# Pure Python and pure integers: this module imports no COM, so it can be +# tested on any platform and imported by the UI to render a message without +# pulling pywin32 in. +# +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +from vatic.logger import get_logger + + +LOGGER = get_logger(__name__) + + +class ExcelLinkError(Exception): + """Base class for every failure raised by the Excel link.""" + + #: Shown to the user. Subclasses override it with something actionable. + advice = "" + + def user_message(self) -> str: + """Return a message fit to put in a dialog. + + Returns: + The error text, followed by advice when the class offers any. + """ + text = str(self) + return f"{text}\n\n{self.advice}" if self.advice else text + + +class ExcelNotAvailableError(ExcelLinkError): + """Excel is not installed, or COM could not start it.""" + + advice = ( + "The spreadsheet link needs Microsoft Excel installed on this " + "machine. The in-app formula model works without it." + ) + + +class WorkbookNotFoundError(ExcelLinkError): + """The named workbook is not open and could not be opened.""" + + advice = "Open the workbook in Excel, then connect again." + + +class WorkbookReadOnlyError(ExcelLinkError): + """The workbook cannot be written to.""" + + advice = ( + "A simulation has to write trial values into the sheet. Close the " + "read-only copy and reopen the workbook with write access." + ) + + +class SheetProtectedError(ExcelLinkError): + """A protected sheet refused a write.""" + + advice = "Unprotect the sheet in Excel, then run again." + + +class CellResolutionError(ExcelLinkError): + """A tagged cell does not exist in the workbook any more.""" + + advice = "Re-tag the cell; it may have been deleted or the sheet renamed." + + +class ExcelBusyError(ExcelLinkError): + """Excel refused the call because it is busy or showing a dialog.""" + + advice = ( + "Excel is busy. Close any open dialog or finish editing a cell, " + "then run again." + ) + + +class CircularReferenceError(ExcelLinkError): + """The workbook contains a circular reference.""" + + advice = ( + "Excel cannot resolve a circular reference without iterative " + "calculation, and the results would not be trustworthy. Fix the " + "reference, then run again." + ) + + +class VolatileModelError(ExcelLinkError): + """The workbook uses functions that make a batched run unsound.""" + + advice = ( + "Volatile functions such as RAND, RANDBETWEEN, NOW and TODAY change " + "on every recalculation, so each trial would use different values " + "and the results would be meaningless." + ) + + +class SimulationCancelled(ExcelLinkError): + """The user stopped the run.""" + + +#: Worksheet error values arrive over COM as these negative integers. +ERROR_VALUES: dict[int, str] = { + -2146826281: "#DIV/0!", + -2146826246: "#N/A", + -2146826259: "#NAME?", + -2146826288: "#NULL!", + -2146826252: "#NUM!", + -2146826265: "#REF!", + -2146826273: "#VALUE!", +} + +#: HRESULTs worth naming when they come back from Excel. +_HRESULTS: dict[int, type[ExcelLinkError]] = { + -2147221164: ExcelNotAvailableError, # REGDB_E_CLASSNOTREG + -2147221005: ExcelNotAvailableError, # CO_E_CLASSSTRING + -2147221021: ExcelNotAvailableError, # MK_E_UNAVAILABLE + -2147418111: ExcelBusyError, # RPC_E_CALL_REJECTED + -2147417846: ExcelBusyError, # RPC_E_SERVERCALL_RETRYLATER +} + + +def describe_error_value(value: object) -> str | None: + """Name the worksheet error a cell returned, if it returned one. + + Args: + value: A value read back from a cell. + + Returns: + The Excel error text such as ``#DIV/0!``, or None for a real value. + """ + if isinstance(value, bool) or not isinstance(value, int): + return None + return ERROR_VALUES.get(int(value)) + + +def is_error_value(value: object) -> bool: + """Whether a cell value is a worksheet error rather than a number. + + Args: + value: A value read back from a cell. + + Returns: + True when the value encodes an Excel error. + """ + return describe_error_value(value) is not None + + +def classify(exc: BaseException) -> ExcelLinkError: + """Turn a COM failure into a typed, explainable error. + + Args: + exc: The exception raised by pywin32. + + Returns: + A typed error carrying a message worth showing to a person. + """ + if isinstance(exc, ExcelLinkError): + return exc + + hresult = getattr(exc, "hresult", None) + args = getattr(exc, "args", ()) + if hresult is None and args and isinstance(args[0], int): + hresult = args[0] + + detail = "" + for entry in args: + if isinstance(entry, tuple): + detail = next( + (str(part) for part in entry if isinstance(part, str) and part), + "", + ) + break + detail = detail or str(exc) + + lowered = detail.lower() + if "read-only" in lowered or "read only" in lowered: + return WorkbookReadOnlyError(detail) + if "protect" in lowered: + return SheetProtectedError(detail) + if "circular" in lowered: + return CircularReferenceError(detail) + + factory = _HRESULTS.get(int(hresult)) if hresult is not None else None + if factory is not None: + return factory(detail) + + LOGGER.debug("Unclassified COM failure | hresult=%s | %s", hresult, detail) + return ExcelLinkError(detail) diff --git a/vatic/excel/runner.py b/vatic/excel/runner.py new file mode 100644 index 0000000..7f8e92b --- /dev/null +++ b/vatic/excel/runner.py @@ -0,0 +1,403 @@ +# -------------------------------------*- vatic -*---------------------------- +# Open Source Risk Analysis +# +# Copyright (c) 2026, eggzec +# Contact: https://eggzec.github.io/ +# +# License: GNU General Public License +# Version 3, 29 June 2007 +# +# ---------------------------------------------------------------------------- +# +# Author(s) +# Saud Zahir +# +# Date +# 7 May 2026 +# +# Description +# Runs a Monte Carlo simulation through a live Excel workbook. +# +# ---------------------------------------------------------------------------- +# +# The reference implementation wrote one cell and recalculated once per +# trial, which costs a cross-process COM call per cell per iteration and +# takes roughly eighteen minutes for ten thousand trials. +# +# This runner instead writes the whole sample matrix to a hidden sheet, points +# each assumption cell at its column through INDEX, and builds a one-variable +# Data Table over a trial index. Excel then evaluates every trial in a single +# recalculation. Measured against a live Excel 16.0 on a real workbook that is +# about 1.4 seconds for ten thousand trials, and 0.45 seconds to re-run with +# fresh samples. +# +# Two details are load-bearing and were both found the hard way: +# +# * The Data Table's input cell must live on the same worksheet as the table, +# otherwise Excel rejects it with "Input cell reference is not valid". +# * Assumption cells frequently hold formulas rather than constants, so the +# original FORMULA is captured and restored. The reference implementation +# restored the computed value instead, permanently destroying the formula. +# +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +import time +from collections.abc import Callable +from dataclasses import dataclass, field + +import numpy as np + +from vatic.excel.errors import ( + ExcelLinkError, + SimulationCancelled, + classify, + describe_error_value, +) +from vatic.excel.session import ( + XL_SHEET_VERY_HIDDEN, + XL_SHEET_VISIBLE, + ExcelSession, +) +from vatic.logger import get_logger +from vatic.sheetmodel import SheetModel + + +LOGGER = get_logger(__name__) + +TRIALS_SHEET = "_vatic_trials" +TABLE_SHEET = "_vatic_table" + +#: Excel caps a worksheet at 1,048,576 rows; the table needs a header row and +#: the trials sheet needs one row per trial. +MAX_TRIALS = 1_000_000 + +#: Cell tints applied while a model is connected, taken from the brand palette. +ASSUMPTION_TINT = "#24AEFF" +FORECAST_TINT = "#C04AFF" + + +@dataclass +class RunDiagnostics: + """What happened during a run, beyond the numbers themselves.""" + + trials: int = 0 + seconds: float = 0.0 + #: Worksheet errors seen per forecast tag, e.g. ``{"profit": {"#DIV/0!": 3}}``. + errors: dict[str, dict[str, int]] = field(default_factory=dict) + + @property + def error_count(self) -> int: + """Total number of trials that produced a worksheet error. + + Returns: + The summed error tally across every forecast. + """ + return sum( + count for tally in self.errors.values() for count in tally.values() + ) + + +@dataclass +class RunResult: + """The outcome of a spreadsheet-backed simulation.""" + + #: Sampled inputs, one column per assumption, in model order. + inputs: dict[str, np.ndarray] + #: Collected outputs, one column per forecast, in model order. + forecasts: dict[str, np.ndarray] + diagnostics: RunDiagnostics + + +class ExcelRunner: + """Drives one simulation against a connected workbook.""" + + def __init__(self, session: ExcelSession, model: SheetModel) -> None: + """Prepare a runner. + + Args: + session: A connected session, owned by the calling thread. + model: The tagged assumptions and forecasts. + """ + self.session = session + self.model = model + self._created_sheets: list[str] = [] + self._original_formulas: dict[str, str] = {} + + # ------------------------------------------------------------- helpers + + def _worksheet(self, name: str): # noqa: ANN202 - a COM object + """Return a scratch worksheet, creating it if needed. + + Args: + name: Worksheet name. + + Returns: + The Excel ``Worksheet`` object. + """ + book = self.session.workbook + for sheet in book.Worksheets: + if str(sheet.Name) == name: + return sheet + sheet = book.Worksheets.Add() + sheet.Name = name + sheet.Visible = XL_SHEET_VERY_HIDDEN + self._created_sheets.append(name) + LOGGER.debug("Created scratch sheet | name=%s", name) + return sheet + + @staticmethod + def _column_letters(index: int) -> str: + """Convert a one-based column index to its letters. + + Args: + index: One-based column number. + + Returns: + The column letters, such as ``AA`` for 27. + """ + letters = "" + while index > 0: + index, remainder = divmod(index - 1, 26) + letters = chr(65 + remainder) + letters + return letters + + # ---------------------------------------------------------------- run + + def run( + self, + samples: np.ndarray, + *, + progress: Callable[[str, float], None] | None = None, + should_cancel: Callable[[], bool] | None = None, + ) -> RunResult: + """Evaluate every trial through the workbook. + + Args: + samples: Array shaped ``(trials, assumptions)`` holding one column + per assumption, in model order. + progress: Called with a stage description and a 0..1 fraction. + should_cancel: Polled between stages; returning True aborts. + + Returns: + The sampled inputs and the collected forecast columns. + + Raises: + ValueError: If the sample matrix does not match the model. + SimulationCancelled: If the caller asked to stop; raised from + the nested cancellation check. + ExcelLinkError: If Excel refused any step; the concrete type + comes from ``classify``. + """ # noqa: DOC501, DOC502 + self.model.validate() + trials, columns = samples.shape + if columns != len(self.model.assumptions): + raise ValueError( + f"Sample matrix has {columns} columns but the model has " + f"{len(self.model.assumptions)} assumptions" + ) + if not 0 < trials <= MAX_TRIALS: + raise ValueError(f"Trial count out of range: {trials}") + + def announce(stage: str, fraction: float) -> None: + if progress is not None: + progress(stage, fraction) + + def check_cancelled() -> None: + if should_cancel is not None and should_cancel(): + raise SimulationCancelled("Simulation cancelled") + + started = time.monotonic() + self.session.begin_run() + try: + check_cancelled() + announce("Writing trial values", 0.05) + self._write_trials(samples) + + check_cancelled() + announce("Rewiring assumption cells", 0.25) + self._rewire_assumptions(trials) + + check_cancelled() + announce("Calculating all trials", 0.40) + self._build_table(trials) + + check_cancelled() + announce("Reading results", 0.85) + outputs, diagnostics = self._read_results(trials) + except ExcelLinkError: + raise + except Exception as exc: # noqa: BLE001 - mapped to a typed error + raise classify(exc) from exc + finally: + # Restoration runs even on cancellation or crash, so the user's + # workbook is never left rewired. + self._restore() + self.session.end_run() + announce("Done", 1.0) + + diagnostics.trials = trials + diagnostics.seconds = time.monotonic() - started + LOGGER.info( + "Excel run complete | trials=%s | seconds=%.2f | errors=%s", + trials, + diagnostics.seconds, + diagnostics.error_count, + ) + + inputs = { + assumption.tag: samples[:, index] + for index, assumption in enumerate(self.model.assumptions) + } + return RunResult( + inputs=inputs, forecasts=outputs, diagnostics=diagnostics + ) + + # ------------------------------------------------------------- stages + + def _write_trials(self, samples: np.ndarray) -> None: + """Write the whole sample matrix to the hidden trials sheet. + + Args: + samples: Array shaped ``(trials, assumptions)``. + """ + sheet = self._worksheet(TRIALS_SHEET) + trials, columns = samples.shape + sheet.Range( + sheet.Cells(1, 1), sheet.Cells(trials, columns) + ).ClearContents() + block = tuple(tuple(float(v) for v in row) for row in samples) + sheet.Range( + sheet.Cells(1, 1), sheet.Cells(trials, columns) + ).Value = block + + def _rewire_assumptions(self, trials: int) -> None: + """Point each assumption cell at its trial column. + + The cell's existing contents are captured first so the original + formula, not merely its value, can be put back. + + Args: + trials: Number of trials in the matrix. + """ + table = self._worksheet(TABLE_SHEET) + table.Range("A1").Value = 1 + + for index, assumption in enumerate(self.model.assumptions, start=1): + ref = assumption.ref + key = ref.qualified() + if key not in self._original_formulas: + self._original_formulas[key] = self.session.read_formula(ref) + assumption.original_formula = self._original_formulas[key] + + column = self._column_letters(index) + self.session.write_formula( + ref, + f"=INDEX({TRIALS_SHEET}!${column}$1:${column}${trials}," + f"{TABLE_SHEET}!$A$1)", + ) + + def _build_table(self, trials: int) -> None: + """Build and evaluate the one-variable Data Table. + + Args: + trials: Number of trials to evaluate. + """ + table = self._worksheet(TABLE_SHEET) + forecasts = self.model.forecasts + + # Header row: A1 is the input cell, B1.. probe each forecast. + for offset, forecast in enumerate(forecasts, start=2): + letter = self._column_letters(offset) + table.Range(f"{letter}1").Formula = f"={forecast.ref.qualified()}" + + indices = tuple((float(i),) for i in range(1, trials + 1)) + table.Range( + table.Cells(2, 1), table.Cells(trials + 1, 1) + ).Value = indices + + last_column = len(forecasts) + 1 + table.Range( + table.Cells(1, 1), table.Cells(trials + 1, last_column) + ).Table(ColumnInput=table.Range("A1")) + + def _read_results( + self, trials: int + ) -> tuple[dict[str, np.ndarray], RunDiagnostics]: + """Read the computed table back in one block per forecast. + + Args: + trials: Number of trials evaluated. + + Returns: + The forecast columns and the diagnostics gathered while reading. + """ + table = self._worksheet(TABLE_SHEET) + diagnostics = RunDiagnostics() + outputs: dict[str, np.ndarray] = {} + + for offset, forecast in enumerate(self.model.forecasts, start=2): + block = table.Range( + table.Cells(2, offset), table.Cells(trials + 1, offset) + ).Value + column = np.full(trials, np.nan, dtype=float) + tally: dict[str, int] = {} + for row, entry in enumerate(block): + value = entry[0] if isinstance(entry, tuple) else entry + name = describe_error_value(value) + if name is not None: + tally[name] = tally.get(name, 0) + 1 + continue + if isinstance(value, (int, float)) and not isinstance( + value, bool + ): + column[row] = float(value) + if tally: + diagnostics.errors[forecast.tag] = tally + LOGGER.warning( + "Worksheet errors in forecast | tag=%s | %s", + forecast.tag, + tally, + ) + outputs[forecast.tag] = column + + return outputs, diagnostics + + # ---------------------------------------------------------- restore + + def _restore(self) -> None: + """Undo every change the run made to the workbook.""" + for key, formula in self._original_formulas.items(): + try: + from vatic.sheetmodel import CellRef + + self.session.write_formula(CellRef.parse(key), formula) + except Exception as exc: # noqa: BLE001 - keep restoring + LOGGER.error("Could not restore %s | %s", key, exc) + self._original_formulas.clear() + + book = self.session.workbook + app = self.session.app + for name in list(self._created_sheets): + try: + for sheet in book.Worksheets: + if str(sheet.Name) != name: + continue + # Excel refuses to delete a very hidden sheet, and it + # prompts for confirmation on a visible one, so the sheet + # is revealed and the prompt suppressed around the delete. + sheet.Visible = XL_SHEET_VISIBLE + previous_alerts = bool(app.DisplayAlerts) + app.DisplayAlerts = False + try: + sheet.Delete() + finally: + app.DisplayAlerts = previous_alerts + break + except Exception as exc: # noqa: BLE001 - keep restoring + LOGGER.error( + "Could not delete scratch sheet %s | %s", name, exc + ) + self._created_sheets.clear() + LOGGER.debug("Workbook restored") diff --git a/vatic/excel/session.py b/vatic/excel/session.py new file mode 100644 index 0000000..5fc4dbc --- /dev/null +++ b/vatic/excel/session.py @@ -0,0 +1,443 @@ +# -------------------------------------*- vatic -*---------------------------- +# Open Source Risk Analysis +# +# Copyright (c) 2026, eggzec +# Contact: https://eggzec.github.io/ +# +# License: GNU General Public License +# Version 3, 29 June 2007 +# +# ---------------------------------------------------------------------------- +# +# Author(s) +# Saud Zahir +# +# Date +# 7 May 2026 +# +# Description +# The COM connection to Excel, and every read and write against it. +# +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from types import TracebackType + +from vatic.excel.errors import ( + CellResolutionError, + ExcelLinkError, + ExcelNotAvailableError, + WorkbookNotFoundError, + WorkbookReadOnlyError, + classify, +) +from vatic.logger import get_logger +from vatic.sheetmodel import CellRef, InteriorState + + +LOGGER = get_logger(__name__) + +# Excel enumerations, spelled out so no type library import is needed. +XL_CALC_AUTOMATIC = -4105 +XL_CALC_MANUAL = -4135 +XL_PATTERN_NONE = -4142 +XL_PATTERN_SOLID = 1 +XL_SHEET_VERY_HIDDEN = 2 +XL_SHEET_VISIBLE = -1 + + +def _to_bgr(rgb: str) -> int: + """Convert an ``#RRGGBB`` string to the BGR integer Excel expects. + + Args: + rgb: Hex colour string. + + Returns: + The colour as Excel's little-endian BGR integer. + """ + raw = rgb.lstrip("#") + red, green, blue = (int(raw[i : i + 2], 16) for i in (0, 2, 4)) + return blue << 16 | green << 8 | red + + +@dataclass(frozen=True) +class ApplicationState: + """The Excel settings a run changes, captured so they can be restored.""" + + screen_updating: bool + calculation: int + enable_events: bool + display_alerts: bool + + +def excel_available() -> bool: + """Whether Excel can actually be driven on this machine. + + The registry lookup proves Excel is installed without launching it, which + is what separates a machine that merely has pywin32 from one that has + Excel. A Windows CI runner is exactly the former, and without this check + the spreadsheet tests would try to start an Excel that is not there. + + Returns: + True when Windows, pywin32 and a registered Excel are all present. + """ + if sys.platform != "win32": + return False + + try: + import win32com.client # noqa: F401 + except ImportError: + LOGGER.debug("pywin32 is not installed; no spreadsheet link") + return False + + import winreg + + try: + with winreg.OpenKey( + winreg.HKEY_CLASSES_ROOT, r"Excel.Application\CLSID" + ): + return True + except OSError as exc: + LOGGER.debug("Excel is not registered on this machine | %s", exc) + return False + + +class ExcelSession: + """A connection to Excel, scoped to one workbook. + + The session owns the COM objects and therefore belongs to exactly one + thread. COM apartments are per-thread, so a session created on the GUI + thread cannot be used from a worker; construct it where it is used. + """ + + def __init__(self, *, visible: bool = True, private: bool = False) -> None: + """Prepare a session without connecting yet. + + Args: + visible: Whether a newly started Excel should be shown. + private: Start a dedicated Excel instance instead of attaching to + the one the user already has open. Useful for tests, which + must never disturb a real session. + """ + self._visible = visible + self._private = private + self._owns_instance = False + self._initialised_com = False + self.app: object | None = None + self.workbook: object | None = None + self._saved: ApplicationState | None = None + + # ------------------------------------------------------------ lifecycle + + def connect(self, path: str | None = None) -> str: + """Attach to Excel and select a workbook. + + Args: + path: Workbook to open. When omitted the active workbook of a + running Excel is used. + + Returns: + The name of the connected workbook. + + Raises: + ExcelNotAvailableError: If Excel cannot be reached. + WorkbookNotFoundError: If no workbook could be selected. + WorkbookReadOnlyError: If the workbook cannot be written to. + ExcelLinkError: If Excel refused the connection for any other + reason; the concrete type comes from ``classify``. + """ # noqa: DOC501 + if not excel_available(): + raise ExcelNotAvailableError( + "Microsoft Excel is not available on this machine" + ) + + import pythoncom + import win32com.client as client + + try: + pythoncom.CoInitialize() + self._initialised_com = True + except Exception: # noqa: BLE001 - already initialised is fine + self._initialised_com = False + + try: + if self._private: + self.app = client.DispatchEx("Excel.Application") + self._owns_instance = True + else: + try: + self.app = client.GetActiveObject("Excel.Application") + except Exception: # noqa: BLE001 - nothing running yet + self.app = client.DispatchEx("Excel.Application") + self._owns_instance = True + self.app.Visible = self._visible + except Exception as exc: # noqa: BLE001 - mapped to a typed error + raise classify(exc) from exc + + try: + if path is not None: + self.workbook = self.app.Workbooks.Open(path) + elif int(self.app.Workbooks.Count) > 0: + self.workbook = self.app.ActiveWorkbook + elif self._private: + # A dedicated instance starts empty; the caller populates it. + self.workbook = None + else: + raise WorkbookNotFoundError("No workbook is open in Excel") + except ExcelLinkError: + raise + except Exception as exc: # noqa: BLE001 - mapped to a typed error + raise classify(exc) from exc + + if self.workbook is None: + LOGGER.info("Connected to Excel with no workbook selected") + return "" + + if bool(self.workbook.ReadOnly): + raise WorkbookReadOnlyError( + f"'{self.workbook.Name}' is open read-only" + ) + + name = str(self.workbook.Name) + LOGGER.info("Connected to workbook | name=%s", name) + return name + + def new_workbook(self) -> str: + """Create and select an empty workbook. + + Connecting never creates one implicitly, because attaching to Excel + and silently spawning a blank book would be a surprising side effect + on someone's desktop. Callers that genuinely want a scratch workbook + ask for it. + + Returns: + The name of the new workbook. + + Raises: + ExcelLinkError: If Excel refused to create it. + """ # noqa: DOC501, DOC502 + if self.app is None: + raise ExcelNotAvailableError("Not connected to Excel") + try: + self.workbook = self.app.Workbooks.Add() + except Exception as exc: # noqa: BLE001 - mapped to a typed error + raise classify(exc) from exc + name = str(self.workbook.Name) + LOGGER.debug("Created workbook | name=%s", name) + return name + + def close(self) -> None: + """Release Excel, quitting only an instance this session started.""" + try: + if self._owns_instance and self.app is not None: + if self.workbook is not None: + self.workbook.Close(SaveChanges=False) + self.app.Quit() + except Exception as exc: # noqa: BLE001 - teardown must not raise + LOGGER.debug("Ignoring error while closing Excel | %s", exc) + finally: + self.workbook = None + self.app = None + # Deliberately no CoUninitialize here. Callers routinely still + # hold Range or Worksheet proxies when the session is closed, and + # tearing the apartment down underneath them makes their eventual + # collection raise RPC_E_DISCONNECTED (0x80010108), which surfaces + # as a hard interpreter crash rather than an exception. The + # apartment is released when the owning thread ends, which is the + # only point at which no proxy can outlive it. + + def __enter__(self) -> ExcelSession: + """Enter the session context. + + Returns: + This session. + """ + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Leave the session context, releasing Excel. + + Args: + exc_type: Exception class, when leaving because of one. + exc: The exception instance. + traceback: The traceback. + """ + self.close() + + # -------------------------------------------------------- app settings + + def begin_run(self) -> None: + """Put Excel into a quiet, deterministic state for a run.""" + app = self.app + self._saved = ApplicationState( + screen_updating=bool(app.ScreenUpdating), + calculation=int(app.Calculation), + enable_events=bool(app.EnableEvents), + display_alerts=bool(app.DisplayAlerts), + ) + app.ScreenUpdating = False + app.EnableEvents = False + app.DisplayAlerts = False + # Data tables are skipped under xlCalculationAutomaticExceptTables, + # so the run insists on full automatic calculation. + app.Calculation = XL_CALC_AUTOMATIC + LOGGER.debug("Excel prepared for run | saved=%s", self._saved) + + def end_run(self) -> None: + """Restore whatever Excel settings the run changed.""" + if self._saved is None or self.app is None: + return + app, saved = self.app, self._saved + for attribute, value in ( + ("Calculation", saved.calculation), + ("EnableEvents", saved.enable_events), + ("DisplayAlerts", saved.display_alerts), + ("ScreenUpdating", saved.screen_updating), + ): + try: + setattr(app, attribute, value) + except Exception as exc: # noqa: BLE001 - best effort restore + LOGGER.warning("Could not restore %s | %s", attribute, exc) + self._saved = None + LOGGER.debug("Excel settings restored") + + # --------------------------------------------------------------- cells + + def range(self, ref: CellRef): # noqa: ANN201 - a COM object + """Resolve a reference to a COM ``Range``. + + Args: + ref: The cell to resolve. + + Returns: + The Excel ``Range`` object. + + Raises: + CellResolutionError: If the sheet or cell does not exist. + """ + try: + sheet = ( + self.workbook.Worksheets(ref.sheet) + if ref.sheet + else self.workbook.ActiveSheet + ) + return sheet.Range(ref.cell) + except Exception as exc: # noqa: BLE001 - mapped to a typed error + raise CellResolutionError( + f"Cannot resolve {ref.qualified()} in " + f"'{getattr(self.workbook, 'Name', '?')}'" + ) from exc + + def read(self, ref: CellRef) -> object: + """Read one cell's value. + + Args: + ref: The cell to read. + + Returns: + The cell's value, which may encode a worksheet error. + """ + return self.range(ref).Value + + def read_formula(self, ref: CellRef) -> str: + """Read one cell's formula text. + + Args: + ref: The cell to read. + + Returns: + The formula, or the literal text when the cell holds a constant. + """ + return str(self.range(ref).Formula) + + def write_formula(self, ref: CellRef, formula: str) -> None: + """Write a formula or a literal into one cell. + + Args: + ref: The cell to write. + formula: Formula text, including the leading equals sign. + """ + self.range(ref).Formula = formula + + def sheet_names(self) -> list[str]: + """List the worksheets in the connected workbook. + + Returns: + Worksheet names in workbook order. + """ + return [str(s.Name) for s in self.workbook.Worksheets] + + def label_of(self, ref: CellRef) -> str: + """Guess a human label for a cell from the text to its left. + + Spreadsheet models put the name of a quantity immediately left of its + value, which is how the reference implementation's examples derived + every tag. + + Args: + ref: The cell being tagged. + + Returns: + The neighbouring label, or the cell reference when there is none. + """ + try: + cell = self.range(ref) + for offset in (-1, -2): + value = cell.Offset(1, offset).Value + if isinstance(value, str) and value.strip(): + return value.strip() + except Exception as exc: # noqa: BLE001 - a label is optional + LOGGER.debug("No label found for %s | %s", ref, exc) + return ref.cell + + # ------------------------------------------------------------ markup + + def capture_interior(self, ref: CellRef) -> InteriorState: + """Record a cell's fill so it can be restored exactly. + + Args: + ref: The cell to inspect. + + Returns: + The captured fill. + """ + interior = self.range(ref).Interior + return InteriorState( + color=int(interior.Color), pattern=int(interior.Pattern) + ) + + def highlight(self, ref: CellRef, rgb: str) -> None: + """Tint a cell so the tagging is visible inside Excel. + + Args: + ref: The cell to tint. + rgb: Colour as ``#RRGGBB``. + """ + interior = self.range(ref).Interior + interior.Pattern = XL_PATTERN_SOLID + interior.Color = _to_bgr(rgb) + + def restore_interior(self, ref: CellRef, state: InteriorState) -> None: + """Put a captured fill back. + + Setting the colour alone would leave a solid white fill on a cell + that previously had none, so the pattern is restored first. + + Args: + ref: The cell to restore. + state: The fill captured by :meth:`capture_interior`. + """ + interior = self.range(ref).Interior + if state.is_unfilled: + interior.Pattern = XL_PATTERN_NONE + return + interior.Pattern = state.pattern + interior.Color = state.color diff --git a/vatic/reporting.py b/vatic/reporting.py index 19ff2a4..17dbec6 100644 --- a/vatic/reporting.py +++ b/vatic/reporting.py @@ -63,10 +63,10 @@ def _base_layout(title: str) -> go.Layout: return go.Layout( title=title, template="plotly_white", - paper_bgcolor="#ffffff", - plot_bgcolor="#f8fbff", + paper_bgcolor="#FFFFFF", + plot_bgcolor="#FFFFFF", margin=dict(l=70, r=50, t=70, b=60), - font=dict(family="Segoe UI, sans-serif", size=12, color="#10233f"), + font=dict(family="Segoe UI, sans-serif", size=12, color="#13138C"), ) @@ -77,7 +77,7 @@ def _fig_histogram(output: np.ndarray) -> go.Figure: go.Histogram( x=output, nbinsx=bins, - marker=dict(color="#2A9D8F", line=dict(color="#264653", width=1)), + marker=dict(color="#2323FF", line=dict(color="#0F0F6B", width=1)), opacity=0.9, ) ) @@ -97,7 +97,7 @@ def _fig_cdf(output: np.ndarray) -> go.Figure: x=sorted_values, y=cumulative, mode="lines", - line=dict(color="#1D3557", width=2), + line=dict(color="#2323FF", width=2), ) ) fig.update_xaxes(title_text="Outcome") @@ -116,7 +116,7 @@ def _fig_exceedance(output: np.ndarray) -> go.Figure: x=sorted_values, y=exceedance, mode="lines", - line=dict(color="#E76F51", width=2), + line=dict(color="#C04AFF", width=2), ) ) fig.update_xaxes(title_text="Threshold") @@ -157,7 +157,7 @@ def _fig_tornado( names = [name for name, _ in points] values = [value for _, value in points] - colorscale = ["#E76F51" if value < 0 else "#2A9D8F" for value in values] + colorscale = ["#C04AFF" if value < 0 else "#2323FF" for value in values] fig.add_trace( go.Bar( x=values, y=names, orientation="h", marker=dict(color=colorscale) @@ -188,7 +188,7 @@ def _fig_var_cvar(output: np.ndarray, confidence: float = 0.95) -> go.Figure: go.Histogram( x=output, nbinsx=bins, - marker=dict(color="#8ECAE6", line=dict(color="#1D3557", width=1)), + marker=dict(color="#9C9CFF", line=dict(color="#2323FF", width=1)), opacity=0.85, ) ) @@ -197,7 +197,7 @@ def _fig_var_cvar(output: np.ndarray, confidence: float = 0.95) -> go.Figure: x=[var_threshold, var_threshold], y=[0.0, y_max], mode="lines", - line=dict(color="#E76F51", width=2, dash="dash"), + line=dict(color="#C04AFF", width=2, dash="dash"), name=f"VaR {confidence:.0%}", hovertemplate=f"VaR {confidence:.0%}: {var_threshold:,.4f}", ) @@ -207,7 +207,7 @@ def _fig_var_cvar(output: np.ndarray, confidence: float = 0.95) -> go.Figure: x=[cvar_value, cvar_value], y=[0.0, y_max], mode="lines", - line=dict(color="#D00000", width=2, dash="dot"), + line=dict(color="#772E9E", width=2, dash="dot"), name="CVaR", hovertemplate=f"CVaR: {cvar_value:,.4f}", ) @@ -221,7 +221,7 @@ def _fig_var_cvar(output: np.ndarray, confidence: float = 0.95) -> go.Figure: xanchor="left", showarrow=False, text=f"VaR {confidence:.0%}: {var_threshold:,.4f}", - font=dict(color="#9C2D1D", size=12), + font=dict(color="#7E3DFF", size=12), bgcolor="rgba(255,255,255,0.85)", ) fig.add_annotation( @@ -232,7 +232,7 @@ def _fig_var_cvar(output: np.ndarray, confidence: float = 0.95) -> go.Figure: xanchor="right", showarrow=False, text=f"CVaR: {cvar_value:,.4f}", - font=dict(color="#7A0000", size=12), + font=dict(color="#45228C", size=12), bgcolor="rgba(255,255,255,0.85)", ) fig.update_xaxes(title_text="Outcome") @@ -263,9 +263,9 @@ def _fig_kde(output: np.ndarray) -> go.Figure: x=x_grid, y=y_density, mode="lines", - line=dict(color="#2A9D8F", width=2.5), + line=dict(color="#2323FF", width=2.5), fill="tozeroy", - fillcolor="rgba(42,157,143,0.22)", + fillcolor="rgba(35,35,255,0.18)", ) ) fig.update_xaxes(title_text="Outcome") @@ -297,7 +297,7 @@ def _fig_qq_normal(output: np.ndarray) -> go.Figure: x=theoretical, y=observed, mode="markers", - marker=dict(size=6, color="#3A7CA5", opacity=0.6), + marker=dict(size=6, color="#24AEFF", opacity=0.6), name="Sample quantiles", ) ) @@ -306,7 +306,7 @@ def _fig_qq_normal(output: np.ndarray) -> go.Figure: x=theoretical, y=fit_line, mode="lines", - line=dict(color="#E76F51", width=2), + line=dict(color="#C04AFF", width=2), name=f"Reference line (r={corr:.4f})", ) ) @@ -326,7 +326,7 @@ def _fig_pareto(output: np.ndarray) -> go.Figure: fig = go.Figure(layout=_base_layout("Pareto (Binned Outcomes)")) fig.add_trace( go.Bar( - x=labels, y=counts, marker=dict(color="#3A7CA5"), name="Frequency" + x=labels, y=counts, marker=dict(color="#24AEFF"), name="Frequency" ) ) fig.add_trace( @@ -334,8 +334,8 @@ def _fig_pareto(output: np.ndarray) -> go.Figure: x=labels, y=cumulative, mode="lines+markers", - marker=dict(color="#E76F51", size=8), - line=dict(color="#E76F51", width=2), + marker=dict(color="#C04AFF", size=8), + line=dict(color="#C04AFF", width=2), name="Cumulative %", yaxis="y2", ) @@ -371,7 +371,7 @@ def _fig_trend(output: np.ndarray) -> go.Figure: x=x, y=running_mean, mode="lines", - line=dict(color="#1D3557", width=2.2), + line=dict(color="#2323FF", width=2.2), name="Running mean", ) ) @@ -380,7 +380,7 @@ def _fig_trend(output: np.ndarray) -> go.Figure: x=x, y=sampled, mode="lines", - line=dict(color="#A8DADC", width=1), + line=dict(color="#9C9CFF", width=1), opacity=0.6, name="Sample outcome", ) @@ -450,7 +450,7 @@ def _fig_scatter_primary( x=x, y=y, mode="markers", - marker=dict(color="#457B9D", size=6, opacity=0.45), + marker=dict(color="#4E269E", size=6, opacity=0.45), name=best_name, ) ) @@ -469,8 +469,8 @@ def _fig_box(output: np.ndarray, inputs: dict[str, np.ndarray]) -> go.Figure: x=values, name=name, boxpoints=False, - marker=dict(color="#3A7CA5"), - line=dict(color="#1D3557"), + marker=dict(color="#24AEFF"), + line=dict(color="#2323FF"), ) ) fig.update_xaxes(title_text="Value") @@ -489,8 +489,8 @@ def _fig_violin(output: np.ndarray, inputs: dict[str, np.ndarray]) -> go.Figure: box_visible=True, meanline_visible=True, points=False, - line_color="#1D3557", - fillcolor="rgba(69,123,157,0.35)", + line_color="#2323FF", + fillcolor="rgba(126,61,255,0.30)", ) ) fig.update_xaxes(title_text="Value") @@ -521,15 +521,15 @@ def _fig_rich_statistics(stats: dict[str, float]) -> go.Figure: go.Table( header=dict( values=["Metric", "Value"], - fill_color="#1D3557", + fill_color="#2323FF", font=dict(color="white", size=13), align="left", ), cells=dict( values=[labels, values], - fill_color=["#f8fbff", "#ffffff"], + fill_color=["#FFFFFF", "#FFFFFF"], align="left", - font=dict(color="#10233f", size=12), + font=dict(color="#13138C", size=12), height=30, ), ) @@ -539,8 +539,8 @@ def _fig_rich_statistics(stats: dict[str, float]) -> go.Figure: title="Rich Statistics", template="plotly_white", margin=dict(l=40, r=40, t=70, b=20), - paper_bgcolor="#ffffff", - font=dict(family="Segoe UI, sans-serif", size=12, color="#10233f"), + paper_bgcolor="#FFFFFF", + font=dict(family="Segoe UI, sans-serif", size=12, color="#13138C"), ) return fig @@ -626,14 +626,14 @@ def export_pdf_report( stats_table = Table(stats_rows, colWidths=[2.3 * inch, 3.7 * inch]) stats_table.setStyle( TableStyle([ - ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1D3557")), + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#2323FF")), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#9CB6D8")), + ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#D3D3FF")), ( "ROWBACKGROUNDS", (0, 1), (-1, -1), - [colors.white, colors.HexColor("#F8FBFF")], + [colors.white, colors.HexColor("#FAFAFF")], ), ("FONTSIZE", (0, 0), (-1, -1), 9), ("ALIGN", (0, 0), (-1, -1), "LEFT"), @@ -686,14 +686,14 @@ def export_pdf_report( ) capability_table.setStyle( TableStyle([ - ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1D3557")), + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#2323FF")), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#9CB6D8")), + ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#D3D3FF")), ( "ROWBACKGROUNDS", (0, 1), (-1, -1), - [colors.white, colors.HexColor("#F8FBFF")], + [colors.white, colors.HexColor("#FAFAFF")], ), ("FONTSIZE", (0, 0), (-1, -1), 9), ("ALIGN", (0, 0), (-1, -1), "LEFT"), @@ -718,14 +718,14 @@ def export_pdf_report( ) assumption_table.setStyle( TableStyle([ - ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1D3557")), + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#2323FF")), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#9CB6D8")), + ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#D3D3FF")), ( "ROWBACKGROUNDS", (0, 1), (-1, -1), - [colors.white, colors.HexColor("#F8FBFF")], + [colors.white, colors.HexColor("#FAFAFF")], ), ("FONTSIZE", (0, 0), (-1, -1), 8), ("ALIGN", (0, 0), (-1, -1), "LEFT"), diff --git a/vatic/resources.py b/vatic/resources.py new file mode 100644 index 0000000..2ba60ed --- /dev/null +++ b/vatic/resources.py @@ -0,0 +1,195 @@ +# -------------------------------------*- vatic -*---------------------------- +# Open Source Risk Analysis +# +# Copyright (c) 2026, eggzec +# Contact: https://eggzec.github.io/ +# +# License: GNU General Public License +# Version 3, 29 June 2007 +# +# ---------------------------------------------------------------------------- +# +# Author(s) +# Saud Zahir +# +# Date +# 7 May 2026 +# +# Description +# Bundled brand assets and the application icon. +# +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +import sys +from functools import lru_cache +from pathlib import Path + +from PySide6.QtCore import QRectF, Qt +from PySide6.QtGui import QFontDatabase, QIcon, QPainter, QPainterPath, QPixmap + +from vatic.logger import get_logger + + +LOGGER = get_logger(__name__) + +ASSETS_DIR = Path(__file__).resolve().parent / "assets" + +#: Raster sizes shipped alongside the vector logo. Qt picks the closest one for +#: window decorations, alt-tab and the task bar, so the small hand-tuned sizes +#: matter more than the large ones. +ICON_SIZES = (16, 24, 32, 48, 64, 128, 256, 512) + +#: Windows uses the AppUserModelID to group task bar buttons and to decide which +#: icon to show there. Without it a ``python.exe``-hosted app inherits the +#: interpreter's icon no matter what ``setWindowIcon`` says. +APP_USER_MODEL_ID = "eggzec.vatic.RiskAnalysis" + + +def asset_path(name: str) -> Path: + """Return the absolute path of a bundled asset. + + Args: + name: File name relative to the package ``assets`` directory. + + Returns: + Absolute path to the asset, which may not exist. + """ + return ASSETS_DIR / name + + +@lru_cache(maxsize=1) +def app_icon() -> QIcon: + """Build the application icon from every bundled resolution. + + Returns: + A multi-resolution icon, or an empty icon when no asset is bundled. + """ + icon = QIcon() + for size in ICON_SIZES: + candidate = asset_path(f"vatic-icon-{size}.png") + if candidate.exists(): + icon.addFile(str(candidate)) + + if icon.isNull(): + fallback = asset_path("vatic-icon.png") + if fallback.exists(): + icon.addFile(str(fallback)) + + if icon.isNull(): + LOGGER.warning("No bundled application icon found in %s", ASSETS_DIR) + else: + LOGGER.debug( + "Loaded application icon | sizes=%s", + [f"{s.width()}x{s.height()}" for s in icon.availableSizes()], + ) + return icon + + +def logo_pixmap(height: int) -> QPixmap: + """Return the logo scaled to ``height`` for use as an in-app brand mark. + + Args: + height: Target height in device-independent pixels. + + Returns: + A square pixmap, or a null pixmap when no asset is bundled. + """ + pixmap = app_icon().pixmap(height, height) + if pixmap.isNull(): + LOGGER.warning("Logo pixmap unavailable at height=%s", height) + return pixmap + + +def rounded_logo_pixmap(size: int, radius: int = 7) -> QPixmap: + """Return the logo as a rounded square for use on light chrome. + + The mark is a full-bleed electric-blue square, which reads as an + unfinished screenshot when dropped straight onto a white surface. Rounding + the corners makes it read as a deliberate app icon instead. + + Args: + size: Edge length in device-independent pixels. + radius: Corner radius in device-independent pixels. + + Returns: + A rounded pixmap, or a null pixmap when no asset is bundled. + """ + source = app_icon().pixmap(size, size) + if source.isNull(): + return source + + ratio = source.devicePixelRatio() or 1.0 + rounded = QPixmap(source.size()) + rounded.setDevicePixelRatio(ratio) + rounded.fill(Qt.transparent) + + painter = QPainter(rounded) + painter.setRenderHint(QPainter.Antialiasing) + path = QPainterPath() + path.addRoundedRect(QRectF(0, 0, size, size), float(radius), float(radius)) + painter.setClipPath(path) + painter.drawPixmap(0, 0, size, size, source) + painter.end() + return rounded + + +#: Weights bundled under the SIL Open Font License (see assets/fonts/OFL.txt). +FONT_FILES = ( + "JetBrainsMono-Regular.ttf", + "JetBrainsMono-Medium.ttf", + "JetBrainsMono-SemiBold.ttf", + "JetBrainsMono-Bold.ttf", +) + + +@lru_cache(maxsize=1) +def load_bundled_fonts() -> tuple[str, ...]: + """Register the bundled JetBrains Mono weights with Qt. + + Shipping the font means the interface looks the same on machines that do + not have it installed, instead of silently falling back. + + Returns: + The font family names Qt registered, empty when none could be loaded. + """ + families: list[str] = [] + for name in FONT_FILES: + path = ASSETS_DIR / "fonts" / name + if not path.exists(): + LOGGER.debug("Bundled font missing | file=%s", name) + continue + font_id = QFontDatabase.addApplicationFont(str(path)) + if font_id == -1: + LOGGER.warning("Qt rejected bundled font | file=%s", name) + continue + families.extend(QFontDatabase.applicationFontFamilies(font_id)) + + unique = tuple(dict.fromkeys(families)) + if unique: + LOGGER.debug("Registered bundled fonts | families=%s", list(unique)) + else: + LOGGER.warning("No bundled fonts registered; falling back to system") + return unique + + +def register_app_user_model_id() -> None: + """Tell Windows this process is vatic so the task bar shows our icon. + + No-op on every platform other than Windows, and on Windows failures are + swallowed because an unset identifier only degrades the task bar icon. + """ + if sys.platform != "win32": + return + + try: + import ctypes + + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( + APP_USER_MODEL_ID + ) + except (AttributeError, OSError) as exc: + LOGGER.debug("Could not set AppUserModelID | error=%s", exc) + else: + LOGGER.debug("AppUserModelID set | id=%s", APP_USER_MODEL_ID) diff --git a/vatic/sheetmodel.py b/vatic/sheetmodel.py new file mode 100644 index 0000000..a20cc0a --- /dev/null +++ b/vatic/sheetmodel.py @@ -0,0 +1,267 @@ +# -------------------------------------*- vatic -*---------------------------- +# Open Source Risk Analysis +# +# Copyright (c) 2026, eggzec +# Contact: https://eggzec.github.io/ +# +# License: GNU General Public License +# Version 3, 29 June 2007 +# +# ---------------------------------------------------------------------------- +# +# Author(s) +# Saud Zahir +# +# Date +# 7 May 2026 +# +# Description +# Data model for a spreadsheet-backed analysis. +# +# ---------------------------------------------------------------------------- +# +# Deliberately free of COM, Qt and openpyxl so it imports on every platform. +# Storage, pre-flight, the window and the Excel runner all share these types, +# which keeps the Windows-only half of the feature a thin execution layer +# rather than a parallel model. +# +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +from vatic.logger import get_logger + + +LOGGER = get_logger(__name__) + +#: ``[Book1.xlsx]Sheet One!$C$8``, with the workbook and sheet optional. +_REFERENCE = re.compile( + r"^\s*(?:\[(?P[^\]]+)\])?" + r"(?:(?P'[^']+'|[^!']+)!)?" + r"(?P\$?[A-Za-z]{1,3}\$?\d{1,7})\s*$" +) + +_CELL = re.compile(r"^\$?([A-Za-z]{1,3})\$?(\d{1,7})$") + + +class ReferenceError(ValueError): + """Raised when a cell reference cannot be understood.""" + + +@dataclass(frozen=True) +class CellRef: + """A single cell, optionally qualified by sheet and workbook.""" + + cell: str + sheet: str | None = None + workbook: str | None = None + + @classmethod + def parse(cls, text: str) -> CellRef: + """Parse an A1-style reference. + + Accepts ``C8``, ``Sheet1!C8``, ``'Seal-Groove Design'!$C$8`` and + ``[Book1.xlsx]Sheet1!C8``. + + Args: + text: The reference to parse. + + Returns: + The parsed reference, with dollar signs stripped. + + Raises: + ReferenceError: If the text is not an A1-style reference. + """ + match = _REFERENCE.match(text or "") + if match is None: + raise ReferenceError(f"Not a cell reference: {text!r}") + + sheet = match.group("quoted") + if sheet is not None: + sheet = sheet.strip().strip("'") + + return cls( + cell=match.group("cell").replace("$", "").upper(), + sheet=sheet or None, + workbook=match.group("workbook"), + ) + + @property + def column(self) -> str: + """Return the column letters. + + Returns: + The column portion, upper case. + + Raises: + ReferenceError: If the cell is malformed. + """ + match = _CELL.match(self.cell) + if match is None: + raise ReferenceError(f"Malformed cell: {self.cell!r}") + return match.group(1).upper() + + @property + def row(self) -> int: + """Return the one-based row number. + + Returns: + The row portion as an integer. + + Raises: + ReferenceError: If the cell is malformed. + """ + match = _CELL.match(self.cell) + if match is None: + raise ReferenceError(f"Malformed cell: {self.cell!r}") + return int(match.group(2)) + + def qualified(self) -> str: + """Render the reference the way Excel writes it in a formula. + + Returns: + A reference string including the sheet when one is known. + """ + if not self.sheet: + return self.cell + sheet = self.sheet + if not sheet.replace("_", "").isalnum(): + sheet = f"'{sheet}'" + return f"{sheet}!{self.cell}" + + def __str__(self) -> str: + """Return the qualified reference. + + Returns: + The same string as :meth:`qualified`. + """ + return self.qualified() + + +@dataclass(frozen=True) +class InteriorState: + """The fill of a cell, captured so it can be put back exactly. + + Restoring the colour alone is not enough: an unfilled cell reports + ``Color == 16777215`` with ``Pattern == xlNone``, so writing the colour + back leaves a solid white fill where there was none before. + """ + + #: ``Interior.Color`` as Excel's BGR integer. + color: int + #: ``Interior.Pattern``; ``-4142`` is ``xlNone``, meaning no fill at all. + pattern: int + + XL_PATTERN_NONE = -4142 + + @property + def is_unfilled(self) -> bool: + """Whether the cell had no fill. + + Returns: + True when the pattern is ``xlNone``. + """ + return self.pattern == self.XL_PATTERN_NONE + + +@dataclass +class SheetAssumption: + """An input cell that a distribution is sampled into.""" + + ref: CellRef + tag: str + distribution: str + parameters: dict[str, float] = field(default_factory=dict) + #: The cell's contents before vatic touched it. A formula is kept as its + #: formula text, so restoring never silently replaces it with a number. + original_formula: str | None = None + original_interior: InteriorState | None = None + + def as_dict(self) -> dict[str, object]: + """Return a JSON-safe representation. + + Returns: + The assumption as plain data, for storage. + """ + return { + "cell": self.ref.qualified(), + "workbook": self.ref.workbook, + "tag": self.tag, + "distribution": self.distribution, + "parameters": dict(self.parameters), + } + + +@dataclass +class SheetForecast: + """An output cell whose value is collected each trial.""" + + ref: CellRef + tag: str + lsl: float | None = None + usl: float | None = None + target: float | None = None + original_interior: InteriorState | None = None + + def as_dict(self) -> dict[str, object]: + """Return a JSON-safe representation. + + Returns: + The forecast as plain data, for storage. + """ + return { + "cell": self.ref.qualified(), + "workbook": self.ref.workbook, + "tag": self.tag, + "lsl": self.lsl, + "usl": self.usl, + "target": self.target, + } + + +@dataclass +class SheetModel: + """Everything vatic needs to run a workbook-backed simulation.""" + + workbook: str = "" + assumptions: list[SheetAssumption] = field(default_factory=list) + forecasts: list[SheetForecast] = field(default_factory=list) + + def validate(self) -> None: + """Check the model can be run at all. + + Raises: + ValueError: If the model is missing inputs, missing outputs, or + reuses a tag or a cell. + """ + if not self.assumptions: + raise ValueError("Tag at least one assumption cell before running") + if not self.forecasts: + raise ValueError("Tag at least one forecast cell before running") + + tags: set[str] = set() + for item in (*self.assumptions, *self.forecasts): + if item.tag in tags: + raise ValueError(f"Duplicate tag: {item.tag}") + tags.add(item.tag) + + cells = [a.ref.qualified() for a in self.assumptions] + overlap = cells and set(cells) & { + f.ref.qualified() for f in self.forecasts + } + if overlap: + raise ValueError( + f"A cell cannot be both an assumption and a forecast: " + f"{', '.join(sorted(overlap))}" + ) + if len(set(cells)) != len(cells): + raise ValueError("The same cell is tagged as two assumptions") + + LOGGER.debug( + "Sheet model validated | assumptions=%s | forecasts=%s", + len(self.assumptions), + len(self.forecasts), + ) diff --git a/vatic/theme.py b/vatic/theme.py new file mode 100644 index 0000000..ee90738 --- /dev/null +++ b/vatic/theme.py @@ -0,0 +1,777 @@ +# -------------------------------------*- vatic -*---------------------------- +# Open Source Risk Analysis +# +# Copyright (c) 2026, eggzec +# Contact: https://eggzec.github.io/ +# +# License: GNU General Public License +# Version 3, 29 June 2007 +# +# ---------------------------------------------------------------------------- +# +# Author(s) +# Saud Zahir +# +# Date +# 7 May 2026 +# +# Description +# Brand palette, design tokens and the application style sheet. +# +# ---------------------------------------------------------------------------- +# +# The palette is deliberately closed: white plus the four hues taken from the +# vatic banner. Every other value in this module is a tint (mixed toward +# white) or a shade (mixed toward black) of one of those four hues, so the +# whole interface stays inside the brand's hue family. +# +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +from pathlib import Path + +from vatic.logger import get_logger + + +LOGGER = get_logger(__name__) + +# --------------------------------------------------------------- brand hues + +WHITE = "#FFFFFF" +BLUE = "#2323FF" # banner field hue 240 +VIOLET = "#7E3DFF" # banner orb hue 260 +MAGENTA = "#C04AFF" # banner highlight hue 279 +CYAN = "#24AEFF" # banner spark hue 202 + +BRAND_HUES = (BLUE, VIOLET, MAGENTA, CYAN) + + +def _channels(value: str) -> tuple[int, int, int]: + """Split a ``#RRGGBB`` string into integer channels. + + Args: + value: Hex colour string, with or without a leading hash. + + Returns: + The red, green and blue channels. + """ + raw = value.lstrip("#") + return int(raw[0:2], 16), int(raw[2:4], 16), int(raw[4:6], 16) + + +def tint(colour: str, amount: float) -> str: + """Mix ``colour`` toward white. + + Args: + colour: Base hex colour. + amount: Fraction of the base colour to keep, from 0.0 to 1.0. + + Returns: + Hex string for the tinted colour. + """ + r, g, b = _channels(colour) + mixed = ( + round(r * amount + 255 * (1 - amount)), + round(g * amount + 255 * (1 - amount)), + round(b * amount + 255 * (1 - amount)), + ) + return "#{:02X}{:02X}{:02X}".format(*mixed) + + +def shade(colour: str, amount: float) -> str: + """Mix ``colour`` toward black. + + Args: + colour: Base hex colour. + amount: Fraction of the base colour to keep, from 0.0 to 1.0. + + Returns: + Hex string for the shaded colour. + """ + r, g, b = _channels(colour) + return f"#{round(r * amount):02X}{round(g * amount):02X}{round(b * amount):02X}" + + +def alpha(colour: str, opacity: float) -> str: + """Render ``colour`` as a Qt ``rgba()`` string. + + Qt reads eight digit hex as ``#AARRGGBB`` rather than CSS's ``#RRGGBBAA``, + so transparency is always expressed as ``rgba()`` to avoid the ambiguity. + + Args: + colour: Base hex colour. + opacity: Alpha channel from 0.0 to 1.0. + + Returns: + A Qt style sheet ``rgba(...)`` literal. + """ + r, g, b = _channels(colour) + return f"rgba({r}, {g}, {b}, {opacity:.3f})" + + +def _luminance(colour: str) -> float: + """Return the WCAG relative luminance of ``colour``. + + Args: + colour: Hex colour string. + + Returns: + Relative luminance between 0.0 and 1.0. + """ + + def channel(value: int) -> float: + srgb = value / 255 + if srgb <= 0.04045: + return srgb / 12.92 + return ((srgb + 0.055) / 1.055) ** 2.4 + + r, g, b = (channel(c) for c in _channels(colour)) + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + +def contrast(foreground: str, background: str) -> float: + """Return the WCAG contrast ratio between two colours. + + Args: + foreground: Hex colour of the text or glyph. + background: Hex colour behind it. + + Returns: + Contrast ratio, from 1.0 to 21.0. + """ + first, second = _luminance(foreground), _luminance(background) + lighter, darker = max(first, second), min(first, second) + return (lighter + 0.05) / (darker + 0.05) + + +# ------------------------------------------------------------------- tokens + +#: Semantic design tokens. Each value is white, one of the four brand hues, or +#: a tint or shade of one of them, so the closed palette holds everywhere. +NEUTRAL = "#1F242C" # cool near-black, the ink the interface reads on + +TOKENS: dict[str, str] = { + # Surfaces. White dominates, with cool neutral greys for recessed areas. + "surface.canvas": WHITE, + "surface.panel": WHITE, + "surface.sunken": "#F5F7FA", + "surface.stripe": "#FAFBFD", + "surface.hover": "#EEF2F8", + "surface.pressed": "#E1E7F0", + "surface.header": "#F7F9FC", + # Ink. Neutral, not brand blue. Saturated blue text clears the contrast + # threshold on paper but is tiring to read for long stretches and blurs + # against the accents, so text is near-black and the brand hues are kept + # for the things that are actually interactive. + "ink.strong": "#12161C", + "ink.body": NEUTRAL, + "ink.muted": "#5A6472", + "ink.placeholder": "#667085", + "ink.onAccent": WHITE, + "ink.brand": BLUE, + # Lines. + "border.hairline": "#E6EAF1", + "border.subtle": "#D6DDE7", + "border.strong": "#B9C2D0", + "border.input": "#8B94A5", + "border.focus": BLUE, + # Interaction. The brand blue now carries every interactive affordance, + # which is what makes it read as an accent rather than as body text. + "accent": BLUE, + "accent.hover": shade(BLUE, 0.86), + "accent.pressed": shade(BLUE, 0.72), + "accent.wash": tint(BLUE, 0.08), + "accent.violet": VIOLET, + "accent.magenta": MAGENTA, + "accent.cyan": CYAN, + "selection.bg": BLUE, + "selection.fg": WHITE, + "selection.soft": tint(BLUE, 0.12), + # Panel washes, one per logo hue, kept pale so near-black ink stays + # comfortably legible on top of them. + "wash.cyan": tint(CYAN, 0.10), + "wash.blue": tint(BLUE, 0.07), + "wash.violet": tint(VIOLET, 0.08), + "wash.magenta": tint(MAGENTA, 0.08), +} + +#: Ordered categorical ramp for charts. Lightness is deliberately staggered so +#: the series stay separable for viewers with red-green colour deficiency, +#: where hue alone across four blue-to-magenta hues would not be enough. +CHART_SEQUENCE: tuple[str, ...] = ( + CYAN, + BLUE, + MAGENTA, + shade(VIOLET, 0.62), + tint(BLUE, 0.45), + shade(MAGENTA, 0.60), +) + +#: JetBrains Mono ships with the package under the SIL Open Font License, so +#: it leads the stack as a family that is actually present rather than a +#: hopeful first entry. The rest are safety nets for a checkout with the font +#: assets stripped out. +FONT_STACK = ( + '"JetBrains Mono", "Cascadia Mono", "Consolas", ' + '"DejaVu Sans Mono", monospace' +) +MONO_STACK = FONT_STACK + + +def audit_contrast() -> list[tuple[str, str, float]]: + """Check every meaningful ink and surface pairing. + + Returns: + Triples of foreground token, background token and contrast ratio, + ordered worst first. + """ + pairs = [ + ("ink.strong", "surface.panel"), + ("ink.body", "surface.panel"), + ("ink.body", "surface.stripe"), + ("ink.body", "surface.sunken"), + ("ink.body", "surface.hover"), + ("ink.muted", "surface.panel"), + ("ink.muted", "surface.sunken"), + ("ink.placeholder", "surface.panel"), + ("ink.brand", "surface.panel"), + ("ink.brand", "surface.sunken"), + ("ink.onAccent", "accent"), + ("ink.body", "accent.wash"), + ("ink.body", "wash.cyan"), + ("ink.body", "wash.blue"), + ("ink.body", "wash.violet"), + ("ink.body", "wash.magenta"), + ("ink.muted", "wash.violet"), + ("ink.muted", "wash.magenta"), + ("ink.muted", "surface.hover"), + ("selection.fg", "selection.bg"), + ("ink.strong", "selection.soft"), + ("border.input", "surface.panel"), + ] + report = [(fg, bg, contrast(TOKENS[fg], TOKENS[bg])) for fg, bg in pairs] + report.sort(key=lambda row: row[2]) + return report + + +# ------------------------------------------------------------- style sheet + +#: Qt style sheets have no vector primitives, so chevrons and the tick ship as +#: tiny SVGs. Paths are POSIX-style because Qt treats a backslash inside a +#: style sheet url() as an escape character. +_ASSETS = (Path(__file__).resolve().parent / "assets").as_posix() + +_STYLE_SHEET = """ +/* ----------------------------------------------------------- foundation */ +QWidget {{ + background: {surface_canvas}; + color: {ink_body}; + font-family: {font}; + font-size: 10pt; + selection-background-color: {selection_bg}; + selection-color: {selection_fg}; +}} +QMainWindow, QDialog {{ background: {surface_canvas}; }} + +/* ------------------------------------------------------------- menu bar */ +QMenuBar {{ + background: {surface_canvas}; + border-bottom: 1px solid {border_hairline}; + padding: 2px 6px; +}} +QMenuBar::item {{ + background: transparent; + padding: 6px 12px; + margin: 2px; + border-radius: 7px; + color: {ink_body}; +}} +QMenuBar::item:selected {{ background: {accent_wash}; color: {ink_brand}; }} +QMenuBar::item:pressed {{ background: {surface_pressed}; }} + +QMenu {{ + background: {surface_panel}; + border: 1px solid {border_subtle}; + border-radius: 10px; + padding: 6px; +}} +QMenu::item {{ + padding: 7px 18px; + border-radius: 7px; + color: {ink_body}; +}} +QMenu::item:selected {{ background: {accent}; color: {ink_on_accent}; }} +QMenu::item:disabled {{ color: {ink_muted}; }} +QMenu::separator {{ + height: 1px; + background: {border_hairline}; + margin: 5px 8px; +}} + +/* --------------------------------------------------------------- panels */ +QGroupBox {{ + background: {surface_panel}; + border: 1px solid {border_hairline}; + border-radius: 12px; + margin-top: 15px; + padding: 14px 12px 12px 12px; + font-weight: 600; +}} +QGroupBox::title {{ + subcontrol-origin: margin; + subcontrol-position: top left; + left: 14px; + padding: 0 6px; + color: {ink_muted}; + font-size: 8pt; + font-weight: 700; +}} + +/* --------------------------------------------------------------- labels */ +QLabel {{ background: transparent; color: {ink_body}; }} +QLabel#brandWordmark {{ + color: {ink_strong}; + font-size: 15pt; + font-weight: 700; +}} +QLabel#brandTagline {{ color: {ink_muted}; font-size: 8pt; font-weight: 600; }} +QLabel#sectionTitle {{ + color: {ink_muted}; + font-size: 8pt; + font-weight: 700; +}} +QLabel#analysisName {{ + color: {ink_strong}; + font-size: 11pt; + font-weight: 700; +}} +QLabel#metaLabel {{ color: {ink_muted}; font-size: 9pt; }} +QLabel#statsCard {{ + background: {surface_sunken}; + border: 1px solid {border_hairline}; + border-radius: 12px; + padding: 14px 16px; + color: {ink_body}; + font-family: {mono}; + font-size: 9pt; +}} +QLabel#emptyState {{ color: {ink_muted}; font-size: 10pt; }} + +/* --------------------------------------------------------------- inputs */ +QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox, QPlainTextEdit, QTextEdit {{ + background: {surface_panel}; + border: 1px solid {border_input}; + border-radius: 9px; + padding: 7px 11px; + color: {ink_body}; + min-height: 18px; +}} +QLineEdit:hover, QComboBox:hover, QSpinBox:hover {{ border-color: {ink_muted}; }} +QLineEdit:focus, QComboBox:focus, QSpinBox:focus, +QPlainTextEdit:focus, QTextEdit:focus {{ + border: 2px solid {border_focus}; + padding: 6px 10px; +}} +QLineEdit:disabled, QComboBox:disabled, QSpinBox:disabled {{ + background: {surface_sunken}; + color: {ink_muted}; + border-color: {border_subtle}; +}} + +QComboBox {{ padding-right: 32px; }} +QComboBox::drop-down {{ + subcontrol-origin: padding; + subcontrol-position: center right; + width: 28px; + border: none; + background: transparent; +}} +QComboBox::down-arrow {{ + image: url({assets}/chevron-down.svg); + width: 14px; + height: 14px; +}} +QComboBox::down-arrow:disabled {{ + image: url({assets}/chevron-down-muted.svg); +}} +QComboBox QAbstractItemView {{ + background: {surface_panel}; + border: 1px solid {border_subtle}; + border-radius: 10px; + padding: 5px; + outline: none; + selection-background-color: {accent}; + selection-color: {ink_on_accent}; +}} +QComboBox QAbstractItemView::item {{ + padding: 6px 10px; + border-radius: 6px; + min-height: 20px; +}} + +/* Editors embedded in table cells drop the standalone control's generous + padding, but they still need enough height for a full line of text: too + little and the glyphs are clipped through the middle. */ +QTableWidget QComboBox, QTableView QComboBox, +QTableWidget QLineEdit, QTableView QLineEdit, +QTableWidget QSpinBox, QTableView QSpinBox {{ + min-height: 22px; + padding: 0 8px; + border-radius: 6px; + border: 1px solid {border_hairline}; + background: {surface_panel}; + color: {ink_body}; +}} +QTableWidget QComboBox {{ padding-right: 26px; }} +QTableWidget QComboBox:hover, QTableView QComboBox:hover {{ + border-color: {accent}; +}} +QTableWidget QComboBox::drop-down, QTableView QComboBox::drop-down {{ + width: 22px; +}} +QTableWidget QComboBox::down-arrow, QTableView QComboBox::down-arrow {{ + width: 12px; + height: 12px; +}} + +QSpinBox, QDoubleSpinBox {{ padding-right: 28px; }} +QSpinBox::up-button, QDoubleSpinBox::up-button {{ + subcontrol-origin: border; + subcontrol-position: top right; + width: 24px; + height: 15px; + border: none; + background: transparent; + margin-right: 4px; +}} +QSpinBox::down-button, QDoubleSpinBox::down-button {{ + subcontrol-origin: border; + subcontrol-position: bottom right; + width: 24px; + height: 15px; + border: none; + background: transparent; + margin-right: 4px; +}} +QSpinBox::up-arrow, QDoubleSpinBox::up-arrow {{ + image: url({assets}/chevron-up.svg); + width: 11px; + height: 11px; +}} +QSpinBox::down-arrow, QDoubleSpinBox::down-arrow {{ + image: url({assets}/chevron-down.svg); + width: 11px; + height: 11px; +}} + +QCheckBox, QRadioButton {{ background: transparent; spacing: 8px; }} +QCheckBox::indicator, QRadioButton::indicator {{ + width: 17px; + height: 17px; + border: 1px solid {border_input}; + background: {surface_panel}; +}} +QCheckBox::indicator {{ border-radius: 5px; }} +QRadioButton::indicator {{ border-radius: 9px; }} +QCheckBox::indicator:checked {{ + background: {accent}; + border-color: {accent}; + image: url({assets}/check.svg); +}} +QRadioButton::indicator:checked {{ + border: 1px solid {accent}; + background: qradialgradient(cx:0.5, cy:0.5, radius:0.5, + fx:0.5, fy:0.5, + stop:0 {accent}, stop:0.55 {accent}, + stop:0.6 {surface_panel}, stop:1 {surface_panel}); +}} + +/* -------------------------------------------------------------- buttons */ +QPushButton {{ + background: {surface_panel}; + border: 1px solid {border_input}; + border-radius: 9px; + padding: 8px 16px; + color: {ink_body}; + font-weight: 600; + min-height: 18px; +}} +QPushButton:hover {{ background: {accent_wash}; border-color: {accent}; }} +QPushButton:pressed {{ background: {surface_pressed}; }} +QPushButton:disabled {{ + background: {surface_sunken}; + color: {ink_muted}; + border-color: {border_subtle}; +}} + +QPushButton[variant="primary"] {{ + background: {accent}; + border: 1px solid {accent}; + color: {ink_on_accent}; + padding: 9px 22px; + font-weight: 700; +}} +QPushButton[variant="primary"]:hover {{ + background: {accent_hover}; + border-color: {accent_hover}; +}} +QPushButton[variant="primary"]:pressed {{ background: {accent_pressed}; }} +QPushButton[variant="primary"]:disabled {{ + background: {surface_sunken}; + border-color: {border_subtle}; + color: {ink_muted}; +}} + +QPushButton[variant="ghost"] {{ + background: transparent; + border: 1px solid transparent; + color: {ink_muted}; + padding: 7px 12px; +}} +QPushButton[variant="ghost"]:hover {{ + background: {accent_wash}; + color: {ink_brand}; +}} + +QPushButton[calcKey="true"] {{ + background: {surface_sunken}; + border: 1px solid {border_subtle}; + border-radius: 8px; + color: {ink_body}; + font-family: {mono}; + font-size: 9pt; + font-weight: 600; + padding: 6px 4px; +}} +QPushButton[calcKey="true"]:hover {{ + background: {accent_wash}; + border-color: {accent}; +}} +QPushButton[calcKey="true"]:pressed {{ + background: {accent}; + color: {ink_on_accent}; +}} +QPushButton[calcKey="fn"] {{ + background: {surface_sunken}; + border: 1px solid {border_hairline}; + border-radius: 8px; + color: {accent_violet}; + font-family: {mono}; + font-size: 9pt; + font-weight: 600; + padding: 6px 4px; +}} +QPushButton[calcKey="fn"]:hover {{ + background: {accent_wash}; + border-color: {accent_violet}; +}} +QPushButton[calcKey="edit"] {{ + background: {surface_sunken}; + border: 1px solid {border_hairline}; + border-radius: 8px; + color: {accent_magenta}; + font-family: {mono}; + font-size: 9pt; + font-weight: 700; + padding: 6px 4px; +}} +QPushButton[calcKey="edit"]:hover {{ + background: {accent_wash}; + border-color: {accent_magenta}; +}} + +/* --------------------------------------------------------------- tables */ +QTableWidget, QTableView {{ + background: {surface_panel}; + alternate-background-color: {surface_stripe}; + gridline-color: {border_hairline}; + border: 1px solid {border_hairline}; + border-radius: 10px; + color: {ink_body}; + outline: none; + /* A cell's text is drawn by the delegate using these, not by the + ::item:selected rule below. Leaving them at the global white-on-blue + pair put white text on the pale selected row. */ + selection-background-color: {selection_soft}; + selection-color: {ink_strong}; +}} +QTableWidget::item, QTableView::item {{ padding: 5px 7px; border: none; }} +QTableWidget::item:selected, QTableView::item:selected {{ + background: {selection_soft}; + color: {ink_strong}; +}} + +QHeaderView {{ background: transparent; }} +QHeaderView::section {{ + background: {surface_panel}; + color: {ink_muted}; + padding: 8px; + border: none; + border-bottom: 1px solid {border_subtle}; + font-size: 8pt; + font-weight: 700; +}} +QHeaderView::section:horizontal {{ border-right: 1px solid {border_hairline}; }} +QHeaderView::section:vertical {{ + border-right: 1px solid {border_hairline}; + border-bottom: 1px solid {border_hairline}; + padding: 4px 6px; + font-weight: 600; +}} +QHeaderView::section:hover {{ color: {ink_brand}; }} +QTableCornerButton::section {{ + background: {surface_panel}; + border: none; + border-bottom: 1px solid {border_subtle}; + border-right: 1px solid {border_hairline}; +}} + +/* ---------------------------------------------------------------- lists */ +QListWidget, QListView, QTreeView {{ + background: {surface_panel}; + border: 1px solid {border_hairline}; + border-radius: 10px; + padding: 5px; + outline: none; + color: {ink_body}; +}} +QListWidget::item, QListView::item {{ + padding: 8px 10px; + border-radius: 7px; + margin: 1px 0; +}} +QListWidget::item:hover {{ background: {accent_wash}; }} +QListWidget::item:selected {{ background: {accent}; color: {ink_on_accent}; }} + +/* ---------------------------------------------------------- scroll bars */ +QScrollBar:vertical {{ + background: transparent; + width: 11px; + margin: 3px 2px 3px 0; +}} +QScrollBar:horizontal {{ + background: transparent; + height: 11px; + margin: 0 3px 2px; +}} +QScrollBar::handle:vertical {{ + background: {border_strong}; + border-radius: 5px; + min-height: 34px; +}} +QScrollBar::handle:horizontal {{ + background: {border_strong}; + border-radius: 5px; + min-width: 34px; +}} +QScrollBar::handle:hover {{ background: {ink_muted}; }} +QScrollBar::add-line, QScrollBar::sub-line {{ + height: 0; + width: 0; + border: none; + background: transparent; +}} +QScrollBar::add-page, QScrollBar::sub-page {{ background: transparent; }} + +/* -------------------------------------------------------------- chrome */ +QSplitter::handle {{ background: transparent; }} +QSplitter::handle:horizontal {{ width: 9px; }} +QSplitter::handle:vertical {{ height: 9px; }} +QSplitter::handle:hover {{ background: {accent_wash}; }} + +QStatusBar {{ + background: {surface_canvas}; + border-top: 1px solid {border_hairline}; + color: {ink_muted}; + padding: 2px 8px; +}} +QStatusBar::item {{ border: none; }} + +QToolTip {{ + background: {ink_strong}; + color: {surface_panel}; + border: none; + border-radius: 7px; + padding: 6px 9px; +}} + +QDialogButtonBox QPushButton {{ min-width: 84px; }} +QMessageBox {{ background: {surface_panel}; }} +QMessageBox QLabel {{ color: {ink_body}; }} + +QProgressBar {{ + background: {surface_sunken}; + border: none; + border-radius: 5px; + height: 8px; + text-align: center; + color: {ink_brand}; + font-size: 8pt; +}} +QProgressBar::chunk {{ background: {accent}; border-radius: 5px; }} + +QSlider::groove:horizontal {{ + background: {surface_pressed}; + height: 5px; + border-radius: 3px; +}} +QSlider::sub-page:horizontal {{ background: {accent}; border-radius: 3px; }} +QSlider::handle:horizontal {{ + background: {surface_panel}; + border: 2px solid {accent}; + width: 13px; + height: 13px; + margin: -5px 0; + border-radius: 8px; +}} +QSlider::handle:horizontal:hover {{ border-color: {accent_magenta}; }} + +/* ------------------------------------------------- named layout regions */ +QWidget#appHeader {{ + background: {surface_panel}; + border-bottom: 1px solid {border_hairline}; +}} +QWidget#keypadPanel {{ + background: {surface_sunken}; + border: 1px solid {border_hairline}; + border-radius: 12px; +}} + +/* One light wash per panel, drawn from a different logo hue, so the sidebar + reads as distinct sections instead of one undifferentiated column. */ +QGroupBox#sheetsPanel {{ background: {wash_blue}; }} +QGroupBox#sheetsPanel::title {{ color: {ink_brand}; }} +QGroupBox#assumptionsPanel {{ background: {wash_cyan}; }} +QGroupBox#assumptionsPanel::title {{ color: {ink_brand}; }} +QGroupBox#formulasPanel {{ background: {wash_violet}; }} +QGroupBox#formulasPanel::title {{ color: {accent_violet}; }} +QGroupBox#simulationPanel {{ background: {wash_magenta}; }} +QGroupBox#simulationPanel::title {{ color: {accent_magenta}; }} +QGroupBox#calculatorBox {{ background: {wash_cyan}; }} +QGroupBox#calculatorBox::title {{ color: {ink_brand}; }} +QWidget#chartToolbar {{ + background: {surface_panel}; + border: 1px solid {border_hairline}; + border-radius: 12px; +}} +QWidget#chartFrame {{ + background: {surface_panel}; + border: 1px solid {border_hairline}; + border-radius: 12px; +}} +""" + + +def build_stylesheet() -> str: + """Render the application style sheet from the design tokens. + + Returns: + A Qt style sheet string ready to hand to ``setStyleSheet``. + """ + values = {key.replace(".", "_"): value for key, value in TOKENS.items()} + values["ink_on_accent"] = TOKENS["ink.onAccent"] + values["font"] = FONT_STACK + values["mono"] = MONO_STACK + values["assets"] = _ASSETS + return _STYLE_SHEET.format(**values) diff --git a/vatic/window.py b/vatic/window.py index 0425125..04f834d 100644 --- a/vatic/window.py +++ b/vatic/window.py @@ -30,13 +30,14 @@ import mcerp import numpy as np -from PySide6.QtCore import QObject, Qt, QThread, Signal +from PySide6.QtCore import QEvent, QObject, QSize, Qt, QThread, Signal from PySide6.QtGui import QAction, QCloseEvent from PySide6.QtWidgets import ( QAbstractItemView, + QApplication, QComboBox, QFileDialog, - QFormLayout, + QFrame, QGridLayout, QGroupBox, QHBoxLayout, @@ -50,6 +51,7 @@ QMenu, QMessageBox, QPushButton, + QScrollArea, QSpinBox, QSplitter, QStatusBar, @@ -67,18 +69,25 @@ ) from vatic.assumption import Assumption from vatic.chart import PlotCanvas -from vatic.dialogs import ParameterDialog +from vatic.dialogs import ExcelHelpDialog, ForecastDialog, ParameterDialog from vatic.distributions import ( + RANDOM_SEED, build_variable, default_parameters, distribution_labels, get_distribution_spec, + seed_sampler, ) +from vatic.excel import ExcelLinkError, excel_available +from vatic.excel.runner import ASSUMPTION_TINT, FORECAST_TINT from vatic.formula import evaluate_formula from vatic.logger import get_logger from vatic.reporting import export_pdf_report as write_pdf_report from vatic.reporting import export_pptx_report as write_pptx_report +from vatic.resources import app_icon, load_bundled_fonts, rounded_logo_pixmap +from vatic.sheetmodel import CellRef, SheetAssumption, SheetForecast, SheetModel from vatic.storage import AnalysisStore +from vatic.theme import build_stylesheet CHART_TYPES = [ @@ -125,7 +134,10 @@ def __init__(self) -> None: LOGGER.debug("Initializing vatic main window") self.setWindowTitle(f"vatic {__version__}") - self.resize(1320, 820) + # Set on the window as well as the application, so the icon is present + # even when VaticWindow is constructed directly instead of via main(). + self.setWindowIcon(app_icon()) + self.resize(1340, 860) self.store = AnalysisStore(Path.cwd() / "vatic.db") self.current_analysis_id: int | None = None @@ -145,14 +157,30 @@ def __init__(self) -> None: ] = [] self._export_jobs: dict[int, tuple[QThread, str, str, str]] = {} + # Spreadsheet link. None until a workbook is connected, at which + # point Run Simulation drives Excel instead of the in-app model. + self.excel_session: object | None = None + self.sheet_model = SheetModel() + + load_bundled_fonts() + # Rounded popups need a translucent window or the square native + # window corners show through behind the border radius. + QApplication.instance().installEventFilter(self) self._build_menu_bar() self._apply_styles() self.setStatusBar(QStatusBar(self)) root = QWidget() self.setCentralWidget(root) - root_layout = QGridLayout(root) - root_layout.setContentsMargins(8, 8, 8, 8) + root_layout = QVBoxLayout(root) + root_layout.setContentsMargins(0, 0, 0, 0) + root_layout.setSpacing(0) + + root_layout.addWidget(self._build_header()) + + body = QWidget() + body_layout = QVBoxLayout(body) + body_layout.setContentsMargins(14, 12, 14, 12) sheets_panel = self._build_sheets_section() assumptions_panel = self._build_assumptions_section() @@ -162,22 +190,35 @@ def __init__(self) -> None: left_panel = QWidget() left_layout = QGridLayout(left_panel) left_layout.setContentsMargins(0, 0, 0, 0) - left_layout.setHorizontalSpacing(8) - left_layout.setVerticalSpacing(4) - left_layout.setRowStretch(0, 1) - left_layout.setRowStretch(1, 6) + left_layout.setHorizontalSpacing(10) + left_layout.setVerticalSpacing(10) + left_layout.setRowStretch(0, 2) + left_layout.setRowStretch(1, 7) left_layout.setRowStretch(2, 3) left_layout.addWidget(sheets_panel, 0, 0) left_layout.addWidget(assumptions_panel, 1, 0) left_layout.addWidget(simulation_panel, 2, 0) + # The input sidebar is dense; letting it scroll keeps the window's + # minimum height inside a 1366x768 laptop screen instead of forcing a + # window taller than the display. + left_scroll = QScrollArea() + left_scroll.setWidget(left_panel) + left_scroll.setWidgetResizable(True) + left_scroll.setFrameShape(QFrame.NoFrame) + left_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + left_scroll.setMinimumWidth(360) + splitter = QSplitter(Qt.Horizontal) - splitter.addWidget(left_panel) + splitter.setHandleWidth(9) + splitter.addWidget(left_scroll) splitter.addWidget(results_panel) splitter.setStretchFactor(0, 4) - splitter.setStretchFactor(1, 6) + splitter.setStretchFactor(1, 7) + splitter.setSizes([460, 900]) - root_layout.addWidget(splitter, 0, 0) + body_layout.addWidget(splitter) + root_layout.addWidget(body, 1) self.load_defaults() self._reload_analysis_list() @@ -185,6 +226,112 @@ def __init__(self) -> None: self._update_window_caption() LOGGER.debug("Vatic window ready") + def eventFilter(self, watched: QObject, event: QEvent) -> bool: + """Round popup windows and stop scroll wheels editing values. + + Two separate fixes share this filter: + + A ``QMenu`` and a combo box drop-down are top level windows, so a + border radius in the style sheet leaves the square native corners + painted behind the rounded edge unless the background is punched out. + + A spin box or combo box inside the scrolling sidebar swallows the + wheel and silently edits itself, so a user scrolling past the + iteration count changes it by accident. Unfocused controls therefore + hand the wheel back to the scroll area. + + Args: + watched: The object the event was sent to. + event: The event being delivered. + + Returns: + True to swallow the event, otherwise the base class result. + """ + if not isinstance(watched, QWidget): + return super().eventFilter(watched, event) + + if event.type() == QEvent.Show and watched.isWindow(): + if isinstance(watched, QMenu | QAbstractItemView): + if not watched.testAttribute(Qt.WA_TranslucentBackground): + self._round_popup(watched) + + if event.type() == QEvent.Wheel and isinstance( + watched, QComboBox | QSpinBox + ): + if not watched.hasFocus(): + event.ignore() + return True + + return super().eventFilter(watched, event) + + def _build_header(self) -> QWidget: + """Build the branded application header. + + Returns: + The header widget, carrying the logo, the active analysis name + and the primary simulation action. + """ + header = QWidget() + header.setObjectName("appHeader") + layout = QHBoxLayout(header) + layout.setContentsMargins(16, 10, 16, 10) + layout.setSpacing(12) + + mark = QLabel() + mark.setPixmap(rounded_logo_pixmap(30)) + mark.setFixedSize(QSize(30, 30)) + layout.addWidget(mark) + + wordmark_box = QWidget() + wordmark_layout = QVBoxLayout(wordmark_box) + wordmark_layout.setContentsMargins(0, 0, 0, 0) + wordmark_layout.setSpacing(0) + + wordmark = QLabel("vatic") + wordmark.setObjectName("brandWordmark") + tagline = QLabel("OPEN SOURCE RISK ANALYSIS") + tagline.setObjectName("brandTagline") + wordmark_layout.addWidget(wordmark) + wordmark_layout.addWidget(tagline) + layout.addWidget(wordmark_box) + + divider = QFrame() + divider.setFrameShape(QFrame.VLine) + divider.setFixedWidth(1) + divider.setFixedHeight(30) + layout.addSpacing(6) + layout.addWidget(divider) + layout.addSpacing(6) + + self.header_analysis_label = QLabel(self.current_analysis_name) + self.header_analysis_label.setObjectName("analysisName") + layout.addWidget(self.header_analysis_label) + + layout.addStretch(1) + + self.run_button = QPushButton("Run Simulation") + self.run_button.setProperty("variant", "primary") + self.run_button.setToolTip("Run the Monte Carlo simulation (F5)") + self.run_button.clicked.connect(self.run_simulation) + layout.addWidget(self.run_button) + + return header + + @staticmethod + def _round_popup(widget: QWidget) -> None: + """Punch out a popup's square window so its border radius shows. + + A menu or a combo box drop-down is a top level window. Giving it a + border radius in the style sheet otherwise leaves the square native + corners painted behind the rounded edge. + + Args: + widget: The popup window to make translucent. + """ + widget.setAttribute(Qt.WA_TranslucentBackground, True) + widget.setWindowFlag(Qt.FramelessWindowHint, True) + widget.setWindowFlag(Qt.NoDropShadowWindowHint, True) + def _build_menu_bar(self) -> None: menu = self.menuBar() @@ -237,10 +384,15 @@ def _build_menu_bar(self) -> None: edit_menu.addAction(self.remove_row_action) edit_menu.addSeparator() - self.add_formula_action = QAction("Add Formula", self) - self.add_formula_action.triggered.connect(self.add_formula_row) + self.add_formula_action = QAction("Add Formula...", self) + self.add_formula_action.triggered.connect(self.add_formula_via_dialog) edit_menu.addAction(self.add_formula_action) + self.edit_formula_action = QAction("Edit Formula...", self) + self.edit_formula_action.setEnabled(False) + self.edit_formula_action.triggered.connect(self.edit_selected_formula) + edit_menu.addAction(self.edit_formula_action) + self.remove_formula_action = QAction("Remove Formula", self) self.remove_formula_action.setEnabled(False) self.remove_formula_action.triggered.connect( @@ -258,139 +410,49 @@ def _build_menu_bar(self) -> None: run_action.triggered.connect(self.run_simulation) view_menu.addAction(run_action) + sheet_menu = menu.addMenu("&Spreadsheet") + self.connect_workbook_action = QAction("Connect Workbook...", self) + self.connect_workbook_action.triggered.connect(self.connect_workbook) + sheet_menu.addAction(self.connect_workbook_action) + + self.disconnect_workbook_action = QAction("Disconnect", self) + self.disconnect_workbook_action.setEnabled(False) + self.disconnect_workbook_action.triggered.connect( + self.disconnect_workbook + ) + sheet_menu.addAction(self.disconnect_workbook_action) + + sheet_menu.addSeparator() + self.tag_assumption_action = QAction( + "Tag Selected Cell as Assumption...", self + ) + self.tag_assumption_action.setEnabled(False) + self.tag_assumption_action.triggered.connect(self.tag_assumption) + sheet_menu.addAction(self.tag_assumption_action) + + self.tag_forecast_action = QAction( + "Tag Selected Cell as Forecast...", self + ) + self.tag_forecast_action.setEnabled(False) + self.tag_forecast_action.triggered.connect(self.tag_forecast) + sheet_menu.addAction(self.tag_forecast_action) + + self.clear_tags_action = QAction("Clear Tagged Cells", self) + self.clear_tags_action.setEnabled(False) + self.clear_tags_action.triggered.connect(self.clear_sheet_tags) + sheet_menu.addAction(self.clear_tags_action) + help_menu = menu.addMenu("&Help") + help_menu.addAction("Using vatic with Excel...", self.show_excel_help) + help_menu.addSeparator() help_menu.addAction("Info", self.show_info) + for submenu in menu.findChildren(QMenu): + self._round_popup(submenu) + def _apply_styles(self) -> None: - self.setStyleSheet( - """ - QMainWindow { - background: #eef3fb; - } - QWidget { - color: #000000; - selection-background-color: #cfe3ff; - selection-color: #10233f; - font-family: "Segoe UI"; - font-size: 9pt; - } - QMenuBar { - background: #f3f7fd; - border-bottom: 1px solid #c7d6ea; - } - QMenuBar::item { - background: transparent; - padding: 5px 10px; - } - QMenuBar::item:selected { - background: #dce8fb; - border: 1px solid #9cb8dd; - } - QMenu { - background: #ffffff; - border: 1px solid #a9bbd8; - } - QMenu::item:selected { - background: #dce8fb; - color: #0c2446; - } - QGroupBox { - background: #ffffff; - border: 1px solid #c8d6ea; - border-radius: 2px; - margin-top: 8px; - font-weight: 400; - color: #003399; - } - QGroupBox::title { - subcontrol-origin: margin; - left: 8px; - padding: 0 4px; - } - QLineEdit, QComboBox, QSpinBox { - border: 1px solid #9cb6d8; - border-radius: 2px; - padding: 4px 6px; - background: #ffffff; - color: #000000; - selection-background-color: #cfe3ff; - selection-color: #10233f; - } - QTableWidget { - gridline-color: #dbe4f2; - border: 1px solid #9cb6d8; - border-radius: 2px; - background: #ffffff; - color: #000000; - selection-background-color: #cfe3ff; - selection-color: #10233f; - } - QTableWidget::item:selected { - color: #10233f; - background: #cfe3ff; - } - QHeaderView::section { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 #f6f9ff, stop:1 #e3ecf9); - color: #003399; - padding: 5px; - border: none; - border-right: 1px solid #c8d6ea; - border-bottom: 1px solid #c8d6ea; - font-weight: 400; - } - QPushButton { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 #ffffff, stop:1 #e6eef9); - border: 1px solid #a8bcd8; - border-radius: 2px; - padding: 4px 10px; - color: #003399; - font-weight: 400; - } - QPushButton:hover { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 #ffffff, stop:1 #d9e7fb); - border: 1px solid #7ea2d8; - } - QPushButton:pressed { - background: #d6e4f8; - } - QGroupBox#calculatorBox QPushButton[calcKey="true"] { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, - stop:0 #ffffff, stop:1 #eaf1fc); - border: 1px solid #afc1db; - font-weight: 400; - } - QGroupBox#calculatorBox QPushButton[calcKey="true"]:hover { - background: #dce8fa; - border: 1px solid #7ea2d8; - } - QLabel { - color: #000000; - } - QListWidget { - border: 1px solid #9cb6d8; - border-radius: 2px; - background: #ffffff; - } - QListWidget::item { - padding: 4px; - } - QListWidget::item:selected { - background: #cfe3ff; - color: #10233f; - } - QStatusBar { - background: #edf3fb; - border-top: 1px solid #c7d6ea; - } - QSplitter::handle { - background: #d2deef; - width: 5px; - } - """ - ) + """Install the brand style sheet on the whole application.""" + self.setStyleSheet(build_stylesheet()) def _build_sheets_section(self) -> QWidget: sheet = QWidget() @@ -399,6 +461,7 @@ def _build_sheets_section(self) -> QWidget: layout.setSpacing(4) box = QGroupBox("Analysis Sheets") + box.setObjectName("sheetsPanel") box_layout = QVBoxLayout(box) self.analysis_search_input = QLineEdit() @@ -417,7 +480,9 @@ def _build_sheets_section(self) -> QWidget: self.analysis_list.itemSelectionChanged.connect(self._sync_row_actions) self.analysis_meta_label = QLabel("No analysis loaded") + self.analysis_meta_label.setObjectName("metaLabel") + box_layout.setSpacing(8) box_layout.addWidget(self.analysis_search_input) box_layout.addWidget(self.analysis_list) box_layout.addWidget(self.analysis_meta_label) @@ -430,8 +495,22 @@ def _build_assumptions_section(self) -> QWidget: layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(4) - box = QGroupBox() + box = QGroupBox("Assumptions") + box.setObjectName("assumptionsPanel") box_layout = QVBoxLayout(box) + box_layout.setSpacing(8) + + assumption_actions = QHBoxLayout() + assumption_actions.setSpacing(6) + self.add_variable_button = QPushButton("Add Variable") + self.add_variable_button.clicked.connect(self.add_row) + self.remove_variable_button = QPushButton("Remove") + self.remove_variable_button.setProperty("variant", "ghost") + self.remove_variable_button.clicked.connect(self.remove_selected_rows) + assumption_actions.addWidget(self.add_variable_button) + assumption_actions.addWidget(self.remove_variable_button) + assumption_actions.addStretch(1) + box_layout.addLayout(assumption_actions) self.assumption_table = QTableWidget(0, 3) self.assumption_table.setHorizontalHeaderLabels([ @@ -447,7 +526,9 @@ def _build_assumptions_section(self) -> QWidget: self._sync_row_actions ) self.assumption_table.itemChanged.connect(self._on_model_input_changed) - self.assumption_table.setMinimumHeight(220) + self.assumption_table.setMinimumHeight(120) + self.assumption_table.setAlternatingRowColors(True) + self.assumption_table.verticalHeader().setDefaultSectionSize(34) box_layout.addWidget(self.assumption_table) self.assumption_table.setContextMenuPolicy(Qt.CustomContextMenu) @@ -458,10 +539,24 @@ def _build_assumptions_section(self) -> QWidget: self._handle_assumption_double_click ) - formula_box = QWidget() + formula_box = QGroupBox("Forecast Formulas") + formula_box.setObjectName("formulasPanel") formula_layout = QVBoxLayout(formula_box) - formula_layout.setContentsMargins(0, 0, 0, 0) - formula_layout.setSpacing(4) + formula_layout.setSpacing(8) + + formula_actions = QHBoxLayout() + formula_actions.setSpacing(6) + self.add_formula_button = QPushButton("Add Formula") + self.add_formula_button.clicked.connect(self.add_formula_via_dialog) + self.remove_formula_button = QPushButton("Remove") + self.remove_formula_button.setProperty("variant", "ghost") + self.remove_formula_button.clicked.connect( + self.remove_selected_formula_rows + ) + formula_actions.addWidget(self.add_formula_button) + formula_actions.addWidget(self.remove_formula_button) + formula_actions.addStretch(1) + formula_layout.addLayout(formula_actions) self.formula_table = QTableWidget(0, 5) self.formula_table.setHorizontalHeaderLabels([ @@ -493,7 +588,13 @@ def _build_assumptions_section(self) -> QWidget: self.formula_table.customContextMenuRequested.connect( self._show_formula_context_menu ) - self.formula_table.setMinimumHeight(160) + self.formula_table.setMinimumHeight(96) + self.formula_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.formula_table.cellDoubleClicked.connect( + self._handle_formula_double_click + ) + self.formula_table.setAlternatingRowColors(True) + self.formula_table.verticalHeader().setDefaultSectionSize(30) formula_layout.addWidget(self.formula_table) assumptions_splitter = QSplitter(Qt.Vertical) @@ -502,7 +603,7 @@ def _build_assumptions_section(self) -> QWidget: assumptions_splitter.addWidget(formula_box) assumptions_splitter.setStretchFactor(0, 6) assumptions_splitter.setStretchFactor(1, 4) - assumptions_splitter.setSizes([420, 260]) + assumptions_splitter.setSizes([300, 210]) layout.addWidget(assumptions_splitter) return sheet @@ -513,21 +614,45 @@ def _build_simulation_section(self) -> QWidget: layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(4) - controls_box = QGroupBox() - controls_form = QFormLayout(controls_box) + controls_box = QGroupBox("Simulation") + controls_box.setObjectName("simulationPanel") + controls_row = QHBoxLayout(controls_box) + controls_row.setSpacing(10) self.iteration_spin = QSpinBox() self.iteration_spin.setRange(500, 200000) self.iteration_spin.setValue(10000) self.iteration_spin.setSingleStep(1000) + self.iteration_spin.setGroupSeparatorShown(True) + self.iteration_spin.setFocusPolicy(Qt.StrongFocus) self.iteration_spin.valueChanged.connect(self._on_model_input_changed) - controls_form.addRow("Iterations", self.iteration_spin) + self.seed_spin = QSpinBox() + self.seed_spin.setRange(0, 2_147_483_647) + self.seed_spin.setValue(RANDOM_SEED) + self.seed_spin.setSpecialValueText("random") + self.seed_spin.setToolTip( + "Seed the sampler so a run can be reproduced exactly. " + "Leave it on 'random' for a fresh sample set each time." + ) + self.seed_spin.setFocusPolicy(Qt.StrongFocus) + self.seed_spin.valueChanged.connect(self._on_model_input_changed) + + iterations_label = QLabel("Iterations") + iterations_label.setObjectName("sectionTitle") + seed_label = QLabel("Seed") + seed_label.setObjectName("sectionTitle") + controls_row.addWidget(iterations_label) + controls_row.addWidget(self.iteration_spin, 1) + controls_row.addSpacing(10) + controls_row.addWidget(seed_label) + controls_row.addWidget(self.seed_spin, 1) layout.addWidget(controls_box) - calculator_box = QGroupBox() + calculator_box = QGroupBox("Formula Keypad") calculator_box.setObjectName("calculatorBox") calculator_grid = QGridLayout(calculator_box) + calculator_grid.setSpacing(5) button_rows: list[list[tuple[str, str]]] = [ [ ("7", "7"), @@ -582,8 +707,13 @@ def _build_simulation_section(self) -> QWidget: for row_idx, row_buttons in enumerate(button_rows): for col_idx, (label, token) in enumerate(row_buttons): button = QPushButton(label) - button.setMinimumHeight(30) - button.setProperty("calcKey", True) + button.setMinimumHeight(28) + if token in {"__clear__", "__del__"}: + button.setProperty("calcKey", "edit") + elif label.isalpha() and len(label) > 1: + button.setProperty("calcKey", "fn") + else: + button.setProperty("calcKey", "true") if token == "__clear__": button.clicked.connect(self._clear_formula) elif token == "__del__": @@ -601,6 +731,193 @@ def _build_simulation_section(self) -> QWidget: layout.addStretch(1) return sheet + def show_excel_help(self) -> None: + """Explain how to drive a spreadsheet model from vatic.""" + ExcelHelpDialog(available=excel_available(), parent=self).exec() + + # ------------------------------------------------------ spreadsheet + + def _sync_sheet_actions(self) -> None: + """Enable the spreadsheet actions that make sense right now.""" + connected = self.excel_session is not None + self.disconnect_workbook_action.setEnabled(connected) + self.tag_assumption_action.setEnabled(connected) + self.tag_forecast_action.setEnabled(connected) + self.clear_tags_action.setEnabled( + bool(self.sheet_model.assumptions or self.sheet_model.forecasts) + ) + + def connect_workbook(self) -> None: + """Attach to a workbook so the simulation runs through Excel.""" + if not excel_available(): + ExcelHelpDialog(available=False, parent=self).exec() + return + + from vatic.excel import ExcelSession + + path, _filter = QFileDialog.getOpenFileName( + self, + "Connect Workbook", + "", + "Excel workbooks (*.xlsx *.xlsm *.xls);;All files (*)", + ) + + session = ExcelSession(visible=True) + try: + name = session.connect(path or None) + except ExcelLinkError as exc: + session.close() + QMessageBox.critical(self, "Connect Workbook", exc.user_message()) + return + + self.excel_session = session + self.sheet_model = SheetModel(workbook=name) + self._sync_sheet_actions() + self.statusBar().showMessage(f"Connected to {name}") + LOGGER.info("Spreadsheet link established | workbook=%s", name) + QMessageBox.information( + self, + "Connect Workbook", + f"Connected to '{name}'.\n\n" + "Now select an input cell in Excel and use " + "Spreadsheet > Tag Selected Cell as Assumption, then select an " + "output cell and tag it as a forecast. Run Simulation will drive " + "the workbook.", + ) + + def disconnect_workbook(self) -> None: + """Release the workbook and go back to the in-app formula model.""" + if self.excel_session is None: + return + self.clear_sheet_tags() + self.excel_session.close() + self.excel_session = None + self.sheet_model = SheetModel() + self._sync_sheet_actions() + self.statusBar().showMessage("Disconnected from Excel") + LOGGER.info("Spreadsheet link released") + + def _selected_cell(self) -> CellRef | None: + """Return the cell currently selected in Excel. + + Returns: + The selection as a reference, or None when it cannot be read. + """ + try: + selection = self.excel_session.app.Selection + sheet = str(selection.Worksheet.Name) + cell = str(selection.Cells(1, 1).Address).replace("$", "") + return CellRef(cell=cell, sheet=sheet) + except Exception as exc: # noqa: BLE001 - reported to the user + LOGGER.warning("Could not read the Excel selection | %s", exc) + return None + + def tag_assumption(self) -> None: + """Tag the cell selected in Excel as a sampled input.""" + ref = self._selected_cell() + if ref is None: + QMessageBox.warning( + self, "Tag Assumption", "Select a single cell in Excel first." + ) + return + + suggested = self.excel_session.label_of(ref) + tag, accepted = QInputDialog.getText( + self, + "Tag Assumption", + f"Name for {ref.qualified()}:", + text=suggested, + ) + if not accepted or not tag.strip(): + return + + spec = get_distribution_spec("Normal") + current = self.excel_session.read(ref) + nominal = float(current) if isinstance(current, (int, float)) else 0.0 + dialog = ParameterDialog( + spec, {"mu": nominal, "sigma": abs(nominal) * 0.05 or 1.0}, self + ) + if dialog.exec() != ParameterDialog.Accepted: + return + + interior = self.excel_session.capture_interior(ref) + self.sheet_model.assumptions.append( + SheetAssumption( + ref=ref, + tag=tag.strip(), + distribution=spec.label, + parameters=dialog.values(), + original_interior=interior, + ) + ) + self.excel_session.highlight(ref, ASSUMPTION_TINT) + self._sync_sheet_actions() + self.statusBar().showMessage( + f"Tagged {ref.qualified()} as assumption '{tag.strip()}'" + ) + + def tag_forecast(self) -> None: + """Tag the cell selected in Excel as a tracked output.""" + ref = self._selected_cell() + if ref is None: + QMessageBox.warning( + self, "Tag Forecast", "Select a single cell in Excel first." + ) + return + + suggested = self.excel_session.label_of(ref) + dialog = ForecastDialog( + name=suggested if suggested.isidentifier() else "forecast", + expression=ref.qualified(), + parent=self, + ) + dialog.expression_input.setEnabled(False) + dialog.expression_input.setToolTip( + "A spreadsheet forecast is the cell itself, not an expression." + ) + if dialog.exec() != ForecastDialog.Accepted: + return + values = dialog.values() + + interior = self.excel_session.capture_interior(ref) + self.sheet_model.forecasts.append( + SheetForecast( + ref=ref, + tag=values["name"], + lsl=self._parse_optional_float(values["lsl"], "LSL", 0), + usl=self._parse_optional_float(values["usl"], "USL", 0), + target=self._parse_optional_float( + values["target"], "Target", 0 + ), + original_interior=interior, + ) + ) + self.excel_session.highlight(ref, FORECAST_TINT) + self._sync_sheet_actions() + self.statusBar().showMessage( + f"Tagged {ref.qualified()} as forecast '{values['name']}'" + ) + + def clear_sheet_tags(self) -> None: + """Remove every tag and put the cell colours back.""" + if self.excel_session is not None: + for item in ( + *self.sheet_model.assumptions, + *self.sheet_model.forecasts, + ): + if item.original_interior is not None: + try: + self.excel_session.restore_interior( + item.ref, item.original_interior + ) + except Exception as exc: # noqa: BLE001 - keep going + LOGGER.warning( + "Could not restore %s | %s", item.ref, exc + ) + self.sheet_model.assumptions.clear() + self.sheet_model.forecasts.clear() + self._sync_sheet_actions() + def show_info(self) -> None: dialog = QMessageBox(self) dialog.setWindowTitle("vatic") @@ -687,41 +1004,60 @@ def _build_results_section(self) -> QWidget: layout.setColumnStretch(0, 1) layout.setColumnStretch(1, 1) - chart_controls = QGroupBox() + chart_controls = QWidget() + chart_controls.setObjectName("chartToolbar") chart_controls_layout = QHBoxLayout(chart_controls) + chart_controls_layout.setContentsMargins(14, 10, 14, 10) self.chart_type_combo = QComboBox() self.chart_type_combo.addItems(CHART_TYPES) self.chart_type_combo.currentTextChanged.connect( self.render_selected_chart ) - self.chart_type_combo.setMaximumWidth(220) + self.chart_type_combo.setFocusPolicy(Qt.StrongFocus) + self.chart_type_combo.setMinimumWidth(170) + self.chart_type_combo.setMaximumWidth(240) self.scatter_var_combo = QComboBox() self.scatter_var_combo.currentTextChanged.connect( self.render_selected_chart ) - self.scatter_var_combo.setMaximumWidth(220) - chart_controls_layout.addWidget(QLabel("Chart")) + self.scatter_var_combo.setFocusPolicy(Qt.StrongFocus) + self.scatter_var_combo.setMinimumWidth(170) + self.scatter_var_combo.setMaximumWidth(240) + chart_label = QLabel("CHART") + chart_label.setObjectName("sectionTitle") + series_label = QLabel("SERIES") + series_label.setObjectName("sectionTitle") + chart_controls_layout.addWidget(chart_label) chart_controls_layout.addWidget(self.chart_type_combo) - chart_controls_layout.addSpacing(8) - chart_controls_layout.addWidget(QLabel("Series")) + chart_controls_layout.addSpacing(14) + chart_controls_layout.addWidget(series_label) chart_controls_layout.addWidget(self.scatter_var_combo) chart_controls_layout.addStretch(1) - chart_controls.setMaximumHeight(74) + chart_controls.setMaximumHeight(58) - chart_box = QGroupBox() + chart_box = QWidget() + chart_box.setObjectName("chartFrame") chart_layout = QVBoxLayout(chart_box) + chart_layout.setContentsMargins(4, 4, 4, 4) self.canvas = PlotCanvas() + self.canvas.setMinimumHeight(200) chart_layout.addWidget(self.canvas) - self.stats_label = QLabel("Run a simulation to view results") + self.stats_label = QLabel( + "No results yet\n\n" + "Define your assumptions and forecast formulas, then run the " + "simulation to see\nstatistics and capability metrics here." + ) self.stats_label.setObjectName("statsCard") self.stats_label.setAlignment(Qt.AlignTop | Qt.AlignLeft) self.stats_label.setTextInteractionFlags(Qt.TextSelectableByMouse) - self.stats_label.setStyleSheet( - "border: 1px solid #c8d6ea; background: #f8fbff; " - "padding: 10px; font-family: 'Consolas';" - ) + self.stats_label.setWordWrap(True) + self.stats_label.setMinimumHeight(96) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(10) + layout.setRowStretch(1, 5) + layout.setRowStretch(2, 2) layout.addWidget(chart_controls, 0, 0, 1, 2) layout.addWidget(chart_box, 1, 0, 1, 2) layout.addWidget(self.stats_label, 2, 0, 1, 2) @@ -778,6 +1114,84 @@ def add_formula_row( row, 4, QTableWidgetItem("" if target is None else str(target)) ) + def _handle_formula_double_click(self, row: int, _col: int) -> None: + """Open the formula editor for a double-clicked row. + + Args: + row: Row that was double-clicked. + _col: Column that was double-clicked; every column opens the + same editor. + """ + self.edit_formula_row(row) + + def add_formula_via_dialog(self) -> None: + """Ask for a new forecast formula and append it when confirmed.""" + dialog = ForecastDialog(parent=self) + if dialog.exec() != ForecastDialog.Accepted: + LOGGER.debug("Forecast dialog cancelled") + return + self._write_formula_row(self.formula_table.rowCount(), dialog.values()) + + def edit_formula_row(self, row: int) -> None: + """Edit an existing forecast formula in a dialog. + + Args: + row: Index of the formula row to edit. + """ + if not 0 <= row < self.formula_table.rowCount(): + return + + def cell(column: int) -> str: + item = self.formula_table.item(row, column) + return item.text() if item else "" + + dialog = ForecastDialog( + name=cell(0), + expression=cell(1), + lsl=cell(2), + usl=cell(3), + target=cell(4), + parent=self, + ) + if dialog.exec() != ForecastDialog.Accepted: + LOGGER.debug("Forecast edit cancelled | row=%s", row) + return + self._write_formula_row(row, dialog.values()) + + def edit_selected_formula(self) -> None: + """Edit whichever formula row is currently selected.""" + row = self.formula_table.currentRow() + if row < 0: + QMessageBox.information( + self, "vatic", "Select a formula row to edit." + ) + return + self.edit_formula_row(row) + + def _write_formula_row(self, row: int, values: dict[str, str]) -> None: + """Write dialog values into a formula row, appending if needed. + + Args: + row: Target row index; a row is appended when it does not exist. + values: Field values returned by :class:`ForecastDialog`. + """ + if row >= self.formula_table.rowCount(): + self.formula_table.insertRow(self.formula_table.rowCount()) + for column, key in enumerate(( + "name", + "expression", + "lsl", + "usl", + "target", + )): + self.formula_table.setItem( + row, column, QTableWidgetItem(values.get(key, "")) + ) + self.formula_table.setCurrentCell(row, 1) + LOGGER.debug( + "Formula row written | row=%s | name=%s", row, values.get("name") + ) + def remove_selected_formula_rows(self) -> None: rows = sorted( {index.row() for index in self.formula_table.selectedIndexes()}, @@ -906,6 +1320,7 @@ def _show_assumption_context_menu(self, point) -> None: self.assumption_table.selectRow(row) menu = QMenu(self) + self._round_popup(menu) edit_action = menu.addAction("Edit Parameters") edit_action.setEnabled(row >= 0) add_action = menu.addAction("Add Assumption") @@ -925,13 +1340,19 @@ def _show_formula_context_menu(self, point) -> None: self.formula_table.selectRow(row) menu = QMenu(self) - add_action = menu.addAction("Add Formula") + self._round_popup(menu) + add_action = menu.addAction("Add Formula...") + edit_action = menu.addAction("Edit Formula...") + edit_action.setEnabled(row >= 0) + menu.addSeparator() remove_action = menu.addAction("Remove Selected") remove_action.setEnabled(row >= 0) chosen = menu.exec(self.formula_table.viewport().mapToGlobal(point)) if chosen is add_action: - self.add_formula_row() + self.add_formula_via_dialog() + elif chosen is edit_action: + self.edit_formula_row(row) elif chosen is remove_action: self.remove_selected_formula_rows() @@ -959,6 +1380,7 @@ def _sync_row_actions(self) -> None: self.edit_row_action.setEnabled(has_selection) self.load_analysis_action.setEnabled(has_analysis_selection) self.remove_formula_action.setEnabled(has_formula_selection) + self.edit_formula_action.setEnabled(has_formula_selection) def _selected_analysis_id(self) -> int | None: item = self.analysis_list.currentItem() @@ -1011,6 +1433,8 @@ def _update_window_caption(self) -> None: self.analysis_meta_label.setText( f"Active: {self.current_analysis_name}" ) + if hasattr(self, "header_analysis_label"): + self.header_analysis_label.setText(self.current_analysis_name) self.statusBar().showMessage( f"Current analysis: {self.current_analysis_name}" ) @@ -1566,14 +1990,19 @@ def _parse_optional_float( ) from exc def run_simulation(self) -> None: + if self.excel_session is not None and self.sheet_model.assumptions: + self.run_spreadsheet_simulation() + return try: assumptions = self._parse_assumptions() formula_text = self._serialize_formula_rows() mcerp.npts = self.iteration_spin.value() + active_seed = seed_sampler(self.seed_spin.value()) LOGGER.info( - "Starting simulation | iterations=%s | assumptions=%s | formulas=%s", + "Starting simulation | iterations=%s | seed=%s | assumptions=%s | formulas=%s", mcerp.npts, + active_seed if active_seed is not None else "random", len(assumptions), formula_text, ) @@ -1665,6 +2094,125 @@ def run_simulation(self) -> None: self._invalidate_simulation_results() QMessageBox.critical(self, "Simulation Error", str(exc)) + def run_spreadsheet_simulation(self) -> None: + """Run the simulation through the connected Excel workbook. + + The run happens on the GUI thread. A COM apartment belongs to the + thread that created it, and a ten thousand trial run completes in + about a second, so blocking briefly is preferable to marshalling the + session across threads. + """ # noqa: DOC501 + from vatic.excel import ExcelRunner + + try: + self.sheet_model.validate() + except ValueError as exc: + QMessageBox.warning(self, "Run Simulation", str(exc)) + return + + trials = self.iteration_spin.value() + seed_sampler(self.seed_spin.value()) + + try: + columns = [] + for assumption in self.sheet_model.assumptions: + spec = get_distribution_spec(assumption.distribution) + variable = build_variable( + assumption.tag, spec, assumption.parameters + ) + samples = np.asarray(variable._mcpts, dtype=float) + if samples.size < trials: + raise ValueError( + f"Sampler produced {samples.size} values for " + f"'{assumption.tag}' but {trials} trials were asked " + "for; raise the iteration count before running." + ) + columns.append(samples[:trials]) + matrix = np.column_stack(columns) + except (ValueError, KeyError) as exc: + QMessageBox.critical(self, "Run Simulation", str(exc)) + return + + status = self.statusBar() + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + result = ExcelRunner(self.excel_session, self.sheet_model).run( + matrix, + progress=lambda stage, _f: status.showMessage( + f"Excel: {stage}" + ), + ) + except ExcelLinkError as exc: + QMessageBox.critical(self, "Run Simulation", exc.user_message()) + return + except ValueError as exc: + QMessageBox.critical(self, "Run Simulation", str(exc)) + return + finally: + QApplication.restoreOverrideCursor() + + self._publish_spreadsheet_results(result) + + def _publish_spreadsheet_results(self, result: object) -> None: + """Feed a spreadsheet run into the charts, stats and reports. + + Args: + result: The :class:`~vatic.excel.runner.RunResult` to publish. + """ + outputs: dict[str, np.ndarray] = {} + specs: dict[str, dict[str, float | None]] = {} + capability: dict[str, dict[str, float]] = {} + + for forecast in self.sheet_model.forecasts: + column = result.forecasts[forecast.tag] + usable = column[~np.isnan(column)] + if usable.size == 0: + QMessageBox.critical( + self, + "Run Simulation", + f"Every trial for '{forecast.tag}' produced a worksheet " + "error, so there is nothing to summarise.", + ) + return + outputs[forecast.tag] = usable + specs[forecast.tag] = { + "lsl": forecast.lsl, + "usl": forecast.usl, + "target": forecast.target, + } + capability[forecast.tag] = compute_capability_metrics( + usable, + lsl=forecast.lsl, + usl=forecast.usl, + target=forecast.target, + ) + + self.last_forecasts = outputs + self.last_forecast_order = list(outputs) + self.active_forecast_name = self.last_forecast_order[-1] + self.last_output = outputs[self.active_forecast_name] + self.last_forecast_specs = specs + self.last_capability_by_forecast = capability + self.last_capability = dict( + capability.get(self.active_forecast_name, {}) + ) + self.last_inputs = dict(result.inputs) + self.last_stats = compute_statistics(self.last_output) + + self._update_statistics_label() + self._refresh_scatter_variable_combo() + self.render_selected_chart() + + diagnostics = result.diagnostics + message = ( + f"Excel run complete: {diagnostics.trials:,} trials in " + f"{diagnostics.seconds:.2f}s" + ) + if diagnostics.error_count: + message += f" ({diagnostics.error_count} trials had cell errors)" + self.statusBar().showMessage(message) + LOGGER.info("Spreadsheet results published | %s", message) + def _extract_output_values(self, outcome: object) -> np.ndarray: if hasattr(outcome, "_mcpts"): values = np.asarray(outcome._mcpts, dtype=float)