Files
tcad-devsim_triac/gui1d/solve_1d.py
T
pchang718 44b41698e8 feat(gui1d, physics): implement interactive 1D simulator GUI & fix avalanche generation sign bug
- gui1d:
  - Created interactive 1D Diode simulator dashboard (gui1d/app.py, solve_1d.py).
  - Redesigned doping process step editor, layout, and centered legend plots.
  - Implemented state caching for I-V sweeps and grid spacing optimization.
  - Doubled voltage step resolution: 0.05V step for V < 1V, and V/20 step for V >= 1V.
- physics (avalanche bug fix):
  - Fixed charge sign in Hole Continuity Equation for avalanche generation.
  - Created AvalancheGeneration_p in physics/new_physics.py to correctly act as a hole source.
  - Resolved physical breakdown current polarity and negative current leakage at high reverse bias.
2026-06-17 23:08:05 +08:00

509 lines
21 KiB
Python

# gui1d/solve_1d.py
import sys
import os
import numpy as np
import math
# Ensure root directory is in the path to import physics and config modules
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT_DIR not in sys.path:
sys.path.append(ROOT_DIR)
import devsim
from physics.model_create import CreateSolution
from physics.new_physics import (
CreateSiliconPotentialOnly,
CreateSiliconPotentialOnlyContact,
CreateAroraMobilityLF,
CreateHFMobility,
CreateSiliconDriftDiffusion,
CreateSiliconDriftDiffusionContact,
CreateAvalancheGeneration
)
# Vectorized complementary error function
erfc_vec = np.vectorize(math.erfc)
# --- Process Database ---
# Implant Range & Straggle in Silicon (Energy in keV, Rp and dRp in microns)
IMPLANT_DB = {
'Boron': {
'energy': np.array([10.0, 20.0, 30.0, 50.0, 80.0, 100.0, 150.0, 200.0]),
'Rp': np.array([0.04, 0.07, 0.10, 0.16, 0.24, 0.30, 0.42, 0.55]),
'dRp': np.array([0.015, 0.025, 0.035, 0.050, 0.063, 0.070, 0.085, 0.100])
},
'Phosphorus': {
'energy': np.array([10.0, 20.0, 30.0, 50.0, 80.0, 100.0, 150.0, 200.0]),
'Rp': np.array([0.015, 0.028, 0.040, 0.065, 0.100, 0.120, 0.180, 0.240]),
'dRp': np.array([0.007, 0.012, 0.016, 0.025, 0.038, 0.045, 0.060, 0.075])
},
'Arsenic': {
'energy': np.array([10.0, 20.0, 30.0, 50.0, 80.0, 100.0, 150.0, 200.0]),
'Rp': np.array([0.010, 0.018, 0.025, 0.038, 0.058, 0.070, 0.100, 0.130]),
'dRp': np.array([0.004, 0.007, 0.009, 0.014, 0.020, 0.025, 0.035, 0.045])
}
}
# Diffusion Arrhenius parameters in Silicon (D0 in cm^2/s, Ea in eV)
DIFFUSION_DB = {
'Boron': {'D0': 1.0, 'Ea': 3.46},
'Phosphorus': {'D0': 10.5, 'Ea': 3.69},
'Arsenic': {'D0': 0.32, 'Ea': 3.56}
}
def get_implant_params(dopant, energy_kev):
"""Interpolate Rp and dRp from the database for a given energy in keV."""
db = IMPLANT_DB.get(dopant, IMPLANT_DB['Boron'])
e = np.clip(energy_kev, db['energy'][0], db['energy'][-1])
Rp = np.interp(e, db['energy'], db['Rp'])
dRp = np.interp(e, db['energy'], db['dRp'])
return Rp, dRp
def get_diffusion_coefficient(dopant, temp_c):
"""Calculate diffusion coefficient D in cm^2/s at temperature in °C."""
params = DIFFUSION_DB.get(dopant, DIFFUSION_DB['Boron'])
T_kelvin = temp_c + 273.15
k_B = 8.6173e-5 # eV/K
D = params['D0'] * np.exp(-params['Ea'] / (k_B * T_kelvin))
return D
def calc_donors_acceptors(x_um, process_steps, substrate_type, substrate_doping, length):
"""
Calculate the separate Donors and Acceptors concentration arrays (cm^-3)
across a position array x_um (in microns), and also return individual step profiles.
"""
donors = np.zeros_like(x_um)
acceptors = np.zeros_like(x_um)
step_profiles = []
# Initialize substrate baseline
if substrate_type == 'n':
donors += substrate_doping
else:
acceptors += substrate_doping
# Add background contact doping to ensure Ohmic contact at bottom (x = L)
# Contact doping thickness is 0.5 um, peak is 1e19
contact_profile = 1e19 * np.exp(-((length - x_um) / 0.5) ** 2)
if substrate_type == 'n':
donors += contact_profile
else:
acceptors += contact_profile
# We also keep Step 0 Substrate profile in step_profiles
step_profiles.append({
'name': 'Substrate (Step 0)',
'type': substrate_type,
'profile': np.full_like(x_um, substrate_doping)
})
# Compute profiles for each process step
num_steps = len(process_steps)
for i, step in enumerate(process_steps):
if not step.get('enabled', True):
continue
dopant = step.get('dopant', 'Boron')
type_ = step.get('type', 'p')
method = step.get('method', 'Implant')
surface = step.get('surface', 'Top')
# Calculate cumulative Dt (in cm^2) for this step's dopant
Dt_total_cm2 = 0.0
for j in range(i, num_steps):
sub_step = process_steps[j]
if not sub_step.get('enabled', True):
continue
t_temp = sub_step.get('temp', 25.0)
t_time = sub_step.get('time', 0.0) # in minutes
if t_time > 0 and t_temp > 100.0:
D_temp = get_diffusion_coefficient(dopant, t_temp)
Dt_total_cm2 += D_temp * (t_time * 60.0)
# Convert Dt to microns^2
Dt_total_um2 = Dt_total_cm2 * 1e8
# Calculate profile
if method == 'Implant':
energy = step.get('energy', 80.0)
dose = step.get('dose', 1e12)
Rp, dRp = get_implant_params(dopant, energy)
dRp_eff = np.sqrt(dRp ** 2 + 2 * Dt_total_um2)
peak_conc = dose / (np.sqrt(2 * np.pi) * (dRp_eff * 1e-4)) # dose in cm^-2, dRp in cm
if surface == 'Top':
prof = peak_conc * (np.exp(-((x_um - Rp) / (np.sqrt(2) * dRp_eff)) ** 2) +
np.exp(-((x_um + Rp) / (np.sqrt(2) * dRp_eff)) ** 2))
else:
prof = peak_conc * (np.exp(-((x_um - (length - Rp)) / (np.sqrt(2) * dRp_eff)) ** 2) +
np.exp(-((x_um - (length + Rp)) / (np.sqrt(2) * dRp_eff)) ** 2))
else: # Constant Source Predeposition
cs = step.get('cs', 1e19)
if Dt_total_um2 <= 0.0:
Dt_total_um2 = 1e-10
if surface == 'Top':
prof = cs * erfc_vec(x_um / (2 * np.sqrt(Dt_total_um2)))
else:
prof = cs * erfc_vec((length - x_um) / (2 * np.sqrt(Dt_total_um2)))
# Add to Net Donors/Acceptors arrays
if type_ == 'n':
donors += prof
else:
acceptors += prof
# Save this step's final diffused profile
step_profiles.append({
'name': f"Step {i+1}: {dopant} {method}",
'type': type_,
'profile': prof
})
return donors, acceptors, step_profiles
def find_junction_depths(process_steps, substrate_type, substrate_doping, length):
"""Locate depth coordinates in microns where the net doping crosses zero."""
x_test = np.linspace(0, length, 2000)
donors, acceptors, _ = calc_donors_acceptors(x_test, process_steps, substrate_type, substrate_doping, length)
doping_test = donors - acceptors
sign_doping = np.sign(doping_test)
crossings = np.where(sign_doping[:-1] != sign_doping[1:])[0]
junctions = []
for idx in crossings:
x1, x2 = x_test[idx], x_test[idx+1]
y1, y2 = doping_test[idx], doping_test[idx+1]
if abs(y2 - y1) > 1e-20:
x_cross = x1 - y1 * (x2 - x1) / (y2 - y1)
junctions.append(x_cross)
return junctions
def build_and_solve_1d(
bias_target,
substrate_type='n',
substrate_doping=1e14,
length=30.0,
process_steps=[],
enable_avalanche=False,
area_cm2=1.0
):
"""
Builds a 1D Diode mesh, sets up doping, solves from 0V equilibrium
to bias_target using a rapid micro-sweep, and returns physical profiles.
"""
# 1. Reset DEVSIM state to prevent name collisions
devsim.reset_devsim()
um = 1e-4 # 1 micron = 1e-4 cm
device = "device1d"
region = "Silicon"
mesh_name = "mesh1d"
# 2. Build 1D adaptive mesh
# Find junctions to refine the mesh around them
junctions = find_junction_depths(process_steps, substrate_type, substrate_doping, length)
# Define control points (x, target spacing)
control_points = [(0.0, 0.05)]
for j in junctions:
control_points.append((j, 0.02)) # Fine mesh around junctions
control_points.append((length, 0.5)) # Coarser mesh near bottom
# Sort control points
control_points.sort(key=lambda x: x[0])
# Construct node list
all_x = [0.0]
for idx in range(len(control_points) - 1):
x_start, sp_start = control_points[idx]
x_end, sp_end = control_points[idx+1]
seg_len = x_end - x_start
if seg_len <= 0:
continue
curr_x = x_start
while curr_x < x_end:
t = (curr_x - x_start) / seg_len
spacing = sp_start + t * (sp_end - sp_start)
spacing = max(0.01, min(2.0, spacing))
curr_x += spacing
if curr_x < x_end - 0.005:
all_x.append(curr_x)
all_x.append(x_end)
all_x = np.unique(np.array(all_x))
# Create mesh in DEVSIM
devsim.create_1d_mesh(mesh=mesh_name)
for i, x_val in enumerate(all_x):
tag = ""
if i == 0:
tag = "top"
elif i == len(all_x) - 1:
tag = "bottom"
if i == 0:
ps = all_x[1] - all_x[0]
elif i == len(all_x) - 1:
ps = all_x[-1] - all_x[-2]
else:
ps = min(all_x[i] - all_x[i-1], all_x[i+1] - all_x[i])
devsim.add_1d_mesh_line(mesh=mesh_name, pos=x_val * um, ps=ps * um, tag=tag)
devsim.add_1d_contact(mesh=mesh_name, name="top", tag="top", material="metal")
devsim.add_1d_contact(mesh=mesh_name, name="bottom", tag="bottom", material="metal")
devsim.add_1d_region(mesh=mesh_name, region=region, tag1="top", tag2="bottom", material="Silicon")
devsim.finalize_mesh(mesh=mesh_name)
devsim.create_device(mesh=mesh_name, device=device)
# 3. Setup Doping Profile models in DEVSIM
# Calculate separate donors and acceptors on the grid
donors_array, acceptors_array, step_profiles = calc_donors_acceptors(all_x, process_steps, substrate_type, substrate_doping, length)
# Set Donors directly
devsim.node_solution(device=device, region=region, name="Donors_data")
devsim.set_node_values(device=device, region=region, name="Donors_data", values=donors_array)
devsim.node_model(device=device, region=region, name="Donors", equation="Donors_data")
# Set Acceptors directly
devsim.node_solution(device=device, region=region, name="Acceptors_data")
devsim.set_node_values(device=device, region=region, name="Acceptors_data", values=acceptors_array)
devsim.node_model(device=device, region=region, name="Acceptors", equation="Acceptors_data")
# Define NetDoping model
devsim.node_model(device=device, region=region, name="NetDoping", equation="Donors - Acceptors")
devsim.node_model(device=device, region=region, name="x_um", equation=f"x / {um}")
devsim.set_parameter(device=device, name="top_bias", value=0.0)
devsim.set_parameter(device=device, name="bottom_bias", value=0.0)
# 4. Setup Potential-only Equilibrium equations
CreateSolution(device, region, "Potential")
CreateSiliconPotentialOnly(device, region)
CreateSiliconPotentialOnlyContact(device, region, "top")
CreateSiliconPotentialOnlyContact(device, region, "bottom")
# Solve thermal equilibrium (0V)
devsim.solve(type="dc", absolute_error=1.0, relative_error=1e-10, maximum_iterations=100)
# 5. Set up Drift-Diffusion physical models
CreateSolution(device, region, "Electrons")
CreateSolution(device, region, "Holes")
devsim.set_node_values(device=device, region=region, name="Electrons", init_from="IntrinsicElectrons")
devsim.set_node_values(device=device, region=region, name="Holes", init_from="IntrinsicHoles")
# Redefine IntrinsicElectrons, IntrinsicHoles to avoid overflow/underflow
devsim.node_model(device=device, region=region, name="IntrinsicElectrons", equation="Electrons")
devsim.node_model(device=device, region=region, name="IntrinsicElectrons:Potential", equation="0")
devsim.node_model(device=device, region=region, name="IntrinsicElectrons:Electrons", equation="1")
devsim.node_model(device=device, region=region, name="IntrinsicElectrons:Holes", equation="0")
devsim.node_model(device=device, region=region, name="IntrinsicHoles", equation="Holes")
devsim.node_model(device=device, region=region, name="IntrinsicHoles:Potential", equation="0")
devsim.node_model(device=device, region=region, name="IntrinsicHoles:Electrons", equation="0")
devsim.node_model(device=device, region=region, name="IntrinsicHoles:Holes", equation="1")
arora_opts = CreateAroraMobilityLF(device, region)
hf_opts = CreateHFMobility(device, region, **arora_opts)
CreateSiliconDriftDiffusion(device, region, **hf_opts)
CreateSiliconDriftDiffusionContact(device, region, "top", hf_opts['Jn'], hf_opts['Jp'])
CreateSiliconDriftDiffusionContact(device, region, "bottom", hf_opts['Jn'], hf_opts['Jp'])
# Override equations
if enable_avalanche:
CreateAvalancheGeneration(device, region, hf_opts['Jn'], hf_opts['Jp'])
devsim.equation(device=device, region=region, name="ElectronContinuityEquation", variable_name="Electrons",
time_node_model="NCharge", edge_model=hf_opts['Jn'], edge_volume_model="AvalancheGeneration",
variable_update="positive", node_model="ElectronGeneration", min_error=1e5)
devsim.equation(device=device, region=region, name="HoleContinuityEquation", variable_name="Holes",
time_node_model="PCharge", edge_model=hf_opts['Jp'], edge_volume_model="AvalancheGeneration_p",
variable_update="positive", node_model="HoleGeneration", min_error=1e5)
else:
devsim.equation(device=device, region=region, name="ElectronContinuityEquation", variable_name="Electrons",
time_node_model="NCharge", edge_model=hf_opts['Jn'],
variable_update="positive", node_model="ElectronGeneration", min_error=1e5)
devsim.equation(device=device, region=region, name="HoleContinuityEquation", variable_name="Holes",
time_node_model="PCharge", edge_model=hf_opts['Jp'],
variable_update="positive", node_model="HoleGeneration", min_error=1e5)
devsim.equation(device=device, region=region, name="PotentialEquation", variable_name="Potential",
node_model="PotentialNodeCharge", edge_model="DField", variable_update="default", min_error=1e-3)
# Solve 0V Drift-Diffusion
devsim.solve(type="dc", absolute_error=1e10, relative_error=1e-8, charge_error=1e12, maximum_iterations=100)
# Calculate current density at 0V
jn_edge_0 = np.array(devsim.get_edge_model_values(device=device, region=region, name=hf_opts['Jn']))
jp_edge_0 = np.array(devsim.get_edge_model_values(device=device, region=region, name=hf_opts['Jp']))
jtot_avg_0 = -np.mean(jn_edge_0 + jp_edge_0) # Current flows from right to left
v_history = [0.0]
j_history = [jtot_avg_0]
# 6. Adaptive Geometric/Log Micro-Sweep execution to reach bias_target
v_current = 0.0
v_target = bias_target
adaptive_mult = 1.0
while abs(v_current) < abs(v_target):
# Calculate base step size based on current voltage
abs_v = abs(v_current)
if abs_v < 1.0:
step_size = 0.05
else:
step_size = abs_v / 20.0
# For avalanche sweep, if current starts to rise, limit step size to 2V
if enable_avalanche and len(j_history) > 0 and j_history[-1] > 1e-4:
step_size = min(step_size, 2.0)
# Apply adaptive multiplier (from convergence failure backtracking)
step_size *= adaptive_mult
# Halt if step size becomes non-physically tiny
if step_size < 0.005:
print(f"Step size too small ({step_size:.5f} V) at V = {v_current} V, stopping sweep.")
break
# Determine next voltage
v_next = v_current + np.sign(v_target) * step_size
if abs(v_next) > abs(v_target):
v_next = v_target
devsim.set_parameter(device=device, name="top_bias", value=0.0) # Left is reference 0V
devsim.set_parameter(device=device, name="bottom_bias", value=v_next) # Right is biased
try:
devsim.solve(type="dc", absolute_error=1e10, relative_error=1e-5, charge_error=1e12, maximum_iterations=40)
v_current = v_next
# Record current (flowing from right to left, which is -J)
jn_edge = np.array(devsim.get_edge_model_values(device=device, region=region, name=hf_opts['Jn']))
jp_edge = np.array(devsim.get_edge_model_values(device=device, region=region, name=hf_opts['Jp']))
j_val = -np.mean(jn_edge + jp_edge)
v_history.append(v_current)
j_history.append(j_val)
# Check 1mA current limit
current_ma = j_val * area_cm2 * 1000.0
if current_ma > 1.0:
print(f"Current limit 1mA reached at V = {v_current} V ({current_ma:.2f} mA). Stopping sweep.")
break
# If successful, recover the multiplier towards 1.0
adaptive_mult = min(1.0, adaptive_mult * 1.5)
except Exception as e:
print(f"Sweep convergence failure at V = {v_next} V. Backtracking with smaller step size...")
adaptive_mult /= 4.0
# Note: v_current is not updated, so the loop retries from same position with smaller step_size
# 7. Collect output profiles
x_cm = np.array(devsim.get_node_model_values(device=device, region=region, name="x"))
x_plot = x_cm / um # Convert back to um for plotting
potential = np.array(devsim.get_node_model_values(device=device, region=region, name="Potential"))
electrons = np.array(devsim.get_node_model_values(device=device, region=region, name="Electrons"))
holes = np.array(devsim.get_node_model_values(device=device, region=region, name="Holes"))
doping = np.array(devsim.get_node_model_values(device=device, region=region, name="NetDoping"))
efn = np.array(devsim.get_node_model_values(device=device, region=region, name="EFN"))
efp = np.array(devsim.get_node_model_values(device=device, region=region, name="EFP"))
ec = np.array(devsim.get_node_model_values(device=device, region=region, name="EC"))
ev = np.array(devsim.get_node_model_values(device=device, region=region, name="EV"))
efield_edge = np.array(devsim.get_edge_model_values(device=device, region=region, name="EField"))
x_edge_mid = (x_plot[:-1] + x_plot[1:]) / 2.0
jn_edge = np.array(devsim.get_edge_model_values(device=device, region=region, name=hf_opts['Jn']))
jp_edge = np.array(devsim.get_edge_model_values(device=device, region=region, name=hf_opts['Jp']))
jtot_edge = jn_edge + jp_edge
g_av_edge = np.zeros_like(efield_edge)
if enable_avalanche:
try:
g_av_edge = np.array(devsim.get_edge_model_values(device=device, region=region, name="AvalancheGeneration"))
q = 1.6e-19
g_av_edge = np.abs(g_av_edge) / q
except Exception:
pass
# Final current density from right to left
j_avg_rl = -np.mean(jtot_edge)
peak_efield = np.max(np.abs(efield_edge))
is_depleted = np.abs(electrons - holes) < 0.1 * np.abs(doping)
dep_edges = x_plot[is_depleted]
if len(dep_edges) > 0:
w_left = np.min(dep_edges)
w_right = np.max(dep_edges)
depletion_width = w_right - w_left
else:
w_left, w_right, depletion_width = 0.0, 0.0, 0.0
return {
"v_solved": v_current,
"x": x_plot,
"potential": potential,
"electrons": electrons,
"holes": holes,
"doping": doping,
"efn": efn,
"efp": efp,
"ec": ec,
"ev": ev,
"x_edge": x_edge_mid,
"efield": np.abs(efield_edge),
"jn": jn_edge,
"jp": jp_edge,
"jtot": jtot_edge,
"g_av": g_av_edge,
"current_density": j_avg_rl,
"peak_field": peak_efield,
"depletion_width": depletion_width,
"depletion_left": w_left,
"depletion_right": w_right,
# Sweep history
"v_history": v_history,
"j_history": j_history,
"step_profiles": step_profiles
}
if __name__ == "__main__":
import pickle
import sys
if len(sys.argv) < 3:
print("Usage: solve_1d.py <input_pickle_path> <output_pickle_path>")
sys.exit(1)
input_path = sys.argv[1]
output_path = sys.argv[2]
with open(input_path, 'rb') as f:
args = pickle.load(f)
result = build_and_solve_1d(
bias_target=args['bias_target'],
substrate_type=args['substrate_type'],
substrate_doping=args['substrate_doping'],
length=args['length'],
process_steps=args['process_steps'],
enable_avalanche=args['enable_avalanche'],
area_cm2=args['area_cm2']
)
with open(output_path, 'wb') as f:
pickle.dump(result, f)