﻿# EasyCalibratePro.py V1.0
#
# Python tool for line scan cameras by EURECA Messtechnik GmbH
# - shows how to calibrate a spectometer using known emission lines or filter central wavelengths
#   using linear regressions or a polynominal fit
#
# For details please refer to: www.eureca.de

# import library for widgets
from tkinter import *
from tkinter.filedialog import askopenfilename, asksaveasfilename

import os
import numpy as np
from scipy.signal import find_peaks

# import library for handling external camera DLL
import ctypes

# basic values of the used linear sensor; e.g. for e9u-LSMD-TCD1304-STD: 3648 pixel; 16bit data
PIXEL_NUMBER = int(3648)
COUNT_MAX = int(65536)

# select the used camera type; necessary for later using the correct DLL, as well as the respective functions
CAMERA_TYPE = "STD/PRO"
#CAMERA_TYPE = "EDU"

# offsets for the plot window; needed for displaying addional data
PLOT_OFFSETX = 70
PLOT_OFFSETY = 100

calibration_window = None
selected_ref_index = -1

CURSOR_HALF = 60          # length of lines halfs in px
CURSOR_RADIUS = 10        # target circle

# defining the arrays, which will be needed for the fit
x_array = np.array([1, 2, 3])
y_array = np.array([1.0, 2.0, 3.0])
polyfit = np.polyfit(x_array, y_array, 2)

# variables to test the camera status and the wavelength calibration
is_reading = True
is_calibrated = False

#-----------------------------------------------------------------------------
# calibration data for five bandpass filters

# number of calibration points (min. 2, max. 5)
# N_REF_POINTS = 5

# wavelengths of the calibartion lines/points
# ref_values_nm = [436, 480, 532, 580, 680][:N_REF_POINTS]

# label of buttons
# ref_labels = ["Peak for \"436/10\"", "Peak for \"480/10\"", "Peak for \"532/4\"", "Peak for \"580/10\"", "Peak for \"680/10\""][:N_REF_POINTS]

# text for the status line
# ref_pick_instructions = [
#    "Mark the maximum for the blue-violett filter!\nPress the left mouse button or Space",
#    "Mark the maximum for the blue filter!\nPress the left mouse button or Space",
#    "Mark the maximum for the green filter!\nPress the left mouse button or Space",
#    "Mark the maximum for the yellow filter!\nPress the left mouse button or Space",
#    "Mark the maximum for the red filter!\nPress the left mouse button or Space"
#][:N_REF_POINTS]

#-----------------------------------------------------------------------------
# calibration data for five emission lines (Xe/Hg) of a HID lamp

# number of calibration points (min. 2, max. 5)
# N_REF_POINTS = 5

# wavelengths of the calibartion lines/points
# ref_values_nm = [404.66, 435.83, 546.07, 823.16, 871.66][:N_REF_POINTS]

# label of buttons
# ref_labels = ["Peak for \"Hg 404\"", "Peak for \"Hg 435\"", "Peak for \"Hg 546\"", "Peak for \"Xe 823\"", "Peak for \"Hg 871\""][:N_REF_POINTS]

# text for the status line
# ref_pick_instructions = [
#     "Mark the maximum for Hg 404!\nPress the left mouse button or Space",
#     "Mark the maximum for Hg 435!\nPress the left mouse button or Space",
#     "Mark the maximum for Hg 546!\nPress the left mouse button or Space",
#     "Mark the maximum for Xe 823!\nPress the left mouse button or Space",
#     "Mark the maximum for Hg 871!\nPress the left mouse button or Space"
# ][:N_REF_POINTS]

#-----------------------------------------------------------------------------
# calibration data for five emission lines (Hg/Ar) of a red CCFL (Cold Cathode Fluorescent Lamp)

# number of calibration points (min. 2, max. 5)
N_REF_POINTS = 5

# wavelengths of the calibartion lines/points
ref_values_nm = [435.83, 546.07, 696.54, 763.51, 810.37][:N_REF_POINTS]

# label of buttons
ref_labels = ["Peak for \"Hg 435\"", "Peak for \"Hg 546\"", "Peak for \"Ar 696\"", "Peak for \"Ar 763\"", "Peak for \"Ar 810\""][:N_REF_POINTS]

# text for the status line
ref_pick_instructions = [
   "Mark the maximum for Hg 435!\nPress the left mouse button or Space",
   "Mark the maximum for Hg 546!\nPress the left mouse button or Space",
   "Mark the maximum for Ar 696!\nPress the left mouse button or Space",
   "Mark the maximum for Ar 763!\nPress the left mouse button or Space",
   "Mark the maximum for Ar 810!\nPress the left mouse button or Space"
][:N_REF_POINTS]

#-----------------------------------------------------------------------------
# calibration data for three laser lines (blue, green and red) from laser diode modules

# number of calibration points (min. 2, max. 5)
#N_REF_POINTS = 3

# wavelengths of the calibartion lines/points
#ref_values_nm = [405.5, 520.0, 652.5][:N_REF_POINTS]

# label of buttons
#ref_labels = ["Laser blue", "Laser green", "Laser red"][:N_REF_POINTS]

# text for the status line
#ref_pick_instructions = [
#    "Mark the maximum for the blue laser!\nPress the left mouse button or Space",
#    "Mark the maximum for the green laser!\nPress the left mouse button or Space",
#    "Mark the maximum for the red laser!\nPress the left mouse button or Space"
#][:N_REF_POINTS]

#-----------------------------------------------------------------------------
# calibration data for three laser lines (blue and red) from laser diode modules

# number of calibration points (min. 2, max. 5)
#N_REF_POINTS = 2

# wavelengths of the calibartion lines/points
#ref_values_nm = [405.5, 652.5][:N_REF_POINTS]

# label of buttons
#ref_labels = ["Laser blue", "Laser red"][:N_REF_POINTS]

# text for the status line
#ref_pick_instructions = [
#    "Mark the maximum for the blue laser!\nPress the left mouse button or Space",
#    "Mark the maximum for the red laser!\nPress the left mouse button or Space"
#][:N_REF_POINTS]

#-----------------------------------------------------------------------------

ref_pixels_px = [0] * N_REF_POINTS

ref_buttons = []
ref_value_labels = []
ref_status_label = None
fit_calibration_button = None

#--------------------------------------------------
# Function for starting/stopping the sensor readout

def toggle_reading():
  global is_reading

  is_reading = not is_reading

#--------------------------------------------------
# plot the wavelength at the y-axis after calibration of the spectrometer

def Plot_Sensordata_Wavelength():

  # calculate the wavelength range out of the fit (works also when the axis runs "backwards")
  w0 = float(polyfit(0))
  w1 = float(polyfit(PIXEL_NUMBER - 1))

  wavelength_start = min(w0, w1)
  wavelength_end   = max(w0, w1)

  plot.delete("wavelength")

  for x in range(100,1200,50):
    if (x > wavelength_start) and (x < wavelength_end):

      plot.create_line(PLOT_OFFSETX + wavelength_to_pixel(x)*plot_width/PIXEL_NUMBER, plot_height + PLOT_OFFSETY, PLOT_OFFSETX + \
                       wavelength_to_pixel(x)*plot_width/PIXEL_NUMBER, plot_height + PLOT_OFFSETY + 10, tags="wavelength")
      plot.create_text(PLOT_OFFSETX + wavelength_to_pixel(x)*plot_width/PIXEL_NUMBER, plot_height + PLOT_OFFSETY + 20, font=("Arial Bold", 18),text=str(x),fill="black", tags="wavelength")
      plot.create_line(PLOT_OFFSETX + wavelength_to_pixel(x)*plot_width/PIXEL_NUMBER, PLOT_OFFSETY, PLOT_OFFSETX + \
 wavelength_to_pixel(x)*plot_width/PIXEL_NUMBER, PLOT_OFFSETY + plot_height, fill="#dddddd", tags="wavelength")

  spectrometer_range_output = f"Range: {wavelength_start:.1f}nm - {wavelength_end:.1f}nm"
  spectrometer_resolution_output = f"Resolution: {spectrometer_resolution:.4f}nm/pixel"
  wavelength_output =f"Wavelength in nm                       Spectrometer: SN {SerialNumber()}          {spectrometer_range_output}         {spectrometer_resolution_output}"
  plot.create_text(PLOT_OFFSETX, plot_height + PLOT_OFFSETY + 45, font=("Arial Bold",18),text=wavelength_output,fill="black", tags="wavelength", anchor="w")

#--------------------------------------------------
# displaying a cursor target at the sensor signal at the y-coordinate of the mouse pointer

def draw_cursor_target(pointer_x_px, count_y_px, pixel, count_pos, pointer_wavelength_text):

    plot.delete("cursor")

    # calculate the canvas coordinates
    x = pointer_x_px + PLOT_OFFSETX
    y = plot_height + PLOT_OFFSETY - count_y_px

    # draw target lines (dashed)
    plot.create_line(x, y - CURSOR_HALF, x, y + CURSOR_HALF,
                     width=2, dash=(4, 3), tags="cursor")
    plot.create_line(x - CURSOR_HALF, y, x + CURSOR_HALF, y,
                     width=2, dash=(4, 3), tags="cursor")

    # draw circle plus center
    plot.create_oval(x - CURSOR_RADIUS, y - CURSOR_RADIUS, x + CURSOR_RADIUS, y + CURSOR_RADIUS,
                     width=2, tags="cursor")
    plot.create_oval(x - 2, y - 2, x + 2, y + 2, width=0, fill="black", tags="cursor")

    # build bubble text
    line1 = f"Pixel #{pixel}: Counts = {int(count_pos)}"
    line2 = pointer_wavelength_text if pointer_wavelength_text else ""
    #line3 = f"Local peak: px {peak_pixel} / {peak_counts}"

    # additional hint when a reference is selected
    hint = ""
    if 0 <= selected_ref_index < N_REF_POINTS:
        hint = f"Set Ref #{selected_ref_index+1}: {ref_labels[selected_ref_index]} ({ref_values_nm[selected_ref_index]} nm)\nRight-click or Space to set"

    bubble_text = line1
    if line2: bubble_text += "\n" + line2
    #bubble_text += "\n" + line3
    if hint: bubble_text += "\n\n" + hint

    # Bubble position (next to the cursor, but within the plot area)
    bx = x + 18
    by = y - 100

    # Text zeichnen, bbox holen, Hintergrund-Rechteck drunter
    text_id = plot.create_text(bx, by, text=bubble_text, font=("Arial", 12),
                               justify="left", anchor="nw", tags="cursor")
    bbox = plot.bbox(text_id)
    if bbox:
        pad_x, pad_y = 8, 6
        rect_id = plot.create_rectangle(bbox[0]-pad_x, bbox[1]-pad_y, bbox[2]+pad_x, bbox[3]+pad_y,
                                        fill="#fffbe6", outline="black", width=1, tags="cursor")
        plot.tag_lower(rect_id, text_id)

#--------------------------------------------------
# calibrate the spectrometer

def fit_wavelength_calibration():

    global spectrometer_offset, spectrometer_resolution, polyfit, is_calibrated, selected_ref_index

    # Paare bilden: (pixel, wavelength) – wichtig, damit nichts vertauscht wird
    pairs = [(ref_pixels_px[i], ref_values_nm[i]) for i in range(N_REF_POINTS) if ref_pixels_px[i] > 0]
    n_used = len(pairs)

    if n_used < 2:
        ref_status_label.configure(text="Select at least two reference points!")
        return

    pixels = np.array([p[0] for p in pairs], dtype=float)
    waves  = np.array([p[1] for p in pairs], dtype=float)

    if n_used == 2:
        # linear fit: wavelength = b*x + c
        b, c = np.polyfit(pixels, waves, 1)
        a = 0.0
        polyfit = np.poly1d([b, c])   # grade 1
    else:
        # square fit
        a, b, c = np.polyfit(pixels, waves, 2)
        polyfit = np.poly1d([a, b, c])

    # calculate range & resolution from the fit
    w0 = float(polyfit(0))
    w1 = float(polyfit(PIXEL_NUMBER - 1))
    spectrometer_offset = min(w0, w1)
    spectrometer_end    = max(w0, w1)

    # nm per pixel
    spectrometer_resolution = abs(w1 - w0) / (PIXEL_NUMBER - 1)

    is_calibrated = True
    Plot_Sensordata_Wavelength()

    selected_ref_index = -1

    show_fit_result_window(polyfit, n_used)

    try:
      load_calibration_button.destroy()
    except Exception:
      pass

#--------------------------------------------------
# convert a given wavelength to the respective pixel number

def wavelength_to_pixel(wavelength):

    roots = (polyfit - wavelength).roots
    # take only real roots, which are in the pixel range
    real_roots = [r.real for r in roots if abs(r.imag) < 1e-6]
    # candidate in valid pixel range
    candidates = [r for r in real_roots if 0 <= r <= PIXEL_NUMBER-1]
    if not candidates:
        return 0
    return int(round(candidates[0]))

#--------------------------------------------------
def close_peak_window():

  plot.delete("peaks")
  peak_window.destroy()

#--------------------------------------------------
# update the plot canvas with the new sensor data

def update_plot():

  #global polyfit

  if not master.winfo_exists():
    return

  if is_reading:
    # getting the integration time from the slider and using this value for the exposure time as well as for the frame time
    exposure_time = int(slider_exp_time.get()) * faktor.get()
    frame_time = exposure_time;

    if CAMERA_TYPE == "STD/PRO":
      # reading out the camera; for details refer to the EasyAccess documentation
      libe9u.e9u_LSMD_set_times_us (0, exposure_time, frame_time)
      libe9u.e9u_LSMD_get_next_frame (0)
    else:
      # reading out the camera; for details refer to the EasyAccess documentation
      libe9u.e9u_LSMD_EDU_set_exp_time_us (0, exposure_time)
      libe9u.e9u_LSMD_EDU_get_next_frame (0)

    x_old = 0
    y_old = 0

    # deleting the old data points
    plot.delete("data")

    for pixel in range(0, PIXEL_NUMBER):

        counts = pointer[pixel]

        # scaling the sensor data to fit into the plot region
        x = int(pixel / PIXEL_NUMBER * plot_width)
        y = int(counts / COUNT_MAX * plot_height)

        # plotting the data point via connecting the current data with the last one
        plot.create_line(x_old + PLOT_OFFSETX,plot_height + PLOT_OFFSETY - y_old,x + PLOT_OFFSETX,plot_height + PLOT_OFFSETY - y, tags="data")

        x_old = x
        y_old = y

  else:

    # getting the position of the mouse pointer
    pointer_x = plot.winfo_pointerx() - plot.winfo_rootx() - PLOT_OFFSETX
    pointer_y = plot.winfo_pointery() - plot.winfo_rooty() - PLOT_OFFSETY

    # calculating the pixel number out of the x value
    pixel = int(PIXEL_NUMBER / plot_width * pointer_x + 0.5)

    # getting the signal value at the pixel number
    count_pos = pointer[pixel]
    count_y = int(count_pos / COUNT_MAX * plot_height)

    if is_calibrated == 1:
      pointer_wavelength = str(round(float(polyfit(pointer_x * PIXEL_NUMBER / plot_width)) ,1)) + "nm"
    else:
      pointer_wavelength = ""
    #pixel_text = "Pixel " + str(pixel) + ": counts =" + str(count_pos) + "\n" + str(pointer_wavelength)

    draw_cursor_target(pointer_x, count_y, pixel, count_pos, pointer_wavelength)

  plot.update()

  master.after(1, update_plot)

#--------------------------------------------------
# reading the serial number from the camera eeprom
def SerialNumber():

  e9u_LSMD_EEPROM_SN = 1

  if CAMERA_TYPE == "STD/PRO":
    serial_number_pointer = libe9u.e9u_LSMD_eeprom_string(0, e9u_LSMD_EEPROM_SN)
    serial_number_content = ctypes.string_at(serial_number_pointer)
    serial_number_string = serial_number_content.decode("utf-8")
    spectrometer_serial_number = serial_number_string.split(" ")[1]
    return spectrometer_serial_number
  else:
   # serial_number_pointer = libe9u.e9u_LSMD_EDU_eeprom_string(0, e9u_LSMD_EEPROM_SN)
   return "n/a"

#--------------------------------------------------

def select_ref_point(index):
    global selected_ref_index

    selected_ref_index = index

    # set all buttons to "neutral"
    for i, button in enumerate(ref_buttons):
        if ref_pixels_px[i] == 0:
            button.configure(bg="#fffbe6")  # not yet set
        else:
            button.configure(bg="#90EE90")  # pixel value already set

    # mark the selected button yellow
    ref_buttons[index].configure(bg="#FFE680")

    # display info text
    ref_status_label.configure(text=ref_pick_instructions[index])

#--------------------------------------------------

def on_right_click_set_ref(event):

    global ref_pixels_px, ref_buttons, ref_value_labels, ref_status_label, fit_calibration_button

    pointer_x = plot.winfo_pointerx() - plot.winfo_rootx() - PLOT_OFFSETX
    pixel = int(PIXEL_NUMBER / plot_width * pointer_x + 0.5)

    if 0 <= selected_ref_index < N_REF_POINTS:
        # Pixelwert setzen
        ref_pixels_px[selected_ref_index] = int(pixel)

        # Button grün für "gesetzt"
        ref_buttons[selected_ref_index].configure(bg="#90EE90")

        # Textfeld neben dem Button aktualisieren
        text_output = "at pixel No. " + str(pixel)
        ref_value_labels[selected_ref_index].configure(text=text_output)

    ref_status_label.configure(text="Select a reference point!")

    TestSave()

#--------------------------------------------------

def close_calibration_window():

  global calibration_window

  calibration_window.destroy()

#--------------------------------------------------

def TestSave(event=None):

    global Calibrate_save_button, fit_calibration_button

    n_set = sum(1 for p in ref_pixels_px if p > 0)
    has_name = bool(Calibration_file_name_entry.get().strip())

    if fit_calibration_button is not None:
        if n_set >= N_REF_POINTS:
            fit_calibration_button.config(state="normal", bg="#FFE680")
        else:
            fit_calibration_button.config(state="disabled", bg="#fffbe6")

    if n_set >= N_REF_POINTS and has_name:
        Calibrate_save_button.config(state="normal", bg="#FFE680")
    else:
        Calibrate_save_button.config(state="disabled", bg="#fffbe6")

#--------------------------------------------------

def open_calibration_dialog():

    global ref_buttons, ref_value_labels, ref_status_label, fit_calibration_button, calibration_window, Calibrate_save_button, Calibration_file_name_entry

    ref_buttons = []
    ref_value_labels = []

    window_width = 400
    window_height = 200 + N_REF_POINTS * 50
    base_x_button = 30
    base_x_text = 180
    base_y = 20
    dy = 40  # vertikaler Abstand zwischen den Zeilen

    calibration_window = Toplevel(master)
    calibration_window.title("Spectrometer Calibration")
    calibration_window.attributes("-topmost", True)

    # setting the size of the display window to cover nearly the complete screen
    calibration_window.geometry(str(window_width) + "x" + str(window_height) + "+1400+300")

    for i in range(N_REF_POINTS):
        y = base_y + 50 + i * dy

        btn = Button(
            calibration_window,
            text=ref_labels[i],
            font=("Arial", 12),
            bg="#fffbe6",
            command=lambda idx=i: select_ref_point(idx)
        )
        btn.place(x=base_x_button, y=y)
        ref_buttons.append(btn)

        lbl = Label(calibration_window, text="at pixel No. n/a", font=("Arial", 12))
        lbl.place(x=base_x_text, y=y+4)
        ref_value_labels.append(lbl)

    ref_status_label = Label(calibration_window, text="Select a reference point!", font=("Arial", 12), fg="black", anchor="nw", justify="left")
    ref_status_label.place(x=base_x_button, y=20)

    fit_calibration_button = Button(
        calibration_window,
        text="Calibrate",
        font=("Arial", 12),
        bg="#fffbe6",
        state="disabled",
        command=fit_wavelength_calibration
    )
    fit_calibration_button.place(x=base_x_button, y=base_y + 50 + N_REF_POINTS * dy + 20)

    Calibration_file_name_text = Label(calibration_window, text="File name:", font=("Arial", 12))
    Calibration_file_name_text.place(x=base_x_button, y=base_y + 50 + N_REF_POINTS * dy + 75)

    Calibration_file_name_entry = Entry(calibration_window, font=("Arial", 12), width=20)
    Calibration_file_name_entry.place(x=base_x_text, y=base_y + 50 + N_REF_POINTS * dy + 75)

    Calibrate_save_button = Button(calibration_window, text="Save", font=("Arial", 12), bg="#fffbe6", state="disabled", command=Writeto_ConfigFile)
    Calibrate_save_button.place(x=base_x_button, y=base_y + 50 + N_REF_POINTS * dy + 125)

    Calibrate_button_exit = Button(calibration_window, text="Exit", font=("Arial", 12), bg="#fffbe6", command=close_calibration_window)
    Calibrate_button_exit.place(x=base_x_text,y=base_y + 50 + N_REF_POINTS * dy + 125)

    plot.bind("<Button-3>", on_right_click_set_ref)
    master.bind_all("<Key-space>", on_right_click_set_ref)

    Calibration_file_name_entry.bind("<KeyRelease>", TestSave)

#--------------------------------------------------

def close_window():
    global calibration_window

    if calibration_window is not None:
        try:
            if calibration_window.winfo_exists():
                calibration_window.destroy()
        except Exception:
            pass
        calibration_window = None

    master.destroy()

####################################################################################################

def Readfrom_ConfigFile():

  global polyfit, spectrometer_offset, spectrometer_end, spectrometer_resolution

  file_name = askopenfilename()

  with open(file_name, "r", encoding="utf-8") as f:
    erste_zeile  = f.readline()
    zweite_zeile = f.readline()

    polyfit_0 = float(zweite_zeile.split(",")[0])
    polyfit_1 = float(zweite_zeile.split(",")[1])
    polyfit_2 = float(zweite_zeile.split(",")[2])

    print(polyfit_0)
    print(polyfit_1)
    print(polyfit_2)

    polyfit = np.poly1d([polyfit_0, polyfit_1, polyfit_2])

    spectrometer_offset = polyfit[2]
    spectrometer_end = polyfit[2] + polyfit[1]*PIXEL_NUMBER + polyfit[0]*PIXEL_NUMBER*PIXEL_NUMBER
    spectrometer_resolution = (spectrometer_end - spectrometer_offset) / PIXEL_NUMBER

    is_calibrated = True
    Plot_Sensordata_Wavelength()

####################################################################################################

def Writeto_ConfigFile():

    global ref_status_label, Calibration_file_name_entry, polyfit

    base = str(Calibration_file_name_entry.get().strip())
    if not base:
        ref_status_label.configure(text="Please enter file name!")
        return

    file_name = base + "_config.csv"

    # Koeffizienten robust holen (poly1d oder Liste/Array)
    coeffs = list(polyfit.c) if hasattr(polyfit, "c") else list(polyfit)

    # Immer als (a,b,c) abspeichern
    if len(coeffs) == 2:      # linear: [b,c]
        a, b, c = 0.0, coeffs[0], coeffs[1]
    else:                     # quadratisch: [a,b,c]
        a, b, c = coeffs[-3], coeffs[-2], coeffs[-1]

    with open(file_name, "w", encoding="utf-8") as file:
        file.write("polyfit_a,polyfit_b,polyfit_c\n")
        file.write(f"{a},{b},{c}\n")

    # write a second file with the calibration date; needed for other (older) scripts

    serial_number = SerialNumber()
    if serial_number != "n/a":

      file_name = str(serial_number) + "_config.txt"

      spectrometer_offset = c
      spectrometer_end = c + b*PIXEL_NUMBER + a*PIXEL_NUMBER*PIXEL_NUMBER
      spectrometer_resolution = (spectrometer_end - spectrometer_offset) / PIXEL_NUMBER

      with open(file_name, "w", encoding="utf-8") as file:
          file.write(f"spectrometer_offset {c}\n")
          file.write(f"spectrometer_resolution {spectrometer_resolution}\n")
          file.write(f"polyfit {a},{b},{c}\n")

    print(f"Datei gespeichert: {file_name}")
    ref_status_label.configure(text="Calibration file saved")

####################################################################################################

def show_fit_result_window(polyfit, n_used_points):

    # Zeigt ein kleines Fenster mit Fit-Parametern und didaktischer Erklärung.
    # polyfit: np.poly1d (Grad 1 oder 2)
    #          n_used_points: Anzahl verwendeter Referenzpunkte (z.B. 2 oder >=3)

    # Koeffizienten robust holen
    coeffs = list(polyfit.c) if hasattr(polyfit, "c") else list(polyfit)

    # Fenster erstellen
    win = Toplevel(master)
    win.title("Calibration result")
    win.attributes("-topmost", True)
    win.resizable(False, False)

    # Position: ein Stück neben dein Calibration-Fenster (oder einfach fix)
    win.geometry("520x220+850+120")

    # Inhaltstext bauen
    if len(coeffs) == 2:   # linear: wavelength = b*x + c
        b, c = coeffs
        a = 0.0

        txt = (
            f"Fit type: linear (2 reference points)\n"
            f"Model:  λ(px) = b·px + c\n\n"
            f"b = {b:.6g}  nm/px   (scale / dispersion)\n"
            f"c = {c:.6g}  nm      (offset at pixel 0)\n\n"
            f"Meaning:\n"
            f"• b tells how many nanometers correspond to one pixel.\n"
            f"• c is the wavelength assigned to pixel 0."
        )
    else:                 # quadratic: wavelength = a*x² + b*x + c
        a, b, c = coeffs[-3], coeffs[-2], coeffs[-1]

        txt = (
            f"Fit type: quadratic (≥3 reference points)\n"
            f"Model:  λ(px) = a·px² + b·px + c\n\n"
            f"a = {a:.6g}  nm/px²  (non-linearity)\n"
            f"b = {b:.6g}  nm/px   (scale / dispersion)\n"
            f"c = {c:.6g}  nm      (offset at pixel 0)\n\n"
            f"Meaning:\n"
            f"• a corrects optical non-linearity (curvature).\n"
            f"• b sets the main dispersion (nm per pixel).\n"
            f"• c is the wavelength assigned to pixel 0."
        )

    # Header
    body = Label(
    win,
    text=txt,
    font=("Arial", 12),
    justify="left",
    anchor="nw"          # wichtig: nicht vertikal zentrieren
    )
    body.pack(fill="both", expand=True, padx=12, pady=6)

    # Textbox (Label reicht, aber Text mit justify)
    Label(win, text=txt, font=("Arial", 12), justify="left").pack(anchor="w", padx=12, pady=6)

    # Close button
    Button(win, text="OK", font=("Arial Bold", 12), command=win.destroy).pack(pady=(4, 10))


####################################################################################################
# Save current sensor data to CSV.
# Columns: pixel, counts, wavelength_nm (blank if not calibrated)

def save_sensordata_csv():

    global pointer, is_calibrated, polyfit

    # take a snapshot of the sensor data
    counts_snapshot = [int(pointer[i]) for i in range(PIXEL_NUMBER)]
    calibrated = bool(is_calibrated)

    # Default-Dateiname
    try:
        sn = SerialNumber()
    except Exception:
        sn = "n_a"

    import datetime
    ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
    default_name = f"spectrum_{sn}_{ts}.csv"

    # Datei-Dialog
    file_path = asksaveasfilename(
        title="Save spectrum as CSV",
        defaultextension=".csv",
        initialfile=default_name,
        filetypes=[("CSV files", "*.csv"), ("All files", "*.*")]
    )

    if not file_path:
        return  # user cancelled

    import csv
    with open(file_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.writer(f, delimiter=",")

        # Header
        writer.writerow(["pixel", "counts", "wavelength_nm"])

        # Daten
        if calibrated:
            for px, c in enumerate(counts_snapshot):
                wl = float(polyfit(px))  # nm
                writer.writerow([px, c, f"{wl:.6f}"])
        else:
            for px, c in enumerate(counts_snapshot):
                writer.writerow([px, c, ""])

    # Optional: Rückmeldung
    try:
        # falls du das Info-Label umbenannt hast, hier anpassen
        # z.B. ref_status_label.configure(...)
        print(f"Saved spectrum: {file_path}")
    except Exception:
        pass

####################################################
# main routine

print("EasyCalibratePro.py V1.0\nSearching for camera.... (this might take some seconds)...")

if CAMERA_TYPE == "STD/PRO":
  # open external DLL
  libe9u = ctypes.WinDLL("./libe9u_LSMD_x64.dll")

  # define argument and return types for the used functions
  libe9u.e9u_LSMD_search_for_camera.argtype = ctypes.c_uint
  libe9u.e9u_LSMD_search_for_camera.restype = ctypes.c_int

  libe9u.e9u_LSMD_start_camera_async.argtype = ctypes.c_uint
  libe9u.e9u_LSMD_start_camera_async.restype = ctypes.c_int

  libe9u.e9u_LSMD_set_times_us.argtypes = (ctypes.c_uint, ctypes.c_uint, ctypes.c_uint)
  libe9u.e9u_LSMD_set_times_us.restype = ctypes.c_int

  libe9u.e9u_LSMD_get_next_frame.argtype = ctypes.c_uint
  libe9u.e9u_LSMD_get_next_frame.restype = ctypes.c_int

  libe9u.e9u_LSMD_get_pixel_pointer.argtypes = (ctypes.c_uint, ctypes.c_uint)
  libe9u.e9u_LSMD_get_pixel_pointer.restype = ctypes.POINTER(ctypes.c_uint16)

  libe9u.e9u_LSMD_eeprom_string.restype = ctypes.POINTER(ctypes.c_char)

  # Seach for a suitable camera on all USB ports and quit with returning the error code, if no camera is found
  i_status = libe9u.e9u_LSMD_search_for_camera(0)
  if i_status != 0:
    print(f"No camera found! Error Code: {i_status}")
    exit(1)

  print("Starting camera: ", end="")
  libe9u.e9u_LSMD_start_camera_async(0)

  # getting the pointer to the array containing the sensor data
  pointer = libe9u.e9u_LSMD_get_pixel_pointer(0, 0)

else:

  # open external DLL for 64 bit systems
  libe9u = ctypes.WinDLL('./libe9u_LSMD_EDU_x64.dll')

  # define argument and return types for the used functions
  libe9u.e9u_LSMD_EDU_search_for_camera.argtype = ctypes.c_uint
  libe9u.e9u_LSMD_EDU_search_for_camera.restype = ctypes.c_int

  #libe9u.e9u_LSMD_start_camera_async.argtype = ctypes.c_uint
  #libe9u.e9u_LSMD_start_camera_async.restype = ctypes.c_int

  libe9u.e9u_LSMD_EDU_set_exp_time_us.argtypes = (ctypes.c_uint, ctypes.c_uint)
  libe9u.e9u_LSMD_EDU_set_exp_time_us.restype = ctypes.c_int

  libe9u.e9u_LSMD_EDU_get_next_frame.argtype = ctypes.c_uint
  libe9u.e9u_LSMD_EDU_get_next_frame.restype = ctypes.c_int

  libe9u.e9u_LSMD_EDU_get_pixel_pointer.argtype = ctypes.c_uint16
  libe9u.e9u_LSMD_EDU_get_pixel_pointer.restype = ctypes.POINTER(ctypes.c_uint16)

  # Seach for a suitable camera on all USB ports and quit with returning the error code, if no camera is found
  print("Starting camera: ", end='')
  i_status = libe9u.e9u_LSMD_EDU_search_for_camera(0)
  if i_status != 0:
    print("No camera found! Error Code: " + str(i_status))
    exit(1)

  #libe9u.e9u_LSMD_EDU_search_for_camera(0)

  #enable 12 bit mode, "2" like two bytes per pixel
  libe9u.e9u_LSMD_EDU_send_byte(0, ord('2'))

  # getting the pointer to the array containing the sensor data
  pointer = libe9u.e9u_LSMD_EDU_get_pixel_pointer(0, 0)


# defining the master window for graphical output
master = Tk()

# getting the size of the master window
screen_width = master.winfo_screenwidth()
screen_height = master.winfo_screenheight()

# setting the size of the display window to cover nearly the complete screen
master.geometry(f"{screen_width - 50}x{screen_height - 100}+10+20")

# defining the dimensions for the plot area
plot_width = screen_width - 150
plot_height = screen_height - 350

# defining and packing the control/output widgets
statusline = Frame(master)
statusline.pack(side="top")
plot = Canvas(master)
plot.pack(side ="bottom", fill=BOTH, expand=YES)

# output label for program name and version number
output_peak_width = Label(statusline, text="EasyCalibratePro.py V1.0 / www.eureca.de\nx-axis: pixel number/wavelength\ny-axis: counts", justify=LEFT, font=("Arial Bold", 18))
output_peak_width.pack(side="left", padx=0)

# defining slider for integration time and setting it to 10
faktor = IntVar()
slider_exp_time = Scale(statusline, from_=1, to=10000, length=plot_width/4, orient=HORIZONTAL, label="Integration time:", font=("Arial Bold", 18))
slider_exp_time.pack(side="left", padx=50)
slider_exp_time.set(100)

# defining two radio buttons for switching the integration time between µs and ms
Radiobutton_us = Radiobutton(statusline, text="µs", font=("Arial Bold", 18), variable=faktor, value=1)
Radiobutton_us.pack(side="left", padx=20)
Radiobutton_us.invoke()
Radiobutton_ms = Radiobutton(statusline, text="ms", font=("Arial Bold", 18), variable=faktor, value=1000)
Radiobutton_ms.pack(side="left", padx=20)

start_stop_button = Button(statusline, text="Start/Stop", font=("Arial Bold", 18), command=toggle_reading)
start_stop_button.pack(side="left", padx=20)

find_peaks_button = Button(statusline, text="Start Calibration", font=("Arial Bold", 18), command=open_calibration_dialog)
find_peaks_button.pack(side="left", padx=20)

save_csv_button = Button(statusline, text="Save", font=("Arial Bold", 18), command=save_sensordata_csv)
save_csv_button.pack(side="left", padx=20)

# defining the exit button with the closing function
exit_button = Button(statusline, text="Exit", font=("Arial Bold", 18), command=close_window)
exit_button.pack(side="left", padx=20)

# drawing a rectangular frame for the sensor data
Sensor_Plot = plot.create_rectangle(PLOT_OFFSETX, PLOT_OFFSETY, plot_width + PLOT_OFFSETX, plot_height + PLOT_OFFSETY, fill="#fffbe6")

# y-axis marking with count number
for y in range(14):
  plot.create_line(PLOT_OFFSETX-10, PLOT_OFFSETY + plot_height - y*5000*plot_height/COUNT_MAX,
                   PLOT_OFFSETX, PLOT_OFFSETY + plot_height - y*5000*plot_height/COUNT_MAX)
  text_output = str(y*5) + "k"
  plot.create_text(PLOT_OFFSETX-40, plot_height + PLOT_OFFSETY - y*5000*plot_height/COUNT_MAX,font=("Arial Bold", 18),text=text_output,fill="black")

  # draw hozizontal gray lines into the plot windows with the exception of the base line
  if y > 0:
    plot.create_line(PLOT_OFFSETX + plot_width, PLOT_OFFSETY + plot_height - y*5000*plot_height/COUNT_MAX,
                     PLOT_OFFSETX, PLOT_OFFSETY + plot_height - y*5000*plot_height/COUNT_MAX, fill="#dddddd")

spectrometer_offset = 0.0
spectrometer_resolution = 0.0

# check if there is a configuration file in the same directory
file_name = f"{SerialNumber()}_config.txt"
if os.path.exists(file_name):
  # reading configuration file, values indicated here will overwrite the default values
  file = open(file_name, "r")
  for line in file:
    config_variable = line.split()[0]
    config_value = line.split()[1]
    if config_variable == "spectrometer_offset":
      spectrometer_offset = float(config_value)
    if config_variable == "spectrometer_resolution":
      spectrometer_resolution = float(config_value)
    if config_variable == "polyfit":
      polyfit_0 = float(config_value.split(",")[0])
      polyfit_1 = float(config_value.split(",")[1])
      polyfit_2 = float(config_value.split(",")[2])
      polyfit = np.poly1d([polyfit_0, polyfit_1, polyfit_2])

  file.close()

  # print the sensor wavelength at the lower side of the plot window
  Plot_Sensordata_Wavelength()
  is_calibrated = True
else:
  plot.create_text(PLOT_OFFSETX + 100, plot_height + PLOT_OFFSETY + 28, font=("Arial Bold", 18),text="not calibrated",fill="black", tags="wavelength")

  load_calibration_button = Button(plot, text="Load Calibration", font=("Arial Bold", 18), command=Readfrom_ConfigFile)
  load_calibration_button.place(x=PLOT_OFFSETX + 250, y=plot_height + PLOT_OFFSETY + 5)

update_plot()

master.mainloop()



