#!/usr/bin/env python3
"""
Star-Oddi DST CTD-online serial reader, with calibration conversion.
----------------------------------------------------------------------
Works unchanged on Windows and Linux (pyserial abstracts the port naming:
COM3 on Windows, /dev/ttyUSB0 on Linux, etc.)

Protocol + calibration formulas reference: Star-Oddi
"Communicating with DST CTD online" manual, sections 3 & 4, Appendix A2-A4.

Calibration for this specific sensor is hardcoded below in CAT_DATA
(taken from its .CAT file), so no separate file is needed at runtime.

Usage:
    python dst_ctd_online_reader.py            (lists ports, asks you to pick one)
    python dst_ctd_online_reader.py COM3       (Windows, direct)
    python dst_ctd_online_reader.py /dev/ttyUSB0   (Linux, direct)
"""

import sys
import time
import serial
import serial.tools.list_ports

BAUD = 4800
TIMEOUT = 2
# The CTD's own measurement conversion takes ~0.4s (per the manual's "Power
# draw" section) -- that's a hardware floor, not something this script can
# push past. 0.5s gives a small safety margin above that floor.
POLL_INTERVAL = 0.5

# --- Protocol constants ---
CMD_TEST = 0x00
CMD_VERSION = 0x04
CMD_PCMODE = 0x0C
CMD_POLL = 0x01
ACK = 0x55
NAK = 0x33
PCMODE_ACK = 0x02

# Physical constants for depth (assumes seawater)
GRAVITY = 9.80665
SEAWATER_DENSITY = 1.026
GRAVITY_CONV = 100 / GRAVITY

# ---------------------------------------------------------------------
# Hardcoded calibration data for this specific sensor (its .CAT file contents).
# 39 values, in the fixed order documented in the manual's Appendix A3:
#   Temp.C0-C5, Pres.C0-C5, Ptc.C1-C5, Tpr,
#   Cond.C0-C7, Ctc.C1-C5, Ctc1.C1-C5, Tcr, L, H
# If you ever get a new/updated CAT file for this unit (re-calibration),
# just replace the numbers below.
# ---------------------------------------------------------------------
CAT_DATA = """
139.97266004692
-0.190016515763284
0.000164129890874462
-8.58001425722184E-8
2.32247060075268E-11
-2.62425916141576E-15
-1.90714542999785
0.00646417362749347
2.33016985256053E-7
-2.14975507699466E-10
8.38801991239045E-14
-1.18504757857332E-17
-2.04836013031339
-0.146513522518316
0.00664628931327262
-0.000158500921053675
1.44911085952983E-6
21.6177854492197
112.858074341173
-0.0634249106144219
4.72779392304789E-5
-3.06055123467665E-8
1.1393832826894E-11
-2.40594823932034E-15
2.61941285625249E-19
-1.10922241087759E-23
2.28978490503898
-0.0172068918625733
-0.00013407065735925
1.7963718401507E-5
-3.59601724620871E-7
0.49466010211695
0.0151454209438859
-0.00102046031219291
1.2535007050768E-5
6.06382092538632E-8
32.81
926
3491
"""


# ---------------------------------------------------------------------
# Calibration handling
# ---------------------------------------------------------------------
class Calibration:
    """Applies a Star-Oddi CAT calibration (39 values, in order)."""

    def __init__(self, cat_path=None, cat_text=None):
        if cat_text is not None:
            values = self._parse_text(cat_text)
        else:
            values = self._parse_file(cat_path)
        if len(values) != 39:
            raise ValueError(
                f"Expected 39 calibration values, found {len(values)}. "
                "Check CAT_DATA wasn't edited or truncated."
            )
        i = iter(values)
        self.temp_c = [next(i) for _ in range(6)]      # Temp.C0-C5
        self.pres_c = [next(i) for _ in range(6)]       # Pres.C0-C5
        self.ptc_c = [None] + [next(i) for _ in range(5)]  # Ptc.C1-C5 (index from 1)
        self.tpr = next(i)                               # pressure temp ref
        self.cond_c = [next(i) for _ in range(8)]        # Cond.C0-C7
        self.ctc_c = [None] + [next(i) for _ in range(5)]   # Ctc.C1-C5
        self.ctc1_c = [None] + [next(i) for _ in range(5)]  # Ctc1.C1-C5
        self.tcr = next(i)                                # conductivity temp ref
        self.load_l = next(i)
        self.load_h = next(i)

    @staticmethod
    def _parse_lines(lines):
        values = []
        for line in lines:
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            try:
                values.append(float(line))
            except ValueError:
                values.append(float(line.replace(",", ".")))
        return values

    @classmethod
    def _parse_text(cls, text):
        return cls._parse_lines(text.splitlines())

    @classmethod
    def _parse_file(cls, path):
        with open(path, "r", encoding="utf-8", errors="ignore") as f:
            return cls._parse_lines(f.readlines())

    @staticmethod
    def _poly(coeffs, x):
        return sum(c * x**n for n, c in enumerate(coeffs))

    def temperature(self, T_raw):
        """Eq (4): raw decimal -> degrees C"""
        return self._poly(self.temp_c, T_raw)

    def pressure_and_depth(self, P_raw, Tv):
        """Eq (6),(7),(8): raw decimal + temp(C) -> (Bar, meters)"""
        Pc = P_raw
        for n in range(1, 6):
            Pc += self.ptc_c[n] * (self.tpr**n - Tv**n)
        Pv = self._poly(self.pres_c, Pc)
        depth_m = Pv * GRAVITY_CONV / SEAWATER_DENSITY
        return Pv, depth_m

    def conductivity(self, C_raw, Tv):
        """Eq (10)-(15): raw decimal + temp(C) -> mS/cm"""
        Cc0 = C_raw
        Cc1 = C_raw
        for n in range(1, 6):
            Cc0 += self.ctc_c[n] * (self.tcr**n - Tv**n)
            Cc1 += self.ctc1_c[n] * (self.tcr**n - Tv**n)
        A = (Cc1 - Cc0) / (self.load_h - self.load_l)
        B = Cc0 - A * self.load_l
        Cc = B + A * C_raw
        return self._poly(self.cond_c, Cc)

    @staticmethod
    def salinity(Cv_mS_cm, Tv_C, Pv_bar):
        """PSS-78 (Appendix A4): mS/cm, degC, Bar -> practical salinity (PSU),
        or None if conductivity is at/below zero (e.g. sensor in air, or noise
        near the zero crossing) -- the formula's sqrt() goes complex/undefined
        there, which isn't a real reading."""
        if Cv_mS_cm <= 0:
            return None
        R = Cv_mS_cm / 42.914
        T68 = 1.00024 * Tv_C
        p = abs(Pv_bar * 10)  # dbar

        A1, A2, A3 = 2.070e-5, -6.370e-10, 3.989e-15
        B1, B2, B3, B4 = 3.426e-2, 4.464e-4, 4.215e-1, -3.107e-3
        Rp = 1 + (A1 * p + A2 * p**2 + A3 * p**3) / (
            1 + B1 * T68 + B2 * T68**2 + B3 * R + B4 * T68 * R
        )

        c0, c1, c2, c3, c4 = 6.766097e-1, 2.00564e-2, 1.104259e-4, -6.9698e-7, 1.0031e-9
        rT = c0 + c1 * T68 + c2 * T68**2 + c3 * T68**3 + c4 * T68**4

        RT = R / (rT * Rp)
        if RT < 0:
            return None
        RTr = RT**0.5

        a0, a1, a2, a3, a4, a5 = 0.008, -0.1692, 25.3851, 14.0941, -7.0261, 2.7081
        b0, b1, b2, b3, b4, b5 = 0.0005, -0.0056, -0.0066, -0.0375, 0.0636, -0.0144
        k = 0.0162

        S = (
            a0 + a1 * RTr + a2 * RT + a3 * RT * RTr + a4 * RT**2 + a5 * RT**2 * RTr
            + ((T68 - 15) / (1 + k * (T68 - 15)))
            * (b0 + b1 * RTr + b2 * RT + b3 * RT * RTr + b4 * RT**2 + b5 * RT**2 * RTr)
        )
        return S


# ---------------------------------------------------------------------
# Serial / protocol handling
# ---------------------------------------------------------------------
def list_available_ports():
    ports = list(serial.tools.list_ports.comports())
    if not ports:
        print("No serial ports found.")
        return []
    print("Available serial ports:")
    for i, p in enumerate(ports):
        print(f"  [{i}] {p.device}  -  {p.description}")
    return ports


def choose_port():
    ports = list_available_ports()
    if not ports:
        sys.exit("Plug in the USB-RS232 adapter and try again.")
    if len(ports) == 1:
        print(f"Using the only port found: {ports[0].device}")
        return ports[0].device
    idx = input("Enter the number of the port to use: ").strip()
    try:
        return ports[int(idx)].device
    except (ValueError, IndexError):
        sys.exit("Invalid selection.")


def wake_and_connect(ser):
    ser.dtr = True
    ser.rts = True
    time.sleep(0.5)
    ser.reset_input_buffer()

    ser.write(bytes([CMD_TEST]))
    echo = ser.read(1)
    ack = ser.read(1)
    if echo != bytes([CMD_TEST]) or ack != bytes([ACK]):
        return False

    ser.write(bytes([CMD_PCMODE]))
    echo = ser.read(1)
    resp = ser.read(1)
    if echo != bytes([CMD_PCMODE]) or resp != bytes([PCMODE_ACK]):
        return False

    return True


def get_version(ser):
    ser.write(bytes([CMD_VERSION]))
    echo = ser.read(1)
    if echo != bytes([CMD_VERSION]):
        return None
    ser.write(bytes([ACK]))
    data = ser.read(2)
    if len(data) != 2:
        return None
    return data[0], data[1]


def poll_once(ser):
    ser.write(bytes([CMD_POLL]))
    echo = ser.read(1)
    if echo != bytes([CMD_POLL]):
        return None
    ser.write(bytes([ACK]))
    data = ser.read(6)
    if len(data) != 6:
        return None
    Tl, Th, Pl, Ph, Cl, Ch = data
    T = Tl + (Th * 256)
    P = Pl + (Ph * 256)
    C = Cl + (Ch * 256)
    return T, P, C


# ---------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------
def main():
    port = sys.argv[1] if len(sys.argv) > 1 else choose_port()

    try:
        cal = Calibration(cat_text=CAT_DATA)
        print("Loaded hardcoded calibration data for this sensor.")
    except Exception as e:
        sys.exit(f"Could not load calibration data: {e}")

    print(f"\nOpening {port} at {BAUD} baud, 8N1 ...")

    with serial.Serial(port, baudrate=BAUD, bytesize=8, parity="N",
                        stopbits=1, timeout=TIMEOUT) as ser:

        print("Waking up the CTD and testing connection ...")
        if not wake_and_connect(ser):
            sys.exit("No response from the CTD. Check wiring, port, and power (+V).")
        print("Connected and in PC-mode.")

        ver = get_version(ser)
        if ver is None:
            sys.exit("Could not read version info.")
        logger_type, logger_version = ver
        is_v20 = logger_version >= 20
        print(f"Logger type: {logger_type}, firmware version: {logger_version}"
              + (" (V20 conductivity inversion applies)" if is_v20 else ""))

        print("\nPolling for data. Press Ctrl+C to stop.\n")
        if cal:
            print(f"{'Time':<10} {'Temp(C)':>9} {'Depth(m)':>9} {'Cond(mS/cm)':>13} {'Sal(PSU)':>9}")
        else:
            print(f"{'Time':<10} {'T_raw':>8} {'P_raw':>8} {'C_raw':>8}")

        try:
            while True:
                loop_start = time.time()

                result = poll_once(ser)
                if result is None:
                    print("  (no response, retrying)")
                    time.sleep(1)
                    continue
                T, P, C = result
                if is_v20:
                    C = 4095 - C
                ts = time.strftime("%H:%M:%S")

                if cal:
                    Tv = cal.temperature(T)
                    Pv, depth_m = cal.pressure_and_depth(P, Tv)
                    Cv = cal.conductivity(C, Tv)
                    Sal = cal.salinity(Cv, Tv, Pv)
                    sal_str = f"{Sal:>9.3f}" if Sal is not None else f"{'n/a':>9}"
                    print(f"{ts:<10} {Tv:>9.3f} {depth_m:>9.2f} {Cv:>13.4f} {sal_str}")
                else:
                    print(f"{ts:<10} {T:>8} {P:>8} {C:>8}")

                # Pace to POLL_INTERVAL, accounting for time already spent
                # talking to the CTD (send/wake/measure/read), rather than
                # blindly sleeping the full interval on top.
                elapsed = time.time() - loop_start
                remaining = POLL_INTERVAL - elapsed
                if remaining > 0:
                    time.sleep(remaining)
        except KeyboardInterrupt:
            print("\nStopped.")


if __name__ == "__main__":
    main()