praiteri commited on
Commit
80d8563
·
1 Parent(s): 9858ec4

Raman fitting added

Browse files
deployment/requirements.txt CHANGED
@@ -1,3 +1,5 @@
1
  fastapi
2
  colorama
3
  marimo
 
 
 
1
  fastapi
2
  colorama
3
  marimo
4
+ altair
5
+
marimo/app.py CHANGED
@@ -23,6 +23,7 @@ marimo_server = (
23
  .with_app(path="/stats", root="./statistics_lab.py")
24
  .with_app(path="/eq", root="./equilibrium.py")
25
  .with_app(path="/surface", root="./surface_adsorption.py")
 
26
  )
27
 
28
  def get_media_type(file_name: str):
 
23
  .with_app(path="/stats", root="./statistics_lab.py")
24
  .with_app(path="/eq", root="./equilibrium.py")
25
  .with_app(path="/surface", root="./surface_adsorption.py")
26
+ .with_app(path="/raman", root="./raman.py")
27
  )
28
 
29
  def get_media_type(file_name: str):
marimo/raman.py ADDED
@@ -0,0 +1,464 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import marimo
2
+
3
+ __generated_with = "0.18.2"
4
+ app = marimo.App(width="full")
5
+
6
+
7
+ @app.cell
8
+ def _():
9
+ import marimo as mo
10
+ import pandas as pd
11
+ import numpy as np
12
+ import matplotlib.pyplot as plt
13
+ from io import BytesIO, StringIO
14
+ from scipy.optimize import curve_fit
15
+ from scipy.signal import find_peaks
16
+
17
+ from raman_fit import RamanFitter
18
+
19
+ import altair as alt
20
+ import polars as pl
21
+ return RamanFitter, StringIO, alt, mo, np, pd
22
+
23
+
24
+ @app.cell
25
+ def _(mo):
26
+ get_files, set_files = mo.state([])
27
+ # file_button = mo.ui.file(kind="button",on_change=set_files)
28
+ file_area = mo.ui.file(kind="area",on_change=set_files)
29
+ # mo.vstack([file_button, file_area])
30
+ _text = """
31
+ # Raman Spectrum Fitting
32
+ ## Upload a **.txt** file with the Raman spectrum and fit its peaks using Lorentzian functions.
33
+ """
34
+ mo.vstack([mo.md(_text), file_area])
35
+ return (file_area,)
36
+
37
+
38
+ @app.cell
39
+ def _(file_area):
40
+ filename = None
41
+ # if file_button.value:
42
+ # filename = file_button
43
+ if file_area.value:
44
+ filename = file_area
45
+ return (filename,)
46
+
47
+
48
+ @app.cell
49
+ def _(StringIO, alt, filename, mo, pd):
50
+ if filename is None:
51
+ mo.stop(mo.md("Upload a file"))
52
+
53
+ _file_contents = filename.contents()
54
+ df = pd.read_csv(StringIO(_file_contents.decode('utf-8')), sep=r"\s+")
55
+ df.columns = ("X","Y","Z")
56
+ # print(df)
57
+
58
+ # Create an interval selection for box zoom
59
+ brush = alt.selection_interval(encodings=['x'])
60
+
61
+ # Base chart with selection
62
+ base_chart = alt.Chart(df).mark_line().encode(
63
+ x='X:Q',
64
+ y=alt.Y('Y:Q', scale=alt.Scale(zero=False)),
65
+ ).properties(height=300)
66
+
67
+ # Chart with brush selection
68
+ selection_chart = base_chart.add_params(brush)
69
+
70
+ # Zoomed chart that filters based on selection and autoscales Y
71
+ zoomed_chart = base_chart.transform_filter(
72
+ brush
73
+ ).properties(
74
+ title='Zoomed View (Y autoscaled)'
75
+ )
76
+
77
+ # Combine both charts vertically
78
+ _chart = alt.vconcat(selection_chart, zoomed_chart)
79
+ # Make it reactive ⚡
80
+ chart = mo.ui.altair_chart(selection_chart)
81
+ # chart = mo.ui.altair_chart(_chart)
82
+ return chart, df
83
+
84
+
85
+ @app.cell
86
+ def _(chart, mo):
87
+ # Access the selected/zoomed data
88
+ # selected_data = chart.value
89
+
90
+ mo.vstack([
91
+ chart,
92
+ ])
93
+ return
94
+
95
+
96
+ @app.cell
97
+ def _(chart, df, mo):
98
+ selected_df = chart.value if len(chart.value) > 0 else df
99
+ # Use it however you want
100
+ # mo.vstack([
101
+ # mo.md(f"Selected {len(selected_df)} rows"),
102
+ # mo.ui.table(selected_df)
103
+ # ])
104
+
105
+ wavenumbers = selected_df.iloc[:, 0]
106
+ intensities = selected_df.iloc[:, 1]
107
+
108
+ get_npeaks, set_npeaks = mo.state(0)
109
+ get_xmin, set_xmin = mo.state(min(wavenumbers))
110
+ get_xmax, set_xmax = mo.state(max(wavenumbers))
111
+
112
+ get_fit, set_fit = mo.state(False)
113
+
114
+ # Create a state to hold peak positions as a list
115
+ get_peak_positions, set_peak_positions = mo.state([])
116
+
117
+ # Create a state to hold fit results
118
+ get_fit_results, set_fit_results = mo.state(None)
119
+ return (
120
+ get_fit_results,
121
+ get_npeaks,
122
+ get_peak_positions,
123
+ get_xmax,
124
+ get_xmin,
125
+ intensities,
126
+ set_fit_results,
127
+ set_npeaks,
128
+ set_peak_positions,
129
+ set_xmax,
130
+ set_xmin,
131
+ wavenumbers,
132
+ )
133
+
134
+
135
+ @app.cell
136
+ def _(get_npeaks, get_xmax, get_xmin):
137
+ fitting_parameters = {
138
+ "range" : [get_xmin(),get_xmax()],
139
+ "npeaks" : get_npeaks(),
140
+ }
141
+ return (fitting_parameters,)
142
+
143
+
144
+ @app.cell
145
+ def _(get_npeaks, get_xmax, get_xmin, mo, set_npeaks, set_xmax, set_xmin):
146
+ x_min = mo.ui.text(
147
+ value=str(get_xmin()),
148
+ label="Minimum Raman shift",
149
+ on_change = lambda value: set_xmin(value))
150
+ x_max = mo.ui.text(
151
+ value=str(get_xmax()),
152
+ label="Maximum Raman shift",
153
+ on_change = lambda value: set_xmax(value))
154
+
155
+ n_input = mo.ui.number(
156
+ start=-1,
157
+ value=get_npeaks(),
158
+ label="Number of peaks for fitting (N):",
159
+ on_change=lambda value: set_npeaks(value)
160
+ )
161
+ # n_inpxut # This displays the input box
162
+
163
+ # mo.hstack([mo.vstack([x_min,x_max,n_input]),chart])
164
+ # mo.vstack([chart,mo.hstack([x_min,x_max,n_input])])
165
+ return n_input, x_max, x_min
166
+
167
+
168
+ @app.cell
169
+ def _(
170
+ get_peak_positions,
171
+ mo,
172
+ n_input,
173
+ set_fit_results,
174
+ set_npeaks,
175
+ set_peak_positions,
176
+ ):
177
+ # Create a state to track when to guess peaks
178
+ get_should_guess, set_should_guess = mo.state(0)
179
+
180
+ guess_peaks = mo.ui.button(
181
+ label="Guess Peaks",
182
+ on_click=lambda _: (
183
+ set_should_guess(get_should_guess() + 1),
184
+ set_fit_results(None)
185
+ )
186
+ )
187
+
188
+ # Create a state to track when fit button is clicked
189
+ get_fit_trigger, set_fit_trigger = mo.state(0)
190
+
191
+ fit_spectrum = mo.ui.button(
192
+ label="Fit Spectrum",
193
+ on_click=lambda _: set_fit_trigger(get_fit_trigger() + 1)
194
+ )
195
+
196
+ # Create a reset button
197
+ reset_button = mo.ui.button(
198
+ label="Reset Fit",
199
+ on_click=lambda _: (
200
+ set_npeaks(0),
201
+ set_peak_positions([]),
202
+ set_fit_results(None)
203
+ )
204
+ )
205
+
206
+ n = n_input.value
207
+
208
+ # Get current peak positions from state
209
+ current_positions = get_peak_positions()
210
+
211
+ # Initialize peak positions if needed
212
+ if len(current_positions) != n:
213
+ current_positions = [0] * n
214
+ set_peak_positions(current_positions)
215
+
216
+ if n > 0:
217
+ # Create the float_inputs array using current positions
218
+ float_inputs = mo.ui.array(
219
+ [
220
+ mo.ui.number(
221
+ label=f"Peak position {i+1}:",
222
+ value=current_positions[i],
223
+ step=1,
224
+ on_change=lambda value, idx=i: (
225
+ lambda v, i: (
226
+ new_positions := get_peak_positions().copy(),
227
+ new_positions.__setitem__(i, v),
228
+ set_peak_positions(new_positions),
229
+ set_fit_results(None)
230
+ )[-1]
231
+ )(value, idx)
232
+ )
233
+ for i in range(n)
234
+ ]
235
+ )
236
+ else:
237
+ float_inputs = ""
238
+
239
+ # mo.vstack([
240
+ # mo.hstack([x_min,x_max,n_input]),
241
+ # mo.hstack(float_inputs),
242
+ # mo.hstack([guess_peaks, fit_spectrum, reset_button]),
243
+ # ])
244
+
245
+ return (
246
+ fit_spectrum,
247
+ float_inputs,
248
+ get_fit_trigger,
249
+ get_should_guess,
250
+ guess_peaks,
251
+ reset_button,
252
+ )
253
+
254
+
255
+ @app.cell
256
+ def _(
257
+ RamanFitter,
258
+ filename,
259
+ fitting_parameters,
260
+ get_fit_results,
261
+ get_fit_trigger,
262
+ get_npeaks,
263
+ get_peak_positions,
264
+ get_should_guess,
265
+ intensities,
266
+ mo,
267
+ n_input,
268
+ np,
269
+ set_fit_results,
270
+ set_peak_positions,
271
+ wavenumbers,
272
+ ):
273
+ fitter = RamanFitter(wavenumbers, intensities)
274
+
275
+ freq_range = fitting_parameters['range']
276
+ if freq_range[0] in [None, ""]:
277
+ freq_range[0] = min(wavenumbers)
278
+ else:
279
+ freq_range[0] = float(freq_range[0])
280
+
281
+ if freq_range[1] in [None, ""]:
282
+ freq_range[1] = max(wavenumbers)
283
+ else:
284
+ freq_range[1] = float(freq_range[1])
285
+
286
+ text = ''
287
+
288
+ # Check if guess button was clicked
289
+ if get_should_guess() > 0:
290
+ print(n_input.value)
291
+ p0 = fitter.get_peaks_guess(
292
+ n_peaks=n_input.value,
293
+ freq_range=freq_range,
294
+ )
295
+ # Extract peak positions and update state
296
+ new_positions = [p0[i*3] for i in range(min(len(p0)//3, n_input.value))]
297
+ set_peak_positions(new_positions)
298
+
299
+ # Check if fit button was clicked
300
+ if get_fit_trigger() > 0 and get_npeaks() > 0:
301
+ current_fit_results = get_fit_results()
302
+ # Only fit if we don't have results or the trigger is new
303
+ if current_fit_results is None or current_fit_results.get('trigger') != get_fit_trigger():
304
+ pp = get_peak_positions()
305
+ print("Running fit with positions:", pp)
306
+ popt = fitter.fit(
307
+ n_peaks=fitting_parameters['npeaks'],
308
+ freq_range=freq_range,
309
+ peak_positions=pp,
310
+ remove_background=True
311
+ )
312
+
313
+ # Extract fitted peak positions and update state
314
+ fitted_positions = [popt[i*3] for i in range(fitting_parameters['npeaks'])]
315
+ set_peak_positions(fitted_positions)
316
+
317
+ # Store fit results
318
+ set_fit_results({
319
+ 'trigger': get_fit_trigger(),
320
+ 'popt': popt,
321
+ 'fitted': True
322
+ })
323
+
324
+ if filename is None:
325
+ mo.stop(mo.md("Upload a file"))
326
+
327
+ # Display results
328
+ fit_results = get_fit_results()
329
+
330
+ text_h = ""
331
+ text_b = ""
332
+ text_p = ""
333
+
334
+ if get_npeaks() > 0:
335
+ text_h = "## Displaying fit result"
336
+ pp = get_peak_positions()
337
+
338
+ if fit_results is not None and fit_results.get('fitted'):
339
+ # # Need to re-run the fit to populate the fitter object for plotting
340
+ popt = fitter.fit(
341
+ n_peaks=fitting_parameters['npeaks'],
342
+ freq_range=freq_range,
343
+ peak_positions=pp,
344
+ remove_background=True
345
+ )
346
+
347
+ n_peaks = len(popt) // 3 if (len(popt) % 3 == 0) else (len(popt) - 2) // 3
348
+
349
+ has_background = len(popt) % 3 == 2
350
+
351
+ # Background table (if present)
352
+ text_lines = []
353
+ if has_background:
354
+ bg_a, bg_b = popt[-2:]
355
+ text_lines.append("### Background (linear)\n")
356
+ text_lines.append("| Parameter | Value |")
357
+ text_lines.append("| --- | --- |")
358
+ text_lines.append(f"| **Offset** | {bg_a:.4f} |")
359
+ text_lines.append(f"| **Slope** | {bg_b:.6f} |")
360
+ text_lines.append("")
361
+ text_b = "\n".join(text_lines)
362
+
363
+ # Peaks table
364
+ text_lines = [f"## Fitting Results ({n_peaks} peaks)\n"]
365
+
366
+ # Group peaks into rows of max 10 columns
367
+ max_cols = 10
368
+ for row_start in range(0, n_peaks, max_cols):
369
+ row_end = min(row_start + max_cols, n_peaks)
370
+ n_cols = row_end - row_start
371
+
372
+ # Create header row
373
+ header = "| | " + " | ".join([f"**Peak {i+1}**" for i in range(row_start, row_end)]) + " |"
374
+ separator = "| --- |" + "|".join([" --- " for _ in range(n_cols)]) + "|"
375
+ text_lines.append(header)
376
+ text_lines.append(separator)
377
+
378
+ # Create data rows
379
+ rows_data = [[] for _ in range(4)] # Position, Height, Width, Integral
380
+
381
+ for i in range(row_start, row_end):
382
+ pos, height, width = popt[i*3:(i*3)+3]
383
+ integral = height * width * np.pi
384
+ rows_data[0].append(f"{pos:.2f} cm⁻¹")
385
+ rows_data[1].append(f"{height:.4f}")
386
+ rows_data[2].append(f"{width:.4f} cm⁻¹")
387
+ rows_data[3].append(f"{integral:.4f}")
388
+
389
+ text_lines.append("| **Position** | " + " | ".join(rows_data[0]) + " |")
390
+ text_lines.append("| **Height** | " + " | ".join(rows_data[1]) + " |")
391
+ text_lines.append("| **Width** | " + " | ".join(rows_data[2]) + " |")
392
+ text_lines.append("| **Integral** | " + " | ".join(rows_data[3]) + " |")
393
+ text_lines.append("") # Empty line between row groups
394
+
395
+ text_p = "\n".join(text_lines)
396
+
397
+ image = fitter.plot(show_components=True)
398
+ else:
399
+ text_h = "## Displaying initial guess"
400
+ image = fitter.plot_data_with_initial_guess(
401
+ freq_range=freq_range,
402
+ peak_positions=pp
403
+ )
404
+ else:
405
+ text_h = "## Displaying raw data"
406
+ image = fitter.plot_data(freq_range=freq_range)
407
+ return image, text_b, text_h, text_p
408
+
409
+
410
+ @app.cell
411
+ def _(image, mo):
412
+ import io
413
+ import base64
414
+
415
+ def download_plot():
416
+ buf = io.BytesIO()
417
+ image.savefig(buf, format='png', dpi=300, bbox_inches='tight')
418
+ buf.seek(0)
419
+ return buf.getvalue()
420
+
421
+ download_button = mo.download(
422
+ data=download_plot,
423
+ filename="plot.png",
424
+ label="Download PNG",
425
+ )
426
+ return (download_button,)
427
+
428
+
429
+ @app.cell
430
+ def _(
431
+ download_button,
432
+ fit_spectrum,
433
+ float_inputs,
434
+ guess_peaks,
435
+ image,
436
+ mo,
437
+ n_input,
438
+ reset_button,
439
+ text_h,
440
+ x_max,
441
+ x_min,
442
+ ):
443
+ mo.vstack([
444
+ mo.md(text_h),
445
+ mo.hstack([x_min,x_max,n_input]),
446
+ mo.hstack(float_inputs),
447
+ mo.hstack([guess_peaks, fit_spectrum, reset_button], align="center"),
448
+ download_button,
449
+ image,
450
+ ])
451
+
452
+ return
453
+
454
+
455
+ @app.cell
456
+ def _(mo, text_b, text_p):
457
+ mo.vstack([
458
+ mo.md(text_p) , mo.md(text_b)
459
+ ])
460
+ return
461
+
462
+
463
+ if __name__ == "__main__":
464
+ app.run()
pyproject.toml CHANGED
@@ -22,7 +22,9 @@ dependencies = [
22
  "colorama",
23
  "matplotlib",
24
  "marimo",
25
- "fastapi"
 
 
26
  ]
27
 
28
  [project.optional-dependencies]
 
22
  "colorama",
23
  "matplotlib",
24
  "marimo",
25
+ "fastapi",
26
+ "altair",
27
+ "pandas",
28
  ]
29
 
30
  [project.optional-dependencies]
src/pycek_public/__init__.py CHANGED
@@ -9,4 +9,4 @@ from .crystal_violet import *
9
  from .surface_adsorption import *
10
 
11
  from .plotting import *
12
-
 
9
  from .surface_adsorption import *
10
 
11
  from .plotting import *
12
+ from .raman_fitter.py import *
src/pycek_public/raman_fitter.py ADDED
@@ -0,0 +1,468 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from scipy.optimize import curve_fit
3
+ from scipy.integrate import trapezoid
4
+ import matplotlib.pyplot as plt
5
+
6
+
7
+ class RamanFitter:
8
+ def __init__(self, wavenumbers, intensities):
9
+ """
10
+ Initialize the Raman fitter.
11
+
12
+ Parameters:
13
+ wavenumbers: array of Raman shift values (cm^-1)
14
+ intensities: array of intensity values
15
+ """
16
+ self.wavenumbers = np.array(wavenumbers)
17
+ self.intensities = np.array(intensities)
18
+ self.fit_result = None
19
+ self.fit_wavenumbers = None
20
+ self.background_fit = None
21
+ self.popt = None
22
+ self.mask = None
23
+ self.p0 = None # Store initial guess
24
+
25
+ def lorentzian(self, x, *params):
26
+ """
27
+ Sum of multiple Lorentzian functions.
28
+
29
+ Parameters: position1, height1, width1, position2, height2, width2, ...
30
+ (3 parameters per peak)
31
+ """
32
+ result = np.zeros_like(x)
33
+ for i in range(0, len(params), 3):
34
+ pos, height, width = params[i:i+3]
35
+ result += height * (width**2) / ((x - pos)**2 + width**2)
36
+ return result
37
+
38
+ def lorentzian_with_background(self, x, *params):
39
+ """
40
+ Sum of Lorentzians plus polynomial background.
41
+ Last 2 parameters are for linear background: a + b*x
42
+ """
43
+ n_peaks = (len(params) - 2) // 3
44
+ bg_a, bg_b = params[-2:]
45
+ background = bg_a + bg_b * x
46
+
47
+ lorentz_params = params[:-2]
48
+ result = self.lorentzian(x, *lorentz_params) + background
49
+ return result
50
+
51
+ def get_peaks_guess(self, n_peaks, freq_range=None):
52
+ # Select data in frequency range
53
+ if freq_range is not None:
54
+ self.mask = (self.wavenumbers >= freq_range[0]) & (self.wavenumbers <= freq_range[1])
55
+ x_fit = self.wavenumbers[self.mask]
56
+ y_fit = self.intensities[self.mask]
57
+ else:
58
+ self.mask = np.ones(len(self.wavenumbers), dtype=bool)
59
+ x_fit = self.wavenumbers
60
+ y_fit = self.intensities
61
+ p0 = self._estimate_initial_params(x_fit, y_fit, n_peaks, True)
62
+ return p0
63
+
64
+ def fit(self, n_peaks, freq_range=None, fix_params=None, remove_background=False, peak_positions=None):
65
+ """
66
+ Fit the spectrum with Lorentzian functions.
67
+
68
+ Parameters:
69
+ n_peaks: number of Lorentzian peaks to fit
70
+ freq_range: tuple (min, max) for fitting range. If None, uses all data
71
+ fix_params: dict with keys like 'position_0', 'height_1', 'width_2', etc.
72
+ Values are the fixed values for those parameters
73
+ remove_background: if True, removes linear background before fitting
74
+ peak_positions: list of initial peak positions. If provided, heights and widths
75
+ are estimated from these positions. If None, positions are auto-detected.
76
+
77
+ Returns:
78
+ popt: optimized parameters
79
+ """
80
+ # Select data in frequency range
81
+ if freq_range is not None:
82
+ self.mask = (self.wavenumbers >= freq_range[0]) & (self.wavenumbers <= freq_range[1])
83
+ x_fit = self.wavenumbers[self.mask]
84
+ y_fit = self.intensities[self.mask]
85
+ else:
86
+ self.mask = np.ones(len(self.wavenumbers), dtype=bool)
87
+ x_fit = self.wavenumbers
88
+ y_fit = self.intensities
89
+
90
+ # Estimate initial parameters from data
91
+ if peak_positions is not None:
92
+ p0 = self._estimate_heights_widths(x_fit, y_fit, peak_positions, remove_background)
93
+ else:
94
+ p0 = self._estimate_initial_params(x_fit, y_fit, n_peaks, remove_background)
95
+
96
+ self.p0 = p0 # Store initial guess
97
+
98
+ # Determine which parameters to fix
99
+ fixed_mask = self._create_fixed_mask(n_peaks, fix_params, remove_background)
100
+
101
+ # Fit with constraints
102
+ if remove_background:
103
+ fit_func = self.lorentzian_with_background
104
+ else:
105
+ fit_func = self.lorentzian
106
+
107
+ try:
108
+ popt, _ = curve_fit(fit_func, x_fit, y_fit, p0=p0, maxfev=10000)
109
+
110
+ # Apply fixed values
111
+ if fixed_mask is not None:
112
+ for i, is_fixed in enumerate(fixed_mask):
113
+ if is_fixed:
114
+ popt[i] = p0[i]
115
+
116
+ self.popt = popt
117
+
118
+ # Create high-resolution fit for plotting (only in fitted range)
119
+ if freq_range is not None:
120
+ self.fit_wavenumbers = np.linspace(freq_range[0], freq_range[1], len(x_fit) * 5)
121
+ else:
122
+ self.fit_wavenumbers = np.linspace(x_fit.min(), x_fit.max(), len(x_fit) * 5)
123
+
124
+ self.fit_result = fit_func(self.fit_wavenumbers, *popt)
125
+ return popt
126
+ except RuntimeError as e:
127
+ print(f"Fitting failed: {e}")
128
+ return None
129
+
130
+ def _estimate_initial_params(self, x, y, n_peaks, include_background):
131
+ """Estimate initial parameters from data."""
132
+ # Find peaks
133
+ from scipy.signal import find_peaks
134
+ peaks, _ = find_peaks(y, distance=len(y)//(n_peaks+1))
135
+
136
+ # Use n_peaks with highest intensity
137
+ if len(peaks) > 0:
138
+ top_peaks = peaks[np.argsort(y[peaks])[-n_peaks:]]
139
+ else:
140
+ top_peaks = np.linspace(x.min(), x.max(), n_peaks)
141
+
142
+ p0 = []
143
+ for idx in sorted(top_peaks):
144
+ p0.extend([x[idx], y[idx], 0.3]) # position, height, width
145
+
146
+ if include_background:
147
+ p0.extend([y.min(), 0.0]) # background amplitude and slope
148
+
149
+ return p0
150
+
151
+ def _estimate_heights_widths(self, x, y, peak_positions, include_background):
152
+ """Estimate heights and widths for given peak positions."""
153
+ p0 = []
154
+ for pos in peak_positions:
155
+ idx = np.argmin(np.abs(x - pos))
156
+ height = y[idx]
157
+ width = 0.3
158
+ p0.extend([pos, height, width])
159
+
160
+ if include_background:
161
+ p0.extend([y.min(), 0.0])
162
+
163
+ return p0
164
+
165
+ def _create_fixed_mask(self, n_peaks, fix_params, include_background):
166
+ """Create mask for fixed parameters."""
167
+ if fix_params is None:
168
+ return None
169
+
170
+ n_params = n_peaks * 3
171
+ if include_background:
172
+ n_params += 2
173
+
174
+ fixed_mask = [False] * n_params
175
+
176
+ for param_name, value in fix_params.items():
177
+ parts = param_name.rsplit('_', 1)
178
+ if len(parts) == 2:
179
+ ptype, idx = parts[0], int(parts[1])
180
+ param_idx = int(idx) * 3
181
+
182
+ if ptype == 'position':
183
+ fixed_mask[param_idx] = True
184
+ elif ptype == 'height':
185
+ fixed_mask[param_idx + 1] = True
186
+ elif ptype == 'width':
187
+ fixed_mask[param_idx + 2] = True
188
+
189
+ return fixed_mask if any(fixed_mask) else None
190
+
191
+ def get_peak_integrals(self):
192
+ """
193
+ Calculate the integral (area) under each peak using high-resolution fit data.
194
+
195
+ Returns:
196
+ list of integral values for each peak
197
+ """
198
+ if self.popt is None or self.fit_wavenumbers is None:
199
+ print("No fit available. Run fit() first.")
200
+ return None
201
+
202
+ integrals = []
203
+ n_peaks = len(self.popt) // 3 if (len(self.popt) % 3 == 0) else (len(self.popt) - 2) // 3
204
+
205
+ for i in range(n_peaks):
206
+ pos, height, width = self.popt[i*3:(i*3)+3]
207
+ # Integral of Lorentzian: height * width * pi
208
+ integral = height * width * np.pi
209
+ integrals.append(integral)
210
+
211
+ return integrals
212
+
213
+ def get_background(self):
214
+ """Get the background component if fitted with background removal."""
215
+ if self.popt is None or len(self.popt) % 3 != 2:
216
+ return None
217
+
218
+ bg_a, bg_b = self.popt[-2:]
219
+ x_vals = self.fit_wavenumbers if self.fit_wavenumbers is not None else self.wavenumbers
220
+ return bg_a + bg_b * x_vals
221
+
222
+ def plot_data(self, freq_range=None):
223
+ """
224
+ Plot only the input data.
225
+
226
+ Parameters:
227
+ freq_range: tuple (min, max) for plotting range. If None, plots all data
228
+ """
229
+ if freq_range is not None:
230
+ mask = (self.wavenumbers >= freq_range[0]) & (self.wavenumbers <= freq_range[1])
231
+ x_data = self.wavenumbers[mask]
232
+ y_data = self.intensities[mask]
233
+ else:
234
+ x_data = self.wavenumbers
235
+ y_data = self.intensities
236
+
237
+ fig = plt.figure(figsize=(12, 6))
238
+ plt.plot(x_data, y_data, 'o-', label='Data', alpha=0.7, linewidth=2, markersize=5)
239
+ plt.xlabel('Raman Shift (cm$^{-1}$)')
240
+ plt.ylabel('Intensity')
241
+ plt.legend()
242
+ plt.grid(True, alpha=0.3)
243
+ plt.tight_layout()
244
+ #plt.show()
245
+ return fig
246
+
247
+ def plot_data_with_initial_guess(self, freq_range=None, n_peaks=None, peak_positions=None, remove_background=False):
248
+ """
249
+ Plot the input data and the initial guess for peaks.
250
+
251
+ Parameters:
252
+ freq_range: tuple (min, max) for plotting range. If None, plots all data
253
+ n_peaks: number of peaks to estimate (required if p0 not already computed)
254
+ peak_positions: list of initial peak positions (optional)
255
+ remove_background: if True, includes background in initial guess
256
+ """
257
+ # Generate initial guess if not already available
258
+ # if self.p0 is None:
259
+ # if n_peaks is None:
260
+ # print("No initial guess available. Provide n_peaks or run fit() first.")
261
+ # return
262
+ #
263
+ # # Select data for estimation
264
+ # if freq_range is not None:
265
+ # mask = (self.wavenumbers >= freq_range[0]) & (self.wavenumbers <= freq_range[1])
266
+ # x_fit = self.wavenumbers[mask]
267
+ # y_fit = self.intensities[mask]
268
+ # else:
269
+ # x_fit = self.wavenumbers
270
+ # y_fit = self.intensities
271
+ #
272
+ # # Estimate initial parameters
273
+ # if peak_positions is not None:
274
+ # self.p0 = self._estimate_heights_widths(x_fit, y_fit, peak_positions, remove_background)
275
+ # else:
276
+ # self.p0 = self._estimate_initial_params(x_fit, y_fit, n_peaks, remove_background)
277
+ # Select data for estimation
278
+ if freq_range is not None:
279
+ mask = (self.wavenumbers >= freq_range[0]) & (self.wavenumbers <= freq_range[1])
280
+ x_fit = self.wavenumbers[mask]
281
+ y_fit = self.intensities[mask]
282
+ else:
283
+ x_fit = self.wavenumbers
284
+ y_fit = self.intensities
285
+
286
+ # Estimate initial parameters
287
+ if peak_positions is None:
288
+ if self.p0 is None:
289
+ if n_peaks is None:
290
+ print("No initial guess available. Provide n_peaks or run fit() first.")
291
+ return
292
+ self.p0 = self._estimate_initial_params(x_fit, y_fit, n_peaks, remove_background)
293
+ else:
294
+ self.p0 = self._estimate_heights_widths(x_fit, y_fit, peak_positions, remove_background)
295
+
296
+ if freq_range is not None:
297
+ mask = (self.wavenumbers >= freq_range[0]) & (self.wavenumbers <= freq_range[1])
298
+ x_data = self.wavenumbers[mask]
299
+ y_data = self.intensities[mask]
300
+ fit_x = np.linspace(freq_range[0], freq_range[1], len(x_data) * 5)
301
+ else:
302
+ x_data = self.wavenumbers
303
+ y_data = self.intensities
304
+ fit_x = np.linspace(self.wavenumbers.min(), self.wavenumbers.max(), len(self.wavenumbers) * 5)
305
+
306
+ # Compute initial guess (check if background is included)
307
+ is_background = (len(self.p0) % 3 == 2)
308
+ if is_background:
309
+ initial_fit = self.lorentzian_with_background(fit_x, *self.p0)
310
+ else:
311
+ initial_fit = self.lorentzian(fit_x, *self.p0)
312
+
313
+ fig = plt.figure(figsize=(12, 6))
314
+ plt.plot(x_data, y_data, 'o-', label='Data', alpha=0.7, linewidth=2, markersize=5)
315
+ plt.plot(fit_x, initial_fit, '-', linewidth=2, label='Initial Guess')
316
+ plt.xlabel('Raman Shift (cm$^{-1}$)')
317
+ plt.ylabel('Intensity')
318
+ plt.legend()
319
+ plt.grid(True, alpha=0.3)
320
+ plt.tight_layout()
321
+ #plt.show()
322
+ return fig
323
+
324
+ def plot(self, show_components=True):
325
+ """Plot the spectrum and fit in the fitted range."""
326
+ if self.fit_result is None or self.fit_wavenumbers is None:
327
+ print("No fit available. Run fit() first.")
328
+ return
329
+
330
+ # Get data in fitted range
331
+ if self.mask is not None:
332
+ x_data = self.wavenumbers[self.mask]
333
+ y_data = self.intensities[self.mask]
334
+ else:
335
+ x_data = self.wavenumbers
336
+ y_data = self.intensities
337
+
338
+ fig = plt.figure(figsize=(12, 6))
339
+ plt.plot(x_data, y_data, 'o-', label='Data', alpha=0.7)
340
+ plt.plot(self.fit_wavenumbers, self.fit_result, '-', linewidth=2, label='Fit')
341
+
342
+ if show_components and self.popt is not None:
343
+ n_peaks = len(self.popt) // 3 if (len(self.popt) % 3 == 0) else (len(self.popt) - 2) // 3
344
+
345
+ for i in range(n_peaks):
346
+ pos, height, width = self.popt[i*3:(i*3)+3]
347
+ peak = height * (width**2) / ((self.fit_wavenumbers - pos)**2 + width**2)
348
+ plt.plot(self.fit_wavenumbers, peak, '--', alpha=0.5, label=f'Peak {i+1}')
349
+
350
+ bg = self.get_background()
351
+ if bg is not None:
352
+ plt.plot(self.fit_wavenumbers, bg, ':', linewidth=2, label='Background')
353
+
354
+ plt.xlabel('Raman Shift (cm$^{-1}$)')
355
+ plt.ylabel('Intensity')
356
+ plt.legend()
357
+ plt.grid(True, alpha=0.3)
358
+ plt.tight_layout()
359
+ #plt.show()
360
+ return fig
361
+
362
+ def print_results(self):
363
+ """Print fitting results."""
364
+ if self.popt is None:
365
+ print("No fit available.")
366
+ return
367
+
368
+ n_peaks = len(self.popt) // 3 if (len(self.popt) % 3 == 0) else (len(self.popt) - 2) // 3
369
+
370
+ print(f"\n{'='*60}")
371
+ print(f"Fitting Results ({n_peaks} peaks)")
372
+ print(f"{'='*60}")
373
+
374
+ for i in range(n_peaks):
375
+ pos, height, width = self.popt[i*3:(i*3)+3]
376
+ integral = height * width * np.pi
377
+ print(f"\nPeak {i+1}:")
378
+ print(f" Position: {pos:.2f} cm⁻¹")
379
+ print(f" Height: {height:.4f}")
380
+ print(f" Width: {width:.4f} cm⁻¹")
381
+ print(f" Integral: {integral:.4f}")
382
+
383
+ if len(self.popt) % 3 == 2:
384
+ bg_a, bg_b = self.popt[-2:]
385
+ print(f"\nBackground (linear):")
386
+ print(f" Offset: {bg_a:.4f}")
387
+ print(f" Slope: {bg_b:.6f}")
388
+
389
+ print(f"{'='*60}\n")
390
+
391
+ def print_results_string(self):
392
+ """Print fitting results."""
393
+ if self.popt is None:
394
+ return "No fit available."
395
+
396
+ n_peaks = len(self.popt) // 3 if (len(self.popt) % 3 == 0) else (len(self.popt) - 2) // 3
397
+
398
+ lines = []
399
+ lines.append("=" * 60)
400
+ lines.append(f"Fitting Results ({n_peaks} peaks)")
401
+ lines.append("=" * 60)
402
+
403
+ for i in range(n_peaks):
404
+ pos, height, width = self.popt[i*3:(i*3)+3]
405
+ integral = height * width * np.pi
406
+ lines.append(f"\nPeak {i+1}:")
407
+ lines.append(f" Position: {pos:.2f} cm⁻¹")
408
+ lines.append(f" Height: {height:.4f}")
409
+ lines.append(f" Width: {width:.4f} cm⁻¹")
410
+ lines.append(f" Integral: {integral:.4f}")
411
+
412
+ if len(self.popt) % 3 == 2:
413
+ bg_a, bg_b = self.popt[-2:]
414
+ lines.append(f"\nBackground (linear):")
415
+ lines.append(f" Offset: {bg_a:.4f}")
416
+ lines.append(f" Slope: {bg_b:.6f}")
417
+
418
+ lines.append("=" * 60)
419
+
420
+ return "\n".join(lines)
421
+
422
+ # Example usage
423
+ if __name__ == "__main__":
424
+
425
+ # Read spectrum from file
426
+ # Assumes a text file with two columns: wavenumber (cm-1) and intensity
427
+ spectrum_file = "raman_spectrum.txt"
428
+ data = np.loadtxt(spectrum_file)
429
+ wavenumbers = data[:, 0]
430
+ intensities = data[:, 1]
431
+
432
+ # Create fitter
433
+ fitter = RamanFitter(wavenumbers, intensities)
434
+
435
+ # Data range
436
+ data_range = (540, 555)
437
+ # Initial peak positions
438
+ peak_positions = [542, 546, 547] # Your initial guesses in cm-1
439
+
440
+ # Plot raw data
441
+ fitter.plot_data(freq_range=data_range)
442
+
443
+ # Option 1: Auto-detect peaks
444
+ # fitter.plot_data_with_initial_guess(freq_range=data_range, n_peaks=3)
445
+
446
+ # Option 2: Provide specific peak positions
447
+ fitter.plot_data_with_initial_guess(freq_range=data_range, peak_positions=peak_positions)
448
+
449
+ # Fit with custom peak positions
450
+ popt = fitter.fit(
451
+ n_peaks=3,
452
+ freq_range=data_range,
453
+ peak_positions=peak_positions,
454
+ remove_background=True
455
+ )
456
+
457
+ # Print results
458
+ fitter.print_results()
459
+
460
+ fitter.plot_data_with_initial_guess(freq_range=data_range)
461
+
462
+ # Get peak integrals
463
+ # integrals = fitter.get_peak_integrals()
464
+ # for i in range(len(integrals)):
465
+ # print(f"\nIntegral of peak {i}: {integrals[i]}")
466
+
467
+ # Plot final fit
468
+ fitter.plot(show_components=True)