diff --git a/.gitignore b/.gitignore index 53c67e9..9b9be86 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ __pycache__ *.mp3 *.mp4 *.log -test \ No newline at end of file +test +.venv +uv.lock \ No newline at end of file diff --git a/cord/main.py b/cord/main.py new file mode 100644 index 0000000..2d04204 --- /dev/null +++ b/cord/main.py @@ -0,0 +1,199 @@ +from matplotlib import pyplot as plt +import pandas as pd +import numpy as np +import io +import streamlit as st +# import scienceplots + +# plt.style.use(['nature', 'no-latex',"cjk-sc-font"]) +plt.rcParams['font.family'] = 'sans-serif' +plt.rcParams['font.sans-serif'] = ['SimHei'] # 设置中文字体 +plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题 + +def cubic_bezier_with_zero_derivatives(p0, p1, t_array, influence_factor): + """ + 创建三次贝塞尔曲线,确保起点和终点的导数为0 + + 参数: + p0: 起点 [x0, y0] + p1: 终点 [x1, y1] + t_array: 参数数组 (0到1) + influence_factor: 影响因子,控制控制点的位置 + + 返回: + x_array, y_array: 贝塞尔曲线上的点 + """ + x0, y0 = p0 + x1, y1 = p1 + + # 计算控制点,确保起点和终点导数为0 + # 控制点位置基于影响因子和两点间距离 + dx = x1 - x0 + + # 第一个控制点:在起点右侧,y坐标与起点相同(确保起点导数为0) + p1_control = [x0 + dx * influence_factor[0], y0] + + # 第二个控制点:在终点左侧,y坐标与终点相同(确保终点导数为0) + p2_control = [x1 - dx * influence_factor[1], y1] + + # 计算贝塞尔曲线 + x_bezier = ((1-t_array)**3 * x0 + + 3*(1-t_array)**2 * t_array * p1_control[0] + + 3*(1-t_array) * t_array**2 * p2_control[0] + + t_array**3 * x1) + + y_bezier = ((1-t_array)**3 * y0 + + 3*(1-t_array)**2 * t_array * p1_control[1] + + 3*(1-t_array) * t_array**2 * p2_control[1] + + t_array**3 * y1) + + return x_bezier, y_bezier + +# @st.cache_resource +def plot_reaction_coordinate(changed=None, _lines=None): + """ + 绘制反应坐标图 + """ + + lines = [] + fig,ax1 = plt.subplots(figsize=(9, 6)) + + last=(-1,-1) + + + maxy = data["Energy"].max() + miny = data["Energy"].min() + varyy = maxy - miny + + for i in range(data.shape[0]): + line:pd.Series = data.loc[i] + if last == (-1,-1): + last = (1, line["Energy"]) + if not pd.isna(line["Name"]): + ax1.annotate(str(line["Name"]), (1, line["Energy"]+varyy*K_POS[i]), ha='center') + else: + p1 = last[0]+2,line["Energy"] + x,y = cubic_bezier_with_zero_derivatives(last,p1, np.linspace(0, 1, 300), INFLU_FACTORS[(i*2-2):i*2]) + l = ax1.plot(x, y, "-", color="black")[0] + lines.append(l) + if not pd.isna(line["Name"]): + p = p1[0],p1[1]+varyy*K_POS[i] + ax1.annotate(str(line["Name"]), p, ha='center') + last = p1 + + + + ax1.set_xlabel("Reaction Coordinate") + ax1.xaxis.set_ticks([]) + ax1.set_ylabel("Energy (kcal/mol)") + ax1.set_ylim(miny-varyy*0.1, maxy+varyy*0.1) + return fig,lines + +# 创建图形和坐标轴 + +def callback_gen(x,typ=0): + if typ: + def callback(): + global K_POS + K_POS[x] = st.session_state.get(f'text_slider_{x}', 0.05) + plot_reaction_coordinate(changed=x, _lines=lines) + else: + def callback(): + global INFLU_FACTORS + INFLU_FACTORS[x-1] = st.session_state.get(f'slider_{x}', 0.5) + plot_reaction_coordinate(changed=x, _lines=lines) + + return callback + +def on_save(): + global out_file + # for slider in slides: + # slider.ax.set_visible(False) + plt.draw() + plt.tight_layout() + out_file = io.BytesIO() + fig.savefig(out_file, format=st.session_state.get("export_format", ".tiff")[1:], dpi=300, bbox_inches='tight') + out_file.seek(0) + return out_file.getvalue() + +@st.cache_resource +def load_data(file): + # 读取数据文件 + if file is not None: + try: + data = pd.read_excel(file) if file.name.endswith((".xlsx", ".xls")) else pd.read_csv(file) + except Exception as e: + st.error(f"Error reading file: {e}") + exit() + else: + exit() + + INFLU_FACTORS = [0.5] * data.shape[0] * 2 # 动态创建数组 + + ene = data["Energy"].to_numpy() + K_POS = np.where(ene[1:]>ene[:1],0.03,-0.05) + K_POS = [-0.05] + K_POS.tolist() + st.info(K_POS) + + data["Energy"] -= data["Energy"][0] + data["Energy"]*=627.509 + + return data, INFLU_FACTORS,K_POS + + +out_file = io.BytesIO() +st.set_page_config( + page_title="反应坐标绘制", + page_icon=":chart_with_upwards_trend:", + layout="wide", + initial_sidebar_state="expanded" +) +st.title("反应坐标绘制") +st.write("---") +col1,col2,col3 = st.columns([0.4,0.25,0.25],gap="medium") + +with col1: + file = st.file_uploader("上传能量文件", type=["xlsx", "xls", "csv"]) + data, INFLU_FACTORS,K_POS = load_data(file) + + fig,lines = plot_reaction_coordinate() + stfig = st.pyplot(fig,False) + st.slider("字体大小",8,20, value=12, key="font_size", + on_change=lambda: plt.rcParams.update({'font.size': st.session_state.get("font_size", 12)})) + st.selectbox("导出文件拓展名",[".tiff",".pdf",".png",".pgf"],key="export_format") + st.download_button( + label="Download Plot", + data=on_save(), + file_name="reaction_coordinate"+st.session_state.get("export_format", ".tiff"), + # mime="image/tiff" + ) +with col2: + st.write("调整滑块以改变反应坐标图曲线形状。") + for i in range(data.shape[0]): + if i!=0: + st.slider( + f'{data.loc[i,"Name"]} 左', + 0.0, 1.0, value=INFLU_FACTORS[i*2-1], + key=f'slider_{i*2}', + on_change=callback_gen(i*2) + ) + if i!= data.shape[0] - 1: + st.slider( + f'{data.loc[i,"Name"]} 右', + 0.0, 1.0, value=INFLU_FACTORS[i*2], + key=f'slider_{i*2+1}', + on_change=callback_gen(i*2+1) + ) + +with col3: + st.write("调整参数以改变文字位置。") + for i in range(data.shape[0]): + st.slider( + f'{data.loc[i,"Name"]}', + -0.1, 0.1, value=K_POS[i], + key=f'text_slider_{i}', + on_change=callback_gen(i,1) + ) + +st.write("---") +st.dataframe(data) diff --git a/cord/requirements.txt b/cord/requirements.txt new file mode 100644 index 0000000..d7d4701 --- /dev/null +++ b/cord/requirements.txt @@ -0,0 +1,4 @@ +matplotlib>=3.10.5 +openpyxl>=3.1.5 +pandas>=2.3.1 +streamlit>=1.47.1 \ No newline at end of file diff --git a/mw_tool/main.py b/mw_tool/main.py index 2d2b024..c841f2a 100644 --- a/mw_tool/main.py +++ b/mw_tool/main.py @@ -1,99 +1,51 @@ +import molmass import streamlit as st import pubchempy as pcp -from rdkit import Chem -from rdkit.Chem import rdMolDescriptors +import re +from typing import Optional, Dict, List, cast import requests -from io import BytesIO +import pandas as pd +import numpy as np +import traceback -st.set_page_config( - page_title="质量及密度查询", - layout="wide" -) - -# 初始化 session state -if 'compound_data' not in st.session_state: - st.session_state.compound_data = None - -def search_compound(query): - """搜索化合物信息""" - try: - compounds = None - try: - comp = Chem.MolFromSmiles(query) - if comp: - compounds = pcp.get_compounds(query, 'smiles', listkey_count=3) - except Exception: - st.error("使用smiles精确查询失败") - # 尝试通过化学式搜索 - if not (isinstance(compounds, list) and len(compounds) != 0): - try: - compounds = pcp.get_compounds(query, 'formula', listkey_count=3) - except Exception: - st.error("使用化学式精确查询失败") - if not (isinstance(compounds, list) and len(compounds) != 0): - # 尝试通过名称搜索 - compounds = pcp.get_compounds(query, 'name', listkey_count=3) - - if isinstance(compounds, list) and len(compounds) > 0: - st.info("成功查询物质基本信息,正在获取更多数据。") - return compounds[0] +class PubChemCompound: + def __init__(self, + compound: Optional[pcp.Compound]=None, + extra: Optional[Dict[str, Optional[List[str]]]] = None, + **kwargs + ): + if compound: + self.cid = compound.cid + self.name = compound.iupac_name + self.formula = compound.molecular_formula + self.smiles = compound.isomeric_smiles + self.exact_mass = float(compound.exact_mass) if compound.exact_mass else None else: - return None - except Exception as e: - st.error(f"搜索时发生错误: {str(e)}") - # raise e - return None - -def calculate_molecular_weight_from_smiles(smiles): - """从SMILES计算分子量""" - try: - mol = Chem.MolFromSmiles(smiles) - if mol: - return rdMolDescriptors.CalcExactMolWt(mol) + self.cid = None + self.name = None + self.formula = None + self.smiles = None + self.exact_mass = None + if extra: + self.density = extra.get("density") + self.melting_point = extra.get("melting_point") + self.boiling_point = extra.get("boiling_point") else: - return None - except Exception as e: - st.error(f"SMILES分子量计算错误: {str(e)}") - return None + self.density = None + self.melting_point = None + self.boiling_point = None + self.__dict__.update(kwargs) # 允许传入其他属性 -def generate_molecule_image(inchi=None, smiles=None): - """从SMILES生成分子结构图""" - return None - try: - if inchi: - mol = Chem.MolFromInchi(inchi) - elif smiles: - mol = Chem.MolFromSmiles(smiles) - else: - st.error("必须提供InChI或SMILES字符串") - return None - if mol: - # 生成分子图像 - img = Draw.MolToImage(mol, size=(300, 300)) - # 将图像转换为字节流 - img_buffer = BytesIO() - img.save("a.png") - img.save(img_buffer, format='PNG') - img_buffer.seek(0) - return img_buffer - else: - return None - except Exception as e: - st.error(f"分子结构图生成错误: {str(e)}") - return None - -def get_pubchem_properties(compound): +def get_pubchem_properties(cid:str) -> Dict[str, Optional[List[str]]]: """从PubChem获取密度、熔点、沸点信息""" try: # 初始化返回数据 - properties = { + properties:Dict[str, Optional[List[str]]] = { 'density': None, 'melting_point': None, 'boiling_point': None } - cid = compound.cid - # 尝试获取物理化学性质相关的记录 try: url = f"https://pubchem.ncbi.nlm.nih.gov/rest/pug_view/data/compound/{cid}/JSON?heading=Experimental+Properties" @@ -140,490 +92,797 @@ def get_pubchem_properties(compound): 'boiling_point': None } -def is_liquid_at_room_temp(melting_point): - """判断常温下是否为液体(假设常温为25°C)""" - if melting_point is None: - return False + +def search_compound(query: str, search_type: str = "name"): + """ + 根据不同类型搜索化合物 + + Args: + query: 搜索词 + search_type: 搜索类型 ("name", "formula", "smiles") + + Returns: + PubChem Compound对象或None + """ try: - mp = float(melting_point) - return mp < 25 # 熔点低于25°C认为是液体 - except: - return False - -def sync_calculations(compound_data, mmol=None, mass=None, volume=None, changed_field=None): - """同步计算mmol、质量、体积""" - if not compound_data: - return mmol, mass, volume - - # 确保数值类型转换 - try: - molecular_weight = float(compound_data.get('molecular_weight', 0)) - density_select = compound_data.get('density_select', None) - density = float(density_select) if density_select is not None else None - except (ValueError, TypeError): - st.error("分子量或密度数据格式错误,无法进行计算") - return mmol, mass, volume - - if molecular_weight == 0: - return mmol, mass, volume - - try: - if changed_field == 'mmol' and mmol is not None: - # 根据mmol计算质量 - mass = (mmol / 1000) * molecular_weight # mmol转mol再乘分子量 - # 如果有密度,计算体积 - if density and density > 0: - volume = mass / density - - elif changed_field == 'mass' and mass is not None: - # 根据质量计算mmol - mmol = (mass / molecular_weight) * 1000 # 质量除分子量得mol,再转mmol - # 如果有密度,计算体积 - if density and density > 0: - volume = mass / density - - elif changed_field == 'volume' and volume is not None and density and density > 0: - # 根据体积计算质量 - mass = volume * density - # 根据质量计算mmol - mmol = (mass / molecular_weight) * 1000 - - except Exception as e: - st.error(f"计算错误: {str(e)}") - - return mmol, mass, volume - -# 主界面 -col1, col2 = st.columns([1, 2]) - -with col1: - st.subheader("物质查询") - query = st.text_input("输入化学式、名称或SMILES:", placeholder="例如: H2O, water, CCO") - - # 添加直接计算分子量选项 - calc_mw_only = st.checkbox("仅计算分子量(不查询数据库)", help="勾选此项将跳过数据库查询,仅从SMILES计算分子量") - - if st.button("查询" if not calc_mw_only else "计算", type="primary"): - if query: - with st.spinner("正在处理..."): - # 如果选择仅计算分子量,直接从SMILES计算 - if calc_mw_only: - mol_weight = calculate_molecular_weight_from_smiles(query) - if mol_weight: - compound_data = { - 'name': "用户输入化合物", - 'formula': "从SMILES计算", - 'molecular_weight': mol_weight, - 'melting_point': None, - 'density_src': None, - 'melting_point_src': None, - 'boiling_point_src': None, - 'smiles': query, - "inchi": None, - 'found': False - } - st.session_state.compound_data = compound_data - st.success("✅ 分子量计算完成!") - else: - st.error("❌ 输入的SMILES格式无效") - st.session_state.compound_data = None - else: - # 原有的查询逻辑 - compound = search_compound(query) - - if compound is not None: - # 查询到化合物 - # 获取PubChem的物理化学性质信息 - pubchem_properties = get_pubchem_properties(compound) - - compound_data = { - 'name': compound.iupac_name, - 'cid': compound.cid, - 'formula': compound.molecular_formula, - 'molecular_weight': compound.molecular_weight, - "density_src": pubchem_properties['density'], - 'melting_point_src': pubchem_properties['melting_point'], - 'boiling_point_src': pubchem_properties['boiling_point'], - 'smiles': compound.canonical_smiles, - "inchi": compound.inchi if hasattr(compound, 'inchi') else None, - 'found': True, - } - - - st.session_state.compound_data = compound_data - - # 显示查询结果信息 - if compound_data['density_src'] or compound_data['melting_point_src'] or compound_data['boiling_point_src']: - properties_found = [] - if compound_data['density_src']: - properties_found.append("密度") - if compound_data['melting_point_src']: - properties_found.append("熔点") - if compound_data['boiling_point_src']: - properties_found.append("沸点") - st.success(f"✅ 查询成功!(找到{', '.join(properties_found)}信息)") - else: - st.success("✅ 查询成功!(未找到物理性质信息)") - - else: - # 未查询到,检查是否为SMILES - if query: - mol_weight = calculate_molecular_weight_from_smiles(query) - if mol_weight: - compound_data = { - 'name': "未知化合物", - 'formula': "从SMILES计算", - 'molecular_weight': mol_weight, - 'melting_point': None, - 'density_src': None, - 'melting_point_src': None, - 'boiling_point_src': None, - 'smiles': query, - "inchi": None, - 'found': False - } - st.session_state.compound_data = compound_data - st.warning("⚠️ 未在数据库中找到,但已从SMILES计算分子量") - else: - st.error("❌ 未找到该化合物,且SMILES格式无效") - st.session_state.compound_data = None - -with col2: - st.subheader("化合物信息") - - if st.session_state.compound_data: - data = st.session_state.compound_data - - # 显示基本信息 - info_col1, info_col2 = st.columns(2) - - with info_col1: - st.metric("物质名称", data['name']) - try: - molecular_weight_value = float(data['molecular_weight']) - st.metric("分子量 (g/mol)", f"{molecular_weight_value:.3f}") - except (ValueError, TypeError): - st.metric("分子量 (g/mol)", "数据格式错误") - - with info_col2: - st.metric("化学式", data['formula']) - if data.get("cid"): - st.markdown("### 其他数据") - st.page_link(f"https://pubchem.ncbi.nlm.nih.gov/compound/{data['cid']}",label="**访问PubChem**") - st.image(f"https://pubchem.ncbi.nlm.nih.gov/image/imgsrv.fcgi?cid={data['cid']}&t=s","结构式") - # st.button("访问PubChem",on_click=lambda :st.dire) - # if data['melting_point']: - # st.metric("熔点 (°C)", data['melting_point']) - # # 显示分子结构图 - # if data.get('inchi') or data.get('smiles'): - # st.markdown("分子结构图") - # mol_img = generate_molecule_image(inchi=data['inchi'], smiles=data['smiles']) - # if mol_img: - # st.image(mol_img, caption="分子键线式结构图", width=150) - # else: - # st.info("无法生成分子结构图") - - # 添加熔沸点信息的展开区域 - if data.get('melting_point_src') or data.get('boiling_point_src'): - with st.expander("熔沸点信息", expanded=False): - col1, col2 = st.columns(2) - with col1: - if data.get('melting_point_src'): - st.markdown("### 熔点数据") - melting_data = data['melting_point_src'] - if isinstance(melting_data, list): - for i, mp in enumerate(melting_data, 1): - st.write(f"{i}. {mp}") - else: - st.write(melting_data) - with col2: - if data.get('boiling_point_src'): - st.markdown("### 沸点数据") - boiling_data = data['boiling_point_src'] - if isinstance(boiling_data, list): - for i, bp in enumerate(boiling_data, 1): - st.write(f"{i}. {bp}") - else: - st.write(boiling_data) - - # 判断是否为液体 - melting_data = data['melting_point_src'] - if isinstance(melting_data, list) and len(melting_data) > 0: - import re - melting_point = re.search(r'\d*\.\d+', melting_data[0]) - if melting_point: - melting_point = float(melting_point.group()) - is_liquid = is_liquid_at_room_temp(melting_point) + if search_type == "name": + compounds = pcp.get_compounds(query, 'name') + elif search_type == "formula": + compounds = pcp.get_compounds(query, 'formula') + elif search_type == "smiles": + compounds = pcp.get_compounds(query, 'smiles') else: - is_liquid = False - - # 检测值变化并执行计算 - def handle_change(field_name, new_value, current_value): - try: - # 确保都转换为浮点数 - new_value = float(new_value) if new_value is not None else 0.0 - current_value = float(current_value) if current_value is not None else 0.0 - - if abs(new_value - current_value) > 1e-6: # 避免浮点数比较问题 - # 同步计算 - 确保数据类型正确 - try: - calc_data = { - 'molecular_weight': float(data['molecular_weight']), - 'density_select': float(st.session_state.get('density_select', 0)) if (show_density and st.session_state.get('density_select')) else None - } - except (ValueError, TypeError): - st.error("化合物数据格式错误,无法进行计算") - return - - mmol_calc = mass_calc = volume_calc = 0.0 - - if field_name == 'mmol': - mmol_calc, mass_calc, volume_calc = sync_calculations( - calc_data, new_value, None, None, 'mmol' - ) - elif field_name == 'mass': - mmol_calc, mass_calc, volume_calc = sync_calculations( - calc_data, None, new_value, None, 'mass' - ) - elif field_name == 'volume': - mmol_calc, mass_calc, volume_calc = sync_calculations( - calc_data, None, None, new_value, 'volume' - ) - elif field_name == 'density': - # 密度变化时,如果已有质量,重新计算体积;如果已有体积,重新计算质量 - current_mass = st.session_state.mass_val - current_volume = st.session_state.volume_val - - if current_mass > 0: - # 根据质量重新计算体积 - mmol_calc, mass_calc, volume_calc = sync_calculations( - calc_data, None, current_mass, None, 'mass' - ) - elif current_volume > 0: - # 根据体积重新计算质量 - mmol_calc, mass_calc, volume_calc = sync_calculations( - calc_data, None, None, current_volume, 'volume' - ) - else: - return # 没有质量或体积数据,无需重新计算 - - # 更新session state - st.session_state.mmol_val = float(mmol_calc) if mmol_calc is not None else 0.0 - st.session_state.mass_val = float(mass_calc) if mass_calc is not None else 0.0 - st.session_state.volume_val = float(volume_calc) if volume_calc is not None else 0.0 - st.session_state.last_changed = field_name - - # 强制刷新页面以更新输入框的值 - if field_name != 'density': # 密度变化时不需要rerun,因为已经在密度输入处理中rerun了 - st.rerun() - except (ValueError, TypeError) as e: - st.error(f"数值转换错误: {str(e)}") - return - - # 密度显示选项 - show_density = False - if data['density_src']: - if is_liquid: - show_density = st.checkbox("显示密度信息", value=True) - else: - show_density = st.checkbox("显示密度信息", value=False) + return None - if show_density: - import re - - # 初始化密度值在session state中 - if 'density_select' not in st.session_state: - st.session_state.density_select = None - if 'density_input_value' not in st.session_state: - st.session_state.density_input_value = 0.0 - - density_data = data['density_src'] - # print(density_data) - - # 如果密度是列表且长度>1,让用户选择 - if isinstance(density_data, list) and len(density_data) > 1: - st.markdown("**选择密度数据:**") - - # 为每个密度选项提取数值并显示 - density_options = [] - density_values = [] - - for i, density_str in enumerate(density_data): - # 使用正则表达式提取密度数值 - match = re.search(r'\d*\.\d+', str(density_str)) - if match: - extracted_value = float(match.group()) - density_options.append(f"{extracted_value:.3f}: {density_str}") - density_values.append(extracted_value) - else: - density_options.append(f"0.000: {density_str} (无法提取数值)") - density_values.append(None) - - # 用户选择密度 - selected_index = st.selectbox( - "选择要使用的密度数据:", - range(len(density_options)), - format_func=lambda x: density_options[x], - key="density_selector" - ) - - # 获取选中的密度值 - if density_values[selected_index] is not None: - selected_density_value = density_values[selected_index] - st.session_state.density_select = selected_density_value - - # 显示并允许用户修改密度值 - st.markdown("**密度值 (可修改):**") - new_density = st.number_input( - "密度 (g/mL)", - min_value=0.0, - value=float(st.session_state.density_select), - step=0.001, - format="%.3f", - key="density_input", - help="选择的密度值,可以手动修改" - ) - - # 检测密度值变化 - if abs(new_density - st.session_state.density_input_value) > 1e-6: - st.session_state.density_select = new_density - st.session_state.density_input_value = new_density - # 更新compound_data中的密度值用于计算 - st.session_state.compound_data['density_select'] = new_density - handle_change('density', 1, 0) - st.rerun() - - else: - st.error("所选密度数据无法提取有效数值") - - # 如果密度是单个值或列表长度为1 - else: - try: - if isinstance(density_data, list): - density_str = str(density_data[0]) - else: - density_str = str(density_data) - - # 提取密度数值 - match = re.search(r'\d*\.\d+', density_str) - if match: - density_value = float(match.group()) - st.session_state.density_select = density_value - - # 显示并允许用户修改密度值 - st.markdown("**密度值 (可修改):**") - new_density = st.number_input( - "密度 (g/mL)", - min_value=0.0, - value=float(st.session_state.density_select), - step=0.001, - format="%.3f", - key="density_input_single", - help="提取的密度值,可以手动修改" - ) - - # 检测密度值变化 - if abs(new_density - st.session_state.density_input_value) > 1e-6: - st.session_state.density_select = new_density - st.session_state.density_input_value = new_density - # 更新compound_data中的密度值用于计算 - st.session_state.compound_data['density_select'] = new_density - handle_change('density', 1, 0) - st.rerun() - else: - st.error("无法从密度数据中提取有效数值") - except (ValueError, TypeError): - st.error("密度数据格式错误") + if compounds is not None and len(compounds) > 0: + return compounds[0] # 返回第一个匹配的化合物 + return None + except Exception as e: + st.error(f"搜索出错: {str(e)}") + return None + + +def get_structure_image(cid: int, width: int = 300, height: int = 300): + """ + 获取化合物的2D结构图 + + Args: + cid: PubChem CID + width: 图片宽度 + height: 图片高度 + + Returns: + url + """ + url = f"https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/{cid}/PNG?record_type=2d&image_size={width}x{height}" + return url + + +def extract_density_value(density_text: str) -> Optional[float]: + """ + 从密度文本中提取数值 + + Args: + density_text: 密度描述文本 + Returns: + 提取的密度数值或None + """ + # 使用正则表达式提取数字 + pattern = r'(\d*\.\d+|\d+\.\d*|\d+)' + matches = re.findall(pattern, density_text) + if matches: + try: + return float(matches[0]) + except ValueError: + return None + return None + + +def calculate_properties(molecular_weight: float, amount_mmol: Optional[float] = None, + mass_g: Optional[float] = None, volume_ml: Optional[float] = None, + density: Optional[float] = None): + """ + 计算用量、质量、体积之间的关系 + + Args: + molecular_weight: 分子量 (g/mol) + amount_mmol: 用量 (mmol) + mass_g: 质量 (g) + volume_ml: 体积 (mL) + density: 密度 (g/mL) + + Returns: + 计算结果字典 + """ + result = { + 'amount_mmol': amount_mmol, + 'mass_g': mass_g, + 'volume_ml': volume_ml + } + + # 如果有用量和分子量,计算质量 + if amount_mmol is not None and molecular_weight: + result['mass_g'] = amount_mmol * molecular_weight / 1000 + + # 如果有质量和分子量,计算用量 + if mass_g is not None and molecular_weight: + result['amount_mmol'] = mass_g * 1000 / molecular_weight + + # 如果有质量和密度,计算体积 + if result['mass_g'] is not None and density is not None and density > 0: + result['volume_ml'] = result['mass_g'] / density + + # 如果有体积和密度,计算质量 + if volume_ml is not None and density is not None and density > 0: + result['mass_g'] = volume_ml * density + result["amount_mmol"] = result['mass_g'] * 1000 / molecular_weight + + return result + + +def reaction_table_page(): + """反应表格页面""" + st.header("反应表格") + + with st.expander("**使用说明**", expanded=False): + st.markdown('''在下方的表格中,您可以输入反应数据,程序会尝试计算其余各项。当量为0时,该物质不参与当量计算。 +### 立即计算 + +选中`立即计算`选项后,输入后立即尝试计算修改部分,您可以实时查看计算结果。但请注意,请勿同时修改多项内容。如果输入某项后计算未正确进行,这是潜在的缺陷,请反馈。 + +不保证在您主动修改计算结果后程序能正确处理(例如,程序算出质量后您主动修改),通常这会伴随着警告,但也有可能遗漏某些情况。 + +### 延迟计算 + +不选中`立即计算`后,在完成输入后,您可以点击“计算”按钮,程序会尝试计算所有可用的结果。 + +程序在计算时会检查是否有冗余数据,例如同时给定分子量、用量、质量,并拒绝在这种情况下计算。您可以选中`不使用检查`选项来禁用检查,但请保证数据自洽(即使得所有已知量之间的关系成立),否则会发生未定义行为。 + +如果计算后您想要修改或补充内容再次计算,请选中`不使用检查`选项。 +''') + with st.expander("关于未定义行为"): + st.markdown('''程序内部计算顺序为 +1. n -> mass +2. mass -> n +3. mass -> volumn +4. volumn -> mass +5. mass -> n +6. eqiv -> n +7. n -> mass +8. mass -> volumn + +如果存在不自洽的数据,您可以通过上述内容预测程序行为。但这不应该在实际使用中依赖,因为在不同版本下实现可能发生变化。 + +''') + + # 初始化数据 + if 'reaction_data' not in st.session_state: + df = pd.DataFrame([[None,None,None,None,None,None,None,None]],columns=[ + "物质", + "分子量", + "当量", + "用量(mmol)", + "质量(g)", + "密度(g/mL)", + "体积(mL)", + "备注" + ],dtype="float") + df["物质"] = df["物质"].astype("string") + df["备注"] = df["备注"].astype("string") + st.session_state.reaction_data = df + + + st.info(f"💡 当量为0时,该物质不参与当量计算。") + use_on_change = st.checkbox("立即计算", value=True) + if not use_on_change: + st.checkbox("不使用检查", value=False,key="no_check",) + st.button("计算",on_click=calc_reaction_data,type="primary") + + # 使用data_editor创建可编辑表格 + edited_data = st.data_editor( + st.session_state.reaction_data, + num_rows="dynamic", + use_container_width=True, + column_config={ + "物质": st.column_config.TextColumn("物质", width="medium"), + "分子量": st.column_config.NumberColumn( + "分子量", + format="%.4f", + min_value=0.0, + step=0.0001 + ), + "当量": st.column_config.NumberColumn( + "当量", + format="%.2f", + min_value=0.0, + step=0.1, + help="当量为0时不参与当量计算" + ), + "用量(mmol)": st.column_config.NumberColumn( + "用量(mmol)", + format="%.3f", + min_value=0.0, + step=0.001 + ), + "质量(g)": st.column_config.NumberColumn( + "质量(g)", + format="%.6f", + min_value=0.0, + step=0.000001 + ), + "密度(g/mL)": st.column_config.NumberColumn( + "密度(g/mL)", + format="%.3f", + min_value=0.0, + step=0.001 + ), + "体积(mL)": st.column_config.NumberColumn( + "体积(mL)", + format="%.6f", + min_value=0.0, + step=0.000001 + ), + "备注": st.column_config.TextColumn("备注", width="medium") + }, + key="reaction_table", + on_change=recalculate_reaction_data if use_on_change else None + ) + + if not use_on_change and isinstance(edited_data, pd.DataFrame): + st.session_state.static_reaction_data = edited_data + + if st.session_state.get("reaction_table_refresh",0) == 2: + st.warning("发生多个编辑,无法计算。") + st.warning("由于计算失败,当前表格内容可能存在错误。") + st.session_state.reaction_table_refresh = 0 + + if st.session_state.get("reaction_table_refresh",0): + st.session_state.reaction_data = edited_data + st.session_state.reaction_table_refresh = 2 + st.rerun() + +def get_mass_safe(chemical_name: str) -> float: + try: + mass = molmass.Formula(chemical_name).mass + return mass + except Exception as e: + return float("nan") + +def calc_reaction_data(): + try: + df: pd.DataFrame = st.session_state.get("static_reaction_data",None) + if not isinstance(df, pd.DataFrame): + st.error("反应数据格式不正确") + raise ValueError("reaction_data must be a DataFrame") + df.columns = ["name","mw","eq","mol","mass","rho","vol","note"] + + # validate + if not st.session_state.get("no_check",False): + if any(df["mw"].notna() & df["mol"].notna() & df["mass"].notna()): + st.error("分子量、物质的量和质量不能同时存在") + raise ValueError + if any((df["mass"].notna() | (df["mw"].notna() & df["mol"].notna())) & df["vol"].notna() & df["rho"].notna()): + st.error("质量、体积和密度不能同时存在或可求") + raise ValueError + + fil = df["mw"].isna() & df["name"].notna() + df.loc[fil, "mw"] = df[fil]["name"].apply(get_mass_safe) + + # mol -> mass + fil = df["mw"].notna() & df["mol"].notna() + df.loc[fil, "mass"] = df[fil]["mol"] * df[f"{'mw'}"] / 1000.0 # mmol -> mol,再乘以 g/mol + + # mass -> mol + fil = df["mw"].notna() & df["mass"].notna() + df.loc[fil, "mol"] = df[fil]["mass"] * 1000.0 / df[f"{'mw'}"] # g -> mol,再除以 g/mol + + # mass -> vol + fil = df["mass"].notna() & df["rho"].notna() + df.loc[fil, "vol"] = df[fil]["mass"] / df[fil]["rho"] # g -> mL,再除以 g/mL + + # vol -> mass + fil = df["vol"].notna() & df["rho"].notna() + df.loc[fil, "mass"] = df[fil]["vol"] * df[fil]["rho"] # mL -> g,再乘以 g/mL + + # mass -> mol + fil = df["mw"].notna() & df["mass"].notna() + df.loc[fil, "mol"] = df[fil]["mass"] * 1000.0 / df[f"{'mw'}"] # g -> mol,再除以 g/mol + + eql = df[(df["eq"] > 0) & (df["mol"] > 0)] + if not st.session_state.get("no_check",False): + if eql.shape[0] > 1: + st.error("对于当量存在物质,只允许一个物质设置用量、质量或体积") + raise ValueError + if eql.shape[0] == 0 and not df[(df["eq"] > 0)].empty: + st.error("设置了当量,但是均没有设置用量、质量或体积") + raise ValueError + if not eql.empty: + ref = eql.iloc[0] + base = ref["mol"]/ref["eq"] + eqfil = df["eq"] > 0 + df.loc[eqfil, "mol"] = df[eqfil]["eq"] * base + + fil = df["mw"].notna() & eqfil + df.loc[fil, "mass"] = df[fil]["mol"] * df[f"{'mw'}"] / 1000.0 # mmol -> mol,再乘以 g/mol + + fil = df["rho"].notna() & df["mass"].notna() & eqfil + df.loc[fil, "vol"] = df[fil]["mass"] / df[fil]["rho"] # g -> mL,再除以 g/mL + + + + except Exception as e: + # if "df" in locals(): + # st.session_state.reaction_data = df + st.error("计算过程中出错,表格可能有误") + print("calc_reaction_data error:", traceback.format_exc()) + return + finally: + if "df" in locals(): + df.columns = [ + "物质", + "分子量", + "当量", + "用量(mmol)", + "质量(g)", + "密度(g/mL)", + "体积(mL)", + "备注" + ] + st.session_state.reaction_data = df + +def recalculate_reaction_data(): + """根据最近一次编辑的行及当量,推算其他未编辑行的用量,并更新质量/体积。""" + try: + edits = st.session_state.get("reaction_table") + df = st.session_state.get("reaction_data") + + # 基本校验 + if df is None or not isinstance(df, pd.DataFrame): + return + + # 仅当从 data_editor 拿到变更字典时才处理 + if not isinstance(edits, dict): + return + + # 处理新增/删除行(若有) + for new_row in edits.get("added_rows", []) or []: + # 对象列名对齐现有表头 + if isinstance(new_row, dict): + to_add = {col: new_row.get(col, None) for col in df.columns} + df = pd.concat([df, pd.DataFrame([to_add])], ignore_index=True) + for del_idx in edits.get("deleted_rows", []) or []: + try: + df.drop(index=int(del_idx), inplace=True) + except Exception: + pass + if (edits.get("deleted_rows") or []): + df.reset_index(drop=True, inplace=True) + + + edited_rows = edits.get("edited_rows", {}) or {} + if not edited_rows: + st.session_state.reaction_data = df + print("No edited rows found, skipping recalculation.") + return + if len(edited_rows) > 1: + st.session_state.reaction_table_refresh = 1 + return + + # 将编辑内容先写回到 DataFrame,记录“最后编辑的行”作为基准行 + edited_indices = [] + edited = {} + for idx_str, changes in edited_rows.items(): + try: + i = int(idx_str) + except Exception: + # 有些情况下索引就是 int + i = idx_str + edited_indices.append(i) + for col, val in changes.items(): + if col in df.columns: + df.loc[i, col] = val + edited[col] = val + # if col in ["用量(mmol)","质量(g)","体积(mL)","密度(g/mL)","当量"]: + if col == "当量": + if val != 0: + example = df[(df["当量"] > 0) & (df["用量(mmol)"] > 0)] + if example.size > 0: + j=0 + tmp = example.iloc[j] + while tmp.name == i: + j+=1 + tmp = example.iloc[j] + sing = tmp['用量(mmol)']/tmp["当量"] + edited["用量(mmol)"] = sing * edited["当量"] + + basis_idx = edited_indices[-1] # 以最后一条编辑为本次基准 + + + # 数值清洗工具 + def _to_float(x): + try: + if x is None: + return None + # 处理 NaN/空串 + try: + import pandas as _pd + if _pd.isna(x): + return None + except Exception: + pass + s = str(x).strip() + if s == "": + return None + return float(s) + except Exception: + return None + + # 基准行的自洽计算(用量/质量/体积) + + brow = df.loc[basis_idx] + + if "物质" in edited.keys() and pd.isna(brow["分子量"]) and "分子量" not in edited.keys(): + df.loc[basis_idx, "分子量"] = get_mass_safe(edited["物质"]) + + if "密度(g/mL)" in edited.keys(): + if _to_float(brow.get("体积(mL)")) is None and "质量(g)" in brow.keys(): + edited["质量(g)"] = _to_float(brow.get("质量(g)")) + elif _to_float(brow.get("质量(g)")) is None and "体积(mL)" in brow.keys(): + edited["体积(mL)"] = _to_float(brow.get("体积(mL)")) + else: + print(brow) + st.error("当质量和体积同时存在时,修改密度为未定义行为。") + raise ValueError + + if "分子量" in edited.keys(): + if all(pd.notna(brow[["质量(g)","用量(mmol)"]])): + st.error("当质量和用量同时存在时,修改分子量为未定义行为。") + raise ValueError + if pd.notna(brow.get("质量(g)")): + edited["质量(g)"] = _to_float(brow.get("质量(g)")) + if pd.notna(brow.get("用量(mmol)")): + edited["用量(mmol)"] = _to_float(brow.get("用量(mmol)")) + + + b_mw = _to_float(brow.get("分子量")) + b_density = edited.get("密度(g/mL)", _to_float(brow.get("密度(g/mL)"))) + b_amount = edited.get("用量(mmol)", None) + b_mass = edited.get("质量(g)", None) + b_volume = edited.get("体积(mL)", None) + b_eq = _to_float(brow.get("当量")) + + props = calculate_properties( + molecular_weight=b_mw if b_mw else 0, + amount_mmol=b_amount, + mass_g=b_mass, + volume_ml=b_volume, + density=b_density, + ) + + _v = props.get("amount_mmol") + if isinstance(_v, (int, float)): + df.at[basis_idx, "用量(mmol)"] = round(float(_v), 6) + _v = props.get("mass_g") + if isinstance(_v, (int, float)): + df.at[basis_idx, "质量(g)"] = round(float(_v), 6) + _v = props.get("volume_ml") + if isinstance(_v, (int, float)): + df.at[basis_idx, "体积(mL)"] = round(float(_v), 6) + + + # 基准行当量为 0 或不可用,则不进行当量联动计算 + if not (b_eq and b_eq > 0): + st.session_state.reaction_data = df + return + + b_amount_final = _to_float(df.at[basis_idx, "用量(mmol)"]) + if b_amount_final is None: + st.session_state.reaction_data = df + return + + base_per_eq = b_amount_final / b_eq + + + # 按当量推算其他“未编辑行”的用量,并据此计算质量/体积 + for j in range(len(df)): + if j == basis_idx: + continue + if j in edited_indices: + # 本次被用户直接修改的行不改动 + continue + eq_j = _to_float(df.at[j, "当量"]) if "当量" in df.columns else None + if not (eq_j and eq_j > 0): + continue + + amt_j = base_per_eq * eq_j + df.at[j, "用量(mmol)"] = round(amt_j, 6) + + mw_j = _to_float(df.at[j, "分子量"]) if "分子量" in df.columns else None + if mw_j: + mass_j = amt_j * mw_j / 1000.0 # mmol -> mol,再乘以 g/mol + df.at[j, "质量(g)"] = round(mass_j, 6) + + dens_j = _to_float(df.at[j, "密度(g/mL)"]) if "密度(g/mL)" in df.columns else None + if dens_j and dens_j > 0: + vol_j = mass_j / dens_j + df.at[j, "体积(mL)"] = round(vol_j, 6) + # 持久化 + st.session_state.reaction_data = df + except Exception as e: + # raise e + st.warning("重新计算反应数据时出错,表格可能有误") + print("recalculate_reaction_data error:", e) + +def add_compound_to_reaction(compound:PubChemCompound): + """将化合物添加到反应中""" + d = { + "物质":compound.formula, + "分子量":compound.exact_mass, + "当量":None, + "用量(mmol)":None, + "质量(g)":None, + "密度(g/mL)":st.session_state.get("custom_density",None), + "体积(mL)":None, + "备注":compound.name + } + st.session_state.reaction_data = pd.concat([st.session_state.reaction_data, pd.DataFrame([d])], ignore_index=True) + st.success("化合物已添加到反应中") + +def compound_search_page(): + """化合物搜索页面""" + # 输入区域 + + + with st.expander("**使用说明**"): + st.markdown(""" + 1. **选择搜索类型**: + - 名称: 输入化合物的常用名称或IUPAC名称 + - 化学式: 输入分子式 (如 C2H6O) + - SMILES: 输入SMILES字符串 (如 CCO) + + 2. **输入查询条件**: 在输入框中输入相应的查询词 + + 3. **点击搜索**: 系统将从PubChem数据库中查询匹配的化合物 + + 4. **查看结果**: + - 基本信息包括名称、化学式、分子量和2D结构图 + - 密度和熔沸点信息可在展开区域查看 + - 计算器可帮助您计算用量、质量和体积的关系 + + ### 示例查询 + - **名称**: ethanol, water, glucose + - **化学式**: C2H6O, H2O, C6H12O6 + - **SMILES**: CCO, O, C(C1C(C(C(C(O1)O)O)O)O)O + """) + + st.header("查询条件") + col1, col2 = st.columns([1, 2]) + + with col1: + # 选择搜索类型 + mp = {"name": "名称", "formula": "化学式", "smiles": "SMILES","calc":"化学式(本地计算)"} + search_type = st.selectbox( + "选择搜索类型", + mp.keys(), + format_func=lambda x: mp[x] + ) + + with col2: + # 输入搜索词 + query = st.text_input( + f"输入{mp[search_type]}", + placeholder="例如: ethanol, C2H6O, CCO" + ) + + search_button = st.button("🔍 搜索", type="primary") + + # 主要内容区域 + if search_button and query: + if search_type == "calc": + try: + mass = molmass.Formula(query).mass + st.session_state.compound = PubChemCompound(exact_mass=mass, formula=query) + except Exception as e: + st.error(f"计算分子量时出错: {e}") + else: + with st.spinner("正在搜索..."): + _compound = search_compound(query, search_type) + + print(_compound,search_type) + + if _compound is not None: + st.info("找到匹配的化合物,正在获取详细信息...") + # 在session_state中存储化合物信息 + additional_props = get_pubchem_properties(str(_compound.cid)) + st.session_state.compound = PubChemCompound(cast(pcp.Compound, _compound), additional_props) + + elif search_type == "formula": + try: + mass = molmass.Formula(query).mass + st.session_state.compound = PubChemCompound(exact_mass=mass, formula=query) + print(mass) + st.info("根据化学式计算得到分子量") + except Exception as e: + st.error(f"计算分子量时出错: {e}") + else: + st.error("未找到匹配的化合物,请检查输入并重试。") + + # 如果session_state中有化合物信息,显示结果 + if hasattr(st.session_state, 'compound') and st.session_state.compound: + compound = st.session_state.compound + st.button("添加到反应", on_click=add_compound_to_reaction, args=(compound,)) + + # 基本信息展示 + col1, col2 = st.columns(2) + + with col1: + st.header("📊 基本信息") + + st.metric("物质名称", compound.name or "未知") + st.metric("化学式", compound.formula or "未知") + st.metric("分子量", f"{compound.exact_mass:.4f} g/mol" if compound.exact_mass else "未知") + if compound.cid: + st.markdown(f"[**访问PubChem页面**](https://pubchem.ncbi.nlm.nih.gov/compound/{compound.cid})") + # 创建信息表格 + + + # st.table(info_data) + + with col2: + st.header("🖼️ 2D结构图") + if hasattr(compound, 'cid') and compound.cid: + structure_img = get_structure_image(compound.cid) + if structure_img: + st.image(structure_img, caption=f"CID: {compound.cid}") + else: + st.warning("无法获取结构图") + else: + st.warning("无CID信息,无法获取结构图") + + # 扩展信息 st.markdown("---") - # 计算器部分 - st.subheader("用量计算器") - - # 初始化值 - if 'mmol_val' not in st.session_state: - st.session_state.mmol_val = 0.0 - if 'mass_val' not in st.session_state: - st.session_state.mass_val = 0.0 - if 'volume_val' not in st.session_state: - st.session_state.volume_val = 0.0 - if 'last_changed' not in st.session_state: - st.session_state.last_changed = None - - # 创建响应式列布局 - density_select = st.session_state.get('density_select') - if show_density and density_select is not None: - calc_col1, calc_col2, calc_col3 = st.columns([1, 1, 1]) - else: - calc_col1, calc_col2 = st.columns([1, 1]) - calc_col3 = None - - - with calc_col1: - st.markdown("**物质的量**") - new_mmol = st.number_input( - "用量 (mmol)", - min_value=0.0, - value=float(st.session_state.mmol_val), - step=0.1, - format="%.3f", - key="mmol_input", - help="输入或计算得到的物质的量,单位:毫摩尔" - ) - - - # 检测mmol变化 - if st.session_state.last_changed != 'mmol': - handle_change('mmol', new_mmol, st.session_state.mmol_val) - - with calc_col2: - st.markdown("**质量**") - new_mass = st.number_input( - "质量 (g)", - min_value=0.0, - value=float(st.session_state.mass_val), - step=0.001, - format="%.3f", - key="mass_input", - help="输入或计算得到的质量,单位:克" - ) - - # 检测mass变化 - if st.session_state.last_changed != 'mass': - handle_change('mass', new_mass, st.session_state.mass_val) - - if calc_col3 is not None: - with calc_col3: - st.markdown("**体积**") - new_volume = st.number_input( - "体积 (mL)", - min_value=0.0, - value=float(st.session_state.volume_val), - step=0.01, - format="%.3f", - key="volume_input", - help="输入或计算得到的体积,单位:毫升" + # 密度信息 + if compound.density: + with st.expander("📏 密度信息", expanded=False): + st.subheader("可用密度数据:") + + # 初始化session_state中的密度选择 + if 'selected_density_idx' not in st.session_state: + st.session_state.selected_density_idx = 0 + if 'custom_density' not in st.session_state: + st.session_state.custom_density = None + + # 显示密度选项 + density_options = compound.density + selected_idx = st.radio( + "选择要使用的密度数据:", + range(len(density_options)), + format_func=lambda x: density_options[x], + key="density_radio", + index=st.session_state.selected_density_idx ) - # 检测volume变化 - if st.session_state.last_changed != 'volume': - handle_change('volume', new_volume, st.session_state.volume_val) - - # 重置last_changed状态 - st.session_state.last_changed = None + # 提取密度数值 + selected_density_text = density_options[selected_idx] + extracted_density = extract_density_value(selected_density_text) + + custom_density = st.number_input( + "密度值 (g/mL):", + value=extracted_density if extracted_density else 1.0, + # min_value=0.001, + # max_value=50.0, + step=0.001, + format="%.3f", + key="custom_density_input" + ) + st.session_state.custom_density = custom_density + st.session_state.selected_density_idx = selected_idx + else: + st.session_state.custom_density = None + # 熔沸点信息 + if compound.melting_point or compound.boiling_point: + with st.expander("🌡️ 熔沸点信息", expanded=False): + col1, col2 = st.columns(2) + + with col1: + st.subheader("熔点") + if compound.melting_point: + for mp in compound.melting_point: + st.write(f"• {mp}") + else: + st.warning("未找到熔点数据") + + with col2: + st.subheader("沸点") + if compound.boiling_point: + for bp in compound.boiling_point: + st.write(f"• {bp}") + else: + st.warning("未找到沸点数据") + + # 计算器 + st.markdown("---") + st.header("🧮 用量计算器") + + if compound.exact_mass: + # 初始化session_state中的计算器数值 + if 'calc_amount' not in st.session_state: + st.session_state.calc_amount = None + if 'calc_mass' not in st.session_state: + st.session_state.calc_mass = None + if 'calc_volume' not in st.session_state: + st.session_state.calc_volume = None + + col1, col2, col3 = st.columns(3) + + with col1: + amount_mmol = st.number_input( + "用量 (mmol)", + min_value=0.0, + value=st.session_state.calc_amount if st.session_state.calc_amount else 0.0, + step=0.1, + format="%.3f", + key="amount_input", + ) + + with col2: + mass_g = st.number_input( + "质量 (g)", + min_value=0.0, + value=st.session_state.calc_mass if st.session_state.calc_mass else 0.0, + step=0.001, + format="%.6f", + key="mass_input" + ) + + with col3: + # 只有在有密度数据时才显示体积输入 + if st.session_state.get('custom_density'): + volume_ml = st.number_input( + "体积 (mL)", + min_value=0.0, + value=st.session_state.calc_volume if st.session_state.calc_volume else 0.0, + step=0.001, + format="%.6f", + key="volume_input" + ) + else: + volume_ml = None + + # 检测哪个值发生了变化并重新计算 + current_values = { + 'amount': amount_mmol if amount_mmol!=st.session_state.calc_amount else None, + 'mass': mass_g if mass_g != st.session_state.calc_mass else None, + 'volume': volume_ml if volume_ml and volume_ml != st.session_state.calc_volume else None + } + + # 执行计算 + if any(current_values.values()): + density = st.session_state.get('custom_density') + results = calculate_properties( + molecular_weight=compound.exact_mass, + amount_mmol=current_values['amount'], + mass_g=current_values['mass'], + volume_ml=current_values['volume'], + density=density + ) + + # 更新session_state + st.session_state.calc_amount = results['amount_mmol'] + st.session_state.calc_mass = results['mass_g'] + st.session_state.calc_volume = results['volume_ml'] + print(f"计算结果: {results}") + st.rerun() + else: + st.warning("无分子量数据,无法进行计算") - # 清零按钮 - if st.button("清零所有数值", type="secondary"): - st.session_state.mmol_val = 0.0 - st.session_state.mass_val = 0.0 - st.session_state.volume_val = 0.0 - st.session_state.last_changed = None - st.rerun() - st.session_state.mmol_val = 0.0 - st.session_state.mass_val = 0.0 - st.session_state.volume_val = 0.0 - st.rerun() - else: - st.info("👆 请在左侧输入要查询的化学物质") + + +def main(): + st.set_page_config( + page_title="有机合成用量计算工具", + page_icon="🧪", + layout="wide" + ) + + # 侧边栏导航 + with st.sidebar: + page = st.radio( + "选择功能页面", + ["化合物查询", "反应表格"], + index=0 + ) + + # 根据选择显示不同页面 + if page == "化合物查询": + compound_search_page() + elif page == "反应表格": + reaction_table_page() + + +if __name__ == "__main__": + main() diff --git a/mw_tool/main_old.py b/mw_tool/main_old.py new file mode 100644 index 0000000..bcf1511 --- /dev/null +++ b/mw_tool/main_old.py @@ -0,0 +1,581 @@ +import streamlit as st +import pubchempy as pcp +from rdkit import Chem +from rdkit.Chem import rdMolDescriptors +import requests +from io import BytesIO + +st.set_page_config( + page_title="质量及密度查询", + layout="wide" +) + +# 初始化 session state +if 'compound_data' not in st.session_state: + st.session_state.compound_data = None + +def search_compound(query): + """搜索化合物信息""" + try: + compounds = None + try: + comp = Chem.MolFromSmiles(query) + if comp: + compounds = pcp.get_compounds(query, 'smiles', listkey_count=3) + except Exception: + st.error("使用smiles精确查询失败") + # 尝试通过化学式搜索 + if not (isinstance(compounds, list) and len(compounds) != 0): + # 尝试通过名称搜索 + try: + compounds = pcp.get_compounds(query, 'name', listkey_count=3) + except Exception: + st.error("使用名称查询失败") + + if not (isinstance(compounds, list) and len(compounds) != 0): + try: + compounds = pcp.get_compounds(query, 'formula', listkey_count=3) + except Exception: + st.error("使用化学式精确查询失败") + + if isinstance(compounds, list) and len(compounds) > 0: + st.info("成功查询物质基本信息,正在获取更多数据。") + return compounds[0] + else: + return None + except Exception as e: + st.error(f"搜索时发生错误: {str(e)}") + # raise e + return None + +def calculate_molecular_weight_from_smiles(smiles): + """从SMILES计算分子量""" + try: + mol = Chem.MolFromSmiles(smiles) + if mol: + return rdMolDescriptors.CalcExactMolWt(mol) + else: + return None + except Exception as e: + st.error(f"SMILES分子量计算错误: {str(e)}") + return None + + +def get_pubchem_properties(compound): + """从PubChem获取密度、熔点、沸点信息""" + try: + # 初始化返回数据 + properties = { + 'density': None, + 'melting_point': None, + 'boiling_point': None + } + + cid = compound.cid + + # 尝试获取物理化学性质相关的记录 + try: + url = f"https://pubchem.ncbi.nlm.nih.gov/rest/pug_view/data/compound/{cid}/JSON?heading=Experimental+Properties" + data = requests.get(url, timeout=3).json() + for section in data["Record"]["Section"]: + if section["TOCHeading"] == "Chemical and Physical Properties": + for sub in section["Section"]: + if sub["TOCHeading"] == "Experimental Properties": + for prop in sub["Section"]: + prop_heading = prop["TOCHeading"] + + if prop_heading == "Density" and not properties['density']: + # 可能有多条不同温度/浓度的记录,逐条返回 + properties['density'] = [ + info["Value"]["StringWithMarkup"][0]["String"] + for info in prop["Information"] + if "Value" in info and "StringWithMarkup" in info["Value"] + ] + + elif prop_heading == "Melting Point" and not properties['melting_point']: + properties['melting_point'] = [ + info["Value"]["StringWithMarkup"][0]["String"] + for info in prop["Information"] + if "Value" in info and "StringWithMarkup" in info["Value"] + ] + + elif prop_heading == "Boiling Point" and not properties['boiling_point']: + properties['boiling_point'] = [ + info["Value"]["StringWithMarkup"][0]["String"] + for info in prop["Information"] + if "Value" in info and "StringWithMarkup" in info["Value"] + ] + + return properties + + except Exception: + return properties + + except Exception as e: + # 静默处理异常,返回空的properties字典 + return { + 'density': None, + 'melting_point': None, + 'boiling_point': None + } + +def sync_calculations(compound_data, mmol=None, mass=None, volume=None, changed_field=None): + """同步计算mmol、质量、体积""" + if not compound_data: + return mmol, mass, volume + + # 确保数值类型转换 + try: + molecular_weight = float(compound_data.get('molecular_weight', 0)) + density_select = compound_data.get('density_select', None) + density = float(density_select) if density_select is not None else None + except (ValueError, TypeError): + st.error("分子量或密度数据格式错误,无法进行计算") + return mmol, mass, volume + + if molecular_weight == 0: + return mmol, mass, volume + + try: + if changed_field == 'mmol' and mmol is not None: + # 根据mmol计算质量 + mass = (mmol / 1000) * molecular_weight # mmol转mol再乘分子量 + # 如果有密度,计算体积 + if density and density > 0: + volume = mass / density + + elif changed_field == 'mass' and mass is not None: + # 根据质量计算mmol + mmol = (mass / molecular_weight) * 1000 # 质量除分子量得mol,再转mmol + # 如果有密度,计算体积 + if density and density > 0: + volume = mass / density + + elif changed_field == 'volume' and volume is not None and density and density > 0: + # 根据体积计算质量 + mass = volume * density + # 根据质量计算mmol + mmol = (mass / molecular_weight) * 1000 + + except Exception as e: + st.error(f"计算错误: {str(e)}") + + return mmol, mass, volume + +# 主界面 +col1, col2 = st.columns([1, 2]) + +with col1: + st.subheader("物质查询") + query = st.text_input("输入化学式、名称或SMILES:", placeholder="例如: H2O, water, CCO") + + # 添加直接计算分子量选项 + calc_mw_only = st.checkbox("仅计算分子量(不查询数据库)", help="勾选此项将跳过数据库查询,仅从SMILES计算分子量") + + if st.button("查询" if not calc_mw_only else "计算", type="primary"): + if query: + with st.spinner("正在处理..."): + # 如果选择仅计算分子量,直接从SMILES计算 + if calc_mw_only: + mol_weight = calculate_molecular_weight_from_smiles(query) + if mol_weight: + compound_data = { + 'name': "用户输入化合物", + 'formula': "从SMILES计算", + 'molecular_weight': mol_weight, + 'melting_point': None, + 'density_src': None, + 'melting_point_src': None, + 'boiling_point_src': None, + 'smiles': query, + "inchi": None, + 'found': False + } + st.session_state.compound_data = compound_data + st.success("分子量计算完成!") + else: + st.error("输入的SMILES格式无效") + st.session_state.compound_data = None + else: + # 原有的查询逻辑 + compound = search_compound(query) + + if compound is not None: + # 查询到化合物 + # 获取PubChem的物理化学性质信息 + pubchem_properties = get_pubchem_properties(compound) + + compound_data = { + 'name': compound.iupac_name, + 'cid': compound.cid, + 'formula': compound.molecular_formula, + 'molecular_weight': compound.molecular_weight, + "density_src": pubchem_properties['density'], + 'melting_point_src': pubchem_properties['melting_point'], + 'boiling_point_src': pubchem_properties['boiling_point'], + 'smiles': compound.canonical_smiles, + "inchi": compound.inchi if hasattr(compound, 'inchi') else None, + 'found': True, + } + + + st.session_state.compound_data = compound_data + + # 显示查询结果信息 + if compound_data['density_src'] or compound_data['melting_point_src'] or compound_data['boiling_point_src']: + properties_found = [] + if compound_data['density_src']: + properties_found.append("密度") + if compound_data['melting_point_src']: + properties_found.append("熔点") + if compound_data['boiling_point_src']: + properties_found.append("沸点") + st.success(f"查询成功!(找到{', '.join(properties_found)}信息)") + else: + st.success("查询成功!(未找到物理性质信息)") + + else: + # 未查询到,检查是否为SMILES + if query: + mol_weight = calculate_molecular_weight_from_smiles(query) + if mol_weight: + compound_data = { + 'name': "未知化合物", + 'formula': "从SMILES计算", + 'molecular_weight': mol_weight, + 'melting_point': None, + 'density_src': None, + 'melting_point_src': None, + 'boiling_point_src': None, + 'smiles': query, + "inchi": None, + 'found': False + } + st.session_state.compound_data = compound_data + st.warning("⚠️ 未在数据库中找到,但已从SMILES计算分子量") + else: + st.error("❌ 未找到该化合物,且SMILES格式无效") + st.session_state.compound_data = None + +with col2: + st.subheader("化合物信息") + + if st.session_state.compound_data: + data = st.session_state.compound_data + + # 显示基本信息 + info_col1, info_col2 = st.columns(2) + + with info_col1: + st.metric("物质名称", data['name']) + try: + molecular_weight_value = float(data['molecular_weight']) + st.metric("分子量 (g/mol)", f"{molecular_weight_value:.3f}") + except (ValueError, TypeError): + st.metric("分子量 (g/mol)", "数据格式错误") + + with info_col2: + st.metric("化学式", data['formula']) + if data.get("cid"): + st.markdown("### 其他数据") + st.page_link(f"https://pubchem.ncbi.nlm.nih.gov/compound/{data['cid']}",label="**访问PubChem**") + st.image(f"https://pubchem.ncbi.nlm.nih.gov/image/imgsrv.fcgi?cid={data['cid']}&t=s","结构式") + + # 添加熔沸点信息的展开区域 + if data.get('melting_point_src') or data.get('boiling_point_src'): + with st.expander("熔沸点信息", expanded=False): + col1, col2 = st.columns(2) + with col1: + if data.get('melting_point_src'): + st.markdown("### 熔点数据") + melting_data = data['melting_point_src'] + if isinstance(melting_data, list): + for i, mp in enumerate(melting_data, 1): + st.write(f"{i}. {mp}") + else: + st.write(melting_data) + with col2: + if data.get('boiling_point_src'): + st.markdown("### 沸点数据") + boiling_data = data['boiling_point_src'] + if isinstance(boiling_data, list): + for i, bp in enumerate(boiling_data, 1): + st.write(f"{i}. {bp}") + else: + st.write(boiling_data) + + # 判断是否为液体 + melting_data = data['melting_point_src'] + if isinstance(melting_data, list) and len(melting_data) > 0: + import re + melting_point = re.search(r'\d*\.\d+', melting_data[0]) + if melting_point: + melting_point = float(melting_point.group()) + + # 检测值变化并执行计算 + def handle_change(field_name, new_value, current_value): + try: + # 确保都转换为浮点数 + new_value = float(new_value) if new_value is not None else 0.0 + current_value = float(current_value) if current_value is not None else 0.0 + + if abs(new_value - current_value) > 1e-6: # 避免浮点数比较问题 + # 同步计算 - 确保数据类型正确 + try: + calc_data = { + 'molecular_weight': float(data['molecular_weight']), + 'density_select': float(st.session_state.get('density_select', 0)) if (show_density and st.session_state.get('density_select')) else None + } + except (ValueError, TypeError): + st.error("化合物数据格式错误,无法进行计算") + return + + mmol_calc = mass_calc = volume_calc = 0.0 + + if field_name == 'mmol': + mmol_calc, mass_calc, volume_calc = sync_calculations( + calc_data, new_value, None, None, 'mmol' + ) + elif field_name == 'mass': + mmol_calc, mass_calc, volume_calc = sync_calculations( + calc_data, None, new_value, None, 'mass' + ) + elif field_name == 'volume': + mmol_calc, mass_calc, volume_calc = sync_calculations( + calc_data, None, None, new_value, 'volume' + ) + elif field_name == 'density': + # 密度变化时,如果已有质量,重新计算体积;如果已有体积,重新计算质量 + current_mass = st.session_state.mass_val + current_volume = st.session_state.volume_val + + if current_mass > 0: + # 根据质量重新计算体积 + mmol_calc, mass_calc, volume_calc = sync_calculations( + calc_data, None, current_mass, None, 'mass' + ) + elif current_volume > 0: + # 根据体积重新计算质量 + mmol_calc, mass_calc, volume_calc = sync_calculations( + calc_data, None, None, current_volume, 'volume' + ) + else: + return # 没有质量或体积数据,无需重新计算 + + # 更新session state + st.session_state.mmol_val = float(mmol_calc) if mmol_calc is not None else 0.0 + st.session_state.mass_val = float(mass_calc) if mass_calc is not None else 0.0 + st.session_state.volume_val = float(volume_calc) if volume_calc is not None else 0.0 + st.session_state.last_changed = field_name + + # 强制刷新页面以更新输入框的值 + if field_name != 'density': # 密度变化时不需要rerun,因为已经在密度输入处理中rerun了 + st.rerun() + except (ValueError, TypeError) as e: + st.error(f"数值转换错误: {str(e)}") + return + + # 密度显示选项 + show_density = False + if data['density_src']: + show_density = st.checkbox("显示密度信息", value=False) + + if show_density: + import re + + # 初始化密度值在session state中 + if 'density_select' not in st.session_state: + st.session_state.density_select = None + if 'density_input_value' not in st.session_state: + st.session_state.density_input_value = 0.0 + + density_data = data['density_src'] + # print(density_data) + + # 如果密度是列表且长度>1,让用户选择 + if isinstance(density_data, list) and len(density_data) > 1: + st.markdown("**选择密度数据:**") + + # 为每个密度选项提取数值并显示 + density_options = [] + density_values = [] + + for i, density_str in enumerate(density_data): + # 使用正则表达式提取密度数值 + match = re.search(r'\d*\.\d+', str(density_str)) + if match: + extracted_value = float(match.group()) + density_options.append(f"{extracted_value:.3f}: {density_str}") + density_values.append(extracted_value) + else: + density_options.append(f"0.000: {density_str} (无法提取数值)") + density_values.append(None) + + # 用户选择密度 + selected_index = st.selectbox( + "选择要使用的密度数据:", + range(len(density_options)), + format_func=lambda x: density_options[x], + key="density_selector" + ) + + # 获取选中的密度值 + if density_values[selected_index] is not None: + selected_density_value = density_values[selected_index] + st.session_state.density_select = selected_density_value + + # 显示并允许用户修改密度值 + st.markdown("**密度值 (可修改):**") + new_density = st.number_input( + "密度 (g/mL)", + min_value=0.0, + value=float(st.session_state.density_select), + step=0.001, + format="%.3f", + key="density_input", + help="选择的密度值,可以手动修改" + ) + + # 检测密度值变化 + if abs(new_density - st.session_state.density_input_value) > 1e-6: + st.session_state.density_select = new_density + st.session_state.density_input_value = new_density + # 更新compound_data中的密度值用于计算 + st.session_state.compound_data['density_select'] = new_density + handle_change('density', 1, 0) + st.rerun() + + else: + st.error("所选密度数据无法提取有效数值") + + # 如果密度是单个值或列表长度为1 + else: + try: + if isinstance(density_data, list): + density_str = str(density_data[0]) + else: + density_str = str(density_data) + + # 提取密度数值 + match = re.search(r'\d*\.\d+', density_str) + if match: + density_value = float(match.group()) + st.session_state.density_select = density_value + + # 显示并允许用户修改密度值 + st.markdown("**密度值 (可修改):**") + new_density = st.number_input( + "密度 (g/mL)", + min_value=0.0, + value=float(st.session_state.density_select), + step=0.001, + format="%.3f", + key="density_input_single", + help="提取的密度值,可以手动修改" + ) + + # 检测密度值变化 + if abs(new_density - st.session_state.density_input_value) > 1e-6: + st.session_state.density_select = new_density + st.session_state.density_input_value = new_density + # 更新compound_data中的密度值用于计算 + st.session_state.compound_data['density_select'] = new_density + handle_change('density', 1, 0) + st.rerun() + else: + st.error("无法从密度数据中提取有效数值") + except (ValueError, TypeError): + st.error("密度数据格式错误") + + st.markdown("---") + + # 计算器部分 + st.subheader("用量计算器") + + # 初始化值 + if 'mmol_val' not in st.session_state: + st.session_state.mmol_val = 0.0 + if 'mass_val' not in st.session_state: + st.session_state.mass_val = 0.0 + if 'volume_val' not in st.session_state: + st.session_state.volume_val = 0.0 + if 'last_changed' not in st.session_state: + st.session_state.last_changed = None + + # 创建响应式列布局 + density_select = st.session_state.get('density_select') + if show_density and density_select is not None: + calc_col1, calc_col2, calc_col3 = st.columns([1, 1, 1]) + else: + calc_col1, calc_col2 = st.columns([1, 1]) + calc_col3 = None + + + with calc_col1: + st.markdown("**物质的量**") + new_mmol = st.number_input( + "用量 (mmol)", + min_value=0.0, + value=float(st.session_state.mmol_val), + step=0.1, + format="%.3f", + key="mmol_input", + help="输入或计算得到的物质的量,单位:毫摩尔" + ) + + + # 检测mmol变化 + if st.session_state.last_changed != 'mmol': + handle_change('mmol', new_mmol, st.session_state.mmol_val) + + with calc_col2: + st.markdown("**质量**") + new_mass = st.number_input( + "质量 (g)", + min_value=0.0, + value=float(st.session_state.mass_val), + step=0.001, + format="%.3f", + key="mass_input", + help="输入或计算得到的质量,单位:克" + ) + + # 检测mass变化 + if st.session_state.last_changed != 'mass': + handle_change('mass', new_mass, st.session_state.mass_val) + + if calc_col3 is not None: + with calc_col3: + st.markdown("**体积**") + new_volume = st.number_input( + "体积 (mL)", + min_value=0.0, + value=float(st.session_state.volume_val), + step=0.01, + format="%.3f", + key="volume_input", + help="输入或计算得到的体积,单位:毫升" + ) + + # 检测volume变化 + if st.session_state.last_changed != 'volume': + handle_change('volume', new_volume, st.session_state.volume_val) + + # 重置last_changed状态 + st.session_state.last_changed = None + + + # 清零按钮 + if st.button("清零所有数值", type="secondary"): + st.session_state.mmol_val = 0.0 + st.session_state.mass_val = 0.0 + st.session_state.volume_val = 0.0 + st.session_state.last_changed = None + st.rerun() + st.session_state.mmol_val = 0.0 + st.session_state.mass_val = 0.0 + st.session_state.volume_val = 0.0 + st.rerun() + + else: + st.info("请在左侧输入要查询的化学物质") diff --git a/mw_tool/requirements.txt b/mw_tool/requirements.txt index 69b682a..8332154 100644 --- a/mw_tool/requirements.txt +++ b/mw_tool/requirements.txt @@ -1,3 +1,5 @@ streamlit>=1.28.0 pubchempy>=1.0.4 -rdkit>=2022.9.5 +requests>=2.25.0 +Pillow>=8.0.0 +molmass \ No newline at end of file