Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Time-dependent GO adaptation demo and time-dependent constants #137

Closed
wants to merge 12 commits into from
313 changes: 313 additions & 0 deletions demos/bubble_shear-goal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
# Goal-oriented mesh adaptation for a 2D time-dependent problem
# ===================================================
jwallwork23 marked this conversation as resolved.
Show resolved Hide resolved

# In a `previous demo <./bubble_shear.py.html>`__, we considered the advection of a scalar
# concentration field in a non-uniform background velocity field. The demo concluded with
# labelling the problem as a good candidate for a goal-oriented mesh adaptation approach.
# This is what we set out to do in this demo.
#
# We will use the same setup as in the previous demo, but a small modifiction to the
# :meth:`get_form` function is necessary in order to take into account a prescribed
# time-dependent background velocity field which is not passed to :class:`GoalOrientedMeshSeq`. ::

from firedrake import *
from goalie_adjoint import *
from animate.metric import RiemannianMetric
from animate.adapt import adapt


period = 6.0


def velocity_expression(x, y, t):
u_expr = as_vector(
[
2 * sin(pi * x) ** 2 * sin(2 * pi * y) * cos(2 * pi * t / period),
-sin(2 * pi * x) * sin(pi * y) ** 2 * cos(2 * pi * t / period),
]
)
return u_expr


fields = ["c"]


def get_function_spaces(mesh):
return {"c": FunctionSpace(mesh, "CG", 1)}


def get_bcs(mesh_seq):
def bcs(index):
return [DirichletBC(mesh_seq.function_spaces["c"][index], 0.0, "on_boundary")]

return bcs


def ball_initial_condition(x, y):
ball_r0, ball_x0, ball_y0 = 0.15, 0.5, 0.65
r = sqrt(pow(x - ball_x0, 2) + pow(y - ball_y0, 2))
return conditional(r < ball_r0, 1.0, 0.0)


def get_initial_condition(mesh_seq):
fs = mesh_seq.function_spaces["c"][0]
x, y = SpatialCoordinate(mesh_seq[0])
ball = ball_initial_condition(x, y)
c0 = assemble(interpolate(ball, fs))
return {"c": c0}


def get_solver(mesh_seq):
def solver(index, ic):
tp = mesh_seq.time_partition
t_start, t_end = tp.subintervals[index]
dt = tp.timesteps[index]

# Initialise the concentration fields
Q = mesh_seq.function_spaces["c"][index]
c = Function(Q, name="c")
c_ = Function(Q, name="c_old").assign(ic["c"])

# Specify the velocity function space
V = VectorFunctionSpace(mesh_seq[index], "CG", 1)
u = Function(V)
u_ = Function(V)

# Compute the velocity field at t_start and assign it to u_
x, y = SpatialCoordinate(mesh_seq[index])
u_.interpolate(velocity_expression(x, y, t_start))

# We pass both the concentration and velocity Functions to get_form
form_fields = {"c": (c, c_), "u": (u, u_)}
F = mesh_seq.form(index, form_fields)["c"]
nlvp = NonlinearVariationalProblem(F, c, bcs=mesh_seq.bcs(index))
nlvs = NonlinearVariationalSolver(nlvp, ad_block_tag="c")

# Time integrate from t_start to t_end
t = t_start + dt
while t < t_end + 0.5 * dt:
# update the background velocity field at the current timestep
u.interpolate(velocity_expression(x, y, t))

# solve the advection equation
nlvs.solve()

# update the 'lagged' concentration and velocity field
c_.assign(c)
u_.assign(u)
t += dt

return {"c": c}

return solver


# In the previous demo, the :meth:`get_form` method was only called from within the
# :meth:`get_solver`, so we were able to pass the velocity field as an argument when
# calling :meth:`get_form`. However, in the context of goal-oriented mesh adaptation,
# the :meth:`get_form` method is called from within :class:`GoalOrientedMeshSeq`
# while computing error indicators. There, only those fields that are passed to
# :class:`GoalOrientedMeshSeq` are passed to :meth:`get_form`. This means that the velocity
# field would not be updated in time. In such a case, we will manually compute the
# velocity field using the current simulation time, which will be passed automatically
# as an :arg:`err_ind_time` kwarg in :meth:`GoalOrientedMeshSeq.indicate_errors`. ::


def get_form(mesh_seq):
def form(index, form_fields, err_ind_time=None):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should just add time as an argument to form in general? For problems where the form doesn't vary with time, it will just be ignored. IMO it would be better to only pass solution fields (and their lagged counterparts) through the second argument and always determine other variables based on the current time. I guess this would imply some refactoring, including the other demos.

It seems odd to reference error indicators in the form expression.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you do end up making this change then it would be handy to fix #140 while you're at it if you don't mind.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ddundo I like @jwallwork23's idea of adding time as an argument in form. Passing time directly vs a flag for time would cut down potentially on extra variables and make the intent clear and possibly easier to follow. I can help with demo refractoring if needed.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @acse-ej321, I just saw this! Please see the new comment I left explaining my reasoning here. I agree that this suggestion is much nicer but I think it's also much more inefficient - unless I'm missing something! Please correct me if I'm wrong :)

Q = mesh_seq.function_spaces["c"][index]
c, c_ = form_fields["c"]

if "u" in form_fields:
u, u_ = form_fields["u"]
else:
x, y = SpatialCoordinate(mesh_seq[index])
V = VectorFunctionSpace(mesh_seq[index], "CG", 1)
u = Function(V).interpolate(velocity_expression(x, y, err_ind_time))
u_ = Function(V).interpolate(velocity_expression(x, y, err_ind_time))

# the rest remains unchanged

R = FunctionSpace(mesh_seq[index], "R", 0)
dt = Function(R).assign(mesh_seq.time_partition.timesteps[index])
theta = Function(R).assign(0.5) # Crank-Nicolson implicitness

# SUPG stabilisation parameter
D = Function(R).assign(0.1) # diffusivity coefficient
h = CellSize(mesh_seq[index]) # mesh cell size
U = sqrt(dot(u, u)) # velocity magnitude
tau = 0.5 * h / U
tau = min_value(tau, U * h / (6 * D))

phi = TestFunction(Q)
phi += tau * dot(u, grad(phi))

a = c * phi * dx + dt * theta * dot(u, grad(c)) * phi * dx
L = c_ * phi * dx - dt * (1 - theta) * dot(u_, grad(c_)) * phi * dx
F = a - L

return {"c": F}

return form


# To compute the adjoint, we must also define the goal functional. As motivated in
# the previous demo, we will use the L2 norm of the difference between the
# concentration field at :math:`t=0` and :math:`t=T/2`, i.e. the simulation end time.
#
# .. math::
# J(c) := \int_{\Omega} (c(x, y, T/2) - c_0(x, y))^2 \, dx \, dy. ::


def get_qoi(self, sols, index):
def qoi():
c0 = self.get_initial_condition()["c"]
c0_proj = project(c0, self.function_spaces["c"][index])
c = sols["c"]

J = (c - c0_proj) * (c - c0_proj) * dx
return J

return qoi


# We must also define the adaptor function to drive the mesh adaptation process. Here
# we compute the anisotropic DWR metric which requires us to compute the hessian of the
# tracer concentration field. We combine each metric in a subinterval by intersection.
# We will only run two iterations of the fixed point iteration algorithm so we do not
# ramp up the complexity. ::


def adaptor(mesh_seq, solutions, indicators):
iteration = mesh_seq.fp_iteration
mp = {
"dm_plex_metric": {
"target_complexity": 1000,
"p": 1.0,
"h_min": 1e-04,
"h_max": 1.0,
}
}

metrics = []
for i, mesh in enumerate(mesh_seq):
c_solutions = solutions["c"]["forward"][i]

P1_ten = TensorFunctionSpace(mesh, "CG", 1)
subinterval_metric = RiemannianMetric(P1_ten)

for j, c_sol in enumerate(c_solutions):
# Recover the Hessian of the forward solution
hessian = RiemannianMetric(P1_ten)
hessian.compute_hessian(c_sol)

metric = RiemannianMetric(P1_ten)
metric.set_parameters(mp)

# Compute an anisotropic metric from the error indicator field and Hessian
metric.compute_anisotropic_dwr_metric(indicators["c"][i][j], hessian)

# Combine the metrics from each exported timestep in the subinterval
subinterval_metric.intersect(metric)
metrics.append(metric)

# Normalise the metrics in space and time
space_time_normalise(metrics, mesh_seq.time_partition, mp)

complexities = np.zeros(len(mesh_seq))
for i, metric in enumerate(metrics):
complexities[i] = metric.complexity()
mesh_seq[i] = adapt(mesh_seq[i], metric)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side note: this makes me think about #122.


num_dofs = mesh_seq.count_vertices()
num_elem = mesh_seq.count_elements()
pyrint(f"Fixed point iteration {iteration}:")
for i, (complexity, ndofs, nelem) in enumerate(
zip(complexities, num_dofs, num_elem)
):
pyrint(
f" subinterval {i}, complexity: {complexity:4.0f}"
f", dofs: {ndofs:4d}, elements: {nelem:4d}"
)

return True


# Finally, we can define the :class:`GoalOrientedMeshSeq` and run the fixed point iteration.
# For the purposes of this demo, we divide the time interval into 6 subintervals and only
# run two iterations of the fixed point iteration, which is not enough to reach convergence. ::

# Reduce the cost of the demo during testing
test = os.environ.get("GOALIE_REGRESSION_TEST") is not None
n = 50 if not test else 5
dt = 0.01 if not test else 0.05
maxiter = 2 if not test else 1 # maximum number of fixed point iterations

num_subintervals = 6
meshes = [UnitSquareMesh(n, n) for _ in range(num_subintervals)]
end_time = period / 2
time_partition = TimePartition(
end_time,
len(meshes),
jwallwork23 marked this conversation as resolved.
Show resolved Hide resolved
dt,
fields,
num_timesteps_per_export=5,
)

parameters = GoalOrientedMetricParameters({"maxiter": maxiter})
msq = GoalOrientedMeshSeq(
time_partition,
meshes,
get_function_spaces=get_function_spaces,
get_initial_condition=get_initial_condition,
get_bcs=get_bcs,
get_form=get_form,
get_solver=get_solver,
get_qoi=get_qoi,
qoi_type="end_time",
parameters=parameters,
)
solutions, indicators = msq.fixed_point_iteration(adaptor)

# Let us plot the intermediate and final solutions, as well as the final adapted meshes. ::

import matplotlib.pyplot as plt
from firedrake.pyplot import tripcolor, triplot

fig, axes = plt.subplots(2, 3, figsize=(7.5, 5), sharex=True, sharey=True)

for i, ax in enumerate(axes.flatten()):
ax.set_aspect("equal")
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
# ax.tick_params(axis='both', which='major', labelsize=6)

# plot the solution at the final subinterval timestep
time = time_partition.subintervals[i][-1]
tripcolor(solutions["c"]["forward"][i][-1], axes=ax, cmap="coolwarm")
triplot(msq[i], axes=ax, interior_kw={"linewidth": 0.08})
ax.annotate(f"t={time:.2f}", (0.05, 0.05), color="white")

fig.tight_layout(pad=0.3)
fig.savefig("bubble_shear-goal_final_meshes.jpg", dpi=300, bbox_inches="tight")

# .. figure:: bubble_shear-goal_final_meshes.jpg
jwallwork23 marked this conversation as resolved.
Show resolved Hide resolved
# :figwidth: 80%
# :align: center
#
# Compared to the solution obtained on a fixed uniform mesh in the previous demo, we
# observe that the final solution at :math:`t=T/2` better matches the initial tracer
# bubble :math:`c_0` and is less diffused. Despite employing only two fixed
# point iterations, the goal-oriented mesh adaptation process was still able to
# significantly improve the accuracy of the solution while reducing the number of
# degrees of freedom by half.
#
# As shown in the above figure, the bubble experiences extreme deformation and requires
# frequent adaptation to be resolved accurately (surely more than 5, as in this demo!).
# We encourage users to experiment with different numbers of subintervals, number of
# exports per subinterval, adaptor functions, and other parameters to explore the
# convergence of the fixed point iteration. Another interesting experiment would be to
# compare the impact of switching to an explicit time integration and using a smaller
# timestep to maintain numerical stability (look at CFL condition).
#
# This tutorial can be dowloaded as a `Python script <bubble_shear-goal.py>`__.
Loading
Loading