AnveshAI-Edge-V2 / physics_engine.py
developeranveshraman's picture
Upload 26 files
bb1008e verified
Raw
History Blame Contribute Delete
59.4 kB
"""
Physics Engine β€” deterministic formula solver for common physics problems.
Supported domains:
Β· Kinematics : SUVAT equations (v=u+at, s=ut+Β½atΒ², vΒ²=uΒ²+2as)
Β· Dynamics : F=ma, weight, friction, Newton's laws
Β· Energy : KE=Β½mvΒ², PE=mgh, W=Fd, conservation of energy
Β· Power : P=W/t, P=Fv
Β· Momentum : p=mv, impulse=FΞ”t, conservation
Β· Circular : F=mvΒ²/r, a_c=vΒ²/r, Ο‰=v/r, T=2Ο€r/v
Β· Gravitation : F=Gm₁mβ‚‚/rΒ², g at altitude
Β· Waves : v=fΞ», T=1/f, E=hf (photon energy)
Β· Optics : Snell's law n₁sinθ₁=nβ‚‚sinΞΈβ‚‚, lens 1/f=1/v+1/u
Β· Electricity : V=IR, P=VI=IΒ²R=VΒ²/R, series/parallel R
Β· Thermodynamics: Q=mcΞ”T, PV=nRT, efficiency Ξ·=W/Q_h
· Fluid/Pressure: P=F/A, P=ρgh, ρ=m/V
Β· Relativity : time dilation, length contraction (conceptual)
All quantities extracted from natural language via regex.
Returns (success, result_string, formula_used).
"""
from __future__ import annotations
import re
import math
from typing import Optional
# ─────────────────────────────────────────────────────────────────────────────
# Physical constants
# ─────────────────────────────────────────────────────────────────────────────
G = 6.674e-11 # Gravitational constant (NΒ·mΒ²/kgΒ²)
g = 9.81 # Standard gravity (m/sΒ²)
h = 6.626e-34 # Planck constant (JΒ·s)
c = 3e8 # Speed of light (m/s)
R = 8.314 # Ideal gas constant (J/molΒ·K)
e = 1.602e-19 # Elementary charge (C)
k_B = 1.381e-23 # Boltzmann constant (J/K)
k_e = 8.987e9 # Coulomb constant (NΒ·mΒ²/CΒ²)
m_e = 9.109e-31 # Electron mass (kg)
m_p = 1.673e-27 # Proton mass (kg)
# ─────────────────────────────────────────────────────────────────────────────
# Unit conversion helpers
# ─────────────────────────────────────────────────────────────────────────────
def _km_h_to_m_s(v: float) -> float:
return v / 3.6
def _m_s_to_km_h(v: float) -> float:
return v * 3.6
def _deg_to_rad(deg: float) -> float:
return math.radians(deg)
# ─────────────────────────────────────────────────────────────────────────────
# Number extraction utilities
# ─────────────────────────────────────────────────────────────────────────────
def _num(text: str, *patterns: str) -> Optional[float]:
"""
Try each pattern in order; return the first matched float.
Patterns should contain one capture group for the number.
"""
for pat in patterns:
m = re.search(pat, text, re.IGNORECASE)
if m:
try:
return float(m.group(1).replace(',', ''))
except ValueError:
continue
return None
def _extract(text: str) -> dict[str, Optional[float]]:
"""
Extract all recognisable physical quantities from a natural-language string.
Returns a dict of quantity_name -> value (or None if not found).
"""
t = text.lower()
vals: dict[str, Optional[float]] = {}
# ── Kinematics ────────────────────────────────────────────────────────────
vals['initial_velocity'] = _num(t,
r'initial\s+(?:velocity|speed)\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'(?:starts?|begins?|initial)\s+(?:at|with)?\s*([\d.]+)\s*(?:m/s|km/h|mph)',
r'u\s*=\s*([\d.]+)',
r'(?:thrown|launched|projected|fired)\s+(?:\w+\s+)?at\s+([\d.]+)\s*(?:m/s|km/h)',
r'(?:decelerates?|slows?)\s+from\s+([\d.]+)\s*m/s',
)
if re.search(r'starts?\s+from\s+rest|initial\s+velocity\s+(?:is\s+)?zero|u\s*=\s*0'
r'|starting\s+from\s+rest|begins?\s+from\s+rest|from\s+rest\b', t):
vals['initial_velocity'] = 0.0
vals['final_velocity'] = _num(t,
r'final\s+(?:velocity|speed)\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'reaches?\s+(?:a\s+speed\s+of\s+|velocity\s+of\s+)?([\d.]+)\s*(?:m/s|km/h)',
r'v\s*=\s*([\d.]+)',
r'(?:decelerates?|slows?)\s+from\s+[\d.]+\s*m/s\s+to\s+([\d.]+)',
)
# "to rest" / "to 0 m/s" / "at maximum/max height" β†’ final_velocity = 0
if re.search(r'\bto\s+(?:a\s+)?(?:complete\s+)?(?:stop|rest)\b|to\s+0\s*(?:m/s)?\b'
r'|\bmax(?:imum)?\s+height\b|\bat\s+the\s+top\b', t):
vals['final_velocity'] = 0.0
vals['speed'] = _num(t,
r'(?:^|[^a-z])speed\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'(?:traveling|moving|travelling|goes?|drives?)\s+at\s+([\d.]+)\s*(?:m/s|km/h|mph|kph)?',
r'velocity\s+(?:of\s+|=\s*)([\d.]+)',
r'([\d.]+)\s*(?:km/h|kph|mph|m/s|ms-1)\b',
)
vals['acceleration'] = _num(t,
r'acceleration\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'accelerates?\s+at\s+([\d.]+)',
r'a\s*=\s*([\d.]+)',
r'([\d.]+)\s*m/s[Β²2]',
)
vals['time'] = _num(t,
r'(?:for|in|after|over|takes?|duration\s+of)\s+([\d.]+)\s*(?:s|sec(?:ond)?s?|h(?:ours?)?|min(?:utes?)?)\b',
r'time\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r't\s*=\s*([\d.]+)',
r'([\d.]+)\s*(?:seconds?|minutes?)\b',
)
vals['displacement'] = _num(t,
r'(?:displacement|distance)\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'travels?\s+([\d.]+)\s*(?:m|km|miles?|feet|ft)\b',
r'covers?\s+([\d.]+)\s*(?:m|km)',
r's\s*=\s*([\d.]+)',
r'([\d.]+)\s*(?:\bkm\b|\bm\b)\s*(?:away|far|distant)',
r'(?:over|through|across|along)\s+([\d.]+)\s*m\b',
r'(?:over|through|across|along)\s+([\d.]+)\s*km\b',
r'd\s*=\s*([\d.]+)',
)
# Unit correction β€” convert km to m for displacement if needed
if vals['displacement'] is not None:
m_km = re.search(r'(?:travels?|covers?|distance\s+of)\s+([\d.]+)\s*km\b', t)
if m_km:
vals['displacement'] = float(m_km.group(1)) * 1000
# ── Mass / weight ──────────────────────────────────────────────────────────
vals['mass'] = _num(t,
r'mass\s+(?:of\s+|=\s*|:?\s*)([\d.]+)\s*(?:kg|g|lb|pounds?)?',
r'([\d.]+)\s*kg\b',
r'm\s*=\s*([\d.]+)',
r'(?:weighs?|weight)\s+(?:of\s+|=\s*)?([\d.]+)\s*kg',
)
# Convert g β†’ kg
m_grams = re.search(r'mass\s+(?:of\s+|=\s*)?([\d.]+)\s*g\b', t)
if m_grams and vals['mass'] is None:
vals['mass'] = float(m_grams.group(1)) / 1000
# ── Force ─────────────────────────────────────────────────────────────────
vals['force'] = _num(t,
r'force\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'F\s*=\s*([\d.]+)',
r'([\d.]+)\s*(?:N|newtons?)\b',
)
# ── Energy ────────────────────────────────────────────────────────────────
vals['height'] = _num(t,
r'height\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'h\s*=\s*([\d.]+)',
r'([\d.]+)\s*m\s+(?:high|above|tall)',
r'(?:drops?|falls?|raised?)\s+(?:from\s+|by\s+)?([\d.]+)\s*m',
r'at\s+([\d.]+)\s*m\b',
)
vals['kinetic_energy'] = _num(t,
r'kinetic\s+energy\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'KE\s*=\s*([\d.]+)',
r'k\.?e\.?\s*=\s*([\d.]+)',
)
vals['potential_energy'] = _num(t,
r'(?:potential|gravitational\s+potential)\s+energy\s+(?:of\s+|=\s*)?([\d.]+)',
r'PE\s*=\s*([\d.]+)',
r'p\.?e\.?\s*=\s*([\d.]+)',
)
vals['work'] = _num(t,
r'work\s+(?:done|of)\s+(?:of\s+|=\s*|:?\s*)?([\d.]+)',
r'W\s*=\s*([\d.]+)',
r'([\d.]+)\s*(?:J|joules?)\b',
)
vals['power'] = _num(t,
r'power\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'P\s*=\s*([\d.]+)',
r'([\d.]+)\s*(?:W|watts?|kW)\b',
)
# ── Waves ─────────────────────────────────────────────────────────────────
vals['frequency'] = _num(t,
r'frequency\s+(?:of\s+|=\s*|:?\s*)([\d.e\-]+)',
r'f\s*=\s*([\d.e\-]+)',
r'([\d.]+)\s*(?:Hz|hertz|kHz|MHz|GHz)\b',
)
# Unit conversion for frequency
hz_m = re.search(r'([\d.]+)\s*kHz\b', t)
if hz_m and vals['frequency'] is None:
vals['frequency'] = float(hz_m.group(1)) * 1e3
hz_m = re.search(r'([\d.]+)\s*MHz\b', t)
if hz_m and vals['frequency'] is None:
vals['frequency'] = float(hz_m.group(1)) * 1e6
vals['wavelength'] = _num(t,
r'wavelength\s+(?:of\s+|=\s*|:?\s*)([\d.e\-]+)',
r'Ξ»\s*=\s*([\d.e\-]+)',
r'([\d.e\-]+)\s*(?:nm|Β΅m|ΞΌm|mm|cm)\s+(?:wave|light)',
)
# Unit conversions for wavelength (always apply to convert to metres)
nm_m = re.search(r'([\d.]+)\s*nm\b', t)
um_m = re.search(r'([\d.]+)\s*[¡μ]m\b', t)
mm_m = re.search(r'([\d.]+)\s*mm\b', t)
if nm_m:
vals['wavelength'] = float(nm_m.group(1)) * 1e-9
elif um_m:
vals['wavelength'] = float(um_m.group(1)) * 1e-6
elif mm_m:
vals['wavelength'] = float(mm_m.group(1)) * 1e-3
vals['period'] = _num(t,
r'period\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'T\s*=\s*([\d.]+)',
)
vals['wave_speed'] = _num(t,
r'wave\s+speed\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'speed\s+(?:of\s+(?:the\s+)?(?:wave|sound|light)\s+)?(?:of\s+|=\s*|:?\s*)([\d.]+)\s*m/s',
r'speed\s+(?:is\s+)?([\d.]+)\s*m/s',
)
# ── Friction ──────────────────────────────────────────────────────────────
mu_m = re.search(r'(?:ΞΌ|mu)\s*=\s*([\d.]+)', t)
coeff_m = re.search(
r'coefficient\s+(?:of\s+(?:friction|kinetic|static)\s+)?(?:ΞΌ\s*=\s*|mu\s*=\s*|=\s*)?([\d.]+)', t
)
if mu_m:
vals['friction_coefficient'] = float(mu_m.group(1))
elif coeff_m:
vals['friction_coefficient'] = float(coeff_m.group(1))
else:
vals['friction_coefficient'] = None
# ── Electricity ───────────────────────────────────────────────────────────
vals['voltage'] = _num(t,
r'voltage\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'potential\s+difference\s+(?:of\s+|=\s*)?([\d.]+)',
r'V\s*=\s*([\d.]+)',
r'([\d.]+)\s*(?:V|volts?)\b',
)
vals['current'] = _num(t,
r'current\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'I\s*=\s*([\d.]+)',
r'([\d.]+)\s*(?:A|amps?|amperes?)\b',
)
vals['resistance'] = _num(t,
r'resistance\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'R\s*=\s*([\d.]+)',
r'([\d.]+)\s*(?:Ξ©|ohm|ohms?)\b',
)
# ── Thermodynamics ────────────────────────────────────────────────────────
vals['temperature_change'] = _num(t,
r'temperature\s+(?:change|rise|drop|difference|increases?\s+by|decreases?\s+by)\s+(?:of\s+|=\s*)?([\d.]+)',
r'Ξ”T\s*=\s*([\d.]+)',
r'delta\s*T\s*=\s*([\d.]+)',
r'changes?\s+(?:by|from\s+[\d.]+\s+to\s+[\d.]+)\s*([\d.]+)',
)
# Ξ”T from "from X to Y"
if vals['temperature_change'] is None:
m_range = re.search(r'from\s+([\d.]+)\s*[°℃C]?\s+to\s+([\d.]+)\s*[°℃C]?', t)
if m_range:
vals['temperature_change'] = abs(float(m_range.group(2)) - float(m_range.group(1)))
vals['specific_heat'] = _num(t,
r'specific\s+heat\s+(?:capacity\s+)?(?:of\s+|=\s*|:?\s*)?([\d.]+)',
r'c\s*=\s*([\d.]+)',
r'([\d.]+)\s*J/\(?kg[Β·.Β·]?K?\)?',
)
vals['heat'] = _num(t,
r'heat\s+(?:energy\s+)?(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'Q\s*=\s*([\d.]+)',
r'([\d.]+)\s*(?:kJ|kcal)', # will convert below
)
# ── Pressure / Fluid ──────────────────────────────────────────────────────
vals['pressure'] = _num(t,
r'pressure\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'P\s*=\s*([\d.]+)',
r'([\d.]+)\s*(?:Pa|pascal|atm|bar|kPa)\b',
)
vals['area'] = _num(t,
r'(?:cross[\-\s]?sectional\s+)?area\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'A\s*=\s*([\d.]+)',
r'([\d.]+)\s*m[Β²2]',
)
vals['density'] = _num(t,
r'density\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'ρ\s*=\s*([\d.]+)',
r'([\d.]+)\s*kg/m[Β³3]',
)
vals['volume'] = _num(t,
r'volume\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'V\s*=\s*([\d.]+)',
r'([\d.]+)\s*(?:m[Β³3]|L|liters?|litres?)',
)
# ── Optics ────────────────────────────────────────────────────────────────
vals['angle'] = _num(t,
r'angle\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'ΞΈ\s*=\s*([\d.]+)',
r'([\d.]+)\s*Β°',
)
vals['refractive_index_1'] = _num(t,
r'n[_₁1]\s*=\s*([\d.]+)',
r'refractive\s+index\s+(?:of\s+)?(?:medium\s+)?1\s*[=:]\s*([\d.]+)',
)
vals['refractive_index_2'] = _num(t,
r'n[_β‚‚2]\s*=\s*([\d.]+)',
r'refractive\s+index\s+(?:of\s+)?(?:medium\s+)?2\s*[=:]\s*([\d.]+)',
)
vals['radius'] = _num(t,
r'radius\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'r\s*=\s*([\d.]+)',
)
vals['focal_length'] = _num(t,
r'focal\s+length\s+(?:of\s+|=\s*|:?\s*)([\d.]+)',
r'f\s*=\s*([\d.]+)\s*(?:cm|m)',
)
vals['object_distance'] = _num(t,
r'object\s+distance\s+(?:of\s+|=\s*|:?\s*)?([\d.]+)',
r'u\s*=\s*([\d.]+)',
)
vals['image_distance'] = _num(t,
r'image\s+distance\s+(?:of\s+|=\s*|:?\s*)?([\d.]+)',
r'(?:v|image)\s*=\s*([\d.]+)',
)
# ── Spring ────────────────────────────────────────────────────────────────
vals['spring_constant'] = _num(t,
r'spring\s+constant\s+(?:k\s*=\s*|of\s*|=\s*)?([\d.]+)',
r'\bk\s*=\s*([\d.]+)\s*N/m',
r'([\d.]+)\s*N/m\b',
)
vals['spring_compression'] = _num(t,
r'compressed?\s+(?:by\s+)?([\d.]+)\s*m',
r'extended?\s+(?:by\s+)?([\d.]+)\s*m',
r'stretch(?:ed)?\s+(?:by\s+)?([\d.]+)\s*m',
r'\bx\s*=\s*([\d.]+)',
)
# ── Electrostatics ────────────────────────────────────────────────────────
# Extract charges β€” look for q= or Β΅C / microC / C values
# preprocess converts Β΅C β†’ microC, so match both
UC_PAT = r'[¡μ]C\b|micro\s*C\b'
q_vals = re.findall(
r'(?:q[₁₂12]?\s*=\s*)?([\d.e\-]+)\s*(?:[¡μ]C\b|micro\s*C\b)', t, re.I
)
is_microcoulomb = bool(re.search(UC_PAT, t, re.I))
if not q_vals:
q_vals = re.findall(r'(?:q[₁₂12]?\s*=\s*)?([\d.e\-]+)\s*C\b', t, re.I)
is_microcoulomb = False
if q_vals:
scale = 1e-6 if is_microcoulomb else 1.0
vals['charge1'] = float(q_vals[0]) * scale
vals['charge2'] = float(q_vals[1]) * scale if len(q_vals) > 1 else None
else:
vals['charge1'] = None
vals['charge2'] = None
# Single charge for magnetic force
q_m = re.search(r'(?:charge\s+)?q\s*=\s*([\d.e\-]+)\s*([¡μm]?C|micro\s*C)', t, re.I)
if q_m:
val_q = float(q_m.group(1))
unit = q_m.group(2).lower().replace(' ', '')
if unit in ('Β΅c', 'ΞΌc', 'mc', 'microc'):
val_q *= 1e-6
vals['charge'] = val_q
vals['magnetic_field'] = _num(t,
r'(?:magnetic\s+field|B)\s*=\s*([\d.e\-]+)',
r'([\d.e\-]+)\s*T\b',
)
vals['separation'] = _num(t,
r'([\d.]+)\s*m\s+apart',
r'apart\s+(?:by\s+)?([\d.]+)\s*m',
r'separation\s+(?:of\s+|=\s*)?([\d.]+)',
r'placed\s+([\d.]+)\s*m\b',
r'distance\s+(?:between|of)\s+([\d.]+)\s*m',
)
# ── Launch angle ──────────────────────────────────────────────────────────
ang_m = re.search(
r'(?:at|angle\s+of|angle)\s+([\d.]+)\s*degree|'
r'([\d.]+)\s*Β°\s*(?:to\s+(?:the\s+)?horizontal|above|below)',
t, re.I
)
if ang_m:
vals['launch_angle'] = float(ang_m.group(1) or ang_m.group(2))
# ── Lines per mm (diffraction) ─────────────────────────────────────────────
lpm_m = re.search(r'([\d.]+)\s*lines?/mm', t, re.I)
if lpm_m:
vals['lines_per_mm'] = float(lpm_m.group(1))
# ── Diffraction order ─────────────────────────────────────────────────────
ord_m = re.search(r'(\d+)(?:st|nd|rd|th)\s+order', t, re.I)
if ord_m:
vals['diffraction_order'] = int(ord_m.group(1))
# Speed in km/h β†’ convert to m/s if needed (common for kinematics)
kmh_m = re.search(r'([\d.]+)\s*km/h\b', t)
if kmh_m:
v_kmh = float(kmh_m.group(1))
# If no speed/velocity set yet in m/s, use km/h converted
if vals['speed'] is None and vals['initial_velocity'] is None:
vals['speed'] = _km_h_to_m_s(v_kmh)
vals['_speed_kmh'] = v_kmh # preserve original for display
return vals
# ─────────────────────────────────────────────────────────────────────────────
# Question-type detector
# ─────────────────────────────────────────────────────────────────────────────
def _detect_question_type(text: str) -> str:
"""Return the type of physics problem to solve."""
I = re.IGNORECASE
# Ordered from most specific to most general
if re.search(r'\bsnell|\brefract|\brefractive\s+index\b|n₁|n1\s*sin|critical\s+angle', text, I):
return 'snell'
if re.search(r'\blens\b|\bmirror\b|focal\s+length|1/v\s*\+\s*1/u|object\s+distance|image\s+distance', text, I):
return 'lens'
if re.search(r'\bohm\b|resistance|resistor|V\s*=\s*IR|series\s+circuit|parallel\s+circuit', text, I):
return 'ohm'
if re.search(r'\bvoltage\b|\bcurrent\b', text, I) and re.search(r'\bpower\b|\bwatt', text, I):
return 'electric_power'
if re.search(r'\bphoton\b|\bE\s*=\s*hf|\bPlanck\b', text, I):
return 'photon'
# ── Specific types that must come before generic energy/kinematics ─────────
if re.search(r'\bescape\s+velocity\b|\bescape\s+speed\b', text, I):
return 'escape_velocity'
if re.search(r'\bde\s+broglie\b|\bmatter\s+wave\b|\bwavelength\s+of\s+(?:an?\s+)?electron\b'
r'|\bwavelength\s+of\s+(?:a\s+)?(?:proton|neutron|particle)\b', text, I):
return 'de_broglie'
if re.search(r'\bmoment\s+of\s+inertia\b|\brotational\s+inertia\b', text, I):
return 'moment_of_inertia'
if re.search(r'\bcoulomb\b|\belectric\s+force\b|\bforce\s+between\s+(?:two\s+)?charges\b'
r'|\btwo\s+charges?\b|\bcharges?\b.*\bplaced\b|\bF\s*=\s*kq'
r'|\b[¡μ]C\b|\bmicro\s*C\b', text, I):
return 'coulomb'
if re.search(r'\bmagnetic\s+force\b|\bLorentz\s+force\b|\bF\s*=\s*qvB\b'
r'|\bcharge.*moving.*(?:magnetic|field)\b|\bparticle.*magnetic\s+field\b', text, I):
return 'magnetic_force'
if re.search(r'\bdiffraction\s+grating\b|\blines\s+per\s+mm\b|\bgrating\s+spacing\b', text, I):
return 'diffraction'
if re.search(r'\belastic\s+potential\b|\bspring\s+PE\b|\bspring\b.*\benergy\b'
r'|\bcompressed?\s+spring\b|\benergy\s+(?:stored\s+)?in\s+(?:a\s+)?spring\b', text, I):
return 'spring_energy'
if re.search(r'\bgravitational\s+potential\s+energy\s+between\b'
r'|\battraction\s+between\s+two\s+masses\b', text, I):
return 'gravitational_pe'
if re.search(r'\bprojectile\b|\bthrown\b.*\d+\s*degree'
r'|\bfired\s+at\s+(?:\d+\s*degrees?|an?\s+angle)\b'
r'|\blaunched\s+at\s+(?:\d+\s*degrees?|an?\s+angle)\b'
r'|\brange\s+of\s+(?:a\s+)?(?:projectile|ball|object)\b', text, I):
return 'projectile'
# ── Generic energy / dynamics ──────────────────────────────────────────────
if re.search(r'\bkinetic\s+energy\b|\bKE\b|\bΒ½mvΒ²|half\s+mv\s+squared', text, I):
return 'kinetic_energy'
if re.search(r'\bpotential\s+energy\b|\bGPE\b|\bPE\b|\bmgh\b|\bgravitational\s+(?:potential\s+)?energy', text, I):
return 'potential_energy'
if re.search(r'\bwork\s+done\b|\bwork\s*=|\bW\s*=\s*Fd\b|\benergy\s+transferred'
r'|\bforce\s+(?:of\s+[\d.]+\s*N\s+)?applied\s+over\s+a?\s*distance', text, I):
return 'work'
if re.search(r'\bpower\b|\bwatts?\b', text, I) and re.search(r'\bwork\b|\btime\b|\bforce\b|\bvelocity\b', text, I):
return 'power'
if re.search(r'\bmomentum\b|\bimpulse\b|\bp\s*=\s*mv', text, I):
return 'momentum'
if re.search(r'\bcentripetal\b|\bcircular\s+motion\b|\borbiting\b|\borbital\b', text, I):
return 'circular'
if re.search(r'\bgravitation|\borbital\s+speed|\bNewton.s\s+law\s+of\s+gravit|\bGm', text, I):
return 'gravitation'
if re.search(r'\bwavelength\b|\bfrequency\b|\bwave\s+speed\b|\bperiod\b|\bv\s*=\s*f', text, I):
return 'waves'
if re.search(r'\bheat\b|\bspecific\s+heat|\bthermal\s+energy\b|Q\s*=\s*mc|[ΔδΔ]T|delta\s*T', text, I):
return 'heat'
if re.search(r'\bpressure\b|\bforce\s+per\s+unit\s+area\b|P\s*=\s*F/A', text, I):
return 'pressure'
if re.search(r'\bdensity\b|\bρ\b|\brho\b', text, I):
return 'density'
if re.search(r'\bfluid\s+pressure\b|\bhydrostatic\b|\bdepth\b|ρgh', text, I):
return 'fluid_pressure'
if re.search(r'F\s*=\s*ma|\bnewton.s\s+second\b|\bnet\s+force\b', text, I):
return 'force'
if re.search(r'\bacceleration\b', text, I) and re.search(r'\bforce\b|\bmass\b', text, I):
return 'force'
if re.search(r'\bweight\b', text, I) and re.search(r'\bmass\b|g\s*=|\bgravit', text, I):
return 'weight'
if re.search(r'\bfriction\b|\bcoefficient\b|\bmu\s*=|\bΞΌ\s*=', text, I):
return 'friction'
if re.search(r'\bkinematic|\bsuvat\b|\bdisplacement\b|\baccelerat|\bvelocity\b|\bspeed\b'
r'|\bthrown\b|\bdecelerat|\bmax(?:imum)?\s+height\b|\bfrom\s+rest\b', text, I):
return 'kinematics'
return 'unknown'
# ─────────────────────────────────────────────────────────────────────────────
# Formula solvers
# ─────────────────────────────────────────────────────────────────────────────
def _solve_kinematics(v: dict, text: str) -> tuple[bool, str]:
t = text.lower()
u = v.get('initial_velocity')
vf = v.get('final_velocity')
a = v.get('acceleration')
s = v.get('displacement')
t_ = v.get('time')
# Infer a = -g for vertical throw / projectile with no explicit acceleration
if a is None and re.search(r'\bthrown\b|\bprojectile\b|\bfired\s+(?:up|vert)|\blaunched\s+(?:up|vert)'
r'|\bdropped\b|\bfalls?\b|\bfalling\b', t):
a = -g # upward positive convention
# Determine what is being asked
asking_velocity = bool(re.search(r'find\s+(?:the\s+)?(?:final\s+)?velocity|what\s+is\s+(?:the\s+)?(?:final\s+)?velocity|speed\s+after', t))
asking_distance = bool(re.search(r'how\s+far|find\s+(?:the\s+)?distance|distance\s+travel|displacement', t))
asking_time = bool(re.search(r'how\s+long|time\s+taken|when\s+does|find\s+(?:the\s+)?time', t))
asking_acceleration = bool(re.search(r'find\s+(?:the\s+)?acceleration|what\s+is\s+(?:the\s+)?acceleration', t))
# Use speed as initial_velocity if not set
if u is None:
u = v.get('speed')
results = []
# v = u + at
if u is not None and a is not None and t_ is not None:
vf_calc = u + a * t_
results.append(f"v = u + at = {u} + {a}Γ—{t_} = {vf_calc:.4g} m/s")
# s = ut + Β½atΒ²
if u is not None and a is not None and t_ is not None:
s_calc = u * t_ + 0.5 * a * t_**2
results.append(f"s = ut + Β½atΒ² = {u}Γ—{t_} + Β½Γ—{a}Γ—{t_}Β² = {s_calc:.4g} m")
# vΒ² = uΒ² + 2as
if u is not None and a is not None and s is not None:
v2 = u**2 + 2 * a * s
if v2 >= 0:
vf_calc = math.sqrt(v2)
results.append(f"vΒ² = uΒ² + 2as β†’ v = √({u}Β² + 2Γ—{a}Γ—{s}) = {vf_calc:.4g} m/s")
# Solve for time: s = ut + Β½atΒ² β†’ quadratic in t
if s is not None and u is not None and a is not None and t_ is None:
# atΒ²/2 + ut - s = 0
A_, B_, C_ = 0.5 * a, u, -s
disc = B_**2 - 4 * A_ * C_
if disc >= 0 and A_ != 0:
t1 = (-B_ + math.sqrt(disc)) / (2 * A_)
t2 = (-B_ - math.sqrt(disc)) / (2 * A_)
t_pos = [t for t in [t1, t2] if t >= 0]
if t_pos:
results.append(f"Time: t = {min(t_pos):.4g} s")
elif A_ == 0 and B_ != 0:
results.append(f"Time: t = s/u = {s}/{u} = {s/u:.4g} s")
# Simple d = vt (constant speed, no acceleration)
if s is None and (u is not None or v.get('speed') is not None) and t_ is not None and a is None:
spd = u if u is not None else v.get('speed')
s_calc = spd * t_
results.append(f"s = vΓ—t = {spd}Γ—{t_} = {s_calc:.4g} m")
# d = vt β†’ t
if s is not None and (u is not None or v.get('speed') is not None) and t_ is None and a is None:
spd = u if u is not None else v.get('speed')
t_calc = s / spd
results.append(f"t = s/v = {s}/{spd} = {t_calc:.4g} s")
# a = (v - u) / t
if u is not None and vf is not None and t_ is not None and a is None:
a_calc = (vf - u) / t_
results.append(f"a = (v - u)/t = ({vf} - {u})/{t_} = {a_calc:.4g} m/sΒ²")
# t = (v - u) / a (u, vf, a known β€” e.g. thrown upward, max height)
if u is not None and vf is not None and a is not None and t_ is None:
if a != 0:
t_calc = (vf - u) / a
if t_calc >= 0:
results.append(f"t = (v - u)/a = ({vf} - {u})/{a:.4g} = {t_calc:.4g} s")
# s = (vΒ² - uΒ²) / (2a)
s_calc = (vf**2 - u**2) / (2 * a)
results.append(f"s = (vΒ² - uΒ²)/(2a) = ({vf}Β² - {u}Β²)/(2Γ—{a:.4g}) = {s_calc:.4g} m")
if results:
return True, "\n".join(results)
return False, "Not enough information to solve kinematics problem. Provide at least 3 of: u, v, a, s, t."
def _solve_force(v: dict, text: str) -> tuple[bool, str]:
m = v.get('mass')
a = v.get('acceleration')
F = v.get('force')
if m is not None and a is not None:
F_calc = m * a
return True, f"F = ma = {m} Γ— {a} = {F_calc:.4g} N"
if m is not None and F is not None:
a_calc = F / m
return True, f"a = F/m = {F}/{m} = {a_calc:.4g} m/sΒ²"
if F is not None and a is not None:
m_calc = F / a
return True, f"m = F/a = {F}/{a} = {m_calc:.4g} kg"
return False, "Provide any two of: force (N), mass (kg), acceleration (m/sΒ²)."
def _solve_weight(v: dict, text: str) -> tuple[bool, str]:
m = v.get('mass')
if m is not None:
W = m * g
return True, f"W = mg = {m} Γ— {g} = {W:.4g} N"
return False, "Provide mass in kg to calculate weight."
def _solve_kinetic_energy(v: dict, text: str) -> tuple[bool, str]:
m = v.get('mass')
vf = v.get('final_velocity') or v.get('speed') or v.get('initial_velocity')
KE = v.get('kinetic_energy')
if m is not None and vf is not None:
ke = 0.5 * m * vf**2
return True, f"KE = Β½mvΒ² = Β½ Γ— {m} Γ— {vf}Β² = {ke:.4g} J"
if KE is not None and m is not None:
vf_calc = math.sqrt(2 * KE / m)
return True, f"v = √(2KE/m) = √(2Γ—{KE}/{m}) = {vf_calc:.4g} m/s"
if KE is not None and vf is not None:
m_calc = 2 * KE / vf**2
return True, f"m = 2KE/vΒ² = 2Γ—{KE}/{vf}Β² = {m_calc:.4g} kg"
return False, "Provide mass (kg) and velocity (m/s) to calculate kinetic energy."
def _solve_potential_energy(v: dict, text: str) -> tuple[bool, str]:
m = v.get('mass')
hv = v.get('height')
PE = v.get('potential_energy')
if m is not None and hv is not None:
pe = m * g * hv
return True, f"PE = mgh = {m} Γ— {g} Γ— {hv} = {pe:.4g} J"
if PE is not None and m is not None:
h_calc = PE / (m * g)
return True, f"h = PE/(mg) = {PE}/({m}Γ—{g}) = {h_calc:.4g} m"
if PE is not None and hv is not None:
m_calc = PE / (g * hv)
return True, f"m = PE/(gh) = {PE}/({g}Γ—{hv}) = {m_calc:.4g} kg"
return False, "Provide mass (kg) and height (m) to calculate potential energy."
def _solve_work(v: dict, text: str) -> tuple[bool, str]:
F = v.get('force')
d = v.get('displacement')
W = v.get('work')
# Check for angle
theta_m = re.search(r'angle\s+(?:of\s+|=\s*)?([\d.]+)\s*Β°', text.lower())
cos_theta = math.cos(math.radians(float(theta_m.group(1)))) if theta_m else 1.0
if F is not None and d is not None:
w = F * d * cos_theta
angle_str = f"Γ—cos({theta_m.group(1)}Β°)" if theta_m else ""
return True, f"W = Fd{angle_str} = {F} Γ— {d}{angle_str} = {w:.4g} J"
if W is not None and d is not None:
F_calc = W / (d * cos_theta)
return True, f"F = W/d = {W}/{d} = {F_calc:.4g} N"
if W is not None and F is not None:
d_calc = W / (F * cos_theta)
return True, f"d = W/F = {W}/{F} = {d_calc:.4g} m"
return False, "Provide force (N) and distance (m) to calculate work."
def _solve_power(v: dict, text: str) -> tuple[bool, str]:
W = v.get('work')
t_ = v.get('time')
P = v.get('power')
F = v.get('force')
vf = v.get('speed') or v.get('final_velocity') or v.get('initial_velocity')
if W is not None and t_ is not None:
p = W / t_
return True, f"P = W/t = {W}/{t_} = {p:.4g} W"
if F is not None and vf is not None:
p = F * vf
return True, f"P = Fv = {F} Γ— {vf} = {p:.4g} W"
if P is not None and t_ is not None:
W_calc = P * t_
return True, f"W = Pt = {P} Γ— {t_} = {W_calc:.4g} J"
return False, "Provide work (J) and time (s), or force (N) and velocity (m/s) to calculate power."
def _solve_momentum(v: dict, text: str) -> tuple[bool, str]:
m = v.get('mass')
vf = v.get('final_velocity') or v.get('speed') or v.get('initial_velocity')
p = v.get('potential_energy') # reuse field, but check context
F = v.get('force')
t_ = v.get('time')
if m is not None and vf is not None:
mom = m * vf
return True, f"p = mv = {m} Γ— {vf} = {mom:.4g} kgΒ·m/s"
if F is not None and t_ is not None:
imp = F * t_
return True, f"Impulse = FΞ”t = {F} Γ— {t_} = {imp:.4g} NΒ·s (= change in momentum)"
return False, "Provide mass (kg) and velocity (m/s) to calculate momentum."
def _solve_waves(v: dict, text: str) -> tuple[bool, str]:
f = v.get('frequency')
lam = v.get('wavelength')
T = v.get('period')
ws = v.get('wave_speed')
results = []
# wave speed = f Γ— Ξ» (or solve for the missing one)
if f is not None and lam is not None:
wave_speed = f * lam
results.append(f"v = fΞ» = {f:.4g} Hz Γ— {lam:.4g} m = {wave_speed:.4g} m/s")
elif ws is not None and f is not None:
lam_calc = ws / f
results.append(f"Ξ» = v/f = {ws:.4g}/{f:.4g} = {lam_calc:.4g} m")
elif ws is not None and lam is not None:
f_calc = ws / lam
results.append(f"f = v/Ξ» = {ws:.4g}/{lam:.4g} = {f_calc:.4g} Hz")
# period
if f is not None:
T_calc = 1 / f
results.append(f"T = 1/f = 1/{f:.4g} = {T_calc:.4g} s")
elif T is not None:
f_calc = 1 / T
results.append(f"f = 1/T = 1/{T} = {f_calc:.4g} Hz")
if results:
return True, "\n".join(results)
return False, "Provide frequency (Hz) and wavelength (m), or wave speed (m/s) with either, to solve wave problems."
def _solve_photon(v: dict, text: str) -> tuple[bool, str]:
f = v.get('frequency')
lam = v.get('wavelength')
if f is not None:
E = h * f
return True, f"E = hf = {h:.4e} Γ— {f:.4g} = {E:.4e} J ({E/e:.4g} eV)"
if lam is not None:
E = h * c / lam
return True, f"E = hc/Ξ» = ({h:.4e} Γ— {c:.4e})/{lam:.4e} = {E:.4e} J ({E/e:.4g} eV)"
return False, "Provide frequency (Hz) or wavelength (m) to calculate photon energy."
def _solve_ohm(v: dict, text: str) -> tuple[bool, str]:
V_ = v.get('voltage')
I = v.get('current')
R_ = v.get('resistance')
results = []
if V_ is not None and I is not None:
R_calc = V_ / I
results.append(f"R = V/I = {V_}/{I} = {R_calc:.4g} Ξ©")
if V_ is not None and R_ is not None:
I_calc = V_ / R_
results.append(f"I = V/R = {V_}/{R_} = {I_calc:.4g} A")
if I is not None and R_ is not None:
V_calc = I * R_
results.append(f"V = IR = {I} Γ— {R_} = {V_calc:.4g} V")
if results:
return True, "\n".join(results)
return False, "Provide any two of: voltage (V), current (A), resistance (Ξ©) to apply Ohm's law."
def _solve_electric_power(v: dict, text: str) -> tuple[bool, str]:
V_ = v.get('voltage')
I = v.get('current')
R_ = v.get('resistance')
P_ = v.get('power')
results = []
if I is not None and V_ is not None:
p = I * V_
results.append(f"P = IV = {I} Γ— {V_} = {p:.4g} W")
if I is not None and R_ is not None:
p = I**2 * R_
results.append(f"P = IΒ²R = {I}Β² Γ— {R_} = {p:.4g} W")
if V_ is not None and R_ is not None:
p = V_**2 / R_
results.append(f"P = VΒ²/R = {V_}Β²/{R_} = {p:.4g} W")
if results:
return True, "\n".join(results)
return False, "Provide voltage (V) and current (A), or current and resistance (Ξ©), to calculate power."
def _solve_heat(v: dict, text: str) -> tuple[bool, str]:
m = v.get('mass')
c_ = v.get('specific_heat')
dT = v.get('temperature_change')
Q = v.get('heat')
if m is not None and c_ is not None and dT is not None:
q = m * c_ * dT
return True, f"Q = mcΞ”T = {m} Γ— {c_} Γ— {dT} = {q:.4g} J"
if Q is not None and c_ is not None and dT is not None:
m_calc = Q / (c_ * dT)
return True, f"m = Q/(cΞ”T) = {Q}/({c_}Γ—{dT}) = {m_calc:.4g} kg"
if Q is not None and m is not None and dT is not None:
c_calc = Q / (m * dT)
return True, f"c = Q/(mΞ”T) = {Q}/({m}Γ—{dT}) = {c_calc:.4g} J/(kgΒ·K)"
if Q is not None and m is not None and c_ is not None:
dT_calc = Q / (m * c_)
return True, f"Ξ”T = Q/(mc) = {Q}/({m}Γ—{c_}) = {dT_calc:.4g} K"
return False, "Provide mass (kg), specific heat capacity (J/kgΒ·K), and Ξ”T (K) to calculate heat."
def _solve_pressure(v: dict, text: str) -> tuple[bool, str]:
F = v.get('force')
A = v.get('area')
P_ = v.get('pressure')
if F is not None and A is not None:
p = F / A
return True, f"P = F/A = {F}/{A} = {p:.4g} Pa"
if P_ is not None and A is not None:
F_calc = P_ * A
return True, f"F = PΓ—A = {P_} Γ— {A} = {F_calc:.4g} N"
return False, "Provide force (N) and area (mΒ²) to calculate pressure."
def _solve_density(v: dict, text: str) -> tuple[bool, str]:
m = v.get('mass')
vol = v.get('volume')
rho = v.get('density')
if m is not None and vol is not None:
d = m / vol
return True, f"ρ = m/V = {m}/{vol} = {d:.4g} kg/m³"
if rho is not None and vol is not None:
m_calc = rho * vol
return True, f"m = ρV = {rho} Γ— {vol} = {m_calc:.4g} kg"
if rho is not None and m is not None:
v_calc = m / rho
return True, f"V = m/ρ = {m}/{rho} = {v_calc:.4g} m³"
return False, "Provide mass (kg) and volume (mΒ³) to calculate density."
def _solve_fluid_pressure(v: dict, text: str) -> tuple[bool, str]:
rho = v.get('density')
hv = v.get('height') # used as depth
P_ = v.get('pressure')
depth_m = re.search(r'depth\s+(?:of\s+|=\s*)?([\d.]+)\s*m', text.lower())
depth = float(depth_m.group(1)) if depth_m else hv
if rho is not None and depth is not None:
p = rho * g * depth
return True, f"P = ρgh = {rho} Γ— {g} Γ— {depth} = {p:.4g} Pa"
return False, "Provide density (kg/mΒ³) and depth (m) for fluid pressure."
def _solve_snell(v: dict, text: str) -> tuple[bool, str]:
n1 = v.get('refractive_index_1')
n2 = v.get('refractive_index_2')
theta1 = v.get('angle')
# Parse n1=X / n2=X patterns (including "n1=1" style without subscripts)
n1_m = re.search(r'n[_₁1]\s*=\s*([\d.]+)', text, re.I)
n2_m = re.search(r'n[_β‚‚2]\s*=\s*([\d.]+)', text, re.I)
if n1_m:
n1 = float(n1_m.group(1))
if n2_m:
n2 = float(n2_m.group(1))
# Fallback: plain "n = X n = Y" style
if n1 is None or n2 is None:
ri_vals = re.findall(r'(?<![0-9])n\s*=\s*([\d.]+)', text, re.I)
if len(ri_vals) >= 2:
n1, n2 = float(ri_vals[0]), float(ri_vals[1])
elif len(ri_vals) == 1 and n2 is None:
n2 = float(ri_vals[0])
if n1 is None:
n1 = 1.0 # default: air
# Parse angle: "angle1=30", "angle=30", "30Β°"
ang_m = re.search(r'angle[_₁1]?\s*=\s*([\d.]+)', text, re.I) or re.search(r'ΞΈ[_₁1]?\s*=\s*([\d.]+)', text, re.I)
if ang_m:
theta1 = float(ang_m.group(1))
if theta1 is None:
angles = re.findall(r'([\d.]+)\s*Β°', text)
if angles:
theta1 = float(angles[0])
if n1 is not None and n2 is not None and theta1 is not None:
sin_theta2 = n1 * math.sin(math.radians(theta1)) / n2
if abs(sin_theta2) <= 1:
theta2 = math.degrees(math.asin(sin_theta2))
return True, (
f"Snell's law: n₁sin(θ₁) = nβ‚‚sin(ΞΈβ‚‚)\n"
f"{n1} Γ— sin({theta1}Β°) = {n2} Γ— sin(ΞΈβ‚‚)\n"
f"sin(ΞΈβ‚‚) = {sin_theta2:.4f}\n"
f"ΞΈβ‚‚ = {theta2:.4g}Β°"
)
else:
return True, (
f"Total internal reflection! sin(ΞΈβ‚‚) = {sin_theta2:.4f} > 1"
)
return False, "Provide both refractive indices and angle of incidence."
def _solve_lens(v: dict, text: str) -> tuple[bool, str]:
f_ = v.get('focal_length')
u_ = v.get('object_distance')
vi_ = v.get('image_distance')
if f_ is not None and u_ is not None:
inv_v = 1/f_ - 1/u_
if inv_v != 0:
vi_calc = 1 / inv_v
m_val = vi_calc / u_
return True, (
f"Lens formula: 1/f = 1/v + 1/u\n"
f"1/{f_} = 1/v + 1/{u_}\n"
f"1/v = {1/f_:.4g} - {1/u_:.4g} = {inv_v:.4g}\n"
f"v = {vi_calc:.4g} cm\n"
f"Magnification m = v/u = {m_val:.4g}"
)
if f_ is not None and vi_ is not None:
inv_u = 1/f_ - 1/vi_
if inv_u != 0:
u_calc = 1 / inv_u
return True, (
f"u = {u_calc:.4g} cm"
)
return False, "Provide focal length and object distance (or image distance) to solve lens problem."
def _solve_circular(v: dict, text: str) -> tuple[bool, str]:
m = v.get('mass')
r = v.get('radius')
vf = v.get('speed') or v.get('final_velocity') or v.get('initial_velocity')
F = v.get('force')
results = []
if m is not None and vf is not None and r is not None:
Fc = m * vf**2 / r
ac = vf**2 / r
results.append(f"Centripetal force: F = mvΒ²/r = {m}Γ—{vf}Β²/{r} = {Fc:.4g} N")
results.append(f"Centripetal acceleration: a = vΒ²/r = {vf}Β²/{r} = {ac:.4g} m/sΒ²")
if vf is not None and r is not None:
omega = vf / r
T = 2 * math.pi * r / vf
results.append(f"Angular velocity: Ο‰ = v/r = {vf}/{r} = {omega:.4g} rad/s")
results.append(f"Period: T = 2Ο€r/v = {T:.4g} s")
if results:
return True, "\n".join(results)
return False, "Provide mass (kg), speed (m/s), and radius (m) for circular motion."
def _solve_gravitation(v: dict, text: str) -> tuple[bool, str]:
# Extract all numeric values followed by 'kg' as masses
masses = re.findall(r'([\d.e\+\-]+)\s*kg\b', text, re.I)
# Also try m1= / m2= format
m1_m = re.search(r'm[₁1]\s*=\s*([\d.e\+\-]+)', text, re.I)
m2_m = re.search(r'm[β‚‚2]\s*=\s*([\d.e\+\-]+)', text, re.I)
if m1_m and m2_m:
masses = [m1_m.group(1), m2_m.group(1)]
r_m = re.search(r'(?:distance|radius|separation|apart)\s+(?:of\s+|=\s*)?([\d.e\+\-]+)\s*(?:m|km)?', text, re.I)
# Also accept plain "100m" patterns if distance keyword present
if r_m is None:
r_m = re.search(r',\s*([\d.e\+\-]+)\s*m\b', text, re.I)
if len(masses) >= 2 and r_m:
m1, m2 = float(masses[0]), float(masses[1])
r_ = float(r_m.group(1))
# If km β†’ convert
if re.search(r'[\d.]+\s*km', text, re.I):
r_ *= 1000
F_grav = G * m1 * m2 / r_**2
return True, (
f"F = Gm₁mβ‚‚/rΒ² = {G:.4e} Γ— {m1:.4g} Γ— {m2:.4g} / {r_:.4g}Β²"
f" = {F_grav:.4e} N"
)
return False, "Provide two masses (kg) and separation distance (m)."
def _solve_friction(v: dict, text: str) -> tuple[bool, str]:
mu = v.get('friction_coefficient')
m = v.get('mass')
N_ = v.get('force') # sometimes normal force is given directly
# Fallback: parse mu from text if not in vals
if mu is None:
mu_m = re.search(r'(?:ΞΌ|mu|coefficient(?:\s+of\s+friction)?)\s*=?\s*([\d.]+)', text, re.I)
if mu_m:
mu = float(mu_m.group(1))
if mu is not None and m is not None:
N = m * g
Ff = mu * N
return True, (
f"Normal force N = mg = {m}Γ—{g} = {N:.4g} N\n"
f"Friction force f = ΞΌN = {mu}Γ—{N:.4g} = {Ff:.4g} N"
)
if mu is not None and N_ is not None:
Ff = mu * N_
return True, f"Friction force f = ΞΌN = {mu} Γ— {N_} = {Ff:.4g} N"
return False, "Provide coefficient of friction (ΞΌ) and mass (kg) or normal force (N)."
# ─────────────────────────────────────────────────────────────────────────────
# New formula solvers (T001 gap-fill)
# ─────────────────────────────────────────────────────────────────────────────
def _solve_escape_velocity(v: dict, text: str) -> tuple[bool, str]:
t = text.lower()
g_ = _num(t, r'g\s*=\s*([\d.e\-]+)', r'([\d.e\-]+)\s*m/s[Β²2]') or g
R_ = v.get('radius') or _num(t,
r'R\s*=\s*([\d.e\+\-]+)', r'radius\s+(?:of\s+|=\s*)?([\d.e\+\-]+)',
r'([\d.e\+\-]+)\s*m\b',
)
M_ = _num(t, r'M\s*=\s*([\d.e\+\-]+)', r'mass\s+of\s+(?:the\s+)?(?:planet|earth|moon)\s+(?:=\s*)?([\d.e\+\-]+)')
if R_ is not None:
if M_ is not None:
ve = math.sqrt(2 * G * M_ / R_)
return True, (
f"Escape velocity: v_e = √(2GM/R)\n"
f" = √(2 Γ— {G:.4e} Γ— {M_:.4e} / {R_:.4e})\n"
f" = {ve:.4g} m/s ({ve/1000:.4g} km/s)"
)
ve = math.sqrt(2 * g_ * R_)
return True, (
f"Escape velocity: v_e = √(2gR)\n"
f" = √(2 Γ— {g_} Γ— {R_:.4e})\n"
f" = {ve:.4g} m/s ({ve/1000:.4g} km/s)"
)
return False, "Provide planet radius R (m) and optionally mass M (kg) or surface gravity g (m/sΒ²)."
def _solve_spring_energy(v: dict, text: str) -> tuple[bool, str]:
k_ = v.get('spring_constant')
x_ = v.get('spring_compression')
if k_ is not None and x_ is not None:
E = 0.5 * k_ * x_**2
return True, (
f"Elastic PE: E = Β½kxΒ²\n"
f" = Β½ Γ— {k_} Γ— {x_}Β²\n"
f" = {E:.4g} J"
)
if k_ is not None:
return False, f"Spring constant k = {k_} N/m found. Provide compression/extension x (m)."
return False, "Provide spring constant k (N/m) and compression/extension x (m)."
def _solve_de_broglie(v: dict, text: str) -> tuple[bool, str]:
t = text.lower()
mass_ = v.get('mass')
speed_ = v.get('speed') or v.get('initial_velocity') or v.get('final_velocity')
# Determine particle type if mass not given
if mass_ is None:
if re.search(r'\belectron\b', t):
mass_ = m_e
particle = "electron"
elif re.search(r'\bproton\b', t):
mass_ = m_p
particle = "proton"
else:
particle = "particle"
else:
particle = "particle"
if mass_ is not None and speed_ is not None:
lam = h / (mass_ * speed_)
p = mass_ * speed_
return True, (
f"de Broglie wavelength: Ξ» = h/(mv)\n"
f" h = {h:.4e} JΒ·s, m = {mass_:.4e} kg ({particle}), v = {speed_:.4e} m/s\n"
f" p = mv = {p:.4e} kgΒ·m/s\n"
f" Ξ» = {h:.4e} / {p:.4e} = {lam:.4e} m"
)
return False, "Provide particle mass (kg) and velocity (m/s) β€” or name 'electron'/'proton' and velocity."
def _solve_moment_of_inertia(v: dict, text: str) -> tuple[bool, str]:
t = text.lower()
M_ = v.get('mass')
R_ = v.get('radius')
L_ = _num(t, r'length\s+(?:of\s+|=\s*)?([\d.]+)', r'L\s*=\s*([\d.]+)')
results = []
if re.search(r'\bsolid\s+sphere\b', t) and M_ and R_:
I = 0.4 * M_ * R_**2
results.append(f"Solid sphere: I = (2/5)MRΒ² = 0.4 Γ— {M_} Γ— {R_}Β² = {I:.4g} kgΒ·mΒ²")
if re.search(r'\bhollow\s+sphere\b|\bspherical\s+shell\b|\bthin\s+shell\b', t) and M_ and R_:
I = (2/3) * M_ * R_**2
results.append(f"Hollow sphere (thin shell): I = (2/3)MRΒ² = {I:.4g} kgΒ·mΒ²")
if re.search(r'\bsolid\s+(?:cylinder|disc|disk)\b|\bdisc\b|\bdisk\b', t) and M_ and R_:
I = 0.5 * M_ * R_**2
results.append(f"Solid cylinder/disc: I = (1/2)MRΒ² = 0.5 Γ— {M_} Γ— {R_}Β² = {I:.4g} kgΒ·mΒ²")
if re.search(r'\bhollow\s+cylinder\b|\bthin\s+(?:ring|hoop)\b|\bring\b', t) and M_ and R_:
I = M_ * R_**2
results.append(f"Thin ring/hollow cylinder: I = MRΒ² = {M_} Γ— {R_}Β² = {I:.4g} kgΒ·mΒ²")
if re.search(r'\brod\b|\bstick\b|\bbar\b', t) and M_ and L_:
I_cm = (1/12) * M_ * L_**2
I_end = (1/3) * M_ * L_**2
results.append(f"Rod about centre: I = (1/12)MLΒ² = {I_cm:.4g} kgΒ·mΒ²")
results.append(f"Rod about end: I = (1/3)MLΒ² = {I_end:.4g} kgΒ·mΒ²")
# Generic sphere fallback (no solid/hollow keyword)
if not results and M_ and R_:
I_solid = 0.4 * M_ * R_**2
I_hol = (2/3) * M_ * R_**2
results.append(f"Solid sphere: I = (2/5)MRΒ² = {I_solid:.4g} kgΒ·mΒ²")
results.append(f"Hollow sphere: I = (2/3)MRΒ² = {I_hol:.4g} kgΒ·mΒ²")
if results:
return True, "\n".join(results)
return False, "Provide mass M (kg) and radius R (m) [sphere/cylinder] or length L (m) [rod]."
def _solve_coulomb(v: dict, text: str) -> tuple[bool, str]:
t = text.lower()
# Extract two charge values with sign context β€” handle both Β΅C and microC
UC_RE = r'[¡μ]C\b|micro\s*C\b'
plus_vals = re.findall(r'\+([\d.e\-]+)\s*(?:[¡μ]C\b|micro\s*C\b)', text, re.I)
minus_vals = re.findall(r'[-βˆ’]([\d.e\-]+)\s*(?:[¡μ]C\b|micro\s*C\b)', text, re.I)
all_vals = re.findall(r'([\d.e\-]+)\s*(?:[¡μ]C\b|micro\s*C\b)', text, re.I)
# Fall back to signed all_vals to get sign from Β±
if not all_vals:
all_vals = re.findall(r'([+\-]?[\d.e]+)\s*(?:[¡μ]C\b|micro\s*C\b)', text, re.I)
q1 = q2 = None
if len(all_vals) >= 2:
q1 = float(all_vals[0]) * 1e-6
q2 = float(all_vals[1]) * 1e-6
elif v.get('charge1') is not None:
q1 = v['charge1']
q2 = v.get('charge2')
r_ = v.get('separation') or _num(t,
r'([\d.]+)\s*m\s+apart', r'apart\s+(?:by\s+)?([\d.]+)',
r'distance\s+of\s+([\d.]+)\s*m', r'([\d.]+)\s*m\b',
)
if q1 is not None and q2 is not None and r_ is not None:
F = k_e * abs(q1) * abs(q2) / r_**2
nature = "attractive" if (q1 * q2 < 0) else "repulsive"
return True, (
f"Coulomb's Law: F = k|q₁||qβ‚‚|/rΒ²\n"
f" k = {k_e:.4e} NΒ·mΒ²/CΒ²\n"
f" q₁ = {q1:.4e} C, qβ‚‚ = {q2:.4e} C, r = {r_} m\n"
f" F = {k_e:.4e} Γ— {abs(q1):.4e} Γ— {abs(q2):.4e} / {r_}Β²\n"
f" F = {F:.4e} N ({nature})"
)
return False, "Provide two charges (C or Β΅C) and separation distance (m)."
def _solve_gravitational_pe(v: dict, text: str) -> tuple[bool, str]:
t = text.lower()
masses = re.findall(r'([\d.e\+\-]+)\s*kg\b', text, re.I)
m1_m = re.search(r'm[₁1]\s*=\s*([\d.e\+\-]+)', text, re.I)
m2_m = re.search(r'm[β‚‚2]\s*=\s*([\d.e\+\-]+)', text, re.I)
if m1_m and m2_m:
masses = [m1_m.group(1), m2_m.group(1)]
# Handle "two X kg masses" β€” only one kg value but word "two" means both masses = X
if len(masses) == 1 and re.search(r'\btwo\b', t):
masses = [masses[0], masses[0]]
r_ = v.get('separation') or _num(t,
r'([\d.]+)\s*m\s+apart', r'apart\s+by\s+([\d.]+)',
r'distance\s+(?:of\s+|=\s*)?([\d.]+)\s*m', r'at\s+([\d.]+)\s*m\b',
)
if len(masses) >= 2 and r_:
m1, m2 = float(masses[0]), float(masses[1])
U = -G * m1 * m2 / r_
return True, (
f"Gravitational PE: U = βˆ’Gm₁mβ‚‚/r\n"
f" G = {G:.4e} NΒ·mΒ²/kgΒ²\n"
f" U = βˆ’{G:.4e} Γ— {m1} Γ— {m2} / {r_}\n"
f" U = {U:.4e} J"
)
return False, "Provide two masses (kg) and separation distance (m)."
def _solve_diffraction(v: dict, text: str) -> tuple[bool, str]:
t = text.lower()
lines_mm = v.get('lines_per_mm')
order = v.get('diffraction_order', 1)
lam = v.get('wavelength')
angle = v.get('angle')
# Grating spacing d = 1/N (in mm), convert to m
d = (1.0 / lines_mm) * 1e-3 if lines_mm else None
# Wavelength from nm if not found
nm_m = re.search(r'([\d.]+)\s*nm\b', t)
if nm_m and lam is None:
lam = float(nm_m.group(1)) * 1e-9
if d is not None and lam is not None:
sin_val = order * lam / d
if abs(sin_val) <= 1:
theta = math.degrees(math.asin(sin_val))
return True, (
f"Diffraction grating: dΒ·sin ΞΈ = mΞ»\n"
f" d = 1/{lines_mm:.0f} mm = {d:.4e} m (grating spacing)\n"
f" m = {order} (order), Ξ» = {lam:.4e} m\n"
f" sin ΞΈ = mΞ»/d = {order}Γ—{lam:.4e}/{d:.4e} = {sin_val:.4f}\n"
f" ΞΈ = arcsin({sin_val:.4f}) = {theta:.2f}Β°"
)
return False, f"sin ΞΈ = {sin_val:.4f} > 1 β€” this order does not exist for given wavelength and grating."
if d is not None and angle is not None:
lam_calc = d * math.sin(math.radians(angle)) / order
return True, (
f"Diffraction grating: dΒ·sin ΞΈ = mΞ»\n"
f" d = {d:.4e} m, ΞΈ = {angle}Β°, m = {order}\n"
f" Ξ» = dΒ·sin ΞΈ / m = {lam_calc:.4e} m"
)
return False, "Provide grating (lines/mm), wavelength (nm), and order number."
def _solve_projectile(v: dict, text: str) -> tuple[bool, str]:
t = text.lower()
u_ = v.get('initial_velocity') or v.get('speed')
theta_ = v.get('launch_angle')
if u_ is None or theta_ is None:
return False, "Provide initial velocity (m/s) and launch angle (degrees) for projectile motion."
theta_r = math.radians(theta_)
ux = u_ * math.cos(theta_r)
uy = u_ * math.sin(theta_r)
T = 2 * uy / g # time of flight
H = uy**2 / (2 * g) # max height
Rg = u_**2 * math.sin(2 * theta_r) / g # horizontal range
return True, (
f"Projectile Motion: u = {u_} m/s at ΞΈ = {theta_}Β°\n"
f" uβ‚“ = uΒ·cos ΞΈ = {u_}Β·cos({theta_}Β°) = {ux:.4g} m/s\n"
f" u_y = uΒ·sin ΞΈ = {u_}Β·sin({theta_}Β°) = {uy:.4g} m/s\n\n"
f" Time of flight: T = 2u_y/g = 2Γ—{uy:.4g}/{g} = {T:.4g} s\n"
f" Maximum height: H = u_yΒ²/(2g) = {uy:.4g}Β²/(2Γ—{g}) = {H:.4g} m\n"
f" Horizontal range: R = uΒ²Β·sin2ΞΈ/g = {u_}Β²Β·sin({2*theta_}Β°)/{g} = {Rg:.4g} m"
)
def _solve_magnetic_force(v: dict, text: str) -> tuple[bool, str]:
t = text.lower()
q_ = v.get('charge') or _num(t,
r'q\s*=\s*([\d.e\-]+)\s*[¡μ]C',
r'([\d.e\-]+)\s*[¡μ]C\b',
)
if q_ and re.search(r'[¡μ]C', t) and q_ > 1e-3:
q_ *= 1e-6
spd = v.get('speed') or v.get('initial_velocity') or _num(t,
r'v\s*=\s*([\d.e\+\-]+)',
r'moving\s+at\s+([\d.e\+\-]+)',
r'velocity\s+(?:of\s+|=\s*)?([\d.e\+\-]+)',
)
B_ = v.get('magnetic_field')
ang_m = re.search(r'angle\s+(?:of\s+|=\s*)?([\d.]+)\s*Β°', t)
sin_theta = math.sin(math.radians(float(ang_m.group(1)))) if ang_m else 1.0 # perpendicular default
if q_ is not None and spd is not None and B_ is not None:
F = abs(q_) * spd * B_ * sin_theta
angle_str = f"Β·sin({ang_m.group(1)}Β°)" if ang_m else " (perpendicular, sinΞΈ = 1)"
return True, (
f"Magnetic Force: F = qvBΒ·sinΞΈ\n"
f" q = {q_:.4e} C, v = {spd:.4e} m/s, B = {B_:.4e} T{angle_str}\n"
f" F = {abs(q_):.4e} Γ— {spd:.4e} Γ— {B_:.4e}{' Γ— sin(' + ang_m.group(1) + 'Β°)' if ang_m else ''}\n"
f" F = {F:.4e} N"
)
return False, "Provide charge q (C or Β΅C), velocity v (m/s), and magnetic field B (T)."
# ─────────────────────────────────────────────────────────────────────────────
# Public interface
# ─────────────────────────────────────────────────────────────────────────────
_SOLVERS = {
'kinematics': _solve_kinematics,
'projectile': _solve_projectile,
'force': _solve_force,
'weight': _solve_weight,
'kinetic_energy': _solve_kinetic_energy,
'potential_energy': _solve_potential_energy,
'spring_energy': _solve_spring_energy,
'work': _solve_work,
'power': _solve_power,
'momentum': _solve_momentum,
'waves': _solve_waves,
'photon': _solve_photon,
'de_broglie': _solve_de_broglie,
'ohm': _solve_ohm,
'electric_power': _solve_electric_power,
'coulomb': _solve_coulomb,
'magnetic_force': _solve_magnetic_force,
'heat': _solve_heat,
'pressure': _solve_pressure,
'density': _solve_density,
'fluid_pressure': _solve_fluid_pressure,
'snell': _solve_snell,
'lens': _solve_lens,
'circular': _solve_circular,
'gravitation': _solve_gravitation,
'gravitational_pe': _solve_gravitational_pe,
'escape_velocity': _solve_escape_velocity,
'moment_of_inertia': _solve_moment_of_inertia,
'diffraction': _solve_diffraction,
'friction': _solve_friction,
}
class PhysicsEngine:
"""Deterministic physics formula solver."""
CONSTANTS = {
'g': f"{g} m/sΒ² (standard gravity)",
'G': f"{G:.4e} NΒ·mΒ²/kgΒ² (gravitational constant)",
'h': f"{h:.4e} JΒ·s (Planck constant)",
'c': f"{c:.4e} m/s (speed of light)",
'R': f"{R} J/(molΒ·K) (ideal gas constant)",
'e': f"{e:.4e} C (elementary charge)",
'k_B': f"{k_B:.4e} J/K (Boltzmann constant)",
}
def solve(self, text: str) -> tuple[bool, str, str]:
"""
Attempt to solve a physics problem from natural language.
Returns (success, result_string, formula_type).
"""
qtype = _detect_question_type(text)
vals = _extract(text)
solver = _SOLVERS.get(qtype)
if solver is None:
return False, "Could not identify the physics formula to apply.", "unknown"
try:
success, result = solver(vals, text)
return success, result, qtype
except Exception as exc:
return False, f"Calculation error: {exc}", qtype
def is_physics_question(self, text: str) -> bool:
"""Quick check β€” used by the router."""
return _detect_question_type(text) != 'unknown'