993 lines
42 KiB
Python
993 lines
42 KiB
Python
# gui1d/app.py
|
||
import streamlit as st
|
||
import json
|
||
import numpy as np
|
||
import plotly.graph_objects as go
|
||
from plotly.subplots import make_subplots
|
||
import os
|
||
import sys
|
||
import threading
|
||
|
||
# Ensure root directory is in the path to import backend
|
||
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||
if ROOT_DIR not in sys.path:
|
||
sys.path.append(ROOT_DIR)
|
||
|
||
# from gui1d.solve_1d import build_and_solve_1d
|
||
import subprocess
|
||
import pickle
|
||
|
||
def build_and_solve_1d(bias_target, substrate_type, substrate_doping, length, process_steps, enable_avalanche, enable_btbt, area_cm2):
|
||
# Run the simulation in a separate python process to ensure thread-safety and prevent DEVSIM C++ segment faults
|
||
temp_dir = os.path.join(ROOT_DIR, "gui1d", "temp_runs")
|
||
os.makedirs(temp_dir, exist_ok=True)
|
||
|
||
thread_id = threading.get_ident()
|
||
in_file = os.path.join(temp_dir, f"in_{thread_id}.pkl")
|
||
out_file = os.path.join(temp_dir, f"out_{thread_id}.pkl")
|
||
|
||
args = {
|
||
'bias_target': bias_target,
|
||
'substrate_type': substrate_type,
|
||
'substrate_doping': substrate_doping,
|
||
'length': length,
|
||
'process_steps': process_steps,
|
||
'enable_avalanche': enable_avalanche,
|
||
'enable_btbt': enable_btbt,
|
||
'area_cm2': area_cm2
|
||
}
|
||
|
||
try:
|
||
with open(in_file, 'wb') as f:
|
||
pickle.dump(args, f)
|
||
|
||
python_bin = sys.executable # Runs the same virtualenv python
|
||
cmd = [python_bin, os.path.join(ROOT_DIR, "gui1d", "solve_1d.py"), in_file, out_file]
|
||
|
||
res_proc = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||
|
||
with open(out_file, 'rb') as f:
|
||
result = pickle.load(f)
|
||
|
||
return result
|
||
except subprocess.CalledProcessError as e:
|
||
err_msg = str(e)
|
||
if e.stderr:
|
||
err_msg += "\n" + e.stderr
|
||
raise RuntimeError(f"Simulation process failed: {err_msg}")
|
||
except Exception as e:
|
||
raise RuntimeError(f"Simulation process failed: {str(e)}")
|
||
finally:
|
||
for fpath in (in_file, out_file):
|
||
if os.path.exists(fpath):
|
||
try:
|
||
os.remove(fpath)
|
||
except Exception:
|
||
pass
|
||
|
||
# Global lock to prevent concurrent thread execution in DEVSIM
|
||
DEVSIM_LOCK = threading.Lock()
|
||
|
||
# Helper to render label and input on the same row, hide default label
|
||
def label_input_row(label_text, widget_type, key, ratio=[5, 5], **kwargs):
|
||
col_lbl, col_val = st.columns(ratio, gap="small")
|
||
col_lbl.markdown(f"<div style='padding-top: 6px; font-weight: 500; font-size: 0.8rem; line-height: 1.2; color: #8a99ad;'>{label_text}</div>", unsafe_allow_html=True)
|
||
if widget_type == "selectbox":
|
||
if key in st.session_state and "options" in kwargs:
|
||
val = st.session_state[key]
|
||
if val in kwargs["options"]:
|
||
kwargs["index"] = kwargs["options"].index(val)
|
||
return col_val.selectbox("", label_visibility="collapsed", key=key, **kwargs)
|
||
elif widget_type == "number_input":
|
||
if key in st.session_state:
|
||
kwargs["value"] = st.session_state[key]
|
||
return col_val.number_input("", label_visibility="collapsed", key=key, **kwargs)
|
||
elif widget_type == "slider":
|
||
if key in st.session_state:
|
||
kwargs["value"] = st.session_state[key]
|
||
return col_val.slider("", label_visibility="collapsed", key=key, **kwargs)
|
||
elif widget_type == "toggle":
|
||
if key in st.session_state:
|
||
kwargs["value"] = st.session_state[key]
|
||
return col_val.toggle("", label_visibility="collapsed", key=key, **kwargs)
|
||
|
||
# --- 1. Streamlit Page Setup ---
|
||
st.set_page_config(
|
||
page_title="Interactive 1D Semiconductor Simulator",
|
||
layout="wide",
|
||
initial_sidebar_state="expanded"
|
||
)
|
||
|
||
# --- 2. Custom Modern CSS (Premium Dark Theme & Typography) ---
|
||
st.markdown(
|
||
"""
|
||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&family=JetBrains+Mono:wght@300;400&display=swap" rel="stylesheet">
|
||
<style>
|
||
/* Global CSS Overrides */
|
||
html, body, [class*="css"] {
|
||
font-family: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif;
|
||
background-color: #0e1117;
|
||
color: #fafafa;
|
||
}
|
||
h1, h2, h3, h4, h5, h6 {
|
||
font-family: 'Outfit', sans-serif;
|
||
font-weight: 600;
|
||
letter-spacing: -0.5px;
|
||
}
|
||
|
||
/* Metric Card Styling */
|
||
.metric-container {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 1.5rem;
|
||
margin-bottom: 2rem;
|
||
}
|
||
.metric-card {
|
||
flex: 1;
|
||
background: linear-gradient(135deg, #171d26 0%, #1e2633 100%);
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
border-radius: 16px;
|
||
padding: 1.5rem;
|
||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||
text-align: center;
|
||
transition: all 0.3s ease;
|
||
}
|
||
.metric-card:hover {
|
||
transform: translateY(-4px);
|
||
border-color: rgba(0, 200, 255, 0.4);
|
||
box-shadow: 0 12px 40px 0 rgba(0, 200, 255, 0.15);
|
||
}
|
||
.metric-label {
|
||
font-size: 0.85rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1.5px;
|
||
color: #8a99ad;
|
||
margin-bottom: 0.5rem;
|
||
font-weight: 400;
|
||
}
|
||
.metric-value {
|
||
font-size: 2rem;
|
||
font-weight: 800;
|
||
color: #00d2ff;
|
||
font-family: 'Outfit', sans-serif;
|
||
}
|
||
.metric-sub {
|
||
font-size: 0.8rem;
|
||
color: #627285;
|
||
margin-top: 0.25rem;
|
||
}
|
||
|
||
/* Sidebar Styling */
|
||
section[data-testid="stSidebar"] {
|
||
background-color: #090d13 !important;
|
||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
/* Code font styling */
|
||
code, pre {
|
||
font-family: 'JetBrains Mono', monospace !important;
|
||
}
|
||
|
||
/* --- Compact Sidebar Style Overrides --- */
|
||
[data-testid="stSidebarUserContent"] {
|
||
padding-top: 1rem !important;
|
||
padding-left: 0.8rem !important;
|
||
padding-right: 0.8rem !important;
|
||
}
|
||
|
||
[data-testid="stSidebar"] [data-testid="stVerticalBlock"] {
|
||
gap: 6px !important;
|
||
}
|
||
|
||
[data-testid="stSidebar"] .element-container {
|
||
margin-bottom: 6px !important;
|
||
padding: 0px !important;
|
||
}
|
||
|
||
[data-testid="stSidebar"] hr {
|
||
margin-top: 10px !important;
|
||
margin-bottom: 10px !important;
|
||
}
|
||
|
||
[data-testid="stSidebar"] [data-testid="stHorizontalBlock"] {
|
||
gap: 8px !important;
|
||
}
|
||
|
||
[data-testid="stSidebar"] [data-testid="column"] {
|
||
padding-left: 0px !important;
|
||
padding-right: 0px !important;
|
||
}
|
||
|
||
/* Expander compacting */
|
||
[data-testid="stSidebar"] [data-testid="stExpander"] [data-testid="stVerticalBlock"] {
|
||
padding-left: 4px !important;
|
||
padding-right: 4px !important;
|
||
gap: 4px !important;
|
||
}
|
||
[data-testid="stSidebar"] [data-testid="stExpander"] {
|
||
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||
background-color: transparent !important;
|
||
margin-top: 4px !important;
|
||
margin-bottom: 4px !important;
|
||
}
|
||
[data-testid="stSidebar"] [data-testid="stExpander"] summary {
|
||
padding: 6px 10px !important;
|
||
}
|
||
|
||
[data-testid="stSidebar"] [data-testid="stNumberInput"],
|
||
[data-testid="stSidebar"] [data-testid="stSelectbox"],
|
||
[data-testid="stSidebar"] [data-testid="stSlider"],
|
||
[data-testid="stSidebar"] [data-testid="stToggle"] {
|
||
margin-top: 0px !important;
|
||
margin-bottom: 0px !important;
|
||
padding-top: 0px !important;
|
||
padding-bottom: 0px !important;
|
||
}
|
||
|
||
[data-testid="stSidebar"] div[data-baseweb="select"] {
|
||
height: 32px !important;
|
||
min-height: 32px !important;
|
||
}
|
||
[data-testid="stSidebar"] div[data-baseweb="select"] > div {
|
||
height: 32px !important;
|
||
min-height: 32px !important;
|
||
display: flex !important;
|
||
align-items: center !important;
|
||
}
|
||
[data-testid="stSidebar"] div[data-baseweb="input"] {
|
||
height: 32px !important;
|
||
min-height: 32px !important;
|
||
}
|
||
[data-testid="stSidebar"] input {
|
||
height: 32px !important;
|
||
padding-top: 0px !important;
|
||
padding-bottom: 0px !important;
|
||
padding-left: 8px !important;
|
||
padding-right: 8px !important;
|
||
font-size: 0.85rem !important;
|
||
}
|
||
[data-testid="stSidebar"] div[data-baseweb="select"] [data-testid="stSelectboxSelectedValue"] {
|
||
font-size: 0.85rem !important;
|
||
display: flex !important;
|
||
align-items: center !important;
|
||
height: 100% !important;
|
||
}
|
||
|
||
[data-testid="stSidebar"] button {
|
||
padding-top: 0px !important;
|
||
padding-bottom: 0px !important;
|
||
min-height: 28px !important;
|
||
height: 28px !important;
|
||
line-height: 28px !important;
|
||
font-size: 0.8rem !important;
|
||
margin: 0px !important;
|
||
}
|
||
|
||
[data-testid="stSidebar"] [data-testid="stCheckbox"] {
|
||
padding-top: 0px !important;
|
||
padding-bottom: 0px !important;
|
||
margin-top: 2px !important;
|
||
margin-bottom: 2px !important;
|
||
}
|
||
[data-testid="stSidebar"] [data-testid="stCheckbox"] label {
|
||
font-size: 0.85rem !important;
|
||
padding-top: 2px !important;
|
||
}
|
||
|
||
[data-testid="stSidebar"] [data-testid="stToggle"] {
|
||
padding-top: 2px !important;
|
||
padding-bottom: 2px !important;
|
||
}
|
||
[data-testid="stSidebar"] [data-testid="stToggle"] label {
|
||
font-size: 0.85rem !important;
|
||
}
|
||
|
||
[data-testid="stSidebar"] [data-testid="stSlider"] {
|
||
padding-top: 0px !important;
|
||
padding-bottom: 0px !important;
|
||
height: 32px !important;
|
||
}
|
||
[data-testid="stSidebar"] [data-testid="stSlider"] > div {
|
||
padding-top: 0px !important;
|
||
padding-bottom: 0px !important;
|
||
}
|
||
|
||
[data-testid="stSidebar"] input[type="number"] {
|
||
-moz-appearance: textfield !important;
|
||
}
|
||
[data-testid="stSidebar"] input[type="number"]::-webkit-outer-spin-button,
|
||
[data-testid="stSidebar"] input[type="number"]::-webkit-inner-spin-button {
|
||
-webkit-appearance: none !important;
|
||
margin: 0 !important;
|
||
}
|
||
|
||
button[data-testid="stNumberInputStepUp"],
|
||
button[data-testid="stNumberInputStepDown"] {
|
||
display: none !important;
|
||
}
|
||
</style>
|
||
""",
|
||
unsafe_allow_html=True
|
||
)
|
||
|
||
# --- 3. Sidebar Container ---
|
||
with st.sidebar:
|
||
# --- 3. Sidebar Header ---
|
||
st.markdown(
|
||
"""
|
||
<div style="text-align: center; margin-bottom: 1rem;">
|
||
<h1 style="color: #00d2ff; font-size: 1.8rem; font-weight: 800; margin-bottom: 0.2rem;">sim1d</h1>
|
||
<p style="color: #627285; font-size: 0.85rem; letter-spacing: 1px; text-transform: uppercase;">1D TCAD Process & Device</p>
|
||
</div>
|
||
""",
|
||
unsafe_allow_html=True
|
||
)
|
||
|
||
with st.expander("💾 Config File (Save/Retrieve)", expanded=False):
|
||
st.markdown("<div style='font-size: 0.8rem; color: #8a99ad; margin-bottom: 0.3rem;'>Retrieve saved configuration file (.json):</div>", unsafe_allow_html=True)
|
||
uploaded_file = st.file_uploader("Retrieve Config JSON", type=["json"], label_visibility="collapsed")
|
||
if uploaded_file is not None:
|
||
try:
|
||
import hashlib
|
||
file_bytes = uploaded_file.getvalue()
|
||
file_hash = hashlib.md5(file_bytes).hexdigest()
|
||
|
||
if st.session_state.get('last_loaded_config_hash') != file_hash:
|
||
config = json.loads(file_bytes.decode('utf-8'))
|
||
|
||
# Clear process step widget keys to force Streamlit to refresh widgets with new config values
|
||
keys_to_clear = [k for k in st.session_state.keys() if k.startswith("step_") or k.startswith("delete_confirm_")]
|
||
for k in keys_to_clear:
|
||
del st.session_state[k]
|
||
|
||
if 'sub_type' in config:
|
||
st.session_state['sub_type'] = config['sub_type']
|
||
if 'sub_doping' in config:
|
||
st.session_state['sub_doping'] = float(config['sub_doping'])
|
||
if 'sub_length' in config:
|
||
st.session_state['sub_length'] = float(config['sub_length'])
|
||
if 'device_area' in config:
|
||
st.session_state['device_area'] = float(config['device_area'])
|
||
if 'process_steps' in config:
|
||
st.session_state['process_steps'] = config['process_steps']
|
||
# Explicitly set widget keys in session state to force Streamlit inputs to update and bypass fallback caching bugs
|
||
for idx, step in enumerate(config['process_steps']):
|
||
st.session_state[f"step_{idx}_enabled"] = bool(step.get('enabled', True))
|
||
st.session_state[f"step_{idx}_type"] = step.get('type', 'p')
|
||
st.session_state[f"step_{idx}_dopant"] = step.get('dopant', 'Boron')
|
||
st.session_state[f"step_{idx}_method"] = step.get('method', 'Implant')
|
||
st.session_state[f"step_{idx}_surface"] = step.get('surface', 'Top')
|
||
st.session_state[f"step_{idx}_energy"] = float(step.get('energy', 80.0))
|
||
st.session_state[f"step_{idx}_dose"] = float(step.get('dose', 1e12))
|
||
st.session_state[f"step_{idx}_cs"] = float(step.get('cs', 1e19))
|
||
st.session_state[f"step_{idx}_temp"] = float(step.get('temp', 1000.0))
|
||
st.session_state[f"step_{idx}_time"] = float(step.get('time', 60.0))
|
||
st.session_state[f"step_{idx}_thickness"] = float(step.get('thickness', 10.0))
|
||
if 'bias_slider_val' in config:
|
||
st.session_state['bias_slider_val'] = float(config['bias_slider_val'])
|
||
if 'enable_avalanche_toggle_val' in config:
|
||
st.session_state['enable_avalanche_toggle_val'] = bool(config['enable_avalanche_toggle_val'])
|
||
if 'enable_btbt_toggle_val' in config:
|
||
st.session_state['enable_btbt_toggle_val'] = bool(config['enable_btbt_toggle_val'])
|
||
if 'run_full_sweep_toggle_val' in config:
|
||
st.session_state['run_full_sweep_toggle_val'] = bool(config['run_full_sweep_toggle_val'])
|
||
if 'plot_doping_xmax' in config:
|
||
st.session_state['plot_doping_xmax'] = float(config['plot_doping_xmax'])
|
||
if 'plot_electro_xmax' in config:
|
||
st.session_state['plot_electro_xmax'] = float(config['plot_electro_xmax'])
|
||
if 'sweep_v_max' in config:
|
||
st.session_state['sweep_v_max'] = float(config['sweep_v_max'])
|
||
|
||
st.session_state['last_loaded_config_hash'] = file_hash
|
||
st.success("Config retrieved successfully!")
|
||
st.rerun()
|
||
except Exception as e:
|
||
st.error(f"Error: {e}")
|
||
else:
|
||
if 'last_loaded_config_hash' in st.session_state:
|
||
st.session_state['last_loaded_config_hash'] = None
|
||
|
||
# Create an empty container for the download button, to be populated at the bottom of the sidebar.
|
||
download_btn_container = st.container()
|
||
|
||
with st.expander("📈 Plot Viewport 繪圖視角", expanded=False):
|
||
plot_doping_xmax = label_input_row(
|
||
"Doping X-Max (μm)", "number_input", "plot_doping_xmax",
|
||
ratio=[7, 3],
|
||
min_value=0.1, max_value=500.0, value=5.0, step=0.5,
|
||
help="Doping Profile X-axis maximum range (μm)."
|
||
)
|
||
plot_electro_xmax = label_input_row(
|
||
"Electro X-Max (μm)", "number_input", "plot_electro_xmax",
|
||
ratio=[7, 3],
|
||
min_value=0.1, max_value=500.0, value=5.0, step=0.5,
|
||
help="Electrostatic Profile X-axis maximum range (μm)."
|
||
)
|
||
sweep_v_max = label_input_row(
|
||
"Sweep Max (V)", "number_input", "sweep_v_max",
|
||
ratio=[7, 3],
|
||
min_value=1.0, max_value=1000.0, value=1000.0, step=10.0,
|
||
help="Sweep target voltage (e.g. 1000V for high-voltage, 15V for Zener)."
|
||
)
|
||
|
||
st.markdown("### ⚡ Bias & Physics")
|
||
sweep_max_v = float(st.session_state.get('sweep_v_max', 1000.0))
|
||
if st.session_state.get('bias_slider_val', 5.0) > sweep_max_v:
|
||
st.session_state['bias_slider_val'] = sweep_max_v
|
||
|
||
bias = label_input_row(
|
||
"Bias Vbias (V)", "slider", "bias_slider_val",
|
||
ratio=[4.5, 5.5],
|
||
min_value=0.0,
|
||
max_value=sweep_max_v,
|
||
value=min(5.0, sweep_max_v),
|
||
step=0.1 if sweep_max_v <= 20.0 else 1.0,
|
||
help="Applied at bottom contact (x = L). Top contact (x = 0) is reference 0V."
|
||
)
|
||
|
||
enable_avalanche = label_input_row(
|
||
"Avalanche (Impact)", "toggle", "enable_avalanche_toggle_val",
|
||
ratio=[6.5, 3.5],
|
||
value=False,
|
||
help="Enable impact ionization for the selected voltage simulation."
|
||
)
|
||
|
||
enable_btbt = label_input_row(
|
||
"BTBT (Tunneling)", "toggle", "enable_btbt_toggle_val",
|
||
ratio=[6.5, 3.5],
|
||
value=False,
|
||
help="Enable band-to-band tunneling for the selected voltage simulation."
|
||
)
|
||
|
||
run_full_sweep = label_input_row(
|
||
f"Run {sweep_max_v:.0f}V Sweep", "toggle", "run_full_sweep_toggle_val",
|
||
ratio=[6.5, 3.5],
|
||
value=True,
|
||
help=f"Run the full 0~{sweep_max_v:.0f}V sweep with and without avalanche. Turn off for faster parameter tuning."
|
||
)
|
||
|
||
st.markdown("---")
|
||
st.markdown("### 🎛️ Step 0: Substrate 基底")
|
||
|
||
substrate_type = label_input_row(
|
||
"Substrate Type", "selectbox", "sub_type",
|
||
options=['n', 'p'], index=0,
|
||
help="Select the background doping type of the substrate wafer."
|
||
)
|
||
|
||
n_sub = label_input_row(
|
||
"Doping (cm⁻³)", "number_input", "sub_doping",
|
||
ratio=[7, 3],
|
||
min_value=1e10, max_value=1e20, value=5.5e13, format="%e",
|
||
help="Doping concentration of the background wafer substrate."
|
||
)
|
||
|
||
length = label_input_row(
|
||
"Thickness (μm)", "number_input", "sub_length",
|
||
ratio=[7, 3],
|
||
min_value=5.0, max_value=500.0, value=100.0, step=5.0,
|
||
help="Total thickness of the substrate wafer (determines L)."
|
||
)
|
||
|
||
area_cm2 = label_input_row(
|
||
"Area (cm²)", "number_input", "device_area",
|
||
ratio=[7, 3],
|
||
min_value=1e-6, max_value=100.0, value=0.01, format="%e",
|
||
help="Cross-sectional area of the diode to calculate current from current density."
|
||
)
|
||
|
||
# Initialize process steps list if not present
|
||
if 'process_steps' not in st.session_state:
|
||
st.session_state['process_steps'] = [
|
||
# Default Step 1: Boron P-well Implant + thermal drive-in
|
||
{
|
||
'enabled': True,
|
||
'type': 'p',
|
||
'dopant': 'Boron',
|
||
'method': 'Implant',
|
||
'surface': 'Top',
|
||
'energy': 80.0,
|
||
'dose': 1e12,
|
||
'cs': 1e19,
|
||
'thickness': 10.0,
|
||
'temp': 1000.0,
|
||
'time': 60.0
|
||
}
|
||
]
|
||
|
||
# If process_steps is empty, show a button to add the first step in the sidebar
|
||
if len(st.session_state['process_steps']) == 0:
|
||
if st.button("➕ Add Step 1", key="add_first_step"):
|
||
st.session_state['process_steps'].append({
|
||
'enabled': True,
|
||
'type': 'p',
|
||
'dopant': 'Boron',
|
||
'method': 'Implant',
|
||
'surface': 'Top',
|
||
'energy': 80.0,
|
||
'dose': 1e12,
|
||
'cs': 1e19,
|
||
'thickness': 10.0,
|
||
'temp': 1000.0,
|
||
'time': 60.0
|
||
})
|
||
st.rerun()
|
||
|
||
st.markdown("### 🧬 Process Steps (Step 1 ~ n)")
|
||
|
||
# Render process steps inputs dynamically
|
||
new_steps = []
|
||
steps_to_pop = []
|
||
steps_to_move_up = -1
|
||
steps_to_insert_below = -1
|
||
|
||
for idx, step in enumerate(st.session_state['process_steps']):
|
||
st.markdown("---")
|
||
st.markdown(f"**Step {idx+1}: {step['dopant']} {step['method']}**")
|
||
|
||
if idx == 0:
|
||
# Step 1: no Move Up button, so only 3 columns!
|
||
col_en, col_ins, col_del = st.columns([2.2, 4.8, 3.5])
|
||
enabled = col_en.checkbox("On", value=step['enabled'], key=f"step_{idx}_enabled")
|
||
if col_ins.button("➕ Insert", key=f"step_{idx}_insert"):
|
||
steps_to_insert_below = idx
|
||
else:
|
||
# Step 2+: 4 columns!
|
||
col_en, col_ins, col_mv, col_del = st.columns([2.2, 3.0, 2.8, 2.5])
|
||
enabled = col_en.checkbox("On", value=step['enabled'], key=f"step_{idx}_enabled")
|
||
if col_ins.button("➕ Insert", key=f"step_{idx}_insert"):
|
||
steps_to_insert_below = idx
|
||
if col_mv.button("🔼 Up", key=f"step_{idx}_move"):
|
||
steps_to_move_up = idx
|
||
|
||
# Confirmable delete
|
||
confirm_key = f"delete_confirm_{idx}"
|
||
if confirm_key not in st.session_state:
|
||
st.session_state[confirm_key] = False
|
||
|
||
if not st.session_state[confirm_key]:
|
||
if col_del.button("❌ Del", key=f"step_{idx}_delete"):
|
||
st.session_state[confirm_key] = True
|
||
st.rerun()
|
||
else:
|
||
st.warning(f"Delete Step {idx+1}?")
|
||
col_yes, col_no = st.columns(2)
|
||
if col_yes.button("Yes", key=f"step_{idx}_confirm_yes"):
|
||
steps_to_pop.append(idx)
|
||
st.session_state[confirm_key] = False
|
||
if col_no.button("No", key=f"step_{idx}_confirm_no"):
|
||
st.session_state[confirm_key] = False
|
||
st.rerun()
|
||
|
||
# Expander for parameters
|
||
with st.expander("Parameters", expanded=True):
|
||
type_ = label_input_row("Doping Type", "selectbox", f"step_{idx}_type", options=['p', 'n'], index=0 if step['type'] == 'p' else 1)
|
||
dopant_options = ['Boron', 'Phosphorus', 'Arsenic']
|
||
dopant_idx = dopant_options.index(step['dopant']) if step['dopant'] in dopant_options else 0
|
||
dopant = label_input_row("Dopant", "selectbox", f"step_{idx}_dopant", options=dopant_options, index=dopant_idx)
|
||
|
||
method_options = ['Implant', 'Diffusion', 'Epi Growth']
|
||
method_idx = method_options.index(step['method']) if step['method'] in method_options else 0
|
||
method = label_input_row("Method", "selectbox", f"step_{idx}_method", options=method_options, index=method_idx)
|
||
|
||
if method == 'Implant':
|
||
surface = 'Top'
|
||
energy = label_input_row("Energy (keV)", "number_input", f"step_{idx}_energy", ratio=[7, 3], min_value=1.0, max_value=1000.0, value=float(step.get('energy', 80.0)), step=5.0)
|
||
dose = label_input_row("Dose (cm⁻²)", "number_input", f"step_{idx}_dose", ratio=[7, 3], min_value=1e9, max_value=1e17, value=float(step.get('dose', 1e12)), format="%e")
|
||
cs = step.get('cs', 1e19) # preserve
|
||
thickness = step.get('thickness', 10.0) # preserve
|
||
elif method == 'Diffusion': # Diffusion
|
||
surface_options = ['Top', 'Bottom']
|
||
surface_idx = surface_options.index(step.get('surface', 'Top')) if step.get('surface', 'Top') in surface_options else 0
|
||
surface = label_input_row("Surface Loc.", "selectbox", f"step_{idx}_surface", ratio=[5, 5], options=surface_options, index=surface_idx)
|
||
cs = label_input_row("Surface Cs (cm⁻³)", "number_input", f"step_{idx}_cs", ratio=[7, 3], min_value=1e13, max_value=1e22, value=float(step.get('cs', 1e19)), format="%e")
|
||
energy = step.get('energy', 80.0) # preserve
|
||
dose = step.get('dose', 1e12) # preserve
|
||
thickness = step.get('thickness', 10.0) # preserve
|
||
else: # Epi Growth
|
||
surface = 'Top'
|
||
thickness = label_input_row("Thickness (μm)", "number_input", f"step_{idx}_thickness", ratio=[7, 3], min_value=0.1, max_value=200.0, value=float(step.get('thickness', 10.0)), step=0.5)
|
||
cs = label_input_row("Doping Conc. (cm⁻³)", "number_input", f"step_{idx}_cs", ratio=[7, 3], min_value=1e10, max_value=1e20, value=float(step.get('cs', 1e15)), format="%e")
|
||
energy = step.get('energy', 80.0) # preserve
|
||
dose = step.get('dose', 1e12) # preserve
|
||
|
||
temp = label_input_row("Temp (°C)", "number_input", f"step_{idx}_temp", ratio=[7, 3], min_value=25.0, max_value=1300.0, value=float(step['temp']), step=25.0)
|
||
time = label_input_row("Time (min)", "number_input", f"step_{idx}_time", ratio=[7, 3], min_value=0.0, max_value=1000.0, value=float(step['time']), step=5.0)
|
||
|
||
new_steps.append({
|
||
'enabled': enabled,
|
||
'type': type_,
|
||
'dopant': dopant,
|
||
'method': method,
|
||
'surface': surface,
|
||
'energy': energy,
|
||
'dose': dose,
|
||
'cs': cs,
|
||
'thickness': thickness,
|
||
'temp': temp,
|
||
'time': time
|
||
})
|
||
|
||
# Apply actions
|
||
if len(steps_to_pop) > 0 or steps_to_move_up != -1 or steps_to_insert_below != -1:
|
||
# Clear step keys to prevent index-shift misalignment in Streamlit widgets
|
||
keys_to_clear = [k for k in st.session_state.keys() if k.startswith("step_") or k.startswith("delete_confirm_")]
|
||
for k in keys_to_clear:
|
||
del st.session_state[k]
|
||
|
||
if len(steps_to_pop) > 0:
|
||
for pop_idx in sorted(steps_to_pop, reverse=True):
|
||
st.session_state['process_steps'].pop(pop_idx)
|
||
st.rerun()
|
||
|
||
if steps_to_move_up != -1:
|
||
idx = steps_to_move_up
|
||
st.session_state['process_steps'][idx], st.session_state['process_steps'][idx-1] = st.session_state['process_steps'][idx-1], st.session_state['process_steps'][idx]
|
||
st.rerun()
|
||
|
||
if steps_to_insert_below != -1:
|
||
idx = steps_to_insert_below
|
||
default_step = {
|
||
'enabled': True,
|
||
'type': 'p',
|
||
'dopant': 'Boron',
|
||
'method': 'Implant',
|
||
'surface': 'Top',
|
||
'energy': 80.0,
|
||
'dose': 1e12,
|
||
'cs': 1e19,
|
||
'thickness': 10.0,
|
||
'temp': 1000.0,
|
||
'time': 60.0
|
||
}
|
||
st.session_state['process_steps'].insert(idx+1, default_step)
|
||
st.rerun()
|
||
|
||
st.session_state['process_steps'] = new_steps
|
||
|
||
# Generate JSON string of current state and populate the top container at the end of the sidebar execution
|
||
config_to_save = {
|
||
'sub_type': st.session_state.get('sub_type', 'n'),
|
||
'sub_doping': st.session_state.get('sub_doping', 5.5e13),
|
||
'sub_length': st.session_state.get('sub_length', 100.0),
|
||
'device_area': st.session_state.get('device_area', 0.01),
|
||
'process_steps': st.session_state.get('process_steps', []),
|
||
'bias_slider_val': st.session_state.get('bias_slider_val', 5.0),
|
||
'enable_avalanche_toggle_val': st.session_state.get('enable_avalanche_toggle_val', False),
|
||
'enable_btbt_toggle_val': st.session_state.get('enable_btbt_toggle_val', False),
|
||
'run_full_sweep_toggle_val': st.session_state.get('run_full_sweep_toggle_val', True),
|
||
'plot_doping_xmax': st.session_state.get('plot_doping_xmax', 5.0),
|
||
'plot_electro_xmax': st.session_state.get('plot_electro_xmax', 5.0),
|
||
'sweep_v_max': st.session_state.get('sweep_v_max', 1000.0),
|
||
}
|
||
json_str = json.dumps(config_to_save, indent=2)
|
||
download_btn_container.download_button(
|
||
label="📤 Save Settings",
|
||
data=json_str,
|
||
file_name="sim1d_config.json",
|
||
mime="application/json",
|
||
use_container_width=True
|
||
)
|
||
|
||
# --- 5. Main Dashboard Header ---
|
||
st.markdown(
|
||
"""
|
||
<div style="margin-bottom: 2rem;">
|
||
<h1 style="font-size: 2.2rem; font-weight: 800; background: linear-gradient(to right, #fafafa, #8a99ad); -webkit-background-clip: text; -webkit-text-fill-color: transparent;">Interactive 1D Diode Process-Device Simulator</h1>
|
||
<p style="color: #8a99ad; font-size: 1rem; margin-top: 0.2rem;">Real-time FVM Drift-Diffusion & Cumulative Thermal Budget process modeling</p>
|
||
</div>
|
||
""",
|
||
unsafe_allow_html=True
|
||
)
|
||
|
||
# --- 6. Execute Simulation & Cache ---
|
||
# Create unique key to track doping state changes
|
||
sweep_max_v = float(st.session_state.get('sweep_v_max', 1000.0))
|
||
doping_key = (substrate_type, n_sub, length, area_cm2, str(st.session_state['process_steps']), sweep_max_v)
|
||
|
||
# Initialize I-V curve caches if not present
|
||
if 'iv_curve_basic' not in st.session_state:
|
||
st.session_state['iv_curve_basic'] = ([0.0], [0.0])
|
||
if 'iv_curve_with_avalanche' not in st.session_state:
|
||
st.session_state['iv_curve_with_avalanche'] = ([0.0], [0.0])
|
||
if 'iv_curve_with_btbt' not in st.session_state:
|
||
st.session_state['iv_curve_with_btbt'] = ([0.0], [0.0])
|
||
|
||
try:
|
||
with DEVSIM_LOCK:
|
||
if run_full_sweep and ('cached_doping_key' not in st.session_state or st.session_state['cached_doping_key'] != doping_key):
|
||
with st.spinner(f"Calculating I-V sweeps (0 ~ {sweep_max_v:.0f}V)..."):
|
||
# 1. Sweep Basic (no avalanche, no btbt)
|
||
try:
|
||
res_no_av = build_and_solve_1d(
|
||
bias_target=sweep_max_v,
|
||
substrate_type=substrate_type,
|
||
substrate_doping=n_sub,
|
||
length=length,
|
||
process_steps=st.session_state['process_steps'],
|
||
enable_avalanche=False,
|
||
enable_btbt=False,
|
||
area_cm2=area_cm2
|
||
)
|
||
v_no_av, j_no_av = res_no_av['v_history'], res_no_av['j_history']
|
||
except Exception as e:
|
||
v_no_av, j_no_av = [0.0], [0.0]
|
||
st.error(f"Basic sweep failed: {e}")
|
||
|
||
# 2. Sweep with avalanche (no btbt)
|
||
try:
|
||
res_av = build_and_solve_1d(
|
||
bias_target=sweep_max_v,
|
||
substrate_type=substrate_type,
|
||
substrate_doping=n_sub,
|
||
length=length,
|
||
process_steps=st.session_state['process_steps'],
|
||
enable_avalanche=True,
|
||
enable_btbt=False,
|
||
area_cm2=area_cm2
|
||
)
|
||
v_av, j_av = res_av['v_history'], res_av['j_history']
|
||
except Exception as e:
|
||
v_av, j_av = [0.0], [0.0]
|
||
st.error(f"Avalanche sweep failed to converge: {e}")
|
||
|
||
# 3. Sweep with BTBT (no avalanche)
|
||
try:
|
||
res_btbt = build_and_solve_1d(
|
||
bias_target=sweep_max_v,
|
||
substrate_type=substrate_type,
|
||
substrate_doping=n_sub,
|
||
length=length,
|
||
process_steps=st.session_state['process_steps'],
|
||
enable_avalanche=False,
|
||
enable_btbt=True,
|
||
area_cm2=area_cm2
|
||
)
|
||
v_btbt, j_btbt = res_btbt['v_history'], res_btbt['j_history']
|
||
except Exception as e:
|
||
v_btbt, j_btbt = [0.0], [0.0]
|
||
st.error(f"BTBT sweep failed to converge: {e}")
|
||
|
||
# Store in session state
|
||
st.session_state['cached_doping_key'] = doping_key
|
||
st.session_state['iv_curve_basic'] = (v_no_av, j_no_av)
|
||
st.session_state['iv_curve_with_avalanche'] = (v_av, j_av)
|
||
st.session_state['iv_curve_with_btbt'] = (v_btbt, j_btbt)
|
||
v_pt_val = None
|
||
if 'res_no_av' in locals() and isinstance(res_no_av, dict):
|
||
v_pt_val = res_no_av.get('v_punchthrough')
|
||
if v_pt_val is None and 'res_av' in locals() and isinstance(res_av, dict):
|
||
v_pt_val = res_av.get('v_punchthrough')
|
||
if v_pt_val is None and 'res_btbt' in locals() and isinstance(res_btbt, dict):
|
||
v_pt_val = res_btbt.get('v_punchthrough')
|
||
st.session_state['v_punchthrough'] = v_pt_val
|
||
|
||
# 4. Single-bias simulation for electrostatic plots
|
||
with st.spinner(f"Solving electrostatic profiles at Vbias = {bias} V..."):
|
||
res = build_and_solve_1d(
|
||
bias_target=bias,
|
||
substrate_type=substrate_type,
|
||
substrate_doping=n_sub,
|
||
length=length,
|
||
process_steps=st.session_state['process_steps'],
|
||
enable_avalanche=enable_avalanche,
|
||
enable_btbt=enable_btbt,
|
||
area_cm2=area_cm2
|
||
)
|
||
# If full sweep did not run or v_pt was not found, try to grab from single bias solve
|
||
if not run_full_sweep or st.session_state.get('v_punchthrough') is None:
|
||
st.session_state['v_punchthrough'] = res.get('v_punchthrough')
|
||
|
||
# Check if target bias was reached
|
||
v_actual = res["v_solved"]
|
||
if abs(v_actual - bias) > 0.01:
|
||
st.warning(f"⚠️ Convergence Limit: Simulation halted at {v_actual:.2f} V due to breakdown divergence.")
|
||
|
||
# Calculate current in mA
|
||
current_ma = res['current_density'] * area_cm2 * 1000.0
|
||
|
||
# --- 7. Metrics Cards Row ---
|
||
st.markdown(
|
||
f"""
|
||
<div class="metric-container">
|
||
<div class="metric-card">
|
||
<div class="metric-label">Current (I)</div>
|
||
<div class="metric-value">{current_ma:.4e}</div>
|
||
<div class="metric-sub">mA (flowing right to left)</div>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="metric-label">Peak Electric Field</div>
|
||
<div class="metric-value">{res['peak_field']:.3e}</div>
|
||
<div class="metric-sub">V/cm</div>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="metric-label">Depletion Width</div>
|
||
<div class="metric-value">{res['depletion_width']:.3f}</div>
|
||
<div class="metric-sub">μm (from {res['depletion_left']:.2f} to {res['depletion_right']:.2f})</div>
|
||
</div>
|
||
</div>
|
||
""",
|
||
unsafe_allow_html=True
|
||
)
|
||
|
||
# --- 8. Plots Dashboard (2x2 Grid Layout reorganized to 2 columns) ---
|
||
plot_layout = dict(
|
||
paper_bgcolor='rgba(0,0,0,0)',
|
||
plot_bgcolor='rgba(0,0,0,0)',
|
||
margin=dict(l=50, r=50, t=60, b=80),
|
||
legend=dict(orientation="h", yanchor="top", y=-0.2, xanchor="center", x=0.5),
|
||
font=dict(family="Outfit, sans-serif", color="#fafafa"),
|
||
uirevision="plot_state", # Preserve user zoom/pan zoom state on parameter changes!
|
||
xaxis=dict(
|
||
title="Position (μm)",
|
||
gridcolor='rgba(255,255,255,0.05)',
|
||
zerolinecolor='rgba(255,255,255,0.1)',
|
||
linecolor='rgba(255,255,255,0.2)',
|
||
mirror=True
|
||
),
|
||
yaxis=dict(
|
||
gridcolor='rgba(255,255,255,0.05)',
|
||
zerolinecolor='rgba(255,255,255,0.1)',
|
||
linecolor='rgba(255,255,255,0.2)',
|
||
mirror=True
|
||
),
|
||
hovermode="x unified"
|
||
)
|
||
|
||
# Create 2 columns for layout: Left has Plot A and B, Right has Plot C
|
||
col_left, col_right = st.columns(2)
|
||
|
||
# --- LEFT COLUMN: Stacked Plot A (Voltage & E-field) & Plot B (Doping profiles) ---
|
||
with col_left:
|
||
# Plot A: Voltage & Electric Field (dual y-axis)
|
||
figA = make_subplots(specs=[[{"secondary_y": True}]])
|
||
if res['depletion_width'] > 0.05:
|
||
figA.add_vrect(
|
||
x0=res['depletion_left'],
|
||
x1=res['depletion_right'],
|
||
fillcolor="rgba(0, 210, 255, 0.08)",
|
||
line_width=0,
|
||
annotation_text="Depletion Region",
|
||
annotation_position="top left",
|
||
annotation_font=dict(color="#00d2ff", size=11)
|
||
)
|
||
# Voltage Trace (relative to top surface)
|
||
figA.add_trace(
|
||
go.Scatter(
|
||
x=res['x'], y=res['potential'] - res['potential'][0],
|
||
name="Voltage Profile (V)",
|
||
line=dict(color='#ff4b4b', width=3),
|
||
hovertemplate='%{y:.2f} V'
|
||
),
|
||
secondary_y=False
|
||
)
|
||
# Electric Field Trace
|
||
figA.add_trace(
|
||
go.Scatter(
|
||
x=res['x_edge'], y=res['efield'],
|
||
name="Electric Field (V/cm)",
|
||
line=dict(color='#00d2ff', width=3),
|
||
hovertemplate='%{y:.3e} V/cm'
|
||
),
|
||
secondary_y=True
|
||
)
|
||
figA.update_layout(**plot_layout)
|
||
figA.update_xaxes(range=[0.0, float(st.session_state.get('plot_electro_xmax', 5.0))])
|
||
figA.update_layout(title=f"⚡ Voltage & Electric Field Profile at {bias}V", height=360, legend=dict(y=-0.25))
|
||
figA.update_yaxes(title_text="Voltage (V)", range=[0.0, max(0.1, float(bias))], secondary_y=False)
|
||
figA.update_yaxes(title_text="Electric Field (V/cm)", secondary_y=True)
|
||
st.plotly_chart(figA, use_container_width=True)
|
||
|
||
# Plot B: Doping Profiles (arcsinh log scale)
|
||
figB = go.Figure()
|
||
|
||
# Plot Net Doping
|
||
y_net = np.arcsinh(res['doping'] / 2.0) / np.log(10.0)
|
||
figB.add_trace(go.Scatter(
|
||
x=res['x'], y=y_net,
|
||
name="Net Doping (arcsinh)",
|
||
line=dict(color='#00ff88', width=3),
|
||
hovertemplate='Net: %{y:.2f}'
|
||
))
|
||
|
||
# Plot individual step profiles (signed based on type: N is positive, P is negative)
|
||
for step_prof in res['step_profiles']:
|
||
prof_data = step_prof['profile']
|
||
if step_prof['type'] == 'p':
|
||
y_val = np.arcsinh(-prof_data / 2.0) / np.log(10.0)
|
||
else:
|
||
y_val = np.arcsinh(prof_data / 2.0) / np.log(10.0)
|
||
|
||
figB.add_trace(go.Scatter(
|
||
x=res['x'], y=y_val,
|
||
name=step_prof['name'],
|
||
line=dict(width=1.5, dash='dash'),
|
||
hovertemplate='%{y:.2f}'
|
||
))
|
||
|
||
figB.update_layout(**plot_layout)
|
||
figB.update_xaxes(range=[0.0, float(st.session_state.get('plot_doping_xmax', 5.0))])
|
||
figB.update_layout(
|
||
title="📈 Doping Profiles (arcsinh Log Scale)",
|
||
height=360,
|
||
legend=dict(y=-0.25),
|
||
yaxis=dict(
|
||
title="arcsinh(Doping / 2) / ln(10)",
|
||
zeroline=True,
|
||
zerolinecolor='rgba(255, 255, 255, 0.25)',
|
||
zerolinewidth=1
|
||
)
|
||
)
|
||
st.plotly_chart(figB, use_container_width=True)
|
||
|
||
# --- RIGHT COLUMN: Taller I-V Curves ---
|
||
with col_right:
|
||
figC = go.Figure()
|
||
|
||
# 1. Curve: Basic (Without Avalanche, Without BTBT)
|
||
v_basic, j_basic = st.session_state.get('iv_curve_basic', ([0.0], [0.0]))
|
||
if len(v_basic) == 1 and v_basic[0] == 0.0:
|
||
# Fallback to legacy cached key
|
||
v_basic, j_basic = st.session_state.get('iv_curve_without_avalanche', ([0.0], [0.0]))
|
||
i_basic_ma = np.abs(np.array(j_basic)) * area_cm2 * 1000.0
|
||
i_basic_ma = np.clip(i_basic_ma, 1e-12, None)
|
||
figC.add_trace(go.Scatter(
|
||
x=v_basic, y=i_basic_ma,
|
||
name="Basic (Drift-Diffusion)",
|
||
line=dict(color='#00d2ff', width=2, dash='dot'),
|
||
mode='lines+markers',
|
||
marker=dict(size=4),
|
||
hovertemplate='Bias: %{x:.1f} V<br>Current: %{y:.3e} mA'
|
||
))
|
||
|
||
# 2. Curve: With Avalanche
|
||
v_av, j_av = st.session_state.get('iv_curve_with_avalanche', ([0.0], [0.0]))
|
||
i_av_ma = np.abs(np.array(j_av)) * area_cm2 * 1000.0
|
||
i_av_ma = np.clip(i_av_ma, 1e-12, None)
|
||
figC.add_trace(go.Scatter(
|
||
x=v_av, y=i_av_ma,
|
||
name="With Avalanche",
|
||
line=dict(color='#ffae00', width=3),
|
||
mode='lines+markers',
|
||
hovertemplate='Bias: %{x:.1f} V<br>Current: %{y:.3e} mA'
|
||
))
|
||
|
||
# 3. Curve: With BTBT
|
||
v_btbt, j_btbt = st.session_state.get('iv_curve_with_btbt', ([0.0], [0.0]))
|
||
i_btbt_ma = np.abs(np.array(j_btbt)) * area_cm2 * 1000.0
|
||
i_btbt_ma = np.clip(i_btbt_ma, 1e-12, None)
|
||
figC.add_trace(go.Scatter(
|
||
x=v_btbt, y=i_btbt_ma,
|
||
name="With BTBT",
|
||
line=dict(color='#a000ff', width=3),
|
||
mode='lines+markers',
|
||
hovertemplate='Bias: %{x:.1f} V<br>Current: %{y:.3e} mA'
|
||
))
|
||
|
||
# Add punch-through vertical line if available
|
||
v_pt = st.session_state.get('v_punchthrough')
|
||
if v_pt is not None:
|
||
figC.add_vline(
|
||
x=v_pt,
|
||
line_width=2,
|
||
line_dash="dash",
|
||
line_color="#ff4b4b",
|
||
annotation_text=f"Punch-through: {v_pt:.1f}V",
|
||
annotation_position="top left",
|
||
annotation_font=dict(color="#ff4b4b", size=12)
|
||
)
|
||
|
||
figC.update_layout(**plot_layout)
|
||
figC.update_layout(
|
||
title=f"📉 I-V Characteristics (0 ~ {sweep_max_v:.0f}V)",
|
||
height=750,
|
||
legend=dict(y=-0.12),
|
||
xaxis=dict(title="Applied Bias Vbias (V)"),
|
||
yaxis=dict(type="log", title="Current |I| (mA)", range=[-12, 0.0], exponentformat="power")
|
||
)
|
||
st.plotly_chart(figC, use_container_width=True)
|
||
|
||
except Exception as ex:
|
||
st.error("❌ Simulation Error")
|
||
st.exception(ex)
|