Skip to content
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
14aa1b8
Add OptogenSIMLight prototype and compressed 4D dataset
akhilsnarra-ui Jul 11, 2026
57bfebe
Store dataset as gzipped NetCDF3 (scipy backend, no new dependency)
akhilsnarra-ui Jul 15, 2026
3d89dd8
Load packaged data via importlib.resources; include data file in Poet…
akhilsnarra-ui Jul 16, 2026
3abb054
Add OptogenSIMLight to light visualization notebook
akhilsnarra-ui Jul 18, 2026
3db06a3
Add dedicated OptogenSIMLight transmittance test and viz_params cover…
akhilsnarra-ui Jul 20, 2026
9317a61
Add OptogenSIMLight demonstration to multi-opto tutorial
akhilsnarra-ui Jul 21, 2026
bed38a7
Expose real backscatter behind source in OptogenSIMLight
akhilsnarra-ui Jul 25, 2026
df44ed5
Regenerate dataset with per-wavelength tissue fix
akhilsnarra-ui Jul 25, 2026
4629a3f
Re-run light_viz notebook with regenerated dataset
akhilsnarra-ui Jul 25, 2026
fa6aeed
Replace OpticFiber with OptogenSIMLight in multi-opto tutorial
akhilsnarra-ui Jul 25, 2026
e684943
Rename optogensim_light.py to optogensim.py per review
akhilsnarra-ui Aug 5, 2026
80fb94e
Clean notebooks: remove change markers, strip execution counts, fresh…
akhilsnarra-ui Aug 5, 2026
f2a58d6
Restructure OptogenSIM: data as field, derived properties, add defocu…
akhilsnarra-ui Aug 6, 2026
2329245
Re-run notebooks against renamed OptogenSIM class
akhilsnarra-ui Aug 6, 2026
e26da6c
Update OptogenSIM reference in profile explorer notebook
akhilsnarra-ui Aug 6, 2026
06efe53
Add OptogenSIM to transmittance test; add beam_radius and wavelength …
akhilsnarra-ui Aug 6, 2026
62c6ea9
Tune multi_opto light amplitude to 0.6 mW/mm2 for OptogenSIM
akhilsnarra-ui Aug 6, 2026
aa6acab
Reduce docstring duplication in defocused_gaussian_beam
akhilsnarra-ui Aug 6, 2026
069a4fb
Remove unused enphr3 opsin from multi_opto tutorial
akhilsnarra-ui Aug 6, 2026
f88c835
Move wavelength from Light to LightModel
akhilsnarra-ui Aug 7, 2026
56d081b
Fix two_photon call site missed in wavelength refactor
akhilsnarra-ui Aug 8, 2026
3cf2aec
Address review on optogensim.py + fix light_viz wavelength calls
akhilsnarra-ui Aug 14, 2026
d280664
Extend beam-width transmittance test to fiber and GaussianEllipsoid
akhilsnarra-ui Aug 17, 2026
8b2fc0e
Apply ruff formatting to optogensim.py
akhilsnarra-ui Aug 18, 2026
81dd5e5
Tune multi_opto amplitude to 0.45 and use np.ptp for trace spacing
akhilsnarra-ui Aug 19, 2026
47a6540
Use OptogenSIM LightModel in profile explorer; fix heatmap aspect ratio
akhilsnarra-ui Aug 21, 2026
838a676
Add OptogenSIM fork link to data docstring
akhilsnarra-ui Aug 26, 2026
d6319a0
Silence brian2 warning for discarded light-source objects
akhilsnarra-ui Aug 28, 2026
9949c01
tweak visualization
kjohnsen Aug 31, 2026
5e10b0e
format tests
kjohnsen Aug 31, 2026
b42b63b
Merge remote-tracking branch 'origin/master' into light-model-improve…
akhilsnarra-ui Sep 5, 2026
3b60daf
Address final review comments
akhilsnarra-ui Sep 12, 2026
9b0e017
Warn when OptogenSIM targets fall outside the data range
akhilsnarra-ui Sep 19, 2026
24e74f1
Zero OptogenSIM transmittance outside data range
kjohnsen Oct 3, 2026
5bdc7b4
Fix CI: drop setup-python, let setup-uv provide Python
kjohnsen Oct 3, 2026
8ff3df5
Test OptogenSIM packaged data is peak-normalized to 1
kjohnsen Oct 3, 2026
e51e7ff
Use setup-uv@v7 (no v10 major tag exists)
kjohnsen Oct 3, 2026
3213f9e
CI: prefer uv-managed Python (runner Pythons are PEP 668 locked)
kjohnsen Oct 3, 2026
4fc4657
CI: setup-uv v7 cancels on all runners, use known-good v5
kjohnsen Oct 3, 2026
193d00e
CI: install into uv venv instead of system python
kjohnsen Oct 3, 2026
69c46e4
CI: drop empty env block (invalid workflow)
kjohnsen Oct 3, 2026
6e8b96b
CI: setup-uv already provides .venv, drop manual uv venv
kjohnsen Oct 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,6 @@ dmypy.json
.pytype/

# Cython debug symbols
cython_debug/
cython_debug/
.DS_Store
**/.DS_Store
1 change: 1 addition & 0 deletions cleo/light/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
OpticFiber,
fiber473nm,
)
from cleo.light.optogensim import OptogenSIM, defocused_gaussian_beam
from cleo.light.light_dependence import (
LightDependent,
cubic_interpolator,
Expand Down
Empty file added cleo/light/data/__init__.py
Empty file.
Binary file added cleo/light/data/light_model_4d.nc.gz
Binary file not shown.
19 changes: 17 additions & 2 deletions cleo/light/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@
class LightModel(ABC):
"""Defines how light propagates given a source location and direction."""

wavelength: Quantity = field(default=473 * nmeter, kw_only=True)
"""Light wavelength with unit (usually nmeter)."""

@abstractmethod
def transmittance(
self,
Expand Down Expand Up @@ -225,6 +228,7 @@ def fiber473nm(
K=0.125 / mm, # absorbance coefficient
S=7.37 / mm, # scattering coefficient
ntis=1.36, # tissue index of refraction
wavelength=473 * nmeter,
) -> OpticFiber:
"""Returns an :class:`OpticFiber` model with parameters for 473 nm light.

Expand All @@ -235,6 +239,7 @@ def fiber473nm(
K=K,
S=S,
ntis=ntis,
wavelength=wavelength,
)


Expand Down Expand Up @@ -313,8 +318,6 @@ class Light(Stimulator):
Can also be an nx3 array for multiple sources.
"""

wavelength: Quantity = field(default=473 * nmeter, kw_only=True)
"""light wavelength with unit (usually nmeter)"""

@coords.validator
def _check_coords(self, attribute, value):
Expand Down Expand Up @@ -369,6 +372,15 @@ def connect_to_neuron_group(
)
registry.register_light(self, neuron_group)

@property
def wavelength(self) -> Quantity:
"""Light wavelength, delegated to the LightModel"""
return self.light_model.wavelength

@wavelength.setter
def wavelength(self, value: Quantity):
self.light_model.wavelength = value

@property
def n(self):
"""Number of light sources"""
Expand Down Expand Up @@ -402,6 +414,9 @@ def power_(self) -> Quantity:
"""Returns history of light power without units (/mwatt)."""
return self.power / mwatt


Comment thread
akhilsnarra-ui marked this conversation as resolved.


def add_self_to_plot(self, ax, axis_scale_unit, **kwargs) -> list[PathCollection]:
# show light with point field, assigning r and z coordinates
# to all points
Expand Down
135 changes: 135 additions & 0 deletions cleo/light/optogensim.py
Comment thread
akhilsnarra-ui marked this conversation as resolved.
Comment thread
kjohnsen marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
from functools import cached_property
from importlib.resources import files

import numpy as np
import xarray as xr
from attrs import define, field
from brian2.units import Quantity, cm, nmeter, um

from cleo.coords import coords_from_xyz
from cleo.light.light import LightModel
from cleo.utilities import uniform_cylinder_rθz, xyz_from_rθz


@define
class OptogenSIM(LightModel):
"""Light model from OptogenSIM Monte Carlo simulations."""

beam_radius: Quantity = 100 * um
"""Beam (1/e^2) radius. Must be within the simulated grid range."""
data: xr.DataArray = field(default=None, repr=False)
"""The 4D transmittance dataset. Defaults to the dataset packaged with Cleo.

To supply your own, pass an :class:`xarray.DataArray` with dimensions
``(wavelength, beam_size, r, z)`` in that order, with coordinate values in
these units: ``wavelength`` in nm, ``beam_size`` (the beam radius) in µm,
and ``r`` and ``z`` in cm, where ``z`` is measured from the top of the
simulated tissue volume. Values are transmittance in [0, 1]. The packaged
dataset spans wavelengths 380-740 nm (30 nm steps) and beam radii of 10, 20,
100, 200, 400, and 800 µm; it was generated with a modified version of the
OptogenSIM Monte Carlo model (https://github.com/akhilsnarra-ui/optogensim-cleo) sweeping over
wavelength and beam radius."""

def __attrs_post_init__(self):
if self.data is None:
path = str(files("cleo.light.data") / "light_model_4d.nc.gz")
self.data = xr.open_dataarray(path, engine="scipy")

@cached_property
def _rz_slice(self):
rz = self.data.interp(
wavelength=self.wavelength / nmeter, beam_size=self.beam_radius / um
Comment thread
akhilsnarra-ui marked this conversation as resolved.
)
# data measures z from the atlas top; re-zero to the source
z_source = float(rz.isel(r=0).z[rz.isel(r=0).argmax("z")])
Comment thread
akhilsnarra-ui marked this conversation as resolved.
return rz.assign_coords(z=rz.z - z_source)

@cached_property
def _r_range(self):
return (float(self._rz_slice.r.min()), float(self._rz_slice.r.max()))

@cached_property
def _z_range(self):
return (float(self._rz_slice.z.min()), float(self._rz_slice.z.max()))

def transmittance(self, source_coords, source_dir_uvec, target_coords):
assert np.allclose(np.linalg.norm(source_dir_uvec, axis=-1), 1)
r, z = self._get_rz_for_xyz(source_coords, source_dir_uvec, target_coords)
r_cm = np.asarray(r / cm)
z_cm = np.asarray(z / cm)
T = self._rz_slice.interp(
r=xr.DataArray(np.clip(r_cm, *self._r_range)),
z=xr.DataArray(np.clip(z_cm, *self._z_range)),
).values
T = np.nan_to_num(T, nan=0.0)
T[z_cm < self._z_range[0]] = (
0 # zero only beyond data range; keep real backscatter within it
)
return T

@property
def area0(self):
return np.pi * self.beam_radius**2

def viz_params(
self, coords, direction, T_threshold, n_points_per_source=1.5e5, **kwargs
):
r_thresh, zc_thresh, zc_back = self._find_rz_thresholds(T_threshold)
# cylinder spans from behind the source (zc_back < 0) through zc_thresh
total_length = zc_thresh - zc_back
r, theta, zc = uniform_cylinder_rθz(n_points_per_source, r_thresh, total_length)
start = coords + zc_back * direction
end = coords + zc_thresh * direction
x, y, z = xyz_from_rθz(r, theta, zc, start, end)
density_factor = 3
cyl_vol = np.pi * r_thresh**2 * total_length
markersize = (cyl_vol / n_points_per_source * density_factor) ** (1 / 3)
intensity_scale = 1.5 * (4e3 / n_points_per_source) ** (1 / 3)
return coords_from_xyz(x, y, z), markersize, intensity_scale

def _find_rz_thresholds(self, thresh):
"""Find r and z extents for visualization at the given transmittance threshold."""
# On-axis (r[0]) profile along z (re-zeroed so source at z=0).
on_axis = self._rz_slice.isel(r=0)
z_vals = on_axis.z.values # cm, source at 0
T_z = on_axis.values

# Forward extent (z >= 0): first z where T drops below threshold.
fwd = z_vals >= 0
z_pos, T_pos = z_vals[fwd], T_z[fwd]
below = np.where(T_pos < thresh)[0]
zc_thresh = (z_pos[below[0]] if len(below) > 0 else z_pos[-1]) * cm

# Backward extent (z < 0): most-negative z still above threshold,
# so backscatter behind the source is included in the visualization.
back = z_vals < 0
z_neg, T_neg = z_vals[back], T_z[back]
above_back = z_neg[T_neg >= thresh]
zc_back = (float(above_back.min()) if len(above_back) > 0 else 0.0) * cm

# r threshold at the forward midpoint.
z_mid = float(zc_thresh / cm) / 2
z_mid = np.clip(
z_mid, float(self._rz_slice.z.min()), float(self._rz_slice.z.max())
)
r_vals = self._rz_slice.r.values
T_r = self._rz_slice.interp(z=float(z_mid)).values
below_r = np.where(T_r < thresh)[0]
r_thresh = (r_vals[below_r[0]] if len(below_r) > 0 else r_vals[-1]) * cm

return r_thresh * 1.2, zc_thresh, zc_back


def defocused_gaussian_beam(
wavelength: Quantity = 473 * nmeter,
beam_radius: Quantity = 100 * um,
) -> OptogenSIM:
"""Construct an :class:`OptogenSIM` model for a defocused Gaussian beam.

This is the default OptogenSIM profile: light emitted from a defocused
Gaussian beam source, propagating through gray matter, using the Monte
Carlo dataset packaged with Cleo. ``wavelength`` and ``beam_radius`` are
as documented on :class:`OptogenSIM`. To use a custom dataset, construct
:class:`OptogenSIM` directly and pass your own ``data`` array.
"""
return OptogenSIM(wavelength=wavelength, beam_radius=beam_radius)
3 changes: 1 addition & 2 deletions cleo/light/two_photon.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,7 @@ def tp_light_from_scope(scope, wavelength=1060 * nmeter, **kwargs) -> Light:
light = Light(
coords=coords,
direction=scope.direction,
light_model=GaussianEllipsoid(),
wavelength=wavelength,
light_model=GaussianEllipsoid(wavelength=wavelength),
**kwargs,
)
return light
2 changes: 2 additions & 0 deletions cleo/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ def _add_brian_object(self, obj):
def _remove_brian_object(self, obj):
self.brian_objects.remove(obj)
self.sim.network.remove(obj)
# brian2's BrianObject.__del__ warns if _network is None when the object is garbage-collected ("never included in a network"). This object was in the network and is being deliberately discarded (replaced by a combined light source), so set _network to the network's id to suppress the spurious warning.
obj._network = self.sim.network.id
Comment thread
kjohnsen marked this conversation as resolved.

def _get_or_create_light_prop_syn(
self, ldd: "LightDependent", ng: NeuronGroup
Expand Down
1 change: 0 additions & 1 deletion docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,6 @@ This requires injection of both a light source and an opsin---see the {doc}`tuto
fiber = cleo.light.Light(
coords=(0, 0, 0.5)*b2.mm,
light_model=cleo.light.fiber473nm(),
wavelength=473*b2.nmeter
)
chr2 = cleo.opto.chr2_4s()
sim.inject(fiber, ng).inject(chr2, ng)
Expand Down
124 changes: 61 additions & 63 deletions docs/tutorials/multi_opto.ipynb
Comment thread
akhilsnarra-ui marked this conversation as resolved.
Comment thread
kjohnsen marked this conversation as resolved.
Comment thread
kjohnsen marked this conversation as resolved.
Comment thread
kjohnsen marked this conversation as resolved.

Large diffs are not rendered by default.

128 changes: 79 additions & 49 deletions docs/tutorials/optogenetics.ipynb

Large diffs are not rendered by default.

126 changes: 126 additions & 0 deletions notebooks/light_profile_explorer.py
Comment thread
kjohnsen marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import marimo

__generated_with = "0.23.14"
app = marimo.App(width="medium")


@app.cell
def _():
return


@app.cell
def _(mo):
mo.md("""
# OptogenSIM profile explorer

Interactive 2D cross-sections of the OptogenSIM Monte Carlo light model.
Drag the sliders to set a wavelength and beam radius and see how light
propagates through gray matter. The heatmap shows log₁₀ transmittance in
the (r, z) plane, mirrored across the beam axis. z is depth from the
source; r is radial distance.

*Note: values between simulated grid points (wavelengths every 30 nm;
beam radii 10, 20, 100, 200, 400, 800 µm) are linearly interpolated.
""")
return


@app.cell
def _():
import numpy as np
import plotly.graph_objects as go
Comment thread
kjohnsen marked this conversation as resolved.
from cleo.light import OptogenSIM
from brian2 import mm, nmeter, um

return OptogenSIM, go, mm, nmeter, np, um


@app.cell
def _(OptogenSIM):
_m = OptogenSIM() # default instance, just to read the packaged data grid
wl_vals = _m.data.wavelength.values
bs_vals = _m.data.beam_size.values
return bs_vals, wl_vals


@app.cell
def _(bs_vals, mo, wl_vals):
wavelength = mo.ui.slider(
start=float(wl_vals.min()),
stop=float(wl_vals.max()),
step=5,
value=470.0,
label="Wavelength (nm)",
show_value=True,
)
beam = mo.ui.slider(
start=float(bs_vals.min()),
stop=float(bs_vals.max()),
step=10,
value=200.0,
label="Beam radius (µm)",
show_value=True,
)
mo.hstack([wavelength, beam], justify="start")
return beam, wavelength


@app.cell
def _(OptogenSIM, beam, go, mm, nmeter, np, um, wavelength):
model = OptogenSIM(
wavelength=wavelength.value * nmeter,
beam_radius=beam.value * um,
)

# build an (r, z) grid of target points, as a user would: real coordinates
# with brian2 units. Source at origin pointing in +z.
r_mm = np.linspace(0, 3, 120)
z_mm = np.linspace(-1, 6, 200)
R, Z = np.meshgrid(r_mm, z_mm, indexing="ij")
grid_coords = np.stack([R.ravel(), np.zeros(R.size), Z.ravel()], axis=-1) * mm

source = np.array([0, 0, 0]) * mm
direction = np.array([0, 0, 1.0])

T = model.transmittance(source, direction, grid_coords).reshape(R.shape)

# normalize to peak for display (colorbar is log10 T/peak)

logT = np.log10(np.clip(T, 1e-4, None))

# mirror across the beam axis for a symmetric view
r_full = np.concatenate([-r_mm[::-1], r_mm])
logT_full = np.vstack([logT[::-1, :], logT])

fig = go.Figure(
data=go.Heatmap(
x=z_mm,
y=r_full,
z=logT_full,
colorscale="Viridis",
zmin=-4, zmax=0,
colorbar=dict(title="log₁₀ T"),
)
)
fig.update_layout(
title=f"{wavelength.value:.0f} nm, {beam.value:.0f} µm beam radius",
xaxis_title="z, depth from source (mm)",
yaxis_title="r, radial distance (mm)",
width=750, height=450,
Comment thread
kjohnsen marked this conversation as resolved.
Outdated
yaxis=dict(scaleanchor="x", scaleratio=1),
)
fig.update_xaxes(range=[float(z_mm.min()), min(float(z_mm.max()), 3)])
fig
return


@app.cell
def _():
import marimo as mo

return (mo,)


if __name__ == "__main__":
app.run()
319 changes: 252 additions & 67 deletions notebooks/light_viz.ipynb
Comment thread
kjohnsen marked this conversation as resolved.

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dependencies = [
"scipy>=1.7.2,<1.14.1; python_version<'3.13'",
"scipy>=1.14.1; python_version>='3.13'",
"numpy >= 2.1.0,<3.0.0; python_version>='3.13'",
"xarray>=2025.1.0",
"tklfp>=0.3,<0.4",
"attrs>=23.0,<24.0",
"neo>=0.12.0,<0.13.0",
Expand All @@ -40,11 +41,13 @@ dev = [
"ruff>=0.1.15,<1.0.0",
"pytest-xdist>=3.5.0,<4.0.0",
"marimo>=0.11.20,<1.0.0",
"plotly>=5.0.0,<7.0.0",
"cvxpy>=1.6.5,<2.0.0",
]

[tool.poetry]
packages = [{ include = "cleo" }]
include = [{ path = "cleo/light/data/*.nc.gz", format = ["sdist", "wheel"] }]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Science/Research",
Expand Down
Loading
Loading