Skip to content

Commit e2f6501

Browse files
Add temperature estimation #422
add temperature estimation make estimation function independently importable
1 parent c3e4f8c commit e2f6501

3 files changed

Lines changed: 264 additions & 3 deletions

File tree

src/openlifu/plan/solution.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import base64
44
import json
5+
import logging
56
import tempfile
67
from dataclasses import asdict, dataclass, field
78
from datetime import datetime
@@ -21,12 +22,14 @@
2122
find_centroid,
2223
get_beamwidth,
2324
get_mask,
25+
model_tx_temperature_rise,
2426
)
2527
from openlifu.util.annotations import OpenLIFUFieldData
2628
from openlifu.util.json import PYFUSEncoder
2729
from openlifu.util.units import getunitconversion, rescale_coords, rescale_data_arr
2830
from openlifu.xdc import Transducer
2931

32+
logger = logging.getLogger(__name__)
3033

3134
def _construct_nc_filepath_from_json_filepath(json_filepath:Path) -> Path:
3235
"""Construct a default filepath to netCDF file given filepath to associated solution json file."""
@@ -276,10 +279,45 @@ def analyze(self,
276279
solution_analysis.TIC = np.mean(TIC)
277280
solution_analysis.voltage_V = self.voltage
278281
solution_analysis.power_W = np.mean(power_W)
279-
282+
solution_analysis.estimated_tx_temperature_rise_C = self.estimate_tx_temperature_rise(
283+
t_sec=solution_analysis.sequence_duration_s,
284+
)
280285
solution_analysis.param_constraints = param_constraints
281286
return solution_analysis
282287

288+
def estimate_tx_temperature_rise(self,
289+
t_sec: float,
290+
T0_degC: float = 30.0):
291+
292+
"""
293+
Standalone temperature prediction function for thermal modeling.
294+
295+
Based on physics-based power decay gradient model fitted from experimental data.
296+
Model: dT/dt = (t + t_shift)^(-n) + C
297+
298+
Parameters:
299+
-----------
300+
self: Solution
301+
The treatment solution containing voltage and pulse information.
302+
t_sec: float
303+
Time in seconds for which to predict temperature rise.
304+
T0_degC: float
305+
Initial temperature in Celsius. Default is 30.0°C.
306+
307+
Returns:
308+
--------
309+
float
310+
Predicted temperature rise in Celsius
311+
"""
312+
return model_tx_temperature_rise(
313+
voltage=self.voltage,
314+
t_sec=t_sec,
315+
duty_cycle=self.get_sequence_dutycycle(),
316+
apodization_fraction=np.mean(self.apodizations),
317+
frequency_kHz=self.pulse.frequency / 1e3,
318+
T0_degC=T0_degC,
319+
)
320+
283321
def compute_scaling_factors(
284322
self,
285323
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 = {
@@ -45,7 +48,8 @@
4548
"power_W": [None, "0.2f", "W", "Emitted Power"],
4649
"duty_cycle_pulse_train_pct": [None, "0.1f", "%", "Pulse Train Duty Cycle"],
4750
"duty_cycle_sequence_pct": [None, "0.1f", "%", "Sequence Duty Cycle"],
48-
"sequence_duration_s": [None, "0.0f", "s", "Sequence Duration"],}
51+
"sequence_duration_s": [None, "0.0f", "s", "Sequence Duration"],
52+
"estimated_tx_temperature_rise_C": [None, "0.2f", "°C", "Estimated TX Temperature Rise"]}
4953

5054
@dataclass
5155
class SolutionAnalysis(DictMixin):
@@ -139,6 +143,8 @@ class SolutionAnalysis(DictMixin):
139143
sequence_duration_s: Annotated[float | None, OpenLIFUFieldData("Sequence duration (s)", "Total duration of the sequence (s)")] = None
140144
"""Total duration of the sequence (s)"""
141145

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

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