From 2abad9a66e8242a32f5e63bd3ab6a320776e8272 Mon Sep 17 00:00:00 2001 From: richardsheridan Date: Tue, 19 May 2026 15:25:53 -0400 Subject: [PATCH 01/18] feat(dynamfit): implement smooth_prony_fit --- services/app/dynamfit/dynamfit2.py | 34 +++-- services/app/dynamfit/helper.py | 224 +++++++++++++---------------- services/app/dynamfit/routes.py | 15 +- services/app/utils/util.py | 111 ++++---------- 4 files changed, 157 insertions(+), 227 deletions(-) diff --git a/services/app/dynamfit/dynamfit2.py b/services/app/dynamfit/dynamfit2.py index a26531c21..205709a2c 100644 --- a/services/app/dynamfit/dynamfit2.py +++ b/services/app/dynamfit/dynamfit2.py @@ -1,11 +1,15 @@ -import pandas as pd -import numpy as np import os +os.environ['OPENBLAS_NUM_THREADS'] = '1' from app.config import Config +import numpy as np +import pandas as pd import plotly.express as px import plotly.graph_objects as go -from app.dynamfit.helper import prony_linear_fit, compute_rspectum, tts_frequency_to_temperature, tts_temperature_to_frequency, tts_temperature_to_frequency_V2, estimate_Tg, estimate_TL +from app.dynamfit.helper import smooth_prony_fit, compute_relaxation_spectrum, \ + tts_frequency_to_temperature, tts_temperature_to_frequency, \ + tts_temperature_to_frequency_V2, estimate_Tg, estimate_TL, compute_complex, \ + compute_relaxation_modulus from app.utils.util import log_errors def estimate_shift_model_parameters(uploadData, shift_model, Tg_estimate, C1_estimate, C2_estimate, Ea_estimate, TL_estimate, domain): @@ -49,14 +53,15 @@ def check_file_exists(file_name): @log_errors -def update_line_chart(uploadData, number_of_prony, model, fit_settings, domain, Tg=None, C1=None, C2=None, Ea=None, TL=None, shift_model=None, shiftData=None): +def update_line_chart(uploadData, number_of_prony, smoothness, fit_settings, domain, + Tg=None, C1=None, C2=None, Ea=None, TL=None, shift_model=None, shiftData=None): """ Updates a line chart based on the provided data. Parameters: uploadData (pd.DataFrame): The data to be used for the chart. number_of_prony (int): The number of terms in the Prony series. - model: The model to be used for fitting. + smoothness (float): The smoothing regularization to use for the fit. fit_settings: A flag indicating whether to use fit settings. Returns: @@ -70,7 +75,6 @@ def update_line_chart(uploadData, number_of_prony, model, fit_settings, domain, """ try: dfl = uploadData - model = model N = number_of_prony @@ -269,9 +273,13 @@ def update_line_chart(uploadData, number_of_prony, model, fit_settings, domain, # Frequency Domain: # Fit prony series - tau, E, complex, relax = prony_linear_fit(df, N, model) - N_nz = np.count_nonzero(E) + tau_i, E_i = smooth_prony_fit( + omega=df['Frequency'].to_numpy(), E_stor=df['E Storage'].to_numpy(), + E_loss=df['E Loss'].to_numpy(), N=N, smoothness=smoothness) + N_nz = np.count_nonzero(E_i) + complex = compute_complex(tau_i, E_i) + relax = compute_relaxation_modulus(tau_i, E_i) # Get the column names of the first two columns as x and y x_column_name = df.columns[0] y_column_name = df.columns[1] @@ -349,8 +357,8 @@ def update_line_chart(uploadData, number_of_prony, model, fit_settings, domain, ) basis_df = pd.DataFrame() - basis_df["Time"] = tau - basis_df["E"] = E + basis_df["Time"] = tau_i + basis_df["E"] = E_i[len(E_i)-len(tau_i):] basis_df["Type"] = f"{N_nz}-Term Basis" # basis_df["Modulus"] = "E Storage" @@ -386,7 +394,7 @@ def update_line_chart(uploadData, number_of_prony, model, fit_settings, domain, ) - rspectrum = compute_rspectum(tau, E) + rspectrum = compute_relaxation_spectrum(tau_i, E_i) rspectrum["Type"] = f"{N_nz}-Term Prony" fig3a = px.line(rspectrum, x="Time", y="H", @@ -433,7 +441,7 @@ def update_line_chart(uploadData, number_of_prony, model, fit_settings, domain, fig2 = fig2a fig3 = fig3a - coef = {"tau_i":tau, "E_i":E,} + coef = {"tau_i":tau_i, "E_i":E_i[len(E_i)-len(tau_i):], } coef_df = pd.DataFrame(coef) coef_df = coef_df[coef_df.E_i != 0].reset_index(drop=False) # df.reset_index(inplace=True) @@ -447,4 +455,4 @@ def update_line_chart(uploadData, number_of_prony, model, fit_settings, domain, except ValueError: raise except Exception as e: - raise ValueError("File contains corrupt data") \ No newline at end of file + raise ValueError("File contains corrupt data") diff --git a/services/app/dynamfit/helper.py b/services/app/dynamfit/helper.py index 43bfc98de..bc0571f73 100644 --- a/services/app/dynamfit/helper.py +++ b/services/app/dynamfit/helper.py @@ -1,193 +1,167 @@ import numpy as np import pandas as pd -from scipy.interpolate import interp1d -from sklearn import linear_model +from scipy.optimize import minimize from scipy.signal import find_peaks float_correction = 1e-7 R = 8.31446261815324 # J/(mol*K) -def real_basis(omega,tau): - """ - Calculates N(number of relaxation time units) real basis functions evaluated at M frequency points. - - Parameters: - - omega: The frequency points. Shape (M,). - - tau: The relaxation time units. Shape (N,). - Returns: - - result: The calculated real basis functions. Shape (M, N). - """ - omega = np.expand_dims(omega,axis=1) - tau = np.expand_dims(tau,axis=0) - return (omega**2*tau**2)/(1+omega**2*tau**2) +def prony_basis(freq, relaxations, solid): + dt = dimensionless_time = np.outer(freq, relaxations) - -def imag_basis(omega,tau): ##calculates N imaginary basis functions evaluated at M frequency points - """ - Calculates N imaginary basis functions evaluated at M frequency points. + dt2 = dt * dt + dt2p1 = dt2 + 1 + ep_basis = dt2 / dt2p1 + epp_basis = dt / dt2p1 - Parameters: - - omega: 1-D array-like - An array of shape (M,) representing the frequency points. - - tau: 1-D array-like - An array of shape (N,) representing the basis function parameters. + if solid: + ep_basis=np.concatenate( + (np.ones_like(ep_basis, shape=(len(ep_basis), 1)), ep_basis), axis=1 + ) + epp_basis=np.concatenate( + (np.zeros_like(epp_basis, shape=(len(epp_basis), 1)), epp_basis), axis=1 + ) - Returns: - - result: ndarray - An array of shape (M,N) representing the calculated imaginary basis functions. + return np.concatenate((ep_basis, epp_basis), axis=0) - """ - omega = np.expand_dims(omega,axis=1) ##shape (M,1) - tau = np.expand_dims(tau,axis=0) ##shape (1,N) - return (omega*tau)/(1+omega**2*tau**2) ##shape (M,N) -def compute_prony2(data,tau,w): - """ - Compute the Prony2 approximation for the given data. +def prony_relaxation_space(tau_min, tau_max, N): + return np.logspace(np.log10(tau_min), np.log10(tau_max), N, endpoint=True) - Parameters: - - data: numpy array of shape (n, 2) - The input data containing the frequency and complex amplitude values. - - tau: float - The time constant parameter for the Prony2 approximation. - - w: numpy array of shape (m,) - The weight vector for the Prony2 approximation. - Returns: - - approximation: numpy array of shape (n, 4) - The Prony2 approximation of the input data, where each row contains the - frequency, real approximation, imaginary approximation, and complex - amplitude values. - """ - omega = data[:,0] - eps_inft = data[0,1] - real_approximation = np.expand_dims(np.add(np.matmul(real_basis(omega,tau),w),eps_inft),axis=1) - imag_approximation = np.expand_dims(np.matmul(imag_basis(omega,tau),w),axis=1) - approximation = np.concatenate((np.expand_dims(omega,axis=1),real_approximation,imag_approximation),axis=1) - return approximation - -def compute_complex(data,tau,w): +def compute_complex(tau_i,E_i): """ Compute the complex values of a given dataset. Parameters: - data (numpy.ndarray): The input data containing frequency and epsilon values. - tau (float): The tau value used for computations. - w (numpy.ndarray): The weight vector used for computations. + tau_i (array-like): The time constants for each energy value. + E_i (array-like): The energy values corresponding to each time constant. Returns: pandas.DataFrame: A DataFrame containing the computed frequency, real and imaginary values. """ - omega = data[:,0] - eps_inft = data[0,1] - real = (real_basis(omega,tau) @ w) + eps_inft - imag = (imag_basis(omega,tau) @ w) - # return np.concatenate((omega, real, imag)) + omega = np.logspace(-np.log10(np.max(tau_i)), -np.log10(np.min(tau_i)), 1000) + basis = prony_basis(omega,tau_i, solid=not (len(E_i) == len(tau_i))) + complex = basis @ E_i + real, imag = complex.reshape(2,1000) + return pd.DataFrame(data={"Frequency":omega, "E Storage":real, "E Loss":imag}) -def compute_relax(tau_i,E_i): + +def compute_relaxation_modulus(tau_i,E_i): """ - Compute the relaxation curve using the given parameters. + Compute the relaxation modulus using the given parameters. Parameters: - tau_i (list): A list of relaxation times. - E_i (list): A list of corresponding relaxation energies. + tau_i (array-like): The time constants for each energy value. + E_i (array-like): The energy values corresponding to each time constant. Returns: pd.DataFrame: A DataFrame containing the computed relaxation curve with two columns: "Time" and "E". """ t = np.logspace(np.log10(np.min(tau_i)), np.log10(np.max(tau_i)), 1000) - - E = 0 - for i , v in enumerate(E_i): - E += E_i[i] * np.exp(-t/tau_i[i]) + dt = dimensionless_time = np.outer(t, 1/tau_i) + solid = not (len(E_i) == len(tau_i)) + E = np.exp(-dt) @ E_i[solid:] return pd.DataFrame(data={"Time":t, "E":E}) -def compute_rspectum(tau_i,E_i): + +def compute_relaxation_spectrum(tau_i,E_i): """ - Compute the rspectum of the given data. + Compute the relaxation spectrum of the given data. Parameters: tau_i (array-like): The time constants for each energy value. E_i (array-like): The energy values corresponding to each time constant. Returns: - pd.DataFrame: A DataFrame containing the computed rspectum values with the + pd.DataFrame: A DataFrame containing the computed relaxation spectrum values with the following columns: - Time: The time values. - - H: The computed rspectum values. + - H: The computed relaxation spectrum values. """ t = np.logspace(np.log10(np.min(tau_i)), np.log10(np.max(tau_i)), 1000) - - H = 0 - for i , v in enumerate(E_i): - H += (t/tau_i[i]) * E_i[i] * np.exp(-t/tau_i[i]) + dt = dimensionless_time = np.outer(t, 1/tau_i) + solid = not (len(E_i) == len(tau_i)) + H = (dt * np.exp(-dt)) @ E_i[solid:] return pd.DataFrame(data={"Time":t, "H":H}) -def prony_linear_fit(df, N, model): + +def prony_objective(logcoefs, data, std, basis, smoothness, solid): + coefs = np.exp(logcoefs) + estimate = basis @ coefs + resid = (data - estimate) / std + loss = resid@resid + if smoothness: + curve = smoothness * np.diff(logcoefs[solid:], n=2) + loss += curve@curve + + grad = -(basis.T @ (resid / std)) + + # apply chain rule because these are functions of + # coefs rather than logcoefs + grad *= coefs + + if smoothness: + diffs = smoothness * curve # smoothness*np.diff(logcoefs[solid:], n=2) + grad_slice = grad[solid:] + grad_slice[:-2] += diffs + grad_slice[1:-1] -= 2 * diffs + grad_slice[2:] += diffs + + return loss, 2 * grad # more chain rule (squared errors) + + +def smooth_prony_fit(omega, E_stor, E_loss, N, smoothness, solid=True): """ - Perform a linear fit using the Prony method. + Perform a fit using the Prony method with coefficient smoothing. Parameters: - - df: pandas DataFrame - The input DataFrame containing the training data. + - omega: ndarray + The input array containing frequency data. + - E_stor: ndarray + The input array containing storage modulus data. + - E_loss: ndarray + The input array containing loss modulus data. - N: int The number of points to generate in the logarithmic time grid. - - model: str - The type of linear regression model to use. Options are "Linear", "LASSO", and "Ridge". + - smoothness: float + Amount of smoothing regularization to apply to the log-coefficients + - solid: bool + Whether to add an extra coefficient for solid-like behavior Returns: - tuple A tuple containing the following elements: - - tau: ndarray + - tau_i: ndarray The logarithmic time grid. - - E: ndarray + - E_i: ndarray The fitted coefficients. - - prony: ndarray - The complex values calculated using the Prony method. - - relax: ndarray - The relaxation values calculated using the Prony method. """ - df.sort_values(by=[df.columns[0]], inplace=True) - train_data = df.to_numpy() - - omega = train_data[:,0] - eps_inft = train_data[0,1] - eps_real = train_data[:,1] - eps_imag = train_data[:,2] - - Tau_max = 1/train_data[0,0] - Tau_min = 1/train_data[-1,0] - tau = np.logspace(np.log10(Tau_min),np.log10(Tau_max),N,endpoint=True) + tau_max = 1 / np.min(omega) + tau_min = 1 / np.max(omega) + tau_i = prony_relaxation_space(tau_min, tau_max, N) - model_dict = { - "Linear":linear_model.LinearRegression(positive=True, fit_intercept=False), - "LASSO":linear_model.Lasso(positive=True, fit_intercept=False), - "Ridge":linear_model.Ridge(positive=True, fit_intercept=False), - } - clf = model_dict[model] - # alpha = 1.0 - # clf = linear_model.LinearRegression(positive=True, fit_intercept=False) + basis = prony_basis(omega, tau_i, solid) - D_real = real_basis(omega,tau).T @ real_basis(omega,tau) - D_imag = imag_basis(omega,tau).T @ imag_basis(omega,tau) + y = np.concatenate((E_stor,E_loss)) - y_real = real_basis(omega,tau).T @ (eps_real - eps_inft) - y_imag = imag_basis(omega,tau).T @ eps_imag + y_std = np.absolute(E_stor+1.0j*E_loss) + y_std *= 0.2 # TODO: make this an input to the function + y_std = np.concatenate((y_std, y_std)) - X = D_real + D_imag - y = y_real + y_imag - - clf.fit(X, y) - E = clf.coef_ - - # print(f'Non-Zero Weights Used: {np.count_nonzero(W)}') - prony = compute_complex(train_data,tau,E) - relax = compute_relax(tau,E) + with np.errstate(over='ignore', invalid='ignore'): + result = minimize( + fun=prony_objective, + x0=np.full(N+solid,7.) , + args=(y, y_std, basis, smoothness, solid), + jac=True, + ) + E_i = np.exp(result.x) - return (tau, E, prony, relax) + return tau_i, E_i # OLD Method # def wlf_shift(T, T_ref, C1, C2): @@ -485,4 +459,4 @@ def estimate_TL(uploadData, domain): TL = df.loc[peak_idx, "Frequency"] # loss_peak = df.loc[peak_idx, "E''"] loss_peak = df.loc[peak_idx, "E Loss"] - return TL \ No newline at end of file + return TL diff --git a/services/app/dynamfit/routes.py b/services/app/dynamfit/routes.py index a4ba83666..c0853a23a 100644 --- a/services/app/dynamfit/routes.py +++ b/services/app/dynamfit/routes.py @@ -3,7 +3,7 @@ import json import datetime from app.dynamfit.dynamfit2 import update_line_chart, check_file_exists, estimate_shift_model_parameters -from app.utils.util import token_required, upload_init, shift_upload_init, request_logger, log_errors +from app.utils.util import token_required, upload_init, request_logger, log_errors dynamfit = Blueprint("dynamfit", __name__, url_prefix="/dynamfit") # NEW @@ -88,7 +88,7 @@ def extract_data_from_file(request_id): data = request.get_json() file_name = data.get('file_name') number_of_prony = data.get('number_of_prony', 100) - model = data.get('model', 'Linear') + smoothness = data.get('smoothness', 0) fit_settings = data.get('fit_settings', False) domain = data.get('domain', 'frequency') shift_model = data.get('transform_method', 'hybrid') @@ -117,8 +117,8 @@ def extract_data_from_file(request_id): if number_of_prony not in range(1, 101) or not isinstance(number_of_prony, int): return jsonify({'message': 'The number of prony must be between 1 and 100'}), 400 - if model not in ['Linear', 'LASSO', 'Ridge']: - return jsonify({'message': 'The model must be one of Linear, LASSO, Ridge'}), 400 + if smoothness < 0: + return jsonify({'message': 'The smoothness must be non-negative'}), 400 if fit_settings not in [True, False]: return jsonify({'message': 'The fit settings must be either True or False'}), 400 @@ -142,7 +142,7 @@ def extract_data_from_file(request_id): # print("Upload Data:", uploadData) print("before shift_upload_init") - shiftData = shift_upload_init(shift_file_name) + shiftData = upload_init(shift_file_name, 'shift') print("after shift_upload_init") # add a function for handling shift data: # shiftData = upload_shift_init(shift_file_name) @@ -176,7 +176,8 @@ def extract_data_from_file(request_id): # Assuming the update_line_chart function returns values in a specific order print("before update_line_chart") - result = update_line_chart(uploadData, number_of_prony, model, fit_settings, domain, **shift_params) + result = update_line_chart(uploadData, number_of_prony, smoothness, + fit_settings, domain, **shift_params) print("after update_line_chart") # # Note: add shift factor prediction here # # Prerequisite boolean detection @@ -235,4 +236,4 @@ def extract_data_from_file(request_id): except ValueError as ve: return jsonify({'message': str(ve)}), 400 except Exception as e: - return jsonify({'message': str(e)}), 500 \ No newline at end of file + return jsonify({'message': str(e)}), 500 diff --git a/services/app/utils/util.py b/services/app/utils/util.py index 91e15092a..14f490981 100644 --- a/services/app/utils/util.py +++ b/services/app/utils/util.py @@ -1,11 +1,13 @@ import os os.environ['OPENBLAS_NUM_THREADS'] = '1' + +import numpy as np + from typing import Any, Dict from app.config import Config from functools import wraps -from flask import request, jsonify, current_app as app # type: ignore +from flask import request, jsonify, has_app_context, current_app as app # type: ignore import jwt # type: ignore -import pandas as pd # type: ignore from datetime import datetime, timedelta, timezone import functools import jwt # type: ignore @@ -18,8 +20,8 @@ def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except Exception as e: - func_name = func.__name__ - app.logger.info(f"Error in {func_name} function: {e}") + if has_app_context(): + app.logger.info(f"Error in {func.__name__} function: {e}") raise return wrapper @@ -50,6 +52,7 @@ def upload_init(file_name, domain): Args: file_name (str): The name of the file to be uploaded. + domain (str): The domain of data to interpret columns. Returns: dict: The content of the file as a dictionary. @@ -60,102 +63,46 @@ def upload_init(file_name, domain): ValueError: If there is an error parsing the file content. """ try: - file_path = os.path.join(Config.FILES_DIRECTORY, file_name) + file_path = os.path.join(Config.FILES_DIRECTORY, file_name) extension = os.path.splitext(file_name)[1].lower() # Get the file extension - + if extension == '.csv': delimiter = ',' elif extension == '.tsv' or extension == '.txt': delimiter = '\t' else: raise ValueError("Unsupported file extension") - - df = pd.read_csv(file_path, delimiter=delimiter, header=None) - if df.empty: + + with open(file_path, 'r') as f: + csvlines = f.readlines() + + if not csvlines: raise ValueError('File is empty') # Find the first numeric row index valid_start_index = None - for i, row in df.iterrows(): - if is_numeric_row(row): + for i, row in enumerate(csvlines): + if is_numeric_row(row.strip().split(delimiter)): valid_start_index = i break if valid_start_index is None: raise ValueError("No valid numeric rows found in the file") - - # Slice to valid rows only - df = df.iloc[valid_start_index:].reset_index(drop=True) - # Convert to float explicitly - df = df.applymap(float) + # Parse valid rows only + data = np.loadtxt(csvlines, delimiter=delimiter, skiprows=valid_start_index, dtype=float, unpack=True) + sortind = np.argsort(data[0]) + data = data[:, sortind] - # Rename columns + # Name columns if domain == 'frequency': - df.columns =['Frequency', 'E Storage', 'E Loss'] + columns =['Frequency', 'E Storage', 'E Loss'] elif domain == 'temperature': - df.columns =['Temperature', 'E Storage', 'E Loss'] + columns =['Temperature', 'E Storage', 'E Loss'] + elif domain == 'shift': + columns = ['Temperature', 'a_T'] else: - df.columns =['UNKNOWN', 'E Storage', 'E Loss'] - df = df.sort_values(by=df.columns[0], ascending=False).reset_index(drop=True) #sort the data - return df.to_dict("records") - except pd.errors.EmptyDataError as e: - raise ValueError("File is Empty") - except Exception as pe: - raise ValueError("Failed to parse file content") - -@log_errors -def shift_upload_init(file_name): - """ - Uploads a file and returns its content as a dictionary. - - Args: - file_name (str): The name of the file to be uploaded. - - Returns: - dict: The content of the file as a dictionary. - - Raises: - ValueError: If the file extension is not supported. - ValueError: If the file is empty. - ValueError: If there is an error parsing the file content. - """ - try: - if file_name is None: - return None - file_path = os.path.join(Config.FILES_DIRECTORY, file_name) - extension = os.path.splitext(file_name)[1].lower() # Get the file extension - - if extension == '.csv': - delimiter = ',' - elif extension == '.tsv' or extension == '.txt': - delimiter = '\t' - else: - raise ValueError("Unsupported file extension") - - df = pd.read_csv(file_path, delimiter=delimiter, header=None) - print(f'df: {df}') - if df.empty: - raise ValueError('File is empty') - # Find the first numeric row index - valid_start_index = None - for i, row in df.iterrows(): - if is_numeric_row(row): - valid_start_index = i - break - if valid_start_index is None: - raise ValueError("No valid numeric rows found in the file") - - # Slice to valid rows only - df = df.iloc[valid_start_index:].reset_index(drop=True) - - # Convert to float explicitly - df = df.applymap(float) - - # Rename columns - df.columns =['Temperature', 'a_T'] - df = df.sort_values(by=df.columns[0], ascending=False).reset_index(drop=True) #sort the data - return df.to_dict("records") - except pd.errors.EmptyDataError as e: - raise ValueError("File is Empty") + raise AssertionError("Unknown domain:", domain) + + return dict(zip(columns, data)) except Exception as pe: raise ValueError("Failed to parse file content") @@ -301,4 +248,4 @@ def is_numeric_row(row): [float(val) for val in row] return True except ValueError: - return False \ No newline at end of file + return False From 0fa6df80fd4d6b877fa3a6b692b3b4e37fb789a9 Mon Sep 17 00:00:00 2001 From: richardsheridan Date: Thu, 21 May 2026 19:11:16 -0400 Subject: [PATCH 02/18] feat(dynamfit): generate dynamfit backend test suite --- services/app/config.py | 1 - services/app/dynamfit/dynamfit2.py | 1371 ++++++++++++----- .../PETMP-TATATO master curve 55C clean.txt | 403 +++++ .../dynamfit/files/PMMA_shifted_R10_data.txt | 1121 ++++++++++++++ .../VeroCyan (5) Temperature Ramp clean.txt | 122 ++ .../VeroCyan (5) master curve 80C clean.txt | 500 ++++++ .../VeroCyan (5) shift factors 80C clean.txt | 25 + .../agilus30 (8) Temperature Ramp clean.txt | 87 ++ .../agilus30 (8) master curve 20C clean.txt | 500 ++++++ .../agilus30 (8) shift factors 20C clean.txt | 21 + services/app/dynamfit/helper.py | 462 ------ services/app/dynamfit/routes.py | 180 +-- services/app/utils/util.py | 140 +- services/tests/dynamfit/__init__.py | 0 services/tests/dynamfit/test_dynamfit2.py | 1092 +++++++++++++ services/tests/utils/test_util.py | 146 +- 16 files changed, 5084 insertions(+), 1087 deletions(-) create mode 100644 services/app/dynamfit/files/PETMP-TATATO master curve 55C clean.txt create mode 100644 services/app/dynamfit/files/PMMA_shifted_R10_data.txt create mode 100644 services/app/dynamfit/files/VeroCyan (5) Temperature Ramp clean.txt create mode 100644 services/app/dynamfit/files/VeroCyan (5) master curve 80C clean.txt create mode 100644 services/app/dynamfit/files/VeroCyan (5) shift factors 80C clean.txt create mode 100644 services/app/dynamfit/files/agilus30 (8) Temperature Ramp clean.txt create mode 100644 services/app/dynamfit/files/agilus30 (8) master curve 20C clean.txt create mode 100644 services/app/dynamfit/files/agilus30 (8) shift factors 20C clean.txt delete mode 100644 services/app/dynamfit/helper.py create mode 100644 services/tests/dynamfit/__init__.py create mode 100644 services/tests/dynamfit/test_dynamfit2.py diff --git a/services/app/config.py b/services/app/config.py index b04b572ca..a5a576812 100644 --- a/services/app/config.py +++ b/services/app/config.py @@ -13,7 +13,6 @@ class Config: EMAIL_API_TOKEN = os.environ.get('AUTH_API_TOKEN_EMAIL', '') EMAIL_REFRESH_TOKEN = os.environ.get('AUTH_API_REFRESH_EMAIL', '') FILES_DIRECTORY = os.environ.get('FILES_WORKING_DIR', '/usr/src/files') - ALLOWED_EXTENSIONS = set(['tsv', 'csv']) API_SERVICES = os.environ.get('API_URL', 'http://restful:3001') GITHUB_USERNAME = os.environ.get('GITHUB_USERNAME') GITHUB_TOKEN = os.environ.get('GITHUB_TOKEN') diff --git a/services/app/dynamfit/dynamfit2.py b/services/app/dynamfit/dynamfit2.py index 205709a2c..5e44dbfdd 100644 --- a/services/app/dynamfit/dynamfit2.py +++ b/services/app/dynamfit/dynamfit2.py @@ -1,68 +1,898 @@ import os os.environ['OPENBLAS_NUM_THREADS'] = '1' -from app.config import Config import numpy as np import pandas as pd + +from contextlib import contextmanager +from scipy.optimize import minimize +from scipy.signal import find_peaks import plotly.express as px import plotly.graph_objects as go -from app.dynamfit.helper import smooth_prony_fit, compute_relaxation_spectrum, \ - tts_frequency_to_temperature, tts_temperature_to_frequency, \ - tts_temperature_to_frequency_V2, estimate_Tg, estimate_TL, compute_complex, \ - compute_relaxation_modulus from app.utils.util import log_errors -def estimate_shift_model_parameters(uploadData, shift_model, Tg_estimate, C1_estimate, C2_estimate, Ea_estimate, TL_estimate, domain): +float_correction = 1e-7 +R = 8.31446261815324 # J/(mol*K) + +# Williams-Landel-Ferry's "universal" empirical constants — reasonable defaults +# across many polymers when the user hasn't supplied material-specific values. +# Used (a) by the frequency-domain visualization to scatter the master curve +# across a temperature axis, and (b) by the route as the fallback when the +# C1/C2 "estimate" toggles are on. +UNIVERSAL_WLF_C1 = 17.44 +UNIVERSAL_WLF_C2 = 51.6 + +# Reference frequency / temperature for the frequency-domain visualization's +# inverse-WLF scatter; arbitrary but stable so the temperature axis stays +# comparable across uploads. +VIS_REF_FREQUENCY_HZ = 1.0 +VIS_REF_TEMPERATURE_C = 30.0 + + +@contextmanager +def _fp_safe(user_message: str): + """Convert numpy FP overflow/divide/invalid into a user-facing ValueError. + + The shift-factor functions all need the same protective shell around their + arithmetic: switch numpy to raise on overflow/divide/invalid, catch those + (and any FloatingPointError the body raises explicitly for a non-finite + result), and re-raise as ValueError(user_message) so the route can convert + it to HTTP 400 with text the end user can act on. + """ + with np.errstate(divide='raise', invalid='raise', over='raise'): + try: + yield + except (FloatingPointError, ZeroDivisionError): + raise ValueError(user_message) + + +def prony_basis(freq: np.ndarray, relaxations: np.ndarray, solid: bool) -> np.ndarray: + """ + Construct the Prony-series basis matrix for storage and loss moduli. + + For each (ω, τ) pair the storage-modulus contribution is ω²τ² / (1 + ω²τ²) + and the loss-modulus contribution is ωτ / (1 + ω²τ²). The two blocks are + stacked vertically so that prony_basis(...) @ E_i yields a flat + [E_storage; E_loss] vector. When solid is True, a leading column is + prepended to represent an equilibrium modulus: ones on the storage block, + zeros on the loss block. + + Parameters: + freq (numpy.ndarray): 1-D array of angular frequencies ω. + relaxations (numpy.ndarray): 1-D array of relaxation times τ. + solid (bool): Whether to prepend an equilibrium-modulus column. + + Returns: + numpy.ndarray: Basis matrix of shape (2 * len(freq), len(relaxations) + bool(solid)). + """ + assert isinstance(freq, np.ndarray) and freq.ndim == 1, \ + "freq must be a 1-D numpy.ndarray" + assert isinstance(relaxations, np.ndarray) and relaxations.ndim == 1, \ + "relaxations must be a 1-D numpy.ndarray" + + # dimensionless time ωτ + dt = np.outer(freq, relaxations) + + dt2 = dt * dt + dt2p1 = dt2 + 1 + ep_basis = dt2 / dt2p1 + epp_basis = dt / dt2p1 + + if solid: + ep_basis = np.concatenate( + (np.ones_like(ep_basis, shape=(len(ep_basis), 1)), ep_basis), axis=1 + ) + epp_basis = np.concatenate( + (np.zeros_like(epp_basis, shape=(len(epp_basis), 1)), epp_basis), axis=1 + ) + + return np.concatenate((ep_basis, epp_basis), axis=0) + + +def prony_relaxation_space(tau_min: float, tau_max: float, N: int) -> np.ndarray: + """ + Build a log-spaced grid of relaxation times. + + Parameters: + tau_min (float): Lower bound of the relaxation-time range. + tau_max (float): Upper bound of the relaxation-time range. + N (int): Number of grid points. + + Returns: + numpy.ndarray: 1-D array of N log-spaced values from tau_min to tau_max (inclusive). + """ + return np.logspace(np.log10(tau_min), np.log10(tau_max), N, endpoint=True) + + +def compute_complex(tau_i: np.ndarray, E_i: np.ndarray, + num_pts: int = 1000) -> pd.DataFrame: + """ + Compute the complex modulus on a log-spaced frequency grid. + + Builds an angular-frequency grid spanning 1/max(tau_i) to 1/min(tau_i) and + evaluates the storage and loss moduli from the Prony coefficients in E_i. + When E_i has one more element than tau_i, the leading coefficient is + treated as an equilibrium-modulus term. + + Parameters: + tau_i (numpy.ndarray): 1-D array of relaxation times. + E_i (numpy.ndarray): 1-D array of Prony coefficients (same length as + tau_i, or one longer to include an equilibrium-modulus term). + num_pts (int): Number of points in the output frequency grid. + + Returns: + pandas.DataFrame: Frame with num_pts rows and columns + "Frequency", "E Storage", "E Loss". + """ + assert isinstance(tau_i, np.ndarray) and tau_i.ndim == 1, \ + "tau_i must be a 1-D numpy.ndarray" + assert isinstance(E_i, np.ndarray) and E_i.ndim == 1, \ + "E_i must be a 1-D numpy.ndarray" + omega = np.logspace(-np.log10(np.max(tau_i)), -np.log10(np.min(tau_i)), num_pts) + basis = prony_basis(omega, tau_i, solid=not (len(E_i) == len(tau_i))) + complex = basis @ E_i + real, imag = complex.reshape(2, num_pts) + + return pd.DataFrame(data={"Frequency": omega, "E Storage": real, "E Loss": imag}) + + +def compute_relaxation_modulus(tau_i: np.ndarray, E_i: np.ndarray, + num_pts: int = 1000) -> pd.DataFrame: + """ + Compute the time-domain relaxation modulus on a log-spaced time grid. + + Builds a time grid spanning min(tau_i) to max(tau_i) and evaluates the + decaying part of the Prony relaxation modulus from the coefficients in + E_i. When E_i has one more element than tau_i, the leading + equilibrium-modulus coefficient is excluded from the output. + + Parameters: + tau_i (numpy.ndarray): 1-D array of relaxation times. + E_i (numpy.ndarray): 1-D array of Prony coefficients (same length as + tau_i, or one longer to include an equilibrium-modulus term). + num_pts (int): Number of points in the output time grid. + + Returns: + pandas.DataFrame: Frame with num_pts rows and columns "Time", "E". + """ + assert isinstance(tau_i, np.ndarray) and tau_i.ndim == 1, \ + "tau_i must be a 1-D numpy.ndarray" + assert isinstance(E_i, np.ndarray) and E_i.ndim == 1, \ + "E_i must be a 1-D numpy.ndarray" + t = np.logspace(np.log10(np.min(tau_i)), np.log10(np.max(tau_i)), num_pts) + # dimensionless time t/τ + dt = np.outer(t, 1 / tau_i) + solid = not (len(E_i) == len(tau_i)) + E = np.exp(-dt) @ E_i[solid:] + return pd.DataFrame(data={"Time": t, "E": E}) + + +def compute_relaxation_spectrum(tau_i: np.ndarray, E_i: np.ndarray, + num_pts: int = 1000) -> pd.DataFrame: + """ + Compute the relaxation spectrum on a log-spaced time grid. + + Builds a time grid spanning min(tau_i) to max(tau_i) and evaluates the + relaxation spectrum H from the Prony coefficients in E_i. When E_i has + one more element than tau_i, the leading equilibrium-modulus coefficient + is excluded from the output. + + Parameters: + tau_i (numpy.ndarray): 1-D array of relaxation times. + E_i (numpy.ndarray): 1-D array of Prony coefficients (same length as + tau_i, or one longer to include an equilibrium-modulus term). + num_pts (int): Number of points in the output time grid. + + Returns: + pandas.DataFrame: Frame with num_pts rows and columns "Time", "H". + """ + assert isinstance(tau_i, np.ndarray) and tau_i.ndim == 1, \ + "tau_i must be a 1-D numpy.ndarray" + assert isinstance(E_i, np.ndarray) and E_i.ndim == 1, \ + "E_i must be a 1-D numpy.ndarray" + t = np.logspace(np.log10(np.min(tau_i)), np.log10(np.max(tau_i)), num_pts) + # dimensionless time t/τ + dt = np.outer(t, 1 / tau_i) + solid = not (len(E_i) == len(tau_i)) + H = (dt * np.exp(-dt)) @ E_i[solid:] + return pd.DataFrame(data={"Time": t, "H": H}) + + +def _prony_objective( + logcoefs: np.ndarray, + data: np.ndarray, + std: np.ndarray, + basis: np.ndarray, + smoothness: float, + solid: bool, +) -> tuple: + """ + Compute the loss and gradient for the smoothed Prony fit. + + Designed to be passed to scipy.optimize.minimize with jac=True. Coefficients + are parameterized in log-space (E_i = exp(logcoefs)) so that the optimizer + sees an unconstrained problem while the physical coefficients remain + positive. The loss is the weighted sum of squared residuals between + basis @ exp(logcoefs) and data, plus an optional second-difference penalty + on logcoefs[solid:] scaled by smoothness. + + Parameters: + logcoefs (numpy.ndarray): 1-D array of log-coefficients to fit. + data (numpy.ndarray): 1-D array of target values. + std (numpy.ndarray): 1-D array of per-point standard deviations for weighting. + basis (numpy.ndarray): 2-D basis matrix; basis @ exp(logcoefs) is the model. + smoothness (float): Strength of the second-difference penalty on + logcoefs[solid:]. Pass 0 to disable. + solid (bool): Whether the leading coefficient is an equilibrium term to + exclude from the smoothness penalty. + + Returns: + tuple: (loss, gradient) where loss is a float and gradient is a 1-D + ndarray with the same shape as logcoefs. + """ + coefs = np.exp(logcoefs) + estimate = basis @ coefs + resid = (data - estimate) / std + loss = resid @ resid + if smoothness: + curve = smoothness * np.diff(logcoefs[solid:], n=2) + loss += curve @ curve + + grad = -(basis.T @ (resid / std)) + + # apply chain rule because these are functions of + # coefs rather than logcoefs + grad *= coefs + + if smoothness: + diffs = smoothness * curve # smoothness*np.diff(logcoefs[solid:], n=2) + grad_slice = grad[solid:] + grad_slice[:-2] += diffs + grad_slice[1:-1] -= 2 * diffs + grad_slice[2:] += diffs + + return loss, 2 * grad # more chain rule (squared errors) + + +def smooth_prony_fit( + omega: np.ndarray, + E_stor: np.ndarray, + E_loss: np.ndarray, + N: int, + smoothness: float, + solid: bool = True, +) -> tuple: + """ + Fit a Prony series to complex-modulus data with coefficient smoothing. + + Builds a log-spaced relaxation-time grid spanning 1/max(omega) to + 1/min(omega), constructs the Prony basis at those (omega, tau) pairs, and + solves for positive Prony coefficients by minimizing weighted squared + residuals plus an optional second-difference smoothness penalty on the + log-coefficients (see _prony_objective). Coefficients are parameterized in + log-space so the optimizer sees an unconstrained problem. + + Parameters: + omega (numpy.ndarray): 1-D array of angular frequencies. + E_stor (numpy.ndarray): 1-D array of storage-modulus values, same + length as omega. + E_loss (numpy.ndarray): 1-D array of loss-modulus values, same length + as omega. + N (int): Number of relaxation times in the fit grid. + smoothness (float): Strength of the second-difference penalty on the + log-coefficients. Pass 0 to disable. + solid (bool): Whether to include an equilibrium-modulus term. + + Returns: + tuple: (tau_i, E_i) where tau_i is the 1-D relaxation-time grid of + length N and E_i is the 1-D coefficient array of length N + bool(solid). """ - Estimates the parameters used by the shift factor Model. + assert isinstance(omega, np.ndarray) and omega.ndim == 1, \ + "omega must be a 1-D numpy.ndarray" + assert isinstance(E_stor, np.ndarray) and E_stor.ndim == 1, \ + "E_stor must be a 1-D numpy.ndarray" + assert isinstance(E_loss, np.ndarray) and E_loss.ndim == 1, \ + "E_loss must be a 1-D numpy.ndarray" + assert len(omega) == len(E_stor) == len(E_loss), \ + "omega, E_stor, E_loss must all have the same length" + + tau_max = 1 / np.min(omega) + tau_min = 1 / np.max(omega) + tau_i = prony_relaxation_space(tau_min, tau_max, N) + + basis = prony_basis(omega, tau_i, solid) + + y = np.concatenate((E_stor, E_loss)) + + y_std = np.absolute(E_stor + 1.0j * E_loss) + y_std *= 0.2 # TODO: make this an input to the function + y_std = np.concatenate((y_std, y_std)) + + # Data-scaled initial guess: spread max(E_stor) evenly across N+solid terms + # so the initial model prediction matches the data magnitude. A constant + # like x0=7.0 (E_i ≈ 1097) is many orders of magnitude off when E_stor is + # large, and then returned coefficients become inf. + x0 = np.full(N + solid, np.log(E_stor.max() / (N + solid))) + with np.errstate(over='ignore', invalid='ignore'): + result = minimize( + fun=_prony_objective, + x0=x0, + args=(y, y_std, basis, smoothness, solid), + jac=True, + ) + E_i = np.exp(result.x) + + return tau_i, E_i + + +# OLD Method +# def wlf_shift(T, T_ref, C1, C2): +# """Calculate shift factor a_T using the WLF equation.""" +# print(f"T: {T}") +# print(f"T_ref: {T_ref}") +# print(f'C1: {C1}') +# print(f'C2: {C2}') +# return 10 ** (-C1 * (T - T_ref) / (C2 + (T - T_ref))) + +def wlf_shift(T, T_ref: float, C1: float, C2: float) -> np.ndarray: """ - C1=C2=Tg=Ea=TL=None - # "Universal" Estimations for C1, C2 - if C1_estimate: - C1 = 17.44 - if C2_estimate: - C2 = 51.6 - # Calculate Tg from argmax of tandelta(T) - if Tg_estimate: - print("before estimate_Tg") - Tg = estimate_Tg(uploadData, domain) - print("after estimate_Tg") - # Calculate TL from argmax of E''(T) - if TL_estimate: - print("before estimate_TL") - TL = estimate_TL(uploadData, domain) - print("after estimate_TL") - # Use "generic" Ea for thermoplastic elastomers - if Ea_estimate: - Ea = 200 #kJ/mol - return C1, C2, Tg, Ea, TL + Calculate the WLF shift factor a_T. + Implements the Williams-Landel-Ferry equation: + log10(a_T) = -C1 * (T - T_ref) / (C2 + (T - T_ref)) -def check_file_exists(file_name): + Parameters: + T: Temperatures at which to evaluate a_T. Scalars and 0-D arrays are + promoted to a length-1 1-D array; 1-D arrays pass through. + T_ref (float): Reference temperature. + C1 (float): WLF parameter C1. + C2 (float): WLF parameter C2. + + Returns: + numpy.ndarray: 1-D array of shift factors a_T, same length as T. + + Raises: + ValueError: If the WLF denominator C2 + (T - T_ref) reaches zero or the + result is non-finite (overflow or invalid arithmetic). """ - Checks if a file exists. + T = np.atleast_1d(T) + assert T.ndim == 1, "T must be a 1-D numpy.ndarray or scalar" + + with _fp_safe( + "divide by zero detected when calculating WLF. " + "Please adjust parameters or manually provide shift factors." + ): + a_T = np.power(10.0, -C1 * (T - T_ref) / (C2 + (T - T_ref))) + if not np.all(np.isfinite(a_T)): + raise FloatingPointError("Non-finite result in WLF computation") + return a_T - Args: - file_name (str): The name of the file to check. + +def _arr_shift(T: np.ndarray, T_ref: float, Ea: float) -> np.ndarray: + """ + Calculate the Arrhenius shift factor a_T. + + Implements the Arrhenius equation: + log10(a_T) = (Ea_J_per_mol / (2.303 * R)) * (1/T_K - 1/T_ref_K) + + where T_K and T_ref_K are absolute temperatures in Kelvin (T + 273.15) and + Ea_J_per_mol = 1000 * Ea. + + Parameters: + T (numpy.ndarray): 1-D array of temperatures in °C. + T_ref (float): Reference temperature in °C. + Ea (float): Activation energy in kJ/mol. Returns: - bool: True if the file exists, False otherwise. + numpy.ndarray: 1-D array of shift factors a_T, same length as T. + + Raises: + ValueError: If an absolute-zero singularity is reached or the result + is non-finite (overflow or invalid arithmetic). """ - file_path = os.path.join(Config.FILES_DIRECTORY, file_name) - return os.path.exists(file_path) - + T_ref_K = T_ref + 273.15 + T_K = T + 273.15 + m = (Ea * 1000.0) / (2.303 * R) + with _fp_safe( + "absolute-zero singularity detected when calculating Arrhenius shift. " + "Please adjust parameters or manually provide shift factors." + ): + a_T = np.power(10.0, m * (1.0 / T_K - 1.0 / T_ref_K)) + if not np.all(np.isfinite(a_T)): + raise FloatingPointError("Non-finite result in Arrhenius computation") + return a_T + + +def hybrid_shift( + T, + T_ref: float, + C1: float, + C2: float, + Ea: float, +) -> np.ndarray: + """ + Calculate hybrid Arrhenius/WLF shift factors. + + Applies the WLF equation to temperatures above T_ref and the Arrhenius + equation to temperatures at or below T_ref. With two or more elements, T + must be monotonically sorted (ascending or descending); the direction is + detected from the signs of consecutive differences so the assembled output + preserves the input order. + + Parameters: + T: Temperatures in °C. Scalars and 0-D arrays are promoted to a + length-1 1-D array; 1-D arrays of length >= 2 must be monotonically + sorted. + T_ref (float): Reference temperature in °C; WLF/Arrhenius boundary. + C1 (float): WLF parameter C1. + C2 (float): WLF parameter C2. + Ea (float): Arrhenius activation energy in kJ/mol. + + Returns: + numpy.ndarray: 1-D array of shift factors a_T, same length as T. + + Raises: + ValueError: If the WLF or Arrhenius sub-calculation hits a singularity. + """ + T = np.atleast_1d(T) + assert T.ndim == 1, "T must be a 1-D numpy.ndarray or scalar" + assert np.all(np.isfinite(T)), "T must contain only finite values" + diffs = np.diff(T) + if len(diffs) > 0: + ascending = bool(np.all(diffs > 0)) # base case from loader utility + assert ascending or np.all(diffs < 0), "T must be monotonically sorted" + else: + ascending = True # single element; direction irrelevant + + T_work = T if ascending else T[::-1] + k = np.searchsorted(T_work, T_ref + float_correction, side='right') + a_T_arr = _arr_shift(T_work[:k], T_ref, Ea) + a_T_wlf = wlf_shift(T_work[k:], T_ref, C1, C2) + result = np.concatenate((a_T_arr, a_T_wlf)) + return result if ascending else result[::-1] + + +def inverse_wlf_shift(a_T, T_ref: float, C1: float, C2: float) -> np.ndarray: + """ + Calculate the temperature T corresponding to a WLF shift factor a_T. + + Inverts the Williams-Landel-Ferry equation + log10(a_T) = -C1 * (T - T_ref) / (C2 + (T - T_ref)) + to give + T = T_ref - C2 * log10(a_T) / (C1 + log10(a_T)). + + Parameters: + a_T: Shift factor(s). Scalars and 0-D arrays are promoted to a length-1 + 1-D array; 1-D arrays pass through. + T_ref (float): Reference temperature. + C1 (float): WLF parameter C1. + C2 (float): WLF parameter C2. + + Returns: + numpy.ndarray: 1-D array of temperatures, same length as a_T. + + Raises: + ValueError: If a_T <= 0 (log10 undefined), C1 + log10(a_T) = 0 + (singularity), or the result is non-finite. + """ + a_T = np.atleast_1d(a_T) + assert a_T.ndim == 1, "a_T must be a 1-D numpy.ndarray or scalar" + + with _fp_safe( + "invalid shift factor when inverting WLF " + "(a_T must be positive and log10(a_T) != -C1). Please adjust parameters." + ): + log_a_T = np.log10(a_T) + T = (log_a_T * (T_ref - C2) + C1 * T_ref) / (log_a_T + C1) + if not np.all(np.isfinite(T)): + raise FloatingPointError("Non-finite result in inverse WLF computation") + return T + + +def tts_temperature_to_frequency_V2(temp_sweep_data, shift_model, *, + Tg=None, TL=None, C1=None, C2=None, Ea=None, + shiftData=None): + """ + Convert temperature-sweep viscoelastic data to frequency-sweep data via TTS. + + Computes a shift factor a_T per row and returns a new DataFrame at the + reference temperature with shifted frequencies (Frequency * a_T). Rows are + sorted by Temperature ascending before the shift is applied. + + The reference temperature is selected from shift_model: 'WLF' uses Tg + (glass transition); 'hybrid' uses TL (WLF/Arrhenius crossover). + + Parameters: + temp_sweep_data (pd.DataFrame): Input data with columns + ['Temperature', "E'", "E''"], optionally including 'Frequency' + for the per-row measurement frequency. If 'Frequency' is absent, + 1.0 Hz is assumed (typical for a fixed-frequency DMA temperature sweep). + shift_model (str): Which shift function to apply. 'WLF' uses + wlf_shift across all temperatures; 'hybrid' uses hybrid_shift + (Arrhenius at or below TL, WLF above); 'manual' requires + shiftData and uses it directly. + Tg (float): WLF reference temperature (used when shift_model == 'WLF'). + TL (float): Hybrid WLF/Arrhenius crossover temperature (used when + shift_model == 'hybrid'). + C1 (float): WLF equation parameter C1. + C2 (float): WLF equation parameter C2. + Ea (float): Arrhenius activation energy in kJ/mol; used only when + shift_model == 'hybrid'. + shiftData: Optional precomputed shift factors. When falsy (None, + empty, etc.), shift factors are computed from shift_model with + the supplied parameters. When truthy, must be convertible to a + DataFrame with an 'a_T' column whose values are used directly; + the provided values must align with temp_sweep_data sorted by + Temperature ascending. + + Returns: + pd.DataFrame: Frequency-sweep data at T_ref with columns + ['Frequency', 'Temperature', "E'", "E''"], sorted by Frequency with a + fresh 0..N-1 index. Input temp_sweep_data is not mutated. + + Raises: + ValueError: If shift_model is 'manual' without shiftData. + """ + df = temp_sweep_data.sort_values('Temperature') + T = df['Temperature'].to_numpy() + + if shiftData: + a_T_df = pd.DataFrame(shiftData) + assert 'a_T' in a_T_df.columns, \ + f"shiftData must have an 'a_T' column; got {list(a_T_df.columns)}. " \ + "shiftData should come from upload_init(..., 'shift') which guarantees this." + a_T = a_T_df['a_T'].to_numpy() + if len(a_T) != len(T): + raise ValueError( + f"Shift file has {len(a_T)} rows but data file has {len(T)} rows. " + "Make sure both files have the same number of rows." + ) + elif shift_model == 'WLF': + a_T = wlf_shift(T, Tg, C1, C2) + elif shift_model == 'hybrid': + a_T = hybrid_shift(T, TL, C1, C2, Ea) + elif shift_model == 'manual': + raise ValueError( + "Manual shift model selected but no shift-factor file was provided. " + "Upload a shift-factor file or pick 'WLF' or 'hybrid'." + ) + else: + assert False, ( + f"Unknown shift_model: {shift_model!r}; expected 'WLF', 'hybrid', " + "or 'manual'. The route must restrict shift_model to this set." + ) + + T_ref = {'WLF': Tg, 'hybrid': TL}.get(shift_model) # None for 'manual' + + source_omega = df['Frequency'].to_numpy() if 'Frequency' in df.columns else 1.0 + return pd.DataFrame({ + 'Frequency': source_omega * a_T, + 'Temperature': T_ref, + "E'": df["E'"].to_numpy(), + "E''": df["E''"].to_numpy(), + }).sort_values('Frequency').reset_index(drop=True) + + +def tts_frequency_to_temperature( + freq_sweep_data: pd.DataFrame, + omega_ref: float, + T_ref: float, + C1: float, + C2: float, +) -> pd.DataFrame: + """ + Convert frequency-sweep viscoelastic data to temperature-sweep data via TTS. + + For each row computes a_T = Frequency / omega_ref and inverts the WLF + equation about T_ref to find the temperature that would produce that shift + factor. The returned DataFrame is at the reference frequency omega_ref. + + Parameters: + freq_sweep_data (pd.DataFrame): Input data with columns + ['Frequency', "E'", "E''"]. + omega_ref (float): Reference frequency for shifting. + T_ref (float): WLF reference temperature. + C1 (float): WLF parameter C1. + C2 (float): WLF parameter C2. + + Returns: + pd.DataFrame: Temperature-sweep data at omega_ref with columns + ['Frequency', 'Temperature', "E'", "E''"], sorted by Temperature with a + fresh 0..N-1 index. Input freq_sweep_data is not mutated. + + Raises: + ValueError: If the inverse-WLF computation hits a singularity. + """ + omega = freq_sweep_data['Frequency'].to_numpy() + a_T = omega / omega_ref + shifted_T = inverse_wlf_shift(a_T, T_ref, C1, C2) + + return pd.DataFrame({ + 'Frequency': np.full(len(omega), omega_ref), + 'Temperature': shifted_T, + "E'": freq_sweep_data["E'"].to_numpy(), + "E''": freq_sweep_data["E''"].to_numpy(), + }).sort_values('Temperature').reset_index(drop=True) + + +def argmax_peak(signal: np.ndarray) -> int: + """ + Return the index of the most prominent peak in a 1-D signal. + + Uses scipy.signal.find_peaks with prominence=0.01 to locate candidate + peaks and picks the one with the largest signal value. + + Parameters: + signal (numpy.ndarray): 1-D array to scan for peaks. + + Returns: + int: Index of the most prominent peak in signal. + + Raises: + ValueError: If find_peaks returns no peaks. + """ + assert isinstance(signal, np.ndarray) and signal.ndim == 1, \ + "signal must be a 1-D numpy.ndarray" + peaks, _ = find_peaks(signal, prominence=0.01) + if len(peaks) == 0: + raise ValueError( + "No peaks found. Try lowering the prominence parameter or enter the value manually." + ) + return int(peaks[np.argmax(signal[peaks])]) + + +def _build_temperature_figures(temp_sweep_data: pd.DataFrame) -> tuple: + """ + Build E vs Temperature and tan-delta vs Temperature figures. + + Parameters: + temp_sweep_data (pd.DataFrame): Frame with columns + ['Temperature', "E'", "E''"]; extra columns are ignored. + + Returns: + tuple: (fig4, fig41) where fig4 is the E' / E'' line plot in + Temperature and fig41 is the E' / tan-delta line plot in Temperature. + """ + df_melt = pd.melt( + temp_sweep_data, + id_vars=["Temperature"], + value_vars=["E'", "E''"], + var_name='Modulus', + value_name="Young's Modulus (MPa)", + ) + df_melt["Type"] = "Experiment" + + fig4 = px.line( + df_melt, x="Temperature", y="Young's Modulus (MPa)", + log_y=True, + facet_col='Modulus', + color="Type", line_dash="Type", + labels={"Temperature": "Temperature (C)"}, + ) + + df41_concat = df_melt.copy() + df41_tand = pd.DataFrame() + df41_tand["Temperature"] = df41_concat[df41_concat["Modulus"] == "E''"]["Temperature"] + df41_tand["Type"] = df41_concat[df41_concat["Modulus"] == "E''"]["Type"] + df41_tand["Young's Modulus (MPa)"] = ( + df41_concat[df41_concat["Modulus"] == "E''"]["Young's Modulus (MPa)"].to_numpy() / + df41_concat[df41_concat["Modulus"] == "E'"]["Young's Modulus (MPa)"].to_numpy() + ) + df41_tand['Modulus'] = 'tan delta' + df41_concat = pd.concat([df41_concat, df41_tand], ignore_index=True) + + fig41 = px.line( + df41_concat[df41_concat['Modulus'] != "E''"], + x="Temperature", y="Young's Modulus (MPa)", + facet_col='Modulus', + color="Type", line_dash="Type", + labels={"Temperature": "Temperature (C)"}, + ) + fig41.update_yaxes(matches=None, showticklabels=True) + fig41.update_yaxes(type="log", col=1) + fig4.update_yaxes(exponentformat='power') + fig41.update_yaxes(exponentformat='power') + return fig4, fig41 + + +def _build_complex_figures(df: pd.DataFrame, tau_i: np.ndarray, E_i: np.ndarray, + N_nz: int) -> tuple: + """ + Build E vs frequency and tan-delta vs frequency figures with Prony overlay. + + Parameters: + df (pd.DataFrame): Experimental data with columns + ['Frequency', 'E Storage', 'E Loss']. + tau_i (numpy.ndarray): Prony relaxation times. + E_i (numpy.ndarray): Prony coefficients (length tau_i or tau_i + 1). + N_nz (int): Number of nonzero Prony coefficients; used in trace names. + + Returns: + tuple: (fig1, fig11) where fig1 is E' / E'' vs Frequency and fig11 is + E' / tan-delta vs Frequency. + """ + complex_df = compute_complex(tau_i, E_i) + x_col, y_col, z_col = df.columns[0], df.columns[1], df.columns[2] + df_melt = pd.melt( + df, id_vars=[x_col], value_vars=[y_col, z_col], + var_name='Modulus', value_name="Young's Modulus (MPa)", + ) + df_melt["Type"] = "Experiment" + + cx_x, cx_y, cx_z = complex_df.columns[0], complex_df.columns[1], complex_df.columns[2] + complex_melt = pd.melt( + complex_df, id_vars=[cx_x], value_vars=[cx_y, cx_z], + var_name='Modulus', value_name="Young's Modulus (MPa)", + ) + complex_melt["Type"] = f"{N_nz}-Term Prony" + + df_concat = pd.concat([df_melt, complex_melt], ignore_index=True) + + fig1 = px.line( + df_concat, x=cx_x, y="Young's Modulus (MPa)", + log_x=True, log_y=True, + facet_col='Modulus', + color="Type", line_dash="Type", + line_dash_map={"Experiment": "solid", f"{N_nz}-Term Prony": "dash"}, + labels={"Frequency": "Frequency (Hz)"}, + ) + + df11_concat = df_concat.copy() + df11_tand = pd.DataFrame() + df11_tand["Frequency"] = df11_concat[df11_concat["Modulus"] == "E Loss"]["Frequency"] + df11_tand["Type"] = df11_concat[df11_concat["Modulus"] == "E Loss"]["Type"] + df11_tand["Young's Modulus (MPa)"] = ( + df11_concat[df11_concat["Modulus"] == "E Loss"]["Young's Modulus (MPa)"].to_numpy() / + df11_concat[df11_concat["Modulus"] == "E Storage"]["Young's Modulus (MPa)"].to_numpy() + ) + df11_tand['Modulus'] = 'tan delta' + df11_concat = pd.concat([df11_concat, df11_tand], ignore_index=True) + + fig11 = px.line( + df11_concat[df11_concat['Modulus'] != "E Loss"], + x="Frequency", y="Young's Modulus (MPa)", + log_x=True, + facet_col='Modulus', + color="Type", line_dash="Type", + line_dash_map={"Experiment": "solid", f"{N_nz}-Term Prony": "dash"}, + labels={"Frequency": "Frequency (Hz)"}, + ) + fig11.update_yaxes(matches=None, showticklabels=True) + fig11.update_yaxes(type="log", col=1) + for fig in (fig1, fig11): + fig.update_xaxes(exponentformat='power') + fig.update_yaxes(exponentformat='power') + return fig1, fig11 + + +def _build_relaxation_figures(tau_i: np.ndarray, E_i: np.ndarray, N_nz: int, + fit_settings: bool) -> tuple: + """ + Build relaxation-modulus and relaxation-spectrum figures with optional basis overlay. + + Parameters: + tau_i (numpy.ndarray): Prony relaxation times. + E_i (numpy.ndarray): Prony coefficients (length tau_i or tau_i + 1). + N_nz (int): Number of nonzero Prony coefficients; used in trace names. + fit_settings (bool): If True, overlay the basis scatter on each figure; + if False, return only the line traces. + + Returns: + tuple: (fig2, fig3) where fig2 is the time-domain relaxation modulus E(t) + and fig3 is the relaxation spectrum H(t). + """ + relax = compute_relaxation_modulus(tau_i, E_i) + relax["Type"] = f"{N_nz}-Term Prony" + fig2a = px.line( + relax, x="Time", y="E", + log_x=True, log_y=True, + color="Type", line_dash="Type", + line_dash_map={"Basis": "solid", f"{N_nz}-Term Prony": "dash"}, + labels={"Time": "Time (s)", "E": "Relaxation Modulus (MPa)"}, + ) + fig2a.update_layout( + autosize=False, width=800, height=450, + margin=dict(l=80, r=60, t=60, b=80), + ) + + basis_df = pd.DataFrame({ + "Time": tau_i, + "E": E_i[len(E_i) - len(tau_i):], + "Type": f"{N_nz}-Term Basis", + }) + fig2b = px.scatter( + basis_df, x="Time", y="E", + log_x=True, log_y=True, + symbol="Type", + labels={"Time": "Time (s)", "E": "Relaxation Modulus (MPa)"}, + ) + + fig2 = go.Figure(data=fig2a.data + fig2b.data) + fig2.update_xaxes(type="log") + fig2.update_yaxes(type="log") + fig2.update_layout( + autosize=False, margin=dict(l=80, r=60, t=60, b=80), + xaxis_title="Time (s)", + yaxis_title="Relaxation Modulus (MPa)", + legend_title="Type", + ) + + rspectrum = compute_relaxation_spectrum(tau_i, E_i) + rspectrum["Type"] = f"{N_nz}-Term Prony" + fig3a = px.line( + rspectrum, x="Time", y="H", + log_x=True, log_y=True, + color="Type", line_dash="Type", + line_dash_map={"Basis": "solid", f"{N_nz}-Term Prony": "dash"}, + labels={"Time": "Time (s)", "H": "Relaxation Spectrum (MPa)"}, + ) + fig3a.update_layout( + autosize=False, width=800, height=450, + margin=dict(l=80, r=60, t=60, b=80), + ) + + fig3 = go.Figure(data=fig3a.data + fig2b.data) + fig3.update_xaxes(type="log") + fig3.update_yaxes(type="log") + fig3.update_layout( + autosize=False, margin=dict(l=80, r=60, t=60, b=80), + xaxis_title="Time (s)", + yaxis_title="Relaxation Spectrum (MPa)", + legend_title="Type", + ) + + if not fit_settings: + fig2, fig3 = fig2a, fig3a + + for fig in (fig2, fig3): + fig.update_xaxes(exponentformat='power') + fig.update_yaxes(exponentformat='power') + return fig2, fig3 + + +def _build_coef_records(tau_i: np.ndarray, E_i: np.ndarray) -> list: + """ + Build the Prony coefficient table as a list of records. + + Parameters: + tau_i (numpy.ndarray): Prony relaxation times. + E_i (numpy.ndarray): Prony coefficients (length tau_i or tau_i + 1). + + Returns: + list: List of dicts with keys 'i', 'tau_i', 'E_i' — one per nonzero + coefficient, with 'i' the original (pre-filter) index. + """ + coef_df = pd.DataFrame({"tau_i": tau_i, "E_i": E_i[len(E_i) - len(tau_i):]}) + coef_df = coef_df[coef_df.E_i != 0].reset_index(drop=False) + coef_df = coef_df.rename(columns={'index': 'i'}) + return coef_df.to_dict("records") + + +EXPECTED_DOMAIN_COLUMNS = { + 'frequency': ('Frequency', 'E Storage', 'E Loss'), + 'temperature': ('Temperature', 'E Storage', 'E Loss'), +} + @log_errors def update_line_chart(uploadData, number_of_prony, smoothness, fit_settings, domain, Tg=None, C1=None, C2=None, Ea=None, TL=None, shift_model=None, shiftData=None): """ - Updates a line chart based on the provided data. + Update the dynamfit figures and Prony coefficient table for an uploaded dataset. Parameters: - uploadData (pd.DataFrame): The data to be used for the chart. - number_of_prony (int): The number of terms in the Prony series. - smoothness (float): The smoothing regularization to use for the fit. - fit_settings: A flag indicating whether to use fit settings. + uploadData (dict[str, np.ndarray]): Output of upload_init() for the + chosen domain. Keys must be ('Frequency', 'E Storage', 'E Loss') + for frequency domain or ('Temperature', 'E Storage', 'E Loss') for + temperature domain. + number_of_prony (int): Number of terms in the Prony series. + smoothness (float): Smoothing regularization for the fit. + fit_settings (bool): If True, overlay the basis scatter on the + relaxation modulus and spectrum figures. + domain (str): 'frequency' or 'temperature'. + Tg, C1, C2, Ea, TL: Shift-model parameters (temperature domain only). + shift_model (str): 'WLF' or 'hybrid' (temperature domain only). + shiftData: Optional precomputed shift factors {'Temperature': ..., 'a_T': ...}. Returns: fig1 (plotly.graph_objects.Figure): The line chart. @@ -72,387 +902,88 @@ def update_line_chart(uploadData, number_of_prony, smoothness, fit_settings, dom fig4 (plotly.graph_objects.Figure): The temperature line chart. fig41 (plotly.graph_objects.Figure): The tandelta temperature updated line chart. coef_df (List[Dict[str, Union[float, int]]]): The coefficients. + + Raises: + ValueError: If the uploaded data is empty, contains non-finite values, + or has non-positive frequency values where positives are required. + AssertionError: If domain or uploadData keys do not match the contract; + this signals a server bug, not user-fixable input. """ - try: - dfl = uploadData - N = number_of_prony - - - if dfl is not None: - - df = pd.DataFrame(dfl) - - - if domain == "frequency": - freq_sweep_data = df.copy() - freq_sweep_data.columns = ["Frequency", "E'", "E''"] - freq_sweep_data["Temperature"] = 30 - C1 = 17.44 - C2 = 51.6 - omega_ref = 1 - # Perform Temperature Conversion - # TODO: update this when reverse hybrid TTSP becomes available - temp_sweep_data = tts_frequency_to_temperature(freq_sweep_data, omega_ref, C1, C2) - - temp_sweep_data_melt = pd.melt(temp_sweep_data, id_vars=["Temperature"], - value_vars=["E'", "E''"], - var_name='Modulus', value_name="Young's Modulus (MPa)") - temp_sweep_data_melt["Type"] = "Experiment" - - fig4 = px.line(temp_sweep_data_melt, x="Temperature", y="Young's Modulus (MPa)", - # log_x=True, - log_y=True, - facet_col='Modulus', - color="Type", line_dash="Type", - # line_dash_map={"Experiment":"solid", f"{N_nz}-Term Prony":"dash"}, - labels={ - "Temperature": "Temperature (C)", - }, - ) - - df41_concat = temp_sweep_data_melt.copy() - df41_tand = pd.DataFrame() - df41_tand["Temperature"] = df41_concat[df41_concat["Modulus"] == "E''"]["Temperature"] - df41_tand["Type"] = df41_concat[df41_concat["Modulus"] == "E''"]["Type"] - - df41_tand["Young's Modulus (MPa)"] = df41_concat[df41_concat["Modulus"] == "E''"]["Young's Modulus (MPa)"].to_numpy() / df41_concat[df41_concat["Modulus"] == "E'"]["Young's Modulus (MPa)"].to_numpy() - - df41_tand['Modulus'] = 'tan delta' - df41_concat = pd.concat([df41_concat, df41_tand], ignore_index=True) - - fig41 = px.line(df41_concat[df41_concat['Modulus']!="E''"], x="Temperature", y="Young's Modulus (MPa)", - # log_x=True, - # log_y=True, - facet_col='Modulus', - color="Type", line_dash="Type", - # line_dash_map={"Experiment":"solid", f"{N_nz}-Term Prony":"dash"}, - # template=template_from_url(theme), - labels={ - "Temperature": "Temperature (C)", - }, - ) - fig41.update_yaxes(matches=None, showticklabels=True) - fig41.update_yaxes(type="log", col=1) - fig4.update_yaxes(exponentformat = 'power') - fig41.update_yaxes(exponentformat = 'power') - - elif domain == "temperature": - - temp_sweep_data = df.copy() - temp_sweep_data.columns = ["Temperature", "E'", "E''"] - - temp_sweep_data_melt = pd.melt(temp_sweep_data, id_vars=["Temperature"], - value_vars=["E'", "E''"], - var_name='Modulus', value_name="Young's Modulus (MPa)") - temp_sweep_data_melt["Type"] = "Experiment" - - fig4 = px.line(temp_sweep_data_melt, x="Temperature", y="Young's Modulus (MPa)", - # log_x=True, - log_y=True, - facet_col='Modulus', - color="Type", line_dash="Type", - # line_dash_map={"Experiment":"solid", f"{N_nz}-Term Prony":"dash"}, - labels={ - "Temperature": "Temperature (C)", - }, - ) - - df41_concat = temp_sweep_data_melt.copy() - df41_tand = pd.DataFrame() - df41_tand["Temperature"] = df41_concat[df41_concat["Modulus"] == "E''"]["Temperature"] - df41_tand["Type"] = df41_concat[df41_concat["Modulus"] == "E''"]["Type"] - - df41_tand["Young's Modulus (MPa)"] = df41_concat[df41_concat["Modulus"] == "E''"]["Young's Modulus (MPa)"].to_numpy() / df41_concat[df41_concat["Modulus"] == "E'"]["Young's Modulus (MPa)"].to_numpy() - - df41_tand['Modulus'] = 'tan delta' - df41_concat = pd.concat([df41_concat, df41_tand], ignore_index=True) - - fig41 = px.line(df41_concat[df41_concat['Modulus']!="E''"], x="Temperature", y="Young's Modulus (MPa)", - # log_x=True, - # log_y=True, - facet_col='Modulus', - color="Type", line_dash="Type", - # line_dash_map={"Experiment":"solid", f"{N_nz}-Term Prony":"dash"}, - # template=template_from_url(theme), - labels={ - "Temperature": "Temperature (C)", - }, - ) - fig41.update_yaxes(matches=None, showticklabels=True) - fig41.update_yaxes(type="log", col=1) - fig4.update_yaxes(exponentformat = 'power') - fig41.update_yaxes(exponentformat = 'power') - - # Note: add shift factor prediction here - # Prerequisite boolean detection - # Only predict the shift factors if this statement resolves to true - b = (Tg and C1 and C2 and (shift_model == "WLF")) or (Tg and C1 and C2 and TL and Ea and (shift_model == "hybrid")) or (shiftData) - if b: - # perform TTSP shifting - # Note: add TTSP here (separate from update_line_chart) - # Inputs: domain, shift_model, Tg, C1, C2, TL, Ea) - print("Remove This Temporary Print. Here for python correctness only") - - temp_sweep_data["Frequency"] = 1 - # TTSP temp to freq - # update to new method - # # Current TODO Remove this - # C1 = 17.44 - # C2 = 51.6 - # T_ref = 30 - # Perform Temperature Conversion (Old Method) - # freq_sweep_data = tts_temperature_to_frequency(temp_sweep_data, T_ref, C1, C2) - - # New Method - # use TL fpr T_ref for hybrid and Tg for WLF - if shift_model == "WLF": - T_ref = Tg - elif shift_model == "hybrid": - T_ref = TL - else: - T_ref = None - print("before tts_temperature_to_frequency_V2") - freq_sweep_data = tts_temperature_to_frequency_V2(temp_sweep_data, T_ref, C1, C2, Ea, shift_model, shiftData) - print("after tts_temperature_to_frequency_V2") - # reassign df to transformed data - df = freq_sweep_data[["Frequency", "E'", "E''"]] - df.columns = ["Frequency", "E Storage", "E Loss"] - - else: - # end run early. do not generate any frequency related charts or tables - # fig1=fig11=fig2=fig3=coef_df=None - fig1=fig11=fig2=fig3=go.Figure() # set unused plots to empty figures - coef_df = pd.DataFrame(columns=["tau_i", "E_i"]).to_dict("records") # set unused tables to empty dataframes - return fig1, fig11, fig2, fig3, fig4, fig41, coef_df - - # temp_sweep_data_melt = pd.melt(freq_sweep_data, id_vars=["Frequency"], - # value_vars=["E'", "E''"], - # var_name='Modulus', value_name="Young's Modulus (MPa)") - # temp_sweep_data_melt["Type"] = "Experiment" - - # fig4 = px.line(temp_sweep_data_melt, x="Frequency", y="Young's Modulus (MPa)", - # # log_x=True, - # log_y=True, - # facet_col='Modulus', - # color="Type", line_dash="Type", - # line_dash_map={"Experiment":"solid", f"{N_nz}-Term Prony":"dash"}, - # labels={ - # "Frequency": "Frequency (Hz)", - # }, - # ) - - # df41_concat = temp_sweep_data_melt.copy() - # df41_tand = pd.DataFrame() - # df41_tand["Frequency"] = df41_concat[df41_concat["Modulus"] == "E''"]["Frequency"] - # df41_tand["Type"] = df41_concat[df41_concat["Modulus"] == "E''"]["Type"] - - # df41_tand["Young's Modulus (MPa)"] = df41_concat[df41_concat["Modulus"] == "E''"]["Young's Modulus (MPa)"].to_numpy() / df41_concat[df41_concat["Modulus"] == "E'"]["Young's Modulus (MPa)"].to_numpy() - # # print(df11_tand.head(5)) - # df41_tand['Modulus'] = 'tan delta' - # # print(df11_tand.head(5)) - # df41_concat = pd.concat([df41_concat, df41_tand], ignore_index=True) - - # # print(df11_tand.head(5)) - # # print(df11_concat) - - # fig41 = px.line(df41_concat[df41_concat['Modulus']!="E''"], x="Frequency", y="Young's Modulus (MPa)", - # log_x=True, - # # log_y=True, - # facet_col='Modulus', - # color="Type", line_dash="Type", - # line_dash_map={"Experiment":"solid", f"{N_nz}-Term Prony":"dash"}, - # # template=template_from_url(theme), - # labels={ - # "Frequency": "Frequency (Hz)", - # }, - # ) - # fig41.update_yaxes(matches=None, showticklabels=True) - # fig41.update_yaxes(type="log", col=1) - # fig4.update_yaxes(exponentformat = 'power') - # fig41.update_yaxes(exponentformat = 'power') - # Frequency Domain: - - # Fit prony series - tau_i, E_i = smooth_prony_fit( - omega=df['Frequency'].to_numpy(), E_stor=df['E Storage'].to_numpy(), - E_loss=df['E Loss'].to_numpy(), N=N, smoothness=smoothness) - N_nz = np.count_nonzero(E_i) - - complex = compute_complex(tau_i, E_i) - relax = compute_relaxation_modulus(tau_i, E_i) - # Get the column names of the first two columns as x and y - x_column_name = df.columns[0] - y_column_name = df.columns[1] - z_column_name = df.columns[2] - - df_melt = pd.melt(df, id_vars=[x_column_name], - value_vars=[y_column_name, z_column_name], - var_name='Modulus', value_name="Young's Modulus (MPa)") - df_melt["Type"] = "Experiment" - x_column_name = complex.columns[0] - y_column_name = complex.columns[1] - z_column_name = complex.columns[2] - - complex_melt = pd.melt(complex, id_vars=[x_column_name], - value_vars=[y_column_name, z_column_name], - var_name='Modulus', value_name="Young's Modulus (MPa)") - complex_melt["Type"] = f"{N_nz}-Term Prony" - - df_concat = pd.concat([df_melt, complex_melt], ignore_index=True) - - fig1 = px.line(df_concat, x=x_column_name, y="Young's Modulus (MPa)", - log_x=True, - log_y=True, - facet_col='Modulus', - color="Type", line_dash="Type", - line_dash_map={"Experiment":"solid", f"{N_nz}-Term Prony":"dash"}, - labels={ - "Frequency": "Frequency (Hz)", - }, - ) - - df11_concat = df_concat.copy() - df11_tand = pd.DataFrame() - df11_tand["Frequency"] = df11_concat[df11_concat["Modulus"] == "E Loss"]["Frequency"] - df11_tand["Type"] = df11_concat[df11_concat["Modulus"] == "E Loss"]["Type"] - - df11_tand["Young's Modulus (MPa)"] = df11_concat[df11_concat["Modulus"] == "E Loss"]["Young's Modulus (MPa)"].to_numpy() / df11_concat[df11_concat["Modulus"] == "E Storage"]["Young's Modulus (MPa)"].to_numpy() - - df11_tand['Modulus'] = 'tan delta' - df11_concat = pd.concat([df11_concat, df11_tand], ignore_index=True) - - fig11 = px.line(df11_concat[df11_concat['Modulus']!="E Loss"], x="Frequency", y="Young's Modulus (MPa)", - log_x=True, - # log_y=True, - facet_col='Modulus', - color="Type", line_dash="Type", - line_dash_map={"Experiment":"solid", f"{N_nz}-Term Prony":"dash"}, - # template=template_from_url(theme), - labels={ - "Frequency": "Frequency (Hz)", - }, - ) - fig11.update_yaxes(matches=None, showticklabels=True) - fig11.update_yaxes(type="log", col=1) - - relax["Type"] = f"{N_nz}-Term Prony" - fig2a = px.line(relax, x="Time", y="E", - log_x=True, - log_y=True, - # facet_col='Modulus', - color="Type", line_dash="Type", - line_dash_map={"Basis":"solid", f"{N_nz}-Term Prony":"dash"}, - #template=template_from_url(theme), - # width=800, height=450, - labels={ - "Time": "Time (s)", - "E": "Relaxation Modulus (MPa)", - }, - ) - fig2a.update_layout( - autosize=False, - width=800, - height=450, - margin=dict(l=80, r=60, t=60, b=80), - ) - - basis_df = pd.DataFrame() - basis_df["Time"] = tau_i - basis_df["E"] = E_i[len(E_i)-len(tau_i):] - basis_df["Type"] = f"{N_nz}-Term Basis" - # basis_df["Modulus"] = "E Storage" - - fig2b = px.scatter(basis_df, x="Time", y="E", - log_x=True, - log_y=True, - symbol= "Type", - # label="Type", - # facet_col='Modulus', - # color="Type", line_dash="Type", - # line_dash_map={"Basis":"solid", f"{N_nz}-Term Prony":"dash"}, - # scatter_marker_map={"Basis":"solid", f"{N_nz}-Term Prony":"dash"}, - #template=template_from_url(theme), - labels={ - "Time": "Time (s)", - "E": "Relaxation Modulus (MPa)", - }, - ) - fig2 = go.Figure(data = fig2a.data + fig2b.data, - # layout_xaxis_range=["auto",4], - ) - fig2.update_xaxes(type="log") - fig2.update_yaxes(type="log") - - fig2.update_layout( - autosize=False, - margin=dict(l=80, r=60, t=60, b=80), - # width=800, height=550, - xaxis_title="Time (s)", - yaxis_title="Relaxation Modulus (MPa)", - legend_title="Type", - #template=template_from_url(theme), + assert domain in EXPECTED_DOMAIN_COLUMNS, \ + f"Unknown domain {domain!r}; expected one of {list(EXPECTED_DOMAIN_COLUMNS)}." + expected_cols = EXPECTED_DOMAIN_COLUMNS[domain] + assert isinstance(uploadData, dict) and tuple(uploadData.keys()) == expected_cols, \ + f"uploadData keys must be {expected_cols} for domain {domain!r}; got " \ + f"{tuple(uploadData.keys()) if isinstance(uploadData, dict) else type(uploadData).__name__}." + + df = pd.DataFrame(uploadData) + + if len(df) == 0: + raise ValueError("Uploaded file has no data rows.") + if not np.all(np.isfinite(df.to_numpy())): + raise ValueError( + "Uploaded file contains non-finite values (NaN or Inf). " + "Check for blank entries or text in numeric columns." + ) + + if domain == "frequency": + if np.any(df['Frequency'].to_numpy() <= 0): + raise ValueError( + "All Frequency values in the uploaded file must be positive. " + "Remove rows with zero or negative frequencies." ) - - - rspectrum = compute_relaxation_spectrum(tau_i, E_i) - - rspectrum["Type"] = f"{N_nz}-Term Prony" - fig3a = px.line(rspectrum, x="Time", y="H", - log_x=True, - log_y=True, - # facet_col='Modulus', - color="Type", line_dash="Type", - line_dash_map={"Basis":"solid", f"{N_nz}-Term Prony":"dash"}, - #template=template_from_url(theme), - # width=800, height=450, - labels={ - "Time": "Time (s)", - "H": "Relaxation Spectrum (MPa)", - }, - ) - fig3a.update_layout( - autosize=False, - width=800, - height=450, - margin=dict(l=80, r=60, t=60, b=80), - ) - - - fig3 = go.Figure(data = fig3a.data + fig2b.data, - # layout_xaxis_range=["auto",4], - ) - fig3.update_xaxes(type="log") - fig3.update_yaxes(type="log") - - fig3.update_layout( - autosize=False, - margin=dict(l=80, r=60, t=60, b=80), - # width=800, height=550, - xaxis_title="Time (s)", - yaxis_title="Relaxation Spectrum (MPa)", - legend_title="Type", - #template=template_from_url(theme), + freq_sweep_data = df.rename(columns={'E Storage': "E'", 'E Loss': "E''"}) + # TODO: update this when reverse hybrid TTSP becomes available + temp_sweep_data = tts_frequency_to_temperature( + freq_sweep_data, + omega_ref=VIS_REF_FREQUENCY_HZ, T_ref=VIS_REF_TEMPERATURE_C, + C1=UNIVERSAL_WLF_C1, C2=UNIVERSAL_WLF_C2, + ) + fig4, fig41 = _build_temperature_figures(temp_sweep_data) + + elif domain == "temperature": + temp_sweep_data = df.rename(columns={'E Storage': "E'", 'E Loss': "E''"}) + fig4, fig41 = _build_temperature_figures(temp_sweep_data) + + # `is not None` rather than truthy checks: Tg = 0 °C is a valid + # reference, and the route default-fills numeric estimates that may + # legitimately be zero. Tg is required only for WLF (used as T_ref); + # hybrid_shift uses TL as the WLF/Arrhenius crossover and never reads Tg. + has_shift_params = ( + shiftData is not None + or (shift_model == "WLF" + and Tg is not None and C1 is not None and C2 is not None) + or (shift_model == "hybrid" + and TL is not None and C1 is not None and C2 is not None + and Ea is not None) + ) + if not has_shift_params: + # No way to bring temperature data onto a master curve, so the + # frequency-domain figures and coefficient table are not produced. + empty = go.Figure() + return ( + empty, empty, empty, empty, fig4, fig41, + pd.DataFrame(columns=["tau_i", "E_i"]).to_dict("records"), ) - if fit_settings: - fig2 = fig2 - fig3 = fig3 - else: - fig2 = fig2a - fig3 = fig3a - - coef = {"tau_i":tau_i, "E_i":E_i[len(E_i)-len(tau_i):], } - coef_df = pd.DataFrame(coef) - coef_df = coef_df[coef_df.E_i != 0].reset_index(drop=False) - # df.reset_index(inplace=True) - coef_df = coef_df.rename(columns = {'index':'i'}) - figs = [fig1, fig11, fig2, fig3] - for fig in figs: - fig.update_xaxes(exponentformat = 'power') - fig.update_yaxes(exponentformat = 'power') - - return fig1, fig11, fig2, fig3, fig4, fig41, coef_df.to_dict("records") - except ValueError: - raise - except Exception as e: - raise ValueError("File contains corrupt data") + freq_sweep_data = tts_temperature_to_frequency_V2( + temp_sweep_data, shift_model, + Tg=Tg, TL=TL, C1=C1, C2=C2, Ea=Ea, shiftData=shiftData, + ) + df = freq_sweep_data[["Frequency", "E'", "E''"]].rename( + columns={"E'": 'E Storage', "E''": 'E Loss'}, + ) + + tau_i, E_i = smooth_prony_fit( + omega=df['Frequency'].to_numpy(), + E_stor=df['E Storage'].to_numpy(), + E_loss=df['E Loss'].to_numpy(), + N=number_of_prony, smoothness=smoothness, + ) + N_nz = np.count_nonzero(E_i) + + fig1, fig11 = _build_complex_figures(df, tau_i, E_i, N_nz) + fig2, fig3 = _build_relaxation_figures(tau_i, E_i, N_nz, fit_settings) + coef_records = _build_coef_records(tau_i, E_i) + + return fig1, fig11, fig2, fig3, fig4, fig41, coef_records diff --git a/services/app/dynamfit/files/PETMP-TATATO master curve 55C clean.txt b/services/app/dynamfit/files/PETMP-TATATO master curve 55C clean.txt new file mode 100644 index 000000000..7bc4eb4e8 --- /dev/null +++ b/services/app/dynamfit/files/PETMP-TATATO master curve 55C clean.txt @@ -0,0 +1,403 @@ +2293935 2840869888 111904000 +1822138.07 2846269952 109930000 +1447374.636 2837870080 104096000 +1149690.754 2829829888 102173000 +1045846.257 2790799872 116431000 +913231.9343 2820730112 107573000 +830745.5445 2779909888 117166000 +725406.121 2810350080 98973504 +659884.1493 2764519936 122861000 +576210.247 2810259968 112342000 +524164.7094 2764359936 128175000 +457700.6593 2790230016 108489000 +416358.877 2715960064 88192200 +363564.1028 2790119936 102800000 +330725.7079 2729029888 122933000 +288790.039 2770380032 130977000 +262704.6234 2720590080 128465000 +229393.4961 2769019904 123926000 +208673.9692 2709440000 133776000 +182213.8031 2767340032 119548000 +165755.4186 2690500096 132897000 +144737.4675 2740029952 126209000 +131664.577 2673619968 146350000 +114969.0735 2719630080 121963000 +114016.5983 2581590016 172447008 +104584.6239 2656839936 149692000 +91323.18757 2765469952 169002000 +90566.63955 2560489984 169350000 +83074.55267 2638789888 152662000 +72540.61699 2666429952 107439000 +71939.58522 2542370048 170059008 +65988.41671 2610070016 151828000 +57621.02665 2689870080 203916992 +57143.65442 2524349952 175596000 +52416.47005 2607630080 189704992 +45770.06397 2563330048 149674000 +45390.82344 2502609920 182983008 +41635.88503 2572869888 174703008 +36356.41125 2480669952 95549200 +36055.22314 2476720128 185768000 +33072.57302 2552570112 188035008 +28879.00244 2839569920 95302600 +28639.66601 2452100096 199372000 +26270.46323 2535160064 194000992 +22939.35009 2255610112 244578000 +22749.32472 2431640064 205764000 +20867.39603 2510970112 201574000 +18221.38153 2443290112 681705024 +18070.40838 2403419904 213451008 +16575.54231 2485740032 215096992 +14473.747 2093789952 596254016 +14353.8757 2376940032 215000992 +13166.45703 2465009920 217996992 +11496.90735 2721949952 -258543008 +11401.65963 2349179904 231291008 +10458.46262 2440130048 231619008 +9132.319001 2435729920 303830016 +9056.663761 2312110080 240007008 +8307.455823 2395180032 244840992 +7254.061699 2525750016 245922000 +7193.958717 2275630080 219195008 +6598.841783 2376440064 261164992 +5762.102787 2455490048 247312000 +5714.365344 2238560000 264544992 +5241.647005 2350409984 282793984 +4577.00658 2444489984 276670016 +4539.082053 2227040000 273712000 +4531.943864 2124450048 281884992 +4163.588614 2291500032 299478016 +3635.641125 2414170112 326662016 +3605.522556 2163780096 273172992 +3599.852413 2095369984 282872000 +3307.257302 2264039936 306929984 +2887.900366 2407069952 281902016 +2863.966698 2141980032 311564000 +2859.462279 2059549952 295720992 +2627.046379 2220039936 318524000 +2293.935071 2358099968 311543008 +2274.932375 2084790016 315622016 +2271.352048 2014230016 302835008 +2086.739686 2182739968 380468992 +1807.040887 2038439936 335476992 +1804.199274 1977670016 318188000 +1657.554231 2166789888 346361984 +1435.387498 1983219968 356465984 +1433.126841 1932370048 327239008 +1316.645759 2089629952 365163008 +1140.165987 1946439936 353652992 +1138.372488 1882019968 341908992 +1045.846289 2056240000 366192000 +905.6664368 1897030016 372920992 +904.2425764 1836259968 346740000 +719.3958838 1839480064 397603008 +718.2645127 1786700032 365708000 +571.4365344 1770749952 402304000 +570.5393769 1732989952 377723008 +453.9082175 1713420032 397044000 +453.1943787 1673590016 387911008 +360.5522556 1649740032 410040000 +359.9852336 1623559936 401836992 +286.3966759 1590599936 426499008 +285.9462356 1554700032 400808992 +227.4932466 1545869952 466524000 +227.135201 1510480000 409276992 +207.5506509 1395539968 415006016 +180.7040887 1477859968 448412000 +180.4199158 1436630016 432328000 +164.8634083 1351280000 420084000 +143.5387558 1396419968 472732000 +143.3126937 1370870016 436246016 +130.9555623 1305219968 427724000 +114.0166018 1331800064 467833984 +113.8372527 1307059968 448447008 +104.0217201 1249100032 433291008 +90.42425378 1238439936 449617984 +82.62739895 1189379968 435718016 +71.8264532 1168310016 451720992 +65.63329502 1122089984 436598016 +57.0539348 1099059968 457840000 +52.13435073 1058240000 436924992 +45.31943883 1020920000 455984000 +41.41184025 988563968 436457984 +35.99852577 951798016 450759008 +32.89455289 923057024 435076992 +28.59462404 880388992 447100992 +26.12914515 857160000 428944000 +22.7135201 814913984 439471008 +20.75506474 788316032 420734016 +18.04199206 747174016 430302016 +16.48634047 724009984 410057984 +14.33126937 689894976 417406016 +14 568672000 349667008 +13.09555659 662268992 396695008 +11.38372551 619676032 402409984 +11.12059975 540694976 349884000 +10.40217183 600414016 387304992 +9.04242574 571596992 386844000 +8.833399773 514884000 349825984 +8.262739365 538400000 370492000 +7.18264532 506838016 368244992 +7.016620159 480158016 342475008 +6.563329944 479663008 351972000 +5.705393721 460131008 347563008 +5.573500156 443440992 331536992 +5.213435249 431500992 334171008 +4.531944004 412248992 326948000 +4.427189827 403097984 315656992 +4.141183848 383539008 311496000 +3.516639948 358104992 295672992 +3.289455377 337899008 284119008 +2.793370008 315992992 273343008 +2.612914383 297115008 263807008 +2.218849897 279875008 253183008 +2.075506518 261351008 241736000 +1.762500048 243412000 230644992 +1.648634158 227535008 219187008 +1.521461564 185922000 189778000 +1.399999976 210956000 208712992 +1.309555681 199146000 197946000 +1.208540363 169226000 177728992 +1.112059951 184403008 187440992 +1.040217183 174464992 177396992 +0.959977017 154779008 165544992 +0.883340001 161538000 167650000 +0.826273959 152975008 157468000 +0.762536992 141368000 152604992 +0.701662004 141479008 149466000 +0.656332994 133571000 139380992 +0.605704733 128419000 138216000 +0.55734998 123358000 132034000 +0.521343536 116966000 123477000 +0.481128511 113787000 122583000 +0.442719012 108774000 115436000 +0.414118401 104231000 107230000 +0.382173751 100493000 106855000 +0.351664007 95464200 100732000 +0.328945538 93233504 93945200 +0.303571793 89531104 93136704 +0.279336989 85304800 86527000 +0.261291449 83069400 82009696 +0.254906645 72493200 79922704 +0.241135345 80012496 81561504 +0.221884996 76707600 74736704 +0.207550657 75283800 71653696 +0.202479627 68407696 71454400 +0.191541149 72078496 70229400 +0.176249996 69047696 65297700 +0.160835164 64300900 62504500 +0.152146154 65128700 60521200 +0.140000001 63146500 56378200 +0.127755936 60160200 54351000 +0.120854034 59613100 51939400 +0.111206003 57939100 48411700 +0.101480159 55833000 46751300 +0.095997704 55037300 45646200 +0.088334002 53580900 41600200 +0.080608579 51407300 40360300 +0.076253698 51088900 38743100 +0.0701662 50247200 35288000 +0.064029635 48044200 34666900 +0.060570469 48078200 33460300 +0.055734999 47112700 30852500 +0.055285669 44620800 32979000 +0.050860613 45136800 29851900 +0.048112854 45129200 28628800 +0.044271901 44435100 26627000 +0.043914985 43165300 28346100 +0.04039997 42734000 25583100 +0.038217376 42658500 24471700 +0.035166401 42270400 23067700 +0.034882887 41217000 24205200 +0.032090927 40628800 22074600 +0.030357178 40202400 20941800 +0.0279337 40564600 19763700 +0.027708467 39411200 20661800 +0.025490664 38853800 18898600 +0.024113535 38612900 18072900 +0.0221885 38904400 17099600 +0.02200962 38127900 17624200 +0.020247962 37329200 16269200 +0.019154114 37362800 15606600 +0.017625 37759900 14901400 +0.017482868 36596400 15192900 +0.016083517 36330400 14328300 +0.015214616 35859800 13490700 +0.014 36480500 12911400 +0.013902737 35446800 14574100 +0.013887128 35438800 12988400 +0.012775593 35307100 11422100 +0.012085404 34834900 11753100 +0.011043341 34601400 12361200 +0.011030952 34478900 11189700 +0.010148015 34016700 10631100 +0.009599771 34110400 9850600 +0.008772031 33825700 10495300 +0.008762186 33588800 9665130 +0.008060858 33254800 8905910 +0.00762537 33248600 8761070 +0.006967873 33061700 8969100 +0.006960071 32880500 8360630 +0.006402964 32671300 7738190 +0.006057047 32782100 7579470 +0.005534779 32683900 7584470 +0.005528567 32371800 7268790 +0.005086061 31939800 6835280 +0.004811285 32103000 6940420 +0.004396433 32079500 6602980 +0.004391498 31855300 6354160 +0.004039997 31476700 6142920 +0.00384241 32029700 6956780 +0.003821738 31632700 6148660 +0.003492209 31693800 5632170 +0.003488289 31569100 5871530 +0.003209093 30994100 5424230 +0.003052136 31561400 5992860 +0.003035718 31322300 5542750 +0.002773964 31289000 5006010 +0.002770847 31121200 4653900 +0.002549066 30516400 4879250 +0.002424396 31218000 5127310 +0.002411354 31014900 5024820 +0.002203435 30929500 4388170 +0.002200962 30997200 4154300 +0.002024796 30444200 4460210 +0.001925767 30950800 4483630 +0.001915411 30650300 4599020 +0.001750255 30744200 3873000 +0.001748287 30318600 3683550 +0.001608352 30009000 4029210 +0.001529691 30893900 3776310 +0.001521462 30437600 4208790 +0.001390274 30496000 3406840 +0.001388713 30098400 3488380 +0.001277559 29873300 3770610 +0.001215077 30524900 3432220 +0.001104334 30248600 3150830 +0.001103095 30182300 3432290 +0.001014802 29764400 3546340 +0.000965169 30443800 3065790 +0.000877203 30206600 3167000 +0.000876219 29845500 3096020 +0.000806086 29573500 3323220 +0.000766662 30380200 2784710 +0.000696787 30102100 2424010 +0.000696007 29606500 2913400 +0.000640296 29356500 3206470 +0.000608981 30166000 2582290 +0.00060258 30848100 3663450 +0.000553478 29947200 2579610 +0.000552857 29467800 2811530 +0.000508606 29287200 3055880 +0.000483732 30050800 2326470 +0.000478647 30683500 3193320 +0.000439643 29713900 2347130 +0.00043915 29260600 2695530 +0.000404 29213900 2877910 +0.000384241 29840100 2229950 +0.000380202 30511200 2835070 +0.000349221 29612800 2286870 +0.000348829 29182500 2530960 +0.000320909 29046000 2761340 +0.000305214 29769700 2134990 +0.000302005 30381800 2491770 +0.000277396 29416100 2251860 +0.000277085 28996000 2528630 +0.000254907 29030600 2622450 +0.00024244 29825700 2198520 +0.000239891 30450400 2149960 +0.000220343 29160600 2138380 +0.000220096 28836000 2459800 +0.000192577 29669300 1822850 +0.000190553 30222800 2074410 +0.000175026 29161600 2210350 +0.000174829 28884000 2355240 +0.000152969 29638400 1845890 +0.000151361 30139100 1949010 +0.000139027 28880500 2076200 +0.000138871 28765000 2310400 +0.000121508 29370100 1948610 +0.000120231 30059300 1918850 +0.000110433 28873500 2083810 +0.00011031 28688900 2263690 +9.65E-05 29186100 1861410 +9.55E-05 29977600 1822240 +8.77E-05 28686800 1985870 +8.76E-05 28668000 2119560 +7.67E-05 29217500 1893700 +7.59E-05 29908000 1780560 +6.97E-05 28735200 1992410 +6.96E-05 28496600 2037330 +6.09E-05 28953400 1836020 +6.03E-05 29768000 1786650 +5.53E-05 28585900 1962190 +5.53E-05 28728900 1960150 +4.84E-05 28999900 1758730 +4.79E-05 29635900 1645220 +4.40E-05 28663900 1849300 +4.39E-05 30515700 2133780 +3.84E-05 28853500 1744300 +3.80E-05 29822100 2043100 +3.49E-05 28665000 1781910 +3.49E-05 30382600 1961410 +3.05E-05 28736700 1674030 +3.02E-05 29553100 1438830 +2.77E-05 28645800 1814080 +2.77E-05 30306300 1759320 +2.42E-05 28710500 1625570 +2.40E-05 29632100 1450440 +2.20E-05 28672000 1763790 +2.20E-05 30233000 1690340 +1.93E-05 28770500 1647760 +1.91E-05 29328300 1529930 +1.75E-05 28734200 1757860 +1.75E-05 30286200 1505330 +1.53E-05 28735200 1639490 +1.51E-05 29241300 1542040 +1.39E-05 28566600 1614910 +1.39E-05 30146300 1586810 +1.22E-05 28707200 1547600 +1.20E-05 29073700 1471940 +1.10E-05 30076200 1456670 +9.65E-06 28729400 1566920 +9.55E-06 28999200 1446460 +8.76E-06 30011200 1476900 +7.67E-06 28735700 1511470 +7.59E-06 28960100 1409310 +6.96E-06 29952300 1473630 +6.09E-06 28735200 1494190 +6.03E-06 28954600 1463510 +5.53E-06 29836200 1477860 +4.84E-06 28867100 1415670 +4.79E-06 28892400 1457370 +4.39E-06 29744000 1431860 +3.84E-06 28831600 1360210 +3.80E-06 28903600 1358990 +3.49E-06 29716200 1442250 +3.02E-06 29011100 1301650 +2.77E-06 29705900 1588670 +2.40E-06 28948800 1343320 +2.20E-06 29695500 1085050 +1.91E-06 29020000 1305390 +1.75E-06 29987000 1366020 +1.51E-06 29033200 1291710 +1.39E-06 29324900 1358050 +1.20E-06 29100600 1211410 +1.10E-06 29380700 1366960 +9.55E-07 29075500 1213850 +8.76E-07 29341600 1289460 +7.59E-07 29144600 1096030 +6.96E-07 29263300 1243890 +6.03E-07 29168700 1125230 +5.53E-07 29305200 1224530 +4.39E-07 29211300 1216400 +3.49E-07 29241900 1210200 +2.77E-07 29355100 1177430 +2.20E-07 29306000 1158370 +1.75E-07 29357400 1120370 +1.39E-07 29340900 1072250 +1.10E-07 29325800 1044810 +8.76E-08 29554600 928337 +6.96E-08 29534000 966649 +5.53E-08 29746900 947072 +4.39E-08 29773200 859087 diff --git a/services/app/dynamfit/files/PMMA_shifted_R10_data.txt b/services/app/dynamfit/files/PMMA_shifted_R10_data.txt new file mode 100644 index 000000000..5c7814a0d --- /dev/null +++ b/services/app/dynamfit/files/PMMA_shifted_R10_data.txt @@ -0,0 +1,1121 @@ +8.0000e+11 3.6000e+03 3.1500e+02 +6.3546e+11 3.6000e+03 3.1600e+02 +5.0477e+11 3.6000e+03 3.2100e+02 +4.4398e+11 3.6000e+03 3.2300e+02 +4.0095e+11 3.6000e+03 3.2900e+02 +3.5266e+11 3.6000e+03 3.3400e+02 +3.1849e+11 3.6000e+03 3.3400e+02 +2.8013e+11 3.6000e+03 3.4000e+02 +2.5298e+11 3.6000e+03 3.4000e+02 +2.2252e+11 3.6000e+03 3.4000e+02 +2.0095e+11 3.6000e+03 3.4000e+02 +1.8488e+11 3.6000e+03 3.4000e+02 +1.7675e+11 3.6000e+03 3.4000e+02 +1.5962e+11 3.6000e+03 3.4000e+02 +1.4685e+11 3.6000e+03 3.4000e+02 +1.4040e+11 3.6000e+03 3.4000e+02 +1.2679e+11 3.6000e+03 3.3400e+02 +1.1665e+11 3.5900e+03 3.3400e+02 +1.1152e+11 3.5900e+03 3.3100e+02 +1.0071e+11 3.5800e+03 3.3100e+02 +9.2659e+10 3.5700e+03 3.3100e+02 +8.8585e+10 3.5600e+03 3.3100e+02 +8.0257e+10 3.5400e+03 3.2900e+02 +8.0000e+10 3.5200e+03 3.2900e+02 +7.3601e+10 3.5200e+03 3.2600e+02 +7.0365e+10 3.5200e+03 3.2300e+02 +6.3750e+10 3.5100e+03 3.2100e+02 +6.3546e+10 3.4800e+03 3.1900e+02 +5.8464e+10 3.4700e+03 3.1900e+02 +5.5893e+10 3.4600e+03 3.1800e+02 +5.0639e+10 3.4600e+03 3.1600e+02 +5.0477e+10 3.4400e+03 3.1600e+02 +4.6439e+10 3.4400e+03 3.1500e+02 +4.4398e+10 3.4300e+03 3.1100e+02 +4.0224e+10 3.4200e+03 3.0800e+02 +4.0095e+10 3.4100e+03 3.0700e+02 +3.6888e+10 3.4000e+03 3.0700e+02 +3.5266e+10 3.3900e+03 3.0700e+02 +3.4005e+10 3.3800e+03 3.0700e+02 +3.1951e+10 3.3700e+03 3.0700e+02 +3.1849e+10 3.3600e+03 3.0700e+02 +2.9301e+10 3.3600e+03 3.0700e+02 +2.8013e+10 3.3600e+03 3.0700e+02 +2.7011e+10 3.3500e+03 3.0700e+02 +2.5379e+10 3.3300e+03 3.0600e+02 +2.5298e+10 3.3300e+03 3.0400e+02 +2.3275e+10 3.3200e+03 3.0400e+02 +2.2252e+10 3.3100e+03 3.0400e+02 +2.1456e+10 3.3000e+03 3.0400e+02 +2.0160e+10 3.3000e+03 3.0400e+02 +2.0095e+10 3.2900e+03 3.0300e+02 +1.8488e+10 3.2900e+03 3.0200e+02 +1.7675e+10 3.2800e+03 3.0100e+02 +1.7043e+10 3.2800e+03 3.0100e+02 +1.6013e+10 3.2800e+03 3.0000e+02 +1.5962e+10 3.2700e+03 2.9900e+02 +1.4685e+10 3.2700e+03 2.9800e+02 +1.4396e+10 3.2600e+03 2.9600e+02 +1.4040e+10 3.2300e+03 2.9500e+02 +1.3538e+10 3.2200e+03 2.9400e+02 +1.2720e+10 3.2200e+03 2.9300e+02 +1.2679e+10 3.2200e+03 2.9300e+02 +1.1665e+10 3.2100e+03 2.9300e+02 +1.1435e+10 3.2100e+03 2.9300e+02 +1.1152e+10 3.2000e+03 2.9300e+02 +1.0753e+10 3.1800e+03 2.9300e+02 +1.0104e+10 3.1800e+03 2.9300e+02 +1.0071e+10 3.1800e+03 2.9300e+02 +9.2659e+09 3.1700e+03 2.9300e+02 +9.0831e+09 3.1700e+03 2.9000e+02 +8.8585e+09 3.1500e+03 2.9000e+02 +8.5418e+09 3.1400e+03 2.8900e+02 +8.0257e+09 3.1400e+03 2.8900e+02 +8.0000e+09 3.1400e+03 2.8900e+02 +7.3601e+09 3.1400e+03 2.8900e+02 +7.2150e+09 3.1400e+03 2.8800e+02 +7.0365e+09 3.1300e+03 2.8700e+02 +6.7850e+09 3.1300e+03 2.8700e+02 +6.3750e+09 3.1300e+03 2.8500e+02 +6.3546e+09 3.1300e+03 2.8400e+02 +6.2489e+09 3.1200e+03 2.8400e+02 +5.8464e+09 3.0900e+03 2.8300e+02 +5.7311e+09 3.0900e+03 2.8300e+02 +5.5893e+09 3.0900e+03 2.8200e+02 +5.3895e+09 3.0900e+03 2.7900e+02 +5.0639e+09 3.0800e+03 2.7900e+02 +5.0477e+09 3.0800e+03 2.7900e+02 +4.9637e+09 3.0800e+03 2.7900e+02 +4.6439e+09 3.0500e+03 2.8200e+02 +4.5523e+09 3.0400e+03 2.8200e+02 +4.4398e+09 3.0400e+03 2.8300e+02 +4.2810e+09 3.0400e+03 2.8300e+02 +4.0224e+09 3.0400e+03 2.7900e+02 +4.0095e+09 3.0400e+03 2.7900e+02 +3.9428e+09 3.0300e+03 2.7800e+02 +3.6888e+09 3.0300e+03 2.7600e+02 +3.6161e+09 3.0200e+03 2.7600e+02 +3.5266e+09 3.0100e+03 2.7300e+02 +3.4005e+09 3.0100e+03 2.7300e+02 +3.1951e+09 3.0100e+03 2.6800e+02 +3.1849e+09 3.0000e+03 2.6800e+02 +3.1319e+09 3.0000e+03 2.6500e+02 +2.9301e+09 3.0000e+03 2.6500e+02 +2.8723e+09 3.0000e+03 2.6500e+02 +2.8013e+09 3.0000e+03 2.6300e+02 +2.7026e+09 2.9900e+03 2.6300e+02 +2.7011e+09 2.9700e+03 2.6100e+02 +2.5379e+09 2.9600e+03 2.6100e+02 +2.5298e+09 2.9600e+03 2.5900e+02 +2.4877e+09 2.9600e+03 2.5700e+02 +2.3275e+09 2.9600e+03 2.5500e+02 +2.2816e+09 2.9500e+03 2.5500e+02 +2.2252e+09 2.9500e+03 2.5500e+02 +2.1468e+09 2.9500e+03 2.5500e+02 +2.1456e+09 2.9300e+03 2.5500e+02 +2.0160e+09 2.9300e+03 2.5500e+02 +2.0095e+09 2.9200e+03 2.5500e+02 +1.9761e+09 2.9200e+03 2.5500e+02 +1.8488e+09 2.9200e+03 2.5500e+02 +1.8123e+09 2.9200e+03 2.5500e+02 +1.7675e+09 2.9100e+03 2.5300e+02 +1.7052e+09 2.9100e+03 2.5200e+02 +1.7043e+09 2.9000e+03 2.4900e+02 +1.6013e+09 2.8900e+03 2.4900e+02 +1.5962e+09 2.8900e+03 2.5300e+02 +1.5696e+09 2.8900e+03 2.4900e+02 +1.4685e+09 2.8900e+03 2.4900e+02 +1.4396e+09 2.8800e+03 2.4900e+02 +1.4040e+09 2.8800e+03 2.4900e+02 +1.3545e+09 2.8800e+03 2.4600e+02 +1.3538e+09 2.8800e+03 2.4600e+02 +1.2720e+09 2.8800e+03 2.4400e+02 +1.2679e+09 2.8800e+03 2.4400e+02 +1.2645e+09 2.8700e+03 2.4300e+02 +1.2468e+09 2.8700e+03 2.4200e+02 +1.1665e+09 2.8600e+03 2.4000e+02 +1.1435e+09 2.8600e+03 2.4000e+02 +1.1152e+09 2.8600e+03 2.3900e+02 +1.0759e+09 2.8600e+03 2.3900e+02 +1.0753e+09 2.8500e+03 2.3900e+02 +1.0104e+09 2.8500e+03 2.3900e+02 +1.0071e+09 2.8500e+03 2.3800e+02 +1.0044e+09 2.8400e+03 2.3800e+02 +9.9038e+08 2.8200e+03 2.3500e+02 +9.2659e+08 2.8100e+03 2.3500e+02 +9.0831e+08 2.8100e+03 2.3400e+02 +8.8584e+08 2.8100e+03 2.3300e+02 +8.5465e+08 2.8100e+03 2.3300e+02 +8.5418e+08 2.8100e+03 2.3300e+02 +8.0257e+08 2.8000e+03 2.3300e+02 +8.0000e+08 2.8000e+03 2.3300e+02 +7.9783e+08 2.8000e+03 2.3300e+02 +7.8669e+08 2.7800e+03 2.3300e+02 +7.3602e+08 2.7800e+03 2.3300e+02 +7.2150e+08 2.7800e+03 2.3200e+02 +7.0365e+08 2.7800e+03 2.3200e+02 +6.7887e+08 2.7800e+03 2.3000e+02 +6.7850e+08 2.7800e+03 2.2400e+02 +6.3750e+08 2.7700e+03 2.2400e+02 +6.3550e+08 2.7700e+03 2.2400e+02 +6.3374e+08 2.7700e+03 2.2400e+02 +6.2489e+08 2.7700e+03 2.2400e+02 +5.8463e+08 2.7600e+03 2.2400e+02 +5.7311e+08 2.7600e+03 2.2100e+02 +5.5891e+08 2.7600e+03 2.1900e+02 +5.3925e+08 2.7500e+03 2.1800e+02 +5.3895e+08 2.7500e+03 2.1700e+02 +5.2131e+08 2.7400e+03 2.1600e+02 +5.0639e+08 2.7400e+03 2.1600e+02 +5.0480e+08 2.7400e+03 2.1500e+02 +5.0340e+08 2.7400e+03 2.1400e+02 +4.9637e+08 2.7400e+03 2.1500e+02 +4.6439e+08 2.7400e+03 2.1500e+02 +4.5523e+08 2.7300e+03 2.1400e+02 +4.4398e+08 2.7300e+03 2.1400e+02 +4.2834e+08 2.7200e+03 2.1400e+02 +4.2810e+08 2.7100e+03 2.1400e+02 +4.1409e+08 2.7100e+03 2.1400e+02 +4.0224e+08 2.7100e+03 2.1400e+02 +4.0090e+08 2.7100e+03 2.1400e+02 +3.9986e+08 2.7000e+03 2.1400e+02 +3.9428e+08 2.6900e+03 2.1400e+02 +3.6888e+08 2.6900e+03 2.1400e+02 +3.6161e+08 2.6900e+03 2.1400e+02 +3.5268e+08 2.6900e+03 2.1400e+02 +3.4024e+08 2.6900e+03 2.1300e+02 +3.4005e+08 2.6900e+03 2.1000e+02 +3.2893e+08 2.6800e+03 2.1000e+02 +3.1951e+08 2.6800e+03 2.1000e+02 +3.1850e+08 2.6700e+03 2.1000e+02 +3.1762e+08 2.6600e+03 2.1000e+02 +3.1319e+08 2.6600e+03 2.1300e+02 +2.9301e+08 2.6600e+03 2.1300e+02 +2.8723e+08 2.6600e+03 2.1200e+02 +2.8015e+08 2.6500e+03 2.1200e+02 +2.7026e+08 2.6500e+03 2.1200e+02 +2.7011e+08 2.6500e+03 2.1000e+02 +2.6127e+08 2.6500e+03 2.1000e+02 +2.5379e+08 2.6500e+03 2.1000e+02 +2.5300e+08 2.6500e+03 2.1000e+02 +2.5230e+08 2.6500e+03 2.1000e+02 +2.4877e+08 2.6500e+03 2.1000e+02 +2.3274e+08 2.6400e+03 2.1000e+02 +2.2816e+08 2.6400e+03 2.1000e+02 +2.2249e+08 2.6400e+03 2.1000e+02 +2.1468e+08 2.6400e+03 2.1000e+02 +2.1456e+08 2.6400e+03 2.0900e+02 +2.1443e+08 2.6300e+03 2.0900e+02 +2.0754e+08 2.6300e+03 2.0900e+02 +2.0159e+08 2.6200e+03 2.0600e+02 +2.0100e+08 2.6200e+03 2.0400e+02 +2.0040e+08 2.6100e+03 2.0400e+02 +1.9761e+08 2.6100e+03 2.0400e+02 +1.8488e+08 2.6100e+03 2.0400e+02 +1.8123e+08 2.6100e+03 2.0500e+02 +1.7676e+08 2.6100e+03 2.0500e+02 +1.7052e+08 2.6000e+03 2.0400e+02 +1.7043e+08 2.6000e+03 2.0400e+02 +1.7033e+08 2.6000e+03 2.0400e+02 +1.6485e+08 2.6000e+03 2.0200e+02 +1.6013e+08 2.5900e+03 1.9500e+02 +1.5960e+08 2.5900e+03 1.9400e+02 +1.5919e+08 2.5900e+03 1.9300e+02 +1.5696e+08 2.5800e+03 1.9300e+02 +1.4686e+08 2.5800e+03 1.9200e+02 +1.4396e+08 2.5800e+03 1.9300e+02 +1.4041e+08 2.5800e+03 1.9300e+02 +1.3545e+08 2.5800e+03 1.9200e+02 +1.3538e+08 2.5700e+03 1.9200e+02 +1.3530e+08 2.5700e+03 1.9200e+02 +1.3095e+08 2.5600e+03 1.9200e+02 +1.2720e+08 2.5600e+03 1.9200e+02 +1.2680e+08 2.5600e+03 1.9200e+02 +1.2645e+08 2.5600e+03 1.9200e+02 +1.2468e+08 2.5600e+03 1.9200e+02 +1.1666e+08 2.5600e+03 1.9200e+02 +1.1435e+08 2.5600e+03 1.9300e+02 +1.1155e+08 2.5500e+03 1.9400e+02 +1.0759e+08 2.5500e+03 1.9400e+02 +1.0753e+08 2.5500e+03 1.9400e+02 +1.0747e+08 2.5500e+03 1.9400e+02 +1.0402e+08 2.5400e+03 1.9300e+02 +1.0103e+08 2.5400e+03 1.8500e+02 +1.0070e+08 2.5400e+03 1.8500e+02 +1.0044e+08 2.5400e+03 1.8500e+02 +9.9038e+07 2.5400e+03 1.8500e+02 +9.2647e+07 2.5400e+03 1.8500e+02 +9.0832e+07 2.5300e+03 1.9300e+02 +8.8573e+07 2.5300e+03 1.9300e+02 +8.7223e+07 2.5300e+03 1.9300e+02 +8.5465e+07 2.5200e+03 1.9300e+02 +8.5417e+07 2.5200e+03 1.9300e+02 +8.5366e+07 2.5200e+03 1.9300e+02 +8.2622e+07 2.5100e+03 1.9100e+02 +8.0257e+07 2.5100e+03 1.8500e+02 +7.9783e+07 2.5100e+03 1.9100e+02 +7.8669e+07 2.5000e+03 1.9100e+02 +7.3605e+07 2.5000e+03 1.9100e+02 +7.2150e+07 2.5000e+03 1.9100e+02 +7.0370e+07 2.4900e+03 1.9100e+02 +6.9284e+07 2.4900e+03 1.9100e+02 +6.7887e+07 2.4900e+03 1.8400e+02 +6.7849e+07 2.4900e+03 1.8400e+02 +6.7809e+07 2.4900e+03 1.8300e+02 +6.5629e+07 2.4800e+03 1.7900e+02 +6.3754e+07 2.4800e+03 1.7900e+02 +6.3374e+07 2.4800e+03 1.7900e+02 +6.2489e+07 2.4800e+03 1.7800e+02 +5.8468e+07 2.4700e+03 1.7700e+02 +5.7311e+07 2.4700e+03 1.7800e+02 +5.5886e+07 2.4700e+03 1.7800e+02 +5.5034e+07 2.4600e+03 1.7800e+02 +5.3925e+07 2.4600e+03 1.7700e+02 +5.3894e+07 2.4600e+03 1.7400e+02 +5.3862e+07 2.4500e+03 1.7100e+02 +5.2131e+07 2.4500e+03 1.7100e+02 +5.0642e+07 2.4500e+03 1.7100e+02 +5.0340e+07 2.4400e+03 1.7000e+02 +4.9636e+07 2.4400e+03 1.6700e+02 +4.6451e+07 2.4400e+03 1.6700e+02 +4.5523e+07 2.4300e+03 1.7000e+02 +4.3715e+07 2.4300e+03 1.7100e+02 +4.2834e+07 2.4300e+03 1.7100e+02 +4.2809e+07 2.4300e+03 1.7400e+02 +4.2784e+07 2.4300e+03 1.7500e+02 +4.1409e+07 2.4300e+03 1.7400e+02 +4.0219e+07 2.4200e+03 1.7100e+02 +3.9986e+07 2.4200e+03 1.7100e+02 +3.9428e+07 2.4200e+03 1.7100e+02 +3.6883e+07 2.4200e+03 1.7100e+02 +3.6160e+07 2.4200e+03 1.7000e+02 +3.4724e+07 2.4200e+03 1.7000e+02 +3.4024e+07 2.4200e+03 1.7000e+02 +3.4005e+07 2.4100e+03 1.7000e+02 +3.3985e+07 2.4000e+03 1.7100e+02 +3.2893e+07 2.4000e+03 1.7100e+02 +3.1952e+07 2.4000e+03 1.7100e+02 +3.1762e+07 2.4000e+03 1.7000e+02 +3.1319e+07 2.3900e+03 1.6900e+02 +3.1100e+07 2.3900e+03 1.6800e+02 +2.9303e+07 2.3900e+03 1.6600e+02 +2.8723e+07 2.3900e+03 1.6600e+02 +2.7582e+07 2.3800e+03 1.6800e+02 +2.7026e+07 2.3800e+03 1.6600e+02 +2.7013e+07 2.3800e+03 1.6800e+02 +2.6995e+07 2.3700e+03 1.6900e+02 +2.6127e+07 2.3700e+03 1.6900e+02 +2.5381e+07 2.3700e+03 1.6800e+02 +2.5230e+07 2.3600e+03 1.6800e+02 +2.4878e+07 2.3600e+03 1.6800e+02 +2.4700e+07 2.3600e+03 1.6600e+02 +2.3272e+07 2.3500e+03 1.6600e+02 +2.2815e+07 2.3500e+03 1.6100e+02 +2.1910e+07 2.3500e+03 1.6100e+02 +2.1468e+07 2.3400e+03 1.6100e+02 +2.1457e+07 2.3400e+03 1.6100e+02 +2.1443e+07 2.3400e+03 1.6100e+02 +2.0754e+07 2.3400e+03 1.6100e+02 +2.0165e+07 2.3400e+03 1.5700e+02 +2.0040e+07 2.3300e+03 1.5700e+02 +1.9761e+07 2.3300e+03 1.5700e+02 +1.9600e+07 2.3300e+03 1.5700e+02 +1.8122e+07 2.3300e+03 1.5700e+02 +1.7403e+07 2.3200e+03 1.5700e+02 +1.7053e+07 2.3200e+03 1.5700e+02 +1.7041e+07 2.3200e+03 1.5700e+02 +1.7033e+07 2.3200e+03 1.5700e+02 +1.6485e+07 2.3200e+03 1.5700e+02 +1.6011e+07 2.3200e+03 1.5700e+02 +1.5919e+07 2.3100e+03 1.5700e+02 +1.5696e+07 2.3100e+03 1.5700e+02 +1.5600e+07 2.3100e+03 1.5700e+02 +1.4396e+07 2.3100e+03 1.5800e+02 +1.3824e+07 2.3000e+03 1.5800e+02 +1.3545e+07 2.3000e+03 1.5800e+02 +1.3538e+07 2.3000e+03 1.5800e+02 +1.3530e+07 2.3000e+03 1.5800e+02 +1.3095e+07 2.3000e+03 1.5700e+02 +1.2721e+07 2.3000e+03 1.5500e+02 +1.2645e+07 2.3000e+03 1.5400e+02 +1.2468e+07 2.2900e+03 1.5500e+02 +1.2400e+07 2.2900e+03 1.5800e+02 +1.1436e+07 2.2900e+03 1.6100e+02 +1.0981e+07 2.2900e+03 1.6100e+02 +1.0760e+07 2.2900e+03 1.6200e+02 +1.0754e+07 2.2900e+03 1.6200e+02 +1.0747e+07 2.2800e+03 1.6200e+02 +1.0402e+07 2.2800e+03 1.6100e+02 +1.0102e+07 2.2800e+03 1.6100e+02 +1.0044e+07 2.2800e+03 1.6100e+02 +1.0000e+07 2.2800e+03 1.6100e+02 +9.9037e+06 2.2700e+03 1.6100e+02 +9.8200e+06 2.2700e+03 1.6200e+02 +9.0837e+06 2.2700e+03 1.6300e+02 +8.7223e+06 2.2600e+03 1.6400e+02 +8.5464e+06 2.2600e+03 1.6300e+02 +8.5438e+06 2.2600e+03 1.6200e+02 +8.5366e+06 2.2500e+03 1.6100e+02 +8.2622e+06 2.2500e+03 1.6100e+02 +7.9800e+06 2.2400e+03 1.6100e+02 +7.9783e+06 2.2400e+03 1.6100e+02 +7.8666e+06 2.2400e+03 1.6100e+02 +7.8000e+06 2.2400e+03 1.6100e+02 +7.2141e+06 2.2400e+03 1.6100e+02 +6.9284e+06 2.2400e+03 1.6100e+02 +6.7887e+06 2.2300e+03 1.6000e+02 +6.7841e+06 2.2300e+03 1.6000e+02 +6.7809e+06 2.2300e+03 1.5700e+02 +6.5629e+06 2.2300e+03 1.5700e+02 +6.3400e+06 2.2300e+03 1.5700e+02 +6.3374e+06 2.2300e+03 1.5700e+02 +6.2489e+06 2.2200e+03 1.5700e+02 +6.2000e+06 2.2200e+03 1.5700e+02 +5.7313e+06 2.2200e+03 1.5700e+02 +5.5034e+06 2.2200e+03 1.5700e+02 +5.3924e+06 2.2200e+03 1.5600e+02 +5.3898e+06 2.2200e+03 1.5100e+02 +5.3862e+06 2.2100e+03 1.4900e+02 +5.2131e+06 2.2100e+03 1.4900e+02 +5.0400e+06 2.2100e+03 1.4900e+02 +5.0340e+06 2.2100e+03 1.5100e+02 +4.9639e+06 2.2000e+03 1.5100e+02 +4.9200e+06 2.2000e+03 1.5100e+02 +4.5527e+06 2.2000e+03 1.4900e+02 +4.3715e+06 2.1900e+03 1.4800e+02 +4.2833e+06 2.1900e+03 1.4700e+02 +4.2804e+06 2.1800e+03 1.4700e+02 +4.2784e+06 2.1800e+03 1.4700e+02 +4.1409e+06 2.1800e+03 1.4700e+02 +4.0000e+06 2.1800e+03 1.4700e+02 +3.9986e+06 2.1800e+03 1.4700e+02 +3.9430e+06 2.1800e+03 1.4700e+02 +3.9100e+06 2.1800e+03 1.4700e+02 +3.6169e+06 2.1800e+03 1.4700e+02 +3.4724e+06 2.1700e+03 1.4700e+02 +3.4023e+06 2.1700e+03 1.4700e+02 +3.3985e+06 2.1700e+03 1.4700e+02 +3.2893e+06 2.1700e+03 1.4700e+02 +3.1800e+06 2.1600e+03 1.4700e+02 +3.1762e+06 2.1600e+03 1.4900e+02 +3.1315e+06 2.1600e+03 1.5000e+02 +3.1100e+06 2.1500e+03 1.5000e+02 +2.8719e+06 2.1400e+03 1.4900e+02 +2.7582e+06 2.1400e+03 1.4700e+02 +2.7026e+06 2.1400e+03 1.4700e+02 +2.6995e+06 2.1400e+03 1.4500e+02 +2.6128e+06 2.1400e+03 1.4300e+02 +2.5229e+06 2.1300e+03 1.4700e+02 +2.5200e+06 2.1300e+03 1.4700e+02 +2.4878e+06 2.1300e+03 1.4700e+02 +2.4700e+06 2.1300e+03 1.4700e+02 +2.2817e+06 2.1200e+03 1.4700e+02 +2.1909e+06 2.1200e+03 1.4700e+02 +2.1469e+06 2.1200e+03 1.4200e+02 +2.1443e+06 2.1200e+03 1.4100e+02 +2.0754e+06 2.1100e+03 1.4100e+02 +2.0100e+06 2.1100e+03 1.4000e+02 +2.0040e+06 2.1100e+03 1.4000e+02 +1.9762e+06 2.1100e+03 1.4000e+02 +1.9600e+06 2.1100e+03 1.4000e+02 +1.8121e+06 2.1100e+03 1.4000e+02 +1.7403e+06 2.1000e+03 1.3900e+02 +1.7054e+06 2.1000e+03 1.3600e+02 +1.7033e+06 2.1000e+03 1.3300e+02 +1.6485e+06 2.0900e+03 1.3300e+02 +1.5918e+06 2.0900e+03 1.3300e+02 +1.5900e+06 2.0800e+03 1.3300e+02 +1.5700e+06 2.0800e+03 1.3300e+02 +1.5600e+06 2.0800e+03 1.3300e+02 +1.3824e+06 2.0700e+03 1.3200e+02 +1.3544e+06 2.0700e+03 1.3200e+02 +1.3530e+06 2.0700e+03 1.3200e+02 +1.3095e+06 2.0700e+03 1.3200e+02 +1.2700e+06 2.0700e+03 1.3200e+02 +1.2645e+06 2.0600e+03 1.3200e+02 +1.2466e+06 2.0600e+03 1.3200e+02 +1.2400e+06 2.0600e+03 1.3200e+02 +1.0981e+06 2.0600e+03 1.3200e+02 +1.0760e+06 2.0600e+03 1.3200e+02 +1.0747e+06 2.0600e+03 1.3200e+02 +1.0401e+06 2.0600e+03 1.3200e+02 +1.0100e+06 2.0600e+03 1.3200e+02 +1.0045e+06 2.0600e+03 1.3100e+02 +1.0000e+06 2.0600e+03 1.3100e+02 +9.9000e+05 2.0500e+03 1.3100e+02 +9.8200e+05 2.0500e+03 1.3100e+02 +8.7200e+05 2.0500e+03 1.3100e+02 +8.5500e+05 2.0500e+03 1.2800e+02 +8.5400e+05 2.0500e+03 1.2800e+02 +8.2600e+05 2.0500e+03 1.2800e+02 +7.9800e+05 2.0400e+03 1.2800e+02 +7.8700e+05 2.0400e+03 1.2800e+02 +7.8000e+05 2.0400e+03 1.3200e+02 +6.9300e+05 2.0300e+03 1.3200e+02 +6.7900e+05 2.0300e+03 1.3200e+02 +6.7800e+05 2.0300e+03 1.2800e+02 +6.5600e+05 2.0200e+03 1.2800e+02 +6.3400e+05 2.0200e+03 1.2800e+02 +6.2000e+05 2.0100e+03 1.2800e+02 +5.5000e+05 2.0100e+03 1.2700e+02 +5.3900e+05 2.0100e+03 1.2700e+02 +5.2100e+05 2.0100e+03 1.2800e+02 +5.0400e+05 2.0100e+03 1.2800e+02 +5.0300e+05 2.0000e+03 1.2300e+02 +4.9200e+05 2.0000e+03 1.2300e+02 +4.3700e+05 2.0000e+03 1.1900e+02 +4.2800e+05 1.9900e+03 1.2300e+02 +4.1400e+05 1.9900e+03 1.2300e+02 +4.0000e+05 1.9800e+03 1.2300e+02 +3.9100e+05 1.9800e+03 1.2300e+02 +3.4700e+05 1.9800e+03 1.1900e+02 +3.4000e+05 1.9600e+03 1.1600e+02 +3.2900e+05 1.9600e+03 1.1300e+02 +3.1800e+05 1.9600e+03 1.1300e+02 +3.1100e+05 1.9600e+03 1.1600e+02 +2.7600e+05 1.9500e+03 1.1900e+02 +2.7000e+05 1.9500e+03 1.1900e+02 +2.6100e+05 1.9500e+03 1.1300e+02 +2.5200e+05 1.9400e+03 1.1300e+02 +2.4700e+05 1.9400e+03 1.1400e+02 +2.1900e+05 1.9400e+03 1.1400e+02 +2.1400e+05 1.9400e+03 1.1400e+02 +2.0800e+05 1.9300e+03 1.1400e+02 +2.0100e+05 1.9300e+03 1.1300e+02 +2.0000e+05 1.9300e+03 1.1300e+02 +1.9600e+05 1.9300e+03 1.1300e+02 +1.7400e+05 1.9300e+03 1.1300e+02 +1.7000e+05 1.9300e+03 1.1300e+02 +1.6500e+05 1.9300e+03 1.1300e+02 +1.5900e+05 1.9200e+03 1.1400e+02 +1.5600e+05 1.9200e+03 1.1400e+02 +1.5100e+05 1.9100e+03 1.1300e+02 +1.3800e+05 1.9100e+03 1.1300e+02 +1.3500e+05 1.9000e+03 1.1300e+02 +1.3100e+05 1.9000e+03 1.1300e+02 +1.2700e+05 1.9000e+03 1.1300e+02 +1.2400e+05 1.8900e+03 1.1300e+02 +1.2000e+05 1.8900e+03 1.1300e+02 +1.1000e+05 1.8900e+03 1.1100e+02 +1.0700e+05 1.8800e+03 1.1100e+02 +1.0400e+05 1.8800e+03 1.1100e+02 +1.0100e+05 1.8800e+03 1.1200e+02 +1.0000e+05 1.8800e+03 1.1200e+02 +9.8200e+04 1.8800e+03 1.1200e+02 +9.5100e+04 1.8700e+03 1.1200e+02 +8.7200e+04 1.8700e+03 1.1100e+02 +8.5400e+04 1.8700e+03 1.1100e+02 +8.2600e+04 1.8600e+03 1.0900e+02 +7.9800e+04 1.8600e+03 1.0900e+02 +7.8000e+04 1.8600e+03 1.0900e+02 +7.5500e+04 1.8600e+03 1.0900e+02 +6.9300e+04 1.8600e+03 1.0900e+02 +6.7800e+04 1.8500e+03 1.0900e+02 +6.5600e+04 1.8500e+03 1.0800e+02 +6.3400e+04 1.8500e+03 1.0800e+02 +6.2000e+04 1.8400e+03 1.0700e+02 +6.0000e+04 1.8400e+03 1.0600e+02 +5.5000e+04 1.8400e+03 1.0600e+02 +5.3900e+04 1.8400e+03 1.0600e+02 +5.0400e+04 1.8300e+03 1.0600e+02 +4.9200e+04 1.8300e+03 1.0600e+02 +4.7700e+04 1.8200e+03 1.0600e+02 +4.3700e+04 1.8200e+03 1.0600e+02 +4.2800e+04 1.8100e+03 1.0400e+02 +4.0000e+04 1.8100e+03 1.0400e+02 +3.9100e+04 1.8100e+03 1.0400e+02 +3.7900e+04 1.8000e+03 1.0400e+02 +3.4700e+04 1.8000e+03 1.0600e+02 +3.4000e+04 1.8000e+03 1.0600e+02 +3.1800e+04 1.8000e+03 1.0600e+02 +3.1100e+04 1.8000e+03 1.0700e+02 +3.0100e+04 1.7900e+03 1.0700e+02 +2.7600e+04 1.7900e+03 1.0700e+02 +2.7000e+04 1.7900e+03 1.0800e+02 +2.5200e+04 1.7900e+03 1.0900e+02 +2.4700e+04 1.7800e+03 1.0900e+02 +2.3900e+04 1.7800e+03 1.0900e+02 +2.1900e+04 1.7800e+03 1.1000e+02 +2.0100e+04 1.7700e+03 1.1200e+02 +2.0000e+04 1.7700e+03 1.1200e+02 +1.9600e+04 1.7700e+03 1.1200e+02 +1.9000e+04 1.7700e+03 1.1200e+02 +1.7400e+04 1.7700e+03 1.1200e+02 +1.6900e+04 1.7600e+03 1.1200e+02 +1.5900e+04 1.7600e+03 1.1400e+02 +1.5600e+04 1.7600e+03 1.1400e+02 +1.5100e+04 1.7600e+03 1.1400e+02 +1.3800e+04 1.7500e+03 1.1400e+02 +1.3400e+04 1.7400e+03 1.1400e+02 +1.2700e+04 1.7400e+03 1.1400e+02 +1.2400e+04 1.7300e+03 1.1400e+02 +1.2000e+04 1.7200e+03 1.1600e+02 +1.1000e+04 1.7100e+03 1.1600e+02 +1.0600e+04 1.7100e+03 1.1600e+02 +1.0100e+04 1.7100e+03 1.1600e+02 +1.0000e+04 1.7100e+03 1.1600e+02 +9.8200e+03 1.7100e+03 1.1600e+02 +9.5100e+03 1.7100e+03 1.1500e+02 +8.4600e+03 1.7100e+03 1.1400e+02 +7.9800e+03 1.7100e+03 1.1400e+02 +7.8000e+03 1.7000e+03 1.1400e+02 +7.5500e+03 1.6900e+03 1.1400e+02 +6.7200e+03 1.6900e+03 1.1400e+02 +6.3400e+03 1.6900e+03 1.1400e+02 +6.1900e+03 1.6800e+03 1.1400e+02 +6.0000e+03 1.6700e+03 1.1200e+02 +5.3400e+03 1.6700e+03 1.0900e+02 +5.0400e+03 1.6600e+03 1.0800e+02 +4.9200e+03 1.6600e+03 1.0800e+02 +4.7700e+03 1.6600e+03 1.0700e+02 +4.2400e+03 1.6600e+03 1.0700e+02 +4.0000e+03 1.6600e+03 1.0700e+02 +3.9100e+03 1.6600e+03 1.0700e+02 +3.7900e+03 1.6400e+03 1.0700e+02 +3.3700e+03 1.6400e+03 1.0700e+02 +3.1800e+03 1.6200e+03 1.0700e+02 +3.0100e+03 1.6200e+03 1.0700e+02 +2.6700e+03 1.6200e+03 1.0800e+02 +2.5200e+03 1.6200e+03 1.0800e+02 +2.3900e+03 1.6200e+03 1.0900e+02 +2.1200e+03 1.6200e+03 1.0900e+02 +2.0100e+03 1.6200e+03 1.0900e+02 +2.0000e+03 1.6100e+03 1.0900e+02 +1.9000e+03 1.6100e+03 1.0900e+02 +1.6900e+03 1.6100e+03 1.0900e+02 +1.5900e+03 1.6100e+03 1.0900e+02 +1.5100e+03 1.6100e+03 1.1900e+02 +1.4200e+03 1.6100e+03 1.1900e+02 +1.3400e+03 1.6000e+03 1.1900e+02 +1.2700e+03 1.5900e+03 1.1900e+02 +1.2600e+03 1.5900e+03 1.1900e+02 +1.2000e+03 1.5800e+03 1.1900e+02 +1.1300e+03 1.5700e+03 1.1900e+02 +1.0600e+03 1.5700e+03 1.1900e+02 +1.0100e+03 1.5700e+03 1.1900e+02 +9.5100e+02 1.5600e+03 1.1900e+02 +8.9800e+02 1.5600e+03 1.2000e+02 +8.4600e+02 1.5600e+03 1.2100e+02 +7.9900e+02 1.5500e+03 1.2200e+02 +7.9800e+02 1.5500e+03 1.2200e+02 +7.5500e+02 1.5500e+03 1.2300e+02 +7.1400e+02 1.5500e+03 1.2300e+02 +6.7200e+02 1.5400e+03 1.2300e+02 +6.3400e+02 1.5300e+03 1.2300e+02 +6.0000e+02 1.5300e+03 1.2300e+02 +5.6700e+02 1.5200e+03 1.2300e+02 +5.3400e+02 1.5200e+03 1.2300e+02 +5.0400e+02 1.5100e+03 1.2300e+02 +4.7700e+02 1.5100e+03 1.2300e+02 +4.5000e+02 1.5100e+03 1.2300e+02 +4.2400e+02 1.5000e+03 1.2300e+02 +4.0000e+02 1.5000e+03 1.2300e+02 +3.7900e+02 1.4800e+03 1.2300e+02 +3.5800e+02 1.4800e+03 1.2300e+02 +3.3700e+02 1.4700e+03 1.2300e+02 +3.1800e+02 1.4700e+03 1.2400e+02 +3.0100e+02 1.4600e+03 1.2500e+02 +2.8400e+02 1.4600e+03 1.2500e+02 +2.6700e+02 1.4600e+03 1.2500e+02 +2.5300e+02 1.4500e+03 1.2800e+02 +2.3900e+02 1.4500e+03 1.2800e+02 +2.2600e+02 1.4500e+03 1.2800e+02 +2.1200e+02 1.4400e+03 1.3000e+02 +2.0100e+02 1.4400e+03 1.3000e+02 +1.9000e+02 1.4300e+03 1.3100e+02 +1.7900e+02 1.4200e+03 1.3200e+02 +1.6900e+02 1.4100e+03 1.3200e+02 +1.5900e+02 1.4100e+03 1.3300e+02 +1.5100e+02 1.4000e+03 1.3300e+02 +1.4200e+02 1.3900e+03 1.3400e+02 +1.3400e+02 1.3900e+03 1.3400e+02 +1.2700e+02 1.3900e+03 1.3500e+02 +1.2000e+02 1.3900e+03 1.3900e+02 +1.1300e+02 1.3900e+03 1.3900e+02 +1.0600e+02 1.3800e+03 1.3900e+02 +1.0100e+02 1.3800e+03 1.4100e+02 +9.5100e+01 1.3700e+03 1.4300e+02 +8.9800e+01 1.3700e+03 1.4300e+02 +8.4600e+01 1.3700e+03 1.4500e+02 +8.3800e+01 1.3700e+03 1.4700e+02 +7.5500e+01 1.3600e+03 1.4800e+02 +7.1400e+01 1.3600e+03 1.4900e+02 +6.7200e+01 1.3600e+03 1.5100e+02 +6.6600e+01 1.3500e+03 1.5200e+02 +6.0000e+01 1.3500e+03 1.5200e+02 +5.6700e+01 1.3400e+03 1.5300e+02 +5.3400e+01 1.3400e+03 1.5300e+02 +5.2900e+01 1.3300e+03 1.5300e+02 +4.7700e+01 1.3200e+03 1.5400e+02 +4.5000e+01 1.3000e+03 1.5400e+02 +4.2400e+01 1.3000e+03 1.5400e+02 +4.2000e+01 1.3000e+03 1.5400e+02 +3.7900e+01 1.2900e+03 1.5400e+02 +3.5800e+01 1.2800e+03 1.5500e+02 +3.3700e+01 1.2800e+03 1.5500e+02 +3.3400e+01 1.2800e+03 1.5500e+02 +3.0100e+01 1.2700e+03 1.5700e+02 +2.8400e+01 1.2600e+03 1.5700e+02 +2.6700e+01 1.2500e+03 1.5700e+02 +2.6500e+01 1.2500e+03 1.5700e+02 +2.3900e+01 1.2400e+03 1.5800e+02 +2.2600e+01 1.2300e+03 1.5800e+02 +2.1200e+01 1.2200e+03 1.5900e+02 +2.1000e+01 1.2200e+03 1.5900e+02 +1.9000e+01 1.2100e+03 1.5900e+02 +1.7900e+01 1.1900e+03 1.5900e+02 +1.6900e+01 1.1900e+03 1.6100e+02 +1.6700e+01 1.1800e+03 1.6100e+02 +1.4200e+01 1.1700e+03 1.6200e+02 +1.3400e+01 1.1600e+03 1.6500e+02 +1.3300e+01 1.1500e+03 1.6500e+02 +1.1300e+01 1.1500e+03 1.6600e+02 +1.0700e+01 1.1400e+03 1.6600e+02 +1.0500e+01 1.1300e+03 1.6700e+02 +8.9800e+00 1.1200e+03 1.7100e+02 +8.4600e+00 1.1100e+03 1.7100e+02 +8.3800e+00 1.1000e+03 1.7300e+02 +7.1400e+00 1.0900e+03 1.7500e+02 +6.7200e+00 1.0800e+03 1.7500e+02 +6.6600e+00 1.0700e+03 1.7700e+02 +5.6700e+00 1.0700e+03 1.7700e+02 +5.3400e+00 1.0600e+03 1.8000e+02 +5.2900e+00 1.0400e+03 1.8000e+02 +4.5000e+00 1.0400e+03 1.8000e+02 +4.2400e+00 1.0300e+03 1.8100e+02 +4.2000e+00 1.0200e+03 1.8100e+02 +3.5800e+00 1.0100e+03 1.8500e+02 +3.3700e+00 1.0000e+03 1.8600e+02 +3.3400e+00 9.8600e+02 1.8700e+02 +2.8400e+00 9.8500e+02 1.9100e+02 +2.6800e+00 9.7300e+02 1.9100e+02 +2.6500e+00 9.4600e+02 1.9100e+02 +2.2600e+00 9.4000e+02 1.9200e+02 +2.1200e+00 9.2700e+02 1.9200e+02 +2.1000e+00 8.9700e+02 1.9300e+02 +1.9100e+00 8.9300e+02 1.9300e+02 +1.7900e+00 8.9000e+02 1.9300e+02 +1.6700e+00 8.6800e+02 1.9300e+02 +1.5100e+00 8.6400e+02 1.9400e+02 +1.4200e+00 8.6000e+02 1.9500e+02 +1.3300e+00 8.3800e+02 1.9500e+02 +1.2000e+00 8.3200e+02 1.9500e+02 +1.1300e+00 8.2800e+02 1.9500e+02 +1.0500e+00 8.0400e+02 1.9600e+02 +9.5600e-01 7.9700e+02 1.9700e+02 +8.9800e-01 7.9000e+02 1.9800e+02 +8.3800e-01 7.6500e+02 1.9800e+02 +7.5900e-01 7.6200e+02 1.9900e+02 +7.1400e-01 7.5600e+02 1.9800e+02 +6.6600e-01 7.2800e+02 1.9900e+02 +6.0300e-01 7.2300e+02 1.9900e+02 +5.6700e-01 7.2000e+02 1.9900e+02 +5.2900e-01 6.8800e+02 1.9900e+02 +4.7900e-01 6.8400e+02 1.9900e+02 +4.5000e-01 6.8300e+02 1.9900e+02 +4.2000e-01 6.5200e+02 1.9900e+02 +3.8000e-01 6.5200e+02 1.9900e+02 +3.5800e-01 6.2500e+02 1.9900e+02 +3.3400e-01 6.0900e+02 1.9900e+02 +3.0200e-01 6.0200e+02 1.9900e+02 +2.8400e-01 5.8800e+02 1.9800e+02 +2.7300e-01 5.7900e+02 1.9800e+02 +2.6500e-01 5.7300e+02 1.9800e+02 +2.4000e-01 5.6100e+02 1.9700e+02 +2.2600e-01 5.6000e+02 1.9500e+02 +2.1700e-01 5.4900e+02 1.9400e+02 +2.1000e-01 5.3800e+02 1.9400e+02 +1.9100e-01 5.2900e+02 1.9300e+02 +1.7900e-01 5.1700e+02 1.9300e+02 +1.7200e-01 4.9700e+02 1.9100e+02 +1.6700e-01 4.9300e+02 1.9000e+02 +1.5100e-01 4.7100e+02 1.8900e+02 +1.3700e-01 4.6700e+02 1.8800e+02 +1.3300e-01 4.5800e+02 1.8800e+02 +1.2000e-01 4.3600e+02 1.8600e+02 +1.0900e-01 4.2800e+02 1.8400e+02 +1.0500e-01 4.2100e+02 1.8300e+02 +9.5600e-02 3.9900e+02 1.8200e+02 +8.6400e-02 3.9000e+02 1.8200e+02 +8.3800e-02 3.8200e+02 1.8200e+02 +7.5900e-02 3.7200e+02 1.8000e+02 +6.8700e-02 3.5800e+02 1.8000e+02 +6.6600e-02 3.5400e+02 1.8000e+02 +6.4800e-02 3.4500e+02 1.7900e+02 +6.0300e-02 3.4200e+02 1.7700e+02 +5.4500e-02 3.2800e+02 1.7700e+02 +5.2900e-02 3.2000e+02 1.7500e+02 +5.1400e-02 3.1700e+02 1.7500e+02 +4.7900e-02 3.1100e+02 1.7400e+02 +4.3300e-02 2.9800e+02 1.7400e+02 +4.2000e-02 2.9200e+02 1.7100e+02 +4.0900e-02 2.8900e+02 1.7000e+02 +3.8000e-02 2.8400e+02 1.6800e+02 +3.4400e-02 2.7300e+02 1.6600e+02 +3.3400e-02 2.6700e+02 1.6500e+02 +3.2500e-02 2.6100e+02 1.6400e+02 +3.0200e-02 2.5700e+02 1.5900e+02 +2.7300e-02 2.4600e+02 1.5800e+02 +2.6500e-02 2.4000e+02 1.5700e+02 +2.5800e-02 2.3400e+02 1.5700e+02 +2.4000e-02 2.3000e+02 1.5700e+02 +2.1700e-02 2.2000e+02 1.5600e+02 +2.1100e-02 2.1500e+02 1.5300e+02 +2.0500e-02 2.0700e+02 1.5100e+02 +1.9100e-02 2.0100e+02 1.5000e+02 +1.8500e-02 1.9600e+02 1.4900e+02 +1.7200e-02 1.9400e+02 1.4800e+02 +1.6700e-02 1.9000e+02 1.4700e+02 +1.6300e-02 1.8400e+02 1.4500e+02 +1.5100e-02 1.7800e+02 1.4300e+02 +1.4700e-02 1.7400e+02 1.4200e+02 +1.3700e-02 1.7100e+02 1.4000e+02 +1.3300e-02 1.6600e+02 1.3900e+02 +1.2900e-02 1.6000e+02 1.3500e+02 +1.2000e-02 1.5700e+02 1.3300e+02 +1.1700e-02 1.5300e+02 1.3300e+02 +1.0900e-02 1.5200e+02 1.3200e+02 +1.0500e-02 1.5000e+02 1.3100e+02 +1.0300e-02 1.4800e+02 1.3000e+02 +9.5600e-03 1.4700e+02 1.2900e+02 +9.2800e-03 1.4500e+02 1.2700e+02 +8.6400e-03 1.3700e+02 1.2400e+02 +8.1500e-03 1.3300e+02 1.2400e+02 +7.5900e-03 1.2800e+02 1.2000e+02 +7.3700e-03 1.2400e+02 1.1800e+02 +6.8700e-03 1.1400e+02 1.1600e+02 +6.4800e-03 1.1100e+02 1.1400e+02 +6.0300e-03 1.0800e+02 1.1200e+02 +5.8500e-03 1.0400e+02 1.1100e+02 +5.7200e-03 1.0000e+02 1.0800e+02 +5.4500e-03 9.8700e+01 1.0600e+02 +5.1400e-03 9.5800e+01 1.0400e+02 +4.7900e-03 9.2900e+01 1.0200e+02 +4.6500e-03 8.9900e+01 1.0000e+02 +4.5400e-03 8.5900e+01 9.7400e+01 +4.3300e-03 8.3600e+01 9.6100e+01 +4.0900e-03 8.0900e+01 9.4200e+01 +3.8000e-03 7.8200e+01 9.2200e+01 +3.6900e-03 7.5100e+01 9.0300e+01 +3.6100e-03 7.2700e+01 8.8400e+01 +3.4400e-03 7.1600e+01 8.6700e+01 +3.2500e-03 6.9000e+01 8.5000e+01 +3.0200e-03 6.6200e+01 8.3100e+01 +2.9300e-03 6.3900e+01 8.1100e+01 +2.8700e-03 6.1800e+01 7.9100e+01 +2.7300e-03 5.9500e+01 7.7000e+01 +2.5800e-03 5.7600e+01 7.5400e+01 +2.4000e-03 5.5900e+01 7.3600e+01 +2.3300e-03 5.3600e+01 7.1900e+01 +2.2800e-03 5.1600e+01 7.0000e+01 +2.1700e-03 4.9600e+01 6.8000e+01 +2.0500e-03 4.5300e+01 6.5900e+01 +1.9100e-03 4.3900e+01 6.4300e+01 +1.8500e-03 4.3300e+01 6.4300e+01 +1.8100e-03 4.1900e+01 6.1900e+01 +1.8000e-03 4.0800e+01 6.0300e+01 +1.7200e-03 4.0400e+01 5.8700e+01 +1.6300e-03 3.8800e+01 5.6800e+01 +1.5100e-03 3.7600e+01 5.5400e+01 +1.4700e-03 3.6900e+01 5.5200e+01 +1.4400e-03 3.5300e+01 5.3100e+01 +1.4300e-03 3.4700e+01 5.2000e+01 +1.3700e-03 3.3600e+01 5.0300e+01 +1.2900e-03 3.2200e+01 4.9100e+01 +1.2000e-03 3.1300e+01 4.8200e+01 +1.1700e-03 3.1100e+01 4.8100e+01 +1.1400e-03 2.9300e+01 4.6400e+01 +1.1300e-03 2.8900e+01 4.6000e+01 +1.0900e-03 2.8000e+01 4.4100e+01 +1.0300e-03 2.7800e+01 4.2800e+01 +9.5600e-04 2.7000e+01 4.2100e+01 +9.2800e-04 2.7000e+01 4.2000e+01 +9.0600e-04 2.5000e+01 4.0000e+01 +9.0100e-04 2.2900e+01 3.7600e+01 +8.6400e-04 2.2000e+01 3.6400e+01 +8.1500e-04 2.1600e+01 3.5700e+01 +7.5900e-04 2.1000e+01 3.4800e+01 +7.4900e-04 2.0800e+01 3.4600e+01 +7.3700e-04 2.0600e+01 3.4200e+01 +7.2000e-04 2.0500e+01 3.4100e+01 +7.1500e-04 1.9100e+01 3.2000e+01 +6.8700e-04 1.8800e+01 3.1000e+01 +6.4800e-04 1.8300e+01 3.0400e+01 +6.0300e-04 1.8100e+01 2.9400e+01 +5.9500e-04 1.7700e+01 2.9400e+01 +5.8500e-04 1.7700e+01 2.9200e+01 +5.7200e-04 1.7500e+01 2.8900e+01 +5.6800e-04 1.6500e+01 2.8200e+01 +5.4500e-04 1.6200e+01 2.7100e+01 +5.1400e-04 1.5800e+01 2.5700e+01 +4.7900e-04 1.5500e+01 2.5300e+01 +4.7300e-04 1.5400e+01 2.5100e+01 +4.6500e-04 1.5300e+01 2.4800e+01 +4.5400e-04 1.4900e+01 2.4200e+01 +4.5100e-04 1.4600e+01 2.3400e+01 +4.3300e-04 1.4200e+01 2.2900e+01 +4.0900e-04 1.4000e+01 2.1900e+01 +3.8000e-04 1.3800e+01 2.1400e+01 +3.7500e-04 1.3800e+01 2.1400e+01 +3.6900e-04 1.3700e+01 2.1200e+01 +3.6100e-04 1.2600e+01 2.0400e+01 +3.5900e-04 1.2300e+01 1.9500e+01 +3.4400e-04 1.2300e+01 1.9000e+01 +3.2500e-04 1.1900e+01 1.8000e+01 +3.0200e-04 1.1800e+01 1.7800e+01 +2.9800e-04 1.1700e+01 1.7700e+01 +2.9300e-04 1.0700e+01 1.7600e+01 +2.8700e-04 1.0300e+01 1.7400e+01 +2.8500e-04 1.0200e+01 1.6900e+01 +2.7300e-04 1.0000e+01 1.6300e+01 +2.7000e-04 9.9700e+00 1.5700e+01 +2.5800e-04 9.9500e+00 1.5500e+01 +2.4000e-04 9.7700e+00 1.5400e+01 +2.3700e-04 9.5200e+00 1.5200e+01 +2.3300e-04 9.5200e+00 1.4400e+01 +2.2800e-04 9.4200e+00 1.4200e+01 +2.2600e-04 9.4100e+00 1.3000e+01 +2.1700e-04 9.2900e+00 1.2700e+01 +2.1500e-04 8.9800e+00 1.2600e+01 +2.0500e-04 8.9400e+00 1.2600e+01 +1.8800e-04 8.9100e+00 1.2500e+01 +1.8500e-04 8.6000e+00 1.2100e+01 +1.8100e-04 8.5500e+00 1.2000e+01 +1.8000e-04 8.5100e+00 1.1800e+01 +1.7200e-04 8.3200e+00 1.1500e+01 +1.7100e-04 8.3200e+00 1.1300e+01 +1.6300e-04 8.3000e+00 1.1200e+01 +1.4900e-04 8.1500e+00 1.0700e+01 +1.4700e-04 8.1100e+00 1.0600e+01 +1.4400e-04 8.0500e+00 1.0500e+01 +1.4300e-04 8.0300e+00 9.9900e+00 +1.3700e-04 8.0300e+00 9.4600e+00 +1.3500e-04 7.8000e+00 9.2900e+00 +1.2900e-04 7.6900e+00 9.1400e+00 +1.1900e-04 7.4800e+00 8.6600e+00 +1.1700e-04 7.3200e+00 8.5900e+00 +1.1400e-04 7.2700e+00 8.5400e+00 +1.1300e-04 7.1700e+00 8.3500e+00 +1.0900e-04 7.1000e+00 8.2800e+00 +1.0800e-04 7.0000e+00 8.2000e+00 +1.0300e-04 6.7500e+00 7.6100e+00 +9.4300e-05 6.5700e+00 7.1800e+00 +9.2800e-05 6.4400e+00 7.0700e+00 +9.0600e-05 6.3800e+00 7.0500e+00 +9.0100e-05 5.4900e+00 7.0300e+00 +8.6400e-05 5.4600e+00 6.9900e+00 +8.5500e-05 5.3500e+00 6.9800e+00 +8.1500e-05 5.3000e+00 6.9100e+00 +7.7400e-05 5.1900e+00 5.9300e+00 +7.4900e-05 5.1300e+00 5.5200e+00 +7.3700e-05 5.1000e+00 5.4900e+00 +7.2000e-05 5.1000e+00 5.3700e+00 +7.1500e-05 5.0500e+00 5.3600e+00 +6.8700e-05 5.0500e+00 5.3400e+00 +6.7900e-05 5.0300e+00 5.3400e+00 +6.4800e-05 5.0300e+00 5.3300e+00 +6.1500e-05 5.0200e+00 5.3200e+00 +5.9500e-05 5.0200e+00 5.3100e+00 +5.8500e-05 4.9800e+00 5.2200e+00 +5.7200e-05 4.9500e+00 5.1300e+00 +5.6800e-05 4.9500e+00 4.9200e+00 +5.4500e-05 4.9500e+00 4.8300e+00 +5.3900e-05 4.9500e+00 4.7700e+00 +5.1400e-05 4.9500e+00 4.7600e+00 +4.8900e-05 4.9500e+00 4.7600e+00 +4.7300e-05 4.9300e+00 4.7300e+00 +4.6500e-05 4.9300e+00 4.6500e+00 +4.5400e-05 4.9300e+00 4.6000e+00 +4.5100e-05 4.9300e+00 4.5600e+00 +4.3300e-05 4.9300e+00 4.5100e+00 +4.2800e-05 4.9200e+00 4.4400e+00 +4.0900e-05 4.8700e+00 4.3300e+00 +3.8800e-05 4.8600e+00 4.3300e+00 +3.7500e-05 4.8400e+00 4.2900e+00 +3.6900e-05 4.7800e+00 3.9500e+00 +3.6100e-05 4.7700e+00 3.8600e+00 +3.5900e-05 4.7700e+00 3.8500e+00 +3.4400e-05 4.7700e+00 3.6000e+00 +3.4000e-05 4.7500e+00 3.5300e+00 +3.2500e-05 4.7400e+00 3.4700e+00 +3.0800e-05 4.6900e+00 3.3800e+00 +2.9800e-05 4.6500e+00 3.1600e+00 +2.9300e-05 4.6100e+00 3.1500e+00 +2.8700e-05 4.5800e+00 3.1200e+00 +2.8500e-05 4.5100e+00 3.1100e+00 +2.7000e-05 4.5000e+00 3.0000e+00 +2.5800e-05 4.4800e+00 2.9200e+00 +2.4500e-05 4.4800e+00 2.9000e+00 +2.3700e-05 4.4600e+00 2.7600e+00 +2.3300e-05 4.4600e+00 2.6700e+00 +2.2800e-05 4.4200e+00 2.4500e+00 +2.2600e-05 4.4200e+00 2.3500e+00 +2.1500e-05 4.4200e+00 2.3400e+00 +2.0500e-05 4.4200e+00 2.3200e+00 +1.9500e-05 4.3500e+00 2.2900e+00 +1.8800e-05 4.2900e+00 2.2800e+00 +1.8500e-05 4.2800e+00 2.2500e+00 +1.8100e-05 4.1800e+00 2.2400e+00 +1.8000e-05 4.1800e+00 2.2400e+00 +1.7100e-05 4.1700e+00 2.1500e+00 +1.6300e-05 4.1400e+00 2.1400e+00 +1.5500e-05 4.0900e+00 2.1100e+00 +1.4900e-05 4.0800e+00 2.0600e+00 +1.4700e-05 4.0600e+00 2.0100e+00 +1.4400e-05 3.8400e+00 2.0000e+00 +1.4300e-05 3.7900e+00 2.0000e+00 +1.4200e-05 3.7800e+00 1.9900e+00 +1.3500e-05 3.7300e+00 1.9700e+00 +1.2900e-05 3.6800e+00 1.9700e+00 +1.2300e-05 3.6400e+00 1.9400e+00 +1.1900e-05 3.6400e+00 1.8100e+00 +1.1700e-05 3.6400e+00 1.8100e+00 +1.1400e-05 3.6400e+00 1.7700e+00 +1.1300e-05 3.6400e+00 1.7000e+00 +1.0800e-05 3.6400e+00 1.7000e+00 +1.0300e-05 3.6400e+00 1.7000e+00 +9.7500e-06 3.6400e+00 1.6800e+00 +9.4300e-06 3.6400e+00 1.6700e+00 +9.2800e-06 3.6500e+00 1.6400e+00 +9.0600e-06 3.6800e+00 1.6300e+00 +9.0100e-06 3.7300e+00 1.5700e+00 +8.9800e-06 3.7300e+00 1.5700e+00 +8.5500e-06 3.7300e+00 1.5500e+00 +8.1500e-06 3.7300e+00 1.5300e+00 +7.7400e-06 3.7000e+00 1.5100e+00 +7.4900e-06 3.7000e+00 1.5100e+00 +7.3700e-06 3.7300e+00 1.5100e+00 +7.2000e-06 3.7300e+00 1.5100e+00 +7.1500e-06 3.7300e+00 1.4500e+00 +7.1400e-06 3.7300e+00 1.4000e+00 +6.7900e-06 3.7000e+00 1.3800e+00 +6.1500e-06 3.7000e+00 1.3600e+00 +5.9500e-06 3.6500e+00 1.3000e+00 +5.8600e-06 3.7000e+00 1.3000e+00 +5.7200e-06 3.7300e+00 1.1900e+00 +5.6800e-06 3.7300e+00 1.1900e+00 +5.6700e-06 3.7300e+00 1.0700e+00 +5.3900e-06 3.7300e+00 1.0000e+00 +4.8900e-06 3.7300e+00 9.8400e-01 +4.7300e-06 3.7300e+00 9.7000e-01 +4.6500e-06 3.7300e+00 9.4900e-01 +4.5400e-06 3.7200e+00 9.4900e-01 +4.5100e-06 3.7000e+00 9.4900e-01 +4.5000e-06 3.6500e+00 9.4900e-01 +4.2800e-06 3.6500e+00 9.4900e-01 +3.8800e-06 3.5900e+00 9.4300e-01 +3.7500e-06 3.5900e+00 9.4000e-01 +3.6900e-06 3.5900e+00 9.2000e-01 +3.6100e-06 3.5800e+00 9.1000e-01 +3.5900e-06 3.5600e+00 9.0100e-01 +3.5800e-06 3.5500e+00 8.9900e-01 +3.4000e-06 3.5400e+00 8.8900e-01 +3.0800e-06 3.5400e+00 8.7200e-01 +2.9800e-06 3.5300e+00 8.7200e-01 +2.9300e-06 3.5300e+00 8.7200e-01 +2.8700e-06 3.5100e+00 8.3100e-01 +2.8500e-06 3.5000e+00 8.0300e-01 +2.8400e-06 3.4700e+00 7.9400e-01 +2.7000e-06 3.3300e+00 7.7400e-01 +2.4500e-06 3.3000e+00 7.7200e-01 +2.3700e-06 3.3000e+00 7.5900e-01 +2.3300e-06 3.2800e+00 7.5900e-01 +2.2800e-06 3.2500e+00 7.5000e-01 +2.2600e-06 3.2200e+00 6.7400e-01 +2.1500e-06 3.2100e+00 6.5900e-01 +1.9500e-06 3.2000e+00 6.4700e-01 +1.8800e-06 3.2000e+00 6.3600e-01 +1.8100e-06 3.1900e+00 6.1200e-01 +1.8000e-06 3.1700e+00 6.1200e-01 +1.7900e-06 3.1600e+00 6.1100e-01 +1.7100e-06 3.1500e+00 6.0800e-01 +1.5500e-06 3.1400e+00 5.9100e-01 +1.4900e-06 3.1200e+00 5.8700e-01 +1.4400e-06 3.1200e+00 5.7300e-01 +1.4300e-06 3.1200e+00 5.6900e-01 +1.4200e-06 3.1200e+00 5.6700e-01 +1.3500e-06 3.1200e+00 5.6400e-01 +1.2300e-06 3.1200e+00 5.6200e-01 +1.1900e-06 3.1000e+00 5.6200e-01 +1.1400e-06 3.0900e+00 5.6200e-01 +1.1300e-06 3.0400e+00 5.6200e-01 +1.0800e-06 3.0200e+00 5.6200e-01 +9.7500e-07 2.9800e+00 5.5400e-01 +9.4300e-07 2.9600e+00 5.4600e-01 +9.0600e-07 2.9500e+00 5.3800e-01 +9.0100e-07 2.9500e+00 4.9400e-01 +8.9800e-07 2.9500e+00 4.9400e-01 +8.5500e-07 2.9500e+00 4.9400e-01 +7.7400e-07 2.9500e+00 4.7200e-01 +7.4900e-07 2.9500e+00 4.5100e-01 +7.2000e-07 2.9500e+00 4.4600e-01 +7.1500e-07 2.9300e+00 4.4500e-01 +7.1400e-07 2.9300e+00 4.3600e-01 +6.7900e-07 2.9300e+00 4.3100e-01 +6.1500e-07 2.9300e+00 4.3100e-01 +5.9500e-07 2.9100e+00 4.2800e-01 +5.6800e-07 2.9100e+00 4.1800e-01 +5.6700e-07 2.9000e+00 4.1500e-01 +5.3900e-07 2.8800e+00 4.1400e-01 +4.8900e-07 2.8700e+00 4.0600e-01 +4.7300e-07 2.8400e+00 4.0200e-01 +4.5200e-07 2.8400e+00 4.0200e-01 +4.5000e-07 2.7900e+00 4.0500e-01 +4.2800e-07 2.6900e+00 4.0500e-01 +3.8800e-07 2.6900e+00 4.0200e-01 +3.7500e-07 2.6800e+00 3.8600e-01 +3.5900e-07 2.6600e+00 3.8400e-01 +3.5800e-07 2.6500e+00 3.6900e-01 +3.4000e-07 2.6500e+00 3.6600e-01 +3.0800e-07 2.6500e+00 3.6500e-01 +2.9800e-07 2.6500e+00 3.5800e-01 +2.8500e-07 2.6500e+00 3.5500e-01 +2.8400e-07 2.6500e+00 3.5400e-01 +2.7000e-07 2.6500e+00 3.5400e-01 +2.4500e-07 2.6400e+00 3.5200e-01 +2.3700e-07 2.6300e+00 3.5200e-01 +2.2600e-07 2.6200e+00 3.5200e-01 +2.1500e-07 2.6000e+00 3.5200e-01 +1.9500e-07 2.6000e+00 3.5200e-01 +1.8800e-07 2.5800e+00 3.5200e-01 +1.7900e-07 2.5400e+00 3.5200e-01 +1.7100e-07 2.5300e+00 3.5200e-01 +1.5500e-07 2.5200e+00 3.5200e-01 +1.4900e-07 2.5200e+00 3.5200e-01 +1.4200e-07 2.5100e+00 3.4900e-01 +1.3500e-07 2.5100e+00 3.4500e-01 +1.2300e-07 2.5100e+00 3.3800e-01 +1.1900e-07 2.5100e+00 3.3100e-01 +1.1300e-07 2.5000e+00 3.3800e-01 +1.0800e-07 2.4900e+00 3.4500e-01 +9.7500e-08 2.4900e+00 3.4900e-01 +9.4300e-08 2.4600e+00 3.4900e-01 +8.9800e-08 2.4600e+00 3.4900e-01 +8.5500e-08 2.4600e+00 3.4900e-01 +7.7400e-08 2.4400e+00 3.4900e-01 +7.1400e-08 2.4300e+00 3.4900e-01 +6.7900e-08 2.4300e+00 3.5700e-01 +6.1500e-08 2.4000e+00 3.5800e-01 +5.6700e-08 2.3900e+00 3.6200e-01 +5.3900e-08 2.3900e+00 3.6200e-01 +4.8900e-08 2.3800e+00 3.6500e-01 +4.5000e-08 2.3700e+00 3.7800e-01 +4.2800e-08 2.3200e+00 3.8500e-01 +3.8800e-08 2.3200e+00 3.8900e-01 +3.5800e-08 2.3200e+00 3.8900e-01 +3.4000e-08 2.3100e+00 3.8900e-01 +3.0800e-08 2.2300e+00 3.8900e-01 +2.8400e-08 2.2300e+00 3.8800e-01 +2.4500e-08 2.2000e+00 3.8500e-01 +2.2600e-08 2.1700e+00 3.6500e-01 +1.9500e-08 2.1700e+00 3.6200e-01 +1.7900e-08 2.1600e+00 3.6200e-01 +1.5500e-08 2.1600e+00 3.5800e-01 +1.4200e-08 2.1500e+00 3.5700e-01 +1.2300e-08 2.1400e+00 3.5500e-01 +1.1300e-08 2.1100e+00 3.5500e-01 +9.7500e-09 2.0800e+00 3.5500e-01 +8.9800e-09 2.0400e+00 3.5500e-01 +7.1300e-09 1.9800e+00 3.5500e-01 +5.6700e-09 1.9200e+00 3.4900e-01 +4.5000e-09 1.8500e+00 3.0800e-01 diff --git a/services/app/dynamfit/files/VeroCyan (5) Temperature Ramp clean.txt b/services/app/dynamfit/files/VeroCyan (5) Temperature Ramp clean.txt new file mode 100644 index 000000000..2b8311d59 --- /dev/null +++ b/services/app/dynamfit/files/VeroCyan (5) Temperature Ramp clean.txt @@ -0,0 +1,122 @@ +99.39 17373200 739272 +98.54 17486700 2850690 +97.69 18578400 3431510 +96.85 17593300 4965830 +96.01 18966600 5307510 +95.21 19774000 5135070 +94.38 19815400 5788570 +93.56 20522700 6631770 +92.76 21516000 7538390 +91.93 22541800 8302860 +91.03 23937100 9587510 +90.22 25273400 10648100 +89.42 26774800 11953800 +88.61 28505300 13304000 +87.81 30417100 14802400 +86.99 32669400 16407600 +86.17 35085300 18224300 +85.37 37830100 20178300 +84.47 41335100 22541100 +83.59 45214700 25192200 +82.76 49020300 27668300 +81.96 53293000 30555400 +81.16 58006900 33577900 +79.85 66896000 39150100 +79.05 72952900 42948600 +78.22 79869600 47118600 +77.44 87576700 51590300 +76.61 96118600 56556700 +75.81 105563000 61989300 +74.99 115835000 67717400 +74.19 127466000 74102800 +73.4 139801000 80829100 +72.58 154473000 88167900 +71.77 168449000 95855400 +70.95 186031000 104105000 +70.14 203415000 111796000 +69.35 223667000 121596000 +68.16 258478000 135942000 +67.3 282977000 145455000 +66.54 310783000 155197000 +65.71 339218000 166015000 +64.89 369850000 174306000 +64.11 403409000 186222000 +63.31 436971000 196519000 +62.47 475843000 206192000 +61.65 515364000 216769000 +60.86 560252000 222924000 +60.07 607535000 233320000 +59.18 652661000 238704000 +58.25 705791000 251013000 +57.46 759251000 255583000 +56.66 809577000 260421000 +55.85 861544000 265213000 +54.97 921807000 277550000 +54.08 977800000 278746000 +53.18 1043910000 277327000 +52.27 1098870000 284471000 +51.36 1164200000 285024000 +50.5 1223170000 284671000 +49.63 1284820000 279695000 +48.73 1345690000 282200000 +47.82 1412510000 280382000 +46.97 1465670000 287067000 +46.05 1535050000 287421000 +45.17 1571320000 279634000 +44.27 1644110000 275548000 +43.37 1708140000 282439000 +42.47 1755310000 279234000 +41.55 1818450000 263492000 +40.7 1857560000 255888000 +39.79 1916280000 275227000 +38.89 1954830000 264463000 +38.05 2040810000 263998000 +37.03 2082210000 250211000 +36.04 2113410000 259504000 +35.07 2196600000 252084000 +34.08 2232260000 248053000 +33.31 2275230000 233700000 +32.49 2340180000 246935000 +31.72 2365130000 238881000 +31.03 2386210000 232490000 +30.51 2448390000 221985000 +29.99 2462550000 223057000 +29.61 2478550000 209468000 +29.21 2503490000 217599000 +28.84 2536350000 219456000 +28.87 2566060000 203077000 +28.95 2555990000 190777000 +28.74 2577600000 196052000 +28.12 2564220000 211892000 +26.25 2552640000 223612000 +23.75 2611380000 197417000 +19.1 2682600000 211974000 +21.35 2798330000 185963000 +18.14 2762460000 183246000 +15.81 2835160000 196595000 +14.37 2947340000 189234000 +13.39 2968430000 189354000 +12.32 2976060000 158100000 +11.23 3037830000 224983000 +10.14 3057080000 153261000 +9.07 3109320000 197579000 +8.06 3128350000 197880000 +7.11 3143950000 159304000 +5.73 3034410000 157508000 +4.76 3156180000 174622000 +3.77 3117350000 138721000 +2.86 3196350000 166804000 +1.89 3178170000 148718000 +0.99 3186980000 148468000 +0.17 3220050000 151047000 +-1.14 3221140000 131478000 +-2.22 3262400000 155557000 +-3.22 3288540000 169768000 +-4.21 3280820000 150840000 +-5.09 3345420000 154172000 +-6.15 3330190000 148443000 +-7.09 3337350000 91253900 +-8.03 3367380000 153336000 +-9.06 3371770000 111710000 +-9.99 3321200000 156758000 +-9.99 3394040000 120512000 diff --git a/services/app/dynamfit/files/VeroCyan (5) master curve 80C clean.txt b/services/app/dynamfit/files/VeroCyan (5) master curve 80C clean.txt new file mode 100644 index 000000000..c86bba0b2 --- /dev/null +++ b/services/app/dynamfit/files/VeroCyan (5) master curve 80C clean.txt @@ -0,0 +1,500 @@ +5.07E+13 3.44E+09 2.83E+07 +4.69E+13 3.44E+09 2.95E+07 +4.33E+13 3.44E+09 3.09E+07 +4.01E+13 3.44E+09 3.22E+07 +3.71E+13 3.44E+09 3.35E+07 +3.43E+13 3.43E+09 3.49E+07 +3.17E+13 3.43E+09 3.63E+07 +2.93E+13 3.43E+09 3.78E+07 +2.71E+13 3.43E+09 3.92E+07 +2.51E+13 3.43E+09 4.07E+07 +2.32E+13 3.43E+09 4.22E+07 +2.14E+13 3.42E+09 4.38E+07 +1.98E+13 3.42E+09 4.53E+07 +1.83E+13 3.42E+09 4.69E+07 +1.70E+13 3.42E+09 4.85E+07 +1.57E+13 3.41E+09 5.01E+07 +1.45E+13 3.41E+09 5.17E+07 +1.34E+13 3.41E+09 5.33E+07 +1.24E+13 3.41E+09 5.49E+07 +1.15E+13 3.40E+09 5.65E+07 +1.06E+13 3.40E+09 5.81E+07 +9.81E+12 3.40E+09 5.97E+07 +9.07E+12 3.40E+09 6.12E+07 +8.39E+12 3.39E+09 6.28E+07 +7.76E+12 3.39E+09 6.45E+07 +7.18E+12 3.39E+09 6.61E+07 +6.64E+12 3.38E+09 6.77E+07 +6.14E+12 3.38E+09 6.94E+07 +5.68E+12 3.38E+09 7.11E+07 +5.25E+12 3.37E+09 7.28E+07 +4.86E+12 3.37E+09 7.45E+07 +4.49E+12 3.37E+09 7.63E+07 +4.15E+12 3.36E+09 7.80E+07 +3.84E+12 3.36E+09 7.97E+07 +3.55E+12 3.35E+09 8.15E+07 +3.28E+12 3.35E+09 8.32E+07 +3.04E+12 3.35E+09 8.49E+07 +2.81E+12 3.34E+09 8.66E+07 +2.60E+12 3.34E+09 8.83E+07 +2.40E+12 3.33E+09 9.01E+07 +2.22E+12 3.33E+09 9.18E+07 +2.05E+12 3.32E+09 9.36E+07 +1.90E+12 3.32E+09 9.54E+07 +1.76E+12 3.31E+09 9.72E+07 +1.63E+12 3.31E+09 9.90E+07 +1.50E+12 3.30E+09 1.01E+08 +1.39E+12 3.30E+09 1.03E+08 +1.29E+12 3.29E+09 1.04E+08 +1.19E+12 3.29E+09 1.06E+08 +1.10E+12 3.28E+09 1.08E+08 +1.02E+12 3.28E+09 1.10E+08 +9.40E+11 3.27E+09 1.11E+08 +8.69E+11 3.26E+09 1.13E+08 +8.04E+11 3.26E+09 1.14E+08 +7.44E+11 3.25E+09 1.16E+08 +6.88E+11 3.25E+09 1.17E+08 +6.36E+11 3.24E+09 1.19E+08 +5.88E+11 3.23E+09 1.20E+08 +5.44E+11 3.23E+09 1.21E+08 +5.03E+11 3.22E+09 1.23E+08 +4.65E+11 3.21E+09 1.24E+08 +4.30E+11 3.21E+09 1.25E+08 +3.98E+11 3.20E+09 1.26E+08 +3.68E+11 3.19E+09 1.27E+08 +3.40E+11 3.19E+09 1.27E+08 +3.15E+11 3.18E+09 1.28E+08 +2.91E+11 3.17E+09 1.28E+08 +2.69E+11 3.16E+09 1.28E+08 +2.49E+11 3.16E+09 1.28E+08 +2.30E+11 3.15E+09 1.28E+08 +2.13E+11 3.14E+09 1.28E+08 +1.97E+11 3.14E+09 1.28E+08 +1.82E+11 3.13E+09 1.27E+08 +1.68E+11 3.12E+09 1.27E+08 +1.56E+11 3.12E+09 1.26E+08 +1.44E+11 3.11E+09 1.25E+08 +1.33E+11 3.10E+09 1.24E+08 +1.23E+11 3.09E+09 1.24E+08 +1.14E+11 3.09E+09 1.22E+08 +1.05E+11 3.08E+09 1.21E+08 +9.74E+10 3.07E+09 1.20E+08 +9.01E+10 3.07E+09 1.19E+08 +8.33E+10 3.06E+09 1.17E+08 +7.70E+10 3.06E+09 1.16E+08 +7.13E+10 3.05E+09 1.14E+08 +6.59E+10 3.04E+09 1.13E+08 +6.09E+10 3.04E+09 1.11E+08 +5.64E+10 3.03E+09 1.10E+08 +5.21E+10 3.03E+09 1.08E+08 +4.82E+10 3.02E+09 1.06E+08 +4.46E+10 3.02E+09 1.05E+08 +4.12E+10 3.01E+09 1.04E+08 +3.81E+10 3.01E+09 1.02E+08 +3.53E+10 3.00E+09 1.01E+08 +3.26E+10 3.00E+09 1.00E+08 +3.02E+10 3.00E+09 9.92E+07 +2.79E+10 2.99E+09 9.85E+07 +2.58E+10 2.99E+09 9.79E+07 +2.38E+10 2.98E+09 9.74E+07 +2.21E+10 2.98E+09 9.71E+07 +2.04E+10 2.98E+09 9.69E+07 +1.89E+10 2.97E+09 9.69E+07 +1.74E+10 2.97E+09 9.70E+07 +1.61E+10 2.96E+09 9.73E+07 +1.49E+10 2.96E+09 9.77E+07 +1.38E+10 2.96E+09 9.83E+07 +1.28E+10 2.95E+09 9.90E+07 +1.18E+10 2.95E+09 9.98E+07 +1.09E+10 2.94E+09 1.01E+08 +1.01E+10 2.94E+09 1.02E+08 +9.33E+09 2.93E+09 1.03E+08 +8.63E+09 2.93E+09 1.04E+08 +7.98E+09 2.92E+09 1.05E+08 +7.38E+09 2.92E+09 1.07E+08 +6.83E+09 2.92E+09 1.08E+08 +6.31E+09 2.91E+09 1.10E+08 +5.84E+09 2.91E+09 1.11E+08 +5.40E+09 2.90E+09 1.13E+08 +4.99E+09 2.89E+09 1.15E+08 +4.62E+09 2.89E+09 1.16E+08 +4.27E+09 2.88E+09 1.18E+08 +3.95E+09 2.88E+09 1.20E+08 +3.65E+09 2.87E+09 1.22E+08 +3.38E+09 2.87E+09 1.24E+08 +3.12E+09 2.86E+09 1.26E+08 +2.89E+09 2.85E+09 1.28E+08 +2.67E+09 2.85E+09 1.29E+08 +2.47E+09 2.84E+09 1.31E+08 +2.29E+09 2.83E+09 1.33E+08 +2.11E+09 2.83E+09 1.35E+08 +1.95E+09 2.82E+09 1.36E+08 +1.81E+09 2.81E+09 1.38E+08 +1.67E+09 2.81E+09 1.40E+08 +1.55E+09 2.80E+09 1.41E+08 +1.43E+09 2.79E+09 1.43E+08 +1.32E+09 2.78E+09 1.44E+08 +1.22E+09 2.78E+09 1.46E+08 +1.13E+09 2.77E+09 1.48E+08 +1.05E+09 2.76E+09 1.49E+08 +9.67E+08 2.75E+09 1.51E+08 +8.94E+08 2.75E+09 1.52E+08 +8.27E+08 2.74E+09 1.54E+08 +7.65E+08 2.73E+09 1.55E+08 +7.07E+08 2.72E+09 1.56E+08 +6.54E+08 2.71E+09 1.57E+08 +6.05E+08 2.71E+09 1.58E+08 +5.59E+08 2.70E+09 1.59E+08 +5.17E+08 2.69E+09 1.61E+08 +4.78E+08 2.68E+09 1.61E+08 +4.42E+08 2.67E+09 1.62E+08 +4.09E+08 2.67E+09 1.63E+08 +3.78E+08 2.66E+09 1.64E+08 +3.50E+08 2.65E+09 1.65E+08 +3.24E+08 2.64E+09 1.66E+08 +2.99E+08 2.63E+09 1.67E+08 +2.77E+08 2.62E+09 1.68E+08 +2.56E+08 2.61E+09 1.69E+08 +2.37E+08 2.61E+09 1.70E+08 +2.19E+08 2.60E+09 1.71E+08 +2.02E+08 2.59E+09 1.72E+08 +1.87E+08 2.58E+09 1.72E+08 +1.73E+08 2.57E+09 1.73E+08 +1.60E+08 2.56E+09 1.74E+08 +1.48E+08 2.55E+09 1.74E+08 +1.37E+08 2.55E+09 1.75E+08 +1.27E+08 2.54E+09 1.76E+08 +1.17E+08 2.53E+09 1.76E+08 +1.08E+08 2.52E+09 1.77E+08 +1.00E+08 2.51E+09 1.78E+08 +9.27E+07 2.50E+09 1.79E+08 +8.57E+07 2.49E+09 1.80E+08 +7.92E+07 2.48E+09 1.81E+08 +7.33E+07 2.48E+09 1.82E+08 +6.78E+07 2.47E+09 1.83E+08 +6.27E+07 2.46E+09 1.83E+08 +5.80E+07 2.45E+09 1.84E+08 +5.36E+07 2.44E+09 1.85E+08 +4.96E+07 2.43E+09 1.86E+08 +4.58E+07 2.42E+09 1.87E+08 +4.24E+07 2.41E+09 1.88E+08 +3.92E+07 2.40E+09 1.89E+08 +3.63E+07 2.39E+09 1.90E+08 +3.35E+07 2.38E+09 1.91E+08 +3.10E+07 2.37E+09 1.92E+08 +2.87E+07 2.37E+09 1.94E+08 +2.65E+07 2.36E+09 1.95E+08 +2.45E+07 2.35E+09 1.96E+08 +2.27E+07 2.34E+09 1.98E+08 +2.10E+07 2.33E+09 1.99E+08 +1.94E+07 2.32E+09 2.00E+08 +1.79E+07 2.31E+09 2.01E+08 +1.66E+07 2.30E+09 2.03E+08 +1.53E+07 2.29E+09 2.04E+08 +1.42E+07 2.28E+09 2.05E+08 +1.31E+07 2.27E+09 2.06E+08 +1.21E+07 2.26E+09 2.08E+08 +1.12E+07 2.25E+09 2.09E+08 +1.04E+07 2.24E+09 2.10E+08 +9.60E+06 2.23E+09 2.12E+08 +8.88E+06 2.21E+09 2.13E+08 +8.21E+06 2.20E+09 2.15E+08 +7.59E+06 2.19E+09 2.16E+08 +7.02E+06 2.18E+09 2.17E+08 +6.49E+06 2.17E+09 2.19E+08 +6.01E+06 2.16E+09 2.20E+08 +5.55E+06 2.15E+09 2.21E+08 +5.14E+06 2.14E+09 2.23E+08 +4.75E+06 2.13E+09 2.24E+08 +4.39E+06 2.12E+09 2.25E+08 +4.06E+06 2.10E+09 2.26E+08 +3.76E+06 2.09E+09 2.27E+08 +3.47E+06 2.08E+09 2.28E+08 +3.21E+06 2.07E+09 2.29E+08 +2.97E+06 2.06E+09 2.30E+08 +2.75E+06 2.05E+09 2.31E+08 +2.54E+06 2.03E+09 2.32E+08 +2.35E+06 2.02E+09 2.33E+08 +2.17E+06 2.01E+09 2.34E+08 +2.01E+06 2.00E+09 2.35E+08 +1.86E+06 1.99E+09 2.36E+08 +1.72E+06 1.98E+09 2.37E+08 +1.59E+06 1.96E+09 2.38E+08 +1.47E+06 1.95E+09 2.39E+08 +1.36E+06 1.94E+09 2.39E+08 +1.26E+06 1.93E+09 2.40E+08 +1.16E+06 1.91E+09 2.41E+08 +1.08E+06 1.90E+09 2.41E+08 +9.95E+05 1.89E+09 2.42E+08 +9.20E+05 1.88E+09 2.43E+08 +8.51E+05 1.87E+09 2.43E+08 +7.87E+05 1.85E+09 2.44E+08 +7.28E+05 1.84E+09 2.45E+08 +6.73E+05 1.83E+09 2.45E+08 +6.22E+05 1.82E+09 2.46E+08 +5.75E+05 1.80E+09 2.47E+08 +5.32E+05 1.79E+09 2.47E+08 +4.92E+05 1.78E+09 2.48E+08 +4.55E+05 1.77E+09 2.48E+08 +4.21E+05 1.75E+09 2.49E+08 +3.89E+05 1.74E+09 2.49E+08 +3.60E+05 1.73E+09 2.49E+08 +3.33E+05 1.72E+09 2.50E+08 +3.08E+05 1.71E+09 2.50E+08 +2.85E+05 1.69E+09 2.51E+08 +2.63E+05 1.68E+09 2.51E+08 +2.44E+05 1.67E+09 2.52E+08 +2.25E+05 1.66E+09 2.52E+08 +2.08E+05 1.64E+09 2.53E+08 +1.93E+05 1.63E+09 2.54E+08 +1.78E+05 1.62E+09 2.54E+08 +1.65E+05 1.60E+09 2.55E+08 +1.52E+05 1.59E+09 2.55E+08 +1.41E+05 1.58E+09 2.56E+08 +1.30E+05 1.57E+09 2.56E+08 +1.20E+05 1.55E+09 2.57E+08 +1.11E+05 1.54E+09 2.57E+08 +1.03E+05 1.53E+09 2.58E+08 +9.53E+04 1.52E+09 2.58E+08 +8.81E+04 1.50E+09 2.58E+08 +8.15E+04 1.49E+09 2.59E+08 +7.54E+04 1.48E+09 2.60E+08 +6.97E+04 1.46E+09 2.60E+08 +6.45E+04 1.45E+09 2.61E+08 +5.96E+04 1.44E+09 2.61E+08 +5.51E+04 1.42E+09 2.62E+08 +5.10E+04 1.41E+09 2.62E+08 +4.72E+04 1.40E+09 2.63E+08 +4.36E+04 1.38E+09 2.63E+08 +4.03E+04 1.37E+09 2.64E+08 +3.73E+04 1.36E+09 2.64E+08 +3.45E+04 1.34E+09 2.64E+08 +3.19E+04 1.33E+09 2.64E+08 +2.95E+04 1.32E+09 2.64E+08 +2.73E+04 1.30E+09 2.65E+08 +2.52E+04 1.29E+09 2.65E+08 +2.33E+04 1.28E+09 2.65E+08 +2.16E+04 1.26E+09 2.65E+08 +2.00E+04 1.25E+09 2.65E+08 +1.85E+04 1.24E+09 2.66E+08 +1.71E+04 1.22E+09 2.66E+08 +1.58E+04 1.21E+09 2.66E+08 +1.46E+04 1.20E+09 2.66E+08 +1.35E+04 1.18E+09 2.66E+08 +1.25E+04 1.17E+09 2.66E+08 +1.15E+04 1.16E+09 2.66E+08 +1.07E+04 1.14E+09 2.65E+08 +9.87E+03 1.13E+09 2.65E+08 +9.13E+03 1.12E+09 2.65E+08 +8.44E+03 1.10E+09 2.64E+08 +7.81E+03 1.09E+09 2.64E+08 +7.22E+03 1.08E+09 2.64E+08 +6.68E+03 1.06E+09 2.63E+08 +6.18E+03 1.05E+09 2.63E+08 +5.71E+03 1.04E+09 2.63E+08 +5.28E+03 1.02E+09 2.62E+08 +4.89E+03 1.01E+09 2.62E+08 +4.52E+03 9.97E+08 2.61E+08 +4.18E+03 9.83E+08 2.61E+08 +3.86E+03 9.70E+08 2.60E+08 +3.57E+03 9.57E+08 2.59E+08 +3.30E+03 9.44E+08 2.59E+08 +3.06E+03 9.30E+08 2.58E+08 +2.83E+03 9.17E+08 2.57E+08 +2.61E+03 9.04E+08 2.56E+08 +2.42E+03 8.91E+08 2.55E+08 +2.24E+03 8.79E+08 2.54E+08 +2.07E+03 8.66E+08 2.53E+08 +1.91E+03 8.53E+08 2.52E+08 +1.77E+03 8.40E+08 2.51E+08 +1.64E+03 8.28E+08 2.50E+08 +1.51E+03 8.15E+08 2.49E+08 +1.40E+03 8.02E+08 2.48E+08 +1.29E+03 7.90E+08 2.47E+08 +1.20E+03 7.77E+08 2.45E+08 +1.11E+03 7.64E+08 2.44E+08 +1.02E+03 7.52E+08 2.43E+08 +9.46E+02 7.40E+08 2.41E+08 +8.75E+02 7.27E+08 2.40E+08 +8.09E+02 7.15E+08 2.38E+08 +7.48E+02 7.03E+08 2.36E+08 +6.92E+02 6.91E+08 2.35E+08 +6.40E+02 6.79E+08 2.33E+08 +5.92E+02 6.68E+08 2.31E+08 +5.47E+02 6.56E+08 2.30E+08 +5.06E+02 6.44E+08 2.28E+08 +4.68E+02 6.33E+08 2.27E+08 +4.33E+02 6.21E+08 2.25E+08 +4.00E+02 6.10E+08 2.23E+08 +3.70E+02 5.99E+08 2.21E+08 +3.42E+02 5.87E+08 2.19E+08 +3.17E+02 5.76E+08 2.17E+08 +2.93E+02 5.65E+08 2.15E+08 +2.71E+02 5.54E+08 2.13E+08 +2.50E+02 5.43E+08 2.11E+08 +2.32E+02 5.33E+08 2.09E+08 +2.14E+02 5.22E+08 2.07E+08 +1.98E+02 5.12E+08 2.05E+08 +1.83E+02 5.02E+08 2.02E+08 +1.69E+02 4.91E+08 2.00E+08 +1.57E+02 4.81E+08 1.98E+08 +1.45E+02 4.71E+08 1.96E+08 +1.34E+02 4.62E+08 1.94E+08 +1.24E+02 4.52E+08 1.91E+08 +1.15E+02 4.42E+08 1.89E+08 +1.06E+02 4.33E+08 1.87E+08 +9.80E+01 4.23E+08 1.84E+08 +9.06E+01 4.14E+08 1.82E+08 +8.38E+01 4.05E+08 1.79E+08 +7.75E+01 3.96E+08 1.77E+08 +7.17E+01 3.87E+08 1.74E+08 +6.63E+01 3.78E+08 1.72E+08 +6.13E+01 3.70E+08 1.69E+08 +5.67E+01 3.61E+08 1.67E+08 +5.24E+01 3.53E+08 1.64E+08 +4.85E+01 3.45E+08 1.62E+08 +4.49E+01 3.37E+08 1.59E+08 +4.15E+01 3.29E+08 1.57E+08 +3.84E+01 3.21E+08 1.54E+08 +3.55E+01 3.14E+08 1.52E+08 +3.28E+01 3.06E+08 1.49E+08 +3.03E+01 2.99E+08 1.47E+08 +2.81E+01 2.92E+08 1.44E+08 +2.59E+01 2.85E+08 1.42E+08 +2.40E+01 2.78E+08 1.39E+08 +2.22E+01 2.71E+08 1.37E+08 +2.05E+01 2.64E+08 1.34E+08 +1.90E+01 2.58E+08 1.32E+08 +1.76E+01 2.51E+08 1.29E+08 +1.62E+01 2.45E+08 1.27E+08 +1.50E+01 2.39E+08 1.24E+08 +1.39E+01 2.33E+08 1.22E+08 +1.28E+01 2.27E+08 1.20E+08 +1.19E+01 2.21E+08 1.17E+08 +1.10E+01 2.16E+08 1.15E+08 +1.02E+01 2.10E+08 1.13E+08 +9.39E+00 2.05E+08 1.11E+08 +8.69E+00 1.99E+08 1.08E+08 +8.03E+00 1.94E+08 1.06E+08 +7.43E+00 1.89E+08 1.04E+08 +6.87E+00 1.84E+08 1.02E+08 +6.35E+00 1.79E+08 9.94E+07 +5.88E+00 1.74E+08 9.72E+07 +5.43E+00 1.70E+08 9.51E+07 +5.03E+00 1.65E+08 9.30E+07 +4.65E+00 1.61E+08 9.09E+07 +4.30E+00 1.57E+08 8.89E+07 +3.97E+00 1.53E+08 8.69E+07 +3.68E+00 1.49E+08 8.50E+07 +3.40E+00 1.45E+08 8.31E+07 +3.14E+00 1.41E+08 8.12E+07 +2.91E+00 1.37E+08 7.94E+07 +2.69E+00 1.33E+08 7.75E+07 +2.49E+00 1.29E+08 7.57E+07 +2.30E+00 1.26E+08 7.39E+07 +2.13E+00 1.22E+08 7.21E+07 +1.97E+00 1.19E+08 7.04E+07 +1.82E+00 1.16E+08 6.86E+07 +1.68E+00 1.13E+08 6.70E+07 +1.56E+00 1.10E+08 6.53E+07 +1.44E+00 1.07E+08 6.37E+07 +1.33E+00 1.04E+08 6.21E+07 +1.23E+00 1.01E+08 6.06E+07 +1.14E+00 9.81E+07 5.91E+07 +1.05E+00 9.53E+07 5.76E+07 +9.73E-01 9.27E+07 5.62E+07 +9.00E-01 9.01E+07 5.48E+07 +8.32E-01 8.76E+07 5.34E+07 +7.70E-01 8.51E+07 5.20E+07 +7.12E-01 8.27E+07 5.07E+07 +6.58E-01 8.04E+07 4.93E+07 +6.09E-01 7.82E+07 4.80E+07 +5.63E-01 7.60E+07 4.67E+07 +5.21E-01 7.39E+07 4.55E+07 +4.81E-01 7.18E+07 4.42E+07 +4.45E-01 6.98E+07 4.30E+07 +4.12E-01 6.79E+07 4.18E+07 +3.81E-01 6.60E+07 4.07E+07 +3.52E-01 6.42E+07 3.96E+07 +3.26E-01 6.24E+07 3.85E+07 +3.01E-01 6.07E+07 3.75E+07 +2.79E-01 5.90E+07 3.64E+07 +2.58E-01 5.73E+07 3.54E+07 +2.38E-01 5.57E+07 3.44E+07 +2.20E-01 5.42E+07 3.34E+07 +2.04E-01 5.26E+07 3.25E+07 +1.88E-01 5.12E+07 3.15E+07 +1.74E-01 4.98E+07 3.06E+07 +1.61E-01 4.84E+07 2.97E+07 +1.49E-01 4.71E+07 2.88E+07 +1.38E-01 4.58E+07 2.79E+07 +1.27E-01 4.46E+07 2.70E+07 +1.18E-01 4.34E+07 2.62E+07 +1.09E-01 4.23E+07 2.54E+07 +1.01E-01 4.11E+07 2.46E+07 +9.32E-02 4.01E+07 2.39E+07 +8.62E-02 3.90E+07 2.31E+07 +7.97E-02 3.80E+07 2.24E+07 +7.37E-02 3.70E+07 2.17E+07 +6.82E-02 3.60E+07 2.10E+07 +6.31E-02 3.51E+07 2.03E+07 +5.83E-02 3.42E+07 1.96E+07 +5.39E-02 3.34E+07 1.90E+07 +4.99E-02 3.25E+07 1.83E+07 +4.61E-02 3.17E+07 1.77E+07 +4.27E-02 3.10E+07 1.71E+07 +3.95E-02 3.03E+07 1.65E+07 +3.65E-02 2.95E+07 1.59E+07 +3.37E-02 2.89E+07 1.54E+07 +3.12E-02 2.82E+07 1.48E+07 +2.89E-02 2.76E+07 1.43E+07 +2.67E-02 2.70E+07 1.38E+07 +2.47E-02 2.64E+07 1.33E+07 +2.28E-02 2.58E+07 1.28E+07 +2.11E-02 2.53E+07 1.23E+07 +1.95E-02 2.48E+07 1.19E+07 +1.81E-02 2.43E+07 1.14E+07 +1.67E-02 2.38E+07 1.10E+07 +1.54E-02 2.33E+07 1.05E+07 +1.43E-02 2.29E+07 1.01E+07 +1.32E-02 2.25E+07 9.71E+06 +1.22E-02 2.21E+07 9.32E+06 +1.13E-02 2.17E+07 8.93E+06 +1.04E-02 2.14E+07 8.56E+06 +9.66E-03 2.10E+07 8.20E+06 +8.93E-03 2.07E+07 7.86E+06 +8.26E-03 2.04E+07 7.53E+06 +7.64E-03 2.01E+07 7.21E+06 +7.07E-03 1.98E+07 6.90E+06 +6.53E-03 1.96E+07 6.60E+06 +6.04E-03 1.93E+07 6.31E+06 +5.59E-03 1.91E+07 6.03E+06 +5.17E-03 1.89E+07 5.76E+06 +4.78E-03 1.86E+07 5.50E+06 +4.42E-03 1.84E+07 5.25E+06 +4.09E-03 1.83E+07 5.01E+06 +3.78E-03 1.81E+07 4.78E+06 +3.50E-03 1.79E+07 4.56E+06 +3.23E-03 1.77E+07 4.35E+06 +2.99E-03 1.76E+07 4.14E+06 +2.77E-03 1.74E+07 3.95E+06 +2.56E-03 1.73E+07 3.76E+06 +2.37E-03 1.72E+07 3.58E+06 +2.19E-03 1.70E+07 3.41E+06 +2.02E-03 1.69E+07 3.24E+06 +1.87E-03 1.68E+07 3.09E+06 +1.73E-03 1.67E+07 2.94E+06 +1.60E-03 1.66E+07 2.79E+06 +1.48E-03 1.65E+07 2.65E+06 +1.37E-03 1.64E+07 2.52E+06 +1.27E-03 1.63E+07 2.39E+06 +1.17E-03 1.63E+07 2.27E+06 +1.08E-03 1.62E+07 2.15E+06 +1.00E-03 1.61E+07 2.04E+06 +9.26E-04 1.60E+07 1.93E+06 +8.56E-04 1.60E+07 1.83E+06 +7.92E-04 1.59E+07 1.73E+06 +7.32E-04 1.59E+07 1.63E+06 +6.77E-04 1.58E+07 1.54E+06 +6.26E-04 1.58E+07 1.46E+06 +5.79E-04 1.57E+07 1.38E+06 diff --git a/services/app/dynamfit/files/VeroCyan (5) shift factors 80C clean.txt b/services/app/dynamfit/files/VeroCyan (5) shift factors 80C clean.txt new file mode 100644 index 000000000..497beb8e5 --- /dev/null +++ b/services/app/dynamfit/files/VeroCyan (5) shift factors 80C clean.txt @@ -0,0 +1,25 @@ +-1.00E+01 5.07E+12 +-5.00E+00 2.51E+12 +0.00E+00 1.24E+12 +5.00E+00 6.15E+11 +1.00E+01 3.04E+11 +1.50E+01 6.50E+10 +2.00E+01 1.13E+10 +2.50E+01 1.69E+09 +3.00E+01 2.10E+08 +3.50E+01 3.86E+07 +4.00E+01 4.61E+06 +4.50E+01 5.97E+05 +5.00E+01 7.44E+04 +5.50E+01 9.26E+03 +6.00E+01 1.24E+03 +6.50E+01 1.86E+02 +7.00E+01 3.05E+01 +7.50E+01 5.30E+00 +8.00E+01 1.00E+00 +8.50E+01 2.04E-01 +9.00E+01 4.65E-02 +9.50E+01 1.22E-02 +1.00E+02 3.63E-03 +1.05E+02 1.31E-03 +1.10E+02 5.79E-04 diff --git a/services/app/dynamfit/files/agilus30 (8) Temperature Ramp clean.txt b/services/app/dynamfit/files/agilus30 (8) Temperature Ramp clean.txt new file mode 100644 index 000000000..1d0c2924f --- /dev/null +++ b/services/app/dynamfit/files/agilus30 (8) Temperature Ramp clean.txt @@ -0,0 +1,87 @@ +39.4 4837860 -2631550 +38.53 1452600 4495910 +37.7 3151440 2379310 +36.86 2674860 2979090 +36.03 3130030 2199350 +35.22 3625400 2126220 +34.37 2758820 1433300 +33.6 2911790 1709490 +32.74 3017010 2184600 +32 3418940 2386970 +31.15 3486920 2542500 +30.35 3726650 2906080 +29.5 3941370 3342680 +28.7 4283520 3832850 +27.91 4626690 4184500 +27.08 5052850 4791540 +26.31 5541420 5574470 +25.44 6037500 6335720 +24.67 6678390 7288640 +23.83 7544150 8484530 +23.08 8278450 9827250 +22.27 9270970 11233700 +21.43 10597000 13479500 +20.69 12278600 15081200 +19.81 13437400 18291200 +18.98 15730700 21502600 +18.17 18472900 25224000 +17.41 20223800 29825000 +16.59 24895500 35909200 +15.78 29849700 43408100 +14.95 35842800 50129000 +14.2 42367800 60447800 +13.37 52748200 71608200 +12.5 61687300 83432300 +11.72 75854800 96786600 +10.93 94283100 117289000 +10.14 112380000 133434000 +9.32 137493000 154193000 +8.5 168182000 173992000 +7.67 201554000 192097000 +6.93 250970000 225544000 +6.07 291816000 240050000 +5.3 343529000 277301000 +4.52 386707000 283006000 +3.67 457157000 310219000 +2.85 526301000 330419000 +2.04 625132000 348486000 +1.2 692855000 386330000 +0.43 772681000 417979000 +-0.32 861268000 398342000 +-1.15 992859000 358889000 +-1.97 1071880000 354044000 +-2.81 1068100000 461176000 +-3.53 1142250000 372665000 +-4.4 1261520000 451332000 +-5.22 1458150000 389970000 +-6 1440580000 355687000 +-6.79 1421920000 319794000 +-7.61 1641260000 513150000 +-8.5 1685930000 503353000 +-9.24 1925160000 259485000 +-10.11 2043610000 291530000 +-10.84 2087990000 681356000 +-11.68 1892310000 357861000 +-12.44 2310840000 428975000 +-13.26 2161140000 591976000 +-14.2 1997850000 280977000 +-14.83 2290300000 529538000 +-15.64 2364150000 168078000 +-16.53 2426640000 404554000 +-17.34 2014210000 231799000 +-18.13 2922020000 394682000 +-18.92 2717300000 605533000 +-19.73 2796450000 479923000 +-20.51 3217630000 255847000 +-21.41 3102220000 669917000 +-22.11 3166410000 601452000 +-23 2497520000 403740000 +-23.81 2398710000 407407000 +-24.55 3027520000 204432000 +-25.4 3103210000 640789000 +-26.12 2797590000 567703000 +-26.99 3106940000 352974000 +-27.82 2638500000 157012000 +-28.61 3130040000 123604000 +-29.43 3296020000 358941000 +-30.03 3264650000 116073000 diff --git a/services/app/dynamfit/files/agilus30 (8) master curve 20C clean.txt b/services/app/dynamfit/files/agilus30 (8) master curve 20C clean.txt new file mode 100644 index 000000000..55905103b --- /dev/null +++ b/services/app/dynamfit/files/agilus30 (8) master curve 20C clean.txt @@ -0,0 +1,500 @@ +1.62E+08 2.85E+09 4.62E+07 +1.53E+08 2.85E+09 4.74E+07 +1.45E+08 2.85E+09 4.87E+07 +1.37E+08 2.85E+09 4.99E+07 +1.30E+08 2.84E+09 5.12E+07 +1.23E+08 2.84E+09 5.25E+07 +1.16E+08 2.84E+09 5.39E+07 +1.10E+08 2.84E+09 5.52E+07 +1.04E+08 2.84E+09 5.66E+07 +9.87E+07 2.84E+09 5.81E+07 +9.35E+07 2.83E+09 5.95E+07 +8.85E+07 2.83E+09 6.11E+07 +8.38E+07 2.83E+09 6.26E+07 +7.93E+07 2.83E+09 6.42E+07 +7.51E+07 2.83E+09 6.59E+07 +7.11E+07 2.83E+09 6.76E+07 +6.73E+07 2.82E+09 6.94E+07 +6.38E+07 2.82E+09 7.12E+07 +6.04E+07 2.82E+09 7.31E+07 +5.71E+07 2.82E+09 7.50E+07 +5.41E+07 2.82E+09 7.71E+07 +5.12E+07 2.81E+09 7.92E+07 +4.85E+07 2.81E+09 8.13E+07 +4.59E+07 2.81E+09 8.35E+07 +4.35E+07 2.81E+09 8.58E+07 +4.12E+07 2.81E+09 8.82E+07 +3.90E+07 2.80E+09 9.07E+07 +3.69E+07 2.80E+09 9.32E+07 +3.49E+07 2.80E+09 9.58E+07 +3.31E+07 2.79E+09 9.85E+07 +3.13E+07 2.79E+09 1.01E+08 +2.96E+07 2.79E+09 1.04E+08 +2.81E+07 2.79E+09 1.07E+08 +2.66E+07 2.78E+09 1.10E+08 +2.52E+07 2.78E+09 1.13E+08 +2.38E+07 2.78E+09 1.16E+08 +2.26E+07 2.77E+09 1.20E+08 +2.14E+07 2.77E+09 1.23E+08 +2.02E+07 2.77E+09 1.27E+08 +1.91E+07 2.76E+09 1.30E+08 +1.81E+07 2.76E+09 1.34E+08 +1.72E+07 2.75E+09 1.37E+08 +1.62E+07 2.75E+09 1.41E+08 +1.54E+07 2.75E+09 1.45E+08 +1.46E+07 2.74E+09 1.49E+08 +1.38E+07 2.74E+09 1.53E+08 +1.31E+07 2.73E+09 1.57E+08 +1.24E+07 2.73E+09 1.61E+08 +1.17E+07 2.72E+09 1.66E+08 +1.11E+07 2.71E+09 1.70E+08 +1.05E+07 2.71E+09 1.74E+08 +9.93E+06 2.70E+09 1.78E+08 +9.40E+06 2.70E+09 1.83E+08 +8.90E+06 2.69E+09 1.87E+08 +8.43E+06 2.68E+09 1.91E+08 +7.98E+06 2.68E+09 1.96E+08 +7.55E+06 2.67E+09 2.00E+08 +7.15E+06 2.66E+09 2.05E+08 +6.77E+06 2.65E+09 2.09E+08 +6.41E+06 2.65E+09 2.13E+08 +6.07E+06 2.64E+09 2.18E+08 +5.75E+06 2.63E+09 2.22E+08 +5.44E+06 2.62E+09 2.26E+08 +5.15E+06 2.61E+09 2.30E+08 +4.88E+06 2.60E+09 2.34E+08 +4.62E+06 2.59E+09 2.38E+08 +4.37E+06 2.59E+09 2.42E+08 +4.14E+06 2.58E+09 2.45E+08 +3.92E+06 2.57E+09 2.49E+08 +3.71E+06 2.56E+09 2.52E+08 +3.51E+06 2.54E+09 2.55E+08 +3.33E+06 2.53E+09 2.58E+08 +3.15E+06 2.52E+09 2.60E+08 +2.98E+06 2.51E+09 2.62E+08 +2.82E+06 2.50E+09 2.64E+08 +2.67E+06 2.49E+09 2.66E+08 +2.53E+06 2.48E+09 2.67E+08 +2.40E+06 2.47E+09 2.68E+08 +2.27E+06 2.46E+09 2.69E+08 +2.15E+06 2.45E+09 2.70E+08 +2.03E+06 2.44E+09 2.71E+08 +1.92E+06 2.42E+09 2.71E+08 +1.82E+06 2.41E+09 2.71E+08 +1.73E+06 2.40E+09 2.71E+08 +1.63E+06 2.39E+09 2.71E+08 +1.55E+06 2.38E+09 2.71E+08 +1.46E+06 2.37E+09 2.71E+08 +1.39E+06 2.36E+09 2.70E+08 +1.31E+06 2.35E+09 2.70E+08 +1.24E+06 2.34E+09 2.69E+08 +1.18E+06 2.33E+09 2.69E+08 +1.11E+06 2.32E+09 2.68E+08 +1.05E+06 2.31E+09 2.68E+08 +9.98E+05 2.30E+09 2.67E+08 +9.45E+05 2.29E+09 2.66E+08 +8.95E+05 2.28E+09 2.65E+08 +8.47E+05 2.27E+09 2.64E+08 +8.02E+05 2.26E+09 2.64E+08 +7.59E+05 2.26E+09 2.63E+08 +7.19E+05 2.25E+09 2.62E+08 +6.81E+05 2.24E+09 2.61E+08 +6.45E+05 2.23E+09 2.60E+08 +6.10E+05 2.22E+09 2.59E+08 +5.78E+05 2.21E+09 2.59E+08 +5.47E+05 2.20E+09 2.58E+08 +5.18E+05 2.19E+09 2.57E+08 +4.90E+05 2.19E+09 2.57E+08 +4.64E+05 2.18E+09 2.56E+08 +4.40E+05 2.17E+09 2.56E+08 +4.16E+05 2.16E+09 2.56E+08 +3.94E+05 2.15E+09 2.56E+08 +3.73E+05 2.15E+09 2.56E+08 +3.53E+05 2.14E+09 2.56E+08 +3.34E+05 2.13E+09 2.57E+08 +3.17E+05 2.12E+09 2.57E+08 +3.00E+05 2.12E+09 2.58E+08 +2.84E+05 2.11E+09 2.59E+08 +2.69E+05 2.10E+09 2.60E+08 +2.54E+05 2.09E+09 2.61E+08 +2.41E+05 2.08E+09 2.62E+08 +2.28E+05 2.08E+09 2.64E+08 +2.16E+05 2.07E+09 2.65E+08 +2.04E+05 2.06E+09 2.67E+08 +1.93E+05 2.05E+09 2.69E+08 +1.83E+05 2.04E+09 2.71E+08 +1.73E+05 2.03E+09 2.73E+08 +1.64E+05 2.03E+09 2.75E+08 +1.55E+05 2.02E+09 2.77E+08 +1.47E+05 2.01E+09 2.80E+08 +1.39E+05 2.00E+09 2.83E+08 +1.32E+05 1.99E+09 2.85E+08 +1.25E+05 1.98E+09 2.88E+08 +1.18E+05 1.97E+09 2.91E+08 +1.12E+05 1.96E+09 2.94E+08 +1.06E+05 1.95E+09 2.97E+08 +1.00E+05 1.94E+09 3.00E+08 +9.50E+04 1.93E+09 3.03E+08 +9.00E+04 1.92E+09 3.06E+08 +8.52E+04 1.91E+09 3.09E+08 +8.07E+04 1.90E+09 3.12E+08 +7.64E+04 1.89E+09 3.15E+08 +7.23E+04 1.88E+09 3.18E+08 +6.84E+04 1.87E+09 3.21E+08 +6.48E+04 1.85E+09 3.23E+08 +6.14E+04 1.84E+09 3.26E+08 +5.81E+04 1.83E+09 3.29E+08 +5.50E+04 1.82E+09 3.32E+08 +5.21E+04 1.81E+09 3.34E+08 +4.93E+04 1.80E+09 3.37E+08 +4.67E+04 1.78E+09 3.39E+08 +4.42E+04 1.77E+09 3.42E+08 +4.18E+04 1.76E+09 3.45E+08 +3.96E+04 1.75E+09 3.47E+08 +3.75E+04 1.73E+09 3.50E+08 +3.55E+04 1.72E+09 3.53E+08 +3.36E+04 1.71E+09 3.55E+08 +3.18E+04 1.70E+09 3.58E+08 +3.01E+04 1.68E+09 3.60E+08 +2.85E+04 1.67E+09 3.63E+08 +2.70E+04 1.66E+09 3.65E+08 +2.56E+04 1.64E+09 3.67E+08 +2.42E+04 1.63E+09 3.70E+08 +2.29E+04 1.62E+09 3.72E+08 +2.17E+04 1.60E+09 3.74E+08 +2.05E+04 1.59E+09 3.76E+08 +1.95E+04 1.58E+09 3.78E+08 +1.84E+04 1.56E+09 3.80E+08 +1.74E+04 1.55E+09 3.81E+08 +1.65E+04 1.54E+09 3.83E+08 +1.56E+04 1.52E+09 3.85E+08 +1.48E+04 1.51E+09 3.86E+08 +1.40E+04 1.49E+09 3.88E+08 +1.33E+04 1.48E+09 3.89E+08 +1.26E+04 1.47E+09 3.91E+08 +1.19E+04 1.45E+09 3.92E+08 +1.13E+04 1.44E+09 3.94E+08 +1.07E+04 1.42E+09 3.95E+08 +1.01E+04 1.41E+09 3.97E+08 +9.56E+03 1.40E+09 3.98E+08 +9.05E+03 1.38E+09 4.00E+08 +8.57E+03 1.37E+09 4.01E+08 +8.11E+03 1.35E+09 4.02E+08 +7.68E+03 1.34E+09 4.03E+08 +7.27E+03 1.32E+09 4.04E+08 +6.88E+03 1.31E+09 4.05E+08 +6.52E+03 1.29E+09 4.06E+08 +6.17E+03 1.28E+09 4.07E+08 +5.84E+03 1.26E+09 4.07E+08 +5.53E+03 1.25E+09 4.08E+08 +5.24E+03 1.23E+09 4.08E+08 +4.96E+03 1.22E+09 4.09E+08 +4.69E+03 1.20E+09 4.09E+08 +4.44E+03 1.19E+09 4.09E+08 +4.21E+03 1.17E+09 4.09E+08 +3.98E+03 1.16E+09 4.09E+08 +3.77E+03 1.14E+09 4.09E+08 +3.57E+03 1.13E+09 4.09E+08 +3.38E+03 1.11E+09 4.09E+08 +3.20E+03 1.10E+09 4.09E+08 +3.03E+03 1.08E+09 4.09E+08 +2.87E+03 1.07E+09 4.09E+08 +2.72E+03 1.05E+09 4.08E+08 +2.57E+03 1.04E+09 4.08E+08 +2.43E+03 1.02E+09 4.08E+08 +2.31E+03 1.01E+09 4.07E+08 +2.18E+03 9.93E+08 4.07E+08 +2.07E+03 9.78E+08 4.06E+08 +1.96E+03 9.63E+08 4.05E+08 +1.85E+03 9.48E+08 4.04E+08 +1.75E+03 9.33E+08 4.03E+08 +1.66E+03 9.18E+08 4.02E+08 +1.57E+03 9.03E+08 4.00E+08 +1.49E+03 8.89E+08 3.99E+08 +1.41E+03 8.74E+08 3.97E+08 +1.33E+03 8.59E+08 3.95E+08 +1.26E+03 8.45E+08 3.94E+08 +1.20E+03 8.31E+08 3.92E+08 +1.13E+03 8.16E+08 3.90E+08 +1.07E+03 8.02E+08 3.88E+08 +1.01E+03 7.88E+08 3.86E+08 +9.61E+02 7.74E+08 3.84E+08 +9.10E+02 7.60E+08 3.82E+08 +8.61E+02 7.46E+08 3.80E+08 +8.15E+02 7.32E+08 3.77E+08 +7.72E+02 7.18E+08 3.75E+08 +7.31E+02 7.05E+08 3.73E+08 +6.92E+02 6.91E+08 3.70E+08 +6.55E+02 6.77E+08 3.68E+08 +6.20E+02 6.64E+08 3.65E+08 +5.87E+02 6.50E+08 3.63E+08 +5.56E+02 6.37E+08 3.60E+08 +5.26E+02 6.23E+08 3.57E+08 +4.98E+02 6.10E+08 3.54E+08 +4.72E+02 5.97E+08 3.51E+08 +4.47E+02 5.84E+08 3.47E+08 +4.23E+02 5.72E+08 3.44E+08 +4.00E+02 5.59E+08 3.40E+08 +3.79E+02 5.47E+08 3.37E+08 +3.59E+02 5.35E+08 3.33E+08 +3.40E+02 5.23E+08 3.30E+08 +3.22E+02 5.11E+08 3.26E+08 +3.05E+02 4.99E+08 3.22E+08 +2.88E+02 4.88E+08 3.19E+08 +2.73E+02 4.76E+08 3.15E+08 +2.59E+02 4.65E+08 3.11E+08 +2.45E+02 4.54E+08 3.08E+08 +2.32E+02 4.43E+08 3.04E+08 +2.19E+02 4.32E+08 3.00E+08 +2.08E+02 4.22E+08 2.96E+08 +1.97E+02 4.11E+08 2.93E+08 +1.86E+02 4.01E+08 2.89E+08 +1.76E+02 3.90E+08 2.85E+08 +1.67E+02 3.80E+08 2.81E+08 +1.58E+02 3.70E+08 2.77E+08 +1.50E+02 3.60E+08 2.73E+08 +1.42E+02 3.51E+08 2.69E+08 +1.34E+02 3.41E+08 2.65E+08 +1.27E+02 3.32E+08 2.61E+08 +1.20E+02 3.23E+08 2.56E+08 +1.14E+02 3.14E+08 2.52E+08 +1.08E+02 3.05E+08 2.48E+08 +1.02E+02 2.97E+08 2.44E+08 +9.66E+01 2.88E+08 2.40E+08 +9.15E+01 2.80E+08 2.36E+08 +8.66E+01 2.72E+08 2.32E+08 +8.20E+01 2.64E+08 2.27E+08 +7.76E+01 2.56E+08 2.23E+08 +7.35E+01 2.49E+08 2.19E+08 +6.96E+01 2.41E+08 2.15E+08 +6.59E+01 2.34E+08 2.11E+08 +6.24E+01 2.27E+08 2.07E+08 +5.91E+01 2.20E+08 2.03E+08 +5.59E+01 2.13E+08 2.00E+08 +5.29E+01 2.06E+08 1.96E+08 +5.01E+01 2.00E+08 1.92E+08 +4.74E+01 1.93E+08 1.88E+08 +4.49E+01 1.87E+08 1.84E+08 +4.25E+01 1.81E+08 1.80E+08 +4.03E+01 1.75E+08 1.76E+08 +3.81E+01 1.69E+08 1.72E+08 +3.61E+01 1.63E+08 1.69E+08 +3.42E+01 1.58E+08 1.65E+08 +3.24E+01 1.52E+08 1.61E+08 +3.06E+01 1.47E+08 1.57E+08 +2.90E+01 1.42E+08 1.54E+08 +2.75E+01 1.37E+08 1.50E+08 +2.60E+01 1.32E+08 1.46E+08 +2.46E+01 1.28E+08 1.43E+08 +2.33E+01 1.23E+08 1.39E+08 +2.21E+01 1.19E+08 1.36E+08 +2.09E+01 1.15E+08 1.32E+08 +1.98E+01 1.10E+08 1.29E+08 +1.87E+01 1.06E+08 1.25E+08 +1.77E+01 1.03E+08 1.22E+08 +1.68E+01 9.88E+07 1.19E+08 +1.59E+01 9.51E+07 1.16E+08 +1.50E+01 9.16E+07 1.13E+08 +1.42E+01 8.81E+07 1.10E+08 +1.35E+01 8.48E+07 1.07E+08 +1.28E+01 8.16E+07 1.04E+08 +1.21E+01 7.85E+07 1.01E+08 +1.14E+01 7.54E+07 9.77E+07 +1.08E+01 7.25E+07 9.48E+07 +1.03E+01 6.98E+07 9.20E+07 +9.71E+00 6.71E+07 8.92E+07 +9.20E+00 6.45E+07 8.65E+07 +8.71E+00 6.20E+07 8.38E+07 +8.24E+00 5.96E+07 8.12E+07 +7.81E+00 5.74E+07 7.87E+07 +7.39E+00 5.52E+07 7.61E+07 +7.00E+00 5.31E+07 7.37E+07 +6.62E+00 5.11E+07 7.13E+07 +6.27E+00 4.92E+07 6.90E+07 +5.94E+00 4.74E+07 6.68E+07 +5.62E+00 4.56E+07 6.46E+07 +5.32E+00 4.40E+07 6.24E+07 +5.04E+00 4.24E+07 6.04E+07 +4.77E+00 4.08E+07 5.83E+07 +4.52E+00 3.93E+07 5.64E+07 +4.28E+00 3.79E+07 5.45E+07 +4.05E+00 3.65E+07 5.27E+07 +3.83E+00 3.52E+07 5.09E+07 +3.63E+00 3.40E+07 4.92E+07 +3.44E+00 3.28E+07 4.75E+07 +3.25E+00 3.16E+07 4.58E+07 +3.08E+00 3.05E+07 4.43E+07 +2.92E+00 2.95E+07 4.27E+07 +2.76E+00 2.85E+07 4.13E+07 +2.61E+00 2.75E+07 3.98E+07 +2.47E+00 2.66E+07 3.84E+07 +2.34E+00 2.57E+07 3.71E+07 +2.22E+00 2.48E+07 3.58E+07 +2.10E+00 2.40E+07 3.46E+07 +1.99E+00 2.32E+07 3.34E+07 +1.88E+00 2.25E+07 3.22E+07 +1.78E+00 2.17E+07 3.11E+07 +1.69E+00 2.10E+07 3.00E+07 +1.60E+00 2.04E+07 2.90E+07 +1.51E+00 1.97E+07 2.80E+07 +1.43E+00 1.91E+07 2.70E+07 +1.36E+00 1.85E+07 2.61E+07 +1.28E+00 1.79E+07 2.52E+07 +1.22E+00 1.74E+07 2.43E+07 +1.15E+00 1.68E+07 2.34E+07 +1.09E+00 1.63E+07 2.26E+07 +1.03E+00 1.58E+07 2.18E+07 +9.77E-01 1.53E+07 2.11E+07 +9.25E-01 1.48E+07 2.03E+07 +8.76E-01 1.44E+07 1.96E+07 +8.29E-01 1.40E+07 1.89E+07 +7.85E-01 1.36E+07 1.83E+07 +7.43E-01 1.32E+07 1.76E+07 +7.03E-01 1.28E+07 1.70E+07 +6.66E-01 1.24E+07 1.64E+07 +6.31E-01 1.21E+07 1.58E+07 +5.97E-01 1.17E+07 1.53E+07 +5.65E-01 1.14E+07 1.47E+07 +5.35E-01 1.11E+07 1.42E+07 +5.07E-01 1.08E+07 1.37E+07 +4.80E-01 1.05E+07 1.32E+07 +4.54E-01 1.02E+07 1.28E+07 +4.30E-01 9.93E+06 1.23E+07 +4.07E-01 9.67E+06 1.19E+07 +3.85E-01 9.42E+06 1.15E+07 +3.65E-01 9.17E+06 1.11E+07 +3.46E-01 8.94E+06 1.07E+07 +3.27E-01 8.71E+06 1.03E+07 +3.10E-01 8.49E+06 9.99E+06 +2.93E-01 8.28E+06 9.65E+06 +2.78E-01 8.07E+06 9.32E+06 +2.63E-01 7.87E+06 9.00E+06 +2.49E-01 7.68E+06 8.69E+06 +2.36E-01 7.50E+06 8.40E+06 +2.23E-01 7.32E+06 8.11E+06 +2.11E-01 7.15E+06 7.84E+06 +2.00E-01 6.98E+06 7.57E+06 +1.89E-01 6.82E+06 7.32E+06 +1.79E-01 6.67E+06 7.07E+06 +1.70E-01 6.52E+06 6.84E+06 +1.61E-01 6.37E+06 6.61E+06 +1.52E-01 6.23E+06 6.39E+06 +1.44E-01 6.09E+06 6.19E+06 +1.36E-01 5.96E+06 5.98E+06 +1.29E-01 5.83E+06 5.79E+06 +1.22E-01 5.71E+06 5.61E+06 +1.16E-01 5.58E+06 5.43E+06 +1.10E-01 5.46E+06 5.25E+06 +1.04E-01 5.35E+06 5.09E+06 +9.82E-02 5.24E+06 4.93E+06 +9.30E-02 5.13E+06 4.77E+06 +8.80E-02 5.02E+06 4.62E+06 +8.33E-02 4.91E+06 4.48E+06 +7.89E-02 4.81E+06 4.34E+06 +7.47E-02 4.72E+06 4.20E+06 +7.07E-02 4.62E+06 4.07E+06 +6.70E-02 4.53E+06 3.95E+06 +6.34E-02 4.44E+06 3.83E+06 +6.00E-02 4.35E+06 3.71E+06 +5.68E-02 4.26E+06 3.60E+06 +5.38E-02 4.18E+06 3.49E+06 +5.09E-02 4.10E+06 3.39E+06 +4.82E-02 4.02E+06 3.29E+06 +4.57E-02 3.94E+06 3.19E+06 +4.32E-02 3.87E+06 3.09E+06 +4.09E-02 3.79E+06 3.00E+06 +3.88E-02 3.72E+06 2.92E+06 +3.67E-02 3.65E+06 2.83E+06 +3.47E-02 3.58E+06 2.75E+06 +3.29E-02 3.51E+06 2.67E+06 +3.11E-02 3.45E+06 2.59E+06 +2.95E-02 3.38E+06 2.52E+06 +2.79E-02 3.32E+06 2.44E+06 +2.64E-02 3.26E+06 2.37E+06 +2.50E-02 3.20E+06 2.30E+06 +2.37E-02 3.14E+06 2.23E+06 +2.24E-02 3.08E+06 2.17E+06 +2.12E-02 3.03E+06 2.10E+06 +2.01E-02 2.98E+06 2.04E+06 +1.90E-02 2.92E+06 1.98E+06 +1.80E-02 2.87E+06 1.92E+06 +1.71E-02 2.83E+06 1.87E+06 +1.62E-02 2.78E+06 1.81E+06 +1.53E-02 2.73E+06 1.76E+06 +1.45E-02 2.69E+06 1.71E+06 +1.37E-02 2.65E+06 1.66E+06 +1.30E-02 2.61E+06 1.61E+06 +1.23E-02 2.57E+06 1.56E+06 +1.16E-02 2.53E+06 1.52E+06 +1.10E-02 2.49E+06 1.47E+06 +1.04E-02 2.45E+06 1.43E+06 +9.87E-03 2.42E+06 1.39E+06 +9.35E-03 2.38E+06 1.35E+06 +8.85E-03 2.35E+06 1.31E+06 +8.38E-03 2.32E+06 1.27E+06 +7.93E-03 2.28E+06 1.24E+06 +7.51E-03 2.25E+06 1.20E+06 +7.11E-03 2.22E+06 1.17E+06 +6.73E-03 2.19E+06 1.13E+06 +6.38E-03 2.16E+06 1.10E+06 +6.04E-03 2.13E+06 1.07E+06 +5.71E-03 2.11E+06 1.04E+06 +5.41E-03 2.08E+06 1.01E+06 +5.12E-03 2.05E+06 9.82E+05 +4.85E-03 2.03E+06 9.54E+05 +4.59E-03 2.00E+06 9.27E+05 +4.35E-03 1.98E+06 9.01E+05 +4.12E-03 1.96E+06 8.76E+05 +3.90E-03 1.93E+06 8.52E+05 +3.69E-03 1.91E+06 8.28E+05 +3.49E-03 1.89E+06 8.05E+05 +3.31E-03 1.87E+06 7.83E+05 +3.13E-03 1.85E+06 7.61E+05 +2.96E-03 1.83E+06 7.39E+05 +2.81E-03 1.81E+06 7.18E+05 +2.66E-03 1.79E+06 6.98E+05 +2.52E-03 1.77E+06 6.78E+05 +2.38E-03 1.75E+06 6.58E+05 +2.26E-03 1.73E+06 6.39E+05 +2.14E-03 1.72E+06 6.20E+05 +2.02E-03 1.70E+06 6.01E+05 +1.91E-03 1.68E+06 5.83E+05 +1.81E-03 1.67E+06 5.65E+05 +1.72E-03 1.65E+06 5.48E+05 +1.62E-03 1.64E+06 5.31E+05 +1.54E-03 1.63E+06 5.14E+05 +1.46E-03 1.61E+06 4.98E+05 +1.38E-03 1.60E+06 4.83E+05 +1.31E-03 1.59E+06 4.68E+05 +1.24E-03 1.58E+06 4.53E+05 +1.17E-03 1.57E+06 4.40E+05 +1.11E-03 1.56E+06 4.26E+05 +1.05E-03 1.55E+06 4.13E+05 +9.93E-04 1.54E+06 4.01E+05 +9.40E-04 1.53E+06 3.89E+05 +8.90E-04 1.52E+06 3.78E+05 +8.43E-04 1.51E+06 3.68E+05 +7.98E-04 1.50E+06 3.57E+05 +7.55E-04 1.49E+06 3.48E+05 +7.15E-04 1.49E+06 3.39E+05 +6.77E-04 1.48E+06 3.30E+05 +6.41E-04 1.47E+06 3.22E+05 +6.07E-04 1.46E+06 3.15E+05 +5.75E-04 1.46E+06 3.07E+05 +5.44E-04 1.45E+06 3.01E+05 +5.15E-04 1.44E+06 2.94E+05 +4.88E-04 1.44E+06 2.88E+05 +4.62E-04 1.43E+06 2.82E+05 +4.37E-04 1.42E+06 2.77E+05 +4.14E-04 1.41E+06 2.72E+05 +3.92E-04 1.41E+06 2.67E+05 +3.71E-04 1.40E+06 2.62E+05 +3.51E-04 1.39E+06 2.57E+05 +3.33E-04 1.38E+06 2.52E+05 +3.15E-04 1.37E+06 2.48E+05 +2.98E-04 1.37E+06 2.43E+05 +2.82E-04 1.36E+06 2.39E+05 +2.67E-04 1.35E+06 2.34E+05 +2.53E-04 1.34E+06 2.29E+05 +2.40E-04 1.34E+06 2.24E+05 +2.27E-04 1.33E+06 2.20E+05 diff --git a/services/app/dynamfit/files/agilus30 (8) shift factors 20C clean.txt b/services/app/dynamfit/files/agilus30 (8) shift factors 20C clean.txt new file mode 100644 index 000000000..ed329e50c --- /dev/null +++ b/services/app/dynamfit/files/agilus30 (8) shift factors 20C clean.txt @@ -0,0 +1,21 @@ +-3.00E+01 1.62E+07 +-2.50E+01 4.12E+06 +-2.00E+01 2.04E+06 +-1.50E+01 6.10E+05 +-1.00E+01 1.47E+05 +-5.00E+00 1.94E+04 +0.00E+00 2.00E+03 +5.00E+00 2.35E+02 +1.00E+01 3.23E+01 +1.50E+01 5.23E+00 +2.00E+01 1.00E+00 +2.50E+01 2.30E-01 +3.00E+01 6.10E-02 +3.50E+01 2.27E-02 +4.00E+01 6.35E-03 +4.50E+01 2.24E-03 +5.00E+01 8.90E-04 +5.50E+01 4.10E-04 +6.00E+01 1.85E-04 +6.50E+01 9.16E-05 +7.00E+01 4.54E-05 diff --git a/services/app/dynamfit/helper.py b/services/app/dynamfit/helper.py deleted file mode 100644 index bc0571f73..000000000 --- a/services/app/dynamfit/helper.py +++ /dev/null @@ -1,462 +0,0 @@ -import numpy as np -import pandas as pd -from scipy.optimize import minimize -from scipy.signal import find_peaks - -float_correction = 1e-7 -R = 8.31446261815324 # J/(mol*K) - - -def prony_basis(freq, relaxations, solid): - dt = dimensionless_time = np.outer(freq, relaxations) - - dt2 = dt * dt - dt2p1 = dt2 + 1 - ep_basis = dt2 / dt2p1 - epp_basis = dt / dt2p1 - - if solid: - ep_basis=np.concatenate( - (np.ones_like(ep_basis, shape=(len(ep_basis), 1)), ep_basis), axis=1 - ) - epp_basis=np.concatenate( - (np.zeros_like(epp_basis, shape=(len(epp_basis), 1)), epp_basis), axis=1 - ) - - return np.concatenate((ep_basis, epp_basis), axis=0) - - -def prony_relaxation_space(tau_min, tau_max, N): - return np.logspace(np.log10(tau_min), np.log10(tau_max), N, endpoint=True) - - -def compute_complex(tau_i,E_i): - """ - Compute the complex values of a given dataset. - - Parameters: - tau_i (array-like): The time constants for each energy value. - E_i (array-like): The energy values corresponding to each time constant. - - Returns: - pandas.DataFrame: A DataFrame containing the computed frequency, real and imaginary values. - """ - omega = np.logspace(-np.log10(np.max(tau_i)), -np.log10(np.min(tau_i)), 1000) - basis = prony_basis(omega,tau_i, solid=not (len(E_i) == len(tau_i))) - complex = basis @ E_i - real, imag = complex.reshape(2,1000) - - return pd.DataFrame(data={"Frequency":omega, "E Storage":real, "E Loss":imag}) - - -def compute_relaxation_modulus(tau_i,E_i): - """ - Compute the relaxation modulus using the given parameters. - - Parameters: - tau_i (array-like): The time constants for each energy value. - E_i (array-like): The energy values corresponding to each time constant. - - Returns: - pd.DataFrame: A DataFrame containing the computed relaxation curve with two columns: "Time" and "E". - """ - t = np.logspace(np.log10(np.min(tau_i)), np.log10(np.max(tau_i)), 1000) - dt = dimensionless_time = np.outer(t, 1/tau_i) - solid = not (len(E_i) == len(tau_i)) - E = np.exp(-dt) @ E_i[solid:] - return pd.DataFrame(data={"Time":t, "E":E}) - - -def compute_relaxation_spectrum(tau_i,E_i): - """ - Compute the relaxation spectrum of the given data. - - Parameters: - tau_i (array-like): The time constants for each energy value. - E_i (array-like): The energy values corresponding to each time constant. - - Returns: - pd.DataFrame: A DataFrame containing the computed relaxation spectrum values with the - following columns: - - Time: The time values. - - H: The computed relaxation spectrum values. - """ - t = np.logspace(np.log10(np.min(tau_i)), np.log10(np.max(tau_i)), 1000) - dt = dimensionless_time = np.outer(t, 1/tau_i) - solid = not (len(E_i) == len(tau_i)) - H = (dt * np.exp(-dt)) @ E_i[solid:] - return pd.DataFrame(data={"Time":t, "H":H}) - - -def prony_objective(logcoefs, data, std, basis, smoothness, solid): - coefs = np.exp(logcoefs) - estimate = basis @ coefs - resid = (data - estimate) / std - loss = resid@resid - if smoothness: - curve = smoothness * np.diff(logcoefs[solid:], n=2) - loss += curve@curve - - grad = -(basis.T @ (resid / std)) - - # apply chain rule because these are functions of - # coefs rather than logcoefs - grad *= coefs - - if smoothness: - diffs = smoothness * curve # smoothness*np.diff(logcoefs[solid:], n=2) - grad_slice = grad[solid:] - grad_slice[:-2] += diffs - grad_slice[1:-1] -= 2 * diffs - grad_slice[2:] += diffs - - return loss, 2 * grad # more chain rule (squared errors) - - -def smooth_prony_fit(omega, E_stor, E_loss, N, smoothness, solid=True): - """ - Perform a fit using the Prony method with coefficient smoothing. - - Parameters: - - omega: ndarray - The input array containing frequency data. - - E_stor: ndarray - The input array containing storage modulus data. - - E_loss: ndarray - The input array containing loss modulus data. - - N: int - The number of points to generate in the logarithmic time grid. - - smoothness: float - Amount of smoothing regularization to apply to the log-coefficients - - solid: bool - Whether to add an extra coefficient for solid-like behavior - - Returns: - - tuple - A tuple containing the following elements: - - tau_i: ndarray - The logarithmic time grid. - - E_i: ndarray - The fitted coefficients. - """ - - tau_max = 1 / np.min(omega) - tau_min = 1 / np.max(omega) - tau_i = prony_relaxation_space(tau_min, tau_max, N) - - basis = prony_basis(omega, tau_i, solid) - - y = np.concatenate((E_stor,E_loss)) - - y_std = np.absolute(E_stor+1.0j*E_loss) - y_std *= 0.2 # TODO: make this an input to the function - y_std = np.concatenate((y_std, y_std)) - - with np.errstate(over='ignore', invalid='ignore'): - result = minimize( - fun=prony_objective, - x0=np.full(N+solid,7.) , - args=(y, y_std, basis, smoothness, solid), - jac=True, - ) - E_i = np.exp(result.x) - - return tau_i, E_i - -# OLD Method -# def wlf_shift(T, T_ref, C1, C2): -# """Calculate shift factor a_T using the WLF equation.""" -# print(f"T: {T}") -# print(f"T_ref: {T_ref}") -# print(f'C1: {C1}') -# print(f'C2: {C2}') -# return 10 ** (-C1 * (T - T_ref) / (C2 + (T - T_ref))) - -# With error checking -def wlf_shift(T, T_ref, C1, C2): - """ - Calculate shift factor a_T using the WLF equation with robust checks. - - Raises: - ValueError: if a divide-by-zero, overflow, or non-finite result is detected. - """ - # Ensure numeric numpy arrays (works for scalars too) - T_arr = np.array(T, dtype=np.float64) - T_ref = float(T_ref) - C1 = float(C1) - C2 = float(C2) - - # denom = C2 + (T - T_ref) - denom = C2 + (T_arr - T_ref) - - # Check for exact zero in denom (handles scalars and arrays) - # Use an absolute tolerance for floating point near-zero - if np.any(np.isclose(denom, 0.0, atol=1e-12)): - # Raise ValueError so Flask route will return the JSON message you asked for - raise ValueError( - "divide by zero detected when calculating WLF. Please adjust parameters or manually provide shift factors." - ) - - # Compute with error state catching to detect overflow/invalid results - with np.errstate(divide='raise', invalid='raise', over='raise'): - try: - exponent = -C1 * (T_arr - T_ref) / denom - a_T = np.power(10.0, exponent) - - # Ensure finite output - if not np.all(np.isfinite(a_T)): - raise FloatingPointError("Non-finite result in WLF computation") - - # If input was scalar, return scalar - if a_T.size == 1: - return float(a_T) - return a_T - - except (FloatingPointError, ZeroDivisionError) as e: - # Normalize all numeric errors to the ValueError your route expects - raise ValueError( - "divide by zero detected when calculating WLF. Please adjust parameters or manually provide shift factors." - ) - -def arr_shift(T, T_ref, Ea): - """Calculate shift factor a_T using the Arrhenius equation.""" - T_ref_K = T_ref + 273.15 - T_temp_arr_K = T + 273.15 - - Ea_J_per_mol = Ea * 1000.0 - m = Ea_J_per_mol / (2.303 * R) - a_T = 10 ** (m * ((1.0 / T_temp_arr_K) - (1.0 / T_ref_K))) - return a_T - -def hybrid_shift(T, T_ref, C1, C2, Ea, ascending = True): - # temperature values must be ordered! - # WLF - print('before mask') - mask_temp_wlf = ( - np.isfinite(T) & - (T > (T_ref + float_correction)) - ) - print('before mask 2') - print(f'T = {T}') - print(f'mask = {mask_temp_wlf}') - T_wlf = T[mask_temp_wlf] - print(f'T_wlf: {T_wlf}') - print("before WLF_shift") - a_T_wlf = wlf_shift(T_wlf, T_ref, C1, C2) - print("after WLF_shift") - - # Arrhenius - mask_temp_arr = ( - np.isfinite(T) & - (T <= (T_ref + float_correction)) - ) - T_arr = T[mask_temp_arr] - print(f'T_arr: {T_wlf}') - print("before arr_shift") - a_T_arr = arr_shift(T_arr, T_ref, Ea) - print("after arr_shift") - - # determine which order to concatenate models based on ascending or descending order of temperature values - if ascending: - a_T = np.concatenate((a_T_arr, a_T_wlf)) - else: - a_T = np.concatenate((a_T_wlf, a_T_arr)) - - return a_T - -def inverse_wlf_shift(a_T, T_ref, C1, C2): - """calculate the shifted temperature given a shift factor""" - return (np.log10(a_T)*(T_ref-C2)+C1*T_ref)/(np.log10(a_T)+C1) - -def tts_temperature_to_frequency(temp_sweep_data, T_ref, C1, C2, calcShifts=True): - """ - Convert temperature sweep viscoelastic data to frequency sweep data using TTS. - - Parameters: - temp_sweep_data (pd.DataFrame): Data with columns ['Temperature', 'Frequency', "E'", "E''"]. - T_ref (float): Reference temperature for shifting. - C1 (float): WLF equation parameter 1. - C2 (float): WLF equation parameter 2. - - Returns: - pd.DataFrame: Frequency sweep data at T_ref. - """ - shifted_data = [] - i = 0 - - for T, group in temp_sweep_data.groupby('Temperature'): - if not calcShifts: - a_T = group['a_T'] - else: - a_T = wlf_shift(T, T_ref, C1, C2) - - shifted_freq = group['Frequency'] * a_T - shifted_data.append(pd.DataFrame({ - 'Frequency': shifted_freq, - 'Temperature': T_ref, - "E'": group["E'"], - "E''": group["E''"] - })) - i = i+1 - - combined_data = pd.concat(shifted_data).sort_values('Frequency') - - return combined_data - -def tts_temperature_to_frequency_V2(temp_sweep_data, T_ref, C1, C2, Ea, shift_model, shiftData): - """ - Convert temperature sweep viscoelastic data to frequency sweep data using TTS. - - Parameters: - temp_sweep_data (pd.DataFrame): Data with columns ['Temperature', 'Frequency', "E'", "E''"]. - T_ref (float): Reference temperature for shifting. - C1 (float): WLF equation parameter 1. - C2 (float): WLF equation parameter 2. - - Returns: - pd.DataFrame: Frequency sweep data at T_ref. - """ - # sort T ascending: - temp_sweep_data = temp_sweep_data.sort_values('Temperature') - - shifted_data = [] - i = 0 - - T = temp_sweep_data['Temperature'] - if not shiftData: - if shift_model == 'WLF': - print("before WLF_shift 1") - a_T = wlf_shift(T, T_ref, C1, C2) - print("after WLF_shift 1") - elif shift_model == 'hybrid': - print("before hybrid_shift") - a_T = hybrid_shift(T, T_ref, C1, C2, Ea) - print("after hybrid_shift") - else: - # use manual shift values - # DONE: replace with shiftData['a_T'] - # a_T = group['a_T'] - # possible TODO: offer to use interpolated shift factor values when file is provided - sd_df = pd.DataFrame(shiftData) - a_T = sd_df['a_T'] - - temp_sweep_data['a_T'] = a_T - - for T, group in temp_sweep_data.groupby('Temperature'): - print(f'group: {group}') - # moved out of grouping so a_T can be calculated by batch - # if not shiftData: - # if shift_model == 'WLF': - # print("before WLF_shift") - # a_T = wlf_shift(T, T_ref, C1, C2) - # print("after WLF_shift") - # elif shift_model == 'hybrid': - # print("before hybrid_shift") - # a_T = hybrid_shift(T, T_ref, C1, C2, Ea) - # print("after hybrid_shift") - # else: - # # use manual shift values - # # DONE: replace with shiftData['a_T'] - # # a_T = group['a_T'] - # # possible TODO: offer to use interpolated shift factor values when file is provided - # sd_df = pd.DataFrame(shiftData) - # a_T = sd_df['a_T'] - - # shifted_freq = group['Frequency'] * a_T - shifted_freq = group['Frequency'] * group['a_T'] - shifted_data.append(pd.DataFrame({ - 'Frequency': shifted_freq, - 'Temperature': T_ref, - "E'": group["E'"], - "E''": group["E''"] - })) - i = i+1 - - combined_data = pd.concat(shifted_data).sort_values('Frequency') - - return combined_data - -def tts_frequency_to_temperature(freq_sweep_data, omega_ref, C1, C2, calcShifts=True): - """ - Convert temperature sweep viscoelastic data to frequency sweep data using TTS. - - Parameters: - freq_sweep_data (pd.DataFrame): Data with columns ['Temperature', 'Frequency', "E'", "E''"]. - omega_ref (float): Reference frequency for shifting. - C1 (float): WLF equation parameter 1. - C2 (float): WLF equation parameter 2. - - Returns: - pd.DataFrame: Temperature sweep data at omega_ref. - """ - shifted_data = [] - i = 0 - - for omega, group in freq_sweep_data.groupby('Frequency'): - a_T = omega/omega_ref - T_ref = group["Temperature"] - - if not calcShifts: - print("not implemented error") - # not implemented but would need shift temps - # a_T = group['a_T'] - else: - # a_T = wlf_shift(T, T_ref, C1, C2) - shifted_T = inverse_wlf_shift(a_T, T_ref, C1, C2) - - # shifted_freq = group['Frequency'] * a_T - shifted_data.append(pd.DataFrame({ - 'Frequency': omega_ref, - 'Temperature': shifted_T, - "E'": group["E'"], - "E''": group["E''"] - })) - i = i+1 - - combined_data = pd.concat(shifted_data).sort_values('Temperature') - - return combined_data - -def estimate_Tg(uploadData, domain): - # --- Compute tan delta --- - # df = uploadData.copy() - df = pd.DataFrame(uploadData) - print(df) - # df["tan_delta"] = df["E''"] / df["E'"] - df["tan_delta"] = df["E Loss"] / df["E Storage"] - - # --- Use scipy.signal.find_peaks for robust peak detection --- - peaks, properties = find_peaks(df["tan_delta"], prominence=0.01) # adjust 'prominence' if needed - - if len(peaks) == 0: - raise ValueError("No tanδ peaks found to estimate Tg. Try lowering the prominence parameter or enter Tg manually.") - - # Pick the most prominent (highest tan delta) peak - peak_idx = peaks[np.argmax(df["tan_delta"].iloc[peaks])] - if domain == "temperature": - Tg = df.loc[peak_idx, "Temperature"] - elif domain == "frequency": - Tg = df.loc[peak_idx, "Frequency"] - tan_delta_peak = df.loc[peak_idx, "tan_delta"] - return Tg - -def estimate_TL(uploadData, domain): - # df = uploadData.copy() - df = pd.DataFrame(uploadData) - # --- Use scipy.signal.find_peaks for robust peak detection on loss curve--- - # peaks, properties = find_peaks(df["E''"], prominence=0.01) # adjust 'prominence' if needed - peaks, properties = find_peaks(df["E Loss"], prominence=0.01) # adjust 'prominence' if needed - - if len(peaks) == 0: - raise ValueError("No Loss peaks found to estimate TL. Try lowering the prominence parameter or enter TL manually.") - - # Pick the most prominent (highest tan delta) peak - # peak_idx = peaks[np.argmax(df["E''"].iloc[peaks])] - peak_idx = peaks[np.argmax(df["E Loss"].iloc[peaks])] - if domain == "temperature": - TL = df.loc[peak_idx, "Temperature"] - elif domain == "frequency": - TL = df.loc[peak_idx, "Frequency"] - # loss_peak = df.loc[peak_idx, "E''"] - loss_peak = df.loc[peak_idx, "E Loss"] - return TL diff --git a/services/app/dynamfit/routes.py b/services/app/dynamfit/routes.py index c0853a23a..e9e6c2fcb 100644 --- a/services/app/dynamfit/routes.py +++ b/services/app/dynamfit/routes.py @@ -1,72 +1,32 @@ -# WIP -from flask import request, Blueprint, jsonify, Response +import os import json import datetime -from app.dynamfit.dynamfit2 import update_line_chart, check_file_exists, estimate_shift_model_parameters + +from flask import request, Blueprint, jsonify, Response + +from app.dynamfit.dynamfit2 import ( + update_line_chart, argmax_peak, + UNIVERSAL_WLF_C1, UNIVERSAL_WLF_C2, +) from app.utils.util import token_required, upload_init, request_logger, log_errors +from app.config import Config dynamfit = Blueprint("dynamfit", __name__, url_prefix="/dynamfit") -# NEW -# estimate = Blueprint("estimate", __name__, url_prefix="/dynamfit/estimate") - -# ISSUE: TL and Tg estimators use data from the upload to perform estimation. extract route both extracts and plots. Should we still do this first? -# Current soln is to import again (Messy and slow) -# Still need to predict the shift factors. do we do this in estimate call, or on its own? -# @dynamfit.route('/estimate/', methods=['POST']) -# @log_errors -# @request_logger -# @token_required -# def estimate_fit_values(request_id): -# try: -# start_time = datetime.datetime.now() -# # Read inputs to API call -# input_data = request.get_json() -# file_name = input_data.get('file_name') -# shift_model = input_data.get('model', 'hybrid') - -# if not check_file_exists(file_name): -# return jsonify({'message': f"File '{file_name}' not found"}), 404 - -# if shift_model not in ['WLF', 'hybrid']: -# return jsonify({'message': 'The shift factor model must be one of WLF, hybrid'}), 400 - -# if not file_name or file_name == '': -# return jsonify({'message': 'No file name provided'}) - -# # Import the Data -# uploadData = upload_init(file_name) -# # Perform estimation -# C1, C2, Tg, Ea, TL = estimate_shift_model_parameters(uploadData, shift_model) - -# # Constructing the data dictionary -# data = { -# "multi": True, -# "response": { -# "C1": C1, -# "C2": C2, -# "Tg": Tg, -# "Ea": Ea, -# "TL": TL, -# } -# } -# end_time = datetime.datetime.now() -# latency = f"{((end_time - start_time)).total_seconds()} seconds" - -# # Manually serialize JSON to ensure order is maintained -# json_data = json.dumps(data, indent=4) # data is your dictionary - -# # Create a Flask response -# response = Response(json_data, content_type='application/json; charset=utf-8', status=200) -# response.headers['startTime'] = start_time -# response.headers['endTime'] = end_time -# response.headers['latency'] = str(latency) -# response.headers['responseId'] = request_id -# return response -# except ValueError as ve: -# return jsonify({'message': str(ve)}), 400 -# except Exception as e: -# return jsonify({'message': str(e)}), 500 + +def check_file_exists(file_name): + """ + Checks if a file exists. + + Args: + file_name (str): The name of the file to check. + + Returns: + bool: True if the file exists, False otherwise. + """ + file_path = os.path.join(Config.FILES_DIRECTORY, file_name) + return os.path.exists(file_path) + @dynamfit.route('/extract/', methods=['POST']) @log_errors @@ -108,86 +68,61 @@ def extract_data_from_file(request_id): # add manual shift factor upload shift_file_name = data.get('shift_file_name', None) - if not check_file_exists(file_name): + if not file_name: + return jsonify({'message': 'No file name provided'}), 400 + + if not check_file_exists(file_name): return jsonify({'message': f"File '{file_name}' not found"}), 404 - - # if not check_file_exists(shift_file_name): - # return jsonify({'message': f"Shift file '{shift_file_name}' not found"}), 404 if number_of_prony not in range(1, 101) or not isinstance(number_of_prony, int): return jsonify({'message': 'The number of prony must be between 1 and 100'}), 400 - + if smoothness < 0: return jsonify({'message': 'The smoothness must be non-negative'}), 400 - + if fit_settings not in [True, False]: return jsonify({'message': 'The fit settings must be either True or False'}), 400 - - if not file_name or file_name == '': - return jsonify({'message': 'No file name provided'}) if shift_model not in ['WLF', 'hybrid', 'manual']: return jsonify({'message': 'The shift factor model must be one of WLF, hybrid, manual'}), 400 - print("before upload_init") uploadData = upload_init(file_name, domain) - print("after upload_init") - # add a function for handling shift data: - # shiftData = upload_shift_init(shift_file_name) - # Check if the file content is empty if not uploadData: return jsonify({'message': f"File '{file_name}' is empty"}), 400 - - # Print uploadData for debugging - # print("Upload Data:", uploadData) - - print("before shift_upload_init") - shiftData = upload_init(shift_file_name, 'shift') - print("after shift_upload_init") - # add a function for handling shift data: - # shiftData = upload_shift_init(shift_file_name) - # Check if the file content is empty - # if not shiftData: - # return jsonify({'message': f"Shift file '{shift_file_name}' is empty"}), 400 - # check that shift data and uploadData lengths align align - if shiftData and (len(uploadData) != len(shiftData)): - return jsonify({'message': f"Shift file and Upload File must have the same number of rows."}), 400 - - # Print shiftData for debugging - # print("Shift Upload Data:", shiftData) - # # Assuming the update_line_chart function returns values in a specific order - # result = update_line_chart(uploadData, number_of_prony, model, fit_settings, domain) - + if shift_file_name: + shiftData = upload_init(shift_file_name, 'shift') + else: + shiftData = None + # Row-count alignment between uploadData and shiftData is verified + # inside tts_temperature_to_frequency_V2, which raises ValueError → 400 + # if they differ. + # Perform shift variable estimation - estimate_bools = dict(Tg_estimate=Tg_estimate, C1_estimate=C1_estimate, C2_estimate=C2_estimate, Ea_estimate=Ea_estimate, TL_estimate=TL_estimate, domain=domain) - print("before estimate_shift_model") - C1_est, C2_est, Tg_est, Ea_est, TL_est = estimate_shift_model_parameters(uploadData, shift_model, **estimate_bools) - print("after estimate_shift_model") - - # Assign the new values for each variable based on the boolean switches: - Tg = Tg_est if Tg_estimate else Tg - C1 = C1_est if C1_estimate else C1 - C2 = C2_est if C2_estimate else C2 - Ea = Ea_est if Ea_estimate else Ea - TL = TL_est if TL_estimate else TL + + # "Universal" Estimations for C1, C2 + if C1_estimate: + C1 = UNIVERSAL_WLF_C1 + if C2_estimate: + C2 = UNIVERSAL_WLF_C2 + if Tg_estimate or TL_estimate: + domain_col = {"temperature": "Temperature", "frequency": "Frequency"}[domain] + # Tg from the tan-δ peak + if Tg_estimate: + tan_delta = (uploadData["E Loss"] / uploadData["E Storage"]) + Tg = uploadData[domain_col][argmax_peak(tan_delta)] + # TL from the E_loss peak + if TL_estimate: + TL = uploadData[domain_col][argmax_peak(uploadData["E Loss"])] + # Use "generic" Ea for thermoplastic elastomers + if Ea_estimate: + Ea = 200 # kJ/mol shift_params = dict(Tg=Tg, C1=C1, C2=C2, Ea=Ea, TL=TL, shift_model=shift_model, shiftData=shiftData) # Assuming the update_line_chart function returns values in a specific order - print("before update_line_chart") result = update_line_chart(uploadData, number_of_prony, smoothness, fit_settings, domain, **shift_params) - print("after update_line_chart") - # # Note: add shift factor prediction here - # # Prerequisite boolean detection - # # Only predict the shift factors if this statement resolves to true - # b = (Tg and C1 and C2 and (shift_model is "WLF")) or (Tg and C1 and C2 and TL and Ea and (shift_model is "hybrid")) or (shiftData) - # if b: - # # perform TTSP shifting - # # Note: add TTSP here (separate from update_line_chart) - # # Inputs: domain, shift_model, Tg, C1, C2, TL, Ea) - # print("Remove This Temporary Print. Here for python correctness only") # Unpacking values into a dictionary chart_data = { @@ -236,4 +171,9 @@ def extract_data_from_file(request_id): except ValueError as ve: return jsonify({'message': str(ve)}), 400 except Exception as e: + # XXX: leaks internal error text to clients — raw exception messages + # (including AssertionError contract-violation strings naming server-bug + # details) reach the response body. Should log+correlate via request_id + # and return a generic "Internal server error" message. Needs upstream + # decision on logging infra before changing. return jsonify({'message': str(e)}), 500 diff --git a/services/app/utils/util.py b/services/app/utils/util.py index 14f490981..218a08e44 100644 --- a/services/app/utils/util.py +++ b/services/app/utils/util.py @@ -10,7 +10,6 @@ import jwt # type: ignore from datetime import datetime, timedelta, timezone import functools -import jwt # type: ignore import uuid @@ -32,79 +31,92 @@ def filter_none(**kwargs): return {k: v for k, v in kwargs.items() if v is None} -def check_extension(filename): +@log_errors +def upload_init(file_name, domain): """ - Check if the given filename has a valid extension. + Read a tabular data file and return its columns as a dict of 1-D float arrays. + + Reads CSV (.csv), TSV (.tsv), or whitespace-separated (.txt) files, + skipping non-numeric leading rows (headers/comments). Rows are sorted + ascending by the first column before return. Parameters: - filename (str): The name of the file to check. + file_name (str): Filename to load, relative to Config.FILES_DIRECTORY. + domain (str): Domain tag controlling expected column count and the + keys of the returned dict. One of: + 'frequency' → 3 cols: ['Frequency', 'E Storage', 'E Loss'] + 'temperature' → 3 cols: ['Temperature', 'E Storage', 'E Loss'] + 'shift' → 2 cols: ['Temperature', 'a_T'] Returns: - bool: True if the filename has a valid extension, False otherwise. - """ - print("filename", filename) - return '.' in filename and filename.rsplit('.', 1)[1].lower() in Config.ALLOWED_EXTENSIONS + dict[str, np.ndarray]: Column-name → 1-D float ndarray (all of equal + length), in the column order listed above for the chosen domain. -@log_errors -def upload_init(file_name, domain): - """ - Uploads a file and returns its content as a dictionary. - - Args: - file_name (str): The name of the file to be uploaded. - domain (str): The domain of data to interpret columns. - - Returns: - dict: The content of the file as a dictionary. - Raises: - ValueError: If the file extension is not supported. - ValueError: If the file is empty. - ValueError: If there is an error parsing the file content. + ValueError: If domain is unrecognized, the file extension is not + supported, the file is missing or empty, no numeric rows are + found, the file has the wrong number of columns for the chosen + domain, or the file's numeric contents cannot be parsed as floats. """ + columns_by_domain = { + 'frequency': ['Frequency', 'E Storage', 'E Loss'], + 'temperature': ['Temperature', 'E Storage', 'E Loss'], + 'shift': ['Temperature', 'a_T'], + } + if domain not in columns_by_domain: + raise ValueError( + f"Unknown domain {domain!r}. Expected one of {list(columns_by_domain)}." + ) + columns = columns_by_domain[domain] + + extension = os.path.splitext(file_name)[1].lower() + if extension == '.csv': + delimiter = ',' + elif extension in ('.tsv', '.txt'): + delimiter = '\t' + else: + raise ValueError( + f"Unsupported file extension {extension!r}. Use .csv, .tsv, or .txt." + ) + + file_path = os.path.join(Config.FILES_DIRECTORY, file_name) + with open(file_path, 'r') as f: + csvlines = f.readlines() + + if not csvlines: + raise ValueError(f"File {file_name} is empty.") + + valid_start_index = None + for i, row in enumerate(csvlines): + if is_numeric_row(row.strip().split(delimiter)): + valid_start_index = i + break + if valid_start_index is None: + raise ValueError(f"No numeric rows found in {file_name}.") + try: - file_path = os.path.join(Config.FILES_DIRECTORY, file_name) - extension = os.path.splitext(file_name)[1].lower() # Get the file extension + data = np.loadtxt( + csvlines, delimiter=delimiter, skiprows=valid_start_index, + dtype=float, unpack=True, + ) + except ValueError as e: + raise ValueError(f"Could not parse numeric data in {file_name}: {e}") - if extension == '.csv': - delimiter = ',' - elif extension == '.tsv' or extension == '.txt': - delimiter = '\t' - else: - raise ValueError("Unsupported file extension") - - with open(file_path, 'r') as f: - csvlines = f.readlines() - - if not csvlines: - raise ValueError('File is empty') - # Find the first numeric row index - valid_start_index = None - for i, row in enumerate(csvlines): - if is_numeric_row(row.strip().split(delimiter)): - valid_start_index = i - break - if valid_start_index is None: - raise ValueError("No valid numeric rows found in the file") - - # Parse valid rows only - data = np.loadtxt(csvlines, delimiter=delimiter, skiprows=valid_start_index, dtype=float, unpack=True) - sortind = np.argsort(data[0]) - data = data[:, sortind] - - # Name columns - if domain == 'frequency': - columns =['Frequency', 'E Storage', 'E Loss'] - elif domain == 'temperature': - columns =['Temperature', 'E Storage', 'E Loss'] - elif domain == 'shift': - columns = ['Temperature', 'a_T'] - else: - raise AssertionError("Unknown domain:", domain) + # np.loadtxt with unpack=True returns 1-D for a single-column file; reshape + # so data.shape[0] is always the column count. + if data.ndim == 1: + data = data.reshape(1, -1) + + if data.shape[0] != len(columns): + raise ValueError( + f"Expected {len(columns)} columns for {domain} domain " + f"({', '.join(columns)}), but {file_name} has {data.shape[0]}." + ) + + sortind = np.argsort(data[0]) + data = data[:, sortind] - return dict(zip(columns, data)) - except Exception as pe: - raise ValueError("Failed to parse file content") + return dict(zip(columns, data)) # Decorator def token_required(f): @@ -200,7 +212,7 @@ def decorated_function(*args, **kwargs): if response: app.logger.info(f"[END]: Request Successful. Exiting {func.__name__} function at {end_time}. Execution time: {execution_time} miliseconds. Response sent.") else: - app.logger.error(f"[INTERNAL ERROR]: Error in {func.__name__}. Response: {response}, Status code: {response.status_code}. Exiting at {end_time}. Execution time: {execution_time} miliseconds.") + app.logger.error(f"[INTERNAL ERROR]: Error in {func.__name__}. Response: {response!r}. Exiting at {end_time}. Execution time: {execution_time} miliseconds.") return response return decorated_function diff --git a/services/tests/dynamfit/__init__.py b/services/tests/dynamfit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/services/tests/dynamfit/test_dynamfit2.py b/services/tests/dynamfit/test_dynamfit2.py new file mode 100644 index 000000000..5a42ae3dc --- /dev/null +++ b/services/tests/dynamfit/test_dynamfit2.py @@ -0,0 +1,1092 @@ +import unittest +import os +os.environ['OPENBLAS_NUM_THREADS'] = '1' +import sys +import numpy as np +import pandas as pd + +# Append the directory above 'test' to sys.path to find the 'app' module +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) + +from app.dynamfit.dynamfit2 import ( + prony_basis, + prony_relaxation_space, + compute_complex, + compute_relaxation_modulus, + compute_relaxation_spectrum, + _prony_objective, + smooth_prony_fit, + wlf_shift, + _arr_shift, + hybrid_shift, + inverse_wlf_shift, + tts_temperature_to_frequency_V2, + tts_frequency_to_temperature, + argmax_peak, + update_line_chart, +) +from app.config import Config +from app.utils.util import upload_init + + +TAU = np.array([0.1, 1.0, 10.0]) +# Coefficient magnitudes ~exp(7) ≈ 1097, the solver's default x0, so the +# round-trip recovery test in TestSmoothPronyFit converges from x0. +VISCOUS_E = np.array([1000.0, 2000.0, 3000.0]) +SOLID_E = np.array([500.0, 1000.0, 2000.0, 3000.0]) # one extra leading equilibrium term + + +class TestPronyBasis(unittest.TestCase): + def test_shape_solid_false(self): + freq = np.array([1.0, 2.0, 3.0]) + tau = np.array([0.1, 1.0]) + result = prony_basis(freq, tau, solid=False) + self.assertEqual(result.shape, (6, 2)) + + def test_shape_solid_true(self): + freq = np.array([1.0, 2.0, 3.0]) + tau = np.array([0.1, 1.0]) + result = prony_basis(freq, tau, solid=True) + self.assertEqual(result.shape, (6, 3)) + + def test_values_at_omega_tau_unity(self): + # ωτ = 1 → storage = loss = 1/2 exactly. + freq = np.array([1.0]) + tau = np.array([1.0]) + result = prony_basis(freq, tau, solid=False) + self.assertEqual(result.tolist(), [[0.5], [0.5]]) + + def test_values_at_zero_frequency(self): + # ω = 0 → storage = loss = 0 exactly. + freq = np.array([0.0, 0.0]) + tau = np.array([1.0, 2.0]) + result = prony_basis(freq, tau, solid=False) + np.testing.assert_array_equal(result, np.zeros((4, 2))) + + def test_solid_prepends_one_and_zero_columns(self): + freq = np.array([2.0, 3.0]) + tau = np.array([1.0, 5.0]) + result = prony_basis(freq, tau, solid=True) + # Top half (storage): first column is ones. + np.testing.assert_array_equal(result[:2, 0], np.ones(2)) + # Bottom half (loss): first column is zeros. + np.testing.assert_array_equal(result[2:, 0], np.zeros(2)) + + def test_rejects_non_ndarray_freq(self): + with self.assertRaises(AssertionError): + prony_basis([1.0, 2.0], np.array([1.0]), solid=False) + + def test_rejects_non_ndarray_relaxations(self): + with self.assertRaises(AssertionError): + prony_basis(np.array([1.0]), [1.0], solid=False) + + def test_rejects_2d_freq(self): + with self.assertRaises(AssertionError): + prony_basis(np.array([[1.0], [2.0]]), np.array([1.0]), solid=False) + + def test_rejects_2d_relaxations(self): + with self.assertRaises(AssertionError): + prony_basis(np.array([1.0]), np.array([[1.0]]), solid=False) + + +class TestPronyRelaxationSpace(unittest.TestCase): + def test_length(self): + result = prony_relaxation_space(1e-3, 1e3, 7) + self.assertEqual(len(result), 7) + + def test_endpoints(self): + result = prony_relaxation_space(1e-3, 1e3, 5) + np.testing.assert_allclose(result[0], 1e-3) + np.testing.assert_allclose(result[-1], 1e3) + + +class TestComputeComplex(unittest.TestCase): + def test_shape_and_columns_viscous(self): + result = compute_complex(TAU, VISCOUS_E) + self.assertIsInstance(result, pd.DataFrame) + self.assertEqual(len(result), 1000) + self.assertListEqual(list(result.columns), ['Frequency', 'E Storage', 'E Loss']) + + def test_shape_and_columns_solid(self): + result = compute_complex(TAU, SOLID_E) + self.assertIsInstance(result, pd.DataFrame) + self.assertEqual(len(result), 1000) + self.assertListEqual(list(result.columns), ['Frequency', 'E Storage', 'E Loss']) + + def test_num_pts_kwarg(self): + result = compute_complex(TAU, VISCOUS_E, num_pts=50) + self.assertEqual(len(result), 50) + + def test_storage_modulus_monotonic_in_frequency(self): + # Positive E_i, no equilibrium term → storage modulus is non-decreasing in ω. + result = compute_complex(TAU, VISCOUS_E) + self.assertTrue(np.all(np.diff(result['E Storage'].values) >= -1e-12)) + + def test_rejects_non_ndarray_tau(self): + with self.assertRaises(AssertionError): + compute_complex([0.1, 1.0, 10.0], VISCOUS_E) + + def test_rejects_non_ndarray_E(self): + with self.assertRaises(AssertionError): + compute_complex(TAU, [1.0, 2.0, 3.0]) + + def test_rejects_2d_tau(self): + with self.assertRaises(AssertionError): + compute_complex(TAU.reshape(1, -1), VISCOUS_E) + + def test_rejects_2d_E(self): + with self.assertRaises(AssertionError): + compute_complex(TAU, VISCOUS_E.reshape(1, -1)) + + +class TestComputeRelaxationModulus(unittest.TestCase): + def test_shape_and_columns_viscous(self): + result = compute_relaxation_modulus(TAU, VISCOUS_E) + self.assertIsInstance(result, pd.DataFrame) + self.assertEqual(len(result), 1000) + self.assertListEqual(list(result.columns), ['Time', 'E']) + + def test_shape_and_columns_solid(self): + result = compute_relaxation_modulus(TAU, SOLID_E) + self.assertEqual(len(result), 1000) + self.assertListEqual(list(result.columns), ['Time', 'E']) + + def test_num_pts_kwarg(self): + result = compute_relaxation_modulus(TAU, VISCOUS_E, num_pts=50) + self.assertEqual(len(result), 50) + + def test_modulus_decreasing_in_time(self): + # Positive E_i, equilibrium term excluded → E(t) is non-increasing in t. + result = compute_relaxation_modulus(TAU, VISCOUS_E) + self.assertTrue(np.all(np.diff(result['E'].values) <= 1e-12)) + + def test_rejects_non_ndarray_tau(self): + with self.assertRaises(AssertionError): + compute_relaxation_modulus([0.1, 1.0, 10.0], VISCOUS_E) + + def test_rejects_non_ndarray_E(self): + with self.assertRaises(AssertionError): + compute_relaxation_modulus(TAU, [1.0, 2.0, 3.0]) + + def test_rejects_2d_tau(self): + with self.assertRaises(AssertionError): + compute_relaxation_modulus(TAU.reshape(1, -1), VISCOUS_E) + + def test_rejects_2d_E(self): + with self.assertRaises(AssertionError): + compute_relaxation_modulus(TAU, VISCOUS_E.reshape(1, -1)) + + +class TestComputeRelaxationSpectrum(unittest.TestCase): + def test_shape_and_columns_viscous(self): + result = compute_relaxation_spectrum(TAU, VISCOUS_E) + self.assertIsInstance(result, pd.DataFrame) + self.assertEqual(len(result), 1000) + self.assertListEqual(list(result.columns), ['Time', 'H']) + + def test_shape_and_columns_solid(self): + result = compute_relaxation_spectrum(TAU, SOLID_E) + self.assertEqual(len(result), 1000) + self.assertListEqual(list(result.columns), ['Time', 'H']) + + def test_num_pts_kwarg(self): + result = compute_relaxation_spectrum(TAU, VISCOUS_E, num_pts=50) + self.assertEqual(len(result), 50) + + def test_rejects_non_ndarray_tau(self): + with self.assertRaises(AssertionError): + compute_relaxation_spectrum([0.1, 1.0, 10.0], VISCOUS_E) + + def test_rejects_non_ndarray_E(self): + with self.assertRaises(AssertionError): + compute_relaxation_spectrum(TAU, [1.0, 2.0, 3.0]) + + def test_rejects_2d_tau(self): + with self.assertRaises(AssertionError): + compute_relaxation_spectrum(TAU.reshape(1, -1), VISCOUS_E) + + def test_rejects_2d_E(self): + with self.assertRaises(AssertionError): + compute_relaxation_spectrum(TAU, VISCOUS_E.reshape(1, -1)) + + +class TestPronyObjective(unittest.TestCase): + def setUp(self): + # Small viscous problem: 10 frequencies, 3 relaxation times. + self.omega = np.logspace(np.log10(0.5), np.log10(2.0), 10) + self.tau_i = np.array([0.1, 1.0, 10.0]) + self.basis = prony_basis(self.omega, self.tau_i, solid=False) + self.logcoefs = np.log(np.array([1.0, 2.0, 3.0])) + # Construct data so that the model fits exactly at self.logcoefs. + self.data = self.basis @ np.exp(self.logcoefs) + self.std = np.ones_like(self.data) + + def test_returns_scalar_loss_and_gradient_shape(self): + loss, grad = _prony_objective( + self.logcoefs, self.data, self.std, self.basis, + smoothness=0.0, solid=False, + ) + self.assertTrue(np.isscalar(loss) or np.ndim(loss) == 0) + self.assertEqual(grad.shape, self.logcoefs.shape) + + def test_exact_fit_zero_loss_and_zero_gradient(self): + loss, grad = _prony_objective( + self.logcoefs, self.data, self.std, self.basis, + smoothness=0.0, solid=False, + ) + self.assertEqual(loss, 0.0) + np.testing.assert_array_equal(grad, np.zeros_like(grad)) + + def test_smoothness_penalty_increases_loss(self): + # Use non-smooth (zig-zag) logcoefs so the second-difference is nonzero. + logcoefs = np.array([0.0, 5.0, 0.0, 5.0, 0.0]) + tau_i = prony_relaxation_space(0.1, 10.0, 5) + basis = prony_basis(self.omega, tau_i, solid=False) + data = np.zeros(basis.shape[0]) + std = np.ones_like(data) + loss_unsmoothed, _ = _prony_objective( + logcoefs, data, std, basis, smoothness=0.0, solid=False, + ) + loss_smoothed, _ = _prony_objective( + logcoefs, data, std, basis, smoothness=1.0, solid=False, + ) + self.assertGreater(loss_smoothed, loss_unsmoothed) + + +class TestSmoothPronyFit(unittest.TestCase): + def setUp(self): + # Small problem so the fit runs quickly; synthesize from a known Prony series. + df = compute_complex(TAU, VISCOUS_E, num_pts=25) + self.omega = df['Frequency'].values + self.E_stor = df['E Storage'].values + self.E_loss = df['E Loss'].values + + def test_shape_viscous(self): + tau_i, E_i = smooth_prony_fit( + self.omega, self.E_stor, self.E_loss, N=5, + smoothness=1.0, solid=False, + ) + self.assertEqual(tau_i.shape, (5,)) + self.assertEqual(E_i.shape, (5,)) + + def test_shape_solid(self): + tau_i, E_i = smooth_prony_fit( + self.omega, self.E_stor, self.E_loss, N=5, + smoothness=1.0, solid=True, + ) + self.assertEqual(tau_i.shape, (5,)) + self.assertEqual(E_i.shape, (6,)) + + def test_recovers_input_coefficients(self): + # When N matches the source grid size and smoothing is off, the fit + # should recover the input coefficients. + tau_i, E_i = smooth_prony_fit( + self.omega, self.E_stor, self.E_loss, + N=len(TAU), smoothness=0.0, solid=False, + ) + np.testing.assert_allclose(tau_i, TAU) + np.testing.assert_allclose(E_i, VISCOUS_E, rtol=1e-3) + + def test_N_controls_tau_grid_size(self): + tau_i, E_i = smooth_prony_fit( + self.omega, self.E_stor, self.E_loss, N=8, + smoothness=1.0, solid=False, + ) + self.assertEqual(tau_i.shape, (8,)) + self.assertEqual(E_i.shape, (8,)) + + def test_rejects_non_ndarray_omega(self): + with self.assertRaises(AssertionError): + smooth_prony_fit( + list(self.omega), self.E_stor, self.E_loss, N=5, + smoothness=1.0, solid=False, + ) + + def test_rejects_non_ndarray_E_stor(self): + with self.assertRaises(AssertionError): + smooth_prony_fit( + self.omega, list(self.E_stor), self.E_loss, N=5, + smoothness=1.0, solid=False, + ) + + def test_rejects_non_ndarray_E_loss(self): + with self.assertRaises(AssertionError): + smooth_prony_fit( + self.omega, self.E_stor, list(self.E_loss), N=5, + smoothness=1.0, solid=False, + ) + + def test_rejects_2d_omega(self): + with self.assertRaises(AssertionError): + smooth_prony_fit( + self.omega.reshape(1, -1), self.E_stor, self.E_loss, N=5, + smoothness=1.0, solid=False, + ) + + def test_rejects_2d_E_stor(self): + with self.assertRaises(AssertionError): + smooth_prony_fit( + self.omega, self.E_stor.reshape(1, -1), self.E_loss, N=5, + smoothness=1.0, solid=False, + ) + + def test_rejects_2d_E_loss(self): + with self.assertRaises(AssertionError): + smooth_prony_fit( + self.omega, self.E_stor, self.E_loss.reshape(1, -1), N=5, + smoothness=1.0, solid=False, + ) + + def test_rejects_length_mismatch(self): + with self.assertRaises(AssertionError): + smooth_prony_fit( + self.omega, self.E_stor[:-1], self.E_loss, N=5, + smoothness=1.0, solid=False, + ) + + def test_handles_data_scale_orders_of_magnitude(self): + # Regression: a constant initial guess (x0 = 7 → E_i ≈ 1097 each) sent + # BFGS' first line search into logcoefs > 700 whenever the data + # magnitude was many orders of magnitude away from ~1e3, and exp() + # overflowed to inf. The data-scaled x0 keeps the initial step inside + # float64 range regardless of dataset scale. + # Use a peak-shaped Prony series over 16 decades of tau — closer to + # real master-curve structure than the narrow TAU/VISCOUS_E fixture, + # which doesn't trip the bug. + tau = np.logspace(-8.0, 8.0, 17) + E_shape = np.exp(-(np.log10(tau)) ** 2 / 4.0) # peaked at tau=1 + for scale in [1e3, 1e6, 1e9, 1e12]: + E_input = np.concatenate(([0.1 * scale], E_shape * scale)) + df = compute_complex(tau, E_input, num_pts=400) + omega = df['Frequency'].to_numpy() + E_stor = df['E Storage'].to_numpy() + E_loss = df['E Loss'].to_numpy() + for N in [10, 20, 50]: + with self.subTest(scale=scale, N=N): + _, E_i = smooth_prony_fit( + omega, E_stor, E_loss, N=N, + smoothness=0.0, solid=True, + ) + self.assertTrue( + np.all(np.isfinite(E_i)), + msg=f'non-finite E_i at scale={scale:g}, N={N}: {E_i}', + ) + + +class TestWlfShift(unittest.TestCase): + def test_identity_at_T_ref(self): + # T == T_ref → a_T == 1.0 exactly. + T = np.array([100.0]) + result = wlf_shift(T, T_ref=100.0, C1=17.44, C2=51.6) + np.testing.assert_array_equal(result, np.array([1.0])) + + def test_known_value(self): + # T - T_ref = C2 → denom = 2*C2, exponent = -C1/2. + # With C1=2 and C2=10 → a_T = 10^-1 = 0.1. + T = np.array([20.0]) + result = wlf_shift(T, T_ref=10.0, C1=2.0, C2=10.0) + np.testing.assert_allclose(result, np.array([0.1])) + + def test_array_in_array_out(self): + T = np.array([100.0, 110.0, 120.0]) + result = wlf_shift(T, T_ref=100.0, C1=17.44, C2=51.6) + self.assertIsInstance(result, np.ndarray) + self.assertEqual(result.shape, (3,)) + + def test_raises_on_singularity(self): + # T - T_ref = -C2 → denom = 0. + T = np.array([0.0]) + with self.assertRaises(ValueError): + wlf_shift(T, T_ref=51.6, C1=17.44, C2=51.6) + + def test_raises_on_array_with_singularity(self): + # One element hits the singularity. + T = np.array([100.0, 0.0, 120.0]) + with self.assertRaises(ValueError): + wlf_shift(T, T_ref=51.6, C1=17.44, C2=51.6) + + def test_accepts_scalar(self): + # Scalar input is promoted to length-1 ndarray output. + result = wlf_shift(100.0, T_ref=100.0, C1=17.44, C2=51.6) + self.assertIsInstance(result, np.ndarray) + self.assertEqual(result.shape, (1,)) + np.testing.assert_array_equal(result, np.array([1.0])) + + def test_rejects_2d_ndarray(self): + with self.assertRaises(AssertionError): + wlf_shift(np.array([[100.0, 110.0]]), T_ref=100.0, C1=17.44, C2=51.6) + + +class TestArrShift(unittest.TestCase): + def test_identity_at_T_ref(self): + # T == T_ref → a_T == 1.0 exactly. + T = np.array([25.0]) + result = _arr_shift(T, T_ref=25.0, Ea=50.0) + np.testing.assert_array_equal(result, np.array([1.0])) + + def test_direction(self): + # With Ea > 0, T > T_ref → a_T < 1. + T = np.array([50.0]) + result = _arr_shift(T, T_ref=25.0, Ea=50.0) + self.assertLess(result[0], 1.0) + + def test_raises_at_absolute_zero(self): + T = np.array([-273.15]) + with self.assertRaises(ValueError): + _arr_shift(T, T_ref=25.0, Ea=50.0) + + +class TestHybridShift(unittest.TestCase): + T_REF = 25.0 + C1 = 17.44 + C2 = 51.6 + EA = 50.0 # kJ/mol + + def test_ascending_mixed(self): + # Crosses T_ref; result preserves ascending order, monotone-decreasing in T. + T = np.array([10.0, 20.0, 30.0, 40.0]) + result = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + self.assertEqual(result.shape, T.shape) + self.assertTrue(np.all(np.diff(result) <= 0)) + + def test_descending_mixed(self): + # Descending T → a_T monotonically increasing along the input. + T = np.array([40.0, 30.0, 20.0, 10.0]) + result = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + self.assertEqual(result.shape, T.shape) + self.assertTrue(np.all(np.diff(result) >= 0)) + + def test_ascending_and_descending_consistent(self): + # The same temperatures in opposite order should produce the same a_T + # values, just reversed. + T_asc = np.array([10.0, 20.0, 30.0, 40.0]) + T_desc = T_asc[::-1] + result_asc = hybrid_shift(T_asc, self.T_REF, self.C1, self.C2, self.EA) + result_desc = hybrid_shift(T_desc, self.T_REF, self.C1, self.C2, self.EA) + np.testing.assert_allclose(result_desc, result_asc[::-1]) + + def test_all_below_T_ref(self): + # All Arrhenius; final element at T == T_ref gives a_T == 1. + T = np.array([10.0, 20.0, 25.0]) + result = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + self.assertEqual(result.shape, T.shape) + np.testing.assert_allclose(result[-1], 1.0) + + def test_all_above_T_ref(self): + # All WLF; with positive C1, a_T < 1 throughout. + T = np.array([30.0, 40.0, 50.0]) + result = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + self.assertEqual(result.shape, T.shape) + self.assertTrue(np.all(result < 1.0)) + + def test_accepts_scalar_below_T_ref(self): + # Scalar at T_ref → Arrhenius bucket → a_T == 1. + result = hybrid_shift(self.T_REF, self.T_REF, self.C1, self.C2, self.EA) + self.assertIsInstance(result, np.ndarray) + self.assertEqual(result.shape, (1,)) + np.testing.assert_allclose(result, np.array([1.0])) + + def test_accepts_scalar_above_T_ref(self): + # Scalar above T_ref → WLF bucket → a_T < 1. + result = hybrid_shift(self.T_REF + 10.0, self.T_REF, self.C1, self.C2, self.EA) + self.assertEqual(result.shape, (1,)) + self.assertLess(result[0], 1.0) + + def test_accepts_single_element_array(self): + T = np.array([self.T_REF + 10.0]) + result = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + self.assertEqual(result.shape, (1,)) + + def test_rejects_2d_ndarray(self): + with self.assertRaises(AssertionError): + hybrid_shift( + np.array([[10.0, 20.0]]), + self.T_REF, self.C1, self.C2, self.EA, + ) + + def test_rejects_non_finite(self): + T = np.array([10.0, np.nan, 30.0]) + with self.assertRaises(AssertionError): + hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + + def test_rejects_unsorted(self): + T = np.array([10.0, 30.0, 20.0, 40.0]) + with self.assertRaises(AssertionError): + hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + + +class TestInverseWlfShift(unittest.TestCase): + T_REF = 100.0 + C1 = 17.44 + C2 = 51.6 + + def test_identity_at_a_T_unity(self): + # log10(1) = 0 → T = T_ref exactly. + result = inverse_wlf_shift(np.array([1.0]), self.T_REF, self.C1, self.C2) + np.testing.assert_array_equal(result, np.array([self.T_REF])) + + def test_round_trip_with_wlf_shift(self): + # T → a_T → T should recover the original temperatures. + T = np.array([110.0, 120.0, 150.0, 200.0]) + a_T = wlf_shift(T, self.T_REF, self.C1, self.C2) + T_back = inverse_wlf_shift(a_T, self.T_REF, self.C1, self.C2) + np.testing.assert_allclose(T_back, T) + + def test_accepts_scalar(self): + result = inverse_wlf_shift(1.0, self.T_REF, self.C1, self.C2) + self.assertIsInstance(result, np.ndarray) + self.assertEqual(result.shape, (1,)) + + def test_rejects_2d_ndarray(self): + with self.assertRaises(AssertionError): + inverse_wlf_shift(np.array([[1.0, 0.5]]), self.T_REF, self.C1, self.C2) + + def test_raises_on_nonpositive_a_T(self): + with self.assertRaises(ValueError): + inverse_wlf_shift(np.array([0.0]), self.T_REF, self.C1, self.C2) + with self.assertRaises(ValueError): + inverse_wlf_shift(np.array([-1.0]), self.T_REF, self.C1, self.C2) + + +class TestTtsTemperatureToFrequencyV2(unittest.TestCase): + T_REF = 25.0 + C1 = 17.44 + C2 = 51.6 + EA = 50.0 + MASTER_FREQ = 2.5 + + def _input_df(self, T_values): + # Build a temperature sweep whose hybrid TTS shift collapses every row + # to MASTER_FREQ: per-row Frequency = MASTER_FREQ / a_T(T). + T = np.array(T_values, dtype=float) + a_T = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + n = len(T) + return pd.DataFrame({ + 'Temperature': T, + 'Frequency': self.MASTER_FREQ / a_T, + "E'": np.full(n, 100.0), + "E''": np.full(n, 10.0), + }) + + def _params(self, **overrides): + # T_REF stands in for both Tg (WLF reference) and TL (hybrid crossover); + # the fixture data round-trips for either interpretation. + kwargs = dict(Tg=self.T_REF, TL=self.T_REF, + C1=self.C1, C2=self.C2, Ea=self.EA) + kwargs.update(overrides) + return kwargs + + def test_output_columns(self): + df = self._input_df([25.0, 30.0, 35.0]) + result = tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) + self.assertListEqual(list(result.columns), ['Frequency', 'Temperature', "E'", "E''"]) + + def test_output_length(self): + df = self._input_df([25.0, 30.0, 35.0]) + result = tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) + self.assertEqual(len(result), len(df)) + + def test_temperature_column_is_T_ref(self): + df = self._input_df([25.0, 30.0, 35.0]) + result = tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) + np.testing.assert_array_equal( + result['Temperature'].values, np.full(len(df), self.T_REF), + ) + + def test_hybrid_round_trip(self): + # Data was built via hybrid_shift → 'hybrid' TTS recovers MASTER_FREQ. + df = self._input_df([20.0, 25.0, 30.0, 35.0]) + result = tts_temperature_to_frequency_V2(df, 'hybrid', **self._params()) + np.testing.assert_allclose(result['Frequency'].values, self.MASTER_FREQ) + + def test_WLF_round_trip_above_T_ref(self): + # All T > T_ref: hybrid_shift == wlf_shift, so 'WLF' TTS also recovers + # MASTER_FREQ on data built via hybrid_shift. + df = self._input_df([30.0, 35.0, 40.0]) + result = tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) + np.testing.assert_allclose(result['Frequency'].values, self.MASTER_FREQ) + + def test_identity_single_row_at_T_ref(self): + # Single row at T_ref → a_T == 1 → output Frequency == input Frequency. + df = self._input_df([self.T_REF]) + result_wlf = tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) + result_hybrid = tts_temperature_to_frequency_V2(df, 'hybrid', **self._params()) + np.testing.assert_allclose(result_wlf['Frequency'].values, df['Frequency'].values) + np.testing.assert_allclose(result_hybrid['Frequency'].values, df['Frequency'].values) + + def test_shiftData_override_uses_supplied_a_T(self): + # When shiftData is provided, it multiplies Frequency directly; + # shift_model parameters are ignored. + df = self._input_df([25.0, 30.0, 35.0]) + shiftData = {'a_T': [0.5, 1.5, 2.0]} + result = tts_temperature_to_frequency_V2( + df, 'WLF', **self._params(shiftData=shiftData), + ) + expected = df['Frequency'].values * np.array([0.5, 1.5, 2.0]) + np.testing.assert_allclose( + np.sort(result['Frequency'].values), np.sort(expected), + ) + + def test_E_columns_preserved(self): + df = self._input_df([25.0, 30.0, 35.0]) + result = tts_temperature_to_frequency_V2(df, 'hybrid', **self._params()) + # E' and E'' are constant 100/10 throughout the fixture; they should + # pass through unchanged regardless of row order. + np.testing.assert_array_equal(result["E'"].values, np.full(3, 100.0)) + np.testing.assert_array_equal(result["E''"].values, np.full(3, 10.0)) + + def test_does_not_mutate_input(self): + df = self._input_df([25.0, 30.0, 35.0]) + original_columns = list(df.columns) + tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) + self.assertListEqual(list(df.columns), original_columns) + + def test_rejects_unknown_shift_model(self): + # Unknown shift_model is a server-bug class: the route's allow-list + # must match this function's, so reaching here with 'bogus' is a + # contract violation rather than user error. + df = self._input_df([25.0, 30.0, 35.0]) + with self.assertRaises(AssertionError): + tts_temperature_to_frequency_V2(df, 'bogus', **self._params()) + + def test_manual_with_shiftData_succeeds(self): + # 'manual' selects the shiftData path; WLF/hybrid params are ignored. + df = self._input_df([25.0, 30.0, 35.0]) + shiftData = {'a_T': [0.5, 1.5, 2.0]} + result = tts_temperature_to_frequency_V2( + df, 'manual', shiftData=shiftData, + ) + expected = df['Frequency'].values * np.array([0.5, 1.5, 2.0]) + np.testing.assert_allclose( + np.sort(result['Frequency'].values), np.sort(expected), + ) + + def test_manual_without_shiftData_raises_value_error(self): + # User picks 'manual' in the UI but forgets to upload the shift file. + df = self._input_df([25.0, 30.0, 35.0]) + with self.assertRaisesRegex(ValueError, 'shift-factor file'): + tts_temperature_to_frequency_V2(df, 'manual', **self._params()) + + def test_output_has_reset_index(self): + df = self._input_df([25.0, 30.0, 35.0]) + result = tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) + self.assertListEqual(list(result.index), list(range(len(result)))) + + def test_missing_frequency_column_assumes_one_hz(self): + # Pure temperature sweep input (no Frequency col) → shifted Frequency == a_T. + T = np.array([20.0, 25.0, 30.0]) + df = pd.DataFrame({ + 'Temperature': T, + "E'": np.full(len(T), 100.0), + "E''": np.full(len(T), 10.0), + }) + result = tts_temperature_to_frequency_V2(df, 'hybrid', **self._params()) + expected_a_T = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + np.testing.assert_allclose( + np.sort(result['Frequency'].values), np.sort(expected_a_T), + ) + + +class TestTtsFrequencyToTemperature(unittest.TestCase): + T_REF = 25.0 + C1 = 17.44 + C2 = 51.6 + OMEGA_REF = 1.0 + + def _input_df(self, T_values): + # Build a freq sweep whose inverse-WLF maps each row back to T in T_values: + # per-row Frequency = OMEGA_REF * a_T(T). + T = np.array(T_values, dtype=float) + a_T = wlf_shift(T, self.T_REF, self.C1, self.C2) + n = len(T) + return pd.DataFrame({ + 'Frequency': self.OMEGA_REF * a_T, + "E'": np.full(n, 100.0), + "E''": np.full(n, 10.0), + }) + + def test_output_columns(self): + df = self._input_df([25.0, 30.0, 35.0]) + result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) + self.assertListEqual(list(result.columns), ['Frequency', 'Temperature', "E'", "E''"]) + + def test_output_length(self): + df = self._input_df([25.0, 30.0, 35.0]) + result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) + self.assertEqual(len(result), len(df)) + + def test_frequency_column_is_omega_ref(self): + df = self._input_df([25.0, 30.0, 35.0]) + result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) + np.testing.assert_array_equal( + result['Frequency'].values, np.full(len(df), self.OMEGA_REF), + ) + + def test_round_trip_temperatures(self): + T_input = [27.0, 30.0, 35.0] + df = self._input_df(T_input) + result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) + np.testing.assert_allclose(result['Temperature'].values, sorted(T_input)) + + def test_identity_at_omega_ref(self): + # Frequency == omega_ref means a_T = 1, so inverse WLF gives T = T_ref. + df = pd.DataFrame({ + 'Frequency': [self.OMEGA_REF], + "E'": [100.0], + "E''": [10.0], + }) + result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) + np.testing.assert_allclose(result['Temperature'].values, [self.T_REF]) + + def test_E_columns_preserved(self): + df = self._input_df([25.0, 30.0, 35.0]) + result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) + np.testing.assert_array_equal(result["E'"].values, np.full(3, 100.0)) + np.testing.assert_array_equal(result["E''"].values, np.full(3, 10.0)) + + def test_does_not_mutate_input(self): + df = self._input_df([25.0, 30.0, 35.0]) + original_columns = list(df.columns) + original_freq = df['Frequency'].to_numpy().copy() + tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) + self.assertListEqual(list(df.columns), original_columns) + np.testing.assert_array_equal(df['Frequency'].values, original_freq) + + def test_output_has_reset_index(self): + df = self._input_df([25.0, 30.0, 35.0]) + result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) + self.assertListEqual(list(result.index), list(range(len(result)))) + + def test_output_sorted_by_temperature(self): + df = self._input_df([35.0, 27.0, 30.0]) # unsorted + result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) + T = result['Temperature'].values + self.assertTrue(np.all(np.diff(T) >= 0)) + + def test_round_trip_with_V2(self): + # Scatter a master curve across temperatures with freq_to_temp, then + # collapse back with V2. Frequencies must match the original master. + master_freqs = np.array([0.1, 1.0, 10.0]) + df_master = pd.DataFrame({ + 'Frequency': master_freqs, + "E'": [100.0, 200.0, 300.0], + "E''": [10.0, 20.0, 30.0], + }) + scattered = tts_frequency_to_temperature( + df_master, self.OMEGA_REF, self.T_REF, self.C1, self.C2, + ) + recovered = tts_temperature_to_frequency_V2( + scattered, 'WLF', Tg=self.T_REF, C1=self.C1, C2=self.C2, + ) + np.testing.assert_allclose( + np.sort(recovered['Frequency'].values), np.sort(master_freqs), + ) + + +class TestArgmaxPeak(unittest.TestCase): + def test_finds_known_peak(self): + # Gaussian centered at x=1.5 on a fine grid → peak index lands on 1.5. + x = np.linspace(-5.0, 5.0, 1001) + signal = np.exp(-(x - 1.5) ** 2) + self.assertAlmostEqual(x[argmax_peak(signal)], 1.5, places=2) + + def test_picks_most_prominent_of_multiple(self): + # Two Gaussians; the taller one should win. + x = np.linspace(-5.0, 5.0, 1001) + signal = np.exp(-(x + 2.0) ** 2) + 2.0 * np.exp(-(x - 2.0) ** 2) + self.assertAlmostEqual(x[argmax_peak(signal)], 2.0, places=2) + + def test_returns_int(self): + x = np.linspace(-5.0, 5.0, 101) + signal = np.exp(-(x - 0.0) ** 2) + self.assertIsInstance(argmax_peak(signal), int) + + def test_raises_when_no_peaks(self): + # Monotonic ramp has no interior peaks. + with self.assertRaises(ValueError): + argmax_peak(np.linspace(0.0, 1.0, 50)) + + def test_rejects_non_ndarray(self): + with self.assertRaises(AssertionError): + argmax_peak([0.0, 1.0, 0.5, 0.0]) + + def test_rejects_2d_ndarray(self): + with self.assertRaises(AssertionError): + argmax_peak(np.zeros((3, 3))) + + +DATA_DIR = os.path.abspath(os.path.join( + os.path.dirname(__file__), '..', '..', 'app', 'dynamfit', 'files', +)) + + +class TestUpdateLineChartFrequency(unittest.TestCase): + """Characterization tests for the frequency-domain branch of update_line_chart.""" + + @classmethod + def setUpClass(cls): + Config.FILES_DIRECTORY = DATA_DIR + cls.uploadData = upload_init( + 'agilus30 (8) master curve 20C clean.txt', 'frequency', + ) + cls.N = 10 + cls.result = update_line_chart( + cls.uploadData, + number_of_prony=cls.N, + smoothness=0.1, + fit_settings=True, + domain='frequency', + ) + + def test_returns_7_tuple(self): + self.assertEqual(len(self.result), 7) + + def test_coef_df_schema(self): + coef_df = self.result[6] + self.assertIsInstance(coef_df, list) + self.assertGreater(len(coef_df), 0) + for row in coef_df: + self.assertSetEqual(set(row.keys()), {'i', 'tau_i', 'E_i'}) + self.assertNotEqual(row['E_i'], 0.0) + # 'i' values are the original DataFrame indices, all nonnegative + self.assertTrue(all(row['i'] >= 0 for row in coef_df)) + + def test_fig1_trace_names(self): + fig1 = self.result[0] + names = [t.name for t in fig1.data] + # Two facets (E Storage, E Loss) × two colors (Experiment + N-Term Prony) + self.assertEqual(names.count('Experiment'), 2) + self.assertEqual(sum(1 for n in names if 'Term Prony' in n), 2) + + def test_fig1_experiment_y_matches_input(self): + # px.line(facet_col='Modulus') splits by Modulus, so the two Experiment + # traces carry the E Storage and E Loss columns from the input. + fig1 = self.result[0] + experiment_y = sorted( + (tuple(t.y) for t in fig1.data if t.name == 'Experiment'), + key=lambda ys: ys[0], + ) + expected = sorted( + (tuple(self.uploadData['E Loss']), tuple(self.uploadData['E Storage'])), + key=lambda ys: ys[0], + ) + for got, want in zip(experiment_y, expected): + np.testing.assert_array_equal(got, want) + + def test_fig2_fig3_trace_counts_with_fit_settings_true(self): + # fit_settings=True → fig2/fig3 are overlay figs (line + basis scatter). + fig2, fig3 = self.result[2], self.result[3] + self.assertEqual(len(fig2.data), 2) + self.assertEqual(len(fig3.data), 2) + names2 = {t.name for t in fig2.data} + names3 = {t.name for t in fig3.data} + self.assertTrue(any('Basis' in n for n in names2)) + self.assertTrue(any('Basis' in n for n in names3)) + + def test_fig4_fig41_have_only_experiment_traces(self): + # In frequency domain, fig4/fig41 visualize the inverse-WLF temperature + # conversion of the input — no Prony fit is overlaid there. + fig4, fig41 = self.result[4], self.result[5] + for fig in (fig4, fig41): + names = {t.name for t in fig.data} + self.assertEqual(names, {'Experiment'}) + + def test_fit_settings_false_drops_basis_overlay(self): + result = update_line_chart( + self.uploadData, number_of_prony=self.N, smoothness=0.1, + fit_settings=False, domain='frequency', + ) + fig2, fig3 = result[2], result[3] + self.assertEqual(len(fig2.data), 1) + self.assertEqual(len(fig3.data), 1) + self.assertNotIn('Basis', {t.name for t in fig2.data}) + self.assertNotIn('Basis', {t.name for t in fig3.data}) + + +class TestUpdateLineChartTemperature(unittest.TestCase): + """Characterization tests for the temperature-domain branches.""" + + T_REF = 25.0 + C1 = 17.44 + C2 = 51.6 + EA = 200.0 + + @classmethod + def setUpClass(cls): + Config.FILES_DIRECTORY = DATA_DIR + # Real temperature ramp for the early-exit path. + cls.real_temp_data = upload_init( + 'agilus30 (8) Temperature Ramp clean.txt', 'temperature', + ) + # Synthetic temperature ramp chosen so WLF and hybrid shifts both stay + # well-defined (T spans both sides of T_ref, well clear of the + # WLF C2 singularity at T_ref - C2). + T = np.linspace(0.0, 80.0, 30) + cls.synthetic_temp_data = { + 'Temperature': T, + 'E Storage': np.linspace(1000.0, 10.0, len(T)), + 'E Loss': np.full(len(T), 50.0), + } + + def test_early_exit_with_no_shift_params(self): + fig1, fig11, fig2, fig3, fig4, fig41, coef_df = update_line_chart( + self.real_temp_data, number_of_prony=10, smoothness=0.1, + fit_settings=True, domain='temperature', + ) + for empty in (fig1, fig11, fig2, fig3): + self.assertEqual(len(empty.data), 0) + self.assertEqual(coef_df, []) + self.assertGreater(len(fig4.data), 0) + self.assertGreater(len(fig41.data), 0) + + def test_WLF_shift_populates_all_figures(self): + result = update_line_chart( + self.synthetic_temp_data, number_of_prony=8, smoothness=0.1, + fit_settings=True, domain='temperature', + Tg=self.T_REF, C1=self.C1, C2=self.C2, shift_model='WLF', + ) + fig1, fig11, fig2, fig3, fig4, fig41, coef_df = result + for fig in (fig1, fig11, fig2, fig3, fig4, fig41): + self.assertGreater(len(fig.data), 0) + self.assertIsInstance(coef_df, list) + self.assertGreater(len(coef_df), 0) + + def test_hybrid_shift_populates_all_figures(self): + result = update_line_chart( + self.synthetic_temp_data, number_of_prony=8, smoothness=0.1, + fit_settings=True, domain='temperature', + Tg=self.T_REF, TL=self.T_REF, C1=self.C1, C2=self.C2, + Ea=self.EA, shift_model='hybrid', + ) + fig1, fig11, fig2, fig3, fig4, fig41, coef_df = result + for fig in (fig1, fig11, fig2, fig3, fig4, fig41): + self.assertGreater(len(fig.data), 0) + self.assertGreater(len(coef_df), 0) + + def test_hybrid_works_without_Tg(self): + # hybrid_shift uses TL (not Tg) as the WLF/Arrhenius crossover, so + # update_line_chart should drive the master-curve path even when Tg is + # not supplied. + result = update_line_chart( + self.synthetic_temp_data, number_of_prony=8, smoothness=0.1, + fit_settings=True, domain='temperature', + TL=self.T_REF, C1=self.C1, C2=self.C2, Ea=self.EA, + shift_model='hybrid', + ) + fig1, fig11, fig2, fig3, fig4, fig41, coef_df = result + for fig in (fig1, fig11, fig2, fig3, fig4, fig41): + self.assertGreater(len(fig.data), 0) + self.assertGreater(len(coef_df), 0) + + def test_Tg_zero_does_not_falsely_trigger_early_exit(self): + # Regression: the old `Tg and C1 and C2` truthy check treated Tg = 0 °C + # as missing and dropped users into the empty-figures branch. + result = update_line_chart( + self.synthetic_temp_data, number_of_prony=8, smoothness=0.1, + fit_settings=True, domain='temperature', + Tg=0.0, C1=self.C1, C2=self.C2, shift_model='WLF', + ) + fig1, fig11, fig2, fig3, fig4, fig41, coef_df = result + for fig in (fig1, fig11, fig2, fig3, fig4, fig41): + self.assertGreater(len(fig.data), 0) + self.assertGreater(len(coef_df), 0) + + def test_shiftData_override_triggers_shift_path(self): + # Even without Tg/C1/C2/shift_model, supplying shiftData should drive + # the shift branch (b is true via the shiftData term). + n = len(self.synthetic_temp_data['Temperature']) + T = self.synthetic_temp_data['Temperature'] + a_T = wlf_shift(T, self.T_REF, self.C1, self.C2) + shiftData = {'Temperature': T, 'a_T': a_T} + result = update_line_chart( + self.synthetic_temp_data, number_of_prony=8, smoothness=0.1, + fit_settings=True, domain='temperature', + shiftData=shiftData, + ) + fig1, fig11, fig2, fig3, fig4, fig41, coef_df = result + for fig in (fig1, fig11, fig2, fig3, fig4, fig41): + self.assertGreater(len(fig.data), 0) + self.assertGreater(len(coef_df), 0) + + +class TestUpdateLineChartValidation(unittest.TestCase): + """ + Validation paths in update_line_chart. + + uploadData is contractually the output of upload_init() — a dict with + canonical keys for the chosen domain mapped to 1-D float ndarrays. Tests + here exercise the user-fixable validation paths (ValueError → HTTP 400 + via the Flask route) and the contract assertions (AssertionError → HTTP + 500 — only reachable via a server bug). + """ + + @staticmethod + def _freq(f, s, l): + return {'Frequency': np.asarray(f, float), + 'E Storage': np.asarray(s, float), + 'E Loss': np.asarray(l, float)} + + @staticmethod + def _temp(T, s, l): + return {'Temperature': np.asarray(T, float), + 'E Storage': np.asarray(s, float), + 'E Loss': np.asarray(l, float)} + + def _call(self, data, **kw): + return update_line_chart( + data, number_of_prony=5, smoothness=0.1, fit_settings=True, **kw, + ) + + def test_empty_data_raises_value_error(self): + with self.assertRaisesRegex(ValueError, 'no data rows'): + self._call(self._freq([], [], []), domain='frequency') + + def test_nan_in_data_raises_value_error(self): + bad = self._freq([1.0, 2.0, 3.0], [100.0, np.nan, 300.0], [10.0, 20.0, 30.0]) + with self.assertRaisesRegex(ValueError, 'non-finite'): + self._call(bad, domain='frequency') + + def test_inf_in_data_raises_value_error(self): + bad = self._freq([1.0, 2.0, 3.0], [100.0, 200.0, 300.0], [10.0, np.inf, 30.0]) + with self.assertRaisesRegex(ValueError, 'non-finite'): + self._call(bad, domain='frequency') + + def test_zero_frequency_raises_value_error(self): + bad = self._freq([0.0, 1.0, 10.0], [100.0, 200.0, 300.0], [10.0, 20.0, 30.0]) + with self.assertRaisesRegex(ValueError, 'Frequency.*positive'): + self._call(bad, domain='frequency') + + def test_negative_frequency_raises_value_error(self): + bad = self._freq([-1.0, 1.0, 10.0], [100.0, 200.0, 300.0], [10.0, 20.0, 30.0]) + with self.assertRaisesRegex(ValueError, 'Frequency.*positive'): + self._call(bad, domain='frequency') + + def test_wrong_uploadData_keys_raises_assertion(self): + # Server-bug class: uploadData doesn't match upload_init's contract. + bad = {'Frequency': np.array([1.0, 2.0, 3.0])} + with self.assertRaises(AssertionError): + self._call(bad, domain='frequency') + + def test_unknown_domain_raises_assertion(self): + # Server-bug class: frontend only ever sends 'frequency' or 'temperature'. + ok = self._freq([1.0, 2.0, 3.0], [100.0, 200.0, 300.0], [10.0, 20.0, 30.0]) + with self.assertRaises(AssertionError): + self._call(ok, domain='bogus') + + def test_shiftdata_length_mismatch_raises_value_error(self): + temp = self._temp([0.0, 25.0, 50.0], [100.0, 200.0, 300.0], [10.0, 20.0, 30.0]) + bad_shift = {'Temperature': [0.0, 25.0], 'a_T': [1.0, 1.0]} + with self.assertRaisesRegex(ValueError, 'same number of rows'): + self._call(temp, domain='temperature', shiftData=bad_shift) + + def test_shiftdata_missing_a_T_raises_assertion(self): + # Server-bug class: upload_init(..., 'shift') always produces an 'a_T' key, + # so a missing one means someone constructed shiftData by hand. + temp = self._temp([0.0, 25.0, 50.0], [100.0, 200.0, 300.0], [10.0, 20.0, 30.0]) + bad_shift = {'Temperature': [0.0, 25.0, 50.0], 'wrong_key': [1.0, 1.0, 1.0]} + with self.assertRaises(AssertionError): + self._call(temp, domain='temperature', shiftData=bad_shift) + + +if __name__ == '__main__': + unittest.main() diff --git a/services/tests/utils/test_util.py b/services/tests/utils/test_util.py index 3ced51290..69d930a5e 100644 --- a/services/tests/utils/test_util.py +++ b/services/tests/utils/test_util.py @@ -1,8 +1,9 @@ import unittest -from unittest.mock import patch, mock_open +from unittest.mock import patch import os os.environ['OPENBLAS_NUM_THREADS'] = '1' -import pandas as pd +import tempfile +import numpy as np import sys import jwt @@ -12,27 +13,132 @@ from app.utils.util import upload_init, decode_jwt, generate_jwt from app.config import Config + class TestUploadInit(unittest.TestCase): - def setUp(self): - # Create a test file path and name - self.filename = 'test.csv' - self.filepath = os.path.join(Config.FILES_DIRECTORY, self.filename) - self.valid_content = "Frequency,E StoragAe,E Loss\n1,2,3\n4,5,6" - - @patch('pandas.read_csv') - def test_upload_init_valid_file(self, mock_read_csv): - # Mocking the pandas read_csv function to return a DataFrame - df = pd.DataFrame({ - "Frequency": [1, 4], - "E Storage": [2, 5], - "E Loss": [3, 6] - }) - mock_read_csv.return_value = df - # Test the function - result = upload_init(self.filename) - self.assertIsInstance(result, list) + """ + Tests for upload_init() — reads CSV/TSV/TXT files and returns dicts of + 1-D float arrays keyed by domain-specific column names. + + Each test writes a small file to a temporary directory and points + Config.FILES_DIRECTORY at it. + """ + + @classmethod + def setUpClass(cls): + cls._tmpdir = tempfile.TemporaryDirectory() + cls._original_files_dir = Config.FILES_DIRECTORY + Config.FILES_DIRECTORY = cls._tmpdir.name + + @classmethod + def tearDownClass(cls): + Config.FILES_DIRECTORY = cls._original_files_dir + cls._tmpdir.cleanup() + + def _write(self, name, content): + with open(os.path.join(Config.FILES_DIRECTORY, name), 'w') as f: + f.write(content) + return name + + def test_parses_canonical_frequency_csv(self): + name = self._write( + 'ok.csv', + "Frequency,E Storage,E Loss\n1,100,10\n2,200,20\n3,300,30\n", + ) + result = upload_init(name, 'frequency') + self.assertEqual(set(result.keys()), {'Frequency', 'E Storage', 'E Loss'}) + np.testing.assert_array_equal(result['Frequency'], [1.0, 2.0, 3.0]) + np.testing.assert_array_equal(result['E Storage'], [100.0, 200.0, 300.0]) + np.testing.assert_array_equal(result['E Loss'], [10.0, 20.0, 30.0]) + + def test_parses_tsv(self): + name = self._write( + 'ok.tsv', + "Frequency\tE Storage\tE Loss\n1\t100\t10\n2\t200\t20\n", + ) + result = upload_init(name, 'frequency') + self.assertEqual(set(result.keys()), {'Frequency', 'E Storage', 'E Loss'}) + + def test_parses_txt_as_tab_delimited(self): + name = self._write( + 'ok.txt', + "Frequency\tE Storage\tE Loss\n1\t100\t10\n2\t200\t20\n", + ) + result = upload_init(name, 'frequency') + self.assertEqual(set(result.keys()), {'Frequency', 'E Storage', 'E Loss'}) + + def test_sorts_rows_by_first_column(self): + name = self._write( + 'unsorted.csv', + "Frequency,E Storage,E Loss\n3,300,30\n1,100,10\n2,200,20\n", + ) + result = upload_init(name, 'frequency') + np.testing.assert_array_equal(result['Frequency'], [1.0, 2.0, 3.0]) + + def test_skips_non_numeric_header_rows(self): + name = self._write( + 'header.csv', + "# comment\nFrequency,E Storage,E Loss\n1,100,10\n2,200,20\n", + ) + result = upload_init(name, 'frequency') + np.testing.assert_array_equal(result['Frequency'], [1.0, 2.0]) + + def test_shift_domain_returns_2col_dict(self): + name = self._write('shift.csv', "Temperature,a_T\n0,0.5\n25,1.0\n") + result = upload_init(name, 'shift') + self.assertEqual(set(result.keys()), {'Temperature', 'a_T'}) + + def test_unknown_domain_raises_value_error(self): + name = self._write('ok.csv', "1,2,3\n") + with self.assertRaisesRegex(ValueError, 'Unknown domain'): + upload_init(name, 'garbage') + + def test_unsupported_extension_raises_value_error(self): + with self.assertRaisesRegex(ValueError, 'Unsupported file extension'): + upload_init('file.xlsx', 'frequency') + + def test_empty_file_raises_value_error(self): + name = self._write('empty.csv', "") + with self.assertRaisesRegex(ValueError, 'is empty'): + upload_init(name, 'frequency') + + def test_no_numeric_rows_raises_value_error(self): + name = self._write('text.csv', "this is\njust text\n") + with self.assertRaisesRegex(ValueError, 'No numeric rows'): + upload_init(name, 'frequency') + + def test_wrong_column_count_raises_value_error(self): + # 2-column file for frequency domain (needs 3). + name = self._write('2col.csv', "Frequency,E Storage\n1,100\n2,200\n") + with self.assertRaisesRegex(ValueError, 'Expected 3 columns'): + upload_init(name, 'frequency') + + def test_single_column_raises_value_error(self): + # The np.loadtxt(unpack=True) 1-D promotion path: previously silently + # truncated; should now surface the column-count mismatch. + name = self._write('1col.csv', "Frequency\n1\n2\n3\n") + with self.assertRaisesRegex(ValueError, 'Expected 3 columns'): + upload_init(name, 'frequency') + + def test_too_many_columns_raises_value_error(self): + name = self._write('4col.csv', "a,b,c,d\n1,2,3,4\n5,6,7,8\n") + with self.assertRaisesRegex(ValueError, 'Expected 3 columns'): + upload_init(name, 'frequency') + + def test_missing_file_raises_file_not_found(self): + # FileNotFoundError propagates as itself; the route is expected to + # call check_file_exists() before invoking upload_init. + with self.assertRaises(FileNotFoundError): + upload_init('nonexistent.csv', 'frequency') + + def test_mid_file_non_numeric_raises_value_error(self): + # First row is numeric so np.loadtxt starts there; the later + # non-numeric row breaks the parse. + name = self._write('mix.csv', "1,2,3\ntext,is,here\n4,5,6\n") + with self.assertRaisesRegex(ValueError, 'parse'): + upload_init(name, 'frequency') class TestDecodeJwt(unittest.TestCase): + @patch.object(Config, 'SECRET_KEY', 'test-secret-key') def test_decode_jwt_valid(self): # Generate a valid JWT for testing token, request_id = generate_jwt() From 96ab92487178455d161337b3c3e7ce58937c9f59 Mon Sep 17 00:00:00 2001 From: richardsheridan Date: Sun, 24 May 2026 14:19:24 -0400 Subject: [PATCH 03/18] feat(dynamfit): expose y_std in smooth_prony_fit all the way up to the route Users can now supply per-point measurement uncertainties via optional error columns in the uploaded data file, and tune the fallback scale when they don't. The previous behavior (y_std = 0.2 * |E*|) is preserved as the default, so existing callers see identical fits. * smooth_prony_fit takes explicit E_stor_std / E_loss_std arrays * upload_init accepts 3/4/5-column frequency or temperature files (extras: a shared 'Error' column or per-modulus 'E Storage Error' / 'E Loss Error') and 2/3-column shift files (extra 'Error' parsed but unused). --- services/app/dynamfit/dynamfit2.py | 55 ++++-- services/app/dynamfit/routes.py | 4 +- services/app/utils/util.py | 34 +++- services/tests/dynamfit/test_dynamfit2.py | 221 +++++++++++++++++++--- services/tests/utils/test_util.py | 65 ++++++- 5 files changed, 324 insertions(+), 55 deletions(-) diff --git a/services/app/dynamfit/dynamfit2.py b/services/app/dynamfit/dynamfit2.py index 5e44dbfdd..84d735c98 100644 --- a/services/app/dynamfit/dynamfit2.py +++ b/services/app/dynamfit/dynamfit2.py @@ -258,6 +258,8 @@ def smooth_prony_fit( omega: np.ndarray, E_stor: np.ndarray, E_loss: np.ndarray, + E_stor_std: np.ndarray, + E_loss_std: np.ndarray, N: int, smoothness: float, solid: bool = True, @@ -278,6 +280,10 @@ def smooth_prony_fit( length as omega. E_loss (numpy.ndarray): 1-D array of loss-modulus values, same length as omega. + E_stor_std (numpy.ndarray): 1-D array of per-point standard deviations + for E_stor, same length as omega. Used to weight residuals. + E_loss_std (numpy.ndarray): 1-D array of per-point standard deviations + for E_loss, same length as omega. Used to weight residuals. N (int): Number of relaxation times in the fit grid. smoothness (float): Strength of the second-difference penalty on the log-coefficients. Pass 0 to disable. @@ -295,6 +301,10 @@ def smooth_prony_fit( "E_loss must be a 1-D numpy.ndarray" assert len(omega) == len(E_stor) == len(E_loss), \ "omega, E_stor, E_loss must all have the same length" + assert isinstance(E_stor_std, np.ndarray) and E_stor_std.shape == E_stor.shape, \ + "E_stor_std must be a 1-D numpy.ndarray matching E_stor" + assert isinstance(E_loss_std, np.ndarray) and E_loss_std.shape == E_loss.shape, \ + "E_loss_std must be a 1-D numpy.ndarray matching E_loss" tau_max = 1 / np.min(omega) tau_min = 1 / np.max(omega) @@ -303,10 +313,7 @@ def smooth_prony_fit( basis = prony_basis(omega, tau_i, solid) y = np.concatenate((E_stor, E_loss)) - - y_std = np.absolute(E_stor + 1.0j * E_loss) - y_std *= 0.2 # TODO: make this an input to the function - y_std = np.concatenate((y_std, y_std)) + y_std = np.concatenate((E_stor_std, E_loss_std)) # Data-scaled initial guess: spread max(E_stor) evenly across N+solid terms # so the initial model prediction matches the data magnitude. A constant @@ -565,12 +572,10 @@ def tts_temperature_to_frequency_V2(temp_sweep_data, shift_model, *, T_ref = {'WLF': Tg, 'hybrid': TL}.get(shift_model) # None for 'manual' source_omega = df['Frequency'].to_numpy() if 'Frequency' in df.columns else 1.0 - return pd.DataFrame({ - 'Frequency': source_omega * a_T, - 'Temperature': T_ref, - "E'": df["E'"].to_numpy(), - "E''": df["E''"].to_numpy(), - }).sort_values('Frequency').reset_index(drop=True) + out = df.assign(Frequency=source_omega * a_T, Temperature=T_ref) + canonical = ['Frequency', 'Temperature', "E'", "E''"] + extras = [c for c in df.columns if c not in canonical] + return out[canonical + extras].sort_values('Frequency').reset_index(drop=True) def tts_frequency_to_temperature( @@ -876,7 +881,8 @@ def _build_coef_records(tau_i: np.ndarray, E_i: np.ndarray) -> list: @log_errors def update_line_chart(uploadData, number_of_prony, smoothness, fit_settings, domain, - Tg=None, C1=None, C2=None, Ea=None, TL=None, shift_model=None, shiftData=None): + Tg=None, C1=None, C2=None, Ea=None, TL=None, shift_model=None, shiftData=None, + relative_error=0.2): """ Update the dynamfit figures and Prony coefficient table for an uploaded dataset. @@ -912,8 +918,8 @@ def update_line_chart(uploadData, number_of_prony, smoothness, fit_settings, dom assert domain in EXPECTED_DOMAIN_COLUMNS, \ f"Unknown domain {domain!r}; expected one of {list(EXPECTED_DOMAIN_COLUMNS)}." expected_cols = EXPECTED_DOMAIN_COLUMNS[domain] - assert isinstance(uploadData, dict) and tuple(uploadData.keys()) == expected_cols, \ - f"uploadData keys must be {expected_cols} for domain {domain!r}; got " \ + assert isinstance(uploadData, dict) and all(k in uploadData for k in expected_cols), \ + f"uploadData must contain {expected_cols} for domain {domain!r}; got " \ f"{tuple(uploadData.keys()) if isinstance(uploadData, dict) else type(uploadData).__name__}." df = pd.DataFrame(uploadData) @@ -970,18 +976,35 @@ def update_line_chart(uploadData, number_of_prony, smoothness, fit_settings, dom temp_sweep_data, shift_model, Tg=Tg, TL=TL, C1=C1, C2=C2, Ea=Ea, shiftData=shiftData, ) - df = freq_sweep_data[["Frequency", "E'", "E''"]].rename( + df = freq_sweep_data.rename( columns={"E'": 'E Storage', "E''": 'E Loss'}, ) + E_stor_arr = df['E Storage'].to_numpy() + E_loss_arr = df['E Loss'].to_numpy() + if 'E Storage Error' in df.columns and 'E Loss Error' in df.columns: + E_stor_std = df['E Storage Error'].to_numpy() + E_loss_std = df['E Loss Error'].to_numpy() + elif 'Error' in df.columns: + E_stor_std = df['Error'].to_numpy() + E_loss_std = E_stor_std + else: + E_stor_std = np.abs(E_stor_arr + 1.0j * E_loss_arr) * relative_error + E_loss_std = E_stor_std tau_i, E_i = smooth_prony_fit( omega=df['Frequency'].to_numpy(), - E_stor=df['E Storage'].to_numpy(), - E_loss=df['E Loss'].to_numpy(), + E_stor=E_stor_arr, + E_loss=E_loss_arr, + E_stor_std=E_stor_std, + E_loss_std=E_loss_std, N=number_of_prony, smoothness=smoothness, ) N_nz = np.count_nonzero(E_i) + # Downstream figure builders assume df's first three columns are + # exactly (Frequency, E Storage, E Loss); drop any extras now that + # the std arrays have been pulled out. + df = df[['Frequency', 'E Storage', 'E Loss']] fig1, fig11 = _build_complex_figures(df, tau_i, E_i, N_nz) fig2, fig3 = _build_relaxation_figures(tau_i, E_i, N_nz, fit_settings) coef_records = _build_coef_records(tau_i, E_i) diff --git a/services/app/dynamfit/routes.py b/services/app/dynamfit/routes.py index e9e6c2fcb..ccf3ca88e 100644 --- a/services/app/dynamfit/routes.py +++ b/services/app/dynamfit/routes.py @@ -51,6 +51,7 @@ def extract_data_from_file(request_id): smoothness = data.get('smoothness', 0) fit_settings = data.get('fit_settings', False) domain = data.get('domain', 'frequency') + relative_error = data.get('relative_error', 0.2) shift_model = data.get('transform_method', 'hybrid') # Read incoming shift factor model metrics Tg = data.get('Tg', None) @@ -122,7 +123,8 @@ def extract_data_from_file(request_id): # Assuming the update_line_chart function returns values in a specific order result = update_line_chart(uploadData, number_of_prony, smoothness, - fit_settings, domain, **shift_params) + fit_settings, domain, + relative_error=relative_error, **shift_params) # Unpacking values into a dictionary chart_data = { diff --git a/services/app/utils/util.py b/services/app/utils/util.py index 218a08e44..d2b7a0986 100644 --- a/services/app/utils/util.py +++ b/services/app/utils/util.py @@ -58,16 +58,28 @@ def upload_init(file_name, domain): found, the file has the wrong number of columns for the chosen domain, or the file's numeric contents cannot be parsed as floats. """ - columns_by_domain = { - 'frequency': ['Frequency', 'E Storage', 'E Loss'], - 'temperature': ['Temperature', 'E Storage', 'E Loss'], - 'shift': ['Temperature', 'a_T'], + allowed_columns_by_domain = { + 'frequency': [ + ['Frequency', 'E Storage', 'E Loss'], + ['Frequency', 'E Storage', 'E Loss', 'Error'], + ['Frequency', 'E Storage', 'E Loss', 'E Storage Error', 'E Loss Error'], + ], + 'temperature': [ + ['Temperature', 'E Storage', 'E Loss'], + ['Temperature', 'E Storage', 'E Loss', 'Error'], + ['Temperature', 'E Storage', 'E Loss', 'E Storage Error', 'E Loss Error'], + ], + 'shift': [ + ['Temperature', 'a_T'], + ['Temperature', 'a_T', 'Error'], + ], } - if domain not in columns_by_domain: + if domain not in allowed_columns_by_domain: raise ValueError( - f"Unknown domain {domain!r}. Expected one of {list(columns_by_domain)}." + f"Unknown domain {domain!r}. " + f"Expected one of {list(allowed_columns_by_domain)}." ) - columns = columns_by_domain[domain] + allowed_shapes = allowed_columns_by_domain[domain] extension = os.path.splitext(file_name)[1].lower() if extension == '.csv': @@ -107,10 +119,12 @@ def upload_init(file_name, domain): if data.ndim == 1: data = data.reshape(1, -1) - if data.shape[0] != len(columns): + columns = next((s for s in allowed_shapes if len(s) == data.shape[0]), None) + if columns is None: + allowed_counts = ', '.join(str(len(s)) for s in allowed_shapes) raise ValueError( - f"Expected {len(columns)} columns for {domain} domain " - f"({', '.join(columns)}), but {file_name} has {data.shape[0]}." + f"Expected one of [{allowed_counts}] columns for {domain} domain, " + f"but {file_name} has {data.shape[0]}." ) sortind = np.argsort(data[0]) diff --git a/services/tests/dynamfit/test_dynamfit2.py b/services/tests/dynamfit/test_dynamfit2.py index 5a42ae3dc..0a6933060 100644 --- a/services/tests/dynamfit/test_dynamfit2.py +++ b/services/tests/dynamfit/test_dynamfit2.py @@ -260,19 +260,24 @@ def setUp(self): self.omega = df['Frequency'].values self.E_stor = df['E Storage'].values self.E_loss = df['E Loss'].values + # Flat uniform weights — same loss landscape as unweighted least-squares. + self.E_stor_std = np.ones_like(self.E_stor) + self.E_loss_std = np.ones_like(self.E_loss) def test_shape_viscous(self): tau_i, E_i = smooth_prony_fit( - self.omega, self.E_stor, self.E_loss, N=5, - smoothness=1.0, solid=False, + self.omega, self.E_stor, self.E_loss, + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, + N=5, smoothness=1.0, solid=False, ) self.assertEqual(tau_i.shape, (5,)) self.assertEqual(E_i.shape, (5,)) def test_shape_solid(self): tau_i, E_i = smooth_prony_fit( - self.omega, self.E_stor, self.E_loss, N=5, - smoothness=1.0, solid=True, + self.omega, self.E_stor, self.E_loss, + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, + N=5, smoothness=1.0, solid=True, ) self.assertEqual(tau_i.shape, (5,)) self.assertEqual(E_i.shape, (6,)) @@ -282,6 +287,7 @@ def test_recovers_input_coefficients(self): # should recover the input coefficients. tau_i, E_i = smooth_prony_fit( self.omega, self.E_stor, self.E_loss, + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, N=len(TAU), smoothness=0.0, solid=False, ) np.testing.assert_allclose(tau_i, TAU) @@ -289,59 +295,114 @@ def test_recovers_input_coefficients(self): def test_N_controls_tau_grid_size(self): tau_i, E_i = smooth_prony_fit( - self.omega, self.E_stor, self.E_loss, N=8, - smoothness=1.0, solid=False, + self.omega, self.E_stor, self.E_loss, + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, + N=8, smoothness=1.0, solid=False, ) self.assertEqual(tau_i.shape, (8,)) self.assertEqual(E_i.shape, (8,)) + def test_std_arrays_weight_residuals(self): + # Under-parameterized fit (N=1 against a 3-term source) so the model + # cannot match both moduli exactly. Tiny std on the storage side, + # huge on the loss side → the storage residuals dominate the loss, + # so the fit tracks E_stor better than E_loss; reversing the weights + # should swap which side tracks well. + n = len(self.omega) + small = np.full(n, 1e-3) + big = np.full(n, 1e3) + tau_i, E_i = smooth_prony_fit( + self.omega, self.E_stor, self.E_loss, + E_stor_std=small, E_loss_std=big, + N=1, smoothness=0.0, solid=False, + ) + basis = prony_basis(self.omega, tau_i, solid=False) + model = basis @ E_i + stor_err = np.linalg.norm(self.E_stor - model[:n]) + loss_err = np.linalg.norm(self.E_loss - model[n:]) + self.assertLess(stor_err, loss_err) + + tau_i_r, E_i_r = smooth_prony_fit( + self.omega, self.E_stor, self.E_loss, + E_stor_std=big, E_loss_std=small, + N=1, smoothness=0.0, solid=False, + ) + basis_r = prony_basis(self.omega, tau_i_r, solid=False) + model_r = basis_r @ E_i_r + stor_err_r = np.linalg.norm(self.E_stor - model_r[:n]) + loss_err_r = np.linalg.norm(self.E_loss - model_r[n:]) + self.assertGreater(stor_err_r, loss_err_r) + def test_rejects_non_ndarray_omega(self): with self.assertRaises(AssertionError): smooth_prony_fit( - list(self.omega), self.E_stor, self.E_loss, N=5, - smoothness=1.0, solid=False, + list(self.omega), self.E_stor, self.E_loss, + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, + N=5, smoothness=1.0, solid=False, ) def test_rejects_non_ndarray_E_stor(self): with self.assertRaises(AssertionError): smooth_prony_fit( - self.omega, list(self.E_stor), self.E_loss, N=5, - smoothness=1.0, solid=False, + self.omega, list(self.E_stor), self.E_loss, + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, + N=5, smoothness=1.0, solid=False, ) def test_rejects_non_ndarray_E_loss(self): with self.assertRaises(AssertionError): smooth_prony_fit( - self.omega, self.E_stor, list(self.E_loss), N=5, - smoothness=1.0, solid=False, + self.omega, self.E_stor, list(self.E_loss), + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, + N=5, smoothness=1.0, solid=False, ) def test_rejects_2d_omega(self): with self.assertRaises(AssertionError): smooth_prony_fit( - self.omega.reshape(1, -1), self.E_stor, self.E_loss, N=5, - smoothness=1.0, solid=False, + self.omega.reshape(1, -1), self.E_stor, self.E_loss, + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, + N=5, smoothness=1.0, solid=False, ) def test_rejects_2d_E_stor(self): with self.assertRaises(AssertionError): smooth_prony_fit( - self.omega, self.E_stor.reshape(1, -1), self.E_loss, N=5, - smoothness=1.0, solid=False, + self.omega, self.E_stor.reshape(1, -1), self.E_loss, + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, + N=5, smoothness=1.0, solid=False, ) def test_rejects_2d_E_loss(self): with self.assertRaises(AssertionError): smooth_prony_fit( - self.omega, self.E_stor, self.E_loss.reshape(1, -1), N=5, - smoothness=1.0, solid=False, + self.omega, self.E_stor, self.E_loss.reshape(1, -1), + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, + N=5, smoothness=1.0, solid=False, ) def test_rejects_length_mismatch(self): with self.assertRaises(AssertionError): smooth_prony_fit( - self.omega, self.E_stor[:-1], self.E_loss, N=5, - smoothness=1.0, solid=False, + self.omega, self.E_stor[:-1], self.E_loss, + E_stor_std=self.E_stor_std[:-1], E_loss_std=self.E_loss_std, + N=5, smoothness=1.0, solid=False, + ) + + def test_rejects_E_stor_std_wrong_length(self): + with self.assertRaises(AssertionError): + smooth_prony_fit( + self.omega, self.E_stor, self.E_loss, + E_stor_std=self.E_stor_std[:-1], E_loss_std=self.E_loss_std, + N=5, smoothness=1.0, solid=False, + ) + + def test_rejects_E_loss_std_wrong_length(self): + with self.assertRaises(AssertionError): + smooth_prony_fit( + self.omega, self.E_stor, self.E_loss, + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std[:-1], + N=5, smoothness=1.0, solid=False, ) def test_handles_data_scale_orders_of_magnitude(self): @@ -361,11 +422,13 @@ def test_handles_data_scale_orders_of_magnitude(self): omega = df['Frequency'].to_numpy() E_stor = df['E Storage'].to_numpy() E_loss = df['E Loss'].to_numpy() + mag = np.abs(E_stor + 1.0j * E_loss) * 0.2 for N in [10, 20, 50]: with self.subTest(scale=scale, N=N): _, E_i = smooth_prony_fit( - omega, E_stor, E_loss, N=N, - smoothness=0.0, solid=True, + omega, E_stor, E_loss, + E_stor_std=mag, E_loss_std=mag, + N=N, smoothness=0.0, solid=True, ) self.assertTrue( np.all(np.isfinite(E_i)), @@ -1088,5 +1151,119 @@ def test_shiftdata_missing_a_T_raises_assertion(self): self._call(temp, domain='temperature', shiftData=bad_shift) +class TestUpdateLineChartErrorColumns(unittest.TestCase): + """update_line_chart should pass E_stor_std/E_loss_std from the uploadData + error columns when present, else fall back to relative_error*|E*|.""" + + @staticmethod + def _freq_data(extras=None): + d = { + 'Frequency': np.array([1.0, 2.0, 3.0]), + 'E Storage': np.array([100.0, 200.0, 300.0]), + 'E Loss': np.array([10.0, 20.0, 30.0]), + } + if extras: + d.update(extras) + return d + + @staticmethod + def _temp_data(extras=None): + d = { + 'Temperature': np.array([0.0, 25.0, 50.0]), + 'E Storage': np.array([100.0, 200.0, 300.0]), + 'E Loss': np.array([10.0, 20.0, 30.0]), + } + if extras: + d.update(extras) + return d + + def _spy(self): + # Return a minimal valid fit result so downstream figure builders run. + from unittest.mock import patch + spy_target = patch( + 'app.dynamfit.dynamfit2.smooth_prony_fit', + return_value=(np.array([1.0]), np.array([1.0])), + ) + return spy_target + + def test_per_modulus_error_columns_flow_into_smooth_prony_fit(self): + stor_err = np.array([5.0, 10.0, 15.0]) + loss_err = np.array([0.5, 1.0, 1.5]) + data = self._freq_data({ + 'E Storage Error': stor_err, + 'E Loss Error': loss_err, + }) + with self._spy() as spy: + update_line_chart( + data, number_of_prony=5, smoothness=0.1, + fit_settings=False, domain='frequency', + ) + kwargs = spy.call_args.kwargs + np.testing.assert_array_equal(kwargs['E_stor_std'], stor_err) + np.testing.assert_array_equal(kwargs['E_loss_std'], loss_err) + + def test_shared_error_column_flows_into_both_std_kwargs(self): + err = np.array([1.0, 2.0, 3.0]) + data = self._freq_data({'Error': err}) + with self._spy() as spy: + update_line_chart( + data, number_of_prony=5, smoothness=0.1, + fit_settings=False, domain='frequency', + ) + kwargs = spy.call_args.kwargs + np.testing.assert_array_equal(kwargs['E_stor_std'], err) + np.testing.assert_array_equal(kwargs['E_loss_std'], err) + + def test_no_error_columns_falls_back_to_default_relative_error(self): + data = self._freq_data() + with self._spy() as spy: + update_line_chart( + data, number_of_prony=5, smoothness=0.1, + fit_settings=False, domain='frequency', + ) + kwargs = spy.call_args.kwargs + expected = np.abs(data['E Storage'] + 1.0j * data['E Loss']) * 0.2 + np.testing.assert_allclose(kwargs['E_stor_std'], expected) + np.testing.assert_allclose(kwargs['E_loss_std'], expected) + + def test_relative_error_kwarg_scales_fallback(self): + data = self._freq_data() + with self._spy() as spy: + update_line_chart( + data, number_of_prony=5, smoothness=0.1, + fit_settings=False, domain='frequency', + relative_error=0.5, + ) + kwargs = spy.call_args.kwargs + expected = np.abs(data['E Storage'] + 1.0j * data['E Loss']) * 0.5 + np.testing.assert_allclose(kwargs['E_stor_std'], expected) + np.testing.assert_allclose(kwargs['E_loss_std'], expected) + + def test_temperature_per_modulus_error_columns_flow_through_tts(self): + # The temperature branch routes data through tts_temperature_to_frequency_V2, + # which reorders rows by post-shift Frequency. The per-row error values + # must ride along that reorder so smooth_prony_fit sees them aligned. + stor_err = np.array([5.0, 10.0, 15.0]) + loss_err = np.array([0.5, 1.0, 1.5]) + data = self._temp_data({ + 'E Storage Error': stor_err, + 'E Loss Error': loss_err, + }) + with self._spy() as spy: + update_line_chart( + data, number_of_prony=5, smoothness=0.1, + fit_settings=False, domain='temperature', + Tg=25.0, C1=17.44, C2=51.6, shift_model='WLF', + ) + kwargs = spy.call_args.kwargs + # Each (storage, loss) error pair must still be co-located with its + # source row after the frequency-sort reorder. The set of pairs is + # therefore invariant under TTS even though the order changes. + pairs_out = set(zip(kwargs['E_stor_std'].tolist(), + kwargs['E_loss_std'].tolist())) + pairs_in = set(zip(stor_err.tolist(), loss_err.tolist())) + self.assertEqual(pairs_out, pairs_in) + + if __name__ == '__main__': unittest.main() diff --git a/services/tests/utils/test_util.py b/services/tests/utils/test_util.py index 69d930a5e..f01e6c713 100644 --- a/services/tests/utils/test_util.py +++ b/services/tests/utils/test_util.py @@ -107,23 +107,76 @@ def test_no_numeric_rows_raises_value_error(self): upload_init(name, 'frequency') def test_wrong_column_count_raises_value_error(self): - # 2-column file for frequency domain (needs 3). + # 2-column file for frequency domain (needs 3, 4, or 5). name = self._write('2col.csv', "Frequency,E Storage\n1,100\n2,200\n") - with self.assertRaisesRegex(ValueError, 'Expected 3 columns'): + with self.assertRaisesRegex(ValueError, 'columns'): upload_init(name, 'frequency') def test_single_column_raises_value_error(self): # The np.loadtxt(unpack=True) 1-D promotion path: previously silently # truncated; should now surface the column-count mismatch. name = self._write('1col.csv', "Frequency\n1\n2\n3\n") - with self.assertRaisesRegex(ValueError, 'Expected 3 columns'): + with self.assertRaisesRegex(ValueError, 'columns'): upload_init(name, 'frequency') - def test_too_many_columns_raises_value_error(self): - name = self._write('4col.csv', "a,b,c,d\n1,2,3,4\n5,6,7,8\n") - with self.assertRaisesRegex(ValueError, 'Expected 3 columns'): + def test_six_columns_raises_value_error(self): + name = self._write('6col.csv', "a,b,c,d,e,f\n1,2,3,4,5,6\n7,8,9,10,11,12\n") + with self.assertRaisesRegex(ValueError, 'columns'): upload_init(name, 'frequency') + def test_frequency_accepts_5_columns_with_per_modulus_errors(self): + name = self._write( + '5col.csv', + "Frequency,E Storage,E Loss,E Storage Error,E Loss Error\n" + "1,100,10,5,0.5\n" + "2,200,20,10,1.0\n" + "3,300,30,15,1.5\n", + ) + result = upload_init(name, 'frequency') + self.assertEqual( + set(result.keys()), + {'Frequency', 'E Storage', 'E Loss', 'E Storage Error', 'E Loss Error'}, + ) + np.testing.assert_array_equal(result['Frequency'], [1.0, 2.0, 3.0]) + np.testing.assert_array_equal(result['E Storage Error'], [5.0, 10.0, 15.0]) + np.testing.assert_array_equal(result['E Loss Error'], [0.5, 1.0, 1.5]) + + def test_frequency_accepts_4_columns_with_shared_error(self): + name = self._write( + '4col.csv', + "Frequency,E Storage,E Loss,Error\n1,100,10,5\n2,200,20,10\n", + ) + result = upload_init(name, 'frequency') + self.assertEqual( + set(result.keys()), + {'Frequency', 'E Storage', 'E Loss', 'Error'}, + ) + np.testing.assert_array_equal(result['Error'], [5.0, 10.0]) + + def test_temperature_accepts_5_columns_with_per_modulus_errors(self): + name = self._write( + 'temp5.csv', + "Temperature,E Storage,E Loss,E Storage Error,E Loss Error\n" + "0,100,10,5,0.5\n" + "25,200,20,10,1.0\n", + ) + result = upload_init(name, 'temperature') + self.assertEqual( + set(result.keys()), + {'Temperature', 'E Storage', 'E Loss', 'E Storage Error', 'E Loss Error'}, + ) + + def test_shift_accepts_3_columns_with_extra(self): + # Shift-factor files may carry an extra Error column for symmetry with + # the data files; the value is parsed but the fit doesn't consume it. + name = self._write( + 'shift3.csv', + "Temperature,a_T,Error\n0,0.5,0.05\n25,1.0,0.1\n", + ) + result = upload_init(name, 'shift') + self.assertEqual(set(result.keys()), {'Temperature', 'a_T', 'Error'}) + np.testing.assert_array_equal(result['Error'], [0.05, 0.1]) + def test_missing_file_raises_file_not_found(self): # FileNotFoundError propagates as itself; the route is expected to # call check_file_exists() before invoking upload_init. From 4cdc2faf5868006206982cdebecba73428ef97f4 Mon Sep 17 00:00:00 2001 From: richardsheridan Date: Mon, 25 May 2026 11:28:11 -0400 Subject: [PATCH 04/18] chore: add .gitattributes to force LF on shell scripts and husky hooks Prevents Windows/WSL from rewriting hook shebangs to CRLF, which breaks execution in Linux containers (cannot run: No such file or directory). --- .gitattributes | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..42d99acb9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Default: let git normalize text, store LF in the repo +* text=auto + +# Files executed in Linux containers / WSL — must be LF or the shebang breaks +*.sh text eol=lf +.husky/** text eol=lf +install text eol=lf From 44f74483fc67705cfdeed251ce839302c5a63ed8 Mon Sep 17 00:00:00 2001 From: richardsheridan Date: Mon, 25 May 2026 14:12:37 -0400 Subject: [PATCH 05/18] feat(dynamfit): fit best-fit WLF/hybrid coefficients from shift-domain data Add fit_wlf_coefficients and fit_hybrid_coefficients to dynamfit2.py: fit shift-model coefficients from uploaded shift-factor data (T, a_T) via scipy.optimize.curve_fit in log10(a_T) space. Caller-supplied coefficients are held fixed by excluding them from the fit (closure reparametrization, since scipy 1.10.1 rejects equal bounds; TODO to switch to lb==ub on bump). For hybrid, TL is a required upstream input (from a prior extract call), not co-fit. Expose via a fit_shift_coefficients short-circuit branch in /extract/ that returns coefficients only (no Prony/charts) and does not require the primary temp/freq file. Left as a branch with a TODO motivating a future dedicated route, per its limited inputs/outputs. Tests: WLF/hybrid round-trip recovery, param-fixing, ValueError guards, and a new route-test harness (bare Flask app + blueprint, sidestepping the in-container log-file handler) covering the branch's validation and response. Add end-to-end route tests over real shift files (agilus30 WLF both-free, VeroCyan hybrid) plus a cold-data unit test asserting convergence and the C2 bound. Full module: 158 tests. --- services/app/dynamfit/dynamfit2.py | 293 ++++++++++++- services/app/dynamfit/routes.py | 67 ++- services/tests/dynamfit/test_dynamfit2.py | 495 ++++++++++++++++++++++ 3 files changed, 852 insertions(+), 3 deletions(-) diff --git a/services/app/dynamfit/dynamfit2.py b/services/app/dynamfit/dynamfit2.py index 84d735c98..77ed83f3c 100644 --- a/services/app/dynamfit/dynamfit2.py +++ b/services/app/dynamfit/dynamfit2.py @@ -4,7 +4,7 @@ import pandas as pd from contextlib import contextmanager -from scipy.optimize import minimize +from scipy.optimize import curve_fit, minimize from scipy.signal import find_peaks import plotly.express as px import plotly.graph_objects as go @@ -341,6 +341,27 @@ def smooth_prony_fit( # print(f'C2: {C2}') # return 10 ** (-C1 * (T - T_ref) / (C2 + (T - T_ref))) +def wlf_log10_shift(T: np.ndarray, T_ref: float, C1: float, C2: float) -> np.ndarray: + """ + Return log10(a_T) = -C1 * (T - T_ref) / (C2 + (T - T_ref)) directly. + + Computing in log10 space avoids the 10**exponent overflow that occurs for + cold data (T well below T_ref) when the exponent is large but finite. + No finiteness guard is applied here; wlf_shift wraps this function and + raises ValueError via _fp_safe if the result is non-finite. + + Parameters: + T (numpy.ndarray): 1-D array of temperatures in °C. + T_ref (float): Reference temperature in °C. + C1 (float): WLF parameter C1. + C2 (float): WLF parameter C2. + + Returns: + numpy.ndarray: 1-D array of log10(a_T) values, same length as T. + """ + return -C1 * (T - T_ref) / (C2 + (T - T_ref)) + + def wlf_shift(T, T_ref: float, C1: float, C2: float) -> np.ndarray: """ Calculate the WLF shift factor a_T. @@ -348,6 +369,10 @@ def wlf_shift(T, T_ref: float, C1: float, C2: float) -> np.ndarray: Implements the Williams-Landel-Ferry equation: log10(a_T) = -C1 * (T - T_ref) / (C2 + (T - T_ref)) + Delegates to wlf_log10_shift for the analytic log10, then exponentiates. + The single-source-of-truth formula lives in wlf_log10_shift; this function + adds the _fp_safe shell and the finiteness check. + Parameters: T: Temperatures at which to evaluate a_T. Scalars and 0-D arrays are promoted to a length-1 1-D array; 1-D arrays pass through. @@ -369,7 +394,7 @@ def wlf_shift(T, T_ref: float, C1: float, C2: float) -> np.ndarray: "divide by zero detected when calculating WLF. " "Please adjust parameters or manually provide shift factors." ): - a_T = np.power(10.0, -C1 * (T - T_ref) / (C2 + (T - T_ref))) + a_T = np.power(10.0, wlf_log10_shift(T, T_ref, C1, C2)) if not np.all(np.isfinite(a_T)): raise FloatingPointError("Non-finite result in WLF computation") return a_T @@ -459,6 +484,270 @@ def hybrid_shift( return result if ascending else result[::-1] +def _curve_fit_shift(model, T: np.ndarray, log10_a_T: np.ndarray, + p0: list, bounds=(-np.inf, np.inf), + log10_space: bool = False) -> np.ndarray: + """ + Fit shift-model parameters to log10(a_T) data via curve_fit. + + By default wraps model (which returns linear-scale a_T) in a log10 + transform so the optimizer sees uniform weighting across many orders of + magnitude. When log10_space=True the model already returns log10(a_T) + directly (avoiding intermediate exponentiation and potential overflow). + Only free parameters are passed; fixed parameters must already be closed + over in the model callable. + + Parameters: + model: Callable with signature model(T, *free_params) -> np.ndarray. + Returns linear-scale a_T when log10_space=False, or log10(a_T) + when log10_space=True. Fixed parameters must be captured in the + closure. + T (numpy.ndarray): 1-D array of temperatures in °C. + log10_a_T (numpy.ndarray): 1-D array of log10(a_T) target values, + same length as T. + p0 (list): Initial guesses for the free parameters. + bounds: Bounds for the free parameters forwarded to curve_fit + (same format as scipy's bounds argument). Defaults to no bounds. + log10_space (bool): If True, model already returns log10(a_T) and no + np.log10 wrapping is applied. Defaults to False. + + Returns: + numpy.ndarray: Fitted values for the free parameters, same length as p0. + + Raises: + ValueError: If curve_fit fails to converge. + """ + # TODO(scipy>=1.11): fixed parameters are currently held constant by + # closure reparametrization (excluded from the fit vector and captured in + # `model`) because scipy 1.10.1 rejects equal bounds with "Each lower bound + # must be strictly less than each upper bound". Once the services env is + # bumped to scipy >= 1.11, this can be revised to pass ALL parameters to + # curve_fit with bounds, fixing a parameter via lb == ub. That would let the + # callers (fit_wlf_coefficients / fit_hybrid_coefficients) drop their + # per-parameter free/fixed branching in favor of one bounds vector. + if log10_space: + fit_model = model + else: + def fit_model(T_arg, *params): + return np.log10(model(T_arg, *params)) + + try: + popt, _ = curve_fit(fit_model, T, log10_a_T, p0=p0, bounds=bounds) + except RuntimeError as exc: + raise ValueError( + f"Shift-factor curve fit did not converge: {exc}. " + "Try supplying better initial guesses or checking the input data." + ) from exc + return popt + + +def fit_wlf_coefficients( + T: np.ndarray, + a_T: np.ndarray, + T_ref: float, + C1: float = None, + C2: float = None, + fix_C1: bool = False, + fix_C2: bool = False, +) -> tuple: + """ + Fit WLF shift-model coefficients (C1, C2) to shift-domain data. + + Uses scipy.optimize.curve_fit in log10(a_T) space so that points spanning + many decades of shift factor receive uniform weight. T_ref is required and + is never optimized — it is a physical input (e.g. Tg) supplied by the + caller. + + Individual parameters can be fixed at their supplied values by setting the + corresponding fix_* flag. A supplied-but-not-fixed value becomes the + initial guess; otherwise the WLF universal constants are used. Fixed + parameters are held constant by closing them over inside the model rather + than passing them to curve_fit. + + When C2 is free, a lower bound c2_min = (T_ref - min(T)) + 1.0 is enforced + to keep the WLF denominator positive (1 °C margin). The fit uses + wlf_log10_shift directly (log10_space=True) to avoid the 10**exponent + overflow that otherwise occurs for cold data (T well below T_ref). + + Parameters: + T (numpy.ndarray): 1-D array of temperatures in °C, same length as a_T. + a_T (numpy.ndarray): 1-D array of positive shift factors (linear scale). + T_ref (float): Reference temperature in °C; passed through to wlf_shift + and never optimized. + C1 (float): Initial guess for WLF C1. Defaults to UNIVERSAL_WLF_C1 if + None. + C2 (float): Initial guess for WLF C2. Defaults to UNIVERSAL_WLF_C2 if + None. + fix_C1 (bool): If True, hold C1 constant at its supplied value. + fix_C2 (bool): If True, hold C2 constant at its supplied value. + + Returns: + tuple: (C1_fit, C2_fit) — fitted (or fixed) WLF coefficients. + + Raises: + ValueError: If any a_T value is non-positive (log10 undefined), or if + curve_fit does not converge. + """ + T = np.atleast_1d(T) + a_T = np.atleast_1d(a_T) + assert T.ndim == 1 and a_T.ndim == 1, \ + "T and a_T must be 1-D numpy.ndarray or scalar" + assert len(T) == len(a_T), \ + f"T and a_T must have the same length; got {len(T)} and {len(a_T)}" + if not np.all(a_T > 0): + raise ValueError( + "All shift factors a_T must be positive (log10 is undefined for " + "non-positive values). Check the input shift-factor data." + ) + + C1_0 = C1 if C1 is not None else UNIVERSAL_WLF_C1 + C2_0 = C2 if C2 is not None else UNIVERSAL_WLF_C2 + + # Lower bound for a free C2: keeps denominator C2 + (T - T_ref) >= 1 for + # all T in the dataset, preventing a WLF pole and keeping the analytic + # log10 finite for any finite C1. + c2_min = (T_ref - float(np.min(T))) + 1.0 + + # Build a model over only the free parameters; fixed ones are closed over. + # This avoids passing degenerate lb==ub bounds to curve_fit, which some + # scipy versions reject. + if fix_C1 and fix_C2: + return float(C1_0), float(C2_0) + elif fix_C1: + # C2 free: use analytic log10 form and enforce c2_min bound. + def model(T_arg, C2_p): + return wlf_log10_shift(T_arg, T_ref, C1_0, C2_p) + C2_start = max(C2_0, c2_min) + (C2_fit,) = _curve_fit_shift( + model, T, np.log10(a_T), p0=[C2_start], + bounds=([c2_min], [np.inf]), log10_space=True, + ) + return float(C1_0), float(C2_fit) + elif fix_C2: + # C1 free, C2 fixed: no overflow risk (denominator is fixed and positive + # as long as the fixed C2 was chosen appropriately by the caller). + def model(T_arg, C1_p): + return wlf_shift(T_arg, T_ref, C1_p, C2_0) + (C1_fit,) = _curve_fit_shift(model, T, np.log10(a_T), p0=[C1_0]) + return float(C1_fit), float(C2_0) + else: + # Both free: use analytic log10 form directly (avoids 10**exponent + # overflow for cold data) and enforce c2_min on C2. + def model(T_arg, C1_p, C2_p): + return wlf_log10_shift(T_arg, T_ref, C1_p, C2_p) + C2_start = max(C2_0, c2_min) + C1_fit, C2_fit = _curve_fit_shift( + model, T, np.log10(a_T), p0=[C1_0, C2_start], + bounds=([-np.inf, c2_min], [np.inf, np.inf]), log10_space=True, + ) + return float(C1_fit), float(C2_fit) + + +def fit_hybrid_coefficients( + T: np.ndarray, + a_T: np.ndarray, + TL: float, + C1: float = None, + C2: float = None, + Ea: float = None, + fix_C1: bool = False, + fix_C2: bool = False, + fix_Ea: bool = False, +) -> tuple: + """ + Fit hybrid Arrhenius/WLF shift-model coefficients (C1, C2, Ea) to + shift-domain data. + + Uses scipy.optimize.curve_fit in log10(a_T) space so that points spanning + many decades of shift factor receive uniform weight. TL is required and is + never optimized — it is produced upstream by the E-loss-peak logic in the + extract step and is a physical input, not a fit parameter. + + Individual parameters can be fixed at their supplied values by setting the + corresponding fix_* flag. A supplied-but-not-fixed value becomes the + initial guess; otherwise the WLF universal constants are used for C1/C2 and + a moderate activation energy (100 kJ/mol) for Ea. Fixed parameters are held + constant by closing them over inside the model rather than passing them to + curve_fit. + + Parameters: + T (numpy.ndarray): 1-D array of temperatures in °C, same length as a_T. + Must be monotonically sorted (ascending or descending) as required by + hybrid_shift. + a_T (numpy.ndarray): 1-D array of positive shift factors (linear scale). + TL (float): WLF/Arrhenius crossover temperature in °C; passed through + to hybrid_shift and never optimized. + C1 (float): Initial guess for WLF C1. Defaults to UNIVERSAL_WLF_C1 if + None. + C2 (float): Initial guess for WLF C2. Defaults to UNIVERSAL_WLF_C2 if + None. + Ea (float): Initial guess for Arrhenius activation energy in kJ/mol. + Defaults to 100.0 if None. + fix_C1 (bool): If True, hold C1 constant at its supplied value. + fix_C2 (bool): If True, hold C2 constant at its supplied value. + fix_Ea (bool): If True, hold Ea constant at its supplied value. + + Returns: + tuple: (C1_fit, C2_fit, Ea_fit) — fitted (or fixed) hybrid coefficients. + + Raises: + ValueError: If any a_T value is non-positive (log10 undefined), or if + curve_fit does not converge. + """ + T = np.atleast_1d(T) + a_T = np.atleast_1d(a_T) + assert T.ndim == 1 and a_T.ndim == 1, \ + "T and a_T must be 1-D numpy.ndarray or scalar" + assert len(T) == len(a_T), \ + f"T and a_T must have the same length; got {len(T)} and {len(a_T)}" + if not np.all(a_T > 0): + raise ValueError( + "All shift factors a_T must be positive (log10 is undefined for " + "non-positive values). Check the input shift-factor data." + ) + + _EA_DEFAULT = 100.0 # kJ/mol — broad mid-range starting point + C1_0 = C1 if C1 is not None else UNIVERSAL_WLF_C1 + C2_0 = C2 if C2 is not None else UNIVERSAL_WLF_C2 + Ea_0 = Ea if Ea is not None else _EA_DEFAULT + + log10_a_T = np.log10(a_T) + + # Build a model over only the free parameters; fixed ones are closed over. + # This avoids passing degenerate lb==ub bounds to curve_fit, which some + # scipy versions reject. + free_names = [n for n, fixed in [('C1', fix_C1), ('C2', fix_C2), ('Ea', fix_Ea)] + if not fixed] + p0 = [v for v, fixed in [(C1_0, fix_C1), (C2_0, fix_C2), (Ea_0, fix_Ea)] + if not fixed] + + if not free_names: + return float(C1_0), float(C2_0), float(Ea_0) + + # Simple non-negativity floor on free C2. Hybrid's WLF branch only sees + # T > TL (not cold data), so the 10**exponent overflow that afflicts + # fit_wlf_coefficients cannot occur here; log10_space=False is intentional. + lb = [-np.inf if n != 'C2' else 1.0 for n in free_names] + ub = [np.inf] * len(free_names) + + def model(T_arg, *free_vals): + vals = dict(zip(free_names, free_vals)) + return hybrid_shift( + T_arg, TL, + vals.get('C1', C1_0), + vals.get('C2', C2_0), + vals.get('Ea', Ea_0), + ) + + fitted = _curve_fit_shift(model, T, log10_a_T, p0=p0, bounds=(lb, ub)) + result = dict(zip(free_names, fitted)) + return ( + float(result.get('C1', C1_0)), + float(result.get('C2', C2_0)), + float(result.get('Ea', Ea_0)), + ) + + def inverse_wlf_shift(a_T, T_ref: float, C1: float, C2: float) -> np.ndarray: """ Calculate the temperature T corresponding to a WLF shift factor a_T. diff --git a/services/app/dynamfit/routes.py b/services/app/dynamfit/routes.py index ccf3ca88e..57a6a3085 100644 --- a/services/app/dynamfit/routes.py +++ b/services/app/dynamfit/routes.py @@ -2,11 +2,14 @@ import json import datetime +import numpy as np + from flask import request, Blueprint, jsonify, Response from app.dynamfit.dynamfit2 import ( update_line_chart, argmax_peak, UNIVERSAL_WLF_C1, UNIVERSAL_WLF_C2, + fit_wlf_coefficients, fit_hybrid_coefficients, ) from app.utils.util import token_required, upload_init, request_logger, log_errors from app.config import Config @@ -68,7 +71,69 @@ def extract_data_from_file(request_id): # add manual shift factor upload shift_file_name = data.get('shift_file_name', None) - + + fit_shift_coefficients = data.get('fit_shift_coefficients', False) + + # TODO: This branch should become its own route (e.g. POST /dynamfit/fit-shift/). + # The shift-domain fit is a special case: its only effective inputs are the + # shift file plus Tg or TL, and its only output is the fitted coefficients — + # no Prony fit, no charts. Grafted into /extract/ for now to avoid a new + # endpoint while the feature stabilises. + if fit_shift_coefficients: + if not shift_file_name: + return jsonify({'message': 'No shift file name provided'}), 400 + if not check_file_exists(shift_file_name): + return jsonify({'message': f"File '{shift_file_name}' not found"}), 404 + if shift_model not in ('WLF', 'hybrid'): + return jsonify({'message': 'The shift factor model must be one of WLF, hybrid'}), 400 + + shiftData = upload_init(shift_file_name, 'shift') + if not shiftData: + return jsonify({'message': f"File '{shift_file_name}' is empty"}), 400 + + T = np.asarray(shiftData['Temperature'], dtype=float) + a_T = np.asarray(shiftData['a_T'], dtype=float) + + if shift_model == 'WLF': + if Tg is None: + return jsonify({'message': 'Tg is required for WLF coefficient fitting'}), 400 + C1_fit, C2_fit = fit_wlf_coefficients( + T, a_T, T_ref=Tg, + C1=C1, C2=C2, + fix_C1=(C1 is not None), + fix_C2=(C2 is not None), + ) + result_data = { + 'transform_method': 'WLF', + 'Tg': Tg, 'C1': C1_fit, 'C2': C2_fit, + 'Ea': None, 'TL': None, + } + else: # hybrid + if TL is None: + return jsonify({'message': 'TL is required for hybrid coefficient fitting'}), 400 + C1_fit, C2_fit, Ea_fit = fit_hybrid_coefficients( + T, a_T, TL=TL, + C1=C1, C2=C2, Ea=Ea, + fix_C1=(C1 is not None), + fix_C2=(C2 is not None), + fix_Ea=(Ea is not None), + ) + result_data = { + 'transform_method': 'hybrid', + 'Tg': None, 'C1': C1_fit, 'C2': C2_fit, + 'Ea': Ea_fit, 'TL': TL, + } + + end_time = datetime.datetime.now() + latency = f"{(end_time - start_time).total_seconds()} seconds" + json_data = json.dumps(result_data, indent=4) + response = Response(json_data, content_type='application/json; charset=utf-8', status=200) + response.headers['startTime'] = start_time + response.headers['endTime'] = end_time + response.headers['latency'] = str(latency) + response.headers['responseId'] = request_id + return response + if not file_name: return jsonify({'message': 'No file name provided'}), 400 diff --git a/services/tests/dynamfit/test_dynamfit2.py b/services/tests/dynamfit/test_dynamfit2.py index 0a6933060..56e0f8354 100644 --- a/services/tests/dynamfit/test_dynamfit2.py +++ b/services/tests/dynamfit/test_dynamfit2.py @@ -2,8 +2,12 @@ import os os.environ['OPENBLAS_NUM_THREADS'] = '1' import sys +import json +import tempfile +import datetime import numpy as np import pandas as pd +from unittest.mock import patch # Append the directory above 'test' to sys.path to find the 'app' module sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) @@ -24,6 +28,10 @@ tts_frequency_to_temperature, argmax_peak, update_line_chart, + fit_wlf_coefficients, + fit_hybrid_coefficients, + UNIVERSAL_WLF_C1, + UNIVERSAL_WLF_C2, ) from app.config import Config from app.utils.util import upload_init @@ -1265,5 +1273,492 @@ def test_temperature_per_modulus_error_columns_flow_through_tts(self): self.assertEqual(pairs_out, pairs_in) +class TestFitWlfCoefficients(unittest.TestCase): + """Tests for fit_wlf_coefficients — pure function, no Flask app needed.""" + + T_REF = 25.0 + C1_TRUE = 14.0 + C2_TRUE = 45.0 + + @classmethod + def setUpClass(cls): + # Deterministic synthetic shift data: generate exact a_T from wlf_shift. + cls.T = np.linspace(30.0, 80.0, 20) + cls.a_T = wlf_shift(cls.T, cls.T_REF, cls.C1_TRUE, cls.C2_TRUE) + + def test_round_trip_recovers_C1_and_C2(self): + # Both params free — fit must recover the true values to tight tolerance. + C1_fit, C2_fit = fit_wlf_coefficients( + self.T, self.a_T, self.T_REF, + C1=self.C1_TRUE, C2=self.C2_TRUE, + ) + self.assertAlmostEqual(C1_fit, self.C1_TRUE, places=4) + self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=4) + + def test_fixed_C1_returned_exactly_and_C2_fitted(self): + # C1 is pinned; C2 must be fitted back to truth. + C1_fit, C2_fit = fit_wlf_coefficients( + self.T, self.a_T, self.T_REF, + C1=self.C1_TRUE, C2=self.C2_TRUE, + fix_C1=True, + ) + self.assertEqual(C1_fit, self.C1_TRUE) # exact — no optimizer touched it + self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=4) + + def test_default_initial_guesses_converge(self): + # Omit C1 and C2 so the function falls back to UNIVERSAL_WLF_* as p0. + # Data generated from (C1=UNIVERSAL_WLF_C1, C2=UNIVERSAL_WLF_C2) so + # the universal constants are both the truth and the starting point. + T = np.linspace(30.0, 80.0, 20) + a_T = wlf_shift(T, self.T_REF, UNIVERSAL_WLF_C1, UNIVERSAL_WLF_C2) + C1_fit, C2_fit = fit_wlf_coefficients(T, a_T, self.T_REF) + self.assertAlmostEqual(C1_fit, UNIVERSAL_WLF_C1, places=4) + self.assertAlmostEqual(C2_fit, UNIVERSAL_WLF_C2, places=4) + + def test_nonpositive_a_T_raises_value_error(self): + # a_T containing zero is physically meaningless; log10 is undefined. + bad_a_T = self.a_T.copy() + bad_a_T[3] = 0.0 + with self.assertRaises(ValueError): + fit_wlf_coefficients(self.T, bad_a_T, self.T_REF) + + def test_negative_a_T_raises_value_error(self): + bad_a_T = self.a_T.copy() + bad_a_T[0] = -1.0 + with self.assertRaises(ValueError): + fit_wlf_coefficients(self.T, bad_a_T, self.T_REF) + + def test_cold_data_both_free_converges_and_c2_respects_bound(self): + # Regression: when T spans far below T_ref (cold side), the default + # UNIVERSAL_WLF_C2 initial guess lands near the WLF pole and the + # optimizer evaluates 10**exponent values that overflow float64. + # The fix applies an analytic log10(a_T) model so overflow can't happen, + # and enforces C2 >= c2_min = (T_ref - min(T)) + 1.0 throughout. + T_ref_local = 20.0 + T = np.linspace(-30.0, 70.0, 21) # T_min = -30 → c2_min = 51 + # True parameters well away from the pole, deterministic ground truth. + C1_true, C2_true = 22.0, 180.0 + a_T = wlf_shift(T, T_ref_local, C1_true, C2_true) + c2_min = (T_ref_local - float(T.min())) + 1.0 # = 51.0 + + # Both C1 and C2 free, default initial guesses — this is the case that + # used to raise ValueError before the fix. + C1_fit, C2_fit = fit_wlf_coefficients(T, a_T, T_ref_local) + + self.assertTrue(np.isfinite(C1_fit) and C1_fit > 0, + f"Expected finite positive C1, got {C1_fit}") + self.assertGreaterEqual(C2_fit, c2_min, + f"C2_fit {C2_fit:.4f} < c2_min {c2_min:.4f} — bound not respected") + + +class TestFitHybridCoefficients(unittest.TestCase): + """Tests for fit_hybrid_coefficients — pure function, no Flask app needed.""" + + TL = 25.0 # WLF/Arrhenius crossover — required input, never fitted + C1_TRUE = 14.0 + C2_TRUE = 45.0 + EA_TRUE = 80.0 # kJ/mol — well within curve_fit's convergence basin + + @classmethod + def setUpClass(cls): + # Span both sides of TL so both Arrhenius and WLF branches are exercised. + cls.T = np.linspace(5.0, 70.0, 30) + cls.a_T = hybrid_shift(cls.T, cls.TL, cls.C1_TRUE, cls.C2_TRUE, cls.EA_TRUE) + + def test_round_trip_recovers_C1_C2_and_Ea(self): + C1_fit, C2_fit, Ea_fit = fit_hybrid_coefficients( + self.T, self.a_T, self.TL, + C1=self.C1_TRUE, C2=self.C2_TRUE, Ea=self.EA_TRUE, + ) + self.assertAlmostEqual(C1_fit, self.C1_TRUE, places=4) + self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=4) + self.assertAlmostEqual(Ea_fit, self.EA_TRUE, places=4) + + def test_fixed_Ea_returned_exactly_and_others_fitted(self): + # Ea is pinned; C1 and C2 must be recovered from the data. + C1_fit, C2_fit, Ea_fit = fit_hybrid_coefficients( + self.T, self.a_T, self.TL, + C1=self.C1_TRUE, C2=self.C2_TRUE, Ea=self.EA_TRUE, + fix_Ea=True, + ) + self.assertEqual(Ea_fit, self.EA_TRUE) # exact — never passed to optimizer + self.assertAlmostEqual(C1_fit, self.C1_TRUE, places=4) + self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=4) + + def test_nonpositive_a_T_raises_value_error(self): + bad_a_T = self.a_T.copy() + bad_a_T[5] = 0.0 + with self.assertRaises(ValueError): + fit_hybrid_coefficients(self.T, bad_a_T, self.TL) + + +# --------------------------------------------------------------------------- +# Route-level tests for the fit_shift_coefficients branch of /dynamfit/extract/ +# --------------------------------------------------------------------------- +import jwt +from flask import Flask +from app.dynamfit.routes import dynamfit +from app.config import Config + + +def _make_app(): + """ + Build a minimal Flask test app that registers only the dynamfit blueprint, + avoiding the FileHandler('/services/services_app.log') crash in create_app(). + SECRET_KEY is fixed so we can mint tokens without an .env file. + """ + app = Flask(__name__) + app.config['SECRET_KEY'] = 'test-secret' + app.config['TESTING'] = True + app.register_blueprint(dynamfit) + return app + + +def _make_token(secret='test-secret', req_id='test-req-id'): + """Mint a JWT with the reqId field that token_required expects.""" + payload = { + 'reqId': req_id, + 'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=600), + } + return jwt.encode(payload, secret, algorithm='HS256') + + +# Synthetic shift data (5 points, all positive a_T) used across tests. +_SHIFT_T = np.array([0.0, 10.0, 20.0, 30.0, 40.0]) +_SHIFT_A_T = np.array([3.0, 2.0, 1.0, 0.5, 0.25]) +_SHIFT_DATA = {'Temperature': _SHIFT_T, 'a_T': _SHIFT_A_T} + +# Representative WLF coefficients returned by the mocked fit functions. +_C1_RETURNED = 14.0 +_C2_RETURNED = 45.0 +_EA_RETURNED = 80.0 + + +class TestExtractFitShiftCoefficientsRoute(unittest.TestCase): + """ + Route-level tests for the fit_shift_coefficients=true short-circuit branch + of POST /dynamfit/extract/. + + Strategy: + - Build a bare Flask app (no create_app) to avoid the Docker-only FileHandler. + - Patch Config.SECRET_KEY so token_required decodes our test tokens. + - Patch check_file_exists and upload_init at the route module boundary so + tests never touch disk and don't depend on real file content. + - Patch fit_wlf_coefficients and fit_hybrid_coefficients so tests verify + route wiring, not fit math (fit math is covered in TestFitWlfCoefficients + and TestFitHybridCoefficients above). + """ + + @classmethod + def setUpClass(cls): + Config.SECRET_KEY = 'test-secret' + cls.app = _make_app() + cls.client = cls.app.test_client() + cls.token = _make_token() + cls.headers = {'Authorization': f'Bearer {cls.token}', + 'Content-Type': 'application/json'} + + def _post(self, body): + return self.client.post( + '/dynamfit/extract/', + data=json.dumps(body), + headers=self.headers, + ) + + def _base_wlf_body(self, **overrides): + body = { + 'fit_shift_coefficients': True, + 'shift_file_name': 'shift.txt', + 'transform_method': 'WLF', + 'Tg': 25.0, + } + body.update(overrides) + return body + + def _base_hybrid_body(self, **overrides): + body = { + 'fit_shift_coefficients': True, + 'shift_file_name': 'shift.txt', + 'transform_method': 'hybrid', + 'TL': 25.0, + } + body.update(overrides) + return body + + # ------------------------------------------------------------------ + # Happy paths + # ------------------------------------------------------------------ + + @patch('app.dynamfit.routes.fit_wlf_coefficients', + return_value=(_C1_RETURNED, _C2_RETURNED)) + @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_wlf_happy_path_returns_six_coefficient_keys( + self, _exists, _upload, _fit): + resp = self._post(self._base_wlf_body()) + self.assertEqual(resp.status_code, 200) + data = json.loads(resp.data) + self.assertEqual( + set(data.keys()), + {'transform_method', 'Tg', 'C1', 'C2', 'Ea', 'TL'}, + ) + + @patch('app.dynamfit.routes.fit_wlf_coefficients', + return_value=(_C1_RETURNED, _C2_RETURNED)) + @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_wlf_happy_path_ea_and_tl_are_null( + self, _exists, _upload, _fit): + resp = self._post(self._base_wlf_body()) + data = json.loads(resp.data) + self.assertIsNone(data['Ea']) + self.assertIsNone(data['TL']) + + @patch('app.dynamfit.routes.fit_wlf_coefficients', + return_value=(_C1_RETURNED, _C2_RETURNED)) + @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_wlf_happy_path_c1_c2_are_plain_floats( + self, _exists, _upload, _fit): + # Confirms JSON serialization works: numpy scalars would raise TypeError. + resp = self._post(self._base_wlf_body()) + data = json.loads(resp.data) + self.assertIsInstance(data['C1'], float) + self.assertIsInstance(data['C2'], float) + + @patch('app.dynamfit.routes.fit_hybrid_coefficients', + return_value=(_C1_RETURNED, _C2_RETURNED, _EA_RETURNED)) + @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_hybrid_happy_path_tg_null_ea_tl_populated( + self, _exists, _upload, _fit): + resp = self._post(self._base_hybrid_body()) + self.assertEqual(resp.status_code, 200) + data = json.loads(resp.data) + self.assertIsNone(data['Tg']) + self.assertIsInstance(data['Ea'], float) + self.assertIsInstance(data['TL'], float) + + @patch('app.dynamfit.routes.fit_wlf_coefficients', + return_value=(_C1_RETURNED, _C2_RETURNED)) + @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_wlf_supplied_c1_passes_fix_c1_true_to_fit( + self, _exists, _upload, mock_fit): + """When C1 is in the body the route must pass fix_C1=True to the fit function.""" + resp = self._post(self._base_wlf_body(C1=99.0)) + self.assertEqual(resp.status_code, 200) + _, kwargs = mock_fit.call_args + self.assertTrue(kwargs.get('fix_C1')) + + @patch('app.dynamfit.routes.fit_wlf_coefficients', + return_value=(_C1_RETURNED, _C2_RETURNED)) + @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_wlf_response_lacks_chart_keys( + self, _exists, _upload, _fit): + """The short-circuit branch must NOT include chart output keys.""" + resp = self._post(self._base_wlf_body()) + data = json.loads(resp.data) + for chart_key in ('complex-chart', 'mytable', 'multi', 'response'): + self.assertNotIn(chart_key, data) + + # ------------------------------------------------------------------ + # Validation / short-circuit errors + # ------------------------------------------------------------------ + + def test_missing_shift_file_name_returns_400(self): + body = { + 'fit_shift_coefficients': True, + 'transform_method': 'WLF', + 'Tg': 25.0, + # shift_file_name intentionally omitted + } + resp = self._post(body) + self.assertEqual(resp.status_code, 400) + + @patch('app.dynamfit.routes.check_file_exists', return_value=False) + def test_shift_file_not_found_returns_404(self, _exists): + resp = self._post(self._base_wlf_body()) + self.assertEqual(resp.status_code, 404) + + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_invalid_transform_method_returns_400(self, _exists): + body = self._base_wlf_body(transform_method='manual') + resp = self._post(body) + self.assertEqual(resp.status_code, 400) + + @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_wlf_missing_tg_returns_400(self, _exists, _upload): + body = { + 'fit_shift_coefficients': True, + 'shift_file_name': 'shift.txt', + 'transform_method': 'WLF', + # Tg intentionally omitted + } + resp = self._post(body) + self.assertEqual(resp.status_code, 400) + + @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_hybrid_missing_tl_returns_400(self, _exists, _upload): + body = { + 'fit_shift_coefficients': True, + 'shift_file_name': 'shift.txt', + 'transform_method': 'hybrid', + # TL intentionally omitted + } + resp = self._post(body) + self.assertEqual(resp.status_code, 400) + + @patch('app.dynamfit.routes.upload_init', return_value=None) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_empty_shift_file_returns_400(self, _exists, _upload): + resp = self._post(self._base_wlf_body()) + self.assertEqual(resp.status_code, 400) + + +# --------------------------------------------------------------------------- +# End-to-end tests: real files + real optimizer, no mocking of upload_init or +# fit functions. These complement TestExtractFitShiftCoefficientsRoute (which +# mocks everything and verifies route wiring) by verifying that the optimizer +# actually converges and produces a fit that reconstructs the data to a +# meaningful tolerance. +# +# WLF calibration (agilus30 20C): both C1 and C2 fitted freely from defaults. +# The data spans T down to -30 °C (50 °C below T_ref=20), which previously +# caused a 10**exponent overflow in the optimizer (ValueError → HTTP 400) with +# the default UNIVERSAL_WLF_C2=51.6 initial guess. The pole-avoidance fix +# uses an analytic log10(a_T) model so overflow can't propagate as an exception, +# and enforces C2 >= c2_min throughout. Observed log10-RMSE ≈ 0.437 (threshold: 0.55). +# +# Hybrid calibration (VeroCyan 80C): all three parameters (C1, C2, Ea) fitted +# freely from defaults. Observed log10-RMSE ≈ 0.844 (threshold: 1.0). +# --------------------------------------------------------------------------- + +_REAL_FILES_DIR = os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', '..', 'app', 'dynamfit', 'files') +) + + +class TestExtractFitShiftCoefficientsEndToEnd(unittest.TestCase): + """ + E2E tests for the fit_shift_coefficients branch of POST /dynamfit/extract/. + + Real files on disk, real optimizer — no mocking of upload_init, check_file_exists, + or the fit functions. Config.FILES_DIRECTORY is pointed at the checked-in + files/ directory and restored in tearDownClass. + """ + + _orig_files_dir = None + + @classmethod + def setUpClass(cls): + cls._orig_files_dir = Config.FILES_DIRECTORY + Config.FILES_DIRECTORY = _REAL_FILES_DIR + Config.SECRET_KEY = 'test-secret' + cls.app = _make_app() + cls.client = cls.app.test_client() + cls.token = _make_token() + cls.headers = { + 'Authorization': f'Bearer {cls.token}', + 'Content-Type': 'application/json', + } + + @classmethod + def tearDownClass(cls): + Config.FILES_DIRECTORY = cls._orig_files_dir + + def _post(self, body): + return self.client.post( + '/dynamfit/extract/', + data=json.dumps(body), + headers=self.headers, + ) + + def test_wlf_e2e_converges_and_fit_is_accurate(self): + """ + WLF fit on agilus30 shift data (T_ref=20 °C), BOTH C1 and C2 free. + + This is the direct regression test for the pole-avoidance fix. Before + the fix, the default UNIVERSAL_WLF_C2=51.6 initial guess produced a + 10**exponent overflow on data spanning T down to -30 °C, causing a + ValueError → HTTP 400. The fix applies an analytic log10(a_T) model + with a C2 lower bound, so the optimizer can start from the universal + defaults and converge. Observed log10-RMSE ≈ 0.437 (threshold: 0.55). + """ + body = { + 'fit_shift_coefficients': True, + 'shift_file_name': 'agilus30 (8) shift factors 20C clean.txt', + 'transform_method': 'WLF', + 'Tg': 20.0, + # No C1 or C2 — both are freely fitted from UNIVERSAL_WLF_* defaults + } + resp = self._post(body) + self.assertEqual(resp.status_code, 200) + + result = json.loads(resp.data) + self.assertEqual(result['transform_method'], 'WLF') + self.assertIsNone(result['Ea']) + self.assertIsNone(result['TL']) + + C1 = result['C1'] + C2 = result['C2'] + self.assertTrue(np.isfinite(C1) and C1 > 0, f"Expected finite positive C1, got {C1}") + self.assertTrue(np.isfinite(C2) and C2 > 0, f"Expected finite positive C2, got {C2}") + + # RMSE check: load real data and reconstruct with fitted coefficients. + # Both-free observed RMSE ≈ 0.437; threshold 0.55 allows optimizer + # variance without masking a regression to a grossly wrong solution. + shift_data = upload_init('agilus30 (8) shift factors 20C clean.txt', 'shift') + T = np.asarray(shift_data['Temperature'], dtype=float) + a_T = np.asarray(shift_data['a_T'], dtype=float) + reconstructed = wlf_shift(T, 20.0, C1, C2) + log_rmse = np.sqrt(np.mean((np.log10(reconstructed) - np.log10(a_T)) ** 2)) + self.assertLess(log_rmse, 0.55, + f"WLF log10-RMSE {log_rmse:.4f} exceeds 0.55 — fit likely diverged") + + def test_hybrid_e2e_converges_and_fit_is_accurate(self): + """ + Hybrid Arrhenius/WLF fit on VeroCyan shift data (T_L=80 °C). + + All three parameters (C1, C2, Ea) are fitted freely from default initial + guesses — no body-level C1/C2/Ea, so the optimizer is fully exercised. + The route must return 200 with finite positive C1, C2, and Ea; Tg must + be null; reconstructed shift factors must agree with the data in log10 space. + """ + body = { + 'fit_shift_coefficients': True, + 'shift_file_name': 'VeroCyan (5) shift factors 80C clean.txt', + 'transform_method': 'hybrid', + 'TL': 80.0, + # No C1, C2, Ea — all three are freely fitted + } + resp = self._post(body) + self.assertEqual(resp.status_code, 200) + + result = json.loads(resp.data) + self.assertEqual(result['transform_method'], 'hybrid') + self.assertIsNone(result['Tg']) + + C1 = result['C1'] + C2 = result['C2'] + Ea = result['Ea'] + for name, val in [('C1', C1), ('C2', C2), ('Ea', Ea)]: + self.assertTrue(np.isfinite(val) and val > 0, + f"Expected finite positive {name}, got {val}") + + # RMSE check: observed RMSE ≈ 0.844; threshold 1.0 is meaningful without + # being so tight that minor optimizer variance causes flakiness. + shift_data = upload_init('VeroCyan (5) shift factors 80C clean.txt', 'shift') + T = np.asarray(shift_data['Temperature'], dtype=float) + a_T = np.asarray(shift_data['a_T'], dtype=float) + reconstructed = hybrid_shift(T, 80.0, C1, C2, Ea) + log_rmse = np.sqrt(np.mean((np.log10(reconstructed) - np.log10(a_T)) ** 2)) + self.assertLess(log_rmse, 1.0, + f"Hybrid log10-RMSE {log_rmse:.4f} exceeds 1.0 — fit likely diverged") + + if __name__ == '__main__': unittest.main() From 564024b89c2fe68a0ff9e03732539f2931edf4f0 Mon Sep 17 00:00:00 2001 From: richardsheridan Date: Fri, 29 May 2026 15:20:25 -0400 Subject: [PATCH 06/18] feat(dynamfit): co-fit reference offset a_T_ref in hybrid shift-factor fit Shift-factor files are not necessarily referenced to TL, but hybrid_shift was always 1 at TL. Now fit_hybrid_coefficients co-fits a_T_ref so even cases where TL is not aligned with the data will work. --- services/app/dynamfit/dynamfit2.py | 99 +++++++++++++------ services/app/dynamfit/routes.py | 7 +- services/tests/dynamfit/test_dynamfit2.py | 115 +++++++++++++++++++++- 3 files changed, 186 insertions(+), 35 deletions(-) diff --git a/services/app/dynamfit/dynamfit2.py b/services/app/dynamfit/dynamfit2.py index 77ed83f3c..efc8e707a 100644 --- a/services/app/dynamfit/dynamfit2.py +++ b/services/app/dynamfit/dynamfit2.py @@ -405,7 +405,7 @@ def _arr_shift(T: np.ndarray, T_ref: float, Ea: float) -> np.ndarray: Calculate the Arrhenius shift factor a_T. Implements the Arrhenius equation: - log10(a_T) = (Ea_J_per_mol / (2.303 * R)) * (1/T_K - 1/T_ref_K) + ln(a_T) = (Ea_J_per_mol / R) * (1/T_K - 1/T_ref_K) where T_K and T_ref_K are absolute temperatures in Kelvin (T + 273.15) and Ea_J_per_mol = 1000 * Ea. @@ -424,12 +424,12 @@ def _arr_shift(T: np.ndarray, T_ref: float, Ea: float) -> np.ndarray: """ T_ref_K = T_ref + 273.15 T_K = T + 273.15 - m = (Ea * 1000.0) / (2.303 * R) + m = (Ea * 1000.0) / R with _fp_safe( "absolute-zero singularity detected when calculating Arrhenius shift. " "Please adjust parameters or manually provide shift factors." ): - a_T = np.power(10.0, m * (1.0 / T_K - 1.0 / T_ref_K)) + a_T = np.exp(m * (1.0 / T_K - 1.0 / T_ref_K)) if not np.all(np.isfinite(a_T)): raise FloatingPointError("Non-finite result in Arrhenius computation") return a_T @@ -441,9 +441,11 @@ def hybrid_shift( C1: float, C2: float, Ea: float, + a_T_ref: float=1.0, + ascending: bool=None, ) -> np.ndarray: """ - Calculate hybrid Arrhenius/WLF shift factors. + Calculate hybrid Arrhenius/WLF shift factors at T_ref. Applies the WLF equation to temperatures above T_ref and the Arrhenius equation to temperatures at or below T_ref. With two or more elements, T @@ -459,28 +461,33 @@ def hybrid_shift( C1 (float): WLF parameter C1. C2 (float): WLF parameter C2. Ea (float): Arrhenius activation energy in kJ/mol. + a_T_ref (float): convenience argument to scale returned shift factors. + ascending (bool): Flag to skip descending handling if pre-calculated elsewhere Returns: - numpy.ndarray: 1-D array of shift factors a_T, same length as T. + numpy.ndarray: 1-D array of shift factors a_T, same length as T, where + np.interp(T_ref,T,a_T) ~ a_T_ref Raises: ValueError: If the WLF or Arrhenius sub-calculation hits a singularity. """ T = np.atleast_1d(T) - assert T.ndim == 1, "T must be a 1-D numpy.ndarray or scalar" - assert np.all(np.isfinite(T)), "T must contain only finite values" - diffs = np.diff(T) - if len(diffs) > 0: - ascending = bool(np.all(diffs > 0)) # base case from loader utility - assert ascending or np.all(diffs < 0), "T must be monotonically sorted" - else: - ascending = True # single element; direction irrelevant - - T_work = T if ascending else T[::-1] - k = np.searchsorted(T_work, T_ref + float_correction, side='right') - a_T_arr = _arr_shift(T_work[:k], T_ref, Ea) - a_T_wlf = wlf_shift(T_work[k:], T_ref, C1, C2) + if ascending is None: + assert T.ndim == 1, "T must be a 1-D numpy.ndarray or scalar" + assert np.all(np.isfinite(T)), "T must contain only finite values" + diffs = np.diff(T) + if len(diffs) > 0: + ascending = bool(np.all(diffs > 0)) # base case from loader utility + assert ascending or np.all(diffs < 0), "T must be monotonically sorted" + else: + ascending = True # single element; direction irrelevant + + T = T if ascending else T[::-1] + k = np.searchsorted(T, T_ref + float_correction, side='right') + a_T_arr = _arr_shift(T[:k], T_ref, Ea) + a_T_wlf = wlf_shift(T[k:], T_ref, C1, C2) result = np.concatenate((a_T_arr, a_T_wlf)) + result *= a_T_ref return result if ascending else result[::-1] @@ -688,10 +695,13 @@ def fit_hybrid_coefficients( fix_Ea (bool): If True, hold Ea constant at its supplied value. Returns: - tuple: (C1_fit, C2_fit, Ea_fit) — fitted (or fixed) hybrid coefficients. + tuple: (C1_fit, C2_fit, Ea_fit, a_T_ref) — fitted (or fixed) hybrid + coefficients plus the co-fitted reference shift factor a_T_ref (the + data's shift factor at TL; 1.0 when the data is referenced to TL). Raises: - ValueError: If any a_T value is non-positive (log10 undefined), or if + ValueError: If any a_T value is non-positive (log10 undefined), if TL + falls outside the data range so a model segment is degenerate, or if curve_fit does not converge. """ T = np.atleast_1d(T) @@ -705,29 +715,57 @@ def fit_hybrid_coefficients( "All shift factors a_T must be positive (log10 is undefined for " "non-positive values). Check the input shift-factor data." ) + assert np.all(np.isfinite(T)), "T must contain only finite values" _EA_DEFAULT = 100.0 # kJ/mol — broad mid-range starting point C1_0 = C1 if C1 is not None else UNIVERSAL_WLF_C1 C2_0 = C2 if C2 is not None else UNIVERSAL_WLF_C2 Ea_0 = Ea if Ea is not None else _EA_DEFAULT + diffs = np.diff(T) + if len(diffs) > 0: + ascending = bool(np.all(diffs > 0)) # base case from loader utility + assert ascending or np.all(diffs < 0), "T must be monotonically sorted" + else: + ascending = True # single element; direction irrelevant + + if not fix_Ea and TL <= T[-(not ascending)]: + raise ValueError("TL is below the range of the shift factor data, leading" + "to a degenerate Arrhenius segment of hybrid fit. Check data " + "or supply a different TL.") + + if not (fix_C1 or fix_C2) and TL >= T[-(ascending)]: + raise ValueError("TL is above the range of the shift factor data, leading" + "to a degenerate WLF segment of hybrid fit. Check data or " + "supply a different TL.") + + # The shift factors may not be referenced to TL, but hybrid_shift is always 1 + # at TL. So a_T_ref (the data's shift factor at TL) is co-fitted as a vertical + # offset rather than read off a single interpolated point: interpolating + # across the WLF/Arrhenius kink at TL biases the estimate (and hence the whole + # fit) even when the data is referenced exactly to TL. The interp value only + # seeds the optimizer; np.interp needs ascending samples, so order them. log10_a_T = np.log10(a_T) + T_asc = T if ascending else T[::-1] + log10_asc = log10_a_T if ascending else log10_a_T[::-1] + a_T_ref_0 = 10 ** float(np.interp(TL, T_asc, log10_asc)) # Build a model over only the free parameters; fixed ones are closed over. # This avoids passing degenerate lb==ub bounds to curve_fit, which some - # scipy versions reject. - free_names = [n for n, fixed in [('C1', fix_C1), ('C2', fix_C2), ('Ea', fix_Ea)] + # scipy versions reject. a_T_ref is always free (the co-fitted offset). + free_names = [n for n, fixed in + [('C1', fix_C1), ('C2', fix_C2), ('Ea', fix_Ea), ('a_T_ref', False)] if not fixed] - p0 = [v for v, fixed in [(C1_0, fix_C1), (C2_0, fix_C2), (Ea_0, fix_Ea)] + p0 = [v for v, fixed in + [(C1_0, fix_C1), (C2_0, fix_C2), (Ea_0, fix_Ea), (a_T_ref_0, False)] if not fixed] - if not free_names: - return float(C1_0), float(C2_0), float(Ea_0) - - # Simple non-negativity floor on free C2. Hybrid's WLF branch only sees - # T > TL (not cold data), so the 10**exponent overflow that afflicts + # C2 floored at 1.0 to stay off the WLF pole; a_T_ref floored just above 0 so + # the log10 wrapper in _curve_fit_shift stays finite. Hybrid's WLF branch only + # sees T > TL (not cold data), so the 10**exponent overflow that afflicts # fit_wlf_coefficients cannot occur here; log10_space=False is intentional. - lb = [-np.inf if n != 'C2' else 1.0 for n in free_names] + _floor = {'C2': 1.0, 'a_T_ref': np.finfo(float).tiny} + lb = [_floor.get(n, -np.inf) for n in free_names] ub = [np.inf] * len(free_names) def model(T_arg, *free_vals): @@ -737,6 +775,8 @@ def model(T_arg, *free_vals): vals.get('C1', C1_0), vals.get('C2', C2_0), vals.get('Ea', Ea_0), + vals.get('a_T_ref', a_T_ref_0), + ascending, ) fitted = _curve_fit_shift(model, T, log10_a_T, p0=p0, bounds=(lb, ub)) @@ -745,6 +785,7 @@ def model(T_arg, *free_vals): float(result.get('C1', C1_0)), float(result.get('C2', C2_0)), float(result.get('Ea', Ea_0)), + float(result.get('a_T_ref', a_T_ref_0)), ) diff --git a/services/app/dynamfit/routes.py b/services/app/dynamfit/routes.py index 57a6a3085..be000dbd3 100644 --- a/services/app/dynamfit/routes.py +++ b/services/app/dynamfit/routes.py @@ -111,7 +111,12 @@ def extract_data_from_file(request_id): else: # hybrid if TL is None: return jsonify({'message': 'TL is required for hybrid coefficient fitting'}), 400 - C1_fit, C2_fit, Ea_fit = fit_hybrid_coefficients( + # TODO: a_T_ref (the co-fitted vertical reference offset) is + # discarded for now. There's a good chance the frontend will need + # it to reconstruct/align master curves whose shift factors aren't + # referenced to TL — surface it in result_data when the frontend + # is refactored to consume it (not part of this change). + C1_fit, C2_fit, Ea_fit, _a_T_ref = fit_hybrid_coefficients( T, a_T, TL=TL, C1=C1, C2=C2, Ea=Ea, fix_C1=(C1 is not None), diff --git a/services/tests/dynamfit/test_dynamfit2.py b/services/tests/dynamfit/test_dynamfit2.py index 56e0f8354..ece23d899 100644 --- a/services/tests/dynamfit/test_dynamfit2.py +++ b/services/tests/dynamfit/test_dynamfit2.py @@ -585,6 +585,27 @@ def test_rejects_unsorted(self): with self.assertRaises(AssertionError): hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + def test_a_T_ref_scales_output(self): + # a_T_ref multiplies every returned shift factor. + T = np.array([10.0, 20.0, 30.0, 40.0]) + base = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + scaled = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA, a_T_ref=5.0) + np.testing.assert_allclose(scaled, 5.0 * base) + + def test_explicit_ascending_matches_autodetect(self): + # Passing ascending explicitly bypasses direction detection but must + # yield the same numbers as letting hybrid_shift detect it. + T = np.array([10.0, 20.0, 30.0, 40.0]) + auto = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + forced = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA, ascending=True) + np.testing.assert_allclose(forced, auto) + + def test_explicit_descending_matches_autodetect(self): + T = np.array([40.0, 30.0, 20.0, 10.0]) + auto = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + forced = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA, ascending=False) + np.testing.assert_allclose(forced, auto) + class TestInverseWlfShift(unittest.TestCase): T_REF = 100.0 @@ -1366,7 +1387,7 @@ def setUpClass(cls): cls.a_T = hybrid_shift(cls.T, cls.TL, cls.C1_TRUE, cls.C2_TRUE, cls.EA_TRUE) def test_round_trip_recovers_C1_C2_and_Ea(self): - C1_fit, C2_fit, Ea_fit = fit_hybrid_coefficients( + C1_fit, C2_fit, Ea_fit, a_T_ref = fit_hybrid_coefficients( self.T, self.a_T, self.TL, C1=self.C1_TRUE, C2=self.C2_TRUE, Ea=self.EA_TRUE, ) @@ -1376,7 +1397,7 @@ def test_round_trip_recovers_C1_C2_and_Ea(self): def test_fixed_Ea_returned_exactly_and_others_fitted(self): # Ea is pinned; C1 and C2 must be recovered from the data. - C1_fit, C2_fit, Ea_fit = fit_hybrid_coefficients( + C1_fit, C2_fit, Ea_fit, a_T_ref = fit_hybrid_coefficients( self.T, self.a_T, self.TL, C1=self.C1_TRUE, C2=self.C2_TRUE, Ea=self.EA_TRUE, fix_Ea=True, @@ -1391,6 +1412,83 @@ def test_nonpositive_a_T_raises_value_error(self): with self.assertRaises(ValueError): fit_hybrid_coefficients(self.T, bad_a_T, self.TL) + def test_returns_a_T_ref_near_one_for_TL_referenced_data(self): + # setUpClass data is built referenced to TL (hybrid_shift, a_T_ref=1), so + # the co-fitted a_T_ref recovers ~1.0. + *_, a_T_ref = fit_hybrid_coefficients( + self.T, self.a_T, self.TL, + C1=self.C1_TRUE, C2=self.C2_TRUE, Ea=self.EA_TRUE, + ) + self.assertAlmostEqual(a_T_ref, 1.0, places=4) + + def test_cofits_offset_for_unreferenced_data(self): + # Data scaled by a known constant K (reference shifted off TL): the fit + # recovers K as a_T_ref and the same C1/C2/Ea. + K = 12.0 + C1_fit, C2_fit, Ea_fit, a_T_ref = fit_hybrid_coefficients( + self.T, K * self.a_T, self.TL, + C1=self.C1_TRUE, C2=self.C2_TRUE, Ea=self.EA_TRUE, + ) + self.assertAlmostEqual(a_T_ref, K, places=3) + self.assertAlmostEqual(C1_fit, self.C1_TRUE, places=3) + self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=3) + self.assertAlmostEqual(Ea_fit, self.EA_TRUE, places=3) + + def test_degenerate_Arrhenius_guard_fires_and_suppressed_by_fix_Ea(self): + # All data above TL → empty Arrhenius segment → ValueError while Ea is + # free; fixing Ea suppresses the guard and the WLF-only fit succeeds. + T = np.linspace(30.0, 70.0, 20) + a_T = wlf_shift(T, 25.0, self.C1_TRUE, self.C2_TRUE) + with self.assertRaises(ValueError): + fit_hybrid_coefficients(T, a_T, TL=25.0) + C1_fit, C2_fit, Ea_fit, _ = fit_hybrid_coefficients( + T, a_T, TL=25.0, Ea=self.EA_TRUE, fix_Ea=True, + ) + self.assertEqual(Ea_fit, self.EA_TRUE) + self.assertAlmostEqual(C1_fit, self.C1_TRUE, places=3) + self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=3) + + def test_degenerate_WLF_guard_fires_and_suppressed_by_fix_C(self): + # All data below TL → empty WLF segment → ValueError while C1/C2 are + # free; fixing them suppresses the guard and the Arrhenius-only fit + # recovers Ea. + T = np.linspace(5.0, 20.0, 20) + a_T = _arr_shift(T, 25.0, self.EA_TRUE) + with self.assertRaises(ValueError): + fit_hybrid_coefficients(T, a_T, TL=25.0) + C1_fit, C2_fit, Ea_fit, _ = fit_hybrid_coefficients( + T, a_T, TL=25.0, C1=self.C1_TRUE, C2=self.C2_TRUE, + fix_C1=True, fix_C2=True, + ) + self.assertEqual((C1_fit, C2_fit), (self.C1_TRUE, self.C2_TRUE)) + self.assertAlmostEqual(Ea_fit, self.EA_TRUE, places=3) + + def test_all_fixed_returns_four_tuple_and_cofits_offset(self): + # C1/C2/Ea all fixed → echoed exactly; a_T_ref is still co-fitted. + K = 7.0 + C1_fit, C2_fit, Ea_fit, a_T_ref = fit_hybrid_coefficients( + self.T, K * self.a_T, self.TL, + C1=self.C1_TRUE, C2=self.C2_TRUE, Ea=self.EA_TRUE, + fix_C1=True, fix_C2=True, fix_Ea=True, + ) + self.assertEqual( + (C1_fit, C2_fit, Ea_fit), + (self.C1_TRUE, self.C2_TRUE, self.EA_TRUE), + ) + self.assertAlmostEqual(a_T_ref, K, places=3) + + def test_descending_T_round_trip(self): + # Reversed (descending) T/a_T must recover the same coefficients — checks + # the direction handling and the ascending-ordered a_T_ref interpolation. + C1_fit, C2_fit, Ea_fit, a_T_ref = fit_hybrid_coefficients( + self.T[::-1], self.a_T[::-1], self.TL, + C1=self.C1_TRUE, C2=self.C2_TRUE, Ea=self.EA_TRUE, + ) + self.assertAlmostEqual(C1_fit, self.C1_TRUE, places=4) + self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=4) + self.assertAlmostEqual(Ea_fit, self.EA_TRUE, places=4) + self.assertAlmostEqual(a_T_ref, 1.0, places=4) + # --------------------------------------------------------------------------- # Route-level tests for the fit_shift_coefficients branch of /dynamfit/extract/ @@ -1432,6 +1530,7 @@ def _make_token(secret='test-secret', req_id='test-req-id'): _C1_RETURNED = 14.0 _C2_RETURNED = 45.0 _EA_RETURNED = 80.0 +_A_T_REF_RETURNED = 1.0 class TestExtractFitShiftCoefficientsRoute(unittest.TestCase): @@ -1527,7 +1626,7 @@ def test_wlf_happy_path_c1_c2_are_plain_floats( self.assertIsInstance(data['C2'], float) @patch('app.dynamfit.routes.fit_hybrid_coefficients', - return_value=(_C1_RETURNED, _C2_RETURNED, _EA_RETURNED)) + return_value=(_C1_RETURNED, _C2_RETURNED, _EA_RETURNED, _A_T_REF_RETURNED)) @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) @patch('app.dynamfit.routes.check_file_exists', return_value=True) def test_hybrid_happy_path_tg_null_ea_tl_populated( @@ -1634,7 +1733,9 @@ def test_empty_shift_file_returns_400(self, _exists, _upload): # and enforces C2 >= c2_min throughout. Observed log10-RMSE ≈ 0.437 (threshold: 0.55). # # Hybrid calibration (VeroCyan 80C): all three parameters (C1, C2, Ea) fitted -# freely from defaults. Observed log10-RMSE ≈ 0.844 (threshold: 1.0). +# freely from defaults; a_T_ref (the vertical reference offset) is co-fitted and +# must be applied when reconstructing (the VeroCyan data is not referenced to TL, +# a_T_ref ≈ 9.07). Observed log10-RMSE ≈ 0.704 (threshold: 1.0). # --------------------------------------------------------------------------- _REAL_FILES_DIR = os.path.abspath( @@ -1754,7 +1855,11 @@ def test_hybrid_e2e_converges_and_fit_is_accurate(self): shift_data = upload_init('VeroCyan (5) shift factors 80C clean.txt', 'shift') T = np.asarray(shift_data['Temperature'], dtype=float) a_T = np.asarray(shift_data['a_T'], dtype=float) - reconstructed = hybrid_shift(T, 80.0, C1, C2, Ea) + # a_T_ref (the co-fitted vertical reference offset) is not surfaced by the + # route, so reconstruct it as the log-space offset a consumer would apply. + unscaled = hybrid_shift(T, 80.0, C1, C2, Ea) + a_T_ref = 10 ** np.mean(np.log10(a_T) - np.log10(unscaled)) + reconstructed = a_T_ref * unscaled log_rmse = np.sqrt(np.mean((np.log10(reconstructed) - np.log10(a_T)) ** 2)) self.assertLess(log_rmse, 1.0, f"Hybrid log10-RMSE {log_rmse:.4f} exceeds 1.0 — fit likely diverged") From e7aeac9ccfb228cec86c33fab031f3bedebcb82d Mon Sep 17 00:00:00 2001 From: richardsheridan Date: Mon, 1 Jun 2026 12:32:43 -0400 Subject: [PATCH 07/18] feat(dynamfit): Create new fit-shift route --- resfulservice/config/constant.js | 1 + services/app/dynamfit/routes.py | 173 ++++++++++++-------- services/tests/dynamfit/test_dynamfit2.py | 184 +++++++++++++++++++--- 3 files changed, 270 insertions(+), 88 deletions(-) diff --git a/resfulservice/config/constant.js b/resfulservice/config/constant.js index aea5ee8d8..7c68b9435 100644 --- a/resfulservice/config/constant.js +++ b/resfulservice/config/constant.js @@ -153,6 +153,7 @@ module.exports = { ManagedServiceRegister: { chemprops: '/chemprops/call/', dynamfit: '/dynamfit/extract/', + 'fit-shift': '/dynamfit/fit-shift/', loadxml: 'loadxml', ontology: '/ontology/extract/', 'validate-ontology': '/ontology/validate/', diff --git a/services/app/dynamfit/routes.py b/services/app/dynamfit/routes.py index be000dbd3..1771345a9 100644 --- a/services/app/dynamfit/routes.py +++ b/services/app/dynamfit/routes.py @@ -72,73 +72,6 @@ def extract_data_from_file(request_id): # add manual shift factor upload shift_file_name = data.get('shift_file_name', None) - fit_shift_coefficients = data.get('fit_shift_coefficients', False) - - # TODO: This branch should become its own route (e.g. POST /dynamfit/fit-shift/). - # The shift-domain fit is a special case: its only effective inputs are the - # shift file plus Tg or TL, and its only output is the fitted coefficients — - # no Prony fit, no charts. Grafted into /extract/ for now to avoid a new - # endpoint while the feature stabilises. - if fit_shift_coefficients: - if not shift_file_name: - return jsonify({'message': 'No shift file name provided'}), 400 - if not check_file_exists(shift_file_name): - return jsonify({'message': f"File '{shift_file_name}' not found"}), 404 - if shift_model not in ('WLF', 'hybrid'): - return jsonify({'message': 'The shift factor model must be one of WLF, hybrid'}), 400 - - shiftData = upload_init(shift_file_name, 'shift') - if not shiftData: - return jsonify({'message': f"File '{shift_file_name}' is empty"}), 400 - - T = np.asarray(shiftData['Temperature'], dtype=float) - a_T = np.asarray(shiftData['a_T'], dtype=float) - - if shift_model == 'WLF': - if Tg is None: - return jsonify({'message': 'Tg is required for WLF coefficient fitting'}), 400 - C1_fit, C2_fit = fit_wlf_coefficients( - T, a_T, T_ref=Tg, - C1=C1, C2=C2, - fix_C1=(C1 is not None), - fix_C2=(C2 is not None), - ) - result_data = { - 'transform_method': 'WLF', - 'Tg': Tg, 'C1': C1_fit, 'C2': C2_fit, - 'Ea': None, 'TL': None, - } - else: # hybrid - if TL is None: - return jsonify({'message': 'TL is required for hybrid coefficient fitting'}), 400 - # TODO: a_T_ref (the co-fitted vertical reference offset) is - # discarded for now. There's a good chance the frontend will need - # it to reconstruct/align master curves whose shift factors aren't - # referenced to TL — surface it in result_data when the frontend - # is refactored to consume it (not part of this change). - C1_fit, C2_fit, Ea_fit, _a_T_ref = fit_hybrid_coefficients( - T, a_T, TL=TL, - C1=C1, C2=C2, Ea=Ea, - fix_C1=(C1 is not None), - fix_C2=(C2 is not None), - fix_Ea=(Ea is not None), - ) - result_data = { - 'transform_method': 'hybrid', - 'Tg': None, 'C1': C1_fit, 'C2': C2_fit, - 'Ea': Ea_fit, 'TL': TL, - } - - end_time = datetime.datetime.now() - latency = f"{(end_time - start_time).total_seconds()} seconds" - json_data = json.dumps(result_data, indent=4) - response = Response(json_data, content_type='application/json; charset=utf-8', status=200) - response.headers['startTime'] = start_time - response.headers['endTime'] = end_time - response.headers['latency'] = str(latency) - response.headers['responseId'] = request_id - return response - if not file_name: return jsonify({'message': 'No file name provided'}), 400 @@ -218,7 +151,17 @@ def extract_data_from_file(request_id): "complex-temp-chart": json.loads(chart_data['complex_temperature_chart_placeholder'].to_json()), "temp-tand-chart": json.loads(chart_data['temperature_tand_chart_placeholder2'].to_json()), "mytable": chart_data['mytable_placeholder'], - "upload-data": uploadData, + # Emit upload-data as row-objects (one dict per row), matching + # mytable's shape and the frontend "Uploaded Data" tab, whose + # TableComponent derives columns from Object.keys(rows[0]). + # uploadData is a dict of equal-length numpy arrays; transpose + # the stacked columns to rows, then tolist() each row to get + # JSON-native floats (stdlib json.dumps can't serialize ndarrays). + # np.array needs a real sequence, hence tuple(...) over the view. + "upload-data": [ + dict(zip(uploadData, row)) + for row in zip(*(col.tolist() for col in uploadData.values())) + ], "C1": C1, "C2": C2, "Tg": Tg, @@ -249,3 +192,97 @@ def extract_data_from_file(request_id): # and return a generic "Internal server error" message. Needs upstream # decision on logging infra before changing. return jsonify({'message': str(e)}), 500 + + +@dynamfit.route('/fit-shift/', methods=['POST']) +@log_errors +@request_logger +@token_required +def fit_shift_coefficients(request_id): + """ + Fit WLF or hybrid shift-factor coefficients from an uploaded shift-factor + file (2 columns, no header: Temperature [°C], a_T [linear scale]). Returns + the fitted coefficients only — no Prony fit, no charts. + + WLF requires Tg (the a_T == 1 anchor); hybrid requires TL. Both are + upstream-supplied inputs — this route does not load the main viscoelastic + data file and so cannot estimate them. Supplying any of C1/C2/Ea holds that + coefficient fixed instead of fitting it. + + The hybrid fit co-fits a vertical reference offset, surfaced as a_T_ref (the + data's shift factor at TL; 1.0 only if the file is truly referenced to TL). + For WLF, a_T_ref is 1.0 by construction (the fit is anchored at T_ref=Tg). + """ + try: + start_time = datetime.datetime.now() + data = request.get_json() + shift_file_name = data.get('shift_file_name') + shift_model = data.get('transform_method', 'hybrid') + Tg = data.get('Tg', None) + C1 = data.get('C1', None) + C2 = data.get('C2', None) + Ea = data.get('Ea', None) + TL = data.get('TL', None) + + if not shift_file_name: + return jsonify({'message': 'No shift file name provided'}), 400 + if not check_file_exists(shift_file_name): + return jsonify({'message': f"File '{shift_file_name}' not found"}), 404 + if shift_model not in ('WLF', 'hybrid'): + return jsonify({'message': 'The shift factor model must be one of WLF, hybrid'}), 400 + + shiftData = upload_init(shift_file_name, 'shift') + if not shiftData: + return jsonify({'message': f"File '{shift_file_name}' is empty"}), 400 + + T = np.asarray(shiftData['Temperature'], dtype=float) + a_T = np.asarray(shiftData['a_T'], dtype=float) + + if shift_model == 'WLF': + if Tg is None: + return jsonify({'message': 'Tg is required for WLF coefficient fitting'}), 400 + C1_fit, C2_fit = fit_wlf_coefficients( + T, a_T, T_ref=Tg, + C1=C1, C2=C2, + fix_C1=(C1 is not None), + fix_C2=(C2 is not None), + ) + result_data = { + 'transform_method': 'WLF', + 'Tg': Tg, 'C1': C1_fit, 'C2': C2_fit, + 'Ea': None, 'TL': None, + # WLF is anchored at T_ref=Tg, where a_T == 1 by construction. + 'a_T_ref': 1.0, + } + else: # hybrid + if TL is None: + return jsonify({'message': 'TL is required for hybrid coefficient fitting'}), 400 + C1_fit, C2_fit, Ea_fit, a_T_ref = fit_hybrid_coefficients( + T, a_T, TL=TL, + C1=C1, C2=C2, Ea=Ea, + fix_C1=(C1 is not None), + fix_C2=(C2 is not None), + fix_Ea=(Ea is not None), + ) + result_data = { + 'transform_method': 'hybrid', + 'Tg': None, 'C1': C1_fit, 'C2': C2_fit, + 'Ea': Ea_fit, 'TL': TL, + 'a_T_ref': a_T_ref, + } + + end_time = datetime.datetime.now() + latency = f"{(end_time - start_time).total_seconds()} seconds" + json_data = json.dumps(result_data, indent=4) + response = Response(json_data, content_type='application/json; charset=utf-8', status=200) + response.headers['startTime'] = start_time + response.headers['endTime'] = end_time + response.headers['latency'] = str(latency) + response.headers['responseId'] = request_id + return response + except ValueError as ve: + return jsonify({'message': str(ve)}), 400 + except Exception as e: + # See the note in extract_data_from_file: raw error text is leaked to + # clients pending an upstream decision on logging infra. + return jsonify({'message': str(e)}), 500 diff --git a/services/tests/dynamfit/test_dynamfit2.py b/services/tests/dynamfit/test_dynamfit2.py index ece23d899..b4fa37b1b 100644 --- a/services/tests/dynamfit/test_dynamfit2.py +++ b/services/tests/dynamfit/test_dynamfit2.py @@ -1491,7 +1491,9 @@ def test_descending_T_round_trip(self): # --------------------------------------------------------------------------- -# Route-level tests for the fit_shift_coefficients branch of /dynamfit/extract/ +# Route-level tests (Flask test client). The shared _make_app / _make_token +# helpers and synthetic shift data below are used by both the /dynamfit/extract/ +# and /dynamfit/fit-shift/ route tests. # --------------------------------------------------------------------------- import jwt from flask import Flask @@ -1533,10 +1535,10 @@ def _make_token(secret='test-secret', req_id='test-req-id'): _A_T_REF_RETURNED = 1.0 -class TestExtractFitShiftCoefficientsRoute(unittest.TestCase): +class TestFitShiftCoefficientsRoute(unittest.TestCase): """ - Route-level tests for the fit_shift_coefficients=true short-circuit branch - of POST /dynamfit/extract/. + Route-level tests for POST /dynamfit/fit-shift/, the dedicated shift-domain + coefficient-fit endpoint (split out of /extract/). Strategy: - Build a bare Flask app (no create_app) to avoid the Docker-only FileHandler. @@ -1559,14 +1561,13 @@ def setUpClass(cls): def _post(self, body): return self.client.post( - '/dynamfit/extract/', + '/dynamfit/fit-shift/', data=json.dumps(body), headers=self.headers, ) def _base_wlf_body(self, **overrides): body = { - 'fit_shift_coefficients': True, 'shift_file_name': 'shift.txt', 'transform_method': 'WLF', 'Tg': 25.0, @@ -1576,7 +1577,6 @@ def _base_wlf_body(self, **overrides): def _base_hybrid_body(self, **overrides): body = { - 'fit_shift_coefficients': True, 'shift_file_name': 'shift.txt', 'transform_method': 'hybrid', 'TL': 25.0, @@ -1592,16 +1592,27 @@ def _base_hybrid_body(self, **overrides): return_value=(_C1_RETURNED, _C2_RETURNED)) @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) @patch('app.dynamfit.routes.check_file_exists', return_value=True) - def test_wlf_happy_path_returns_six_coefficient_keys( + def test_wlf_happy_path_returns_seven_coefficient_keys( self, _exists, _upload, _fit): resp = self._post(self._base_wlf_body()) self.assertEqual(resp.status_code, 200) data = json.loads(resp.data) self.assertEqual( set(data.keys()), - {'transform_method', 'Tg', 'C1', 'C2', 'Ea', 'TL'}, + {'transform_method', 'Tg', 'C1', 'C2', 'Ea', 'TL', 'a_T_ref'}, ) + @patch('app.dynamfit.routes.fit_wlf_coefficients', + return_value=(_C1_RETURNED, _C2_RETURNED)) + @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_wlf_happy_path_a_T_ref_is_one( + self, _exists, _upload, _fit): + # WLF is anchored at T_ref=Tg where a_T == 1 by construction. + resp = self._post(self._base_wlf_body()) + data = json.loads(resp.data) + self.assertEqual(data['a_T_ref'], 1.0) + @patch('app.dynamfit.routes.fit_wlf_coefficients', return_value=(_C1_RETURNED, _C2_RETURNED)) @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) @@ -1638,6 +1649,18 @@ def test_hybrid_happy_path_tg_null_ea_tl_populated( self.assertIsInstance(data['Ea'], float) self.assertIsInstance(data['TL'], float) + @patch('app.dynamfit.routes.fit_hybrid_coefficients', + return_value=(_C1_RETURNED, _C2_RETURNED, _EA_RETURNED, _A_T_REF_RETURNED)) + @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_hybrid_happy_path_surfaces_co_fit_a_T_ref( + self, _exists, _upload, _fit): + # The 4th element of fit_hybrid_coefficients (the co-fit vertical offset) + # must reach the response as a_T_ref. + resp = self._post(self._base_hybrid_body()) + data = json.loads(resp.data) + self.assertEqual(data['a_T_ref'], _A_T_REF_RETURNED) + @patch('app.dynamfit.routes.fit_wlf_coefficients', return_value=(_C1_RETURNED, _C2_RETURNED)) @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) @@ -1668,7 +1691,6 @@ def test_wlf_response_lacks_chart_keys( def test_missing_shift_file_name_returns_400(self): body = { - 'fit_shift_coefficients': True, 'transform_method': 'WLF', 'Tg': 25.0, # shift_file_name intentionally omitted @@ -1691,7 +1713,6 @@ def test_invalid_transform_method_returns_400(self, _exists): @patch('app.dynamfit.routes.check_file_exists', return_value=True) def test_wlf_missing_tg_returns_400(self, _exists, _upload): body = { - 'fit_shift_coefficients': True, 'shift_file_name': 'shift.txt', 'transform_method': 'WLF', # Tg intentionally omitted @@ -1703,7 +1724,6 @@ def test_wlf_missing_tg_returns_400(self, _exists, _upload): @patch('app.dynamfit.routes.check_file_exists', return_value=True) def test_hybrid_missing_tl_returns_400(self, _exists, _upload): body = { - 'fit_shift_coefficients': True, 'shift_file_name': 'shift.txt', 'transform_method': 'hybrid', # TL intentionally omitted @@ -1743,9 +1763,9 @@ def test_empty_shift_file_returns_400(self, _exists, _upload): ) -class TestExtractFitShiftCoefficientsEndToEnd(unittest.TestCase): +class TestFitShiftCoefficientsEndToEnd(unittest.TestCase): """ - E2E tests for the fit_shift_coefficients branch of POST /dynamfit/extract/. + E2E tests for POST /dynamfit/fit-shift/. Real files on disk, real optimizer — no mocking of upload_init, check_file_exists, or the fit functions. Config.FILES_DIRECTORY is pointed at the checked-in @@ -1773,7 +1793,7 @@ def tearDownClass(cls): def _post(self, body): return self.client.post( - '/dynamfit/extract/', + '/dynamfit/fit-shift/', data=json.dumps(body), headers=self.headers, ) @@ -1790,7 +1810,6 @@ def test_wlf_e2e_converges_and_fit_is_accurate(self): defaults and converge. Observed log10-RMSE ≈ 0.437 (threshold: 0.55). """ body = { - 'fit_shift_coefficients': True, 'shift_file_name': 'agilus30 (8) shift factors 20C clean.txt', 'transform_method': 'WLF', 'Tg': 20.0, @@ -1803,6 +1822,7 @@ def test_wlf_e2e_converges_and_fit_is_accurate(self): self.assertEqual(result['transform_method'], 'WLF') self.assertIsNone(result['Ea']) self.assertIsNone(result['TL']) + self.assertEqual(result['a_T_ref'], 1.0) C1 = result['C1'] C2 = result['C2'] @@ -1830,7 +1850,6 @@ def test_hybrid_e2e_converges_and_fit_is_accurate(self): be null; reconstructed shift factors must agree with the data in log10 space. """ body = { - 'fit_shift_coefficients': True, 'shift_file_name': 'VeroCyan (5) shift factors 80C clean.txt', 'transform_method': 'hybrid', 'TL': 80.0, @@ -1850,20 +1869,145 @@ def test_hybrid_e2e_converges_and_fit_is_accurate(self): self.assertTrue(np.isfinite(val) and val > 0, f"Expected finite positive {name}, got {val}") + # The route now surfaces the co-fit vertical reference offset; the VeroCyan + # data is not referenced to TL, so a_T_ref must be finite, positive, ≠ 1. + a_T_ref = result['a_T_ref'] + self.assertTrue(np.isfinite(a_T_ref) and a_T_ref > 0, + f"Expected finite positive a_T_ref, got {a_T_ref}") + # RMSE check: observed RMSE ≈ 0.844; threshold 1.0 is meaningful without # being so tight that minor optimizer variance causes flakiness. shift_data = upload_init('VeroCyan (5) shift factors 80C clean.txt', 'shift') T = np.asarray(shift_data['Temperature'], dtype=float) a_T = np.asarray(shift_data['a_T'], dtype=float) - # a_T_ref (the co-fitted vertical reference offset) is not surfaced by the - # route, so reconstruct it as the log-space offset a consumer would apply. + # Reconstruct using the surfaced a_T_ref a consumer would apply. unscaled = hybrid_shift(T, 80.0, C1, C2, Ea) - a_T_ref = 10 ** np.mean(np.log10(a_T) - np.log10(unscaled)) reconstructed = a_T_ref * unscaled log_rmse = np.sqrt(np.mean((np.log10(reconstructed) - np.log10(a_T)) ** 2)) self.assertLess(log_rmse, 1.0, f"Hybrid log10-RMSE {log_rmse:.4f} exceeds 1.0 — fit likely diverged") +class TestExtractRoute(unittest.TestCase): + """ + Route-level tests for POST /dynamfit/extract/ (the Prony fit + charts route). + + Real files on disk + real fit, no mocking, so the full response envelope is + JSON-serialized exactly as a client receives it. This covers what the + pure-function TestUpdateLineChart* classes cannot: request parsing, + validation short-circuits, and JSON serialization of the response — in + particular the regression guard for numpy values reaching stdlib json.dumps. + """ + + _orig_files_dir = None + _FREQ_FILE = 'agilus30 (8) master curve 20C clean.txt' + + @classmethod + def setUpClass(cls): + cls._orig_files_dir = Config.FILES_DIRECTORY + Config.FILES_DIRECTORY = _REAL_FILES_DIR + Config.SECRET_KEY = 'test-secret' + cls.app = _make_app() + cls.client = cls.app.test_client() + cls.token = _make_token() + cls.headers = { + 'Authorization': f'Bearer {cls.token}', + 'Content-Type': 'application/json', + } + + @classmethod + def tearDownClass(cls): + Config.FILES_DIRECTORY = cls._orig_files_dir + + def _post(self, body): + return self.client.post( + '/dynamfit/extract/', + data=json.dumps(body), + headers=self.headers, + ) + + def _freq_body(self, **overrides): + body = {'file_name': self._FREQ_FILE, 'domain': 'frequency', 'number_of_prony': 5} + body.update(overrides) + return body + + # ------------------------------------------------------------------ + # Happy path — full response envelope, fully JSON-serialized + # ------------------------------------------------------------------ + + def test_frequency_happy_path_returns_200(self): + """ + Regression guard for 'Object of type ndarray is not JSON serializable': + a real frequency-domain extract must serialize the whole response + (upload-data + mytable contain numpy values) and return 200, not 500. + """ + resp = self._post(self._freq_body()) + self.assertEqual(resp.status_code, 200, resp.data[:400]) + + def test_frequency_response_has_all_chart_and_coef_keys(self): + resp = self._post(self._freq_body()) + self.assertEqual(resp.status_code, 200, resp.data[:400]) + data = json.loads(resp.data) + self.assertTrue(data['multi']) + response = data['response'] + for key in ('complex-chart', 'complex-tand-chart', 'relaxation-chart', + 'relaxation-spectrum-chart', 'complex-temp-chart', + 'temp-tand-chart', 'mytable', 'upload-data', + 'C1', 'C2', 'Tg', 'Ea', 'TL'): + self.assertIn(key, response) + + def test_upload_data_is_array_of_row_objects(self): + """ + upload-data must be an array of row-objects (one dict per data row), + matching mytable's shape, the contract doc, and the frontend + TableComponent (which derives columns from Object.keys(rows[0])) — not a + dict of columns. + """ + resp = self._post(self._freq_body()) + self.assertEqual(resp.status_code, 200, resp.data[:400]) + upload = json.loads(resp.data)['response']['upload-data'] + self.assertIsInstance(upload, list) + self.assertTrue(upload, 'expected at least one data row') + self.assertEqual(set(upload[0]), {'Frequency', 'E Storage', 'E Loss'}) + self.assertIsInstance(upload[0]['Frequency'], (int, float)) + + def test_mytable_records_have_expected_keys(self): + resp = self._post(self._freq_body()) + self.assertEqual(resp.status_code, 200, resp.data[:400]) + mytable = json.loads(resp.data)['response']['mytable'] + self.assertIsInstance(mytable, list) + self.assertTrue(mytable, 'expected at least one Prony coefficient record') + self.assertEqual(set(mytable[0]), {'i', 'tau_i', 'E_i'}) + + def test_estimated_shift_params_echo_serializes(self): + """ + When Tg/TL are filled by *_estimate they are numpy scalars read out of + the data array; the echoed response must still JSON-serialize and carry + them as plain numbers (regression guard against numpy values in the + echoed C1/C2/Tg/Ea/TL). + """ + resp = self._post(self._freq_body(Tg_estimate=True, TL_estimate=True)) + self.assertEqual(resp.status_code, 200, resp.data[:400]) + response = json.loads(resp.data)['response'] + self.assertIsInstance(response['Tg'], (int, float)) + self.assertIsInstance(response['TL'], (int, float)) + + # ------------------------------------------------------------------ + # Validation short-circuits (do not reach response serialization) + # ------------------------------------------------------------------ + + def test_missing_file_name_returns_400(self): + resp = self._post({'domain': 'frequency'}) + self.assertEqual(resp.status_code, 400) + + def test_file_not_found_returns_404(self): + resp = self._post(self._freq_body(file_name='does-not-exist.txt')) + self.assertEqual(resp.status_code, 404) + + def test_number_of_prony_out_of_range_returns_400(self): + resp = self._post(self._freq_body(number_of_prony=999)) + self.assertEqual(resp.status_code, 400) + + if __name__ == '__main__': unittest.main() From 5aa63d5f298e25045f5a0ba0f3fd918fc0db96a8 Mon Sep 17 00:00:00 2001 From: richardsheridan Date: Mon, 1 Jun 2026 14:32:04 -0400 Subject: [PATCH 08/18] test(dynamfit): split monolithic suite into meaningful subsets Also fix tests.utils.test_db_utils so it passes outside docker and package services/tests/ so discovery runs --- services/tests/__init__.py | 0 services/tests/dynamfit/_route_helpers.py | 60 + .../tests/dynamfit/test_coefficient_fits.py | 229 ++ services/tests/dynamfit/test_dynamfit2.py | 2013 ----------------- services/tests/dynamfit/test_prony.py | 473 ++++ services/tests/dynamfit/test_routes.py | 240 ++ services/tests/dynamfit/test_routes_e2e.py | 293 +++ services/tests/dynamfit/test_shift_factors.py | 463 ++++ .../tests/dynamfit/test_update_line_chart.py | 415 ++++ services/tests/utils/test_db_utils.py | 11 +- 10 files changed, 2181 insertions(+), 2016 deletions(-) create mode 100644 services/tests/__init__.py create mode 100644 services/tests/dynamfit/_route_helpers.py create mode 100644 services/tests/dynamfit/test_coefficient_fits.py delete mode 100644 services/tests/dynamfit/test_dynamfit2.py create mode 100644 services/tests/dynamfit/test_prony.py create mode 100644 services/tests/dynamfit/test_routes.py create mode 100644 services/tests/dynamfit/test_routes_e2e.py create mode 100644 services/tests/dynamfit/test_shift_factors.py create mode 100644 services/tests/dynamfit/test_update_line_chart.py diff --git a/services/tests/__init__.py b/services/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/services/tests/dynamfit/_route_helpers.py b/services/tests/dynamfit/_route_helpers.py new file mode 100644 index 000000000..3d3723792 --- /dev/null +++ b/services/tests/dynamfit/_route_helpers.py @@ -0,0 +1,60 @@ +""" +Shared fixtures for the dynamfit Flask route tests (test_routes, +test_routes_e2e). Underscore-prefixed so it is never collected as a test module. + +Builds a minimal Flask app registering only the dynamfit blueprint — this +avoids the FileHandler('/services/services_app.log') crash that create_app() +would hit outside the docker container. +""" +import os +os.environ['OPENBLAS_NUM_THREADS'] = '1' +import sys +import datetime +import numpy as np +import jwt +from flask import Flask + +# Append the directory above 'tests' to sys.path to find the 'app' module +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) + +from app.dynamfit.routes import dynamfit + + +# Real fixture data shipped in the repo, used by the end-to-end route tests. +REAL_FILES_DIR = os.path.abspath(os.path.join( + os.path.dirname(__file__), '..', '..', 'app', 'dynamfit', 'files', +)) + + +def make_app(): + """ + Build a minimal Flask test app that registers only the dynamfit blueprint, + avoiding the FileHandler('/services/services_app.log') crash in create_app(). + SECRET_KEY is fixed so we can mint tokens without an .env file. + """ + app = Flask(__name__) + app.config['SECRET_KEY'] = 'test-secret' + app.config['TESTING'] = True + app.register_blueprint(dynamfit) + return app + + +def make_token(secret='test-secret', req_id='test-req-id'): + """Mint a JWT with the reqId field that token_required expects.""" + payload = { + 'reqId': req_id, + 'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=600), + } + return jwt.encode(payload, secret, algorithm='HS256') + + +# Synthetic shift data (5 points, all positive a_T) used across mocked tests. +SHIFT_T = np.array([0.0, 10.0, 20.0, 30.0, 40.0]) +SHIFT_A_T = np.array([3.0, 2.0, 1.0, 0.5, 0.25]) +SHIFT_DATA = {'Temperature': SHIFT_T, 'a_T': SHIFT_A_T} + +# Representative WLF coefficients returned by the mocked fit functions. +C1_RETURNED = 14.0 +C2_RETURNED = 45.0 +EA_RETURNED = 80.0 +A_T_REF_RETURNED = 1.0 diff --git a/services/tests/dynamfit/test_coefficient_fits.py b/services/tests/dynamfit/test_coefficient_fits.py new file mode 100644 index 000000000..fdea78fab --- /dev/null +++ b/services/tests/dynamfit/test_coefficient_fits.py @@ -0,0 +1,229 @@ +""" +Shift-coefficient calibration: `fit_wlf_coefficients` (recovers C1/C2) and +`fit_hybrid_coefficients` (recovers C1/C2/Ea and co-fits the a_T_ref offset) +from measured shift-factor data. + +Pure functions running the real optimizer on small synthetic data — no Flask +app, no disk. Run when changing the fit math, the pole-avoidance bounds, or the +degenerate-segment guards. + + python -m unittest tests.dynamfit.test_coefficient_fits +""" +import unittest +import os +os.environ['OPENBLAS_NUM_THREADS'] = '1' +import sys +import numpy as np + +# Append the directory above 'tests' to sys.path to find the 'app' module +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) + +from app.dynamfit.dynamfit2 import ( + wlf_shift, + _arr_shift, + hybrid_shift, + fit_wlf_coefficients, + fit_hybrid_coefficients, + UNIVERSAL_WLF_C1, + UNIVERSAL_WLF_C2, +) + + +class TestFitWlfCoefficients(unittest.TestCase): + """Tests for fit_wlf_coefficients — pure function, no Flask app needed.""" + + T_REF = 25.0 + C1_TRUE = 14.0 + C2_TRUE = 45.0 + + @classmethod + def setUpClass(cls): + # Deterministic synthetic shift data: generate exact a_T from wlf_shift. + cls.T = np.linspace(30.0, 80.0, 20) + cls.a_T = wlf_shift(cls.T, cls.T_REF, cls.C1_TRUE, cls.C2_TRUE) + + def test_round_trip_recovers_C1_and_C2(self): + # Both params free — fit must recover the true values to tight tolerance. + C1_fit, C2_fit = fit_wlf_coefficients( + self.T, self.a_T, self.T_REF, + C1=self.C1_TRUE, C2=self.C2_TRUE, + ) + self.assertAlmostEqual(C1_fit, self.C1_TRUE, places=4) + self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=4) + + def test_fixed_C1_returned_exactly_and_C2_fitted(self): + # C1 is pinned; C2 must be fitted back to truth. + C1_fit, C2_fit = fit_wlf_coefficients( + self.T, self.a_T, self.T_REF, + C1=self.C1_TRUE, C2=self.C2_TRUE, + fix_C1=True, + ) + self.assertEqual(C1_fit, self.C1_TRUE) # exact — no optimizer touched it + self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=4) + + def test_default_initial_guesses_converge(self): + # Omit C1 and C2 so the function falls back to UNIVERSAL_WLF_* as p0. + # Data generated from (C1=UNIVERSAL_WLF_C1, C2=UNIVERSAL_WLF_C2) so + # the universal constants are both the truth and the starting point. + T = np.linspace(30.0, 80.0, 20) + a_T = wlf_shift(T, self.T_REF, UNIVERSAL_WLF_C1, UNIVERSAL_WLF_C2) + C1_fit, C2_fit = fit_wlf_coefficients(T, a_T, self.T_REF) + self.assertAlmostEqual(C1_fit, UNIVERSAL_WLF_C1, places=4) + self.assertAlmostEqual(C2_fit, UNIVERSAL_WLF_C2, places=4) + + def test_nonpositive_a_T_raises_value_error(self): + # a_T containing zero is physically meaningless; log10 is undefined. + bad_a_T = self.a_T.copy() + bad_a_T[3] = 0.0 + with self.assertRaises(ValueError): + fit_wlf_coefficients(self.T, bad_a_T, self.T_REF) + + def test_negative_a_T_raises_value_error(self): + bad_a_T = self.a_T.copy() + bad_a_T[0] = -1.0 + with self.assertRaises(ValueError): + fit_wlf_coefficients(self.T, bad_a_T, self.T_REF) + + def test_cold_data_both_free_converges_and_c2_respects_bound(self): + # Regression: when T spans far below T_ref (cold side), the default + # UNIVERSAL_WLF_C2 initial guess lands near the WLF pole and the + # optimizer evaluates 10**exponent values that overflow float64. + # The fix applies an analytic log10(a_T) model so overflow can't happen, + # and enforces C2 >= c2_min = (T_ref - min(T)) + 1.0 throughout. + T_ref_local = 20.0 + T = np.linspace(-30.0, 70.0, 21) # T_min = -30 → c2_min = 51 + # True parameters well away from the pole, deterministic ground truth. + C1_true, C2_true = 22.0, 180.0 + a_T = wlf_shift(T, T_ref_local, C1_true, C2_true) + c2_min = (T_ref_local - float(T.min())) + 1.0 # = 51.0 + + # Both C1 and C2 free, default initial guesses — this is the case that + # used to raise ValueError before the fix. + C1_fit, C2_fit = fit_wlf_coefficients(T, a_T, T_ref_local) + + self.assertTrue(np.isfinite(C1_fit) and C1_fit > 0, + f"Expected finite positive C1, got {C1_fit}") + self.assertGreaterEqual(C2_fit, c2_min, + f"C2_fit {C2_fit:.4f} < c2_min {c2_min:.4f} — bound not respected") + + +class TestFitHybridCoefficients(unittest.TestCase): + """Tests for fit_hybrid_coefficients — pure function, no Flask app needed.""" + + TL = 25.0 # WLF/Arrhenius crossover — required input, never fitted + C1_TRUE = 14.0 + C2_TRUE = 45.0 + EA_TRUE = 80.0 # kJ/mol — well within curve_fit's convergence basin + + @classmethod + def setUpClass(cls): + # Span both sides of TL so both Arrhenius and WLF branches are exercised. + cls.T = np.linspace(5.0, 70.0, 30) + cls.a_T = hybrid_shift(cls.T, cls.TL, cls.C1_TRUE, cls.C2_TRUE, cls.EA_TRUE) + + def test_round_trip_recovers_C1_C2_and_Ea(self): + C1_fit, C2_fit, Ea_fit, a_T_ref = fit_hybrid_coefficients( + self.T, self.a_T, self.TL, + C1=self.C1_TRUE, C2=self.C2_TRUE, Ea=self.EA_TRUE, + ) + self.assertAlmostEqual(C1_fit, self.C1_TRUE, places=4) + self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=4) + self.assertAlmostEqual(Ea_fit, self.EA_TRUE, places=4) + + def test_fixed_Ea_returned_exactly_and_others_fitted(self): + # Ea is pinned; C1 and C2 must be recovered from the data. + C1_fit, C2_fit, Ea_fit, a_T_ref = fit_hybrid_coefficients( + self.T, self.a_T, self.TL, + C1=self.C1_TRUE, C2=self.C2_TRUE, Ea=self.EA_TRUE, + fix_Ea=True, + ) + self.assertEqual(Ea_fit, self.EA_TRUE) # exact — never passed to optimizer + self.assertAlmostEqual(C1_fit, self.C1_TRUE, places=4) + self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=4) + + def test_nonpositive_a_T_raises_value_error(self): + bad_a_T = self.a_T.copy() + bad_a_T[5] = 0.0 + with self.assertRaises(ValueError): + fit_hybrid_coefficients(self.T, bad_a_T, self.TL) + + def test_returns_a_T_ref_near_one_for_TL_referenced_data(self): + # setUpClass data is built referenced to TL (hybrid_shift, a_T_ref=1), so + # the co-fitted a_T_ref recovers ~1.0. + *_, a_T_ref = fit_hybrid_coefficients( + self.T, self.a_T, self.TL, + C1=self.C1_TRUE, C2=self.C2_TRUE, Ea=self.EA_TRUE, + ) + self.assertAlmostEqual(a_T_ref, 1.0, places=4) + + def test_cofits_offset_for_unreferenced_data(self): + # Data scaled by a known constant K (reference shifted off TL): the fit + # recovers K as a_T_ref and the same C1/C2/Ea. + K = 12.0 + C1_fit, C2_fit, Ea_fit, a_T_ref = fit_hybrid_coefficients( + self.T, K * self.a_T, self.TL, + C1=self.C1_TRUE, C2=self.C2_TRUE, Ea=self.EA_TRUE, + ) + self.assertAlmostEqual(a_T_ref, K, places=3) + self.assertAlmostEqual(C1_fit, self.C1_TRUE, places=3) + self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=3) + self.assertAlmostEqual(Ea_fit, self.EA_TRUE, places=3) + + def test_degenerate_Arrhenius_guard_fires_and_suppressed_by_fix_Ea(self): + # All data above TL → empty Arrhenius segment → ValueError while Ea is + # free; fixing Ea suppresses the guard and the WLF-only fit succeeds. + T = np.linspace(30.0, 70.0, 20) + a_T = wlf_shift(T, 25.0, self.C1_TRUE, self.C2_TRUE) + with self.assertRaises(ValueError): + fit_hybrid_coefficients(T, a_T, TL=25.0) + C1_fit, C2_fit, Ea_fit, _ = fit_hybrid_coefficients( + T, a_T, TL=25.0, Ea=self.EA_TRUE, fix_Ea=True, + ) + self.assertEqual(Ea_fit, self.EA_TRUE) + self.assertAlmostEqual(C1_fit, self.C1_TRUE, places=3) + self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=3) + + def test_degenerate_WLF_guard_fires_and_suppressed_by_fix_C(self): + # All data below TL → empty WLF segment → ValueError while C1/C2 are + # free; fixing them suppresses the guard and the Arrhenius-only fit + # recovers Ea. + T = np.linspace(5.0, 20.0, 20) + a_T = _arr_shift(T, 25.0, self.EA_TRUE) + with self.assertRaises(ValueError): + fit_hybrid_coefficients(T, a_T, TL=25.0) + C1_fit, C2_fit, Ea_fit, _ = fit_hybrid_coefficients( + T, a_T, TL=25.0, C1=self.C1_TRUE, C2=self.C2_TRUE, + fix_C1=True, fix_C2=True, + ) + self.assertEqual((C1_fit, C2_fit), (self.C1_TRUE, self.C2_TRUE)) + self.assertAlmostEqual(Ea_fit, self.EA_TRUE, places=3) + + def test_all_fixed_returns_four_tuple_and_cofits_offset(self): + # C1/C2/Ea all fixed → echoed exactly; a_T_ref is still co-fitted. + K = 7.0 + C1_fit, C2_fit, Ea_fit, a_T_ref = fit_hybrid_coefficients( + self.T, K * self.a_T, self.TL, + C1=self.C1_TRUE, C2=self.C2_TRUE, Ea=self.EA_TRUE, + fix_C1=True, fix_C2=True, fix_Ea=True, + ) + self.assertEqual( + (C1_fit, C2_fit, Ea_fit), + (self.C1_TRUE, self.C2_TRUE, self.EA_TRUE), + ) + self.assertAlmostEqual(a_T_ref, K, places=3) + + def test_descending_T_round_trip(self): + # Reversed (descending) T/a_T must recover the same coefficients — checks + # the direction handling and the ascending-ordered a_T_ref interpolation. + C1_fit, C2_fit, Ea_fit, a_T_ref = fit_hybrid_coefficients( + self.T[::-1], self.a_T[::-1], self.TL, + C1=self.C1_TRUE, C2=self.C2_TRUE, Ea=self.EA_TRUE, + ) + self.assertAlmostEqual(C1_fit, self.C1_TRUE, places=4) + self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=4) + self.assertAlmostEqual(Ea_fit, self.EA_TRUE, places=4) + self.assertAlmostEqual(a_T_ref, 1.0, places=4) + + +if __name__ == '__main__': + unittest.main() diff --git a/services/tests/dynamfit/test_dynamfit2.py b/services/tests/dynamfit/test_dynamfit2.py deleted file mode 100644 index b4fa37b1b..000000000 --- a/services/tests/dynamfit/test_dynamfit2.py +++ /dev/null @@ -1,2013 +0,0 @@ -import unittest -import os -os.environ['OPENBLAS_NUM_THREADS'] = '1' -import sys -import json -import tempfile -import datetime -import numpy as np -import pandas as pd -from unittest.mock import patch - -# Append the directory above 'test' to sys.path to find the 'app' module -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) - -from app.dynamfit.dynamfit2 import ( - prony_basis, - prony_relaxation_space, - compute_complex, - compute_relaxation_modulus, - compute_relaxation_spectrum, - _prony_objective, - smooth_prony_fit, - wlf_shift, - _arr_shift, - hybrid_shift, - inverse_wlf_shift, - tts_temperature_to_frequency_V2, - tts_frequency_to_temperature, - argmax_peak, - update_line_chart, - fit_wlf_coefficients, - fit_hybrid_coefficients, - UNIVERSAL_WLF_C1, - UNIVERSAL_WLF_C2, -) -from app.config import Config -from app.utils.util import upload_init - - -TAU = np.array([0.1, 1.0, 10.0]) -# Coefficient magnitudes ~exp(7) ≈ 1097, the solver's default x0, so the -# round-trip recovery test in TestSmoothPronyFit converges from x0. -VISCOUS_E = np.array([1000.0, 2000.0, 3000.0]) -SOLID_E = np.array([500.0, 1000.0, 2000.0, 3000.0]) # one extra leading equilibrium term - - -class TestPronyBasis(unittest.TestCase): - def test_shape_solid_false(self): - freq = np.array([1.0, 2.0, 3.0]) - tau = np.array([0.1, 1.0]) - result = prony_basis(freq, tau, solid=False) - self.assertEqual(result.shape, (6, 2)) - - def test_shape_solid_true(self): - freq = np.array([1.0, 2.0, 3.0]) - tau = np.array([0.1, 1.0]) - result = prony_basis(freq, tau, solid=True) - self.assertEqual(result.shape, (6, 3)) - - def test_values_at_omega_tau_unity(self): - # ωτ = 1 → storage = loss = 1/2 exactly. - freq = np.array([1.0]) - tau = np.array([1.0]) - result = prony_basis(freq, tau, solid=False) - self.assertEqual(result.tolist(), [[0.5], [0.5]]) - - def test_values_at_zero_frequency(self): - # ω = 0 → storage = loss = 0 exactly. - freq = np.array([0.0, 0.0]) - tau = np.array([1.0, 2.0]) - result = prony_basis(freq, tau, solid=False) - np.testing.assert_array_equal(result, np.zeros((4, 2))) - - def test_solid_prepends_one_and_zero_columns(self): - freq = np.array([2.0, 3.0]) - tau = np.array([1.0, 5.0]) - result = prony_basis(freq, tau, solid=True) - # Top half (storage): first column is ones. - np.testing.assert_array_equal(result[:2, 0], np.ones(2)) - # Bottom half (loss): first column is zeros. - np.testing.assert_array_equal(result[2:, 0], np.zeros(2)) - - def test_rejects_non_ndarray_freq(self): - with self.assertRaises(AssertionError): - prony_basis([1.0, 2.0], np.array([1.0]), solid=False) - - def test_rejects_non_ndarray_relaxations(self): - with self.assertRaises(AssertionError): - prony_basis(np.array([1.0]), [1.0], solid=False) - - def test_rejects_2d_freq(self): - with self.assertRaises(AssertionError): - prony_basis(np.array([[1.0], [2.0]]), np.array([1.0]), solid=False) - - def test_rejects_2d_relaxations(self): - with self.assertRaises(AssertionError): - prony_basis(np.array([1.0]), np.array([[1.0]]), solid=False) - - -class TestPronyRelaxationSpace(unittest.TestCase): - def test_length(self): - result = prony_relaxation_space(1e-3, 1e3, 7) - self.assertEqual(len(result), 7) - - def test_endpoints(self): - result = prony_relaxation_space(1e-3, 1e3, 5) - np.testing.assert_allclose(result[0], 1e-3) - np.testing.assert_allclose(result[-1], 1e3) - - -class TestComputeComplex(unittest.TestCase): - def test_shape_and_columns_viscous(self): - result = compute_complex(TAU, VISCOUS_E) - self.assertIsInstance(result, pd.DataFrame) - self.assertEqual(len(result), 1000) - self.assertListEqual(list(result.columns), ['Frequency', 'E Storage', 'E Loss']) - - def test_shape_and_columns_solid(self): - result = compute_complex(TAU, SOLID_E) - self.assertIsInstance(result, pd.DataFrame) - self.assertEqual(len(result), 1000) - self.assertListEqual(list(result.columns), ['Frequency', 'E Storage', 'E Loss']) - - def test_num_pts_kwarg(self): - result = compute_complex(TAU, VISCOUS_E, num_pts=50) - self.assertEqual(len(result), 50) - - def test_storage_modulus_monotonic_in_frequency(self): - # Positive E_i, no equilibrium term → storage modulus is non-decreasing in ω. - result = compute_complex(TAU, VISCOUS_E) - self.assertTrue(np.all(np.diff(result['E Storage'].values) >= -1e-12)) - - def test_rejects_non_ndarray_tau(self): - with self.assertRaises(AssertionError): - compute_complex([0.1, 1.0, 10.0], VISCOUS_E) - - def test_rejects_non_ndarray_E(self): - with self.assertRaises(AssertionError): - compute_complex(TAU, [1.0, 2.0, 3.0]) - - def test_rejects_2d_tau(self): - with self.assertRaises(AssertionError): - compute_complex(TAU.reshape(1, -1), VISCOUS_E) - - def test_rejects_2d_E(self): - with self.assertRaises(AssertionError): - compute_complex(TAU, VISCOUS_E.reshape(1, -1)) - - -class TestComputeRelaxationModulus(unittest.TestCase): - def test_shape_and_columns_viscous(self): - result = compute_relaxation_modulus(TAU, VISCOUS_E) - self.assertIsInstance(result, pd.DataFrame) - self.assertEqual(len(result), 1000) - self.assertListEqual(list(result.columns), ['Time', 'E']) - - def test_shape_and_columns_solid(self): - result = compute_relaxation_modulus(TAU, SOLID_E) - self.assertEqual(len(result), 1000) - self.assertListEqual(list(result.columns), ['Time', 'E']) - - def test_num_pts_kwarg(self): - result = compute_relaxation_modulus(TAU, VISCOUS_E, num_pts=50) - self.assertEqual(len(result), 50) - - def test_modulus_decreasing_in_time(self): - # Positive E_i, equilibrium term excluded → E(t) is non-increasing in t. - result = compute_relaxation_modulus(TAU, VISCOUS_E) - self.assertTrue(np.all(np.diff(result['E'].values) <= 1e-12)) - - def test_rejects_non_ndarray_tau(self): - with self.assertRaises(AssertionError): - compute_relaxation_modulus([0.1, 1.0, 10.0], VISCOUS_E) - - def test_rejects_non_ndarray_E(self): - with self.assertRaises(AssertionError): - compute_relaxation_modulus(TAU, [1.0, 2.0, 3.0]) - - def test_rejects_2d_tau(self): - with self.assertRaises(AssertionError): - compute_relaxation_modulus(TAU.reshape(1, -1), VISCOUS_E) - - def test_rejects_2d_E(self): - with self.assertRaises(AssertionError): - compute_relaxation_modulus(TAU, VISCOUS_E.reshape(1, -1)) - - -class TestComputeRelaxationSpectrum(unittest.TestCase): - def test_shape_and_columns_viscous(self): - result = compute_relaxation_spectrum(TAU, VISCOUS_E) - self.assertIsInstance(result, pd.DataFrame) - self.assertEqual(len(result), 1000) - self.assertListEqual(list(result.columns), ['Time', 'H']) - - def test_shape_and_columns_solid(self): - result = compute_relaxation_spectrum(TAU, SOLID_E) - self.assertEqual(len(result), 1000) - self.assertListEqual(list(result.columns), ['Time', 'H']) - - def test_num_pts_kwarg(self): - result = compute_relaxation_spectrum(TAU, VISCOUS_E, num_pts=50) - self.assertEqual(len(result), 50) - - def test_rejects_non_ndarray_tau(self): - with self.assertRaises(AssertionError): - compute_relaxation_spectrum([0.1, 1.0, 10.0], VISCOUS_E) - - def test_rejects_non_ndarray_E(self): - with self.assertRaises(AssertionError): - compute_relaxation_spectrum(TAU, [1.0, 2.0, 3.0]) - - def test_rejects_2d_tau(self): - with self.assertRaises(AssertionError): - compute_relaxation_spectrum(TAU.reshape(1, -1), VISCOUS_E) - - def test_rejects_2d_E(self): - with self.assertRaises(AssertionError): - compute_relaxation_spectrum(TAU, VISCOUS_E.reshape(1, -1)) - - -class TestPronyObjective(unittest.TestCase): - def setUp(self): - # Small viscous problem: 10 frequencies, 3 relaxation times. - self.omega = np.logspace(np.log10(0.5), np.log10(2.0), 10) - self.tau_i = np.array([0.1, 1.0, 10.0]) - self.basis = prony_basis(self.omega, self.tau_i, solid=False) - self.logcoefs = np.log(np.array([1.0, 2.0, 3.0])) - # Construct data so that the model fits exactly at self.logcoefs. - self.data = self.basis @ np.exp(self.logcoefs) - self.std = np.ones_like(self.data) - - def test_returns_scalar_loss_and_gradient_shape(self): - loss, grad = _prony_objective( - self.logcoefs, self.data, self.std, self.basis, - smoothness=0.0, solid=False, - ) - self.assertTrue(np.isscalar(loss) or np.ndim(loss) == 0) - self.assertEqual(grad.shape, self.logcoefs.shape) - - def test_exact_fit_zero_loss_and_zero_gradient(self): - loss, grad = _prony_objective( - self.logcoefs, self.data, self.std, self.basis, - smoothness=0.0, solid=False, - ) - self.assertEqual(loss, 0.0) - np.testing.assert_array_equal(grad, np.zeros_like(grad)) - - def test_smoothness_penalty_increases_loss(self): - # Use non-smooth (zig-zag) logcoefs so the second-difference is nonzero. - logcoefs = np.array([0.0, 5.0, 0.0, 5.0, 0.0]) - tau_i = prony_relaxation_space(0.1, 10.0, 5) - basis = prony_basis(self.omega, tau_i, solid=False) - data = np.zeros(basis.shape[0]) - std = np.ones_like(data) - loss_unsmoothed, _ = _prony_objective( - logcoefs, data, std, basis, smoothness=0.0, solid=False, - ) - loss_smoothed, _ = _prony_objective( - logcoefs, data, std, basis, smoothness=1.0, solid=False, - ) - self.assertGreater(loss_smoothed, loss_unsmoothed) - - -class TestSmoothPronyFit(unittest.TestCase): - def setUp(self): - # Small problem so the fit runs quickly; synthesize from a known Prony series. - df = compute_complex(TAU, VISCOUS_E, num_pts=25) - self.omega = df['Frequency'].values - self.E_stor = df['E Storage'].values - self.E_loss = df['E Loss'].values - # Flat uniform weights — same loss landscape as unweighted least-squares. - self.E_stor_std = np.ones_like(self.E_stor) - self.E_loss_std = np.ones_like(self.E_loss) - - def test_shape_viscous(self): - tau_i, E_i = smooth_prony_fit( - self.omega, self.E_stor, self.E_loss, - E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, - N=5, smoothness=1.0, solid=False, - ) - self.assertEqual(tau_i.shape, (5,)) - self.assertEqual(E_i.shape, (5,)) - - def test_shape_solid(self): - tau_i, E_i = smooth_prony_fit( - self.omega, self.E_stor, self.E_loss, - E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, - N=5, smoothness=1.0, solid=True, - ) - self.assertEqual(tau_i.shape, (5,)) - self.assertEqual(E_i.shape, (6,)) - - def test_recovers_input_coefficients(self): - # When N matches the source grid size and smoothing is off, the fit - # should recover the input coefficients. - tau_i, E_i = smooth_prony_fit( - self.omega, self.E_stor, self.E_loss, - E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, - N=len(TAU), smoothness=0.0, solid=False, - ) - np.testing.assert_allclose(tau_i, TAU) - np.testing.assert_allclose(E_i, VISCOUS_E, rtol=1e-3) - - def test_N_controls_tau_grid_size(self): - tau_i, E_i = smooth_prony_fit( - self.omega, self.E_stor, self.E_loss, - E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, - N=8, smoothness=1.0, solid=False, - ) - self.assertEqual(tau_i.shape, (8,)) - self.assertEqual(E_i.shape, (8,)) - - def test_std_arrays_weight_residuals(self): - # Under-parameterized fit (N=1 against a 3-term source) so the model - # cannot match both moduli exactly. Tiny std on the storage side, - # huge on the loss side → the storage residuals dominate the loss, - # so the fit tracks E_stor better than E_loss; reversing the weights - # should swap which side tracks well. - n = len(self.omega) - small = np.full(n, 1e-3) - big = np.full(n, 1e3) - tau_i, E_i = smooth_prony_fit( - self.omega, self.E_stor, self.E_loss, - E_stor_std=small, E_loss_std=big, - N=1, smoothness=0.0, solid=False, - ) - basis = prony_basis(self.omega, tau_i, solid=False) - model = basis @ E_i - stor_err = np.linalg.norm(self.E_stor - model[:n]) - loss_err = np.linalg.norm(self.E_loss - model[n:]) - self.assertLess(stor_err, loss_err) - - tau_i_r, E_i_r = smooth_prony_fit( - self.omega, self.E_stor, self.E_loss, - E_stor_std=big, E_loss_std=small, - N=1, smoothness=0.0, solid=False, - ) - basis_r = prony_basis(self.omega, tau_i_r, solid=False) - model_r = basis_r @ E_i_r - stor_err_r = np.linalg.norm(self.E_stor - model_r[:n]) - loss_err_r = np.linalg.norm(self.E_loss - model_r[n:]) - self.assertGreater(stor_err_r, loss_err_r) - - def test_rejects_non_ndarray_omega(self): - with self.assertRaises(AssertionError): - smooth_prony_fit( - list(self.omega), self.E_stor, self.E_loss, - E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, - N=5, smoothness=1.0, solid=False, - ) - - def test_rejects_non_ndarray_E_stor(self): - with self.assertRaises(AssertionError): - smooth_prony_fit( - self.omega, list(self.E_stor), self.E_loss, - E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, - N=5, smoothness=1.0, solid=False, - ) - - def test_rejects_non_ndarray_E_loss(self): - with self.assertRaises(AssertionError): - smooth_prony_fit( - self.omega, self.E_stor, list(self.E_loss), - E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, - N=5, smoothness=1.0, solid=False, - ) - - def test_rejects_2d_omega(self): - with self.assertRaises(AssertionError): - smooth_prony_fit( - self.omega.reshape(1, -1), self.E_stor, self.E_loss, - E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, - N=5, smoothness=1.0, solid=False, - ) - - def test_rejects_2d_E_stor(self): - with self.assertRaises(AssertionError): - smooth_prony_fit( - self.omega, self.E_stor.reshape(1, -1), self.E_loss, - E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, - N=5, smoothness=1.0, solid=False, - ) - - def test_rejects_2d_E_loss(self): - with self.assertRaises(AssertionError): - smooth_prony_fit( - self.omega, self.E_stor, self.E_loss.reshape(1, -1), - E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, - N=5, smoothness=1.0, solid=False, - ) - - def test_rejects_length_mismatch(self): - with self.assertRaises(AssertionError): - smooth_prony_fit( - self.omega, self.E_stor[:-1], self.E_loss, - E_stor_std=self.E_stor_std[:-1], E_loss_std=self.E_loss_std, - N=5, smoothness=1.0, solid=False, - ) - - def test_rejects_E_stor_std_wrong_length(self): - with self.assertRaises(AssertionError): - smooth_prony_fit( - self.omega, self.E_stor, self.E_loss, - E_stor_std=self.E_stor_std[:-1], E_loss_std=self.E_loss_std, - N=5, smoothness=1.0, solid=False, - ) - - def test_rejects_E_loss_std_wrong_length(self): - with self.assertRaises(AssertionError): - smooth_prony_fit( - self.omega, self.E_stor, self.E_loss, - E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std[:-1], - N=5, smoothness=1.0, solid=False, - ) - - def test_handles_data_scale_orders_of_magnitude(self): - # Regression: a constant initial guess (x0 = 7 → E_i ≈ 1097 each) sent - # BFGS' first line search into logcoefs > 700 whenever the data - # magnitude was many orders of magnitude away from ~1e3, and exp() - # overflowed to inf. The data-scaled x0 keeps the initial step inside - # float64 range regardless of dataset scale. - # Use a peak-shaped Prony series over 16 decades of tau — closer to - # real master-curve structure than the narrow TAU/VISCOUS_E fixture, - # which doesn't trip the bug. - tau = np.logspace(-8.0, 8.0, 17) - E_shape = np.exp(-(np.log10(tau)) ** 2 / 4.0) # peaked at tau=1 - for scale in [1e3, 1e6, 1e9, 1e12]: - E_input = np.concatenate(([0.1 * scale], E_shape * scale)) - df = compute_complex(tau, E_input, num_pts=400) - omega = df['Frequency'].to_numpy() - E_stor = df['E Storage'].to_numpy() - E_loss = df['E Loss'].to_numpy() - mag = np.abs(E_stor + 1.0j * E_loss) * 0.2 - for N in [10, 20, 50]: - with self.subTest(scale=scale, N=N): - _, E_i = smooth_prony_fit( - omega, E_stor, E_loss, - E_stor_std=mag, E_loss_std=mag, - N=N, smoothness=0.0, solid=True, - ) - self.assertTrue( - np.all(np.isfinite(E_i)), - msg=f'non-finite E_i at scale={scale:g}, N={N}: {E_i}', - ) - - -class TestWlfShift(unittest.TestCase): - def test_identity_at_T_ref(self): - # T == T_ref → a_T == 1.0 exactly. - T = np.array([100.0]) - result = wlf_shift(T, T_ref=100.0, C1=17.44, C2=51.6) - np.testing.assert_array_equal(result, np.array([1.0])) - - def test_known_value(self): - # T - T_ref = C2 → denom = 2*C2, exponent = -C1/2. - # With C1=2 and C2=10 → a_T = 10^-1 = 0.1. - T = np.array([20.0]) - result = wlf_shift(T, T_ref=10.0, C1=2.0, C2=10.0) - np.testing.assert_allclose(result, np.array([0.1])) - - def test_array_in_array_out(self): - T = np.array([100.0, 110.0, 120.0]) - result = wlf_shift(T, T_ref=100.0, C1=17.44, C2=51.6) - self.assertIsInstance(result, np.ndarray) - self.assertEqual(result.shape, (3,)) - - def test_raises_on_singularity(self): - # T - T_ref = -C2 → denom = 0. - T = np.array([0.0]) - with self.assertRaises(ValueError): - wlf_shift(T, T_ref=51.6, C1=17.44, C2=51.6) - - def test_raises_on_array_with_singularity(self): - # One element hits the singularity. - T = np.array([100.0, 0.0, 120.0]) - with self.assertRaises(ValueError): - wlf_shift(T, T_ref=51.6, C1=17.44, C2=51.6) - - def test_accepts_scalar(self): - # Scalar input is promoted to length-1 ndarray output. - result = wlf_shift(100.0, T_ref=100.0, C1=17.44, C2=51.6) - self.assertIsInstance(result, np.ndarray) - self.assertEqual(result.shape, (1,)) - np.testing.assert_array_equal(result, np.array([1.0])) - - def test_rejects_2d_ndarray(self): - with self.assertRaises(AssertionError): - wlf_shift(np.array([[100.0, 110.0]]), T_ref=100.0, C1=17.44, C2=51.6) - - -class TestArrShift(unittest.TestCase): - def test_identity_at_T_ref(self): - # T == T_ref → a_T == 1.0 exactly. - T = np.array([25.0]) - result = _arr_shift(T, T_ref=25.0, Ea=50.0) - np.testing.assert_array_equal(result, np.array([1.0])) - - def test_direction(self): - # With Ea > 0, T > T_ref → a_T < 1. - T = np.array([50.0]) - result = _arr_shift(T, T_ref=25.0, Ea=50.0) - self.assertLess(result[0], 1.0) - - def test_raises_at_absolute_zero(self): - T = np.array([-273.15]) - with self.assertRaises(ValueError): - _arr_shift(T, T_ref=25.0, Ea=50.0) - - -class TestHybridShift(unittest.TestCase): - T_REF = 25.0 - C1 = 17.44 - C2 = 51.6 - EA = 50.0 # kJ/mol - - def test_ascending_mixed(self): - # Crosses T_ref; result preserves ascending order, monotone-decreasing in T. - T = np.array([10.0, 20.0, 30.0, 40.0]) - result = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) - self.assertEqual(result.shape, T.shape) - self.assertTrue(np.all(np.diff(result) <= 0)) - - def test_descending_mixed(self): - # Descending T → a_T monotonically increasing along the input. - T = np.array([40.0, 30.0, 20.0, 10.0]) - result = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) - self.assertEqual(result.shape, T.shape) - self.assertTrue(np.all(np.diff(result) >= 0)) - - def test_ascending_and_descending_consistent(self): - # The same temperatures in opposite order should produce the same a_T - # values, just reversed. - T_asc = np.array([10.0, 20.0, 30.0, 40.0]) - T_desc = T_asc[::-1] - result_asc = hybrid_shift(T_asc, self.T_REF, self.C1, self.C2, self.EA) - result_desc = hybrid_shift(T_desc, self.T_REF, self.C1, self.C2, self.EA) - np.testing.assert_allclose(result_desc, result_asc[::-1]) - - def test_all_below_T_ref(self): - # All Arrhenius; final element at T == T_ref gives a_T == 1. - T = np.array([10.0, 20.0, 25.0]) - result = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) - self.assertEqual(result.shape, T.shape) - np.testing.assert_allclose(result[-1], 1.0) - - def test_all_above_T_ref(self): - # All WLF; with positive C1, a_T < 1 throughout. - T = np.array([30.0, 40.0, 50.0]) - result = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) - self.assertEqual(result.shape, T.shape) - self.assertTrue(np.all(result < 1.0)) - - def test_accepts_scalar_below_T_ref(self): - # Scalar at T_ref → Arrhenius bucket → a_T == 1. - result = hybrid_shift(self.T_REF, self.T_REF, self.C1, self.C2, self.EA) - self.assertIsInstance(result, np.ndarray) - self.assertEqual(result.shape, (1,)) - np.testing.assert_allclose(result, np.array([1.0])) - - def test_accepts_scalar_above_T_ref(self): - # Scalar above T_ref → WLF bucket → a_T < 1. - result = hybrid_shift(self.T_REF + 10.0, self.T_REF, self.C1, self.C2, self.EA) - self.assertEqual(result.shape, (1,)) - self.assertLess(result[0], 1.0) - - def test_accepts_single_element_array(self): - T = np.array([self.T_REF + 10.0]) - result = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) - self.assertEqual(result.shape, (1,)) - - def test_rejects_2d_ndarray(self): - with self.assertRaises(AssertionError): - hybrid_shift( - np.array([[10.0, 20.0]]), - self.T_REF, self.C1, self.C2, self.EA, - ) - - def test_rejects_non_finite(self): - T = np.array([10.0, np.nan, 30.0]) - with self.assertRaises(AssertionError): - hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) - - def test_rejects_unsorted(self): - T = np.array([10.0, 30.0, 20.0, 40.0]) - with self.assertRaises(AssertionError): - hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) - - def test_a_T_ref_scales_output(self): - # a_T_ref multiplies every returned shift factor. - T = np.array([10.0, 20.0, 30.0, 40.0]) - base = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) - scaled = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA, a_T_ref=5.0) - np.testing.assert_allclose(scaled, 5.0 * base) - - def test_explicit_ascending_matches_autodetect(self): - # Passing ascending explicitly bypasses direction detection but must - # yield the same numbers as letting hybrid_shift detect it. - T = np.array([10.0, 20.0, 30.0, 40.0]) - auto = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) - forced = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA, ascending=True) - np.testing.assert_allclose(forced, auto) - - def test_explicit_descending_matches_autodetect(self): - T = np.array([40.0, 30.0, 20.0, 10.0]) - auto = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) - forced = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA, ascending=False) - np.testing.assert_allclose(forced, auto) - - -class TestInverseWlfShift(unittest.TestCase): - T_REF = 100.0 - C1 = 17.44 - C2 = 51.6 - - def test_identity_at_a_T_unity(self): - # log10(1) = 0 → T = T_ref exactly. - result = inverse_wlf_shift(np.array([1.0]), self.T_REF, self.C1, self.C2) - np.testing.assert_array_equal(result, np.array([self.T_REF])) - - def test_round_trip_with_wlf_shift(self): - # T → a_T → T should recover the original temperatures. - T = np.array([110.0, 120.0, 150.0, 200.0]) - a_T = wlf_shift(T, self.T_REF, self.C1, self.C2) - T_back = inverse_wlf_shift(a_T, self.T_REF, self.C1, self.C2) - np.testing.assert_allclose(T_back, T) - - def test_accepts_scalar(self): - result = inverse_wlf_shift(1.0, self.T_REF, self.C1, self.C2) - self.assertIsInstance(result, np.ndarray) - self.assertEqual(result.shape, (1,)) - - def test_rejects_2d_ndarray(self): - with self.assertRaises(AssertionError): - inverse_wlf_shift(np.array([[1.0, 0.5]]), self.T_REF, self.C1, self.C2) - - def test_raises_on_nonpositive_a_T(self): - with self.assertRaises(ValueError): - inverse_wlf_shift(np.array([0.0]), self.T_REF, self.C1, self.C2) - with self.assertRaises(ValueError): - inverse_wlf_shift(np.array([-1.0]), self.T_REF, self.C1, self.C2) - - -class TestTtsTemperatureToFrequencyV2(unittest.TestCase): - T_REF = 25.0 - C1 = 17.44 - C2 = 51.6 - EA = 50.0 - MASTER_FREQ = 2.5 - - def _input_df(self, T_values): - # Build a temperature sweep whose hybrid TTS shift collapses every row - # to MASTER_FREQ: per-row Frequency = MASTER_FREQ / a_T(T). - T = np.array(T_values, dtype=float) - a_T = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) - n = len(T) - return pd.DataFrame({ - 'Temperature': T, - 'Frequency': self.MASTER_FREQ / a_T, - "E'": np.full(n, 100.0), - "E''": np.full(n, 10.0), - }) - - def _params(self, **overrides): - # T_REF stands in for both Tg (WLF reference) and TL (hybrid crossover); - # the fixture data round-trips for either interpretation. - kwargs = dict(Tg=self.T_REF, TL=self.T_REF, - C1=self.C1, C2=self.C2, Ea=self.EA) - kwargs.update(overrides) - return kwargs - - def test_output_columns(self): - df = self._input_df([25.0, 30.0, 35.0]) - result = tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) - self.assertListEqual(list(result.columns), ['Frequency', 'Temperature', "E'", "E''"]) - - def test_output_length(self): - df = self._input_df([25.0, 30.0, 35.0]) - result = tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) - self.assertEqual(len(result), len(df)) - - def test_temperature_column_is_T_ref(self): - df = self._input_df([25.0, 30.0, 35.0]) - result = tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) - np.testing.assert_array_equal( - result['Temperature'].values, np.full(len(df), self.T_REF), - ) - - def test_hybrid_round_trip(self): - # Data was built via hybrid_shift → 'hybrid' TTS recovers MASTER_FREQ. - df = self._input_df([20.0, 25.0, 30.0, 35.0]) - result = tts_temperature_to_frequency_V2(df, 'hybrid', **self._params()) - np.testing.assert_allclose(result['Frequency'].values, self.MASTER_FREQ) - - def test_WLF_round_trip_above_T_ref(self): - # All T > T_ref: hybrid_shift == wlf_shift, so 'WLF' TTS also recovers - # MASTER_FREQ on data built via hybrid_shift. - df = self._input_df([30.0, 35.0, 40.0]) - result = tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) - np.testing.assert_allclose(result['Frequency'].values, self.MASTER_FREQ) - - def test_identity_single_row_at_T_ref(self): - # Single row at T_ref → a_T == 1 → output Frequency == input Frequency. - df = self._input_df([self.T_REF]) - result_wlf = tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) - result_hybrid = tts_temperature_to_frequency_V2(df, 'hybrid', **self._params()) - np.testing.assert_allclose(result_wlf['Frequency'].values, df['Frequency'].values) - np.testing.assert_allclose(result_hybrid['Frequency'].values, df['Frequency'].values) - - def test_shiftData_override_uses_supplied_a_T(self): - # When shiftData is provided, it multiplies Frequency directly; - # shift_model parameters are ignored. - df = self._input_df([25.0, 30.0, 35.0]) - shiftData = {'a_T': [0.5, 1.5, 2.0]} - result = tts_temperature_to_frequency_V2( - df, 'WLF', **self._params(shiftData=shiftData), - ) - expected = df['Frequency'].values * np.array([0.5, 1.5, 2.0]) - np.testing.assert_allclose( - np.sort(result['Frequency'].values), np.sort(expected), - ) - - def test_E_columns_preserved(self): - df = self._input_df([25.0, 30.0, 35.0]) - result = tts_temperature_to_frequency_V2(df, 'hybrid', **self._params()) - # E' and E'' are constant 100/10 throughout the fixture; they should - # pass through unchanged regardless of row order. - np.testing.assert_array_equal(result["E'"].values, np.full(3, 100.0)) - np.testing.assert_array_equal(result["E''"].values, np.full(3, 10.0)) - - def test_does_not_mutate_input(self): - df = self._input_df([25.0, 30.0, 35.0]) - original_columns = list(df.columns) - tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) - self.assertListEqual(list(df.columns), original_columns) - - def test_rejects_unknown_shift_model(self): - # Unknown shift_model is a server-bug class: the route's allow-list - # must match this function's, so reaching here with 'bogus' is a - # contract violation rather than user error. - df = self._input_df([25.0, 30.0, 35.0]) - with self.assertRaises(AssertionError): - tts_temperature_to_frequency_V2(df, 'bogus', **self._params()) - - def test_manual_with_shiftData_succeeds(self): - # 'manual' selects the shiftData path; WLF/hybrid params are ignored. - df = self._input_df([25.0, 30.0, 35.0]) - shiftData = {'a_T': [0.5, 1.5, 2.0]} - result = tts_temperature_to_frequency_V2( - df, 'manual', shiftData=shiftData, - ) - expected = df['Frequency'].values * np.array([0.5, 1.5, 2.0]) - np.testing.assert_allclose( - np.sort(result['Frequency'].values), np.sort(expected), - ) - - def test_manual_without_shiftData_raises_value_error(self): - # User picks 'manual' in the UI but forgets to upload the shift file. - df = self._input_df([25.0, 30.0, 35.0]) - with self.assertRaisesRegex(ValueError, 'shift-factor file'): - tts_temperature_to_frequency_V2(df, 'manual', **self._params()) - - def test_output_has_reset_index(self): - df = self._input_df([25.0, 30.0, 35.0]) - result = tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) - self.assertListEqual(list(result.index), list(range(len(result)))) - - def test_missing_frequency_column_assumes_one_hz(self): - # Pure temperature sweep input (no Frequency col) → shifted Frequency == a_T. - T = np.array([20.0, 25.0, 30.0]) - df = pd.DataFrame({ - 'Temperature': T, - "E'": np.full(len(T), 100.0), - "E''": np.full(len(T), 10.0), - }) - result = tts_temperature_to_frequency_V2(df, 'hybrid', **self._params()) - expected_a_T = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) - np.testing.assert_allclose( - np.sort(result['Frequency'].values), np.sort(expected_a_T), - ) - - -class TestTtsFrequencyToTemperature(unittest.TestCase): - T_REF = 25.0 - C1 = 17.44 - C2 = 51.6 - OMEGA_REF = 1.0 - - def _input_df(self, T_values): - # Build a freq sweep whose inverse-WLF maps each row back to T in T_values: - # per-row Frequency = OMEGA_REF * a_T(T). - T = np.array(T_values, dtype=float) - a_T = wlf_shift(T, self.T_REF, self.C1, self.C2) - n = len(T) - return pd.DataFrame({ - 'Frequency': self.OMEGA_REF * a_T, - "E'": np.full(n, 100.0), - "E''": np.full(n, 10.0), - }) - - def test_output_columns(self): - df = self._input_df([25.0, 30.0, 35.0]) - result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) - self.assertListEqual(list(result.columns), ['Frequency', 'Temperature', "E'", "E''"]) - - def test_output_length(self): - df = self._input_df([25.0, 30.0, 35.0]) - result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) - self.assertEqual(len(result), len(df)) - - def test_frequency_column_is_omega_ref(self): - df = self._input_df([25.0, 30.0, 35.0]) - result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) - np.testing.assert_array_equal( - result['Frequency'].values, np.full(len(df), self.OMEGA_REF), - ) - - def test_round_trip_temperatures(self): - T_input = [27.0, 30.0, 35.0] - df = self._input_df(T_input) - result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) - np.testing.assert_allclose(result['Temperature'].values, sorted(T_input)) - - def test_identity_at_omega_ref(self): - # Frequency == omega_ref means a_T = 1, so inverse WLF gives T = T_ref. - df = pd.DataFrame({ - 'Frequency': [self.OMEGA_REF], - "E'": [100.0], - "E''": [10.0], - }) - result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) - np.testing.assert_allclose(result['Temperature'].values, [self.T_REF]) - - def test_E_columns_preserved(self): - df = self._input_df([25.0, 30.0, 35.0]) - result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) - np.testing.assert_array_equal(result["E'"].values, np.full(3, 100.0)) - np.testing.assert_array_equal(result["E''"].values, np.full(3, 10.0)) - - def test_does_not_mutate_input(self): - df = self._input_df([25.0, 30.0, 35.0]) - original_columns = list(df.columns) - original_freq = df['Frequency'].to_numpy().copy() - tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) - self.assertListEqual(list(df.columns), original_columns) - np.testing.assert_array_equal(df['Frequency'].values, original_freq) - - def test_output_has_reset_index(self): - df = self._input_df([25.0, 30.0, 35.0]) - result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) - self.assertListEqual(list(result.index), list(range(len(result)))) - - def test_output_sorted_by_temperature(self): - df = self._input_df([35.0, 27.0, 30.0]) # unsorted - result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) - T = result['Temperature'].values - self.assertTrue(np.all(np.diff(T) >= 0)) - - def test_round_trip_with_V2(self): - # Scatter a master curve across temperatures with freq_to_temp, then - # collapse back with V2. Frequencies must match the original master. - master_freqs = np.array([0.1, 1.0, 10.0]) - df_master = pd.DataFrame({ - 'Frequency': master_freqs, - "E'": [100.0, 200.0, 300.0], - "E''": [10.0, 20.0, 30.0], - }) - scattered = tts_frequency_to_temperature( - df_master, self.OMEGA_REF, self.T_REF, self.C1, self.C2, - ) - recovered = tts_temperature_to_frequency_V2( - scattered, 'WLF', Tg=self.T_REF, C1=self.C1, C2=self.C2, - ) - np.testing.assert_allclose( - np.sort(recovered['Frequency'].values), np.sort(master_freqs), - ) - - -class TestArgmaxPeak(unittest.TestCase): - def test_finds_known_peak(self): - # Gaussian centered at x=1.5 on a fine grid → peak index lands on 1.5. - x = np.linspace(-5.0, 5.0, 1001) - signal = np.exp(-(x - 1.5) ** 2) - self.assertAlmostEqual(x[argmax_peak(signal)], 1.5, places=2) - - def test_picks_most_prominent_of_multiple(self): - # Two Gaussians; the taller one should win. - x = np.linspace(-5.0, 5.0, 1001) - signal = np.exp(-(x + 2.0) ** 2) + 2.0 * np.exp(-(x - 2.0) ** 2) - self.assertAlmostEqual(x[argmax_peak(signal)], 2.0, places=2) - - def test_returns_int(self): - x = np.linspace(-5.0, 5.0, 101) - signal = np.exp(-(x - 0.0) ** 2) - self.assertIsInstance(argmax_peak(signal), int) - - def test_raises_when_no_peaks(self): - # Monotonic ramp has no interior peaks. - with self.assertRaises(ValueError): - argmax_peak(np.linspace(0.0, 1.0, 50)) - - def test_rejects_non_ndarray(self): - with self.assertRaises(AssertionError): - argmax_peak([0.0, 1.0, 0.5, 0.0]) - - def test_rejects_2d_ndarray(self): - with self.assertRaises(AssertionError): - argmax_peak(np.zeros((3, 3))) - - -DATA_DIR = os.path.abspath(os.path.join( - os.path.dirname(__file__), '..', '..', 'app', 'dynamfit', 'files', -)) - - -class TestUpdateLineChartFrequency(unittest.TestCase): - """Characterization tests for the frequency-domain branch of update_line_chart.""" - - @classmethod - def setUpClass(cls): - Config.FILES_DIRECTORY = DATA_DIR - cls.uploadData = upload_init( - 'agilus30 (8) master curve 20C clean.txt', 'frequency', - ) - cls.N = 10 - cls.result = update_line_chart( - cls.uploadData, - number_of_prony=cls.N, - smoothness=0.1, - fit_settings=True, - domain='frequency', - ) - - def test_returns_7_tuple(self): - self.assertEqual(len(self.result), 7) - - def test_coef_df_schema(self): - coef_df = self.result[6] - self.assertIsInstance(coef_df, list) - self.assertGreater(len(coef_df), 0) - for row in coef_df: - self.assertSetEqual(set(row.keys()), {'i', 'tau_i', 'E_i'}) - self.assertNotEqual(row['E_i'], 0.0) - # 'i' values are the original DataFrame indices, all nonnegative - self.assertTrue(all(row['i'] >= 0 for row in coef_df)) - - def test_fig1_trace_names(self): - fig1 = self.result[0] - names = [t.name for t in fig1.data] - # Two facets (E Storage, E Loss) × two colors (Experiment + N-Term Prony) - self.assertEqual(names.count('Experiment'), 2) - self.assertEqual(sum(1 for n in names if 'Term Prony' in n), 2) - - def test_fig1_experiment_y_matches_input(self): - # px.line(facet_col='Modulus') splits by Modulus, so the two Experiment - # traces carry the E Storage and E Loss columns from the input. - fig1 = self.result[0] - experiment_y = sorted( - (tuple(t.y) for t in fig1.data if t.name == 'Experiment'), - key=lambda ys: ys[0], - ) - expected = sorted( - (tuple(self.uploadData['E Loss']), tuple(self.uploadData['E Storage'])), - key=lambda ys: ys[0], - ) - for got, want in zip(experiment_y, expected): - np.testing.assert_array_equal(got, want) - - def test_fig2_fig3_trace_counts_with_fit_settings_true(self): - # fit_settings=True → fig2/fig3 are overlay figs (line + basis scatter). - fig2, fig3 = self.result[2], self.result[3] - self.assertEqual(len(fig2.data), 2) - self.assertEqual(len(fig3.data), 2) - names2 = {t.name for t in fig2.data} - names3 = {t.name for t in fig3.data} - self.assertTrue(any('Basis' in n for n in names2)) - self.assertTrue(any('Basis' in n for n in names3)) - - def test_fig4_fig41_have_only_experiment_traces(self): - # In frequency domain, fig4/fig41 visualize the inverse-WLF temperature - # conversion of the input — no Prony fit is overlaid there. - fig4, fig41 = self.result[4], self.result[5] - for fig in (fig4, fig41): - names = {t.name for t in fig.data} - self.assertEqual(names, {'Experiment'}) - - def test_fit_settings_false_drops_basis_overlay(self): - result = update_line_chart( - self.uploadData, number_of_prony=self.N, smoothness=0.1, - fit_settings=False, domain='frequency', - ) - fig2, fig3 = result[2], result[3] - self.assertEqual(len(fig2.data), 1) - self.assertEqual(len(fig3.data), 1) - self.assertNotIn('Basis', {t.name for t in fig2.data}) - self.assertNotIn('Basis', {t.name for t in fig3.data}) - - -class TestUpdateLineChartTemperature(unittest.TestCase): - """Characterization tests for the temperature-domain branches.""" - - T_REF = 25.0 - C1 = 17.44 - C2 = 51.6 - EA = 200.0 - - @classmethod - def setUpClass(cls): - Config.FILES_DIRECTORY = DATA_DIR - # Real temperature ramp for the early-exit path. - cls.real_temp_data = upload_init( - 'agilus30 (8) Temperature Ramp clean.txt', 'temperature', - ) - # Synthetic temperature ramp chosen so WLF and hybrid shifts both stay - # well-defined (T spans both sides of T_ref, well clear of the - # WLF C2 singularity at T_ref - C2). - T = np.linspace(0.0, 80.0, 30) - cls.synthetic_temp_data = { - 'Temperature': T, - 'E Storage': np.linspace(1000.0, 10.0, len(T)), - 'E Loss': np.full(len(T), 50.0), - } - - def test_early_exit_with_no_shift_params(self): - fig1, fig11, fig2, fig3, fig4, fig41, coef_df = update_line_chart( - self.real_temp_data, number_of_prony=10, smoothness=0.1, - fit_settings=True, domain='temperature', - ) - for empty in (fig1, fig11, fig2, fig3): - self.assertEqual(len(empty.data), 0) - self.assertEqual(coef_df, []) - self.assertGreater(len(fig4.data), 0) - self.assertGreater(len(fig41.data), 0) - - def test_WLF_shift_populates_all_figures(self): - result = update_line_chart( - self.synthetic_temp_data, number_of_prony=8, smoothness=0.1, - fit_settings=True, domain='temperature', - Tg=self.T_REF, C1=self.C1, C2=self.C2, shift_model='WLF', - ) - fig1, fig11, fig2, fig3, fig4, fig41, coef_df = result - for fig in (fig1, fig11, fig2, fig3, fig4, fig41): - self.assertGreater(len(fig.data), 0) - self.assertIsInstance(coef_df, list) - self.assertGreater(len(coef_df), 0) - - def test_hybrid_shift_populates_all_figures(self): - result = update_line_chart( - self.synthetic_temp_data, number_of_prony=8, smoothness=0.1, - fit_settings=True, domain='temperature', - Tg=self.T_REF, TL=self.T_REF, C1=self.C1, C2=self.C2, - Ea=self.EA, shift_model='hybrid', - ) - fig1, fig11, fig2, fig3, fig4, fig41, coef_df = result - for fig in (fig1, fig11, fig2, fig3, fig4, fig41): - self.assertGreater(len(fig.data), 0) - self.assertGreater(len(coef_df), 0) - - def test_hybrid_works_without_Tg(self): - # hybrid_shift uses TL (not Tg) as the WLF/Arrhenius crossover, so - # update_line_chart should drive the master-curve path even when Tg is - # not supplied. - result = update_line_chart( - self.synthetic_temp_data, number_of_prony=8, smoothness=0.1, - fit_settings=True, domain='temperature', - TL=self.T_REF, C1=self.C1, C2=self.C2, Ea=self.EA, - shift_model='hybrid', - ) - fig1, fig11, fig2, fig3, fig4, fig41, coef_df = result - for fig in (fig1, fig11, fig2, fig3, fig4, fig41): - self.assertGreater(len(fig.data), 0) - self.assertGreater(len(coef_df), 0) - - def test_Tg_zero_does_not_falsely_trigger_early_exit(self): - # Regression: the old `Tg and C1 and C2` truthy check treated Tg = 0 °C - # as missing and dropped users into the empty-figures branch. - result = update_line_chart( - self.synthetic_temp_data, number_of_prony=8, smoothness=0.1, - fit_settings=True, domain='temperature', - Tg=0.0, C1=self.C1, C2=self.C2, shift_model='WLF', - ) - fig1, fig11, fig2, fig3, fig4, fig41, coef_df = result - for fig in (fig1, fig11, fig2, fig3, fig4, fig41): - self.assertGreater(len(fig.data), 0) - self.assertGreater(len(coef_df), 0) - - def test_shiftData_override_triggers_shift_path(self): - # Even without Tg/C1/C2/shift_model, supplying shiftData should drive - # the shift branch (b is true via the shiftData term). - n = len(self.synthetic_temp_data['Temperature']) - T = self.synthetic_temp_data['Temperature'] - a_T = wlf_shift(T, self.T_REF, self.C1, self.C2) - shiftData = {'Temperature': T, 'a_T': a_T} - result = update_line_chart( - self.synthetic_temp_data, number_of_prony=8, smoothness=0.1, - fit_settings=True, domain='temperature', - shiftData=shiftData, - ) - fig1, fig11, fig2, fig3, fig4, fig41, coef_df = result - for fig in (fig1, fig11, fig2, fig3, fig4, fig41): - self.assertGreater(len(fig.data), 0) - self.assertGreater(len(coef_df), 0) - - -class TestUpdateLineChartValidation(unittest.TestCase): - """ - Validation paths in update_line_chart. - - uploadData is contractually the output of upload_init() — a dict with - canonical keys for the chosen domain mapped to 1-D float ndarrays. Tests - here exercise the user-fixable validation paths (ValueError → HTTP 400 - via the Flask route) and the contract assertions (AssertionError → HTTP - 500 — only reachable via a server bug). - """ - - @staticmethod - def _freq(f, s, l): - return {'Frequency': np.asarray(f, float), - 'E Storage': np.asarray(s, float), - 'E Loss': np.asarray(l, float)} - - @staticmethod - def _temp(T, s, l): - return {'Temperature': np.asarray(T, float), - 'E Storage': np.asarray(s, float), - 'E Loss': np.asarray(l, float)} - - def _call(self, data, **kw): - return update_line_chart( - data, number_of_prony=5, smoothness=0.1, fit_settings=True, **kw, - ) - - def test_empty_data_raises_value_error(self): - with self.assertRaisesRegex(ValueError, 'no data rows'): - self._call(self._freq([], [], []), domain='frequency') - - def test_nan_in_data_raises_value_error(self): - bad = self._freq([1.0, 2.0, 3.0], [100.0, np.nan, 300.0], [10.0, 20.0, 30.0]) - with self.assertRaisesRegex(ValueError, 'non-finite'): - self._call(bad, domain='frequency') - - def test_inf_in_data_raises_value_error(self): - bad = self._freq([1.0, 2.0, 3.0], [100.0, 200.0, 300.0], [10.0, np.inf, 30.0]) - with self.assertRaisesRegex(ValueError, 'non-finite'): - self._call(bad, domain='frequency') - - def test_zero_frequency_raises_value_error(self): - bad = self._freq([0.0, 1.0, 10.0], [100.0, 200.0, 300.0], [10.0, 20.0, 30.0]) - with self.assertRaisesRegex(ValueError, 'Frequency.*positive'): - self._call(bad, domain='frequency') - - def test_negative_frequency_raises_value_error(self): - bad = self._freq([-1.0, 1.0, 10.0], [100.0, 200.0, 300.0], [10.0, 20.0, 30.0]) - with self.assertRaisesRegex(ValueError, 'Frequency.*positive'): - self._call(bad, domain='frequency') - - def test_wrong_uploadData_keys_raises_assertion(self): - # Server-bug class: uploadData doesn't match upload_init's contract. - bad = {'Frequency': np.array([1.0, 2.0, 3.0])} - with self.assertRaises(AssertionError): - self._call(bad, domain='frequency') - - def test_unknown_domain_raises_assertion(self): - # Server-bug class: frontend only ever sends 'frequency' or 'temperature'. - ok = self._freq([1.0, 2.0, 3.0], [100.0, 200.0, 300.0], [10.0, 20.0, 30.0]) - with self.assertRaises(AssertionError): - self._call(ok, domain='bogus') - - def test_shiftdata_length_mismatch_raises_value_error(self): - temp = self._temp([0.0, 25.0, 50.0], [100.0, 200.0, 300.0], [10.0, 20.0, 30.0]) - bad_shift = {'Temperature': [0.0, 25.0], 'a_T': [1.0, 1.0]} - with self.assertRaisesRegex(ValueError, 'same number of rows'): - self._call(temp, domain='temperature', shiftData=bad_shift) - - def test_shiftdata_missing_a_T_raises_assertion(self): - # Server-bug class: upload_init(..., 'shift') always produces an 'a_T' key, - # so a missing one means someone constructed shiftData by hand. - temp = self._temp([0.0, 25.0, 50.0], [100.0, 200.0, 300.0], [10.0, 20.0, 30.0]) - bad_shift = {'Temperature': [0.0, 25.0, 50.0], 'wrong_key': [1.0, 1.0, 1.0]} - with self.assertRaises(AssertionError): - self._call(temp, domain='temperature', shiftData=bad_shift) - - -class TestUpdateLineChartErrorColumns(unittest.TestCase): - """update_line_chart should pass E_stor_std/E_loss_std from the uploadData - error columns when present, else fall back to relative_error*|E*|.""" - - @staticmethod - def _freq_data(extras=None): - d = { - 'Frequency': np.array([1.0, 2.0, 3.0]), - 'E Storage': np.array([100.0, 200.0, 300.0]), - 'E Loss': np.array([10.0, 20.0, 30.0]), - } - if extras: - d.update(extras) - return d - - @staticmethod - def _temp_data(extras=None): - d = { - 'Temperature': np.array([0.0, 25.0, 50.0]), - 'E Storage': np.array([100.0, 200.0, 300.0]), - 'E Loss': np.array([10.0, 20.0, 30.0]), - } - if extras: - d.update(extras) - return d - - def _spy(self): - # Return a minimal valid fit result so downstream figure builders run. - from unittest.mock import patch - spy_target = patch( - 'app.dynamfit.dynamfit2.smooth_prony_fit', - return_value=(np.array([1.0]), np.array([1.0])), - ) - return spy_target - - def test_per_modulus_error_columns_flow_into_smooth_prony_fit(self): - stor_err = np.array([5.0, 10.0, 15.0]) - loss_err = np.array([0.5, 1.0, 1.5]) - data = self._freq_data({ - 'E Storage Error': stor_err, - 'E Loss Error': loss_err, - }) - with self._spy() as spy: - update_line_chart( - data, number_of_prony=5, smoothness=0.1, - fit_settings=False, domain='frequency', - ) - kwargs = spy.call_args.kwargs - np.testing.assert_array_equal(kwargs['E_stor_std'], stor_err) - np.testing.assert_array_equal(kwargs['E_loss_std'], loss_err) - - def test_shared_error_column_flows_into_both_std_kwargs(self): - err = np.array([1.0, 2.0, 3.0]) - data = self._freq_data({'Error': err}) - with self._spy() as spy: - update_line_chart( - data, number_of_prony=5, smoothness=0.1, - fit_settings=False, domain='frequency', - ) - kwargs = spy.call_args.kwargs - np.testing.assert_array_equal(kwargs['E_stor_std'], err) - np.testing.assert_array_equal(kwargs['E_loss_std'], err) - - def test_no_error_columns_falls_back_to_default_relative_error(self): - data = self._freq_data() - with self._spy() as spy: - update_line_chart( - data, number_of_prony=5, smoothness=0.1, - fit_settings=False, domain='frequency', - ) - kwargs = spy.call_args.kwargs - expected = np.abs(data['E Storage'] + 1.0j * data['E Loss']) * 0.2 - np.testing.assert_allclose(kwargs['E_stor_std'], expected) - np.testing.assert_allclose(kwargs['E_loss_std'], expected) - - def test_relative_error_kwarg_scales_fallback(self): - data = self._freq_data() - with self._spy() as spy: - update_line_chart( - data, number_of_prony=5, smoothness=0.1, - fit_settings=False, domain='frequency', - relative_error=0.5, - ) - kwargs = spy.call_args.kwargs - expected = np.abs(data['E Storage'] + 1.0j * data['E Loss']) * 0.5 - np.testing.assert_allclose(kwargs['E_stor_std'], expected) - np.testing.assert_allclose(kwargs['E_loss_std'], expected) - - def test_temperature_per_modulus_error_columns_flow_through_tts(self): - # The temperature branch routes data through tts_temperature_to_frequency_V2, - # which reorders rows by post-shift Frequency. The per-row error values - # must ride along that reorder so smooth_prony_fit sees them aligned. - stor_err = np.array([5.0, 10.0, 15.0]) - loss_err = np.array([0.5, 1.0, 1.5]) - data = self._temp_data({ - 'E Storage Error': stor_err, - 'E Loss Error': loss_err, - }) - with self._spy() as spy: - update_line_chart( - data, number_of_prony=5, smoothness=0.1, - fit_settings=False, domain='temperature', - Tg=25.0, C1=17.44, C2=51.6, shift_model='WLF', - ) - kwargs = spy.call_args.kwargs - # Each (storage, loss) error pair must still be co-located with its - # source row after the frequency-sort reorder. The set of pairs is - # therefore invariant under TTS even though the order changes. - pairs_out = set(zip(kwargs['E_stor_std'].tolist(), - kwargs['E_loss_std'].tolist())) - pairs_in = set(zip(stor_err.tolist(), loss_err.tolist())) - self.assertEqual(pairs_out, pairs_in) - - -class TestFitWlfCoefficients(unittest.TestCase): - """Tests for fit_wlf_coefficients — pure function, no Flask app needed.""" - - T_REF = 25.0 - C1_TRUE = 14.0 - C2_TRUE = 45.0 - - @classmethod - def setUpClass(cls): - # Deterministic synthetic shift data: generate exact a_T from wlf_shift. - cls.T = np.linspace(30.0, 80.0, 20) - cls.a_T = wlf_shift(cls.T, cls.T_REF, cls.C1_TRUE, cls.C2_TRUE) - - def test_round_trip_recovers_C1_and_C2(self): - # Both params free — fit must recover the true values to tight tolerance. - C1_fit, C2_fit = fit_wlf_coefficients( - self.T, self.a_T, self.T_REF, - C1=self.C1_TRUE, C2=self.C2_TRUE, - ) - self.assertAlmostEqual(C1_fit, self.C1_TRUE, places=4) - self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=4) - - def test_fixed_C1_returned_exactly_and_C2_fitted(self): - # C1 is pinned; C2 must be fitted back to truth. - C1_fit, C2_fit = fit_wlf_coefficients( - self.T, self.a_T, self.T_REF, - C1=self.C1_TRUE, C2=self.C2_TRUE, - fix_C1=True, - ) - self.assertEqual(C1_fit, self.C1_TRUE) # exact — no optimizer touched it - self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=4) - - def test_default_initial_guesses_converge(self): - # Omit C1 and C2 so the function falls back to UNIVERSAL_WLF_* as p0. - # Data generated from (C1=UNIVERSAL_WLF_C1, C2=UNIVERSAL_WLF_C2) so - # the universal constants are both the truth and the starting point. - T = np.linspace(30.0, 80.0, 20) - a_T = wlf_shift(T, self.T_REF, UNIVERSAL_WLF_C1, UNIVERSAL_WLF_C2) - C1_fit, C2_fit = fit_wlf_coefficients(T, a_T, self.T_REF) - self.assertAlmostEqual(C1_fit, UNIVERSAL_WLF_C1, places=4) - self.assertAlmostEqual(C2_fit, UNIVERSAL_WLF_C2, places=4) - - def test_nonpositive_a_T_raises_value_error(self): - # a_T containing zero is physically meaningless; log10 is undefined. - bad_a_T = self.a_T.copy() - bad_a_T[3] = 0.0 - with self.assertRaises(ValueError): - fit_wlf_coefficients(self.T, bad_a_T, self.T_REF) - - def test_negative_a_T_raises_value_error(self): - bad_a_T = self.a_T.copy() - bad_a_T[0] = -1.0 - with self.assertRaises(ValueError): - fit_wlf_coefficients(self.T, bad_a_T, self.T_REF) - - def test_cold_data_both_free_converges_and_c2_respects_bound(self): - # Regression: when T spans far below T_ref (cold side), the default - # UNIVERSAL_WLF_C2 initial guess lands near the WLF pole and the - # optimizer evaluates 10**exponent values that overflow float64. - # The fix applies an analytic log10(a_T) model so overflow can't happen, - # and enforces C2 >= c2_min = (T_ref - min(T)) + 1.0 throughout. - T_ref_local = 20.0 - T = np.linspace(-30.0, 70.0, 21) # T_min = -30 → c2_min = 51 - # True parameters well away from the pole, deterministic ground truth. - C1_true, C2_true = 22.0, 180.0 - a_T = wlf_shift(T, T_ref_local, C1_true, C2_true) - c2_min = (T_ref_local - float(T.min())) + 1.0 # = 51.0 - - # Both C1 and C2 free, default initial guesses — this is the case that - # used to raise ValueError before the fix. - C1_fit, C2_fit = fit_wlf_coefficients(T, a_T, T_ref_local) - - self.assertTrue(np.isfinite(C1_fit) and C1_fit > 0, - f"Expected finite positive C1, got {C1_fit}") - self.assertGreaterEqual(C2_fit, c2_min, - f"C2_fit {C2_fit:.4f} < c2_min {c2_min:.4f} — bound not respected") - - -class TestFitHybridCoefficients(unittest.TestCase): - """Tests for fit_hybrid_coefficients — pure function, no Flask app needed.""" - - TL = 25.0 # WLF/Arrhenius crossover — required input, never fitted - C1_TRUE = 14.0 - C2_TRUE = 45.0 - EA_TRUE = 80.0 # kJ/mol — well within curve_fit's convergence basin - - @classmethod - def setUpClass(cls): - # Span both sides of TL so both Arrhenius and WLF branches are exercised. - cls.T = np.linspace(5.0, 70.0, 30) - cls.a_T = hybrid_shift(cls.T, cls.TL, cls.C1_TRUE, cls.C2_TRUE, cls.EA_TRUE) - - def test_round_trip_recovers_C1_C2_and_Ea(self): - C1_fit, C2_fit, Ea_fit, a_T_ref = fit_hybrid_coefficients( - self.T, self.a_T, self.TL, - C1=self.C1_TRUE, C2=self.C2_TRUE, Ea=self.EA_TRUE, - ) - self.assertAlmostEqual(C1_fit, self.C1_TRUE, places=4) - self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=4) - self.assertAlmostEqual(Ea_fit, self.EA_TRUE, places=4) - - def test_fixed_Ea_returned_exactly_and_others_fitted(self): - # Ea is pinned; C1 and C2 must be recovered from the data. - C1_fit, C2_fit, Ea_fit, a_T_ref = fit_hybrid_coefficients( - self.T, self.a_T, self.TL, - C1=self.C1_TRUE, C2=self.C2_TRUE, Ea=self.EA_TRUE, - fix_Ea=True, - ) - self.assertEqual(Ea_fit, self.EA_TRUE) # exact — never passed to optimizer - self.assertAlmostEqual(C1_fit, self.C1_TRUE, places=4) - self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=4) - - def test_nonpositive_a_T_raises_value_error(self): - bad_a_T = self.a_T.copy() - bad_a_T[5] = 0.0 - with self.assertRaises(ValueError): - fit_hybrid_coefficients(self.T, bad_a_T, self.TL) - - def test_returns_a_T_ref_near_one_for_TL_referenced_data(self): - # setUpClass data is built referenced to TL (hybrid_shift, a_T_ref=1), so - # the co-fitted a_T_ref recovers ~1.0. - *_, a_T_ref = fit_hybrid_coefficients( - self.T, self.a_T, self.TL, - C1=self.C1_TRUE, C2=self.C2_TRUE, Ea=self.EA_TRUE, - ) - self.assertAlmostEqual(a_T_ref, 1.0, places=4) - - def test_cofits_offset_for_unreferenced_data(self): - # Data scaled by a known constant K (reference shifted off TL): the fit - # recovers K as a_T_ref and the same C1/C2/Ea. - K = 12.0 - C1_fit, C2_fit, Ea_fit, a_T_ref = fit_hybrid_coefficients( - self.T, K * self.a_T, self.TL, - C1=self.C1_TRUE, C2=self.C2_TRUE, Ea=self.EA_TRUE, - ) - self.assertAlmostEqual(a_T_ref, K, places=3) - self.assertAlmostEqual(C1_fit, self.C1_TRUE, places=3) - self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=3) - self.assertAlmostEqual(Ea_fit, self.EA_TRUE, places=3) - - def test_degenerate_Arrhenius_guard_fires_and_suppressed_by_fix_Ea(self): - # All data above TL → empty Arrhenius segment → ValueError while Ea is - # free; fixing Ea suppresses the guard and the WLF-only fit succeeds. - T = np.linspace(30.0, 70.0, 20) - a_T = wlf_shift(T, 25.0, self.C1_TRUE, self.C2_TRUE) - with self.assertRaises(ValueError): - fit_hybrid_coefficients(T, a_T, TL=25.0) - C1_fit, C2_fit, Ea_fit, _ = fit_hybrid_coefficients( - T, a_T, TL=25.0, Ea=self.EA_TRUE, fix_Ea=True, - ) - self.assertEqual(Ea_fit, self.EA_TRUE) - self.assertAlmostEqual(C1_fit, self.C1_TRUE, places=3) - self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=3) - - def test_degenerate_WLF_guard_fires_and_suppressed_by_fix_C(self): - # All data below TL → empty WLF segment → ValueError while C1/C2 are - # free; fixing them suppresses the guard and the Arrhenius-only fit - # recovers Ea. - T = np.linspace(5.0, 20.0, 20) - a_T = _arr_shift(T, 25.0, self.EA_TRUE) - with self.assertRaises(ValueError): - fit_hybrid_coefficients(T, a_T, TL=25.0) - C1_fit, C2_fit, Ea_fit, _ = fit_hybrid_coefficients( - T, a_T, TL=25.0, C1=self.C1_TRUE, C2=self.C2_TRUE, - fix_C1=True, fix_C2=True, - ) - self.assertEqual((C1_fit, C2_fit), (self.C1_TRUE, self.C2_TRUE)) - self.assertAlmostEqual(Ea_fit, self.EA_TRUE, places=3) - - def test_all_fixed_returns_four_tuple_and_cofits_offset(self): - # C1/C2/Ea all fixed → echoed exactly; a_T_ref is still co-fitted. - K = 7.0 - C1_fit, C2_fit, Ea_fit, a_T_ref = fit_hybrid_coefficients( - self.T, K * self.a_T, self.TL, - C1=self.C1_TRUE, C2=self.C2_TRUE, Ea=self.EA_TRUE, - fix_C1=True, fix_C2=True, fix_Ea=True, - ) - self.assertEqual( - (C1_fit, C2_fit, Ea_fit), - (self.C1_TRUE, self.C2_TRUE, self.EA_TRUE), - ) - self.assertAlmostEqual(a_T_ref, K, places=3) - - def test_descending_T_round_trip(self): - # Reversed (descending) T/a_T must recover the same coefficients — checks - # the direction handling and the ascending-ordered a_T_ref interpolation. - C1_fit, C2_fit, Ea_fit, a_T_ref = fit_hybrid_coefficients( - self.T[::-1], self.a_T[::-1], self.TL, - C1=self.C1_TRUE, C2=self.C2_TRUE, Ea=self.EA_TRUE, - ) - self.assertAlmostEqual(C1_fit, self.C1_TRUE, places=4) - self.assertAlmostEqual(C2_fit, self.C2_TRUE, places=4) - self.assertAlmostEqual(Ea_fit, self.EA_TRUE, places=4) - self.assertAlmostEqual(a_T_ref, 1.0, places=4) - - -# --------------------------------------------------------------------------- -# Route-level tests (Flask test client). The shared _make_app / _make_token -# helpers and synthetic shift data below are used by both the /dynamfit/extract/ -# and /dynamfit/fit-shift/ route tests. -# --------------------------------------------------------------------------- -import jwt -from flask import Flask -from app.dynamfit.routes import dynamfit -from app.config import Config - - -def _make_app(): - """ - Build a minimal Flask test app that registers only the dynamfit blueprint, - avoiding the FileHandler('/services/services_app.log') crash in create_app(). - SECRET_KEY is fixed so we can mint tokens without an .env file. - """ - app = Flask(__name__) - app.config['SECRET_KEY'] = 'test-secret' - app.config['TESTING'] = True - app.register_blueprint(dynamfit) - return app - - -def _make_token(secret='test-secret', req_id='test-req-id'): - """Mint a JWT with the reqId field that token_required expects.""" - payload = { - 'reqId': req_id, - 'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=600), - } - return jwt.encode(payload, secret, algorithm='HS256') - - -# Synthetic shift data (5 points, all positive a_T) used across tests. -_SHIFT_T = np.array([0.0, 10.0, 20.0, 30.0, 40.0]) -_SHIFT_A_T = np.array([3.0, 2.0, 1.0, 0.5, 0.25]) -_SHIFT_DATA = {'Temperature': _SHIFT_T, 'a_T': _SHIFT_A_T} - -# Representative WLF coefficients returned by the mocked fit functions. -_C1_RETURNED = 14.0 -_C2_RETURNED = 45.0 -_EA_RETURNED = 80.0 -_A_T_REF_RETURNED = 1.0 - - -class TestFitShiftCoefficientsRoute(unittest.TestCase): - """ - Route-level tests for POST /dynamfit/fit-shift/, the dedicated shift-domain - coefficient-fit endpoint (split out of /extract/). - - Strategy: - - Build a bare Flask app (no create_app) to avoid the Docker-only FileHandler. - - Patch Config.SECRET_KEY so token_required decodes our test tokens. - - Patch check_file_exists and upload_init at the route module boundary so - tests never touch disk and don't depend on real file content. - - Patch fit_wlf_coefficients and fit_hybrid_coefficients so tests verify - route wiring, not fit math (fit math is covered in TestFitWlfCoefficients - and TestFitHybridCoefficients above). - """ - - @classmethod - def setUpClass(cls): - Config.SECRET_KEY = 'test-secret' - cls.app = _make_app() - cls.client = cls.app.test_client() - cls.token = _make_token() - cls.headers = {'Authorization': f'Bearer {cls.token}', - 'Content-Type': 'application/json'} - - def _post(self, body): - return self.client.post( - '/dynamfit/fit-shift/', - data=json.dumps(body), - headers=self.headers, - ) - - def _base_wlf_body(self, **overrides): - body = { - 'shift_file_name': 'shift.txt', - 'transform_method': 'WLF', - 'Tg': 25.0, - } - body.update(overrides) - return body - - def _base_hybrid_body(self, **overrides): - body = { - 'shift_file_name': 'shift.txt', - 'transform_method': 'hybrid', - 'TL': 25.0, - } - body.update(overrides) - return body - - # ------------------------------------------------------------------ - # Happy paths - # ------------------------------------------------------------------ - - @patch('app.dynamfit.routes.fit_wlf_coefficients', - return_value=(_C1_RETURNED, _C2_RETURNED)) - @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) - @patch('app.dynamfit.routes.check_file_exists', return_value=True) - def test_wlf_happy_path_returns_seven_coefficient_keys( - self, _exists, _upload, _fit): - resp = self._post(self._base_wlf_body()) - self.assertEqual(resp.status_code, 200) - data = json.loads(resp.data) - self.assertEqual( - set(data.keys()), - {'transform_method', 'Tg', 'C1', 'C2', 'Ea', 'TL', 'a_T_ref'}, - ) - - @patch('app.dynamfit.routes.fit_wlf_coefficients', - return_value=(_C1_RETURNED, _C2_RETURNED)) - @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) - @patch('app.dynamfit.routes.check_file_exists', return_value=True) - def test_wlf_happy_path_a_T_ref_is_one( - self, _exists, _upload, _fit): - # WLF is anchored at T_ref=Tg where a_T == 1 by construction. - resp = self._post(self._base_wlf_body()) - data = json.loads(resp.data) - self.assertEqual(data['a_T_ref'], 1.0) - - @patch('app.dynamfit.routes.fit_wlf_coefficients', - return_value=(_C1_RETURNED, _C2_RETURNED)) - @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) - @patch('app.dynamfit.routes.check_file_exists', return_value=True) - def test_wlf_happy_path_ea_and_tl_are_null( - self, _exists, _upload, _fit): - resp = self._post(self._base_wlf_body()) - data = json.loads(resp.data) - self.assertIsNone(data['Ea']) - self.assertIsNone(data['TL']) - - @patch('app.dynamfit.routes.fit_wlf_coefficients', - return_value=(_C1_RETURNED, _C2_RETURNED)) - @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) - @patch('app.dynamfit.routes.check_file_exists', return_value=True) - def test_wlf_happy_path_c1_c2_are_plain_floats( - self, _exists, _upload, _fit): - # Confirms JSON serialization works: numpy scalars would raise TypeError. - resp = self._post(self._base_wlf_body()) - data = json.loads(resp.data) - self.assertIsInstance(data['C1'], float) - self.assertIsInstance(data['C2'], float) - - @patch('app.dynamfit.routes.fit_hybrid_coefficients', - return_value=(_C1_RETURNED, _C2_RETURNED, _EA_RETURNED, _A_T_REF_RETURNED)) - @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) - @patch('app.dynamfit.routes.check_file_exists', return_value=True) - def test_hybrid_happy_path_tg_null_ea_tl_populated( - self, _exists, _upload, _fit): - resp = self._post(self._base_hybrid_body()) - self.assertEqual(resp.status_code, 200) - data = json.loads(resp.data) - self.assertIsNone(data['Tg']) - self.assertIsInstance(data['Ea'], float) - self.assertIsInstance(data['TL'], float) - - @patch('app.dynamfit.routes.fit_hybrid_coefficients', - return_value=(_C1_RETURNED, _C2_RETURNED, _EA_RETURNED, _A_T_REF_RETURNED)) - @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) - @patch('app.dynamfit.routes.check_file_exists', return_value=True) - def test_hybrid_happy_path_surfaces_co_fit_a_T_ref( - self, _exists, _upload, _fit): - # The 4th element of fit_hybrid_coefficients (the co-fit vertical offset) - # must reach the response as a_T_ref. - resp = self._post(self._base_hybrid_body()) - data = json.loads(resp.data) - self.assertEqual(data['a_T_ref'], _A_T_REF_RETURNED) - - @patch('app.dynamfit.routes.fit_wlf_coefficients', - return_value=(_C1_RETURNED, _C2_RETURNED)) - @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) - @patch('app.dynamfit.routes.check_file_exists', return_value=True) - def test_wlf_supplied_c1_passes_fix_c1_true_to_fit( - self, _exists, _upload, mock_fit): - """When C1 is in the body the route must pass fix_C1=True to the fit function.""" - resp = self._post(self._base_wlf_body(C1=99.0)) - self.assertEqual(resp.status_code, 200) - _, kwargs = mock_fit.call_args - self.assertTrue(kwargs.get('fix_C1')) - - @patch('app.dynamfit.routes.fit_wlf_coefficients', - return_value=(_C1_RETURNED, _C2_RETURNED)) - @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) - @patch('app.dynamfit.routes.check_file_exists', return_value=True) - def test_wlf_response_lacks_chart_keys( - self, _exists, _upload, _fit): - """The short-circuit branch must NOT include chart output keys.""" - resp = self._post(self._base_wlf_body()) - data = json.loads(resp.data) - for chart_key in ('complex-chart', 'mytable', 'multi', 'response'): - self.assertNotIn(chart_key, data) - - # ------------------------------------------------------------------ - # Validation / short-circuit errors - # ------------------------------------------------------------------ - - def test_missing_shift_file_name_returns_400(self): - body = { - 'transform_method': 'WLF', - 'Tg': 25.0, - # shift_file_name intentionally omitted - } - resp = self._post(body) - self.assertEqual(resp.status_code, 400) - - @patch('app.dynamfit.routes.check_file_exists', return_value=False) - def test_shift_file_not_found_returns_404(self, _exists): - resp = self._post(self._base_wlf_body()) - self.assertEqual(resp.status_code, 404) - - @patch('app.dynamfit.routes.check_file_exists', return_value=True) - def test_invalid_transform_method_returns_400(self, _exists): - body = self._base_wlf_body(transform_method='manual') - resp = self._post(body) - self.assertEqual(resp.status_code, 400) - - @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) - @patch('app.dynamfit.routes.check_file_exists', return_value=True) - def test_wlf_missing_tg_returns_400(self, _exists, _upload): - body = { - 'shift_file_name': 'shift.txt', - 'transform_method': 'WLF', - # Tg intentionally omitted - } - resp = self._post(body) - self.assertEqual(resp.status_code, 400) - - @patch('app.dynamfit.routes.upload_init', return_value=_SHIFT_DATA) - @patch('app.dynamfit.routes.check_file_exists', return_value=True) - def test_hybrid_missing_tl_returns_400(self, _exists, _upload): - body = { - 'shift_file_name': 'shift.txt', - 'transform_method': 'hybrid', - # TL intentionally omitted - } - resp = self._post(body) - self.assertEqual(resp.status_code, 400) - - @patch('app.dynamfit.routes.upload_init', return_value=None) - @patch('app.dynamfit.routes.check_file_exists', return_value=True) - def test_empty_shift_file_returns_400(self, _exists, _upload): - resp = self._post(self._base_wlf_body()) - self.assertEqual(resp.status_code, 400) - - -# --------------------------------------------------------------------------- -# End-to-end tests: real files + real optimizer, no mocking of upload_init or -# fit functions. These complement TestExtractFitShiftCoefficientsRoute (which -# mocks everything and verifies route wiring) by verifying that the optimizer -# actually converges and produces a fit that reconstructs the data to a -# meaningful tolerance. -# -# WLF calibration (agilus30 20C): both C1 and C2 fitted freely from defaults. -# The data spans T down to -30 °C (50 °C below T_ref=20), which previously -# caused a 10**exponent overflow in the optimizer (ValueError → HTTP 400) with -# the default UNIVERSAL_WLF_C2=51.6 initial guess. The pole-avoidance fix -# uses an analytic log10(a_T) model so overflow can't propagate as an exception, -# and enforces C2 >= c2_min throughout. Observed log10-RMSE ≈ 0.437 (threshold: 0.55). -# -# Hybrid calibration (VeroCyan 80C): all three parameters (C1, C2, Ea) fitted -# freely from defaults; a_T_ref (the vertical reference offset) is co-fitted and -# must be applied when reconstructing (the VeroCyan data is not referenced to TL, -# a_T_ref ≈ 9.07). Observed log10-RMSE ≈ 0.704 (threshold: 1.0). -# --------------------------------------------------------------------------- - -_REAL_FILES_DIR = os.path.abspath( - os.path.join(os.path.dirname(__file__), '..', '..', 'app', 'dynamfit', 'files') -) - - -class TestFitShiftCoefficientsEndToEnd(unittest.TestCase): - """ - E2E tests for POST /dynamfit/fit-shift/. - - Real files on disk, real optimizer — no mocking of upload_init, check_file_exists, - or the fit functions. Config.FILES_DIRECTORY is pointed at the checked-in - files/ directory and restored in tearDownClass. - """ - - _orig_files_dir = None - - @classmethod - def setUpClass(cls): - cls._orig_files_dir = Config.FILES_DIRECTORY - Config.FILES_DIRECTORY = _REAL_FILES_DIR - Config.SECRET_KEY = 'test-secret' - cls.app = _make_app() - cls.client = cls.app.test_client() - cls.token = _make_token() - cls.headers = { - 'Authorization': f'Bearer {cls.token}', - 'Content-Type': 'application/json', - } - - @classmethod - def tearDownClass(cls): - Config.FILES_DIRECTORY = cls._orig_files_dir - - def _post(self, body): - return self.client.post( - '/dynamfit/fit-shift/', - data=json.dumps(body), - headers=self.headers, - ) - - def test_wlf_e2e_converges_and_fit_is_accurate(self): - """ - WLF fit on agilus30 shift data (T_ref=20 °C), BOTH C1 and C2 free. - - This is the direct regression test for the pole-avoidance fix. Before - the fix, the default UNIVERSAL_WLF_C2=51.6 initial guess produced a - 10**exponent overflow on data spanning T down to -30 °C, causing a - ValueError → HTTP 400. The fix applies an analytic log10(a_T) model - with a C2 lower bound, so the optimizer can start from the universal - defaults and converge. Observed log10-RMSE ≈ 0.437 (threshold: 0.55). - """ - body = { - 'shift_file_name': 'agilus30 (8) shift factors 20C clean.txt', - 'transform_method': 'WLF', - 'Tg': 20.0, - # No C1 or C2 — both are freely fitted from UNIVERSAL_WLF_* defaults - } - resp = self._post(body) - self.assertEqual(resp.status_code, 200) - - result = json.loads(resp.data) - self.assertEqual(result['transform_method'], 'WLF') - self.assertIsNone(result['Ea']) - self.assertIsNone(result['TL']) - self.assertEqual(result['a_T_ref'], 1.0) - - C1 = result['C1'] - C2 = result['C2'] - self.assertTrue(np.isfinite(C1) and C1 > 0, f"Expected finite positive C1, got {C1}") - self.assertTrue(np.isfinite(C2) and C2 > 0, f"Expected finite positive C2, got {C2}") - - # RMSE check: load real data and reconstruct with fitted coefficients. - # Both-free observed RMSE ≈ 0.437; threshold 0.55 allows optimizer - # variance without masking a regression to a grossly wrong solution. - shift_data = upload_init('agilus30 (8) shift factors 20C clean.txt', 'shift') - T = np.asarray(shift_data['Temperature'], dtype=float) - a_T = np.asarray(shift_data['a_T'], dtype=float) - reconstructed = wlf_shift(T, 20.0, C1, C2) - log_rmse = np.sqrt(np.mean((np.log10(reconstructed) - np.log10(a_T)) ** 2)) - self.assertLess(log_rmse, 0.55, - f"WLF log10-RMSE {log_rmse:.4f} exceeds 0.55 — fit likely diverged") - - def test_hybrid_e2e_converges_and_fit_is_accurate(self): - """ - Hybrid Arrhenius/WLF fit on VeroCyan shift data (T_L=80 °C). - - All three parameters (C1, C2, Ea) are fitted freely from default initial - guesses — no body-level C1/C2/Ea, so the optimizer is fully exercised. - The route must return 200 with finite positive C1, C2, and Ea; Tg must - be null; reconstructed shift factors must agree with the data in log10 space. - """ - body = { - 'shift_file_name': 'VeroCyan (5) shift factors 80C clean.txt', - 'transform_method': 'hybrid', - 'TL': 80.0, - # No C1, C2, Ea — all three are freely fitted - } - resp = self._post(body) - self.assertEqual(resp.status_code, 200) - - result = json.loads(resp.data) - self.assertEqual(result['transform_method'], 'hybrid') - self.assertIsNone(result['Tg']) - - C1 = result['C1'] - C2 = result['C2'] - Ea = result['Ea'] - for name, val in [('C1', C1), ('C2', C2), ('Ea', Ea)]: - self.assertTrue(np.isfinite(val) and val > 0, - f"Expected finite positive {name}, got {val}") - - # The route now surfaces the co-fit vertical reference offset; the VeroCyan - # data is not referenced to TL, so a_T_ref must be finite, positive, ≠ 1. - a_T_ref = result['a_T_ref'] - self.assertTrue(np.isfinite(a_T_ref) and a_T_ref > 0, - f"Expected finite positive a_T_ref, got {a_T_ref}") - - # RMSE check: observed RMSE ≈ 0.844; threshold 1.0 is meaningful without - # being so tight that minor optimizer variance causes flakiness. - shift_data = upload_init('VeroCyan (5) shift factors 80C clean.txt', 'shift') - T = np.asarray(shift_data['Temperature'], dtype=float) - a_T = np.asarray(shift_data['a_T'], dtype=float) - # Reconstruct using the surfaced a_T_ref a consumer would apply. - unscaled = hybrid_shift(T, 80.0, C1, C2, Ea) - reconstructed = a_T_ref * unscaled - log_rmse = np.sqrt(np.mean((np.log10(reconstructed) - np.log10(a_T)) ** 2)) - self.assertLess(log_rmse, 1.0, - f"Hybrid log10-RMSE {log_rmse:.4f} exceeds 1.0 — fit likely diverged") - - -class TestExtractRoute(unittest.TestCase): - """ - Route-level tests for POST /dynamfit/extract/ (the Prony fit + charts route). - - Real files on disk + real fit, no mocking, so the full response envelope is - JSON-serialized exactly as a client receives it. This covers what the - pure-function TestUpdateLineChart* classes cannot: request parsing, - validation short-circuits, and JSON serialization of the response — in - particular the regression guard for numpy values reaching stdlib json.dumps. - """ - - _orig_files_dir = None - _FREQ_FILE = 'agilus30 (8) master curve 20C clean.txt' - - @classmethod - def setUpClass(cls): - cls._orig_files_dir = Config.FILES_DIRECTORY - Config.FILES_DIRECTORY = _REAL_FILES_DIR - Config.SECRET_KEY = 'test-secret' - cls.app = _make_app() - cls.client = cls.app.test_client() - cls.token = _make_token() - cls.headers = { - 'Authorization': f'Bearer {cls.token}', - 'Content-Type': 'application/json', - } - - @classmethod - def tearDownClass(cls): - Config.FILES_DIRECTORY = cls._orig_files_dir - - def _post(self, body): - return self.client.post( - '/dynamfit/extract/', - data=json.dumps(body), - headers=self.headers, - ) - - def _freq_body(self, **overrides): - body = {'file_name': self._FREQ_FILE, 'domain': 'frequency', 'number_of_prony': 5} - body.update(overrides) - return body - - # ------------------------------------------------------------------ - # Happy path — full response envelope, fully JSON-serialized - # ------------------------------------------------------------------ - - def test_frequency_happy_path_returns_200(self): - """ - Regression guard for 'Object of type ndarray is not JSON serializable': - a real frequency-domain extract must serialize the whole response - (upload-data + mytable contain numpy values) and return 200, not 500. - """ - resp = self._post(self._freq_body()) - self.assertEqual(resp.status_code, 200, resp.data[:400]) - - def test_frequency_response_has_all_chart_and_coef_keys(self): - resp = self._post(self._freq_body()) - self.assertEqual(resp.status_code, 200, resp.data[:400]) - data = json.loads(resp.data) - self.assertTrue(data['multi']) - response = data['response'] - for key in ('complex-chart', 'complex-tand-chart', 'relaxation-chart', - 'relaxation-spectrum-chart', 'complex-temp-chart', - 'temp-tand-chart', 'mytable', 'upload-data', - 'C1', 'C2', 'Tg', 'Ea', 'TL'): - self.assertIn(key, response) - - def test_upload_data_is_array_of_row_objects(self): - """ - upload-data must be an array of row-objects (one dict per data row), - matching mytable's shape, the contract doc, and the frontend - TableComponent (which derives columns from Object.keys(rows[0])) — not a - dict of columns. - """ - resp = self._post(self._freq_body()) - self.assertEqual(resp.status_code, 200, resp.data[:400]) - upload = json.loads(resp.data)['response']['upload-data'] - self.assertIsInstance(upload, list) - self.assertTrue(upload, 'expected at least one data row') - self.assertEqual(set(upload[0]), {'Frequency', 'E Storage', 'E Loss'}) - self.assertIsInstance(upload[0]['Frequency'], (int, float)) - - def test_mytable_records_have_expected_keys(self): - resp = self._post(self._freq_body()) - self.assertEqual(resp.status_code, 200, resp.data[:400]) - mytable = json.loads(resp.data)['response']['mytable'] - self.assertIsInstance(mytable, list) - self.assertTrue(mytable, 'expected at least one Prony coefficient record') - self.assertEqual(set(mytable[0]), {'i', 'tau_i', 'E_i'}) - - def test_estimated_shift_params_echo_serializes(self): - """ - When Tg/TL are filled by *_estimate they are numpy scalars read out of - the data array; the echoed response must still JSON-serialize and carry - them as plain numbers (regression guard against numpy values in the - echoed C1/C2/Tg/Ea/TL). - """ - resp = self._post(self._freq_body(Tg_estimate=True, TL_estimate=True)) - self.assertEqual(resp.status_code, 200, resp.data[:400]) - response = json.loads(resp.data)['response'] - self.assertIsInstance(response['Tg'], (int, float)) - self.assertIsInstance(response['TL'], (int, float)) - - # ------------------------------------------------------------------ - # Validation short-circuits (do not reach response serialization) - # ------------------------------------------------------------------ - - def test_missing_file_name_returns_400(self): - resp = self._post({'domain': 'frequency'}) - self.assertEqual(resp.status_code, 400) - - def test_file_not_found_returns_404(self): - resp = self._post(self._freq_body(file_name='does-not-exist.txt')) - self.assertEqual(resp.status_code, 404) - - def test_number_of_prony_out_of_range_returns_400(self): - resp = self._post(self._freq_body(number_of_prony=999)) - self.assertEqual(resp.status_code, 400) - - -if __name__ == '__main__': - unittest.main() diff --git a/services/tests/dynamfit/test_prony.py b/services/tests/dynamfit/test_prony.py new file mode 100644 index 000000000..236c9e5cf --- /dev/null +++ b/services/tests/dynamfit/test_prony.py @@ -0,0 +1,473 @@ +""" +Prony-series core math: basis construction, the relaxation grid, the forward +transforms (complex modulus / relaxation modulus / relaxation spectrum), the +fit objective, the smooth Prony fit, and the argmax peak helper. + +Pure functions — no Flask app, no disk access — so this is the fastest subset +and the one to run while iterating on the Prony math. + + python -m unittest tests.dynamfit.test_prony +""" +import unittest +import os +os.environ['OPENBLAS_NUM_THREADS'] = '1' +import sys +import numpy as np +import pandas as pd + +# Append the directory above 'tests' to sys.path to find the 'app' module +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) + +from app.dynamfit.dynamfit2 import ( + prony_basis, + prony_relaxation_space, + compute_complex, + compute_relaxation_modulus, + compute_relaxation_spectrum, + _prony_objective, + smooth_prony_fit, + argmax_peak, +) + + +TAU = np.array([0.1, 1.0, 10.0]) +# Coefficient magnitudes ~exp(7) ≈ 1097, the solver's default x0, so the +# round-trip recovery test in TestSmoothPronyFit converges from x0. +VISCOUS_E = np.array([1000.0, 2000.0, 3000.0]) +SOLID_E = np.array([500.0, 1000.0, 2000.0, 3000.0]) # one extra leading equilibrium term + + +class TestPronyBasis(unittest.TestCase): + def test_shape_solid_false(self): + freq = np.array([1.0, 2.0, 3.0]) + tau = np.array([0.1, 1.0]) + result = prony_basis(freq, tau, solid=False) + self.assertEqual(result.shape, (6, 2)) + + def test_shape_solid_true(self): + freq = np.array([1.0, 2.0, 3.0]) + tau = np.array([0.1, 1.0]) + result = prony_basis(freq, tau, solid=True) + self.assertEqual(result.shape, (6, 3)) + + def test_values_at_omega_tau_unity(self): + # ωτ = 1 → storage = loss = 1/2 exactly. + freq = np.array([1.0]) + tau = np.array([1.0]) + result = prony_basis(freq, tau, solid=False) + self.assertEqual(result.tolist(), [[0.5], [0.5]]) + + def test_values_at_zero_frequency(self): + # ω = 0 → storage = loss = 0 exactly. + freq = np.array([0.0, 0.0]) + tau = np.array([1.0, 2.0]) + result = prony_basis(freq, tau, solid=False) + np.testing.assert_array_equal(result, np.zeros((4, 2))) + + def test_solid_prepends_one_and_zero_columns(self): + freq = np.array([2.0, 3.0]) + tau = np.array([1.0, 5.0]) + result = prony_basis(freq, tau, solid=True) + # Top half (storage): first column is ones. + np.testing.assert_array_equal(result[:2, 0], np.ones(2)) + # Bottom half (loss): first column is zeros. + np.testing.assert_array_equal(result[2:, 0], np.zeros(2)) + + def test_rejects_non_ndarray_freq(self): + with self.assertRaises(AssertionError): + prony_basis([1.0, 2.0], np.array([1.0]), solid=False) + + def test_rejects_non_ndarray_relaxations(self): + with self.assertRaises(AssertionError): + prony_basis(np.array([1.0]), [1.0], solid=False) + + def test_rejects_2d_freq(self): + with self.assertRaises(AssertionError): + prony_basis(np.array([[1.0], [2.0]]), np.array([1.0]), solid=False) + + def test_rejects_2d_relaxations(self): + with self.assertRaises(AssertionError): + prony_basis(np.array([1.0]), np.array([[1.0]]), solid=False) + + +class TestPronyRelaxationSpace(unittest.TestCase): + def test_length(self): + result = prony_relaxation_space(1e-3, 1e3, 7) + self.assertEqual(len(result), 7) + + def test_endpoints(self): + result = prony_relaxation_space(1e-3, 1e3, 5) + np.testing.assert_allclose(result[0], 1e-3) + np.testing.assert_allclose(result[-1], 1e3) + + +class TestComputeComplex(unittest.TestCase): + def test_shape_and_columns_viscous(self): + result = compute_complex(TAU, VISCOUS_E) + self.assertIsInstance(result, pd.DataFrame) + self.assertEqual(len(result), 1000) + self.assertListEqual(list(result.columns), ['Frequency', 'E Storage', 'E Loss']) + + def test_shape_and_columns_solid(self): + result = compute_complex(TAU, SOLID_E) + self.assertIsInstance(result, pd.DataFrame) + self.assertEqual(len(result), 1000) + self.assertListEqual(list(result.columns), ['Frequency', 'E Storage', 'E Loss']) + + def test_num_pts_kwarg(self): + result = compute_complex(TAU, VISCOUS_E, num_pts=50) + self.assertEqual(len(result), 50) + + def test_storage_modulus_monotonic_in_frequency(self): + # Positive E_i, no equilibrium term → storage modulus is non-decreasing in ω. + result = compute_complex(TAU, VISCOUS_E) + self.assertTrue(np.all(np.diff(result['E Storage'].values) >= -1e-12)) + + def test_rejects_non_ndarray_tau(self): + with self.assertRaises(AssertionError): + compute_complex([0.1, 1.0, 10.0], VISCOUS_E) + + def test_rejects_non_ndarray_E(self): + with self.assertRaises(AssertionError): + compute_complex(TAU, [1.0, 2.0, 3.0]) + + def test_rejects_2d_tau(self): + with self.assertRaises(AssertionError): + compute_complex(TAU.reshape(1, -1), VISCOUS_E) + + def test_rejects_2d_E(self): + with self.assertRaises(AssertionError): + compute_complex(TAU, VISCOUS_E.reshape(1, -1)) + + +class TestComputeRelaxationModulus(unittest.TestCase): + def test_shape_and_columns_viscous(self): + result = compute_relaxation_modulus(TAU, VISCOUS_E) + self.assertIsInstance(result, pd.DataFrame) + self.assertEqual(len(result), 1000) + self.assertListEqual(list(result.columns), ['Time', 'E']) + + def test_shape_and_columns_solid(self): + result = compute_relaxation_modulus(TAU, SOLID_E) + self.assertEqual(len(result), 1000) + self.assertListEqual(list(result.columns), ['Time', 'E']) + + def test_num_pts_kwarg(self): + result = compute_relaxation_modulus(TAU, VISCOUS_E, num_pts=50) + self.assertEqual(len(result), 50) + + def test_modulus_decreasing_in_time(self): + # Positive E_i, equilibrium term excluded → E(t) is non-increasing in t. + result = compute_relaxation_modulus(TAU, VISCOUS_E) + self.assertTrue(np.all(np.diff(result['E'].values) <= 1e-12)) + + def test_rejects_non_ndarray_tau(self): + with self.assertRaises(AssertionError): + compute_relaxation_modulus([0.1, 1.0, 10.0], VISCOUS_E) + + def test_rejects_non_ndarray_E(self): + with self.assertRaises(AssertionError): + compute_relaxation_modulus(TAU, [1.0, 2.0, 3.0]) + + def test_rejects_2d_tau(self): + with self.assertRaises(AssertionError): + compute_relaxation_modulus(TAU.reshape(1, -1), VISCOUS_E) + + def test_rejects_2d_E(self): + with self.assertRaises(AssertionError): + compute_relaxation_modulus(TAU, VISCOUS_E.reshape(1, -1)) + + +class TestComputeRelaxationSpectrum(unittest.TestCase): + def test_shape_and_columns_viscous(self): + result = compute_relaxation_spectrum(TAU, VISCOUS_E) + self.assertIsInstance(result, pd.DataFrame) + self.assertEqual(len(result), 1000) + self.assertListEqual(list(result.columns), ['Time', 'H']) + + def test_shape_and_columns_solid(self): + result = compute_relaxation_spectrum(TAU, SOLID_E) + self.assertEqual(len(result), 1000) + self.assertListEqual(list(result.columns), ['Time', 'H']) + + def test_num_pts_kwarg(self): + result = compute_relaxation_spectrum(TAU, VISCOUS_E, num_pts=50) + self.assertEqual(len(result), 50) + + def test_rejects_non_ndarray_tau(self): + with self.assertRaises(AssertionError): + compute_relaxation_spectrum([0.1, 1.0, 10.0], VISCOUS_E) + + def test_rejects_non_ndarray_E(self): + with self.assertRaises(AssertionError): + compute_relaxation_spectrum(TAU, [1.0, 2.0, 3.0]) + + def test_rejects_2d_tau(self): + with self.assertRaises(AssertionError): + compute_relaxation_spectrum(TAU.reshape(1, -1), VISCOUS_E) + + def test_rejects_2d_E(self): + with self.assertRaises(AssertionError): + compute_relaxation_spectrum(TAU, VISCOUS_E.reshape(1, -1)) + + +class TestPronyObjective(unittest.TestCase): + def setUp(self): + # Small viscous problem: 10 frequencies, 3 relaxation times. + self.omega = np.logspace(np.log10(0.5), np.log10(2.0), 10) + self.tau_i = np.array([0.1, 1.0, 10.0]) + self.basis = prony_basis(self.omega, self.tau_i, solid=False) + self.logcoefs = np.log(np.array([1.0, 2.0, 3.0])) + # Construct data so that the model fits exactly at self.logcoefs. + self.data = self.basis @ np.exp(self.logcoefs) + self.std = np.ones_like(self.data) + + def test_returns_scalar_loss_and_gradient_shape(self): + loss, grad = _prony_objective( + self.logcoefs, self.data, self.std, self.basis, + smoothness=0.0, solid=False, + ) + self.assertTrue(np.isscalar(loss) or np.ndim(loss) == 0) + self.assertEqual(grad.shape, self.logcoefs.shape) + + def test_exact_fit_zero_loss_and_zero_gradient(self): + loss, grad = _prony_objective( + self.logcoefs, self.data, self.std, self.basis, + smoothness=0.0, solid=False, + ) + self.assertEqual(loss, 0.0) + np.testing.assert_array_equal(grad, np.zeros_like(grad)) + + def test_smoothness_penalty_increases_loss(self): + # Use non-smooth (zig-zag) logcoefs so the second-difference is nonzero. + logcoefs = np.array([0.0, 5.0, 0.0, 5.0, 0.0]) + tau_i = prony_relaxation_space(0.1, 10.0, 5) + basis = prony_basis(self.omega, tau_i, solid=False) + data = np.zeros(basis.shape[0]) + std = np.ones_like(data) + loss_unsmoothed, _ = _prony_objective( + logcoefs, data, std, basis, smoothness=0.0, solid=False, + ) + loss_smoothed, _ = _prony_objective( + logcoefs, data, std, basis, smoothness=1.0, solid=False, + ) + self.assertGreater(loss_smoothed, loss_unsmoothed) + + +class TestSmoothPronyFit(unittest.TestCase): + def setUp(self): + # Small problem so the fit runs quickly; synthesize from a known Prony series. + df = compute_complex(TAU, VISCOUS_E, num_pts=25) + self.omega = df['Frequency'].values + self.E_stor = df['E Storage'].values + self.E_loss = df['E Loss'].values + # Flat uniform weights — same loss landscape as unweighted least-squares. + self.E_stor_std = np.ones_like(self.E_stor) + self.E_loss_std = np.ones_like(self.E_loss) + + def test_shape_viscous(self): + tau_i, E_i = smooth_prony_fit( + self.omega, self.E_stor, self.E_loss, + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, + N=5, smoothness=1.0, solid=False, + ) + self.assertEqual(tau_i.shape, (5,)) + self.assertEqual(E_i.shape, (5,)) + + def test_shape_solid(self): + tau_i, E_i = smooth_prony_fit( + self.omega, self.E_stor, self.E_loss, + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, + N=5, smoothness=1.0, solid=True, + ) + self.assertEqual(tau_i.shape, (5,)) + self.assertEqual(E_i.shape, (6,)) + + def test_recovers_input_coefficients(self): + # When N matches the source grid size and smoothing is off, the fit + # should recover the input coefficients. + tau_i, E_i = smooth_prony_fit( + self.omega, self.E_stor, self.E_loss, + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, + N=len(TAU), smoothness=0.0, solid=False, + ) + np.testing.assert_allclose(tau_i, TAU) + np.testing.assert_allclose(E_i, VISCOUS_E, rtol=1e-3) + + def test_N_controls_tau_grid_size(self): + tau_i, E_i = smooth_prony_fit( + self.omega, self.E_stor, self.E_loss, + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, + N=8, smoothness=1.0, solid=False, + ) + self.assertEqual(tau_i.shape, (8,)) + self.assertEqual(E_i.shape, (8,)) + + def test_std_arrays_weight_residuals(self): + # Under-parameterized fit (N=1 against a 3-term source) so the model + # cannot match both moduli exactly. Tiny std on the storage side, + # huge on the loss side → the storage residuals dominate the loss, + # so the fit tracks E_stor better than E_loss; reversing the weights + # should swap which side tracks well. + n = len(self.omega) + small = np.full(n, 1e-3) + big = np.full(n, 1e3) + tau_i, E_i = smooth_prony_fit( + self.omega, self.E_stor, self.E_loss, + E_stor_std=small, E_loss_std=big, + N=1, smoothness=0.0, solid=False, + ) + basis = prony_basis(self.omega, tau_i, solid=False) + model = basis @ E_i + stor_err = np.linalg.norm(self.E_stor - model[:n]) + loss_err = np.linalg.norm(self.E_loss - model[n:]) + self.assertLess(stor_err, loss_err) + + tau_i_r, E_i_r = smooth_prony_fit( + self.omega, self.E_stor, self.E_loss, + E_stor_std=big, E_loss_std=small, + N=1, smoothness=0.0, solid=False, + ) + basis_r = prony_basis(self.omega, tau_i_r, solid=False) + model_r = basis_r @ E_i_r + stor_err_r = np.linalg.norm(self.E_stor - model_r[:n]) + loss_err_r = np.linalg.norm(self.E_loss - model_r[n:]) + self.assertGreater(stor_err_r, loss_err_r) + + def test_rejects_non_ndarray_omega(self): + with self.assertRaises(AssertionError): + smooth_prony_fit( + list(self.omega), self.E_stor, self.E_loss, + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, + N=5, smoothness=1.0, solid=False, + ) + + def test_rejects_non_ndarray_E_stor(self): + with self.assertRaises(AssertionError): + smooth_prony_fit( + self.omega, list(self.E_stor), self.E_loss, + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, + N=5, smoothness=1.0, solid=False, + ) + + def test_rejects_non_ndarray_E_loss(self): + with self.assertRaises(AssertionError): + smooth_prony_fit( + self.omega, self.E_stor, list(self.E_loss), + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, + N=5, smoothness=1.0, solid=False, + ) + + def test_rejects_2d_omega(self): + with self.assertRaises(AssertionError): + smooth_prony_fit( + self.omega.reshape(1, -1), self.E_stor, self.E_loss, + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, + N=5, smoothness=1.0, solid=False, + ) + + def test_rejects_2d_E_stor(self): + with self.assertRaises(AssertionError): + smooth_prony_fit( + self.omega, self.E_stor.reshape(1, -1), self.E_loss, + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, + N=5, smoothness=1.0, solid=False, + ) + + def test_rejects_2d_E_loss(self): + with self.assertRaises(AssertionError): + smooth_prony_fit( + self.omega, self.E_stor, self.E_loss.reshape(1, -1), + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std, + N=5, smoothness=1.0, solid=False, + ) + + def test_rejects_length_mismatch(self): + with self.assertRaises(AssertionError): + smooth_prony_fit( + self.omega, self.E_stor[:-1], self.E_loss, + E_stor_std=self.E_stor_std[:-1], E_loss_std=self.E_loss_std, + N=5, smoothness=1.0, solid=False, + ) + + def test_rejects_E_stor_std_wrong_length(self): + with self.assertRaises(AssertionError): + smooth_prony_fit( + self.omega, self.E_stor, self.E_loss, + E_stor_std=self.E_stor_std[:-1], E_loss_std=self.E_loss_std, + N=5, smoothness=1.0, solid=False, + ) + + def test_rejects_E_loss_std_wrong_length(self): + with self.assertRaises(AssertionError): + smooth_prony_fit( + self.omega, self.E_stor, self.E_loss, + E_stor_std=self.E_stor_std, E_loss_std=self.E_loss_std[:-1], + N=5, smoothness=1.0, solid=False, + ) + + def test_handles_data_scale_orders_of_magnitude(self): + # Regression: a constant initial guess (x0 = 7 → E_i ≈ 1097 each) sent + # BFGS' first line search into logcoefs > 700 whenever the data + # magnitude was many orders of magnitude away from ~1e3, and exp() + # overflowed to inf. The data-scaled x0 keeps the initial step inside + # float64 range regardless of dataset scale. + # Use a peak-shaped Prony series over 16 decades of tau — closer to + # real master-curve structure than the narrow TAU/VISCOUS_E fixture, + # which doesn't trip the bug. + tau = np.logspace(-8.0, 8.0, 17) + E_shape = np.exp(-(np.log10(tau)) ** 2 / 4.0) # peaked at tau=1 + for scale in [1e3, 1e6, 1e9, 1e12]: + E_input = np.concatenate(([0.1 * scale], E_shape * scale)) + df = compute_complex(tau, E_input, num_pts=400) + omega = df['Frequency'].to_numpy() + E_stor = df['E Storage'].to_numpy() + E_loss = df['E Loss'].to_numpy() + mag = np.abs(E_stor + 1.0j * E_loss) * 0.2 + for N in [10, 20, 50]: + with self.subTest(scale=scale, N=N): + _, E_i = smooth_prony_fit( + omega, E_stor, E_loss, + E_stor_std=mag, E_loss_std=mag, + N=N, smoothness=0.0, solid=True, + ) + self.assertTrue( + np.all(np.isfinite(E_i)), + msg=f'non-finite E_i at scale={scale:g}, N={N}: {E_i}', + ) + + +class TestArgmaxPeak(unittest.TestCase): + def test_finds_known_peak(self): + # Gaussian centered at x=1.5 on a fine grid → peak index lands on 1.5. + x = np.linspace(-5.0, 5.0, 1001) + signal = np.exp(-(x - 1.5) ** 2) + self.assertAlmostEqual(x[argmax_peak(signal)], 1.5, places=2) + + def test_picks_most_prominent_of_multiple(self): + # Two Gaussians; the taller one should win. + x = np.linspace(-5.0, 5.0, 1001) + signal = np.exp(-(x + 2.0) ** 2) + 2.0 * np.exp(-(x - 2.0) ** 2) + self.assertAlmostEqual(x[argmax_peak(signal)], 2.0, places=2) + + def test_returns_int(self): + x = np.linspace(-5.0, 5.0, 101) + signal = np.exp(-(x - 0.0) ** 2) + self.assertIsInstance(argmax_peak(signal), int) + + def test_raises_when_no_peaks(self): + # Monotonic ramp has no interior peaks. + with self.assertRaises(ValueError): + argmax_peak(np.linspace(0.0, 1.0, 50)) + + def test_rejects_non_ndarray(self): + with self.assertRaises(AssertionError): + argmax_peak([0.0, 1.0, 0.5, 0.0]) + + def test_rejects_2d_ndarray(self): + with self.assertRaises(AssertionError): + argmax_peak(np.zeros((3, 3))) + + +if __name__ == '__main__': + unittest.main() diff --git a/services/tests/dynamfit/test_routes.py b/services/tests/dynamfit/test_routes.py new file mode 100644 index 000000000..592ff3d69 --- /dev/null +++ b/services/tests/dynamfit/test_routes.py @@ -0,0 +1,240 @@ +""" +Fast route-wiring tests for the dynamfit Flask endpoints. + +Everything external is mocked at the route-module boundary (check_file_exists, +upload_init, fit_wlf_coefficients, fit_hybrid_coefficients), so these tests +never touch disk or run the optimizer. They verify request parsing, the +response envelope, parameter pass-through (e.g. fix_C1), and the +validation/short-circuit error codes — NOT the fit math (see +test_coefficient_fits) and NOT real convergence (see test_routes_e2e). + + python -m unittest tests.dynamfit.test_routes +""" +import unittest +import os +os.environ['OPENBLAS_NUM_THREADS'] = '1' +import sys +import json +from unittest.mock import patch + +# Append the directory above 'tests' to sys.path to find the 'app' module +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) + +from app.config import Config + +from ._route_helpers import ( + make_app, + make_token, + SHIFT_DATA, + C1_RETURNED, + C2_RETURNED, + EA_RETURNED, + A_T_REF_RETURNED, +) + + +class TestFitShiftCoefficientsRoute(unittest.TestCase): + """ + Route-level tests for POST /dynamfit/fit-shift/, the dedicated shift-domain + coefficient-fit endpoint (split out of /extract/). + + Strategy: + - Build a bare Flask app (no create_app) to avoid the Docker-only FileHandler. + - Patch Config.SECRET_KEY so token_required decodes our test tokens. + - Patch check_file_exists and upload_init at the route module boundary so + tests never touch disk and don't depend on real file content. + - Patch fit_wlf_coefficients and fit_hybrid_coefficients so tests verify + route wiring, not fit math (fit math is covered in TestFitWlfCoefficients + and TestFitHybridCoefficients in test_coefficient_fits). + """ + + @classmethod + def setUpClass(cls): + Config.SECRET_KEY = 'test-secret' + cls.app = make_app() + cls.client = cls.app.test_client() + cls.token = make_token() + cls.headers = {'Authorization': f'Bearer {cls.token}', + 'Content-Type': 'application/json'} + + def _post(self, body): + return self.client.post( + '/dynamfit/fit-shift/', + data=json.dumps(body), + headers=self.headers, + ) + + def _base_wlf_body(self, **overrides): + body = { + 'shift_file_name': 'shift.txt', + 'transform_method': 'WLF', + 'Tg': 25.0, + } + body.update(overrides) + return body + + def _base_hybrid_body(self, **overrides): + body = { + 'shift_file_name': 'shift.txt', + 'transform_method': 'hybrid', + 'TL': 25.0, + } + body.update(overrides) + return body + + # ------------------------------------------------------------------ + # Happy paths + # ------------------------------------------------------------------ + + @patch('app.dynamfit.routes.fit_wlf_coefficients', + return_value=(C1_RETURNED, C2_RETURNED)) + @patch('app.dynamfit.routes.upload_init', return_value=SHIFT_DATA) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_wlf_happy_path_returns_seven_coefficient_keys( + self, _exists, _upload, _fit): + resp = self._post(self._base_wlf_body()) + self.assertEqual(resp.status_code, 200) + data = json.loads(resp.data) + self.assertEqual( + set(data.keys()), + {'transform_method', 'Tg', 'C1', 'C2', 'Ea', 'TL', 'a_T_ref'}, + ) + + @patch('app.dynamfit.routes.fit_wlf_coefficients', + return_value=(C1_RETURNED, C2_RETURNED)) + @patch('app.dynamfit.routes.upload_init', return_value=SHIFT_DATA) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_wlf_happy_path_a_T_ref_is_one( + self, _exists, _upload, _fit): + # WLF is anchored at T_ref=Tg where a_T == 1 by construction. + resp = self._post(self._base_wlf_body()) + data = json.loads(resp.data) + self.assertEqual(data['a_T_ref'], 1.0) + + @patch('app.dynamfit.routes.fit_wlf_coefficients', + return_value=(C1_RETURNED, C2_RETURNED)) + @patch('app.dynamfit.routes.upload_init', return_value=SHIFT_DATA) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_wlf_happy_path_ea_and_tl_are_null( + self, _exists, _upload, _fit): + resp = self._post(self._base_wlf_body()) + data = json.loads(resp.data) + self.assertIsNone(data['Ea']) + self.assertIsNone(data['TL']) + + @patch('app.dynamfit.routes.fit_wlf_coefficients', + return_value=(C1_RETURNED, C2_RETURNED)) + @patch('app.dynamfit.routes.upload_init', return_value=SHIFT_DATA) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_wlf_happy_path_c1_c2_are_plain_floats( + self, _exists, _upload, _fit): + # Confirms JSON serialization works: numpy scalars would raise TypeError. + resp = self._post(self._base_wlf_body()) + data = json.loads(resp.data) + self.assertIsInstance(data['C1'], float) + self.assertIsInstance(data['C2'], float) + + @patch('app.dynamfit.routes.fit_hybrid_coefficients', + return_value=(C1_RETURNED, C2_RETURNED, EA_RETURNED, A_T_REF_RETURNED)) + @patch('app.dynamfit.routes.upload_init', return_value=SHIFT_DATA) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_hybrid_happy_path_tg_null_ea_tl_populated( + self, _exists, _upload, _fit): + resp = self._post(self._base_hybrid_body()) + self.assertEqual(resp.status_code, 200) + data = json.loads(resp.data) + self.assertIsNone(data['Tg']) + self.assertIsInstance(data['Ea'], float) + self.assertIsInstance(data['TL'], float) + + @patch('app.dynamfit.routes.fit_hybrid_coefficients', + return_value=(C1_RETURNED, C2_RETURNED, EA_RETURNED, A_T_REF_RETURNED)) + @patch('app.dynamfit.routes.upload_init', return_value=SHIFT_DATA) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_hybrid_happy_path_surfaces_co_fit_a_T_ref( + self, _exists, _upload, _fit): + # The 4th element of fit_hybrid_coefficients (the co-fit vertical offset) + # must reach the response as a_T_ref. + resp = self._post(self._base_hybrid_body()) + data = json.loads(resp.data) + self.assertEqual(data['a_T_ref'], A_T_REF_RETURNED) + + @patch('app.dynamfit.routes.fit_wlf_coefficients', + return_value=(C1_RETURNED, C2_RETURNED)) + @patch('app.dynamfit.routes.upload_init', return_value=SHIFT_DATA) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_wlf_supplied_c1_passes_fix_c1_true_to_fit( + self, _exists, _upload, mock_fit): + """When C1 is in the body the route must pass fix_C1=True to the fit function.""" + resp = self._post(self._base_wlf_body(C1=99.0)) + self.assertEqual(resp.status_code, 200) + _, kwargs = mock_fit.call_args + self.assertTrue(kwargs.get('fix_C1')) + + @patch('app.dynamfit.routes.fit_wlf_coefficients', + return_value=(C1_RETURNED, C2_RETURNED)) + @patch('app.dynamfit.routes.upload_init', return_value=SHIFT_DATA) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_wlf_response_lacks_chart_keys( + self, _exists, _upload, _fit): + """The short-circuit branch must NOT include chart output keys.""" + resp = self._post(self._base_wlf_body()) + data = json.loads(resp.data) + for chart_key in ('complex-chart', 'mytable', 'multi', 'response'): + self.assertNotIn(chart_key, data) + + # ------------------------------------------------------------------ + # Validation / short-circuit errors + # ------------------------------------------------------------------ + + def test_missing_shift_file_name_returns_400(self): + body = { + 'transform_method': 'WLF', + 'Tg': 25.0, + # shift_file_name intentionally omitted + } + resp = self._post(body) + self.assertEqual(resp.status_code, 400) + + @patch('app.dynamfit.routes.check_file_exists', return_value=False) + def test_shift_file_not_found_returns_404(self, _exists): + resp = self._post(self._base_wlf_body()) + self.assertEqual(resp.status_code, 404) + + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_invalid_transform_method_returns_400(self, _exists): + body = self._base_wlf_body(transform_method='manual') + resp = self._post(body) + self.assertEqual(resp.status_code, 400) + + @patch('app.dynamfit.routes.upload_init', return_value=SHIFT_DATA) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_wlf_missing_tg_returns_400(self, _exists, _upload): + body = { + 'shift_file_name': 'shift.txt', + 'transform_method': 'WLF', + # Tg intentionally omitted + } + resp = self._post(body) + self.assertEqual(resp.status_code, 400) + + @patch('app.dynamfit.routes.upload_init', return_value=SHIFT_DATA) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_hybrid_missing_tl_returns_400(self, _exists, _upload): + body = { + 'shift_file_name': 'shift.txt', + 'transform_method': 'hybrid', + # TL intentionally omitted + } + resp = self._post(body) + self.assertEqual(resp.status_code, 400) + + @patch('app.dynamfit.routes.upload_init', return_value=None) + @patch('app.dynamfit.routes.check_file_exists', return_value=True) + def test_empty_shift_file_returns_400(self, _exists, _upload): + resp = self._post(self._base_wlf_body()) + self.assertEqual(resp.status_code, 400) + + +if __name__ == '__main__': + unittest.main() diff --git a/services/tests/dynamfit/test_routes_e2e.py b/services/tests/dynamfit/test_routes_e2e.py new file mode 100644 index 000000000..dfd5a4294 --- /dev/null +++ b/services/tests/dynamfit/test_routes_e2e.py @@ -0,0 +1,293 @@ +""" +End-to-end route tests: real fixture files on disk + the real optimizer, with +NO mocking of upload_init, check_file_exists, or the fit functions. These are +the slowest dynamfit tests — they run the actual SciPy optimizer to +convergence and JSON-serialize the full response a client would receive. + +They complement test_routes (which mocks everything and checks wiring) by +verifying that the optimizer converges and reconstructs the data to a +meaningful tolerance, and they guard the 'numpy not JSON serializable' +regression on the real response envelope. + +Config.FILES_DIRECTORY is pointed at the checked-in files/ directory and +restored in tearDownClass. + + python -m unittest tests.dynamfit.test_routes_e2e + +WLF calibration (agilus30 20C): both C1 and C2 fitted freely from defaults. +The data spans T down to -30 °C (50 °C below T_ref=20), which previously +caused a 10**exponent overflow in the optimizer (ValueError → HTTP 400) with +the default UNIVERSAL_WLF_C2=51.6 initial guess. The pole-avoidance fix uses +an analytic log10(a_T) model so overflow can't propagate as an exception, and +enforces C2 >= c2_min throughout. Observed log10-RMSE ≈ 0.437 (threshold: 0.55). + +Hybrid calibration (VeroCyan 80C): all three parameters (C1, C2, Ea) fitted +freely from defaults; a_T_ref (the vertical reference offset) is co-fitted and +must be applied when reconstructing (the VeroCyan data is not referenced to TL, +a_T_ref ≈ 9.07). Observed log10-RMSE ≈ 0.704 (threshold: 1.0). +""" +import unittest +import os +os.environ['OPENBLAS_NUM_THREADS'] = '1' +import sys +import json +import numpy as np + +# Append the directory above 'tests' to sys.path to find the 'app' module +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) + +from app.dynamfit.dynamfit2 import wlf_shift, hybrid_shift +from app.config import Config +from app.utils.util import upload_init + +from ._route_helpers import make_app, make_token, REAL_FILES_DIR + + +class TestFitShiftCoefficientsEndToEnd(unittest.TestCase): + """ + E2E tests for POST /dynamfit/fit-shift/. + + Real files on disk, real optimizer — no mocking of upload_init, check_file_exists, + or the fit functions. Config.FILES_DIRECTORY is pointed at the checked-in + files/ directory and restored in tearDownClass. + """ + + _orig_files_dir = None + + @classmethod + def setUpClass(cls): + cls._orig_files_dir = Config.FILES_DIRECTORY + Config.FILES_DIRECTORY = REAL_FILES_DIR + Config.SECRET_KEY = 'test-secret' + cls.app = make_app() + cls.client = cls.app.test_client() + cls.token = make_token() + cls.headers = { + 'Authorization': f'Bearer {cls.token}', + 'Content-Type': 'application/json', + } + + @classmethod + def tearDownClass(cls): + Config.FILES_DIRECTORY = cls._orig_files_dir + + def _post(self, body): + return self.client.post( + '/dynamfit/fit-shift/', + data=json.dumps(body), + headers=self.headers, + ) + + def test_wlf_e2e_converges_and_fit_is_accurate(self): + """ + WLF fit on agilus30 shift data (T_ref=20 °C), BOTH C1 and C2 free. + + This is the direct regression test for the pole-avoidance fix. Before + the fix, the default UNIVERSAL_WLF_C2=51.6 initial guess produced a + 10**exponent overflow on data spanning T down to -30 °C, causing a + ValueError → HTTP 400. The fix applies an analytic log10(a_T) model + with a C2 lower bound, so the optimizer can start from the universal + defaults and converge. Observed log10-RMSE ≈ 0.437 (threshold: 0.55). + """ + body = { + 'shift_file_name': 'agilus30 (8) shift factors 20C clean.txt', + 'transform_method': 'WLF', + 'Tg': 20.0, + # No C1 or C2 — both are freely fitted from UNIVERSAL_WLF_* defaults + } + resp = self._post(body) + self.assertEqual(resp.status_code, 200) + + result = json.loads(resp.data) + self.assertEqual(result['transform_method'], 'WLF') + self.assertIsNone(result['Ea']) + self.assertIsNone(result['TL']) + self.assertEqual(result['a_T_ref'], 1.0) + + C1 = result['C1'] + C2 = result['C2'] + self.assertTrue(np.isfinite(C1) and C1 > 0, f"Expected finite positive C1, got {C1}") + self.assertTrue(np.isfinite(C2) and C2 > 0, f"Expected finite positive C2, got {C2}") + + # RMSE check: load real data and reconstruct with fitted coefficients. + # Both-free observed RMSE ≈ 0.437; threshold 0.55 allows optimizer + # variance without masking a regression to a grossly wrong solution. + shift_data = upload_init('agilus30 (8) shift factors 20C clean.txt', 'shift') + T = np.asarray(shift_data['Temperature'], dtype=float) + a_T = np.asarray(shift_data['a_T'], dtype=float) + reconstructed = wlf_shift(T, 20.0, C1, C2) + log_rmse = np.sqrt(np.mean((np.log10(reconstructed) - np.log10(a_T)) ** 2)) + self.assertLess(log_rmse, 0.55, + f"WLF log10-RMSE {log_rmse:.4f} exceeds 0.55 — fit likely diverged") + + def test_hybrid_e2e_converges_and_fit_is_accurate(self): + """ + Hybrid Arrhenius/WLF fit on VeroCyan shift data (T_L=80 °C). + + All three parameters (C1, C2, Ea) are fitted freely from default initial + guesses — no body-level C1/C2/Ea, so the optimizer is fully exercised. + The route must return 200 with finite positive C1, C2, and Ea; Tg must + be null; reconstructed shift factors must agree with the data in log10 space. + """ + body = { + 'shift_file_name': 'VeroCyan (5) shift factors 80C clean.txt', + 'transform_method': 'hybrid', + 'TL': 80.0, + # No C1, C2, Ea — all three are freely fitted + } + resp = self._post(body) + self.assertEqual(resp.status_code, 200) + + result = json.loads(resp.data) + self.assertEqual(result['transform_method'], 'hybrid') + self.assertIsNone(result['Tg']) + + C1 = result['C1'] + C2 = result['C2'] + Ea = result['Ea'] + for name, val in [('C1', C1), ('C2', C2), ('Ea', Ea)]: + self.assertTrue(np.isfinite(val) and val > 0, + f"Expected finite positive {name}, got {val}") + + # The route now surfaces the co-fit vertical reference offset; the VeroCyan + # data is not referenced to TL, so a_T_ref must be finite, positive, ≠ 1. + a_T_ref = result['a_T_ref'] + self.assertTrue(np.isfinite(a_T_ref) and a_T_ref > 0, + f"Expected finite positive a_T_ref, got {a_T_ref}") + + # RMSE check: observed RMSE ≈ 0.844; threshold 1.0 is meaningful without + # being so tight that minor optimizer variance causes flakiness. + shift_data = upload_init('VeroCyan (5) shift factors 80C clean.txt', 'shift') + T = np.asarray(shift_data['Temperature'], dtype=float) + a_T = np.asarray(shift_data['a_T'], dtype=float) + # Reconstruct using the surfaced a_T_ref a consumer would apply. + unscaled = hybrid_shift(T, 80.0, C1, C2, Ea) + reconstructed = a_T_ref * unscaled + log_rmse = np.sqrt(np.mean((np.log10(reconstructed) - np.log10(a_T)) ** 2)) + self.assertLess(log_rmse, 1.0, + f"Hybrid log10-RMSE {log_rmse:.4f} exceeds 1.0 — fit likely diverged") + + +class TestExtractRoute(unittest.TestCase): + """ + Route-level tests for POST /dynamfit/extract/ (the Prony fit + charts route). + + Real files on disk + real fit, no mocking, so the full response envelope is + JSON-serialized exactly as a client receives it. This covers what the + pure-function TestUpdateLineChart* classes cannot: request parsing, + validation short-circuits, and JSON serialization of the response — in + particular the regression guard for numpy values reaching stdlib json.dumps. + """ + + _orig_files_dir = None + _FREQ_FILE = 'agilus30 (8) master curve 20C clean.txt' + + @classmethod + def setUpClass(cls): + cls._orig_files_dir = Config.FILES_DIRECTORY + Config.FILES_DIRECTORY = REAL_FILES_DIR + Config.SECRET_KEY = 'test-secret' + cls.app = make_app() + cls.client = cls.app.test_client() + cls.token = make_token() + cls.headers = { + 'Authorization': f'Bearer {cls.token}', + 'Content-Type': 'application/json', + } + + @classmethod + def tearDownClass(cls): + Config.FILES_DIRECTORY = cls._orig_files_dir + + def _post(self, body): + return self.client.post( + '/dynamfit/extract/', + data=json.dumps(body), + headers=self.headers, + ) + + def _freq_body(self, **overrides): + body = {'file_name': self._FREQ_FILE, 'domain': 'frequency', 'number_of_prony': 5} + body.update(overrides) + return body + + # ------------------------------------------------------------------ + # Happy path — full response envelope, fully JSON-serialized + # ------------------------------------------------------------------ + + def test_frequency_happy_path_returns_200(self): + """ + Regression guard for 'Object of type ndarray is not JSON serializable': + a real frequency-domain extract must serialize the whole response + (upload-data + mytable contain numpy values) and return 200, not 500. + """ + resp = self._post(self._freq_body()) + self.assertEqual(resp.status_code, 200, resp.data[:400]) + + def test_frequency_response_has_all_chart_and_coef_keys(self): + resp = self._post(self._freq_body()) + self.assertEqual(resp.status_code, 200, resp.data[:400]) + data = json.loads(resp.data) + self.assertTrue(data['multi']) + response = data['response'] + for key in ('complex-chart', 'complex-tand-chart', 'relaxation-chart', + 'relaxation-spectrum-chart', 'complex-temp-chart', + 'temp-tand-chart', 'mytable', 'upload-data', + 'C1', 'C2', 'Tg', 'Ea', 'TL'): + self.assertIn(key, response) + + def test_upload_data_is_array_of_row_objects(self): + """ + upload-data must be an array of row-objects (one dict per data row), + matching mytable's shape, the contract doc, and the frontend + TableComponent (which derives columns from Object.keys(rows[0])) — not a + dict of columns. + """ + resp = self._post(self._freq_body()) + self.assertEqual(resp.status_code, 200, resp.data[:400]) + upload = json.loads(resp.data)['response']['upload-data'] + self.assertIsInstance(upload, list) + self.assertTrue(upload, 'expected at least one data row') + self.assertEqual(set(upload[0]), {'Frequency', 'E Storage', 'E Loss'}) + self.assertIsInstance(upload[0]['Frequency'], (int, float)) + + def test_mytable_records_have_expected_keys(self): + resp = self._post(self._freq_body()) + self.assertEqual(resp.status_code, 200, resp.data[:400]) + mytable = json.loads(resp.data)['response']['mytable'] + self.assertIsInstance(mytable, list) + self.assertTrue(mytable, 'expected at least one Prony coefficient record') + self.assertEqual(set(mytable[0]), {'i', 'tau_i', 'E_i'}) + + def test_estimated_shift_params_echo_serializes(self): + """ + When Tg/TL are filled by *_estimate they are numpy scalars read out of + the data array; the echoed response must still JSON-serialize and carry + them as plain numbers (regression guard against numpy values in the + echoed C1/C2/Tg/Ea/TL). + """ + resp = self._post(self._freq_body(Tg_estimate=True, TL_estimate=True)) + self.assertEqual(resp.status_code, 200, resp.data[:400]) + response = json.loads(resp.data)['response'] + self.assertIsInstance(response['Tg'], (int, float)) + self.assertIsInstance(response['TL'], (int, float)) + + # ------------------------------------------------------------------ + # Validation short-circuits (do not reach response serialization) + # ------------------------------------------------------------------ + + def test_missing_file_name_returns_400(self): + resp = self._post({'domain': 'frequency'}) + self.assertEqual(resp.status_code, 400) + + def test_file_not_found_returns_404(self): + resp = self._post(self._freq_body(file_name='does-not-exist.txt')) + self.assertEqual(resp.status_code, 404) + + def test_number_of_prony_out_of_range_returns_400(self): + resp = self._post(self._freq_body(number_of_prony=999)) + self.assertEqual(resp.status_code, 400) + + +if __name__ == '__main__': + unittest.main() diff --git a/services/tests/dynamfit/test_shift_factors.py b/services/tests/dynamfit/test_shift_factors.py new file mode 100644 index 000000000..eecd72e3c --- /dev/null +++ b/services/tests/dynamfit/test_shift_factors.py @@ -0,0 +1,463 @@ +""" +Time–temperature superposition shift factors and the TTS frequency/temperature +conversions: WLF, Arrhenius (`_arr_shift`), the piecewise `hybrid_shift`, the +inverse WLF, and the two `tts_*` DataFrame transforms that apply them. + +Pure functions — no Flask app, no disk access. Run while iterating on shift +math or the TTS collapse/scatter logic. + + python -m unittest tests.dynamfit.test_shift_factors +""" +import unittest +import os +os.environ['OPENBLAS_NUM_THREADS'] = '1' +import sys +import numpy as np +import pandas as pd + +# Append the directory above 'tests' to sys.path to find the 'app' module +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) + +from app.dynamfit.dynamfit2 import ( + wlf_shift, + _arr_shift, + hybrid_shift, + inverse_wlf_shift, + tts_temperature_to_frequency_V2, + tts_frequency_to_temperature, +) + + +class TestWlfShift(unittest.TestCase): + def test_identity_at_T_ref(self): + # T == T_ref → a_T == 1.0 exactly. + T = np.array([100.0]) + result = wlf_shift(T, T_ref=100.0, C1=17.44, C2=51.6) + np.testing.assert_array_equal(result, np.array([1.0])) + + def test_known_value(self): + # T - T_ref = C2 → denom = 2*C2, exponent = -C1/2. + # With C1=2 and C2=10 → a_T = 10^-1 = 0.1. + T = np.array([20.0]) + result = wlf_shift(T, T_ref=10.0, C1=2.0, C2=10.0) + np.testing.assert_allclose(result, np.array([0.1])) + + def test_array_in_array_out(self): + T = np.array([100.0, 110.0, 120.0]) + result = wlf_shift(T, T_ref=100.0, C1=17.44, C2=51.6) + self.assertIsInstance(result, np.ndarray) + self.assertEqual(result.shape, (3,)) + + def test_raises_on_singularity(self): + # T - T_ref = -C2 → denom = 0. + T = np.array([0.0]) + with self.assertRaises(ValueError): + wlf_shift(T, T_ref=51.6, C1=17.44, C2=51.6) + + def test_raises_on_array_with_singularity(self): + # One element hits the singularity. + T = np.array([100.0, 0.0, 120.0]) + with self.assertRaises(ValueError): + wlf_shift(T, T_ref=51.6, C1=17.44, C2=51.6) + + def test_accepts_scalar(self): + # Scalar input is promoted to length-1 ndarray output. + result = wlf_shift(100.0, T_ref=100.0, C1=17.44, C2=51.6) + self.assertIsInstance(result, np.ndarray) + self.assertEqual(result.shape, (1,)) + np.testing.assert_array_equal(result, np.array([1.0])) + + def test_rejects_2d_ndarray(self): + with self.assertRaises(AssertionError): + wlf_shift(np.array([[100.0, 110.0]]), T_ref=100.0, C1=17.44, C2=51.6) + + +class TestArrShift(unittest.TestCase): + def test_identity_at_T_ref(self): + # T == T_ref → a_T == 1.0 exactly. + T = np.array([25.0]) + result = _arr_shift(T, T_ref=25.0, Ea=50.0) + np.testing.assert_array_equal(result, np.array([1.0])) + + def test_direction(self): + # With Ea > 0, T > T_ref → a_T < 1. + T = np.array([50.0]) + result = _arr_shift(T, T_ref=25.0, Ea=50.0) + self.assertLess(result[0], 1.0) + + def test_raises_at_absolute_zero(self): + T = np.array([-273.15]) + with self.assertRaises(ValueError): + _arr_shift(T, T_ref=25.0, Ea=50.0) + + +class TestHybridShift(unittest.TestCase): + T_REF = 25.0 + C1 = 17.44 + C2 = 51.6 + EA = 50.0 # kJ/mol + + def test_ascending_mixed(self): + # Crosses T_ref; result preserves ascending order, monotone-decreasing in T. + T = np.array([10.0, 20.0, 30.0, 40.0]) + result = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + self.assertEqual(result.shape, T.shape) + self.assertTrue(np.all(np.diff(result) <= 0)) + + def test_descending_mixed(self): + # Descending T → a_T monotonically increasing along the input. + T = np.array([40.0, 30.0, 20.0, 10.0]) + result = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + self.assertEqual(result.shape, T.shape) + self.assertTrue(np.all(np.diff(result) >= 0)) + + def test_ascending_and_descending_consistent(self): + # The same temperatures in opposite order should produce the same a_T + # values, just reversed. + T_asc = np.array([10.0, 20.0, 30.0, 40.0]) + T_desc = T_asc[::-1] + result_asc = hybrid_shift(T_asc, self.T_REF, self.C1, self.C2, self.EA) + result_desc = hybrid_shift(T_desc, self.T_REF, self.C1, self.C2, self.EA) + np.testing.assert_allclose(result_desc, result_asc[::-1]) + + def test_all_below_T_ref(self): + # All Arrhenius; final element at T == T_ref gives a_T == 1. + T = np.array([10.0, 20.0, 25.0]) + result = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + self.assertEqual(result.shape, T.shape) + np.testing.assert_allclose(result[-1], 1.0) + + def test_all_above_T_ref(self): + # All WLF; with positive C1, a_T < 1 throughout. + T = np.array([30.0, 40.0, 50.0]) + result = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + self.assertEqual(result.shape, T.shape) + self.assertTrue(np.all(result < 1.0)) + + def test_accepts_scalar_below_T_ref(self): + # Scalar at T_ref → Arrhenius bucket → a_T == 1. + result = hybrid_shift(self.T_REF, self.T_REF, self.C1, self.C2, self.EA) + self.assertIsInstance(result, np.ndarray) + self.assertEqual(result.shape, (1,)) + np.testing.assert_allclose(result, np.array([1.0])) + + def test_accepts_scalar_above_T_ref(self): + # Scalar above T_ref → WLF bucket → a_T < 1. + result = hybrid_shift(self.T_REF + 10.0, self.T_REF, self.C1, self.C2, self.EA) + self.assertEqual(result.shape, (1,)) + self.assertLess(result[0], 1.0) + + def test_accepts_single_element_array(self): + T = np.array([self.T_REF + 10.0]) + result = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + self.assertEqual(result.shape, (1,)) + + def test_rejects_2d_ndarray(self): + with self.assertRaises(AssertionError): + hybrid_shift( + np.array([[10.0, 20.0]]), + self.T_REF, self.C1, self.C2, self.EA, + ) + + def test_rejects_non_finite(self): + T = np.array([10.0, np.nan, 30.0]) + with self.assertRaises(AssertionError): + hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + + def test_rejects_unsorted(self): + T = np.array([10.0, 30.0, 20.0, 40.0]) + with self.assertRaises(AssertionError): + hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + + def test_a_T_ref_scales_output(self): + # a_T_ref multiplies every returned shift factor. + T = np.array([10.0, 20.0, 30.0, 40.0]) + base = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + scaled = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA, a_T_ref=5.0) + np.testing.assert_allclose(scaled, 5.0 * base) + + def test_explicit_ascending_matches_autodetect(self): + # Passing ascending explicitly bypasses direction detection but must + # yield the same numbers as letting hybrid_shift detect it. + T = np.array([10.0, 20.0, 30.0, 40.0]) + auto = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + forced = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA, ascending=True) + np.testing.assert_allclose(forced, auto) + + def test_explicit_descending_matches_autodetect(self): + T = np.array([40.0, 30.0, 20.0, 10.0]) + auto = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + forced = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA, ascending=False) + np.testing.assert_allclose(forced, auto) + + +class TestInverseWlfShift(unittest.TestCase): + T_REF = 100.0 + C1 = 17.44 + C2 = 51.6 + + def test_identity_at_a_T_unity(self): + # log10(1) = 0 → T = T_ref exactly. + result = inverse_wlf_shift(np.array([1.0]), self.T_REF, self.C1, self.C2) + np.testing.assert_array_equal(result, np.array([self.T_REF])) + + def test_round_trip_with_wlf_shift(self): + # T → a_T → T should recover the original temperatures. + T = np.array([110.0, 120.0, 150.0, 200.0]) + a_T = wlf_shift(T, self.T_REF, self.C1, self.C2) + T_back = inverse_wlf_shift(a_T, self.T_REF, self.C1, self.C2) + np.testing.assert_allclose(T_back, T) + + def test_accepts_scalar(self): + result = inverse_wlf_shift(1.0, self.T_REF, self.C1, self.C2) + self.assertIsInstance(result, np.ndarray) + self.assertEqual(result.shape, (1,)) + + def test_rejects_2d_ndarray(self): + with self.assertRaises(AssertionError): + inverse_wlf_shift(np.array([[1.0, 0.5]]), self.T_REF, self.C1, self.C2) + + def test_raises_on_nonpositive_a_T(self): + with self.assertRaises(ValueError): + inverse_wlf_shift(np.array([0.0]), self.T_REF, self.C1, self.C2) + with self.assertRaises(ValueError): + inverse_wlf_shift(np.array([-1.0]), self.T_REF, self.C1, self.C2) + + +class TestTtsTemperatureToFrequencyV2(unittest.TestCase): + T_REF = 25.0 + C1 = 17.44 + C2 = 51.6 + EA = 50.0 + MASTER_FREQ = 2.5 + + def _input_df(self, T_values): + # Build a temperature sweep whose hybrid TTS shift collapses every row + # to MASTER_FREQ: per-row Frequency = MASTER_FREQ / a_T(T). + T = np.array(T_values, dtype=float) + a_T = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + n = len(T) + return pd.DataFrame({ + 'Temperature': T, + 'Frequency': self.MASTER_FREQ / a_T, + "E'": np.full(n, 100.0), + "E''": np.full(n, 10.0), + }) + + def _params(self, **overrides): + # T_REF stands in for both Tg (WLF reference) and TL (hybrid crossover); + # the fixture data round-trips for either interpretation. + kwargs = dict(Tg=self.T_REF, TL=self.T_REF, + C1=self.C1, C2=self.C2, Ea=self.EA) + kwargs.update(overrides) + return kwargs + + def test_output_columns(self): + df = self._input_df([25.0, 30.0, 35.0]) + result = tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) + self.assertListEqual(list(result.columns), ['Frequency', 'Temperature', "E'", "E''"]) + + def test_output_length(self): + df = self._input_df([25.0, 30.0, 35.0]) + result = tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) + self.assertEqual(len(result), len(df)) + + def test_temperature_column_is_T_ref(self): + df = self._input_df([25.0, 30.0, 35.0]) + result = tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) + np.testing.assert_array_equal( + result['Temperature'].values, np.full(len(df), self.T_REF), + ) + + def test_hybrid_round_trip(self): + # Data was built via hybrid_shift → 'hybrid' TTS recovers MASTER_FREQ. + df = self._input_df([20.0, 25.0, 30.0, 35.0]) + result = tts_temperature_to_frequency_V2(df, 'hybrid', **self._params()) + np.testing.assert_allclose(result['Frequency'].values, self.MASTER_FREQ) + + def test_WLF_round_trip_above_T_ref(self): + # All T > T_ref: hybrid_shift == wlf_shift, so 'WLF' TTS also recovers + # MASTER_FREQ on data built via hybrid_shift. + df = self._input_df([30.0, 35.0, 40.0]) + result = tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) + np.testing.assert_allclose(result['Frequency'].values, self.MASTER_FREQ) + + def test_identity_single_row_at_T_ref(self): + # Single row at T_ref → a_T == 1 → output Frequency == input Frequency. + df = self._input_df([self.T_REF]) + result_wlf = tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) + result_hybrid = tts_temperature_to_frequency_V2(df, 'hybrid', **self._params()) + np.testing.assert_allclose(result_wlf['Frequency'].values, df['Frequency'].values) + np.testing.assert_allclose(result_hybrid['Frequency'].values, df['Frequency'].values) + + def test_shiftData_override_uses_supplied_a_T(self): + # When shiftData is provided, it multiplies Frequency directly; + # shift_model parameters are ignored. + df = self._input_df([25.0, 30.0, 35.0]) + shiftData = {'a_T': [0.5, 1.5, 2.0]} + result = tts_temperature_to_frequency_V2( + df, 'WLF', **self._params(shiftData=shiftData), + ) + expected = df['Frequency'].values * np.array([0.5, 1.5, 2.0]) + np.testing.assert_allclose( + np.sort(result['Frequency'].values), np.sort(expected), + ) + + def test_E_columns_preserved(self): + df = self._input_df([25.0, 30.0, 35.0]) + result = tts_temperature_to_frequency_V2(df, 'hybrid', **self._params()) + # E' and E'' are constant 100/10 throughout the fixture; they should + # pass through unchanged regardless of row order. + np.testing.assert_array_equal(result["E'"].values, np.full(3, 100.0)) + np.testing.assert_array_equal(result["E''"].values, np.full(3, 10.0)) + + def test_does_not_mutate_input(self): + df = self._input_df([25.0, 30.0, 35.0]) + original_columns = list(df.columns) + tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) + self.assertListEqual(list(df.columns), original_columns) + + def test_rejects_unknown_shift_model(self): + # Unknown shift_model is a server-bug class: the route's allow-list + # must match this function's, so reaching here with 'bogus' is a + # contract violation rather than user error. + df = self._input_df([25.0, 30.0, 35.0]) + with self.assertRaises(AssertionError): + tts_temperature_to_frequency_V2(df, 'bogus', **self._params()) + + def test_manual_with_shiftData_succeeds(self): + # 'manual' selects the shiftData path; WLF/hybrid params are ignored. + df = self._input_df([25.0, 30.0, 35.0]) + shiftData = {'a_T': [0.5, 1.5, 2.0]} + result = tts_temperature_to_frequency_V2( + df, 'manual', shiftData=shiftData, + ) + expected = df['Frequency'].values * np.array([0.5, 1.5, 2.0]) + np.testing.assert_allclose( + np.sort(result['Frequency'].values), np.sort(expected), + ) + + def test_manual_without_shiftData_raises_value_error(self): + # User picks 'manual' in the UI but forgets to upload the shift file. + df = self._input_df([25.0, 30.0, 35.0]) + with self.assertRaisesRegex(ValueError, 'shift-factor file'): + tts_temperature_to_frequency_V2(df, 'manual', **self._params()) + + def test_output_has_reset_index(self): + df = self._input_df([25.0, 30.0, 35.0]) + result = tts_temperature_to_frequency_V2(df, 'WLF', **self._params()) + self.assertListEqual(list(result.index), list(range(len(result)))) + + def test_missing_frequency_column_assumes_one_hz(self): + # Pure temperature sweep input (no Frequency col) → shifted Frequency == a_T. + T = np.array([20.0, 25.0, 30.0]) + df = pd.DataFrame({ + 'Temperature': T, + "E'": np.full(len(T), 100.0), + "E''": np.full(len(T), 10.0), + }) + result = tts_temperature_to_frequency_V2(df, 'hybrid', **self._params()) + expected_a_T = hybrid_shift(T, self.T_REF, self.C1, self.C2, self.EA) + np.testing.assert_allclose( + np.sort(result['Frequency'].values), np.sort(expected_a_T), + ) + + +class TestTtsFrequencyToTemperature(unittest.TestCase): + T_REF = 25.0 + C1 = 17.44 + C2 = 51.6 + OMEGA_REF = 1.0 + + def _input_df(self, T_values): + # Build a freq sweep whose inverse-WLF maps each row back to T in T_values: + # per-row Frequency = OMEGA_REF * a_T(T). + T = np.array(T_values, dtype=float) + a_T = wlf_shift(T, self.T_REF, self.C1, self.C2) + n = len(T) + return pd.DataFrame({ + 'Frequency': self.OMEGA_REF * a_T, + "E'": np.full(n, 100.0), + "E''": np.full(n, 10.0), + }) + + def test_output_columns(self): + df = self._input_df([25.0, 30.0, 35.0]) + result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) + self.assertListEqual(list(result.columns), ['Frequency', 'Temperature', "E'", "E''"]) + + def test_output_length(self): + df = self._input_df([25.0, 30.0, 35.0]) + result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) + self.assertEqual(len(result), len(df)) + + def test_frequency_column_is_omega_ref(self): + df = self._input_df([25.0, 30.0, 35.0]) + result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) + np.testing.assert_array_equal( + result['Frequency'].values, np.full(len(df), self.OMEGA_REF), + ) + + def test_round_trip_temperatures(self): + T_input = [27.0, 30.0, 35.0] + df = self._input_df(T_input) + result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) + np.testing.assert_allclose(result['Temperature'].values, sorted(T_input)) + + def test_identity_at_omega_ref(self): + # Frequency == omega_ref means a_T = 1, so inverse WLF gives T = T_ref. + df = pd.DataFrame({ + 'Frequency': [self.OMEGA_REF], + "E'": [100.0], + "E''": [10.0], + }) + result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) + np.testing.assert_allclose(result['Temperature'].values, [self.T_REF]) + + def test_E_columns_preserved(self): + df = self._input_df([25.0, 30.0, 35.0]) + result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) + np.testing.assert_array_equal(result["E'"].values, np.full(3, 100.0)) + np.testing.assert_array_equal(result["E''"].values, np.full(3, 10.0)) + + def test_does_not_mutate_input(self): + df = self._input_df([25.0, 30.0, 35.0]) + original_columns = list(df.columns) + original_freq = df['Frequency'].to_numpy().copy() + tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) + self.assertListEqual(list(df.columns), original_columns) + np.testing.assert_array_equal(df['Frequency'].values, original_freq) + + def test_output_has_reset_index(self): + df = self._input_df([25.0, 30.0, 35.0]) + result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) + self.assertListEqual(list(result.index), list(range(len(result)))) + + def test_output_sorted_by_temperature(self): + df = self._input_df([35.0, 27.0, 30.0]) # unsorted + result = tts_frequency_to_temperature(df, self.OMEGA_REF, self.T_REF, self.C1, self.C2) + T = result['Temperature'].values + self.assertTrue(np.all(np.diff(T) >= 0)) + + def test_round_trip_with_V2(self): + # Scatter a master curve across temperatures with freq_to_temp, then + # collapse back with V2. Frequencies must match the original master. + master_freqs = np.array([0.1, 1.0, 10.0]) + df_master = pd.DataFrame({ + 'Frequency': master_freqs, + "E'": [100.0, 200.0, 300.0], + "E''": [10.0, 20.0, 30.0], + }) + scattered = tts_frequency_to_temperature( + df_master, self.OMEGA_REF, self.T_REF, self.C1, self.C2, + ) + recovered = tts_temperature_to_frequency_V2( + scattered, 'WLF', Tg=self.T_REF, C1=self.C1, C2=self.C2, + ) + np.testing.assert_allclose( + np.sort(recovered['Frequency'].values), np.sort(master_freqs), + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/services/tests/dynamfit/test_update_line_chart.py b/services/tests/dynamfit/test_update_line_chart.py new file mode 100644 index 000000000..614a64b99 --- /dev/null +++ b/services/tests/dynamfit/test_update_line_chart.py @@ -0,0 +1,415 @@ +""" +`update_line_chart` — the orchestration layer that turns uploaded data into the +chart figures + coefficient table. Covers the frequency and temperature +domains, the validation / contract-assertion paths, and the std error-column +wiring into smooth_prony_fit. + +Some tests load real fixture files from app/dynamfit/files/ (via upload_init), +so this subset touches disk and is slower than the pure-math suites. It does +NOT spin up a Flask app — that's test_routes / test_routes_e2e. + + python -m unittest tests.dynamfit.test_update_line_chart +""" +import unittest +import os +os.environ['OPENBLAS_NUM_THREADS'] = '1' +import sys +import numpy as np +from unittest.mock import patch + +# Append the directory above 'tests' to sys.path to find the 'app' module +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) + +from app.dynamfit.dynamfit2 import wlf_shift, update_line_chart +from app.config import Config +from app.utils.util import upload_init + + +DATA_DIR = os.path.abspath(os.path.join( + os.path.dirname(__file__), '..', '..', 'app', 'dynamfit', 'files', +)) + + +class TestUpdateLineChartFrequency(unittest.TestCase): + """Characterization tests for the frequency-domain branch of update_line_chart.""" + + @classmethod + def setUpClass(cls): + Config.FILES_DIRECTORY = DATA_DIR + cls.uploadData = upload_init( + 'agilus30 (8) master curve 20C clean.txt', 'frequency', + ) + cls.N = 10 + cls.result = update_line_chart( + cls.uploadData, + number_of_prony=cls.N, + smoothness=0.1, + fit_settings=True, + domain='frequency', + ) + + def test_returns_7_tuple(self): + self.assertEqual(len(self.result), 7) + + def test_coef_df_schema(self): + coef_df = self.result[6] + self.assertIsInstance(coef_df, list) + self.assertGreater(len(coef_df), 0) + for row in coef_df: + self.assertSetEqual(set(row.keys()), {'i', 'tau_i', 'E_i'}) + self.assertNotEqual(row['E_i'], 0.0) + # 'i' values are the original DataFrame indices, all nonnegative + self.assertTrue(all(row['i'] >= 0 for row in coef_df)) + + def test_fig1_trace_names(self): + fig1 = self.result[0] + names = [t.name for t in fig1.data] + # Two facets (E Storage, E Loss) × two colors (Experiment + N-Term Prony) + self.assertEqual(names.count('Experiment'), 2) + self.assertEqual(sum(1 for n in names if 'Term Prony' in n), 2) + + def test_fig1_experiment_y_matches_input(self): + # px.line(facet_col='Modulus') splits by Modulus, so the two Experiment + # traces carry the E Storage and E Loss columns from the input. + fig1 = self.result[0] + experiment_y = sorted( + (tuple(t.y) for t in fig1.data if t.name == 'Experiment'), + key=lambda ys: ys[0], + ) + expected = sorted( + (tuple(self.uploadData['E Loss']), tuple(self.uploadData['E Storage'])), + key=lambda ys: ys[0], + ) + for got, want in zip(experiment_y, expected): + np.testing.assert_array_equal(got, want) + + def test_fig2_fig3_trace_counts_with_fit_settings_true(self): + # fit_settings=True → fig2/fig3 are overlay figs (line + basis scatter). + fig2, fig3 = self.result[2], self.result[3] + self.assertEqual(len(fig2.data), 2) + self.assertEqual(len(fig3.data), 2) + names2 = {t.name for t in fig2.data} + names3 = {t.name for t in fig3.data} + self.assertTrue(any('Basis' in n for n in names2)) + self.assertTrue(any('Basis' in n for n in names3)) + + def test_fig4_fig41_have_only_experiment_traces(self): + # In frequency domain, fig4/fig41 visualize the inverse-WLF temperature + # conversion of the input — no Prony fit is overlaid there. + fig4, fig41 = self.result[4], self.result[5] + for fig in (fig4, fig41): + names = {t.name for t in fig.data} + self.assertEqual(names, {'Experiment'}) + + def test_fit_settings_false_drops_basis_overlay(self): + result = update_line_chart( + self.uploadData, number_of_prony=self.N, smoothness=0.1, + fit_settings=False, domain='frequency', + ) + fig2, fig3 = result[2], result[3] + self.assertEqual(len(fig2.data), 1) + self.assertEqual(len(fig3.data), 1) + self.assertNotIn('Basis', {t.name for t in fig2.data}) + self.assertNotIn('Basis', {t.name for t in fig3.data}) + + +class TestUpdateLineChartTemperature(unittest.TestCase): + """Characterization tests for the temperature-domain branches.""" + + T_REF = 25.0 + C1 = 17.44 + C2 = 51.6 + EA = 200.0 + + @classmethod + def setUpClass(cls): + Config.FILES_DIRECTORY = DATA_DIR + # Real temperature ramp for the early-exit path. + cls.real_temp_data = upload_init( + 'agilus30 (8) Temperature Ramp clean.txt', 'temperature', + ) + # Synthetic temperature ramp chosen so WLF and hybrid shifts both stay + # well-defined (T spans both sides of T_ref, well clear of the + # WLF C2 singularity at T_ref - C2). + T = np.linspace(0.0, 80.0, 30) + cls.synthetic_temp_data = { + 'Temperature': T, + 'E Storage': np.linspace(1000.0, 10.0, len(T)), + 'E Loss': np.full(len(T), 50.0), + } + + def test_early_exit_with_no_shift_params(self): + fig1, fig11, fig2, fig3, fig4, fig41, coef_df = update_line_chart( + self.real_temp_data, number_of_prony=10, smoothness=0.1, + fit_settings=True, domain='temperature', + ) + for empty in (fig1, fig11, fig2, fig3): + self.assertEqual(len(empty.data), 0) + self.assertEqual(coef_df, []) + self.assertGreater(len(fig4.data), 0) + self.assertGreater(len(fig41.data), 0) + + def test_WLF_shift_populates_all_figures(self): + result = update_line_chart( + self.synthetic_temp_data, number_of_prony=8, smoothness=0.1, + fit_settings=True, domain='temperature', + Tg=self.T_REF, C1=self.C1, C2=self.C2, shift_model='WLF', + ) + fig1, fig11, fig2, fig3, fig4, fig41, coef_df = result + for fig in (fig1, fig11, fig2, fig3, fig4, fig41): + self.assertGreater(len(fig.data), 0) + self.assertIsInstance(coef_df, list) + self.assertGreater(len(coef_df), 0) + + def test_hybrid_shift_populates_all_figures(self): + result = update_line_chart( + self.synthetic_temp_data, number_of_prony=8, smoothness=0.1, + fit_settings=True, domain='temperature', + Tg=self.T_REF, TL=self.T_REF, C1=self.C1, C2=self.C2, + Ea=self.EA, shift_model='hybrid', + ) + fig1, fig11, fig2, fig3, fig4, fig41, coef_df = result + for fig in (fig1, fig11, fig2, fig3, fig4, fig41): + self.assertGreater(len(fig.data), 0) + self.assertGreater(len(coef_df), 0) + + def test_hybrid_works_without_Tg(self): + # hybrid_shift uses TL (not Tg) as the WLF/Arrhenius crossover, so + # update_line_chart should drive the master-curve path even when Tg is + # not supplied. + result = update_line_chart( + self.synthetic_temp_data, number_of_prony=8, smoothness=0.1, + fit_settings=True, domain='temperature', + TL=self.T_REF, C1=self.C1, C2=self.C2, Ea=self.EA, + shift_model='hybrid', + ) + fig1, fig11, fig2, fig3, fig4, fig41, coef_df = result + for fig in (fig1, fig11, fig2, fig3, fig4, fig41): + self.assertGreater(len(fig.data), 0) + self.assertGreater(len(coef_df), 0) + + def test_Tg_zero_does_not_falsely_trigger_early_exit(self): + # Regression: the old `Tg and C1 and C2` truthy check treated Tg = 0 °C + # as missing and dropped users into the empty-figures branch. + result = update_line_chart( + self.synthetic_temp_data, number_of_prony=8, smoothness=0.1, + fit_settings=True, domain='temperature', + Tg=0.0, C1=self.C1, C2=self.C2, shift_model='WLF', + ) + fig1, fig11, fig2, fig3, fig4, fig41, coef_df = result + for fig in (fig1, fig11, fig2, fig3, fig4, fig41): + self.assertGreater(len(fig.data), 0) + self.assertGreater(len(coef_df), 0) + + def test_shiftData_override_triggers_shift_path(self): + # Even without Tg/C1/C2/shift_model, supplying shiftData should drive + # the shift branch (b is true via the shiftData term). + n = len(self.synthetic_temp_data['Temperature']) + T = self.synthetic_temp_data['Temperature'] + a_T = wlf_shift(T, self.T_REF, self.C1, self.C2) + shiftData = {'Temperature': T, 'a_T': a_T} + result = update_line_chart( + self.synthetic_temp_data, number_of_prony=8, smoothness=0.1, + fit_settings=True, domain='temperature', + shiftData=shiftData, + ) + fig1, fig11, fig2, fig3, fig4, fig41, coef_df = result + for fig in (fig1, fig11, fig2, fig3, fig4, fig41): + self.assertGreater(len(fig.data), 0) + self.assertGreater(len(coef_df), 0) + + +class TestUpdateLineChartValidation(unittest.TestCase): + """ + Validation paths in update_line_chart. + + uploadData is contractually the output of upload_init() — a dict with + canonical keys for the chosen domain mapped to 1-D float ndarrays. Tests + here exercise the user-fixable validation paths (ValueError → HTTP 400 + via the Flask route) and the contract assertions (AssertionError → HTTP + 500 — only reachable via a server bug). + """ + + @staticmethod + def _freq(f, s, l): + return {'Frequency': np.asarray(f, float), + 'E Storage': np.asarray(s, float), + 'E Loss': np.asarray(l, float)} + + @staticmethod + def _temp(T, s, l): + return {'Temperature': np.asarray(T, float), + 'E Storage': np.asarray(s, float), + 'E Loss': np.asarray(l, float)} + + def _call(self, data, **kw): + return update_line_chart( + data, number_of_prony=5, smoothness=0.1, fit_settings=True, **kw, + ) + + def test_empty_data_raises_value_error(self): + with self.assertRaisesRegex(ValueError, 'no data rows'): + self._call(self._freq([], [], []), domain='frequency') + + def test_nan_in_data_raises_value_error(self): + bad = self._freq([1.0, 2.0, 3.0], [100.0, np.nan, 300.0], [10.0, 20.0, 30.0]) + with self.assertRaisesRegex(ValueError, 'non-finite'): + self._call(bad, domain='frequency') + + def test_inf_in_data_raises_value_error(self): + bad = self._freq([1.0, 2.0, 3.0], [100.0, 200.0, 300.0], [10.0, np.inf, 30.0]) + with self.assertRaisesRegex(ValueError, 'non-finite'): + self._call(bad, domain='frequency') + + def test_zero_frequency_raises_value_error(self): + bad = self._freq([0.0, 1.0, 10.0], [100.0, 200.0, 300.0], [10.0, 20.0, 30.0]) + with self.assertRaisesRegex(ValueError, 'Frequency.*positive'): + self._call(bad, domain='frequency') + + def test_negative_frequency_raises_value_error(self): + bad = self._freq([-1.0, 1.0, 10.0], [100.0, 200.0, 300.0], [10.0, 20.0, 30.0]) + with self.assertRaisesRegex(ValueError, 'Frequency.*positive'): + self._call(bad, domain='frequency') + + def test_wrong_uploadData_keys_raises_assertion(self): + # Server-bug class: uploadData doesn't match upload_init's contract. + bad = {'Frequency': np.array([1.0, 2.0, 3.0])} + with self.assertRaises(AssertionError): + self._call(bad, domain='frequency') + + def test_unknown_domain_raises_assertion(self): + # Server-bug class: frontend only ever sends 'frequency' or 'temperature'. + ok = self._freq([1.0, 2.0, 3.0], [100.0, 200.0, 300.0], [10.0, 20.0, 30.0]) + with self.assertRaises(AssertionError): + self._call(ok, domain='bogus') + + def test_shiftdata_length_mismatch_raises_value_error(self): + temp = self._temp([0.0, 25.0, 50.0], [100.0, 200.0, 300.0], [10.0, 20.0, 30.0]) + bad_shift = {'Temperature': [0.0, 25.0], 'a_T': [1.0, 1.0]} + with self.assertRaisesRegex(ValueError, 'same number of rows'): + self._call(temp, domain='temperature', shiftData=bad_shift) + + def test_shiftdata_missing_a_T_raises_assertion(self): + # Server-bug class: upload_init(..., 'shift') always produces an 'a_T' key, + # so a missing one means someone constructed shiftData by hand. + temp = self._temp([0.0, 25.0, 50.0], [100.0, 200.0, 300.0], [10.0, 20.0, 30.0]) + bad_shift = {'Temperature': [0.0, 25.0, 50.0], 'wrong_key': [1.0, 1.0, 1.0]} + with self.assertRaises(AssertionError): + self._call(temp, domain='temperature', shiftData=bad_shift) + + +class TestUpdateLineChartErrorColumns(unittest.TestCase): + """update_line_chart should pass E_stor_std/E_loss_std from the uploadData + error columns when present, else fall back to relative_error*|E*|.""" + + @staticmethod + def _freq_data(extras=None): + d = { + 'Frequency': np.array([1.0, 2.0, 3.0]), + 'E Storage': np.array([100.0, 200.0, 300.0]), + 'E Loss': np.array([10.0, 20.0, 30.0]), + } + if extras: + d.update(extras) + return d + + @staticmethod + def _temp_data(extras=None): + d = { + 'Temperature': np.array([0.0, 25.0, 50.0]), + 'E Storage': np.array([100.0, 200.0, 300.0]), + 'E Loss': np.array([10.0, 20.0, 30.0]), + } + if extras: + d.update(extras) + return d + + def _spy(self): + # Return a minimal valid fit result so downstream figure builders run. + spy_target = patch( + 'app.dynamfit.dynamfit2.smooth_prony_fit', + return_value=(np.array([1.0]), np.array([1.0])), + ) + return spy_target + + def test_per_modulus_error_columns_flow_into_smooth_prony_fit(self): + stor_err = np.array([5.0, 10.0, 15.0]) + loss_err = np.array([0.5, 1.0, 1.5]) + data = self._freq_data({ + 'E Storage Error': stor_err, + 'E Loss Error': loss_err, + }) + with self._spy() as spy: + update_line_chart( + data, number_of_prony=5, smoothness=0.1, + fit_settings=False, domain='frequency', + ) + kwargs = spy.call_args.kwargs + np.testing.assert_array_equal(kwargs['E_stor_std'], stor_err) + np.testing.assert_array_equal(kwargs['E_loss_std'], loss_err) + + def test_shared_error_column_flows_into_both_std_kwargs(self): + err = np.array([1.0, 2.0, 3.0]) + data = self._freq_data({'Error': err}) + with self._spy() as spy: + update_line_chart( + data, number_of_prony=5, smoothness=0.1, + fit_settings=False, domain='frequency', + ) + kwargs = spy.call_args.kwargs + np.testing.assert_array_equal(kwargs['E_stor_std'], err) + np.testing.assert_array_equal(kwargs['E_loss_std'], err) + + def test_no_error_columns_falls_back_to_default_relative_error(self): + data = self._freq_data() + with self._spy() as spy: + update_line_chart( + data, number_of_prony=5, smoothness=0.1, + fit_settings=False, domain='frequency', + ) + kwargs = spy.call_args.kwargs + expected = np.abs(data['E Storage'] + 1.0j * data['E Loss']) * 0.2 + np.testing.assert_allclose(kwargs['E_stor_std'], expected) + np.testing.assert_allclose(kwargs['E_loss_std'], expected) + + def test_relative_error_kwarg_scales_fallback(self): + data = self._freq_data() + with self._spy() as spy: + update_line_chart( + data, number_of_prony=5, smoothness=0.1, + fit_settings=False, domain='frequency', + relative_error=0.5, + ) + kwargs = spy.call_args.kwargs + expected = np.abs(data['E Storage'] + 1.0j * data['E Loss']) * 0.5 + np.testing.assert_allclose(kwargs['E_stor_std'], expected) + np.testing.assert_allclose(kwargs['E_loss_std'], expected) + + def test_temperature_per_modulus_error_columns_flow_through_tts(self): + # The temperature branch routes data through tts_temperature_to_frequency_V2, + # which reorders rows by post-shift Frequency. The per-row error values + # must ride along that reorder so smooth_prony_fit sees them aligned. + stor_err = np.array([5.0, 10.0, 15.0]) + loss_err = np.array([0.5, 1.0, 1.5]) + data = self._temp_data({ + 'E Storage Error': stor_err, + 'E Loss Error': loss_err, + }) + with self._spy() as spy: + update_line_chart( + data, number_of_prony=5, smoothness=0.1, + fit_settings=False, domain='temperature', + Tg=25.0, C1=17.44, C2=51.6, shift_model='WLF', + ) + kwargs = spy.call_args.kwargs + # Each (storage, loss) error pair must still be co-located with its + # source row after the frequency-sort reorder. The set of pairs is + # therefore invariant under TTS even though the order changes. + pairs_out = set(zip(kwargs['E_stor_std'].tolist(), + kwargs['E_loss_std'].tolist())) + pairs_in = set(zip(stor_err.tolist(), loss_err.tolist())) + self.assertEqual(pairs_out, pairs_in) + + +if __name__ == '__main__': + unittest.main() diff --git a/services/tests/utils/test_db_utils.py b/services/tests/utils/test_db_utils.py index 2a6ef05d5..819da6090 100644 --- a/services/tests/utils/test_db_utils.py +++ b/services/tests/utils/test_db_utils.py @@ -13,7 +13,12 @@ def setUp(self): self.payload = {'key': 'value'} self.jwt_token = 'mock_jwt' self.request_id = 'mock_request_id' - self.url = 'http://localhost/api/mn/db/test_collection' + # Derive the expected URL from the same Config.API_SERVICES that + # db_utils.baseURL is built from, so the assertion holds regardless of + # the API_URL env (docker proxy vs. the restful:3001 default) instead of + # pinning one environment's value. + from app.config import Config + self.url = f'{Config.API_SERVICES}/mn/db/{self.collection}' self.headers = { 'Authorization': f'Bearer {self.jwt_token}', 'Content-Type': 'application/json' @@ -30,7 +35,7 @@ def tearDown(self): # Popping the Flask app context after each test self.ctx.pop() - @patch('app.utils.util.generate_jwt', return_value=('mock_jwt', 'mock_request_id')) + @patch('app.utils.db_utils.generate_jwt', return_value=('mock_jwt', 'mock_request_id')) @patch('requests.post') def test_send_post_request_success_matching_id(self, mock_post, mock_generate_jwt): mock_response = Mock() @@ -45,7 +50,7 @@ def test_send_post_request_success_matching_id(self, mock_post, mock_generate_jw mock_post.assert_called_once_with(self.url, json=self.data, headers=self.headers) self.assertEqual(response, {'data': 'success'}) - @patch('app.utils.util.generate_jwt', return_value=('mock_jwt', 'mock_request_id')) + @patch('app.utils.db_utils.generate_jwt', return_value=('mock_jwt', 'mock_request_id')) @patch('requests.post') def test_send_post_request_failure_mismatch_id(self, mock_post, mock_generate_jwt): mock_response = Mock() From 18bfcc1221a4a92e265835affc3df6a985d179d8 Mon Sep 17 00:00:00 2001 From: tholulomo Date: Tue, 16 Jun 2026 02:57:53 -0400 Subject: [PATCH 09/18] fix(Dynamfit): Adding dynamfit-separator, dynamfit-toggle and moved a.disabled from component inline style --- app/src/assets/css/modules/_button.scss | 5 ++ app/src/assets/css/modules/_tools.scss | 87 +++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/app/src/assets/css/modules/_button.scss b/app/src/assets/css/modules/_button.scss index 3c93ef423..1c6017d23 100644 --- a/app/src/assets/css/modules/_button.scss +++ b/app/src/assets/css/modules/_button.scss @@ -185,3 +185,8 @@ a.btn-text, color: #fff !important; } } + +a.disabled { + pointer-events: none; + cursor: default; +} diff --git a/app/src/assets/css/modules/_tools.scss b/app/src/assets/css/modules/_tools.scss index 2b739090f..fa15958a5 100644 --- a/app/src/assets/css/modules/_tools.scss +++ b/app/src/assets/css/modules/_tools.scss @@ -385,4 +385,91 @@ min-height: 20vh; } } + + &-separator { + border: none; + border-top: 1px solid $secondary-grey; + margin: 2.4rem 0 1.2rem; + } + + &-toggle { + display: inline-flex; + border: 1px solid $primary; + border-radius: 3px; + overflow: hidden; + + @include respond(phone) { + display: flex; + width: 100%; + } + + &__option { + padding: 0.8rem 2.4rem; + font-size: $medium-size; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + color: $primary; + background-color: $primary-white; + border: none; + outline: none; + + @include respond(phone) { + flex: 1; + text-align: center; + } + + &--active { + background-color: $primary; + color: $primary-white; + } + + &:not(:last-child) { + border-right: 1px solid $primary; + } + } + } + + &-readonly { + display: flex; + align-items: center; + padding: 0.4rem 0.8rem; + background-color: rgba($secondary-light, 0.3); + border-left: 3px solid $secondary; + border-radius: 0 3px 3px 0; + font-size: $small-size; + + &__label { + font-weight: 700; + color: $primary; + margin-right: 0.4rem; + } + + &__value { + color: $primary-grey; + } + } + + &-field--half { + max-width: 40% !important; + + @include respond(phone) { + max-width: 100% !important; + } + } + + &-shift-upload { + border: 2px dashed $secondary-grey; + border-radius: 3px; + padding: 1.2rem; + margin-bottom: 1.5rem; + text-align: center; + + &__label { + font-size: $small-size; + font-weight: 500; + color: $primary; + margin-bottom: 0.6rem; + } + } } From 52f6e8c8b827f08ed952fb88f1bcefff5cea1911 Mon Sep 17 00:00:00 2001 From: tholulomo Date: Tue, 16 Jun 2026 03:00:37 -0400 Subject: [PATCH 10/18] feat(Dynamfit): Major refactor to chartsettings and chart visualizer cleanup --- .../explorer/dynamfit/ChartSetting.vue | 987 +++++++++++------- .../explorer/dynamfit/ChartVisualizer.vue | 101 +- 2 files changed, 591 insertions(+), 497 deletions(-) diff --git a/app/src/components/explorer/dynamfit/ChartSetting.vue b/app/src/components/explorer/dynamfit/ChartSetting.vue index 362a27696..363c5f917 100644 --- a/app/src/components/explorer/dynamfit/ChartSetting.vue +++ b/app/src/components/explorer/dynamfit/ChartSetting.vue @@ -8,10 +8,9 @@ Click reset below to clear all your selections and begin again. - +
-
- -
-