Skip to content

Commit e65827c

Browse files
peterhollenderebrahimebrahim
authored andcommitted
Add temperature estimation #422
add temperature estimation make estimation function independently importable
1 parent 7b17ec4 commit e65827c

3 files changed

Lines changed: 263 additions & 3 deletions

File tree

src/openlifu/plan/solution.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
find_centroid,
2323
get_beamwidth,
2424
get_mask,
25+
model_tx_temperature_rise,
2526
)
2627
from openlifu.sim import SimSetup, run_simulation
2728
from openlifu.util.annotations import OpenLIFUFieldData
@@ -30,6 +31,7 @@
3031
from openlifu.util.units import getunitconversion, rescale_coords, rescale_data_arr
3132
from openlifu.xdc import Transducer
3233

34+
logger = logging.getLogger(__name__)
3335

3436
def _construct_nc_filepath_from_json_filepath(json_filepath:Path) -> Path:
3537
"""Construct a default filepath to netCDF file given filepath to associated solution json file."""
@@ -342,10 +344,45 @@ def analyze(self,
342344
solution_analysis.TIC = np.mean(TIC)
343345
solution_analysis.voltage_V = self.voltage
344346
solution_analysis.power_W = np.mean(power_W)
345-
347+
solution_analysis.estimated_tx_temperature_rise_C = self.estimate_tx_temperature_rise(
348+
t_sec=solution_analysis.sequence_duration_s,
349+
)
346350
solution_analysis.param_constraints = param_constraints
347351
return solution_analysis
348352

353+
def estimate_tx_temperature_rise(self,
354+
t_sec: float,
355+
T0_degC: float = 30.0):
356+
357+
"""
358+
Standalone temperature prediction function for thermal modeling.
359+
360+
Based on physics-based power decay gradient model fitted from experimental data.
361+
Model: dT/dt = (t + t_shift)^(-n) + C
362+
363+
Parameters:
364+
-----------
365+
self: Solution
366+
The treatment solution containing voltage and pulse information.
367+
t_sec: float
368+
Time in seconds for which to predict temperature rise.
369+
T0_degC: float
370+
Initial temperature in Celsius. Default is 30.0°C.
371+
372+
Returns:
373+
--------
374+
float
375+
Predicted temperature rise in Celsius
376+
"""
377+
return model_tx_temperature_rise(
378+
voltage=self.voltage,
379+
t_sec=t_sec,
380+
duty_cycle=self.get_sequence_dutycycle(),
381+
apodization_fraction=np.mean(self.apodizations),
382+
frequency_kHz=self.pulse.frequency / 1e3,
383+
T0_degC=T0_degC,
384+
)
385+
349386
def compute_scaling_factors(
350387
self,
351388
focal_pattern: FocalPattern,

src/openlifu/plan/solution_analysis.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import json
4+
import logging
45
from dataclasses import dataclass, field
56
from typing import Annotated, Any, Dict, Tuple, Type
67

@@ -13,6 +14,8 @@
1314
from openlifu.util.dict_conversion import DictMixin
1415
from openlifu.util.units import getunitconversion, getunittype
1516

17+
logger = logging.getLogger(__name__)
18+
1619
DEFAULT_ORIGIN = np.zeros(3)
1720

1821
PARAM_FORMATS = {
@@ -46,7 +49,8 @@
4649
"power_W": [None, "0.2f", "W", "Emitted Power"],
4750
"duty_cycle_pulse_train_pct": [None, "0.1f", "%", "Pulse Train Duty Cycle"],
4851
"duty_cycle_sequence_pct": [None, "0.1f", "%", "Sequence Duty Cycle"],
49-
"sequence_duration_s": [None, "0.0f", "s", "Sequence Duration"],}
52+
"sequence_duration_s": [None, "0.0f", "s", "Sequence Duration"],
53+
"estimated_tx_temperature_rise_C": [None, "0.2f", "°C", "Estimated TX Temperature Rise"]}
5054

5155
@dataclass
5256
class SolutionAnalysis(DictMixin):
@@ -143,6 +147,8 @@ class SolutionAnalysis(DictMixin):
143147
sequence_duration_s: Annotated[float | None, OpenLIFUFieldData("Sequence duration (s)", "Total duration of the sequence (s)")] = None
144148
"""Total duration of the sequence (s)"""
145149

150+
estimated_tx_temperature_rise_C: Annotated[float | None, OpenLIFUFieldData("Estimated TX temperature rise (°C)", "Estimated temperature rise (assume 30°C baseline)")] = None
151+
"""Estimated temperature rise in Celsius (assume 30°C baseline)"""
146152

147153
param_constraints: Annotated[Dict[str, ParameterConstraint], OpenLIFUFieldData("Parameter constraints", None)] = field(default_factory=dict)
148154
"""TODO: Add description"""
@@ -576,3 +582,69 @@ def get_beamwidth(
576582
negoff, posoff = get_beam_bounds(da, focus=focus, dim=dim, cutoff=float(cutoff), origin=origin, min_offset=min_offset, max_offset=max_offset)
577583
bw = posoff - negoff
578584
return bw
585+
586+
def model_tx_temperature_rise(voltage: float,
587+
t_sec: float,
588+
duty_cycle: float=1.0,
589+
apodization_fraction: float=1.0,
590+
frequency_kHz: float=400.0,
591+
T0_degC: float = 30.0):
592+
"""
593+
Temperature prediction function for thermal modeling.
594+
595+
Based on physics-based power decay gradient model fitted from experimental data.
596+
Model: dT/dt = (t + t_shift)^(-n) + C
597+
598+
Parameters:
599+
-----------
600+
voltage: float
601+
Voltage to use when running sonication.
602+
duty_cycle: float
603+
Duty cycle of the sonication.
604+
apodization_fraction: float
605+
Fraction of apodization applied.
606+
frequency_kHz: float
607+
Frequency in kHz.
608+
t_sec: float
609+
Time in seconds for which to predict temperature rise.
610+
T0_degC: float
611+
Initial temperature in Celsius. Default is 30.0°C.
612+
613+
Returns:
614+
--------
615+
float
616+
Predicted temperature rise in Celsius
617+
"""
618+
P = voltage**2 * duty_cycle
619+
P = P * apodization_fraction
620+
t = t_sec
621+
T0 = T0_degC
622+
623+
if T0 < 20 or T0 > 40:
624+
logger.warning("Initial temperature T0 must be between 20 and 40 degrees Celsius for the model to be valid.")
625+
626+
if P < 50 or P > 500:
627+
logger.warning("Squared Voltage must be between 50 and 500 V^2 for the model to be valid.")
628+
629+
if t < 1 or t > 600:
630+
logger.warning("Time t must be between 1 and 600 seconds for the model to be valid.")
631+
632+
if frequency_kHz < 380 or frequency_kHz > 420:
633+
logger.warning("Frequency must be between 380 and 420 kHz for the model to be valid.")
634+
635+
# Predict power law parameters using polynomial regression (degree 2)
636+
n = (2.131832 + -0.003475*P + -0.044916*T0 +
637+
0.000002*P*P + 0.000049*P*T0 + 0.000474*T0*T0)
638+
639+
t_shift = (1.074525 + 0.101532*P + -0.097741*T0 +
640+
-0.000080*P*P + -0.001891*P*T0 + 0.005975*T0*T0)
641+
642+
C = (0.051572 + -0.000072*P + -0.001668*T0 +
643+
-0.000000*P*P + 0.000004*P*T0 + 0.000007*T0*T0)
644+
645+
# Apply integrated power decay gradient model
646+
t_adj = t + t_shift
647+
integral_term = (t_adj**(1-n) - t_shift**(1-n)) / (1 - n)
648+
temperature_rise_C = integral_term + C * t
649+
650+
return temperature_rise_C

tests/test_solution_analysis.py

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
from __future__ import annotations
22

3+
import logging
4+
35
import pytest
46

57
from openlifu.plan.param_constraint import ParameterConstraint
6-
from openlifu.plan.solution_analysis import SolutionAnalysis, SolutionAnalysisOptions
8+
from openlifu.plan.solution_analysis import (
9+
SolutionAnalysis,
10+
SolutionAnalysisOptions,
11+
model_tx_temperature_rise,
12+
)
713

814
# ---- Tests for SolutionAnalysis ----
915

@@ -80,3 +86,148 @@ def test_to_dict_from_dict_solution_analysis_options(example_solution_analysis_o
8086
options_dict = example_solution_analysis_options.to_dict()
8187
new_options = SolutionAnalysisOptions.from_dict(options_dict)
8288
assert new_options == example_solution_analysis_options
89+
90+
91+
# ---- Tests for model_tx_temperature_rise ----
92+
93+
# Valid mid-range parameters used as a baseline throughout the tests.
94+
# P = voltage^2 * duty_cycle = 14^2 * 1.0 = 196 V^2 (valid range: 50-500)
95+
_BASE_VOLTAGE = 14.0 # V
96+
_LOW_VOLTAGE = 8.0 # V → P = 64 V^2 (near low end of valid range)
97+
_HIGH_VOLTAGE = 21.0 # V → P = 441 V^2 (near high end of valid range)
98+
_BASE_T0 = 30.0 # °C (mid-range of valid 20-40 °C)
99+
_BASE_FREQ = 400.0 # kHz (centre of valid 380-420 kHz)
100+
101+
102+
def test_low_voltage_less_heating_than_high_voltage():
103+
"""Higher voltage (more power) should produce a greater temperature rise."""
104+
t = 120.0 # mid-range time, well within valid 1-600 s
105+
rise_low = model_tx_temperature_rise(_LOW_VOLTAGE, t, T0_degC=_BASE_T0, frequency_kHz=_BASE_FREQ)
106+
rise_high = model_tx_temperature_rise(_HIGH_VOLTAGE, t, T0_degC=_BASE_T0, frequency_kHz=_BASE_FREQ)
107+
assert rise_low < rise_high, (
108+
f"Expected lower voltage ({_LOW_VOLTAGE} V, rise={rise_low:.4f} °C) to produce "
109+
f"less heating than higher voltage ({_HIGH_VOLTAGE} V, rise={rise_high:.4f} °C)"
110+
)
111+
112+
113+
def test_little_heating_right_after_start():
114+
"""Temperature rise at t=1 s (just started) should be much less than at t=300 s."""
115+
rise_early = model_tx_temperature_rise(_BASE_VOLTAGE, t_sec=1.0, T0_degC=_BASE_T0)
116+
rise_later = model_tx_temperature_rise(_BASE_VOLTAGE, t_sec=300.0, T0_degC=_BASE_T0)
117+
assert rise_early < rise_later, (
118+
f"Expected rise at t=1 s ({rise_early:.4f} °C) to be less than rise at t=300 s ({rise_later:.4f} °C)"
119+
)
120+
# Additionally confirm the early rise is small in absolute terms
121+
assert rise_early < 5.0, (
122+
f"Expected temperature rise at t=1 s to be < 5 °C, got {rise_early:.4f} °C"
123+
)
124+
125+
126+
def test_temperature_rises_monotonically_with_time():
127+
"""Temperature rise must be strictly increasing across a span of time points."""
128+
times = [1.0, 10.0, 60.0, 180.0, 360.0, 600.0]
129+
rises = [model_tx_temperature_rise(_BASE_VOLTAGE, t, T0_degC=_BASE_T0) for t in times]
130+
for i in range(len(rises) - 1):
131+
assert rises[i] < rises[i + 1], (
132+
f"Temperature rise not monotonically increasing: "
133+
f"rise[{times[i]} s]={rises[i]:.4f} >= rise[{times[i+1]} s]={rises[i+1]:.4f}"
134+
)
135+
136+
137+
def test_temperature_rises_monotonically_with_voltage():
138+
"""Temperature rise must increase with voltage (at fixed time and other params)."""
139+
# Voltages chosen so that P = V^2 stays within the valid 50-500 V^2 range
140+
voltages = [8.0, 11.0, 14.0, 17.0, 21.0]
141+
t = 120.0
142+
rises = [model_tx_temperature_rise(v, t, T0_degC=_BASE_T0) for v in voltages]
143+
for i in range(len(rises) - 1):
144+
assert rises[i] < rises[i + 1], (
145+
f"Temperature rise not monotonically increasing with voltage: "
146+
f"rise[{voltages[i]} V]={rises[i]:.4f} >= rise[{voltages[i+1]} V]={rises[i+1]:.4f}"
147+
)
148+
149+
150+
def test_lower_duty_cycle_produces_less_heating():
151+
"""Reducing duty cycle reduces effective power and therefore temperature rise."""
152+
t = 120.0
153+
voltage = _BASE_VOLTAGE
154+
rise_full = model_tx_temperature_rise(voltage, t, duty_cycle=1.0, T0_degC=_BASE_T0)
155+
rise_half = model_tx_temperature_rise(voltage, t, duty_cycle=0.5, T0_degC=_BASE_T0)
156+
assert rise_half < rise_full, (
157+
f"Expected 50 % duty cycle ({rise_half:.4f} °C) to produce less heating "
158+
f"than 100 % duty cycle ({rise_full:.4f} °C)"
159+
)
160+
161+
162+
def test_lower_apodization_produces_less_heating():
163+
"""Partial apodization reduces effective power and therefore temperature rise."""
164+
t = 120.0
165+
rise_full = model_tx_temperature_rise(_BASE_VOLTAGE, t, apodization_fraction=1.0, T0_degC=_BASE_T0)
166+
rise_half = model_tx_temperature_rise(_BASE_VOLTAGE, t, apodization_fraction=0.5, T0_degC=_BASE_T0)
167+
assert rise_half < rise_full, (
168+
f"Expected apodization=0.5 ({rise_half:.4f} °C) to produce less heating "
169+
f"than apodization=1.0 ({rise_full:.4f} °C)"
170+
)
171+
172+
173+
@pytest.mark.parametrize("bad_T0", [19.9, 40.1])
174+
def test_warning_emitted_for_T0_out_of_range(bad_T0, caplog):
175+
"""A warning must be logged when T0 is outside the valid 20-40 °C range."""
176+
with caplog.at_level(logging.WARNING, logger="openlifu.plan.solution_analysis"):
177+
model_tx_temperature_rise(_BASE_VOLTAGE, t_sec=60.0, T0_degC=bad_T0)
178+
assert any("T0" in record.message or "temperature" in record.message.lower()
179+
for record in caplog.records), (
180+
f"Expected a warning about T0 out of range for T0={bad_T0} °C"
181+
)
182+
183+
184+
@pytest.mark.parametrize(("bad_voltage","bad_duty_cycle"), [
185+
(6.0, 1.0), # P = 36 < 50
186+
(25.0, 1.0), # P = 625 > 500
187+
])
188+
def test_warning_emitted_for_power_out_of_range(bad_voltage, bad_duty_cycle, caplog):
189+
"""A warning must be logged when the squared-voltage power is outside 50-500 V^2."""
190+
with caplog.at_level(logging.WARNING, logger="openlifu.plan.solution_analysis"):
191+
model_tx_temperature_rise(bad_voltage, t_sec=60.0, duty_cycle=bad_duty_cycle, T0_degC=_BASE_T0)
192+
assert any("voltage" in record.message.lower() or "squared" in record.message.lower() or "v^2" in record.message.lower()
193+
for record in caplog.records), (
194+
f"Expected a warning about power out of range for voltage={bad_voltage} V"
195+
)
196+
197+
198+
@pytest.mark.parametrize("bad_time", [0.5, 601.0])
199+
def test_warning_emitted_for_time_out_of_range(bad_time, caplog):
200+
"""A warning must be logged when t is outside the valid 1-600 s range."""
201+
with caplog.at_level(logging.WARNING, logger="openlifu.plan.solution_analysis"):
202+
model_tx_temperature_rise(_BASE_VOLTAGE, t_sec=bad_time, T0_degC=_BASE_T0)
203+
assert any("time" in record.message.lower() or "seconds" in record.message.lower()
204+
for record in caplog.records), (
205+
f"Expected a warning about time out of range for t={bad_time} s"
206+
)
207+
208+
209+
@pytest.mark.parametrize("bad_freq", [379.9, 420.1])
210+
def test_warning_emitted_for_frequency_out_of_range(bad_freq, caplog):
211+
"""A warning must be logged when frequency is outside the valid 380-420 kHz range."""
212+
with caplog.at_level(logging.WARNING, logger="openlifu.plan.solution_analysis"):
213+
model_tx_temperature_rise(_BASE_VOLTAGE, t_sec=60.0, frequency_kHz=bad_freq, T0_degC=_BASE_T0)
214+
assert any("frequency" in record.message.lower() or "khz" in record.message.lower()
215+
for record in caplog.records), (
216+
f"Expected a warning about frequency out of range for freq={bad_freq} kHz"
217+
)
218+
219+
220+
def test_no_warnings_for_valid_inputs(caplog):
221+
"""No warnings should be emitted when all inputs are within their valid ranges."""
222+
with caplog.at_level(logging.WARNING, logger="openlifu.plan.solution_analysis"):
223+
model_tx_temperature_rise(
224+
voltage=_BASE_VOLTAGE,
225+
t_sec=60.0,
226+
duty_cycle=1.0,
227+
apodization_fraction=1.0,
228+
frequency_kHz=_BASE_FREQ,
229+
T0_degC=_BASE_T0,
230+
)
231+
assert len(caplog.records) == 0, (
232+
f"Unexpected warnings for valid inputs: {[r.message for r in caplog.records]}"
233+
)

0 commit comments

Comments
 (0)