Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 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
113 changes: 113 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,113 @@
import xarray as xr
Comment thread
akhilsnarra-ui marked this conversation as resolved.
Outdated
import numpy as np
from functools import cached_property
from attrs import define, field
from brian2.units import Quantity, nmeter, um, cm, mm
from jaxtyping import Float
from cleo.light.light import LightModel
from cleo.utilities import uniform_cylinder_rθz, xyz_from_rθz
from cleo.coords import coords_from_xyz
from importlib.resources import files


@define(slots=False)
Comment thread
akhilsnarra-ui marked this conversation as resolved.
Outdated
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 (wavelength, beam_size, r, z) dataset. Defaults to the dataset
Comment thread
kjohnsen marked this conversation as resolved.
Outdated
packaged with Cleo if not provided."""

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,
Comment thread
akhilsnarra-ui marked this conversation as resolved.
Outdated
n_points_per_source=4000, **kwargs):
r_thresh, zc_thresh = self._find_rz_thresholds(T_threshold)
r, theta, zc = uniform_cylinder_rθz(n_points_per_source, r_thresh, zc_thresh)
end = coords + zc_thresh * direction
x, y, z = xyz_from_rθz(r, theta, zc, coords, end)
density_factor = 3
cyl_vol = np.pi * r_thresh**2 * zc_thresh
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
# Only look in the forward (z >= 0) direction.
mask = z_vals >= 0
z_pos = z_vals[mask]
Comment thread
akhilsnarra-ui marked this conversation as resolved.
Outdated
T_pos = T_z[mask]
below = np.where(T_pos < thresh)[0]
zc_thresh = (z_pos[below[0]] if len(below) > 0 else z_pos[-1]) * cm

# Find r threshold at z = zc_thresh / 2.
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


def defocused_gaussian_beam(
wavelength: Quantity = 473 * nmeter,
beam_radius: Quantity = 100 * um,
data_path: str = None,
Comment thread
akhilsnarra-ui marked this conversation as resolved.
Outdated
) -> OptogenSIM:
"""Construct an :class:`OptogenSIM` light model from a dataset file.
Comment thread
kjohnsen marked this conversation as resolved.
Outdated

``wavelength`` and ``beam_radius`` are as documented on
:class:`OptogenSIM`. ``data_path`` is an optional path to a 4D dataset;
if None, the dataset packaged with Cleo is used.
"""
if data_path is None:
data_path = str(files("cleo.light.data") / "light_model_4d.nc.gz")
data = xr.open_dataarray(data_path, engine="scipy")
return OptogenSIM(wavelength=wavelength, beam_radius=beam_radius, data=data)
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
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
94 changes: 69 additions & 25 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.

130 changes: 80 additions & 50 deletions docs/tutorials/optogenetics.ipynb

Large diffs are not rendered by default.

118 changes: 118 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,118 @@
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 xarray as xr
import plotly.graph_objects as go
Comment thread
kjohnsen marked this conversation as resolved.

return go, np, xr


@app.cell
def _(xr):
from importlib.resources import files
_path = str(files("cleo.light.data") / "light_model_4d.nc.gz")
da = xr.open_dataarray(_path, engine="scipy")
da.load()
return (da,)


@app.cell
def _(da, mo):
wl_vals = da.wavelength.values
bs_vals = da.beam_size.values
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 _(beam, da, go, np, wavelength):
sl = da.interp(wavelength=wavelength.value, beam_size=beam.value)

r = sl.r.values * 10.0 # cm -> mm
z = sl.z.values * 10.0 # cm -> mm

on_axis = sl.isel(r=0).values
z0 = z[int(np.argmax(on_axis))]
z_rel = z - z0

T = sl.values
T = T / np.nanmax(T)
Comment thread
kjohnsen marked this conversation as resolved.
Outdated
logT = np.log10(np.clip(T, 1e-4, None))

r_full = np.concatenate([-r[::-1], r])
logT_full = np.vstack([logT[::-1, :], logT])

fig = go.Figure(
data=go.Heatmap(
x=z_rel,
y=r_full,
z=logT_full,
colorscale="Viridis",
zmin=-4, zmax=0,
colorbar=dict(title="log₁₀ T/peak"),
)
)
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
)
fig.update_xaxes(range=[float(z_rel.min()), min(float(z_rel.max()), 3)])
fig
return


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

return (mo,)


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

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ dev = [

[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