diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 24f4fd3..897b91b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -85,3 +85,53 @@ jobs: if [ -n "$unexpected" ]; then echo "unexpected skips:"; echo "$unexpected"; exit 1 fi + + one-extra: + # The leg above installs the core alone and then everything. These install + # one extra each, so the extras' boundaries are tested in both directions: + # the extra's own modules import without the others, and every test that + # needs another extra skips rather than erroring at collection. + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - {extra: spec, modules: "ymf.schema, ymf.compose, ymf.generated"} + - {extra: units, modules: "ymf.units"} + - {extra: symbolic, modules: "ymf.symbolic, ymf.schema"} + name: linux-64 / py3.12 / ymf[${{ matrix.extra }}] alone + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build sdist + run: | + python -m pip install --upgrade pip build + python -m build --sdist + + - name: Install ymf[${{ matrix.extra }}] alone + shell: bash + run: | + python -m venv /tmp/one + /tmp/one/bin/pip install "$(ls dist/ymf-*.tar.gz)[${{ matrix.extra }}]" pytest + + - name: Its modules import without the other extras + shell: bash + run: | + cd /tmp + /tmp/one/bin/python -c "import ymf, ${{ matrix.modules }}; print('ymf', ymf.__version__, 'imports ${{ matrix.modules }}')" + + - name: Run test suite + shell: bash + run: | + # The tests that need another extra skip themselves; a collection + # error or a failure here is a test, or a module, that assumes an + # extra it does not declare. + export PATH=/tmp/one/bin:$PATH + python -c "import ymf; print(ymf.__file__)" | grep -v "/src/ymf/" + python -m pytest tests -q -rs -p no:cacheprovider diff --git a/src/ymf/schema.py b/src/ymf/schema.py index 92a126c..f28e473 100644 --- a/src/ymf/schema.py +++ b/src/ymf/schema.py @@ -27,6 +27,7 @@ Seq, Str, ) +from strictyaml.validators import OrValidator from ymf.discretizations import METHODS from ymf.state import STATES @@ -506,7 +507,15 @@ def _partial(validator, keep=()): an inherited discretization by naming it and giving only that field. The composed document is validated in full, so an entry that is new and incomplete is still caught there. + + Both sides of an alternative are relaxed too: the domain is the notation + string or a mapping, and a file that tags an inherited region writes + ``boundary_tags`` without restating its ``kind``. The composed domain + is checked against its kind by ymf.domain. """ + if isinstance(validator, OrValidator): + return OrValidator(_partial(validator._validator_a, keep), + _partial(validator._validator_b, keep)) if isinstance(validator, Map): return Map({ (key if isinstance(key, Optional) or key in keep else Optional(key)): diff --git a/tests/test_closure.py b/tests/test_closure.py index 576597a..e8805fe 100644 --- a/tests/test_closure.py +++ b/tests/test_closure.py @@ -15,6 +15,7 @@ def spec_with_mesh(tmp_path, mesh=MESH, name="poisson.ymf"): """examples/poisson.ymf with this mesh on P1_linear, and none on P2.""" + pytest.importorskip("strictyaml") # closure.load composes the spec: ymf[spec] text = "".join(line for line in (EXAMPLES / "poisson.ymf").read_text(encoding="utf-8").splitlines(True) if not line.lstrip().startswith("mesh:")) diff --git a/tests/test_compose.py b/tests/test_compose.py index db3eb94..f898059 100644 --- a/tests/test_compose.py +++ b/tests/test_compose.py @@ -3,7 +3,8 @@ from pathlib import Path import pytest -import strictyaml + +strictyaml = pytest.importorskip("strictyaml") from ymf.compose import YmfCompositionError from ymf.schema import load_ymf diff --git a/tests/test_core_without_frontend.py b/tests/test_core_without_frontend.py index e9f3be6..3d71a30 100644 --- a/tests/test_core_without_frontend.py +++ b/tests/test_core_without_frontend.py @@ -81,7 +81,8 @@ def test_which_loader_a_file_needs_is_a_core_question(): def test_compose_still_offers_the_old_private_name(): - """Proteus imports ymf.compose._is_archive_text; it must keep working.""" + """Proteus imported ymf.compose._is_archive_text; it must keep working.""" + pytest.importorskip("strictyaml") # ymf.compose is ymf[spec] from ymf.archive import is_archive_text from ymf.compose import _is_archive_text assert _is_archive_text is is_archive_text diff --git a/tests/test_domain.py b/tests/test_domain.py index f27f37e..e7ddc9a 100644 --- a/tests/test_domain.py +++ b/tests/test_domain.py @@ -13,12 +13,15 @@ state of whatever object happens to hold it. """ +from pathlib import Path + import pytest +strictyaml = pytest.importorskip("strictyaml") + from ymf import load_ymf from ymf.domain import DOMAIN_KINDS, YmfDomainError, box_bounds, validate_domain_block -strictyaml = pytest.importorskip("strictyaml") def spec(domain): @@ -152,7 +155,7 @@ def test_the_two_box_forms_give_the_same_problem(): from ymf.symbolic.problem import adr_problem - doc = load_ymf("examples/poisson.ymf") + doc = load_ymf(Path(__file__).parent.parent / "examples" / "poisson.ymf") mapped = copy.deepcopy(doc) mapped["model"]["strong_form"]["domain"] = { "kind": "box", "lower": [0.0, 0.0], "upper": [1.0, 1.0]} @@ -171,6 +174,64 @@ def test_a_kind_nothing_solves_yet_says_so(tmp_path): adr_problem(doc) +# -------------------------------------------------------------------------- +# a domain composed across files +# -------------------------------------------------------------------------- + +PSLG_MODEL = spec(""" + kind: piecewise_linear + dimension: 2 + vertices: [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]] + segments: [[0, 1], [1, 2], [2, 3], [3, 0]]""") + + +def compose(tmp_path, child): + (tmp_path / "model.ymf").write_text(PSLG_MODEL, encoding="utf-8") + (tmp_path / "child.ymf").write_text( + "ymf_state: abstract\nextends: model.ymf\n" + child, encoding="utf-8") + return load_ymf(tmp_path / "child.ymf") + + +def test_a_child_may_tag_the_region_its_parent_states(tmp_path): + """The region in the model, the names of its boundaries in the problem.""" + doc = compose(tmp_path, """\ +model: + strong_form: + domain: + boundary_tags: {bottom: 1, right: 2, top: 3, left: 4} +""") + domain = doc["model"]["strong_form"]["domain"] + assert domain["kind"] == "piecewise_linear" and len(domain["vertices"]) == 4 + assert domain["boundary_tags"]["left"] == 4 + + +def test_a_child_that_changes_the_kind_keeps_the_parents_keys_and_is_refused(tmp_path): + """Mappings merge key by key, so a box over a PSLG still has vertices. + The composed domain is checked against its kind, which names them.""" + with pytest.raises(YmfDomainError, match="kind 'box' does not take segments, vertices"): + compose(tmp_path, """\ +model: + strong_form: + domain: {kind: box, lower: [0.0, 0.0], upper: [1.0, 2.0]} +""") + + +def test_a_child_replaces_the_region_by_removing_what_it_does_not_keep(tmp_path): + doc = compose(tmp_path, """\ +remove: + - "model.strong_form.domain.vertices" + - "model.strong_form.domain.segments" +model: + strong_form: + domain: {kind: box, lower: [0.0, 0.0], upper: [1.0, 2.0]} +""") + domain = doc["model"]["strong_form"]["domain"] + assert box_bounds(domain) == [[0.0, 0.0], [1.0, 2.0]] + assert "vertices" not in domain and "segments" not in domain + removed = [r["path"] for r in doc["composition"]["removals"]] + assert removed == ["model.strong_form.domain.vertices", "model.strong_form.domain.segments"] + + def test_every_kind_is_covered_by_the_tests_above(): """So a kind added later is not silently untested.""" assert set(DOMAIN_KINDS) == {"box", "piecewise_linear", "discrete", "file"} diff --git a/tests/test_examples.py b/tests/test_examples.py index 483c526..0d76b57 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -8,6 +8,7 @@ import pytest # the example scripts walk the unit checks, so they need the units extra +pytest.importorskip("strictyaml") pytest.importorskip("pint") from ymf.schema import load_ymf diff --git a/tests/test_references.py b/tests/test_references.py index 5e886cd..b3cd657 100644 --- a/tests/test_references.py +++ b/tests/test_references.py @@ -15,10 +15,11 @@ import pytest +strictyaml = pytest.importorskip("strictyaml") + from ymf import load_ymf from ymf.references import YmfReferenceError -strictyaml = pytest.importorskip("strictyaml") def spec(weak_forms, discretizations, analytical="", vvuq="", extra=""): diff --git a/tests/test_removal.py b/tests/test_removal.py index 18618a4..4fe5806 100644 --- a/tests/test_removal.py +++ b/tests/test_removal.py @@ -16,10 +16,11 @@ import pytest +strictyaml = pytest.importorskip("strictyaml") + from ymf import load_ymf from ymf.compose import YmfRemovalError -strictyaml = pytest.importorskip("strictyaml") MODEL = """\ diff --git a/tests/test_schema.py b/tests/test_schema.py index 1cf3afd..fa14c18 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1,7 +1,8 @@ from pathlib import Path import pytest -import strictyaml + +strictyaml = pytest.importorskip("strictyaml") from ymf.schema import load_ymf diff --git a/tests/test_symbolic.py b/tests/test_symbolic.py index 5f62e9d..bb0386a 100644 --- a/tests/test_symbolic.py +++ b/tests/test_symbolic.py @@ -331,3 +331,74 @@ def test_strong_form_equations_are_what_adr_problem_classifies(): assert len(residuals) == 3 assert [e["component"] for e in adr_problem(doc)["adr"]["equations"]] == space.components assert to_notation(residuals[2]) == "∂v_0/∂x + ∂v_1/∂y" + + +# --- classify, directly, on a coupled system --------------------------------- +# +# adr_form exports plain data; classify is the step before it, still in sympy, +# and the one a model family (a level set advected by a flow, a density that +# depends on the level set) builds on. These pin down where each kind of +# coupling term lands, one scalar residual at a time. + + +def _classified(lines, unknowns, coefficients=None, dim=2, **kwargs): + from ymf.symbolic.adr import classify as classify_residual + space = Space(dim, unknowns, coefficients or {}) + residuals = [] + for line in lines: + residuals.extend(parse_equation(line, space)) + return space, [classify_residual(r, space, **kwargs) for r in residuals] + + +def _symbols(space): + """The space's own symbols, by component name (they carry assumptions, + so a bare sympy.Symbol of the same name is a different symbol).""" + return dict(zip(space.components, space.component_symbols)) + + +def test_classify_keys_cross_diffusion_by_the_other_unknown(): + """-div(phi grad phi) in u's equation is diffusion of phi, not of u.""" + space, (eq_u, eq_phi) = _classified( + ["dt(u) - div(k*grad(u)) - div(phi*grad(phi)) + u*phi = 0", + "dt(phi) + div(phi*(u, 0)) = 0"], + {"u": 0, "phi": 0}, {"k": 2.0}) + u, phi = _symbols(space)["u"], _symbols(space)["phi"] + assert eq_u.mass == u + assert set(eq_u.diffusion) == {"u", "phi"} + assert eq_u.diffusion["u"] == 2.0 * sympy.eye(2) + assert eq_u.diffusion["phi"] == phi * sympy.eye(2) + assert sympy.simplify(eq_u.reaction - u * phi) == 0 + assert eq_u.advection == [0, 0] and eq_u.hamiltonian == 0 + # phi advected by a flux that depends on u: advection, nothing else + assert eq_phi.mass == phi + assert eq_phi.advection == [phi * u, 0] + assert eq_phi.diffusion == {} and eq_phi.reaction == 0 and eq_phi.hamiltonian == 0 + + +def test_classify_puts_a_nonlinear_gradient_term_in_the_hamiltonian(): + """|grad phi|^2 is not a divergence: it cannot be a flux.""" + space, (eq,) = _classified(["dt(phi) + dx(phi)**2 + dy(phi)**2 = 1"], {"phi": 0}) + assert eq.mass == _symbols(space)["phi"] + assert eq.advection == [0, 0] and eq.diffusion == {} + assert eq.reaction == -1 + assert eq.hamiltonian != 0 + + +def test_classify_moves_a_named_gradient_from_the_flux_to_the_hamiltonian(): + """grad(p) is div(p I) or H = dp/dx_j: one strong form, two weak forms.""" + lines = ["rho*dt(v) + div(rho*outer(v, v)) - div(mu*grad(v)) + grad(p) = 0"] + unknowns, coefficients = {"v": 1, "p": 0}, {"rho": 1.0, "mu": 1.0} + space, as_flux = _classified(lines, unknowns, coefficients) + _, as_h = _classified(lines, unknowns, coefficients, hamiltonian_gradients=("p",)) + p = _symbols(space)["p"] + for axis, (flux, h) in enumerate(zip(as_flux, as_h)): + assert flux.advection[axis].has(p) and flux.hamiltonian == 0 + assert not any(f.has(p) for f in h.advection) + assert h.hamiltonian.has(p) + + +def test_classify_refuses_a_name_that_is_not_declared(): + from ymf.symbolic.adr import classify as classify_residual + space = Space(2, {"u": 0}, {}) + with pytest.raises(SymbolicError, match="undefined names q"): + classify_residual(sympy.Symbol("q") * _symbols(space)["u"], space) diff --git a/tests/test_units.py b/tests/test_units.py index f68ee2f..1a0ce50 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -1,4 +1,8 @@ +import importlib.util import math +import sys +from pathlib import Path + import pytest # The YMF core deliberately depends on pyyaml alone, so a solver writing @@ -209,13 +213,18 @@ def test_compute_dimensionless_numbers_uses_validated_coefficients(coefficients_ # Regression: coefficients are MapPattern(Str(), Any()) in the schema, so # strictyaml returns '1.0e-3' (a string) and formulas referencing kappa # used to be silently dropped. + pytest.importorskip("strictyaml") from ymf import validate_ymf doc = validate_ymf(_heat_doc_with_numbers(coefficients_yaml)) numbers = compute_dimensionless_numbers(doc["model"]) assert numbers["Peclet"] == pytest.approx(0.5 * 1.0 / 1.0e-3) - # the time scale is derived, 1/(2π²κ), so κ·time/L² is 1/(2π²) exactly - assert numbers["Fourier"] == pytest.approx(1.0 / (2.0 * math.pi ** 2)) + # the time scale is derived, 1/(2π²κ), so κ·time/L² is 1/(2π²) exactly; + # that formula is in the spec notation, which only ymf[symbolic] reads + if importlib.util.find_spec("sympy") is None: + assert "Fourier" not in numbers + else: + assert numbers["Fourier"] == pytest.approx(1.0 / (2.0 * math.pi ** 2)) def test_an_expression_coefficient_resolves_but_a_non_number_still_does_not(): @@ -225,6 +234,7 @@ def test_an_expression_coefficient_resolves_but_a_non_number_still_does_not(): expression-valued coefficient went silently missing while adr_problem resolved the same coefficient without difficulty. """ + pytest.importorskip("sympy") # evaluating "π²" is ymf.symbolic's job problem = { "characteristic_scales": {"length": {"value": 1.0, "units": "m"}}, "strong_form": {"coefficients": {"Q": "π²", "k": "nan", "flag": True}}, @@ -240,12 +250,13 @@ def test_an_expression_coefficient_resolves_but_a_non_number_still_does_not(): def test_units_and_the_solver_agree_on_a_coefficient(): """One spec, one answer -- the reason sympy is in the frontend extra.""" + pytest.importorskip("strictyaml") + pytest.importorskip("sympy") from ymf import load_ymf from ymf.symbolic.problem import coefficient_numbers from ymf.units import _coefficient_values - pytest.importorskip("sympy") - problem = load_ymf("examples/kovasznay_flow.ymf")["model"] + problem = load_ymf(Path(__file__).parent.parent / "examples" / "kovasznay_flow.ymf")["model"] by_units = _coefficient_values(problem) by_solver = coefficient_numbers(problem["strong_form"]["coefficients"]) assert by_units == by_solver @@ -573,3 +584,55 @@ def test_checking_twice_reuses_the_stand_in_units(): doc = _conduction({"type": "neumann", "value": 1.0}, unknown_units=None) first = [str(i) for i in check(doc, missing_units="warn")] assert first == [str(i) for i in check(doc, missing_units="warn")] + + +# --------------------------------------------------------------------------- +# a bare ymf[units] install, without ymf[symbolic] +# --------------------------------------------------------------------------- + + +def _without_symbolic(monkeypatch): + """Make sympy and ymf.symbolic unimportable for the rest of the test. + + The units-only CI leg runs this for real; here it runs in the full + environment too, so the fallback is guarded wherever the suite runs. + """ + for name in list(sys.modules): + if name in ("sympy", "ymf.symbolic") or name.startswith(("sympy.", "ymf.symbolic.")): + monkeypatch.delitem(sys.modules, name) + monkeypatch.setitem(sys.modules, "sympy", None) + monkeypatch.setitem(sys.modules, "ymf.symbolic", None) + + +def test_without_symbolic_numbers_resolve_and_expressions_are_left_out(monkeypatch): + """What problem-specification.md promises a bare ymf[units] install: + it still sees the coefficients that are already numbers.""" + _without_symbolic(monkeypatch) + problem = { + "characteristic_scales": {"length": {"value": 2.0, "units": "m"}}, + "strong_form": {"coefficients": {"k": 3.0, "c": {"value": "0.5"}, "Q": "π²"}}, + "dimensionless_numbers": { + "A": {"formula": "k * L"}, # a number: resolves + "B": {"formula": "c * L"}, # a quoted number: resolves + "C": {"formula": "Q * L"}, # an expression coefficient: left out + "D": {"formula": "2πL"}, # the spec notation: left out + }, + } + assert compute_dimensionless_numbers(problem) == { + "A": pytest.approx(6.0), "B": pytest.approx(1.0)} + + +def test_without_symbolic_a_flux_condition_is_undecided_not_wrong(monkeypatch): + """A neumann g is compared with [k][T]/[L], which needs the equation read: + without ymf[symbolic] the check says it did not check, and does not fail + a condition that may well be right.""" + _without_symbolic(monkeypatch) + bc = {"type": "neumann", "value": 5.0, "units": "W/m**2"} + assert check(_conduction(bc)).ok # skip: nothing to report + strict = check(_conduction(bc), missing_units="error") + assert not strict.ok + assert all("not checked" in e for e in _errors(strict)) + assert any("ymf[symbolic]" in e for e in _errors(strict)) + # a dirichlet condition needs no equation, so it is still decided + assert check(_conduction({"type": "dirichlet", "value": 300.0, "units": "K"}), + missing_units="error").ok diff --git a/tests/test_values.py b/tests/test_values.py index b11fa43..ebacef5 100644 --- a/tests/test_values.py +++ b/tests/test_values.py @@ -15,9 +15,10 @@ import pytest +strictyaml = pytest.importorskip("strictyaml") + from ymf import load_ymf -strictyaml = pytest.importorskip("strictyaml") SPEC = """\ diff --git a/tests/test_xdmf.py b/tests/test_xdmf.py index f25aa05..014f771 100644 --- a/tests/test_xdmf.py +++ b/tests/test_xdmf.py @@ -224,6 +224,7 @@ def test_ymf_extra_with_special_xml_chars_survives_round_trip(tmp_path): def test_full_notebook_example_round_trips(tmp_path): """End-to-end: the same Problem doc used in YMF-updated.ipynb, plus the mesh archive, written and read back through write_xdmf/read_xdmf.""" + pytest.importorskip("strictyaml") from ymf import validate_ymf poisson_problem_yaml = """