import math from dataclasses import asdict, dataclass from typing import Dict, List, Optional, Tuple import gradio as gr import pandas as pd import plotly.express as px # External dependency: # pip install periodictable from periodictable import elements # ----------------------------- # Data extraction helpers # ----------------------------- NUMERIC_PROPS = [ ("mass", "Atomic mass (u)"), ("density", "Density (g/cm^3)"), ("electronegativity", "Pauling electronegativity"), ("boiling_point", "Boiling point (K)"), ("melting_point", "Melting point (K)"), ("vdw_radius", "van der Waals radius (pm)"), ("covalent_radius", "Covalent radius (pm)"), ] # Some curated quick facts. We'll augment with group-based facts so every element gets at least one. CURATED_FACTS: Dict[str, List[str]] = { "H": ["Lightest element; ~74% of the visible universe by mass is hydrogen in stars."], "He": ["Inert, used in cryogenics and balloons; second lightest element."], "Li": ["Batteries MVP: lithium-ion cells power phones and EVs."], "C": ["Backbone of life; diamond and graphite are pure carbon with wildly different properties."], "N": ["~78% of Earth's atmosphere is nitrogen (mostly N₂)."], "O": ["Essential for respiration; ~21% of Earth's atmosphere."], "Na": ["Sodium metal reacts violently with water—handle only under oil or inert gas."], "Mg": ["Burns with a bright white flame; used in flares and fireworks."], "Al": ["Light and strong; forms a protective oxide layer that resists corrosion."], "Si": ["Silicon is the basis of modern electronics—hello, semiconductors."], "Cl": ["Powerful disinfectant; elemental chlorine is toxic, compounds are widely useful."], "Ar": ["Argon is used to provide inert atmospheres for welding and 3D printing."], "Fe": ["Core of steel; iron is essential in hemoglobin for oxygen transport."], "Cu": ["Excellent electrical conductor; iconic blue-green patina (verdigris)."], "Ag": ["Highest electrical conductivity of all metals; historically used as currency."], "Au": ["Very unreactive ('noble'); prized for electronics and jewelry."], "Hg": ["Only metal that's liquid at room temperature; toxic—use with care."], "Pb": ["Dense and malleable; toxicity led to phase-out from gasoline and paints."], "U": ["Radioactive; used as nuclear reactor fuel (U-235)."], "Pu": ["Man-made in quantity; key in certain nuclear technologies."], "F": ["Most electronegative element; extremely reactive."], "Ne": ["Neon glows striking red-orange in discharge tubes—classic signs."], "Xe": ["Xenon makes bright camera flashes and high-intensity lamps."], } GROUP_FACTS = { "alkali": "Alkali metal: very reactive soft metal; forms +1 cations and reacts with water.", "alkaline-earth": "Alkaline earth metal: reactive (less than Group 1); forms +2 cations.", "transition": "Transition metal: often good catalysts, colorful compounds, multiple oxidation states.", "post-transition": "Post-transition metal: softer metals with lower melting points than transition metals.", "metalloid": "Metalloid: properties between metals and nonmetals; often semiconductors.", "nonmetal": "Nonmetal: tends to form covalent compounds; wide range of roles in biology and materials.", "halogen": "Halogen: very reactive nonmetals; form salts with metals and −1 oxidation state.", "noble-gas": "Noble gas: chemically inert under most conditions; monatomic gases.", "lanthanide": "Lanthanide: f-block rare earths; notable for magnets, lasers, and phosphors.", "actinide": "Actinide: radioactive f-block; includes nuclear fuel materials.", } # Map periodictable categories into the above buckets def classify_category(el) -> str: try: if el.block == "s" and el.group == 1 and el.number != 1: return "alkali" if el.block == "s" and el.group == 2: return "alkaline-earth" if el.block == "p" and el.group in (13, 14, 15, 16) and el.metallic: return "post-transition" if el.block == "d": return "transition" if el.block == "p" and el.group == 17: return "halogen" if el.block == "p" and el.group == 18: return "noble-gas" if el.block == "p" and not el.metallic: return "nonmetal" if el.block == "f" and 57 <= el.number <= 71: return "lanthanide" if el.block == "f" and 89 <= el.number <= 103: return "actinide" except Exception: pass return "nonmetal" if not getattr(el, "metallic", False) else "post-transition" # Build a dataframe of elements def build_elements_df() -> pd.DataFrame: rows = [] for Z in range(1, 119): el = elements[Z] if el is None: continue data = { "Z": el.number, "symbol": el.symbol, "name": el.name.title(), "period": getattr(el, "period", None), "group": getattr(el, "group", None), "block": getattr(el, "block", None), "mass": getattr(el, "mass", None), "density": getattr(el, "density", None), "electronegativity": getattr(el, "electronegativity", None), "boiling_point": getattr(el, "boiling_point", None), "melting_point": getattr(el, "melting_point", None), "vdw_radius": getattr(el, "vdw_radius", None), "covalent_radius": getattr(el, "covalent_radius", None), "category": classify_category(el), "is_radioactive": bool(getattr(el, "radioactive", False)), } rows.append(data) df = pd.DataFrame(rows).sort_values("Z").reset_index(drop=True) return df DF = build_elements_df() # Layout positions: group (1-18) x period (1-7); f-block as two rows MAX_GROUP = 18 MAX_PERIOD = 7 GRID: List[List[Optional[int]]] = [[None for _ in range(MAX_GROUP)] for _ in range(MAX_PERIOD)] for _, row in DF.iterrows(): period, group, Z = int(row["period"]), row["group"], int(row["Z"]) if group is None: continue GRID[period-1][group-1] = Z # f-block positions (lanthanides/actinides) - show in separate rows LAN = [z for z in DF["Z"] if 57 <= z <= 71] ACT = [z for z in DF["Z"] if 89 <= z <= 103] # ----------------------------- # UI callbacks # ----------------------------- def element_info(z_or_symbol: str): # Accept atomic number or symbol try: if z_or_symbol.isdigit(): Z = int(z_or_symbol) el = elements[Z] else: el = elements.symbol(z_or_symbol) Z = el.number except Exception: return f\"Unknown element: {z_or_symbol}\", None, None row = DF.loc[DF['Z'] == Z].iloc[0].to_dict() symbol = row['symbol'] # Build facts facts = [] facts.extend(CURATED_FACTS.get(symbol, [])) facts.append(GROUP_FACTS.get(row['category'], None)) facts = [f for f in facts if f] # Properties text props_lines = [ f\"{row['name']} ({symbol}), Z = {Z}\", f\"Period {int(row['period'])}, Group {row['group']}, Block {row['block']} | Category: {row['category'].replace('-', ' ').title()}\", f\"Atomic mass: {row['mass'] if row['mass'] else '—'} u\", f\"Density: {row['density'] if row['density'] else '—'} g/cm³\", f\"Electronegativity: {row['electronegativity'] if row['electronegativity'] else '—'} (Pauling)\", f\"Melting point: {row['melting_point'] if row['melting_point'] else '—'} K | Boiling point: {row['boiling_point'] if row['boiling_point'] else '—'} K\", f\"vdW radius: {row['vdw_radius'] if row['vdw_radius'] else '—'} pm | Covalent radius: {row['covalent_radius'] if row['covalent_radius'] else '—'} pm\", f\"Radioactive: {'Yes' if row['is_radioactive'] else 'No'}\", ] info_text = \"\\n\".join(props_lines) facts_text = \"\\n• \".join([\"Interesting facts:\"] + facts) if facts else \"No fact on file—still cool though!\" # Trend plot (Atomic number vs selected property) # We'll default to electronegativity if available, else mass. prop_key = 'electronegativity' if not pd.isna(row['electronegativity']) else 'mass' label = dict(NUMERIC_PROPS)[prop_key] trend_df = DF[['Z', 'symbol', prop_key]].dropna() fig = px.scatter( trend_df, x='Z', y=prop_key, hover_name='symbol', title=f'{label} across the periodic table', ) # Highlight selected element fig.add_scatter(x=[Z], y=[row[prop_key]] if row[prop_key] else [None], mode='markers+text', text=[symbol], textposition='top center') return info_text, facts_text, fig def handle_button_click(z: int): return element_info(str(z)) def search_element(query: str): query = (query or '').strip() if not query: return gr.update(), gr.update(), gr.update() return element_info(query) def heatmap(property_key: str): prop_label = dict(NUMERIC_PROPS)[property_key] # Create a pseudo-2D matrix for the s/p/d blocks (7x18) with property values import numpy as np grid_vals = np.full((MAX_PERIOD, MAX_GROUP), None, dtype=object) for r in range(MAX_PERIOD): for c in range(MAX_GROUP): z = GRID[r][c] if z is None: continue val = DF.loc[DF['Z'] == z, property_key].values[0] grid_vals[r, c] = val if not pd.isna(val) else None fig = px.imshow( grid_vals.astype(float), origin='upper', labels=dict(color=prop_label, x='Group', y='Period'), x=list(range(1, MAX_GROUP+1)), y=list(range(1, MAX_PERIOD+1)), title=f'Periodic heatmap: {prop_label}', aspect='auto', color_continuous_scale='Viridis' ) return fig # ----------------------------- # Build UI # ----------------------------- with gr.Blocks(title="Interactive Periodic Table", css=\"\"\" .button-cell {min-width: 40px; height: 40px; padding: 0.25rem; font-weight: 600;} .symbol {font-size: 0.95rem;} .small {font-size: 0.7rem; opacity: 0.8;} .grid {display: grid; grid-template-columns: repeat(18, 1fr); gap: 4px;} .fgrid {display: grid; grid-template-columns: repeat(15, 1fr); gap: 4px;} .header {text-align:center; font-weight:700; margin: 0.5rem 0;} \"\"\") as demo: gr.Markdown(\"# 🧪 Interactive Periodic Table\\nClick an element or search by symbol/name/atomic number.\") with gr.Row(): with gr.Column(scale=2): gr.Markdown(\"### Main Table\") main_buttons = [] with gr.Group(): with gr.Row(): gr.HTML('