"""
Simudo GUI — Optical Fields panel.
Lists optical fields with add / delete. Each field has:
• name (TextInput)
• direction (+x / −x radio)
• photon_energy (value + unit)
• intensity mode (Explicit | Blackbody)
Explicit → value + unit
Blackbody → T_source, concentration, E_min, E_max (∞ checkbox)
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import panel as pn
from simudo.gui.model import (OpticalField, OpticalFieldSet, ExplicitIntensity,
BlackbodyIntensity)
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
_ENERGY_UNITS = ["eV", "J", "cm^-1", "nm", "um"]
_FLUX_UNITS = ["cm^-2 s^-1", "m^-2 s^-1"]
_POWER_UNITS = ["mW/cm^2", "W/m^2", "suns", "W/cm^2", "kW/m^2"]
_TEMP_UNITS = ["K", "°C"]
_SPECTRA = ["am15g", "am15d", "blackbody"]
_BIN_EDGE_MODES = ["equal_flux", "uniform_energy"]
# Physical constants for live flux preview (no pint dependency in GUI)
_EV_TO_J = 1.60218e-19 # J per eV
_H_JS = 6.62607e-34 # J·s
_C_MS = 2.99792e8 # m/s
# Power-density → W/cm² conversion factors for supported units
_TO_W_CM2 = {
"mw/cm^2": 1e-3,
"w/cm^2": 1.0,
"w/m^2": 1e-4,
"kw/m^2": 0.1,
"suns": 0.1, # 1 sun ≈ 100 mW/cm²
"sun": 0.1,
}
def _power_to_flux_str(power_val: float, power_unit: str,
E_val: float, E_unit: str) -> str:
"""Return a human-readable photon-flux string, or empty string on failure."""
try:
factor = _TO_W_CM2.get(power_unit.lower().replace(" ", ""))
if factor is None:
return ""
I_wcm2 = float(power_val) * factor # W/cm²
# Photon energy in Joules
eu = E_unit.lower()
if eu == "ev":
E_J = float(E_val) * _EV_TO_J
elif eu == "j":
E_J = float(E_val)
elif eu == "nm":
E_J = _H_JS * _C_MS / (float(E_val) * 1e-9)
elif eu == "um":
E_J = _H_JS * _C_MS / (float(E_val) * 1e-6)
elif eu == "cm^-1":
E_J = _H_JS * _C_MS * float(E_val) * 100.0
else:
return ""
if E_J <= 0:
return ""
flux = I_wcm2 / E_J # photons/cm²/s
return f"{flux:.3e} cm⁻²s⁻¹"
except Exception:
return ""
[docs]
class OptFieldsPanel:
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 monochromatic field", stylesheets=ADD_BTN_SS,
sizing_mode="stretch_width", height=30, margin=(4, 0, 0, 0),
)
self._add_btn.on_click(lambda e: self._add_field())
# Spectrum-expanded field sets.
self._sets_col = pn.Column(sizing_mode="stretch_width", styles={"gap": "2px"})
self._add_set_btn = pn.widgets.Button(
name="+ Add field set (spectrum)", stylesheets=ADD_BTN_SS,
sizing_mode="stretch_width", height=30, margin=(4, 0, 0, 0),
)
self._add_set_btn.on_click(lambda e: self._add_field_set())
self._view = pn.Column(
sec_header("OPTICAL FIELDS"),
self._list_col,
self._add_btn,
sec_header("OPTICAL FIELD SETS"),
self._sets_col,
self._add_set_btn,
sizing_mode="stretch_width",
styles={"padding": "12px", "gap": "4px"},
)
[docs]
def view(self) -> pn.Column:
self._refresh()
return self._view
def _refresh(self):
fields = self.app.project.optical_fields
self._list_col.objects = [self._build_row(i, f) for i, f in enumerate(fields)]
sets = self.app.project.optical_field_sets
self._sets_col.objects = [self._build_set_row(i, s) for i, s in enumerate(sets)]
def _add_field(self):
fields = self.app.project.optical_fields
n = len(fields) + 1
fields.append(OpticalField(name=f"field{n}"))
self.app.autosave()
self._refresh()
def _delete_field(self, idx: int):
self.app.project.optical_fields.pop(idx)
self.app.autosave()
self._refresh()
def _add_field_set(self):
sets = self.app.project.optical_field_sets
existing = {s.name_prefix for s in sets}
base = "sun"
name = base
n = 2
while name in existing:
name = f"{base}{n}"
n += 1
sets.append(OpticalFieldSet(name_prefix=name))
self.app.autosave()
self._refresh()
def _delete_field_set(self, idx: int):
self.app.project.optical_field_sets.pop(idx)
self.app.autosave()
self._refresh()
def _build_set_row(self, idx: int, fs: OpticalFieldSet) -> pn.Column:
sets = self.app.project.optical_field_sets
name_in = pn.widgets.TextInput(
value=fs.name_prefix, placeholder="name prefix",
height=26, margin=0, stylesheets=INPUT_SS, sizing_mode="stretch_width")
def _on_name(e, i=idx):
sets[i].name_prefix = e.new.strip() or sets[i].name_prefix
self.app.autosave()
name_in.param.watch(_on_name, "value")
dir_rbg = pn.widgets.RadioButtonGroup(
options=["+x", "−x"], value=fs.direction if fs.direction == "+x" else "−x",
height=26, margin=0, stylesheets=RADIO_SS)
def _on_dir(e, i=idx):
sets[i].direction = "+x" if e.new == "+x" else "-x"; self.app.autosave()
dir_rbg.param.watch(_on_dir, "value")
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_field_set(i))
header = pn.Row(
name_in, dir_rbg, del_btn,
margin=0, styles={"gap": "4px", "align-items": "center"},
sizing_mode="stretch_width")
# Spectrum selector + T_source (only for blackbody).
spec_sel = pn.widgets.Select(
value=fs.spectrum if fs.spectrum in _SPECTRA else "am15g",
options=_SPECTRA, height=26, width=110, margin=0, stylesheets=SELECT_SS)
T_in = pn.widgets.NumberInput(
value=fs.T_source, step=100.0, start=0, height=26, width=80, margin=0,
stylesheets=INPUT_SS, visible=(fs.spectrum == "blackbody"))
T_unit = pn.widgets.Select(
value=fs.T_source_unit if fs.T_source_unit in _TEMP_UNITS else "K",
options=_TEMP_UNITS, height=26, width=46, margin=0, stylesheets=SELECT_SS,
visible=(fs.spectrum == "blackbody"))
spec_row = pn.Row(
label("Spectrum:", 60), spec_sel, label("T:", 14), T_in, T_unit,
margin=0, styles={"gap": "4px", "align-items": "center"},
sizing_mode="stretch_width")
# N bins, concentration, bin-edge mode.
N_in = pn.widgets.IntInput(
value=fs.N_bins, start=1, step=1, height=26, width=60, margin=0,
stylesheets=INPUT_SS)
conc_in = pn.widgets.NumberInput(
value=fs.concentration, step=1.0, start=0, height=26, width=70, margin=0,
stylesheets=INPUT_SS)
bin_sel = pn.widgets.Select(
value=fs.bin_edges if fs.bin_edges in _BIN_EDGE_MODES else "equal_flux",
options=_BIN_EDGE_MODES, height=26, width=120, margin=0, stylesheets=SELECT_SS)
nbc_row = pn.Row(
label("N bins:", 44), N_in, label("Conc.:", 38), conc_in,
label("Edges:", 40), bin_sel,
margin=0, styles={"gap": "4px", "align-items": "center"},
sizing_mode="stretch_width")
# Energy range.
Emin_in = pn.widgets.NumberInput(
value=fs.E_min, step=0.01, start=0, height=26, width=70, margin=0,
stylesheets=INPUT_SS)
Emin_unit = pn.widgets.Select(
value=fs.E_min_unit if fs.E_min_unit in _ENERGY_UNITS else "eV",
options=_ENERGY_UNITS, height=26, width=60, margin=0, stylesheets=SELECT_SS)
Emax_in = pn.widgets.NumberInput(
value=fs.E_max, step=0.01, start=0, height=26, width=70, margin=0,
stylesheets=INPUT_SS)
Emax_unit = pn.widgets.Select(
value=fs.E_max_unit if fs.E_max_unit in _ENERGY_UNITS else "eV",
options=_ENERGY_UNITS, height=26, width=60, margin=0, stylesheets=SELECT_SS)
e_row = pn.Row(
label("E min:", 40), Emin_in, Emin_unit,
label("E max:", 38), Emax_in, Emax_unit,
margin=0, styles={"gap": "4px", "align-items": "center"},
sizing_mode="stretch_width")
help_pane = pn.pane.HTML(
'<div style="font-size:11px;color:#8ab4d0;background:#0d1a2a;'
'border-left:2px solid #2d4a6e;padding:5px 8px;'
'border-radius:0 3px 3px 0;line-height:1.5;">'
f'Expands at run time into N monochromatic fields named '
f'<code>{fs.name_prefix}_000</code> … binned across the energy '
'range. <code>equal_flux</code> bins carry equal photon flux; '
'<code>uniform_energy</code> bins are evenly spaced in energy. '
'Concentration multiplies the spectrum flux.'
'</div>',
sizing_mode="stretch_width", margin=0)
def _commit(_=None, i=idx):
s = sets[i]
s.spectrum = spec_sel.value
s.N_bins = int(N_in.value or 1)
s.concentration = float(conc_in.value or 0.0)
s.bin_edges = bin_sel.value
s.E_min = float(Emin_in.value or 0.0)
s.E_min_unit = Emin_unit.value
s.E_max = float(Emax_in.value or 0.0)
s.E_max_unit = Emax_unit.value
s.T_source = float(T_in.value or 0.0)
s.T_source_unit = T_unit.value
self.app.autosave()
def _on_spec(e, i=idx):
is_bb = (e.new == "blackbody")
T_in.visible = is_bb
T_unit.visible = is_bb
_commit(i=i)
spec_sel.param.watch(_on_spec, "value")
for w in (N_in, conc_in, bin_sel, Emin_in, Emin_unit, Emax_in, Emax_unit,
T_in, T_unit):
w.param.watch(lambda e, i=idx: _commit(i=i), "value")
return pn.Column(
header, spec_row, nbc_row, e_row, help_pane,
sizing_mode="stretch_width",
styles={
"background": "#1a2435",
"border": "1px solid #2d3748",
"border-radius": "4px",
"padding": "8px",
"gap": "4px",
},
)
def _build_row(self, idx: int, field: OpticalField) -> pn.Column:
fields = self.app.project.optical_fields
# ── Name ─────────────────────────────────────────────────────────────
name_in = pn.widgets.TextInput(
value=field.name, placeholder="name",
height=26, margin=0, stylesheets=INPUT_SS,
sizing_mode="stretch_width",
)
def _on_name(e, i=idx): fields[i].name = e.new.strip() or fields[i].name; self.app.autosave()
name_in.param.watch(_on_name, "value")
# ── Direction ─────────────────────────────────────────────────────────
dir_rbg = pn.widgets.RadioButtonGroup(
options=["+x", "−x"], value=field.direction if field.direction == "+x" else "−x",
height=26, margin=0, stylesheets=RADIO_SS,
)
def _on_dir(e, i=idx):
fields[i].direction = "+x" if e.new == "+x" else "-x"
self.app.autosave()
dir_rbg.param.watch(_on_dir, "value")
# ── 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_field(i))
header = pn.Row(
name_in, dir_rbg, del_btn,
margin=0, styles={"gap": "4px", "align-items": "center"},
sizing_mode="stretch_width",
)
# ── Photon energy ─────────────────────────────────────────────────────
pe_in = pn.widgets.NumberInput(
value=field.photon_energy, step=0.001, start=0,
height=26, width=90, margin=0, stylesheets=INPUT_SS,
)
pe_unit = pn.widgets.Select(
value=field.photon_energy_unit if field.photon_energy_unit in _ENERGY_UNITS else "eV",
options=_ENERGY_UNITS, height=26, width=60, margin=0, stylesheets=SELECT_SS,
)
def _on_pe(e, i=idx): fields[i].photon_energy = float(e.new or 0); self.app.autosave()
def _on_pe_unit(e, i=idx): fields[i].photon_energy_unit = e.new; self.app.autosave()
pe_in.param.watch(_on_pe, "value")
pe_unit.param.watch(_on_pe_unit, "value")
pe_row = pn.Row(
label("Photon E:", 56), pe_in, pe_unit,
margin=0, styles={"gap": "4px", "align-items": "center"},
sizing_mode="stretch_width",
)
# ── Intensity mode ────────────────────────────────────────────────────
is_bb = isinstance(field.intensity, BlackbodyIntensity)
mode_sel = pn.widgets.Select(
value="Blackbody" if is_bb else "Explicit",
options=["Explicit", "Blackbody"],
height=26, width=110, margin=0, stylesheets=SELECT_SS,
)
# ---- Explicit intensity sub-form ─────────────────────────────────────
inten = field.intensity
is_exp = isinstance(inten, ExplicitIntensity)
is_pd = is_exp and inten.input_type == "power_density"
exp_val = inten.value if is_exp else 100.0
exp_unit = inten.unit if is_exp else "mW/cm^2"
# Sub-mode toggle: photon flux vs power density
exp_type_rbg = pn.widgets.RadioButtonGroup(
value="Power density" if is_pd else "Photon flux",
options=["Photon flux", "Power density"],
height=26, margin=0, stylesheets=RADIO_SS,
)
# Value input (shared)
exp_val_in = pn.widgets.NumberInput(
value=exp_val, step=1.0, start=0,
height=26, width=90, margin=0, stylesheets=INPUT_SS,
)
# Unit selector — options differ by sub-mode
_init_units = _POWER_UNITS if is_pd else _FLUX_UNITS
_init_unit = (exp_unit if exp_unit in _init_units
else (_POWER_UNITS[0] if is_pd else _FLUX_UNITS[0]))
exp_unit_sel = pn.widgets.Select(
value=_init_unit, options=_init_units,
height=26, width=100, margin=0, stylesheets=SELECT_SS,
)
# Hint shown when power-density sub-mode is active
def _flux_hint() -> str:
fs = _power_to_flux_str(
exp_val_in.value or 0.0, exp_unit_sel.value,
field.photon_energy, field.photon_energy_unit,
)
body = (f"≈ {fs}" if fs else "(enter value above to see conversion)")
return (
'<div style="font-size:11px;color:#8ab4d0;background:#0d1a2a;'
'border-left:2px solid #2d4a6e;padding:5px 8px;'
'border-radius:0 3px 3px 0;line-height:1.5;">'
'Simudo needs photon flux (photons · cm⁻² · s⁻¹). '
'The value you enter will be divided by the photon energy '
'to convert it automatically.<br>'
f'<span style="color:#c8d6e5;font-family:monospace;">{body}</span>'
'</div>'
)
exp_hint_pane = pn.pane.HTML(
_flux_hint() if is_pd else "",
sizing_mode="stretch_width", margin=(0, 0, 0, 0),
visible=(not is_bb and is_pd),
)
exp_row = pn.Column(
pn.Row(
exp_type_rbg,
margin=0, styles={"gap": "4px", "align-items": "center"},
),
pn.Row(
label("Value:", 42), exp_val_in, exp_unit_sel,
margin=0, styles={"gap": "4px", "align-items": "center"},
sizing_mode="stretch_width",
),
exp_hint_pane,
margin=0, styles={"gap": "4px"},
sizing_mode="stretch_width",
visible=not is_bb,
)
# ---- Blackbody sub-form ──────────────────────────────────────────────
bb = inten if isinstance(inten, BlackbodyIntensity) else BlackbodyIntensity()
T_in = pn.widgets.NumberInput(
value=bb.T_source, step=100.0, start=0,
height=26, width=80, margin=0, stylesheets=INPUT_SS,
)
T_unit = pn.widgets.Select(
value=bb.T_source_unit if bb.T_source_unit in _TEMP_UNITS else "K",
options=_TEMP_UNITS, height=26, width=46, margin=0, stylesheets=SELECT_SS,
)
conc_in = pn.widgets.NumberInput(
value=bb.concentration, step=1.0, start=0,
height=26, width=70, margin=0, stylesheets=INPUT_SS,
)
Emin_in = pn.widgets.NumberInput(
value=bb.E_min, step=0.01, start=0,
height=26, width=70, margin=0, stylesheets=INPUT_SS,
)
Emin_unit = pn.widgets.Select(
value=bb.E_min_unit if bb.E_min_unit in _ENERGY_UNITS else "eV",
options=_ENERGY_UNITS, height=26, width=60, margin=0, stylesheets=SELECT_SS,
)
Emax_val = bb.E_max if bb.E_max is not None else 0.0
Emax_in = pn.widgets.NumberInput(
value=Emax_val, step=0.01, start=0,
height=26, width=70, margin=0, stylesheets=INPUT_SS,
visible=(bb.E_max is not None),
)
Emax_unit = pn.widgets.Select(
value=bb.E_max_unit if bb.E_max_unit in _ENERGY_UNITS else "eV",
options=_ENERGY_UNITS, height=26, width=60, margin=0, stylesheets=SELECT_SS,
visible=(bb.E_max is not None),
)
Emax_inf_cb = pn.widgets.Checkbox(
name="∞", value=(bb.E_max is None),
height=26, margin=0, stylesheets=CHECKBOX_SS,
)
# ── Blackbody help text ───────────────────────────────────────────────
bb_help_pane = pn.pane.HTML(
'<div style="font-size:11px;color:#8ab4d0;background:#0d1a2a;'
'border-left:2px solid #2d4a6e;padding:5px 8px;'
'border-radius:0 3px 3px 0;line-height:1.5;">'
'Specify the temperature of the blackbody source, the concentration '
'(relative to the solar solid-angle fraction '
'<em>f</em><sub>s</sub> = 1/26200), '
'and the spectral range to be integrated into this field.'
'</div>',
sizing_mode="stretch_width", margin=(0, 0, 2, 0),
visible=is_bb,
)
# ── Concentration warning (shown when > 46 200×) ──────────────────────
_CONC_THRESH = 46200
_init_conc = bb.concentration if isinstance(inten, BlackbodyIntensity) else 0.0
conc_warn_pane = pn.pane.HTML(
'<div style="font-size:11px;color:#f0c040;'
'background:rgba(240,192,64,0.10);'
'border:1px solid rgba(240,192,64,0.35);'
'border-radius:4px;padding:5px 8px;">'
f'⚠ Concentration exceeds {_CONC_THRESH:,}×, which is the '
'theoretical maximum solar concentration. Verify this is intentional.'
'</div>',
sizing_mode="stretch_width", margin=0,
visible=(is_bb and _init_conc > _CONC_THRESH),
)
# ── E_max ∞ checkbox hint ─────────────────────────────────────────────
emax_hint_pane = pn.pane.HTML(
'<div style="font-size:11px;color:#556070;padding:1px 2px;">'
'Check ∞ to use no upper energy cutoff '
'(integrate the blackbody spectrum to infinity).'
'</div>',
sizing_mode="stretch_width", margin=0,
visible=is_bb,
)
bb_T_row = pn.Row(
label("T source:", 56), T_in, T_unit,
label("Conc.:", 38), conc_in,
margin=0, styles={"gap": "4px", "align-items": "center"},
sizing_mode="stretch_width",
visible=is_bb,
)
bb_E_row = pn.Row(
label("E min:", 40), Emin_in, Emin_unit,
label("E max:", 38), Emax_in, Emax_unit, Emax_inf_cb,
margin=0, styles={"gap": "4px", "align-items": "center"},
sizing_mode="stretch_width",
visible=is_bb,
)
# ── Callbacks ─────────────────────────────────────────────────────────
def _on_exp_type(event, i=idx):
"""Switch explicit sub-mode between Photon flux and Power density."""
pd = (event.new == "Power density")
new_units = _POWER_UNITS if pd else _FLUX_UNITS
exp_unit_sel.options = new_units
exp_unit_sel.value = new_units[0]
exp_hint_pane.visible = pd
exp_hint_pane.object = _flux_hint() if pd else ""
if isinstance(fields[i].intensity, ExplicitIntensity):
fields[i].intensity.input_type = "power_density" if pd else "flux"
fields[i].intensity.unit = new_units[0]
self.app.autosave()
exp_type_rbg.param.watch(_on_exp_type, "value")
def _on_mode(event, i=idx):
if event.new == "Explicit":
pd = (exp_type_rbg.value == "Power density")
fields[i].intensity = ExplicitIntensity(
value=exp_val_in.value or 100.0,
unit=exp_unit_sel.value,
input_type="power_density" if pd else "flux",
)
exp_row.visible = True
exp_hint_pane.visible = pd
bb_help_pane.visible = False
bb_T_row.visible = False
conc_warn_pane.visible = False
bb_E_row.visible = False
emax_hint_pane.visible = False
else:
fields[i].intensity = BlackbodyIntensity(
T_source=T_in.value or 6000.0,
T_source_unit=T_unit.value,
concentration=conc_in.value or 1.0,
E_min=Emin_in.value or 0.0,
E_min_unit=Emin_unit.value,
E_max=None if Emax_inf_cb.value else (Emax_in.value or 0.0),
E_max_unit=Emax_unit.value,
)
exp_row.visible = False
exp_hint_pane.visible = False
bb_help_pane.visible = True
bb_T_row.visible = True
conc_warn_pane.visible = (conc_in.value or 0) > _CONC_THRESH
bb_E_row.visible = True
emax_hint_pane.visible = True
self.app.autosave()
mode_sel.param.watch(_on_mode, "value")
def _sync_explicit(i=idx):
if isinstance(fields[i].intensity, ExplicitIntensity):
fields[i].intensity.value = exp_val_in.value or 0.0
fields[i].intensity.unit = exp_unit_sel.value
# Refresh conversion hint
if exp_hint_pane.visible:
exp_hint_pane.object = _flux_hint()
self.app.autosave()
exp_val_in.param.watch(lambda e, i=idx: _sync_explicit(i), "value")
exp_unit_sel.param.watch(lambda e, i=idx: _sync_explicit(i), "value")
def _sync_bb(i=idx):
if isinstance(fields[i].intensity, BlackbodyIntensity):
b = fields[i].intensity
b.T_source = T_in.value or 0.0
b.T_source_unit = T_unit.value
b.concentration = conc_in.value or 0.0
b.E_min = Emin_in.value or 0.0
b.E_min_unit = Emin_unit.value
b.E_max = None if Emax_inf_cb.value else (Emax_in.value or 0.0)
b.E_max_unit = Emax_unit.value
conc_warn_pane.visible = (conc_in.value or 0) > _CONC_THRESH
self.app.autosave()
for w in (T_in, T_unit, conc_in, Emin_in, Emin_unit, Emax_in, Emax_unit):
w.param.watch(lambda e, i=idx: _sync_bb(i), "value")
def _on_emax_inf(event, i=idx):
Emax_in.visible = not event.new
Emax_unit.visible = not event.new
_sync_bb(i)
Emax_inf_cb.param.watch(_on_emax_inf, "value")
mode_row = pn.Row(
label("Intensity mode:", 96), mode_sel,
margin=0, styles={"gap": "4px", "align-items": "center"},
sizing_mode="stretch_width",
)
return pn.Column(
header, pe_row, mode_row, bb_help_pane,
exp_row, bb_T_row, conc_warn_pane, bb_E_row, emax_hint_pane,
sizing_mode="stretch_width",
styles={
"background": "#1a2435",
"border": "1px solid #2d3748",
"border-radius": "4px",
"padding": "8px",
"gap": "4px",
},
)