Skip to content

Commit 7b5d728

Browse files
Add OptogenSIMLight: Monte Carlo–based light model
* Add OptogenSIMLight prototype and compressed 4D dataset * Store dataset as gzipped NetCDF3 (scipy backend, no new dependency) * Load packaged data via importlib.resources; include data file in Poetry build * Add OptogenSIMLight to light visualization notebook * Add dedicated OptogenSIMLight transmittance test and viz_params coverage; export from cleo.light * Add OptogenSIMLight demonstration to multi-opto tutorial * Expose real backscatter behind source in OptogenSIMLight Zero transmittance only beyond the data's negative-z range rather than blanket-zeroing everything behind the source, preserving near-source backscatter. Update test to assert small positive backscatter within range and hard zero beyond it. * Regenerate dataset with per-wavelength tissue fix Previous sweep used stale tissue properties (fixed at startup wavelength) for all 78 runs. Fixed OptogenSIM GO loop to recompute tissue per wavelength. Penetration now traces hemoglobin absorption: shallowest at 410nm (0.33mm, Soret peak), deepest in red (~1.6mm), Q-band dip at 560nm. * Re-run light_viz notebook with regenerated dataset * Replace OpticFiber with OptogenSIMLight in multi-opto tutorial Both fibers (590nm amber, 470nm blue) now use OptogenSIMLight. Amber previously used OpticFiber blue coefficients scaled 0.8x as a 590nm stand-in; now uses real per-wavelength MC data. Removed redundant additive demo section. Narrative claims verified against regenerated figures. * Rename optogensim_light.py to optogensim.py per review * Clean notebooks: remove change markers, strip execution counts, fresh run * Restructure OptogenSIM: data as field, derived properties, add defocused_gaussian_beam constructor * Re-run notebooks against renamed OptogenSIM class * Update OptogenSIM reference in profile explorer notebook * Add OptogenSIM to transmittance test; add beam_radius and wavelength monotonicity tests * Tune multi_opto light amplitude to 0.6 mW/mm2 for OptogenSIM * Reduce docstring duplication in defocused_gaussian_beam Factory docstring now references OptogenSIM for wavelength/beam_radius rather than restating them, keeping parameter descriptions in one place. Retains data_path handling since the factory's role is file loading. * Remove unused enphr3 opsin from multi_opto tutorial * Move wavelength from Light to LightModel * Fix two_photon call site missed in wavelength refactor * Address review on optogensim.py + fix light_viz wavelength calls optogensim.py: - Remove unnecessary slots=False (cached_property works slotted) - Remove data_path from defocused_gaussian_beam - Expand factory + data docstrings (structure, units, ranges; repo link to follow) - Extend viz to show backscatter behind the source - Default n_points_per_source to 16000 to reduce marker size Also: declare xarray+plotly in pyproject; update light_viz Light(wavelength=) call sites to the new light-model wavelength API. * Extend beam-width transmittance test to fiber and GaussianEllipsoid Generalized test_OptogenSIM_T_increases_with_beam_radius into test_T_increases_with_beam_width, parametrized across fiber (R0), GaussianEllipsoid (sigma_lateral), and OptogenSIM (beam_radius), each with a model-appropriate target point. Addresses review comment. * Apply ruff formatting to optogensim.py Sort imports, remove unused imports (mm, Float), fix line formatting and trailing newline. Addresses review comment on formatting. * Tune multi_opto amplitude to 0.45 and use np.ptp for trace spacing Lower photostimulation amplitude (0.6 -> 0.45) for cleaner activity modulation without saturation. Replace hardcoded 1.3 trace offset with np.ptp of the irradiance range so spacing adapts to the amplitude. Addresses review comments. * Use OptogenSIM LightModel in profile explorer; fix heatmap aspect ratio Replace direct xarray loading with the OptogenSIM model (simpler, acts as an integration test). Set heatmap axes to equal scale (scaleanchor) so 1mm is the same vertically and horizontally. Note: marimo run still errors, but the cause is brian2 installing a SIGINT handler at import (fails in marimo run's worker thread), not the file-loading. marimo edit works. * Add OptogenSIM fork link to data docstring * Silence brian2 warning for discarded light-source objects When a second light is injected, init_register_light removes the old light_source_ng and light_prop_syns and rebuilds combined ones. brian2's BrianObject.__del__ warned these discarded objects were 'never included in a network' (fires when _network is None at GC). Set _network to network.id in _remove_brian_object after removal so the intentional discards don't warn. multi_opto now runs clean; full suite passes (269). * tweak visualization * format tests * Address final review comments - Add comment explaining the _network warning workaround in registry - Rework light_profile_explorer to call model.transmittance() with a coordinate grid instead of _rz_slice; plot raw transmittance (no scaling) - Recalibrate multi_opto amplitude (0.2 -> 8) for the merged aggregation fix * Warn when OptogenSIM targets fall outside the data range transmittance now warns if any r/z target coordinate is outside the simulated data range, and documents the behavior (r/z clipped to the data edge; z beyond the near edge zeroed). Adds a unit test checking the warning fires and out-of-range output stays finite in [0, 1]. * Zero OptogenSIM transmittance outside data range Out-of-range targets (r beyond far edge, z outside grid) now get transmittance 0 instead of the clipped edge value, and the warning says so. Targets nearer the axis than the first radial bin keep the near-axis value. far_behind test expects the warning. * Fix CI: drop setup-python, let setup-uv provide Python actions/setup-python's pip-cache post step fails on the Node 24 runners (pip cache dir missing; project uses uv, not pip). Bump checkout v3->v5 and setup-uv v5->v10, pass python-version through setup-uv instead. Also correct a stale peak-normalization comment in the profile explorer. * Test OptogenSIM packaged data is peak-normalized to 1 * Use setup-uv@v7 (no v10 major tag exists) * CI: prefer uv-managed Python (runner Pythons are PEP 668 locked) * CI: setup-uv v7 cancels on all runners, use known-good v5 * CI: install into uv venv instead of system python * CI: drop empty env block (invalid workflow) * CI: setup-uv already provides .venv, drop manual uv venv --------- Co-authored-by: Kyle Johnsen <johnsenkyle13@gmail.com>
1 parent 4a888af commit 7b5d728

17 files changed

Lines changed: 824 additions & 202 deletions

‎.github/workflows/test.yml‎

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,31 +27,23 @@ jobs:
2727
os: [macos-latest, windows-latest, ubuntu-latest]
2828
python: ["3.10", 3.11, 3.12, 3.13]
2929

30-
env:
31-
# use system python for uv
32-
UV_SYSTEM_PYTHON: 1
33-
3430
# Steps represent a sequence of tasks that will be executed as part of the job
3531
steps:
3632
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
37-
- uses: actions/checkout@v3
33+
- uses: actions/checkout@v5
3834

39-
- name: Install uv
35+
- name: Install uv and set up Python
4036
uses: astral-sh/setup-uv@v5
4137
with:
4238
enable-cache: true
43-
44-
- name: Set up Python
45-
uses: actions/setup-python@v4
46-
with:
4739
python-version: ${{ matrix.python }}
48-
cache: 'pip'
4940

5041
- name: Install package
42+
# setup-uv already created and activated a .venv above
5143
run: uv pip install .[dev]
5244

5345
- name: Run tests
54-
run: uv run pytest -n auto --ignore=docs/tutorials/optimal_ctrl.ipynb
46+
run: uv run --no-sync pytest -n auto --ignore=docs/tutorials/optimal_ctrl.ipynb
5547

5648
# - name: Setup tmate session # for optional debugging
5749
# uses: mxschmitt/action-tmate@v3

‎.gitignore‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,6 @@ dmypy.json
153153
.pytype/
154154

155155
# Cython debug symbols
156-
cython_debug/
156+
cython_debug/
157+
.DS_Store
158+
**/.DS_Store

‎cleo/light/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
OpticFiber,
66
fiber473nm,
77
)
8+
from cleo.light.optogensim import OptogenSIM, defocused_gaussian_beam
89
from cleo.light.light_dependence import (
910
LightDependent,
1011
cubic_interpolator,

‎cleo/light/data/__init__.py‎

Whitespace-only changes.
6.02 MB
Binary file not shown.

‎cleo/light/light.py‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@
4444
class LightModel(ABC):
4545
"""Defines how light propagates given a source location and direction."""
4646

47+
wavelength: Quantity = field(default=473 * nmeter, kw_only=True)
48+
"""Light wavelength with unit (usually nmeter)."""
49+
4750
@abstractmethod
4851
def transmittance(
4952
self,
@@ -225,6 +228,7 @@ def fiber473nm(
225228
K=0.125 / mm, # absorbance coefficient
226229
S=7.37 / mm, # scattering coefficient
227230
ntis=1.36, # tissue index of refraction
231+
wavelength=473 * nmeter,
228232
) -> OpticFiber:
229233
"""Returns an :class:`OpticFiber` model with parameters for 473 nm light.
230234
@@ -235,6 +239,7 @@ def fiber473nm(
235239
K=K,
236240
S=S,
237241
ntis=ntis,
242+
wavelength=wavelength,
238243
)
239244

240245

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

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

319322
@coords.validator
320323
def _check_coords(self, attribute, value):
@@ -369,6 +372,15 @@ def connect_to_neuron_group(
369372
)
370373
registry.register_light(self, neuron_group)
371374

375+
@property
376+
def wavelength(self) -> Quantity:
377+
"""Light wavelength, delegated to the LightModel"""
378+
return self.light_model.wavelength
379+
380+
@wavelength.setter
381+
def wavelength(self, value: Quantity):
382+
self.light_model.wavelength = value
383+
372384
@property
373385
def n(self):
374386
"""Number of light sources"""
@@ -402,6 +414,9 @@ def power_(self) -> Quantity:
402414
"""Returns history of light power without units (/mwatt)."""
403415
return self.power / mwatt
404416

417+
418+
419+
405420
def add_self_to_plot(self, ax, axis_scale_unit, **kwargs) -> list[PathCollection]:
406421
# show light with point field, assigning r and z coordinates
407422
# to all points

‎cleo/light/optogensim.py‎

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
from functools import cached_property
2+
from importlib.resources import files
3+
4+
import numpy as np
5+
import xarray as xr
6+
import warnings
7+
from attrs import define, field
8+
from brian2.units import Quantity, cm, nmeter, um
9+
10+
from cleo.coords import coords_from_xyz
11+
from cleo.light.light import LightModel
12+
from cleo.utilities import uniform_cylinder_rθz, xyz_from_rθz
13+
14+
15+
@define
16+
class OptogenSIM(LightModel):
17+
"""Light model from OptogenSIM Monte Carlo simulations."""
18+
19+
beam_radius: Quantity = 100 * um
20+
"""Beam (1/e^2) radius. Must be within the simulated grid range."""
21+
data: xr.DataArray = field(default=None, repr=False)
22+
"""The 4D transmittance dataset. Defaults to the dataset packaged with Cleo.
23+
24+
To supply your own, pass an :class:`xarray.DataArray` with dimensions
25+
``(wavelength, beam_size, r, z)`` in that order, with coordinate values in
26+
these units: ``wavelength`` in nm, ``beam_size`` (the beam radius) in µm,
27+
and ``r`` and ``z`` in cm, where ``z`` is measured from the top of the
28+
simulated tissue volume. Values are transmittance in [0, 1]. The packaged
29+
dataset spans wavelengths 380-740 nm (30 nm steps) and beam radii of 10, 20,
30+
100, 200, 400, and 800 µm; it was generated with a modified version of the
31+
OptogenSIM Monte Carlo model (https://github.com/akhilsnarra-ui/optogensim-cleo) sweeping over
32+
wavelength and beam radius."""
33+
34+
def __attrs_post_init__(self):
35+
if self.data is None:
36+
path = str(files("cleo.light.data") / "light_model_4d.nc.gz")
37+
self.data = xr.open_dataarray(path, engine="scipy")
38+
39+
@cached_property
40+
def _rz_slice(self):
41+
rz = self.data.interp(
42+
wavelength=self.wavelength / nmeter, beam_size=self.beam_radius / um
43+
)
44+
# data measures z from the atlas top; re-zero to the source
45+
z_source = float(rz.isel(r=0).z[rz.isel(r=0).argmax("z")])
46+
return rz.assign_coords(z=rz.z - z_source)
47+
48+
@cached_property
49+
def _r_range(self):
50+
return (float(self._rz_slice.r.min()), float(self._rz_slice.r.max()))
51+
52+
@cached_property
53+
def _z_range(self):
54+
return (float(self._rz_slice.z.min()), float(self._rz_slice.z.max()))
55+
56+
def transmittance(self, source_coords, source_dir_uvec, target_coords):
57+
assert np.allclose(np.linalg.norm(source_dir_uvec, axis=-1), 1)
58+
r, z = self._get_rz_for_xyz(source_coords, source_dir_uvec, target_coords)
59+
r_cm = np.asarray(r / cm)
60+
z_cm = np.asarray(z / cm)
61+
62+
# Warn if targets fall outside the simulated data range. Targets with
63+
# r beyond the far edge or z outside the data range get transmittance
64+
# 0. Targets nearer the axis than the first radial bin (r < r_min)
65+
# are clipped to the edge, i.e. take the near-axis peak value.
66+
# Data coverage may be extended in a future re-run.
67+
outside = (
68+
(r_cm > self._r_range[1])
69+
| (z_cm < self._z_range[0])
70+
| (z_cm > self._z_range[1])
71+
)
72+
if np.any(outside):
73+
warnings.warn(
74+
"Some target coordinates fall outside the OptogenSIM data range "
75+
f"(r: {self._r_range} cm, z: {self._z_range} cm). Transmittance "
76+
"is set to 0 outside the data range."
77+
)
78+
79+
T = self._rz_slice.interp(
80+
r=xr.DataArray(np.clip(r_cm, *self._r_range)),
81+
z=xr.DataArray(np.clip(z_cm, *self._z_range)),
82+
).values
83+
T = np.nan_to_num(T, nan=0.0)
84+
T[outside] = 0 # zero outside data range; keep real backscatter within it
85+
return T
86+
87+
@property
88+
def area0(self):
89+
return np.pi * self.beam_radius**2
90+
91+
def viz_params(
92+
self, coords, direction, T_threshold, n_points_per_source=1.5e5, **kwargs
93+
):
94+
r_thresh, zc_thresh, zc_back = self._find_rz_thresholds(T_threshold)
95+
# cylinder spans from behind the source (zc_back < 0) through zc_thresh
96+
total_length = zc_thresh - zc_back
97+
r, theta, zc = uniform_cylinder_rθz(n_points_per_source, r_thresh, total_length)
98+
start = coords + zc_back * direction
99+
end = coords + zc_thresh * direction
100+
x, y, z = xyz_from_rθz(r, theta, zc, start, end)
101+
density_factor = 3
102+
cyl_vol = np.pi * r_thresh**2 * total_length
103+
markersize = (cyl_vol / n_points_per_source * density_factor) ** (1 / 3)
104+
intensity_scale = 1.5 * (4e3 / n_points_per_source) ** (1 / 3)
105+
return coords_from_xyz(x, y, z), markersize, intensity_scale
106+
107+
def _find_rz_thresholds(self, thresh):
108+
"""Find r and z extents for visualization at the given transmittance threshold."""
109+
# On-axis (r[0]) profile along z (re-zeroed so source at z=0).
110+
on_axis = self._rz_slice.isel(r=0)
111+
z_vals = on_axis.z.values # cm, source at 0
112+
T_z = on_axis.values
113+
114+
# Forward extent (z >= 0): first z where T drops below threshold.
115+
fwd = z_vals >= 0
116+
z_pos, T_pos = z_vals[fwd], T_z[fwd]
117+
below = np.where(T_pos < thresh)[0]
118+
zc_thresh = (z_pos[below[0]] if len(below) > 0 else z_pos[-1]) * cm
119+
120+
# Backward extent (z < 0): most-negative z still above threshold,
121+
# so backscatter behind the source is included in the visualization.
122+
back = z_vals < 0
123+
z_neg, T_neg = z_vals[back], T_z[back]
124+
above_back = z_neg[T_neg >= thresh]
125+
zc_back = (float(above_back.min()) if len(above_back) > 0 else 0.0) * cm
126+
127+
# r threshold at the forward midpoint.
128+
z_mid = float(zc_thresh / cm) / 2
129+
z_mid = np.clip(
130+
z_mid, float(self._rz_slice.z.min()), float(self._rz_slice.z.max())
131+
)
132+
r_vals = self._rz_slice.r.values
133+
T_r = self._rz_slice.interp(z=float(z_mid)).values
134+
below_r = np.where(T_r < thresh)[0]
135+
r_thresh = (r_vals[below_r[0]] if len(below_r) > 0 else r_vals[-1]) * cm
136+
137+
return r_thresh * 1.2, zc_thresh, zc_back
138+
139+
140+
def defocused_gaussian_beam(
141+
wavelength: Quantity = 473 * nmeter,
142+
beam_radius: Quantity = 100 * um,
143+
) -> OptogenSIM:
144+
"""Construct an :class:`OptogenSIM` model for a defocused Gaussian beam.
145+
146+
This is the default OptogenSIM profile: light emitted from a defocused
147+
Gaussian beam source, propagating through gray matter, using the Monte
148+
Carlo dataset packaged with Cleo. ``wavelength`` and ``beam_radius`` are
149+
as documented on :class:`OptogenSIM`. To use a custom dataset, construct
150+
:class:`OptogenSIM` directly and pass your own ``data`` array.
151+
"""
152+
return OptogenSIM(wavelength=wavelength, beam_radius=beam_radius)

‎cleo/light/two_photon.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,7 @@ def tp_light_from_scope(scope, wavelength=1060 * nmeter, **kwargs) -> Light:
114114
light = Light(
115115
coords=coords,
116116
direction=scope.direction,
117-
light_model=GaussianEllipsoid(),
118-
wavelength=wavelength,
117+
light_model=GaussianEllipsoid(wavelength=wavelength),
119118
**kwargs,
120119
)
121120
return light

‎cleo/registry.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ def _add_brian_object(self, obj):
122122
def _remove_brian_object(self, obj):
123123
self.brian_objects.remove(obj)
124124
self.sim.network.remove(obj)
125+
# 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.
126+
obj._network = self.sim.network.id
125127

126128
def _get_or_create_light_prop_syn(
127129
self, ldd: "LightDependent", ng: NeuronGroup

‎docs/overview.md‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,6 @@ This requires injection of both a light source and an opsin---see the {doc}`tuto
133133
fiber = cleo.light.Light(
134134
coords=(0, 0, 0.5)*b2.mm,
135135
light_model=cleo.light.fiber473nm(),
136-
wavelength=473*b2.nmeter
137136
)
138137
chr2 = cleo.opto.chr2_4s()
139138
sim.inject(fiber, ng).inject(chr2, ng)

0 commit comments

Comments
 (0)