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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions src/ymf/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
Seq,
Str,
)
from strictyaml.validators import OrValidator

from ymf.discretizations import METHODS
from ymf.state import STATES
Expand Down Expand Up @@ -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)):
Expand Down
1 change: 1 addition & 0 deletions tests/test_closure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:"))
Expand Down
3 changes: 2 additions & 1 deletion tests/test_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion tests/test_core_without_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
65 changes: 63 additions & 2 deletions tests/test_domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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]}
Expand All @@ -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"}
1 change: 1 addition & 0 deletions tests/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion tests/test_references.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=""):
Expand Down
3 changes: 2 additions & 1 deletion tests/test_removal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """\
Expand Down
3 changes: 2 additions & 1 deletion tests/test_schema.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from pathlib import Path

import pytest
import strictyaml

strictyaml = pytest.importorskip("strictyaml")

from ymf.schema import load_ymf

Expand Down
71 changes: 71 additions & 0 deletions tests/test_symbolic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading
Loading