Source code for simudo.gui.panels.processes

"""
Simudo GUI — Processes panel.

Each process has:
  • name
  • class (Select — discovered by AST-parsing electro_optical_process.py)
  • lower-energy band  (src_band in YAML)
  • higher-energy band (dst_band in YAML)
  • trap_band          (conditional — TrapEOPMixin subclasses only)
  • Include radiative recombination: Yes/No  (conditional — optical classes only)

EOP class discovery::

  The panel scans electro_optical_process.py at startup using ast.parse() (no
  import, no dolfin) to find all concrete subclasses of ElectroOpticalProcess
  and determine:
    - optical classes: inherit AbsorptionAndRadiativeRecombinationEOPMixin
                       -> show radiative toggle
    - trap classes:    inherit TrapEOPMixin
                       -> show trap band selector
  Cross-reference: see those mixin classes in electro_optical_process.py.

gui_hint attribute:
  Any EOP class may define a class-level string attribute:
      gui_hint = "Brief description visible in the GUI."
  The AST extractor reads this at startup and displays it as a styled note below
  the class selector whenever that class is selected. Classes without gui_hint
  show no note. This is how per-class usage instructions are added without
  modifying the GUI code.  The hint is plain text, not markup: it is escaped
  before display, so a placeholder like '<name>/alpha_function' survives.
"""

from __future__ import annotations
import ast, html, os
from typing import TYPE_CHECKING, Dict, List, Set, Tuple

import panel as pn

from simudo.gui.model import Process, CallableTopHat
from simudo.gui.panels.shared import (
    INPUT_SS, SELECT_SS, BTN_LIGHT_SS, RADIO_SS, ADD_BTN_SS, CHECKBOX_SS,
    sec_header, label,
)

if TYPE_CHECKING:
    from simudo.gui.app import SimudoApp

# ── EOP class discovery via AST ────────────────────────────────────────────────

_EOP_FILE = os.path.normpath(
    os.path.join(os.path.dirname(__file__),
                 "../../physics/electro_optical_process.py")
)

def _discover_eop_classes(
    path: str,
) -> Tuple[
        List[str], Set[str], Set[str],
        Dict[str, str],
        Dict[str, Dict[str, str]],
        Dict[str, Dict[str, Dict[str, str]]],
        Dict[str, bool]]:
    """AST-parse the EOP source and return metadata for all concrete subclasses.

    Returns:
        process_classes     — ordered list of concrete ElectroOpticalProcess subclass names
        optical_classes     — subset that inherit AbsorptionAndRadiativeRecombinationEOPMixin
        trap_classes        — subset that inherit TrapEOPMixin
        gui_hints           — {class_name: hint_string} for classes defining gui_hint = "..."
        required_params     — {class_name: {suffix: unit}} from required_spatial_params = {...}
        required_callables  — {class_name: {suffix: {meta_key: meta_value}}} from
                              required_spatial_callables = {...}.  Inherited from parent
                              classes within this file (e.g. IBBeerLambert overrides
                              BeerLambert's alpha_function with sigma_function); the
                              extractor honours the lexical override but DOES propagate
                              the attribute down through ancestors that don't redeclare it.
        rad_defaults        — {class_name: bool} effective default of the
                              enable_radiative_recombination class attribute (inherited
                              through ancestors; False when never declared).  Used by the
                              Layers panel to decide whether refractive_index is required.
    """
    with open(path, encoding='utf-8') as f:
        tree = ast.parse(f.read())

    # Collect every class definition: name → set of direct base names
    all_bases: Dict[str, Set[str]] = {}
    all_nodes: Dict[str, ast.ClassDef] = {}
    for node in ast.walk(tree):
        if isinstance(node, ast.ClassDef):
            all_bases[node.name] = {b.id for b in node.bases if isinstance(b, ast.Name)}
            all_nodes[node.name] = node

    # Compute full ancestor sets transitively (within this file)
    def ancestors(name: str, seen: Set[str] | None = None) -> Set[str]:
        if seen is None:
            seen = set()
        for base in all_bases.get(name, set()):
            if base not in seen:
                seen.add(base)
                ancestors(base, seen)
        return seen

    process_classes:    List[str] = []
    optical_classes:    Set[str] = set()
    trap_classes:       Set[str] = set()
    gui_hints:          Dict[str, str] = {}
    required_params:    Dict[str, Dict[str, str]] = {}
    # Per-class direct (non-inherited) declaration of required_spatial_callables;
    # we resolve inheritance below so callers see the effective dict.
    direct_callables:   Dict[str, Dict[str, Dict[str, str]]] = {}
    # Per-class direct declaration of enable_radiative_recombination (bool).
    # Resolved through inheritance below; default False if never declared.
    direct_rad:         Dict[str, bool] = {}

    def _const_dict(d: ast.Dict) -> Dict[str, str]:
        """Return {key: value} from an ast.Dict whose entries are all Constant→Constant."""
        out: Dict[str, str] = {}
        for k, v in zip(d.keys, d.values):
            if isinstance(k, ast.Constant) and isinstance(v, ast.Constant):
                out[str(k.value)] = str(v.value)
        return out

    # Classify EOP subclasses (filtered to ElectroOpticalProcess descendants).
    for name, node in all_nodes.items():
        anc = ancestors(name)
        if "ElectroOpticalProcess" not in anc:
            continue
        process_classes.append(name)
        if "AbsorptionAndRadiativeRecombinationEOPMixin" in anc:
            optical_classes.add(name)
        if "TrapEOPMixin" in anc:
            trap_classes.add(name)

    # Extract class-level attributes from EVERY class in the file — including
    # mixins that are NOT ElectroOpticalProcess subclasses — so that
    # inheritance resolution below can pick up attributes declared on a mixin
    # (e.g. enable_radiative_recombination = True on
    # AbsorptionAndRadiativeRecombinationEOPMixin).
    for name, node in all_nodes.items():
        for stmt in node.body:
            if not (isinstance(stmt, ast.Assign)
                    and len(stmt.targets) == 1
                    and isinstance(stmt.targets[0], ast.Name)):
                continue
            attr = stmt.targets[0].id

            if attr == "gui_hint" and isinstance(stmt.value, ast.Constant):
                gui_hints[name] = stmt.value.value

            elif attr == "required_spatial_params" and isinstance(stmt.value, ast.Dict):
                rsp: Dict[str, str] = {}
                for k, v in zip(stmt.value.keys, stmt.value.values):
                    if isinstance(k, ast.Constant) and isinstance(v, ast.Constant):
                        rsp[k.value] = v.value
                required_params[name] = rsp

            elif attr == "required_spatial_callables" and isinstance(stmt.value, ast.Dict):
                rsc: Dict[str, Dict[str, str]] = {}
                for k, v in zip(stmt.value.keys, stmt.value.values):
                    if (isinstance(k, ast.Constant)
                            and isinstance(v, ast.Dict)):
                        rsc[str(k.value)] = _const_dict(v)
                direct_callables[name] = rsc

            elif (attr == "enable_radiative_recombination"
                    and isinstance(stmt.value, ast.Constant)
                    and isinstance(stmt.value.value, bool)):
                direct_rad[name] = stmt.value.value

    # Resolve inheritance of required_spatial_callables: a class that does NOT
    # redeclare the attribute inherits the dict from its nearest ancestor (in
    # this file) that does declare it.  This mirrors Python attribute lookup
    # without requiring imports.
    required_callables: Dict[str, Dict[str, Dict[str, str]]] = {}
    def _resolve_callables(cls_name: str) -> Dict[str, Dict[str, str]]:
        if cls_name in direct_callables:
            return direct_callables[cls_name]
        # Walk MRO in declaration order: depth-first through bases.
        for base in all_bases.get(cls_name, ()):
            if base in all_nodes:
                inherited = _resolve_callables(base)
                if inherited:
                    return inherited
        return {}

    for cls in process_classes:
        eff = _resolve_callables(cls)
        if eff:
            required_callables[cls] = eff

    # Resolve enable_radiative_recombination defaults through inheritance.
    # Use None as the "not declared anywhere" sentinel (False is a real value).
    rad_defaults: Dict[str, bool] = {}
    def _resolve_rad(cls_name: str):
        if cls_name in direct_rad:
            return direct_rad[cls_name]
        for base in all_bases.get(cls_name, ()):
            if base in all_nodes:
                r = _resolve_rad(base)
                if r is not None:
                    return r
        return None
    for cls in process_classes:
        rad_defaults[cls] = bool(_resolve_rad(cls))

    return (process_classes, optical_classes, trap_classes,
            gui_hints, required_params, required_callables, rad_defaults)


# Fallback used if the runner source tree is not found alongside the GUI.
_FALLBACK_CLASSES = ["SRHRecombination", "NonRadiativeTrap",
                      "ShockleyReadBand2BandTrap", "ShockleyReadTrap2Trap",
                      "NonOverlappingTopHatBeerLambert",
                      "NonOverlappingTopHatBeerLambertIB"]
_FALLBACK_OPTICAL  = {"NonOverlappingTopHatBeerLambert",
                       "NonOverlappingTopHatBeerLambertIB"}
_FALLBACK_TRAP     = {"NonRadiativeTrap", "NonOverlappingTopHatBeerLambertIB",
                       "ShockleyReadBand2BandTrap", "ShockleyReadTrap2Trap"}
_FALLBACK_HINTS: Dict[str, str] = {}
_FALLBACK_REQUIRED: Dict[str, Dict[str, str]] = {
    "NonOverlappingTopHatBeerLambert":   {"alpha": "1/cm"},
    "NonOverlappingTopHatBeerLambertIB": {"sigma_opt": "cm^2"},
    "SRHRecombination":    {"{dst_band}/tau": "s", "{src_band}/tau": "s", "energy_level": "eV"},
    "NonRadiativeTrap":    {"sigma_th": "cm^2", "vth": "cm/s"},
    "ShockleyReadBand2BandTrap": {"{dst_band}/tau": "s", "{dst_band}/capture_rate": "cm^3/s"},
    "ShockleyReadTrap2Trap":     {"{dst_band}/capture_rate": "cm^3/s"},
}
_FALLBACK_CALLABLES: Dict[str, Dict[str, Dict[str, str]]] = {
    "BeerLambert": {"alpha_function": {
        "arg_units": "eV", "return_units": "1/cm",
        "description": "optical absorption coefficient α(E)"}},
    "IBBeerLambert": {"sigma_function": {
        "arg_units": "eV", "return_units": "cm^2",
        "description": "IB optical cross-section σ(E)"}},
}
_FALLBACK_RAD_DEFAULTS: Dict[str, bool] = {
    "BeerLambert": False,                       # overrides mixin default to False
    "IBBeerLambert": True,
    "NonOverlappingTopHatBeerLambert": True,    # inherits mixin default True
    "NonOverlappingTopHatBeerLambertIB": True,
}

if os.path.exists(_EOP_FILE):
    (_PROCESS_CLASSES, _OPTICAL_CLASSES, _TRAP_CLASSES,
     _PROCESS_HINTS, _EOP_REQUIRED_PARAMS, _EOP_REQUIRED_CALLABLES,
     _EOP_RAD_DEFAULTS) = \
        _discover_eop_classes(_EOP_FILE)
else:
    _PROCESS_CLASSES        = _FALLBACK_CLASSES
    _OPTICAL_CLASSES        = _FALLBACK_OPTICAL
    _TRAP_CLASSES           = _FALLBACK_TRAP
    _PROCESS_HINTS          = _FALLBACK_HINTS
    _EOP_REQUIRED_PARAMS    = _FALLBACK_REQUIRED
    _EOP_REQUIRED_CALLABLES = _FALLBACK_CALLABLES
    _EOP_RAD_DEFAULTS       = _FALLBACK_RAD_DEFAULTS

_RAD_OPTIONS = ["Yes", "No"]

# Units offered for the SVR quadrature bounds.  Energy only: the window
# is defined as a fixed *width* above the absorption threshold, which has
# no meaning in wavelength, where the mapping is inverse.
_SVR_ENERGY_UNITS = ["eV", "meV", "J"]

# Short prefix used for auto-generating process names from class names.
_CLASS_PREFIX: dict[str, str] = {
    "SRHRecombination":                   "srh",
    "NonRadiativeTrap":                   "nr",
    "ShockleyReadBand2BandTrap":          "b2b",
    "ShockleyReadTrap2Trap":              "t2t",
    "NonOverlappingTopHatBeerLambert":    "opt",
    "NonOverlappingTopHatBeerLambertIB":  "opt_ib",
}


def _unique_process_name(base: str, existing_names: list[str]) -> str:
    """Return *base* if it is not in *existing_names*, else *base_2*, *base_3*, …

    Strips any trailing ``_<digits>`` suffix from *base* before generating
    candidates, so calling with ``base='srh_2'`` still produces ``srh_3``
    rather than ``srh_2_2``.
    """
    import re as _re
    root = _re.sub(r"_\d+$", "", base)
    if root not in existing_names:
        return root
    n = 2
    while True:
        candidate = f"{root}_{n}"
        if candidate not in existing_names:
            return candidate
        n += 1

# ── Stylesheet for hint pane ───────────────────────────────────────────────────
_HINT_STYLE = (
    "font-size:11px;color:#8ab4d0;background:#0d1a2a;"
    "border-left:2px solid #2d4a6e;padding:5px 8px;"
    "border-radius:0 3px 3px 0;margin-bottom:2px;"
)


def _hint_html(text: str) -> str:
    """Wrap a gui_hint for display.  Hints are plain text written by physicists,
    not markup: escape them, or a placeholder like '<name>/generation' is parsed
    as a tag and silently disappears from the pane."""
    return f'<div style="{_HINT_STYLE}">{html.escape(text)}</div>' if text else ""


[docs] class ProcessesPanel: def __init__(self, app: "SimudoApp"): self.app = app self._list_col = pn.Column(sizing_mode="stretch_width", styles={"gap": "2px"}) self._add_btn = pn.widgets.Button( name="+ Add process", stylesheets=ADD_BTN_SS, sizing_mode="stretch_width", height=30, margin=(4, 0, 0, 0), ) self._add_btn.on_click(lambda e: self._add_process()) self._view = pn.Column( sec_header("PROCESSES"), self._list_col, self._add_btn, sizing_mode="stretch_width", styles={"padding": "12px", "gap": "4px"}, )
[docs] def view(self) -> pn.Column: self._refresh() return self._view
def _refresh(self): self._list_col.objects = [ self._build_row(i, p) for i, p in enumerate(self.app.project.processes) ] def _add_process(self): procs = self.app.project.processes band_names = self.app.project.band_names src = band_names[1] if len(band_names) > 1 else (band_names[0] if band_names else "VB") dst = band_names[0] if band_names else "CB" default_cls = "SRHRecombination" base = _CLASS_PREFIX.get(default_cls, "proc") existing = [p.name for p in procs] name = _unique_process_name(base, existing) procs.append(Process(name=name, cls=default_cls, src_band=src, dst_band=dst, radiative_recombination=None)) self.app.autosave() self.app.refresh_layers_missing_params() self._refresh() def _delete_process(self, idx: int): self.app.project.processes.pop(idx) self.app.autosave() self.app.refresh_layers_missing_params() self._refresh() def _ib_band_name(self) -> str | None: """Return the name of the unique Sharp Intermediate Band, or None if 0 or >1 exist.""" ib_bands = [b for b in self.app.project.bands if b.type == "intermediate"] return ib_bands[0].name if len(ib_bands) == 1 else None def _band_type(self, name: str) -> str | None: """Return the type string of the band with the given name, or None if not found.""" for b in self.app.project.bands: if b.name == name: return b.type return None def _material_provided_callable_keys(self) -> set: """Full spatial keys (e.g. 'opt_cv/alpha_function') that are provided by a material applied to at least one layer. Reuses the mat_ast scanner, which now flags callable get_dict entries in the returned key set.""" try: applied_mats = {l.material for l in self.app.project.layers if l.material} if not applied_mats: return set() lib_dirs = self.app.get_library_dirs() from simudo.gui.panels.mat_ast import get_property_keys_for_material keys: set = set() for mat in self.app.project.materials: if mat.name in applied_mats: keys.update(get_property_keys_for_material(mat, lib_dirs)) return keys except Exception: return set() def _region_options(self) -> list: """Region scopes a top-hat can target: 'domain' plus every layer name.""" names = [l.name for l in self.app.project.layers if l.name] return ["domain"] + names def _build_callable_section(self, idx: int, proc: Process) -> pn.Column: """Build the alpha/sigma callable editor for a process card. Empty (invisible) for classes that declare no required callables. For each required callable shorthand, shows: - a header with the description, - whether a material already provides the key, - an editable list of per-region top-hat specs, - an '+ add region' button. """ rsc = _EOP_REQUIRED_CALLABLES.get(proc.cls, {}) container = pn.Column(margin=0, sizing_mode="stretch_width", styles={"gap": "3px"}) if not rsc: container.visible = False return container mat_keys = self._material_provided_callable_keys() procs = self.app.project.processes for shorthand, meta in rsc.items(): ret_unit = meta.get("return_units", "1/cm") desc = meta.get("description", shorthand) full_key = f"{proc.name}/{shorthand}" by_material = full_key in mat_keys # AST-derived value label: the callable's own shorthand minus the # conventional '_function' suffix (e.g. 'alpha_function' → 'alpha', # 'sigma_function' → 'sigma'). Extends automatically to new EOPs. val_label = (shorthand[:-len("_function")] if shorthand.endswith("_function") else shorthand) # Ensure the model has a list to mutate for this shorthand. rows_model = proc.callable_top_hats.setdefault(shorthand, []) # Header + material status line. status_html = ( f'<span style="color:#3fb98a;">✓ provided by a material ' f'({full_key})</span>' if by_material else f'<span style="color:#8a9bb5;">set via top-hat below, or by a ' f'material providing <code>{full_key}</code></span>' ) header = pn.pane.HTML( f'<div style="font-size:11px;color:#8ab4d0;margin-top:3px;">' f'<b>{desc}</b><br>{status_html}</div>', sizing_mode="stretch_width", margin=0, ) rows_col = pn.Column(margin=0, sizing_mode="stretch_width", styles={"gap": "2px"}) def _make_row_widgets(row: CallableTopHat, shand=shorthand): region_sel = pn.widgets.Select( value=(row.region if row.region in self._region_options() else self._region_options()[0]), options=self._region_options(), width=70, height=24, margin=0, stylesheets=SELECT_SS) e_low_in = pn.widgets.FloatInput( value=row.E_low, width=58, height=24, margin=0, stylesheets=INPUT_SS) inf = row.E_high is None e_high_in = pn.widgets.FloatInput( value=(0.0 if inf else row.E_high), width=58, height=24, margin=0, stylesheets=INPUT_SS, disabled=inf) inf_chk = pn.widgets.Checkbox( value=inf, name="∞", margin=(4, 0, 0, 0), stylesheets=CHECKBOX_SS, width=34) val_in = pn.widgets.FloatInput( value=row.value, width=72, height=24, margin=0, stylesheets=INPUT_SS) unit_in = pn.widgets.TextInput( value=row.unit or ret_unit, width=64, height=24, margin=0, stylesheets=INPUT_SS) del_btn = pn.widgets.Button( name="✕", width=22, height=24, margin=0, stylesheets=BTN_LIGHT_SS) def _commit(_=None, r=row, rs=region_sel, el=e_low_in, eh=e_high_in, ic=inf_chk, vi=val_in, ui=unit_in): r.region = rs.value r.E_low = float(el.value or 0.0) r.E_high = None if ic.value else float(eh.value or 0.0) r.value = float(vi.value or 0.0) r.unit = ui.value or ret_unit self.app.autosave() self.app.refresh_layers_missing_params() def _on_inf(e, eh=e_high_in): eh.disabled = bool(e.new) _commit() region_sel.param.watch(_commit, "value") e_low_in.param.watch(_commit, "value") e_high_in.param.watch(_commit, "value") val_in.param.watch(_commit, "value") unit_in.param.watch(_commit, "value") inf_chk.param.watch(_on_inf, "value") def _delete(_=None, r=row, shd=shand): lst = procs[idx].callable_top_hats.get(shd, []) if r in lst: lst.remove(r) self.app.autosave() self.app.refresh_layers_missing_params() self._refresh() del_btn.on_click(_delete) return pn.Row( label("E≥", 22), e_low_in, label("E<", 22), e_high_in, inf_chk, label(f"{val_label}:", 44), val_in, unit_in, label("in", 16), region_sel, del_btn, margin=0, styles={"gap": "3px", "align-items": "center"}, sizing_mode="stretch_width") rows_col.objects = [_make_row_widgets(r) for r in rows_model] add_btn = pn.widgets.Button( name="+ add top-hat region", stylesheets=ADD_BTN_SS, height=24, margin=(2, 0, 0, 0), sizing_mode="stretch_width") def _add(_=None, shd=shorthand, ru=ret_unit): opts = self._region_options() default_region = opts[1] if len(opts) > 1 else opts[0] procs[idx].callable_top_hats.setdefault(shd, []).append( CallableTopHat(region=default_region, E_low=0.0, E_high=None, value=0.0, unit=ru)) self.app.autosave() self.app.refresh_layers_missing_params() self._refresh() add_btn.on_click(_add) container.append(header) container.append(rows_col) container.append(add_btn) return container def _build_row(self, idx: int, proc: Process) -> pn.Column: procs = self.app.project.processes band_names = self.app.project.band_names or ["CB", "VB"] # ── Name ───────────────────────────────────────────────────────────── name_in = pn.widgets.TextInput( value=proc.name, placeholder="name", height=26, margin=0, stylesheets=INPUT_SS, sizing_mode="stretch_width", ) name_error = pn.pane.HTML( '<div style="font-size:11px;color:#e07b39;padding:1px 0 1px 4px;">' '⚠ Process name must be unique.</div>', sizing_mode="stretch_width", margin=0, visible=False, ) def _on_name(e, i=idx): new_name = e.new.strip() if not new_name: # Blank — revert display to current model value without saving name_error.visible = False name_in.value = procs[i].name return other_names = [p.name for j, p in enumerate(procs) if j != i] if new_name in other_names: name_error.visible = True # Do not commit the duplicate to the model return name_error.visible = False procs[i].name = new_name self.app.autosave() self.app.refresh_layers_missing_params() name_in.param.watch(_on_name, "value") # ── Class ───────────────────────────────────────────────────────────── cls_val = proc.cls if proc.cls in _PROCESS_CLASSES else _PROCESS_CLASSES[0] cls_sel = pn.widgets.Select( value=cls_val, options=_PROCESS_CLASSES, height=26, margin=0, stylesheets=SELECT_SS, sizing_mode="stretch_width", ) # ── Delete ──────────────────────────────────────────────────────────── del_btn = pn.widgets.Button(name="✕", width=24, height=26, margin=0, stylesheets=BTN_LIGHT_SS) del_btn.on_click(lambda e, i=idx: self._delete_process(i)) header = pn.Column( pn.Row( name_in, cls_sel, del_btn, margin=0, styles={"gap": "4px", "align-items": "center"}, sizing_mode="stretch_width", ), name_error, margin=0, sizing_mode="stretch_width", ) # ── Hint pane ───────────────────────────────────────────────────────── hint_text = _PROCESS_HINTS.get(cls_val, "") hint_pane = pn.pane.HTML( _hint_html(hint_text), sizing_mode="stretch_width", margin=0, visible=bool(hint_text), ) # ── Band selectors ──────────────────────────────────────────────────── def _safe_band(val): return val if val in band_names else (band_names[0] if band_names else "") src_sel = pn.widgets.Select( value=_safe_band(proc.src_band), options=band_names, height=26, width=90, margin=0, stylesheets=SELECT_SS, ) dst_sel = pn.widgets.Select( value=_safe_band(proc.dst_band), options=band_names, height=26, width=90, margin=0, stylesheets=SELECT_SS, ) bands_row = pn.Row( label("Lower E band:", 72), src_sel, label("Higher E band:", 80), dst_sel, margin=0, styles={"gap": "4px", "align-items": "center"}, sizing_mode="stretch_width", ) # ── Trap band (conditional) ─────────────────────────────────────────── trap_opts = ["(none)"] + band_names ib_name = self._ib_band_name() # Auto-fill trap band on initial build: if this is a trap class and exactly # one IB band exists, pre-select it when the stored trap_band is unset. stored_trap = proc.trap_band if stored_trap is None and proc.cls in _TRAP_CLASSES and ib_name is not None: stored_trap = ib_name procs[idx].trap_band = ib_name # persist immediately trap_val = stored_trap if stored_trap in band_names else "(none)" trap_sel = pn.widgets.Select( value=trap_val, options=trap_opts, height=26, width=100, margin=0, stylesheets=SELECT_SS, ) # Error indicators for the trap band def _trap_is_valid(tv: str) -> bool: """Trap band should be a Sharp Intermediate Band (or none).""" return tv == "(none)" or self._band_type(tv) == "intermediate" def _trap_matches_bands(tv: str, sv: str, dv: str) -> bool: """For trap classes, trap band must equal src or dst band.""" return tv == "(none)" or tv == sv or tv == dv trap_error_type = pn.pane.HTML( '<div style="font-size:11px;color:#e07b39;padding:1px 0 1px 4px;">' '⚠ Trap band should be a Sharp Intermediate Band.</div>', sizing_mode="stretch_width", margin=0, visible=(proc.cls in _TRAP_CLASSES and not _trap_is_valid(trap_val)), ) trap_error_match = pn.pane.HTML( '<div style="font-size:11px;color:#e07b39;padding:1px 0 1px 4px;">' '⚠ Trap band must equal either the Lower E or Higher E band.</div>', sizing_mode="stretch_width", margin=0, visible=(proc.cls in _TRAP_CLASSES and not _trap_matches_bands(trap_val, _safe_band(proc.src_band), _safe_band(proc.dst_band))), ) trap_row = pn.Column( pn.Row( label("Trap band:", 64), trap_sel, margin=0, styles={"gap": "4px", "align-items": "center"}, sizing_mode="stretch_width", ), trap_error_type, trap_error_match, sizing_mode="stretch_width", margin=0, visible=(proc.cls in _TRAP_CLASSES), ) # ── Radiative recombination (optical classes only) ──────────────────── if proc.radiative_recombination is False: rad_val = "No" else: rad_val = "Yes" # True or None both map to Yes rad_rbg = pn.widgets.RadioButtonGroup( options=_RAD_OPTIONS, value=rad_val, height=26, margin=0, stylesheets=RADIO_SS, ) rad_row = pn.Row( label("Include radiative recomb.:", 162), rad_rbg, margin=0, styles={"gap": "4px", "align-items": "center"}, sizing_mode="stretch_width", visible=(proc.cls in _OPTICAL_CLASSES), ) # ── SVR quadrature window ───────────────────────────────────────────── # The emission integrand carries a blackbody weight exp(-E/kT) that peaks # at the absorption threshold, so *where* this window starts matters as # much as how wide it is: at the runner's 0-4 eV default the threshold # falls partway through a bin, and that bin -- the largest in the sum -- # is counted or dropped whole, worth tens of percent either way. Start # at the threshold and run 23 kT (0.6 eV at 300 K) and a silicon cell is # accurate to ~0.2% at the same bin count. Hence E_max tracks E_min # automatically, at the 300 K width, which the user must widen if the # device is hotter. svr_Emin_in = pn.widgets.FloatInput( value=proc.svr_E_min, step=0.01, start=0, height=26, width=70, margin=0, stylesheets=INPUT_SS, placeholder="auto", ) svr_Emin_unit = pn.widgets.Select( options=_SVR_ENERGY_UNITS, height=26, width=58, margin=0, value=(proc.svr_E_min_unit if proc.svr_E_min_unit in _SVR_ENERGY_UNITS else "eV"), stylesheets=SELECT_SS, ) svr_Emax_in = pn.widgets.FloatInput( value=proc.svr_E_max, step=0.01, start=0, height=26, width=70, margin=0, stylesheets=INPUT_SS, placeholder="auto", ) svr_Emax_unit = pn.widgets.Select( options=_SVR_ENERGY_UNITS, height=26, width=58, margin=0, value=(proc.svr_E_max_unit if proc.svr_E_max_unit in _SVR_ENERGY_UNITS else "eV"), stylesheets=SELECT_SS, ) svr_hint = pn.pane.HTML( _hint_html( "Energy window for the emission integral, split into 100 bins. " "Set the lower bound exactly at the absorption threshold (the " "band gap, or the bottom of the top hat) — emission peaks right " "there, so a bin straddling it can throw the rate off by tens " "of percent. The upper bound then follows automatically, 0.6 eV " "higher; nothing above that contributes at 300 K. Above 300 K " "the emission spectrum is broader, so raise the upper bound (it " "scales as kT: ~23 kT above the threshold). Leaving both blank " "uses the 0-4 eV default, which is not aligned to any " "threshold."), sizing_mode="stretch_width", margin=0, ) svr_row = pn.Column( pn.Row( label("Emission integral:", 162), label("from", 26), svr_Emin_in, svr_Emin_unit, label("to", 16), svr_Emax_in, svr_Emax_unit, margin=0, styles={"gap": "4px", "align-items": "center"}, sizing_mode="stretch_width", ), svr_hint, margin=0, sizing_mode="stretch_width", visible=(proc.cls in _OPTICAL_CLASSES and proc.radiative_recombination is not False), ) # Width of the default window above the absorption threshold, at 300 K. # That is ~23 kT, where the blackbody weight has fallen by e^-23 and the # tail left out is ~1e-8 of the integral. Fixed rather than computed: # temperature is a spatial rule and need not be uniform, so the GUI has # no single T to scale by. The hint tells the user to widen it if the # device runs hot. _SVR_WINDOW_EV = 0.6 def _svr_default_max(v_min: float, unit: str) -> float: """E_max implied by E_min, expressed in `unit`.""" per_eV = {"eV": 1.0, "meV": 1000.0, "J": 1.602176634e-19}.get(unit) if per_eV is None: # unfamiliar unit: leave it to the user return None return round(v_min + _SVR_WINDOW_EV * per_eV, 6) # ── Callable (alpha / sigma) section ────────────────────────────────── callable_section = self._build_callable_section(idx, proc) # ── Callbacks ───────────────────────────────────────────────────────── def _refresh_trap_errors(tv, sv, dv, is_trap): trap_error_type.visible = is_trap and not _trap_is_valid(tv) trap_error_match.visible = is_trap and not _trap_matches_bands(tv, sv, dv) def _on_cls(event, i=idx): new_cls = event.new procs[i].cls = new_cls # Suggest a unique name based on the new class prefix if the current # name still matches the old auto-generated pattern (safe to replace). base = _CLASS_PREFIX.get(new_cls, "proc") other_names = [p.name for j, p in enumerate(procs) if j != i] suggested = _unique_process_name(base, other_names) if name_in.value != suggested: # Only update if no duplicate error is currently shown — # don't override a name the user is actively editing. if not name_error.visible: procs[i].name = suggested name_in.value = suggested # Update hint pane h = _PROCESS_HINTS.get(new_cls, "") hint_pane.object = _hint_html(h) hint_pane.visible = bool(h) # Show/hide conditional rows is_trap = new_cls in _TRAP_CLASSES is_optical = new_cls in _OPTICAL_CLASSES trap_row.visible = is_trap rad_row.visible = is_optical svr_row.visible = is_optical and rad_rbg.value == "Yes" # When switching to a trap class, always apply smart defaults if an IB exists: # lower E band = IB, higher E band = first non-IB, trap band = IB. # (No `is None` guard — a class switch is an intentional action that # should reset to sensible defaults regardless of prior state.) if is_trap: ib = self._ib_band_name() if ib is not None: procs[i].trap_band = ib trap_sel.value = ib procs[i].src_band = ib src_sel.value = ib non_ib = next((n for n in band_names if n != ib), None) if non_ib: procs[i].dst_band = non_ib dst_sel.value = non_ib _refresh_trap_errors(trap_sel.value, src_sel.value, dst_sel.value, is_trap) self.app.autosave() self.app.refresh_layers_missing_params() # The callable (alpha/sigma) section differs per class, so rebuild # the whole card list to show/hide and repopulate it. self._refresh() cls_sel.param.watch(_on_cls, "value") def _on_src(e, i=idx): procs[i].src_band = e.new _refresh_trap_errors(trap_sel.value, e.new, dst_sel.value, procs[i].cls in _TRAP_CLASSES) self.app.autosave() def _on_dst(e, i=idx): procs[i].dst_band = e.new _refresh_trap_errors(trap_sel.value, src_sel.value, e.new, procs[i].cls in _TRAP_CLASSES) self.app.autosave() src_sel.param.watch(_on_src, "value") dst_sel.param.watch(_on_dst, "value") def _on_trap(e, i=idx): procs[i].trap_band = None if e.new == "(none)" else e.new _refresh_trap_errors(e.new, src_sel.value, dst_sel.value, procs[i].cls in _TRAP_CLASSES) self.app.autosave() trap_sel.param.watch(_on_trap, "value") def _on_rad(e, i=idx): procs[i].radiative_recombination = (e.new == "Yes") svr_row.visible = (e.new == "Yes") self.app.autosave() rad_rbg.param.watch(_on_rad, "value") def _on_svr_emin(e, i=idx): procs[i].svr_E_min = e.new # Setting a lower bound fills in the upper bound, unless the user # has already chosen one themselves. if e.new is not None and procs[i].svr_E_max is None: implied = _svr_default_max(e.new, svr_Emin_unit.value) if implied is not None: svr_Emax_unit.value = svr_Emin_unit.value svr_Emax_in.value = implied # its watcher stores it self.app.autosave() svr_Emin_in.param.watch(_on_svr_emin, "value") def _on_svr_emin_unit(e, i=idx): procs[i].svr_E_min_unit = e.new self.app.autosave() svr_Emin_unit.param.watch(_on_svr_emin_unit, "value") def _on_svr_emax(e, i=idx): procs[i].svr_E_max = e.new self.app.autosave() svr_Emax_in.param.watch(_on_svr_emax, "value") def _on_svr_emax_unit(e, i=idx): procs[i].svr_E_max_unit = e.new self.app.autosave() svr_Emax_unit.param.watch(_on_svr_emax_unit, "value") return pn.Column( header, hint_pane, bands_row, trap_row, rad_row, svr_row, callable_section, sizing_mode="stretch_width", styles={ "background": "#1a2435", "border": "1px solid #2d3748", "border-radius": "4px", "padding": "8px", "gap": "4px", }, )