ProCreations's picture
Publish validated GP-FVM reproduction
c711202 verified
Raw
History Blame Contribute Delete
20.6 kB
"""
GP-FVM solver for advection-diffusion source identification.
Uses sparse Gaussian Process Finite Volume Method with exact face integrals.
Follows the same approach as experiments/steady_advection_diffusion.jl.
Jointly infers:
- Source field s(x,y) with uncertainty
- Concentration field c(x,y) with uncertainty
Reads problem from TOML and ground truth data from NPZ.
Outputs predictions to NPZ for comparison with PINN baseline.
Usage:
julia --project=../.. run_gpfvm.jl --problem problems/default.toml
julia --project=../.. run_gpfvm.jl --problem problems/default.toml --rho 2.5
"""
using LinearAlgebra, SparseArrays
using ArgParse
using NPZ
push!(LOAD_PATH, joinpath(@__DIR__, "..", ".."))
using GPFiniteVolume
using FunctionalGPs, GaussianMarkovRandomFields
import GaussianMarkovRandomFields: mean, std
import FunctionalGPs: ⊗
include("problem.jl")
# ------------------------------------------------------------------------------
# FVM Constraint Builder (exact face integrals)
# ------------------------------------------------------------------------------
"""
Build FVM constraint using EXACT face integrals for flux computation.
For each cell: Σ(face fluxes of c) - s_int = 0
"""
function build_fvm_constraint(xs, ys, prob::SourceIdentificationProblem,
layout_c, layout_s, n_c)
Nx, Ny = length(xs), length(ys)
n_cells_x, n_cells_y = Nx - 1, Ny - 1
n_cells = n_cells_x * n_cells_y
n_s = layout_s.total
n_total = n_c + n_s
s_offset = n_c
# Face indexing (column-major order from TensorProductFunctional):
# Vertical faces: shape (Nx, n_cells_y) - x varies fastest
# Horizontal faces: shape (n_cells_x, Ny) - x varies fastest
vert_idx(i, j) = (j-1)*Nx + i # i in 1:Nx, j in 1:n_cells_y
horiz_idx(i, j) = (j-1)*n_cells_x + i # i in 1:n_cells_x, j in 1:Ny
c_vert_base = first(indices(layout_c, :c_vert))
c_horiz_base = first(indices(layout_c, :c_horiz))
c_dx_vert_base = first(indices(layout_c, :c_dx_vert))
c_dy_horiz_base = first(indices(layout_c, :c_dy_horiz))
c_vert(i, j) = c_vert_base + vert_idx(i, j) - 1
c_horiz(i, j) = c_horiz_base + horiz_idx(i, j) - 1
c_dx_vert(i, j) = c_dx_vert_base + vert_idx(i, j) - 1
c_dy_horiz(i, j) = c_dy_horiz_base + horiz_idx(i, j) - 1
s_int_idx(ci, cj) = s_offset + indices(layout_s, :s_int)[(cj-1)*n_cells_x + ci]
constraints = []
for cj in 1:n_cells_y
for ci in 1:n_cells_x
row = spzeros(n_total)
# Advective flux using EXACT face integrals
# Right face: +vx * ∫c dy
row[c_vert(ci+1, cj)] += prob.vx
# Left face: -vx * ∫c dy
row[c_vert(ci, cj)] -= prob.vx
# vy advection (if nonzero)
if prob.vy != 0
# Top face: +vy * ∫c dx
row[c_horiz(ci, cj+1)] += prob.vy
# Bottom face: -vy * ∫c dx
row[c_horiz(ci, cj)] -= prob.vy
end
# Diffusive flux using EXACT derivative face integrals
# Right face: -D * ∫(∂c/∂x) dy
row[c_dx_vert(ci+1, cj)] -= prob.D
# Left face: +D * ∫(∂c/∂x) dy
row[c_dx_vert(ci, cj)] += prob.D
# Top face: -D * ∫(∂c/∂y) dx
row[c_dy_horiz(ci, cj+1)] -= prob.D
# Bottom face: +D * ∫(∂c/∂y) dx
row[c_dy_horiz(ci, cj)] += prob.D
# Source integral: -s_int
row[s_int_idx(ci, cj)] = -1.0
push!(constraints, row)
end
end
A_fvm = vcat([reshape(r, 1, :) for r in constraints]...)
b_fvm = zeros(n_cells)
return sparse(A_fvm), b_fvm
end
"""
Build boundary condition constraints using face integrals.
- Dirichlet: c = c_inflow at left boundary (point evaluations)
- Neumann: ∫(∂c/∂x)dy = 0 at right boundary
- Neumann: ∫(∂c/∂y)dx = 0 at top/bottom
"""
function build_boundary_constraints(xs, ys, prob::SourceIdentificationProblem,
layout_c, n_c, n_total)
Nx, Ny = length(xs), length(ys)
n_cells_x, n_cells_y = Nx - 1, Ny - 1
# Point evaluation indices for Dirichlet BCs
c_eval_idx(i, j) = indices(layout_c, :c)[(j-1)*Nx + i]
# Face indexing (column-major order)
vert_idx(i, j) = (j-1)*Nx + i
horiz_idx(i, j) = (j-1)*n_cells_x + i
c_dx_vert_base = first(indices(layout_c, :c_dx_vert))
c_dy_horiz_base = first(indices(layout_c, :c_dy_horiz))
c_dx_vert(i, j) = c_dx_vert_base + vert_idx(i, j) - 1
c_dy_horiz(i, j) = c_dy_horiz_base + horiz_idx(i, j) - 1
constraints = []
rhs = Float64[]
# Left boundary (inflow): c = c_inflow (Dirichlet via point evals)
for j in 1:Ny
row = spzeros(n_total)
row[c_eval_idx(1, j)] = 1.0
push!(constraints, row)
push!(rhs, prob.c_inflow)
end
# Right boundary (outflow): ∫(∂c/∂x)dy = 0 (Neumann)
for j in 1:n_cells_y
row = spzeros(n_total)
row[c_dx_vert(Nx, j)] = 1.0
push!(constraints, row)
push!(rhs, 0.0)
end
# Top boundary: ∫(∂c/∂y)dx = 0 (Neumann)
for i in 1:n_cells_x
row = spzeros(n_total)
row[c_dy_horiz(i, Ny)] = 1.0
push!(constraints, row)
push!(rhs, 0.0)
end
# Bottom boundary: ∫(∂c/∂y)dx = 0 (Neumann)
for i in 1:n_cells_x
row = spzeros(n_total)
row[c_dy_horiz(i, 1)] = 1.0
push!(constraints, row)
push!(rhs, 0.0)
end
A_bc = vcat([reshape(r, 1, :) for r in constraints]...)
return sparse(A_bc), rhs
end
# ------------------------------------------------------------------------------
# Main Solver
# ------------------------------------------------------------------------------
function solve_source_identification(prob::SourceIdentificationProblem, data::Dict;
ρ::Real = 2.0,
lengthscale_c::Union{Real, Nothing} = nothing,
lengthscale_s::Union{Real, Nothing} = nothing,
source_amplitude::Real = 1.0,
output_scale::Real = 1.0,
output_scale_c::Union{Real, Nothing} = nothing,
smoothness::Int = 2,
constraint_noise::Real = 1e-5,
verbose::Bool = true,
benchmark::Bool = false
)
timings = Dict{String, Float64}()
verbose && println("=" ^ 60)
verbose && println("GP-FVM Source Identification")
verbose && println("=" ^ 60)
# Extract grid from data
xs = data["xs"]
ys = data["ys"]
Nx, Ny = length(xs), length(ys)
n_cells_x, n_cells_y = Nx - 1, Ny - 1
verbose && println("\nGrid: $(Nx) × $(Ny) nodes, $(n_cells_x) × $(n_cells_y) cells")
# Compute grid spacing
Δx = xs[2] - xs[1]
Δy = ys[2] - ys[1]
Δ = min(Δx, Δy)
# Default lengthscales: 5 * grid spacing (resolve ~5 cells)
lengthscale_c = isnothing(lengthscale_c) ? 5 * Δ : lengthscale_c
lengthscale_s = isnothing(lengthscale_s) ? 5 * Δ : lengthscale_s
verbose && println(" Grid spacing: Δx=$(round(Δx, digits=4)), Δy=$(round(Δy, digits=4))")
verbose && println(" Lengthscales: c=$(round(lengthscale_c, digits=4)), s=$(round(lengthscale_s, digits=4))")
# Build intervals
x_intervals = intervals_from_endpoints(collect(xs))
y_intervals = intervals_from_endpoints(collect(ys))
# Build 2D grid and cells
grid = FactorizedGrid(xs, ys)
cells_2d = x_intervals ⊗ y_intervals
# Build kernels (2D product kernels)
verbose && println("\nBuilding kernels...")
k_c = HalfIntegerMaternKernel(smoothness, [lengthscale_c]) ⊗
HalfIntegerMaternKernel(smoothness, [lengthscale_c])
k_s = HalfIntegerMaternKernel(smoothness, [lengthscale_s]) ⊗
HalfIntegerMaternKernel(smoothness, [lengthscale_s])
# Build functionals with exact face integrals
verbose && println("Building functionals...")
# Point evaluations (for BCs, observations, and output)
L_c_eval = EvaluationFunctional(grid)
# Face integrals for exact flux computation
# Vertical faces: ∫_y c(x,y) dy at each x-position
L_c_vert = EvaluationFunctional(xs) ⊗ VectorizedLebesgueIntegral(y_intervals)
# Horizontal faces: ∫_x c(x,y) dx at each y-position
L_c_horiz = VectorizedLebesgueIntegral(x_intervals) ⊗ EvaluationFunctional(ys)
# Derivative face integrals for diffusive flux
L_c_dx_vert = L_c_vert ∘ PartialDerivative((1, 0)) # ∫(∂c/∂x) dy
L_c_dy_horiz = L_c_horiz ∘ PartialDerivative((0, 1)) # ∫(∂c/∂y) dx
# Source functionals
L_s_eval = EvaluationFunctional(grid)
L_s_int = VectorizedLebesgueIntegral(cells_2d)
# Build sparse precisions
verbose && println("\nBuilding sparse precisions (ρ = )...")
timings["sparse_prec_c"] = @elapsed approx_c = sparse_precision([
:c => L_c_eval,
:c_vert => L_c_vert,
:c_horiz => L_c_horiz,
:c_dx_vert => L_c_dx_vert,
:c_dy_horiz => L_c_dy_horiz,
], k_c; ρ=ρ, ordering=:integrals_coarsest)
verbose && println(" Concentration: ", approx_c)
timings["sparse_prec_s"] = @elapsed approx_s = sparse_precision([
:s => L_s_eval,
:s_int => L_s_int,
], k_s; ρ=4.0, ordering=:integrals_coarsest)
verbose && println(" Source: ", approx_s)
# Combine block-diagonally
n_c = approx_c.info.n
n_s = approx_s.info.n
n_total = n_c + n_s
# Scale source precision by 1/amplitude² to get prior variance = amplitude²
# Scale by output_scale (prior covariance scales by output_scale)
# Optionally use separate output_scale_c for concentration (flat c prior)
σ²_c = isnothing(output_scale_c) ? output_scale : output_scale_c
σ²_s = output_scale
Q_s_scaled = sparse(approx_s.Q) / (source_amplitude^2 * σ²_s)
Q_c_scaled = sparse(approx_c.Q) / σ²_c
Q_joint = blockdiag(Q_c_scaled, Q_s_scaled)
verbose && source_amplitude != 1.0 && println(" Source amplitude: $source_amplitude (prior std)")
layout_c = approx_c.layout
layout_s = approx_s.layout
verbose && println(" Joint state dimension: $n_total")
# Build FVM constraints
verbose && println("\nBuilding FVM constraints...")
A_fvm, b_fvm = build_fvm_constraint(xs, ys, prob, layout_c, layout_s, n_c)
A_bc, b_bc = build_boundary_constraints(xs, ys, prob, layout_c, n_c, n_total)
verbose && println(" FVM equations: $(size(A_fvm, 1))")
verbose && println(" Boundary conditions: $(size(A_bc, 1))")
# Combine FVM and BC constraints
A_constraints = vcat(A_fvm, A_bc)
b_constraints = vcat(b_fvm, b_bc)
n_constraints = size(A_constraints, 1)
Q_constraints = (1.0 / constraint_noise^2) * sparse(I, n_constraints, n_constraints)
# Build observation matrix
obs_x = data["obs_x"]
obs_y = data["obs_y"]
obs_c = data["obs_c"]
n_obs = length(obs_c)
noise_std = Float64(data["noise_std"])
verbose && println("\nBuilding observation matrix ($(n_obs) observations)...")
# Find nearest grid points and build selection matrix
obs_ix = [argmin(abs.(xs .- ox)) for ox in obs_x]
obs_iy = [argmin(abs.(ys .- oy)) for oy in obs_y]
obs_indices = [indices(layout_c, :c)[(iy-1)*Nx + ix] for (ix, iy) in zip(obs_ix, obs_iy)]
A_obs = spzeros(n_obs, n_total)
for (i, idx) in enumerate(obs_indices)
A_obs[i, idx] = 1.0
end
Q_obs = (1.0 / noise_std^2) * sparse(I, n_obs, n_obs)
# Apply all conditioning in one step (avoids intermediate Cholesky)
verbose && println("\nConditioning (combined FVM + BC + observations)...")
timings["conditioning"] = @elapsed x_posterior = condition_precision(Q_joint, [
(A=A_constraints, Q_ϵ=Q_constraints, y=b_constraints),
(A=A_obs, Q_ϵ=Q_obs, y=obs_c),
])
# Extract posterior statistics
verbose && println("\nExtracting posterior statistics...")
timings["posterior_stats"] = @elapsed begin
μ = mean(x_posterior)
σ = std(x_posterior)
end
# Concentration posterior
c_mean = reshape(μ[indices(layout_c, :c)], Nx, Ny)
c_std = reshape(σ[indices(layout_c, :c)], Nx, Ny)
# Source posterior
s_eval_indices = n_c .+ indices(layout_s, :s)
s_int_indices = n_c .+ indices(layout_s, :s_int)
s_mean = reshape(μ[s_eval_indices], Nx, Ny)
s_std = reshape(σ[s_eval_indices], Nx, Ny)
s_int_mean = reshape(μ[s_int_indices], n_cells_x, n_cells_y)
s_int_std = reshape(σ[s_int_indices], n_cells_x, n_cells_y)
# Find MAP estimate of source location from point evaluations
max_idx = argmax(s_mean)
map_x = xs[max_idx[1]]
map_y = ys[max_idx[2]]
# Cell midpoints for integral visualization
cell_mx = [0.5 * (xs[i] + xs[i+1]) for i in 1:n_cells_x]
cell_my = [0.5 * (ys[j] + ys[j+1]) for j in 1:n_cells_y]
# True source location (first source)
true_x = Float64(data["source_x"][1])
true_y = Float64(data["source_y"][1])
location_error = sqrt((map_x - true_x)^2 + (map_y - true_y)^2)
verbose && println("\nResults:")
verbose && println(" Concentration range: [$(round(minimum(c_mean), digits=4)), $(round(maximum(c_mean), digits=4))]")
verbose && println(" Source range: [$(round(minimum(s_mean), digits=4)), $(round(maximum(s_mean), digits=4))]")
verbose && println(" True source: ($true_x, $true_y)")
verbose && println(" MAP estimate: ($(round(map_x, digits=3)), $(round(map_y, digits=3)))")
verbose && println(" Location error: $(round(location_error, digits=4))")
# Compute total time
timings["total"] = sum(values(timings))
info = (
ρ = ρ,
lengthscale_c = lengthscale_c,
lengthscale_s = lengthscale_s,
n_total = n_total,
n_c = n_c,
n_obs = n_obs,
fill_c = approx_c.info.fill_pct,
fill_s = approx_s.info.fill_pct,
map_x = map_x,
map_y = map_y,
location_error = location_error,
timings = timings,
layout_c = layout_c,
layout_s = layout_s,
)
return (
c_mean = c_mean,
c_std = c_std,
s_mean = s_mean,
s_std = s_std,
s_int_mean = s_int_mean,
s_int_std = s_int_std,
xs = xs,
ys = ys,
cell_mx = cell_mx,
cell_my = cell_my,
info = info,
gmrf = x_posterior,
)
end
# ------------------------------------------------------------------------------
# CLI
# ------------------------------------------------------------------------------
function parse_commandline()
s = ArgParseSettings(description = "GP-FVM source identification solver")
@add_arg_table! s begin
"--problem", "-p"
help = "Path to problem TOML file"
arg_type = String
required = true
"--data", "-d"
help = "Path to data NPZ file (default: inferred from problem)"
arg_type = String
default = ""
"--output", "-o"
help = "Output NPZ file (default: results/<problem_name>_gpfvm.npz)"
arg_type = String
default = ""
"--rho", "-r"
help = "Sparsity parameter"
arg_type = Float64
default = 2.0
"--lengthscale-c"
help = "Concentration kernel lengthscale (default: 5*Δx)"
arg_type = Float64
default = -1.0
"--lengthscale-s"
help = "Source kernel lengthscale (default: 5*Δx)"
arg_type = Float64
default = -1.0
"--smoothness"
help = "Matérn smoothness (1=3/2, 2=5/2)"
arg_type = Int
default = 2
"--source-amplitude"
help = "Prior std for source field (default: 1.0)"
arg_type = Float64
default = 1.0
"--quiet", "-q"
help = "Suppress progress output"
action = :store_true
"--benchmark", "-b"
help = "Run in benchmark mode (warmup + timing)"
action = :store_true
"--benchmark-runs"
help = "Number of timed runs in benchmark mode"
arg_type = Int
default = 3
end
return parse_args(s)
end
function print_benchmark_results(timings::Dict{String, Float64})
println("\n" * "=" ^ 50)
println("BENCHMARK RESULTS")
println("=" ^ 50)
println(" sparse_precision (c): $(round(timings["sparse_prec_c"]*1000, digits=1)) ms")
println(" sparse_precision (s): $(round(timings["sparse_prec_s"]*1000, digits=1)) ms")
println(" conditioning: $(round(timings["conditioning"]*1000, digits=1)) ms")
println(" posterior stats: $(round(timings["posterior_stats"]*1000, digits=1)) ms")
println("-" ^ 50)
println(" TOTAL: $(round(timings["total"]*1000, digits=1)) ms")
println("=" ^ 50)
end
function main()
args = parse_commandline()
verbose = !args["quiet"]
benchmark = args["benchmark"]
n_runs = args["benchmark-runs"]
# Load problem
prob = load_problem(args["problem"])
verbose && println(prob)
# Determine data path
data_path = args["data"]
if isempty(data_path)
problem_name = splitext(basename(args["problem"]))[1]
data_path = joinpath(dirname(args["problem"]), "..", "data", "$(problem_name).npz")
end
verbose && println("\nLoading data from: $data_path")
data = npzread(data_path)
# Solve (use nothing for lengthscales if default -1.0)
ls_c = args["lengthscale-c"] < 0 ? nothing : args["lengthscale-c"]
ls_s = args["lengthscale-s"] < 0 ? nothing : args["lengthscale-s"]
solver_kwargs = (
ρ = args["rho"],
lengthscale_c = ls_c,
lengthscale_s = ls_s,
source_amplitude = args["source-amplitude"],
smoothness = args["smoothness"],
)
if benchmark
# Benchmark mode: warmup + multiple timed runs
println("\n" * "=" ^ 50)
println("BENCHMARK MODE")
println("=" ^ 50)
println("\nWarmup run...")
solve_source_identification(prob, data; solver_kwargs..., verbose=false, benchmark=true)
println("Warmup complete.")
println("\nRunning $n_runs timed iterations...")
all_timings = Vector{Dict{String, Float64}}()
for i in 1:n_runs
result = solve_source_identification(prob, data; solver_kwargs..., verbose=false, benchmark=true)
push!(all_timings, result.info.timings)
println(" Run $i: $(round(result.info.timings["total"]*1000, digits=1)) ms")
end
# Compute min timings (best case)
best_timings = Dict{String, Float64}()
for key in keys(all_timings[1])
best_timings[key] = minimum(t[key] for t in all_timings)
end
print_benchmark_results(best_timings)
# Final run with verbose output for results
println("\nFinal run (with output):")
result = solve_source_identification(prob, data; solver_kwargs..., verbose=true, benchmark=true)
else
# Normal mode
result = solve_source_identification(prob, data; solver_kwargs..., verbose=verbose, benchmark=false)
end
# Prepare output
problem_name = splitext(basename(args["problem"]))[1]
output_path = args["output"]
if isempty(output_path)
output_path = joinpath(dirname(args["problem"]), "..", "results", "$(problem_name)_gpfvm.npz")
end
mkpath(dirname(output_path))
output = Dict{String, Any}(
# Grid
"xs" => result.xs,
"ys" => result.ys,
"cell_mx" => result.cell_mx,
"cell_my" => result.cell_my,
# Concentration predictions
"c_mean" => result.c_mean,
"c_std" => result.c_std,
# Source predictions
"s_mean" => result.s_mean,
"s_std" => result.s_std,
"s_int_mean" => result.s_int_mean,
"s_int_std" => result.s_int_std,
# Solver info
"rho" => result.info.ρ,
"lengthscale_c" => result.info.lengthscale_c,
"lengthscale_s" => result.info.lengthscale_s,
"n_total" => result.info.n_total,
"n_obs" => result.info.n_obs,
"fill_c_pct" => result.info.fill_c,
"fill_s_pct" => result.info.fill_s,
"map_x" => result.info.map_x,
"map_y" => result.info.map_y,
"location_error" => result.info.location_error,
)
npzwrite(output_path, output)
verbose && println("\nSaved results to: $output_path")
return result
end
if abspath(PROGRAM_FILE) == @__FILE__
main()
end