pyphyschemtools 0.3.2__py3-none-any.whl → 0.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,230 @@
1
+ ############################################################
2
+ # easy_rdkit
3
+ ############################################################
4
+ from .visualID_Eng import fg, bg, hl
5
+ from .core import centerTitle, centertxt
6
+
7
+ import rdkit
8
+ from rdkit import Chem
9
+ from rdkit.Chem import AllChem, GetPeriodicTable, Draw, rdCoordGen
10
+ import pandas as pd
11
+ from rdkit.Chem.Draw import rdMolDraw2D
12
+ from IPython.display import SVG
13
+ from PIL import Image
14
+
15
+ class easy_rdkit():
16
+ """
17
+ A helper class to analyze and visualize molecules using RDKit.
18
+ Provides tools for Lewis structure analysis and advanced 2D drawing.
19
+ """
20
+
21
+ def __init__(self,smiles, canonical=True):
22
+ """
23
+ Initialize the molecule object from a SMILES string.
24
+
25
+ Args:
26
+ smiles (str): The SMILES representation of the molecule.
27
+ canonical (bool): If True, converts the SMILES to its canonical form
28
+ to ensure consistent atom numbering and uniqueness.
29
+ """
30
+ from rdkit import Chem
31
+
32
+ mol = Chem.MolFromSmiles(smiles)
33
+ if mol is None:
34
+ raise ValueError(f"Invalid SMILES string: {smiles}")
35
+
36
+ if canonical:
37
+ # Generate canonical isomeric SMILES
38
+ self.smiles = Chem.MolToSmiles(mol, isomericSmiles=True, canonical=True)
39
+ # Re-load the molecule from the canonical SMILES to sync atom indices
40
+ self.mol = Chem.MolFromSmiles(self.smiles)
41
+ else:
42
+ self.mol=mol
43
+ self.smiles = smiles
44
+
45
+ def analyze_lewis(self):
46
+ """
47
+ Performs a Lewis structure analysis for each atom in the molecule.
48
+ Calculates valence electrons, lone pairs, formal charges, and octet rule compliance.
49
+
50
+ Returns:
51
+ pd.DataFrame: A table containing detailed Lewis electronic data per atom.
52
+ """
53
+ if self.mol is None:
54
+ raise ValueError(f"Molécule invalide pour {self.smiles} (SMILES incorrect ?)")
55
+
56
+ pt = GetPeriodicTable()
57
+ rows = []
58
+
59
+ for atom in self.mol.GetAtoms():
60
+ Z = atom.GetAtomicNum()
61
+ valence_e = pt.GetNOuterElecs(Z)
62
+ bonding_e = atom.GetTotalValence()
63
+ formal_charge = atom.GetFormalCharge()
64
+ num_bonds = int(sum(bond.GetBondTypeAsDouble() for bond in atom.GetBonds()))
65
+ # hybridization = atom.GetHybridization()
66
+ nonbonding = valence_e - bonding_e - formal_charge
67
+
68
+ lone_pairs = max(0, nonbonding // 2)
69
+
70
+ if Z==1 or Z==2: # règle du duet
71
+ target = 2
72
+ else: # règle de l’octet
73
+ target = 8
74
+
75
+ missing_e = max(0, target/2 - (bonding_e + 2*lone_pairs))
76
+ vacancies = int(missing_e)
77
+ total_e = 2*(lone_pairs + bonding_e)
78
+
79
+ if total_e > 8:
80
+ octet_msg = "❌ hypercoordiné"
81
+ elif total_e < 8 and Z > 2:
82
+ octet_msg = "❌ électron-déficient"
83
+ elif total_e == 8:
84
+ octet_msg = "✅ octet"
85
+ elif total_e == 2 and (Z == 1 or Z == 2):
86
+ octet_msg = "✅ duet"
87
+ else:
88
+ octet_msg = "🤔"
89
+ rows.append({
90
+ "index atome": atom.GetIdx(),
91
+ "symbole": atom.GetSymbol(),
92
+ "e- valence": valence_e,
93
+ "e- liants": bonding_e,
94
+ "charge formelle": formal_charge,
95
+ "doublets non-liants (DNL)": lone_pairs,
96
+ "lacunes ([])": vacancies,
97
+ "nombre de liaisons": num_bonds,
98
+ "e- total (octet ?)": total_e,
99
+ "O/H/D ?": octet_msg
100
+ })
101
+ return pd.DataFrame(rows)
102
+
103
+ def show_mol(self,
104
+ size: tuple=(400,400),
105
+ show_Lewis: bool=False,
106
+ plot_conjugation: bool=False,
107
+ plot_aromatic: bool=False,
108
+ show_n: bool=False,
109
+ show_hybrid: bool=False,
110
+ show_H: bool=False,
111
+ rep3D: bool=False,
112
+ macrocycle: bool=False,
113
+ highlightAtoms: list=[],
114
+ legend: str=''
115
+ ):
116
+ """
117
+ Renders the molecule in 2D SVG format with optional property overlays.
118
+
119
+ Args:
120
+ size (tuple): Drawing dimensions in pixels.
121
+ show_Lewis (bool): Annotates atoms with Lone Pairs and Vacancies.
122
+ plot_conjugation (bool): Highlights conjugated bonds in blue.
123
+ plot_aromatic (bool): Highlights aromatic rings in red.
124
+ show_n (bool): Displays atom indices.
125
+ show_hybrid (bool): Displays atom hybridization (sp3, sp2, etc.).
126
+ show_H (bool): Adds explicit Hydrogens to the drawing.
127
+ rep3D (bool): Computes a 3D-like conformation before drawing.
128
+ macrocycle (bool): Uses CoordGen for better rendering of large rings (e.g., Cyclodextrins).
129
+ highlightAtoms (list): List of indices to highlight.
130
+ legend (str): Title or legend text for the drawing.
131
+ """
132
+
133
+ def safe_add_hs():
134
+ try:
135
+ return Chem.AddHs(self.mol)
136
+ except Exception as e:
137
+ print(f"[Warning] Impossible d'ajouter les H pour {self.smiles} ({e}), on garde la version brute.")
138
+ return mol
139
+
140
+ if show_H and not show_Lewis:
141
+ mol = Chem.AddHs(self.mol)
142
+ else:
143
+ mol = self.mol
144
+ if show_Lewis:
145
+ mol = safe_add_hs()
146
+ self.mol = mol
147
+ df = self.analyze_lewis()
148
+ lewis_info = {row["index atome"]: (row["doublets non-liants (DNL)"], row["lacunes ([])"])
149
+ for _, row in df.iterrows()}
150
+ else:
151
+ df = None
152
+
153
+ if rep3D:
154
+ mol = Chem.AddHs(self.mol)
155
+ self.mol = mol
156
+ AllChem.EmbedMolecule(mol)
157
+
158
+ if macrocycle:
159
+ rdCoordGen.AddCoords(self.mol)
160
+
161
+ d2d = rdMolDraw2D.MolDraw2DSVG(size[0],size[1])
162
+
163
+ atoms = list(mol.GetAtoms())
164
+
165
+ if plot_conjugation:
166
+ from collections import defaultdict
167
+ Chem.SetConjugation(mol)
168
+ colors = [(0.0, 0.0, 1.0, 0.4)]
169
+ athighlights = defaultdict(list)
170
+ arads = {}
171
+ bndhighlights = defaultdict(list)
172
+ for bond in mol.GetBonds():
173
+ aid1 = bond.GetBeginAtomIdx()
174
+ aid2 = bond.GetEndAtomIdx()
175
+
176
+ if bond.GetIsConjugated():
177
+ bid = mol.GetBondBetweenAtoms(aid1,aid2).GetIdx()
178
+ bndhighlights[bid].append(colors[0])
179
+
180
+ if plot_aromatic:
181
+ from collections import defaultdict
182
+ colors = [(1.0, 0.0, 0.0, 0.4)]
183
+ athighlights = defaultdict(list)
184
+ arads = {}
185
+ for a in atoms:
186
+ if a.GetIsAromatic():
187
+ aid = a.GetIdx()
188
+ athighlights[aid].append(colors[0])
189
+ arads[aid] = 0.3
190
+
191
+ bndhighlights = defaultdict(list)
192
+ for bond in mol.GetBonds():
193
+ aid1 = bond.GetBeginAtomIdx()
194
+ aid2 = bond.GetEndAtomIdx()
195
+
196
+ if bond.GetIsAromatic():
197
+ bid = mol.GetBondBetweenAtoms(aid1,aid2).GetIdx()
198
+ bndhighlights[bid].append(colors[0])
199
+
200
+ if show_hybrid or show_Lewis:
201
+ for i,atom in enumerate(atoms):
202
+ # print(i,atom.GetDegree(),atom.GetImplicitValence())
203
+ note_parts = []
204
+ if show_hybrid and(atom.GetValence(rdkit.Chem.rdchem.ValenceType.IMPLICIT) > 0 or atom.GetDegree() > 1):
205
+ note_parts.append(str(atom.GetHybridization()))
206
+ if show_Lewis and i in lewis_info:
207
+ lp, vac = lewis_info[i]
208
+ if lp > 0:
209
+ note_parts.append(f" {lp}DNL")
210
+ if vac > 0:
211
+ note_parts.append(f" {vac}[]")
212
+ if note_parts:
213
+ mol.GetAtomWithIdx(i).SetProp('atomNote',"".join(note_parts))
214
+ # print(f"Atom {i+1:3}: {atom.GetAtomicNum():3} {atom.GetSymbol():>2} {atom.GetHybridization()}")
215
+ if show_Lewis:
216
+ display(df)
217
+
218
+ if show_n:
219
+ d2d.drawOptions().addAtomIndices=show_n
220
+
221
+ if plot_aromatic or plot_conjugation:
222
+ d2d.DrawMoleculeWithHighlights(mol,legend,dict(athighlights),dict(bndhighlights),arads,{})
223
+ else:
224
+ d2d.DrawMolecule(mol,legend=legend, highlightAtoms=highlightAtoms)
225
+
226
+ d2d.FinishDrawing()
227
+ display(SVG(d2d.GetDrawingText()))
228
+
229
+ return
230
+
@@ -0,0 +1,272 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ import matplotlib.pyplot as plt
4
+ from scipy.optimize import curve_fit
5
+ import os
6
+ import re
7
+
8
+ class KORD:
9
+ """
10
+ Initialize the kinetic study with experimental data.
11
+ Reaction: alpha A = beta B
12
+
13
+ Args:
14
+ - t_exp (array-like): Time values.
15
+ - G_exp (array-like): Measured physical quantity (Absorbance, Conductivity, etc.).
16
+
17
+ Fixed Parameters:
18
+ - alpha (float): Stoichiometric coefficient for A reactant (smallest positive integer). Default is 1.0.
19
+ - beta (float): Stoichiometric coefficient for B product (smallest positive integer). Default is 1.0.
20
+ - A0 (float): Initial concentration. Note: G_theo is independent of A0 for Order 1. Default is 1.0.
21
+
22
+ Adjustable Variables (Initial Guesses):
23
+ - k_guess (float, optional): Initial estimate for the rate constant. Default is 0.01.
24
+ - G_0_guess (float, optional): Initial estimate for the initial measured value (G at t=0). if None, it will be initialized as G_exp[0]
25
+ - G_inf_guess (float, optional): Initial estimate for the final measured value (G at infinity). if None, it will be initialized as G_exp[-1]
26
+
27
+ Other Args:
28
+ - verbose (bool): If True, enables debug messages and detailed optimization logs.
29
+ - headers (tuple of strings): headers of the t_exp and G_exp arrays read in the excel file
30
+ """
31
+
32
+ def __init__(self, t_exp, G_exp, headers, A0=1.0, alpha=1.0, beta=1.0, k_guess=0.01,
33
+ G_0_guess = None, G_inf_guess = None, verbose=False):
34
+
35
+ # Force conversion to float arrays to prevent 'object' dtype issues.
36
+ # This ensures [0, 0.5, ...] (objects) become [0.0, 0.5, ...] (floats),
37
+ # allowing NumPy mathematical functions (like np.exp) to operate correctly.
38
+ self.t_exp = np.array(t_exp, dtype=float)
39
+ self.G_exp = np.array(G_exp, dtype=float)
40
+ self.headers = headers
41
+ # Ensure fixed parameters are treated as native floats.
42
+ # This prevents errors during optimization if values are passed as strings or tuples.
43
+ self.A0 = float(A0)
44
+ self.alpha = float(alpha)
45
+ self.beta = float(beta)
46
+
47
+ self.k_guess = k_guess
48
+ self.G_0_guess = G_0_guess if G_0_guess is not None else G_exp[0]
49
+ self.G_inf_guess = G_inf_guess if G_inf_guess is not None else G_exp[-1]
50
+ self.results = {}
51
+ self.verbose = verbose
52
+ # Color mapping for orders
53
+ self.order_colors = {0: 'red', 1: 'green', 2: 'blue'}
54
+ self.ansi_colors = {0: "\033[91m", 1: "\033[92m", 2: "\033[94m"}
55
+ self.reset = "\033[0m"
56
+ # t_fin = self.A0 / (self.alpha * self.k_guess)
57
+ # print(f"{t_fin=}")
58
+
59
+ @staticmethod
60
+ def load_from_excel(file_path, exp_number, sheet_name=0, show_data=True):
61
+ """
62
+ Static method to extract data from an Excel file.
63
+ Selects the pair of columns (t, G) corresponding to the experiment number.
64
+ Also loads parameters (A0, alpha, beta)
65
+ Format:
66
+ Row 1: Headers for t and G
67
+ Row 2: [A]0 value (in the G column)
68
+ Row 3: alpha value (in the G column)
69
+ Row 4: beta value (in the G column)
70
+ Row 5+: [t, G] data points
71
+ """
72
+ # 1. Check if file exists
73
+ if not os.path.exists(file_path):
74
+ print(f"❌ Error: The file '{file_path}' was not found.")
75
+ return None
76
+
77
+ try:
78
+ df = pd.read_excel(file_path, sheet_name=sheet_name)
79
+ except Exception as e:
80
+ print(f"❌ Error while reading the Excel file: {e}")
81
+ return None
82
+
83
+ idx_t, idx_G = 2*(exp_number-1), 2*(exp_number-1)+1
84
+
85
+ # --- Parameter Extraction (Now looking in the G column: idx_G) ---
86
+ def parse_param(val):
87
+ if isinstance(val, str):
88
+ match = re.search(r"(\d+\.?\d*)", val)
89
+ return float(match.group(1)) if match else 1.0
90
+ return float(val) if pd.notnull(val) else 1.0
91
+
92
+ total_cols = len(df.columns)
93
+ num_experiments = total_cols // 2
94
+ print(f"Experiments detected: {num_experiments}")
95
+
96
+ # Parameters are expected in rows 2, 3, and 4 of Excel (indices 0, 1, 2)
97
+ a0 = parse_param(df.iloc[0, idx_G])
98
+ alpha = parse_param(df.iloc[1, idx_G])
99
+ beta = parse_param(df.iloc[2, idx_G])
100
+
101
+ # --- Data Extraction (From index 3 onwards) ---
102
+ data = df.iloc[3:, [idx_t, idx_G]].dropna()
103
+
104
+ label_t = KORD._clean_pandas_suffix(df.columns[idx_t])
105
+ label_G = KORD._clean_pandas_suffix(df.columns[idx_G])
106
+ data.columns = [label_t, label_G]
107
+
108
+ print(f"✅ Loaded: {label_G} (Exp {exp_number})")
109
+ print(f" [Parameters from {label_G}] A0: {a0:.4e} mol.L-1 | alpha: {alpha} | beta: {beta}\n")
110
+
111
+ if show_data:
112
+ from IPython.display import display
113
+ display(data)
114
+
115
+ return data.iloc[:, 0].values, data.iloc[:, 1].values, (label_t, label_G), (a0, alpha, beta)
116
+
117
+ @staticmethod
118
+ def _clean_pandas_suffix(name):
119
+ """
120
+ Safely removes the '.1', '.2' suffixes added by Pandas for duplicate names.
121
+ Only matches a dot followed by digits at the VERY END of the string.
122
+ Example: 'mol.L-1.1' -> 'mol.L-1'
123
+ """
124
+ return re.sub(r'\.\d+$', '', str(name))
125
+
126
+ def G0_theo(self, t, k, G0, Ginf):
127
+ """
128
+ Continuous linear model for optimization.
129
+ Allows A(t) to be negative so curve_fit can find the gradient.
130
+ """
131
+ return G0 + (float(self.alpha) * k * t / float(self.A0)) * (Ginf - G0)
132
+
133
+ def G1_theo(self, t, k, G0, Ginf):
134
+ """Model for Order 1 kinetics"""
135
+ return Ginf + np.exp(-self.alpha * k * t) * (G0 - Ginf)
136
+
137
+ def G2_theo(self, t, k, G0, Ginf):
138
+ """Model for Order 2 kinetics"""
139
+ return Ginf - (Ginf - G0) / (1 + self.A0 * self.alpha * k * t)
140
+
141
+ def fit(self, k_guess, G_0_guess, G_inf_guess, order=1):
142
+ """
143
+ Fits the chosen kinetic model to the experimental data, with order=order (Default: 1)
144
+ verbose=True: prints the initial guess vector p0
145
+ """
146
+ models = {0: self.G0_theo, 1: self.G1_theo, 2: self.G2_theo}
147
+ func = models[order]
148
+
149
+ # Initial guess vector [k, G0, Ginf]
150
+ p0 = [self.k_guess, self.G_0_guess, self.G_inf_guess]
151
+
152
+ # 1. Inspection des types des paramètres de classe
153
+ # print(f"--- TYPE CHECK (Order {order}) ---")
154
+ # print(f"self.A0: {type(self.A0)} | Value: {self.A0}")
155
+ # print(f"self.alpha: {type(self.alpha)} | Value: {self.alpha}")
156
+ # print("----------------------------------")
157
+ # print(f"p0 types: {[type(x) for x in p0]}")
158
+ # print(f"t_exp type: {type(self.t_exp)} | dtype: {self.t_exp.dtype}")
159
+ # print(f"G_exp type: {type(self.G_exp)} | dtype: {self.G_exp.dtype}")
160
+ # print("----------------------------------")
161
+ # print(self.t_exp)
162
+ # print("----------------------------------")
163
+
164
+ if self.verbose:
165
+ c = self.ansi_colors[order]
166
+ print(f"{c}--- DEBUG INITIAL GUESS (Order {order}) ---{self.reset}")
167
+ print(f" GUESS: k: {p0[0]:.2e} | G0: {p0[1]:.4f} | Ginf: {p0[2]:.4f}")
168
+
169
+ try:
170
+ popt, _ = curve_fit(func, self.t_exp, self.G_exp, p0=p0)
171
+ k_opt, G0_opt, Ginf_opt = popt
172
+ G_theo = func(self.t_exp, *popt)
173
+
174
+ rmsd = np.sqrt(np.mean((self.G_exp - G_theo)**2))
175
+
176
+ # t1/2 calculation
177
+ if order == 0: t_half = self.A0 / (2 * self.alpha * k_opt)
178
+ elif order == 1: t_half = np.log(2) / (self.alpha * k_opt)
179
+ else: t_half = 1 / (self.A0 * self.alpha * k_opt)
180
+
181
+ if self.verbose:
182
+ # Aligned exactly with the GUESS print for easy comparison
183
+ print(f" OPTIM: k: {k_opt:.2e} | G0: {G0_opt:.4f} | Ginf: {Ginf_opt:.4f}")
184
+ print(f" ✅ RMSD: {rmsd:.2e}")
185
+
186
+ self.results[order] = {
187
+ 'k': k_opt, 'G0': G0_opt, 'Ginf': Ginf_opt,
188
+ 'rmsd': rmsd, 't_half': t_half, 'G_theo': G_theo
189
+ }
190
+ return self.results[order]
191
+ except Exception as e:
192
+ print(f"Could not fit order {order}: {e}")
193
+ return None
194
+
195
+ def plot_all_fits(self):
196
+ """Plots experimental data and all three kinetic models for visual comparison."""
197
+ plt.figure(figsize=(10, 6))
198
+ ax1 = plt.gca()
199
+
200
+ ax1.scatter(self.t_exp, self.G_exp, label="Experimental", color='black', s=35, alpha=0.5)
201
+
202
+ t_smooth = np.linspace(self.t_exp.min(), self.t_exp.max(), 500)
203
+ models = {0: self.G0_theo, 1: self.G1_theo, 2: self.G2_theo}
204
+
205
+ for order in [0, 1, 2]:
206
+ if order not in self.results:
207
+ self.fit(self.k_guess, self.G_0_guess, self.G_inf_guess, order)
208
+
209
+ res = self.results[order]
210
+
211
+ G_smooth = models[order](t_smooth, res['k'], res['G0'], res['Ginf'])
212
+ # if order == 0:
213
+ # t_fin = self.A0 / (self.alpha * res['k'])
214
+ # print(f"{t_fin=}")
215
+
216
+ ax1.plot(t_smooth, G_smooth,
217
+ label=f"Order {order} (RMSD: {res['rmsd']:.2e})",
218
+ color=self.order_colors[order], lw=2)
219
+
220
+ # 3. Add horizontal lines for the BEST model
221
+ best_order = self.get_best_order(verbose=False)
222
+ best_res = self.results[best_order]
223
+ best_color = self.order_colors[best_order]
224
+
225
+ ax1.axhline(best_res['G0'], color=best_color, linestyle='--', alpha=0.6)
226
+ ax1.axhline(best_res['Ginf'], color=best_color, linestyle='--', alpha=0.6)
227
+
228
+ # 4. Add the second axis for the fitted values
229
+ ax2 = ax1.twinx()
230
+ ax2.set_ylim(ax1.get_ylim()) # Keep scales aligned
231
+ ax2.set_yticks([best_res['G0'], best_res['Ginf']])
232
+ ax2.set_yticklabels([f"G0_fit={best_res['G0']:.3f}", f"Ginf_fit={best_res['Ginf']:.3f}"])
233
+ ax2.tick_params(axis='y', labelcolor=best_color)
234
+
235
+ ax1.set_xlabel("Time")
236
+ ax1.set_ylabel("Quantity G")
237
+ ax1.set_title(f"KORD Kinetic Models Comparison (0, 1, 2). Label exp = {self.headers[1]}")
238
+ ax1.legend()
239
+ # ax1.grid(True, linestyle=':', alpha=0.6)
240
+ plt.show()
241
+
242
+ def get_best_order(self, verbose=True):
243
+ """Determines and prints the best model based on the lowest RMSD."""
244
+ for i in [0, 1, 2]:
245
+ if i not in self.results: self.fit(self.k_guess, self.G_0_guess,
246
+ self.G_inf_guess, i)
247
+
248
+ best_order = min(self.results, key=lambda x: self.results[x]['rmsd'])
249
+ res = self.results[best_order]
250
+
251
+ if verbose:
252
+ # ANSI Escape sequences for color in terminal/notebook
253
+ reset = self.reset
254
+ color = self.ansi_colors[best_order]
255
+
256
+ print(f"--- {color}KORD CONCLUSION ---")
257
+ print(f"Best model: ORDER {best_order}")
258
+ print(f"Initial concentration: {self.A0:.3e} mol.L-1")
259
+ print(f"alpha: {self.alpha}")
260
+ print(f"beta: {self.beta}")
261
+ print()
262
+ print(f"RMSD: {res['rmsd']:.2e}")
263
+ print(f"k: {res['k']:.3e}")
264
+ print(f"t1/2: {res['t_half']:.3f}")
265
+ print()
266
+ print(f"G0_exp: {self.G_exp[0]:.3e}")
267
+ print(f"G0_fit: {res['G0']:.3e}")
268
+ print(f"Ginf_fit: {res['Ginf']:.3e}")
269
+ print(f"------------------------{reset}")
270
+ return
271
+ else:
272
+ return best_order
@@ -1,5 +1,5 @@
1
1
  # tools4pyPhysChem/__init__.py
2
- __version__ = "0.3.2"
2
+ __version__ = "0.3.4"
3
3
  __last_update__ = "2026-02-03"
4
4
 
5
5
  import importlib
@@ -6,78 +6,113 @@ import os
6
6
  import re
7
7
 
8
8
  class KORD:
9
- def __init__(self, t_exp, G_exp, headers, A0=1.0, alpha=1.0, k_guess=0.01, verbose=False):
10
- """
11
- Initialize the kinetic study with experimental data.
9
+ """
10
+ Initialize the kinetic study with experimental data.
11
+ Reaction: alpha A = beta B
12
+
13
+ Args:
14
+ - t_exp (array-like): Time values.
15
+ - G_exp (array-like): Measured physical quantity (Absorbance, Conductivity, etc.).
16
+
17
+ Fixed Parameters:
18
+ - alpha (float): Stoichiometric coefficient for A reactant (smallest positive integer). Default is 1.0.
19
+ - beta (float): Stoichiometric coefficient for B product (smallest positive integer). Default is 1.0.
20
+ - A0 (float): Initial concentration. Note: G_theo is independent of A0 for Order 1. Default is 1.0.
21
+
22
+ Adjustable Variables (Initial Guesses):
23
+ - k_guess (float, optional): Initial estimate for the rate constant. Default is 0.01.
24
+ - G_0_guess (float, optional): Initial estimate for the initial measured value (G at t=0). if None, it will be initialized as G_exp[0]
25
+ - G_inf_guess (float, optional): Initial estimate for the final measured value (G at infinity). if None, it will be initialized as G_exp[-1]
12
26
 
13
- Parameters:
14
- t_exp (array-like): Time values.
15
- G_exp (array-like): Measured physical quantity (Absorbance, Conductivity, etc.).
16
- A0 (float): Initial concentration. Default is 1.0.
17
- alpha (float): Stoichiometric coefficient. Default is 1.0.
18
- k_guess (float): rate constant. Default is 0.01
19
- verbose (bool): kind of debug variable
20
- """
21
- self.t_exp = np.array(t_exp)
22
- self.G_exp = np.array(G_exp)
27
+ Other Args:
28
+ - verbose (bool): If True, enables debug messages and detailed optimization logs.
29
+ - headers (tuple of strings): headers of the t_exp and G_exp arrays read in the excel file
30
+ """
31
+
32
+ def __init__(self, t_exp, G_exp, headers, A0=1.0, alpha=1.0, beta=1.0, k_guess=0.01,
33
+ G_0_guess = None, G_inf_guess = None, verbose=False):
34
+
35
+ # Force conversion to float arrays to prevent 'object' dtype issues.
36
+ # This ensures [0, 0.5, ...] (objects) become [0.0, 0.5, ...] (floats),
37
+ # allowing NumPy mathematical functions (like np.exp) to operate correctly.
38
+ self.t_exp = np.array(t_exp, dtype=float)
39
+ self.G_exp = np.array(G_exp, dtype=float)
23
40
  self.headers = headers
24
- self.A0 = A0
25
- self.alpha = alpha
41
+ # Ensure fixed parameters are treated as native floats.
42
+ # This prevents errors during optimization if values are passed as strings or tuples.
43
+ self.A0 = float(A0)
44
+ self.alpha = float(alpha)
45
+ self.beta = float(beta)
46
+
26
47
  self.k_guess = k_guess
48
+ self.G_0_guess = G_0_guess if G_0_guess is not None else G_exp[0]
49
+ self.G_inf_guess = G_inf_guess if G_inf_guess is not None else G_exp[-1]
27
50
  self.results = {}
28
51
  self.verbose = verbose
29
52
  # Color mapping for orders
30
53
  self.order_colors = {0: 'red', 1: 'green', 2: 'blue'}
31
54
  self.ansi_colors = {0: "\033[91m", 1: "\033[92m", 2: "\033[94m"}
32
55
  self.reset = "\033[0m"
56
+ # t_fin = self.A0 / (self.alpha * self.k_guess)
57
+ # print(f"{t_fin=}")
33
58
 
34
59
  @staticmethod
35
60
  def load_from_excel(file_path, exp_number, sheet_name=0, show_data=True):
36
61
  """
37
62
  Static method to extract data from an Excel file.
38
63
  Selects the pair of columns (t, G) corresponding to the experiment number.
64
+ Also loads parameters (A0, alpha, beta)
65
+ Format:
66
+ Row 1: Headers for t and G
67
+ Row 2: [A]0 value (in the G column)
68
+ Row 3: alpha value (in the G column)
69
+ Row 4: beta value (in the G column)
70
+ Row 5+: [t, G] data points
39
71
  """
40
72
  # 1. Check if file exists
41
73
  if not os.path.exists(file_path):
42
74
  print(f"❌ Error: The file '{file_path}' was not found.")
43
- return None, None
75
+ return None
44
76
 
45
77
  try:
46
78
  df = pd.read_excel(file_path, sheet_name=sheet_name)
47
79
  except Exception as e:
48
80
  print(f"❌ Error while reading the Excel file: {e}")
49
- return None, None
81
+ return None
82
+
83
+ idx_t, idx_G = 2*(exp_number-1), 2*(exp_number-1)+1
50
84
 
85
+ # --- Parameter Extraction (Now looking in the G column: idx_G) ---
86
+ def parse_param(val):
87
+ if isinstance(val, str):
88
+ match = re.search(r"(\d+\.?\d*)", val)
89
+ return float(match.group(1)) if match else 1.0
90
+ return float(val) if pd.notnull(val) else 1.0
91
+
51
92
  total_cols = len(df.columns)
52
93
  num_experiments = total_cols // 2
53
-
54
94
  print(f"Experiments detected: {num_experiments}")
55
95
 
56
- for i in range(1, num_experiments + 1):
57
- it, ig = 2*(i-1), 2*(i-1)+1
58
- # Clean names for display using regex
59
- n_t = KORD._clean_pandas_suffix(df.columns[it])
60
- n_g = KORD._clean_pandas_suffix(df.columns[ig])
61
- pts = len(df.iloc[:, [it, ig]].dropna())
62
- print(f" [{i}] {n_t} | {n_g} ({pts} points)")
63
- print(f"-----------------------\n")
64
-
65
- if exp_number < 1 or exp_number > num_experiments:
66
- print(f"⚠️ Error: Experiment {exp_number} does not exist (Choice: 1 to {num_experiments}).")
67
- return None, None, None, None
96
+ # Parameters are expected in rows 2, 3, and 4 of Excel (indices 0, 1, 2)
97
+ a0 = parse_param(df.iloc[0, idx_G])
98
+ alpha = parse_param(df.iloc[1, idx_G])
99
+ beta = parse_param(df.iloc[2, idx_G])
100
+
101
+ # --- Data Extraction (From index 3 onwards) ---
102
+ data = df.iloc[3:, [idx_t, idx_G]].dropna()
68
103
 
69
- # Position-based extraction
70
- idx_t, idx_G = 2*(exp_number-1), 2*(exp_number-1)+1
71
104
  label_t = KORD._clean_pandas_suffix(df.columns[idx_t])
72
105
  label_G = KORD._clean_pandas_suffix(df.columns[idx_G])
73
-
74
- data = df.iloc[:, [idx_t, idx_G]].dropna()
75
106
  data.columns = [label_t, label_G]
76
- print(f"✅ Loaded: {label_G} (Exp {exp_number})\n")
107
+
108
+ print(f"✅ Loaded: {label_G} (Exp {exp_number})")
109
+ print(f" [Parameters from {label_G}] A0: {a0:.4e} mol.L-1 | alpha: {alpha} | beta: {beta}\n")
77
110
 
78
- if show_data: display(data)
111
+ if show_data:
112
+ from IPython.display import display
113
+ display(data)
79
114
 
80
- return data.iloc[:, 0].values, data.iloc[:, 1].values, (label_t, label_G)
115
+ return data.iloc[:, 0].values, data.iloc[:, 1].values, (label_t, label_G), (a0, alpha, beta)
81
116
 
82
117
  @staticmethod
83
118
  def _clean_pandas_suffix(name):
@@ -89,21 +124,21 @@ class KORD:
89
124
  return re.sub(r'\.\d+$', '', str(name))
90
125
 
91
126
  def G0_theo(self, t, k, G0, Ginf):
92
- """Model for Order 0 kinetics."""
93
- t_fin = self.A0 / (self.alpha * k)
94
- return np.where(t <= t_fin,
95
- G0 + (self.alpha * k * t / self.A0) * (Ginf - G0),
96
- Ginf)
127
+ """
128
+ Continuous linear model for optimization.
129
+ Allows A(t) to be negative so curve_fit can find the gradient.
130
+ """
131
+ return G0 + (float(self.alpha) * k * t / float(self.A0)) * (Ginf - G0)
97
132
 
98
133
  def G1_theo(self, t, k, G0, Ginf):
99
- """Model for Order 1 kinetics."""
134
+ """Model for Order 1 kinetics"""
100
135
  return Ginf + np.exp(-self.alpha * k * t) * (G0 - Ginf)
101
136
 
102
137
  def G2_theo(self, t, k, G0, Ginf):
103
- """Model for Order 2 kinetics."""
138
+ """Model for Order 2 kinetics"""
104
139
  return Ginf - (Ginf - G0) / (1 + self.A0 * self.alpha * k * t)
105
140
 
106
- def fit(self, order=1, k_guess=None):
141
+ def fit(self, k_guess, G_0_guess, G_inf_guess, order=1):
107
142
  """
108
143
  Fits the chosen kinetic model to the experimental data, with order=order (Default: 1)
109
144
  verbose=True: prints the initial guess vector p0
@@ -111,10 +146,21 @@ class KORD:
111
146
  models = {0: self.G0_theo, 1: self.G1_theo, 2: self.G2_theo}
112
147
  func = models[order]
113
148
 
114
- # Initial guess [k, G0, Ginf]
115
- current_k = k_guess if k_guess is not None else self.k_guess
116
149
  # Initial guess vector [k, G0, Ginf]
117
- p0 = [current_k, self.G_exp[0], self.G_exp[-1]]
150
+ p0 = [self.k_guess, self.G_0_guess, self.G_inf_guess]
151
+
152
+ # 1. Inspection des types des paramètres de classe
153
+ # print(f"--- TYPE CHECK (Order {order}) ---")
154
+ # print(f"self.A0: {type(self.A0)} | Value: {self.A0}")
155
+ # print(f"self.alpha: {type(self.alpha)} | Value: {self.alpha}")
156
+ # print("----------------------------------")
157
+ # print(f"p0 types: {[type(x) for x in p0]}")
158
+ # print(f"t_exp type: {type(self.t_exp)} | dtype: {self.t_exp.dtype}")
159
+ # print(f"G_exp type: {type(self.G_exp)} | dtype: {self.G_exp.dtype}")
160
+ # print("----------------------------------")
161
+ # print(self.t_exp)
162
+ # print("----------------------------------")
163
+
118
164
  if self.verbose:
119
165
  c = self.ansi_colors[order]
120
166
  print(f"{c}--- DEBUG INITIAL GUESS (Order {order}) ---{self.reset}")
@@ -124,6 +170,7 @@ class KORD:
124
170
  popt, _ = curve_fit(func, self.t_exp, self.G_exp, p0=p0)
125
171
  k_opt, G0_opt, Ginf_opt = popt
126
172
  G_theo = func(self.t_exp, *popt)
173
+
127
174
  rmsd = np.sqrt(np.mean((self.G_exp - G_theo)**2))
128
175
 
129
176
  # t1/2 calculation
@@ -148,46 +195,78 @@ class KORD:
148
195
  def plot_all_fits(self):
149
196
  """Plots experimental data and all three kinetic models for visual comparison."""
150
197
  plt.figure(figsize=(10, 6))
151
- plt.scatter(self.t_exp, self.G_exp, label="Experimental", color='black', s=35, alpha=0.5)
198
+ ax1 = plt.gca()
199
+
200
+ ax1.scatter(self.t_exp, self.G_exp, label="Experimental", color='black', s=35, alpha=0.5)
152
201
 
153
202
  t_smooth = np.linspace(self.t_exp.min(), self.t_exp.max(), 500)
154
203
  models = {0: self.G0_theo, 1: self.G1_theo, 2: self.G2_theo}
155
-
204
+
156
205
  for order in [0, 1, 2]:
157
206
  if order not in self.results:
158
- self.fit(order)
207
+ self.fit(self.k_guess, self.G_0_guess, self.G_inf_guess, order)
159
208
 
160
209
  res = self.results[order]
161
210
 
162
211
  G_smooth = models[order](t_smooth, res['k'], res['G0'], res['Ginf'])
212
+ # if order == 0:
213
+ # t_fin = self.A0 / (self.alpha * res['k'])
214
+ # print(f"{t_fin=}")
163
215
 
164
- plt.plot(t_smooth, G_smooth,
216
+ ax1.plot(t_smooth, G_smooth,
165
217
  label=f"Order {order} (RMSD: {res['rmsd']:.2e})",
166
218
  color=self.order_colors[order], lw=2)
219
+
220
+ # 3. Add horizontal lines for the BEST model
221
+ best_order = self.get_best_order(verbose=False)
222
+ best_res = self.results[best_order]
223
+ best_color = self.order_colors[best_order]
224
+
225
+ ax1.axhline(best_res['G0'], color=best_color, linestyle='--', alpha=0.6)
226
+ ax1.axhline(best_res['Ginf'], color=best_color, linestyle='--', alpha=0.6)
227
+
228
+ # 4. Add the second axis for the fitted values
229
+ ax2 = ax1.twinx()
230
+ ax2.set_ylim(ax1.get_ylim()) # Keep scales aligned
231
+ ax2.set_yticks([best_res['G0'], best_res['Ginf']])
232
+ ax2.set_yticklabels([f"G0_fit={best_res['G0']:.3f}", f"Ginf_fit={best_res['Ginf']:.3f}"])
233
+ ax2.tick_params(axis='y', labelcolor=best_color)
167
234
 
168
- plt.xlabel("Time")
169
- plt.ylabel("Quantity G")
170
- plt.title(f"KORD Kinetic Models Comparison (0, 1, 2). Label exp = {self.headers[1]}")
171
- plt.legend()
172
- plt.grid(True, linestyle=':', alpha=0.6)
235
+ ax1.set_xlabel("Time")
236
+ ax1.set_ylabel("Quantity G")
237
+ ax1.set_title(f"KORD Kinetic Models Comparison (0, 1, 2). Label exp = {self.headers[1]}")
238
+ ax1.legend()
239
+ # ax1.grid(True, linestyle=':', alpha=0.6)
173
240
  plt.show()
174
241
 
175
- def get_best_order(self):
242
+ def get_best_order(self, verbose=True):
176
243
  """Determines and prints the best model based on the lowest RMSD."""
177
244
  for i in [0, 1, 2]:
178
- if i not in self.results: self.fit(i)
245
+ if i not in self.results: self.fit(self.k_guess, self.G_0_guess,
246
+ self.G_inf_guess, i)
179
247
 
180
248
  best_order = min(self.results, key=lambda x: self.results[x]['rmsd'])
181
249
  res = self.results[best_order]
182
250
 
183
- # ANSI Escape sequences for color in terminal/notebook
184
- reset = self.reset
185
- color = self.ansi_colors[best_order]
186
-
187
- print(f"--- {color}KORD CONCLUSION ---")
188
- print(f"Best model: ORDER {best_order}")
189
- print(f"RMSD: {res['rmsd']:.2e}")
190
- print(f"k: {res['k']:.3e}")
191
- print(f"t1/2: {res['t_half']:.3f}")
192
- print(f"------------------------{reset}")
193
- return
251
+ if verbose:
252
+ # ANSI Escape sequences for color in terminal/notebook
253
+ reset = self.reset
254
+ color = self.ansi_colors[best_order]
255
+
256
+ print(f"--- {color}KORD CONCLUSION ---")
257
+ print(f"Best model: ORDER {best_order}")
258
+ print(f"Initial concentration: {self.A0:.3e} mol.L-1")
259
+ print(f"alpha: {self.alpha}")
260
+ print(f"beta: {self.beta}")
261
+ print()
262
+ print(f"RMSD: {res['rmsd']:.2e}")
263
+ print(f"k: {res['k']:.3e}")
264
+ print(f"t1/2: {res['t_half']:.3f}")
265
+ print()
266
+ print(f"G0_exp: {self.G_exp[0]:.3e}")
267
+ print(f"G0_fit: {res['G0']:.3e}")
268
+ print(f"Ginf_fit: {res['Ginf']:.3e}")
269
+ print(f"------------------------{reset}")
270
+ return
271
+ else:
272
+ return best_order
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyphyschemtools
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: A comprehensive Python toolbox for physical chemistry and cheminformatics
5
5
  Author-email: "Romuald POTEAU, LPCNO" <romuald.poteau@utoulouse.fr>
6
6
  Project-URL: Repository, https://github.com/rpoteau/pyphyschemtools
@@ -1,13 +1,12 @@
1
1
  pyphyschemtools/.readthedocs.yaml,sha256=ZTw2bOyF9p3JpeF8Ux0fwhYWO6KHCsroNEOvnXxbYGM,469
2
- pyphyschemtools/.visualID_Eng.py.swp,sha256=e47tjB8u-H3ZCAru897onACs2xbR2XhrLBMLhqrrzS8,20480
3
2
  pyphyschemtools/Chem3D.py,sha256=NuhoLvWpeATO88UklXg-3XqPpIiHqvP__b2tLHrypL8,34718
4
3
  pyphyschemtools/ML.py,sha256=kR_5vm5TOOjVef8uXCW57y7685ts6K6OkRMBYKP_cYw,1599
5
4
  pyphyschemtools/PeriodicTable.py,sha256=LfLSFOzRkirREQlwfeSR3TyvgHyjGiltIZXNmvBkbhQ,13526
6
- pyphyschemtools/__init__.py,sha256=zHorm9gKJs_YFIpVg33jdWp4e6p9EhgKVBBB6XRDZ_k,1442
5
+ pyphyschemtools/__init__.py,sha256=CqTWs1geGKDrA9V_CnzNFTukpwQ8-z9qQl7FwluLrqo,1442
7
6
  pyphyschemtools/aithermo.py,sha256=kF8wtuYIJzkUKM2AGubmn9haAJKz-XaBskZ7HjivJeY,14984
8
7
  pyphyschemtools/cheminformatics.py,sha256=Qps_JSYWOzZQcXwKElI1iWGjWAPDgwmtDKuJwONsKmI,8977
9
8
  pyphyschemtools/core.py,sha256=5fRu83b125w2p_m2H521fLjktyswZHJXNKww1wfBwbU,4847
10
- pyphyschemtools/kinetics.py,sha256=3YyzzQ7FKZ7wNALRDsLNLhpZpDp38LHWOUfMojsh8hE,7696
9
+ pyphyschemtools/kinetics.py,sha256=IVZSY38ai9yO8GyMVXpywfzKMZ6K8Q3n-2UiElOoCyA,11737
11
10
  pyphyschemtools/spectra.py,sha256=eCN9X6pK5k4KMcfWUskedWYwtxbrmJH_BvFXU1GZfVo,21702
12
11
  pyphyschemtools/survey.py,sha256=YjZhhb8GFVNXoXSCxgGdZFqmCtNCx7O_uiFVCcGBYYo,24268
13
12
  pyphyschemtools/sympyUtilities.py,sha256=LgLloh9dD9Mkff2WNoSnrJa3hxK0axOnK-4GS9wPtT0,1545
@@ -17,7 +16,9 @@ pyphyschemtools/visualID_Eng.py,sha256=W-rYHg4g090JUpTJxtbBZVZ2lXShq8f0ALeiNjFqW
17
16
  pyphyschemtools/.ipynb_checkpoints/Chem3D-checkpoint.py,sha256=NuhoLvWpeATO88UklXg-3XqPpIiHqvP__b2tLHrypL8,34718
18
17
  pyphyschemtools/.ipynb_checkpoints/PeriodicTable-checkpoint.py,sha256=LfLSFOzRkirREQlwfeSR3TyvgHyjGiltIZXNmvBkbhQ,13526
19
18
  pyphyschemtools/.ipynb_checkpoints/aithermo-checkpoint.py,sha256=kF8wtuYIJzkUKM2AGubmn9haAJKz-XaBskZ7HjivJeY,14984
19
+ pyphyschemtools/.ipynb_checkpoints/cheminformatics-checkpoint.py,sha256=Qps_JSYWOzZQcXwKElI1iWGjWAPDgwmtDKuJwONsKmI,8977
20
20
  pyphyschemtools/.ipynb_checkpoints/core-checkpoint.py,sha256=5fRu83b125w2p_m2H521fLjktyswZHJXNKww1wfBwbU,4847
21
+ pyphyschemtools/.ipynb_checkpoints/kinetics-checkpoint.py,sha256=IVZSY38ai9yO8GyMVXpywfzKMZ6K8Q3n-2UiElOoCyA,11737
21
22
  pyphyschemtools/.ipynb_checkpoints/spectra-checkpoint.py,sha256=eCN9X6pK5k4KMcfWUskedWYwtxbrmJH_BvFXU1GZfVo,21702
22
23
  pyphyschemtools/.ipynb_checkpoints/survey-checkpoint.py,sha256=Rcw0xb0_nwsxETleB1C2xjKmZfrUw4PXDm48CMSptHU,45816
23
24
  pyphyschemtools/.ipynb_checkpoints/sympyUtilities-checkpoint.py,sha256=LgLloh9dD9Mkff2WNoSnrJa3hxK0axOnK-4GS9wPtT0,1545
@@ -86,8 +87,8 @@ pyphyschemtools/resources/svg/qrcode-pyPhysChem.png,sha256=rP7X-9eHL7HYj4ffmwBML
86
87
  pyphyschemtools/resources/svg/repository-open-graph-template.png,sha256=UlnW5BMkLGOv6IAnEi7teDYS_5qeSLmpxRMT9r9m-5Q,51470
87
88
  pyphyschemtools/resources/svg/tools4pyPC_banner.png,sha256=z7o_kBK0sIBsXHEJrT2GyLHu-0T0T3S8YkWcpxR2joA,89058
88
89
  pyphyschemtools/resources/svg/tools4pyPC_banner.svg,sha256=BXxXHra9vwahaiet1IJW4q8QLA03crSeCIQYo30VpN8,651579
89
- pyphyschemtools-0.3.2.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
90
- pyphyschemtools-0.3.2.dist-info/METADATA,sha256=FSgiMf_Yn_gfmV-X7BwpyLNRs6OkkEJARWeMemRX_yQ,1328
91
- pyphyschemtools-0.3.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
92
- pyphyschemtools-0.3.2.dist-info/top_level.txt,sha256=N92w2qk4LQ42OSdzK1R2h_x1CyUFaFBOrOML2RnmFgE,16
93
- pyphyschemtools-0.3.2.dist-info/RECORD,,
90
+ pyphyschemtools-0.3.4.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
91
+ pyphyschemtools-0.3.4.dist-info/METADATA,sha256=hb_ShyL8oPomJKdJODh18tFkUEnkjNkrn1M4PvRTE58,1328
92
+ pyphyschemtools-0.3.4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
93
+ pyphyschemtools-0.3.4.dist-info/top_level.txt,sha256=N92w2qk4LQ42OSdzK1R2h_x1CyUFaFBOrOML2RnmFgE,16
94
+ pyphyschemtools-0.3.4.dist-info/RECORD,,
Binary file