diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e0e499306..fc81554d6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,20 +38,15 @@ jobs: - name: Gusto unit-tests run: | . /home/firedrake/firedrake/bin/activate - python $(which firedrake-clean) + which firedrake-clean python -m pytest -n 12 -v unit-tests - name: Gusto integration-tests run: | . /home/firedrake/firedrake/bin/activate - python $(which firedrake-clean) + firedrake-clean python -m pytest -n 12 -v integration-tests - name: Gusto examples run: | . /home/firedrake/firedrake/bin/activate - python $(which firedrake-clean) + firedrake-clean python -m pytest -n 12 -v examples - - name: Lint - if: ${{ always() }} - run: | - . /home/firedrake/firedrake/bin/activate - make lint diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..e6bec0821 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,57 @@ +name: Check docs build cleanly + +on: + # Run on pushes to master + push: + branches: + - master + # And all pull requests + pull_request: + +concurrency: + # Cancels jobs running if new commits are pushed + group: > + ${{ github.workflow }}- + ${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build_docs: + name: Run doc build + # The type of runner that the job will run on + runs-on: ubuntu-latest + # The docker container to use. + container: + image: firedrakeproject/firedrake-docdeps:latest + options: --user root + volumes: + - ${{ github.workspace }}:/home/firedrake/output + # Steps represent a sequence of tasks that will be executed as + # part of the jobs + steps: + - uses: actions/checkout@v3 + - name: Install checkedout Gusto + run: | + . /home/firedrake/firedrake/bin/activate + python -m pip install -e . + - name: Install Read the Docs theme + run: | + . /home/firedrake/firedrake/bin/activate + python -m pip install sphinx_rtd_theme + - name: Check documentation links + if: ${{ github.ref == 'refs/heads/master' }} + run: | + . /home/firedrake/firedrake/bin/activate + cd docs + make linkcheck + - name: Build docs + run: | + . /home/firedrake/firedrake/bin/activate + cd docs + make html + - name: Upload artifact + uses: actions/upload-pages-artifact@v1 + with: + name: github-pages + path: /__w/gusto/gusto/docs/build/html + retention-days: 1 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..8c29106e4 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,40 @@ +name: Run lint + +on: + # Push to master or PR + push: + branches: + - master + pull_request: + +jobs: + linter: + name: "Run linter" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup python + uses: actions/setup-python@v4 + with: + python-version: 3.11 + - name: Setup flake8 annotations + uses: rbialon/flake8-annotations@v1 + - name: Install linter + run: | + pip install flake8 pylint + - name: Lint codebase + run: | + make lint GITHUB_ACTIONS_FORMATTING=1 + actionlint: + name: "Lint Github actions YAML files" + # There's a way to add error formatting so GH actions adds messages to code, + # but I can't work out the right number of quotes to get it to work + # https://github.com/rhysd/actionlint/blob/main/docs/usage.md + # #example-error-annotation-on-github-actions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Check workflow files + uses: docker://rhysd/actionlint:latest + with: + args: -color diff --git a/Makefile b/Makefile index f077972cb..4ffd2902f 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,22 @@ +# Adds file annotations to Github Actions (only useful on CI) +GITHUB_ACTIONS_FORMATTING=0 +ifeq ($(GITHUB_ACTIONS_FORMATTING), 1) + FLAKE8_FORMAT=--format='::error file=%(path)s,line=%(row)d,col=%(col)d,title=%(code)s::%(path)s:%(row)d:%(col)d: %(code)s %(text)s' +else + FLAKE8_FORMAT= +endif + lint: @echo " Linting gusto codebase" - @python3 -m flake8 gusto + @python3 -m flake8 $(FLAKE8_FORMAT) gusto @echo " Linting gusto examples" - @python3 -m flake8 examples + @python3 -m flake8 $(FLAKE8_FORMAT) examples @echo " Linting gusto unit-tests" - @python3 -m flake8 unit-tests + @python3 -m flake8 $(FLAKE8_FORMAT) unit-tests @echo " Linting gusto integration-tests" - @python3 -m flake8 integration-tests + @python3 -m flake8 $(FLAKE8_FORMAT) integration-tests @echo " Linting gusto plotting scripts" - @python3 -m flake8 plotting + @python3 -m flake8 $(FLAKE8_FORMAT) plotting test: @echo " Running all tests" @@ -24,4 +32,4 @@ integration_test: example: @echo " Running all examples" - @python3 -m pytest examples $(PYTEST_ARGS) \ No newline at end of file + @python3 -m pytest examples $(PYTEST_ARGS) diff --git a/docs/source/conf.py b/docs/source/conf.py index 642702049..28a7e16a4 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -254,5 +254,5 @@ 'pyop2': ('https://op2.github.io/PyOP2', None), 'ufl': ('https://fenics.readthedocs.io/projects/ufl/en/latest/', None), 'h5py': ('http://docs.h5py.org/en/latest/', None), - 'python':('https://docs.python.org/2.7/', None), + 'python':('https://docs.python.org/', None), } diff --git a/gusto/__init__.py b/gusto/__init__.py index c932d6811..d1a210f01 100644 --- a/gusto/__init__.py +++ b/gusto/__init__.py @@ -13,6 +13,7 @@ from gusto.limiters import * # noqa from gusto.linear_solvers import * # noqa from gusto.meshes import * # noqa +from gusto.numerical_integrator import * # noqa from gusto.physics import * # noqa from gusto.preconditioners import * # noqa from gusto.recovery import * # noqa diff --git a/gusto/fields.py b/gusto/fields.py index cfa70cb08..4d204642c 100644 --- a/gusto/fields.py +++ b/gusto/fields.py @@ -34,7 +34,7 @@ def add_field(self, name, space, subfield_names=None): if len(space) > 1: assert len(space) == len(subfield_names) - for field_name, field in zip(subfield_names, value.split()): + for field_name, field in zip(subfield_names, value.subfunctions): setattr(self, field_name, field) field.rename(field_name) self.fields.append(field) diff --git a/gusto/initialisation_tools.py b/gusto/initialisation_tools.py index 74e1126fa..ad85578fb 100644 --- a/gusto/initialisation_tools.py +++ b/gusto/initialisation_tools.py @@ -133,7 +133,7 @@ def incompressible_hydrostatic_balance(equation, b0, p0, top=False, params=None) solve(a == L, w1, bcs=bcs, solver_parameters=params) - v, pprime = w1.split() + v, pprime = w1.subfunctions p0.project(pprime) @@ -235,13 +235,13 @@ def compressible_hydrostatic_balance(equation, theta0, rho0, exner0=None, options_prefix="exner_solver") exner_solver.solve() - v, exner = w.split() + v, exner = w.subfunctions if exner0 is not None: exner0.assign(exner) if solve_for_rho: w1 = Function(W) - v, rho = w1.split() + v, rho = w1.subfunctions rho.interpolate(thermodynamics.rho(parameters, theta0, exner)) v, rho = split(w1) dv, dexner = TestFunctions(W) @@ -256,7 +256,7 @@ def compressible_hydrostatic_balance(equation, theta0, rho0, exner0=None, rhosolver = NonlinearVariationalSolver(rhoproblem, solver_parameters=params, options_prefix="rhosolver") rhosolver.solve() - v, rho_ = w1.split() + v, rho_ = w1.subfunctions rho0.assign(rho_) else: rho0.interpolate(thermodynamics.rho(parameters, theta0, exner)) diff --git a/gusto/linear_solvers.py b/gusto/linear_solvers.py index 547fe99d7..63450add3 100644 --- a/gusto/linear_solvers.py +++ b/gusto/linear_solvers.py @@ -373,7 +373,7 @@ def solve(self, xrhs, dy): # Solve the hybridized system self.hybridized_solver.solve() - broken_u, rho1, _ = self.urhol0.split() + broken_u, rho1, _ = self.urhol0.subfunctions u1 = self.u_hdiv # Project broken_u into the HDiv space @@ -387,7 +387,7 @@ def solve(self, xrhs, dy): bc.apply(u1) # Copy back into u and rho cpts of dy - u, rho, theta = dy.split()[0:3] + u, rho, theta = dy.subfunctions[0:3] u.assign(u1) rho.assign(rho1) @@ -495,7 +495,7 @@ def trace_nullsp(T): b = TrialFunction(Vb) gamma = TestFunction(Vb) - u, p = self.up.split() + u, p = self.up.subfunctions self.b = Function(Vb) b_eqn = gamma*(b - b_in @@ -522,8 +522,8 @@ def solve(self, xrhs, dy): with timed_region("Gusto:VelocityPressureSolve"): self.up_solver.solve() - u1, p1 = self.up.split() - u, p, b = dy.split() + u1, p1 = self.up.subfunctions + u, p, b = dy.subfunctions u.assign(u1) p.assign(p1) diff --git a/gusto/numerical_integrator.py b/gusto/numerical_integrator.py new file mode 100644 index 000000000..d04ab2f2d --- /dev/null +++ b/gusto/numerical_integrator.py @@ -0,0 +1,63 @@ +import numpy as np + + +class NumericalIntegral(object): + """ + A class for numerically evaluating and tabulating some 1D integral. + Args: + lower_bound(float): lower bound of integral + upper_bound(float): upper bound of integral + num_points(float): number of points to tabulate integral at + + """ + def __init__(self, lower_bound, upper_bound, num_points=500): + + # if upper_bound <= lower_bound: + # raise ValueError('lower_bound must be lower than upper_bound') + self.x = np.linspace(lower_bound, upper_bound, num_points) + self.x_double = np.linspace(lower_bound, upper_bound, 2*num_points-1) + self.lower_bound = lower_bound + self.upper_bound = upper_bound + self.num_points = num_points + self.tabulated = False + + def tabulate(self, expression): + """ + Tabulate some integral expression using Simpson's rule. + Args: + expression (func): a function representing the integrand to be + evaluated. should take a numpy array as an argument. + """ + + self.cumulative = np.zeros_like(self.x) + self.interval_areas = np.zeros(len(self.x)-1) + # Evaluate expression in advance to make use of numpy optimisation + # We evaluate at the tabulation points and the midpoints of the intervals + f = expression(self.x_double) + + # Just do Simpson's rule for evaluating area of each interval + self.interval_areas = ((self.x[1:] - self.x[:-1]) / 6.0 + * (f[2::2] + 4.0 * f[1::2] + f[:-1:2])) + + # Add the interval areas together to create cumulative integral + for i in range(self.num_points - 1): + self.cumulative[i+1] = self.cumulative[i] + self.interval_areas[i] + + self.tabulated = True + + def evaluate_at(self, points): + """ + Evaluates the integral at some point using linear interpolation. + Args: + points (float or iter) the point value, or array of point values to + evaluate the integral at. + Return: + returns the numerical approximation of the integral from lower + bound to point(s) + """ + # Do linear interpolation from tabulated values + if not self.tabulated: + raise RuntimeError( + 'Integral must be tabulated before we can evaluate it at a point') + + return np.interp(points, self.x, self.cumulative) diff --git a/gusto/physics.py b/gusto/physics.py index f8240d4a1..1cf7562ee 100644 --- a/gusto/physics.py +++ b/gusto/physics.py @@ -108,8 +108,8 @@ def __init__(self, equation, vapour_name='water_vapour', # Vapour and cloud variables are needed for every form of this scheme cloud_idx = equation.field_names.index(cloud_name) vap_idx = equation.field_names.index(vapour_name) - cloud_water = self.X.split()[cloud_idx] - water_vapour = self.X.split()[vap_idx] + cloud_water = self.X.subfunctions[cloud_idx] + water_vapour = self.X.subfunctions[vap_idx] # Indices of variables in mixed function space V_idxs = [vap_idx, cloud_idx] @@ -119,8 +119,8 @@ def __init__(self, equation, vapour_name='water_vapour', if isinstance(equation, CompressibleEulerEquations): rho_idx = equation.field_names.index('rho') theta_idx = equation.field_names.index('theta') - rho = self.X.split()[rho_idx] - theta = self.X.split()[theta_idx] + rho = self.X.subfunctions[rho_idx] + theta = self.X.subfunctions[theta_idx] if latent_heat: V_idxs.append(theta_idx) @@ -146,7 +146,7 @@ def __init__(self, equation, vapour_name='water_vapour', if (active_tracer.phase == Phases.liquid and active_tracer.chemical == 'H2O' and active_tracer.name != cloud_name): liq_idx = equation.field_names.index(active_tracer.name) - liquid_water += self.X.split()[liq_idx] + liquid_water += self.X.subfunctions[liq_idx] # define some parameters as attributes self.dt = Constant(0.0) @@ -280,7 +280,7 @@ def __init__(self, equation, rain_name, domain, transport_method, self.X = Function(equation.X.function_space()) rain_idx = equation.field_names.index(rain_name) - rain = self.X.split()[rain_idx] + rain = self.X.subfunctions[rain_idx] Vu = domain.spaces("HDiv") # TODO: there must be a better way than forcing this into the equation @@ -314,7 +314,7 @@ def __init__(self, equation, rain_name, domain, transport_method, # this advects the third moment M3 of the raindrop # distribution, which corresponds to the mean mass rho_idx = equation.field_names.index('rho') - rho = self.X.split()[rho_idx] + rho = self.X.subfunctions[rho_idx] rho_w = Constant(1000.0) # density of liquid water # assume n(D) = n_0 * D^mu * exp(-Lambda*D) # n_0 = N_r * Lambda^(1+mu) / gamma(1 + mu) @@ -451,8 +451,8 @@ def evaluate(self, x_in, dt): """ # Update the values of internal variables self.dt.assign(dt) - self.rain.assign(x_in.split()[self.rain_idx]) - self.cloud_water.assign(x_in.split()[self.cloud_idx]) + self.rain.assign(x_in.subfunctions[self.rain_idx]) + self.cloud_water.assign(x_in.subfunctions[self.cloud_idx]) # Evaluate the source self.source.assign(self.source_interpolator.interpolate()) @@ -509,8 +509,8 @@ def __init__(self, equation, rain_name='rain', vapour_name='water_vapour', # Vapour and cloud variables are needed for every form of this scheme rain_idx = equation.field_names.index(rain_name) vap_idx = equation.field_names.index(vapour_name) - rain = self.X.split()[rain_idx] - water_vapour = self.X.split()[vap_idx] + rain = self.X.subfunctions[rain_idx] + water_vapour = self.X.subfunctions[vap_idx] # Indices of variables in mixed function space V_idxs = [rain_idx, vap_idx] @@ -520,8 +520,8 @@ def __init__(self, equation, rain_name='rain', vapour_name='water_vapour', if isinstance(equation, CompressibleEulerEquations): rho_idx = equation.field_names.index('rho') theta_idx = equation.field_names.index('theta') - rho = self.X.split()[rho_idx] - theta = self.X.split()[theta_idx] + rho = self.X.subfunctions[rho_idx] + theta = self.X.subfunctions[theta_idx] if latent_heat: V_idxs.append(theta_idx) @@ -543,7 +543,7 @@ def __init__(self, equation, rain_name='rain', vapour_name='water_vapour', if (active_tracer.phase == Phases.liquid and active_tracer.chemical == 'H2O' and active_tracer.name != rain_name): liq_idx = equation.field_names.index(active_tracer.name) - liquid_water += self.X.split()[liq_idx] + liquid_water += self.X.subfunctions[liq_idx] # define some parameters as attributes self.dt = Constant(0.0) @@ -761,10 +761,10 @@ def evaluate(self, x_in, dt): interval for the scheme. """ if self.convective_feedback: - self.D.assign(x_in.split()[self.VD_idx]) + self.D.assign(x_in.subfunctions[self.VD_idx]) if self.time_varying_saturation: self.saturation_curve.interpolate(self.saturation_computation(x_in)) if self.set_tau_to_dt: self.tau.assign(dt) - self.water_v.assign(x_in.split()[self.Vv_idx]) + self.water_v.assign(x_in.subfunctions[self.Vv_idx]) self.source.assign(self.source_interpolator.interpolate()) diff --git a/gusto/preconditioners.py b/gusto/preconditioners.py index deee1e7b9..6148da642 100644 --- a/gusto/preconditioners.py +++ b/gusto/preconditioners.py @@ -244,8 +244,8 @@ def _reconstruction_calls(self, split_mixed_op, split_trace_op): K_1 = Tensor(split_trace_op[(0, id1)]) # Split functions and reconstruct each bit separately - split_residual = self.broken_residual.split() - split_sol = self.broken_solution.split() + split_residual = self.broken_residual.subfunctions + split_sol = self.broken_solution.subfunctions g = AssembledVector(split_residual[id0]) f = AssembledVector(split_residual[id1]) sigma = split_sol[id0] @@ -316,8 +316,8 @@ def apply(self, pc, x, y): # Transfer unbroken_rhs into broken_rhs # NOTE: Scalar space is already "broken" so no need for # any projections - unbroken_scalar_data = self.unbroken_residual.split()[self.pidx] - broken_scalar_data = self.broken_residual.split()[self.pidx] + unbroken_scalar_data = self.unbroken_residual.subfunctions[self.pidx] + broken_scalar_data = self.broken_residual.subfunctions[self.pidx] unbroken_scalar_data.dat.copy(broken_scalar_data.dat) with timed_region("VertHybridRHS"): @@ -328,8 +328,8 @@ def apply(self, pc, x, y): # basis functions that add together to give unbroken # basis functions. - unbroken_res_hdiv = self.unbroken_residual.split()[self.vidx] - broken_res_hdiv = self.broken_residual.split()[self.vidx] + unbroken_res_hdiv = self.unbroken_residual.subfunctions[self.vidx] + broken_res_hdiv = self.broken_residual.subfunctions[self.vidx] broken_res_hdiv.assign(0) self.average_kernel.apply(broken_res_hdiv, self.weight, unbroken_res_hdiv) @@ -351,13 +351,13 @@ def apply(self, pc, x, y): with timed_region("VertHybridRecover"): # Project the broken solution into non-broken spaces - broken_pressure = self.broken_solution.split()[self.pidx] - unbroken_pressure = self.unbroken_solution.split()[self.pidx] + broken_pressure = self.broken_solution.subfunctions[self.pidx] + unbroken_pressure = self.unbroken_solution.subfunctions[self.pidx] broken_pressure.dat.copy(unbroken_pressure.dat) # Compute the hdiv projection of the broken hdiv solution - broken_hdiv = self.broken_solution.split()[self.vidx] - unbroken_hdiv = self.unbroken_solution.split()[self.vidx] + broken_hdiv = self.broken_solution.subfunctions[self.vidx] + unbroken_hdiv = self.unbroken_solution.subfunctions[self.vidx] unbroken_hdiv.assign(0) self.average_kernel.apply(unbroken_hdiv, self.weight, broken_hdiv) diff --git a/gusto/recovery/recovery_kernels.py b/gusto/recovery/recovery_kernels.py index efc3435bd..1356ea7d6 100644 --- a/gusto/recovery/recovery_kernels.py +++ b/gusto/recovery/recovery_kernels.py @@ -58,8 +58,7 @@ def apply(self, v_out, weighting, v_in): par_loop(self._kernel, dx, {"vo": (v_out, INC), "w": (weighting, READ), - "v": (v_in, READ)}, - is_loopy_kernel=True) + "v": (v_in, READ)}) class AverageWeightings(object): @@ -103,8 +102,7 @@ def apply(self, w): lives in the continuous target space. """ par_loop(self._kernel, dx, - {"w": (w, INC)}, - is_loopy_kernel=True) + {"w": (w, INC)}) class BoundaryRecoveryExtruded(): @@ -167,7 +165,6 @@ def apply(self, x_out, x_in): par_loop(self._bot_kernel, dx, args={"x_out": (x_out, WRITE), "x_in": (x_in, READ)}, - is_loopy_kernel=True, iteration_region=ON_BOTTOM) @@ -240,7 +237,6 @@ def apply(self, x_out, x_in): par_loop(self._top_kernel, dx, args={"x_out": (x_out, WRITE), "x_in": (x_in, READ)}, - is_loopy_kernel=True, iteration_region=ON_TOP) par_loop(self._bot_kernel, dx, args={"x_out": (x_out, WRITE), @@ -521,5 +517,4 @@ def apply(self, v_DG1_old, v_DG1, act_coords, eff_coords, num_ext): "DG1": (v_DG1, WRITE), "ACT_COORDS": (act_coords, READ), "EFF_COORDS": (eff_coords, READ), - "NUM_EXT": (num_ext, READ)}, - is_loopy_kernel=True) + "NUM_EXT": (num_ext, READ)}) diff --git a/gusto/timeloop.py b/gusto/timeloop.py index 02d11b74a..b525fc5ae 100644 --- a/gusto/timeloop.py +++ b/gusto/timeloop.py @@ -210,7 +210,7 @@ def set_reference_profiles(self, reference_profiles): assert field_name in self.equation.field_names, \ f'Cannot set reference profile as field {field_name} not found' idx = self.equation.field_names.index(field_name) - X_ref = self.equation.X_ref.split()[idx] + X_ref = self.equation.X_ref.subfunctions[idx] X_ref.assign(ref) self.reference_profiles_initialised = True diff --git a/unit-tests/fml_tests/test_replace_perp.py b/unit-tests/fml_tests/test_replace_perp.py index 967780696..4be0f993f 100644 --- a/unit-tests/fml_tests/test_replace_perp.py +++ b/unit-tests/fml_tests/test_replace_perp.py @@ -30,7 +30,7 @@ def test_replace_perp(): # make a function to replace the subject with and give it some values U1 = Function(W) - u1, _ = U1.split() + u1, _ = U1.subfunctions x, y = SpatialCoordinate(mesh) u1.interpolate(as_vector([1, 2])) @@ -40,9 +40,9 @@ def test_replace_perp(): U2 = Function(W) solve(a == L.form, U2) - u2, _ = U2.split() + u2, _ = U2.subfunctions U3 = Function(W) - u3, _ = U3.split() + u3, _ = U3.subfunctions u3.interpolate(as_vector([-2, 1])) assert errornorm(u2, u3) < 1e-14 diff --git a/unit-tests/test_numerical_integrator.py b/unit-tests/test_numerical_integrator.py new file mode 100644 index 000000000..53d0b1131 --- /dev/null +++ b/unit-tests/test_numerical_integrator.py @@ -0,0 +1,34 @@ +""" +Tests the numerical integrator. +""" +from gusto import NumericalIntegral +from numpy import sin, pi +import pytest + + +def quadratic(x): + return x**2 + + +def sine(x): + return sin(x) + + +@pytest.mark.parametrize("integrand_name", ["quadratic", "sine"]) +def test_numerical_integrator(integrand_name): + if integrand_name == "quadratic": + integrand = quadratic + upperbound = 3 + answer = 9 + elif integrand_name == "sine": + integrand = sine + upperbound = pi + answer = 2 + else: + raise ValueError(f'{integrand_name} integrand not recognised') + numerical_integral = NumericalIntegral(0, upperbound) + numerical_integral.tabulate(integrand) + area = numerical_integral.evaluate_at(upperbound) + err_tol = 1e-10 + assert abs(area-answer) < err_tol, \ + f'numerical integrator is incorrect for {integrand_name} function'