Skip to content

Fix gauge invariance in electric potential depletion-handling - #634

Open
claudiaalvgar wants to merge 18 commits into
JuliaPhysics:mainfrom
claudiaalvgar:gaugeinv
Open

claudiaalvgar wants to merge 18 commits into
JuliaPhysics:mainfrom
claudiaalvgar:gaugeinv

Conversation

@claudiaalvgar

@claudiaalvgar claudiaalvgar commented Sep 7, 2026 •

Copy link
Copy Markdown
Collaborator

Some issues in the depletion-handling / SOR machinery all shared the same root cause: an implicit, unshiftable "0 V" reference baked into several places that should instead reference the detector's actual applied potentials. Fixed together because they're only fully verifiable together, the r0-axis fix alone still leaves gauge-dependent mismatches near threshold (see table below).

BoundaryConditionsCartesian.jl

The :infinite boundary condition sets edge cells to decay toward literal 0, which is correct only when the lowest applied potential (minimum_applied_potential) equals 0V. Under gauge shifts, this decayed toward wrong values. Since the potential array itself now lives in the gauge_ref_potential-shifted frame (established once at setup, not per-iteration), reconciling it with :infinite's actual target requires a second shift: Δ = minimum_applied_potential - gauge_ref_potential, applied to just the edge margin before the formula runs and undone after: n → n - Δ → f·(n - Δ) → f·(n - Δ) + Δ. This runs every SOR iteration, on narrow edge margins only.

BoundaryConditionsCylindrical.jl

Same fix, applied to the r-axis and z-axis dispatchers for cylindrical grids, for the same reason.

PotentialCalculationSetupCartesian3D.jl / PotentialCalculationSetupCylindrical.jl

Two independent fixes each:

  • Interior seed: Changed from zeros(T, ...) to fill(gauge_ref_potential, ...) — a new struct field, the mean of the applied contact potentials (0 for weighting-potential solves). An earlier version of this fix used fill(minimum_applied_potential, ...) instead, but min isn't antisymmetric under a simultaneous sign flip of bias and impurity density (min and max swap contacts), so two sign-mirrored, gauge-equivalent configurations could still seed inconsistently and converge to different fixed points. gauge_ref_potential (mean) is exactly antisymmetric under that flip, fixing this. minimum_applied_potential is retained separately as :infinite's own decay target (see above) — the two references now serve different purposes and are not the same value.
  • is_weighting_potential guard on contact_potentials (renamed from contact_bias_voltages): Weighting-potential solves use the 0/1 convention (target = 1, rest = 0), not the detector's real bias voltages. Without this guard, minimum_applied_potential/gauge_ref_potential would derive from real (possibly large) bias voltages even during weighting-potential solves, seeding incorrect values.

Also added: estimate_depletion_voltage (Depletion.jl) now combines all filtered depletion-voltage candidates via max/min instead of assuming exactly one via only(...), fixing a crash on detectors with multiple candidates. The "shift back to real units" step for the electric potential now lives inside ElectricPotentialArray (both coordinate systems) rather than duplicated at each Simulation.jl call site.

CPU_innerloop.jl

r0_handling_depletion_handling (Cylindrical) read an unwritten "ghost" neighbour at the r=0 axis that silently defaults to 0, acting as an implicit depletion floor regardless of gauge. Now duplicates the r-right (radially outward) neighbour into that slot instead — a value that moves with the actual solution rather than a fixed external constant.

convergence.jl

Purely diagnostic, no solved values change: labels why the SOR loop exited (:converged / :stagnated / :max_iterations) and warns once (maxlog=1, separate _ids per cause) when stagnation is due to near-threshold depletion bistability vs. hitting floating-point precision. Near-threshold points can genuinely flip classification between iterations, so tight-tolerance convergence is not always reachable, by design.

PointTypes.jl

is_depleted required bulk_bit (all 26 neighbours also pn_junction_bit), which structurally excludes any point one grid-step from a contact — exactly where a shrinking undepleted sliver ends up right before true full depletion. Switched to pn_junction_bit & update_bit, with no such neighbour requirement — the same pair get_active_volume already uses elsewhere in this file for the equivalent question.

Tests

  • test_gauge_invariance.jl (new): Validates fixes against closed-form analytical depletion voltages across ten testsets covering planar slabs, annular coax, and cylindrical geometries with various boundary conditions — including direct pointwise field-comparison checks (epotΔ ≈ epot0 + Δ) under both depletion_handling values, which probe the mean-seed fix directly rather than only through is_depleted.
  • test_depletion.jl: Updated r0_handling_depletion_handling unit test to corrected output. Tolerances were initially loosened to account for observed near-threshold noise, but that noise was subsequently root-caused to BEGe_01.yaml's :infinite boundaries sitting too close to the crystal (a 20mm buffer around a 40mm crystal).
  • test_real_detectors.jl: Inverted Coax's post-depletion margin widened (0.5% → 15%) since the r0-axis fix now correctly detects a small undepleted region near the axis that used to be missed, raising the true depletion voltage. Hexagon's depletion-voltage check replaced with an is_depleted-based bracket: confirmed undepleted at -5.0V and depleted at -11.0V.
  • test_refine_existing_potential.jl: grid-size assertions updated.

Gauge-invariance sweep, Cylindrical (InvertedCoax)

T = Float64 refinement = [0.2, 0.1, 0.05]
:infinite boundary condition

U (V), is_depleted unfixed orig/swap r0-only orig/swap all-fixes orig/swap unfixed match r0-only match all-fixes match
1866.0 false/false false/false false/false ✅ ✅ ✅
1867.0 true/false false/false false/false ❌ ✅ ✅
2014.0 true/false false/false false/false ❌ ✅ ✅
2015.01 true/false false/false true/true ❌ ✅ ✅
2015.76 true/false true/false true/true ❌ ❌ ✅
2016.0 true/false true/false true/true ❌ ❌ ✅
2018.0 true/false false/true true/true ❌ ❌ ✅
2020.0 true/false true/true true/true ❌ ✅ ✅
2022–2050V true/false true/true true/true ❌ ✅ ✅

All-fixes matches ground truth at every single voltage tested, including exactly at and above the true threshold (2015.76V–2018V), where r0-only alone still leaves 3 gauge-dependent mismatches — direct evidence that the :infinite-boundary and interior-seed fixes are independently necessary, not redundant with the r0-axis fix.

U (V), is_depleted all-fixes orig/swap (seed mean, :fixed) all-fixes orig/swap (seed mean, :reflecting)
2012.0 false/false false/false
2014.0 true/true true/true
2016.0 true/true true/true
2018.0 true/true true/true
2020.0 true/true true/true
U (V), is_depleted unfixed orig/swap (:fixed) unfixed orig/swap (:reflecting)
1866.0 false/false false/false
1867.0 false/false true/false
1868.0 false/false true/false
2014.0 true/false true/false
2016.0 true/false true/false

Gauge-invariance sweep, Cartesian (InvertedCoax)

No r0-only column here: Cartesian grids never dispatch through r0_handling_depletion_handling at all, so that fix is structurally irrelevant to this case — only the :infinite-boundary and interior-seed fixes can do anything for Cartesian.
T = Float64 refinement = [0.2, 0.1, 0.05]
:infinite boundary condition

U (V), is_depleted unfixed orig/swap all-fixes orig/swap (seed min) all-fixes orig/swap (seed mean) unfixed match all-fixes match
2025.51 false/false false/false false/false ✅ ✅
2026.0 false/false false/false false/false ✅ ✅
2026.12 true/false true/true false/false ❌ ✅
2028.0 true/false true/true false/false ❌ ✅
2029.0 true/false true/true true/true ❌ ✅

All-fixes matches at every point. Confirms the Cylindrical result generalises: on a coordinate system where the r0 fix contributes nothing at all, the remaining two fixes alone are sufficient to fully restore gauge invariance here.

U (V), is_depleted all-fixes orig/swap (seed mean, :fixed) all-fixes orig/swap (seed mean, :reflecting)
2020.0 false/false false/false
2022.0 false/false false/false
2023.0 false/false false/false
2024.0 false/false true/true
2025.0 true/true true/true

Holding the algorithm fixed and varying only the boundary type shows the near-threshold transition point itself moving by several volts: :reflecting transitions around 2022-2024V, :fixed around 2024-2025V, and :infinite (table above) around 2028-2029V.

U (V), is_depleted unfixed orig/swap (:fixed) unfixed orig/swap (:reflecting)
2020.0 true/false false/false
2022.0 true/false false/false
2023.0 true/false true/false
2024.0 true/false true/true
2025.0 true/false true/true

Open questions / still needs verification

For detectors using only :infinite boundaries, estimate_depletion_voltage's numeric answer is sensitive to how much separation the grid gives the boundary from the crystal. Confirmed directly: enlarging BEGe_01.yaml's grid (20mm→300mm buffer) reduced a comparable instability from ~50V down to under 1V. This is still an open, structural limitation of :infinite (see the Faraday-cage test in test_gauge_invariance.jl) — not something a code fix removes, since the correct open-boundary reference is geometry/capacitance-dependent, not derivable from contact potentials alone. Giving :infinite enough separation mitigates it; nothing eliminates it outright.

@fhagemann

fhagemann commented Sep 7, 2026 •

Copy link
Copy Markdown
Collaborator

Without having had a close look at the code, but looking at the description:

  • How do we find a good V_ref?
    This seems to address gauge-invariance by absolute shifts in the electric potential, but what about if we were to flip the signs of all contact potentials and impurity densities?
    Assume we take the ICPC example detector, flip the sign of all of its bias voltage and convert it from p-type to n-type by flipping also the sign of the impurity density, I would expect to get different values for the depletion voltage (not only in the sign), because V_ref will be set to the mantle contact in one case, and to the point contact in the other case.
flip

Comment on lines +308 to +315
# Seed the interior at minimum_applied_potential, not a hardcoded 0.
# A literal-0 start isn't gauge invariant -- it sits at a different
# point relative to the contacts depending on which one is
# grounded. Since depletion handling's clamp is bistable (issue
# #618), starting two gauge-equivalent solves from
# non-corresponding states can make them converge to different
# fixed points, even with a shift-invariant update rule.
potential = ismissing(potential_array) ? fill(minimum_applied_potential, size(grid)...) : potential_array

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm not sure if this is what we want because minimum is also not invariant with respect to sign-flips.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I fixed: the interior seed now uses gauge_ref_potential = mean(contact potentials), which is odd under sign flip (unlike min), and directly verified in tests. One thing to take into account is: :infinite's own boundary decay ended up staying on min, not mean because mean measurably corrupts the near-boundary field for realistic single-bias-direction detectors (confirmed on the isochrone-style geometry). So :infinite's truncation itself is not sign-flip-invariant but the cryostat test added shows that's an unavoidable property of any boundary approximation

Comment on lines +182 to +183
contact_bias_voltages::Vector{T} = if is_weighting_potential
T[0, 1]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Then, we might want to rename this to contact_potentials (instead of contact_bias_voltages) if we are also using this for weighting potentials.

Comment on lines +119 to +127
# Checks pn_junction_bit & update_bit, not bulk_bit. bulk_bit only
# gets set when ALL 26 neighbors of a point are also pn_junction --
# so any point one grid step from a contact can never count as
# "bulk", even if it's genuinely undepleted. That's exactly where an
# undepleted sliver ends up right before full depletion: as bias
# approaches V_d, the undepleted region shrinks toward whichever
# contact it's pinching off against. So checking bulk_bit made
# is_depleted blind to tese cases.
!any(b -> (pn_junction_bit & b > 0) && (update_bit & b > 0) && (undepleted_bit & b > 0) &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Back in the days (#407), we included the bulk_bit check because we were getting individual grid points very close to the surface that would be wrongly marked as undepleted. How can we avoid those messing up the is_depleted result?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

In theory we could leave it as is as long as the grid size close to the surface is small enough. Sure it may lag the true depletion voltage by a bit, but would be more robust. Maybe we can quantify this effect with a reasonable grid size? I think for explorations of the Li profile and depletion voltage the grid size should be small enough by construction?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm not sure we have to worry about the Li profile, because that part should be covered by the inactive_layer_bit part in the next line. I think this becomes a more relevant issue for coarse grids.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I would also like to see some tests where all signs are swapped (bias voltage, impurity density) to cross-check if the electrical potential is also sign-flipped.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I added new tests for this in the new commits

Comment thread test/test_gauge_invariance.jl Outdated
Comment on lines +1 to +2
using SolidStateDetectors
using Test

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
using SolidStateDetectors
using Test
# This file is a part of SolidStateDetectors.jl, licensed under the MIT License (MIT).
using Test
using SolidStateDetectors

Comment thread test/test_gauge_invariance.jl Outdated
Comment on lines +258 to +259
apply_initial_state!(sim, ElectricPotential, Grid(sim))
calculate_electric_potential!(sim, depletion_handling = false, refinement_limits = refinement_limits, use_nthreads = 4, verbose = false)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Read in test_utils.jl at the beginning and used the timed_ equivalent functions here (and elsewhere in this file).

Comment thread test/test_real_detectors.jl Outdated
# undepleted region near the r=0 axis that used to be missed, which
# raises the true depletion voltage. So the old ~0.5% margin above
# `deplV` is no longer enough -- 15% above `deplV` is confirmed depleted.
sim.detector = SolidStateDetector(sim.detector, contact_id = id, contact_potential = ustrip(deplV * 1.15))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm not sure i like this... In an ideal world, estimate_depletion_voltage should give a good estimate and not something that is 15% off..

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think what's happening is that at deplV*1.005 (the old 0.5% margin), the detector is not depleted with the current fixes. This isn't a numerical issue in estimate_depletion_voltage, it's that deplV is an approximate linear estimate, while the true depletion threshold with depletion_handling on sits measurably higher once r0_handling correctly catches a previously-missed undepleted sliver near the axis

Comment thread test/test_real_detectors.jl Outdated
@info signalsum
@test isapprox( signalsum, T(2), atol = 5e-3 )
@test isapprox(timed_estimate_depletion_voltage(sim, verbose = false, check_for_depletion = false), T(-13.15)*u"V", atol = 1.0u"V")
# estimate_depletion_voltage's only(...) call assumes a single filtered

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why are we getting two candidates?
I can see this happening if there were one positive and one negative candidate, but for two negative candidates, we should always return the more extreme one..

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should be fixed with #623, but I would still be interested why the changes in this PR result in two candidates instead of just one. Is this related to removing the bulk_bit check from is_depleted?

Comment thread test/test_depletion.jl Outdated
Comment on lines +65 to +83
# `dep_target` is matched analytically, via superposition, no solve, so
# no numerical noise. `dep_sim`, on the other hand, comes from a fresh SOR
# solve near the depletion threshold, which does carry the near-threshold
# stagnation noise described above. So the entire gap between the two
# numbers shows up in this comparison.
@test abs(dep_sim - dep_target) < 75u"V"
@test sim.detector.semiconductor.impurity_density_model != imp_model_before

# Re-run simulation in place and check depletion voltage matches again. This is a check that impurity_density_model and
# contact_potential where adapted correctly
# contact_potential where adapted correctly.
timed_calculate_electric_potential!(sim, refinement_limits = 0.01, depletion_handling = true)
@test abs(estimate_depletion_voltage(sim, check_for_depletion = false, verbose = false) - dep_sim) < 5u"V"
@test abs(estimate_depletion_voltage(sim, check_for_depletion = false, verbose = false) - dep_sim) < 75u"V"

# Finally, compare to fresh simulation which is changed manually
sim_fresh = Simulation{T}(joinpath(@__DIR__, "test_config_files/BEGe_01.yaml"))
sim_fresh.detector = SolidStateDetector(sim_fresh.detector, contact_id = id, contact_potential = bias_target)
sim_fresh.detector = SolidStateDetector(sim_fresh.detector, sim.detector.semiconductor.impurity_density_model)
timed_calculate_electric_potential!(sim_fresh, refinement_limits = 0.01, depletion_handling = true)
@test abs(estimate_depletion_voltage(sim_fresh, check_for_depletion = false, verbose = false) - dep_sim) < 5u"V"
@test abs(estimate_depletion_voltage(sim_fresh, check_for_depletion = false, verbose = false) - dep_sim) < 75u"V"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm also not sure I like the fact that the values are now 75V apart instead of 5V..

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I don't like this either, that's why I wanted to keep this as a draft. For Cartesian, unfixed code's gauges agree again above ~2030V too — not sure if that's a fluke or expected (agreement at high bias may just mean we've passed both gauges' thresholds, regardless of correctness — same pattern as r0-only Cylindrical above 2020V). I'll dig into the rest so we can reach a conclusion of whether this is a change we want to have or not

@fhagemann

Copy link
Copy Markdown
Collaborator

Thanks!
Maybe we can also find an analytical example that's a bit more complicated than the infinite plate capacitor or inifinite coaxial detector, that might allow us to test what's actually correct for pinch-off or close-to-contact undepletion, to be included in the decision which solution we want to go for.

ϕmax - ϕmin < abs(U) ? Umin = U : Umax = U
end
end
bulk = findall((sim.point_types.data .& bulk_bit .> 0) .& (sim.point_types.data .& inactive_layer_bit .== 0))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

estimate_depletion_voltage still seems to be restricted to bulk_bits, whereas this restriction was removed in is_depleted.
Could this be why the results of estimate_depletion_voltage and manual is_depleted checks used to be only ~10V apart and are now 75V apart.

Can we adjust estimate_depletion_voltage to give more realistic estimates, e.g. by dealing differently with bulk_bits?

@claudiaalvgar claudiaalvgar Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I checked this and I think this is not the issue. BEGe_01 :infinite boundaries only had a 20mm buffer beyond the 40mm crystal. The Faraday-cage/sign-flip tests in test_gauge_invariance.jl show that :infinite's truncation-edge behaviour is only accurate once it has real separation from the region of interest. So the fix in my commits was to give BEGe_01.yaml the separation :infinite needs and all four comparisons now pass at 10V

Comment thread src/Simulation/Simulation.jl Outdated
Comment on lines +505 to +506
# pcs.potential lives in the V_ref = 0 (gauge-shifted) frame; shift back to real potentials once, here.
sim.electric_potential = ElectricPotential(ElectricPotentialArray(pcs) .+ pcs.gauge_ref_potential, grid)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe this is something to add in ElectricPotentialArray instead of here?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

function ElectricPotentialArray(pcs::PotentialCalculationSetup{T, Cylindrical, 3, Array{T, 3}})::Array{T, 3} where {T}
    pot::Array{T, 3} = Array{T, 3}(undef, size(pcs.grid))
    for iz in axes(pot, 3)
        irbz::Int = rbidx(iz)
        for iφ in axes(pot, 2)
            irbφ::Int = iφ + 1
            idxsum::Int = iz + iφ
            for ir in axes(pot, 1)
                irbr::Int = ir + 1
                rbi::Int = iseven(idxsum + ir) ? rb_even::Int : rb_odd::Int
                pot[ir, iφ, iz] = pcs.potential[ irbz, irbφ, irbr, rbi ]
            end
        end
    end
    for iz in axes(pot, 3)
        p_r0 = mean(pot[1,:,iz])
        pot[1,:,iz] .= p_r0
    end
    # pcs.potential lives in the V_ref = 0 (gauge-shifted) frame; shift back to real potentials once, here.
    return pot .+ pcs.gauge_ref_potential
end

@claudiaalvgar
claudiaalvgar marked this pull request as ready for review September 16, 2026 14:15

@fhagemann fhagemann left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Commenting on the changes on the tests first, to make sure that this is what we want.
We might also want to include the findings in the new PR #639 here.

Comment thread test/test_real_detectors.jl Outdated
Comment on lines +40 to +41
# `deplV` is no longer enough -- 15% above `deplV` is confirmed depleted.
sim.detector = SolidStateDetector(sim.detector, contact_id = id, contact_potential = ustrip(deplV * 1.15))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we somehow address to make estimate_depletion_voltage better in this sense? 15% tolerance is pretty bad in my opinion..

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@claudiaalvgar could you check if with the updates to estimate_depletion_voltage in PR #639, the two methods agree again (and we don't have to increase the value from estimate_depletion_voltage by 15% to make this work).

Comment thread test/test_real_detectors.jl Outdated
Comment on lines +158 to +166
@test (timed_estimate_depletion_voltage(sim, verbose = false, check_for_depletion = false); true)
id = SolidStateDetectors.determine_bias_voltage_contact_id(sim.detector)
sim.detector = SolidStateDetector(sim.detector, contact_id = id, contact_potential = T(-5.0))
timed_calculate_electric_potential!(sim, depletion_handling = true)
@test !is_depleted(sim.point_types)

sim.detector = SolidStateDetector(sim.detector, contact_id = id, contact_potential = T(-11.0))
timed_calculate_electric_potential!(sim, depletion_handling = true)
@test is_depleted(sim.point_types)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Also, what used to be a depletion voltage of -13.15V is now between -5V and -11V. Do we understand this, and does this make sense?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

-13.15V came from the previous code, where :infinite always decayed toward a hardcoded 0 on both the real solve and the weighting-potential solve used in estimate_depletion_voltage so no mismatch. This PR's gauge-invariance change makes the real solve's :infinite decay target depend on the bias sign, while the weighting potential's stays fixed at 0. For this detector's negative bias, that mismatch corrupts estimate_depletion_voltage to ≈-70 to -75V (in both Float32 and Float64). :reflecting's boundary formula is a plain mirror-copy of the neighbouring grid cell with no reference-potential term at all, so running this same code with :reflecting instead gives ≈-12.16V/-12.19V, close to the old -13.15V

Comment thread test/test_config_files/BEGe_01.yaml Outdated
Comment on lines +11 to +21
@@ -17,8 +17,8 @@ grid:
left: periodic
right: periodic
z:
from: -20
to: 60
from: -300
to: 300

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I know that these changes were done to move the :infinite boundary further away from the crystal. But is there any way that we can also get a reasonable boundary condition without having to extend the world this much?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The enlargement here is compensating for the same weighting-potential mismatch inside adjust_impurity_and_electric_potential_to_match_depletion!/adjust_bias_and_electric_potential!, which pushes dep_sim ≈30V off target on that small world. For now, the validated fix could be using :reflecting : no reference-potential term, so it can't develop this mismatch

@claudiaalvgar

Copy link
Copy Markdown
Collaborator Author

I ran the combined PR634+639 (julia version 1.11.5): on the real-detector config, Float32 now gives 2030.11 V against Float64's 2030.006 V — so PR639's fix holds fully when combined with PR634, and the old 15% safety margin isn't needed anymore; on the BEGe config, though, the U_est/U_alt comparison in test_depletion.jl is unmoved by adding PR639 — both still fail the <5V check, but this failure would also be true if we don't consider PR634 nor PR639 and use Float64 instead of Float32. I put the results on the table attached for future reference
Screenshot 2026-09-17 at 14 58 27

@claudiaalvgar

Copy link
Copy Markdown
Collaborator Author

Update: I rebased this branch since the changes were needed for these fixes to apply cleanly. PR634 added gauge_ref_potential (a mean-based SOR interior seed) for gauge-consistent convergence, but before the last commits, left the :infinite boundary itself decaying toward minimum_applied_potential unconditionally — a reference that's exactly translation-invariant but sign-convention dependent, so for detectors where the swept contact happens to be negative (BEGe_01, HexagonalPrism) the boundary decays toward the wrong value and the depletion-voltage estimate becomes unusable (tens of volts off, or a failed bisection); enlarging the :infinite world was tried first since it also masks the symptom (pushing the truncation edge far enough away dilutes the wrong reference's error), but it doesn't fix the actual cause, only hides it for whatever world size happens to be tested, and would have to be re-applied to every current and future detector config hitting this pattern rather than being fixed once. With the new commits, boundary_ref_potential decays toward 0 whenever exactly one contact is non-zero, the physically correct grounded reference, matching the previous convention — and falls back to the old minimum_applied_potential otherwise, so nothing regresses where that convention doesn't hold. The one cost, specific to the :infinite boundary, is that this value-based rule can't be made exactly translation-invariant under an artificial relabeling shift (Δ = -U in test_gauge_invariance.jl, which swaps which contact reads 0).
Together with gauge_ref_potential, which stops the nonlinear depletion clamp from landing on a different answer for what should be the same physical setup, no matter what boundary type is used, PR634 as a whole now gives correct, consistent depletion-voltage estimates for detectors that used to either fail outright or quietly return the wrong number

@claudiaalvgar

claudiaalvgar commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Update: explicit potential of the surroundings for :infinite and :fixed

The rule from my last update (:infinite decays towards 0 if exactly one contact is non-zero, otherwise towards minimum_applied_potential) was still a guess about where ground is, and it regressed in two ways:

  • Several biased contacts or no grounded contact, e.g. (−1000, 0, +1000) or (−1500, +1500): the fallback put the surroundings at the negative bias. main hardcodes 0 V and gets these right.
  • :fixed never writes the outer (ghost) layer of the red-black array, so it keeps its initial value.

Changes

  • New keyword surroundings_potential for calculate_electric_potential! and simulate!: the potential that :infinite decays towards and that :fixed holds. The default is 0 V, identical to main. It is stored in sim.world and reused by later re-solves.
  • The :fixed ghost layer is set to this value. The mean stays only as the SOR starting guess.
  • Docs: a new manual page "Potential of the Surroundings". The fixed text in grids.md said ϕ(x0) = ϕ(x1), but :fixed never writes the outer points, which stay at 0 in main; the text now describes that. Behaviour with the default is unchanged.

Results (InvertedCoax, orig/swap mismatches in bold)

  • orig: point = 0 V, mantle = U, surroundings = 0 V
  • "this PR", full shift: point = −U, mantle = 0 V, surroundings = −U (the same setup, all voltages shifted by −U)
  • "Current SSD" (main at f6f639cc), contacts only: point = −U, mantle = 0 V, surroundings = 0 V (main can only shift the contacts)

Cylindrical (boundary on r_max, z_min, z_max)

U (V) orig/swap :fixed Current SSD orig/swap :fixed this PR orig/swap :reflecting Current SSD orig/swap :reflecting this PR orig/swap :infinite Current SSD orig/swap :infinite this PR
contacts only full shift contacts only full shift contacts only full shift
2012 true/false true/true false/false false/false false/false false/false
2014 true/false true/true false/true true/true false/false false/false
2016 true/false true/true false/true true/true true/false true/true
2018 true/false true/true false/true true/true false/true true/true
2020 true/true true/true true/true true/true true/true true/true
2022 true/true true/true true/true true/true true/true true/true

Cartesian (InvertedCoax on an 80 × 80 × 100 mm box, boundary on all six faces)

U (V) orig/swap :fixed Current SSD orig/swap :fixed this PR orig/swap :reflecting Current SSD orig/swap :reflecting this PR orig/swap :infinite Current SSD orig/swap :infinite this PR
contacts only full shift contacts only full shift contacts only full shift
2022 true/false true/true false/false false/false false/false false/false
2023 true/false true/true true/false false/false false/false false/false
2024 true/false true/true true/true true/true false/false false/false
2025 true/false true/true true/true true/true false/false false/false
2026 true/false true/true true/true true/true false/false false/false
2028 true/false true/true true/true true/true true/false false/false
2028.5 true/false true/true true/true true/true true/false true/true
2029 true/false true/true true/true true/true true/false true/true
2030 true/false true/true true/true true/true true/true true/true
2032 true/true true/true true/true true/true true/true true/true

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants